第三章:Agent 主循环——query() 的 while(true)

如果你只能读懂 Claude Code 源码中的一个函数,那应该是
queryLoop。它是整个系统的心跳,是”聊天”和”Agent”之间真正的分界线。一个普通的聊天机器人只需要”请求-响应”;而一个 Agent 需要不断地思考、行动、观察、再思考——直到任务完成。这个”不断”,在代码里就是一个while(true)。
学习目标
完成本章后,你将能够:
- 理解
QueryEngine类如何管理对话生命周期和状态 - 掌握
query()→queryLoop()的核心 while(true) 循环结构 - 理解 AsyncGenerator 流式管道如何实现实时响应和内存效率
- 掌握
messages[]数组的增长、压缩和管理模型 - 理解 API 调用封装中的重试策略、速率限制处理和流式响应解析
- 列举并解释循环终止的 5 种情况
- 理解错误恢复的三层机制:API 重试、Tool 错误处理、上下文溢出压缩
- 评价流式架构在内存效率、实时反馈、可中断性方面的工程优势
3.1 QueryEngine:对话生命周期的管理者
在深入循环之前,我们先看一个更高层的抽象——QueryEngine。它是整个查询生命周期的”拥有者”,管理着对话状态、消息历史、权限追踪和使用量统计。

3.1.1 类的核心职责
// src/QueryEngine.ts 第 184-207 行
export class QueryEngine {
private config: QueryEngineConfig
private mutableMessages: Message[]
private abortController: AbortController
private permissionDenials: SDKPermissionDenial[]
private totalUsage: NonNullableUsage
private hasHandledOrphanedPermission = false
private readFileState: FileStateCache
constructor(config: QueryEngineConfig) {
this.config = config
this.mutableMessages = config.initialMessages ?? []
this.abortController = config.abortController ?? createAbortController()
this.permissionDenials = []
this.readFileState = config.readFileCache
this.totalUsage = EMPTY_USAGE
}
// ...
}
注意这几个关键字段:
| 字段 | 作用 |
|---|---|
mutableMessages |
对话历史的可变数组,随着每一轮交互不断增长 |
abortController |
用户中断信号的传递通道 |
permissionDenials |
权限拒绝的追踪,用于 SDK 报告 |
totalUsage |
跨多轮的累计 token 使用量 |
readFileState |
文件读取缓存,避免重复读取已知文件 |
QueryEngine 的设计遵循一个引擎一个对话的原则:
One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.
3.1.2 submitMessage:一轮对话的入口
submitMessage() 是一个 AsyncGenerator——这不是偶然的选择,而是整个流式架构的起点:
// src/QueryEngine.ts 第 209-212 行
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {
这个方法负责:
- 准备系统提示:调用
fetchSystemPromptParts()构建完整的 system prompt - 处理用户输入:通过
processUserInput()解析斜杠命令、附件等 - 持久化消息:将用户消息写入 transcript(在 API 响应之前!这样即使进程被杀死,消息也不会丢失)
- 进入查询循环:调用
query()并逐个 yield 结果
其中最重要的一步是第 675 行开始的 for await 循环——它将 query() 产出的每一条消息分发到正确的处理路径:
// src/QueryEngine.ts 第 675-686 行
for await (const message of query({
messages,
systemPrompt,
userContext,
systemContext,
canUseTool: wrappedCanUseTool,
toolUseContext: processUserInputContext,
fallbackModel,
querySource: 'sdk',
maxTurns,
taskBudget,
})) {
// 根据 message.type 分发处理:
// assistant → 记录 + yield 给 SDK 调用方
// user → 追加到历史(tool_result 消息)
// system → 处理压缩边界、API 错误重试等
// ...
}
这里有一个精妙的设计:QueryEngine 对 canUseTool 做了包装,在每次权限判断时追踪拒绝记录——这让 SDK 调用方可以在最终 result 消息中看到哪些工具调用被拒绝了。
// src/QueryEngine.ts 第 244-271 行
const wrappedCanUseTool: CanUseToolFn = async (
tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision,
) => {
const result = await canUseTool(
tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision,
)
// Track denials for SDK reporting
if (result.behavior !== 'allow') {
this.permissionDenials.push({
tool_name: sdkCompatToolName(tool.name),
tool_use_id: toolUseID,
tool_input: input,
})
}
return result
}
3.2 query() 的核心结构:while(true) 循环
3.2.1 两层函数的设计
Claude Code 的主循环实际上由两个函数组成:外层 query() 和内层 queryLoop()。
// src/query.ts 第 218-239 行
export async function* query(
params: QueryParams,
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
Terminal
> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
// 只在正常返回时执行,throw/abort 时跳过
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
return terminal
}
为什么要分两层?外层 query() 负责命令生命周期管理——它追踪在循环期间消费的命令 UUID,并在正常结束时通知它们已完成。如果循环因错误抛出或用户中断而退出,这些通知不会发送——这是刻意的设计,让命令系统知道”这个命令的处理被中断了”。
yield* 语法至关重要:它将 queryLoop() 的所有产出直接转发给 query() 的调用方,同时保持双向通信通道——调用方对生成器调用 .return() 会穿透到内层,使两个生成器同时关闭。
3.2.2 State:跨迭代的可变状态
进入 queryLoop() 之前,我们先看它维护的状态结构——这是理解循环的关键:
// src/query.ts 第 204-217 行
type State = {
messages: Message[] // 当前轮次的消息数组
toolUseContext: ToolUseContext // 工具执行上下文
autoCompactTracking: AutoCompactTrackingState | undefined // 自动压缩追踪
maxOutputTokensRecoveryCount: number // 输出 token 超限恢复计数
hasAttemptedReactiveCompact: boolean // 是否已尝试过反应式压缩
maxOutputTokensOverride: number | undefined // 输出 token 限制覆盖
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined // Stop Hook 是否激活
turnCount: number // 当前轮次计数
transition: Continue | undefined // 上一次迭代为什么继续
}
transition 字段特别值得关注。它记录了”上一次迭代为什么选择 continue 而不是 return“,可能的值包括:
next_turn:正常的下一轮工具调用reactive_compact_retry:反应式压缩后重试max_output_tokens_recovery:输出 token 超限后注入恢复消息max_output_tokens_escalate:从 8K 默认升级到 64Kcollapse_drain_retry:上下文折叠后重试stop_hook_blocking:Stop Hook 返回了阻止错误token_budget_continuation:token 预算允许继续
每次循环继续时,整个 State 被替换为一个新对象,而不是逐个字段赋值——这让”继续点”的意图一目了然,也让测试可以轻松断言”在什么条件下选择了什么路径”。
3.2.3 while(true) 的完整骨架
下面是去除细节后的循环骨架,每个阶段用注释标出:
// src/query.ts 第 307-1728 行(骨架)
while (true) {
// ===== 阶段 1:准备消息 =====
let { toolUseContext } = state
const { messages, turnCount, ... } = state
yield { type: 'stream_request_start' } // 通知 UI:新一轮请求开始
// 消息预处理管线:
let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]
messagesForQuery = await applyToolResultBudget(messagesForQuery, ...) // 工具结果裁剪
messagesForQuery = snipCompactIfNeeded(messagesForQuery) // 历史裁剪
messagesForQuery = await microcompact(messagesForQuery, ...) // 微压缩
messagesForQuery = await contextCollapse.applyCollapsesIfNeeded(...) // 上下文折叠
const { compactionResult } = await autocompact(messagesForQuery, ...) // 自动压缩
// ===== 阶段 2:调用 API(流式) =====
const assistantMessages: AssistantMessage[] = []
const toolResults: (UserMessage | AttachmentMessage)[] = []
const toolUseBlocks: ToolUseBlock[] = []
let needsFollowUp = false
for await (const message of deps.callModel({ messages, systemPrompt, tools, ... })) {
yield message // 流式转发给 UI
if (message.type === 'assistant') {
assistantMessages.push(message)
// 检测 tool_use 块
if (hasToolUseBlocks(message)) {
needsFollowUp = true
// 流式工具执行:模型还在生成时就开始执行工具
streamingToolExecutor.addTool(toolBlock, message)
}
}
}
// ===== 阶段 3:中断检查 =====
if (toolUseContext.abortController.signal.aborted) {
yield createUserInterruptionMessage({ toolUse: false })
return { reason: 'aborted_streaming' }
}
// ===== 阶段 4:终止判定 =====
if (!needsFollowUp) {
// 没有工具调用 → 检查各种恢复/终止条件
// ... prompt-too-long 恢复、max_output_tokens 恢复、stop hooks ...
return { reason: 'completed' }
}
// ===== 阶段 5:执行工具 =====
for await (const update of toolUpdates) {
yield update.message
toolResults.push(...)
}
// ===== 阶段 6:中断检查(工具执行后) =====
if (toolUseContext.abortController.signal.aborted) {
return { reason: 'aborted_tools' }
}
// ===== 阶段 7:附件注入 =====
for await (const attachment of getAttachmentMessages(...)) {
yield attachment
toolResults.push(attachment)
}
// ===== 阶段 8:轮次限制检查 =====
if (maxTurns && nextTurnCount > maxTurns) {
return { reason: 'max_turns', turnCount: nextTurnCount }
}
// ===== 阶段 9:准备下一轮 =====
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
turnCount: nextTurnCount,
transition: { reason: 'next_turn' },
// ...
}
} // while (true)

让我们逐一深入每个阶段。
3.3 AsyncGenerator 流式管道
3.3.1 三层生成器架构
Claude Code 的流式管道由三层 AsyncGenerator 嵌套而成:
QueryEngine.submitMessage() ← 最外层,面向 SDK/UI
└─ query() ← 中间层,命令生命周期
└─ queryLoop() ← 核心循环
├─ deps.callModel() ← API 流式响应
├─ runTools() ← 工具执行结果
└─ getAttachmentMessages() ← 附件注入
每一层都是 AsyncGenerator,它们通过 yield 和 yield* 将数据向上传递,形成一条从 API 到 UI 的流式管道。
// 三层之间的连接方式
// 层 1: QueryEngine.submitMessage() 消费 query() 的产出
for await (const message of query({ ... })) {
switch (message.type) {
case 'assistant': yield* normalizeMessage(message); break;
case 'stream_event': if (includePartialMessages) yield ...; break;
// ...
}
}
// 层 2: query() 透传 queryLoop() 的所有产出
const terminal = yield* queryLoop(params, consumedCommandUuids)
// 层 3: queryLoop() 产出 API 流中的每条消息
for await (const message of deps.callModel({ ... })) {
yield message // 直接向上传递
}
3.3.2 yield 的类型系统
注意 queryLoop() 的返回类型签名:
// src/query.ts 第 241-251 行
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[],
): AsyncGenerator<
| StreamEvent // API 流事件(content_block_delta 等)
| RequestStartEvent // 每轮请求开始信号
| Message // 完整消息(assistant/user/system/attachment/progress)
| TombstoneMessage // 消息删除标记
| ToolUseSummaryMessage, // 工具使用摘要
Terminal // return 值:终止原因
> {
产出类型(yield 类型)是一个联合类型,包含了循环中所有可能向外传递的数据。返回类型 Terminal 是一个对象,描述了循环终止的原因。
这种设计让 TypeScript 编译器能够精确检查:生成器的消费方必须处理所有可能的产出类型,而 return 值也有明确的结构。
3.3.3 流式工具执行:StreamingToolExecutor
传统的 Agent 循环是串行的:等 API 完成 → 解析工具调用 → 执行工具 → 下一轮。Claude Code 引入了一个关键优化——流式工具执行:
// src/query.ts 第 560-568 行
const useStreamingToolExecution = config.gates.streamingToolExecution
let streamingToolExecutor = useStreamingToolExecution
? new StreamingToolExecutor(
toolUseContext.options.tools,
canUseTool,
toolUseContext,
)
: null
当 API 流中出现一个完整的 tool_use 块时,不等待 API 完成,立即开始执行:
// src/query.ts 第 838-844 行(在 API 流式循环内部)
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message)
}
}
同时,已完成的工具结果也在流式阶段就被收集:
// src/query.ts 第 848-862 行
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
for (const result of streamingToolExecutor.getCompletedResults()) {
if (result.message) {
yield result.message
toolResults.push(...)
}
}
}
这意味着:当 Claude 在说”让我先读取 A 文件,然后再看 B 文件”的时候,A 文件的读取可能已经完成了。这对用户体验的影响是巨大的——多工具调用的总延迟不再是各工具延迟之和,而接近最慢那个工具的延迟。
工具编排层(toolOrchestration.ts)还做了更细粒度的优化——将工具调用分为并发安全和非并发安全两组:
// src/services/tools/toolOrchestration.ts 第 19-80 行(简化)
export async function* runTools(
toolUseMessages: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
let currentContext = toolUseContext
for (const { isConcurrencySafe, blocks } of partitionToolCalls(
toolUseMessages, currentContext,
)) {
if (isConcurrencySafe) {
// 读取类工具(Read、Grep 等)并发执行
for await (const update of runToolsConcurrently(blocks, ...)) {
yield { message: update.message, newContext: currentContext }
}
} else {
// 写入类工具串行执行,保证顺序
for await (const update of runToolsSerially(blocks, ...)) {
yield { message: update.message, newContext: currentContext }
}
}
}
}
3.4 消息累积模型:messages[] 的增长与管理
3.4.1 消息的增长
每一次循环迭代,messages 数组都在增长。在阶段 9(准备下一轮)中,新消息被追加:
// src/query.ts 第 1715-1728 行
const next: State = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
turnCount: nextTurnCount,
transition: { reason: 'next_turn' },
// ...
}
state = next
一轮典型的交互会增加:
- 1 条
AssistantMessage(模型的响应,包含 thinking + text + tool_use 块) - N 条
UserMessage(每个 tool_use 对应一个 tool_result) - M 条
AttachmentMessage(文件变更通知、记忆注入等)
在一个复杂任务中(比如”重构这个模块”),模型可能调用 20+ 次工具,每次读取或写入文件都会产生消息。经过 10 轮迭代后,消息数组可能包含 200+ 条消息,总 token 数轻松突破 100K。
3.4.2 上下文压缩管线
这就是为什么循环的阶段 1 有一条复杂的压缩管线。它由五个步骤组成,按顺序执行:
原始消息
│
├── 1. getMessagesAfterCompactBoundary() ← 跳过已压缩的历史
│
├── 2. applyToolResultBudget() ← 大工具结果裁剪
│
├── 3. snipCompactIfNeeded() ← 历史修剪
│
├── 4. microcompactMessages() ← 微压缩(移除冗余的工具结果)
│
├── 5. contextCollapse.applyCollapsesIfNeeded() ← 上下文折叠
│
└── 6. autoCompactIfNeeded() ← 全量压缩(超过阈值时触发)
全量自动压缩是最重要的一环。它有一个 token 阈值,当消息总量接近模型上下文窗口时触发:
// src/services/compact/autoCompact.ts 第 72-76 行
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
const autocompactThreshold =
effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS // 13,000 token 缓冲
return autocompactThreshold
}
压缩成功后,整个消息历史被替换为一条压缩摘要 + 被保留的尾部消息:
// src/query.ts 第 528-535 行
if (compactionResult) {
const postCompactMessages = buildPostCompactMessages(compactionResult)
for (const message of postCompactMessages) {
yield message // 通知 UI 发生了压缩
}
messagesForQuery = postCompactMessages
}
3.4.3 压缩的连续失败保护
自动压缩不一定总能成功——比如压缩 API 自身可能失败,或者压缩后仍然超过阈值。Claude Code 对此有一个断路器(circuit breaker):
// src/services/compact/autoCompact.ts 第 67-70 行
// Stop trying autocompact after this many consecutive failures.
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
三次连续失败后停止重试——这不是凭感觉定的,注释引用了真实的线上数据:曾有 1,279 个会话遭遇 50 次以上的连续失败,全球每天浪费了 25 万次 API 调用。
3.5 API 调用封装
3.5.1 依赖注入:QueryDeps
循环不直接调用 API,而是通过依赖注入:
// src/query/deps.ts 第 21-40 行
export type QueryDeps = {
callModel: typeof queryModelWithStreaming // API 调用
microcompact: typeof microcompactMessages // 微压缩
autocompact: typeof autoCompactIfNeeded // 自动压缩
uuid: () => string // UUID 生成
}
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming,
microcompact: microcompactMessages,
autocompact: autoCompactIfNeeded,
uuid: randomUUID,
}
}
这个设计让测试可以替换任何依赖——注入一个假的 callModel 就能测试循环逻辑而不需要真正调用 API。注释说得很直白:
Passing a
depsoverride into QueryParams lets tests inject fakes directly instead of spyOn-per-module — the most common mocks (callModel, autocompact) are each spied in 6-8 test files today with module-import-and-spy boilerplate.
3.5.2 queryModelWithStreaming:流式 API 调用
deps.callModel 在生产环境中指向 queryModelWithStreaming:
// src/services/api/claude.ts 第 752-780 行
export async function* queryModelWithStreaming({
messages, systemPrompt, thinkingConfig, tools, signal, options,
}: { ... }): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
return yield* withStreamingVCR(messages, async function* () {
yield* queryModel(
messages, systemPrompt, thinkingConfig, tools, signal, options,
)
})
}
这里有一层 VCR(录像/回放)包装,用于测试时录制和回放 API 交互——但核心逻辑在 queryModel() 中。
queryModel() 内部做了大量的工作:
- 构建工具 Schema:将内部 Tool 定义转换为 API 格式
- 消息归一化:
normalizeMessagesForAPI()将内部 Message 类型转为 API 格式 - Prompt 缓存:在关键位置添加
cache_control标记 - 流式解析:将 API 的 SSE 流解析为结构化消息
- 模型回退:streaming 失败时回退到非流式请求
3.5.3 重试策略:withRetry
API 调用的重试逻辑在 withRetry 中,它本身也是一个 AsyncGenerator——这让它可以在等待重试时向上通知 UI:
// src/services/api/withRetry.ts 第 170-178 行
export async function* withRetry<T>(
getClient: () => Promise<Anthropic>,
operation: (client: Anthropic, attempt: number, context: RetryContext) => Promise<T>,
options: RetryOptions,
): AsyncGenerator<SystemAPIErrorMessage, T> {
返回类型 AsyncGenerator<SystemAPIErrorMessage, T> 含义是:yield 出重试状态消息(显示给用户),最终 return API 响应。
重试策略包含多个层次:
指数退避 + 抖动:
// src/services/api/withRetry.ts 第 530-548 行
export function getRetryDelay(
attempt: number,
retryAfterHeader?: string | null,
maxDelayMs = 32000,
): number {
if (retryAfterHeader) {
const seconds = parseInt(retryAfterHeader, 10)
if (!isNaN(seconds)) return seconds * 1000
}
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1), // 500ms, 1s, 2s, 4s, 8s, 16s, 32s
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay // 25% 随机抖动
return baseDelay + jitter
}
529 过载保护:连续 3 次 529 错误后触发模型回退:
// src/services/api/withRetry.ts 第 327-364 行
if (is529Error(error)) {
consecutive529Errors++
if (consecutive529Errors >= MAX_529_RETRIES) {
if (options.fallbackModel) {
throw new FallbackTriggeredError(options.model, options.fallbackModel)
}
}
}
持久重试模式(无人值守会话):当环境变量 CLAUDE_CODE_UNATTENDED_RETRY 开启时,429/529 会无限重试,同时发送心跳保持会话活跃:
// src/services/api/withRetry.ts 第 486-512 行
if (persistent) {
let remaining = delayMs
while (remaining > 0) {
if (options.signal?.aborted) throw new APIUserAbortError()
yield createSystemAPIErrorMessage(error, remaining, reportedAttempt, maxRetries)
const chunk = Math.min(remaining, HEARTBEAT_INTERVAL_MS) // 30 秒心跳
await sleep(chunk, options.signal, { abortError })
remaining -= chunk
}
if (attempt >= maxRetries) attempt = maxRetries // 永不终止
}
上下文溢出自动调整:当 API 返回 input_tokens + max_tokens > context_limit 错误时,自动减小 max_tokens:
// src/services/api/withRetry.ts 第 389-427 行
const overflowData = parseMaxTokensContextOverflowError(error)
if (overflowData) {
const { inputTokens, contextLimit } = overflowData
const safetyBuffer = 1000
const availableContext = Math.max(0, contextLimit - inputTokens - safetyBuffer)
retryContext.maxTokensOverride = Math.max(FLOOR_OUTPUT_TOKENS, availableContext)
continue // 用新的 max_tokens 重试
}
非前台请求不重试 529:背景任务(摘要、分类器等)遇到 529 直接失败,避免在服务过载时雪上加霜:
// src/services/api/withRetry.ts 第 62-89 行
const FOREGROUND_529_RETRY_SOURCES = new Set<QuerySource>([
'repl_main_thread', 'sdk', 'agent:custom', 'compact', ...
])
function shouldRetry529(querySource: QuerySource | undefined): boolean {
return querySource === undefined || FOREGROUND_529_RETRY_SOURCES.has(querySource)
}
3.5.4 模型回退:FallbackTriggeredError
当 withRetry 抛出 FallbackTriggeredError 时,queryLoop 中的内层 while(attemptWithFallback) 循环会捕获它:
// src/query.ts 第 893-950 行
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true
// 清理旧的未完成消息
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
assistantMessages.length = 0
toolResults.length = 0
// 丢弃流式工具执行器的待处理结果
if (streamingToolExecutor) {
streamingToolExecutor.discard()
streamingToolExecutor = new StreamingToolExecutor(...)
}
// 通知用户
yield createSystemMessage(
`Switched to ${renderModelName(innerError.fallbackModel)} due to high demand`,
'warning',
)
continue // 用新模型重试
}
throw innerError
}
这个回退是透明的——用户只会看到一条切换通知,循环继续正常运行。
3.6 终止条件的 5 种情况
循环的 return 语句散布在代码各处。整理后,有 5 大类终止原因:
教学口径说明:本章把终止条件归纳为 5 种(正常完成 / 用户中断 / 最大轮次 / 致命错误 / Hook 阻止),是为了便于初学者建立心智模型。源码内部的
reason字段实际上有 7+ 种取值(completed / aborted_streaming / aborted_tools / max_turns / model_error / image_error / prompt_too_long / blocking_limit / stop_hook_prevented / hook_stopped 等),细分原因详见 3.6.6 节的”终止原因一览”。SessionEndHook 还有独立的EXIT_REASONS = 5(clear / resume / logout / prompt_input_exit / other),属于会话级别终止,与单轮 query 的终止 reason 不同。完整数字口径详见docs/canonical-numbers.md。

3.6.1 正常完成(end_turn)
这是最常见的终止:模型没有返回 tool_use 块,说明它认为任务已完成。
// src/query.ts 第 1062 行附近
if (!needsFollowUp) {
// ... 各种恢复检查 ...
// 如果没有需要恢复的情况:
return { reason: 'completed' }
}
在返回之前,还会经过 Stop Hook 检查——Hook 可以阻止终止,强制循环继续(比如验证 hook 发现了遗留问题):
// src/query.ts 第 1267-1306 行
const stopHookResult = yield* handleStopHooks(
messagesForQuery, assistantMessages,
systemPrompt, userContext, systemContext,
toolUseContext, querySource, stopHookActive,
)
if (stopHookResult.preventContinuation) {
return { reason: 'stop_hook_prevented' }
}
if (stopHookResult.blockingErrors.length > 0) {
// 注入阻止消息,继续循环
state = {
messages: [...messagesForQuery, ...assistantMessages, ...stopHookResult.blockingErrors],
stopHookActive: true,
transition: { reason: 'stop_hook_blocking' },
// ...
}
continue
}
3.6.2 用户中断(abort)
用户按 Ctrl+C 或通过 SDK 调用 interrupt() 时触发。循环在两个检查点检测中断:
API 流式阶段中断:
// src/query.ts 第 1015-1052 行
if (toolUseContext.abortController.signal.aborted) {
if (streamingToolExecutor) {
// 消费剩余结果,生成合成的 tool_result
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
} else {
yield* yieldMissingToolResultBlocks(assistantMessages, 'Interrupted by user')
}
yield createUserInterruptionMessage({ toolUse: false })
return { reason: 'aborted_streaming' }
}
工具执行阶段中断:
// src/query.ts 第 1484-1515 行
if (toolUseContext.abortController.signal.aborted) {
if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: true })
}
return { reason: 'aborted_tools' }
}
注意一个细节:如果中断原因是 'interrupt'(提交新消息中断),则不发送 [Request interrupted by user] 消息——因为紧接着就会有新的用户消息作为上下文。
还有一个关键设计:中断时必须为所有未完成的 tool_use 块补充 tool_result。API 要求每个 tool_use 都有对应的 tool_result,否则下一轮请求会失败。yieldMissingToolResultBlocks 负责这件事:
// src/query.ts 第 123-149 行
function* yieldMissingToolResultBlocks(
assistantMessages: AssistantMessage[],
errorMessage: string,
) {
for (const assistantMessage of assistantMessages) {
const toolUseBlocks = assistantMessage.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
for (const toolUse of toolUseBlocks) {
yield createUserMessage({
content: [{
type: 'tool_result',
content: errorMessage,
is_error: true,
tool_use_id: toolUse.id,
}],
})
}
}
}
3.6.3 最大轮次限制
SDK 调用方可以设置 maxTurns 限制 Agent 的自主运行轮次:
// src/query.ts 第 1705-1712 行
if (maxTurns && nextTurnCount > maxTurns) {
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
turnCount: nextTurnCount,
})
return { reason: 'max_turns', turnCount: nextTurnCount }
}
QueryEngine 捕获这个附件消息后会生成一个 error_max_turns 结果:
// src/QueryEngine.ts 第 851-873 行
if (message.attachment.type === 'max_turns_reached') {
yield {
type: 'result',
subtype: 'error_max_turns',
is_error: true,
num_turns: message.attachment.turnCount,
errors: [`Reached maximum number of turns (${message.attachment.maxTurns})`],
// ...
}
return
}
3.6.4 致命错误
当 API 调用抛出不可重试的错误时(已用完所有重试次数,或者是不可恢复的错误类型):
// src/query.ts 第 955-997 行
} catch (error) {
logError(error)
// 图片相关错误:直接终止
if (error instanceof ImageSizeError || error instanceof ImageResizeError) {
yield createAssistantAPIErrorMessage({ content: error.message })
return { reason: 'image_error' }
}
// 其他错误:补充缺失的 tool_result,上报错误
yield* yieldMissingToolResultBlocks(assistantMessages, errorMessage)
yield createAssistantAPIErrorMessage({ content: errorMessage })
return { reason: 'model_error', error }
}
3.6.5 权限拒绝与 Hook 阻止
当 Stop Hook 明确表示”不允许继续”时:
// src/query.ts 第 1278-1280 行
if (stopHookResult.preventContinuation) {
return { reason: 'stop_hook_prevented' }
}
另外,工具执行中如果 hook 返回了 hook_stopped_continuation 附件:
// src/query.ts 第 1519-1521 行
if (shouldPreventContinuation) {
return { reason: 'hook_stopped' }
}
3.6.6 终止原因一览
| 终止原因 | 触发条件 | 对用户的含义 |
|---|---|---|
completed |
模型返回纯文本,无 tool_use | 任务正常完成 |
aborted_streaming |
流式阶段用户 Ctrl+C | 用户主动中断 |
aborted_tools |
工具执行阶段用户 Ctrl+C | 用户主动中断 |
max_turns |
超过 maxTurns 限制 | 安全阀,防止无限运行 |
model_error |
API 不可恢复的错误 | 需要检查 API 状态 |
image_error |
图片太大或不支持 | 需要调整图片 |
prompt_too_long |
上下文超限且压缩失败 | 需要手动压缩 |
blocking_limit |
手动压缩模式下 token 超限 | 需要运行 /compact |
stop_hook_prevented |
Stop Hook 阻止继续 | Hook 逻辑决定终止 |
hook_stopped |
工具 hook 阻止继续 | Hook 逻辑决定终止 |
3.7 错误恢复机制
Claude Code 的错误恢复不是简单的”重试”,而是一个分层的、有状态的恢复系统。
3.7.1 API 错误重试(withRetry 层)
这在 3.5.3 节已详细讨论。核心策略:
- 指数退避 + 25% 抖动
- 429/529 有专门的处理路径
- 持久模式下无限重试(带心跳)
- 上下文溢出自动调整 max_tokens
3.7.2 prompt-too-long 的多级恢复
当 API 返回”prompt too long”错误时,恢复是分阶段的。错误在流式循环中被暂时扣留(withheld),不立即向上传递——给恢复逻辑一个尝试修复的机会:
// src/query.ts 第 799-825 行(流式循环内)
let withheld = false
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, ...)) {
withheld = true
}
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (!withheld) {
yield yieldMessage // 只有不被扣留的消息才 yield
}
恢复的优先级:
第一级:上下文折叠排空
// src/query.ts 第 1086-1117 行
if (feature('CONTEXT_COLLAPSE') && contextCollapse
&& state.transition?.reason !== 'collapse_drain_retry') {
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) {
state = { ...state, messages: drained.messages,
transition: { reason: 'collapse_drain_retry', committed: drained.committed } }
continue // 排空折叠后重试
}
}
第二级:反应式压缩
// src/query.ts 第 1119-1166 行
if ((isWithheld413 || isWithheldMedia) && reactiveCompact) {
const compacted = await reactiveCompact.tryReactiveCompact({
hasAttempted: hasAttemptedReactiveCompact,
querySource,
messages: messagesForQuery,
// ...
})
if (compacted) {
const postCompactMessages = buildPostCompactMessages(compacted)
for (const msg of postCompactMessages) yield msg
state = { ...state, messages: postCompactMessages,
hasAttemptedReactiveCompact: true,
transition: { reason: 'reactive_compact_retry' } }
continue // 压缩后重试
}
// 压缩也失败了:释放扣留的错误消息
yield lastMessage
return { reason: 'prompt_too_long' }
}
注意 hasAttemptedReactiveCompact 标志——它防止无限压缩循环。如果压缩后仍然超限,直接报错。
3.7.3 max_output_tokens 恢复
当模型输出被截断(达到 max_output_tokens 限制)时,恢复分两步:
第一步:升级 token 限制
// src/query.ts 第 1195-1221 行
if (maxOutputTokensOverride === undefined
&& !process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
// 从默认 8K 升级到 64K
state = { ...state,
maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
transition: { reason: 'max_output_tokens_escalate' } }
continue
}
第二步:注入恢复消息
如果升级后仍然截断,注入一条用户消息让模型继续——最多 3 次:
// src/query.ts 第 1223-1252 行
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,
})
state = { ...state,
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
transition: { reason: 'max_output_tokens_recovery',
attempt: maxOutputTokensRecoveryCount + 1 } }
continue
}
// 3 次恢复都用完了:释放扣留的错误消息
yield lastMessage
MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 和提示文本本身都经过精心设计。提示要求模型”不要道歉、不要复述、直接继续”——因为每一轮恢复都消耗宝贵的上下文空间。
3.7.4 工具执行失败处理
工具执行失败不会终止循环——错误信息作为 is_error: true 的 tool_result 返回给模型,让模型自己决定如何处理:
// 工具执行层(toolExecution.ts 中)将错误转为 tool_result
{
type: 'tool_result',
content: 'Error: file not found: /foo/bar.ts',
is_error: true,
tool_use_id: toolUse.id,
}
这是 Agent 模式的核心设计理念之一:错误不是异常,而是信息。模型看到文件找不到,可能会搜索正确的路径;看到权限拒绝,可能会选择另一种方法。循环不关心工具成功还是失败——它只关心模型是否返回了新的 tool_use 块。
3.7.5 错误恢复决策图
API 错误
├── 可重试?(withRetry 判断)
│ ├── 是 → 指数退避 → 重试
│ └── 否 → CannotRetryError → return { reason: 'model_error' }
│
├── 529 过载?
│ ├── 非前台请求 → 立即失败
│ ├── 连续 3 次 → 有 fallback 模型?
│ │ ├── 是 → FallbackTriggeredError → 切换模型重试
│ │ └── 否 → 报错
│ └── 未满 3 次 → 正常重试
│
├── prompt-too-long?
│ ├── 上下文折叠可排空 → 排空后 continue
│ ├── 反应式压缩未尝试 → 压缩后 continue
│ └── 都试过了 → return { reason: 'prompt_too_long' }
│
└── max_output_tokens?
├── 未升级过 → 升级到 64K → continue
├── 恢复次数 < 3 → 注入恢复消息 → continue
└── 恢复次数 >= 3 → yield 错误 → return
3.8 流式架构的工程优势
3.8.1 内存效率
传统的”收集全部结果再返回”模式需要在内存中保存整个响应。流式架构通过 AsyncGenerator,每条消息产出后就可以被消费方处理并释放引用:
// QueryEngine.submitMessage() 中的消费模式
for await (const message of query({ ... })) {
// message 被处理后,上一次迭代的 message 可以被 GC
switch (message.type) {
case 'assistant': yield* normalizeMessage(message); break;
// ...
}
}
同样,API 流的消费也是逐块的——一条 10000 token 的响应不需要等全部接收完毕再处理,而是每个 content block 完成时就立即处理。
压缩后的内存释放也值得关注。QueryEngine 在收到 compact_boundary 消息后,会截断 mutableMessages:
// src/QueryEngine.ts 第 926-933 行
if (message.subtype === 'compact_boundary' && message.compactMetadata) {
const mutableBoundaryIdx = this.mutableMessages.length - 1
if (mutableBoundaryIdx > 0) {
this.mutableMessages.splice(0, mutableBoundaryIdx) // 释放旧消息
}
}
3.8.2 实时反馈
流式架构让 UI 可以在模型思考的同时展示进度。循环中有多个 yield 点:
yield { type: 'stream_request_start' } // "正在发送请求"
yield message (type: 'stream_event') // 打字机效果的文字流
yield message (type: 'assistant') // 完整的助手消息块
yield update.message (type: 'progress') // 工具执行进度
yield attachment // 文件变更通知
特别是 stream_request_start 事件——它在 API 调用之前就被发送,让 UI 可以立即显示”正在思考”的状态,而不需要等待网络往返。
3.8.3 可中断性
AbortController 贯穿了整个调用链:
QueryEngine.interrupt()
→ this.abortController.abort()
→ signal 传递到 API 调用 → fetch abort → 流停止
→ signal 传递到工具执行 → 子进程终止
→ signal 传递到 withRetry → sleep 中断
循环在关键节点检查 signal.aborted——API 流结束后、工具执行结束后——确保用户中断可以在合理的时间内被响应。
3.8.4 背压(Backpressure)控制
AsyncGenerator 的一个隐含优势是自然的背压:如果消费方没有调用 next(),生产方就不会继续执行。这意味着如果 UI 渲染速度跟不上数据产生速度,循环会自动放慢——不需要额外的缓冲区管理。
// 消费方的 for-await-of 自动实现背压
for await (const message of query({ ... })) {
// 如果这行代码处理很慢,query() 内部的 yield 会阻塞
// → API 流的消费也会阻塞
// → 没有无限制的内存增长
await heavyProcessing(message)
}
3.9 一次完整交互的时序分析
让我们用一个具体例子——用户输入”修改 config.ts 中的端口号为 8080”——来追踪一次完整的循环:
时间轴 →
T0 用户输入 "修改 config.ts 中的端口号为 8080"
T1 QueryEngine.submitMessage() 开始
T2 processUserInput() 处理输入
T3 recordTranscript() 持久化用户消息
T4 query() → queryLoop() 开始
=== 第 1 轮迭代 ===
T5 yield { type: 'stream_request_start' }
T6 消息预处理(微压缩、自动压缩检查 → 不需要)
T7 deps.callModel() 开始流式调用 API
T8 Claude 思考:"我需要先读取 config.ts..."
T9 yield stream_event (thinking 块)
T10 yield assistant message: "我来看一下 config.ts 的内容"
T11 yield assistant message: tool_use { name: 'Read', path: 'config.ts' }
T11.5 StreamingToolExecutor.addTool() → 立即开始读取文件!
T12 API 流结束
T13 收集 streamingToolExecutor 结果
T14 yield user message: tool_result { content: "文件内容..." }
T15 needsFollowUp = true → continue
=== 第 2 轮迭代 ===
T16 yield { type: 'stream_request_start' }
T17 消息预处理(消息数组已包含第 1 轮的所有消息)
T18 deps.callModel() 流式调用
T19 Claude 分析文件,决定修改
T20 yield assistant message: "我看到端口定义在第 42 行..."
T21 yield assistant message: tool_use { name: 'Edit', path: 'config.ts', ... }
T22 API 流结束
T23 工具执行:Edit 工具修改文件
T24 yield user message: tool_result { content: "文件已修改" }
T25 yield attachment: { type: 'edited_text_file', ... } // 文件变更通知
T26 needsFollowUp = true → continue
=== 第 3 轮迭代 ===
T27 yield { type: 'stream_request_start' }
T28 deps.callModel() 流式调用
T29 Claude: "已将端口号修改为 8080,修改了 config.ts 的第 42 行。"
T30 yield assistant message: 纯文本(无 tool_use)
T31 API 流结束
T32 needsFollowUp = false
T33 handleStopHooks() → 无阻止
T34 return { reason: 'completed' }
T35 QueryEngine 收到 return,yield result 消息
T36 完成
整个过程 3 轮迭代,用户从 T9 开始就能看到 Claude 的思考过程——这是流式架构带来的体验优势。
3.10 动手实践
实践 1:构建最小 Agent 循环
用 TypeScript 实现一个简化版的 Agent 循环,包含核心的 while(true) 结构:
// mini-agent-loop.ts
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
// 定义工具
const tools: Anthropic.Tool[] = [
{
name: 'read_file',
description: 'Read a file from disk',
input_schema: {
type: 'object' as const,
properties: { path: { type: 'string', description: 'File path' } },
required: ['path'],
},
},
{
name: 'write_file',
description: 'Write content to a file',
input_schema: {
type: 'object' as const,
properties: {
path: { type: 'string', description: 'File path' },
content: { type: 'string', description: 'Content to write' },
},
required: ['path', 'content'],
},
},
];
// 工具执行
async function executeTool(
name: string,
input: Record<string, string>
): Promise<string> {
const fs = await import('fs/promises');
switch (name) {
case 'read_file':
return await fs.readFile(input.path, 'utf-8');
case 'write_file':
await fs.writeFile(input.path, input.content);
return 'File written successfully';
default:
return `Unknown tool: ${name}`;
}
}
// 核心循环——模仿 Claude Code 的 queryLoop()
async function* agentLoop(
userMessage: string
): AsyncGenerator<string, { reason: string }> {
const messages: Anthropic.MessageParam[] = [
{ role: 'user', content: userMessage },
];
const MAX_TURNS = 10;
let turnCount = 0;
while (true) {
turnCount++;
yield `\n--- 第 ${turnCount} 轮 ---\n`;
// 阶段 1: 调用 API
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
tools,
messages,
});
// 阶段 2: 处理响应
let hasToolUse = false;
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type === 'text') {
yield `Claude: ${block.text}\n`;
}
if (block.type === 'tool_use') {
hasToolUse = true;
yield `[调用工具] ${block.name}(${JSON.stringify(block.input)})\n`;
try {
const result = await executeTool(
block.name,
block.input as Record<string, string>
);
yield `[工具结果] ${result.substring(0, 200)}...\n`;
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: result,
});
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
yield `[工具错误] ${errMsg}\n`;
toolResults.push({
type: 'tool_result',
tool_use_id: block.id,
content: errMsg,
is_error: true,
});
}
}
}
// 阶段 3: 终止判定
if (!hasToolUse) {
return { reason: 'completed' };
}
if (turnCount >= MAX_TURNS) {
return { reason: 'max_turns' };
}
// 阶段 4: 累积消息,继续循环
messages.push({ role: 'assistant', content: response.content });
messages.push({ role: 'user', content: toolResults });
}
}
// 使用
async function main() {
const generator = agentLoop('读取 package.json 文件,告诉我项目名称和版本号');
for await (const output of generator) {
process.stdout.write(output);
}
}
main().catch(console.error);
实践 2:添加中断支持
扩展上面的循环,添加 AbortController 支持:
async function* agentLoopWithAbort(
userMessage: string,
signal: AbortSignal
): AsyncGenerator<string, { reason: string }> {
// ... 同上 ...
while (true) {
// 检查点 1:循环开始时
if (signal.aborted) {
return { reason: 'aborted' };
}
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
tools,
messages,
}); // 注意:实际应把 signal 传给 fetch
// 检查点 2:API 响应后
if (signal.aborted) {
return { reason: 'aborted_after_api' };
}
// ... 处理响应 ...
// 检查点 3:工具执行后
if (signal.aborted) {
return { reason: 'aborted_after_tools' };
}
}
}
// 使用:5 秒后自动中断
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const gen = agentLoopWithAbort('分析所有 TypeScript 文件', controller.signal);
for await (const output of gen) {
process.stdout.write(output);
}
实践 3:添加简单的上下文管理
当消息过长时进行简单的截断:
function trimMessages(
messages: Anthropic.MessageParam[],
maxTokenEstimate: number = 100000
): Anthropic.MessageParam[] {
// 粗略估算:1 token ≈ 4 字符
const estimateTokens = (msgs: Anthropic.MessageParam[]) =>
JSON.stringify(msgs).length / 4;
if (estimateTokens(messages) < maxTokenEstimate) {
return messages;
}
// 保留第一条(用户原始请求)和最后 N 条消息
const first = messages[0];
const keepLast = 10;
const tail = messages.slice(-keepLast);
// 注入压缩标记
return [
first,
{
role: 'user' as const,
content: '[Earlier conversation history was summarized to save context space]',
},
...tail,
];
}
3.11 源码对照表
| 概念 | 源文件 | 关键行号 |
|---|---|---|
| QueryEngine 类定义 | src/QueryEngine.ts |
184-207 |
| submitMessage 入口 | src/QueryEngine.ts |
209-1049 |
| query() 外层函数 | src/query.ts |
218-239 |
| queryLoop() 核心循环 | src/query.ts |
241-1729 |
| State 类型定义 | src/query.ts |
204-217 |
| QueryParams 类型 | src/query.ts |
181-199 |
| 消息预处理管线 | src/query.ts |
365-467 |
| API 流式消费 | src/query.ts |
654-863 |
| 流式工具执行 | src/query.ts |
560-568, 838-862 |
| 中断处理(流式阶段) | src/query.ts |
1015-1052 |
| 中断处理(工具阶段) | src/query.ts |
1484-1515 |
| 终止判定 | src/query.ts |
1062-1357 |
| Stop Hook 处理 | src/query/stopHooks.ts |
65-473 |
| 工具执行编排 | src/services/tools/toolOrchestration.ts |
19-80 |
| prompt-too-long 恢复 | src/query.ts |
1062-1183 |
| max_output_tokens 恢复 | src/query.ts |
1188-1256 |
| queryModelWithStreaming | src/services/api/claude.ts |
752-780 |
| queryModel 内部 | src/services/api/claude.ts |
1017-1350+ |
| withRetry 重试策略 | src/services/api/withRetry.ts |
170-517 |
| 重试延迟计算 | src/services/api/withRetry.ts |
530-548 |
| 529 过载处理 | src/services/api/withRetry.ts |
318-364 |
| FallbackTriggeredError | src/services/api/withRetry.ts |
160-168 |
| 模型回退处理 | src/query.ts |
893-950 |
| QueryDeps 依赖注入 | src/query/deps.ts |
21-40 |
| QueryConfig 配置快照 | src/query/config.ts |
16-46 |
| 自动压缩阈值 | src/services/compact/autoCompact.ts |
72-76 |
| 压缩失败断路器 | src/services/compact/autoCompact.ts |
67-70 |
| 缺失 tool_result 补充 | src/query.ts |
123-149 |
| 消息增长(准备下一轮) | src/query.ts |
1715-1728 |
| 内存释放(compact 后) | src/QueryEngine.ts |
926-933 |
3.12 本章小结
本章深入剖析了 Claude Code 的核心——Agent 主循环。以下是关键的设计洞察:
1. 循环即 Agent 的本质
一个 while(true) 循环将”聊天模型”变成了”Agent”。循环内部的 callAPI → processToolUse → checkTermination 模式是所有 Agent 系统的基本骨架,无论框架如何变化,这个核心模式不变。
2. AsyncGenerator 是流式架构的基石
Claude Code 选择 AsyncGenerator 而非 EventEmitter 或 Observable 来实现流式管道,获得了三个关键优势:类型安全的双向通信(yield/return)、自然的背压控制、以及通过 yield* 实现的生成器组合。这是一个值得在你自己的项目中借鉴的架构选择。
3. 错误是信息,不是异常
工具执行错误不会终止循环——它们作为 is_error: true 的 tool_result 返回给模型。API 级别的错误有分层的恢复策略:重试、回退、压缩、升级。只有真正不可恢复的错误才会终止循环。
4. 状态管理的不可变替换模式
每次 continue 时,整个 State 对象被替换为新的——而不是逐字段修改。这让”为什么继续”(transition)成为可追踪的、可测试的数据。
5. 依赖注入让测试成为可能
通过 QueryDeps,循环的核心逻辑可以在不调用真实 API 的情况下被完整测试。这是大型生产系统的必备模式。
下一章,我们将进入循环内部最复杂的子系统之一——会话上下文与对话管理,看看 messages[] 数组是如何构建、压缩和恢复的。
思考题
你会怎么把 5 种终止条件简化为更少的几种?哪些可以合并?
欢迎在评论区聊聊你的想法。
下一讲,我们换个角度看《Tool 抽象与注册》。