SOURCE-CODE DEEP DIVE

Agent Loop 是 Claude Code 的
「心脏」—— 它如何把
"模型 + 工具 + 错误恢复"编成一台状态机?

本文追踪 QueryEngine.submitMessagequery()queryLoop() 的完整执行链路 —— 两层架构、七大阶段、八种状态转换、三层错误恢复、四层上下文压缩。 读完后你能精确指出每一次模型调用、每一次工具并行、每一次 PTL 重试在源码哪里、为什么。

核心源码 · QueryEngine.ts · query.ts · query/deps.ts · query/stopHooks.ts · query/tokenBudget.ts · services/tools/* · services/compact/*
本文导航
  1. 一、总览 · Agent Loop 是什么
  2. 二、QueryEngine · 外层编排器
  3. 三、queryLoop · 内层状态机(7 阶段)
  4. 四、模型调用阶段详解(withheld / fallback)
  5. 五、工具执行 · 并发安全 vs 串行
  6. 六、上下文压缩 · 四层递进
  7. 七、错误恢复 · "先轻后重"策略
  8. 八、Stop Hooks · 模型说完之后还要做什么
  9. 九、附件注入 · 时机与策略
  10. 十、Token Budget · 双重预算
  11. 十一、关键细节 · 你可能不知道的
  12. 十二、完整示例 · 单轮对话全程
  13. 十三、源码文件索引

01Agent Loop 是什么

一句话定义:接收用户输入 → 调模型 → 执行模型请求的工具 → 把工具结果反馈给模型 → 循环, 直到模型产出最终响应(不再调用工具)。这种"模型生成 — 工具执行 — 结果反馈"的迭代结构,是所有 Agent 框架的本质。

1.1 两层架构

Agent Loop 由两层组成:外层编排负责会话级的 IO 与状态管理;内层循环负责单次提交内的模型/工具往返。

🎯 外层编排器 · QueryEngine.submitMessage()

每个会话一个 QueryEngine 实例;每次 submitMessage() 是同会话的新 turn。

  • 初始化会话状态(system prompt、user context、权限)
  • 处理用户输入(slash 命令、附件、权限)
  • 调用 query() 并消费其流式输出
  • 将内部消息格式转换为 SDK 协议消息
  • 管理会话持久化(transcript 写盘)
  • 处理预算/成本/轮次上限
⚙️ 内层循环 · query() → queryLoop()

while(true) 状态机。每轮代表一个"模型回合"。

  • 压缩上下文(snip → microcompact → collapse → autocompact)
  • 调用模型 API(流式)
  • 执行工具(并发/串行)
  • 注入附件(记忆、队列命令、skill)
  • 错误恢复(PTL、max_output_tokens、fallback)
  • stop hooks(记忆提取、prompt 建议、auto-dream)
  • 状态机 Continue/Terminal 转换

1.2 五条核心设计原则

原则 1

单 while(true) 状态机

所有恢复路径都不递归调用,统一通过更新 state 进入下一轮。栈深度恒定,错误处理可预测。

原则 2

三段式回合

每轮严格遵循「模型采样 → 工具执行 → 附件注入」顺序。保证 tool_use / tool_result 协议配对。

原则 3

错误先暂缓后暴露

withheld 机制让 PTL/媒体/max_output_tokens 错误暂不暴露给 SDK,避免"前台已停、后台在跑"的分裂状态。

原则 4

依赖注入

QueryDeps 只暴露 4 个钩子(callModel/microcompact/autocompact/uuid),让测试可注入 fake,不再依赖 spyOn。

原则 5

配置快照(QueryConfig)

QueryConfig 在入口一次性快照,循环内不漂移。任何运行时配置变化(feature flag、模型切换)都不会破坏正在进行的会话语义。

02QueryEngine · 外层编排器

QueryEngine 是会话生命周期的拥有者,持有跨 turn 的累积状态。 它把"用户敲下一条消息"翻译成"驱动 query() 跑完一整个回合"。

2.1 类内部结构

export class QueryEngine {
  private config: QueryEngineConfig           // 不可变配置
  private mutableMessages: Message[]          // 跨 turn 累积的消息
  private abortController: AbortController     // 中断控制
  private permissionDenials: SDKPermissionDenial[]  // 权限拒绝记录
  private totalUsage: NonNullableUsage          // 累计 token 用量
  private readFileState: FileStateCache         // 文件读取缓存
  private discoveredSkillNames: Set<string>     // 已发现技能
  private loadedNestedMemoryPaths: Set<string>  // 已加载嵌套记忆
}

2.2 submitMessage 的七步处理流程

flowchart TB START(["submitMessage(prompt)"]) --> S1["Step 1 · 初始化
清空 skills / setCwd / 包装 canUseTool
fetchSystemPromptParts() 三路并行"] S1 --> S2["Step 2 · 系统提示词组装
customPrompt | defaultSystemPrompt
+ memoryMechanicsPrompt
+ appendSystemPrompt"] S2 --> S3["Step 3 · processUserInput
解析 slash / 处理附件 / 权限
→ messagesFromUserInput"] S3 --> S4["Step 4 · Transcript 预写入
⚠️ 必须在进入 query 前完成
防止进程被杀导致用户消息丢失"] S4 --> Q{"shouldQuery?"} Q -->|"否(本地命令)"| LOCAL["Step 5 · 本地命令分支
直接回放命令输出 → return"] Q -->|"是"| LOOP["Step 6 · 进入 query() 循环
for await message of query(...)
按 message.type 分发处理"] LOOP --> END(["Step 7 · 返回 result"]) LOCAL --> END style S4 fill:#FFE5E0,stroke:#C23B22,stroke-width:2px style LOOP fill:#EAF1FF,stroke:#5769F7,stroke-width:2px

图 2.1 · submitMessage 七步流程

2.3 关键细节 · Transcript 为什么必须预写?

如果不在进入 query() 前预写 transcript

正常流程:用户输入 → query() → API → assistant 响应 → 写盘 ✅
异常流程:用户输入 → query() → API 请求发出 → 进程被 kill ❌
后果:transcript 中只有 queue-operation,resume 时过滤为空, 用户消息彻底丢失,会话报 "No conversation found"。

预写盘保证:即便模型尚未回包,"用户消息已被接受"这一事实可恢复。

2.4 流式消息分发 · query() 输出的 7 种 message.type

message.typeQueryEngine 的处理
assistant累加 usage、推送 mutableMessages、yield 给 SDK;写盘 fire-and-forget(不 await)
userturnCount++、推送 mutableMessages、yield 给 SDK
progress内联写盘、yield 给 SDK
attachment处理 max_turns / structured_output / queued_command 等附件类型
stream_event更新 usage;条件 yield(仅 includePartialMessages 时)
system处理 compact_boundary(裁剪 mutableMessages)、snip 回放
tombstone不对外透传(孤儿 assistant 标记,仅内部使用)
tool_use_summary透传给 SDK

2.5 compact_boundary 的双重裁剪

query() 产出 compact_boundary 系统消息时,QueryEngine 同步裁剪两套消息引用:

QueryEngine.mutableMessages
[m1, m2, m3, ..., COMPACT_BOUNDARY, m100, m101]
↓ splice(0, boundaryIdx)
裁剪后
[COMPACT_BOUNDARY, m100, m101]
✅ 同步裁剪本地 messages 快照、向 SDK 透传 compact_boundary,让客户端会话视图与本地状态一致。

03queryLoop · 内层状态机

这是 Agent Loop 的真正心脏。一个 while(true), 每轮按 7 个阶段顺序执行。任何错误恢复都不递归 —— 而是更新 state 后 continue。

3.1 query() 是 queryLoop() 的薄包装

export async function* query(params: QueryParams) {
  // 创建/复用 Langfuse trace(可观测性)
  const langfuseTrace = params.toolUseContext.langfuseTrace
    ?? (isLangfuseEnabled() ? createTrace({...}) : null)
  const paramsWithTrace = ...

  try {
    yield* queryLoop(paramsWithTrace, consumedCommandUuids)  // ← 委托
  } finally {
    if (ownsTrace) endTrace(...)  // 保证 trace 被关闭
  }
}

3.2 State · 跨迭代的可变状态

queryLoop 的 State 结构

字段类型含义
messagesMessage[]当前消息序列
toolUseContextToolUseContext工具执行上下文
autoCompactTrackingAutoCompactStatecompact 追踪状态
maxOutputTokensRecoveryCountnumber输出 token 恢复计数(≤3)
hasAttemptedReactiveCompactboolean是否已尝试 reactive
maxOutputTokensOverridenumber?输出 token 上限覆盖
pendingToolUseSummaryPromise?待输出工具摘要
stopHookActiveboolean?stop hook 是否激活(防死循环)
turnCountnumber当前轮次
transitionContinue?上一轮为何 continue

3.3 八种状态转换 · transition 类型一览

transition 名触发条件state 更新
next_turn模型调用了工具,需要执行并反馈messages += assistant + toolResults
collapse_drain_retryPTL 恢复 · 排空 context-collapse 队列messages = drained.messages
reactive_compact_retryPTL/媒体错误 · reactive compact 摘要messages = postCompactMessages
max_output_tokens_escalate输出 token 上限升档(8k → 64k)maxOutputTokensOverride = 64k
max_output_tokens_recovery输出 token 恢复 · 注入续跑提示messages += assistant + recoveryMsg
stop_hook_blockingstop hook 产出阻塞错误messages += assistant + blockingErrors
token_budget_continuationtoken 预算续跑messages += assistant + nudgeMsg
Terminal循环结束—(返回终止原因)

3.4 单轮迭代的 7 个阶段

下方是 queryLoop 的核心心智模型。每轮迭代严格按 Phase 0 → Phase 7 顺序执行。

PHASE 0
初始化

解构 state、启动 skill 预取(异步)、yield stream_request_start、链路追踪、获取 messagesForQuery。

PHASE 1
上下文压缩

四层递进:toolResultBudget → snip → microcompact → collapse → autocompact。

PHASE 2
API 调用

硬阻塞预检 → 拼装 fullSystemPrompt → prependUserContext → deps.callModel 流式调用。

PHASE 3
中断处理

检查 abort signal → 消费剩余工具结果 → chicago MCP 清理 → return。

PHASE 4
终止判定

PTL 恢复链 / max_output_tokens 恢复 / stop hooks / token budget。

PHASE 5
工具执行

StreamingToolExecutor 或 runTools,分并发安全 / 非安全两批执行。

PHASE 6
附件注入

队列命令 / 记忆预取 / skill 发现 → 工具结果之后注入。

PHASE 7
状态提交

turnCount++ / maxTurns 检查 / state 更新 → continue。

3.5 七阶段流程图

flowchart TB START(["queryLoop 单轮迭代开始"]) --> P0["Phase 0 · 初始化
· 解构 state
· 启动 skill 预取(异步)
· getMessagesAfterCompactBoundary"] P0 --> P1["Phase 1 · 上下文压缩
① toolResultBudget
② snip
③ microcompact
④ collapse
⑤ autocompact"] P1 --> P2["Phase 2 · API 调用
· 硬阻塞预检
· fullSystemPrompt = systemPrompt + systemContext
· prependUserContext
· deps.callModel 流式"] P2 --> ABORT{"abort 信号?"} ABORT -->|"是"| P3["Phase 3 · 中断处理
消费 streamingExecutor 剩余结果
return aborted_streaming"] ABORT -->|"否"| FOLLOW{"needsFollowUp?"} FOLLOW -->|"否(最终响应)"| P4["Phase 4 · 终止判定
· PTL 恢复链
· max_output_tokens 恢复
· API 错误
· stop hooks
· token budget"] FOLLOW -->|"是(含 tool_use)"| P5["Phase 5 · 工具执行
StreamingExecutor.getRemainingResults
或 runTools(toolUseBlocks)
partition: 并发安全 vs 非安全"] P5 --> P6["Phase 6 · 附件注入
· getAttachmentMessages
· pendingMemoryPrefetch
· skillPrefetch
· 刷新工具列表"] P6 --> P7["Phase 7 · 状态提交
nextTurnCount = turnCount + 1
maxTurns 检查
state.transition = 'next_turn'"] P7 --> CONT(["continue 进入下一轮"]) P4 -->|"recovery"| CONT P4 -->|"completed"| TERM(["return Terminal"]) P3 --> TERM style P0 fill:#F5F0E8,stroke:#6B5A4F style P1 fill:#FFEFD0,stroke:#C8862C style P2 fill:#EAF1FF,stroke:#5769F7 style P3 fill:#FFE5E0,stroke:#C23B22 style P4 fill:#F3EAFF,stroke:#6B4A8E style P5 fill:#FFF6F0,stroke:#D77757 style P6 fill:#E8F5EE,stroke:#2D8659 style P7 fill:#E0EAEC,stroke:#5A8C95

图 3.1 · queryLoop 单轮迭代的 7 个阶段

04模型调用阶段详解

Phase 2 是 queryLoop 中最复杂的阶段。它不仅要把 messages / systemPrompt 发给 API, 还要处理流式响应、并行启动工具执行、识别可恢复错误、做流式 fallback。

4.1 callModel 参数构建

deps.callModel({
  messages: prependUserContext(messagesForQuery, userContext),  // ← 注入用户上下文
  systemPrompt: fullSystemPrompt,                                // ← 已含 systemContext
  thinkingConfig: toolUseContext.options.thinkingConfig,        // adaptive | disabled
  tools: toolUseContext.options.tools,
  signal: toolUseContext.abortController.signal,
  options: {
    model: currentModel,                // 可能被 fast mode 切换
    fallbackModel,                       // 主模型不可用时切换目标
    isNonInteractiveSession,
    querySource,                          // repl_main_thread | sdk | compact | agent:* ...
    agents: activeAgents,
    maxOutputTokensOverride,             // 升档时设为 64k
    taskBudget: { total, remaining },    // API 侧整轮预算感知
    langfuseTrace,
  },
})

4.2 流式响应的事件流

sequenceDiagram autonumber participant Q as queryLoop participant CM as callModel participant API as Anthropic API participant TE as StreamingToolExecutor Q->>CM: callModel({messages, systemPrompt, ...}) CM->>API: HTTPS streaming 请求 API-->>CM: message_start (init usage) API-->>CM: content_block_start (text) API-->>CM: content_block_delta * N CM-->>Q: yield AssistantMessage(text 部分) API-->>CM: content_block_start (tool_use A) API-->>CM: content_block_delta * N (input streaming) API-->>CM: content_block_stop (tool_use A) Note over CM,TE: tengu_streaming_tool_execution2 启用时 CM->>TE: enqueue(tool_use A) — 立即开始执行 TE-->>TE: tool A running... API-->>CM: content_block_start (tool_use B) API-->>CM: content_block_stop (tool_use B) CM->>TE: enqueue(tool_use B) API-->>CM: message_delta (stop_reason='tool_use') API-->>CM: message_stop CM-->>Q: yield AssistantMessage(完整) Note over Q,TE: Phase 5 取已完成的工具结果
大部分可能已 ready

图 4.1 · 流式响应 + 流式工具执行(StreamingToolExecutor)

4.3 withheld 机制 · 错误暂缓暴露

为什么不能立即暴露错误?

如果流式阶段立即把可恢复错误暴露给 SDK,上层会在看到 error 后直接结束会话 —— 而本地恢复循环还在继续,导致"后台在跑、前台已停听"的分裂状态。SDK 会以为会话已死, queryLoop 还在重试。
解决方案:先暂缓(withheld),等确认恢复链路是否成功,再决定是否暴露。

let withheld = false

// context-collapse 的 PTL 恢复
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) {
  withheld = true
}

// reactive compact 的 PTL/媒体错误恢复
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
  withheld = true
}

// 媒体尺寸错误恢复
if (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) {
  withheld = true
}

// max_output_tokens 恢复
if (isWithheldMaxOutputTokens(message)) {
  withheld = true
}

if (!withheld) {
  yield yieldMessage  // 正常暴露给 SDK
}
// withheld 的消息仍推入 assistantMessages,供 Phase 4 恢复判断使用

4.4 流式 Fallback · 主模型不可用时切换

flowchart LR A["主模型 callModel 失败
FallbackTriggeredError"] --> B["为孤儿 assistant 消息发 tombstone"] B --> C["清空 assistantMessages
清空 toolResults
清空 toolUseBlocks"] C --> D["streamingToolExecutor.discard()
+ 重建新 executor"] D --> E["currentModel = fallbackModel"] E --> F["stripSignatureBlocks
剥离 thinking 签名
(防 fallback 模型 400)"] F --> G["重跑 while(attemptWithFallback)"] G --> SUCCESS(["成功 → 回到主流程"]) style A fill:#FFE5E0,stroke:#C23B22 style F fill:#FFF7E6,stroke:#C8862C style SUCCESS fill:#E8F5EE,stroke:#2D8659

图 4.2 · 流式 Fallback 完整流程

为什么必须剥离 thinking 签名?

thinking 块的签名与具体模型绑定。重放到不兼容的 fallback 模型会触发 API 400 错误。 stripSignatureBlocks 在 fallback 前清掉签名,只保留 thinking 文本。

05工具执行 · 并发安全 vs 串行

Phase 5 处理工具执行。两种模式(流式 vs 批量),两种并发(安全 vs 非安全),一种铁律: tool_use 与 tool_result 必须严格配对,否则 API 报协议错误。

5.1 两种执行模式

StreamingToolExecutor

启用条件:tengu_streaming_tool_execution2 gate 启用。

优势:模型输出 tool_use A 时立即开始执行 A,不等流结束。 模型还在生成 tool_use B 时,A 可能已经完成。流式输出结束后,大部分工具已 ready。 显著减少用户感知等待时间。

runTools(默认)

模型流式输出完成后,批量执行所有工具。

简单可控,调试友好。但工具执行只能在流结束后开始 —— 如果模型用了 5 个工具且每个 1 秒,至少多 5 秒延迟。

5.2 工具批次划分 · partitionToolCalls

工具按"并发安全性"划分批次。规则简单:

类型判定标准典型工具执行方式
并发安全 只读,不修改外部状态 Read · Grep · Glob · WebFetch · WebSearch Promise.all 并行
非安全 有副作用(写文件、执行命令、改状态) Edit · Write · Bash · TodoWrite · MCP 写工具 串行,独占执行
partitionToolCalls 划分示例
输入
[Read, Grep, Edit, Bash, Read]
Batch 1 · 并发安全 · 并行
Promise.all([Read, Grep])
Batch 2 · 非安全 · 串行
Edit
Batch 3 · 非安全 · 串行
Bash
Batch 4 · 并发安全
Read
⚠️ 顺序保留 —— 前一个 Batch 完成后才开始下一个,保证有副作用的工具看到一致状态。

5.3 单工具执行流程

flowchart TB START(["runToolUse(tool, input, canUseTool, ctx)"]) --> PERM{"canUseTool"} PERM -->|"allow"| EXEC["tool.execute(input, ctx)"] PERM -->|"deny"| DENIED["返回拒绝消息"] PERM -->|"ask"| WAIT["等待用户确认 (REPL)"] WAIT -->|"approve"| EXEC WAIT -->|"reject"| DENIED EXEC --> POST["后处理
· 更新 readFileState
· 更新 fileHistory
· 更新 attribution
· nestedMemoryAttachmentTriggers"] POST --> RET(["返回 MessageUpdate
{ message, newContext }"]) DENIED --> RET style PERM fill:#FFF7E6,stroke:#C8862C style EXEC fill:#FFF6F0,stroke:#D77757 style POST fill:#E8F5EE,stroke:#2D8659

图 5.1 · 单工具执行流程(含权限三态判定)

5.4 协议铁律 · tool_use / tool_result 必须配对

铁律

API 要求每个 tool_use 必须有对应 tool_result。 中断/错误时若有未配对的 tool_use 残留,下一次 API 调用会 400。

// 中断时补齐合成 tool_result,保证下次调用合法
yieldMissingToolResultBlocks(toolUseBlocks, completedResults)

// fallback 时也要丢弃旧 tool_use_id,避免孤儿 result 混入
streamingToolExecutor.discard()  // 进行中的工具收到合成错误结果

06上下文压缩 · 四层递进

每轮调用模型前,Phase 1 按"先轻后重"的顺序执行五道压缩工序。 前面几道便宜(裁剪/cache 编辑),最后才是昂贵的"摘要重建"。

6.1 五道工序流水线

flowchart LR M0["state.messages"] --> P1["① applyToolResultBudget
工具结果总量预算收敛"] P1 --> P2["② snipCompactIfNeeded
HISTORY_SNIP · 规则裁剪"] P2 --> P3["③ microcompactMessages
cache editing 修改条目"] P3 --> P4["④ applyCollapsesIfNeeded
CONTEXT_COLLAPSE · 读时投影"] P4 --> P5["⑤ autoCompactIfNeeded
token 超限 → 完整摘要重建"] P5 --> OUT(["messagesForQuery"]) style P1 fill:#FFEFD0,stroke:#C8862C style P2 fill:#FFE2C5,stroke:#C8862C style P3 fill:#FFD4B8,stroke:#D77757 style P4 fill:#FCE8DC,stroke:#D77757 style P5 fill:#FFE5E0,stroke:#C23B22,stroke-width:2px style OUT fill:#E8F5EE,stroke:#2D8659

图 6.1 · 上下文压缩五道工序(颜色由轻到重)

6.2 五种压缩详解

层级触发条件方式开销是否破坏对话历史
toolResultBudget 工具结果总量超阈值 截断/替换单条工具结果 极低 仅工具结果,不动消息结构
snip HISTORY_SNIP feature 启用 按规则裁剪历史消息段 裁剪段消失,注入边界标记
microcompact cache editing 触发 修改已有 cache 条目(不重写) 仅修改 cache 视图,原消息保留
collapse CONTEXT_COLLAPSE feature 读时投影,不写回 REPL 数组 跨回合持久,可重放
autocompact token 超限 分叉代理生成完整摘要 (一次完整模型调用) 是 — 旧消息被替换

6.3 为什么是这个顺序?

设计权衡 1

toolResultBudget 先于 microcompact

cached MC 只看 tool_use_id 不读内容,两者可无冲突组合。先收工具结果,再做 cache 编辑。

设计权衡 2

snip 先于 autocompact

snip 释放的 token 需要透传给 autocompact 的阈值判断。如果 snip 已把 token 拉回阈值内,可避免触发 autocompact。

设计权衡 3

collapse 先于 autocompact

同上 —— collapse 也是轻量手段,先用它,再用昂贵的全量摘要。

设计权衡 4

autocompact 最后

它是最重的手段(要发起一次完整模型调用生成摘要),只在轻量手段都不够时触发。

6.4 compact 后消息重建

const postCompactMessages = buildPostCompactMessages(compactionResult)
// 包含:
//   - compact_boundary 系统消息(标记裁剪点)
//   - 摘要消息(assistant 摘要历史)
//   - 附件消息
//   - hook 结果消息

messagesForQuery = postCompactMessages
// 旧消息被完全替换,不再保留在内存中
// QueryEngine.mutableMessages 也同步裁剪

07错误恢复 · "先轻后重"策略

Phase 4 是 Agent Loop 最复杂的部分。queryLoop 不会立刻让 SDK 看到错误 —— 它会先在自己内部尝试 1~3 次恢复,恢复成功就 continue,失败才暴露。

7.1 错误恢复总表

错误类型恢复路径(先后顺序)最大重试
Prompt Too Long (413) ① context-collapse drain → ② reactive compact → ③ 暴露 每路 1 次
媒体尺寸错误 ① reactive compact (strip-retry) → ② 暴露 1 次
max_output_tokens ① 升档重试 (8k→64k) → ② 注入续跑提示 → ③ 暴露 升档 1 次 + 续跑 3 次
模型不可用 ① fallback 模型重试 1 次
API 限流/鉴权 ① 重试(exponential backoff) 配置决定
通用错误 ① 补齐缺失 tool_result → ② 暴露

7.2 PTL 恢复链 · "先轻后重"的典范

触发:API 返回 413(Prompt Too Long),消息被 withheld 暂缓
第一步 · context-collapse drain
排空 staged collapse 队列。如果有未投影的 collapse,先把它们 drain 出来,可能就够了。
✅ 成功 → continue(collapse_drain_retry
❌ 无可排空内容 → 进入第二步
第二步 · reactive compact
使用分叉代理生成摘要,重建消息序列。
✅ 成功 → continue(reactive_compact_retry
❌ 失败 → 暴露错误
暴露 · 终止循环,向 SDK 透传 PTL 错误。

特殊情况:跳过第一步

如果上一轮已经执行过 collapse_drain_retry(即 collapse 已经排空过),本轮再次 PTL 时直接进入第二步, 避免无谓尝试。

7.3 max_output_tokens 恢复链

flowchart TB START(["max_output_tokens 错误
(被 withheld)"]) --> SLOT{"tengu_otk_slot_v1?"} SLOT -->|"启用"| ESCALATE["升档重试
maxOutputTokensOverride = 64k
continue → max_output_tokens_escalate"] SLOT -->|"未启用"| RECOVERY ESCALATE -->|"仍失败"| RECOVERY RECOVERY{"recoveryCount < 3?"} -->|"是"| INJECT["注入续跑提示:
'Output token limit hit. Resume directly —
no apology, no recap. Pick up mid-thought.'
messages += assistant + recoveryMsg
recoveryCount++
continue → max_output_tokens_recovery"] INJECT -.检查.-> RECOVERY RECOVERY -->|"否(耗尽)"| EXPOSE([暴露 withheld 错误
return]) style START fill:#FFE5E0,stroke:#C23B22 style ESCALATE fill:#FFF7E6,stroke:#C8862C style INJECT fill:#FFF6F0,stroke:#D77757 style EXPOSE fill:#FFE5E0,stroke:#C23B22

图 7.1 · max_output_tokens 恢复链(升档 + 续跑双策略)

7.4 thinking 块的硬约束

API 协议级约束

① 含 thinking / redacted_thinking 的消息必须出现在允许思考长度的查询中;
② thinking 块不能作为消息序列的最后块
③ 在单个 assistant 轨迹内必须保持完整。

为什么 fallback 时要剥离签名? thinking 签名与具体模型绑定。重放到不兼容的 fallback 模型会触发 400。 因此 fallback 前需要 stripSignatureBlocks

08Stop Hooks · 模型说完之后还要做什么

当模型产出最终响应(needsFollowUp=false)且不是 API 错误时,handleStopHooks 触发。 这是会话结束前的"清理 + 增强"环节 —— 记忆提取、prompt 建议、auto-dream、外部 hook。

8.1 Stop Hooks 执行内容

flowchart TB START(["needsFollowUp=false
且非 API 错误"]) --> S1["1. 保存 cacheSafeParams
供分叉代理复用缓存"] S1 --> S2["2. Template job 分类
TEMPLATES feature"] S2 --> S3["3. 后台书签任务(非 --bare)
· prompt 建议
· 记忆提取(仅主线程 + extractMode + 非 poor)
· auto-dream"] S3 --> S4["4. chicago MCP 清理
· auto-unhide
· 锁释放"] S4 --> S5["5. 外部 stop hooks 执行
读取用户配置脚本
收集输出"] S5 --> CHECK{"hook 输出?"} CHECK -->|"blockingErrors"| BLOCK["注入错误消息
continue → stop_hook_blocking"] CHECK -->|"hook_stopped_continuation"| STOP["preventContinuation = true
return"] CHECK -->|"无阻塞"| END(["return Terminal · completed"]) style S3 fill:#EAF1FF,stroke:#5769F7 style S5 fill:#F3EAFF,stroke:#6B4A8E style BLOCK fill:#FFF7E6,stroke:#C8862C style STOP fill:#FFE5E0,stroke:#C23B22 style END fill:#E8F5EE,stroke:#2D8659

图 8.1 · Stop Hooks 五步流程

8.2 三个后台书签任务

extractMemories

记忆提取

分析当前对话,提取值得长期保留的记忆条目。仅在主线程 + isExtractModeActive + 非 poor 模式下运行。

promptSuggestion

Prompt 建议

分析对话生成下一条建议消息(即 REPL 中"接下来你可能想问..."的提示)。仅交互模式生效。

auto-dream

Auto-Dream

会话空闲时的"做梦"任务,整合记忆/反思上下文/生成衍生工作。仅特定 feature 启用时运行。

8.3 阻塞机制 · stop hook 如何让循环再跑一轮

信号效果使用场景
blockingErrors 注入错误消息,continue 到下一轮(让模型修正) hook 检测到代码风格问题、test 失败等需要模型回应
hook_stopped_continuation 直接终止循环,return Terminal hook 判定不应继续(如安全策略命中)

防死循环 · stopHookActive 标记

如果上一轮已经是 stop_hook_blocking 转换,下一轮的 stop hooks 看到 stopHookActive=true, 会跳过自己 —— 避免 hook 反复阻塞导致死循环。

09附件注入 · 时机与策略

附件注入发生在 Phase 6,时机至关重要 —— 必须在工具执行完成之后、 下一轮迭代之前。这个顺序由 API 协议约束决定。

9.1 为什么必须在工具结果之后注入?

三条理由

  1. API 协议约束:tool_result 必须紧跟 tool_use,不能与普通 user 消息交错。
  2. 触发器依赖:工具执行可能产生新的附件触发器(如 nested memory)。
  3. 可见性:附件内容需要在下一轮 API 调用中才对模型可见。

9.2 七种附件类型

附件类型来源含义
edited_text_file文件变更监听外部编辑器修改了项目文件
relevant_memories异步记忆预取与当前对话相关的记忆文件
skill_discovery异步技能预取发现的新技能
queued_command消息队列排队的用户命令或任务通知
hook_stopped_continuation外部 hookhook 请求停止续轮
max_turns_reached轮次上限达到最大轮次
structured_output结构化输出StructuredOutput 工具结果

9.3 记忆预取的"异步消费"策略

// queryLoop 入口启动(异步,不阻塞)— 注意 using 关键字
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(...)

// 在 Phase 6 工具执行完成后尝试消费
if (
  pendingMemoryPrefetch.settledAt !== null &&  // 预取已完成
  pendingMemoryPrefetch.consumedOnIteration === -1  // 尚未消费
) {
  const memoryAttachments = filterDuplicateMemoryAttachments(
    await pendingMemoryPrefetch.promise,
    toolUseContext.readFileState,  // 过滤模型已读/写过的记忆
  )
  // 注入为附件
  pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}

三个关键设计

异步预取:不阻塞主循环,预取与模型调用并行;
未完成则跳过:本轮如果预取还没好,下一轮再尝试;
filterDuplicateMemoryAttachments:避免注入模型已通过 Read 工具看过的记忆,省 token。

10Token Budget · 双重预算

Claude Code 有两套并行的"预算"机制 —— 一个本地控制何时让模型续跑,一个发给 API 让模型自己感知整体预算。

① 本地预算 · tokenBudget

位置:src/query/tokenBudget.ts
前提:TOKEN_BUDGET feature 启用。

每轮结束后检查 token 使用
  ├── action: 'continue'
  │   ├── 注入 nudgeMessage
  │   ├── incrementBudgetContinuationCount
  │   └── continue → token_budget_continuation
  └── action: 'complete'
      ├── diminishingReturns=true → 提前停止
      └── return { reason: 'completed' }

② API 侧 · taskBudget

作为 callModel 参数下发给模型,让它"知道整轮还剩多少预算"。

taskBudget: {
  total: number,      // 整轮预算
  remaining: number,  // 剩余预算(compact 后补偿)
}

· 首次 compact 前 remaining=undefined:服务端可见完整历史
· compact 后用 remaining 补偿"被摘要掉的 pre-compact 上下文"
· 多次 compact 按触发点累减

11关键细节 · 你可能不知道的

本节是阅读源码的"通关秘籍"。这些设计不写出来很难看出来,写出来后会让你恍然大悟"原来是这样"。

11.1 双轨制消息:mutableMessages vs messages

为什么需要两套?

query() 内部的 compact 会裁剪消息;
② 但 QueryEngine 需要保留完整历史用于 transcript;
compact_boundary 到达时,两套同步裁剪。

11.2 processUserInput 被调用两次

看起来奇怪,但有原因:

11.3 assistant 写盘 · 唯一的 fire-and-forget

if (message.type === 'assistant') {
  void recordTranscript(messages)  // ⚠️ 不 await!
} else {
  await recordTranscript(messages)
}

为什么唯独 assistant 不 await?

assistant 按 content block 流出,usagestop_reason 在后续 message_delta 才回填。 如果在这里 await,会阻塞生成器消费,导致 message_delta 延迟,最终 usage 统计延迟显示。

11.4 StreamingToolExecutor.discard 的清理逻辑

// 流式 fallback 发生时
streamingToolExecutor.discard()              // 标记已丢弃
streamingToolExecutor = new StreamingToolExecutor(...) // 重建

// discard 的效果:
//   - 排队的工具不再启动
//   - 进行中的工具收到合成错误 tool_result
//   - 避免旧 tool_use_id 的孤儿结果混入重试

11.5 backfillObservableInput · 路径回填

某些工具的输入字段是"可观察但不可序列化"的(如相对路径展开为绝对路径):

if (tool?.backfillObservableInput) {
  const inputCopy = { ...originalInput }
  tool.backfillObservableInput(inputCopy)
  // 仅在"新增字段"时才产出克隆消息
  // 纯覆盖场景跳过,避免破坏 transcript VCR 哈希
}

11.6 消息队列的代理作用域

消息队列是进程级单例,主协调器与子代理共享:

角色消费规则
主线程消费 agentId === undefined 的命令
子代理仅消费 mode === 'task-notification' && agentId === currentAgentId

这避免了子代理意外消费主线程的用户 prompt。

11.7 compact_boundary 的双重写入

为什么要补写 tailUuid 之前的内存消息?

compact_boundary 到达时,QueryEngine 需要先把 preservedSegment.tailUuid 之前的仅内存消息补写到 transcript。 若此时子进程重启而 tailUuid 未落盘,relink 会失败 —— resume 无法剪枝旧历史,导致会话状态不一致。

11.8 硬阻塞阈值预检的三个豁免

自动 compact 关闭时,queryLoop 在 API 调用前会做硬阻塞预检。但下列场景豁免

  1. compact/snip 刚执行后 —— usage 读数仍是旧窗口,预检会误拦截。
  2. compact / session_memory 分叉代理 —— 在此阻塞会自锁。
  3. reactive compact / context-collapse 已启用 —— 不应在 API 前合成错误,否则真实 413 恢复链路拿不到触发点。

11.9 QueryDeps · 为什么只有 4 个钩子

export type QueryDeps = {
  callModel: typeof queryModelWithStreaming
  microcompact: typeof microcompactMessages
  autocompact: typeof autoCompactIfNeeded
  uuid: () => string
}

刻意保持小范围

这是一种约束 —— 证明依赖注入模式可行。每个 dep 都是被 6-8 个测试文件 spyOn 的热点模块。 用 deps 注入替代 spyOn 消除了模块导入顺序和缓存污染问题。如果未来"每个 import 都要 deps", 这种模式就会膨胀到失控。所以严格止于 4 个。

11.10 using 关键字 · ES2025 显式资源管理

using pendingMemoryPrefetch = startRelevantMemoryPrefetch(...)
// 离开作用域时自动调用 [Symbol.dispose]()
// 替代了传统的 try-finally 手动清理
// 保证 AsyncGenerator 在所有退出路径都正确释放资源

11.11 querySource 的六种值与差异行为

querySource说明特殊行为
repl_main_threadREPL 交互保存 cacheSafeParams、触发 prompt 建议
sdkSDK / Headless保存 cacheSafeParams
compactcompact 分叉代理跳过硬阻塞预检
session_memorysession memory 分叉跳过硬阻塞预检
agent:*子代理仅持久化工具结果替换记录
extract_memories记忆提取代理跳过 transcript 记录

11.12 Terminal 的十种终止原因

reason说明
completed模型产出最终响应,无工具调用 ✅
aborted_streaming用户在模型流式输出期间中断
aborted_tools用户在工具执行期间中断
blocking_limit硬阻塞阈值预检失败
prompt_too_longPTL 恢复失败
image_error媒体尺寸错误恢复失败 / 图像缩放错误
model_error模型 API 错误
stop_hook_preventedstop hook 阻止续轮
hook_stopped外部 hook 请求停止
max_turns达到最大轮次

12完整示例 · 单轮对话全程

最后用一条具体消息把所有概念串起来。场景:用户输入 "帮我分析这个文件", 模型决定先 Read 文件再回答。这是一个典型的双 API 调用会话。

sequenceDiagram autonumber participant U as 用户 participant QE as QueryEngine participant QL as queryLoop participant API as Claude API participant T as Tools U->>QE: submitMessage("帮我分析这个文件") QE->>QE: fetchSystemPromptParts() QE->>QE: processUserInput() QE->>QE: recordTranscript() 预写入 ⚠️ QE->>QL: query() Note over QL: 第一轮迭代 QL->>QL: Phase 0 · 初始化
Phase 1 · 压缩(无) QL->>QL: Phase 2 · prependUserContext
+ 拼装 fullSystemPrompt QL->>API: callModel({messages, system, ...}) API-->>QL: assistant: text + tool_use(Read) QL->>QL: needsFollowUp = true QL->>T: Phase 5 · runTools([Read]) T->>T: canUseTool(Read) → allow T->>T: Read.execute(file_path) T-->>QL: tool_result: 文件内容 QL->>QL: Phase 6 · 附件注入(若有) QL->>QL: Phase 7 · state.messages 更新 Note over QL: 第二轮迭代 QL->>QL: Phase 1 · 压缩(无) QL->>API: callModel({messages含工具结果}) API-->>QL: assistant: "这个文件实现了..." QL->>QL: needsFollowUp = false QL->>QL: Phase 4 · handleStopHooks QL->>QL: 后台: extractMemories
后台: promptSuggestion QL-->>QE: return Terminal · completed QE->>QE: 累计 usage / cost QE-->>U: yield result(success)

图 12.1 · 单轮对话的完整时序(双 API 调用,含 Read 工具)

12.1 数据视角的全程

第一轮
输入 messages
[user("帮我分析这个文件")]
+ prependUserContext 注入 system-reminder
输入 systemPrompt
fullSystemPrompt
= systemPrompt + systemContext (gitStatus 等)
↓ deps.callModel
输出 assistant
text("我来读取文件") + tool_use(Read, "x.ts")
stop_reason: 'tool_use'
↓ Phase 5 runTools
tool_result
{tool_use_id: 'toolu_xxx', content: '文件内容...'}
↓ Phase 7 state 更新
第二轮
输入 messages
[..., assistant#1, tool_result#1]
prependUserContext 再次注入
↓ callModel(缓存命中前缀)
输出 assistant
text("这个文件实现了 XXX 功能...")
stop_reason: 'end_turn'
↓ needsFollowUp = false
Phase 4 stop hooks
后台触发 extractMemories + promptSuggestion → Terminal · completed

12.2 最终返回给 SDK 的 result 对象

{
  type: 'result',
  subtype: 'success',
  result: "这个文件实现了 XXX 功能...",
  usage: {
    input_tokens: 12500,
    output_tokens: 320,
    cache_read_input_tokens: 11800,  // 第二轮命中前缀缓存
    cache_creation_input_tokens: 0,
  },
  total_cost_usd: 0.024,
  duration_ms: 3420,
  num_turns: 2,
}

13核心源码文件索引

想自己读源码?以下是按职责分类的关键文件清单。建议从 QueryEngine.ts 开始, 然后顺着 query() 调用链进入 queryLoop

文件职责
src/QueryEngine.ts外层编排器:会话管理、用户输入处理、SDK 协议转换
src/query.ts内层循环:状态机、API 调用、工具执行、错误恢复
src/query/deps.ts依赖注入:callModel、microcompact、autocompact、uuid
src/query/config.ts配置快照:会话级不可变配置
src/query/transitions.ts状态转换类型:Terminal、Continue
src/query/stopHooks.tsStop hooks:记忆提取、prompt 建议、外部 hook
src/query/tokenBudget.tsToken 预算:续跑/完成决策
src/services/tools/toolOrchestration.ts工具编排:并发/串行执行
src/services/tools/StreamingToolExecutor.ts流式工具执行器:边流边执行
src/services/tools/toolExecution.ts单工具执行:权限检查 → 执行 → 后处理
src/services/compact/autoCompact.ts自动压缩:token 超限时触发
src/services/compact/microCompact.ts微压缩:cache editing
src/services/compact/snipCompact.tsSnip 压缩:规则裁剪
src/services/compact/reactiveCompact.ts响应式压缩:PTL/媒体错误恢复
src/services/compact/compact.ts压缩核心:摘要生成 + 消息重建
src/services/api/claude.tsAPI 客户端:流式调用模型
src/utils/attachments.ts附件系统:记忆预取、队列命令、嵌套记忆
src/utils/api.tsAPI 工具:prependUserContext、appendSystemContext

建议的阅读路径

flowchart LR A["1️⃣ QueryEngine.ts
submitMessage 流程"] --> B["2️⃣ query.ts
queryLoop 状态机"] B --> C["3️⃣ query/deps.ts
看 4 个钩子"] C --> D["4️⃣ query/transitions.ts
看 transition 类型"] D --> E["5️⃣ services/api/claude.ts
看 API 实际怎么调"] E --> F["6️⃣ services/tools/
看工具如何并发"] F --> G["7️⃣ services/compact/
看四层压缩细节"] G --> H["8️⃣ query/stopHooks.ts
看会话尾声"] style A fill:#FFF6F0,stroke:#D77757,stroke-width:2px style H fill:#E8F5EE,stroke:#2D8659,stroke-width:2px

最后一个洞察

Claude Code 的 Agent Loop 本质上是"不可变配置 + 不可变上下文 + 可变状态"的组合: QueryConfig 在入口快照不变;systemPrompt / userContext / systemContext 进入 queryLoop 后不变;只有 state(messages、turnCount、transition)每轮变化。 这种设计让推理路径可预测、错误恢复可追踪、测试可注入。

阅读源码时,盯紧 state 的每次更新 —— 哪一行修改了 messages、哪一行设置了 transition —— 你就掌握了 Agent Loop 的全部秘密。