跳到主要内容

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 把结果喂回)

因为机器从不等待任何东西,连带两个大好处:

  1. 它跟运行时无关——不绑任何异步框架;
  2. 整个运行状态可以序列化——工具挂起时把状态存盘,换个进程读回来接着跑。

(依据:前沿库 · 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 更快。