Merge pull request #1502 from CyC2018/update-llm-notes-3

Update 大模型应用.md
This commit is contained in:
CyC2018
2026-10-28 22:54:26 +08:00
committed by GitHub
4 changed files with 375 additions and 204 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

+284
View File
@@ -0,0 +1,284 @@
# 大模型应用 - 进阶
<!-- GFM-TOC -->
* [大模型应用 - 进阶](#大模型应用---进阶)
* [一、大模型基础](#一大模型基础)
* [注意力机制](#注意力机制)
* [自回归生成与 KV Cache](#自回归生成与-kv-cache)
* [训练过程](#训练过程)
* [微调](#微调)
* [量化](#量化)
* [附录:实测](#附录实测)
* [A1 多头注意力、GQA 与 KV Cache 的实现](#a1-多头注意力gqa-与-kv-cache-的实现)
* [A2 在线 softmax 与注意力的计算量](#a2-在线-softmax-与注意力的计算量)
* [A3 ZeRO 的显存与 MLA 的 KV Cache](#a3-zero-的显存与-mla-的-kv-cache)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
## 一、大模型基础
### 注意力机制
#### 1. 多头注意力的实现
主线见[大模型应用 · 多头注意力](大模型应用.md#1-多头注意力),那里以 Qwen2.5-7B 为例给出了各步的形状变化。
手写的实现如下(PyTorch,与 torch.nn.MultiheadAttention 用同一组权重对照,最大误差为 1.8 × 10<sup>-7</sup>,见[附录 A1](#a1-多头注意力gqa-与-kv-cache-的实现)):
```python
class MHA(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.h, self.dk = n_heads, d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model) # 一次算出 Q、K、V
self.out = nn.Linear(d_model, d_model)
def forward(self, x): # x: (B, n, d_model)
B, n, d = x.shape
q, k, v = self.qkv(x).split(d, dim=-1)
# (B, n, d) -> (B, n, h, dk) -> (B, h, n, dk):每个头只用其中 dk 维
q, k, v = [t.view(B, n, self.h, self.dk).transpose(1, 2) for t in (q, k, v)]
scores = q @ k.transpose(-2, -1) / self.dk ** 0.5 # (B, h, n, n)
mask = torch.ones(n, n, dtype=torch.bool).triu(1) # 因果掩码:右上角
scores = scores.masked_fill(mask, float("-inf"))
o = scores.softmax(dim=-1) @ v # (B, h, n, dk)
o = o.transpose(1, 2).contiguous().view(B, n, d) # 拼接各头
return self.out(o)
```
拼接之前要调用 contiguous(),这是因为 transpose 只改变了张量各维的步长,数据在内存中仍按原来的顺序排列,这时直接调用 view 会报错。contiguous() 按新的顺序复制一份连续的数据,也可以直接用 reshape,它在需要时自动复制。
#### 2. 计算量与 FlashAttention
主线见[大模型应用 · 计算量与 FlashAttention](大模型应用.md#2-计算量与-flashattention),这里先比较线性层与注意力的计算量,再看 FlashAttention 怎样在不改变结果的前提下减少显存的读写。
序列长度为 n、向量维度为 d 时,QK<sup>T</sup> 和乘以 V 各需要约 n<sup>2</sup>d 次乘加,时间复杂度为 O(n<sup>2</sup>d),分数矩阵占用 O(n<sup>2</sup>) 的显存。按 Qwen2.5-7B 的配置计算,每个 Token 经过一层的线性层约需 4.7 亿次浮点运算,与上下文长度无关;生成第 T 个 Token 时,一层注意力的分数和加权求和约需 14336 × T 次,T 约为 3.2 万时两者相当(见[附录 A2](#a2-在线-softmax-与注意力的计算量))。所以上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大。
标准实现分几步完成:算出 n × n 的分数矩阵写入显存,读出来做 softmax 再写回,再读出来乘以 V。GPU 的显存(HBM)容量大但带宽有限,A100 为 1.5~2.0 TB/s;每个计算单元旁边的片上缓存(SRAM)带宽约 19 TB/s,但总共只有约 20 MB。这个矩阵又很大,n = 32K 时,Qwen2.5-7B 一个头的分数矩阵在 BF16 下就占 2 GB,大部分时间花在了读写显存上。
FlashAttention 把计算分块:每次把一块 Q 和一块 K、V 读入 SRAM,算出这一块的分数后直接累积到输出中,n × n 的矩阵从不写回显存。难点在于 softmax 要除以全部分数的指数和,只看到一部分 K 时还不知道这个和。解决方法是在线 softmax:对每个查询维护目前为止的最大分数 m、指数和 l 以及未归一化的输出 o,读入新的一块后,如果最大值变大了,就把之前的 l 和 o 乘以 e<sup>m<sub>旧</sub> − m<sub>新</sub></sup> 再累加。例如一个查询与 4 个 Token 的分数为 2、1、3、0,对应的 V 为 10、20、30、40,每块 2 个 Token:
```text
第 1 块(分数 2、1) m = 2 l = e^0 + e^-1 = 1.368
o = 10 × e^0 + 20 × e^-1 = 17.358
第 2 块(分数 3、0) m = 3 之前的 l 和 o 乘以 e^(2-3) = 0.368
l = 1.368 × 0.368 + e^0 + e^-3 = 1.553
o = 17.358 × 0.368 + 30 × e^0 + 40 × e^-3 = 38.377
输出 o / l = 24.711,与一次性做 softmax 的结果相同
```
所以 FlashAttention 是精确计算,而不是近似(numpy 实测,见[附录 A2](#a2-在线-softmax-与注意力的计算量))。反向传播时也不保存分数矩阵,只保存 m 和 l,需要时重新计算分数,多做了一些计算,但显存的读写更少,整体仍然更快。论文在 A100 上计算 GPT-2 medium 的注意力(序列长度 1024),浮点运算量从 66.6 G 增加到 75.2 G,显存读写却从 40.3 GB 降到 4.4 GB,运行时间从 41.7 毫秒降到 7.3 毫秒。FlashAttention-2 进一步减少了矩阵乘法以外的运算,并沿序列长度的方向并行,速度又提高了约 1 倍。
### 自回归生成与 KV Cache
#### 1. 带 KV Cache 的 GQA
主线见[大模型应用 · 自回归生成与 KV Cache](大模型应用.md#自回归生成与-kv-cache),这里接着上文多头注意力的实现,给出解码时带 KV Cache 的 GQA。
带 KV Cache 的 GQA 在解码时每一步只输入新的 Token,把它的 K、V 追加到缓存末尾,再与缓存中的全部 K、V 计算注意力:
```python
k = torch.cat([cache["k"], k_new], dim=2) # (B, KV 头数, 已有长度 + 1, 每个头的维度)
v = torch.cat([cache["v"], v_new], dim=2)
cache["k"], cache["v"] = k, v # 缓存的是 4 组 K、V,而不是 28 个头
g = n_heads // n_kv_heads # 每组 K、V 由 g 个查询头共用
k, v = k.repeat_interleave(g, dim=1), v.repeat_interleave(g, dim=1)
o = (q_new @ k.transpose(-2, -1) / dk ** 0.5).softmax(-1) @ v # 新 Token 可以看到全部前文,不需要掩码
```
这样逐个 Token 解码的结果与整段一次计算的结果一致,最大误差为 1.9 × 10<sup>-7</sup>(完整代码见[附录 A1](#a1-多头注意力gqa-与-kv-cache-的实现))。
#### 2. 多头潜在注意力
主线见[大模型应用 · 自回归生成与 KV Cache](大模型应用.md#自回归生成与-kv-cache)中减少 KV Cache 的方法。
多头潜在注意力(Multi-head Latent Attention, MLA)由 DeepSeek-V2 提出。GQA 减少的是 K、V 的组数,MLA 则把 K、V 联合压缩成一个低维的潜在向量,只缓存这个向量,计算时再用矩阵还原出各个头的 K、V;推理时还原矩阵可以合并到 Q 和输出的矩阵中,不必真的算出每个头的 K、V。RoPE 会妨碍这种合并,所以另用一个所有头共享的小向量携带位置信息。DeepSeek-V3 每层每个 Token 只缓存 512 + 64 = 576 个数,61 层共 68.6 KB,与 Qwen2.5-7B 的 56 KB 相近;同样的 128 个头如果用 MHA,每个 Token 要 4.77 MB(按 config.json 计算,见[附录 A3](#a3-zero-的显存与-mla-的-kv-cache))。论文中 MLA 的缓存量只相当于 2.25 组的 GQA,效果却好于 MHA。
### 训练过程
#### 1. 广义优势估计
主线见[大模型应用 · RLHF 与 PPO](大模型应用.md#2-rlhf-与-ppo),这里展开 PPO 中每个 Token 的优势怎样计算。
把生成过程看作强化学习:状态是提示词和已经生成的 Token,动作是下一个 Token,奖励模型的分数在回答结束时才给出。每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)计算:先算每一步的时序差分误差 δ<sub>t</sub> = r<sub>t</sub> + γV(s<sub>t+1</sub>) − V(s<sub>t</sub>),即走完这一步后价值模型的估计比之前高了多少,优势是之后各步 δ 的加权和 Σ (γλ)<sup>k</sup> δ<sub>t+k</sub>。λ = 0 时只看一步,依赖价值模型的估计,方差小但有偏差;λ = 1 时相当于最终得分减去 V(s<sub>t</sub>),没有偏差但方差大;λ 取两者之间的值来折中。
#### 2. GRPO 的改进
主线见[大模型应用 · GRPO 与三者的选择](大模型应用.md#4-grpo-与三者的选择)。
大规模训练推理模型时,GRPO 暴露出几个问题,后续的算法分别做了改进:
- 全对或全错的题没有梯度:模型越学越好,全对的题越来越多,一批中有效的题目越来越少。DAPO 的动态采样多采一些题,丢掉全对和全错的,直到填满一批;
- 熵坍塌(Entropy Collapse):模型输出的熵在训练中很快下降,同一道题的多个回答几乎一样,失去了探索新解法的能力。原因之一是概率之比的上限:ε = 0.2 时,概率为 0.9 的 Token 一次可以升到 1,而概率为 0.01 的 Token 最多只能升到 0.012。DAPO 把上下限分开,下限仍为 0.2,上限放宽到 0.28;
- 长度偏差:GRPO 先在每个回答内部按 Token 数求平均,长回答中每个 Token 的权重变小,错误的长回答受到的惩罚被稀释,回答越来越长。DAPO 改为把一批中所有回答的 Token 放在一起平均;
- MoE 模型训练不稳定:每次更新后,同一个回答激活的专家会发生变化,Qwen3-30B-A3B 中约 10% 不同,按 Token 计算的概率之比剧烈波动。GSPO 改为按整个回答计算概率之比(取每个 Token 概率之比的几何平均),并对整个回答截断,Qwen3 的强化学习使用了这种方法。
DAPO 还去掉了 KL 项,理由是训练长推理时模型本来就要远离初始模型。它用 Qwen2.5-32B 基座模型在 AIME 2024 上达到 50 分,最初直接使用 GRPO 时只有 30 分。
### 微调
#### 1. ZeRO 与 FSDP
主线见[大模型应用 · QLoRA 与全参数微调](大模型应用.md#4-qlora-与全参数微调),这里展开全参数微调时怎样把模型状态分到多张卡上。
全参数微调一张卡放不下时要用多张卡。普通的数据并行在每张卡上保存完整的模型状态,增加卡数并不能减少每张卡的显存。DeepSpeed 的 ZeRO 把模型状态切分到各张卡上,按切分的内容分为三个阶段。以 Qwen2.5-7B 在 8 张卡上训练为例,只算模型状态,不含激活(见[附录 A3](#a3-zero-的显存与-mla-的-kv-cache)):
| 阶段 | 切分的内容 | 每张卡的显存 |
| --- | --- | --- |
| 普通数据并行 | 不切分 | 121.8 GB |
| ZeRO-1 | 优化器状态 | 41.9 GB |
| ZeRO-2 | 优化器状态、梯度 | 28.6 GB |
| ZeRO-3 | 优化器状态、梯度、参数 | 15.2 GB |
优化器状态每个参数占 12 字节,是模型状态中最大的部分,所以 ZeRO-1 的收益最明显。ZeRO-1、ZeRO-2 的通信量与普通数据并行相同;ZeRO-3 在每一层计算前都要从其它卡收集完整的参数,用完即丢弃,通信量约为 1.5 倍。PyTorch 的 FSDP 采用同样的思路,FULL_SHARD 对应 ZeRO-3,SHARD_GRAD_OP 对应 ZeRO-2。显存仍然不够时,还可以把参数或优化器状态卸载到 CPU 内存,代价是训练变慢。
### 量化
#### 1. GPTQ 与 AWQ
主线见[大模型应用 · GPTQ 与 AWQ](大模型应用.md#3-gptq-与-awq),这里展开两种方法的具体做法。
GPTQ 逐层量化,每层按列逐个量化权重:每量化一列,就根据校准数据算出的二阶信息(输入的 XX<sup>T</sup>),调整还没有量化的那些列,抵消这一列产生的输出误差。论文中 175B 的模型在一张显卡上约 4 小时就能量化到 3~4 位。
AWQ(Activation-aware Weight Quantization)基于这样的观察:权重的重要程度不同,与较大的激活相乘的那部分权重对输出影响最大,只保护其中 1% 就能明显减小误差。但为个别通道保留 FP16 会形成混合精度,硬件上效率不高。AWQ 的做法是:
1. 用校准数据统计每个输入通道激活的平均大小 s<sub>X</sub>;
2. 量化之前把权重的第 j 个输入通道乘以 s<sub>j</sub> = s<sub>X,j</sub><sup>α</sup>,推理时把输入的第 j 个通道除以 s<sub>j</sub>,乘积不变;
3. 重要的通道被放大之后,相对的量化误差变小;在 [0, 1] 中搜索使输出误差最小的 α。
对 Qwen2.5-7B 第 14 层 q_proj 做每 128 个一组的 INT4 量化,这样缩放之后输出的相对误差从 8.98% 降到 7.86%(α = 0.3,见[大模型应用 · 附录 A18](大模型应用.md#a18-权重量化的误差与困惑度))。两者的区别:GPTQ 通过修改未量化的权重来补偿误差;AWQ 只做通道的缩放,不需要反向传播和重建,对校准数据的依赖更小。
## 附录:实测
以下实验在 Apple M1 Pro、macOS 上运行,软件版本见各条。
### A1 多头注意力、GQA 与 KV Cache 的实现
环境:Apple M1 Pro,macOS 15.8.1,Python 3.13.7,PyTorch 2.8.0(CPU)。正文中的 MHA 类与 torch.nn.MultiheadAttention 复制同一组权重后对照;下面的 GQA 类带 KV Cache,先预填充 5 个 Token,再逐个输入 7 个 Token,与整段一次计算的结果对照,并与 PyTorch 自带的 scaled_dot_product_attention(enable_gqa=True) 对照(d_model = 64,8 个查询头,2 组 K、V):
```python
class GQA(nn.Module):
def __init__(self, d_model, n_heads, n_kv_heads):
super().__init__()
self.h, self.hkv, self.dk = n_heads, n_kv_heads, d_model // n_heads
self.wq = nn.Linear(d_model, n_heads * self.dk)
self.wk = nn.Linear(d_model, n_kv_heads * self.dk) # K、V 只有 n_kv_heads 组
self.wv = nn.Linear(d_model, n_kv_heads * self.dk)
self.out = nn.Linear(d_model, d_model)
def forward(self, x, cache=None): # x: (B, t, d),解码时 t = 1
B, t, d = x.shape
q = self.wq(x).view(B, t, self.h, self.dk).transpose(1, 2)
k = self.wk(x).view(B, t, self.hkv, self.dk).transpose(1, 2)
v = self.wv(x).view(B, t, self.hkv, self.dk).transpose(1, 2)
if cache is not None: # 新 Token 的 K、V 追加到缓存末尾
if "k" in cache:
k = torch.cat([cache["k"], k], dim=2); v = torch.cat([cache["v"], v], dim=2)
cache["k"], cache["v"] = k, v # 缓存的是 hkv 组,而不是 h 个头
g = self.h // self.hkv # 每组 K、V 由 g 个查询头共用
k, v = k.repeat_interleave(g, dim=1), v.repeat_interleave(g, dim=1)
T = k.size(2)
scores = q @ k.transpose(-2, -1) / self.dk ** 0.5 # (B, h, t, T)
mask = torch.ones(t, T, dtype=torch.bool).triu(T - t + 1) # 第 i 个新 Token 只看前 T-t+i+1 个
o = scores.masked_fill(mask, float("-inf")).softmax(-1) @ v
return self.out(o.transpose(1, 2).contiguous().view(B, t, d))
g = GQA(64, 8, 2)
x = torch.randn(1, 12, 64)
full = g(x) # 整段一次计算(训练、预填充)
cache, steps = {}, []
p = g(x[:, :5], cache) # 预填充前 5 个
for i in range(5, 12): # 之后每次只输入 1 个新 Token
steps.append(g(x[:, i:i + 1], cache))
inc = torch.cat([p] + steps, dim=1)
print("3. KV Cache 逐步解码与整段计算的最大误差:", (inc - full).abs().max().item())
print(" 缓存的 K 形状:", tuple(cache["k"].shape), "(8 个查询头,只缓存 2 组)")
```
输出:
```text
1. 与 nn.MultiheadAttention 的最大误差: 1.7881393432617188e-07
2. transpose 后 is_contiguous = False
直接 view 报错: view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces)
reshape 等价于 contiguous().view,是否复制了数据: True
3. KV Cache 逐步解码与整段计算的最大误差: 1.862645149230957e-07
缓存的 K 形状: (1, 2, 12, 8) (8 个查询头,只缓存 2 组)
与 scaled_dot_product_attention(enable_gqa=True) 的最大误差: 1.4901161193847656e-07
torch 2.8.0
```
### A2 在线 softmax 与注意力的计算量
环境:Python 3.13.7,numpy 2.3.3。按块读取 K、V,用在线 softmax 累积输出,与一次性计算的标准注意力对照(n = 1000,d = 64);再按 Qwen2.5-7B 的 config.json(hidden_size 3584,28 层,28 个查询头、4 组 K、V,intermediate_size 18944)计算分数矩阵的大小,以及每层线性层与注意力的计算量(不含偏置和归一化):
```python
def online(q, K, V, block):
m, l, o = -np.inf, 0.0, np.zeros(V.shape[1]) # 当前最大值、指数和、未归一化的输出
for i in range(0, len(K), block):
s = K[i:i + block] @ q / np.sqrt(len(q)) # 只算这一块的分数
m_new = max(m, s.max())
scale = np.exp(m - m_new) # 旧的累积量按新的最大值缩放
p = np.exp(s - m_new)
l = l * scale + p.sum()
o = o * scale + p @ V[i:i + block]
m = m_new
return o / l
```
```text
1. 块大小 1:与标准 softmax 注意力的最大误差 3.05e-16
1. 块大小 16:与标准 softmax 注意力的最大误差 1.11e-16
1. 块大小 128:与标准 softmax 注意力的最大误差 1.53e-16
2. 第 1 块后 m=2, l=1.368, o=17.358
第 2 块:缩放系数 e^(2-3)=0.368, l=1.553, o=38.377, 输出 o/l=24.711
一次 softmax:权重 [0.237 0.087 0.644 0.032], 输出 24.711
3. n = 4096:一个头的 n×n 分数矩阵(BF16)= 32 MiB,一层 28 个头 = 0.9 GiB
3. n = 32768:一个头的 n×n 分数矩阵(BF16)= 2048 MiB,一层 28 个头 = 56.0 GiB
每层线性层参数 233.0M,每个 Token 的线性层计算量 2×233.0M = 466M FLOPs/层
生成第 T 个 Token 时,注意力分数和加权求和的计算量 = 4 × T × 28×128 = 14336T FLOPs/层
两者相等时 T = 32512
```
线性层每层的参数为 3584 × 3584 × 2(q_proj、o_proj)+ 3584 × 512 × 2(k_proj、v_proj)+ 3 × 3584 × 18944(前馈网络),每个参数做一次乘加计为 2 次浮点运算。
### A3 ZeRO 的显存与 MLA 的 KV Cache
按 ZeRO 论文的记法,混合精度训练时每个参数的模型状态为 2 字节(BF16 参数)+ 2 字节(BF16 梯度)+ K 字节(FP32 参数副本和 Adam 的两个状态,K = 12),N 张卡时 ZeRO-1、ZeRO-2、ZeRO-3 每张卡分别为 4 + K / N、2 + (2 + K) / N、(4 + K) / N 字节。先用论文中的例子(7.5B,64 张卡)核对公式,再代入 Qwen2.5-7B(7,615,616,512 个参数)。MLA 按 DeepSeek-V3 的 config.json(kv_lora_rank 512,qk_rope_head_dim 64,qk_nope_head_dim 128,v_head_dim 128,128 个头,61 层)和 BF16 计算:
```text
Qwen2.5-7B,8 张卡,只算模型状态(不含激活):
不切分(普通数据并行) 每参数 16.00 字节,每张卡 121.8 GB
ZeRO-1:切分优化器状态 每参数 5.50 字节,每张卡 41.9 GB
ZeRO-2:再切分梯度 每参数 3.75 字节,每张卡 28.6 GB
ZeRO-3:再切分参数 每参数 2.00 字节,每张卡 15.2 GB
核对 ZeRO 论文 7.5B、64 卡: [120.0, 31.4, 16.6, 1.9]
DeepSeek-V3:61 层,128 个头,c_KV 512 维 + RoPE 键 64 维
MLA 每个 Token:(512+64) × 61 × 2 字节 = 70272 字节 = 68.6 KB
同样的头数和维度用 MHA:128 × (192+128) × 61 × 2 = 4.77 MB,是 MLA 的 71 倍
对照 Qwen2.5-7B(GQA):56 KB
```
论文中的数值为 120 GB、31.4 GB、16.6 GB、1.9 GB,与上面的核对结果一致。
## 参考资料
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
- [FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness](https://arxiv.org/abs/2205.14135)
- [Dao T. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. arXiv:2307.08691](https://arxiv.org/abs/2307.08691)
- [NVIDIA A100 Tensor Core GPU](https://www.nvidia.com/en-us/data-center/a100/)
- [GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints](https://arxiv.org/abs/2305.13245)
- [Qwen2.5-7B-Instruct 模型配置](https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/blob/main/config.json)
- [DeepSeek-AI. DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model. arXiv:2405.04434](https://arxiv.org/abs/2405.04434)
- [DeepSeek-V3 config.json](https://huggingface.co/deepseek-ai/DeepSeek-V3/blob/main/config.json)
- [Proximal Policy Optimization Algorithms](https://arxiv.org/abs/1707.06347)
- [Schulman J, et al. High-Dimensional Continuous Control Using Generalized Advantage Estimation. arXiv:1506.02438](https://arxiv.org/abs/1506.02438)
- [DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models](https://arxiv.org/abs/2402.03300)
- [Yu Q, et al. DAPO: An Open-Source LLM Reinforcement Learning System at Scale. arXiv:2503.14476](https://arxiv.org/abs/2503.14476)
- [Zheng C, et al. Group Sequence Policy Optimization. arXiv:2507.18071](https://arxiv.org/abs/2507.18071)
- [Liu Z, et al. Understanding R1-Zero-Like Training: A Critical Perspective. arXiv:2503.20783](https://arxiv.org/abs/2503.20783)
- [Qwen Team. Qwen3 Technical Report. arXiv:2505.09388](https://arxiv.org/abs/2505.09388)
- [ZeRO: Memory Optimizations Toward Training Trillion Parameter Models](https://arxiv.org/abs/1910.02054)
- [PyTorch FullyShardedDataParallel](https://docs.pytorch.org/docs/stable/fsdp.html)
- [GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers](https://arxiv.org/abs/2210.17323)
- [AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration](https://arxiv.org/abs/2306.00978)
+91 -204
View File
@@ -76,12 +76,10 @@
* [A20 Agent 任务的 Token 数与前缀缓存](#a20-agent-任务的-token-数与前缀缓存)
* [A21 batch 大小与吞吐、延迟](#a21-batch-大小与吞吐延迟)
* [A22 模型输出 JSON 的修复、校验与重试](#a22-模型输出-json-的修复校验与重试)
* [A23 多头注意力、GQA 与 KV Cache 的实现](#a23-多头注意力gqa-与-kv-cache-的实现)
* [A24 在线 softmax 与注意力的计算量](#a24-在线-softmax-与注意力的计算量)
* [A25 ZeRO 的显存与 MLA 的 KV Cache](#a25-zero-的显存与-mla-的-kv-cache)
* [A26 LoRA 的三种初始化](#a26-lora-的三种初始化)
* [A27 MCP 服务端的最小实现](#a27-mcp-服务端的最小实现)
* [A28 并发执行工具调用](#a28-并发执行工具调用)
* [A23 LoRA 的三种初始化](#a23-lora-的三种初始化)
* [A24 MCP 服务端的最小实现](#a24-mcp-服务端的最小实现)
* [A25 并发执行工具调用](#a25-并发执行工具调用)
* [A26 停止前校验](#a26-停止前校验)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -208,51 +206,15 @@ Q = x W_Q (n, 3584)
交换回来并拼接 (n, 3584) 再乘以 W_O
```
每个头的维度是 3584 / 28,所以多头注意力的计算量与一个 3584 维的单头注意力基本相同,多出来的只是切分和拼接。手写的实现如下(PyTorch,与 torch.nn.MultiheadAttention 用同一组权重对照,最大误差为 1.8 × 10<sup>-7</sup>,见[附录 A23](#a23-多头注意力gqa-与-kv-cache-的实现)):
```python
class MHA(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.h, self.dk = n_heads, d_model // n_heads
self.qkv = nn.Linear(d_model, 3 * d_model) # 一次算出 Q、K、V
self.out = nn.Linear(d_model, d_model)
def forward(self, x): # x: (B, n, d_model)
B, n, d = x.shape
q, k, v = self.qkv(x).split(d, dim=-1)
# (B, n, d) -> (B, n, h, dk) -> (B, h, n, dk):每个头只用其中 dk 维
q, k, v = [t.view(B, n, self.h, self.dk).transpose(1, 2) for t in (q, k, v)]
scores = q @ k.transpose(-2, -1) / self.dk ** 0.5 # (B, h, n, n)
mask = torch.ones(n, n, dtype=torch.bool).triu(1) # 因果掩码:右上角
scores = scores.masked_fill(mask, float("-inf"))
o = scores.softmax(dim=-1) @ v # (B, h, n, dk)
o = o.transpose(1, 2).contiguous().view(B, n, d) # 拼接各头
return self.out(o)
```
拼接之前要调用 contiguous(),这是因为 transpose 只改变了张量各维的步长,数据在内存中仍按原来的顺序排列,这时直接调用 view 会报错。contiguous() 按新的顺序复制一份连续的数据,也可以直接用 reshape,它在需要时自动复制。
每个头的维度是 3584 / 28,所以多头注意力的计算量与一个 3584 维的单头注意力基本相同,多出来的只是切分和拼接。用 PyTorch 手写的实现见[进阶:多头注意力的实现](大模型应用%20-%20进阶.md#1-多头注意力的实现)。
#### 2. 计算量与 FlashAttention
序列长度为 n、向量维度为 d 时,QK<sup>T</sup> 和乘以 V 各需要约 n<sup>2</sup>d 次乘加,时间复杂度为 O(n<sup>2</sup>d),分数矩阵占用 O(n<sup>2</sup>) 的显存。按 Qwen2.5-7B 的配置计算,每个 Token 经过一层的线性层约需 4.7 亿次浮点运算,与上下文长度无关;生成第 T 个 Token 时,一层注意力的分数和加权求和约需 14336 × T 次,T 约为 3.2 万时两者相当(见[附录 A24](#a24-在线-softmax-与注意力的计算量))。所以上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大。
序列长度为 n、向量维度为 d 时,QK<sup>T</sup> 和乘以 V 各需要约 n<sup>2</sup>d 次乘加,时间复杂度为 O(n<sup>2</sup>d),分数矩阵占用 O(n<sup>2</sup>) 的显存。上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大,按 Qwen2.5-7B 的配置计算,两者在约 3.2 万个 Token 时相当。
降低这部分开销有两类做法。一类让每个 Token 只看一部分 Token,或者改写 softmax,结果与标准注意力不同:例如 Mistral 7B 的滑动窗口注意力中,每层的每个 Token 只看前 4096 个 Token;线性注意力用核函数代替 softmax,利用矩阵乘法的结合律把复杂度降为 O(n)。另一类结果不变,只减少显存的读写,即 FlashAttention。
标准实现分几步完成:算出 n × n 的分数矩阵写入显存,读出来做 softmax 再写回,再读出来乘以 V。GPU 的显存(HBM)容量大但带宽有限,A100 为 1.5~2.0 TB/s;每个计算单元旁边的片上缓存(SRAM)带宽约 19 TB/s,但总共只有约 20 MB。这个矩阵又很大,n = 32K 时,Qwen2.5-7B 一个头的分数矩阵在 BF16 下就占 2 GB,大部分时间花在了读写显存上。
FlashAttention 把计算分块:每次把一块 Q 和一块 K、V 读入 SRAM,算出这一块的分数后直接累积到输出中,n × n 的矩阵从不写回显存。难点在于 softmax 要除以全部分数的指数和,只看到一部分 K 时还不知道这个和。解决方法是在线 softmax:对每个查询维护目前为止的最大分数 m、指数和 l 以及未归一化的输出 o,读入新的一块后,如果最大值变大了,就把之前的 l 和 o 乘以 e<sup>m<sub>旧</sub> − m<sub>新</sub></sup> 再累加。例如一个查询与 4 个 Token 的分数为 2、1、3、0,对应的 V 为 10、20、30、40,每块 2 个 Token:
```text
第 1 块(分数 2、1) m = 2 l = e^0 + e^-1 = 1.368
o = 10 × e^0 + 20 × e^-1 = 17.358
第 2 块(分数 3、0) m = 3 之前的 l 和 o 乘以 e^(2-3) = 0.368
l = 1.368 × 0.368 + e^0 + e^-3 = 1.553
o = 17.358 × 0.368 + 30 × e^0 + 40 × e^-3 = 38.377
输出 o / l = 24.711,与一次性做 softmax 的结果相同
```
所以 FlashAttention 是精确计算,而不是近似(numpy 实测,见[附录 A24](#a24-在线-softmax-与注意力的计算量))。反向传播时也不保存分数矩阵,只保存 m 和 l,需要时重新计算分数,多做了一些计算,但显存的读写更少,整体仍然更快。论文在 A100 上计算 GPT-2 medium 的注意力(序列长度 1024),浮点运算量从 66.6 G 增加到 75.2 G,显存读写却从 40.3 GB 降到 4.4 GB,运行时间从 41.7 毫秒降到 7.3 毫秒。FlashAttention-2 进一步减少了矩阵乘法以外的运算,并沿序列长度的方向并行,速度又提高了约 1 倍。
FlashAttention 把计算分块放进 GPU 的片上缓存完成,n × n 的分数矩阵从不写回显存,再用在线 softmax 逐块累积,结果与标准注意力完全相同。它减少的是显存的读写而不是计算量,原理和数值例子见[进阶:计算量与 FlashAttention](大模型应用%20-%20进阶.md#2-计算量与-flashattention)。
### 自回归生成与 KV Cache
@@ -278,24 +240,13 @@ KV Cache 是用显存换计算。每个 Token 要在每一层缓存 K 和 V 两
减少 KV Cache 的方法:
- 分组查询注意力(Grouped-Query Attention, GQA):多个查询头共享一组 K、V。Qwen2.5-7B 有 28 个查询头、4 组 K、V,如果每个查询头都有自己的 K、V,每个 Token 要占用 392 KB。极端情况是所有查询头共享一组 K、V,称为多查询注意力(Multi-Query Attention, MQA),缓存最小,但效果下降更明显;
- 多头潜在注意力(Multi-head Latent Attention, MLA):DeepSeek-V2 提出。GQA 减少的是 K、V 的组数,MLA 则把 K、V 联合压缩成一个低维的潜在向量,只缓存这个向量,计算时再用矩阵还原出各个头的 K、V;推理时还原矩阵可以合并到 Q 和输出的矩阵中,不必真的算出每个头的 K、V。RoPE 会妨碍这种合并,所以另用一个所有头共享的小向量携带位置信息。DeepSeek-V3 每层每个 Token 只缓存 512 + 64 = 576 个数,61 层共 68.6 KB,与 Qwen2.5-7B 的 56 KB 相近;同样的 128 个头如果用 MHA,每个 Token 要 4.77 MB(按 config.json 计算,见[附录 A25](#a25-zero-的显存与-mla-的-kv-cache))。论文中 MLA 的缓存量只相当于 2.25 组的 GQA,效果却好于 MHA;
- 多头潜在注意力(Multi-head Latent Attention, MLA):DeepSeek-V2 提出。GQA 减少的是 K、V 的组数,MLA 则把 K、V 联合压缩成一个低维的潜在向量,只缓存这个向量,计算时再用矩阵还原出各个头的 K、V。DeepSeek-V3 每个 Token 只缓存 68.6 KB,与 Qwen2.5-7B 的 56 KB 相近,细节见[进阶:多头潜在注意力](大模型应用%20-%20进阶.md#2-多头潜在注意力);
- PagedAttention:推理框架 vLLM 把 KV Cache 切成固定大小的块按需分配,不再按最大长度预留连续的显存,相同前缀的请求还可以共享块,见[推理服务部署](#4-pagedattention);
- 量化:用 8 位甚至更少的位数存储 K、V。
<div align="center"> <img src="pics/gqa.png" width="680px"/> </div><br>
带 KV Cache 的 GQA 在解码时每一步只输入新的 Token,把它的 K、V 追加到缓存末尾,再与缓存中的全部 K、V 计算注意力:
```python
k = torch.cat([cache["k"], k_new], dim=2) # (B, KV 头数, 已有长度 + 1, 每个头的维度)
v = torch.cat([cache["v"], v_new], dim=2)
cache["k"], cache["v"] = k, v # 缓存的是 4 组 K、V,而不是 28 个头
g = n_heads // n_kv_heads # 每组 K、V 由 g 个查询头共用
k, v = k.repeat_interleave(g, dim=1), v.repeat_interleave(g, dim=1)
o = (q_new @ k.transpose(-2, -1) / dk ** 0.5).softmax(-1) @ v # 新 Token 可以看到全部前文,不需要掩码
```
这样逐个 Token 解码的结果与整段一次计算的结果一致,最大误差为 1.9 × 10<sup>-7</sup>(完整代码见[附录 A23](#a23-多头注意力gqa-与-kv-cache-的实现))。
带 KV Cache 的 GQA 在解码时每一步只输入新的 Token,把它的 K、V 追加到缓存末尾,再与缓存中的全部 K、V 计算注意力,代码见[进阶:带 KV Cache 的 GQA](大模型应用%20-%20进阶.md#1-带-kv-cache-的-gqa)。
### 采样参数
@@ -338,7 +289,7 @@ SFT 之后还要做偏好对齐,是因为 SFT 只告诉模型“这个回答
PPO 更新模型时不直接使用奖励的高低,而是使用优势(Advantage):一个回答的实际得分比预期高多少。预期由价值模型(Critic)给出,它估计“从当前位置继续生成,平均能得多少分”。只看奖励时,简单的题目每个回答都得高分,模型分不清哪个回答真正更好;减去预期之后,比预期好的回答被强化,比预期差的被抑制。
把生成过程看作强化学习:状态是提示词和已经生成的 Token,动作是下一个 Token,奖励模型的分数在回答结束时才给出。每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)计算:先算每一步的时序差分误差 δ<sub>t</sub> = r<sub>t</sub> + γV(s<sub>t+1</sub>) − V(s<sub>t</sub>),即走完这一步后价值模型的估计比之前高了多少,优势是之后各步 δ 的加权和 Σ (γλ)<sup>k</sup> δ<sub>t+k</sub>。λ = 0 时只看一步,依赖价值模型的估计,方差小但有偏差;λ = 1 时相当于最终得分减去 V(s<sub>t</sub>),没有偏差但方差大;λ 取两者之间的值来折中。
把生成过程看作强化学习时,奖励模型的分数在回答结束时才给出,每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)由价值模型的逐步估计算出,推导见[进阶:广义优势估计](大模型应用%20-%20进阶.md#1-广义优势估计)。
奖励模型只是人类偏好的近似,模型可能找到它的漏洞,生成得分高但没有意义的回答。所以 PPO 在奖励中减去新模型与参考模型(冻结的 SFT 模型)之间的 KL 散度,限制模型偏离太远。
@@ -381,14 +332,7 @@ DPO 的损失只取决于两个回答的概率之差,需要注意的是:
GRPO 把 KL 散度直接加在损失中,概率之比的限制与 PPO 相同。代价是每道题要生成一组回答,DeepSeekMath 中每题采样 64 个,生成的开销很大。
大规模训练推理模型时,GRPO 暴露出几个问题,后续的算法分别做了改进:
- 全对或全错的题没有梯度:模型越学越好,全对的题越来越多,一批中有效的题目越来越少。DAPO 的动态采样多采一些题,丢掉全对和全错的,直到填满一批;
- 熵坍塌(Entropy Collapse):模型输出的熵在训练中很快下降,同一道题的多个回答几乎一样,失去了探索新解法的能力。原因之一是概率之比的上限:ε = 0.2 时,概率为 0.9 的 Token 一次可以升到 1,而概率为 0.01 的 Token 最多只能升到 0.012。DAPO 把上下限分开,下限仍为 0.2,上限放宽到 0.28;
- 长度偏差:GRPO 先在每个回答内部按 Token 数求平均,长回答中每个 Token 的权重变小,错误的长回答受到的惩罚被稀释,回答越来越长。DAPO 改为把一批中所有回答的 Token 放在一起平均;
- MoE 模型训练不稳定:每次更新后,同一个回答激活的专家会发生变化,Qwen3-30B-A3B 中约 10% 不同,按 Token 计算的概率之比剧烈波动。GSPO 改为按整个回答计算概率之比(取每个 Token 概率之比的几何平均),并对整个回答截断,Qwen3 的强化学习使用了这种方法。
DAPO 还去掉了 KL 项,理由是训练长推理时模型本来就要远离初始模型。它用 Qwen2.5-32B 基座模型在 AIME 2024 上达到 50 分,最初直接使用 GRPO 时只有 30 分。
大规模训练推理模型时,GRPO 还暴露出全对或全错的题没有梯度、熵坍塌、长度偏差和 MoE 模型训练不稳定等问题,DAPO、GSPO 等算法分别做了改进,见[进阶:GRPO 的改进](大模型应用%20-%20进阶.md#2-grpo-的改进)。
| | PPO | DPO | GRPO |
| --- | --- | --- | --- |
@@ -463,7 +407,7 @@ SFT 的样本是一段完整的对话,通常每行一条 JSON:
- 秩 r 决定 B、A 的大小,也就是可训练参数的多少。一个 d<sub>in</sub> × d<sub>out</sub> 的矩阵增加 r × (d<sub>in</sub> + d<sub>out</sub>) 个参数;
- α 是缩放系数,实际起作用的是 α / r。LoRA 论文把 α 设为第一次尝试的 r 之后就不再调整,改变 r 时 α / r 的缩放可以减少重新调学习率的需要;
- 初始化:A 随机初始化(PEFT 默认使用 Kaiming 均匀分布),B 初始化为 0,所以训练开始时 BA = 0,模型与原模型完全相同。如果两个矩阵都随机初始化,训练一开始模型的输出就被打乱了;如果两个都为 0,B 的梯度中含有因子 Ax,A 的梯度中含有因子 B,两者都是 0,训练永远无法开始。反过来让 A 为 0、B 随机也能训练(见[附录 A26](#a26-lora-的三种初始化)),但有研究发现默认的方式能使用更大的学习率,效果平均更好。
- 初始化:A 随机初始化(PEFT 默认使用 Kaiming 均匀分布),B 初始化为 0,所以训练开始时 BA = 0,模型与原模型完全相同。如果两个矩阵都随机初始化,训练一开始模型的输出就被打乱了;如果两个都为 0,B 的梯度中含有因子 Ax,A 的梯度中含有因子 B,两者都是 0,训练永远无法开始。反过来让 A 为 0、B 随机也能训练(见[附录 A23](#a23-lora-的三种初始化)),但有研究发现默认的方式能使用更大的学习率,效果平均更好。
按 Qwen2.5-7B 的真实维度计算(见[附录 A16](#a16-qwen25-7b-与-qwen3-30b-a3b-的参数量)):
@@ -502,16 +446,7 @@ Qwen2.5-7B 的权重在 4 位下约 4 GB,比 BF16 的 15.2 GB 少了 11 GB 左
- 只调整格式、风格或者某个具体任务,数据只有几千条,或者要同时维护多个任务,用 LoRA;
- 显卡显存不够放下 BF16 的权重时,用 QLoRA。
全参数微调一张卡放不下时要用多张卡。普通的数据并行在每张卡上保存完整的模型状态,增加卡数并不能减少每张卡的显存。DeepSpeed 的 ZeRO 把模型状态切分到各张卡上,按切分的内容分为三个阶段。以 Qwen2.5-7B 在 8 张卡上训练为例,只算模型状态,不含激活(见[附录 A25](#a25-zero-的显存与-mla-的-kv-cache)):
| 阶段 | 切分的内容 | 每张卡的显存 |
| --- | --- | --- |
| 普通数据并行 | 不切分 | 121.8 GB |
| ZeRO-1 | 优化器状态 | 41.9 GB |
| ZeRO-2 | 优化器状态、梯度 | 28.6 GB |
| ZeRO-3 | 优化器状态、梯度、参数 | 15.2 GB |
优化器状态每个参数占 12 字节,是模型状态中最大的部分,所以 ZeRO-1 的收益最明显。ZeRO-1、ZeRO-2 的通信量与普通数据并行相同;ZeRO-3 在每一层计算前都要从其它卡收集完整的参数,用完即丢弃,通信量约为 1.5 倍。PyTorch 的 FSDP 采用同样的思路,FULL_SHARD 对应 ZeRO-3,SHARD_GRAD_OP 对应 ZeRO-2。显存仍然不够时,还可以把参数或优化器状态卸载到 CPU 内存,代价是训练变慢。
全参数微调一张卡放不下时要用多张卡。DeepSpeed 的 ZeRO 和 PyTorch 的 FSDP 把优化器状态、梯度和参数切分到各张卡上,例如 Qwen2.5-7B 在 8 张卡上训练时,全部切分后每张卡的模型状态从 121.8 GB 降到 15.2 GB,见[进阶:ZeRO 与 FSDP](大模型应用%20-%20进阶.md#1-zero-与-fsdp)。
#### 5. 灾难性遗忘
@@ -564,15 +499,7 @@ INT8 按通道量化几乎没有损失;INT4 必须按组量化,困惑度从
直接对每个权重四舍五入,称为 RTN(Round-To-Nearest)。GPTQ 和 AWQ 都用一小批校准数据计算出各层的输入,在此基础上减小量化后每层输出的误差,而不只是权重本身的误差。
GPTQ 逐层量化,每层按列逐个量化权重:每量化一列,就根据校准数据算出的二阶信息(输入的 XX<sup>T</sup>),调整还没有量化的那些列,抵消这一列产生的输出误差。论文中 175B 的模型在一张显卡上约 4 小时就能量化到 3~4 位。
AWQ(Activation-aware Weight Quantization)基于这样的观察:权重的重要程度不同,与较大的激活相乘的那部分权重对输出影响最大,只保护其中 1% 就能明显减小误差。但为个别通道保留 FP16 会形成混合精度,硬件上效率不高。AWQ 的做法是:
1. 用校准数据统计每个输入通道激活的平均大小 s<sub>X</sub>;
2. 量化之前把权重的第 j 个输入通道乘以 s<sub>j</sub> = s<sub>X,j</sub><sup>α</sup>,推理时把输入的第 j 个通道除以 s<sub>j</sub>,乘积不变;
3. 重要的通道被放大之后,相对的量化误差变小;在 [0, 1] 中搜索使输出误差最小的 α。
对 Qwen2.5-7B 第 14 层 q_proj 做每 128 个一组的 INT4 量化,这样缩放之后输出的相对误差从 8.98% 降到 7.86%(α = 0.3,见[附录 A18](#a18-权重量化的误差与困惑度))。两者的区别:GPTQ 通过修改未量化的权重来补偿误差;AWQ 只做通道的缩放,不需要反向传播和重建,对校准数据的依赖更小。
两者的区别:GPTQ 每量化一列权重,就调整还没有量化的列来补偿这一列的误差;AWQ 先把与较大激活相乘的重要通道放大再量化,只做通道的缩放,不需要反向传播和重建,对校准数据的依赖更小。具体做法见[进阶:GPTQ 与 AWQ](大模型应用%20-%20进阶.md#1-gptq-与-awq)。
#### 4. KV Cache 量化
@@ -1306,7 +1233,7 @@ for (CompletableFuture<Message> f : results) {
}
```
两个各需 500 毫秒的调用,并发执行共用时约 520 毫秒(JDK 21 实测,见[附录 A28](#a28-并发执行工具调用))。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。部分调用失败时的处理见[可靠性与评测](#1-容错)。
两个各需 500 毫秒的调用,并发执行共用时约 520 毫秒(JDK 21 实测,见[附录 A25](#a25-并发执行工具调用))。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。部分调用失败时的处理见[可靠性与评测](#1-容错)。
耗时很长的工具,例如生成报表、等待人工审批,不适合在循环中同步等待。可以让工具先返回一个任务 ID,保存当前的消息列表后结束这一轮;任务完成后通过消息队列或者回调通知应用,应用把结果作为工具消息追加到保存的消息中,再继续调用模型。这与[开发框架](#2-checkpoint-与人工介入)中暂停和恢复的做法相同。
@@ -1656,7 +1583,7 @@ if __name__ == "__main__":
mcp.run() # 默认使用 stdio;远程服务用 mcp.run(transport="streamable-http")
```
SDK 根据类型注解和文档字符串生成 tools/list 返回的描述和参数 Schema。工具抛出 ToolError 时,错误信息作为 isError 为 true 的结果返回给模型;抛出其它异常时,模型只收到“Error executing tool get_order”,具体内容留在服务端的日志中(实测,见[附录 A27](#a27-mcp-服务端的最小实现))。
SDK 根据类型注解和文档字符串生成 tools/list 返回的描述和参数 Schema。工具抛出 ToolError 时,错误信息作为 isError 为 true 的结果返回给模型;抛出其它异常时,模型只收到“Error executing tool get_order”,具体内容留在服务端的日志中(实测,见[附录 A24](#a24-mcp-服务端的最小实现))。
本地服务端以子进程运行,权限就是启动它的用户的权限,规范建议从环境变量中读取凭据;远程服务端通过 HTTP 对外提供,规范按 OAuth 2.1 定义授权:客户端带着访问令牌请求,服务端必须校验令牌是否签发给自己,无效或过期的令牌返回 HTTP 401。令牌只说明调用者是谁,每个工具还要按用户校验权限,例如只能查询自己的订单。
@@ -1811,19 +1738,45 @@ Vibe Coding 这个说法由 Andrej Karpathy 在 2025 年 2 月提出,指的是
### Harness
Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所有代码、配置和执行逻辑:Agent 循环、系统提示词、工具及其描述、上下文压缩、记忆、沙箱与权限、子 Agent 的调度,以及在固定时机执行的钩子(例如每次修改文件后自动格式化)。前面不到二十行的 Agent 循环就是一个最简单的 Harness;Claude Code、Codex 是功能完整的 Harness,可以换用不同的模型。MCP 和 Skill 也是 Harness 的组成部分,前者负责接入工具,后者负责按需提供做事的方法。
Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所有代码、配置和执行逻辑:Agent 循环、系统提示词、工具及其描述、上下文压缩、记忆、沙箱与权限、子 Agent 的调度,以及在固定时机执行的钩子(例如每次修改文件后自动格式化)。模型本身只接收文本、输出文本,不能保存状态、执行代码、获取实时信息,这些都要由 Harness 补上。前面不到二十行的 Agent 循环就是一个最简单的 Harness;Claude Code、Codex 是功能完整的 Harness,可以换用不同的模型。MCP 和 Skill 也是 Harness 的组成部分,前者负责接入工具,后者负责按需提供做事的方法。
提示词工程关注写给模型的指令,上下文工程关注每次调用放进上下文的内容,两者都是 Harness 的一部分;Harness 还包括工具、执行环境、校验和长任务的状态。
模型决定了能力的上限,Harness 决定了实际能发挥出多少。前面的实验中,模型不变,只在系统提示词中写清楚查询的步骤,成功率就从 0/10 变成了 10/10。围绕 Agent 设计和改进 Harness 的工作称为 Harness 工程(Harness Engineering),这个说法在 2026 年初开始流行,核心是两类控制:
- 引导:在 Agent 动手之前给出方向,例如 CLAUDE.md 中的规范、架构文档和 Skill;
- 反馈:在 Agent 动手之后检查结果,例如编译、测试、lint、架构检查,以及用另一个模型审查。
#### 1. 组成
Harness 的每个部分都对应一个模型自己做不到或做不稳的问题:
| 组成 | 解决的问题 |
| --- | --- |
| Agent 循环与停止条件 | 模型一次只输出一段文本,循环负责执行工具、把结果送回,并决定何时结束,见 [Agent 循环](#agent-循环) |
| 上下文的组装与压缩 | 决定每次调用放进哪些消息、工具描述和记忆,过长时摘要或裁剪,见 [上下文工程](#上下文工程) |
| 工具与权限 | 工具的描述决定模型会不会用、用得对不对;危险操作要求人工确认,命令在沙箱中执行,见 [工具调用](#工具调用) 和 [安全](#安全) |
| 记忆与状态交接 | 模型不保存状态,跨会话的进度要写到外部,见 [记忆](#记忆) 和下文的长任务交接 |
| 子 Agent | 把独立的子任务交给新的上下文,只把结论带回来,见 [多 Agent](#多-agent) |
| 钩子与校验 | 在固定时机执行确定的检查,不通过就把错误送回模型继续 |
| 评测与可观测 | 记录每一步的输入输出和 Token,改动 Harness 后用同一批任务回归,见 [可靠性与评测](#可靠性与评测) |
工具的返回很长时,Harness 通常只把开头和结尾,或者最新的几条放进上下文,完整结果写到文件中,模型需要时再读取;Claude Code 默认把一次工具返回限制在 25,000 个 Token 以内。
#### 2. 引导与反馈
以“Controller 只能调用 Service”这条分层规范为例:
<div align="center"> <img src="pics/harness-loop.png" width="760px"/> </div><br>
这条规范只写在 CLAUDE.md 中时,模型大多数时候会遵守,但无法保证。再加一条用 ArchUnit 写的架构测试,违反时测试失败,错误信息写明了违反哪条规则、在哪一行,Agent 读到后就能自己修正,不需要人逐行审查。编译、测试这类确定的检查快、便宜、结果可靠,应该优先使用;模型审查能发现规则写不出来的问题,但更慢,结论也不稳定。同一个问题反复出现时,应该补一条规范或一个检查,而不是每次在对话中纠正。
反馈对能力较弱的模型作用更明显。仍以“订单 1024 到哪了”为例,在模型准备结束时检查一次:用户问的是位置,就必须调用过物流查询,而且回答中出现了查到的地点;不满足时把原因作为一条消息送回,最多拦截两次。Qwen2.5-7B 在只写了“回答要简短”的提示词下,没有校验时 10 次全部失败,多数只查了订单就结束;加上校验后 10 次全部成功,代价是模型调用从平均 1.7 次增加到 4.0 次,输入 Token 约为原来的 3 倍。更小的 Qwen2.5-3B 在写清步骤的提示词下从 0/10 提高到 7/10,提示词过短时加上校验也只有 2/10,反馈弥补不了模型能力的全部差距(见[附录 A26](#a26-停止前校验))。
让做事的 Agent 自己评价结果时,它往往会夸自己做得好,所以检查最好交给确定的程序或另一个 Agent。Anthropic 的一次对比中,同一个模型单独做一个复古游戏编辑器,用时 20 分钟、花费 9 美元,做出的游戏无法正常游玩;换成规划、生成、评估三个 Agent 分工的 Harness,用时 6 小时、花费 200 美元,核心功能可以正常使用。
#### 3. 长任务的交接
任务大到一个上下文窗口装不下时,Harness 还要负责多次会话之间的交接。Anthropic 介绍过一种做法:第一次会话只做准备,把需求拆成功能清单写入 JSON 文件,每项带测试步骤和 passes 字段,初始全部为 false,同时写好启动脚本并提交到 git;之后的每次会话先读进度文件和 git log,启动应用做一次冒烟测试,然后只挑一个未通过的功能实现,端到端测试通过后才把 passes 改为 true,最后提交代码、更新进度文件。清单中的一项例如:
```json
@@ -1834,6 +1787,14 @@ Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所
这些设计分别针对长任务中的常见问题:新会话不知道之前做了什么,靠进度文件和 git log 交接;一次做太多、上下文用完时留下半成品,靠每次只做一个功能;没有测试就宣布完成,靠功能清单和端到端测试。清单用 JSON 而不是 Markdown,是因为模型不容易随意改写 JSON 的结构;规定只能修改 passes 字段,是为了防止 Agent 删掉测试来“完成”任务。
#### 4. 模型变强以后
Harness 的每个组件都隐含一个假设:模型自己做不到这件事。模型升级后,这些假设可能不再成立,需要逐个去掉组件验证效果。Anthropic 的例子中,Claude Opus 4.5 基本不再因为担心上下文用完而提前收尾,于是去掉了会话之间的上下文重置;Opus 4.6 能连续工作两个多小时,又去掉了把任务拆成多个冲刺的结构。他们的结论是,值得做的 Harness 组合不会随模型变强而减少,而是会转移到新的问题上。LangChain 的看法类似:一部分现在由 Harness 完成的事会被模型吸收,但环境、工具、持久的状态和校验循环对任何模型都有用;Claude Code、Codex 这类产品的模型在后训练时就带着 Harness 一起训练。
#### 5. 与开发框架的关系
开发框架(例如 LangChain)提供模型调用、工具、Agent 循环等抽象;运行时(例如 LangGraph)负责持久执行、流式输出、人工介入和状态保存;Harness 在两者之上直接给出一套可用的默认配置,包括系统提示词、工具调用的处理、规划工具和文件系统等,拿来就能工作。这个划分来自 LangChain 的文章,边界并不严格。现成的 Harness 产品变化很快,例如 Claude Code(官方文档称为 agentic harness)、DeepSeek Harness(开发者预览版,所有能力都以插件提供)、Pi(只带最小的默认功能,其余靠扩展),具体功能以官方最新文档为准。
### 开发框架
上面的循环、工具定义和消息管理在每个项目中都要重复编写,LangChain、LangGraph、Spring AI 等框架把它们封装了起来:
@@ -3057,116 +3018,7 @@ msgs += [{"role": "assistant", "content": raw},
重试输出: '```json\n{\n "order_id": "5120,5121",\n "intent": "其它",\n "amount": null\n}\n```' 仍失败
```
### A23 多头注意力、GQA 与 KV Cache 的实现
环境:Apple M1 Pro,macOS 15.8.1,Python 3.13.7,PyTorch 2.8.0(CPU)。正文中的 MHA 类与 torch.nn.MultiheadAttention 复制同一组权重后对照;下面的 GQA 类带 KV Cache,先预填充 5 个 Token,再逐个输入 7 个 Token,与整段一次计算的结果对照,并与 PyTorch 自带的 scaled_dot_product_attention(enable_gqa=True) 对照(d_model = 64,8 个查询头,2 组 K、V):
```python
class GQA(nn.Module):
def __init__(self, d_model, n_heads, n_kv_heads):
super().__init__()
self.h, self.hkv, self.dk = n_heads, n_kv_heads, d_model // n_heads
self.wq = nn.Linear(d_model, n_heads * self.dk)
self.wk = nn.Linear(d_model, n_kv_heads * self.dk) # K、V 只有 n_kv_heads 组
self.wv = nn.Linear(d_model, n_kv_heads * self.dk)
self.out = nn.Linear(d_model, d_model)
def forward(self, x, cache=None): # x: (B, t, d),解码时 t = 1
B, t, d = x.shape
q = self.wq(x).view(B, t, self.h, self.dk).transpose(1, 2)
k = self.wk(x).view(B, t, self.hkv, self.dk).transpose(1, 2)
v = self.wv(x).view(B, t, self.hkv, self.dk).transpose(1, 2)
if cache is not None: # 新 Token 的 K、V 追加到缓存末尾
if "k" in cache:
k = torch.cat([cache["k"], k], dim=2); v = torch.cat([cache["v"], v], dim=2)
cache["k"], cache["v"] = k, v # 缓存的是 hkv 组,而不是 h 个头
g = self.h // self.hkv # 每组 K、V 由 g 个查询头共用
k, v = k.repeat_interleave(g, dim=1), v.repeat_interleave(g, dim=1)
T = k.size(2)
scores = q @ k.transpose(-2, -1) / self.dk ** 0.5 # (B, h, t, T)
mask = torch.ones(t, T, dtype=torch.bool).triu(T - t + 1) # 第 i 个新 Token 只看前 T-t+i+1 个
o = scores.masked_fill(mask, float("-inf")).softmax(-1) @ v
return self.out(o.transpose(1, 2).contiguous().view(B, t, d))
g = GQA(64, 8, 2)
x = torch.randn(1, 12, 64)
full = g(x) # 整段一次计算(训练、预填充)
cache, steps = {}, []
p = g(x[:, :5], cache) # 预填充前 5 个
for i in range(5, 12): # 之后每次只输入 1 个新 Token
steps.append(g(x[:, i:i + 1], cache))
inc = torch.cat([p] + steps, dim=1)
print("3. KV Cache 逐步解码与整段计算的最大误差:", (inc - full).abs().max().item())
print(" 缓存的 K 形状:", tuple(cache["k"].shape), "(8 个查询头,只缓存 2 组)")
```
输出:
```text
1. 与 nn.MultiheadAttention 的最大误差: 1.7881393432617188e-07
2. transpose 后 is_contiguous = False
直接 view 报错: view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces)
reshape 等价于 contiguous().view,是否复制了数据: True
3. KV Cache 逐步解码与整段计算的最大误差: 1.862645149230957e-07
缓存的 K 形状: (1, 2, 12, 8) (8 个查询头,只缓存 2 组)
与 scaled_dot_product_attention(enable_gqa=True) 的最大误差: 1.4901161193847656e-07
torch 2.8.0
```
### A24 在线 softmax 与注意力的计算量
环境:Python 3.13.7,numpy 2.3.3。按块读取 K、V,用在线 softmax 累积输出,与一次性计算的标准注意力对照(n = 1000,d = 64);再按 Qwen2.5-7B 的 config.json(hidden_size 3584,28 层,28 个查询头、4 组 K、V,intermediate_size 18944)计算分数矩阵的大小,以及每层线性层与注意力的计算量(不含偏置和归一化):
```python
def online(q, K, V, block):
m, l, o = -np.inf, 0.0, np.zeros(V.shape[1]) # 当前最大值、指数和、未归一化的输出
for i in range(0, len(K), block):
s = K[i:i + block] @ q / np.sqrt(len(q)) # 只算这一块的分数
m_new = max(m, s.max())
scale = np.exp(m - m_new) # 旧的累积量按新的最大值缩放
p = np.exp(s - m_new)
l = l * scale + p.sum()
o = o * scale + p @ V[i:i + block]
m = m_new
return o / l
```
```text
1. 块大小 1:与标准 softmax 注意力的最大误差 3.05e-16
1. 块大小 16:与标准 softmax 注意力的最大误差 1.11e-16
1. 块大小 128:与标准 softmax 注意力的最大误差 1.53e-16
2. 第 1 块后 m=2, l=1.368, o=17.358
第 2 块:缩放系数 e^(2-3)=0.368, l=1.553, o=38.377, 输出 o/l=24.711
一次 softmax:权重 [0.237 0.087 0.644 0.032], 输出 24.711
3. n = 4096:一个头的 n×n 分数矩阵(BF16)= 32 MiB,一层 28 个头 = 0.9 GiB
3. n = 32768:一个头的 n×n 分数矩阵(BF16)= 2048 MiB,一层 28 个头 = 56.0 GiB
每层线性层参数 233.0M,每个 Token 的线性层计算量 2×233.0M = 466M FLOPs/层
生成第 T 个 Token 时,注意力分数和加权求和的计算量 = 4 × T × 28×128 = 14336T FLOPs/层
两者相等时 T = 32512
```
线性层每层的参数为 3584 × 3584 × 2(q_proj、o_proj)+ 3584 × 512 × 2(k_proj、v_proj)+ 3 × 3584 × 18944(前馈网络),每个参数做一次乘加计为 2 次浮点运算。
### A25 ZeRO 的显存与 MLA 的 KV Cache
按 ZeRO 论文的记法,混合精度训练时每个参数的模型状态为 2 字节(BF16 参数)+ 2 字节(BF16 梯度)+ K 字节(FP32 参数副本和 Adam 的两个状态,K = 12),N 张卡时 ZeRO-1、ZeRO-2、ZeRO-3 每张卡分别为 4 + K / N、2 + (2 + K) / N、(4 + K) / N 字节。先用论文中的例子(7.5B,64 张卡)核对公式,再代入 Qwen2.5-7B(7,615,616,512 个参数)。MLA 按 DeepSeek-V3 的 config.json(kv_lora_rank 512,qk_rope_head_dim 64,qk_nope_head_dim 128,v_head_dim 128,128 个头,61 层)和 BF16 计算:
```text
Qwen2.5-7B,8 张卡,只算模型状态(不含激活):
不切分(普通数据并行) 每参数 16.00 字节,每张卡 121.8 GB
ZeRO-1:切分优化器状态 每参数 5.50 字节,每张卡 41.9 GB
ZeRO-2:再切分梯度 每参数 3.75 字节,每张卡 28.6 GB
ZeRO-3:再切分参数 每参数 2.00 字节,每张卡 15.2 GB
核对 ZeRO 论文 7.5B、64 卡: [120.0, 31.4, 16.6, 1.9]
DeepSeek-V3:61 层,128 个头,c_KV 512 维 + RoPE 键 64 维
MLA 每个 Token:(512+64) × 61 × 2 字节 = 70272 字节 = 68.6 KB
同样的头数和维度用 MHA:128 × (192+128) × 61 × 2 = 4.77 MB,是 MLA 的 71 倍
对照 Qwen2.5-7B(GQA):56 KB
```
论文中的数值为 120 GB、31.4 GB、16.6 GB、1.9 GB,与上面的核对结果一致。
### A26 LoRA 的三种初始化
### A23 LoRA 的三种初始化
环境:Python 3.13.7,PyTorch 2.8.0(CPU)。冻结一个 64 × 64 的权重 W,用 r = 4 的 LoRA 拟合一个秩为 4 的变化量,Adam、学习率 0.01、300 步,比较三种初始化:B = 0、A 随机(PEFT 的默认方式);A = 0、B 随机;两者都为 0:
@@ -3198,7 +3050,7 @@ A=0 初始损失 2.3699 第一步梯度 |∂A|=0.1667 |∂B|=0.0000 300 步
两者都为 0 时,第一步的梯度全部为 0,300 步后损失没有任何变化;另外两种方式都能训练到损失接近 0。这个小实验只说明能否训练,两种可训练的方式在大模型上的效果差异见参考资料中 Hayou 等人的论文。
### A27 MCP 服务端的最小实现
### A24 MCP 服务端的最小实现
环境:Python 3.13.7,mcp 2.3.0(pip 安装)。服务端即正文中的代码,ORDERS 中只有订单 1024。客户端以 stdio 方式启动服务端子进程,依次调用 tools/list 和 tools/call:
@@ -3237,7 +3089,7 @@ streamable-http tools/call: { "status": "已发货", "tracking_no": "SF1234567
把 ToolError 换成普通的 ValueError 后,订单 9999 的结果变为 `Error executing tool get_order`,isError 同样为 True,错误的具体内容只出现在服务端的日志中。
### A28 并发执行工具调用
### A25 并发执行工具调用
环境:JDK 21.0.13(Adoptium),Apple M1 Pro。Message、ToolCall 用 record 定义,execute 为桩代码,每次调用休眠 500 毫秒;两个 get_order 调用用正文中的片段并发执行:
@@ -3248,6 +3100,39 @@ Message[role=tool, toolCallId=call_2, content={"order_id": "1025", "status": "
openjdk version "21.0.13" 2026-10-20 LTS
```
### A26 停止前校验
环境:Qwen2.5-7B-Instruct、Qwen2.5-3B-Instruct,transformers 在 Apple M1 Pro 的 MPS 上以 float16 运行,temperature 0.7、top_p 0.9,每种配置用随机种子 0~9 运行 10 次。工具 get_order 返回运单号 SF1234567,get_logistics 返回位置“杭州转运中心”;回答中出现“杭州”算成功。两种系统提示词:
- A:你是电商客服助手,回答要简短。
- B:你是电商客服助手。用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间。回答要简短。
模型准备结束(没有发起工具调用)时执行校验,不通过就把原因作为用户消息追加进去,最多拦截 2 次:
```python
def stop_check(trace, answer):
"""返回 None 表示通过,否则返回要发回给模型的原因。"""
locs = [r.get("location") for name, r in trace if name == "get_logistics" and "location" in r]
if not locs:
return "[校验未通过] 用户问的是包裹位置,但还没有查询物流(get_logistics),不能结束。请先查询物流再回答。"
if locs[-1] not in answer:
return f"[校验未通过] 回答中的位置与物流查询结果不一致,物流结果为:{locs[-1]}。请据此重新回答。"
return None
```
原始输出(每种配置只保留统计行):
```text
qwen2.5-7b 提示词 A 校验钩子 无: 成功 0/10;工具调用 [0, 1, 1, 1, 0, 1, 0, 1, 1, 1];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 1.7 次,平均输入 Token 484,平均输出 Token 43
qwen2.5-7b 提示词 A 校验钩子 有: 成功 10/10;工具调用 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2];被拦截 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];平均调用模型 4.0 次,平均输入 Token 1457,平均输出 Token 119
qwen2.5-7b 提示词 B 校验钩子 无: 成功 10/10;工具调用 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 3.0 次,平均输入 Token 1084,平均输出 Token 68
qwen2.5-3b 提示词 B 校验钩子 无: 成功 0/10;工具调用 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 2.0 次,平均输入 Token 638,平均输出 Token 55
qwen2.5-3b 提示词 B 校验钩子 有: 成功 7/10;工具调用 [2, 1, 2, 2, 2, 2, 1, 2, 1, 2];被拦截 [1, 2, 1, 1, 2, 1, 2, 1, 2, 1];平均调用模型 4.1 次,平均输入 Token 1626,平均输出 Token 118
qwen2.5-3b 提示词 A 校验钩子 有: 成功 2/10;工具调用 [1, 1, 1, 1, 2, 1, 1, 1, 1, 2];被拦截 [2, 2, 2, 2, 1, 2, 2, 2, 2, 1];平均调用模型 4.0 次,平均输入 Token 1439,平均输出 Token 105
```
7B 在提示词 A 下没有校验时的典型回答是“您的订单已发货,运单号是 SF1234567。我可以帮您查询物流信息。”,即只查了订单就结束。
## 参考资料
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
@@ -3390,7 +3275,6 @@ openjdk version "21.0.13" 2026-10-20 LTS
- [Google SRE Book: Addressing Cascading Failures](https://sre.google/sre-book/addressing-cascading-failures/)
- [LangGraph: Streaming](https://docs.langchain.com/oss/python/langgraph/streaming)
- [OWASP Top 10 for LLM Applications: Excessive Agency](https://genai.owasp.org/llmrisk/llm062025-excessive-agency/)
- [Dao T. FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. arXiv:2307.08691](https://arxiv.org/abs/2307.08691)
- [Jiang A Q, et al. Mistral 7B. arXiv:2310.06825](https://arxiv.org/abs/2310.06825)
- [Katharopoulos A, et al. Transformers are RNNs: Fast Autoregressive Transformers with Linear Attention. arXiv:2006.16236](https://arxiv.org/abs/2006.16236)
- [DeepSeek-AI. DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model. arXiv:2405.04434](https://arxiv.org/abs/2405.04434)
@@ -3400,7 +3284,6 @@ openjdk version "21.0.13" 2026-10-20 LTS
- [Schulman J, et al. High-Dimensional Continuous Control Using Generalized Advantage Estimation. arXiv:1506.02438](https://arxiv.org/abs/1506.02438)
- [Yu Q, et al. DAPO: An Open-Source LLM Reinforcement Learning System at Scale. arXiv:2503.14476](https://arxiv.org/abs/2503.14476)
- [Zheng C, et al. Group Sequence Policy Optimization. arXiv:2507.18071](https://arxiv.org/abs/2507.18071)
- [Liu Z, et al. Understanding R1-Zero-Like Training: A Critical Perspective. arXiv:2503.20783](https://arxiv.org/abs/2503.20783)
- [Meng Y, et al. SimPO: Simple Preference Optimization with a Reference-Free Reward. arXiv:2405.14734](https://arxiv.org/abs/2405.14734)
- [Hayou S, et al. The Impact of Initialization on LoRA Finetuning Dynamics. arXiv:2406.08447](https://arxiv.org/abs/2406.08447)
- [PyTorch FullyShardedDataParallel](https://docs.pytorch.org/docs/stable/fsdp.html)
@@ -3417,3 +3300,7 @@ openjdk version "21.0.13" 2026-10-20 LTS
- [Simon Willison: Not all AI-assisted programming is vibe coding](https://simonwillison.net/2025/Mar/19/vibe-coding/)
- [LangChain4j: Introduction](https://docs.langchain4j.dev/intro)
- [LangChain4j: AI Services](https://docs.langchain4j.dev/tutorials/ai-services)
- [Agent Frameworks, Runtimes, and Harnesses - oh my!](https://blog.langchain.com/agent-frameworks-runtimes-and-harnesses-oh-my/)
- [Harness design for long-running application development](https://www.anthropic.com/engineering/harness-design-long-running-apps)
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
- [Pi](https://pi.dev/)