Harness Engineering

第3章 Agent Loop:心跳与决策循环

作者 杨艺韬 · 17,301 字

每一个 AI Agent 的核心都是一个循环——观察、思考、行动、再观察。这个循环的工程质量,决定了 Agent 是一个惊艳的 demo 还是一个可靠的生产系统。

本章先建立 Agent Loop 的基本模型,然后对比两种典型实现:Claude Code 的命令式 while 循环与 LangGraph 的声明式状态图。接着我们会深入循环运行中的五个核心工程问题——终止条件、错误处理、压缩与恢复、并发执行与心跳反馈。最后把这些机制组合成一个可用于生产的完整循环,并提炼出设计方法论。

各节里以源码符号命名的小节(reactive_compact_retrystripSignatureBlocks 这一类)是针对 query.ts / QueryEngine.ts 的源码实证,放在它们各自所属的主题下面。首读时可以跳过,不影响主线;想弄清"真实实现到底长什么样"时再回来看。

3.1 从 OODA 到 Agent Loop

军事理论家 John Boyd 提出的 OODA 循环(Observe → Orient → Decide → Act)被广泛应用于决策理论。AI Agent 的核心循环本质上是 OODA 的工程化实现:

flowchart TD
    O["🔍 Observe<br/>接收输入/工具结果"] --> T["🧠 Think<br/>模型推理决策"]
    T --> D{"需要工具?"}
    D -->|是| A["⚡ Act<br/>执行工具调用"]
    A --> F["📥 Feedback<br/>工具返回结果"]
    F --> O
    D -->|否| R["✅ 输出最终回答"]
    style T fill:#fef3c7,stroke:#f59e0b
    style A fill:#dbeafe,stroke:#3b82f6
    style R fill:#dcfce7,stroke:#22c55e
  • Observe:接收用户输入或上一轮工具执行的结果
  • Think:大模型基于当前上下文进行推理,决定下一步动作
  • Act:调用工具、生成代码、发送请求
  • Feedback:工具返回结果,成为下一轮 Observe 的输入

看起来简单,但魔鬼藏在每一个箭头里。模型可能产生幻觉调用不存在的工具,工具可能超时或失败,循环可能陷入无限重试。一个生产级的 Agent Loop 需要处理所有这些情况。

让我们先看两个真实系统是怎么做的。

3.2 Claude Code 的 Agent Loop 实现

Claude Code 的 Agent Loop 是一个经典的 while 循环实现。剥去日志、遥测等非核心逻辑后,其核心骨架如下:

async function agentLoop(
  userMessage: string,
  context: ConversationContext
): Promise<void> {
  // 将用户消息加入对话历史
  context.messages.push({ role: "user", content: userMessage });

  let shouldContinue = true;
  let turnCount = 0;

  while (shouldContinue) {
    turnCount++;
    // 1. Think: 调用模型
    const response = await queryModel({
      messages: context.messages,
      tools: context.availableTools,
      system: context.systemPrompt,
    });

    // 2. 将模型响应加入对话历史
    context.messages.push({ role: "assistant", content: response.content });

    // 3. 检查是否有工具调用
    const toolUses = response.content.filter(
      (block) => block.type === "tool_use"
    );

    if (toolUses.length === 0) {
      // 模型没有调用工具,说明它认为任务完成了
      shouldContinue = false;
      break;
    }

    // 4. Act: 执行工具调用(支持并行)
    const toolResults = await Promise.all(
      toolUses.map(async (toolUse) => {
        const result = await executeTool(toolUse.name, toolUse.input);
        return {
          type: "tool_result",
          tool_use_id: toolUse.id,
          content: result,
        };
      })
    );

    // 5. Feedback: 将工具结果加入对话历史
    context.messages.push({ role: "user", content: toolResults });

    // 6. 检查终止条件
    //    注意用独立的轮次计数——一轮会追加 assistant 和 user 两条消息,
    //    messages.length 不等于轮次数
    if (turnCount >= MAX_TURNS) {
      shouldContinue = false;
    }
  }
}

这段代码揭示了几个关键设计决策:

决策一:循环的驱动力是工具调用。 模型不调用工具 = 任务完成。这是一个优雅的终止条件——不需要额外的"done"信号,模型自己通过行为表达"我做完了"。

决策二:工具并行执行。 Promise.all 意味着同一轮迭代中的多个工具调用是并发的。当模型同时请求读取三个文件时,三次 IO 并行发生,而不是串行等待。这在实践中能将某些迭代的耗时缩短数倍。

决策三:把工具结果重新注入对话历史。 Anthropic API 将工具结果包装成 user 消息,让模型在下一轮能"看到"工具的输出;OpenAI API 则使用 tool 角色。两种约定的本质相同:保持对话的交替结构,并把执行反馈回传给模型。下文的示例以 Anthropic 风格为主。

但真实的 Claude Code 远比这复杂。让我们逐一看它处理的边界情况。

3.2.1 查询引擎与消息调度

Claude Code 的查询引擎不是简单地把消息丢给 API。它在发送之前要做大量预处理:

async function queryModelWithPreprocessing(
  context: ConversationContext
): Promise<MessageStream> {
  // 1. 上下文窗口管理:如果消息太多,截断早期内容
  const messages = truncateToFitContext(context.messages, {
    maxTokens: MODEL_CONTEXT_LIMIT,
    strategy: "keep-recent-and-system",
  });

  // 2. 注入系统提醒(system reminder)
  //    在最后一条 user 消息中附加实时上下文
  const enrichedMessages = injectSystemReminder(messages, {
    currentDir: process.cwd(),
    gitStatus: await getGitStatus(),
    activeFile: context.activeFile,
  });

  // 3. 工具过滤:根据权限模型过滤可用工具
  const tools = filterToolsByPermission(
    context.availableTools,
    context.permissionLevel
  );

  // 4. 发送请求,启用流式响应
  const stream = await anthropic.messages.stream({
    model: context.model,
    messages: enrichedMessages,
    tools,
    system: context.systemPrompt,
    max_tokens: 16384,
  });

  return stream;
}

注意第 2 步的 injectSystemReminder。这是 Claude Code 的一个精妙设计:每次循环迭代都会在消息末尾注入最新的环境状态(当前目录、git 状态等)。这确保模型始终基于最新信息做决策,而不是依赖几轮之前的过时上下文。这个机制我们会在第8章"System Prompt 分层设计"中详细讨论。

3.2.2 流式处理与实时反馈

在生产系统中,模型的推理可能需要 10-30 秒。如果让用户干等一个 loading 动画,体验是灾难性的。Claude Code 通过流式处理解决这个问题:

async function processStreamingResponse(
  stream: MessageStream,
  onText: (text: string) => void,
  onToolStart: (tool: ToolUse) => void,
  onToolEnd: (result: ToolResult) => void
): Promise<ModelResponse> {
  const contentBlocks: ContentBlock[] = [];
  let currentBlock: Partial<ContentBlock> | null = null;
  let stopReason: string | null = null;

  for await (const event of stream) {
    switch (event.type) {
      case "content_block_start":
        currentBlock = event.content_block;
        if (currentBlock.type === "tool_use") {
          onToolStart(currentBlock as ToolUse);
        }
        break;

      case "content_block_delta":
        if (event.delta.type === "text_delta") {
          // 文本流式输出——用户立刻看到模型的思考过程
          onText(event.delta.text);
        } else if (event.delta.type === "input_json_delta") {
          // 工具参数逐步到达——可以提前展示工具调用意图
          accumulateToolInput(currentBlock, event.delta.partial_json);
        }
        break;

      case "content_block_stop":
        contentBlocks.push(currentBlock as ContentBlock);
        currentBlock = null;
        break;

      case "message_delta":
        // stop_reason 由 message_delta 事件携带(连同最终 usage 统计)
        if (event.delta.stop_reason) {
          stopReason = event.delta.stop_reason;
        }
        break;

      case "message_stop":
        // message_stop 只标记流结束、本身不含 stop_reason——用累积值收尾
        return { content: contentBlocks, stopReason };
    }
  }
}

流式处理不只是"好看"。它有两个关键工程价值:

  1. 用户感知延迟降低:第一个 token 到达的时间(TTFT)通常在 1-2 秒内,用户立刻知道系统在工作。
  2. 提前执行工具:某些实现会在工具参数完整但消息尚未结束时就开始执行工具,进一步压缩端到端延迟。

上面这段处理逻辑直接消费了 SDK 的 stream()。而真实的 query.ts 并没有止步于此——它在这之上又套了一层 async function*。多这一层是为了什么?

stream vs AsyncGenerator:为什么 Claude Code 选后者

Anthropic SDK 本身就提供了 messages.stream(),它返回一个 MessageStream,也支持 for await 消费,看起来和 AsyncGenerator 完全等价。既然如此,query.ts 为什么还要在它之上再套一层 async function*

差别有三处,而且都不是风格问题。

第一处是流出来的东西不一样MessageStream 只流 SSE 事件——content_block_start、content_block_delta(含 text_delta 与 input_json_delta)、message_delta 这些 API 层信号;而循环真正要往外送的还有业务层事件:turn_complete、tool_executed、compact_triggered。后者在 SDK 的抽象里根本不存在。

第二处是调用方需要一个统一的消费接口。使用这个循环的人不关心手里这条事件到底来自 SDK 的流还是 Claude Code 自己的状态转移,他只想用同一个 for await 把它们都消费掉。多套一层生成器,正是为了把两类来源合并成一条流。

第三处是提前终止。生成器天然支持 .return():用户按下 Ctrl-C 时,一次 return 就能把整条管线连同下游的清理逻辑一起收掉;而手动管理一个 Stream 的中断要繁琐得多。

这三点合起来是"包装层"的经典模式:把下游多个抽象统一成一个,再给上游一个干净的接口。React 的 Concurrent Rendering 调度器、Tokio 的 Stream combinator 用的都是同源思路。

既然循环是靠 yield 往外吐事件,那就得说清楚吐出来的到底是什么。

STREAM_EVENT 的 3 层嵌套

query.ts yield 出来的事件类型是一个三层嵌套的 tagged union,每一层都把"可能是什么"穷举干净:

顶层是 StreamEvent | RequestStartEvent | Message | ToolUseSummaryMessage | TombstoneMessage;其中 Message 内部又分为 UserMessage | AssistantMessage | SystemMessage | AttachmentMessage | HookResultMessage | ToolUseSummaryMessage | TombstoneMessage;再往里一层,AssistantMessage.content 的元素是 text | tool_use | thinking | redacted_thinking 等类型——注意 signature 并不是一个独立的 content 类型,它是 thinking block 内部的一个字段。

三层下来,每个事件、每条消息、每个 content block 都有明确的类型标签。带来的直接好处是:调用方写出来的 switch 分支结构,和源码里的 switch 分支是一一对应的——不需要靠猜,也不会漏掉某个分支。

这正是 TypeScript 在工程实践里的价值所在:它不是让你写 any 图省事,而是用精确类型逼你把每一种情况都考虑一遍。这和 §3.4 讨论的 Terminal tagged union 是一脉相承的两处设计。

事件是给外面看的;循环自己还要记住"我为什么走到这一步"。

关于 transition 的 state machine 哲学

query.ts 的 state 里有一个 transition?: { reason: '...' } 字段,作用是让每一次循环迭代都带上一个"上一步是怎么进来的"标记。transition.reason 的取值把所有入口穷举了出来:

  • undefined——首次迭代
  • next_turn——正常进入下一轮
  • reactive_compact_retry——刚压缩完重试
  • collapse_drain_retry——context collapse 重试
  • stop_hook_blocking——stop hook 暂缓继续
  • token_budget_continuation——token 预算重续
  • max_output_tokens_escalate——刚升级 max_tokens
  • max_output_tokens_recovery——刚恢复 max_tokens
  • streaming_fallback——流式工具执行被丢弃、回退非流式路径重试
  • model_fallback——API 抛出降级错误、currentModel 切换后重试(即 §3.6 提到的"query.ts 的 fallback 分支")

给每一种入口都起个名字,换来三样东西。首先是分支逻辑的依据:当前这一轮要不要跳过某些步骤,往往取决于"上一轮是不是刚 retry 过"——比如同一处已经连续重试到第三次,就不该再重试下去。其次是遥测的语义:trace 里能看到"这个 turn 是怎么来的",而不是只看到一条孤零零的消息。最后是事后可查:复盘"为什么这里突然转向了"时,有一个显式字段可以直接读,不必从上下文倒推。

这就是"可观察的状态机"与"隐式状态加一堆散落分支"的区别——每一种 transition 都有名字、都能被 trace 到。分布式系统里的 Raft、Paxos 状态机也是照这个路子设计的。

还有一个贯穿全章的细节:上面这些机制并非同时对所有人开启。

feature() 条件编译

本章反复出现的 feature('REACTIVE_COMPACT')feature('CONTEXT_COLLAPSE') 并不是普通的运行期开关,而是 bun:bundle 提供的条件编译:编译期就把 feature('X') ? importX : null 里不走的那条分支整个 DCE 掉。

这么做的原因来自构建需求。Claude Code 的构建系统要区分公开 bundle 与内部功能(据源码快照),而 CONTEXT_COLLAPSEKAIROSMARBLE_ORIGAMI 这类内部 feature 不能出现在公开 bundle 里——不是"运行时不启用",是"代码根本不该在那儿"。做法是常量折叠:feature('X') 的值在编译期已知,于是整个 if (feature('X')) 分支被直接移除。

这是"单仓库多产品"的常见工程模式。Rust 有 #[cfg],Java 有 @Conditional,C 有 #ifdef,JS/TS 则靠 bundler 来做,效果是一样的。

对读者的启示是:如果你的 Agent 要分"免费版 / 付费版"或"内部 / 外部"两条轨,编译期 DCE 比运行期 if 干净得多——免费版的 bundle 里连付费功能的代码都不存在,既省体积,也少一类被绕过的风险。

3.3 LangGraph 的状态机方法

如果说 Claude Code 的 Agent Loop 是"命令式的 while 循环",那 LangGraph 就是"声明式的状态图"。LangGraph 基于 Pregel 执行引擎,将 Agent Loop 建模为一个有向图的遍历过程。

from langgraph.graph import StateGraph, END
# 实际项目中通常还需要:from langgraph.graph.message import add_messages; from langchain_core.messages import ToolMessage
from typing import TypedDict, Annotated

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    iteration_count: int

def call_model(state: AgentState) -> dict:
    """Think 节点:调用模型"""
    messages = state["messages"]
    response = model.invoke(messages)
    return {
        "messages": [response],
        "iteration_count": state["iteration_count"] + 1,
    }

def call_tools(state: AgentState) -> dict:
    """Act 节点:执行工具"""
    last_message = state["messages"][-1]
    tool_calls = last_message.tool_calls

    results = []
    for call in tool_calls:
        tool = tool_registry[call["name"]]
        result = tool.invoke(call["args"])
        results.append(ToolMessage(content=result, tool_call_id=call["id"]))

    return {"messages": results}

def should_continue(state: AgentState) -> str:
    """路由函数:决定下一步去哪个节点"""
    last_message = state["messages"][-1]

    # 终止条件 1:模型没有调用工具
    if not last_message.tool_calls:
        return "end"

    # 终止条件 2:超过最大迭代次数
    if state["iteration_count"] >= MAX_ITERATIONS:
        return "end"

    return "tools"

# 构建状态图
graph = StateGraph(AgentState)
graph.add_node("model", call_model)
graph.add_node("tools", call_tools)
graph.set_entry_point("model")
graph.add_conditional_edges("model", should_continue, {
    "tools": "tools",
    "end": END,
})
graph.add_edge("tools", "model")

# 编译为可执行的 Runnable
agent = graph.compile()

这种方法和 while 循环在运行时行为上是等价的,但在工程特性上有显著差异:

特性 while 循环(Claude Code) 状态图(LangGraph)
可视化 需要额外日志 图结构天然可视化
中断恢复 需要手动序列化 状态快照内置
分支逻辑 if/else 嵌套 条件边,声明式
调试 断点+日志 节点级别 trace
并发控制 开发者自己管理 同一 superstep 内激活的节点并行,由 Pregel 引擎调度
灵活性 极高 受图结构约束

3.3.1 Pregel 执行引擎

LangGraph 的执行引擎借鉴了 Google 的 Pregel 图计算模型。Pregel 的原语是"超级步"(superstep):每个超级步中所有被激活的节点默认并行执行,结果汇入共享状态,然后进入下一个超级步。这意味着静态 fan-out——从同一个节点用多条 add_edge 指向多个下游节点——本身就能获得并行执行;Send 原语解决的是另一个问题:动态 map-reduce,即分支数量在运行时才能确定(比如"对列表里的每个元素各起一个节点实例")。状态合并、检查点和重入这些 Pregel 思想,同样内建在 LangGraph 的执行模型中。下面的伪代码为了突出检查点机制,只演示了单节点激活的简化路径:

class PregelExecutor:
    def __init__(self, graph, state):
        self.graph = graph
        self.state = state
        self.checkpoint_store = CheckpointStore()

    async def run(self):
        current_node = self.graph.entry_point

        while current_node != END:
            # 1. 保存检查点(支持中断恢复)
            self.checkpoint_store.save(self.state, step=current_node)

            # 2. 执行当前节点
            node_fn = self.graph.nodes[current_node]
            updates = await node_fn(self.state)

            # 3. 合并状态更新
            self.state = merge_state(self.state, updates)

            # 4. 评估出边,决定下一个节点
            edges = self.graph.get_edges(current_node)
            current_node = evaluate_edges(edges, self.state)

        return self.state

检查点机制是 LangGraph 的杀手级特性之一。当 Agent 执行到一半需要人工审批时(比如确认是否执行一个危险的数据库操作),可以保存当前状态、挂起执行、等待人工介入、然后从断点恢复。这种能力在 while 循环模式下需要大量额外工程来实现。

两种范式的差别不止于"命令式 vs 声明式"——往下挖一层,会看到它们在执行模型上的分歧。

和 LangGraph Pregel 的深度对比

前面介绍了 LangGraph 的状态图范式——这里做一次更深的对比

维度 Claude Code query.ts LangGraph Pregel
抽象单位 turn(一次 LLM 调用 + 工具执行) superstep(图的一个遍历步)
状态演进 单一 state 对象、每 turn 更新 多个 channel 各自版本追踪
并发模型 tool 之间按语义分组并发 节点之间由 pregel 引擎调度
终止 10 种 Terminal tagged union END 节点 + 条件边
可恢复 靠 session storage 存消息 内置 checkpoint + time-travel
侵入性 调用方手写 while 风格 调用方声明 graph
最佳场景 单 agent 高灵活度主循环 多 agent 编排 / 长期工作流

两者没有"谁更好"——是两种范式解决不同问题

  • Claude Code 的选择——单用户交互式 CLI——query.ts 的命令式风格更契合
  • LangGraph 的选择——企业多步工作流——状态图 + checkpoint 更契合

读过《LangGraph 设计与实现》中关于 Pregel streaming 与设计模式的相关章节——会发现 LangGraph 的 Pregel 模型正是针对"长时间运行 + 多节点协作"优化的——和 query.ts 的设计目标互补、不冲突。

3.4 循环终止:最被低估的工程问题

无论是 Claude Code 的 while 循环,还是 LangGraph 的状态图,每次迭代结束时都要做一个判断:是继续循环,还是停下来给出最终答案?这个判断就是循环终止。一个失控的 Agent Loop 能在几分钟内烧掉几十美元的 API 费用,更糟糕的是可能执行数百次无意义的工具调用对外部系统造成副作用。循环终止条件的设计,是 Agent Loop 中最被低估的工程问题。

flowchart TD
    L["循环迭代完成"] --> C1{"模型停止调用工具?"}
    C1 -->|是| T1["✅ 自然终止"]
    C1 -->|否| C2{"迭代次数 ≥ 上限?"}
    C2 -->|是| T2["⛔ 强制终止<br/>(max iterations)"]
    C2 -->|否| C3{"Token 预算耗尽?"}
    C3 -->|是| T3["⛔ 预算终止<br/>(token budget)"]
    C3 -->|否| C4{"墙钟超时?"}
    C4 -->|是| T4["⛔ 超时终止<br/>(wall time)"]
    C4 -->|否| C5{"检测到死循环?"}
    C5 -->|是| T5["⛔ 熔断终止<br/>(circuit breaker)"]
    C5 -->|否| N["继续下一轮迭代"]
    style T1 fill:#dcfce7,stroke:#22c55e
    style T2 fill:#fee2e2,stroke:#ef4444
    style T3 fill:#fee2e2,stroke:#ef4444
    style T4 fill:#fee2e2,stroke:#ef4444
    style T5 fill:#fee2e2,stroke:#ef4444

3.4.1 终止条件的完整清单

一个生产级的 Agent Loop 至少需要以下终止条件:

function checkTermination(context: LoopContext): TerminationReason | null {
  // 1. 自然终止:模型不再调用工具
  if (context.lastResponse.stopReason === "end_turn") {
    return { reason: "natural", message: "模型认为任务完成" };
  }

  // 2. 迭代上限
  if (context.iterationCount >= context.maxIterations) {
    return {
      reason: "max_iterations",
      message: `达到最大迭代次数 ${context.maxIterations}`,
    };
  }

  // 3. Token 预算耗尽
  if (context.totalTokensUsed >= context.tokenBudget) {
    return {
      reason: "token_budget",
      message: `Token 使用量 ${context.totalTokensUsed} 超出预算`,
    };
  }

  // 4. 时间超限
  if (Date.now() - context.startTime >= context.timeoutMs) {
    return {
      reason: "timeout",
      message: `执行时间超过 ${context.timeoutMs / 1000} 秒`,
    };
  }

  // 5. 循环检测:连续相同的工具调用
  if (detectRepeatingPattern(context.toolCallHistory)) {
    return {
      reason: "loop_detected",
      message: "检测到重复的工具调用模式",
    };
  }

  // 6. 致命错误累积
  if (context.consecutiveErrors >= context.maxConsecutiveErrors) {
    return {
      reason: "error_threshold",
      message: `连续 ${context.consecutiveErrors} 次错误`,
    };
  }

  return null; // 继续执行
}

3.4.2 循环检测的工程实现

循环检测值得单独讨论。最简单的方法是检查连续 N 次工具调用是否完全相同:

function detectRepeatingPattern(
  history: ToolCall[],
  windowSize: number = 3
): boolean {
  if (history.length < windowSize * 2) return false;

  const recent = history.slice(-windowSize);
  const previous = history.slice(-windowSize * 2, -windowSize);

  // 比较最近的 N 次调用和之前的 N 次调用是否相同
  // 注意:JSON.stringify 对键顺序敏感;生产环境应使用稳定的深度比较或语义归一化
  return recent.every(
    (call, i) =>
      call.name === previous[i].name &&
      JSON.stringify(call.input) === JSON.stringify(previous[i].input)
  );
}

但实际中更常见的是"语义循环"——模型不断尝试略有不同的参数调用同一个工具,每次都失败。对付这种情况需要更高级的策略:

function detectSemanticLoop(
  history: ToolCall[],
  windowSize: number = 5
): boolean {
  if (history.length < windowSize) return false;

  const recent = history.slice(-windowSize);

  // 统计最近 N 次中同一个工具被调用的比例
  const toolCounts = new Map<string, number>();
  for (const call of recent) {
    toolCounts.set(call.name, (toolCounts.get(call.name) || 0) + 1);
  }

  // 如果同一个工具占了 80% 以上的调用,且都返回了错误
  for (const [toolName, count] of toolCounts) {
    if (count / windowSize >= 0.8) {
      const recentResults = recent
        .filter((c) => c.name === toolName)
        .map((c) => c.result);
      const allFailed = recentResults.every((r) => r.is_error);
      if (allFailed) return true;
    }
  }

  return false;
}

知道了该在什么时候停,还有一个问题:停下来这件事本身,怎么表达

Terminal 类型的工程哲学

§3.9 会列出 Claude Code 的十种终止原因,它们在类型层面不是十个字符串常量,而是一个 tagged union:

type Terminal =
  | { reason: 'completed' }
  | { reason: 'max_turns'; turnCount: number }
  | { reason: 'model_error'; error: Error }
  | { reason: 'blocking_limit' }
  // ... 其余 6 种

为什么不用"一个 string 加一个 any 附加字段"这种更省事的写法?三个理由,一个比一个实在。

第一,TypeScript 能逐一校验每种终止携带的字段。 max_turns 必须带 turnCountmodel_error 必须带 error——这些约束写在类型里,而不是靠文档和自觉。忘了带,编译期就过不去。

第二,switch-exhaustiveness 检查让你永远不漏分支。 将来新增一种终止原因时,所有 switch 到 Terminal 的地方都会被编译器点名,要求你补上对应处理。这一条在 Agent 这种"终止路径会持续增加"的系统里尤其值钱:终止原因从 4 种长到 10 种是必然的,而每一次增长都不该靠人肉去搜哪里需要跟着改。

第三,序列化不丢信息。 整个 Terminal 可以直接 JSON 化写进 trace,事后复盘时 turnCounterror 这些上下文都还在,不需要再从日志里拼。

这套思路和 Rust 的 enum Result<T, E>、Haskell 的 ADT 同源——用类型把"可能的状态"穷举出来,同时保证每种状态携带它该有的数据Terminal 就是这个模式在 Agent 工程里的一个生产案例。

回头看本节开头那个只用 string 的 TerminationReason:如果你的循环里还是那种写法,这就是最该优先偿还的一笔技术债。

前面的终止条件都是循环自己定的。生产环境里还需要一个口子,让使用者插进来说"这次别停"或"这次必须停"。

stopHookActive 和 Stop hook:让用户扩展终止策略

queryLoop 的 state 里还有一个 stopHookActive,它服务的是 Stop hook——用户在 settings 里注册的一条命令,会在"模型打算停下来"这一刻被调用,从而拿到干预的机会。

按快照里的 src/query/stopHooks.ts 与公开 hooks 文档,这个 hook 的返回值有三种走向。

最简单的一种是放行:hook 以 exit 0 正常退出,循环照常结束。

第二种是阻止停止。hook 以 exit code 2 退出,或者输出 {"decision": "block", "reason": "..."},Harness 就会把这个 reason 包装成一条 meta user 消息追加进对话(也就是 handleStopHooks 返回的 blockingErrors),然后带着 transition: 'stop_hook_blocking' 让循环继续跑。关键在于模型是能看到这条 reason 的——它不是被无声地拒绝了停止,而是被告知了"为什么还不能停",于是可以接着往下干活。

第三种是强制终止整个 turn,对应输出里的 "continue": false。当 preventContinuation 为 true 时,循环不再继续,直接返回 { reason: 'stop_hook_prevented' }

stopHookActive 这个状态位解决的则是一个很容易被忽略的死循环风险。它会作为 stop_hook_active 传回给 hook,告诉后者:"这一次的停止请求,已经是被你 block 过之后的再一次停止了。" hook 据此就能选择放行,而不至于陷入 block → 继续 → 又 block 的无限往复——任何允许外部代码否决系统决定的扩展点,都必须给外部代码一个知道"我已经否决过一次"的办法

这一处和第 11 章§11.17 讨论的 pre/post compact hooks 是同一套设计:hook 是"可扩展性"的统一接口。Agent 的每个关键阶段都应该留出 hook 点,让使用者能插手自己的策略,而不必去改平台代码。

3.5 错误处理:让循环具备韧性

终止条件回答"什么时候停下来",错误处理回答"出错了怎么办"。在生产环境中,Agent Loop 中的每一步都可能失败:工具可能超时,API 可能限流,模型可能返回畸形的 JSON。一个健壮的循环需要在不同层次处理错误。

3.5.1 工具级错误处理

最常见的错误发生在工具执行阶段。关键原则是:不要让工具的失败终止循环,而是把错误信息交给模型,让模型决定下一步。

async function executeToolSafely(
  toolName: string,
  toolInput: unknown
): Promise<ToolResult> {
  try {
    // 设置单个工具的执行超时
    const result = await withTimeout(
      executeTool(toolName, toolInput),
      TOOL_TIMEOUT_MS
    );

    return { content: result, is_error: false };
  } catch (error) {
    if (error instanceof TimeoutError) {
      return {
        content: `工具 ${toolName} 执行超时(${TOOL_TIMEOUT_MS / 1000}秒)。` +
          `请尝试减小操作范围或使用其他方式。`,
        is_error: true,
      };
    }

    if (error instanceof PermissionError) {
      return {
        content: `权限不足:${error.message}。该操作需要用户确认。`,
        is_error: true,
      };
    }

    // 未知错误:返回有用的错误信息,但不暴露内部细节
    return {
      content: `工具 ${toolName} 执行失败:${error.message}。` +
        `你可以尝试不同的参数或使用其他工具。`,
      is_error: true,
    };
  }
}

注意错误消息的措辞。我们不只是说"失败了",而是给模型提供行动建议:"请尝试减小操作范围"、"你可以尝试不同的参数"。这种设计让模型有机会自我纠正,而不是在相同的错误上反复碰壁。

3.5.2 模型级错误处理

模型本身也可能出错——返回无效的工具调用、产生幻觉工具名、或者 API 请求失败:

async function queryModelWithRetry(
  messages: Message[],
  tools: Tool[],
  maxRetries: number = 3
): Promise<ModelResponse> {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await queryModel(messages, tools);
      return response;
    } catch (error) {
      lastError = error;

      if (error instanceof RateLimitError) {
        // 限流:指数退避重试
        const backoffMs = Math.min(1000 * 2 ** attempt, 30000);
        await sleep(backoffMs);
        continue;
      }

      if (error instanceof OverloadedError) {
        // 服务过载:等待更长时间
        await sleep(5000 * (attempt + 1));
        continue;
      }

      // 其他错误不重试
      throw error;
    }
  }

  throw new MaxRetriesError(`模型调用失败 ${maxRetries} 次`, lastError);
}

还有一类错误不发生在网络层:模型可能幻觉出一个不存在的工具名。处理它的正确位置不在重试逻辑里,而在工具执行阶段——不终止循环,而是把错误包装成 is_error 的 tool_result 反馈给模型,让它在下一轮看到"这个工具不存在"后换一个工具重试:

async function executeToolWithValidation(
  toolUse: ToolUse,
  tools: Tool[]
): Promise<ToolResult> {
  if (!tools.some((t) => t.name === toolUse.name)) {
    // 模型幻觉了一个不存在的工具——照常生成 tool_result,
    // 随本轮其他结果一起追加进 messages,循环继续
    return {
      type: "tool_result",
      tool_use_id: toolUse.id,
      content: `工具 "${toolUse.name}" 不存在。可用工具:${tools.map((t) => t.name).join(", ")}`,
      is_error: true,
    };
  }
  return executeToolSafely(toolUse.name, toolUse.input);
}

这里有一个细微但重要的区别:网络层错误(限流、过载)通过重试处理,逻辑层错误(幻觉工具名)通过反馈处理。 重试是对外部环境的容错;反馈是对模型行为的纠正。把它们混为一谈是很多 Agent 系统不稳定的根源。

不是所有异常都该按错误处理。有一类看起来像失败,其实是"话没说完"。

截断不是错误:maxOutputTokensRecoveryCount

query.ts 的 state 里有个不起眼的字段 maxOutputTokensRecoveryCount,它背后是生产环境里很常见、却经常被当成"随机故障"的一类问题。

现象是这样的:模型的响应过长时,API 并不报错。它返回 200,stop_reasonmax_tokens——响应被静默截断,一个 tool_use 的参数可能只写到一半。Claude Code 在流层把这种情况合成为内部的 max_output_tokens 错误消息(快照 claude.ts:当 stopReason === 'max_tokens' 时调用 createAssistantAPIErrorMessage({ apiError: 'max_output_tokens' }))。

最直觉的处理是把它当成一次失败直接上报。但这既浪费了模型已经写出来的那大半内容,也没解决问题——下次多半还会撞上同一堵墙。

Claude Code 在 query.ts 的 recovery 分支里做的是两级处理。第一级是一次性升档:如果本轮用的是较小的默认输出上限,就把 max_tokens 直接提到 64K(ESCALATED_MAX_TOKENS = 64_000)重发同一个请求,并标记 transition 为 max_output_tokens_escalate。这个升档每个 turn 只做一次,避免变成无限加码。

第二级是有限次续写。升档之后仍然截断,说明这次任务的输出量本身就超出了单次响应能承载的规模,于是转入恢复流程:注入一条 meta user 消息("Output token limit hit. Resume directly…"),让模型从截断处接着往下写,同时把剩余工作拆得更小。maxOutputTokensRecoveryCount 就是这个流程的计数器,上限是 3 次(MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3);三次用尽仍未完成,才把错误抛给用户。

合起来看,这是"升档一次 + 有限次续写"的策略:比写死一个 max_tokens 更能适应任务规模的波动,又比无限重试省钱、有边界。

值得带走的是这条通则:任何来自上游的资源限制,都值得用"渐进放宽 + 上限兜底"来应对——既不是一次失败就罢手,也不是拼命重试到天亮。本质上,它和指数退避是同一种思路在不同维度上的应用。

前面对所有可重试错误用了同一条退避曲线。真实系统不能这么省事。

categorizeRetryableAPIError:重试策略的真相

QueryEngine.ts 的错误处理模块引用了一个 categorizeRetryableAPIError,作用是把 API 错误分派到不同的 retry 策略上。它之所以必须存在,是因为"可重试"这三个字掩盖了太多差别:限流要等多久由服务端说了算,5xx 值得多试几次,而 4xx 再试一万次也是同样的结果。

真实的分类如下:

错误类型 HTTP 重试策略
rate_limit_error 429 指数退避,但 Retry-After header 优先
overloaded_error Anthropic 特有 固定等待 10 秒,最多 3 次
api_error 5xx 指数退避,最多 5 次
invalid_request_error 4xx(非 429) 不重试,直接报错
authentication_error 401 不重试,刷新 token 后再调
permission_error 403 不重试,交给用户决策

对照这张表就能看出,§3.5.2 里"全部走指数退避"的写法是教学用的简化版:它对 429 太急(忽略了服务端已经告诉你该等多久),对 401 又太蠢(token 过期了,退避再久也不会自己变好)。

所以下次动手写 retry 层时,先把这张"错误码 → 重试策略"的表列出来,再去写代码。表比散落在函数里的 if-else 更容易维护,也更容易在评审时被人一眼看出漏了哪一类。

3.6 压缩与恢复:另一种「终止 → 继续」

终止不总是终点。§3.4 讨论的那些终止条件里,有几种其实是"暂停"——循环停下来,做一件必须做的事,然后从断点继续。上下文压缩就是最典型的一种:历史太长装不下了,把它压缩成摘要,再接着跑。

这件事看起来属于上下文工程(第 11 章讲得更细),但它有一半属于循环——因为压缩会打断循环,而打断之后怎么接回来,是循环自己的责任。本节讲的就是这后半截:什么时候被迫压缩、压缩之后预算怎么算、哪些字段在压缩中会失效、以及怎么把消息重新拼回可以继续的样子。

reactive_compact_retry:反应式压缩的秘密

正常情况下,压缩是主动发生的:shouldAutoCompact() 在每轮循环里估算上下文用量,快到上限时提前压一次(第 11 章会讲它的判据)。这条路径叫 proactive compact,它的好处是用户完全感知不到——压缩发生在请求发出之前(第 11 章§11.2 讲的 13K buffer 就是干这个的)。

但主动预判总有失手的时候。最典型的情况是某一轮的 tool_result 意外地大:一个 grep 命中了上万行,一个文件读取返回了几 MB,这些内容在 shouldAutoCompact() 做判断的那一刻还不存在。于是循环带着一份已经超限的历史把请求发了出去,API 直接以 prompt_too_long 拒绝。

这时就需要第二条路径——reactive compact,撞墙之后的补救。Claude Code 的处理是:捕获 prompt_too_long 错误,调用 reactiveCompact 模块压缩历史,记下一次状态转移(state transition){ reason: 'reactive_compact_retry' },然后回到循环开头重跑这一轮。从循环的角度看,这是一次完整的"终止 → 恢复":本轮没能走完,但循环没有退出,而是换了一份更短的历史重新来过。

两条路径缺一不可,而且原因是对称的。只有 proactive,就挡不住上面那种长尾场景——判断时看不见的东西,判断不了;只有 reactive,那么每一次逼近上限都要让用户先等一次失败、再等一次重试,压缩从"无感"变成"每次都卡一下"。proactive 负责常规节奏,reactive 负责兜底,组合起来循环才既顺畅又不会被撑爆。

压缩接回来了,但有个东西没接回来:账。

Token Budget 跨 compact 的会计难题

queryLoop 的状态里有一个不太起眼的字段 task_budget.remaining,它解决的是压缩带来的一个副作用:压缩会让服务端算错账

源码注释把原因讲得很清楚(据源码快照中的注释):

task_budget.remaining tracking across compaction boundaries. Undefined until first compact fires — while context is uncompacted the server can see the full history and handles the countdown from {total} itself. After a compact, the server sees only the summary and would under-count spend; remaining tells it the pre-compact final window that got summarized away.

拆开来说是这样:压缩之前,服务端能看到完整历史,所以"你已经花掉多少 token"它自己就能算,客户端不用管;压缩之后,服务端看到的只剩一份摘要,它会以为这场对话才刚开始,于是把已经花掉的部分漏计。要补上这个缺口,客户端必须主动告诉服务端——摘要之前我已经用掉了 X,请从 total − X 开始倒数。这就是 remaining 这个字段存在的全部理由:它不是"还剩多少"的缓存,而是一次 checkpoint 的补丁

这个问题并不是 Agent 特有的。两端各自累加、其中一端在某个时刻做了 checkpoint、另一端必须把 checkpoint 之前的量补回来——这是分布式计数的经典形态,和 Paxos / Raft 里的 snapshot_lag 同源。Agent 的经济会计只是从数据库工程师的工具箱里借了一招。

对读者的启示也就落在这里:凡是跨模块累加的数字——token、成本、重试次数、延迟——都要问一句,压缩、快照、重启之后,这个累积值靠什么传下去。答不上来,数字迟早会漂。

消息能重建,不代表每个字段都还有效。

stripSignatureBlocks 和 thinking 签名

Anthropic 的 extended thinking 模式下,模型返回的 thinking block 会带一个 cryptographic signature,用来防止后续请求里回放被篡改过的 thinking 内容。但这个签名不是无条件有效的——它绑定在生成它的凭证和模型上,换了任何一个,签名就失效了。

query.ts 引用的 stripSignatureBlocks(快照 src/utils/messages.ts)处理的正是这件事。它把 assistant 消息里带签名的 block(thinking、redacted_thinking 等)整块剥掉,而且只在签名注定失效的两种场景下调用。

第一种是凭证变更之后。用户执行 /login 换了 API key,源码注释写得很直白:签名绑定生成它的 key,换凭证之后回放会被 API 以 400 拒绝——所以登录成功时对历史消息统一剥离。第二种是模型 fallback 重试之前。thinking 签名同样绑定模型,把 A 模型产出的 thinking 回放给降级后的 B 模型也会 400,所以在 query.ts 的 fallback 分支里,重试前先从实际要发送的 messagesForQuery 中剥离。

有一个方向值得注意:剥离发生在真正要发送出去的消息上,而不是对历史做一次性的破坏。正常轮次里签名是原样回传的——缺了签名模型反而拒绝处理;只有在签名已经注定无效时,才连同 thinking 一起整块丢弃。

这是"协议层字段"的一种典型处理方式。签名有自己的有效域(凭证 × 模型),而这个有效域决定了一条消息还能不能被回放。 设计 API wrapper 的时候,得知道手上哪些字段是带"有效期"的。

知道了哪些要剥掉,剩下的问题就是按什么顺序拼回去。

buildPostCompactMessages:接回 reactive compact

压缩产出摘要之后,循环需要一份可以继续跑的 messages 数组。query.ts 引用的 buildPostCompactMessages(快照 compact.ts)就是干这件事的,它只接收一个参数:

export function buildPostCompactMessages(result: CompactionResult): Message[] {
  return [
    result.boundaryMarker,
    ...result.summaryMessages,
    ...(result.messagesToKeep ?? []),
    ...result.attachments,
    ...result.hookResults,
  ]
}

CompactionResult 的几个字段各有分工。boundaryMarker 是一条 "compact boundary" system 消息,让模型明确看到"过去被压缩了、这里是分界";summaryMessages 是压缩产出的摘要(摘要本身的结构见第 11 章§11.5);messagesToKeep 是可选的、保留原文的近期消息段——注意它不是写死的"最近 N 条",保留哪一段由具体的压缩路径自己决定;attachmentshookResults 则是压缩期间产生的附件与 hook 结果,固定排在最后。

函数本身只做一件事:把所有压缩路径的输出统一成同一种排列顺序(boundary → summary → keep → attachments → hooks)。源码注释的原话是 "This ensures consistent ordering across all compaction paths"。压缩可以有多条路径,但循环只认一种消息布局——这个函数就是那道收口。

把本章和第 11 章合起来读,"压缩 → buildPostCompactMessages → 继续 loop"这条链路就完整了。

3.7 并发:单次迭代内的并行执行

终止条件保证循环"正确"结束,错误处理保证循环"韧性",并发则关乎循环的"效率"。Claude Code 支持模型在一次响应中返回多个工具调用,并且并行执行它们。这个能力在实际使用中非常重要——想象模型需要读取 5 个文件来理解一个 bug,如果串行读取需要 5 次 IO,并行只需要 1 次。

但并行执行带来了新的工程问题:

async function executeToolsConcurrently(
  toolUses: ToolUse[],
  concurrencyLimit: number = 10
): Promise<ToolResult[]> {
  // 使用信号量控制并发数;Semaphore 可用 async-mutex 等库实现,或自行封装
  const semaphore = new Semaphore(concurrencyLimit);
  const results: ToolResult[] = [];

  const promises = toolUses.map(async (toolUse, index) => {
    await semaphore.acquire();
    try {
      const result = await executeToolSafely(toolUse.name, toolUse.input);
      results[index] = {
        type: "tool_result",
        tool_use_id: toolUse.id,
        content: result.content,
        is_error: result.is_error,
      };
    } finally {
      semaphore.release();
    }
  });

  // 使用 allSettled 而非 all——一个工具失败不应阻塞其他工具
  await Promise.allSettled(promises);

  return results;
}

关键设计点:

  1. 并发上限:不设上限可能导致系统资源耗尽。Claude Code 默认限制为合理的并发数。
  2. allSettled 而非 allPromise.all 在任一 Promise 失败时就会 reject。但工具 A 的失败不应该影响工具 B 的结果。allSettled 确保所有工具都有机会完成。
  3. 结果顺序保持:通过 results[index] 保证结果数组的顺序与工具调用数组一致,这对模型理解结果至关重要。

3.7.1 工具间的依赖与冲突

并行执行还要考虑工具之间的语义冲突。比如模型同时调用"写入文件 A"和"读取文件 A"——执行顺序会影响结果的正确性。

Claude Code 的策略是简洁的:信任模型。 如果模型同时发出了两个工具调用,它应该对并行执行有预期。在实践中,模型几乎总是在同一批次中发出无依赖的并行请求(比如同时读取多个不同的文件),很少出现冲突情况。

但如果你在构建自己的 Agent 系统且无法完全信任模型的判断,可以引入工具冲突检测:

function partitionByConflict(toolUses: ToolUse[]): ToolUse[][] {
  const groups: ToolUse[][] = [];
  const resourceLocks = new Map<string, number>();

  for (const toolUse of toolUses) {
    const resources = getAffectedResources(toolUse);
    let conflictGroup = -1;

    for (const resource of resources) {
      if (resourceLocks.has(resource)) {
        conflictGroup = Math.max(conflictGroup, resourceLocks.get(resource)!);
      }
    }

    const targetGroup = conflictGroup + 1;
    if (!groups[targetGroup]) groups[targetGroup] = [];
    groups[targetGroup].push(toolUse);

    for (const resource of resources) {
      resourceLocks.set(resource, targetGroup);
    }
  }

  return groups; // 组内并行,组间串行
}

上面那段并发代码是教学用的简化版。真实实现要处理的情况更多。

并发执行的真实细节:runTools 内部

前面给的 executeToolsConcurrently 是教学用的简化版,它假设"一轮里的工具都能并行"。Claude Code 的实际行为(快照 src/services/tools/toolOrchestration.ts)要谨慎得多:并发不是一个全局开关,而是逐个工具问出来的结论

判断的入口是每个工具都要实现的 isConcurrencySafe()runTools 拿着这个标记,把一轮里的 tool_use 序列切成若干批:连续的 concurrency-safe 工具合并成一批并行执行,非 safe 的工具各自单独成批,批与批之间仍然按模型给出的原始顺序依次执行。也就是说,顺序语义没有被并发破坏——只是把其中安全的相邻片段压平了。

具体到几类工具,结论是清晰的。Read、Grep、Glob 这类只读工具是 concurrency-safe 的,可以并行,并行批的并发上限默认为 10(由 getMaxToolUseConcurrency 决定,可用 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 环境变量覆盖)。Edit、Write、Bash 这类会改变状态的工具则不是 safe,只能串行——否则两次写入同一个文件就会撞成 file state 竞态。

MCP 工具的处理最能体现这套设计的谨慎:它是否并行取决于工具自己声明的 readOnlyHint annotation(mcp/client.ts 里写着 isConcurrencySafe() { return tool.annotations?.readOnlyHint ?? false })。没有声明 read-only 的一律按非 safe 串行处理——默认值是 false 而不是 true,这是一个把"不知道"当成"不安全"的选择。

还有一处细节容易被忽略:所有工具共享同一个 AbortController。用户按下 Ctrl-C 时,正在跑的每一个工具会同时收到 abort 信号,而不是等当前这批跑完再停。

这种"按类别分组并发"比无脑 Promise.all 更贴近真实的工具语义:读文件之间不会互相干扰,写文件之间可能干扰,而这件事 Harness 层是知道的——让它来判断,比把安全性寄托在模型每次都恰好按正确顺序发起调用要可靠得多。

并发执行还有一类特殊的"工具"——它并不真的执行什么。

SYNTHETIC_OUTPUT_TOOL_NAME 的用途

QueryEngine.ts 里引用的 SYNTHETIC_OUTPUT_TOOL_NAME 指向一个"合成工具":模型从来没有真正调用过它,是 Harness 伪造了一次 tool_use。

它解决的是"结构化输出"这个老问题。当调用方需要 Agent 返回一份 JSON 而不是一段自然语言时,Claude Code 的做法不是在 prompt 里恳求模型"请只输出 JSON",而是给它一个虚拟工具 output(result: JSON),让它以 tool_use 的形式把结果交出来。这个 tool 不会真的执行,Harness 直接把调用参数当成最终输出返回给用户。

绕这一圈换来三样东西。其一是复用了 tool_use 已有的 schema 机制——参数结构由 schema 约束和校验,不必再单独写一个 JSON validator。其二是模型的输出天然就是结构化的——不需要从 markdown 的 json 代码块里把内容抠出来再解析,也就没有"模型多写了一句解释导致解析失败"这类问题。其三是给调用方的 API 干净:拿到的是 { status: 'completed', output: {...} },而不是 { output: "一段夹着 json 代码块的 markdown 字符串" }

这是"借协议实现功能"的一记妙手:tool_use 原本是为"调用外部工具"设计的,Claude Code 把它扩展成了"强制结构化输出"的通道——同一套 API,两种用法,而且第二种用法不需要模型端做任何配合

第 18 章《评估与测试》讨论过 structured output enforcement,本章这个 synthetic tool 就是那一章所说的实现路径之一。

3.8 心跳机制:让用户知道"我还活着"

正确、高效地执行循环还不够——如果用户看不到进展,再好的 Agent 也会让人焦虑。Agent 执行复杂任务可能需要几分钟。在这段时间里,用户最大的焦虑来源不是"慢",而是"不知道在干什么"。心跳机制(heartbeat)解决的就是这个问题。

3.8.1 多层级的进度反馈

一个完善的心跳系统应该提供多个层级的信息:

interface HeartbeatSystem {
  // 第一层:循环级别——当前是第几轮迭代
  onIterationStart(iteration: number, totalEstimate?: number): void;

  // 第二层:工具级别——正在执行什么工具
  onToolStart(toolName: string, toolInput: unknown): void;
  onToolEnd(toolName: string, result: ToolResult): void;

  // 第三层:文本级别——模型正在生成什么内容
  onTextDelta(text: string): void;

  // 第四层:元信息——资源消耗情况
  onMetrics(metrics: {
    tokensUsed: number;
    elapsedMs: number;
    toolCallCount: number;
  }): void;
}

Claude Code 的实现是终端 UI 驱动的。每次工具开始执行时,界面上会显示一个旋转的 spinner 和工具名称;执行完成后显示结果摘要。模型生成的文本则实时流式输出。这看起来是 UI 细节,但它本质上是 Agent Loop 的一部分——反馈回路不只包括给模型的反馈,也包括给用户的反馈。

3.8.2 进度估算

心跳的一个进阶需求是进度估算。用户不仅想知道"在干什么",还想知道"大概还要多久"。

class ProgressEstimator {
  private history: { taskType: string; iterations: number; durationMs: number }[] = [];

  estimateProgress(
    currentIteration: number,
    taskType: string
  ): { percent: number; remainingMs: number } | null {
    // 基于历史数据估算
    const similar = this.history.filter((h) => h.taskType === taskType);
    if (similar.length < 3) return null; // 数据不足,不估算

    const avgIterations = similar.reduce((s, h) => s + h.iterations, 0) / similar.length;
    const avgDuration = similar.reduce((s, h) => s + h.durationMs, 0) / similar.length;

    const percent = Math.min((currentIteration / avgIterations) * 100, 95);
    const remainingMs = Math.max(
      avgDuration * (1 - currentIteration / avgIterations),
      0
    );

    return { percent, remainingMs };
  }

  recordCompletion(taskType: string, iterations: number, durationMs: number): void {
    this.history.push({ taskType, iterations, durationMs });
    // 只保留最近 100 条记录
    if (this.history.length > 100) this.history.shift();
  }
}

注意 Math.min(..., 95) 这个细节。永远不要告诉用户"99% 完成"然后让他们再等 2 分钟——这比不给进度条更让人抓狂。上限设为 95%,直到真正完成才跳到 100%。

Heartbeat 不只是 UX——也是安全信号

前面讨论 heartbeat 一直是从 UX 角度出发的:让用户看到进展。但它还有第二个读者——系统自己。heartbeat 同时是"这个 Agent 没有卡死"的证据。

生产环境里,Agent 偶尔会陷入一种最难查的状态:请求发出去了,然后永远不返回。原因可能是网络僵死,也可能是模型侧的内部 deadlock。这种僵尸态的麻烦之处在于它没有任何错误——没有 heartbeat,你根本不知道它已经不动了,只会看到一个"还在运行中"的任务挂在那里。

工程上的做法是给 heartbeat 加一层用途。每次 tick 除了更新 UI,还顺手 touch 一个"最后活跃时间戳";外层的看门狗(watchdog)定期扫这个时间戳,连续 N 分钟没有活跃就强制 abort。abort 的同时上报一个 zombie_detected 事件,这样 trace 里就能直接算出"zombie rate"这个指标——僵尸态从"偶尔听说"变成了可度量的东西。

所以 heartbeat 是有双重职责的:对人是反馈,对系统是 liveness 信号。少了任何一个都不算完整——只做 UI 的心跳救不了僵尸进程,只做 watchdog 的心跳则让用户继续对着空白屏幕等待。

进度要发出去,完成也要发出去——而这两件事的语义并不对称。

notifyCommandLifecycle 的设计:完成事件的"非对称"语义

query.ts 循环末尾有一个 for 循环,负责给 consumedCommandUuids 里的每个命令补发一条 completed 通知。这段代码本身很短,值得琢磨的是它上面那条注释:

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.

拆开来说:started 通知在命令开始时立即发出,而 completed 通知只在循环正常结束时才发——抛错、中断、取消这三种情况下,它一条都不会发。

这是有意为之的非对称生命周期,而不是常见的"start/end 必须成对"。下游消费者因此可以靠一个简单的不平衡来统计失败:凡是有 started 却等不到 completed 的,就是失败的那些

和传统的成对设计相比,非对称版本少了一种 failure mode——不会出现"end 事件发了,但命令其实并没有真正完成"这种更难排查的情况。用"事件的缺失"表达失败,比再引入一个 failed 事件更简洁,也更难写错:漏发比误发安全,而这里的漏发恰好就是正确语义。

这是事件 API 设计里的一条小智慧:缺失本身就能承载信息

3.9 完整的生产级 Agent Loop

把 3.2–3.8 的机制组合起来,一个生产级的 Agent Loop 长这样。注意这不是一个从头重写的架构,而是把前面讨论过的终止条件、错误处理、并发执行和心跳反馈拼成一个可运行的骨架:

async function productionAgentLoop(
  userMessage: string,
  context: ConversationContext,
  heartbeat: HeartbeatSystem
): Promise<AgentResult> {
  context.messages.push({ role: "user", content: userMessage });

  const startTime = Date.now();
  let iterationCount = 0;
  // 生产环境应从每轮 API 响应的 usage 元数据累加,这里用局部变量示意
  let totalTokens = 0;

  while (true) {
    iterationCount++;
    heartbeat.onIterationStart(iterationCount);

    // ---- Think:带重试与预处理的模型调用(见 3.2.1、3.5.2)----
    let response: ModelResponse;
    try {
      response = await queryModelWithRetry(
        preprocessMessages(context),
        getPermittedTools(context),
        3 // max retries
      );
      // 实际应从 API usage 字段读取;此处假设 response.usage.total_tokens 存在
      totalTokens += response.usage?.total_tokens ?? 0;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      return {
        status: "error",
        message: `模型调用失败:${message}`,
        iterations: iterationCount,
      };
    }

    // 流式输出模型的文本内容(完整响应版本;生产可接入 3.2.2 的流式处理)
    for (const block of response.content) {
      if (block.type === "text") {
        heartbeat.onTextDelta(block.text);
      }
    }

    context.messages.push({ role: "assistant", content: response.content });

    // ---- 提取工具调用 ----
    const toolUses = response.content.filter(
      (block) => block.type === "tool_use"
    );

    // ---- 检查终止条件(见 3.4)----
    const termination = checkTermination({
      lastResponse: response,
      iterationCount,
      startTime,
      maxIterations: context.config.maxIterations ?? 50,
      timeoutMs: context.config.timeoutMs ?? 600_000,
      totalTokensUsed: totalTokens,
      tokenBudget: context.config.tokenBudget ?? 1_000_000,
      toolCallHistory: context.toolCallHistory,
      consecutiveErrors: context.consecutiveErrors,
      maxConsecutiveErrors: 5,
    });

    if (termination) {
      heartbeat.onMetrics({
        tokensUsed: totalTokens,
        elapsedMs: Date.now() - startTime,
        toolCallCount: context.toolCallHistory.length,
      });
      return {
        status: termination.reason === "natural" ? "success" : "terminated",
        message: termination.message,
        iterations: iterationCount,
      };
    }

    // ---- 边界情况:没有工具调用、但也不是 end_turn ----
    // 例如 stop_reason 为 "max_tokens"(响应被截断)。此时不能继续循环:
    // 否则下面会 push 一条 content 为空数组的 user 消息,API 直接返回 400
    if (toolUses.length === 0) {
      return {
        status: "terminated",
        message: `模型停止但未调用工具(stop_reason: ${response.stopReason})`,
        iterations: iterationCount,
      };
    }

    // ---- Act:并行执行工具(见 3.7)----
    for (const toolUse of toolUses) {
      heartbeat.onToolStart(toolUse.name, toolUse.input);
    }

    const toolResults = await executeToolsConcurrently(toolUses);

    for (let i = 0; i < toolUses.length; i++) {
      heartbeat.onToolEnd(toolUses[i].name, toolResults[i]);
      context.toolCallHistory.push({
        name: toolUses[i].name,
        input: toolUses[i].input,
        result: toolResults[i],
      });

      // 更新连续错误计数(toolResults[i].is_error 的语义见 3.7)
      if (toolResults[i].is_error) {
        context.consecutiveErrors++;
      } else {
        context.consecutiveErrors = 0;
      }
    }

    // ---- Feedback:工具结果回传(见 3.2)----
    context.messages.push({ role: "user", content: toolResults });
  }
}

生产级 Agent Loop 的复杂度:以 query.ts 为例(源码快照约 1729 行)

以下关于 Claude Code 内部实现的细节来自作者可获得的源码快照,公开版本可能有所不同。

本章前面给出的伪代码是 while (true) 加一个终止条件,一百行上下就能讲完。而 Claude Code 的真实实现(src/query.ts)是一个 async generator,1729 行。打开它最直观的感受是:一个看似简单的 "loop",里面藏着十种终止路径、多层 fallback、reactive compact 以及跨边界的 token budget tracking。

核心签名本身已经透露了不少信息:

export async function* query(params: QueryParams):
  AsyncGenerator<StreamEvent | RequestStartEvent | Message | ... , Terminal>
{
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

这里有三个值得记住的决策。

第一,用 async function* 而不是 async function 生成器让每一条 stream event、每一条 message、每一次 transition 都能立即 yield 给调用方,用户不必等一整轮跑完才看到进展——这是前面讲的心跳机制在类型签名层面的体现。

第二,yield* queryLoop(...) 把内部循环委托给另一个生成器。 外层的 query 只负责生命周期管理(比如上面那圈补发完成通知),真正的业务逻辑隔离在 queryLoop 里。两者的边界很干净:外层管"这次调用整体怎么收场",内层管"每一轮怎么跑"。

第三,返回值是 Terminal 而不是 void 循环结束时必须交代它是怎么结束的——下一节列的十种出口,就是这个返回值的全部取值空间。

十种终止路径——一个生产 Agent 的真实"出口"

query.ts 里跑一次 grep -n "return { reason:",能找到 12 处 return 语句;去重之后是 10 种 Terminal reason——completedprompt_too_longimage_error 各有不止一处出口。

reason 触发条件 含义
completed 模型不再调 tool 自然终止(最常见)
max_turns 达到 maxTurns 上限 强制终止
blocking_limit context 使用超过 blocking 限 压缩都来不及了
image_error 图片 validation / resize 失败 输入问题
model_error 模型 API 返回错误 上游故障
aborted_streaming 流式响应期间用户 abort 用户取消
aborted_tools 工具执行期间 abort 用户取消(晚一步)
prompt_too_long API 返回 "prompt is too long: N tokens > M maximum" 压缩已不可挽救
stop_hook_prevented Stop hook 阻止继续 用户扩展
hook_stopped 其他 hook 拦截 用户扩展

还有两个名字很像、却完全不是一回事的 reason:collapse_drain_retryreactive_compact_retry它们不是终止,是 transition(见§3.2)——循环并没有结束,它们只是标记了"这一轮是怎么回到循环开头的"。把 transition 误当成 Terminal 来处理,是读这类源码时最容易犯的错。

这 10 种终止加 2 种重试 transition 合起来说明了一件事:生产级 Agent 的标准不是"能跑就行",而是每一种可能的结束情况都要有名字、有对应处理、有 trace。§3.4 里列的 6 种终止条件只是起步,真实系统会比它复杂得多——而复杂的部分恰恰是最需要被命名的部分。

maxTurns 的精细度

本章前面把 maxTurns 当成一个简单常量来用,Claude Code 的实现要微妙一些(query.ts:1704-1711):

if (maxTurns && nextTurnCount > maxTurns) {
  yield createAttachmentMessage({ type: 'max_turns_reached', maxTurns, turnCount: nextTurnCount })
  return { reason: 'max_turns', turnCount: nextTurnCount }
}

三个细节值得注意。maxTurns 可以是 undefined——内部主循环默认并不设限,是外部调用者(SDK、subagent)才施加上限,这让同一段循环代码能服务于"交互式会话"和"受控子任务"两种场景。yield 一条 max_turns_reached attachment message——让模型和 UI 都明确知道"是因为轮数到了",而不是无声地停下;无声的停止会让模型误以为任务已完成。返回值里带上 turnCount——上层因此能统计"实际跑了几轮",而不是想当然地假设它等于 maxTurns

这种"带情境的终止"比一刀切的 if (i > N) break 强得多:调用方能精确区分"自然完成"和"被限制打断",而这两者在计费、重试和用户提示上的处理完全不同。

query.ts 之上还有一层。循环本身之外的事,都在那里。

QueryEngine.ts 1295 行做的事

src/QueryEngine.ts 是比 query.ts 更上层的封装,1295 行,负责的都是"循环之外"的事:

  • Session 生命周期(创建 / 持久化 / 恢复)
  • 消息预处理processUserInput
  • Permission context 注入
  • Analytics / telemetry hook
  • File state cache 管理(第 15 章讨论过的 FileStateCache
  • Attribution 追踪(第 19 章讨论的 trace attribution)

这两层的关系可以写成一个类比:QueryEngine : query.ts = Web 服务器的 HTTP handler : 业务逻辑函数。一层处理协议、认证、日志这些横切关注点,另一层专心把业务循环跑完。

对读者的启示很直接:生产级 Agent 不会把"循环"和"session 管理"塞进同一个文件。分成两层,各自控制在 2000 行以内,维护成本会低得多——更重要的是,循环那一层可以被独立测试,而混在一起的版本几乎无法测试。

3.10 方法论:从事故模式到 12 诫

前面九节把 Agent Loop 拆开讲了一遍。这一节反过来:把它合起来看,问三个问题——哪些坑是必然会踩的、这些机制之间是什么关系、如果只能记住几条该记哪几条。

顺序是从教训到规则:先看五个真实的事故模式(每一个都对应前面某一节没做到位),再看这一章和上下文、工具、心跳三章如何互相咬合,最后收成 12 条可以直接抄走的规则。

方法论提炼

回顾本章内容,我们可以提炼出关于 Agent Loop 设计的几条核心方法论:

方法论一:终止条件是首要设计对象。 大多数人设计 Agent Loop 时先想"怎么跑起来",但生产系统中最先爆出问题的是"怎么停下来"。你的终止条件清单至少应该包括:自然终止、迭代上限、token 预算、时间超限、循环检测、错误累积阈值。

方法论二:错误分层处理,不要一刀切。 网络层错误用重试解决,逻辑层错误用反馈解决,资源层错误用终止解决。把工具失败的信息交给模型而不是直接抛异常,这是让 Agent 具备自愈能力的关键。

方法论三:心跳不是锦上添花,是基础设施。 对于任何执行时间超过 5 秒的 Agent 操作,都应该有进度反馈机制。流式输出、工具执行状态、迭代计数——这些信息让用户从"等待黑盒"变成"观察同事工作"。

方法论四:并发是性能的乘数,但要有控制。 并行工具执行可以显著缩短端到端延迟,但需要设置并发上限、使用 allSettled 容错、保持结果顺序。在无法确保工具间无冲突时,按资源分组串行执行。

方法论五:循环和状态图是两种等价的表达方式,选择取决于你的需求。 如果需要中断恢复、可视化调试、复杂的分支逻辑,状态图(LangGraph)更合适。如果追求极致的灵活性和最小的抽象开销,while 循环(Claude Code)更直接。没有"更好"的方案,只有"更适合"的方案。

方法论是正着说的。反着说一遍往往更有用——下面五个事故模式,每一个都对应上面某条方法论没做到位。

五个常见的 Agent Loop 事故模式

第一种是用 Promise.all 代替 allSettled 一批工具并行执行,只要其中一个偶发报错,整轮就以拒绝告终。模型因此看不到其他工具已经拿到的成功结果,只能从零重来——一次本可以局部修正的失败,变成了一整轮的重跑,成本和延迟同时翻倍。

第二种是终止条件只有 max_turns 没有 token budget、没有 wall-time、没有 consecutive_errors 计数,那么"陷入死循环但每轮只消耗几千 token"这种场景就无人拦截:它不会触发任何快速失败,只能一直跑到迭代上限才被救下来,而那时往往已经过去几个小时、烧掉数十美元量级的 token。

第三种是工具错误用 throw 而不是 tool_result 异常一抛,模型那边看到的只是一条 assistant 消息突然消失,它不知道发生了什么,自然也无从修正。把错误翻译成 tool_result 回传,模型才有机会换个参数、换个工具、或者告诉用户为什么做不到——这正是"错误分层处理"那条方法论在实现层的落点。

第四种是 heartbeat 只做 UI、不做 watchdog。 用户按下 Ctrl-C 之后,界面上的进度条停了,但底层的僵尸进程没人清理,它会一直沉默地消耗资源,直到 session timeout 才被回收。心跳既然已经在采集"我还活着"的信号,就应该同时用它来判断"谁已经不该活着"。

第五种是信任模型返回的工具名合法。 不做校验直接执行,一旦 API 返回一个并不存在的 tool,异常堆栈就会一路冒泡到用户面前。正确的做法是把它当成一次普通的工具失败:返回 "tool 'X' doesn't exist" 反馈给模型,让它换一个再试。

这五种事故在 Agent Loop 的实现里反复出现,共同点是它们都不需要多高深的技术就能避开——提前知道就绕得开,而绕开它们的成本远低于事后排查

这一章只是四分之一。四件事咬合在一起,Agent 才立得住。

四章打通:loop / context / tool / heartbeat

Agent 运行时不是一个循环就能撑起来的,它至少由四件事咬合而成,而这四件事分散在本专栏的四个章节里。

本章的 Agent Loop 决定的是节奏——心跳多久打一次、什么条件下停下来。第 4 章的上下文工程决定每一次迭代"看到"什么,也就是 OODA 里的观察环节;第 5-7 章的工具设计决定每一次迭代"能做"什么,对应行动环节;第 19 章的可观测性则决定每一次心跳产出的 trace 给谁看、怎么用。

四章单独看各有各的价值,但只有合起来读,才能理解为什么说完整的 Agent Runtime 不是"一个简单的循环":循环只是骨架,观察、行动、可观测性分别是它的眼睛、手和体检报告。

最后把前面所有内容压成一张可以贴在显示器边上的清单。

本章压轴:Agent Loop 的 "12 诫"

下面 12 条可以直接抄走,每一条都对应本章的某一节。它保持清单形态是有意的——它的用途就是逐条打勾。

  1. async function* 而不是 async function(§3.9)
  2. 终止用 tagged union 而不是 string(§3.4)
  3. 终止原因穷尽枚举——Claude Code 有 10 种 terminal reason(§3.9)
  4. max_turns 要 yield max_turns_reached attachment(§3.9)
  5. Proactive compact + reactive compact 必须双轨(§3.6)
  6. 跨 compact 边界要追踪 token budget(§3.6)
  7. 工具执行用 allSettled 不是 all(§3.7 / §3.11)
  8. 工具错误翻译成 tool_result、不抛(§3.5.1)
  9. 并发按语义分组(读并行、写串行)(§3.7)
  10. hook 机制开放给用户扩展(§3.4)
  11. Heartbeat 兼做 liveness watchdog(§3.8)
  12. 模型返回的 tool 名要校验、失败反馈(§3.5.2)

打勾 12 条,这个 Agent Loop 可以进生产;打勾少于 8 条,下一次事故已经在路上了。

3.11 上手:从 20 行到 1729 行

规则读完了,但规则不会告诉你从哪儿动手。这一节给两段可以直接跑的代码:一段 20 行的玩具循环,用来验证你确实理解了这一章讲的骨架;一段偏工程的启动模板,覆盖了 12 诫里的 7 条,剩下 5 条等你的 Agent 长大了再补。

写一个玩具 Agent Loop 的 20 行

把这一章讲过的东西压到最小,一个能跑的 Agent Loop 只需要 20 行。它不是玩具意义上的"能跑"——下面每一行都对应前面讨论过的一个决策:

async function* toyAgentLoop(history: Message[], maxTurns = 10): AsyncGenerator<Message, Terminal> {
  for (let turn = 1; turn <= maxTurns; turn++) {
    const response = await llm.chat(history, { tools })
    history.push({ role: 'assistant', content: response.content })
    yield response
    const toolUses = response.content.filter(b => b.type === 'tool_use')
    if (toolUses.length === 0) return { reason: 'completed' }
    const results = await Promise.allSettled(toolUses.map(tu => executeTool(tu)))
    const toolResultMessage = { role: 'user', content: results.map((r, i) =>
      ({ type: 'tool_result', tool_use_id: toolUses[i].id,
         content: r.status === 'fulfilled' ? r.value : `Error: ${String(r.reason)}`,
         is_error: r.status === 'rejected' })
    ) }
    history.push(toolResultMessage)
    yield toolResultMessage
  }
  return { reason: 'max_turns', turnCount: maxTurns }
}

这 20 行里有四个决策是刻意的。用 async function* 而不是普通 async 函数,是为了让每条 message 都能流式 yield 出去;用 Promise.allSettled 而不是 all,是为了让一个工具的失败不阻塞其余工具;工具错误被翻译成 tool_result 反馈给模型而不是直接抛异常,是为了给模型自我修正的机会;返回 Terminal 而不是 void,是为了让调用方能精确区分完成方式。

四个决策,正好对应前面四节的主题。从这 20 行到 Claude Code 那 1729 行的 query.ts骨架是同一个,中间差的是近百倍的工程加固——而这条演进路径,读到这里你已经能完整读懂了。

玩具版够理解,不够上生产。下面这一版补齐了工程上必须有的部分。

给新手的 5 分钟启动模板

如果你是第一次动手构建 Agent Loop,可以直接从下面这个模板开始。它比上面的玩具版多出的部分,都是工程上绕不过去的:

type Terminal = { reason: 'completed' } | { reason: 'max_turns'; turns: number } | { reason: 'error'; error: Error }

async function* agentLoop(
  messages: Message[],
  tools: Tool[],
  maxTurns = 20
): AsyncGenerator<Message, Terminal> {
  for (let turn = 1; turn <= maxTurns; turn++) {
    try {
      const response = await llm.chat(messages, { tools, stream: true })
      messages.push({ role: 'assistant', content: response.content })
      yield { role: 'assistant', content: response.content }

      const toolUses = response.content.filter(b => b.type === 'tool_use')
      if (toolUses.length === 0) return { reason: 'completed' }

      const results = await Promise.allSettled(
        toolUses.map(tu => executeTool(tu.name, tu.input, { timeout: 30000 }))
      )

      const toolResults = results.map((r, i) => ({
        type: 'tool_result' as const,
        tool_use_id: toolUses[i].id,
        content: r.status === 'fulfilled' ? r.value : `Error: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
        is_error: r.status === 'rejected'
      }))

      messages.push({ role: 'user', content: toolResults })
      yield { role: 'user', content: toolResults }
    } catch (e) {
      return { reason: 'error', error: e as Error }
    }
  }
  return { reason: 'max_turns', turns: maxTurns }
}

这个模板已经覆盖了§3.10 那 12 诫里的 7 条。剩下的 5 条——reactive compact、token budget、hook、heartbeat、tool 名校验——建议等你的 Agent 真正遇到对应的问题时再补,不要一开始就把它们全塞进来。过度工程化在这里代价很实:每一条都会带来状态、分支和测试成本,而它们要解决的问题在小规模下根本不会出现。

从 20 行到 1729 行是一条渐进路径:前期保持 KISS,中期按遇到的问题逐项加固,后期才谈得上全面。这一章讲的就是这条路径上每一站分别在解决什么。

3.12 速查卡与源码锚点

最后是查阅用的东西:一张把本章要点压成一页的速查卡,一张本章引用过的 Claude Code 源码文件清单(方便你自己往下挖),以及几句收尾。

本章速查卡

下面这张表是本章的压缩版,用途是回头查而不是从头读,所以保持清单形态:

  • 主循环async function* queryLoop 1729 行(Claude Code src/query.ts
  • 终止:10 种 Terminal reason(completed / max_turns / model_error / blocking_limit / image_error / prompt_too_long / aborted_streaming / aborted_tools / stop_hook_prevented / hook_stopped)+ 2 种重试 transition(collapse_drain_retry / reactive_compact_retry)
  • Compact 双轨:proactive(autoCompact §3.6)+ reactive(捕获 prompt_too_long 后)
  • Token budget:跨 compact 边界追踪 task_budget.remaining
  • 并发:tool 按语义分组、Read 并行、Write 串行
  • Hook:onStop、onPreCompact、onPostCompact 三大扩展点
  • Heartbeat:UI 反馈 + liveness watchdog 双职责
  • Retry:按错误类型分策略(429 退避、503 固定等、4xx 不重试)
  • 类型:Terminal / StreamEvent / Message 三层 tagged union
  • DCEfeature(...) 条件编译剥离内部代码

想自己往下挖的话,从这些文件开始。

源码锚点速查

本章引用到的 Claude Code 文件都列在下面,行数一并给出,方便估算阅读成本:

话题 源码位置 行数
主循环(async generator) src/query.ts 1729
上层 session 管理 src/QueryEngine.ts 1295
Compact 模块 src/services/compact/* 3960(本专栏第 11 章)
Auto-compact 阈值 src/services/compact/autoCompact.ts 351
Post-compact 重建 src/services/compact/compact.ts:buildPostCompactMessages -
Retry 策略 src/services/api/errors.ts:categorizeRetryableAPIError -
Signature 处理 src/utils/messages.ts:stripSignatureBlocks -
合成输出工具 src/tools/SyntheticOutputTool/* -
Feature 条件编译 bun:bundle.feature() -

尾声

Agent Loop 是 Agent 工程的心跳:心跳稳,Agent 才跑得住。

本章的推进路线是从一个 while 循环的 demo 出发,依次经过十种终止路径、reactive compact、以及跨 compact 边界的 token budget 会计。每往下一层,对应的都是 Claude Code 里一段真实存在的代码——这一章想给的不是"Agent Loop 大概是这样",而是"生产级的 Agent Loop 具体是这样"

所以下一次动手写 Agent Loop 的时候,脑子里应该浮现的是 query.ts 那 1729 行:知道哪里藏着陷阱,知道哪里值得直接抄,也知道 12 诫里每一条是从哪一段代码里长出来的。

下一章要讨论的是这个循环最关键的输入——上下文。如何在有限的上下文窗口里塞进最有用的信息,是决定 Agent 能力上限的核心工程问题,也是这一章反复提到、却一直留给后面细说的那件事。