☰
大模型上下文管理:用context-mode控制Token预算与多轮对话记忆
2026/10/8 20:20:47 网站建设 项目流程

写这篇文章的起因,是我最近在给一个客服机器人项目做优化时,反复被同一个问题折磨:模型明明能力很强,却在对话第三轮之后开始“失忆”,把用户刚说过的关键信息忘得一干二净。查来查去,问题不在模型,而在我们喂给它内容的方式。折腾了几天后,我把这套处理手段收敛成了一个模式,内部管它叫 context-mode。今天这篇就把这个模式彻底拆开讲清楚,包括为什么要这么做、Token预算怎么算、代码怎么落,以及踩过的四个大坑。

1. 先理解context-mode:它在解决什么真实问题

1.1 大模型的记忆缺陷,逼出了一个新模式

先明确一个概念:大模型本身没有任何“记忆”。它的推理过程是逐Token生成,每个Token只能看到你当前输入里的内容。一旦这个请求结束,之前对话的所有信息就从它的“视野”里消失了。你可以把它理解成一个严重短期失忆的同事:每次你走进办公室和他沟通,他都当你是个陌生人,除非你提前把“我们上次聊了什么”写在便签纸上递给他。

context-mode就是管理这些“便签纸”的机制。它本质上是一套围绕大模型上下文窗口(Context Window)的管理策略——决定哪些信息该进入窗口、以什么顺序排列、保留多久、何时压缩、何时丢弃。这个模式在AI应用开发里之所以越来越重要,是因为大部分真实业务场景根本不是“一问一答”,而是多轮对话、文档问答、工具调用这些需要连续状态的复杂交互。

以我做的客服项目为例,用户第一轮说“我买了你们家X型号打印机,打印出来有条纹”,第二轮说“我用了原装硒鼓”,第三轮问“我应该怎么设置”。如果没有context-mode,第三轮模型根本不知道用户说的是什么设备、之前做过哪些尝试,回答自然只能给出一堆通用建议。而把前三轮的关键信息整理成结构化上下文注入后,模型才能给出“针对X型号、原装硒鼓、条纹问题”的具体方案。

这个模式适合谁?说实话,只要你在用大模型API开发任何带连续交互的应用,都需要它。不管是ChatBot、Agent、Copilot还是RAG问答系统,context-mode就是你控制模型“记忆”的核心手段。

1.2 四种场景下的context-mode选型对照

不是所有场景都需要复杂的上下文管理,我见过太多人一上来就堆历史消息,结果Token成本爆炸、响应变慢、效果还变差。根据场景选模式,才是正确的做法。

模式上下文来源使用方式典型场景主要代价
无状态模式仅当前请求不注入任何历史单次翻译、单轮分类、独立问答无法处理连续任务
全量历史模式完整对话历史按顺序全量拼接短对话、代码调试、早期MVPToken消耗线性增长
窗口滑动模式最近N轮对话只保留尾部消息客服机器人、闲聊助手早期关键信息易丢失
结构化摘要模式历史摘要+关键锚点+最近对话分层注入复杂Agent、长会话任务摘要本身有信息损耗

我在实际项目里的选择经验是:对话轮数小于等于4轮时,全量历史模式最省心,没必要压缩;超过4轮还不做管理,上下文就会开始失控。窗口滑动模式是性价比最高的起步方案,代码量不到50行就能搞定;而一旦涉及关键用户信息、跨多轮的任务型对话,就必须上结构化摘要模式,也就是我后面要讲的完整版context-mode。

2. 设计context-mode前,先算清三笔账

2.1 Token预算怎么算才不超限

很多人做上下文管理凭感觉,结果一到线上就报错“maximum context length exceeded”。问题就出在没有先算账。

上下文窗口是硬性上限,但窗口不等于你可用的全部容量。一次请求内,窗口要同时装下四部分内容:

可用历史 = 模型窗口 - 系统指令 - 用户本轮输入 - 模型输出预留

我拿一个8K窗口的模型举例。假设系统指令写了一份2000字的产品说明,约2000 Token;用户本轮问了一个600字的问题,约600 Token;模型的回答你预留1000 Token,防止生成到一半被截断。那么:

可用历史 = 8000 - 2000 - 600 - 1000 = 4400 Token

4400 Token换算成中文大约是4000多个汉字,也就是七八轮的简短对话。如果这都算不明白,你设计的压缩策略就是盲人摸象。

还有一个实操经验:生产环境最好把预算再打八折。因为模型的实际窗口会根据请求格式有细微差异,而且有些嵌入内容(比如工具定义)也会占用Token。我在代码里习惯用一个HISTORY_BUDGET_RATIO = 0.8,宁可给历史少留一点,也别让请求爆掉。

2.2 三层上下文结构:一条消息该放在哪一层

算完账之后,下一个问题是:手头所有信息往窗口里塞的时候,怎么排布最合理?我实践下来最好用的是三层结构。

第一层是System层,固定锚点。这一层放三样东西:任务指令、用户关键信息锚点、早期对话摘要。它的特点是几乎不参与裁剪,每次请求都原样带上。用户关键信息锚点是什么意思?比如用户在第一轮说“我家三岁的金毛犬呕吐”,这个信息太重要了,不能等它滑出窗口,我会单独把它提取出来,放到System层里,格式就是一句话:“用户关键信息:宠物为3岁金毛犬,症状为呕吐。”

第二层是历史层,动态滑窗。这一层放最近的若干轮对话,按时间顺序排列,参与滑动裁剪。每次新消息进来,如果总量超预算,就从这一层的最前面开始丢。

第三层是工具层,临时注入。这一层专门放代码执行结果、RAG检索出来的文档片段、API返回的结构化数据。它们的特点是时效性强,用一次就可以丢,不需要进历史。很多人的上下文膨胀,就是因为把工具返回的一长串JSON塞进了历史层,几轮下来窗口就满了。

2.3 三种压缩策略的取舍

压缩历史有三大流派:滑动窗口、Token预算裁剪、摘要压缩。我用一个表格直接对比,然后说我的选择。

策略实现难度信息保留能力成本适用阶段
滑动窗口极低仅保留最近内容极低快速上线
Token预算裁剪低受限于截断位置极低防御性兜底
摘要压缩中高高(取决于摘要质量)每次压缩+1次模型调用正式产品

我的实践结论是:不要单选,要组合。核心策略是“摘要优先、裁剪兜底”。当历史超限时,先把最早的对话生成摘要(花一次模型调用),放到System层;如果摘要加最近对话还是超限,再走滑动窗口裁剪。这样既保住了早期关键信息,又确保请求永远不会爆掉。

还有一个细节很多人忽略:摘要不要反复对同一批内容生成。如果上一轮已经生成了摘要,新的压缩发生时就只处理“上次摘要之后”的对话。我见过一些人每次压缩都对全量历史做摘要,既浪费Token,又越缩越失真。

3. 一套可落地的ContextManager实现

3.1 核心类实现:压缩、锚点、拼装一口气搞定

这部分我直接给出一套我在项目中实际使用的Python实现。这版不是教学玩具,是可以直接抄进项目改造的骨架。为了便于理解,我把Token计数简化成字符估算,误差在工程可接受范围内。

class ContextManager: def __init__(self, system_prompt: str, max_context_chars: int = 12000, keep_recent_rounds: int = 4): self.system_prompt = system_prompt self.max_context_chars = max_context_chars self.keep_recent_rounds = keep_recent_rounds self.messages = [] # 历史消息,格式 [{"role": "user"/"assistant", "content": "..."}] self.anchored_facts = [] # 关键用户信息锚点 self.summary = "" def add_message(self, role: str, content: str): self.messages.append({"role": role, "content": content}) self._ensure_budget() def anchor_fact(self, fact: str): if fact not in self.anchored_facts: self.anchored_facts.append(fact) def _total_chars(self) -> int: total = len(self.system_prompt) for m in self.messages: total += len(m["content"]) if self.summary: total += len(self.summary) for f in self.anchored_facts: total += len(f) return total def _ensure_budget(self): if self._total_chars() <= self.max_context_chars: return # 策略1:对最早的对话做摘要压缩(只做一次,避免重复开销) if not self.summary and len(self.messages) > self.keep_recent_rounds * 2: old_messages = self.messages[:-self.keep_recent_rounds * 2] self.summary = self._summarize(old_messages) self.messages = self.messages[-self.keep_recent_rounds * 2:] # 策略2:仍超限则滑动裁剪,保留最近keep_recent_rounds轮 while self._total_chars() > self.max_context_chars and len(self.messages) > 2: self.messages.pop(0) def _summarize(self, messages) -> str: # 实际项目中这里应调用LLM生成结构化摘要,示例保留关键信息提取逻辑 key_points = [] for msg in messages: content = msg["content"].strip() if msg["role"] == "user" and content: key_points.append(content[:40]) return "用户先后提到:" + ";".join(key_points[-5:]) + "。" def build_context(self) -> list: context = [{"role": "system", "content": self.system_prompt}] if self.anchored_facts: facts_text = "需始终记住的用户信息:" + ";".join(self.anchored_facts) context.append({"role": "system", "content": facts_text}) if self.summary: context.append({"role": "system", "content": "[历史摘要] " + self.summary}) context.extend(self.messages) return context

这套实现的核心逻辑就三个:add_message写入新消息后立刻检查预算,超限先摘要后裁剪;anchor_fact把不能丢的关键信息独立保存;build_context把系统指令、锚点、摘要、最近历史按优先级拼装成发送给模型的完整消息列表。

3.2 关键设计决定背后的原因

这套实现看着简单,但每一行背后都有取舍,我逐个解释。

为什么用max_context_chars而不是真正的Token数?因为每次调用tokenizer数Token有延迟,而且很多tokenizer没有本地Python实现。工程上通用做法是用字符数近似:在中文场景,1个汉字约等于1个Token,英文约4个字符等于1个Token。我在上面代码里设置12000字符,对应的就是约3000-4000 Token的历史预算,配合一个8K窗口的模型正合适。如果你用的是16K或更大窗口,把这个值调大即可。

为什么keep_recent_rounds设置为4轮?这是经验值。少于4轮,摘要压缩触发太频繁,浪费模型调用;多于4轮,用户最新的诉求容易被淹没在旧对话里。4轮覆盖了用户“提问-追问-澄清-完成”的最常见交互长度。

为什么压缩的时候只对self.messages[:-self.keep_recent_rounds * 2]做摘要?这里乘2是因为一轮对话包含一条user消息和一条assistant消息。这个切片的意思就是“保留最近4轮完整对话,把更早的全部拿去摘要”。这样摘要只生成一次,后续新消息进来若再超限,走的就是滑动裁剪分支,不会重复花摘要的钱。

为什么System层可以放多条system消息?我实测下来,主流模型的API都支持一个请求内带多条system消息,效果等同于拼接成一条长System消息。这样做的最大好处是锚点和摘要可以被独立更新,不用每次重新拼接整个系统指令。只要你的模型供应商支持这个能力,强烈建议用这种分离写法。

3.3 一个多轮示例,看看context-mode实际怎么工作

说再多不如跑一遍。下面我把一个客服场景的对话喂进上面的ContextManager,观察每一步的context结构。

场景:用户报修一台打印机。

  • 第一轮:user表示“我买了X型号打印机,打印出来有横向条纹”,assistant回复“建议清洁玻璃扫描面板试试”
  • 第二轮:user说“已经清洁过了,还是有条纹”,assistant回复“建议让我看下打印样张”
  • 第三轮:user发来一张图的描述“样张上黑道间距约2厘米”,assistant回复“怀疑感光鼓问题,清洁感光鼓后再测试”
  • 第四轮:user问“清洁感光鼓的具体步骤是什么”

前3轮的处理很简单,就是add_message原样追加。真正的关键在第四轮。因为此时历史总字符可能逼近了max_context_chars,_ensure_budget会触发压缩逻辑,把第一轮和第二轮的对话摘要成“用户上报X型号打印机横向条纹问题,已清洁扫描面板未解决”。

最终发给模型的结构是:

[ {"role": "system", "content": "你是XXX品牌打印机技术支持助手,回答要简洁、分步骤"}, {"role": "system", "content": "需始终记住的用户信息:设备型号为X型号;故障为横向条纹;已尝试清洁玻璃"}, {"role": "system", "content": "[历史摘要] 用户上报打印机横向条纹问题,已引导清洁扫描面板但无效,检查样张发现黑道间距约2厘米"}, {"role": "user", "content": "清洁感光鼓的具体步骤是什么"} ]

模型拿到这个上下文,就不会再问“你的打印机是什么型号”这种蠢问题了,因为它已经被告知了设备型号、已尝试过的操作和样张的关键特征。这就是context-mode的意义——它不提升模型能力,但让模型把能力用在正确的方向上。

4. 落地时最常踩的四个坑

4.1 上下文污染:模型“听过太多不该听的话”

上下文污染是我踩得最狠的坑。表现在:模型回答风格漂移、突然提到之前对话里毫不相关的内容、或者被历史里的错误信息带着走。

举个例子,用户在前面对话里随口说了一句“我同事说这个机器噪音很大”,这本是一条无关信息。但如果你的历史全量拼接,模型可能会在下一次回答时莫名强调“液晶屏噪音”——因为它以为“噪音很大”是当前需要处理的主题。

排查思路其实很清晰:把发送给模型的消息全部打印出来,从头到尾读一遍。凡是和当前任务无关的内容,都是污染源。解决办法就是在add_message之前做一道“过滤闸门”,判断这条消息是否属于当前任务主线;不属于的直接丢弃或转入长期记忆侧边栏,不进对话上下文。

4.2 关键信息被挤出窗口,模型当机

滑动窗口最致命的副作用,是用户早期提供的核心信息被悄悄抹掉。比如用户第一轮说“我在MacOS 14.2.1上跑不起来”,到了第八轮,这条信息早就滑出窗口了,模型开始给出“Windows下执行步骤”的答案。

这就是我前面引入anchor_fact的原因。你需要一个规则:识别那些“贯穿全对话、变更频率低、一旦丢失就会让回答方向出错”的信息,主动提取成锚点。我常用的提取逻辑很简单——如果用户消息里出现型号、版本号、年龄、预算、地址、症状这类强实体词,就调一次轻量抽取,把实体和值锚定进System层。

4.3 Token成本失控,账单吓人

不做上下文管理的最直接代价是:对话轮数增加,请求Token线性上涨。我给你算笔账:平均每轮往返消耗800 Token,一个用户聊50轮,就是40000 Token。如果一天有1000个用户这么聊,就是4000万Token。按现在主流模型每百万Token几十块的价格算,光上下文传输一个月就是上万元。而加了context-mode之后,单请求Token被强行限制在预算内,成本立刻从线性增长变成有上限的常量。

这里补一个细节:摘要压缩本身会调用一次LLM,宜选用便宜的小模型做摘要,不必用旗舰大模型。摘要质量不会差太远,但成本会低一个数量级。

4.4 摘要失真,越压越离谱

摘要压缩不是无损的,压缩过程中会丢失细节,甚至产生“幻觉摘要”——模型把没有发生过的操作写进了摘要。我遇到过一次:摘要里赫然写着“用户已经更换了主板”,而用户实际只说“考虑过换主板”。结果模型后续的回答全部建立在错误前提上,彻底跑偏。

应对办法有两个。第一,摘要生成时Prompt里强制加一条指令:“只总结用户明确说过的事实,不得推断、补充、猜测。”第二,摘要存进System层之后,如果后续用户的说法和摘要冲突,要以用户当前说法为准,代码里给最近一轮user消息更高的优先级,防止模型被旧摘要带偏。

结合这些问题,我给一个排查速查表,线上出问题直接对着查。

现象大概率原因处理方式
回答包含无关历史内容上下文污染加入消息过滤闸门,清理无关历史
回答忽略早期关键信息关键信息滑出窗口使用anchor_fact锚定强实体信息
Token消耗随轮数爆炸未做预算控制设置max_context_chars,强制压缩
摘要内容与事实不符摘要模型幻觉摘要Prompt加约束,以最近消息为准
请求报超限错误预算计算未留余量乘0.8安全系数,预留输出Token
同一批历史反复被摘要缺少压缩状态标记摘要后立即裁剪,避免重复触发

我个人在这些项目里反复体会最深的一点是:context-mode不是一次性做完就结束的东西,它是需要持续根据真实对话日志去调优的模块。每次版本迭代,我都建议拉一批线上真实对话、把build_context的输出打印出来看一遍,你会发现很多问题根本不用等模型答错就能预判到。

如果后续要在这个方向继续深入,我建议下一步做“分层长期记忆”——把用户信息抽到独立的UserProfile存储,把对话摘要按时间窗口分片,让模型按需检索而非全量携带。这套东西再往上走,就是目前业界说的Memory系统和Agent记忆管理了,但核心思想和我上面讲的三层结构一脉相承。先把手里的context-mode跑稳,再谈更复杂的记忆架构,这个顺序不会错。

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

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

立即咨询