Harness Engineering

第7章 工具结果处理与错误恢复

作者 杨艺韬 · 7,775 字

第7章 工具结果处理与错误恢复

“A system that never fails is a system that was never used.” — 分布式系统领域的老话

“业余 Agent 和生产级 Agent 的差别,不在功能多少,在对失败的态度。” —— 杨艺韬

本章要点

  • 工具结果生命周期四阶段:执行 → 捕获 → 格式化 → 反馈
  • 三种格式化策略:原样返回 / 截断 / 结构化摘要——按输出体积和信息密度选
  • 五类核心错误 + 可恢复性分级——盲目重试是最常见反模式
  • 模型即错误处理器:忠实反馈错误,让 LLM 自己决定恢复路径
  • 毒循环(Poison Loop)检测与熔断——生产事故的头号元凶
  • 部分成功的透明处理——永远不隐藏复杂性
  • 用户升级机制——知道何时放弃是一种智慧
  • 生产级日志体系——事后诊断的唯一依据

7.1 工具结果的完整生命周期

在前两章中,我们讨论了工具的设计与编排。但一个常被忽视的关键环节是:工具执行完毕后,结果如何被处理、格式化、反馈给模型,以及模型如何基于结果决定下一步行动。

flowchart LR
    E[① 执行工具] --> C[② 结果捕获<br/>成功/失败/超时]
    C --> F[③ 格式化<br/>截断/摘要/原样]
    F --> M[④ 反馈给模型]
    M --> D{模型决策}
    D -->|继续| N[下一步行动]
    D -->|重试| E
    D -->|换方案| A[使用替代工具]
    D -->|放弃| U[请求用户帮助]

    style E fill:#dbeafe,stroke:#3b82f6
    style M fill:#fef3c7,stroke:#f59e0b
    style D fill:#dcfce7,stroke:#22c55e

这个过程可以分为四个阶段:

执行(Execute) → 结果捕获(Capture) → 格式化(Format) → 反馈(Feed Back)

阶段一:执行

Harness 将模型请求的工具调用分发给对应的工具实现。这一步需要处理超时、权限检查等前置条件。

阶段二:结果捕获

无论工具成功还是失败,Harness 都需要捕获完整的执行结果。成功时捕获返回值,失败时捕获错误类型、错误消息和堆栈信息。

关键原则:永远不要让异常逃逸到 Agent 循环之外。

async function executeToolSafely(
  tool: Tool,
  params: Record<string, unknown>
): Promise<ToolResult> {
  const startTime = Date.now();
  try {
    const result = await Promise.race([
      tool.execute(params),
      timeout(tool.timeoutMs ?? 30000)
    ]);
    return {
      status: 'success',
      output: result,
      durationMs: Date.now() - startTime
    };
  } catch (error) {
    return {
      status: 'error',
      errorType: classifyError(error),
      message: error.message,
      stack: error.stack,
      durationMs: Date.now() - startTime
    };
  }
}

为什么不能让异常逃逸?因为 Agent 循环是单一通道的——异常如果冒泡到最外层,整个会话就崩溃了。而一个生产级 Agent 应该能优雅地从任何工具失败中恢复。

阶段三:格式化

原始结果往往不适合直接塞进上下文窗口。一个 grep 命令可能返回数万行,一个 API 响应可能包含巨量的嵌套 JSON。格式化决定了模型能”看到”什么

阶段四:反馈

格式化后的结果以 tool_result 消息的形式注入对话历史,模型在下一轮推理时读取它,决定是继续调用工具、切换策略,还是生成最终回复。

这四个阶段构成了一个闭环。Harness 的质量,很大程度上取决于这个闭环的健壮程度。

生命周期的关键不变式

不变式 1: 每次调用都必然有一个结果(成功或失败),不存在"消失"
不变式 2: 结果总是以可解析的形式(structured)传给模型
不变式 3: 错误信息保留足够的恢复线索
不变式 4: 执行时间有上限(超时)
不变式 5: 结果的大小有上限(截断)

这五条不变式如果任何一条被违反,Agent 的可靠性就会出现漏洞。

7.2 结果格式化:原始输出 vs 结构化摘要

模型的上下文窗口是有限资源。将工具的原始输出不加处理地全部返回,既浪费 Token,又可能淹没真正重要的信息。但过度截断或摘要,又会丢失模型做决策所需的细节。

7.2.1 三种格式化策略

策略一:原样返回(Passthrough)

适用于输出简短、信息密度高的场景。比如一个文件读取工具返回 50 行代码,或者一个数学计算工具返回一个数字。原样返回的好处是零信息损失

策略二:截断(Truncate)

当输出超过预设阈值时,保留前 N 行或前 N 个字符,并附加一条说明:

“输出已截断,共 X 行,显示前 Y 行。”

截断简单粗暴,但它有一个重要优势——不会引入歧义。模型知道自己看到的是不完整的原始数据,而不是被改写后的数据。

function truncateOutput(output: string, maxLines: number = 200): string {
  const lines = output.split('\n');
  if (lines.length <= maxLines) return output;
  const truncated = lines.slice(0, maxLines).join('\n');
  return `${truncated}\n\n[输出已截断:共 ${lines.length} 行,显示前 ${maxLines} 行]`;
}

更智能的截断策略是头尾保留

function smartTruncate(output: string, maxLines: number = 200): string {
  const lines = output.split('\n');
  if (lines.length <= maxLines) return output;

  const headSize = Math.floor(maxLines * 0.7);
  const tailSize = maxLines - headSize;

  const head = lines.slice(0, headSize);
  const tail = lines.slice(-tailSize);
  const omitted = lines.length - headSize - tailSize;

  return [
    ...head,
    `\n[... 省略了 ${omitted} 行 ...]\n`,
    ...tail
  ].join('\n');
}

头尾保留的理由:

  • 头部通常包含上下文信息(imports、header、错误开头)
  • 尾部通常包含最终状态(test 结果、总结、错误栈底部)
  • 中间往往是重复的相似内容(循环 log 等)

策略三:结构化摘要(Summarize)

对原始输出进行语义提取,生成结构化的摘要。例如:

工具调用: search("error handling")
原始输出: 500 个匹配结果(约 15000 行)

摘要输出:
  找到 500 个匹配,分布在 23 个文件中
  最相关的 10 个文件 (按匹配数):
    src/error/handler.ts (45 matches)
    src/error/types.ts (38 matches)
    ...
  常见模式:
    1. try-catch blocks (320 matches)
    2. error class definitions (90 matches)
    3. error logging (60 matches)

结构化摘要信息密度最高,但实现成本也最高,且存在摘要过程引入错误的风险——摘要器理解偏差会传递到模型。

7.2.2 如何选择策略

一个实用的判断框架:

条件推荐策略理由
输出 < 100 行原样返回成本可接受,零失真
输出 100-1000 行,信息分布均匀头尾截断 + 总量提示保留最重要部分
输出 > 1000 行结构化摘要原样放不下
输出高度集中(少数关键行 + 大量噪音)过滤保留只保留有意义的行
输出是二进制或非文本摘要元信息描述类型/大小
错误输出保留错误部分,截断其他错误是关键决策依据

7.2.3 Claude Code 的格式化实践

Claude Code 的做法值得参考:

工具格式化策略特殊处理
Read(文件)原样 + 行号默认限 2000 行,offset/limit 按需
Grep(搜索)按文件分组 + 截断默认 250 条,超过提示 offset 续读
Bash(命令)截断(1 MB/30000 字符)stderr 和 stdout 分别保留
WebFetch提取主体去除 nav/ads/footer 噪音
Glob按时间排序最多 250 条路径

这些策略都在工具定义层面就确定了,而不是事后处理。格式化策略是工具的一部分,不是外挂

7.3 错误分类体系

工具调用可能遇到的错误远比想象中多样。建立清晰的错误分类是设计恢复策略的前提。

graph TD
    Error[工具调用错误]
    Error --> E1[❶ 工具未找到<br/>模型幻觉]
    Error --> E2[❷ 参数校验失败<br/>类型/格式不对]
    Error --> E3[❸ 执行错误<br/>文件不存在/网络失败]
    Error --> E4[❹ 超时<br/>操作耗时过长]
    Error --> E5[❺ 权限拒绝<br/>安全策略拦截]

    E1 -->|恢复| R1[提示模型可用工具列表]
    E2 -->|恢复| R2[返回 schema 提示重试]
    E3 -->|恢复| R3[反馈错误让模型调整]
    E4 -->|恢复| R4[终止进程返回超时信息]
    E5 -->|恢复| R5[请求用户授权]

    style E1 fill:#fef3c7,stroke:#f59e0b
    style E3 fill:#fee2e2,stroke:#ef4444
    style E5 fill:#dbeafe,stroke:#3b82f6

7.3.1 五类核心错误详解

第一类:工具未找到(Tool Not Found)

模型请求了一个不存在的工具。这通常意味着模型产生了幻觉,“发明”了一个它认为应该存在的工具。

典型场景:
- 模型想用 "find_file" 但实际工具叫 "Glob"
- 模型想用 "run_test" 但只能通过 Bash 运行
- 模型在上下文溢出后忘记了哪些工具可用

恢复策略:错误消息中列出所有可用工具 + 提示可能的正确选择。

第二类:参数校验失败(Parameter Validation Error)

工具存在,但模型提供的参数不符合 schema。比如缺少必填字段、类型不匹配、值超出范围等。

典型错误:
- file_path 用了相对路径(期望绝对路径)
- line_number 传成了字符串(期望 number)
- mode 传了 enum 外的值
- 缺少必填字段

恢复策略:错误消息中明确指出哪个字段错了,期望什么格式,提供一个正确示例。

第三类:执行错误(Execution Error)

参数合法,但执行过程中出错。比如文件不存在、网络请求失败、数据库连接断开等。

典型场景:
- Read: 文件不存在
- Edit: old_string 不匹配
- Bash: 命令返回非 0 退出码
- WebFetch: DNS 解析失败

恢复策略:返回详细的错误信息 + 可能的修复建议(见 7.5 节)。

第四类:超时(Timeout)

工具在规定时间内未完成执行。可能是操作本身耗时过长,也可能是死锁或无限循环。

典型场景:
- Bash 命令卡在等输入
- 网络请求长时间无响应
- 巨大文件的 Grep 耗时过长

恢复策略:终止进程(重要!避免僵尸进程),返回明确的 “timed out after Xms” 错误。

第五类:权限拒绝(Permission Denied)

工具被安全策略拦截。比如用户未授权某个危险操作,或沙箱策略禁止访问某个路径。

典型场景:
- Edit 试图修改 /etc/ 下文件
- Bash 执行 rm -rf
- WebFetch 访问被屏蔽的域名
- 用户在 plan 模式下拒绝写操作

恢复策略:清晰说明被拒绝的原因 + 如何获取权限。

7.3.2 错误的可恢复性

并非所有错误都值得重试。一个有用的分类维度是可恢复性

级别含义处理策略示例
确定可恢复暂时性故障,重试大概率成功自动重试 + 退避网络超时、临时文件锁
条件可恢复换方法可能成功反馈给模型调整参数错误、old_string 不匹配
确定不可恢复结构性问题不重试,升级用户工具不存在、权限被永久拒绝
function isRetryable(error: ToolError): 'yes' | 'maybe' | 'no' {
  switch (error.type) {
    case 'timeout':
    case 'network_error':
      return 'yes';  // 暂时性故障,值得自动重试

    case 'parameter_validation':
    case 'execution_error':
      return 'maybe'; // 模型可以换参数尝试

    case 'permission_denied':
    case 'tool_not_found':
      return 'no';   // 结构性问题,重试无意义
  }
}

7.3.3 特殊情况:模型幻觉错误

模型有时会”发明”不存在的工具或参数。这不是传统意义上的执行错误,而是模型的生成错误

处理这类错误的关键是:教育模型,而不是简单报错。

// ❌ 差的错误消息
"Error: Tool 'read_file' not found"

// ✅ 好的错误消息
"Error: Tool 'read_file' does not exist.
Available tools: Read, Edit, Write, Glob, Grep, Bash, Agent.
Did you mean 'Read'? It's the tool for reading file contents.
Example: Read({ file_path: '/absolute/path/to/file.ts' })"

后者包含了:

  1. 错误事实
  2. 可用工具列表
  3. 最可能的正确工具
  4. 如何使用

这样模型能从一个错误中学到三件事,而不是卡死在同一个错误上。

7.4 错误恢复策略

识别了错误类型之后,Harness 需要选择合适的恢复策略。这里有四种基本策略,它们可以组合使用。

7.4.1 重试与退避

对于暂时性故障,最简单的恢复策略是重试。但盲目重试可能加剧问题(比如对一个已经过载的服务反复请求),因此需要配合退避策略。

async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  maxRetries: number = 3,
  baseDelayMs: number = 1000
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries || !isRetryable(error)) throw error;

      // 指数退避 + 随机抖动(避免惊群)
      const delay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.random() * delay * 0.1;
      await sleep(delay + jitter);
    }
  }
  throw new Error('unreachable');
}

Harness 层重试 vs 模型层重试

需要注意:重试应该发生在 Harness 层面,对模型透明

场景: 网络闪断 → 工具超时

Harness 重试 3 次:
  - 第 1 次: 超时(500ms)
  - 第 2 次: 超时(1000ms)
  - 第 3 次: 成功

对模型的呈现:
  {"status": "success", "output": "..."}
  // 模型不知道发生了重试,也不需要知道

如果所有重试都失败,才告诉模型最终结果。这样:

  • 模型看到的世界更简单
  • 减少不必要的模型调用(省 token)
  • 暂时性故障不会干扰模型推理

7.4.2 回退到替代工具

当一个工具持续失败时,Harness 可以建议模型使用替代方案。但关键原则是:不是 Harness 自动切换工具,而是通过错误消息引导模型自主选择替代方案

// 错误消息中带有"建议工具"
return {
  error: `Edit failed: old_string "${oldStr}" not found in ${path}.

  Suggestion:
  1. Use Read tool first to see actual file content
  2. Then retry Edit with correct old_string

  Or consider:
  - Use Grep to find exact string in the codebase
  - Use Write to replace the entire file (only if complete rewrite)`
}

模型比 Harness 更理解当前任务的语境,让它来决策更合理。

7.4.3 请求用户帮助

某些错误只有人类才能解决:

错误类型人类介入的理由
需要输入密码安全,不能自动化
需要物理操作(插 USB)AI 无法触及物理世界
业务决策涉及业务判断,AI 不能代替
冲突解决需要人的主观判断
不可逆操作的确认安全红线

在这种场景下,Harness 应该暂停 Agent 循环,将问题清晰地呈现给用户,等待用户响应后再继续。设计要点是:向用户展示的信息要足够具体,让用户能做出决策,而不是简单地说”出错了”。

7.4.4 优雅降级

当工具完全不可用时,系统不应该崩溃,而应该降级到一个功能受限但仍然可用的状态:

原始能力降级能力
代码搜索(Grep)文件名匹配(Glob)
网络查询本地缓存
AI 代码审查静态分析工具
Elicitation 表单纯文本询问
彩色终端 UI纯文本

优雅降级的核心原则是:宁可给出一个不完美的结果,也不要给出零结果。同时,必须明确告知模型当前处于降级状态,让它在后续推理中考虑这一限制。

7.5 模型作为错误处理器

传统软件中,错误处理逻辑是开发者硬编码的:if error A then do X, if error B then do Y。但在 Agent 系统中,我们拥有一个前所未有的优势——模型本身就是一个通用的错误推理引擎

核心思路

将错误信息忠实地反馈给模型,让模型自己决定如何应对。

// ❌ 不要这样做——在 Harness 层硬编码恢复逻辑
if (error.type === 'file_not_found') {
  // 自动创建文件?自动搜索类似文件?
  // Harness 不知道模型的意图,很容易做错
}

// ✅ 应该这样做——让模型处理
function formatErrorForModel(error: ToolError): string {
  return [
    `工具执行失败。`,
    `工具名称: ${error.toolName}`,
    `错误类型: ${error.type}`,
    `错误详情: ${error.message}`,
    error.context ? `上下文信息: ${error.context}` : '',
    error.suggestion ? `建议: ${error.suggestion}` : ''
  ].filter(Boolean).join('\n');
}

为什么有效

实践中,这种方式效果惊人地好:

  • 模型看到”文件 /src/app.ts 不存在”后,会主动搜索正确的文件路径
  • 看到”参数 line_number 必须为正整数”后,会自行修正参数再试一次
  • 看到”操作被权限策略拦截”后,会向用户解释为什么需要该权限并请求授权
  • 看到”old_string 在文件中出现 3 次,需要 replace_all=true”后,会加参数重试

模型拥有人类工程师的通用推理能力。硬编码规则只能覆盖开发时能想到的情况,而模型能应对开发时没想到的情况。

错误反馈的三要素

要让这种机制高效运作,错误信息的质量至关重要。一条好的错误反馈应该包含三个要素:

  1. 发生了什么(What)——错误的客观描述
  2. 为什么发生(Why)——尽可能给出原因分析
  3. 可以怎么做(How)——如果存在已知的恢复路径,明确提示

对比示例

// 差:模型无法判断下一步
"Error: ENOENT"

// 中:模型能定位问题
"Error: File not found: /src/utils/helper.ts"

// 好:模型可以自主恢复
"File read failed: /src/utils/helper.ts does not exist.
Similar files in the directory:
  /src/utils/helpers.ts (note the plural)
  /src/utils/index.ts
  /src/utils/format.ts
Possible cause: filename typo (helper.ts vs helpers.ts)?
You can:
  1. Use Glob('src/utils/*.ts') to see all files
  2. Retry Read with the correct filename
  3. Ask user for the intended file"

三要素齐全的错误消息,让模型的恢复成功率提升数倍。

错误消息写作模板

[错误事实]: 简洁陈述发生了什么
[上下文]: 相关的额外信息(文件内容、当前状态)
[可能原因]: 1-3 条可能的根因猜测
[建议做法]: 1-3 条下一步建议

Claude Code 的 Edit 工具在 old_string 不匹配时,会返回文件的实际前后 20 行——这就是典型的”上下文”部分,让模型看到现状后能准确重试。

7.6 毒循环:检测与熔断

“毒循环”(Poison Loop)是 Agent 系统中最危险的故障模式之一:模型反复调用同一个失败的工具,每次都用相同或几乎相同的参数,每次都收到相同的错误,然后继续重试。

这种循环可能快速消耗 Token 预算,同时不产生任何有意义的进展。

7.6.1 典型的毒循环场景

场景 1: Edit 参数错误
  Attempt 1: Edit("old text A")  → error "not found"
  Attempt 2: Edit("old text A ")  → error (空格差异)
  Attempt 3: Edit("old text A")  → error (回到第1次)
  Attempt 4: Edit("old text A")  → error
  ...无限循环

场景 2: Bash 命令无效
  Attempt 1: Bash("pytest")  → error "command not found"
  Attempt 2: Bash("pytest")  → error
  Attempt 3: Bash("pytest")  → error
  ...模型没意识到环境里没有 pytest

场景 3: 搜索不存在的内容
  Attempt 1: Grep("nonexistent")  → no matches
  Attempt 2: Grep("nonexistent")  → no matches
  ...没切换关键词

毒循环的根因:模型没从错误中学到东西

7.6.2 检测毒循环

检测的核心逻辑是识别重复模式

interface CallRecord {
  toolName: string;
  paramsHash: string;  // 参数的规范化 hash
  status: 'success' | 'error';
  timestamp: number;
}

function detectPoisonLoop(
  history: CallRecord[],
  windowSize: number = 5,
  threshold: number = 3
): { detected: boolean; severity: 'low' | 'high'; suggestion: string } {
  const recent = history.slice(-windowSize);

  // 规则 1: 同工具连续失败
  const failedSameTool = recent.filter(
    r => r.status === 'error' &&
         r.toolName === recent[recent.length - 1].toolName
  );
  if (failedSameTool.length < threshold) {
    return { detected: false, severity: 'low', suggestion: '' };
  }

  // 规则 2: 参数几乎没变化
  const uniqueParams = new Set(failedSameTool.map(r => r.paramsHash));
  if (uniqueParams.size <= 2) {
    return {
      detected: true,
      severity: 'high',
      suggestion: `连续 ${failedSameTool.length} 次调用 ${failedSameTool[0].toolName},参数几乎未变。建议换方法。`
    };
  }

  // 规则 3: 参数在变但都在失败
  return {
    detected: true,
    severity: 'low',
    suggestion: `连续 ${failedSameTool.length} 次失败,参数在调整但都没成功。可能问题根因不同。`
  };
}

7.6.3 打破循环的三种措施

一旦检测到毒循环,Harness 需要主动干预。可采取的措施包括:

措施 1:注入系统提示

在下一轮对话中插入一条消息:

<system-reminder>
You have called Edit 4 times in a row, all failing with "old_string not found".
Please:
1. Use Read to see the actual file content
2. Analyze why your old_string doesn't match
3. Try a different approach OR ask the user for help

Do NOT retry the same Edit again.
</system-reminder>

措施 2:临时禁用工具

将失败的工具从可用工具列表中暂时移除,强制模型选择其他路径。这是一种更强硬的干预方式,适用于模型忽略提示仍然坚持重试的情况。

if (poisonLoopDetected && previousWarningIgnored) {
  // 从下一轮的 tools 列表中移除该工具 5 轮
  context.disabledTools.add({
    tool: failedTool,
    until: currentTurn + 5,
    reason: 'poison_loop_protection'
  });
}

措施 3:强制升级

直接暂停 Agent 循环,将当前状态呈现给用户,请求人工介入。这是最后的护栏。

7.6.4 预防胜于治疗

Claude Code 的做法是设置调用次数上限

const MAX_TOOL_CALLS_PER_TURN = 50;  // 每轮对话的工具调用上限
const MAX_SAME_TOOL_CONSECUTIVE = 10; // 同工具连续调用上限

if (toolCallCount > MAX_TOOL_CALLS_PER_TURN) {
  return { action: 'pause', reason: 'max calls exceeded, user review needed' };
}

这是一种简单但有效的熔断机制。用户可以随时中断 Agent,避免被失控消耗资源。

7.7 部分成功的处理

现实世界中,工具调用不总是”全部成功”或”全部失败”。一个批量操作可能成功处理了 3 个文件,但在第 4 个文件上失败了。

处理部分成功的关键原则是透明。Harness 必须让模型清楚地知道:哪些操作成功了,哪些失败了,系统当前处于什么状态。

7.7.1 结构化的部分结果

interface PartialResult<T> {
  completed: Array<{ item: string; result: T }>;
  failed: Array<{ item: string; error: string }>;
  skipped: Array<{ item: string; reason: string }>;
}

function formatPartialResult<T>(result: PartialResult<T>): string {
  const lines: string[] = [];
  lines.push(`操作部分完成。`);
  lines.push(`成功: ${result.completed.length} 项`);
  result.completed.forEach(c =>
    lines.push(`  [成功] ${c.item}`)
  );
  lines.push(`失败: ${result.failed.length} 项`);
  result.failed.forEach(f =>
    lines.push(`  [失败] ${f.item}: ${f.error}`)
  );
  if (result.skipped.length > 0) {
    lines.push(`跳过: ${result.skipped.length} 项`);
    result.skipped.forEach(s =>
      lines.push(`  [跳过] ${s.item}: ${s.reason}`)
    );
  }
  return lines.join('\n');
}

7.7.2 模型面对部分成功的四种决策

面对部分成功的结果,模型可能需要做出以下决策:

graph TD
    PS[部分成功]
    PS --> D1[继续处理失败项<br/>用不同方法重试]
    PS --> D2[回滚已成功的部分<br/>需要原子性]
    PS --> D3[接受现状并继续<br/>失败不影响目标]
    PS --> D4[请求用户决策<br/>无法判断]

    D1 --> E1[示例: 批量 Edit<br/>失败的文件单独处理]
    D2 --> E2[示例: 数据迁移<br/>必须要么全做要么不做]
    D3 --> E3[示例: 批量 format<br/>1 个失败不影响其他]
    D4 --> E4[示例: 部署到 3 台机器<br/>1 台失败,要不要回滚?]

    style D1 fill:#dcfce7,stroke:#22c55e
    style D2 fill:#fee2e2,stroke:#ef4444
    style D3 fill:#dbeafe,stroke:#3b82f6
    style D4 fill:#fef3c7,stroke:#f59e0b

7.7.3 工具设计中的部分成功

在工具设计时就需要考虑部分成功的场景。理想情况下,工具应该返回结构化的结果,明确标注每个子操作的状态,而不是简单地抛出一个异常然后丢失所有进度信息。

// ❌ 差的工具设计
async function batchEdit(edits: Edit[]): Promise<void> {
  for (const edit of edits) {
    await applyEdit(edit);  // 任何一个失败就全部丢失进度
  }
}

// ✅ 好的工具设计
async function batchEdit(edits: Edit[]): Promise<PartialResult<void>> {
  const result: PartialResult<void> = {
    completed: [], failed: [], skipped: []
  };
  for (const edit of edits) {
    try {
      await applyEdit(edit);
      result.completed.push({ item: edit.file, result: undefined });
    } catch (error) {
      result.failed.push({ item: edit.file, error: error.message });
      // 不抛出异常,继续处理下一项
    }
  }
  return result;
}

7.8 用户升级:何时放弃、如何交接

Agent 不是万能的。存在一些场景,继续让 Agent 自行处理不仅不会带来进展,反而会浪费资源甚至造成损害。识别这些场景并及时升级给用户,是成熟的 Agent 系统的标志

7.8.1 升级的触发条件

以下情况应该触发用户升级:

触发条件场景示例
安全敏感操作删除数据、修改权限、推送到生产环境
歧义决策存在多个合理方案,无法判断用户真实意图
持续失败多次重试和策略切换后仍未解决
资源耗尽即将用完 Token 预算或时间限制
工具被阻止需要的工具被安全策略拦截,无法绕过
毒循环检测到反复失败的循环
需要外部资源需要用户提供密码、确认、物理操作

7.8.2 升级的信息质量

升级不是简单地说”我遇到了问题”。一个好的升级应该包含:

## 升级给用户的模板

**当前任务**: 我在帮你把 API v1 迁移到 v2

**已完成**:
- ✅ 更新了 5 个文件的 import 路径
- ✅ 重构了 UserService 的 3 个方法

**遇到的问题**:
在修改 src/auth/login.ts 时,发现该文件使用了 v1 特有的 session 字段,
而 v2 完全改变了 session 结构。

**我尝试过**:
1. 直接替换字段名 → 类型检查失败
2. 查找 v2 的等价字段 → 没有直接对应
3. 阅读 v2 迁移文档 → 需要结构性重写

**我的建议**:
需要你决定:
A. 在 login.ts 中做全面的结构重写(约 50 行改动)
B. 暂时保留 v1 接口,通过适配层桥接
C. 跳过 login.ts,标记为需要后续人工处理

请告诉我选择哪个方向。

Claude Code 在遇到被阻止的工具调用时,会清晰地告诉用户它想执行什么操作、为什么需要权限,然后等待用户确认。这种模式将决策权交还给用户,同时不丢失上下文信息

7.8.3 升级之后

升级不是终点——用户给出指示后,Agent 应该:

  1. 感谢用户的指引——建立良好的协作氛围
  2. 复述理解——确认自己理解正确
  3. 继续执行——不要反复请求
  4. 记录决策——用户的判断可以作为未来类似情况的参考

7.9 日志与诊断

在生产环境中,当 Agent 表现不符合预期时,你需要能够回溯发生了什么。好的日志体系是事后诊断的唯一可靠依据

7.9.1 该记录什么

每一次工具调用至少应该记录以下信息:

interface ToolCallLog {
  // 基础信息
  callId: string;          // 唯一标识
  sessionId: string;       // 所属会话
  turnId: string;          // 所属对话轮次
  timestamp: string;       // ISO 时间戳
  toolName: string;        // 工具名称

  // 输入
  parameters: unknown;     // 调用参数(脱敏后)
  parametersHash: string;  // 参数 hash(用于毒循环检测)

  // 输出
  status: 'success' | 'error';
  output?: string;         // 成功时的输出(可截断)
  outputLength?: number;    // 原始输出长度(截断前)
  error?: {
    type: string;
    message: string;
    stack?: string;
    suggestion?: string;
  };

  // 性能
  durationMs: number;
  retryCount: number;
  queuedMs?: number;        // 排队等待时间

  // 上下文
  modelDecision?: string;  // 模型为什么调用这个工具(从推理中提取)
  precedingCalls?: string[];  // 前几次工具调用的 ID

  // 安全
  permissionMode: string;   // 当时的权限模式
  userApproved?: boolean;   // 如果需要用户批准,批准了吗
}

7.9.2 日志分级

不是所有信息都需要同等对待:

级别触发条件用途
ERROR工具失败且无法自动恢复人工关注、告警
WARN失败但通过重试/回退恢复趋势分析
INFO正常执行完成性能统计、用量分析
DEBUG详细参数、完整输出、模型推理排查问题

7.9.3 敏感信息处理

日志中不应出现用户的敏感数据。在记录工具参数和输出时,需要对以下内容进行脱敏:

  • API 密钥和令牌
  • 密码和凭据
  • 个人身份信息(PII)
  • 文件内容中的敏感业务数据
const SENSITIVE_PATTERNS = [
  /api[_-]?key["\s:=]+['"](\w+)['"]/gi,
  /password["\s:=]+['"](\w+)['"]/gi,
  /token["\s:=]+['"](\w+)['"]/gi,
  /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,  // email
  /\b\d{16}\b/g,  // credit card
];

function redact(text: string): string {
  let result = text;
  for (const pattern of SENSITIVE_PATTERNS) {
    result = result.replace(pattern, '[REDACTED]');
  }
  return result;
}

更好的做法是在工具定义中标注哪些参数是敏感的,由 Harness 在记录时自动过滤:

const GitHubTokenTool = {
  name: 'create_issue',
  parameters: {
    token: { type: 'string', sensitive: true },  // 标记为敏感
    title: { type: 'string' },
    body: { type: 'string' },
  },
};

// 记录时自动脱敏
const logParams = Object.fromEntries(
  Object.entries(call.parameters).map(([key, value]) => {
    const isSensitive = tool.parameters[key]?.sensitive;
    return [key, isSensitive ? '[REDACTED]' : value];
  })
);

7.9.4 可观测性的三个支柱

工具调用的可观测性包括三个支柱:

graph TD
    Obs[可观测性]
    Obs --> M[Metrics<br/>度量]
    Obs --> L[Logs<br/>日志]
    Obs --> T[Traces<br/>追踪]

    M --> M1[工具调用频次]
    M --> M2[成功率]
    M --> M3[P99 延迟]
    M --> M4[token 消耗]

    L --> L1[每次调用详细]
    L --> L2[错误堆栈]
    L --> L3[用户决策]

    T --> T1[跨工具调用链]
    T --> T2[因果关系]
    T --> T3[关键路径]

    style M fill:#dbeafe,stroke:#3b82f6
    style L fill:#fef3c7,stroke:#f59e0b
    style T fill:#dcfce7,stroke:#22c55e

三者互补:

  • Metrics 告诉你”有什么问题”(趋势、异常)
  • Logs 告诉你”问题具体是什么”(每次调用的细节)
  • Traces 告诉你”问题发生在哪个路径”(工具调用链)

7.10 四个反模式

反模式一:吞掉所有异常

现象try { ... } catch { } 到处都是,错误被无声吞掉。

后果:Agent 看不到失败,无法恢复;问题在生产环境隐蔽很久。

对策:异常必须被记录并反馈给模型,哪怕是 Harness 自己处理了。

反模式二:过度抽象错误消息

现象:所有错误都包装成 “Internal server error”。

后果:模型无法推断原因,只能盲目重试或放弃。

对策:保留错误的类型和细节,在用户界面再做友好化。

反模式三:自动恢复导致的行为隐藏

现象:Harness 自动重试、自动切换工具,但不告诉模型。

后果:模型对系统状态的理解与实际脱节,做出错误推理。

对策:重要的自动行为要在下一轮 context 中通知模型(如果会影响决策)。

反模式四:无限制的错误重试

现象:模型反复失败但 Harness 不干预。

后果:token 耗尽、用户等待超时、资源浪费。

对策:毒循环检测 + 熔断 + 升级机制。

7.11 本章小结:错误恢复的七条原则

工具结果处理与错误恢复是 Agent 系统中最”不性感”但最关键的工程环节。一个 Agent 的可靠性上限,不取决于它能调用多少工具,而取决于它在工具失败时的应对能力

核心原则

  1. 结果格式化服务于模型理解——不是越多越好,也不是越少越好,而是刚好够模型做出正确决策
  2. 错误分类驱动恢复策略——不同类型的错误需要不同的处理方式,盲目重试是最常见的反模式
  3. 让模型成为错误处理器——给它充分的错误上下文,它往往能找到你没预想到的恢复路径
  4. 主动检测毒循环——这是生产系统中最常见的资源浪费来源
  5. 透明处理部分成功——不要隐藏复杂性,让模型和用户都清楚当前状态
  6. 及时升级给用户——知道何时放弃,是一种智慧
  7. 日志是你唯一的时光机——事后诊断全靠它

核心口号

Tools fail. Good agents learn from failures. Great harnesses ensure agents can learn.

工具会失败。好的 Agent 从失败中学习。伟大的 Harness 确保 Agent 能学习。

下一章,我们将进入提示词工程的领域,探讨如何通过精心设计的 system prompt 来塑造 Agent 的行为模式。


延伸阅读:错误处理的三代演化

Agent 错误处理的思路、经历了三代演化、每一代都是对前一代局限的回应第一代(2022-2023)——“直接报错”——工具失败就把原始错误扔回给 LLM、LLM 自己想办法——这种粗放式处理是 Agent 框架刚起步时的常态这种做法简单、但经常让 LLM 陷入”重复同样错误”的死循环——每次都犯一样的错、每次都收到一样的错误消息、永远走不出循环第二代(2023-2024)——“结构化错误”——把错误分类(超时、权限、参数错、网络错)、给 LLM 明确提示”这是什么类型的错、该怎么处理”、把 LLM 从”原始错误消息”中解放出来第三代(2024-2026)——“Agent 自反思”——让 LLM 看到错误后先思考”为什么错、下次该怎么避免、根本原因是什么”、再决定下一步

这三代演化、对应了工程界对 Agent 的理解深化——从把它当”简单工具”到把它当”智能体第一代——“Agent 是自动化工具”;第二代——“Agent 是带错误处理的自动化工具”;第三代——“Agent 是能学习的智能体”;未来可能的第四代——“Agent 是能自主进化的生命体每一代都让 Agent 在”可靠性”和”智能性”上前进一步、也让 Agent 工程更接近成熟软件工程《OpenClaw 源码》第 6 章讨论的 Agent 循环、《LangGraph 源码》第 6 章讨论的 Pregel 执行——都涉及错误处理的具体实现——读完能对这个话题有立体认识、建立跨框架的通用模式感

延伸阅读:工具错误的分类学

好的错误处理、从”正确分类错误”开始Agent 工具错误、通常分几类——“transient”(临时性错误、重试可能成功、比如网络超时)、“permanent”(永久性错误、重试没意义、比如参数错误)、“auth”(权限错误、需要用户介入)、“rate-limit”(限流错误、需要等待后重试)、“partial”(部分成功错误、需要人工判断)、“unknown”(未知错误、保守处理)每一类错误、有不同的最佳处理策略、用同一种策略处理所有错误是新手常见的误区

这种错误分类、借鉴了 HTTP 状态码的经验——2xx 成功、3xx 重定向、4xx 客户端错、5xx 服务器错——分类清晰、便于自动化处理Agent 错误分类、是这种思路的扩展——加入了 Agent 时代特有的错误类型(比如 “LLM misuse”——LLM 错误调用 Tool)好的 Agent 工程师、会为每种错误类型设计专门的处理策略——而不是”一刀切”的”重试 3 次

延伸阅读:错误消息的”可操作性

给 LLM 看的错误消息、要”可操作”——不只告诉 LLM “错了”、还要告诉 LLM “怎么改不可操作的错误——“Internal Server Error”、“Invalid Input”——LLM 只能瞎试可操作的错误——“文件路径必须是绝对路径、你传的是相对路径”、“温度参数必须在 0-2 之间、你传的是 3”——LLM 能准确修复

这种”可操作错误”的设计、比传统软件的错误消息要求更高传统软件里、错误消息给人看、人能查文档;Agent 里、错误消息给 LLM 看、LLM 只有当前 context——要在错误消息里塞进足够信息这让”写错误消息”成为 Agent 工程的核心技能——看似简单、实际上是区分”会用的 Agent”和”好用的 Agent”的关键

延伸阅读:重试策略的工程细节

Agent 的重试逻辑、看似简单(“失败后重试”)、实际上有大量细节几次重试”(通常 3-5 次)、“重试间隔”(指数退避:1s、2s、4s、8s…)、“哪些错误值得重试”(transient 是、permanent 不是)、“重试时是否修改参数”(有些场景应该)、“重试是否计入配额”(涉及付费)每个细节都要仔细考虑

重试策略的研究、在分布式系统领域有大量积累——Netflix 的 Hystrix、Amazon 的 retry with exponential backoff、Google 的 token bucket——都是业界标杆Agent 系统可以直接借用这些成熟方案《Tokio 源码》第 12 章讨论的 tower 中间件、《LangGraph 源码》第 9 章讨论的 checkpoint 恢复——都涉及重试语义——值得对比学习

延伸阅读:人在回路作为最终兜底

再强大的自动化错误处理、都有极限——当系统怎么重试都失败时、就需要”人在回路”(Human-in-the-Loop)介入好的 Agent 系统、设计了”自动升级到人工”的机制——某个错误重试 N 次仍失败 → 保存 context → 通知人工 → 人工介入修复 → Agent 继续这种”自动 + 人工”的混合、比纯自动更可靠、比纯人工更高效

人在回路的设计、要考虑”升级阈值”(什么时候介入)、“通知渠道”(邮件、Slack、PagerDuty)、“人工操作界面”(要有方便的工具让人查看 context 和继续 Agent)Claude Code、OpenClaw 的审批流、LangGraph 的 interrupt(见《LangGraph 源码》第 9 章)——都是这种思路的具体实现完整的错误处理设计、必须包括”何时升级到人”这一步——否则当自动化失败时、系统就彻底卡死

延伸阅读:错误处理与可观测性

错误处理的前提、是”能看到错误”——这就是可观测性的重要性没有好的监控和日志、错误发生了你都不知道;没有统计分析、你看不到错误率趋势、无法优化生产级 Agent 系统、必须有完整的错误可观测性——每次错误都记录(时间、类型、context、堆栈)、错误率按类型统计、异常时告警

可观测性的投入、初期感觉”浪费时间”、但出问题时能救命很多生产事故、定位时间占 90%、修复时间占 10%——好的可观测性让定位时间从几小时降到几分钟这个投入回报比、任何时候都值得《OpenClaw 源码》第 14 章、《LangChain 源码》第 5 章讨论的回调系统——都是类似的可观测性基础设施——读完能对”生产 AI 系统的可观测性”有全面认识——可观测性是所有生产级系统的生命线

延伸阅读:错误恢复的”优雅降级

当 Agent 无法完美完成任务时、“优雅降级”比”彻底失败”好得多——用户宁愿得到”不完整但有用”的结果、也不愿看到”什么都没有”的错误页比如搜索工具返回 500 个结果、但 context 只能容纳 20 个——不要报错、而是返回前 20 个并告知”还有 480 个未显示”;比如网络超时、不要完全放弃、而是返回”基于缓存的部分结果 + 提示用户这是缓存数据”;比如 LLM 返回格式异常、不要崩溃、而是尝试解析可识别的部分这种”退而求其次”的能力、让 Agent 在各种意外条件下都能提供价值——而不是在第一个故障点就彻底瘫痪

优雅降级是一个哲学——“完美的失败”不如”不完美的成功”——让部分功能活着比整体死掉好得多Netflix 的 chaos engineering、Google 的 SRE 原则、AWS 的 fault isolation、Microsoft 的 circuit breaker 模式——都建立在优雅降级之上Agent 系统应该吸收这些思想——设计时就考虑”每一步失败了怎么退回”——而不是期待一切顺利这种”假设会失败、设计容错”的心态、是生产工程师的基本功、也是区分业余爱好者和职业选手的分水岭

延伸阅读:错误作为学习信号

最前沿的 Agent 研究、把错误作为”学习信号”——Agent 不只是处理错误、还要从错误中学习、改进未来行为——把每次错误变成成长的台阶具体做法——每次错误后、让 Agent 反思”我哪里做错了、下次怎么避免、根本原因在哪”、把这个反思存到长期记忆、未来遇到类似场景时参考这种”经验积累”、让 Agent 越用越聪明

这种思路的学术基础——强化学习(Reinforcement Learning)里的”from errors to policy”——把失败作为策略更新的信号OpenAI 的 o1、Anthropic 的 Claude 4、Google 的 Gemini 2、DeepSeek 的 R1——都在朝”自我改进”的方向发展Agent 系统的错误处理、不再只是”让当前任务不崩”、而是”让未来任务做得更好”——这是一个激动人心的研究方向、也是 Agent 从”工具”走向”智能体”的关键一步

延伸阅读:错误预算的经济学

SRE(Site Reliability Engineering、Google 于 2003 年首创的工程学科)里有一个概念叫”错误预算”(error budget)——允许系统有一定比例的错误(比如 99.9% 可用性意味着每月允许 43 分钟宕机)——不是追求完美、而是用”一定的错误”换取”更快的迭代Agent 系统也能应用这个思路——为不同类型的操作设不同的错误预算、按风险分级管理

比如——“用户查询”这种低风险操作、可以容忍 1% 错误率(换取快速响应);“用户下单”这种高风险操作、只能容忍 0.01% 错误率(不惜牺牲速度保正确);“支付操作”则几乎零容忍错误这种”按风险分级错误预算”的思路、让 Agent 系统的工程投入更合理——该快的地方快、该稳的地方稳——资源分配精准、避免一刀切Google 的 SRE 实践、Netflix 的 chaos engineering、Amazon 的 well-architected framework——都建立在类似思路上

延伸阅读:错误信息的国际化

错误消息的国际化、是一个经常被忽视的工程话题中文 Agent 给英文 LLM 看中文错误——LLM 可能理解不准;英文 Agent 给中文用户看英文错误——用户可能看不懂好的错误处理、要有”双路径”——给 LLM 的错误消息用英文(LLM 对英文理解更准、训练数据更多)、给人的错误消息用用户语言(中文用户看中文、英文用户看英文)

这种”技术消息 + 用户消息”的分离、让系统对 LLM 友好、对用户也友好实现上——可以维护两套消息模板、根据受众自动切换;或者让 LLM 在最后一步把技术错误翻译成用户语言后一种更灵活、但依赖 LLM 的翻译能力这些细节、在小项目里可以忽略、在面向全球用户的 Agent 产品里必须认真处理——这是国际化产品必备的基本功

延伸阅读:错误处理与测试

错误处理代码、最难测试——因为错误场景难以复现、常常要等到生产出事才暴露问题网络超时、数据库连接丢失、第三方服务宕机、磁盘写入失败——都不是能”随时触发”的场景好的测试策略——用”fault injection”(故障注入)工具(chaos monkey、litmus、toxiproxy、Gremlin)主动制造错误场景、验证 Agent 系统的错误处理逻辑

没有 fault injection、错误处理代码就是”理论正确、实际未知”——可能看起来写得很好、但真出故障时不工作成熟的 Agent 系统、应该把”混沌测试”纳入 CI——每天/每周自动跑一次、主动制造各种故障、验证系统的韧性这种”主动破坏系统来验证稳健性”的思路、源自 Netflix 的 Chaos Monkey(2011 年开源)、现在已经成为 SRE 的标配、被许多大厂广泛采用

延伸阅读:错误处理的组织文化

技术层面的错误处理做得好、还不够——需要”组织文化”的支持有些团队把错误看作”失败”、避讳讨论;有些团队把错误看作”学习机会”、主动复盘后者的系统可靠性、远高于前者谷歌 SRE 的”blameless postmortem”(无责事后分析)——就是这种文化的体现——讨论错误是为了改进系统、不是为了追究责任

Agent 工程团队、应该建立类似文化——Agent 出错是常态、不是耻辱;错误复盘是价值、不是负担;错误分类是基础、不是繁琐只有这种文化、才能让团队持续改进 Agent 的可靠性、在长期竞争中不被拖垮技术和文化相辅相成——光有技术没文化、技术再好也会逐渐劣化;光有文化没技术、再好文化也解决不了实际问题——两者缺一不可、是成熟工程组织的共同特征