Repository navigation
fix(server): each turn reuses the previous one, long prompts share time, late system messages work - #835
Conversation
…t at once Three things kept conversations from reusing their own prefix. - qwen4exp spaces its restore points 4096 tokens apart (#828), and a capture moved down to the deepest spaced point. A chat shorter than that never cached past its system prompt, an agent re-read up to 4K tokens on every turn, and a follow-up question on a 130K-token document re-read all of it (103 s). A backend with spaced restore points now saves the end of the prompt's last message, the point the next turn extends; the extra cut costs one forward over the generation prompt. Other cuts still move down to a spaced point. - The prefix cache held one capture reservation at a time, so the requests prefilling beside a capturing one saved nothing. Each prefilling request can now hold one: a capture in flight keeps its slot and its bytes out of the other selections, and the same prefix is not captured twice. - Concurrent serving never pruned the snapshots a new one supersedes. It now prunes them as each capture lands, as single-sequence serving does, and frees their payloads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While one request prefilled a 130K-token document, a person chatting beside it got one token per 4096-token chunk: a 300-token reply took about 200 s. - StepPlanLimits::prefill_time_share: while requests decode, a prompt longer than one step's chunk takes at most that share of the time; after each of its chunks the decoding requests step alone for the rest. qwen4exp sets 0.5, since a chunk pays the whole expert read and a shorter one would not let them run faster. A prompt that fits in one chunk (an agent's next turn) is not held back. - A prompt that fits in one step runs before longer ones, alone, so a question asked beside a long document waits for one short forward instead of the document's next chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut blank lines - Claude Code 2.1 sends its environment as a system message after the user's first one. Qwen3.8-Flash-Next's template raises on a system message that is not first, so every Claude Code request failed with HTTP 500 and was retried 10 times. A template now gets a late system or developer message as a user turn, in place, so the prompt before it stays the same and cached. - A template that rejects the messages answers 400, which clients do not retry like a server failure. - Claude Code asks for thinking type "adaptive", which turned thinking off. It now means the model's default, as when the field is absent. - A reply no longer starts with the newlines a template puts after </think>; apps showed them as blank lines on top. Spaces stay, since they can indent the first line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex showed 0 cached input tokens for every turn. Chat Completions usage now carries prompt_tokens_details.cached_tokens and Responses usage input_tokens_details.cached_tokens, where OpenAI's clients read them, from the same count as usage.timings.cached_prefix_tokens. Anthropic usage is unchanged: its input_tokens and cache fields add up to the context, which Claude Code would count twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
davide221
left a comment
There was a problem hiding this comment.
Code review
Found 1 issue:
- Every non-first system/developer message is silently demoted to a user turn for all Jinja templates · likely (
server/src/server/chat_template.cpp:1084-1086)
Reviewed 980e3f43 · 1 candidate issue checked, kept at ≥70% confidence · add the code-review label to review again
| const bool late_system = !messages_j.empty() && | ||
| (m.role == "system" || m.role == "developer"); | ||
| mj["role"] = late_system ? std::string("user") : m.role; |
There was a problem hiding this comment.
Every non-first system/developer message is silently demoted to a user turn for all Jinja templates · likely
The change rewrites m.role to "user" whenever a system or developer message appears after the first message, for every Jinja template, not only for templates that reject late system messages. Most ChatML-style templates (including Qwen's) accept a system message at any position and render it as a system block. A normal multi-system conversation — e.g. OpenWebUI's per-chat notes appended after the main system prompt, or an app injecting a mid-conversation system instruction — now has those instructions rendered as user text, changing model behaviour, and it also changes the rendered prompt for conversations whose prefix is already cached, silently invalidating existing prefix-cache entries for that turn. A fix that only rewrites the role when the template actually cannot take a late system message (or only when the first render raises) would keep Claude Code working without altering prompts for templates that support it.
Checked (80%): chat_template.cpp:1084-1086 unconditionally rewrites any non-first system/developer message to role "user" for every Jinja template, with no check of whether the template actually rejects late system messages. Mid-conversation system messages are a normal input (the PR itself cites Claude Code doing this), so templates that previously rendered them as system blocks now render them as user text, a real behavior change introduced by these changed lines; the only mitigating uncertainty is how harmful the demotion is in practice.
The Claude Code fix rendered every system or developer message after the first as a user turn, for every Jinja template. A template that accepts system messages anywhere now gets them as they are; only when the template rejects the messages and a late system message is among them is it rendered again with those as user turns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t for edits Only Qwen3.8-Flash-Next saved a turn at the end of its last message. Every other model saved before it, so a follow-up question re-read the message it followed: Qwen 3.8 27B read a 118K-token document again in 532 s, and now restores it and answers in 4 s. - Every backend saves the prompt up to the end of its last message, the point the next turn extends. A conversation's first turn still saves its system head, so new conversations on the same system prompt restore it, unless the head is short beside a long message (as tool requests do). - A turn's new snapshot keeps the previous one while the resident budget holds it: a person who edits their last message resumes there. Eviction drops these kept turns before any conversation's newest one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paged attention split the context into one partition per 1024 tokens whatever the batch, and each partition keeps partials for every output row. A 2048-row prompt chunk 118K tokens deep asked for more memory than the R9700 had left beside Qwen 3.8 27B's weights and KV cache, and the server aborted: a long document sent to Qwen 3.8 27B on its own crashed the engine 79 s in. The partials now take at most 256 MiB. A chunk with that many rows covers the context with fewer, longer partitions, its rows giving it the parallelism: the same document now prefills in 524 s, as in 512-row chunks (530 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A step that carries a prompt chunk decodes without the DFlash drafter, one token per request. Beside a 118K-token document, a person chatting with Qwen 3.8 27B got 0.6 tok/s for the 9 minutes the document took. - While requests decode, a prompt of more than 8192 new tokens takes half the time (StepPlanLimits::prefill_time_share, as Qwen3.8-Flash-Next does), and the steps between its chunks decode with the drafter: the person gets 46 to 57 tok/s, and the document answers after 543 s instead of 530 s. - A prompt is long by the new tokens it brought (StepPlanLimits::time_share_prompt_tokens; by default more than one step's chunk), so it shares the time from its first chunk to its last. An agent's next step stays under 8192 and keeps every step: four agents at once finish in 117 s, against 153 s when their steps shared the time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing requests A prompt beside decoding requests shared the time while more than one granule (4096 tokens) of it was left, so an agent's next step after a large tool result waited for the others' replies. It now shares the time only when it brings more than 8192 new tokens, as on Qwen 3.8 27B: a document, not an agent's step. Four agents at once on Qwen3.8-Flash-Next: time to first token 3.29 s mean and 5.95 s at most, against 3.79 s and 11.37 s; the person chatting beside a 130K-token document still decodes at 19 tok/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ault The disk session cache is on by default (~/.cache/lucebox/kv-cache, or $XDG_CACHE_HOME/lucebox/kv-cache; 128 GB budget; --kv-cache-dir off or --no-kv-cache disables it). A snapshot is written only when it leaves RAM, never after a turn: - a RAM eviction (a new snapshot takes its slot, or the resident budget evicts it after a commit), on the single-sequence and the concurrent (paged) paths; - a graceful shutdown (SIGTERM/SIGINT): one last eviction of everything still resident, most recently used first, bounded by --kv-cache-shutdown-flush seconds (default 120) and logged. A crash or kill -9 loses what was only in RAM. What is written is each conversation's newest snapshot (the inline snapshot of its last request, or the agent-turn state with the generated reply) and the shared system/tools heads. A previous turn kept only as an edit backup (Luce-Org#835) is not written while its conversation's deeper snapshot is resident (PrefixCache::persist_on_evict). Writing a state deletes the files of its shallower states past the cold head. Lookups probe the index for the deepest stored prefix, so a restart restores sessions keyed past the prompt's chat boundaries, and a disk state deeper than the best RAM hit wins over it (a session evicted to disk usually still matches the shared head in RAM; a failed read keeps the RAM hit). The concurrent scheduler reads the file on a reader thread while live streams keep decoding. Each session file carries the tool-call replay text (ToolMemory) its prompt was rendered with, restored at startup; without it a new process renders earlier tool calls differently and no deep state could match. Compressed prompts (PFlash, FlowKV) never get a disk key, cold-head or continued checkpoint, so a compressed view is never persisted. Continued checkpoints remain only as the fallback without RAM prefix slots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes for problems found by serving the Manage models the way Manage does (Qwen3.8-Flash-Next split over an R9700 + Strix Halo, Qwen 3.8 27B on the R9700, DeepSeek V4.1 Flash on an R9700 + 192 GB Strix Halo) to scripted chats and agents and to real Claude Code and Codex sessions.
What was wrong
ROCm error: out of memory).adaptivethinking (Claude Code does) got none; replies in every app started with blank lines; OpenAI API clients (Codex, for one) showed 0 cached tokensadaptivethinking turned thinking off; the newlines after</think>were sent as text; usage had no cached-token field.Changes
StepPlanLimits::prefill_time_share), and a prompt that fits in one step runs first.adaptivethinking means the model's default; a template error answers 400.prompt_tokens_details.cached_tokens,input_tokens_details.cached_tokens).StepPlanLimits::time_share_prompt_tokens), so an agent's next step keeps every step.Measured
Each model served with its Manage command on one box (R9700 + Strix Halo, 192 GB), greedy, from a cold start. Scenarios: an 8-turn chat; one agent with tools over this repository (13.7K-token system prompt); four agents at once; a long document and a follow-up while a person chats; a chat on a 4K-token system prompt whose last message is edited and sent again, then a new conversation on the same prompt.
Qwen3.8-Flash-Next (split, 262K context, four slots), main (b8b3cf5) against this PR:
Qwen 3.8 27B (R9700, 131K context, eight slots, built with Manage's flags for it: HIP graphs off), Manage's pin (e6f643d) against this PR:
DeepSeek V4.1 Flash (R9700 + Strix Halo,
ds41-gorgon, one request at a time, HIP graphs off as Manage builds it): the follow-up on a 33K-token document restores it and prefills 79 tokens in 1.5 s, against 33,233 tokens in 63 s; its time to first token drops from 72 s to 11 s, the rest being the wait behind a person's request. The agent, edit and chat scenarios take the same time as at Manage's pin (55.6 / 25.3 / 80.4 s against 56.0 / 26.6 / 79.2 s), and the R9700's memory stays at 27.3 to 28.0 GiB as it does there.Real apps. None of the changes is specific to one app; they apply to any client of the OpenAI or Anthropic API. We checked them with two coding agents against this PR on Flash-Next, Claude Code 2.1.296 on a clean config (22 tools) and Codex 0.155.1: an 11-turn Claude Code task in 73 s (every request failed on main) and a 10-turn Codex task in 84 s. From the second turn on each turn restores the previous turn's end. On Qwen 3.8 27B, the same Claude Code task finishes in 97 s, and Codex in 52 s with 66,895 of its 82,926 input tokens served from the prefix cache.
Costs:
Tests
test_server_unittest_parallel_prefix_txnNew and updated tests: the capture is the end of the last message on every backend; a first turn saves its head; captures in flight hold their slots and bytes; the previous turn survives pruning while the budget holds it and an edit restores it; kept turns are evicted first; the scheduler captures for two requests admitted together, keeps the previous turn and frees the one before; decode-only steps run between the chunks of a long prompt and not beside a prompt under the engine's long-prompt size; a short prompt runs alone before a long one; a strict template gets a late system message as a user turn and a permissive one keeps it;
adaptivethinking is the default; the cached-token usage fields.Built for gfx1151 + gfx1201.
🤖 Generated with Claude Code