
1. 先把上下文模式到底是什么说清楚前几年我接手一个客服机器人项目第一个难缠的bug是AI把用户名字忘了用户很不高兴。我第一反应是调大上下文窗口结果连续调了三次参数问题依旧。后来我才想明白难点根本不在窗口大小而在于哪些内容该被放进窗口——这正是 context-mode 要回答的问题。上下文模式说白了就是一套对话历史管理策略在多轮对话场景里决定每次请求携带哪些历史消息、按什么顺序排列、超长时怎么裁剪或压缩。它看起来只是工程细节实际直接决定了用户体验、接口成本、响应速度和回答一致性。我最早也天真地以为模型上下文窗口越大越好把整个对话一股脑塞进去就行。直到被延迟和费用打脸又在真实用户反馈里反复验证才慢慢把这套逻辑梳理清楚。这篇文章我想完整分享一下自己设计 context-mode 的思路四种常见模式各自的取舍、从零实现一个可切换的上下文管理器、Token预算怎么分、以及上线后踩过的坑。如果你正在做AI客服、智能写作助手或者给内部工具接大模型这部分内容应该能直接派上用场。1.1 context-mode 和记忆是两回事很多人容易把上下文模式和记忆混在一起我在项目评审里就没少纠正这个概念。上下文模式管的是这一轮请求实际发给模型的内容快照而记忆通常指跨会话的用户画像、偏好、历史结论一般存在向量数据库或专门的存储模块里由独立逻辑维护。打个比方上下文模式像是摊开在一个人面前的谈话记录决定了TA此刻能看到多少信息记忆则是这个人脑海里的长期经验不需要每次重新翻资料。两者配合才能让AI既记得住又不超载。只做记忆不做上下文管理检索到的资料可能根本塞不进请求只做上下文不做记忆用户换个会话就要重新自我介绍一遍体验非常割裂。1.2 为什么全塞进去行不通大模型的上下文窗口是有限资源窗口大小决定了单次推理最多能处理多少Token。但这里有个常见的认知误区窗口大不等于可以随意挥霍。窗口越大单次推理延迟越高、费用也越高。更麻烦的是业界公认存在中间丢失现象——模型对上下文开头和结尾的内容注意力更强中间一大段经常被忽略。也就是说即使你的窗口足够装下500轮历史对话把500轮全塞进去模型反而可能漏掉中间最关键的信息。与其靠硬件参数硬扛不如在上层做好取舍把真正重要的内容留在窗口里。这就是 context-mode 存在的根本意义它是一道策略层决定在有限的窗口里哪些信息值得留下、哪些可以丢弃。2. 四种常见上下文模式的设计取舍2.1 零上下文模式每一次都是初见零上下文模式就是每次请求只携带系统提示词和当前用户输入不带任何历史消息。最早我觉得这种模式太傻后来才发现它在两类场景里非常实用一类是单轮、无状态的批处理任务比如给一段文本做分类、抽取摘要历史消息本身没有意义另一类是工具调用链里的子请求比如一次回复里连续调用多个插件插件之间的独立请求并不需要知道彼此的完整对话。零上下文的好处非常直接成本最低、延迟最低、行为完全可复现不存在上下文漂移。坏处也明显它撑不起真正的多轮对话用户刚说完我叫小李下一句问我叫什么AI就答不上来。所以它更适合作为默认模式的降级选项在不需要记忆的场景里兜底而不是全局唯一方案。2.2 全量上下文模式把整段对话原封不动搬进窗口全量模式是把截至当前的所有用户消息和AI回复全部带入请求。它实现起来最简单适合对话轮数少、单轮内容长的场景比如一次长文档阅读理解、几轮以内的深度咨询。但它在真实业务里撑不了太久。一旦对话超过二三十轮Token开销会指数上升。我见过一个真实案例某客服机器人上线初期用全量模式单日成本随平均会话轮数增长直接翻了三倍接口P95延迟从1.2秒涨到3秒以上。用户多聊几轮响应就开始变慢运营团队不得不半夜紧急改配置。这个案例之后我形成了一个习惯新项目上线前先按用户平均对话轮数做一轮成本推演而不是等出了事故才补救。2.3 滑动窗口模式只保留最近N轮滑动窗口是目前最主流的默认选择。逻辑很简单只取最近N轮比如10轮对话历史更早的丢弃。它实现成本低、效果稳定恰好顺应了越近的信息越重要这一对话直觉。但它有个隐蔽问题如果关键信息出现在很早的轮次比如用户在第十轮说过我是会员用户地址在朝阳区到第四十轮时这条信息早被窗口挤掉了AI就会给出与用户身份不符的回复。要缓解这个问题通常需要配合信息提取机制——把关键信息在它出现的那一刻单独抽出来放进一个常驻的事实卡随每次请求一起携带。事实卡机制我在后面预算分配部分会再展开。2.4 摘要混合模式旧的做减法新的保留原样摘要混合模式是我个人最推荐的做法对超过阈值的早期消息定期压缩成一段摘要最近的若干轮始终保留完整原文。这样窗口里既有长期记忆的脉络又有短期对话的细节是兼顾质量与成本的最好平衡点。代价是复杂度。你需要一个专门的摘要任务而且摘要本身也消耗Token触发太频繁反而省钱效果有限。更麻烦的是摘要错误会累积——早期信息一旦被总结错后面所有轮次都会被带偏。所以摘要任务必须用独立提示词还要明确要求不确定的信息宁可删除不要推测同时保留最近几轮不压缩给用户留出纠错空间。为了方便对比我把四种模式的关键差异整理成了一张表模式优点缺点典型场景零上下文成本低、延迟低、可复现无法支撑多轮记忆批处理、单轮分类全量上下文信息完整、实现简单Token激增、延迟高、中间丢失轮数少的长文档分析滑动窗口稳定、成本可控早期关键信息易丢失通用客服、日常对话摘要混合兼顾长期脉络与近期细节实现复杂、摘要错误累积长会话、个性化助手3. 从零实现一个可切换的上下文管理器3.1 先约定配置结构在实际工程里我不会把上下文逻辑散落在业务代码里而是单独做一个 ContextManager对外暴露三个接口追加消息、生成请求消息列表、重置会话。模式切换只需要改配置不用动业务代码这样测试和灰度都方便。配置文件我习惯用数据类组织class ContextMode(Enum): ZERO zero FULL full SLIDING sliding SUMMARY summary dataclass class ContextConfig: mode: ContextMode max_tokens: int 8000 # 上下文整体预算 system_tokens: int 1000 # 系统提示词预留 sliding_window_turns: int 10 # 滑动窗口保留轮数 summary_threshold: int 6 # 超过多少轮触发摘要 summary_target_tokens: int 800 # 摘要目标长度这里的 max_tokens 不是模型窗口上限而是我们主动设的业务预算。我习惯把它设成模型窗口的70%左右剩下30%留给模型输出和安全余量。这个比例是踩过几次坑之后总结出来的留太少模型一旦输出长文本就直接溢出报错留太多历史信息装不进去浪费可用容量。3.2 Token计数要用对tokenizer第二步是Token计数。这里有个非常关键的前提Token数量必须用模型对应的tokenizer去算不能靠字符数估算。中文字符和Token的换算比例并不稳定尤其英文缩写、代码片段混合出现时估算误差会非常大严重的偏差能到35%以上。用 OpenAI 生态的模型时我通常直接依赖 tiktokenimport tiktoken enc tiktoken.get_encoding(cl100k_base) def count_tokens(text: str) - int: return len(enc.encode(text))注意不同模型对应不同编码。如果项目里只接GPT-4这一类模型cl100k_base 够用如果接了更新的模型要按文档换编码。Token计数不一致的后果很直接你以为历史记录只占4000 Token实际已经6000预算分配全部失真裁剪逻辑跟着出错。这个问题我会在第5章再讲一次因为它是上线后最容易翻车的隐藏雷区。3.3 四种模式的生成逻辑ContextManager 的核心方法是 build_request_messages。我把四种模式的逻辑统一放在这一个方法里用配置驱动class ContextManager: def __init__(self, config: ContextConfig): self.config config self.messages [] # 存储历史消息 [{role, content, tokens}] def append(self, role: str, content: str) - None: self.messages.append({ role: role, content: content, tokens: count_tokens(content), }) def build_request_messages(self, user_input: str) - list[dict]: self.append(user, user_input) mode self.config.mode if mode ContextMode.ZERO: return [self.messages[-1]] if mode ContextMode.FULL: history list(self.messages) elif mode ContextMode.SLIDING: n self.config.sliding_window_turns * 2 # 用户助手各N条 history self.messages[-n:] elif mode ContextMode.SUMMARY: history self._build_summary_history() return self._trim_to_budget(history)这里有个容易忽略的细节滑动窗口里的轮数一定要按用户消息和助手消息成对计算否则单边截断会造成角色错位。比如窗口只取最后5条可能截出来连续两条用户消息模型会以为用户在自问自答。代码里乘2就是为了保证成对截取。3.4 摘要模式的实现细节摘要模式里_build_summary_history 分三步先判断哪些消息需要进摘要再调用一次独立的摘要请求最后拼装新的消息列表。def _build_summary_history(self) - list[dict]: threshold self.config.summary_threshold * 2 if len(self.messages) threshold: return list(self.messages) to_summarize self.messages[:-threshold] recent self.messages[-threshold:] raw_text \n.join( f{m[role]}: {m[content]} for m in to_summarize ) summary_prompt ( 请把下面的对话历史压缩成一段中文摘要 保留与用户身份、需求、偏好、关键决策直接相关的信息 不确定的内容宁可删除不要推测 不要添加原文不存在的内容\n raw_text ) summary self._call_summary_model(summary_prompt) return [ {role: system, content: f早期对话摘要{summary}}, *recent, ]摘要内容放在系统提示词位置而不是用户消息里原因是我实测下来模型对系统消息里的信息遵循度更高摘要中的关键事实更容易被后续回复正确引用。不过这里的调用是独立计费的触发太频繁成本反而上去因此阈值要设得保守。我的经验是6到8轮以上才触发一次摘要比较合理低于这个阈值直接用滑动窗口更划算。3.5 预算裁剪宁可少放不可溢出最后的 _trim_to_budget 是所有模式的公共出入口。思路是先算可用预算再从尾部往前保留消息直到预算耗尽。def _trim_to_budget(self, history: list[dict]) - list[dict]: if not history: return [] budget ( self.config.max_tokens - self.config.system_tokens - count_tokens(self.messages[-1][content]) # 当前输入 ) used 0 kept [] for msg in reversed(history[:-1]): if used msg[tokens] budget: break kept.insert(0, msg) used msg[tokens] kept.append(history[-1]) return kept注意裁剪是从尾部往前保留因为离当前提问越近的消息越重要最末尾的当前输入无论如何都要保留。如果预算紧张到连当前输入都放不下那就得在更上层提示用户精简问题而不是继续硬塞。预算裁剪的作用不是牺牲质量而是保证每一次请求都不会因为溢出而彻底失败——这是可用性的底线。4. Token预算怎么分配才不容易翻车4.1 三方博弈系统提示词、历史记录、当前输入预算分配的本质是三方博弈。系统提示词定义人设和规则历史记录提供背景当前输入提出具体问题。任何一方被过度挤压回复质量都会明显下滑。我给它们的优先级排序是当前输入大于系统提示词系统提示词大于历史记录。原因很直接当前输入是本次请求的目标缺失会导致答非所问系统提示词是人设底线缺失会导致风格跑偏历史记录只是背景信息丢了顶多显得健忘。落实到数字上假设模型窗口是12800 Token我会这样分配业务预算设成9000 Token其中系统提示词占1000当前输入预留1200剩下的6800给历史记录。这个结构的好处是历史记录还有接近一半的容量可以呼吸不容易频繁触发裁剪同时又给模型输出留足了空间。4.2 位置权重把最重要的信息放到窗口两端前面提到的中间丢失现象在预算分配时一定要考虑进去。模型的注意力天然偏向开头和结尾所以那些必须生效的约束比如用户会员等级、发货地址、禁止事项应该尽量放在系统提示词或者离当前输入最近的位置而不是埋在一长串历史消息正中。一个具体做法是把抽取出的事实卡始终紧跟系统提示词不作为普通历史消息存储。这样每次请求里它都在窗口靠前的位置模型引用它的概率明显提高。我在实验里对比过同一批会话同样一条用户地址在朝阳区的信息放在窗口首位时后续五轮内正确引用率超过九成放在历史消息中段时正确引用率掉到六成左右。位置的影响就是这么直接。4.3 实测数据一个客服项目的量化收益我在一个客服机器人项目里做过对比实验同样5000条真实会话分别跑全量模式和摘要混合模式结果非常直观指标全量上下文摘要混合平均Token消耗/请求61002300接口P95延迟2.8s1.4s关键信息召回率人工抽检82%89%单日总成本基准值约38%成本下降了六成以上延迟减半关键信息召回率反而提升。原因不难理解全量模式里大量无关寒暄挤占了模型注意力摘要模式过滤了噪音模型反而更容易抓住重点。这次对比也让我对信息多等于质量好这个直觉产生了怀疑——很多时候少而准比多而杂更有效。5. 上线后最容易踩的四个坑5.1 tokenizer版本不一致导致的预算失控第一个坑是tokenizer用错。我在另一个项目里吃过亏开发环境一切正常上线后发现相同的历史记录在线上被截断得厉害。排查了半天发现是团队里有人图省事给历史消息用了另一种编码估算Token和模型实际tokenizer偏差超过35%。中文场景下按字符估算会高估按另一种编码估算又会严重低估预算控制完全失灵。从那以后我定了一个规则Token计数必须和实际调用模型绑定封装成同一个工具函数禁止手写估算逻辑。换模型时第一件事就是换tokenizer并且要跑一个固定的回归用例验证计数准确性。这类问题往往不会报错只会让用户感觉AI记忆力变差了特别难定位。5.2 摘要错误累积导致的角色信息污染摘要模式的坑在于错误会累积。有一次用户在对话早期说过不需要宠物推荐摘要任务把这句话概括成了用户关注宠物相关推荐后面对话里AI反复推送宠物用品用户直接投诉。根源就是摘要任务没有做好信息边界控制。这个案例让我意识到摘要提示词必须加上一条硬性约束不确定的信息宁可删除不要推测。同时摘要只覆盖真正早期的消息最近几轮永远保留原文让模型有依据原文纠正摘要错误的机会。条件允许的话还可以把摘要内容展示在界面上用户发现不对能立刻纠正这也是个很好的产品化手段。5.3 工具调用场景里的上下文污染现在很多AI应用会调用外部工具工具返回的结果会被拼进上下文。这里的坑是工具返回内容通常又长又结构化动辄一千多Token几轮工具调用下来历史里塞满了中间结果真正的用户意图反而被挤出去了。我的处理办法是进入下一轮对话前把上一轮的工具返回结果替换成一句话摘要比如天气接口返回多云20度而不是保留完整的JSON。既保留了决策所需信息又不会再浪费大量预算。如果场景本身需要完整结果参与推理那就把它设成较高的裁剪优先级让工具结果先于普通寒暄被丢出窗口而不是一视同仁按时间顺序砍。5.4 并发场景下的会话隔离最后一个坑和并发有关。ContextManager 如果做成全局共享实例多用户同时对话时历史消息就会互相串。早期我在内部演示系统里踩过两个用户同时测试一个人的对话历史跑到了另一个人窗口里场面相当尴尬还涉及数据隐私问题。解决办法是给每个会话单独实例化一个 ContextManager用 session_id 做隔离并加上过期清理策略。存储上如果量不大内存字典加TTL就够用户量大就要落到外部存储每次请求时从存储重建上下文。别小看这个设计会话隔离一旦出错不只是功能bug还可能变成安全事故。6. 这套方案还能怎么扩展如果产品形态不是传统客服聊天而是写作助手或者编程助手上下文模式还需要做点定制。写作场景里历史记录往往是几篇参考文档而不是对话我会把文档拆成块并按相关度排序放进程上下文的靠前位置保证模型先看到最相关的素材。编程场景则更依赖工具返回的代码片段和仓库结构摘要模式的重点要放在文件级别的结论上比如某模块负责登录已经重构为Python而不是把整段源码塞进历史。不管扩展到什么场景核心逻辑始终没变在有限窗口里保住最重要的信息让每一次请求都物有所值。context-mode 不是某个固定算法而是一套需要持续调优的策略组合。我自己的习惯是每次版本迭代都保留一份上下文模式的快照用来对比改动前后的质量变化。这个习惯帮我避免了好几次改坏了还不知道改坏了哪里的窘境。如果你正在设计自己的上下文策略建议也从最小的滑动窗口开始跑用真实会话数据观察再逐步升级到摘要混合模式——每一步都留下数据别凭感觉做决定。