资讯动态

大模型上下文管理:用context-mode控制Token预算与多轮对话记忆

发布时间:2026/10/10 5:42:31 来源:尧图企业网站定制
写这篇文章的起因是我最近在给一个客服机器人项目做优化时反复被同一个问题折磨模型明明能力很强却在对话第三轮之后开始“失忆”把用户刚说过的关键信息忘得一干二净。查来查去问题不在模型而在我们喂给它内容的方式。折腾了几天后我把这套处理手段收敛成了一个模式内部管它叫 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 Token4400 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跑稳再谈更复杂的记忆架构这个顺序不会错。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑