Skip to content

Latest commit

 

History

History
 
 

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 

README.md

02 — Agent Loop 核心循环

Claude Code 的核心不是“一次回答”,而是一轮又一轮的模型与工具协作

先说一个很重要的区别

很多人会把 QueryEngine.ts 当成完整循环,但这样理解会错过一些关键细节。

更准确的说法是:

  • QueryEngine.ts 负责组织一轮请求——它是会话级的状态管理器
  • query.ts 负责执行真正的循环——它是请求级的执行引擎

打个比方:如果把一次对话比作一场演出,QueryEngine 就像是导演和舞台监督,负责准备道具、安排演员、记录进度;而 query() 则是真正上台表演的演员,负责一幕一幕地推进剧情。

为什么要这样拆分?

职责分离 QueryEngine 管理的是"这次对话的整体状态":消息历史、token 用量、权限拒绝记录、文件状态缓存。这些状态可能跨越多轮请求,需要长期持有。

query() 管理的是"这一轮请求的执行过程":调模型、执行工具、回传结果、判断继续还是停止。这些逻辑是请求级的,每次都是一个完整的生命周期。

可测试性 如果把所有逻辑都塞进一个巨大的类里,测试会变得非常困难。拆分后,你可以单独测试"状态管理是否正确"和"循环执行是否正确"。

可维护性 当你需要修改"如何判断是否继续循环"时,只需要看 query.ts;当你需要修改"如何记录权限拒绝"时,只需要看 QueryEngine.ts。职责清晰,改动范围可控。

这种拆分不是过度设计,而是让一个复杂系统保持可理解的必要手段。

本章关键源码入口

文件 作用
src/QueryEngine.ts 一轮请求的状态管理、参数组装、对外接口
src/query.ts 模型调用、工具执行、继续/终止判断的核心循环
src/utils/queryContext.ts 获取 System Prompt、userContext、systemContext

新手先建立一个直觉

Claude Code 的一次回答,通常不是:

用户提问 -> 模型一次性回答 -> 结束

而更像:

用户提问
  ↓
模型先判断要不要调工具
  ↓
如果要,就请求某个工具
  ↓
Claude Code 执行工具
  ↓
把工具结果再发回模型
  ↓
模型基于新结果继续决定下一步
  ↓
直到模型认为任务完成

这就是 Agent Loop。

QueryEngine 管的是什么

从源码看,一个 QueryEngine 实例大体对应一段对话会话。

它会长期持有这些状态:

  • 消息历史
  • 中断控制器
  • 权限拒绝记录
  • token 用量
  • 文件读取状态缓存
  • 本轮或本会话发现的技能/工具信息

所以它不是单纯的函数,而是一个带状态的会话控制器

submitMessage() 这一轮做了哪些准备

当用户发来一条消息时,submitMessage() 会先做准备工作,再把任务交给 query()

它会先决定本轮的运行参数

例如:

  • 用哪个模型
  • thinking 是用户强制指定,还是默认 adaptive
  • 本轮有哪些工具可用
  • 这轮请求的 prompt 和上下文是什么

它会包装权限检查

源码里有一个非常值得注意的小细节:canUseTool 会被包一层,变成 wrappedCanUseTool

这层包装的作用不是改权限逻辑,而是补充记录:

  • 哪些工具被拒绝了
  • 对应的 tool_use_id 是什么
  • 输入参数是什么

这说明 Claude Code 不只是“问一句允许不允许”,它还在认真记录整轮工具交互的状态。

真正的循环在 query.ts

如果要用一句话概括 query.ts,可以说:

它负责把“调用模型”和“执行工具”拼成一个可以持续推进的循环。

先纠正一个很容易看错的点

query() 本身不是那条长期运转的核心循环。
它更像一层外部包装,真正反复推进的是 queryLoop() 里的 while (true)

这不是命名细节,而是理解整套系统的关键:

  • query() 负责暴露生成器接口
  • queryLoop() 负责维护循环状态
  • 很多 gate 会在进入 queryLoop() 前先被快照

你可以把它理解成下面这条主链路

准备好的 messages + prompt + context
  ↓
调用模型
  ↓
流式收到 assistant 输出
  ↓
如果包含 tool_use:
  - 找到对应工具
  - 检查权限
  - 执行工具
  - 生成 tool_result
  - 把结果追加回消息历史
  - 再次调用模型
  ↓
如果不再包含 tool_use:
  - 输出最终文本
  - 本轮结束

但真实代码比这个简图多了几层重要细节:

  • 发给模型的消息不是原始 transcript,而是先做上下文裁切、压缩视图整理,再做 API 协议归一化
  • tool_use 的识别不是靠 stop_reason === 'tool_use',而是直接扫描 assistant content block
  • tool_result 回流不是隐式发生,而是本地显式重建成下一轮的 user-side message
  • “继续 / 停止 / 压缩”也不是一个二叉判断,而是多层关卡串起来的结果

为什么这套 loop 很关键

因为 Claude Code 的绝大多数聪明行为,都不是 CLI 自己规划出来的,而是模型在每一轮拿到新上下文后临时做的决定。

所以这套 loop 的首要任务不是“替模型思考”,而是:

  • 准确传递上下文
  • 正确执行工具
  • 稳定记录状态
  • 在每一轮之间保持一致性

这里还有一个很重要但新手容易漏掉的事实:

每一轮发给模型的内容,已经被运行时主动整理过一次,而不是把内存里的所有消息原封不动扔进去。

为什么 submitMessage() 要做成 AsyncGenerator

如果只是普通的 async function,那调用方只能“等最终结果”。

AsyncGenerator 的好处是:

  • 模型吐出一点文本,就能立刻显示一点
  • 工具开始执行时,UI 可以实时显示
  • 某一步失败时,不需要等整轮结束才知道
  • 用户可以在中途打断

这和终端产品的体验直接相关。对 CLI 来说,“能流出来”比“最后一次性返回”重要得多。

这一轮里,上下文是怎么被拼进去的

QueryEngine.ts 里,本轮请求并不是只拿用户输入去调用模型。

它会先通过 fetchSystemPromptParts() 拿到三类上下文:

  • defaultSystemPrompt
  • userContext
  • systemContext

这一步很关键,因为 Claude Code 从一开始就不把“上下文”简单理解为聊天记录。它知道不同来源的信息要走不同通道,这样模型才能既有规则,又有环境感知,还能兼顾缓存。

真正进入 API 前,还会再做一层整理

query.tsclaude.ts 的调用链看,进入模型 API 前至少有两层整形:

  1. 循环级整形

    • 裁掉 compact 边界之前不需要直接参与本轮的历史
    • 处理过长的 tool result
    • 结合 snip / microcompact / context collapse 等机制
    • systemContextuserContext 拼进本轮可见输入
  2. 协议级整形

    • normalizeMessagesForAPI()
    • 修复 tool/result pairing
    • 清理某些模型相关的冗余字段
    • 控制媒体与附加内容的发送形状

模型与 thinking 模式是怎么决定的

submitMessage() 还会处理两个基础问题:

1. 本轮用哪个模型

大体是:

  • 如果用户显式指定模型,就优先用显式值
  • 否则回退到会话或默认模型

2. 本轮 thinking 怎么开

源码里默认偏向 adaptive,意思是:

让系统按情况决定是否进入更重的思考模式,而不是每轮都强开。

这本质上是在质量、速度和 token 成本之间做平衡。

为什么 Claude Code 选择顺序执行工具

这是很多人读到这里会问的问题:既然模型可能一次返回多个工具调用,为什么不并发跑?

Claude Code 选择了更保守也更稳定的策略:默认按顺序推进

原因很现实:

  • 前一个工具结果可能决定后一个工具是否还需要执行
  • 顺序执行更容易让模型保持一致的推理链
  • 出错时更容易定位责任点
  • UI 展示和用户理解成本更低

但“顺序执行”不等于“等整条 assistant 响应结束后再想”

在开启流式工具执行时,assistant 流一到达带 tool_use 的 content block,系统就能立刻把它结构化捕获并交给执行器,而不是必须等整条 assistant 响应结束。

并发是怎么引入的

Claude Code 不是完全不能并发,而是把并发放在更高层:

  • 通过 AgentTool 生成子 Agent
  • 通过 TeamCreateTool 管理多 Agent 协作
  • 由每个子 Agent 各自运行独立 loop

也就是说,单个 loop 保持简单,真正需要并行时,用多个 loop,而不是把一个 loop 改成复杂并发引擎。

tool_result 回流是显式闭环,不是魔法

一旦某个工具执行完成,Claude Code 会把工具产物整理成下一轮输入的一部分,而不是让模型“自动知道结果回来了”。

真正的闭环更像这样:

assistant content
  -> 识别 tool_use
  -> 本地执行工具
  -> 生成 tool_result
  -> 重建 messages
  -> 再次调用模型

这一章真正要学会什么

如果你以后要做自己的 Agent CLI,这一章最值得借鉴的是:

1. 把会话状态管理和循环执行拆开

QueryEngine.tsquery.ts 的拆分很重要,它让代码职责更清楚。

2. 用流式接口贯穿全链路

从模型输出、工具执行到 UI 渲染,都能自然串起来。

3. 让循环简单,但让外围信息足够充分

真正复杂的是 prompt、上下文、权限和工具,不是 while 循环本身。

新手常见误区

误区 1:Agent Loop 就是无限 while

不准确。真正难的是每轮之间怎么维护消息、权限、上下文、压缩和错误恢复。

误区 2:QueryEngine 就等于全部业务逻辑

不对。它是组织者,真正执行循环的核心仍在 query.ts

误区 3:实时输出只是 UI 花活

不对。AsyncGenerator 是这类交互式 Agent 工具体验的重要基础。

误区 4:模型是靠 stop_reason 才知道要不要调工具

不对。Claude Code 在本地会直接扫描 assistant content block 里的 tool_use,这比只看 stop_reason 更具体,也更稳定。

本章小结

Claude Code 的 Agent Loop 体现出一个很成熟的产品化思路:

  • 主循环保持直接
  • 会话状态独立维护
  • 工具调用串行推进
  • 结果以流式方式暴露给 UI
  • 并发能力通过多 Agent 层引入,而不是污染单 loop
  • 发给模型的消息每轮都会被主动整理,而不是原样透传
  • tool_use -> tool_result -> 下一轮 messages 是本地显式闭环

下一步