模块三:Agent 核心循环 | 前置依赖:第 06 课 | 预计学习时间:75 分钟
学习目标
完成本课后,你将能够:
- 解释
query()异步生成器函数的签名与返回类型 - 完整追踪
while(true)循环中一次迭代的所有阶段 - 说明
callModel()的调用方式与流式响应处理 - 理解
tool_use检测、findToolByName()分发、以及工具结果收集的完整流程 - 描述错误恢复机制(FallbackTriggeredError、max_output_tokens recovery、reactive compact)
7.1 query.ts 的地位
在第 01 课中,我们说 query.ts 是整个系统的"心跳"。现在我们要深入这颗心脏的每一条血管。
query.ts 大约 1730 行,是一个单文件状态机。它的核心职责:
query.ts 职责清单:
├── 接收消息列表,构建 API 请求
├── 调用 Claude API(通过 callModel)
├── 流式接收响应(text 块 + tool_use 块)
├── 检测 tool_use → 分发工具执行
├── 收集工具结果 → 追加到消息列表
├── 判断是否继续循环
├── 上下文压缩(auto-compact、snip、microcompact)
├── 错误恢复(模型降级、prompt-too-long、max_output_tokens)
└── 附件注入(memory、skill、queued commands)
为什么是生成器函数?
query() 不是普通的 async function,而是 async function* —— 异步生成器。这个设计选择至关重要:
// query.ts 核心签名
export async function* query(
params: QueryParams,
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
Terminal
> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
return terminal
}
为什么用生成器而不是回调?
| 方式 | 问题 |
|---|---|
| 回调/EventEmitter | 调用方无法控制消费节奏;容易内存泄漏 |
| Promise | 只能返回一次值;无法表达"流" |
| Observable (RxJS) | 增加大量依赖;学习成本高 |
| AsyncGenerator | 原生 JS;调用方用 for await...of 按需消费;支持背压 |
调用方(REPL.tsx)这样消费:
for await (const event of query(params)) {
// 每个 event 可能是:
// - StreamEvent(text 块 / tool_use 块的流式片段)
// - Message(完整的 assistant/user/system 消息)
// - TombstoneMessage(需要从 UI 移除的消息)
// - ToolUseSummaryMessage(工具使用摘要)
handleEvent(event)
}
7.2 QueryParams — 输入参数
进入循环前,先看输入:
export type QueryParams = {
messages: Message[] // 完整对话历史
systemPrompt: SystemPrompt // 系统提示词
userContext: { [k: string]: string } // 用户上下文(CLAUDE.md 等)
systemContext: { [k: string]: string } // 系统上下文(git status 等)
canUseTool: CanUseToolFn // 权限检查函数
toolUseContext: ToolUseContext // 工具执行上下文(巨大的上下文对象)
fallbackModel?: string // 降级模型名称
querySource: QuerySource // 查询来源('repl_main_thread', 'agent:', 'sdk')
maxOutputTokensOverride?: number
maxTurns?: number // 最大工具使用轮数
skipCacheWrite?: boolean
taskBudget?: { total: number } // API task_budget
deps?: QueryDeps // 依赖注入(测试用)
}
注意 deps 参数 —— 这是依赖注入的入口,允许测试替换 callModel、autocompact、microcompact 等核心依赖。
7.3 循环状态机 — State 类型
queryLoop 内部维护一个可变状态对象,在每次循环迭代之间传递:
type State = {
messages: Message[] // 当前消息列表(每轮可能变化)
toolUseContext: ToolUseContext // 工具执行上下文
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number // max_output_tokens 恢复次数
hasAttemptedReactiveCompact: boolean // 是否已尝试 reactive compact
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined
turnCount: number // 当前轮次计数
transition: Continue | undefined // 上一次迭代为什么 continue
}
transition 字段记录上一轮为什么继续循环,这对调试和测试非常有价值:
// 所有可能的 continue 原因
type Continue =
| { reason: 'next_turn' } // 正常:有 tool_use,继续
| { reason: 'reactive_compact_retry' } // prompt-too-long 后压缩重试
| { reason: 'collapse_drain_retry'; committed: number }
| { reason: 'max_output_tokens_recovery'; attempt: number }
| { reason: 'max_output_tokens_escalate' }
| { reason: 'stop_hook_blocking' }
| { reason: 'token_budget_continuation' }
7.4 while(true) 循环的完整流程
这是整个文件的核心。每次迭代包含以下阶段:
┌─────────────────────────────────────────────────────────────┐
│ queryLoop: while(true) 一次迭代 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ① 解构 state → 获取本轮参数 │
│ │ │
│ ② 上下文预处理 │
│ ├── applyToolResultBudget() → 裁剪过大的工具结果 │
│ ├── snipCompactIfNeeded() → 历史修剪 │
│ ├── microcompact() → 微压缩 │
│ ├── applyCollapsesIfNeeded() → 上下文折叠 │
│ └── autocompact() → 自动压缩(可能触发完整压缩) │
│ │ │
│ ③ 阻塞检查 — token 是否超限 │
│ │ │
│ ④ callModel() — 调用 Claude API │
│ ├── for await (message of callModel(...)) │
│ ├── 收集 assistantMessages[] │
│ ├── 检测 tool_use 块 → toolUseBlocks[] │
│ ├── 流式工具执行(StreamingToolExecutor) │
│ └── 处理 FallbackTriggeredError(模型降级) │
│ │ │
│ ⑤ 后处理 │
│ ├── executePostSamplingHooks() │
│ ├── 处理 abort(用户中断) │
│ └── yield pendingToolUseSummary │
│ │ │
│ ⑥ 分支判断 │
│ ├── needsFollowUp === false → 尝试退出 │
│ │ ├── prompt-too-long 恢复 │
│ │ ├── max_output_tokens 恢复 │
│ │ ├── stop hooks 检查 │
│ │ ├── token budget 检查 │
│ │ └── return { reason: 'completed' } │
│ │ │
│ └── needsFollowUp === true → 执行工具 │
│ ├── runTools() / streamingToolExecutor │
│ ├── 收集 toolResults[] │
│ ├── 注入附件(memory、skill、queued commands) │
│ ├── 检查 maxTurns 限制 │
│ └── state = next → continue │
│ │
└─────────────────────────────────────────────────────────────┘
阶段详解
① 解构状态
每次迭代开始时,从 state 中解构出本轮需要的所有变量:
let { toolUseContext } = state
const {
messages,
autoCompactTracking,
maxOutputTokensRecoveryCount,
hasAttemptedReactiveCompact,
maxOutputTokensOverride,
pendingToolUseSummary,
stopHookActive,
turnCount,
} = state
注意 toolUseContext 用 let 声明 —— 它在迭代内会被重新赋值(queryTracking 更新)。
② 上下文预处理流水线
在调用 API 之前,消息列表要经过 5 层处理:
原始消息
│
▼ applyToolResultBudget() — 大结果持久化到磁盘
│
▼ snipCompactIfNeeded() — 历史修剪(HISTORY_SNIP feature)
│
▼ microcompact() — 精细压缩(移除冗余的 tool_result)
│
▼ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
│
▼ autocompact() — 完整自动压缩(生成摘要替代历史)
│
▼ 就绪的 messagesForQuery
如果 autocompact 触发了压缩,会 yield 新的压缩边界消息:
if (compactionResult) {
const postCompactMessages = buildPostCompactMessages(compactionResult)
for (const message of postCompactMessages) {
yield message // 通知调用方:消息已被压缩
}
messagesForQuery = postCompactMessages
}
③ 阻塞限制检查
如果 token 计数超过硬限制且 auto-compact 未开启,直接返回错误:
const { isAtBlockingLimit } = calculateTokenWarningState(
tokenCountWithEstimation(messagesForQuery) - snipTokensFreed,
toolUseContext.options.mainLoopModel,
)
if (isAtBlockingLimit) {
yield createAssistantAPIErrorMessage({
content: PROMPT_TOO_LONG_ERROR_MESSAGE,
error: 'invalid_request',
})
return { reason: 'blocking_limit' }
}
④ callModel — API 调用与流式处理
这是核心中的核心:
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,
thinkingConfig: toolUseContext.options.thinkingConfig,
tools: toolUseContext.options.tools,
signal: toolUseContext.abortController.signal,
options: {
model: currentModel,
fallbackModel,
querySource,
// ... 更多选项
},
})) {
// 处理每个流式消息
if (message.type === 'assistant') {
assistantMessages.push(message)
// 检测 tool_use 块
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true // 标记:需要继续循环
}
// 流式工具执行(边收边做)
if (streamingToolExecutor) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message)
}
}
}
// yield 给调用方(除非被扣留)
if (!withheld) {
yield yieldMessage
}
}
关键细节:needsFollowUp 标志
这是决定循环是否继续的唯一信号。只有当 tool_use 块存在时才设为 true。stop_reason 不可靠(源码注释明确说明),所以不依赖它。
7.5 FallbackTriggeredError — 模型降级
当主模型不可用(过载)时,API 层抛出 FallbackTriggeredError:
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
// 切换到降级模型
currentModel = fallbackModel
attemptWithFallback = true
// 清除已收集的消息(它们属于失败的请求)
yield* yieldMissingToolResultBlocks(
assistantMessages,
'Model fallback triggered',
)
assistantMessages.length = 0
toolUseBlocks.length = 0
needsFollowUp = false
// 通知用户
yield createSystemMessage(
`Switched to ${renderModelName(innerError.fallbackModel)} ...`,
'warning',
)
continue // 重试内层循环
}
throw innerError
}
降级流程图:
请求主模型 → 失败(FallbackTriggeredError)
│
▼
清除已收集的 assistantMessages
│
▼
为孤立的 tool_use 生成错误 tool_result(yieldMissingToolResultBlocks)
│
▼
yield 系统消息告知用户
│
▼
切换 currentModel = fallbackModel
│
▼
attemptWithFallback = true → continue(重试内层 while)
注意 yieldMissingToolResultBlocks 的作用:如果流式传输过程中已经 yield 了部分 tool_use 块,降级时必须为每个 tool_use 生成一个对应的 tool_result(标记为错误),否则 API 协议会不一致。
7.6 工具执行与结果收集
当 needsFollowUp === true,进入工具执行阶段:
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults() // 流式执行器:收集剩余结果
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
for await (const update of toolUpdates) {
if (update.message) {
yield update.message // 工具结果消息
// 收集归一化后的 tool_result
toolResults.push(
...normalizeMessagesForAPI(
[update.message],
toolUseContext.options.tools,
).filter(_ => _.type === 'user'),
)
}
if (update.newContext) {
updatedToolUseContext = { ...update.newContext, queryTracking }
}
}
两种执行模式:
| 模式 | 触发条件 | 特点 |
|---|---|---|
StreamingToolExecutor | feature gate 开启 | 边接收边执行;并发安全的工具先跑 |
runTools | 默认 | 等 API 响应完整后再批量执行 |
findToolByName — 工具查找
工具分发通过 findToolByName 完成(定义在 Tool.ts 中):
export function findToolByName(tools: Tools, name: string): Tool | undefined {
return tools.find(t => toolMatchesName(t, name))
}
export function toolMatchesName(
tool: { name: string; aliases?: string[] },
name: string,
): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false)
}
支持别名查找 —— 工具重命名后,旧名仍可工作。
7.7 附件注入
工具执行完成后、下一轮循环前,系统注入各种附件:
// 1. 排队的命令(用户中途输入)
for await (const attachment of getAttachmentMessages(
null, updatedToolUseContext, null,
queuedCommandsSnapshot,
[...messagesForQuery, ...assistantMessages, ...toolResults],
querySource,
)) {
yield attachment
toolResults.push(attachment)
}
// 2. 记忆预取结果
if (pendingMemoryPrefetch && pendingMemoryPrefetch.settledAt !== null) {
const memoryAttachments = filterDuplicateMemoryAttachments(...)
for (const memAttachment of memoryAttachments) {
const msg = createAttachmentMessage(memAttachment)
yield msg
toolResults.push(msg)
}
}
// 3. 技能发现结果
if (skillPrefetch && pendingSkillPrefetch) {
const skillAttachments = await skillPrefetch.collectSkillDiscoveryPrefetch(...)
for (const att of skillAttachments) {
const msg = createAttachmentMessage(att)
yield msg
toolResults.push(msg)
}
}
注入时机是关键:必须在工具执行之后、下一次 API 调用之前。如果在工具执行之前注入,可能会打断 tool_use / tool_result 的配对关系。
7.8 循环退出条件
循环有多种退出路径:
// 正常退出 — 没有 tool_use,Claude 完成了回复
if (!needsFollowUp) {
// ... 各种恢复检查 ...
return { reason: 'completed' }
}
// 用户中断
if (toolUseContext.abortController.signal.aborted) {
return { reason: 'aborted_streaming' } // 或 'aborted_tools'
}
// 达到最大轮次
if (maxTurns && nextTurnCount > maxTurns) {
yield createAttachmentMessage({ type: 'max_turns_reached', ... })
return { reason: 'max_turns', turnCount: nextTurnCount }
}
// Hook 阻止继续
if (shouldPreventContinuation) {
return { reason: 'hook_stopped' }
}
// 阻塞限制
return { reason: 'blocking_limit' }
// 模型错误
return { reason: 'model_error', error }
所有退出都通过 Terminal 类型返回原因,这让调用方知道循环为什么结束。
7.9 max_output_tokens 恢复机制
当 Claude 的输出被截断(达到 max_output_tokens 限制)时,系统有两级恢复:
第一级:Escalate(升级)
如果使用的是默认的 8k token 上限,先尝试升级到 64k:
if (capEnabled && maxOutputTokensOverride === undefined) {
const next: State = {
...state,
maxOutputTokensOverride: ESCALATED_MAX_TOKENS, // 64k
transition: { reason: 'max_output_tokens_escalate' },
}
state = next
continue // 用更高限制重试
}
第二级:Recovery(接续)
如果 64k 也不够,注入恢复消息要求 Claude 继续:
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) { // 最多 3 次
const recoveryMessage = createUserMessage({
content: `Output token limit hit. Resume directly — no apology, no recap...`,
isMeta: true,
})
state = {
...state,
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
transition: { reason: 'max_output_tokens_recovery', attempt: ... },
}
continue
}
恢复消息的措辞经过精心设计:"不要道歉、不要回顾你在做什么、从中断点直接继续、把剩余工作拆成小块"。
7.10 状态转移图
┌──────────────────┐
│ while(true) │
│ 循环入口 │
└───────┬──────────┘
│
┌────────▼────────┐
│ 上下文预处理 │
│ (compact 等) │
└────────┬────────┘
│
┌────────▼────────┐
│ callModel() │
│ 流式接收响应 │
└────────┬────────┘
│
┌─────────────┼─────────────┐
│ │
┌────────▼────────┐ ┌──────────▼──────────┐
│ needsFollowUp │ │ needsFollowUp │
│ === false │ │ === true │
│ (无 tool_use) │ │ (有 tool_use) │
└────────┬────────┘ └──────────┬──────────┘
│ │
┌────────▼────────┐ ┌──────────▼──────────┐
│ 恢复检查 │ │ 执行工具 │
│ PTL/MOT/hooks │ │ runTools() │
└───┬────┬────────┘ └──────────┬──────────┘
│ │ │
┌────▼┐ ┌─▼──────┐ ┌──────────▼──────────┐
│退出 │ │continue│ │ 注入附件 │
│ │ │(恢复) │ │ (memory/skill/cmd) │
└─────┘ └────────┘ └──────────┬──────────┘
│
┌──────────▼──────────┐
│ state = next │
│ continue │
└─────────────────────┘
课后练习
练习 1:追踪一次完整循环
假设用户发送 "请帮我看看 src/index.ts 的内容",Claude 返回一个包含 tool_use (Read 工具) 的响应。追踪这次交互在 queryLoop 中的完整路径:
- 第一次迭代中
callModel()返回什么? needsFollowUp在何处被设为true?- 工具执行发生在循环的哪个位置?
- 第二次迭代的
messages数组比第一次多了什么? - 循环如何知道第二次迭代可以退出?
练习 2:降级机制分析
阅读 FallbackTriggeredError 的处理代码,回答:
- 为什么降级时需要清空
assistantMessages? yieldMissingToolResultBlocks解决什么问题?- 如果
fallbackModel为undefined,降级会怎样? - 流式降级(
streamingFallbackOccured)和异常降级有什么区别?
练习 3:设计恢复策略
max_output_tokens 恢复最多尝试 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT)。思考:
- 为什么不能无限重试?
- 恢复消息为什么要求 "不要道歉"?
- 如果你来设计这个恢复机制,你会做什么不同的设计?
练习 4:依赖注入实验
queryLoop 通过 deps 参数实现依赖注入:
const deps = params.deps ?? productionDeps()
思考如果你要为 queryLoop 写测试:
- 需要 mock 哪些
deps方法? - 如何模拟一个
prompt_too_long错误? - 如何验证
autocompact被正确触发?
本课小结
| 要点 | 内容 |
|---|---|
| 函数签名 | async function* query() — 异步生成器,yield 流式事件 |
| 核心循环 | while(true) + State 状态对象 + transition 记录 continue 原因 |
| API 调用 | deps.callModel() 返回 AsyncIterable<Message> |
| tool_use 检测 | 遍历 message.content 找 type === 'tool_use' 的块 |
| 工具分发 | findToolByName() 支持主名称和别名查找 |
| 上下文压缩 | 5 层流水线:budget → snip → micro → collapse → auto |
| 错误恢复 | 模型降级 / prompt-too-long reactive compact / max_output_tokens escalate+recovery |
| 循环退出 | Terminal 类型记录退出原因:completed / aborted / max_turns / hook_stopped / error |
下一课预告
第 08 课:消息类型与对话流转 — 我们将深入 types/message.ts 和 utils/messages.ts,理解 Claude Code 的 7 种消息类型(UserMessage、AssistantMessage、SystemMessage、AttachmentMessage、ProgressMessage、ToolUseSummaryMessage、TombstoneMessage)各自的结构、创建方式、以及它们如何在系统中流转。消息是 Agent 循环的血液,理解了它们,你就能读懂系统中任何一段数据流。