Harness Engineering

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

作者 杨艺韬 · 8,055 字

本章要点

  • 工具结果生命周期四阶段:执行 → 捕获 → 格式化 → 反馈
  • 三种格式化策略:原样返回 / 截断 / 结构化摘要——按输出体积和信息密度选
  • 五类核心错误 + 可恢复性分级——盲目重试是最常见反模式
  • 模型即错误处理器:忠实反馈错误,让 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)  // timeout 为内部辅助函数
    ]);
    return {
      status: 'success',
      output: result,
      durationMs: Date.now() - startTime
    };
  } catch (error) {
    // 防御式转换:工具可能抛出非 Error 对象
    const message = error instanceof Error ? error.message : String(error);
    const stack = error instanceof Error ? error.stack : undefined;
    return {
      status: 'error',
      errorType: classifyError(error),  // classifyError 为内部辅助函数
      message,
      stack,
      durationMs: Date.now() - startTime
    };
  }
}

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

阶段三:格式化

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

阶段四:反馈

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

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

生命周期的关键不变式

不变式 1: 每次调用都必然有一个结果(成功或失败),不存在"消失"
不变式 2: 结果总是以模型可理解的形式(human-readable + 边界清晰)返回,必要时带结构化的元信息
不变式 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 行 结构化摘要 原样放不下
输出高度集中(少数关键行 + 大量噪音) 过滤保留 只保留有意义的行
输出是二进制或非文本 摘要元信息 描述类型/大小
错误输出 保留错误部分,截断其他 错误是关键决策依据

注:100 行是建议的敏感阈值,便于模型快速扫描;对于 Read 等以完整内容为主的工具,可设置更高的硬上限(如 2000 行),超过后再截断或分页。

7.2.3 Claude Code 的格式化实践

Claude Code 的做法值得参考:

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

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

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[终止进程并返回超时错误<br/>Harness 可按策略自动重试]
    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';   // 结构性问题,重试无意义

    default:
      return 'maybe'; // 未知错误保守处理,交给模型判断
  }
}

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>,
  maxAttempts: number = 4,  // 首次 + 最多 3 次重试
  baseDelayMs: number = 1000
): Promise<T> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      const retryable = isRetryable(error);
      // 'no':结构性错误,立即抛出;'maybe':不做原样自动重试,
      // 抛给上层反馈模型换参数(见 §7.3.2);只有 'yes' 才走退避重试
      if (retryable !== 'yes' || attempt === maxAttempts - 1) throw error;

      // 指数退避 + 随机抖动(避免惊群)
      const delay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.random() * delay * 0.1;
      await sleep(delay + jitter);  // sleep 为内部辅助函数
    }
  }
  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 匹配到多处时,会返回这样的错误消息(作者本地快照 src/tools/FileEditTool/FileEditTool.ts:336):"Found N matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance."——错误事实、原因和两条可选做法都在同一条消息里,模型读完即可准确重试。

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);

  // 最近一次调用本身必须失败,否则不算毒循环
  const last = recent[recent.length - 1];
  if (!last || last.status !== 'error') {
    return { detected: false, severity: 'low', suggestion: '' };
  }

  // 规则 1: 同工具连续失败
  const failedSameTool = recent.filter(
    r => r.status === 'error' && r.toolName === last.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 预防胜于治疗

一种可行的做法是设置调用次数上限(以下为示例值,并非某个具体产品的真实常量):

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 instanceof Error ? error.message : String(error),
      });
      // 不抛出异常,继续处理下一项
    }
  }
  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-Za-z]{2,}\b/g,  // email
  /\b(?:\d[ -]*?){13,19}\b/g,  // 信用卡号(粗略模式;生产环境应配合 Luhn 校验)
];

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 的行为模式。