错误处理与自愈:AI 如何从失败中恢复

深入 Claude Code 的错误恢复机制——query.ts 恢复状态机、工具失败处理、网络重试、用户中断、/doctor 自检

问题引入

一个 AI Agent 在真实环境中运行时,失败是常态而非例外。网络超时、API 过载、文件权限不足、模型输出截断、用户突然按下 Esc——这些不是边缘情况,而是每天发生数百万次的日常事件。

Claude Code 的核心设计哲学是:错误不应该终止会话,而应该触发恢复query.ts 中的主循环不是线性的请求-响应,而是一个包含多种恢复路径的状态机。当 API 返回 max_output_tokens 错误时,系统会自动重试并注入 "continue" 指令;当 prompt 超长时,系统会触发响应式压缩然后重试;当用户按 Esc 中断时,系统会生成合成的 tool_result 保持消息格式合法。

本文深入分析这个恢复状态机的每一条路径。


query.ts 恢复状态机

状态定义

query 循环维护一个可变状态对象,在每次迭代间传递:

src/query.ts
TypeScript
1type State = {
2 messages: Message[]
3 toolUseContext: ToolUseContext
4 autoCompactTracking: AutoCompactTrackingState | undefined
5 maxOutputTokensRecoveryCount: number
6 hasAttemptedReactiveCompact: boolean
7 maxOutputTokensOverride: number | undefined
8 pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
9 stopHookActive: boolean | undefined
10 turnCount: number
11 transition: Continue | undefined
12}

关键的恢复状态字段:

  • maxOutputTokensRecoveryCount — 已尝试的输出截断恢复次数(上限 3)
  • hasAttemptedReactiveCompact — 是否已尝试过响应式压缩
  • maxOutputTokensOverride — 当前覆盖的 max output tokens
  • transition — 上一次迭代继续的原因(用于防止重复恢复)

循环初始化

src/query.ts
TypeScript
1let state: State = {
2 messages: params.messages,
3 toolUseContext: params.toolUseContext,
4 maxOutputTokensOverride: params.maxOutputTokensOverride,
5 autoCompactTracking: undefined,
6 stopHookActive: undefined,
7 maxOutputTokensRecoveryCount: 0,
8 hasAttemptedReactiveCompact: false,
9 turnCount: 1,
10 pendingToolUseSummary: undefined,
11 transition: undefined,
12}

恢复路径总览

...

max_output_tokens 恢复

当模型输出被截断(stop_reason: max_output_tokens)时,系统不是直接报错,而是尝试让模型继续:

src/query.ts
TypeScript
1const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3

错误抑制

流式循环中,max_output_tokens 错误被抑制(不发送给 SDK 消费者):

src/query.ts
TypeScript
1function isWithheldMaxOutputTokens(
2 msg: Message | StreamEvent | undefined,
3): msg is AssistantMessage {
4 return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
5}
src/query.ts
TypeScript
1if (isWithheldMaxOutputTokens(message)) {
2 withheld = true
3}

升级重试

如果使用的是默认 8K max output tokens,先升级到 64K 重试同一请求——不注入 continue 消息,不增加恢复计数:

src/query.ts
TypeScript
1// Escalating retry: if we used the capped 8k default and hit the
2// limit, retry the SAME request at 64k — no meta message, no
3// multi-turn dance. This fires once per turn.
4const capEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
5 'tengu_otk_slot_v1',
6 false,
7)

如果 64K 也不够,进入多轮恢复——注入一条用户消息("你的输出在这里被截断,请从截断处继续"),然后循环回 API 调用:

TypeScript
1// 恢复逻辑伪代码
2if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
3 // 注入 continue 消息
4 state = {
5 ...state,
6 maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
7 maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
8 transition: { reason: 'max_output_tokens_recovery' },
9 }
10 continue // 回到循环顶部
11}
12// 超过限制——表面错误
13yield lastMessage
14return { reason: 'max_output_tokens' }

恢复上限为 3 次——防止无限循环(模型可能在某些情况下持续产生超长输出)。


Prompt Too Long 恢复

当上下文超过模型限制时,系统有两级恢复:

第一级:Context Collapse 排空

Context Collapse 是一种轻量级压缩——将旧的消息折叠成摘要,但保留粒度。排空(drain)是把所有已准备好的折叠一次性提交:

src/query.ts
TypeScript
1if (feature('CONTEXT_COLLAPSE') && contextCollapse &&
2 state.transition?.reason !== 'collapse_drain_retry') {
3 const drained = contextCollapse.recoverFromOverflow(
4 messagesForQuery,
5 querySource,
6 )
7 if (drained.committed > 0) {
8 const next: State = {
9 messages: drained.messages,
10 toolUseContext,
11 autoCompactTracking: tracking,
12 maxOutputTokensRecoveryCount,
13 hasAttemptedReactiveCompact,
14 maxOutputTokensOverride: undefined,
15 pendingToolUseSummary: undefined,
16 stopHookActive: undefined,
17 turnCount,
18 transition: { reason: 'collapse_drain_retry', committed: drained.committed },
19 }
20 state = next
21 continue
22 }
23}

注意 state.transition?.reason !== 'collapse_drain_retry' 检查——如果上一次迭代就是 collapse drain 且仍然 413,说明排空不够,需要更激进的措施。

第二级:Reactive Compact

如果 collapse 排空不够(或未启用),触发完整的响应式压缩:

src/query.ts
TypeScript
1if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
2 const compacted = await reactiveCompact.tryReactiveCompact({
3 hasAttempted: hasAttemptedReactiveCompact,
4 querySource,
5 aborted: toolUseContext.abortController.signal.aborted,
6 messages: messagesForQuery,
7 cacheSafeParams: {
8 systemPrompt, userContext, systemContext,
9 toolUseContext,
10 forkContextMessages: messagesForQuery,
11 },
12 })
13
14 if (compacted) {
15 const postCompactMessages = buildPostCompactMessages(compacted)
16 for (const msg of postCompactMessages) {
17 yield msg
18 }
19 const next: State = {
20 messages: postCompactMessages,
21 toolUseContext,
22 autoCompactTracking: undefined,
23 maxOutputTokensRecoveryCount,
24 hasAttemptedReactiveCompact: true, // 标记为已尝试
25 maxOutputTokensOverride: undefined,
26 pendingToolUseSummary: undefined,
27 stopHookActive: undefined,
28 turnCount,
29 transition: { reason: 'reactive_compact_retry' },
30 }
31 state = next
32 continue
33 }
34
35 // 无法恢复——表面错误
36 yield lastMessage
37 void executeStopFailureHooks(lastMessage, toolUseContext)
38 return { reason: isWithheldMedia ? 'image_error' : 'prompt_too_long' }
39}

关键的安全措施:

  • hasAttemptedReactiveCompact: true 确保只尝试一次——防止"压缩→重试→413→压缩"死循环
  • 不执行 stop hooks——模型没有产生有效响应,hooks 无法评估
  • executeStopFailureHooks 是不同的函数——它只做最基本的失败通知

前置阻断

在进入 API 调用前,如果 auto-compact 关闭且 token 已到阈值,直接阻断:

src/query.ts
TypeScript
1if (!compactionResult && querySource !== 'compact' && querySource !== 'session_memory'
2 && !(reactiveCompact?.isReactiveCompactEnabled() && isAutoCompactEnabled())
3 && !collapseOwnsIt) {
4 const { isAtBlockingLimit } = calculateTokenWarningState(
5 tokenCountWithEstimation(messagesForQuery) - snipTokensFreed,
6 toolUseContext.options.mainLoopModel,
7 )
8 if (isAtBlockingLimit) {
9 yield createAssistantAPIErrorMessage({
10 content: PROMPT_TOO_LONG_ERROR_MESSAGE,
11 })
12 return { reason: 'blocking_limit' }
13 }
14}

注意跳过条件——当 reactive compact 或 context collapse 启用时,不做前置阻断,因为它们能在 API 错误发生后恢复。前置阻断会阻止错误发生,也就阻止了恢复机会。


模型降级恢复

当流式传输过程中触发 FallbackTriggeredError

src/query.ts
TypeScript
1} catch (innerError) {
2 if (innerError instanceof FallbackTriggeredError && fallbackModel) {
3 currentModel = fallbackModel
4 attemptWithFallback = true
5
6 // 为已发出的消息生成 tool_result 占位
7 yield* yieldMissingToolResultBlocks(
8 assistantMessages,
9 'Model fallback triggered',
10 )
11 assistantMessages.length = 0
12 toolResults.length = 0
13
14 // 丢弃流式工具执行器的待处理结果
15 if (streamingToolExecutor) {
16 streamingToolExecutor.discard()
17 streamingToolExecutor = new StreamingToolExecutor(...)
18 }
19
20 // 更新工具上下文中的模型
21 toolUseContext.options.mainLoopModel = fallbackModel
22
23 // Thinking 签名是模型绑定的——清除以避免 400 错误
24 if (process.env.USER_TYPE === 'ant') {
25 messagesForQuery = stripSignatureBlocks(messagesForQuery)
26 }
27
28 yield createSystemMessage(
29 `Switched to ${renderModelName(innerError.fallbackModel)} due to high demand`,
30 'warning',
31 )
32
33 continue // 重试内层循环
34 }
35 throw innerError
36}

特别值得注意的是 stripSignatureBlocks——protected thinking blocks 带有模型特定的加密签名,在降级到不同模型后会导致 API 400 错误。


用户中断处理

用户按 Esc 或 Ctrl+C 时,系统需要优雅地停止:

src/hooks/useCancelRequest.ts
TypeScript
1const handleCancel = useCallback(() => {
2 // Priority 1: 如果有活跃任务,取消它
3 if (abortSignal !== undefined && !abortSignal.aborted) {
4 logEvent('tengu_cancel', cancelProps)
5 setToolUseConfirmQueue(() => [])
6 onCancel()
7 return
8 }
9
10 // Priority 2: Claude 空闲时,弹出队列
11 if (hasCommandsInQueue()) {
12 if (popCommandFromQueue) {
13 popCommandFromQueue()
14 return
15 }
16 }
17
18 // Fallback: 没有可取消的
19 logEvent('tengu_cancel', cancelProps)
20 setToolUseConfirmQueue(() => [])
21 onCancel()
22}, [...])

中断优先级:

  1. 活跃任务 — 设置 abort signal,取消 API 调用和工具执行
  2. 命令队列 — 如果 Claude 空闲但有排队命令,弹出最后一条
  3. 回退 — 清空权限确认队列

中断后的消息清理

在 query.ts 中,中断后需要为所有未完成的 tool_use 生成合成的 tool_result:

src/query.ts
TypeScript
1if (toolUseContext.abortController.signal.aborted) {
2 if (streamingToolExecutor) {
3 // 消费剩余结果——executor 为中断的工具生成合成 tool_results
4 for await (const update of streamingToolExecutor.getRemainingResults()) {
5 if (update.message) {
6 yield update.message
7 }
8 }
9 } else {
10 yield* yieldMissingToolResultBlocks(
11 assistantMessages,
12 'Interrupted by user',
13 )
14 }
15
16 // 跳过 submit-interrupt 的中断消息
17 if (toolUseContext.abortController.signal.reason !== 'interrupt') {
18 yield createUserInterruptionMessage({ toolUse: false })
19 }
20 return { reason: 'aborted_streaming' }
21}

yieldMissingToolResultBlocks 确保消息格式合法——API 要求每个 tool_use 后必须有对应的 tool_result

src/query.ts
TypeScript
1function* yieldMissingToolResultBlocks(
2 assistantMessages: AssistantMessage[],
3 errorMessage: string,
4) {
5 for (const assistantMessage of assistantMessages) {
6 const toolUseBlocks = assistantMessage.message.content.filter(
7 content => content.type === 'tool_use',
8 ) as ToolUseBlock[]
9
10 for (const toolUse of toolUseBlocks) {
11 yield createUserMessage({
12 content: [{
13 type: 'tool_result',
14 content: errorMessage,
15 is_error: true,
16 tool_use_id: toolUse.id,
17 }],
18 toolUseResult: errorMessage,
19 sourceToolAssistantUUID: assistantMessage.uuid,
20 })
21 }
22 }
23}

Ctrl+C vs Esc 的区别

src/hooks/useCancelRequest.ts
TypeScript
1// Escape: 尊重模式切换,不在特殊输入模式时触发
2const isEscapeActive =
3 isContextActive &&
4 (canCancelRunningTask || hasQueuedCommands) &&
5 !isInSpecialModeWithEmptyInput &&
6 !isViewingTeammate
7
8// Ctrl+C: 更强势,在查看 teammate 时也能中断
9const isCtrlCActive =
10 isContextActive &&
11 (canCancelRunningTask || hasQueuedCommands || isViewingTeammate)

Ctrl+C 额外处理了查看 teammate 的场景——停止所有后台 Agent 并返回主线程。

Kill All Agents (二次确认)

src/hooks/useCancelRequest.ts
TypeScript
1const handleKillAgents = useCallback(() => {
2 const now = Date.now()
3 const elapsed = now - lastKillAgentsPressRef.current
4
5 if (elapsed <= KILL_AGENTS_CONFIRM_WINDOW_MS) {
6 // 3 秒内第二次按下——确认杀死所有后台 Agent
7 lastKillAgentsPressRef.current = 0
8 killAllAgentsAndNotify()
9 return
10 }
11
12 // 第一次按下——显示确认提示
13 lastKillAgentsPressRef.current = now
14 addNotification({
15 key: 'kill-agents-confirm',
16 text: `Press ${shortcut} again to stop background agents`,
17 timeoutMs: KILL_AGENTS_CONFIRM_WINDOW_MS,
18 })
19}, [...])

3 秒确认窗口防止误操作——后台 Agent 可能正在执行重要任务。


工具执行失败反馈

当工具执行失败时,错误信息作为 tool_resultis_error: true 内容反馈给模型。这让模型能理解发生了什么并决定下一步——是重试、换方法、还是向用户报告:

TypeScript
1// 简化表示——工具执行错误处理
2yield createUserMessage({
3 content: [{
4 type: 'tool_result',
5 content: `Error: ${error.message}`,
6 is_error: true,
7 tool_use_id: toolUse.id,
8 }],
9})

这是 Claude Code 的核心自愈模式——错误不是系统终止信号,而是模型的输入信号。模型看到 bash 命令失败后,通常会修改命令重试。看到文件不存在后,会先用 ls 检查。


/doctor 环境自检

/doctor 命令提供系统级的诊断:

src/utils/doctorDiagnostic.ts
TypeScript
1export type DiagnosticInfo = {
2 installationType: InstallationType
3 version: string
4 installationPath: string
5 invokedBinary: string
6 configInstallMethod: InstallMethod | 'not set'
7 autoUpdates: string
8 hasUpdatePermissions: boolean | null
9 multipleInstallations: Array<{ type: string; path: string }>
10 warnings: Array<{ issue: string; fix: string }>
11 recommendation?: string
12 packageManager?: string
13 ripgrepStatus: {
14 working: boolean
15 mode: 'system' | 'builtin' | 'embedded'
16 systemPath: string | null
17 }
18}

诊断覆盖:

  1. 安装类型检测 — npm-global/npm-local/native/package-manager/development
  2. 多安装检测 — 发现系统中多个 Claude Code 安装
  3. 权限检查 — 自动更新是否有写权限
  4. ripgrep 状态 — 搜索引擎是否正常工作
  5. Shell 配置 — alias 和环境变量是否正确

安装类型检测逻辑相当详尽:

src/utils/doctorDiagnostic.ts
TypeScript
1export async function getCurrentInstallationType(): Promise<InstallationType> {
2 if (process.env.NODE_ENV === 'development') return 'development'
3
4 if (isInBundledMode()) {
5 // 检查是否由包管理器安装
6 if (detectHomebrew() || detectWinget() || detectMise() ||
7 detectAsdf() || await detectPacman() ||
8 await detectDeb() || await detectRpm() || await detectApk()) {
9 return 'package-manager'
10 }
11 return 'native'
12 }
13
14 if (isRunningFromLocalInstallation()) return 'npm-local'
15
16 // 检查典型的 npm 全局路径
17 const npmGlobalPaths = [
18 '/usr/local/lib/node_modules',
19 '/usr/lib/node_modules',
20 '/opt/homebrew/lib/node_modules',
21 '/.nvm/versions/node/',
22 ]
23 if (npmGlobalPaths.some(path => invokedPath.includes(path))) {
24 return 'npm-global'
25 }
26
27 return 'unknown'
28}

检测覆盖了所有主流包管理器——Homebrew、winget、mise、asdf、pacman、deb、rpm、apk——确保在任何 Linux/macOS/Windows 环境下都能正确识别安装方式。


恢复路径的互动关系

各恢复路径之间有复杂的互动关系,理解这些关系是理解系统韧性的关键:

...

关键的互动规则:

  1. 前置阻断与恢复互斥 — 启用 reactive compact 或 context collapse 时,不做前置阻断(否则恢复路径永远不会被触发)
  2. Collapse → Reactive 级联 — collapse 排空失败后才尝试 reactive compact
  3. 同类型只尝试一次hasAttemptedReactiveCompact 防止 reactive compact 死循环
  4. transition 防止重复state.transition?.reason 检查防止同一恢复策略连续执行
  5. 错误抑制与恢复必须一致 — 流式循环中抑制的错误,必须在恢复检查中有对应处理;否则错误会被吞掉

流式错误抑制的一致性要求

src/query.ts
TypeScript
1// Hoist media-recovery gate once per turn. Withholding (inside the
2// stream loop) and recovery (after) must agree; CACHED_MAY_BE_STALE can
3// flip during the 5-30s stream, and withhold-without-recover would eat
4// the message.
5const mediaRecoveryEnabled =
6 reactiveCompact?.isReactiveCompactEnabled() ?? false

Feature flag 的值在流式传输的 5-30 秒内可能变化(GrowthBook 缓存刷新)。如果流开始时抑制了错误,但流结束时恢复检查看到 flag 关闭,错误就丢失了。所以在 turn 开始时一次性提取 flag 值,全程使用同一个值。


小结

Claude Code 的错误恢复系统体现了几个核心原则:

  • 错误是输入,不是终止信号 — 工具执行失败变成 tool_result(is_error: true) 反馈给模型
  • 分级恢复 — 从轻量(collapse drain)到重量(reactive compact),逐级升级
  • 有限重试 — 每种恢复路径都有明确的尝试上限,防止死循环
  • 状态完整性 — 中断后生成合成 tool_result,保持消息格式合法
  • Flag 一致性 — 抑制和恢复必须看到相同的 feature flag 值
  • 环境自检 — /doctor 提供系统级诊断,帮助用户定位环境问题

这个系统的复杂性直接来源于"永远不应该终止会话"的设计目标。在一个 AI Agent 可能连续运行数小时的世界里,每一种故障模式都需要一条恢复路径——不是因为工程师喜欢复杂性,而是因为现实就是复杂的。