文档导航
文档 / LLM Ensemble 设计:静态阵容与动态 router 选择

LLM Ensemble 设计:静态阵容与动态 router 选择

llm_ensemble 运行一个 B5 fusion turn:多个 proposer 模型各自起草一份答案,然后一个 aggregator 模型将这些草稿融合为最终响应。本文档描述如何为一个 turn 选择模型集合。它不涵盖 ensemble 的运行时机制(流式、超时、quorum、fallback)——只涉及模型选择。

为什么用 ensemble 而不是单一模型

任何单一模型都有一组固定的盲点:其训练数据的失效模式、其解码随机性,以及其特有的偏差。再次询问同一个模型并不能消除它们——那只是重新掷出同一个分布。ensemble 从不同的角度攻击这个问题:用多个不同的模型起草答案,再让一个 aggregator 去调和它们。相比单模型 turn 的收益:

  • 误差抵消 / 更高的准确率。 独立的模型很少会在同一输入上犯相同的错误。当草稿彼此不一致时,aggregator 可以交叉核对它们,保留多数所支持的答案;当它们一致时,这种一致本身就是答案可靠的真实信号。特有的一次性错误会被投票淘汰,而不是被交付出去。
  • 通过多样性获得覆盖面。 不同的厂商/家族/架构确实各有不同的强项——一个更擅长代码,另一个更擅长长篇推理,还有一个更擅长严格的指令遵循。一个横跨它们的阵容比任何单一模型的强项覆盖了更大的输入空间。这正是为什么选择过程奖励多样性(不同的厂商/家族/架构),而不是按原始质量挑选 top-N。
  • 健壮性与可用性。 单一模型是单点故障——一次超时、限流或降级响应就会让整个 turn 失败。有了一组 proposer 的 quorum,只要有足够多的草稿返回,turn 仍能成功,aggregator 只需融合已到达的部分。
  • 降低方差。 融合多份草稿会平滑掉每次调用的采样噪声,因此对同一 prompt 的重复运行更稳定,也更不容易受到任何单一模型运气不佳的一次掷骰影响。
  • 一次批判环节,而不仅是投票。 aggregator 本身也是一个模型:它可以发现某份自信却错误的草稿,在推理更充分的答案与更冗长的答案之间做出偏好选择,并综合多份草稿中最好的部分——这是单模型 turn 永远得不到的一步。

代价是实实在在的——一个 N-proposer turn 大约花费 N+1 次模型调用,其延迟受限于最慢的 proposer 加上 aggregator。下文的选择策略正是为了把这份预算花得值:让 ensemble 的规模与构成与 turn 实际的难度相匹配,而不是总是为最大的阵容买单。低难度的 turn 得到一个小而廉价的阵容;更难的 turn 得到更多的 proposer 和更强的 critic。

选择策略

共有三种选择策略,由 build_ensemble_provider_from_config 中的 llm_ensemble.selection_mode 分发(src/opensquilla/provider/ensemble.py):

selection_mode家族状态
static_openrouter_b5静态阵容全新配置的默认值
static_tokenrhythm_b5静态阵容支持
custom_b5静态阵容(用户自建)支持
router_dynamic动态选择遗留

前两个家族是静态的:阵容在 turn 之前就已固定,要么来自打包的预设,要么来自显式的用户自建列表。最后一个是动态的:阵容根据 router 自身的 tier 决策,在每个 turn 逐次评分并组装。

全新配置默认使用 static_openrouter_b5。Web UI 只提供静态家族(预设 + 自定义);router_dynamic 不再在此提供,已存储的配置会呈现一键迁移到 custom_b5 的入口。直接的 TOML/RPC 配置对每种模式都仍然有效。


第 1 部分 — 静态阵容(当前设计)

静态阵容在 turn 运行之前就已固定:一组 proposer 模型加上一个 aggregator 模型,全部提前已知。不存在逐 turn 的评分。两种变体共享这一形态:

  • 预设static_openrouter_b5 / static_tokenrhythm_b5:打包的、硬编码的阵容,位于单个 provider 上。
  • 自定义custom_b5:一个显式的、用户自建的阵容,带有角色标签的候选以及单个 aggregator。

两种变体都属于同一个固定阵容默认值家族(builder 中的 is_static_b5),因此继承相同的运行时默认值(quorum、超时、不打乱、quorum grace)——参见共享的固定阵容默认值

1.1 静态预设

来源:_build_static_b5_membersSTATIC_B5_PROFILESsrc/opensquilla/provider/ensemble.py)。

每个预设都是一个 StaticB5Profile——四个固定的 proposer 加上一个 aggregator,全部绑定到单个 provider:

ProfileProviderProposersAggregator
static_openrouter_b5openrouterdeepseek/deepseek-v4-pro, z-ai/glm-5.2, moonshotai/kimi-k2.7-code, qwen/qwen3.7-maxz-ai/glm-5.2
static_tokenrhythm_b5tokenrhythmdeepseek-v4-pro, glm-5.2, kimi-k2.7-code, qwen3.7-maxglm-5.2

TokenRhythm profile 是 OpenRouter profile 的镜像:相同的聚合形态与默认值、相同的四个模型,只有 provider 和 model-id 命名不同(OpenRouter 风格的 vendor/model slug,对比 TokenRhythm 的裸名称)。

_build_static_b5_members 只是把 profile 具体化:每个 proposer 模型变成一个标记为 proposer_1..NEnsembleMemberConfig,aggregator 模型变成一个标记为 aggregator 的条目,选择计划则记录 profile 名称、proposer/aggregator 模型以及 proposer 数量。没有什么可评分的——阵容就是 profile。

凭据门控

static_b5_credential_available 决定 ensemble 是否可以运行:它使用与运行时相同的 key 解析顺序,为每个成员(全部四个 proposer + aggregator)解析出一个 API key(参见成员 provider 解析)。一个激活 provider 不同、但其环境中带有该 profile provider 的环境 key(例如 OPENROUTER_API_KEYTOKENRHYTHM_API_KEY)的用户,会被视为已选择加入。如果任何成员无法解析出 key,则跳过 ensemble,而不是带着一个空的 bearer token 向上游发起 turn。

1.2 自定义阵容(custom_b5

来源:_build_custom_b5_members_custom_b5_candidatessrc/opensquilla/provider/ensemble.py);schema LlmEnsembleCandidateConfigsrc/opensquilla/gateway/config.py)。

custom_b5 让运维人员通过 llm_ensemble.candidates 显式地编写阵容。每一行候选携带:

  • provider / model — 必填、非空;provider 会被转为小写。
  • roleaggregator 是唯一的结构性取值;空的或省略的 role 表示 proposer。因此 Web UI 只呈现 ProposerAggregator。已发布的取值 primarycontrastfast_checkcritic 仍被接受,并作为建议性的决策轨迹标签保留,但它们全都作为 proposer 执行并在设置中显示。未知值会被强制转为 "" 而不是报错,因此手工编辑过的配置永远不会阻断启动。
  • enabled — 被禁用的行会为读取兼容性而保留,但绝不会被计入或运行。

阵容组装(_build_custom_b5_members):

  1. 每个 role 不是 aggregator 的启用行都作为 proposer 运行,按其 role 标记(未指定时标记为 proposer_N)。
  2. role 为 aggregator 的那一行负责融合草稿。proposer 行会按 (provider, model) 去重;aggregator 行可以合法地复用某个 proposer 的模型(一个既起草又融合的模型)。
  3. Fallback: 如果不存在 aggregator 行,aggregator 会回退到当前路由到的模型——即用户在没有 ensemble 时本会得到的同一个模型——这样一个只有 proposer 的配置仍然会运行,而不是在 turn 时报错。选择计划会相应地把 aggregator.source 记录为 candidate_roleinherited_model

阵容边界与校验

LlmEnsembleConfig._validate_custom_b5_lineupsrc/opensquilla/gateway/config.py)强制执行,selection_mode == "custom_b5" 时检查(预设携带固定阵容;router_dynamic 逐 turn 选择):

  • 最多一个启用的候选可以携带 role aggregator
  • 启用的 proposer 数量必须保持在 [CUSTOM_B5_MIN_PROPOSERS=2, CUSTOM_B5_MAX_PROPOSERS=6] 之内。
  • 每 turn 的总调用次数上限为 CUSTOM_B5_MAX_TOTAL_CALLS=8(proposer + aggregator)。

就绪门控

custom_b5_lineup_ready 在包装 turn 之前返回 (ready, reason)。当阵容不可运行时,它以 fail-closed 的方式返回一个机器可读的原因:no_proposersunknown_provider:<p>missing_credential:<p>(一个 provider 需要 key 但解析不出 key 的成员)。这与静态预设门控相呼应——一个带有空 bearer token 的成员会未经认证地把对话发往上游,因此会跳过包装。

1.3 共享的固定阵容默认值

两个静态家族都在 build_ensemble_provider_from_config 中设置 is_static_b5 = True,这会应用固定阵容家族的默认值。对于下方可配置的行,替换在存储值仍等于其遗留默认值时应用(_static_default_if_legacy),因此运维人员的覆盖会被保留。Quorum grace 是一项运行时家族策略,而非公开的配置字段:

参数遗留(router_dynamic固定阵容默认值
min_successful_proposers13(预设)/ N-1(自定义,“除一个外全部”)
proposer_timeout_seconds3600300
aggregator_timeout_seconds3600480
shuffle_candidatesTrueFalse
quorum_grace_seconds0(等待每一个 proposer)10

min_successful_proposers 还会被额外向下钳制到实际的 proposer 数量。配置值和生效值(min-success、超时、shuffle)都会记录在选择计划中以便调试。

遗留值 0 会禁用 quorum 提前退出并等待每一个 proposer;它并不意味着立即、零延迟的截断。固定阵容则在达到 quorum 之后额外允许十秒,这样一份近乎完成的最终草稿仍能加入融合,而无需等待完整的 proposer 超时。

Proposer 从不拥有可执行的工具边界。默认情况下它们不会收到任何当前的工具 schema。设置 proposer_tools = true 会把那些 schema 仅作为建议性词汇暴露:原生的或文本形式的、呈工具形状的输出会被转换为有界的、不受信任的候选文本。aggregator 可以使用这些信息,但必须通过正常的 registry、permission、审批与 sandbox 检查来独立发起任何真实的工具调用。

1.4 成员 provider 解析

每个成员(静态或自定义)都通过 _member_provider_config 解析出其具体的 ProviderConfig,它把成员意图叠加在继承的/路由到的 provider 配置之上:

  • API key — 如果设置了成员级的 api_key_env 环境变量则用它;否则当成员的 provider 与激活 provider 匹配时用继承的 key;否则用 provider 注册表的环境 key(例如 OPENROUTER_API_KEY)。
  • base_url — 成员覆盖,否则用继承的 base URL(同一 provider)或 provider spec 的默认 base URL。
  • proxy / org_id / provider_routing — 仅当成员共享激活 provider 时继承;否则重置。

这正是让一个静态/自定义阵容能够针对用户当前并未主动路由到的 provider 运行的机制,只要该 provider 的凭据存在于环境中。

1.5 配置界面

[llm_ensemble]
enabled = true
selection_mode = "static_openrouter_b5"   # or static_tokenrhythm_b5 / custom_b5

自定义阵容:

[llm_ensemble]
enabled = true
selection_mode = "custom_b5"

[[llm_ensemble.candidates]]
provider = "openrouter"
model = "deepseek/deepseek-v4-pro"

[[llm_ensemble.candidates]]
provider = "openrouter"
model = "z-ai/glm-5.2"

[[llm_ensemble.candidates]]
provider = "openrouter"
model = "z-ai/glm-5.2"
role = "aggregator"

静态预设不暴露任何阵容调优——模型在代码中固定。自定义阵容完全通过 candidates 列表调优(受上述边界约束)。两者共享固定阵容运行时默认值,运维人员仍可显式覆盖它们(min_successful_proposersproposer_timeout_secondsaggregator_timeout_secondsshuffle_candidates)。


第 2 部分 — router_dynamic 选择(遗留)

状态:遗留。 router_dynamic 对已有配置仍然完全支持,但不再在 Web UI 中提供。已存储的 router_dynamic 配置会呈现一键迁移到 custom_b5 的入口。直接的 TOML/RPC 配置仍如下所述继续有效。

router_dynamic 是动态模型选择策略:它不使用固定阵容,而是逐 turn 挑选 proposer 和 aggregator,由 SquillaRouter 对该 turn 的 tier 决策驱动。用 llm_ensemble.selection_mode = "router_dynamic" 启用它。

来源:src/opensquilla/provider/ensemble.py_candidate_pool_score_dynamic_candidate_select_dynamic_candidate_build_router_dynamic_members)。

2.1 为什么用动态选择

固定的 proposer/aggregator 列表无法适应某个 turn 实际选中的模型,并迫使运维人员在每个 router tier 上手工调优哪些模型能良好搭配。router_dynamic 则改为:

  • 复用 SquillaRouter 已经为该 turn 挑选的模型作为锚点(anchor)proposer,因此 ensemble 绝不会与 router 自身的 tier 决策相矛盾;
  • 通过对一个候选模型池按每 tier 的”槽位模板”评分,来填充其余的 proposer 槽位和 aggregator 槽位;
  • 惩罚重复选中已在 ensemble 中的模型,因此 proposer 保持多样,而不会坍缩到少数几个高质量模型上。

2.2 输入

_build_router_dynamic_members 接收三样东西:

  1. inherited_provider_config — SquillaRouter 已经为此 turn 解析出的 provider/模型(成为锚点)。
  2. turn_metadata — 携带 routed_tierc0c3)、routing_confidence(0.0–1.0)以及 routing_extra(当 routed_tier 缺失时使用的 final_tier/base_tier fallback)。如果找不到任何可用值,默认为 tier c1
  3. configllm_ensemble.model_optionssquilla_router.tiers,用于构建候选池。

2.3 候选池

_candidate_pool 按以下顺序组装一个去重后的 (provider, model) 候选列表:

  1. Router 锚点 — 继承的 provider/模型(source="router_anchor")。它始终是 pool[0],并始终成为第一个 proposer。
  2. llm_ensemble.model_options — 运维人员配置的候选列表(source="model_options")。如果一个模型字符串包含 /,则假定它是 OpenRouter 风格的 id,并经由 openrouter 路由;否则它继承锚点的 provider。
  3. squilla_router.tiers[*].model — 为某个 SquillaRouter tier 配置的每一个模型(source="router_tier:<tier>"),这样即使运维人员接入 router 的 tier 专属模型未列在 model_options 中,它们也符合资格。

每个候选都会用来自 _DYNAMIC_MODEL_CATALOG 的先验进行标注——这是一张内置表,包含约 14 个已知模型,带有 tierquality(0–1)、cost_latency(0–1,越高 = 越便宜/越快)、familyvendorarchitecture。不在目录中的模型会回退到从模型字符串或 tier 提示推导出的 tier 平均先验(_tier_quality_prior_tier_cost_latency_prior)。

2.4 槽位模板

每个 router tier 映射到一个有序的 proposer”槽位”列表(_DYNAMIC_TIER_SLOTS):

TierSlots
c0anchor, cheap_contrast
c1anchor, balanced_contrast
c2anchor, adjacent_tier_check, orthogonal_family
c3anchor, strong_critic, orthogonal_family, fast_sanity

较低的 tier(廉价/简单的 turn)得到一个小的、偏成本的 ensemble;较高的 tier(困难的 turn)得到更多的 proposer,其槽位偏向质量与对比。anchor 槽位始终由 router 自己的模型填充,且永远不被评分——它被原样采纳。

每个 tier 还映射到一个 aggregator 槽位(_DYNAMIC_AGGREGATOR_SLOT):c0→aggregator_fastc1→aggregator_balancedc2/c3→aggregator_strong

2.5 为一个槽位给候选评分

对每个非锚点槽位,池中的每个候选都会被评分,并选出最佳的那一个(_select_dynamic_candidate_score_dynamic_candidate):

score = weights.quality   * quality_prior
      + weights.affinity  * router_affinity_score
      + weights.diversity * diversity_score
      + weights.cost      * cost_latency_prior
      + weights.role      * role_match_score(slot)
      - duplicate_penalty

每个槽位有其自己的权重向量(_DYNAMIC_SLOT_WEIGHTS),例如 cheap_contrastcostrole 赋予很高权重、对 affinity 赋予很低权重,而 strong_criticqualityrole 赋予很高权重、对 cost 几乎不赋权重。

评分分量

  • router_affinity_score — 候选的 tier 先验与该 turn 的 routed_tier 有多接近,并按 routing_confidence 缩放。较低的 router 置信度会放松 tier 匹配,而不是强行做出脆弱的锁定,因为一个低置信度的路由本身对正确的 tier 就是不确定的。
  • diversity_score — 奖励那些其 family/vendor/provider/tier/architecture 尚未在本 turn 迄今挑选的 proposer 中出现过的候选(逐槽位增量检查)。
  • role_match_score — 槽位专属逻辑(见下文),根据该槽位应贡献什么,组合 tier 定向、对锚点的对比、质量或成本。
  • duplicate_penalty_DYNAMIC_SELECTED_PENALTY[slot] * times_already_selected。允许再次选中同一个 (provider, model),但随着同一模型不断赢下槽位,代价会越来越高。

按槽位的 role 匹配

_role_match_score 因槽位而异——这里正是每个槽位意图的实际编码之处:

  • cheap_contrast — 偏好 tier c0/c1、与锚点的对比,以及成本/延迟。一个廉价的”第二意见”。
  • balanced_contrast — 偏好 tier c1/c2、对比,以及质量。
  • adjacent_tier_check — 偏好比路由到的 tier 高或低一档的 tier(adjacent_distance == 1),外加质量。检查一个强度略有不同的模型是否认同。
  • orthogonal_family — 高于一切地偏好对比与多样性——一个来自与锚点不同厂商/家族/架构的模型。
  • strong_critic — 大力偏好 tier c3 与质量——把可用的最强模型作为 critic,仅在较高 tier 使用。
  • fast_sanity — 偏好 tier c0/c1 与成本/延迟——一个快速、廉价的合理性检查,仅在 c3 使用。
  • aggregator_fast / aggregator_balanced / aggregator_strong — 各自以不同方式平衡 tier 定向与质量;aggregator_strong 对质量赋予最高权重、对成本赋予最低权重,因为 aggregator 的输出就是最终响应。

打破平局

候选按 (score, quality_prior, cost_latency_prior, -pool_index) 降序排序,因此平局时会回退到更高的质量,然后是更高的成本/延迟分数,然后是更靠前的池位置(更接近锚点/运维人员配置的列表)胜出。

2.6 选择顺序

_build_router_dynamic_members 按 tier 模板的顺序运行槽位:

  1. anchor — 直接采纳,不评分。
  2. 其余的 proposer 槽位,按顺序——每次选择在下一个槽位评分之前都会被加入 selectedselected_counts,因此后续槽位能看到更新后的多样性/重复状态。
  3. aggregator 槽位,最后评分,针对与 proposer 相同的累积 selected 状态(因此如果它重复了某个 proposer 的模型,也会受到重复惩罚)。

2.7 输出

该函数返回 (profile_name, proposers, aggregator, selection_plan)

  • profile_name"router_dynamic/<tier>",例如 "router_dynamic/c2"
  • proposers — 每个槽位一个 EnsembleMemberConfig,按槽位名称标记(anchorcheap_contrast、……)。
  • aggregator — 一个 EnsembleMemberConfig,标记为 aggregator
  • selection_plan — 一份用于可观测性的完整轨迹,包括解析出的 tier/置信度、锚点、槽位模板、每槽位的评分分解(_score_trace,包含每槽位得分最高的 3 个候选以便调试险些落选者)、aggregator 的评分分解、完整的候选池,以及 duplicate_policy: "selected_penalty"

build_ensemble_provider_from_config(公开入口)还会在配置值超过该 tier 模板实际产生的 proposer 槽位数量时,把 min_successful_proposers 向下钳制到 len(proposers)——例如在 tier c0(2 个槽位)配置 min_successful_proposers=4 会得到一个生效的最小值 2。配置值和生效值都会记录在 selection_plan 中以便调试。

2.8 配置界面

[llm_ensemble]
enabled = true
selection_mode = "router_dynamic"

运维人员可以调优的内容:

  • llm_ensemble.model_options — 把候选池扩展到 router 锚点与已配置的 router tier 之外。
  • llm_ensemble.min_successful_proposers — 期望的最小成功 proposer 数(按上文所述逐 turn 钳制)。
  • squilla_router.tiers[*].model — 间接扩展候选池,并决定对于给定 tier 哪个模型成为锚点。

运维人员对槽位模板、权重或模型目录先验没有控制权——那些在代码中固定。与静态家族不同,router_dynamic 保留遗留的运行时默认值(超时 3600s、shuffle_candidates=Truemin_successful_proposers=1)。

在 GitHub 上编辑此页(英文原稿) OpenSquilla 文档 · 中文社区翻译