你大概遇到过这种情况:一个 Agent 任务开头跑得挺聪明,跑到中后段开始反复读同一个文件、忘记开头定好的规则、把已经做过的事又做一遍。第一反应通常是「提示词没写好」,然后花半天改提示词——结果没什么用。
真正的病因往往是上下文腐化(context rot):上下文是一种有限且会退化的资源,随着窗口被填满,模型的判断质量会下降。修它的办法不是改提示词,而是上下文工程。
先搞懂:什么时候根本不该用 Agent
这是最省事的一招——很多被称作「Agent 任务」的东西,其实是工作流。工作流是你写好代码路径、模型只填空;Agent 是模型自己决定路径。
| 判断维度 | 选工作流(固定代码路径) | 选 Agent(模型决定路径) |
|---|---|---|
| 任务形态 | 步骤已知,分支可预测 | 开放式,步数事先不确定 |
| 控制流 | 你写 if / else | 模型决定下一步调什么 |
| 成本延迟 | 低且有上界 | 高,每轮都是一次模型调用 |
| 可调试性 | 高,就是普通代码 | 低,只能读执行轨迹 |
| 经验法则 | 先从这里开始,多数任务是工作流 | 分支空间真的开放时才升级 |
三条判断标准:①路径能不能提前用代码写出来——能,就用工作流;②犯错的代价能不能承受——不能,就降低自主度;③这活的价值能不能覆盖 Token 成本 + 人工复核成本——不能,就别自动化。
Step 1:把 Agent 循环的四条铁律焊死
Agent 不是框架,它是一个控制流模式:模型调工具 → 看结果 → 决定下一步 → 直到不再调工具。骨架长这样:
def run_agent(user_input, tools, tool_impls, max_turns=10):
messages = [{"role": "user", "content": user_input}]
for _ in range(max_turns):
resp = call_model(messages, tools=tools) # 1 模型决策
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use": # 4 不再调工具即完成
return resp
results = []
for block in resp.content: # 2 执行工具
if block.type == "tool_use":
try:
out = tool_impls[block.name](**block.input)
results.append(tool_result(block.id, out))
except Exception as e: # 3 错误回喂,别崩
results.append(tool_result(block.id, "Error: " + str(e), is_error=True))
messages.append({"role": "user", "content": results})
raise NeedsHuman("超出轮次上限,该人来接手了")
这段里有四条写错就是 bug 的规则:
① 把 assistant 的完整响应(含 tool_use 块)原样写回历史
—— 模型需要看见自己发出的调用,才能理解你回给它的结果
② 并行工具的所有结果放进同一条消息,各自带 tool_use_id
—— 拆成多条,等于教会模型以后别再并行调用了
③ 工具报错要作为结果回喂(标记 is_error),不要抛异常崩掉
—— 看到「Error: rate limited」的模型会退避重试;什么都看不到的模型只会卡死
④ 给轮次封顶
—— max_turns 加上「不再请求工具」的退出条件,是唯一能兜住失控的护栏
Step 2:第一板斧——清理陈旧工具结果
Agent 历史里最占地方也最没用的,就是早期那些已经被后续动作取代的工具返回值:
典型的垃圾占位:
· 第 2 轮读了整个 3000 行文件,第 5 轮已经改完了,
那 3000 行还老老实实躺在上下文里
· 连续三次搜索,前两次结果没用上,仍然全文保留
· 一次失败的调用返回了 500 行堆栈,只有第一行有信息量
清理策略(按激进程度递增):
A. 保留最近 N 次工具结果的全文,更早的替换成一行摘要
例:「[已读取 config.yaml,共 220 行,已用于第 4 步修改]」
B. 同一文件被多次读取时,只留最后一次
C. 失败调用只留错误首行 + 错误码,砍掉堆栈
Step 3:第二板斧——接近上限时压缩历史
触发时机:
上下文用量达到窗口的 70%-80% 时触发,别等到爆掉再压。
压缩时必须原样保留(不能被摘要掉):
· 用户最初的目标原文
· 已确认的硬性约束(禁用项、格式要求、命名规范)
· 尚未完成的待办清单
· 已经产生的外部副作用(发了什么、改了什么、建了什么)
可以放心摘要掉:
· 中间推理过程
· 已完成步骤的细节
· 被取代的旧方案讨论
压缩最常见的翻车:把「已经产生的副作用」摘要没了,导致 Agent 重复发一遍邮件、重复建一遍文件。凡是不可逆的动作,一定要在压缩后的摘要里显式列出「已完成,勿重复」。
Step 4:第三板斧——即时检索代替前置塞文档
反面做法:
任务开始就把 5 份 PRD、整个 API 文档、全部历史工单
一股脑塞进系统提示词,图个「省事」。
结果:还没开始干活,窗口已经用掉一半,
而且大部分内容这次任务根本用不上。
正确做法(just-in-time):
1. 开场只给「地图」:有哪些资料、各自讲什么、怎么查
2. 需要时再检索:模型自己决定去读哪一段
3. 读回来的内容用完即清(回到 Step 2 的清理策略)
给「地图」的写法示例:
可用资料:
- docs/api.md 接口定义,查字段和错误码时读
- docs/style.md 代码规范,写新文件前读
- tickets/ 历史工单,排查同类问题时按关键词搜
Step 5:工具描述就是提示词
差的工具描述:
search_docs: 搜索文档。
好的工具描述:
search_docs: 在项目文档中做关键词检索。
当你需要确认接口字段、错误码、配置项含义时调用;
不要用它来读已知路径的文件(那应该用 read_file)。
输入具体关键词而非整句问题,一次一个主题。
差别有多大:
只加一句「什么时候调用」,就能明显提升那些
「倾向于保守、不太敢调工具」的模型的正确调用率。
还要写「什么时候不要用」:Agent 乱调工具的常见原因是两个工具描述看起来差不多。显式写清边界(「不要用它来 X,那应该用 Y」)比堆形容词有效得多。
Step 6:把同一个工具挂到 MCP 上
工具的本质就是三件东西:名称 + 描述 + 输入 Schema。把它包成 MCP Server,任何支持 MCP 的 Agent 都能直接用:
好处:
· 同一个内部工具,Claude Code、Cursor、自研 Agent 都能挂
· 描述和 Schema 只维护一份,不会各处版本漂移
· 2026-07-28 的无状态规范之后,每次调用都是自包含请求,
可以直接放在普通负载均衡后面横向扩容
迁移检查清单:
1. 工具是否有副作用?有的话在描述里明确标注
2. 输入 Schema 是否严格?宽松的 Schema 会诱发乱传参
3. 错误返回是否结构化?给模型可读的错误比堆栈更有用
4. 是否需要鉴权?无状态不等于无权限
症状 → 病因 → 处方
| 症状 | 多半的病因 | 处方 |
|---|---|---|
| 反复读同一个文件 | 旧的读取结果堆在历史里,模型认不出已经读过 | Step 2 清理陈旧结果,同文件只留最后一次 |
| 忘记开头定的规则 | 压缩时把硬性约束摘要掉了 | Step 3 把约束列入「不可摘要」白名单 |
| 重复执行不可逆动作 | 已产生的副作用未在摘要中标注 | Step 3 显式记录「已完成,勿重复」 |
| 该调工具时不调 | 工具描述没写清什么时候用 | Step 5 补「调用时机」和「不要用于」 |
| 一开场就慢且贵 | 前置塞了大量用不上的文档 | Step 4 改成给地图 + 即时检索 |
| 偶发无限循环 | 没设轮次上限 | Step 1 铁律④,max_turns 封顶 + 转人工 |