1. 上下文管理到底在管什么
第一次听到“上下文管理”这个词,很多人会下意识觉得它是个很虚的概念,像是某种管理学的黑话。但如果你写过稍微复杂一点的对话系统、智能体应用,或者做过长文档问答,你就会发现这四个字背后全是实打实的工程问题。简单说,上下文管理就是决定“在有限的窗口里,到底该塞哪些信息进去”。它要解决的核心矛盾是:模型能记住的内容长度是有限的,但用户希望它记住的东西几乎是无限的。
我拿一个最常见的场景举例。你做了一个客服助手,用户第一句话问“你们家那款蓝色的保温杯多少钱”,第二句问“有优惠吗”,第三句问“那红色的呢”。如果系统只把最后一句话丢给模型,模型根本不知道“那红色的”指的是什么。这时候就需要把前面的对话历史作为上下文一起送进去。但问题来了,如果这个用户聊了三百轮,你不可能把三百轮全塞进去,一是超出窗口限制,二是成本高得离谱,三是无关信息太多反而会干扰模型判断。所以上下文管理要做的,就是在这三者之间找平衡:相关性、成本、窗口容量。
这套东西适合谁来学?我个人的判断是,只要你涉及以下任意一种工作,都值得花时间研究:做对话机器人或智能客服的、做知识库问答的、做多轮任务型智能体的、做长文档摘要和分析的,甚至做代码补全工具的。哪怕你只是用现成的接口搭一个小应用,理解上下文管理的机制也能让你少踩很多坑,省下不少调用费用。
我见过太多项目,前期 demo 跑得挺顺,一上真实场景就崩,十有八九是上下文管理没做好。要么是历史对话越堆越长导致响应变慢,要么是关键信息被后来的闲聊挤出了窗口,要么是每次请求都带一大堆重复内容白白烧钱。这些问题不是模型能力不够,而是工程层面没设计好。接下来我会把这套东西拆开,从整体思路到具体实现,再到排查技巧,尽量讲透。
2. 整体设计思路与方案选型
2.1 为什么不能“全塞进去”这么简单
很多人第一反应是:既然模型支持长上下文,那我全塞进去不就行了?这个想法在理论上没错,但实际落地会遇到三个硬约束。
第一个约束是成本。上下文长度和调用费用基本是线性关系,你塞进去的每一个字都要花钱。假设一次对话平均带 8000 字上下文,一天一万次调用,这个成本累积起来相当可观。而如果通过合理的上下文管理把平均长度压到 2000 字,费用直接降到四分之一。
第二个约束是延迟。上下文越长,模型处理时间越久,用户等待就越长。在实时对话场景里,超过两三秒的等待就会明显影响体验。
第三个约束,也是最容易被忽略的,是注意力稀释。上下文里信息太多太杂,模型反而抓不住重点。这就像你跟一个人交代事情,一口气说五十件无关紧要的小事,最后他可能连最重要的那件都记混了。所以上下文管理的目标不是“塞满”,而是“塞准”。
2.2 三种主流策略的取舍
在实际项目里,上下文管理大致可以归为三种策略,它们不是互斥的,通常是组合使用。
策略一:滑动窗口。只保留最近 N 轮对话,超出的直接丢弃。这是最简单的做法,实现成本极低,适合闲聊类场景。但它的致命伤是会把早期的重要信息一起丢掉。比如用户第一轮说了自己的订单号,聊了二十轮之后要查物流,订单号早被滑出去了。
策略二:摘要压缩。把历史对话定期做一次摘要,用简短的文字概括之前发生了什么,然后把摘要加上最近的原始对话一起送进去。这样既保留了长期记忆,又控制了长度。代价是需要额外调用一次模型做摘要,增加了复杂度和成本,而且摘要本身可能丢失细节。
策略三:检索增强。把所有历史信息存起来,每次根据当前问题去检索最相关的片段,只把相关片段放进上下文。这是目前长对话和知识库场景里最常用的方案。它的核心难点在于检索质量,检索不准,后面全白搭。
我一般建议的组合是:近期对话用滑动窗口保留原文,远期对话用摘要压缩,同时用检索机制把关键实体和历史事实捞回来。这三层配合起来,基本能覆盖绝大多数场景。
2.3 一个容易被忽视的设计原则
有个原则我想单独强调:上下文管理要区分“事实”和“对话”。对话是流动的,可以压缩、可以丢弃;但事实是结构化的,比如用户的姓名、订单号、偏好设置、当前任务状态,这些东西不应该混在对话历史里被滑动窗口冲走,而应该单独存成一个结构化的状态对象,每次请求都稳定地带上。
我踩过这个坑。早期做的一个任务型助手,把用户填的地址信息混在对话里,结果用户多聊了几句,地址就被挤出去了,助手又开始重新问地址,体验极差。后来把关键槽位抽出来单独管理,问题立刻消失。这个思路其实就是把“上下文”分成两层:一层是易变的对话流,一层是稳定的状态机。
3. 核心细节解析与实操要点
3.1 窗口预算怎么分配
上下文窗口是有限资源,得像个预算一样分配。我的习惯是把它切成四块:系统指令区、状态区、检索区、对话区。
系统指令区放角色设定、输出格式要求、安全约束,这部分基本固定,占比控制在 10% 到 15%。状态区放结构化的关键信息,比如用户 ID、当前任务阶段、已确认的槽位,占比 10% 左右。检索区放从历史或知识库捞回来的相关片段,这部分弹性最大,占比 30% 到 40%。剩下的留给对话区,也就是最近的原始对话。
这个比例不是死的,要根据场景调。比如知识库问答,检索区可以放大到 50%;纯闲聊,对话区可以占 70%。关键是心里要有这本账,知道每一块大概占多少,超了就从优先级最低的地方砍。
提示:一定要给输出留出空间。很多人算预算时只算输入,忘了模型还要生成回复。如果输入把窗口占满了,输出就会被截断,表现为回复说到一半突然没了。
3.2 摘要压缩的触发时机与写法
摘要不是越频繁越好。太频繁,成本高且容易丢细节;太稀疏,又起不到压缩作用。我的经验是按 token 数触发,而不是按轮数。比如设定当对话区超过 3000 token 时,就把最老的一半对话拿去做摘要,合并进已有的历史摘要里。
摘要的提示词写法很关键。不要写“请总结以下对话”这种笼统的指令,而要明确要求保留哪些要素。我常用的模板是这样的:
请将以下对话压缩成简洁的摘要,必须保留: 1. 用户提到的所有具体数字、订单号、日期、金额 2. 用户明确表达的偏好和禁忌 3. 已经确认的结论和待办事项 4. 尚未解决的问题 不要保留寒暄、重复确认和无关闲聊。这样写出来的摘要,信息密度高,后续检索和拼接都好用。实测下来,一段 2000 token 的对话能压到 200 token 左右,压缩比接近 10:1,而且关键信息基本不丢。
3.3 检索环节的实操细节
检索增强听起来高大上,但落地时细节决定成败。首先是切块粒度。切得太碎,一个完整意思被拆散,检索出来是残句;切得太粗,一块里混了多个主题,检索精度下降。我一般按语义段落切,每块 200 到 500 字,块之间留一点重叠,避免边界信息丢失。
其次是检索数量。不是召回越多越好。召回 20 条,可能 15 条是噪音,反而干扰模型。我通常先召回 10 条,再用一个轻量的重排序步骤筛出前 3 到 5 条。这个重排序可以用简单的关键词匹配,也可以再调一次模型打分,看你对延迟的容忍度。
最后是拼接格式。检索回来的片段要带上来源标记,让模型知道这是参考资料而不是用户说的话。我习惯用这样的格式:
[参考资料1] 用户在上周三提到过订单号 A12345,要求发往城东。 [参考资料2] 用户表示对蓝色款有偏好,不喜欢红色。这样模型能清楚区分“用户当前说的”和“历史上说过的”,减少混淆。
3.4 状态对象的字段设计
状态对象是上下文管理里最稳的一块,值得好好设计。我一般会包含这几类字段:身份类(用户标识、会话标识)、任务类(当前意图、任务阶段、待确认槽位)、事实类(已确认的关键信息)、偏好类(用户的习惯和禁忌)。
这些字段每次请求都序列化成简短文本带上,不参与滑动窗口的淘汰。序列化时要注意精简,别把整个 JSON 原样塞进去,只挑当前任务相关的字段。比如当前任务是查物流,那就只带订单号和物流偏好,用户的其他历史偏好可以先不带。
注意:状态对象要设置过期和清理机制。一个会话结束后,相关状态该归档归档,该删除删除,不然越积越多,既占存储又可能造成信息串扰。
4. 实操过程与核心环节实现
4.1 从零搭一个上下文管理模块
假设我们要给一个多轮问答助手加上下文管理,我会按下面的顺序来搭。
第一步,定义数据结构。核心是三个东西:一个存原始对话的列表,一个存历史摘要的字符串,一个存结构化状态的字典。对话列表里每条记录包含角色、内容、时间戳、token 数。
第二步,实现 token 计数。不同模型的分词方式不一样,不能简单按字数估算。要么用对应模型的分词器,要么用经验公式(中文大约 1 字 1 token,英文大约 4 字符 1 token)先顶着,但正式上线前一定要换成准确的分词器。
第三步,实现预算分配函数。输入是当前所有可用内容,输出是拼好的最终上下文。逻辑是:先放系统指令,再放状态,再按优先级放检索结果,最后从新到旧放对话,直到接近预算上限。
第四步,实现摘要触发。每次请求结束后检查对话区 token 数,超过阈值就异步触发摘要,把老对话压缩掉。
第五步,实现检索索引。把历史对话和摘要都切块存入向量库,每次新问题来了先检索。
4.2 关键参数的计算过程
这里我拿一个具体例子算一遍,方便你理解预算分配。假设模型窗口是 8000 token,我们给输出留 1000 token,那输入预算是 7000 token。
按前面的比例:系统指令 800,状态 600,检索区 2500,对话区 3100。对话区 3100 token 大概能放多少轮?中文对话一轮平均 150 token 的话,大约 20 轮。所以当对话超过 20 轮,或者 token 超过 3100,就该触发摘要了。
摘要触发后,把最老的 10 轮(约 1500 token)压成 150 token 的摘要,对话区立刻腾出 1350 token 的空间。这样循环下去,对话区始终维持在合理长度,而历史信息以摘要形式保留。
这个计算不是一次性的,要根据实际运行数据调整。我建议上线后记录每次请求的各区实际占用,跑一周就能看出比例是否合理。
4.3 拼接顺序的讲究
上下文拼接的顺序会影响模型的理解效果。我的经验顺序是:系统指令 → 状态 → 历史摘要 → 检索片段 → 最近对话 → 当前问题。
为什么这么排?系统指令放最前面,是给模型定基调。状态紧随其后,让模型先知道“现在是什么情况”。历史摘要和检索片段属于背景资料,放在对话之前。最近的原始对话放最后,紧挨着当前问题,这样模型对最近发生的事印象最深。当前问题放最末尾,符合模型的注意力习惯,通常对末尾内容更敏感。
我试过把检索片段放在最后,结果模型经常把参考资料当成用户说的话来回应,闹出笑话。调整顺序后就正常了。
4.4 一个完整的请求处理流程
把上面的东西串起来,一次请求的完整流程是这样的:
- 收到用户新问题,先做意图识别,更新状态对象。
- 用新问题去检索历史索引,召回相关片段并重排序。
- 检查对话区 token 数,必要时触发摘要压缩。
- 按预算分配和拼接顺序组装最终上下文。
- 调用模型生成回复。
- 把本轮问答追加进对话列表,更新索引。
- 异步检查是否需要摘要,需要就后台处理。
这个流程里,第 3 步和第 7 步是很多人会漏掉的。摘要如果放在请求主流程里同步做,会明显增加延迟,所以一定要异步化。
5. 常见问题与排查技巧实录
5.1 模型“失忆”了怎么办
最常见的抱怨就是模型忘了之前说过的信息。排查时按这个顺序来:先确认信息是否真的还在上下文里,打印出实际发送的内容看看;如果不在,检查是不是被滑动窗口淘汰了,或者摘要时丢了;如果在但模型还是没用到,那可能是信息位置太靠前被稀释了,试着把它挪到靠近末尾的位置,或者在状态区再强调一遍。
我遇到过一次,用户的名字在摘要里被压没了,因为摘要提示词没强调保留人名。后来在提示词里明确加上“保留所有人名和称谓”,问题解决。
5.2 响应越来越慢
延迟升高通常有两个原因:上下文越来越长,或者检索环节太重。先看上下文长度曲线,如果随对话轮数线性增长,说明摘要没生效或者阈值设太高。再看检索,如果每次召回几百条还做重排序,那延迟肯定下不来。把召回数降下来,重排序用轻量方案,通常能明显改善。
5.3 成本失控
成本问题八成出在上下文太长。我建议做个监控,记录每次请求的输入 token 数和费用,按天汇总。如果发现平均值远高于预期,就去查是哪个环节在膨胀。常见的是检索片段带太多、摘要没及时触发、或者状态对象序列化时带了冗余字段。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 模型忘记早期信息 | 被窗口淘汰或摘要丢失 | 打印实际上下文检查 | 关键信息移入状态区 |
| 响应延迟高 | 上下文过长或检索过重 | 看长度曲线和检索耗时 | 降召回数、异步摘要 |
| 成本超预期 | 输入 token 膨胀 | 按天统计平均 token | 精简检索和状态字段 |
| 答非所问 | 检索片段干扰 | 检查召回相关性 | 加重排序、减召回数 |
| 回复被截断 | 输入占满窗口 | 算输入输出总预算 | 给输出预留空间 |
5.5 几条踩坑换来的经验
第一条,永远打印实际发送的上下文。调试上下文问题时,光看代码没用,必须看真正发出去的那串文本。我习惯在开发环境把每次请求的完整上下文落盘,出问题直接翻记录。
第二条,摘要和检索都要有兜底。摘要失败时不能阻塞主流程,检索为空时也要能正常回答。这些边界情况在 demo 阶段看不出来,上线后一定会遇到。
第三条,状态对象的字段宁少勿多。我一开始恨不得把所有信息都塞进状态,结果序列化后一大坨,既占预算又让模型分心。后来只保留当前任务真正需要的字段,效果好很多。
第四条,定期回放历史会话做回归测试。上下文管理的改动很容易引入隐蔽的回归,比如改了拼接顺序导致某类问题答错。我一般会存一批典型会话,每次改动后跑一遍,对比输出有没有异常。
这套上下文管理的思路,我在几个不同规模的项目里都用过,核心逻辑是通用的,具体参数得根据你的场景调。真正上手之后你会发现,它不是什么高深的技术,更多是细致的工程活,把预算算清楚、把优先级排明白、把边界情况兜住,基本就稳了。