凌晨两点,线上客服机器人开始胡说八道。它明明拥有十万 token 的上下文窗口,对话记录也都完整塞进去了,可偏偏在用户问“刚才我说要退款的事你帮我办了没”时,回答成了“您没有申请过退款”。我翻日志发现,关键的那句退款诉求早就被后面几轮闲聊挤出了有效注意力范围。这不是模型笨,是上下文没做好。
这类问题在大模型应用里太常见了。模型本身的智商摆在那里,但喂给它的“上下文”长什么样,直接决定了它能不能答对。上下文工程,本质上是给大模型设计一套信息供给系统,包括窗口管理、消息编排、记忆压缩、检索增强这四件事。很多人把它当成“提示词写得好不好”的附属品,其实它更像系统的架构设计,属于比 prompt 工程更靠前、更能决定成败的一层。这篇笔记是系列第 21.8 篇,专门把这几块的核心策略拆开讲透,适合正在做 LLM 应用的开发者、正在调 RAG 流程的算法工程师,以及被越调越乱的上下文折腾过的所有同行。
1. 上下文窗口不是越大越好:预算制是被低估的一套反馈闭环
1.1 从一次生产事故说起:10万token上下文为何还是答非所问
我用过一次真实生产事故当开场,因为它足够说明上下文工程的残酷。当时我们接的是国内某家大厂的模型,上下文窗口 128K,看起来很大,于是团队直接把整段对话历史、全套业务说明、几份政策文档全塞进去,觉得“反正放得下”。结果线上效果差得离谱:用户问一个简单的订单状态,模型反而开始引用文档里的无关条款;用户明确表达过退款意愿,后续模型却像失忆一样。
后来我做了个实验:把上下文里的内容分成“最新用户消息”“最近 8 轮对话”“业务规则”“历史长尾对话”四块,分别测它们的注意力占比。虽然不是研究员,但从 token 位置和输出质量的对应关系来看,处于窗口中间偏后的长尾历史,几乎对生成结果没有贡献,反而会把关键信息挤得七零八落。
问题出在哪?窗口大不等于有效信息多。模型对上下文不同位置的注意力天然不平均,中间部分容易被稀释,再加上长上下文的指令遵循能力会下降,塞得越满,噪声越大。一个实用的心态是把上下文窗口当成预算,不是仓库。百分之十留给系统指令,百分之十五留给必要的业务规则,百分之五十留给当前会话主体,其余留给工具结果和临时信息。
1.2 把窗口当“团队工位”:分配比扩容更关键
打一个更容易理解的比方:上下文窗口像一个团队的工位区域,模型就是那个坐在工位上的人。工位面积大,不代表这人效率高。你得把最重要的资料放在手边,把不常用的档案放到柜子里,而不是把所有文件堆到桌面上。桌面越乱,他找东西越慢,甚至拿错。
所以在做上下文工程时,我从来不优先看“窗口够不够大”,而是看“重点信息有没有被放在最该放的位置”。什么是最该放的位置?通常来说,系统指令放在最前面,因为它要统领全局;当前用户问题放在最后面,因为模型需要针对它生成答案;而中间区域,必须按照“相关性从高到低”排序,把最重要的外部知识放在离问题尽可能近的位置。
有一类问题被反复验证过:当用户问题偏晚时,模型对前面长上下文中的细节记忆会模糊。如果你把需要严格遵守的事实放在窗口开头,又把最新的用户问题放在窗口末尾,这中间一旦隔着大量无关内容,事实的“引用距离”就太远了。这就是为什么要把知识分段、然后把最相关的段落在最后时刻插入,而不是提前放好。
1.3 一个可落地的 Token 预算表
我自己在项目里维护了一张动态预算表,不是固定值,而是按场景调整比例。这里可以给出一个通用版本,多数对话类应用可以直接照搬:
- 系统指令与角色设定:10% 以内,尽量精简,能写成 300 token 就不写 1000 token。
- 业务规则与少样本示例:10%~15%,只在必要任务中给,普通闲聊直接砍掉。
- 当前用户问题:不超过 5%,因为问题本身通常很短,但它的向量表示和关键词会用于检索。
- 关联记忆/摘要:20%~25%,这是压缩后的历史,不是原始全文。
- 检索增强结果:30%~40%,按相关性排序,每段 200~500 token,最多取 3~5 段。
- 缓冲余量:10%,给模型生成、工具返回和意外数据留出空间。
这套分配的心理学基础是:模型在生成每个 token 时,其实是在对整个上下文做条件概率计算。信息越多,选择的路径越分散。你把预算严格卡住,等于强迫系统每次都做一次信息筛选。筛选不是损失,是服务。
窗口管理的第一条原则就出来了:不是能塞多少就塞多少,而是塞之前已经完成了一次完整的信息价值排序。排序的能力,决定了窗口利用的最终效率。
2. 窗口管理:基于优先级和滚动路径的上下文调度
2.1 硬裁剪与软裁剪:为什么直接截断会“破相”
窗口管理的初级做法是硬裁剪:超出长度就扔掉前面的消息,只保留最近 N 条。这个做法实现简单,但经常出问题。比如你扔掉的恰好是用户早些时候说过的核心诉求,模型后面就会答非所问;又比如你保留了中间几段,但把上一轮问题的上下文丢了,模型会对当前问题一头雾水。
硬裁剪的本质问题是它不理解“哪部分信息重要”,只按时间顺序截断。时间顺序和重要程度没有必然关系。用户可能在一小时前交代过家庭住址,中间聊了十轮无关内容,最后你问“送到哪里”,如果硬裁剪把住址丢了,自然答不出来。
我用的替代方案是软裁剪。先按语义和用途把历史内容划分成模块,再给每个模块标记优先级。优先级由两个因子决定:一是与当前问题的语义相似度,二是该模块中是否存在用户显式给出的持久型信息,比如地址、订单号、目标偏好、临时承诺。每次构造上下文时,按优先级降序叠加,直到预算用完,低优先级模块被丢弃或改为摘要形式。
2.2 会话级窗口状态同步:不让历史与当前令牌错位
另一件容易忽略的事是状态同步。当你用外部存储管理聊天记录时,数据库里存的内容是一套,真正发给模型的上下文又是另一套。两者一旦错位,模型就会看到断裂的对话:用户在前一轮提到了一个文件,到了这一轮那条消息还在,但文件附件内容不在;或者你在上一轮对历史做了摘要,但摘要忘了更新,导致模型引用旧信息。
我常用一种叫“会话视图”的结构来管理:把原始消息流只当作数据源,每次请求真正使用的是一份动态渲染视图。视图由若干上下文块组成,每块有类型、内容和生效时间。比如一个上下文块的类型可能是“系统指令”“长期事实”或“轮次摘要”,当新的用户消息到来时,调度器重新渲染视图,把已经过期的块移除,把需要更新的摘要块重写。
做到这一步之后,窗口管理就不再是简单的“字符串拼接”,而是像操作系统的虚拟内存一样,有一层调度逻辑:哪些页常驻,哪些页换入换出,哪些页被交换到摘要区。对一个对话系统来说,长期事实就是常驻页面,轮次内容就是工作集,摘要就是磁盘交换文件。
2.3 关键内容保温机制:让上下文“常驻”的三种做法
如果某些信息特别重要,比如用户身份、偏好、当前任务目标,我建议做“保温”处理。所谓保温,就是不让它随着消息滚动被挤出窗口,而是通过机制让它始终出现在模型视野内。
做法一:关键信息前置。把提取出的用户核心信息放到系统指令末尾,或者紧跟系统指令的区域。比如“用户偏好速览:用户只接受顺丰快递”“用户当前目标:申请退款”。这些短语不占用多少 token,却会在整个会话中持续影响模型。
做法二:轮次尾部复述。如果你确实需要模型记住某个新出现的重要信息,可以在最新一轮的 assistant 回复之后,加上一个隐藏的“记忆确认”字段,让模型自己复述一次关键点。这一步在实际效果上往往会强化模型对信息的编码。注意,这里不是让模型把重复信息输出给用户,而是作为结构化记忆留存在上下文中。
做法三:时间衰减的摘要刷新。当一个信息块超过若干轮没有被引用时,把它从“全量原文”降级为“单行摘要”;一旦它再次被检索命中,就重新提升为全量原文。这种动态升降级配合 RAG 使用效果很好,相当于让窗口内容处于流动状态,永远只保留“当下最重要”的组合。
窗口管理的核心结论:上下文不是录像带,不能从头到尾平等对待。它更像是编辑剪辑出来的视频,你要根据剧情重点决定哪个镜头给特写、哪个镜头只留快剪。
3. 消息编排:结构化的角色、指令与数据分段
3.1 一条消息块该长什么样:system/user/assistant 的边界
窗口管理解决的是“哪些信息进来”,消息编排解决的是“进来的信息用什么姿势摆好”。很多人在调模型时,把规则、对话、知识一股脑写在一个 user 消息里,模型勉强也能工作,但一旦场景复杂,效果就会崩塌。
我通常按角色区分三个主层:system 负责稳定指令,user 负责当前诉求与输入数据,assistant 负责历史输出与中间推理。稳定指令要写得像“操作手册”,不随对话变化;当前诉求要放在最新一条 user 里,避免被淹没;历史 assistant 消息则用于提供生成轨迹,让模型知道“我之前说过什么”,从而保持前后一致。
这里有一个容易踩的边界坑:不要把动态内容放进 system。有人为了让模型“记住一下用户信息”,每轮把用户地址、订单号追加进 system,结果 system 越来越长,模型对指令本身的遵循度越来越差。system 必须保持娇小稳定,动态数据放到 user 或专门的数据段中,让指令与数据分离。指令负责“怎么处理”,数据负责“处理什么”,边界不清会互相污染。
3.2 工具调用与多轮消息之间的依赖关系如何编排
做 Agent 类应用时,消息编排会更复杂。工具调用的结果不能随便丢,但它也不适合长期霸占上下文。我的编排思路是三步:工具调用将原始结果缩成“结果摘要”,把摘要作为一条独立消息插入,并在其中标注它对应的用户问题编号;等到下一轮用户追问时,调度器根据问题编号去记忆区找回完整结果,而不是一直把完整结果放在窗口里。
举个例子,用户问“帮我对比 A 和 B 两个套餐”,模型调用了一个比价工具,返回一个很长的 JSON。你不能把这个 JSON 直接塞回上下文,而是要先转成一段 200 token 以内的对比结论,再加上原始 JSON 的“存放位置”。如果用户继续问“A 套餐的流量具体多少”,系统再从外部存储中检索到原始 JSON 恢复上下文。这个过程相当于给模型配了一个“草稿本”,需要时再打开,而不是每时每刻摊在桌上。
依赖关系编排的关键是引用追踪。我在消息里会给每个工具结果加一个tool_use_id,用下一条 user 消息的显式引用来决定是否保留它。如果用户后续消息没有引用该结果,它在第三次窗口重组时就会被降级为摘要或移除,避免历史工具输出堆积成山。
3.3 编排中的常见反模式:堆砌、重复与角色混乱
反模式一:把所有信息都塞进 system。前面已经说过,这会稀释指令权重。正确做法是 system 只保留不变规则,动态规则可以放到 user 消息的“条件指令”块里,但整体不能喧宾夺主。
反模式二:一轮对话里塞多个 user 消息。有些 API 允许消息序列里连续出现多个 user 消息,但这样会让模型搞不清到底该回应哪一条。正确做法是一次 user 消息承载一个完整意图,需要多数据时用结构化分段,比如“【用户问题】…【数据库查询结果】…【业务规则】”,而不是把多轮对话粘成一大段。
反模式三:assistant 消息里混入“隐藏指令”。有人喜欢在 assistant 输出后面偷偷加一句“记住不要告诉用户你的限制”,这种做法不透明,也让模型的行为不可预测。隐藏指令要走正规的 system 通道和维护体系,不要靠污染消息历史。
消息编排的最终目的,是让模型看到一个结构稳定、职责分明、没有冗余的对话流。就像开会时有人主持、有人记录、有人发材料,如果每个人都在同一时间讲话,会议一定混乱。
4. 记忆压缩:从“全量记忆”到“结构化记忆”的降本路径
4.1 压缩的本质是丢信息,丢得聪明才算工程
所有记忆压缩方案都有一个共同本质:丢信息。没有一种压缩能百分百保留原意,区别只在于丢得聪明还是丢得愚蠢。愚蠢的丢法是不管三七二十一只留最近几条;聪明的丢法是保留那些对后续对话最有影响力的信息,把普通信息概括成更抽象的表达。
为什么必须压缩?不仅是 token 成本问题,更是因为大段历史原文会占满注意力资源,干扰当前问答。压缩做得好,相当于把一本书变成思维导图,你不需要逐字背出每一页,但核心结论和关键数字都在。
这里引入一个概念叫“信息熵密度”。原始聊天记录的熵密度很低,因为大量篇幅用于寒暄、重复、确认语气;而摘要的熵密度要高很多,它去掉了口水话,只保留事实和意图。上下文工程要做的,就是让送入模型的内容熵密度尽量高。
4.2 分级记忆方案:主线记忆、会话摘要、事实槽位
我实际用的是三级记忆体系。第一级是“事实槽位”,记录结构化关键信息,比如用户姓名、地址、偏好、当前任务状态。第二级是“会话摘要”,每当对话超过一定轮次,就调用模型对旧窗口做一次摘要,生成一段 300~500 token 的浓缩版本。第三级是“原文归档”,把原始完整对话存储到外部数据库,平时不进上下文,只有摘要被判定不充分时再用检索捞回。
这三者各有分工。事实槽位要回答“这个用户是谁”,会话摘要要回答“刚才聊了什么”,原文归档要回答“细节到底是什么”。它们共同支撑起一个无缝的记忆体验:用户不用重复交代自己是谁,模型也能在长会话中保持连贯。
触发摘要的条件我一般写成:累计消息数超过 20 轮,或者当前上下文估算 token 超过预算的 70%。摘要生成后,不是直接替换旧消息,而是保留概率判断:如果摘要覆盖范围内仍有关键细节被后续问题依赖,则该细节单独提到事实槽位。这一步可以用规则抽取,也可以用模型抽取,但在生产环境我倾向于“模型生成摘要 + 正则兜底关键字段”。
4.3 压缩触发时机与效果验证
压缩最怕的就是触发太晚,导致一次请求直接溢出窗口,或者触发太频繁,让模型每每丢失正在讨论的细节。我实践下来的触发策略分两步:
第一步,硬性水位线。估算当前上下文 token 数,超过 70% 开始触发压缩;超过 90% 强制进入“只保留系统指令 + 最新 5 轮 + 摘要 + 当前问题”的最小模式。第二步,语义变化触发。当用户话题发生明显切换时,比如从“退款咨询”跳到“改收货地址”,理解旧话题的完整历史不再重要,可以先对旧话题生成摘要,并把新话题设置为当前关注点。
效果验证不能只看用户满意度。我会用一组“记忆点测试题”:每次压缩前后,人为构造几个追问,比如“用户刚才说的收货地址是什么”“退款金额是多少”。在压缩前模型回答正确,压缩后也应当回答正确。如果压缩后答错,说明摘要丢掉了关键事实槽位,需要把该信息提升到一级记忆。这套回归测试跑通了,记忆压缩才敢上线。
还有一个小技巧:压缩摘要时给摘要加上时间戳与标签。这样当模型需要区分“昨天聊的事”和“刚才聊的事”时,不会被混在一起。标签可以是“已解决”“待处理”“用户情绪信息”,它们能帮模型更快定位相关信息。
记忆压缩不是让模型“记得更多”,而是让模型“记得更对”。在一个长会话系统里,这往往比模型参数大小更影响体验。
5. 检索增强应用的落地实践:RAG 在上下文工程里的位置
5.1 RAG 不是简单的“查一下拼进去”
RAG(检索增强生成)做到后面,你会发现它本质是上下文工程的一个下游模块:外部知识经过检索、切片、筛选、排序,最终以合适的形态注入有限窗口。很多人第一次写 RAG demo,就是 embedding 一下、向量库查 top-k、拼到 prompt 里。demo 能跑,线上稀碎,因为检索到的内容没做上下文适配。
有一段我心里的警句:RAG 检索的目标不是“找到相似文本”,而是“找到能补全模型缺失知识且不与现有上下文冲突的信息”。相似文本不等于有用信息,用户问题中出现的词可能在文档里反复出现,但真正关键的那句结论反而因为措辞不同没被检索到。这就是为什么需要做查询改写、混合检索、重排序等环节。
在上下文工程框架里,RAG 的产出必须受“注入约束”限制:每段不超过 500 token、总共不超过 5 段、按与当前问题相关度排序、段与段之间用分隔标签指明来源。不能让 RAG 结果变成第二个失控的聊天记录。
5.2 切片粒度、嵌入模型与重排的联动调优
切片粒度是 RAG 中最容易“一动不动就出事”的地方。切片太短,语义不全,检索到的片段可能只讲了半句话;切片太长,噪声太多,向量表示会被稀释,同时浪费上下文窗口。我常用的是递归字符分割法,先按标题结构分块,再按段落大小 300~500 字切分,重叠 50~100 字。但不同文档类型要单独调:合同类适合按条款切,FAQ 类适合按 QA 对切,技术文档适合按章节切。
嵌入模型的选择也会影响最终质量。通用向量模型对专有名词多的场景往往不友好,我会做一个小评测集:准备 20 个真实用户问题,每个问题标注对应的标准答案片段,然后测试不同 embedding 模型的召回率。召回率至少要做到 80%,否则后续重排再强也无济于事。重排模型也很重要,它能把相关性分数重新校准,很多时候 top-5 里正确的文档初始就排在 top-20,但经过 rerank 后能进入目标位置。
在上下文注入时,不要只注入“命中的片段”,还要让模型知道该片段的出处和可信度。比如某个关键数据来自两年前的文档,你必须在文本中标注“来源:2023 年产品手册”,否则模型很容易把过期信息当最新事实,甚至自己在回答时“脑补”更新数据。
5.3 混合检索和上下文注入时的格式工程
纯向量检索有天然缺陷:它对精确词匹配、特定编号、反义词不敏感。比如用户问“不支持哪些支付方式”,文档里写的是“支持支付宝、微信、银行卡”,向量检索可能因为语义相似而把这段捞出来,但模型却很可能搞反否定关系。
更稳的做法是混合检索:向量检索负责语义召回,BM25 关键词检索负责精确匹配,再用重排模型融合两类结果。融合权重不是拍脑袋定的,我会先跑离线实验,统计两类检索各自召回正确答案的比例,再按比例加权。像“订单号 ER-2024-8899”这种查询,BM25 的权重必须明显更高。
格式工程上,RAG 片段注入前要转换成“人话”。举例来说,原始文档可能是表格,直接灌入 Markdown 表格模型也能处理,但在 token 有限的情况下,我会先把表格转成“Key-Value 列表”或“自然语言结论”,优先保留与问题直接相关的字段。每次注入时,我给每个片段加一个标签行,比如“【参考资料 1】来自《退货政策》第 3 节”,模型在回答时就知道引用来源。这样不仅提高准确性,还方便后续做可解释性追踪。
如果你的系统有多个知识库,务必在每个片段上标注知识库名称。否则模型很可能把 A 库的条款与 B 库的条款混在一起,生成一个看似合理却完全不存在的结论。RAG 的上限由检索质量决定,下限由上下文注入格式决定,两者缺一不可。
6. 我能反复复用的调试流程与避坑清单
6.1 六步调试法:从失败样本反推上下文问题
很多团队拿着 badcase 就去改 prompt,改来改去效果原地打转。我更建议按六步来定位上下文问题:
第一步,复现失败并记录完整输入输出,把发出去的 messages 全部存下来,包括系统指令、历史、检索结果。第二步,人工检查上下文里是否存在“正确答案”。如果存在,说明问题出在信息的摆放位置、权重分配或格式上;如果不存在,问题出在检索丢失或记忆压缩过度,而不是模型不行。第三步,用“最小上下文法”测试。只保留系统指令和当前问题,看模型能否答对,排除上下文噪声干扰。第四步,逐步加入检索结果和历史,每加一部分就测一次,找出让答案从“对”变“错”的引入点。第五步,检查消息边界,看是否存在角色混乱、指令被数据覆盖、工具结果残留在错误位置。第六步,修正后回归全部记忆点测试和检索测试,确保不是“修好一个坏十个”。
这套方法不需要高深的框架,只要你在日志里把上下文完整记录下来,坚持几轮,你会发现自己比想象中更快找到症结。
6.2 避坑清单:我踩过的、帮你提前躲开的坑
坑一:漏掉“隐含否定”类检索。用户问“不要带壳的”,检索结果却全是“带壳手机壳推荐”。解决方法是针对每类业务维护一个否定词表,查询改写时把否定意图显式化,并过滤掉包含“不、免、无需”等反向词的文档。
坑二:压缩摘要时把“数字”丢了。人类写摘要时经常忽略“用户要求 3 天内发货”这个数字,但模型后续决策必须依赖它。解决方案是在摘要生成后,用正则把所有数字、日期、价格、编号抽出来,单独放到事实槽位。
坑三:system 指令被动态内容无限膨胀。一旦你允许把动态规则堆进 system,它三个月后可能变成 5000 token 的怪物。解决办法是固定 system 的上限,新增规则必须走外部知识库或规则引擎,而不是直接堆 prompt。
坑四:盲目追求“上下文越长越好”。我在开头已经提过,长上下文容易导致注意力稀释、成本飙升、响应变慢。真正的高质量服务是短、准、稳,而不是大而全。
坑五:RAG 片段里混入相互矛盾的信息。多个文档之间经常不一致,比如 A 文档说“7 天无理由退货”,B 文档说“生鲜不支持 7 天无理由”。如果你不加校验直接注入,模型大概率会选择某个它觉得更可信的答案,但很不稳定,而且一旦用户追问两个文档的关系就穿帮。解决方法是新增一层“冲突检测”,如果检索段之间存在明显冲突,把冲突点显式告诉模型,并让模型做说明而不是强行二选一。
6.3 一套最小可用的上下文工程模板
最后给出一套我常用的模板,可以直接复制到项目里做骨架,再根据场景调整。
消息结构示例,这是一个对话请求的 messages 数组简化版本:
[ { "role": "system", "content": "你是客服助手。回答前必须阅读【用户偏好】和【参考资料】。若信息不足,直接说明。" }, { "role": "user", "content": "【用户偏好】用户ID 88231,常用收货地址:杭州市西湖区文一西路 100 号;当前目标:申请退款。\n\n【关键历史摘要】用户于 10 月 20 日购买订单 A1,10 月 22 日表示商品破损,要求退款。\n\n【参考资料】\n<ref id=1 source=退货政策>生鲜类商品不支持 7 天无理由退货,但存在质量问题可在签收 24 小时内申请退款。</ref>\n\n【当前问题】订单 A1 的商品是生鲜,还能退款吗?" } ]这个模板的核心特点是:所有动态信息都归入 user 消息的数据段,system 只保留稳定的行为规则,历史以摘要而不是原文存在,参考资料标注来源。如果模型回答仍然不准,优先调整数据段的顺序、篇幅和相关性,而不是改 system 里的咒语。
另外,我建议在 API 调用前加一个“上下文预算检查”函数,用 tokenizer 统计 messages 总长度,一旦超过阈值就自动走摘要或截断流程。这个简单的拦截器能避免很多生产事故。
用总结的方式说一句:上下文工程不是某个单一技巧,而是一套持续迭代的配置管理方法。把窗口当预算,把消息当结构,把记忆当分级,把检索当注入,四者联动,才能让大模型真正发挥实力。我在实际项目中得到的最大体会是,与其不停换更强的新模型,不如先把每次请求的上下文治理好——这一步的投入产出比,远超想象。