[RL+VLM] Avoid retokenization drift for pre-tokenized (token-id) VLM requests (#26555)

Co-authored-by: Byron Hsu <byron+per@periodiclabs.ai>
Co-authored-by: root <root@slurm-h200-209-231.slurm-compute.tenant-slurm.svc.cluster.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mick <mickjagger19@icloud.com>
This commit is contained in:
Byron Hsu
2026-06-01 09:58:14 -07:00
committed by GitHub
co-authored by Byron Hsu root Cursor Mick
parent 1988a2c9ea
commit f6a5a1b59c
4 changed files with 227 additions and 0 deletions
+3
View File
@@ -555,6 +555,9 @@ class Envs:
SGLANG_MM_PRECOMPUTE_HASH = EnvBool(False)
SGLANG_VIT_ENABLE_CUDA_GRAPH = EnvBool(False)
SGLANG_MM_SKIP_COMPUTE_HASH = EnvBool(False)
# For pre-tokenized (list[int]) multimodal prompts,
# preserve the user's original tokens to avoid retokenization drift.
SGLANG_MM_AVOID_RETOKENIZE = EnvBool(True)
# VLM Item CUDA IPC Transport
@@ -1227,6 +1227,58 @@ class BaseMultimodalProcessor(ABC):
return tensor
return tensor.cpu()
def resolve_image_token_counts(self, images: List) -> List[int]:
"""Per-image expanded token counts, computed without re-tokenizing.
Default implementation uses the transformers in-tree convention
``_get_num_multimodal_tokens(image_sizes=...)`` (present on the in-tree
VLM processors, e.g. Qwen-VL, Gemma3, GLM4V). Models whose processor
does not implement it (e.g. Kimi) override this method.
"""
assert images is not None
image_sizes = [(image.height, image.width) for image in images]
num_image_tokens = self._processor._get_num_multimodal_tokens(
image_sizes=image_sizes
).num_image_tokens
return [int(count) for count in num_image_tokens]
@staticmethod
def _expand_input_ids(
original_ids: List[int],
counts: List[int],
placeholder_token_id: Optional[int],
) -> List[int]:
"""Rebuild final input_ids for a pre-tokenized (list[int]) prompt.
Keep the user's ORIGINAL tokens verbatim and expand the i-th image
placeholder into ``counts[i]`` copies of ``placeholder_token_id``. The HF
processor's re-tokenization is discarded, so non-media tokens cannot
drift.
"""
if placeholder_token_id is None:
raise ValueError("placeholder_token_id is not set for this processor")
num_placeholders = sum(
1 for token_id in original_ids if token_id == placeholder_token_id
)
if num_placeholders != len(counts):
raise ValueError(
f"prompt has {num_placeholders} image placeholder token(s) but "
f"{len(counts)} image(s) were provided"
)
rebuilt: List[int] = []
next_image_idx = 0
for token_id in original_ids:
if token_id == placeholder_token_id:
rebuilt.extend([placeholder_token_id] * counts[next_image_idx])
next_image_idx += 1
else:
rebuilt.append(token_id)
return rebuilt
def process_and_combine_mm_data(
self,
base_output: BaseMultiModalProcessorOutput,
@@ -1276,6 +1328,48 @@ class BaseMultimodalProcessor(ABC):
**kwargs,
)
all_collected_items = collected_items
# When SGLANG_MM_AVOID_RETOKENIZE is on, keep the user's exact tokens to avoid retokenize drift.
# Drift happens when Retokenization is not identity: Decode(X) => String => Re-tokenize => Y, X != Y.
if (
envs.SGLANG_MM_AVOID_RETOKENIZE.get()
and base_output.input_ids is not None
and input_ids is not None
and raw_images
and not raw_audios
and not raw_videos
):
assert isinstance(
base_output.input_ids, list
), f"expected list[int] input_ids, got {type(base_output.input_ids)}"
try:
counts = self.resolve_image_token_counts(raw_images)
image_placeholder_token_id = mm_tokens.image_token_id
if image_placeholder_token_id is None:
raise ValueError(
"image placeholder token id is not set for this processor"
)
processor_placeholder_count = int(
(input_ids == image_placeholder_token_id).sum().item()
)
if processor_placeholder_count != sum(counts):
raise ValueError(
"processor image placeholder count mismatch: "
f"processor={processor_placeholder_count}, "
f"resolved={sum(counts)}"
)
input_ids = torch.tensor(
self._expand_input_ids(
base_output.input_ids,
counts,
image_placeholder_token_id,
),
dtype=input_ids.dtype,
)
except Exception as e:
logger.warning(
f"Due to {e}, falling back to decode+retokenize, which may change prompt length (token drift)."
)
else:
ret = None
@@ -23,6 +23,21 @@ class KimiGridMMDataMixin:
- self._tokenizer (with .encode())
"""
def resolve_image_token_counts(self, images):
"""Kimi's processor is remote-code and does not implement the
transformers ``_get_num_multimodal_tokens`` convention; use its
``media_tokens_calculator`` instead.
"""
assert images is not None
media_tokens_calculator = (
self._processor.media_processor.media_tokens_calculator
)
return [
int(media_tokens_calculator({"type": "image", "image": image}))
for image in images
]
def _num_image_tokens_from_grid(
self, grid_thw: Union[torch.Tensor, np.ndarray, list, tuple]
) -> int:
@@ -0,0 +1,115 @@
"""E2E test for SGLANG_MM_AVOID_RETOKENIZE on the pre-tokenized VLM path.
A client may send a multimodal request as input_ids (list[int]) instead of text.
On that path the server decodes the ids back to text and the HF processor
re-tokenizes them. If the original ids were non-canonical (decode -> re-encode is
not identity), that re-tokenization drifts: the reported prompt_tokens changes.
With SGLANG_MM_AVOID_RETOKENIZE ON (default), the server keeps the user's
original tokens verbatim and only expands the image placeholder, so prompt_tokens
stays faithful to what the client sent.
For each model we launch a real server twice with the same predefined,
non-canonical prompt ("Describe" split into "D"+"escribe") plus one image:
* flag OFF -> the prompt re-tokenizes (drift): prompt_tokens shrinks by the
drift delta.
* flag ON -> no drift: prompt_tokens equals the original length (with the
image placeholder expanded).
"""
import base64
import io
import unittest
import requests
from PIL import Image
from transformers import AutoProcessor
from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
register_cuda_ci(est_time=300, suite="base-b-test-1-gpu-large")
def _data_uri():
img = Image.new("RGB", (64, 64), (128, 128, 128))
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def _build_drift_prompt(model, image_token):
"""Return (input_ids, drift_delta).
input_ids is a predefined non-canonical prompt: "Describe" is split into
"D"+"escribe" (decodes to the same text but re-encodes to the single merged
token), followed by one image placeholder. drift_delta is how many extra
tokens the non-canonical form carries vs. the canonical re-tokenization.
"""
tok = AutoProcessor.from_pretrained(
model, trust_remote_code=True, use_fast=True
).tokenizer
def enc(text):
return tok.encode(text, add_special_tokens=False)
input_ids = enc("D") + enc("escribe") + enc(" the picture: ") + enc(image_token)
canonical = enc(tok.decode(input_ids))
drift_delta = len(input_ids) - len(canonical)
return input_ids, drift_delta
def _prompt_tokens(base_url, input_ids, image):
resp = requests.post(
base_url + "/generate",
json={
"input_ids": input_ids,
"image_data": [image],
"sampling_params": {"temperature": 0.0, "max_new_tokens": 1},
},
timeout=300,
)
resp.raise_for_status()
return resp.json()["meta_info"]["prompt_tokens"]
class TestQwenVLTokenIdRetokenize(CustomTestCase):
model = "Qwen/Qwen2.5-VL-3B-Instruct"
image_token = "<|vision_start|><|image_pad|><|vision_end|>"
other_args = ["--trust-remote-code", "--mem-fraction-static", "0.7"]
def test_flag_off_drifts_flag_on_does_not(self):
input_ids, drift_delta = _build_drift_prompt(self.model, self.image_token)
self.assertGreater(drift_delta, 0, "prompt is canonical; no drift to exercise")
image = _data_uri()
prompt_tokens = {}
for flag in ("0", "1"):
process = popen_launch_server(
self.model,
DEFAULT_URL_FOR_TEST,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=self.other_args,
env={"SGLANG_MM_AVOID_RETOKENIZE": flag},
)
try:
prompt_tokens[flag] = _prompt_tokens(
DEFAULT_URL_FOR_TEST, input_ids, image
)
finally:
kill_process_tree(process.pid)
# ON keeps the user's original tokens; OFF loses the drift_delta tokens.
pt_off, pt_on = prompt_tokens["0"], prompt_tokens["1"]
self.assertEqual(pt_on - pt_off, drift_delta, f"on={pt_on}, off={pt_off}")
if __name__ == "__main__":
unittest.main()