454 lines
20 KiB
Markdown
454 lines
20 KiB
Markdown
# LLM Ensemble Design: Static Lineups & Dynamic Router Selection
|
||
|
||
`llm_ensemble` runs a **B5 fusion** turn: several *proposer* models each draft
|
||
an answer, and one *aggregator* model fuses those drafts into the final
|
||
response. This document describes how the **set of models** is chosen for a
|
||
turn. It does not cover the ensemble runtime mechanics (streaming, timeouts,
|
||
quorum, fallback) — only model selection.
|
||
|
||
## Why an ensemble instead of a single model
|
||
|
||
Any single model has a fixed set of blind spots: the failure modes of its
|
||
training data, its decoding randomness, and its particular biases. Asking that
|
||
one model again doesn't remove them — it re-rolls the *same* distribution. An
|
||
ensemble attacks the problem from a different angle: draft an answer with
|
||
several *different* models, then have an aggregator reconcile them. The wins
|
||
over a single-model turn:
|
||
|
||
- **Error cancellation / higher accuracy.** Independent models rarely make the
|
||
*same* mistake on the same input. When drafts disagree, the aggregator can
|
||
cross-check them and keep the answer the majority supports; when they agree,
|
||
that agreement is real signal that the answer is solid. Idiosyncratic
|
||
one-off errors get outvoted instead of shipped.
|
||
- **Coverage through diversity.** Different vendors/families/architectures have
|
||
genuinely different strengths — one is better at code, another at long-form
|
||
reasoning, another at careful instruction-following. A lineup that spans them
|
||
covers more of the input space than any single model's strong suit. This is
|
||
exactly why selection *rewards diversity* (distinct vendor/family/
|
||
architecture) rather than picking the top-N by raw quality.
|
||
- **Robustness & availability.** A single model is a single point of failure —
|
||
one timeout, rate-limit, or degraded response fails the whole turn. With a
|
||
quorum of proposers the turn still succeeds as long as *enough* drafts come
|
||
back, and the aggregator simply fuses what arrived.
|
||
- **Reduced variance.** Fusing several drafts smooths out per-call sampling
|
||
noise, so repeated runs of the same prompt are more stable and less
|
||
sensitive to an unlucky roll of the dice on any one model.
|
||
- **A critic pass, not just a vote.** The aggregator is a model in its own
|
||
right: it can spot a draft that's confidently wrong, prefer the
|
||
better-reasoned answer over the more verbose one, and synthesize the best
|
||
parts of several drafts — a step a single-model turn never gets.
|
||
|
||
The cost is real — an N-proposer turn spends roughly N+1 model calls and its
|
||
latency is bounded by the slowest proposer plus the aggregator. The selection
|
||
strategies below exist to spend that budget well: match ensemble size and
|
||
composition to how hard the turn actually is, rather than always paying for the
|
||
largest lineup. Lower-difficulty turns get a small, cheap lineup; harder turns
|
||
get more proposers and stronger critics.
|
||
|
||
## Selection strategies
|
||
|
||
There are three selection strategies, dispatched by
|
||
`llm_ensemble.selection_mode` in
|
||
`build_ensemble_provider_from_config`
|
||
(`src/opensquilla/provider/ensemble.py`):
|
||
|
||
| `selection_mode` | Family | Status |
|
||
|------------------|--------|--------|
|
||
| `static_openrouter_b5` | Static lineup | Default for fresh configs |
|
||
| `static_tokenrhythm_b5` | Static lineup | Supported |
|
||
| `custom_b5` | Static lineup (user-authored) | Supported |
|
||
| `router_dynamic` | Dynamic selection | Legacy |
|
||
|
||
The first two families are **static**: the lineup is fixed ahead of the turn,
|
||
either from a packaged preset or from an explicit user-authored list. The last
|
||
is **dynamic**: the lineup is scored and assembled per turn from the router's
|
||
own tier decision.
|
||
|
||
Fresh configs default to `static_openrouter_b5`. The Web UI offers only the
|
||
static families (preset + custom); `router_dynamic` is no longer offered there
|
||
and stored configs surface a one-click migration to `custom_b5`. Direct
|
||
TOML/RPC configuration keeps working for every mode.
|
||
|
||
---
|
||
|
||
# Part 1 — Static Lineups (current design)
|
||
|
||
A static lineup is fixed before the turn runs: a set of proposer models plus one
|
||
aggregator model, all known ahead of time. No per-turn scoring happens. Two
|
||
variants share this shape:
|
||
|
||
- **Presets** — `static_openrouter_b5` / `static_tokenrhythm_b5`: packaged,
|
||
hard-coded lineups on a single provider.
|
||
- **Custom** — `custom_b5`: an explicit user-authored lineup with
|
||
role-labelled candidates and a single aggregator.
|
||
|
||
Both variants belong to the same **fixed-lineup defaults family**
|
||
(`is_static_b5` in the builder) and therefore inherit the same runtime defaults
|
||
(quorum, timeouts, no shuffle, quorum grace) — see
|
||
[Shared fixed-lineup defaults](#shared-fixed-lineup-defaults).
|
||
|
||
## 1.1 Static presets
|
||
|
||
Source: `_build_static_b5_members`, `STATIC_B5_PROFILES`
|
||
(`src/opensquilla/provider/ensemble.py`).
|
||
|
||
Each preset is a `StaticB5Profile` — four fixed proposers plus one aggregator,
|
||
all bound to a single 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` |
|
||
|
||
The TokenRhythm profile is a mirror of the OpenRouter one: same aggregation
|
||
shape and defaults, the same four models, only the provider and the model-id
|
||
naming differ (OpenRouter-style `vendor/model` slugs vs. TokenRhythm's bare
|
||
names).
|
||
|
||
`_build_static_b5_members` simply materializes the profile: each proposer model
|
||
becomes an `EnsembleMemberConfig` labeled `proposer_1..N`, the aggregator model
|
||
becomes one labeled `aggregator`, and the selection plan records the profile
|
||
name, proposer/aggregator models, and proposer count. There is nothing to score
|
||
— the lineup is the profile.
|
||
|
||
### Credential gate
|
||
|
||
`static_b5_credential_available` decides whether the ensemble may run: it
|
||
resolves an API key for every member (all four proposers + aggregator) using the
|
||
same key-resolution order as the runtime (see
|
||
[Member provider resolution](#member-provider-resolution)). A user whose active
|
||
provider differs but whose environment carries the profile provider's env key
|
||
(e.g. `OPENROUTER_API_KEY`, `TOKENRHYTHM_API_KEY`) is treated as opted in. If any
|
||
member cannot resolve a key, the ensemble is skipped rather than posting a turn
|
||
upstream with an empty bearer token.
|
||
|
||
## 1.2 Custom lineup (`custom_b5`)
|
||
|
||
Source: `_build_custom_b5_members`, `_custom_b5_candidates`
|
||
(`src/opensquilla/provider/ensemble.py`); schema `LlmEnsembleCandidateConfig`
|
||
(`src/opensquilla/gateway/config.py`).
|
||
|
||
`custom_b5` lets an operator author the lineup explicitly via
|
||
`llm_ensemble.candidates`. Each candidate row carries:
|
||
|
||
- **`provider`** / **`model`** — required, non-empty; provider is lower-cased.
|
||
- **`role`** — advisory label, one of `""` (unassigned), `primary`, `contrast`,
|
||
`fast_check`, `critic`, or the structural `aggregator`. Unknown values coerce
|
||
to `""` instead of failing, so a hand-edited config never blocks boot.
|
||
- **`enabled`** — disabled rows are kept for read compatibility but never
|
||
counted or run.
|
||
|
||
Lineup assembly (`_build_custom_b5_members`):
|
||
|
||
1. Every enabled row whose role is **not** `aggregator` runs as a proposer,
|
||
labeled by its role (or `proposer_N` when unassigned).
|
||
2. The single row with role `aggregator` fuses the drafts. Proposer rows dedupe
|
||
on `(provider, model)`; the aggregator row may legitimately reuse a
|
||
proposer's model (a model both drafts and fuses).
|
||
3. **Fallback:** if no `aggregator` row exists, the aggregator falls back to the
|
||
currently routed model — the same model the user would have gotten without
|
||
the ensemble — so a proposer-only config still runs instead of erroring at
|
||
turn time. The plan records `aggregator.source` as `candidate_role` or
|
||
`inherited_model` accordingly.
|
||
|
||
### Lineup bounds & validation
|
||
|
||
Enforced by `LlmEnsembleConfig._validate_custom_b5_lineup`
|
||
(`src/opensquilla/gateway/config.py`), checked **only** when
|
||
`selection_mode == "custom_b5"` (presets carry fixed lineups; `router_dynamic`
|
||
selects per turn):
|
||
|
||
- At most **one** enabled candidate may carry role `aggregator`.
|
||
- Enabled proposer count must stay within
|
||
`[CUSTOM_B5_MIN_PROPOSERS=2, CUSTOM_B5_MAX_PROPOSERS=6]`.
|
||
- Total per-turn calls are capped at `CUSTOM_B5_MAX_TOTAL_CALLS=8`
|
||
(proposers + aggregator).
|
||
|
||
### Readiness gate
|
||
|
||
`custom_b5_lineup_ready` returns `(ready, reason)` before wrapping the turn. It
|
||
fails closed with a machine-readable reason when the lineup is not runnable:
|
||
`no_proposers`, `unknown_provider:<p>`, or `missing_credential:<p>` (a member
|
||
whose provider requires a key but resolves none). This mirrors the static-preset
|
||
gate — a member with an empty bearer token would post the conversation upstream
|
||
unauthenticated, so the wrap is skipped.
|
||
|
||
## 1.3 Shared fixed-lineup defaults
|
||
|
||
Both static families set `is_static_b5 = True` in
|
||
`build_ensemble_provider_from_config`, which swaps the legacy per-turn defaults
|
||
for the fixed-lineup family defaults. The swap is **only** applied when the
|
||
configured value still equals the legacy default (`_static_default_if_legacy`),
|
||
so any operator override is preserved:
|
||
|
||
| Parameter | Legacy (`router_dynamic`) | Fixed-lineup default |
|
||
|-----------|---------------------------|----------------------|
|
||
| `min_successful_proposers` | 1 | 3 (presets) / `N-1` (custom, "all but one") |
|
||
| `proposer_timeout_seconds` | 3600 | 300 |
|
||
| `aggregator_timeout_seconds` | 3600 | 480 |
|
||
| `shuffle_candidates` | `True` | `False` |
|
||
| `quorum_grace_seconds` | 0 | 30 |
|
||
|
||
`min_successful_proposers` is additionally clamped down to the actual proposer
|
||
count. Both the configured and effective values (min-success, timeouts, shuffle)
|
||
are recorded in the selection plan for debugging.
|
||
|
||
## 1.4 Member provider resolution
|
||
|
||
Every member (static or custom) resolves its concrete `ProviderConfig` through
|
||
`_member_provider_config`, which layers member intent over the inherited/routed
|
||
provider config:
|
||
|
||
- **API key** — a member-level `api_key_env` env var if set; else the inherited
|
||
key when the member's provider matches the active provider; else the provider
|
||
registry's env key (e.g. `OPENROUTER_API_KEY`).
|
||
- **`base_url`** — member override, else the inherited base URL (same provider)
|
||
or the provider spec's default base URL.
|
||
- **`proxy` / `org_id` / `provider_routing`** — inherited only when the member
|
||
shares the active provider; otherwise reset.
|
||
|
||
This is what lets a static/custom lineup run against a provider the user isn't
|
||
actively routing to, as long as that provider's credential is present in the
|
||
environment.
|
||
|
||
## 1.5 Configuration surface
|
||
|
||
```toml
|
||
[llm_ensemble]
|
||
enabled = true
|
||
selection_mode = "static_openrouter_b5" # or static_tokenrhythm_b5 / custom_b5
|
||
```
|
||
|
||
Custom lineup:
|
||
|
||
```toml
|
||
[llm_ensemble]
|
||
enabled = true
|
||
selection_mode = "custom_b5"
|
||
|
||
[[llm_ensemble.candidates]]
|
||
provider = "openrouter"
|
||
model = "deepseek/deepseek-v4-pro"
|
||
role = "primary"
|
||
|
||
[[llm_ensemble.candidates]]
|
||
provider = "openrouter"
|
||
model = "z-ai/glm-5.2"
|
||
role = "contrast"
|
||
|
||
[[llm_ensemble.candidates]]
|
||
provider = "openrouter"
|
||
model = "z-ai/glm-5.2"
|
||
role = "aggregator"
|
||
```
|
||
|
||
Static presets expose no lineup tuning — the models are fixed in code. Custom
|
||
lineups are tuned entirely through the `candidates` list (subject to the bounds
|
||
above). Both share the fixed-lineup runtime defaults, which an operator may
|
||
still override explicitly (`min_successful_proposers`,
|
||
`proposer_timeout_seconds`, `aggregator_timeout_seconds`, `shuffle_candidates`).
|
||
|
||
---
|
||
|
||
# Part 2 — `router_dynamic` Selection (legacy)
|
||
|
||
> **Status: legacy.** `router_dynamic` remains fully supported for existing
|
||
> configs but is no longer offered in the Web UI. Stored `router_dynamic`
|
||
> configs surface a one-click migration to `custom_b5`. Direct TOML/RPC
|
||
> configuration keeps working as described below.
|
||
|
||
`router_dynamic` is the dynamic model-selection strategy: instead of a fixed
|
||
lineup, it picks proposers and the aggregator **per turn**, driven by
|
||
SquillaRouter's tier decision for that turn. Enable it with
|
||
`llm_ensemble.selection_mode = "router_dynamic"`.
|
||
|
||
Source: `src/opensquilla/provider/ensemble.py`
|
||
(`_candidate_pool`, `_score_dynamic_candidate`, `_select_dynamic_candidate`,
|
||
`_build_router_dynamic_members`).
|
||
|
||
## 2.1 Why dynamic selection
|
||
|
||
A fixed proposer/aggregator list can't adapt to the model actually chosen for a
|
||
turn, and forces operators to hand-tune which models pair well together at each
|
||
router tier. `router_dynamic` instead:
|
||
|
||
- reuses the model SquillaRouter already picked for the turn as the **anchor**
|
||
proposer, so the ensemble never contradicts the router's own tier decision;
|
||
- fills the remaining proposer slots and the aggregator slot by scoring a pool
|
||
of candidate models against a per-tier "slot template";
|
||
- penalizes re-selecting a model that's already in the ensemble, so proposers
|
||
stay diverse instead of collapsing onto a few high-quality models.
|
||
|
||
## 2.2 Inputs
|
||
|
||
`_build_router_dynamic_members` takes three things:
|
||
|
||
1. **`inherited_provider_config`** — the provider/model SquillaRouter already
|
||
resolved for this turn (becomes the anchor).
|
||
2. **`turn_metadata`** — carries `routed_tier` (`c0`–`c3`), `routing_confidence`
|
||
(0.0–1.0), and `routing_extra` (`final_tier`/`base_tier` fallbacks used if
|
||
`routed_tier` is missing). Defaults to tier `c1` if nothing usable is found.
|
||
3. **`config`** — `llm_ensemble.model_options` and `squilla_router.tiers`,
|
||
used to build the candidate pool.
|
||
|
||
## 2.3 Candidate pool
|
||
|
||
`_candidate_pool` assembles a deduplicated list of `(provider, model)`
|
||
candidates, in this order:
|
||
|
||
1. **Router anchor** — the inherited provider/model (`source="router_anchor"`).
|
||
This is always `pool[0]` and always becomes the first proposer.
|
||
2. **`llm_ensemble.model_options`** — the operator-configured candidate list
|
||
(`source="model_options"`). If a model string contains `/` it's assumed to
|
||
be an OpenRouter-style id and routed via `openrouter`; otherwise it inherits
|
||
the anchor's provider.
|
||
3. **`squilla_router.tiers[*].model`** — every model configured for a
|
||
SquillaRouter tier (`source="router_tier:<tier>"`), so tier-specific models
|
||
the operator has wired into the router are eligible even if not listed in
|
||
`model_options`.
|
||
|
||
Each candidate is annotated with priors from `_DYNAMIC_MODEL_CATALOG` — a
|
||
built-in table of ~14 known models with `tier`, `quality` (0–1), `cost_latency`
|
||
(0–1, higher = cheaper/faster), `family`, `vendor`, and `architecture`. Models
|
||
not in the catalog fall back to tier-average priors (`_tier_quality_prior`,
|
||
`_tier_cost_latency_prior`) derived from the model string or tier hint.
|
||
|
||
## 2.4 Slot templates
|
||
|
||
Each router tier maps to an ordered list of proposer "slots"
|
||
(`_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` |
|
||
|
||
Lower tiers (cheap/simple turns) get a small, cost-biased ensemble; higher
|
||
tiers (hard turns) get more proposers with slots biased toward quality and
|
||
contrast. The `anchor` slot is always filled by the router's own model and is
|
||
never scored — it's taken as-is.
|
||
|
||
Each tier also maps to an aggregator slot (`_DYNAMIC_AGGREGATOR_SLOT`):
|
||
`c0→aggregator_fast`, `c1→aggregator_balanced`, `c2`/`c3→aggregator_strong`.
|
||
|
||
## 2.5 Scoring a candidate for a slot
|
||
|
||
For every non-anchor slot, every pool candidate is scored and the best one is
|
||
selected (`_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
|
||
```
|
||
|
||
Each slot has its own weight vector (`_DYNAMIC_SLOT_WEIGHTS`), e.g.
|
||
`cheap_contrast` weights `cost` and `role` heavily and `affinity` lightly,
|
||
while `strong_critic` weights `quality` and `role` heavily and `cost` almost
|
||
not at all.
|
||
|
||
### Score components
|
||
|
||
- **`router_affinity_score`** — how close the candidate's tier prior is to the
|
||
turn's `routed_tier`, scaled by `routing_confidence`. Low router confidence
|
||
relaxes tier matching instead of forcing a brittle lock, since a
|
||
low-confidence route is itself uncertain about the right tier.
|
||
- **`diversity_score`** — rewards a candidate whose family/vendor/provider/
|
||
tier/architecture aren't already represented among the proposers picked so
|
||
far in this turn (checked incrementally, slot by slot).
|
||
- **`role_match_score`** — slot-specific logic (see below), combining tier
|
||
targeting, contrast against the anchor, quality, or cost depending on what
|
||
that slot is supposed to contribute.
|
||
- **`duplicate_penalty`** — `_DYNAMIC_SELECTED_PENALTY[slot] * times_already_selected`.
|
||
Selecting the same `(provider, model)` again is allowed but costs
|
||
increasingly more as the same model keeps winning slots.
|
||
|
||
### Role match by slot
|
||
|
||
`_role_match_score` differs by slot — this is where each slot's intent is
|
||
actually encoded:
|
||
|
||
- **`cheap_contrast`** — favors tier `c0`/`c1`, contrast with the anchor, and
|
||
cost/latency. A cheap "second opinion."
|
||
- **`balanced_contrast`** — favors tier `c1`/`c2`, contrast, and quality.
|
||
- **`adjacent_tier_check`** — favors a tier one step above/below the routed
|
||
tier (`adjacent_distance == 1`), plus quality. Checks whether a
|
||
slightly-different-strength model agrees.
|
||
- **`orthogonal_family`** — favors contrast and diversity above all — a
|
||
model from a different vendor/family/architecture than the anchor.
|
||
- **`strong_critic`** — favors tier `c3` and quality heavily — the strongest
|
||
available model as a critic, used only at higher tiers.
|
||
- **`fast_sanity`** — favors tier `c0`/`c1` and cost/latency — a fast,
|
||
cheap sanity check, used only at `c3`.
|
||
- **`aggregator_fast` / `aggregator_balanced` / `aggregator_strong`** — each
|
||
balances tier targeting and quality differently; `aggregator_strong`
|
||
weights quality highest and cost lowest, since the aggregator's output is
|
||
the final response.
|
||
|
||
### Tie-breaking
|
||
|
||
Candidates are sorted by `(score, quality_prior, cost_latency_prior,
|
||
-pool_index)` descending, so ties fall back to higher quality, then higher
|
||
cost/latency score, then earlier pool position (closer to the anchor/operator-
|
||
configured list) wins.
|
||
|
||
## 2.6 Selection order
|
||
|
||
`_build_router_dynamic_members` runs slots in the tier's template order:
|
||
|
||
1. `anchor` — taken directly, no scoring.
|
||
2. Remaining proposer slots, in order — each selection is added to `selected`
|
||
and `selected_counts` before the next slot is scored, so later slots see
|
||
updated diversity/duplicate state.
|
||
3. The aggregator slot, scored last, against the same accumulated `selected`
|
||
state as the proposers (so it also gets a duplicate penalty if it repeats
|
||
a proposer's model).
|
||
|
||
## 2.7 Output
|
||
|
||
The function returns `(profile_name, proposers, aggregator, selection_plan)`:
|
||
|
||
- `profile_name` — `"router_dynamic/<tier>"`, e.g. `"router_dynamic/c2"`.
|
||
- `proposers` — one `EnsembleMemberConfig` per slot, labeled by slot name
|
||
(`anchor`, `cheap_contrast`, ...).
|
||
- `aggregator` — one `EnsembleMemberConfig`, labeled `aggregator`.
|
||
- `selection_plan` — a full trace for observability, including the resolved
|
||
tier/confidence, the anchor, the slot template, per-slot score breakdowns
|
||
(`_score_trace`, including the top-3 scored candidates per slot for
|
||
debugging near-misses), the aggregator's score breakdown, the full
|
||
candidate pool, and `duplicate_policy: "selected_penalty"`.
|
||
|
||
`build_ensemble_provider_from_config` (the public entrypoint) additionally
|
||
clamps `min_successful_proposers` down to `len(proposers)` if the configured
|
||
value exceeds how many proposer slots the tier's template actually produced
|
||
— e.g. configuring `min_successful_proposers=4` at tier `c0` (2 slots) yields
|
||
an effective minimum of 2. Both the configured and effective values are
|
||
recorded in `selection_plan` for debugging.
|
||
|
||
## 2.8 Configuration surface
|
||
|
||
```toml
|
||
[llm_ensemble]
|
||
enabled = true
|
||
selection_mode = "router_dynamic"
|
||
```
|
||
|
||
What operators can tune:
|
||
|
||
- `llm_ensemble.model_options` — extends the candidate pool beyond the router
|
||
anchor and configured router tiers.
|
||
- `llm_ensemble.min_successful_proposers` — desired minimum successful
|
||
proposers (clamped per-turn as described above).
|
||
- `squilla_router.tiers[*].model` — indirectly expands the candidate pool and
|
||
determines which model becomes the anchor for a given tier.
|
||
|
||
There is no operator control over slot templates, weights, or the model
|
||
catalog priors — those are fixed in code. Unlike the static families,
|
||
`router_dynamic` keeps the legacy runtime defaults (timeouts 3600s,
|
||
`shuffle_candidates=True`, `min_successful_proposers=1`).
|