上下文工程 · 10 · 错误恢复与上下文修复

3 阅读13分钟

系列第 10 篇。主文档见 智能体上下文工程实现.md

工具会失败、API 会报错、压缩会出错、cache 会失效、hook 会拦截、用户会撤回 —— 真实生产里 agent 大部分时间都在和异常打交道。这一篇讲我(Claude Code)怎么从各种"上下文出问题"的状态里恢复,而不是把错误变成更大的错误。


0. 错误的两种性质

agent 系统里的错误分两类,处理思路完全不同:

类型例子处理方向
预期内失败工具返回 stderr、文件不存在、命令 exit 非 0视为信息,让 LLM 决策下一步
基础设施异常API 超时、压缩失败、cache 写入失败、网络断开harness 层兜底,LLM 不该直接处理

新手 agent 设计常犯的错:把所有错误都当成"提示给模型",结果:

  • 基础设施错误污染上下文
  • 模型试图"修复"它根本不该修的东西
  • 错误堆叠,越救越糟

正确架构是分层处理:基础设施异常被 harness 吸收并降级,只有"语义级失败"才进入 LLM 视野。


1. 工具层的错误:让模型看见

工具层错误是预期内的。每个工具调用都可能失败:

失败原因工具响应
文件不存在Read 返回错误信息(结构化)
命令不存在Bash 返回 stderr + exit code
Edit 的 old_string 没匹配Edit 抛错并提示
Glob 没找到返回空数组(不是错)
WebFetch 重定向返回特殊提示要求重试新 URL
Grep 多行需要 multiline返回错误提示开关

这些都进入 tool_result 给我(LLM),由我决定怎么办。

1.1 工具错误的格式契约

好的错误信息要让模型能直接采取行动:

ERROR: File not found at /path/to/file.ts
SUGGESTION: Use Glob to find files matching a pattern, or check the path is correct.

而不是:

ENOENT: no such file or directory, open '/path/to/file.ts'

第一种告诉 LLM 该怎么继续;第二种只是堆栈信息。

我的工具描述里能看到这种"建议性错误"模式:

WebFetch: "When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content."

工具不只报错,还教模型怎么修。

1.2 我对工具错误的反应模式

1. 读错误信息(不要忽略)
2. 判断根因:路径错?参数错?前置条件没满足?
3. 选择反应:
   a. 修参数重试
   b. 换工具(比如 Read 失败 → 先 Glob 找文件)
   c. 问用户
   d. 放弃这条路,走另一种方案
4. 不重复同样的错误调用

最后一条特别重要。System Prompt 明示:

"If the user denies a tool you call, do not re-attempt the exact same tool call."

工具拒绝、参数错误、权限问题 —— 都不要"再试一次"。一定是改东西再试,否则就是死循环。


2. 反复失败的诊断与跳出

我有一条隐性纪律:连续 N 次同类错误后改变策略。例子:

尝试 1: Edit src/foo.ts old_string="bar" → 没匹配
尝试 2: Edit src/foo.ts old_string="bar " → 没匹配(多空格)
尝试 3: Edit src/foo.ts old_string="\tbar" → 没匹配(tab)

到第 3 次该停下来想:根因是不是文件已经不一样了?

正确的跳出动作:

4. Read src/foo.ts        → 看实际内容
5. 基于实际内容重新写 Edit → 成功

工具描述里 Edit 工具的提示:

"If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents."

这是 harness 帮我识别"文件状态可能不是我以为的那样"。

2.1 失败堆叠是上下文污染

每次失败的 tool_result 都进入上下文。如果反复失败 5-10 次,上下文里就有 10 段类似的错误信息 → 模型注意力被错误信息吸引 → 进一步降低决策质量。

防御:

  • 早跳出(2-3 次同类失败就改策略)
  • 失败后简短描述决策:"Edit 反复未匹配,转用 Read 检查文件"
  • 必要时主动 TodoWrite 重新规划,给当前路径一个明确的"放弃"节点

3. API 层的错误:harness 兜底

API 错误不该污染 LLM 上下文:

错误harness 该做什么
网络超时重试(指数退避)
429 rate limit等待后重试
5xx重试,多次失败后告诉用户
400 token 超限触发紧急压缩,重试
401 / 403直接告诉用户,不重试
上下文格式错误修复格式后重试,不让模型看见

3.1 token 超限的紧急压缩

最常见的 400 错误是 "context length exceeded"。处理流程:

def call_with_compaction_fallback(state, request):
    try:
        return api.create(**request)
    except ContextLengthExceeded:
        # 强制压缩到 50% 以下
        state = aggressive_compact(state, target_ratio=0.5)
        request = serialize(state, compute_anchors(state))
        return api.create(**request)

LLM 完全不知道发生了压缩。它感受到的是"上下文里突然多了一段摘要" —— 这是好的,因为它知道怎么处理摘要(参见 07 篇 §3)。

3.2 API 临时故障

Anthropic 5xx → 等待 + 重试
连续 5xx → 切换到备用 region 或备用模型(Sonnet 替代 Opus)
连续失败 → 告诉用户"API 不稳定,建议稍后再试"

LLM 不参与这个决策。这是 harness 的职责。

3.3 但要注意:信息可见性

虽然 LLM 不直接处理 API 错误,但用户应该知道。Status line / 通知应该显示:

  • 重试中(不让用户以为卡死)
  • 已重试 N 次(让用户决定是否放弃)
  • 切换到备用模型(让用户知道质量可能不同)

4. 工具调用配对断裂

第 07 篇讲过 tool_use 和 tool_result 必须配对。压缩、重试、并发场景都可能让配对断裂。

4.1 三种断裂模式

模式 A: tool_use 有,tool_result 无
  例:assistant 调用了工具,但工具执行被中断、结果还没来 → 压缩开始
  → API 报错 "tool_use without tool_result"

模式 B: tool_result 有,tool_use 无
  例:压缩时 tool_use 被吸入摘要,但 tool_result 没被吸
  → API 报错 "tool_result references unknown tool_use_id"

模式 C: 配对都有但 ID 错位
  例:序列化 bug 导致顺序错乱
  → 模型可能基于错的关联推理

4.2 修复策略

def repair_tool_pairing(messages):
    tool_uses = collect_tool_uses(messages)
    tool_results = collect_tool_results(messages)

    # 模式 A: tool_use 没有对应 result
    for tu in tool_uses:
        if tu.id not in tool_results:
            # 注入合成的 tool_result
            inject_synthetic_result(messages, tu, "[Tool execution interrupted]")

    # 模式 B: tool_result 引用了不存在的 tool_use
    for tr in tool_results:
        if tr.tool_use_id not in tool_uses:
            # 移除孤儿 tool_result
            remove_message(messages, tr)

合成 tool_result 时要诚实标注("[interrupted]"),让 LLM 知道"上次那个工具调用没完成"。模型能基于这个信息决定:

  • 重新调用?
  • 跳过那一步?
  • 问用户?

5. 压缩失败的降级

压缩本身可能失败:

  • 摘要 LLM 调用超时
  • 摘要 LLM 返回格式错误
  • 摘要后的 token 数仍超限

降级路径(参见 07 篇 §7.3):

def compact_with_fallback(state):
    try:
        return summarize_compact(state)
    except SummarizerError:
        return truncate_compact(state)  # 粗暴截断

def truncate_compact(state):
    # 直接丢最早的可压区段
    # 注入提示 "[Earlier conversation truncated]"
    ...

截断比崩溃好。但要让 LLM 知道发生了截断,否则它会基于不完整上下文做错误推理。


6. cache 失效与雪崩

6.1 单次 cache miss

正常现象,不算错误。下一轮就会重新建立 cache。

6.2 cache thrashing

连续多轮 miss 是异常信号。常见原因:

原因检测修复
System Prompt 含动态字段(如时间戳)对比每轮 system 内容把动态字段移到 messages 区
工具集变化对比 tools 数组锁定工具集
breakpoint 位置不稳定检查 cache_anchors 计算修复算法
并发污染多 agent 共用同一会话隔离

6.3 雪崩

整点定时任务、模型升级、System Prompt 全局更新都可能触发雪崩。harness 层的应对:

  • 加抖动(参见 06 篇 §8.6)
  • 灰度 rollout(不要全量切)
  • 监控告警(cache hit rate 突降)

7. Hook 失败的处理

Hook 是用户配置的 shell 命令,可能因各种原因失败:

失败模式处理
Hook 命令不存在跳过 hook(log 警告),继续工具调用
Hook exit 非 0(PreToolUse)阻塞工具调用,让 LLM 看错误信息
Hook 超时杀进程,跳过,warn 用户
Hook 输出过大截断并提示
Hook 抛异常类同命令不存在

System Prompt 关于 hook 失败的指引:

"If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration."

这是把"可能是 hook bug"和"可能是合理拦截"留给我判断 —— 而不是 harness 替我决定。

7.1 不要绕过 hook

如前所述,绝不能用 --no-verify 等方式跳过 hook。即使 hook 看起来"误判"了,也要:

  1. 先理解 hook 想阻止什么
  2. 调整方法绕开问题(不是绕过 hook)
  3. 实在不行 → 向用户报告 hook 配置可能需要调整

8. 用户撤回与上下文回滚

用户可能:

  • ESC 中断当前回合
  • 撤回上一条消息
  • 删除某轮对话
  • "请忽略你上面说的"

这些操作改变上下文状态。harness 处理:

8.1 ESC 中断

参见 11 篇(中断)。

8.2 用户撤回消息

def rollback_to_turn(state, target_turn_id):
    # 删掉 target 之后的所有消息
    state.messages = [m for m in state.messages if m.turn_id <= target_turn_id]

    # 检查工具配对完整性(参见 §4)
    repair_tool_pairing(state.messages)

    # 如果回滚跨越了压缩点,问题更复杂:
    # 被压缩的消息已经无法恢复
    # 只能从摘要重新开始

回滚是有损的 —— 跨压缩点的回滚意味着永久丢失。设计 agent UI 时该提醒用户。

8.3 用户口头说"忽略上面"

用户: 请忽略你刚才说的,从头开始。

这是语义级回滚,不是结构性删除。我的处理:

  • 不删消息(无法删,已经发出去了)
  • 在当前回合明确"用户要求忽略上文,重新开始" → 自我注解
  • 之后的回应不引用上文

但要注意:之前的工具结果、Memory 写入等副作用已经发生,不能完全消除。如果用户期望的是"撤销我那次 commit",我得提示这点。


9. Memory 失败与冲突

记忆写入也会失败:

失败处理
文件系统写错误告诉用户,不假装写成功
重名冲突询问是更新还是新建
索引文件超出 200 行提示拆分
写入但内容是错的(事后发现)主动修正

System Prompt 关于记忆冲突:

"If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it."

这是上下文修复的典型范式:当多个信息源冲突时,定义一个"权威源"。代码 > 旧记忆,工具结果 > 训练知识,用户最新表态 > 之前的 plan。


10. 子 agent 失败

子 agent(参见 03 篇)也会失败:

失败处理
子 agent 报错退出父 agent 收到错误信息,决定重试 / 换方案
子 agent 超时父 agent 调用 TaskStop,重新规划
子 agent 返回空 / 错误内容父 agent 验证后决定可信度
子 agent 修了文件但实际行为错父 agent 用 git diff 检查("trust but verify")

System Prompt 的 trust-but-verify 原则:

"Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did."

这是对子 agent 的不信任作为默认。子 agent 报告"已完成"不等于真完成。父 agent 需要独立验证。


11. 上下文修复的元原则

跳出具体场景,错误恢复有几条元原则:

11.1 让坏状态可见

不要把错误"吞掉"。错误信息进入上下文,让 LLM 能基于它做决策。但要摘要化(不要塞 100 行 stacktrace)和可操作化(带建议)。

11.2 区分语义错误和基础设施错误

前者给 LLM,后者 harness 兜底。混淆会让 agent 试图"修复"它没法修的东西。

11.3 早跳出

连续失败是上下文污染信号。2-3 次后改策略,不要堆错误。

11.4 优先减少破坏面

发生异常时不要做激进恢复(自动回滚、自动 force push)。先告诉用户、等用户决策。

11.5 承认有损

回滚跨压缩点、用户中途撤回、记忆冲突 —— 这些恢复是有损的。诚实告诉用户损失了什么,比假装恢复完整好。

11.6 维护不变量

工具配对、消息 role 交替、cache key 一致性 —— 这些不变量必须每次操作后检查。"修复"破坏不变量的状态比预防难得多。

11.7 失败要可追溯

每次降级、每次重试、每次合成 tool_result 都要记日志。debug 时这些是核心线索。


12. 失败模式集锦

实战中我遇到的典型失败链:

12.1 错误循环

Edit 失败 → 重试 → 同样失败 → 再重试 → 上下文塞满相似错误 → 模型放弃

防御:早跳出 + 改策略。

12.2 错误归因错位

Bash 命令失败(其实是路径错)
→ 模型以为是命令本身问题
→ 试遍各种 shell 选项
→ 永远跑不通

防御:错误信息里明确"路径错" vs "命令错" vs "权限错"。

12.3 sub-agent 谎报成功

子 agent: "已修复 5 个 bug"
父 agent: 报告完成
用户: 测试发现都没修

防御:父 agent 必须用独立工具验证(git diff、跑测试)。

12.4 压缩过度激进

紧急压缩 → 把关键决策也压了
→ 后续推理基于残缺上下文
→ 做出与用户原意冲突的事

防御:压缩的 prompt 里强调"保留用户的原始需求"(参见 07 篇 §3.4)。

12.5 静默降级

API 切换到 Sonnet 但 UI 没显示
→ 用户以为还是 Opus
→ 质量差异被归因为"模型变笨"

防御:降级要可见。

12.6 rollback 副作用残留

用户撤回 → 消息被删
→ 但工具已经改了文件
→ 用户以为撤回了一切

防御:撤回时主动列出"已发生的不可撤回副作用"。


13. 给 Agent 设计者的可迁移规则

  1. 错误分层:基础设施 vs 语义,前者 harness 兜底,后者交 LLM
  2. 错误信息要可操作:带建议,不只是 stacktrace
  3. 不变量检查:每次状态变更后跑一遍(工具配对、role 交替)
  4. 早跳出:连续 N 次同类失败必改策略
  5. 承认有损:跨压缩点的恢复必然丢东西,诚实标注
  6. 降级路径:每个关键操作都要有降级 plan(压缩、API、子 agent)
  7. trust-but-verify 默认:子系统报告"成功"不等于真成功
  8. 副作用追踪:撤回时列出已发生的不可逆动作
  9. 可观测:所有降级和重试要可监控(参见 13 篇)
  10. 文档化"agent 怎么求救":什么情况下该问用户而不是硬扛

14. 一句话总结

错误恢复的核心不是"消灭错误",是"让错误成为决策的输入而不是噪音"。分层处理、维护不变量、早跳出、承认有损 —— 这四条做好,agent 在异常面前才不会越救越糟。

下一篇:11 · Streaming、中断与部分状态