推理与自回归生成
训练时,模型看到的是正确答案的前文(Teacher Forcing),所有位置可以并行计算。推理时没有正确答案了——模型必须先生成一个 token,把它拼回输入,再生成下一个。
这一节实现自回归生成的核心策略:greedy decoding、temperature sampling、top-k/top-p 截断、beam search。先训练一个小模型,让每种策略的效果都能在输出中直接看到。
自回归生成(Autoregressive Generation)是当前所有 LLM 生成文本的基本方式。模型先生成第一个 token,拼到输入里,再生成第二个,再拼回去,如此循环。
每次只生成一个 token,但每次都要把累积的序列重新算一遍 Attention。这个串行特性是 LLM 推理慢的根本原因,也是模型能根据已生成内容动态调整输出的关键。
1. 推理和训练的根本区别
训练: 有答案 → 所有位置并行算 loss → Teacher Forcing
推理: 没答案 → 只能逐个 token 串行生成 → Autoregressive
自回归的意思:用自己生成的输出,作为下一步的输入。
Step 1: 输入 [BOS] → 模型预测 → 我
Step 2: 输入 [BOS, 我] → 模型预测 → 爱
Step 3: 输入 [BOS, 我, 爱] → 模型预测 → 你
Step 4: 输入 [BOS, 我, 爱, 你] → 模型预测 → EOS → 停止
像贪吃蛇——吃自己的尾巴,越吃越长。
2. 训练一个能看到效果的模型
为了让 temperature、top-k 等策略的效果真实可见,我们需要一个模型:它的输出不是完全确定的(有概率分散),也不是完全随机的。用一段有规律但不唯一的文本作为训练数据:给出几个词,后面的接法有多个合理选项。
这里用一个「多元模式」:每个位置有 2-3 个合理的后继,模型对不同后继有不同的概率。这样 temperature 的效果就能在输出中体现出来。
import torch
import torch.nn as nn
class SimpleGPT(nn.Module):
def __init__(self, vocab_size, d_model=32, num_heads=2, num_layers=2):
super().__init__()
self.d_model = d_model
self.token_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(64, d_model)
self.blocks = nn.ModuleList([
nn.TransformerEncoderLayer(d_model=d_model, nhead=num_heads,
dim_feedforward=4*d_model, batch_first=True, activation='relu')
for _ in range(num_layers)
])
self.lm_head = nn.Linear(d_model, vocab_size)
def forward(self, x):
batch, seq = x.shape
pos = torch.arange(seq, device=x.device).unsqueeze(0).expand(batch, -1)
h = self.token_emb(x) + self.pos_emb(pos)
mask = nn.Transformer.generate_square_subsequent_mask(seq, device=x.device)
for block in self.blocks:
h = block(h, src_mask=mask, is_causal=True)
return self.lm_head(h)
VOCAB = 16
def make_multi_path_data(n=800, seq_len=10):
# 多条路径:从 token 1 出发,有两条可能的路径
# 路径 A: 1→2→3→4→5→6→7→8→1... (70%)
# 路径 B: 1→2→9→10→11→12→7→8→1... (30%)
# 分叉点在位置 2:3 或 9
data = []
for i in range(n):
path = i % 10 # 约 70% 走 A
seq = [1, 2]
if path < 7:
seq.extend([3, 4, 5, 6])
else:
seq.extend([9, 10, 11, 12])
seq.extend([7, 8])
data.append(seq[:seq_len])
return torch.tensor(data)
train_data = make_multi_path_data()
print(f'训练数据: {train_data.shape}')
print(f'路径 A 样本: {train_data[0].tolist()}')
print(f'路径 B 样本: {train_data[7].tolist()}')
print(f'分叉点: 位置2 → token 3 (70%) 或 token 9 (30%)')
import torch
import torch.nn as nn
torch.manual_seed(42)
class SimpleGPT(nn.Module):
def __init__(self, vocab_size, d_model=32, num_heads=2, num_layers=2):
super().__init__()
self.d_model = d_model
self.token_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(64, d_model)
self.blocks = nn.ModuleList([
nn.TransformerEncoderLayer(d_model=d_model, nhead=num_heads,
dim_feedforward=4*d_model, batch_first=True, activation='relu')
for _ in range(num_layers)
])
self.lm_head = nn.Linear(d_model, vocab_size)
def forward(self, x):
batch, seq = x.shape
pos = torch.arange(seq, device=x.device).unsqueeze(0).expand(batch, -1)
h = self.token_emb(x) + self.pos_emb(pos)
mask = nn.Transformer.generate_square_subsequent_mask(seq, device=x.device)
for block in self.blocks:
h = block(h, src_mask=mask, is_causal=True)
return self.lm_head(h)
VOCAB = 16
def make_multi_path_data(n=800, seq_len=10):
# 多条路径:从 token 1 出发,有两条可能 的路径
# 路径 A: 1→2→3→4→5→6→7→8→1... (70%)
# 路径 B: 1→2→9→10→11→12→7→8→1... (30%)
# 分叉点在位置 2:3 或 9
data = []
for i in range(n):
path = i % 10 # 约 70% 走 A
seq = [1, 2]
if path < 7:
seq.extend([3, 4, 5, 6])
else:
seq.extend([9, 10, 11, 12])
seq.extend([7, 8])
data.append(seq[:seq_len])
return torch.tensor(data)
train_data = make_multi_path_data()
print(f'训练数据: {train_data.shape}')
print(f'路径 A 样本: {train_data[0].tolist()}')
print(f'路径 B 样本: {train_data[7].tolist()}')
print(f'分叉点: 位置2 → token 3 (70%) 或 token 9 (30%)')
# 查看模型在分叉点的概率分布
import torch
import torch.nn.functional as F
model.eval()
with torch.no_grad():
# 输入 [1, 2],看位置2的预测
logits = model(torch.tensor([[1, 2]]))
probs = F.softmax(logits[0, -1, :], dim=-1)
print('输入 [1, 2] 后,模型对下一个 token 的概率分布:')
for tok_id in range(VOCAB):
p = probs[tok_id].item()
if p > 0.01:
bar = '█' * int(p * 50)
print(f' token {tok_id:2d}: {p:.3f} {bar}')
print(f'\ntoken 3 (路径A): {probs[3].item():.1%}')
print(f'token 9 (路径B): {probs[9].item():.1%}')
print(f'\n模型学到了两条路径的概率分布!后续 temperature 调整就能改变走哪条路。')
3. Greedy Decoding
每一步选概率最高的 token。优点是确定性和速度快——同样输入总是同样输出。缺点是一旦选了某个 token,就永远失去了探索其他分支的机会。
import torch
def generate_greedy(model, input_ids, max_new_tokens=20, eos_id=None):
model.eval()
generated = input_ids.clone()
with torch.no_grad():
for _ in range(max_new_tokens):
logits = model(generated)
next_logits = logits[0, -1, :]
next_token = torch.argmax(next_logits, dim=-1, keepdim=True)
generated = torch.cat([generated, next_token.unsqueeze(0)], dim=1)
if eos_id is not None and next_token.item() == eos_id:
break
return generated
prompt = torch.tensor([[1, 2]])
result = generate_greedy(model, prompt, max_new_tokens=12)
print(f'Greedy 结果: {result[0].tolist()}')
print(f'\nGreedy 总是选概率最高的,所以每次运行都走路径 A (token 3)。')
print(f'即使路径 B 也有 30% 的概率,Greedy 永远不会选它。')
4. Temperature:控制随机性
Temperature 通过缩放 logits 来控制概率分布的形状:
probability = softmax(logits / temperature)
temperature = 0.1 → 分布极尖 → 几乎 greedy
temperature = 1.0 → 原始分布
temperature = 2.0 → 分布更平 → 更随机
# Temperature 对分叉点的影响
import torch
import torch.nn.functional as F
model.eval()
with torch.no_grad():
logits = model(torch.tensor([[1, 2]]))[0, -1, :]
print('=== Temperature 对分叉点概率的影响 ===')
print(f'{"Temperature":>12} {"P(token 3)":>10} {"P(token 9)":>10} {"比 3/9":>8}')
print('-' * 48)
for T in [0.1, 0.3, 0.5, 1.0, 1.5, 2.0, 5.0]:
probs = F.softmax(logits / T, dim=-1)
p3 = probs[3].item()
p9 = probs[9].item()
ratio = p3 / p9 if p9 > 0 else float('inf')
print(f'{T:>12.1f} {p3:>10.3f} {p9:>10.3f} {ratio:>8.1f}')
print(f'\n低温: token 3 几乎 100%,相当于 greedy')
print(f'高温: token 3 和 9 的差距缩小,模型更可能探索路径 B')
# 可视化
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 4, figsize=(14, 3))
example_logits = logits.clone()
for ax, T in zip(axes, [0.2, 0.7, 1.5, 5.0]):
probs = F.softmax(example_logits / T, dim=-1)
top_k_probs, top_k_idx = torch.topk(probs, 6)
ax.bar(range(6), top_k_probs.numpy())
ax.set_xticks(range(6))
ax.set_xticklabels([str(i.item()) for i in top_k_idx])
ax.set_title(f'T={T}')
ax.set_ylim(0, 1)
plt.suptitle('Temperature 对分叉点概率分布的影响')
plt.tight_layout()
plt.show()
# 不同 temperature 的实际生成结果
import torch
import torch.nn.functional as F
print('同一 prompt [1, 2],不同 temperature 的生成结果:')
print()
for T in [0.1, 0.5, 1.0, 1.5, 2.0]:
results = []
for seed in range(5):
torch.manual_seed(seed)
generated = torch.tensor([[1, 2]])
model.eval()
with torch.no_grad():
for _ in range(12):
logits = model(generated)[0, -1, :] / T
probs = F.softmax(logits, dim=-1)
token = torch.multinomial(probs, 1).unsqueeze(0)
generated = torch.cat([generated, token], dim=1)
results.append(generated[0, 2:6].tolist()) # 看分叉点的选择
paths = ['A' if r[0] == 3 else 'B' for r in results]
print(f'T={T:.1f}: {results} 路径: {paths}')
print(f'\n低温: 几乎全走路径A')
print(f'高温: 开始出现路径B,多样性增加')
5. Top-k 和 Top-p 采样
即使 temperature 调高了,仍有一大堆概率极低的 token。如果碰巧采样到它们,生成质量会很差。
Top-k:只从概率最高的 k 个 token 中选。Top-p(nucleus sampling):从高到低累加概率,加到 p 就停。top-p 自适应——分布集中时选得少,分散时选得多。
import torch
import torch.nn.functional as F
def generate_sampled(model, input_ids, max_new=20, temperature=1.0, top_k=None, top_p=None, seed=42):
torch.manual_seed(seed)
model.eval()
generated = input_ids.clone()
with torch.no_grad():
for _ in range(max_new):
logits = model(generated)[0, -1, :]
logits = logits / max(temperature, 0.01)
if top_k is not None:
topk_vals, _ = torch.topk(logits, top_k)
logits[logits < topk_vals[-1]] = float('-inf')
if top_p is not None:
sorted_l, sorted_idx = torch.sort(logits, descending=True)
cum_probs = torch.cumsum(F.softmax(sorted_l, dim=-1), dim=-1)
remove = cum_probs > top_p
remove[0] = False
logits[sorted_idx[remove]] = float('-inf')
probs = F.softmax(logits, dim=-1)
token = torch.multinomial(probs, 1).unsqueeze(0)
generated = torch.cat([generated, token], dim=1)
return generated
print('=== Top-k vs Top-p 对比 ===')
prompt = torch.tensor([[1, 2]])
print(f'\n同一 prompt [1, 2], temperature=1.0, seed=42:')
for k in [1, 2, 4, None]:
r = generate_sampled(model, prompt, max_new=12, temperature=1.0, top_k=k, seed=42)
label = f'top_k={k}' if k else 'no filter'
print(f' {label:12s}: {r[0].tolist()}')
for p in [0.5, 0.9, 0.99, None]:
r = generate_sampled(model, prompt, max_new=12, temperature=1.0, top_p=p, seed=42)
label = f'top_p={p}' if p else 'no filter'
print(f' {label:12s}: {r[0].tolist()}')
6. Beam Search
Greedy 每步选最优,但局部最优 ≠ 全局最优。Beam Search 同时维护 K 条路径,每步从所有候选中选总分最高的 K 条。
适用:翻译、摘要等「有明确答案」的任务。不适用:创意写作——beam search 让输出变得 boring。
import torch
import torch.nn.functional as F
def beam_search(model, input_ids, beam_size=3, max_new=12):
beams = [(0.0, input_ids.clone())]
for _ in range(max_new):
candidates = []
for score, seq in beams:
with torch.no_grad():
logits = model(seq)[0, -1, :]
log_probs = F.log_softmax(logits, dim=-1)
top_probs, top_idx = torch.topk(log_probs, beam_size)
for i in range(beam_size):
new_seq = torch.cat([seq, top_idx[i].unsqueeze(0).unsqueeze(0)], dim=1)
candidates.append((score + top_probs[i].item(), new_seq))
candidates.sort(key=lambda x: x[0], reverse=True)
beams = candidates[:beam_size]
best_score, best_seq = beams[0]
return best_seq, best_score
prompt = torch.tensor([[1, 2]])
print('=== Beam Search ===')
for bs in [1, 2, 3, 5]:
result, score = beam_search(model, prompt, beam_size=bs, max_new=10)
label = f'Greedy' if bs == 1 else f'Beam k={bs}'
print(f'{label:12s}: {result[0].tolist()} score={score:.2f}')
7. Repetition Penalty
LLM 生成时容易陷入重复循环。Repetition Penalty 对已出现过的 token 降低 logit,打破循环。
惩罚公式:如果 logit > 0,除以 penalty;否则乘以 penalty。penalty > 1 表示施加惩罚。
import torch
import torch.nn.functional as F
def apply_repetition_penalty(logits, token_ids, penalty=1.2):
if penalty == 1.0:
return logits
for tid in set(token_ids.tolist()):
score = logits[tid]
logits[tid] = score / penalty if score > 0 else score * penalty
return logits
# 用一个容易重复的 prompt 来演示
# 多次运行,统计重复率
print('=== Repetition Penalty 效果 ===')
print(f'{"penalty":>8} {"生成序列":>40} {"唯一率":>6}')
print('-' * 60)
prompt = torch.tensor([[1, 2, 3]])
for penalty in [1.0, 1.1, 1.3, 1.5, 2.0]:
torch.manual_seed(42)
generated = prompt.clone()
model.eval()
with torch.no_grad():
for _ in range(15):
logits = model(generated)[0, -1, :].clone()
logits = apply_repetition_penalty(logits, generated[0], penalty)
probs = F.softmax(logits / 0.8, dim=-1)
token = torch.multinomial(probs, 1).unsqueeze(0)
generated = torch.cat([generated, token], dim=1)
tokens = generated[0].tolist()
unique_ratio = len(set(tokens)) / len(tokens)
print(f'{penalty:>8.1f} {str(tokens):>40} {unique_ratio:>5.1%}')
8. 对话模板与 System Prompt
前面的生成都是「给一段文本,接着往下写」。实际使用 LLM 时用对话形式。对话模板把多轮对话拼成模型能理解的单段文本。
不同模型用不同的模板。用错模板会导致回复质量下降。这也是为什么 HuggingFace 的 tokenizer.apply_chat_template() 很有用——它自动处理格式。
messages = [
{'role': 'system', 'content': '你是一个有帮助的助手。'},
{'role': 'user', 'content': '什么是注意力机制?'},
{'role': 'assistant', 'content': '注意力机制是一种...'},
{'role': 'user', 'content': '能举个例子吗?'},
]
def apply_chatml(msgs):
parts = []
for m in msgs:
parts.append(f'<|im_start|>{m["role"]}\n{m["content"]}<|im_end|>')
parts.append('<|im_start|>assistant')
return '\n'.join(parts)
def apply_llama(msgs):
parts = []
for m in msgs:
if m['role'] == 'system':
parts.append(f'<<SYS>>\n{m["content"]}<</SYS>>\n\n')
elif m['role'] == 'user':
parts.append(f'[INST] {m["content"]} [/INST]')
else:
parts.append(f' {m["content"]} </s><s>')
return ''.join(parts)
def apply_alpaca(msgs):
system = next((m['content'] for m in msgs if m['role'] == 'system'), '')
text = 'Below is an instruction. Write a response.\n\n'
if system: text += f'### System\n{system}\n\n'
for m in msgs:
if m['role'] == 'user': text += f'### Instruction\n{m["content"]}\n\n'
elif m['role'] == 'assistant': text += f'### Response\n{m["content"]}\n\n'
text += '### Response\n'
return text
print('=== ChatML (Qwen/Yi) ===')
print(apply_chatml(messages))
print('\n=== Llama Chat ===')
print(apply_llama(messages))
print('\n=== Alpaca ===')
print(apply_alpaca(messages)[:200] + '...')
System Prompt 的写法技巧
几个原则:
- 明确角色:告诉模型「你是谁」
- 给边界:「不能做什么」比「要做什么」更有效
- 给格式:「输出格式是 JSON」或「用 markdown 表格」
- 给示例:一个例子比一百条规则管用(few-shot)
不好的 System Prompt:你是一个助手,请帮助用户。
好的 System Prompt:
你是一位 Python 调试专家。
用户会给你报错信息和相关代码。
你的任务:
1. 找到根本原因(一句话概括)
2. 给出修复代码
3. 解释为什么修复有效
用中文回复。代码用 markdown 代码块包裹。
9. 完整生成 Pipeline
把从 Part 1 到现在的所有概念串成一条线:
用户输入文本
↓ Tokenizer.encode() ← Part 1 & 2
token ID 序列
↓ Embedding + Position ← Part 3
向量序列
↓ N × Transformer Block ← Part 4
logits
↓ Sampling 策略 ← 当前
逐个生成 token
↓ Tokenizer.decode() ← Part 1 & 2
输出文本
GPT-4、Claude、Gemini 都是这条线的超级放大版。
# 完整生成函数:集成所有策略
import torch
import torch.nn.functional as F
def generate(model, input_ids, max_new=30, temperature=1.0, top_k=None, top_p=None,
repetition_penalty=1.0, eos_id=None, seed=None):
if seed is not None:
torch.manual_seed(seed)
model.eval()
generated = input_ids.clone()
with torch.no_grad():
for _ in range(max_new):
logits = model(generated)[0, -1, :].clone()
# 1. Repetition penalty
if repetition_penalty != 1.0:
logits = apply_repetition_penalty(logits, generated[0], repetition_penalty)
# 2. Temperature
logits = logits / max(temperature, 0.01)
# 3. Top-k
if top_k is not None:
topk_vals, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < topk_vals[-1]] = float('-inf')
# 4. Top-p
if top_p is not None:
sorted_l, sorted_idx = torch.sort(logits, descending=True)
cum = torch.cumsum(F.softmax(sorted_l, dim=-1), dim=-1)
remove = cum > top_p
remove[0] = False
logits[sorted_idx[remove]] = float('-inf')
# 5. Sample
probs = F.softmax(logits, dim=-1)
token = torch.multinomial(probs, 1).unsqueeze(0)
generated = torch.cat([generated, token], dim=1)
# 6. EOS check
if eos_id is not None and token.item() == eos_id:
break
return generated
print('完整生成函数定义完成!')
print('\n常见采样执行顺序(框架可能不 同):')
print(' 1. Repetition Penalty → 惩罚已出现的')
print(' 2. Temperature → 调整分布形状')
print(' 3. Top-k / Top-p → 截断低概率')
print(' 4. Sampling → 从最终分布采样')
print(' 5. EOS check → 是否该停')
# 最终对比:不同配置的生成结果
import torch
prompt = torch.tensor([[1, 2]])
configs = [
('Greedy', dict(temperature=0.01, top_k=1)),
('T=0.5', dict(temperature=0.5)),
('T=1.0', dict(temperature=1.0)),
('T=1.5', dict(temperature=1.5)),
('T=1.0, k=2', dict(temperature=1.0, top_k=2)),
('T=1.0, p=0.9', dict(temperature=1.0, top_p=0.9)),
('T=0.8, rep=1.3', dict(temperature=0.8, repetition_penalty=1.3)),
]
print(f'{"配置":>16} 生成结果')
print('-' * 65)
for name, cfg in configs:
r = generate(model, prompt, max_new=12, seed=42, **cfg)
tokens = r[0].tolist()
path = 'A' if 3 in tokens[2:5] else ('B' if 9 in tokens[2:5] else '?')
print(f'{name:>16} {tokens} 路径{path}')
小结
- 推理和训练的区别:训练有答案(并行),推理没答案(串行)
- Greedy 选概率最高——确定但无趣
- Temperature 控制随机度——低温确定,高温多样
- Top-k 限制候选数量,Top-p 按累积概率截断
- Beam Search 维护多条路径——适合翻译/摘要
- Repetition Penalty 打破重复循环
- 对话模板把多轮对话拼成模型可读文本
- System Prompt 通过角色和格式要求引导模型
- 常见采样执行顺序:penalty → temperature → top-k/p → sample → eos;具体顺序以框架实现为准
下一节:拆解推理慢的原因,学习 KV Cache、量化、FlashAttention 等加速技术。
作业
作业 1:Temperature 计算
给定 logits = [2.0, 1.0, 0.5],temperature = 0.5。缩放后的 logits 是多少?
小提示:logits / temperature
import torch
import torch.nn.functional as F
logits = torch.tensor([2.0, 1.0, 0.5])
T = 0.5
scaled = logits / T
probs = F.softmax(scaled, dim=-1)
print(f'缩放后: {scaled.tolist()}')
print(f'概率: {probs.tolist()}')
assert torch.allclose(scaled, torch.tensor([4.0, 2.0, 1.0]))
print('✅ 作业 1 通过')
作业 2:Top-k 过滤
logits = [0.1, 2.0, 0.5, 3.0, 0.01],top_k=2。保留哪些位置?
小提示:找最大的 2 个值的位置。
import torch
logits = torch.tensor([0.1, 2.0, 0.5, 3.0, 0.01])
topk_vals, topk_idx = torch.topk(logits, 2)
print(f'保留的位置: {topk_idx.tolist()} (值: {topk_vals.tolist()})')
assert topk_idx.tolist() == [3, 1]
print('✅ 作业 2 通过: 位置 3 (值3.0) 和位置 1 (值2.0)')
**作业 3:Top-p(Nucleus Sampling)**Top-p 采样选择概率从大到小累积到 p 的最小 token 集合。给定概率分布 [0.4, 0.3, 0.15, 0.1, 0.05],p = 0.8。按概率从大到小累积,找出哪些 token 会被保留。小提示:从最大概率开始累加:0.4 + 0.3 = 0.7 < 0.8,0.4 + 0.3 + 0.15 = 0.85 > 0.8。保留前 3 个 token。
# 作业 3:Top-p 采样import torchprobs = torch.tensor([0.4, 0.3, 0.15, 0.1, 0.05])p = 0.8# TODO: 按概率从大到小排序,计算累积概率sorted_probs, sorted_idx = torch.sort(probs, descending=True)cumsum = torch.cumsum(sorted_probs, dim=0)# 找到累积概率超过 p 的位置# 至少保留概率最大的 tokennum_kept = None # 在这里填入保留的 token 数量assert num_kept is not None, '请先计算保留的 token 数量'assert num_kept == 3, f'Top-p=0.8 时应保留 3 个 token,你得到 {num_kept}'print(f'原始概率: {probs.tolist()}')print(f'排序后: {sorted_probs.tolist()}')print(f'累积: {[f"{x:.2f}" for x in cumsum.tolist()]}')print(f'Top-p={p} 保留 {num_kept} 个 token')print('Top-p 比 Top-k 更灵活:概率集中时少选,分散时多选。')print(chr(10004) + ' 作业 3 通过')
参考资料
- Holtzman et al., The Curious Case of Neural Text Degeneration, 2020 — Nucleus Sampling (top-p)
- Fan et al., Hierarchical Neural Story Generation, 2018 — Top-k sampling
- Keskar et al., CTRL: A Conditional Transformer Language Model, 2019 — Temperature and repetition penalty
- Harvard NLP, The Annotated Transformer