引子:30 行 ReAct demo 距离上线还有多远

几乎所有 agent 教程都从同一段代码开始:

1
2
3
4
5
while True:
    action = llm(messages)
    if action.is_final:
        break
    messages.append(run_tool(action))

三十行、一个 while、一张 ReAct prompt 图,就号称能跑任意任务。写 demo、发 demo 视频没问题,但把同样的代码丢到生产环境里跑真实编程任务,你会在一周内集齐以下 bug:模型陷入工具调用死循环、单次对话烧光当月配额、上下文撑爆 200k tokens 后直接 400、权限失控地执行了 rm -rf、线上崩了却没有任何可回放的 trace。

工业级 agent 框架解决的正是这些"demo 里看不见的问题"。以开源实现 claw-code 为例,它把 Claude Code 的主控制流拆成了至少 8 个显式控制点:max_turns / max_budget_tokens / compact_after_turns / route_prompt / hooks / sandbox / streaming events / sidechain。claw-code 的默认值是 max_turns=8max_budget_tokens=2000compact_after_turns=12——这三个数字不是随便填的,是把一个"能跑"的 agent 变成"可以跑 1000 个 turn 不爆"的最小必要条件。

这一篇不讲 prompt engineering,也不讲模型选型。我只回答两个问题:

  1. 一个能上线的 agent 框架,比 50 行 ReAct demo 多了什么?
  2. 如何用一张图解释 Claude Code / claw-code 的主控制流?

下面这张图会在文中反复出现,我们围绕它展开 5 个工程学意义上的拆解。

flowchart LR
  U[User prompt] --> R[route_prompt: token match commands+tools]
  R --> Q[QueryEngine.submit_message]
  Q -->|loop| LLM[LLM call]
  LLM -->|tool_use| TX[Tool exec pipeline]
  TX --> Q
  Q -->|stop_reason| End[(TurnResult)]
  Q -. >max_turns .-> End
  Q -. >budget .-> End
  Q -. compact_after .-> C[compact transcript] --> Q

一、Agent Loop 的真实形态

读源码最容易产生一种幻觉:QueryEngine.submit_message() 这么重要的函数,实现一定很"高级"。打开 src/query_engine.py,你会发现它的主干其实就是一个 for turn in range(max_turns) 的循环——和 30 行 demo 一模一样。区别不在循环本身,在循环的边界

claw-code 的 submit_message 签名如下:

1
2
3
4
5
6
7
def submit_message(
    self,
    prompt: str,
    matched_commands: tuple[str, ...] = (),
    matched_tools: tuple[str, ...] = (),
    denied_tools: tuple[PermissionDenial, ...] = (),
) -> TurnResult:

注意这里的参数:没有 messages 列表,没有 全局 history。取而代之的是一串"外部已经替你决策好的上下文"——哪些 command 命中了、哪些 tool 命中了、哪些 tool 被权限模块挡下了。也就是说,QueryEngine 本身不做路由、不做权限判定,它只做一件事:在一个 prompt / tool / budget 三重有界 的区域里推进对话。

这个"有界"通过三种 stop_reason 显式兑现:

  • completed——模型主动吐出 final text,正常结束。
  • max_turns_reached——超过最大轮数,强制中断。
  • max_budget_reached——累计 token 超过预算,强制中断。

为什么这三个都要写成 stop_reason 而不是 throw exception?因为任何一次 agent 运行都必须返回一个结构化结果,哪怕是失败的那种。对调用方来说,TurnResult(..., stop_reason="max_turns_reached")TurnResult(..., stop_reason="completed") 在接口上等价,上游可以统一做埋点、计费、回放。让 agent"自由跑无停机"是典型的 toy 思路,在生产里等于把用户钱包交给 LLM 随机数。

我在 mini_agent_loop.py 里把这套骨架缩成了 130 行。核心循环长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
for turn in range(self.max_turns):
    yield {"type": "message_delta", "turn": turn}
    decision = fake_llm(prompt, history)
    used += 50 + 10 * turn
    if used > self.max_budget_tokens:
        stop = "max_budget_reached"
        break
    if decision["type"] == "final":
        output = decision["text"]
        break
    # ... tool dispatch
else:
    stop = "max_turns_reached"

Python 的 for ... else 在这里非常好用:只有当 for 自然耗尽 range(max_turns)else 才触发,语义完美匹配"走到最后一轮都没 break 出来"的状态。这是工业实现里看似不起眼、但 review 时你应该立刻认出来的模式。

Insight:Agent loop 的工程化,本质是把"无限制 while True“替换为”显式三元停机 + 结构化返回值"。剩下的所有事——重试、compact、监控——都以这个结构化返回值为支点展开。


二、Turn 是一等对象

上一节我顺手用了 TurnResult,没有解释为什么要把"一次模型往返"抽成对象。来看 claw-code 的定义:

1
2
3
4
5
6
7
8
9
@dataclass(frozen=True)
class TurnResult:
    prompt: str
    output: str
    matched_commands: tuple[str, ...]
    matched_tools: tuple[str, ...]
    permission_denials: tuple[PermissionDenial, ...]
    usage: UsageSummary
    stop_reason: str

七个字段,全部 frozen=True。这意味着一个 TurnResult 一旦生成就不可变,可以安全地写进 SQLite、丢进队列、做 diff。对照 toy demo 里那个会被无限 append 的 messages: list[dict]——后者根本没法做审计。

TurnResult 不是日志,是协议。它回答了三个问题:

  • 这次 turn 输入是什么?prompt + matched_*
  • 这次 turn 产出了什么?output + permission_denials
  • 这次 turn 花了多少?usage + stop_reason

有了这个协议,整个 runtime 可以往外吐一条有结构的事件流。claw-code 在 submit_message 内部通过 generator 吐出 6 种流式事件:

  1. message_start——开启 turn,带上路由命中的 tool / command 列表。
  2. command_match——某个显式命令被路由器匹配到。
  3. tool_match——LLM 决定调用某个 tool。
  4. permission_denial——某个 tool 被权限层挡下。
  5. message_delta——流式 token 推进(对应 Anthropic API 的 message_delta)。
  6. message_stop——turn 结束,附 stop_reason

这 6 种事件和 Anthropic 官方 Messages API 的 streaming event 名字几乎一一对应,这不是巧合——对齐上游协议,意味着调用方可以用同一套 UI 组件渲染"本地 tool 执行"和"远端 API 流"。我在 demo 里把它简化为生成器:

1
2
3
4
5
6
7
8
def submit_message(self, prompt: str) -> Generator[dict, None, TurnResult]:
    ...
    yield {"type": "message_start", "matched_tools": matched}
    for turn in range(self.max_turns):
        yield {"type": "message_delta", "turn": turn}
        ...
    yield {"type": "message_stop", "stop_reason": stop}
    return TurnResult(...)

注意签名:Generator[dict, None, TurnResult]。中间 yield 事件,最后 return 结果。调用方通过 StopIteration.value 拿到 TurnResult——既流式、又结构化,两头不误。

Insight:把 Turn 抽成一等对象之后,它同时承担了三个角色——可观测(事件流)、可重放(冻结字段)、可计费(usage)。这三件事任何一件单独实现都很难;合并进同一对象之后,只要所有子系统都以 TurnResult 为总线,就自动串起来了。


三、模型调用前的 Routing 层

打开 claw-code 的 src/runtime.py,你会看到一个容易被忽略的函数:

1
2
3
4
5
6
7
8
9
def route_prompt(self, prompt: str, limit: int = 5) -> list[RoutedMatch]:
    ...

@dataclass(frozen=True)
class RoutedMatch:
    kind: str          # "command" | "tool"
    name: str
    source_hint: str
    score: int

它做的事不复杂:把用户 prompt 按 /- 切词,和 PORTED_COMMANDS + PORTED_TOOLS 里注册的名字做重叠匹配,打分后取 top-k。默认 limit=5

乍一看这层是多余的——反正 LLM 自己就会看 tool description 然后选择。但你算一下账:假设系统里注册了 200 个 tool,每个 description 平均 100 token,全量塞进 system prompt 就是 20k token,每次调用都要烧一次,即使用户只是说"你好"。Routing 层干的就是把 20k 砍到几 k:

  • route_prompt("读一下 /src/main.py") → 命中 ReadFileGlob 等少数几个 tool。
  • 只把这几个的完整 description 写进 LLM prompt。
  • 其他 195 个工具根本不出现在这次请求里。

这是一个在 LLM 前面跑的 retrieval。你可以把它理解为"工具层 RAG"——不过通常不必上向量库,token overlap 这种朴素打分在工具名这种短文本上效果已经很好(claw-code 选择的正是 token overlap)。

在我的 demo 里,这段被实现为十几行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def route_prompt(prompt: str, limit: int = 5) -> list[RoutedMatch]:
    p = set(t.lower() for t in _TOK.findall(prompt))
    out: list[RoutedMatch] = []
    for name, desc in TOOLS.items():
        d = set(t.lower() for t in _TOK.findall(name + " " + desc))
        s = len(p & d)
        if s > 0:
            out.append(RoutedMatch("tool", name, s))
    out.sort(key=lambda m: -m.score)
    return out[:limit]

跑一下 route_prompt("please add 3 and 4"),输出 [RoutedMatch(kind='tool', name='add', score=1)]。在真实系统里,这个分数还会叠加别名表、模糊匹配、用户历史偏好,但骨架就是这么简单。

Insight先剪枝,再让模型做选择题。Agent 的瓶颈从来不是"模型不够聪明",而是"每次把所有上下文都喂给模型"的暴力解。Routing 层的存在,是工业级 agent 对 prompt cache 和 token budget 做出的工程妥协——也是 Claude Code 能够注册海量内置 command 却不拖慢每次响应的根本原因。


四、多入口共享同一 Runtime

这一节几乎不涉及算法,但它是 Claude Code 这类产品能同时活在 terminal、IDE 扩展、MCP server、SDK 四种形态里的架构底座。

按 PDF §2.2 的描述,claw-code 把入口放在 src/entrypoints/ 下:

  • cli.tsx——交互式 REPL。
  • init.ts——claw init 首次配置。
  • mcp.ts——Model Context Protocol server 模式。
  • sdk/——给第三方程序调用的库。

这 4 个入口共享同一个 agent runtime。也就是说,无论你是在终端敲命令、在 VS Code 插件里触发、还是另一个 agent 通过 MCP 调用过来,底层跑的都是同一份 QueryEngine.submit_message()

claw-code 的 Rust 版本把这件事推得更彻底。它拆成两个 crate:

  • rust/crates/rusty-claude-cli——专管终端 UI / 参数解析 / TTY 管理。
  • rust/crates/runtime——纯粹的 agent runtime,不知道自己被谁调用。

为什么这种解耦值得专门讲?因为绝大多数 agent demo 都把 runtime 和 CLI 缝在一起——main() 里直接 input() 然后 print(),听起来无害,但意味着:

  • 要做 MCP server?整个 runtime 重新抠一遍。
  • 要做 SDK?再抠一遍。
  • 要做 IDE 插件?再抠一遍。
  • 三份代码分叉之后,哪份出 bug 你都说不清。

工程价值在于:CLI / MCP server / SDK 三种身份共享 80% 代码,bug 修一次处处生效,监控指标也是同一套。这不是架构洁癖,是上线一年后才会体会到的"还好当初没偷懒"。

Insight:判断一个 agent 框架是 demo 还是产品,看一个信号就够——runtime 层是否显式禁止了对 stdin/stdout 的直接依赖。如果 runtime 里还出现 printinputsys.stdout.write,它就没法进化成多入口系统。


五、Bootstrap 是图,不是脚本

最后看 claw-code 里一个名字很朴素的文件:src/bootstrap_graph.py。它把启动过程拆成 7 个阶段:

  1. prefetch——预拉取模型配置、最近会话快照。
  2. warnings——打印弃用信息、版本检查。
  3. CLI parsing——解析命令行参数。
  4. parallel setup——并行初始化 Tool registry、Permission policy、MCP client。
  5. deferred init——可延迟的模块(遥测、自动更新检查)懒加载。
  6. mode routing——根据入口类型(cli / mcp / sdk)分流。
  7. query engine——最后拉起 QueryEngine

乍一看这就是个顺序启动脚本,为什么要叫 “graph”?因为阶段之间有依赖关系,而且有些边是可跳过的。典型场景是 session resume:

  • 用户上次跑到一半 Ctrl-C 了,现在 claw --resume <id>
  • prefetch 阶段发现本地已有快照 → 跳过重新拉取配置。
  • CLI parsing 阶段已被 resume 指令消化 → 跳过默认参数注入。
  • permission policy 直接从快照恢复 → 跳过重新询问用户授权。

如果 bootstrap 是线性脚本,上面每一条都得写 if resume: ... else: ...,逻辑很快变成意面。把启动过程建模成图(DAG)之后,resume 不过是"把部分节点标记为已完成"——图执行器会自动跳过前驱已满足的节点。

这个设计的隐藏好处是启动时间可观测。每个节点都有独立的 span,线上如果发现冷启动变慢,立刻能看到是哪个节点回归了。对比一个巨型 main() 函数,光是找回归点就得打几十个 print。

Insight:Bootstrap 用图建模,本质是承认"启动"本身也是一种可暂停、可恢复、可并行的工作流——和 agent loop 在哲学上完全一致。当你发现一个框架的作者在启动路径上就舍得用 DAG,那它的核心循环大概率也是可控的。


回到那张图

现在回头看开篇的 Mermaid 图,你应该能把每一条边对应到源码了:

  • U → R 走的是 route_prompt(),对应第三节的 token-overlap 剪枝。
  • R → Qmatched_* 作为参数送进 QueryEngine.submit_message(),对应第二节 Turn 的输入协议。
  • Q -->|loop| LLM --> TX --> Q 是第一节的 for turn in range(max_turns) 主干。
  • Q -. >max_turns .-> End / >budget 是三种 stop_reason 里的两种。
  • Q -. compact_after .-> C --> Q 是文章 3 会详细展开的 context compaction——用 compact_after_turns=12 触发,在不丢语义的前提下把 transcript 压缩回预算内。

把这张图记在心里,后续五篇文章基本都是在它的某一个节点上钻下去。

思考题

claw-code 的默认 max_turns=8。不是 5,也不是 100。为什么?(提示:想想绝大多数真实编程任务的"tool call 深度"分布——读文件 / 编辑 / 运行测试 / 再修一轮——这个数字其实是 Anthropic 工程师从生产数据里反算出来的一个 p95 上界。超过 8 轮还没收敛的任务,一般意味着需要人工介入,而不是让模型继续烧钱。)

下一篇预告

本篇聚焦的是"主循环"这一根脊椎。下一篇 《Tool 系统与权限模型》 会把上文一再略过的 permission_denials 打开——claw-code 如何把 200+ 内置工具塞进一套统一的 schema 注册表?PermissionDenial 这个对象的生命周期是怎样的?为什么 Bash 工具单独有 sandbox,而 Read 没有?工业级 agent 的"安全边界"究竟长什么样——我们下篇见。

参考