01|把一次用户请求做成可持续执行的回合:主循环才是 Agent 的骨架

7 阅读8分钟

这是「Claude Code 第一性原理拆解」的第 2 篇。
如果说上一篇在回答“Claude Code 到底在解决什么”,那这一篇就只盯住一个核心问题:为什么 Agent 一定会长出主循环。

摘要

这一篇重点不是介绍 query.ts 的所有细节,而是先把主循环的语义抓稳:

  • 对聊天系统来说,一轮常常是一次生成;对 Agent 来说,一轮通常要等动作链条耗尽才结束。
  • Claude Code 的主循环本质上在维护“消息流 + 工具执行 + 状态回流”的统一闭环。
  • QueryEnginequery 的关系,决定了它为什么不是一次性工具调用器。

读完这一篇你应该能回答

  • 为什么“有 tool call 就继续 loop”不是实现细节,而是 Agent 语义本身。
  • 为什么工具结果必须回到同一条消息历史。
  • 为什么工作流图、LangGraph 和显式 query loop 在语义上能互相映射。

如果我要给 Claude Code 选一个最不该被忽视的模块,我会选主循环。

因为我觉得很多人对 Agent 的理解都有一个根深蒂固的误区:

觉得一次用户请求,就是一次模型调用。

而 Claude Code 最值得学的一点,恰恰是它明确反对这个误区。

在它的世界里,一次用户请求更像一个“回合”:

  • 用户说了一次意图
  • 模型可能先回答一段话
  • 然后发起工具调用
  • 工具执行以后,结果又进入同一条消息流
  • 模型继续判断下一步
  • 直到不再需要工具,这一回合才真正结束

这和普通聊天系统差别非常大。

如果一句话概括,我会说:

对聊天系统来说,一轮是“问一次,答一次”;对 Agent 系统来说,一轮是“直到动作链条消耗完为止”。

一、为什么主循环是第一性原理上的必需品

从最底层看,Agent 和聊天系统的差异不在文案,而在控制流。

聊天系统的最小控制流

def chat(user_input):
    prompt = build_prompt(user_input)
    return llm(prompt)

Agent 系统的最小控制流

def agent_turn(user_input):
    messages.append(user_input)
    while True:
        assistant = llm(messages)
        messages.append(assistant)
        if not assistant.tool_calls:
            return assistant
        results = run_tools(assistant.tool_calls)
        messages.extend(results)

真正的变化就在这里:

  • 有没有循环
  • 工具结果是不是回到同一条状态链里
  • 回合结束条件是谁定义的

一旦这三件事变了,系统性质就完全变了。

二、源码主线:Claude Code 是怎么把一轮请求做成回合的

如果我要顺着源码看这条主线,我会抓这几个文件:

  • src/QueryEngine.ts
  • src/query.ts
  • src/constants/prompts.ts
  • src/utils/systemPrompt.ts

这四个文件一起看,才能把完整回合看明白。

1. QueryEngine.ts 解决的是“会话级控制”

QueryEngine 在我眼里不像一个简单的 wrapper,而更像一个:

  • conversation controller
  • turn lifecycle owner

它维护的东西不是单次调用参数,而是整段会话的可持续状态,比如:

  • mutableMessages
  • readFileState
  • abortController
  • permissionDenials
  • totalUsage
  • discoveredSkillNames

这说明作者一开始就把系统想成:

一个会持续积累消息、状态、使用量和边界条件的会话对象。

2. query.ts 解决的是“单回合推进”

query()queryLoop() 才是回合发动机。

它不是“调完模型就结束”的函数,而是一个生成器式循环。
里面会反复处理:

  • 模型输出
  • 工具调用
  • 工具结果回填
  • token budget
  • compact
  • 异常恢复

所以我会把 query.ts 看成:

Agent Turn Executor

3. prompts.tssystemPrompt.ts 解决的是“每回合怎么构造系统语义”

很多人把 system prompt 想成一个静态长字符串。
但 Claude Code 这里明显不是。

它会根据不同模式、不同 agent、不同配置动态构造“本次真正要给模型的系统提示”。

这很关键,因为它说明:

system prompt 在这里不是写作模板,而是运行时策略对象。

三、我会怎么把 Claude Code 的回合控制翻译成 Python 伪代码

我会先写成最粗的骨架:

class QueryEngine:
    def __init__(self, config):
        self.config = config
        self.messages = config.initial_messages or []
        self.read_file_cache = config.read_file_cache
        self.abort_controller = config.abort_controller

    def submit_message(self, prompt):
        self.messages.append(normalize_user_message(prompt))

        system_prompt = build_effective_system_prompt(
            default_prompt=self.config.default_prompt,
            custom_prompt=self.config.custom_prompt,
            agent_definition=self.config.agent_definition,
        )

        for event in query_loop(
            messages=self.messages,
            system_prompt=system_prompt,
            tool_context=self.build_tool_context(),
        ):
            yield event

再往里走一层,query_loop() 在我脑子里大概长这样:

def query_loop(messages, system_prompt, tool_context):
    while True:
        response = stream_model(messages, system_prompt)
        yield response.events

        assistant_msg = response.final_message
        messages.append(assistant_msg)

        if not assistant_msg.tool_calls:
            return

        tool_updates = run_tools(
            tool_calls=assistant_msg.tool_calls,
            tool_context=tool_context,
        )

        for update in tool_updates:
            messages.extend(update.messages)
            tool_context = update.new_context

        if should_compact(messages):
            messages = compact_messages(messages)

如果我读源码时一直把逻辑压回这两段伪代码,我会非常清楚:

  • 哪些东西是骨架
  • 哪些东西只是骨架上的增强

四、为什么“回合结束条件”是 Agent 系统最容易写错的地方

我觉得这是一个特别实际的问题。

很多人第一次做 Agent,会自然地写成这种结构:

  1. 调一次模型
  2. 如果有 tool call,就执行
  3. 再调一次模型
  4. 返回

这看起来很简洁,但很容易错。

因为真实情况往往是:

  • 第二次模型输出里还会继续 tool call
  • 某次 tool result 会触发 compact
  • 某次调用可能需要 ask user
  • 某次恢复后模型可能还要继续行动

所以如果系统把“第二次模型调用结束”误当成“本轮结束”,后面很多状态都会错。

这也是为什么我越来越认为:

回合的结束条件必须绑定在“当前 assistant 是否还请求动作”上,而不是绑定在“你已经调用了几次模型”上。

五、为什么我觉得生成器式流式循环特别适合这种系统

Claude Code 这里用 async generator / yield 这套方式,我觉得非常自然。

因为 Agent 回合本来就不是一个“先算完再返回”的事情。
它天然包含多种事件:

  • request started
  • stream delta
  • tool use
  • tool result
  • summary
  • terminal state

如果所有东西都等最后一次性返回,会有几个问题:

  • UI 体验很差
  • 中途状态不可见
  • 恢复点难定位
  • 观测粒度不够细

所以从第一性原理看,一个行动型 Agent 更适合被组织成:

  • 长时段循环
  • 流式事件
  • 明确状态落点

这点和很多大模型应用最大的差别就在这里。

六、system prompt 在主循环里到底扮演什么角色

我觉得很多人容易把主循环和 system prompt 分开看,好像:

  • 主循环是 runtime 的事
  • prompt 是 prompt engineering 的事

但在 Claude Code 里,它们是绑在一起的。

因为每一轮循环真正调用模型之前,都必须确定:

  • 这次系统身份是什么
  • 当前模式是什么
  • 当前允许哪些行为
  • 当前用户偏好是什么
  • 当前 agent 有没有额外指令

这说明 prompt 在这里已经不是“调优模型风格”的文本,而是:

主循环每次进入模型之前的行为约束快照

这也是为什么我特别重视 buildEffectiveSystemPrompt() 这类逻辑。

七、用一张图把“请求”与“回合”分开

我会用下面这张图帮助自己区分两件事:

flowchart TD
    A[用户请求] --> B[生成 user message]
    B --> C[构造本回合 system prompt]
    C --> D[模型流式输出]
    D --> E{assistant 是否含 tool calls}
    E -->|否| F[本回合结束]
    E -->|是| G[执行工具]
    G --> H[写回 tool_result 消息]
    H --> I[必要时 compact / memory]
    I --> D

这张图最想强调的是:

  • “用户请求”只是回合起点
  • “有没有 tool calls”才是回合是否继续的关键

八、这件事和 LangGraph / 工作流框架怎么对应

如果我把 Claude Code 的主循环思路映射到 LangGraph,我不会做字面对照,而是做语义对照。

LangGraph 里更像是:

  • 一个 model node
  • 一个 conditional edge
  • 一个 tool execution node
  • 一个回跳 edge

Claude Code 里则是:

  • 一个显式 while 循环
  • 循环中手动处理这些状态迁移

二者最大的区别不是能力,而是表达方式:

  • LangGraph 用图显式表达状态迁移
  • Claude Code 用代码控制流表达状态迁移

所以我不会说谁更高级。
我只会说:

如果我要做高度交互式 Agent,Claude Code 这种显式 loop 很值得读;
如果我要做稳定工作流,LangGraph 的显式图更适合审计和协作。

九、我的个人判断:主循环是最能暴露你有没有真正理解 Agent 的地方

我越来越觉得,判断一个人有没有真的理解 Agent,不是看他会不会讲:

  • prompt
  • RAG
  • tools

而是看他能不能清楚回答:

  • 一轮是怎么定义的
  • 状态怎么持续
  • 工具结果怎么回到主链路
  • 什么时候真正结束

Claude Code 的主循环之所以值得看,就是因为它逼着我正面回答这些问题。

十、这一篇我想留下的结论

如果只留一句话,我会写:

对 Agent 来说,主循环不是实现细节,而是系统骨架。Claude Code 真正让我学到的,不是“怎么把模型接进终端”,而是“怎么把一次用户请求变成一个可持续推进、可中断、可恢复、直到动作耗尽才结束的回合”。

而这恰恰是很多大模型应用在往智能体演进时,最先该补的那一课。

系列串联