Appearance
第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 必须为正整数"后,会自行修正参数再试一次;看到"操作被权限策略拦截"后,会向用户解释为什么需要该权限并请求授权。
但要让这种机制高效运作,错误信息的质量至关重要。一条好的错误反馈应该包含三个要素:
- 发生了什么——错误的客观描述
- 为什么发生——尽可能给出原因分析
- 可以怎么做——如果存在已知的恢复路径,明确提示
比较以下两条错误信息:
// 差:模型无法判断下一步
"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 升级的信息质量
升级不是简单地说"我遇到了问题"。一个好的升级应该包含:
- 我在做什么——当前任务的简要描述
- 发生了什么——遇到的具体问题
- 我试过什么——已经尝试的恢复方法及其结果
- 我建议什么——如果有初步判断,给出建议选项
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 的可靠性上限,不取决于它能调用多少工具,而取决于它在工具失败时的应对能力。
核心原则回顾:
- 结果格式化服务于模型理解——不是越多越好,也不是越少越好,而是刚好够模型做出正确决策
- 错误分类驱动恢复策略——不同类型的错误需要不同的处理方式,盲目重试是最常见的反模式
- 让模型成为错误处理器——给它充分的错误上下文,它往往能找到你没预想到的恢复路径
- 主动检测毒循环——这是生产系统中最常见的资源浪费来源
- 透明处理部分成功——不要隐藏复杂性,让模型和用户都清楚当前状态
- 及时升级给用户——知道何时放弃,是一种智慧
- 日志是你唯一的时光机——事后诊断全靠它
下一章,我们将进入提示词工程的领域,探讨如何通过精心设计的 system prompt 来塑造 Agent 的行为模式。