CI / checks (push) Skipped
CI / checks (pull_request) Successful in 55s
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>
377 lines
18 KiB
Markdown
377 lines
18 KiB
Markdown
# 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.
|