把 coding agent 做成库:rpi 的架构、历史与集成取舍
“把这个配置里的端口改成 8080。”
在终端里,这是一条普通的 coding-agent 请求:读文件、修改、显示结果。如果要把同样的能力接进编辑器或内部服务,事情就多了一层。界面得知道工具什么时候开始,应用得决定取消后怎么办,进程重启后还得分清任务究竟做到了哪一步。
模型可以提出修改,却不会替应用管理这些状态。把 agent 接进产品,最终要回答的是:谁来对这次任务的执行过程负责?
rpi 选择先提供运行时库,再在上面构建 CLI。它最初是 Mario Zechner 发起的 Pi TypeScript SDK 的 Rust 重新实现,后来发展出自己的执行基础设施和插件的二进制接口(ABI)。本文核心架构以 0.5.0、提交 e247f58 为准;仓外工具扩展的例子另参考 rpi-package 的 f43bfc0。
我看这个项目时,最关心的不是它拆出了九个 crate,而是应用拿走终端之后,剩下的运行时还能不能把一项任务交代清楚。正常跑通只是起点;中途崩溃、换成插件工具,才更能看出这些边界有没有用。
先把终端拿走,看看还剩下什么
改端口的任务至少有三种工作:把消息发给模型,根据模型的工具调用读写文件,把执行过程变成界面能显示的事件。终端负责最后的显示,并不需要参与前两件事。
rpi 把模型服务适配器(provider)放在 rpi-ai,把“请求模型、执行工具、将结果送回下一轮”的循环放在 rpi-agent。rpi-tools 提供文件与 shell 等工具,rpi-cli 再将这些能力装配成终端程序。应用若要自己画界面,可以从循环这一层接入,不必先启动 CLI。
这层关系在最小 SDK 示例里就能看出来。下面只节选构造和调用部分,model、provider 与 make_stream_fn 的准备代码见文末的完整示例,片段不能单独运行:
let agent = AgentBuilder::new()
.model(model)
.stream_fn(make_stream_fn(provider))
.build()
.expect("agent builds");
let mut events = agent.subscribe();
agent.prompt("hi").await.expect("prompt completes");调用方持有 agent,先订阅事件,再发出请求。CLI 也使用这套运行时,并没有另一份专供终端的循环。需要保存会话、压缩上下文时,再加入 rpi-harness。harness 可以理解为把循环、工具与会话存储接起来的装配层,它不要求调用方使用终端界面。
图里先看从 CLI 向下的主干:输入交给 harness,harness 驱动 agent,agent 通过 rpi-ai 对接模型,再调用工具。旁边的终端组件、观测和插件加载,是接在这条主干上的其他能力。
图 1:核心产品 crate 的运行时分工。箭头表示调用、持有或数据流向,不是静态依赖;未展开辅助桥接库和仓外扩展包。依据提交 e247f58 整理。交互版与源码引用
能不能拆走,不能只看图上的方框。实际依赖里,rpi-agent 不依赖 CLI,harness 也不导入扩展加载器 rpi-extensions,而是通过 AgentHarnessOptions 接收适配器。换成嵌入应用后,调用方可以提供自己的装配,不必顺带接收整个终端宿主。
循环与具体实现之间,也有两处可替换的接口。模型这一侧是 StreamFn:调用方提供一个返回消息事件流的函数,循环消费流,不替应用选择供应商。工具这一侧是 AgentTool:工具公开参数定义、执行入口、取消信号和进度回调,循环按名字分发调用。内置文件工具再通过 ExecutionEnv 访问文件系统和 shell。因此,要换模型、换工具或换执行环境,不必一起改循环。StreamFn 同步返回流,只是调用边界的约定,消费流和运行循环仍是异步过程。
终端也确实拆出了一层,但不是把整个交互应用都搬进了 rpi-tui。这个 crate 提供编辑器、布局、Markdown 和消息渲染组件,依赖里没有其他 rpi-* crate;模型选择、会话切换和扩展命令的业务调度仍在 CLI。一个具体例子是插件对 Markdown 的变换:CLI 从扩展注册表构造闭包,再交给渲染组件,组件不直接依赖插件加载器。嵌入应用可以不用终端组件,另一个终端应用也可以复用组件而不带上 agent。
这种拆分还改变了测试方式。同一个改端口任务,可以把真实模型换成按脚本返回工具调用的 faux provider,把真实文件系统换成 InMemoryExecutionEnv。工具实现仍通过 ExecutionEnv 读写,只是这次读写发生在内存里。测试于是能断言文件内容、工具结果和下一轮消息,而不必押注模型这次是否愿意调用正确的工具。
rpi-ai 默认不启用真实 HTTP provider 依赖,为这条离线路径留出了空间。不过,离线测试成立的原因是替换了外部服务和执行环境,不是程序被禁止联网;自定义工具、其他依赖和外部进程仍可能访问网络。
正常调用的代码细节,站内 《Agent 源码看不懂?先跟着一条消息走一遍》 已经展开过。不过,能替换一个接口,还不等于所有实现都应该放在核心仓库里。
供应商和工作流,怎样移出核心
前面说的拆分,有的是核心仓库内部的独立库,有的则已经跨出了仓库边界。模型接入能看出这两种拆法的区别:rpi-ai 保留 Anthropic Messages、OpenAI Chat Completions、OpenAI Responses 等通用协议和测试用的 Faux;具体厂商的目录、认证声明和请求策略,则可以由独立包提供。
核心的架构文档把 DeepSeek、OpenRouter、llama.cpp 和通用 Proxy 收进仓外的统一 rpi-providers 包,一个动态库注册多份供应商实现。它们不是四个必须跟随 CLI 编译的核心 crate。比如 DeepSeek 的思考开关、历史思考与工具续轮策略在供应商模块中维护,OpenRouter 的专用请求头也由包提供。只在 models.json 填一个兼容地址,并不会自动得到这些厂商策略。
外移之后仍有公共代码留在核心仓库:packages/provider-runtime 下的 rpi-provider-runtime 负责把异步 provider 接到插件 ABI,复用流式、取消和请求桥接。它不内置供应商名单,也不解析宿主凭据。图中的九个产品 crate 因而不是整个 workspace 的全部成员,辅助库与示例还在图外。
按该提交的文档,统一供应商包尚未发布到 crates.io,当前通过源码 link 使用。它需要兼容的宿主提交,不能拿任意版本的宿主直接安装。
加载时,包先声明模型目录并注册实现;宿主合并用户配置、解析认证,再把选中的模型绑定到运行时。内置协议和扩展实现走同一套 ProviderFactory 契约:只有 provider ID 与 API 协议都匹配,才优先使用扩展,否则查找对应的内置协议。这样,安装一个 DeepSeek Completions 实现,不会顺带接管同名 provider 的 Responses 配置。
这里还拆开了“展示模型”和“执行请求”。公开模型描述不带请求头,真实端点、模型级请求头和默认认证留在私有 ExecutionBinding 中;发请求时按 provider、API 和模型 ID 找回配置。调用方不能只改一份展示对象的地址,就把已经绑定的凭据重定向过去。这是宿主侧的执行约束,不是防御恶意进程内插件的安全沙箱。
工具侧的外移也有现成例子。独立的 rpi-package 仓库里,MCP 连接由 rpi-mcp-adapter 管理,网页检索由 rpi-websearch 提供;rpi-codegraph 启动外部 CodeGraph 服务,向本地索引发出有范围限制的查询,而不是在核心循环里实现索引器。rpi-todo 则从当前会话分支的工具结果重建任务状态,分叉后各自演进。这些能力通过扩展接入运行时,不必成为每个嵌入应用的必选依赖。
于是,同样是改端口,最小应用只需模型和文件工具;需要追踪受影响的调用点时再接代码索引,需要外部系统时再接 MCP。循环继续处理统一的工具调用和结果,宿主负责装配,包负责具体能力。接下来的问题是:这些部件已经接好,一次运行中断后,谁能说清它们到底做了什么?
文件改了,任务却没有完成
假设端口已经写成 8080,但工具结果尚未写入会话,进程就崩溃了。
文件改动确实发生了,可重启后的会话里没有成功结果。这时直接重跑,会不会重复修改其他东西?直接告诉模型成功,又凭什么确定?对于一个还会发请求、提交代码或执行 shell 的通用工具系统,“没有记录”不能解释成“没有发生”。
这是我觉得 rpi 比 crate 分层更值得看的地方。恢复能力的难处,在于日志必须留下足够信息,让程序知道自己不知道什么。
承接这些状态的是 harness 的会话层。它保存的是一棵 entry 树:分支表示历史路径,lane 保存当前执行位置,模型上下文再从选中的分支构造。存储通过 SessionStorage 接口接入,仓库里有内存、JSONL 和 SQLite 后端。循环因此不用直接操作某个日志文件;持久化结构和模型最终读到的消息,也不是同一份东西。
rpi 先持久化用户输入,再构造模型上下文。因此 run_core_with_entry 调用 run_agent_loop 时,传入的 prompts 是 Vec::new():消息已经在上下文里,再加一次就重复了。这个看起来古怪的空参数,背后是一个明确顺序:先承认用户提交了任务,再开始执行。
模型生成中的输出另走一条记录流,以 LaneRecord::AssistantFrame 保存。它们是流式进度,不直接成为下一轮模型读取的对话历史。正常完成后,定稿消息再进入会话;若被中断,恢复逻辑可以从已持久化的帧重建部分输出,并标明消息被打断。较新的、尚未保存的输出仍可能丢失。
工具调用还要多跨一步。带调用的 assistant 消息定稿后提交,执行前记下 tool_started,把这次调用与参数、恢复策略关联起来。结果未落盘时,恢复逻辑至少知道要追查的是哪次操作,而不是只剩半段终端文字。
回到那个已改文件的工具。rpi 默认不重放结果未知的调用。只有日志里的策略和当前工具声明都允许安全重放,恢复逻辑才会重新执行;否则补上一条“结果未知”的工具结果,让后续对话明确保留这份不确定性。它没有凭空获得所有外部系统的事务能力,也不承诺副作用恰好执行一次。
同样,处于重试退避中的运行需要单独的 retry_pending 记录。否则重启后,失败尝试留下的流式帧可能被误当成值得保留的回答。续跑还有预算,避免同一份工作在反复崩溃后无限重启。
这些记录最终把控制权交回调用方。harness 恢复会话状态,通过 has_pending_resume() 暴露尚未完成的工作,不会自动继续整个模型循环。终端可以先准备好显示,嵌入应用也可以先恢复自己的业务状态,再决定是否继续。
到这里,“把 agent 做成库”的含义才更具体:应用不只拿到一个发 prompt 的函数,还能决定一项未完成任务何时重新进入执行。
工具来自插件时,谁来结束它
现在把内置工具换成第三方插件。改端口仍是同一个任务,但工具的实现不再和宿主一起编译。宿主能启动它,也就必须知道怎样读进度、怎样取消、最后由谁回收资源。
rpi 的插件是 Rust cdylib,也就是宿主可以动态加载的库。双方虽然都用 Rust,却不能把 Vec<Message> 直接当成稳定的跨库接口:编译器和依赖版本可能不同,Rust 原生类型也没有替双方保证这样的 ABI。
因此,工具执行被拆成明确的生命周期:execute 创建句柄,宿主反复 poll 取得进度或结果;需要取消时调用 cancel,最后通过 destroy 释放。取消表达的是停止意图,不是释放内存,正常完成也不需要先取消一次。宿主将轮询驱动放进 spawn_blocking,再用通道把进度送回 async 调用方。
这比“传一个 Rust 对象过去”啰嗦,但问题变得可检查了:哪个句柄还活着,哪个调用已经结束,取消后谁继续负责清理。若这些事只靠双方猜测,界面上的取消按钮很容易变成资源泄漏或重复释放的入口。
数据也遵守相同的分工。结构体用 #[repr(C)] 约定布局,字符串通过 StbString 传指针和长度。接收方取得所有权后,要调用生产方提供的 free_string 恰好释放一次,不能随便用自己一侧的分配器回收。结构化内容则序列化为 JSON;两侧还需一致启用 SDK 要求的 serde_json 特性,以保持大整数和对象键序的语义。
为了让插件只依赖这份契约,rpi-plugin-sdk 不依赖任何 rpi-* 运行时 crate。它并非所有配置下都零依赖:默认的 json feature 使用 serde_json,process 和 stdio 还有可选的平台依赖。这个边界的目的,是不让插件作者为了实现一个工具而编入整套运行时。
兼容性也有明确的拒绝条件。注册时检查 PluginApi 的 abi_version 和 struct_size:版本不匹配就拒绝;版本一致、必需前缀完整时,缺失的可选尾部槽位可以补成 None,由插件降级使用。所谓稳定边界,靠的是这些规则,不是新旧版本永远互通。
进程内调用的代价也在这里。插件若在自己的 ABI 入口内部捕获可展开的 panic,可以将其转成错误;未防护的 extern "C" 函数却可能在 panic 逃出时直接终止进程,宿主外层的 catch_unwind 兜不住。panic=abort 和内存错误同样不在捕获保障内。这套 ABI 帮助双方正确合作,不能让不可信代码变得安全。
同样的生命周期问题也出现在委派任务里。rpi-subagents 继承父级当时生效的模型、工具、工作目录与审批配置,使用独立内存会话,并只允许缩小工具集。父 run 完成或取消时,宿主会停止子任务。这能避免委派任务脱离父级生命周期,却没有隔离文件系统和操作系统权限:继承了能写文件的工具,就仍然具备那份能力。
从动态库加载到注册表合并的细节,见 《从一个 helloworld 动态库到 rpi package:ABI 插件是怎样被加载的》。本文关心的是它付出的交换:应用获得进程内组合能力,也接过了共享进程的故障风险。
历史里,哪些变化真正改变了边界
有了前面的执行过程,再读版本历史就不必从头数功能了。对我来说,最能解释当前形态的是几次边界选择。
第一条提交在 2026-08-12。随后从 M2 的循环与集成测试,到 M3 的 Anthropic provider,再到工具、会话和持久化,2026-08-14 才进入 M6 的 CLI。这个顺序与今天的结构一致:先有能被装配的核心,再有终端。0.1.6 在 9 月 9 日首次公开发布这一组 crate,将循环、工具、会话和插件 SDK 一起带出来。
另一个转折是扩展接口。0.3.0 在 9 月 25 日把注册统一到 rpi_plugin_register,版本号放进 PluginApi 内。宿主寻找一个约定入口,后续兼容性由结构体里的版本与尺寸协商,正是上一节那套规则。
更直接的取舍发生在 0.3.9。早期项目曾用 --enable-pi-packages 打开 Pi 包桥,后来连同 npm/Git 包存储和 Node 扩展宿主一起删除。10 月 1 日的 CHANGELOG 明确说明这套兼容层不会恢复。rpi 由此选择了 Rust 原生扩展路线,现成 Pi 扩展不能默认搬过来。
到了 10 月 17 日的 0.5.0,变化继续落在生命周期上:插件自有的子 agent、流式且可取消的插件 provider,以及 provider 登录流程。它们让扩展可以做更多事,也要求宿主更清楚地管理取消、凭据和清理。
这几次变化比“支持了多少插件”更能说明项目的方向。它正在形成自己的运行时契约,而不是维持一个与 TypeScript Pi 接口一致的 Rust 外壳。迁移成本也是这条路线的一部分,不能只把独立性算成收益。
更快是另一笔账,不能从分层推出来
Rust 和单二进制分发确实让人期待较低的运行成本,不过,前面的架构并不能证明它到底省了多少。项目做过一次与原生 TypeScript pi 的启动对比,结果应当单独看。
测量在 2026-09-30 进行:Windows 11 / x86_64、rustc 1.97.1 --release、Node v25.9.0,比较 rpi 0.3.6 与 pi 0.87.1。计时丢弃 3 次预热,测量 11 次,报告中位数。两边使用空配置目录、空工作目录和离线模式。
| 指标 | rpi | pi | pi / rpi |
|---|---|---|---|
--version 墙钟中位数 | 17.5 ms | 169.5 ms | 9.7× |
| 冷启动墙钟中位数 | 17.7 ms | 189.5 ms | 10.7× |
| 就绪时 RSS | 12.0 MiB | 91.5 MiB | 7.6× |
| 安装体积 | 21.8 MiB(单二进制) | ~385 MiB(+ ~91 MiB Node) | 约 18–22× |
这里的冷启动,是从创建进程前开始,到父进程解析出 get_state 响应为止,不包含模型和工具工作,也不包含之后的进程清理。RSS 在单独运行中采样,避免干扰计时;“冷”指新进程和隔离配置,不是清空操作系统磁盘缓存。
这些数字背后的修正过程,比最终倍数更有意思。测量曾只给 rpi 设置 Pi 使用的 PI_CODING_AGENT_DIR,却漏掉它实际读取的 RPI_CODING_AGENT_DIR。于是所谓隔离运行,仍加载着测试机上的 25 个插件 DLL。修正后,记录的耗时从约 101 ms 降到约 10 ms。
约 10 ms 也不是最终答案。秒表当时在 spawn() 之后才开始,漏掉了创建进程的一段同步耗时。把起点移到前面,补进当时约 7.3 ms 的开销,才得到表里的 17.7 ms。
内存采样和启动入口也影响结果。在计时窗口里每 10 ms 启动一次 tasklist 采样内存,曾把两边都拖慢到约 220 ms。直接运行 npm 的 .bin/pi 包装入口,又会在 Windows 上多经过一个 cmd.exe。后来的方法把内存采样移出计时窗口,并直接用 Node 启动解析出的 JS 入口。
所以,这组结果能支持的是特定环境下的启动、就绪内存与安装体积差异。rpi 两个入口的中位数接近,不代表全部耗时都是进程创建;pi 的两个入口相差约 20 ms,也不足以把剩余时间都归给 Node 启动。
这些是项目公开测量,不是本文实测,更不是当前 0.5.0 在所有机器上的表现。它没有测 LLM 延迟、工具循环吞吐、长会话内存增长和 TUI 帧成本,也没有测 Claude Code、Codex CLI、opencode 或 aider。原始数据、方法与复现命令见文末的 benchmark 文档,站内 《Rust 版 Pi 工作基本完成了,记录下性能对比》 也保留了同一批记录。
最后还是回到:你要接管什么
如果需求只是让 CI 发一条任务、接收结构化结果,不必因为“库优先”就选择同进程集成。JSON 或 RPC 子进程接口可以避开解析终端画面,也把进程边界保留下来。rpi 自己同样提供 --mode rpc。
它的远程模式进一步把界面与执行分开:--connect 客户端不构建本地 provider、工具、扩展或会话存储,由服务端的 rpi --mode rpc 子进程执行任务。应用可以只拥有界面,不拥有执行进程。服务端默认绑定本机地址,跨机器使用仍要处理访问控制和传输保护,token 认证不等于加密通道。
真正需要库的场景,是应用要直接组合模型与工具、订阅执行事件,并接管一项未完成任务的后续处理。rpi 为这件事提供了入口,代价也没有消失:应用负责自己的装配与生命周期,进程内插件带来故障风险,迁移 Pi 扩展需要重写。
回到那句“把端口改成 8080”:正常结束时,这些设计不一定显眼。进程在写文件后崩溃、工具被取消、插件和宿主版本不同时,它们才决定应用还能知道什么、能继续做什么。
这也是我理解的 rpi 的价值。它让调用方不只发出任务,还能沿着明确的接口追到执行、保存和释放的位置。至于是否值得承担这份控制权,要看你准备把 agent 接进多深的产品里。
想开始读代码,可以在仓库运行 cargo run -p minimal。它使用 faux provider,不调用真实模型,也不需要 API key;构建时仍可能下载 Cargo 依赖。先看事件怎样到达 AgentEnd,再读 rpi-agent/src/agent_loop.rs;需要持久化时,接着看 rpi-harness/src/agent_harness.rs。直接使用终端程序可安装预编译二进制,或运行 cargo install rpi-cli;workspace 声明的最低 Rust 版本为 1.78。
参考资料:
- 项目与本文源码快照:https://github.com/bigfish1913/pi-rust/tree/e247f58
- 官网:https://rpi.bigfisharc.cn/
- 架构概览:https://github.com/bigfish1913/pi-rust/blob/e247f58/docs/architecture/overview.md
- 供应商包拆分、factory 与执行绑定:https://github.com/bigfish1913/pi-rust/blob/e247f58/docs/architecture/provider-packages.md
- 宿主 provider 装配实现:https://github.com/bigfish1913/pi-rust/tree/e247f58/crates/rpi-cli/src/provider
- 仓外扩展源码快照:https://github.com/pi-rust/rpi-package/tree/f43bfc0/packages
- 完整最小示例:https://github.com/bigfish1913/pi-rust/tree/e247f58/examples/minimal
- Agent Loop 与消息解析链路:https://github.com/bigfish1913/pi-rust/blob/e247f58/docs/agent/loop-walkthrough.md
- 恢复与任务装配:https://github.com/bigfish1913/pi-rust/blob/e247f58/crates/rpi-harness/src/agent_harness.rs
- 插件契约与注册入口:https://github.com/bigfish1913/pi-rust/blob/e247f58/crates/rpi-plugin-sdk/src/lib.rs
- 宿主工具桥:https://github.com/bigfish1913/pi-rust/blob/e247f58/crates/rpi-extensions/src/tool.rs
- 版本历史:https://github.com/bigfish1913/pi-rust/blob/e247f58/CHANGELOG.md
- Roadmap:https://github.com/bigfish1913/pi-rust/blob/e247f58/ROADMAP.md
- 上游 Pi 与归属声明:https://github.com/earendil-works/pi 、https://github.com/bigfish1913/pi-rust/blob/e247f58/NOTICE 、https://github.com/bigfish1913/pi-rust/blob/e247f58/LICENSE
- 启动性能测量与复现:https://github.com/bigfish1913/pi-rust/blob/e247f58/docs/performance/benchmark-vs-pi.md
- 站内延伸:《如果你想拥有自己的 Agent,为什么值得从 rpi 开始》、《Pi 的 Rust 实现来了:rpi,不止一个终端 Agent》、《rpi 这些扩展到底怎么选?一张工具地图看懂 16 个 Rust 插件》
云服务器推荐
如果你需要部署博客、Docker 服务或个人项目,可以参考:
本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。
评论