fix(python-backends): re-attach media markers under use_tokenizer_template (#11621) - #11738
Open
Anai-Guo wants to merge 1 commit into
Open
fix(python-backends): re-attach media markers under use_tokenizer_template (#11621)#11738Anai-Guo wants to merge 1 commit into
Anai-Guo wants to merge 1 commit into
Conversation
Anai-Guo
force-pushed
the
fix-vlm-tokenizer-template-placeholder
branch
from
August 26, 2026 16:17
2a3ce79 to
eb9ac01
Compare
…plate (mudler#11621) With `template.use_tokenizer_template: true` the sglang and vllm backends render the prompt themselves via `tokenizer.apply_chat_template()`, and they hand it plain string content. A chat template only emits the model's own media tokens when the content is a list of parts, so the rendered prompt carries no `<|vision_start|><|image_pad|><|vision_end|>`. The pixels do reach the engine (`image_data` / `multi_modal_data`), but both engines locate them by scanning the prompt for that token, so they are discarded silently: HTTP 200, no warning, and the model answers as if no image had been attached. Add `attach_media_parts()` to the shared `python_utils` helper and call it in both backends: the last user turn is rebuilt as `[{"type": "image"} * n, {"type": "video"} * n, {"type": "text", ...}]` before templating, which makes the template emit the placeholders. The pixels keep travelling out of band exactly as before. Text-only requests are untouched - with no media the helper returns None and the original string-content path runs unchanged. If a template cannot iterate content parts (a text-only model), the parts render is caught and the request falls back to the previous string-content prompt instead of failing. Signed-off-by: Tai An <antai12232931@outlook.com>
Anai-Guo
force-pushed
the
fix-vlm-tokenizer-template-placeholder
branch
from
August 26, 2026 16:18
eb9ac01 to
910c785
Compare
localai-org-maint-bot
approved these changes
Aug 26, 2026
localai-org-maint-bot
left a comment
Collaborator
There was a problem hiding this comment.
Good to merge. The shared helper reconstructs the tokenizer content parts without mutating the original messages, both Python backends use it only for media-bearing tokenizer-template requests, and text-only/template-incompatible paths preserve the prior fallback. I verified the focused common helper suite (14 tests), Python compilation for the helper plus both backends, and diff hygiene; DCO is green. @mudler
Contributor
Author
|
Friendly ping @mudler — this one is approved and still |
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.
Description
Fixes #11621.
With
template.use_tokenizer_template: true, animage_urlcontent part is silently ignored on the sglang and vllm backends — HTTP 200, nothing in the log, and the model answers as if no image had been attached.The pixels are forwarded correctly. What is missing is the media placeholder in the prompt:
_build_prompt()(sglang) / the templating block in_predict()(vllm) pass string content totokenizer.apply_chat_template().<|vision_start|><|image_pad|><|vision_end|>for the Qwen-VL family — when the message content is a list of parts.sglang/srt/multimodal/processors/qwen_vl.py; vLLM substitutesmulti_modal_datainto it). With no placeholder present, the media are dropped without a word.This is option (a) from the issue: backend-local, no protocol change.
Change
backend/python/common/python_utils.py— newattach_media_parts(messages_dicts, n_images, n_videos). It rebuilds the last user turn as[{"type": "image"} * n, {"type": "video"} * n, {"type": "text", "text": ...}]and returns a new list, orNonewhen there is nothing to attach (no media, no user turn, content already a list of parts). It never mutates its input.backend/python/sglang/backend.py,backend/python/vllm/backend.py— call it just before templating. The pixels keep travelling out of band viaimage_data/multi_modal_dataexactly as before.backend/python/common/python_utils_test.py— 7 new cases for the helper.Backwards compatibility
Noneon its first line and the existing string-content path runs verbatim.except TypeErrorretry is preserved underneath.Verification
Helper unit tests (stdlib only, no backend venv — the target the Makefile already runs):
End-to-end on the real code path, without loading a model: the
_messages_to_dicts/_build_promptmethods are lifted out of the actualbackend.pyfiles withastand driven with a tokenizer stub that renders the realchat_template.jsonofQwen/Qwen2-VL-7B-Instructthrough jinja2.<|vision_start|><|image_pad|><|vision_end|><|vision_start|><|video_pad|><|vision_end|><|im_start|>user\nWie hoch steht das Wasser?<|im_end|>Rendered user turn after the change, 1 image:
Both backends produce the same before/after table.
Scope
Option (b) from the issue — carrying media per message in
backend.proto— is deliberately not attempted here. That is the only way to get images in different turns of a multi-turn conversation right, and it belongs in its own protocol-level change. This PR covers every single-image and last-turn request, which is effectively all real vision traffic today.Notes
mastercommit rather than the current tip — the token used here has noworkflowscope, so GitHub refuses to sync the fork. The diff is nonetheless exactly the four files above, and it merges into the currentmastercleanly (GitHub reports the PR as mergeable). I re-ran the before/after table on the auto-merged trees of both backends to confirm the merge is semantically right, not just conflict-free.🤖 Generated with Claude Code