Provider 不属于 Agent 核心:rpi 的分层理由与收益
先把项目定位说清楚,否则"为什么这样分层"没有判断依据。
rpi 是一个 Rust 原生的 coding-agent runtime,加一个建立在它之上的终端 agent。九个 crate 覆盖 provider 适配、可恢复的 agent 循环、工具、会话持久化、终端界面和一套稳定的插件 ABI;同一套代码支持三种用法:装成 rpi 命令直接用、作为 rpi-agent 库嵌进自己的进程、编译成 cdylib 插件去扩展全局 rpi 的每一次会话。
这个定位带来一个硬约束:同一份供应商适配代码,既要能在你自己的进程里以库的形式跑,也要能作为一个动态库被别人的 rpi 加载。 如果 provider 是 Agent 核心里的 match provider { ... },第二种用法就不成立——插件没法修改宿主的核心分支。所以 provider 必须被拆成一个契约加若干可替换实现。
下面按"定位决定边界 → 每个决策的理由 → 换来的收益与代价 → 还没收敛的地方"讲一遍。对照的是 pi-rust 提交 162d0dd(分支 feature-stream-porider);插件流式 provider 在这个提交里仍属 CHANGELOG 的 [Unreleased],最近发布为 0.3.24。
定位决定边界:什么是核心,什么是边界外
分层的判断标准可以很朴素:改这个东西,会不会影响所有调用方? 会,就属于核心;只影响某一个供应商,就属于边界外。
| 能力 | 归属 | 理由 |
|---|---|---|
| 会话、工具循环、取消、事件 | 核心 | 每个 provider 都要走同一条调用路径 |
| 消息与事件模型 | 核心 | 上层只消费统一文本、思考、工具调用、用量和结束事件 |
Provider 契约 | 核心(唯一 seam) | 上层依赖它,而不是依赖某个供应商 |
| 协议实现(请求构造、SSE 解析) | 边界 | 一种协议一份实现,多个身份共用 |
| 端点、凭据、模型目录 | 边界 | 每个身份各不相同 |
| 流式实现、取消细节 | 边界 | 取决于供应商 API 和插件写法 |
图 1:职责与调用总览(交互版:搜索、聚焦与源码引用),依据提交 162d0dd 的源码整理。箭头表示调用或持有关系,不是时间顺序或 crate 静态依赖;生命周期节点是适配器持有的状态,不是请求必须经过的一跳。package 可以提供 Provider、工具等多种能力;未验证的供应商 package 网络路径没有连到远端服务。窄屏可在图内横向滚动,点击静态图查看原尺寸;交互版主要面向桌面阅读。
图里最重要的不是层数,而是中间那个契约:Agent 通过同一入口调用内置实现和扩展适配器,供应商请求的构造则留在适配层。拆出去的是身份和装配,不是要求每个供应商重写协议。 package 可以复用协议实现,也可以自行适配;当前可见仓库的 stub 测试验证了扩展桥接,但不等于已经验证所有供应商 package 都复用同一套协议代码。
契约只有四个成员,定义在 crates/rpi-ai/src/provider.rs:
pub trait Provider: Send + Sync {
fn id(&self) -> &str;
fn models(&self) -> &[Model];
async fn stream_simple(&self, model: &Model, ctx: &Context, opts: &SimpleStreamOptions)
-> AssistantMessageEventStream;
fn deferred(&self) -> Option<&dyn DeferredProvider> { None }
}为什么是这四个。 id 是身份,models 是这个身份提供什么,stream_simple 是唯一的执行入口,deferred 是可选能力(长轮询续传)。deferred 和 ProviderHooks 都做成旁挂 trait,不塞进主 trait,理由很实际:不该逼着每个实现写一堆空方法,也不该让"支持续传"变成所有 provider 的负担。
为什么失败不返回 Err。 stream_simple 同步返回,内部把生产者任务 spawn 起来,失败以流上的 Error 事件表达。这样上层处理成功和失败是同一条路——读事件流、等终态——不需要为"同步错误"和"流中途错误"写两套分支。这是分层能成立的前提:如果契约本身有两种失败形态,上层就还是得知道底层细节。
收益是具体的:新增供应商通常不动 Agent 循环、会话存储和 TUI。代价是每次加能力都要先想清楚"它属于契约还是旁挂",这个判断本身有成本。
为什么是"一份协议 + 多个身份",而不是一个供应商一份实现
现实里,供应商大量复用同一套协议。DeepSeek 说的是 OpenAI 的 Chat Completions,很多公司网关也说自己"兼容 OpenAI"。于是有两条路:
- 每个供应商复制一份协议实现:代码重复,协议修一次要改 N 处;
- 只按协议建实现:身份就没了,端点、key、模型目录会互相污染。
rpi 选了第三条:协议实现共享,身份由包装层给出。 models.json 里一个 Anthropic 兼容的 provider 会被包成 NamedAnthropicProvider,内部还是那一份 AnthropicProvider,但 id() 返回配置里声明的 id;非内置的 OpenAI 兼容 provider 走 with_models_without_env_api_key 构造路径,意思是"这个身份不许去读内置身份的环境变量"。
好处有三层:
- 协议修一次,所有身份一起受益;
- 身份、凭据、模型目录互不污染,
provider/id能精确路由到目标端点; - 新增供应商的成本从"写一份协议实现"降到"写一段配置",因为协议已经存在。
代价是一层薄包装,以及"身份必须显式声明"这条纪律。少了这层纪律,代码会退化成按协议猜。
图 2:协议共享,身份隔离(交互版:查看独立实例与代码复用关系)。依据提交 162d0dd 的配置解析与实例构造代码;corp 是说明性配置名,不是内置供应商或网关实测。左右两列分别绑定自己的凭据、目录与端点;指向中央的箭头只表示复用代码,不表示请求或 key 汇合,也不表示共用运行时实例。窄屏可在图内横向滚动,点击静态图查看原尺寸;交互版主要面向桌面阅读。
为什么凭据必须绑在身份上
最容易被低估的一条。公司网关和 OpenAI 官方可能都说 OpenAI 协议,但它们的 key 完全不能混用。如果宿主按"你兼容 OpenAI,那我去读 OPENAI_API_KEY"来解析,公司网关的请求就会被塞进一把不属于它的钥匙。
所以凭据按 provider id 建表,auth.json 也按 id 存。优先级写在 crates/rpi-cli/src/provider.rs 的模块文档里:
--api-key(只影响最终选中的 provider)
→ auth.json[provider-id]
→ models.json.providers[provider-id].apiKey
→ 该身份专属的环境变量(内置身份是 ANTHROPIC_* / OPENAI_API_KEY,其余走 id→环境变量表)
→ 模型自带的认证头(models.json 给模型声明的 authorization / x-api-key 等)实现上就是"每个身份独立解析":
let credential = stored_key
.or(configured_key)
.map(|key| anthropic_credential_from_key(key, auth_header))
.or_else(|| {
if provider_id != DEFAULT_PROVIDER_ID {
return None; // 环境变量只属于内置 anthropic 身份
}
std::env::var(ANTHROPIC_AUTH_TOKEN_ENV).ok().or_else(|| {
std::env::var(ANTHROPIC_API_KEY_ENV).ok()
})
});好处是三条同时成立:key 不会串;切换模型时认证不丢(凭据跟着身份走);解析规则可以按来源解释,而不是"看运气"。
代价有两处,都要如实说。一是供应商差异还留在核心里——config.rs 有一张 id 到环境变量的表:
fn native_provider_api_key_env_var(provider_id: &str) -> Option<&'static str> {
match provider_id {
"deepseek" => Some("DEEPSEEK_API_KEY"),
"groq" => Some("GROQ_API_KEY"),
"openrouter" => Some("OPENROUTER_API_KEY"),
_ => None,
}
}表旁边的注释解释了为什么不能靠"把 id 规范化"替代:Provider ids are identities, so aliases and case variants must not inherit another provider's credential. 判断是对的,代价是每加一个供应商要动一次核心。二是同一套规则现在有几处各写了一遍:真实解析在 provider.rs,rpi auth check 走 auth.rs 的 detect_credential(注释自称"mirrors the provider::resolve precedence"),/provider 的判断在 provider_has_key。后两处只探测内置身份的变量,所以在只设 DEEPSEEK_API_KEY 的机器上,auth check 会说 ready:false,而真实请求已经能发出去。这正是"规则要收敛到一份解析器、策略要移到 package"的现实理由。
为什么用 C ABI 动态库,而不是子进程或 HTTP
这是个有明显权衡的决策。子进程或本地 HTTP 服务能天然隔离崩溃和内存,但 rpi 的三个用法里有两个要求同进程低开销:嵌进你自己的进程、以及扩展每次会话的每一次请求。而且——文档写得很直白——插件不要求与宿主使用相同的 Rust 编译器版本。跨编译器就不能直接传 Rust 的 String、Arc、trait object。
所以边界规则是:只传 JSON 字符串、函数指针和显式释放函数;两侧的调用都包 catch_unwind,不让 panic 跨 FFI;插件用自己注册的 plugin_free_string 回收自己分配的内存,宿主绝不代劳。
好处是这套东西一旦成立,插件就获得了和内置 provider 相同的能力:真流式、可取消、可测试、一个文件分发;同一份工具/provider 代码在本地以库的形式调试,线上以 cdylib 形式被加载,不用维护两套。
代价是生命周期必须自己管。插件和宿主同进程,函数指针指向动态库内部,只要还可能调用它,库就不能卸载——这件事由 PluginKeepalive 负责:它持有 Library 句柄,工具适配器和 provider 适配器各持一份克隆,最后一个持有者释放才卸载。另一个代价是信任边界:同进程就意味着进程权限,当前的凭据隔离是配置和路由隔离,不是安全沙箱。
为什么流式要定义成"事件 + 唯一终态"
用户看到的"边生成边显示"只是表象,底下要满足三件事:增量必须在响应结束前到达上层;每次请求只能有一个终态;用户取消必须和供应商失败区分开。
为什么增量要在上层可见。 stream_simple 返回一个流对象,这件事本身证明不了流式能力。同一个仓库里就有两种实现:openai-completions 和 anthropic 会在每个 SSE 帧到达时 push TextDelta / ThinkingDelta,而内置的 DeepSeekProvider 把内容累加进一个字符串,等循环结束才 push 一个 Done。前者让 TUI 在模型还在生成时就开始渲染,后者只是"晚一点一次性交付"。判断标准因此不在接口形态,而在事件层:有没有 *_delta、它们在不在终态之前、终态是否唯一。
为什么唯一终态要由流对象保证。 event_stream.rs 的 push() 在收到第一个终态事件时置位 done 并兑现 result oneshot,其后的 push 变成 no-op;消费者在终态之后调用 next() 直接拿到 None。于是"先取消、随后又冒出成功的 Done"在结构上不可能发生——不用靠调用方自觉。
| 结束方式 | 事件流终态 | 已渲染内容 |
|---|---|---|
| 正常完成 | 一个 Done | 完整 |
| 请求失败 | 一个 Error | 到失败为止 |
| 用户取消(未保留插件成功结果时) | 一个 Error { reason: Aborted } | 保留已收到的增量 |
为什么插件侧要绕一层通道。 插件的请求函数是同步 FFI,没法直接驱动异步流。宿主的做法是把同步调用丢进 spawn_blocking,插件通过回调把每个 chunk 交出来,宿主立刻转发到事件流;映射和累积只在宿主侧一个任务里做。这样插件线程不会因为 UI 慢而被堵在 FFI 调用里,消费者也不会看到"delta 先于 Start"这种顺序错乱。
取消为什么要分两个目标。 宿主观察到取消,和插件真正停止工作,是两件事。当前提交在 select! 收到取消信号后先将控制状态置为已取消、退出转发循环,随后关闭接收端、清理缓冲,并执行 join.await 等待插件返回。ProviderStreamControl 提供 is_cancelled 和 wait_cancelled_ms 供插件协作退出,但宿主不能强制中断同步 FFI,因此也不能保证取消立即返回。插件若已经返回可用的成功结果,当前实现仍可保留该结果;否则中途取消收敛为 Aborted。
图 3:增量转发与协作取消(交互版:查看路径与源码引用),依据提交 162d0dd 的流式扩展驱动整理。上排是增量转交路径;右侧是退出循环后等待插件返回、合并最终消息并结束的路径;左侧是取消控制。协作取消不代表强制中断线程,取消信号也不直接通往终态。图省略字符串释放与 keepalive 持有关系。窄屏可在图内横向滚动,点击静态图查看原尺寸。
读图时要把“增量何时可见”和“请求何时结束”分开:前者由回调、通道和事件映射实现,后者在当前代码中仍受插件返回时机约束。唯一终态避免重复结束,但不能独自提供及时取消的保证。
这些性质是有测试兜着的,而且测的是真实动态库:plugin_stub_smoke.rs 断言 delta 按顺序分别到达、thinking 在 text 之前、恰好一个 done;取消后恰好一个 error 且 reason == Aborted。我在本机跑过(需先 cargo build -p plugin-stub,否则用例跳过):2 passed; 0 failed。
代价是流控还没定义:事件流和插件 chunk 通道目前都是无界队列,慢消费者和大量并发下会积压。这是"先保证语义正确,再补流量控制"的典型取舍。
为什么要三层目录,为什么执行实例要绑定凭据
如果把"有哪些模型"做成一个列表,会同时踩三个坑:没配 key 的 provider 看不到(没法配置);列表里点进去报 No API key(能看不能用);换身份时认证丢失。所以目录按用途分开:
| 目录 | 回答的问题 | 入口 |
|---|---|---|
catalog_all | 有哪些模型(不需要凭据) | --list-models、/provider 表单 |
available_catalog | 当前这条 lane 上已认证的模型 | 启动时快照,供扩展 runtime action 的 set_model(id) 解析 |
authenticated_catalog | 所有已配置身份里哪些模型能跑 | /model 切换列表 |
而"执行实例 = 实现 + 目录 + 凭据"这条设计,是为了让切换和重载都不丢认证。绑定发生在 build_runtime:
let mut provider_models: Vec<Model> = catalog
.iter()
.filter(|candidate| candidate.api == *api && candidate.provider == provider_id)
.cloned()
.collect();
if let Some(key) = openai_key {
let headers = BTreeMap::from([("authorization".into(), format!("Bearer {key}"))]);
for candidate in &mut provider_models {
merge_auth_headers(candidate, &headers);
}
}收益是:/model 热切换和扩展重载都不用重新解析凭据,因为认证跟着模型走。代价是模型描述与执行绑定混在一个结构里——Model 会被序列化,插件请求信封里带着整个 model,base_url 和 headers 跟着跨过 ABI 边界。当前可接受(插件是用户自己安装的本机代码),更干净的边界是分开 ModelDescriptor(ID、能力、上下文限制)和 ExecutionBinding(端点、认证、实现引用):前者给目录、UI 和日志看,后者只在发请求时用。
这套分层换来了什么:加一个供应商要改哪一层
把收益落成一份可检验的清单,比抽象地说“解耦”有用:
- 已有协议的新身份(公司网关、DeepSeek):改
models.json加一段配置和一把 key,不动代码。 - 一种新协议:在
rpi-ai加协议实现,或写一个 provider package;后者还要求先在models.json里声明模型。 - 一个新的流式供应商:写 cdylib 并注册流式 provider,插件不必和宿主同编译器。
- 一个新的工具:内置进
rpi-tools,或用插件注册。
由此也能反过来判断"拆得够不够干净":新增供应商身份现在可以只改配置;新增协议仍然要动核心或写 package。 三处还没收敛的地方正好卡在这条线上:
- 扩展 provider 还没进入模型解析。
PluggableProvider::models()返回空数组——注册时没有模型列表这个通道;扩展 provider 是在 harness 装配阶段被追加到 provider 列表末尾的,要靠models.json里同 id 的声明才能被选中。而models.json只认三种协议,其他协议直接返回None。所以"写一个 package 就接入新供应商"目前只对已有协议成立。 - 认证策略还在核心。 就是前面那张 id→环境变量表和几处重复判断。方向是扩展只声明策略(
apiKeyEnv、是否必须认证),宿主保留机制(存储、优先级、token 有效期、注入)。 - 模型描述与执行绑定耦合。 也就是
Model.headers里那份折叠进去的凭据。
另外两件事属于同一类取舍,值得提前定:流式调用还没有被写成"带生命周期的状态机"(增量顺序、唯一终态、取消、背压、旧实例退出现在散在注释和测试里),重载也还是分步替换(注册表、provider 列表、目录、harness 各换一次),更清楚的做法是收敛成一个带代次的运行时快照,切换时只换一个引用。
回到定位
rpi 的定位是"一个运行时,三种用法"。这个定位决定了它必须把供应商适配做成契约加可替换实现,而不是核心里的分支;决定了协议实现要共享、身份必须独立;决定了凭据只能绑身份;也决定了插件边界要用 C ABI 加 JSON,并自己承担生命周期。
这些决策换来的不是"架构好看",而是三个可以验收的结果:同一份 provider 代码能同时以库和插件两种形态存在;新增供应商身份不改代码;流式和取消在不同来源的 provider 上表现一致——包括内置实现和你自己写的 cdylib。
而判断这套拆分是否完成,标准也很简单:什么时候"加一个供应商"只意味着"加一个 package",什么时候拆分就到位了。 在那之前,每加一种协议或一种认证策略,仍然要回到核心里改代码。
云服务器推荐
如果你需要部署博客、Docker 服务或个人项目,可以参考:
本文包含推广链接。通过链接购买不会增加你的费用,但我可能获得少量佣金。
评论