☰
context-mode上下文模式设计:从token预算到模式切换的完整实践
2026/10/7 16:13:11 网站建设 项目流程

上周我在调客服机器人的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%以内就够用了。

预算不足的时候,优先砍谁?我总结了一个固定优先级,按这个顺序做裁减,回答质量损失最小:

  1. 砍历史轮次原文,但保留最近一到两轮。旧对话的信息价值随时间递减,且已经浓缩到摘要里了。
  2. 砍重复出现的段落。同一个知识片段在检索结果里出现两次,保留一份就行。
  3. 砍长摘要的中间层级。如果用的是多级摘要,优先砍二级以上的概括,保留底层接近原文的摘要。
  4. 最后才考虑砍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_tokens=500) # 摘要模式只保留最近两轮原文 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_chunks
FULL注入检索结果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%,防止关键句被切碎。
  • 同一个语义单元尽量不跨chunk,Markdown按标题、PDF按章节边界切割比粗暴按字符切效果更好。

这个"手感"每个知识库都不一样。我见过技术文档用1024效果很好,也见过合同类文本用256才不丢条款。最好的办法不是拍脑袋,而是准备二三十个带标准答案的问题,跑一次离线评测,用回答正确率来标定chunk_size和top_k这两个参数。这一步花的时间很少,收益却非常直接。

还有一点是检索结果插入顺序。多个chunk按什么顺序进Memory区也有讲究。我建议按相关度从高到低排列,而不是按原文顺序。因为模型结尾注意力最强,如果把相关度最高的chunk放在当前区末尾前插,命中率会好一些。但这也意味着系统指令和摘要被挤到前面,语义上要有清晰边界,不能让模型把检索块当成新的系统指令。

每次调context-mode都像在做抽屉收纳:先想清楚哪个分区放什么内容,再谈怎么让模型理解这些内容。模型本身不记得你切过几个模式,它只看得见你最后塞给它的messages数组。真正决定回答质量的,永远是你有没有把对的信息放在对的位置上,并且把不该出现的残留清干净。

最后再分享一个实操经验:把每次context-mode切换的决策和触发原因写进日志,包含模式、epoch、token占比和触发条件。上线跑一周之后回头翻一遍,你会发现大量所谓"模型回答飘了"的问题,本质都是预算分配或切换时机设计不合理,而不是模型能力问题。把这些问题理清,比换更强的大模型更能立竿见影地提升应用质量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询