Skip to content

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

"A system that never fails is a system that was never used."

本章要点

  • 理解工具结果从执行到反馈给模型的完整生命周期
  • 掌握结果格式化的原则:何时截断、何时摘要、何时原样返回
  • 建立错误分类体系:五类常见错误及其恢复策略
  • 将模型本身作为错误处理器,让它自适应地调整策略
  • 识别并打破"毒循环"——模型反复调用失败工具的死锁
  • 处理部分成功的复杂场景
  • 设计合理的用户升级机制
  • 构建生产级的日志与诊断体系

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

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

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

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

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

阶段二:结果捕获。无论工具成功还是失败,Harness 都需要捕获完整的执行结果。成功时捕获返回值,失败时捕获错误类型、错误消息和堆栈信息。关键原则是:永远不要让异常逃逸到 Agent 循环之外

typescript
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,
      durationMs: Date.now() - startTime
    };
  }
}

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

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

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

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

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

7.2.1 三种格式化策略

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

策略二:截断。当输出超过预设阈值时,保留前 N 行或前 N 个字符,并附加一条说明:"输出已截断,共 X 行,显示前 Y 行。" 截断简单粗暴,但它有一个重要优势——不会引入歧义。模型知道自己看到的是不完整的原始数据,而不是被改写后的数据。

typescript
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} 行]`;
}

策略三:结构化摘要。对原始输出进行语义提取,生成结构化的摘要。例如,一个搜索工具返回 500 个匹配结果,摘要可以是:"找到 500 个匹配,分布在 23 个文件中,最相关的 10 个结果如下:..." 结构化摘要信息密度最高,但实现成本也最高,且存在摘要过程引入错误的风险。

7.2.2 如何选择策略

一个实用的判断框架:

条件推荐策略
输出 < 100 行原样返回
输出 100-1000 行,且信息分布均匀截断 + 提示总量
输出 > 1000 行,或信息高度集中结构化摘要
输出是二进制或非文本摘要(描述类型、大小等元信息)

Claude Code 的做法值得参考:对于文件读取,它会原样返回内容并附带行号;对于搜索结果,会按文件分组并截断;对于 bash 命令输出,设置一个合理的行数上限后截断。这些策略都在工具定义层面就确定了,而不是事后处理。

7.3 错误分类体系

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

7.3.1 五类核心错误

第一类:工具未找到(Tool Not Found)。模型请求了一个不存在的工具。这通常意味着模型产生了幻觉,"发明"了一个它认为应该存在的工具。

第二类:参数校验失败(Parameter Validation Error)。工具存在,但模型提供的参数不符合 schema。比如缺少必填字段、类型不匹配、值超出范围等。

第三类:执行错误(Execution Error)。参数合法,但执行过程中出错。比如文件不存在、网络请求失败、数据库连接断开等。

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

第五类:权限拒绝(Permission Denied)。工具被安全策略拦截。比如用户未授权某个危险操作,或沙箱策略禁止访问某个路径。

7.3.2 错误的可恢复性

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

  • 确定可恢复:网络超时、临时文件锁——重试大概率成功
  • 条件可恢复:参数错误——换一组参数可能成功
  • 确定不可恢复:权限被永久拒绝、工具不存在——重试毫无意义
typescript
function isRetryable(error: ToolError): boolean {
  switch (error.type) {
    case 'timeout':
    case 'network_error':
      return true;  // 暂时性故障,值得重试
    case 'parameter_validation':
      return false; // 相同参数重试必定失败
    case 'execution_error':
      return error.transient ?? false; // 取决于具体错误
    case 'permission_denied':
    case 'tool_not_found':
      return false; // 结构性问题,重试无意义
  }
}

7.4 错误恢复策略

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

7.4.1 重试与退避

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

typescript
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);
      await sleep(delay + Math.random() * delay * 0.1);
    }
  }
  throw new Error('unreachable');
}

需要注意:重试应该发生在 Harness 层面,对模型透明。模型不需要知道一个工具被重试了三次才成功——它只需要看到最终结果。但如果所有重试都失败了,模型需要看到最终的错误信息。

7.4.2 回退到替代工具

当一个工具持续失败时,Harness 可以建议模型使用替代方案。例如,当精确的文件编辑工具因为内容匹配失败而报错时,可以在错误信息中提示模型:"编辑失败,因为指定的文本在文件中不存在。你可以先用读取工具确认文件的当前内容,然后重新尝试编辑。"

这种策略的关键在于:不是 Harness 自动切换工具,而是通过错误消息引导模型自主选择替代方案。模型比 Harness 更理解当前任务的语境,让它来决策更合理。

7.4.3 请求用户帮助

某些错误只有人类才能解决:需要输入密码、需要物理操作(如插入 USB 设备)、需要业务决策(如"这个文件要不要覆盖")。

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

7.4.4 优雅降级

当工具完全不可用时,系统不应该崩溃,而应该降级到一个功能受限但仍然可用的状态。比如:代码搜索工具不可用时,降级为基础的文件名匹配;网络请求工具不可用时,使用本地缓存的数据。

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

7.5 模型作为错误处理器

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

核心思路非常简单:将错误信息忠实地反馈给模型,让模型自己决定如何应对。

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

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

实践中,这种方式效果惊人地好。模型看到"文件 /src/app.ts 不存在"后,会主动搜索正确的文件路径;看到"参数 line_number 必须为正整数"后,会自行修正参数再试一次;看到"操作被权限策略拦截"后,会向用户解释为什么需要该权限并请求授权。

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

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

比较以下两条错误信息:

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

// 好:模型可以自主恢复
"文件读取失败: /src/utils/helper.ts 不存在。
当前目录下的文件有: /src/utils/helpers.ts, /src/utils/index.ts。
可能是文件名拼写有误。"

7.6 毒循环:检测与熔断

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

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

7.6.1 检测毒循环

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

typescript
interface CallRecord {
  toolName: string;
  paramsHash: string;
  status: 'success' | 'error';
  timestamp: number;
}

function detectPoisonLoop(
  history: CallRecord[],
  windowSize: number = 5,
  threshold: number = 3
): boolean {
  const recent = history.slice(-windowSize);
  const failedSameTool = recent.filter(
    r => r.status === 'error' &&
         r.toolName === recent[recent.length - 1].toolName
  );
  if (failedSameTool.length < threshold) return false;

  // 检查参数是否高度相似
  const uniqueParams = new Set(failedSameTool.map(r => r.paramsHash));
  return uniqueParams.size <= 2; // 参数几乎没有变化
}

7.6.2 打破循环

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

注入系统提示:在下一轮对话中插入一条消息:"你已经连续 N 次调用工具 X 但都失败了。请换一种方法解决问题,或者向用户寻求帮助。"

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

强制升级:直接暂停 Agent 循环,将当前状态呈现给用户,请求人工介入。

Claude Code 的做法是设置一个调用次数上限,当同一工具在一个对话轮次中被调用超过一定次数时,会暂停执行并提示用户。这是一种简单但有效的熔断机制。

7.7 部分成功的处理

现实世界中,工具调用不总是"全部成功"或"全部失败"。一个批量操作可能成功处理了 3 个文件,但在第 4 个文件上失败了。一个多步骤的重构可能完成了代码修改,但在运行测试时发现了新问题。

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

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

function formatPartialResult(result: PartialResult): 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.8 用户升级:何时放弃、如何交接

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

7.8.1 升级的触发条件

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

  • 安全敏感操作:删除数据、修改权限、推送到生产环境等不可逆操作
  • 歧义决策:存在多个合理方案,模型无法判断用户的真实意图
  • 持续失败:经过多次重试和策略切换,问题仍未解决
  • 资源耗尽:即将用完 Token 预算或时间限制
  • 工具被阻止:需要的工具被安全策略拦截,无法绕过

7.8.2 升级的信息质量

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

  1. 我在做什么——当前任务的简要描述
  2. 发生了什么——遇到的具体问题
  3. 我试过什么——已经尝试的恢复方法及其结果
  4. 我建议什么——如果有初步判断,给出建议选项

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

7.9 日志与诊断

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

7.9.1 该记录什么

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

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

  // 输入
  parameters: unknown;     // 调用参数(脱敏后)

  // 输出
  status: 'success' | 'error';
  output?: string;         // 成功时的输出(可截断)
  error?: {
    type: string;
    message: string;
    stack?: string;
  };

  // 性能
  durationMs: number;
  retryCount: number;

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

7.9.2 日志分级

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

  • ERROR:工具执行失败,且未能自动恢复。需要人工关注。
  • WARN:工具执行失败,但通过重试或回退成功恢复。记录以便分析趋势。
  • INFO:工具正常执行完成。记录调用和耗时,用于性能分析。
  • DEBUG:详细的参数、完整的输出、模型的推理过程。仅在排查问题时开启。

7.9.3 敏感信息处理

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

  • API 密钥和令牌
  • 密码和凭据
  • 个人身份信息(PII)
  • 文件内容中的敏感业务数据

一个简单的做法是维护一个脱敏规则列表,在写入日志前对所有字符串字段进行扫描和替换。更好的做法是在工具定义中标注哪些参数是敏感的,由 Harness 在记录时自动过滤。

7.10 小结

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

核心原则回顾:

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

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

基于 VitePress 构建