课 03 看清了"循环怎么一圈圈转"。这一课往下钻一层:一个 transformer block 内部到底在算什么、 "自回归"为什么能成立,以及最关键的——KV cache 凭什么是精确的,而不是一种"差不多就行"的近似。 看懂这一层,你就明白课 03 那个循环为什么"可以这么省"。
llama 系模型(和当下绝大多数 LLM)都是 decoder-only 结构。把它拆开,数据从下往上是一条很直的链:词 id 先查成向量, 再穿过许多层结构相同的 block,最后投影回词表,得到每个候选词的分数。一层 block 内部又分两个子层—— 自注意力和前馈网络(FFN):
把每个 token id 查成一个稠密向量(词向量);模型后面所有运算都在这些向量上进行。
src/llama-graph.cpp · build_inp_embd
RMSNorm 归一化 -> 自注意力(含 RoPE 位置编码)-> 残差相加。token 之间唯一互相交流的地方就在这里。
ggml_rms_norm · ggml_rope · ggml_soft_max_ext
再一次 RMSNorm -> 前馈网络(SwiGLU)-> 残差相加。对每个 token 各自做一次非线性加工,位置之间互不影响。
所有 block 叠完后,再过一道 RMSNorm,稳定输出的数值尺度。
用输出层(lm_head)把向量投影回词表大小,得到每个候选 token 的分数(logits)。
llama_get_logits_ith 取出
顺带说说词嵌入为什么重要。它不是简单的"查字典编号",而是把每个 token 映射到高维空间里的一个点, 语义相近的词,向量也彼此靠近——这正是模型"理解"语言的起点。原始词向量只带"这个词大概什么意思",还不含上下文; 真正让它"读懂整句"的,是随后那几十层 block。你可以把多层 block 想成一条逐级精炼的流水线:浅层更多在抓局部搭配与语法 (谁修饰谁、短语边界在哪),深层逐渐组合出更抽象的语义与长程关系(指代、逻辑、主题)。每过一层,token 的向量就被 "注入更多来自上下文的信息";到顶层时,最后一个位置的向量已经浓缩了"接下来最该说什么"的全部线索,投影到词表就成了 logits。
把这套"检索"用一个具体例子走一遍:当前 token 给 3 个历史 token 打分,分数越高、softmax 之后的权重越大,最后按权重把它们的 Value 加权汇总成一个向量。
还有个常被忽略的点:注意力本身并不知道词的先后顺序——它只是在做"按相似度加权汇总",把同样几个词打乱顺序喂进去, 纯注意力算出的结果竟然一样。可语言显然讲究语序("狗咬人"和"人咬狗"天差地别)。位置信息就是为此补进来的:llama 系普遍用 RoPE(旋转位置编码),它不是简单地给每个位置加一个"序号向量",而是按位置给 Query / Key 做一次旋转, 让两个 token 的注意力分数自然带上"它们相距多远"的信息。这也是为什么前面 vflow 里,自注意力子层特意标了"含 RoPE"。
顺手把最后那一步输出投影也说明白:它把顶层那个 token 向量,乘上一个"词表大小 × 隐藏维度"的大矩阵(lm_head), 得到词表里每个 token 各一个分数——这就是 logits 的长度恒等于词表大小的原因。不少模型还让它和最底层的词嵌入矩阵 共享权重(weight tying),既省参数,又让"输入怎么编码"和"输出怎么打分"保持一致。
把一层 block 的前向写成伪代码,就是"两条带残差的支路":
# 一层 block 的前向: 两条带残差的支路 def layer(x): # x: [n_tokens, n_embd] a = attn(rms_norm(x)) # tokens talk to each other here x = x + a # residual f = ffn(rms_norm(x)) # per-token non-linear mix return x + f # residual
既然注意力让 token 互相参考,那"写第 5 个字时能不能偷看第 6、第 7 个字"?绝对不能——生成时它们还不存在。 于是 decoder 给注意力加了一道因果掩码(causal mask):第 i 个 token 只允许注意到位置 <= i 的 token, 对"未来"的位置一律屏蔽。画成一张方格表,就是一个下三角:
表里每一行是"某个 token 在看谁":亮格表示可以注意(不晚于自己),灰格表示被屏蔽(在自己之后)。 实现上很直接:算完注意力分数后,把所有"未来位置"的分数置成 -inf,再过 softmax,这些位置的权重就变成 0,等于没看。 这就是"自回归"在数学上的样子——每个位置的输出只依赖它及它之前的输入,绝不泄露未来。
也正因为训练和推理共用同一套前向,llama.cpp 里并没有"两套代码":无论是 prefill 一次喂进几百个 token, 还是 decode 每次只喂 1 个新 token,走的都是同一张计算图,区别只在"这一批有几个 token、要输出哪些位置"。 理解了这点,再回看课 03 的七步数据流,就明白它为什么能用一个 llama_decode 同时扛起这两种节奏。
llama.cpp 里这一步由 src/llama-graph.cpp 的 build_attn / build_attn_mha 拼进计算图,掩码通过 ggml_soft_max_ext 在求 softmax 时一并施加,简化出来大致是:
// 注意力打分 + 因果掩码 (对应 build_attn / build_attn_mha) kq = ggml_mul_mat(ctx, k, q); // scores [n_kv, n_q] kq = ggml_soft_max_ext(ctx, kq, mask, // mask: causal -inf on j>i scale, max_bias); kqv = ggml_mul_mat(ctx, v, kq); // weighted sum of values
(以本仓库 2026-06-15 源码为准;真实实现散落在 llama-graph.cpp,这里只取主干、略去缩放与多头细节。)
把"每次只根据已有 token 预测下一个"画成回路,就是自回归循环:
注意末端那条回灌箭头——新采样出的 token 被接到序列尾巴,成为下一轮的输入。这里就引出全课的"题眼": 每往后写一个字,是不是都要把前面所有字重新算一遍注意力?不需要。原因恰恰藏在因果掩码里。
注意力里每个 token 会算出三样东西:Query(拿去"问"的向量)、Key(被查的"标签")、Value(携带的"内容")。 当我们新增第 n+1 个 token 时,它要拿自己的 Query 去和"所有历史 token 的 Key"逐一比对,再按比对出的权重把对应的 Value 加权汇总。 关键在于:历史 token 的 K 和 V,只取决于它自己和它的位置,而因果掩码保证它"看不到"任何后来的 token—— 所以无论后面再追加多少新 token,前面那些 K/V 一个数都不会变。既然不变,就可以算一次、存下来、永远复用。这就是 KV cache:
看这两行——第 n+1 步相比第 n 步,只多出最右边一个高亮新格,左边那些全是"原封不动"的旧值。 所以 KV cache 不是一种"用精度换速度"的近似优化,而是一个数学上完全等价的复用:缓存里的值, 和"每步都从头重算"得到的值逐位相等。它省掉的是重复劳动,不是精度。顺带一提,预测下一个词时只需要 最后一个位置的那一行输出,所以取 logits 时只取末位:
logits = llama_get_logits_ith(ctx, -1); // 只取最后一个位置, 返回 n_vocab 个分数
当然,KV cache 也不是白来的:它要为每一层、每个历史 token 各存一份 K 和一份 V,占用的显存/内存随 上下文长度线性增长。上下文开到几万 token 时,这块缓存会吃掉相当可观的一片显存——这也是"上下文窗口"为什么总有上限、 长上下文为什么格外吃硬件的原因之一(分配、写入与复用都在 src/llama-kv-cache.cpp)。所以工程上既要靠它省计算, 又得想办法压它的体积——下面深挖里的 GQA / MQA 正是干这个的,这是一对需要一直权衡的矛盾。
下面三个问题,想深究的同学点开看;只想抓主线的可以先跳过。
最初的 Transformer 是 encoder-decoder 结构,为翻译这类"读完整句再生成"的任务设计:encoder 双向读懂源句, decoder 据此逐词生成译文。但"预测下一个词"这件事并不需要双向——你写字时本来就只能依赖已经写出的部分。
GPT 类模型于是只保留 decoder,配上因果掩码做纯粹的"续写",结构更简单、训练目标更统一(始终是"猜下一个 token"), 特别适合生成式任务。llama.cpp 支持的开源大模型,几乎清一色是这种 decoder-only 架构。
反过来,只做"理解"不做"生成"的任务(如文本分类、检索、判断两句话是否同义),更适合 encoder-only (如 BERT)那种双向结构——它可以同时看左右两边的全部上下文;而"边读边写"的对话生成,则是 decoder-only 的主场。
标准多头注意力(MHA)里,Query、Key、Value 都有同样多的"头"。但 KV cache 的大小正比于 Key/Value 头的数量, 于是出现了省内存的变体:GQA(分组查询注意力)让多个 Query 头共享一组 K/V 头, K/V 头数(n_head_kv)少于 Q 头数(n_head,见 src/llama-hparams.h)。
MQA 是极端情形——所有 Query 头只共享一组 K/V。头数越少,KV cache 越小、长上下文越省显存,代价是表达力略降。 如今多数大模型用 GQA 来折中:既显著压缩缓存,又几乎不掉效果。
为什么共享了还几乎不掉效果?直觉是:相邻的若干 Query 头往往在关注差不多的东西,让它们共用一组 K/V, 损失的表达力很有限,省下的显存却相当可观——这是一笔很划算的买卖,也是它能被广泛采用的原因。
logits 是词表上每个 token 的原始分数,可正可负、加起来也不等于 1,还不是概率。 要变成概率,得过一道 softmax(指数化再归一化)。
温度(temperature, T)正作用在 softmax 之前:把所有 logits 同时除以 T——T 大于 1 会把分布"摊平" (更随机、更有创意),T 小于 1 会把分布"拉尖"(更确定、更保守),T 趋近 0 就退化成"每次都选分数最高的那个"(贪心)。 所以同一份 logits,调温度就能在"稳重"和"放飞"之间滑动——这部分课 03 提过,后面还会有专门一课展开。
顺便记住一个对照:贪心每次都选分数最高的词,稳定但容易重复、显得呆板;带温度的采样引入随机性, 更生动也更容易"跑偏"。生成质量的调参,很大程度上就是在这两端之间找平衡。
Lesson 03 showed how the loop turns, round after round. This lesson drills one level deeper: what a transformer block actually computes, why autoregression works at all, and - most importantly - why the KV cache is exact rather than a "good enough" approximation. Understand this layer and you see why that loop can be so cheap.
llama-family models (like most LLMs today) are decoder-only. Unpacked, the data flows bottom-up in a straight chain: token ids are looked up into vectors, pushed through many identical blocks, then projected back to the vocabulary to score every candidate word. Inside one block there are two sub-layers - self-attention and the feed-forward network (FFN):
Look up each token id into a dense vector (a word embedding); all later math runs on these vectors.
src/llama-graph.cpp - build_inp_embd
RMSNorm -> self-attention (with RoPE positions) -> residual add. This is the only place tokens talk to each other.
ggml_rms_norm - ggml_rope - ggml_soft_max_ext
Another RMSNorm -> feed-forward network (SwiGLU) -> residual add. A per-token non-linear mix; positions do not interact here.
After all blocks, one more RMSNorm to stabilize the output scale.
The output head (lm_head) projects the vector back to vocabulary size, giving a score (logit) per candidate token.
read out with llama_get_logits_ith
A word on why embeddings matter. They are not a plain "dictionary lookup into an integer"; each token maps to a point in a high-dimensional space where semantically similar words land close together - the starting point of the model "understanding" language. A raw embedding only carries "roughly what this word means", with no context; what makes it "read the whole sentence" is the dozens of blocks that follow. Think of the stacked blocks as a refinement pipeline: shallow layers capture local collocations and syntax (what modifies what, phrase boundaries), deeper layers compose more abstract semantics and long-range relations (coreference, logic, topic). Each layer injects more context into a token's vector; by the top, the last position's vector has distilled every clue about "what to say next", which the projection turns into logits.
Walk this "retrieval" through one concrete example: the current token scores 3 history tokens; a higher score means a higher weight after softmax, and the Values get summed by those weights into one vector.
An easily missed point: attention itself does not know word order - it only does similarity-weighted summing, so shuffling the same words and feeding them in gives the same result from pure attention. But language clearly cares about order ("dog bites man" vs "man bites dog"). Position information is added for exactly this: llama-family models commonly use RoPE (rotary position embedding), which does not simply add an "index vector" per position but rotates Query/Key by position, so two tokens' attention score naturally carries "how far apart they are". That is why the self-attention sub-layer above is tagged "with RoPE".
And the final output projection: it multiplies the top-layer token vector by a big "vocab-size x hidden-dim" matrix (lm_head) to get one score per vocabulary token - which is why the logits length always equals the vocabulary size. Many models also tie this with the bottom embedding matrix (weight tying), saving parameters and keeping "how inputs are encoded" consistent with "how outputs are scored".
Written as pseudocode, one block's forward pass is "two residual branches":
# one block's forward: two residual branches def layer(x): # x: [n_tokens, n_embd] a = attn(rms_norm(x)) # tokens talk to each other here x = x + a # residual f = ffn(rms_norm(x)) # per-token non-linear mix return x + f # residual
Since attention lets tokens reference one another, can token 5 peek at tokens 6 and 7 while writing? Never - during generation they do not exist yet. So the decoder adds a causal mask to attention: token i may attend only to positions <= i, masking out every "future" position. Drawn as a grid, it is a lower triangle:
Each row is "who one token may look at": a lit cell means attendable (no later than itself), a dim cell means masked (after itself). The implementation is direct: after computing attention scores, set every "future" score to -inf, then softmax turns those weights into 0 - as if unseen. This is what "autoregression" looks like mathematically - each position's output depends only on itself and what came before, never leaking the future.
Because training and inference share the same forward pass, llama.cpp has no "two codebases": whether prefill feeds in hundreds of tokens at once or decode feeds just 1 new token, both run the same compute graph, differing only in "how many tokens in this batch, and which positions to output". With that in mind, revisit lesson 03's seven-step flow and you see why a single llama_decode can carry both rhythms.
In llama.cpp this is assembled into the compute graph by build_attn / build_attn_mha in src/llama-graph.cpp, with the mask applied during softmax via ggml_soft_max_ext. Simplified:
// attention scores + causal mask (cf. build_attn / build_attn_mha) kq = ggml_mul_mat(ctx, k, q); // scores [n_kv, n_q] kq = ggml_soft_max_ext(ctx, kq, mask, // mask: causal -inf on j>i scale, max_bias); kqv = ggml_mul_mat(ctx, v, kq); // weighted sum of values
(Per this repo's source as of 2026-06-15; the real implementation lives in llama-graph.cpp; only the trunk is shown, omitting scaling and multi-head details.)
Draw "predict the next from what we already have" as a loop and you get the autoregressive cycle:
Note the feed-back arrow at the end - the newly sampled token is appended to the sequence and becomes the next round's input. This raises the lesson's core question: to write each next word, must we recompute attention over all previous words? No - and the reason is hidden in the causal mask.
In attention each token computes three things: Query (the vector that "asks"), Key (the "tag" being matched), and Value (the "content" carried). To add token n+1, it takes its Query, compares against every historical token's Key, and sums their Values by the resulting weights. The crucial point: a historical token's K and V depend only on itself and its position, and the causal mask guarantees it cannot see any later token - so no matter how many new tokens are appended afterwards, those earlier K/V never change by a single number. Since they never change, they can be computed once, stored, and reused forever. That is the KV cache:
Look at the two rows - step n+1 differs from step n by just one highlighted new cell on the right; everything on the left is untouched old values. So the KV cache is not an "accuracy-for-speed" approximation; it is a mathematically exact reuse: the cached values equal what "recompute every step from scratch" would produce, bit for bit. What it saves is repeated work, not precision. And since predicting the next word needs only the last position's row of output, we read logits at the end:
logits = llama_get_logits_ith(ctx, -1); // last position only, returns n_vocab scores
Of course, the KV cache is not free: it stores one K and one V for every layer and every historical token, so its footprint grows linearly with context length. At tens of thousands of tokens it eats a sizeable chunk of memory - one reason "context windows" always have a ceiling and long contexts are so hardware-hungry (allocation, writes, and reuse all live in src/llama-kv-cache.cpp). So engineering must both save compute with it and shrink its size - GQA/MQA below do exactly that - a tension to keep balancing.
Three questions below; open them if you want depth, skip them if you only want the main line.
The original Transformer was encoder-decoder, designed for tasks like translation ("read the whole sentence, then generate"): the encoder reads the source bidirectionally, the decoder generates the translation word by word. But predicting the next word does not need bidirectionality - while writing you can only depend on what you have already written.
GPT-style models therefore keep only the decoder, pair it with a causal mask, and do pure "continuation". The structure is simpler and the training objective is uniform (always "guess the next token"), which suits generation. Almost every open model llama.cpp supports is this decoder-only architecture.
Conversely, tasks that only "understand" without generating (classification, retrieval, deciding if two sentences mean the same) suit encoder-only models (like BERT) with their bidirectional structure - able to see all context on both sides at once; whereas "read-and-write" conversational generation is decoder-only's home turf.
In standard multi-head attention (MHA), Query, Key, and Value all have the same number of heads. But the KV cache size is proportional to the number of Key/Value heads, so memory-saving variants appeared: GQA (grouped-query attention) lets several Query heads share one group of K/V heads, with fewer K/V heads (n_head_kv) than Q heads (n_head, see src/llama-hparams.h).
MQA is the extreme - all Query heads share one set of K/V. Fewer heads means a smaller KV cache and cheaper long contexts, at a small cost in expressiveness. Most large models today use GQA as the compromise: much smaller cache, almost no quality loss.
Why does sharing barely hurt? Intuitively, neighboring Query heads often attend to much the same thing, so having them share one set of K/V loses little expressiveness while saving a lot of memory - a great trade, and why it is so widely adopted.
logits are the raw scores for each token in the vocabulary - they can be positive or negative and do not sum to 1; they are not yet probabilities. To become probabilities they pass through a softmax (exponentiate, then normalize).
Temperature (T) acts just before softmax: divide all logits by T - T > 1 flattens the distribution (more random, more creative), T < 1 sharpens it (more deterministic, more conservative), and T near 0 degenerates into always picking the highest score (greedy). So with the same logits, tuning temperature slides you between "steady" and "wild" - lesson 03 touched on this, and a later lesson covers it in full.
Keep one contrast in mind: greedy always picks the highest-scoring word - stable but prone to repetition and blandness; temperature sampling adds randomness - livelier but more prone to going off the rails. Tuning generation quality is largely about balancing these two ends.