Files
tatlock/docs/steward-routing-findings.md
T
jpmschweitzerandClaude 2290320e9c 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>
2026-08-08 18:01:11 +02:00

5.7 KiB
Raw Blame History

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.