Update 大模型应用.md

补充多头注意力的实现、计算量与 FlashAttention、MLA、交叉注意力、GRPO 的改进、LoRA 初始化、ZeRO、重排的实现、工具调用的并发执行、MCP 服务端的最小实现和 Skill 的测试,增加实测附录。
This commit is contained in:
CyC2018
2026-10-28 13:35:28 +08:00
parent 5177e8fed3
commit 7d8b9dcdb9
+398 -13
View File
@@ -76,6 +76,12 @@
* [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-并发执行工具调用)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -120,6 +126,12 @@ Token 数决定了两件事:
只用解码器的好处是训练目标统一:任何一段文本都可以直接用来训练“预测下一个 Token”,不需要成对的输入和输出;理解问题和生成回答也由同一个结构完成,生成时前文的计算结果可以缓存复用,见下文的 KV Cache。
三种结构的注意力有两处不同。编码器中每个 Token 都能看到全部 Token;解码器的自注意力带有因果掩码,只能看到前面的 Token,见下文的注意力机制。编码器-解码器结构的解码器每层还多一个交叉注意力(Cross-Attention):Q 来自解码器,K 和 V 来自编码器最后一层的输出,生成每个词时都可以查阅整句原文。
可以并行的部分也因此不同:编码器一次处理整个输入;解码器在训练时把完整的目标文本一起输入,靠因果掩码保证每个位置只看到前文,所有位置同时计算;只有推理时才必须逐个 Token 生成。
Transformer 取代循环神经网络(Recurrent Neural Network, RNN)和 LSTM,主要是因为能够并行。RNN 按顺序处理 Token,第 t 步要等第 t - 1 步的隐藏状态算完,长度为 n 的序列需要 n 步串行计算,难以发挥 GPU 的算力;相距很远的两个 Token 之间的信息要经过 n 步传递,容易在途中衰减,LSTM 的门控缓解了梯度消失,但仍然无法并行。自注意力中任意两个 Token 直接计算相关性,信息传递的路径长度为 1,所有位置可以同时计算。代价是计算量与 n<sup>2</sup> 成正比,而 RNN 与 n 成正比,见下文的注意力机制。
<div align="center"> <img src="pics/transformer-block.png" width="640px"/> </div><br>
1. 词嵌入(Embedding)把每个 Token 编号转换为一个向量,Qwen2.5-7B 中是 3584 维;
@@ -148,6 +160,8 @@ Token 数决定了两件事:
这样做的目的是用更多的参数存储知识,而每个 Token 的计算量不随之增加。例如 Qwen3-30B-A3B 有 48 层,每层 128 个专家,每个 Token 选 8 个,总参数 30.5B,每个 Token 实际用到的参数只有 3.3B(按 config.json 计算,与模型卡一致,见[附录 A16](#a16-qwen25-7b-与-qwen3-30b-a3b-的参数量))。
DeepSeekMoE 还把少数专家设为共享专家(Shared Expert),每个 Token 都要经过它们,用于学习各类输入都需要的通用知识;其余的路由专家(Routed Expert)由路由器选择,可以更专注于各自擅长的内容。例如 DeepSeek-V3 每层有 1 个共享专家和 256 个路由专家,每个 Token 再选 8 个路由专家;Qwen3 的 MoE 模型则不使用共享专家。
MoE 与参数量相同的 Dense 模型相比:
- 计算量取决于激活的参数,生成一个 Token 的计算量接近 3B 的 Dense 模型;
@@ -176,11 +190,69 @@ MoE 与参数量相同的 Dense 模型相比:
生成文本时,一个 Token 只能看到它自己和前面的 Token,这称为因果掩码(Causal Mask)。实现方法是把“后面的 Token”对应的分数设为负无穷,softmax 之后权重为 0,即图中分数矩阵的右上角。
其它要点:
注意力本身不区分 Token 的先后顺序,需要额外加入位置信息,称为位置编码。目前常用旋转位置编码(Rotary Position Embedding, RoPE),它按位置把 Q 和 K 旋转不同的角度,使注意力分数只取决于两个 Token 的内容和相对距离。
- 多头注意力(Multi-Head Attention, MHA):把向量分成多份,每份独立计算注意力再拼接起来,不同的头可以关注不同的关系,例如指代关系、相邻的词;
- 位置编码:注意力本身不区分 Token 的先后顺序,需要额外加入位置信息。目前常用旋转位置编码(Rotary Position Embedding, RoPE),它按位置把 Q 和 K 旋转不同的角度,使注意力分数只取决于两个 Token 的内容和相对距离;
- 计算量:每个 Token 都要与前面所有 Token 计算分数,序列长度为 n 时,分数矩阵的大小与 n<sup>2</sup> 成正比,这是长上下文成本高的根本原因。FlashAttention 把计算分块放在 GPU 的片上缓存中完成,避免把 n × n 的矩阵写入显存,结果与标准注意力完全相同,但速度更快、占用的显存更少。
#### 1. 多头注意力
单头注意力中,每个 Token 只得到一组权重,只能按一种标准挑选信息。多头注意力(Multi-Head Attention, MHA)把 Q、K、V 都切成 h 份,每份独立计算注意力,再把各头的结果拼接起来,乘以输出矩阵 W<sub>O</sub> 混合在一起。不同的头可以关注不同的关系,例如一个头寻找指代的对象,另一个头关注相邻的词。
以 Qwen2.5-7B 的查询为例,3584 维的向量分成 28 个头,每个头 128 维,形状的变化如下:
```text
x (n, 3584)
Q = x W_Q (n, 3584)
切分 (n, 28, 128)
交换前两维 (28, n, 128) 28 个头分别计算
每个头的分数 (28, n, n)
每个头的输出 (28, n, 128)
交换回来并拼接 (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,它在需要时自动复制。
#### 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-与注意力的计算量))。所以上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大。
降低这部分开销有两类做法。一类让每个 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 倍。
### 自回归生成与 KV Cache
@@ -206,11 +278,25 @@ 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;
- 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-的实现))。
### 采样参数
模型输出的是下一个 Token 的概率分布,最终选哪个 Token 由采样参数决定:
@@ -252,9 +338,11 @@ 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>),没有偏差但方差大;λ 取两者之间的值来折中。
奖励模型只是人类偏好的近似,模型可能找到它的漏洞,生成得分高但没有意义的回答。所以 PPO 在奖励中减去新模型与参考模型(冻结的 SFT 模型)之间的 KL 散度,限制模型偏离太远。
PPO 还把每次更新时新旧模型生成同一个 Token 的概率之比限制在 [1 - ε, 1 + ε] 之内,ε 通常取 0.2,超出的部分不再提供梯度,避免一次更新的步子太大。
生成回答的代价很高,所以 PPO 用同一批回答更新多轮,而这批回答是更新前的旧模型生成的。用新旧模型生成同一个 Token 的概率之比给优势加权,就可以用旧模型的样本估计新模型的效果,这种方法称为重要性采样。比值离 1 越远,估计越不可靠,所以 PPO 把它限制在 [1 - ε, 1 + ε] 之内,ε 通常取 0.2,超出的部分不再提供梯度,避免一次更新的步子太大。
因此 PPO 训练时要同时加载 4 个模型:策略模型(正在训练的模型)、价值模型、奖励模型和参考模型,其中策略模型和价值模型都要训练。后来的 DPO 和 GRPO 都减少了模型的数量:
@@ -279,7 +367,9 @@ loss = -log σ( β × [ (log π(y_w|x) - log π_ref(y_w|x)) - (log π(y_l|x) - l
DPO 的损失只取决于两个回答的概率之差,需要注意的是:
- 训练中较好回答的概率也可能下降,只要较差回答下降得更多,损失仍在减小,两个回答只差几个 Token 时更明显;
- 偏好数据中较好的回答普遍更长时,模型会学到“越长越好”,输出越来越长,构造数据时要控制两个回答的长度差异。
- 偏好数据中较好的回答普遍更长时,模型会学到“越长越好”,输出越来越长,构造数据时要控制两个回答的长度差异。SimPO 从算法上缓解这个问题:用回答的平均对数概率作为隐式奖励,即按长度归一化,同时去掉了参考模型。
偏好对通常这样构造:让 SFT 模型对同一个提示词生成多个回答,由人工或者模型裁判选出较好和较差的一对。数据最好来自待训练模型自己的输出,DPO 论文中参考模型就是生成偏好数据的 SFT 模型,两者的分布一致;没有这样的 SFT 模型时,先用较好的回答做一次 SFT 作为参考模型,以减小分布的差异。
#### 4. GRPO 与三者的选择
@@ -291,6 +381,15 @@ 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 分。
| | PPO | DPO | GRPO |
| --- | --- | --- | --- |
| 训练数据 | 提示词,奖励模型打分 | 离线的偏好对 | 提示词,奖励模型或规则打分 |
@@ -343,7 +442,7 @@ SFT 的样本是一段完整的对话,通常每行一条 JSON:
这条样本共 48 个 Token,前 31 个是系统消息、用户消息和助手回答的开头,标签设为 -100,不计入损失;后 17 个是回答和结束标记(Qwen2.5 分词器实测,见[附录 A17](#a17-sft-样本的损失掩码))。
如果提示部分也计入损失,模型会花力气学习复述用户的问题。结束标记一定要计入,否则模型学不会在哪里停止。多轮对话的样本中,每一轮助手的回答都计入损失。
如果提示部分也计入损失,模型会花力气学习复述用户的问题。结束标记一定要计入,否则模型学不会在哪里停止。多轮对话的样本中,每一轮助手的回答都计入损失。训练 Agent 的样本是完整的工具调用轨迹,同样只对模型的输出计算损失,包括它生成的工具调用;工具返回的结果由环境给出,模型不需要学会生成它,不计入损失。
构造数据时,质量和多样性比数量更重要:
@@ -364,7 +463,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 随机初始化,B 初始化为 0,所以训练开始时 BA = 0,模型与原模型完全相同。如果两个矩阵都随机初始化,训练一开始模型的输出就被打乱了。
- 初始化:A 随机初始化(PEFT 默认使用 Kaiming 均匀分布),B 初始化为 0,所以训练开始时 BA = 0,模型与原模型完全相同。如果两个矩阵都随机初始化,训练一开始模型的输出就被打乱了;如果两个都为 0,B 的梯度中含有因子 Ax,A 的梯度中含有因子 B,两者都是 0,训练永远无法开始。反过来让 A 为 0、B 随机也能训练(见[附录 A26](#a26-lora-的三种初始化)),但有研究发现默认的方式能使用更大的学习率,效果平均更好。
按 Qwen2.5-7B 的真实维度计算(见[附录 A16](#a16-qwen25-7b-与-qwen3-30b-a3b-的参数量)):
@@ -403,6 +502,17 @@ 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 内存,代价是训练变慢。
#### 5. 灾难性遗忘
灾难性遗忘(Catastrophic Forgetting)指的是模型在新数据上训练之后,原有的能力明显下降,例如只用客服对话微调之后,写代码和多轮推理的能力变差,也就是常说的“微调之后变傻了”。对 1B 到 7B 模型的持续微调实验中普遍出现了遗忘,而且模型越大遗忘越严重。
@@ -562,7 +672,7 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
- 给出几组输入和期望的输出,称为少样本(Few-shot)提示,示例比文字描述更能说明格式;
- 让模型先写出推理步骤再给出答案,称为思维链(Chain of Thought, CoT),可以提高多步推理问题的准确率。
提示词和代码一样需要版本管理,每次修改后都在固定的评测集上回归,对比修改前后的效果。
提示词和代码一样需要版本管理,每次修改后都在固定的评测集上回归,对比修改前后的效果。提示词、模型版本、工具定义和采样参数共同决定了输出,最好作为一个整体编号发布,线上记录每次请求用的是哪个版本,出问题时整体回滚;比较线上效果时把模型固定到具体的版本,一次只改一处,否则分不清变化来自提示词还是模型。
### 结构化输出
@@ -804,7 +914,7 @@ b = “休假的流程是什么” (0.6, 0.8) cos(a, b) = 0.48 + 0.48 = 0.9
c = “今天股价上涨了” (0.6, -0.8) cos(a, c) = 0.48 - 0.48 = 0
```
a 和 b 几乎没有相同的字,相似度却很高,这正是向量检索相比关键词检索的优势。向量通常会归一化为长度 1,这时余弦相似度等于点积,计算更快。
a 和 b 几乎没有相同的字,相似度却很高,这正是向量检索相比关键词检索的优势。向量通常会归一化为长度 1,这时余弦相似度等于点积,计算更快。归一化之后,欧氏距离与余弦相似度也是等价的:|a − b|<sup>2</sup> = 2 − 2cos(a, b),按距离从小到大排序与按相似度从大到小排序的结果相同。没有归一化时三者的排序可能不同,点积还会受到向量长度的影响,应该使用模型训练时所用的度量。
用真实的模型计算,结论相同但数值没有这么极端:用 bge-small-zh-v1.5(512 维)编码这三句话,“怎么申请年假”与“休假的流程是什么”的余弦相似度为 0.603,与“今天股价上涨了”为 0.194(见[附录 A1](#a1-embedding-相似度与-bm25))。实际系统中不同句子的相似度大多落在一个不太宽的区间里,判断是否相关要看排名和阈值,而不是期望不相关的结果接近 0。
@@ -926,6 +1036,8 @@ Elasticsearch 中 BM25 的参数 k1 和 b 默认为 1.2 和 0.75,分别控制
所以先用速度快的检索从海量数据中召回几十条,再用准确的重排模型选出最终的几条,与搜索和推荐系统中先召回、后排序的分层思路一致。
实现时,把混合检索合并后的几十个候选分别与问题组成(问题, 切片)对,交给重排模型打分。输出是一个相关性分数,可以用 sigmoid 映射到 0~1,按分数排序后保留前几条,再用阈值去掉分数过低的。常用开源的交叉编码器,例如 bge-reranker-v2-m3;也有基于大模型的重排模型,更准确但更慢。候选越多,越不容易漏掉相关的切片,延迟也越高,所以候选数量要在评测集上与延迟一起权衡。
### Top-K 与阈值
#### 1. 两个 K
@@ -962,6 +1074,8 @@ K 太小,需要多个切片才能回答的问题又会答不全,例如“几
- 问题太短:问题只有几个字,切片有几百字,两者的向量不容易靠近。HyDE 的做法是先让模型写一段假设的回答,再用这段回答去检索,因为回答在形式上更接近文档;
- 包含多个子问题:拆成多个查询分别检索,再合并结果。
改写也可以扩展成多路召回:RAG-Fusion 让模型把问题改写成几个不同角度的查询,每个查询分别检索,再用 RRF 合并全部结果,覆盖的措辞更多,但改写偏离原意时会引入无关的结果;HyDE 生成的假设回答也可以作为其中一路。
改写需要多调用一次模型,会增加延迟。
### 评测与优化
@@ -1180,6 +1294,22 @@ String run(String question) {
什么时候结束由模型判断,但模型可能一直不停,或者反复调用同一个工具,所以代码中必须限制最大步数,见下文的可靠性与评测。
模型在一次回复中给出多个工具调用,说明它认为这些调用互不依赖,例如同时查询两个订单,代码可以并发执行,全部完成后再调用模型:
```java
List<CompletableFuture<Message>> results = reply.toolCalls().stream()
.map(call -> CompletableFuture.supplyAsync(
() -> Message.tool(call.id(), execute(call)), toolPool)) // 互不依赖的调用并发执行
.toList();
for (CompletableFuture<Message> f : results) {
messages.add(f.join()); // 全部完成后按调用的顺序追加,结果靠 tool_call_id 对应
}
```
两个各需 500 毫秒的调用,并发执行共用时约 520 毫秒(JDK 21 实测,见[附录 A28](#a28-并发执行工具调用))。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。部分调用失败时的处理见[可靠性与评测](#1-容错)。
耗时很长的工具,例如生成报表、等待人工审批,不适合在循环中同步等待。可以让工具先返回一个任务 ID,保存当前的消息列表后结束这一轮;任务完成后通过消息队列或者回调通知应用,应用把结果作为工具消息追加到保存的消息中,再继续调用模型。这与[开发框架](#2-checkpoint-与人工介入)中暂停和恢复的做法相同。
### Agent 与工作流
与 Agent 相对的是工作流(Workflow):执行哪些步骤、按什么顺序执行都由代码事先写好,大模型只负责其中的某些步骤,例如对用户的消息分类。
@@ -1268,6 +1398,8 @@ ReAct 适合下一步取决于上一步结果、步数不多的任务,例如
- 执行器:按依赖顺序执行,没有依赖关系的步骤并行,每个步骤内部可以是一个小的 ReAct 循环,也可以使用更便宜的模型;
- 重规划:每完成一步,把原计划和已有结果交给模型,判断是继续、修改剩余的步骤还是结束。某一步失败、结果与计划的假设不符(例如查不到 B 的续航)时触发。
有的系统还单独设一个审查者(Critic),在每一步或者全部完成后检查结果是否满足目标和约束,例如对比表中每款产品的续航是否都来自查询结果,不满足时把具体的问题交给重规划或执行器。审查者依据工具结果、测试这类外部证据判断才可靠,只凭模型自己的判断复查效果有限,见下文的反思。
计划保存在状态中,可以向用户展示进度,中断后也能从未完成的步骤继续。重规划的次数要设上限,例如 3 次,超过后返回已完成的部分并说明原因,避免模型一直规划失败、无限循环。
两种方式的比较:
@@ -1505,12 +1637,36 @@ MCP 中有三种角色:
"result": {"content": [{"type": "text", "text": "# CS-Notes ..."}], "isError": false}}
```
实现服务端时用官方 SDK 注册工具即可。用 Python SDK 把前文的 get_order 写成 MCP 服务端(mcp 2.3.0,1.x 版本中这个类名为 FastMCP):
```python
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("order")
@mcp.tool()
def get_order(order_id: str) -> dict:
"""按订单号查询订单状态和运单号。用户询问订单、发货情况时使用"""
if order_id not in ORDERS:
raise ToolError(f"订单 {order_id} 不存在,请向用户确认订单号") # 这段文字会返回给模型
return ORDERS[order_id]
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-服务端的最小实现))。
本地服务端以子进程运行,权限就是启动它的用户的权限,规范建议从环境变量中读取凭据;远程服务端通过 HTTP 对外提供,规范按 OAuth 2.1 定义授权:客户端带着访问令牌请求,服务端必须校验令牌是否签发给自己,无效或过期的令牌返回 HTTP 401。令牌只说明调用者是谁,每个工具还要按用户校验权限,例如只能查询自己的订单。
MCP 与相关概念的区别:
- 工具调用是模型这一侧的能力,模型根据工具描述给出调用;MCP 是应用与工具之间的协议,解决工具如何被发现和调用。两者配合使用:主机把从服务端获取的工具转换为工具定义交给模型,模型给出调用后,再通过 MCP 发给服务端执行;
- 普通的 HTTP 接口也能封装成工具,但每个接口的认证方式、参数格式和文档各不相同,需要逐个适配;MCP 统一了发现和调用的方式,工具描述本身就是写给模型看的。
- 普通的 HTTP 接口也能封装成工具,但每个接口的认证方式、参数格式和文档各不相同,需要逐个适配;MCP 统一了发现和调用的方式,工具描述本身就是写给模型看的;
- 能执行命令的 Agent 可以直接调用 git、kubectl 这类命令行工具(CLI)。模型在训练中见过大量常用命令,不需要事先把定义放进上下文,需要时运行 --help 查看,输出还可以先用管道过滤,只有结果进入上下文;代价是需要能执行命令的环境,权限只能靠沙箱和命令白名单限制,输出是给人看的文本,没有统一的 Schema。MCP 适合需要统一授权、远程访问或者结构化结果的场景,以及没有命令行环境的应用,例如聊天客户端接入公司的工单系统。
MCP 也有代价。每个服务端的工具定义都会进入上下文,Anthropic 的文档中举的例子是同时接入 GitHub、Slack、Sentry、Grafana、Splunk 五个服务端,工具定义就占约 5.5 万个 Token;可用的工具超过 30~50 个后,模型选对工具的能力也会明显下降。解决方法是按需加载:上下文中只放一个搜索工具和最常用的几个工具,模型先搜索,再把找到的工具的完整定义加入上下文;也可以由应用先根据问题检索出相关的工具,只把这几个交给模型。
MCP 也有代价。每个服务端的工具定义都会进入上下文,Anthropic 的文档中举的例子是同时接入 GitHub、Slack、Sentry、Grafana、Splunk 五个服务端,工具定义就占约 5.5 万个 Token;可用的工具超过 30~50 个后,模型选对工具的能力也会明显下降。解决方法是按需加载:上下文中只放一个搜索工具和最常用的几个工具,模型先搜索,再把找到的工具的完整定义加入上下文;也可以由应用先根据问题检索出相关的工具,只把这几个交给模型。Anthropic 还介绍过一种做法:把 MCP 工具生成为代码文件,Agent 按需读取工具的定义、编写代码调用,中间结果留在执行环境中,不经过模型,例子中的 Token 消耗从 15 万降到 2000。
另外,第三方服务端的工具描述和返回结果可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,细节以官方最新版本为准。
@@ -1557,6 +1713,13 @@ description: 按团队模板生成周报。用户要求写周报、总结本周
name 只能包含小写字母、数字和连字符,最长 64 个字符;description 最长 1024 个字符,要同时写清楚做什么和什么时候用,Agent 就是根据它判断是否加载这个 Skill。需要接入外部系统、要求权限控制的能力(例如查询公司的工单系统)适合做成 MCP 服务端;告诉 Agent 怎样组合已有工具完成任务的流程和规范,适合写成 Skill。Skill 中可以包含脚本,等同于让 Agent 执行代码,只应该使用可信来源的 Skill。
Agent 是否使用某个 Skill,完全取决于它的 description,所以要像测试[意图识别与路由](#意图识别与路由)一样测试它:
- 触发测试:准备约 20 条真实的提问,一半应该触发、一半不应该触发。不应该触发的重点选关键词相近但需求不同的,例如“把周报模板翻译成英文”不应该触发生成周报的 Skill。每条运行 3 次左右计算触发率,并留出一部分提问不参与修改,检查改进是否适用于新的说法;
- 效果测试:同一批任务分别在有和没有这个 Skill(或者使用旧版本)的情况下运行,能用代码判断的项由脚本检查,其余由人或模型裁判比较,同时记录 Token 消耗和耗时。
Skill 越来越多时,目录中每个 Skill 的描述约占 50~100 个 Token,几十个也不算多,问题主要在于描述相近的 Skill 互相争抢。处理方法:职责重叠的合并成一个,或者在描述中写清边界,例如“只用于生成周报,修改模板请使用 report-template”;不适用于当前用户或环境的 Skill 不放进目录;数量很大时,先按问题检索出候选的几个,再交给模型选择。线上选错 Skill、用户纠正的会话加入触发测试集,修改描述后回归,Skill 就是这样持续迭代的。
### 多 Agent
#### 1. 为什么要多个 Agent
@@ -1642,6 +1805,10 @@ Claude Code、Cursor、Codex 这类 AI 编程助手是目前最常用的 Agent
- 任务拆小,一次只改一个功能,便于审查;生成的代码同样要做代码审查和测试,不能因为是 AI 写的就跳过;
- 把反复纠正的要求写进 CLAUDE.md 这类项目约定文件,而不是每次在对话中重复。
Vibe Coding 这个说法由 Andrej Karpathy 在 2025 年 2 月提出,指的是完全凭感觉让 AI 写代码:接受所有修改,不看 diff,报错了就把错误信息贴回去,代码超出了自己的理解范围也不在意。他本人认为这种方式适合周末随手做的一次性项目。Simon Willison 进一步区分:用 AI 生成代码之后,自己审查、测试过,并且能向别人解释它是怎样工作的,就不属于 Vibe Coding,而是正常的软件开发。
所以 AI 生成的代码占多大比例不是关键,开发者的职责没有变:明确需求和验收标准,设计方案,审查和测试生成的代码,对上线的结果负责。原型和一次性的脚本可以放手让 AI 生成;需要长期维护、涉及资金和数据安全的代码,每一处修改都要经过审查。
### Harness
Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所有代码、配置和执行逻辑:Agent 循环、系统提示词、工具及其描述、上下文压缩、记忆、沙箱与权限、子 Agent 的调度,以及在固定时机执行的钩子(例如每次修改文件后自动格式化)。前面不到二十行的 Agent 循环就是一个最简单的 Harness;Claude Code、Codex 是功能完整的 Harness,可以换用不同的模型。MCP 和 Skill 也是 Harness 的组成部分,前者负责接入工具,后者负责按需提供做事的方法。
@@ -1768,7 +1935,7 @@ String answer = ChatClient.create(chatModel)
框架根据方法签名生成参数的 JSON Schema,模型返回工具调用后,由框架执行方法、把结果发回模型,直到得到最终回答。工具抛出运行时异常时,默认把异常信息作为工具结果发回模型,让它修正或者向用户说明。需要注意的是,编译时要保留参数名(javac 的 -parameters 选项,继承 spring-boot-starter-parent 时默认开启),否则 Schema 中的参数名是 arg0(Spring AI 2.0.2 实测,见[附录 A9](#a9-spring-ai-的工具调用循环))。
Spring AI 主要解决模型接入和单个 Agent 的工具调用;需要状态图编排时,Java 中也有仿照 LangGraph 的实现,例如 LangGraph4j。
Spring AI 主要解决模型接入和单个 Agent 的工具调用。Java 中另一个常用的框架是 LangChain4j,它不依赖 Spring,可以集成到 Spring Boot 或 Quarkus 中,特点是 AI Services:声明一个接口,框架生成实现这个接口的代理对象,与 Spring Data JPA 的 Repository 类似。两者的功能相近,Spring 项目用 Spring AI 与已有的配置、依赖注入结合得更自然。需要状态图编排时,可以用仿照 LangGraph 的 LangGraph4j,它可以与 LangChain4j 或 Spring AI 一起使用。
#### 4. 怎样选择
@@ -2890,6 +3057,197 @@ 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 的三种初始化
环境: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:
```python
def run(init, steps=300, lr=1e-2):
A = torch.zeros(r, d); B = torch.zeros(d, r)
if init == "B=0": torch.nn.init.kaiming_uniform_(A, a=5 ** 0.5) # 与 PEFT 默认相同
if init == "A=0": torch.nn.init.kaiming_uniform_(B, a=5 ** 0.5)
A.requires_grad_(); B.requires_grad_()
opt = torch.optim.Adam([A, B], lr=lr)
for s in range(steps):
loss = ((X @ (W + B @ A).T - Y) ** 2).mean()
opt.zero_grad(); loss.backward()
if s == 0:
g = (A.grad.norm().item(), B.grad.norm().item()); l0 = loss.item()
opt.step()
return l0, g, loss.item()
for init in ("B=0", "A=0", "都为 0"):
l0, (ga, gb), l1 = run(init)
print(f"{init:5s} 初始损失 {l0:.4f} 第一步梯度 |∂A|={ga:.4f} |∂B|={gb:.4f} 300 步后损失 {l1:.6f}")
```
```text
B=0 初始损失 2.3699 第一步梯度 |∂A|=0.0000 |∂B|=0.0573 300 步后损失 0.000000
A=0 初始损失 2.3699 第一步梯度 |∂A|=0.1667 |∂B|=0.0000 300 步后损失 0.000000
都为 0 初始损失 2.3699 第一步梯度 |∂A|=0.0000 |∂B|=0.0000 300 步后损失 2.369939
```
两者都为 0 时,第一步的梯度全部为 0,300 步后损失没有任何变化;另外两种方式都能训练到损失接近 0。这个小实验只说明能否训练,两种可训练的方式在大模型上的效果差异见参考资料中 Hayou 等人的论文。
### A27 MCP 服务端的最小实现
环境:Python 3.13.7,mcp 2.3.0(pip 安装)。服务端即正文中的代码,ORDERS 中只有订单 1024。客户端以 stdio 方式启动服务端子进程,依次调用 tools/list 和 tools/call:
```python
import asyncio, json, os, sys
from mcp import Client, StdioServerParameters
async def main():
params = StdioServerParameters(command=sys.executable, args=["order_server.py"],
env={**os.environ, "PYTHONPATH": "pylib"})
async with Client(params) as c:
tools = await c.list_tools()
for t in tools.tools:
print("tools/list:", json.dumps({"name": t.name, "description": t.description,
"inputSchema": t.input_schema}, ensure_ascii=False))
r = await c.call_tool("get_order", {"order_id": "1024"})
print("tools/call 1024:", r.content[0].text, "isError =", r.is_error)
r = await c.call_tool("get_order", {"order_id": "9999"})
print("tools/call 9999:", r.content[0].text, "isError =", r.is_error)
asyncio.run(main())
```
输出(服务端日志省略;最后一行是改用 mcp.run(transport="streamable-http") 启动服务端后,客户端连接 http://localhost:8000/mcp 调用的结果):
```text
tools/list: {"name": "get_order", "description": "按订单号查询订单状态和运单号。用户询问订单、发货情况时使用", "inputSchema": {"type": "object", "properties": {"order_id": {"title": "Order Id", "type": "string"}}, "required": ["order_id"], "title": "get_orderArguments"}}
tools/call 1024: {
"status": "已发货",
"tracking_no": "SF1234567"
} isError = False
tools/call 9999: Error executing tool get_order: 订单 9999 不存在,请向用户确认订单号 isError = True
mcp 2.3.0, Python 3.13.7
streamable-http tools/call: { "status": "已发货", "tracking_no": "SF1234567"}
```
把 ToolError 换成普通的 ValueError 后,订单 9999 的结果变为 `Error executing tool get_order`,isError 同样为 True,错误的具体内容只出现在服务端的日志中。
### A28 并发执行工具调用
环境:JDK 21.0.13(Adoptium),Apple M1 Pro。Message、ToolCall 用 record 定义,execute 为桩代码,每次调用休眠 500 毫秒;两个 get_order 调用用正文中的片段并发执行:
```text
2 个调用并发执行耗时 519 毫秒
Message[role=tool, toolCallId=call_1, content={"order_id": "1024", "status": "已发货"}]
Message[role=tool, toolCallId=call_2, content={"order_id": "1025", "status": "已发货"}]
openjdk version "21.0.13" 2026-10-20 LTS
```
## 参考资料
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
@@ -3032,3 +3390,30 @@ msgs += [{"role": "assistant", "content": raw},
- [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)
- [DeepSeek-V3 config.json](https://huggingface.co/deepseek-ai/DeepSeek-V3/blob/main/config.json)
- [Dai D, et al. DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models. arXiv:2401.06066](https://arxiv.org/abs/2401.06066)
- [Qwen Team. Qwen3 Technical Report. arXiv:2505.09388](https://arxiv.org/abs/2505.09388)
- [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)
- [Zeng A, et al. AgentTuning: Enabling Generalized Agent Abilities for LLMs. arXiv:2310.12823](https://arxiv.org/abs/2310.12823)
- [Rackauckas Z. RAG-Fusion: a New Take on Retrieval-Augmented Generation. arXiv:2402.03367](https://arxiv.org/abs/2402.03367)
- [BAAI/bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3)
- [Langfuse: Prompt Version Control](https://langfuse.com/docs/prompt-management/features/prompt-version-control)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [MCP Specification: Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization)
- [Anthropic: Code execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp)
- [Agent Skills: Optimizing skill descriptions](https://agentskills.io/skill-creation/optimizing-descriptions)
- [Agent Skills: Evaluating skill output quality](https://agentskills.io/skill-creation/evaluating-skills)
- [Agent Skills: How to add skills support to your agent](https://agentskills.io/client-implementation/adding-skills-support)
- [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)