processconversationturn 源码拆解 本节摘要:本章前两节讲了 Agent 的状态容器(SessionActor)与运行入口,现在进入真正的核心——那个让 Agent 能多轮「思考-行动」的过程。这个过程集中在一个叫 的函数里(源码在 )。它是 Agent 的心脏:接收一个用户消息,然后循环地「准备工具→组装请求→调模型→处理响应→执行工具→回填结果→决定下一轮」,直到模型不再需要工具,本轮对话才算结束。本节会用伪代码逐段拆解这个循环,让你看清 Agent 「转起来」的每一个齿轮。这是全书最值得反复咀嚼的一节。 一、为什么需要「循环」而不是「一次调用」 在拆代码之前,先理解为什么 Agent 需要循环,而不是「调一次模型就完事」。
本节摘要:本章前两节讲了 Agent 的状态容器(SessionActor)与运行入口,现在进入真正的核心——那个让 Agent 能多轮「思考-行动」的过程。这个过程集中在一个叫
process_conversation_turn的函数里(源码在xai-grok-shell/src/session/acp_session_impl/turn.rs)。它是 Agent 的心脏:接收一个用户消息,然后循环地「准备工具→组装请求→调模型→处理响应→执行工具→回填结果→决定下一轮」,直到模型不再需要工具,本轮对话才算结束。本节会用伪代码逐段拆解这个循环,让你看清 Agent 「转起来」的每一个齿轮。这是全书最值得反复咀嚼的一节。
在拆代码之前,先理解为什么 Agent 需要循环,而不是「调一次模型就完事」。
最朴素的聊天是「一问一答」:用户说一句,模型回一句,结束。这种交互只需要一次模型调用。
但 Agent 要做的是任务,而任务往往无法一步完成。比如「修复这个 bug」,Agent 可能需要:
每一步都依赖前一步的结果。模型无法在第一次调用时就预知所有信息,它需要边做边看。这就是「思考-行动循环」(在国际上常被称为 ReAct,即 Reason + Act)的本质:让模型把大任务拆成小步,每步行动后基于结果继续推理。
process_conversation_turn 就是承载这个循环的函数。它接收用户的一条消息,跑完整个循环,直到模型认为任务完成(或达到某些终止条件),才返回。
先看这个循环的整体骨架(大幅简化的伪代码,只保留主干):
async fn process_conversation_turn(req_id, user_message, ...) -> TurnOutcome { 把 user_message 追加到会话历史 发出 LoopStarted 事件 loop { # 思考-行动循环 # ── 准备阶段 ── drain_pending_interjections() # 处理用户中途插话 flush 各种 reminder(skill / monitor / memory) check_auto_compact_needed() # 必要时压缩上下文 tools = prepare_tool_definitions(...) # ① 准备可用工具列表 request = chat_state.build_request(tools) # ② 组装模型请求 发出 PhaseChanged(WaitingForModel) # ── 调用阶段 ── match run_turn_via_sampler(request) { # ③ 下沉到 sampler Response(resp) => { 处理响应 } CompactAndResubmit => { 压缩上下文,continue } RefreshAuthAndResubmit => { 刷认证,backoff,continue } } 记录 token 用量 # ── 决策阶段 ── if resp 包含工具调用 { execute_tools(resp.tool_calls) # ④ 执行工具 把工具结果塞回 chat_state # ⑤ 回填历史 continue # 继续下一轮思考 } else { finalize_turn_bookkeeping() # ⑥ 收尾 发出 TurnEnded return TurnOutcome::Done } } }
这个骨架揭示了循环的三个核心阶段:准备、调用、决策。每一轮迭代都完整跑一遍这三个阶段。下面逐段拆解。
每轮循环开始,先做准备。这一阶段做四件事:
drain_pending_interjections() # 用户可能在 Agent 思考时插话 flush skill/monitor/memory reminders # 系统提醒(如有 skill 匹配、监控触发)
插话(interjection):Agent 正在一轮长循环里跑时,用户可能想补充信息或纠正方向。这些插话被缓存,在每轮开始时排干(drain),合并进当前轮的上下文。这让用户不必等 Agent 跑完才能说话。
reminder:系统级的提醒,比如某个 Skill 与当前任务匹配应该被激活、某个监控任务触发、记忆系统有相关条目。这些提醒在每轮刷新,确保 Agent 能感知到最新的系统状态。
check_auto_compact_needed() # 上下文接近窗口上限时压缩
这是第 7 章的重点,这里先提一句:当会话历史的 token 数逼近模型上下文窗口的一定比例(默认 85%),会触发自动压缩——把较早的对话总结成摘要,腾出空间。压缩在循环里发生,而非独立的预处理,因为它要考虑「当前轮的请求组装」。
tools = prepare_tool_definitions(...)
这一步把「当前会话可用的工具」整理成模型能理解的格式。可用工具包括:
每个工具的「定义」包含它的名字、描述、参数 JSON Schema——模型根据这些信息决定何时、如何调用。这一步可能需要等待 MCP 初始化完成,所以有超时控制。
request = chat_state.build_request(tools)
ChatStateActor(下一节详谈)把以下内容组装成完整的模型请求:
这个组装是「有状态的」——它依赖 chat_state 里维护的完整历史,而非每次从头发所有内容。压缩后的历史也是从这里取。
match run_turn_via_sampler(request) { Response(resp) => { ... } CompactAndResubmit => { ... } RefreshAuthAndResubmit => { ... } }
这是循环里「真正调用模型」的步骤。注意它不直接调 HTTP,而是通过一个叫 run_turn_via_sampler 的桥接函数(下一节详谈)下沉到 sampler 层。
这个函数返回三种可能的结果:
注意后两种情况不结束循环,而是处理错误后重试。这是循环鲁棒性的体现——临时性的错误不应该让整个任务失败。
关键概念:循环把「可恢复的错误」(上下文超限、认证失效)与「正常流程」(拿到响应)统一处理,通过 continue 实现重试。这种设计让 Agent 对网络与服务端的波动具有韧性。
拿到 Response(resp) 后,循环进入决策:
if resp 包含工具调用 { execute_tools(resp.tool_calls) 把工具结果塞回 chat_state continue # 继续下一轮思考 } else { finalize_turn_bookkeeping() 发出 TurnEnded return TurnOutcome::Done }
有工具调用:执行它们,把结果塞回历史,进入下一轮。这是循环之所以是「循环」的原因——工具结果成为下一轮推理的输入。
无工具调用:模型给出了纯文本回复,没有要求执行任何工具。这意味着模型认为任务完成(或至少本轮不需要再动手),循环结束。
执行工具(execute_tools)的内部:
for call in resp.tool_calls: 1. 在 tool_bridge 查到工具(按名字,如 "GrokBuild:bash") 2. 构造调用上下文 ToolCallContext { call_id, extensions: 含 cwd、权限模式、取消令牌、会话上下文、 工作区绑定元数据(yolo_mode、capability_mode、tools 白名单) } 3. 调 tool.execute_dyn(ctx, input) → 内部走 ToolDispatch::call_streaming() → 鉴权(PreToolUse hooks → 权限规则 → remembered → 内建放行 → 提示) → 实际执行(可能跨 leader/pager) → 标准化输出 4. 流式产出 Progress 增量(实时回显给用户) 5. 流式产出 Terminal(最终结果) 6. 把结果作为新消息(tool_result)塞回 chat_state
这一段涉及工具系统(第 5 章)、权限管线(第 6 章),这里先建立印象:工具执行不是简单的「调函数」,而是要经过鉴权、流式输出、结果标准化等一整套流程。
把整个循环看下来,有几个设计点值得专门琢磨:
传统想象里,「用户发消息→Agent 处理→Agent 回复」是严格的请求-响应。但 Grok Build 的循环每轮开始都 drain 插话,意味着用户在 Agent 长循环跑的过程中说的话,会被合并进当前轮。这让交互更自然——你不用憋到 Agent 跑完才能补充。
CompactAndResubmit 与 RefreshAuthAndResubmit 不是异常分支,而是循环的正常组成部分。它们用 continue 把自己融入循环,让错误处理与正常流程共用同一套结构。这种「错误即流程」的设计,比把错误处理散落在各处要清晰得多。
工具执行的结果,不是直接给用户看,而是作为新的「tool_result 消息」塞回会话历史。下一轮调用模型时,模型会看到这条消息,基于它继续推理。这是 ReAct 模式的精髓——行动的结果成为下一步思考的输入。
循环什么时候结束?答案是「模型这一轮的响应不包含工具调用」。这是一个协作式的终止——终止权在模型手里,框架只负责尊重模型的决定。当然,也有保护机制:最大轮数(--max-turns)、用户取消(Cancel)、致命错误等会强制终止。
注意循环每轮都重做「准备工具」「组装请求」。这不是浪费——因为每轮的上下文都不同(上一轮的工具结果加进来了),可用工具也可能变化(如某个 MCP server 在循环中连上了)。重新准备保证每轮都基于最新状态。
用一张流程图固化整个循环:
这张图建议反复回看。后续章节(工具系统、会话压缩、错误处理)都会回到这张图的某个节点展开。
最后,用一个具体例子把循环「展开」,直观感受多轮:
假设用户说「修复 src/main.rs 里的编译错误」。循环可能这样跑:
─── 第 1 轮 ─── 准备工具(含 ReadFile、bash、Edit 等) 组装请求(历史里只有用户这一句) 下沉 sampler → 模型回复:"我先读一下这个文件" + 工具调用 ReadFile(src/main.rs) 执行 ReadFile → 文件内容 结果塞回历史 continue ─── 第 2 轮 ─── 准备工具 组装请求(历史里现在有用户消息 + 模型回复 + 文件内容) 下沉 sampler → 模型回复:"我看到第 42 行有语法错误,先跑一次编译确认" + 工具调用 bash(cargo build) 执行 bash(走鉴权:bash 跑 cargo build 可能被询问或自动放行)→ 编译错误输出 结果塞回历史 continue ─── 第 3 轮 ─── 组装请求(历史又多了上一轮的工具结果) 下沉 sampler → 模型回复:"错误在第 42 行少了分号,我来修复" + 工具调用 Edit(src/main.rs, ...) 执行 Edit(走鉴权:编辑文件可能询问用户)→ 编辑成功 结果塞回历史 continue ─── 第 4 轮 ─── 组装请求 下沉 sampler → 模型回复:"再跑一次编译验证" + 工具调用 bash(cargo build) 执行 bash → 编译通过 结果塞回历史 continue ─── 第 5 轮 ─── 组装请求 下沉 sampler → 模型回复:"已修复,第 42 行补了分号,编译通过。" (纯文本,无工具调用) finalize,发出 TurnEnded return Done
用户只说了一句话,Agent 内部跑了 5 轮,调了 5 次模型、5 次工具。这就是「思考-行动循环」的实际形态——简单的话语背后,是复杂的迭代。
下一节,我们认识循环的「记忆」——ChatStateActor,看会话历史与 token 计数如何被专门管理。