跳转至

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 的抽象模板

不适用的场景:


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 时不要硬拒

坏例子:

if maxTools > 0 && len(req.Tools) > maxTools {
    return nil, err // 错!没看 exhaustive
}

正确:只有 Exhaustive=true 才客户端硬拒,软处理路径让 API 自己拒:

if maxTools > 0 && exhaustive && len(req.Tools) > maxTools {
    return nil, err
}

反向思维:为什么不让 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 canAND 不是 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)
  • [ ] detectFeatureWarningsStream() 下游调用之前计算
  • [ ] 兜底路径(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: detectFeatureWarningsMessage 字段要写中文还是英文? 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