Skip to content

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
eugr:mainfrom
yaro-tal:fix-anthropic-templateless-inline-system
Open

Add mods/fix-anthropic-templateless-inline-system: stop hoisting inline system messages for templateless models#381
yaro-tal wants to merge 1 commit into
eugr:mainfrom
yaro-tal:fix-anthropic-templateless-inline-system

Conversation

@yaro-tal

@yaro-tal yaro-tal commented Sep 8, 2026

Copy link
Copy Markdown

What

One line, in vllm/entrypoints/anthropic/serving.py:

if not chat_template:
-    return True
+    return False

plus a VLLM_ANTHROPIC_MERGE_INLINE_SYSTEM escape hatch.

Why

_detect_merge_inline_system (added in vllm-project/vllm#46025) decides whether to merge inline role: system messages 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_v4 renders 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:

shared prefix between consecutive turns
stock (merge=True) 4198 chars — exactly the static system prompt, nothing else
patched (merge=False) 100% of the previous turn

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-header because 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_template branch.

Relationship to upstream

If the upstream default ever changes, this mod becomes a no-op and git apply --reverse --check makes run.sh skip cleanly.

Deliberately NOT included

This was originally found alongside a second DeepSeek-V4 defect — a conversation ending in a system message got no <|Assistant|> generation prompt, so the model emitted an immediate EOS. That is now fixed upstream in vllm/tokenizers/deepseek_v4_encoding.py, with a tighter condition than our local patch used, so it is not shipped here.

⚠️ On a build that predates that upstream fix, this mod alone makes things worse — it stops the system message being hoisted, so it now actually reaches the encoder, where the EOS bug lives. The README says so explicitly.

Testing

  • git apply --check passes against both vLLM e2666d9a6 (verified by md5 against the pinned base image) and current main.
  • Applies, parses, and is --reverse --check idempotent, so run.sh's already-applied guard works.
  • Detector re-tested standalone after patching: templateless → False, plain template → False, Qwen loop.first template → True (unchanged), env override honoured in both directions.
  • In production on the cluster described above.

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

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
yaro-tal force-pushed the fix-anthropic-templateless-inline-system branch from 81a6d93 to a5ca2ef Compare September 8, 2026 07:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant