🦙 第4章 解决 Llama 2 大模型多轮对话难题
📄 一句话概括

这一节动手:写一份六段对话的数据、存成 JSON、转成 Llama 2 模板、报出六个训练前该知道的数。 中间有一个岔路要选:保多轮还是拆单轮。演示里两种都算了,代价和收益都不一样。

📝 本章和前面几章的关系

上一节讲了原则,这一节照着做一遍。 JSON 怎么读怎么写,17 课 编程原理 里讲过; 这一节的重点不是语法,是字段名这件事为什么改不得。

一、结构化:先定字段,再填内容

每条样本至少两个字段:id 和 conversations。 id 不参与训练,作用只有一个——出错时能定位到哪一条。 conversations 里每项两个字段:from 和 value。 from 取 human 与 gpt,这是社区约定,很多处理脚本只认这两个词。 也可以存成 JSONL,一行一条。 好处是能流式读,坏处是不能直接肉眼打开——几万条的时候编辑器会卡死,反而不好查。

字段名看着无所谓,其实是接口。 改一个名字,整条数据管道都要跟着改,而改的地方越多,漏改一处就越难发现。

二、两种构造法,各有各的代价

保多轮:一条样本是一整段对话。样本数少,但有上下文,模型能学到「接着上文说」。 拆单轮:把每轮拆成一条「问-答」。样本数翻几倍,训练更省 token,但把代词一起拆散了。 演示里的账:六段对话 584 token,拆成十二条 582 token,总 token 几乎一样。差别不在成本,在信息。

拆开之后,那条「那它要不要单独接地」失去了指代对象。模型学不到「它指什么」,只学到「看到它就答」。 比例怎么定:看业务里代词出现的频率。客服对话里「这个」「那个」满地都是,就得以多轮为主; 如果业务里用户每句都说得完整,拆单轮更划算。

三、转模板:系统提示只写一次

Llama 2 的约定是把系统提示塞进第一个 [INST] 里的 <<SYS>> 段, 后面每轮只写 [INST] 用户话 [/INST] 助手话 </s>。 只写一次不是省 token 的技巧,是模板本身的定义。写两次,模型会以为指令在变,输出风格来回跳。 转完要回头数 token:六段数据从 584 涨到 904,平均 1.55 倍。这个倍数直接乘在显存和时长上。

膨胀主要来自系统提示(每条一次)和 </s> 这类标记。 数据量一大,系统提示那几十个字就要乘以几万。

四、训练前报六个数字

总条数、总 token、最短与最长、平均、超上限的条数、含代词的条数。六个都要报。 报数不是为了好看,是为了横向比较。 同样是「几百条数据」,一条 50 token 和一条 500 token 是两个量级的工作量。 「含代词的条数」这一项最容易被漏,但它决定了你的数据能不能训出多轮能力——全是独立问句的数据, 训完模型依然不会处理「它」。

超上限的条数要报,并且要真的去看那几条被截断之后长什么样。 数字告诉你有没有问题,内容告诉你问题有多严重。

五、动手 Demo:真造一份多轮数据集:写盘、转模板、两种构造法

六段亮化工程现场的对话,二十四轮。 先写成 ShareGPT 形状的 JSON 真落盘,再转成 Llama 2 模板数 token, 最后比较保多轮与拆单轮两种构造法。 指代检测用的是关键词表(它、这个、那面、刚才…),不是语义理解——所以它只会多报,不会漏报, 这个方向的误差是可以接受的。

data = []
for cid, _topic, ms in DIALOGS:
    data.append({u'id': cid,
                 u'conversations': [{u'from': u'human' if r == U else u'gpt',
                                    u'value': t} for r, t in ms]})
put(root, u'train.json', json.dumps(data, ensure_ascii=False, indent=2))

# 转模板:SYS 只在第一个 [INST] 里
for i, (r, t) in enumerate(ms):
    if r == U:
        if i == 0:
            parts.append(u'<s>[INST] <<SYS>>\n%s\n<</SYS>>\n\n%s [/INST] ' % (SYS, t))
        else:
            parts.append(u'[INST] %s [/INST] ' % t)
    else:
        parts.append(u'%s </s>' % t)

# 拆单轮:每两轮拆成一条
for i in range(0, len(ms), 2):
    single.append((ms[i][1], ms[i + 1][1]))

实跑输出:

--------------------------------------------------------------------
真造一份多轮数据集:写盘、转模板、两种构造法
--------------------------------------------------------------------


  ① 六段对话
  --------------------------------------------------------------------
| 编号 | 话题 | 轮数 | token |
|---|---|---|---|
| d01 | 外立面改造 | 4 | 118 |
| d02 | 预算压缩 | 4 | 108 |
| d03 | 配电位置 | 4 | 97 |
| d04 | 验收准备 | 2 | 58 |
| d05 | 工期与到场 | 6 | 114 |
| d06 | 色温选择 | 4 | 89 |

  六段对话合计 24 轮,584 token。平均每轮 24 token——这个数决定了单条样本的长短。

  ② 写成 ShareGPT 形状的 JSON
  --------------------------------------------------------------------
  文件 3044 字节,6 条记录。
  ┌─ 头两条,原样摘录
  │ {
  │   "id": "d01",
  │   "conversations": [
  │     {
  │       "from": "human",
  │       "value": "甲方说第三版图纸的外立面要改"
  │     },
  │     {
  │       "from": "gpt",
  └─ …
  ! 存的时候用 human/gpt 这两个名字,是为了跟社区数据对齐——大多数处理脚本默认认这两个词,
  不认 user/assistant。

  ③ 转成 Llama 2 模板
  --------------------------------------------------------------------
| 编号 | 字符 | token | 膨胀 |
|:---:|---:|---:|---:|
| d01 | 172 | 171 | 1.45 |
| d02 | 167 | 162 | 1.50 |
| d03 | 159 | 150 | 1.55 |
| d04 | 110 | 106 | 1.83 |
| d05 | 196 | 173 | 1.52 |
| d06 | 154 | 142 | 1.60 |

  膨胀那一列是把系统提示、[INST]、</s> 都算进去之后,比原始对话多出来的倍数。
  ! 系统提示只在第一个 [INST] 里出现一次,不是每轮一次——这是 Llama 2 的约定,
  写错了模型会以为每轮都换了新指令。

  ④ 两种构造法:保多轮,还是拆单轮
  --------------------------------------------------------------------
| 构造法 | 条数 | 总 token | 指代问题 |
|---|---:|---:|---|
| 保多轮 | 6 | 584 | 0(有前文) |
| 拆单轮 | 12 | 582 | 1(拆开后指代悬空) |

  拆单轮总 token 反而少一点(系统提示不重复了),但 1 条里的「它」失去了指代对象。
  ! 这就是多轮数据的核心矛盾:保多轮省 token、有上下文,但样本数少;
  拆单轮样本多,但把「指代」这个能力训没了。两种都要有,比例看业务里代词出现的频率。

  ⑤ 训练前要报的几个数
  --------------------------------------------------------------------
| 指标 | 值 |
|---|---|
| 总样本 | 6 段 / 拆开后 12 条 |
| 总 token | 584 |
| 最短 / 最长 | 58 / 118 |
| 平均 | 97 |
| 超 512 的 | 0 |
| 含代词的 | 1 |

  ! 六段数据、几十个 token 的量级,只能用来验流程。
  真机上一份能训的数据至少几百条,但流程是先在小数据上跑通,再放大——放大之后出问题,
  你根本分不清是数据还是代码。

①六段对话二十四轮,最短 58 token,最长 118。平均每轮 24 token——这个数偏小,因为样例写得短; 真实对话一轮通常上百 token。 ②真落盘,3044 字节。注意摘录里 from 的值是 human 和 gpt,不是 user 和 assistant。 这不是笔误,是跟社区数据对齐——大多数处理脚本默认认这两个词。 ③转模板:膨胀 1.45 到 1.83 倍。 四轮的那几段倍数低(系统提示被摊薄),两轮的那段最高(1.83,系统提示占的比例大)。 数据越短,模板开销占比越高。 ④是这一节的核心对比。 保多轮六条 584 token,拆单轮十二条 582 token——总 token 几乎一样,条数翻了一倍。 但拆开之后有一条的「它」没有指代对象了。 这是拆单轮的隐性代价:你省了上下文,也把「理解上下文」这个能力从数据里删掉了。 ⑤六个数字里,「含代词的 1」是最该盯的一项。 它告诉你这份数据里只有一条能训指代——训出来的模型八成还是不会处理「它」。 最后那句提醒很重要:六段数据只能验流程。 放大到几百条之后出问题,你分不清是数据还是代码,所以流程一定要在小数据上先跑通。

30 秒实验

  1. 把 d05(六轮)拆成单轮,看总 token 和条数怎么变。 再想想:拆开之后「下雨怎么办」那一轮还能不能看懂?
  2. 把系统提示从三十字扩到两百字,重跑第③步。膨胀比涨到多少?按五千条数据算,多花多少 token?
  3. 给每条 JSON 加一个 meta 字段(来源、日期、标注人)。 看文件涨多少字节,再想想:这个字段该不该进训练?

常见误区

  1. 用 user/assistant 存数据。 社区脚本大多认 human/gpt,改名之后整条工具链都要改,而报错往往出现在很后面的环节。
  2. 系统提示每轮都写一遍。多花 token 是小事,模型以为指令在变是大事——输出风格会开始漂。
  3. 写完就训,不校验。 跑完发现一半的 from 拼错了、有三分之一是空回答,而训练过程一路正常,损失曲线也好看。
自测三问(点开对答案) 1. 为什么保多轮和拆单轮要混着用? —— 保多轮能训「理解上下文」和指代,但样本少;拆单轮样本多、成本低,但把上下文切断了。 两类都在,模型才既会答独立问题、又能接住「它」。 2. 模板膨胀 1.55 倍,主要花在哪? —— 两处:系统提示每条重复一遍,以及 ``、`[INST]` 这些标记。 数据越短,系统提示占的比例越高,膨胀比越大。 3. `id` 字段不参与训练,为什么还要有? —— 为了定位。几万条数据里有一条格式不对,没有 id 你只能一行行翻; 有 id,报错信息里带一个编号,直接跳到那一行。

数据造好了,下一节解决「怎么把它塞进上下文」——这是多轮对话里最费钱的一步。

相关笔记