🦙 llama.cpp 图解教程llama.cpp Visual Guide 第四部分 · llama 推理内部Part 4 · Inside llama inference 24 / 40
第四部分 · llama 推理内部Part 4 · Inside llama inference

LoRA 适配器LoRA adapters

第四部分一路走来,你已经能让模型加载、推理、分词、采样、套对话格式、约束输出(L14-L23)。最后一块拼图:如果想让模型学会新风格、新任务呢?全量微调要重训并存下整套几十 GB 的权重,又贵又笨。这一课讲 LoRA——一种轻量得多的办法,用两个小矩阵给权重打个"补丁",几 MB 就能改变模型行为。

LoRA 的精髓是低秩:它不动原权重,只学一个低秩增量(两个小矩阵 A、B 相乘),加到原权重的输出上。因为秩很低,这两个矩阵小得可怜(适配器常只有几 MB),却能逼近全量微调的效果。更妙的是,它即插即用——想要某种风格就挂上对应适配器,不想要随时卸下,还能几个叠着用。

🔌 生活类比
LoRA 像给镜头套滤镜:原镜头(基础权重)一点不动,套上一片轻巧的滤镜(A、B 组成的低秩增量),成像风格就变了;不喜欢随时摘下,也能叠加几片。而控制向量则像一个调色旋钮,沿某个固定方向整体平移画面色调。两者都不动底片,只在出片时做手脚。

为什么需要 LoRA

全量微调

更新所有权重,存一整套(几十 GB),每个任务一份。又贵又笨。

LoRA

冻结原权重 W,只学两个矩阵 A、B。适配器往往几 MB,可叠加、可卸下。

先想清楚全量微调的痛点。一个几十亿参数的模型,要让它适配一个新任务,传统做法是继续训练、更新它的所有权重,然后把这一整套新权重存下来。问题是:每个任务都得存一份几十 GB 的模型,训练也要很大显存——又贵、又占地方、又难分享。

LoRA 换了个思路:冻结原权重一个字节都不改,只在旁边学两个小矩阵 A、B。要用的时候,把 A、B 算出的增量临时加到原权重上即可。于是你存的、传的、加载的"适配器",就只有 A、B 这两个小矩阵,常常只有几 MB——和几十 GB 的全量微调一比,省了好几个数量级。

这背后的关键假设是:微调给权重带来的改变,往往集中在一个低维子空间里。换句话说,"让模型学会某个新任务"所需的调整,没那么多自由度,用一个低秩矩阵就能很好地近似。正是这个洞察,让"只学两个小矩阵"成为可能,且效果出奇地好。

🌍 宏观理解
实际收益是实打实的:一个基础模型 + 一堆几 MB 的 LoRA,就能变出无数"专精版本"——写代码的、扮角色的、特定领域的,随用随挂。基础模型只读、只存一份,差异全在那些小适配器里。这种"一份底座、多个补丁"的格局,正是 LoRA 流行的根本原因。

打个更接地气的比方。全量微调像是为了改一句话,把整本书重新印一遍;LoRA 则像在原书上贴几张便签——书没动,便签却足以表达你的修改。要换一种修改,撕掉便签换一批即可,原书永远是那一本。这种"原件不动、改动外挂"的思路,是 LoRA 一切便利的源头。

它带来的协作红利也很大。社区里,大家共享的不再是几十 GB 的整模型,而是几 MB 的适配器——下载快、存储省、还能像插件一样自由组合。一个流行的基础模型周围,往往围着成百上千个各显神通的 LoRA,这种繁荣正是"轻量、可分享"换来的。

当然 LoRA 不是万能的。它擅长"在已有能力上做风格化、领域化的调整",但要让模型学会一项它完全没有的全新本领,低秩增量的表达力可能就不够,那时还得靠更重的训练。明白它的边界,才能用在刀刃上——多数"调性、格式、领域"层面的需求,LoRA 都能漂亮地接住。

LoRA 数学

x
输入
->
A
降到秩 r
->
B
升回原维
->
x scale
缩放强度
->
+ W·x
加到基础输出

具体怎么算?设原权重是 W、输入是 x。基础输出就是 W·x(一次普通的矩阵乘)。LoRA 在它旁边加一条低秩支路:先用 A 把 x 降到一个很低的维度(秩 r),再用 B 升回原来的维度,得到 B·(A·x);乘上一个缩放系数 scale,加到 W·x 上。最终输出 = W·x + scale·B·A·x。

把这条公式按"维度"画出来,低秩瓶颈就一目了然:主干 W·x 维度不变;旁路先被 A 压到很窄的秩 r,再被 B 升回来,乘 scale 加回主干。

追踪一次 LoRA 前向:主干 W·x 不动,旁路把 x 压到低秩 r 再升回来、乘 scale 加回去(维度为示意)。
主干 W·x(冻结,不更新) 旁路:把 x 压到低秩 r 再升回来 x[4] W冻结 W·x[4] A4->r r=1 Br->4 [4] ×scale + y[4]
// 简化自 src/llama-graph.cpp build_lora_mm
res = ggml_mul_mat(w, cur);                  // 基础权重输出 W·x
for (lora : active_adapters) {
    ab = ggml_mul_mat(b, ggml_mul_mat(a, cur)); // 低秩两步 B·(A·x)
    ab  = ggml_scale(ab, scale);             // scale = alpha/rank * 用户比例
    res = ggml_add(res, ab);                 // 叠加增量
}

上面这段(简化自 src/llama-graph.cppbuild_lora_mm)就是它在建图(L16)时干的事:先算基础的 mul_mat(w, cur),再对每个生效的适配器,算 B·(A·cur)、乘 scale、加回去。注意这一切发生在计算图里——增量是临时算出来叠加的,并没有真去改 W 那几个 GB 的权重。

那个 scale 也有讲究:它由适配器的 alpha 和秩 r 算出(大致是 alpha/rank,再乘上用户给的比例)。这个比例就是你挂载时能调的"强度"旋钮——调大,适配器的影响更强;调小,更接近原模型。把强度做成可调,让你能在"原汁原味"和"完全变身"之间平滑过渡。

🔬 细节 / 源码对应
为什么是 A 降维、B 升维这两步,而不是直接学一个同样大小的增量矩阵?因为直接学一个 d×d 的满秩矩阵,参数量和原权重一样大,就失去意义了。拆成 d×r 和 r×d 两个瘦长矩阵(r 远小于 d),参数量从 d×d 降到 2dr——这正是"低秩"省参数的数学本质。

再把"秩"这个词说透一点。一个矩阵的秩,粗略地说就是它"真正独立的方向"有多少。满秩意味着各个方向都用上了,信息量最大但也最占参数;低秩则是说"其实只用了少数几个方向就够描述这次改动"。LoRA 赌的就是:适配一个任务所需的改动,本质上是低秩的,于是用 r 个方向(A、B 的中间维度就是 r)足以近似。

还有个常被忽略的细节:A、B 的初始化是不对称的。通常 B 初始化为全零、A 随机初始化,于是训练刚开始时增量 B·A 为零——也就是说,挂上一个没训练过的 LoRA,对模型毫无影响,和不挂一样。训练过程才慢慢让这个增量长出有用的方向。这个"从零开始、平滑加入"的设计,让 LoRA 训练既稳定又安全。

加载与应用

# 伪代码: 加载并挂载 LoRA
adapter = llama_adapter_lora_init(model, "style.gguf")   # 读 A/B 张量
llama_set_adapters_lora(ctx, [adapter], n=1, scales=[0.8])  # 批量挂载, 各带 scale
# ... decode 若干步, 输出带上这个风格 ...
llama_set_adapters_lora(ctx, [], n=0, NULL)              # n=0 => 清空, 卸下全部
加载llama_adapter_lora_init(model, "x.gguf")
从 GGUF 读出 A/B 张量,按目标权重名索引
挂载llama_set_adapters_lora(ctx, [..], n, scales)
批量挂到 context、各带 scale;不复制权重(n=0 清空)
生效build_lora_mm(每次 decode 建图)
按张量名把 scale·B·A 折进相关 matmul

用起来很简单:llama_adapter_lora_init 从一个 .gguf 适配器文件读出 A、B 张量,得到一个适配器对象;再用 llama_set_adapters_lora 把它挂到 context(L17)上、并给一个 scale。之后的每次 decode,建图时就会自动把这个适配器的增量折进去。

⚠ 注意
挂载用的是复数、批量llama_set_adapters_lora(一次可以挂多个适配器、各带一个 scale)。早期那套单数llama_set_adapter_lora/rm/clear 已经不存在了——清空适配器就是调批量版、传 n=0。看老代码时别再找单数那几个。

"能同时挂多个、各带 scale"不是摆设,而是能力叠加的基础:你可以把"中文风格"和"法律领域"两个 LoRA 同时挂上、各给一个权重,让模型同时具备两种特长。批量接口天然支持这种组合——这也是为什么它被设计成一组 {适配器, scale},而不是一次只能挂一个。

还要强调那个"不复制权重":挂载只是在 context 上记下"现在生效哪些适配器、各什么 scale",真正的叠加发生在每次 decode 建图时(build_lora_mm)。所以挂上、卸下 LoRA 几乎是零成本的——不涉及那几十 GB 权重的任何拷贝或修改,切换风格快得像换个滤镜。

🌍 宏观理解
适配器为什么也用 .gguf 格式(L13)?因为 LoRA 本质上也是"一堆带名字的张量"(A、B 矩阵,按它们要修改的目标权重命名),和模型权重是同一类东西。复用 GGUF 这套自描述格式,意味着加载器(L14)几乎能照搬——读元数据、按名字建张量清单,连工具链都是现成的。一种格式通吃,省了重复造轮子。

挂载时按目标张量名对号入座,也呼应了 L15 的命名约定。每个 LoRA 张量的名字,记着它要修改的是哪一层的哪个权重(比如某层的 attn_q)。建图时 build_lora_mm 算到那个权重,就去适配器里按名字找有没有对应的 A、B,有就把增量加上。名字再一次成了把"权重"和"补丁"对上的关键。

这种设计还带来一个好处:同一个 LoRA 文件,能套到任何结构兼容的基础模型上——因为它修改的目标是按名字指定的,不绑死某个具体模型实例。于是社区里一个针对某架构训练的 LoRA,常常能直接用在该架构的不同微调版本上。名字驱动的松耦合,让适配器的复用范围大大扩展。

顺带提一个实践中的常见组合:很多人用一个量化过的基础模型(L12)+ 一个 LoRA 适配器来跑,既享受量化省下的显存、又靠适配器获得任务特长。llama.cpp 对这种"量化底座 + LoRA"是支持的——适配器的增量在建图时按需叠加,和底座怎么量化基本正交。省显存和可定制,两个好处可以同时要。

控制向量与衔接

改什么怎么生效
LoRA权重(低秩增量 scale·B·A)折进 matmul(build_lora_mm)
控制向量激活(沿固定方向平移)加进残差流(set_adapter_cvec)

除了 LoRA,还有一种更轻的"调味"手段:控制向量(control vector,cvec)。它不动权重,而是直接在某些层的激活(残差流)上,加一个固定方向的向量——好比给模型的"思路"轻轻推一把,让它整体偏向某种语气或倾向(更正式、更乐观之类)。

两者的区别值得记牢:LoRA 改的是权重(给 matmul 加低秩增量,影响那一层的全部计算),表达力强、能学复杂适配;控制向量改的是激活(沿一个方向平移残差流),更轻、更像"调味",擅长沿某个语义方向微调风格。

⚠ 注意
C API 上,cvec 用 llama_set_adapter_cvec(旧的 llama_apply_adapter_cvec 已移除)。

把这一课接回第四部分的主线:适配器挂在 llama_context(L17)上,每次 llama_decode 建图(L16)时,build_lora_mm 把增量折进相关的 matmul。所以 LoRA/cvec 不是另起炉灶的新系统,而是嵌在已有推理回路里的一层薄薄的"行为调节"——复用了你前面学的建图、上下文这些机制。

🌍 宏观理解
至此,第四部分(llama 推理内部)就完整了:从一个 .gguf 被加载成模型(L14-15),搭成计算图(L16),装进上下文按批用 KV 高效推理(L17-19),再到分词采样对话模板语法约束、以及这一课的轻量微调(L20-24)。你已经把"一个大模型如何被驱动、控制、改造"从头到尾走了一遍。

控制向量是怎么"算"出来的,值得一提。它往往不需要训练,而是用对比的办法:拿一批"正面例子"(比如语气正式的文本)和一批"负面例子"(随意的文本),分别跑过模型、取某层的激活,两组激活的的方向,就大致是"正式"这个概念在模型内部的方向。把这个方向向量加进残差流,就能把输出往"更正式"推。简单、直接、还不用训练。

退一步看 LoRA 和控制向量的共同点:它们都践行了同一条原则——基础模型只读,改动外挂且可叠加。这跟 L17 把"只读权重"和"会话状态"分开、L21 把采样策略做成可插拔的链,是一脉相承的设计哲学。整个第四部分,其实都在反复演奏这一个主题:把不变的沉淀下来,把可变的拆出去,于是系统既稳固又灵活。

第四部分到此收尾。再往后(第五部分)我们会跳出 llama 内部,去看这些能力是怎么通过公共 API 和命令行工具暴露给你用的——你已经懂了引擎盖下的机理,接下来就是学会怎么开这辆车。

1 为什么"低秩"就够用? 点击展开

经验和理论都指向同一个观察:微调给权重带来的变化,往往落在一个低维子空间里。也就是说,"适配某个任务"所需的方向并不多,用一个秩很低(比如 r=8 或 16)的矩阵就能很好地张成。于是花极小的参数,就能逼近全量微调的效果。

直觉上也说得通:基础模型已经学到了海量通用能力,适配新任务更像是在它之上做"小幅修正",而不是推倒重来。小幅修正的自由度本就不高,低秩矩阵正好够用。这也是为什么 r 通常取得很小,再大收益也递减。

这是个非常划算的取舍:参数量从 d×d 降到 2×d×r(r 远小于 d),可能小几百倍,效果却所失无几。用一点点近似换来巨大的成本下降——LoRA 之所以能在消费级硬件上微调大模型,根子就在这。

2 为什么挂载 API 是批量复数 llama_set_adapters_lora? 点击展开

因为现实里你常想同时挂多个 LoRA:一个管语言风格、一个管领域知识,各给一个 scale 叠加使用。接口若一次只能挂一个,就表达不了这种组合。于是它天然被设计成一组 {适配器, scale} 的批量形式。

这也简化了语义:挂载、替换、清空,全用同一个批量 setter 表达——传新的一组就是替换,传空(n=0)就是清空。不需要单独的 add/remove/clear 三件套,一个函数搞定所有情况,干净利落。

所以旧教程里那套单数的 llama_set_adapter_lora/llama_rm_adapter_lora/llama_clear_adapter_lora 已经被这一个批量函数取代、不复存在了。看到老代码按单数签名调用,要知道那是过时的写法。

3 LoRA 和控制向量,到底差在哪? 点击展开

层面不同。LoRA 动的是权重:在某些 matmul 上加一个低秩增量,于是那一层的整个线性变换都被改写了,表达力强,能学相对复杂的适配(新风格、新格式、新领域)。代价是它要训练、要存 A/B 矩阵。

控制向量动的是激活:直接在残差流上加一个固定方向的向量,相当于沿某个语义轴("正式 vs 随意""乐观 vs 悲观")把模型的状态推一推。它更轻、更直接,往往不需要训练(可以从对比样本里算出方向),但表达力也更有限——擅长"调味",不擅长"教新本事"。

一句话:LoRA 是"低秩权重补丁",控制向量是"激活方向偏置"。一个改算子怎么算,一个改数据往哪偏。它们都不碰基础权重、都即插即用,是同一类"轻量行为调节"的两种风味,按需要的表达力和成本来选。

✅ 关键要点
  • LoRA = 冻结原权重 W,只学小矩阵 A、B,输出 = W·x + scale·B·A·x(低秩增量);适配器常仅几 MB。
  • 数学在建图时实现(build_lora_mmsrc/llama-graph.cpp):res = W·x,再 + scale·B·(A·x);scale 来自 alpha/rank × 用户比例。
  • 加载 llama_adapter_lora_init;挂载用批量 llama_set_adapters_lora(单数 set/rm/clear 已移除,n=0 清空)。
  • 控制向量 llama_set_adapter_cvec:沿固定方向平移激活;LoRA 改权重。两者都不复制权重、即插即用。
  • 适配器挂在 context(L17)、decode 建图(L16)时折进 matmul,不改基础权重。
💡 设计洞察
LoRA 把"改变模型行为"从"重训整套权重"降到"加一片几 MB 的低秩滤镜"——基础模型只读、增量即插即用。它和第四部分反复出现的主题一脉相承:把只读的知识(权重)和可变的部分(适配器、上下文、采样策略)分开,于是一份大模型能被千变万化地复用。学到这里,你已走完第四部分——从一个 .gguf 文件被加载,到它如何被驱动、约束、并轻量改造成你想要的样子。

🧪 自测 · 想一想为什么这么设计

1. LoRA 怎么改变模型行为?
  1. 重训全部权重
  2. 换一个词表
  3. 冻结原权重,用两个小矩阵 A、B 给权重加一个低秩增量 scale·B·A
  4. 改采样温度
看答案与解析 点击展开
答案:C。LoRA 冻结原权重 W,只学小矩阵 A、B,输出 = W·x + scale·B·A·x。适配器只有几 MB,远比重训全部权重轻;它和换词表、调温度是完全不同的事。
2. 当前 llama.cpp 给上下文挂载 LoRA 用哪个 API?
  1. llama_lora_apply
  2. 单数的 llama_set_adapter_lora
  3. 重新加载模型
  4. 批量的 llama_set_adapters_lora(单数 set/rm/clear 已移除,n=0 清空)
看答案与解析 点击展开
答案:D。当前 API 是复数批量的 llama_set_adapters_lora(一次可挂多个、各带 scale,n=0 清空);早期单数的 set/rm/clear 三件套已被它取代、不复存在。
3. LoRA 和控制向量(control vector)的主要区别?
  1. LoRA 改词表、cvec 改采样
  2. 都要重训模型
  3. LoRA 给权重加低秩增量(改 matmul),控制向量沿固定方向平移激活(加进残差流)
  4. 两者完全一样
看答案与解析 点击展开
答案:C。LoRA 动权重(低秩增量折进 matmul,build_lora_mm),控制向量动激活(沿方向平移残差流,set_adapter_cvec)。两者都不碰基础权重、即插即用,但层面不同。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 结合 L16,说说为什么挂上 LoRA 能'不复制权重'就生效——build_lora_mm 在建图的哪一步把增量加进来。

All through Part 4, you can now load, infer, tokenize, sample, apply chat formats, and constrain output (L14-L23). The last piece of the puzzle: what if you want the model to learn a new style or task? Full fine-tuning means retraining and storing a whole set of tens-of-GB weights - expensive and clumsy. This lesson covers LoRA - a far lighter approach that patches the weights with two small matrices, changing model behavior in mere megabytes.

LoRA's essence is low rank: it leaves the original weights untouched and learns only a low-rank delta (the product of two small matrices A and B), added to the original weights' output. Because the rank is low, these two matrices are tiny (an adapter is often just a few MB), yet they approximate full fine-tuning's effect. Better still, it is plug-and-play - attach the adapter for a style you want, drop it anytime, and even stack several.

🔌 Analogy
LoRA is like putting a filter on a lens: the original lens (base weights) does not move at all, you put on a light filter (the low-rank delta of A and B) and the look changes; do not like it, take it off anytime, and you can stack a few. A control vector, by contrast, is like a color-grading knob, shifting the whole picture's tone along one fixed direction. Neither touches the negative, just tweaks at print time.

Why LoRA is needed

Full fine-tuning

Update all weights, store a whole set (tens of GB), one per task. Expensive and clumsy.

LoRA

Freeze the original weights W, learn only two small matrices A, B. An adapter is often a few MB, stackable, removable.

First, see full fine-tuning's pain point clearly. To adapt a billions-of-parameters model to a new task, the traditional way is to keep training and update all its weights, then store this whole new weight set. The problem: each task needs its own tens-of-GB model, training takes lots of VRAM - expensive, space-hungry, and hard to share.

LoRA takes a different tack: freeze the original weights, not a byte changed, and learn just two small matrices A, B alongside. To use it, temporarily add the delta computed from A and B onto the original weights. So the "adapter" you store, share, and load is just those two small matrices A and B, often only a few MB - compared to tens of GB of full fine-tuning, several orders of magnitude smaller.

The key assumption behind this is: the change fine-tuning brings to the weights often concentrates in a low-dimensional subspace. In other words, the adjustment needed to "make the model learn a task" has not that many degrees of freedom, and a low-rank matrix approximates it well. It is exactly this insight that makes "learn just two small matrices" possible, and surprisingly effective.

🌍 Big picture
The real payoff is concrete: one base model + a pile of few-MB LoRAs conjures countless "specialized versions" - a coder, a role-player, a domain expert, attached on demand. The base model is read-only, stored once, and all the difference lives in those small adapters. This "one base, many patches" pattern is the fundamental reason LoRA caught on.

A more down-to-earth analogy. Full fine-tuning is like reprinting a whole book to change one sentence; LoRA is like sticking a few notes onto the original book - the book is untouched, yet the notes suffice to express your edits. To change an edit, peel the notes and swap a new batch; the original book is forever that one book. This "original untouched, edits attached" thinking is the source of all of LoRA's convenience.

The collaboration dividend is large too. In the community, what people share is no longer the tens-of-GB whole model but few-MB adapters - fast to download, cheap to store, and freely combinable like plugins. A popular base model is often surrounded by hundreds or thousands of LoRAs each with its own trick, a flourishing bought precisely by "lightweight and shareable".

Of course LoRA is not omnipotent. It excels at "stylistic, domain-specific adjustments on top of existing ability", but to make the model learn a brand-new skill it utterly lacks, the low-rank delta's expressiveness may fall short, and heavier training is then needed. Knowing its boundary lets you use it where it counts - most needs at the "tone, format, domain" level, LoRA catches beautifully.

The LoRA math

x
input
->
A
down to rank r
->
B
back up to dim
->
x scale
scale strength
->
+ W*x
add to base output

How exactly is it computed? Let the original weight be W and the input x. The base output is W*x (an ordinary matmul). LoRA adds a low-rank branch alongside it: first A drops x to a very low dimension (rank r), then B lifts it back to the original dimension, giving B*(A*x); multiply by a scale factor and add onto W*x. The final output = W*x + scale*B*A*x.

Draw this formula by "dimension" and the low-rank bottleneck pops out: the base W*x keeps its width; the bypass is first squeezed by A to a narrow rank r, lifted back by B, scaled, and added to the base.

Tracing one LoRA forward: the frozen W*x stays; the bypass squeezes x to low rank r, lifts it back, scales it, and adds it in (dims illustrative).
base W*x (frozen, not trained) bypass: squeeze x to low rank r, lift back x[4] Wfrozen W*x[4] A4->r r=1 Br->4 [4] *scale + y[4]
// simplified from src/llama-graph.cpp build_lora_mm
res = ggml_mul_mat(w, cur);                  // base weight output W*x
for (lora : active_adapters) {
    ab = ggml_mul_mat(b, ggml_mul_mat(a, cur)); // low-rank two steps B*(A*x)
    ab  = ggml_scale(ab, scale);             // scale = alpha/rank * user ratio
    res = ggml_add(res, ab);                 // add the delta
}

The snippet above (simplified from src/llama-graph.cpp's build_lora_mm) is what it does at graph-build time (L16): first compute the base mul_mat(w, cur), then for each active adapter compute B*(A*cur), multiply by scale, and add back. Note all this happens in the compute graph - the delta is computed and added on the fly, never actually modifying those GB of W weights.

That scale matters too: it is computed from the adapter's alpha and the rank r (roughly alpha/rank, times a user-given ratio). This ratio is the "strength" knob you can tune at attach time - turn it up for a stronger adapter influence, down to stay closer to the original model. Making strength adjustable lets you glide smoothly between "as-is" and "fully transformed".

🔬 Details / source
Why the two steps of A down, B up, rather than learning one delta matrix of the same size directly? Because learning a full-rank d x d matrix directly has as many parameters as the original weight, defeating the point. Splitting into two slim matrices d x r and r x d (r far smaller than d) drops the parameters from d squared to 2dr - this is the mathematical essence of "low rank" saving parameters.

Let me spell out the word "rank" a bit more. A matrix's rank is, roughly, how many "truly independent directions" it has. Full rank means all directions are used, maximal information but also maximal parameters; low rank says "actually only a few directions suffice to describe this change". LoRA's bet is exactly that the change needed to adapt a task is essentially low-rank, so r directions (the inner dimension of A and B is r) approximate it well enough.

One often-missed detail: the initialization of A and B is asymmetric. Usually B is initialized to all zeros and A randomly, so at the start of training the delta B*A is zero - that is, attaching an untrained LoRA has no effect on the model, the same as attaching none. Only training gradually grows useful directions in this delta. This "start from zero, join smoothly" design makes LoRA training both stable and safe.

Loading and applying

# pseudocode: load and attach a LoRA
adapter = llama_adapter_lora_init(model, "style.gguf")   # read A/B tensors
llama_set_adapters_lora(ctx, [adapter], n=1, scales=[0.8])  # batch attach, each with scale
# ... decode a few steps, output carries this style ...
llama_set_adapters_lora(ctx, [], n=0, NULL)              # n=0 => clear, detach all
loadllama_adapter_lora_init(model, "x.gguf")
read A/B tensors from GGUF, indexed by target weight name
attachllama_set_adapters_lora(ctx, [..], n, scales)
batch-attach to the context, each with a scale; no weight copy (n=0 clears)
effectbuild_lora_mm (per-decode graph build)
fold scale*B*A into the relevant matmul by tensor name

It is simple to use: llama_adapter_lora_init reads the A, B tensors from a .gguf adapter file, giving an adapter object; then llama_set_adapters_lora attaches it to the context (L17) with a scale. Every subsequent decode automatically folds this adapter's delta in at graph-build time.

⚠ Heads-up
Attaching uses the plural, batched llama_set_adapters_lora (you can attach several adapters at once, each with a scale). The early singular llama_set_adapter_lora/rm/clear no longer exist - clearing adapters is calling the batched version with n=0. Do not go looking for those singular ones in old code.

"Attach several at once, each with a scale" is no ornament but the basis of stacking abilities: you can attach a "Chinese style" and a "legal domain" LoRA at once, each with a weight, giving the model both specialties. The batched interface naturally supports this combination - which is why it is designed as a set of {adapter, scale}, not one-at-a-time.

Stress that "no weight copy" again: attaching merely records on the context "which adapters are active now, each at what scale"; the real addition happens at each decode's graph build (build_lora_mm). So attaching and detaching a LoRA is nearly free - involving no copy or modification of those GB of weights, switching styles as fast as swapping a filter.

🌍 Big picture
Why is an adapter also in .gguf format (L13)? Because a LoRA is essentially also "a bunch of named tensors" (the A, B matrices, named after the target weights they modify), the same kind of thing as model weights. Reusing GGUF's self-describing format means the loader (L14) can be reused almost verbatim - read metadata, build the tensor list by name, even the toolchain is ready-made. One format fits all, sparing reinvented wheels.

Matching by target tensor name at attach time also echoes L15's naming convention. Each LoRA tensor's name records which layer's which weight it modifies (say a layer's attn_q). At graph build, when build_lora_mm reaches that weight, it looks up the adapter by name for a matching A, B, and adds the delta if found. Names once again become the key that pairs "weight" with "patch".

This design brings another benefit: the same LoRA file can apply to any structurally compatible base model - because its modification targets are specified by name, not bound to a specific model instance. So a community LoRA trained for one architecture can often be used directly on different fine-tuned versions of that architecture. Name-driven loose coupling vastly expands an adapter's reuse range.

A common combination in practice worth mentioning: many people run a quantized base model (L12) + a LoRA adapter, enjoying the VRAM saved by quantization while gaining task specialty from the adapter. llama.cpp supports this "quantized base + LoRA" - the adapter's delta is added on demand at graph build, largely orthogonal to how the base is quantized. Saving VRAM and staying customizable, you can have both.

Control vectors and the hand-off

What it changesHow it takes effect
LoRAweights (low-rank delta scale*B*A)folded into matmul (build_lora_mm)
Control vectoractivations (shift along a fixed direction)added to the residual stream (set_adapter_cvec)

Besides LoRA, there is an even lighter "seasoning" means: the control vector (cvec). It touches no weights but adds a fixed-direction vector directly onto the activations (the residual stream) of certain layers - like nudging the model's "train of thought", tilting it overall toward some tone or tendency (more formal, more optimistic, and so on).

The difference is worth remembering: LoRA changes weights (adding a low-rank delta to matmul, affecting that layer's entire computation), expressive, able to learn complex adaptations; the control vector changes activations (shifting the residual stream along one direction), lighter, more like "seasoning", good at fine-tuning style along a semantic direction.

⚠ Heads-up
In the C API, cvec uses llama_set_adapter_cvec (the old llama_apply_adapter_cvec is removed).

Connecting this lesson back to Part 4's main line: the adapter is attached to llama_context (L17), and at each llama_decode graph build (L16), build_lora_mm folds the delta into the relevant matmul. So LoRA/cvec is not a new system started from scratch but a thin layer of "behavior tuning" embedded in the existing inference loop - reusing the graph-build and context mechanisms you learned earlier.

🌍 Big picture
With that, Part 4 (inside llama inference) is complete: from a .gguf being loaded into a model (L14-15), assembled into a compute graph (L16), packed into a context and inferred efficiently in batches with the KV cache (L17-19), to tokenization, sampling, chat templates, grammar constraints, and this lesson's lightweight fine-tuning (L20-24). You have now walked end to end through "how a large model is driven, controlled, and reshaped".

How a control vector is "computed" is worth a mention. It often needs no training but uses a contrastive method: take a batch of "positive examples" (say formal-toned text) and a batch of "negative examples" (casual text), run each through the model and take a layer's activations; the direction of the difference between the two activation sets is roughly the direction of the concept "formal" inside the model. Add this direction vector to the residual stream and you push the output toward "more formal". Simple, direct, and training-free.

Step back to the common ground of LoRA and control vectors: both practice the same principle - the base model is read-only, the changes are attached and stackable. This is of one piece with L17 separating "read-only weights" from "session state" and L21 making the sampling strategy a pluggable chain. All of Part 4, really, plays this one theme over and over: settle the invariant, split out the mutable, so the system is both solid and flexible.

Part 4 ends here. Beyond it (Part 5) we step out of llama's internals to see how these abilities are exposed to you through the public API and command-line tools - having understood the machinery under the hood, next is learning to drive the car.

1 Why is "low rank" enough? Click to expand

Experience and theory point to the same observation: the change fine-tuning brings to the weights often lands in a low-dimensional subspace. That is, "adapting to a task" needs few directions, well spanned by a very low-rank matrix (say r=8 or 16). So with tiny parameters you approximate full fine-tuning's effect.

It makes intuitive sense too: the base model already learned vast general ability, and adapting to a new task is more like a "small correction" on top of it than a rebuild from scratch. A small correction has inherently few degrees of freedom, and a low-rank matrix is just enough. This is also why r is usually small - bigger brings diminishing returns.

It is a very cost-effective trade: parameters drop from d x d to 2 x d x r (r far smaller than d), possibly hundreds of times smaller, with the effect barely diminished. Trading a little approximation for a huge cost cut - this is the root of why LoRA can fine-tune large models on consumer hardware.

2 Why is the attach API the batched plural llama_set_adapters_lora? Click to expand

Because in reality you often want to attach several LoRAs at once: one for language style, one for domain knowledge, each with a scale, used together. An interface that attaches only one at a time cannot express this combination. So it is naturally designed as a batched set of {adapter, scale}.

It also simplifies the semantics: attach, replace, clear are all expressed by the same batched setter - pass a new set to replace, pass empty (n=0) to clear. No need for a separate add/remove/clear trio; one function handles every case, clean and tidy.

So the singular llama_set_adapter_lora/llama_rm_adapter_lora/llama_clear_adapter_lora from old tutorials are replaced by this one batched function and no longer exist. Seeing old code call them by the singular signature, know that is an outdated form.

3 LoRA vs control vectors, what exactly differs? Click to expand

Different levels. LoRA acts on weights: adding a low-rank delta to certain matmuls, so that layer's entire linear transform is rewritten - expressive, able to learn relatively complex adaptations (new style, new format, new domain). The cost is it needs training and storing the A/B matrices.

The control vector acts on activations: adding a fixed-direction vector directly to the residual stream, like nudging the model's state along a semantic axis ("formal vs casual", "optimistic vs pessimistic"). It is lighter and more direct, often needing no training (the direction can be computed from contrasting samples), but also more limited in expressiveness - good at "seasoning", not at "teaching new skills".

In a sentence: LoRA is a "low-rank weight patch", the control vector is an "activation-direction bias". One changes how an operator computes, the other where the data leans. Both leave the base weights untouched and are plug-and-play, two flavors of the same "lightweight behavior tuning" - choose by the expressiveness and cost you need.

✅ Key points
  • LoRA = freeze the original weights W, learn only small matrices A, B; output = W*x + scale*B*A*x (a low-rank delta); adapters are often just a few MB.
  • The math is implemented at graph build (build_lora_mm, src/llama-graph.cpp): res = W*x, then + scale*B*(A*x); scale comes from alpha/rank x a user ratio.
  • Load with llama_adapter_lora_init; attach with the batched llama_set_adapters_lora (singular set/rm/clear removed, n=0 clears).
  • Control vector llama_set_adapter_cvec: shifts activations along a fixed direction; LoRA changes weights. Both copy no weights and are plug-and-play.
  • The adapter is attached to the context (L17) and folded into matmul at decode graph build (L16), not changing the base weights.
💡 Design insight
LoRA brings "changing model behavior" down from "retraining a whole weight set" to "adding a few-MB low-rank filter" - the base model read-only, the delta plug-and-play. It is of one piece with Part 4's recurring theme: separate the read-only knowledge (weights) from the mutable parts (adapters, context, sampling strategy), so one large model can be reused in endless variations. Reaching here, you have finished Part 4 - from a .gguf file being loaded, to how it is driven, constrained, and lightly reshaped into what you want.

🧪 Self-test - think about the design

1. How does LoRA change model behavior?
  1. retrain all the weights
  2. swap the vocabulary
  3. freeze the original weights and add a low-rank delta scale*B*A via two small matrices A, B
  4. change the sampling temperature
Show answer & explanation click to expand
Answer: C. LoRA freezes the original W and learns only small matrices A, B; output = W*x + scale*B*A*x. The adapter is just a few MB, far lighter than retraining all weights; it is wholly different from swapping the vocab or tuning temperature.
2. Which API attaches a LoRA to the context in current llama.cpp?
  1. llama_lora_apply
  2. the singular llama_set_adapter_lora
  3. reload the model
  4. the batched llama_set_adapters_lora (singular set/rm/clear removed, n=0 clears)
Show answer & explanation click to expand
Answer: D. The current API is the plural, batched llama_set_adapters_lora (attach several at once, each with a scale; n=0 clears); the early singular set/rm/clear trio is replaced by it and no longer exists.
3. The main difference between LoRA and a control vector?
  1. LoRA changes the vocab, cvec changes sampling
  2. both require retraining the model
  3. LoRA adds a low-rank delta to weights (changing matmul); a control vector shifts activations along a fixed direction (added to the residual stream)
  4. they are exactly the same
Show answer & explanation click to expand
Answer: C. LoRA acts on weights (a low-rank delta folded into matmul, build_lora_mm); a control vector acts on activations (shifting the residual stream along a direction, set_adapter_cvec). Both leave base weights untouched and are plug-and-play, but at different levels.
💭 Open questions (no single right answer - just think or try)
  • Drawing on L16, explain why attaching a LoRA takes effect 'without copying weights' - at which graph-build step build_lora_mm adds the delta.