Harness Engineering
第7章 工具结果处理与错误恢复
本章要点
- 工具结果生命周期四阶段:执行 → 捕获 → 格式化 → 反馈
- 三种格式化策略:原样返回 / 截断 / 结构化摘要——按输出体积和信息密度选
- 五类核心错误 + 可恢复性分级——盲目重试是最常见反模式
- 模型即错误处理器:忠实反馈错误,让 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' })"
后者包含了:
- 错误事实
- 可用工具列表
- 最可能的正确工具
- 如何使用
这样模型能从一个错误中学到三件事,而不是卡死在同一个错误上。
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"后,会加参数重试
模型拥有人类工程师的通用推理能力。硬编码规则只能覆盖开发时能想到的情况,而模型能应对开发时没想到的情况。
错误反馈的三要素
要让这种机制高效运作,错误信息的质量至关重要。一条好的错误反馈应该包含三个要素:
- 发生了什么(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 匹配到多处时,会返回这样的错误消息(作者本地快照 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 应该:
- 感谢用户的指引——建立良好的协作氛围
- 复述理解——确认自己理解正确
- 继续执行——不要反复请求
- 记录决策——用户的判断可以作为未来类似情况的参考
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 的可靠性上限,不取决于它能调用多少工具,而取决于它在工具失败时的应对能力。
核心原则
- 结果格式化服务于模型理解——不是越多越好,也不是越少越好,而是刚好够模型做出正确决策
- 错误分类驱动恢复策略——不同类型的错误需要不同的处理方式,盲目重试是最常见的反模式
- 让模型成为错误处理器——给它充分的错误上下文,它往往能找到你没预想到的恢复路径
- 主动检测毒循环——这是生产系统中最常见的资源浪费来源
- 透明处理部分成功——不要隐藏复杂性,让模型和用户都清楚当前状态
- 及时升级给用户——知道何时放弃,是一种智慧
- 日志是你唯一的时光机——事后诊断全靠它
核心口号
Tools fail. Good agents learn from failures. Great harnesses ensure agents can learn.
工具会失败。好的 Agent 从失败中学习。伟大的 Harness 确保 Agent 能学习。
下一章,我们将进入提示词工程的领域,探讨如何通过精心设计的 system prompt 来塑造 Agent 的行为模式。