Agent Loop 错误恢复机制
从 SDK 网络重试,到模型流式响应中的可恢复错误,再到工具执行内部失败 —— Claude Code 用一套分层 + 状态机的设计把"失败"驯化成可修复、可观测、不污染上下文的事件。 本文逐层拆解这套机制,辅以源码级流程图,让从未读过源码的人也能在一小时内建立完整心智模型。
01引言:为什么 agent loop 需要错误恢复
一个 agent 回合从用户输入开始,经历若干次"模型采样 → 工具执行 → 结果回灌"。 每一步都可能失败,而失败的代价不只是当前回合崩溃,更可能导致后续请求都用 400 拒绝。
典型失败场景
| 失败类型 | 典型表现 | 不处理的后果 |
|---|---|---|
| 网络抖动 | ECONNRESET / 超时 | 用户看到突兀的报错,即使再点一次就能成功 |
| 限流 429 / 过载 529 | API 头部 retry-after |
不退避会被持续拒绝;不限制重试次数会放大容量级联 |
| Prompt 超长 413 | 对话累积超模型上下文 | 之后每次请求都 413,会话进入死锁 |
| 输出截断 max_output_tokens | 模型在 8k token 处被截断 | tool_use 块发了一半,下一次请求会因签名/配对错误 400 |
| tool_use / tool_result 配对断裂 | 发了 tool_use 但还没发 tool_result 时崩溃 | API 校验拒绝整个会话:tool_use ids were found without tool_result |
| thinking 块跨模型重放 | Fallback 切到不兼容模型 | thinking blocks cannot be modified 400 |
| 工具内部异常 | Bash 命令抛错、文件不存在、MCP server 断连 | 整个 query 循环被 throw 出去,模型永远没有机会修复错误 |
| 用户中断 | Ctrl+C 在 tool_use 进行中 | 遗留孤儿 tool_use,下一回合 400 |
tool_result(is_error: true) 或
AssistantMessage(isApiErrorMessage: true),继续留在对话里,模型下一回合就能根据它修正策略。
02分层架构总览
错误处理被刻意拆成 4 层,每层只关心自己尺度的失败,层与层之间通过"已知错误类型"对接。
每层解决的核心问题
- 第 1 层"能不能再发一次?"—— 网络/限流是瞬态的,先盲重试
- 第 2 层"这是什么错?"—— 把陌生异常翻译成 5~6 个标准化错误类型
- 第 3 层"能不能改改上下文重发?"—— 压缩、剥图、注入续写提示
- 第 4 层"工具自己挂了怎么办?"—— 不让工具异常打断 agent 循环
03第 1 层:withRetry — SDK 重试
src/services/api/withRetry.ts:这是离 Anthropic SDK 最近的一层。
它本质是一个 async generator,在外层 generator 里穿插 yield SystemAPIErrorMessage 通知 UI"正在重试",同时 sleep 退避。
3.1 整体重试流程
3.2 关键决策点
1) 哪些错误"可重试"
// withRetry.ts
const shouldRetry = (err: APIError) =>
err.status === 529 || // overloaded
err.status === 429 || // rate limit
err.status >= 500 || // server error
isStaleConnectionError(err) // ECONNRESET / EPIPE
401/403 不进重试列表,但会触发一次客户端重建(强制刷新 OAuth token),给"token 刚刚过期"留一次机会;
第二次还是 401 就 CannotRetryError。
2) 背景查询的"快失败"
FOREGROUND_529_RETRY_SOURCES
—— 只有用户在等的查询才重试 529,其他直接 CannotRetryError。
const FOREGROUND_529_RETRY_SOURCES = new Set([
'repl_main_thread', 'sdk',
'agent:custom', 'agent:default', 'agent:builtin',
'compact', 'hook_agent', 'hook_prompt',
'verification_agent', 'side_question',
'auto_mode', // 安全分类器:必须完成才能保证 auto-mode 正确性
])
3) Fast mode 降级
fast mode 用的是 Opus 4.6 加速版本,共享同一个 prompt cache。一旦命中 429/529:
- retry-after < 阈值:短退避 + 保持 fast mode(保住缓存)
- retry-after > 阈值:进入 cooldown,本会话切到普通速度版本
- overage 被禁用:永久关闭 fast mode
4) max_tokens 上下文溢出
这是个老 API 的兼容路径。如果遇到 input length and `max_tokens` exceed context limit: 188059 + 20000 > 200000,
不会 throw,而是把 maxTokensOverride 调小后 continue:
const overflowData = parseMaxTokensContextOverflowError(error)
if (overflowData) {
const available = contextLimit - inputTokens - 1000 // 1000 安全 buffer
retryContext.maxTokensOverride = Math.max(3000, available, minThinking + 1)
continue // 用调小后的 max_tokens 重发
}
5) 持久重试模式(无人值守)
设置 CLAUDE_CODE_UNATTENDED_RETRY=1(ant-only)后,429/529 会无限重试,但有两个关键改造:
- 背避上限 5 分钟,总 cap 6 小时(读
anthropic-ratelimit-unified-reset头部直接 sleep 到重置时间) - 长 sleep 切片为 30s 心跳,每片都 yield 一条
SystemAPIErrorMessage, 防止宿主环境(CI、Lambda)把会话标记为 idle
3.3 跨模型 fallback 信号
当连续 MAX_529_RETRIES = 3 次 529 仍未恢复:
有 fallbackModel
抛 FallbackTriggeredError(originalModel, fallbackModel)。
不在 withRetry 内切模型 —— 因为切模型会导致 thinking 签名失配,必须在更上层处理。
无 fallbackModel
外部用户抛 CannotRetryError(REPEATED_529_ERROR_MESSAGE);
内部用户(ant)继续重试到上限。
04第 2 层:错误翻译为伪 Assistant 消息
src/services/api/errors.ts 的 getAssistantMessageFromError()
把任意 JS 异常翻译成一条结构化的 AssistantMessage,这是把"错误"接入到 agent 对话流的关键转换。
4.1 伪 Assistant 消息的结构
// utils/messages.ts:443
export function createAssistantAPIErrorMessage({ content, apiError, error, errorDetails }) {
return baseCreateAssistantMessage({
content: [{ type: 'text', text: content }],
isApiErrorMessage: true, // ← 关键标志
apiError,
error,
errorDetails, // 原始错误(如 PTL 的 token 数)
})
}
tool_use → tool_result 必须是 assistant → user 配对。
如果错误发生在模型采样阶段,流里产生的就是一条 assistant 消息,只是它内容是错误描述、并打了 isApiErrorMessage 标记。
下游的 stop hook、UI 渲染、查询循环都依赖这个标记来识别"这不是模型的真正回答"。
4.2 翻译表(节选)
| 错误特征 | 翻译结果 | 用户看到 |
|---|---|---|
APIConnectionTimeoutError |
API_TIMEOUT_ERROR_MESSAGE |
"Request timed out" |
429 + 头部 unified-representative-claim |
error: 'rate_limit' + getRateLimitErrorMessage() |
"5-hour limit reached. Resets at …" |
错误文本含 prompt is too long |
content = 'Prompt is too long' (固定字面量)errorDetails = 原文 (含 token 数) |
"Prompt is too long" (后续被 reactive compact 解析 token 数) |
| 413 + image exceeds maximum | getImageTooLargeErrorMessage() |
"Image was too large. Double press esc to go back…" |
| 413 + many-image dimensions exceed | "Run /compact to remove old images…" | 引导用户主动 compact |
400 + tool_use ids were found without tool_result |
error: 'invalid_request' + 提示 /rewind |
"API Error: 400 due to tool use concurrency issues. Run /rewind to recover." |
400 + tool_use ids must be unique |
tengu_duplicate_tool_use_id 埋点 + /rewind 提示 |
"duplicate tool_use ID in conversation history" |
403 + OAuth token has been revoked |
error: 'authentication_failed' |
"OAuth token revoked · Please run /login" |
| 404 | 引导 /model 切换;3P 用户给具体兜底建议 |
"Try /model to switch to claude-sonnet-4-5…" |
stop_reason === 'refusal' |
引导切 sonnet-4 / esc esc 重写 | "unable to respond, violates Usage Policy" |
4.3 一个细节:tool_use 配对错乱时的诊断埋点
当 API 返回 tool_use ids were found without tool_result,Claude Code 不只是给用户提示,
还会把规范化前后的消息序列都序列化进 Statsig 埋点 tengu_tool_use_tool_result_mismatch_error:
// errors.ts:222
function logToolUseToolResultMismatch(toolUseId, messages, messagesForAPI) {
// 1. 在 messagesForAPI 中找到出问题的 tool_use 索引
// 2. 在原始 messages 中找到同一 tool_use 索引
// 3. 把 tool_use 之后的所有块按 "role:type:id" 序列化
// 例如: "assistant:tool_use:toolu_X, user:text, user:tool_result:toolu_Y"
// 4. 同时上报两条序列,用于追因 normalize 阶段是否丢了 tool_result
}
这种"出错时上报上下文形态"的设计在源码里很常见,是排查偶发问题的关键基础设施。
05第 3 层:queryLoop 状态机
src/query.ts 的 queryLoop() 是整个 agent loop 的核心。
它是一个单 while(true) 状态机,所有恢复路径都通过 state = { ... }; continue 提交。
5.1 为什么用单 while + state 而不是递归
- 降低分支爆炸:compact、fallback、stop-hook、token-budget、中断 5 条恢复路径若用递归实现, 每条路径都要复制一遍参数传递,容易遗漏字段。状态机里 5 条路径都只是赋值 state 然后 continue, 所有字段一次性更新。
- 三段式回合:每轮都按"模型采样 → 工具执行 → 附件注入"顺序, 先保证 tool_use/tool_result 配对完整,再注入普通附件,避免 API 协议冲突。
- 错误先压住、后恢复、最后暴露:防止 SDK 提前结束会话导致恢复链路在后台空跑。
5.2 跨迭代的 State
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // 输出截断已重试次数
hasAttemptedReactiveCompact: boolean // reactive compact 是否已用过(防死循环)
maxOutputTokensOverride: number | undefined // 升档到 64k 的标志
pendingToolUseSummary: Promise<...> | undefined
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined // 上一轮为何 continue,测试可断言
}
5.3 主流程示意
06withheld 暂缓暴露机制
这是 queryLoop 错误处理的"心脏"。可恢复错误从模型流里到达时, 不立即 yield 给 SDK 调用方,而是先暂存,等确认恢复成功或失败再决定要不要暴露。
6.1 哪些消息会被 withheld
// query.ts:805
let withheld = false
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, isPromptTooLongMessage, querySource)) {
withheld = true
}
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) {
yield yieldMessage // ← 只有不被 withheld 才出去
}
// withheld = true 时仍会 push 进 assistantMessages,后续判定可见
assistantMessages.push(message)
6.2 为什么要 withheld?
error 字段就立刻关闭会话。
而 Claude Code 的恢复链路其实还在跑(可能要 10s+ 做 reactive compact 然后重发)。
结果就是:前端已停止监听,后端还在烧 token,最终的成功响应没有任何观众。
- 暂缓:错误消息推入 assistantMessages,但不 yield 出去
- 恢复:回合结束时(needsFollowUp = false 分支)判断错误类型,触发对应恢复链
- 暴露:恢复成功 → state.continue 重发;恢复失败 → 才 yield withheld 消息并 return
6.3 mediaRecoveryEnabled 的"提早冻结"
const mediaRecoveryEnabled = reactiveCompact?.isReactiveCompactEnabled() ?? false
这个值在流式开始前就读取一次并固定下来。原因:流式可能持续 5-30 秒,
中间 CACHED_MAY_BE_STALE 的 GrowthBook 配置可能翻转。如果"前面把消息 withheld 了,后面恢复开关却关闭了",
错误消息就会被永久吞掉。提早冻结门控值保证 withheld 与恢复决策对齐。
07PromptTooLong 恢复链
PTL(413 / 400 prompt is too long)是最常见的可恢复错误。Claude Code 用"先轻后重"两层恢复策略, 每层只允许尝试一次,防止陷入死循环。
7.1 两层恢复策略
7.2 流程详解
-
识别 withheld PTL
const isWithheld413 = lastMessage?.type === 'assistant' && lastMessage.isApiErrorMessage && isPromptTooLongMessage(lastMessage) -
第 1 层:collapse drain
排空已经 stage 但未提交的 context-collapse 项。这一层成本最低,因为 collapse 已经预备好了摘要。
if (state.transition?.reason !== 'collapse_drain_retry') { const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource) if (drained.committed > 0) { state = { ...drained, transition: { reason: 'collapse_drain_retry' } } continue // 重发 } }透过 transition 防止"刚 drain 完又因为同样原因 drain"的退化。
-
第 2 层:reactive compact
调用 fork agent 生成整段历史的摘要,把对话压成几段 summary。
const compacted = await reactiveCompact.tryReactiveCompact({ hasAttempted: hasAttemptedReactiveCompact, querySource, messages: messagesForQuery, cacheSafeParams: { systemPrompt, userContext, systemContext, ... }, }) if (compacted) { state = { messages: buildPostCompactMessages(compacted), hasAttemptedReactiveCompact: true, // ← 关键:本回合不再重试 transition: { reason: 'reactive_compact_retry' }, } continue } -
恢复失败:暴露并退出
yield lastMessage // 这时才把 withheld 错误真正暴露 void executeStopFailureHooks(lastMessage, toolUseContext) return { reason: 'prompt_too_long' }为什么不落到 stop hooks?注释里直接点明:"模型未产生有效响应,hook 评估没有意义,且会形成 错误→hook 阻塞→重试→再错误 的死循环 (hook 还会继续注入 token)"。所以 PTL 失败 = 直接 return,跳过整个 stop hook 阶段。
7.3 媒体尺寸错误的并行路径
image / PDF / many-image 超限走同一个 reactive compact 分支,但有两点不同:
- 跳过 collapse drain:collapse 不会剥图,排空也无意义
- compact 内部自动剥图:reactiveCompact 检测到媒体错误时,会调用
stripImagesFromMessages配合摘要 - 终态分类:返回
{ reason: 'image_error' }而非'prompt_too_long'
08max_output_tokens 恢复
输出截断和 PTL 完全不同:不是输入超长,而是模型输出还没说完就到 8k 上限。 这个错误不能通过压缩历史解决,只能给模型更多输出空间或让模型续写。
8.1 两阶段恢复
阶段 A:一次性升档(MAX_OUTPUT_TOKENS escalation)
条件:本回合用的是默认 8k 上限(maxOutputTokensOverride === undefined),且 GrowthBook 启用。
if (capEnabled && maxOutputTokensOverride === undefined && !process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
logEvent('tengu_max_tokens_escalate', { escalatedTo: ESCALATED_MAX_TOKENS })
state = {
...,
maxOutputTokensOverride: ESCALATED_MAX_TOKENS, // 64000
transition: { reason: 'max_output_tokens_escalate' },
}
continue // 重发,这次给 64k 输出空间
}
动机:避免立即进入多轮恢复对话(每轮至少 1 次完整回合的成本),先一次性补足输出窗口。
3P 默认关闭:Bedrock / Vertex 还没验证过 64k 输出。
阶段 B:多轮续写(最多 3 次)
升档后仍触顶,或已经是升档状态:
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
const recoveryMessage = createUserMessage({
content:
'Output token limit hit. Resume directly — no apology, no recap of what you were doing. ' +
'Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.',
isMeta: true, // 不在 transcript 显示
})
state = {
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
transition: { reason: 'max_output_tokens_recovery', attempt: ... },
}
continue
}
- "no apology":防止模型说"抱歉,我刚被截断了…",浪费 token
- "no recap":防止重复已经说过的内容
- "Pick up mid-thought":从被切断的位置继续,而不是重新组织
- "Break remaining work into smaller pieces":让模型主动切分,降低再次截断概率
- isMeta: true:不在 UI 显示这条续写指令,用户感觉模型自然地写完了
8.2 三次都用完?
// 恢复额度耗尽:此时才把 withheld 错误对外输出
yield lastMessage
注意:这里没有 return —— 它会落入下方的 if (lastMessage?.isApiErrorMessage) { ... return 'completed' } 分支,
最终以 completed 状态结束(因为已经把错误暴露给用户了)。
09协议配对修复
Anthropic API 有一个硬约束:每个 tool_use 块必须有对应的 tool_result 块紧随其后。 违反这个规则会导致后续每次请求都 400。Claude Code 在所有可能"中断 tool_use 配对"的地方都做了修复。
9.1 yieldMissingToolResultBlocks
src/query.ts:126 是核心修复函数:
function* yieldMissingToolResultBlocks(
assistantMessages: AssistantMessage[],
errorMessage: string,
) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = (assistantMessage.message.content || [])
.filter(c => c.type === 'tool_use')
// 为每个 tool_use 补一个错误型 tool_result,保证配对
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{
type: 'tool_result',
content: errorMessage,
is_error: true,
tool_use_id: toolUse.id,
}],
toolUseResult: errorMessage,
sourceToolAssistantUUID: assistantMessage.uuid,
})
}
}
}
9.2 在哪些地方调用
| 调用点 | 触发原因 | 错误消息 |
|---|---|---|
| query.ts:904 (FallbackTriggeredError) |
切模型前清场,旧 tool_use 不能跟新模型 mix | "Model fallback triggered" |
| query.ts:1023 (streaming 中断) |
没有 streamingToolExecutor 时退化路径 | "Interrupted by user" |
| query.ts:983 (顶层 catch) |
queryModelWithStreaming 本应 yield 错误却 throw 出来,可能已发 tool_use | error.message 原文 |
9.3 流式 fallback 的特殊处理
当 onStreamingFallback 触发时(流式请求中途切到非流式),不能调 yieldMissingToolResultBlocks,因为 thinking 块的签名跟原模型绑定:
if (streamingFallbackOccured) {
// 为孤儿消息发 tombstone,让 UI 与 transcript 都能删除它们
for (const msg of assistantMessages) {
yield { type: 'tombstone', message: msg }
}
logEvent('tengu_orphaned_messages_tombstoned', ...)
// 清空所有累积状态,准备完整重跑
assistantMessages.length = 0
toolResults.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// 丢弃执行器并重建
streamingToolExecutor?.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
}
{type: 'tombstone', message} 是一个特殊事件,UI 收到后会删除之前显示的对应消息,
transcript 也会跳过它。这样用户视角是"没看到失败的尝试,直接看到 fallback 后的成功响应",体验连续。
10tombstone 与流式 fallback
tombstone 是一个用来"事后撤销"已经流出去的消息的机制,主要为流式 fallback 服务。
10.1 为什么需要 tombstone
典型场景:用户用 Opus 模型,fast mode 开启。流式请求开始,模型流出了:
assistant:
thinking_block_1 (signature: opus-4-7-fast)
text_block: "Let me check..."
tool_use_block: { name: "Read", id: "toolu_X", input: {...} }
这时 fast mode 触发 cooldown,SDK 内部决定改走非流式 fallback。问题:
- thinking_block 的签名跟 fast 模型绑定,重发到普通模型会 400
- 已经流出去的 tool_use 还没有 tool_result,不能直接拼接
- UI 已经显示了"Let me check..."和工具调用的 spinner
10.2 tombstone 的处理
for (const msg of assistantMessages) {
yield { type: 'tombstone', message: msg }
}
UI 端收到 tombstone 事件后:
- 找到对应 message 的渲染节点
- 从 transcript 中删除
- 从存档文件中跳过
对用户来说,这次失败的流式尝试就像没发生过。
10.3 ant-only 的 stripSignatureBlocks
if (process.env.USER_TYPE === 'ant') {
messagesForQuery = stripSignatureBlocks(messagesForQuery)
}
切模型前对历史里的 thinking 签名进行剥除。注释:"thinking 签名与模型绑定:把受保护 thinking 重放到不兼容 fallback 模型会 400"。 给 fallback 模型一份"干净"的历史,让它能继续接力。
11FallbackTriggeredError 模型切换
这是 withRetry 跟 queryLoop 之间的特殊"信号":withRetry 自己不切模型,而是抛出特殊异常让 queryLoop 处理。
11.1 为什么 withRetry 不切模型
11.2 处理流程(query.ts:898)
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true // 内层 while 重试
// 1. 清掉所有可能孤儿的 tool_use
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
assistantMessages.length = 0
toolResults.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// 2. 重建执行器
streamingToolExecutor?.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
// 3. 更新 toolUseContext 中的当前模型
toolUseContext.options.mainLoopModel = fallbackModel
// 4. ant-only:剥签名
if (process.env.USER_TYPE === 'ant') {
messagesForQuery = stripSignatureBlocks(messagesForQuery)
}
// 5. 埋点
logEvent('tengu_model_fallback_triggered', { original_model, fallback_model, ... })
// 6. 给用户友好提示
yield createSystemMessage(
`Switched to ${renderModelName(fallbackModel)} due to high demand for ${renderModelName(originalModel)}`,
'warning', // 默认可见
)
continue // 内层 while 重新跑请求
}
throw innerError // 其他错误冒泡到外层 catch
}
11.3 fallback 触发条件
- 用户启用了
FALLBACK_FOR_ALL_PRIMARY_MODELS或 - 非订阅用户 + 用 Opus 模型(
isNonCustomOpusModel)且 - 连续 3 次 529(
MAX_529_RETRIES)且 - 有 fallbackModel 配置
12第 4 层:工具执行错误
src/services/tools/toolExecution.ts:工具内部的失败永远不会让 query 循环崩溃。
所有失败都被翻译成一条 tool_result(is_error: true),模型下一回合可以读到错误并自我修复。
12.1 工具失败的种类与处理
| 失败种类 | 翻译为 tool_result.content | 位置 |
|---|---|---|
| 工具不存在(typo / MCP 断连) | <tool_use_error>Error: No such tool available: {name}</tool_use_error> |
L425 |
| 调用前已 abort | createToolResultStopMessage + CANCEL_MESSAGE |
L472 |
| Zod 输入 schema 校验失败 | <tool_use_error>InputValidationError: ...</tool_use_error> + schemaHint |
L712 |
tool 自定义 validateInput 拒绝 |
<tool_use_error>{validationMessage}</tool_use_error> |
L765 |
| PreToolUse hook 拒绝 | tool_result(is_error) + 附带图像块(若有) | L1075 |
| 工具运行时异常 | formatError(error) + 跑 PostToolUseFailure hook |
L1801 |
MCP 鉴权过期(McpAuthError) |
同上,且把 client 状态置为 needs-auth(/mcp 显示重新授权) |
L1687 |
| 外层 try/catch(runToolUse 内部) | tool_result(is_error) "Error calling tool ({name}): {message}" |
L498 |
12.2 完整结构示例
// toolExecution.ts:1801 — 工具运行时抛异常的处理
return [
{
message: createUserMessage({
content: [{
type: 'tool_result',
content, // formatError(error) 的结果
is_error: true, // ← 关键标志
tool_use_id: toolUseID, // 必须配对
}],
toolUseResult: `Error: ${content}`,
mcpMeta: error instanceof McpToolCallError ? error.mcpMeta : undefined,
sourceToolAssistantUUID: assistantMessage.uuid,
}),
},
...hookMessages, // PostToolUseFailure 的输出
]
12.3 多重观测
每条工具错误都同时:
- Statsig 埋点:
tengu_tool_use_error,带classifyToolError分类 - Langfuse:
recordToolObservation({ isError: true, parentBatchSpan }) - OTLP:
logOTelEvent('tool_result', { success: 'false', duration_ms, error }) - 本地日志:
logForDebugging+logError(若不是 ShellError 这类常见错误)
12.4 classifyToolError 的反混淆
export function classifyToolError(error: unknown): string {
// 1. TelemetrySafeError:开发者显式声明的安全字符串
if (error instanceof TelemetrySafeError) return error.telemetryMessage.slice(0, 200)
// 2. Node.js fs 错误:用 errno code(ENOENT, EACCES…)
const errnoCode = getErrnoCode(error)
if (errnoCode) return errnoCode
// 3. 自定义错误类型:用 name(只在未压缩时有效)
if (error.name && error.name !== 'Error' && error.name.length > 3)
return error.name.slice(0, 60)
return 'unknown'
}
问题:压缩构建里 error.constructor.name 会被 mangled 成 "a"、"b" 这种,
埋点完全没用。所以专门做了这套反混淆 fallback 链。
13中断处理
中断(用户按 Ctrl+C、esc esc)可能在任何阶段发生:模型流式中、工具执行中、附件注入前。 Claude Code 在每个阶段都有专门的清理逻辑,确保协议配对完整。
13.1 流式阶段中断(query.ts:1012)
if (toolUseContext.abortController.signal.aborted) {
if (streamingToolExecutor) {
// 消费剩余结果:executeTool() 内会检查 abort 信号
// 并自动生成"中断型 tool_result"
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
// 退化路径:用 yieldMissingToolResultBlocks 补
yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
}
// chicago MCP:中断时自动 unhide + 释放锁
if (feature('CHICAGO_MCP') && !toolUseContext.agentId) {
try {
await cleanupComputerUseAfterTurn(toolUseContext)
} catch {}
}
// submit-interrupt 跳过中断提示(后续排队消息已表达上下文)
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: false })
}
return { reason: 'aborted_streaming' }
}
13.2 工具执行阶段中断(query.ts:1467)
工具批次执行完成后再次检查 abort:
if (toolUseContext.abortController.signal.aborted) {
// chicago MCP 清理(同上)
...
if (signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: true }) // ← 注意 toolUse: true
}
// 中断返回前仍检查 maxTurns,保证行为一致
if (maxTurns && nextTurnCountOnAbort > maxTurns) {
yield createAttachmentMessage({ type: 'max_turns_reached', ... })
}
return { reason: 'aborted_tools' }
}
13.3 两种 abort 信号源
| signal.reason | 触发场景 | 是否 yield 中断提示 |
|---|---|---|
| (undefined) | 用户 Ctrl+C / esc esc | 是 显示"Interrupted by user" |
'interrupt' | submit-interrupt:用户在模型流式时直接发新消息 | 否 后续排队消息已表达上下文 |
14终态枚举
queryLoop 永远以 Terminal 类型结束,每个终态对应一类原因。这让外层(REPL、SDK consumer)可以做精细的差异化处理。
| reason | 含义 | 外层行为 |
|---|---|---|
'completed' |
正常完成,模型不再产生 tool_use | 等待下一次用户输入 |
'aborted_streaming' |
模型流式阶段被中断 | Langfuse trace 标记 interrupted |
'aborted_tools' |
工具执行阶段被中断 | 同上 + 检查 maxTurns |
'blocking_limit' |
预检阶段就发现 token 超阈,且禁了 autocompact | 引导用户手动 /compact |
'image_error' |
图像处理错误(本地 ImageSizeError 或 reactive compact 媒体恢复失败) | 提示用户用更小的图 |
'prompt_too_long' |
PTL 两层恢复都失败 | 引导 /compact 或重启会话 |
'model_error' |
顶层 catch:不可识别的运行时错误 | 暴露原始 error.message,ant 内部高声量日志 |
'stop_hook_prevented' |
stop hook 主动阻止续轮 | 正常结束,不计入错误 |
'hook_stopped' |
PreToolUse hook 阻止续轮(hook_stopped_continuation 附件) |
同上 |
'max_turns' |
达到 maxTurns 上限 | 显示 max_turns_reached 附件 |
14.1 顶层 query() 的 finally 处理
try {
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
} finally {
if (ownsTrace) {
const isAborted =
terminal?.reason === 'aborted_streaming' ||
terminal?.reason === 'aborted_tools'
endTrace(langfuseTrace, undefined, isAborted ? 'interrupted' : undefined)
}
}
// 仅当 queryLoop 正常 return 时才会到这里
// throw 与 .return() 都会跳过
// 故意保留"started 但未 completed"的不对称信号,便于追因
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
15端到端示例
用一个具体场景串起所有机制。
场景:用户发送一个长会话,触发 PTL,且模型已经流出了一半 tool_use
-
用户输入
"基于我们整个对话历史,生成一个总结报告"此时对话已经累积到 ~135k token,接近 Sonnet-4.5 的限制。
-
queryLoop 第一轮:预处理
autocompact 被禁用(用户配置),阻塞限阈预检通过(还差 200 token 触发)。开始流式调用。
-
模型流式响应
模型先输出了 thinking + 一个 Read tool_use(
id: toolu_X),正在等待 tool_result 返回。 -
工具执行注入了大文件
Read 工具读了一个 50k token 的文件,作为 tool_result 注入。下一轮请求 messages 现在 ~185k token。
-
第二轮请求 → 413 Prompt is too long
API 返回错误:
"prompt is too long: 187234 tokens > 200000 maximum"。 getAssistantMessageFromError 把它翻译成:{ type: 'assistant', isApiErrorMessage: true, content: [{ type: 'text', text: 'Prompt is too long' }], apiError: undefined, error: 'invalid_request', errorDetails: 'prompt is too long: 187234 tokens > 200000 maximum', } -
withheld 触发
contextCollapse.isWithheldPromptTooLong()返回 true。错误消息没有 yield 出去, 但被 push 进 assistantMessages,UI 端用户还在转圈圈,以为模型还在思考。 -
检查恢复链 - 第 1 层 collapse drain
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource) // drained = { committed: 12, messages: [...缩短后的 messages] }collapse 找到 12 个分组提交摘要,messages 从 185k → 145k。
state 更新:
transition: { reason: 'collapse_drain_retry' },continue 重发。 -
重发 → 仍 PTL
压缩后还是 145k,Sonnet-4.5 上限 200k 应该够,但因为有大量 tool_use 块的元数据,实际仍 207k。再次返回 PTL。
-
state.transition === 'collapse_drain_retry',跳过第 1 层(已经试过)
直接进入第 2 层 reactive compact。
-
第 2 层 reactive compact
const compacted = await reactiveCompact.tryReactiveCompact({ hasAttempted: false, ... }) // 启动一个 fork agent,生成历史摘要 // compacted = { summaryMessages: [3 条 summary], ... }messages 被压缩到 50k token,注入"Compacted history"系统消息。
state 更新:
hasAttemptedReactiveCompact: true, transition: 'reactive_compact_retry' -
重发 → 成功
50k token 远低于上限,API 正常返回。模型基于压缩后的历史生成报告。用户视角:转圈了 8 秒,然后看到完整回答。 所有 PTL 错误对用户都是不可见的。
-
对比:如果 reactive compact 也失败
yield lastMessage // 这时才把 withheld 的 PTL 错误暴露给 UI void executeStopFailureHooks(lastMessage, toolUseContext) return { reason: 'prompt_too_long' }用户看到"Prompt is too long"错误消息,系统提示运行
/compact或重启会话。
关键时间线
16核心设计原则
把整套机制提炼成 6 条可迁移到其他 agent 系统的设计原则。
-
withheld 优先于 yield
SDK 调用方收到
error字段就会断开。所以可恢复错误先压住,确认恢复失败再暴露, 否则会出现"前端关、后端跑、token 烧"的分裂状态。 -
协议配对永远修复
tool_use 一旦发出,无论后续怎么失败,都要保证有对应 tool_result(中断型/错误型/合成型)。 否则下一次请求会因 schema 校验 400,会话进入死锁。
yieldMissingToolResultBlocks在所有可能断裂的地方都做了兜底。 -
恢复链单步上限 + transition 标记
hasAttemptedReactiveCompact/maxOutputTokensRecoveryCount/state.transition.reason共同防止"错误→恢复→再错误"死循环。每层恢复策略只允许试一次, 失败就升级到下一层。 -
API 错误绕过 stop hooks
错误响应不是可评估产物(模型并没有真正完成回合),继续跑 hook 还会注入 token 触发更多错误,形成无限循环。 所以
if (lastMessage?.isApiErrorMessage)就直接 return,跳过 hook 阶段。 -
统一状态机而非递归
所有恢复路径(compact、fallback、stop-hook、token-budget、中断)都通过
state = { ... }; continue提交, 降低分支爆炸与遗漏字段重置的风险。每条恢复路径只是一次状态赋值,容易测试、容易追因。 -
错误最终以"伪 assistant message"形式存活
createAssistantAPIErrorMessage生成isApiErrorMessage: true的 assistant 消息, 既能在 transcript / UI 显式显示,又能被下游分类、被 stop hook 识别为"非有效产物"。 这是把"异常"变成"对话流的一部分"的关键转换。
<tool_use_error>ENOENT: no such file</tool_use_error> 时,
会自然地下一回合用 ls 检查路径再重试 —— 这种"自我修复"行为来自把错误信息保留在对话流里,
而不是某种特殊的"重试 prompt"。当系统设计得恰当时,错误处理几乎是不可见的。