到这里为止,你脑子里的 LLM 还只会读文字:一段 prompt 切成 token、查 embedding、过几十层 transformer。可现在的模型动不动就能"看图说话"——你发一张图,它就能描述、问答、读表格。它是怎么把"一张图"塞进一个只认 token 的模型里的?答案出乎意料地朴素:LLM 的输入层根本不在乎喂进来的 embedding 向量是从文字来的还是从图像来的。多模态要做的,就是把"一张图"也变成一串 embedding,和文本 token 的 embedding 拼在同一个序列里,一起送进 llama_decode。
这一课看 llama.cpp 的 mtmd(multimodal)子系统怎么干这件事。核心只有一句:模型主体一个字都不用改,多模态全靠在它前面接一段"翻译"流水——视觉编码器(clip / ViT)把图像压成视觉特征,projector(mmproj)再把这些特征投影到 LLM 的 embedding 空间,得到 N 个"看起来就像 token embedding"的向量,按 prompt 里 <image> 占位的地方插进序列。LLM 拿到这串向量,根本分不出哪些来自文字、哪些来自图像——它只管照常往下算。这一点初看会让人愣一下:模型明明"看懂"了图,怎么会"不知道那是图"?但这恰恰是这套设计最聪明的地方——把"看懂"这件事完全外包给了前面的视觉流水,LLM 只负责它最擅长的那件事:"在向量序列上做推理"。
路线图:先看整条 mtmd 管线(切 chunk -> 编码 -> 取 embedding -> 和文本交织 decode),配一张追踪图看"一张图进 LLM";再单独讲 projector 这座桥为什么不可少;最后两个折叠深挖 clip 的 ViT 内部,以及图像 embedding 在序列里怎么"占位置"、怎么进 KV cache。
先把整条流水看一遍。用户给的是"图文混排"的输入——一段带 <image> 标记的文字,外加一张(或几张)图的像素。mtmd 把它走成五步:(1) mtmd_init_from_file 加载 projector(那个单独的 mmproj 文件);(2) mtmd_tokenize 把输入切成一串 chunk——文字段落是 TEXT chunk,每张图变成一个 IMAGE chunk(音频则是 AUDIO);(3) 对每个 image chunk 跑 mtmd_encode_chunk(内部就是 clip + projector);(4) mtmd_get_output_embd 取出编码好的视觉 embedding;(5) 把 text chunk 的 token 和 image chunk 的 embedding按原顺序交织,一段段喂进 llama_decode。先看切 chunk 这一步:
// 把"文字 + <image> 标记 + 图像 bitmap"切成有序 chunk (简化自 tools/mtmd/mtmd.h) mtmd_input_chunks * chunks = mtmd_input_chunks_init(); mtmd_tokenize(mtmd_ctx, chunks, &text, bitmaps, n_bitmaps); // chunks 里现在是按原文顺序排好的一串: // [TEXT "这张图是"] [IMAGE 一张图] [TEXT "里面有什么?"] // 每个 chunk 带一个类型: TEXT / IMAGE / AUDIO
注意 prompt 里那个 <image>(源码里默认标记其实是 <__media__>):它就是个占位符,告诉 mtmd"这张图该插在文字的哪个位置"。源码里那段注释举的例子很直白:形如"here is an image: <__media__> ..."这样一句,会被切成三个 chunk——标记前的文字、图像本身、标记后的文字,顺序和原文严丝合缝。切完 chunk,真正的重头戏是把 image chunk 编码成 embedding、再和文字交织着 decode。llama.cpp 把这套逻辑打包进了一个 helper,它的注释几乎就是整条管线的伪代码:
// 逐个 chunk: 文字直接 decode, 图像先 encode 再 decode (简化自 mtmd-helper.cpp) for (each chunk : chunks) { if (type(chunk) == MTMD_INPUT_CHUNK_TYPE_TEXT) { llama_decode(lctx, batch_of(chunk.tokens)); // 文字: 老路, 查 embedding 再算 } else { // IMAGE / AUDIO chunk: mtmd_encode_chunk(mtmd_ctx, chunk); // 1) 内部跑 clip(ViT) + projector float * embd = mtmd_get_output_embd(mtmd_ctx); // 2) 取出 N 个视觉 embedding llama_decode(lctx, batch_of_embd(embd)); // 3) 直接把 embedding 喂进去 } }
看出门道了吗?文字和图像最后都落到同一个 llama_decode 上——区别只在"喂进去的是 token(让模型自己查 embedding)还是已经算好的 embedding(直接用)"。llama_batch 早就同时支持这两种输入(还记得 L18 那个 batch 里既能放 token 也能放 embd 吗?),所以图像 embedding 能无缝地塞进序列,模型主体一行都不用改。这套设计的妙处在于复用:mtmd 没有为图像另起一条推理通路,而是把图像"伪装"成 embedding、复用了文本那一整套 batch、KV cache、采样逻辑——多模态于是变成了一个"前处理"问题,而不是"重写引擎"问题。把"一张图进 LLM"的全过程定格成一条流水:
token id 序列 -> llama_decode 内部查 embedding 表 -> 算。走的是 L04/L20 的老路。
像素 -> clip + projector 算出 embedding -> 直接把 embedding 喂进 llama_decode。embedding 已备好,跳过查表。
值得一提的是,这套"切 chunk -> 编码 -> 交织"的机制对音频一视同仁:把语音切成帧、过一个音频编码器(如 Whisper 的前端)、再过 projector 投影成 embedding,走的是和图像完全相同的通路——这就是为什么 mtmd 的 chunk 类型里 AUDIO 和 IMAGE 并列。换句话说,mtmd 的设计目标从一开始就不是"支持图像",而是"支持任意能被编码成 embedding 的模态"。理解了图像这一条,音频、乃至将来更多模态,都是同一个模子里刻出来的。
上面那步 mtmd_encode_chunk 内部分两半:先 clip(ViT) 把图像"看"成视觉特征,再 projector 把特征"翻译"成 LLM 能读的 embedding。为什么非要这第二步?因为 clip 输出的视觉特征,和 LLM 的 token embedding根本不在一个空间——维度可能不一样(clip 也许输出 1024 维,LLM 要 4096 维),数值分布、语义含义更是两套体系。直接把 clip 的输出塞进 LLM,就像把一段没翻译的外文丢给只懂中文的专家,他只会一脸茫然。projector(就是那个单独的 mmproj 文件)就是这座桥:一个小网络,把视觉特征投影到 LLM 的 embedding 维度和空间,让它"看起来、用起来都像一个 token embedding"。打个比方,clip 的输出像一段"视觉速记",每个数字的含义是按视觉任务编排的;LLM 的 embedding 空间则是按语言任务长出来的,同样长度的向量,"坐标系"也完全不同。projector 干的就是坐标变换:把视觉速记重新表达进语言的坐标系里,让"图里有只猫"这件事,落在 LLM 一向用来表示"猫"的那片向量空间附近。
两个关键的"对齐"由两个函数把关:clip_n_mmproj_embd(ctx) 返回 projector 的输出维度——它必须等于 LLM 的 embedding 维度,否则向量塞不进序列;clip_n_output_tokens(ctx, img) 返回这一张图会占几个 embedding token(也就是前面 trace 里那个 N)。这个 N 不是随便定的:图越大、patch 越多,N 越大;有些 projector(resampler 类)还会主动压缩 N,把几百个 patch 特征汇聚成几十个 embedding,省 KV cache、也省算力。这个 N 直接决定了一张图的"开销":N 个 embedding 就要占 N 个序列位置、写 N 份 KV——所以同样一张图,projector 把它压成 64 个 embedding 还是铺成 576 个,对显存和速度的影响是数量级的。这也是高分辨率多模态模型的核心权衡之一:看得越细(patch 越多、N 越大)越准,但序列越长、越慢、越吃显存。
// projector 内部: clip 先编码, 投影维度由 mmproj 决定 (简化自 tools/mtmd/clip.h) int n_embd = clip_n_mmproj_embd(clip_ctx); // projector 输出维度 == LLM embedding 维度 int n_tokens = clip_n_output_tokens(clip_ctx, img); // 这张图占几个 embedding token std::vector<float> out_vec; clip_image_encode(clip_ctx, n_threads, img, out_vec); // 跑 ViT + projector, 输出 n_tokens x n_embd // out_vec 现在是 n_tokens 个、每个 n_embd 维的视觉 embedding, // 形状和 n_tokens 个 token 的 embedding 完全一样 -> 直接进序列
常见的 projector 有三档复杂度:线性层(一个矩阵乘,最简单,早期 LLaVA 用)、两层 MLP(多一层非线性,对齐更好,现在很常见)、resampler / cross-attention(用一组可学习 query 把变长的 patch 特征"重采样"成固定个数的 embedding,能压 N、也能处理任意分辨率,Qwen-VL 等用)。选哪一档是精度和成本的权衡:线性最省但表达力弱,resampler 最灵活但自己也带一摞参数和算力。不管哪一档,它的职责都一样:把视觉特征对齐到 LLM 的 embedding 空间。这也是为什么 mmproj 是个单独的文件、要单独加载——它是"某个视觉编码器 + 某个 LLM"这对组合专门训练出来的桥,换一个 LLM 或换一个 clip,桥就得重训。理解这一点,你就明白为什么下载多模态模型时,除了主模型那个大 GGUF,还得配一个小小的 mmproj 文件:少了那座桥,模型就只剩"读字"的本事,"看图"的能力无从谈起。一个实用的小知识:HuggingFace 上多模态模型的 GGUF 仓库里,那个名字带 mmproj 的小文件就是它,通常几百 MB 量级,千万别漏下。
| projector 类型 | 结构 | 特点 | 代表 |
|---|---|---|---|
| 线性层 | 一个矩阵乘 | 最省,表达力弱,固定 N | 早期 LLaVA |
| 两层 MLP | 线性 + 非线性 + 线性 | 对齐更好,现在常见 | LLaVA-1.5+ |
| resampler | 可学习 query + cross-attention | 能压 N、处理任意分辨率 | Qwen-VL |
两个折叠,补两个真要落地多模态时绕不开的细节。
本课把 clip 当黑盒——给它一张图,它吐出一串视觉特征。掀开看,clip 的视觉编码器就是一个标准的 Vision Transformer(ViT),和你前几部分学的文本 transformer 几乎一个套路,只是把"token"换成了"图像 patch":(1) 把图切成固定大小的小块(patch,比如 14x14 像素一块),每块拉平、线性投影成一个向量——这就是图像版的"embedding";(2) 加上位置编码(告诉模型每块在原图的哪个位置);(3) 过若干层自注意力 + FFN,让每个 patch"看到"全图、聚合出语义。最后每个 patch 对应一个输出向量,合起来就是那串视觉特征。所以一张 336x336 的图、14 像素一块,就是 24x24 = 576 个 patch -> 576 个特征(前面 trace 里的数字就是这么来的)。复杂度也从这来:patch 越多,自注意力的开销越大(O(patch^2)),这正是高分辨率图像为什么贵。为了又看得清、又不让 patch 数爆炸,很多实现会把大图切成几块分别编码(image tiling / 切片),再把各块的 embedding 拼起来——这也是为什么有的多模态模型吃一张大图会吐出成百上千个 embedding。真正的实现都在 tools/mtmd/clip.cpp,里面用 ggml 把这套 ViT 搭了出来——如果你已经读懂了 L16 的文本 build graph,那 clip.cpp 对你不会陌生,无非是换了一种 token。本课不逐行展开它,是因为它对"多模态怎么接进 LLM"这条主线不是重点:重点是它吐出的特征,要靠 projector 那座桥才能进 LLM。顺带一提,正因为 clip 内部也是个 transformer,它同样能用 ggml 那套算子、同样能量化、同样能跑在各种后端上——这就是为什么 llama.cpp 能把视觉编码器和 LLM 装进同一套推理框架,而不必再拉一个 PyTorch 进来。
image embedding 一旦插进序列,对 LLM 来说它就是序列里实打实的 N 个位置——和文本 token 一样要分配 position、一样要写进 KV cache(呼应 L19)。这带来两个要处理的问题。一是位置编码:普通文本是一维位置(第 0、1、2 个 token),但图像是二维的(某 patch 在第几行第几列),硬拍平成一维会丢掉空间结构。于是不少模型用 M-RoPE(多维 RoPE),给图像 token 一个能表达"行、列"的多维位置——llama.cpp 里 mtmd_decode_use_mrope 就是问"这个模型要不要用 M-RoPE",而 mtmd_helper_get_n_pos 专门算一串 chunk 占了多少个"位置"(注释里点明:一般 n_pos == n_tokens,但 M-RoPE 下两者不同)。直觉上,M-RoPE 给图像 token 的位置不再是一根数轴上的一个点,而更像棋盘上的一个坐标格——这样模型才知道左上角那块和右下角那块在空间上离得远。二是注意力掩码:文本是因果的(只能看前面),但一张图内部的 patch 之间往往要互相都能看见(双向),所以有些模型(如 Gemma 3)在 decode 图像段时要临时切成非因果注意力——这正是 mtmd_decode_use_non_causal 在管的事。理解这两点,你就明白图像进 LLM 不只是"塞 N 个向量"那么简单:它还得在位置和注意力这两件 transformer 的根本机制上,和文本和谐共处。好在这些 llama.cpp 都替你处理好了,你只要知道:图像 embedding 进了序列,就和文本一样占 KV cache、一样参与后面每个 token 的注意力——这也是为什么图越多、KV cache 涨得越快。这件事在工程上很要命:一张高分辨率图可能就占掉几百上千个位置,几张图下来,KV cache 的占用直追一段长文本。所以多模态服务里,"图片预算"经常得和"上下文预算"一起算——这又一次把你带回 L19 的老问题:序列越长,KV cache 越大,能并发的请求就越少。多模态没有逃开这条铁律,只是让"序列里能有什么"变得更丰富了。所以下次看到"32K 上下文的多模态模型",你心里要清楚:这 32K 是图和字共享的预算,一张高清图就能吃掉一大块。
Up to here, the LLM in your head still only reads text: a prompt is split into tokens, each looked up to an embedding, then run through dozens of transformer layers. Yet today's models routinely "talk about pictures" - you send an image and they describe it, answer questions, read tables. How do they fit "an image" into a model that only knows tokens? The answer is surprisingly plain: the LLM's input layer does not care whether the embedding vectors fed in came from text or from an image. Multimodality just turns "an image" into a run of embeddings too, splices them into the same sequence as the text tokens' embeddings, and sends the lot into llama_decode.
This lesson looks at how llama.cpp's mtmd (multimodal) subsystem does it. The core is one sentence: the model body needs not a single change; multimodality rides entirely on a "translation" pipeline bolted in front of it - a vision encoder (clip / ViT) compresses the image into visual features, and the projector (mmproj) projects those features into the LLM's embedding space, yielding N vectors that "look just like token embeddings", spliced in where the prompt's <image> marker sits. The LLM, handed this run of vectors, cannot tell which came from text and which from the image - it just computes on as usual. This gives pause at first: if the model clearly "understood" the image, how can it "not know it was an image"? But that is precisely the cleverest part of the design - "understanding the picture" is fully outsourced to the vision pipeline in front, and the LLM does only what it is best at: reasoning over a sequence of vectors.
Roadmap: first the whole mtmd pipeline (split into chunks -> encode -> get embeddings -> interleave with text and decode), with a trace of "one image entering the LLM"; then a dedicated look at why the projector bridge is indispensable; and finally two folds digging into clip's ViT internals and how image embeddings "take positions" in the sequence and enter the KV cache.
First walk the whole pipeline. The user gives an "interleaved image-text" input - some text carrying an <image> marker, plus the pixels of one (or a few) images. mtmd runs it in five steps: (1) mtmd_init_from_file loads the projector (that separate mmproj file); (2) mtmd_tokenize splits the input into a run of chunks - text passages are TEXT chunks, each image becomes an IMAGE chunk (audio is AUDIO); (3) each image chunk is run through mtmd_encode_chunk (internally clip + projector); (4) mtmd_get_output_embd pulls out the encoded visual embeddings; (5) the text chunks' tokens and the image chunks' embeddings are interleaved in original order and fed segment by segment into llama_decode. First, the splitting:
// split "text + <image> marker + image bitmap" into ordered chunks (simplified from tools/mtmd/mtmd.h) mtmd_input_chunks * chunks = mtmd_input_chunks_init(); mtmd_tokenize(mtmd_ctx, chunks, &text, bitmaps, n_bitmaps); // chunks now hold, in original order: // [TEXT "this image is"] [IMAGE one picture] [TEXT "what is in it?"] // each chunk carries a type: TEXT / IMAGE / AUDIO
Note the <image> in the prompt (the source's default marker is actually <__media__>): it is just a placeholder telling mtmd "where in the text this image should be inserted". The source's comment gives a plain example: a line like "here is an image: <__media__> ..." splits into three chunks - the text before the marker, the image itself, the text after - in exact original order. Once chunks are split, the real show is encoding the image chunk into embeddings and decoding it interleaved with text. llama.cpp packs this logic into a helper whose comment is practically the whole pipeline's pseudo-code:
// per chunk: text decodes directly, image encodes first then decodes (simplified from mtmd-helper.cpp) for (each chunk : chunks) { if (type(chunk) == MTMD_INPUT_CHUNK_TYPE_TEXT) { llama_decode(lctx, batch_of(chunk.tokens)); // text: old path, look up embeddings then compute } else { // IMAGE / AUDIO chunk: mtmd_encode_chunk(mtmd_ctx, chunk); // 1) internally runs clip(ViT) + projector float * embd = mtmd_get_output_embd(mtmd_ctx); // 2) pull out N visual embeddings llama_decode(lctx, batch_of_embd(embd)); // 3) feed the embeddings straight in } }
See the trick? Text and image both land on the same llama_decode - the only difference is "whether you feed in tokens (and let the model look up embeddings) or already-computed embeddings (used directly)". llama_batch has long supported both inputs (remember from L18 that a batch can carry either token or embd?), so image embeddings slot seamlessly into the sequence, with not one line of the model body changed. The beauty of this design is reuse: mtmd does not open a second inference path for images, it "disguises" images as embeddings and reuses the entire text machinery of batching, KV cache, and sampling - so multimodality becomes a "preprocessing" problem, not a "rewrite the engine" problem. Freezing one image's whole journey into the LLM as a pipeline:
a sequence of token ids -> llama_decode looks up the embedding table inside -> compute. The old path of L04/L20.
pixels -> clip + projector compute embeddings -> feed embeddings straight into llama_decode. Embeddings ready, table lookup skipped.
Worth noting: this "split chunks -> encode -> interleave" mechanism treats audio identically - cut speech into frames, run an audio encoder (like Whisper's front end), then a projector to embeddings, taking exactly the same path as images. That is why mtmd's chunk types put AUDIO right beside IMAGE. In other words, mtmd's design goal from the start was not "support images" but "support any modality that can be encoded into embeddings". Once you understand the image path, audio - and more modalities to come - are cast from the same mold.
That mtmd_encode_chunk step splits internally into two halves: first clip(ViT) "sees" the image as visual features, then the projector "translates" those features into embeddings the LLM can read. Why is this second step mandatory? Because clip's visual features and the LLM's token embeddings are simply not in the same space - the dimensions may differ (clip might output 1024-d, the LLM wants 4096-d), and the value distributions and semantics are two different systems entirely. Feeding clip's output straight into the LLM is like handing an untranslated foreign passage to an expert who only reads Chinese - blank stares. The projector (that separate mmproj file) is the bridge: a small network that projects visual features into the LLM's embedding dimension and space, making them "look and behave just like a token embedding". By analogy, clip's output is a kind of "visual shorthand" whose numbers mean things arranged for a vision task; the LLM's embedding space grew out of a language task, so vectors of the same length live in completely different "coordinate systems". The projector does exactly that change of coordinates: re-expressing the visual shorthand into the language coordinate system, so "there is a cat in the image" lands near the patch of vector space the LLM has always used for "cat".
Two key "alignments" are guarded by two functions: clip_n_mmproj_embd(ctx) returns the projector's output dimension - it must equal the LLM's embedding dimension, or the vectors will not fit into the sequence; clip_n_output_tokens(ctx, img) returns how many embedding tokens this one image occupies (the N in the earlier trace). That N is not arbitrary: bigger image, more patches, larger N; some projectors (resampler types) even actively compress N, aggregating hundreds of patch features into a few dozen embeddings, saving KV cache and compute. This N directly sets an image's "cost": N embeddings take N sequence positions and write N entries of KV - so for the same image, whether the projector squeezes it to 64 embeddings or lays out 576 changes VRAM and speed by an order of magnitude. This is one of the core tradeoffs of high-resolution multimodal models: the finer it sees (more patches, larger N) the more accurate, but the longer the sequence, the slower and more VRAM-hungry.
// inside the projector: clip encodes first, output dim set by mmproj (simplified from tools/mtmd/clip.h) int n_embd = clip_n_mmproj_embd(clip_ctx); // projector output dim == LLM embedding dim int n_tokens = clip_n_output_tokens(clip_ctx, img); // how many embedding tokens this image takes std::vector<float> out_vec; clip_image_encode(clip_ctx, n_threads, img, out_vec); // run ViT + projector, output n_tokens x n_embd // out_vec is now n_tokens visual embeddings, each n_embd-dim, // exactly the shape of n_tokens token embeddings -> straight into the sequence
Common projectors come in three tiers of complexity: a linear layer (one matmul, simplest, early LLaVA), a two-layer MLP (one more nonlinearity, better alignment, common today), and a resampler / cross-attention (a set of learned queries "resamples" the variable-length patch features into a fixed number of embeddings, compressing N and handling arbitrary resolution, used by Qwen-VL etc). Which tier is a tradeoff of accuracy and cost: linear is cheapest but least expressive, the resampler is most flexible but carries its own pile of parameters and compute. Whichever tier, its job is the same: align visual features into the LLM's embedding space. This is also why the mmproj is a separate file, loaded separately - it is a bridge specifically trained for one "this vision encoder + this LLM" pairing; swap the LLM or the clip and the bridge must be retrained. Grasp this and you see why, when downloading a multimodal model, besides the big main GGUF you also need a tiny mmproj file: without that bridge, the model keeps only its "read text" skill, and "see images" is off the table. A practical tip: in a multimodal model's GGUF repo on HuggingFace, the small file with mmproj in its name is exactly this, usually on the order of a few hundred MB - do not forget to grab it.
| projector type | structure | traits | example |
|---|---|---|---|
| linear | one matmul | cheapest, least expressive, fixed N | early LLaVA |
| two-layer MLP | linear + nonlinearity + linear | better alignment, common today | LLaVA-1.5+ |
| resampler | learned queries + cross-attention | compresses N, any resolution | Qwen-VL |
Two folds for two details you cannot avoid when really deploying multimodality.
This lesson treats clip as a black box - give it an image, it spits out a run of visual features. Lift the lid and clip's vision encoder is just a standard Vision Transformer (ViT), almost the same recipe as the text transformer from earlier parts, only with "token" swapped for "image patch": (1) cut the image into fixed-size blocks (patches, say 14x14 pixels each), flatten each and linearly project it to a vector - the image's version of an "embedding"; (2) add positional encoding (telling the model where each block sits in the original image); (3) run through several layers of self-attention + FFN, letting each patch "see" the whole image and aggregate semantics. Each patch ends up with one output vector, and together they are that run of visual features. So a 336x336 image at 14 pixels a block is 24x24 = 576 patches -> 576 features (that is where the trace's number came from). The cost comes from here too: more patches, larger self-attention cost (O(patch^2)), which is why high-resolution images are expensive. To stay sharp without letting the patch count explode, many implementations cut a big image into tiles, encode each, and concatenate the tiles' embeddings - which is also why some multimodal models emit hundreds or thousands of embeddings for one large image. The real implementation lives in tools/mtmd/clip.cpp, where ggml builds this ViT out - if you already followed L16's text build-graph, clip.cpp will not feel foreign, just a different kind of token. This lesson does not unroll it line by line because, for the through-line of "how multimodality plugs into the LLM", it is not the point: the point is that the features it emits need the projector bridge to get into the LLM. Incidentally, because clip is internally a transformer too, it can use the same ggml ops, be quantized, and run on the same backends - which is why llama.cpp can fit the vision encoder and the LLM into one inference framework, without dragging in a separate PyTorch.
Once image embeddings are spliced into the sequence, to the LLM they are N real positions in it - assigned positions like text tokens, written into the KV cache like text tokens (echoing L19). This brings two things to handle. First, positional encoding: plain text is one-dimensional position (the 0th, 1st, 2nd token), but an image is two-dimensional (which row and column a patch is in), and flattening to 1D loses spatial structure. So many models use M-RoPE (multi-dimensional RoPE), giving image tokens a multi-dimensional position that can express "row, column" - in llama.cpp mtmd_decode_use_mrope asks "does this model need M-RoPE", and mtmd_helper_get_n_pos specifically counts how many "positions" a run of chunks occupies (its comment notes: normally n_pos == n_tokens, but under M-RoPE they differ). Intuitively, M-RoPE gives an image token's position not a single point on one number line but more like a coordinate cell on a chessboard - so the model knows the top-left patch and the bottom-right patch are far apart in space. Second, the attention mask: text is causal (can only see what came before), but the patches within one image usually need to all see each other (bidirectional), so some models (like Gemma 3) temporarily switch to non-causal attention while decoding the image segment - exactly what mtmd_decode_use_non_causal governs. Grasp these two and you see that an image entering the LLM is not just "splice in N vectors": it must also coexist with text on the two fundamental transformer mechanisms of position and attention. Helpfully llama.cpp handles all this for you; you just need to know: once image embeddings enter the sequence, they take KV cache like text and join the attention of every later token - which is why more images make the KV cache grow faster. This matters acutely in engineering: one high-resolution image can take hundreds or thousands of positions, and a few images in, the KV cache rivals a long passage of text. So in multimodal serving the "image budget" often has to be counted together with the "context budget" - which brings you right back to L19's old problem: the longer the sequence, the bigger the KV cache, the fewer requests you can run concurrently. Multimodality does not escape this iron law, it only makes "what can be in the sequence" richer. So next time you see a "32K-context multimodal model", be clear in your head: that 32K is a budget shared by images and text, and one high-res image can eat a big chunk of it.