2026年10月30日 · 阅读 —

Pi-Bifrost 源码解读:把"这条提示词发给哪个模型"做成一条看得见的决策链

Agent 与 SkillsAI 工程实践

Pi-Bifrost 源码解读:把”这条提示词发给哪个模型”做成一条看得见的决策链

每次打开 Pi(@earendil-works/pi-coding-agent)敲一句话,总有一个默认模型在替你干活。换模型的成本低到看不见,可换来换去,你其实不知道手里的上下文喂给了哪个模型。iamaamir/pi-bifrost 这个扩展解决的就是这件事:把”这条提示词该用哪个模型”变成一个可配置、可见、可回滚的决策过程。

它不是 LLM 网关,也不是代理。CONTEXT.md 开头就把定位写死了:configuration-first model-routing extension,帮你把每次对话路由到配置好的一层(tier),在生成前切 Pi 的真实模型。所有信号都要可见、可覆盖、可测试,拒绝静默改变路由。

仓库本身不大,6 段目录、6668 行 TypeScript,却带了 15+ 份 ADR 和 40+ 个测试文件。吸引力的部分不在 README 的演示里,而在那套”决策是怎么被证明是对的”的工程纪律上。

一次路由背后,夹着五层判断

先把主线捋清楚。classification-pipeline.ts 里 createPipeline.classify() 是一个纯函数式的五段管线,读同一个提示词,按顺序撞卡,命中即返回:

1. 预检正则:只查"直接模型引用"(规则目标是 provider/model 时)
2. 模糊缓存:Jaccard 词袋相似度命中 -> 跳过分类器
3. 分类器:优先 TypeSafe/pi-native 后端,再退回提示词 LLM 分类器
4. 正则规则:按配置顺序,第一条命中优先
5. 默认 tier 兜底

关键在第一段和第四段用的不是同一个”正则”。管线构造时 compileRules() 只编译一次,之后每次判断都用这份编译产物,不重复构建;直接模型引用(规则值带 /)在查 tier 之前就先行短路,所以哪怕一个 tier 都没配,也能靠裸的 provider/id 规则把模型捞出来。测试读者会认得这个套路:热路径上的资源只初始化一次,能力判定和数据判定分开走。

分类器那一段有降级设计,index.ts 里 directStagePlan() 决定后端是否可用。TypeSafe 后端缺 API key、pi-native 缺凭证、或者配置本身有错,管线会打印 [bifrost/config] error 并自动退到 prompt 分类器或纯正则/兜底,不会因为分类器挂掉就让整条路由空转。诊断信息走 stderr 与 JSON 双通道,[bifrost-json] 前缀那行是稳定输出契约——脚本只认这一行,别的 stderr 全部忽略,脚本测试就不用靠”缺行推断失败”。失败也是完整一行,{"ok":false,"error":"unclassified"}。

切模型之前,先给模型做体检

路由选中的是”层”,不是具体模型。routing.ts 的 findCandidates() 要做两件事:把 tier 的 model pattern(provider/id 精确或裸子串)在 Pi 的 model registry 里映射成候选,再按策略挑一个。策略有七种:first、cheapest、cheapest_input、cheapest_output、largest_context、random、fastest。minBy 实现最顺——只扫一遍找最小,不触发 sort,成本/上下文没配的模型也不会被比较函数拖进死路。

健康的入口不在 selectModel,而在 resolveHealthyModel():它把候选按健康状态过滤,不健康的直接进 skipped,原因只有两种——open_circuit(熔断打开)或 trial_active(半开试探中)。tier 层滑掉会导致一级 fallback:requested_tier_unhealthy → requested_tier_unavailable → all_tiers_exhausted,每一级都记 fallbackReason,可回溯。

这套健康机制长在 reliability.ts,一个标准的熔断器:

状态含义触发
CLOSED(健康)正常候选,参与选择失败数 < 阈值
OPEN熔断打开,进入冷却窗口内失败 ≥ failureThreshold(默认 3 次 / 5 分钟)
HALF-OPEN冷却到期,可放一个试探请求openUntil 已过且无 trial 在进行
试探失败 → OPEN冷却翻倍冷却分钟数 × multiplier,每次试探失败 multiplier ×2

reliability-store.ts 把所有变更收敛到一个 store 里:写方法不接 config 参数,调用方只表达意图(模型、来源、原因),store 内部读自己持有的配置并负责持久化。纯路由函数 getCircuitState、resolveHealthyModel 留在 reliability.ts,仍然无副作用可单独测。这块是 ADR 0005 的落地,把”四个调用点各自 save”这种静默bug面,收成一个唯一入口。

顺着账本,而不是顺着一次请求

很多路由器统计失败都绑在”单次请求”上,一遇到宿主内部重试就崩。Bifrost 反着做:它按一次 agent run 的账本记账。

runtime-reliability.ts 的 RuntimeReliabilityTracker 维护一个 pending Map,记录本轮 dispatch 过的每个模型。observe() 在 agent_end 阶段扫 assistant 消息,按 provider/model 找到每个模型最后一次的 stopReason,是 error 就记下 errorMessage。settle() 在 agent_settled 时一次性结算,返回每条 dispatch 的成功/失败,然后清空 pending。注释写得很直白:一次 run 可能 dispatch 多个模型(排队中的多轮在 agent_settled 前一起排干),单 slot 会丢先到的 half-open 试探、把它卡死,所以每个 dispatch 都要单独结算。

结算走” Policy A”:报错比如”provider failure,未来提示词可能绕开它”打 warning,干净结算静默。索引里白纸黑字解释为什么——正常路由不该制造日志噪音。

为什么不能简单地在请求失败时立刻记失败?AGENTS.md 有硬约束:Bifrost 绝不自动重放失败的用户提示词,哪怕配置了 opt-in。理由是前一轮可能已经改了文件、调过工具、碰过外部系统。任何重试设计都要用户逐次确认、宿主要能证明零副作用、还要有确定性 E2E 覆盖和已批的 ADR。这条约束决定了失败观测必须在”整个 run 结束后”结算,而不是在中间某个重试点草率判死。

虚拟模型与两个形态的切换

setModel 是物理切换:index.ts 在生成前调 await pi.setModel(physical),直接把 Pi 的真实模型换掉。但 Bifrost 还藏着一个虚拟模型 bifrost/auto——选中它之后,Pi 的虚拟模型 API 会在每请求分发时替你落到具体物理模型上。这就是 virtual-model.ts、virtual-routing.ts、virtual-override.ts 那一票虚拟层的分工。

虚拟分发有自己的 ownership 语义(createDispatchOwnership):dispatch 只释放自己记下的 trial claim 和账本条目,onDispatchFailed 绝不会偷走另一个 dispatch 为同一模型记的账。fail-closed 策略下,如果 half-open 试探正在被占,就直接抛 Bifrost: half-open trial unavailable,宁可不路由也不硬塞。这个问题是有真实出处的:ADR 0015 讲就是子代理继承父会话 pinned: true 导致没法独立路由,后来定位到 bifrost.json(config,enabled 持久)和 bifrost-state.json(state,pinned 也持久)混用,修复改成:enabled 持久、pinned 只活在当前 session,子代理 boot 时继承 enabled: true + pinned: false。

工程上是怎么兜住这套东西的

架构分层上,这套系统可以画成这样:

层模块职责
入口/命令层index.ts、commands.ts生命周期 hook、/bifrost * 命令注册与分发
路由判定层classification-pipeline.ts、routing.ts五段管线、候选解析、策略选择
分类器层classifier*.ts、typesafe-classifier.ts、classifier-detection.ts后端选择、降级、置信度
可靠性层reliability.ts、reliability-store.ts、runtime-reliability.ts熔断器、持久化、run 账本
缓存层cache.tsJaccard 模糊缓存、LRU 驱逐
配置层config.ts四层配置合并、校验、默认值

配置合并顺序在 CONTEXT.md 写死:扩展默认 → ~/.pi/agent/ → 项目内 .pi/ → 项目根,后面的覆盖前面的;规则文件 bifrost-routes.json 单独加载、覆盖内联 rules。/bifrost init 会先 probe 每个 registry 模型(1+1=,主传输上限 5 token),缓存 1 小时内复用,-f 强制重测;然后按成本提议 tier——FRONTIER_COST_THRESHOLD=5、QUICK_COST_THRESHOLD=1,单位是每 1M token 的 $。注释写得很清醒:“cost 是稳定信号,命名约定会变”。

命令速查一手材料:

命令作用
/bifrost init探活 + 提议配置 + 引导 classifier
/bifrost probe向每个 registry 模型发小请求,报可用性
/bifrost preview <prompt>只算路由结果不动模型;--json 输出机器可读行
/bifrost on / off开 / 关路由策略
/bifrost pin / unpin本会话锁死当前模型 / 恢复逐条路由
/bifrost reload重载合并配置
/bifrost classifier status / test / on / off分类器后端诊断与开关
/bifrost debug路由 + 可靠性诊断
/bifrost benchmark <prompt>分类结果显示,不作生成

config.ts 给分层配置做了带 severity 的校验:分类器 criteria 缺失、正则语法非法、failureThreshold 非整数都会报 error,而非静默吞掉;无效配置会在管线里降级并打印 [bifrost/config] error,不会悄悄把路由带偏。每个命令都能在不进 TUI 的情况下从终端驱动,preview --json 是脚本化测试的主入口。

测试这块是 Bifrost 最不惜力气的地方:tests/ 下 40+ 个文件,reliability.test.ts、runtime-reliability.test.ts 盯账本结算,dispatch-ownership.test.ts 盯 trial 归属,docs-command-drift.test.ts 会解析 docs/guide/commands.md 里的命令表、和 BIFROST_COMMAND_OPTIONS 对齐,文档和命令注册表漂移会让测试失败。这等于把”文档发布和代码发布一致”做进了 CI,而不是靠人肉复核。加一个特性就要过 ADR 的 feature gate 五问:用户价值、显式策略、可见性、可覆盖、有确定性场景覆盖。这套门槛直接长在 docs/product-philosophy.md 里。

这套设计的取舍

最大的克制是没有默认模型偏好。Bifrost 不 ship 维护者自己的 provider/model 当默认路由策略,/bifrost init 之前会先问你,--json 输出稳定契约给脚本用。物理切换是默认,bifrost/auto 是显式 opt-in 的分发形态——符合”直接、安全、可见、用户控制、可移植”五条核心承诺里的”advisory 信号不静默改路由”。

限速点也摆在明面上:/bifrost probe 默认并发 50、单模型超时 10 秒,发小请求会产生 provider 用量、扣额度、可能触发突发限流。getting-started.md 会提醒你 tight-limit 的 provider 先在配置里把 concurrency 降到 4。

这套东西换到普通对话工具上未必成立——Pi 有完整的 model registry、virtual model API、agent_settled 生命周期,Bifrost 是贴着这些宿主能力长的。但”决策前先体检、失败按 run 结算、文档和代码用测试焊死、加功能先过门禁”这套工程纪律,可以单独搬走。


文末说明:仓库位置 github.com/iamaamir/pi-bifrost,MIT 协议。