先把项目定位说清楚否则为什么这样分层没有判断依据。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.rspubtraitProvider:SendSync{fnid(self)-str;fnmodels(self)-[Model];asyncfnstream_simple(self,model:Model,ctx:Context,opts:SimpleStreamOptions)-AssistantMessageEventStream;fndeferred(self)-OptiondynDeferredProvider{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 等实现上就是每个身份独立解析letcredentialstored_key.or(configured_key).map(|key|anthropic_credential_from_key(key,auth_header)).or_else(||{ifprovider_id!DEFAULT_PROVIDER_ID{returnNone;// 环境变量只属于内置 anthropic 身份}std::env::var(ANTHROPIC_AUTH_TOKEN_ENV).ok().or_else(||{std::env::var(ANTHROPIC_API_KEY_ENV).ok()})});好处是三条同时成立key 不会串切换模型时认证不丢凭据跟着身份走解析规则可以按来源解释而不是看运气。代价有两处都要如实说。一是供应商差异还留在核心里——config.rs有一张 id 到环境变量的表fnnative_provider_api_key_env_var(provider_id:str)-Optionstaticstr{matchprovider_id{deepseekSome(DEEPSEEK_API_KEY),groqSome(GROQ_API_KEY),openrouterSome(OPENROUTER_API_KEY),_None,}}表旁边的注释解释了为什么不能靠把 id 规范化替代Provider ids are identities, so aliases and case variants must not inherit another providers credential.判断是对的代价是每加一个供应商要动一次核心。二是同一套规则现在有几处各写了一遍真实解析在provider.rsrpi 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 帧到达时 pushTextDelta/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_runtimeletmutprovider_models:Veccatalog.iter().filter(|candidate|candidate.api*apicandidate.providerprovider_id).cloned().collect();ifletSome(key)openai_key{letheadersBTreeMap::from([(authorization.into(),format!(Bearer {key}))]);forcandidateinmutprovider_models{merge_auth_headers(candidate,headers);}}收益是/model热切换和扩展重载都不用重新解析凭据因为认证跟着模型走。代价是模型描述与执行绑定混在一个结构里——Model会被序列化插件请求信封里带着整个 modelbase_url和headers跟着跨过 ABI 边界。当前可接受插件是用户自己安装的本机代码更干净的边界是分开ModelDescriptorID、能力、上下文限制和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什么时候拆分就到位了。在那之前每加一种协议或一种认证策略仍然要回到核心里改代码。项目地址https://github.com/bigfish1913/pi-rust原文链接https://blog.bigfisharc.cn/blog/20261017-1810-ba-providerchai-chu-agenthe-xin/