跳到主要内容

数据截至 (上游 commit 65b4508389c8)

第 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-agent/src/agent/run/mod.rs,模块头部文档讲得极清楚)。它的规矩是:

AgentRun 拥有循环里的每一个「决策」——轮数计数、工具调用合法性校验、非法调用恢复、历史拼接、用量聚合、最终回复构造——但它自己不做任何 IO。

它对外只暴露一个「问答协议」:驱动器(driver)调 next_step() 问「下一步该干嘛」,机器回一个 AgentRunStep,驱动器照做、再把结果喂回来。三种步骤(crates/rig-agent/src/agent/run/mod.rs:159AgentRunStep):

步骤意思驱动器要做什么做完喂回
CallModel { prompt, history, turn }该调模型了发一次 completion 请求model_response(ModelTurn)
CallTools { calls }该执行工具了按任意并发跑这些工具tool_results(results)
Done(response)结束了拿走最终回复——

因为机器从不 await 任何东西,所以:

  • 它是运行时无关的(不绑 tokio)。
  • 整个 run 状态是 Serialize + Deserialize:工具挂起时能把 run 序列化存盘,换个进程 Deserialize 回来接着跑(crates/rig-agent/src/agent/run/mod.rs:16 模块文档)。
┌─────────────────── 驱动器 (做 IO) ───────────────────┐
│ │
│ run.next_step() ──► AgentRunStep │
│ ▲ │ │
│ │ ┌──────┼───────┐ │
│ │ ▼ ▼ ▼ │
│ │ CallModel CallTools Done │
│ │ │ │ │
│ │ 发HTTP │ 跑工具│ │
│ │ ▼ ▼ │
│ └── model_response / tool_results ◄──────────┤
│ │
└─────────────────────────────────────────────────────┘
AgentRun 只在框内「想」,IO 全在框外

手动驱动它长这样(crates/rig-agent/src/agent/run/mod.rs:44 模块文档示例):

// 示意,摘自 crates/rig-agent/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-agent/src/agent/run/mod.rs:365)。理解这几个状态,就理解了整个协议:

内部状态含义下一步
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-agent/src/agent/run/mod.rs:725)。它用 std::mem::replace(&mut self.state, RunState::Failed) 先把状态取出、默认置为 Failed——如果中途 panic 或逻辑漏了分支,机器会停在 Failed 而不是留在半吊子状态,这是防御式设计。

一个关键语义:轮数预算是「精确」的

轮数检查在 PreparingRequest 分支里(crates/rig-agent/src/agent/run/mod.rs:736):

// 示意,摘自 crates/rig-agent/src/agent/run/mod.rs:736
if self.current_turn >= self.max_turns {
return Err(PromptError::MaxTurnsError { .. });
}

max_turns模型调用总数的硬预算:初始调用、工具后续轮、重试全部各占一格,预算耗尽再要调模型就直接 MaxTurnsError。默认值是 1(只许初始一次调用,crates/rig-agent/src/agent/run/mod.rs:433);「工具轮 + 模型收尾作答」这种最普通的两段流,得显式给 2。CHANGELOG 把这次收紧记成了破坏性变更——旧版的 n 实际允许 n + 2 次调用,新版就是字面意义的 n 次(CHANGELOG.md:590)。这是 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-agent/src/agent/run/mod.rs:1333)——这正是第 1 章说的「工具结果以 user 身份回传」,且并行工具的多个结果合并成一条 user 消息,符合供应商对并行工具调用的要求。

tool_results 还做了严格校验(crates/rig-agent/src/agent/run/mod.rs:1296 起,按工具调用 ID 做多重集匹配):每个结果必须应答某个待处理的调用,且每个待处理调用都必须被应答——少一个、多一个、答重复了都报协议违规。因为供应商 API 就是这么要求的:有 tool_use 就必须有对应的 tool_result,否则下一轮请求会被拒。机器在这里替你守住了这条不变量。


2.5 非法工具调用:机器怎么兜底

模型有时会「幻觉」出一个不存在的工具名,或调一个本轮不被允许的工具。朴素实现要么崩、要么把错误 JSON 塞回去。AgentRun 把这件事做成一个可恢复的子协议。

model_response 发现某个工具调用不在允许列表里,它不直接失败,而是返回 ModelTurnOutcome::NeedsResolution(context)crates/rig-agent/src/agent/run/mod.rs:300),把决定权交给驱动器(通常驱动器再问业务的 hook)。驱动器给个动作,机器按五种语义处理(resolve_invalid_tool_callcrates/rig-agent/src/agent/run/mod.rs:1178):

恢复动作机器怎么做
Fail直接以 UnknownToolCall 错误结束
Retry { feedback }把这轮回滚,追加纠正反馈让模型重来(消耗总轮数预算)
Repair { tool_name }把工具名改成合法的,重新校验
Skip { reason }造一个合成的工具结果、跳过本轮所有工具调用(ToolChoice::None 下禁止 skip)
Stop { reason }用给定理由取消整个 run(PromptError::prompt_cancelled

这套设计的价值:「模型犯错」被当成一等状态来处理,而不是异常。 第 3 章会从工具侧再讲一遍这套恢复的用户接口。

还有个连带细节(crates/rig-agent/src/agent/run/mod.rs:1386):一旦某个工具调用被 skip,本轮所有工具调用都不执行,其余的会拿到一个合成的「因非法同伴未执行」结果(TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER)。这是为了保证「每个 tool_use 都有 tool_result」的不变量不被破坏。


2.6 Agent 与 AgentBuilder:状态机的外壳

AgentRun 是大脑,Agent 是你实际拿在手里的对象(crates/rig-agent/src/agent/completion.rs:573)。新版它瘦成了两个字段:一份 AgentConfig 配置加一个工具集句柄。配置被抽成了 AgentConfig 结构体crates/rig-agent/src/agent/completion.rs:585),builder、agent、runner 三方共享同一份——加一个配置项只需在一处声明(这次重构的动机就写在结构体文档里):

// 示意,摘自 crates/rig-agent/src/agent/completion.rs:573 Agent
pub struct Agent {
pub(crate) config: AgentConfig,
pub(crate) tool_server_handle: ToolServerHandle,
}

AgentConfig 里装着一次对话所需的全部配置:

字段作用
model底层 completion model(类型擦除的 ModelHandle,可被 hook 逐轮换模型)
preamble系统提示
static_context永远提供的上下文文档
max_turns总轮数预算(默认 1,见 2.3)
hooks默认 hook 栈(见第 5 章)
output_schema / output_mode结构化输出配置(见第 5 章 #1928)
memory / conversation_id对话记忆后端(见第 5 章)
tool_choice / temperature / max_tokens / additional_params逐项采样与透传参数

(RAG 的动态上下文不再是一个字段——它改成了 hook 实现,见第 4 章。)

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

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

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

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


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

AgentRun 只决策,真正驱动它跑完的是 AgentRunnercrates/rig-agent/src/agent/runner.rs:152)。它持有驱动所需的所有 IO 依赖(整份克隆来的 AgentConfig——含模型与 hook 栈——加上工具集句柄、ToolContext、并发度等),从 Agent 克隆而来(from_agentcrates/rig-agent/src/agent/runner.rs:182)。

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

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

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

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-agent/src/agent/runner.rs:854);streaming 版逐块转发。循环骨架、轮数逻辑、工具编排、记忆写入全部一份代码。这样两种模式行为天然一致AgentRunner 的结构体文档就写着「run()stream() 共享同一个循环、触发同样的事件」,crates/rig-agent/src/agent/runner.rs:145;仓库里成对的 blocking_hook / streaming_hook 断言测试在守这条线,如 crates/rig-agent/src/agent/runner.rs:6693multi_hook_stack_parity_across_run_and_stream)。

工具执行的编排也共享(drive_tool_callscrates/rig-agent/src/agent/prompt_request/streaming.rs:672):默认 concurrency <= 1 时严格按调用顺序执行(顺序 fail-fast);并发度更高时并行跑(buffer_unordered:813)。但注意新版语义是原子批 + fail-closed:749 注释):

  • 整批工具的结果先收集、后提交——批没全部落定前,不向流面发任何成功结果、不写历史(atomic per-batch)。
  • 一旦某个工具终止运行或 fail-closed 报错,还没启动的同伴不再启动(共享 terminating 标志,注释点名这是在避免「Semantic-Kernel 式 fail-open」);已在飞的跑完收尾,调用序号最低的终止者获胜
  • blocking 折叠(forward_items = false)不发流项,但收集/提交/fail-fast 行为完全相同,所以 run()stream() 返回同样的终局(:668 注释)。

2.8 本章小结与去向

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

代码地图

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