Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第3章

第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_retry413 且折叠队列有货排空后的 messages
reactive_compact_retry413 且折叠救不了摘要后的 messages,hasAttemptedReactiveCompact = true
max_output_tokens_escalate输出撞 8k 上限同一批 messages,maxOutputTokensOverride = 64k
max_output_tokens_recovery加码后仍撞上限messages + 一条"接着说"的 meta 消息,计数 +1
stop_hook_blockingStop 钩子返回阻塞错误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_preventedStop 钩子明确要求停止
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

十、总结

  1. 只有一层 while(true),靠一个 11 字段的显式 State 对象承载跨圈状态;每个 continue 点整体重写 State,啰嗦但可读
  2. transition 字段是为可测试性存在的,同时被用来做"上一圈已经排空过折叠队列"这类守卫
  3. 依赖注入只有 4 个,用 typeof fn 从实现推导类型;配置快照故意排除 feature() 门控,因为那是 tree-shaking 边界
  4. 一圈的入场是五层上下文预算流水线,顺序原则是"便宜的、保真度高的先上"
  5. 流式工具执行tool_use 一出流就开跑;代价是任何重试都必须整体丢弃 executor 并新建一个
  6. withhold:可恢复的错误先扣住不吐给消费者,因为下游 SDK 看到 error 就收摊;恢复用尽再补吐
  7. 7 个 continue 里 6 个是异常恢复,10 个终止里只有 1 个是"干完了"——1700 行的来源就是这个比例
  8. 三处预取藏在模型流式响应的 5–30 秒延迟里,消费点全部零等待
  9. 两处"死亡螺旋"注释记录了真实事故:布尔守卫在哪个 continue 点重置,能决定烧不烧掉几千次 API 调用

下一章处理本教程的主线问题:为什么"这会不会破坏缓存前缀"是这个系统的第一性问题。


  • 第2章-工程骨架-一个npm包里的AgentOS
  • 第4章-模型调用与缓存经济学
  • 第7章-上下文压缩-五层防线 —— 本章第三节那条流水线的完整展开
  • 第9章-工具执行链-一次调用要过多少道关 —— runTools 之后发生的事
  • 第11章-钩子系统-27个事件的治理层 —— Stop / PostSampling 钩子在循环里的落点
  • Codex 教程第 4 章 —— 三级循环的对照
  • Pi 教程第 3 章 —— 极简循环的对照
  • dsh 教程第 5 章 —— 三级生命周期的对照

本章目录
一、只有一层循环二、依赖注入:只有 4 个三、一圈的完整流水线四、流式工具执行:模型还在说,工具已经在跑五、withhold:先别把错误吐出去六、7 个 continue 与 10 个 Terminal七、藏在延迟里的两个预取八、结束前的最后一步:把外面发生的事捞进来九、动手复核十、总结Related Documents
苏ICP备2025204887号-2