上下文管理实战:从滑动窗口到检索增强的工程化方案
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 阶段看不出来上线后一定会遇到。第三条状态对象的字段宁少勿多。我一开始恨不得把所有信息都塞进状态结果序列化后一大坨既占预算又让模型分心。后来只保留当前任务真正需要的字段效果好很多。第四条定期回放历史会话做回归测试。上下文管理的改动很容易引入隐蔽的回归比如改了拼接顺序导致某类问题答错。我一般会存一批典型会话每次改动后跑一遍对比输出有没有异常。这套上下文管理的思路我在几个不同规模的项目里都用过核心逻辑是通用的具体参数得根据你的场景调。真正上手之后你会发现它不是什么高深的技术更多是细致的工程活把预算算清楚、把优先级排明白、把边界情况兜住基本就稳了。