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:

rust 复制代码
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 构造路径,意思是"这个身份不许去读内置身份的环境变量"。

好处有三层:

  1. 协议修一次,所有身份一起受益;
  2. 身份、凭据、模型目录互不污染,provider/id 能精确路由到目标端点;
  3. 新增供应商的成本从"写一份协议实现"降到"写一段配置",因为协议已经存在。

代价是一层薄包装,以及"身份必须显式声明"这条纪律。少了这层纪律,代码会退化成按协议猜。

图 2:协议共享,身份隔离(交互版:查看独立实例与代码复用关系)。依据提交 162d0dd 的配置解析与实例构造代码;corp 是说明性配置名,不是内置供应商或网关实测。左右两列分别绑定自己的凭据、目录与端点;指向中央的箭头只表示复用代码,不表示请求或 key 汇合,也不表示共用运行时实例。窄屏可在图内横向滚动,点击静态图查看原尺寸;交互版主要面向桌面阅读。

为什么凭据必须绑在身份上

最容易被低估的一条。公司网关和 OpenAI 官方可能都说 OpenAI 协议,但它们的 key 完全不能混用。如果宿主按"你兼容 OpenAI,那我去读 OPENAI_API_KEY"来解析,公司网关的请求就会被塞进一把不属于它的钥匙。

所以凭据按 provider id 建表,auth.json 也按 id 存。优先级写在 crates/rpi-cli/src/provider.rs 的模块文档里:

plain 复制代码
--api-key(只影响最终选中的 provider)
    → auth.json[provider-id]
    → models.json.providers[provider-id].apiKey
    → 该身份专属的环境变量(内置身份是 ANTHROPIC_* / OPENAI_API_KEY,其余走 id→环境变量表)
    → 模型自带的认证头(models.json 给模型声明的 authorization / x-api-key 等)

实现上就是"每个身份独立解析":

rust 复制代码
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 到环境变量的表:

rust 复制代码
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:

rust 复制代码
let mut provider_models: Vec = 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。 三处还没收敛的地方正好卡在这条线上:

  1. 扩展 provider 还没进入模型解析。 PluggableProvider::models() 返回空数组------注册时没有模型列表这个通道;扩展 provider 是在 harness 装配阶段被追加到 provider 列表末尾的,要靠 models.json 里同 id 的声明才能被选中。而 models.json 只认三种协议,其他协议直接返回 None。所以"写一个 package 就接入新供应商"目前只对已有协议成立。
  2. 认证策略还在核心。 就是前面那张 id→环境变量表和几处重复判断。方向是扩展只声明策略(apiKeyEnv、是否必须认证),宿主保留机制(存储、优先级、token 有效期、注入)。
  3. 模型描述与执行绑定耦合。 也就是 Model.headers 里那份折叠进去的凭据。

另外两件事属于同一类取舍,值得提前定:流式调用还没有被写成"带生命周期的状态机"(增量顺序、唯一终态、取消、背压、旧实例退出现在散在注释和测试里),重载也还是分步替换(注册表、provider 列表、目录、harness 各换一次),更清楚的做法是收敛成一个带代次的运行时快照,切换时只换一个引用。

回到定位

rpi 的定位是"一个运行时,三种用法"。这个定位决定了它必须把供应商适配做成契约加可替换实现,而不是核心里的分支;决定了协议实现要共享、身份必须独立;决定了凭据只能绑身份;也决定了插件边界要用 C ABI 加 JSON,并自己承担生命周期。

这些决策换来的不是"架构好看",而是三个可以验收的结果:同一份 provider 代码能同时以库和插件两种形态存在;新增供应商身份不改代码;流式和取消在不同来源的 provider 上表现一致------包括内置实现和你自己写的 cdylib。

而判断这套拆分是否完成,标准也很简单:什么时候"加一个供应商"只意味着"加一个 package",什么时候拆分就到位了。 在那之前,每加一种协议或一种认证策略,仍然要回到核心里改代码。


原文链接:https://blog.bigfisharc.cn/blog/20261017-1810-ba-providerchai-chu-agenthe-xin/

相关推荐
女神下凡27 分钟前
芯参谋(95):嵌入式文件系统_开发指南
linux·服务器·arm开发·驱动开发·设计规范
@兽血沸腾1 小时前
Windows系统安装Jenkins部署应用至远程Windows服务器操作指南
运维·服务器·jenkins
承渊政道4 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达6 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
数字新视界9 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落9 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
lisanmengmeng9 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
严肃小白兔10 小时前
Cloudflare 更新 API Token 管理:从最小权限到自动化凭据安全
网络·安全·自动化
Android系统攻城狮10 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
上火的金鱼妹10 小时前
k8s生产实际问题排查(网关,数据库读写等)
运维·服务器