1. 上下文工程到底在解决什么问题
1.1 从提示词工程到上下文工程的认知升级
很多人第一次接触 AI Agent 开发时,会把大部分精力花在“怎么写提示词”上。这个阶段我称之为提示词工程阶段,核心思路是找到一句“魔法咒语”,让模型输出理想结果。但真正把 Agent 跑在生产环境里的人很快会发现,提示词写得再漂亮,也扛不住多轮对话、工具调用、外部知识注入、历史状态累积带来的上下文膨胀。
上下文工程要解决的核心问题,是在有限的上下文窗口内,动态地组装出对当前决策最有价值的信息集合。它不只是“写提示词”,而是涵盖了系统指令、对话历史、工具定义、检索结果、中间推理状态、输出格式约束等多个维度的综合调度。你可以把它理解为:提示词工程是写一封邮件,上下文工程是管理整个收件箱、归档规则、优先级排序和自动回复策略。
为什么这个区分很重要?因为 Agent 和单轮问答的本质差异在于,Agent 需要在一个循环里持续做决策。每一轮决策都依赖上一轮的输出,而上一轮的输出又会成为下一轮上下文的一部分。如果不加控制,上下文会像滚雪球一样越滚越大,最终要么超出窗口限制,要么让模型在噪声中迷失重点。
1.2 上下文窗口不是越大越好
现在很多模型宣称支持超长上下文,动辄几十万 token。但实际用下来你会发现,窗口大不等于效果好。我做过一个简单的对比测试:同样一个多步推理任务,把全部历史塞进 128K 窗口,和只保留最近 5 轮加摘要,后者的任务完成率反而更高。原因有两个:一是模型在超长上下文中对中间位置的注意力会衰减,二是无关信息会稀释关键指令的权重。
所以上下文工程的第一原则是:不是把所有信息都塞进去,而是只放当前决策需要的信息。这听起来像废话,但实操中很多人就是舍不得删历史,总觉得“万一有用呢”。我的经验是,凡是当前步骤用不上的信息,一律不放进主上下文,需要时再通过工具或检索动态拉取。
1.3 一个典型的上下文组成清单
为了让你有个直观感受,我把一个 ReAct 风格 Agent 单轮决策时的上下文拆成以下几块:
| 上下文模块 | 内容示例 | 是否每轮都带 | 典型 token 占比 |
|---|---|---|---|
| 系统指令 | 角色定义、行为约束、输出格式 | 是 | 5%–10% |
| 工具定义 | 可用工具的名称、参数、描述 | 是 | 10%–20% |
| 对话历史 | 用户输入、Agent 回复 | 部分 | 20%–40% |
| 检索结果 | 从知识库拉取的片段 | 按需 | 10%–30% |
| 中间状态 | 已执行步骤、观察结果 | 是 | 10%–20% |
| 输出约束 | JSON schema、格式示例 | 是 | 5%–10% |
这张表不是标准答案,但能帮你建立“上下文是有预算的”这个意识。每一块都在抢 token,你需要根据任务类型动态调整配比。比如工具调用密集的任务,工具定义占比会上升;知识问答密集的任务,检索结果占比会上升。
2. 核心细节解析与实操要点
2.1 系统指令的写法:约束比描述更重要
很多人写系统指令喜欢用大段描述,比如“你是一个专业的、友好的、乐于助人的助手”。这种话对模型行为的影响微乎其微。真正有效的是硬约束:能做什么、不能做什么、遇到什么情况走什么分支。
我常用的系统指令结构是这样的:
角色:你是订单查询 Agent。 能力边界:只能查询订单状态和物流信息,不能修改订单。 工具使用规则: - 用户提供订单号时,直接调用 query_order。 - 用户未提供订单号时,先追问,不要猜测。 - 工具返回错误时,最多重试一次,仍失败则告知用户稍后再试。 输出格式:先给结论,再给依据,不超过 100 字。这种写法的好处是,模型不需要“理解”你的意图,只需要按规则执行。实测下来,任务完成率比模糊描述高出不少。注意,系统指令要放在上下文最前面,并且每轮都带,不要指望模型记住上一轮的系统指令。
2.2 工具定义的粒度控制
工具定义是上下文里最容易被浪费的部分。我见过一个 Agent 定义了 30 多个工具,每个工具的描述写了 200 字,光工具定义就占了 6000 token。结果模型经常选错工具,因为选项太多、描述太像。
我的做法是按场景分组,动态加载。比如一个客服 Agent,可以分成“订单类工具”“退款类工具”“账户类工具”三组。根据用户当前意图,只加载对应组的工具定义。这样每轮的工具定义 token 能压到原来的三分之一,选错率也明显下降。
另外,工具描述要写“什么时候用”,而不是“这个工具是什么”。比如:
- 差的描述:
query_order:查询订单信息。 - 好的描述:
query_order:当用户提供订单号并询问状态、物流、金额时使用。不用于修改订单。
2.3 对话历史的压缩策略
对话历史是上下文膨胀的主要来源。我的压缩策略分三层:
第一层是滑动窗口,只保留最近 N 轮完整对话。N 的取值取决于任务复杂度,简单问答 3 到 5 轮,复杂任务 8 到 10 轮。
第二层是摘要压缩,把超出窗口的历史用模型生成一段摘要,放在窗口前面。摘要要包含关键实体、已确认事实、未解决问题。
第三层是状态外置,把结构化的状态(比如订单号、用户 ID、已执行步骤)存到外部变量里,需要时再注入上下文。这样历史对话可以大胆删,关键信息不会丢。
注意:摘要压缩会引入信息损失,对于需要精确回溯的任务(比如多步计算),不要用摘要替代原始数据,而是把原始数据存到外部,需要时重新拉取。
2.4 ReAct 循环中的上下文管理
ReAct 模式的核心是“推理-行动-观察”循环。每一轮循环,上下文都会增加推理文本、工具调用、工具返回三块内容。如果不加控制,五轮之后上下文就会翻倍。
我的做法是在每轮循环结束时做一次上下文清理:
- 把上一轮的推理文本压缩成一句话结论。
- 工具返回如果很长,只保留关键字段,其余丢弃。
- 如果连续两轮没有进展,强制注入“换策略”指令。
这样能把 ReAct 循环的上下文增长从线性变成近似常数,实测能多跑 3 到 5 轮才触及窗口上限。
3. 实操过程与核心环节实现
3.1 从零搭一个带上下文管理的 Agent
下面我用一个订单查询 Agent 的例子,把上下文工程的落地过程拆开讲。技术栈选择上,我用 Python 加一个通用的大模型接口,不绑定具体厂商,方便你迁移。
第一步,定义上下文容器。我习惯用一个字典来管理各个模块,而不是拼一个大字符串。这样方便按需裁剪和替换。
context = { "system": SYSTEM_PROMPT, "tools": [], "history": [], "scratchpad": [], "retrieval": [] }第二步,实现工具动态加载。根据用户输入的关键词,决定加载哪组工具。
def load_tools(user_input): if any(k in user_input for k in ["订单", "物流", "发货"]): return ORDER_TOOLS elif any(k in user_input for k in ["退款", "退货"]): return REFUND_TOOLS else: return []第三步,实现历史压缩。这里用一个简单的滑动窗口加摘要。
def compress_history(history, max_turns=5): if len(history) <= max_turns * 2: return history old = history[:-max_turns * 2] recent = history[-max_turns * 2:] summary = summarize(old) return [{"role": "system", "content": f"历史摘要:{summary}"}] + recent第四步,组装最终上下文。注意顺序:系统指令在最前,工具定义其次,然后是摘要和历史,最后是当前输入。
def build_context(context, user_input): parts = [context["system"]] if context["tools"]: parts.append(format_tools(context["tools"])) parts.extend(context["history"]) parts.append({"role": "user", "content": user_input}) return parts第五步,ReAct 循环。每轮结束后清理 scratchpad。
def react_loop(user_input, max_steps=8): context["tools"] = load_tools(user_input) context["history"] = compress_history(context["history"]) for step in range(max_steps): messages = build_context(context, user_input) response = call_llm(messages) if is_final_answer(response): return response action = parse_action(response) observation = execute_tool(action) context["scratchpad"].append({ "step": step, "action": action, "observation": truncate(observation, 500) }) context["history"].append({"role": "assistant", "content": response}) context["history"].append({"role": "user", "content": f"观察结果:{observation}"}) return "任务未完成,请稍后重试"这套代码不复杂,但包含了上下文工程的几个关键动作:动态工具加载、历史压缩、scratchpad 截断、循环上限控制。你可以直接拿去改。
3.2 参数选择与计算过程
上下文预算怎么分配?我一般按窗口大小的百分比来算。假设模型窗口是 8K token,我的分配是:
- 系统指令:500 token
- 工具定义:1500 token
- 历史对话:2500 token
- 检索结果:2000 token
- 输出预留:1500 token
这个配比不是固定的。如果任务以工具调用为主,我会把工具定义提到 2500,历史压到 1500。如果任务以知识问答为主,检索结果提到 3000,工具定义降到 500。
计算 token 有个粗略公式:中文大约 1 个字等于 1.5 到 2 个 token,英文大约 1 个单词等于 1.3 个 token。实际开发中,我会在每轮组装上下文后打印 token 估算值,超过预算就触发压缩。
3.3 实操现场记录:一次上下文超限的排查
有一次线上 Agent 突然开始胡言乱语,回答和问题完全无关。我查了日志,发现上下文 token 数从正常的 3000 飙到了 12000,超出了模型窗口。原因是某个工具返回了一个超长的 JSON,里面有大量嵌套字段,直接塞进了历史。
修复方案有两个:一是在工具返回处加截断,只保留关键字段;二是在组装上下文前做一次 token 检查,超限就触发压缩。我两个都做了,之后没再出现类似问题。
这个坑给我的教训是:永远不要相信外部输入的长度。工具返回、检索结果、用户输入,都可能超长。上下文工程必须包含防御性截断。
4. 常见问题与排查技巧实录
4.1 模型不按格式输出怎么办
这是最常见的问题。你要求 JSON,它给你一段解释加 JSON。我的排查顺序是:
- 检查输出约束是否放在上下文最后。模型对末尾内容的注意力最强,格式要求要放在最后。
- 检查是否给了示例。给一个正确的输出示例,比写十句“请输出 JSON”都管用。
- 检查温度参数。格式要求严格时,温度调到 0 到 0.3。
- 如果还不行,用工具调用接口强制结构化输出,而不是靠提示词。
4.2 工具选错或参数传错
工具选错通常是因为工具描述太相似,或者工具太多。我的解决步骤:
- 先看工具描述,把“什么时候用”写清楚。
- 再看工具数量,超过 10 个就分组动态加载。
- 最后看参数定义,参数名要自解释,不要用
arg1、arg2这种。
参数传错还有一个常见原因是缺少示例。在工具描述里加一个调用示例,能显著降低错误率。
4.3 多轮对话后 Agent 忘记早期信息
这是上下文压缩的副作用。我的做法是把关键信息外置成状态变量,而不是依赖模型记忆。比如用户 ID、订单号、已确认的偏好,都存在外部字典里,每轮组装上下文时注入。这样即使历史被压缩,关键信息也不会丢。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 回答与问题无关 | 上下文超限或噪声过多 | 打印 token 数和上下文内容 | 压缩历史、截断工具返回 |
| 工具选错 | 工具描述相似或数量过多 | 检查工具定义 | 分组加载、改写描述 |
| 格式不符 | 约束位置靠前或缺少示例 | 检查输出约束位置 | 移到末尾、加示例 |
| 忘记早期信息 | 历史被压缩 | 检查摘要是否丢关键实体 | 关键信息外置成状态 |
| 循环不终止 | 缺少步数上限 | 检查循环控制 | 加 max_steps 和换策略指令 |
| 响应变慢 | 上下文过长 | 统计每轮 token | 动态裁剪、按需检索 |
4.5 几个我踩过的坑
第一个坑是把检索结果直接拼进历史。检索结果每轮都可能变,拼进历史会导致历史快速膨胀。正确做法是检索结果单独放一块,每轮重新检索、重新注入,不累积。
第二个坑是摘要用模型生成但不校验。模型生成的摘要可能丢关键信息,甚至编造内容。我的做法是摘要只压缩对话,不压缩结构化数据,并且摘要生成后用规则校验关键实体是否保留。
第三个坑是忽略输出预留。很多人算上下文预算时只算输入,忘了输出也要占窗口。结果输入塞满了,模型没空间输出,直接截断。预留 15% 到 20% 给输出是必要的。
第四个坑是系统指令写太长。系统指令每轮都带,写 2000 token 就是每轮浪费 2000。我的经验是系统指令控制在 500 token 以内,把细节放到工具描述和输出约束里。
5. 上下文工程的进阶思路
5.1 分层上下文架构
当 Agent 变复杂时,单一上下文容器会不够用。我现在的做法是分三层:
- 全局层:系统指令、全局约束,每轮都带,不变。
- 会话层:当前会话的历史、状态,随会话变化。
- 步骤层:当前步骤的检索结果、工具返回,每步重建。
这样分层的好处是,每层的更新频率不同,可以独立压缩和替换。全局层几乎不动,会话层按轮压缩,步骤层每步清空。实测能显著降低上下文管理的复杂度。
5.2 上下文工程的评估指标
怎么判断上下文工程做得好不好?我关注四个指标:
- 任务完成率:最终是否解决了用户问题。
- 平均步数:完成任务的循环次数,越少越好。
- token 效率:完成任务消耗的总 token,越低越好。
- 错误率:工具选错、格式错误、循环不终止的比例。
这四个指标要一起看。只追求完成率,可能会把上下文塞满;只追求 token 效率,可能会牺牲完成率。我的经验是先把完成率做到 90% 以上,再优化 token 效率。
5.3 不同模型下的上下文策略差异
不同模型对上下文的敏感度不一样。有的模型对系统指令遵循度高,系统指令可以写简略些;有的模型对末尾内容更敏感,输出约束必须放最后。我的做法是给每个模型维护一套上下文模板,换模型时先跑一轮评估,再调整模板。
另外,小模型对大上下文的承受力更差。如果你用 7B 级别的模型,上下文最好控制在 4K 以内,并且要更激进地压缩历史。大模型可以放宽到 16K 甚至 32K,但也不建议塞满。
5.4 上下文工程和 RAG 的配合
RAG 解决的是“知识从哪来”,上下文工程解决的是“知识怎么放”。两者配合的关键是:检索结果不要直接拼进历史,而是作为独立模块,每轮按需注入。检索的 top-k 也不要太大,3 到 5 条足够,太多会稀释关键信息。
还有一个细节是检索结果的排序。我习惯把最相关的放最前面,因为模型对开头和末尾的注意力最强。如果检索结果有冲突,在注入时加一句“以下信息按相关度排序,优先参考前面的内容”。
5.5 上下文工程的未来方向
从我个人观察来看,上下文工程正在从“手工调参”走向“自动优化”。现在已经有一些工作在尝试用模型自己管理上下文,比如让模型决定哪些历史该保留、哪些该丢弃。这个方向很有意思,但目前的可靠性还不够,关键任务还是得靠规则兜底。
另一个方向是上下文压缩的专门模型。用小模型做摘要和压缩,比用大模型便宜得多,速度也快。我在一些项目里试过用 1B 级别的模型做历史压缩,效果可以接受,成本降了一个数量级。
最后再分享一个小技巧:如果你不确定上下文该怎么配比,先做一个最小可用版本,把系统指令、工具定义、最近三轮历史、当前输入放进去,跑一批测试用例,看失败案例集中在哪,再针对性调整。上下文工程没有万能公式,都是根据具体任务迭代出来的。我在实际项目里的体会是,前两版上下文设计基本都会推倒重来,第三版才趋于稳定,所以别怕改,快速迭代比一次设计到位更现实。