Harness Engineering
第7章 工具结果处理与错误恢复
第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' })"
后者包含了:
- 错误事实
- 可用工具列表
- 最可能的正确工具
- 如何使用
这样模型能从一个错误中学到三件事,而不是卡死在同一个错误上。
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”后,会加参数重试
模型拥有人类工程师的通用推理能力。硬编码规则只能覆盖开发时能想到的情况,而模型能应对开发时没想到的情况。
错误反馈的三要素
要让这种机制高效运作,错误信息的质量至关重要。一条好的错误反馈应该包含三个要素:
- 发生了什么(What)——错误的客观描述
- 为什么发生(Why)——尽可能给出原因分析
- 可以怎么做(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 应该:
- 感谢用户的指引——建立良好的协作氛围
- 复述理解——确认自己理解正确
- 继续执行——不要反复请求
- 记录决策——用户的判断可以作为未来类似情况的参考
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 的可靠性上限,不取决于它能调用多少工具,而取决于它在工具失败时的应对能力。
核心原则
- 结果格式化服务于模型理解——不是越多越好,也不是越少越好,而是刚好够模型做出正确决策
- 错误分类驱动恢复策略——不同类型的错误需要不同的处理方式,盲目重试是最常见的反模式
- 让模型成为错误处理器——给它充分的错误上下文,它往往能找到你没预想到的恢复路径
- 主动检测毒循环——这是生产系统中最常见的资源浪费来源
- 透明处理部分成功——不要隐藏复杂性,让模型和用户都清楚当前状态
- 及时升级给用户——知道何时放弃,是一种智慧
- 日志是你唯一的时光机——事后诊断全靠它
核心口号
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 的可靠性、在长期竞争中不被拖垮。技术和文化相辅相成——光有技术没文化、技术再好也会逐渐劣化;光有文化没技术、再好文化也解决不了实际问题——两者缺一不可、是成熟工程组织的共同特征。