【Bug已解决】GRPO + vLLM corrupts SmolVLM multimodal prompts from pre-expanded image tokens 解决方案

【Bug已解决】GRPO + vLLM corrupts SmolVLM multimodal prompts from pre-expanded image tokens 解决方案
【Bug已解决】GRPO vLLM corrupts SmolVLM multimodal prompts from pre-expanded image tokens 解决方案原始报错GRPO vLLM corrupts SmolVLM multimodal prompts from pre-expanded image tokens 场景用 GRPO组相对策略优化配合 vLLM 做多模态SmolVLM 图文训练/采样时prompt 里的图像被预展开成多个图像占位 token比如一个image展开成 64 个视觉 token。但在 vLLM 的采样/多轮生成流程里这些预展开的 token 与实际图像的对齐出错prompt 被损坏——图像占位符数量或位置错乱模型拿到的是错位的多模态输入训练信号失真。 关键词多模态训练、图像 token 预展开、GRPO、vLLM、prompt 对齐、占位符映射。一、现象长什么样多模态训练流程原始 prompt 含 1 张图 文本图像被处理器展开成 N 个img占位 token用 vLLM 对这个 prompt 做 GRPO 的多条 rollout 采样采样/多轮拼接后某些 rollout 的 prompt 里图像占位 token 数量不对多了或少了或位置被文本挤偏模型前向时pixel_values与 token 序列对不上报形状不匹配或静默学歪只在 GRPO vLLM需要多份采样、动态拼接时出现单条推理正常。根因是多模态 prompt 在预展开图像 token之后又被采样/拼接逻辑当成纯文本处理图像占位符的连续块被打散或重复破坏了N 个占位符 ↔ 1 张图的契约。二、背景多模态 token 是怎么预展开的多模态模型SmolVLM 等的 prompt 构造通常是文本里写image这样的占位符处理器processor把每个image展开成固定数量 N 的视觉 token如img_0...img_0共 N 个同时把真实图像编码进pixel_values模型要求token 序列里视觉 token 的总数必须等于pixel_values里图像数 × N且顺序对应。GRPO vLLM 的难点在于要为一个 prompt 生成 G 条 rollout每条都是同一图像 不同生成文本。如果采样/拼接代码不理解这 N 个连续 token 是一个不可拆分的图像块就可能在中途插入、截断或复制这些 token破坏对齐。三、根因图像块被当纯文本处理根因拆解占位符不可见采样逻辑只看到 token id 序列不知道这几段连续 token 是图像块于是随意截断/拼接。数量假设错假设图像 token 数 1纯文本思维实际是 N导致pixel_values形状算错。多轮拼接错位tool-call / 多轮里把图像块和文本混拼块被拆到不同段。预展开时机晚图像在采样阶段才展开而采样逻辑已按未展开格式处理了占位符。无校验生成后没校验视觉 token 总数 图像数 × N错乱直接进前向。下面用最小模型复现图像块被截断导致数量不符再给修复。四、最小可运行复现IMG_TOKEN img N 4 # 每张图展开成 4 个视觉 token def expand_image(text, num_images): # 把 num_images 个 image 各展开成 N 个 img toks [] for _ in range(num_images): toks.extend([IMG_TOKEN] * N) return toks text.split() # 错误图像块和文本简单拼接块无保护 def sample_rollouts(prompt_tokens, g3): # 错误把 prompt_tokens 当纯文本随机截断尾部可能截到图像块 outs [] for i in range(g): cut len(prompt_tokens) - i # 每条截断长度不同 outs.append(prompt_tokens[:cut]) return outs if __name__ __main__: pt expand_image(describe, num_images1) # [imgx4, describe] print(原始视觉token数:, pt.count(IMG_TOKEN)) # 4 for i, r in enumerate(sample_rollouts(pt)): print(frollout{i} 视觉token数:, r.count(IMG_TOKEN)) # 可能 4,3,2 错位运行看到不同 rollout 的视觉 token 数不一4/3/2图像块被截断——pixel_values与 token 对不上prompt 损坏。五、方案图像块作为不可拆分单元管理第一层把一张图展开的 N 个 token封装成不可拆分的结构单元任何拼接/截断都以完整图像块为粒度from dataclasses import dataclass from typing import List dataclass class ImageBlock: image_index: int tokens: List[str] # 长度固定 N dataclass class MultimodalPrompt: images: List[ImageBlock] text_tokens: List[str] def total_image_tokens(self) - int: return sum(len(b.tokens) for b in self.images) def to_tokens(self) - List[str]: # 图像块整体在前文本在后块不被拆 out [] for b in self.images: out.extend(b.tokens) # 整块追加绝不中途截断块 out.extend(self.text_tokens) return out def build_prompt(num_images, text, n4): imgs [ImageBlock(i, [IMG_TOKEN] * n) for i in range(num_images)] return MultimodalPrompt(imgs, text.split()) if __name__ __main__: p build_prompt(1, describe) print(视觉token总数:, p.total_image_tokens()) # 4 print(序列:, p.to_tokens())图像块成为一等公民拼接时整块操作采样逻辑不会把块截一半。六、方案采样时保持 prompt 结构一致第二层GRPO 的多条 rollout 共享同一份图像结构只对文本生成部分做采样图像块原样复用绝不重新展开或截断def sample_rollouts_structured(p: MultimodalPrompt, g3): results [] for i in range(g): # 图像块整块复用文本部分模拟不同生成 gen_text p.text_tokens [f[gen{i}]] rp MultimodalPrompt(p.images, gen_text) # 图像不变 results.append(rp) return results if __name__ __main__: p build_prompt(1, describe) rolls sample_rollouts_structured(p, g3) for i, r in enumerate(rolls): assert r.total_image_tokens() 4 # 每条都正好 4不错位 print(frollout{i} 视觉token数:, r.total_image_tokens())所有 rollout 图像块完全一致只有文本生成不同符合 GRPO 多 rollout 的语义。七、方案生成后校验 token 数与图像数匹配第三层每次构造完 prompt强制校验视觉 token 总数 图像数 × N不符直接报错而非带病进前向def validate(p: MultimodalPrompt, n_per_image4): expected len(p.images) * n_per_image actual p.total_image_tokens() if actual ! expected: raise ValueError( f视觉token数不符: 期望 {expected}{len(p.images)}图×{n_per_image} f实际 {actual}prompt 已损坏) return True if __name__ __main__: p build_prompt(2, describe) # 2图 - 期望 8 validate(p) print(校验通过: 视觉token数 , p.total_image_tokens())校验是最后一道防线任何破坏对齐的拼接都会在此抛错不会让损坏 prompt 静默进训练。八、验证把图像块不可分 校验锁进测试def test_image_block_not_split_across_rollouts(): p build_prompt(1, describe) rolls sample_rollouts_structured(p, g3) for r in rolls: assert r.total_image_tokens() 4 def test_validate_catches_mismatch(): p build_prompt(1, describe) # 人为破坏删掉一个图像 token p.images[0].tokens.pop() try: validate(p) assert False except ValueError: pass if __name__ __main__: test_image_block_not_split_across_rollouts() test_validate_catches_mismatch() print(多模态 prompt 图像块一致性测试通过。)九、排查清单多模态 prompt 损坏按顺序查token 数生成后视觉 token 总数是否 图像数 × N不符即损坏。图像块采样/拼接是否把N 个连续视觉 token当不可分单元还是当纯文本截断多 rolloutGRPO 多条采样是否共享同一图像结构还是各自重新展开多轮拼接tool-call/多轮里图像块是否被拆到不同段预展开时机图像在采样前还是采样中展开采样逻辑是否假设未展开格式校验构造后是否校验视觉 token 数与 pixel_values 匹配缺校验则静默学歪。单条正常多份异常单条推理正常、GRPO 多份异常强烈指向采样/拼接破坏了块。十、小结GRPO vLLM 因预展开图像 token 损坏多模态 prompt是采样/拼接逻辑把N 个连续视觉 token当成纯文本处理破坏了块 ↔ 图像契约。修复三层图像块不可分把一张图展开的 N 个 token 封装为结构单元拼接以整块为粒度采样保结构GRPO 多 rollout 共享同一图像结构只对文本生成采样图像块原样复用强制校验构造后校验视觉 token 总数 图像数 × N不符即报错。核心原则多模态 prompt 里的图像占位块是带数量契约的结构绝不能被当作可任意截断的纯文本。把图像块作为一等公民管理并在每次构造后校验对齐GRPO vLLM 的多模态训练才不会出现图像错位、悄悄学歪的隐性 bug。