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 五条核心设计原则
单 while(true) 状态机
所有恢复路径都不递归调用,统一通过更新 state 进入下一轮。栈深度恒定,错误处理可预测。
三段式回合
每轮严格遵循「模型采样 → 工具执行 → 附件注入」顺序。保证 tool_use / tool_result 协议配对。
错误先暂缓后暴露
withheld 机制让 PTL/媒体/max_output_tokens 错误暂不暴露给 SDK,避免"前台已停、后台在跑"的分裂状态。
依赖注入
QueryDeps 只暴露 4 个钩子(callModel/microcompact/autocompact/uuid),让测试可注入 fake,不再依赖 spyOn。
配置快照(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 的七步处理流程
清空 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.type | QueryEngine 的处理 |
|---|---|
assistant | 累加 usage、推送 mutableMessages、yield 给 SDK;写盘 fire-and-forget(不 await) |
user | turnCount++、推送 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 同步裁剪两套消息引用:
[m1, m2, m3, ..., COMPACT_BOUNDARY, m100, m101]
[COMPACT_BOUNDARY, m100, m101]
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 结构
| 字段 | 类型 | 含义 |
|---|---|---|
messages | Message[] | 当前消息序列 |
toolUseContext | ToolUseContext | 工具执行上下文 |
autoCompactTracking | AutoCompactState | compact 追踪状态 |
maxOutputTokensRecoveryCount | number | 输出 token 恢复计数(≤3) |
hasAttemptedReactiveCompact | boolean | 是否已尝试 reactive |
maxOutputTokensOverride | number? | 输出 token 上限覆盖 |
pendingToolUseSummary | Promise? | 待输出工具摘要 |
stopHookActive | boolean? | stop hook 是否激活(防死循环) |
turnCount | number | 当前轮次 |
transition | Continue? | 上一轮为何 continue |
3.3 八种状态转换 · transition 类型一览
| transition 名 | 触发条件 | state 更新 |
|---|---|---|
next_turn | 模型调用了工具,需要执行并反馈 | messages += assistant + toolResults |
collapse_drain_retry | PTL 恢复 · 排空 context-collapse 队列 | messages = drained.messages |
reactive_compact_retry | PTL/媒体错误 · reactive compact 摘要 | messages = postCompactMessages |
max_output_tokens_escalate | 输出 token 上限升档(8k → 64k) | maxOutputTokensOverride = 64k |
max_output_tokens_recovery | 输出 token 恢复 · 注入续跑提示 | messages += assistant + recoveryMsg |
stop_hook_blocking | stop hook 产出阻塞错误 | messages += assistant + blockingErrors |
token_budget_continuation | token 预算续跑 | messages += assistant + nudgeMsg |
Terminal | 循环结束 | —(返回终止原因) |
3.4 单轮迭代的 7 个阶段
下方是 queryLoop 的核心心智模型。每轮迭代严格按 Phase 0 → Phase 7 顺序执行。
初始化
解构 state、启动 skill 预取(异步)、yield stream_request_start、链路追踪、获取 messagesForQuery。
上下文压缩
四层递进:toolResultBudget → snip → microcompact → collapse → autocompact。
API 调用
硬阻塞预检 → 拼装 fullSystemPrompt → prependUserContext → deps.callModel 流式调用。
中断处理
检查 abort signal → 消费剩余工具结果 → chicago MCP 清理 → return。
终止判定
PTL 恢复链 / max_output_tokens 恢复 / stop hooks / token budget。
工具执行
StreamingToolExecutor 或 runTools,分并发安全 / 非安全两批执行。
附件注入
队列命令 / 记忆预取 / skill 发现 → 工具结果之后注入。
状态提交
turnCount++ / maxTurns 检查 / state 更新 → continue。
3.5 七阶段流程图
· 解构 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 流式响应的事件流
大部分可能已 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 · 主模型不可用时切换
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 写工具 | 串行,独占执行 |
5.3 单工具执行流程
· 更新 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 五道工序流水线
工具结果总量预算收敛"] 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 为什么是这个顺序?
toolResultBudget 先于 microcompact
cached MC 只看 tool_use_id 不读内容,两者可无冲突组合。先收工具结果,再做 cache 编辑。
snip 先于 autocompact
snip 释放的 token 需要透传给 autocompact 的阈值判断。如果 snip 已把 token 拉回阈值内,可避免触发 autocompact。
collapse 先于 autocompact
同上 —— collapse 也是轻量手段,先用它,再用昂贵的全量摘要。
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 恢复链 · "先轻后重"的典范
排空 staged collapse 队列。如果有未投影的 collapse,先把它们 drain 出来,可能就够了。
✅ 成功 → continue(
collapse_drain_retry)❌ 无可排空内容 → 进入第二步
使用分叉代理生成摘要,重建消息序列。
✅ 成功 → continue(
reactive_compact_retry)❌ 失败 → 暴露错误
特殊情况:跳过第一步
如果上一轮已经执行过 collapse_drain_retry(即 collapse 已经排空过),本轮再次 PTL 时直接进入第二步,
避免无谓尝试。
7.3 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 执行内容
且非 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 三个后台书签任务
记忆提取
分析当前对话,提取值得长期保留的记忆条目。仅在主线程 + isExtractModeActive + 非 poor 模式下运行。
Prompt 建议
分析对话生成下一条建议消息(即 REPL 中"接下来你可能想问..."的提示)。仅交互模式生效。
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 为什么必须在工具结果之后注入?
三条理由
- API 协议约束:tool_result 必须紧跟 tool_use,不能与普通 user 消息交错。
- 触发器依赖:工具执行可能产生新的附件触发器(如 nested memory)。
- 可见性:附件内容需要在下一轮 API 调用中才对模型可见。
9.2 七种附件类型
| 附件类型 | 来源 | 含义 |
|---|---|---|
edited_text_file | 文件变更监听 | 外部编辑器修改了项目文件 |
relevant_memories | 异步记忆预取 | 与当前对话相关的记忆文件 |
skill_discovery | 异步技能预取 | 发现的新技能 |
queued_command | 消息队列 | 排队的用户命令或任务通知 |
hook_stopped_continuation | 外部 hook | hook 请求停止续轮 |
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 被调用两次
看起来奇怪,但有原因:
- 第一次:允许
setMessages回写 —— slash 命令可以改写消息数组(如 /compact 加边界标记)。 - 第二次:
setMessages: () => {}—— 不再允许命令改写消息,避免多源修改导致状态漂移。
11.3 assistant 写盘 · 唯一的 fire-and-forget
if (message.type === 'assistant') {
void recordTranscript(messages) // ⚠️ 不 await!
} else {
await recordTranscript(messages)
}
为什么唯独 assistant 不 await?
assistant 按 content block 流出,usage 和 stop_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 调用前会做硬阻塞预检。但下列场景豁免:
- compact/snip 刚执行后 —— usage 读数仍是旧窗口,预检会误拦截。
- compact / session_memory 分叉代理 —— 在此阻塞会自锁。
- 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_thread | REPL 交互 | 保存 cacheSafeParams、触发 prompt 建议 |
sdk | SDK / Headless | 保存 cacheSafeParams |
compact | compact 分叉代理 | 跳过硬阻塞预检 |
session_memory | session memory 分叉 | 跳过硬阻塞预检 |
agent:* | 子代理 | 仅持久化工具结果替换记录 |
extract_memories | 记忆提取代理 | 跳过 transcript 记录 |
11.12 Terminal 的十种终止原因
| reason | 说明 |
|---|---|
completed | 模型产出最终响应,无工具调用 ✅ |
aborted_streaming | 用户在模型流式输出期间中断 |
aborted_tools | 用户在工具执行期间中断 |
blocking_limit | 硬阻塞阈值预检失败 |
prompt_too_long | PTL 恢复失败 |
image_error | 媒体尺寸错误恢复失败 / 图像缩放错误 |
model_error | 模型 API 错误 |
stop_hook_prevented | stop hook 阻止续轮 |
hook_stopped | 外部 hook 请求停止 |
max_turns | 达到最大轮次 |
12完整示例 · 单轮对话全程
最后用一条具体消息把所有概念串起来。场景:用户输入 "帮我分析这个文件",
模型决定先 Read 文件再回答。这是一个典型的双 API 调用会话。
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 数据视角的全程
[user("帮我分析这个文件")]+ prependUserContext 注入 system-reminder
fullSystemPrompt= systemPrompt + systemContext (gitStatus 等)
stop_reason: 'tool_use'
[..., assistant#1, tool_result#1]prependUserContext 再次注入
stop_reason: 'end_turn'
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.ts | Stop hooks:记忆提取、prompt 建议、外部 hook |
src/query/tokenBudget.ts | Token 预算:续跑/完成决策 |
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.ts | Snip 压缩:规则裁剪 |
src/services/compact/reactiveCompact.ts | 响应式压缩:PTL/媒体错误恢复 |
src/services/compact/compact.ts | 压缩核心:摘要生成 + 消息重建 |
src/services/api/claude.ts | API 客户端:流式调用模型 |
src/utils/attachments.ts | 附件系统:记忆预取、队列命令、嵌套记忆 |
src/utils/api.ts | API 工具:prependUserContext、appendSystemContext |
建议的阅读路径
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 的全部秘密。