1. 从一个真实困境说起:为什么模型越强,效果反而越不稳定
很多人有过类似的体验:同一个模型,同一套提示词模板,昨天跑出来的结果堪称惊艳,今天再跑一遍,输出却像换了个脑子。更让人抓狂的是,在一个对话窗口里聊到第十几轮之后,模型开始"忘事"——前面明确说过的约束条件,它当没看见;前面已经确认过的方案,它又绕回去重新讨论。你以为是模型退化了,其实大概率是上下文工程没做好。
先把概念说清楚。所谓上下文工程,指的是围绕模型单次推理所能"看到"的全部信息进行的设计、组织、裁剪与调度工作。它包含但不限于:系统指令怎么写、历史对话保留多少、外部知识怎么注入、工具返回结果怎么压缩、多轮状态怎么维护。它和"提示词工程"不是一回事——提示词工程关注的是"这一句话怎么说",上下文工程关注的是"这一整屏信息怎么摆"。
这个区别很关键。提示词工程像是给一个人交代任务时的措辞技巧,而上下文工程像是给这个人准备一整套工作台:桌面上放哪些资料、哪些先放、哪些收进抽屉、哪些直接扔掉。措辞再好,如果桌面上堆了一百份互相矛盾的文档,对方照样干不好活。
这篇文章适合三类人看:一是已经在做智能体、知识库问答、多轮对话系统,但被"效果不稳定"折磨的开发者;二是刚接触大模型应用,想知道除了调提示词还能做什么的入门者;三是负责把原型推向生产、需要关心成本和延迟的工程负责人。我会从设计思路、核心细节、实操落地、问题排查四个层面,把上下文工程这件事拆开讲透,尽量给到可以直接抄作业的方案。
2. 上下文工程的整体设计思路拆解
2.1 上下文窗口不是"越大越好"的仓库
一个常见的误解是:既然模型支持很长的上下文窗口,那就把所有能塞的东西都塞进去,反正它自己会挑重点。实测下来,这个想法会踩两个坑。
第一个坑叫中间遗忘。大量实践观察表明,模型对上下文开头和结尾部分的信息利用效率明显高于中间部分。你把一份关键约束放在第三千字的位置,它被忽略的概率远高于放在开头。这不是玄学,而是注意力分配机制在长序列上的自然表现。所以"塞满"不等于"用好"。
第二个坑叫噪声稀释。上下文里每多一段无关内容,模型判断"什么才是当前任务重点"的难度就上升一分。当无关内容占比过高时,模型容易抓错重点,甚至把历史对话里已经废弃的方案当成当前方案来执行。我见过一个客服机器人,因为把全部历史工单都塞进上下文,结果它开始用别的用户的问题来回答当前用户,场面相当尴尬。
所以上下文工程的第一原则是:上下文是稀缺资源,要像管理内存一样管理它。不是能放多少就放多少,而是该放什么、该丢什么、该压缩什么,都要有明确策略。
2.2 分层设计:把上下文切成几个功能区
比较成熟的做法是把上下文分成几个逻辑层,每层职责清晰:
| 层级 | 内容 | 是否常驻 | 典型长度占比 |
|---|---|---|---|
| 系统层 | 角色设定、硬性约束、输出格式 | 常驻 | 10%~20% |
| 任务层 | 当前任务目标、验收标准 | 常驻 | 5%~10% |
| 知识层 | 检索到的外部资料、文档片段 | 动态 | 30%~50% |
| 记忆层 | 历史对话摘要、关键状态 | 动态 | 15%~25% |
| 工具层 | 工具调用返回结果 | 动态 | 10%~20% |
这样分层的好处是,当上下文超长时,你知道该从哪一层开始裁剪。系统层和任务层是"宪法",轻易不动;知识层和工具层是"临时材料",用完即弃;记忆层需要定期压缩成摘要。
提示:分层不是目的,可裁剪才是目的。任何一层如果无法被安全裁剪,说明它的设计有问题。
2.3 为什么现在特别强调这件事
早几年做应用,模型能力有限,大家把精力都花在"怎么把话说清楚"上。现在模型能力上来了,单轮指令遵循已经相当可靠,瓶颈转移到了"信息组织"上。尤其是智能体场景,一次任务可能涉及十几次工具调用、几十轮交互,上下文管理稍有不慎就会崩盘。
另一个推动因素是成本。长上下文意味着更高的推理开销和更长的响应时间。一个把上下文从两万 token 压到六千 token 的优化,可能直接让单次调用成本降一半、延迟降三分之一。在规模化场景下,这不是锦上添花,而是能不能跑通商业模型的问题。
3. 核心细节解析与实操要点
3.1 系统指令的写法:约束要"可判定"
系统层最容易犯的错是写一堆"要专业、要友好、要准确"这类无法判定的形容词。模型没法验证自己是否"专业",只能靠感觉,结果就是每次表现都不一样。
正确的做法是把约束写成可判定的规则。比如不说"回答要简洁",而说"回答不超过三句话,每句不超过四十字";不说"要准确引用资料",而说"每个事实性陈述后面用方括号标注来源编号,没有来源的陈述不要写"。
我整理了一个对比表,方便你自查:
| 模糊约束 | 可判定约束 |
|---|---|
| 语气要专业 | 使用陈述句,不使用感叹号和口语词 |
| 回答要详细 | 分点作答,每点不少于两句 |
| 不要跑题 | 只回答与当前任务目标相关的问题,无关问题回复"该问题超出当前范围" |
| 引用要准确 | 引用内容用引号包裹并标注来源编号 |
可判定约束的另一个好处是,你可以写自动化测试来验证模型是否遵守。模糊约束没法测,可判定约束可以。
3.2 历史对话的处理:摘要 + 关键状态双轨制
多轮对话里,历史不能全留,也不能全丢。我的做法是双轨制:
一条轨道是滚动摘要。每积累若干轮对话,就让模型把这段对话压缩成一段摘要,保留决策、结论、未解决问题,丢弃寒暄和试错过程。摘要本身也要控制长度,太长就再压缩一次。
另一条轨道是结构化状态。把对话中产生的关键变量抽出来,用键值对形式单独维护。比如用户已经确认的预算、时间、偏好,这些不放在自然语言摘要里,而是放在一个结构化的状态对象里,每轮都完整注入。
{ "user_confirmed": { "budget": "5000以内", "deadline": "本月底", "preferred_style": "简约" }, "open_questions": ["是否需要发票", "配送地址未确认"], "decisions_made": ["方案A已否决", "方案B待评估"] }这样做的原因是,自然语言摘要容易在反复压缩中丢失细节,而结构化状态是精确的、不会漂移的。两者配合,既有上下文连贯性,又有关键信息的确定性。
3.3 外部知识的注入:先排序,再截断,最后标注
知识层最常见的做法是检索一堆文档片段直接拼进去。问题在于,检索结果的相关性参差不齐,直接拼进去会稀释重点。
我的处理流程是三步:
- 重排序:检索回来的片段先按相关性重新排序,把最相关的放最前面。很多检索系统返回的顺序并不理想,重排序这一步能显著提升利用效率。
- 截断:设定一个知识层的 token 预算,从高到低填充,填满即止。宁可少放几段,也不要把预算撑爆。
- 标注:每段资料前面加上编号和来源标识,方便模型引用,也方便你排查问题。
注意:知识片段之间的分隔符要清晰。我习惯用明显的分隔标记,避免模型把两段不相关内容连起来理解。
3.4 工具返回结果的压缩:别把原始 JSON 直接塞进去
工具调用返回的结果往往是冗长的 JSON,包含大量模型不需要的字段。直接塞进上下文,既浪费预算又干扰判断。
我的做法是在工具层和上下文层之间加一个结果处理器:只提取模型真正需要的字段,把嵌套结构拍平,把长列表截断成前若干项加一个总数说明。比如一个搜索工具返回二十条结果,上下文里只放前五条的标题和摘要,再加一句"共二十条结果,已展示前五条"。
这一步看起来琐碎,但在多轮工具调用场景下,节省的上下文非常可观。我做过一个对比,同样的任务,加了结果压缩之后,平均上下文长度下降了约四成,任务成功率反而略有提升。
4. 实操过程与核心环节实现
4.1 搭一个最小可用的上下文管理器
下面给一个可以直接参考的实现思路,用伪代码加说明的方式,语言无关。
核心是一个上下文组装函数,输入是当前任务、历史、检索结果、工具结果,输出是最终拼好的消息列表。
def build_context(task, history, retrieved_docs, tool_results, budget): # 1. 固定层:系统指令 + 任务目标,优先占用预算 fixed = [system_prompt, task_prompt] fixed_tokens = count_tokens(fixed) remaining = budget - fixed_tokens # 2. 状态层:结构化状态,完整注入 state_block = render_state(history.structured_state) remaining -= count_tokens(state_block) # 3. 知识层:重排序后按预算截断 ranked_docs = rerank(retrieved_docs, task) doc_block, remaining = fill_until_budget(ranked_docs, remaining) # 4. 工具层:压缩后的结果 compressed_tools = [compress(r) for r in tool_results] tool_block, remaining = fill_until_budget(compressed_tools, remaining) # 5. 记忆层:滚动摘要,用剩余预算填充 summary_block, remaining = fill_until_budget([history.summary], remaining) return assemble(fixed, state_block, doc_block, tool_block, summary_block)这个函数的关键在于预算分配的顺序。固定层和状态层优先级最高,先占;知识层和工具层按相关性竞争剩余预算;记忆层用剩下的边角料。这样即使预算紧张,最核心的约束也不会丢。
4.2 预算怎么定:从模型上限倒推
预算不是拍脑袋定的。我的方法是:
- 查清模型的最大上下文长度,记为 M。
- 预留输出空间,一般留 M 的 20% 到 30% 给模型生成。
- 剩下的作为输入预算 B。
- 再给 B 打个八折作为安全边际,因为 token 计数有误差。
举个例子,假设模型上限是三万两千 token,预留三成给输出,输入预算是两万两千四百。打八折后实际使用约一万八千。这个数字就是你分配各层预算的总盘子。
提示:不同模型的分词方式不同,同一个字符串的 token 数可能差百分之十几。预算一定要留余量,不要卡着上限用。
4.3 滚动摘要的触发时机与写法
滚动摘要不要每轮都做,那样开销太大。我的触发条件是:当历史对话的 token 数超过记忆层预算的百分之七十时,触发一次压缩。
压缩的提示词也有讲究。不要只说"总结一下",而要明确要求保留什么、丢弃什么:
请将以下对话压缩成不超过两百字的摘要,必须保留: 1. 用户已确认的所有参数和偏好 2. 已经做出的决策及理由 3. 尚未解决的问题 请丢弃:寒暄、重复确认、已被否决的方案细节。这样压出来的摘要,信息密度高,后续注入时不会浪费预算。
4.4 一次完整的上下文组装现场记录
我拿一个实际的咨询类任务走一遍流程,让你看到每一步的取舍。
任务背景:用户要选一款办公设备,已经聊了八轮,中间查了两次资料,调用了一次比价工具。
第一步,固定层注入系统指令和任务目标,约八百 token。系统指令里写明了输出格式和硬性约束。
第二步,状态层注入结构化状态:预算已确认、用途已确认、两个待定问题。约一百五十 token。
第三步,知识层。检索回来六段资料,重排序后取前三段,约一千二百 token。第四段开始相关性明显下降,果断截断。
第四步,工具层。比价工具返回了十五条记录,压缩成前五条加总数说明,约三百 token。
第五步,记忆层。八轮对话的滚动摘要,约两百 token。
总计约两千六百五十 token,远低于预算上限。这意味着还有余量,可以在知识层多放一段资料,或者把摘要写得更详细。整个组装过程在代码里是毫秒级的,真正的开销在检索和重排序上。
5. 常见问题与排查技巧实录
5.1 模型"忘记"了前面的约束
这是最高频的问题。排查顺序如下:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 约束是否还在上下文里 | 打印最终组装的消息 | 被预算裁剪掉了 |
| 约束位置是否太靠中间 | 看约束在消息中的位置 | 中间遗忘效应 |
| 约束是否被后续内容覆盖 | 检查是否有矛盾指令 | 知识层或工具层注入了冲突信息 |
| 约束是否可判定 | 对照可判定约束表 | 约束本身太模糊 |
我的经验是,把最重要的约束同时放在系统层开头和任务层结尾,形成"首尾呼应"。这样即使中间内容再多,约束也不容易被忽略。
5.2 多轮之后回答开始"串味"
表现为模型把之前话题的内容混进当前话题。原因通常是历史摘要里保留了太多旧话题的细节。
解决办法是在摘要时明确标注话题边界,并在状态层里记录"当前活跃话题"。当话题切换时,主动清理旧话题的细节,只保留结论。
5.3 上下文没超限但响应变慢
不一定是上下文长度的问题,也可能是内容结构的问题。嵌套很深的 JSON、大量重复的格式标记,都会增加模型解析负担。把结构拍平、去掉冗余标记,往往能改善响应速度。
5.4 检索结果明明相关,模型却不用
先检查检索结果在上下文中的位置。如果被放在很靠后的位置,模型可能没注意到。把最相关的资料往前放,或者用显式指令引导:"请优先参考资料一和资料二"。
另一个可能是资料之间互相矛盾,模型不知道该信哪个。这时候需要在注入前做一次冲突检测,或者明确标注资料的时效性和可信度。
5.5 预算总是不够用
如果反复遇到预算紧张,说明你的信息组织方式需要优化,而不是简单加大预算。可以尝试:
- 把长文档预先做结构化抽取,只注入抽取后的要点
- 把常用知识做成固定模板,减少每次检索的量
- 把工具返回结果做更激进的压缩
- 检查是否有重复注入的内容
我踩过的一个坑是,系统指令和任务指令里写了重复的约束,白白浪费了预算。后来做了一次去重,直接省出百分之十五的空间。
6. 几个容易被忽略的进阶技巧
6.1 给上下文加"目录"
当上下文很长时,在开头加一段简短的目录,说明后面各部分的顺序和内容。比如"接下来依次是:任务目标、已确认参数、参考资料、工具结果、历史摘要"。这段目录本身很短,但能帮模型快速建立全局认知,减少迷路。
6.2 用占位符管理动态内容
不要把动态内容直接拼进模板字符串,而是用占位符,在组装阶段替换。这样模板可以复用,也方便做单元测试。比如模板里写{{knowledge_block}},组装时再填入实际内容。
6.3 记录每次组装的"上下文指纹"
每次组装完上下文,算一个哈希值或者记录各层的 token 数,存到日志里。当出现问题时,你可以回溯是哪次组装、哪一层出了问题。这个习惯在排查偶发问题时特别有用。
6.4 定期做上下文"体检"
每隔一段时间,抽样检查实际组装的上下文,看看有没有冗余、有没有矛盾、有没有该丢没丢的内容。我一般每周抽十条看一遍,经常能发现可以优化的地方。
7. 我在实际项目中的几点体会
做上下文工程这几年,最大的感受是:它更像是一门"信息策展"的手艺,而不是纯技术活。技术手段(检索、压缩、重排序)是工具,但真正决定效果的是你对"什么信息在什么时刻最重要"的判断力。
我见过太多团队把精力全花在换模型、调参数上,却忽略了上下文组织这个更底层的变量。实际上,把上下文理顺带来的提升,往往比换一个更强的模型更明显,而且成本更低。
还有一个体会是,上下文工程没有一劳永逸的方案。任务类型变了、用户群体变了、模型版本变了,上下文策略都要跟着调整。把它当成一个持续迭代的过程,而不是一次性的配置。
最后分享一个小技巧:当你不知道该怎么组织上下文时,先问自己一个问题——"如果我是接这个任务的人,我需要看到哪些信息才能干好?"把这个清单列出来,再按重要性排序,你的上下文结构基本就出来了。这个方法比任何框架都管用。