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

《Harness Engineering --- AI Agent 工程方法论》完整目录

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

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

:::tip 本章要点

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

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

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

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

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

scss 复制代码
执行(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 错误分类体系

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

graph TD Error["工具调用错误"] --> E1["❶ 工具未找到\n模型幻觉"] Error --> E2["❷ 参数校验失败\n类型/格式不对"] Error --> E3["❸ 执行错误\n文件不存在/网络失败"] Error --> E4["❹ 超时\n操作耗时过长"] Error --> E5["❺ 权限拒绝\n安全策略拦截"] 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)。模型请求了一个不存在的工具。这通常意味着模型产生了幻觉,"发明"了一个它认为应该存在的工具。

第二类:参数校验失败(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. 可以怎么做------如果存在已知的恢复路径,明确提示

比较以下两条错误信息:

ruby 复制代码
// 差:模型无法判断下一步
"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 的行为模式。

相关推荐
KaneLogger8 小时前
如何提升模型编码能力
agent·ai编程
louiX8 小时前
初级 AI Agent 工程师
langchain·agent·客户端
阿珊和她的猫9 小时前
从实践中提炼的架构设计与工程规范
ai·agent·llama·cli·mcp
大山同学10 小时前
Feynman—证据驱动的 AI 研究代理
人工智能·agent·智能体
欧雷殿10 小时前
跨设备自动化:家庭 AI 工作台的首个小目标
后端·agent·aiops
DigitalOcean10 小时前
AI变智能体,传统云不够用了:成本降67%,延迟降40%的新解法
aigc·agent
python零基础入门小白12 小时前
从0到1:手把手教你用Coze打造AI Agent,小白也能转行AI!
人工智能·学习·程序员·大模型·agent·产品经理·ai大模型
johnny23313 小时前
多智能体协作:Edict、Open Multi-Agent、Agora
agent
后端小肥肠14 小时前
白嫖小云雀 API 200 秒免费额度,封装 Skill,玩转 Seedance2.0 视频
人工智能·agent
大模型真好玩14 小时前
LangChain DeepAgents 速通指南(八)—— DeepAgents流式输出详解
人工智能·langchain·agent