这是一个系列博客,剖析 Grok Build 是如何工作的。
Copiloted by Grok Build.
关于 Grok Build
Grok Build 可能是现在最值得学习的“大而全”开源 Harness(这里说的大而全主要是和 Pi 对立,Pi 不是一个完全开箱即用的 Harness,它需要你自己搭积木做配置,和 Claude Code, Grok Build 不是同一个路数)。我不知道大家会不会认为上面的结论是某种暴论,但我们可以细数一下:
- Claude Code 很优秀但不开源(泄漏的开源版本已经被无数人做成了各种“Github 教学项目”,但绝大多数都是 AI 写的可读性很差的项目,而且经过这么长时间,泄漏版本应该已经与最新的 Claude Code 区别甚大)。
- Codex 开源,但我主观认为它不是一个很好的 Harness,codex 的能力很大程度上依靠的是模型的智能水平,而非 Harness 框架的能力。至少我个人在社交媒体上看到的对 Codex 的吐槽远远多于对 Claude Code 的,包括 Bug、预期外的沙箱拦截、UI 设计、略显混乱的 Agents 调度等等。我个人的感受也是如此,Codex 不是一个特别优秀的 Harness 产品。
当前 Codex 的竞争力似乎主要来源于优秀的模型能力和 Tibo —— 他正在不断按下额度重置按钮。 - Cursor 实际上很优秀,但它不开源。很可惜,我听说他们为了每个(系列)模型都定制了不同的 Harness,我很想知道他们具体做了哪些区别。
- Opencode,常常无法约束模型行为,我遇到过几次模型意外提早结束一个 Turn 的情况,并且我遇到过在一系列边界条件下导致我丢失了两个小时内我用 opencode 做的全部代码改动的情况(当时在写一个汇报 html,不是正式项目,所以没有用 git)。除了 TUI 比较好看以外,它也没有什么让我好奇的功能。Oh-my-opencode 引入了更多噱头,但我很质疑 ultrawork 是否真的带来了什么交付质量上的显著提升。绝大多数的 Harness 所支持的长程任务模式都仅代表它能产出更多交付产物,而非带来智能水平的提升。
- 没有人在意 OpenClaw。
虽然我是大概四月份下载的 Grok Build,但当时没怎么用。五月份 Grok Build 正式发布,而它真正进入大家视野实际上是随着 Grok 4.5 的发布。大家突然发现它干活快速,可靠,稳定,简单易用,有好用的 TUI。这不完全是我的一家之言,可以看看社群评价:
以上社交平台的吹捧言论均不来自本人
除社交平台的用户反馈以外,还可以看看 Artificialanalysis 的 coding-agents 榜单。在我写这篇文章的时候,Grok 4.5 + Grok Build 在 Artificial Analysis Coding Agent Index 上逊于 Claude Code + Opus 5 (max),优于Codex + GPT 5.6 Terrra (max). 在 Terminal-Bench v2 上,仅次于 Codex + GPT 5.6 Sol (max).
我其实不是一个很爱研究 coding 产品功能的人,比起研究用户侧的功能,我更关注他们如何实现了这样的优秀效果,所以就不在这里介绍 Grok Build 的功能特色了。如果你还没有用过 Grok Build,完全值得下载下来试试。
不过在正式开始之前,我还是要提示这份文档的不足。我们可以通过源代码来了解 Grok Build 是如何实现的,但却不能永远理解为什么要这样实现。总会有许多可选的技术路线、实现方式和看起来难以决断的取舍,但促使 Grok Build 变成现在这样的,往往是来自于系统内部微妙的牵制关系,捕捉到的 bad case,以及内部的完善测试集带来的持续反馈。若非亲自参与构建,难以指望彻底理解。
三层循环
Grok Build 的主流程包括三层循环。从大到小分别是:
Session 级别循环
后文将称为 L1 循环。这一层级的循环,会用 select! 多路等待多类事件源,任一就绪则进入对应分支处理,然后回到等待。
命令 channel
命令 channel 是异步消息队列,也是外部控制 session 的主入口。Session 启动时(spawn_session_actor)做以下事情:
- 建一对 channel:cmd_tx(发送端)/ cmd_rx(接收端)
- 把接收端交给 session,只由 L1 取消息
- 把发送端经 SessionHandle 克隆给外部持有方(TUI、workflow、后台回调等)
- 建立一个 ChatState,用于保存当前对话记录。
发送方只 send,不直接改 session 状态;L1 取出后串行处理。
消息类型是 SessionCommand,种类很多:Prompt、会话生命周期、模型/模式变更、压缩、队列操作、任务通知、查询回包等。Prompt 只是其中一种。
L1 在等什么
L1 不只等命令 channel。select! 的每一臂都是一个 future,来源不同:
• 若干路来自 channel 的 recv(命令 cmd_rx、turn 结束的 completion、ChatState 事件、session 事件等)
• 若干路来自 定时器(memory 空闲 flush、dream 检查)
• 一路来自 watch(模型切换)
这些不同来源的 future 挂在同一个循环上。
Turn 循环
后文将称之为 L2 循环。其入口是handle_prompt. 不过其实这一层循环大多数时候只会执行一次,原因后面会写。
L1 在 maybe_start_running_task 里另起一个异步任务跑它;跑完后结果送回 L1。同一时刻最多有一个在运行的 L2,L2 按顺序处理本轮输入。
L2 大致分几段
-
开场
根据 prompt_id 判断这条输入从哪来(用户、定时任务、后台通知、goal 等),标记 turn 为活跃。!开头加命令会直接走 bash,整轮在这里结束。 -
/ 命令
• 多数内置命令(/session-info、/yolo 等):本地执行完,本 turn 直接结束,不进模型。
• skill 形式的 /xxx:展开 skill 信息,改写成普通 prompt,继续往下。
• 普通文字:不当 slash 处理,继续往下。 -
拼本轮用户消息,写入对话
这是 L2 的主要任务之一:• 解析正文、附件、图片、skill 信息
• 过大则截断;图片做 normalize / 落盘
• 注入一批 reminder(MCP、plan mode、任务恢复、workflow 状态、中断提示等)
• 把用户消息写进 ChatState
• 跑 UserPromptSubmit hook做完这些,对话里已有本轮用户消息,模型还没开始。
L2 的 loop
首先解释一下 L3。L3:调用模型、执行工具,直到本轮采样与工具循环正常或异常结束。
用户消息写入对话历史之后,L2 进入这一段:
循环 {进入一次 L3若 L3 结果不是正常完成 → 结束 L2若存在状态为进行中的 goal → 执行 goal 轮次结束判定需要继续 → 将续跑说明写入对话历史,再次进入 L3不需要因 goal 继续 → 进入 stop 检查执行 stop 检查允许结束 → 结束 L2不允许结束 → 将 hook 反馈写入对话历史,再次进入 L3
}
无进行中的 goal,且 stop 检查允许结束时,该循环通常只执行 1 次。
除 goal、stop 以外,L3 上的完成条件
在 Agent 定义里,会写有哪些必须要调用的工具。L2 会检查 Agent 是否调用了工具,如果没有则会拦截。
- L3 结束时已调用该工具 → 视为满足完成条件,返回外层循环。
- 未调用 → 写入纠正说明,在完成条件逻辑内重试 L3(含退避)。重试不一定经过外层循环。
- 重试次数用尽,或 L3 因步数上限、动作停滞等原因结束 → 不再为完成条件重试,将结果返回外层。
完成条件来自 agent 定义。Goal 来自 session 级目标状态。二者独立。
L3 结果与外层循环:
| L3 结果 | 外层行为 |
|---|---|
| 取消、错误、步数上限等非「正常完成」 | 结束 L2;不执行 goal 判定与 stop 检查 |
| 正常完成,且提供方返回拒答 | 若 goal 为进行中,则 pause goal;结束 L2 |
| 正常完成 | 执行 goal 判定(若适用),再执行 stop 检查 |
goal 的判定
前置条件:goal 功能已启用,且 goal 状态为进行中。否则跳过本段,直接执行 stop 检查。
步骤:
-
评估
session 在 L3 正常结束后,额外发一次隐藏 LLM 请求:
- 无 tools,强制 JSON schema 输出
- 超时 30s;最多试 2 次(先小模型,失败再退到当前会话模型)
- 用户在 TUI 里一般看不到这是“另一个 agent”,代码里 system prompt 自称 hidden completion evaluator
System prompt 固定写死在 goal_evaluator.rs,大意:你不是 coding agent,只根据 goal + transcript 判三种结论(continue | candidate_complete | blocked),要保守,别信模型嘴上说做完了。
-
根据评估结果更新 goal
- 未完成:记录进度;状态保持进行中
- 候选完成:运行验证。验证通过可将 goal 标为已完成;未通过则记录差距,状态可仍为进行中;验证基础设施失败或次数达上限可将 goal pause
- 阻塞:记录阻塞信息;同一阻塞连续达到阈值可将 goal pause
-
读取 goal 状态
若已不是进行中(已完成、已暂停、预算受限等)→ 本 L2 不再因 goal 再次进入 L3。
-
Token 预算
超出预算时,停止因 goal 再次进入 L3(goal 可变为预算受限)。
-
状态仍为进行中
生成续跑说明(目标摘要、计划引用、验证差距、下一步等),写入对话历史,然后再次进入 L3。
主路径下,goal 接近结束通常需要:评估为候选完成,且验证通过。
Stop 检查
Stop 检查调用用户/客户端注册的 Stop(主 session)或 SubagentStop(子 agent session)类 hook。
两类来源:文件配置的 hook,以及 client 注册的 hook。都没有启用时,直接允许结束 L2。
判定顺序:
- 未注册 上述 Stop / SubagentStop hook → 允许结束 L2
- 本 turn 内因 stop 再次进入 L3 的次数 ≥ 8 → 强制允许结束 L2(达到上限后本次不再询问 hook)
- 执行 hook:
- hook 返回 强制结束(prevent continuation)→ 允许结束 L2
- hook 返回 继续(block / deny,要求 agent 接着干)→ 将反馈写入对话历史,同一 L2 内再次进入 L3
- hook 未要求继续 → 允许结束 L2
反馈内容来自 hook 的 block reason 与 additional context,写入后主模型在下一轮 L3 能看到。
与 goal 的关系:
- 顺序固定:先 goal 判定,后 stop 检查
- goal 判定为需要继续时,不执行 stop 检查
- stop 只能在「本 turn 已不再因 goal 续跑」之后拦截结束
三种导致再次进入 L3 的机制
| 机制 | 条件 | 作用位置 | 备注 |
|---|---|---|---|
| 完成条件(completion requirement) | agent 配置了必须调用的工具名,且本轮未调用 | 多在 L3 外包的 recovery 重试里 | 内置 agent 默认未 |
| Goal | goal 状态为进行中,且轮次结束判定为需要继续 | L2 外层循环 | 隐藏评估器 + 可选验证器;续跑说明写入对话后再进 L3 |
| Stop hook | Stop / SubagentStop hook 要求继续 | L2 外层循环 | 同 turn 最多因 stop 续跑 8 次 |
三者都发生在同一次 L2(同一条已开始处理的输入)内,不经过 L1 重新调度。
采样与工具循环
后文将称之为 L3 循环。它是一个经典的 ReAct 循环,入口是 process_conversation_turn
L3 由 L2 调用。同一时刻一个 L2 里最多同时跑一段 L3(外层 loop 串行多次进入时,是一次结束后再进下一次)。L3 的职责:在当前 ChatState 上反复 采样模型 →(有 tool call 则执行工具并写回结果)→ 再采样,直到满足结束条件之一。
主路径的结束条件是:模型某一轮输出 没有 tool call(且未触发下文的续跑分支)。
进入循环前
- 准备本 turn 可用的 tool 定义(含 MCP 等待与统计)
- 输出 JSON Schema(可选)
调用方可为这一次 Prompt 指定最终答案的结构(Prompt.json_schema/ ACPmeta.outputSchema/ headless--json-schema)。必须是 JSON object。普通 TUI 聊天一般为无。
有合法 schema 时:- 后端原生支持 schema → 走原生 structured output
- 否则 → 注入 reminder,要求模型最后用
StructuredOutput工具交答案
- 进入
loop
每一圈大致顺序
{1. 动作停滞硬停检查(见下)2. 采样前注入 / 维护• 处理插话(用户 mid-turn 输入)• skill / monitor 等 pending 注入• 首轮 memory(本 turn 内最多注入一次,未来会讲 memory 系统)• 如有必要,告诉模型当前 MCP 服务器/工具集合发生了什么变化的 system-reminder• token 将超窗口 → auto-compact(改 ChatState 历史)3. 从 ChatState 组装请求(build_request:messages + tools 等)4. 调用模型(流式/非流式由采样层处理)5. 把 assistant(及流里带来的相关项)写入 ChatState6. 分支:• 无 tool call → 尝试结束(中间可能 TodoGate / 插话 再 continue)• 有 tool call → 执行工具,结果写入 ChatState,再进入下一圈7. 工具步数达到 max_turns(若配置了)→ MaxTurnsReached,结束 L3
}
无 tool call:默认视为本段 L3 正常完成
模型本轮输出不含 tool call 时:
- TodoGate(策略开启且非拒答等情况下):若 todo 列表仍有 pending / in_progress 且判定应推进,可注入 reminder 并
continue(有次数上限,用尽后放行) - 插话:若 turn 中途攒了用户插话,先写入对话再
continue - 否则 →
TurnOutcome::Completed,带上本段调用过的工具名列表等,返回 L2
因此:
- 常规结束:无 tool call → Completed
- 这不等于整次 L2 一定结束;L2 外层还可能因 goal / stop 再进 L3
有 tool call:执行后继续
- 将 tool call 记入本段
tools_called - 做 动作停滞 统计(同工具同参数连续重复)
- 执行工具(权限、沙箱、后台任务等走工具运行时)
- 工具结果写回 ChatState(模型下一轮能看到)
- 部分工具结果会要求 follow-up 用户消息,则写入后再
continue - 若配置了
max_turns,工具轮次将超过上限 →MaxTurnsReached,结束 L3,不再采样 - 否则进入下一圈(再次采样)
执行工具前后,L1 仍可收到 Cancel 等命令;取消会中止当前 L2/L3。
动作停滞(action stationarity)
防止模型对同一工具、同一参数(或纯 no-op)空转:
| 情况 | 阈值(当前代码) | 行为 |
|---|---|---|
| 连续相同 tool call | 约 8 次 | 注入 nudge reminder,提示停止空转轮询 |
| 连续相同 tool call | 16 次 | 硬停:StationarityEnded,结束 L3 |
| 连续 true no-op 类调用 | 4 次 | 硬停阈值更低 |
硬停后 L2 按「非普通 Completed」处理:不跑 goal 续跑那条正常完成路径(与 L2 表中非正常完成一致)。
其它结束 / 特殊结果
| 结果 | 含义 |
|---|---|
Completed |
正常结束(通常无 tool call,或 structured output 交卷成功) |
Cancelled |
用户取消或其它取消路径 |
MaxTurnsReached |
达到 agent/session 配置的工具轮次上限 |
StationarityEnded |
动作停滞硬停 |
| 错误 | 采样/工具等不可恢复错误向上返回 |
| 提供方拒答(content filter) | 仍可能记为 Completed,但带 refusal;L2 侧对 goal 会 pause |
和 L2、完成条件的边界
| 职责 | |
|---|---|
| L3 | 采样+工具循环;写 assistant / tool 结果;auto-compact;插话与 TodoGate 等 turn 内续跑 |
| L3 外的 completion requirement | L3 返回后检查是否调过规定工具;未调则注入 reminder、退避后 再跑整段 L3(多数不经过 L2 的 goa |
| L2 外层 | L3 已 Completed 之后:goal 评估/验证、stop hook |
Context 相关:
- L2:本轮用户消息、多数 reminder 写入 ChatState
- L3 采样前:再补 memory / MCP / 插话等,并可能 compact,再
build_request生成发给模型的 message list
发给模型的格式:由 ChatState 中的对话条目转成 API 请求(常见为 OpenAI 兼容的 role + content 列表)。<system-reminder> 标签会作为某条 user content 里的文本。
总结
run_session(L0,session 基本的生命周期;接受外部任何信息)││ 同时听几路,谁先到处理谁:│ · channel 来了 SessionCommand│ · 当前 turn 跑完(completion)│ · 空闲定时(memory flush / dream 等)│ · chat-state / 模型切换等旁路│ · channel 关了 → 收尾退出│├─ Prompt 指令│ ││ ├─ 入队(同时最多一个 running turn)│ ├─ send_now → 可取消当前 turn,优先这条│ └─ 空闲 → 队头起 handle_prompt(L1)│ ││ ├─ 开场:prompt 来源、turn 标记、lifecycle…│ ├─ / 命令?│ │ · 多数 builtin → 本地做完,本 turn 结束│ │ · skill 类 / → 化成普通 prompt 继续│ │ · 普通文字 → 往下│ ├─ 拼本 turn 的 user 消息(附件、reminder…)│ ││ ├─ loop(harness 还不让停就再来)│ │ ││ │ ├─ process_conversation_turn_with_recovery│ │ │ · 若配了 completion requirement:│ │ │ L2 正常结束但没 call 约定 tool│ │ │ → 塞 reminder、退避、再跑整段 L2│ │ │ · 否则直接进 L2│ │ ││ │ │ L2 ReAct loop│ │ │ ├─ 圈头:若同一 tool+参数已连打满 16 次│ │ │ │ → Cancelled(ActionStationarity),整 turn 停│ │ │ ├─ 每圈开头:插话 / skill / monitor / 首次 memory 等│ │ │ ├─ 采样前:可能 auto-compact(默认 ~85%,可 per-model)│ │ │ ├─ build_request(history + tools)│ │ │ ├─ 调模型│ │ │ │ · 有 tool → 权限 → 执行 → 结果入对话│ │ │ │ · 同一 tool+参数连 8 次 → 塞 nudge 再继续│ │ │ │ · 否则继续下一圈│ │ │ │ · 无 tool → TodoGate?插话?│ │ │ │ 还要继续则再圈,否则 L2 Completed│ │ │ └─ 其它:取消 / 权限拒 / max_turns / 错误…│ │ ││ │ ├─ 非 Completed / refusal → 跳出 L1 loop│ │ ├─ goal 还 Active 且要 Continue│ │ │ → 塞 continuation → 再开 L2│ │ └─ Stop hook:KeepWorking → 塞 feedback 再跑│ │ AllowStop → 跳出│ ││ └─ 记账、回包 → completion 通知 L0│├─ turn 完成(completion)│ ├─ 收尾、冲滞留插话│ ├─ 队列有下一条 → 再起 L1│ └─ 否则 drain 通知 / 标 idle│├─ 空闲定时 / 其它指令│ memory、换模型、改 mode、shutdown、│ 改队列(删/改/重排/hold combine)、插话、MCP 开关…│└─ Loop
最后,我也让 AI 画了张架构图。

预计下一章会写模型调用相关的内容
作者的邮箱:tokamak9000@163.com。如有问题,欢迎讨论
