Add mods/fix-anthropic-templateless-inline-system: stop hoisting inline system messages for templateless models - #381
Open
yaro-tal wants to merge 1 commit into
Conversation
A model with no jinja chat template gets its Anthropic inline `system` messages hoisted to offset 0, which destroys the prefix cache on every turn. `_detect_merge_inline_system` decides whether to merge inline `role: system` messages into the leading system block by rendering a probe conversation against the jinja template. With no template there is nothing to probe, so it returns its conservative default of True — merge. Claude Code >= 2.1.154 sends a per-turn system reminder inline in `messages`, so each turn puts new bytes at offset ~0 and nothing after them is reusable. But a templateless model is not an unknown quantity: it is rendered by a Python encoder chosen with --tokenizer-mode (deepseek_v4 here), and those encoders accept inline system messages at their original position. Merging is the destructive answer and should not be the fallback for "I could not check". Measured on a 2-node TP=2 DeepSeek-V4-Flash-0731 cluster driving Claude Code: prefix cache hit rate ~78% (effectively 0 on long conversations) -> 98.3%, prompt throughput 1600-2400 tok/s of real prefill work -> ~80. Templated models are unaffected — only the `not chat_template` branch changes. VLLM_ANTHROPIC_MERGE_INLINE_SYSTEM=1 restores stock behaviour without a rebuild. Verified to apply cleanly to both vLLM e2666d9a6 and current main, and to be reverse-check idempotent. Detector behaviour re-tested standalone: templateless -> False, plain template -> False, Qwen loop.first template -> True (unchanged), env override honoured in both directions. Deliberately excludes the second half of the original finding — a DeepSeek-V4 conversation ending in a `system` message got no generation prompt — because that is now fixed upstream in tokenizers/deepseek_v4_encoding.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
yaro-tal
force-pushed
the
fix-anthropic-templateless-inline-system
branch
from
September 8, 2026 07:58
81a6d93 to
a5ca2ef
Compare
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.
What
One line, in
vllm/entrypoints/anthropic/serving.py:plus a
VLLM_ANTHROPIC_MERGE_INLINE_SYSTEMescape hatch.Why
_detect_merge_inline_system(added in vllm-project/vllm#46025) decides whether to merge inlinerole: systemmessages into the leading system block by rendering a probe conversation against the jinja chat template. With no template there is nothing to probe, so it returns its documented conservative default: merge.DeepSeek-V4-Flash has no jinja template —
--tokenizer-mode deepseek_v4renders the prompt in Python instead. So the probe never runs and every request takes the merge path.Claude Code (>= 2.1.154) sends a per-turn system reminder inline in
messages. Merging puts each new one at offset ~0. New reminder each turn → new byte 0 → nothing after it is reusable. On a 200k-token conversation that is the entire prompt, every turn.Measured on a synthetic 4-turn conversation through the real encoder:
merge=True)merge=False)On a live 2-node TP=2 DeepSeek-V4-Flash-0731 cluster (2x DGX Spark GB10) driving Claude Code: prefix cache hit rate ~78% → 98.3% (effectively 0 → 98.3% on long conversations), prompt throughput 1600–2400 tok/s of real prefill work → ~80.
The same file already fights this exact battle two functions down, where it drops Claude Code's
x-anthropic-billing-headerbecause it "contains a per-request hash that defeats prefix caching". The merge path reintroduces that wholesale, with the entire reminder text.Scope
Only models with no jinja template at all. Templated models take the existing render-probe path and are bit-for-bit unaffected — the patch changes only the
not chat_templatebranch.Relationship to upstream
--chat-templatearg. Complementary, but it does not help here: a--tokenizer-modemodel has no jinja template to resolve, so the detector still getsNoneand still returnsTrue.messagespositionally into the chat template (breaks Claude Code >=2.1.2xx tool calling) vllm-project/vllm#48874). If that merges it would re-hoist Claude Code's per-turn reminder and undo this win, so the two want reconciling.If the upstream default ever changes, this mod becomes a no-op and
git apply --reverse --checkmakesrun.shskip cleanly.Deliberately NOT included
This was originally found alongside a second DeepSeek-V4 defect — a conversation ending in a
systemmessage got no<|Assistant|>generation prompt, so the model emitted an immediate EOS. That is now fixed upstream invllm/tokenizers/deepseek_v4_encoding.py, with a tighter condition than our local patch used, so it is not shipped here.Testing
git apply --checkpasses against both vLLMe2666d9a6(verified by md5 against the pinned base image) and currentmain.--reverse --checkidempotent, sorun.sh's already-applied guard works.False, plain template →False, Qwenloop.firsttemplate →True(unchanged), env override honoured in both directions.Provenance
Written with AI assistance and verified on the hardware described above. The patches in this and the related PR are a collaboration between Claude Opus 5 and DeepSeek-V4-Flash, each reviewing the other's work and keeping the other honest; the measurements are from a real two-node deployment, not from a model's say-so. Please apply whatever policy you have for AI-assisted contributions — flagging it so it's your call rather than my assumption.
🤖 Generated with Claude Code