Agent 在生产为什么会出错
把一个 demo 跑通,和把它放到生产里一天处理几千个请求,是两件事。我们这一年踩过的坑大概可以归四类:
- LLM 输出格式不合规 — 你要 JSON,它给你 JSON 包在
```json围栏里,或者多塞了一句"以下是您要的结果:" - tool call 参数错 — 类型对了但值不在 enum 里、必填漏了、嵌套对象扁平化了
- 工具调用 timeout 或 503 — 下游不稳定,重试一次就好但没人写重试
- 业务校验失败 — 格式都对,但生成的 SQL 引用了不存在的列、生成的代码引了不存在的函数
第一反应往往是在每个出错的地方包 try/except,加日志,然后把异常吞掉返回兜底答案。这样做的代价是用户体验崩了——本来 Agent 该直接给结果,现在退化成"抱歉我没听懂"。
5 轮自动修复模式
先说结论,再说为什么。
原始调用 → 校验 → 通过?返回 : 把校验报告塞 prompt → 重调 → 校验 → ... 最多 5 轮 → 超限转人工
核心是:每一次失败都不是终点,而是下一次 prompt 的输入。不要让 LLM 重新猜,而是告诉它"你上次错在哪、错的具体行号、期望是什么"。
async function runWithRepair<T>(
task: Task,
validate: (out: string) => ValidationResult<T>,
opts = { maxRounds: 5 },
): Promise<RepairResult<T>> {
const history: Round[] = []
let prompt = buildInitialPrompt(task)
for (let round = 1; round <= opts.maxRounds; round++) {
const output = await llm.call(prompt)
const result = validate(output)
history.push({ round, output, result })
if (result.ok) {
return { ok: true, value: result.value, rounds: round, history }
}
// 关键:把结构化校验报告塞回去,不是把异常 message 拼接
prompt = buildRepairPrompt(task, output, result.report)
}
// 超 5 轮兜底:完整 history 落库 + 转人工
await escalate(task, history)
return { ok: false, history }
}
注意 result.report 是结构化的——错误类型、出错位置(行号、字段路径)、期望值、当前值。不是 "validation failed: invalid input" 这种字符串。
为什么是 5 轮
经验值。从我们的数据看:
| 轮次 | 累计通过率(SCL agent,约 4000 个生产请求) | |------|-------------------------| | 1 | 60% | | 2 | 78% | | 3 | 86% | | 4 | 90% | | 5 | 92% | | 6+ | 92.5%(边际收益已经塌了) |
第 5 轮之后再修,token 和延迟成本曲线陡升,通过率几乎不动。这不是普适规律,业务复杂度高的可以放到 7 轮,简单 JSON 抽取 3 轮就够。但 "5" 是我们目前默认的起点。
更重要的一点——超过 5 轮的样本不是垃圾,是金子。这些 case 大概率是:prompt 描述模糊、工具 schema 设计有歧义、训练数据里没见过的边界。我们每周固定 review 这批样本,半年下来 prompt 改了 11 版、tool schema 加了 3 个 enum、知识库补了 27 条规则。
校验报告的格式很关键
同样是"SQL 校验失败",下面两种喂给 LLM,效果差一个数量级。
反例:
Validation failed: column not found
正例:
errors:
- type: COLUMN_NOT_FOUND
location: "line 3, col 18"
snippet: "select user_age from users"
issue: "users 表没有 user_age 列"
candidates: ["age", "birth_year"]
hint: "你可能想要 age"
第二种里面有三个 LLM 决策必需的信号:精确位置、可选项、人写的 hint。少了 hint,LLM 在多个 candidates 之间会瞎猜;少了位置,LLM 会重写整段而不是定点改。
每轮要清空 history 吗
看场景。
- 生成类任务(写 SQL、写代码、写 JSON):保留 history。LLM 看到自己之前错在哪,第二次会绕开同一个坑。
- 抽取/分类任务(从一段话里抽实体):清空 history,只保留校验报告。否则 LLM 会被自己上次的错误判断锚定。
我们 SCL 里两种都用——生成类节点保留,抽取类节点清空。这点从框架默认配置看不出来,需要业务自己定。
兜底转人工不是失败
很多团队把"转人工"当成 Agent 没用了的标志。我反着看:有兜底的 Agent 才能放生产。没有兜底就只剩两条路——要么把请求直接返回错误(用户跑路),要么死循环烧 token(账单跑路)。
兜底要做三件事:
- 完整 trace 入库 — 输入、5 轮的每次输出、每次校验报告、最终错误类型。少一项,事后都没法分析
- 人工有清晰的 UI — 不要让人去翻 JSON 日志。我们做了个简单的 web 页面:左侧原始请求,右侧 5 轮 diff,下面一个"采纳第 N 轮 / 手动修改 / 标记为不可解"的按钮
- 人工修过的 case 反向回流 — 不是简单加进 few-shot,而是分析错误模式,决定是改 prompt、加 tool 参数校验、还是补知识库
这套模式还能套到哪
任何"输出有可校验格式"的 Agent 都能套:
- NL2SQL — 用 EXPLAIN 校验
- 代码生成 — 用 lint + 单测校验
- API 调用 — 用 schema validator 校验参数
- JSON 抽取 — 用 JSON Schema 校验
- markdown 文档生成 — 用 marked + 自定义规则校验
不能套的场景是"输出本身就是开放性文本",比如写邮件、写文案。这些场景的"校验"是主观的,套这个模式会过度修改、越改越平庸。
最后一句
我们一开始把 5 轮上限放到 10,跑了一周发现 token 账单飙到原来的 3 倍。然后退到 3 轮,通过率掉到 86%。最后停在 5。这种数字没有银弹,先上线,再用真实生产数据调。我现在写下的 5 也许过半年就被打脸——但这个判断你自己跑数据,比任何博客都靠谱。