diff --git a/notes/pics/hnsw.png b/notes/pics/hnsw.png index d7996301..8c41ea1f 100644 Binary files a/notes/pics/hnsw.png and b/notes/pics/hnsw.png differ diff --git a/notes/pics/transformer-block.png b/notes/pics/transformer-block.png index ef51b52b..e8674f7b 100644 Binary files a/notes/pics/transformer-block.png and b/notes/pics/transformer-block.png differ diff --git a/notes/大模型应用 - 进阶.md b/notes/大模型应用 - 进阶.md new file mode 100644 index 00000000..39308781 --- /dev/null +++ b/notes/大模型应用 - 进阶.md @@ -0,0 +1,284 @@ +# 大模型应用 - 进阶 + +* [大模型应用 - 进阶](#大模型应用---进阶) + * [一、大模型基础](#一大模型基础) + * [注意力机制](#注意力机制) + * [自回归生成与 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) + * [参考资料](#参考资料) + + + +## 一、大模型基础 + +### 注意力机制 + +#### 1. 多头注意力的实现 + +主线见[大模型应用 · 多头注意力](大模型应用.md#1-多头注意力),那里以 Qwen2.5-7B 为例给出了各步的形状变化。 + +手写的实现如下(PyTorch,与 torch.nn.MultiheadAttention 用同一组权重对照,最大误差为 1.8 × 10-7,见[附录 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 时,QKT 和乘以 V 各需要约 n2d 次乘加,时间复杂度为 O(n2d),分数矩阵占用 O(n2) 的显存。按 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 乘以 em旧 − m新 再累加。例如一个查询与 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-7(完整代码见[附录 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)计算:先算每一步的时序差分误差 δt = rt + γV(st+1) − V(st),即走完这一步后价值模型的估计比之前高了多少,优势是之后各步 δ 的加权和 Σ (γλ)k δt+k。λ = 0 时只看一步,依赖价值模型的估计,方差小但有偏差;λ = 1 时相当于最终得分减去 V(st),没有偏差但方差大;λ 取两者之间的值来折中。 + +#### 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 逐层量化,每层按列逐个量化权重:每量化一列,就根据校准数据算出的二阶信息(输入的 XXT),调整还没有量化的那些列,抵消这一列产生的输出误差。论文中 175B 的模型在一张显卡上约 4 小时就能量化到 3~4 位。 + +AWQ(Activation-aware Weight Quantization)基于这样的观察:权重的重要程度不同,与较大的激活相乘的那部分权重对输出影响最大,只保护其中 1% 就能明显减小误差。但为个别通道保留 FP16 会形成混合精度,硬件上效率不高。AWQ 的做法是: + +1. 用校准数据统计每个输入通道激活的平均大小 sX; +2. 量化之前把权重的第 j 个输入通道乘以 sj = sX,jα,推理时把输入的第 j 个通道除以 sj,乘积不变; +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) diff --git a/notes/大模型应用.md b/notes/大模型应用.md index 648576a4..88b864d2 100644 --- a/notes/大模型应用.md +++ b/notes/大模型应用.md @@ -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-停止前校验) * [参考资料](#参考资料) @@ -208,51 +206,15 @@ Q = x W_Q (n, 3584) 交换回来并拼接 (n, 3584) 再乘以 W_O ``` -每个头的维度是 3584 / 28,所以多头注意力的计算量与一个 3584 维的单头注意力基本相同,多出来的只是切分和拼接。手写的实现如下(PyTorch,与 torch.nn.MultiheadAttention 用同一组权重对照,最大误差为 1.8 × 10-7,见[附录 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 时,QKT 和乘以 V 各需要约 n2d 次乘加,时间复杂度为 O(n2d),分数矩阵占用 O(n2) 的显存。按 Qwen2.5-7B 的配置计算,每个 Token 经过一层的线性层约需 4.7 亿次浮点运算,与上下文长度无关;生成第 T 个 Token 时,一层注意力的分数和加权求和约需 14336 × T 次,T 约为 3.2 万时两者相当(见[附录 A24](#a24-在线-softmax-与注意力的计算量))。所以上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大。 +序列长度为 n、向量维度为 d 时,QKT 和乘以 V 各需要约 n2d 次乘加,时间复杂度为 O(n2d),分数矩阵占用 O(n2) 的显存。上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大,按 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 乘以 em旧 − m新 再累加。例如一个查询与 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。

-带 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-7(完整代码见[附录 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)计算:先算每一步的时序差分误差 δt = rt + γV(st+1) − V(st),即走完这一步后价值模型的估计比之前高了多少,优势是之后各步 δ 的加权和 Σ (γλ)k δt+k。λ = 0 时只看一步,依赖价值模型的估计,方差小但有偏差;λ = 1 时相当于最终得分减去 V(st),没有偏差但方差大;λ 取两者之间的值来折中。 +把生成过程看作强化学习时,奖励模型的分数在回答结束时才给出,每个 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 的大小,也就是可训练参数的多少。一个 din × dout 的矩阵增加 r × (din + dout) 个参数; - α 是缩放系数,实际起作用的是 α / 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 逐层量化,每层按列逐个量化权重:每量化一列,就根据校准数据算出的二阶信息(输入的 XXT),调整还没有量化的那些列,抵消这一列产生的输出误差。论文中 175B 的模型在一张显卡上约 4 小时就能量化到 3~4 位。 - -AWQ(Activation-aware Weight Quantization)基于这样的观察:权重的重要程度不同,与较大的激活相乘的那部分权重对输出影响最大,只保护其中 1% 就能明显减小误差。但为个别通道保留 FP16 会形成混合精度,硬件上效率不高。AWQ 的做法是: - -1. 用校准数据统计每个输入通道激活的平均大小 sX; -2. 量化之前把权重的第 j 个输入通道乘以 sj = sX,jα,推理时把输入的第 j 个通道除以 sj,乘积不变; -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 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”这条分层规范为例:

这条规范只写在 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/)