第四部分一路走来,你已经能让模型加载、推理、分词、采样、套对话格式、约束输出(L14-L23)。最后一块拼图:如果想让模型学会新风格、新任务呢?全量微调要重训并存下整套几十 GB 的权重,又贵又笨。这一课讲 LoRA——一种轻量得多的办法,用两个小矩阵给权重打个"补丁",几 MB 就能改变模型行为。
LoRA 的精髓是低秩:它不动原权重,只学一个低秩增量(两个小矩阵 A、B 相乘),加到原权重的输出上。因为秩很低,这两个矩阵小得可怜(适配器常只有几 MB),却能逼近全量微调的效果。更妙的是,它即插即用——想要某种风格就挂上对应适配器,不想要随时卸下,还能几个叠着用。
更新所有权重,存一整套(几十 GB),每个任务一份。又贵又笨。
冻结原权重 W,只学两个小矩阵 A、B。适配器往往几 MB,可叠加、可卸下。
先想清楚全量微调的痛点。一个几十亿参数的模型,要让它适配一个新任务,传统做法是继续训练、更新它的所有权重,然后把这一整套新权重存下来。问题是:每个任务都得存一份几十 GB 的模型,训练也要很大显存——又贵、又占地方、又难分享。
LoRA 换了个思路:冻结原权重一个字节都不改,只在旁边学两个小矩阵 A、B。要用的时候,把 A、B 算出的增量临时加到原权重上即可。于是你存的、传的、加载的"适配器",就只有 A、B 这两个小矩阵,常常只有几 MB——和几十 GB 的全量微调一比,省了好几个数量级。
这背后的关键假设是:微调给权重带来的改变,往往集中在一个低维子空间里。换句话说,"让模型学会某个新任务"所需的调整,没那么多自由度,用一个低秩矩阵就能很好地近似。正是这个洞察,让"只学两个小矩阵"成为可能,且效果出奇地好。
打个更接地气的比方。全量微调像是为了改一句话,把整本书重新印一遍;LoRA 则像在原书上贴几张便签——书没动,便签却足以表达你的修改。要换一种修改,撕掉便签换一批即可,原书永远是那一本。这种"原件不动、改动外挂"的思路,是 LoRA 一切便利的源头。
它带来的协作红利也很大。社区里,大家共享的不再是几十 GB 的整模型,而是几 MB 的适配器——下载快、存储省、还能像插件一样自由组合。一个流行的基础模型周围,往往围着成百上千个各显神通的 LoRA,这种繁荣正是"轻量、可分享"换来的。
当然 LoRA 不是万能的。它擅长"在已有能力上做风格化、领域化的调整",但要让模型学会一项它完全没有的全新本领,低秩增量的表达力可能就不够,那时还得靠更重的训练。明白它的边界,才能用在刀刃上——多数"调性、格式、领域"层面的需求,LoRA 都能漂亮地接住。
具体怎么算?设原权重是 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 加回主干。
// 简化自 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.cpp 的 build_lora_mm)就是它在建图(L16)时干的事:先算基础的 mul_mat(w, cur),再对每个生效的适配器,算 B·(A·cur)、乘 scale、加回去。注意这一切发生在计算图里——增量是临时算出来叠加的,并没有真去改 W 那几个 GB 的权重。
那个 scale 也有讲究:它由适配器的 alpha 和秩 r 算出(大致是 alpha/rank,再乘上用户给的比例)。这个比例就是你挂载时能调的"强度"旋钮——调大,适配器的影响更强;调小,更接近原模型。把强度做成可调,让你能在"原汁原味"和"完全变身"之间平滑过渡。
再把"秩"这个词说透一点。一个矩阵的秩,粗略地说就是它"真正独立的方向"有多少。满秩意味着各个方向都用上了,信息量最大但也最占参数;低秩则是说"其实只用了少数几个方向就够描述这次改动"。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 从一个 .gguf 适配器文件读出 A、B 张量,得到一个适配器对象;再用 llama_set_adapters_lora 把它挂到 context(L17)上、并给一个 scale。之后的每次 decode,建图时就会自动把这个适配器的增量折进去。
"能同时挂多个、各带 scale"不是摆设,而是能力叠加的基础:你可以把"中文风格"和"法律领域"两个 LoRA 同时挂上、各给一个权重,让模型同时具备两种特长。批量接口天然支持这种组合——这也是为什么它被设计成一组 {适配器, scale},而不是一次只能挂一个。
还要强调那个"不复制权重":挂载只是在 context 上记下"现在生效哪些适配器、各什么 scale",真正的叠加发生在每次 decode 建图时(build_lora_mm)。所以挂上、卸下 LoRA 几乎是零成本的——不涉及那几十 GB 权重的任何拷贝或修改,切换风格快得像换个滤镜。
挂载时按目标张量名对号入座,也呼应了 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 加低秩增量,影响那一层的全部计算),表达力强、能学复杂适配;控制向量改的是激活(沿一个方向平移残差流),更轻、更像"调味",擅长沿某个语义方向微调风格。
把这一课接回第四部分的主线:适配器挂在 llama_context(L17)上,每次 llama_decode 建图(L16)时,build_lora_mm 把增量折进相关的 matmul。所以 LoRA/cvec 不是另起炉灶的新系统,而是嵌在已有推理回路里的一层薄薄的"行为调节"——复用了你前面学的建图、上下文这些机制。
控制向量是怎么"算"出来的,值得一提。它往往不需要训练,而是用对比的办法:拿一批"正面例子"(比如语气正式的文本)和一批"负面例子"(随意的文本),分别跑过模型、取某层的激活,两组激活的差的方向,就大致是"正式"这个概念在模型内部的方向。把这个方向向量加进残差流,就能把输出往"更正式"推。简单、直接、还不用训练。
退一步看 LoRA 和控制向量的共同点:它们都践行了同一条原则——基础模型只读,改动外挂且可叠加。这跟 L17 把"只读权重"和"会话状态"分开、L21 把采样策略做成可插拔的链,是一脉相承的设计哲学。整个第四部分,其实都在反复演奏这一个主题:把不变的沉淀下来,把可变的拆出去,于是系统既稳固又灵活。
第四部分到此收尾。再往后(第五部分)我们会跳出 llama 内部,去看这些能力是怎么通过公共 API 和命令行工具暴露给你用的——你已经懂了引擎盖下的机理,接下来就是学会怎么开这辆车。
经验和理论都指向同一个观察:微调给权重带来的变化,往往落在一个低维子空间里。也就是说,"适配某个任务"所需的方向并不多,用一个秩很低(比如 r=8 或 16)的矩阵就能很好地张成。于是花极小的参数,就能逼近全量微调的效果。
直觉上也说得通:基础模型已经学到了海量通用能力,适配新任务更像是在它之上做"小幅修正",而不是推倒重来。小幅修正的自由度本就不高,低秩矩阵正好够用。这也是为什么 r 通常取得很小,再大收益也递减。
这是个非常划算的取舍:参数量从 d×d 降到 2×d×r(r 远小于 d),可能小几百倍,效果却所失无几。用一点点近似换来巨大的成本下降——LoRA 之所以能在消费级硬件上微调大模型,根子就在这。
因为现实里你常想同时挂多个 LoRA:一个管语言风格、一个管领域知识,各给一个 scale 叠加使用。接口若一次只能挂一个,就表达不了这种组合。于是它天然被设计成一组 {适配器, scale} 的批量形式。
这也简化了语义:挂载、替换、清空,全用同一个批量 setter 表达——传新的一组就是替换,传空(n=0)就是清空。不需要单独的 add/remove/clear 三件套,一个函数搞定所有情况,干净利落。
所以旧教程里那套单数的 llama_set_adapter_lora/llama_rm_adapter_lora/llama_clear_adapter_lora 已经被这一个批量函数取代、不复存在了。看到老代码按单数签名调用,要知道那是过时的写法。
层面不同。LoRA 动的是权重:在某些 matmul 上加一个低秩增量,于是那一层的整个线性变换都被改写了,表达力强,能学相对复杂的适配(新风格、新格式、新领域)。代价是它要训练、要存 A/B 矩阵。
控制向量动的是激活:直接在残差流上加一个固定方向的向量,相当于沿某个语义轴("正式 vs 随意""乐观 vs 悲观")把模型的状态推一推。它更轻、更直接,往往不需要训练(可以从对比样本里算出方向),但表达力也更有限——擅长"调味",不擅长"教新本事"。
一句话:LoRA 是"低秩权重补丁",控制向量是"激活方向偏置"。一个改算子怎么算,一个改数据往哪偏。它们都不碰基础权重、都即插即用,是同一类"轻量行为调节"的两种风味,按需要的表达力和成本来选。
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.
Update all weights, store a whole set (tens of GB), one per task. Expensive and clumsy.
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.
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.
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.
// 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".
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.
# 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
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.
"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.
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.
| What it changes | How it takes effect | |
|---|---|---|
| LoRA | weights (low-rank delta scale*B*A) | folded into matmul (build_lora_mm) |
| Control vector | activations (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.
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.
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.
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.
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.
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.