先聊个有意思的事。我第一次给手写的推理引擎套上自回归循环时,看着终端里一个token一个token地蹦出完整句子,那种感觉比第一次跑通反向传播还爽。但紧接着就被现实教育了——速度慢得令人发指、偶尔生成乱码、显存莫名其妙暴涨。这一篇就是把我踩过的坑和最终沉淀下来的实现方案完整拆开,讲讲朴素自回归推理到底该怎么写、为什么要这么写。
这个系列前面几篇分别讲了张量库、内存管理和算子层,到这一篇终于进入推理引擎最核心的主循环。自回归推理是几乎所有生成式大模型(GPT、LLaMA、Qwen这些)在线服务的灵魂,无论上层套了多少层工程优化,底层跑的还是这个循环。如果你是刚入门大模型推理的新手,或者正在自己写一套推理引擎,这篇文章就是为你准备的。我会先用大白话讲清楚自回归的本质,然后给出完整可跑的朴素实现,再把你大概率会遇到的坑一个个列出来。
1. 自回归推理的整体思路拆解
1.1 为什么大模型必须“一个字一个字”地往外蹦
自回归(Autoregressive)这个词听起来很高大上,本质就是一个很朴素的道理:下一个token只取决于之前所有的token。用数学语言表达就是:
P(x₁, x₂, ..., xₙ) = ∏ P(xₜ | x₁, x₂, ..., xₜ₋₁)
也就是说,整个句子的生成概率可以拆解成每一步的条件概率相乘。模型每次只预测“下一个词”的概率分布,然后从中选一个词,把它拼到已有序列的末尾,再继续预测下一个。这个过程不断重复,直到遇到结束符或达到最大长度。
你可能会问:为什么不能像BERT那样一次预测整句话?原因在于生成任务的本质是逐步决策——你在写每一个字的时候,都需要看到前面已经写出来的完整内容。就好比写文章,你不能提前想好第100个字的准确写法,因为第100个字取决于前面99个字到底写了什么。这是生成模型与理解模型的根本区别,也是自回归分解存在的意义。
1.2 Prefill 与 Decode 两个阶段的本质区别
所有自回归推理引擎,无论多复杂,核心流程都分为两个阶段:Prefill(预填充)和 Decode(解码)。
Prefill 阶段处理的是用户输入的整个提示词(Prompt)。假设用户输入了“中国的首都”,模型需要把这4个token一次性送进网络,算出每个位置的隐藏状态,并缓存下每一层的Key和Value矩阵。这个阶段的特点是:并行度高、计算密集,因为所有位置的token可以同时参与矩阵乘法。
Decode 阶段则完全不同。它每次只处理一个新token,也就是上一步预测出来的那个token。拿这个token的隐藏状态和之前缓存的KV Cache一起,计算出下一步的概率分布。这个阶段的特点是:串行、访存密集。因为每次只输入一个token,矩阵乘法的计算量很小,真正的瓶颈变成了从内存中读取KV Cache。
这两个阶段的性能特征差异巨大,所以在后续优化中通常会被分开考虑。比如Prefill阶段更吃算力,更适合用Tensor Parallel做多卡并行;而Decode阶段更吃显存带宽,往往需要优化KV Cache的访存模式,或者用投机采样(Speculative Decoding)来减少串行步数。但在朴素实现里,我们先不做这些花活,把主干流程跑通才是关键。
1.3 从 logits 到 token:采样的完整流程
很多人以为模型输出的是一个词,其实模型输出的是一个未经归一化的分数向量(Logits),维度是词表大小(比如32000或128256)。要从这个向量里得到最终的token,中间还有一道采样工序。
最基本的流程是:先对logits做温度缩放(Temperature Scaling),再通过Softmax转成概率分布,最后根据不同的策略从这个分布里抽样。抽样策略有很多种——贪心解码直接选概率最大的那个token,Top-K采样则先把概率最高的K个token挑出来再重新归一化,Top-P(Nucleus Sampling)会动态选择累积概率超过阈值P的最小token集合。
这里有一个新手容易误解的点:采样这一步是有随机性的。同样一个提示词,多次生成的结果可能不同(温度大于0时)。这种随机性不是模型的bug,而是生成式模型的特性。如果所有场景都走贪心解码,模型会陷入严重的重复生成。所以工程上通常的做法是:在评测和需要确定性回答的场合用贪心或低温采样,在创意写作和对话场景用更高的温度和Top-P。后面我会给出一份调参的经验表。
2. 朴素自回归推理的完整实现
2.1 最小核心循环的代码骨架
先给出一个最朴素的实现骨架,去掉所有复杂优化,只保留主干逻辑。这里我假设前面几篇已经实现了张量库的核心操作(matmul、softmax、layer_norm等),以及Transformer模型的forward函数。这一段代码就是一个标准的Python伪代码,你可以直接抄下来对照着自己的引擎改造。
def generate(model, tokenizer, prompt, max_new_tokens=128, temperature=0.8, top_p=0.9): # 1. 输入编码:把文本变成token id序列 input_ids = tokenizer.encode(prompt) # shape: [seq_len] # 2. Prefill阶段:一次跑完整个prompt,缓存KV model.init_kv_cache() # 清空KV缓存 hidden_states, kv_cache = model.forward(input_ids) # 返回最后一个位置的logits # 3. 循环解码:逐token生成 generated_ids = [] next_token = sample_from_logits(hidden_states[:, -1, :], temperature, top_p) generated_ids.append(next_token) for step in range(max_new_tokens - 1): # Decode阶段:一次只输入一个新token hidden_states, kv_cache = model.forward( [next_token], kv_cache=kv_cache, use_cache=True ) next_token = sample_from_logits(hidden_states[:, -1, :], temperature, top_p) generated_ids.append(next_token) # 遇到结束符就提前终止 if next_token == tokenizer.eos_token_id: break # 4. 解码输出 output_text = tokenizer.decode(generated_ids) return output_text整个核心循环只有十几行。但就是这十几行,牵涉到大量细节——如果前面的矩阵运算实现得不够稳,任何一个小bug都会在这里被无限放大。我在调试时最大的感受是:这个循环的性能瓶颈不在计算本身,而在数据搬运。
2.2 采样策略的实现与选择
采样函数是生成质量的分水岭。下面给出一个同时支持Top-K、Top-P和温度缩放的采样实现。代码逻辑不复杂,但每个细节都值得推敲。
def sample_from_logits(logits, temperature=1.0, top_k=0, top_p=0.0): import numpy as np logits = logits.astype(np.float32) # Step 1: 温度缩放 # 温度>1时分布更平缓(更随机),温度<1时分布更尖锐(更确定) if temperature != 1.0: logits = logits / max(temperature, 1e-9) # Step 2: Top-K 截断——只保留概率最高的K个token if top_k > 0: k = min(top_k, logits.shape[-1]) indices = np.argpartition(logits, -k)[-k:] mask = np.full_like(logits, -np.inf) mask[indices] = logits[indices] logits = mask # Step 3: Softmax → 得到概率分布 exp_logits = np.exp(logits - np.max(logits)) # 减max防止数值溢出 probs = exp_logits / np.sum(exp_logits) # Step 4: Top-P (Nucleus) 截断——动态选择累积概率达到阈值的最小集合 if top_p > 0.0 and top_p < 1.0: sorted_probs = np.sort(probs)[::-1] cumsum = np.cumsum(sorted_probs) cutoff = int(np.searchsorted(cumsum, top_p)) + 1 threshold = sorted_probs[cutoff] probs[probs < threshold] = 0.0 probs = probs / np.sum(probs) # 重新归一化 # Step 5: 按概率抽样 next_token = np.random.choice(len(probs), p=probs) return int(next_token)这里有几个值得展开的细节:
温度缩放的位置。注意必须先缩放logits再做softmax,不能反过来,因为softmax对输入不是线性的。温度趋于0时,实际上就是贪心采样——softmax会退化成one-hot。但工程上不建议直接把温度设为0,因为一旦logits里有数值误差,会导致除以零。稳妥做法是设一个很小的值比如1e-6。
数值溢出问题。softmax里的logits - np.max(logits)这一行可不是可有可无的。如果logits里有数值很大的值(比如1000),直接np.exp(1000)会得到inf,整个采样就崩了。这个减max的trick不影响结果,因为softmax是平移不变的。
Top-K用argpartition而不是argsort。argpartition的时间复杂度是O(n),argsort是O(n log n)。词表可能在12万以上,这个差距在高并发下还是很明显的。虽然做得是效率优化,但确实对整体性能有帮助。
2.3 让 KV Cache 真正跑起来
KV Cache 是自回归推理高效运行的核心。朴素的做法是:在每一步解码时,把前面所有token重新跑一遍——这种做法复杂度是O(n²),一旦序列超过500个token,速度就会让人抓狂。KV Cache 本质上就是用显存换算力,把中间结果存下来,省掉重复计算。
在实现KV Cache时,我遇到过几个非常隐蔽的坑,值得单独说明。
第一个坑是缓存容量的预分配。很多人一开始会图省事,在生成过程中用append的方式动态扩展KV缓存数组。这会导致频繁的内存分配和拷贝——每生成一个token就多一次O(seq_len)的拷贝,深度大的模型甚至出现几十次内存分配。正确做法是按照最大序列长度一次性预分配,用当前步数记录真实长度。比如max_seq_len = 2048,每层的KV缓存形状就是[max_seq_len, num_heads, head_dim](这里假设单batch推理),每步只更新第step行。
第二个坑是dtype 不一致。我遇到过一次诡异的现象:前几个token生成正常,到第20个token左右开始出现NaN。排查了很久才发现是KV Cache的dtype用了float16,而后续矩阵运算的输入是float32,导致精度问题被逐步放大。KV Cache的dtype必须和模型权重保持一致,不要做隐式转换。
第三个坑是缓存的shape排序。不同框架对KV Cache的维度排列不一样,有的是[seq, batch, head, dim],有的是[batch, head, seq, dim]。这个排列方式决定了你后续做矩阵乘法时是否需要转置。一旦中途变了顺序,调试起来非常折磨。建议从一开始就固定一种布局,并写一个简单的shape断言。
下面给出一个典型的KV Cache管理器的骨架:
class KVCache: def __init__(self, num_layers, batch_size, num_heads, head_dim, max_seq_len, dtype): self.num_layers = num_layers self.keys = [] self.values = [] self.step = 0 for _ in range(num_layers): k = np.zeros((batch_size, num_heads, max_seq_len, head_dim), dtype=dtype) v = np.zeros((batch_size, num_heads, max_seq_len, head_dim), dtype=dtype) self.keys.append(k) self.values.append(v) def update(self, layer_idx, new_k, new_v): """在第 step 位置写入新的KV,并更新 step""" seq_len = new_k.shape[2] self.keys[layer_idx][:, :, self.step:self.step+seq_len, :] = new_k self.values[layer_idx][:, :, self.step:self.step+seq_len, :] = new_v def get_slice(self, layer_idx): """获取当前step之前的所有KV,用于Attention计算""" return self.keys[layer_idx][:, :, :self.step, :], self.values[layer_idx][:, :, :self.step, :] def update_step(self, seq_len): self.step += seq_len3. 实操中的关键细节与调试经验
3.1 形状对齐:推理中最隐蔽的翻车点
自回归推理和训练的最大区别是:训练阶段每个batch的输入长度是固定的,而推理阶段每个step的形状都在变。这导致了大量的形状对齐问题。
我的亲身经历是这样的:第一次跑通循环时,前面几个token的生成都很正常,到了某一步突然报了维度不匹配的错。看error message里,明明说维度是3,但我的矩阵是4维的。后来才发现是有一层Attention在计算时直接用了输入序列的长度,而不是KV Cache当前存储的长度。
具体来说:Prefill阶段,输入有seq_len个token,Attention的Q矩阵形状是[seq_len, head_dim],K矩阵形状是[seq_len, head_dim]。到了Decode阶段,Q矩阵形状变成[1, head_dim],但K矩阵应该从KV Cache里取,形状是[step, head_dim]——其中step是当前已生成的token总数。如果你偷懒直接把当前输入的shape取出来当attention长度用,就会导致维度错乱。
还有一个常见的翻车点是在最后一个批次。假设模型配置了attention的dropout,训练时是正常的,但推理时必须把dropout关掉。否则每个token生成时都在随机丢弃信息,输出会完全不可控。这个坑看起来简单,但很容易被一起带进来。
我的建议:在每层forward的开头和结尾各放一个shape断言,把调试成本前置。虽然在性能上有微小损耗,但你可以在确信整个流程稳定后再去掉。
def forward(self, x, kv_cache=None, layer_idx=0): assert x.ndim == 3, f"Expected 3D input, got {x.ndim}" assert x.shape[1] == 1, f"Decode stage should have seq_len=1, got {x.shape[1]}" assert kv_cache is not None and kv_cache.step > 0, "KV cache should be initialized" # ... 其余前向逻辑3.2 数值稳定性与 logits 处理
在自回归循环里,数值稳定性问题会随着生成长度增加而爆发。我遇到过的最典型问题就是logits数值异常变大,导致softmax结果全是NaN。
这个问题发生在把logits发给采样函数之前。我的原始实现里直接用FP16存储logits,但词表里最大的logits可能在FP16的表示范围边缘(最大值65504)。一旦超过这个范围,FP16就变成inf,进一步导致所有概率都为0或NaN。
后来我用的方案是常驻FP32的logits缓冲区。即使模型主干的中间计算用的是FP16/FP8,最后的线性层输出也显式地转回FP32再做采样。这个转换的开销很小,但对稳定性的收益极大。
另外一个稳定性的关键点是**重复惩罚(Repetition Penalty)**处理。在采样前对已经生成的token对应的logits做惩罚,防止重复。这个操作需要在logits层面做,而不能在probs层面做。因为惩罚的本质是把某个token的分数往下压,如果先做softmax,被压的token会把概率“让”给其他token,效果会打折扣。
3.3 随机性与可复现的平衡
自回归推理涉及随机采样,这给测试和调试带来了麻烦。同一个模型、同一个提示词,跑两次结果不一样,你怎么确定代码改对了?
我的做法是在采样函数里显式传入np.random.RandomState对象,并用固定的种子初始化。这样每次运行都产生相同的结果,代码改完之后可以方便对比。
def create_generator(seed=None): return np.random.RandomState(seed)另一个方案是写一个纯贪心解码模式。这个模式下不走随机采样,而是直接用np.argmax选logits最大的token。因为它是确定性的,非常适合做单元测试和回归验证。等贪心模式验证通过之后,再去打开采样逻辑,这样你能更精准地隔离问题。
4. 常见问题与排查技巧实录
4.1 生成内容无限重复怎么办
这是自回归推理最常用的“翻车”现场。模型在前几个token还很正常,后面就开始不断重复同一个词或同一个短语。原因主要有三个:
- 采样温度过低——概率分布被压得太尖锐,模型陷入了确定的循环。
- 缺少重复惩罚——这是生成文本重复最核心的诱因之一。模型不会自动意识到自己刚说过什么,需要在采样时主动抑制历史token的概率。
- 上下文窗口的自我强化——一旦模型输出了一个重复片段,这个片段又会作为后续预测的上下文,把模型推向同样的输出。
解决方案,除了调节温度和添加重复惩罚之外,还有一个更简单但非常有效的策略:设置no_repeat_ngram_size。这个参数的含义是生成时禁止出现指定大小的重复n-gram。比如设no_repeat_ngram_size=3,模型就不能生成任何连续三个token在历史中出现过的组合。
4.2 速度慢到不可用怎么办
朴素实现的运行速度本来就不会快,但如果你发现速度“不正常”地慢,可以按下面的顺序排查。
第一优先级是确认KV Cache是否真的生效。有些时候你写了KV Cache但忘了传进去,导致每个step都在重新计算全局。从时间复杂度来看,如果第n步跑一次forward要O(n),那整个生成序列的总复杂度就是O(n²),具体表现为越到后面越慢。正常生效的推理,除了Prefill阶段,后续Decode阶段每步耗时应该基本稳定。
第二优先级是dtype不匹配导致的隐式转换。在numpy里,如果float16的矩阵和float32的矩阵做矩阵乘法,numpy不会报错,而是会把它们都转成float64计算。这个转换的开销是巨大的。我用python -c脚本打印每层的dtype之后,才发现有好几层是float32——早已混了。
第三优先级是验证是否在做不必要的计算。比如有些矩阵乘法本来可以复用,但因为实现太“朴素”,每步都在重复计算。最常见的就是LayerNorm的均值方差重复计算,或者embedding层重复查表。
4.3 结果完全乱码 / token 不对应
这类问题的根源往往是token化流程不对。生成阶段模型的输出是token id,需要用tokenizer把它映射回文本。有些token可能是特殊符号,比如BOS(句子开头)、EOS(句子结束)、空格、换行等。如果你的tokenizer没有正确处理这些特殊token,解码出来的文本就会是一堆乱码。
还有一个排查方向是词表的对齐。检查tokenizer的词表和模型的输出维度是否一致。如果模型词表大小是32000,但tokenizer的词表大小也是32000,那是正常的。不少国内的模型会加一些额外token(比如中文的special token),导致模型输出头的维度比tokenizer词表大,超出部分就是padding或者预留的special token,需要忽略掉。
5. 朴素实现的边界与下一步优化方向
5.1 朴素实现的性能瓶颈到底在哪
讲完实现之后,我们退一步审视这个朴素实现。直接说结论:这个版本在中短序列(512 token以内)上是可以用的,但到了长上下文或者高并发场景,瓶颈会集中在三个地方。
第一个瓶颈是KV Cache的磁盘/显存占用随序列长度线性增长。每个token要存L层 × H头 × D维的Key和Value,深度7B的模型,单条序列生成的KV缓存大约几千MB。这还不算多,但如果同时处理多个请求,显存就会迅速耗尽。这也是为什么后来的推理引擎都在做KV Cache量化、PagedAttention这类优化。
第二个瓶颈是逐token解码的串行性。GPU的并行计算能力很强,但Decode阶段每一步的矩阵乘法的batch size只有1(当前新token对应的Q),计算利用率极低。你可以把Decode阶段理解为:用小算力赶大卡车,每步只需少量矩阵乘法,但必须按步执行。投机采样就是针对这个瓶颈的经典优化——用一个小模型先预测多个候选token,再让大模型一次验证。
第三个瓶颈是动态形状的GPU kernel。训练框架里,矩阵乘法通常针对固定shape做了很多优化。推理阶段序列长度不断变化,如果每步都重新做shape推导、重新选择kernel,开销会非常大。所以工程上常见的做法是提前padding到固定长度,或者使用支持动态shape的推理专用框架(如vLLM、TensorRT-LLM)做blocked KV Cache管理。
5.2 接下来值得优先做的几个优化方向
如果你的朴素版本已经跑通,接下来按照性价比排序,优先做这几个优化。
Continuous Batching(连续批处理)。这是最值得做的一项。朴素实现里,一个请求独占整个GPU。如果同时来10个请求,就得排队等前一个处理完,或者复制多个模型副本。连续批处理是把不同请求的不同Decode步骤合并到一个batch里,GPU的利用率一下子就上去了。推理引擎从vLLM开始大幅依赖这项技术,不是没有原因的。
Blocked KV Cache(分块KV缓存)。把KV Cache分成固定大小的block,结合PagedAttention思想,可以避免显存碎片化问题。这个做法的核心是用索引间接寻址,避免连续分配一个大数组。做的时候注意管理好block的分配和释放,防止内存泄漏。
模型权重的量化(FP16→INT8/INT4)。权重量化后模型体积变小,加载更快,显存占用也更小。推理是显存带宽受限的场景,量化之后访存量降低,Decode速度会有明显提升。从朴素实现到这一步,你的推理引擎已经具备在生产环境跑起来的基础了。
投机采样(Speculative Decoding)。这个优化可以保持输出分布不变的前提下,把Decode速度提升2-3倍。核心思路是用一个小模型做草稿,大模型做验证,一次生成多个token。在长序列生成场景下尤其有效。
这些优化方向每个都能单独开一篇,如果你感兴趣,我会在后续的文章里继续拆解。这里先给你一张对比表,方便判断当前该做哪个:
| 优化方向 | 解决的核心问题 | 实现难度 | 预期收益 |
|---|---|---|---|
| Continuous Batching | GPU利用率低 | 中 | 吞吐量提升 3~10 倍 |
| Blocked KV Cache | 显存碎片化、长序列OOM | 中高 | 支持更长上下文,吞吐量提升 |
| 权重量化 | 显存占用高、访存带宽瓶颈 | 中 | 速度提升 1.5~2 倍 |
| 投机采样 | Decode串行步数多 | 高 | 速度提升 2~3 倍 |
写在最后
做朴素自回归推理最大的价值,不是让你在生产环境直接用它(那太慢了),而是让你真正理解生成式模型的运作机制。KV Cache为什么省时间?采样温度怎么影响多样性?Top-P到底在截断什么?这些概念如果只看论文和框架文档,始终隔了一层。当你亲手实现了这个循环,看到每个token在你的矩阵乘法里一步步蹦出来的时候,你对大模型推理的认识会有一个质的飞跃。
最后再分享一个调试技巧:在跑自回归循环之前,先用贪心模式(temperature=0, top_k=1)做一次确定性输出,确认模型本身是正常的,再去调采样参数。如果贪心输出也乱码,问题一定在模型加载、tokenizer或KV Cache实现,千万不要先去调采样参数。这个顺序能帮你省下一整天。