rig — 本课题摘录
读了哪几篇: 02-agent-loop(Agent 与无 IO 多轮状态机)、03-tools(工具系统)。
其余三篇(统一 completion 抽象、RAG 与嵌入、进阶机制)本轮没读。
这一家是"循环该怎么切"这一问最干净的答案。 它自己那一章的开头写着"这是全库最精华的一章",不是自夸。
它对本课题回答了什么
决定一(架构):把决策和 IO 彻底分开
它先把裸 while 的三个毛病点出来:
| 毛病 | 说的是什么 |
|---|---|
| 混着 IO 和决策 | 发请求、跑工具跟"该不该继续、轮数够不够、工具名合不合法"搅在一起,难测难复用 |
| 工具跑很久时进程崩了,整轮对话就丢了 | 状态活在内存里 |
| 流式和非流式各写一份循环 | 逻辑漂移、行为不一致 |
它的答案:一台完全不做 IO 的状态机拥有循环里的每一个决策——轮数计数、工具调用合法性校验、非法调用恢复、历史拼接、用量聚合、最终回复构造——但它自己不发一个请求、不跑一个工具。 (依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— AgentRun 拥有循环里的每一个决策(轮数计数/工具合法性校验/非法调用恢复/历史拼接/用量聚合/最终回复构造),但自己不做任何 IO)
它对外只暴露一个"问答协议"
驱动器问"下一步该干嘛",机器回一个步骤,驱动器照做、再把结果喂回来。只有三种步骤:
| 步骤 | 驱动器要做什么 | 做完喂回 |
|---|---|---|
| 该调模型了 | 发一次请求 | 模型这一轮的回复 |
| 该执行工具了 | 按任意并发跑这些工具 | 工具结果 |
| 结束了 | 拿走最终回复 | —— |
(依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— AgentRunStep 只有三种——CallModel / CallTools / Done,驱动器照做后用 model_response / tool_results 把结果喂回)
因为机器从不等待任何东西,连带两个大好处:
- 它跟运行时无关——不绑任何异步框架;
- 整个运行状态可以序列化——工具挂起时把状态存盘,换个进程读回来接着跑。
(依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— 因为状态机从不 await,它运行时无关,且整个 run 状态是 Serialize+Deserialize 的,可存盘后换进程恢复接着跑)
跟 openai-agents-js 的"运行状态能存成字符串"是同一个结论的两条不同路子: 一个是"把状态设计成可序列化的",另一个是**"把 IO 拿出去,剩下的自然就可序列化了"**。 后者更彻底——不是努力让状态能存,而是让状态里根本没有存不下的东西。
决定四:内部状态与"精确的轮数预算"
机器内部用一个枚举表示当前处于哪个阶段:准备发请求 → 等模型回复 → 逐个校验工具调用是否合法 → 准备决定"执行工具还是结束" → 等工具结果 → 终态。
一处防御式设计值得抄: 状态转移时先把当前状态取出、默认置成"失败",转移成功再写入新状态。如果中途出错或漏了分支,机器会停在"失败"而不是留在半吊子状态。 (依据:前沿库 · Rig · Agent 与无 IO 多轮 状态机 —— next_step 用 mem::replace 把状态取出并默认置为 Failed,若中途 panic 或漏分支,机器会停在 Failed 而非半吊子状态)
决定二/三:模型幻觉出不存在的工具 —— 五种恢复动作
这是别家都没做到这么细的一处。 模型有时会编一个不存在的工具名,或调一个本轮不被允许的工具。朴素实现要么崩、要么把错误塞回去。
它把这件事做成一个可恢复的子协议:机器发现非法调用时不失败,而是把决定权交给驱动器,驱动器给一个动作,机器按五种语义处理:
| 恢复动作 | 机器怎么做 |
|---|---|
| 失败 | 直接以"未知工具调用"错误结束 |
| 重试 | 把这轮回滚,追加纠正反馈让模型重来(消耗总轮数预算) |
| 改名 | 把工具名改成合法的,重新校验 |
| 跳过 | 造一个合成的工具结果,跳过本轮所有工具调用 |
| 停止 | 用给定理由取消整个运行 |
(依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— 非法工具调用交给驱动器决定,五种恢复动作 Fail/Retry{feedback}/Repair{tool_name}/Skip{reason}/Stop{reason},分别对应报错/回滚重来/改名重校验/合成结果跳过/取消整个 run)
"改 名"这一档最实用——模型把 search 写成 serch 是常见错误,直接改回来比让它重来一轮便宜得多。
一个连带的不变量值得记住: 一旦某个工具调用被跳过,本轮所有工具调用都不执行,其余的会拿到一个合成的"因非法同伴未执行"结果。 (依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— 某个工具调用被 skip 时本轮所有工具调用都不执行,其余拿到合成的 TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER 结果,以保证「每个 tool_use 都有 tool_result」的不变量)
理由是"每个工具调用都必须有一个工具结果"这条不变量不能破。 这条不变量本身就值得抄——历史里出现"有调用没结果",很多厂商的 API 会直接报错。
决定四补充:流式和非流式共用同一台机器
它把这件事写成一句话:"唯一的 agent 驱动循环,流式和非流式两个界面共用。" (依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— drive_agent 的文档写明它是「唯一的 agent 驱动循环,blocking 和 streaming 两个界面共用」)
这跟 openai-agents-js 的"两条平行循环共用三个函数"是同一目标的两种做法。rig 更彻底:根本只有一条循环。
它的做法(可以抄的部分)
把配置抽成一个结构体,建造器、agent、驱动器三方共享同一份——加一个配置项只需在一处声明。这次重构的动机它写在结构体文档里。 (依据:前沿库 · Rig · Agent 与无 IO 多轮状态机 —— AgentConfig 被抽成结构体由 builder/agent/runner 三方共享,加配置项只需一处声明,动机写在结构体文档里)
它没回答什么
- 历史怎么压——这两篇没讲长对话瘦身。
- 系统提示怎么拼——只说"历史拼接",没讲细节。
- 工具清单怎么每轮变——有"本轮允许列表"的概念,但没讲这个列表怎么算出来。要看
beeai-framework。
坑与代价
- "无 IO 状态机"要求所有状态都能序列化。 这条是它的力量来源,也是它的约束:工具如果需要持有一个活的连接(浏览器会话、数据库事务),这条路就得改成"状态里存句柄 id,句柄另存"。
- 问答式协议把复杂度转移给了驱动器。 机器很干净,但"怎么并发跑工具、怎么重 试网络、怎么限流"全落在驱动器身上——这部分工作量没消失,只是挪了地方。
判断(无锚): 对我们最小原型来说,这个切法值得从第一天就用——因为驱动器那部分我们本来就要写,而把决策抽出来能让"停止条件"这块变得可单测。 如果错,会错在: 如果第一版只有一个工具、循环不超过三轮,那分成"机器 + 驱动器"两层是纯粹的额外概念,直接一个 while 更快。