提示工程

提示原则

原则一:编写清晰、具体的指令

  • 使用分隔符实现不同文本部分的隔断,比如````,”””,< >, `等,可以防止提示词注入
  • 结构化输出,json形式
  • 要求模型检查是否满足条件
  • 提供少量示例

原则二:给模型时间去思考

  • 指定完成任务所需的步骤
  • 指导模型在下结论之前找出一个自己的解法

直接给模型输入一份解答,模型可能默认会判断输入是对的,需要让它自己思考一次再对比

提示词设计

语言类prompt检查是否满足四要素:

  • 立角色
  • 述问题
  • 定目标
  • 补要求

统一模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
你是一名具有【领域】经验的【角色】

## 任务背景
...

## 任务说明
子任务1:...
子任务2:...
...

## 任务示例

## 输入[不一定都有输入]
<INPUT>
{{输入内容}}
</INPUT>

## 任务约束
1. 【具体要求1】
2. 【具体要求2】
3. 【具体要求3】
4. 不得添加输入中不存在的事实
5. 信息不足时明确指出,不要自行假设

## 输出结构
请严格按照以下结构输出:
一般以json形式输出,且要规定好输出的json结构

最重要的是:任务、约束、输出格式

提示词优化方法:

图像类prompt一般不止包含正向提示词,还需要负向提示词来限制画面输出,正向提示词包含:

  • 主体
  • 细节
  • 风格参考

多模态prompt需要明确模态分工,减少不同模态之间的对应关系歧义

  • 图片:核心的语义负载,表达“是什么“
  • 文字:权重调节器和语义补全器,表达“要什么”

设计原则:明确检索意图、锚定视觉焦点、属性调节校准

上下文学习

  • Zero-shot(零样本提示):只告诉模型任务和要求,不给示例
  • Few-shot(少样本提示):除任务要求外,再提供少量“输入—输出”示例,让模型模仿其中的规则、标签含义和输出格式

GPT-3 论文系统比较了 zero-shot、one-shot 和 few-shot 三种设置

Language Models are Few-Shot Learners

一般来说先零样本测试,输出不稳定或错误较多时,再添加代表性示例,构成小样本提示,但是不能无限增加示例

Responses API

最新版不再推荐使用 system

1
2
3
developer = 规则和业务逻辑
user = 用户问题、动态数据、检索上下文
assistant = 模型历史回答或示例答案

在 Responses API 中有几类数据

1
2
3
developer / user / assistant -> 普通对话消息 Message
function_call -> 模型生成的工具调用项
function_call_output -> 程序返回给模型的工具执行结果

标准流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
用户输入

第一次调用 LLM
├─ 判断是否需要工具
├─ 选择工具
└─ 抽取工具参数

程序执行工具

返回 function_call_output

第二次调用 LLM

生成最终回答

函数定义:放在 tools

1
2
3
4
5
6
7
8
tools = [
{
"type": "function",
"name": "get_weather",
"description": "查询天气",
"parameters": {...},
}
]

函数调用:放在 response.output,模型返回样式:

1
2
3
4
5
6
{
"type": "function_call",
"call_id": "call_abc123",
"name": "get_weather",
"arguments": '{"city":"北京"}',
}

模型只是生成了调用请求,并没有执行 Python 函数,程序必须读取 namearguments,然后自己调用对应函数

函数执行结果:放在 function_call_output

1
2
3
4
5
{
"type": "function_call_output",
"call_id": "call_abc123",
"output": '{"temperature":26}',
}

完整结构可以理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
input_items = [
# 普通消息
{
"role": "developer",
"content": "系统规则",
},
{
"role": "user",
"content": "用户问题",
},

# 模型上一次产生的工具调用
{
"type": "function_call",
"call_id": "call_123",
"name": "get_weather",
"arguments": '{"city":"北京"}',
},

# 你的程序提供的工具结果
{
"type": "function_call_output",
"call_id": "call_123",
"output": '{"temperature":26}',
},
]

最后,模型才会生成普通的 assistant 消息

1
2
3
4
{
"role": "assistant",
"content": "北京当前气温为 26℃。",
}

定义区分:

  • Function Calling 是模型与外部工具交互的接口机制
  • RAG 是利用外部知识增强生成结果的信息处理架构
  • Agent 是由模型负责决策和控制任务执行过程的系统架构

在 RAG 和 Agent 中都可以用到 Function Calling

区分:

  • RAG 的典型流程:用户问题 → 检索文档 → 拼接上下文 → 生成回答,不一定利用Function Calling
  • Agent 的典型流程:目标 → 决策 → 调工具 → 观察结果 → 再决策,根据目标和中间结果,动态选择步骤和工具

LLaMa

架构和GPT-3相同,但是用了一些不同的组件

旋转位置编码 RoPE

不是把位置向量加到词向量上,而是根据 token 的位置,对注意力中的 $Q$ 和 $K$ 向量进行旋转

  • 绝对位置控制旋转角度;
  • 在注意力分数中自然形成相对位置信息

相对传统Transformer使用的加法位置编码,RoPE不把位置向量加进 token,利用位置信息旋转 $Q/K$

对于一个二维向量,二维旋转矩阵为:
$$
R(\phi)=\begin{bmatrix}
\cos\phi & -\sin\phi\\
\sin\phi & \cos\phi
\end{bmatrix}
$$

重点,因为二维旋转矩阵是一个正交矩阵,RoPE 改变向量的方向和相位,但不改变其欧氏范数

旋转后的向量为:
$$
R(\phi)x = \begin{bmatrix}
x_1\cos\phi-x_2\sin\phi\\
x_1\sin\phi+x_2\cos\phi
\end{bmatrix}
$$
因为输入向量通常不是二维,而是高维,RoPE 会把向量的维度两两分组,每一对维度都视为一个二维平面,然后分别旋转

对于第 $i$ 对维度:
$$
\begin{bmatrix}x’{2i}\\x’{2i+1}
\end{bmatrix}=\begin{bmatrix}
\cos(m\theta_i) & -\sin(m\theta_i)\\
\sin(m\theta_i) & \cos(m\theta_i)
\end{bmatrix}
\begin{bmatrix}
x_{2i}\\
x_{2i+1}
\end{bmatrix}
$$
不同的二维子空间使用不同的旋转频率:
$$
\theta_i=b^{-\frac{2i}{d_h}}
$$
原始设计取 $b=10000$,不同频率的作用类似于传统正弦位置编码:

  • 高频维度变化快,容易区分附近位置
  • 低频维度变化慢,可以覆盖较长距离

位置 $m$ 的 query 为 $q_m$,位置 $n$ 的 key 为 $k_n$,经过 RoPE 后
$$
\tilde{q}_m = R_m q_m \qquad \tilde{k}_n = R_n k_n
$$
旋转后的注意力点积为:
$$
\tilde{q}_m^{\top}\tilde{k}n= (R_m q_m)^{\top}(R_n k_n) \rightarrow \tilde{q}m^{\top}\tilde{k}n= q_m^{\top}R_m^{\top}R_n k_n
$$
旋转矩阵满足:
$$
\color{red} R_m^{\top} = R
{-m} \qquad R
{-m}R_n = R
{n-m}
$$
所以点积时可以反映信息
$$
\tilde{q}_m^{\top}\tilde{k}n= q_m^{\top}R{n-m}k_n
$$

Su J, Ahmed M, Lu Y, et al. Roformer: Enhanced transformer with rotary position embedding[J]. Neurocomputing, 2024, 568: 127063.

https://arxiv.org/abs/2104.09864

优点:

  • 自然表达相对位置,无需额外维护相对位置 embedding 表
  • 不改变向量长度
  • 不需要学习位置参数

局限:

  • 旋转具有周期性
  • 上下文窗口拓展性受限

RMSNorm

$$
\operatorname{RMSNorm}(x)=\gamma\odot\frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d}x_i^2+\epsilon}}
$$

  • LayerNorm 同时做“减均值”和“缩放方差”
  • RMSNorm 省略减均值,只根据均方根缩放

RMSNorm 可以看作 LayerNorm 的简化版本,且现代LLM基本都采用了RMSNorm

减均值这个操作有三个坏处:

  • 打扰注意力机制的结构
  • 性能变慢
  • 对梯度反而不稳定

分组查询注意力 GQA

标准多头注意力中,输入隐藏状态 $X$ 分别投影成 $Q, K, V$

GQA 不改变注意力本身公式,但Query head 的数量和 Key/Value head 的数量不再必须相同,且通常有 $H_{KV}<H_Q$

多个 Query heads 被分成若干组,每一组共享一个 Key head 和一个 Value head

现在大模型高效注意力大致形成了 GQA、MQA、MLA 以及局部注意力等多条路线

MHA MQA GQA(4Q共享为例)
Q1 → K1, V1
Q2 → K2, V2
Q3 → K3, V3
Q4 → K4, V4
Q5 → K5, V5
Q6 → K6, V6
Q7 → K7, V7
Q8 → K8, V8
Q1 ─┐
Q2 ─┤
Q3 ─┤
Q4 ─┤
Q5 ─┤──→ 唯一的 K1, V1
Q6 ─┤
Q7 ─┤
Q8 ─┘
Q1 ─┐
Q2 ─┤
Q3 ─┤──→ K1, V1
Q4 ─┘

Q5 ─┐
Q6 ─┤
Q7 ─┤──→ K2, V2
Q8 ─┘

主要目的是为了减少 KV Cache

MQA 的 KV Cache 最小,但共享程度太高,可能限制不同注意力头表达不同类型的信息

GQA 的思想和Resnext很像,常见的就是4Q/8Q共享KV,多个 Query 头可以提出不同的问题,但必须使用同一套索引方式和信息表示

很多情况下:

  • 多个 KV 头的投影空间相似;
  • 不同头可能关注相近的位置;
  • Query 头的差异比 KV 头的差异更重要;

所以共享KV不会带来太大的性能下降,反而因为KV cache的减少能带来更多收益

SwiGLU

传统 Transformer FFN 通常写成:
$$
\operatorname{FFN}(x)=W_2\phi(W_1x+b_1)+b_2
$$
普通 FFN 只有一条升维分支

SwiGLU加入门控,门控 FFN 则使用两条分支

1
2
3
4
5
           +--> ---------内容分支 ----------+
| |
输入 x ----+ * --> 降维 --> 输出
| |
+--> 门控分支 --> 激活函数 -------+

一条分支产生内容,一条分支决定这些内容应该保留、放大还是抑制

标准 GLU 可以写成:
$$
\operatorname{GLU}(x)=(W_v x+b_v)\odot\sigma(W_g x+b_g)
$$
SwiGLU 将门控分支中的激活函数换成 SiLU
$$
\operatorname{SwiGLU}(x)=\operatorname{SiLU}(W_g x)\odot(W_u x)
$$

第 $i$ 个专家可以写成
$$
E(x)=W_{\text{down}}
\left[\operatorname{SiLU}(W_{\text{gate}}x)\odot(W_{\text{up}}x)\right]
$$

混合专家结构 MoE

在LLaMa4中加入

在普通稠密 Transformer 中,每个 Token 都经过同一个 FFN

导致的问题:

  • 所有 Token 使用相同参数
  • 增大 FFN 参数量时,每个 Token 的计算量也同步增加
  • 总参数量与单 Token 计算量紧密绑定

MoE 的目标是增加大量参数容量,但每个 Token 只激活其中一小部分,通过训练一个 Router,让输入选择少量专家

实现总参数量大幅增长,而单样本计算量不会按同样比例增长

1
2
3
4
5
6
7
8
9
10
11
12
13
14
输入 Token x

Router
|
+--------> Expert 1
+--------> Expert 2
+--------> Expert 3
|
...
|
+--------> Expert N
Router 只选择 Top-K 个专家

专家输出加权求和

一个 Token 会经过:

  1. 始终启用的 Shared Expert,共享专家;(非必须)
  2. 经过 Router 选出的 Routed Expert,路由专家

Router 通常是一个很小的线性层
$$
z=W_r x\qquad W_r\in\mathbb{R}^{E\times d}
$$
${z}$ 中包含每个专家的路由分数,通常经过 Softmax 再选出 Top-k
$$
y(x)=\sum_{i\in \operatorname{TopK}(g(x))}p_i(x)E_i(x)
$$
Top-1 的情况下

优点 缺点
计算最少;
通信简单;
单个专家错误会直接影响输出;
容易出现专家负载不均;
路由选择更离散;

专家分工是训练中自动形成的,并不一定具有稳定、清晰的人类语义标签

主要训练问题:路由不均衡,热门专家过载,冷门专家得不到充分训练;

部分现代 MoE 不只使用路由专家,还增加一个或多个共享专家,减少路由专家间的知识冗余

1
2
3
                    +--> Router --> Routed FFN Experts --+
x --> RMSNorm ------+ +--> 相加
+--> Shared FFN Experts -------------+

$$
y(x)=\sum_{j=1}^{N_s}E^{\mathrm{shared}}j(x)+\sum{i\in\operatorname{TopK}(g(x))}p_i(x)E^{\mathrm{routed}}_i(x)
$$

DeepSeekMoE 将这个设计称为 Shared Expert Isolation,共享专家隔离

分布式训练框架

预训练一个 LLM 所需要的算力资源极其庞大,需要分布式训练

分布式训练的两条基本思路:

  • 数据并行:模型复制,数据切分
  • 模型并行:数据可以相同或分成 micro-batch,模型本身被切分

数据并行

主要就是Pytorch 的 DDP

每张 GPU:

  1. 保存一份完整模型;
  2. 读取不同的数据;
  3. 独立进行前向和反向传播;
  4. 通过 AllReduce 聚合梯度;
  5. 获得相同梯度后,各自更新模型。
1
2
3
4
GPU 0: batch 0 → gradient G0 ─┐
GPU 1: batch 1 → gradient G1 ─┤
GPU 2: batch 2 → gradient G2 ─┼→ AllReduce → 平均梯度(g1,g2,g3,g4) → 各自更新
GPU 3: batch 3 → gradient G3 ─┘

$$
g=\frac{1}{P}\sum_{p=1}^{P}G_p
$$

前提是:完整模型及其训练状态能放入单卡

模型并行

当数据并行分片仍然不够,或者单个层本身太大,就需要模型并行

张量并行 TP:将一个矩阵运算拆到多张 GPU

比如:

  • MLP层:将权重矩阵 W 按列切分为 [W1,W2] ,分别放置于不同GPU
  • Attention层:将多头注意力切分到不同设备

流水线并行 PP:按照模型层切分

1
2
3
4
5
6
7
输入


GPU 0 GPU 1 GPU 2 GPU 3
Layer 0~7 ---> Layer 8~15 ---> Layer 16~23 ---> Layer 24~31
│ │
└────────────────────── 反向传播 <────────────────────┘

PP 的主要问题是 pipeline bubble,流水线气泡

1
2
3
4
5
6
时间      0    1    2    3    4    5    6    7
---------------------------------------
GPU 0 F -- -- -- -- -- -- B
GPU 1 -- F -- -- -- -- B --
GPU 2 -- -- F -- -- B -- --
GPU 3 -- -- -- F B -- -- --
1
2
3
F  = 整个 batch 的前向传播
B = 整个 batch 的反向传播
-- = GPU 空闲

大部分时间只有一张 GPU 在工作,设备利用率很低

把一个 batch 拆成多个 micro-batch,使不同流水线阶段同时处理不同 micro-batch

1
2
3
4
5
6
时间      0    1    2    3    4    5    6    7    8    9   10   11   12   13
---------------------------------------------------------------------
GPU 0 F0 F1 F2 F3 -- -- -- -- -- -- B3 B2 B1 B0
GPU 1 -- F0 F1 F2 F3 -- -- -- -- B3 B2 B1 B0 --
GPU 2 -- -- F0 F1 F2 F3 -- -- B3 B2 B1 B0 -- --
GPU 3 -- -- -- F0 F1 F2 F3 B3 B2 B1 B0 -- -- --

增加 micro-batch 数量可以摊薄填充和排空气泡,但也会导致激活显存、调度复杂度和优化器更新延迟增加

序列/上下文并行

对于长上下文模型,激活和注意力矩阵可能成为显存瓶颈,因此可以沿序列维度切分

1
2
3
4
5
6
7
8
9
完整序列:
[token 0 ... token 32767]

GPU 0:
[token 0 ... token 8191]

GPU 1:
[token 8192 ... token 16383]
...

但注意力需要 token 之间相互交互,因此会引入 AllGatherAllToAll 或其他通信

  • AllGather:每张 GPU 把自己的数据分享给所有 GPU,最终所有 GPU 都拥有完整数据
  • AllToAll:每张 GPU 把不同的数据块分别发送给不同 GPU,最终数据在 GPU 之间重新分布

专家并行 EP

MoE的专家通常不在一张 GPU 上,Token 根据路由结果被发送到对应 GPU

1
2
3
4
GPU 0: Experts 0, 1
GPU 1: Experts 2, 3
GPU 2: Experts 4, 5
GPU 3: Experts 6, 7

token 需要从原 GPU 去往专家 GPU,专家计算完成后又要返回原 GPU,所以 MoE 的 FLOPs 可能较低,但端到端速度不一定按理论比例提升

零冗余优化器(ZeRO)

ZeRO 在数据并行基础上,通过“分片保存模型状态”降低单张 GPU 显存占用

普通 DDP 中,每张 GPU 都保存相同的三类东西

1
2
3
P:模型参数 Parameters
G:梯度 Gradients
O:优化器状态 Optimizer States
1
2
3
4
5
6
7
8
9
GPU 0:
参数 p0 p1 p2 p3 p4 p5 p6 p7
梯度 g0 g1 g2 g3 g4 g5 g6 g7
优化器 O0 O1 O2 O3 O4 O5 O6 O7

GPU 1:
参数 p0 p1 p2 p3 p4 p5 p6 p7
梯度 g0 g1 g2 g3 g4 g5 g6 g7
优化器 O0 O1 O2 O3 O4 O5 O6 O7

ZeRO 将数据并行中的冗余状态逐步分片,分为三种不同的配置

ZeRO-1

1
2
3
4
GPU 0:[完整参数] [完整梯度] [O0 O1]
GPU 1:[完整参数] [完整梯度] [O2 O3]
GPU 2:[完整参数] [完整梯度] [O4 O5]
GPU 3:[完整参数] [完整梯度] [O6 O7]

每张 GPU 只保存四分之一的优化器状态,参数和梯度依然是完整复制的

ZeRO-2

1
2
3
4
GPU 0:[完整参数] [g0 g1] [O0 O1]
GPU 1:[完整参数] [g2 g3] [O2 O3]
GPU 2:[完整参数] [g4 g5] [O4 O5]
GPU 3:[完整参数] [g6 g7] [O6 O7]

这里的g已经是平均后的了

使用 ReduceScatter聚合完成后直接拆开

ZeRO-3

把参数也分片,每张 GPU 平时只保存一部分模型状态

1
2
3
4
GPU 0:[p0 p1] [g0 g1] [O0 O1]
GPU 1:[p2 p3] [g2 g3] [O2 O3]
GPU 2:[p4 p5] [g4 g5] [O4 O5]
GPU 3:[p6 p7] [g6 g7] [O6 O7]

但是计算前需要 AllGather

大模型微调

  • 继续预训练:没有人工标签,仅输入额外文本,模型只是继续进行语言模型训练,适合学习领域术语、特殊语言
  • 监督微调 SFT:用已经完成预训练的大语言模型,使用人工整理的“问题-答案”数据继续训练
  • 强化学习微调 RL:让模型根据奖励改进,奖励可以来自多种来源,最主要是RLHF(基于人类反馈的强化学习)和PPO(近端策略优化)

SFT数据集

单条数据评估视角:规范性、完整性、及时性、安全性、有效性、唯一性

数据集评估视角:多样性(领域知识)、平衡性(领域数据集数量)、一致性、正交性(领域能力互不影响)、必要性(数据价值)

在数据筛选时需要用到

  • 困惑度:形式上和交叉熵损失一致,困惑度与交叉熵损失是指数关系
    $$
    P_i(x)=\exp\left(-\frac{1}{T}\sum_{t=1}^{T}\log p(x_t\mid x_{<t})\right)
    $$

  • 学习百分比:判断一条 SFT 数据的学习难度
    $$
    \operatorname{LP}(i)=\frac{P_{i-1}-P_i}{P_0-P_n}
    $$
    分子表示第 $i$ 个epoch内,困惑度的下降,一般选择 $LP(1)$

  • 数据难度可转移性:不同规模模型对数据难易程度的相对排序具有较强一致性,所以可以用小模型来筛选,降低成本

    Smaller Language Models are capable of selecting Instruction-Tuning Training Data for Larger Language Models - ACL Anthology

RL算法

算法 中文名 是否需要价值模型 核心思想
PPO 近端策略优化 通常需要 限制每次策略更新幅度
GRPO 组相对策略优化 不需要 同一问题的多个回答组内比较
DAPO 解耦裁剪与动态采样策略优化 不需要 GRPO + 非对称裁剪 + 动态采样 + 长序列优化

PPO常见的目标函数是
$$
L_{\mathrm{PPO}}(\theta)=\mathbb{E}_t\left[\min\left(r_t(\theta)\hat A_t,;\operatorname{clip}\left(r_t(\theta),1-\epsilon,1+\epsilon\right)\hat A_t\right)\right]
$$

Tokenizer

主要有以下几类切分方法

  • 词级切分(Word-level)
  • 字符级切分(Character-level)
  • 子词切分(Subword-level)
  • 字节级切分(Byte-level)

目前大多数大语言模型使用的是子词或字节级方法

词级切分

将文本按空格和标点符号分割成单词

  • 优点在于简单直接;
  • 缺点很明显,无法处理未登录词(不在词表的词)和罕见词,对复合词或缩略词的处理也不够精细;
1
2
I love machine learning
["I", "love", "machine", "learning"]

现在的大语言模型很少使用纯词级 Tokenizer

字符级切分

将每个字符作为一个 Token,能非常精细地处理文本,适用于处理拼写错误、未登录词或新词

对于一些特定的应用场景,如生成式任务或需要处理大量未登录词的任务,特别有用

  • 优点在于词表会非常小,几乎不会出现未知的token,对拼写错误、罕见单词比较鲁棒;
  • 缺点是让 token 序列变得非常长,增加了模型的计算复杂度和训练时间;
1
2
hello
["h", "e", "l", "l", "o"]

子词切分

子词方法介于词级和字符级之间

  • 常见单词保留为完整 Token
  • 罕见单词拆成多个有意义或高频的片段

常见的子词分词方法包括 BPE、WordPiece 和 Unigram

BPE

BPE 全称是 Byte Pair Encoding,核心思想是:从字符开始,不断合并语料中出现频率最高的相邻 Token 对

优点在于其简单和高效,能够有效地处理未知词和罕见词,同时保持较低的词典大小

1
2
3
low
lower -> low + er
lowest -> low + est

在 GPT-2 上得到应用

WordPiece

与 BPE 很相似,但它选择合并项时,不只是看绝对频率,更关注合并后是否能提高语言模型对语料的解释能力

在简化理解下,可以把候选合并分数写成
$$
\operatorname{score}(a,b)=\frac{\operatorname{freq}(ab)}
{\operatorname{freq}(a)\operatorname{freq}(b)}
$$

1
2
playing
["play", "##ing"]

"##ing" 表示这个子词不能单独作为单词开头,而是接在其他 Token 后面

在 BERT 上得到应用

Unigram

Unigram 方法与 BPE 的方向相反

Unigram 是从一个较大的候选子词集合开始,不断删除不重要的子词