Files
nisse/docs/Tuning-Guide.md
T
dongarandClaude Opus 5.5 494715efa9
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 55s
Fit every model request to its context window
Every turn re-sent the whole transcript with nothing checking it against
the model's window. On a local model that fails silently: Ollama drops
the start of an over-long prompt, which is where the system prompt and
the plan are. Measured on the Jetson's llama3.2, cut at 4096 tokens with
no error.

- The model catalog reads max_input_tokens and max_output_tokens from
  LiteLLM's model_info, and startup refuses a role on a model with no
  declared window
- BudgetedClient wraps both routes. It fits the canonical events before
  rendering: older tool results cut to head and tail, then the oldest
  turns dropped with a note, keeping calls with their results; a request
  that still cannot fit fails with ContextOverflow and is never sent.
  The output ceiling is the smaller of config and the model's limit
- Tokens are estimated from characters, pessimistically, and the
  estimate is corrected from reported prompt sizes, only ever upward
- The executor takes its scope from the log: the latest plan, which is
  the approved milestone plan, goes in its system prompt, and it sees
  only events after it
- Tool output keeps its head and, mostly, its tail, so a test runner's
  failure summary survives truncation
- tuning.context.keep_recent_turns and safety_margin are new knobs
- scripts/probe_context.py checks a declared window against what the
  server does; it confirmed llama3.2 cuts at the declared 4096
- The tuning guide covers the budget, declaring a local model's limits,
  and the probe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 00:26:30 +00:00

377 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tuning Guide
How to fit a local model onto the inference host, and how to set the agent's
tuning knobs. Part 1 covers the ideas you need to reason about the hardware.
Part 2 covers each knob in `nisse-dev.yaml`. Part 3 gives an order to tune
in.
The knobs live under `tuning:` in the config file (see
`nisse-dev.example.yaml`). They are read at startup, so a change takes effect
on the next restart. Unknown or misspelt keys are startup errors.
---
## Part 1 — How a local model spends VRAM
A model server holding a model in GPU memory spends it on three things:
1. **Weights**, which are fixed for a given model and quantization.
2. **The KV cache**, which grows with context length.
3. **Working memory** for activations and runtime buffers. Budget 1–2 GB of
headroom.
On a 24 GB card, the whole game is trading (1) against (2).
### Weights and quantization
A model's weights take roughly *parameters × bits per weight ÷ 8* bytes.
| | 16-bit (full) | 8-bit | ~4.5-bit (Q4_K_M, AWQ) |
|---|---|---|---|
| 24B | 48 GB | 24 GB | ~14 GB |
| 32B | 64 GB | 32 GB | ~19 GB |
Quantization stores each weight in fewer bits. Common 4-bit formats come out
nearer 4.5–5 bits per weight once scaling factors are counted, so check the
actual file size. The quality cost is small at 5–6 bits, usually acceptable at
4, and it climbs quickly below that. For agent work the cost shows up first as
**worse tool-call discipline**, not as worse prose. The capability matrix
(Part 3) measures exactly that.
**Mixture-of-experts** models such as Qwen3-30B-A3B are different. Memory is
set by *total* parameters (30B), but speed is set by the parameters *active*
for each token (3B). An MoE model of a given size therefore generates much
faster than a dense model of the same size. It's often weaker than a dense
model of the same total size, but not always for tool use.
### The KV cache, and why context costs memory
As the model reads the prompt, every layer stores a key and a value vector for
every token, so that later tokens can attend to them without recomputing.
That store is the KV cache. Its size per token is:
2 (key and value) × layers × KV heads × head size × bytes per value
The numbers come from the model's `config.json` (`num_hidden_layers`,
`num_key_value_heads`, `head_dim`). Some worked examples at 16-bit:
| Model | Layers × KV heads × head size | Per token | 32k tokens |
|---|---|---|---|
| Qwen3-32B | 64 × 8 × 128 | 256 KB | ~8 GB |
| Mistral/Devstral Small 24B | 40 × 8 × 128 | 160 KB | ~5 GB |
| Qwen3-30B-A3B (MoE) | 48 × 4 × 128 | 96 KB | ~3 GB |
(Taken from the published configs; recheck against the model card before
relying on them.)
Two consequences:
- **Context length is a memory decision, not a free setting.** The spec's
estimate (Part H) was about 5 GB left for KV cache beside a 4-bit 32B model.
At 16-bit, that's only about 20k tokens of context for Qwen3-32B.
- **KV-cache quantization** stores keys and values in 8 bits (or 4). At 8
bits it halves the cache and is close to lossless. At 4 bits it is
noticeably worse. Quantizing the cache to 8-bit is usually the first lever
to pull, since it buys twice the context for almost nothing.
The server allocates the cache for the context length you configure. It
doesn't matter how much of it a request uses. So set the context length on the
server deliberately: it is the ceiling for *prompt plus response* on every
request.
### Prompt processing vs generation
A request has two phases, and they run at very different speeds:
- **Prefill** reads the prompt. It is parallel and fast: thousands of tokens
per second on a 4090.
- **Decode** writes the response one token at a time. Roughly 30–40 tokens per
second for a dense 32B at 4-bit, and several times that for a 3B-active MoE.
Treat these numbers as ballpark; measure your own.
An agent loop re-sends the whole transcript on every turn, which makes the
prompt long and the responses short (a tool call is often under 200 tokens).
So prefill matters more than the headline tokens-per-second suggests.
**Prefix caching** is the fix. If the start of a prompt matches the previous
request, the server reuses the KV cache for it and prefills only the new part.
llama.cpp does this per slot, and vLLM does it automatically. For this
workload it is often the single largest speed win. It works here because the
rendered transcript only grows at the end, so consecutive turns share
everything but their newest events.
### Tokens and characters
For English and code, a token is roughly **4 characters**. That's useful for
converting the character-based knobs below into context cost.
---
## Part 2 — The knobs
For each knob: what it controls, what goes wrong at either extreme, and what
to look at when deciding.
### `llm.max_output_tokens` — default 16384
The ceiling on a single response, for every role. The ceiling actually sent
is the smaller of this and the model's own `max_output_tokens` in LiteLLM, so
a local model can be held to less without affecting the frontier roles.
- **Why it exists:** a response that hits the ceiling is truncated
mid-sentence, or worse, mid-tool-call, where it can still parse. The agent
detects truncation and treats it as an error, but a ceiling that's too
tight still costs a failed turn. Planning with a frontier model was observed
using over 4,000 output tokens.
- **Too high, on a local model:** the server reserves room for the whole
response inside the context window. With a 32k context and a 16k ceiling,
only 16k is left for the prompt. Some servers reject any request where
prompt plus ceiling exceeds the context.
- **Too low:** truncated plans and tool calls.
- **Deciding:** executor turns are short, so 2–4k is plenty for a local
executor. Frontier planning wants the large default. Leave this high, and
set a local model's smaller ceiling as its `max_output_tokens` in LiteLLM.
### `tuning.executor.max_turns` — default 24
How many model calls one milestone may make before the executor stops with
`capped` and asks you what to do.
- **Too low:** milestones that were making progress stop short. You'll see
`capped` outcomes on work that was going well.
- **Too high:** a confused model keeps going. Every turn also adds its tool
calls and results to the transcript, so a long run spends context as well
as time.
- **Deciding:** look at how many turns *successful* milestones take, and set
the cap comfortably above the high end, around 1.5–2× the turns of your
longer successful runs. A stronger model usually needs fewer turns, not
more.
### `tuning.executor.failure_limit` — default 2
How many consecutive failed `run_command` calls trigger escalation: the
sub-task is handed to a frontier model as a delegation (spec A12). A
successful command resets the count. Failed file reads don't count, since
reading is looking around, not doing the work.
- **Why it's a count and not a judgment:** small models are poorly calibrated
about their own competence. Asked "are you stuck?", they say no until far
too late, or yes constantly. A count of observable failures is a signal
that doesn't depend on the model's opinion of itself.
- **Too low:** normal iteration escalates. Test-first work *expects* a failing
test run before the fix, so a limit of 1 hands off routine work and spends
frontier tokens on it.
- **Too high:** a weak model circles, re-running the same failing command
with small variations, while the transcript fills up.
- **Deciding:** raise it as local models get stronger. Watch two things
together. First, how often delegations fix what the local model couldn't
(escalation is doing its job). Second, how often the local model recovers
on its own after its second failure (the limit may be too low).
### `tuning.expert.consult_budget` — default 3
How many `ask_expert` calls one milestone may make. `ask_expert` is a single
stateless question to a stronger model, with no access to the repository
(spec A14), for things like "is this API deprecated?".
- **Too low:** the executor can't get unstuck on a knowledge question it
can't answer from the code.
- **Too high:** a confused executor asks variations of the same question
instead of doing the work or failing. That's the circling A12 warns about.
- **Deciding:** count consults per milestone, and check whether the answers
changed what the executor did next. If it regularly exhausts the budget
without progress, the budget is fine and the model or the plan is the
problem.
### `tuning.delegation` — hard caps on one delegation
A delegation is a frontier model working a sub-task with full tool access
(spec A13). When a cap is hit, the delegation **fails**, and its branch and
work are discarded, because resuming on a half-edited tree isn't safe.
| Knob | Default | Caps |
|---|---|---|
| `max_turns` | 30 | model calls |
| `max_tokens` | 200000 | input + output tokens, as reported by the provider |
| `wall_clock_s` | 900 | elapsed time |
- **`max_tokens` is a scope limit, not the money limit.** Spending limits live
on the LiteLLM key (spec A5). This cap stops one delegation from growing
without bound. An unbounded one could still exhaust a daily budget and take
every frontier model away until it resets.
- **Too low:** delegations fail just short of finishing and their work is
thrown away. That is the most expensive way to be wrong, because you paid
for the work.
- **Too high:** a stuck delegation burns tokens and time before failing.
- **Deciding:** every delegation records the tokens it used and how long it
ran (the `delegation_completed` event, captured for evaluation per spec
A16). Set each cap above what successful delegations actually use, with
room to spare.
### `tuning.tools.command_timeout_s` — default 120
How long one `run_command` may run before its whole process group is killed.
- **Too low:** slow test suites, dependency installs and Docker builds get
killed and reported as failures. That also counts toward `failure_limit`.
- **Too high:** a command that never exits blocks the turn for the whole
timeout, such as a dev server started in the foreground or a test waiting on
input. `stop` waits for a running command to finish too, so this is also the
worst-case delay before a stop takes effect.
- **Deciding:** set it just above the slowest legitimate command in the
projects the agent works on.
### `tuning.tools.max_output_chars` — default 4000
How much of a tool's output (command output, file contents, directory
listings) the model sees. Longer output keeps its first quarter and last three
quarters, with a note in between saying how much was left out. The end gets
the larger share because test runners and compilers print their summary of
what failed last; the start keeps the exit code.
- **This is a context knob.** Every tool result stays in the transcript and is
re-sent every turn. 4,000 characters is about 1,000 tokens, so 20 turns of
maximal output is about 20k tokens, which is already most of a local
model's context.
- **Too low:** the model doesn't see the part of the output that matters.
- **Too high:** the transcript outgrows the context window sooner. The
context budget copes (see below), but by cutting older output harder.
### `tuning.context` — fitting each request into the window
Before any request is sent, the agent checks it against the model's context
window and makes it fit. A server doesn't always refuse a prompt that's too
long. Ollama silently drops its start, which is where the system prompt and
the plan are, so an unfitted request can fail in a way nobody sees.
The window is each model's `max_input_tokens` in LiteLLM (see *Declaring a
local model's limits* below). A model in use without one stops the agent at
startup. From the window come the output ceiling and a safety margin, and
the rest is for the prompt. When the prompt is too big, three steps apply in
order, stopping at the first that fits:
1. **Older tool results are cut** to about 600 characters, head and tail.
The most recent turns keep their output whole.
2. **The oldest turns are dropped,** tool calls together with their results.
A note at the start tells the model how many events were left out. The
log keeps everything; only the request is shortened.
3. **If the system prompt and the recent turns still don't fit,** the
request is not sent. The milestone stops and asks you.
Tokens are estimated from characters (pessimistically, at 3 per token) and
the estimate is corrected from the prompt sizes the server reports. The
correction only ever makes the estimate larger.
The executor also sends less in the first place: the approved milestone plan
goes in its system prompt, which is never cut, and it sees only what happened
since that plan. The planning conversation and earlier milestones stay in the
log.
| Knob | Default | |
|---|---|---|
| `keep_recent_turns` | 6 | model turns that are never cut or dropped |
| `safety_margin` | 0.1 | fraction of the window held back for estimation error |
- **`keep_recent_turns` too low:** the model loses sight of what it just did
and repeats itself. **Too high:** less room to cut before step 3 gives up;
on a small window, overflows come sooner.
- **`safety_margin` too low:** an underestimate reaches the server, and a
silently cutting server drops the start of the prompt. **Too high:** wasted
context. Lower it only once the correction from reported usage has settled,
which takes a few calls per model.
- **Deciding:** if milestones stop with "cannot fit", look first at
`tools.max_output_chars` and the model's window, not at these.
### Declaring a local model's limits
LiteLLM knows commercial models' limits. For a local model, declare them in
LiteLLM's config as the model's `model_info`:
```yaml
model_list:
- model_name: local-fast
litellm_params: {model: openai/qwen3-8b-awq, api_base: http://inference-host:8080/v1}
model_info:
max_input_tokens: 32768 # the server's per-request context
max_output_tokens: 4096 # the most one response may use
```
`max_input_tokens` is the *whole* window, prompt plus response. The agent
takes the output ceiling out of it. That's how the commercial numbers work
too: Claude's prompt and response together must fit in its 200k.
The number to use is what the **server** allocates per request, not the
model's advertised maximum:
- **The model's ceiling:** `max_position_embeddings` in `config.json`, or
`<arch>.context_length` in a GGUF file. Some models reach further with RoPE
scaling; the model card says whether that needs switching on.
- **llama.cpp / llama-swap:** the `-c` / `--ctx-size` flag, shown as `n_ctx`
in the load log and in `GET /props`. With `--parallel N` each request gets
`n_ctx / N`, so run the agent's models with `--parallel 1`.
- **vLLM:** `--max-model-len`, reported by `GET /v1/models`.
- **Ollama:** 4096 unless `OLLAMA_CONTEXT_LENGTH` or a Modelfile's `num_ctx`
says otherwise. `GET /api/ps` shows the loaded model's `context_length`;
`ollama show` shows only the trained maximum.
Then check the declaration against the server:
uv run python scripts/probe_context.py local-fast
It sends one prompt half again as long as the declared window and reports
whether the server refused it, cut it (and where), or took it all. A cut
below the declaration is the case to fix: prompts between the two would be
silently cut. Measured on the Jetson's `llama3.2`: cut at 4096, matching its
declaration.
---
## Part 3 — An order to tune in
Upstream choices change what's right downstream, so tune in this order and
change one thing at a time.
1. **Model and quantization.** Start with whether a candidate holds the
tool-call contract at all:
uv run pytest -m integration tests/integration/test_model_capability.py \
--capability-models=alias-a,alias-b
A model that falls back to prose instead of calling tools is disqualified,
however good its benchmarks look. Before comparing aliases, check what
each one actually points at in LiteLLM (`/model/info`, the
`litellm_params.model` field). Two aliases believed to be the same model
once weren't, and that cost a lot of debugging.
2. **Context length and KV-cache precision** on the server. Take the largest
context that fits beside the weights with 1–2 GB of headroom. Try an 8-bit
KV cache before giving up context.
3. **Declare the window** in LiteLLM and check it with the probe, then set
the model's `max_output_tokens` there so the prompt keeps most of the
window.
4. **Real milestones.** Run representative work and look at the evidence:
turns used, escalations, delegation outcomes, and time taken.
5. **The loop knobs**: `failure_limit`, `max_turns`, `consult_budget`, the
delegation caps, then the tool limits, one at a time.
**Expect noise.** Local model behaviour varies a lot from run to run. One
early comparison measured 12 of 20 runs holding the tool contract against 20
of 20, a real difference (p ≈ 0.003). Three runs each could easily have
pointed the other way. Before changing a knob because of a result, repeat the
run enough times that the result would survive a coin flip.
---
## Coming next
The next changes in the 4090-readiness work add knobs, and this guide gains a
section for each:
- **Compaction.** A model-written summary replacing old turns, instead of
dropping them. The log already has an event for it; it needs a summarizing
model and a policy for what is safe to summarize.
- **Enforced tool calls.** An explicit `finish` tool, so the server can be told
a tool call is required and hold the model to the contract, where today it
can fall back to prose.
- **A benchmark script** that runs the capability matrix and representative
milestones across candidate models and prints a comparison table.