前阵子调试一个多轮问答助手,二十轮对话之后模型突然把用户早就设定好的“不喜欢铺张、预算控制在三千以内”这个偏好给忘了,转头推了一个两万块的方案。那会儿我第一反应是模型不行,后来翻日志才发现根本不是模型的锅——是我压根没做上下文管理。整个对话历史一股脑往窗口里塞,早期的关键约束被淹没在一堆无关闲聊里,模型想记住都难。打那之后我开始认真研究context-mode,也就是上下文管理模式,并且把它作为所有LLM应用的必选项。这篇就把我这一路的理解、踩坑以及最终落地的方法完整写出来,给正在被上下文问题折磨的同学一个可直接参考的方案。
这个内容不是什么高深论文,就是一套非常实际的工程策略:告诉你怎么把海量对话信息分类、压缩、检索、淘汰,让模型在长对话和复杂任务里始终保持“清醒”。适合刚写完Hello World的LLM新手,也适合已经在做Agent但被Token成本和效果漂移搞到头大的老手。接下来先从一次典型的失控现场说起。
1. 先从一次“翻车”说起:context-mode到底要解决什么问题
1.1 一次典型的上下文失控现场
我把那次事故的日志还原一下。我的助手任务是帮用户做旅行规划,用户在第2轮说了“预算总共两万,不想太累”,第5轮说了“住宿偏好带浴缸的”,第18轮问了“东京和镰仓怎么串路线合理”。正常来说,第25轮用户说“按刚才说的帮我出个最终行程”时,模型应该综合上面所有信息。结果实际输出完全不理预算和偏好,推荐了全程高档酒店加特种兵式行程。
看日志时我发现了经典问题:第18轮前后,用户聊了一堆“东京迪士尼排队大概多久”“台场高达像真不真”这类话题,加上系统输出了一段长表格,整个上下文已经超过模型窗口的一半。预算和住宿偏好在Token序列里已经被挤到了几乎看不见的位置,注意力机制根本顾不上那么远的信息。更关键的是,这些历史消息虽然还在窗口里,但它们的“有效密度”极低——真正有用的决策约束被无关闲聊稀释了。
这就是没有context-mode的后果:上下文窗口不等于有效信息容量,塞得进去的跟用得上的之间隔着一道鸿沟。很多工程师第一反应是“那我换更大窗口的模型”,但窗口越大,噪声越多,成本越高,问题只是被暂时掩盖了而已。
1.2 context-mode的定义:不是功能开关,而是一整套策略
现在行业里提到context-mode,并没有一个公认的官方定义。我基于自己的工程实践,把它总结成一句话:围绕特定任务场景,对上下文信息进行分类、压缩、检索与淘汰的完整管理策略。它不是模型自带的一个开关,而是我们在应用层设计的一套规则。
它可以拆成三个层次来看:
- 会话层(Session Context):管“这次对话从头到尾聊了什么”。核心解决连续性问题,让模型不忘记前面轮次的信息。
- 任务层(Task Context):管“当前这个业务目标进行到哪一步了”。核心解决结构性问题,让模型知道现在该干什么、已经完成了什么。
- 记忆层(Memory Context):管“这个用户之前积累过的偏好和事实”。核心解决个性化问题,让模型在跨会话场景里记住人、记住偏好、记住规则。
我见过很多团队的代码里把全部历史消息原封不动塞进Prompt,这其实连第一层会话管理都没做好,更别说任务层和记忆层了。真正好用的context-mode,是在一次请求发出之前,先想清楚这三个问题:这次任务需要哪些上下文?哪些上下文可以不要?哪些上下文需要先做压缩或检索再放进来?想清楚了再拼Prompt,效果天差地别。
2. 为什么说context-mode是Agent应用的“水电基础”
2.1 上下文窗口是有限资源:省Token就是省成本
先算一笔账。假设你的应用每天处理一万次请求,模型输入价格按每百万Token大约几十块钱算,一次请求多塞2000个无用Token,一天就是2000万Token的浪费,一个月下来就是几十万Token的额外开销,折成人民币那是相当可观的成本。而很多团队为了让上下文“够用”,习惯性把整个历史记录带上,这个浪费在企业级应用里会被放大得非常夸张。
成本问题还不只是钱。窗口有上限,比如有的模型是128K Token,但你一旦把能塞的都塞满,留给模型“思考”的空间就小了。一些模型在输入接近窗口上限时,推理速度和准确率都会有肉眼可见的下降。我实测过一个场景:同样一道逻辑题,输入占用20%窗口时一次答对,占用90%窗口时连续两次都答偏。上下文不是无限的保险箱,而是需要精打细算的有限资源。
context-mode的核心价值之一,就是对Token做预算管理。就像装修房子,面积固定,你既要放沙发又要放书桌,那就得规划好每个区域占多少空间,而不是把家具全堆进去再看怎么下脚。我把这个Token预算拆分成系统提示、工作区、历史摘要、检索记忆等几个池子,哪个池子超标了就裁剪或压缩哪个,这样既保护核心信息,也控制成本。
2.2 上下文噪声会直接拉低任务准确率
这是我最想强调的一点。很多人以为模型拿到的信息越多,回答越准确,实际上恰恰相反。斯坦福和UC Berkeley有一项被反复引用的研究叫“Lost in the Middle”,结论是模型对输入中间位置信息的利用能力明显弱于开头和结尾。如果关键信息被埋在超长上下文的中间,模型基本等同于没看到。
拿我之前那次旅行规划的翻车来说,预算和住宿偏好恰好就沉在中间位置。后面我又做了一组对比实验:同样的任务,一组把无关对话全部塞进去,另一组先做压缩和关键信息抽取再塞进去,结果后者的约束遵守率从62%提升到了91%。差距就是这么明显。
为什么?因为Transformer的注意力机制本质上是在所有Token之间做软关联,上下文越长,关键Token的注意力权重被稀释得越厉害。你想让模型“记住”一条信息,就得让这条信息在输入里保持足够的显著性。直接删掉无关内容,比让模型自己从一堆噪音里找重点可靠得多。这也是context-mode存在的根本原因——我们先帮模型做筛选,而不是让模型自己扛。
2.3 没有模式的管理,多轮对话会“越聊越傻”
长对话退化是一个渐进的过程。前10轮通常还好,20轮开始出小错,30轮之后可能连用户最基本的要求都开始走样。我把这种现象称为“上下文疲劳”。它产生的原因有三个:
- 信息稀释:早期关键信息被后期大量无关内容掩盖。
- 约束冲突:某些场景下新旧信息互相冲突,模型不知道该信哪个。
- 方向漂移:任务进行中用户改过需求,但原始指令还留在上下文里,模型就可能依旧按旧方向走。
这三个问题靠“把历史都塞进去”完全无解。context-mode的做法是对上下文做“新陈代谢”:该保留的保留,该压缩的压缩,该废弃的废弃,该从记忆库重新捞回来的捞回来。会话级模式负责让对话连续,任务级模式负责控制业务状态,记忆级模式负责承载长期偏好。三层各管一段,互相配合,把“越聊越傻”变成一个可监控、可调试的工程问题。
3. 三种核心模式与它们的应用边界
3.1 会话模式:滑动窗口配摘要压缩
会话模式是最基础的一层,它要解决的是“对话连续性”。最朴素的做法是维护一个轮次列表,超过一定数量就把最早的几条丢掉。但这会带来一个问题:早期信息可能包含用户明确声明的偏好,直接丢就会失忆。
所以实践中我几乎不用纯滑动窗口,而是用“滑动窗口+滚动摘要”的组合:
- 设定窗口大小,比如保留最近20轮完整内容。
- 当新消息进来导致窗口溢出时,把最早的10轮抽取一份摘要。
- 摘要在下次请求时作为一条“历史回顾”放在上下文靠前的位置。
- 原始消息从窗口里移除,只保留摘要。
这个做法等于给上下文做了“存档”:最近细节完整,远期只留要点。要点包括什么?我通常让摘要至少覆盖四类信息:用户主动声明过的偏好、已经确认的决策、尚未解决的待办、以及任务相关的硬性约束。至于吃了几顿饭、聊了几次天气这种信息,直接丢弃就好。
摘要的生成本身也需要消耗Token,所以不是每一轮都做,而是设一个阈值或者按Token溢出触发。我习惯按Token量触发:历史总Token超过某个水位线才执行压缩。压缩完还要注意把新摘要重新写回上下文顶部,确保它在模型的注意力范围前端。摘要丢失细节这个问题,我后面会在排查章节单独讲。
3.2 任务模式:结构化状态机锁定业务进度
如果会话模式管的是“聊了什么”,任务模式管的就是“活儿干到哪了”。我见过太多翻车案例:用户已经走到下单确认步骤了,模型还在问“你对这个产品还有什么要求吗”,这就是因为模型根本不知道任务进行到哪一步。
任务模式的核心是把业务流程抽象成一个状态机。状态包含四个要素:
- 当前节点:比如“收集需求→生成方案→确认预算→下单支付”中的某一个环节。
- 已收集字段:比如目的地、天数、预算、出行人数,哪些已经有值,哪些还是空的。
- 剩余必填项:业务流程中还没有被满足的硬性条件。
- 关键上下文标签:当前状态对应的重点信息索引。
在拼Prompt的时候,任务状态不是简单地追加在历史后面,而是放在系统提示之后的“任务工作台”区域,用结构化字段表示。模型每次收到请求,第一眼看到的是当前任务的进度和待办,然后才是最新对话。我实测这种设计对多步骤Agent的准确率提升非常明显,尤其是“自动规划工具调用顺序”的场景,状态机制比让模型自己从对话里猜快得多也稳得多。
任务模式还有一个好处:它天然解决了“用户中途改需求”的难题。因为字段是可覆盖的,用户改了预算,任务工作台里那个“预算”字段直接更新成新值。只要字段更新正确,模型就会按新值走,不会被旧记录的残留干扰。这是纯历史对话做不到的。
3.3 记忆模式:向量检索托底长期记忆
会话模式护住了短期连续,任务模式锁住了业务进度,但还有一个场景需要第三层:用户隔了一周回来继续对话,或者这个Agent要服务多个长期用户,每个人的偏好不同。这时候你需要的是记忆模式。
实现上我采用“双层记忆”架构:
- 长期档案库:用向量数据库存储用户在历次会话中暴露出的偏好和事实,比如“用户是素食主义者”“用户偏好安静环境”“上次定制方案选了轻奢风”。每一条记忆都带时间戳和来源会话ID。
- 短期记忆区:当前会话里识别出的新增偏好,先临时保存在这里,等会话结束时再归入长期档案。
每次请求到来时,根据当前任务的关键词去向量库做一次Top-K检索,召回和当前场景最相关的记忆,拼进Prompt。这个做法比“把所有历史会话全部检索一遍”便宜得多,效率也高得多。召回条数我通常控制在5到10条,再多就容易引入噪声和互相矛盾的记忆。
记忆模式最大的坑是“记忆污染”:用户某次随口说了一句“这个也行吧”,结果模型把它当成了长期偏好,之后每次推荐都默认这个方向。解决方案是在记忆写入之前加一道置信度判断。我现在的标准是:连续两次以上出现的相同偏好,或者在会话中被用户明确强调过的信息,才允许写入长期档案。单次出现的模糊表达,最多放在短期记忆区,不升级。
3.4 模式选择的判断标准
三层模式不是每次请求都要全上。全上会带来Token浪费和复杂度爆炸。我的选择逻辑非常简单:
- 只有一轮对话、没有后续依赖?只上会话模式,甚至直接用当前问题拼Prompt。
- 多轮连续对话、任务目标明确?会话模式加任务模式。
- 跨会话、需要记住用户长期偏好?三层全上,但记忆层只做轻量Top-K召回。
按需组合,不按套路全堆。上下文管理的复杂度应该跟业务场景的复杂度成正比,而不是跟工程师的兴奋程度成正比。我见过有些项目一上来就搞三个向量库加五层记忆,结果模型被各种召回内容搞得更乱了。从最少的模式组合起步,跑通基础效果,再逐步加层,这个顺序我推荐给所有人。
4. 从零搭建一套context-mode:完整落地流程
4.1 设计上下文数据结构
我先给出一套我实际在用的数据结构。这个设计把“模式选择”变成了一个可执行的计算流程,而不是每次靠拍脑袋组装Prompt。以下代码用Python展示,跑通逻辑后你可以平移到你自己的语言和框架里。
from dataclasses import dataclass, field from typing import Optional from enum import Enum class ContextLevel(Enum): SESSION = "session" # 会话级:短期连续 TASK = "task" # 任务级:业务进度 MEMORY = "memory" # 记忆级:长期偏好 @dataclass class Message: role: str # system / user / assistant content: str timestamp: int @dataclass class TaskState: node: str # 当前业务节点 fields: dict # 已收集的字段值 required: list # 剩余必填项 updated_at: int @dataclass class ContextBundle: level: ContextLevel system_prompt: str # 系统提示词 task_state: Optional[TaskState] recent_messages: list = field(default_factory=list) history_summary: str = "" # 滚动摘要 memory_hits: list = field(default_factory=list) # 记忆召回结果这里的关键是ContextBundle这个打包对象,它在一次请求中承载了全部要发给模型的上下文信息。组装顺序也写在代码的字段顺序里:先是system_prompt,再是task_state,然后是history_summary和memory_hits,最后才是recent_messages。为什么要这个顺序?因为前面讲了“Lost in the Middle”——你把最重要的状态信息放在Context开头,模型对它的注意力最强。这也是我几次调试之后才定下来的顺序。
4.2 实现模式分发与上下文组装
数据结构定义好之后,接下来是组装逻辑。我写了一个ContextManager类,按不同模式拼接最终Prompt。核心思路是根据当前对话轮次和任务状态判断该启用哪层模式,然后逐层补充信息。
class ContextManager: def __init__(self, system_prompt, window_size=20, compress_threshold=8000): self.system_prompt = system_prompt self.window_size = window_size self.compress_threshold = compress_threshold self.messages = [] self.summary = "" self.task_state = None self.memory_store = None # 向量库客户端,按需注入 def add_message(self, msg: Message): self.messages.append(msg) self._maybe_compress() def _maybe_compress(self): total_tokens = self._estimate_tokens(self.messages) if total_tokens > self.compress_threshold and len(self.messages) > self.window_size: overflow = self.messages[: len(self.messages) - self.window_size] self.summary = self._summarize(overflow) self.messages = self.messages[-self.window_size:] def build_bundle(self, task_keywords: list = None) -> ContextBundle: bundle = ContextBundle(level=ContextLevel.SESSION, system_prompt=self.system_prompt) bundle.task_state = self.task_state bundle.recent_messages = self.messages[-10:] bundle.history_summary = self.summary # 如果启用了记忆层且有关键词,做一次向量召回 if self.memory_store and task_keywords: bundle.memory_hits = self.memory_store.search(task_keywords, top_k=5) bundle.level = ContextLevel.MEMORY # 如果任务状态存在,层级升级为任务模式 if self.task_state is not None: bundle.level = ContextLevel.TASK return bundle def to_prompt(self, bundle: ContextBundle) -> str: parts = [f"[System]\n{bundle.system_prompt}"] if bundle.task_state: parts.append(f"[Task State]\nnode={bundle.task_state.node}\n" f"fields={bundle.task_state.fields}\n" f"required={bundle.task_state.required}") if bundle.history_summary: parts.append(f"[History Summary]\n{bundle.history_summary}") if bundle.memory_hits: mem_text = "\n".join( f"- {item.payload['keyword']}: {item.payload['content']}" for item in bundle.memory_hits ) parts.append(f"[User Memory]\n{mem_text}") if bundle.recent_messages: chat_text = "\n".join( f"{m.role}: {m.content}" for m in bundle.recent_messages ) parts.append(f"[Recent Chat]\n{chat_text}") return "\n\n".join(parts)_summarize方法我没有贴完整实现,它的核心是用一个较便宜的小模型把溢出内容压缩成要点,要求它尽量保留“用户明确表达的偏好、已确认的决策、未完成的待办”。_estimate_tokens则是对消息内容做粗略的Token估算,通常用字符数除以3或者调用现成的分词器。这两个方法规模不大,属于工程细节,你自己实现的时候可以自由发挥。
这个类的设计里有一个细节值得多说一句:build_bundle里面判断层级的顺序。我们先用记忆层关键词做召回,如果存在任务状态则升级为任务模式。这个顺序是有讲究的——任务状态代表“当前这件事的结构性进度”,它的优先级天然高于记忆召回,因为发散记忆不能覆盖业务流程的确定性。反过来,如果没有任务状态,纯闲聊场景就退化到记忆层加会话层的组合,也不浪费Token。
4.3 关键参数计算:Token预算怎么分
有了组装逻辑,接下来是预算分配。我以本地用到的128K窗口模型为例,讲一套我自己长期使用的分配比例。这个比例不是拍脑袋定的,是根据多轮实测总结出来的。
| 预算池 | 上限比例 | 实际Token数 | 用途说明 |
|---|---|---|---|
| 系统提示与任务状态 | 10% | 约13K | 角色设定、业务流程状态、必填字段 |
| 历史摘要与记忆召回 | 15% | 约20K | 滚动摘要、长期偏好、跨会话信息 |
| 最近对话 | 40% | 约50K | 当前轮次的最近消息,保留完整细节 |
| 模型输出预留 | 25% | 约32K | 留给模型思考和生成的空间 |
| 安全余量 | 10% | 约13K | 兜底,防止单次消息过大突发超限 |
这个分配表的核心逻辑就是给模型“留白”。很多团队把上下文塞到95%,留给模型输出的只有5%,结果模型经常生成到一半就截断,或者生成质量明显下降。我建议任何任务模式下,模型输出预留不低于窗口的15%,推理类任务最好到25%以上。
具体到你自己的模型,如果窗口是32K,把上表按比例缩放就对了。缩放时要注意一个细节:系统提示和任务状态这两块尽量不要缩减,因为它们是决策骨架。需要缩减的是“最近对话”,可以调小窗口轮数或者对最近消息做单条Token裁剪。记忆召回也可以从5条减到3条。省哪里都别省系统和任务状态,这是我在调参中反复验证过的一条铁律。
4.4 多场景验证方法
模式搭好之后,不能只看一两轮对话效果就上线,要做系统性的验证。我自己常用的方法是做一组“约束保持测试”:
- 构造一个需要多轮信息累计的任务,比如旅行规划。
- 在对话的前几轮分散埋入3到5个明确约束。
- 连续对话超过20轮,中间穿插大量无关闲聊。
- 在第25轮左右提出一个“整合所有约束”的要求。
- 检查模型输出是否全部命中约束。
我跑这个测试时,没有context-mode的情况下命中率大概60%到70%,上了会话加任务双模式之后能到90%左右,再加记忆层处理跨会话偏好,命中率基本稳定在95%以上。剩余5%的问题通常不在上下文结构,而在模型本身对冲突信息的取舍,这个要单独处理。
除了效果验证,还要做成本回归。对比相同对话量下,开启context-mode前后每次请求的平均Token数。我的经验是,会话模式的滚动摘要大约砍掉40%的历史Token占用,记忆模式的Top-K召回相比全量检索能省80%以上。省下来的这部分配额,我一律分给模型输出预留,效果提升非常直接。
5. 实战中的坑与排查技巧
5.1 症状:压缩摘要后,关键约束丢了
这是滚动摘要最常见的坑。明明原对话里有“预算不超过三千”,摘要生成之后模型却完全无视这条。我排查后发现,摘要是用便宜模型生成的,它在压缩时会倾向于保留对话中反复出现的词,而不是主动识别约束。如果用户只说了一次“预算不超过三千”,这条信息在摘要阶段很容易被省略。
我的解决办法有两个。第一,摘要生成时给模型明确的抽取规则,让它必须逐项检查“用户偏好”“硬性约束”“已确认决策”这三类信息是否存在,存在就必须保留,宁多勿少。第二,关键字段同时备份到任务状态里。这个就是双保险思路:摘要交给自然语言,结构化字段交给状态机,只要字段在,模型就一定能从任务工作台里读到约束。两条腿走路之后,这个坑我再没踩过。
5.2 症状:上下文截断出现严重语义漂移
窗口溢出时,有些实现直接从头暴力截断消息,结果模型突然不知道自己在做什么,回答风格也变了。我遇到过最典型的一次:模型从“帮用户整理行程”变成了“帮用户写旅游攻略”,因为截断后的上下文里充满了景点介绍,而任务指令被切掉了。
解决这个问题要区分两种情况。如果是会话模式,永远不要切断任务状态和系统提示这两层,宁可把最近对话收紧到5轮也不能把任务状态管道挤掉。如果是长文档输入,不要简单按Token位置截断,而是按语义段落截断,优先保留开头和结尾的语义。实在万不得已需要截断,那么被截掉的部分必须做一个摘要块放在整体上下文的最前面。这个摘要块能挽尊,让模型知道它的基本身份和任务方向。
5.3 症状:记忆检索结果污染主任务
记忆层做Top-K召回时,如果关键词太宽泛,比如用户说“帮我推荐餐厅”,而历史记忆里关于“美食偏好”的条目有几十条,一下子召回5条里面可能混着用户三年前的口味。我遇到过一次:用户之前说爱吃辣,后来因为胃不好改吃清淡了,但模型被旧记忆带着走,推荐了一堆川菜,被用户直接投诉。
排查之后我做了三件事:一是召回前先过滤近期活跃记忆,超过90天没被触发的记忆降权;二是每次召回后加一道“相关性打分”,用轻量模型判断召回内容跟当前会话主题是否相关,不相关的直接扔掉;三是字段冲突时以任务状态和最近对话为准,记忆层只做参考,不做判决。这个优先级顺序(最近对话>任务状态>记忆召回)是我踩了不少坑之后总结出来的原则,它保证模型遇到信息冲突时有明确的裁决规则,而不是自己瞎猜。
5.4 症状:上下文膨胀导致响应变慢
还有一种情况是context-mode本身出了问题:滚动摘要没触发,记忆召回条数过多,最近对话全量保留,导致每次请求的输入Token比裸奔时还多,模型响应从1秒慢到3秒,用户体验直线下降。
排查时需要先确认压缩阈值是不是设得太高。我之前设过12000 Token才压缩,后来实测在长对话场景里,超过8000 Token就开始出现性能和准确率的双重下降,阈值要往下拉。另外还要检查记忆召回是不是每次都在做。纯闲聊场景根本不需要向量召回,只有检测到任务关键字段变化时才触发检索,这样能把大量无意义请求的Token开销省下来。我给所有ContextManager都加了日志,把每次请求的Token分布打印出来,哪个池子超标一眼就能看到,比瞎猜有用得多。
5.5 调试习惯:我每次必做的三件事
最后分享三个我固定执行的调试习惯,它们帮我省下了大量排查时间。
第一,在所有上下文组装入口加一个debug开关,把最终拼好的Prompt完整打印出来。虽然这个Prompt可能很长,但肉眼检查一遍,能发现很多逻辑上没问题但实际放错了位置的上下文。
第二,做一次“盲测”——把模型输出盖住,只看输入,问自己一句:如果我是模型,光靠这份输入,能不能知道用户要什么、任务到哪一步、有什么硬性约束?如果连人都看不出答案,模型也多半不行。
第三,建立黄金用例集。挑20个典型对话场景,每次修改上下文策略后都跑一遍回归,不做这个,你根本无法确认一次优化是变好了还是把原来对的东西弄坏了。黄金用例集就是上下文的单元测试,是保证长期稳定性的底线。
我自己的项目稳定之后,会固定每周跑一次全量回归,顺便看看Token成本的变化趋势。Context-mode不是什么高端魔法,它就是一套把上下文这个核心资源管好的工程方法论。你只要愿意在结构上多花一点心思,效果上的回报绝对远超预期。这套方案我还在继续迭代,后续打算在任务状态里加入自动字段纠错和冲突检测,等有更多实践沉淀了再回来写续篇。