让 llama.cpp 支持多模态向量模型

几周前,我们发布了 jina-embeddings-v4 模型的 GGUF 版本,大幅降低了显存占用,提升了运行效率。不过,受限于 llama.cpp 上游版本的运行时,当时的 GGUF 模型只能当作文本向量模型使用而无法支持多模态向量的输出。

为此,我们深入 llama.cpp 与 GGUF 的技术腹地,最终成功打通了整条链路,让在 GGUF 格式里生成多模态向量成为可能。完整的操作指南已在我们的 README 里开源:https://github.com/jina-ai/llama.cpp/tree/master/jina_embeddings

熟悉的朋友可能立刻就要问,llama.cpp 不是本身就支持多模态输入吗?

是的,但关键在于支持的方向。llama.cpp 的研发重心长期围绕大语言模型与文本生成,在多模态向量上完全是空白。

本文将详细解读我们是如何一步步为 llama.cpp 中补全这一能力,同时,我们也会将其性能(包括两个量化版本)与 PyTorch 版的 jina-embeddings-v4,也就是我们后文将反复提及的参考模型,进行全面的性能对比。

理解 Llama.cpp 中的图像输入机制

要理解我们的工作,首先需要了解多模态向量是如何工作的。以 Pytorch 的参考版本为例,模型先让每个图像输入与一个特殊的提示词 (prompt) 配对,比如下面这样:

go 复制代码
<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n

随后,模型会像处理流水线一样,一口气地完成三项任务:预处理图像、调用 ViT 组件编码图像信息、最后在一次完整的前向传播中,同步理解整个图文交错的序列。这就有点像我们人眼,从头到尾扫过去,文字和图片就都理解了。

但是 llama.cpp 在处理向量任务时,面临一个核心限制:它的内部管线是分离的,文本和图像必须分头处理,无法在一次运算中同时消化。

为此,我们专门 fork 了 llama.cpp,改造了它的向量处理器,设计了一套分阶段的处理流程,让它能够直接接收 Base64 编码的图像。这一改动,为我们后续实现分步处理多模态内容铺平了道路。

现在,在我们改造后的 llama.cpp 中,我们依然从一个相似的提示词开始,但流程变得更加精巧:

go 复制代码
<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n

整个处理流程被分解为以下五个步骤:

1. 添加视觉边界

首先,llama.cpp 捕捉到 <__image__> 词元,并自动用视觉指令 <|vision_start|><|vision_end|> 将其包裹起来,形成一个完整的处理单元。组合成类似这样的结构:<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n,清晰地界定了图像信息的处理范围。

2. 转换内部信号

紧接着,当分词器处理这段加好词元的提示词时,会将 <__image__> 这个特殊词元转换为内部信号 -1,用以告知模型:序列中包含一张图像,需要调用视觉模块(ViT)进行预编码。

3. 处理前置文本

现在,LLM 先处理 <__image__> 之前的所有文本词元(即 <|im_start|>user\n<|vision_start|>),LLM 将对这部分文本的理解,以计算状态的形式注入 KVCache。

4. 编码图像并关联上下文

随后 ViT 负责将原始图像编码成一系列 LLM 能够理解的图像词元。随后,LLM 立即对这些新生成的图像词元进行解码。在解码过程中,注意力机制借助 KVCache,把图像信息与前文语境关联起来。但请注意,此时 LLM 的视野是受限的,它只能看到过去(前文),无法预知未来(即 <|vision_end|> 之后的文本)。

5. 处理后置文本并融会贯通

最后,LLM 解码剩余的文本词元(即 <|end_vision|>Describe the image.<|im_end|>\n)。至此,借助 KVCache,注意力层终于可以贯通全局,同时关注到序列中所有的文本和图像信息,形成对整个图文序列的最终理解。

下图直观地展示了这一系列环环相扣的向量推理过程,包括图像编码和图文词元的解码步骤:
jina-embeddings-v4 llama.cpp 版本的向量推理流程

对图像词元的注意力机制

由于注意力机制的工作方式,上述的多步处理流程可能会给某些模型带来问题。我们先简单回顾一下模型中常见的几种注意力类型:

  • 因果注意力 (Causal attention):这种机制严格遵守时间顺序,它天生就是向后看的。在处理位置k的词元时,注意力机制只关注之前位置[0:k-1]的词元。像是读一本书,读到第 k 页时,只能回顾前面 0 到 k-1 页的内容。

  • 非因果注意力 (Non-causal attention) 这种机制则拥有全局视野,在处理位置 k 的词元时,注意力机制会关注序列中所有位置[0:n]的词元。好比看一幅画,在观察任意一个细节时,都能同时看到整幅画的全貌。

下图清晰地展示了在处理第二个步骤中的img_tok_n时,两种注意力机制分别会关注哪些词元:
因果注意力与非因果注意力的对比

现在,让我们在模型处理图像的瞬间按下暂停键,看看当它处理到第 n 个图像词元(img_tok_n)时,内部是什么状态:

  • 历史信息:所有先前的文本词元(<|im_start|>user\n<|vision_start|>)均已处理完毕,并存入了 KVCache

  • 当前任务:所有图像词元(从 img_tok_1img_tok_n),正在作为当前序列被逐一处理。

  • 未来信息:所有后续的文本词元(<|vision_end|>Describe 等)还静静地躺在队列中,等待处理。

在这里,两种注意力机制的根本矛盾就出现了:

对于因果注意力而言,一切都顺理成章。在处理img_tok_n 时,模型只考虑之前的词元,而这些词元的状态都已完备地保存在 KVCache 中,模型可以直接调用。因此,我们的分步处理流程对它来说畅通无阻。

但对于非因果注意力,它的设计原则要求它必须能同时看到序列里过去、现在和未来的所有词元。但在我们的流程中,未来的文本词元(<|vision_end|>Describe 等)此刻尚未被处理,模型无法获取这些信息。这等于直接破坏了它的工作前提,导致整个流程必然会因信息不完整而中断。

幸运的是,我们的 jina-embeddings-v4 模型,其内部恰好采用的是因果注意力机制。这使得我们的多步处理方案得以完美兼容,顺利运行。但对于其他采用不同注意力机制的模型,此方案则可能不适用。

至于最终的向量生成,模型会输出序列中每一个输入词元所对应的最终隐藏状态 (final hidden state)。当前,像池化 (Pooling) 和归一化 (Normalization) 这类用于生成最终句向量的收尾工作,我们暂时放在 Python 端执行,但未来稍加开发,便可将它们也整合到 llama.cpp 内部。

我们进行的修复工作

当我们在 llama.cpp 服务器上为 Embeddings Endpoint 打通了图像输入功能后,我们满怀期待地启动了基准测试。然而,测试结果却给了我们当头一棒:它与参考模型的输出存在着巨大差异。

我们立刻怀疑问题出在 llama.cpp 对 ViT 的实现上。ViT 是 Qwen2.5-VL 模型中的视觉核心,它负责将图像编码成图像块向量(一种大语言模型能够理解的密集向量表示),以便后续的 Qwen2.5 LLM 进行处理。如果这个编码过程出了岔子,那么后续的一切就会谬以千里。

下面的数据直观地展示了两者输出的 ViT 结果有多么不同:

go 复制代码
=== vit_out reference === Shape: [1008, 1280]
Logging patch 0, dimensions 0-9
Patch 0: -0.375000 -0.250000 -4.281250 -5.968750 2.953125 -8.125000 8.625000 -9.250000 8.937500 -0.332031 ... (dims 10-1279)
... (patches 1-1007 not shown)
go 复制代码
=== vit_out llama.cpp === Shape: [1280, 1008, 1, 1]
Logging patch 0, dimensions 0-9
Patch 0: -2.998136 -2.226554 0.233671 -7.486460 0.596918 -12.889042 8.904849 -8.6
... (patches 1-1007 not shown)

可以看出,两者的数值差异巨大,甚至连数据结构(Shape)都完全不同,这印证了我们的初步猜想。但严谨的工程需要刨根问底:问题仅仅出在 ViT 身上吗?

为了精准定位问题,我们设计了一个隔离实验:我们尝试在 Python 环境中预先计算出图像词元,拿着这份由参考模型生成的标准答案,直接喂给 llama.cpp 的 Qwen2.5 大语言模型部分去解码。

我们原本预期,绕过了 llama.cpp 的 ViT 之后,最终的向量会与参考模型高度吻合。然而,结果再次出乎我们的意料,差异依然存在。这说明,问题比我们想象得更深,除了 ViT,LLM 的处理环节中也有问题。

修复 1:修正因果注意力掩码

既然 LLM 的处理环节也存在问题,我们继续深挖,将目光锁定在最有可能导致数据差异的注意力层。经过细致排查,我们终于揪出了问题的根源:在处理图像词元时,注意力层所使用的因果注意力掩码计算有误。

让我们回到之前的示例序列里,看看错误是如何发生的。当模型处理图像词元时,内部的序列会展开成这个样子:

go 复制代码
<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last <|vision_end|>Describe the image.<|im_end|>\n

⚠️ 说明:在当前的处理流程里,无论是文本 <|im_start|>、user 还是图像词元 img_tok_1、img_tok_2,它们都已是向量化的密集表示,不是原始的文本字符。为了让解释更简单,我们才沿用了这种序列的写法。

因果注意力的核心原则是"严格遵守时间顺序"。因此,按照设计,当模型解码第二个图像词元 img_tok_2 时,它的注意力只应该关注到所有在此之前出现过的词元,即:

go 复制代码
<|im_start|>user\n<|vision_start|> img_tok_1

然而,注意力掩码中的一个错误,却导致机制关注了整个图像序列,它的视野变成了这样:

go 复制代码
<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last

这样就彻底破坏了因果依赖关系,导致计算结果出现严重偏差。

我们修复了这个掩码错误,强制注意力机制回归到严格的因果顺序上。修复之后,我们再次进行了那个隔离实验,将参考模型 ViT 输出的"标准答案"喂给 llama.cpp 的 LLM 部分。

这一次,结果终于与我们的预期一致:llama.cpp 生成的向量与参考模型的向量高度匹配,误差被控制在了极小的范围内。至此,我们确认已成功修复了 LLM 处理环节的漏洞。

修复 2:优化图像处理与图像块向量

攻克了 LLM 的难题后,我们回过头来,全力解决 ViT 编码器的差异问题。我们追溯了问题的源头,发现数值从预处理阶段一开始就出现了偏差。并且在初始的图像分块 (patch-creation) 步骤中表现得尤为突出。在这个步骤中,参考模型和 llama.cpp 都会把原始的图像分割成多个图像块(patches),再通过卷积层进行编码。

下面的数据对比说明了,在 ViT 进行处理之前,两者生成的原始图像块就已大相径庭:

go 复制代码
=== raw_patches reference === Shape: [1008, 1176]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.484375 0.484375 0.500000 0.500000 0.470703 0.470703 0.470703 0.484375 0.470703 0.484375 ... (dims 10-1175)
... (patches 1-1007 not shown)
go 复制代码
=== raw_patches llama.cpp === Shape: [1176, 1008, 1, 1]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.455895 0.455895 0.455895 0.455895 0.455895 0.455895 0.470494 0.470494 0.470494 0.470494 ... (dims 10-1175)
... (patches 1-1007 not shown)

深入探究后我们发现,两种实现走了完全不同的技术路线:

  • 参考模型:运用一系列重塑(reshape)操作对像素进行分组,然后调用单个 conv3d (三维卷积) 层来统一编码。

  • llama.cpp 模型:通过两个 conv2d (二维卷积) 层的堆叠来创建和编码图像块。

与其深入调试 llama.cpp 复杂的双层 conv2d 实现,我们选择了一条更直接的路径:精确复刻参考模型的操作,从根源上统一两者行为。

然而,复刻也没那么顺利。我们很快遇到了第一个障碍:参考模型生成像素块时,需要进行复杂的 reshape 和 transpose (转置)操作,这要求张量支持高达 9 个维度。而 llama.cpp 的底层张量库 ggml 无法支持这个操作。

为了绕开这个限制,我们设计了一个巧妙的变通方案:将图像分块这一步剥离出来,用一个独立的 Python 服务来完成,然后通过 HTTP 将预处理好的图像块发送给 llama.cpp 服务器。

但第二个障碍又接踵而至:ggml 同样不支持 conv3d 层。通过分析参考模型中 conv3d 层的配置,我们发现了一个关键突破口:

go 复制代码
kernel_size = [
 2,  # temporal_patch_size, 
14, # patch_size
14# patch_size
]

proj = nn.Conv3d(
3, # in_channels
1152, # embed_dim, 
 kernel_size=kernel_size, 
 stride=kernel_size, 
 bias=False
)

可以看到,stride (步长)和 kernel_size(卷积核大小)是相同的,这就意味着,我们可以直接将 conv3d 层的输入和权重都展平,然后执行一个简单的矩阵乘法操作来替代它。

为了实现这一步,我们兵分两路:

1. 改造模型转换脚本

先是修改了 llama.cpp 中的模型转换脚本 (convert_hf_to_gguf.py),让它在导出模型时,额外生成一份展平后的 conv3d 权重,专为矩阵乘法准备。

go 复制代码
if 'patch_embed.proj.weight' in name:
 c1, c2, kt, kh, kw = data_torch.shape
 # 注意:脚本的这部分也会导出该层的其他版本
 # 此处仅展示相关部分
 # 用于矩阵乘法的扁平化权重:行主序 [输出维度, 输入维度*kT*kH*kW] = [向量维度, 1176]
 W_flat = data_torch.contiguous().view(c1, -1)
 outputs.append(("v.patch_embd.weight_flat", W_flat))

2. 修改 llama.cpp 计算图

接着,我们修改了 llama.cpp 中构建 Qwen2.5-VL ViT 计算图的核心代码,增加了一个条件分支:如果输入的是我们预处理好的图像块,就绕开原有的 conv2d 路径,转而执行我们准备好的矩阵乘法操作。

go 复制代码
ggml_tensor * build_inp_raw_precomputed() {
  ggml_tensor * inp_raw = ggml_new_tensor_2d(
  ctx0, 
  GGML_TYPE_F32, 
  img.p_dim, 
  img.npx * img.npy
 );
  ggml_set_name(inp_raw, "inp_raw");
  ggml_set_input(inp_raw);
return inp_raw;
}

ggml_cgraph * build_qwen2vl() {
// 注意:此处仅展示我们为使用预先排列好的图像词块而添加的代码
constbool uses_precomputed_image = img.is_precomputed;
  ggml_tensor * inp = nullptr;
if (uses_precomputed_image) {
      ggml_tensor * inp_raw = build_inp_raw_precomputed();
      cb(inp_raw, "inp_raw", -1);
      inp = ggml_mul_mat(ctx0, model.patch_embeddings_flat, inp_raw);
 } else {
// 常规的 2x conv2d 路径
 }
// 其余代码
}

经过这一系列改造,我们终于扫清了所有障碍。最终,图像向量与参考模型的误差成功控制在了 2% 以内,这在下一节的评估数据中得到了充分验证。

性能评估

完成上述修改后,我们使用 MTEB 基准测试在 ViDoRe 任务上,对我们修复后的 llama.cpp 模型,和参考模型进行了评估。为了考察其在资源受限环境下的表现,我们还引入了两个不同级别的量化版本一同参评。相关的复现脚本与说明,均已在我们的 llama.cpp 分支中开源。

🔗:https://github.com/jina-ai/llama.cpp/tree/master/jina_embeddings

任务 参考模型 llama.cpp (F16) llama.cpp (Q4_K_M) llama.cpp (IQ4_XS)
VidoreArxivQARetrieval 83.55 85.00 84.38 84.34
VidoreDocVQARetrieval 50.53 52.02 51.93 51.57
VidoreInfoVQARetrieval 87.77 87.31 87.61 87.28
VidoreShiftProjectRetrieval 84.07 82.25 82.56 81.73
VidoreSyntheticDocQAAIRetrieval 97.52 96.71 97.28 97.15
VidoreSyntheticDocQAEnergyRetrieval 91.22 90.34 90.47 90.30
VidoreSyntheticDocQAGovernmentReportsRetrieval 91.61 93.84 93.47 94.47
VidoreSyntheticDocQAHealthcareIndustryRetrieval 95.42 96.08 95.67 96.05
VidoreTabfquadRetrieval 94.52 94.94 94.83 94.72
VidoreTatdqaRetrieval 65.52 64.85 64.63 64.76
平均分 84.17 84.33 84.28 84.23

从上表的结果可以看出,从平均分来看,llama.cpp 的各个版本,包括经过大幅压缩的量化模型,都与参考模型旗鼓相当,性能几乎没有折损。这有力地证明了我们的修复工作取得了成功。

为了进一步深挖两者之间可能存在的细微差异,我们还选取了来自不同领域、不同分辨率的图像,将两者输出的图像块向量(在池化与归一化之前)进行逐一对比,并将其间的余弦距离绘制成了热力图。

图中,颜色越红的区域,就代表两者在该图像块上的认知差异越大。
jina-embeddings-v4 技术报告页面,左:372×526 分辨率,右:2481×3508 分辨率 Jina AI 网站截图,左:594×428 分辨率,右:1982×1428 分辨率 东京涩谷夜景,左:383×255 分辨率,右:5472×3649 分辨率

我们希望通过这种可视化的方式,揪出任何潜在的、系统性的算法缺陷,但热力图上并没有出现指向任何特定模式的异常。

我们唯一观察到的趋势是,随着图像分辨率的提升,存在微小差异的图像块数量会相应增多。

我们推断,不是 Qwen2.5-VLjina-embeddings-v4 的骨干模型)的实现本身存在问题,很可能只是因为后端实现的细微不同,例如底层计算库、硬件加速或浮点运算精度上的差异。

尽管 llama.cpp 模型生成的向量与参考模型存在极其微小的差异,但基准测试的结果也证明了,这些差异在实际应用中几乎不影响最终的性能表现。

综上,我们成功地使 llama.cpp 模型在性能上和参考模型高度对齐,同时证明了向量层面的微小差异在下游任务中可以忽略不计。

待解决的问题

虽然我们取得了显著进展,但在 llama.cpp 中实现多模态向量还有很多值得做的,我们规划了几个关键的后续改进方向:

  • 端到端量化:将视觉编码器纳入量化范畴 :目前,我们的 llama.cpp 仅支持对大语言模型进行量化。为了实现更好的扩展性,下一步的关键就是将量化技术同样应用于视觉编码器,实现端到端的低资源部署。

  • 将视觉编码器分离为独立服务:视觉编码器的工作模式(采用非因果注意力)决定了它必须在一次前向传播中处理完单张图像,无法利用连续批处理,限制了吞吐量。我们计划将其解耦为一个独立的微服务。这样一来,就可以将来自不同源的多张图像批量组合在一起,通过动态批处理一次前向传播完成所有编码。这虽然会增加显存需求,但能大幅提升图像编码的整体吞吐量。更重要的是,架构解耦将允许视觉和语言模块根据负载独立扩展,为生产环境部署提供了极大的灵活性。

  • 支持多向量输出 :本文中,我们只处理了单向量输出。但为了充分发挥 jina-embeddings-v4 的能力,我们还希望启用多向量输出,模型将能捕捉更丰富的语义层次,从而在处理复杂视觉任务时,实现准确性的显著提升。而且这些向量只需在基础模型之上增加一个线性层即可生成,因此实现起来相对容易。

结论

本次工作中,我们成功地在 llama.cpp 中集成了高性能的多模态向量功能,其表现在一系列基准测试中,已能与 PyTorch 参考模型并驾齐驱。

我们通过精准定位并修复注意力掩码与图像分块处理中的核心缺陷,从根本上消除了主要的性能偏差。实验证明,即便是经过大幅压缩的量化版本,也能在消耗更少资源的同时,维持高度可比的准确率。对于在高分辨率图像上残留的微小数值差异,我们将其归因于后端实现的细微不同,而非核心算法问题。

展望未来,通过将量化技术延伸至视觉编码器、以独立服务实现批处理,并支持多向量输出,我们将能进一步提升 llama.cpp 在多模态任务上的效率与精度。这些改进环环相扣,将共同推动该方案变得更具扩展性,更好地服务于真实世界的复杂应用场景。

相关推荐
wyw00002 小时前
大模型微调之LLaMA-Factory实战
llama
2202_756749692 小时前
LLM大模型-大模型微调(常见微调方法、LoRA原理与实战、LLaMA-Factory工具部署与训练、模型量化QLoRA)
人工智能·深度学习·llama
JoannaJuanCV2 小时前
大模型训练框架:LLaMA-Factory框架
llama·大模型训练·llama factory
骑士9991113 天前
llama_factory 安装以及大模型微调
llama
周小码3 天前
llama-stack实战:Python构建Llama应用的可组合开发框架(8k星)
开发语言·python·llama
blackoon886 天前
DeepSeek R1大模型微调实战-llama-factory的模型下载与训练
llama
johnny2336 天前
大模型微调理论、实战:LLaMA-Factory、Unsloth
llama
闲看云起6 天前
从 GPT 到 LLaMA:解密 LLM 的核心架构——Decoder-Only 模型
gpt·架构·llama
小草cys8 天前
在树莓派集群上部署 Distributed Llama (Qwen 3 14B) 详细指南
python·llama·树莓派·qwen