docs: record the Steward routing and thinking findings

No change shipped. The Steward stays on gemma4:e2b with thinking left at
its default, and this records why so the experiment is not repeated on
the premise that started it.

That premise was wrong. The Steward appeared to pay ~300 tokens per turn
for reasoning that was generated and discarded, since no `thinking` field
comes back. The reasoning is emitted inline in the response instead, and
it is what produces a correct DELEGATE line — suppressing it costs 12.5
points of routing accuracy, entirely on multi-capability queries where
the model stops decomposing and names one capability.

e4b is disqualified by memory rather than quality: Ollama predicts
10.6 GiB for it against ~7.9 GiB available, so it evicts every
co-resident before loading, including nomic-embed-text. Lowering context
length does not rescue it — an 8x reduction moved the prediction only
1.1 GiB — and per-request num_ctx reloads the shared runner, dropping the
keep_alive pin and evicting nomic.

Also records that the two axes are independent: model choice governs
VRAM and co-residency, think setting governs tokens and latency and
costs nothing in VRAM.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:01:11 +02:00
co-authored by Claude
parent bf13f9f0de
commit 2290320e9c
+110
View File
@@ -0,0 +1,110 @@
# Steward Routing & Thinking — Findings
**Outcome: no change shipped.** The Steward stays on `gemma4:e2b` with model
thinking left at its default (on). Every alternative was measured and every one
loses. This document exists so the experiment is not repeated on the same
premise.
Run 2026-08-08 with `scripts/benchmark_routing.py` and
`scripts/fixtures/routing_fixtures.py` (40 labelled queries, one repeat per
cell, temperature 0.3 as production sends).
---
## The premise was wrong
The experiment was designed around an observation that the Steward pays ~300
tokens per turn for reasoning that is generated and thrown away: it calls
`/api/generate`, gemma4 reasons by default, and **no `thinking` field comes back
in the response**. Disabling thinking therefore looked close to free.
It is not. The reasoning is not discarded — it is emitted inline in `response`,
and it is what produces a correct `DELEGATE:` line. Those tokens are the work,
not waste. Suppressing them costs 12.5 points of routing accuracy.
## Results
| config | exact | under | over | tokens | latency | resident | predicted | co-resident with nomic |
|---|---|---|---|---|---|---|---|---|
| **e2b, thinking** *(production)* | **97.5%** | 2.5% | 0% | 361 | 5179 ms | 1778 MB | 7.8 GiB | yes |
| e2b, `think: false` | 85.0% | 12.5% | 5.0% | 48 | 1435 ms | 1778 MB | 7.8 GiB | yes |
| e4b, thinking | 100% | 0% | 0% | 192 | 4726 ms | 3089 MB | 10.6 GiB | **no** |
| e4b, `think: false` | 97.5% | 2.5% | 0% | 52 | 2269 ms | 3089 MB | 10.6 GiB | **no** |
`think: true` was also measured and landed within one fixture of the default on
both models, so production's implicit thinking is the same thing as asking for
it explicitly. Format compliance was 100% in every cell — a `DELEGATE:` line is
always emitted.
With 40 fixtures and one repeat, each result is worth 2.5 points, so the
97.5-vs-100 gaps are single fixtures and inside the noise. The latency and token
medians (40 calls each) and the e2b `think: false` degradation (6 failures with a
consistent mechanism) are the parts worth trusting.
## Why each alternative loses
**`think: false` on e2b** — 85% exact, and the failures are not random. All three
multi-capability fixtures under-route, each missing a second capability. Without
reasoning the model names one capability and stops decomposing. It is not
degraded across the board; it specifically stops handling compound requests,
which is where a user would most notice the Butler quietly doing half the job.
**e4b, either setting** — disqualified by memory, not by quality. Ollama predicts
**10.6 GiB** for it at 16k context. Maximum available on this card is ~7.9 GiB
(10.4 free 2.0 GPU overhead 0.46 minimum), so e4b *always* exceeds the budget
and evicts every co-resident before loading. Observed directly: loading it threw
out both `gemma4:e2b` and `nomic-embed-text`. Losing nomic means Tatlock memory
and library-desk thrash on every embedding call. Note this is not caused by the
2 GiB reservation — without it, available would be ~9.7 GiB, still under 10.6.
**Lower `OLLAMA_CONTEXT_LENGTH`** — the obvious way to free headroom, and it does
not work. Dropping 16384 → 2048, an 8× reduction, moved the prediction only from
7.8 to 6.7 GiB. The prediction is dominated by weights and batch size, not KV
cache. It would also truncate the Librarian's retrieved passages and webber's
code context for a 14% saving that funds nothing.
**Per-request `num_ctx`** — worse. A single request with a different `num_ctx`
reloads the shared runner, which **drops the `keep_alive: -1` pin** (expiry fell
from year-2318 to a 2-hour default) and evicts nomic. Three services share this
Ollama, so mixed context sizes are a thrash generator, and it fails silently.
**`OLLAMA_NUM_PARALLEL > 1`** — never viable here. e2b already predicts 7.8 GiB
against ~7.9 available, so there is no room for a second slot at any context
length. It is also set to 1 deliberately, to avoid batch overflow panics.
## What the two axes actually control
They do not interact, which is the useful part:
- **Model choice** governs VRAM and co-residency. e2b 1778 MB, e4b 3089 MB.
- **Think setting** governs tokens, latency and routing quality — and costs
**nothing** in VRAM. Verified: e2b is resident at 1778 MB with `think` unset,
true and false alike, because the KV cache is allocated for the full context at
load time and `think` is a per-request generation parameter.
So the only real question is whether 313 tokens and 3.7 seconds are worth 12.5
points of compound-query routing. On a turn that is already three sequential
Ollama calls, they are.
## Prerequisite: the extraction fix
These numbers are only meaningful because `_extract_capabilities` was fixed first
(commit `a905363`). It previously substring-matched capability *domains* across
the Steward's entire response, so ordinary English in the `REASON:` line selected
agents — "description" contains the housekeeper domain "script", "acknowledge"
contains "knowledge" and "know".
That made **prose length a routing input**. Benchmarking against it would have
shown `think: false` improving routing purely because shorter output produces
fewer accidental substring hits — a thinking policy derived from a parsing
artefact. The `adversarial` fixture group is regression coverage for exactly this.
## If this is revisited
The constraint is the single 11 GB card, not the model. A second inference host
(*forge*) removes it entirely, and e4b's 100% routing becomes reachable without
evicting anything. Re-run then; on this card the answer is settled.
`scripts/benchmark_routing.py` takes `--models`, `--think` and `--repeats`, and
restores GPU residency on exit — including on SIGTERM, which the first version
did not.