总览把 Grodex 拆成八大区域,本文把 区域 3 · Agent Loop 单独拎出来,讲透"一次对话在 Grodex 里是怎么被执行完的"。约定:本文只谈循环本身;记忆、子 Agent、上下文压缩等其它区域只在出现边界时点一句,不展开。
0. 先建立心智模型
Agent Loop 到底是什么
所有聊天式 AI 编码工具,内核都长这样:
python
while True:
把到目前为止的对话发给模型
模型要么回复一段文字,要么请求调用几个工具(读文件 / 改文件 / 执行命令...)
如果是工具调用:执行它们,把结果塞回对话,继续循环
否则:把文字展示给用户,结束这一轮
这个"反复调模型 → 执行工具 → 把结果喂回去"的循环,就是 Agent Loop(智能体主循环)。它像一台蒸汽机:燃料是用户的话,气缸是模型推理,做功的是工具,而废气------每一条对话------会被重新收集起来,当作下一轮推理的燃料。
如果只是为了演示原理,上面 6 行就够。但把它做成一个能真实使用的产品,还要回答一堆棘手的问题:
- 用户在工具执行期间按了取消,谁负责"安全地停下来"?
- 一个工具在等用户审批,其它工具能不能先跑?整个会话会不会因此卡死?
- 模型一口气要调用 3 个工具,它们同时完成、返回顺序随机,对话记录(transcript)该按什么顺序写?
- 执行到一半进程崩溃了,重启后怎么知道哪些工具已经执行、哪些没有?怎么避免把有副作用的命令再执行一遍?
- 模型看到的"工具清单"在执行过程中变了(比如新的 MCP 服务器连上了),这次调用该用旧清单还是新清单?
- 对话太长塞不进上下文窗口怎么办?
这一串问题,恰好就是 Grodex 的 Agent Loop 想解决的事情。答案不是把 while 循环写得更长,而是把一次对话拆成三层、把循环里的每个动作都变成可解释、可重放、可恢复的状态变化。
三个词:SESSION / TURN / STEP
先把三个递进的概念钉死(总览里提过,这里展开):
- Session(会话) ------ 一次完整的对话,从你打开 Grodex 到退出。它有一个状态机(初始化 → 空闲 → 运行中 → ... → 关闭),并且只有一个人有权改它的状态。
- Turn(回合) ------ 你说的一句话 + 它引发的一整段处理。一个 Turn 内部往往要调很多次模型、执行很多次工具。比如你说"帮我把这个 bug 修了",Grodex 可能连续调 10 次模型才收工,这 10 次合起来是一个 Turn。
- Step(步骤) ------ Turn 内部的一次"模型采样 + 它引起的那批工具执行"。一次采样 → 一批工具 → 结果提交,这是一个最小可恢复单元。
Session ⊃ Turn ⊃ Step。整个 Loop 的核心纪律,就是:Session 里的 Turn 串行推进,Turn 里的 Step 串行推进,只有 Step 内部的一批工具可以并行------而且并行归并行,写入记录的顺序必须是确定的。
1. 三层骨架:谁拥有什么
实现里,这套循环被拆成三个长期存活的"演员"(actor)+ 一份共享的日志:
- SessionSupervisor(会话监督者) :一个
tokio::select!事件循环,是 Session 状态的唯一写者。前端把"开始回合 / 取消 / 审批 / 退出"等命令发给它;它负责校验、改状态、派活、收活。 - ChatStateActor(对话状态演员) :对话转录(transcript)的唯一所有者。所有"往对话里加一行"的动作都必须通过它;它保证转录永远自洽。
- TurnCoordinator(回合协调者) :每次新回合,Supervisor 克隆一个 Coordinator,spawn 成一个独立任务去执行。它拿不到 Session 本身,只拿到回合上下文,跑完把结果交还给 Supervisor。
- RolloutWriter(滚动日志写入器) :一份追加式的
rollout.jsonl。Supervisor 和 Coordinator 共用同一个 writer 实例,保证事件序号连续无缝隙------这是整个系统"可崩溃恢复"的根基(区域 7 的职责,这里只用它的边界)。

看图要点:
- 上→下是控制流:前端命令进 Supervisor,Supervisor 克隆 Coordinator 去跑回合;
- 左→右是数据流:转录归 ChatStateActor,日志归 RolloutWriter,模型在 Sampler;
- 一条虚线提示崩溃恢复:启动时 Supervisor 用 SessionReducer 回放日志、重建上下文(详见区域 7)。
为什么必须是三个演员?
用一个"如果违反会怎样"的对照就明白了:
- 如果谁都能改对话历史 → 两个并发的工具结果可能把转录写坏,模型看到自相矛盾的上下文;
- 如果谁都能改会话状态 → 你按了取消,却没法保证在"状态"层面真的停下来;
- 如果回合执行和会话管理混在同一个任务里 → 工具等审批时,整个会话就冻结了,用户连取消都按不动。
把"状态归属"分开,是让一个长期运行的循环既能同时响应外部事件(取消、审批、查询),又不在内部乱了套的关键。这是从 Grok 的 Session Actor 学来的模式。
2. 从"你说一句话"开始:一次 Turn 的完整旅程
假设用户输入:"读一下 src/main.rs,然后帮我确认它 import 了哪些 crate。"

按时间线拆解:
- 前端发命令 。前端发
StartTurn { user_input }给 Supervisor。 - 校验 + 状态迁移 (不变量 #1)。Supervisor 先
admit_turn():此时若有另一个回合在跑,直接拒绝;否则把 Session 状态Idle → Running,用户输入入队。 - 写转录 + 写日志 。
chat_state.push_user_message(...)把用户输入写进转录;writer.write_user_input(...)把它落进日志------顺序是先落盘,再进入执行。 - 装配上下文 。Supervisor 在这一步做三件事:发现当前目录的 Skill 与
AGENTS.md指令、用 PromptBuilder 组装系统提示词、把长期记忆检索结果(如果有)作为"尾部开发者指令"附加进去------注意是放在尾部而不是系统提示词里,为的是不破坏供应商的 prompt 缓存(区域 4 会细讲)。 - 克隆 Coordinator 并 spawn 。Supervisor 克隆一份 Coordinator,把回合上下文
TurnContext、一个取消令牌、一个流式通道交给它,spawn 成独立 tokio 任务。自己则回到事件循环继续响应取消 / 审批等。 - Coordinator 跑回合。循环若干 Step,见下一节。
- 结果交还 。回合任务结束,发
TurnCompletion到 Supervisor;Supervisor 收活:状态Running → Idle,write_turn_completed落盘,向前端发TurnCompleted(带上 token 用量统计,前端可展示缓存命中率)。
注意第 5 步的一个细节:回合任务拿不到 Session。它手里只有转录句柄(ChatStateActor)、日志 writer、权限句柄、能力管理器------唯独没有会话对象。这是"单一写者"在结构上就保证的:不是靠锁,而是靠"压根没给钥匙"。
3. Step:一次"思考 + 动手"的最小单元
Turn 里反复执行的,是 Step。Coordinator 的主循环大概长这样(max_steps 默认 40):
arduino
for step in 0..max_steps:
1. 检查取消令牌
2. 取"冻结的能力基线"下,模型当前可见的工具清单
3. 检查上下文是否超限 → 超了就压缩
4. 从 ChatStateActor 取当前转录
5. 写 StepStarted 到日志
6. 构造请求 → 流式采样模型
7. 处理响应:推理内容、正文、工具调用
8. 若有工具调用:派发、收集、按序提交
9. 若没有工具调用:正常结束本回合
冻结:模型看到的世界,在 Step 内不许变
这是整个 Loop 最值得先讲清楚的设计。
在 Turn 开始的时候 ,Coordinator 从能力管理器取一份"能力基线"(工具定义、Skill 目录、MCP 绑定、权限上界)并冻结。之后每个 Step 构造请求时,模型能看到的工具清单都来自这份冻结的基线 + 回合内的叠加层。
为什么要冻结?因为"模型看到的"和"工具实际执行的"必须一致(不变量 #3、#15)。如果模型依据旧的工具定义发了一个调用,结果执行时工具已经换成了新定义,行为就无法预测。冻结保证了:一次 Tool Call,永远按产生它时的语义执行。回合内能力有没有变化?有------但叠加层要到回合结束时才统一采纳,绝不在 Step 中途悄悄生效。
(冻结的实现不是复制大对象,而是版本号 + Arc 指向不可变对象,所以它几乎没有成本。)
串行:模型必须在"上一批结果已提交"之后才能继续
Step 之间为什么不能流水线?因为下一轮推理的输入包含上一轮的输出。如果模型在工具结果还没提交时就开始了下一次采样,它就是在缺少因果信息的情况下瞎猜(不变量 #2、#7)。
所以 Coordinator 的 Step 是硬串行的:采一次样 → 把工具全部执行完 → 结果按顺序提交 → 才允许下一次采样。串行的是"因果链",并行的是"链上每个环节的内部",这跟铁路调度是一个道理------列车不能超车,但一列火车可以同时拉很多车厢。
Step 内的四小步

- 构造请求 :重新从 ChatStateActor 取转录(不是用内存里的旧副本),带上冻结的工具清单、
tool_choice: Auto、parallel_tool_calls: true。 - 采样 :走
sample_streaming流式接口。模型吐出来的每一个片段(正文 / 推理 / 工具调用的参数片段)都被实时转成SessionEvent推给前端------这就是你在界面上看到的"打字机效果"。 - 处理响应:推理摘要先入转录、正文后入转录(顺序有讲究,兼容 DeepSeek / Qwen 的思考模式);工具调用按模型发出的顺序收集。
- 执行 + 提交:见下一节。
两个值得一提的边界情况:
- 被截断 :如果模型因为
max_output_tokens用尽而提前停下(stop_reason = length),Coordinator 会压一句"请继续"作为用户消息、再采样一次,而不是当作回答结束。 - 上下文超限 :如果请求太大或模型返回"上下文超限",且是第一步,Coordinator 会强制压缩一次、重建请求、重试一次;仍失败才放弃。
4. 工具批次:并行执行,按模型顺序提交
这是 Loop 里最精巧、也最能体现"确定性"的一处。先给结论:
模型发出调用的顺序 = 转录里记录的顺序。 无论工具实际谁先完成、谁后完成。
为什么要这样?
模型一次可能发出 3 个调用:A(写文件)、B(读文件)、C(执行测试)。假如让它们并行跑,结果返回顺序必然是随机的(网络、磁盘、CPU 的偶然性)。如果按"完成顺序"写进转录:
- 两次运行同一个任务,转录可能不一样 → 后续推理上下文随机漂移,行为不可复现;
- 模型的因果判断会被"谁先回来"而不是"我按什么顺序要求的"带偏。
而按"发出顺序"提交,就把执行的速度 (并发)和记录的确定性(按序)解耦了------并行归并行,账目永远一致。这是从 Codex 学来的 commit 语义。
流程

- 按模型序先落"调用" :把 3 个
ToolCall按 A、B、C 顺序写进转录。先立账,再动手。 - 并行派发 :默认每个工具
tokio::spawn一个任务并发跑。例外:如果这批里有一个工具声明自己必须串行(比如会修改共享资源),整批自动退化为串行执行------宁可慢,不能错。 - 结果按到达序收集:每个结果带着"它是第几个被发出的"(CommitSequence 序号)和 operation_id 回来,先到的先存着。
- 排序后提交 :把收集到的结果按模型发出顺序排序(A、B、C),然后逐个:先
write_tool_finished落日志,成功后chat_state.push_tool_result写进转录。
第 4 步的顺序不能反:日志落盘成功,才允许写转录;如果日志写失败,整个回合直接中止(见 §6 的 fail-closed)。
每个工具调用在派发前,还会做几件被"钉"进事件里的事情:
- Schema 校验 + 参数归一:参数不符合工具定义,直接返回错误结果,不执行;
- 权限门 :
permission.check()返回 允许 / 拒绝 / 需要审批 之一。需要审批时,调用挂起等用户决定(120 秒超时),而只有这一条工具 future 被挂起,其它工具、整个会话不受影响; - 能力绑定:每个调用绑定它产生时的能力修订号、策略版本、参数哈希和 operation_id,后续任何"改主意"都有据可查;
- 幂等账目 :执行前把
ToolExecutionStarted落日志(先落盘、再产生副作用);执行后ToolExecutionFinished落日志;提交时ToolResultCommitted落日志。崩溃恢复时靠这三个时间点区分"未执行 / 已执行未提交 / 已提交"。
(权限与审批的完整策略引擎属于区域 6,这里只说它与 Loop 的接缝:一个工具在被执行前,必须先过一道"是否允许、在什么沙箱边界内执行"的门,这道门是整个循环唯一的副作用闸口。)
5. 转录所有权:谁能写对话历史
对话转录是模型的"记忆"。它的正确性直接决定模型看到的世界是否自洽。Grodex 把这份记忆交给 ChatStateActor 独占。
两层结构:conversation 与 projection
ChatStateActor 内部维护两份东西:
- conversation:完整转录,只追加、永不删除;
- projection:给模型看的"投影"。它可能被压缩替换,但压缩替换的只是投影,完整转录仍在。
为什么要分两层?因为"完整记录"和"模型能看的"天然不同:完整记录要保留所有历史用于审计、恢复;而模型受上下文窗口限制,有时只能看到被压缩后的摘要。两层分离让两者不必互相迁就。
写入纪律:每次写边界都修复转录
ChatStateActor 有一条硬纪律:每次写边界(新用户消息进来前、构造请求前)都检查并修复转录的自洽性:
- 重复的 ToolResult 去重;
- 孤儿的 ToolCall(发了调用但结果丢了)被移除;
- 只有工具调用、没有任何正文、且工具调用全部成为孤儿的 assistant 消息,整条删除。
这条纪律不是洁癖------它堵住的是一个真实的线上故障:用户在工具执行中按取消,转录里可能残留"assistant 说我要调工具,但没有结果"的残缺结构,某些模型 API(比如 ChatCompletions)会直接拒绝这种消息序列。修复发生在写边界,所以下一次请求构造时,转录永远自洽(这也是不变量 #9 的落地方式之一)。
6. 先落盘,再动作:循环的持久化边界
Loop 的每个关键动作,都遵循"先落盘,再动作"(Journal-first)的原则。日志不是事后记账,而是动作的前置条件。
一个动作在日志里的完整足迹
以"执行一个工具"为例,rollout.jsonl 里会依次出现:
| 事件 | 时机 | 意义 |
|---|---|---|
StepStarted |
采样前 | 本 Step 开始 |
ModelItemProduced |
采样后 | 模型的正文 / 推理 / 工具调用已持久化 |
ToolCallPrepared |
派发前 | 这个调用准备执行,绑定能力修订 |
ApprovalRequested / ToolCallApproved |
审批前后 | 审批痕迹 |
ToolExecutionStarted |
产生副作用前 | 先写它,才允许真正执行 |
ToolExecutionFinished |
结果产生后 | 已执行,结果在此 |
ToolResultCommitted |
提交前 | 即将进入转录 |
TurnCompleted |
收尾 | 回合正式结束 |
两级持久化:门事件强同步
日志写入分两级:
- 门事件 (gate):
ToolExecutionStarted、ToolResultCommitted、TurnCompleted、压缩提交等,使用force_fsync = true,强同步到磁盘 才返回。因为它们守护"副作用"和"回合边界"------一个工具进程必须在其ToolExecutionStarted真正落在盘上之后才允许启动,否则崩溃恢复根本不知道它存在过; - 普通事件 :
StepStarted、ModelItemProduced等,只保证写入顺序,不强制 fsync。
Fail-closed:宁可中止,不可漂移
最极端的一条:ToolResultCommitted 写入失败,Coordinator 直接中止整个回合,而不是"忽略错误继续走"。
原因是不变量 #13:rollout.jsonl 是唯一事实源,内存中的转录只是它的投影。如果日志没写成、内存状态却继续前进,一旦崩溃,恢复出来的上下文和崩溃前用户看到的不一致------这是不可接受的。所以:任何"内存状态领先于可恢复状态"的机会,都被 fail-closed 掐死。
共享 writer:序号无缝的单一咽喉
Supervisor 和 Coordinator 为什么共用同一个 writer?因为日志序号 seq 必须全会话连续、无缝隙。如果两个 writer 各写各的,重放时就会遇到"缺失序号"并触发严格校验错误。共用一份共享内部状态的 writer,相当于把"分配序号"这件事变成全局唯一的咽喉,谁写都由它发号。
7. 取消与中断:想停就能停
回合执行是异步任务,用户随时可能按取消。Grodex 的取消不是"把 UI 关掉就完事",而是一套完整的收尾协议。

- 用户发
CancelTurn; - Supervisor 拿到当前回合的取消令牌和 AbortHandle,
cancel_token.cancel()+handle.abort(); - 让出一次调度(yield),等 abort 真正落到任务里------不能自己刚说取消就立刻开新回合(不变量 #8);
session.cancel_turn():状态Running → Idle;- 治愈 :扫描转录,找到"发了调用但没有结果"的孤儿工具调用,给它们补一个"已中断、结果未知"的错误结果,并把整个回合封顶(
write_turn_completed); - 通知前端
TurnCompleted(0 token),停止流式指示器。
第 5 步是关键:直接 abort 会让日志停在"工具调用了、结果永远不来"的中间态,重启重放会失败(严格模式报孤儿错误)。治愈 = 把中断也变成一条合法、可重放的事件,让日志永远闭合。
(取消令牌本身是分层的:Session → Turn → Step → 工具 / 模型 / 子 Agent,父取消会传播到子。子 Agent 属于区域 8,这里不展开。)
8. 停下来:回合怎么结束
一个回合有三种"正常"结局,和一种"兜底"结局:
- 模型不再调用工具:模型给出最终回答,自然结束------最常见;
- 出错:采样失败,或工具层 fatal 错误,返回错误结果;
- 取消:用户按 Esc,走 §7 的收尾协议;
- max_steps 耗尽(兜底) :默认最多 40 步。如果 40 步还没收工,Coordinator 强制发起一次"不带工具"的总结采样 (
tool_choice: None,2048 token),把当前进展压缩成一段总结,保证一个长任务不会无声无息地死掉。steps_exhausted标志会让前端展示"已用尽最大步数"的提示。
9. 一张表:朴素循环 vs Grodex 循环
| 朴素 while 循环 | Grodex 的 Loop | |
|---|---|---|
| 用户中途插话 / 取消 | 无解 | CancelTurn 完整收尾,日志闭合 |
| 工具返回顺序随机 | 按完成顺序写,转录漂移 | 并行执行、按发出顺序提交,转录确定 |
| 工具等审批 | 卡住整个循环 | 只挂起该工具 future,会话照常响应 |
| 执行中崩溃 | 上下文全丢 | 日志可重放,区分未执行 / 已执行 / 未知 |
| 模型看到的世界 | 实时变化 | Step 内冻结,回合结束统一采纳 |
| 对话超长 | 要么截断要么崩 | 压缩投影,完整转录仍在 |
| 写日志失败 | 无所谓(反正不写) | fail-closed,中止回合 |
10. 刻进循环的不变量
总览列过 17 条全局不变量,下面这些是 Loop 自己的"命根子",每一条都能在代码里找到对应的断言或结构保证:
- 同一时刻最多一个前台 Turn(Supervisor 的 debug_assert + Session 状态机);
- 同一主 Agent 最多一个 Step 推进转录(Step 硬串行);
- 模型只能调用当前 Step 暴露的工具(能力基线冻结);
- 权限通过前不能产生副作用(权限门 +
ToolExecutionStarted先落盘); - 工具结果按模型发出顺序提交,不以完成顺序改变转录(CommitSequence 排序);
- 结果持久化成功后才能进入下一次采样(
ToolResultCommittedfail-closed); - 取消必须等清理落地,不能只改 UI(yield + 治愈协议);
- 压缩不能留下悬挂工具调用(写边界修复);
rollout.jsonl是唯一事实源,内存转录只是投影(fail-closed 的根);- 工具 / Skill / MCP 目录在同一回合内默认稳定(冻结 + 叠加层)。
11. 小结
一句话总结 Grodex 的 Agent Loop:三层归属 + 两步确定。 三层归属------状态归 Supervisor、转录归 ChatStateActor、执行归 Coordinator,让一个长期运行的循环既能边跑边响应、又不会互相踩脚;两步确定------模型因果链的 Step 串行、工具并发结果按发出顺序提交,让"并发带来的速度"和"转录的确定性"兼得。而这一切外面,包着一层"先落盘再动作、失败即中止"的日志铠甲,把崩溃恢复变成了常态而不是事故。