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_members、STATIC_B5_PROFILES(src/opensquilla/provider/ensemble.py)。
每个预设都是一个 StaticB5Profile——四个固定的 proposer 加上一个 aggregator,全部绑定到单个 provider:
| Profile | Provider | Proposers | Aggregator |
|---|---|---|---|
static_openrouter_b5 | openrouter | deepseek/deepseek-v4-pro, z-ai/glm-5.2, moonshotai/kimi-k2.7-code, qwen/qwen3.7-max | z-ai/glm-5.2 |
static_tokenrhythm_b5 | tokenrhythm | deepseek-v4-pro, glm-5.2, kimi-k2.7-code, qwen3.7-max | glm-5.2 |
TokenRhythm profile 是 OpenRouter profile 的镜像:相同的聚合形态与默认值、相同的四个模型,只有 provider 和 model-id 命名不同(OpenRouter 风格的 vendor/model slug,对比 TokenRhythm 的裸名称)。
_build_static_b5_members 只是把 profile 具体化:每个 proposer 模型变成一个标记为 proposer_1..N 的 EnsembleMemberConfig,aggregator 模型变成一个标记为 aggregator 的条目,选择计划则记录 profile 名称、proposer/aggregator 模型以及 proposer 数量。没有什么可评分的——阵容就是 profile。
凭据门控
static_b5_credential_available 决定 ensemble 是否可以运行:它使用与运行时相同的 key 解析顺序,为每个成员(全部四个 proposer + aggregator)解析出一个 API key(参见成员 provider 解析)。一个激活 provider 不同、但其环境中带有该 profile provider 的环境 key(例如 OPENROUTER_API_KEY、TOKENRHYTHM_API_KEY)的用户,会被视为已选择加入。如果任何成员无法解析出 key,则跳过 ensemble,而不是带着一个空的 bearer token 向上游发起 turn。
1.2 自定义阵容(custom_b5)
来源:_build_custom_b5_members、_custom_b5_candidates(src/opensquilla/provider/ensemble.py);schema LlmEnsembleCandidateConfig(src/opensquilla/gateway/config.py)。
custom_b5 让运维人员通过 llm_ensemble.candidates 显式地编写阵容。每一行候选携带:
provider/model— 必填、非空;provider 会被转为小写。role—aggregator是唯一的结构性取值;空的或省略的 role 表示 proposer。因此 Web UI 只呈现 Proposer 与 Aggregator。已发布的取值primary、contrast、fast_check和critic仍被接受,并作为建议性的决策轨迹标签保留,但它们全都作为 proposer 执行并在设置中显示。未知值会被强制转为""而不是报错,因此手工编辑过的配置永远不会阻断启动。enabled— 被禁用的行会为读取兼容性而保留,但绝不会被计入或运行。
阵容组装(_build_custom_b5_members):
- 每个 role 不是
aggregator的启用行都作为 proposer 运行,按其 role 标记(未指定时标记为proposer_N)。 - role 为
aggregator的那一行负责融合草稿。proposer 行会按(provider, model)去重;aggregator 行可以合法地复用某个 proposer 的模型(一个既起草又融合的模型)。 - Fallback: 如果不存在
aggregator行,aggregator 会回退到当前路由到的模型——即用户在没有 ensemble 时本会得到的同一个模型——这样一个只有 proposer 的配置仍然会运行,而不是在 turn 时报错。选择计划会相应地把aggregator.source记录为candidate_role或inherited_model。
阵容边界与校验
由 LlmEnsembleConfig._validate_custom_b5_lineup(src/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_proposers、unknown_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_proposers | 1 | 3(预设)/ N-1(自定义,“除一个外全部”) |
proposer_timeout_seconds | 3600 | 300 |
aggregator_timeout_seconds | 3600 | 480 |
shuffle_candidates | True | False |
quorum_grace_seconds | 0(等待每一个 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_proposers、proposer_timeout_seconds、aggregator_timeout_seconds、shuffle_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 接收三样东西:
inherited_provider_config— SquillaRouter 已经为此 turn 解析出的 provider/模型(成为锚点)。turn_metadata— 携带routed_tier(c0–c3)、routing_confidence(0.0–1.0)以及routing_extra(当routed_tier缺失时使用的final_tier/base_tierfallback)。如果找不到任何可用值,默认为 tierc1。config—llm_ensemble.model_options与squilla_router.tiers,用于构建候选池。
2.3 候选池
_candidate_pool 按以下顺序组装一个去重后的 (provider, model) 候选列表:
- Router 锚点 — 继承的 provider/模型(
source="router_anchor")。它始终是pool[0],并始终成为第一个 proposer。 llm_ensemble.model_options— 运维人员配置的候选列表(source="model_options")。如果一个模型字符串包含/,则假定它是 OpenRouter 风格的 id,并经由openrouter路由;否则它继承锚点的 provider。squilla_router.tiers[*].model— 为某个 SquillaRouter tier 配置的每一个模型(source="router_tier:<tier>"),这样即使运维人员接入 router 的 tier 专属模型未列在model_options中,它们也符合资格。
每个候选都会用来自 _DYNAMIC_MODEL_CATALOG 的先验进行标注——这是一张内置表,包含约 14 个已知模型,带有 tier、quality(0–1)、cost_latency(0–1,越高 = 越便宜/越快)、family、vendor 和 architecture。不在目录中的模型会回退到从模型字符串或 tier 提示推导出的 tier 平均先验(_tier_quality_prior、_tier_cost_latency_prior)。
2.4 槽位模板
每个 router tier 映射到一个有序的 proposer”槽位”列表(_DYNAMIC_TIER_SLOTS):
| Tier | Slots |
|---|---|
c0 | anchor, cheap_contrast |
c1 | anchor, balanced_contrast |
c2 | anchor, adjacent_tier_check, orthogonal_family |
c3 | anchor, strong_critic, orthogonal_family, fast_sanity |
较低的 tier(廉价/简单的 turn)得到一个小的、偏成本的 ensemble;较高的 tier(困难的 turn)得到更多的 proposer,其槽位偏向质量与对比。anchor 槽位始终由 router 自己的模型填充,且永远不被评分——它被原样采纳。
每个 tier 还映射到一个 aggregator 槽位(_DYNAMIC_AGGREGATOR_SLOT):c0→aggregator_fast、c1→aggregator_balanced、c2/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_contrast 对 cost 和 role 赋予很高权重、对 affinity 赋予很低权重,而 strong_critic 对 quality 和 role 赋予很高权重、对 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— 偏好 tierc0/c1、与锚点的对比,以及成本/延迟。一个廉价的”第二意见”。balanced_contrast— 偏好 tierc1/c2、对比,以及质量。adjacent_tier_check— 偏好比路由到的 tier 高或低一档的 tier(adjacent_distance == 1),外加质量。检查一个强度略有不同的模型是否认同。orthogonal_family— 高于一切地偏好对比与多样性——一个来自与锚点不同厂商/家族/架构的模型。strong_critic— 大力偏好 tierc3与质量——把可用的最强模型作为 critic,仅在较高 tier 使用。fast_sanity— 偏好 tierc0/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 模板的顺序运行槽位:
anchor— 直接采纳,不评分。- 其余的 proposer 槽位,按顺序——每次选择在下一个槽位评分之前都会被加入
selected和selected_counts,因此后续槽位能看到更新后的多样性/重复状态。 - aggregator 槽位,最后评分,针对与 proposer 相同的累积
selected状态(因此如果它重复了某个 proposer 的模型,也会受到重复惩罚)。
2.7 输出
该函数返回 (profile_name, proposers, aggregator, selection_plan):
profile_name—"router_dynamic/<tier>",例如"router_dynamic/c2"。proposers— 每个槽位一个EnsembleMemberConfig,按槽位名称标记(anchor、cheap_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=True、min_successful_proposers=1)。