09|微调与对齐:从“会接龙”到“会聊天”

《大模型原理通俗讲》系列第 9 篇,共 16 篇。不需要数学基础,会用大模型聊天就能读。上一篇讲了预训练:模型怎么靠海量“猜下一个词”练出一身本事。这一篇回答:练完了,为什么它还不能直接当助手用?

上一篇的最后,我们说,预训练得到的叫基础模型,它擅长续写。

一个新问题就来了:你平时用的聊天产品,你问它问题,它会回答。可一个只会“续写”的模型,你问它问题,会发生什么?

我们的迷你模型(下面会有代码)给了一个很形象的答案:

你输入:  问 : 法国 的 首都 是 哪里 ?
基础模型:问 : 日本 的 首都 是 哪里 ? 问 : 意大利 的 首都 是 ……

它没有回答你,而是接着写出了更多的问题。因为在它读过的文字里,一个问题后面,常常跟着另一个问题(比如题库、问答列表)。对它来说,“续写”就是任务,“回答”不是。

读完这一篇,你能回答三个问题:

  1. 聊天助手和基础模型,差在哪里?
  2. “微调”教的是什么?
  3. “对齐”是在对齐什么?

一、从“会接龙”到“会聊天”:三个阶段

先给你一个比喻:一个读了万卷书的毕业生。

  • 预训练 = 读书。 他博览群书,见多识广,但还不懂得怎么在工作里跟人打交道:别人问他问题,他可能滔滔不绝地讲起相关的另一个话题。
  • 微调 = 岗前培训。 带他看几十个“标准答复”的例子:客户这样问,应该这样答,答完就停。他的“知识”没怎么变,但他知道怎么回答了。
  • 对齐 = 带教老师的反馈。 同一个问题,他有时答得好,有时答得啰嗦,有时答得生硬。老师对比着告诉他:这样更好,那样不太好。慢慢地,他的回答越来越让人满意。

整个过程画出来,是这样:
在这里插入图片描述

注意,三个阶段调整的,是同一批数字(参数)。不是三个模型,而是同一个模型,一步步被调成了不同的“性格”。


二、先看清:“聊天”到底是什么

在讲微调之前,要先清楚一件事:你在聊天窗口里看到的“一问一答”,在模型眼里是什么?
在这里插入图片描述

答案是:一段文字。 产品会把整个对话,拼成一段带有“角色标记”的文字(这些标记怎么写,每个模型各不相同,图里只是示意):

  • 最前面,可能有一行系统提示词,告诉它扮演什么角色;
  • 然后是“用户”说的话;
  • 最后留一个“助手”的位置,让模型从这里开始续写,一直写到“结束”标记为止。

所以,聊天的底层,仍然是第 01 篇的那个接龙。区别只是:被续写的这段文字,是一份“剧本”,轮到“助手”这一行时,模型写出的就是回答。

下一轮你再提问,前面所有轮的内容,会一起重新拼进去再读一遍(这就是后篇要讲的“上下文”)。

那“微调”要教的,就很清楚了:让模型认得这种剧本格式,轮到助手时,写出一个像样的回答,然后停下来。


三、第二阶段:微调(SFT)

微调,英文常叫 SFT(监督微调)。它做的事情很简单:

准备一批示范对话:一个问题,配一个理想的回答。然后,在这些示范上,继续做第 08 篇那一流程:看前文,猜下一个词,对答案,微调数字。

和预训练比,有几个不同:

  • 数据少得多。 预训练是海量文本;微调通常只用少得多的示范对话(具体多少,各家差别很大)。
  • 数据是“精挑细选”的。 每条都是一个问题加一个好回答,通常由人写,或者由人审核筛选。
  • 练的是“回答的样子”。 常见做法是:只在“回答”那部分计算扣分,因为我们要它学的是怎么答,而不是怎么提问。

还有一个很重要的认识:

微调主要改变的是模型的“行为”:格式、语气、怎么开头、怎么结束。它需要的新知识很少,大部分的知识,来自预训练。

这是一个常见的看法,也有很多实践印证;但它不是绝对的规律。下面的小实验,就是为了让你亲眼看到这件事。


四、动手:用 3 条示范,教会一个小模型“回答”

我们做一个迷你版(需要 numpy)。它只有几十个词、几行语料,前面的套路都一样:向量、概率表、猜下一个词、对答案、微调数字。

流程是:

  1. 预训练: 先在一些杂乱的文字上训练:有陈述句(“法国的首都是巴黎”),也有问题列表(“问:法国的首都是哪里?问:日本的首都是哪里?……”)。
  2. 微调: 再只用 3 条示范对话(法国、日本、德国),继续训练。注意:示范里没有“意大利”。
  3. 对比: 同样问“法国”和“意大利”,看微调前后有什么不同。
# mini_sft.py —— 迷你"微调":先在杂乱的文本上预训练,再用几条示范对话微调,看行为怎么变
import numpy as np

BASE = ["法国 的 首都 是 巴黎 。", "日本 的 首都 是 东京 。", "德国 的 首都 是 柏林 。", "意大利 的 首都 是 罗马 。"] * 3 + [
        "问 : 法国 的 首都 是 哪里 ? 问 : 日本 的 首都 是 哪里 ? 问 : 意大利 的 首都 是 哪里 ?",
        "问 : 德国 的 首都 是 哪里 ? 问 : 意大利 的 首都 是 哪里 ? 问 : 法国 的 首都 是 哪里 ?",
        "问 : 日本 的 首都 是 哪里 ? 问 : 德国 的 首都 是 哪里 ? 问 : 法国 的 首都 是 哪里 ?"]
SFT = [f"问 : {c} 的 首都 是 哪里 ? 答 : {a} 。 <结束>" for c, a in [("法国", "巴黎"), ("日本", "东京"), ("德国", "柏林")]]   # 注意:没有"意大利"
vocab = sorted({w for s in BASE + SFT for w in s.split()} | {"好的", "不知道", ","}); idx = {w: i for i, w in enumerate(vocab)}; V = len(vocab)
# 让小模型也能"举一反三":给"首都名"这一类词一个共享通道(真实模型里,这种"同类相通"是自己学出来的,不用人指定)
Mc = np.zeros((1, V)); Mc[0, [idx[c] for c in ["巴黎", "东京", "柏林", "罗马"]]] = 1

def feats(prefix):                                    # 模型"看到"的:前文里出现过哪些词 + 最后一个词是什么
    f = np.zeros(2 * V)
    for w in prefix: f[idx[w]] = 1
    f[V + idx[prefix[-1]]] = 1
    return f

def probs(W, prefix):
    z = feats(prefix) @ W
    p = np.exp(z - z.max()); return p / p.sum()

def train(W, texts, steps, lr):                       # 和第 08 篇一样:猜下一个词,对答案,微调一点点
    xs, ys = [], []
    for t in texts:
        ws = t.split()
        for i in range(1, len(ws)): xs.append(feats(ws[:i])); ys.append(idx[ws[i]])
    X, y = np.array(xs), np.array(ys)
    for _ in range(steps):
        z = X @ W; p = np.exp(z - z.max(axis=1, keepdims=True)); p /= p.sum(axis=1, keepdims=True)
        p[np.arange(len(y)), y] -= 1
        g = X.T @ p / len(y)
        W = W - lr * (g + g @ Mc.T @ Mc)                 # 同类的词(首都名)共享一部分更新
    return W

def write(W, prompt, n=14):                           # 永远选最高的(第 07 篇的 T = 0)
    ws = prompt.split()
    while len(ws) < len(prompt.split()) + n and ws[-1] != "<结束>":
        ws.append(vocab[int(probs(W, ws).argmax())])
    return " ".join(ws[len(prompt.split()):])

if __name__ == "__main__":
    base = train(np.zeros((2 * V, V)), BASE, 800, 2.0)           # 预训练:杂乱的文本
    tuned = train(base, SFT, 100, 2.0)                           # 微调:只用 3 条示范对话
    for c in ["法国", "意大利"]:
        q = f"问 : {c} 的 首都 是 哪里 ?"
        print(f"{q}\n  预训练后:{write(base, q)}\n  微调之后:{write(tuned, q + ' 答 :')}\n")

运行结果(我实际跑过):

问 : 法国 的 首都 是 哪里 ?
  预训练后:问 : 日本 的 首都 是 哪里 ? 问 : 意大利 的 首都 是
  微调之后:巴黎 。 <结束>

问 : 意大利 的 首都 是 哪里 ?
  预训练后:问 : 日本 的 首都 是 哪里 ? 问 : 法国 的 首都 是
  微调之后:罗马 。 <结束>

在这里插入图片描述

关注下面三件事:

  1. 微调之前: 问“法国的首都是哪里”,它续写出更多问题。和文章开头说的一样。
  2. 微调之后: 它答出“巴黎。”,然后停下来(“结束”)。3 条示范,就教会了它“轮到回答时,该怎么答、答完就停”。
  3. 最有意思的:“意大利”从没出现在示范里,它却答对了“罗马”。 因为“意大利的首都是罗马”这个知识,早在预训练时就学到了;微调教它的,是“该把这个知识说出来”。 这正是前面说的:知识大头来自预训练,微调主要改变行为。

要说明下:为了让这个极小的模型也能“举一反三”,我给“首都名”这一类词加了一个共享通道(代码里的 Mc,让同类的词共享一部分更新)。我试过,去掉它,小模型答不出“意大利”,只会输出一个句号。真实的大模型里,这种“同类相通”不用专门设计,是训练中自己形成的。


五、第三阶段:对齐

微调之后,模型已经会回答了。那为什么还需要“对齐”?

因为会回答,不等于答得好。 示范对话是人写的,风格不一:有的简短,有的啰嗦;有的礼貌,有的生硬;甚至有的偷懒,直接说“不知道”。模型学了这些,就会写出什么风格都有的回答。

我们想要的,是“更有用、更诚实、更不越界”的回答(这是常见的说法,各家的具体准则不一样)。可这很难直接教:

  • “什么叫更好”,很难写成规则。 你很难写一份清单,列出一个好回答的所有标准。
  • 但人很擅长“比较”。 给你两个回答,让你选哪个更好,比让你亲手写出一个完美回答,容易多了。

所以,对齐的思路是:让人来比较,再让模型朝着“人更喜欢的方向”靠拢。
在这里插入图片描述

最经典的做法叫 RLHF(人类反馈强化学习),大致分四步:

  1. 同一个问题,让模型写多个回答;
  2. 人来比较:哪个更好?排个序;
  3. 用这些比较,训练一个**“打分员”模型**,学会给回答打分(模仿人的偏好);
  4. 用打分员的分数,调整原来的模型:高分的回答,更容易被写出来。

还有一种更简单的做法叫 DPO:不单独训练打分员,直接用“哪个更好、哪个更差”的成对数据,调整模型。两者的细节不同,思想一致。


六、动手:用“挑最好的、再学一遍”,做一个迷你对齐

真实的 RLHF 太复杂,没法在一篇文章里手写。我们用最简单的版本,演示同一个思想:让模型写多个回答,挑出最好的,再让它学一遍。(这种“挑最好再学”的做法,实践中也有人用,但真实系统要复杂得多。)

设定:

  • 微调用的示范,风格故意混杂:有的简短(“巴黎。”),有的礼貌(“好的,巴黎。”),有的偷懒(“不知道。”),比例大约是 5:3:2。
  • “人的偏好”,我用一条规则代替:答对 +2 分,礼貌(含“好的”)+1 分,说“不知道”扣 2 分。
  • 做法:每个问题,让模型抽签写 8 个回答(第 07 篇),挑得分最高的,再学一遍。
# mini_align.py —— 迷你"对齐":让人更喜欢的回答更容易出现(最简单的办法:多写几个、挑最好的、再学一遍)
import numpy as np
from mini_sft import BASE, V, vocab, probs, train           # 沿用上一段的小模型(两个文件放在同一个文件夹)

CAP = {"法国": "巴黎", "日本": "东京", "德国": "柏林", "意大利": "罗马"}
STYLES = [("{a} 。", 5), ("好的 , {a} 。", 3), ("不知道 。", 2)]            # 不同标注员的写法:简短、礼貌、偷懒,各有几条
MIXED = [f"问 : {c} 的 首都 是 哪里 ? 答 : " + s.format(a=CAP[c]) + " <结束>" for c in ["法国", "日本", "德国"] for s, n in STYLES for _ in range(n)]
sft = train(train(np.zeros((2 * V, V)), BASE, 800, 2.0), MIXED, 100, 2.0)   # 预训练 → 微调(示范里什么风格都有)
rng = np.random.default_rng(0)

def sample(W, c):                                          # 按概率抽签写一个回答(第 07 篇,T = 1)
    ws = f"问 : {c} 的 首都 是 哪里 ? 答 :".split(); n0 = len(ws)
    while ws[-1] != "<结束>" and len(ws) < n0 + 8:
        ws.append(vocab[rng.choice(V, p=probs(W, ws))])
    return " ".join(ws[n0:])

def score(c, ans):                                         # 这里用规则当"人"的偏好:答对 +2,礼貌 +1,说不知道 −2
    return 2 * (CAP[c] in ans) + ("好的" in ans) - 2 * ("不知道" in ans)

def report(W, name):                                       # 每个问题各写 400 次,看看各种回答占几成
    outs = [(c, sample(W, c)) for c in CAP for _ in range(400)]
    kinds = {"礼貌且答对": lambda c, a: score(c, a) == 3, "简短但答对": lambda c, a: score(c, a) == 2, "说不知道": lambda c, a: score(c, a) == -2}
    print(f"{name}:" + "  ".join(f"{k} {sum(f(c, a) for c, a in outs) / len(outs):.0%}" for k, f in kinds.items()))
    ital = [a for c, a in outs if c == "意大利"]          # 「意大利」从没出现在示范里,单独看一眼
    print("    其中「意大利」:礼貌且答对 " + f"{sum(score('意大利', a) == 3 for a in ital) / len(ital):.0%}")

report(sft, "对齐之前")
best = []
for _ in range(10):                                        # 每个问题写 8 个回答,挑得分最高的那个
    for c in ["法国", "日本", "德国"]:
        cands = [sample(sft, c) for _ in range(8)]
        best.append(f"问 : {c} 的 首都 是 哪里 ? 答 : " + max(cands, key=lambda a: score(c, a)))
report(train(sft, best, 100, 2.0), "对齐之后")             # 再学一遍"最好的"

(这段代码要和上一段 mini_sft.py 放在同一个文件夹里。)

运行结果(实测过,随机种子固定):

对齐之前:礼貌且答对 24%  简短但答对 41%  说不知道 16%
    其中「意大利」:礼貌且答对 26%
对齐之后:礼貌且答对 79%  简短但答对 8%  说不知道 0%
    其中「意大利」:礼貌且答对 68%

重点:

  • 对齐之前: 礼貌且答对 24%,简短但答对 41%,说不知道 16%(剩下的是别的写法)。什么风格都有,和示范的混杂程度对得上。
  • 对齐之后: 礼貌且答对升到 79%,说不知道降到 0%。回答的整体风格,朝着“得分高”的方向移动了。
  • 而且“意大利”也变好了: 它从没出现在示范里,也没出现在挑选里,礼貌且答对的比例,仍然从 26% 升到了 68%。说明学到的是“什么样的回答更受欢迎”这种倾向,而不是死记某个问题的答案。

要说明白两点:

  1. “人的偏好”在这里是自定的规则(礼貌加分、说不知道扣分)。这也点出了对齐的一个真问题:模型对齐的,是“谁的偏好”? 真实系统里,是标注人员的判断,和制定准则的公司的规定。
  2. 这是极简版。 真实的 RLHF、DPO 要复杂得多,我们只是借这个小实验,让你看到“用偏好改变回答风格”这件事真的会发生。

七、对齐的局限,要知道

对齐很重要,但它不是万能的。几个值得知道的局限:

  • 可能变得过于讨好。 因为“人更喜欢被认同”,有研究者观察到,经过对齐的模型,有时倾向于顺着用户的话说,哪怕用户错了。
  • 可能过于谨慎。 为了“不越界”,有时会拒绝本来可以回答的问题。
  • 不能消灭“胡说”。 对齐能让回答更礼貌、更自信,却不保证更真实。一个答得很有礼貌的错误答案,反而更容易让人相信(第 11 篇讲幻觉)。
  • 不是万无一失。 有人会尝试各种办法绕过它的限制,所以安全是个持续的工作,不是一劳永逸的。

八、一个常被混淆的问题:提示词 vs 微调

你可能会问:我给模型写一段“你是一位专业的客服”,和微调,不都是在“教它怎么回答”吗?

区别很大:

  • 提示词: 只是在对话的最前面加一段文字(上面“剧本”里的系统那一行)。模型里的数字一个都没变,换一个对话就没了。
  • 微调: 真的改了模型里的数字,改完就一直在。

这个区别,后面会详细讲。现在你只需要记住:一个改的是“输入”,一个改的是“模型本身”。


九、毕业生这个比喻,到哪里为止

  1. 岗前培训并不是“完全不教新东西”。 微调也能让模型学到一点新东西,只是它的主要作用,是改变行为。
  2. 毕业生知道为什么,模型只是被调数字。 他明白“为什么这样答更好”;模型没有这种理解,只是数字被调成了倾向于某类回答。
  3. “对齐”不等于“有了价值观”。 它只是让模型更倾向于写出某种人们期望的回答。模型是不是“真的明白对错”,是另一个问题。

十、三个常见误解

误解 1:“聊天助手是另一种技术,和接龙不一样。”
不是。它的底层,仍然是同一个 Transformer,仍然是一个词一个词接龙。多出来的,是微调和对齐,让它学会了按“剧本”来答。

误解 2:“微调可以把新知识灌给模型。”
不太靠谱。微调主要改变的是行为和风格,很难可靠地“灌进”大量新事实。想让模型回答它没学过的资料,常用的办法是第 13 篇的 RAG。

误解 3:“对齐过了,就一定是准确、安全的。”
不是。对齐提高的是“更符合人的偏好”的可能性,不是保证。它可能更礼貌,但仍会出错,也仍可能被绕过。


十一、本篇要点

  1. 基础模型只会续写;要变成聊天助手,要再经过微调(教它怎么回答)和对齐(让回答更合人意)。
  2. 聊天的底层,仍然是接龙:对话被拼成一份“剧本”,模型从“助手”那一行开始续写。微调主要改变行为,知识大头来自预训练。
  3. 对齐靠“人来比较”,让更受欢迎的回答更容易出现。它有用,但有局限:可能讨好、可能过度谨慎、不能保证真实。

下一篇预告: 前面我们反复说“几十亿、上千亿个参数”“要很多 GPU”。这些数字到底是什么?为什么训练一个大模型这么贵?下一篇讲参数、算力与成本。

系列目录

在这里插入图片描述

文中的图、代码,欢迎自己动手改一改,比如把示范里的风格比例改一改,或者把打分规则换成“越短越好”,看看对齐之后回答会怎样变化。有哪里没讲明白,欢迎在评论区告诉我。

Logo

更多推荐