跳到主要内容

第 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:112AgentRunStep):

步骤意思驱动器要做什么做完喂回
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_stepCallToolsDone
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::Usercrates/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_callcrates/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」的不变量不被破坏。


2.6 Agent 与 AgentBuilder:状态机的外壳

AgentRun 是大脑,Agent<M> 是你实际拿在手里的对象(crates/rig-core/src/agent/completion.rs:532)。它持有一次对话所需的全部配置:

字段作用
model底层 completion model(Arc<M>,可克隆共享)
preamble系统提示
static_context永远提供的上下文文档
dynamic_contextRAG:prompt 时按相似度检索的上下文(见第 4 章)
tool_server_handle工具集句柄(见第 3 章)
default_max_turns默认多轮深度
hooks默认 hook 栈(见第 5 章)
output_schema / output_mode结构化输出配置(见第 5 章 #1928)
memory / default_conversation_id对话记忆后端(见第 5 章)

AgentBuilder 链式构造(crates/rig-core/src/agent/builder.rs)。有意思的是类型状态:一旦调 .tool(...),builder 的类型从 AgentBuilder<M, NoToolConfig> 变成 AgentBuilder<M, WithBuilderTools>crates/rig-core/src/agent/builder.rs:350)——用类型系统防止你把「静态工具」和「MCP 工具服务器」两种互斥配置搞混。

Agent 实现了第 1 章的 Prompt/Chat/Completion 特征。但有个精妙处:Agent::prompt 并不直接返回 future,而是返回一个 PromptRequestcrates/rig-core/src/agent/completion.rs:635):

// 示意,摘自 crates/rig-core/src/agent/completion.rs:635
fn prompt(&self, prompt: ...) -> PromptRequest<prompt_request::Standard, M> {
PromptRequest::from_agent(self, prompt)
}

PromptRequest 实现了 IntoFuture,所以你 .await 它就执行,但在 .await 之前还能链式加 .max_turns(5).add_hook(...).extended_details()crates/rig-core/src/agent/prompt_request/mod.rs)。这就是「.prompt(x).await」和「.prompt(x).max_turns(5).await」都成立的原因——一个能延迟执行的构建器。


2.7 驱动器:blocking 与 streaming 共用一台机器

AgentRun 只决策,真正驱动它跑完的是 AgentRunnercrates/rig-core/src/agent/runner.rs:261)。它持有 AgentRun 需要的所有 IO 依赖(model、tool_server_handle、hooks、并发度等),从 Agent 克隆而来(from_agentcrates/rig-core/src/agent/runner.rs:298)。

最漂亮的一手在 drive_agentcrates/rig-core/src/agent/prompt_request/streaming.rs:512):它的文档一句话点破:

「唯一的 agent 驱动循环,blocking 和 streaming 两个界面共用。」

这个循环拥有「介质无关」的部分——next_step 分派、CompletionCall hook、请求准备、Done 时写记忆——而把「介质相关」的部分(怎么调模型、怎么跑工具、span 怎么塑形)委托给一个 TurnSource 特征(crates/rig-core/src/agent/prompt_request/streaming.rs:534):

drive_agent (共享循环)
│ next_step()
┌────────┼─────────┬──────────┐
▼ ▼ ▼ ▼
CallModel CallTools Done (错误)
│ │
│ └─► source.run_tool_calls() ┐ TurnSource 特征
└─► source.run_model_turn() ┘ 两种实现:
· UnaryTurnSource (blocking)
· 流式 source (streaming)

blocking 和 streaming 的差别只在 TurnSource:blocking 版把模型回复一次收全(UnaryTurnSource::run_model_turncrates/rig-core/src/agent/runner.rs:749);streaming 版逐块转发。循环骨架、轮数逻辑、工具编排、记忆写入全部一份代码。这样两种模式行为天然一致(仓库里大量测试就在断言 blocking 和 streaming 产出相同结果,如 crates/rig-core/src/agent/runner.rs:1093 附近)。

工具执行的编排也共享(drive_tool_callscrates/rig-core/src/agent/prompt_request/streaming.rs:673):默认 concurrency <= 1 时顺序执行、严格交错;并发度更高时并行跑。但无论哪种,都是「全跑完再决定」——即使某个工具要求终止,其余工具也照跑完,保证两种并发下两个界面步调一致(crates/rig-core/src/agent/prompt_request/streaming.rs:688 注释)。


2.8 本章小结与去向

  • Rig 把 agent 多轮循环做成 sans-IO 状态机 AgentRun:只决策、不做 IO、可序列化、能换进程恢复。
  • 协议是「next_stepAgentRunStep,驱动器做 IO 再喂回」,三种步骤 CallModel / CallTools / Done。
  • 机器守住关键不变量:轮数上限(留一轮余量)、每个 tool_use 必有 tool_result、非法工具调用可 Fail/Retry/Repair/Skip 恢复。
  • Agent 是外壳,PromptRequest 是能延迟执行的构建器,AgentRunner + drive_agent 让 blocking/streaming 共用同一台机器。
  • 工具本身怎么定义、怎么动态分发、MCP 怎么接 → 第 3 章。
  • RAG 的动态上下文/动态工具怎么进来 → 第 4 章。

代码地图

主题文件符号
状态机(模块文档 + 协议)crates/rig-core/src/agent/run/mod.rsAgentRun
驱动步骤crates/rig-core/src/agent/run/mod.rsAgentRunStep
转移函数crates/rig-core/src/agent/run/mod.rsAgentRun::next_step
喂入模型回复crates/rig-core/src/agent/run/mod.rsAgentRun::model_response
喂入工具结果crates/rig-core/src/agent/run/mod.rsAgentRun::tool_results
非法工具调用恢复crates/rig-core/src/agent/run/mod.rsresolve_invalid_tool_call
Agent 类型crates/rig-core/src/agent/completion.rsAgent
构建器(类型状态)crates/rig-core/src/agent/builder.rsAgentBuilder
延迟执行的请求构建器crates/rig-core/src/agent/prompt_request/mod.rsPromptRequest
驱动器crates/rig-core/src/agent/runner.rsAgentRunner
共享驱动循环crates/rig-core/src/agent/prompt_request/streaming.rsdrive_agent
工具编排crates/rig-core/src/agent/prompt_request/streaming.rsdrive_tool_calls
blocking 介质实现crates/rig-core/src/agent/runner.rsUnaryTurnSource