第3章:Agent Loop —— query() 这一个循环
约 14 分钟 · 更新于 2026-09-01
第3章:Agent Loop —— query() 这一个循环
本章拆 src/query.ts 那个 1729 行的文件。它只有一个 while (true),但里面有 7 个 continue 分支和 10 种终止原因。核心判断和 Codex 教程第 4 章是同一个:agent 循环的骨架很简单,1700 行全是挂在它上面的横切关注点——只不过 Claude Code 挂上去的东西和 Codex 完全不同。
一、只有一层循环
Codex 是三级循环(Task → Turn → Sampling Request),dsh 是三级(session / turn / step)。Claude Code 是一级:
text
QueryEngine.submitMessage() ← 会话级:一条用户消息
↓
query() ← 包装:命令生命周期通知
↓
queryLoop() ← 唯一的 while(true)
↓
每一圈 = 一次模型请求 + 一批工具执行
query() 本身只有 20 行,干一件事 [源码 src/query.ts:219]:
ts
export async function* query(params: QueryParams): AsyncGenerator<…, Terminal> {
const consumedCommandUuids: string[] = []
const terminal = yield* queryLoop(params, consumedCommandUuids)
// Only reached if queryLoop returned normally. Skipped on throw (error
// propagates through yield*) and on .return() (Return completion closes
// both generators). This gives the same asymmetric started-without-completed
// signal as print.ts's drainCommandQueue when the turn fails.
for (const uuid of consumedCommandUuids) {
notifyCommandLifecycle(uuid, 'completed')
}
return terminal
}
这段注释说的事很微妙:只有正常返回才会发 completed 通知。抛异常时异常穿过 yield* 传出去、调用方 .return() 时两个生成器一起关闭——这两种情况都跳过通知。于是外部观察者看到的是"有 started 没有 completed",这个不对称本身就是失败信号。
可迁移的判断 ⑤
用"缺少完成事件"当失败信号,比额外发一个失败事件更可靠。
因为发失败事件本身也可能失败(进程被 kill、异常在 catch 里再抛)。而"没发完成事件"是默认状态,不需要任何代码执行到。
1.1 为什么只有一层
Codex 需要三层是因为它把「重试」和「压缩」放在了不同层:重试属于采样层,压缩属于轮次层,如果混在一起两个循环的终止条件会纠缠。
Claude Code 的解法不同——它把所有恢复策略都拍平到同一层,然后用一个显式的状态对象把"这一圈为什么会有下一圈"记下来:
ts
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined
turnCount: number
// Why the previous iteration continued. Undefined on first iteration.
// Lets tests assert recovery paths fired without inspecting message contents.
transition: Continue | undefined
}
[源码 src/query.ts:204]
最后那个 transition 字段是纯为了可测试性存在的——注释写得很直白:"让测试可以断言恢复路径确实触发了,而不用去翻消息内容"。
这是个值得学的招:把"我为什么走到这一步"变成一个可断言的枚举值,而不是让测试去反推。
1.2 状态对象是显式重建的,不是就地改的
循环体每次 continue 之前都写一整个新的 State:
ts
const next: State = {
messages: postCompactMessages,
toolUseContext,
autoCompactTracking: undefined,
maxOutputTokensRecoveryCount,
hasAttemptedReactiveCompact: true, // ← 这次只改了它
maxOutputTokensOverride: undefined,
pendingToolUseSummary: undefined,
stopHookActive: undefined,
turnCount,
transition: { reason: 'reactive_compact_retry' },
}
state = next
continue
[源码 src/query.ts:1152]
哪怕只改一个字段也把 10 个字段全写一遍。源码注释解释了原因:
Continue sites write state = { ... } instead of 9 separate assignments.
(continue 的位置直接整体赋值 state = { ... },而不是写 9 条独立赋值。)
[源码 src/query.ts:266]
代价是啰嗦,收益是每个 continue 点的完整状态都摆在眼前。 想知道"reactive compact 之后 stopHookActive 会不会被保留",不用去追前面 300 行——就在这 10 行里。
这也解释了另一处注释——为什么 taskBudgetRemaining 是循环外的裸变量而不是塞进 State:
Loop-local (not on State) to avoid touching the 7 continue sites.
(放在循环局部而不是 State 上,免得动那 7 个 continue 点。)
[源码 src/query.ts:288]
"这个字段加进 State 就要改 7 个地方"本身成了架构决策的输入。 显式状态的代价是真实存在的。
二、依赖注入:只有 4 个
ts
export type QueryDeps = {
callModel: typeof queryModelWithStreaming // 模型
microcompact: typeof microcompactMessages // 压缩
autocompact: typeof autoCompactIfNeeded // 压缩
uuid: () => string // 平台
}
[源码 src/query/deps.ts:22]
注释交代了动机和边界:
Passing a deps override 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.
Scope is intentionally narrow (4 deps) to prove the pattern.
"故意只做 4 个,先证明这个模式работ"——这是个诚实的增量重构说明,而不是假装这是完整的依赖注入架构。
注意 typeof fn 这个写法:类型直接从真实实现推导,改了实现签名,注入点自动跟着变,不需要维护一份平行的接口定义。
配置则是另一个对象,进循环时快照一次 [源码 src/query/config.ts:14]:
ts
export type QueryConfig = {
sessionId: SessionId
gates: {
streamingToolExecution: boolean
emitToolUseSummaries: boolean
isAnt: boolean
fastModeEnabled: boolean
}
}
注释说明了它为什么故意不包含 feature() 门控:
Intentionally excludes feature() gates — those are tree-shaking boundaries and must stay inline at the guarded blocks for dead-code elimination.
第 2 章那条"构建期开关必须内联"的约束,在这里第二次决定了数据结构的形状。
同时这段注释还剧透了未来方向:
Separating these from the per-iteration State struct and the mutable ToolUseContext makes future step() extraction tractable — a pure reducer can take (state, event, config) where config is plain data.
他们想把这个循环重构成 (state, event, config) => state 的纯 reducer。 State / QueryConfig / QueryDeps 三个类型的拆分就是为这件事做的地基。
三、一圈的完整流水线
一圈从头到尾是这样的:
text
【入场:上下文预算】
① applyToolResultBudget —— 单条消息的工具结果总量上限
② snipCompact —— 掐掉历史片段 [feature]
③ microcompact —— 清空老工具结果
④ applyCollapsesIfNeeded —— 折叠视图投影 [feature]
⑤ autoCompactIfNeeded —— 整段摘要
⑥ blocking-limit 检查 —— 还是超了就直接返回
【模型】
⑦ deps.callModel(...) —— 流式;同时 StreamingToolExecutor 边流边跑工具
├─ FallbackTriggeredError → 换模型重来(内层 while)
└─ 可恢复错误 → withhold(先不吐给消费者)
【收尾】
⑧ executePostSamplingHooks
⑨ 中断检查
⑩ 没有 tool_use → 走终止分支(恢复 / stop 钩子 / 预算续跑 / completed)
⑪ 有 tool_use → runTools 或 executor.getRemainingResults()
⑫ toolUseSummary(Haiku 异步生成,下一圈才 yield)
⑬ getAttachmentMessages —— 附件注入
⑭ memory / skill prefetch 消费
⑮ refreshTools —— 新连上的 MCP 服务器在这里进来
⑯ maxTurns 检查 → state = next; continue
注意 ①→⑤ 的顺序不是随意的。 每一处都有注释解释为什么必须在这个位置:
ts
// Enforce per-message budget on aggregate tool result size. Runs BEFORE
// microcompact — cached MC operates purely by tool_use_id (never inspects
// content), so content replacement is invisible to it and the two compose
// cleanly.
[源码 src/query.ts:369]
ts
// Runs BEFORE autocompact so that if collapse gets us under the
// autocompact threshold, autocompact is a no-op and we keep granular
// context instead of a single summary.
[源码 src/query.ts:429]
后面这句是整条流水线的设计原则:便宜的、保真度高的手段先上;只有它们都没把上下文压下去,才动用最贵、最有损的整段摘要。 第 7 章会把这五层完整展开。
四、流式工具执行:模型还在说,工具已经在跑
这是 Claude Code 主循环里最值得单独讲的一处。
ts
const useStreamingToolExecution = config.gates.streamingToolExecution
let streamingToolExecutor = useStreamingToolExecution
? new StreamingToolExecutor(toolUseContext.options.tools, canUseTool, toolUseContext)
: null
[源码 src/query.ts:561]
然后在消费模型流的循环里:
ts
if (message.type === 'assistant') {
assistantMessages.push(message)
const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}
if (streamingToolExecutor && !aborted) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message) // ← 立刻开跑
}
}
}
// 每收一个消息就顺手收一次已完成的结果
if (streamingToolExecutor && !aborted) {
for (const result of streamingToolExecutor.getCompletedResults()) {
if (result.message) { yield result.message; toolResults.push(…) }
}
}
[源码 src/query.ts:826-862]
一个 tool_use block 一从流里出来就开始执行,不等整条响应结束。 对于"模型一口气发起 5 个并行 Read"这种情况,第一个 Read 在模型还在生成第 5 个工具调用时就已经跑完了。
代价是复杂度:一旦要重试(模型 fallback 或流式降级),已经在跑的工具结果必须全部丢弃,否则会出现"工具结果的 tool_use_id 对应的是上一次请求里的 block"这种孤儿:
ts
if (streamingToolExecutor) {
streamingToolExecutor.discard()
streamingToolExecutor = new StreamingToolExecutor(…) // 全新一个
}
[源码 src/query.ts:733, 912]
这段逻辑在文件里出现了两次(流式降级一次、模型 fallback 一次),每次都配了一段注释解释孤儿问题。
可迁移的判断 ⑥
当上游是流式的、下游是可并行的,就让下游边流边启动——但必须同时设计"整批作废"的路径。
判断标准很简单:如果上游有任何形式的重试,那么"已经启动的下游工作"就必须能被整体丢弃,且丢弃要发生在产生任何可观测副作用之前或之后都能自洽。Claude Code 的做法是让 executor 整个换掉,而不是逐个取消——换掉一个对象比正确取消 N 个任务简单得多。
五、withhold:先别把错误吐出去
这是本章第二个值得单独讲的设计。
模型返回可恢复的错误(上下文超长、输出 token 超限、媒体太大)时,直觉做法是把错误 yield 给消费者,然后自己再试一次。Claude Code 不这么干:
ts
let withheld = false
if (feature('CONTEXT_COLLAPSE')) {
if (contextCollapse?.isWithheldPromptTooLong(message, isPromptTooLongMessage, querySource)) withheld = true
}
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (mediaRecoveryEnabled && reactiveCompact?.isWithheldMediaSizeError(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) {
yield yieldMessage // ← 只有不需要 withhold 的才吐出去
}
[源码 src/query.ts:799-825]
为什么? 注释说得很具体:
Yielding early leaks an intermediate error to SDK callers (e.g. cowork/desktop) that terminate the session on any error field — the recovery loop keeps running but nobody is listening.
(提前 yield 会把一个中间态错误漏给 SDK 调用方——比如 cowork / 桌面版——它们看到任何 error 字段就终止会话。结果是恢复循环还在跑,但已经没人在听了。)
[源码 src/query.ts:166]
这是一个纯粹由"下游消费者的行为"倒逼出来的设计。 消费者的语义是"看到 error 就收摊",那么生产者就必须保证 error 只在真正无法恢复时才出现。
被 withhold 的消息仍然会 push 进 assistantMessages,因为后面的恢复逻辑要靠它判断发生了什么。恢复用尽之后再补吐:
ts
// Recovery exhausted — surface the withheld error now.
yield lastMessage
[源码 src/query.ts:1254]
5.1 恢复的三级台阶
prompt_too_long 的恢复是三级瀑布 [源码 src/query.ts:1085-1183]:
text
① context-collapse 排空(便宜,保留细粒度上下文)
↓ 排空后仍然 413
② reactive compact(整段摘要,一次性)
↓ 还是不行
③ 吐出错误 + executeStopFailureHooks + return { reason: 'prompt_too_long' }
第①级有个精巧的守卫:
ts
state.transition?.reason !== 'collapse_drain_retry'
用上一圈的 transition 值来防止重复排空。 这就是 transition 字段除了测试之外的实际用途。
第③级的注释解释了为什么不走 stop 钩子:
Do NOT fall through to stop hooks: the model never produced a valid response, so hooks have nothing meaningful to evaluate. Running stop hooks on prompt-too-long creates a death spiral: error → hook blocking → retry → error → … (the hook injects more tokens each cycle).
"死亡螺旋"是个真实发生过的事故形态:错误 → stop 钩子拦截并注入内容 → 重试 → 上下文更长 → 又错误。同一个词在 hasAttemptedReactiveCompact 那里再次出现:
ts
// Preserve the reactive compact guard — if compact already ran and
// couldn't recover from prompt-too-long, retrying after a stop-hook
// blocking error will produce the same result. Resetting to false
// here caused an infinite loop: compact → still too long → error →
// stop hook blocking → compact → … burning thousands of API calls.
[源码 src/query.ts:1292]
一个布尔字段该不该在某个 continue 点重置——这个问题曾经烧掉了几千次 API 调用。
5.2 输出超限:先加码,再多轮
max_output_tokens 的恢复是两段式 [源码 src/query.ts:1188-1256]:
第一段——同一个请求换个上限重发:
ts
if (capEnabled && maxOutputTokensOverride === undefined && !process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
logEvent('tengu_max_tokens_escalate', { escalatedTo: ESCALATED_MAX_TOKENS })
// maxOutputTokensOverride: ESCALATED_MAX_TOKENS
// transition: { reason: 'max_output_tokens_escalate' }
continue
}
注释说:默认上限是 8k,撞了就用 64k 把同一个请求重发一遍——"no meta message, no multi-turn dance"。
第二段——加码也不够,才转成多轮:
ts
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,
})
[源码 src/query.ts:1224]
注意这句提示词的措辞:"不要道歉、不要复述、如果是从半句话被截断的就从半句话接上"。最多 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3)。
这是本教程第一次遇到「注入到消息流里的指令」这种手法——它不在系统提示词里,是运行时按需塞进对话的一条 isMeta 用户消息。第 6 章会看到这套机制的全貌。
六、7 个 continue 与 10 个 Terminal
把它们排成两张表,这个循环的全貌就清楚了。
6.1 七种"还要再来一圈"
| transition.reason | 触发条件 | 下一圈带着什么 |
|---|
| collapse_drain_retry | 413 且折叠队列有货 | 排空后的 messages |
| reactive_compact_retry | 413 且折叠救不了 | 摘要后的 messages,hasAttemptedReactiveCompact = true |
| max_output_tokens_escalate | 输出撞 8k 上限 | 同一批 messages,maxOutputTokensOverride = 64k |
| max_output_tokens_recovery | 加码后仍撞上限 | messages + 一条"接着说"的 meta 消息,计数 +1 |
| stop_hook_blocking | Stop 钩子返回阻塞错误 | messages + 钩子的阻塞消息,stopHookActive = true |
| token_budget_continuation | 用户设了 token 预算且没花够 | messages + 一条"继续干"的 meta 消息 |
| next_turn | 模型调了工具(正常路径) | messages + assistant + 工具结果 + 附件 |
六种是异常恢复,只有一种是正常路径。 这个比例本身就说明了 1700 行是怎么来的。
token_budget_continuation 那条尤其有意思——它对应的是用户说"这个任务给你 +500k token"的场景 [源码 src/query/tokenBudget.ts:59]:
ts
const COMPLETION_THRESHOLD = 0.9
const DIMINISHING_THRESHOLD = 500
const isDiminishing =
tracker.continuationCount >= 3 &&
deltaSinceLastCheck < DIMINISHING_THRESHOLD &&
tracker.lastDeltaTokens < DIMINISHING_THRESHOLD
if (!isDiminishing && turnTokens < budget * COMPLETION_THRESHOLD) {
// → continue,塞一条 nudge 消息让模型接着干
}
没花到 90% 就自动续跑,但连续 3 次每次增量不到 500 token 就判定"边际收益递减",停。 一个防"模型为了凑预算而空转"的止损器。
对应的系统提示词段落是 [源码 src/constants/prompts.ts:546]:
When the user specifies a token target (e.g., "+500k", "spend 2M tokens", "use 1B tokens"), your output token count will be shown each turn. Keep working until you approach the target — plan your work to fill it productively. The target is a hard minimum, not a suggestion. If you stop early, the system will automatically continue you.
这段的缓存注释也值得看:它原本是 DANGEROUS_uncached(跟着预算开关变),"每次预算翻转要烧掉 ~20K token",后来改成无条件缓存——因为 "When the user specifies..." 这个措辞在没有预算时天然是个 no-op。用措辞的通用性换缓存稳定性。
6.2 十种终止
| Terminal.reason | 什么时候 |
|---|
| completed | 模型不再调工具,且没有任何恢复/续跑触发 |
| blocking_limit | 关掉自动压缩时的硬上限,留空间给手动 /compact |
| image_error | 图片尺寸/缩放失败,或媒体错误恢复失败 |
| model_error | 模型或运行时抛异常 |
| prompt_too_long | 三级恢复全部失败 |
| aborted_streaming | 流式阶段被中断 |
| aborted_tools | 工具执行阶段被中断 |
| stop_hook_prevented | Stop 钩子明确要求停止 |
| hook_stopped | 工具阶段某个钩子返回 hook_stopped_continuation |
| max_turns | 超过 maxTurns |
四种是"用户/钩子叫停",四种是错误,一种是资源限制,只有一种是"干完了"。
blocking_limit 的判断有一串很长的注释解释它在哪些情况下必须跳过——反应式压缩开着的时候要跳过(否则合成错误在 API 调用前就返回了,反应式压缩根本没机会看到真的 413)、compact 自己的 forked agent 要跳过(否则死锁:压缩 agent 需要跑起来才能降低 token 数)[源码 src/query.ts:592-620]。这是一段被至少四个后续功能反复打补丁的判断。
七、藏在延迟里的两个预取
主循环里有一对设计,专门利用"模型流式响应要 5–30 秒"这段空窗:
记忆预取(每轮一次,进循环前启动):
ts
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(state.messages, state.toolUseContext)
[源码 src/query.ts:301]
using 是 TypeScript 的显式资源管理语法——生成器无论从哪条路径退出,dispose 都会执行。注释:
Fired once per user turn — the prompt is invariant across loop iterations, so per-iteration firing would ask sideQuery the same question N times. Consume point polls settledAt (never blocks).
消费点是"轮询是否已完成,没完成就跳过、下一圈再试"——零等待。
技能发现预取(每圈一次):
ts
const pendingSkillPrefetch = skillPrefetch?.startSkillDiscoveryPrefetch(null, messages, toolUseContext)
[源码 src/query.ts:331]
注释里有个漂亮的数字:
Replaces the blocking assistant_turn path that ran inside getAttachmentMessages (97% of those calls found nothing in prod).
… collectSkillDiscoveryPrefetch emits hidden_by_main_turn — true when the prefetch resolved before this point (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s).
97% 的调用什么也没找到,所以不能让它阻塞;而它 250–573ms 的延迟能被 2–30 秒的主轮次完全盖住,所以并发跑就是免费的。
还有第三个同类设计——工具调用摘要:
ts
// Yield tool use summary from previous turn — haiku (~1s) resolved during model streaming (5-30s)
if (pendingToolUseSummary) {
const summary = await pendingToolUseSummary
if (summary) yield summary
}
[源码 src/query.ts:1054]
这一圈的工具跑完后,用 Haiku 异步生成一句摘要,不 await,直接塞进下一圈的 State,下一圈开头再取。摘要只给主线程做(!toolUseContext.agentId),因为子 agent 不会出现在移动端 UI 里——"skip the Haiku call"。
可迁移的判断 ⑦
凡是"结果不影响当前决策、只是锦上添花"的辅助计算,都应该在主路径的延迟窗口里并发跑,并且用轮询而不是 await 来消费。
三条实现要点:(a) 启动点尽量早,(b) 消费点零等待、拿不到就下一轮再说,(c) 用 using / defer 之类的机制保证任何退出路径都会清理。Claude Code 三处都这么做,而且都在注释里写清了"主路径要 X 秒,这个要 Y 毫秒"这个比值——没有这个比值就没有这个设计的正当性。
八、结束前的最后一步:把外面发生的事捞进来
模型说完、工具跑完,continue 之前还有一串"收集外部变化"的动作:
队列里的用户插话与任务通知 [源码 src/query.ts:1566]:
ts
const sleepRan = toolUseBlocks.some(b => b.name === SLEEP_TOOL_NAME)
const queuedCommandsSnapshot = getCommandsByMaxPriority(sleepRan ? 'later' : 'next').filter(cmd => {
if (isSlashCommand(cmd)) return false // 斜杠命令不能当文本喂给模型
if (isMainThread) return cmd.agentId === undefined
// Subagents only drain task-notifications addressed to them — never
// user prompts, even if someone stamps an agentId on one.
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
队列是进程级单例,主线程和所有进程内子 agent 共用。每个循环只捞走写给自己的那部分。 而且有一条硬规则:用户的话只给主线程,子 agent 永远看不到提示流——哪怕有人给它盖了个 agentId。
新连上的 MCP 服务器 [源码 src/query.ts:1659]:
ts
// Refresh tools between turns so newly-connected MCP servers become available
if (updatedToolUseContext.options.refreshTools) {
const refreshedTools = updatedToolUseContext.options.refreshTools()
if (refreshedTools !== updatedToolUseContext.options.tools) { … }
}
MCP 是异步连接的。一个服务器可能在第 3 圈才连上——它的工具从第 4 圈开始可用。注意这里用的是引用比较 !==:没变就不动,避免制造一个新对象白白破坏下游的缓存判断。
这也解释了本会话里一件你可能见过的事:中途冒出来的 <system-reminder> 说某某 MCP 服务器连上了/断开了。那正是第 6 章要讲的 mcp_instructions_delta 附件。
九、动手复核
bash
cd claude-code-deep-dive/extracted-source
# 1. 主循环全文(值得完整读一遍,1500 行)
sed -n '219,1729p' src/query.ts | less
# 2. State 类型与那句「为了测试」的注释
sed -n '201,220p' src/query.ts
# 3. 七个 continue 点:搜 transition
grep -n "transition: { reason:" src/query.ts
# 4. 十个终止原因
grep -n "return { reason:" src/query.ts
# 5. withhold 机制
grep -n "withheld\|isWithheld" src/query.ts
# 6. 三处预取及其延迟比值注释
grep -n -B3 'startRelevantMemoryPrefetch\|startSkillDiscoveryPrefetch\|generateToolUseSummary' src/query.ts
# 7. 那两处「死亡螺旋」注释
grep -n -B6 'death spiral\|infinite loop' src/query.ts
# 8. 依赖注入与配置快照
cat src/query/deps.ts src/query/config.ts src/query/tokenBudget.ts
十、总结
- 只有一层 while(true),靠一个 11 字段的显式 State 对象承载跨圈状态;每个 continue 点整体重写 State,啰嗦但可读
- transition 字段是为可测试性存在的,同时被用来做"上一圈已经排空过折叠队列"这类守卫
- 依赖注入只有 4 个,用 typeof fn 从实现推导类型;配置快照故意排除 feature() 门控,因为那是 tree-shaking 边界
- 一圈的入场是五层上下文预算流水线,顺序原则是"便宜的、保真度高的先上"
- 流式工具执行:tool_use 一出流就开跑;代价是任何重试都必须整体丢弃 executor 并新建一个
- withhold:可恢复的错误先扣住不吐给消费者,因为下游 SDK 看到 error 就收摊;恢复用尽再补吐
- 7 个 continue 里 6 个是异常恢复,10 个终止里只有 1 个是"干完了"——1700 行的来源就是这个比例
- 三处预取藏在模型流式响应的 5–30 秒延迟里,消费点全部零等待
- 两处"死亡螺旋"注释记录了真实事故:布尔守卫在哪个 continue 点重置,能决定烧不烧掉几千次 API 调用
下一章处理本教程的主线问题:为什么"这会不会破坏缓存前缀"是这个系统的第一性问题。
- 第2章-工程骨架-一个npm包里的AgentOS
- 第4章-模型调用与缓存经济学
- 第7章-上下文压缩-五层防线 —— 本章第三节那条流水线的完整展开
- 第9章-工具执行链-一次调用要过多少道关 —— runTools 之后发生的事
- 第11章-钩子系统-27个事件的治理层 —— Stop / PostSampling 钩子在循环里的落点
- Codex 教程第 4 章 —— 三级循环的对照
- Pi 教程第 3 章 —— 极简循环的对照
- dsh 教程第 5 章 —— 三级生命周期的对照