第 2 章 · Agent 与无 IO 多轮状态机
本章讲什么: 这是全库最精华的一章。Rig 把「模型调工具、循环好几轮才给最终答案」这套逻辑,抽成一台完全不做 IO、可以序列化到磁盘、换个进程还能恢复的状态机
AgentRun。看懂它,你会明白一个好 agent 循环该怎么设计。
2.1 先看要解决的问题
一次 agent 对话不是「问一句答一句」,而可能是多轮:
用户: 查一下北京天气再总结
→ 模型: "我要调 get_weather('北京')" (第 1 轮:工具调用)
→ 你: 执行 get_weather → "晴 25℃"
→ 模型: "北京今天晴,25 度" (第 2 轮:最终文本)
朴素写法是一个 while 循环:调模型 → 看有没有工具调用 → 有就执行、把结果塞回历史、再调模型 → 没有就返回。问题来了:
- 循环里混着 IO(发 HTTP、跑工具)和决策(该不该继续、轮数够不够、工具名合不合法),很难测、很难复用。
- 工具可能跑很久(比如调外部服务),这期间进程崩了,整轮对话就丢了。
- blocking 和 streaming 两种模式很容易各写一份循环,逻辑漂移、行为不一致。
Rig 的答案:sans-IO(无 IO)状态机。
2.2 核心思想:决策与执行分离(sans-IO)
AgentRun 是这台状态机(crates/rig-core/src/agent/run/mod.rs,模块头部文档讲得极清楚)。它的规矩是:
AgentRun拥有循环里的每一个「决策」——轮数计数、工具调用合法性校验、非法调用恢复、历史拼接、用量聚合、最终回复构造——但它自己不做任何 IO。
它对外只暴露一个「问答协议」:驱动器(driver)调 next_step() 问「下一步该干嘛」,机器回一个 AgentRunStep,驱动器照做、再把结果喂回来。三种步骤(crates/rig-core/src/agent/run/mod.rs:112,AgentRunStep):
| 步骤 | 意思 | 驱动器要做什么 | 做完喂回 |
|---|---|---|---|
CallModel { prompt, history, turn } | 该调模型了 | 发一次 completion 请求 | model_response(ModelTurn) |
CallTools { calls } | 该执行工具了 | 按任意并发跑这些工具 | tool_results(results) |
Done(response) | 结束了 | 拿走最终回复 | —— |
因为机器从不 await 任何东西,所以:
- 它是运行时无关的(不绑 tokio)。
- 整个 run 状态是
Serialize + Deserialize:工具挂起时能把 run 序 列化存盘,换个进程Deserialize回来接着跑(crates/rig-core/src/agent/run/mod.rs:16模块文档)。
┌─────────────────── 驱动器 (做 IO) ───────────────────┐
│ │
│ run.next_step() ──► AgentRunStep │
│ ▲ │ │
│ │ ┌──────┼───────┐ │
│ │ ▼ ▼ ▼ │
│ │ CallModel CallTools Done │
│ │ │ │ │
│ │ 发HTTP │ 跑工具│ │
│ │ ▼ ▼ │
│ └── model_response / tool_results ◄──────────┤
│ │
└─────────────────────────────────────────────────────┘
AgentRun 只在框内「想」,IO 全在框外
手动驱动它长这样(crates/rig-core/src/agent/run/mod.rs:29 模块文档示例):
// 示意,摘自 crates/rig-core/src/agent/run/mod.rs 模块文档
let mut run = AgentRun::new("What is 2+2?").max_turns(3);
loop {
match run.next_step()? {
AgentRunStep::CallModel { prompt, history, .. } => {
// 你去发请求,然后 run.model_response(ModelTurn { ... })?;
}
AgentRunStep::CallTools { calls } => {
// 你去跑工具,然后 run.tool_results(results)?;
}
AgentRunStep::Done(response) => { println!("{}", response.output); break; }
}
}
2.3 状态机内部:它到底记了哪些状态
AgentRun 内部用一个私有枚举 RunState 表示当前处于哪个阶段(crates/rig-core/src/agent/run/mod.rs:249)。理解这几个状态,就理解了整个协议:
| 内部状态 | 含义 | 下一步 |
|---|---|---|
PreparingRequest | 准备发请求 | next_step 会吐 CallModel |
AwaitingModel | 已吐 CallModel,等模型回复 | 驱动器调 model_response |
ResolvingToolCalls | 正在逐个校验本轮工具调用是否合法 | 合法则前进,非法则要驱动器 resolve_invalid_tool_call |
AwaitingAdvance | 本轮已被接受,准备决定「执行工具还是结束」 | next_step 吐 CallTools 或 Done |
ExecutingTools | 已吐 CallTools,等工具结果 | 驱动器调 tool_results |
Done / Failed | 终态 | —— |
next_step() 本质是这些状态之间的转移函数(crates/rig-core/src/agent/run/mod.rs:543)。它用 std::mem::replace(&mut self.state, RunState::Failed) 先把状态取出、默认置为 Failed——如果中途 panic 或逻辑漏了分支,机器会停在 Failed 而不是留在半吊子状态,这是防御式设计。
一 个巧妙细节:轮数上限的判定
轮数检查在 PreparingRequest 分支里(crates/rig-core/src/agent/run/mod.rs:554):
// 示意,摘自 crates/rig-core/src/agent/run/mod.rs:554
if self.current_turn > self.max_turns + 1 {
return Err(PromptError::MaxTurnsError { .. });
}
注意是 max_turns + 1:留出一轮余量,让模型在「工具轮用完后」还能再被调一次去产出最终文本,而不是在最后一次工具结果后直接因超限报错。这种「差一」处理是 agent 循环的常见坑,Rig 显式处理了。
2.4 一次完整多轮的状态流转
把「查天气再总结」那个例子对着状态机走一遍:
AgentRun::new("查天气再总结") state = PreparingRequest
│ next_step()
▼
CallModel(turn=1) ───────────► driver 发请求 state = AwaitingModel
│ model_response(工具调用: get_weather)
▼
(内部)ResolvingToolCalls: get_weather 在允许列表里 → 合法
▼
AwaitingAdvance: 有工具调用 → next_step()
▼
CallTools([get_weather]) ─────► driver 跑工具 state = ExecutingTools
│ tool_results(["晴 25℃"]) → 结果作为 User 消息追加进历史
▼
PreparingRequest(回到开头)
│ next_step()
▼
CallModel(turn=2) ───────────► driver 发请求 state = AwaitingModel
│ model_response(纯文本: "北京今天晴 25 度")
▼
AwaitingAdvance: 无工具调用 → next_step()
▼
Done("北京今天晴 25 度")
工具结果怎么塞回历史?在 tool_results 里,所有结果被拼成一条 Message::User(crates/rig-core/src/agent/run/mod.rs:1012)——这正是第 1 章说的「工具结果以 user 身份回传」,且并行工具的多个结果合并成一条 user 消息,符合供应商对并行工具调用的要求。
tool_results 还做了严格校验(crates/rig-core/src/agent/run/mod.rs:965):每个结果必须应答某个待处理的调用,且每个待处理调用都必须被应答——少一个都报协议违规。因为供应商 API 就是这么要求的:有 tool_use 就必须有对应的 tool_result,否则下一轮请求会被拒。机器在这里替你守住了这条不变量。
2.5 非法工具调用:机器怎么兜底
模型有时会「幻觉」出一个不存在的工具名,或调一个本轮不被允许的工具。朴素实现要么崩、要么把错误 JSON 塞回去。AgentRun 把这件事做成一个可恢复的子协议。
当 model_response 发现某个工具调用不在允许列表里,它不直接失败,而是返回 ModelTurnOutcome::NeedsResolution(context)(crates/rig-core/src/agent/run/mod.rs:210),把决定权交给驱动器(通常驱动器再问业务的 hook)。驱动器给个动作,机器按四种语义处理(resolve_invalid_tool_call,crates/rig-core/src/agent/run/mod.rs:839):
| 恢复动作 | 机器怎么做 |
|---|---|
Fail | 直接以 UnknownToolCall 错误结束 |
Retry { feedback } | 把这轮回滚,追加纠正反馈让模型重来(消耗一次重试预算,也消耗多轮深度) |
Repair { tool_name } | 把工具名改成合法的,重新校验 |
Skip { reason } | 造一个合成的工具结果、跳过本轮所有工具调用(ToolChoice::None 下禁止 skip) |
这套设计的价值:「模型犯错」被当成一等状态来处理,而不是异常。 第 3 章会从工具侧再讲一遍这套恢复的用户接口。
还有个连带细节(crates/rig-core/src/agent/run/mod.rs:1064):一旦某个工具调用被 skip,本轮所有工具调用都不执行,其余的会拿到一个合成的「因非法同伴未执行」结果(TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER)。这是为了保证「每个 tool_use 都有 tool_result」的不变量不被破坏。