Claude Code 源码深度讲解

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 层,每层只关心自己尺度的失败,层与层之间通过"已知错误类型"对接。

Agent Loop 错误恢复 4 层架构 第 1 层 · withRetry() SDK 调用层:对网络/HTTP 错误做指数退避重试 429/529 限流 · ECONNRESET 重连 · OAuth 401 自动刷新 · max_tokens 溢出调小重试 · 跨模型 fallback 信号 src/services/api/withRetry.ts 无法重试 → throw CannotRetryError 第 2 层 · getAssistantMessageFromError() 错误翻译层:把任意异常 → 一条带 isApiErrorMessage 的 AssistantMessage PTL/媒体超限/鉴权/限流/模型不存在/refusal/工具配对错误 · 每类都给可读文案 + errorDetails src/services/api/errors.ts 作为 AssistantMessage 流回循环 第 3 层 · queryLoop while(true) 状态机 核心恢复层:统一处理可恢复错误,通过 state.transition 决定下一步 withheld 暂缓 · collapse drain · reactive compact · max_output 升档+续写 · 协议配对修复 · tombstone src/query.ts 工具调用错误转 tool_result(is_error) 第 4 层 · 工具执行错误处理 src/services/tools/toolExecution.ts
错误从底层向上经过翻译,在第 3 层做大部分恢复决策,无法恢复的才暴露给用户

每层解决的核心问题

  • 第 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 整体重试流程

withRetry 开始 operation(client, attempt) 调用 Anthropic SDK 成功 return result throw error 分类错误 是否可重试? 是否触发 fallback? throw CannotRetryError 529×3 + fallback throw FallbackTriggeredError getRetryDelay() 指数退避 + jitter 429:读取 retry-after 5h Max 配额:用 reset 时间 而非按次退避 yield SystemAPIErrorMessage sleep(delay) 持久模式分片心跳 attempt++ attempt > maxRetries → CannotRetryError
withRetry 的核心逻辑:可重试 → 退避 → 重发;不可重试 → 抛 CannotRetryError;限流连续超阈 + 有 fallback model → 抛 FallbackTriggeredError

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) 背景查询的"快失败"

设计动机
摘要、标题、分类器这些用户根本不会感知到的查询,在容量级联(529 风暴)时如果还重试, 每次重试就是 3-10× 的网关放大。所以 Claude Code 维护了一个白名单 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.tsgetAssistantMessageFromError() 把任意 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 数)
  })
}
为什么是 assistant 角色?
因为 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.tsqueryLoop() 是整个 agent loop 的核心。 它是一个单 while(true) 状态机,所有恢复路径都通过 state = { ... }; continue 提交。

5.1 为什么用单 while + state 而不是递归

注释里的设计说明(query.ts:292)
  1. 降低分支爆炸:compact、fallback、stop-hook、token-budget、中断 5 条恢复路径若用递归实现, 每条路径都要复制一遍参数传递,容易遗漏字段。状态机里 5 条路径都只是赋值 state 然后 continue, 所有字段一次性更新。
  2. 三段式回合:每轮都按"模型采样 → 工具执行 → 附件注入"顺序, 先保证 tool_use/tool_result 配对完整,再注入普通附件,避免 API 协议冲突。
  3. 错误先压住、后恢复、最后暴露:防止 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 主流程示意

while (true) 进入 回合预处理 snip · microcompact · context-collapse · autocompact 阻塞限阈预检 · skill 预取 模型流式采样 deps.callModel() → 边流边判 withheld 收 tool_use → 推入 streamingToolExecutor 并行启动 收 FallbackTriggeredError → 内层 while 重试切模型 收到了 tool_use 块? needsFollowUp 检查 withheld 错误 PTL → collapse → compact OTK → 升档 → 续写 恢复成功 → 重发 handleStopHooks 阻塞返回 → continue return completed runTools() 并发/串行执行 tool_result 入队 注入附件 memory · skill · queued 达到 maxTurns? return state = next; continue; turnCount++ 中断处理(任意阶段触发 abort) streamingToolExecutor.getRemainingResults() 合成 tool_result return aborted_streaming / aborted_tools
queryLoop 主流程:三段式回合 + 集中恢复决策

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?

问题:不 withheld 会怎样
SDK 调用方(cowork、desktop、第三方集成)看到 error 字段就立刻关闭会话。 而 Claude Code 的恢复链路其实还在跑(可能要 10s+ 做 reactive compact 然后重发)。 结果就是:前端已停止监听,后端还在烧 token,最终的成功响应没有任何观众
解法:withheld 三阶段
  1. 暂缓:错误消息推入 assistantMessages,但不 yield 出去
  2. 恢复:回合结束时(needsFollowUp = false 分支)判断错误类型,触发对应恢复链
  3. 暴露:恢复成功 → 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 两层恢复策略

第 1 层 · context-collapse drain 把 staged collapse 摘要排空,释放 token,保留细粒度上下文 条件:state.transition.reason !== 'collapse_drain_retry'(防同层重试)且 drained.committed > 0 contextCollapse.recoverFromOverflow() drained > 0 → continue 重发 仍 PTL 第 2 层 · reactive compact 整段历史摘要重写,大幅压缩(成本高,但效果显著) 条件:hasAttemptedReactiveCompact === false(每个回合最多一次) reactiveCompact.tryReactiveCompact() compacted → continue 重发 无法恢复 暴露 withheld 错误 yield lastMessage; return { reason: 'prompt_too_long' } 关键防线:hasAttemptedReactiveCompact 标记 即使 stop hook 阻塞回到这里,该回合也不会再次执行 reactive compact 否则会形成 "413 → compact → 再 413 → 再 compact" 的死循环 注释原文(query.ts:1278):"曾因在此重置为 false 导致无限循环,消耗大量 API 调用"
PTL 恢复链:两层尝试,失败才暴露;hasAttemptedReactiveCompact 防止跨阶段死循环

7.2 流程详解

  1. 识别 withheld PTL
    const isWithheld413 =
      lastMessage?.type === 'assistant' &&
      lastMessage.isApiErrorMessage &&
      isPromptTooLongMessage(lastMessage)
  2. 第 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"的退化。

  3. 第 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
    }
  4. 恢复失败:暴露并退出
    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 上限。 这个错误不能通过压缩历史解决,只能给模型更多输出空间让模型续写

withheld:apiError === 'max_output_tokens' isWithheldMaxOutputTokens(lastMessage) 是否已经升档过? maxOutputTokensOverride === undefined? tengu_otk_slot_v1 启用? 升档 override = 64k 重发同一请求 续写次数 < 3? 注入续写提示 isMeta: true "Resume directly — no apology, no recap" 暴露错误 yield lastMessage state.continue → 回流式阶段
OTK 恢复:第一次升档 8k→64k(同一请求),后续每次注入续写提示,最多 3 次

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(...)
}
tombstone 的妙用
{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 事件后:

  1. 找到对应 message 的渲染节点
  2. 从 transcript 中删除
  3. 从存档文件中跳过

对用户来说,这次失败的流式尝试就像没发生过。

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 不切模型

分层职责
切模型涉及:1) 清空已发的孤儿 tool_use;2) 剥除 thinking 签名;3) 重置 streamingToolExecutor;4) 给用户发系统消息。 这些都涉及 messages 状态和 UI 通知,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

  1. 用户输入
    "基于我们整个对话历史,生成一个总结报告"

    此时对话已经累积到 ~135k token,接近 Sonnet-4.5 的限制。

  2. queryLoop 第一轮:预处理

    autocompact 被禁用(用户配置),阻塞限阈预检通过(还差 200 token 触发)。开始流式调用。

  3. 模型流式响应

    模型先输出了 thinking + 一个 Read tool_use(id: toolu_X),正在等待 tool_result 返回。

  4. 工具执行注入了大文件

    Read 工具读了一个 50k token 的文件,作为 tool_result 注入。下一轮请求 messages 现在 ~185k token。

  5. 第二轮请求 → 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',
    }
  6. withheld 触发

    contextCollapse.isWithheldPromptTooLong() 返回 true。错误消息没有 yield 出去, 但被 push 进 assistantMessages,UI 端用户还在转圈圈,以为模型还在思考。

  7. 检查恢复链 - 第 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 重发。

  8. 重发 → 仍 PTL

    压缩后还是 145k,Sonnet-4.5 上限 200k 应该够,但因为有大量 tool_use 块的元数据,实际仍 207k。再次返回 PTL。

  9. state.transition === 'collapse_drain_retry',跳过第 1 层(已经试过)

    直接进入第 2 层 reactive compact。

  10. 第 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'

  11. 重发 → 成功

    50k token 远低于上限,API 正常返回。模型基于压缩后的历史生成报告。用户视角:转圈了 8 秒,然后看到完整回答。 所有 PTL 错误对用户都是不可见的。

  12. 对比:如果 reactive compact 也失败
    yield lastMessage  // 这时才把 withheld 的 PTL 错误暴露给 UI
    void executeStopFailureHooks(lastMessage, toolUseContext)
    return { reason: 'prompt_too_long' }

    用户看到"Prompt is too long"错误消息,系统提示运行 /compact 或重启会话。

关键时间线

用户输入 t=0 第 1 次请求 +0.3s tool_use tool_result +1.5s PTL 1 +2.1s withheld collapse drain +2.3s PTL 2 +2.8s reactive compact +5.2s fork agent 成功响应 +8.0s 用户视角:仅一个 8 秒的"思考中"动画,看不到任何错误
端到端时间线:两次 PTL、两层恢复,对用户完全透明

16核心设计原则

把整套机制提炼成 6 条可迁移到其他 agent 系统的设计原则。

  1. withheld 优先于 yield

    SDK 调用方收到 error 字段就会断开。所以可恢复错误先压住,确认恢复失败再暴露, 否则会出现"前端关、后端跑、token 烧"的分裂状态。

  2. 协议配对永远修复

    tool_use 一旦发出,无论后续怎么失败,都要保证有对应 tool_result(中断型/错误型/合成型)。 否则下一次请求会因 schema 校验 400,会话进入死锁。yieldMissingToolResultBlocks 在所有可能断裂的地方都做了兜底。

  3. 恢复链单步上限 + transition 标记

    hasAttemptedReactiveCompact / maxOutputTokensRecoveryCount / state.transition.reason 共同防止"错误→恢复→再错误"死循环。每层恢复策略只允许试一次, 失败就升级到下一层。

  4. API 错误绕过 stop hooks

    错误响应不是可评估产物(模型并没有真正完成回合),继续跑 hook 还会注入 token 触发更多错误,形成无限循环。 所以 if (lastMessage?.isApiErrorMessage) 就直接 return,跳过 hook 阶段。

  5. 统一状态机而非递归

    所有恢复路径(compact、fallback、stop-hook、token-budget、中断)都通过 state = { ... }; continue 提交, 降低分支爆炸与遗漏字段重置的风险。每条恢复路径只是一次状态赋值,容易测试、容易追因。

  6. 错误最终以"伪 assistant message"形式存活

    createAssistantAPIErrorMessage 生成 isApiErrorMessage: true 的 assistant 消息, 既能在 transcript / UI 显式显示,又能被下游分类、被 stop hook 识别为"非有效产物"。 这是把"异常"变成"对话流的一部分"的关键转换。

最重要的一条:让模型看见错误
Claude Code 的核心理念是把工具失败、API 错误、协议异常都变成模型可以读到的 tool_result 或 assistant message, 而不是让它们冒泡到用户。模型读到 <tool_use_error>ENOENT: no such file</tool_use_error> 时, 会自然地下一回合用 ls 检查路径再重试 —— 这种"自我修复"行为来自把错误信息保留在对话流里, 而不是某种特殊的"重试 prompt"。当系统设计得恰当时,错误处理几乎是不可见的。