09 · 视觉链路:从"能读图"到"崩不了"
SGLang v0.5.16 原生没有任何DSV4 视觉支持,模型注册里只有纯文本的
DeepseekV4ForCausalLM,加载带视觉张量的 checkpoint 必然KeyError: aligner.gate_up_proj.weight。所以视觉不是开关,是回移植。而回移植之后,“能读图"又变成"一传图就崩”。这两个阶段暴露的是完全不同类别的问题:前者是"引擎里没有这条路",后者是"路修好了,路上的假设却不成立"。
这一篇按五个阶段讲:回移植、融合 off-by-one、图片数上限、占位符特殊 token 致全会话 400,以及高清图的 TTFT 到底花在哪。
文章目录
- 09 · 视觉链路:从"能读图"到"崩不了"
- 零 开源项目地址
- 一 阶段一:必须回移植的原因
- 二 阶段二:融合 off-by-one,传图就崩整机
- 三 阶段三:每请求只有 2 张图
- 四 阶段四:一次引用,永久投毒
- 五 阶段五:高清图的 TTFT
- 六 模型视觉的检验口径
- 小结
零 开源项目地址
- gitee开源仓库地址:dsv4-vision-exp-sglang-sm80
一 阶段一:必须回移植的原因
- 事实层面没有余地:
| 事实 | 具体 |
|---|---|
| checkpoint 带视觉张量 | architectures=["DeepseekV4ForCausalLM"]、43 层,权重里另有vision.* / aligner.* / image_* |
| v0.5.16 没有 DSV4 视觉 | 多模态处理器目录里有deepseek_ocr/deepseek_vl_v2/glm4v,唯独缺deepseek_v4 |
| 加载必然失败 | 模型注册只有纯文本类,视觉键无人接管 →KeyError: aligner.gate_up_proj.weight |
| 官方视觉镜像不可换 | 那是 v0.5.17+ 语义的另一套实现,与本机 v0.5.16 补丁栈互斥,SM80 兼容性未知 |
于是路线定为:不换镜像,按上游 PR #37253 的语义,把视觉回移植进 v0.5.16 的源码树。
视觉键的装法是四件事:VL wrapper
deepseek_v4_vl.py单独装载视觉键、只把文本键交给内层文本基座;逐层bias_vl做模态分叉的专家偏置路由(文本位置不触发,与现有文本输出位级一致);image span 内的双向可见窗注意力;以及配套的 mm processor。纠正:
KeyError: aligner.gate_up_proj.weight看起来像"权重缺失",其实是 sglang 内部按 MoE 惯例重命名.w1./.w2.造成的,aligner 不是 MoE,是普通 MLP,按参考命名直载即可。这四件事里,
bias_vl与 image span 可见窗是风险最高的两块:前者改变 MoE gate 的打分路径,后者要在一个已被补丁魔改过的稀疏注意力 / KV 布局里,为图片 token 单独开一扇双向窗。文本路径之所以必须位级不变,正是因为这两个改动都可能越过图片边界影响文本,一旦影响,整机的文本质量会以难以归因的方式退化,而这类退化不会报错,只会变差。
二 阶段二:融合 off-by-one,传图就崩整机
- 症状。同一张图走
/v1/responses上传,前端报错,容器日志里是:
RuntimeError: Insufficient multimodal embedding length:num_mm_tokens_in_input_ids=199vsnum_mm_tokens_in_embedding=198.[TP0-3]Scheduler hit an exception...紧接着调度器异常,容器自动重启。表现为"凡是在 Responses 路径带图的请求都会触发"。直连
/v1/chat/completions+ 同一张图(base64)完全正常,198 == 198。off-by-one 只在另一条路径上出现,单看 chat 的自检永远是绿的。更糟的是失败半径:
mm_utils._adjust_embedding_length对"输入占位符多于 embedding"是直接raise,而 Scheduler 没有接住这个异常,于是所有 rank 一起异常、整机重启。一个 token 的偏差被放大成了服务级故障。修复:占位符多于 embedding 时不再
raise,改为重复最后一维 embedding 补齐长度,保证 scatter 对齐、请求可正常服务。多出的 1 个尾部图片 token 可忽略,不产生定位偏移。复现方式很干净:直连 chat 路径 + 同一张 base64 图正常(198 image tokens),同一张图改走 responses 就崩溃重启。两条路径用的是同一份 ViT 与 aligner,唯一差别在融合阶段的占位符计数。这类"同一份实现、两条调用路径结果不同"的 bug,定位时最有效的动作就是固定输入、只换路径。
三 阶段三:每请求只有 2 张图
- 多图请求(一次 3 张)报错:
ValueError: Image count3exceeds limit2per request.- 根因是 v0.5.16 默认
limit_mm_data_per_request={"image":2},这是个服务端安全阀,默认值故意设得很小。改成 96 只是运行期环境变量,重建容器即生效,无需重建镜像。 - 配置成 96 张后的实测(DSPARK,4×A800,TTFT 随图片数亚线性):
| 图片数 | TTFT | 图片 token |
|---|---|---|
| 8 | 0.41s | ~1.6K |
| 24 | 2.60s | ~4.8K |
| 48 | 4.21s | ~9.6K |
| 96 | 5.32s | ~19K |
96 张请求实测 TTFT 5.32s、prompt 19207(其中 image 19105),显存只+2GiB(~58.5GiB/GPU,余 ~21.5GiB,无 OOM)。
- 曲线亚线性说明张数不是瓶颈:96 张很宽裕,TTFT 远小于 30s。真正限制长会话的是上下文累积,要靠客户端做历史裁剪,而不是继续往上调图片数上限。
四 阶段四:一次引用,永久投毒
- 症状:工具结果里出现了一行字面
<|deepseek_image|>,此后会话的每一轮都以同一个 400 终止,连"请继续"也救不回来:
OpenAI API error(400):{"message":"Message content contains image special token '<|deepseek_image|>'. Images should be provided as image content blocks."}触发链:工具结果原文包含这个 token → Agent harness 把工具结果逐字回放进下一轮请求历史→服务端编码器的安全校验发现文本里有图片特殊 token → 拒绝整个请求。投毒的历史每轮原样重放,400 就永久粘住了会话。
一次引用,永久投毒。根因:校验初衷正确,"硬拒绝"策略错误。拦截本身是对的:这个 token 在 tokenizer 里是 added token,
放行后会在 prompt 里变成一个"幽灵占位符",而多模态处理器按占位符数量注入视觉 embedding,数量对不上就错位或崩溃——
这正是 vLLM 已知漏洞类 CVE-2025-48956(“Remote DoS via Special-Token Placeholders”)的形态。错的是策略:Agent 类 harness 天然会把工具结果、日志、源码逐字回放进历史。只要有一次工具结果引用了讲解这个 token 的文件,
会话就永久 400,用户侧无解。
修复:把特殊 token 转义成词表外的 ASCII 形式,即<|deepseek_image|>→<|deepseek_image|>(ASCII 竖线 U+007C)。
| 落点 | 旧行为 | 新行为 |
|---|---|---|
字符串content/reasoning_content | 含 token →raise | 原地转义为 ASCII 形式 |
| 内容块列表里的 text 块 | 含 token →raise | 转义 |
tool_result块的字符串content | 完全绕过校验 | 转义(补上旧漏洞面) |
| 内容列表里的裸字符串块 | 完全绕过校验 | 转义(补上旧漏洞面) |
转义形式与全角|视觉几乎一致、不在词表 / added_tokens 中,绝不成为特殊 token id;顺序上,转义发生在为真实图片块注入占位符之前,所以注入的占位符不会被二次转义。
核心洞察:安全校验必须假设回放文本不可信,转义优于拒绝。
拒绝把"文本里提到了 token"与"请求真的想注入图片"混为一谈,而这两件事用"是否为结构化图片块"就能区分。
修复后:chat 的 tool 消息含 token 由 400 变200,responses 的投毒历史由 400 变200,而真实图片(结构化
image_url块)仍然 200 且识别无损,模型忠实看到的是转义后的引述文本。同类问题在社区已有迭代过的处理,精神是一致的:把"文本提及图片标记"与"成对标记引用 / 结构化图片部分"区分开;把 tool / function 消息文本视为不透明(grep、cat 到含标记的文件不再 400);配对标记扫描不跑在回放的 assistant 文本上。
三条的共同结论都是:文本提及 ≠ 图片引用,只有结构化内容块才驱动图片注入,回放的历史文本必须 opaque。
五 阶段五:高清图的 TTFT
- 视觉"不崩"之后,下一个问题自然是"多快"。单图实测(DSPARK,流式首 token):
| 单图尺寸 | TTFT |
|---|---|
| 纯文本 | 0.27s |
| 2K(2048²) | 0.46s |
| 4K(4096²) | 0.73s |
| 8K(8192²) | 1.67–1.95s |
- 结论有点反直觉:2K / 4K 并不慢,只有 8K 慢,而且慢的地方不是 ViT。
- 服务端对任意图都先走
safe_resize→vision_max_n_token=384:无论原图多大,先降采样到 ≤384 图片 token 的网格再交给 ViT。所以 2K / 4K 与 512² 的 ViT 输入规模同量级,编码成本几乎一样。8K 的真凶在CPU 侧解码那幅超大原图(8192×8192 ≈5300 万像素),PIL.Image解码加.resize()全在 CPU 上,约1s+。
推论很直接:只要客户端先把图降采样,8K 的慢就消失。
| 手段 | 评价 |
|---|---|
| 客户端把源图降到≤2048px再发 | ✅ 最有效:服务端收到有界图,CPU 解码 + ViT + prefill 全部有界,TTFT 稳定 ~0.5s |
降低vision_max_n_token(384→256) | ⚠️ 只省 ViT,救不了 8K 的 CPU 解码(那才是大头),且损失细节 |
| 服务端 tiling | ❌ 反而更慢:tile 越多 token 越多、prefill 越长。这是"要细节",不是"提速" |
一句话:发图前先降采样到 ≤2048px。别发 4K / 8K 原图,除非确需细节并接受更长的 TTFT。
- 另外要排掉一个伪影:1024² 曾单次测得 1.22s,那是首图 ViT 未预热造成的冷缓存结果;预热后同尺寸稳定在 0.3–0.5s。所以分辨率结论一律以"预热后"为准,量级之外的差异不拿来做结论,否则很容易把一次冷启动的噪声写成"某个分辨率有问题"。
六 模型视觉的检验口径
“返回 200"不等于"模型真的看到了图”。我们用三条硬口径:
- 严格冒烟断言:素材是 512×512 纯红图(RGB 198,60,60),应答未命中红色关键词即判 FAIL——无条件,不是"能返回就算过";
- 真正编码的判据:
usage.prompt_tokens_details.image_tokens > 0(纯文本请求为 0 或缺省); - 两种交付形态:chat 路径的
image_url,base64 data URI 与远程 URL 都支持。
回归结果:冒烟PASS=4 FAIL=0;占位符转义回归unit 10/10 + direct 4/4;96 张图请求
prompt 19207(image 19105)。另外每次改动都要两条一起看——文本回归全绿,且图片真的读对:bias_vl的设计约束是文本路径位级不变。这三条口径解决的是同一个问题:视觉链路的失败有很强的"沉默"倾向——接口可以返回 200,图也可能被当成没传。所以判据必须落在"模型答出了什么"与"服务端真的编进去多少 image token"上,而不是 HTTP 状态码。
小结
- v0.5.16 原生没有 DSV4 视觉,能读图靠回移植:VL wrapper + 逐层
bias_vl+ image span 可见窗 + mm processor; - 回移植之后的主要敌人是被破坏的不变式:融合 off-by-one 把单个 token 的偏差放大成全 rank 崩溃与整机重启;
- 修 off-by-one 是"善后 + 弥补不变式"(重复最后一维补齐),不是掩盖错误;
- 图片数上限只是安全阀(默认 2 → 96),96 张 TTFT 5.32s 亚线性、显存仅 +2GiB;长会话的真瓶颈是上下文累积;
- 占位符特殊 token 的教训是"安全校验必须假设回放文本不可信":转义优于拒绝,一次引用不该永久投毒;
- 高清图的正解在客户端,降到 ≤2048px;服务端 tiling 只会更慢,
vision_max_n_token也救不了 CPU 解码。