1. 从“context-mode”这个词说起:它到底指什么
第一次看到“context-mode”这个标题,很多人会愣一下——它不像“XX管理系统”“XX爬虫框架”那样一眼能看出用途,反而像某个库里的一个枚举值、一个配置项,或者某个编辑器里的一个状态开关。我最初接触这个词,是在做对话式应用的时候,当时团队里有人提了一句“把上下文切成 context-mode 再跑”,我才意识到,这其实是一个被严重低估的概念:它描述的是“系统在某一时刻如何理解和使用上下文”这件事本身。
换句话说,context-mode 不是一个具体的产品,而是一种上下文处理模式的抽象。任何需要“记住前面发生了什么、并据此决定下一步做什么”的系统,都会面临一个选择:当前这一轮交互,到底该带多少历史、带哪些历史、以什么优先级去用这些历史。这个选择的结果,就是 context-mode。它可能表现为一个配置项(比如mode: "summary"),也可能表现为一段代码里的分支逻辑(if 上下文长度 > 阈值 则压缩),甚至可能表现为一个隐式的默认行为(比如永远只带最近 5 轮)。
为什么这个词值得单独拿出来讲?因为绝大多数人在做这类系统时,注意力都放在“模型选哪个”“提示词怎么写”上,而 context-mode 这种“上下文怎么组织”的问题,往往被当成一个次要的实现细节。但实际跑起来你会发现,同样一个模型、同样一段提示词,context-mode 选错了,效果能差出一大截。我见过一个客服问答场景,改成按“问题类型”切换上下文模式之后,重复追问率直接降了四成。这不是模型的功劳,是上下文组织方式的功劳。
这篇文章适合谁看?如果你正在做对话系统、智能助手、多轮问答、Agent 编排,或者任何需要“带着历史往前走”的功能,那 context-mode 就是你绕不开的一个设计点。哪怕你只是用现成的 API 做一个简单的聊天窗口,理解这个概念也能帮你少踩很多“为什么它突然忘了前面说的话”的坑。下面我会从它解决的问题、几种典型模式、怎么选、怎么落地、怎么排错这几个角度,把它拆开讲透。
2. 上下文模式要解决的核心矛盾:信息量与干扰度的拉锯
2.1 为什么“全带上”和“全不带”都不行
最朴素的做法有两种。第一种是“全带上”:把从第一轮开始的所有对话原封不动塞进上下文。第二种是“全不带”:每一轮都当成全新问题,只带当前这一句。这两种做法在真实场景里都会出问题,而且出问题的方向刚好相反。
“全带上”的问题在于干扰度随轮次线性上升。假设每轮对话平均 100 字,到第 30 轮就是 3000 字。这 3000 字里,可能只有最近两三句和当前问题相关,前面二十多句全是噪音。模型在生成时,注意力会被这些噪音稀释,表现为:答非所问、把很早之前的一个无关条件又翻出来、或者干脆被前面某句话带偏。更现实的问题是成本——上下文越长,token 消耗越大,延迟越高,这在按量计费的场景里是实打实的开销。
“全不带”的问题则是丢失指代和约束。用户说“那它呢”“再便宜点的有吗”“刚才说的那个方案改一下”,这些句子单独看是无法理解的,必须依赖前文。全不带就等于每轮都失忆,用户得反复重复背景,体验极差。而且很多约束是跨轮次生效的,比如用户第一轮说了“我只要现货”,后面所有推荐都得遵守这个约束,全不带就丢掉了。
所以 context-mode 要解决的核心矛盾,就是在这两者之间找一个平衡点:在保留必要信息的同时,把干扰和成本压到可接受范围。这个平衡点不是固定的,它取决于场景——客服场景可能只需要最近 3 轮加一个摘要,而代码助手场景可能需要保留完整的文件上下文。
2.2 一个容易被忽略的维度:上下文的“角色”
很多人讨论上下文时只关注“长度”,但 context-mode 里还有一个同样重要的维度是角色归属。同样一段历史,它可能是“用户说的”“助手说的”“系统注入的”“工具返回的”,这几种角色在后续轮次里的权重应该是不一样的。
举个例子,用户在前几轮随口说了一句“我随便看看”,这是一句低权重的闲聊;但如果系统注入了一条“当前用户是 VIP,优先推荐高价商品”,这就是一条高权重的约束。如果 context-mode 不区分角色,把两者一视同仁地塞进去,模型很可能被那句闲聊带偏,忽略了 VIP 约束。我在实际项目里就遇到过这种情况:助手把用户的一句玩笑话当成了真实需求,后面几轮一直在围绕那个玩笑展开,直到人工介入才发现。
所以一个成熟的 context-mode,通常要同时回答两个问题:带多少(长度维度)和带什么、以什么身份带(角色维度)。只解决长度不解决角色,效果往往还是不理想。
2.3 上下文模式与“记忆”的区别
这里要澄清一个常见的混淆:context-mode 不等于“记忆系统”。记忆系统关注的是“把哪些信息持久化存下来”,而 context-mode 关注的是“在当前这一轮,从存下来的信息里取哪些、怎么组织”。两者是上下游关系。
打个比方,记忆系统像一个仓库,负责把货物存进去;context-mode 像一个拣货策略,决定这一单从仓库里拿哪些货、按什么顺序摆到打包台上。仓库再大再全,拣货策略不对,打包台上还是一片混乱。很多团队花大力气做向量库、做长期记忆,结果当前轮的上下文组织还是“全塞”,那前面的功夫就白费了。反过来,即使记忆系统很简陋(比如就是一个数组),只要 context-mode 设计得好,当前轮的效果也能做得不错。
3. 几种典型的 context-mode 及其适用边界
3.1 滑动窗口模式:简单但有硬伤
滑动窗口是最容易实现的模式:只保留最近 N 轮对话,更早的直接丢弃。N 通常取 3 到 10。它的优点是实现简单、成本可控、延迟稳定,缺点是硬截断——第 N+1 轮之前的信息一刀切掉,哪怕那条信息很关键。
我早期做的一个日程助手就吃过这个亏。用户第一轮说“帮我安排下周三下午的会”,中间聊了七八轮别的,到第十轮说“就按刚才那个时间”。如果窗口是 5,那“下周三下午”早就被切掉了,助手只能反问“哪个时间”。后来我们改成滑动窗口加一个“关键信息抽取”,把时间、地点、人名这类实体单独拎出来常驻,才解决这个问题。
滑动窗口适合什么场景?对话轮次短、每轮信息独立性强、对历史依赖弱的场景,比如简单的 FAQ 问答、单轮意图识别。一旦涉及多轮约束、指代消解,就得配合其他机制。
3.2 摘要压缩模式:用一次生成换长期记忆
摘要压缩的思路是:当历史超过一定长度时,把较早的部分交给模型生成一段摘要,后续只带这段摘要加最近的原文。这样既保留了长期信息,又控制了长度。
这个模式的关键在于摘要的粒度和触发时机。粒度太粗,细节丢失;太细,摘要本身又变长。触发时机也很讲究——是每轮都重新摘要,还是累积到阈值再摘要?我的经验是采用“分层摘要”:最近 5 轮保留原文,5 到 20 轮压缩成一段中等粒度摘要,20 轮以上再压缩成一段粗粒度摘要。这样既不会每轮都调用摘要(省成本),又能保证不同时间跨度的信息都有保留。
摘要模式的一个坑是摘要会引入误差。模型生成的摘要可能漏掉关键约束,或者把用户的语气曲解。我建议在摘要提示词里明确要求“保留所有数字、时间、否定词和用户明确表达的偏好”,并且在摘要生成后做一次简单的校验(比如检查原文里的数字是否都出现在摘要里)。这个校验成本很低,但能挡掉不少低级错误。
3.3 检索增强模式:按相关性动态取用
检索增强模式不按时间顺序取上下文,而是根据当前问题去历史里检索最相关的片段。实现上通常是把历史对话切块、向量化,当前问题来了之后做相似度检索,取 top-k 片段拼进上下文。
这个模式适合历史很长、但当前问题只和其中少数片段相关的场景,比如知识库问答、长文档对话。它的优点是精准,缺点是依赖检索质量——检索不准,带进来的就是无关噪音,比滑动窗口还糟。而且检索本身有延迟和成本,对实时性要求高的场景要谨慎。
我在一个技术文档助手里用过这个模式,效果好坏几乎完全取决于切块策略。按固定长度切,经常把一段完整说明切成两半;按语义切(比如按标题层级),效果好很多。所以如果你要用检索增强,切块策略比检索算法本身更值得花时间。
3.4 混合模式:真实项目里的常态
实际项目里很少只用一种模式,通常是混合。比如:最近 3 轮用原文(滑动窗口),3 轮以前用摘要(摘要压缩),同时把用户明确表达的约束(如“只要现货”“预算 500 以内”)单独抽出来常驻(结构化记忆)。当前问题进来后,先看有没有命中常驻约束,再拼上最近原文和摘要。
混合模式的设计难点在于优先级和去重。常驻约束、最近原文、摘要三者可能有重叠,拼的时候要去重,否则同一信息出现三次,既浪费 token 又可能让模型过度关注。我的做法是给每类上下文打一个优先级标签,拼接时按优先级排序,遇到内容高度相似的片段只保留优先级高的那个。
| 模式 | 实现复杂度 | 成本 | 适合场景 | 主要风险 |
|---|---|---|---|---|
| 滑动窗口 | 低 | 低 | 短对话、独立问答 | 硬截断丢关键信息 |
| 摘要压缩 | 中 | 中 | 长对话、需要长期记忆 | 摘要误差累积 |
| 检索增强 | 高 | 中高 | 长历史、稀疏相关 | 检索不准引入噪音 |
| 混合模式 | 高 | 中高 | 复杂多轮、多约束 | 优先级和去重难 |
4. 怎么为你的场景选对 context-mode
4.1 先问三个问题,再谈选型
选 context-mode 之前,我通常会先问三个问题,答案基本能框定方向。
第一个问题:当前轮的理解,依赖前面多少轮?如果基本只依赖上一轮,滑动窗口就够;如果依赖一个贯穿始终的约束,就需要常驻结构化记忆;如果依赖某个很早之前的具体细节,就需要检索或摘要。
第二个问题:历史里“噪音”和“信号”的比例大概是多少?如果大部分历史都和当前问题无关(比如一个通用助手,用户什么都聊),那检索增强更合适;如果历史高度连贯(比如一个专项任务),滑动窗口加摘要就够。
第三个问题:成本和延迟的容忍度是多少?如果对延迟极敏感(比如实时语音交互),摘要和检索的额外调用可能不可接受,只能做轻量的窗口加规则抽取;如果是异步任务,就可以用更重的模式。
这三个问题没有标准答案,但问完之后,选型范围会小很多。我见过不少团队一上来就上向量检索,结果场景其实只需要最近 5 轮,白白增加了复杂度和延迟。
4.2 一个反直觉的经验:模式要能“降级”
真实系统里,context-mode 不应该是一个固定值,而应该能根据情况降级。什么意思?就是当摘要服务超时、检索服务不可用时,系统要能退回到一个更简单但可用的模式(比如纯滑动窗口),而不是直接报错。
这个设计在线上环境特别重要。我经历过一次检索服务抖动,导致所有对话请求都失败,因为代码里写死了“必须检索成功才能继续”。后来改成“检索失败则退回最近 5 轮原文”,虽然效果打折,但至少服务可用。可用性永远优先于效果,这个原则在 context-mode 设计里同样适用。
4.3 用 A/B 测试而不是直觉来定参数
窗口取几轮、摘要触发阈值设多少、检索取 top 几,这些参数不要拍脑袋。我的做法是搭一个离线评测集,把历史对话按不同参数跑一遍,看关键指标(比如指代消解准确率、约束遵守率、平均 token 消耗)的变化。
有个细节:评测集要覆盖“边界情况”,比如刚好在窗口边缘的指代、摘要触发前后的对比、检索命中与未命中的对比。只测平均情况会掩盖很多问题。我一般会专门构造一批“第 N 轮引用第 1 轮信息”的样本,用来验证窗口大小是否够用。
5. 落地实现:从数据结构到拼接逻辑
5.1 上下文的数据结构设计
落地第一步是把上下文抽象成一个结构,而不是一个字符串数组。我通常用这样的结构:
class ContextItem: def __init__(self, role, content, turn, priority, item_type): self.role = role # user / assistant / system / tool self.content = content # 文本内容 self.turn = turn # 属于第几轮 self.priority = priority # 优先级,数字越大越优先保留 self.item_type = item_type # raw / summary / constraint / retrieved这个结构的好处是,后续的筛选、排序、去重、拼接都可以基于字段来做,而不是靠字符串匹配。比如“保留最近 3 轮原文”就是turn >= current_turn - 3 and item_type == "raw";“常驻约束”就是item_type == "constraint"。字段化之后,逻辑清晰很多,也方便加新类型。
5.2 拼接顺序对效果的影响
上下文拼进提示词的顺序,很多人不在意,但它对效果有实际影响。我的经验是:约束类放最前,摘要类放中间,最近原文放最后。原因是模型对开头和结尾的内容注意力更强(这是位置偏差的常见表现),把最重要的约束放开头、把最需要即时响应的最近对话放结尾,中间放背景摘要,整体效果比较稳。
另外,不同角色之间要有明确的分隔标记。我一般用类似[用户][助手][系统约束]这样的前缀,而不是只靠换行。实测下来,有明确角色标记的上下文,模型对“谁说了什么”的区分准确率更高,尤其在多轮指代时更明显。
5.3 一个可复用的拼接函数
下面是一个简化版的拼接逻辑,核心思路是“按优先级筛选、按类型排序、去重后拼接”:
def build_context(items, current_turn, max_tokens): # 1. 筛选:常驻约束 + 最近3轮原文 + 摘要 selected = [] for item in items: if item.item_type == "constraint": selected.append(item) elif item.item_type == "raw" and item.turn >= current_turn - 3: selected.append(item) elif item.item_type == "summary": selected.append(item) # 2. 去重:内容相似度高于阈值的只留优先级高的 selected = dedup_by_similarity(selected) # 3. 排序:constraint -> summary -> raw order = {"constraint": 0, "summary": 1, "raw": 2} selected.sort(key=lambda x: (order[x.item_type], x.turn)) # 4. 按 token 预算截断(从低优先级开始丢) return truncate_by_tokens(selected, max_tokens)这个函数里,dedup_by_similarity和truncate_by_tokens是两个需要自己实现的辅助函数。去重可以用简单的编辑距离或向量相似度;截断要保证约束类永远不被丢掉,从 raw 里最老的开始丢。
提示:截断时一定要保护 constraint 类型,否则可能把用户明确表达的约束丢掉,导致后面所有回答都违反约束。这个坑我踩过,排查了半天才发现是截断逻辑把约束当普通历史丢了。
6. 实测中那些文档不会写的坑
6.1 摘要里的否定词丢失
这是摘要模式最隐蔽的坑。用户说“我不要红色的,也不要带花纹的”,摘要模型很可能生成“用户对颜色和花纹有偏好”,把否定丢了。后续推荐就会推红色带花纹的,用户直接炸毛。
解决办法是在摘要提示词里强制要求“保留所有否定表达”,并且在摘要生成后做一次否定词检查——如果原文有“不”“别”“无需”等词,摘要里必须也有对应的否定表达,否则重新生成或退回原文。这个检查逻辑很简单,但能挡掉大部分否定丢失问题。
6.2 检索命中了“过时约束”
检索增强模式有个陷阱:历史里用户可能改过主意。比如第 3 轮说“预算 500”,第 10 轮说“算了预算提到 1000”。如果检索只按相关性,很可能把第 3 轮的“预算 500”也检索出来,和新的“预算 1000”一起塞进去,模型就懵了。
我的处理方式是给约束类信息加时间戳,检索到同类约束时只保留最新的那个。具体做法是在拼接前去重阶段,对item_type == "constraint"且约束类型相同的项,按 turn 排序只留最新的。这样即使检索命中了旧约束,也会被新约束覆盖。
6.3 窗口边缘的指代断裂
滑动窗口最典型的故障是“指代刚好跨在窗口边缘”。比如窗口是 5,用户在第 6 轮说“就按刚才那个来”,而“那个”指的是第 1 轮的内容,已经被切掉了。模型只能反问或瞎猜。
这个问题没法完全避免,但可以缓解:在窗口边缘做一次“指代检查”,如果当前轮包含“那个”“它”“刚才”这类指代词,且窗口内找不到明确指代对象,就触发一次检索或摘要回溯。这个检查可以用简单的规则实现,不需要模型参与,成本很低。
6.4 多轮之后模型开始“自说自话”
有时候你会发现,聊了十几轮之后,模型开始重复自己之前说过的话,或者围绕一个早已结束的话题打转。这通常是因为上下文里助手自己的历史回复占比过高,模型被自己的话带偏了。
缓解办法是在拼接时对助手的历史回复做降权——比如助手的历史只保留最近 2 轮,更早的助手回复不直接带原文,而是并入摘要。这样能减少模型“自我强化”的倾向。实测下来,这个调整对长对话的稳定性有明显帮助。
7. 把 context-mode 当成一个可演进的系统来维护
7.1 监控什么指标
context-mode 上线后,不能只看“回答对不对”,还要看几个过程指标:平均上下文长度、摘要触发频率、检索命中率、约束遵守率、指代消解失败率。这些指标能帮你在效果下滑时快速定位是哪个环节出了问题。
我一般会在日志里记录每一轮实际拼进上下文的 item 列表(类型、轮次、token 数),这样出问题时可以回放。有一次线上反馈“助手忘了用户说过只要现货”,我回放日志发现那一轮的上下文里 constraint 类型是空的,顺着查下去发现是约束抽取的规则漏了一种表达方式。没有这个日志,这种问题很难定位。
7.2 什么时候该换模式
没有一种模式能一直用下去。当出现这些信号时,就该考虑调整了:约束遵守率持续下降(说明常驻约束不够或抽取不全)、指代失败率上升(说明窗口或检索不够)、平均 token 消耗增长过快(说明摘要或截断策略失效)。这些信号不用等用户投诉,监控里就能提前看到。
换模式不一定要大改,很多时候只是调参数——窗口从 5 调到 8,摘要阈值从 20 轮调到 15 轮,检索 top-k 从 3 调到 5。但每次调整都要有评测数据支撑,不能凭感觉。
7.3 一个我常用的演进路径
如果让我给一个从零开始的团队建议演进路径,大概是这样:先用滑动窗口加规则抽取的约束常驻,把基本的多轮能力跑通;等对话轮次变长、窗口不够用了,加摘要压缩;等历史里相关性变得稀疏了,再加检索增强。每一步都基于实际遇到的问题来加,而不是一开始就上全套。
这个路径的好处是每一步的复杂度都可控,出问题也容易定位。我见过一上来就上混合模式的团队,结果出了问题根本不知道是哪个环节的锅,排查成本极高。context-mode 的复杂度应该跟着场景的复杂度走,而不是反过来。
最后分享一个我自己的小习惯:每次设计一个新的 context-mode,我都会先手写几段“理想上下文”长什么样,再倒推需要什么筛选和拼接逻辑。这个习惯帮我避免了很多“先写代码再想效果”的返工。上下文组织这件事,想清楚比写快更重要。