
先说一个我前阵子差点通宵的事一个跑得顺顺当当的AI客服Demo对话到第二十轮模型突然像失忆一样把用户最开始提的“预算1500以内要白色降噪耳机”忘得干干净净开始推荐上万的旗舰款。我的第一反应是换更大的模型第二反应是骂模型拉胯最后查了实际发给API的原文才发现问题出在context-mode——我根本没管上下文只是把历史消息一股脑全塞进去可厂商对上下文长度是有限制的超出部分要么直接报错要么用更隐蔽的方式丢弃最老的内容模型自然看不见被丢掉的那条早期需求。context-mode翻译过来是“上下文模式”听起来像是模型内部机制实际上它就是应用层的一套规则每次调用模型时该带哪些历史信息、带多少、以原文还是摘要的形式带以及Token预算怎么分配。大模型本身没有记忆它只对你本次请求里给它的文字负责所以你给它什么它就看到什么就这么简单。最近圈子里聊context-mode的人突然变多我觉得不是炒概念而是大家把大模型接进真实业务后绕不过这个坎了。这篇我打算把它的典型设计、可复现代码、实测翻车案例和进阶架构一次说透正在做AI应用开发、Agent、客服机器人以及刚开始碰LLM API还没被上下文撑爆过的同学应该都能用得上。1. 为什么context-mode成了大模型应用的隐性瓶颈1.1 大模型没有记忆每次调用都要“重新自我介绍”我见过太多人第一次接大模型API时的自信把聊天记录循环拼起来append到messages数组里完事。这个做法在前几次调用里完全没问题因为对话短Token少模型表现很正常。可一旦对话变长麻烦就来了。你要先理解一个前提大模型接口的调用是无状态的服务器不会帮你记住“上次聊到哪了”每一次请求都要把整个聊天记录原封不动送过去模型再根据这些内容重新理解“现在是什么情况、用户要什么、我该怎么答”。这就像你每次找同一个同事对接都得把他认识你的所有背景重新讲一遍。如果只讲最近几句话他可能忘了你上一周交代的项目目标如果每次都从三年前讲起还没开口时间就耗完了。所以“带哪些历史”不是可有可无的优化项而是决定对话质量和成本的核心问题。厂商对单次请求的上下文长度有限制按Token计费多带一条消息就多付一笔钱还会拖慢首字响应时间。当你的应用开始面向真实用户会话动辄几十上百轮时不做上下文管理的接口调用基本等于烧钱现场。1.2 不做上下文管理三个典型场景的翻车实录拿我自己做过的几个项目说。第一个是电商客服机器人用户一上来问“帮我找一款降噪耳机预算1500以内最好是白色”客服正常推荐了结果用户又问了七八轮关于售后、运费、佩戴舒适度的问题等话题绕回推荐耳机时模型完全忘了预算和颜色开始推两千多的旗舰款。查日志发现上下文里确实还有那条预算消息——但因为后面几轮用户一直在问售后模型“注意力”被大量无关问答稀释了。这是不做上下文管理的第一类问题信息还在但被淹没。第二个是内容创作助手给新媒体小编用的有一整套风格设定写死在System Prompt里比如“语言要口语化不用生僻词”“段落要短”。前几轮正常后来用户开始一边聊天一边改稿模型逐渐把风格设定抛到脑后输出越来越书面化。原因是用户后续的对话里包含了大量书面表达示例把系统设定的权重冲淡了。第三类更隐蔽做代码生成工具时用户在前面交代“项目用React18TypeScriptVite搭的”二十轮之后模型推荐了一个Vue的包——不是它不知道而是那条最初的背景消息早就被滑动窗口挤出去了。这三个案例分别对应了context-mode要解决的三个子问题是否保留关键信息、系统设定如何不被冲淡、旧消息如何进出窗口。很多人以为是模型不够聪明其实绝大多数情况是“你没把它需要的信息送到它眼前”。1.3 context-mode到底管什么三个维度一次说清把“上下文模式”拆开看其实就管三件事。第一范围哪些消息该进上下文。是所有历史、最近N轮还是从向量库里检索出来的一段相关内容。范围决定信息完整性。第二形态带进去的内容是原文、裁剪后的文本还是大模型二次生成的摘要。形态决定同样的Token能承载多少有效信息。第三预算总Token怎么分。模型窗口是有限的要给系统提示词、输出预留、当前用户输入各留多少历史上限就是多大。预算决定前两个维度的天花板。这三个维度不是独立的。范围大了形态就必须更压缩否则预算撑不住形态压缩得狠范围就得靠检索弥补否则关键信息会丢。后面所有的方案、代码、排错本质上都是在三角之间找平衡。理解了这一点你再看市面上各种“记忆模块”“上下文管理器”就不会觉得玄了。2. 三种基础上下文模式的设计取舍短会话、滑动窗口与摘要压缩2.1 短会话模式单轮工具型交互短会话模式是三种方案里最朴素的一种每次调用只带System Prompt加当前用户输入最多再带上一轮。它的思路是“每次请求都是独立的模型不需要知道之前聊了什么”。适合翻译、改写、分类、摘要、信息抽取这类工具型调用以及一些简单的函数调用场景。实现上几乎零成本messages数组就两项不需要任何状态管理。优点是速度快、费用低、上下文不会被污染模型不会胡联想。缺点也明显只要任务依赖多轮信息就完全不可用。比如你做一个购物推荐助手用户说“再推荐一款跑鞋”没有上文模型根本不知道“再”是指什么。我用短会话模式做最多的场景是批量数据处理比如把一整篇会议纪要在多个线程里并行调用API每段独立做摘要最后再合并。这种任务天然没有依赖单轮模式反而最优。如果你做的是纯工具型产品别纠结直接选短会话模式少写一堆代码。2.2 滑动窗口模式最容易实现但暗坑最多的方案滑动窗口模式是大多数人从短会话往多轮迈出的第一步维护一个最近N轮对话的列表新的进来老的出去。它假设“越近的对话越重要太老的信息不值得带”。实现很简单Python里一个deque就能搞定from collections import deque history deque(maxlen10) # 只保留最近10轮 history.append({role: user, content: 我想找降噪耳机}) history.append({role: assistant, content: 预算范围是}) history.append({role: user, content: 1500以内白色})这个方案最典型的暗坑是它不是按Token算而是按“轮数”算。用户一轮可能只说一句话也可能贴了整整一篇文档deque的maxlen是10结果实际塞进上下文的Token可能差十倍。一旦某条超长消息进队剩下的9轮对话可能凑不满一个短对话的预算Token却已经爆了。我建议至少改成“按Token估算”来截断别只按轮数。比如用一个固定容量的buffer每条消息按估算Token累加超了就从最老的开始弹。滑动窗口模式适合闲聊类、客服类的产品——这类场景用户通常只关心最近几轮早期诉求可以接受被遗忘。但如果你做的是需要长期保持某种约束的产品光靠滑动窗口一定会翻车。2.3 摘要压缩模式用Token换记忆的典型做法摘要压缩模式的思路是既然老消息不能全带那就让大模型把老消息提炼成摘要把“几万Token的对话”压缩成“几百Token的要点”然后作为System Prompt的一部分继续参与后续对话。实现流程一般是设定一个阈值比如上下文占用超过总预算的70%时触发压缩。调用一次模型输入最近的消息和老消息输出一份结构化摘要保留用户核心诉求、已确认信息、待办事项等。然后把摘要放进System Prompt清空部分历史。def compress_history(messages): prompt 请将以下对话压缩为结构化摘要保留用户的意图、关键需求、已确认信息和未完成事项不超过500字。\n\n format_messages(messages) summary call_llm(prompt) return summary它的优点是能显著延长会话轮数同时对模型友好——摘要里全是高信息密度的内容比原始消息更容易被模型利用。缺点是压缩会丢信息尤其是容易被模型认为“不重要”但实际上后面会用到的小细节。还有一个隐藏风险摘要本身是有损的摘要再摘要会放大损失也就是后面要讲的“摘要套娃”问题。这三种模式没有绝对优劣完全看业务场景。我做了个对比表方便选型模式实现成本多轮能力Token消耗典型场景短会话极低无最低翻译、摘要、分类、抽取滑动窗口低中较低闲聊、客服、问答摘要压缩中较强中加摘要费用长会话、Agent、咨询顾问3. 手把手落地一个context-mode管理模块3.1 先从预算说起4096的窗口能装下几轮对话开始写代码前先解决最核心的问题预算怎么定。假设你用的是某厂的对话模型上下文窗口是4096 Token。不要以为4096全都能给历史——一份完整的Prompt里至少包含四部分System Prompt、历史消息、用户当前输入、模型要生成的输出。输出必须预留不然模型生成到一半被截断你收到一段烂尾文字当前输入是用户刚说的话必须完整收下System Prompt承载你的业务指令不能砍。以我的习惯4096的窗口这样分输出预留1024System Prompt约400当前用户输入预留到512剩下的大概2000 Token给历史。再保守一点历史最多用到总窗口的60%~70%就触发清理给突发长消息留缓冲。这个比例不是拍脑袋实测下来如果历史塞到90%以上稍有波动就会触发截断而截断是最恶性的错误——它不报错只安静地砍掉你上下文开头或结尾的部分导致模型继续答非所问。不同模型、不同场景比例会变。代码生成类模型输出经常很长输出预留要加大Agent场景里工具返回内容很长历史预算要留更多空间。总的原则是先把“绝对不能省”的部分预留出来剩下的才是历史能用的空间然后留出20%以上缓冲。这张预算表建议直接写死在配置里别每次运行时临时算。3.2 核心代码一个可运行的ContextManager下面这份代码是我在实际项目里的简化版核心思路刚才已经说过按Token估算容量而不是按条数超限时优先丢弃最老的消息达到压缩阈值时启动摘要压缩。为了能直接跑起来我把它整理成了一个最小可用的类先看完整实现再逐段解释关键设计。import time # 粗略估算中文约0.7 token/字英文约0.25 token/字符混合场景按2.2字符/Token算 def estimate_tokens(text: str) - int: return int(len(text) / 2.2) 1 class Message: def __init__(self, role: str, content: str, ts: float None): self.role role self.content content self.ts ts or time.time() self.tokens estimate_tokens(content) class ContextManager: def __init__(self, system_prompt: str, total_budget: int 4096, reserve_output: int 1024, compress_ratio: float 0.7): self.system_prompt Message(system, system_prompt) self.messages [] self.total_budget total_budget self.reserve_output reserve_output self.compress_ratio compress_ratio self.summary def current_usage(self) - int: system_tokens self.system_prompt.tokens len(self.summary) return system_tokens sum(m.tokens for m in self.messages) def add_message(self, role: str, content: str): self.messages.append(Message(role, content)) self._maybe_clean() def _maybe_clean(self): history_budget int(self.total_budget * self.compress_ratio) while self.current_usage() history_budget and len(self.messages) 4: removed self.messages.pop(0) print(f[ContextManager] drop oldest: {removed.role}:{removed.content[:30]}...) if self.current_usage() history_budget: self._compress() def _compress(self): keep_count 4 old_messages self.messages[:-keep_count] if not old_messages: return to_summarize format_messages(old_messages) summary_text call_llm(把下面的对话内容压缩成要点摘要保留用户的意图、需求与结论\n to_summarize) self.summary 【历史摘要】 summary_text self.messages self.messages[-keep_count:] print(f[ContextManager] compressed, summary tokens{estimate_tokens(summary_text)}) def build_payload(self): payload [{role: system, content: self.system_prompt.content self.summary}] payload [{role: m.role, content: m.content} for m in self.messages] return payload def format_messages(msgs): return \n.join(f{m.role}: {m.content} for m in msgs)这段代码有几个设计点值得说。第一Message统一加了ts时间戳方便以后做时间衰减或者按时间排序的清理策略。第二压缩时保留最近4轮不摘要是因为摘要会丢失语序和细节而最近几轮往往包含用户正在追问的内容直接保留原文更稳。第三摘要放在System Prompt里而非作为普通历史消息是为了让它更靠近系统指令模型对它的“重视程度”会高一些也能避免它被后续滑动窗口误删。实际部署时estimate_tokens建议换成真正的tokenizer比如tiktoken或各厂商的SDK自带方法否则混合中英文误差会很大导致压缩时机偏差。这套代码的压缩率、保留轮数都是可配置的不同业务设不同值别只抄一份参数走天下。3.3 压缩触发时机与参数调优代码里的compress_ratio0.7是“历史占用达到总预算70%时开始清理”。这个值我试过不少场景0.5太激进会话没聊几句就开始压缩摘要频繁刷新费用上去了信息反而丢得厉害0.85太保守触发后刚压缩完几轮又是满的而且响应时Token已经偏高首字延迟明显。0.65~0.75是个比较稳的区间具体看你消息的平均长度波动。另外两个参数也值得调。一个是压缩时保留最近几轮保留2轮摘要更频繁但更省Token保留6轮摘要频率低但单次摘要的时间跨度大摘要质量和成本会上升。我一般取4。另一个是摘要的篇幅限制摘要越长信息越全但会挤占后续历史空间我通常把摘要目标控制在300~500字并且要求模型按“用户诉求/已确认信息/未决事项/备注”四段输出。结构化摘要比自由文本摘要稳定得多这是我压测后最明显的经验。如果你把这套代码接到真实业务里建议最开始把几个关键参数打日志每次请求的历史Token数、摘要触发次数、平均丢掉了多少早期消息。有了这些数据你才知道当前模式是不是真的可持续。别看模型输出正常就觉得没问题很多时候问题要到业务峰值时才显现。4. 实测中的三个翻车现场与完整排查链路4.1 翻车一第20轮模型“失忆”问题出在窗口把最初需求挤掉了开头那个客服机器人的例子这里把排查过程完整展开。现象是对话进行到第二十轮模型忘记用户最初的“预算1500元白色耳机”需求开始推荐其他价位产品。当时同事的第一反应是换更强的模型我坚持先抓现场——把打给API的完整payload打了一份日志检查后发现问题所在发送给模型的消息列表其实只剩最近8轮最早的第1轮用户消息早在对话推进到十几轮时就被deque弹出了模型压根没见过预算那条消息。也就是说模型没“忘记”它从未看到这条消息。这是滑动窗口方案最典型的翻车模式按轮数截断只保留最近N轮早期关键约束被清出上下文。修复方案有两层短期是把窗口轮数加大或者改成按Token截断根本解法是把关键约束抽取出来单独放进System Prompt或者走第5章的向量记忆。这次排查的结论是凡是要长期保持“用户首次表达的需求”的场景都不能只靠滑动窗口保留原文必须有一个不被滚动淘汰的“固定层”。4.2 翻车二摘要压缩之后模型开始不守规矩第二个坑是我自己设计的摘要模块踩的。接入摘要压缩后对话轮数明显变长但有一次测试发现模型突然不遵守“每次回答最后都要反问一句问题是否解决”这个制度性要求。我一度怀疑是System Prompt被污染后来打开payload看发现系统里其实塞了两段内容System Prompt加历史摘要。而历史摘要是压缩时让模型生成的当时对话里恰好有一段用户问“你能不能不要每次都问一句是否解决很烦”压缩器把这个写进了摘要放在了System Prompt的位置。大模型对System Prompt的优先级默认比普通对话高于是“不要每次都问”变成了系统级指令反而覆盖了我的硬性要求。这个过程让我意识到摘要不能无脑塞进System Prompt摘要内容必须被当作“对话历史的一部分”看待不能让它携带与系统指令冲突的信息。修复时我在摘要提示词里加了一行“忽略对话中用户关于交互形式、话术风格的抱怨这些不是有效需求”再测试就正常了。4.3 排查方法论让每次请求“可回放”两个例子下来我强烈建议你在还没出问题时就把排查基础设施搭好。核心就一句话每次调用API的payload必须能回放。也就是说把每次实际发送的messages数组完整记录下来连同当时的Token统计、触发的压缩动作、丢弃了哪些消息一起落日志或者存进数据库。真出了问题直接翻出那一次的payload看模型到底看到了什么一两分钟就能定位省得靠猜。具体做法给每次请求生成一个request_idContextManager的add、drop、compress操作都在log里打点把build_payload返回的内容体序列化成JSON落到日志文件如果用了摘要把压缩前后的消息、生成的摘要文本都留着。排查时先看丢没丢消息再看摘要有没有异变最后看System Prompt和摘要有没有冲突。这一套组合拳能覆盖我见过的九成上下文类问题。别嫌麻烦比起猜来猜去换模型、调温度参数日志回放的排查成本是最低的。5. 进阶方案短期上下文长期向量记忆的双轨架构5.1 纯摘要模式的极限摘要套娃带来的信息衰减很多人把摘要压缩当成终点但我实测下来纯摘要模式撑不过一百轮。不是Token不够而是信息衰减。第一次压缩原始对话变成500字摘要信息损失一些等到摘要也旧了再拿“摘要后续原文”继续压缩损失再叠加。三轮之后用户早期说的“预算1500不要黑色办公场景为主”这类细节很可能被压缩成“用户对耳机有偏好”几乎失去约束力。我曾经让一个测试会话跑到150轮然后把最初的原始消息和新摘要放一起对比结果用户的核心约束只剩“降噪耳机”四个字颜色、预算、用途全部消失。这就是摘要套娃效应每一次压缩都在丢失尾部细节而恰恰是这些细节往往在几十轮后还能派上用场。另一个问题是新旧信息的矛盾用户第2轮说“预算1500”第40轮说“算了预算提到3000”如果两段对话一起被压缩摘要里可能出现两条冲突的预算模型不知道听谁的。纯摘要方案对处理“信息变更”非常笨拙。5.2 双轨架构的具体落地纯摘要加滑动窗口的局限让我把架构改成了双轨短期轨继续用第3章的ContextManager管最近几轮原文长期轨把重要历史消息全部写入向量数据库每次请求前检索TopK相关内容注入。短期轨没什么变化核心是长期轨的设计。写入时机每轮对话结束后把当前用户消息、助手回复、抽取出的关键事实比如“用户预算1500元”都写入向量库同时给每条记录打上“会话ID时间戳”。检索时机用户发来新一轮消息先用Embedding模型把当前消息向量化在向量库里查TopK相似历史K一般取3~5。检索到的历史记录和短期轨消息一起组装进payload。关键点写入的不是原始对话的逐字记录而是建议做一点结构化。拿预算举例不用存“那我想想1500以内吧”可以存成“用户预算1500元以内”。检索效果会好很多因为向量相似度对口语化短句很敏感相同的意思不同说法可能匹配不到。我一般会跑一个轻量抽取提示词把每轮对话提炼成几条“事实记录”再入库。组装payload时的顺序也很关键System Prompt带长期记忆块中间是短期轨里最近几轮原文最后是当前输入。这个顺序是我对比过不同放法之后固定的——长期记忆放在System Prompt里模型回答全局性问题时会优先参考它短期轨放中间回答细节问题时按对话顺序自然衔接当前输入最后模型会把它当作最新需求优先响应。如果顺序颠倒比如把当前输入放在历史中间模型容易把它当成普通历史而不是待回答内容回复质量会明显下降。5.3 检索注入的格式与几个容易踩的坑检索到历史之后注入格式也要讲究。我的做法是把TopK历史组织成一段“相关历史记录”块放在System Prompt和短期历史之间【相关历史记录】 - 2024-05-11 会话#34: 用户明确预算不超过1500元需要白色降噪耳机 - 2024-05-11 会话#34: 用户已确认某款耳机的音质但认为价格偏高注意别把原始几万字的检索片段直接糊上去那样既浪费Token又给模型大量噪音。检索结果里的每一条最好都是“一句能看懂的小结论”而不是“一段对话的截图式粘贴”。踩过的坑有三个。第一检索结果过多反而坏事TopK越大无关内容越多模型容易被一条几百轮之前的闲聊带跑K等于3或4足够宁缺毋滥。第二检索相似度不等于需求相关用户问“售后”检索到的历史可能全是售后相关对话但它们只是相似文本未必包含当前需要的决策信息所以还要对检索结果按“事实密度”做一次排序或者直接让LLM判断哪些历史真正有用。第三时间冲突向量库里可能有用户第2轮“预算1500”和第40轮“预算3000”两条记录同时注入会冲突。我的处理是注入前先按时间排序优先保留最新的记录同一主题只注入时间最近的一两条。这套双轨架构跑下来会话轮数不再是瓶颈两百轮以上的对话依然能稳定记住用户的早期约束。代价是要维护一个向量库、要写抽取和检索的代码、每次请求至少多两次外部调用但换来的是上下文从“滚动遗忘”变成“结构化记忆”对做Agent、咨询顾问、复杂任务类产品的人来说这笔投入非常值。最后分享一个我在代码之外的体会。context-mode的设计表面上是个技术问题深一层其实是产品取舍问题你到底希望模型记住什么、忘掉什么决定了你选择哪种模式。没有一个万能配置能同时保证零遗忘、零噪音、零成本你必须替用户做选择——哪些信息重要到即使占预算也要守住哪些噪音大到宁可遗忘也要过滤。想清楚这一层再回头看代码里的阈值、轮数、摘要格式每一行都有了意义。我自己的考核标准是让一个完全没参与该项目的新人只看发给API的payload就能复述出这个用户最关键的需求。做到了这一点context-mode才算真的过关。