DeepSeek V4.1 Flash 工程实践(三):接入图像——用中间张量找差异
从 image_url 到视觉塔、aligner 与双偏置 MoE 路由:用逐段张量对拍修正 DeepSeek V4.1 Flash 的图文链路
上一篇从权重装配讲到八卡文本链路,重点是让压缩 KV、selection 与 mHC 状态正确跨过设备边界。这一篇把图像接到同一条语言主干上。
多模态接入最容易出现一种假象:HTTP 请求成功,视觉塔没有报错,模型也生成了一段文字,看起来整条链已经工作;但只要 resize 少取一列、RoPE 排列次序不同,或者 aligner 把补零后的网格拼错,后面每一层拿到的都会是形状正确、语义错误的张量。
因此,我们没有把“能够描述一张图片”当作最终的正确性证据。zLLM 先完成端到端接线,再以官方 PyTorch 实现为 oracle,把视觉链拆成多个可观测断点,找出第一个开始分叉的位置。
多模态的基本原理:把图片变成语言模型可以处理的 token
语言模型的基本输入是一行行 hidden vector,原始图片却是二维像素。多模态模型首先要把这两种数据转换到同一个表示空间,再让它们进入同一条推理链路。
第一步是图像分块。图片按照固定大小切成 patch,例如 DeepSeek V4.1 Flash 使用 14×14 的 RGB 区域。每个 patch 展开成一行数值,保留它位于第几行、第几列的网格坐标。这样,一张连续图片就变成了一串带有二维位置的视觉 token:
第二步是视觉塔编码。patch projection 先把像素行映射到视觉 hidden,随后 ViT 通过视觉 attention、位置编码和 MLP,让每个 patch 不只表示局部颜色,也结合整张图的上下文。例如胡萝卜的一小块橙色像素,经过视觉塔后可以同时携带形状、物体类别、相邻物体和画面位置等信息。这里得到的不是一个代表整张图的单独向量,而是一组保留空间结构的语义向量。
第三步是对齐语言空间。视觉 hidden 的宽度和语言模型通常不同,视觉 token 数量也可能过多。aligner 或 merger 会合并相邻视觉位置,再把结果投影到语言模型的 hidden size。这个模块由训练得到,它不仅改变矩阵维度,还负责把视觉特征映射到语言模型已经能够理解的表示空间:
最后,模型把这些视觉 token 放进文本序列中预留的图片位置。文本 token 仍来自词表 embedding,图片位置则由视觉语义向量覆盖。对语言模型而言,输入已经是一条统一的 hidden 序列:
语言层的 attention 可以让问题 token 读取相关的视觉 token,也可以让视觉信息与前后文本逐层融合。模型最终仍通过 LM head 预测下一个文本 token,因此图像识别、描述和视觉问答都表现为普通的文本生成。
在推理工程中,视觉塔通常在 prefill 阶段为本次图片计算一次语义向量;这些视觉 token 随其余输入一起建立语言模型的 KV cache。进入逐 token decode 后,后续 token 通过缓存继续读取已经编码的图像上下文,不需要为每个输出 token 重新运行整座视觉塔。
DeepSeek V4.1 Flash 与 Qwen3-VL 都遵循这条主线,区别集中在怎样分块、怎样表示二维或时间位置、怎样合并视觉网格,以及视觉特征在语言层注入一次还是多次。理解这层共同原理后,后面的中间张量对拍就有了明确目标:每个环节都必须保持训练时约定的视觉语义和空间位置。
先把完整图文链路接起来
一次请求从 OpenAI 兼容接口的 image_url part 开始,经过的并不只是一个 Vision Encoder:
zLLM 在 node 侧保留消息中图像与文字的原始顺序,先把图像占位符写进 prompt。每张图完成预处理后,占位符展开为一段二维 token 网格:
[IMAGE_START]
[IMAGE] [IMAGE] ... [IMAGE] [IMAGE_NEWLINE]
[IMAGE] [IMAGE] ... [IMAGE] [IMAGE_NEWLINE]
...
[IMAGE_END]
这些位置在 input_ids 中都使用同一个 image_token_id。START、IMAGE、NEWLINE 和 END 的差异由额外的类型与 embedding 组装规则表达:普通 IMAGE 行由 aligner 输出覆盖,首尾与换行位置使用各自训练好的向量。
这个表示还必须适应 chunked prefill。一个图像 span 可能横跨两个输入块,所以视觉结果不能只在某个完整 prompt 上替换一次。zLLM 保存每张图对应的 token range 和覆盖行,在每个 prefill chunk 装配 embedding 时,只替换与当前范围相交的部分。
第一步:复现图像预处理的离散规则
V4.1 Flash 的视觉配置使用 14×14 patch、1,024 维视觉 hidden、16 个 attention head 和 32 层 ViT。aligner 按 3×3 网格合并视觉输出,再投影到语言模型的 5,120 维 hidden。单张图最多占用 1,024 个语言 token,过小的图片则先根据最小像素预算等比放大。
图像预处理包含一串离散决策:
- 根据宽高比与 token 上限计算能容纳的最大网格。
- 将目标宽高约束到 patch 与 downsample 对应的网格。
- 按比例执行 PIL
contain语义的 bicubic resize。 - 使用 RGB 127 的灰色在空白方向居中补边。
- 按
(grid_y, grid_x, channel, y, x)展开 patch。 - 将像素从
[0, 255]映射到[-1, 1]。
第一次差异就出现在第三步。通用图像工具里常见的 contain 实现会用 ceil 保证覆盖目标边界,官方路径则根据宽高比分支计算另一条边,并用 round 得到实际尺寸。差一个像素,可能改变补边位置和边界 patch;从此以后所有对应行都不同。
zLLM 因而单独实现这套规划和取整规则,没有把已有的通用 resize helper 直接套进来。图像预处理不是视觉塔外面的普通 I/O,它已经是模型数值定义的一部分。
第二步:用五个中间张量寻找第一个分叉点
仅比较最终生成文本,无法判断误差来自预处理、视觉 attention、aligner 还是语言模型。我们的做法是让官方实现逐段落盘,再让 zLLM 在相同位置导出张量:
| 观测点 | 它能排除或定位什么 |
|---|---|
| patches | resize、补边、归一化和 patch 行序 |
| 2D RoPE cos/sin | 高度与宽度坐标、频率排列与旋转布局 |
| block 0 输出 | patch projection、QKV bias、attention 与残差顺序 |
| final norm 输出 | 32 层误差是否持续放大,以及 RMSNorm 语义 |
| aligner 输出 | 右/下补零、3×3 unfold、通道次序和双线性投影 |
这种方法的关键不是多导出几个文件,而是始终寻找最早出现显著差异的边界。如果 patches 已经不同,就先停在预处理;如果 patches 一致而 block 0 分叉,就检查位置编码与第一个视觉块。这样可以避免拿最终文字反复猜测几十层之前的错误。
zLLM 为此增加了可选的视觉调试输出,并用真实权重建立聚焦测试。测试只运行视觉塔和 aligner,单卡大约一秒即可完成一次对拍,不必每次加载完整八卡语言链路后再观察回答。
第三步:2D RoPE 的 h/w 是分块排列
视觉 attention 使用二维位置。对于网格位置 (h, w),官方实现先构造高度与宽度两组频率,再展平为:
[h·f0, h·f1, ..., h·fn, w·f0, w·f1, ..., w·fn]
早期实现把它理解成了交错布局:
[h·f0, w·f0, h·f1, w·f1, ...]
两者 shape 完全相同,cos² + sin² = 1 之类的基本检查也都会通过,但每个通道获得的空间坐标不同。对拍 cos/sin 后,我们把实现修正为 h/w 分块,并保持官方将向量拆成两半执行旋转的约定。
这也是中间张量比 shape 检查更有价值的例子:排列错误不会造成越界,也不会让数值变成 NaN,只会让模型看到另一套空间。
第四步:aligner 补的是二维网格,不是连续尾行
32 层 ViT 结束后,aligner 将视觉网格右侧和底部补到 3 的倍数,再对每个 3×3 窗口做 unfold。这里曾经把“补零”简化成在扁平张量末尾追加零行。
底部补零时,两种写法碰巧一致;右侧补零时则不同。假设原网格每行有 5 个 patch,补到 6 列后,每一行的第 6 个位置都应为零。只在扁平数组尾部补零,会把第二行的第一个有效 patch 放到第一行的补零位置,之后整张图持续错位。
修正后的实现按 (source_y, source_x) 计算目标 3×3 窗口与窗口内位置,并遵循官方 unfold 的通道主序:
[channel 0 的 9 个位置,
channel 1 的 9 个位置,
...]
当前版本会把 final norm 输出下载到主机,完成这次网格拼装后再上传。单图中转不超过约 15 MB,先换取清晰、可对拍的语义;把拼装改为设备 kernel 是后续独立优化。
第五步:588 列补到 592 列,让硬件约束保持数学等价
一个 14×14 RGB patch 有:
3 × 14 × 14 = 588
588 不是 16 的倍数,而当前 ROCm 矩阵执行路径要求输入列按 16 对齐。这里不能改变 patch 内容或重新解释权重。zLLM 同时在输入 patch 和 projection 权重的尾部补四个零,将 588 列扩展到 592 列:
[x0 ... x587, 0, 0, 0, 0]
×
[w0 ... w587, 0, 0, 0, 0]
新增列对点积贡献恒为零,所以输出与原始 588 维线性层数学等价,同时满足 kernel 的 tile 约束。这类适配应停留在 backend 的准备边界,不能泄漏成模型架构的新维度。
修正 contain 取整、RoPE 布局、aligner 网格拼装和 patch 对齐后,记录中的 aligner 最大相对差异约为 5%。我们把它视为 BF16 执行路径下仍需继续观察的误差范围,而没有宣称逐元素完全一致。
横向对比:DeepSeek V4.1 Flash 与 Qwen3-VL 的视觉塔
zLLM 也接入了 Qwen3-VL。两者都把动态尺寸图片变成 patch,经 ViT 编码并投影到语言模型 hidden,但具体张量契约差异很大。下面以 zLLM 当前的 Qwen3-VL-32B 配置为对照对象;Qwen3-VL 家族其他尺寸和 MoE 版本的语言层规格可能不同。
| 环节 | DeepSeek V4.1 Flash | zLLM 当前 Qwen3-VL-32B 路径 |
|---|---|---|
| patch | 14×14,单图每行 3×14×14=588 列 | 16×16,temporal patch=2,每行 3×2×16×16=1536 列 |
| 视觉塔 | 32 层、hidden 1,024、16 heads、RMSNorm + SwiGLU | 27 层、hidden 1,152、16 heads、LayerNorm + GELU MLP |
| 图像尺寸规划 | 以最多 1,024 个 LLM 图像 token 为目标,contain 后灰色补边 | 以 min/max pixels 做 smart resize,目标边长对齐 patch×merge=32 |
| 视觉位置 | h/w 分块的 2D RoPE | 插值后的学习位置 embedding,再叠加视觉 2D RoPE |
| 空间降采样 | 3×3 aligner,允许右侧/底部补零 | 2×2 merger,预处理先保证网格可整除 |
| 进入语言模型 | 最终 aligner 输出覆盖 IMAGE 行,另有 START/NEWLINE/END 学习向量 | merger 输出覆盖 <image_pad>,由 <vision_start> / <vision_end> 包围 |
| 语言模型位置 | 图像网格序列化后沿文本序列前进 | M-RoPE 为视觉 token 保留 temporal/height/width 三轴位置,并用 rope_delta 衔接后续文本 |
| 多层视觉注入 | 主路径在 embedding 入口注入一次 | DeepStack 从 ViT 第 8、16、24 层取特征,经独立 merger 后继续注入语言模型前部 |
| 当前语言主干 | MoE,图像行使用 bias_vl 双偏置路由 | 32B 接入路径使用 dense MLP,没有图像行 MoE 双偏置 |
两者最直观的共同点,是视觉输出行数必须与语言模型中的图像占位行严格相等。差一行就会让后续文本位置整体错位。它们解决这个问题的方式不同:DeepSeek 在 span 内显式加入每行 NEWLINE,并为 START、NEWLINE、END 准备学习向量;Qwen3-VL 使用独立的 vision 边界 token 和连续的 <image_pad> 区间,同时为整个序列构造三轴 position ids。
Patch 排列体现了各自的下采样方式
DeepSeek 的 patch 按普通二维行主序展开,aligner 到视觉塔末尾才处理 3×3 窗口,因此边缘网格可能需要补零。Qwen3-VL 的预处理会让同一个 2×2 merge group 中的 patch 连续排列,视觉塔结束后可以直接执行空间 merge。对单图,temporal patch 的两个槽位由同一帧填充;对视频,则由相邻两帧组成一个时间 patch。Qwen3-VL 因而从 patch 输入开始就同时保留图像和视频需要的时间维。
这也解释了为什么 DeepSeek 的 588 列需要补到 592,而 Qwen3-VL 的 1,536 列天然满足 16 对齐。对齐问题来自模型 patch 契约与 kernel tile 的组合,不能把一种视觉塔的处理规则复制到另一种模型。
Qwen3-VL 有两层位置语义
DeepSeek 视觉塔直接使用二维 RoPE,输出进入语言模型后按展开后的 span 顺序参与文本位置计算。Qwen3-VL 在视觉塔内部先把一张 48×48 的学习位置表插值到实际图像网格,再应用二维 RoPE;进入语言模型时,又为视觉 token 构造 temporal、height、width 三轴 M-RoPE。文本 token 的三个轴使用相同位置,视觉区间则沿各自的网格坐标推进,rope_delta 负责让图像后的 decode 位置连续。
所以,对拍 Qwen3-VL 时仅检查视觉塔的 cos/sin 还不够,还要检查学习位置表插值、三轴 position ids 和图像结束后的 rope_delta。DeepSeek 本文遇到的 h/w 排列错误属于视觉塔内部;Qwen3-VL 的位置错误还可能发生在视觉输出进入语言模型之后。
DeepStack 让“视觉输出”不再只有一份
DeepSeek 的主路径把最终 aligner embedding 放进图像 span,随后由语言模型逐层处理。Qwen3-VL 除了最终 merger 输出,还从 ViT 第 8、16、24 层各取一份中间特征,通过三个 DeepStack merger 投影到语言 hidden,并在语言模型前部对应层继续相加。
这条路径保留了不同深度的视觉信息:最终视觉特征负责输入 embedding,中间视觉特征继续修正早期语言 hidden。工程上必须把四份结果作为一个整体管理——一份主 embedding 和三份 DeepStack feature。只接最终 merger,形状与文本生成链路仍可能正常,但空间定位和细节感知会失去模型原本依赖的中间层信息。Qwen 官方也将 DeepStack 描述为融合多级 ViT 特征的核心架构更新。Qwen3-VL 官方仓库
zLLM 共享算子能力,保留模型自己的编排
两条路径可以共享图像解码、动态尺寸预算、patch tensor、视觉 attention、线性层、空间 merge 和 embedding scatter 等 backend capability。但 resize 规则、patch 行序、位置编码、merger 布局、span 协议与注入层次仍由各自的 model runtime 编排。
这种边界与第二篇介绍的格式无关实现一致:共享的是稳定的执行能力,模型语义在准备与编排层明确表达。视觉塔看起来都像“ViT + projector”,实现时仍需逐项对齐它们真实的数据契约。
第六步:图像 span 必须改变 Engram 的序列语义
第一篇介绍过 Engram 如何从连续 token 的 n-gram 哈希中检索记忆。图像 span 的所有位置共享同一个 token id;如果直接把它们推入哈希窗口,会制造大量没有语言意义的重复 n-gram,还可能让图像前后的文本跨过整段图片错误连接。
zLLM 按官方语义把图像位置推入 DEAD 状态,阻断跨图像 span 的 n-gram。Engram 注入层还需要一份逐行 image_mask:文本行执行检索和门控,图像行关闭 Engram 门控,让视觉 embedding 直接通过。输出阶段如果采样到了内部 image token,也会立即截断,避免把协议内部标记输出并回喂到下一轮。
这说明图像接入不只是在 embedding 表里替换几行。任何依赖 token 序列的状态机,包括 n-gram、KV cache、位置与输出过滤,都必须知道这些行的类型。
第七步:图像行和文本行使用不同的 MoE 路由偏置
视觉特征进入 40 层语言模型后,还要经过 MoE。每个 token 不会执行全部专家,而是由 router 从专家集合中选出 Top-K,再把这些专家的输出加权合并。
“双偏置路由”中的“双”,指 checkpoint 为同一个 router 提供了两张专家校准表:文本行使用 bias,图像 span 使用 bias_vl。它不表示把两份 bias 同时相加,也不表示执行两套 router。router 权重、专家网络和 Top-K 流程仍然只有一套,运行时根据当前行的类型选择其中一份 bias。
以专家 e 和一行 hidden state h 为例,V4.1 的路由可以简化成五步:
原始 logit: l_e = h · W_router[e]
无偏路由分数: r_e = sqrt(softplus(l_e))
当前行使用的偏置: b_e = text ? bias[e] : bias_vl[e]
专家选择: TopK(r_e + b_e)
专家混合权重: r_e / sum(r_selected) × route_scale
偏置只参与 Top-K 排名,最终混合权重仍由选中专家的无偏分数 r_e 计算。这样可以调整“哪些专家更容易被某类 token 选中”,同时不把 correction bias 注入专家输出的数值权重。
一个四专家、Top-2 的简化例子更直观:
| 专家 | 原始分数 r | 文本 bias | 图像 bias_vl |
|---|---|---|---|
| E0 | 0.60 | 0.00 | 0.00 |
| E1 | 0.55 | 0.10 | 0.00 |
| E2 | 0.50 | 0.00 | 0.20 |
| E3 | 0.45 | 0.00 | 0.00 |
文本行按 r + bias 选中 E1 与 E0,图像行则按 r + bias_vl 选中 E2 与 E0。图像行对 E2 的实际混合权重仍使用原始的 0.50,而不是加过偏置的 0.70。双偏置因此表达的是按模态校准专家集合,而不是按模态放大专家输出。
如果图像行继续使用文本 bias,矩阵形状、专家数量和输出维度都仍然合法,但视觉 token 会被送往另一组专家。这与 RoPE 排列错误很相似:系统可以稳定运行,语义已经偏离模型训练时的路径。
zLLM 将 router_bias_vl 和逐行 image_rows mask 从模型权重一直传到通用 MoE 接口,再由 ROCm kernel 为每一行选择对应 bias。同一个 prefill batch 可以同时包含文本行与图像行,因此选择发生在行级,不能为整批只设置一种 bias。纯文本模型或没有第二组 bias 的路径继续使用原接口语义。CPU reference 与 HIP kernel 使用文本、图像交错的输入逐行对拍,同时检查两件事:专家 ID 必须匹配,route weight 也必须保持官方的无偏归一化规则。
端到端结果,以及它能证明什么
完成视觉张量修正和双偏置路由后,八卡端到端请求取得了几项直观结果:
| 输入与提问 | 实际结果 |
|---|---|
| 胡萝卜示例图,开放描述 | 回答识别为“一堆胡萝卜” |
| 玉米示例图,英文识别 | 回答包含“fresh corn” |
| 玉米细节描述,补齐双偏置前后 | 输出由 10 token 增至 46 token |
| 胡萝卜图片,中文数量提问 | 正确回答“五根胡萝卜” |
这些结果证明 HTTP、预处理、视觉塔、span、语言主干与路由已经连成一条可用链路,也显示 bias_vl 对图像 token 的专家选择有实际影响。它们是工程验收样例,不能替代覆盖 OCR、图表、空间关系、细粒度识别等任务的定量视觉评测。
当前仍有三个明确边界:部分图片在开放式英文提示下回答偏短;aligner 网格拼装仍有主机中转;zLLM 的 GELU 使用 tanh 近似,而官方视觉实现使用 erf,局部差异约在 1e-3。完整模型的统计对齐与稳定多模态吞吐还需要继续验收。
多模态接入真正困难的地方,是每个“看起来差不多”的细节都会被后续网络放大。可靠的方法是把链路切成可验证的数学边界:先对齐像素与 patch,再对齐位置和视觉层,随后对齐网格与语言 embedding,最后检查进入 MoE 后的逐行路由。这样得到的不只是一次能出字的演示,而是一条能够继续优化的工程基线。
下一篇将汇总当前性能数据,说明 50K prefill、连续 decode 和提前投影收益分别采用什么测量口径,以及哪些数字仍不能放在同一张对比表里。
工程依据:zLLM 的 DeepSeek V4.1 Flash 接入记录、当前 Qwen3-VL runtime,以及 69ff88ad、ae6248a5 和 51f83af8 的实际改动。模型语义参考 DeepSeek 官方的 vision.py、image_processor.py 与 Qwen3-VL 官方仓库。
