Data-Driven Capabilities 消费者迁移指南¶
Status: Stable (2026-04-11) 配套 RFC: data-driven-capabilities-rfc.md 适用读者: 要在 flyto 引擎中实现新 provider,或把老 provider 接入数据驱动能力机制的开发者.
0. 你需要这份文档吗?¶
以下任一场景适用:
- 你要实现一个全新的
flyto.ModelProvider(接入某个新的 LLM 厂商) - 你在 fork 或 vendor 一个老 provider,想把它接入
ModelRegistry - 你在审阅一个 PR,想判断它是否正确遵循了 PR1/PR2 的抽象模板
不适用的场景:
- 只是消费
flyto.Engine(作为 SDK 用户),不关心 provider 内部实现 - 直接看docs/sdk/ - 只是改
capability-probe的探测逻辑 - 看core/cmd/capability-probe/README.md
1. 机制速览(3 分钟)¶
核心不变量:provider 的能力决策("这个模型支持多少 tools?支持 thinking 吗?")由 req.Capabilities *flyto.ModelInfo 驱动,engine 在每次 Stream() 之前从 ModelRegistry 取快照注入;registry 空的时候,provider 走包内兜底路径,行为等同 PR1 之前(零回归).
数据流:
capability-probe (offline)
↓ writes
~/.flyto/capabilities/capabilities.json
↓ pricing.LoadAndRegister
ModelRegistry (in-memory)
↓ engine.BuildAndStream 注入
req.Capabilities *ModelInfo (per-request 快照)
↓ provider 读取
provider.resolveXxx(req) helper
↓
Stream() 决策 + WarningEvent 生成
三个 ModelInfo 关键字段(PR1 引入):
type ModelInfo struct {
// ... 现有字段 ID / ContextWindow / Pricing 等
MaxTools int // 0 = 未知/不限制
MaxToolsExhaustive bool // true = 硬上限,客户端硬拒;false/零值 = 下界,软处理
SupportsThinking bool
SupportsCaching bool
CachingMinTokens int // 0 哨兵 = 未探测,降级到包内表
}
双开关协议(want × can):
| 用户 want | 模型 can | 实际行为 |
|---|---|---|
| true | true | ✓ 启用 |
| true | false | ✗ 不启用,provider emit WarningEvent{Code:"feature_unsupported"} |
| false | * | ✗ 不启用(尊重 opt-out) |
2. 改造模板(五步)¶
假设你要给一个新 provider 接入数据驱动能力.模板改造包含五个部分:
2.1 resolve<Capability> helpers¶
每个需要数据驱动的能力,写一个 helper:registry 优先 + 包内兜底.
// resolveMaxTools 返回工具上限 + 是否硬性上限。
// registry 优先 + 包内 fooMaxTools 兜底。
func resolveMaxTools(req *flyto.Request) (int, bool) {
if req.Capabilities != nil && req.Capabilities.MaxTools > 0 {
return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive
}
return fooMaxTools, false // 包内 const 作兜底
}
// resolveThinkingSupport registry 优先 + 静态表兜底。
func resolveThinkingSupport(req *flyto.Request) bool {
if req.Capabilities != nil {
return req.Capabilities.SupportsThinking
}
return modelSupportsThinkingFallback(req.Model) // 扫包内静态表
}
关键约束:helper 不能引用 *Provider--它只读 req.Capabilities 和包内静态数据.这让它可测试(单元测试不需要构造 Provider 实例)且不持有 registry 引用(避免 config 包依赖循环).
2.2 detectFeatureWarnings method¶
检测 want × can 不一致的场景,返回 []*flyto.WarningEvent:
func (p *Provider) detectFeatureWarnings(req *flyto.Request) []*flyto.WarningEvent {
var warnings []*flyto.WarningEvent
wantsThinking := p.cfg.ThinkingBudget > 0 || req.NeedsThinking
if wantsThinking && !resolveThinkingSupport(req) {
warnings = append(warnings, &flyto.WarningEvent{
Code: "feature_unsupported",
Message: "req.NeedsThinking/Config.ThinkingBudget 已设置但模型 " + req.Model + " 不支持 thinking",
Detail: "model=" + req.Model + " feature=thinking",
})
}
// 类似地检测 caching 和其他路径...
return warnings
}
为什么是 method 而不是包级函数:它需要读 p.cfg(用户意图 want 信号来自 Config).helper 是纯函数(只读 req),detectFeatureWarnings 是 method.
2.3 prependWarnings helper¶
在下游 channel 前面 prepend 一组 WarningEvent:
func prependWarnings(downstream <-chan flyto.Event, warnings []*flyto.WarningEvent) <-chan flyto.Event {
out := make(chan flyto.Event, len(warnings))
for _, w := range warnings {
out <- w
}
go func() {
defer close(out)
for evt := range downstream {
out <- evt
}
}()
return out
}
重要:不要从 providers/anthropic 或其他 provider 包 import 这个函数--它在每个 provider 包内都是小写私有.RFC §3 决策明确"不抽共享 helper",理由是不同 provider 的语义差异会让共享层错综交叉.每个 provider 自己复制一份 prependWarnings 是可接受的 duplication(~10 行代码,远低于共享带来的耦合).
2.4 改造 Stream() 调用点¶
func (p *Provider) Stream(ctx context.Context, req *flyto.Request) (<-chan flyto.Event, error) {
// 1. max_tools 检查
maxTools, exhaustive := resolveMaxTools(req)
if maxTools > 0 && exhaustive && len(req.Tools) > maxTools {
if err := wire.CheckToolCount(req.Tools, maxTools); err != nil {
return nil, err
}
}
// 2. want × can 检测(在调用下游 Stream 之前计算)
warnings := p.detectFeatureWarnings(req)
// 3. 实际的 API 调用(该做的改动都先做完)
ch, err := p.client.Stream(ctx, wireReq)
if err != nil {
return nil, err
}
// 4. 零 warnings 时直接返回原 channel,跳过 prependWarnings 的 goroutine 开销
if len(warnings) > 0 {
return prependWarnings(ch, warnings), nil
}
return ch, nil
}
关键约束:
- detectFeatureWarnings 必须在下游 client.Stream 之前计算--因为它要读 "原始 want 信号"(比如 gemini 的 thinkingBudget 会被后续代码 silent 清零,清零后就检测不到了)
- 零 warnings 时直接返回原 channel,不要无条件包一层 prependWarnings--那会引入无意义的 goroutine 开销
2.5 测试¶
每个改造需要覆盖的测试矩阵:
| 场景 | 断言 |
|---|---|
resolveMaxTools nil Capabilities |
返回兜底值(通常 (N, false)) |
resolveMaxTools registry 注入 Exhaustive=true |
返回 registry 值 + true |
resolveMaxTools registry 注入 Exhaustive=false |
返回 registry 值 + false(软处理) |
resolveMaxTools registry MaxTools=0 |
降级到包内兜底 |
resolveThinkingSupport nil |
返回兜底策略结果 |
resolveThinkingSupport registry override |
覆盖静态表 |
detectFeatureWarnings want=false can=false |
零 warning |
detectFeatureWarnings want=false can=true |
零 warning(尊重 opt-out) |
detectFeatureWarnings want=true can=false |
1 warning,Code=feature_unsupported |
detectFeatureWarnings want=true can=true |
零 warning |
测试策略建议:直接单元测试 helpers + detectFeatureWarnings,不要端到端测 Stream()--后者需要 mock 整个 wire client,开销不成比例.helper 是纯函数,单元测试层级足以覆盖 PR2 引入的全部新逻辑.
3. 兜底策略选择(关键决策)¶
这是最容易踩坑的地方.三种兜底模式:
3.1 静态表查询(最常见)¶
适用:provider 维护一个包内静态模型表(xxxModels []flyto.ModelInfo),每个模型显式声明 SupportsThinking / SupportsCaching 等字段.
func modelSupportsThinkingFallback(model string) bool {
for _, m := range xxxModels {
if m.ID == model {
return m.SupportsThinking
}
}
return false // 未知模型保守默认
}
例子:anthropic / openai / gemini / minimax.
3.2 const 0 + false 保守默认¶
适用:provider 没有完整的静态表(或者该能力字段未定义),兜底返回"不支持"的保守默认.
func resolveThinkingSupport(req *flyto.Request) bool {
if req.Capabilities != nil {
return req.Capabilities.SupportsThinking
}
return false // 包注释声明不支持
}
例子:ollama / lmstudio(本地模型默认不支持 thinking).
3.3 兜底 true(语义反转)¶
适用:provider 没有静态表(完全依赖 live API),且早期方案是"无脑注入字段"的零回归要求.
func resolveThinkingSupport(req *flyto.Request) bool {
if req.Capabilities != nil {
return req.Capabilities.SupportsThinking
}
return true // 假设支持,让底层处理
}
例子:openrouter(三路径全部兜底 true).
3.4 决策流程图¶
开始 → provider 有完整静态表且能力字段齐全?
├── 是 → 用 "3.1 静态表查询"
└── 否 → provider 早期方案是"无脑注入能力字段"?
├── 是 → 用 "3.3 兜底 true"(保证零回归)
└── 否 → 用 "3.2 const 0 + false"(保守默认)
4. 反向思维检查清单¶
PR2 推广过程中踩过的坑,实现新 provider 时要避免:
4.1 detectFeatureWarnings 不要放在字段清零之后¶
坏例子(gemini 早期方案有这个结构):
if thinkingBudget > 0 && !modelSupportsThinking(req.Model) {
thinkingBudget = 0 // silent 清零
}
warnings := p.detectFeatureWarnings(req) // 错!want 信号已经没了
正确:先检测 warnings,再做清零(保留早期方案清零行为作零回归):
warnings := p.detectFeatureWarnings(req) // 先检测
if thinkingBudget > 0 && !resolveThinkingSupport(req) {
thinkingBudget = 0 // 清零保留零回归
}
4.2 Exhaustive=false 时不要硬拒¶
坏例子:
正确:只有 Exhaustive=true 才客户端硬拒,软处理路径让 API 自己拒:
反向思维:为什么不让 Exhaustive=false 硬拒?-- 会误伤 probe 测试覆盖不全的模型.Exhaustive=false 意味着 "我们知道至少 N,真实上限可能更高",客户端硬拒反而比 API 更严格,丢失模型真实能力.
4.3 不要把 helpers 抽到共享包¶
这是 RFC §3 明确决策.诱惑很大:为什么 7 个 provider 都有一个 prependWarnings?抽到 pkg/providers/shared 多省事?
否决理由:不同 provider 的 语义差异 会让共享层错综交叉.比如兜底策略的三种分化(3.1/3.2/3.3)如果硬塞进共享 helper,签名会变成 resolveCapability(req, fallbackFunc, isStaticTable, isReversedSemantics) 这种鬼东西.Duplication 是更便宜的方案--7 个 prependWarnings 加起来 70 行,远低于强行抽象带来的耦合维护成本.
4.4 nil Capabilities 不是 error¶
不要写 if req.Capabilities == nil { return nil, fmt.Errorf("no capabilities") }.nil 是合法场景--单元测试 mock / engine 未接 registry / 模型不在 registry 等都会 nil.provider 必须走兜底路径而不是报错.
4.5 registry MaxTools=0 要降级到兜底¶
坏例子:
func resolveMaxTools(req *flyto.Request) (int, bool) {
if req.Capabilities != nil {
return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive
// 错!MaxTools=0 表示未探测,不是"上限 0"
}
return fooMaxTools, false
}
正确:MaxTools > 0 才是 registry 有数据:
if req.Capabilities != nil && req.Capabilities.MaxTools > 0 {
return req.Capabilities.MaxTools, req.Capabilities.MaxToolsExhaustive
}
0 是哨兵值--和 "无限制" 共享字面值,靠"有数据就 > 0"的约束区分.
4.6 用户 opt-out 永远尊重¶
坏例子:
// user 设了 NeedsThinking=false 但模型支持 thinking
if resolveThinkingSupport(req) {
wireReq.Thinking = &Thinking{Enabled: true} // 错!
}
正确:want AND can 的 AND 不是 OR.用户 want=false 时永远不启用(即使模型 can=true).详见 RFC §4.4 表格.
5. 参考实现索引¶
按改造复杂度递增:
| Provider | 复杂度 | 特点 | 适合学习 |
|---|---|---|---|
| ollama / lmstudio | S | 通用模板最简形式 | 第一次实现,从这里看起 |
| openai | M | API 级硬上限特化(单 int 返回签名) |
什么时候需要偏离通用模板 |
| gemini | M | silent disable + 清零保留零回归 | 处理早期方案已有的 silent disable |
| minimax | L | 双端点 dispatcher 层统一决策 | 多 path provider 的改造 |
| openrouter | L | 三路径 + 语义反转(兜底 true)+ 不清零字段 | 没有静态表时的处理 |
所有实现在 core/pkg/providers/<name>/provider.go,测试在 core/pkg/providers/<name>/provider_test.go.
6. 验收清单¶
改造完成后,逐项核对:
- [ ]
resolve<Capability>helpers 全部为包级函数(非 method) - [ ]
detectFeatureWarnings在Stream()下游调用之前计算 - [ ] 兜底路径(nil Capabilities)行为完全等同 PR 之前(零回归测试通过)
- [ ]
Exhaustive=false时走软处理(不客户端硬拒) - [ ]
len(warnings) == 0时直接返回原 channel(不包 prependWarnings goroutine) - [ ] 测试覆盖 want×can 全部 4 组合
- [ ] 测试覆盖 nil Capabilities 兜底 + registry 覆盖两种路径
- [ ]
prependWarnings在本包内是小写私有(未 import 其他 provider 包) - [ ]
provider.go改动只涉及 helper 增加 +Stream()调用点改造(不改下游 wire 层) - [ ]
go test ./pkg/providers/<name>/ -race全绿 - [ ] 跨包 regression:
go test ./pkg/flyto ./pkg/pricing ./pkg/engine -race全绿
7. 常见问题¶
Q: 我的 provider 应该定义 MaxToolsExhaustive=true 还是 false?
A: 看语义.如果 provider 文档明确记录了硬上限(比如 OpenAI 的 128 in OpenAPI spec),是 true.如果是 probe 实测得出的下界("至少到 N 没出错"),是 false.未探测的字段保持零值 false.
Q: 我的 provider 没有 SupportsThinking 这个概念(比如纯 embedding provider),helper 怎么办?
A: 不写 resolveThinkingSupport helper,也不在 detectFeatureWarnings 里检测 thinking 路径.不要强行对齐--只实现本 provider 实际有的能力.
Q: detectFeatureWarnings 的 Message 字段要写中文还是英文?
A: 现有实现都是中文(面向主要读者是中文工程师的团队).保持一致即可.未来若有 i18n 需求,Code 字段(feature_unsupported)已经是稳定的结构化 key,可以作为翻译锚点.
Q: 为什么不用 Observer 模式而用 WarningEvent?
A: RFC §4.4 决策:"warnings prepend 走 channel,不引入新的 observer 接口.engine 主循环已经在消费这个 channel 并转发到 observer/TUI,WarningEvent 会自然被处理.给 provider Config 加 Observer 字段会多一条通信通道,违反 channel 单一职责."
Q: prependWarnings 的 buffered channel 容量是 len(warnings),会不会阻塞?
A: 不会.buffer 容量正好等于要预填的元素数量,for 循环填满 buffer 不阻塞;之后的下游 forwarding 在独立 goroutine 里跑,也不会阻塞 Stream 返回.性能开销仅限于零 warnings 场景,所以调用方必须检查 len(warnings) > 0 才调用本函数.
Last updated: 2026-04-11
对应 RFC: data-driven-capabilities-rfc.md
PR1 参考实现: core/pkg/providers/anthropic/provider.go
PR2 参考实现(ollama 最简): core/pkg/providers/ollama/provider.go