Prhub

#54439 [Bugfix][Multimodal] Avoid caching full prompts in fallback

原始 PR 作者 waizuichougou 合并时间 2026-08-31 00:19 文件变更 1 提交数 1 评论 2 代码增减 +3 / -6

执行摘要

回落编码绕过 LRU,修复多模态长 prompt 内存驻留

PR body 明确指出这是对 #53560 引入回归的修复:当多模态占位符跨 BPE 边界时,shared text fallback 会解码并重新编码完整用户 prompt;#53560 之前该编码绕过缓存,之后则进入进程级 cached_encode LRU。普通 MiniCPM-V 图片聊天请求即可触发,不需要畸形输入或特殊 flag;长期运行的多用户服务中,不同的长 prompt 会以 Python token 列表形式保留到被逐出为止,可累积数百 MiB 或更多内存。

值得精读。这是一个典型的"缓存边界设计"案例:进程级 LRU 只适合高频复用的数据,把每次几乎都不相同的完整 prompt 塞进 2048 条缓存,等于把内存泄漏伪装成缓存。建议同时关注后续是否补充自动化回归测试(例如断言 fallback 后缓存条目数不变),以及是否有更通用的手段(如按长度阈值决定是否缓存)来覆盖其他可能误用 cached_encode 的路径。

讨论亮点

该 PR 没有实质性的技术争论或 review 评论。唯一人工反馈来自维护者 DarkLight1337:"Oops, thanks for fixing",并直接批准。Claude bot 因 PR 来自 fork 而自动跳过审查。回归来源与修复方向均由 PR body 与关联 Issue 明确说明,未出现设计分歧。

实现拆解

  1. 定位回归源:在 vllm/multimodal/processing/processor.py 的 _apply_prompt_updates_via_text 方法中,fallback 渲染出的完整 prompt 文本(out_texts)在重新编码时使用了 cached_encode,使其进入进程级 2048 条 LRU 缓存。
  2. 修改编码调用:将两个分支(encode_segments_separately 为真/假)中的 cached_encode(tokenizer, text, add_special_tokens=False) 全部替换为直接 tokenizer.encode(text, add_special_tokens=False),改动共 3 增 6 删,单文件完成。
  3. 保留可复用序列的缓存:占位符与替换等公共序列仍在上游通过 cached_encode 缓存,本次只绕开"完整重建 prompt"的缓存,缓存边界恢复为 #53560 之前的状态。
  4. 验证与配套:未新增测试文件,运行了 tests/multimodal/test_processing.py(92 passed)、MiniCPM-V BPE-boundary 测试和 ruff 检查;作者补充了内存实验对比(256 条约 32K token 的不同 prompt,RSS 增长 364 MiB → 2 MiB,缓存条目 256 → 0)。
文件 模块 状态 重要度
vllm/multimodal/processing/processor.py 多模态处理 modified 5.71

关键符号

_apply_prompt_updates_via_text

关键源码片段

vllm/multimodal/processing/processor.py core-logic

唯一变更文件,是多模态文本 fallback 的核心处理路径。将 _apply_prompt_updates_via_text 中重建 prompt 的编码从 cached_encode 改为直接 tokenizer.encode,消除完整 prompt 在进程级 LRU 中的内存驻留,同时保留占位符 / 替换序列的上游缓存。

# 多模态文本 fallback:当占位符跨 BPE 边界时,prompt 会被解码后重新编码。
# 重建出的 out_texts 是完整的用户 prompt,通常各不相同,几乎无法命中缓存;
# 若走 cached_encode,会以 token 列表的形式长期驻留在进程级 LRU(2048 条)
# 中,长文本场景可能累积数百 MiB 内存。
if encode_segments_separately:
    # 每个片段单独编码,避免 BPE 在片段边界处合并 token,
    # 否则会改变非特殊 token 占位符的切分方式。
    new_token_ids = flatten_2d_lists(
        [tokenizer.encode(text, add_special_tokens=False) for text in out_texts]
    )
else:
    # 直接 tokenizer.encode,跳过 LRU 缓存:fallback 重建的完整 prompt
    # 本身就是一次性数据,缓存只会造成内存驻留;可复用的占位符与替换序列
    # 仍在上游通过 cached_encode 单独缓存,缓存语义不受影响。
    new_token_ids = tokenizer.encode(
        "".join(out_texts), add_special_tokens=False
    )
​
return new_token_ids, result

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 性能回退风险:直接 tokenizer.encode 每次都会完整重算 fallback prompt 的编码,若某个服务恰好反复提交相同的长 prompt,会失去 cached_encode 的命中收益;但该路径本身每次都要 decode + 重编码,且可复用的占位符/替换序列仍走缓存,实际收益损失很小。
  2. 核心路径回归风险:_apply_prompt_updates_via_text 是多模态文本 fallback 的主路径,改动影响所有占位符跨 BPE 边界的模型(如 MiniCPM-V、Qwen 系列等)。由于 encode 语义与 tokenizer 输出保持一致,理论输出不变,但该断言依赖 tokenizer 无状态缓存行为的一致。
  3. 测试覆盖缺口:本次没有新增自动化测试固化"fallback 不进入缓存"的断言,内存实验是手工进行的,后续改动可能重新引入该回归。

对用户与服务的影响是正面的内存占用改善:长期运行的多用户多模态服务可减少数百 MiB 级内存驻留,尤其对频繁提交不同长 prompt 的 MiniCPM-V 等模型。影响范围被严格限制在 fallback 重建 prompt 的编码路径,不改变缓存命中路径、占位符位置或模型输出。对团队而言是低风险、单文件、单 commit 的小修复,合并成本极低。

核心路径变更 缺少针对性自动化测试 进程级缓存回归修复

关联 Issue

#53560 [MM] Cache common token sequences

完整报告

参与讨论