v3.12 lot 2 — make the router prompt a pure append - #18
Merged
Conversation
Qwen3's /think and /nothink soft switches were removed in Qwen3.5, which
is the model this router runs against. The token is inert: it buys no
behaviour change and occupies a position at the very front of every
router prompt -- the one place where a wasted token also sits in front
of the entire cacheable prefix.
It could not have done anything here regardless. The tool-conditioned
GBNF grammar constrains the first generated token to "{", so a
reasoning block was never reachable from this prompt in the first place.
The graph synthesis prompts (recall, review, research, sysadmin) keep
their own /no_think for now -- they are free-text prompts with no
grammar attached, and they are not on the router's hot path. Cleaning
those up is a separate change with its own testing.
The router prompt was laid out as:
[static template][history][step_context][closing instructions][User:]
so a fixed ~50-token block sat AFTER a section that grows every turn.
Each new turn therefore inserted text into the middle of the prompt
rather than extending its end, and llama-server cannot continue from
the live slot state across an insertion -- it rewinds to the last
recurrent-state checkpoint before the insertion point and replays from
there. Measured on this model: ~0.30 ms/token for a pure append,
~1.80 ms/token for an insertion one tenth of the way from the end, and
a full ~12 ms/token recompute once the insertion lands deeper than
checkpoint coverage.
The layout is now:
[static template + closing instructions + history header][history]
[step_context][User:]
Three separate blocks moved into the static prefix:
- the "respond with a single JSON object" instruction, reworded to
address the LAST "User:" line rather than "the message below", since
the whole conversation now sits between it and the message it means;
- the vague-file-reference instruction, which was the tail of
_format_history() and had the same insertion problem one level down;
- the history header, previously emitted by _format_history() only when
history was non-empty. A block that first appears on turn 2 is an
insertion in front of turn 1, i.e. one guaranteed cache miss per
conversation for a header worth a dozen tokens. It is unconditional
now.
The vague-file-reference instruction was gated on history being
non-empty; it is gated on the tool set instead. Per-turn gates are what
break the prefix, and history was never what made the instruction
meaningful -- the presence of files/review is. The tool set is fixed for
the lifetime of the process, so the block stays static, and the promise
made at the top of router/prompt.py still holds: a tool the operator did
not opt into via ENABLED_TOOLS is not named anywhere in the prompt.
Known and accepted exception: today_line() is in the static header, so
the whole prefix is invalidated once, at midnight. Moving it to the tail
would trade one daily full recompute for a rewind on every turn, which
is the worse deal. Worth knowing before reading a benchmark that
straddles midnight as a regression.
This commit alone does not make the prompt a pure append -- the live
turn and its own history rendering still differ. That is the next
commit.
Two tests updated rather than deleted: the history-block marker string,
and the test asserting the file instruction is absent without history,
now asserting it is gated on tools instead.
The remaining divergence between consecutive prompts. Turn N's message
appeared at the bottom of prompt N as
\nUser: <message>\n
and came back in prompt N+1, through memory.json, as
- they said: <message>
Different bytes for the same message means prompt N is not a prefix of
prompt N+1 no matter where the closing instructions sit: the common
prefix ends where the history block ends, and everything after it has to
be rewound and replayed.
Both renderings now go through a single render_user_turn(), so they
cannot drift apart in a future edit without the type checker or a test
noticing. The newlines are part of the contract -- a prompt is a byte
string, not a list of lines. orchestrator._finish() already persists the
user message verbatim (memory.add_exchange(user_input, output), the same
string handed to build_router_prompt), so no change was needed on the
persistence side; that was the one precondition that could have made
this impossible.
Assistant turns are deliberately NOT made symmetric. They only ever
appear in history, never as a live line, so their shape is unconstrained
-- and that freedom is worth keeping. The bullet format existed for a
real reason: a full 'User: ... / Assistant: ...' dialogue completes a
pattern that contradicts what the examples above it teach
("User: X" -> JSON), and a 9B follows the nearest surface pattern.
Rendering assistant turns as a parenthesised aside keeps that risk out
of the prompt: the only thing that ever follows a bare "User:" line
here is JSON. The GBNF grammar, which forces the first generated token
to be "{", is the structural backstop underneath.
parser.py: "they said:" is gone from _PROMPT_LEAK_MARKERS since the
string no longer exists in any prompt. "User:" is far too generic to
replace it with, so the history header goes in instead -- template-only
text that cannot plausibly show up in a real answer. "you answered:"
still holds and is untouched.
test_history_is_passed_as_context_not_dialogue asserted the old
invariant directly and is replaced by its inverse, exercised through the
real persistence path rather than a hand-built history list.
_MAX_HISTORY_ENTRY was 120 chars for every entry regardless of role. Applied to a user turn that is now rendered identically live and in history, that cap is a divergence generator: the live rendering is never truncated (the router has to see the whole message it is routing), so any message over 120 chars diverged from its own history rendering at character 120. A single multi-line question was enough to lose the pure-append property for the rest of the conversation. User entries get _MAX_USER_HISTORY_ENTRY (4000) instead. Assistant entries keep 120 -- they never appear as a live line, so nothing has to match them. The remaining divergence at 4000 is bounded and one-shot rather than fatal: the truncated form is stable in history from then on, so the turn after an over-cap message is a pure append again, and the one turn that does pay a rewind rewinds to the cap rather than to the start of the conversation. Truncating at ingestion would remove the divergence entirely and is deliberately not done. orchestrator.py:353 already records what that costs: capping what gets persisted silently corrupted the web UI, which renders memory.json directly through GET /history. The prompt's budget problem does not get solved in the user's transcript. 4000 is a per-entry cap, not a total. MEMORY_MAX_HISTORY entries at that size would not fit the context window; bounding the total is what the token-based compaction threshold is for, and is not this commit.
A regression here has no functional symptom whatsoever. Every prompt stays correct, every answer stays correct, every other test in the suite stays green, and the only evidence is that runs get slower -- which is exactly how the layout that this branch fixes survived unnoticed. The invariant has to be asserted directly or it will be re-broken by the first well-meant edit that appends a line to the end of the template. Eleven tests, covering: turn 2 extending turn 1; a ten-turn conversation where each turn extends the previous; the header being present from turn 1; the live turn and its history rendering being the same bytes; the assistant asymmetry; nothing being emitted after the live user line; and the static prefix being a function of the tool set alone. Two boundaries are pinned as bounded damage rather than as invariants, since neither can be made to hold: a message over the user cap diverges exactly at the cap and stops compounding on the next turn, and the step_context rewind is required to leave the persisted history intact rather than invalidating the conversation along with the run. Each test was checked against the defect it is meant to catch, by reintroducing the defect and confirming the failure: - a fixed block after the live user line - the old divergent bullet rendering of user turns - the history header emitted conditionally on history being non-empty - the user cap back at 120 The fourth one is why test_a_realistically_long_user_message_is_still_a _pure_append exists. The bounded-rewind test is written in terms of _MAX_USER_HISTORY_ENTRY, so it passes at any value including 120; only a test anchored to a concrete message of ordinary length -- a pasted traceback, a multi-line question -- actually holds the cap in place.
bench/router_ab.py is what produced the numbers in this branch's PR
description, and without it nobody -- including me in six months -- can
reproduce them. The four lot 1 probes (reuse_partiel, bisect_cache,
seuil_cache, mesure_prompt) were one-off measurements and stay out; this
one is a repeatable harness and earns a place in the repo.
It covers two failure modes the test suite structurally cannot. A
prompt-cache regression has no functional symptom whatsoever: every
prompt stays correct, every answer stays correct, and the only evidence
is that runs get slower. A routing regression is masked by the GBNF
grammar, which guarantees the output shape whatever tool the model
picks, so "did it emit valid JSON" comes back green either way.
Three measurements kept deliberately separate, because they answer
different questions and fail differently:
prefix characters diverging between consecutive prompts. Pure string
arithmetic, no server, deterministic -- the one to trust when
the other two disagree.
bench prompt-processing time over a growing conversation, from
llama-server's own timings.
routing which tool gets picked, over 29 fixtures grouped by the risk
they probe rather than by tool: answer-the-last-message with
a distractor earlier in the history, vague file references
resolved from a path mentioned turns ago, step_context
provenance, dialogue-continuation.
The two arms of a comparison are two checkouts; the harness never
rebuilds the old prompt itself. Two guards come from getting this wrong
while writing it: the import bootstrap prefers a checkout's src/ over an
installed forge (importing the installed package measures the same code
twice and shows up as a suspiciously perfect 100% agreement), and the
run refuses to start on a fallback tool set, since ENABLED_TOOLS decides
what the prompt contains and an A/B across two tool sets compares two
prompts rather than two layouts.
.gitignore: /bench/*.json. Result files hold the model's full output for
every fixture -- worth keeping locally to read by hand, worth nothing in
the history.
README:
- new section documenting the harness, including the point that
agreement is the signal rather than the pass counts (on 29 fixtures a
one- or two-fixture difference is noise);
- v3.12 added to the roadmap, marked in progress since lot 3 is still
open;
- the CI section listed 'ruff check .' and 'pytest tests/ -v'. The
workflow actually runs three commands, and 'ruff format --check .' is
a separate gate that fails on formatting alone -- this branch had a
patch that passed 'ruff check' and would have failed CI on it. The
workflow also runs 'pytest -v', not 'pytest tests/ -v'. Both aligned
with .github/workflows/ci.yml.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch:
v3.12-pure-append(base:main, after PR #17 / lot 1)5 patches · 588 tests green (baseline 577) ·
ruff checkandruff format --checkclean on every commit individuallyMeasured on the target box (Steam Deck → NiPoGi, llama.cpp server, 13 tools loaded).
Why
Lot 1's instrumentation, plus the
LLAMA_CPP_CACHE_PROMPTfix, took a routingrun from ~52 s to ~11.5 s. The remaining ~8.5 s was prompt processing: the
whole prompt was being re-evaluated on every call even though only ~25 tokens
of it changed since the previous one.
Bisecting the cache behaviour with
reuse_partiel.pyproduced a cliff ratherthan a curve:
The model that fits: a pure append continues from the live slot state and
costs almost nothing. Any insertion further up requires a rewind — restoring
the recurrent state from its last checkpoint and replaying forward — and past a
certain depth no checkpoint remains, so the whole prompt is recomputed.
The server-side lever is exhausted.
-ctxcp 16 -cms 128were confirmed appliedand had no effect: in llama-server's help
--ctx-checkpointsis an alias of--swa-checkpoints, so it governs sliding window attention, and this model hasno SWA layers.
-c 16384 -np 1moved nothing either (~5%, inside the noise),though it is worth keeping for the window headroom.
That leaves the application-side lever, which is this branch.
What was actually in the way
The prompt was laid out as:
Two things broke the append property, and only one of them was the closing
instructions.
Closing instructions after a growing block. A fixed ~50-token block sat
after a section that grows every turn, so each turn inserted text into the
middle of the prompt rather than extending its end.
The live turn and its own history rendering were different bytes. Turn N's
message appeared at the bottom of prompt N as
\nUser: <message>\nand cameback in prompt N+1, through
memory.json, as- they said: <message>. Movingthe closing instructions alone would not have helped: the common prefix would
still have ended where the history block ended.
Three smaller ones behind those:
_format_history(),so every new turn was inserted in front of it — the same defect one level
down, invisible from the template;
first appeared on turn 2 — an insertion in front of turn 1, i.e. one
guaranteed cache miss per conversation;
_MAX_HISTORY_ENTRYtruncated user entries at 120 chars while the liveline is never truncated, so any multi-line question diverged from its own
history rendering at character 120.
What changed
The layout is now:
chore(router)— drop the dead/no_thinktoken. Qwen3's soft switch wasremoved in Qwen3.5, and the GBNF grammar forces the first generated token to
be
{anyway, so no reasoning block was ever reachable. It sat in front ofthe entire cacheable prefix.
refactor(router)— hoist the closing instructions, the vague-file-referenceinstruction and the history header into the static block. The first two are
reworded to address the last
User:line, since the whole conversation nowsits between them and the message they refer to. The file instruction is
gated on the tool set rather than on history: a per-turn gate is what
breaks the prefix, and the presence of
files/reviewis what actuallymakes it meaningful. The tool set is fixed for the lifetime of the process,
so the block stays static, and a tool not opted into via
ENABLED_TOOLSisstill never named in the prompt.
fix(router)— both renderings of a user message go through a singlerender_user_turn(), newlines included, so they cannot drift apart in afuture edit. No change was needed on the persistence side:
memory.add_exchange(user_input, output)already stores the messageverbatim, which was the one precondition that could have made this
impossible.
fix(router)—_MAX_USER_HISTORY_ENTRY(4000) for user entries; assistantentries keep 120.
test(router)— eleven tests pinning the invariant.Results
Prefix (string-level, deterministic)
Divergent tail between consecutive prompts, 12-turn conversation:
The tail was a constant ~573 chars regardless of conversation length.
Prompt processing
ms_per_tokenon a growing 12-turn conversation, from llama-server's owntimings:
3.9× faster. In absolute terms on a ~2700-token prompt: ~8.4 s → ~2.2 s of
prompt processing per routing call, which accounts for most of the ~8.5 s
residual lot 1 left behind.
Two caveats worth recording rather than burying.
mainmeasured 3.12, not the ~1.80 the bisect table predicted for an insertionthis close to the end. The real-world cost of an insertion is higher than the
synthetic curve suggests; that table understates it.
And
ms_per_tokenhas become a misleading metric on this branch.prompt_ncounts all ~2700 tokens even though only ~25 are new, so it mixes two
quantities. After this change, absolute
prompt_msis the number to watch.That also explains why repeated runs of the same arm disagree (0.62, 0.81, 0.80
medians across three runs): the residual absolute cost is not yet pinned down,
which is lot 3's problem, not this branch's.
Routing accuracy
The measurement that can regress silently. The GBNF grammar guarantees the
output shape, so "did it emit valid JSON" comes back green whatever happens
and is not evidence of anything.
29 fixtures, run through the real
build_router_prompt→call_llm→parse_router_outputpath with productionENABLED_TOOLS:Agreement 25/29 (86%). Four decisions changed, no regressions:
a03list files in a directoryshell(fail)files(pass)b02script request after a files turnchat(fail)code(pass)c02"analyse le contenu", path only in historychat(fail)review(pass)c05control, no real path anywherefiles(manual)review(manual)c02is the result that matters. It is the vague-file-reference fixture: thepath exists only in an earlier turn, and the instruction that resolves it moved
from directly adjacent to the message to roughly 3000 tokens above it. It not
only still works, it now works where it previously failed — plausibly because
the rewording targets the last
User:line explicitly.b02is a "answer the last message" case, which is what the reorderingtargets, so the mechanism is clear there too.
a03I cannot explain beyond"the history block reads more clearly"; it is credited to luck until reproduced.
Order-independence check. The fixture set was re-run in reverse order
against the same arm: 29/29 agreement, bench median 0.80 vs 0.81. The four
changes above are attributable to the prompt, not to slot state or fixture
ordering.
Two pre-existing failures, not regressions
Both fail identically on
mainand on this branch. Separate issues, notblockers here.
c03— with two file paths in history, "relis ce fichier" resolves tothe older one instead of the most recently mentioned. The instruction says
"most recently mentioned"; the model does not honour the ordering.
e02— with a[web_search]result already instep_context, therouter calls
web_searchagain instead of answering from it. A loop risk.Two findings from the unscored fixtures
c05andc05's siblingf01have no correct automatic answer and arerecorded for reading by hand. Both surface the same gap:
c05(no real path anywhere in the conversation) →reviewwith{"file_path": "src/forge/main.py"}. The path is invented. Theinstruction forbids inventing file content — "Do NOT invent file content
from memory" — but says nothing about inventing a path when none exists.
f01→reviewwith{"file_path": "<same path as above>"}, a literalplaceholder emitted as if it were a path.
Neither is caused by this branch (
c05also routed to a file tool onmain),and
f01did not follow the injected instruction in itsstep_context—it stayed on
reviewrather thanshell, which is the one point on which thestep_contextpositioning argument is positively confirmed. But theinstruction has a hole worth closing in a follow-up: "if no real path is
present, ask rather than guess".
Deliberate asymmetry
Only the user half of a turn is rendered symmetrically. Assistant turns
render as
(you answered: ...), notAssistant: ....The bullet-summary format existed for a real reason, recorded in the code: a
full
User: ... / Assistant: ...dialogue completes a pattern that contradictswhat the examples above teach (
User: X→ JSON), and a 9B follows the nearestsurface pattern. An assistant turn only ever appears in history, never as a
live line, so nothing constrains its shape — and spending that freedom here
means the only thing that ever follows a bare
User:line in this prompt isJSON. The GBNF grammar is the structural backstop underneath.
Known limits, accepted rather than fixed
today_line()is in the static header, so the entire prefix isinvalidated once, at midnight. Moving it to the tail would trade one daily
full recompute for a rewind on every turn. Worth knowing before reading a
benchmark that straddles midnight as a regression.
step_contextis this run's own tool output and is never persisted, sothe first call of the turn after a multi-step run cannot extend the last call
of that run. Since
step_contextsits just before the live user line, therewind covers both. Moving it after the user line would buy those tokens back
and is deliberately not done: it would put untrusted tool output in the last
position before generation, the strongest position in the prompt and exactly
what the E-2 provenance markers defend against. A test asserts the rewind
stays a tail and leaves the persisted history intact.
the truncated form is stable from then on, so the next turn is a pure append
again. Truncating at ingestion would remove it entirely and is not done —
orchestrator.py:353records that capping what gets persisted silentlycorrupted the web UI, which renders
memory.jsonthroughGET /history.everything.
compaction.pyalready documents this. The consequence for thisbranch: eviction must happen in large batches, never one turn at a time, or
every turn past the threshold is a full recompute and the whole gain
evaporates.
Testing
test_router_pure_append.py— eleven tests: turn 2 extending turn 1; aten-turn conversation where each turn extends the previous; the header present
from turn 1; live and history renderings being the same bytes; the assistant
asymmetry; nothing emitted after the live user line; the static prefix being a
function of the tool set alone. Two boundaries are pinned as bounded damage
rather than as invariants, since neither can be made to hold: the over-cap
message and the
step_contextrewind.A regression here has no functional symptom — every prompt stays correct, every
answer stays correct, and the only evidence is that runs get slower. That is
exactly how the old layout survived unnoticed, so each test was checked against
the defect it is meant to catch, by reintroducing the defect and confirming the
failure:
The bounded-rewind test is written in terms of
_MAX_USER_HISTORY_ENTRY, so itpasses at any value including 120. Only a test anchored to a concrete message
of ordinary length — a pasted traceback, a multi-line question — actually holds
the cap in place, which is why
test_a_realistically_long_user_message_is_still_a_pure_appendexists.Three existing tests were updated rather than deleted, since they asserted
invariants this branch deliberately inverts: the history-block marker string,
the file instruction being absent without history (now gated on tools), and
test_history_is_passed_as_context_not_dialogue, replaced by its inverse andexercised through the real persistence path rather than a hand-built history
list.
Two fixtures in the A/B harness were wrong on the first pass and were fixed
before the numbers above were taken.
d01-d03asserted that the answercontent contained the remembered subject, which can only pass if the model
picks
chat— routing torecallputs a query in the content and the answerarrives from the tool afterwards, so a correct route was being scored as a
failure.
b04scoredresearchas a failure on a question about quantization,which is a defensible reading of that question rather than evidence the model
answered an earlier turn; the fixture now ends on a message that operates on
the conversation itself, so only
chatis defensible.