大模型应用
本文目录
- 大模型应用
- 一、大模型基础
- 二、提示词与上下文
- 三、检索增强生成
- 四、Agent
- 五、工程实践
- 附录:实测
- A1 Embedding 相似度与 BM25
- A2 提示词对工具调用的影响
- A3 客服请求的 Token 构成
- A4 普通摘要与结构化摘要
- A5 长期记忆的提取与更新
- A6 意图路由的三种做法
- A7 工具出错时 Agent 的表现
- A8 LangGraph 的 checkpoint 与人工确认
- A9 Spring AI 的工具调用循环
- A10 模型裁判的位置偏差与长度偏差
- A11 PDF 文本层与扫描件 OCR
- A12 切片方式、Top-K 与相似度阈值
- A13 IVF 与 PQ 的召回率
- A14 忠实度与答案相关性
- A15 向量索引的内存估算与 NDCG 核对
- A16 Qwen2.5-7B 与 Qwen3-30B-A3B 的参数量
- A17 SFT 样本的损失掩码
- A18 权重量化的误差与困惑度
- A19 Agent 中的工具幻觉
- A20 Agent 任务的 Token 数与前缀缓存
- A21 batch 大小与吞吐、延迟
- A22 模型输出 JSON 的修复、校验与重试
- A23 LoRA 的三种初始化
- A24 MCP 服务端的最小实现
- A25 并发执行工具调用
- A26 停止前校验
- A27 Embedding 的池化与批内负样本
- A28 Spring AI 的对话记忆与工具调用
- A29 HNSW 的距离计算次数与知识库规模
- A30 混合检索与重排的召回率
- A31 注意力分数的缩放
- A32 语义缓存的相似度
- A33 模型生成 SQL 的权限限制
- A34 Text2SQL 的口径与语义层
- 参考资料
本篇面向准备 AI 应用、Agent 开发岗位,但之前没有接触过大模型开发的程序员。
一、大模型基础
Token 与分词
大语言模型(Large Language Model, LLM)的输入和输出都以 Token 为单位。Token 是分词器切出来的文本片段,可能是一个单词、单词的一部分、一个汉字或者几个汉字。模型内部只处理 Token 在词表中的编号。
常用的分词方法是字节对编码(Byte Pair Encoding, BPE):从单个字符开始,反复把语料中出现次数最多的相邻片段合并成一个新 Token,直到词表达到预定的大小。例如语料中 cat、cats、car、card 分别出现 5、3、4、2 次:
初始 c a t ×5 c a t s ×3 c a r ×4 c a r d ×2
第 1 次合并 c + a(14 次) ca t ca t s ca r ca r d
第 2 次合并 ca + t(8 次) cat cat s ca r ca r d
第 3 次合并 ca + r(6 次) cat cat s car car d
第 4 次合并 cat + s(3 次) cat cats car car d
高频词最终成为一个 Token,低频词被拆成几个片段,例如没见过的 cart 会被拆成 car 和 t,因此任何文本都能被表示。目前的大模型大多在字节上做 BPE,汉字按 UTF-8 编码之后同样可以切分。
Token 数决定了两件事:
- 上下文窗口(Context Window):模型一次能处理的 Token 总数,输入和输出都计算在内;
- 费用:接口按输入和输出的 Token 数计费。
同样的文字在不同模型中对应的 Token 数不同,例如 DeepSeek 的文档给出的估算是 1 个中文字符约 0.6 个 Token,1 个英文字符约 0.3 个 Token。准确的数量以接口返回的 usage 字段为准。
Transformer 结构
1. 整体结构
最初的 Transformer 用于机器翻译,由编码器和解码器组成:编码器双向地读取原文,解码器逐个生成译文。BERT 只保留了编码器,常用于文本分类和 Embedding;GPT 系列只保留了解码器,目前的大模型基本都采用这种结构(Decoder-only)。
只用解码器的好处是训练目标统一:任何一段文本都可以直接用来训练“预测下一个 Token”,不需要成对的输入和输出;理解问题和生成回答也由同一个结构完成,生成时前文的计算结果可以缓存复用,见下文的 KV Cache。
以 Qwen2.5-7B 为例,输入的 Token 依次经过三部分:
- 词嵌入(Embedding)把每个 Token 编号转换为一个向量,Qwen2.5-7B 中是 3584 维;
- 向量依次经过几十个结构相同的层,Qwen2.5-7B 有 28 层。每层先用注意力从前文收集信息,再用前馈网络(Feed-Forward Network, FFN)对每个位置单独加工;
- 最后一个位置的向量经过线性层,得到词表中每个 Token 的分数(logits),再经过 softmax 转为概率。
每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练;归一化把每个向量的数值缩放到稳定的范围。这里用的是 LayerNorm 一类的方法,只用一个 Token 自己向量的各个维度计算统计量,而不像 BatchNorm 那样依赖同一批中的其它样本,所以不受批大小和序列长度影响,训练和推理时的计算也相同。
最初的 Transformer 在残差相加之后做归一化(Post-Norm),目前的大模型大多改为在每个子层之前做(Pre-Norm),残差的主干上不再经过归一化,最后一层之后再做一次归一化。这样训练开始时各层的梯度更平稳,可以不用或少用学习率预热。这些模型还使用计算更简单的 RMSNorm,Qwen2 系列就是这样。RMSNorm 的论文认为 LayerNorm 起作用的主要是缩放,而不是减去均值,所以只把向量除以均方根 √(mean(x²) + ε) 再乘以增益,少算一次均值,论文的实验中效果与 LayerNorm 相当。
三种结构的注意力有两处不同。编码器中每个 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. 参数都在哪里
模型的参数几乎都在每层的几个矩阵中。按 Qwen2.5-7B 的 config.json 逐个计算:
- 前馈网络先把向量从 3584 维放大到 18944 维,再压回 3584 维,三个矩阵占了全部参数的 75%。Qwen2.5 的前馈网络使用 SwiGLU,即 down_proj(SiLU(gate_proj(x)) ⊙ up_proj(x)):gate_proj 的输出经过激活函数后与 up_proj 的输出逐元素相乘,再交给 down_proj。它比原始的前馈网络多一个矩阵,SwiGLU 的论文和 LLaMA 把中间维度取为 4d 的 2/3,使参数量不变;Qwen2.5-7B 的 18944 是 3584 的约 5.29 倍,参数量约为原始前馈网络的两倍;
- 注意力的 k_proj、v_proj 只有 q_proj 的七分之一,这是因为 28 个查询头共享 4 组 K、V,见下文的 GQA;
- 加起来共 7,615,616,512 个参数,与模型权重文件中记录的大小(15,231,233,024 字节,每个参数 2 字节)一致(见附录 A16)。
下文的 LoRA 和量化,处理的都是这些矩阵。
3. 混合专家
普通的稠密(Dense)模型中,每个 Token 都要经过全部参数。混合专家(Mixture of Experts, MoE)把每层的前馈网络换成多个结构相同、参数独立的前馈网络,称为专家,再加一个路由器。路由器是一个线性层,为每个专家打分,每个 Token 只交给得分最高的 k 个专家处理,输出按分数加权求和:
这样做的目的是用更多的参数存储知识,而每个 Token 的计算量不随之增加。例如 Qwen3-30B-A3B 有 48 层,每层 128 个专家,每个 Token 选 8 个,总参数 30.5B,每个 Token 实际用到的参数只有 3.3B(按 config.json 计算,与模型卡一致,见附录 A16)。
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 改为给每个专家的路由分数加一个只用于选择专家的偏置,某个专家负载过高时调低它的偏置,辅助损失只保留一项权重极小的,防止单个序列内极度不均衡。
辅助损失通常在微批次(micro-batch)内计算,一个微批次只有很少几个序列,近似于要求每个序列的 Token 都均匀分给所有专家,代码这类领域集中的序列也不例外。Qwen3 仍用辅助损失,但在整个全局批次上统计各专家的负载。DeepSeek-V3 和 Qwen3 都主要在整个批次上约束均衡,减轻了对单个序列的要求,有利于专家按领域分工。
注意力机制
注意力(Attention)用于让每个 Token 从其它 Token 中收集信息。例如在“小猫很饿”中,处理“饿”时需要知道饿的是谁,就要把注意力集中到“小猫”上。
每个 Token 的向量分别乘以三个参数矩阵,得到三个向量:
- 查询(Query, Q):当前 Token 要找什么样的信息;
- 键(Key, K):自己能被什么样的查询找到;
- 值(Value, V):被找到之后提供的信息。
不直接用 Token 的向量互相做点积,是因为那样分数矩阵是对称的,“饿”找“小猫”与“小猫”找“饿”得分相同,向量长度相同时每个 Token 与自己的分数还总是最高。Q、K 分开投影后,相关性可以是单向的;V 单独投影,使提供的信息不必等于用来匹配的特征。
计算公式为 softmax(QKT / √d) V。以“饿”为例:
- 用“饿”的 Q 与每个 Token 的 K 做点积得到相关性分数,再除以 √d,d 是 Q、K 向量的维度,多头注意力中是每个头的维度(Qwen2.5-7B 为 3584 ÷ 28 = 128);
- 对分数做 softmax 得到权重,权重之和为 1,其中“小猫”的权重最大,为 0.70;
- 按权重对所有 Token 的 V 求和,得到“饿”这个位置的输出,它的大部分信息来自“小猫”。
除以 √d 是因为:当向量的各个分量相互独立、均值为 0、方差为 1 时,点积的方差等于 d。维度越大分数之间的差距越大,softmax 的结果几乎全部集中到一个 Token 上,梯度接近 0,训练变得困难。除以 √d 之后,点积的方差恢复为 1。
例如按 128 维的随机向量、128 个 Token 计算,不缩放时分数的标准差约为 11,约三成的情况下最大的权重超过 0.99;除以 √d 后一次也没有出现。如果改为除以 d,分数的方差变为 1/d,本例中标准差只剩约 0.09,权重接近均匀(numpy 模拟,见附录 A31)。这是按随机初始化时的假设推出的,训练之后的 Q、K 并不满足这个假设。
softmax 把任意分数变成非负、和为 1 的权重,处处可导,指数又拉大了高分与低分的差距。分数不直接用余弦相似度,是因为它的范围只有 −1 到 1,拉不开差距:上下文有 1000 个 Token 时,即使一个 Token 的余弦为 1、其余都是 −1,它的权重也只有 0.7%,要用余弦就得再乘一个较大的系数;点积则保留了向量长度,模型可以靠长度表达匹配的强弱。
生成文本时,一个 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)。RoPE 只旋转 Q 和 K,没有需要训练的参数。不旋转 V,是因为位置只需要影响“和谁相关”;V 是被加权汇总的内容,旋转它会把各 Token 的绝对位置带进输出。超过训练长度时怎样扩展上下文,见进阶:旋转位置编码与长度外推。
1. 多头注意力
单头注意力中,每个 Token 只得到一组权重,只能按一种标准挑选信息。多头注意力(Multi-Head Attention, MHA)把 Q、K、V 都切成 h 份,每份独立计算注意力,再把各头的结果拼接起来,乘以输出矩阵 WO 混合在一起。不同的头可以关注不同的关系,例如一个头寻找指代的对象,另一个头关注相邻的词。头数也不是越多越好:Transformer 论文在计算量不变时比较了不同的头数,单头的 BLEU 比 8 头低 0.9,32 头也比 8 头低。
以 Qwen2.5-7B 的查询为例,3584 维的向量分成 28 个头,每个头 128 维,形状的变化如下:
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 手写的实现见进阶:多头注意力的实现。
2. 计算量与 FlashAttention
序列长度为 n、向量维度为 d 时,QKT 和乘以 V 各需要约 n2d 次乘加,时间复杂度为 O(n2d),分数矩阵占用 O(n2) 的显存。上下文较短时,计算量主要在线性层,上下文越长,注意力所占的比例越大,按 Qwen2.5-7B 的配置计算,两者在约 3.2 万个 Token 时相当。
降低这部分开销有两类做法。一类让每个 Token 只看一部分 Token,或者改写 softmax,结果与标准注意力不同:例如 Mistral 7B 的滑动窗口注意力中,每层的每个 Token 只看前 4096 个 Token;线性注意力用核函数代替 softmax,利用矩阵乘法的结合律把复杂度降为 O(n)。另一类结果不变,只减少显存的读写,即 FlashAttention。
FlashAttention 把计算分块放进 GPU 的片上缓存完成,n × n 的分数矩阵从不写回显存,再用在线 softmax 逐块累积,结果在数学上与标准注意力相同,不是近似。它减少的是显存的读写而不是计算量,原理和数值例子见进阶:计算量与 FlashAttention。
自回归生成与 KV Cache
模型每次只预测下一个 Token:把已有的 Token 序列输入模型,得到下一个 Token 的概率分布,按采样参数选出一个追加到序列末尾,再继续预测,直到生成结束标记。这种生成方式称为自回归(Autoregressive)。
由于因果掩码,前文 Token 的 K 和 V 在后续步骤中不会再变化,可以缓存起来重复使用,这就是 KV Cache。Q 不需要缓存:每一步只有新 Token 要计算注意力,用的是它自己的 Q,前面 Token 的 Q 之后不会再用到。因此一次请求分为两个阶段:
- 预填充(Prefill):一次性并行处理整个输入,计算并缓存所有输入 Token 的 K 和 V,决定了首个 Token 的延迟;
- 解码(Decode):每一步只为新 Token 计算 Q、K、V,再与缓存中的 K、V 计算注意力,逐个生成,决定了后续的输出速度。
KV Cache 是用显存换计算。每个 Token 要在每一层缓存 K 和 V 两个向量,占用的显存为:
2 × 层数 × KV 头数 × 每个头的维度 × 每个数值的字节数
以 Qwen2.5-7B 为例,28 层、4 个 KV 头、每个头 128 维,使用 BF16(2 字节),每个 Token 占用 2 × 28 × 4 × 128 × 2 = 56 KB,一个 32K Token 的请求占用 1.75 GB。模型权重本身约 14 GB,几个长上下文请求的 KV Cache 就能和权重一样大,所以 KV Cache 的大小直接决定了一张显卡能同时服务多少个请求。
减少 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。DeepSeek-V3 每个 Token 只缓存 68.6 KB,与 Qwen2.5-7B 的 56 KB 相近,细节见进阶:多头潜在注意力;
- PagedAttention:推理框架 vLLM 把 KV Cache 切成固定大小的块按需分配,不再按最大长度预留连续的显存,相同前缀的请求还可以共享块,见推理服务部署;
- 量化:用 8 位甚至更少的位数存储 K、V。
GQA 减少的是缓存和读取的数据量,注意力的计算量基本不变:查询头仍是 28 个,每个头照样与全部的 K 计算分数。它能加快解码,是因为解码每一步都要从显存读出全部 KV Cache,速度受显存带宽限制(见批处理)。一个 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。
采样参数
模型输出的是下一个 Token 的概率分布,最终选哪个 Token 由采样参数决定:
- 贪心解码:总是选概率最高的 Token,结果基本确定(线上服务中仍可能有细微差别,见批处理),但容易陷入重复;
- 温度(Temperature):把每个 Token 的分数 z 除以温度 T 之后再做 softmax,T < 1 时高概率的 Token 更突出,T > 1 时分布变平;
- Top-k:只在概率最高的 k 个 Token 中采样;
- Top-p:按概率从高到低累加,只在累计概率刚达到 p 的那些 Token 中采样,候选数量随分布自动变化。
例如原始分布为 0.50、0.22、0.16、0.07、0.05,温度为 0.5 时最高的概率升到 0.75,温度为 1.5 时降到 0.40。Top-p 为 0.8 时,累加到第 3 个 Token 达到 0.88,后两个 Token 被排除,剩下的重新归一化。
几个参数同时设置时,顺序也有影响:vLLM 和 Transformers 先除以温度,再做 Top-k、Top-p;llama.cpp 默认最后才应用温度。温度不改变排序,但会改变 Top-p 留下几个 Token,例如上面的分布在温度 0.5、Top-p 0.8 时,先做温度只留下 2 个(0.75 + 0.15),后做温度留下 3 个。
请求中不传采样参数时,生效的往往是模型自带的 generation_config.json,例如 vLLM 默认读取它,Qwen2.5-7B-Instruct 中设的是温度 0.7、Top-p 0.8、Top-k 20,以及压低重复内容的 repetition_penalty 1.05。温度设为 0 时,vLLM 按贪心解码处理;Transformers 则要设置 do_sample=False。
抽取字段、生成代码、调用工具等需要稳定结果的场景使用较低的温度,写作类场景可以适当调高。
训练过程
1. 三个阶段
一个可以对话的大模型通常经过三个阶段的训练:
- 预训练(Pre-training):在海量文本上学习预测下一个 Token,模型获得语言能力和世界知识,但只会续写文本;
- 监督微调(Supervised Fine-Tuning, SFT):用人工编写的指令和回答继续训练,模型学会按对话的格式回答问题、遵循指令,数据怎样构造见下文的微调;
- 偏好对齐:让回答更符合人的偏好,例如更有帮助、拒绝有害的请求。
SFT 之后还要做偏好对齐,是因为 SFT 只告诉模型“这个回答是对的”,偏好数据告诉模型“两个回答中哪个更好”。有用、安全这类目标很难写出标准答案,但比较两个回答的好坏要容易得多。
2. RLHF 与 PPO
基于人类反馈的强化学习(Reinforcement Learning from Human Feedback, RLHF)是最早用于大模型的对齐方法,InstructGPT 分三步完成:
- SFT:约 1.3 万条提示词和人工编写的回答;
- 训练奖励模型:对同一个提示词,标注员把模型生成的 4~9 个回答按好坏排序,每两个回答组成一对,9 个回答可以组成 36 对;
- 强化学习:用近端策略优化(Proximal Policy Optimization, PPO)算法,让模型对提示词生成回答,奖励模型打分,再根据分数更新模型。
奖励模型输入提示词和回答,输出一个分数,训练目标是让较好回答的分数高于较差回答,损失为 -log σ(rw - rl)。例如两个分数分别为 1.2 和 0.3,损失为 -log σ(0.9) ≈ 0.34,分差越大损失越小。
PPO 更新模型时不直接使用奖励的高低,而是使用优势(Advantage):一个回答的实际得分比预期高多少。预期由价值模型(Critic)给出,它估计“从当前位置继续生成,平均能得多少分”,InstructGPT 中价值模型从奖励模型初始化。只看奖励时,简单的题目每个回答都得高分,模型分不清哪个回答真正更好;减去预期之后,比预期好的回答被强化,比预期差的被抑制。
把生成过程看作强化学习时,奖励模型的分数在回答结束时才给出,每个 Token 的优势用广义优势估计(Generalized Advantage Estimation, GAE)由价值模型的逐步估计算出,计算方法和例子见进阶:广义优势估计。
奖励模型只是人类偏好的近似,模型可能找到它的漏洞,生成得分高但没有意义的回答。所以 PPO 在奖励中减去新模型与参考模型(冻结的 SFT 模型)之间的 KL 散度,限制模型偏离太远。
生成回答的代价很高,所以 PPO 用同一批回答更新多次(拆成多个小批量,或者训练多轮),而这批回答是更新前的旧模型生成的。用新旧模型生成同一个 Token 的概率之比给优势加权,就可以用旧模型的样本估计新模型的效果,这种方法称为重要性采样。比值离 1 越远,估计越不可靠,所以 PPO 把它截断在 [1 - ε, 1 + ε] 之内,ε 通常取 0.2:优势为正时比值超过 1 + ε、优势为负时比值低于 1 - ε,这个 Token 就不再提供梯度,避免一次更新的步子太大;朝相反方向偏离的部分不截断,仍然会被纠正。以旧概率为 0.20 的一个 Token 为例:
因此 PPO 训练时要同时加载 4 个模型:策略模型(正在训练的模型)、价值模型、奖励模型和参考模型,其中策略模型和价值模型都要训练。后来的 DPO 和 GRPO 都减少了模型的数量:
3. DPO
直接偏好优化(Direct Preference Optimization, DPO)不训练奖励模型,也不在训练中生成回答,直接用标注好的偏好对 (提示词, 较好的回答, 较差的回答) 训练,只需要策略模型和参考模型。
不需要单独的奖励模型,是因为它的推导表明,最优的策略模型本身就隐含了一个奖励:r(x, y) = β log (π(y|x) / πref(y|x)),即回答在新模型中的对数概率比参考模型高出多少。严格地说还要加上一个只与提示词有关的项,它在两个回答的奖励相减时抵消,所以不必计算。
把这个隐式奖励代入奖励模型的损失,就得到 DPO 的损失函数,其中 yw、yl 分别是较好和较差的回答,β 控制偏离参考模型的程度:
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 在拉高较好回答概率的同时压低较差回答的概率,是一个二分类问题。
参考模型和 β 起到了 PPO 中 KL 散度的作用,所以 DPO 不需要再单独加 KL 项。需要注意的是,这个结论来自推导中的最优解。IPO 论文指出,偏好越接近确定,这个隐式的约束越弱;在论文只有两个回答的例子中,一方总是胜出时,最优解会把较差回答相对较好回答的概率比压向 0,β 取多大都拦不住;样本少时,经验上的偏好更容易呈现为一方总是胜出。IPO 等方法针对这一点修改了目标函数。
DPO 的损失只取决于两个回答的概率之差,需要注意的是:
- 训练中较好回答的概率也可能下降,只要较差回答下降得更多,损失仍在减小,两个回答只差几个 Token 时更明显;
- 偏好数据中较好的回答普遍更长时,模型会学到“越长越好”,输出越来越长,构造数据时要控制两个回答的长度差异。SimPO 从算法上缓解这个问题:用回答的平均对数概率作为隐式奖励,即按长度归一化,同时去掉了参考模型。
训练中除了损失,还要看隐式奖励的准确率(较好回答的隐式奖励更高的比例)、两者的差距,以及较好回答的对数概率是否在下降;训练后用模型裁判对比新旧模型的胜率,并用通用基准检查其它能力有没有退化。
偏好对通常这样构造:让 SFT 模型对同一个提示词生成多个回答,由人工或者模型裁判选出较好和较差的一对。数据最好来自待训练模型自己的输出,DPO 论文中参考模型就是生成偏好数据的 SFT 模型,两者的分布一致;没有这样的 SFT 模型时,先用较好的回答做一次 SFT 作为参考模型,以减小分布的差异。
4. GRPO 与三者的选择
组相对策略优化(Group Relative Policy Optimization, GRPO)由 DeepSeekMath 提出,DeepSeek-R1 也使用了它。它去掉了价值模型:对同一个问题生成一组回答,用组内的平均分作为预期,比平均分高的回答得到正的优势,低的得到负的优势。
去掉价值模型有两个原因:价值模型通常和策略模型一样大,显存和计算都要多一份;奖励只在回答结束时给出,很难训练出在每个 Token 上都准确的价值估计,推理模型的长思维链中前面的内容还可能被后面推翻,只看一部分回答更难预测最终得分。DeepSeek-R1 论文的对比实验中,PPO 把 GAE 的 λ 从常用的 0.95 调到 1.0 后,在 MATH 上的效果接近 GRPO,所以 GRPO 的好处主要是省资源、少调参。
例如对一道数学题生成 4 个回答,答对得 1 分、答错得 0 分,得分为 [1, 0, 0, 1],平均分 0.5、标准差 0.5,每个回答的优势 (得分 - 平均分) / 标准差 为 [1, -1, -1, 1],答对的回答被强化,答错的被抑制。
如果 4 个回答全对或全错,优势都是 0,这道题对训练没有贡献,所以题目的难度要适中。数学、代码这类能自动判断对错的任务可以直接用规则打分,不需要奖励模型。DeepSeekMath 中的 GRPO 仍使用训练出的奖励模型,DeepSeek-R1 对推理任务改用规则打分,原因是神经网络的奖励模型在大规模强化学习中容易被模型钻空子(Reward Hacking),对通用的对话数据仍使用奖励模型。
规则打分同样会被钻空子。例如以测试通过为奖励训练写代码的模型时,模型可能修改测试文件,或者在断言执行之前调用 sys.exit(0),让测试进程以 0 退出、看起来全部通过。所以测试和打分代码要放在模型改不到的地方,并按测试的实际结果而不是进程的退出码判分。
GRPO 把 KL 散度直接加在损失中,概率之比的限制与 PPO 相同。PPO 把每个 Token 的 KL 惩罚加在奖励中,强化学习最大化的是累计奖励,回答越长累计的惩罚越多,DeepSeek-R1 的作者认为这可能抑制回答变长;GRPO 的 KL 项不进入优势的计算。R1 的训练中还每 400 步把参考模型换成最新的策略模型,让模型在长时间训练中能继续探索。
代价是每道题要生成一组回答,DeepSeekMath 中每题采样 64 个,DeepSeek-R1 中每题 16 个,生成的开销很大。
大规模训练推理模型时,GRPO 还暴露出全对或全错的题没有梯度、熵坍塌、长度偏差和 MoE 模型训练不稳定等问题,DAPO、GSPO 等算法分别做了改进,见进阶:GRPO 的改进。
| PPO | DPO | GRPO | |
|---|---|---|---|
| 训练数据 | 提示词,奖励模型打分 | 离线的偏好对 | 提示词,奖励模型或规则打分 |
| 训练中生成回答 | 是 | 否 | 是,每题一组 |
| 预期得分的来源 | 价值模型 | 不需要 | 组内平均分 |
| 加载的模型 | 4 个,训练 2 个 | 2 个,训练 1 个 | 2~3 个,训练 1 个 |
| 7B 模型状态的显存(不含激活) | 约 274 GB | 约 137 GB | 约 137~152 GB |
三者的选择:DPO 实现简单、训练稳定,适合已有偏好数据的对齐;PPO 和 GRPO 在训练中生成新的回答,能探索出训练数据之外的更好回答,推理能力的训练通常使用 GRPO 这类强化学习方法。DPO 本身没有探索,只能在已有的偏好对之间学习,常见的补救是迭代 DPO:用训练后的模型重新生成回答、构造新的偏好对,再训练一轮。
DPO 与 PPO 谁效果更好取决于实验设置:DPO 论文在 6B 以下的模型上报告与 PPO 相当或更好;Xu 等人把 PPO 调好之后,在他们的对话和代码实验中 PPO 都超过了 DPO,并指出 DPO 对偏好数据之外的回答缺少约束,可能学到偏向这类回答的策略。迭代 DPO 在他们的实验中缩小了一部分差距,但没有消除。
微调
微调指的是用特定任务的数据继续训练模型,最常见的形式是 SFT。它改变的主要是模型的行为:按什么格式回答、用什么语气、怎样完成某种专门的任务,而不是让模型记住新的知识。
1. 什么时候微调
RAG、微调和提示词解决的是不同的问题:
| 需求 | 方案 |
|---|---|
| 回答需要用到私有资料或者经常更新的知识 | RAG |
| 固定输出风格和格式,或者学会某种专业任务 | 微调 |
| 调整任务要求,补充少量规则和示例 | 提示词 |
以电商客服为例:
- 退货政策、商品参数经常变化,回答还要给出依据,用 RAG;
- 要求每次都按公司的话术回复,并输出固定字段的 JSON 工单,提示词和少样本示例仍然经常出错时,用微调;
- 只有 200 组固定的问答,按每组 100 个 Token 估算共约 2 万个 Token,可以直接放进提示词,配合前缀缓存降低成本,没有必要微调。
通常先尝试提示词,再考虑 RAG,最后才考虑微调。微调需要准备训练数据,基座模型升级后要重新训练,而且写入参数的知识难以更新,也无法给出来源。
用微调灌输新知识的效果也不好。有实验表明(全参数微调),SFT 数据中包含模型预训练时不知道的事实时,这些样本学得明显更慢,而一旦被学会,模型编造答案的倾向随之增加。在这项闭卷问答的实验中,负面影响主要出现在训练后期,表现为过拟合,按验证集提前停止训练、去掉模型不知道的样本,或者把它们的答案改为“不知道”,都能减轻。
LIMA 只用 1000 条精选的样本微调 65B 的模型就得到了很好的对话效果,作者据此提出假说:知识和能力几乎都在预训练中学到,对齐主要教会模型回答时用什么格式。
2. SFT 数据
SFT 的样本是一段完整的对话,通常每行一条 JSON:
{"messages": [
{"role": "system", "content": "你是电商客服助手,回答要简短。"},
{"role": "user", "content": "拆封的耳机能退吗?"},
{"role": "assistant", "content": "不影响二次销售的话,签收 7 天内可以退。"}
]}
训练时按模型的对话模板把消息拼成一段文本,训练目标仍然是预测下一个 Token,但只对助手回答的部分计算损失:
这条样本共 48 个 Token,前 31 个是系统消息、用户消息和助手回答的开头,标签设为 -100,不计入损失;后 17 个是回答和结束标记(Qwen2.5 分词器实测,见附录 A17)。
如果提示部分也计入损失,模型会花力气学习复述用户的问题。结束标记一定要计入,否则模型学不会在哪里停止。多轮对话的样本中,每一轮助手的回答都计入损失。训练 Agent 的样本是完整的工具调用轨迹,同样只对模型的输出计算损失,包括它生成的工具调用;工具返回的结果由环境给出,模型不需要学会生成它,不计入损失。
构造数据时,质量和多样性比数量更重要:
- 数量级:LIMA 用 1000 条,InstructGPT 的 SFT 数据约 1.3 万条提示词。LIMA 的消融实验中,只增加数量而不增加问题的多样性,收益很快递减;
- 来源:从线上真实的对话中挑选,脱敏之后由人工修改成标准的回答;线上发现的错误回答(Badcase)修正后补充进训练集,形成闭环;
- 覆盖:常见问题、边界情况和应该拒绝或转人工的情况都要有,回答的风格保持一致,否则模型学到的是混乱的风格;
- 评测:单独留一份不参与训练的评测集,按任务定指标,例如工单场景统计 JSON 能解析的比例和各字段的准确率,话术和语气由人工或模型裁判比较微调前后的回答;同时准备一份通用能力的评测集,检查下文的灾难性遗忘。
3. LoRA
全参数微调要更新所有参数。混合精度训练时,每个参数要保存 BF16 的参数和梯度、FP32 的参数副本和 Adam 优化器的两个状态,共 16 字节,Qwen2.5-7B 只算这部分就需要 7.6B × 16 ≈ 122 GB 显存,还不包括激活。
低秩适配(Low-Rank Adaptation, LoRA)冻结原有的参数矩阵 W,只训练两个小矩阵 B 和 A,用它们的乘积 BA 表示参数的变化量。它基于这样的观察:微调时参数的变化量通常是低秩的,用很少的参数就能表示。
输出为 h = Wx + (α / r) · BAx:
- 秩 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),但有研究发现默认的方式能使用更大的学习率,效果平均更好。
按 Qwen2.5-7B 的真实维度计算(见附录 A16):
| 作用的矩阵 | r | 可训练参数 | 占全部参数 |
|---|---|---|---|
| q_proj、v_proj | 8 | 252 万 | 0.033% |
| 注意力的 4 个矩阵 | 16 | 1009 万 | 0.13% |
| 全部 7 个线性层 | 16 | 4037 万 | 0.53% |
| 全部 7 个线性层 | 64 | 1.61 亿 | 2.12% |
以 q_proj 为例,3584 × 3584 的矩阵有 1284 万个参数,r = 8 时只增加 8 × (3584 + 3584) = 57,344 个。
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;
- 推理无额外开销:训练完成后把 (α / r) · BA 直接加到 W 上,模型结构不变;
- 便于切换:同一个基座模型可以保存多份 LoRA 参数,用于不同的任务,上表第三行的配置以 BF16 保存只有约 81 MB。
需要注意的是,LoRA 减少的主要是显存,而不是计算量:前向和反向传播仍然要经过完整的 W,梯度也要穿过每一层传回去,只是不为 W 计算和保存参数的梯度。
LoRA 属于参数高效微调(Parameter-Efficient Fine-Tuning, PEFT)。这类方法都冻结原模型、只训练少量新增的参数,区别在于参数加在哪里:
- Adapter:在每层中插入一个先降维再升维的小网络。在 BERT 上只增加 3.6% 的参数,GLUE 上的效果与全参数微调只差 0.4%,但它是串行插入的额外层,不能合并进原矩阵,推理时增加延迟;
- 软提示(Soft Prompt):在输入前拼接若干个可训练的向量,称为虚拟 Token。Prompt Tuning 只加在输入层,模型达到几十亿参数后才接近全参数微调;Prefix Tuning 在每一层都加,只训练约 0.1% 的参数;P-Tuning 用一个小网络生成这些向量。它们不改动模型的权重,但要占用上下文长度;
- DoRA:把每个权重矩阵分解为大小和方向两部分,方向用 LoRA 更新,大小单独训练,训练后同样能合并,论文中在多项任务上优于 LoRA。
LoRA 最常用,是因为它训练后可以合并进原矩阵,既不增加推理延迟,也不占用上下文。
4. QLoRA 与全参数微调
QLoRA 进一步把冻结的原始参数量化为 4 位存储,计算时临时还原为 BF16,梯度只流向 LoRA 参数:
- 4 位 NormalFloat(NF4):按正态分布设计的 16 个取值,比均匀分布的 INT4 更贴合权重的分布,每 64 个参数共用一个缩放系数;
- 双重量化:把缩放系数本身再量化一次,平均每个参数再节省约 0.37 位;
- 分页优化器:显存不足时把优化器状态临时换出到内存。
Qwen2.5-7B 的权重在 4 位下约 5.5 GB,比 BF16 的 15.2 GB 少了 10 GB 左右,其中约 2.2 GB 是保持 BF16 的嵌入层和输出层(估算);论文中 65B 的模型可以在一张 48 GB 的显卡上微调。
全参数微调与 LoRA 的取舍:在代码和数学上的对比实验中,LoRA 在目标领域的效果明显不如全参数微调,但更好地保留了原有的能力;全参数微调学到的变化量的秩是常见 LoRA 配置的 10~100 倍。因此:
- 数据多、与预训练差异大的任务,例如学习一门新的编程语言、大量的领域语料,并且有足够的显卡,用全参数微调;
- 只调整格式、风格或者某个具体任务,数据只有几千条,或者要同时维护多个任务,用 LoRA;
- 显卡显存不够放下 BF16 的权重时,用 QLoRA。
全参数微调一张卡放不下时要用多张卡。DeepSpeed 的 ZeRO 和 PyTorch 的 FSDP 把优化器状态、梯度和参数切分到各张卡上,例如 Qwen2.5-7B 在 8 张卡上训练时,全部切分后每张卡的模型状态从 121.8 GB 降到 15.2 GB,见进阶:ZeRO 与 FSDP。
LoRA 微调的效果不好时,按以下顺序检查:
- 数据:先抽查样本的质量,确认训练时的对话模板与推理时一致、只对回答计算损失,见上文的 SFT 数据;
- 学习率:LoRA 的最优学习率约为全参数微调的 10 倍,QLoRA 对 7B、13B 模型使用 2 × 10-4,33B、65B 减半,照搬全参数微调的学习率(例如 LIMA 用的 1 × 10-5)会学得过慢;
- 作用范围和秩:只作用在注意力上时改为全部线性层;数据量大、与预训练差异大时增大 r,仍然不够就改用全参数微调;
- 批量:在一些设置下,批量增大后 LoRA 的损失比全参数微调上升得更多,增大 r 也不能缩小差距,批量较大时可以先试着减小批量;
- 过拟合:训练损失持续下降而验证集上变差,减少轮数,增加数据的多样性。
5. 灾难性遗忘
灾难性遗忘(Catastrophic Forgetting)指的是模型在新数据上训练之后,原有的能力明显下降,例如只用客服对话微调之后,写代码和多轮推理的能力变差,也就是常说的“微调之后变傻了”。对 1B 到 7B 模型的持续微调实验中普遍出现了遗忘,而且模型越大遗忘越严重。
缓解方法:
- 在训练数据中混入一部分通用的指令数据;InstructGPT 在 PPO 阶段混入预训练数据,减少了公开评测集上的退化;
- 用 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)。这是全部参数都按同样的位数折算的值。嵌入层和输出层通常不量化,Qwen2.5-7B 的这两层不共享权重,约 10.9 亿个参数,所以官方发布的 INT4(AWQ)模型权重实际约 5.6 GB。
同样是 16 位,FP16 和 BF16 的分配不同:FP16 用 5 位指数、10 位尾数,最大只能表示 65504;BF16 用 8 位指数、7 位尾数,表示范围与 FP32 接近,精度更低。例如 70000 存成 FP16 会溢出为无穷大,存成 BF16 是 70144;0.1 存成 FP16 是 0.09998,存成 BF16 是 0.10010。用 FP16 训练要靠损失缩放(Loss Scaling)避免梯度过小变成 0,BF16 一般不需要,所以大模型多用 BF16。
FP8 有两种编码:E4M3(4 位指数、3 位尾数)最大 448,E5M2(5 位指数、2 位尾数)最大 57344,0.1 分别存为 0.1016 和 0.0938。每个张量只用一个缩放系数时,通常权重和激活用 E4M3,梯度用 E5M2;缩放的粒度更细时,梯度也可以用 E4M3,例如 DeepSeek-V3 前向计算时激活按 1×128、权重按 128×128 分组缩放,以 FP8 计算的张量统一用 E4M3(arXiv 2412.19437)。
在 NVIDIA 显卡上,FP8 的矩阵乘法需要 Ada、Hopper 及以后的架构;在 Turing、Ampere 上,vLLM 只把 FP8 当作权重的存储格式,计算时转回 16 位(W8A16)。
量化不仅省显存,还能加快生成。这是因为解码阶段每生成一个 Token,都要把全部权重从显存读一遍,而计算量很小,速度受显存带宽限制,权重越小读得越快。推理服务的整体显存怎样估算,见显存:权重与 KV Cache。
1. 怎样量化
权重能够量化,是因为它们的数值集中在很小的范围内。Qwen2.5-7B 第 14 层的 q_proj 中,权重绝对值的平均值只有 0.0124,最大的也只有 0.332(见附录 A18)。
最简单的是对称量化:取一组权重中绝对值的最大值,除以整数能表示的最大值作为缩放系数 s,每个权重除以 s 后取整。INT8 取 -127~127,INT4 取 -7~7,不用 -128 和 -8,使正负两侧对称。使用时乘以 s 还原,还原后与原值的差就是量化误差。
一组权重共用一个缩放系数,这组权重取多大,称为量化的粒度。下面是 q_proj 中真实的 16 个权重,其中有一个离群值:
- 整个矩阵一个系数:s 由整个矩阵中最大的权重决定,普通的权重除以 s 都小于 0.5,全部变成 0;
- 每行(每个输出通道)一个系数:好一些,但这一行的最大值正好是这个离群值,同一行的其它权重仍然全部变成 0;
- 每组一个系数:离群值只影响它所在的组,另一组的权重得以保留。实际常用 128 个一组,每组多存一个 FP16 的缩放系数,平均每个权重多 0.125 位,7B 模型约多 0.12 GB。
把 Qwen2.5-7B 28 层中的全部线性层按不同的方式量化,在一段约 6600 个 Token 的中文技术文本上测量困惑度(越低越好)。困惑度(Perplexity)是模型给文本中每个 Token 的概率取负对数、求平均后再取指数,可以理解为模型平均在多少个候选之间犹豫;分词方式不同的模型之间不能直接比较:
INT8 按通道量化几乎没有损失;INT4 必须按组量化,困惑度从 6.23 升到 6.50,按通道量化则升到 11.97,整个矩阵一个系数时模型只能输出乱码(见附录 A18)。INT4 按组量化时也常用非对称量化:用每组的最小值和最大值确定范围,多存一个零点,0~15 的 16 个整数都能用上。
2. 只量化权重与连同激活一起量化
只量化权重时(例如 W4A16,权重 4 位、激活 16 位),权重以 INT4 存储,计算前还原为 FP16,再与 FP16 的激活相乘。它节省显存和带宽,适合一次生成一个 Token、受带宽限制的解码阶段,是本地部署和小批量服务最常用的方式,GPTQ、AWQ 都属于这一类。
连同激活一起量化时(例如 W8A8),矩阵乘法直接在 INT8 上完成,能利用显卡的整数计算单元,适合计算量大的预填充和大批量推理。
难点在于激活中有离群的通道:Qwen2.5-7B 第 27 层注意力的输入中,2718 号通道的最大绝对值为 115.8,而所有通道的中位数只有 1.22。按 Token 量化时,缩放系数被这个通道拉大,其它通道的精度都下降了。
- LLM.int8():把离群的通道单独用 FP16 计算,其余用 INT8;
- SmoothQuant:把激活的离群通道除以一个系数,同时把权重对应的通道乘以这个系数,乘积不变,量化的难度从激活转移到了权重上。
3. GPTQ 与 AWQ
直接对每个权重四舍五入,称为 RTN(Round-To-Nearest)。GPTQ 和 AWQ 都用一小批校准数据计算出各层的输入,在此基础上减小量化后每层输出的误差,而不只是权重本身的误差。
两者的区别:GPTQ 每量化一列权重,就调整还没有量化的列来补偿这一列的误差;AWQ 先把与较大激活相乘的重要通道放大再量化,只做通道的缩放,不需要反向传播和重建,对校准数据的依赖更小。具体做法见进阶:GPTQ 与 AWQ。
4. KV Cache 量化
KV Cache 也可以量化。Qwen2.5-7B 每个 Token 的 KV Cache 在 BF16 下占 56 KB,改用 FP8 存储后为 28 KB,同样的显存能放下两倍的 Token,见自回归生成与 KV Cache。vLLM 中设置 kv_cache_dtype="fp8" 即可开启。
量化都会损失一些精度,不同任务受到的影响不同,量化之后要在自己的任务上重新评测。
评测时除了准确率,还要看答案有没有改变:准确率前后相近时,仍可能有一部分题由对变错、另一部分由错变对,Dutta 等人的实验中这个比例可达 13.6%,所以还要比较量化前后逐题的答案或输出分布的 KL 散度(arXiv 2407.09141)。
长输入和非英语输入也要单独测。Mekala 等人在 5 个模型上的实验中,8 位量化(FP8、GPTQ-int8)的平均分只下降 0.2~0.8 个百分点;4 位方法下降更多,在长输入检索任务上通常随输入长度增加而加重,最多的是 Llama-3.1 70B 用 BNB-nf4 时,在 64K、128K 输入的检索任务上下降约 59 个百分点;部分 4 位方法在非英语输入上的下降也比英语大(arXiv 2505.20276)。
幻觉
1. 什么是幻觉
幻觉(Hallucination)指的是模型生成了看似合理、但与事实或者给定资料不符的内容。可以分为两类:
- 事实性幻觉:与客观事实不符,例如编造不存在的论文、API;
- 忠实性幻觉:与给定的资料或指令不符,例如资料里写的是 5 天,回答却是 7 天。
2. 为什么会产生
根本原因在于训练目标:模型学的是生成通顺的下一个 Token,而不是核对事实,不知道的内容也会被“合理地”补全。评测也在鼓励这种行为。多数评测只按答对的比例打分,回答“不知道”得 0 分,猜一个答案还有机会得分,所以倾向于猜测的模型在评测上分数更高,训练和挑选模型时也就偏向了猜测,而不是承认不确定。自建评测集时要把答对、答错和回答不知道分开统计,答错扣的分比不回答更多。
其它原因:
- 知识缺失:训练数据有截止时间,也可能包含错误,出现次数很少的事实更容易记错。用微调灌输新知识还会加重这个问题,见上文的微调;
- 解码:采样带有随机性,一旦生成了错误的 Token,后面的内容会顺着它继续生成;
- 上下文:上下文过长或者检索到的资料有噪声时,模型可能用错资料。
3. 检测与缓解
有参考资料时(例如 RAG),检测的是忠实性:把回答拆成一条条结论,逐条检查能否在资料中找到依据,见评测与优化中的忠实度。引用的原文是否真的出现在资料中、数字和实体是否与数据库一致,这些可以直接用代码检查,比让模型判断更可靠。
但代码只能确认引用和数字确实存在,确认不了引用是否支持结论。例如回答“工作两年有 10 天年假”时引用了“已满 10 年不满 20 年的,年休假 10 天”,原文和数字都在资料中,结论却是错的,这一步仍要逐条判断。
RAG 的回答出现幻觉时,先看正确的资料是否在提示词中:不在时先查知识库有没有这份资料、检索有没有找到,同时查模型为什么没有回答不知道;在却答错是生成的问题。逐步排查见排查顺序。测试用例要专门放入资料中没有答案、新旧版本冲突的问题,见评测集。
没有参考资料时,可以对同一个问题多次采样再比较。模型真正知道的事实,多次回答的内容基本一致;编造的内容往往每次都不一样,例如问一个冷门开源库中某个函数的参数,三次可能给出三套不同的参数。
这种方法需要额外多次采样(SelfCheckGPT 的实验中每个回答额外采样 20 次),而且它依赖多次回答之间的不一致,查不出始终一致的错误,例如模型因为训练数据中的常见误解而一贯答错的内容(arXiv 2303.08896;Farquhar 等,Nature 2024)。
用另一个模型判断回答是否有幻觉时,这个模型同样可能出错。让它只回答“这条结论是否被这段资料支持”这样范围很窄的问题,并用一批人工标注的样本分别测出它漏掉幻觉和误报幻觉的比例,才知道它的结果能否信任,只看总的准确率会掩盖其中一种错误。
缓解方法:
- 输入:通过 RAG 提供可靠的资料,资料不足时让模型明确回答不知道;
- 生成:降低温度,要求回答逐条引用资料;
- 校验:用程序检查引用的内容和关键数字,或者让另一个模型判断回答是否被资料支持;
- 产品:展示来源,高风险的结果由人工确认。
4. Agent 中的幻觉
Agent 的幻觉除了回答错误,还会出现在工具调用的过程中。仍以“订单 1024 到哪了”为例:
- 编造参数:用户只问“我的包裹到哪了”,模型自己编一个订单号去查询;
- 编造或误读工具结果:物流接口超时,模型仍然回答“包裹正在运输中”;或者工具结果中没有的信息,模型自己补上;
- 声称做了没做的事:没有调用退款工具,却回答“已为您提交退款”。
用 Qwen2.5-7B-Instruct 实测,每种配置运行 10 次(见附录 A19)。用户没有给出订单号时,模型每次都先询问,没有编造订单号;物流接口超时时,系统提示词只写查询流程,有 3 次回答“包裹正在运输中”;加上“工具返回错误时如实告知”之后为 0 次。物流接口返回的结果中没有预计送达时间,用户却问“订单 1024 什么时候能到?”时:
| 工具结果 | 系统提示词 | 回答中给出了送达时间 |
|---|---|---|
| 只有 location、updated_at | 只写查询流程 | 10 / 10,例如“预计 24 小时内送达” |
| 同上 | 加上“没有某项信息时如实告知,不要猜测” | 9 / 10,其中 8 次把 updated_at 当成了送达时间 |
改为 location_updated_at,并给出 "eta": null 和说明 |
以上两种 | 0 / 10,回答“承运商暂未提供预计送达时间” |
只在提示词中要求“不要编造”作用有限,模型会把含义不清的字段当成它需要的信息;把工具结果写清楚之后,这组实验中就不再出现编造。所以更可靠的做法是在工具和代码上约束:
- 工具结果写清楚:字段名表达准确的含义,没有的信息明确给出,例如
"eta": null并附上说明,而不是省略这个字段; - 参数来源校验:订单号必须出现在用户消息或之前的工具结果中,并且属于当前用户,否则返回错误让模型先询问用户;
- 结果核对:最终回答中的时间、金额、单号等关键信息,用代码检查是否出现在本轮的工具结果中;“已退款”这类操作结论,以退款工具是否真的返回成功为准,由代码生成确认消息;
- 多 Agent:子 Agent 之间传递结构化的结果和来源,关键结论回到原始数据核对,避免一个子 Agent 的错误被其它 Agent 当成事实继续使用。
模型不可能答对所有问题,但不确定时可以回答“不知道”,而不是猜一个答案。工程上的目标是降低自信地答错的概率,并让错误能被发现。
推理模型
推理模型(Reasoning Model)在给出回答之前,会先生成一段思考过程,把复杂问题拆成多步解决。例如 DeepSeek-R1 通过大规模强化学习获得这种能力:对于数学、代码这类答案可以自动验证的题目,按最终答案是否正确给予奖励,模型在训练中自发地学会了更长的思考、检查和修正。
小模型也可以通过蒸馏获得推理能力。经典的知识蒸馏让小模型(学生)拟合大模型(教师)在每个位置输出的概率分布,并提高温度使分布更平缓,让学生也学到次优选项之间的相对关系(arXiv 1503.02531)。
也可以只用教师生成的文本训练学生,不需要教师的输出分布,这同样属于蒸馏。例如 DeepSeek-R1 的 Distill 系列只用 R1 参与整理的约 80 万条样本对 Qwen2.5、Llama 做 SFT,没有再做强化学习(arXiv 2501.12948)。
思考过程同样消耗 Token,更高的准确率是用更长的延迟和更高的成本换来的,简单问题没有必要使用推理模型。
思考的 Token 按输出计费,界面上只显示思考的摘要或者不显示时,仍按完整的思考过程计费。接口一般用 effort(思考强度)调节思考的多少,以 Claude 为例,它只是引导,不是严格的预算;输出长度的上限(Claude 的 max_tokens、OpenAI Responses 的 max_output_tokens)才是硬上限,并且思考和正文共用这个上限,设得太小时正文可能还没写完就被截断。
二、提示词与上下文
提示词
对话接口的输入是一组带角色的消息:系统(system)消息设定模型的身份和规则,用户(user)消息是具体的请求,助手(assistant)消息是模型之前的回答,工具(tool)消息是工具的执行结果。
系统消息由开发者编写,对整个会话生效,适合放身份、规则和输出格式;用户消息来自终端用户,每轮都不同。模型在训练时学会了两者冲突时优先遵守系统消息,但这只是倾向,用户仍可能用一段话绕过,不能当作安全边界,见提示词注入。
一个清晰的提示词通常包含任务、约束、输出格式和示例。例如从客服消息中提取工单,下面这段作为系统消息,用户发来的每条消息作为用户消息:
你是客服工单助手,从用户消息中提取工单信息。
要求:
- 只根据用户消息提取,没有提到的字段填 null,不要猜测;
- category 只能是 退款、物流、账号、其它 之一。
输出 JSON:{"category": ..., "order_id": ..., "summary": ...}
示例:
用户:订单 1024 三天了还没发货
输出:{"category": "物流", "order_id": "1024", "summary": "订单未发货"}
- 要求越具体越好,“简洁一些”不如“不超过 100 字”;
- 说明信息不足时怎么处理,否则模型倾向于编造;
- 给出几组输入和期望的输出,称为少样本(Few-shot)提示,示例比文字描述更能说明格式。示例的选择和顺序会影响结果,例如分类任务中模型会偏向示例里占多数的类别和排在最后的类别,所以分类示例先保持类别均衡,再在评测集上比较不同的示例组合、顺序和数量,并不是越多越好;
- 让模型先写出推理步骤再给出答案,称为思维链(Chain of Thought, CoT),可以提高多步推理问题的准确率。
提示词改得好不好,要在评测集上比较:准备一批带期望结果的输入,每条运行多次,能用代码判断的用代码检查,其余交给人或模型裁判,见评测。例如工具调用一节的实验中,系统提示词写明先查订单、再查物流之后,7B 模型 10 次运行中答出包裹位置的次数从 0 变为 10。修改时从失败的样例入手,补上对应的规则或示例;APE 等自动优化方法由模型生成多个候选提示词,在评测集上打分后保留得分最高的。
提示词和代码一样需要版本管理。提示词、模型版本、工具定义和采样参数共同决定了输出,最好作为一个整体编号发布,线上记录每次请求用的是哪个版本,新版本先灰度给一小部分用户,与旧版本对比线上指标,出问题时整体回滚;比较时把模型固定到具体的版本,一次只改一处,否则分不清变化来自提示词还是模型。
灰度按用户分流而不是按请求,否则同一段多轮对话中前后两轮用的版本不同,效果分不开。
结构化输出
程序需要解析模型的输出时,要求模型按照 JSON Schema 返回结果。JSON Schema 是用 JSON 描述数据格式的规范,写明有哪些字段、各自的类型、哪些必填以及取值范围,例如 {"type": "object", "properties": {"temp": {"type": "integer"}}, "required": ["temp"]} 要求输出一个带整数字段 temp 的对象。只在提示词中要求输出 JSON 是不够的,模型仍可能多说一句解释、漏掉字段或者少写一个括号。
1. 约束解码
约束解码(Constrained Decoding)可以保证输出符合 Schema:每生成一个 Token 之前,先根据 Schema 算出哪些 Token 能让输出仍然是合法的前缀,把其它 Token 的概率置为 0,再进行采样。
例如 Schema 规定 temp 是整数,已经生成了 {"city": "杭州", "temp": 时,只有数字和负号是合法的,“晴”这类 Token 都会被屏蔽。vLLM 等推理框架和很多模型接口都支持这种方式,代价是 Schema 只能使用推理服务支持的子集。各家的子集不同,遇到不支持的关键字时,有的报错,有的直接忽略,例如 Gemini 不强制 minLength、maxLength,所以长度、数量这类约束仍要在本地校验。
需要注意的是,只保证输出是 JSON 的“JSON 模式”与约束解码不同。例如 DeepSeek 的 JSON Output(response_format 为 json_object)只保证输出合法的 JSON,有哪些字段仍要靠提示词中的样例约束;输出达到 max_tokens 时同样会被截断。
字段的顺序也有影响:约束解码按 Schema 中的顺序逐个生成字段,需要推理的任务应把 reason 这类解释字段放在结论字段前面,否则模型要先给出结论,再为它找理由。Claude 的结构化输出会把必填字段排在可选字段之前,要固定顺序时把这几个字段都设为必填。
不按 Schema 约束顺序的 JSON 模式也可能出现这个问题,例如 Tam 等(2024)用 gpt-3.5-turbo-0125 的实验中,JSON 模式的输出全都把 answer 写在 reason 前面,没有先写推理步骤,拼接单词末尾字母(Last Letter)任务的平均准确率只有 1.78%;这项研究的提示词设计存在争议,不能据此认定 JSON 模式必然降低准确率(arXiv 2408.02442)。
把解释字段放在前面,针对的是不带思考的模型。推理模型在输出之前已经思考过,OpenAI 的文档认为不必再让它逐步推理;Claude 的文档还把 JSON 中的 reasoning、thinking 这类字段列为拒答的常见原因,需要说明时改为要求简短的理由或依据(以官方最新文档为准)。
2. 输出无法解析时
不支持约束解码时,模型的输出可能无法解析。最常见的是 JSON 前后多了内容,例如包在 ```json 代码块里;其次是输出达到 max_tokens 被截断(接口返回的 finish_reason 为 length),以及语法合法但不符合 Schema,例如枚举值写成了同义词、金额写成“59 元”这样的字符串。
处理方法按代价从低到高:
- 修复:去掉代码块标记,截取第一个 { 到最后一个 } 之间的内容再解析。不需要再调用模型,但只能处理多出来的内容和简单的语法问题;
- 校验后带着错误重试:解析后用 JSON Schema 校验,失败时把原来的输出和具体的错误发回模型,例如“字段 intent 的值必须是 查物流、退货退款、修改地址、其它 之一”。能修正内容层面的问题,代价是多一次调用,所以要限制次数。
用 Qwen2.5-3B 从 20 条客服消息中提取订单号、意图和金额,每条采样 2 次:40 次输出中有 14 次包在代码块里,直接 json.loads 只成功 26 次;修复后 40 次都能解析,38 次通过 Schema 校验。剩下的 2 次都来自“订单 5120 和 5121 能一起发吗”,模型把两个订单号写进了一个字段,带着错误重试后仍然一样(实测,见附录 A22)。
多数格式问题靠修复就能解决;而输入超出了 Schema 的设计时,重试没有用,应该修改 Schema(例如把订单号改为数组)。实际使用时依次组合:先修复,再校验,失败后带着错误重试 1~2 次,仍然失败就返回默认值或者转人工处理。
3. 格式正确不等于内容正确
约束解码和 Schema 校验只能保证格式。订单是否属于当前用户、退款金额是否超过实付金额这类业务规则取决于当前用户和订单的数据,无法写进固定的 Schema,要在代码中按执行时的数据校验。
约束解码本身也有例外:模型拒答或输出达到 max_tokens 上限时,结果可能不完整、不符合 Schema。输出不符合预期时,先看结束原因,例如 finish_reason 是否为 length,再对比字段类型是否与代码中的类一致,例如订单号是字符串 "1024" 还是数字 1024。
输入中缺少某个字段的信息、Schema 又没有办法表示“缺失”时,模型可能编造一个值填进去,所以 Schema 中应允许返回 null 或“其它”这类选项。
流式输出时,完整的对象仍要等生成结束后再严格解析和校验。需要边生成边显示时,可以用部分 JSON 解析器把已收到的前缀补齐成合法的 JSON,得到中间对象,OpenAI 等官方 SDK 的流式助手提供了这种解析。中间对象只能用于显示,数字、布尔值和数组的最后一项可能还没写完,例如收到 "amount": 5 时中间对象中的 amount 是 5,完整的值却是 59.5(jiter 0.11 实测)。
上下文工程
模型只能看到上下文窗口里的内容,回答的质量很大程度上取决于窗口里放了什么。上下文工程(Context Engineering)指的是在每次调用之前,决定把哪些内容、以什么顺序放进窗口,包括系统规则、工具定义、检索到的资料、对话历史和当前问题。
提示词工程(Prompt Engineering)关注的是怎样写好指令,尤其是系统提示词;上下文工程的范围更大,提示词只是窗口中的一部分,还要管理工具定义、检索结果、对话历史和记忆。Agent 每执行一步都会产生新的工具结果,窗口中的内容一直在变化,每次调用之前都要重新挑选和压缩,这是写好一段固定的提示词解决不了的。
1. 放什么、按什么顺序
上下文并不是越多越好:
- 窗口有上限,输入越长,延迟和成本越高;
- 输入越长,模型对其中信息的利用越差,关键信息位于中间时更容易被忽略,这个现象称为 Lost in the Middle。论文的多文档问答实验中,给 GPT-3.5-Turbo 20 篇文档、答案在中间时,正确率比不给任何文档、只靠模型自己的知识(56.1%)还低。
因此只放入与当前任务相关的内容,重要的规则放在开头或者结尾。
内容的顺序还会影响成本。模型服务普遍支持前缀缓存:两次请求的开头完全相同时,相同部分不需要重新计算,并按更低的价格计费。所以系统规则、工具定义这些不变的内容放在前面,每次都变化的内容放在后面,例如在系统提示词的开头插入当前时间,后面的内容就全部无法命中缓存,详见让前缀缓存命中。
2. Token 预算
窗口中的每一部分都占用 Token,先算清楚各占多少,才知道该限制和压缩哪一部分。以前文的客服 Agent 为例,用户在一次会话中先后查了物流、退了一件商品、改了地址,第 12 次发言时问发票多久能开好。用 Qwen2.5-7B-Instruct 的分词器和对话模板统计这次请求:
- 系统提示词、工具定义和长期记忆每次请求都相同,共 981 个 Token,其中 6 个工具的定义就占了 701 个。工具越多这部分越大,所以要按需加载工具,见 MCP 和 Skills;
- 对话历史只有 11 轮,已经占了 61%,而且会随对话一直增长,其中一半是工具返回的结果;
- 中文大约每个字符 0.6 个 Token,例如系统提示词 332 个字符,对应 205 个 Token(Qwen2.5 实测,见附录 A3)。
分配预算时,先扣除每次都要放的部分,剩下的留给对话历史:
历史可用 = 窗口 − 预留的输出 − 系统提示词 − 工具定义 − 长期记忆 − 检索资料的上限 − 当前问题
例如 Qwen2.5-7B-Instruct 默认的窗口是 32K,预留 2K 给输出,固定部分约 1K,检索资料限制在 4K 以内,历史最多可以用约 25K。预算通常要比窗口的上限小得多,这是因为上下文越长越贵,效果也越差,窗口能放下不代表应该放满。
3. 压缩的方法与时机
对话或者 Agent 运行的时间很长时,历史会超出预算,需要压缩:
- 截断:只保留最近的 N 轮或者最近的若干 Token,也称为滑动窗口。实现简单,不需要额外调用模型,代价是更早的内容全部丢失;
- 摘要:把较早的对话交给模型总结成一段摘要,保留已确定的结论、未解决的问题和用户的要求,丢弃寒暄和重复的内容。代价是多一次模型调用,细节可能被漏掉或者改写,而且摘要中的错误会一直传下去;
- 清理工具结果:较早的工具调用返回的大段原始数据很少再被用到,可以替换为一句说明,需要时重新获取。原始数据随时可以重新查询,所以这是风险最小的一种;
- 外置:把计划、中间结果写到文件或数据库中,上下文中只保留路径,需要时再读取,代价是模型要知道什么时候去读。
需要注意的是,截断历史时工具调用和它的结果要成对保留或者成对删除。只剩下结果而缺少对应的调用时,很多接口会直接报错。删掉之后,模型也不知道已经查过什么,可能重复调用,所以摘要中要写明查过什么、结果是什么,例如“已查询订单 1024 的物流:在杭州转运中心,预计 10 月 27 日送达”。
只有闲聊这类前后轮关系不大的对话,用滑动窗口就够了。客服对话中,用户在第 6 轮说的“取件前发短信,别打电话”到第 30 轮仍然有效;Agent 执行长任务时,早期做出的决定和用户提出的约束一直有用,这些场景适合摘要加外置。实际系统常把几种方法组合起来:上下文由“系统提示词 + 摘要 + 最近几轮的原文 + 当前问题”组成,更早的消息不断并入摘要,并且先清理工具结果,不够时再做摘要;需要读大量资料、反复试错的子任务交给子 Agent,在它自己的上下文中完成,只把结论带回来。
Codex 在本地压缩时把历史换成一段写给“接手的模型”的交接摘要(进展、关键决定、约束和下一步),同时从最新往前保留用户的原话,最多约 2 万个 Token,模型的回复和工具结果只保留在摘要里(Codex 源码,以最新版本为准)。
压缩的时机按 Token 数判断,而不是按轮数。上面的对话中,每轮新增的 Token 从 33 到 611 不等,查了物流的一轮是简单问答的十几倍。上一次请求的准确 Token 数可以从接口返回的 usage 中得到,再用分词器或者按字数估算新增的消息,超过阈值就压缩。常见的做法是设两道线:历史达到预算的约 70% 时在后台生成摘要,不影响当前的回答;达到上限时必须先压缩才能继续调用。MemGPT 就采用这种方式,在 70% 时提醒模型把重要的信息保存到外部,在 100% 时移出一部分消息,并把它们和原有的摘要合并成新的摘要;Claude Code 的做法见 AI 编程助手。
70% 只是一种取法,各产品的默认值相差很大,例如 Codex 没有配置阈值时在窗口的 90% 压缩,Claude Code 接近窗口上限时才压缩,Claude API 的服务端压缩默认在 15 万输入 Token 时触发(以官方最新文档为准)。
保留的“最近几轮”也要按 Token 计,而不是按条数。最近几条本身就超出预算时,通常是某条工具结果或用户粘贴的文档太大:先把这条外置,上下文中只留与当前任务相关的片段和读取位置,需要时再分段读取或检索;仍然放不下就减少保留的轮数,只保证系统提示词、任务状态和当前问题完整。
4. 压缩后不丢关键事实
摘要最容易丢掉的是订单号、金额、时间、地址这类精确的事实,而后面的对话恰恰要用到它们。下面比较两种摘要提示词,一种只要求“用一段话总结这段对话”,另一种要求按订单、已办理、承诺、用户要求、待办分项输出,并且照原文抄写编号、金额和地址。用 Qwen2.5-3B 把上面 11 轮的对话(1654 个 Token)各压缩 3 次,检查 14 条关键事实是否保留,再只根据摘要回答 5 个依赖早期对话的问题,例如“快递员什么时候上门取件”:
3B 的普通摘要平均只保留了 4.3 条事实,5 个问题平均答对 0.3 个;结构化摘要平均保留 9.7 条,每次都答对 3 个,代价是长度从约 150 个 Token 增加到约 480 个。即使是结构化摘要,用户只说过一次的要求,例如取件前发短信、手机尾号 8823,仍然经常丢失。换成 Qwen2.5-7B 各压缩 2 次,结构化摘要平均保留 11.5 条;普通摘要两次分别保留 12 条和 4 条,结果很不稳定(Qwen2.5 实测,见附录 A4)。
摘要以抽取和改写原文为主,常交给更小的模型来降低成本和延迟,但要先用下面的方法检查关键事实是否保留,上面的实验中 3B 保留的事实就少于 7B。因此关键的事实不能只依靠模型的摘要:
- 由代码维护任务状态:订单号、退货单号、金额、地址这些字段,直接从工具调用的参数和结果中提取,单独保存,不经过模型的改写;
- 让承诺通过工具留下记录:例如“取件前短信联系”应该调用备注工具写进订单,而不是只在回答中说一句;
- 摘要分项输出,要求编号、金额、时间照原文抄写;“已办理”只写有工具结果确认的操作,只是说过要做、还在等用户确认的放进“待办”,否则“准备退款”可能被压缩成“已退款”;
- 生成新摘要时在旧摘要的基础上合并,而不是只总结新移出的消息,否则早期的内容会在多次压缩后消失;
- 用框架的默认配置时,检查摘要模型实际看到了哪些消息。例如 LangChain 的摘要中间件默认把待摘要的部分裁剪到约 4000 个 Token(近似计数),保留末尾的消息和开头的系统消息,被裁掉的早期消息不参与摘要,其中往往有用户最初的要求。
压缩有没有丢掉关键信息,可以用两种方法检查,上面的实验就是这样做的:
- 关键事实覆盖:由代码从原始对话中找出订单号、单号、金额、电话等,检查它们是否出现在压缩后的上下文中,缺少的直接补回;
- 探针问题:准备一组依赖早期对话才能回答的问题,例如“用户一开始要求的输出格式是什么”,分别用压缩前和压缩后的上下文回答,比较正确率。
上线之后,模型重复调用已经调用过的工具、用户重复提供已经说过的信息,都说明压缩丢了东西,可以作为监控指标。
三、检索增强生成
检索增强生成(Retrieval-Augmented Generation, RAG)指的是先从知识库中检索与问题相关的资料,再把资料和问题一起交给模型生成回答。它用于解决模型不了解私有数据和最新数据的问题,也能减少幻觉,回答还可以附上来源。
流程
RAG 分为离线建立索引和在线检索生成两个阶段:
- 离线:解析文档,切成小段,用 Embedding 模型把每一段转为向量存入向量数据库,同时建立关键词索引;
- 在线:改写用户的问题,向量检索和关键词检索同时召回候选,合并之后重排,把最相关的几段资料和问题一起放进提示词,生成带引用的回答。
生成时,把重排后的切片编号放进提示词,并写明资料不够时怎么办(示例只保留了关键部分):
根据下面的资料回答问题。只使用资料中的内容,每个结论后标注来源编号;资料中没有答案时回答“资料中没有找到相关规定”,不要猜测。
[1] 员工手册(2026 版) > 第 3 章 年假 > 3.1 天数:员工累计工作已满 1 年不满 10 年的,年休假 5 天……
[2] 员工手册(2026 版) > 第 3 章 年假 > 3.3 结转:当年未休完的年假可以延至次年 3 月底前使用,逾期作废……
问题:工作两年能休几天年假?
RAG 能减少幻觉,是因为模型从凭记忆回答变成了根据给定的资料回答,每个结论都能按编号核对。但资料没找到或者找错时,模型仍然会编造,所以还要用重排分数和相似度阈值判断有没有答案;资料不足时可以改写问题再检索一次,或者向用户追问,仍然没有就如实回答没找到。
每一环都会影响最终的效果:文档解析错了,后面检索得再准也没用;正确的资料没被召回,重排和生成都无法补救。
文档解析
文档解析指的是把 PDF、Word、网页、图片等文件转为带结构的文本,包括标题层级、段落和表格,同时提取页码、版本等元数据。后面的切片、检索和引用都以解析结果为准,解析阶段丢失或打乱的信息,后面无法补回来。
1. PDF 为什么难解析
Word、HTML、Markdown 文件本身记录了标题样式、表格和列表,按结构读取即可。PDF 则不同,它只记录“在某个坐标画某个字”,没有段落、表格的概念;扫描件更进一步,每页只是一张图片,连文字都没有,必须先用光学字符识别(Optical Character Recognition, OCR)识别出文字和位置。
用一页员工手册对比两种读取方式。页面上有页眉页脚、两级标题、一段正文和一个住宿标准表格:
直接读取文本层得到的是按行排列的文字:页眉页脚混进了正文,一句话在折行处断开,“原”字还被排到了下一行,标题和正文也没有区别。把这一页转成图片模拟扫描件后,文本层为空,OCR 能正确识别大部分文字,但表格是一列一列输出的,先输出全部城市,再输出全部价格(macOS PDFKit 与 Vision 实测,见附录 A11)。
2. 版面分析
版面分析(Layout Analysis)根据每段文字的位置、字号和对齐方式,识别出页眉页脚、标题、正文、表格和图片区域,并确定阅读顺序,例如双栏排版要先读完左栏再读右栏。开源工具(例如 MinerU、Docling)用模型完成这些步骤,输出 Markdown 或 JSON。
与只输出文字和坐标的传统 OCR 相比,这类工具多了版面检测、表格结构识别和公式识别,输出的是带标题层级和表格的文档。也可以让多模态大模型直接把页面截图转为 Markdown,但长文档受上下文和输出长度限制,要逐页或分批调用,成本高、速度慢;模型还可能漏掉或改写数字,只输出 Markdown 时也丢失了坐标,难以定位回原文。因此它通常只用于专用工具处理不好的页面,效果要在自己的文档上评估。
用几条简单的规则就能看出原理:把 OCR 结果中 y 坐标相差不超过 12 像素的文字归为一行,同一行有多个文字块的就是表格行;顶部和底部 10% 的区域是页眉页脚,从中取出页码后丢弃;按“第 N 章”“N.N”的格式识别标题。对上面的扫描件,这几条规则的输出如下:
## 第 6 章 出差
### 6.3 住宿
住宿标准按城市分级,超出标准的部分由员工自行承担。两名同性员工同行的,原则上合住一间。
| 城市 | 每晚上限 | 说明 |
| --- | --- | --- |
| 北京、上海、广州、深圳 | 600 元 | 含税 |
| 其他省会城市 | 450元 | 含税 |
| 其余城市 | 350 元 | 含税 |
### 6.4 出差补贴
出差期间每天发放补贴 100元,用于餐费和通讯费,按实际出差天数计算,不需要提供发票。当天往返的出差,补贴按半天计算。
标题层级恢复之后,切片可以按小节进行,每个切片也能带上“第 6 章 出差 > 6.3 住宿”这样的路径,见下一节。
表格转为 Markdown 后保留了行列关系。表格很大、需要按行切分时,每行都要带上表头,例如“城市:其他省会城市;每晚上限:450 元”,否则单独一行“450 元 含税”看不出是什么。图片中的文字同样用 OCR 提取,图片表达的含义可以交给多模态模型生成文字描述,见多模态 RAG。
按页解析会切断跨页的内容:一张表格从第 12 页延续到第 13 页时,第 13 页的部分通常没有表头。合并时比较上一页最后一块和下一页第一块:两块都是表格、列数和列宽一致、下一页的表格前没有新的标题或表名,才拼成一张表并去掉重复的表头;正文要看上一页末句是否完整、下一页首段是否像新段落,判断不了时保持分开。合并后元数据记录页码范围 12~13。
解析结果需要抽样与原文对比。上面的 OCR 把标题识别为 第6章出差,置信度只有 0.30,这类页面要有兜底:有文本层的 PDF 优先用文本层,乱码多或字数过少时才 OCR;OCR 失败或置信度低的页面换更强的模型重试,仍然不行就交给人工复核。批量处理时按页记录状态,失败的页面单独重跑,不必重新解析整份文档。
3. 元数据
解析时同时提取元数据:文档 ID、版本、标题路径、页码、所属部门和权限。元数据作为字段存放在每个切片上,有三个用途:
- 回答时给出“员工手册第 12 页”这样可以核对的来源;
- 检索时只查当前版本、只查用户有权限的资料,见下文的向量数据库;
- 文档更新时按文档 ID 删除它的全部旧切片。
切片
切片(Chunking)指的是把长文档拆成适合检索的小段。
1. 为什么要切片
即使一篇制度文档只有几千字,也需要切片:
- 一个向量只能表示一段文字整体的意思,一段里包含年假、病假、婚假多个主题时,它的向量与哪个具体问题都不够接近;
- Embedding 模型有最大长度,bge-small-zh-v1.5 最多 512 个 Token,超出的部分直接被截断,800 字的中文约 680 个 Token;
- 放进提示词的内容越少,成本越低,引用的来源也越精确。
换成最多 8192 个 Token 的模型虽然不会截断,第一条仍然成立。例如用 jina-embeddings-v2-small 在 LongEmbed 的英文长文本检索任务上,把文本截到 8192 个 Token 后,按 512 个 Token 切片比整段编码的 nDCG@10 平均相对提高约 24%(arXiv 2409.04701)。
但切片也不能太小。例如“工作满 1 年不满 10 年的”和“每年 5 天”被切到两段,单独召回哪一段都无法回答问题。切片太大,向量不够准确,无关的内容还会占用上下文。
2. 常用做法
- 按标题、段落等文档结构切分,表格和代码块保持完整,而不是按固定字数硬切;
- 相邻切片之间保留一部分重叠,避免关键句子被切断;
- 给切片加上文档标题、章节路径等信息,例如“员工手册 > 第 3 章 年假”,使切片单独拿出来也能看懂;
- 用小切片检索,命中之后返回它所在的完整段落,称为父子切片;也可以在切片上记录前后切片的 ID,命中后把相邻的切片一起返回。
一节超过长度上限时,常用递归切分(Recursive Splitting)继续切:按一组由粗到细的分隔符依次尝试,先按空行分成段落,仍然太长的再按换行、句号、逗号切,最后才按字数硬切,切出的小段再合并到接近上限。需要注意的是,LangChain 的 RecursiveCharacterTextSplitter 默认只用空行、换行和空格,中文的空格只出现在数字和英文两边,切 100 字时“已满 20 年的,年休假 15 天。”会在“15”和“天”之间断开。中文要补上“。”“;”“,”等分隔符,并把标点留在前一段末尾,这样会优先在句末切开,上面的员工手册就没有再切断句子;单句仍超过上限时,还是会按逗号或字数切开。
没有标题的长文本,例如会议记录、客服对话,可以用语义切分(Semantic Chunking):为每一句计算 Embedding,在相邻两句相似度明显下降的位置切开,例如两句的距离超过全部相邻距离的 80 分位数。它要额外计算每一句的 Embedding,建索引的开销更大;只按相似度切,切片的长度也没有上限。
3. 对比实验
用一个小实验比较不同的切法。知识库是一份员工手册的 2026 版和 2023 版(除标题和施行日期外,两个版本只有 6 处数字不同)以及一份错误码说明,共约 5000 字;40 个问题都是员工的口语化提问,例如“老婆生孩子,我能休几天假?”。每个问题标注了回答所需的原句,检索出的前 K 个切片完整包含这些原句就算找到,找到的问题所占的比例记为 Recall@K(见附录 A12):
| 切法 | 切片数 | Recall@1 | Recall@3 | 前 3 个切片的字数 |
|---|---|---|---|---|
| 固定 100 字 | 51 | 0.50 | 0.73 | 296 |
| 固定 100 字,重叠 20 字 | 63 | 0.65 | 0.80 | 295 |
| 固定 200 字 | 26 | 0.55 | 0.83 | 590 |
| 固定 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,代价是切片数多了约四分之一。递归切分优先在段落和句子边界切开,不加重叠也有 0.95。语义切分的阈值不好定,改按 95 分位数切时,最长的切片有 841 字,超过了 512 Token,而 LangChain 的 SemanticChunker 默认用的就是 95 分位数。
800 字的切片召回率也高,但主要是因为放进去的字多:前 3 个切片共约 2000 字,相当于知识库的四成,而且每个切片超过 512 Token 的部分没有参与向量计算。父子切片在这里没有优势,因为每个小节本身只有一两百字,小节很长、问题只对应其中一两句时它才有用。
需要注意的是,40 个问题中一个问题就是 0.025,这组数字只说明趋势。另外,两个版本的同一节几乎一样,38 个问题的前 3 个结果里同时出现了新旧两个版本,加标题路径也区分不开(Recall@1、Recall@3 与不加时相同,见附录 A12),需要按版本过滤,见下文的向量数据库。
4. 怎么确定切片大小
切片大小没有通用的最优值。公开的对照研究中,语义切分相对固定长度的收益在不同任务上并不稳定(Qu 等,2024);最优的切片大小随数据集和 Embedding 模型而变,答案简短的事实类数据集适合 64~128 Token,需要更多上下文的适合 512~1024 Token(Bhat 等,2025)。
实践中先按文档结构切,用 Embedding 模型的最大长度给出上限;事实类的问题(几天、多少钱)适合小切片,需要综合理解的问题适合大一些的切片;最后在自己的评测集上对比不同大小和重叠的召回率。
Embedding 与相似度
1. 向量与余弦相似度
Embedding 模型把一段文本转为一个固定长度的向量,例如 1024 维。训练时让意思相近的文本的向量互相靠近、无关的文本的向量互相远离,因此向量之间的距离可以衡量语义的相似程度。
向量的计算过程是:分词之后输入一个 Transformer 编码器,每个 Token 得到一个向量,再把这些向量汇总成一个,称为池化(Pooling),通常再归一化为长度 1。例如 bge-small-zh-v1.5 把“怎么申请年假”切成 [CLS]、怎、么、申、请、年、假、[SEP] 共 8 个 Token,最后一层输出 8 个 512 维的向量,取 [CLS] 位置的向量作为整句的表示。
池化的方式由模型决定:BERT 类的编码器取 [CLS] 位置的向量或者所有 Token 的平均值;Qwen3-Embedding 这类由解码器改成的模型取最后一个 Token 的向量,因为在因果掩码下只有它看到了整句话。使用时要与模型训练时的方式一致,bge-small-zh-v1.5 改用平均值之后,下文“怎么申请年假”与“休假的流程是什么”的相似度从 0.603 变为 0.547(见附录 A27)。
常用余弦相似度衡量两个向量方向的接近程度:cos(a, b) = a · b / (|a| |b|),取值范围是 -1 到 1。用二维向量示意:
a = “怎么申请年假” (0.8, 0.6)
b = “休假的流程是什么” (0.6, 0.8) cos(a, b) = 0.48 + 0.48 = 0.96
c = “今天股价上涨了” (0.6, -0.8) cos(a, c) = 0.48 - 0.48 = 0
a 和 b 几乎没有相同的字,相似度却很高,这是向量检索相比关键词检索的优势。向量通常会归一化为长度 1,这时余弦相似度等于点积,计算更快。归一化之后,欧氏距离与余弦相似度也是等价的:|a − b|2 = 2 − 2cos(a, b),按距离从小到大排序与按相似度从大到小排序的结果相同。没有归一化时三者的排序可能不同,点积还会受到向量长度的影响,应该使用模型训练时所用的度量。
用真实的模型计算,结论相同但数值没有这么极端:用 bge-small-zh-v1.5(512 维)编码这三句话,“怎么申请年假”与“休假的流程是什么”的余弦相似度为 0.603,与“今天股价上涨了”为 0.194(见附录 A1)。实际系统中不同句子的相似度大多落在一个不太宽的区间里,判断是否相关要看排名和阈值,而不是期望不相关的结果接近 0。
意思相近的向量互相靠近,是用对比学习(Contrastive Learning)训练出来的。训练数据是成对的(问题,相关文档),一个批次中每个问题自己的文档是正样本,同一批次中其它问题的文档就是负样本,称为批内负样本(In-batch Negatives)。用 bge-small-zh-v1.5 编码三对数据,三段文档依次是“年假需提前 3 个工作日在 OA 系统提交申请”“错误码 E1024:库存不足”“北京、上海每晚住宿上限 600 元”,问题与文档的相似度如下:
文档 1 文档 2 文档 3
怎么申请年假 0.693 0.223 0.376
错误码 E1024 是什么意思 0.318 0.762 0.266
出差住宿标准是多少 0.380 0.339 0.627
训练时把每一行看成一个三选一的分类问题:相似度除以温度 τ 后做 softmax,损失是正确文档(对角线)的交叉熵,训练使对角线的值高于同一行的其它值。τ 用于放大差距,τ = 1 时第一行选中文档 1 的概率只有 0.43,τ = 0.05 时为 0.998(见附录 A27)。
负样本越多、越难区分,学到的向量越精细。所以 bge 训练时一个批次多达 19,200 对,后期还为每对数据挖掘一个难负样本(Hard Negative),即与问题字面相近但并不相关的文档,例如为 E1024 的问题配上 E1025 的说明。
直接把检索结果的前几名当作难负样本,容易混入实际相关的文档,称为假负样本(False Negative),例如 RocketQA 在 MS MARCO 上抽查 100 个问题,排在前面、没有被标为相关的文档约 70% 实际相关或高度相关(arXiv 2010.08191)。常见的做法是跳过最靠前的若干名,例如 FlagEmbedding 的挖掘脚本默认跳过前 10 名,从第 11~210 名中抽取;NV-Retriever 则把负样本分数的上限设为对应正样本分数的 95%(arXiv 2407.15831)。
2. Top-K 检索
检索时把问题也转为向量,找出与它最相似的 K 个切片,称为 Top-K 检索。问题和切片必须使用同一个 Embedding 模型,更换模型后所有切片都要重新计算向量。K 怎么取见下文的 Top-K 与阈值。
知识库变大后,相似度高却不能回答问题的内容可能增多,把正确切片挤出前 K 个,即使逐个精确比较也是如此。在切片实验的 52 个切片中混入 12.5 万条无关的短问题,Recall@3 从 0.975 降到 0.575:“年假可以只请半天吗?”与干扰文本“是否可以请长假?”的相似度为 0.798,正确切片只有 0.659(见附录 A29)。这是向量本身的排序问题,调向量数据库的索引参数只能使结果更接近精确检索,解决不了;要靠元数据过滤缩小范围,或者召回更多候选交给重排,这里 K 增大到 50 时召回率回到 0.90。
3. 选择模型
选择模型时先看语言和领域是否匹配、最大长度是否够用,维度则决定了存储成本:1 亿个向量从 512 维换成 1024 维,原始向量的内存从 205 GB 变为 410 GB。维度一般随模型固定,Qwen3-Embedding 这类支持自定义维度的模型可以截取前若干维再归一化,0.6B 的模型可在 32~1024 维之间选,维度越低越省,效果通常也越差。能这样截取,是因为这类模型训练时用了套娃表示学习(Matryoshka Representation Learning, MRL),对不同长度的前缀维度都计算损失;没有这样训练的模型直接截取前几维,效果可能明显下降,要在自己的评测集上验证,不能照搬。公开榜单只能作初筛,业务资料的措辞与公开数据集差别很大,最终以候选模型在自己的评测集上的召回率为准。
还要按模型的说明使用。问题短、切片长,两者的写法不同,所以很多模型要求只给问题加一段检索指令。例如 bge 中文模型建议在问题前加上“为这个句子生成表示以用于检索相关文章:”,切片不加;在切片实验的数据上,加前缀后 Recall@3 从 0.975 提高到 1.0,混入上述 12.5 万条短问题时从 0.575 提高到 0.700(见附录 A12、A29)。
其它模型的约定不同,例如 e5 要求问题以“query: ”开头、切片以“passage: ”开头,Qwen3-Embedding 把查询写成 Instruct: {任务描述}\nQuery:{问题} 的格式(指令建议用英文),切片不加指令。用 sentence-transformers 时,encode_query() 默认使用模型配置中的查询前缀,bge-small-zh-v1.5 的配置中这个前缀为空,encode_query() 与 encode() 的结果相同,要显式传入 prompt 参数或自己拼接(sentence-transformers 6.2 实测,见附录 A12)。
向量索引
1. 为什么需要近似检索
最简单的检索方法是把问题向量与所有切片向量逐个比较。100 万个 1024 维的向量,每次查询需要约 10 亿次乘加运算,数据量和并发量大时太慢。向量数据库使用近似最近邻(Approximate Nearest Neighbor, ANN)索引,用少量精度换取速度,可能会漏掉个别真正最近的向量。
索引的召回率指的是返回的结果中有多少是逐个比较得到的真正 Top-K,衡量的是近似带来的误差;它与切片实验中“相关的资料有没有被找到”的召回率不是一回事。
2. HNSW
HNSW 把向量组织成多层的图,每个向量与附近的若干个向量相连。插入时每个向量随机分配一个最高层,层数越高概率越低,所以上层的向量少、连线跨度大,最下层包含所有向量。查询时从最上层的入口开始,上面各层只保留一个最近的节点,贪心地向查询靠近,走不动时下降一层;到最下层时保留 efSearch 个候选,不断扩展候选的邻居,最后从中取前 K 个,思路与 跳跃表 类似。
HNSW 查询快,是因为上层的长连线让查询几步就跳到目标附近,最下层只需在附近的邻居中搜索。在 12.4 万个 512 维的文本向量上,M = 32、efSearch = 64 时每次查询只计算约 1300 次距离,是暴力检索的 1.1%,Recall@10 为 0.936(见附录 A29)。它不需要训练,但有几种情况会变差:过滤掉大部分向量、大量删除,以及数据多到内存放不下,分别见下文的过滤、更新与删除和 IVF-PQ。它的三个参数都在速度、内存和召回率之间权衡:
- M:每个节点连接的邻居数,越大召回率越高、占用内存越多,最下层的邻居数是 2M;
- efConstruction:建图时每个节点搜索的候选数,越大图的质量越好、建索引越慢;
- efSearch(Milvus 中叫 ef):查询时保留的候选数,越大召回率越高、查询越慢,一般不小于 K。
3. IVF 与 PQ
HNSW 的问题是内存:原始向量和图都要放在内存中。IVF 和 PQ 分别从减少比较次数和压缩向量两个方向解决规模问题,两者常一起使用,称为 IVF-PQ:
IVF 先用 k-means 把向量分到 nlist 个桶中,查询时只比较离查询最近的 nprobe 个桶。在 8.3 万个 512 维的真实文本向量上,nlist = 256、nprobe = 16 时只扫描 7.1% 的向量,Recall@10 为 0.859;nprobe = 64 时扫描 27.3%,Recall@10 为 0.949(numpy 实现,见附录 A13)。
乘积量化(Product Quantization, PQ)把 512 维的向量按 8 维一段切成 64 段,每段在训练好的 256 个中心中找最近的一个,只存它的编号,2048 字节压缩为 64 字节。查询时先算出查询的每一段与 256 个中心的内积,得到一张 64 × 256 的表,库中每个向量的近似得分只需查表 64 次再相加。
压缩是有损的,同一份数据上只用 PQ 的近似得分,Recall@10 只有 0.677。补救方法是先用 PQ 取出前 100 个候选,再用原始向量重新计算排序,Recall@10 提高到 0.973。原始向量可以放在磁盘上,每次只读取这 100 个。
4. 按内存选择索引
按 FAISS 文档给出的每个向量占用的字节数,估算 1 亿个 512 维 float32 向量需要的内存(见附录 A15):
| 索引 | 每个向量 | 1 亿个向量 |
|---|---|---|
| 原始向量(暴力检索) | 512 × 4 = 2048 字节 | 204.8 GB |
| HNSW,M = 32 | 2048 + 32 × 2 × 4 = 2304 字节 | 230.4 GB |
| IVF,不压缩 | 2048 + 8 字节 ID = 2056 字节 | 205.6 GB |
| IVF-PQ,64 段 × 8 位 | 64 + 8 = 72 字节 | 7.2 GB |
HNSW 在原始向量之外还要多占 10% 以上的内存,IVF-PQ 只需要原来的 1/28。所以数据量不大、内存充足时用 HNSW;内存受限时用 IVF-PQ,配合原始向量重排弥补精度;数据量超过内存时用基于磁盘的索引,例如 DiskANN 把图和原始向量放在 SSD 上,只在内存中保存 PQ 编码,论文中单机 64 GB 内存可以检索 10 亿个向量。
向量数据库
FAISS 这类向量检索库只负责在内存中建立索引和检索。实际系统中,切片会不断增删改,检索时要按版本、权限过滤,数据还可能多到一台机器放不下,向量数据库在索引之上解决这些问题。数据只有几千条、很少变化时,把向量放在内存中直接计算余弦相似度就够了。
库中的一条记录对应一个切片,除了向量,还保存切片的原文和解析时提取的元数据,检索到之后直接取出原文放进提示词(示例只保留了部分字段):
{
"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 之后存的也是识别出的文本和它的向量。
相似度的度量在建索引时指定,按 Embedding 模型推荐的度量和归一化方式选择,用索引查询时也要用同一种。各产品的默认值和限制不同,例如 Milvus 的稠密浮点向量默认用余弦;pgvector 每种距离对应一个运算符类,查询的距离与索引的运算符类一致才能用上索引;Elasticsearch 的 float 向量用 dot_product 时,文档向量和查询向量都必须是单位长度(以各产品的最新文档为准)。
1. 过滤
检索时往往要同时满足标量条件。如果先检索 Top-K 再过滤,结果可能远少于 K 个。例如 pgvector 的 HNSW 索引默认每次查询只保留 40 个候选,过滤条件只有 10% 的行满足时,平均只剩 4 条结果;满足条件的行与查询向量的位置相关时(例如某个租户的文档都集中在一起,而查询离它们较远),可能一条都不剩。
因此向量数据库一般在检索的过程中过滤:Milvus 先根据过滤条件生成一个位图,检索时跳过不满足条件的向量;Elasticsearch 的 knn 查询中的 filter 也在近似检索的过程中生效,但写在外层 bool 查询中的过滤是检索完成后才执行的,同样可能少于 k 条;pgvector 0.8.0 起支持迭代扫描,结果不够时自动继续扫描,需要设置 hnsw.iterative_scan 开启,默认关闭,继续扫描的近似上限由 hnsw.max_scan_tuples 控制,默认 20000,它不限制最初的一次扫描。
过滤还能直接提高效果。在切片一节的实验中,新旧两个版本的员工手册同时在库中,模型可能读到“次年 6 月底”这样的旧规定;检索时加上 version = 2026 的条件后,Recall@1 从 0.85 提高到 0.93(见附录 A12)。
多租户的系统可以把租户 ID 设为分区键,查询条件中带上租户 ID,就只搜索对应的分区。Milvus 默认只建 16 个分区,一个分区中通常有多个租户,所以仍要按租户 ID 过滤;租户少、隔离要求高时可以每个租户一个集合。
2. 更新与删除
向量索引的修改比普通数据库贵。HNSW 插入一个向量相当于做一次检索,再为它和邻居建立连接;删除更麻烦,从图中删掉一个节点会破坏其他节点的连接,所以一般只做标记,检索时跳过,之后再统一清理;更新等于删除加插入。
以 Milvus 为例,新写入的数据先进入可写的段(Growing Segment),持久化之后变为不可修改的段(Sealed Segment),再在这个段上建立索引;删除只是记下被删除的主键,查询时过滤掉,由后台的压缩(Compaction)在合并或重写段时真正去掉;一个段中被删除的比例达到阈值(默认 20%)是触发压缩的条件之一。所以大量删除和更新会累积被标记删除的数据,压缩之前它们仍然占用内存、拖慢检索。
文档修改后,按文档 ID 和内容的哈希值找出变化的文档,删除它的全部旧切片再写入新切片,不需要重建整个索引;只追加新切片会让旧内容继续被召回。只是权限或版本状态变化时,更新切片上的元数据即可,不必重新计算向量。文档的原文和元数据一般以关系数据库为准,向量库按切片 ID 幂等写入,失败时重试,并定期对账两边的数据。要写入向量库的切片可以在修改文档的同一个事务中记一条同步记录,由后台读取后写入向量库,做法同本地消息表,文档写成功、向量写失败时有记录可以重试。
库存、价格这类变化频繁、要求准确的数据以业务系统为准,回答时用工具查询,知识库主要保存说明类、变化较慢的内容。有有效期的内容,例如促销规则,在元数据中记下生效和失效时间,检索时按问题涉及的时间过滤,问的是现在的规则就用当前时间。
哈希只能发现被修改的文档,整篇源文件被删除时,要靠文档系统的删除事件,或者定期拿源系统的全部文档 ID 与库中比对,删掉多出来的切片。
删旧切片和写新切片不是一次完成的,期间查询可能缺少这篇文档或新旧内容并存;在意这一点时,先写入带新版本号的切片,再把元数据中的当前版本切过去,最后删除旧切片。另外,Milvus 默认的一致性级别是 Bounded,默认容忍最近约 5 秒内写入的数据不可见;查询必须看到刚才的写入时指定 Strong,只需要读到同一个客户端自己的写入时可以用 Session,代价是查询要等待数据可见。
更换 Embedding 模型或切片方式时,所有向量都要重新计算。这时在新的集合中建好索引并评测,再把查询使用的别名(Alias)从旧集合切到新集合,切换过程中查询不中断,出问题时切回去。Milvus 和 Elasticsearch 都支持别名。换了模型时,查询向量也要用新模型计算,所以查询用的模型和集合作为一组配置同时切换、同时回滚。
3. 大规模数据
按上一节的估算,1 亿个 512 维的向量用 IVF-PQ 只需要约 7 GB 内存,单机可以放下。1000 亿个向量即使压缩到每个 72 字节也需要 7.2 TB,只能分片:数据按主键或分区键分到几十台机器上,查询时每个分片各自检索出 Top-K,再合并成全局的 Top-K。在此基础上,用分区键或时间范围缩小每次查询的范围,选择维度更低的模型或更短的编码,或者换用 DiskANN 这类基于磁盘的索引,都能进一步降低成本。
4. 选型
选型主要看数据量、写入频率和已有的技术栈。已经在用 PostgreSQL 或 Elasticsearch、数据量不大时,直接用它们的向量功能(pgvector、dense_vector),向量和业务数据或全文索引放在一起,少维护一个系统;数据量大、写入频繁时,用 Milvus 这类专门的分布式向量数据库。Chroma 可以嵌入在 Python 程序中运行,适合原型和小规模数据;不想自己运维时可以用 Pinecone 这类托管服务。各产品的功能变化较快,以官方最新文档为准。
混合检索
向量检索擅长匹配意思相近但措辞不同的内容,却不擅长精确匹配。例如查询“错误码 E1024”,E1024 和 E1025 的向量非常接近,向量检索可能把 E1025 的文档排在前面。关键词检索正好相反,常用的算法是 BM25,它给文档打分时主要考虑三点:
- 词频:查询词在文档中出现的次数越多分数越高,但增长逐渐放缓,出现 10 次并不比出现 5 次重要一倍;
- 逆文档频率:越少见的词权重越高,E1024 只出现在少数文档中,权重远高于“错误码”;
- 文档长度:词频相同时,较短文档的分数更高,避免长文档仅仅因为字数多而得分高。
写成公式,t 遍历查询中的每个词:
score(q, d) = Σ IDF(t) × f × (k1 + 1) / (f + k1 × (1 − b + b × |d| / avgdl))
IDF(t) = ln(1 + (N − n + 0.5) / (n + 0.5))
其中 f 是 t 在文档 d 中出现的次数,|d| 是文档的长度,avgdl 是所有文档的平均长度,N 是文档总数,n 是包含 t 的文档数。Elasticsearch 中 BM25 的参数 k1 和 b 默认为 1.2 和 0.75,分别控制词频的饱和速度和文档长度的影响程度。
与经典的 TF-IDF 相比,BM25 改进了两处:TF-IDF 的得分与词频成正比,BM25 的词频部分不超过 k1 + 1,文档长度等于平均长度时,出现 1、5、10 次分别得 1.0、1.77、1.96;另外 BM25 按文档长度做了归一化。
用同一个例子对比两种检索。查询“错误码 E1024 是什么意思”,候选文档是“错误码 E1024:库存不足”和“错误码 E1025:支付超时”:bge-small-zh 给出的相似度分别为 0.762 和 0.743,几乎分不开;BM25 中“错误码”出现在每篇文档里,逆文档频率很低,E1024 只出现在一篇文档中,权重高得多。在这两篇加一篇“常见错误码说明”组成的小语料上,按 k1 = 1.2、b = 0.75 计算,第一篇得 1.088 分,其中 E1024 贡献 0.958(IDF 为 0.981),“错误码”只贡献 0.130(IDF 为 0.134);第二篇只命中“错误码”,得 0.130 分(见附录 A1)。
因此通常两路同时召回,再合并结果。两路的分数不能直接相加,BM25 的分数没有上限,余弦相似度在 -1 到 1 之间。倒数排名融合(Reciprocal Rank Fusion, RRF)只看排名:文档的得分是它在各路结果中排名的倒数之和,即 Σ 1 / (k + rank),k 通常取 60。例如:
| 文档 | 向量检索排名 | BM25 排名 | RRF 得分 |
|---|---|---|---|
| A | 1 | 2 | 1/61 + 1/62 = 0.0325 |
| C | 3 | 1 | 1/63 + 1/61 = 0.0323 |
| B | 2 | - | 1/62 = 0.0161 |
| E | - | 3 | 1/63 = 0.0159 |
| D | 4 | - | 1/64 = 0.0156 |
两路结果中都排名靠前的 A 和 C 排在最前面。也可以把两路的分数分别归一化到 0 到 1 之后按权重相加。
关键词一路也可以不用 BM25。BGE-M3 这类模型在输出稠密向量的同时,为文本中的每个 Token 算一个权重(ReLU(W·h),h 是这个 Token 最后一层的输出),得到学出来的稀疏向量;同一个 Token 出现多次时只保留最大的权重,查询和文档的相关性是两边都出现的 Token 的权重乘积之和。它和 BM25 一样按词匹配,但权重由模型学出,一次编码同时得到两种向量。
混合检索不一定比单路好,权重要在评测集上确定。BM25 的优势在错误码、型号、函数名这类精确匹配上;在切片实验的 40 个口语化问题上,等权 RRF 的 Recall@3 为 0.950,低于单独的向量检索(0.975),BM25 的权重降到 0.5 后为 1.0(见附录 A30)。也可以只在查询含有编号时提高 BM25 的权重。
重排
重排(Rerank)用来对召回的几十条候选重新打分排序,只保留最相关的几条。向量检索已经计算过相似度,还需要重排,是因为两种模型的计算方式不同:
- Embedding 模型分别编码问题和文档,文档的向量可以提前计算并建立索引,速度快;但问题和文档在编码时互相看不到,细节的对应关系在压缩成一个向量时丢失了;
- 重排模型把问题和文档拼成一个输入一起编码,称为交叉编码器(Cross-Encoder),注意力可以在问题和文档的每个词之间计算,判断更准确;但每一对都要重新计算,只能处理少量候选。
所以先用速度快的检索从海量数据中召回几十条,再用准确的重排模型选出最终的几条,与搜索和推荐系统中先召回、后排序的分层思路一致。重排也不能作为第三路召回:它没有能提前算好的索引,按 bge-reranker-v2-m3 在 M1 Pro 上的实测速度估算,给 12.5 万条短文本全部打分约要 30 分钟(见附录 A30)。
实现时,把混合检索合并后的几十个候选分别与问题组成(问题, 切片)对,交给重排模型打分,输出的相关性分数可以用 sigmoid 映射到 0~1,排序后保留前几条,再用阈值去掉分数过低的。在 Top-K 检索一节混入 12.5 万条干扰文本的实验中,对 RRF 的前 50 个候选重排后 Recall@1 从 0.275 升到 0.700,例如“结婚可以休几天假?”排第一的从“每周可以实习几天?”变为婚假一节(见附录 A30)。
重排模型的训练与 Embedding 类似:每个问题配一个相关文档和若干难负样本,同一个问题的各个文档的分数一起做 softmax,损失是相关文档的交叉熵。常用开源的交叉编码器,例如 bge-reranker-v2-m3;也有基于大模型的重排模型,更准确但更慢。候选越多,越不容易漏掉相关的切片,延迟也越高,所以候选数量要在评测集上与延迟一起权衡。
Top-K 与阈值
1. 两个 K
RAG 中有两个 K:召回的 K 是每路检索取多少个候选交给重排,例如 50;放进提示词的 K 是重排之后保留几个,例如 3~5。前者影响能不能找到资料,后者影响模型能不能用好资料。
2. 召回的 K
召回的 K 在评测集上确定:计算不同 K 下的召回率,取召回率不再明显上升的位置。在切片一节的实验中,按小节切时 Recall@1 到 Recall@4 依次为 0.85、0.93、0.98、1.0,之后不再变化(见附录 A12)。有重排时召回的 K 可以取大一些,排序交给重排模型。
3. 放进提示词的 K
放进提示词的 K 不是越大越好。Token 越多,成本和延迟越高;排名靠后的切片大多是无关或者半相关的内容,也可能是旧版本、互相矛盾的规定,模型可能用错资料;上下文越长,模型越容易忽略中间的信息,见上下文工程。例如 Lost in the Middle 的开放域问答实验中,检索到的文档从 20 篇增加到 50 篇,检索的召回率还在上升,GPT-3.5-Turbo(16K)的回答正确率只提高约 1.5 个百分点,输入长度却显著增加(2023 年的模型,arXiv 2307.03172)。
K 太小,需要多个切片才能回答的问题又会答不全,例如“几点以后算迟到”需要同时用到工作时间和迟到的定义两节。所以这个 K 要用端到端的回答正确率来确定,并受上下文预算的约束。
4. 相似度阈值
阈值用于丢掉不相关的结果,并识别资料中没有答案的问题。但向量相似度的绝对值很难设定阈值。在同一份数据上加入 10 个资料中没有答案的问题,其中“离职需要提前几天申请”与年假的“3.2 申请”一节的相似度为 0.688,比部分有答案的问题还高:
| 阈值 | 有答案的问题被误拒 | 没有答案的问题被放行 |
|---|---|---|
| 0.50 | 0% | 40% |
| 0.55 | 2.5% | 20% |
| 0.60 | 10% | 10% |
向量相似度的阈值只能在误拒和放行之间折中,适合用较低的值过滤明显无关的结果。判断有没有答案,更多依靠重排模型的分数(它直接判断问题与切片是否相关,阈值同样要在评测集上确定),以及在提示词中要求资料不足时回答不知道。
更换模型后阈值要重新确定,不同模型的分数分布不同。例如 bge 的模型卡说旧版模型的分数集中在 [0.6, 1],按自己数据的分布选阈值时举例 0.8、0.85 甚至 0.9;而在附录 A12 的数据上,bge-small-zh-v1.5 对有答案问题的最高相似度只有 0.774,照搬 0.8 会拒掉全部问题。
查询改写
用户的原始问题往往不适合直接检索:
- 指代:先问“Java 8 的 HashMap 怎么扩容”,再问“那 ConcurrentHashMap 呢”,第二个问题单独检索找不到有用的内容,需要结合历史改写为“Java 8 的 ConcurrentHashMap 怎么扩容”;
- 问题太短:问题只有几个字,切片有几百字,两者的向量不容易靠近。HyDE 的做法是先让模型写一段假设的回答,再用这段回答去检索,因为回答在形式上更接近文档;
- 包含多个子问题:拆成多个查询分别检索,再合并结果。
改写也可以扩展成多路召回:RAG-Fusion 让模型把问题改写成几个不同角度的查询,每个查询分别检索,再用 RRF 合并全部结果,覆盖的措辞更多,但改写偏离原意时会引入无关的结果;HyDE 生成的假设回答也可以作为其中一路。
改写需要多调用一次模型,会增加延迟。改写也可能变差:模型可能漏掉上下文或者偏离原意,改得差时检索效果反而不如原问题。
HyDE 这类生成扩展内容的方法,通常对原本检索效果较差的模型(例如 BM25)提升明显,对效果已经较强的 Embedding 模型和交叉编码器重排收益有限,甚至下降。作者对下降样例的人工分析中,多是扩展加入了无关或错误的词,干扰了相关性判断(Weller 等,arXiv 2309.08541,英文基准)。
已经用了混合检索和重排时,扩展要在评测集上确认有提升再加,也可以只用于召回差的查询;首轮和本身已经完整的问题可以不改写;评估改写时,用同一批标注了相关切片的问题,比较原问题和改写后的问题的 Recall@K、MRR,指标见评测与优化。
评测与优化
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,Precision@5 = 2 / 15 = 0.13。一个问题需要多个切片、并且切片的重要程度不同时,三个指标的区别更明显:
只关心资料有没有找全,看 Recall;用户通常只看第一条结果的场景,看 MRR;有分级标注、要评价整体排序质量时,看 NDCG。上面的 NDCG 用 scikit-learn 的 ndcg_score 核对过(见附录 A15)。
召回率和精确率通常要取舍:K 增大时召回率不会下降,但往往混进更多无关切片,精确率随之降低,所以召回率提高了,最终回答不一定更准,原因见放进提示词的 K。召回阶段看召回率,放进提示词的切片还要看精确率和端到端的回答正确率。
2. 生成指标
生成的质量主要看两点:
- 忠实度(Faithfulness):回答中的每个结论是否都能在检索到的资料中找到依据;
- 答案相关性(Answer Relevance):回答是否回应了问题,不考虑对错。
这两个指标难以用规则计算,通常让另一个模型来打分(LLM-as-a-Judge)。RAGAS 的做法是把打分拆成模型容易做对的小步骤:计算忠实度时,先让模型把回答拆成若干条独立的陈述,再逐条判断能否从资料中推出,得分为被支持的陈述数除以陈述总数;计算答案相关性时,让模型根据回答反推出几个问题,再计算它们与原问题的 Embedding 余弦相似度的平均值,答非所问时反推出的问题与原问题差别大,得分低。
用 Qwen2.5-7B-Instruct 作评判模型,对“工作两年能休几天年假?没休完的怎么办?”的三个回答打分,资料是 2026 版的 3.1 天数和 3.3 结转两节(见附录 A14):
| 回答 | 忠实度 | 答案相关性 |
|---|---|---|
| 5 天;可以延到次年 3 月底前,逾期作废 | 2 / 3 | 0.711 |
| 5 天;可以延到次年 6 月底前 | 0 / 2 | 0.713 |
| 年假以半天为单位,3 天以内提前 3 个工作日申请 | 0 / 2 | 0.665 |
第二个回答把 3 月底写成了 6 月底,忠实度能发现,答案相关性发现不了,所以两个指标要一起看。评判模型本身也会出错:“工作两年有 5 天年假”需要从“满 1 年不满 10 年”推出来,7B 模型判为不支持,使正确的回答只得了 2 / 3。因此评判模型要选能力较强的,并抽样人工核对它的打分。
忠实度低说明回答中有资料推不出的内容,可能是编造,也可能是用了模型自己的知识,它不说明检索的好坏。ragas 库中的 Context Precision、Context Recall 要对照标准答案判断检索结果,属于检索指标,与论文中不需要标准答案的指标不同。
忠实度检查回答有没有资料支持,答案相关性检查有没有回应问题,两者都不能代替与标准答案比对对错。评测集有标准答案时,还要比较回答与标准答案:天数、日期这类短答案规范化后直接比较;长回答拆成几条陈述,与标准答案逐条比对,分别统计说错的和漏掉的,用模型比对时把标准答案交给它并要求以此为准,见模型裁判的偏差。标准答案要写全要点,否则回答中正确但标准答案没写到的内容会被算成错误。
3. 评测集
指标要在一批固定的问题上计算。问题优先从真实的用户问题中抽样,不够时让模型根据文档生成,再人工改写措辞,因为模型生成的问题往往照抄原文的词语,会让检索的评测结果偏高,尤其容易让按词匹配的 BM25 受益,向量检索和混合检索也会受影响。除了常见问题,还要包含资料中没有答案的问题、需要多个切片的问题和新旧版本冲突的问题。没有答案的问题,标准答案标为“资料中没有”,回答不知道算对,编出答案算错;它和有答案的问题被误拒要一起统计,两者的取舍与相似度阈值相同。
每个问题标注标准答案和回答所需的原文片段,而不是切片 ID,这样改变切片方式之后标注仍然可用,切片一节的实验就是这样判断是否找到的。几十到几百个问题就可以比较不同的方案(差距只有几道题时要结合逐题的结果判断,比较方法见 Agent 的评测集与线上指标),每次调整解析、切片、模型或参数,以及知识库中新增大量文档之后,都重新评测;线上发现的错误回答持续补充进来,避免评测集与真实的问题分布脱节。
只标了一部分相关片段时,没标到的结果会被当作不相关,换一种检索方式后分数可能偏低。BEIR 在 TREC-COVID 上统计,稠密检索模型 TAS-B 的前 10 个结果中有 31.8% 没被标注过,补标之后 ANCE 的 nDCG@10 从 0.654 升到 0.735,BM25 只从 0.656 升到 0.668。比较新的检索方案之前,先检查前 K 个结果中有多少没被标注,补标后再比较。
补标可以先交给大模型,但它容易把含有问题关键词、却没有给出答案的片段判为相关,例如 Alaofi 等人把查询词插入无关的文本后,多个模型会因此误判为相关(arXiv 2501.17969)。所以要先用人工标注核对模型的判断,重点检查判为相关的片段;也不要让系统中做重排的同一个模型来补标,否则评测会偏向它自己的排序。
4. 排查顺序
回答效果不好时,按数据流动的顺序从前往后排查,因为前面的错误后面无法弥补:
| 步骤 | 怎么检查 | 常见问题与优化 |
|---|---|---|
| 解析 | 打开相关文档的解析结果,与原文对比 | 表格错乱、页眉混入、扫描件没有 OCR:改用版面分析,表格转 Markdown |
| 切片 | 找到包含答案的切片,看答案是否完整地在一个切片中 | 答案被切断或者和无关内容混在一起:按结构切、加重叠、加标题路径 |
| Embedding 与检索 | 看相关切片在向量检索和 BM25 中分别排第几 | 措辞差异大:查询改写、换模型;专有名词、编号:混合检索;旧版本、无权限:元数据过滤;排名在 K 之外:增大 K |
| 重排 | 对比重排前后相关切片的排名 | 召回了但被排到后面:换重排模型,调整保留的数量 |
| 生成 | 把正确的资料直接交给模型,看能否答对 | 资料对但回答错:减少放入的切片,要求逐条引用,换更强的模型 |
最后一步也可以先做:把正确的资料直接交给模型,答不对说明问题在生成,答得对说明问题在前面的检索。
长上下文与 Agentic RAG
模型的上下文窗口越来越大,资料量不大时可以把全部资料直接放进上下文,不需要 RAG。但知识库很大时,RAG 仍然是必要的:
- 每次请求都处理大量 Token,成本高、延迟大;
- 上下文越长,模型对其中信息的利用越差,标称的窗口也不等于能用好的长度。例如 RULER 测试中,Qwen2-72B 标称支持 128K,但得分超过 85.6(Llama2-7B 在 4K 上的得分,作为基准)的最大长度只有 32K,128K 时得分为 53.7(满分 100)。
普通的 RAG 每个问题只检索一次。问题需要多步查找时,例如“负责支付系统的团队的负责人是谁”,要先查到团队,再查负责人。这时可以把检索作为一个工具交给 Agent,由模型决定检索什么、检索几次,称为 Agentic RAG。它更灵活,但调用次数和延迟都会增加,问题简单时固定流程的 RAG 更可控。
AI 编程助手查找代码也是这种方式:Claude Code 用 find、grep 这类按文件名模式和正则表达式搜索的工具,模型根据结果再换关键词或者打开文件。代码中的函数名、错误码需要精确匹配,代码又在不断修改,向量索引容易过期,而 grep 每次搜索的都是最新的文件。
RAG 解决的是模型缺少知识的问题,固定输出风格或者学习专门的任务需要微调,两者的选择见微调。
GraphRAG
按切片检索有两类问题处理不好。一类是多跳问题,例如上一节的“负责支付系统的团队的负责人是谁”,“支付系统由交易平台组维护”和“交易平台组的负责人是王磊”在两份文档里,问题与第二句的相似度很低;另一类是全局问题,例如“今年的制度主要改了哪些地方”,答案分散在整个知识库中,取几个切片覆盖不了。
GraphRAG 在建索引时让模型从每个切片中抽取实体和关系,组成知识图谱:
(支付系统, 维护团队, 交易平台组) 来自《系统清单》
(交易平台组, 负责人, 王磊) 来自《组织架构》
查询时先找到问题中的实体“支付系统”,沿着边走两步就得到答案,再把相关的原文交给模型生成回答,这是图检索的一般做法。微软的 GraphRAG 的局部搜索(Local Search)则用问题的向量匹配实体描述的向量,取出这些实体相邻的关系、所在社区的摘要和原文,一起放进一次上下文。
对于全局问题,微软的 GraphRAG 用社区检测算法把联系紧密的实体分组,为每组生成一段摘要;回答时先让每个摘要分别给出部分回答,再汇总成最终的回答,称为全局搜索(Global Search)。
代价是建索引要对每个切片调用大模型,比计算 Embedding 贵得多,抽取的错误会进入图中。
全局搜索每次查询也要读一层全部的社区摘要,例如论文中约 100 万 Token 的播客语料,中间一层的社区摘要共约 57 万 Token,最上层只有约 2.7 万 Token,但答案的细节也少。
文档更新时,微软的实现(v3.3.0)可以只抽取新增的文档再合并到原来的图中,但原有的社区和摘要不会重算,删除或修改文档一般要重建。
所以 GraphRAG 适合实体和关系密集的资料,例如组织架构、系统依赖、条款之间的引用;普通的问答用向量检索加关键词检索就够了。
多模态 RAG
资料中常有流程图、截图和图表。最常用的做法是先把图片转为文字:用 OCR 提取图中的文字,再让多模态模型为图片生成一段描述,之后按普通文本切片和检索,整个流程不需要改动,但描述中可能丢失图里的细节。
不转文字的做法是让文字和图片共用一个向量空间。CLIP 用大量的图片和文字说明训练两个编码器,使配对的图片和文字的向量互相靠近,这样文字的问题可以直接检索图片;ColPali 进一步把每页文档的截图直接编码为多个向量(每个图块一个),省去 OCR 和版面分析,适合排版复杂、图表多的文档。
检索到图片之后,生成用的是多模态模型时,可以把图片直接放进上下文让它读图回答;只能用文本模型时,就只能使用图片的文字描述,模型并没有看到图片本身。
Text2SQL
“10 月的销售额是多少”这类问题要对表中的数据做筛选和汇总,答案不在任何一段文字里,把表的内容切片检索也算不出来。自然语言转 SQL(Text-to-SQL,常写作 Text2SQL)指的是让模型把问题写成 SQL,由程序执行,再把结果交给模型组织成回答,或者直接展示成表格。
主要的难点有两个:
- 表结构对应:问题中的“品类”“支付用户”对应哪张表的哪一列、表之间怎样关联,表很多时还要先选出相关的表,只把它们的结构放进提示词;
- 业务口径:“销售额”算不算已退款的订单、“客单价”除以订单数还是用户数,表结构里看不出来。
BIRD 基准中,GPT-4 只看表结构时执行准确率为 34.88%,再给出口径和取值含义这类外部知识后为 54.89%(arXiv 2305.03111)。
例如订单表 orders 有金额 amount 和状态 status,明细表 order_items 有品类 category、单价和数量,字段都带注释,用 6 个 2026 年 10 月的统计问题测试 Qwen2.5-7B-Instruct(见附录 A34)。
只给表结构时答对 1 个:3 个问题的口径错了,例如销售额把已退款、已取消和待支付的订单也加了进去,得到 1290 而不是 620;另外 2 个用了表中不存在的列,执行报错。在提示词中写明 6 个指标的口径后,6 个都答对。
口径写在提示词里,换一个应用、一张报表就可能出现另一种算法。语义层(Semantic Layer)把指标的计算方法和可以分组的维度统一定义好,模型只输出要查哪个指标、按什么分组、查哪个月,由代码或引擎生成 SQL,例如 dbt 的 MetricFlow、Cube。
上面的例子改成这种方式后,模型对“10 月每个品类的销售额”输出:
{"metric": "销售额", "group_by": ["品类"], "month": "2026-10"}
代码按“销售额”的定义生成 SQL,按品类分组时用明细的单价乘数量,只算已支付的订单:
SELECT i.category, SUM(i.price * i.quantity)
FROM orders o JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID' AND o.created_at >= '2026-10-01' AND o.created_at < '2026-11-01'
GROUP BY i.category;
6 个问题也都答对。其中一次模型输出的 JSON 没有给“销售额”加引号,带着解析错误重试一次后改对,用约束解码可以避免这类问题。
表之间的关联也交给语义层。订单关联到明细后,订单上的金额随明细重复出现,例如订单 1001 有 3 条明细,直接对 orders.amount 求和时它的 300 元被算了 3 次,10 月的销售额变成 1220。MetricFlow 按事先声明的实体关系选择关联路径,并限制这种一对多的扇出(fan-out)关联。
语义层的代价是要先建模、持续维护,而且只能查已经定义的指标和维度。定义之外的问题要先补充定义,临时的查询也可以退回由模型或分析师直接写 SQL。
其它常用的做法:
- 表结构带上字段说明和取值的含义,例如 status 的每个取值代表什么;
- 把验证过的“问题—SQL”存下来,按问题的相似度检索几条放进提示词作为示例;
- SQL 执行出错时把错误信息回传给模型修改,结果为空或明显不合理时向用户确认口径;
- 用只能读取指定视图的账号执行,并限制执行时间和返回的行数,见限制模型能造成的后果。
四、Agent
Agent 是什么
普通的大模型调用是一问一答:模型根据输入生成一段文本。它无法自行查询数据库、调用接口,只能依据训练中学到的知识和输入中给出的资料,更不能替用户执行操作。
Agent(智能体)指的是让大模型通过调用工具来完成任务的系统。模型负责决定下一步做什么,应用程序负责执行,并把结果交还给模型,如此循环,直到任务完成。例如用户问“订单 1024 到哪了”:
需要注意的是,模型始终只输出文本,查询数据库、调用接口都是应用代码完成的。模型的作用是在每一步做出决策,应用的作用是执行决策、控制整个循环。
传统的客服机器人与 Agent 的区别在于流程由谁决定:前者识别出意图后按开发者事先设计好的流程处理;Agent 的下一步由模型根据任务和工具结果临时决定,同一组工具可以组合出事先没有写好的流程,代价是行为不完全可预测,见 Agent 与工作流。
一个 Agent 通常由以下几部分组成:
- 模型:理解任务,决定下一步做什么;
- 工具:模型可以使用的函数或接口,用于获取信息和执行操作,见工具调用;
- 记忆:保存对话历史、用户偏好等信息,需要时放进上下文,见记忆;
- 规划:把复杂的任务拆成多个步骤,见规划;
- 控制循环:应用代码中反复调用模型、执行工具的循环,同时负责限制步数、校验参数和权限,见 Agent 循环。
模型以外的部分合称 Harness。
工具调用
工具调用(Function Calling)指的是模型按照约定的格式给出要调用的函数名和参数,由应用程序执行。模型能够“调用工具”,过程如下:
- 应用在请求中附上工具定义,包括名称、用途和参数的 JSON Schema(见结构化输出);
- 推理服务按照模型的对话模板,把工具定义和调用格式的说明拼进提示词;
- 模型在训练时学习过这种格式,判断需要调用工具时,会生成一段特定格式的文本,而不是直接回答;
- 推理服务识别出这段文本,把它解析为结构化的 tool_calls 返回给应用。
工具调用本质上仍然是生成文本,所以模型可能调用不存在的工具、写错参数的格式,或者在用户没说订单号时自己编一个。很多服务提供严格模式,用约束解码保证参数符合 Schema,但编出来的订单号同样符合 Schema,所以应用仍要先校验再执行:缺少的信息向用户追问,见追问澄清;校验和出错后的处理见容错。
以 OpenAI 兼容的接口为例,用户问“订单 1024 到哪了”,请求中这样描述查询订单的工具(省略了查询物流的 get_logistics):
"tools": [{
"type": "function",
"function": {
"name": "get_order",
"description": "按订单号查询订单状态和运单号。用户询问订单、发货情况时使用",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号,例如 1024"}
},
"required": ["order_id"]
}
}
}]
模型决定调用工具时,返回的消息中带有 tool_calls,其中 arguments 是一个 JSON 字符串:
{"role": "assistant", "tool_calls": [{
"id": "call_1", "type": "function",
"function": {"name": "get_order", "arguments": "{\"order_id\": \"1024\"}"}
}]}
应用执行函数后,用 tool 角色的消息把结果发回。一次回复中可能包含多个调用,tool_call_id 用于对应是哪一次调用:
{"role": "tool", "tool_call_id": "call_1", "content": "{\"status\": \"已发货\", \"tracking_no\": \"SF1234567\"}"}
这些结构化的字段只存在于接口中。用 Qwen2.5-7B-Instruct 和 Hugging Face Transformers 运行“订单 1024 到哪了”这个例子,可以直接看到模型收到和输出的原始文本:
工具定义被拼进了系统消息,模型输出的是包在 <tool_call> 标签里的一段 JSON,应用用正则把它解析出来;工具结果则被包在 <tool_response> 中,作为下一条消息发回。不同模型的标签不同,但原理相同,OpenAI 兼容接口中的 tool_calls 字段就是推理服务从这段文本中解析出来的。
需要注意的是,tool_call_id 只在接口中用来对应调用和结果,模型不一定看得到它。以 Qwen2.5 的对话模板为例,一次回复中的两个调用和两条结果渲染后没有 id,两个 <tool_response> 依次放在同一条 user 消息中,模型只能按顺序对应,所以多条结果要按调用的顺序追加(见附录 A25)。
工具结果有单独的标记(OpenAI 兼容接口是 tool 角色,Claude 的接口是 user 消息中的 tool_result 块,Qwen2.5 的对话模板把它放在 user 轮的 <tool_response> 标签中),模型能区分这是工具返回的数据而不是用户的指令,但这并不能防止结果中藏有恶意指令,见安全。
模型只能根据工具的名称、描述和参数说明来决定是否调用、怎么填参数,所以工具的设计直接影响调用的准确率:
- 描述写清楚用途和使用时机,参数给出格式和示例;
- 粒度按任务划分:过细时一个任务要连续调用多个工具,模型容易漏掉一步,例如下面的例子中模型查完订单就停了,总是连着调用的几步可以合成一个工具;过粗时一个工具靠很多参数区分多种用法,参数容易填错;
- 工具之间的职责不要重叠,否则模型容易选错;
- 返回结果要精简,只返回模型需要的字段,大段的原始数据会占用上下文;
- 出错时返回能指导模型修正的信息,例如“日期格式应为 2026-01-01”,而不是只返回“参数错误”。
描述写得是否清楚,对结果的影响很大。在上面的例子中,系统提示词只写“你是电商客服助手,回答要简短”时,7B 模型查到运单号就直接回答“已发货,运单号是 SF1234567”,没有继续查物流;采样运行 10 次,1 次也没有答出包裹的位置。在系统提示词中写明“用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间”后,10 次全部依次调用了两个工具,回答“您的包裹目前位于杭州转运中心,预计明天送达”。同样的提示词换成 3B 模型,20 次都只调用了一个工具;模型能力不足时,提示词也弥补不了(Qwen2.5 实测,见附录 A2)。
工具很多时,全部的工具定义会占用大量上下文,模型也更容易选错。可以只加载与当前任务相关的工具,例如先根据问题检索出最相关的十几个工具,或者把工具分组交给不同的子 Agent。
Agent 循环
把工具调用放进一个循环,就得到了 Agent。大多数 Agent 按照 ReAct(Reasoning and Acting)的方式运行:模型先思考下一步做什么,然后调用工具,把工具返回的结果作为观察放回上下文,再进入下一轮思考,直到信息足够给出最终回答。
从接口的角度看,应用每次调用模型时发送的消息如下:
- 模型不保存状态,每次调用都要发送完整的消息历史,所以步数越多,发送的消息越长,Token 消耗也越多;
- 每一步新增两条消息:模型的调用请求,以及应用执行工具得到的结果;
- 第 3 次调用时模型返回的是文本而不是调用请求,说明它认为信息已经足够,循环结束。
用代码表示这个循环:
String run(String question) {
List<Message> messages = new ArrayList<>();
messages.add(Message.system(SYSTEM_PROMPT));
messages.add(Message.user(question));
for (int step = 0; step < MAX_STEPS; step++) {
Message reply = llm.chat(messages, tools); // 每次都发送完整的历史
messages.add(reply);
if (reply.toolCalls().isEmpty()) {
return reply.content(); // 不再调用工具,得到最终回答
}
for (ToolCall call : reply.toolCalls()) {
String result = execute(call); // 由应用执行,出错时返回错误信息
messages.add(Message.tool(call.id(), result));
}
}
return "超过最大步数,转人工处理";
}
什么时候结束由模型判断,但模型可能一直不停,或者反复调用同一个工具,所以代码中必须限制最大步数,见下文的可靠性与评测。
代码中的 tools 就是工具的注册表:以工具名为键,保存发给模型的定义和执行它的函数,请求时把其中的定义放进 tools 参数,模型返回调用后按名字找到函数执行,新增一个工具只要注册一项。
execute 不抛出异常,而是把错误写成文字作为工具结果返回,模型看到后可以修正参数、换一个工具或者向用户说明。下面是简化的写法,省略了参数校验和临时性错误的重试,实际应用中超时、限流这类错误先由代码重试,仍然失败或者需要模型处理的错误才作为工具结果返回,见容错:
String execute(ToolCall call) {
Tool tool = tools.get(call.name());
if (tool == null) {
return "错误:没有名为 " + call.name() + " 的工具,可用的工具有 " + tools.keySet();
}
Map<String, Object> args;
try {
args = parseJson(call.arguments());
} catch (Exception e) {
return "错误:参数不是合法的 JSON:" + e.getMessage();
}
try {
return tool.call(args);
} catch (Exception e) {
return "错误:" + e.getMessage(); // 例如“订单 9999 不存在”
}
}
模型在一次回复中给出多个工具调用,说明它认为这些调用互不依赖,例如同时查询两个订单,代码可以并发执行,全部完成后再调用模型:
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 实测,见附录 A25)。有依赖关系的调用,模型会分在前后两轮给出,例如先查订单拿到运单号,再查物流,所以循环本身不需要编排调用的顺序。但模型也可能判断错,修改数据的调用(例如同一个订单的改地址和退款)即使在同一次回复中给出,也要按顺序执行。部分调用失败时的处理见可靠性与评测。
耗时很长的工具,例如生成报表、等待人工审批,不适合在循环中同步等待。可以让工具先返回一个任务 ID,保存当前的消息列表后结束这一轮;任务完成后通过消息队列或者回调通知应用,应用把结果作为工具消息追加到保存的消息中,再继续调用模型。这与开发框架中暂停和恢复的做法相同。
Agent 与工作流
与 Agent 相对的是工作流(Workflow):执行哪些步骤、按什么顺序执行都由代码事先写好,大模型只负责其中的某些步骤,例如对用户的消息分类。常见的工作流有链式(Prompt Chain,上一步的输出是下一步的输入)、路由、并行,以及生成后由另一次调用评审、不合格就退回重写。还有一种介于两者之间:子任务由模型根据输入临时拆分,但拆分、分派和汇总的骨架由代码写好,见多 Agent 一节的主管分派。
- 工作流可预测、成本可控,适合步骤固定的业务;
- Agent 更灵活,适合无法事先确定步骤的开放任务,代价是成本更高,错误会在多个步骤之间累积。
例如每一步的成功率是 95%,连续 10 步都成功的概率只有 0.9510 ≈ 60%。工作流同样会累积错误,但它的步骤和转移规则由代码事先定义,便于在步骤之间加入校验,不合格就停下或重试;Agent 也可以在工具调用前后校验,但执行路径由模型临时决定,更难事先覆盖所有情况。因此应该先尝试单次调用或者工作流,确实需要时再引入 Agent,也可以只在工作流的某一步中使用 Agent。
判断用哪一种,看步骤和步骤之间的转移规则能否事先写清。例如排查“订单 1024 为什么没发货”,如果原因只有库存不足、地址异常这几种,可以写成工作流的分支,分别查补货单和风控记录;原因五花八门、要根据查到的线索决定下一步查什么时,路径事先写不全,才交给 Agent。
实际的客服系统大多是两者的组合:先用意图路由把请求分到退款、物流等流程,退款这类涉及资金、步骤固定的流程写成工作流,商品咨询这类开放的问题交给带检索工具的 Agent。路由见下一节,流程的编排见开发框架。
把工作流改造成 Agent 时,原来的节点包装成工具,流程规则写进系统提示词或 Skill,调用顺序交给 Agent 循环。走哪些步骤改由模型决定后,“退款前必须人工确认”这类约束不再由流程的结构保证,金额校验、权限和人工确认要放进工具的代码中,例如退款工具执行前先暂停等待审批。好处是新增场景不必改编排代码,也能处理“一件退款、另一件改地址”这类跨流程的请求;代价是延迟和成本上升、执行路径不再固定,上线前要在原工作流的评测集上对比成功率。
意图识别与路由
意图识别指的是判断用户想做什么,路由指的是根据判断结果把请求交给对应的流程、提示词、工具集或者模型。例如“订单 1024 到哪了”交给物流查询,“衣服尺码不合适想退”交给退款流程。
不把所有请求交给同一个 Agent,是因为不同类别的处理方式差别很大:退款要查订单、校验金额、请求确认,商品咨询要检索商品资料。全部写进一个提示词、提供全部工具,各类请求之间会互相干扰,工具越多也越容易选错;分开之后每个流程可以单独优化、单独评测,还能只给它需要的工具和权限,简单的类别交给更小、更便宜的模型。
1. 三种做法
- 规则:关键词、正则表达式,例如消息中有“退款”就进入退款流程。速度快、没有成本、结果可解释,但用户换个说法就匹配不到,不同类别的关键词还会撞车;
- 分类模型:用标注数据训练一个小模型,例如 BERT 类的文本分类模型,或者 Embedding 加一个简单的分类器。延迟在毫秒级,输出的概率可以作为置信度,但需要标注数据,增加类别要重新训练。也可以不训练,计算消息的 Embedding 与每类几条示例的类中心的相似度,选最近的一类,增加类别只要补示例,但同样要设阈值才能把不属于任何类别的消息分出来;
- 让大模型输出类别:在提示词中写清楚每个类别的定义,让模型只输出类别名。不需要训练数据,能理解没见过的说法,但每次请求多一次模型调用,输出还可能不在类别列表中。
用 60 条客服消息比较规则、Embedding 相似度和大模型(5 个类别各 12 条,其中 10 条含有其它类别的关键词,例如“钱退到哪了”含有“到哪”):
- 关键词规则的准确率是 73.3%。它判为退款、改地址的消息全部正确,但 12 条退款消息只找到 7 条,“收到的杯子碎了,怎么处理”没有命中任何关键词,“快递还没到,但我不想要了”因为“快递”被分到了物流;
- Embedding 相似度(bge-small-zh-v1.5 加每类 6 条示例)的准确率是 78.3%,示例太少时容易被字面相似误导,例如把“保修多久”分到退款;
- Qwen2.5-7B 的准确率是 95%,10 条含有其它类别关键词的消息全部分对。
需要注意的是,7B 模型把“快递员态度太差了,我要投诉”的类别输出成了“投诉”,这个类别并不存在(Qwen2.5 实测,见附录 A6)。
2. 怎样保证路由准确
- 类别定义互斥,并写明边界,例如“退款进度属于退款,不属于物流”“购买前问发货时间属于商品咨询”。分错的消息大多落在两个类别的边界上,补充定义比换模型更有效;
- 校验输出:类别名必须在列表中,用结构化输出把输出限制为枚举值,不在列表中的按“其它”处理;
- 分层:规则只处理精确率高的情况,例如“转人工”、消息中带有订单号格式的查询,其余的交给模型。类别或工具有上百个时也可以逐级分类,先分到售后、物流这样的业务域,再在域内细分或按问题检索工具;
- 低置信度兜底:置信度低于阈值时不硬分,而是反问澄清(“您是想查物流还是申请退款?”),或者交给通用的 Agent、转人工。已经进入流程后发现分错(例如用户说“我不是要退款”),流程把请求退回路由重新分类,而不是继续执行;
- 看上下文:多轮对话中的“那退了吧”单独看无法分类,要把最近几轮对话一起交给分类器,或者先把它改写成完整的问题;一句话包含多个意图时,拆成多个请求,或者交给能分派任务的主 Agent。
阈值要在验证集上扫一遍再定:阈值越高,自动路由的比例越低,准确率一般越高。例如上面 Embedding 相似度方法的置信度阈值从 0.5 提到 0.7,自动路由从 70% 降到 48%,准确率从 90.5% 升到 96.6%,其余转入兜底流程。按兜底流程能承接的量和可以接受的错误率选一个点,分错代价高的类别(例如会进入退款流程的)可以单独设更高的阈值。
阈值的效果还取决于置信度是否可靠:7B 模型分错的 3 条中有 2 条的置信度高于 0.93,阈值几乎拦不住。大模型的输出概率往往过于自信,部分模型在 RLHF 等后训练之后校准明显变差,可以在验证集上做温度缩放,把过于集中的概率摊平,或者多次采样看结论是否一致;让模型自己报置信度,或者在类别中加一个“不确定”让模型自己选,效果都难以保证,要在验证集上检验。
路由的准确率要靠评测集来衡量和守住:
- 按类别收集样本,每类数量接近,特别要包含容易混淆的消息和不属于任何类别的消息,后者要单独统计召回率,例如在 CLINC150 数据集上,BERT 对 150 个意图的准确率约 96%~97%,范围外的消息却只召回 40.3%(把范围外当作一类训练)或 52.3%(按概率阈值判断)(Larson 等,arXiv 1909.02027);
- 按类别看精确率(判为这一类的消息中有多少是对的)和召回率(这一类的消息找回了多少),两种错误的代价不同:误进退款流程的代价通常比漏判后转人工高。例如本例规则对退款的精确率是 100%,召回率只有 58%(7/12);
- 看混淆矩阵,即每个类别被错分到了哪里,例如本例中规则的 16 个错误有 9 个是没有命中任何关键词、落入“其它”,5 个是被“快递”“到哪”这类词误导、分到了物流;
- 线上转人工、用户改口重说的会话说明路由可能错了,定期抽查后加入评测集;
- 修改类别定义、提示词或者模型之后,在同一个评测集上回归,确认没有让其它类别变差。
规划
规划要解决的是一个任务分几步、按什么顺序做、做错了怎么办。常见的几种方式的区别在于:推理是否与行动交替,是否先给出完整的计划,是否同时探索多条思路,失败后是否总结经验再试。
1. 思维链
思维链(Chain-of-Thought, CoT)指的是让模型先写出中间的推理步骤,再给出答案。例如“3 件 59.9 元的 T 恤,满 150 减 20,实付多少”,模型先写出 3 × 59.9 = 179.7,超过 150,再减 20 得 159.7。写出步骤之后,后面的每个 Token 都能参考前面算出的中间结果,多步计算和推理的准确率明显提高。
实现上,可以在少样本示例中写出推理过程,或者在提示词中要求“先逐步分析,再给出答案”;推理模型在训练中已经学会了这种方式,见推理模型。对推理模型通常不必再要求逐步思考:OpenAI 的文档认为这没有必要,有时还会变差;DeepSeek-R1 的论文报告少样本提示会降低它的效果,建议零样本直接描述问题和输出格式。
思维链只发生在一次调用之内,所用的信息全部来自上下文和模型自身的知识。如果某个事实记错了,后面的推理会顺着错误继续推下去。ReAct 论文在 HotpotQA 问答上做过人工分析:思维链的失败案例中 56% 是编造的事实,ReAct 为 0%。思维链适合信息已经在上下文中的推理和计算。
2. ReAct
ReAct 把推理和行动交替进行:每一步先思考,再调用工具,工具返回的真实结果作为观察放回上下文,再进行下一步思考,过程见 Agent 循环。它与思维链的区别在于事实来自外部,而不是模型的记忆。
论文中的 ReAct 让模型按 Thought、Action、Observation 的文本格式输出,再由代码解析;现在的实现直接使用模型原生的工具调用,思考写在回复的文本或推理内容中。
ReAct 适合下一步取决于上一步结果、步数不多的任务,例如先查订单拿到运单号,再查物流。它的问题是每一步都要调用一次模型,步骤只能串行;长任务中容易偏离最初的目标,或者反复执行同一个步骤,上面的分析中 ReAct 的失败有 47% 是推理错误,其中包括陷入重复的步骤。
3. 先规划后执行
先规划后执行(Plan-and-Execute)指的是先让模型把任务拆成步骤列表,再逐个执行。
实现时通常分为三个角色:
- 规划器:调用一次模型,按结构化输出生成计划,每个步骤带编号和依赖,例如 {"id": 5, "task": "汇总对比", "depends_on": [1, 2, 3, 4]},代码检查步骤数、用到的工具是否存在、依赖有没有成环;
- 执行器:按依赖顺序执行,没有依赖关系的步骤并行,每个步骤内部可以是一个小的 ReAct 循环,也可以使用更便宜的模型;
- 重规划:某一步失败、结果与计划的假设不符(例如查不到 B 的续航)时,把原计划和已有结果交给模型,由它决定修改剩余的步骤、继续还是结束。失败由代码根据工具报错和结果校验发现;也可以每完成一步都让模型判断一次,调整更及时,但每步多一次模型调用。
有的系统还单独设一个审查者(Critic),在每一步或者全部完成后检查结果是否满足目标和约束,例如对比表中每款产品的续航是否都来自查询结果,不满足时把具体的问题交给重规划或执行器。审查者依据工具结果、测试这类外部证据判断才可靠,只凭模型自己的判断复查效果有限,见下文的反思。
计划保存在状态中,可以向用户展示进度,中断后也能从未完成的步骤继续。重规划的次数要设上限,例如 3 次,超过后返回已完成的部分并说明原因,避免模型一直规划失败、无限循环。
两种方式的比较:
- 先规划后执行:互不依赖的步骤可以并行,进度容易展示和恢复;但计划可能与实际情况不符,需要重规划。它不一定比 ReAct 省:步骤直接调用不依赖模型的工具、也不重规划时,整个任务可以只调用两次模型,分别用于规划和汇总,ReWOO 论文报告它在 HotpotQA 上的 Token 消耗约为 ReAct 的 1/5;每个步骤再用模型执行,或者每步都重规划时,调用次数可能比 ReAct 还多;
- ReAct:可以根据中间结果随时调整方向;但每一步都要调用一次大模型,长任务中容易偏离最初的目标。
实际系统常把两者结合:先生成一份计划,执行每个步骤时使用 ReAct,并在执行过程中更新计划。
4. 思维树和反思
思维树(Tree of Thoughts, ToT)指的是每一步生成多个候选思路,评估每个候选的前景,只展开有希望的,走不通时回溯到别的分支。例如 4 件商品 120、90、60、30 元,每单满 150 减 25,按顺序把 120 和 90 凑成一单,剩下的 90 元凑不满第二单,只能减 25;如果在第一步列出三种搭配,评估“剩下的商品还能不能凑满 150”,就能找到 120 + 30 和 90 + 60 两单,共减 50。
搜索的框架由代码实现:每一步让模型生成 k 个候选,由模型打分或者代码校验,保留得分最高的几个继续展开。这种方式成本很高,论文中用 GPT-4 玩 24 点游戏,思维链的成功率为 4%,思维树为 74%,但每道题生成约 5500 个 Token,接近 100 次思维链的用量。它适合有明确评估标准、需要试探和回溯的问题,客服问答这类任务很少用到。
反思(Reflexion)指的是任务失败后,让模型根据反馈写一段反思,说明错在哪、下次怎么做,保存到记忆中,下一次尝试时放进上下文。论文在 HumanEval 代码生成上用自己生成的单元测试作为反馈,pass@1 达到 91%,同期 GPT-4 为 80%。
反思是否有效,关键在于反馈是否来自外部,例如测试结果、工具报错、校验规则。没有外部反馈时,让模型检查自己的答案往往发现不了错误,有研究发现在推理任务上这样做甚至会把对的答案改错,例如 gpt-3.5-turbo-0613 在 GSM8K 数学题上自查两轮后,8.8% 的题由对改错、7.6% 由错改对,准确率从 75.9% 降到 74.7%(Huang 等,arXiv 2310.01798)。因此工程上的反思通常由失败信号触发,例如测试不通过或者参数校验失败,并限制重试次数;反复出现的经验可以写进提示词或者 Skill,变成长期的规则,见 Harness。
5. 怎样选择
- 信息都在上下文中的推理和计算:思维链,或者直接使用推理模型;
- 步数少、下一步依赖上一步的结果:ReAct;
- 步骤多、可以事先拆分、需要并行或者展示进度:先规划后执行,每个步骤内部使用 ReAct;
- 有明确的评估标准、需要搜索和回溯:思维树;
- 有可靠的外部反馈、允许多次尝试:反思;
- 步骤固定不变:不需要模型规划,直接写成工作流。
需要注意的是,以上几种方式中,计划的内容都由模型生成,框架和代码只提供结构:ReAct 中下一步做什么由模型在每次调用时决定,代码只负责循环和限制步数;先规划后执行中,计划由模型写出,代码负责校验计划、按依赖调度和限制重规划的次数;思维树的搜索和回溯同样由代码实现。所以规划的质量主要取决于模型和提示词,框架决定的是执行过程是否可控。
记忆
模型本身不保存任何状态。模型服务的对话接口一般是无状态的,不会自动接续之前的请求,每次调用都要把需要的历史重新发过去。聊天应用看起来“记得”之前说过的话,是因为应用把这些信息保存了下来,在下一次调用时放回了上下文。也有接口可以由服务端维护对话状态,例如 OpenAI Responses API 可以用 previous_response_id 或 Conversations 接续之前的对话,由服务端把历史拼回输入,但之前的 Token 仍然按输入计费(以官方最新文档为准)。
所以记忆和上下文是两回事:上下文是一次调用的输入,调用结束就没有了;记忆是应用保存的数据,每次调用前从中挑出一部分放进上下文。记忆要解决的是保存什么、存在哪里,以及什么时候取出来。
- 短期记忆:当前会话的消息、工具调用和结果、摘要和任务状态,存放在 Redis 或数据库中,会话结束后过期;
- 长期记忆:跨会话保存的用户偏好、事实和过去的经验,存放在数据库或者向量库中,直到被更新或者遗忘。
当前上下文窗口中的内容有时称为工作记忆,相当于短期记忆中这一次调用实际用到的部分。长期记忆还可以按内容细分:关于用户和外部世界的事实称为语义记忆,例如“用户住在滨江”;过去的经历称为情景记忆,例如“上次退货时用户嫌上门时间太早”;做事的方法称为程序记忆,系统提示词和 Skills 就属于这一类。
1. 短期记忆
短期记忆保存的是让当前会话继续下去所需要的一切。以前文的客服对话为例,分开保存三样东西:
- 原始消息:用户消息、助手回答,以及工具调用和结果。工具调用也要保存,缺少它们时模型不知道已经查过什么,可能重复查询;
- 摘要:历史超出预算后,较早的消息被压缩成的摘要,见上下文工程;
- 任务状态:当前在处理哪个订单、用户已经确认了什么、还有哪些待办,由代码维护的结构化字段。
每次请求时放进上下文的是“摘要 + 最近几轮的原文 + 任务状态”,原始消息则完整保留,用于审计、排查问题和重新生成摘要。
常见的实现是以会话 ID 为键,存放在 Redis 中:
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 按顺序取出最近的若干条;
- 任务状态用 Hash,每个字段可以单独更新;
- 设置过期时间,长时间不活跃的会话自动清理。
Redis 的持久化不保证不丢数据,开启 AOF 的 everysec 时故障仍可能丢失约 1 秒的写入(见 Redis 持久化)。所以需要保留的原始消息和要恢复的任务状态要先写入数据库,Redis 只缓存最近几轮、摘要和任务状态,键过期或丢失后从数据库恢复。用 MongoDB 的 TTL 索引控制保留期限时,删除由后台任务约每 60 秒执行一次,到期的文档可能还在,读取时仍要按时间过滤。
2. 长期记忆的写入
长期记忆分为写入和读取:
由谁决定写入,有两种做法:
- 后台提取:会话结束后或者每隔几轮,由应用调用模型从对话中提取,不影响回答的延迟,但新的记忆要等提取完成后才能用到;
- 模型主动写入:给 Agent 提供一个 save_memory 工具,由模型在对话中决定是否调用。记忆立即生效,还可以告诉用户“已记住”,代价是每一轮都要额外做判断,模型也可能记下不该记的内容。
值得写入的内容要同时满足三个条件:用户明确说过、以后的会话中还会用到、业务系统中查不到。在前面的客服对话中,“搬到了滨江,以后都寄江南大道 588 号 3 幢 1201”“取件前发短信,别打电话”值得保存;“订单 1024 在杭州转运中心”“退货单 R2051”不应该保存,它们以订单系统为准,写进记忆很快就会过时。
这些规则要写进提取的提示词中,提取的是事实而不是原始对话:
从下面的对话中提取关于用户的、以后的对话中可能用到的事实。
只提取用户明确说过的内容;订单状态、物流进度这类可以从业务系统查到或者很快会过期的信息不要提取。
每条一句话,以 JSON 数组输出,没有则输出 []。
用户:我对花生过敏,推荐个早餐
助手:推荐燕麦粥和鸡蛋……
输出:["用户对花生过敏"]
提取的结果要经过评测,模型同样会出错。用 Qwen2.5-7B 从前面的客服对话中提取:只要求“提取关于用户的信息”时,结果中既有已经过时的旧地址,还有一个用户从没说过的电话号码 1388823;写明“只提取用户明确说过的、以后仍然有用的信息,订单和物流进度不要提取”之后,提取出了新地址和手机尾号,却漏掉了“取件前发短信”这条偏好(Qwen2.5 实测,见附录 A5)。
评测提取时先人工标出对话中应该记住的事实,统计正确提取的条数,同时统计过时和编造的条目。例如这段对话应记住新地址、手机尾号和“取件前发短信”3 条,限定的提示词正确提取了 2 条,宽松的只有 1 条,另有过时和编造的各 1 条。
端到端再准备一批测试问题,比较用记忆、不用记忆和把全部历史放进上下文的回答,题型可以参考 LongMemEval:信息抽取、跨会话推理、时间推理、知识更新和拒答(arXiv 2410.10813)。
每条记忆连同以下信息一起保存:用户 ID、内容、向量、类型、来源(哪一次会话,用户亲口说的还是模型推断的)、写入时间、最近被用到的时间,以及可选的过期时间。
上面是按集合保存:一条信息一条记录,新增容易,但用的时候要靠检索,分散的条目也难以完整表达信息之间的关系。
另一种存法是画像:每个用户一份 JSON,例如 {"address": "...", "phone_tail": "8823", "notify": "取件前发短信"},按字段更新,适合地址、称呼、联系偏好这类范围明确的信息。画像较小时可以按用户 ID 直接读出整份放进上下文,不需要按问题检索。画像的字段多了以后,让模型重写整份容易改错或者丢字段,可以只让它输出要修改的字段(LangGraph 的记忆文档中称为 profile 和 collection)。
3. 长期记忆的检索
检索分三步:
- 按用户 ID 过滤,这个条件由代码写进查询中,不能依靠模型,否则可能取到其他用户的记忆;
- 按与当前问题的相似度排序,方法与 RAG 中的向量检索相同;
- 结合时间和重要性调整顺序。例如 Generative Agents 把三项分数归一化后相加:与当前问题的相关度,按指数衰减的时间分数(每过一小时乘以 0.995),以及写入时由模型打出的重要性分数。
不是每条记忆都要检索:称呼、回答风格这类每次都用得到的少量信息,可以固定放在系统提示词中;其它的按当前问题检索最相关的几条。也可以把检索做成一个 search_memory 工具,由模型判断什么时候需要,适合记忆很多、而大部分问题用不到记忆的场景。
按集合保存的记忆,即使模型的窗口能放下全部条目,也只放相关的几条,原因与上下文工程中的相同:上下文越长越贵,无关的内容还会干扰回答。
每一轮都检索会增加生成之前的等待时间,开销来自查询的向量化和一次带过滤的向量检索,取决于 Embedding 服务、网络和记忆的规模。例如 Mem0 论文中检索耗时的中位数为 0.15 秒,P95 为 0.2 秒。降低开销的办法是与 RAG 检索共用查询向量、两者并行执行,写入放到后台提取,或者改用上面的 search_memory 工具。
4. 更新、冲突与遗忘
新的事实写入之前,要先和已有的记忆比较,否则同一件事会存成多条,甚至互相矛盾。Mem0 的做法是先检索出最相似的几条旧记忆,把它们和新事实一起交给模型,由模型选择四种操作之一:新增(ADD)、更新(UPDATE)、删除(DELETE)或者不做处理(NOOP,下图的提示词中写作 NONE)。例如用户搬了家:
模型正确地选择了更新 m1,但在新记忆中写了一个编造的日期。所以模型只负责判断做哪种操作、新的内容是什么,用户 ID、时间这类字段由代码填写。
需要注意的是,这是 Mem0 论文和早期版本的做法。Mem0 开源版后来改为自动写入只做新增:一次模型调用提取新事实,旧记忆只用来去重,搬家后新旧两条地址都保留,更正和删除由应用调用 update、delete 完成(以官方最新版本为准)。
新旧信息冲突时,按可信程度和适用范围判断:用户本次明确说的优先于记忆中的,用户亲口说的优先于模型推断的,来源相同时以新的为准;只对这一次有效的说法不覆盖长期记忆,例如“这次说详细一点”不会改变“喜欢简短的回答”;订单、账户这类数据以业务系统为准,本来就不写进记忆。仍然无法判断时,在对话中向用户确认。
被更新或删除的旧记忆不直接物理删除,而是标记失效时间,出错时可以追溯和恢复。错误或者过时的记忆被反复检索出来、影响回答,称为记忆污染。除了上面的规则,还可以在放入上下文的记忆前注明“来自长期记忆,可能已经过时,与用户本次的说法冲突时以用户为准”,并给每条记忆带上时间,让模型自己判断。
长期记忆还需要遗忘,否则过时和无用的记忆越积越多,检索时会混入噪声:
- 给每条记忆记录写入时间和最近被用到的时间,检索时把相似度和时间衰减结合起来排序,很久没用到的记忆排在后面;
- 定期清理长期没有被用到、或者已经被新事实覆盖的记忆;
- 可以设置有效期的信息(例如“用户这周在出差”)在写入时就带上过期时间。
5. 中断与恢复
Agent 运行到一半时进程可能重启,用户也可能隔天才回来。保证记忆连续的前提是:状态都存放在进程外部,每完成一步就写入存储,而不是只保存在内存中。
进程重启后,按会话 ID 读出消息列表和任务状态,根据最后一条消息决定从哪里继续:
- 最后一条是用户消息,或者上一轮的工具调用都有了结果:重新调用一次模型即可;
- 有工具调用还没有对应的结果(一次回复中有多个调用时,可能只保存了其中一部分的结果):说明工具执行到一半中断了。查询类的工具直接重新执行;退款、改地址这类修改数据的工具,以工具调用的 ID 作为幂等键(这条带工具调用的回复已经保存,ID 不会变),先查询这次操作是否已经生效,再决定是否执行,避免重复退款。
用户隔天回来时,会话的短期记忆可能已经过期,前一天的完整对话也不应该再放进上下文。做法是会话结束时生成一份摘要,连同未完成的待办一起保存;新会话开始时放入长期记忆和上次会话的摘要。例如用户前一天说“税号我待会儿发你”,第二天只发来一串税号,Agent 根据上次的待办就知道这是订单 1024 的发票,不需要用户重新解释。
LangGraph 等框架用检查点(Checkpoint)在每一步之后自动保存整个状态,见开发框架。
需要注意的是,记忆中存放的是用户的个人信息,应该允许用户查看、修改和删除;手机号、证件号这类敏感信息不写入长期记忆,或者脱敏后再保存。
多轮对话
多轮对话的实现方式与 Agent 循环相同:每次请求都把历史连同新消息一起发给模型,历史由应用按会话保存,见短期记忆。轮数多了之后,除了上下文膨胀(见上下文工程),还有三个常见的问题:用户的话要结合前文才能理解,信息不足时模型自行假设,话题切换后旧的内容造成干扰。
多轮对话对模型本身也更难。一项研究模拟了 20 多万次对话,把同一个任务的要求拆成几轮逐步告诉模型,15 个模型在 6 类任务上的表现比一次说完时平均下降了 39%。主要原因是模型在信息还不全的早期轮次就做出假设、给出完整的答案,之后又过度依赖这些答案,一旦走错就很难回到正轨。
注意力机制在长对话中有两方面的局限:
- 成本:每轮都要处理全部历史,注意力的计算量随长度平方增长,KV Cache 随长度线性增长。Qwen2.5-7B 的对话累积到 32K Token 时,一个请求的 KV Cache 就占 1.75 GB,没有命中前缀缓存时首 Token 时间也越来越长,见计算量与 FlashAttention;
- 效果:无关的历史越多,越容易干扰回答,位于中段的信息也更容易被忽略,见上下文工程。所以关键的约束,例如“取件前发短信,别打电话”,要写进任务状态,在后续每次调用时都保留,见话题漂移与新会话。
1. 指代与省略
用户经常省略已经说过的内容:
用户:还有个订单 1026 没发货,也改成新地址
助手:已修改,订单 1026 将寄到新地址。
用户:它什么时候发货?
完整的历史在上下文中时,模型一般能理解“它”指的是订单 1026,问题出在其它环节:
- 检索:直接用“它什么时候发货”去检索帮助中心,找不到有用的资料,需要先把问题改写成“订单 1026 什么时候发货”,见查询改写;
- 压缩之后:被指代的内容可能已经被压缩掉了,所以任务状态中要记录当前在处理的订单,以及会话中提到过的所有订单;
- 有多个候选:用户先后提到了订单 1024 和 1025,再说“刚才那个退了吧”,两个都有可能,这时应该追问,而不是猜一个。
改写时把最近几轮对话(包括助手的回答,后一个问题常常指向上一轮回答中的内容)和任务状态一起交给模型,要求输出一个不依赖上下文也能看懂的完整问题。这个任务可以交给较小的模型,但要在自己的对话上比较改写前后的检索效果:
任务状态:当前订单 1026;本次会话提到的订单 1024、1025、1026
最近的对话:(最近 3 轮)
用户的新消息:它什么时候发货?
改写结果:订单 1026 什么时候发货?
2. 追问澄清
信息不足时,模型倾向于自己补全缺少的信息,然后直接给出答案。以下情况应该追问:
- 缺少工具的必填参数,而且无法从对话、记忆和业务系统中得到,例如用户说“我要退货”,名下有两个可以退货的订单;
- 有多种理解,选错的代价高,例如上面的“刚才那个退了吧”;
- 退款、改地址这类修改数据的操作,执行前复述订单号和要办理的事项,请用户确认。
能查到的就不要问,例如用户名下只有一个可以退货的订单时,直接复述这个订单请用户确认。追问时给出选项,比开放式的提问更容易回答,例如“您有两个可以退货的订单:1025(65W 氮化镓充电器)和 1027(数据线),要退哪一个?”
实现上,在系统提示词中写明什么时候需要追问,同时在代码中兜底:工具参数校验失败(例如缺少 order_id)时不执行,把错误信息返回给模型,由它向用户追问。信息是否收集完整也由代码判断:任务状态中记录每个必填参数是否已经填上、是否经用户确认,全部满足才执行,不依赖模型自己说“信息已齐全”。追问的次数也要限制,连续几次仍然无法确定时转人工。
3. 话题漂移与新会话
话题漂移有两种:一种是用户换了话题,之前的内容仍然留在上下文中造成干扰,例如用户从退货转到开发票,模型却把退货单号写进了发票申请;另一种是 Agent 执行长任务时,在大量的中间步骤中逐渐偏离了最初的目标。
处理方法:
- 把当前的目标、已确认的信息和待办作为任务状态单独维护,每次放在上下文的末尾,模型对开头和结尾的内容利用得最好;
- 一个话题结束后,把这部分对话压缩成一句结果,例如“订单 1025 已办理退货,单号 R2051”;
- 一个任务没完成就被新话题打断时,把原任务和已经收集到的信息挂起,新任务完成后询问用户是否继续,例如“订单 1025 的退货还要继续吗?”,而不是自动恢复原任务,Rasa 默认的中断恢复流程就是这样处理的;
- 任务结束后,清理任务状态中只属于这个任务的临时信息,收货地址这类之后还要用的信息才保留;
- 长任务中让 Agent 维护一份待办清单,每完成一步就更新,见规划。
以下情况适合开启新会话:
- 用户开始了一个与之前无关的新问题,或者超过一定的时间(例如 30 分钟)没有新消息;
- 对话中积累了多次失败的尝试,模型会受到前面错误回答的影响,上面的研究建议把已知的信息整理成一条完整的要求,在新会话中重新开始;
- 已经压缩了多次,摘要中丢失的细节越来越多。
开启新会话时不带上之前的完整对话,只放入长期记忆,以及上一次会话的摘要和未完成的待办,见记忆中的中断与恢复。
MCP
同一个工具要接入多个 AI 应用时,原本需要在每个应用里分别适配一遍。模型上下文协议(Model Context Protocol, MCP)定义了 AI 应用与外部工具、数据之间的统一接口,工具只需实现一次 MCP 服务端,就能被所有支持 MCP 的应用使用。
MCP 中有三种角色:
- 主机(Host):用户使用的 AI 应用,例如 IDE 或者聊天客户端;
- 客户端(Client):主机中负责与某个服务端通信的连接器,一个客户端只连接一个服务端;
- 服务端(Server):对外提供能力的程序,可以提供三类能力,区别在于由谁决定使用:工具(Tools)由模型决定何时调用,例如查询订单;资源(Resources)是按 URI 读取的数据,例如文件内容,由主机决定放不放进上下文;提示词模板(Prompts)由用户主动选用,例如客户端中的斜杠命令。
以官方的文件系统服务端为例,在主机的配置文件中写明如何启动它:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Documents"]
}
}
}
主机启动时按配置把服务端作为子进程启动,通过它的标准输入输出(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 发起调用。以下请求和响应省略了部分字段:
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "read_file", "arguments": {"path": "README.md"}}}
{"jsonrpc": "2.0", "id": 2,
"result": {"resultType": "complete",
"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):
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”,具体内容留在服务端的日志中(实测,见附录 A24)。参数由 SDK 按 Schema 校验,模型把 order_id 传成数字 1024 或者写成 orderId 时,工具函数不会执行,校验错误同样作为 isError 结果返回,模型可以据此改正后重试。返回值注解为 TypedDict 这类字段固定的类型时,SDK 还会生成 outputSchema,结果中除了文本,还有一份符合它的 structuredContent,供客户端校验后交给程序使用。
本地服务端以子进程运行,权限就是启动它的用户的权限,规范建议从环境变量中读取凭据;远程服务端通过 HTTP 对外提供,规范按 OAuth 2.1 定义授权:客户端带着访问令牌请求,服务端必须校验令牌是否签发给自己,无效或过期的令牌返回 HTTP 401。令牌只说明调用者是谁,每个工具还要按用户校验权限,例如只能查询自己的订单。
MCP 与相关概念的区别:
- 工具调用是模型这一侧的能力,模型根据工具描述给出调用;MCP 是应用与工具之间的协议,解决工具如何被发现和调用。两者配合使用:主机把从服务端获取的工具转换为工具定义交给模型,模型给出调用后,再通过 MCP 发给服务端执行;
- 普通的 HTTP 接口也能封装成工具,但每个接口的认证方式、参数格式和文档各不相同,需要逐个适配;MCP 统一了发现和调用的方式,工具描述本身就是写给模型看的;
- 能执行命令的 Agent 可以直接调用 git、kubectl 这类命令行工具(CLI)。模型在训练中见过大量常用命令,不需要事先把定义放进上下文,需要时运行 --help 查看,输出还可以先用管道过滤,只有结果进入上下文;代价是需要能执行命令的环境,权限只能靠沙箱和命令白名单限制,输出是给人看的文本,没有统一的 Schema。MCP 适合需要统一授权、远程访问或者结构化结果的场景,以及没有命令行环境的应用,例如聊天客户端接入公司的工单系统。
一个 Agent 接入多个服务端时,主机为每个服务端建立一个客户端,把各服务端的工具合并后交给模型。规范只建议工具名在一个服务端内唯一,不同服务端之间可能重名,所以主机通常加上服务端名作前缀(服务端自报的名称也不保证唯一,Claude Code 用的是用户配置中的名称),例如 Claude Code 中的工具名形如 mcp__服务端名__工具名。
MCP 也有代价。全量加载时,每个服务端的工具定义都会进入上下文,Anthropic 的工程博客中举的例子是同时接入 GitHub、Slack、Sentry、Grafana、Splunk 五个服务端,工具定义就占约 5.5 万个 Token。Anthropic 的 Agent SDK 文档还提到,同时加载的工具超过 30~50 个后,选择工具的准确率会下降。
解决方法是按需加载:上下文中只放一个搜索工具和最常用的几个工具,模型先搜索,再把找到的工具的完整定义加入上下文;也可以由应用先根据问题检索出相关的工具,只把这几个交给模型。上述博客报告的内部评测中,启用工具搜索、按需加载后,Opus 4 在 MCP 评测中的准确率从 49% 提高到 74%。
Anthropic 还介绍过一种做法:把 MCP 工具生成为代码文件,Agent 按需读取工具的定义、编写代码调用,中间结果留在执行环境中,不经过模型,例子中的 Token 消耗从 15 万降到 2000。
另外,第三方服务端的工具描述、服务端说明(instructions,客户端可能把它放进系统提示词)和返回结果都可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,本节对照 2026-07-28 版,细节以官方最新版本为准。
Skills
Skill(技能)是一个包含说明文档、脚本和参考资料的文件夹,用于教会 Agent 完成某一类任务,例如按团队模板写周报。文件夹中的 SKILL.md 开头用 name 和 description 描述这个 Skill 做什么、什么时候使用,正文是具体的操作步骤。
Skill 的核心是渐进式加载:
- 启动时只加载每个 Skill 的名称和描述,每个约 100 Token;
- 用户的请求与某个 Skill 的描述匹配时,才读取 SKILL.md 的正文;
- 正文中引用的其它文档和脚本在需要时才读取或执行,脚本只有运行结果进入上下文。
因此即使安装了几十个 Skill,平时占用的上下文也很少。
判断是否使用某个 Skill 的是模型本身,而不是关键词匹配或者向量检索:所有 Skill 的名称和描述放在系统提示词中,模型认为当前任务与某个描述相符时,用读取文件的工具(例如用 cat 命令读取 SKILL.md)或者 Agent 提供的专门工具把正文读进上下文,再按正文中的步骤执行。
读进来的正文和其它工具结果一样追加在对话历史中,此后每次请求都会带上,没有单独的卸载步骤,直到会话结束或上下文被压缩(Claude Code 压缩时会把最近用过的 Skill 重新附在摘要之后,每个最多保留 5000 Token)。效果上这与把正文粘贴进对话相同,区别在于由模型按描述自动发现、不必每次手工提供,还可以附带脚本。
工具、MCP 和 Skill 的区别:
- 工具是一个可调用的函数,提供原子能力,例如查询订单;
- MCP 是连接工具和数据的协议,解决工具怎么接入;
- Skill 提供做事的方法,告诉 Agent 完成某类任务的步骤和规范,执行时仍然需要调用工具。
例如一个生成周报的 Skill 的目录:
weekly-report/
├── SKILL.md 名称、描述和操作步骤
├── template.md 周报模板,按需读取
└── scripts/
└── collect.py 统计本周合入的 PR,Agent 运行它,只有输出进入上下文
---
name: weekly-report
description: 按团队模板生成周报。用户要求写周报、总结本周工作时使用。
---
1. 运行 scripts/collect.py,得到本周合入的 PR 列表;
2. 按 template.md 的格式,把 PR 归入“完成”“进行中”“风险”三部分;
3. 每条不超过一句话,链接放在末尾。
name 只能包含小写字母、数字和连字符,最长 64 个字符;description 最长 1024 个字符,要同时写清楚做什么和什么时候用,Agent 就是根据它判断是否加载这个 Skill。
需要接入外部系统、要求权限控制的能力(例如查询公司的工单系统)适合做成 MCP 服务端;告诉 Agent 怎样组合已有工具完成任务的流程和规范,适合写成 Skill。知识量大、经常更新、每次只用到其中几段的资料(例如产品手册)则适合用 RAG 检索片段,因为 Skill 的正文读入后会整段留在上下文中。
Skill 中可以包含脚本,等同于让 Agent 执行代码,只应该使用可信来源的 Skill。
Skill 与系统提示词、Agent 的区别:
- 系统提示词中的内容每次请求都占用上下文,适合每个任务都要遵守的少量规则,例如项目的构建命令;Skill 平时只占一行描述,适合只有某类任务才用到的流程和资料;
- Agent 是执行任务的主体,由模型、循环、工具和上下文组成;Skill 不会自己运行,只是被 Agent 读进上下文的说明和附带的脚本。需要独立的上下文、并行执行或者单独的权限时,才拆成子 Agent,见多 Agent。
写 Skill 的正文时,要记住它与对话历史共用上下文:
- 只写模型不知道的内容,例如团队的模板、内部系统的用法,通用的知识不写。Anthropic 建议正文不超过 500 行,更长的内容拆到单独的文件中,由正文引用;
- 写多细取决于任务能容忍多大的偏差:有多种合理做法的任务写目标和要点;必须严格一致的操作(例如数据库迁移)写成确定的命令或脚本,例如上面统计 PR 的部分交给 collect.py,而不是让模型自己数;
- 质量要求高的任务在步骤中加入检查,例如“运行校验脚本,有错误就修改后重新运行,直到通过”。
Skill 中的规范只能引导,不能保证模型一定遵守,必须满足的规则还要用脚本或钩子检查,见 Harness 的引导与反馈。模型也不一定会加载 Skill,描述不够贴切时可能直接动手;部署、提交这类有副作用、必须按流程走的 Skill,可以设为只允许用户用斜杠命令(例如 /deploy)显式调用,Claude Code 中在 frontmatter 写 disable-model-invocation: true,它的描述也不再放进目录。
除了用户用斜杠命令直接调用,模型自动选用 Skill 时,是根据目录中的名称和 description 判断它与当前任务是否相关,所以要像测试意图识别与路由一样测试它:
- 触发测试:准备约 20 条真实的提问,一半应该触发、一半不应该触发。不应该触发的重点选关键词相近但需求不同的,例如“把周报模板翻译成英文”不应该触发生成周报的 Skill。每条运行 3 次左右计算触发率,并留出一部分提问不参与修改,检查改进是否适用于新的说法;
- 效果测试:同一批任务分别在有和没有这个 Skill(或者使用旧版本)的情况下运行,能用代码判断的项由脚本检查,其余由人或模型裁判比较,同时记录 Token 消耗和耗时。
Skill 越来越多时,目录中每个 Skill 的描述只占约 100 个 Token,几十个也不算多,问题主要在于描述相近的 Skill 互相争抢。处理方法:职责重叠的合并成一个,或者在描述中写清边界,例如“只用于生成周报,修改模板请使用 report-template”;不适用于当前用户或环境的 Skill 不放进目录;数量很大时,先按问题检索出候选的几个,再交给模型选择。线上选错 Skill、用户纠正的会话加入触发测试集,修改描述后回归,Skill 就是这样持续迭代的。
多 Agent
1. 为什么要多个 Agent
复杂任务可以交给多个 Agent 协作完成。常用的结构是主从模式:主 Agent 负责拆分任务和汇总结果,把子任务分派给子 Agent 并行执行。
实现上,子 Agent 通常被包装成主 Agent 的一个工具。主 Agent 调用 research("厂商 A") 时,应用启动一个新的 Agent 循环,它有自己的系统提示词、工具和消息列表,运行结束后把最终回答作为工具结果返回给主 Agent。
主 Agent 选哪个子 Agent,依据的是这些工具的名称和描述,所以要像意图识别与路由一样写清各自的边界,并用评测集检验。子 Agent 的返回最好约定格式,例如结论、依据和是否完成,同时限制它的最大轮数;超时或失败时,主 Agent 重试、换一种拆法,或者如实报告这部分没有完成。
多 Agent 的主要好处是隔离上下文:每个子 Agent 在自己的上下文中工作,可以读大量资料、反复试错,最后只把精简的结论返回,主 Agent 的上下文不会被中间过程撑满。分派时可以只传任务说明,也可以附带必要的对话历史,传入的历史越多,子 Agent 的输入越长。这也是它与“一个 Agent 调用多个 Skill”的区别:Skill 的内容和执行过程都进入同一个上下文,子 Agent 的过程则不进入。
代价也很明显:
- Token 消耗成倍增加,Anthropic 的数据是 Agent 消耗的 Token 约为普通对话的 4 倍,多 Agent 系统约为 15 倍;
- Agent 之间只传递摘要,细节可能丢失,一个子 Agent 的错误结论也会被主 Agent 当作事实接收。
2. 协作方式
- 主管分派:主管 Agent 决定把哪些子任务交给哪个 Agent,最后汇总,也就是上面的主从模式。子任务由主管根据输入临时决定,适合子任务互相独立、可以并行的情况;
- 流水线:几个 Agent 按固定的顺序执行,上一个的输出是下一个的输入,例如提取商品参数、撰写文案、合规审核。它本质上是节点恰好为 Agent 的工作流,审核不通过可以退回上一步,但要限制退回的次数;
- 讨论:几个 Agent 先各自给出答案,再看过彼此的答案和理由后修改,几轮之后投票或者由裁决者给出结论。研究表明这种方式能提高算术和事实问答的准确率,但后来的对照实验发现,提升大部分来自最后的多数投票,只让几个 Agent 各答一次再投票就能得到大部分收益;它的代价是调用次数等于 Agent 数乘以轮数,适合出错代价高、答案可以互相检验的判断;
- 转交(Handoff):没有固定的主管,当前 Agent 判断问题不归自己处理时,把控制权和需要的上下文(完整历史、筛选后的消息或摘要)交给另一个 Agent,由它接着处理并直接回复用户。例如客服的分诊 Agent 调用 transfer_to_refund_agent 工具,之后由退款 Agent 接手对话。与主管分派的区别是接手的 Agent 处理完不会像工具调用那样自动返回原来的 Agent,需要时再转交回去,适合按业务领域划分、由专门的 Agent 直接面对用户的场景。
Agent 之间传递信息的方式有三种:主管分派时通过工具调用的参数和返回值;同一个应用内通过共享的状态对象,例如 LangGraph 中各节点读写的状态,见开发框架;跨服务、跨团队时通过消息队列或者下文的 A2A 协议。通过消息队列传递时,同一条消息可能被投递多次,接收方要用任务 ID 这类业务键去重,见接收端的可靠性。
多个 Agent 同时修改共享资源时,与多线程的问题相同,处理方法也类似:
- 隔离:每个 Agent 在自己的工作目录中修改代码,例如为每个 Agent 创建一个 git worktree,完成后再像合并分支一样合并,有冲突时由主 Agent 或人工解决;
- 分区:拆分任务时就划清每个 Agent 负责的文件或数据范围,互不重叠;
- 加锁或乐观并发:修改数据库时使用行锁或版本号,修改文件前检查文件自读取后是否被改过,被改过就重新读取再修改;
- 共享状态显式传递:Agent 之间不共享上下文,需要的信息由主 Agent 在分派任务时写进子任务的说明,子 Agent 的结论写入文件或数据库,而不是依赖对方“记得”。
子 Agent 超时或出错时,把错误信息作为工具结果返回给主 Agent,由它调整后续的步骤。Anthropic 的多 Agent 研究系统把这种由模型做的调整与程序控制的重试、定期的检查点结合起来,长任务出错后从断点继续,而不是从头重跑,见中断与恢复。
3. 适用场景
多 Agent 不是越多越好,以下情况用单个 Agent 更合适:
- 单个 Agent 加几个工具或 Skill 能完成的任务。业务上有“多个角色”不等于需要多个 Agent,很多时候只是不同的提示词或者工作流的分支;
- 子任务之间依赖紧密、需要共享全部上下文的任务。例如两个子 Agent 分别写同一功能的前端和后端,各自假设了不同的接口字段,合并时才发现冲突;
- 对延迟和成本敏感的在线请求,例如客服的每一轮回复。
一项对 7 个多 Agent 框架、1600 多条运行记录的研究总结了 14 种失败模式,分为三类:系统设计问题(例如不遵守任务要求、重复已经完成的步骤、不知道何时该结束)、Agent 之间的不一致(例如不澄清就按错误的假设继续、忽略其它 Agent 的输入)、任务验证不足(例如提前结束、没有验证或者验证不正确)。论文也指出,在常用的基准上,多 Agent 相对单 Agent 或者简单地多次采样取最好结果,提升往往很小。
因此多 Agent 适合可以拆成独立部分并行处理、单个上下文装不下的任务,例如同时调研多个主题;需要隔离权限时也可以拆分,例如只让一个 Agent 拥有退款工具。修改同一份代码这类任务,更适合由单个 Agent 完成。
4. 跨团队协作:A2A
上面的子 Agent 都在同一个应用里,由同一个团队开发。当需要调用另一个团队、另一家公司用不同框架开发的 Agent 时,就需要一个约定:怎样知道对方能做什么,怎样把任务交给它,任务执行很久或者中途需要补充信息时怎么办。Agent 间通信协议(Agent2Agent, A2A)就是为此制定的开放协议。
以电商平台的客服 Agent 请快递公司的物流 Agent 改派包裹为例,A2A 的核心机制如下:
- 发现:物流 Agent 在 /.well-known/agent-card.json 发布 Agent Card,写明名称、描述、技能(例如查询轨迹、改派)、服务地址和认证方式,客服 Agent 读取后决定是否委托、怎么调用;
- 任务:客服 Agent 发送一条消息,物流 Agent 创建一个带 ID 的任务(Task)。任务有状态:已提交、处理中、需要输入(例如要收件人手机号后四位)、已完成、失败、已取消等,需要输入时客服 Agent 补充一条消息后任务继续;
- 内容:消息由若干部分(Part)组成,可以是文本、文件或结构化数据,任务的产出称为 Artifact;
- 传输:基于 HTTP,可以用 JSON-RPC 2.0、gRPC 或者 HTTP+JSON;长任务用 SSE 推送进度,或者由对方在任务状态变化时回调客户端登记的地址,回调可能重复送达,规范建议接收方做幂等处理。
A2A 与 MCP 的区别:
- MCP 连接的是 Agent 与工具、数据:工具通常有定义好的输入输出,执行具体的功能,往往不保存调用之间的状态;
- A2A 连接的是 Agent 与 Agent:对方是一个黑盒,自己推理、使用自己的工具,任务有状态、可能持续很久、中途可能需要多轮交互,双方都不暴露内部的提示词、记忆和工具。
两者配合使用:每个 Agent 通过 MCP 使用自己的工具,Agent 之间通过 A2A 协作。同一个应用内部的子 Agent 直接包装成工具更简单,只有跨团队、跨组织的调用才需要 A2A 这样的协议。A2A 规范仍在演进,方法名和字段以官方最新版本为准。
AI 编程助手
Claude Code、Cursor、Codex 这类 AI 编程助手是目前最常用的 Agent,原理与前面的 Agent 循环相同,只是工具换成了读写文件、搜索代码和执行命令:
以 Claude Code 为例,官方文档把它的工作过程描述为收集上下文、执行操作、验证结果三个阶段,下一步做什么由模型根据上一步的结果决定,属于 ReAct;Plan 模式下先调研代码、写出计划,确认后才修改,相当于先规划后执行。几个关键的设计:
- 项目约定与记忆:项目根目录的 CLAUDE.md(Codex、Cursor 等使用 AGENTS.md)在每次会话开始时放入上下文,写入构建和测试命令、代码规范等每次都需要的信息,官方建议每个文件不超过 200 行,内容越长越占上下文,也越难被遵守。Claude Code 还会把开发者的纠正和偏好自己记下来,会话开始时只加载这些记忆的索引 MEMORY.md;
- 上下文管理:MCP 工具平时只占名称,Skill 只占描述,用到时才加载完整的定义和正文;子 Agent 在自己的上下文中工作,只把结论返回;接近上限时自动把对话压缩成摘要,再从磁盘重新读取 CLAUDE.md 和最近读写过的几个文件,只在对话中说过的要求可能丢失;
- 安全:用文件工具修改文件前保存快照,可以撤销,但只被命令改动过的文件不在其中;执行命令、修改文件前按权限模式决定是否请求确认,命令还可以放进操作系统的沙箱,限制能写的目录和能访问的域名。对数据库、线上接口这类外部系统的操作无法撤销,需要用权限加以限制;
- 隔离范围:内置的命令沙箱只约束命令及其子进程,文件工具由权限规则控制,钩子和本地 MCP 服务端不在沙箱中,无人值守地运行、尤其是跳过权限确认时,可以用容器、虚拟机等把整个 Agent 及它启动的进程都隔离起来(具体范围以官方最新文档为准)。
用好这类工具的方法与带一个新同事类似:
- 给出可以验证的目标,例如“让 OrderServiceTest 通过”,而不是“优化一下代码”,Agent 才能自己运行测试判断是否完成;修 bug 时先让它写一个能复现问题的测试。完成后让它给出运行的命令和输出,只说“测试都通过了”不能作为依据;
- 先让它阅读代码、给出方案,确认后再修改,避免方向一开始就错了;要与已有代码保持一致时,指明参照哪个文件;
- 任务拆小,一次只改一个功能,便于审查;生成的代码同样要做代码审查和测试,审查最好交给没看过编写过程的新会话或另一个人,对照需求检查改动,只报影响正确性的问题,否则会被风格上的意见淹没;
- 测试用例按需求来写,由人检查边界,例如金额为 0;照着实现补的测试,断言记录的往往是代码当前的行为,错误也会被当成预期;
- 测试失败时,Agent 可能修改或删除测试,或者针对测试的输入写特例来让它通过,审查时要看测试文件的改动;能做到时把已有测试设为只读,并在提示词中允许它发现测试与需求矛盾时停下来说明;
- 把反复纠正的要求写进 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 拆成任务逐项实现、对照规格检查;规格文档留在仓库中,是新会话和代码审查的依据。
需要注意的是,规格写得再细,Agent 仍可能不按规格实现,实现后仍要对照规格审查改动并运行测试。完整的规格流程也可能带来大量要审查的文档,例如 Böckeler 2025 年试用 Kiro 时,一个小 bug 被展开成 4 个用户故事、16 条验收标准,所以要按改动的大小和类型简化流程。
Harness
Agent 由模型和 Harness 两部分组成。Harness 指的是模型之外的所有代码、配置和执行逻辑:Agent 循环、系统提示词、工具及其描述、上下文压缩、记忆、沙箱与权限、子 Agent 的调度,以及在固定时机执行的钩子(例如每次修改文件后自动格式化)。模型本身只接收文本、输出文本,不能保存状态、执行代码、获取实时信息,这些都要由 Harness 补上。前面不到二十行的 Agent 循环就是一个最简单的 Harness;Claude Code、Codex 是功能完整的 Harness,可以换用不同的模型。MCP 和 Skill 也是 Harness 的组成部分,前者负责接入工具,后者负责按需提供做事的方法。
提示词工程关注写给模型的指令,上下文工程关注每次调用放进上下文的内容,两者都是 Harness 的一部分。关注点一层层扩大,是因为任务变了:Agent 多步执行时,每一步都要重新挑选和压缩上下文,见上下文工程;Agent 长时间自主改代码、执行命令时,权限检查、重试次数和进度保存要由程序确定地执行,写进上下文只能提醒模型,不能保证做到。
ReAct 只规定了循环中怎样决定下一步(思考、调用工具、观察结果),是 Agent 循环的一种运行方式。只有 ReAct 循环时,模型调错工具、执行危险命令、没做完就宣布完成,都没有东西拦住;Harness 在循环外加上权限与沙箱、钩子与校验、上下文压缩和状态交接,更稳定、更安全靠的是这些确定执行的代码,而不是模型更会推理。
模型决定了能力的上限,Harness 决定了实际能发挥出多少。前面的实验中,模型不变,只在系统提示词中写清楚查询的步骤,成功率就从 0/10 变成了 10/10。围绕 Agent 设计和改进 Harness 的工作称为 Harness 工程(Harness Engineering),这个说法在 2026 年初开始流行,核心是两类控制:
- 引导:在 Agent 动手之前给出方向,例如 CLAUDE.md 中的规范、架构文档和 Skill;
- 反馈:在 Agent 动手之后检查结果,例如编译、测试、lint、架构检查,以及用另一个模型审查。
1. 组成
Harness 的每个部分都对应一个模型自己做不到或做不稳的问题:
| 组成 | 解决的问题 |
|---|---|
| Agent 循环与停止条件 | 模型一次只输出一段文本,循环负责执行工具、把结果送回,并决定何时结束,见 Agent 循环 |
| 上下文的组装与压缩 | 决定每次调用放进哪些消息、工具描述和记忆,过长时摘要或裁剪,见 上下文工程 |
| 工具与权限 | 工具的描述决定模型会不会用、用得对不对;危险操作要求人工确认,命令在沙箱中执行,见 工具调用 和 安全 |
| 记忆与状态交接 | 模型不保存状态,跨会话的进度要写到外部,见 记忆 和下文的长任务交接 |
| 子 Agent | 把独立的子任务交给新的上下文,只把结论带回来,见 多 Agent |
| 钩子与校验 | 在固定时机执行确定的检查,不通过就把错误送回模型继续 |
| 评测与可观测 | 记录每一步的输入输出和 Token,改动 Harness 后用同一批任务回归,见 可靠性与评测 |
工具的返回很长时,Harness 通常只把开头和结尾,或者最新的几条放进上下文,完整结果写到文件中,模型需要时再读取;Claude Code 对 MCP 工具的返回默认限制为 25,000 个 Token,超过 5 万字符的文本结果写入文件,只把路径交给模型(2026 年 10 月的官方文档)。
2. 引导与反馈
以“Controller 只能调用 Service”这条分层规范为例:
这条规范只写在 CLAUDE.md 中时,模型大多数时候会遵守,但无法保证。再加一条用 ArchUnit 写的架构测试,违反时测试失败,错误信息写明了违反哪条规则、在哪一行,Agent 读到后就能自己修正,不需要人逐行审查。编译、测试这类确定的检查快、便宜、结果可靠,应该优先使用;模型审查能发现规则写不出来的问题,但更慢,结论也不稳定。同一个问题反复出现时,应该补一条规范或一个检查,而不是每次在对话中纠正。
反馈对能力较弱的模型作用更明显。仍以“订单 1024 到哪了”为例,在模型准备结束时检查一次:用户问的是位置,就必须调用过物流查询,而且回答中出现了查到的地点;不满足时把原因作为一条消息送回,最多拦截两次。Qwen2.5-7B 在只写了“回答要简短”的提示词下,没有校验时 10 次全部失败,多数只查了订单就结束;加上校验后 10 次全部成功,代价是模型调用从平均 1.7 次增加到 4.0 次,输入 Token 约为原来的 3 倍。更小的 Qwen2.5-3B 在写清步骤的提示词下从 0/10 提高到 7/10,提示词过短时加上校验也只有 2/10,反馈弥补不了模型能力的全部差距(见附录 A26)。
让做事的 Agent 自己评价结果时,它往往会夸自己做得好,所以检查最好交给确定的程序或另一个 Agent。Anthropic 的一次对比中,同一个模型单独做一个复古游戏编辑器,用时 20 分钟、花费 9 美元,做出的游戏无法正常游玩;换成规划、生成、评估三个 Agent 分工的 Harness,用时 6 小时、花费 200 美元,核心功能可以正常使用。
3. 长任务的交接
任务大到一个上下文窗口装不下时,Harness 还要负责多次会话之间的交接。Anthropic 介绍过一种做法:第一次会话只做准备,把需求拆成功能清单写入 JSON 文件,每项带测试步骤和 passes 字段,初始全部为 false,同时写好启动脚本并提交到 git;之后的每次会话先读进度文件和 git log,启动应用做一次冒烟测试,然后只挑一个未通过的功能实现,端到端测试通过后才把 passes 改为 true,最后提交代码、更新进度文件。清单中的一项例如:
{"id": 12, "description": "订单列表可以按状态筛选",
"steps": ["打开 /orders", "选择状态“已发货”", "列表中只显示已发货的订单"],
"passes": false}
这些设计分别针对长任务中的常见问题:新会话不知道之前做了什么,靠进度文件和 git log 交接;一次做太多、上下文用完时留下半成品,靠每次只做一个功能;没有测试就宣布完成,靠功能清单和端到端测试。清单用 JSON 而不是 Markdown,是因为模型不容易随意改写 JSON 的结构;规定只能修改 passes 字段,是为了防止 Agent 删掉测试来“完成”任务。
4. 模型变强以后
Harness 的每个组件都隐含一个假设:模型自己做不到这件事。模型升级后,这些假设可能不再成立,需要逐个去掉组件验证效果。Anthropic 的例子中,Claude Opus 4.5 基本不再因为担心上下文用完而提前收尾,于是去掉了会话之间的上下文重置;Opus 4.6 能连续工作两个多小时,又去掉了把任务拆成多个冲刺的结构。他们的结论是,值得做的 Harness 组合不会随模型变强而减少,而是会转移到新的问题上。LangChain 的看法类似:一部分现在由 Harness 完成的事会被模型吸收,但环境、工具、持久的状态和校验循环对任何模型都有用;Claude Code、Codex 这类产品的模型在后训练时就带着 Harness 一起训练。
5. 与开发框架的关系
开发框架(例如 LangChain)提供模型调用、工具、Agent 循环等抽象;运行时(例如 LangGraph)负责持久执行、流式输出、人工介入和状态保存;Harness 在两者之上直接给出一套可用的默认配置,包括系统提示词、工具调用的处理、规划工具和文件系统等,拿来就能工作。这个划分来自 LangChain 的文章,边界并不严格。现成的 Harness 产品变化很快,例如 Claude Code(官方文档称为 agentic harness)、DeepSeek Harness(开发者预览版,所有能力都以插件提供)、Pi(只带最小的默认功能,其余靠扩展),具体功能以官方最新文档为准。
开发框架
上面的循环、工具定义和消息管理在每个项目中都要重复编写,LangChain、LangGraph、Spring AI 等框架把它们封装了起来:
- 统一不同模型服务的接口,切换模型时不需要修改业务代码;
- 把普通函数注册为工具,例如 Spring AI 中给方法加上 @Tool 注解,框架根据方法的参数生成 JSON Schema;
- 自动执行工具调用的循环,直到模型返回不含工具调用的回答;
- 提供对话记忆、RAG、结构化输出等常用组件。
框架减少了重复代码,但也隐藏了细节:排查问题时仍然要查看实际发送给模型的消息。Anthropic 的工程博客建议先直接调用接口,用框架时要弄清它底层做了什么。
核心的循环只有几十行,只用一个模型、少量工具时可以直接调用接口;需要持久化、人工介入或在多个模型之间切换时,框架的价值更大。框架与低代码平台的取舍见怎样选择。
1. 为什么用图编排
LangChain 早期的核心抽象是链(Chain):把提示词模板、模型调用、输出解析按固定的顺序串起来,适合“检索、拼提示词、生成”这样的线性流程。Agent 和带分支的业务流程需要的却是:
- 循环:模型调用工具、拿到结果后再次调用模型,次数事先不确定;
- 条件分支:按意图进入不同的流程,审批通过与否走不同的路;
- 暂停与恢复:在退款前停下来等人工确认,几小时后从原处继续;
- 出错后从中间恢复,而不是从头再来。
这些用链表达很别扭,LangGraph 改用状态图来表示:
- 状态(State):一个所有节点共享的数据结构,例如消息列表、意图、订单信息;
- 节点(Node):一个函数,读取当前状态,执行一次模型调用或者一段代码,返回要更新的字段;
- 边(Edge):决定下一个执行哪个节点,条件边根据状态的值选择分支。
Agent 循环本身就是两个节点和一条回边:模型节点之后是一条条件边,回复中有工具调用就进入工具节点,否则结束;工具节点执行完再回到模型节点。
节点之间不直接传参数,而是通过状态传递:每个节点返回一个只包含部分字段的字典,框架把它合并进状态。字段默认被新值覆盖,声明了合并函数(reducer)的字段按函数合并,例如消息列表是追加而不是覆盖。上图的退款流程用 LangGraph 写出来如下(模型调用用桩代码代替,以官方文档的最新接口为准):
class State(TypedDict):
question: str
intent: str
order: dict
approved: bool
answer: str
trace: Annotated[list, operator.add] # 合并函数:各节点返回的列表追加到一起
def confirm(state):
ok = interrupt({"action": "refund", "order_id": state["order"]["order_id"],
"amount": state["order"]["amount"]}) # 暂停,等待人工确认
return {"approved": ok, "trace": [f"confirm -> {ok}"]}
g = StateGraph(State)
for f in (classify, check_order, confirm, refund, logistics):
g.add_node(f.__name__, f)
g.add_edge(START, "classify")
g.add_conditional_edges("classify", lambda s: s["intent"], {"退款": "check_order", "物流": "logistics"})
g.add_edge("check_order", "confirm")
g.add_conditional_edges("confirm", lambda s: "refund" if s["approved"] else END)
g.add_edge("refund", END)
g.add_edge("logistics", END)
app = g.compile(checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "user-7-session-1"}}
app.invoke({"question": "订单 1024 我要退款", "trace": []}, cfg) # 运行到 confirm 时暂停
app.invoke(Command(resume=True), cfg) # 人工同意后恢复
主图与子图之间也通过状态通信:子图的状态与主图有同名的字段时直接共享;字段不同时,在主图的某个节点中调用子图,自己转换输入和输出。
2. checkpoint 与人工介入
编译时传入 checkpointer 后,框架在每一步结束时把状态保存为一个 checkpoint,不同会话用 thread_id 区分。checkpoint 的用途:
- 多轮对话:下一次请求带上同一个 thread_id,就能接着上次的状态继续;
- 人工介入:暂停时状态已经保存,等多久都不影响;
- 容错:某个节点失败后,从最近的 checkpoint 重新执行,而不是从头开始。同一步中并行的节点有的已经成功时,它们的结果单独保存,恢复时只重跑失败的节点;
- 回放:查看每一步之后的状态,定位是哪个节点写错了数据。
人工介入的过程是:节点中调用 interrupt(),图暂停并保存状态,传给 interrupt() 的内容(例如退款金额)返回给调用方,用于展示审批页面;审批之后,用同一个 thread_id 和 Command(resume=True) 再次调用,interrupt() 的返回值就是 True。
需要注意的是,恢复时被暂停的节点会从头重新执行,而不是从 interrupt() 那一行继续。在上面 confirm 节点的开头加一行打印,暂停前和恢复后各输出一次(LangGraph 1.2.18 实测,见附录 A8)。因此 interrupt() 之前的代码不能有副作用,或者副作用必须是幂等的;真正的退款放在审批之后的单独节点中,并用订单号作为幂等键。
interrupt() 是靠抛出一个特殊的异常来暂停的,所以不要用 except Exception 这类宽泛的异常处理把它吞掉,否则无法正常暂停,后面的代码可能不经审批就执行;一个节点中有多个 interrupt() 时,恢复值按调用的顺序匹配,不要让它们的顺序随条件变化(LangGraph 文档)。
生产环境中 checkpoint 要存进数据库,例如 PostgreSQL 或 Redis,内存实现只适合测试。状态中不要放大文件,只放文件的地址。
checkpoint 会随步数不断增加,而且默认每一步都写入各个状态字段的完整值,包括完整的消息列表,长对话增长很快,需要按会话设置保留期限、定期清理已经结束的会话。
Redis 的实现可以配置 TTL(单位为分钟,默认不过期,需要 Redis 8 或者装了 RedisJSON、RediSearch 模块的 Redis)。PostgreSQL 的实现没有 TTL,可以定期调用 prune,按命名空间只保留最新的 checkpoint 和恢复所需的数据,或者用 delete_thread 删除已经结束的会话(langgraph-checkpoint-postgres 3.2)。
checkpoint 什么时候写入由 durability 参数决定:默认的 async 在执行下一步的同时写入,进程恰好崩溃时可能没写下最近一步;sync 写完再执行下一步,更可靠但更慢,对重复执行敏感的流程可以使用;exit 只在这次执行结束、报错或人工中断时写入,进程中途崩溃时中间的进度无法恢复。
3. Java 项目中的 Spring AI
Spring AI 是 Spring 生态中的 AI 集成层,作用类似 Spring Data 之于数据库:用统一的 ChatClient 访问不同厂商的模型,用 @Tool 把 Java 方法注册为工具,用 Advisor(类似拦截器)加入对话记忆、RAG 等功能,用 entity() 把模型输出转换为 Java 对象,同时提供向量库的统一接口和 MCP 的客户端、服务端支持。
本节按 Spring AI 2.0 编写,它要求 Spring Boot 4;Spring Boot 3 的项目使用 Spring AI 1.1。两者在工具调用和记忆上有差别:1.1 中工具调用的循环由 ChatModel 内部执行,没有默认的调用次数上限,记忆 Advisor 的默认顺序也不同,以官方升级说明为准。
class OrderTools {
@Tool(description = "按订单号查询订单状态和运单号。用户询问订单、发货情况时使用")
OrderInfo getOrder(@ToolParam(description = "订单号,例如 1024") String orderId) {
return orderService.find(orderId);
}
}
String answer = ChatClient.create(chatModel)
.prompt()
.user("订单 1024 到哪了?")
.tools(new OrderTools())
.call()
.content();
框架根据方法签名生成参数的 JSON Schema,模型返回工具调用后,由框架执行方法、把结果发回模型,直到得到最终回答。工具抛出运行时异常时,默认把异常信息作为工具结果发回模型,让它修正或者向用户说明。但并不是所有错误都会回传:模型写了不存在的工具名时,框架直接抛出 IllegalStateException,整个请求失败;框架也不会在执行工具前按生成的 Schema 校验模型传入的参数,多余的字段被忽略,包装类型的参数缺失时传入 null(Spring AI 2.0.2 源码与测试)。已经进入工具方法的参数可以在方法内部校验并抛出异常,由框架回传给模型;不存在的工具名、基本类型的参数缺失这类框架不会自动回传的错误,要自己捕获后组织成工具的错误结果,必要时接管工具调用的循环。需要注意的是,编译时要保留参数名(javac 的 -parameters 选项,继承 spring-boot-starter-parent 时默认开启),否则 Schema 中的参数名是 arg0(Spring AI 2.0.2 实测,见附录 A9)。
对话记忆由 ChatMemory 接口管理:默认的 MessageWindowChatMemory 保留最近的 20 条消息,系统消息始终保留;消息存放在 ChatMemoryRepository 中,默认在内存里,也可以换成 JDBC、Cassandra 等实现。使用时给 ChatClient 加上 MessageChatMemoryAdvisor,每次调用传入会话 ID,它在调用前按会话 ID 取出历史放进请求,调用后保存新的消息。需要注意的是,默认情况下工具调用的请求和结果不会写入记忆,下一轮对话时模型看不到上一轮查过什么(Spring AI 2.0.2 实测,见附录 A28)。这是因为 MessageChatMemoryAdvisor 默认排在工具调用循环之外,只保存最终的用户消息和回答。
需要保存工具记录时,可以把 MessageChatMemoryAdvisor 的 order 设为大于 ToolCallingAdvisor 的值;Spring AI 2.0 的内置仓库中只有内存、Redis、Neo4j 能保存工具消息,JDBC 不能(以官方最新文档为准),用 JDBC 时由应用自己保存,见短期记忆。
可观测方面,Spring AI 基于 Micrometer Observation 为 ChatClient 调用、Advisor、每次模型调用和工具执行生成 span,模型调用的 span 上带模型名和 Token 数;提示词、回答和工具参数默认不记录,需要时用配置项开启(以官方最新文档为准)。它的 span 名称和层级与可观测一节按 OpenTelemetry 约定画的示例不同,例如没有 invoke_agent 这一层,由 ChatClient 和 Advisor 的 span 充当。
Spring AI 主要解决模型接入和单个 Agent 的工具调用。Java 中另一个常用的框架是 LangChain4j,它不依赖 Spring,可以集成到 Spring Boot 或 Quarkus 中,特点是 AI Services:声明一个接口,框架生成实现这个接口的代理对象,与 Spring Data JPA 的 Repository 类似。两者的功能相近,Spring 项目用 Spring AI 与已有的配置、依赖注入结合得更自然。需要状态图编排时,可以用仿照 LangGraph 的 LangGraph4j,它可以与 LangChain4j 或 Spring AI 一起使用。
4. 怎样选择
Dify、Coze 这类低代码平台用拖拽的方式编排工作流,内置模型调用、知识库检索、问题分类、条件分支、工具调用和人工介入等节点,搭好后可以直接发布成接口或网页。
- 低代码平台:搭建快,产品和运营人员也能参与,调试界面直观。平台也提供循环、失败分支、重试和版本发布,但能力受节点的限制,例如 Dify 的循环中任何一个子节点失败,整个循环就终止,代码节点只能使用沙箱中预装的包;流程可以导出为 YAML 文件放进 Git,评审的对象是平台生成的配置和其中代码节点的代码;
- 代码框架:灵活,可以像普通代码一样测试、评审和发布;但开发成本更高,需要自己搭建调试和监控工具。
通常的取舍是:验证想法、流程标准的内部工具用低代码平台;核心业务、对可靠性和集成要求高的系统用代码框架。两者也可以组合,例如平台中的流程通过接口调用自研的服务。
LangChain 和 LangGraph 出自同一个团队,处在不同的层次:
- LangChain 提供模型、工具、检索器等组件和现成的 Agent。1.0 版用 create_agent 创建标准的工具调用循环,摘要、按需选择工具、人工确认等逻辑以中间件(Middleware)的方式插入,早期的 Chain 等旧接口移到了 langchain-classic 包中;
- LangGraph 是更底层的编排运行时,由开发者用状态图定义每个节点和分支,并提供持久化、流式输出和人工介入。create_agent 本身就运行在 LangGraph 之上。
所以标准的“模型加工具”循环用 LangChain 几行就能写完;流程中既有固定的步骤又有模型的决策、需要精确控制每一步的走向时,例如上面的退款流程,用 LangGraph 自己定义图。
代码框架之间的选择可以参考:
- Java 项目优先考虑 Spring AI 或 LangChain4j,与现有的 Spring 体系集成;Python 项目常用 LangChain、LangGraph;
- 流程有分支和循环、需要暂停等待人工审批或者从中断处恢复时,LangGraph 这类状态图框架更合适;
- 其它常被提到的框架侧重点不同:LlamaIndex 侧重文档解析、索引和检索,常用来搭 RAG;CrewAI、AutoGen 侧重多个 Agent 按角色分工协作,AutoGen 已停止维护,由 Microsoft Agent Framework 接替。
框架也有代价,逻辑简单时直接调用模型的接口写一个循环往往更清楚,前面的 Agent 循环不到二十行:
- 抽象层多,实际发给模型的提示词和消息藏在几层封装之后,效果不好时要借助追踪工具(例如 LangSmith)或打印请求才能定位;
- 接口变化快,网上很多按旧版本写的示例不能直接运行,升级也要改代码;
- 用 LangGraph 要先设计状态、节点和边,流程只有两三步时,这些代码比业务逻辑本身还多;
- 依赖 checkpoint 时,状态中的对象都要能序列化,被中断的节点恢复时会重新执行,副作用要幂等。
把图中固定的流程改成单个 ReAct 循环时,人工确认这类约束要移进工具,见 Agent 与工作流。
可靠性与评测
1. 容错
Agent 的每一步都可能出错,容错的逻辑要写在控制循环(Harness)的代码里,而不是依赖提示词和模型自觉。
工具执行前先校验参数:先按 JSON Schema 检查类型、必填字段和枚举值,再做业务校验,例如订单是否属于当前用户、退款金额是否超过实付金额。严格模式和约束解码只能保证格式正确,保证不了业务上正确。
工具出错时,按错误的类型分别处理:
- 临时性错误,例如超时、限流、服务端 5xx:由代码按指数退避重试 2~3 次,模型不需要知道,做法与调用模型接口时相同,见稳定性。修改数据的工具只有在确认操作幂等时才自动重试,例如下游支持幂等键;否则超时后先查询这次操作的状态,确认原请求没有生效、之后也不会再生效时才重试;
- 模型可以修正的错误,例如参数格式不对、取值不在范围内:把错误信息作为工具结果回传给模型,信息中写明错在哪、正确的格式是什么;
- 重试后仍然失败:把失败如实回传给模型,由它换一种办法,或者告诉用户哪一步失败了、返回已经拿到的部分结果、转人工,不能让模型编造结果。
不能指望模型自己处理临时性错误。在“订单 1024 到哪了”的例子中,让物流接口第一次调用返回“物流接口超时,请稍后重试”,并把错误回传给模型,7B 模型 10 次运行中没有一次重新调用,都回答物流信息暂时查不到、请稍后再试;而接口实际上第二次调用就能成功。
参数错误能否被修正,取决于错误信息写得是否清楚。物流接口要求快递公司编码为 shunfeng,模型按常识填了 SF:错误信息只写“参数错误”时 10 次都没有重新调用,其中 4 次编造了位置或送达时间,例如“目前包裹在上海市,预计 2 天内送达”;写明“carrier 应为快递公司编码,顺丰为 shunfeng”时 10 次都改用 shunfeng 重新调用,全部答出了包裹在杭州转运中心(Qwen2.5-7B 实测,见附录 A7)。
错误信息写清楚之后,模型通常能够自行修正,例如:
assistant 调用 refund({"order_id": "1024", "amount": "全部"})
tool 参数错误:amount 必须是数字,订单 1024 的实付金额为 59.9
assistant 调用 refund({"order_id": "1024", "amount": 59.9})
tool 退款申请已提交,单号 R88
一次回复中并行调用了多个工具、其中一部分失败时,成功的结果照常返回,失败的单独标明错误,由模型决定是否只重试失败的那一个。每个调用都要回传一条结果,没有执行的也要回一条错误,Anthropic 的接口要求每个 tool_use 都有对应的 tool_result。
工具持续失败时,例如物流接口宕机,可以像模型接口一样熔断,直接返回“暂不可用”,不必每个请求都先等超时再重试。
死循环的原因是模型每一步只看当前的上下文做决定:工具反复返回同样的结果时,上下文几乎不变,它就可能一次次做出同样的决定;没有明确的完成标准时,它可能一直查下去。因此控制循环中要有这些限制:
- 限制步数、总耗时和 Token 预算,超出时停止,返回已有的结果或者转人工。步数上限参考评测中成功任务用的步数,例如取 P99 再留些余量,并显式设置,不依赖框架的默认值,例如 OpenAI Agents SDK v0.24 默认 10 次模型调用,LangGraph 1.2 默认 10007 个图步数,Claude Agent SDK 默认不限;
- 检测有没有进展:合理的循环每一轮都有进展,例如翻页;同样的参数连续几次得到同样的结果,很可能是在原地打转。所以要同时比较工具名、参数和结果中的关键字段,而不只是数工具调用了几次,连续几次没有进展时先提示模型换一种方法,再次出现才中止(例如 Gemini CLI v0.66),避免误伤正常的重试。等待异步任务完成时不要让模型反复查询,做法见 Agent 循环;
- 重规划、反思、审核退回、Agent 之间的转交都要设次数上限。
有副作用的操作要额外处理:
- 幂等:修改数据的工具用幂等键去重,重试时沿用同一个键,例如同一个订单只能有一条进行中的退款,见幂等;
- 人工确认:转账、删除数据、对外发送信息等操作由代码校验权限,执行前暂停,把操作内容展示给人确认,实现见 checkpoint 与人工介入,待确认的操作和参数保存在服务端,确认请求只带操作 ID 和决定,同一个操作只能被确认执行一次(例如原子地把状态从待确认改为执行中),避免重复提交或确认的内容被替换;
- 补偿:多步操作中后面的步骤失败、前面的需要撤销时,执行对应的补偿操作,例如取消已提交的退款申请,思路与 Saga 相同。只补偿已确认成功的步骤,下游既不支持幂等、也查询不到结果的步骤标为结果未知,转人工或对账,不自动重试,也不自动补偿;
- 隔离:执行代码的工具放在沙箱中运行,并记录审计日志。
同一个操作可能执行两次:工具超时时操作可能已经执行,只是响应没有返回;模型会重复调用,从 checkpoint 恢复时节点会重新执行,框架的自动重试(例如 LangGraph 节点的 RetryPolicy)也是重跑整个节点。所以自动重试的节点应当没有副作用,或者副作用是幂等的。
幂等键由程序生成,不让模型生成,要标识一次业务操作,例如退款申请号;参数变了就是新的操作,换新的键并重新确认。键要在执行工具之前写入存储,否则工具执行之后、checkpoint 保存之前进程崩溃,恢复时会生成新的键,去重就失效了,LangGraph 默认的 async 写入就有这个窗口,见 checkpoint 与人工介入。
沙箱一般要限制可写的路径(工作目录和必要的临时目录等)、网络(默认拒绝或按域名白名单放行)、CPU 和内存,并由调用方计时、超时后终止。密钥不放进沙箱,可以由沙箱外的代理在转发请求时加上,沙箱里的代码即使被诱导也读不到密钥。
常见的隔离机制有三种:共享宿主机内核的容器,配合 seccomp 限制可用的系统调用;gVisor 在用户态实现 Linux 的内核接口,容器中的程序不直接调用宿主机内核;Firecracker 这类轻量虚拟机有独立的内核,借助硬件虚拟化隔离。隔离效果取决于配置,开销取决于实现和工作负载,按要防范的风险选择。
确认得太频繁,人会不看内容就点同意,确认也就失去了作用。所以只确认涉及资金、无法撤销或者对外可见的操作,只读的查询、影响小且可以撤销的操作(例如创建售后工单)、在接触不到真实数据的沙箱中运行的代码自动执行并记录日志;需要确认的操作也可以按规则放行一部分,例如金额低于阈值的退款。
2. 评测
与只看模型输出文本的问答评测相比,Agent 在环境中连续执行多步,评测时还要检查环境的最终状态和执行过程,所以要区分结果和轨迹:
- 结果:任务结束时环境的最终状态,例如数据库中是否真的有一条 59.9 元的退款记录。Agent 回复“已为您退款”不代表退款成功;
- 轨迹:完整的执行过程,包括调用了哪些工具、参数是什么、走了多少步、消耗了多少 Token。轨迹用于检查有没有危险或多余的调用,以及定位失败的原因。
同一个任务可能有多种正确的完成路径,所以主要检查结果,而不是要求调用顺序与标准答案完全一致;轨迹上只检查必须满足的约束。一条退款任务的评测用例可以写成:
task: 用户要求为订单 1024 退款,订单已签收 3 天
setup: orders 表中订单 1024 实付 59.9,状态为已签收
checks:
outcome: refunds 表中订单 1024 有且只有一条退款记录,金额 59.9 # 代码检查
trajectory: 调用过 get_order;refund 的 amount ≤ 59.9;没有调用 update_address;步数 ≤ 6
reply: 告知了退款金额和到账时间,没有编造单号 # 模型评分
检查的方式有三种,优先使用前一种:
- 代码检查:查数据库状态、断言工具参数、统计步数,快、便宜、结果稳定;
- 模型评分:用大模型作为裁判(LLM-as-a-Judge),评价回复是否准确、完整、语气是否合适,适合没有标准答案的开放内容;
- 人工评审:抽检,并用来校准模型裁判。
常用的指标:
- 任务成功率:在一批固定的任务上,最终结果正确的比例,每个任务运行多次取平均;
- 工具调用正确率:选对工具、参数合法的调用占全部调用的比例;
- 效率:完成任务所需的步数、耗时和 Token 消耗;
- 部分得分:多步任务中完成了几步,例如查对了订单但退款失败,比一开始就失败要好。
由于采样的随机性,同一个任务运行多次,结果可能不同,只运行一次的成功率并不可靠。τ-bench 提出用 pass^k 衡量稳定性:同一个任务连续 k 次都成功的概率。它与常见的 pass@k(k 次中至少成功一次)正好相反,假设单次成功率为 90%,pass@8 接近 100%,pass^8 只有 0.98 ≈ 43%。面向用户的 Agent 每次都要做对,更应该关注 pass^k。该论文中 GPT-4o 使用函数调用时,零售场景的单次成功率约 61%,pass^8 降到约 25%;航空场景的单次成功率只有约 35%。
3. 模型裁判的偏差
模型裁判本身也会出错,常见的偏差有三种:
- 位置偏差:比较两个回答时,倾向于选排在某个位置的那个,交换顺序后结论就变了。MT-Bench 的论文中,两个回答质量相近时,GPT-4 交换顺序后结论一致的比例只有 65%;
- 长度偏差:倾向于更长的回答,即使多出来的内容没有新信息。同一论文中,把回答里的列表换个说法重复一遍,Claude-v1 和 GPT-3.5 有 91.3% 的情况认为加长后的回答更好,GPT-4 为 8.7%;
- 自我偏好:给自己生成的回答打更高的分。有研究在摘要任务上发现 GPT-4、Llama 2 能在一定程度上认出自己的输出;对 GPT-3.5 和 Llama 2 的微调实验中,认出自己的能力越强,这种偏差也越大(arXiv 2404.13076)。
用 Qwen2.5-7B 作裁判做了一个小实验:12 对客服回答分为三组,每对按两种顺序各判一次:
不给参考时,4 对质量相当的回答 8 次都判排在第二个的更好;4 对信息相同、只多了客套话的回答,8 次都选了更长的那个;4 对“更详细,但有一处与工具结果不符”(例如把杭州转运中心写成上海转运中心)的回答,8 次中有 7 次选了有错的那个。把工具结果作为参考放进提示词后,有错的回答 8 次都被判为较差,但位置偏差仍然存在,只是变成偏向第一个位置,质量相当的回答 8 次中 6 次选了第一个(见附录 A10)。给出参考能纠正事实错误,但纠正不了位置偏差,几种缓解方法需要一起使用。
缓解方法:
- 交换顺序评两次,两次结论一致才算数,不一致记为平局;
- 给出参考:把工具的返回结果或者标准答案交给裁判,要求以它为准;
- 把评分标准拆成具体的、可以逐项判断的问题,例如“是否给出了运单号”“位置是否与物流接口返回的一致”,而不是笼统地打 1~10 分;
- 裁判使用与被评测的模型不同系列的模型,并允许输出“无法判断”;
- 用一小批人工标注的样本检查裁判与人的一致率,一致率足够高之后再大规模使用,之后定期抽查,失败和正常的样本要分别统计,因为线上的会话大多是正常的,总是判通过的裁判一致率也会很高。
4. 评测集与线上指标
评测集从 20~50 条真实任务开始就有价值,不必等到几百条,只是这时只能发现明显的变化,例如 50 条用例上准确率为 70% 时,95% 的置信区间约为 ±13 个百分点(比较方法见下文):
- 来源:线上日志、转人工和差评的会话、测试人员发现的问题,每个修过的错误都变成一条用例;
- 覆盖:按意图和难度分层,正例和反例都要有,例如既有应该退款的,也有超过退款期限、应该拒绝的;
- 标准:每条任务写清楚怎样算成功,两个人独立判断能得出相同的结论,否则先改任务描述;
- 环境:每次从干净的数据开始运行,避免上一次运行留下的状态影响结果,资源上限、超时这类配置固定并记录,基础设施导致的失败单独统计;多轮对话的任务可以让另一个模型按设定的用户信息和诉求扮演用户,τ-bench 就是这样做的。
每次修改提示词、工具或者模型,都在同一批任务上回归,并记录每次运行的完整轨迹,出错时才能复现和定位。评测分数上不去时,先读几条失败的轨迹,确认是 Agent 真的做错了,而不是评分规则把正确的做法判错了;再按出错的环节归类,例如检索没有召回、选错工具、参数错误、拿到了正确的工具结果却总结错,占比最高的一类就是优先要改的瓶颈。
反复对着同一批用例改提示词,分数会越来越高,但可能只是记住了这些用例。可以按同样的分层留出一部分用例,平时不看、只在大的改动后运行:平时用的用例分数持续上升,留出的用例却没有改善时,就要怀疑过拟合,用例少时还要多跑几次排除随机波动。
比较两个版本时还要考虑用例的数量。假设 200 条用例是从真实问题中独立随机抽取的,测得准确率为 80% 时,总体准确率的 95% 置信区间约为 74.5%~85.5%(按正态近似),只看总分,80% 对 84% 判断不了差异是否可靠。要在同一批用例上逐条对比两个版本:新版本改对了 8 条、没有改错任何一条,与改对 20 条、改错 12 条,总分都是 84%,前者的提升可靠得多。
选模型时可以先参考公开的基准(例如测多学科选择题的 MMLU、测修复真实 GitHub issue 的 SWE-bench),但还要用自己的业务评测集验证:题目可能已经进了训练数据,按 GSM8K 新出一套同类的题时,部分模型的准确率下降了多达 8 个百分点(GSM1k);题目本身也可能有错,估计 MMLU 约有 6.49% 的题有错(MMLU-Redux)。
选型时先按硬条件筛选:数据能否交给外部服务、许可是否允许商用(同一系列不同尺寸的许可也可能不同,例如 Qwen2.5 的 7B 用 Apache 2.0,3B 只允许研究和评估,以模型发布页的许可为准),上下文和输出长度是否够用,是否支持需要的工具调用和结构化输出;再在业务评测集上比较剩下几个模型的效果、延迟和每次任务的费用。
服务商的模型名还可能改指新的模型,旧模型也会下线,例如 DeepSeek 的 deepseek-chat 先后对应过 11 个底层模型,阿里云百炼的正式版模型在启动下线后 3 个月停止服务。服务商提供固定版本的模型名(例如带日期的快照)时,生产环境尽量用它,换模型前在同一批用例上逐条对比(以各服务商的最新文档为准)。
上线后关注的指标:
- 效果:任务完成率、转人工率、用户重复提问或者改口的比例、满意度和差评;
- 过程:工具调用的错误率、平均步数、触发最大步数和重试上限的次数;
- 性能与成本:首个 Token 的延迟、总耗时、每次会话的 Token 消耗。
差评和转人工只能发现一部分失败的会话:大多数用户不会反馈,反馈了也很少说明原因。所以还要按意图定期抽样阅读会话,并用模型裁判检查线上会话,例如回复与工具返回的结果是否一致,筛出可疑的会话再由人工确认。
失败的线上会话经过脱敏和人工标注后,按出错的原因处理:资料缺失的补进知识库,提示词或工具有问题的修改对应部分,需要微调的整理成训练样本。按会话划分训练样本和评测用例,同一个会话不同时进入两者,否则评测分数会偏高;修改后用评测集回归,形成“发现问题、加用例、修改、回归”的循环。
五、工程实践
流式输出
生成一段长回答可能需要十几秒,流式输出让用户在第一个 Token 生成后就能看到内容。大模型接口通常使用服务器发送事件(Server-Sent Events, SSE)返回结果:响应的 Content-Type 为 text/event-stream,连接保持打开,服务端每生成一段内容就写入一条事件。
1. SSE 的格式
id: 1
data: {"delta": "杭州"}
id: 2
data: {"delta": "今天晴"}
: ping
data: [DONE]
- 每条事件由若干行“字段: 值”组成,以空行结束,data 是事件的内容(一条事件有多行 data 时按换行拼接),id 是事件的编号,event 是事件的类型(省略时为 message);
- 以冒号开头的行是注释,客户端会忽略,常用作心跳,防止中间的代理因为连接长时间空闲而断开,间隔要小于代理的读超时,例如 Nginx 的 proxy_read_timeout 默认 60 秒,计的是两次读之间的间隔;
- 浏览器的 EventSource 断线后会自动重连,并在请求首部 Last-Event-ID 中带上最后收到的事件编号,服务端可以据此从断点继续发送。EventSource 只能发 GET 请求、不能带请求体,对话接口用 POST 发送问题时,前端通常用 fetch 读取流,这时重连和 Last-Event-ID 都要自己实现;
- OpenAI 兼容的接口用 data: [DONE] 表示生成结束。
SSE 与 WebSocket 的区别:
- SSE 基于普通的 HTTP 响应,只能由服务端向客户端推送,用浏览器的 EventSource 接收时自带断线重连,正好符合“请求一次、持续返回”的场景;
- WebSocket 是全双工的独立协议,适合客户端和服务端都需要随时发送消息的场景,例如实时语音对话。
后端调用模型的流式接口时,逐行读取响应,读到一行就处理一行:
static void stream(HttpClient client, HttpRequest request, Consumer<String> onData) throws Exception {
HttpResponse<Stream<String>> response = client.send(request, HttpResponse.BodyHandlers.ofLines());
try (Stream<String> lines = response.body()) {
lines.filter(line -> line.startsWith("data:")) // 模型接口每条事件只有一行 data,可以逐行处理
.map(line -> line.substring(5).trim())
.takeWhile(data -> !data.equals("[DONE]"))
.forEach(onData); // 例如转发给前端
}
}
2. 推送 Agent 的执行过程
Agent 的回答之前可能要先调用几次工具,最终的文字出现之前会有好几秒没有输出。把执行过程也作为事件推给前端,用户能看到“正在查询订单”这样的进度。做法是用 event 字段区分事件的类型,前端按类型分别处理。以“订单 1024 到哪了”为例:
事件的类型按前端的需要设计,常见的有:
- tool_call、tool_result:开始调用某个工具,以及调用的结果。结果只推送给用户看的摘要,原始数据可能很大,也可能含有不该展示的字段;
- delta:回答的增量文字,前端逐段追加;
- error:出错的原因,例如工具超时、超过最大步数;
- done:结束,可以附带步数、Token 用量等统计。
服务端在 Agent 循环 中每完成一个动作就写入一条事件,模型生成的文字通过回调边生成边推送:
static void emit(String event, Map<String, Object> data) { // 写一条 SSE 事件
out.print("id: " + nextId++ + "\nevent: " + event + "\ndata: " + json(data) + "\n\n");
out.flush(); // 立即写出,不要缓冲
}
static void run(String question) {
List<Message> messages = new ArrayList<>(List.of(Message.system("你是电商客服助手"), Message.user(question)));
for (int step = 1; step <= MAX_STEPS; step++) {
Message reply = llm.chatStream(messages, text -> emit("delta", Map.of("text", text)));
messages.add(reply);
if (reply.toolCalls().isEmpty()) { emit("done", Map.of("steps", step)); return; }
for (ToolCall call : reply.toolCalls()) {
emit("tool_call", Map.of("name", call.name(), "args", call.arguments()));
String result = execute(call);
emit("tool_result", Map.of("name", call.name(), "summary", summarize(result))); // 只推摘要
messages.add(Message.tool(call.id(), result));
}
}
emit("error", Map.of("message", "超过最大步数"));
}
LangGraph 等框架也提供类似的机制,按节点输出状态的更新或者自定义的事件,原理相同。
3. 生产环境中的注意事项
- Nginx 等反向代理默认会缓冲上游的响应,流式输出变成了一次性输出,需要关闭缓冲,例如在响应首部中加上 X-Accel-Buffering: no;
- 网关和负载均衡的读超时要大于最长的生成时间;
- 不支持续传时,用户关闭页面、连接断开后要及时取消对模型的请求,否则仍在消耗 Token,但断开能否停止生成和计费取决于服务商,例如 OpenRouter 的文档写明,非流式请求或不支持取消的服务商会继续处理并按完整的回答计费,所以还要用 max_tokens 限制输出的上限;
- 网络抖动导致断线时,模型的生成无法从中途重新开始。需要续传时,让生成任务与连接解耦:生成的内容按会话和事件编号写入 Redis 等缓存,客户端带着 Last-Event-ID 重连后,先从缓存中补发之后的事件,再继续推送新的内容,超时没有重连再取消生成。这时断开连接不等于停止生成,“停止”按钮要调用单独的接口;补发缓存和开始订阅新事件之间产生的事件要一并补上,否则会漏;生成中的增量先写缓存,避免每个 Token 都写数据库,正常结束时保存完整的回答,停止时保存已经生成的部分并标记为未完成;
- Agent 任务断线后通常继续执行到当前步骤结束,已经发起的退款这类有副作用的工具调用不能中途放弃,结果写入缓存,等客户端重连后补发;
- 工具调用的参数也是分片段流式返回的,需要按调用的序号拼接,这个调用结束后才能解析出完整的 JSON;结束原因是长度截断(finish_reason 为 length、stop_reason 为 max_tokens)时参数可能不完整,不能执行工具,要重试或把错误回传给模型。
延迟与成本
1. 延迟
大模型请求的延迟用两个指标衡量:
- 首 Token 时间(Time To First Token, TTFT):从发出请求到收到第一个 Token,主要由预填充阶段决定,输入越长越慢;
- 每个输出 Token 的时间(Time Per Output Token, TPOT):解码阶段生成每个 Token 的平均时间,决定了文字出现的速度。
总延迟约为 TTFT + TPOT × 输出的 Token 数。例如 TTFT 为 0.5 秒、TPOT 为 30 毫秒时,输出 300 个 Token 需要约 9.5 秒,其中 9 秒花在输出上。输出是逐个生成的,比并行处理的输入慢得多,所以限制输出长度通常是降低延迟最有效的方法。Agent 每一步都要等模型返回,步数越多越慢,互不依赖的工具调用可以并行执行。
2. 一次 Agent 任务的费用
模型接口按 Token 计费,输入和输出分别计价,输出比输入贵,命中缓存的输入便宜得多。以 DeepSeek 的 deepseek-flash 为例,每百万 Token 的价格为:输入命中缓存 0.04 元,未命中 2 元,输出 8 元(2026-10-27 官方价格表中的高峰时段价格,以官方最新价格为准)。
用 Qwen2.5 的分词器计算“订单 1024 到哪了”的 3 次调用(换成其它模型的分词器,数量会有出入):
输入 与之前的请求相同的前缀 新增 输出
第 1 次 283 264(系统提示词和工具定义) 19 24
第 2 次 355 307(第 1 次的输入和输出) 48 29
第 3 次 446 384(第 2 次的输入和输出) 62 13
合计 1084 955(88%) 129 66
不考虑缓存时一次对话约 0.27 分,前缀全部命中缓存时约 0.082 分;每天 10 万次对话,分别约 270 元和 82 元(见附录 A20)。
这里按关闭思考模式计算,输出只有回答本身。deepseek-flash 默认开启思考模式,思维链也按输出计费,例如每次调用思考 1000 个 Token,3 次调用仅思维链就约 2.4 分,是上面前缀全部命中时的近 30 倍;带工具调用时,之前轮次的思维链还要回传,计入后面请求的输入。
这个例子的工具和提示词都很短。实际的 Agent 往往有几千 Token 的系统提示词和工具定义,每一步还会加入大段的工具结果。假设系统提示词和工具定义共 5000 个 Token,每一步新增 2000 个 Token,第 10 步的输入是 2.3 万个 Token,但 10 步累计的输入达到 14 万个 Token:
每一步的输入都以上一步的输入和输出为前缀,88% 的输入可以命中缓存,费用从 0.296 元降到 0.053 元。输入随步数线性增长,累计的输入随步数平方增长,所以步数多的任务更依赖缓存,也更需要压缩上下文。
3. 让前缀缓存命中
前缀缓存只匹配从第一个 Token 开始完全相同的部分,中间任何一个 Token 不同,它之后的内容都无法命中(原理见推理服务部署)。因此:
- 系统提示词、工具定义这些不变的内容放在最前面,当前时间、用户名这类每次都变的内容放在后面;
- 工具列表的顺序保持固定,按任务动态增减工具会改变前缀;
- 历史消息原样追加,不要每次重新拼接或者调整 JSON 字段的顺序;
- 压缩或截断早期的历史会改变前缀,压缩之后的第一次请求无法命中,所以压缩不宜过于频繁。
缓存是否命中可以从接口返回的用量中查看,例如 DeepSeek 返回的 prompt_cache_hit_tokens 和 prompt_cache_miss_tokens。需要注意的是,服务端的缓存是尽力而为的,构建需要时间,一段时间不用就会被清除,不能保证 100% 命中。
前缀相同也不一定命中,因为服务端只在部分位置写入缓存。例如 DeepSeek 在每次请求的输入结束和输出结束处、多次请求的公共前缀处,以及长内容中每隔一定数量的 Token 写入,后一次请求要完整包含某个已写入的前缀才能命中;Anthropic 在 cache_control 指定的断点处写入。缓存的前缀还有最小长度,例如 OpenAI 较新的模型为 1024 个 Token,可以复用的前缀不到这个长度时不会命中。
部分服务商对写入缓存加价,例如 Anthropic 5 分钟缓存的写入按输入单价的 1.25 倍计费,1 小时缓存按 2 倍,只写不读反而更贵。这些规则变化较快,以各家的官方文档为准。
4. 减少 Token 消耗
前缀缓存降低的是单价,减少 Token 的数量要从上下文和输出两头入手:
- 控制工具返回的内容:工具默认返回摘要或者片段和文档编号,模型判断需要细节时再调用工具读取全文。例如检索返回 5 个片段约 1500 个 Token,把 5 篇文档的全文都放进去约 3 万个 Token,后者每一次调用的输入费用是前者的 20 倍,而且后面的每一步都要带着它;
- 限制输出长度:设置 max_tokens,要求回答简洁。以上面的 deepseek-flash 为例,输出的单价是未命中缓存的输入的 4 倍;
- 按难度选择模型:先用小模型判断问题的类型,查物流、改地址这类简单问题由小模型完成,复杂的投诉交给大模型。路由会分错,需要在评测集上确认整体效果没有下降。
完全相同的问题还可以直接返回缓存的结果。用 Embedding 判断问题是否相近的语义缓存命中率更高,但容易把不同的问题当成同一个。例如用 bge-small-zh-v1.5 计算余弦相似度,“订单 1024 到哪了”和“订单 1025 到哪了”为 0.82,“会员可以免运费吗”和“非会员可以免运费吗”为 0.95,都高于意思相同的“订单 1024 到哪了”和“我的快递 1024 现在在哪”(0.73),只靠阈值分不开(见附录 A32)。
所以涉及用户私有数据或个性化的回答时,语义缓存要按用户分区;订单号、日期这类会改变答案的参数要抽出来,要求完全相同;依赖实时数据(例如物流状态)的问题不缓存最终的回答。
Token 消耗增长过快时,先按步骤和工具汇总每一次调用的输入、命中缓存和输出的 Token 数。常见的原因正是上面几点没有做到:某个工具返回了大段的原始数据,模型反复调用同一个工具,历史一直没有压缩,或者修改提示词后在开头加入了会变化的内容,命中率下降。
推理服务部署
与调用模型服务商的接口相比,自己部署开源模型的好处是数据不出内网、可以使用微调过的模型;代价是显卡空闲时也在计费,运维要自己做,调用量大而稳定时才可能更便宜。
自己部署时,例如用 vLLM 部署 Qwen2.5-7B,除了 TTFT 和 TPOT,还关心吞吐量(Throughput),即整个服务每秒生成的 Token 总数。TTFT、TPOT 决定单个用户的体验,吞吐量决定一张显卡能服务多少用户、每个 Token 的成本是多少。
推理优化的手段按解决的问题分为几类:
- 每一步都要读一遍权重和 KV Cache:用量化、GQA、MLA 缩小要读的数据;
- 注意力的中间矩阵要读写显存:用 FlashAttention 分块计算,不把中间矩阵写回显存,收益主要在预填充和长上下文,解码时读写的大部分仍是 KV Cache;
- 显卡同时处理的请求太少:批处理、连续批处理、PagedAttention;
- 重复计算相同的开头:前缀缓存;
- 只能逐个 Token 解码:投机解码(Speculative Decoding),小模型先连续猜几个 Token,大模型读一遍权重就能同时验证:设大、小模型给某个猜测的概率分别为 p、q,它以 min(1, p / q) 的概率被接受;第一个被拒绝的位置改从 max(0, p − q) 归一化后的分布重新采样,丢弃后面的猜测。这样输出的分布与大模型单独生成相同,适合并发不高的场景。
投机解码的草稿也可以不来自独立的小模型:Medusa 在大模型上加几个解码头,同时预测后面的多个 Token,它常用的宽松接受规则不保证输出分布不变;EAGLE 用一个很小的网络,根据大模型输出层之前的隐藏状态和已经采样出的 Token 逐个预测后面的特征,再用原来的输出层得到草稿;DeepSeek-V3 预训练时带有多 Token 预测(Multi-Token Prediction, MTP)模块,推理时可以用它猜第 2 个 Token,技术报告的评测中接受率为 85%~90%,每秒生成的 Token 数达到原来的 1.8 倍(arXiv 2401.10774、2401.15077、2412.19437)。
1. 批处理:用延迟换吞吐
解码阶段每生成一个 Token,都要把全部权重从显存中读一遍,而计算量很小,速度主要受显存带宽限制。例如 A100 80GB 的显存带宽为 2039 GB/s,Qwen2.5-7B 的 BF16 权重约 152 亿字节,每一步至少需要 7.5 毫秒,单个请求每秒最多生成约 134 个 Token。
预填充则不同,读一遍权重就能处理全部输入,每个 Token 要做约 13 GFLOP 的矩阵乘法(各层线性权重约 65 亿个参数,每个参数一次乘加;输出层只为最后一个 Token 计算,忽略不计)。输入越长,计算越多,读权重的时间却不变,所以长输入的预填充受算力限制。
按 A100 的 BF16 峰值 312 TFLOPS 估算,Qwen2.5-7B 处理 2048 个 Token 的输入至少约 0.09 秒,输入加倍到 4096 个时约 0.18 秒,略多于两倍,因为注意力的计算量与长度的平方成正比。实际达不到峰值算力,这些只是下限。
把多个请求放在同一批中,读一次权重就能为每个请求各生成一个 Token。批次越大,吞吐量越高;代价是每一步还要读取每个请求的 KV Cache、计算量也随之增加,每一步变慢,TPOT 变大。按带宽估算,32 个各有 2K Token 上下文的请求组成一批时,每一步至少 9.3 毫秒,吞吐上限约 3400 Token/s,是单个请求的 26 倍。
在笔记本上用 Qwen2.5-3B 测量,结果如下(Apple M1 Pro 上的示意性测量,不代表服务器,见附录 A21):
batch 从 1 增大到 64,总吞吐从 22 Token/s 提高到 214 Token/s,约 9.6 倍;每个请求的 TPOT 从 44 毫秒增大到 279 毫秒,64 个请求一起做预填充,TTFT 也从 0.1 秒增大到 2.8 秒。笔记本的算力有限,批处理的收益比服务器上小,但趋势相同。
所以在线对话服务要在延迟和吞吐之间取舍:先定下延迟的目标,例如 TPOT 不超过 50 毫秒,再选能满足目标的最大并发数;离线的批量任务不在乎单个请求的延迟,批次尽量大。
需要注意的是,同一个请求在不同大小的批次中,浮点运算的顺序可能不同,结果可能出现细微的差别(附录 A21 的实验中,各个 batch 大小下的贪心输出一致)。对可复现性要求高时,vLLM 提供批次不变(Batch Invariance)的模式,代价是速度可能变慢。
前两名 Token 的分数非常接近时,这种细微的差别可能改变贪心选出的 Token,之后的上下文随之改变,生成的内容就可能分叉。例如 Thinking Machines 的实验用 Qwen3-235B-A22B-Instruct-2507 以温度 0 对同一个提示词生成 1000 次,前 102 个 Token 都相同,最终得到 80 种不同的结果;换成批次不变的算子后,1000 次完全相同。
接口的 seed 参数只固定采样用的随机数,不能消除这类差别。OpenAI 的接口文档把 seed 写作尽力而为、不保证确定,Anthropic 的文档也写明温度为 0 时结果并不完全确定。
2. 连续批处理
传统的静态批处理把一批请求一起送进模型,等这一批全部生成结束才接收新的请求。各个请求的输出长度相差很大,先结束的请求只能空等,新来的请求也要排队:
连续批处理(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),一个 8K 上下文的请求占 448 MB。以下的 GB 按 230 字节计算,权重约 14.2 GB(76 亿个参数,每个 2 字节):
并发请求数 KV Cache(每个 8K) 权重 + KV Cache
1 0.44 GB 14.6 GB
16 7 GB 21.2 GB
32 14 GB 28.2 GB
64 28 GB 42.2 GB
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,放回等待队列的最前面,之后重新做预填充,延迟明显增加,监控中的抢占次数(Prometheus 中的 vllm:num_preemptions_total)持续增长。想用同样的显卡服务更多请求,可以减小最大上下文长度、使用更多显卡,或者对权重和 KV Cache 进行量化。
模型一张卡放不下时用张量并行,把每层的权重矩阵切到多张卡上,每层的注意力和前馈网络算完后各要在卡间做一次 all-reduce 汇总结果,所以一般只在 NVLink 高速互联的一台机器内使用,跨机器时再按层切分(流水线并行)。
4. PagedAttention
请求的实际长度事先不知道。早期的推理系统按最大长度为每个请求预留一段连续的显存,请求实际只用了其中一部分,剩下的空着也不能给别的请求使用。vLLM 的论文统计,这些系统中真正用来存放 KV Cache 的显存只有 20.4%~38.2%。
PagedAttention 借鉴操作系统的分页,把 KV Cache 切成固定大小的块(vLLM 默认每块 16 个 Token),生成到需要时才分配一块,用块表记录每个请求的块在显存中的位置,浪费的只有每个请求最后一块中没写满的部分:
同样 7 GB 的显存,按 8K 预留只能服务 16 个请求;按块分配时,如果请求平均只用到 2K Token,可以服务 64 个。批次越大吞吐越高,vLLM 论文中报告,在延迟相近的情况下吞吐量是 FasterTransformer、Orca 等系统的 2~4 倍。Ollama 则按“并行数 × 上下文长度”预留内存,适合本机试用,多用户的在线服务一般用 vLLM、SGLang。
PagedAttention 管 KV Cache 放在哪里,并为此实现了按块表读取不连续块的注意力内核;FlashAttention 管注意力怎么算,减少中间矩阵的显存读写。两者可以同时使用,flash-attn 从 2.5 版起支持分页的 KV Cache,vLLM 的 FlashAttention 后端把块表传给它的内核。
5. 前缀缓存
很多请求的开头是相同的,例如同一个客服系统的所有请求都以相同的系统提示词和工具定义开头,同一个 Agent 任务的每一步都以上一步的输入为开头。前缀缓存(Prefix Caching)把已经算好的 KV Cache 保留下来,新请求的开头与之相同时直接复用,跳过这部分的预填充,TTFT 随之降低。
vLLM 的实现基于 PagedAttention 的块:每个写满的块用“前面所有块的哈希 + 本块的 Token”计算哈希值,新请求按块逐个查找,前面的块都命中,后面的块才可能命中。只有写满的块会被缓存,例如客服例子中系统提示词和工具定义共 264 个 Token,前 256 个(16 块)可以复用,剩下的 8 个重新计算。SGLang 的 RadixAttention 也是前缀缓存,它用基数树(Radix Tree)组织缓存的 Token 序列,页大小为默认的 1 时按单个 Token 匹配,这 264 个 Token 都能复用;页更大时也只匹配完整的页。
模型服务商对命中缓存的输入按更低的价格计费,就是因为这部分不需要重新计算。需要注意的是,缓存保存在每个实例自己的显存中,部署多个实例时,同一个会话的请求应该尽量路由到同一个实例,否则缓存在别的实例上,无法命中。
不同会话之间也能共享前缀,例如所有用户共用的系统提示词。这时路由器可以记录每个实例处理过哪些前缀,把开头相同的请求发到同一个实例。但命中缓存的实例可能正忙,排队的时间比重新预填充还长,所以路由器要在命中和负载之间折中。以 SGLang 的缓存感知路由为例:各实例待处理的请求数相差悬殊(差值和比值都超过阈值)时,直接选最空闲的实例;否则,前缀匹配的比例超过阈值才选匹配最长的实例,没有超过时仍选最空闲的实例(以框架的最新版本为准)。
整个提示词都命中缓存时,最后一个 Token 仍要计算才能得到下一个 Token 的概率,vLLM 因为要按块对齐,会重算最后一整块,例如 256 个 Token 的提示词全部命中时复用 240 个。
多个租户共用一个服务时,攻击者可能通过首 Token 的延迟推断别人的请求中有没有某段内容,vLLM 支持在请求中加 cache_salt,只有 salt 相同的请求才能复用缓存(vLLM 0.32 源码与文档)。
稳定性
模型接口比普通的后端接口更慢,也更容易失败:生成一段回答要几秒到几十秒,请求可能在服务端排队;超过请求频率、Token 速率或者并发数的限制时返回 429,服务端故障或者负载过高时返回 500、503(Anthropic 的过载为 529);流式输出还可能在中途断开。另一类错误是请求本身有问题,例如输入超过上下文窗口时返回 400,重试也不会成功。
应对这些问题的手段从内到外是:为每次调用设置合适的超时,可恢复的错误按退避重试,重试仍然失败或者主模型持续出错时降级到备用模型,最后返回兜底的结果:
1. 超时
非流式请求要等完整的结果,耗时取决于输出的长度,总超时至少要大于 TTFT + 最大输出 Token 数 × TPOT。例如最多输出 1000 个 Token、TPOT 为 30 毫秒时,正常情况下也需要 30 多秒,如果照搬普通接口的 5 秒超时,长回答会全部失败。
流式请求不需要等完整的结果,但只设一个总超时,无法区分服务卡住了还是还在正常地输出。流式请求分开设置:
- 首 Token 超时:迟迟收不到第一个 Token,说明在排队或者服务异常;
- 间隔超时:相邻两段数据之间超过一定时间没有新内容,说明连接或者生成卡住了;
- 总时长上限:防止异常情况下一直输出。
需要注意的是,有的服务在排队时会持续发送心跳,例如 DeepSeek 对流式请求返回 : keep-alive 注释,对非流式请求返回空行,计算首 Token 超时和间隔超时时不能把心跳当成内容。
2. 重试与指数退避
临时限流导致的 429、5xx 和超时可以重试,400、401 这类错误重试也不会成功,直接失败。重试的间隔按指数增长,例如 0.5 秒、1 秒、2 秒,并加入随机抖动,避免大量请求在同一时刻一起重试;响应中带有 Retry-After 首部时,按它指定的时间等待。
static Reply chatWithRetry(Request req) throws LlmException {
long backoff = 500; // 基准间隔,从 0.5 秒开始
for (int attempt = 1; ; attempt++) {
try {
return client.chat(req);
} catch (LlmException e) {
if (!e.retryable() || attempt == MAX_ATTEMPTS) throw e; // 400、401 等直接失败
long wait = e.retryAfterMillis() > 0
? e.retryAfterMillis() // 服务端指定了等待时间
: backoff / 2 + ThreadLocalRandom.current().nextLong(backoff / 2); // 随机抖动:取基准间隔的 0.5~1 倍
sleep(wait);
backoff = Math.min(backoff * 2, 8_000); // 指数增长,设上限
}
}
}
OpenAI 的官方 SDK 默认也是这样处理的:对 408、409、429 和 5xx 重试 2 次,初始间隔 0.5 秒、最大 8 秒,并遵守 Retry-After。但 Retry-After 过长时各语言的 SDK 处理不同,例如 Python 版超过 120 秒就不再重试,Node 版超过 60 秒则改用默认的退避间隔(openai-python 3.42、openai-node 7.47,以所用版本为准)。
花费达到上限、配额用完时也可能返回 429。例如 Anthropic 达到月度花费上限时,返回的 429 与普通限流一样是 rate_limit_error,但没有 Retry-After,error.details.error_code 为 enforced_spend_limit_reached。包括 SDK 的自动重试在内,重试都不会成功,所以要在代码中识别它、停止重试,等额度重置或提高额度后再恢复。
重试次数要有上限。服务已经过载时,每一层都重试 3 次,请求量会被成倍放大,反而更难恢复。Agent 中模型调用和工具调用嵌套时,只在一层重试。工具调用的重试和容错见可靠性与评测。
3. 限流、降级与熔断
收到 429 再退避是被动的。大量 Agent 任务同时调用模型时,在调用方主动限流更好:按服务商的限额设置并发数上限和令牌桶,超出的请求排队等待,而不是发出去再被拒绝。
服务商的限额一般按账号或组织计算,多个业务共用一份,例如 DeepSeek 的并发限制按账号计、与 API Key 无关。网关可以给各业务预留保底额度、空闲时允许借用;共享的是 Token 配额时,按 Token 用量分配份额,避免请求大的业务占去大部分额度。
严格按优先级排队时,低优先级的请求可能一直排不上,要限制它的排队数量和等待时间,排队超时就拒绝;业务允许时,也可以转为异步任务。
按 Token 数限流时,输出有多少 Token 要等生成完才知道。如果只在请求结束后累加,并发的请求会一起放行,例如每分钟限额 10000、20 个并发请求各用 1500,事后累加时 20 个都已放行,实际用了 30000。要做硬上限,就在请求前按“输入 + max_tokens”预扣,完成后按实际用量结算、退回多扣的部分,所以 max_tokens 应设得接近预期的长度。
流式请求的用量也要留意,例如 OpenAI 的 Chat Completions 要设置 stream_options.include_usage,才会在结束前多收到一个带总用量的分块,流中途断开时可能收不到它。这时计费和统计先按已收到的内容估算,再与服务商的账单对账。
主模型不可用时降级:先切换到能力相近的备用模型,可以是另一家服务商;仍然失败时返回兜底的结果,例如“系统繁忙,已为您转人工客服”。备用模型的提示词、工具调用格式可能不同,上线前要在同一批评测任务上验证效果。
熔断用于避免每个请求都先等主模型超时再切换。主模型在一段时间内的失败率超过阈值时,熔断器打开,之后的请求直接走备用模型,过一段时间再放行少量请求试探主模型是否恢复。限流、降级和熔断的原理见系统设计基础。
统计失败率时要区分错误的类型:短暂的速率限制(429)按 retry-after 等待后重试,一般不计入模型失败,否则突发的限流就可能让熔断器打开,持续限流、重试耗尽时再决定是否切换;流式响应可能在返回 200 之后才在流中报错,要按流中的错误事件判断;判断慢调用要看首 Token 的时间和之后每个 Token 的间隔,回答长本来就慢,按总耗时会误判。
4. 流式输出中途断开
处理方式取决于断开时已经输出了什么:
- 还没有输出任何内容:直接重试,用户感知不到;
- 已经输出了部分文字:可以把已经输出的部分作为 assistant 消息的开头,请求模型接着往下写,例如 DeepSeek 的对话前缀续写;不支持时丢弃已有的内容重新生成,并通知前端清空已显示的文字;
- 工具调用的参数输出了一半:参数不完整,不能执行,只能丢弃后重新请求。
5. 可观测
记录每一次模型调用和工具调用的输入、输出、耗时、Token 数和错误码,以及使用的模型和提示词版本,出错时才能复现和定位问题。还要监控失败率、TTFT、TPOT 的分位数和缓存命中率,在用户感知之前发现模型服务变慢或者出错。
Agent 的一次请求记为一条链路(trace),以这次 Agent 调用为父 span,每次模型调用和工具执行是它下面的子 span,多轮对话再用会话 ID 把多条链路关联起来,原理与链路追踪相同。例如“订单 1024 到哪了”这次请求(省略了外层的 span):
invoke_agent 客服 Agent 会话 ID
├─ chat deepseek-flash 输入、输出的 Token 数,结束原因为 tool_calls
├─ execute_tool get_logistics 耗时、是否出错,开启后还记录参数 order_id = 1024
└─ chat deepseek-flash 输入、输出的 Token 数,结束原因为 stop
OpenTelemetry 的 GenAI 语义约定为这类 span 定义了 invoke_agent、chat、execute_tool 等操作名和 gen_ai.* 属性,目前仍处于开发阶段,以官方最新版本为准。
提示词、消息内容和工具的参数、结果可能包含个人信息,约定把它们列为需要显式开启才记录的属性;需要保存时可以存到单独控制权限的地方,链路上只记引用。
安全
1. 提示词注入
提示词注入(Prompt Injection)指的是攻击者通过输入的内容改变模型原本的指令。直接注入是用户在输入中写入恶意指令;间接注入是指令藏在模型读取的网页、文档或者工具返回结果中,在 RAG 和 Agent 中更加隐蔽。
2. 限制模型能造成的后果
模型无法可靠地区分指令和数据,在提示词中要求“忽略资料中的指令”只能降低风险,不能杜绝。这是因为系统提示词、用户输入和检索到的资料最终拼成同一个 Token 序列,没有参数化查询那样把代码和数据分开的机制,系统消息优先也只是训练出的倾向。因此防范的重点是限制模型能造成的后果:
- 区分指令和数据,把外部内容明确标注为不可信的资料;RAG 的知识库只收录来源可控的文档,防止有人写入恶意文档;
- 输入和输出可以再用规则或分类模型检测可疑指令,但检测会漏报,只能作为补充;
- 防止数据外泄:读取过不可信内容的任务不提供对外发送的工具,前端不自动加载模型输出中的外部图片,否则模型被诱导输出
后,页面一渲染数据就随请求发了出去; - 工具按最小权限设计,当前任务用不到的工具不提供,权限在代码中按当前用户校验,而不是依靠模型遵守提示词;
- 删除数据、转账、对外发送信息这类高风险操作需要人工确认;
- 模型的输出同样不可信,显示到页面之前要转义,防止 跨站脚本攻击,用于查询数据库时要使用参数化查询;
- 模型直接生成整条 SQL 时(例如用自然语言查询数据),不能只靠参数化查询,要在数据库一侧限制:用只能 SELECT 少数视图的账号执行,并设置执行超时和返回行数的上限,不要依赖按关键字检查 SQL 或者会话自己就能改回的只读设置(MySQL 8.0 实测,见附录 A33);
- 不要在提示词中放入密钥等敏感信息,提示词可能被诱导泄露。
以客服 Agent 为例,用户身份取自登录会话,不作为工具参数让模型填写,get_order 在代码中检查订单是否属于当前用户,模型即使被诱导传入别人的订单号,也查不到数据。退款工具只生成一条待确认的申请,前端展示金额和订单,用户点击确认后由普通的业务接口执行,模型自己无法完成退款。
每次工具调用都记录审计日志:谁发起的任务、调用了哪些工具、参数和结果是什么、谁确认了操作,出了问题可以追溯。
设计一个客服 Agent
“设计一个智能客服 Agent”这类题要把前面的机制组合成一个能上线的系统,下面仍以前文的电商客服为例,通用的答题顺序见系统设计题的思路。
1. 需求和约束
先和面试官确认范围。假设要做的是电商 App 内的售后客服:
- 功能:查询订单和物流,回答退换货政策和商品问题,办理退款和改地址,处理不了的创建工单或转人工;
- 规模:平时每天 10 万次咨询,大促期间约为平时的 10 倍(假设);
- 体验:用户发出消息后很快能看到进展,不能对着空白等完多轮调用;
- 安全:用户只能查自己的订单,退款金额不能超过实付金额,资金操作必须由用户确认。
按高峰小时占全天流量的 6% 计算,平时高峰约 10 万 × 6% ÷ 3600 ≈ 1.7 次咨询/秒,大促约 17 次/秒。每次咨询按前文的例子调用 3 次模型,假设共约 6 秒,由利特尔法则,大促高峰同时在进行的咨询约 17 × 6 ≈ 100 个。模型服务的并发和 Token 限额要按这个量申请或部署,见大模型服务的高并发。
多轮调用让首个字出现得晚,所以要把“正在查询订单”这类进展推给前端,见推送 Agent 的执行过程。成本也要在设计时估算:按一次 Agent 任务的费用中的例子,每天 10 万次对话约 82~270 元,差别在于前缀缓存是否命中;实际的提示词和工具结果更长,费用会成倍增加。
2. 架构
- 接入层:从登录会话中取出 user_id,工具都用它鉴权,不作为参数让模型填写,见限制模型能造成的后果;
- 意图路由:退款、改地址这类涉及资金、步骤固定的请求交给工作流,咨询类交给 Agent,置信度低时反问澄清,见意图识别与路由;
- 工作流:退款由代码按校验、生成申请、用户确认、执行的顺序处理,模型只负责从对话中提取订单号和原因,见 Agent 与工作流;
- Agent:一个 ReAct 循环,工具有查询订单、查询物流、检索知识库和创建工单,限制最大步数;
- 知识库:退换货政策和商品资料切片后建立索引,用混合检索加重排,见检索增强生成;
- 短期记忆:摘要、最近几轮和任务状态(例如当前在处理的订单)存放在 Redis 中,见短期记忆。
长期记忆在这个场景中要克制。订单、地址、退款进度以业务系统为准,每次用工具实时查询,不写进记忆,否则记忆中的旧地址会和数据库冲突;值得跨会话保存的只有少量偏好,例如“希望下午送货”,写入的方法见长期记忆的写入。
3. 关键取舍
用框架还是自研。Agent 循环本身不到二十行,工作量在业务工具、权限、上下文组装和评测上,这些框架替代不了。Java 项目可以用 Spring AI 统一模型接口和注册工具,并关闭自动执行,由应用控制每一轮:每次发给模型的消息完全可控,便于保持前缀稳定以提高缓存命中率、在执行前校验权限和记录审计日志。退款流程要暂停等待用户确认、中断后能恢复,状态存在数据库中即可;分支和人工节点多起来时,再换 LangGraph 这类状态图框架,见怎样选择。
单 Agent 还是多 Agent。在线客服对延迟和成本敏感,查订单和查物流前后依赖,单个 Agent 加路由就够了,见适用场景。退款单独成流程主要是为了隔离权限:咨询 Agent 没有退款工具,即使被提示词注入也发起不了退款。转人工相当于一次转交(Handoff),把摘要和任务状态一起交给人工客服,用户不必重复描述。
上下文怎么管理。每次调用按规则和工具定义、摘要、最近几轮、任务状态、本轮工具结果的顺序组装:不变的部分放在最前面,提高前缀缓存的命中率;工具结果只保留模型需要的字段,例如物流只返回最新的几条轨迹;历史超出预算时压缩成摘要,见上下文工程。
做成工具还是 Skill。要查询数据或产生副作用的能力做成工具,例如查订单、建工单;只说明怎么处理一类问题的内容,例如价保的计算规则、换货的话术,按意图放进对应的提示词。这类说明多到每次都放进去太占上下文时,再像 Skills 那样平时只放名称和描述,需要时读取正文。订单查询要同时给客服和内部运营的助手使用时,可以做成 MCP 服务端,一次实现、多处接入。
4. 上线
上线前先有评测集:从历史客服会话中挑出几十条真实任务,按意图分层,既有应该退款的,也有超过期限、应该拒绝的;退款这类写操作检查数据库的最终状态,而不是 Agent 回复了什么,查询和问答检查回复是否与工具结果、知识库一致;路由的准确率单独评测,见评测。
权限逐步放开。先只上线查询类,退款类先由 Agent 生成处理建议、人工客服确认后执行;小流量灰度时对比转人工率和满意度,没有变差再扩大范围。
监控与成本:
- 记录每一步的模型调用和工具调用,监控任务完成率、转人工率、平均步数、触发最大步数的次数、TTFT 和每次咨询的 Token,见评测集与线上指标和可观测;
- 查物流这类简单问题交给更小的模型,限制步数和输出长度,保持前缀稳定以提高缓存命中率,见减少 Token 消耗。
降级按层设计:主模型限流或出错时切到备用模型,见限流、降级与熔断;备用模型也不可用时,咨询类退回关键词规则和常见问题的固定答案,再转人工;退款的校验、确认和执行不依赖模型,用户仍可以从订单页直接申请。大促前按估算的并发压测一次,确认限流和降级能按预期触发。
附录:实测
以下实验的环境为 Apple M1 Pro(32 GB)、macOS,PyTorch 与 Hugging Face Transformers,模型从 Hugging Face 下载到本地。
A1 Embedding 相似度与 BM25
bge-small-zh-v1.5 取 [CLS] 位置的向量并归一化,余弦相似度即点积。BM25 的切词为“错误码”、连续的字母数字和其余单字,三篇文档组成语料,k1 = 1.2、b = 0.75,IDF 取 ln(1 + (N - n + 0.5) / (n + 0.5)):
def emb(texts):
x = tok(texts, padding=True, truncation=True, return_tensors="pt")
with torch.no_grad():
v = model(**x).last_hidden_state[:, 0]
return torch.nn.functional.normalize(v, dim=-1)
def terms(s): return re.findall(r"错误码|[A-Za-z0-9]+|[一-鿿]", s)
def bm25(qs, d):
return sum(idf(t) * d.count(t) * (k1 + 1) / (d.count(t) + k1 * (1 - b + b * len(d) / avg)) for t in set(qs) if t in d)
维度 512;年假-休假 0.603,年假-股价 0.194
E1024 查询与 E1024 文档 0.762,与 E1025 文档 0.743
查询切词 ['错误码', 'E1024', '是', '什', '么', '意', '思']
BM25 错误码 E1024:库存不足:1.088
BM25 错误码 E1025:支付超时:0.130
BM25 常见错误码说明:0.140
按词拆开第一篇的得分(文档长度按切出的词数计算,三篇分别为 6、6、5,平均 5.667):
E1024:n = 1,IDF = 0.981,词频部分 = 0.977,贡献 0.958
错误码:n = 3,IDF = 0.134,词频部分 = 0.977,贡献 0.130
词频部分(文档长度等于平均长度):f = 1、2、5、10 时为 1.000、1.375、1.774、1.964
A2 提示词对工具调用的影响
模型用 float16 加载到 MPS,按“订单 1024 到哪了?”运行 Agent 循环,最多 5 步,采样参数 temperature = 0.7、top_p = 0.9,每次用不同的随机种子。最终回答中出现“杭州”(物流工具返回的当前位置)记为成功:
PROMPTS = {"A": "你是电商客服助手,回答要简短。",
"B": "你是电商客服助手。用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间。回答要简短。"}
out = model.generate(**ids, max_new_tokens=160, do_sample=True, temperature=0.7, top_p=0.9)
qwen2.5-7b 提示词 A: 成功 0/10,工具调用次数 [0, 1, 1, 1, 0, 1, 0, 1, 1, 1]
例: False 请提供您的订单号,以便查询。您提供的订单号是1024。
例: False 您的订单已发货,运单号是 SF1234567。我可以帮您查询物流信息。
qwen2.5-7b 提示词 B: 成功 10/10,工具调用次数 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
例: True 您的包裹目前位于杭州转运中心,预计明天送达。
qwen2.5-3b 提示词 B: 成功 0/20,工具调用次数 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
例: False 您的包裹已经发货,运单号是 SF1234567,请稍后查询物流信息。
A3 客服请求的 Token 构成
环境:Python 3.13、Transformers 5.20.2,只加载 Qwen2.5-7B-Instruct 的分词器。请求由系统提示词(7 条规则)、6 个工具、2 条长期记忆、11 轮对话历史(其中 6 次工具调用)、3 段检索资料和当前问题组成,用对话模板渲染后计数。每部分的 Token 数取“加入这部分前后的差值”,模板为每条消息添加的标记计入所在的部分:
n = lambda msgs, tools=None: len(tok(tok.apply_chat_template(msgs, tools=tools, add_generation_prompt=True, tokenize=False)).input_ids)
parts["工具定义(6 个)"] = n([sysm, U("")], TOOLS) - n([sysm, U("")])
parts["对话历史(11 轮)"] = n([sysmm] + HISTORY + [U("")], TOOLS) - n([sysmm, U("")], TOOLS)
系统提示词 205
工具定义(6 个) 701 6 个工具的 JSON 共 618,其余是模板中说明调用格式的文字
长期记忆 75
对话历史(11 轮) 1862 用户消息 219,助手回答 443,工具调用 287,工具结果 913
检索资料(3 段) 150
当前问题 31
模板标记(空消息) 13
合计 3037
每轮的 Token 数:611 140 233 65 296 54 203 133 33 39 55
物流原始结果 342(563 个字符),只保留需要的字段 50
系统提示词 332 字符 205 Token(0.62);检索资料 222 字符 148 Token(0.67)
A4 普通摘要与结构化摘要
把上面 11 轮对话转成文本记录(1654 个 Token),分别用两种提示词让模型压缩。模型用 float16 加载到 MPS,采样参数 temperature = 0.7、top_p = 0.9,每次用不同的随机种子;Qwen2.5-3B 每种提示词 3 次,Qwen2.5-7B 每种 2 次。关键事实共 14 条,用正则判断是否出现在摘要中(例如退款要求出现 59.9,取件要求同时出现 29 日或周三与 14:00);再贪心解码,只根据摘要回答 5 个问题,例如“快递员什么时候上门取件?”“取件时应该怎样联系用户?”:
PROMPTS = {
"普通摘要": "下面是客服与用户的对话记录。请用一段话总结这段对话,供客服在后续对话中参考。\n\n{t}",
"结构化摘要": "……按以下格式输出……\n订单:每个订单的订单号、商品和当前状态\n已办理:……\n承诺:……\n用户要求:……\n待办:……\n"
"订单号、单号、金额、时间、地址、电话照原文抄写,不要改写或省略。\n\n{t}",
}
a = gen(f"以下是之前对话的摘要:\n{s}\n\n只根据摘要回答,摘要中没有的写“不知道”。问题:{q}", 0, 80, sample=False)
qwen2.5-3b 普通摘要: 关键事实保留 [5, 5, 3](共 14 条),平均 4.3;问答正确 [1, 0, 0](共 5 题);摘要长度 [165, 172, 112] Token
丢失次数: {'运单 SF1234567': 3, '优惠券有效期 11-30': 3, '退货单 R2051': 3, '退款 59.9 元': 3, '新地址 江南大道 588 号 3 幢 1201': 3,
'短信联系、不打电话': 3, '手机尾号 8823': 3, '发票待提供税号': 3, '取件 10-29 14:00-16:00': 2, ……}
qwen2.5-3b 结构化摘要: 关键事实保留 [9, 11, 9](共 14 条),平均 9.7;问答正确 [3, 3, 3](共 5 题);摘要长度 [582, 458, 405] Token
丢失次数: {'运单 SF1234567': 3, '手机尾号 8823': 3, '退货单 R2051': 2, '短信联系、不打电话': 2, '发票待提供税号': 1,
'取件 10-29 14:00-16:00': 1, '退款 59.9 元': 1}
qwen2.5-7b 普通摘要: 关键事实保留 [12, 4](共 14 条),平均 8.0;问答正确 [4, 0](共 5 题);摘要长度 [276, 104] Token
qwen2.5-7b 结构化摘要: 关键事实保留 [11, 12](共 14 条),平均 11.5;问答正确 [3, 4](共 5 题);摘要长度 [381, 538] Token
丢失次数: {'手机尾号 8823': 2, '预计 10-27 送达': 1, '短信联系、不打电话': 1, '优惠券有效期 11-30': 1}
3B 普通摘要(第 1 次,节选):……订单ID为1025的65W氮化镓充电器已签收,用户申请退货,客服为其安排了10月29日
14:00-16:00上门取件,并告知了用户取件地址和退款详情;用户还更改了订单ID为1026机械键盘的收货地址……
A5 长期记忆的提取与更新
Qwen2.5-7B 用 float16 加载到 MPS,贪心解码。先用两种提示词从同一段对话中提取,再把“限定”提取出的每条事实与两条已有记忆(m1 常用收货地址:杭州市西湖区文三路 90 号(2026-08-30),m2 喜欢简短的回答(2026-09-12))一起交给模型,选择 ADD、UPDATE、DELETE 或 NONE:
EXTRACT = {
"宽松": "从下面的客服对话中提取关于用户的信息,每条一句话,以 JSON 数组输出。\n\n{t}",
"限定": "从下面的客服对话中,提取以后的会话中仍然有用的用户信息,例如偏好和长期有效的事实。\n"
"只提取用户明确说过的内容;订单状态、物流进度、退货进度这类可以从业务系统查到或者很快会过期的信息不要提取。\n"
"每条一句话,以 JSON 数组输出,没有则输出 []。\n\n{t}",
}
=== 提取(宽松)
[{"用户ID": "未提供"}, {"用户姓名": "未提供"}, {"用户地址": "杭州市西湖区文三路 90 号"},
{"用户新地址": "杭州市滨江区江南大道 588 号 3 幢 1201"}, {"用户联系电话": "1388823"}]
=== 提取(限定)
[{"address": "杭州市滨江区江南大道 588 号 3 幢 1201"}, {"phone": "尾号 8823"}]
=== 更新
- {'address': '杭州市滨江区江南大道 588 号 3 幢 1201'}
-> {"op": "UPDATE", "id": "m1", "text": "常用收货地址:杭州市滨江区江南大道 588 号 3 幢 1201(2026-09-14)"}
- {'phone': '尾号 8823'}
-> {"op": "NONE", "id": null, "text": null}
A6 意图路由的三种做法
评测集是自己编写的 60 条电商客服消息,物流、退款、改地址、商品咨询、其它 5 个类别各 12 条,其中 10 条含有其它类别的关键词。Embedding 相似度用 bge-small-zh-v1.5 的 [CLS] 向量,每类 6 条示例(不在评测集中)取平均作为类中心,按余弦相似度最近的类中心分类,置信度为相似度除以 0.05 后的 softmax。大模型用 float16 加载到 MPS,贪心解码,置信度取第一个 Token 在 5 个类别首 Token 上归一化后的概率(5 个类别名的首 Token 互不相同):
RULES = [("退款", r"退款|退货|退钱|售后|取消订单|退差价|七天无理由"),
("改地址", r"地址|收件人|收货人|电话"),
("物流", r"物流|快递|到哪|发货|配送|签收|运单|送到|驿站|包裹|能到"),
("商品咨询", r"支持|多久|尺码|什么码|材质|优惠|区别|补货|现货|便宜|保修|适合")] # 按顺序匹配,都不命中为“其它”
SYSTEM = """你是电商客服的意图分类器。把用户消息归入以下类别之一,只输出类别名,不要解释。
物流:查询发货、配送进度、包裹位置
退款:退款、退货、取消订单、售后、退差价
改地址:修改收货地址、收件人、电话
商品咨询:购买前对商品功能、规格、价格、库存的询问
其它:以上都不是,包括闲聊、账号问题、投诉、要求转人工"""
g = model.generate(**ids, max_new_tokens=8, do_sample=False, output_scores=True, return_dict_in_generate=True)
p = torch.softmax(torch.log_softmax(g.scores[0][0].float(), -1)[first_tokens], -1)
规则:准确率 44/60 = 73.3%
标注\预测 物流 退款 改地址 商品咨询 其它
物流 10 0 0 0 2
退款 2 7 0 1 2
改地址 2 0 8 0 2
商品咨询 1 0 0 8 3
其它 1 0 0 0 11
Embedding 相似度:准确率 47/60 = 78.3%
Qwen2.5-3B:准确率 54/60 = 90.0%
Qwen2.5-7B:准确率 57/60 = 95.0%
显示已签收但我没收到 物流 -> 退款 0.931
少发了一个配件 退款 -> 商品咨询 0.503
有没有优惠券 商品咨询 -> 其它 0.995
快递员态度太差了,我要投诉 其它 -> 其它 0.999 原始输出:投诉
在 10 条含其它类别关键词的样本上:规则 4 条正确,Embedding 相似度 6 条,3B 8 条,7B 10 条
Embedding 相似度 置信度阈值:
阈值 0.50:自动路由 42/60(70%),其中正确 38,准确率 90.5%
阈值 0.70:自动路由 29/60(48%),其中正确 28,准确率 96.6%
阈值 0.90:自动路由 7/60(12%),其中正确 6,准确率 85.7%
Qwen2.5-7B 置信度阈值:
阈值 0.90:自动路由 59/60(98%),其中正确 57,准确率 96.6%
阈值 0.95:自动路由 57/60(95%),其中正确 56,准确率 98.2%
60 条样本只够说明趋势,阈值应该在单独的验证集上选取。
A7 工具出错时 Agent 的表现
沿用附录 A2 的 Agent 循环和提示词 B,Qwen2.5-7B-Instruct,temperature = 0.7、top_p = 0.9,最多 6 步,每种情况用 10 个随机种子各运行一次。错误原样作为 tool 消息的内容回传给模型。四种情况:
- 偶发超时:get_logistics 第 1 次返回 {"error": "物流接口超时,请稍后重试"},之后正常返回;
- 持续超时:每次都返回同样的错误;
- 参数错误,笼统提示:get_logistics 增加参数 carrier(描述为“承运商代码”),只有取值为 shunfeng 时才返回物流信息,否则返回 {"error": "参数错误"};
- 参数错误,可修正的提示:同上,错误为 {"error": "carrier 取值错误:应为快递公司编码,顺丰为 shunfeng,中通为 zhongtong"}。
if c["name"] == "get_logistics":
n_logi += 1
if MODE.startswith("param2"):
r = LOGISTICS.get(args.get("tracking_no")) if args.get("carrier") == "shunfeng" else HINT[MODE]
else:
r = ERROR if (MODE == "persistent" or n_logi == 1) else LOGISTICS.get(args.get("tracking_no"))
msgs.append({"role": "tool", "name": c["name"], "content": json.dumps(r, ensure_ascii=False)})
偶发超时:get_logistics 调用次数 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],最终回答中出现“杭州” 0/10
目前物流信息暂不可用,请稍后再试或联系快递公司查询。
目前您的包裹正在运输中,物流信息暂时无法获取,请稍后再试或联系物流查询。
持续超时:get_logistics 调用次数 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],最终回答中出现“杭州” 0/10
参数错误,笼统提示:get_logistics 调用次数 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],最终回答中出现“杭州” 0/10
get_order({"order_id": "1024"}) -> {... "tracking_no": "SF1234567"} | get_logistics({"tracking_no": "SF1234567", "carrier": "SF"}) -> {"error": "参数错误"}
seed=1:目前包裹在上海市,预计2天内送达。请注意查收。
seed=2:您好,目前您的包裹正在运输中,运单号为SF1234567,承运商暂时无法提供详细位置信息,请耐心等待。预计送达时间为3-5个工作日。……
seed=6:目前包裹在运输中,位置信息暂时无法获取。预计今天内送达,请耐心等待。……
seed=7:您好,目前您的订单已发货,运单号为SF1234567,暂时没有更新位置信息,请稍后再试或联系快递公司查询。
参数错误,可修正的提示:get_logistics 调用次数 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2],最终回答中出现“杭州” 10/10
get_order(...) | get_logistics({"tracking_no": "SF1234567", "carrier": "SF"}) -> {"error": "carrier 取值错误:应为快递公司编码,顺丰为 shunfeng,中通为 zhongtong"}
| get_logistics({"tracking_no": "SF1234567", "carrier": "shunfeng"}) -> {"location": "杭州转运中心", ...}
seed=0:您的包裹目前位于杭州转运中心,预计明天送达。谢谢!
另外做过一组参数校验更宽松的对照:carrier 取值为 SF 即可,模型 10 次都在第一次调用时主动填写了 carrier = "SF",没有触发错误。
A8 LangGraph 的 checkpoint 与人工确认
LangGraph 1.2.18、langgraph-checkpoint 4.3.1,Python 3.13。代码即正文中的退款流程,confirm 节点的第一行打印一句话,refund 节点用 refund-订单号 作为幂等键写入字典:
def confirm(state):
print(" confirm 节点开始执行") # 恢复时会从节点开头重新执行
ok = interrupt({"action": "refund", "order_id": state["order"]["order_id"], "amount": state["order"]["amount"]})
return {"approved": ok, "trace": [f"confirm -> {ok}"]}
r = app.invoke({"question": "订单 1024 我要退款", "trace": []}, cfg)
print("第一次调用返回:", r["__interrupt__"][0].value)
snap = app.get_state(cfg)
print("暂停时保存的状态:next =", snap.next, "trace =", snap.values["trace"])
print("checkpoint 数量:", len(list(app.get_state_history(cfg))))
r = app.invoke(Command(resume=True), cfg)
confirm 节点开始执行
第一次调用返回: {'action': 'refund', 'order_id': '1024', 'amount': 59.9}
暂停时保存的状态:next = ('confirm',) trace = ['classify -> 退款', 'check_order']
checkpoint 数量: 4
confirm 节点开始执行
恢复后: 已提交退款 59.9 元 ['classify -> 退款', 'check_order', 'confirm -> True', 'refund']
退款记录: {'refund-1024': 59.9}
A9 Spring AI 的工具调用循环
Spring AI 2.0.2(spring-ai-client-chat),JDK 21。用一个桩模型代替真实的模型:第 1 次调用返回调用 getOrder 的请求,第 2 次返回文本回答,并打印每次收到的消息和工具定义。桩模型要通过 getOptions() 返回 ToolCallingChatOptions,ChatClient 才会把工具加进请求:
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder().build();
}
public ChatResponse call(Prompt prompt) {
AssistantMessage reply = calls.incrementAndGet() == 1
? AssistantMessage.builder().content("").toolCalls(List.of(
new AssistantMessage.ToolCall("call_1", "function", "getOrder", "{\"orderId\": \"1024\"}"))).build()
: AssistantMessage.builder().content("订单 1024 已发货,运单号 SF1234567。").build();
return new ChatResponse(List.of(new Generation(reply)));
}
--- 第 1 次调用模型,消息:
USER: 订单 1024 到哪了?
工具定义:getOrder {"$schema" : "https://json-schema.org/draft/2020-12/schema","type" : "object","properties" : {"orderId" : {"type" : "string","description" : "订单号,例如 1024"}},"required" : [ "orderId" ],"additionalProperties" : false}
执行 getOrder(1024)
--- 第 2 次调用模型,消息:
USER: 订单 1024 到哪了?
ASSISTANT:
TOOL: [ToolResponse[id=call_1, name=getOrder, responseData={"orderId":"1024","status":"已发货","trackingNo":"SF1234567"}]]
最终回答:订单 1024 已发货,运单号 SF1234567。
不加 -parameters 编译时,工具定义中的参数名变成 arg0,桩模型按 orderId 传参,方法收到的是 null:
工具定义:getOrder {..."properties" : {"arg0" : {"type" : "string","description" : "订单号,例如 1024"}},"required" : [ "arg0" ]...}
执行 getOrder(null)
A10 模型裁判的位置偏差与长度偏差
Qwen2.5-7B-Instruct 作为裁判,对 12 对客服回答做成对比较:4 对质量相当、4 对信息相同但回答 2 加了客套话、4 对回答 2 更详细但有一处与工具结果不符(地点、到账时间、门牌号、送达日期)。每对按两种顺序各判一次,取下一个 Token 为 A、B 的概率中较大的一个。分别在不给参考和给出工具结果作参考两种提示词下运行:
ctx = f"工具返回的事实:{fact}\n以工具返回的事实为准,与事实不符的回答应判为较差。\n\n" if ref else ""
user = f"{ctx}用户问题:{q}\n\n回答 A:{first}\n\n回答 B:{second}\n\n哪个回答更好?只输出 A 或 B。"
logits = model(**ids).logits[0, -1]
p = torch.softmax(logits[[A, B]], -1)
不给参考:
same: 4 对 × 2 种顺序;交换顺序后结论一致 0/4;选第一个位置 0/8;选回答 2 4/8;两种顺序都选回答 2 0/4
订单 1024 到哪了? 顺序1 B(P(A)=0.001) 顺序2 B(P(A)=0.001)
退款什么时候到账? 顺序1 B(P(A)=0.006) 顺序2 B(P(A)=0.0)
帮我把地址改成上海浦东张江路 顺序1 B(P(A)=0.446) 顺序2 B(P(A)=0.002)
我的快递到哪了,运单 SF1 顺序1 B(P(A)=0.031) 顺序2 B(P(A)=0.0)
verbose: 4 对 × 2 种顺序;交换顺序后结论一致 4/4;选第一个位置 4/8;选回答 2 8/8;两种顺序都选回答 2 4/4
订单 1024 到哪了? 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=0.99)
退款什么时候到账? 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=0.997)
帮我把地址改成上海浦东张江路 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=1.0)
我的快递到哪了,运单 SF1 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=1.0)
wrong: 4 对 × 2 种顺序;交换顺序后结论一致 3/4;选第一个位置 3/8;选回答 2 7/8;两种顺序都选回答 2 3/4
订单 1024 到哪了? 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=1.0)
退款什么时候到账? 顺序1 B(P(A)=0.052) 顺序2 B(P(A)=0.001)
帮我把地址改成上海浦东张江路 顺序1 B(P(A)=0.017) 顺序2 A(P(A)=0.585)
我的快递到哪了,运单 SF1 顺序1 B(P(A)=0.0) 顺序2 A(P(A)=0.89)
给出工具结果作参考:
same: 4 对 × 2 种顺序;交换顺序后结论一致 2/4;选第一个位置 6/8;选回答 2 2/8;两种顺序都选回答 2 0/4
订单 1024 到哪了? 顺序1 A(P(A)=1.0) 顺序2 A(P(A)=0.976)
退款什么时候到账? 顺序1 A(P(A)=0.999) 顺序2 B(P(A)=0.0)
帮我把地址改成上海浦东张江路 顺序1 A(P(A)=1.0) 顺序2 A(P(A)=0.996)
我的快递到哪了,运单 SF1 顺序1 A(P(A)=1.0) 顺序2 B(P(A)=0.469)
verbose: 4 对 × 2 种顺序;交换顺序后结论一致 1/4;选第一个位置 7/8;选回答 2 5/8;两种顺序都选回答 2 1/4
订单 1024 到哪了? 顺序1 A(P(A)=0.994) 顺序2 A(P(A)=0.993)
退款什么时候到账? 顺序1 A(P(A)=0.951) 顺序2 A(P(A)=1.0)
帮我把地址改成上海浦东张江路 顺序1 A(P(A)=0.999) 顺序2 A(P(A)=1.0)
我的快递到哪了,运单 SF1 顺序1 B(P(A)=0.008) 顺序2 A(P(A)=1.0)
wrong: 4 对 × 2 种顺序;交换顺序后结论一致 4/4;选第一个位置 4/8;选回答 2 0/8;两种顺序都选回答 2 0/4
订单 1024 到哪了? 顺序1 A(P(A)=1.0) 顺序2 B(P(A)=0.003)
退款什么时候到账? 顺序1 A(P(A)=1.0) 顺序2 B(P(A)=0.0)
帮我把地址改成上海浦东张江路 顺序1 A(P(A)=1.0) 顺序2 B(P(A)=0.378)
我的快递到哪了,运单 SF1 顺序1 A(P(A)=1.0) 顺序2 B(P(A)=0.0)
(A 表示排在第一个的回答;P(A) 是 A 在 A、B 两个 Token 上归一化后的概率)
A11 PDF 文本层与扫描件 OCR
用 HTML 排出一页 A5 的员工手册(页眉页脚、两级标题、正文和 3 行的表格),用 Microsoft Edge 无头模式打印为 PDF;用 macOS 的 PDFKit 读取文本层;再把这一页渲染为 150 dpi 的灰度图片,旋转 0.6° 模拟扫描,生成只含图片的 PDF,用 Vision 框架识别文字(macOS 15,Swift 5.8):
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
== 带文本层的 PDF,PDFKit 的输出
员工手册(2026 版) 内部资料
请勿外传
第 6 章 出差
6.3 住宿
住宿标准按城市分级,超出标准的部分由员工自行承担。两名同性员工同
行的,
则上合住一间。
原
城市 每晚上限 说明
北京、上海、广州、深圳 600 元 含税
……
人力资源部第 12 页
== 扫描件,PDFKit 的输出
(没有文本层)
== 扫描件,Vision OCR 的输出(按识别顺序,坐标单位为像素)
y= 86 x= 76 conf=0.50 员工手册(2026 版)
y= 161 x= 76 conf=0.30 第6章出差
y= 366 x= 92 conf=0.50 城市
y= 410 x= 92 conf=1.00 北京、上海、广州、深圳
y= 454 x= 94 conf=1.00 其他省会城市
y= 499 x= 94 conf=1.00 其余城市
y= 362 x= 509 conf=0.50 每晚上限
y= 406 x= 507 conf=0.50 600 元
……
y=1155 x= 735 conf=0.50 第12页
版面还原的规则:y 坐标相差不超过 12 像素的文字块归为一行,一行有多个块的作为表格行;顶部和底部 10% 的区域作为页眉页脚,从中提取页码;匹配“第 N 章”和“N.N ”的行作为二级和三级标题;不以句号结尾的行与下一行合并。输出见正文,提取的元数据为 {"doc": "员工手册(2026 版)", "page": 12}。
A12 切片方式、Top-K 与相似度阈值
环境: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 句原文,召回的切片中包含全部原句才算命中:
hit = [all(any(e in chunks[j] for j in top[i]) for e in ev) for i, (_, ev) in enumerate(Q)]
800 字的纯文本 = 679 个 Token(bge-small-zh 最多 512,超出部分被截断)
方式 切片数 R@1 R@3 R@5 top3字数
固定 100 字,重叠 0 51 0.500 0.725 0.750 296
固定 100 字,重叠 20 63 0.650 0.800 0.875 295
固定 200 字,重叠 0 26 0.550 0.825 0.825 590
固定 200 字,重叠 40 32 0.700 0.925 0.950 575
固定 400 字,重叠 0 13 0.550 0.825 0.950 1163
固定 400 字,重叠 80 17 0.675 0.875 0.900 1128
固定 800 字,重叠 0 7 0.625 0.875 0.950 2281
固定 800 字,重叠 160 9 0.750 0.975 1.000 2065
按小节切 52 0.850 0.975 0.975 278
按小节切,加标题路径 52 0.850 0.975 1.000 355
父子切片(子 = 单句,返回小节) 133 0.825 0.950 0.975 361
按小节切,加标题路径,过滤 2023 版 27 0.925 1.000 1.000 351
Top-K 曲线(按小节切,加标题路径): K=1:0.850 K=2:0.925 K=3:0.975 K=4:1.000 K=5:1.000 ……
有答案的问题 top1 相似度: min 0.537 median 0.655 max 0.774
无答案的问题 top1 相似度: min 0.404 median 0.481 max 0.688
阈值 0.5: 有答案的问题被拒 0.000,无答案的问题被放行 0.400
阈值 0.55: 有答案的问题被拒 0.025,无答案的问题被放行 0.200
阈值 0.6: 有答案的问题被拒 0.100,无答案的问题被放行 0.100
top3 同时含 2023 版和 2026 版同一小节的问题数: 38
问题加“为这个句子生成表示以用于检索相关文章:”前缀(按小节切,加标题路径):
不加前缀: Recall@1 0.850 Recall@3 0.975
加前缀: 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 方式相同,不合并前后句):
v = emb(sents); dist = 1 - (v[:-1] * v[1:]).sum(1); th = np.percentile(dist, p)
默认分隔符,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 字,两种分隔符切出的结果相同。
sentence-transformers 6.2.0 加载同一个 bge-small-zh-v1.5,比较 encode()、encode_query() 和手动拼接前缀的结果:
a, b = m.encode(q), m.encode_query(q)
c = m.encode('为这个句子生成表示以用于检索相关文章:' + q)
prompts {'query': '', 'document': ''}
encode() 与 encode_query() 相同: True
encode_query() 与手动加前缀相同: False
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 等库的速度,只比较扫描比例:
probe = np.argsort(-(C @ q))[:nprobe]; cand = np.concatenate([lists[j] for j in probe])
found = cand[np.argsort(-(B[cand] @ q))[:10]]
table = np.stack([books[s] @ q[s * 8:(s + 1) * 8] for s in range(64)]) # 64 × 256
approx = table[np.arange(64), codes[ids]].sum(1)
库 82514 条,512 维,查询 1000 条,Recall@10 以暴力检索的 Top-10 为准
暴力检索: 每次查询 11.19 ms,扫描 82514 条,内存 161 MiB
IVF 训练与分桶 2.9 s,nlist=256,桶大小 中位数 289 最大 965
IVF nprobe= 1: Recall@10 0.508,扫描 0.5%
IVF nprobe= 4: Recall@10 0.728,扫描 1.9%
IVF nprobe= 16: Recall@10 0.859,扫描 7.1%
IVF nprobe= 64: Recall@10 0.949,扫描 27.3%
PQ 训练与编码 36.2 s,m=64,每条 64 字节(原始 2048 字节),压缩 32 倍,库 5.0 MiB
PQ 全库扫描: Recall@10 0.677
PQ 取前 100 再用原始向量重算: Recall@10 0.973
IVF-PQ nprobe=16 + 重算前 100: Recall@10 0.847
“工作两年能休几天年假?”的第 1 段 [-0.017, -0.040, 0.056, -0.264, 0.036, -0.024, 0.022, -0.004]
-> 中心 129 [-0.029, 0.009, 0.045, -0.282, 0.023, -0.058, 0.012, 0.014]
A14 忠实度与答案相关性
按 RAGAS 论文的方法实现:Qwen2.5-7B-Instruct(float16,MPS,贪心解码)先把回答拆成陈述,再逐条判断能否从资料中推出;然后根据回答反推 3 个问题,用 bge-small-zh-v1.5 计算它们与原问题的余弦相似度:
stmts = as_json(chat(f"把下面的回答拆成若干条独立的陈述,每条只包含一个事实。只输出 JSON 字符串数组。\n问题:{Q}\n回答:{a}"))
ok = [chat(f"资料:\n{CTX}\n\n陈述:{st}\n\n这条陈述能否从资料中直接推出?只回答“是”或“否”。", 5).startswith("是") for st in stmts]
gq = as_json(chat(f"根据下面的回答,写出 3 个这个回答可能在回答的问题。只输出 JSON 字符串数组。\n回答:{a}", 200))
== A 正确:工作两年每年有 5 天年假。当年没休完的可以延到次年 3 月底前使用,逾期作废。
陈述 ✗ 工作两年每年有5天年假。
陈述 ✓ 当年没休完的年假可以延到次年3月底前使用。
陈述 ✓ 逾期未使用的年假作废。
忠实度 2/3 = 0.67
答案相关性(平均余弦相似度)0.711
== B 不忠实:工作两年每年有 5 天年假。当年没休完的可以延到次年 6 月底前使用。
陈述 ✗ 工作两年每年有5天年假。
陈述 ✗ 当年没休完的年假可以延到次年6月底前使用。
忠实度 0/2 = 0.00
答案相关性(平均余弦相似度)0.713
== C 答非所问:年假以半天为最小单位,连续休假 3 天以内的要提前 3 个工作日在 OA 系统提交申请。
陈述 ✗ 年假以半天为最小单位
陈述 ✗ 连续休假3天以内需要提前3个工作日在OA系统提交申请
忠实度 0/2 = 0.00
生成的问题 0.620 公司的年假政策是如何规定的?
生成的问题 0.761 员工如何申请连续休假3天以内的年假?
生成的问题 0.615 关于年假的申请流程是怎样的?
答案相关性(平均余弦相似度)0.665
A15 向量索引的内存估算与 NDCG 核对
每个向量的字节数按 FAISS wiki:HNSW,Flat 为 4d + M × 2 × 4,IVF,Flat 为 4d + 8,IVF,PQ 为 ⌈M × nbits / 8⌉ + 8。NDCG 按 Σ rel / log₂(i + 1) 计算,并用 scikit-learn 1.7.2 的 ndcg_score 核对:
== 1e+08 条 512 维
Flat float32 2048 字节/条 204.8 GB 1000 亿条 204.8 TB
HNSW,Flat M=32 2304 字节/条 230.4 GB 1000 亿条 230.4 TB
IVF,Flat 2056 字节/条 205.6 GB 1000 亿条 205.6 TB
IVF,PQ64(m=64, nbits=8) 72 字节/条 7.2 GB 1000 亿条 7.2 TB
IVF 中心 nlist=65536:134 MB;PQ 码本 64×256×8 维:0.52 MB
== 评测指标示例
DCG 各项 [1.0, 1.2619, 0.0, 0.8614, 0.0] DCG=3.1232
IDCG 各项 [2.0, 1.2619, 0.5, 0.0, 0.0] IDCG=3.7619 NDCG@5=0.8302
sklearn ndcg_score: 0.8302
A16 Qwen2.5-7B 与 Qwen3-30B-A3B 的参数量
按 Hugging Face 上的 config.json 逐个矩阵计算。Qwen2.5-7B 的 q、k、v 带偏置,输入嵌入和输出层不共享参数;LoRA 在每个 din × dout 的矩阵上增加 r × (din + dout) 个参数:
mats = {"q_proj": (H, H), "k_proj": (H, kv), "v_proj": (H, kv), "o_proj": (H, H),
"gate_proj": (H, I), "up_proj": (H, I), "down_proj": (I, H)}
layer = sum(a * b for a, b in mats.values()) + (H + 2 * kv) + 2 * H # 矩阵 + 偏置 + 两个 RMSNorm
total = L * layer + 2 * V * H + H
lora = lambda r, names: L * sum(r * sum(mats[n]) for n in names)
# Qwen3-30B-A3B:每层 E 个专家,每个专家 3 个 H × moe_inter 的矩阵,每个 Token 只经过 k 个
moe_total = L3 * (attn3 + E * 3 * H3 * mi + H3 * E + 2 * H3) + 2 * V3 * H3 + H3
moe_active = L3 * (attn3 + k * 3 * H3 * mi + H3 * E + 2 * H3) + 2 * V3 * H3 + H3
Qwen2.5-7B: hidden=3584 inter=18944 layers=28 vocab=152064 heads=28 kv_heads=4 head_dim=128 kv_dim=512
每层 注意力 29,364,736 FFN 203,685,888 归一化 7,168 合计 233,057,792
28 层 6,525,618,176(注意力 0.82B,FFN 5.70B);嵌入 544,997,376 ×2;总计 7,615,616,512 = 7.616B
safetensors total_size 15,231,233,024 字节 / 2 = 7,615,616,512 个 BF16 参数
LoRA 可训练参数
r=8 q+v 2,523,136 占 0.033%
r=16 q+k+v+o 10,092,544 占 0.133%
r=16 q+k+v+o+gate+up+down 40,370,176 占 0.530%
r=64 q+k+v+o+gate+up+down 161,480,704 占 2.120%
全参数微调(16 字节/参数,不含激活):122 GB
权重 FP16/BF16 15.23 GB INT8 7.62 GB INT4 3.81 GB;INT4 每 128 个一组的 FP16 缩放系数另需 0.12 GB
Qwen3-30B-A3B: hidden=2048 layers=48 experts=128 top-k=8 expert_inter=768
总参数 30.53B,每个 Token 激活 3.35B(含输入、输出嵌入);模型卡为 30.5B、3.3B;safetensors total_size 61,064,245,248 字节
A17 SFT 样本的损失掩码
只加载 Qwen2.5-7B-Instruct 的分词器,提示部分用 add_generation_prompt=True 生成,长度之外的部分作为标签:
full = tok.apply_chat_template(msgs, tokenize=False)
prompt = tok.apply_chat_template(msgs[:-1], tokenize=False, add_generation_prompt=True)
ids = tok(full).input_ids
n_prompt = len(tok(prompt).input_ids)
labels = [-100] * n_prompt + ids[n_prompt:]
'<|im_start|>system\n你是电商客服助手,回答要简短。<|im_end|>\n<|im_start|>user\n拆封的耳机能退吗?<|im_end|>\n<|im_start|>assistant\n不影响二次销售的话,签收 7 天内可以退。<|im_end|>\n'
总 Token 48,提示部分 31,计入损失 17
计入损失的 Token: ['不影响', '二次', '销售', '的话', ',', '签', '收', ' ', '7', ' �', '�', '内', '可以', '退', '。', '<|im_end|>', '\n']
A18 权重量化的误差与困惑度
Qwen2.5-7B-Instruct 用 float16 加载到 MPS(PyTorch 2.8.0、Transformers 5.20.2)。量化采用伪量化:权重量化后立即乘以缩放系数还原为 FP16,只模拟精度损失,不测速度。
单层误差:记录第 14 层 q_proj、gate_proj、down_proj 的输入激活,校准文本 5 段(212 个 Token),评测文本另取 3 段(100 个 Token);输出相对误差为 ‖XWqT - XWT‖ / ‖XWT‖。AWQ 式缩放按论文的 s = sXα,α 在校准集上搜索,误差在评测文本上计算:
def qsym(w, bits, group=None, per_channel=True):
qmax = 2 ** (bits - 1) - 1
if group:
x = w.reshape(w.shape[0], -1, group)
s = x.abs().amax(-1, keepdim=True) / qmax
return (torch.clamp(torch.round(x / s), -qmax, qmax) * s).reshape(w.shape)
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()
wq = qasym(w * s, 4, 128) / s # 推理时输入除以 s,等价于把 s 还原到权重上
== q_proj (3584, 3584) |w| 最大 0.332,平均 0.0124
INT8 整个矩阵一个系数 权重相对误差 4.52% 输出相对误差 3.72%
INT8 每个输出通道一个系数 权重相对误差 1.18% 输出相对误差 0.97%
INT4 整个矩阵一个系数 权重相对误差 71.23% 输出相对误差 58.39%
INT4 每个输出通道一个系数 权重相对误差 21.34% 输出相对误差 17.40%
INT4 每 128 个一组 权重相对误差 13.39% 输出相对误差 10.89%
激活的离群通道(各层 q_proj 的输入)
第 14 层:中位数 1.36,最大 3 个 [608, 2718, 1288] = [25.5, 18.3, 17.5]
第 27 层:中位数 1.22,最大 3 个 [2718, 458, 392] = [115.8, 93.3, 31.7]
INT4 每 128 个一组,输出相对误差
q_proj 对称 10.89% 非对称 8.98% 非对称 + 缩放(α=0.3) 7.86%
gate_proj 对称 8.25% 非对称 6.93% 非对称 + 缩放(α=0.2) 6.78%
down_proj 对称 10.99% 非对称 9.16% 非对称 + 缩放(α=0.3) 9.10%
困惑度:28 层中的全部线性层(q、k、v、o、gate、up、down)分别伪量化,嵌入层和输出层保持 FP16;文本为本仓库 MySQL.md 正文的前 12000 个字符(去掉图片和代码块标记,6653 个 Token),每 1024 个 Token 一段计算交叉熵;再用贪心解码回答“用一句话解释 MySQL 的回表是什么。”:
fp16 困惑度 6.231 回答:MySQL的回表是指在执行查询时,已经通过索引找到部分条件匹配的记录,但还需要再次访问数据表(非索引页)以获取剩余列的数据。
int8-channel 困惑度 6.235 回答:(与 fp16 完全相同)
int4-channel 困惑度 11.972 回答:MySQL 中的回表是指在执行复杂查询时,为了访问到关联列的数据需要再次扫描基表的过程。
int4-group128 困惑度 6.503 回答:MySQL 的回表是指在使用索引扫描后,根据索引查询到的记录ID再去表中找到对应的完整记录数据的过程。
int4-tensor 困惑度 82876.996 回答::<?_stylesheetInMillis strugg strugg strugg ……
A19 Agent 中的工具幻觉
沿用附录 A2 的工具和 Agent 循环(Qwen2.5-7B-Instruct,float16,MPS),temperature = 0.7、top_p = 0.9,每种配置 10 个随机种子,最多 4 步。get_order 正常返回运单号,get_logistics 按场景替换:
FLOW = "你是电商客服助手。用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间。回答要简短。"
HONEST = FLOW + "只根据工具返回的内容回答;工具返回错误或没有某项信息时,如实告诉用户暂时查不到,不要猜测。"
timeout = lambda tracking_no: {"error": "物流接口超时,请稍后重试"}
no_eta = lambda tracking_no: {"location": "杭州转运中心", "updated_at": "2026-10-26 09:12"}
explicit = lambda tracking_no: {"location": "杭州转运中心", "location_updated_at": "2026-10-26 09:12",
"eta": None, "eta_note": "承运商暂未提供预计送达时间"}
场景一 用户问“我的包裹到哪了?”(没有订单号)
只写“你是电商客服助手,回答要简短。”:10 次都没有调用工具,回答“请提供您的运单号,我帮您查询。”
加上“工具参数只能来自用户消息或工具结果,缺少订单号时先询问用户”:10 次都回答“请提供您的订单号”
(只写身份时,订单 1024 的问题中模型查到运单号就直接回答,没有调用物流工具,与附录 A2 一致,所以场景二、三改用写明流程的 FLOW)
场景二 物流接口超时,问“订单 1024 到哪了?”
FLOW:10 次都调用了两个工具;3 次回答“目前您的包裹正在运输中,物流信息暂时无法查询到……”,7 次只说暂时查不到
HONEST:10 次都回答“暂时查不到包裹的位置,请稍后再试。”
场景三 物流结果没有预计送达时间,问“订单 1024 什么时候能到?”
FLOW:10 次都给出了送达时间
当前您的包裹位于杭州转运中心,预计今天送达。……
您的包裹当前位于杭州转运中心,预计将在24小时内送达。谢谢!
当前包裹位于杭州转运中心,预计送达时间为2026年10月26日09点12分左右,请耐心等待。
HONEST:9 次给出了送达时间,其中 8 次为“预计送达时间为2026年10月26日09:12”;1 次回答“预计送达时间暂时无法确定”
场景三' 物流结果改为 {"location": "杭州转运中心", "location_updated_at": "2026-10-26 09:12", "eta": null, "eta_note": "承运商暂未提供预计送达时间"}
FLOW:10 次都没有给出送达时间,例如“当前您的包裹位于杭州转运中心,承运商暂未提供预计送达时间。”
HONEST:10 次都没有给出送达时间
A20 Agent 任务的 Token 数与前缀缓存
用 Qwen2.5-7B-Instruct 的分词器和对话模板(带工具定义)渲染“订单 1024 到哪了”的 3 次调用。模型的输出按附录 A2 中 7B 模型在提示词 B 下的实际格式构造,输出数加上结束标记 <|im_end|>。“相同的前缀”指本次输入与上一次的输入加输出逐个 Token 比较得到的公共前缀;第 1 次与只含系统提示词和工具定义的部分比较。价格为 DeepSeek deepseek-flash 高峰时段价格(2026-10-27 访问)。
def common(a, b): # 两段文本分词后的公共前缀长度
a, b = tok(a).input_ids, tok(b).input_ids
k = 0
while k < min(len(a), len(b)) and a[k] == b[k]: k += 1
return k
p = tok.apply_chat_template(msgs, tools=TOOLS, add_generation_prompt=True, tokenize=False)
hit = common(prev, p) # prev = 上一次的输入 + 输出 + "<|im_end|>"
系统提示词 + 工具定义: 264 个 Token(其中系统提示词正文 38 个)
第 1 次调用:输入 283,其中与之前请求相同的前缀 264(只有系统提示词和工具定义,可被其它用户的请求复用),新增 19,输出 24
模板重新渲染的调用消息与模型原始输出一致: True
第 2 次调用:输入 355,其中与之前请求相同的前缀 307(上一次的输入 + 输出),新增 48,输出 29
模板重新渲染的调用消息与模型原始输出一致: True
第 3 次调用:输入 446,其中与之前请求相同的前缀 384(上一次的输入 + 输出),新增 62,输出 13
合计:输入 1084,可命中缓存 955(88%),输出 66
一次对话的费用:不考虑缓存 0.270 分,命中缓存后 0.082 分;每天 10 万次对话分别为 270 元、82 元
A21 batch 大小与吞吐、延迟
Qwen2.5-3B-Instruct 以 float16 加载到 MPS。同一个提示词复制 b 份组成一批(避免填充的影响),手动执行预填充和逐步解码,每个请求贪心生成 128 个 Token。TTFT 为预填充并得到第 1 个 Token 的耗时,TPOT 为之后每一步的平均耗时,吞吐量 = b × 128 / 总耗时;每个 batch 大小运行 2 次,取总耗时较短的一次。
x = ids.repeat(b, 1)
out = model(input_ids=x, use_cache=True) # 预填充
nxt = out.logits[:, -1].argmax(-1, keepdim=True)
for _ in range(N - 1): # 解码:每步为 b 个请求各生成 1 个 Token
out = model(input_ids=nxt, past_key_values=out.past_key_values, use_cache=True)
nxt = out.logits[:, -1].argmax(-1, keepdim=True)
输入 27 个 Token,每个请求生成 128 个 Token
batch TTFT(ms) TPOT(ms) 总耗时(s) 吞吐(tok/s)
1 98 44.2 5.72 22.4 与 batch=1 输出一致:True
2 130 81.6 10.50 24.4 与 batch=1 输出一致:True
4 212 153.1 19.65 26.1 与 batch=1 输出一致:True
8 400 295.5 37.93 27.0 与 batch=1 输出一致:True
16 746 102.1 13.72 149.3 与 batch=1 输出一致:True
32 1410 161.0 21.86 187.4 与 batch=1 输出一致:True
64 2773 279.2 38.23 214.3 与 batch=1 输出一致:True
输出前 40 个 Token: '退货运费的承担通常取决于商品销售的具体条款和条件,以及买家和卖家之间的协议。在大多数情况下,退货运费是由买家承担的。这是因为买家在购买商品时,'
A22 模型输出 JSON 的修复、校验与重试
Qwen2.5-3B-Instruct 以 float16 加载到 MPS,系统提示词要求从客服消息中提取 order_id、intent(4 个枚举值之一)、amount 三个字段并以 JSON 格式输出,没有要求“只输出 JSON”。20 条消息各采样 2 次(temperature = 0.7、top_p = 0.9)。修复指去掉代码块标记、截取第一个 { 到最后一个 };Schema 要求三个字段齐全、order_id 为纯数字字符串或 null、amount 为数字或 null、不允许其它字段。校验失败时把原输出和错误信息作为下一轮消息发回,重试 1 次。
def repair(s):
s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s.strip())
i, j = s.find("{"), s.rfind("}")
return s[i:j + 1] if i >= 0 and j > i else s
obj = json.loads(repair(raw))
jsonschema.validate(obj, SCHEMA)
# 失败时:
msgs += [{"role": "assistant", "content": raw},
{"role": "user", "content": f"上面的输出无法解析:{err}。请只输出修正后的 JSON,不要输出其它内容。"}]
共 40 次输出,耗时 76 秒
以 ``` 代码块标记开头:14/40;JSON 前后带有解释文字:0/40
直接 json.loads 成功:26/40
去掉代码块标记、截取 {...} 后解析成功:40/40
通过 JSON Schema 校验:38/40
校验失败的 2 次带着错误重试 1 次后通过:0/2
校验失败的例子:
消息: '订单 5120 和 5121 能一起发吗'
第一次输出: '```json\n{\n "order_id": "5120,5121",\n "intent": "其它",\n "amount": null\n}\n```'
错误: 字段 order_id 不符合要求:'5120,5121' does not match '^[0-9]+$'
重试输出: '```json\n{\n "order_id": "5120,5121",\n "intent": "其它",\n "amount": null\n}\n```' 仍失败
A23 LoRA 的三种初始化
环境:Python 3.13.7,PyTorch 2.8.0(CPU)。冻结一个 64 × 64 的权重 W,用 r = 4 的 LoRA 拟合一个秩为 4 的变化量,Adam、学习率 0.01、300 步,比较三种初始化:B = 0、A 随机(PEFT 的默认方式);A = 0、B 随机;两者都为 0:
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}")
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 等人的论文。
A24 MCP 服务端的最小实现
环境:Python 3.13.7,mcp 2.3.0(pip 安装)。服务端即正文中的代码,ORDERS 中只有订单 1024。客户端以 stdio 方式启动服务端子进程,依次调用 tools/list 和 tools/call:
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 调用的结果):
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,错误的具体内容只出现在服务端的日志中。
参数不符合 Schema 时工具函数不会执行。用同一个客户端传入三种错误的参数(输出截断):
{"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 时两者都为空:
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 调用用正文中的片段并发执行:
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
用 Transformers 按 Qwen2.5-3B-Instruct 的对话模板渲染两个调用和两条结果(tool 消息带 tool_call_id call_1、call_2),渲染结果中没有 id:
<|im_start|>assistant
<tool_call>
{"name": "get_order", "arguments": {"order_id": "1024"}}
</tool_call>
<tool_call>
{"name": "get_order", "arguments": {"order_id": "1025"}}
</tool_call><|im_end|>
<|im_start|>user
<tool_response>
{"status": "已发货"}
</tool_response>
<tool_response>
{"status": "待付款"}
</tool_response><|im_end|>
A26 停止前校验
环境:Qwen2.5-7B-Instruct、Qwen2.5-3B-Instruct,transformers 在 Apple M1 Pro 的 MPS 上以 float16 运行,temperature 0.7、top_p 0.9,每种配置用随机种子 0~9 运行 10 次。工具 get_order 返回运单号 SF1234567,get_logistics 返回位置“杭州转运中心”;回答中出现“杭州”算成功。两种系统提示词:
- A:你是电商客服助手,回答要简短。
- B:你是电商客服助手。用户问包裹到哪了时,先查订单拿到运单号,再查物流,告诉用户当前位置和预计送达时间。回答要简短。
模型准备结束(没有发起工具调用)时执行校验,不通过就把原因作为用户消息追加进去,最多拦截 2 次:
def stop_check(trace, answer):
"""返回 None 表示通过,否则返回要发回给模型的原因。"""
locs = [r.get("location") for name, r in trace if name == "get_logistics" and "location" in r]
if not locs:
return "[校验未通过] 用户问的是包裹位置,但还没有查询物流(get_logistics),不能结束。请先查询物流再回答。"
if locs[-1] not in answer:
return f"[校验未通过] 回答中的位置与物流查询结果不一致,物流结果为:{locs[-1]}。请据此重新回答。"
return None
原始输出(每种配置只保留统计行):
qwen2.5-7b 提示词 A 校验钩子 无: 成功 0/10;工具调用 [0, 1, 1, 1, 0, 1, 0, 1, 1, 1];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 1.7 次,平均输入 Token 484,平均输出 Token 43
qwen2.5-7b 提示词 A 校验钩子 有: 成功 10/10;工具调用 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2];被拦截 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];平均调用模型 4.0 次,平均输入 Token 1457,平均输出 Token 119
qwen2.5-7b 提示词 B 校验钩子 无: 成功 10/10;工具调用 [2, 2, 2, 2, 2, 2, 2, 2, 2, 2];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 3.0 次,平均输入 Token 1084,平均输出 Token 68
qwen2.5-3b 提示词 B 校验钩子 无: 成功 0/10;工具调用 [1, 1, 1, 1, 1, 1, 1, 1, 1, 1];被拦截 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0];平均调用模型 2.0 次,平均输入 Token 638,平均输出 Token 55
qwen2.5-3b 提示词 B 校验钩子 有: 成功 7/10;工具调用 [2, 1, 2, 2, 2, 2, 1, 2, 1, 2];被拦截 [1, 2, 1, 1, 2, 1, 2, 1, 2, 1];平均调用模型 4.1 次,平均输入 Token 1626,平均输出 Token 118
qwen2.5-3b 提示词 A 校验钩子 有: 成功 2/10;工具调用 [1, 1, 1, 1, 2, 1, 1, 1, 1, 2];被拦截 [2, 2, 2, 2, 1, 2, 2, 2, 2, 1];平均调用模型 4.0 次,平均输入 Token 1439,平均输出 Token 105
7B 在提示词 A 下没有校验时的典型回答是“您的订单已发货,运单号是 SF1234567。我可以帮您查询物流信息。”,即只查了订单就结束。
A27 Embedding 的池化与批内负样本
bge-small-zh-v1.5(模型目录中 1_Pooling/config.json 的 pooling_mode_cls_token 为 true),transformers 在 Apple M1 Pro 的 CPU 上运行。分别用 [CLS] 位置的向量和所有 Token 的平均值作为句向量,归一化后计算点积;再编码三对(问题,文档),计算相似度矩阵,以及不同温度下以对角线为正样本的交叉熵:
def enc(texts, pool="cls"):
x = tok(texts, padding=True, return_tensors="pt")
with torch.no_grad():
h = model(**x).last_hidden_state
if pool == "cls":
v = h[:, 0]
else:
mask = x["attention_mask"].unsqueeze(-1).float()
v = (h * mask).sum(1) / mask.sum(1)
return F.normalize(v, dim=-1)
questions = ["怎么申请年假", "错误码 E1024 是什么意思", "出差住宿标准是多少"]
docs = ["年假需提前 3 个工作日在 OA 系统提交申请", "错误码 E1024:库存不足", "北京、上海每晚住宿上限 600 元"]
sim = enc(questions) @ enc(docs).T
loss = F.cross_entropy(sim / tau, torch.arange(3), reduction="none")
tokens ['[CLS]', '怎', '么', '申', '请', '年', '假', '[SEP]']
last_hidden_state (1, 8, 512)
cls 年假-休假 0.603 年假-股价 0.194
mean 年假-休假 0.547 年假-股价 0.189
sim matrix
[[0.693 0.223 0.376]
[0.318 0.762 0.266]
[0.38 0.339 0.627]]
tau 1.0 loss per row [0.856 0.811 0.929] mean 0.865 p_diag [0.425 0.444 0.395]
tau 0.05 loss per row [0.002 0. 0.01 ] mean 0.004 p_diag [0.998 1. 0.99 ]
A28 Spring AI 的对话记忆与工具调用
Spring AI 2.0.2(spring-ai-client-chat),JDK 21。ChatClient 加上 MessageChatMemoryAdvisor,记忆使用默认参数的 MessageWindowChatMemory;桩模型第 1 次调用返回调用 getOrder 的请求,之后返回文本,并打印每次收到的消息。同一个会话先问“订单 1024 到哪了?”,再说“谢谢”:
ChatMemory memory = MessageWindowChatMemory.builder().build();
ChatClient client = ChatClient.builder(new StubModel())
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build();
client.prompt().user("订单 1024 到哪了?").tools(new OrderTools())
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-7")).call().content();
client.prompt().user("谢谢")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-7")).call().content();
memory.get("user-7").forEach(m -> System.out.println(" " + m.getMessageType() + ": " + m.getText()));
--- 第 1 次调用模型,收到 1 条消息:
USER: 订单 1024 到哪了?
--- 第 2 次调用模型,收到 3 条消息:
USER: 订单 1024 到哪了?
ASSISTANT(tool_calls):
TOOL: tool result
--- 第 3 次调用模型,收到 3 条消息:
USER: 订单 1024 到哪了?
ASSISTANT: 订单 1024 已发货,运单号 SF1234567。
USER: 谢谢
=== 记忆中 user-7 的消息:
USER: 订单 1024 到哪了?
ASSISTANT: 订单 1024 已发货,运单号 SF1234567。
USER: 谢谢
ASSISTANT: 好的。
同一轮之内,工具调用和结果在请求中;下一轮对话时,请求中只剩用户消息和最终回答。
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:
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)
暴力检索:每次查询计算 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 的 52 个切片(按小节切,加标题路径)中混入 N 条干扰文本,用暴力检索(不用近似索引),40 个问题的 Recall@K 和前 3 个结果中干扰文本的比例:
不加检索指令前缀
干扰 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 的切片、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:
for r, j in enumerate(V[i]): sc[j] += 1 / (61 + r)
for r, j in enumerate(B[i]): sc[j] += w / (61 + r)
干扰 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 越差,等权融合把向量检索的结果也拉低了;问题中的编号、专有名词较多时结果会不同。
A31 注意力分数的缩放
Python 3.13、numpy 2.3(Apple M1 Pro)。q 和 128 个 k 的各分量独立、服从标准正态分布,d = 128,每种缩放方式重复 2000 次;“分数标准差”是每次 128 个分数的标准差的平均值,“最大权重”是每次 softmax 后最大的权重:
import numpy as np, sys
rng = np.random.default_rng(int(sys.argv[1])) # 随机种子
d, n, T = 128, 128, 2000
def softmax(x): e = np.exp(x - x.max()); return e / e.sum()
for name, scale in [("不缩放", 1), ("除以 √d", np.sqrt(d)), ("除以 d", d)]:
sd, mx = [], []
for _ in range(T):
q = rng.standard_normal(d); K = rng.standard_normal((n, d))
s = K @ q / scale
sd.append(s.std()); mx.append(softmax(s).max())
mx = np.array(mx)
print(f"{name}: 分数标准差 {np.mean(sd):.3f},最大权重均值 {mx.mean():.3f},"
f"最大权重>0.99 的比例 {(mx > 0.99).mean() * 100:.1f}%")
种子为 1 时的输出:
不缩放: 分数标准差 11.231,最大权重均值 0.838,最大权重>0.99 的比例 30.1%
除以 √d: 分数标准差 0.989,最大权重均值 0.068,最大权重>0.99 的比例 0.0%
除以 d: 分数标准差 0.088,最大权重均值 0.010,最大权重>0.99 的比例 0.0%
种子为 2、3 时结果基本相同,不缩放时最大权重超过 0.99 的比例为 30.8%、29.6%。均匀分布时每个权重为 1/128 ≈ 0.008。
A32 语义缓存的相似度
Python 3.13、sentence-transformers 6.2,模型 BAAI/bge-small-zh-v1.5(Apple M1 Pro,CPU)。向量归一化后点积即余弦相似度:
from sentence_transformers import SentenceTransformer
m = SentenceTransformer("BAAI/bge-small-zh-v1.5")
pairs = [("订单 1024 到哪了", "订单 1025 到哪了"), ("会员可以免运费吗", "非会员可以免运费吗"),
("订单 1024 到哪了", "我的快递 1024 现在在哪"), ("退货的运费谁出", "退货要自己付运费吗")]
for a, b in pairs:
e = m.encode([a, b], normalize_embeddings=True)
print(a, b, f"{float(e[0] @ e[1]):.3f}")
订单 1024 到哪了 订单 1025 到哪了 0.822 答案不同
会员可以免运费吗 非会员可以免运费吗 0.948 答案不同
订单 1024 到哪了 我的快递 1024 现在在哪 0.734 意思相同
退货的运费谁出 退货要自己付运费吗 0.783 意思相同
A33 模型生成 SQL 的权限限制
MySQL 8.0.44,Apple M1 Pro。root 建表、建视图并创建账号 bi,bi 只被授予视图 v_orders 的 SELECT 权限,v_orders 不含手机号列:
CREATE DATABASE IF NOT EXISTS shop;
USE shop;
CREATE TABLE orders (id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), phone VARCHAR(20));
INSERT INTO orders VALUES (1, 1001, 59.9, '13800000000'), (2, 1002, 120, '13900000000');
CREATE VIEW v_orders AS SELECT id, user_id, amount FROM orders;
CREATE USER 'bi'@'localhost' IDENTIFIED BY '...';
GRANT SELECT ON shop.v_orders TO 'bi'@'localhost';
bi 连接时同样选择 shop 库,依次用 bi 和 root 执行下面的语句(输出中的 ERR 为捕获的异常):
OK SELECT * FROM v_orders -> ((1, 1001, Decimal('59.90')), (2, 1002, Decimal('120.00')))
ERR SELECT phone FROM orders -> (1142, "SELECT command denied to user 'bi'@'localhost' for table 'orders'")
ERR DELETE FROM v_orders WHERE id = 1 -> (1142, "DELETE command denied to user 'bi'@'localhost' for table 'v_orders'")
OK WITH t AS (SELECT 1 AS id) DELETE FROM orders WHERE id IN (SELECT id FROM t) -> []
OK SELECT COUNT(*) FROM orders -> ((1,),)
OK SET SESSION TRANSACTION READ ONLY -> []
OK SET SESSION TRANSACTION READ WRITE -> []
OK SELECT /*+ MAX_EXECUTION_TIME(100) */ SLEEP(1) FROM v_orders LIMIT 1 -> ((1,),)
前三条和最后三条由 bi 执行:bi 读不到原表的手机号,也不能删除;它可以把自己的会话设为只读,再改回读写。中间两条由 root 执行,说明以 WITH 开头的语句也可以删除数据,只检查语句开头的关键字拦不住。最后一条的 SLEEP 返回 1,表示执行到 100 毫秒时被中断。
A34 Text2SQL 的口径与语义层
Qwen2.5-7B-Instruct,float16 加载到 MPS,贪心解码(Transformers 5.20.2、PyTorch 2.8.0);SQL 在 MySQL 8.0.44 上执行,Apple M1 Pro。表结构和数据:
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT COMMENT '用户 ID',
amount DECIMAL(10,2) COMMENT '订单实付金额',
status VARCHAR(16) COMMENT '订单状态:UNPAID 待支付,PAID 已支付,REFUNDED 已退款,CANCELLED 已取消',
created_at DATETIME COMMENT '下单时间'
);
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT COMMENT '订单 ID',
category VARCHAR(16) COMMENT '商品品类',
price DECIMAL(10,2) COMMENT '单价',
quantity INT COMMENT '数量'
);
-- orders:1001 用户 1 300 元 PAID 10-03、1002 用户 2 120 元 PAID 10-08、1003 用户 1 200 元 PAID 10-12、
-- 1004 用户 3 80 元 UNPAID 10-20、1005 用户 4 500 元 REFUNDED 10-25、1006 用户 2 90 元 CANCELLED 10-28、
-- 1007 用户 5 260 元 PAID 09-30、1008 用户 3 150 元 PAID 11-01
-- order_items:1001 有服装 100 × 1、服装 50 × 2、食品 100 × 1;1002 食品 60 × 2;1003 数码 200 × 1;
-- 1004 食品 40 × 2;1005 数码 500 × 1;1006 服装 90 × 1;1007 服装 130 × 2;1008 食品 150 × 1
三种做法的系统提示词:A 为“根据下面的表结构,把用户的问题写成一条 MySQL 查询,只输出 SQL”加上面的建表语句;B 在 A 之后加上指标口径;C 只列出可用的指标和维度,要求输出 {"metric", "group_by", "month"} 格式的 JSON,由代码按指标的定义生成 SQL,JSON 解析失败时带着错误重试一次。B 的指标口径:
- 销售额:已支付订单(status = 'PAID')的 amount 之和,已退款、已取消、待支付的订单不计入;
- 按品类统计销售额时,用 order_items 的 price × quantity 求和(同样只算已支付订单),不要把 orders.amount 关联到明细后求和;
- 支付用户数:有已支付订单的不同用户数;
- 客单价:销售额 ÷ 支付用户数;
- 退款率:已退款订单数 ÷(已支付订单数 + 已退款订单数);
- 销量:已支付订单中 order_items.quantity 之和。
执行结果与标准答案比较(比率写成百分数也算对),A 的输出(节选):
标准答案:销售额 620,品类销售额 数码 200、服装 200、食品 220,支付用户 2,客单价 310,退款率 0.25,销量 7
[错] 销售额 SELECT SUM(o.amount) FROM orders o WHERE YEAR(o.created_at) = 2026 AND MONTH(o.created_at) = 10 -> 1290
[错] 品类销售额 SELECT o.category, SUM(oi.price * oi.quantity) FROM order_items oi JOIN orders o ... GROUP BY o.category -> ERR 1054
[对] 支付用户数 SELECT COUNT(DISTINCT user_id) FROM orders WHERE status = 'PAID' AND ... -> 2
[错] 客单价 SELECT AVG(amount) FROM orders WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 10 -> 215
[错] 退款率 SELECT COUNT(*) / SUM(CASE WHEN status = 'UNPAID' THEN 1 ELSE 0 END) FROM orders WHERE ... AND status = 'REFUNDED' -> NULL
[错] 销量 SELECT COUNT(*) FROM order_items WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 10 -> ERR 1054
A 只给表结构:1/6
B 表结构加指标口径:6/6
C 的输出(节选):
[对] 销售额 第 1 次输出 {"metric": 销售额, "group_by": [], "month": "2026-10"},解析失败:Expecting value: line 1 column 12 (char 11)
重试后 {"metric": "销售额", "group_by": [], "month": "2026-10"} -> 620
[对] 品类销售额 {"metric": "销售额", "group_by": ["品类"], "month": "2026-10"} -> 数码 200、服装 200、食品 220
[对] 客单价 {"metric": "客单价", "group_by": [], "month": "2026-10"}
SELECT SUM(o.amount) / COUNT(DISTINCT o.user_id) FROM orders o WHERE o.status = 'PAID' AND ... -> 310
C 语义层:6/6
把 orders.amount 关联到明细后求和,10 月已支付订单的结果为 1220,按品类为服装 600、食品 420、数码 200。
参考资料
- Attention Is All You Need
- Xiong R, et al. On Layer Normalization in the Transformer Architecture. arXiv:2002.04745
- Neural Machine Translation of Rare Words with Subword Units
- RoFormer: Enhanced Transformer with Rotary Position Embedding
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints
- Efficient Memory Management for Large Language Model Serving with PagedAttention
- Cai T, et al. Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads. arXiv:2401.10774
- Li Y, et al. EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty. arXiv:2401.15077
- Qwen2.5-7B-Instruct 模型配置
- The Curious Case of Neural Text Degeneration
- Training Language Models to Follow Instructions with Human Feedback
- Direct Preference Optimization: Your Language Model is Secretly a Reward Model
- Azar M G, et al. A General Theoretical Paradigm to Understand Learning from Human Preferences. arXiv:2310.12036
- Xu S, et al. Is DPO Superior to PPO for LLM Alignment? A Comprehensive Study. arXiv:2404.10719
- DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models
- AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration
- GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers
- FP8 Formats for Deep Learning
- Dutta A, et al. Accuracy is Not All You Need. arXiv:2407.09141
- Mekala A, et al. Does quantization affect models' performance on long-context tasks? arXiv:2505.20276
- Hinton G, et al. Distilling the Knowledge in a Neural Network. arXiv:1503.02531
- LoRA: Low-Rank Adaptation of Large Language Models
- QLoRA: Efficient Finetuning of Quantized LLMs
- DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning
- Chain-of-Thought Prompting Elicits Reasoning in Large Language Models
- Zhao T Z, et al. Calibrate Before Use: Improving Few-Shot Performance of Language Models. arXiv:2102.09690
- OpenAI: Reasoning best practices
- Claude Docs: Refusals and fallback
- Efficient Guided Generation for Large Language Models
- Tam Z R, et al. Let Me Speak Freely? A Study on the Impact of Format Restrictions on Performance of Large Language Models. arXiv:2408.02442
- Farquhar S, et al. Detecting hallucinations in large language models using semantic entropy. Nature, 2024
- Lost in the Middle: How Language Models Use Long Contexts
- Hsieh C P, et al. RULER: What's the Real Context Size of Your Long-Context Language Models? arXiv:2404.06654
- Qu R, Tu R, Bao F. Is Semantic Chunking Worth the Computational Cost? arXiv:2410.13070
- Bhat S R, et al. Rethinking Chunk Size For Long-Document Retrieval: A Multi-Dataset Analysis. arXiv:2505.21700
- Effective Context Engineering for AI Agents
- DeepSeek API 文档:Token 用量计算
- DeepSeek API 文档:上下文硬盘缓存
- Anthropic: Prompt caching
- OpenAI: Prompt caching
- OpenAI: Conversation state
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
- Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs
- Elasticsearch: Similarity settings
- Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods
- Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks
- Qu Y, et al. RocketQA: An Optimized Training Approach to Dense Passage Retrieval for Open-Domain Question Answering. arXiv:2010.08191
- Moreira G, et al. NV-Retriever: Improving text embedding models with effective hard-negative mining. arXiv:2407.15831
- Günther M, et al. Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models. arXiv:2409.04701
- Precise Zero-Shot Dense Retrieval without Relevance Labels
- Weller O, et al. When do Generative Query and Document Expansions Fail? arXiv:2309.08541
- RAGAS: Automated Evaluation of Retrieval Augmented Generation
- Alaofi M, et al. LLMs can be Fooled into Labelling a Document as Relevant. arXiv:2501.17969
- Thakur N, et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. arXiv:2104.08663
- Larson S, et al. An Evaluation Dataset for Intent Classification and Out-of-Scope Prediction. arXiv:1909.02027
- ReAct: Synergizing Reasoning and Acting in Language Models
- Xu B, et al. ReWOO: Decoupling Reasoning from Observations for Efficient Augmented Language Models. arXiv:2305.18323
- LLM Powered Autonomous Agents
- Building Effective Agents
- Effective Harnesses for Long-Running Agents
- Harness Engineering for Coding Agent Users
- Böckeler B. Understanding Spec-Driven-Development: Kiro, spec-kit, and Tessl
- The Anatomy of an Agent Harness
- ArchUnit User Guide
- Claude Docs: Tool Search Tool
- Claude Agent SDK: Scale to many tools with tool search
- OpenTelemetry: Semantic conventions for generative AI systems
- How We Built Our Multi-Agent Research System
- DeepSeek API 文档:Tool Calls
- Qwen2.5-7B-Instruct 对话模板
- Spring AI: Tool Calling
- LangGraph Overview
- Model Context Protocol Specification
- MCP: Connect to Local MCP Servers
- Agent Skills
- HTML Standard: Server-sent events
- OpenRouter: API Streaming
- Nginx: proxy_buffering
- Orca: A Distributed Serving System for Transformer-Based Generative Models
- OWASP Top 10 for LLM Applications: Prompt Injection
- τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains
- Miller E. Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations. arXiv:2411.00640
- Zhang H, et al. A Careful Examination of Large Language Model Performance on Grade School Arithmetic. arXiv:2405.00332
- Gema A P, et al. Are We Done with MMLU? arXiv:2406.04127
- How Claude Code works
- How Claude remembers your project
- LLMs Get Lost In Multi-Turn Conversation
- MemGPT: Towards LLMs as Operating Systems
- Wu D, et al. LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory. arXiv:2410.10813
- Mem0: Building Production-Ready AI Agents with Scalable Long-Term Memory
- Generative Agents: Interactive Simulacra of Human Behavior
- Cognitive Architectures for Language Agents
- LangChain Docs: Memory overview
- LangGraph Docs: Persistence
- DeepSeek API 文档:多轮对话
- Tree of Thoughts: Deliberate Problem Solving with Large Language Models
- Reflexion: Language Agents with Verbal Reinforcement Learning
- Large Language Models Cannot Self-Correct Reasoning Yet
- Plan-and-Solve Prompting: Improving Zero-Shot Chain-of-Thought Reasoning by Large Language Models
- Improving Factuality and Reasoning in Language Models through Multiagent Debate
- Why Do Multi-Agent LLM Systems Fail?
- Agent2Agent (A2A) Protocol Specification
- A2A and MCP
- LangGraph: Graph API Overview
- LangGraph: Persistence
- LangGraph: Checkpointers
- LangGraph: Memory overview
- LangGraph: Fault tolerance
- LangGraph: Interrupts
- LangGraph: Subgraphs
- LangGraph4j
- Spring AI: Chat Client API
- Spring AI: Advisors API
- Dify 文档:问题分类器
- Dify 文档:人工介入
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena
- Large Language Models are not Fair Evaluators
- LLM Evaluators Recognize and Favor Their Own Generations
- Demystifying Evals for AI Agents
- Writing Effective Tools for Agents
- Product Quantization for Nearest Neighbor Search
- DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node
- Faiss Wiki: Guidelines to Choose an Index
- Faiss Wiki: Faiss Indexes
- Milvus: Data Processing
- Milvus: Filtered Search
- Milvus: Bitset
- Milvus: DISKANN
- Milvus: Use Partition Key
- pgvector
- Elasticsearch: kNN query
- BAAI/bge-small-zh-v1.5 模型说明
- Cumulated Gain-Based Evaluation of IR Techniques
- Ragas: Faithfulness
- Ragas: Answer Relevancy
- From Local to Global: A Graph RAG Approach to Query-Focused Summarization
- Learning Transferable Visual Models From Natural Language Supervision
- ColPali: Efficient Document Retrieval with Vision Language Models
- MinerU
- Docling
- Qwen2 Technical Report
- Root Mean Square Layer Normalization
- GLU Variants Improve Transformer
- Touvron H, et al. LLaMA: Open and Efficient Foundation Language Models. arXiv:2302.13971
- Mixtral of Experts
- Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity
- DeepSeek-V3 Technical Report
- Qwen3-30B-A3B 模型卡
- Proximal Policy Optimization Algorithms
- Smaug: Fixing Failure Modes of Preference Optimisation with DPO-Positive
- Disentangling Length from Quality in Direct Preference Optimization
- LIMA: Less Is More for Alignment
- Does Fine-Tuning LLMs on New Knowledge Encourage Hallucinations?
- ZeRO: Memory Optimizations Toward Training Trillion Parameter Models
- LoRA Learns Less and Forgets Less
- An Empirical Study of Catastrophic Forgetting in Large Language Models During Continual Fine-tuning
- PEFT: LoRA
- TRL: SFT Trainer
- LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale
- SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models
- vLLM: FP8 W8A8
- vLLM: Quantized KV Cache
- A Survey on Hallucination in Large Language Models: Principles, Taxonomy, Challenges, and Open Questions
- Why Language Models Hallucinate
- SelfCheckGPT: Zero-Resource Black-Box Hallucination Detection for Generative Large Language Models
- DeepSeek API 文档:模型 & 价格
- DeepSeek API 文档:限速与隔离
- DeepSeek API 文档:错误码
- DeepSeek API 文档:JSON Output
- DeepSeek API 文档:对话前缀续写
- vLLM: Automatic Prefix Caching
- vLLM: Optimization and Tuning
- vLLM: Engine Arguments
- vLLM: Structured Outputs
- vLLM: Batch Invariance
- NVIDIA A100 Tensor Core GPU
- openai-python: _base_client.py
- Google SRE Book: Addressing Cascading Failures
- LangGraph: Streaming
- OWASP Top 10 for LLM Applications: Excessive Agency
- Jiang A Q, et al. Mistral 7B. arXiv:2310.06825
- Katharopoulos A, et al. Transformers are RNNs: Fast Autoregressive Transformers with Linear Attention. arXiv:2006.16236
- DeepSeek-AI. DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model. arXiv:2405.04434
- DeepSeek-V3 config.json
- Dai D, et al. DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models. arXiv:2401.06066
- Qwen Team. Qwen3 Technical Report. arXiv:2505.09388
- Qiu Z, et al. Demons in the Detail: On Implementing Load Balancing Loss for Training Specialized Mixture-of-Expert Models. arXiv:2501.11873
- Schulman J, et al. High-Dimensional Continuous Control Using Generalized Advantage Estimation. arXiv:1506.02438
- Yu Q, et al. DAPO: An Open-Source LLM Reinforcement Learning System at Scale. arXiv:2503.14476
- Zheng C, et al. Group Sequence Policy Optimization. arXiv:2507.18071
- Meng Y, et al. SimPO: Simple Preference Optimization with a Reference-Free Reward. arXiv:2405.14734
- Hayou S, et al. The Impact of Initialization on LoRA Finetuning Dynamics. arXiv:2406.08447
- PyTorch FullyShardedDataParallel
- Zeng A, et al. AgentTuning: Enabling Generalized Agent Abilities for LLMs. arXiv:2310.12823
- Rackauckas Z. RAG-Fusion: a New Take on Retrieval-Augmented Generation. arXiv:2402.03367
- BAAI/bge-reranker-v2-m3
- Langfuse: Prompt Version Control
- MCP Python SDK
- MCP Specification: Authorization
- Anthropic: Code execution with MCP
- Anthropic: Introducing advanced tool use for Claude
- Agent Skills: Optimizing skill descriptions
- Agent Skills: Evaluating skill output quality
- Agent Skills: How to add skills support to your agent
- Simon Willison: Not all AI-assisted programming is vibe coding
- Best practices for Claude Code
- Claude Code: Explore the context window
- Claude Code: Model configuration
- Anthropic: Compaction
- LangChain: Built-in middleware
- Claude Code: Choose a permission mode
- Claude Code: Checkpointing
- Claude Code: Configure the sandboxed Bash tool
- AGENTS.md
- GitHub Spec Kit: Specification-Driven Development
- Konstantinou M, et al. Can We Find Bugs Using LLM-Generated Oracles? arXiv:2410.21136
- LangChain4j: Introduction
- LangChain4j: AI Services
- Agent Frameworks, Runtimes, and Harnesses - oh my!
- Harness design for long-running application development
- DeepSeek Harness
- Pi
- C-Pack: Packed Resources For General Chinese Embeddings
- Kusupati A, et al. Matryoshka Representation Learning. arXiv:2205.13147
- Chen J, et al. M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation. arXiv:2402.03216
- Qwen3-Embedding-0.6B 模型说明
- Parameter-Efficient Transfer Learning for NLP
- Prefix-Tuning: Optimizing Continuous Prompts for Generation
- The Power of Scale for Parameter-Efficient Prompt Tuning
- GPT Understands, Too
- DoRA: Weight-Decomposed Low-Rank Adaptation
- PEFT: Soft prompts
- LoRA Without Regret
- He H, Thinking Machines Lab. Defeating Nondeterminism in LLM Inference
- Agent Skills: Best practices
- LangChain v1 Release Notes
- LangChain Overview
- Spring AI: Chat Memory
- Fast Inference from Transformers via Speculative Decoding
- Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism
- SGLang: Efficient Execution of Structured Language Model Programs
- Debate or Vote: Which Yields Better Decisions in Multi-Agent Large Language Models?
- SGLang: Server Arguments
- vLLM: Parallelism and Scaling
- vLLM: Metrics
- vLLM: Speculative Decoding
- vLLM 源码:v1/core/sched/scheduler.py
- Ollama FAQ
- Li J, et al. Can LLM Already Serve as A Database Interface? A BIg Bench for Large-Scale Database Grounded Text-to-SQLs. arXiv:2305.03111
- dbt: Joins(MetricFlow)