diff --git a/notes/pics/agent-messages.png b/notes/pics/agent-messages.png
index 3c27bd6d..bb33a494 100644
Binary files a/notes/pics/agent-messages.png and b/notes/pics/agent-messages.png differ
diff --git a/notes/pics/agent-overview.png b/notes/pics/agent-overview.png
index c14ba5ab..fe9434a6 100644
Binary files a/notes/pics/agent-overview.png and b/notes/pics/agent-overview.png differ
diff --git a/notes/pics/agent-sse-events.png b/notes/pics/agent-sse-events.png
index 2db60997..22dc3e4e 100644
Binary files a/notes/pics/agent-sse-events.png and b/notes/pics/agent-sse-events.png differ
diff --git a/notes/pics/align-models.png b/notes/pics/align-models.png
index caf30008..75569729 100644
Binary files a/notes/pics/align-models.png and b/notes/pics/align-models.png differ
diff --git a/notes/pics/context-budget.png b/notes/pics/context-budget.png
index 752129c0..3c8e5a3a 100644
Binary files a/notes/pics/context-budget.png and b/notes/pics/context-budget.png differ
diff --git a/notes/pics/hnsw.png b/notes/pics/hnsw.png
index 8c41ea1f..dc4886e7 100644
Binary files a/notes/pics/hnsw.png and b/notes/pics/hnsw.png differ
diff --git a/notes/pics/intent-routing-eval.png b/notes/pics/intent-routing-eval.png
index 4c58b48c..fae76bfa 100644
Binary files a/notes/pics/intent-routing-eval.png and b/notes/pics/intent-routing-eval.png differ
diff --git a/notes/pics/intent-routing.png b/notes/pics/intent-routing.png
index 3128f1d5..d0465a9a 100644
Binary files a/notes/pics/intent-routing.png and b/notes/pics/intent-routing.png differ
diff --git a/notes/pics/llm-ppo-clip.png b/notes/pics/llm-ppo-clip.png
new file mode 100644
index 00000000..2794b1a9
Binary files /dev/null and b/notes/pics/llm-ppo-clip.png differ
diff --git a/notes/pics/llm-rope-2d.png b/notes/pics/llm-rope-2d.png
new file mode 100644
index 00000000..95ae450a
Binary files /dev/null and b/notes/pics/llm-rope-2d.png differ
diff --git a/notes/pics/lora.png b/notes/pics/lora.png
index b67d3385..dec2746b 100644
Binary files a/notes/pics/lora.png and b/notes/pics/lora.png differ
diff --git a/notes/pics/mcp.png b/notes/pics/mcp.png
index 65489e26..825ae7df 100644
Binary files a/notes/pics/mcp.png and b/notes/pics/mcp.png differ
diff --git a/notes/pics/plan-execute.png b/notes/pics/plan-execute.png
index c05f5440..1506c38b 100644
Binary files a/notes/pics/plan-execute.png and b/notes/pics/plan-execute.png differ
diff --git a/notes/pics/quant-group.png b/notes/pics/quant-group.png
index aa9531d1..0e158302 100644
Binary files a/notes/pics/quant-group.png and b/notes/pics/quant-group.png differ
diff --git a/notes/pics/quant-ppl.png b/notes/pics/quant-ppl.png
index 6d99e726..d827a879 100644
Binary files a/notes/pics/quant-ppl.png and b/notes/pics/quant-ppl.png differ
diff --git a/notes/pics/qwen-layer-params.png b/notes/pics/qwen-layer-params.png
index 1acea7ce..0a8f0ee1 100644
Binary files a/notes/pics/qwen-layer-params.png and b/notes/pics/qwen-layer-params.png differ
diff --git a/notes/pics/rag-pipeline.png b/notes/pics/rag-pipeline.png
index 402e289e..b5abc09b 100644
Binary files a/notes/pics/rag-pipeline.png and b/notes/pics/rag-pipeline.png differ
diff --git a/notes/pics/react-loop.png b/notes/pics/react-loop.png
index 93aac98b..56ebe782 100644
Binary files a/notes/pics/react-loop.png and b/notes/pics/react-loop.png differ
diff --git a/notes/pics/skills.png b/notes/pics/skills.png
index 9cecb3c9..4997cf54 100644
Binary files a/notes/pics/skills.png and b/notes/pics/skills.png differ
diff --git a/notes/pics/tool-call-text.png b/notes/pics/tool-call-text.png
index 996b08d2..97aafac0 100644
Binary files a/notes/pics/tool-call-text.png and b/notes/pics/tool-call-text.png differ
diff --git a/notes/大模型应用.md b/notes/大模型应用.md
index d7a11e31..7721fbb8 100644
--- a/notes/大模型应用.md
+++ b/notes/大模型应用.md
@@ -82,16 +82,13 @@
* [A26 停止前校验](#a26-停止前校验)
* [A27 Embedding 的池化与批内负样本](#a27-embedding-的池化与批内负样本)
* [A28 Spring AI 的对话记忆与工具调用](#a28-spring-ai-的对话记忆与工具调用)
+ * [A29 HNSW 的距离计算次数与知识库规模](#a29-hnsw-的距离计算次数与知识库规模)
+ * [A30 混合检索与重排的召回率](#a30-混合检索与重排的召回率)
* [参考资料](#参考资料)
-本篇面向准备 AI 应用、Agent 开发岗位,但之前没有接触过大模型开发的程序员。建议的阅读顺序:
-
-1. 先读“Agent 是什么”和“工具调用”,理解两件最基本的事:模型只会输出文本,调用工具、读写数据都是应用代码做的;模型不保存状态,每次调用都要把需要的信息重新发过去;
-2. 再读“Agent 循环”和“上下文工程”,看懂一个 Agent 每一步发送了什么、为什么步数越多越贵;
-3. 然后按需要读检索增强生成、记忆、MCP、Skills 和多 Agent,它们都是在这两件基本事实之上解决具体问题;
-4. 第一部分的模型原理(注意力、KV Cache、训练)在面试中常被追问,可以最后补。
+本篇面向准备 AI 应用、Agent 开发岗位,但之前没有接触过大模型开发的程序员。
## 一、大模型基础
@@ -126,11 +123,7 @@ 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,所有位置可以同时计算。代价是计算量与 n2 成正比,而 RNN 与 n 成正比,见下文的注意力机制。
+以 Qwen2.5-7B 为例,输入的 Token 依次经过三部分:
@@ -138,7 +131,13 @@ Transformer 取代循环神经网络(Recurrent Neural Network, RNN)和 LSTM
2. 向量依次经过几十个结构相同的层,Qwen2.5-7B 有 28 层。每层先用注意力从前文收集信息,再用前馈网络(Feed-Forward Network, FFN)对每个位置单独加工;
3. 最后一个位置的向量经过线性层,得到词表中每个 Token 的分数(logits),再经过 softmax 转为概率。
-每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练;归一化把每个向量的数值缩放到稳定的范围。最初的 Transformer 在残差相加之后做归一化,目前的大模型大多改为在每个子层之前做(Pre-Norm),并使用只按均方根缩放、计算更简单的 RMSNorm,Qwen2 系列就是这样。
+每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练;归一化把每个向量的数值缩放到稳定的范围。最初的 Transformer 在残差相加之后做归一化,目前的大模型大多改为在每个子层之前做(Pre-Norm),残差的主干上不再经过归一化,几十层叠加时训练更稳定,并使用只按均方根缩放、计算更简单的 RMSNorm,Qwen2 系列就是这样。
+
+三种结构的注意力有两处不同。编码器中每个 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,所有位置可以同时计算。代价是计算量与 n2 成正比,而 RNN 与 n 成正比,见下文的注意力机制。
#### 2. 参数都在哪里
@@ -160,13 +159,13 @@ Transformer 取代循环神经网络(Recurrent Neural Network, RNN)和 LSTM
这样做的目的是用更多的参数存储知识,而每个 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 模型则不使用共享专家。
+DeepSeekMoE 还把少数专家设为共享专家(Shared Expert),每个 Token 都要经过它们,用于学习各类输入都需要的通用知识;其余的路由专家(Routed Expert)由路由器选择,可以更专注于各自擅长的内容。例如 DeepSeek-V3 除前 3 层仍是稠密的前馈网络外,每层有 1 个共享专家和 256 个路由专家,每个 Token 再选 8 个路由专家;Qwen3 的 MoE 模型则不使用共享专家。
MoE 与参数量相同的 Dense 模型相比:
- 计算量取决于激活的参数,生成一个 Token 的计算量接近 3B 的 Dense 模型;
- 显存取决于总参数,所有专家都要加载,Qwen3-30B-A3B 的 BF16 权重约 61 GB,是 Qwen2.5-7B 的 4 倍;
-- 训练时需要负载均衡:路由器可能总是选中少数几个专家,其它专家得不到训练。Switch Transformer 等模型在损失函数中加一项辅助损失,各专家处理的 Token 数越不均匀,这一项越大;DeepSeek-V3 改为给每个专家的路由分数加一个只用于选择专家的偏置,某个专家负载过高时调低它的偏置,不再使用辅助损失。
+- 训练时需要负载均衡:路由器可能总是选中少数几个专家,其它专家得不到训练。Switch Transformer 等模型在损失函数中加一项辅助损失,各专家处理的 Token 数越不均匀,这一项越大;DeepSeek-V3 改为给每个专家的路由分数加一个只用于选择专家的偏置,某个专家负载过高时调低它的偏置,辅助损失只保留一项权重极小的,防止单个序列内极度不均衡。
### 注意力机制
@@ -178,6 +177,8 @@ MoE 与参数量相同的 Dense 模型相比:
- 键(Key, K):自己能被什么样的查询找到;
- 值(Value, V):被找到之后提供的信息。
+不直接用 Token 的向量互相做点积,是因为那样分数矩阵是对称的,“饿”找“小猫”与“小猫”找“饿”得分相同,向量长度相同时每个 Token 与自己的分数还总是最高。Q、K 分开投影后,相关性可以是单向的;V 单独投影,使提供的信息不必等于用来匹配的特征。
+
计算公式为 softmax(QKT / √d) V。以“饿”为例:
@@ -188,9 +189,17 @@ MoE 与参数量相同的 Dense 模型相比:
除以 √d 是因为:当向量的各个分量相互独立、均值为 0、方差为 1 时,点积的方差等于 d。维度越大分数之间的差距越大,softmax 的结果几乎全部集中到一个 Token 上,梯度接近 0,训练变得困难。除以 √d 之后,点积的方差恢复为 1。
-生成文本时,一个 Token 只能看到它自己和前面的 Token,这称为因果掩码(Causal Mask)。实现方法是把“后面的 Token”对应的分数设为负无穷,softmax 之后权重为 0,即图中分数矩阵的右上角。
+softmax 把任意分数变成非负、和为 1 的权重,处处可导,指数又拉大了高分与低分的差距。分数不直接用余弦相似度,是因为它的范围只有 −1 到 1,拉不开差距:上下文有 1000 个 Token 时,即使一个 Token 的余弦为 1、其余都是 −1,它的权重也只有 0.7%,要用余弦就得再乘一个较大的系数;点积则保留了向量长度,模型可以靠长度表达匹配的强弱。
-注意力本身不区分 Token 的先后顺序,需要额外加入位置信息,称为位置编码。目前常用旋转位置编码(Rotary Position Embedding, RoPE),它按位置把 Q 和 K 旋转不同的角度,使注意力分数只取决于两个 Token 的内容和相对距离。
+生成文本时,一个 Token 只能看到它自己和前面的 Token,这称为因果掩码(Causal Mask)。实现方法是把“后面的 Token”对应的分数设为负无穷,softmax 之后权重为 0,即图中分数矩阵的右上角。训练时整段文本一起输入,不遮住后文的话,预测下一个 Token 时就能直接看到答案。
+
+注意力本身不区分 Token 的先后顺序:把“小猫很饿”打乱成“饿很小猫”,“饿”的 Q 与各个 K 的分数不变,得到的加权和也不变,所以需要额外加入位置信息,称为位置编码。最初的 Transformer 把用正余弦函数生成的位置向量加在词嵌入上,编码的是绝对位置。
+
+目前常用旋转位置编码(Rotary Position Embedding, RoPE):把每个头的 Q、K 向量两两分组,每组看作一个二维向量,位置为 m 的 Token 把第 i 组旋转 mθi(θi = base−2i/d,前面的组转得快)。位置 m 的 Q 和位置 n 的 K 旋转后,夹角变化了 (m − n)θi,所以点积只取决于内容和相对距离。例如只有一组、每个位置转 30°,Q 和 K 旋转前都是 (1, 0):
+
+
+
+前两种情况的距离都是 1,分数相同;距离为 3 时夹角为 90°,点积为 0。按 Qwen2.5-7B 的 128 维向量计算也是如此(见[进阶:附录 A4](大模型应用%20-%20进阶.md#a4-rope-的相对位置与频率))。RoPE 只旋转 Q 和 K,没有需要训练的参数。不旋转 V,是因为位置只需要影响“和谁相关”;V 是被加权汇总的内容,旋转它会把各 Token 的绝对位置带进输出。超过训练长度时怎样扩展上下文,见[进阶:旋转位置编码与长度外推](大模型应用%20-%20进阶.md#3-旋转位置编码与长度外推)。
#### 1. 多头注意力
@@ -224,7 +233,7 @@ FlashAttention 把计算分块放进 GPU 的片上缓存完成,n × n 的分
-由于因果掩码,前文 Token 的 K 和 V 在后续步骤中不会再变化,可以缓存起来重复使用,这就是 KV Cache。因此一次请求分为两个阶段:
+由于因果掩码,前文 Token 的 K 和 V 在后续步骤中不会再变化,可以缓存起来重复使用,这就是 KV Cache。Q 不需要缓存:每一步只有新 Token 要计算注意力,用的是它自己的 Q,前面 Token 的 Q 之后不会再用到。因此一次请求分为两个阶段:
- 预填充(Prefill):一次性并行处理整个输入,计算并缓存所有输入 Token 的 K 和 V,决定了首个 Token 的延迟;
- 解码(Decode):每一步只为新 Token 计算 Q、K、V,再与缓存中的 K、V 计算注意力,逐个生成,决定了后续的输出速度。
@@ -248,13 +257,17 @@ KV Cache 是用显存换计算。每个 Token 要在每一层缓存 K 和 V 两
-带 KV Cache 的 GQA 在解码时每一步只输入新的 Token,把它的 K、V 追加到缓存末尾,再与缓存中的全部 K、V 计算注意力,代码见[进阶:带 KV Cache 的 GQA](大模型应用%20-%20进阶.md#1-带-kv-cache-的-gqa)。
+GQA 减少的是缓存和读取的数据量,注意力的计算量基本不变:查询头仍是 28 个,每个头照样与全部的 K 计算分数。它能加快解码,是因为解码每一步都要从显存读出全部 KV Cache,速度受显存带宽限制(见[批处理](#1-批处理用延迟换吞吐))。一个 32K Token 的请求,GQA 每步读 1.75 GB,改用 MHA 要读 12.25 GB,接近权重本身。
+
+效果接近 MHA,是因为每个查询头仍有自己的 Q,同组的头从同一组 K、V 中仍能算出不同的权重,各自挑选不同的信息。GQA 论文把已训练好的 MHA 模型改为 8 组,再用原预训练约 5% 的计算量继续训练,效果接近 MHA,速度接近 MQA。
+
+带 KV Cache 的 GQA 用 PyTorch 手写的实现见[进阶:带 KV Cache 的 GQA](大模型应用%20-%20进阶.md#1-带-kv-cache-的-gqa)。
### 采样参数
模型输出的是下一个 Token 的概率分布,最终选哪个 Token 由采样参数决定:
-- 贪心解码:总是选概率最高的 Token,结果确定,但容易陷入重复;
+- 贪心解码:总是选概率最高的 Token,结果基本确定(线上服务中仍可能有细微差别,见[批处理](#1-批处理用延迟换吞吐)),但容易陷入重复;
- 温度(Temperature):把每个 Token 的分数 z 除以温度 T 之后再做 softmax,T < 1 时高概率的 Token 更突出,T > 1 时分布变平;
- Top-k:只在概率最高的 k 个 Token 中采样;
- Top-p:按概率从高到低累加,只在累计概率刚达到 p 的那些 Token 中采样,候选数量随分布自动变化。
@@ -291,11 +304,13 @@ SFT 之后还要做偏好对齐,是因为 SFT 只告诉模型“这个回答
PPO 更新模型时不直接使用奖励的高低,而是使用优势(Advantage):一个回答的实际得分比预期高多少。预期由价值模型(Critic)给出,它估计“从当前位置继续生成,平均能得多少分”。只看奖励时,简单的题目每个回答都得高分,模型分不清哪个回答真正更好;减去预期之后,比预期好的回答被强化,比预期差的被抑制。
-把生成过程看作强化学习时,奖励模型的分数在回答结束时才给出,每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)由价值模型的逐步估计算出,推导见[进阶:广义优势估计](大模型应用%20-%20进阶.md#1-广义优势估计)。
+把生成过程看作强化学习时,奖励模型的分数在回答结束时才给出,每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)由价值模型的逐步估计算出,计算方法和例子见[进阶:广义优势估计](大模型应用%20-%20进阶.md#1-广义优势估计)。
奖励模型只是人类偏好的近似,模型可能找到它的漏洞,生成得分高但没有意义的回答。所以 PPO 在奖励中减去新模型与参考模型(冻结的 SFT 模型)之间的 KL 散度,限制模型偏离太远。
-生成回答的代价很高,所以 PPO 用同一批回答更新多轮,而这批回答是更新前的旧模型生成的。用新旧模型生成同一个 Token 的概率之比给优势加权,就可以用旧模型的样本估计新模型的效果,这种方法称为重要性采样。比值离 1 越远,估计越不可靠,所以 PPO 把它限制在 [1 - ε, 1 + ε] 之内,ε 通常取 0.2,超出的部分不再提供梯度,避免一次更新的步子太大。
+生成回答的代价很高,所以 PPO 用同一批回答更新多轮,而这批回答是更新前的旧模型生成的。用新旧模型生成同一个 Token 的概率之比给优势加权,就可以用旧模型的样本估计新模型的效果,这种方法称为重要性采样。比值离 1 越远,估计越不可靠,所以 PPO 把它截断在 [1 - ε, 1 + ε] 之内,ε 通常取 0.2:优势为正时比值超过 1 + ε、优势为负时比值低于 1 - ε,这个 Token 就不再提供梯度,避免一次更新的步子太大;朝相反方向偏离的部分不截断,仍然会被纠正。以旧概率为 0.20 的一个 Token 为例:
+
+
因此 PPO 训练时要同时加载 4 个模型:策略模型(正在训练的模型)、价值模型、奖励模型和参考模型,其中策略模型和价值模型都要训练。后来的 DPO 和 GRPO 都减少了模型的数量:
@@ -305,7 +320,7 @@ PPO 更新模型时不直接使用奖励的高低,而是使用优势(Advanta
直接偏好优化(Direct Preference Optimization, DPO)不训练奖励模型,也不在训练中生成回答,直接用标注好的偏好对 (提示词, 较好的回答, 较差的回答) 训练,只需要策略模型和参考模型。
-不需要单独的奖励模型,是因为它的推导表明,最优的策略模型本身就隐含了一个奖励:r(x, y) = β log (π(y|x) / πref(y|x)),即回答在新模型中的对数概率比参考模型高出多少。
+不需要单独的奖励模型,是因为它的推导表明,最优的策略模型本身就隐含了一个奖励:r(x, y) = β log (π(y|x) / πref(y|x)),即回答在新模型中的对数概率比参考模型高出多少。严格地说还要加上一个只与提示词有关的项,它在两个回答的奖励相减时抵消,所以不必计算。
把这个隐式奖励代入奖励模型的损失,就得到 DPO 的损失函数,其中 yw、yl 分别是较好和较差的回答,β 控制偏离参考模型的程度:
@@ -313,7 +328,7 @@ PPO 更新模型时不直接使用奖励的高低,而是使用优势(Advanta
loss = -log σ( β × [ (log π(y_w|x) - log π_ref(y_w|x)) - (log π(y_l|x) - log π_ref(y_l|x)) ] )
```
-训练开始时模型与参考模型相同,括号内为 0,损失是 log 2 ≈ 0.693。取 β = 0.1,如果训练后较好回答的对数概率比参考模型高了 2,较差回答的低了 1,括号内为 3,损失降为 -log σ(0.3) ≈ 0.554。也就是说,DPO 在拉高较好回答概率的同时压低较差回答的概率,是一个二分类问题。
+训练开始时模型与参考模型相同,括号内为 0,损失是 log 2 ≈ 0.693。取 β = 0.1,如果训练后较好回答的对数概率比参考模型高了 2,较差回答的低了 1,括号内为 3,损失降为 -log σ(0.3) ≈ 0.554。DPO 在拉高较好回答概率的同时压低较差回答的概率,是一个二分类问题。
参考模型和 β 起到了 PPO 中 KL 散度的作用,所以 DPO 不需要再单独加 KL 项。
@@ -322,6 +337,8 @@ DPO 的损失只取决于两个回答的概率之差,需要注意的是:
- 训练中较好回答的概率也可能下降,只要较差回答下降得更多,损失仍在减小,两个回答只差几个 Token 时更明显;
- 偏好数据中较好的回答普遍更长时,模型会学到“越长越好”,输出越来越长,构造数据时要控制两个回答的长度差异。SimPO 从算法上缓解这个问题:用回答的平均对数概率作为隐式奖励,即按长度归一化,同时去掉了参考模型。
+训练中除了损失,还要看隐式奖励的准确率(较好回答的隐式奖励更高的比例)、两者的差距,以及较好回答的对数概率是否在下降;训练后用模型裁判对比新旧模型的胜率,并用通用基准检查其它能力有没有退化。
+
偏好对通常这样构造:让 SFT 模型对同一个提示词生成多个回答,由人工或者模型裁判选出较好和较差的一对。数据最好来自待训练模型自己的输出,DPO 论文中参考模型就是生成偏好数据的 SFT 模型,两者的分布一致;没有这样的 SFT 模型时,先用较好的回答做一次 SFT 作为参考模型,以减小分布的差异。
#### 4. GRPO 与三者的选择
@@ -344,7 +361,7 @@ GRPO 把 KL 散度直接加在损失中,概率之比的限制与 PPO 相同。
| 加载的模型 | 4 个,训练 2 个 | 2 个,训练 1 个 | 2~3 个,训练 1 个 |
| 7B 模型状态的显存(不含激活) | 约 274 GB | 约 137 GB | 约 137~152 GB |
-三者的选择:DPO 实现简单、训练稳定,适合已有偏好数据的对齐;PPO 和 GRPO 在训练中生成新的回答,能探索出训练数据之外的更好回答,推理能力的训练通常使用 GRPO 这类强化学习方法。
+三者的选择:DPO 实现简单、训练稳定,适合已有偏好数据的对齐;PPO 和 GRPO 在训练中生成新的回答,能探索出训练数据之外的更好回答,推理能力的训练通常使用 GRPO 这类强化学习方法。DPO 本身没有探索,只能在已有的偏好对之间学习,常见的补救是迭代 DPO:用训练后的模型重新生成回答、构造新的偏好对,再训练一轮。
### 微调 🔥
@@ -395,7 +412,7 @@ SFT 的样本是一段完整的对话,通常每行一条 JSON:
- 数量级:LIMA 用 1000 条,InstructGPT 的 SFT 数据约 1.3 万条提示词。LIMA 的消融实验中,只增加数量而不增加问题的多样性,收益很快递减;
- 来源:从线上真实的对话中挑选,脱敏之后由人工修改成标准的回答;线上发现的错误回答(Badcase)修正后补充进训练集,形成闭环;
- 覆盖:常见问题、边界情况和应该拒绝或转人工的情况都要有,回答的风格保持一致,否则模型学到的是混乱的风格;
-- 评测:单独留一份不参与训练的评测集,同时准备一份通用能力的评测集,检查下文的灾难性遗忘。
+- 评测:单独留一份不参与训练的评测集,按任务定指标,例如工单场景统计 JSON 能解析的比例和各字段的准确率,话术和语气由人工或[模型裁判](#3-模型裁判的偏差)比较微调前后的回答;同时准备一份通用能力的评测集,检查下文的灾难性遗忘。
#### 3. LoRA
@@ -407,8 +424,8 @@ SFT 的样本是一段完整的对话,通常每行一条 JSON:
输出为 h = Wx + (α / r) · BAx:
-- 秩 r 决定 B、A 的大小,也就是可训练参数的多少。一个 din × dout 的矩阵增加 r × (din + dout) 个参数;
-- α 是缩放系数,实际起作用的是 α / r。LoRA 论文把 α 设为第一次尝试的 r 之后就不再调整,改变 r 时 α / r 的缩放可以减少重新调学习率的需要;
+- 秩 r 决定 B、A 的大小,也就是可训练参数的多少。一个 din × dout 的矩阵增加 r × (din + dout) 个参数。A 先把输入压缩成 r 个数,B 再把它们映射回输出的维度,所以这一层的输出最多只在 r 个方向上被改变;
+- α 是缩放系数,实际起作用的是 α / r。使用 Adam 时调 α 和调学习率的效果差不多,所以 α 通常固定为一个值(例如 16、32),只调学习率。改变 r 时 α 也不变:除以 r 使不同的 r 在训练初期的更新幅度相同,最优学习率变化很小;如果让 α 随 r 一起变(例如 α = 2r),r 越大相当于学习率越大;
- 初始化: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-的参数量)):
@@ -422,11 +439,13 @@ SFT 的样本是一段完整的对话,通常每行一条 JSON:
以 q_proj 为例,3584 × 3584 的矩阵有 1284 万个参数,r = 8 时只增加 8 × (3584 + 3584) = 57,344 个。
-LoRA 论文只在注意力的 q、v 上实验;QLoRA 的实验发现,要达到全参数微调的效果,LoRA 要作用在所有线性层上,这比 r 取多大更重要,所以目前常用的做法是作用在全部线性层(PEFT 中设置 `target_modules="all-linear"`),r 取 8、16 这样较小的值(PEFT 的默认值为 8)。
+r 不需要很大,是因为微调主要是调整模型已有的能力:LoRA 论文在 GPT-3 175B 的 q、v 上,r = 1 与 r = 64 的准确率只差 0.1 个百分点。r 要多大取决于训练数据中有多少信息要学:几千条格式、风格类的样本,可以从 r = 8、16 开始(PEFT 的默认值为 8),再用验证集比较;数据量很大时,r 太小会使训练损失跟不上全参数微调;在只按最终答案给奖励的数学题强化学习实验中,r = 1 也能接近全参数微调的效果。
+
+LoRA 论文只把它用在注意力的矩阵上,在相同参数量下比较后主要使用 q、v;QLoRA 的实验发现,要达到全参数微调的效果,LoRA 要作用在所有线性层上,这比 r 取多大更重要,所以目前常用的做法是作用在全部线性层(PEFT 中设置 `target_modules="all-linear"`)。
LoRA 的优点:
-- 显存少:梯度和优化器状态只为可训练参数保存。上表第三行的配置中,冻结的权重 15.2 GB,可训练部分连同优化器状态只有 0.65 GB;GPT-3 175B 的训练显存从 1.2 TB 降到了 350 GB;
+- 显存少:梯度和优化器状态只为可训练参数保存。上表第三行的配置中,冻结的权重 15.2 GB,可训练部分连同优化器状态只有 0.65 GB;
- 推理无额外开销:训练完成后把 (α / r) · BA 直接加到 W 上,模型结构不变;
- 便于切换:同一个基座模型可以保存多份 LoRA 参数,用于不同的任务,上表第三行的配置以 BF16 保存只有约 81 MB。
@@ -461,8 +480,8 @@ Qwen2.5-7B 的权重在 4 位下约 4 GB,比 BF16 的 15.2 GB 少了 11 GB 左
LoRA 微调的效果不好时,按以下顺序检查:
- 数据:先抽查样本的质量,确认训练时的对话模板与推理时一致、只对回答计算损失,见上文的 SFT 数据;
-- 学习率:LoRA 的最优学习率约为全参数微调的 10 倍,QLoRA 对 7B 模型使用 2 × 10-4,照搬全参数微调的学习率(例如 LIMA 用的 1 × 10-5)会学得过慢;
-- 作用范围和秩:只作用在注意力上时改为全部线性层;数据量大、与预训练差异大时,r 太小会使训练损失降到一定程度后不再跟随全参数微调下降,可以增大 r,仍然不够就改用全参数微调;
+- 学习率:LoRA 的最优学习率约为全参数微调的 10 倍,QLoRA 对 7B、13B 模型使用 2 × 10-4,33B、65B 减半,照搬全参数微调的学习率(例如 LIMA 用的 1 × 10-5)会学得过慢;
+- 作用范围和秩:只作用在注意力上时改为全部线性层;数据量大、与预训练差异大时增大 r,仍然不够就改用全参数微调;
- 过拟合:训练损失持续下降而验证集上变差,减少轮数,增加数据的多样性。
#### 5. 灾难性遗忘
@@ -479,13 +498,13 @@ LoRA 微调的效果不好时,按以下顺序检查:
量化(Quantization)指的是用更少的位数存储模型的参数,例如把 BF16(16 位)的权重转为 INT8 或 INT4。Qwen2.5-7B 的权重在 BF16 下约 15.2 GB,INT8 约 7.6 GB,INT4 约 3.8 GB(本节的 GB 按 109 字节计算,按 230 字节计算时 BF16 权重约 14.2 GB)。
-量化不仅省显存,还能加快生成。这是因为解码阶段每生成一个 Token,都要把全部权重从显存读一遍,而计算量很小,速度受显存带宽限制,权重越小读得越快。推理服务的整体显存怎样估算,见[延迟与成本](#延迟与成本)。
+量化不仅省显存,还能加快生成。这是因为解码阶段每生成一个 Token,都要把全部权重从显存读一遍,而计算量很小,速度受显存带宽限制,权重越小读得越快。推理服务的整体显存怎样估算,见[显存:权重与 KV Cache](#3-显存权重与-kv-cache)。
#### 1. 怎样量化
权重能够量化,是因为它们的数值集中在很小的范围内。Qwen2.5-7B 第 14 层的 q_proj 中,权重绝对值的平均值只有 0.0124,最大的也只有 0.332(见[附录 A18](#a18-权重量化的误差与困惑度))。
-最简单的是对称量化:取一组权重中绝对值的最大值,除以整数能表示的最大值作为缩放系数 s,每个权重除以 s 后取整。INT8 能表示 -127~127,INT4 只能表示 -7~7。使用时乘以 s 还原,还原后与原值的差就是量化误差。
+最简单的是对称量化:取一组权重中绝对值的最大值,除以整数能表示的最大值作为缩放系数 s,每个权重除以 s 后取整。INT8 取 -127~127,INT4 取 -7~7,不用 -128 和 -8,使正负两侧对称。使用时乘以 s 还原,还原后与原值的差就是量化误差。
一组权重共用一个缩放系数,这组权重取多大,称为量化的粒度。下面是 q_proj 中真实的 16 个权重,其中有一个离群值:
@@ -547,7 +566,9 @@ KV Cache 也可以量化。Qwen2.5-7B 每个 Token 的 KV Cache 在 BF16 下占
有参考资料时(例如 RAG),检测的是忠实性:把回答拆成一条条结论,逐条检查能否在资料中找到依据,见[评测与优化](#评测与优化)中的忠实度。引用的原文是否真的出现在资料中、数字和实体是否与数据库一致,这些可以直接用代码检查,比让模型判断更可靠。
-没有参考资料时,可以对同一个问题多次采样再比较。模型真正知道的事实,多次回答的内容基本一致;编造的内容每次都不一样,例如问一个冷门开源库中某个函数的参数,三次可能给出三套不同的参数。
+RAG 的回答出现幻觉时,先看正确的资料是否在提示词中:不在时先查知识库有没有这份资料、检索有没有找到,同时查模型为什么没有回答不知道;在却答错是生成的问题。逐步排查见[排查顺序](#4-排查顺序)。测试用例要专门放入资料中没有答案、新旧版本冲突的问题,见[评测集](#3-评测集)。
+
+没有参考资料时,可以对同一个问题多次采样再比较。模型真正知道的事实,多次回答的内容基本一致;编造的内容往往每次都不一样,例如问一个冷门开源库中某个函数的参数,三次可能给出三套不同的参数。
用另一个模型判断回答是否有幻觉时,这个模型同样可能出错。让它只回答“这条结论是否被这段资料支持”这样范围很窄的问题,并用一批人工标注的样本测出它判断的准确率,才知道它的结果能否信任。
@@ -574,7 +595,7 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
| 同上 | 加上“没有某项信息时如实告知,不要猜测” | 9 / 10,其中 8 次把 updated_at 当成了送达时间 |
| 改为 location_updated_at,并给出 `"eta": null` 和说明 | 以上两种 | 0 / 10,回答“承运商暂未提供预计送达时间” |
-可见只在提示词中要求“不要编造”作用有限,模型会把含义不清的字段当成它需要的信息;把工具结果写清楚之后,这组实验中就不再出现编造。所以更可靠的做法是在工具和代码上约束:
+只在提示词中要求“不要编造”作用有限,模型会把含义不清的字段当成它需要的信息;把工具结果写清楚之后,这组实验中就不再出现编造。所以更可靠的做法是在工具和代码上约束:
- 工具结果写清楚:字段名表达准确的含义,没有的信息明确给出,例如 `"eta": null` 并附上说明,而不是省略这个字段;
- 参数来源校验:订单号必须出现在用户消息或之前的工具结果中,并且属于当前用户,否则返回错误让模型先询问用户;
@@ -595,7 +616,9 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
对话接口的输入是一组带角色的消息:系统(system)消息设定模型的身份和规则,用户(user)消息是具体的请求,助手(assistant)消息是模型之前的回答,工具(tool)消息是工具的执行结果。
-一个清晰的提示词通常包含任务、约束、输出格式和示例。例如从客服消息中提取工单:
+系统消息由开发者编写,对整个会话生效,适合放身份、规则和输出格式;用户消息来自终端用户,每轮都不同。模型在训练时学会了两者冲突时优先遵守系统消息,但这只是倾向,用户仍可能用一段话绕过,不能当作安全边界,见[提示词注入](#1-提示词注入)。
+
+一个清晰的提示词通常包含任务、约束、输出格式和示例。例如从客服消息中提取工单,下面这段作为系统消息,用户发来的每条消息作为用户消息:
```text
你是客服工单助手,从用户消息中提取工单信息。
@@ -616,7 +639,9 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
- 给出几组输入和期望的输出,称为少样本(Few-shot)提示,示例比文字描述更能说明格式;
- 让模型先写出推理步骤再给出答案,称为思维链(Chain of Thought, CoT),可以提高多步推理问题的准确率。
-提示词和代码一样需要版本管理,每次修改后都在固定的评测集上回归,对比修改前后的效果。提示词、模型版本、工具定义和采样参数共同决定了输出,最好作为一个整体编号发布,线上记录每次请求用的是哪个版本,出问题时整体回滚;比较线上效果时把模型固定到具体的版本,一次只改一处,否则分不清变化来自提示词还是模型。
+提示词改得好不好,要在评测集上比较:准备一批带期望结果的输入,每条运行多次,能用代码判断的用代码检查,其余交给人或模型裁判,见[评测](#2-评测)。例如[工具调用](#工具调用)一节的实验中,系统提示词写明先查订单、再查物流之后,7B 模型 10 次运行中答出包裹位置的次数从 0 变为 10。修改时从失败的样例入手,补上对应的规则或示例;APE 等自动优化方法由模型生成多个候选提示词,在评测集上打分后保留得分最高的。
+
+提示词和代码一样需要版本管理。提示词、模型版本、工具定义和采样参数共同决定了输出,最好作为一个整体编号发布,线上记录每次请求用的是哪个版本,新版本先灰度给一小部分用户,与旧版本对比线上指标,出问题时整体回滚;比较时把模型固定到具体的版本,一次只改一处,否则分不清变化来自提示词还是模型。
### 结构化输出
@@ -645,11 +670,11 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
用 Qwen2.5-3B 从 20 条客服消息中提取订单号、意图和金额,每条采样 2 次:40 次输出中有 14 次包在代码块里,直接 json.loads 只成功 26 次;修复后 40 次都能解析,38 次通过 Schema 校验。剩下的 2 次都来自“订单 5120 和 5121 能一起发吗”,模型把两个订单号写进了一个字段,带着错误重试后仍然一样(实测,见[附录 A22](#a22-模型输出-json-的修复校验与重试))。
-可见多数格式问题靠修复就能解决;而输入超出了 Schema 的设计时,重试没有用,应该修改 Schema(例如把订单号改为数组)。实际使用时依次组合:先修复,再校验,失败后带着错误重试 1~2 次,仍然失败就返回默认值或者转人工处理。
+多数格式问题靠修复就能解决;而输入超出了 Schema 的设计时,重试没有用,应该修改 Schema(例如把订单号改为数组)。实际使用时依次组合:先修复,再校验,失败后带着错误重试 1~2 次,仍然失败就返回默认值或者转人工处理。
#### 3. 格式正确不等于内容正确
-约束解码和 Schema 校验只能保证格式。订单是否属于当前用户、退款金额是否超过实付金额这类业务规则无法写进 Schema,要在代码中校验。JSON 合法但业务仍然出错时,先看 finish_reason 是否为 length,再对比字段类型是否与代码中的类一致,例如订单号是字符串 "1024" 还是数字 1024。
+约束解码和 Schema 校验只能保证格式。订单是否属于当前用户、退款金额是否超过实付金额这类业务规则取决于当前用户和订单的数据,无法写进固定的 Schema,要在代码中按执行时的数据校验。JSON 合法但业务仍然出错时,先看 finish_reason 是否为 length,再对比字段类型是否与代码中的类一致,例如订单号是字符串 "1024" 还是数字 1024。
流式输出时,JSON 要等生成结束才能完整解析。需要边生成边显示时,可以只流式显示其中的文本字段。
@@ -678,7 +703,7 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
-- 系统提示词、工具定义和长期记忆每次请求都相同,共 981 个 Token,其中 6 个工具的定义就占了 701 个。工具越多这部分越大,所以要按需加载工具;
+- 系统提示词、工具定义和长期记忆每次请求都相同,共 981 个 Token,其中 6 个工具的定义就占了 701 个。工具越多这部分越大,所以要按需加载工具,见 [MCP](#mcp) 和 [Skills](#skills);
- 对话历史只有 11 轮,已经占了 61%,而且会随对话一直增长,其中一半是工具返回的结果;
- 中文大约每个字符 0.6 个 Token,例如系统提示词 332 个字符,对应 205 个 Token(Qwen2.5 实测,见[附录 A3](#a3-客服请求的-token-构成))。
@@ -703,9 +728,11 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
需要注意的是,截断历史时工具调用和它的结果要成对保留或者成对删除。只剩下结果而缺少对应的调用时,很多接口会直接报错。删掉之后,模型也不知道已经查过什么,可能重复调用,所以摘要中要写明查过什么、结果是什么,例如“已查询订单 1024 的物流:在杭州转运中心,预计 10 月 27 日送达”。
-只有闲聊这类前后轮关系不大的对话,用滑动窗口就够了。客服对话中,用户在第 6 轮说的“取件前发短信,别打电话”到第 30 轮仍然有效;Agent 执行长任务时,早期做出的决定和用户提出的约束一直有用,这些场景适合摘要加外置。实际系统常把几种方法组合起来:上下文由“系统提示词 + 摘要 + 最近几轮的原文 + 当前问题”组成,更早的消息不断并入摘要,并且先清理工具结果,不够时再做摘要。
+只有闲聊这类前后轮关系不大的对话,用滑动窗口就够了。客服对话中,用户在第 6 轮说的“取件前发短信,别打电话”到第 30 轮仍然有效;Agent 执行长任务时,早期做出的决定和用户提出的约束一直有用,这些场景适合摘要加外置。实际系统常把几种方法组合起来:上下文由“系统提示词 + 摘要 + 最近几轮的原文 + 当前问题”组成,更早的消息不断并入摘要,并且先清理工具结果,不够时再做摘要;需要读大量资料、反复试错的子任务交给[子 Agent](#多-agent),在它自己的上下文中完成,只把结论带回来。
-压缩的时机按 Token 数判断,而不是按轮数。上面的对话中,每轮新增的 Token 从 33 到 611 不等,查了物流的一轮是简单问答的十几倍。上一次请求的准确 Token 数可以从接口返回的 usage 中得到,再用分词器或者按字数估算新增的消息,超过阈值就压缩。常见的做法是设两道线:历史达到预算的约 70% 时在后台生成摘要,不影响当前的回答;达到上限时必须先压缩才能继续调用。MemGPT 就采用这种方式,在 70% 时提醒模型把重要的信息保存到外部,在 100% 时移出一部分消息,并把它们和原有的摘要合并成新的摘要。
+压缩的时机按 Token 数判断,而不是按轮数。上面的对话中,每轮新增的 Token 从 33 到 611 不等,查了物流的一轮是简单问答的十几倍。上一次请求的准确 Token 数可以从接口返回的 usage 中得到,再用分词器或者按字数估算新增的消息,超过阈值就压缩。常见的做法是设两道线:历史达到预算的约 70% 时在后台生成摘要,不影响当前的回答;达到上限时必须先压缩才能继续调用。MemGPT 就采用这种方式,在 70% 时提醒模型把重要的信息保存到外部,在 100% 时移出一部分消息,并把它们和原有的摘要合并成新的摘要;Claude Code 的做法见 [AI 编程助手](#ai-编程助手)。
+
+保留的“最近几轮”也要按 Token 计,而不是按条数。最近几条本身就超出预算时,通常是某条工具结果或用户粘贴的文档太大:先把这条外置,上下文中只留与当前任务相关的片段和读取位置,需要时再分段读取或检索;仍然放不下就减少保留的轮数,只保证系统提示词、任务状态和当前问题完整。
#### 4. 压缩后不丢关键事实
@@ -715,11 +742,11 @@ Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。
3B 的普通摘要平均只保留了 4.3 条事实,5 个问题平均答对 0.3 个;结构化摘要平均保留 9.7 条,每次都答对 3 个,代价是长度从约 150 个 Token 增加到约 480 个。即使是结构化摘要,用户只说过一次的要求,例如取件前发短信、手机尾号 8823,仍然经常丢失。换成 Qwen2.5-7B 各压缩 2 次,结构化摘要平均保留 11.5 条;普通摘要两次分别保留 12 条和 4 条,结果很不稳定(Qwen2.5 实测,见[附录 A4](#a4-普通摘要与结构化摘要))。
-因此关键的事实不能只依靠模型的摘要:
+摘要以抽取和改写原文为主,常交给更小的模型来降低成本和延迟,但要先用下面的方法检查关键事实是否保留,上面的实验中 3B 保留的事实就少于 7B。因此关键的事实不能只依靠模型的摘要:
- 由代码维护任务状态:订单号、退货单号、金额、地址这些字段,直接从工具调用的参数和结果中提取,单独保存,不经过模型的改写;
- 让承诺通过工具留下记录:例如“取件前短信联系”应该调用备注工具写进订单,而不是只在回答中说一句;
-- 摘要分项输出,要求编号、金额、时间照原文抄写;
+- 摘要分项输出,要求编号、金额、时间照原文抄写;“已办理”只写有工具结果确认的操作,只是说过要做、还在等用户确认的放进“待办”,否则“准备退款”可能被压缩成“已退款”;
- 生成新摘要时在旧摘要的基础上合并,而不是只总结新移出的消息,否则早期的内容会在多次压缩后消失。
压缩有没有丢掉关键信息,可以用两种方法检查,上面的实验就是这样做的:
@@ -742,9 +769,20 @@ RAG 分为离线建立索引和在线检索生成两个阶段:
- 离线:解析文档,切成小段,用 Embedding 模型把每一段转为向量存入向量数据库,同时建立关键词索引;
- 在线:改写用户的问题,向量检索和关键词检索同时召回候选,合并之后重排,把最相关的几段资料和问题一起放进提示词,生成带引用的回答。
-每一环都会影响最终的效果:文档解析错了,后面检索得再准也没用;正确的资料没被召回,模型就只能编造。下面各节按这个顺序展开,最后的评测与优化一节给出效果不好时的排查顺序。
+生成时,把重排后的切片编号放进提示词,并写明资料不够时怎么办(示例只保留了关键部分):
-文档更新时,按文档 ID 和内容的哈希值找出发生变化的文档,只重新切片和计算这些文档的向量,并删除旧版本的切片,不需要重建整个索引。删除和更新在向量数据库中的开销见下文的向量数据库。
+```text
+根据下面的资料回答问题。只使用资料中的内容,每个结论后标注来源编号;资料中没有答案时回答“资料中没有找到相关规定”,不要猜测。
+
+[1] 员工手册(2026 版) > 第 3 章 年假 > 3.1 天数:员工累计工作已满 1 年不满 10 年的,年休假 5 天……
+[2] 员工手册(2026 版) > 第 3 章 年假 > 3.3 结转:当年未休完的年假可以延至次年 3 月底前使用,逾期作废……
+
+问题:工作两年能休几天年假?
+```
+
+RAG 能减少幻觉,是因为模型从凭记忆回答变成了根据给定的资料回答,每个结论都能按编号核对。但资料没找到或者找错时,模型仍然会编造,所以还要用重排分数和[相似度阈值](#4-相似度阈值)判断有没有答案;资料不足时可以改写问题再检索一次,或者向用户追问,仍然没有就如实回答没找到。
+
+每一环都会影响最终的效果:文档解析错了,后面检索得再准也没用;正确的资料没被召回,重排和生成都无法补救。
### 文档解析
@@ -764,6 +802,8 @@ Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按
版面分析(Layout Analysis)根据每段文字的位置、字号和对齐方式,识别出页眉页脚、标题、正文、表格和图片区域,并确定阅读顺序,例如双栏排版要先读完左栏再读右栏。开源工具(例如 MinerU、Docling)用模型完成这些步骤,输出 Markdown 或 JSON。
+与只输出文字和坐标的传统 OCR 相比,这类工具多了版面检测、表格结构识别和公式识别,输出的是带标题层级和表格的文档。也可以让多模态大模型直接把页面截图转为 Markdown,但长文档受上下文和输出长度限制,要逐页或分批调用,成本高、速度慢;模型还可能漏掉或改写数字,只输出 Markdown 时也丢失了坐标,难以定位回原文。因此它通常只用于专用工具处理不好的页面,效果要在自己的文档上评估。
+
用几条简单的规则就能看出原理:把 OCR 结果中 y 坐标相差不超过 12 像素的文字归为一行,同一行有多个文字块的就是表格行;顶部和底部 10% 的区域是页眉页脚,从中取出页码后丢弃;按“第 N 章”“N.N”的格式识别标题。对上面的扫描件,这几条规则的输出如下:
```markdown
@@ -786,9 +826,11 @@ Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按
标题层级恢复之后,切片可以按小节进行,每个切片也能带上“第 6 章 出差 > 6.3 住宿”这样的路径,见下一节。
-表格转为 Markdown 后保留了行列关系。表格很大、需要按行切分时,每行都要带上表头,例如“城市:其他省会城市;每晚上限:450 元”,否则单独一行“450 元 含税”看不出是什么。图片中的文字同样用 OCR 提取,图片表达的含义可以交给多模态模型生成文字描述,见下文的多模态 RAG。
+表格转为 Markdown 后保留了行列关系。表格很大、需要按行切分时,每行都要带上表头,例如“城市:其他省会城市;每晚上限:450 元”,否则单独一行“450 元 含税”看不出是什么。图片中的文字同样用 OCR 提取,图片表达的含义可以交给多模态模型生成文字描述,见[多模态 RAG](#多模态-rag)。
-解析结果需要抽样与原文对比,OCR 置信度低的页面交给人工复核,上面的 OCR 把标题识别为 `第6章出差`,置信度只有 0.30。
+按页解析会切断跨页的内容:一张表格从第 12 页延续到第 13 页时,第 13 页的部分通常没有表头。合并时比较上一页最后一块和下一页第一块:两块都是表格、列数和列宽一致、下一页的表格前没有新的标题或表名,才拼成一张表并去掉重复的表头;正文要看上一页末句是否完整、下一页首段是否像新段落,判断不了时保持分开。合并后元数据记录页码范围 12~13。
+
+解析结果需要抽样与原文对比。上面的 OCR 把标题识别为 `第6章出差`,置信度只有 0.30,这类页面要有兜底:有文本层的 PDF 优先用文本层,乱码多或字数过少时才 OCR;OCR 失败或置信度低的页面换更强的模型重试,仍然不行就交给人工复核。批量处理时按页记录状态,失败的页面单独重跑,不必重新解析整份文档。
#### 3. 元数据
@@ -817,13 +859,17 @@ Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按
- 按标题、段落等文档结构切分,表格和代码块保持完整,而不是按固定字数硬切;
- 相邻切片之间保留一部分重叠,避免关键句子被切断;
- 给切片加上文档标题、章节路径等信息,例如“员工手册 > 第 3 章 年假”,使切片单独拿出来也能看懂;
-- 用小切片检索,命中之后返回它所在的完整段落,称为父子切片。
+- 用小切片检索,命中之后返回它所在的完整段落,称为父子切片;也可以在切片上记录前后切片的 ID,命中后把相邻的切片一起返回。
+一节超过长度上限时,常用递归切分(Recursive Splitting)继续切:按一组由粗到细的分隔符依次尝试,先按空行分成段落,仍然太长的再按换行、句号、逗号切,最后才按字数硬切,切出的小段再合并到接近上限。需要注意的是,LangChain 的 RecursiveCharacterTextSplitter 默认只用空行、换行和空格,中文的空格只出现在数字和英文两边,切 100 字时“已满 20 年的,年休假 15 天。”会在“15”和“天”之间断开。中文要补上“。”“;”“,”等分隔符,并把标点留在前一段末尾,这样会优先在句末切开,上面的员工手册就没有再切断句子;单句仍超过上限时,还是会按逗号或字数切开。
+
+没有标题的长文本,例如会议记录、客服对话,可以用语义切分(Semantic Chunking):为每一句计算 Embedding,在相邻两句相似度明显下降的位置切开,例如两句的距离超过全部相邻距离的 80 分位数。它要额外计算每一句的 Embedding,建索引的开销更大;只按相似度切,切片的长度也没有上限。
+
#### 3. 对比实验
-用一个小实验比较不同的切法。知识库是一份员工手册的 2026 版和 2023 版(除标题和施行日期外,两个版本只有 6 处数字不同)以及一份错误码说明,共约 1.3 万字;40 个问题都是员工的口语化提问,例如“老婆生孩子,我能休几天假?”。每个问题标注了回答所需的原句,检索出的前 K 个切片完整包含这些原句就算找到,找到的问题所占的比例记为 Recall@K(见[附录 A12](#a12-切片方式top-k-与相似度阈值)):
+用一个小实验比较不同的切法。知识库是一份员工手册的 2026 版和 2023 版(除标题和施行日期外,两个版本只有 6 处数字不同)以及一份错误码说明,共约 5000 字;40 个问题都是员工的口语化提问,例如“老婆生孩子,我能休几天假?”。每个问题标注了回答所需的原句,检索出的前 K 个切片完整包含这些原句就算找到,找到的问题所占的比例记为 Recall@K(见[附录 A12](#a12-切片方式top-k-与相似度阈值)):
| 切法 | 切片数 | Recall@1 | Recall@3 | 前 3 个切片的字数 |
| --- | --- | --- | --- | --- |
@@ -833,12 +879,14 @@ Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按
| 固定 200 字,重叠 40 字 | 32 | 0.70 | 0.93 | 575 |
| 固定 400 字 | 13 | 0.55 | 0.83 | 1163 |
| 固定 800 字,重叠 160 字 | 9 | 0.75 | 0.98 | 2065 |
+| 递归切分,200 字 | 36 | 0.80 | 0.95 | 461 |
+| 语义切分,80 分位数 | 43 | 0.85 | 0.90 | 485 |
| 按小节切 | 52 | 0.85 | 0.98 | 278 |
| 按小节切,单句检索、返回小节(父子切片) | 133 | 0.83 | 0.95 | 361 |
-按小节切的效果最好,放进提示词的字数也最少,因为固定长度的切片会把一句话切成两半,或者把两节的内容拼在一起。重叠对固定长度的切片帮助明显,200 字的 Recall@3 从 0.83 提高到 0.93,代价是切片数多了约四分之一。
+按小节切的效果最好,放进提示词的字数也最少,因为固定长度的切片会把一句话切成两半,或者把两节的内容拼在一起。重叠对固定长度的切片帮助明显,200 字的 Recall@3 从 0.83 提高到 0.93,代价是切片数多了约四分之一。递归切分优先在段落和句子边界切开,不加重叠也有 0.95。语义切分的阈值不好定,改按 95 分位数切时,最长的切片有 841 字,超过了 512 Token。
-800 字的切片召回率也高,但主要是因为放进去的字多:前 3 个切片共约 2000 字,已经是知识库的六分之一,而且每个切片超过 512 Token 的部分没有参与向量计算。父子切片在这里没有优势,因为每个小节本身只有一两百字,小节很长、问题只对应其中一两句时它才有用。
+800 字的切片召回率也高,但主要是因为放进去的字多:前 3 个切片共约 2000 字,相当于知识库的四成,而且每个切片超过 512 Token 的部分没有参与向量计算。父子切片在这里没有优势,因为每个小节本身只有一两百字,小节很长、问题只对应其中一两句时它才有用。
需要注意的是,40 个问题中一个问题就是 0.025,这组数字只说明趋势。另外,两个版本的同一节几乎一样,38 个问题的前 3 个结果里同时出现了新旧两个版本,加标题路径也区分不开,需要按版本过滤,见下文的向量数据库。
@@ -864,7 +912,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|2 = 2 − 2cos(a, b),按距离从小到大排序与按相似度从大到小排序的结果相同。没有归一化时三者的排序可能不同,点积还会受到向量长度的影响,应该使用模型训练时所用的度量。
+a 和 b 几乎没有相同的字,相似度却很高,这是向量检索相比关键词检索的优势。向量通常会归一化为长度 1,这时余弦相似度等于点积,计算更快。归一化之后,欧氏距离与余弦相似度也是等价的:|a − b|2 = 2 − 2cos(a, b),按距离从小到大排序与按相似度从大到小排序的结果相同。没有归一化时三者的排序可能不同,点积还会受到向量长度的影响,应该使用模型训练时所用的度量。
用真实的模型计算,结论相同但数值没有这么极端:用 bge-small-zh-v1.5(512 维)编码这三句话,“怎么申请年假”与“休假的流程是什么”的余弦相似度为 0.603,与“今天股价上涨了”为 0.194(见[附录 A1](#a1-embedding-相似度与-bm25))。实际系统中不同句子的相似度大多落在一个不太宽的区间里,判断是否相关要看排名和阈值,而不是期望不相关的结果接近 0。
@@ -885,11 +933,13 @@ a 和 b 几乎没有相同的字,相似度却很高,这正是向量检索相
检索时把问题也转为向量,找出与它最相似的 K 个切片,称为 Top-K 检索。问题和切片必须使用同一个 Embedding 模型,更换模型后所有切片都要重新计算向量。K 怎么取见下文的 Top-K 与阈值。
+知识库变大后,相似度高却不能回答问题的内容可能增多,把正确切片挤出前 K 个,即使逐个精确比较也是如此。在切片实验的 52 个切片中混入 12.5 万条无关的短问题,Recall@3 从 0.975 降到 0.575:“年假可以只请半天吗?”与干扰文本“是否可以请长假?”的相似度为 0.798,正确切片只有 0.659(见[附录 A29](#a29-hnsw-的距离计算次数与知识库规模))。这是向量本身的排序问题,调向量数据库的索引参数只能使结果更接近精确检索,解决不了;要靠元数据过滤缩小范围,或者召回更多候选交给[重排](#重排),这里 K 增大到 50 时召回率回到 0.90。
+
#### 3. 选择模型
-选择模型时先看语言和领域是否匹配、最大长度是否够用,维度则决定了存储成本:1 亿个向量从 512 维换成 1024 维,原始向量的内存从 205 GB 变为 410 GB。公开榜单只能作初筛,业务资料的措辞与公开数据集差别很大,最终以候选模型在自己的评测集上的召回率为准。
+选择模型时先看语言和领域是否匹配、最大长度是否够用,维度则决定了存储成本:1 亿个向量从 512 维换成 1024 维,原始向量的内存从 205 GB 变为 410 GB。维度一般随模型固定,Qwen3-Embedding 这类支持自定义维度的模型可以截取前若干维再归一化,0.6B 的模型可在 32~1024 维之间选,维度越低越省,效果通常也越差。公开榜单只能作初筛,业务资料的措辞与公开数据集差别很大,最终以候选模型在自己的评测集上的召回率为准。
-还要按模型的说明使用。例如 bge 中文模型建议检索时在问题前加上“为这个句子生成表示以用于检索相关文章:”,切片不加;在切片实验的数据上,加前缀后 Recall@3 从 0.975 提高到 1.0,Recall@1 不变(见[附录 A12](#a12-切片方式top-k-与相似度阈值))。
+还要按模型的说明使用。问题短、切片长,两者的写法不同,所以很多模型要求只给问题加一段检索指令。例如 bge 中文模型建议在问题前加上“为这个句子生成表示以用于检索相关文章:”,切片不加;在切片实验的数据上,加前缀后 Recall@3 从 0.975 提高到 1.0,混入上述 12.5 万条短问题时从 0.575 提高到 0.700(见[附录 A12](#a12-切片方式top-k-与相似度阈值)、[A29](#a29-hnsw-的距离计算次数与知识库规模))。
### 向量索引
@@ -901,11 +951,11 @@ a 和 b 几乎没有相同的字,相似度却很高,这正是向量检索相
#### 2. HNSW
-HNSW 把向量组织成多层的图,每个向量与附近的若干个向量相连。插入时每个向量随机分配一个最高层,层数越高概率越低,所以上层的向量少、连线跨度大,最下层包含所有向量。查询时从最上层的入口开始,每层贪心地走向离查询更近的节点,走不动时下降一层,思路与 [跳跃表](Redis.md#跳跃表) 类似。
+HNSW 把向量组织成多层的图,每个向量与附近的若干个向量相连。插入时每个向量随机分配一个最高层,层数越高概率越低,所以上层的向量少、连线跨度大,最下层包含所有向量。查询时从最上层的入口开始,上面各层只保留一个最近的节点,贪心地向查询靠近,走不动时下降一层;到最下层时保留 efSearch 个候选,不断扩展候选的邻居,最后从中取前 K 个,思路与 [跳跃表](Redis.md#跳跃表) 类似。
-HNSW 查询快、召回率高,不需要训练。它的三个参数都在速度、内存和召回率之间权衡:
+HNSW 查询快,是因为上层的长连线让查询几步就跳到目标附近,最下层只需在附近的邻居中搜索。在 12.4 万个 512 维的文本向量上,M = 32、efSearch = 64 时每次查询只计算约 1300 次距离,是暴力检索的 1.1%,Recall@10 为 0.936(见[附录 A29](#a29-hnsw-的距离计算次数与知识库规模))。它不需要训练,但有几种情况会变差:过滤掉大部分向量、大量删除,以及数据多到内存放不下,分别见下文的[过滤](#1-过滤)、[更新与删除](#2-更新与删除)和 IVF-PQ。它的三个参数都在速度、内存和召回率之间权衡:
- M:每个节点连接的邻居数,越大召回率越高、占用内存越多,最下层的邻居数是 2M;
- efConstruction:建图时每个节点搜索的候选数,越大图的质量越好、建索引越慢;
@@ -940,6 +990,20 @@ HNSW 在原始向量之外还要多占 10% 以上的内存,IVF-PQ 只需要原
FAISS 这类向量检索库只负责在内存中建立索引和检索。实际系统中,切片会不断增删改,检索时要按版本、权限过滤,数据还可能多到一台机器放不下,向量数据库在索引之上解决这些问题。数据只有几千条、很少变化时,把向量放在内存中直接计算余弦相似度就够了。
+库中的一条记录对应一个切片,除了向量,还保存切片的原文和[解析时提取的元数据](#文档解析),检索到之后直接取出原文放进提示词(示例只保留了部分字段):
+
+```json
+{
+ "id": "handbook-2026-3.3",
+ "doc_id": "handbook",
+ "version": 2026,
+ "text": "员工手册(2026 版) > 第 3 章 年假 > 3.3 结转:当年未休完的年假可以延至次年 3 月底前使用……",
+ "embedding": [0.021, -0.113, 0.047, ...]
+}
+```
+
+embedding 由 text 经 Embedding 模型计算得到,bge-small-zh-v1.5 输出 512 维;扫描件 OCR 之后存的也是识别出的文本和它的向量。
+
#### 1. 过滤
检索时往往要同时满足标量条件。如果先检索 Top-K 再过滤,结果可能远少于 K 个。例如 pgvector 的 HNSW 索引默认每次查询只保留 40 个候选,过滤条件只有 10% 的行满足时,平均只剩 4 条结果。
@@ -954,7 +1018,9 @@ FAISS 这类向量检索库只负责在内存中建立索引和检索。实际
以 Milvus 为例,新写入的数据先进入可写的段(Growing Segment),持久化之后变为不可修改的段(Sealed Segment),再在这个段上建立索引;删除只是在位图中标记,后台的压缩(Compaction)合并小段时才真正去掉。所以频繁的小批量更新会产生大量的小段和被标记删除的数据,它们仍然占用内存、拖慢检索,更新应该尽量批量进行。
-文档修改后,按文档 ID 删除它的全部旧切片再写入新切片,只追加新切片会让旧内容继续被召回。文档的原文和元数据一般以关系数据库为准,向量库按切片 ID 幂等写入,失败时重试,并定期对账两边的数据。
+文档修改后,按文档 ID 和内容的哈希值找出变化的文档,删除它的全部旧切片再写入新切片,不需要重建整个索引;只追加新切片会让旧内容继续被召回。只是权限或版本状态变化时,更新切片上的元数据即可,不必重新计算向量。文档的原文和元数据一般以关系数据库为准,向量库按切片 ID 幂等写入,失败时重试,并定期对账两边的数据。
+
+更换 Embedding 模型或切片方式时,所有向量都要重新计算。这时在新的集合中建好索引并评测,再把查询使用的别名(Alias)从旧集合切到新集合,切换过程中查询不中断,出问题时切回去。Milvus 和 Elasticsearch 都支持别名。换了模型时,查询向量也要用新模型计算,所以查询用的模型和集合作为一组配置同时切换、同时回滚。
#### 3. 大规模数据
@@ -962,7 +1028,7 @@ FAISS 这类向量检索库只负责在内存中建立索引和检索。实际
#### 4. 选型
-选型主要看数据量、写入频率和已有的技术栈。已经在用 PostgreSQL 或 Elasticsearch、数据量不大时,直接用它们的向量功能(pgvector、dense_vector),向量和业务数据或全文索引放在一起,少维护一个系统;数据量大、写入频繁时,用 Milvus 这类专门的分布式向量数据库。各产品的功能变化较快,以官方最新文档为准。
+选型主要看数据量、写入频率和已有的技术栈。已经在用 PostgreSQL 或 Elasticsearch、数据量不大时,直接用它们的向量功能(pgvector、dense_vector),向量和业务数据或全文索引放在一起,少维护一个系统;数据量大、写入频繁时,用 Milvus 这类专门的分布式向量数据库。Chroma 可以嵌入在 Python 程序中运行,适合原型和小规模数据;不想自己运维时可以用 Pinecone 这类托管服务。各产品的功能变化较快,以官方最新文档为准。
### 混合检索
@@ -995,7 +1061,9 @@ IDF(t) = ln(1 + (N − n + 0.5) / (n + 0.5))
| E | - | 3 | 1/63 = 0.0159 |
| D | 4 | - | 1/64 = 0.0156 |
-两路结果中都排名靠前的 A 和 C 排在最前面。也可以把两路的分数分别归一化到 0 到 1 之后按权重相加,权重需要在评测集上调整。
+两路结果中都排名靠前的 A 和 C 排在最前面。也可以把两路的分数分别归一化到 0 到 1 之后按权重相加。
+
+混合检索不一定比单路好,权重要在评测集上确定。BM25 的优势在错误码、型号、函数名这类精确匹配上;在切片实验的 40 个口语化问题上,等权 RRF 的 Recall@3 为 0.950,低于单独的向量检索(0.975),BM25 的权重降到 0.5 后为 1.0(见[附录 A30](#a30-混合检索与重排的召回率))。也可以只在查询含有编号时提高 BM25 的权重。
### 重排
@@ -1006,9 +1074,11 @@ IDF(t) = ln(1 + (N − n + 0.5) / (n + 0.5))
- Embedding 模型分别编码问题和文档,文档的向量可以提前计算并建立索引,速度快;但问题和文档在编码时互相看不到,细节的对应关系在压缩成一个向量时丢失了;
- 重排模型把问题和文档拼成一个输入一起编码,称为交叉编码器(Cross-Encoder),注意力可以在问题和文档的每个词之间计算,判断更准确;但每一对都要重新计算,只能处理少量候选。
-所以先用速度快的检索从海量数据中召回几十条,再用准确的重排模型选出最终的几条,与搜索和推荐系统中先召回、后排序的分层思路一致。
+所以先用速度快的检索从海量数据中召回几十条,再用准确的重排模型选出最终的几条,与搜索和推荐系统中先召回、后排序的分层思路一致。重排也不能作为第三路召回:它没有能提前算好的索引,按 bge-reranker-v2-m3 在 M1 Pro 上的实测速度估算,给 12.5 万条短文本全部打分约要 30 分钟(见[附录 A30](#a30-混合检索与重排的召回率))。
-实现时,把混合检索合并后的几十个候选分别与问题组成(问题, 切片)对,交给重排模型打分。输出是一个相关性分数,可以用 sigmoid 映射到 0~1,按分数排序后保留前几条,再用阈值去掉分数过低的。常用开源的交叉编码器,例如 bge-reranker-v2-m3;也有基于大模型的重排模型,更准确但更慢。候选越多,越不容易漏掉相关的切片,延迟也越高,所以候选数量要在评测集上与延迟一起权衡。
+实现时,把混合检索合并后的几十个候选分别与问题组成(问题, 切片)对,交给重排模型打分,输出的相关性分数可以用 sigmoid 映射到 0~1,排序后保留前几条,再用阈值去掉分数过低的。在 [Top-K 检索](#2-top-k-检索)一节混入 12.5 万条干扰文本的实验中,对 RRF 的前 50 个候选重排后 Recall@1 从 0.275 升到 0.700,例如“结婚可以休几天假?”排第一的从“每周可以实习几天?”变为婚假一节(见[附录 A30](#a30-混合检索与重排的召回率))。
+
+重排模型的训练与 Embedding 类似:每个问题配一个相关文档和若干难负样本,同一个问题的各个文档的分数一起做 softmax,损失是相关文档的交叉熵。常用开源的交叉编码器,例如 bge-reranker-v2-m3;也有基于大模型的重排模型,更准确但更慢。候选越多,越不容易漏掉相关的切片,延迟也越高,所以候选数量要在评测集上与延迟一起权衡。
### Top-K 与阈值
@@ -1036,7 +1106,7 @@ K 太小,需要多个切片才能回答的问题又会答不全,例如“几
| 0.55 | 2.5% | 20% |
| 0.60 | 10% | 10% |
-可见向量相似度的阈值只能在误拒和放行之间折中,适合用较低的值过滤明显无关的结果。判断有没有答案,更多依靠重排模型的分数(它直接判断问题与切片是否相关,阈值同样要在评测集上确定),以及在提示词中要求资料不足时回答不知道。更换模型后阈值要重新确定,不同模型的分数分布不同。
+向量相似度的阈值只能在误拒和放行之间折中,适合用较低的值过滤明显无关的结果。判断有没有答案,更多依靠重排模型的分数(它直接判断问题与切片是否相关,阈值同样要在评测集上确定),以及在提示词中要求资料不足时回答不知道。更换模型后阈值要重新确定,不同模型的分数分布不同。
### 查询改写
@@ -1056,18 +1126,21 @@ RAG 需要分别评测检索和生成,这样才能定位问题出在哪一步
#### 1. 检索指标
-最常用的三个指标:
+常用的指标:
- 召回率(Recall@K):相关的切片有多少出现在前 K 个结果中;
+- 精确率(Precision@K):前 K 个结果中有多少是相关的切片;
- 平均倒数排名(Mean Reciprocal Rank, MRR):第一个相关结果排名的倒数的平均值,衡量相关结果是否排得靠前;
- 归一化折损累计增益(Normalized Discounted Cumulative Gain, NDCG):相关程度可以分级,排名越靠后的结果,贡献按 1 / log₂(排名 + 1) 打折扣,再除以理想排序下的值,取值在 0 到 1 之间。
-例如 3 个问题各有 1 个相关切片,在前 5 个结果中的排名分别是 1、3 和未出现,则 Recall@5 = 2 / 3,MRR = (1 + 1/3 + 0) / 3 = 0.44。一个问题需要多个切片、并且切片的重要程度不同时,三个指标的区别更明显:
+例如 3 个问题各有 1 个相关切片,在前 5 个结果中的排名分别是 1、3 和未出现,则 Recall@5 = 2 / 3,MRR = (1 + 1/3 + 0) / 3 = 0.44,Precision@5 = 2 / 15 = 0.13。一个问题需要多个切片、并且切片的重要程度不同时,三个指标的区别更明显:
只关心资料有没有找全,看 Recall;用户通常只看第一条结果的场景,看 MRR;有分级标注、要评价整体排序质量时,看 NDCG。上面的 NDCG 用 scikit-learn 的 ndcg_score 核对过(见[附录 A15](#a15-向量索引的内存估算与-ndcg-核对))。
+召回率和精确率通常要取舍:K 增大时召回率不会下降,但往往混进更多无关切片,精确率随之降低,所以召回率提高了,最终回答不一定更准,原因见[放进提示词的 K](#3-放进提示词的-k)。召回阶段看召回率,放进提示词的切片还要看精确率和端到端的回答正确率。
+
#### 2. 生成指标
生成的质量主要看两点:
@@ -1089,9 +1162,9 @@ RAG 需要分别评测检索和生成,这样才能定位问题出在哪一步
#### 3. 评测集
-指标要在一批固定的问题上计算。问题优先从真实的用户问题中抽样,不够时让模型根据文档生成,再人工改写措辞,因为模型生成的问题往往照抄原文的词语,会高估向量检索的效果。除了常见问题,还要包含资料中没有答案的问题、需要多个切片的问题和新旧版本冲突的问题。
+指标要在一批固定的问题上计算。问题优先从真实的用户问题中抽样,不够时让模型根据文档生成,再人工改写措辞,因为模型生成的问题往往照抄原文的词语,会高估向量检索的效果。除了常见问题,还要包含资料中没有答案的问题、需要多个切片的问题和新旧版本冲突的问题。没有答案的问题,标准答案标为“资料中没有”,回答不知道算对,编出答案算错;它和有答案的问题被误拒要一起统计,两者的取舍与[相似度阈值](#4-相似度阈值)相同。
-每个问题标注标准答案和回答所需的原文片段,而不是切片 ID,这样改变切片方式之后标注仍然可用,切片一节的实验就是这样判断是否找到的。几十到几百个问题就可以比较不同的方案,每次调整解析、切片、模型或参数之后都重新评测;线上发现的错误回答持续补充进来,避免评测集与真实的问题分布脱节。
+每个问题标注标准答案和回答所需的原文片段,而不是切片 ID,这样改变切片方式之后标注仍然可用,切片一节的实验就是这样判断是否找到的。几十到几百个问题就可以比较不同的方案,每次调整解析、切片、模型或参数,以及知识库中新增大量文档之后,都重新评测;线上发现的错误回答持续补充进来,避免评测集与真实的问题分布脱节。
#### 4. 排查顺序
@@ -1112,11 +1185,12 @@ RAG 需要分别评测检索和生成,这样才能定位问题出在哪一步
模型的上下文窗口越来越大,资料量不大时可以把全部资料直接放进上下文,不需要 RAG。但知识库很大时,RAG 仍然是必要的:
- 每次请求都处理大量 Token,成本高、延迟大;
-- 上下文越长,模型对其中信息的利用越差;
-- 检索时可以按用户的权限过滤资料,全部放进上下文就做不到这一点。
+- 上下文越长,模型对其中信息的利用越差。
普通的 RAG 每个问题只检索一次。问题需要多步查找时,例如“负责支付系统的团队的负责人是谁”,要先查到团队,再查负责人。这时可以把检索作为一个工具交给 Agent,由模型决定检索什么、检索几次,称为 Agentic RAG。它更灵活,但调用次数和延迟都会增加,问题简单时固定流程的 RAG 更可控。
+AI 编程助手查找代码也是这种方式:Claude Code 用 find、grep 这类按文件名模式和正则表达式搜索的工具,模型根据结果再换关键词或者打开文件。代码中的函数名、错误码需要精确匹配,代码又在不断修改,向量索引容易过期,而 grep 每次搜索的都是最新的文件。
+
RAG 解决的是模型缺少知识的问题,固定输出风格或者学习专门的任务需要微调,两者的选择见[微调](#微调)。
### GraphRAG
@@ -1154,13 +1228,17 @@ Agent(智能体)指的是让大模型通过调用工具来完成任务的系
需要注意的是,模型始终只输出文本,查询数据库、调用接口都是应用代码完成的。模型的作用是在每一步做出决策,应用的作用是执行决策、控制整个循环。
-一个 Agent 通常由以下几部分组成,后面各节分别介绍:
+传统的客服机器人与 Agent 的区别在于流程由谁决定:前者识别出意图后按开发者事先设计好的流程处理;Agent 的下一步由模型根据任务和工具结果临时决定,同一组工具可以组合出事先没有写好的流程,代价是行为不完全可预测,见 [Agent 与工作流](#agent-与工作流)。
+
+一个 Agent 通常由以下几部分组成:
- 模型:理解任务,决定下一步做什么;
-- 工具:模型可以使用的函数或接口,用于获取信息和执行操作;
-- 记忆:保存对话历史、用户偏好等信息,需要时放进上下文;
-- 规划:把复杂的任务拆成多个步骤;
-- 控制循环:应用代码中反复调用模型、执行工具的循环,同时负责限制步数、校验参数和权限。
+- 工具:模型可以使用的函数或接口,用于获取信息和执行操作,见[工具调用](#工具调用);
+- 记忆:保存对话历史、用户偏好等信息,需要时放进上下文,见[记忆](#记忆);
+- 规划:把复杂的任务拆成多个步骤,见[规划](#规划);
+- 控制循环:应用代码中反复调用模型、执行工具的循环,同时负责限制步数、校验参数和权限,见 [Agent 循环](#agent-循环)。
+
+模型以外的部分合称 [Harness](#harness)。
### 工具调用
@@ -1173,7 +1251,7 @@ Agent(智能体)指的是让大模型通过调用工具来完成任务的系
3. 模型在训练时学习过这种格式,判断需要调用工具时,会生成一段特定格式的文本,而不是直接回答;
4. 推理服务识别出这段文本,把它解析为结构化的 tool_calls 返回给应用。
-可见工具调用本质上仍然是生成文本,所以模型可能写错参数、调用不存在的工具,应用必须先校验再执行。很多服务提供严格模式,用约束解码保证参数符合 Schema。
+工具调用本质上仍然是生成文本,所以模型可能调用不存在的工具、写错参数的格式,或者在用户没说订单号时自己编一个。很多服务提供严格模式,用[约束解码](#1-约束解码)保证参数符合 Schema,但编出来的订单号同样符合 Schema,所以应用仍要先校验再执行:缺少的信息向用户追问,见[追问澄清](#2-追问澄清);校验和出错后的处理见[容错](#1-容错)。
以 OpenAI 兼容的接口为例,用户问“订单 1024 到哪了”,请求中这样描述查询订单的工具(省略了查询物流的 get_logistics):
@@ -1215,16 +1293,17 @@ Agent(智能体)指的是让大模型通过调用工具来完成任务的系
工具定义被拼进了系统消息,模型输出的是包在 <tool_call> 标签里的一段 JSON,应用用正则把它解析出来;工具结果则被包在 <tool_response> 中,作为下一条消息发回。不同模型的标签不同,但原理相同,OpenAI 兼容接口中的 tool_calls 字段就是推理服务从这段文本中解析出来的。
-工具结果使用单独的角色,模型能区分这是工具返回的数据而不是用户的指令,但这并不能防止结果中藏有恶意指令,见下文的安全。
+工具结果使用单独的角色,模型能区分这是工具返回的数据而不是用户的指令,但这并不能防止结果中藏有恶意指令,见[安全](#安全)。
模型只能根据工具的名称、描述和参数说明来决定是否调用、怎么填参数,所以工具的设计直接影响调用的准确率:
- 描述写清楚用途和使用时机,参数给出格式和示例;
-- 一个工具只做一件事,工具之间的职责不要重叠,否则模型容易选错;
+- 粒度按任务划分:过细时一个任务要连续调用多个工具,模型容易漏掉一步,例如下面的例子中模型查完订单就停了,总是连着调用的几步可以合成一个工具;过粗时一个工具靠很多参数区分多种用法,参数容易填错;
+- 工具之间的职责不要重叠,否则模型容易选错;
- 返回结果要精简,只返回模型需要的字段,大段的原始数据会占用上下文;
- 出错时返回能指导模型修正的信息,例如“日期格式应为 2026-01-01”,而不是只返回“参数错误”。
-描述写得是否清楚,对结果的影响很大。在上面的例子中,系统提示词只写“你是电商客服助手,回答要简短”时,7B 模型查到运单号就直接回答“已发货,运单号是 SF1234567”,没有继续查物流;采样运行 10 次,1 次也没有答出包裹的位置。在系统提示词中写明“用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间”后,10 次全部依次调用了两个工具,回答“您的包裹目前位于杭州转运中心,预计明天送达”。同样的提示词换成 3B 模型,20 次都只调用了一个工具,可见模型能力不足时,提示词也弥补不了(Qwen2.5 实测,见[附录 A2](#a2-提示词对工具调用的影响))。
+描述写得是否清楚,对结果的影响很大。在上面的例子中,系统提示词只写“你是电商客服助手,回答要简短”时,7B 模型查到运单号就直接回答“已发货,运单号是 SF1234567”,没有继续查物流;采样运行 10 次,1 次也没有答出包裹的位置。在系统提示词中写明“用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间”后,10 次全部依次调用了两个工具,回答“您的包裹目前位于杭州转运中心,预计明天送达”。同样的提示词换成 3B 模型,20 次都只调用了一个工具;模型能力不足时,提示词也弥补不了(Qwen2.5 实测,见[附录 A2](#a2-提示词对工具调用的影响))。
工具很多时,全部的工具定义会占用大量上下文,模型也更容易选错。可以只加载与当前任务相关的工具,例如先根据问题检索出最相关的十几个工具,或者把工具分组交给不同的子 Agent。
@@ -1278,13 +1357,13 @@ for (CompletableFuture f : results) {
}
```
-两个各需 500 毫秒的调用,并发执行共用时约 520 毫秒(JDK 21 实测,见[附录 A25](#a25-并发执行工具调用))。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。部分调用失败时的处理见[可靠性与评测](#1-容错)。
+两个各需 500 毫秒的调用,并发执行共用时约 520 毫秒(JDK 21 实测,见[附录 A25](#a25-并发执行工具调用))。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。但模型也可能判断错,修改数据的调用(例如同一个订单的改地址和退款)即使在同一次回复中给出,也要按顺序执行。部分调用失败时的处理见[可靠性与评测](#1-容错)。
耗时很长的工具,例如生成报表、等待人工审批,不适合在循环中同步等待。可以让工具先返回一个任务 ID,保存当前的消息列表后结束这一轮;任务完成后通过消息队列或者回调通知应用,应用把结果作为工具消息追加到保存的消息中,再继续调用模型。这与[开发框架](#2-checkpoint-与人工介入)中暂停和恢复的做法相同。
### Agent 与工作流
-与 Agent 相对的是工作流(Workflow):执行哪些步骤、按什么顺序执行都由代码事先写好,大模型只负责其中的某些步骤,例如对用户的消息分类。
+与 Agent 相对的是工作流(Workflow):执行哪些步骤、按什么顺序执行都由代码事先写好,大模型只负责其中的某些步骤,例如对用户的消息分类。常见的工作流有链式(Prompt Chain,上一步的输出是下一步的输入)、路由、并行,以及生成后由另一次调用评审、不合格就退回重写。
@@ -1293,8 +1372,12 @@ for (CompletableFuture f : results) {
例如每一步的成功率是 95%,连续 10 步都成功的概率只有 0.9510 ≈ 60%。因此应该先尝试单次调用或者工作流,确实需要时再引入 Agent,也可以只在工作流的某一步中使用 Agent。
+判断用哪一种,看步骤和步骤之间的转移规则能否事先写清。例如排查“订单 1024 为什么没发货”,如果原因只有库存不足、地址异常这几种,可以写成工作流的分支,分别查补货单和风控记录;原因五花八门、要根据查到的线索决定下一步查什么时,路径事先写不全,才交给 Agent。
+
实际的客服系统大多是两者的组合:先用意图路由把请求分到退款、物流等流程,退款这类涉及资金、步骤固定的流程写成工作流,商品咨询这类开放的问题交给带检索工具的 Agent。路由见下一节,流程的编排见[开发框架](#开发框架)。
+把工作流改造成 Agent 时,原来的节点包装成工具,流程规则写进系统提示词或 [Skill](#skills),调用顺序交给 Agent 循环。走哪些步骤改由模型决定后,“退款前必须人工确认”这类约束不再由流程的结构保证,金额校验、权限和人工确认要放进工具的代码中,例如退款工具执行前先暂停等待审批。好处是新增场景不必改编排代码,也能处理“一件退款、另一件改地址”这类跨流程的请求;代价是延迟和成本上升、执行路径不再固定,上线前要在原工作流的评测集上对比成功率。
+
### 意图识别与路由
意图识别指的是判断用户想做什么,路由指的是根据判断结果把请求交给对应的流程、提示词、工具集或者模型。例如“订单 1024 到哪了”交给物流查询,“衣服尺码不合适想退”交给退款流程。
@@ -1323,21 +1406,22 @@ for (CompletableFuture f : results) {
- 类别定义互斥,并写明边界,例如“退款进度属于退款,不属于物流”“购买前问发货时间属于商品咨询”。分错的消息大多落在两个类别的边界上,补充定义比换模型更有效;
- 校验输出:类别名必须在列表中,用[结构化输出](#结构化输出)把输出限制为枚举值,不在列表中的按“其它”处理;
-- 分层:规则只处理精确率高的情况,例如“转人工”、消息中带有订单号格式的查询,其余的交给模型;
-- 低置信度兜底:置信度低于阈值时不硬分,而是反问澄清(“您是想查物流还是申请退款?”),或者交给通用的 Agent、转人工;
+- 分层:规则只处理精确率高的情况,例如“转人工”、消息中带有订单号格式的查询,其余的交给模型。类别或工具有上百个时也可以逐级分类,先分到售后、物流这样的业务域,再在域内细分或[按问题检索工具](#工具调用);
+- 低置信度兜底:置信度低于阈值时不硬分,而是反问澄清(“您是想查物流还是申请退款?”),或者交给通用的 Agent、转人工。已经进入流程后发现分错(例如用户说“我不是要退款”),流程把请求退回路由重新分类,而不是继续执行;
- 看上下文:多轮对话中的“那退了吧”单独看无法分类,要把最近几轮对话一起交给分类器,或者先把它改写成完整的问题;一句话包含多个意图时,拆成多个请求,或者交给能分派任务的主 Agent。
-置信度阈值的效果取决于置信度是否可靠。上面的分类模型把阈值设为 0.7 时,48% 的消息自动路由,其中准确率 96.6%,其余转入兜底流程;而 7B 模型分错的 3 条中有 2 条的置信度高于 0.93,阈值几乎拦不住。大模型的输出概率往往过于自信,可以改用多次采样看结论是否一致,或者在类别中加入“不确定”,让模型自己选择。
+阈值要在验证集上扫一遍再定:阈值越高,自动路由的比例越低,准确率一般越高。例如上面的分类模型阈值从 0.5 提到 0.7,自动路由从 70% 降到 48%,准确率从 90.5% 升到 96.6%,其余转入兜底流程。按兜底流程能承接的量和可以接受的错误率选一个点,分错代价高的类别(例如会进入退款流程的)可以单独设更高的阈值。
+
+阈值的效果还取决于置信度是否可靠:7B 模型分错的 3 条中有 2 条的置信度高于 0.93,阈值几乎拦不住。大模型的输出概率往往过于自信,可以改用多次采样看结论是否一致,或者在类别中加入“不确定”,让模型自己选择。
路由的准确率要靠评测集来衡量和守住:
- 按类别收集样本,每类数量接近,特别要包含容易混淆的消息和不属于任何类别的消息;
-- 除了总准确率,还要看混淆矩阵,即每个类别被错分到了哪里,例如本例中规则的 16 个错误有 9 个是没有命中任何关键词、落入“其它”,5 个是被“快递”“到哪”这类词误导、分到了物流;
+- 按类别看精确率(判为这一类的消息中有多少是对的)和召回率(这一类的消息找回了多少),两种错误的代价不同:误进退款流程的代价通常比漏判后转人工高。例如本例规则对退款的精确率是 100%,召回率只有 58%(7/12);
+- 看混淆矩阵,即每个类别被错分到了哪里,例如本例中规则的 16 个错误有 9 个是没有命中任何关键词、落入“其它”,5 个是被“快递”“到哪”这类词误导、分到了物流;
- 线上转人工、用户改口重说的会话说明路由可能错了,定期抽查后加入评测集;
- 修改类别定义、提示词或者模型之后,在同一个评测集上回归,确认没有让其它类别变差。
-即使换成能力更强的模型,路由仍然有用:它决定了每个流程能用哪些工具和权限,简单问题可以走更便宜的模型,按类别统计的效果数据也是优化的依据。
-
### 规划 🔥
规划要解决的是一个任务分几步、按什么顺序做、做错了怎么办。常见的几种方式的区别在于:推理是否与行动交替,是否先给出完整的计划,是否同时探索多条思路,失败后是否总结经验再试。
@@ -1368,7 +1452,7 @@ ReAct 适合下一步取决于上一步结果、步数不多的任务,例如
- 规划器:调用一次模型,按结构化输出生成计划,每个步骤带编号和依赖,例如 {"id": 5, "task": "汇总对比", "depends_on": [1, 2, 3, 4]},代码检查步骤数、用到的工具是否存在、依赖有没有成环;
- 执行器:按依赖顺序执行,没有依赖关系的步骤并行,每个步骤内部可以是一个小的 ReAct 循环,也可以使用更便宜的模型;
-- 重规划:每完成一步,把原计划和已有结果交给模型,判断是继续、修改剩余的步骤还是结束。某一步失败、结果与计划的假设不符(例如查不到 B 的续航)时触发。
+- 重规划:某一步失败、结果与计划的假设不符(例如查不到 B 的续航)时,把原计划和已有结果交给模型,由它决定修改剩余的步骤、继续还是结束。失败由代码根据工具报错和结果校验发现;也可以每完成一步都让模型判断一次,调整更及时,但每步多一次模型调用。
有的系统还单独设一个审查者(Critic),在每一步或者全部完成后检查结果是否满足目标和约束,例如对比表中每款产品的续航是否都来自查询结果,不满足时把具体的问题交给重规划或执行器。审查者依据工具结果、测试这类外部证据判断才可靠,只凭模型自己的判断复查效果有限,见下文的反思。
@@ -1413,7 +1497,7 @@ ReAct 适合下一步取决于上一步结果、步数不多的任务,例如
- 短期记忆:当前会话的消息、工具调用和结果、摘要和任务状态,存放在 Redis 或数据库中,会话结束后过期;
- 长期记忆:跨会话保存的用户偏好、事实和过去的经验,存放在数据库或者向量库中,直到被更新或者遗忘。
-长期记忆还可以按内容细分:关于用户和外部世界的事实称为语义记忆,例如“用户住在滨江”;过去的经历称为情景记忆,例如“上次退货时用户嫌上门时间太早”;做事的方法称为程序记忆,系统提示词和 [Skills](#skills) 就属于这一类。
+当前上下文窗口中的内容有时称为工作记忆,相当于短期记忆中这一次调用实际用到的部分。长期记忆还可以按内容细分:关于用户和外部世界的事实称为语义记忆,例如“用户住在滨江”;过去的经历称为情景记忆,例如“上次退货时用户嫌上门时间太早”;做事的方法称为程序记忆,系统提示词和 [Skills](#skills) 就属于这一类。
#### 1. 短期记忆
@@ -1432,6 +1516,7 @@ RPUSH session:7f3a:messages '{"role": "user", "content": "订单 1024 到哪了
LRANGE session:7f3a:messages -20 -1
HSET session:7f3a:state current_order 1024 todo "等待用户提供发票税号"
EXPIRE session:7f3a:messages 86400
+EXPIRE session:7f3a:state 86400
```
- 消息用 List,RPUSH 在末尾追加,LRANGE 按顺序取出最近的若干条;
@@ -1480,6 +1565,8 @@ EXPIRE session:7f3a:messages 86400
即使模型的窗口能放下全部记忆,也只放相关的几条,原因与上下文工程中的相同:上下文越长越贵,无关的内容还会干扰回答。
+每一轮都检索会增加生成之前的等待时间,开销来自查询的向量化和一次带过滤的向量检索,取决于 Embedding 服务、网络和记忆的规模。例如 Mem0 论文中检索耗时的中位数为 0.15 秒,P95 为 0.2 秒。降低开销的办法是与 RAG 检索共用查询向量、两者并行执行,写入放到后台提取,或者改用上面的 search_memory 工具。
+
#### 4. 更新、冲突与遗忘
新的事实写入之前,要先和已有的记忆比较,否则同一件事会存成多条,甚至互相矛盾。Mem0 的做法是先检索出最相似的几条旧记忆,把它们和新事实一起交给模型,由模型选择四种操作之一:新增(ADD)、更新(UPDATE)、删除(DELETE)或者不做处理(NOOP,下图的提示词中写作 NONE)。例如用户搬了家:
@@ -1504,8 +1591,8 @@ Agent 运行到一半时进程可能重启,用户也可能隔天才回来。
进程重启后,按会话 ID 读出消息列表和任务状态,根据最后一条消息决定从哪里继续:
-- 最后一条是用户消息或者工具结果:重新调用一次模型即可;
-- 最后一条是模型发出的工具调用,但没有对应的结果:说明工具执行到一半中断了。查询类的工具直接重新执行;退款、改地址这类修改数据的工具,以工具调用的 ID 作为幂等键,先查询这次操作是否已经生效,再决定是否执行,避免重复退款。
+- 最后一条是用户消息,或者上一轮的工具调用都有了结果:重新调用一次模型即可;
+- 有工具调用还没有对应的结果(一次回复中有多个调用时,可能只保存了其中一部分的结果):说明工具执行到一半中断了。查询类的工具直接重新执行;退款、改地址这类修改数据的工具,以工具调用的 ID 作为幂等键,先查询这次操作是否已经生效,再决定是否执行,避免重复退款。
用户隔天回来时,会话的短期记忆可能已经过期,前一天的完整对话也不应该再放进上下文。做法是会话结束时生成一份摘要,连同未完成的待办一起保存;新会话开始时放入长期记忆和上次会话的摘要。例如用户前一天说“税号我待会儿发你”,第二天只发来一串税号,Agent 根据上次的待办就知道这是订单 1024 的发票,不需要用户重新解释。
@@ -1515,10 +1602,15 @@ LangGraph 等框架用检查点(Checkpoint)在每一步之后自动保存整
### 多轮对话
-多轮对话的实现方式与 Agent 循环相同:每次请求都把历史连同新消息一起发给模型。轮数多了之后,除了上下文膨胀(见[上下文工程](#上下文工程)),还有三个常见的问题:用户的话要结合前文才能理解,信息不足时模型自行假设,话题切换后旧的内容造成干扰。
+多轮对话的实现方式与 Agent 循环相同:每次请求都把历史连同新消息一起发给模型,历史由应用按会话保存,见[短期记忆](#1-短期记忆)。轮数多了之后,除了上下文膨胀(见[上下文工程](#上下文工程)),还有三个常见的问题:用户的话要结合前文才能理解,信息不足时模型自行假设,话题切换后旧的内容造成干扰。
多轮对话对模型本身也更难。一项研究模拟了 20 多万次对话,把同一个任务的要求拆成几轮逐步告诉模型,15 个模型在 6 类任务上的表现比一次说完时平均下降了 39%。主要原因是模型在信息还不全的早期轮次就做出假设、给出完整的答案,之后又过度依赖这些答案,一旦走错就很难回到正轨。
+注意力机制在长对话中有两方面的局限:
+
+- 成本:每轮都要处理全部历史,注意力的计算量随长度平方增长,KV Cache 随长度线性增长。Qwen2.5-7B 的对话累积到 32K Token 时,一个请求的 KV Cache 就占 1.75 GB,没有命中前缀缓存时首 Token 时间也越来越长,见[计算量与 FlashAttention](#2-计算量与-flashattention);
+- 效果:无关的历史越多,越容易干扰回答,位于中段的信息也更容易被忽略,见[上下文工程](#1-放什么按什么顺序)。所以关键的约束,例如“取件前发短信,别打电话”,要写进任务状态,在后续每次调用时都保留,见[话题漂移与新会话](#3-话题漂移与新会话)。
+
#### 1. 指代与省略
用户经常省略已经说过的内容:
@@ -1554,7 +1646,7 @@ LangGraph 等框架用检查点(Checkpoint)在每一步之后自动保存整
能查到的就不要问,例如用户名下只有一个可以退货的订单时,直接复述这个订单请用户确认。追问时给出选项,比开放式的提问更容易回答,例如“您有两个可以退货的订单:1025(65W 氮化镓充电器)和 1027(数据线),要退哪一个?”
-实现上,在系统提示词中写明什么时候需要追问,同时在代码中兜底:工具参数校验失败(例如缺少 order_id)时不执行,把错误信息返回给模型,由它向用户追问。追问的次数也要限制,连续几次仍然无法确定时转人工。
+实现上,在系统提示词中写明什么时候需要追问,同时在代码中兜底:工具参数校验失败(例如缺少 order_id)时不执行,把错误信息返回给模型,由它向用户追问。信息是否收集完整也由代码判断:任务状态中记录每个必填参数是否已经填上、是否经用户确认,全部满足才执行,不依赖模型自己说“信息已齐全”。追问的次数也要限制,连续几次仍然无法确定时转人工。
#### 3. 话题漂移与新会话
@@ -1584,7 +1676,7 @@ MCP 中有三种角色:
- 主机(Host):用户使用的 AI 应用,例如 IDE 或者聊天客户端;
- 客户端(Client):主机中负责与某个服务端通信的连接器,一个客户端只连接一个服务端;
-- 服务端(Server):对外提供能力的程序,可以提供工具(Tools)、资源(Resources)和提示词模板(Prompts)。
+- 服务端(Server):对外提供能力的程序,可以提供三类能力,区别在于由谁决定使用:工具(Tools)由模型决定何时调用,例如查询订单;资源(Resources)是按 URI 读取的数据,例如文件内容,由主机决定放不放进上下文;提示词模板(Prompts)由用户主动选用,例如客户端中的斜杠命令。
@@ -1601,7 +1693,7 @@ MCP 中有三种角色:
}
```
-主机启动时按配置把服务端作为子进程启动,通过它的标准输入输出(stdio)收发消息,服务端只能访问参数中列出的目录;远程服务端则通过 Streamable HTTP 通信。消息使用 JSON-RPC 2.0 格式:客户端先用 tools/list 获取工具列表,每个工具带有名称、描述和参数的 JSON Schema;模型选定工具后,客户端再用 tools/call 发起调用。以下请求和响应省略了部分字段:
+主机启动时按配置把服务端作为子进程启动,通过它的标准输入输出(stdio)收发消息,服务端只能访问参数中列出的目录;远程服务端则通过 Streamable HTTP 通信:每条消息都是发往同一个地址的 POST 请求,服务端返回一个 JSON,或者用 SSE 流式返回多条消息。早期版本的远程传输是 HTTP+SSE(一个 SSE 长连接加一个单独的 POST 地址),2025-03-26 版起被 Streamable HTTP 取代。消息使用 JSON-RPC 2.0 格式:客户端先用 tools/list 获取工具列表,每个工具带有名称、描述和参数的 JSON Schema;模型选定工具后,客户端再用 tools/call 发起调用。以下请求和响应省略了部分字段:
```json
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
@@ -1611,6 +1703,8 @@ MCP 中有三种角色:
"result": {"content": [{"type": "text", "text": "# CS-Notes ..."}], "isError": false}}
```
+调用之前,双方还要确认协议版本和各自支持的能力,例如服务端是否提供工具、工具列表变化时是否发通知。2025-11-25 及以前的版本在连接建立时用 initialize 握手协商一次;2026-07-28 版去掉了握手,每个请求在 _meta 中带上协议版本和客户端能力,服务端逐个请求判断是否支持,客户端也可以先用 server/discover 查询服务端支持的版本和能力。
+
实现服务端时用官方 SDK 注册工具即可。用 Python SDK 把前文的 get_order 写成 MCP 服务端(mcp 2.3.0,1.x 版本中这个类名为 FastMCP):
```python
@@ -1630,7 +1724,7 @@ if __name__ == "__main__":
mcp.run() # 默认使用 stdio;远程服务用 mcp.run(transport="streamable-http")
```
-SDK 根据类型注解和文档字符串生成 tools/list 返回的描述和参数 Schema。工具抛出 ToolError 时,错误信息作为 isError 为 true 的结果返回给模型;抛出其它异常时,模型只收到“Error executing tool get_order”,具体内容留在服务端的日志中(实测,见[附录 A24](#a24-mcp-服务端的最小实现))。
+SDK 根据类型注解和文档字符串生成 tools/list 返回的描述和参数 Schema。工具抛出 ToolError 时,错误信息作为 isError 为 true 的结果返回给模型;抛出其它异常时,模型只收到“Error executing tool get_order”,具体内容留在服务端的日志中(实测,见[附录 A24](#a24-mcp-服务端的最小实现))。参数由 SDK 按 Schema 校验,模型把 order_id 传成数字 1024 或者写成 orderId 时,工具函数不会执行,校验错误同样作为 isError 结果返回,模型可以据此改正后重试。返回值注解为 TypedDict 这类字段固定的类型时,SDK 还会生成 outputSchema,结果中除了文本,还有一份符合它的 structuredContent,供客户端校验后交给程序使用。
本地服务端以子进程运行,权限就是启动它的用户的权限,规范建议从环境变量中读取凭据;远程服务端通过 HTTP 对外提供,规范按 OAuth 2.1 定义授权:客户端带着访问令牌请求,服务端必须校验令牌是否签发给自己,无效或过期的令牌返回 HTTP 401。令牌只说明调用者是谁,每个工具还要按用户校验权限,例如只能查询自己的订单。
@@ -1640,9 +1734,11 @@ MCP 与相关概念的区别:
- 普通的 HTTP 接口也能封装成工具,但每个接口的认证方式、参数格式和文档各不相同,需要逐个适配;MCP 统一了发现和调用的方式,工具描述本身就是写给模型看的;
- 能执行命令的 Agent 可以直接调用 git、kubectl 这类命令行工具(CLI)。模型在训练中见过大量常用命令,不需要事先把定义放进上下文,需要时运行 --help 查看,输出还可以先用管道过滤,只有结果进入上下文;代价是需要能执行命令的环境,权限只能靠沙箱和命令白名单限制,输出是给人看的文本,没有统一的 Schema。MCP 适合需要统一授权、远程访问或者结构化结果的场景,以及没有命令行环境的应用,例如聊天客户端接入公司的工单系统。
+一个 Agent 接入多个服务端时,主机为每个服务端建立一个客户端,把各服务端的工具合并后交给模型。工具名只要求在一个服务端内唯一,合并时可能重名,所以主机通常加上服务端名作前缀,例如 Claude Code 中的工具名形如 mcp__服务端名__工具名。
+
MCP 也有代价。每个服务端的工具定义都会进入上下文,Anthropic 的文档中举的例子是同时接入 GitHub、Slack、Sentry、Grafana、Splunk 五个服务端,工具定义就占约 5.5 万个 Token;可用的工具超过 30~50 个后,模型选对工具的能力也会明显下降。解决方法是按需加载:上下文中只放一个搜索工具和最常用的几个工具,模型先搜索,再把找到的工具的完整定义加入上下文;也可以由应用先根据问题检索出相关的工具,只把这几个交给模型。Anthropic 还介绍过一种做法:把 MCP 工具生成为代码文件,Agent 按需读取工具的定义、编写代码调用,中间结果留在执行环境中,不经过模型,例子中的 Token 消耗从 15 万降到 2000。
-另外,第三方服务端的工具描述和返回结果可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,细节以官方最新版本为准。
+另外,第三方服务端的工具描述和返回结果可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,本节对照 2026-07-28 版,细节以官方最新版本为准。
### Skills 🔥
@@ -1660,6 +1756,8 @@ Skill 的核心是渐进式加载:
判断是否使用某个 Skill 的是模型本身,而不是关键词匹配或者向量检索:所有 Skill 的名称和描述放在系统提示词中,模型认为当前任务与某个描述相符时,用读取文件的工具(例如用 cat 命令读取 SKILL.md)或者 Agent 提供的专门工具把正文读进上下文,再按正文中的步骤执行。
+读进来的正文和其它工具结果一样追加在对话历史中,此后每次请求都会带上,没有单独的卸载步骤,直到会话结束或上下文被压缩(Claude Code 压缩时会把最近用过的 Skill 重新附在摘要之后,每个最多保留 5000 Token)。效果上这与把正文粘贴进对话相同,区别在于由模型按描述自动发现、不必每次手工提供,还可以附带脚本。
+
工具、MCP 和 Skill 的区别:
- 工具是一个可调用的函数,提供原子能力,例如查询订单;
@@ -1700,14 +1798,14 @@ Skill 与系统提示词、Agent 的区别:
- 写多细取决于任务能容忍多大的偏差:有多种合理做法的任务写目标和要点;必须严格一致的操作(例如数据库迁移)写成确定的命令或脚本,例如上面统计 PR 的部分交给 collect.py,而不是让模型自己数;
- 质量要求高的任务在步骤中加入检查,例如“运行校验脚本,有错误就修改后重新运行,直到通过”。
-Skill 中的规范只能引导,不能保证模型一定遵守,必须满足的规则还要用脚本或钩子检查,见 [Harness 的引导与反馈](#2-引导与反馈)。
+Skill 中的规范只能引导,不能保证模型一定遵守,必须满足的规则还要用脚本或钩子检查,见 [Harness 的引导与反馈](#2-引导与反馈)。模型也不一定会加载 Skill,描述不够贴切时可能直接动手;部署、提交这类有副作用、必须按流程走的 Skill,可以设为只允许用户用斜杠命令(例如 /deploy)显式调用,Claude Code 中在 frontmatter 写 disable-model-invocation: true,它的描述也不再放进目录。
Agent 是否使用某个 Skill,完全取决于它的 description,所以要像测试[意图识别与路由](#意图识别与路由)一样测试它:
- 触发测试:准备约 20 条真实的提问,一半应该触发、一半不应该触发。不应该触发的重点选关键词相近但需求不同的,例如“把周报模板翻译成英文”不应该触发生成周报的 Skill。每条运行 3 次左右计算触发率,并留出一部分提问不参与修改,检查改进是否适用于新的说法;
- 效果测试:同一批任务分别在有和没有这个 Skill(或者使用旧版本)的情况下运行,能用代码判断的项由脚本检查,其余由人或模型裁判比较,同时记录 Token 消耗和耗时。
-Skill 越来越多时,目录中每个 Skill 的描述约占 50~100 个 Token,几十个也不算多,问题主要在于描述相近的 Skill 互相争抢。处理方法:职责重叠的合并成一个,或者在描述中写清边界,例如“只用于生成周报,修改模板请使用 report-template”;不适用于当前用户或环境的 Skill 不放进目录;数量很大时,先按问题检索出候选的几个,再交给模型选择。线上选错 Skill、用户纠正的会话加入触发测试集,修改描述后回归,Skill 就是这样持续迭代的。
+Skill 越来越多时,目录中每个 Skill 的描述只占约 100 个 Token,几十个也不算多,问题主要在于描述相近的 Skill 互相争抢。处理方法:职责重叠的合并成一个,或者在描述中写清边界,例如“只用于生成周报,修改模板请使用 report-template”;不适用于当前用户或环境的 Skill 不放进目录;数量很大时,先按问题检索出候选的几个,再交给模型选择。线上选错 Skill、用户纠正的会话加入触发测试集,修改描述后回归,Skill 就是这样持续迭代的。
### 多 Agent
@@ -1719,6 +1817,8 @@ Skill 越来越多时,目录中每个 Skill 的描述约占 50~100 个 Token
实现上,子 Agent 通常被包装成主 Agent 的一个工具。主 Agent 调用 research("厂商 A") 时,应用启动一个新的 Agent 循环,它有自己的系统提示词、工具和消息列表,运行结束后把最终回答作为工具结果返回给主 Agent。
+主 Agent 选哪个子 Agent,依据的是这些工具的名称和描述,所以要像[意图识别与路由](#意图识别与路由)一样写清各自的边界,并用评测集检验。子 Agent 的返回最好约定格式,例如结论、依据和是否完成,同时限制它的最大轮数;超时或失败时,主 Agent 重试、换一种拆法,或者如实报告这部分没有完成。
+
多 Agent 的主要好处是隔离上下文:每个子 Agent 在自己的上下文中工作,可以读大量资料、反复试错,最后只把精简的结论返回,主 Agent 的上下文不会被中间过程撑满。这也是它与“一个 Agent 调用多个 Skill”的区别:Skill 的内容和执行过程都进入同一个上下文,子 Agent 的过程则不进入。
代价也很明显:
@@ -1732,9 +1832,10 @@ Skill 越来越多时,目录中每个 Skill 的描述约占 50~100 个 Token
- 主管分派:主管 Agent 决定把哪些子任务交给哪个 Agent,最后汇总,也就是上面的主从模式。子任务由主管根据输入临时决定,适合子任务互相独立、可以并行的情况;
- 流水线:几个 Agent 按固定的顺序执行,上一个的输出是下一个的输入,例如提取商品参数、撰写文案、合规审核。它本质上是节点恰好为 Agent 的工作流,审核不通过可以退回上一步,但要限制退回的次数;
-- 讨论:几个 Agent 先各自给出答案,再看过彼此的答案和理由后修改,几轮之后投票或者由裁决者给出结论。研究表明这种方式能提高算术和事实问答的准确率,代价是调用次数等于 Agent 数乘以轮数,适合出错代价高、答案可以互相检验的判断。
+- 讨论:几个 Agent 先各自给出答案,再看过彼此的答案和理由后修改,几轮之后投票或者由裁决者给出结论。研究表明这种方式能提高算术和事实问答的准确率,代价是调用次数等于 Agent 数乘以轮数,适合出错代价高、答案可以互相检验的判断;
+- 转交(Handoff):没有固定的主管,当前 Agent 判断问题不归自己处理时,把控制权和需要的上下文(完整历史、筛选后的消息或摘要)交给另一个 Agent,由它接着处理并直接回复用户。例如客服的分诊 Agent 调用 transfer_to_refund_agent 工具,之后由退款 Agent 接手对话。与主管分派的区别是接手的 Agent 处理完不会像工具调用那样自动返回原来的 Agent,需要时再转交回去,适合按业务领域划分、由专门的 Agent 直接面对用户的场景。
-Agent 之间传递信息的方式有三种:主管分派时通过工具调用的参数和返回值;同一个应用内通过共享的状态对象,例如 LangGraph 中各节点读写的状态,见[开发框架](#开发框架);跨服务、跨团队时通过消息队列或者下文的 A2A 协议。
+Agent 之间传递信息的方式有三种:主管分派时通过工具调用的参数和返回值;同一个应用内通过共享的状态对象,例如 LangGraph 中各节点读写的状态,见[开发框架](#开发框架);跨服务、跨团队时通过消息队列或者下文的 A2A 协议。通过消息队列传递时,同一条消息可能被投递多次,接收方要用任务 ID 这类业务键去重,见[接收端的可靠性](消息队列.md#接收端的可靠性)。
多个 Agent 同时修改共享资源时,与多线程的问题相同,处理方法也类似:
@@ -1781,28 +1882,33 @@ Claude Code、Cursor、Codex 这类 AI 编程助手是目前最常用的 Agent
-以 Claude Code 为例,官方文档把它的工作过程描述为收集上下文、执行操作、验证结果三个阶段,内置的工具分为文件操作、搜索、执行命令、网络访问和代码理解几类。几个关键的设计:
+以 Claude Code 为例,官方文档把它的工作过程描述为收集上下文、执行操作、验证结果三个阶段,下一步做什么由模型根据上一步的结果决定,属于 [ReAct](#2-react);Plan 模式下先调研代码、写出计划,确认后才修改,相当于[先规划后执行](#3-先规划后执行)。几个关键的设计:
-- 项目约定:项目根目录的 CLAUDE.md 在每次会话开始时放入上下文,写入构建和测试命令、代码规范等每次都需要的信息,官方建议每个文件不超过 200 行,内容越长越占上下文,也越难被遵守;
-- 上下文管理:接近上限时自动把对话压缩成摘要,早期对话中的细节可能丢失,所以重要的规则应该写进 CLAUDE.md,而不是只在对话中说一次;子 Agent 在自己的上下文中工作,只把结论返回;
-- 安全:修改文件前保存快照,可以撤销;执行命令、修改文件前按权限模式决定是否请求确认。对数据库、线上接口这类外部系统的操作无法撤销,需要用权限加以限制。
+- 项目约定与记忆:项目根目录的 CLAUDE.md(Codex、Cursor 等使用 AGENTS.md)在每次会话开始时放入上下文,写入构建和测试命令、代码规范等每次都需要的信息,官方建议每个文件不超过 200 行,内容越长越占上下文,也越难被遵守。Claude Code 还会把开发者的纠正和偏好自己记下来,会话开始时只加载这些记忆的索引 MEMORY.md;
+- 上下文管理:MCP 工具平时只占名称,Skill 只占描述,用到时才加载完整的定义和正文;子 Agent 在自己的上下文中工作,只把结论返回;接近上限时自动把对话压缩成摘要,再从磁盘重新读取 CLAUDE.md 和最近读写过的几个文件,只在对话中说过的要求可能丢失;
+- 安全:用文件工具修改文件前保存快照,可以撤销,但只被命令改动过的文件不在其中;执行命令、修改文件前按权限模式决定是否请求确认,命令还可以放进操作系统的沙箱,限制能写的目录和能访问的域名。对数据库、线上接口这类外部系统的操作无法撤销,需要用权限加以限制。
用好这类工具的方法与带一个新同事类似:
-- 给出可以验证的目标,例如“让 OrderServiceTest 通过”,而不是“优化一下代码”,Agent 才能自己运行测试判断是否完成;
-- 先让它阅读代码、给出方案,确认后再修改,避免方向一开始就错了;
-- 任务拆小,一次只改一个功能,便于审查;生成的代码同样要做代码审查和测试,不能因为是 AI 写的就跳过;
+- 给出可以验证的目标,例如“让 OrderServiceTest 通过”,而不是“优化一下代码”,Agent 才能自己运行测试判断是否完成;修 bug 时先让它写一个能复现问题的测试;
+- 先让它阅读代码、给出方案,确认后再修改,避免方向一开始就错了;要与已有代码保持一致时,指明参照哪个文件;
+- 任务拆小,一次只改一个功能,便于审查;生成的代码同样要做代码审查和测试;
+- 测试用例按需求来写,由人检查边界,例如金额为 0;照着实现补的测试,断言记录的往往是代码当前的行为,错误也会被当成预期;
- 把反复纠正的要求写进 CLAUDE.md 这类项目约定文件,而不是每次在对话中重复。
Vibe Coding 这个说法由 Andrej Karpathy 在 2025 年 2 月提出,指的是完全凭感觉让 AI 写代码:接受所有修改,不看 diff,报错了就把错误信息贴回去,代码超出了自己的理解范围也不在意。他本人认为这种方式适合周末随手做的一次性项目。Simon Willison 进一步区分:用 AI 生成代码之后,自己审查、测试过,并且能向别人解释它是怎样工作的,就不属于 Vibe Coding,而是正常的软件开发。
所以 AI 生成的代码占多大比例不是关键,开发者的职责没有变:明确需求和验收标准,设计方案,审查和测试生成的代码,对上线的结果负责。原型和一次性的脚本可以放手让 AI 生成;需要长期维护、涉及资金和数据安全的代码,每一处修改都要经过审查。
+与 Vibe Coding 相对的是规格驱动开发(Spec-Driven Development, SDD),也称 Spec Coding:先把需求和验收标准写成规格文档,人审阅后再让 AI 拆成任务逐项实现、对照规格检查;规格文档留在仓库中,是新会话和代码审查的依据。
+
### Harness
Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所有代码、配置和执行逻辑:Agent 循环、系统提示词、工具及其描述、上下文压缩、记忆、沙箱与权限、子 Agent 的调度,以及在固定时机执行的钩子(例如每次修改文件后自动格式化)。模型本身只接收文本、输出文本,不能保存状态、执行代码、获取实时信息,这些都要由 Harness 补上。前面不到二十行的 Agent 循环就是一个最简单的 Harness;Claude Code、Codex 是功能完整的 Harness,可以换用不同的模型。MCP 和 Skill 也是 Harness 的组成部分,前者负责接入工具,后者负责按需提供做事的方法。
-提示词工程关注写给模型的指令,上下文工程关注每次调用放进上下文的内容,两者都是 Harness 的一部分;Harness 还包括工具、执行环境、校验和长任务的状态。
+提示词工程关注写给模型的指令,上下文工程关注每次调用放进上下文的内容,两者都是 Harness 的一部分。关注点一层层扩大,是因为任务变了:Agent 多步执行时,每一步都要重新挑选和压缩上下文,见[上下文工程](#上下文工程);Agent 长时间自主改代码、执行命令时,权限检查、重试次数和进度保存要由程序确定地执行,写进上下文只能提醒模型,不能保证做到。
+
+[ReAct](#2-react) 只规定了循环中怎样决定下一步(思考、调用工具、观察结果),是 Agent 循环的一种运行方式。只有 ReAct 循环时,模型调错工具、执行危险命令、没做完就宣布完成,都没有东西拦住;Harness 在循环外加上权限与沙箱、钩子与校验、上下文压缩和状态交接,更稳定、更安全靠的是这些确定执行的代码,而不是模型更会推理。
模型决定了能力的上限,Harness 决定了实际能发挥出多少。前面的实验中,模型不变,只在系统提示词中写清楚查询的步骤,成功率就从 0/10 变成了 10/10。围绕 Agent 设计和改进 Harness 的工作称为 Harness 工程(Harness Engineering),这个说法在 2026 年初开始流行,核心是两类控制:
@@ -1823,7 +1929,7 @@ Harness 的每个部分都对应一个模型自己做不到或做不稳的问题
| 钩子与校验 | 在固定时机执行确定的检查,不通过就把错误送回模型继续 |
| 评测与可观测 | 记录每一步的输入输出和 Token,改动 Harness 后用同一批任务回归,见 [可靠性与评测](#可靠性与评测) |
-工具的返回很长时,Harness 通常只把开头和结尾,或者最新的几条放进上下文,完整结果写到文件中,模型需要时再读取;Claude Code 默认把一次工具返回限制在 25,000 个 Token 以内。
+工具的返回很长时,Harness 通常只把开头和结尾,或者最新的几条放进上下文,完整结果写到文件中,模型需要时再读取;Claude Code 对 MCP 工具的返回默认限制为 25,000 个 Token,超过 5 万字符的文本结果写入文件,只把路径交给模型。
#### 2. 引导与反馈
@@ -1866,7 +1972,7 @@ Harness 的每个组件都隐含一个假设:模型自己做不到这件事。
- 自动执行工具调用的循环,直到模型返回不含工具调用的回答;
- 提供对话记忆、RAG、结构化输出等常用组件。
-框架减少了重复代码,但也隐藏了细节,排查问题时仍然需要查看实际发送给模型的消息。
+框架减少了重复代码,代价见[怎样选择](#4-怎样选择)。
#### 1. 为什么用图编排
@@ -1932,9 +2038,9 @@ app.invoke(Command(resume=True), cfg) # 人工同
人工介入的过程是:节点中调用 interrupt(),图暂停并保存状态,传给 interrupt() 的内容(例如退款金额)返回给调用方,用于展示审批页面;审批之后,用同一个 thread_id 和 Command(resume=True) 再次调用,interrupt() 的返回值就是 True。
-需要注意的是,恢复时被暂停的节点会从头重新执行,而不是从 interrupt() 那一行继续。上面的例子中 confirm 节点开头的打印语句执行了两次(LangGraph 1.2.18 实测,见[附录 A8](#a8-langgraph-的-checkpoint-与人工确认))。因此 interrupt() 之前的代码不能有副作用,或者副作用必须是幂等的;真正的退款放在审批之后的单独节点中,并用订单号作为幂等键。
+需要注意的是,恢复时被暂停的节点会从头重新执行,而不是从 interrupt() 那一行继续。在上面 confirm 节点的开头加一行打印,暂停前和恢复后各输出一次(LangGraph 1.2.18 实测,见[附录 A8](#a8-langgraph-的-checkpoint-与人工确认))。因此 interrupt() 之前的代码不能有副作用,或者副作用必须是幂等的;真正的退款放在审批之后的单独节点中,并用订单号作为幂等键。
-生产环境中 checkpoint 要存进数据库,例如 PostgreSQL,内存实现只适合测试。checkpoint 会随步数不断增加,需要按会话设置保留期限、定期清理已经结束的会话;状态中不要放大文件,只放文件的地址。
+生产环境中 checkpoint 要存进数据库,例如 PostgreSQL 或 Redis,内存实现只适合测试。checkpoint 会随步数不断增加,需要按会话设置保留期限(Redis 的实现可以直接配置 TTL)、定期清理已经结束的会话;状态中不要放大文件,只放文件的地址。
#### 3. Java 项目中的 Spring AI
@@ -1982,13 +2088,22 @@ LangChain 和 LangGraph 出自同一个团队,处在不同的层次:
- Java 项目优先考虑 Spring AI 或 LangChain4j,与现有的 Spring 体系集成;Python 项目常用 LangChain、LangGraph;
- 流程有分支和循环、需要暂停等待人工审批或者从中断处恢复时,LangGraph 这类状态图框架更合适;
-- 逻辑简单时,直接调用模型的接口写一个循环往往更清楚,前面的 Agent 循环不到二十行。
+- 其它常被提到的框架侧重点不同:LlamaIndex 侧重文档解析、索引和检索,常用来搭 RAG;CrewAI、AutoGen 侧重多个 Agent 按角色分工协作,AutoGen 已停止维护,由 Microsoft Agent Framework 接替。
+
+框架也有代价,逻辑简单时直接调用模型的接口写一个循环往往更清楚,前面的 Agent 循环不到二十行:
+
+- 抽象层多,实际发给模型的提示词和消息藏在几层封装之后,效果不好时要借助追踪工具(例如 LangSmith)或打印请求才能定位;
+- 接口变化快,网上很多按旧版本写的示例不能直接运行,升级也要改代码;
+- 用 LangGraph 要先设计状态、节点和边,流程只有两三步时,这些代码比业务逻辑本身还多;
+- 依赖 checkpoint 时,状态中的对象都要能序列化,被中断的节点恢复时会重新执行,副作用要幂等。
+
+把图中固定的流程改成单个 ReAct 循环时,人工确认这类约束要移进工具,见 [Agent 与工作流](#agent-与工作流)。
### 可靠性与评测
#### 1. 容错
-Agent 的每一步都可能出错,需要在代码中兜底,而不是依赖模型自觉。容错的逻辑写在控制循环(Harness)里,而不是写在提示词里。
+Agent 的每一步都可能出错,容错的逻辑要写在控制循环(Harness)的代码里,而不是依赖提示词和模型自觉。
工具执行前先校验参数:先按 JSON Schema 检查类型、必填字段和枚举值,再做业务校验,例如订单是否属于当前用户、退款金额是否超过实付金额。严格模式和约束解码只能保证格式正确,保证不了业务上正确。
@@ -1996,11 +2111,11 @@ Agent 的每一步都可能出错,需要在代码中兜底,而不是依赖
- 临时性错误,例如超时、限流、服务端 5xx:由代码按指数退避重试 2~3 次,模型不需要知道,做法与调用模型接口时相同,见[稳定性](#稳定性);
- 模型可以修正的错误,例如参数格式不对、取值不在范围内:把错误信息作为工具结果回传给模型,信息中写明错在哪、正确的格式是什么;
-- 重试后仍然失败:如实告诉用户哪一步失败了,返回已经拿到的部分结果,或者转人工,不能让模型编造结果。
+- 重试后仍然失败:把失败如实回传给模型,由它换一种办法,或者告诉用户哪一步失败了、返回已经拿到的部分结果、转人工,不能让模型编造结果。
不能指望模型自己处理临时性错误。在“订单 1024 到哪了”的例子中,让物流接口第一次调用返回“物流接口超时,请稍后重试”,并把错误回传给模型,7B 模型 10 次运行中没有一次重新调用,都回答物流信息暂时查不到、请稍后再试;而接口实际上第二次调用就能成功。
-参数错误能否被修正,取决于错误信息写得是否清楚。物流接口要求快递公司编码为 shunfeng,模型按常识填了 SF:错误信息只写“参数错误”时 10 次都没有重新调用,其中 4 次编造了位置或送达时间,例如“目前包裹在上海市,预计 2 天内送达”;写明“carrier 应为快递公司编码,顺丰为 shunfeng”时 10 次都改用 shunfeng 重新调用,全部答出了包裹在杭州转运中心(Qwen2.5-7B 实测,见[附录 A7](#a7-工具出错时-agent-的表现))。可见含糊的错误信息不但修不好问题,还可能让模型为了给出回答而编造结果。
+参数错误能否被修正,取决于错误信息写得是否清楚。物流接口要求快递公司编码为 shunfeng,模型按常识填了 SF:错误信息只写“参数错误”时 10 次都没有重新调用,其中 4 次编造了位置或送达时间,例如“目前包裹在上海市,预计 2 天内送达”;写明“carrier 应为快递公司编码,顺丰为 shunfeng”时 10 次都改用 shunfeng 重新调用,全部答出了包裹在杭州转运中心(Qwen2.5-7B 实测,见[附录 A7](#a7-工具出错时-agent-的表现))。
错误信息写清楚之后,模型通常能够自行修正,例如:
@@ -2013,22 +2128,26 @@ tool 退款申请已提交,单号 R88
一次回复中并行调用了多个工具、其中一部分失败时,成功的结果照常返回,失败的单独标明错误,由模型决定是否只重试失败的那一个。
-为了防止 Agent 陷入死循环,控制循环中还要有这些限制:
+工具持续失败时,例如物流接口宕机,可以像模型接口一样[熔断](#3-限流降级与熔断),直接返回“暂不可用”,不必每个请求都先等超时再重试。
-- 限制步数、总耗时和 Token 预算,超出时停止,返回已有的结果或者转人工;
-- 检测重复调用,同一个工具以相同参数连续调用多次时中止,或者提示模型换一种方法;
-- 重规划、反思、审核退回都要设次数上限。
+死循环的原因是模型每一步只看当前的上下文做决定:工具反复返回同样的结果时,上下文几乎不变,它就可能一次次做出同样的决定;没有明确的完成标准时,它可能一直查下去。因此控制循环中要有这些限制:
+
+- 限制步数、总耗时和 Token 预算,超出时停止,返回已有的结果或者转人工。步数上限参考评测中成功任务用的步数,例如取 P99 再留些余量;
+- 检测有没有进展:合理的循环每一轮都有进展,例如翻页;同样的参数连续几次得到同样的结果,很可能是在原地打转。所以要同时比较工具名、参数和结果中的关键字段,而不只是数工具调用了几次,连续几次没有进展时中止,或者提示模型换一种方法。等待异步任务完成时不要让模型反复查询,做法见 [Agent 循环](#agent-循环);
+- 重规划、反思、审核退回、Agent 之间的转交都要设次数上限。
有副作用的操作要额外处理:
-- 幂等:模型可能重复调用,工作流从 checkpoint 恢复时节点也会重新执行,所以修改数据的工具要用幂等键去重,例如同一个订单只能有一条进行中的退款;
-- 人工确认:转账、删除数据、对外发送信息等操作由代码校验权限,执行前暂停,把操作内容展示给人确认,见[开发框架](#开发框架)中的人工介入;
+- 幂等:修改数据的工具超时,操作可能已经执行,只是响应没有返回;模型重复调用、从 checkpoint 恢复时节点重新执行,也会让同一个操作执行两次。所以这类工具要用幂等键去重,重试时沿用同一个键,例如同一个订单只能有一条进行中的退款,见[幂等](分布式.md#九幂等);
+- 人工确认:转账、删除数据、对外发送信息等操作由代码校验权限,执行前暂停,把操作内容展示给人确认,实现见 [checkpoint 与人工介入](#2-checkpoint-与人工介入);
- 补偿:多步操作中后面的步骤失败、前面的需要撤销时,执行对应的补偿操作,例如取消已提交的退款申请,思路与 [Saga](分布式.md#saga) 相同;
- 隔离:执行代码的工具放在沙箱中运行,并记录审计日志。
+确认得太频繁,人会不看内容就点同意,确认也就失去了作用。所以只确认涉及资金、无法撤销或者对外可见的操作,只读的查询、影响小且可以撤销的操作(例如创建售后工单)、在接触不到真实数据的沙箱中运行的代码自动执行并记录日志;需要确认的操作也可以按规则放行一部分,例如金额低于阈值的退款。
+
#### 2. 评测
-评测 Agent 时要区分结果和轨迹:
+与只看模型输出文本的问答评测相比,Agent 在环境中连续执行多步,评测时还要检查环境的最终状态和执行过程,所以要区分结果和轨迹:
- 结果:任务结束时环境的最终状态,例如数据库中是否真的有一条 59.9 元的退款记录。Agent 回复“已为您退款”不代表退款成功;
- 轨迹:完整的执行过程,包括调用了哪些工具、参数是什么、走了多少步、消耗了多少 Token。轨迹用于检查有没有危险或多余的调用,以及定位失败的原因。
@@ -2071,7 +2190,7 @@ checks:
-不给参考时,4 对质量相当的回答 8 次都判排在第二个的更好;4 对信息相同、只多了客套话的回答,8 次都选了更长的那个;4 对“更详细,但有一处与工具结果不符”(例如把杭州转运中心写成上海转运中心)的回答,8 次中有 7 次选了有错的那个。把工具结果作为参考放进提示词后,有错的回答 8 次都被判为较差,但位置偏差仍然存在,只是变成偏向第一个位置,质量相当的回答 8 次中 6 次选了第一个(见[附录 A10](#a10-模型裁判的位置偏差与长度偏差))。可见给出参考能纠正事实错误,但纠正不了位置偏差,几种缓解方法需要一起使用。
+不给参考时,4 对质量相当的回答 8 次都判排在第二个的更好;4 对信息相同、只多了客套话的回答,8 次都选了更长的那个;4 对“更详细,但有一处与工具结果不符”(例如把杭州转运中心写成上海转运中心)的回答,8 次中有 7 次选了有错的那个。把工具结果作为参考放进提示词后,有错的回答 8 次都被判为较差,但位置偏差仍然存在,只是变成偏向第一个位置,质量相当的回答 8 次中 6 次选了第一个(见[附录 A10](#a10-模型裁判的位置偏差与长度偏差))。给出参考能纠正事实错误,但纠正不了位置偏差,几种缓解方法需要一起使用。
缓解方法:
@@ -2088,9 +2207,11 @@ checks:
- 来源:线上日志、转人工和差评的会话、测试人员发现的问题,每个修过的错误都变成一条用例;
- 覆盖:按意图和难度分层,正例和反例都要有,例如既有应该退款的,也有超过退款期限、应该拒绝的;
- 标准:每条任务写清楚怎样算成功,两个人独立判断能得出相同的结论,否则先改任务描述;
-- 环境:每次从干净的数据开始运行,避免上一次运行留下的状态影响结果。
+- 环境:每次从干净的数据开始运行,避免上一次运行留下的状态影响结果;多轮对话的任务可以让另一个模型按设定的用户信息和诉求扮演用户,τ-bench 就是这样做的。
-每次修改提示词、工具或者模型,都在同一批任务上回归,并记录每次运行的完整轨迹,出错时才能复现和定位。评测分数上不去时,先读几条失败的轨迹,确认是 Agent 真的做错了,而不是评分规则把正确的做法判错了。
+每次修改提示词、工具或者模型,都在同一批任务上回归,并记录每次运行的完整轨迹,出错时才能复现和定位。评测分数上不去时,先读几条失败的轨迹,确认是 Agent 真的做错了,而不是评分规则把正确的做法判错了;再按出错的环节归类,例如检索没有召回、选错工具、参数错误、拿到了正确的工具结果却总结错,占比最高的一类就是优先要改的瓶颈。
+
+反复对着同一批用例改提示词,分数会越来越高,但可能只是记住了这些用例。可以按同样的分层留出一部分用例,平时不看、只在大的改动后运行:平时用的用例分数持续上升,留出的用例却没有改善时,就要怀疑过拟合,用例少时还要多跑几次排除随机波动。
上线后关注的指标:
@@ -2121,9 +2242,9 @@ data: [DONE]
```
-- 每条事件由若干行“字段: 值”组成,以空行结束,data 是事件的内容,id 是事件的编号,event 是事件的类型(省略时为 message);
+- 每条事件由若干行“字段: 值”组成,以空行结束,data 是事件的内容(一条事件有多行 data 时按换行拼接),id 是事件的编号,event 是事件的类型(省略时为 message);
- 以冒号开头的行是注释,客户端会忽略,常用作心跳,防止中间的代理因为连接长时间空闲而断开;
-- 浏览器的 EventSource 断线后会自动重连,并在请求头 Last-Event-ID 中带上最后收到的事件编号,服务端可以据此从断点继续发送;
+- 浏览器的 EventSource 断线后会自动重连,并在请求首部 Last-Event-ID 中带上最后收到的事件编号,服务端可以据此从断点继续发送;
- OpenAI 兼容的接口用 data: [DONE] 表示生成结束。
SSE 与 WebSocket 的区别:
@@ -2137,7 +2258,7 @@ SSE 与 WebSocket 的区别:
static void stream(HttpClient client, HttpRequest request, Consumer onData) throws Exception {
HttpResponse> response = client.send(request, HttpResponse.BodyHandlers.ofLines());
try (Stream lines = response.body()) {
- lines.filter(line -> line.startsWith("data:")) // 跳过空行、注释和其它字段
+ lines.filter(line -> line.startsWith("data:")) // 模型接口每条事件只有一行 data,可以逐行处理
.map(line -> line.substring(5).trim())
.takeWhile(data -> !data.equals("[DONE]"))
.forEach(onData); // 例如转发给前端
@@ -2187,7 +2308,7 @@ LangGraph 等框架也提供类似的机制,按节点输出状态的更新或
#### 3. 生产环境中的注意事项
-- Nginx 等反向代理默认会缓冲上游的响应,流式输出变成了一次性输出,需要关闭缓冲,例如在响应头中加上 X-Accel-Buffering: no;
+- Nginx 等反向代理默认会缓冲上游的响应,流式输出变成了一次性输出,需要关闭缓冲,例如在响应首部中加上 X-Accel-Buffering: no;
- 网关和负载均衡的读超时要大于最长的生成时间;
- 用户关闭页面后要及时取消对模型的请求,否则仍在消耗 Token;
- 网络抖动导致断线时,模型的生成无法从中途重新开始。需要续传时,让生成任务与连接解耦:生成的内容按会话和事件编号写入 Redis 等缓存,客户端带着 Last-Event-ID 重连后,先从缓存中补发之后的事件,再继续推送新的内容,超时没有重连再取消生成;
@@ -2252,7 +2373,16 @@ Token 消耗增长过快时,先按步骤和工具汇总每一次调用的输
### 推理服务部署
-自己部署开源模型时,例如用 vLLM 部署 Qwen2.5-7B,除了 TTFT 和 TPOT,还关心吞吐量(Throughput),即整个服务每秒生成的 Token 总数。TTFT、TPOT 决定单个用户的体验,吞吐量决定一张显卡能服务多少用户、每个 Token 的成本是多少。
+与调用模型服务商的接口相比,自己部署开源模型的好处是数据不出内网、可以使用微调过的模型;代价是显卡空闲时也在计费,运维要自己做,调用量大而稳定时才可能更便宜。
+
+自己部署时,例如用 vLLM 部署 Qwen2.5-7B,除了 TTFT 和 TPOT,还关心吞吐量(Throughput),即整个服务每秒生成的 Token 总数。TTFT、TPOT 决定单个用户的体验,吞吐量决定一张显卡能服务多少用户、每个 Token 的成本是多少。
+
+推理优化的手段按解决的问题分为几类:
+
+- 每一步都要读一遍权重和 KV Cache:用[量化](#量化)、[GQA、MLA](#自回归生成与-kv-cache) 缩小要读的数据,用 [FlashAttention](#2-计算量与-flashattention) 减少注意力计算中的显存读写;
+- 显卡同时处理的请求太少:批处理、连续批处理、PagedAttention;
+- 重复计算相同的开头:[前缀缓存](#5-前缀缓存);
+- 只能逐个 Token 解码:投机解码(Speculative Decoding),小模型先连续猜几个 Token,大模型读一遍权重就能同时验证:设大、小模型给某个猜测的概率分别为 p、q,它以 min(1, p / q) 的概率被接受;第一个被拒绝的位置改从 max(0, p − q) 归一化后的分布重新采样,丢弃后面的猜测。这样输出的分布与大模型单独生成相同,适合并发不高的场景。
#### 1. 批处理:用延迟换吞吐
@@ -2278,9 +2408,11 @@ batch 从 1 增大到 64,总吞吐从 22 Token/s 提高到 214 Token/s,约 9
连续批处理(Continuous Batching)以每一步为单位调度:每生成一步,就把已经结束的请求移出批次,把等待中的请求加入。它最早由 Orca 提出(称为迭代级调度),vLLM 默认使用。新加入的请求需要先做预填充,长输入的预填充会拖慢同一批中其它请求的解码,vLLM 默认把长的预填充切成小块,与解码请求混在一起执行(分块预填充)。
+vLLM 的调度器维护运行和等待两个队列。每一步处理的 Token 数有上限(max_num_batched_tokens),先分给运行中的请求,剩下的额度再从等待队列加入新请求;上限越小,正在输出的请求越流畅,长输入的 TTFT 越长。一批中的请求长短不一,也不用填充到一样长:各个请求的 Token 拼在一起经过线性层,注意力按请求分别计算。
+
#### 3. 显存:权重与 KV Cache
-显存首先被模型权重占用,剩下的用来存放 KV Cache,能同时服务多少请求取决于 KV Cache 能放下多少 Token。按 Qwen2.5-7B 的配置(28 层、4 个 KV 头、每个头 128 维,BF16),每个 Token 的 KV Cache 为 56 KB(计算见[自回归生成与 KV Cache](#自回归生成与-kv-cache)),一个 8K 上下文的请求占 448 MB。以下的 GB 按 230 字节计算,权重约 14.2 GB:
+显存首先被模型权重占用,剩下的用来存放 KV Cache,能同时服务多少请求取决于 KV Cache 能放下多少 Token。按 Qwen2.5-7B 的配置(28 层、4 个 KV 头、每个头 128 维,BF16),每个 Token 的 KV Cache 为 56 KB(计算见[自回归生成与 KV Cache](#自回归生成与-kv-cache)),一个 8K 上下文的请求占 448 MB。以下的 GB 按 230 字节计算,权重约 14.2 GB(76 亿个参数,每个 2 字节):
```text
并发请求数 KV Cache(每个 8K) 权重 + KV Cache
@@ -2291,7 +2423,9 @@ batch 从 1 增大到 64,总吞吐从 22 Token/s 提高到 214 Token/s,约 9
128 56 GB 70.2 GB
```
-vLLM 默认使用 92% 的显存(--gpu-memory-utilization)。在 24 GB 的显卡上,扣除权重后剩不到 7.9 GB,再扣除激活值等运行时开销,同时只能处理十几个满 8K 的请求;80 GB 的显卡上可以处理 100 多个。KV Cache 不够时,vLLM 会暂停一部分请求,释放它们的 KV Cache,之后再重新计算,延迟会明显增加。想用同样的显卡服务更多请求,可以减小最大上下文长度、使用更多显卡,或者对权重和 KV Cache 进行[量化](#量化)。
+vLLM 默认使用 92% 的显存(--gpu-memory-utilization)。在 24 GB 的显卡上,扣除权重后剩不到 7.9 GB,再扣除激活值等运行时开销,同时只能处理十几个满 8K 的请求;80 GB 的显卡上可以处理 100 多个。KV Cache 不够时,vLLM 抢占最晚加入的请求:释放它的 KV Cache,放回等待队列的最前面,之后重新做预填充,延迟明显增加,监控中的抢占次数(vllm:num_preemptions)持续增长。想用同样的显卡服务更多请求,可以减小最大上下文长度、使用更多显卡,或者对权重和 KV Cache 进行[量化](#量化)。
+
+模型一张卡放不下时用张量并行,把每层的权重矩阵切到多张卡上,每层的注意力和前馈网络算完后各要在卡间做一次 all-reduce 汇总结果,所以一般只在 NVLink 高速互联的一台机器内使用,跨机器时再按层切分(流水线并行)。
#### 4. PagedAttention
@@ -2301,13 +2435,15 @@ PagedAttention 借鉴操作系统的[分页](计算机操作系统%20-%20内存
-同样 7 GB 的显存,按 8K 预留只能服务 16 个请求;按块分配时,如果请求平均只用到 2K Token,可以服务 64 个。批次越大吞吐越高,vLLM 论文中报告,在延迟相近的情况下吞吐量是 FasterTransformer、Orca 等系统的 2~4 倍。
+同样 7 GB 的显存,按 8K 预留只能服务 16 个请求;按块分配时,如果请求平均只用到 2K Token,可以服务 64 个。批次越大吞吐越高,vLLM 论文中报告,在延迟相近的情况下吞吐量是 FasterTransformer、Orca 等系统的 2~4 倍。Ollama 则按“并行数 × 上下文长度”预留内存,适合本机试用,多用户的在线服务一般用 vLLM、SGLang。
+
+PagedAttention 管 KV Cache 放在哪里,FlashAttention 管注意力怎么算,两者可以同时使用,例如 vLLM 用支持块表的 FlashAttention 内核读取不连续的块。
#### 5. 前缀缓存
很多请求的开头是相同的,例如同一个客服系统的所有请求都以相同的系统提示词和工具定义开头,同一个 Agent 任务的每一步都以上一步的输入为开头。前缀缓存(Prefix Caching)把已经算好的 KV Cache 保留下来,新请求的开头与之相同时直接复用,跳过这部分的预填充,TTFT 随之降低。
-vLLM 的实现基于 PagedAttention 的块:每个写满的块用“前面所有块的哈希 + 本块的 Token”计算哈希值,新请求按块逐个查找,前面的块都命中,后面的块才可能命中。只有写满的块会被缓存,例如客服例子中系统提示词和工具定义共 264 个 Token,前 256 个(16 块)可以复用,剩下的 8 个重新计算。
+vLLM 的实现基于 PagedAttention 的块:每个写满的块用“前面所有块的哈希 + 本块的 Token”计算哈希值,新请求按块逐个查找,前面的块都命中,后面的块才可能命中。只有写满的块会被缓存,例如客服例子中系统提示词和工具定义共 264 个 Token,前 256 个(16 块)可以复用,剩下的 8 个重新计算。SGLang 的 RadixAttention 也是前缀缓存,它用基数树(Radix Tree)组织缓存的 Token 序列,页大小为默认的 1 时按单个 Token 匹配,这 264 个 Token 都能复用;页更大时也只匹配完整的页。
模型服务商对命中缓存的输入按更低的价格计费,就是因为这部分不需要重新计算。需要注意的是,缓存保存在每个实例自己的显存中,部署多个实例时,同一个会话的请求应该尽量路由到同一个实例,否则缓存在别的实例上,无法命中。
@@ -2337,7 +2473,7 @@ vLLM 的实现基于 PagedAttention 的块:每个写满的块用“前面所
```java
static Reply chatWithRetry(Request req) throws LlmException {
- long backoff = 500; // 第一次等 0.5 秒
+ long backoff = 500; // 基准间隔,从 0.5 秒开始
for (int attempt = 1; ; attempt++) {
try {
return client.chat(req);
@@ -2345,7 +2481,7 @@ static Reply chatWithRetry(Request req) throws LlmException {
if (!e.retryable() || attempt == MAX_ATTEMPTS) throw e; // 400、401 等直接失败
long wait = e.retryAfterMillis() > 0
? e.retryAfterMillis() // 服务端指定了等待时间
- : backoff / 2 + ThreadLocalRandom.current().nextLong(backoff / 2); // 随机抖动
+ : backoff / 2 + ThreadLocalRandom.current().nextLong(backoff / 2); // 随机抖动:取基准间隔的 0.5~1 倍
sleep(wait);
backoff = Math.min(backoff * 2, 8_000); // 指数增长,设上限
}
@@ -2387,9 +2523,11 @@ OpenAI 的官方 SDK 默认也是这样处理的:对 408、409、429 和 5xx
#### 2. 限制模型能造成的后果
-模型无法可靠地区分指令和数据,在提示词中要求“忽略资料中的指令”只能降低风险,不能杜绝。因此防范的重点是限制模型能造成的后果:
+模型无法可靠地区分指令和数据,在提示词中要求“忽略资料中的指令”只能降低风险,不能杜绝。这是因为系统提示词、用户输入和检索到的资料最终拼成同一个 Token 序列,没有参数化查询那样把代码和数据分开的机制,系统消息优先也只是训练出的倾向。因此防范的重点是限制模型能造成的后果:
-- 区分指令和数据,把外部内容明确标注为不可信的资料;
+- 区分指令和数据,把外部内容明确标注为不可信的资料;RAG 的知识库只收录来源可控的文档,防止有人写入恶意文档;
+- 输入和输出可以再用规则或分类模型检测可疑指令,但检测会漏报,只能作为补充;
+- 防止数据外泄:读取过不可信内容的任务不提供对外发送的工具,前端不自动加载模型输出中的外部图片,否则模型被诱导输出 `` 后,页面一渲染数据就随请求发了出去;
- 工具按最小权限设计,当前任务用不到的工具不提供,权限在代码中按当前用户校验,而不是依靠模型遵守提示词;
- 删除数据、转账、对外发送信息这类高风险操作需要人工确认;
- 模型的输出同样不可信,显示到页面之前要转义,防止 [跨站脚本攻击](攻击技术.md#一跨站脚本攻击),用于查询数据库时要使用参数化查询;
@@ -2736,7 +2874,8 @@ wrong: 4 对 × 2 种顺序;交换顺序后结论一致 4/4;选第一个位
用 HTML 排出一页 A5 的员工手册(页眉页脚、两级标题、正文和 3 行的表格),用 Microsoft Edge 无头模式打印为 PDF;用 macOS 的 PDFKit 读取文本层;再把这一页渲染为 150 dpi 的灰度图片,旋转 0.6° 模拟扫描,生成只含图片的 PDF,用 Vision 框架识别文字(macOS 15,Swift 5.8):
```swift
-let s = PDFDocument(url: URL(fileURLWithPath: path))!.page(at: 0)?.string ?? "" // 文本层
+let doc = PDFDocument(url: URL(fileURLWithPath: path))! // 页面只弱引用文档,文档要用变量持有
+let s = doc.page(at: 0)?.string ?? "" // 文本层
let req = VNRecognizeTextRequest()
req.recognitionLevel = .accurate; req.recognitionLanguages = ["zh-Hans", "en-US"]
try VNImageRequestHandler(cgImage: cg).perform([req]) // OCR
@@ -2775,7 +2914,7 @@ y=1155 x= 735 conf=0.50 第12页
### A12 切片方式、Top-K 与相似度阈值
-环境:Apple M1 Pro,PyTorch 2.8,Transformers 5.20,bge-small-zh-v1.5 取 [CLS] 向量并归一化,最大长度 512,问题不加指令前缀(单独比较前缀时除外)。知识库为员工手册 2026 版(6317 字)、2023 版(6 处数字不同)和错误码说明(550 字)。固定长度切片把 Markdown 去掉标题符号后按字符滑动切分;按小节切时每个三级标题一个切片,加标题路径时在切片开头加“员工手册(2026 版) > 第 3 章 年假 > 3.3 结转”。40 个问题各标注 1~2 句原文,召回的切片中包含全部原句才算命中:
+环境:Apple M1 Pro,PyTorch 2.8,Transformers 5.20,bge-small-zh-v1.5 取 [CLS] 向量并归一化,最大长度 512,问题不加指令前缀(单独比较前缀时除外)。知识库为员工手册 2026 版(2563 个字符)、2023 版(2552 个字符,6 处数字不同)和错误码说明(236 个字符),去掉标题符号和空行后共 4958 个字符。固定长度切片把 Markdown 去掉标题符号后按字符滑动切分;按小节切时每个三级标题一个切片,加标题路径时在切片开头加“员工手册(2026 版) > 第 3 章 年假 > 3.3 结转”。40 个问题各标注 1~2 句原文,召回的切片中包含全部原句才算命中:
```python
hit = [all(any(e in chunks[j] for j in top[i]) for e in ev) for i, (_, ev) in enumerate(Q)]
@@ -2808,6 +2947,29 @@ top3 同时含 2023 版和 2026 版同一小节的问题数: 38
加前缀: Recall@1 0.850 Recall@3 1.000
```
+递归切分和语义切分沿用上面的知识库、问题和命中判定。递归切分用 LangChain text-splitters 的 RecursiveCharacterTextSplitter(GitHub master 分支的源码,2026-10-30),直接切 Markdown 原文,重叠为 0;“加中文标点”的分隔符为 `["\n\n", "\n", "。", ";", ",", ""]`,keep_separator="end"。语义切分按“。”“;”和换行分句,相邻两句的余弦距离超过该文档全部相邻距离的 p 分位数时切开(与 LangChain SemanticChunker 的 percentile 方式相同,不合并前后句):
+
+```python
+v = emb(sents); dist = 1 - (v[:-1] * v[1:]).sum(1); th = np.percentile(dist, p)
+```
+
+```text
+默认分隔符,100 字,3.1 天数附近: ['员工累计工作已满 1 年不满 10 年的,年休假 5 天;已满 10 年不满 20 年的,年休假 10 天;已满 20 年的,年休假 15']
+方式 切片数 平均字数 最长 R@1 R@3 top3 字数 结尾不在句末
+固定 200 字 26 191 200 0.550 0.825 590 0.88
+递归,默认分隔符,100 字 77 68 98 0.725 0.850 244 0.24
+递归,加中文标点,100 字 77 68 98 0.775 0.925 234 0.00
+递归,默认分隔符,200 字 36 147 198 0.800 0.950 461 0.00
+递归,加中文标点,200 字 36 147 198 0.800 0.950 461 0.00
+按小节切 52 90 134 0.850 0.975 278 0.00
+语义切分,80 分位数 43 122 373 0.850 0.900 485 0.00
+语义切分,90 分位数 24 220 761 0.725 0.900 1303 0.00
+语义切分,95 分位数 14 378 841 0.700 0.875 1543 0.00
+95 分位数最长的切片 841 字 = 696 个 Token
+```
+
+“结尾不在句末”统计正文切片中不以“。”“;”结尾的比例。200 字时段落本身都不超过 200 字,两种分隔符切出的结果相同。
+
### A13 IVF 与 PQ 的召回率
数据为本地收集的 83514 条中文短句(本地收集的问题文本,去重),用 bge-small-zh-v1.5 编码为 512 维向量;随机取 1000 条作查询,其余 82514 条作库,以暴力检索的 Top-10 为准计算 Recall@10。IVF 用 numpy 实现的 k-means(在 3 万条样本上训练 20 轮,nlist = 256);PQ 分 64 段,每段 256 个中心(训练 15 轮),用查表的方式计算内积。numpy 的下标取值开销大,耗时不代表 FAISS 等库的速度,只比较扫描比例:
@@ -2949,6 +3111,7 @@ def qsym(w, bits, group=None, per_channel=True):
s = w.abs().amax(1, keepdim=True) / qmax if per_channel else w.abs().max() / qmax
return torch.clamp(torch.round(w / s), -qmax, qmax) * s
+# qasym 是非对称版本:每组按最小值和最大值确定缩放系数和零点,代码略
sx = x_calib.abs().mean(0) # 每个输入通道激活的平均大小
for a in [i / 10 for i in range(11)]:
s = sx.pow(a); s = s / (s.max() * s.min()).sqrt()
@@ -3168,6 +3331,21 @@ streamable-http tools/call: { "status": "已发货", "tracking_no": "SF1234567
把 ToolError 换成普通的 ValueError 后,订单 9999 的结果变为 `Error executing tool get_order`,isError 同样为 True,错误的具体内容只出现在服务端的日志中。
+参数不符合 Schema 时工具函数不会执行。用同一个客户端传入三种错误的参数(输出截断):
+
+```text
+{"order_id": 1024} -> Error executing tool get_order: 1 validation error for get_orderArguments order_id Input should be a valid string [type=string_type, ...] | isError = True
+{} -> Error executing tool get_order: 1 validation error for get_orderArguments order_id Field required [type=missing, ...] | isError = True
+{"orderId": "1024"} -> Error executing tool get_order: 1 validation error for get_orderArguments order_id Field required [type=missing, ...] | isError = True
+```
+
+把返回值的注解从 dict 改为 TypedDict(字段 status、tracking_no,均为 str)后,tools/list 中多出 outputSchema,调用结果多出 structuredContent;注解为 dict 时两者都为空:
+
+```text
+outputSchema: {"properties": {"status": {"title": "Status", "type": "string"}, "tracking_no": {"title": "Tracking No", "type": "string"}}, "required": ["status", "tracking_no"], "title": "Order", "type": "object"}
+structuredContent: {"status": "已发货", "tracking_no": "SF1234567"}
+```
+
### A25 并发执行工具调用
环境:JDK 21.0.13(Adoptium),Apple M1 Pro。Message、ToolCall 用 record 定义,execute 为桩代码,每次调用休眠 500 毫秒;两个 get_order 调用用正文中的片段并发执行:
@@ -3282,6 +3460,85 @@ memory.get("user-7").forEach(m -> System.out.println(" " + m.getMessageType() +
同一轮之内,工具调用和结果在请求中;下一轮对话时,请求中只剩用户消息和最终回答。
+### A29 HNSW 的距离计算次数与知识库规模
+
+本地收集的 124899 条中文短问题(去重),用 bge-small-zh-v1.5 编码为 512 维并归一化,与员工手册无关,作为干扰文本。
+
+HNSW 用 FAISS 1.15.1 的 IndexHNSWFlat(内积,efConstruction = 40),随机 1000 条作查询、其余 123899 条作库,以暴力检索的 Top-10 为准计算 Recall@10,距离计算次数取自 hnsw_stats:
+
+```python
+idx = faiss.IndexHNSWFlat(512, M, faiss.METRIC_INNER_PRODUCT); idx.hnsw.efConstruction = 40; idx.add(B)
+idx.hnsw.efSearch = ef; faiss.cvar.hnsw_stats.reset(); _, I = idx.search(q, 10)
+ndis = faiss.cvar.hnsw_stats.ndis / len(q)
+```
+
+```text
+暴力检索:每次查询计算 123899 次距离
+M = 16,最高层 5,各层节点数 L0:123899 L1:7912 L2:475 L3:28 L4:2 L5:1
+ efSearch = 16:Recall@10 0.732,每次查询计算 312 次距离(库的 0.3%)
+ efSearch = 64:Recall@10 0.866,每次查询计算 726 次距离(库的 0.6%)
+ efSearch = 256:Recall@10 0.931,每次查询计算 2,175 次距离(库的 1.8%)
+M = 32,最高层 4,各层节点数 L0:123899 L1:3959 L2:122 L3:4 L4:1
+ efSearch = 16:Recall@10 0.851,每次查询计算 546 次距离(库的 0.4%)
+ efSearch = 64:Recall@10 0.936,每次查询计算 1,333 次距离(库的 1.1%)
+ efSearch = 256:Recall@10 0.971,每次查询计算 4,198 次距离(库的 3.4%)
+```
+
+知识库规模:[A12](#a12-切片方式top-k-与相似度阈值) 的 52 个切片(按小节切,加标题路径)中混入 N 条干扰文本,用暴力检索(不用近似索引),40 个问题的 Recall@K 和前 3 个结果中干扰文本的比例:
+
+```text
+不加检索指令前缀
+ 干扰 0 条: R@1 0.850 R@3 0.975 R@10 1.000 前 3 中干扰文本占 0.000
+ 干扰 10000 条: R@1 0.500 R@3 0.850 R@10 0.975 前 3 中干扰文本占 0.333
+ 干扰 124899 条: R@1 0.275 R@3 0.575 R@10 0.675 前 3 中干扰文本占 0.617
+ 年假可以只请半天吗? 正确切片排第 3,相似度 0.659;前 3:
+ 0.798 [干扰] 是否可以请长假?
+ 0.679 [干扰] 下学期是否也可以继续请假实习?
+ 0.659 员工手册(2026 版) > 第 3 章 年假 > 3.2 申请 / 年假以半天为最小单位,可以分段使用……
+问题加检索指令前缀
+ 干扰 0 条: R@1 0.850 R@3 1.000 R@10 1.000 前 3 中干扰文本占 0.000
+ 干扰 124899 条: R@1 0.475 R@3 0.700 R@10 0.800 前 3 中干扰文本占 0.425
+混入全部干扰文本时的 Recall@K(不加前缀)
+ K=1:0.275 K=3:0.575 K=10:0.675 K=20:0.750 K=50:0.900 K=100:0.975
+```
+
+干扰文本和问题都是短问句,写法比切片更接近问题,所以排序受影响大;加检索指令后有所缓解。
+
+### A30 混合检索与重排的召回率
+
+沿用 [A29](#a29-hnsw-的距离计算次数与知识库规模) 的切片、40 个问题和干扰文本,暴力检索。向量检索用 bge-small-zh-v1.5(不加前缀);BM25 取 k1 = 1.2、b = 0.75,中文按相邻两个字切词,英文和数字连续的部分算一个词;RRF 的 k = 60,两路各取前 50;重排用 bge-reranker-v2-m3(Transformers 5.20,MPS,fp32)对 RRF 的前 50 个候选打分。加权 RRF 把 BM25 一路的 1 / (k + rank) 乘以 w:
+
+```python
+for r, j in enumerate(V[i]): sc[j] += 1 / (61 + r)
+for r, j in enumerate(B[i]): sc[j] += w / (61 + r)
+```
+
+```text
+干扰 0 条(共 52 条)
+ 向量 R@1 0.850 R@3 0.975 R@10 1.000
+ BM25 R@1 0.600 R@3 0.775 R@10 0.950
+ RRF R@1 0.800 R@3 0.950 R@10 1.000
+ RRF 前 50 + 重排 R@1 0.900 R@3 1.000 R@10 1.000
+干扰 10000 条
+ 向量 R@1 0.500 R@3 0.850 R@10 0.975
+ BM25 R@1 0.200 R@3 0.475 R@10 0.575
+ RRF R@1 0.400 R@3 0.675 R@10 0.850
+ RRF 前 50 + 重排 R@1 0.800 R@3 0.975 R@10 0.975
+干扰 124899 条
+ 向量 R@1 0.275 R@3 0.575 R@10 0.675
+ BM25 R@1 0.050 R@3 0.200 R@10 0.375
+ RRF R@1 0.275 R@3 0.450 R@10 0.600
+ RRF 前 50 + 重排 R@1 0.700 R@3 0.800 R@10 0.850
+ 结婚可以休几天假? RRF 第 1:每周可以实习几天? → 重排第 1:…… > 4.2 婚假 / 员工……
+ 公司的笔记本电脑丢了怎么办? RRF 第 1:MQ消息丢了怎么办? → 重排第 1:…… > 8.3 设备丢失 / 公司配……
+加权 RRF(BM25 一路乘以 w)
+ 干扰 0 条 w=1.0: R@3 0.950 w=0.5: R@3 1.000 w=0.3: R@3 1.000 w=0: R@3 0.975
+ 干扰 10000 条 w=1.0: R@3 0.675 w=0.5: R@3 0.725 w=0.3: R@3 0.725 w=0: R@3 0.850
+重排耗时:随机 2000 条干扰文本(批大小 32)28.2 s,每条 14.1 ms,外推到 124951 条约 29 分钟
+```
+
+干扰文本是短问句,与口语化的问题共用很多二元词(“几天”“怎么办”),BM25 又偏向短文本,所以干扰越多 BM25 越差,等权融合把向量检索的结果也拉低了;问题中的编号、专有名词较多时结果会不同。
+
## 参考资料
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
@@ -3447,6 +3704,14 @@ memory.get("user-7").forEach(m -> System.out.println(" " + m.getMessageType() +
- [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/)
+- [Best practices for Claude Code](https://code.claude.com/docs/en/best-practices)
+- [Claude Code: Explore the context window](https://code.claude.com/docs/en/context-window)
+- [Claude Code: Choose a permission mode](https://code.claude.com/docs/en/permission-modes)
+- [Claude Code: Checkpointing](https://code.claude.com/docs/en/checkpointing)
+- [Claude Code: Configure the sandboxed Bash tool](https://code.claude.com/docs/en/sandboxing)
+- [AGENTS.md](https://agents.md/)
+- [GitHub Spec Kit: Specification-Driven Development](https://github.com/github/spec-kit/blob/main/spec-driven.md)
+- [Konstantinou M, et al. Can We Find Bugs Using LLM-Generated Oracles? arXiv:2410.21136](https://arxiv.org/abs/2410.21136)
- [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/)
@@ -3466,3 +3731,12 @@ memory.get("user-7").forEach(m -> System.out.println(" " + m.getMessageType() +
- [LangChain v1 Release Notes](https://docs.langchain.com/oss/python/releases/langchain-v1)
- [LangChain Overview](https://docs.langchain.com/oss/python/langchain/overview)
- [Spring AI: Chat Memory](https://docs.spring.io/spring-ai/reference/api/chat-memory.html)
+- [Fast Inference from Transformers via Speculative Decoding](https://arxiv.org/abs/2211.17192)
+- [Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism](https://arxiv.org/abs/1909.08053)
+- [SGLang: Efficient Execution of Structured Language Model Programs](https://arxiv.org/abs/2312.07104)
+- [SGLang: Server Arguments](https://docs.sglang.io/docs/advanced_features/server_arguments)
+- [vLLM: Parallelism and Scaling](https://docs.vllm.ai/en/latest/serving/parallelism_scaling/)
+- [vLLM: Metrics](https://docs.vllm.ai/en/latest/design/metrics/)
+- [vLLM: Speculative Decoding](https://docs.vllm.ai/en/latest/features/speculative_decoding/)
+- [vLLM 源码:v1/core/sched/scheduler.py](https://github.com/vllm-project/vllm/blob/main/vllm/v1/core/sched/scheduler.py)
+- [Ollama FAQ](https://docs.ollama.com/faq)