
上周我在调客服机器人的context-mode切换逻辑时遇到一个很典型的问题同一个用户问题在知识检索模式下回答得很稳切到自由对话模式之后模型开始一本正经地编造商品参数。排查半天问题不在模型本身而是我的context-mode设计根本没把两套上下文隔干净——上一轮检索出来的文档片段还残留在上下文里新模式下模型把这些噪音当成了当前对话的一部分。这个标题里的context-mode上下文模式最近在AI应用开发圈里出现得越来越频繁它说的不是一个简单的开/关而是整套围绕大模型上下文窗口的管理策略。本文会从我踩过的坑出发把context-mode的核心设计思路、token预算拆解、一份可落地的Python实现以及实测中最容易翻车的边界条件一次讲清楚适合正在做Agent、智能问答、知识库应用的同学参考。1. 为什么context-mode突然成了绕不开的话题1.1 一个真实场景切换模式后回答质量断崖式下跌先交代一下背景。我维护的是一个电商客服机器人输入是一本几十页的商品手册。用户在机器人里可以问售前参数也可以闲聊式地问你们这个牌子靠不靠谱。为了兼顾两种场景我给机器人设计了两套context-mode一套是RetrievalContext负责从商品手册里检索相关内容再回答一套是FullContext负责基于已有对话历史做自由聊天。第一次联调就翻车了。用户先问整机保修几年机器人从手册里检索到整机一年电池半年答对了。然后用户没什么征兆地切了个话题问你们这个牌子靠谱吗机器人居然开始回答电池属于易耗品不在保修范围内。这不是个例连续试了七八个类似问题每次只要前面走过一轮检索后面的自由对话就会被手册里的碎片带偏。原因不难找。我在切换模式时只改了后续请求的system prompt但messages数组里还留着上一轮的检索片段。这些片段在模型眼里不是历史痕迹而是当前对话的一部分。尤其当片段里包含保修、参数、条款这类信息密度高的句子时模型会默认这是需要继续遵守的事实约束于是回答被带得又硬又偏。这个场景基本概括了context-mode的核心矛盾不同模式对哪些信息该活、哪些信息该死的判定完全不同而模型本身是记不住你切了模式的它只看得见你塞进上下文里的内容。1.2 context-mode管的是哪三件事很多人把context-mode理解成一个开关切过去就完了。实际操作下来它至少包含三个互相关联的层面。第一窗口里放什么。对话历史、知识片段、工具返回结果、临时生成的中间输出哪些要进上下文哪些不要进。这一步决定了模型能看到的信息边界。第二内容怎么组织。同一批信息放在系统指令区、历史区、还是紧跟用户消息之后的区域效果天差地别。模型的注意力不是均匀分布的它对开头和结尾的内容更敏感对中间的大段内容容易视而不见。所以context-mode必须连带着解决排序、分段、格式化的问理。第三过期信息怎么办。对话不会永远短下去知识库也不会永远贴合当前问题。总有一部分上下文会过时你需要决定它是被压缩、被移除还是被一个新的检索结果替换掉。这个决策看起来简单实际上大部分模式切换后的回答漂移都是因为过期信息没有被处理干净。我后来看了一些现成的Agent框架它们也内置了类似的概念比如压缩模式检索模式记忆模式。但默认参数通常只适合演示项目换到真实业务数据上就会暴露出上面三个层面的问题。这也是为什么我最终选择自己维护一层轻量的context-mode状态管理而不是完全依赖框架的默认行为。2. context-mode的骨架token预算与上下文分层2.1 上下文窗口不是让你全塞进去的上下文窗口是有限的这句话听起来像废话但很多人在设计prompt时就是选择性遗忘。我见过最夸张的做法是买到支持200k上下文的模型之后干脆把整本手册、整段对话日志一股脑全塞进messages里。结果模型反而答得更差。两个层面的原因。第一是成本与延迟。API按输入token计费塞进去的每一个字都要付钱。响应前的prefill时间也跟输入长度强相关我实测过同一个模型输入token从4k涨到16k首token延迟能翻到两倍以上。用户不会容忍机器人每次回复前都卡上好几秒。第二是注意力摊薄。大模型虽然在长文本上比过去强很多但对中间部分的理解仍然明显弱于开头和结尾。把大量无关内容塞进中部等于给关键信息制造噪音。这不是玄学是Transformer结构的注意力分布特性带来的实际表现。所以我做context-mode的第一件事就是把上下文窗口划分成三个固定分区按顺序拼装System区放系统指令、行为约束、输出格式。永远放在最开头。Memory区放压缩后的对话摘要、长期记忆、或检索出的知识块。放在中间偏前。Current区放最近几轮对话原文、当前用户问题、以及紧贴回答问题前插入的关键检索结果。放在最末尾附近。为什么检索结果要放在结尾前而不是历史里因为模型回答时会对离问题最近的内容给予更高权重。你想要它引用某份文档里的条款就把这份文档的片段作为上下文最后一块内容塞进去。这一点我踩过很多次坑放到中间容易被历史对话稀释掉。2.2 四种典型模式的预算分配参数有了三个分区之后下一个问题是每种context-mode下各分区占多少预算我根据自己的实践整理了一张表你可以把它当成初始参考值。预算占比针对的是本次请求的总token预算不是窗口上限。实际设置时通常把总预算控制在窗口上限的70%-80%留出余量给模型输出。模式适用场景System区占比Memory区占比Current区占比主要风险FullContext短对话、精读小文档5%-10%10%-20%70%-85%轮数一多就溢出SummaryContext长对话、客服会话、会议总结10%-15%30%-45%40%-50%摘要蒸发细节RetrievalContext大知识库、文档问答10%-15%45%-55%30%-40%检索噪音污染回答HybridContextAgent、多工具调用15%-20%35%-45%35%-45%路由误判导致模式错配FullContext模式好理解就是尽量保留原文适合只有三四轮对话、或需要逐字分析一段短文本的场景。它不需要摘要也不需要检索所有预算都给当前会话。代价是对话一长就必然溢出所以它只适合入口。SummaryContext模式是我用得最多的。当对话超过一定的轮数阈值我就把较早的历史原文丢给模型做一次摘要然后把摘要放进Memory区只保留最近两到三轮原文在Current区。这样总token可以压到一个稳定水平对话可以无限续下去。代价是摘要会丢细节这一点我后面专门讲。RetrievalContext模式针对的是知识库问答。System区保持精简Memory区装的是向量检索返回的文档块Current区放当前问题与最近对话。你先决定查询什么再从库里把相关片段捞出来填进Memory区。它解决的是知识太大塞不进去的问题。HybridContext是前面几者的组合通常在Agent场景使用——模型的每步动作都不同有时要检索有时要读历史有时要调用工具。我一般用一个路由函数先判断用户意图再选择子模式而不是让模型自己在一次请求里处理全部分区。2.3 估算token与预算不足时的取舍优先级要分配预算先得会估算token。不同模型的tokenizer不完全一样但估算逻辑可以通用。英文文本大致按4个字符约等于1个token估算。中文文本大致1个汉字约等于0.6到1个token标点和特殊符号会额外多占。精确计算最好用模型官方提供的tokenizer库。我在生产环境里会用一个本地统计函数把中文、英文、数字、特殊符号分开计数误差控制在5%以内就够用了。预算不足的时候优先砍谁我总结了一个固定优先级按这个顺序做裁减回答质量损失最小砍历史轮次原文但保留最近一到两轮。旧对话的信息价值随时间递减且已经浓缩到摘要里了。砍重复出现的段落。同一个知识片段在检索结果里出现两次保留一份就行。砍长摘要的中间层级。如果用的是多级摘要优先砍二级以上的概括保留底层接近原文的摘要。最后才考虑砍System区的指令和检索块。System区决定行为边界检索块决定当前问题的命脉这两类一砍回答质量立刻肉眼可见地掉。这个顺序背后的逻辑很简单离当前任务越远的信息越可牺牲。系统指令约束整个会话风格当前检索块回答问题本身这两块是现在进行时历史轮次和旧摘要只影响来龙去脉给一点上下文线索就行。3. 一个可落地的实现context-mode管理器3.1 架构与角色划分设计思路是把context-mode的决策从业务代码里抽出来做成一个独立的Python类。业务层只需要告诉它用户刚才说了什么它负责维护当前模式、token预算、构造最终的messages数组。以OpenAI兼容接口为例最终发给模型的messages是一个列表里面混着system、user、assistant三种角色。context-mode管理器要做的事情就是根据当前状态动态填充这个列表保证三个分区的预算符合当前模式。这里有一个容易忽略的点摘要和检索结果也要以明确的角色消息进入列表。比如把历史摘要放在一条system消息里、把检索块放在一条system消息里而不是直接加到user消息里。原因是我实测下来模型对不同角色消息的权重感知不太一样system消息会被当作需要遵守的规则user消息会被当作对话内容。摘要和检索块更适合被当作前者。3.2 核心代码上下文状态管理器下面这份代码简化自客服机器人项目去掉了具体的向量检索和模型调用细节保留了最核心的context-mode切换逻辑。import enum import json from dataclasses import dataclass, field from typing import List, Dict, Optional class ContextMode(enum.Enum): FULL full SUMMARY summary RETRIEVAL retrieval HYBRID hybrid dataclass class TokenBudget: mode: ContextMode total_budget: int 4000 system_ratio: float 0.10 memory_ratio: float 0.20 current_ratio: float 0.70 def estimate_tokens(text: str) - int: 本地估算token数中英文混合场景够用。 if not text: return 0 cjk sum(1 for ch in text if \u4e00 ch \u9fff) other len(text) - cjk return int(cjk * 0.9 other / 3.5) 1 class ContextModeManager: def __init__(self, system_prompt: str, max_rounds_before_summary: int 6): self.system_prompt system_prompt self.mode ContextMode.FULL self.rounds: List[Dict[str, str]] [] # 原始对话轮次 self.summary: str # 摘要文本 self.retrieved_chunks: List[str] [] # 检索得到的内容块 self.context_epoch 0 # 用于清理残留上下文 self.max_rounds_before_summary max_rounds_before_summary def update_rounds(self, user_msg: str, assistant_msg: str): self.rounds.append({role: user, content: user_msg}) self.rounds.append({role: assistant, content: assistant_msg}) def switch_mode(self, new_mode: ContextMode): if new_mode self.mode: return old_mode self.mode self.mode new_mode self.context_epoch 1 # 关键切换模式即标记新纪元 print(f[mode] {old_mode.value} - {new_mode.value}, epoch{self.context_epoch}) if new_mode ContextMode.SUMMARY: # 进入摘要模式前把已有轮次集中压缩一次 joined \n.join( f{r[role]}: {r[content]} for r in self.rounds ) self.summary self._summarize(joined, max_tokens500) # 摘要模式只保留最近两轮原文 self.rounds self.rounds[-4:] def inject_retrieved(self, chunks: List[str]): self.retrieved_chunks chunks if self.mode ContextMode.FULL: # 检索内容一出现应该自动偏向检索模式 self.switch_mode(ContextMode.RETRIEVAL) def _summarize(self, text: str, max_tokens: int 500) - str: # 实际项目中这里调用模型生成摘要 # 简易示例直接截断生产环境必须换成模型摘要 truncated text[: max_tokens * 3] return f[摘要] {truncated} ... def _build_budget(self) - TokenBudget: ratios { ContextMode.FULL: (0.08, 0.12, 0.80), ContextMode.SUMMARY: (0.12, 0.38, 0.50), ContextMode.RETRIEVAL: (0.12, 0.48, 0.40), ContextMode.HYBRID: (0.18, 0.40, 0.42), } s, m, c ratios[self.mode] total 4000 return TokenBudget(self.mode, total, s, m, c) def build_messages(self, current_question: str) - List[Dict[str, str]]: budget self._build_budget() system_tokens int(budget.total_budget * budget.system_ratio) memory_tokens int(budget.total_budget * budget.memory_ratio) current_tokens int(budget.total_budget * budget.current_ratio) system_content self._fit_tokens(self.system_prompt, system_tokens) messages [{role: system, content: system_content}] # Memory区摘要 检索块 memory_parts [] if self.summary: memory_parts.append(self.summary) if self.retrieved_chunks: memory_parts.extend(self.retrieved_chunks) memory_content \n\n.join(memory_parts) memory_content self._fit_tokens(memory_content, memory_tokens) if memory_content: messages.append({role: system, content: memory_content}) # Current区历史轮次 当前问题 current_parts [r[content] for r in self.rounds[-6:]] current_parts.append(current_question) current_content \n.join(current_parts) current_content self._fit_tokens(current_content, current_tokens) messages.append({role: user, content: current_content}) return messages def _fit_tokens(self, text: str, limit: int) - str: if estimate_tokens(text) limit: return text ratio limit / max(estimate_tokens(text), 1) cut_len max(int(len(text) * ratio) - 10, 10) return text[:cut_len]这段代码的核心不是算法而是几个设计决策。第一个决策是用context_epoch标记模式代际。每次切换模式epoch加一。检索块和对话历史会带上epoch属性构建消息时只保留当前epoch的活跃块。这解决了开头说的旧检索残留污染新对话问题。第二个决策是注入检索块时自动切换模式。只要检测到外部检索结果进入就默认full模式不再适合因为后续对话大概率要围绕这个知识块展开这时保持full反而会因为历史原文占太多预算而压掉检索块。第三个决策是summary模式保留最近四轮原文。四轮大约对应用户和助手各两条消息足够维持当前话题的连贯性。这里的数字不是拍脑袋我第一次设成两轮发现用户连续追问时会丢掉前一轮的引用语境设成六轮token又压不下来四轮是折中。3.3 模式切换状态机光有代码还不够模式之间怎么迁移需要明确规则。我整理了一份状态迁移表作为context-mode管理器的默认策略当前状态触发条件目标状态迁移动作FULL轮次超过max_rounds_before_summarySUMMARY对旧轮次做摘要保留最近两轮原文清理retrieved_chunksFULL注入检索结果RETRIEVAL写入检索块压低历史轮次占比SUMMARY用户上传新文档并提问RETRIEVAL清空旧检索块注入新检索结果保留摘要RETRIEVAL连续多轮未触发检索命中SUMMARY清理检索块提升历史摘要占比RETRIEVAL用户明确要求逐字分析某段手册FULL清空检索块与摘要只保留最近轮次原文SUMMARY上下文逻辑被摘要压得太碎且轮次已缩短FULL丢弃摘要如果原文仍可恢复则恢复原文每次迁移动作里清空哪些字段比选择哪个模式更容易翻车。我后来加了一条硬规则任何模式切换都必须显式声明要清理的残留区不允许只改一个mode字段了事。因为model字段只是枚举值真正影响回答的是messages数组里塞了什么东西。4. 实测下来最容易翻车的三个边界4.1 模式切换时的上下文污染这个问题我用context_epoch解决了大半但还有更隐蔽的变种。有一种情况是切换前用户已经连续聊了很多轮你先做了摘要压缩然后又切到检索模式。此时摘要里如果包含之前检索出来的旧条款模型仍然会认为旧条款是有效信息哪怕它已经和新检索结果冲突了。我遇到过一个实际案例用户先问旧产品A的保修政策检索结果进过上下文后来问新产品B机器人正确切到新的检索块但因为摘要里还保留着产品A的政策描述模型对两个产品政策开始各打五十大板地混合输出。处理办法是把摘要也分段并标注时间范围或主题范围。比如以下摘要是2024年6月之前的对话摘要主要关于产品A。这样一旦切换主题模型能识别这段摘要的适用范围已经过期。加上epoch标记之后我会在构建消息时把旧epoch的memory块排除在外但保留之前生成的summary文本因为summary覆盖了整个历史时期不能直接丢。4.2 递归压缩摘要导致的细节蒸发摘要模式最大的敌人不是溢出而是细节蒸发。多轮对话一旦开始递归摘要每压缩一次就会丢一点细节。第一轮压缩丢的是语气和不重要的寒暄第二轮压缩开始丢具体的数字和条件第三轮之后保修年限、联系方式、特殊规则这类关键信息很容易被吞掉。我一开始走的是很多框架默认的路子把旧对话全部丢给模型要求生成一段精简摘要然后把这段摘要作为后续对话的记忆。效果就是回答经常大方向对关键细节错。比如用户问电池保修期多久模型答保修期内有保障等于没答。后来我改用摘要关键事实卡的双轨方案。摘要只负责保留对话的叙事脉络比如用户咨询了产品保修政策对比了A和B两款型号的差异关键事实卡则单独从原文里抽取结构化信息每条事实包含主体、数值、来源和时间。FACT_CARD:[ {subject: 产品A整机保修, value: 12个月, source: 手册P3-1, expire: }, {subject: 产品A电池保修, value: 6个月, source: 手册P3-2, expire: }, {subject: 产品B整机保修, value: 24个月, source: 手册P11-1, expire: 2025-01-01之后购买} ]摘要管发生过什么事实卡管具体数值是什么。构建上下文时事实卡放在Memory区靠后的位置紧挨着Current区让模型在回答事实性问题时能直接引用。这套方案上线后客服机器人对保修期、退换货条件这类高频问题的正确率明显回升。事实卡也有脏数据问题。来源页码、生效时间这些信息一旦缺失模型还是会硬编。我的处理是给每条事实加一个confidence字段低置信度的事实不放进检索结果Onbrighter。4.3 检索模式下chunk粒度的手感问题RetrievalContext模式下检索结果的质量直接决定回答质量而影响检索结果的第一参数是chunk粒度。chunk太大比如2048 token一块单块包含的信息多召回容易命中但噪声也大可能一块里包含五个不相关要点模型容易被带跑。chunk太小比如128 token一块定位精准了但同一个完整的条款可能被切成两半检索时只命中一半模型回答时缺上下文。我在一个100页左右的产品手册上测过一轮用同样的top_k预算分别用256、512、1024的chunk大小跑问答集结果是512左右在这个场景下综合表现最好256的召回缺上下文明显1024的噪声问题最严重。chunk和top_k是联动的。我给自己定的原则是top_k与chunk_size乘积不超过Retrieval模式Memory区预算的80%。chunk之间重叠10%-20%防止关键句被切碎。同一个语义单元尽量不跨chunkMarkdown按标题、PDF按章节边界切割比粗暴按字符切效果更好。这个手感每个知识库都不一样。我见过技术文档用1024效果很好也见过合同类文本用256才不丢条款。最好的办法不是拍脑袋而是准备二三十个带标准答案的问题跑一次离线评测用回答正确率来标定chunk_size和top_k这两个参数。这一步花的时间很少收益却非常直接。还有一点是检索结果插入顺序。多个chunk按什么顺序进Memory区也有讲究。我建议按相关度从高到低排列而不是按原文顺序。因为模型结尾注意力最强如果把相关度最高的chunk放在当前区末尾前插命中率会好一些。但这也意味着系统指令和摘要被挤到前面语义上要有清晰边界不能让模型把检索块当成新的系统指令。每次调context-mode都像在做抽屉收纳先想清楚哪个分区放什么内容再谈怎么让模型理解这些内容。模型本身不记得你切过几个模式它只看得见你最后塞给它的messages数组。真正决定回答质量的永远是你有没有把对的信息放在对的位置上并且把不该出现的残留清干净。最后再分享一个实操经验把每次context-mode切换的决策和触发原因写进日志包含模式、epoch、token占比和触发条件。上线跑一周之后回头翻一遍你会发现大量所谓模型回答飘了的问题本质都是预算分配或切换时机设计不合理而不是模型能力问题。把这些问题理清比换更强的大模型更能立竿见影地提升应用质量。