Add 大模型应用.md
新增大模型应用笔记,包括大模型基础、提示词与上下文、检索增强生成、Agent 和工程实践,并配示意图。
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1,859 @@
|
||||
# 大模型应用
|
||||
<!-- GFM-TOC -->
|
||||
* [大模型应用](#大模型应用)
|
||||
* [一、大模型基础](#一大模型基础)
|
||||
* [Token 与分词](#token-与分词)
|
||||
* [Transformer 结构](#transformer-结构)
|
||||
* [注意力机制](#注意力机制)
|
||||
* [自回归生成与 KV Cache](#自回归生成与-kv-cache)
|
||||
* [采样参数](#采样参数)
|
||||
* [训练过程](#训练过程)
|
||||
* [微调](#微调)
|
||||
* [幻觉](#幻觉)
|
||||
* [推理模型](#推理模型)
|
||||
* [二、提示词与上下文](#二提示词与上下文)
|
||||
* [提示词](#提示词)
|
||||
* [结构化输出](#结构化输出)
|
||||
* [上下文工程](#上下文工程)
|
||||
* [三、检索增强生成](#三检索增强生成)
|
||||
* [流程](#流程)
|
||||
* [切片](#切片)
|
||||
* [Embedding 与相似度](#embedding-与相似度)
|
||||
* [向量索引](#向量索引)
|
||||
* [混合检索](#混合检索)
|
||||
* [重排](#重排)
|
||||
* [查询改写](#查询改写)
|
||||
* [评测与优化](#评测与优化)
|
||||
* [长上下文与 Agentic RAG](#长上下文与-agentic-rag)
|
||||
* [四、Agent](#四agent)
|
||||
* [Agent 是什么](#agent-是什么)
|
||||
* [工具调用](#工具调用)
|
||||
* [Agent 循环](#agent-循环)
|
||||
* [Agent 与工作流](#agent-与工作流)
|
||||
* [规划](#规划)
|
||||
* [记忆](#记忆)
|
||||
* [MCP](#mcp)
|
||||
* [Skills](#skills)
|
||||
* [多 Agent](#多-agent)
|
||||
* [开发框架](#开发框架)
|
||||
* [可靠性与评测](#可靠性与评测)
|
||||
* [五、工程实践](#五工程实践)
|
||||
* [流式输出](#流式输出)
|
||||
* [延迟与成本](#延迟与成本)
|
||||
* [稳定性](#稳定性)
|
||||
* [安全](#安全)
|
||||
* [参考资料](#参考资料)
|
||||
<!-- GFM-TOC -->
|
||||
|
||||
|
||||
## 一、大模型基础
|
||||
|
||||
### Token 与分词
|
||||
|
||||
大语言模型(Large Language Model, LLM)的输入和输出都以 Token 为单位。Token 是分词器切出来的文本片段,可能是一个单词、单词的一部分、一个汉字或者几个汉字。模型内部只处理 Token 在词表中的编号。
|
||||
|
||||
常用的分词方法是字节对编码(Byte Pair Encoding, BPE):从单个字符开始,反复把语料中出现次数最多的相邻片段合并成一个新 Token,直到词表达到预定的大小。例如语料中 cat、cats、car、card 分别出现 5、3、4、2 次:
|
||||
|
||||
```text
|
||||
初始 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 结构
|
||||
|
||||
最初的 Transformer 用于机器翻译,由编码器和解码器组成:编码器双向地读取原文,解码器逐个生成译文。BERT 只保留了编码器,常用于文本分类和 Embedding;GPT 系列只保留了解码器,目前的大模型基本都采用这种结构(Decoder-only)。
|
||||
|
||||
<div align="center"> <img src="pics/transformer-block.png" width="640px"/> </div><br>
|
||||
|
||||
1. 词嵌入(Embedding)把每个 Token 编号转换为一个向量,Qwen2.5-7B 中是 3584 维;
|
||||
2. 向量依次经过几十个结构相同的层,Qwen2.5-7B 有 28 层。每层先用注意力从前文收集信息,再用前馈网络(Feed-Forward Network, FFN)对每个位置单独加工;
|
||||
3. 最后一个位置的向量经过线性层,得到词表中每个 Token 的分数(logits),再经过 softmax 转为概率。
|
||||
|
||||
每个子层都带有残差连接和归一化。残差连接把子层的输入直接加到输出上,使几十层的网络也能稳定训练。
|
||||
|
||||
### 注意力机制
|
||||
|
||||
注意力(Attention)用于让每个 Token 从其它 Token 中收集信息。例如在“小猫很饿”中,处理“饿”时需要知道饿的是谁,就要把注意力集中到“小猫”上。
|
||||
|
||||
每个 Token 的向量分别乘以三个参数矩阵,得到三个向量:
|
||||
|
||||
- 查询(Query, Q):当前 Token 要找什么样的信息;
|
||||
- 键(Key, K):自己能被什么样的查询找到;
|
||||
- 值(Value, V):被找到之后提供的信息。
|
||||
|
||||
计算公式为 softmax(QK<sup>T</sup> / √d) V。以“饿”为例:
|
||||
|
||||
<div align="center"> <img src="pics/attention-example.png" width="680px"/> </div><br>
|
||||
|
||||
1. 用“饿”的 Q 与每个 Token 的 K 做点积得到相关性分数,再除以 √d,d 是向量的维度;
|
||||
2. 对分数做 softmax 得到权重,权重之和为 1,其中“小猫”的权重最大,为 0.70;
|
||||
3. 按权重对所有 Token 的 V 求和,得到“饿”这个位置的输出,它的大部分信息来自“小猫”。
|
||||
|
||||
除以 √d 是因为:当向量的各个分量相互独立、均值为 0、方差为 1 时,点积的方差等于 d。维度越大分数之间的差距越大,softmax 的结果几乎全部集中到一个 Token 上,梯度接近 0,训练变得困难。除以 √d 之后,点积的方差恢复为 1。
|
||||
|
||||
生成文本时,一个 Token 只能看到它自己和前面的 Token,这称为因果掩码(Causal Mask)。实现方法是把“后面的 Token”对应的分数设为负无穷,softmax 之后权重为 0,即图中分数矩阵的右上角。
|
||||
|
||||
其它要点:
|
||||
|
||||
- 多头注意力(Multi-Head Attention, MHA):把向量分成多份,每份独立计算注意力再拼接起来,不同的头可以关注不同的关系,例如指代关系、相邻的词;
|
||||
- 位置编码:注意力本身不区分 Token 的先后顺序,需要额外加入位置信息。目前常用旋转位置编码(Rotary Position Embedding, RoPE),它按位置把 Q 和 K 旋转不同的角度,使注意力分数只取决于两个 Token 的内容和相对距离;
|
||||
- 计算量:每个 Token 都要与前面所有 Token 计算分数,序列长度为 n 时,分数矩阵的大小与 n<sup>2</sup> 成正比,这是长上下文成本高的根本原因。FlashAttention 把计算分块放在 GPU 的片上缓存中完成,避免把 n × n 的矩阵写入显存,结果与标准注意力完全相同,但速度更快、占用的显存更少。
|
||||
|
||||
### 自回归生成与 KV Cache
|
||||
|
||||
模型每次只预测下一个 Token:把已有的 Token 序列输入模型,得到下一个 Token 的概率分布,按采样参数选出一个追加到序列末尾,再继续预测,直到生成结束标记。这种生成方式称为自回归(Autoregressive)。
|
||||
|
||||
<div align="center"> <img src="pics/llm-autoregressive.png" width="640px"/> </div><br>
|
||||
|
||||
由于因果掩码,前文 Token 的 K 和 V 在后续步骤中不会再变化,可以缓存起来重复使用,这就是 KV Cache。因此一次请求分为两个阶段:
|
||||
|
||||
- 预填充(Prefill):一次性并行处理整个输入,计算并缓存所有输入 Token 的 K 和 V,决定了首个 Token 的延迟;
|
||||
- 解码(Decode):每一步只为新 Token 计算 Q、K、V,再与缓存中的 K、V 计算注意力,逐个生成,决定了后续的输出速度。
|
||||
|
||||
<div align="center"> <img src="pics/kv-cache.png" width="656px"/> </div><br>
|
||||
|
||||
KV Cache 是用显存换计算。每个 Token 要在每一层缓存 K 和 V 两个向量,占用的显存为:
|
||||
|
||||
```text
|
||||
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),缓存最小,但效果下降更明显;
|
||||
- PagedAttention:推理框架 vLLM 借鉴操作系统的 [分页](计算机操作系统%20-%20内存管理.md#分页系统地址映射),把 KV Cache 切成固定大小的块按需分配,用块表记录每个请求的块,避免按最大长度预留连续的显存,相同前缀的请求还可以共享块;
|
||||
- 量化:用 8 位甚至更少的位数存储 K、V。
|
||||
|
||||
<div align="center"> <img src="pics/gqa.png" width="680px"/> </div><br>
|
||||
|
||||
### 采样参数
|
||||
|
||||
模型输出的是下一个 Token 的概率分布,最终选哪个 Token 由采样参数决定:
|
||||
|
||||
- 贪心解码:总是选概率最高的 Token,结果确定,但容易陷入重复;
|
||||
- 温度(Temperature):把每个 Token 的分数 z 除以温度 T 之后再做 softmax,T < 1 时高概率的 Token 更突出,T > 1 时分布变平;
|
||||
- Top-k:只在概率最高的 k 个 Token 中采样;
|
||||
- Top-p:按概率从高到低累加,只在累计概率刚达到 p 的那些 Token 中采样,候选数量随分布自动变化。
|
||||
|
||||
<div align="center"> <img src="pics/llm-sampling.png" width="680px"/> </div><br>
|
||||
|
||||
例如原始分布为 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 被排除,剩下的重新归一化。
|
||||
|
||||
抽取字段、生成代码、调用工具等需要稳定结果的场景使用较低的温度,写作类场景可以适当调高。
|
||||
|
||||
### 训练过程
|
||||
|
||||
一个可以对话的大模型通常经过三个阶段的训练:
|
||||
|
||||
<div align="center"> <img src="pics/llm-training.png" width="690px"/> </div><br>
|
||||
|
||||
- 预训练(Pre-training):在海量文本上学习预测下一个 Token,模型获得语言能力和世界知识,但只会续写文本;
|
||||
- 监督微调(Supervised Fine-Tuning, SFT):用人工编写的指令和回答继续训练,模型学会按对话的格式回答问题、遵循指令;
|
||||
- 偏好对齐:让回答更符合人的偏好,例如更有帮助、拒绝有害的请求。基于人类反馈的强化学习(Reinforcement Learning from Human Feedback, RLHF)先用标注了好坏的回答对训练一个奖励模型,再用强化学习优化模型以获得更高的奖励;直接偏好优化(Direct Preference Optimization, DPO)直接用这些回答对训练模型,不需要奖励模型和强化学习,实现更简单。
|
||||
|
||||
SFT 之后还要做偏好对齐,是因为 SFT 只告诉模型“这个回答是对的”,偏好数据告诉模型“两个回答中哪个更好”。有用、安全这类目标很难写出标准答案,但比较两个回答的好坏要容易得多。
|
||||
|
||||
### 微调
|
||||
|
||||
微调指的是用特定任务的数据继续训练模型。全量微调要更新所有参数,7B 模型的参数、梯度和优化器状态加起来需要上百 GB 显存。
|
||||
|
||||
低秩适配(Low-Rank Adaptation, LoRA)冻结原有的参数矩阵 W,只训练两个小矩阵 B 和 A,用它们的乘积 BA 表示参数的变化量。它基于这样的观察:微调时参数的变化量通常是低秩的,用很少的参数就能表示。
|
||||
|
||||
<div align="center"> <img src="pics/lora.png" width="640px"/> </div><br>
|
||||
|
||||
- 参数少:以 4096 × 4096 的矩阵为例,秩 r 取 8 时只需训练 65,536 个参数,是全量微调的 0.39%;
|
||||
- 推理无额外开销:训练完成后把 BA 直接加到 W 上,模型结构不变;
|
||||
- 便于切换:同一个基座模型可以保存多份 LoRA 参数,用于不同的任务。
|
||||
|
||||
QLoRA 进一步把冻结的原始参数量化为 4 位存储,显存需求更低。
|
||||
|
||||
RAG、微调和提示词解决的是不同的问题:
|
||||
|
||||
| 需求 | 方案 |
|
||||
| --- | --- |
|
||||
| 回答需要用到私有资料或者经常更新的知识 | RAG |
|
||||
| 固定输出风格和格式,或者学会某种专业任务 | 微调 |
|
||||
| 调整任务要求,补充少量规则和示例 | 提示词 |
|
||||
|
||||
通常先尝试提示词,再考虑 RAG,最后才考虑微调。微调需要准备训练数据,基座模型升级后要重新训练,而且写入参数的知识难以更新,也无法给出来源。
|
||||
|
||||
### 幻觉
|
||||
|
||||
幻觉(Hallucination)指的是模型生成了看似合理、但与事实或者给定资料不符的内容。可以分为两类:
|
||||
|
||||
- 事实性幻觉:与客观事实不符,例如编造不存在的论文、API;
|
||||
- 忠实性幻觉:与给定的资料或指令不符,例如资料里写的是 5 天,回答却是 7 天。
|
||||
|
||||
产生的原因:
|
||||
|
||||
- 训练目标是生成通顺的下一个 Token,而不是核对事实,模型不知道的内容也会被“合理地”补全;
|
||||
- 训练数据有截止时间,也可能包含错误;
|
||||
- 采样带有随机性,一旦生成了错误的 Token,后面的内容会顺着它继续生成;
|
||||
- 上下文过长或者检索到的资料有噪声时,模型可能用错资料。
|
||||
|
||||
缓解方法:
|
||||
|
||||
- 输入:通过 RAG 提供可靠的资料,资料不足时让模型明确回答不知道;
|
||||
- 生成:降低温度,要求回答逐条引用资料;
|
||||
- 校验:用程序检查引用的内容是否真的出现在资料中,关键的数字和实体与数据库核对,或者让另一个模型判断回答是否被资料支持;
|
||||
- 产品:展示来源,高风险的结果由人工确认。
|
||||
|
||||
幻觉无法完全消除,工程上的目标是降低出现的概率,并让错误能被发现。
|
||||
|
||||
### 推理模型
|
||||
|
||||
推理模型(Reasoning Model)在给出回答之前,会先生成一段思考过程,把复杂问题拆成多步解决。例如 DeepSeek-R1 通过大规模强化学习获得这种能力:对于数学、代码这类答案可以自动验证的题目,按最终答案是否正确给予奖励,模型在训练中自发地学会了更长的思考、检查和修正。
|
||||
|
||||
思考过程同样消耗 Token,更高的准确率是用更长的延迟和更高的成本换来的,简单问题没有必要使用推理模型。
|
||||
|
||||
## 二、提示词与上下文
|
||||
|
||||
### 提示词
|
||||
|
||||
对话接口的输入是一组带角色的消息:系统(system)消息设定模型的身份和规则,用户(user)消息是具体的请求,助手(assistant)消息是模型之前的回答,工具(tool)消息是工具的执行结果。
|
||||
|
||||
一个清晰的提示词通常包含任务、约束、输出格式和示例。例如从客服消息中提取工单:
|
||||
|
||||
```text
|
||||
你是客服工单助手,从用户消息中提取工单信息。
|
||||
|
||||
要求:
|
||||
- 只根据用户消息提取,没有提到的字段填 null,不要猜测;
|
||||
- category 只能是 退款、物流、账号、其它 之一。
|
||||
|
||||
输出 JSON:{"category": ..., "order_id": ..., "summary": ...}
|
||||
|
||||
示例:
|
||||
用户:订单 1024 三天了还没发货
|
||||
输出:{"category": "物流", "order_id": "1024", "summary": "订单未发货"}
|
||||
```
|
||||
|
||||
- 要求越具体越好,“简洁一些”不如“不超过 100 字”;
|
||||
- 说明信息不足时怎么处理,否则模型倾向于编造;
|
||||
- 给出几组输入和期望的输出,称为少样本(Few-shot)提示,示例比文字描述更能说明格式;
|
||||
- 让模型先写出推理步骤再给出答案,称为思维链(Chain of Thought, CoT),可以提高多步推理问题的准确率。
|
||||
|
||||
提示词和代码一样需要版本管理,每次修改后都在固定的评测集上回归,对比修改前后的效果。
|
||||
|
||||
### 结构化输出
|
||||
|
||||
程序需要解析模型的输出时,要求模型按照 JSON Schema 返回结果。只在提示词中要求输出 JSON 是不够的,模型仍可能多说一句解释、漏掉字段或者少写一个括号。
|
||||
|
||||
约束解码(Constrained Decoding)可以保证输出符合 Schema:每生成一个 Token 之前,先根据 Schema 算出哪些 Token 能让输出仍然是合法的前缀,把其它 Token 的概率置为 0,再进行采样。
|
||||
|
||||
<div align="center"> <img src="pics/constrained-decoding.png" width="660px"/> </div><br>
|
||||
|
||||
例如 Schema 规定 temp 是整数,已经生成了 `{"city": "杭州", "temp": ` 时,只有数字和负号是合法的,“晴”这类 Token 都会被屏蔽。vLLM 等推理框架和很多模型接口都支持这种方式。
|
||||
|
||||
约束解码只保证格式合法,不保证内容正确。不支持约束解码时,可以这样处理:
|
||||
|
||||
- 解析前去掉 Markdown 代码块标记等多余的内容;
|
||||
- 用 Schema 校验,失败时把错误信息发回给模型重新生成,并限制重试次数;
|
||||
- 多次失败后返回默认值或者转人工处理。
|
||||
|
||||
### 上下文工程
|
||||
|
||||
模型只能看到上下文窗口里的内容,回答的质量很大程度上取决于窗口里放了什么。上下文工程(Context Engineering)指的是在每次调用之前,决定把哪些内容、以什么顺序放进窗口,包括系统规则、工具定义、检索到的资料、对话历史和当前问题。
|
||||
|
||||
<div align="center"> <img src="pics/context-window.png" width="660px"/> </div><br>
|
||||
|
||||
上下文并不是越多越好:
|
||||
|
||||
- 窗口有上限,输入越长,延迟和成本越高;
|
||||
- 输入越长,模型对其中信息的利用越差,关键信息位于中间时更容易被忽略,这个现象称为 Lost in the Middle。
|
||||
|
||||
因此只放入与当前任务相关的内容,重要的规则放在开头或者结尾。
|
||||
|
||||
内容的顺序还会影响成本。模型服务普遍支持前缀缓存:两次请求的开头完全相同时,相同部分的 KV Cache 可以复用,不需要重新计算,很多服务对命中缓存的 Token 按更低的价格计费。所以系统规则、工具定义这些不变的内容应该放在前面,每次都变化的内容放在后面。如果在系统提示词的开头插入当前时间,后面的内容就全部无法命中缓存。
|
||||
|
||||
对话或者 Agent 运行的时间很长时,历史会超出窗口,需要压缩:
|
||||
|
||||
<div align="center"> <img src="pics/context-compression.png" width="690px"/> </div><br>
|
||||
|
||||
- 滑动窗口:只保留最近的 N 轮对话;
|
||||
- 摘要:把较早的对话交给模型总结成一段摘要,保留已确定的结论、未解决的问题和用户的要求,丢弃寒暄和重复的内容;
|
||||
- 清理工具结果:较早的工具调用返回的大段原始数据很少再被用到,可以替换为一句说明,需要时重新获取;
|
||||
- 外置:把计划、中间结果写到文件或数据库中,上下文中只保留路径,需要时再读取。
|
||||
|
||||
需要注意的是,截断历史时工具调用和它的结果要成对保留或者成对删除。只剩下结果而缺少对应的调用时,很多接口会直接报错。
|
||||
|
||||
## 三、检索增强生成
|
||||
|
||||
检索增强生成(Retrieval-Augmented Generation, RAG)指的是先从知识库中检索与问题相关的资料,再把资料和问题一起交给模型生成回答。它用于解决模型不了解私有数据和最新数据的问题,也能减少幻觉,回答还可以附上来源。
|
||||
|
||||
### 流程
|
||||
|
||||
RAG 分为离线建立索引和在线检索生成两个阶段:
|
||||
|
||||
<div align="center"> <img src="pics/rag-pipeline.png" width="680px"/> </div><br>
|
||||
|
||||
- 离线:解析文档,切成小段,用 Embedding 模型把每一段转为向量存入向量数据库,同时建立关键词索引;
|
||||
- 在线:改写用户的问题,向量检索和关键词检索同时召回候选,合并之后重排,把最相关的几段资料和问题一起放进提示词,生成带引用的回答。
|
||||
|
||||
每一环都会影响最终的效果:文档解析错了,后面检索得再准也没用;正确的资料没被召回,模型就只能编造。
|
||||
|
||||
文档更新时,按文档 ID 和内容的哈希值找出发生变化的文档,只重新切片和计算这些文档的向量,并删除旧版本的切片,不需要重建整个索引。
|
||||
|
||||
### 切片
|
||||
|
||||
切片(Chunking)指的是把长文档拆成适合检索的小段。切片的大小需要权衡:
|
||||
|
||||
- 切片太大:一段里混有多个主题,向量不够准确,召回的内容中无关的部分也会占用上下文;
|
||||
- 切片太小:语义被割裂,例如“工作满 1 年不满 10 年的”和“每年 5 天”被切到两段,单独召回哪一段都无法回答问题。
|
||||
|
||||
常用做法:
|
||||
|
||||
- 按标题、段落等文档结构切分,表格和代码块保持完整,而不是按固定字数硬切;
|
||||
- 相邻切片之间保留一部分重叠,避免关键句子被切断;
|
||||
- 给切片加上文档标题、章节路径等信息,例如“员工手册 > 第 3 章 年假”,使切片单独拿出来也能看懂;
|
||||
- 用小切片检索,命中之后返回它所在的完整段落,称为父子切片。
|
||||
|
||||
<div align="center"> <img src="pics/rag-chunking.png" width="660px"/> </div><br>
|
||||
|
||||
切片大小没有通用的最优值,通常从几百个 Token 开始,在评测集上对比不同大小的召回率来确定。
|
||||
|
||||
### Embedding 与相似度
|
||||
|
||||
Embedding 模型把一段文本转为一个固定长度的向量,例如 1024 维。训练时让意思相近的文本的向量互相靠近、无关的文本的向量互相远离,因此向量之间的距离可以衡量语义的相似程度。
|
||||
|
||||
常用余弦相似度衡量两个向量方向的接近程度:cos(a, b) = a · b / (|a| |b|),取值范围是 -1 到 1。用二维向量示意:
|
||||
|
||||
```text
|
||||
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,这时余弦相似度等于点积,计算更快。
|
||||
|
||||
检索时把问题也转为向量,找出与它最相似的 K 个切片,称为 Top-K 检索。问题和切片必须使用同一个 Embedding 模型,更换模型后所有切片都要重新计算向量。
|
||||
|
||||
### 向量索引
|
||||
|
||||
最简单的检索方法是把问题向量与所有切片向量逐个比较。100 万个 1024 维的向量,每次查询需要约 10 亿次乘加运算,数据量和并发量大时太慢。向量数据库使用近似最近邻(Approximate Nearest Neighbor, ANN)索引,用少量精度换取速度,可能会漏掉个别真正最近的向量。
|
||||
|
||||
常用的两种索引:
|
||||
|
||||
- HNSW:把向量组织成多层的图,每个向量与附近的若干个向量相连。插入时每个向量随机分配一个最高层,层数越高概率越低,所以上层的向量少、连线跨度大,最下层包含所有向量。查询时从最上层的入口开始,每层贪心地走向离查询更近的节点,走不动时下降一层,思路与 [跳跃表](Redis.md#跳跃表) 类似。查询快、召回率高,但图结构占用的内存较多;
|
||||
- IVF:先用聚类把向量分到若干个桶中,查询时只在离查询最近的几个桶里逐个比较。常与乘积量化(Product Quantization, PQ)一起使用来压缩向量,内存占用小,召回率比 HNSW 低一些。
|
||||
|
||||
<div align="center"> <img src="pics/hnsw.png" width="640px"/> </div><br>
|
||||
|
||||
这些索引都可以通过参数在速度和召回率之间权衡,例如 HNSW 的 efSearch 是查询时保留的候选数量,调大之后召回率更高、查询更慢;IVF 的 nprobe 是查询的桶数。
|
||||
|
||||
检索时往往还要按租户、权限、文档版本等条件过滤。如果先检索 Top-K 再过滤,过滤后剩下的结果可能远少于 K 个,因此向量数据库一般支持在检索的过程中同时过滤。
|
||||
|
||||
### 混合检索
|
||||
|
||||
向量检索擅长匹配意思相近但措辞不同的内容,却不擅长精确匹配。例如查询“错误码 E1024”,E1024 和 E1025 的向量非常接近,向量检索可能把 E1025 的文档排在前面。关键词检索正好相反,常用的算法是 BM25,它给文档打分时主要考虑三点:
|
||||
|
||||
- 词频:查询词在文档中出现的次数越多分数越高,但增长逐渐放缓,出现 10 次并不比出现 5 次重要一倍;
|
||||
- 逆文档频率:越少见的词权重越高,E1024 只出现在少数文档中,权重远高于“错误码”;
|
||||
- 文档长度:词频相同时,较短文档的分数更高,避免长文档仅仅因为字数多而得分高。
|
||||
|
||||
Elasticsearch 中 BM25 的参数 k1 和 b 默认为 1.2 和 0.75,分别控制词频的饱和速度和文档长度的影响程度。
|
||||
|
||||
因此通常两路同时召回,再合并结果。两路的分数不能直接相加,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 之后按权重相加,权重需要在评测集上调整。
|
||||
|
||||
### 重排
|
||||
|
||||
重排(Rerank)用来对召回的几十条候选重新打分排序,只保留最相关的几条。向量检索已经计算过相似度,还需要重排,是因为两种模型的计算方式不同:
|
||||
|
||||
<div align="center"> <img src="pics/embedding-vs-rerank.png" width="660px"/> </div><br>
|
||||
|
||||
- Embedding 模型分别编码问题和文档,文档的向量可以提前计算并建立索引,速度快;但问题和文档在编码时互相看不到,细节的对应关系在压缩成一个向量时丢失了;
|
||||
- 重排模型把问题和文档拼成一个输入一起编码,称为交叉编码器(Cross-Encoder),注意力可以在问题和文档的每个词之间计算,判断更准确;但每一对都要重新计算,只能处理少量候选。
|
||||
|
||||
所以先用速度快的检索从海量数据中召回几十条,再用准确的重排模型选出最终的几条,与搜索和推荐系统中先召回、后排序的分层思路一致。
|
||||
|
||||
### 查询改写
|
||||
|
||||
用户的原始问题往往不适合直接检索:
|
||||
|
||||
- 指代:先问“Java 8 的 HashMap 怎么扩容”,再问“那 ConcurrentHashMap 呢”,第二个问题单独检索找不到有用的内容,需要结合历史改写为“Java 8 的 ConcurrentHashMap 怎么扩容”;
|
||||
- 问题太短:问题只有几个字,切片有几百字,两者的向量不容易靠近。HyDE 的做法是先让模型写一段假设的回答,再用这段回答去检索,因为回答在形式上更接近文档;
|
||||
- 包含多个子问题:拆成多个查询分别检索,再合并结果。
|
||||
|
||||
改写需要多调用一次模型,会增加延迟。
|
||||
|
||||
### 评测与优化
|
||||
|
||||
RAG 需要分别评测检索和生成,这样才能定位问题出在哪一步。
|
||||
|
||||
检索的常用指标:
|
||||
|
||||
- 召回率(Recall@K):相关的切片有多少出现在前 K 个结果中;
|
||||
- 平均倒数排名(Mean Reciprocal Rank, MRR):第一个相关结果排名的倒数的平均值,衡量相关结果是否排得靠前。
|
||||
|
||||
例如 3 个问题各有 1 个相关切片,在前 5 个结果中的排名分别是 1、3 和未出现,则 Recall@5 = 2 / 3,MRR = (1 + 1/3 + 0) / 3 = 0.44。
|
||||
|
||||
生成的常用指标:
|
||||
|
||||
- 忠实度:回答中的每个结论是否都能在资料中找到依据;
|
||||
- 相关性:回答是否回应了问题。
|
||||
|
||||
生成的指标难以用规则计算,通常让另一个模型按评分标准打分(LLM-as-a-Judge),并抽样人工核对打分是否可靠。
|
||||
|
||||
评测前需要准备一批问题,标注标准答案和相关的切片,每次调整切片方式、模型或者参数之后都重新评测。常见问题的排查思路:
|
||||
|
||||
| 现象 | 可能的原因 | 优化方法 |
|
||||
| --- | --- | --- |
|
||||
| 相关资料没被召回 | 切片割裂了语义,问题与文档的表述差异大,专有名词 | 调整切片、查询改写、混合检索、增大 K |
|
||||
| 召回了但排在后面 | 向量相似度不够精确 | 重排 |
|
||||
| 召回了无关的资料 | 切片太大,缺少过滤条件 | 缩小切片,按文档类型、时间等过滤 |
|
||||
| 资料正确但回答错误 | 放入的资料太多太杂,提示词没有要求依据资料 | 减少放入的切片,要求逐条引用 |
|
||||
|
||||
### 长上下文与 Agentic RAG
|
||||
|
||||
模型的上下文窗口越来越大,资料量不大时可以把全部资料直接放进上下文,不需要 RAG。但知识库很大时,RAG 仍然是必要的:
|
||||
|
||||
- 每次请求都处理大量 Token,成本高、延迟大;
|
||||
- 上下文越长,模型对其中信息的利用越差;
|
||||
- 检索时可以按用户的权限过滤资料,全部放进上下文就做不到这一点。
|
||||
|
||||
普通的 RAG 每个问题只检索一次。问题需要多步查找时,例如“负责支付系统的团队的负责人是谁”,要先查到团队,再查负责人。这时可以把检索作为一个工具交给 Agent,由模型决定检索什么、检索几次,称为 Agentic RAG。它更灵活,但调用次数和延迟都会增加,问题简单时固定流程的 RAG 更可控。
|
||||
|
||||
## 四、Agent
|
||||
|
||||
### Agent 是什么
|
||||
|
||||
普通的大模型调用是一问一答:模型根据输入生成一段文本。它无法查询数据库、调用接口,也就拿不到训练数据以外的信息,更不能替用户执行操作。
|
||||
|
||||
Agent(智能体)指的是让大模型通过调用工具来完成任务的系统。模型负责决定下一步做什么,应用程序负责执行,并把结果交还给模型,如此循环,直到任务完成。例如用户问“订单 1024 到哪了”:
|
||||
|
||||
<div align="center"> <img src="pics/agent-overview.png" width="690px"/> </div><br>
|
||||
|
||||
需要注意的是,模型始终只输出文本,查询数据库、调用接口都是应用代码完成的。模型的作用是在每一步做出决策,应用的作用是执行决策、控制整个循环。
|
||||
|
||||
一个 Agent 通常由以下几部分组成,后面各节分别介绍:
|
||||
|
||||
- 模型:理解任务,决定下一步做什么;
|
||||
- 工具:模型可以使用的函数或接口,用于获取信息和执行操作;
|
||||
- 记忆:保存对话历史、用户偏好等信息,需要时放进上下文;
|
||||
- 规划:把复杂的任务拆成多个步骤;
|
||||
- 控制循环:应用代码中反复调用模型、执行工具的循环,同时负责限制步数、校验参数和权限。
|
||||
|
||||
### 工具调用
|
||||
|
||||
工具调用(Function Calling)指的是模型按照约定的格式给出要调用的函数名和参数,由应用程序执行。模型能够“调用工具”,过程如下:
|
||||
|
||||
<div align="center"> <img src="pics/tool-call-inside.png" width="700px"/> </div><br>
|
||||
|
||||
1. 应用在请求中附上工具定义,包括名称、用途和参数的 JSON Schema;
|
||||
2. 推理服务按照模型的对话模板,把工具定义和调用格式的说明拼进提示词;
|
||||
3. 模型在训练时学习过这种格式,判断需要调用工具时,会生成一段特定格式的文本,而不是直接回答;
|
||||
4. 推理服务识别出这段文本,把它解析为结构化的 tool_calls 返回给应用。
|
||||
|
||||
可见工具调用本质上仍然是生成文本,所以模型可能写错参数、调用不存在的工具,应用必须先校验再执行。很多服务提供严格模式,用约束解码保证参数符合 Schema。
|
||||
|
||||
以 OpenAI 兼容的接口为例,请求中这样描述工具:
|
||||
|
||||
```json
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "查询城市当天的天气。用户询问天气、气温时使用",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string", "description": "城市名,例如 杭州"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
模型决定调用工具时,返回的消息中带有 tool_calls,其中 arguments 是一个 JSON 字符串:
|
||||
|
||||
```json
|
||||
{"role": "assistant", "tool_calls": [{
|
||||
"id": "call_1", "type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"city\": \"杭州\"}"}
|
||||
}]}
|
||||
```
|
||||
|
||||
应用执行函数后,用 tool 角色的消息把结果发回。一次回复中可能包含多个调用,tool_call_id 用于对应是哪一次调用:
|
||||
|
||||
```json
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{\"weather\": \"晴\", \"temp\": 26}"}
|
||||
```
|
||||
|
||||
工具结果使用单独的角色,模型能区分这是工具返回的数据而不是用户的指令,但这并不能防止结果中藏有恶意指令,见下文的安全。
|
||||
|
||||
模型只能根据工具的名称、描述和参数说明来决定是否调用、怎么填参数,所以工具的设计直接影响调用的准确率:
|
||||
|
||||
- 描述写清楚用途和使用时机,参数给出格式和示例;
|
||||
- 一个工具只做一件事,工具之间的职责不要重叠,否则模型容易选错;
|
||||
- 返回结果要精简,只返回模型需要的字段,大段的原始数据会占用上下文;
|
||||
- 出错时返回能指导模型修正的信息,例如“日期格式应为 2026-01-01”,而不是只返回“参数错误”。
|
||||
|
||||
工具很多时,全部的工具定义会占用大量上下文,模型也更容易选错。可以只加载与当前任务相关的工具,例如先根据问题检索出最相关的十几个工具,或者把工具分组交给不同的子 Agent。
|
||||
|
||||
### Agent 循环
|
||||
|
||||
把工具调用放进一个循环,就得到了 Agent。大多数 Agent 按照 ReAct(Reasoning and Acting)的方式运行:模型先思考下一步做什么,然后调用工具,把工具返回的结果作为观察放回上下文,再进入下一轮思考,直到信息足够给出最终回答。
|
||||
|
||||
<div align="center"> <img src="pics/react-loop.png" width="680px"/> </div><br>
|
||||
|
||||
从接口的角度看,应用每次调用模型时发送的消息如下:
|
||||
|
||||
<div align="center"> <img src="pics/agent-messages.png" width="720px"/> </div><br>
|
||||
|
||||
- 模型不保存状态,每次调用都要发送完整的消息历史,所以步数越多,发送的消息越长,Token 消耗也越多;
|
||||
- 每一步新增两条消息:模型的调用请求,以及应用执行工具得到的结果;
|
||||
- 第 3 次调用时模型返回的是文本而不是调用请求,说明它认为信息已经足够,循环结束。
|
||||
|
||||
用代码表示这个循环:
|
||||
|
||||
```java
|
||||
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 "超过最大步数,转人工处理";
|
||||
}
|
||||
```
|
||||
|
||||
什么时候结束由模型判断,但模型可能一直不停,或者反复调用同一个工具,所以代码中必须限制最大步数,见下文的可靠性与评测。
|
||||
|
||||
### Agent 与工作流
|
||||
|
||||
与 Agent 相对的是工作流(Workflow):执行哪些步骤、按什么顺序执行都由代码事先写好,大模型只负责其中的某些步骤,例如对用户的消息分类。
|
||||
|
||||
<div align="center"> <img src="pics/workflow-vs-agent.png" width="680px"/> </div><br>
|
||||
|
||||
- 工作流可预测、成本可控,适合步骤固定的业务;
|
||||
- Agent 更灵活,适合无法事先确定步骤的开放任务,代价是成本更高,错误会在多个步骤之间累积。
|
||||
|
||||
例如每一步的成功率是 95%,连续 10 步都成功的概率只有 0.95<sup>10</sup> ≈ 60%。因此应该先尝试单次调用或者工作流,确实需要时再引入 Agent,也可以只在工作流的某一步中使用 Agent。
|
||||
|
||||
### 规划
|
||||
|
||||
ReAct 每一步都根据上一步的结果决定下一步,适合需要边做边看的任务。另一种方式是先规划后执行(Plan-and-Execute):先让模型把任务拆成步骤列表,再逐个执行。
|
||||
|
||||
<div align="center"> <img src="pics/plan-execute.png" width="720px"/> </div><br>
|
||||
|
||||
- 先规划后执行:互不依赖的步骤可以并行,执行步骤时可以使用更便宜的模型,进度也容易展示和恢复;但计划可能与实际情况不符,某一步失败或者结果不符合预期时需要重新规划;
|
||||
- ReAct:可以根据中间结果随时调整方向;但每一步都要调用一次大模型,长任务中容易偏离最初的目标。
|
||||
|
||||
实际系统常把两者结合:先生成一份计划,执行每个步骤时使用 ReAct,并在执行过程中更新计划。还可以加入反思(Reflection):任务失败后让模型检查过程、总结原因,再重新尝试。
|
||||
|
||||
### 记忆
|
||||
|
||||
模型本身不保存任何状态,每次调用都要把需要的信息重新放进上下文。记忆要解决的是保存什么,以及什么时候取出来放进上下文。
|
||||
|
||||
- 短期记忆:当前会话的消息、任务状态和工具结果。常见的实现是以会话 ID 为键,把消息列表存放在 Redis 中,每次请求取出历史、追加新消息后写回,并设置过期时间;历史过长时用上下文工程中的方法压缩;
|
||||
- 长期记忆:跨会话保存的用户偏好、已知事实和过去的经验,存放在数据库或者向量库中。
|
||||
|
||||
<div align="center"> <img src="pics/agent-memory.png" width="690px"/> </div><br>
|
||||
|
||||
长期记忆分为写入和读取。写入时,会话结束后或者每隔几轮,用模型从对话中提取值得长期保存的信息,而不是保存原始对话:
|
||||
|
||||
```text
|
||||
从下面的对话中提取关于用户的、以后的对话中可能用到的事实。
|
||||
只提取用户明确说过的内容,每条一句话,以 JSON 数组输出,没有则输出 []。
|
||||
|
||||
用户:我对花生过敏,推荐个早餐
|
||||
助手:推荐燕麦粥和鸡蛋……
|
||||
|
||||
输出:["用户对花生过敏"]
|
||||
```
|
||||
|
||||
- 写入:提取出的每条事实连同它的向量、用户 ID 和时间一起保存。新事实与已有的记忆冲突时,例如“用户住在杭州”和“用户搬到了上海”,先检索出相似的旧记忆,以时间较新的为准更新或者删除;
|
||||
- 读取:新的请求到来时,用当前问题检索这个用户最相关的几条记忆,放进系统提示词,而不是把所有记忆都放进去。
|
||||
|
||||
需要注意的是,记忆中存放的是用户的个人信息,应该允许用户查看、修改和删除。
|
||||
|
||||
### MCP
|
||||
|
||||
同一个工具要接入多个 AI 应用时,原本需要在每个应用里分别适配一遍。模型上下文协议(Model Context Protocol, MCP)定义了 AI 应用与外部工具、数据之间的统一接口,工具只需实现一次 MCP 服务端,就能被所有支持 MCP 的应用使用。
|
||||
|
||||
<div align="center"> <img src="pics/mcp-integration.png" width="640px"/> </div><br>
|
||||
|
||||
MCP 中有三种角色:
|
||||
|
||||
- 主机(Host):用户使用的 AI 应用,例如 IDE 或者聊天客户端;
|
||||
- 客户端(Client):主机中负责与某个服务端通信的连接器,一个客户端只连接一个服务端;
|
||||
- 服务端(Server):对外提供能力的程序,可以提供工具(Tools)、资源(Resources)和提示词模板(Prompts)。
|
||||
|
||||
<div align="center"> <img src="pics/mcp.png" width="660px"/> </div><br>
|
||||
|
||||
以官方的文件系统服务端为例,在主机的配置文件中写明如何启动它:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/Documents"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
主机启动时按配置把服务端作为子进程启动,通过它的标准输入输出(stdio)收发消息,服务端只能访问参数中列出的目录;远程服务端则通过 Streamable HTTP 通信。消息使用 JSON-RPC 2.0 格式:客户端先用 tools/list 获取工具列表,每个工具带有名称、描述和参数的 JSON Schema;模型选定工具后,客户端再用 tools/call 发起调用。以下请求和响应省略了部分字段:
|
||||
|
||||
```json
|
||||
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||
"params": {"name": "read_file", "arguments": {"path": "README.md"}}}
|
||||
|
||||
{"jsonrpc": "2.0", "id": 2,
|
||||
"result": {"content": [{"type": "text", "text": "# CS-Notes ..."}], "isError": false}}
|
||||
```
|
||||
|
||||
MCP 与相关概念的区别:
|
||||
|
||||
- 工具调用是模型这一侧的能力,模型根据工具描述给出调用;MCP 是应用与工具之间的协议,解决工具如何被发现和调用。两者配合使用:主机把从服务端获取的工具转换为工具定义交给模型,模型给出调用后,再通过 MCP 发给服务端执行;
|
||||
- 普通的 HTTP 接口也能封装成工具,但每个接口的认证方式、参数格式和文档各不相同,需要逐个适配;MCP 统一了发现和调用的方式,工具描述本身就是写给模型看的。
|
||||
|
||||
MCP 也有代价:每个服务端的工具定义都会进入上下文,接入的服务端多了会占用大量 Token;第三方服务端的工具描述和返回结果可能包含恶意指令,应该像安装软件一样只使用可信的服务端。MCP 规范仍在快速演进,细节以官方最新版本为准。
|
||||
|
||||
### Skills
|
||||
|
||||
Skill(技能)是一个包含说明文档、脚本和参考资料的文件夹,用于教会 Agent 完成某一类任务,例如按团队模板写周报。文件夹中的 SKILL.md 开头用 name 和 description 描述这个 Skill 做什么、什么时候使用,正文是具体的操作步骤。
|
||||
|
||||
Skill 的核心是渐进式加载:
|
||||
|
||||
<div align="center"> <img src="pics/skills.png" width="690px"/> </div><br>
|
||||
|
||||
1. 启动时只加载每个 Skill 的名称和描述,每个约 100 Token;
|
||||
2. 用户的请求与某个 Skill 的描述匹配时,才读取 SKILL.md 的正文;
|
||||
3. 正文中引用的其它文档和脚本在需要时才读取或执行,脚本只有运行结果进入上下文。
|
||||
|
||||
因此即使安装了几十个 Skill,平时占用的上下文也很少。
|
||||
|
||||
工具、MCP 和 Skill 的区别:
|
||||
|
||||
- 工具是一个可调用的函数,提供原子能力,例如查询订单;
|
||||
- MCP 是连接工具和数据的协议,解决工具怎么接入;
|
||||
- Skill 提供做事的方法,告诉 Agent 完成某类任务的步骤和规范,执行时仍然需要调用工具。
|
||||
|
||||
### 多 Agent
|
||||
|
||||
复杂任务可以交给多个 Agent 协作完成。常用的结构是主从模式:主 Agent 负责拆分任务和汇总结果,把子任务分派给子 Agent 并行执行。
|
||||
|
||||
<div align="center"> <img src="pics/multi-agent.png" width="690px"/> </div><br>
|
||||
|
||||
实现上,子 Agent 通常被包装成主 Agent 的一个工具。主 Agent 调用 research("厂商 A") 时,应用启动一个新的 Agent 循环,它有自己的系统提示词、工具和消息列表,运行结束后把最终回答作为工具结果返回给主 Agent。
|
||||
|
||||
多 Agent 的主要好处是隔离上下文:每个子 Agent 在自己的上下文中工作,可以读大量资料、反复试错,最后只把精简的结论返回,主 Agent 的上下文不会被中间过程撑满。
|
||||
|
||||
代价也很明显:
|
||||
|
||||
- Token 消耗成倍增加,Anthropic 的数据是 Agent 消耗的 Token 约为普通对话的 4 倍,多 Agent 系统约为 15 倍;
|
||||
- Agent 之间只传递摘要,细节可能丢失,一个子 Agent 的错误结论也会被主 Agent 当作事实接收。
|
||||
|
||||
因此多 Agent 适合可以拆成多个独立部分、并行处理的任务,例如同时调研多个主题。子任务之间需要频繁共享上下文的任务,例如修改同一份代码,更适合由单个 Agent 完成。
|
||||
|
||||
### 开发框架
|
||||
|
||||
上面的循环、工具定义和消息管理在每个项目中都要重复编写,LangChain、LangGraph、Spring AI 等框架把它们封装了起来:
|
||||
|
||||
- 统一不同模型服务的接口,切换模型时不需要修改业务代码;
|
||||
- 把普通函数注册为工具,例如 Spring AI 中给方法加上 @Tool 注解,框架根据方法的参数生成 JSON Schema;
|
||||
- 自动执行工具调用的循环,直到模型返回不含工具调用的回答;
|
||||
- 提供对话记忆、RAG、结构化输出等常用组件。
|
||||
|
||||
LangGraph 把 Agent 表示为一张状态图:节点是一次模型调用或者一段代码,边决定下一步执行哪个节点,状态在节点之间传递并可以持久化。这样既能表达固定的工作流,也能表达由模型决定走向的循环,还可以在某个节点暂停,等待人工审批后继续。
|
||||
|
||||
框架减少了重复代码,但也隐藏了细节,排查问题时仍然需要查看实际发送给模型的消息。
|
||||
|
||||
### 可靠性与评测
|
||||
|
||||
Agent 的每一步都可能出错,需要在代码中兜底,而不是依赖模型自觉:
|
||||
|
||||
- 限制步数、总耗时和 Token 预算,超出时停止,返回已有的结果或者转人工;
|
||||
- 检测重复调用,同一个工具以相同参数连续调用多次时中止,或者提示模型换一种方法;
|
||||
- 工具执行设置超时,失败时把错误信息作为结果返回给模型,由它决定重试还是换一种方法;
|
||||
- 修改数据的工具要保证幂等,例如用请求 ID 去重,因为模型可能重复调用;
|
||||
- 转账、删除数据等高风险操作由代码校验权限,并在执行前请求人工确认;
|
||||
- 执行代码的工具放在沙箱中运行。
|
||||
|
||||
把错误信息作为工具结果返回之后,模型通常能够自行修正,例如:
|
||||
|
||||
```text
|
||||
assistant 调用 refund({"order_id": "1024", "amount": "全部"})
|
||||
tool 参数错误:amount 必须是数字,订单 1024 的实付金额为 59.9
|
||||
assistant 调用 refund({"order_id": "1024", "amount": 59.9})
|
||||
tool 退款申请已提交,单号 R88
|
||||
```
|
||||
|
||||
评测 Agent 时,同一个任务可能有多种正确的完成路径,所以主要检查最终结果,例如订单是否真的退款成功,而不是要求调用顺序与标准答案完全一致。常用指标:
|
||||
|
||||
- 任务成功率:在一批固定的任务上,最终结果正确的比例;
|
||||
- 工具调用正确率:选对工具、参数合法的调用占全部调用的比例;
|
||||
- 效率:完成任务所需的步数、耗时和 Token 消耗;
|
||||
- 线上指标:转人工率、用户满意度。
|
||||
|
||||
每次修改提示词、工具或者模型,都在同一批任务上回归,并记录每次运行的完整轨迹,出错时才能复现和定位。
|
||||
|
||||
## 五、工程实践
|
||||
|
||||
### 流式输出
|
||||
|
||||
生成一段长回答可能需要十几秒,流式输出让用户在第一个 Token 生成后就能看到内容。大模型接口通常使用服务器发送事件(Server-Sent Events, SSE)返回结果:响应的 Content-Type 为 text/event-stream,连接保持打开,服务端每生成一段内容就写入一条事件。
|
||||
|
||||
```text
|
||||
id: 1
|
||||
data: {"delta": "杭州"}
|
||||
|
||||
id: 2
|
||||
data: {"delta": "今天晴"}
|
||||
|
||||
: ping
|
||||
|
||||
data: [DONE]
|
||||
|
||||
```
|
||||
|
||||
- 每条事件由若干行“字段: 值”组成,以空行结束,data 是事件的内容,id 是事件的编号;
|
||||
- 以冒号开头的行是注释,客户端会忽略,常用作心跳,防止中间的代理因为连接长时间空闲而断开;
|
||||
- 浏览器的 EventSource 断线后会自动重连,并在请求头 Last-Event-ID 中带上最后收到的事件编号,服务端可以据此从断点继续发送;
|
||||
- OpenAI 兼容的接口用 data: [DONE] 表示生成结束。
|
||||
|
||||
SSE 与 WebSocket 的区别:
|
||||
|
||||
- SSE 基于普通的 HTTP 响应,只能由服务端向客户端推送,自带断线重连,正好符合“请求一次、持续返回”的场景;
|
||||
- WebSocket 是全双工的独立协议,适合客户端和服务端都需要随时发送消息的场景,例如实时语音对话。
|
||||
|
||||
后端调用模型的流式接口时,逐行读取响应,读到一行就处理一行:
|
||||
|
||||
```java
|
||||
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:")) // 跳过空行、注释和其它字段
|
||||
.map(line -> line.substring(5).trim())
|
||||
.takeWhile(data -> !data.equals("[DONE]"))
|
||||
.forEach(onData); // 例如转发给前端
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
生产环境中常见的问题:
|
||||
|
||||
- Nginx 等反向代理默认会缓冲上游的响应,流式输出变成了一次性输出,需要关闭缓冲,例如在响应头中加上 X-Accel-Buffering: no;
|
||||
- 网关和负载均衡的读超时要大于最长的生成时间;
|
||||
- 用户关闭页面后要及时取消对模型的请求,否则仍在消耗 Token;
|
||||
- 工具调用的参数也是分片段流式返回的,需要按调用的序号拼接,生成结束后才能解析出完整的 JSON。
|
||||
|
||||
### 延迟与成本
|
||||
|
||||
大模型请求的延迟用两个指标衡量:
|
||||
|
||||
- 首 Token 时间(Time To First Token, TTFT):从发出请求到收到第一个 Token,主要由预填充阶段决定,输入越长越慢;
|
||||
- 每个输出 Token 的时间(Time Per Output Token, TPOT):解码阶段生成每个 Token 的平均时间,决定了文字出现的速度。
|
||||
|
||||
总延迟约为 TTFT + TPOT × 输出的 Token 数。输出是逐个生成的,比并行处理的输入慢得多,所以限制输出长度通常是降低延迟最有效的方法。
|
||||
|
||||
Agent 的成本会随步数快速增长,因为每一步都要把完整的历史重新发给模型。假设系统提示词和工具定义共 5000 个 Token,每一步新增 2000 个 Token,第 10 步的输入是 2.3 万个 Token,但 10 步累计的输入达到 14 万个 Token。好在每一步的输入都以上一步的输入为前缀,大部分可以命中前缀缓存。
|
||||
|
||||
降低成本的方法:
|
||||
|
||||
- 利用前缀缓存,把不变的内容放在前面;
|
||||
- 按任务难度选择模型,分类、提取等简单任务使用小模型;
|
||||
- 精简提示词、工具定义和对话历史,限制输出长度;
|
||||
- 缓存结果:完全相同的请求直接返回缓存,语义缓存用 Embedding 判断问题是否相近,命中率更高,但要避免把不同的问题当成同一个。
|
||||
|
||||
自己部署模型时,vLLM 等推理框架通过连续批处理提高吞吐量:每生成一步就把已完成的请求移出批次、把新请求加入,而不是等整批请求全部生成结束。
|
||||
|
||||
### 稳定性
|
||||
|
||||
- 限流与重试:模型服务会限制请求频率和 Token 速率,超限时返回 429 状态码,需要按指数退避重试;
|
||||
- 超时与降级:设置合理的超时时间,主模型不可用时切换到备用模型,或者返回降级的结果;
|
||||
- 可观测:记录每一次模型调用和工具调用的输入、输出、耗时和 Token 数,以及使用的模型和提示词版本,出错时才能复现和定位问题。
|
||||
|
||||
### 安全
|
||||
|
||||
提示词注入(Prompt Injection)指的是攻击者通过输入的内容改变模型原本的指令。直接注入是用户在输入中写入恶意指令;间接注入是指令藏在模型读取的网页、文档或者工具返回结果中,在 RAG 和 Agent 中更加隐蔽。
|
||||
|
||||
<div align="center"> <img src="pics/prompt-injection.png" width="680px"/> </div><br>
|
||||
|
||||
模型无法可靠地区分指令和数据,在提示词中要求“忽略资料中的指令”只能降低风险,不能杜绝。因此防范的重点是限制模型能造成的后果:
|
||||
|
||||
- 区分指令和数据,把外部内容明确标注为不可信的资料;
|
||||
- 工具按最小权限设计,当前任务用不到的工具不提供,权限在代码中按当前用户校验,而不是依靠模型遵守提示词;
|
||||
- 删除数据、转账、对外发送信息这类高风险操作需要人工确认;
|
||||
- 模型的输出同样不可信,显示到页面之前要转义,防止 [跨站脚本攻击](攻击技术.md#一跨站脚本攻击),用于查询数据库时要使用参数化查询;
|
||||
- 不要在提示词中放入密钥等敏感信息,提示词可能被诱导泄露。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Attention Is All You Need](https://arxiv.org/abs/1706.03762)
|
||||
- [Neural Machine Translation of Rare Words with Subword Units](https://arxiv.org/abs/1508.07909)
|
||||
- [RoFormer: Enhanced Transformer with Rotary Position Embedding](https://arxiv.org/abs/2104.09864)
|
||||
- [FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness](https://arxiv.org/abs/2205.14135)
|
||||
- [GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints](https://arxiv.org/abs/2305.13245)
|
||||
- [Efficient Memory Management for Large Language Model Serving with PagedAttention](https://arxiv.org/abs/2309.06180)
|
||||
- [Qwen2.5-7B-Instruct 模型配置](https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/blob/main/config.json)
|
||||
- [The Curious Case of Neural Text Degeneration](https://arxiv.org/abs/1904.09751)
|
||||
- [Training Language Models to Follow Instructions with Human Feedback](https://arxiv.org/abs/2203.02155)
|
||||
- [Direct Preference Optimization: Your Language Model is Secretly a Reward Model](https://arxiv.org/abs/2305.18290)
|
||||
- [LoRA: Low-Rank Adaptation of Large Language Models](https://arxiv.org/abs/2106.09685)
|
||||
- [QLoRA: Efficient Finetuning of Quantized LLMs](https://arxiv.org/abs/2305.14314)
|
||||
- [DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning](https://arxiv.org/abs/2501.12948)
|
||||
- [Chain-of-Thought Prompting Elicits Reasoning in Large Language Models](https://arxiv.org/abs/2201.11903)
|
||||
- [Efficient Guided Generation for Large Language Models](https://arxiv.org/abs/2307.09702)
|
||||
- [Lost in the Middle: How Language Models Use Long Contexts](https://arxiv.org/abs/2307.03172)
|
||||
- [Effective Context Engineering for AI Agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)
|
||||
- [DeepSeek API 文档:Token 用量计算](https://api-docs.deepseek.com/zh-cn/quick_start/token_usage)
|
||||
- [DeepSeek API 文档:上下文硬盘缓存](https://api-docs.deepseek.com/zh-cn/guides/kv_cache)
|
||||
- [Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks](https://arxiv.org/abs/2005.11401)
|
||||
- [Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs](https://arxiv.org/abs/1603.09320)
|
||||
- [Elasticsearch: Similarity settings](https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity)
|
||||
- [Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf)
|
||||
- [Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks](https://arxiv.org/abs/1908.10084)
|
||||
- [Precise Zero-Shot Dense Retrieval without Relevance Labels](https://arxiv.org/abs/2212.10496)
|
||||
- [RAGAS: Automated Evaluation of Retrieval Augmented Generation](https://arxiv.org/abs/2309.15217)
|
||||
- [ReAct: Synergizing Reasoning and Acting in Language Models](https://arxiv.org/abs/2210.03629)
|
||||
- [LLM Powered Autonomous Agents](https://lilianweng.github.io/posts/2023-06-23-agent/)
|
||||
- [Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents)
|
||||
- [How We Built Our Multi-Agent Research System](https://www.anthropic.com/engineering/multi-agent-research-system)
|
||||
- [DeepSeek API 文档:Tool Calls](https://api-docs.deepseek.com/zh-cn/guides/tool_calls)
|
||||
- [Qwen2.5-7B-Instruct 对话模板](https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/blob/main/tokenizer_config.json)
|
||||
- [Spring AI: Tool Calling](https://docs.spring.io/spring-ai/reference/api/tools.html)
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview)
|
||||
- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/latest)
|
||||
- [MCP: Connect to Local MCP Servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers)
|
||||
- [Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview)
|
||||
- [HTML Standard: Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
|
||||
- [Nginx: proxy_buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering)
|
||||
- [Orca: A Distributed Serving System for Transformer-Based Generative Models](https://www.usenix.org/conference/osdi22/presentation/yu)
|
||||
- [OWASP Top 10 for LLM Applications: Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/)
|
||||