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 生态的模型时,我通常直接依赖 tiktoken:
import 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消耗/请求 | 6100 | 2300 |
| 接口P95延迟 | 2.8s | 1.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 不是某个固定算法,而是一套需要持续调优的策略组合。我自己的习惯是,每次版本迭代都保留一份上下文模式的快照,用来对比改动前后的质量变化。这个习惯帮我避免了好几次"改坏了还不知道改坏了哪里"的窘境。如果你正在设计自己的上下文策略,建议也从最小的滑动窗口开始跑,用真实会话数据观察,再逐步升级到摘要混合模式——每一步都留下数据,别凭感觉做决定。