【案例】新媒体评论分类与信息抽取 · 数据工程

会写文章的大模型也要按表格交差——Instruction 三段式工单、input_ids 与 labels 的错位对齐,以及开训前必做的截断体检。

30″30 秒看懂生成式微调的数据

前三页那位老师傅只会填空,格子就两个。这一页换人:来的是一位会写文章的大模型,什么都能说,问题恰恰是想说什么说什么

你要的不是文章,是一张能直接入库的表格:主语属于哪个类别这句话里有哪些主谓宾三元组。所以工作方式变成派工单——先递一张工单过去:「你现在是阅读理解器,严格按指令回答。任务是找三元组,输出 json。句子在这儿。Answer:」——最后那个冒号后面故意空着,那是留给他写的位置。他写完,你按格式收走。

图① 30 秒看懂:先递工单,再收表格
图① 30 秒看懂:先递工单,再收表格
比喻里的角色对应的技术概念它到底干了什么
会写文章的人ChatGLM-6B62 亿参数的生成式模型,输出是一段文本而不是一个类别
工单contextInstruction + Input + Answer: 三段式,训练推理逐字一致
必须交回的表格target严格的 JSON 字符串,多写一个字都解析不了
空着的答案栏<sop> 之后的位置损失只算这一段,前面的工单内容一个字都不计分
工单上不计分的部分labels 里的 -100把提示部分涂成 -100,模型就不会去背题干
换一种活换 Instruction 文本分类与抽取共用一套字段结构,训练代码一行不动
⛔ 这一页的铁律 生成式微调里,输入和标签是同一条序列,只是标签把提示部分全部涂成 -100。这一点想不通,后面所有的形状和报错都看不懂。
这一页讲什么、下一页讲什么 这一页把数据侧讲透:工单怎么拼、序列怎么编码、labels 怎么对齐、长度怎么体检。LoRA 本身、训练循环和上线放在下一页。

01概念

从「判别」到「生成」,数据形态变了什么

两类任务的根本差别

对比项BERT 判别式(前三页)ChatGLM 生成式(这一页)
模型输出mask 位置上的词概率一段自由长度的文本
类别数必须事先定死没有这个概念,任务靠文字说清
标签形态类别名的 token id,长度固定一段字符串,通常是 JSON
损失位置mask_positions 那几个位置<sop> 之后的全部位置
换任务的成本改模板 + 改标签表只改 Instruction 文本
输出可靠性一定落在类别集合内可能写出解析不了的东西,必须设校验闸

最后一行是工程上最大的变化。判别式模型的输出天然受限于类别集合,不会给你一个不存在的类别;生成式模型可以写出任何东西——包括把你的 Instruction 复读一遍、在 JSON 外面裹一层客套话、写到一半停笔。所以生成式方案的下游一定要有解析与字段校验,这不是可选项。

Instruction 三段式

本讲的数据格式固定为两个字段:contexttargetcontext 内部是三段:

内容作用
Instruction「你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。」设定角色与总体约束,所有任务共用一句
Input具体任务描述 + 空行 + 待处理的句子换任务只换这一段
Answer:固定以 Answer: 收尾,后面留空既是模型开始作答的信号,也是推理时切结果的锚点

三段式不是唯一写法,但一旦定下来就不能再改一个字——包括空格和换行的数量。训练时模型学的是「见到这个特定的前缀就按这个格式作答」,推理时前缀差一个空格,对模型来说就是另一场考试。

一套格式服务两个任务

本案例要做两件事:评论分类、SPO 信息抽取。它们共用同一套字段结构:

dataset-sample.jsonl —— 数据集节选,两种任务混在一起原始数据
{"context": "Instruction: 现在你是一个非常厉害的SPO抽取器。\nInput: 下面这句中包含了哪些三元组,用json列表的形式回答,不要输出除json外的其他答案。\n\n73获奖记录人物评价:黄磊是一个特别幸运的演员,拍第一部戏就碰到了导演陈凯歌,而且在他的下一部电影《夜半歌声》中演对手戏的张国荣、吴倩莲、黎明等都是著名的港台演员。\nAnswer: ", "target": "```json\n[{\"subject\": \"夜半歌声\",\"subject_type\": \"影视作品\",\"predicate\": \"主演\",\"object\": \"黄磊\",\"object_type\": \"人物\"}]\n```"}
{"context": "Instruction: 现在你是一个非常厉害的SPO抽取器。\nInput: 抽取下面这句话中的SPO,只返回json的答案。\n\n马红宝 男 汉族,1949年8月生,浙江省长兴县人,1978年8月加入中国共产党,1967年9月参加工作,大普文化\nAnswer: ", "target": "```json\n[{\"predicate\": \"民族\", \"object_type\": \"Text\", \"subject_type\": \"人物\", \"object\": \"汉族\", \"subject\": \"马红宝\"}, {\"predicate\": \"国籍\", \"object_type\": \"国家\", \"subject_type\": \"人物\", \"object\": \"中国\", \"subject\": \"马红宝\"}, {\"predicate\": \"出生地\", \"object_type\": \"地点\", \"subject_type\": \"人物\", \"object\": \"浙江省长兴\", \"subject\": \"马红宝\"}, {\"predicate\": \"出生日期\", \"object_type\": \"Date\", \"subject_type\": \"人物\", \"object\": \"1949年8月\", \"subject\": \"马红宝\"}]\n```"}
{"context": "Instruction: 现在你是一个非常厉害的SPO抽取器。\nInput: 前秦世祖宣昭皇帝苻坚(338年-385年10月16日),字永固,又字文玉,小名坚头,氐族,略阳临渭(今甘肃秦安)人,十六国时期前秦的君主,公元357-385年在位。\n\n上面这句话中包含了哪些三元组信息?。\nAnswer: ", "target": "这句话中包含了前秦世祖宣昭皇帝苻坚的生日、字、小名、出生地、民族等信息,以上信息用json可以表示为:\n```json\n[{\"predicate\": \"出生日期\", \"object_type\": \"Date\", \"subject_type\": \"人物\", \"object\": \"338年\", \"subject\": \"苻坚\"}, {\"predicate\": \"字\", \"object_type\": \"Text\", \"subject_type\": \"历史人物\", \"object\": \"文玉\", \"subject\": \"苻坚\"}, {\"predicate\": \"出生地\", \"object_type\": \"地点\", \"subject_type\": \"人物\", \"object\": \"略阳临渭\", \"subject\": \"苻坚\"}, {\"predicate\": \"民族\", \"object_type\": \"Text\", \"subject_type\": \"人物\", \"object\": \"氐族\", \"subject\": \"苻坚\"}]\n```"}
{"context": "Instruction: 现在你是一个非常厉害的SPO抽取器。\nInput: “《大明按察使》该剧由黄海涛,申积军,阙云霞,沈剑波,王江红担任策划、黄克敏执导,姚橹,丁勇岱,杨旸,高鑫,李芯逸等主演的悬疑断案电视剧。”中包含了哪些SPO信息?用json的格式回答,不要说多余的话。\nAnswer: ", "target": "```json\n[{\"predicate\": \"导演\", \"object_type\": \"人物\", \"subject_type\": \"影视作品\", \"object\": \"黄克敏\", \"subject\": \"大明按察使\"}, {\"predicate\": \"主演\", \"object_type\": \"人物\", \"subject_type\": \"影视作品\", \"object\": \"丁勇岱\", \"subject\": \"大明按察使\"}, {\"predicate\": \"主演\", \"object_type\": \"人物\", \"subject_type\": \"影视作品\", \"object\": \"姚橹\", \"subject\": \"大明按察使\"}, {\"predicate\": \"主演\", \"object_type\": \"人物\", \"subject_type\": \"影视作品\", \"object\": \"杨旸\", \"subject\": \"大明按察使\"}]\n```"}
{"context": "Instruction: 现在你是一个非常厉害的SPO抽取器。\nInput: 《中国姓氏大辞典》是在国家自然科学基金的支持下,于2010年由江西人民出版社正式编纂出版的图书,作者是袁义达、邱家儒。\n\n抽取出这句话中包含了哪些关系,并用json的形式回答,不要输出多余信息。\nAnswer: ", "target": "```json\n[{\"predicate\": \"出版社\", \"object_type\": \"出版社\", \"subject_type\": \"书籍\", \"object\": \"江西人民出版社\", \"subject\": \"中国姓氏大辞典\"}, {\"predicate\": \"作者\", \"object_type\": \"人物\", \"subject_type\": \"图书作品\", \"object\": \"袁义达\", \"subject\": \"中国姓氏大辞典\"}, {\"predicate\": \"作者\", \"object_type\": \"人物\", \"subject_type\": \"图书作品\", \"object\": \"邱家儒\", \"subject\": \"中国姓氏大辞典\"}]\n```"}

混在一个文件里一起训练,模型会根据 Instruction 的措辞自动切换行为。这带来两个好处:一次训练服务两个业务;两个任务的数据互相当作正则化,单任务数据少时反而更稳。

混合训练要把顺序打散 前半程全是分类、后半程全是抽取,模型会在后半程「忘掉」前一个任务的行为。构造数据集时必须用固定种子 shuffle 一遍——固定种子是为了可复现,shuffle 是为了两个任务交替出现。

为什么是 ChatGLM-6B

本讲用的底座是 ChatGLM-6B:62 亿参数,GLM 架构,hidden_size=4096num_layers=28,权重以 float16 存储。选它的工程理由是它在单张消费级显卡上能跑起来,而且中文语料占比高。

它的 GLM 架构和常见的 GPT 系有一处差别直接影响数据处理:序列里有 [gMASK]<sop><eop> 这几个特殊 token,标记「从这里开始生成」和「生成结束」。这几个标记不用自己拼,分词器的 build_inputs_with_special_tokens() 会补上——但你必须知道它们在哪,因为 labels 的分界线就是 <sop> 的位置。

02原理:一条 jsonl 怎么变成 input_ids 与 labels

五步,盯住那条 -100 的分界线

先看数据长什么样

一条样本就是一行 JSON,两个字段。换行在 jsonl 里是转义的 \n,所以一条样本永远占一行,可以流式按行读:

data_format_demo.py —— 两个任务的样本逐字段拆开可直接运行
# -*- coding:utf-8 -*-
"""数据长什么样:一条 jsonl 拆开看,纯标准库,可直接运行。

两个任务(文本分类、SPO 抽取)共用同一套字段:context 与 target。
context 里塞的是「Instruction + Input + Answer:」三段式,
Answer: 后面故意留空——那是留给模型写的位置。
"""
import json

CLS_SAMPLE = {
    "context": "Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\n"
               "Input: 下面句子中的主语是什么类别,输出成列表形式。\n\n"
               "朋友说京东上买的水果很新鲜,我就也下单了一箱\nAnswer: ",
    "target": "[\"水果\"]"
}

SPO_SAMPLE = {
    "context": "Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\n"
               "Input: 找到句子中的三元组信息并输出成json给我:\n\n"
               "《霸王别姬》是由张国荣主演的一部电影\nAnswer: ",
    "target": "```json\n[{\"predicate\": \"主演\", \"object_type\": \"人物\", "
              "\"subject_type\": \"影视作品\", \"object\": \"张国荣\", \"subject\": \"霸王别姬\"}]\n```"
}


def show(name, sample):
    print('=' * 60)
    print(name)
    print('=' * 60)
    context, target = sample['context'], sample['target']
    for i, seg in enumerate(context.split('\n')):
        print('  ctx[%d] %s' % (i, seg if seg else '(空行)'))
    print('  target: %s' % target)
    print('  context %d 字 / target %d 字' % (len(context), len(target)))


if __name__ == '__main__':
    show('任务一:文本分类', CLS_SAMPLE)
    show('任务二:SPO 信息抽取', SPO_SAMPLE)

    # 两条样本的字段名完全一样——这是「换任务不改代码」的前提
    assert set(CLS_SAMPLE) == set(SPO_SAMPLE) == {'context', 'target'}
    print('\n两个任务的字段结构完全一致:', sorted(CLS_SAMPLE))

    # context 一定以 'Answer: ' 收尾,推理时按这个标记切结果
    for s in (CLS_SAMPLE, SPO_SAMPLE):
        assert s['context'].rstrip(' ').endswith('Answer:'), 'Answer: 是切分锚点,不能省'
    print("context 都以 'Answer: ' 结尾,推理时按它切出模型写的部分")

    # target 必须是合法 JSON(SPO 的要先剥掉代码围栏)
    print('\n分类 target 解析:', json.loads(CLS_SAMPLE['target']))
    spo = SPO_SAMPLE['target'].strip().removeprefix('```json').removesuffix('```').strip()
    parsed = json.loads(spo)
    print('抽取 target 解析:', parsed[0]['subject'], '-', parsed[0]['predicate'], '-', parsed[0]['object'])
    assert parsed[0]['subject'] == '霸王别姬'

    # 写回一行 jsonl 看看真实存储形态
    line = json.dumps(CLS_SAMPLE, ensure_ascii=False)
    print('\n存盘时就是这么一行:')
    print(' ', line[:100] + ' ...')
    assert '\\n' in line, 'jsonl 里换行是转义的 \\n,一条样本永远占一行'
    print('\n一条样本 = 一行 JSON,换行被转义,所以可以按行流式读')

工单是拼出来的,不是手写的

context 里那三段有严格的空格和换行约定:Input 与正文之间是两个换行Answer: 后面有一个空格,结尾不加换行。手写字符串迟早会在这几个地方出入,而训练与推理只要差一个字符,模型的行为就会变。把它固化成一个函数,两边都调它:

instruction_builder.py —— 三段式工单的唯一生成入口核心逻辑
# -*- coding:utf-8 -*-
"""Instruction 是怎么拼出来的:把三段式做成函数,纯标准库,可直接运行。

手写 context 字符串很容易在空格、换行、冒号上出入,
而训练与推理只要有一个字不一致,模型就像换了一场考试。
所以把它固化成一个函数,两边都调它。
"""
import json

SYSTEM = '你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。'

TASK_PROMPT = {
    'classify': '下面句子中的主语是什么类别,输出成列表形式。',
    'spo': '找到句子中的三元组信息并输出成json给我:',
    'ner': '找到文章中所有【{entity_type}】类型的实体,并按照JSON格式输出。',
}


def build_context(task: str, sentence: str, **kwargs) -> str:
    """拼出 context。注意三个细节,缺一个就和训练数据对不上:
    1) Input 与正文之间是两个换行;
    2) Answer 后面有一个空格;
    3) 结尾不加换行。
    """
    if task not in TASK_PROMPT:
        raise ValueError('未知任务 %s,可选: %s' % (task, list(TASK_PROMPT)))
    instruction = TASK_PROMPT[task].format(**kwargs) if kwargs else TASK_PROMPT[task]
    return ('Instruction: %s\nInput: %s\n\n%s\nAnswer: '
            % (SYSTEM, instruction, sentence))


def build_sample(task: str, sentence: str, target, **kwargs) -> dict:
    """训练样本 = context + target。target 是对象时序列化成紧凑 JSON。"""
    if not isinstance(target, str):
        target = json.dumps(target, ensure_ascii=False)
    return {'context': build_context(task, sentence, **kwargs), 'target': target}


if __name__ == '__main__':
    ctx = build_context('classify', '朋友说京东上买的水果很新鲜,我就也下单了一箱')
    print(repr(ctx))

    # 训练与推理必须走同一个函数,结果逐字相同
    assert build_context('classify', 'x') == build_context('classify', 'x')
    assert ctx.endswith('Answer: '), 'Answer: 后面那个空格不能丢'
    assert '\n\n' in ctx, 'Input 与正文之间是两个换行'

    print('\n三个任务各生成一条:')
    samples = [
        build_sample('classify', '这款平板续航很顶', ['平板']),
        build_sample('spo', '《霸王别姬》是由张国荣主演的一部电影',
                     [{'predicate': '主演', 'subject': '霸王别姬', 'object': '张国荣'}]),
        build_sample('ner', '北京冬奥会在张家口设了赛区', ['北京', '张家口'], entity_type='地点'),
    ]
    for s in samples:
        print(' ', json.dumps(s, ensure_ascii=False)[:96], '...')

    # 换任务只换 instruction 文本,格式骨架一模一样
    heads = [s['context'].split('\n')[0] for s in samples]
    assert len(set(heads)) == 1, '三个任务的第一行完全相同'
    print('\n三条样本的 Instruction 行完全相同,差别只在 Input 行')

    # 带占位符的任务能格式化进去
    assert '【地点】' in samples[2]['context']
    print('ner 任务的实体类型被填进了模板:',
          samples[2]['context'].split('\n')[1])
一个函数,两处调用 构造训练集时调它,线上推理时也调它。这样「训练与推理的提示必须逐字一致」这条约束就从靠自觉变成了靠结构——想不一致都难。

特殊 token 与序列拼接

tokenizer.build_inputs_with_special_tokens(prompts_ids, target_ids) 会把两段拼起来并补上 GLM 的特殊标记,结果是这样一条序列:

位置内容含义
开头context 的 token工单正文
中间[gMASK] <sop>「从这里开始生成」的分界标记
之后target 的 token标准答案
结尾<eop>生成结束标记
尾部pad × N补齐到固定长度

不要自己手拼这几个标记。不同版本的 GLM 在标记数量和顺序上有差异,调分词器的方法才不会错。

图② input_ids 与 labels 的错位对齐
图② input_ids 与 labels 的错位对齐

labels:把工单涂成 -100

这是整个生成式微调最容易写错、错了又不报错的一步。规则只有两句:

  • <sop> 及之前的位置全部填 -100,不计损失。
  • 之后的位置照抄 input_ids,包括结尾的 <eop>

-100 是 PyTorch 交叉熵的默认 ignore_index,落在这个值上的位置会被完全跳过。代码里对应两行:

  • context_length = input_ids.index(tokenizer.bos_token_id) —— 找到 <sop> 的下标。
  • labels = [-100] * context_length + input_ids[mask_position + 1:]
labels_align_demo.py —— 错位对齐的复刻与五条性质断言可直接运行
# -*- coding:utf-8 -*-
"""labels 是怎么涂成 -100 的:纯标准库复刻,可直接运行。

这一段是整个生成式微调最容易写错的地方。
写错了不会报错,只会让模型把「提示词」也当成要背下来的内容,
表现是:推理时它把你的 Instruction 一字不差地复读一遍。
"""

GMASK, SOP, EOP, PAD = 150001, 150004, 150005, 3
IGNORE = -100


def build_inputs(prompt_ids, target_ids):
    """复刻 ChatGLM 的 build_inputs_with_special_tokens。"""
    return prompt_ids + [GMASK, SOP] + target_ids + [EOP]


def make_labels(input_ids, bos_token_id=SOP):
    """提示部分涂 -100,答案部分照抄 input_ids。"""
    context_length = input_ids.index(bos_token_id)
    mask_position = context_length - 1
    return [IGNORE] * context_length + input_ids[mask_position + 1:]


def pad_to(input_ids, labels, max_len):
    pad_len = max_len - len(input_ids)
    return (input_ids + [PAD] * pad_len,
            labels + [IGNORE] * pad_len)   # 标签补 -100,不是补 PAD


if __name__ == '__main__':
    prompt_ids = [5001, 5002, 5003, 5004]      # "Instruction: ... Answer: "
    target_ids = [7001, 7002]                  # '["水果"]'

    input_ids = build_inputs(prompt_ids, target_ids)
    print('input_ids:', input_ids)
    assert input_ids == [5001, 5002, 5003, 5004, GMASK, SOP, 7001, 7002, EOP]

    labels = make_labels(input_ids)
    print('labels   :', labels)
    assert len(labels) == len(input_ids), 'labels 必须和 input_ids 等长'

    # 关键性质一:<sop> 及之前全部是 -100
    context_length = input_ids.index(SOP)
    print('\ncontext_length(<sop> 的下标):', context_length)
    assert labels[:context_length] == [IGNORE] * context_length
    print('前 %d 个位置不计损失' % context_length)

    # 关键性质二:答案段与 input_ids 逐元素相同
    assert labels[context_length:] == input_ids[context_length:]
    print('从下标 %d 起,labels 与 input_ids 完全一致:' % context_length,
          labels[context_length:])

    # 关键性质三:<eop> 要参与损失,模型才学得会「写完就停」
    assert labels[-1] == EOP, '结束符必须计损失,否则模型不知道何时停下'
    print('结束符 <eop> 参与损失 -> 模型学得会停笔')

    # padding 之后再验一次
    ids_p, labels_p = pad_to(input_ids, labels, 16)
    print('\npad 到 16:')
    print('  input_ids:', ids_p)
    print('  labels   :', labels_p)
    assert labels_p[len(input_ids):] == [IGNORE] * 7
    assert IGNORE not in ids_p, 'input_ids 里不能出现 -100'

    # 对照:labels 补 PAD 的错误写法
    wrong = labels + [PAD] * (16 - len(labels))
    diff = sum(1 for a, b in zip(labels_p, wrong) if a != b)
    print('\n标签补 PAD(错误)会让 %d 个位置变成「要模型学着输出 padding」' % diff)
    assert diff == 7

    # 统计参与损失的 token 占比 —— 数据质量的一个硬指标
    valid = sum(1 for t in labels_p if t != IGNORE)
    print('\n参与损失的 token: %d / %d = %.1f%%' % (valid, len(labels_p), 100 * valid / len(labels_p)))
    print('这个比例太低(比如 5%)说明提示太长、答案太短,训练信号被稀释')

三条必须成立的性质,脚本里都用断言钉住了:

性质怎么验不成立的后果
两者等长len(labels) == len(input_ids)形状不匹配,训练直接报错
前缀全是 -100context_length 个都是 -100模型去背工单,推理时把 Instruction 复读一遍
结束符计损失labels[-1]<eop>模型学不会停笔,一直写到长度上限

padding 补什么

input_idspad_token_idlabels-100两边补的东西不一样,这是最容易顺手写错的地方——顺着上一行复制粘贴,就会把 labels 也补成 pad_token_id,于是模型开始努力学习「答案写完之后再输出一串 padding」。

还有一个有用的副产品:labels 里非 -100 的比例,就是真正参与损失的 token 占比。这个数太低(比如 5%)说明工单太长、答案太短,训练信号被稀释,要么精简 Instruction,要么把 max_source_seq_len 调小。开训前打印一个 batch 的这个比例,比看十行日志有用。

03最小代码:编码一条样本

不加载 6B 模型,只把 input_ids 与 labels 对齐关系验证清楚

加载 ChatGLM-6B 要十几 GB 显存,为了验证一个下标关系不值得。最短路径是用假的 token id 把拼接和涂色走一遍,形状与性质对了再上真模型。

① 拼 context三段式工单
② 分别编码prompt_ids / target_ids
③ 补特殊 token[gMASK] <sop> <eop>
④ 找 <sop> 下标context_length
⑤ 涂 -100前缀不计损失
⑥ 补齐ids 补 pad,labels 补 -100
labels_align_demo.py —— 六步走完,断言逐条校验可直接运行
# -*- coding:utf-8 -*-
"""labels 是怎么涂成 -100 的:纯标准库复刻,可直接运行。

这一段是整个生成式微调最容易写错的地方。
写错了不会报错,只会让模型把「提示词」也当成要背下来的内容,
表现是:推理时它把你的 Instruction 一字不差地复读一遍。
"""

GMASK, SOP, EOP, PAD = 150001, 150004, 150005, 3
IGNORE = -100


def build_inputs(prompt_ids, target_ids):
    """复刻 ChatGLM 的 build_inputs_with_special_tokens。"""
    return prompt_ids + [GMASK, SOP] + target_ids + [EOP]


def make_labels(input_ids, bos_token_id=SOP):
    """提示部分涂 -100,答案部分照抄 input_ids。"""
    context_length = input_ids.index(bos_token_id)
    mask_position = context_length - 1
    return [IGNORE] * context_length + input_ids[mask_position + 1:]


def pad_to(input_ids, labels, max_len):
    pad_len = max_len - len(input_ids)
    return (input_ids + [PAD] * pad_len,
            labels + [IGNORE] * pad_len)   # 标签补 -100,不是补 PAD


if __name__ == '__main__':
    prompt_ids = [5001, 5002, 5003, 5004]      # "Instruction: ... Answer: "
    target_ids = [7001, 7002]                  # '["水果"]'

    input_ids = build_inputs(prompt_ids, target_ids)
    print('input_ids:', input_ids)
    assert input_ids == [5001, 5002, 5003, 5004, GMASK, SOP, 7001, 7002, EOP]

    labels = make_labels(input_ids)
    print('labels   :', labels)
    assert len(labels) == len(input_ids), 'labels 必须和 input_ids 等长'

    # 关键性质一:<sop> 及之前全部是 -100
    context_length = input_ids.index(SOP)
    print('\ncontext_length(<sop> 的下标):', context_length)
    assert labels[:context_length] == [IGNORE] * context_length
    print('前 %d 个位置不计损失' % context_length)

    # 关键性质二:答案段与 input_ids 逐元素相同
    assert labels[context_length:] == input_ids[context_length:]
    print('从下标 %d 起,labels 与 input_ids 完全一致:' % context_length,
          labels[context_length:])

    # 关键性质三:<eop> 要参与损失,模型才学得会「写完就停」
    assert labels[-1] == EOP, '结束符必须计损失,否则模型不知道何时停下'
    print('结束符 <eop> 参与损失 -> 模型学得会停笔')

    # padding 之后再验一次
    ids_p, labels_p = pad_to(input_ids, labels, 16)
    print('\npad 到 16:')
    print('  input_ids:', ids_p)
    print('  labels   :', labels_p)
    assert labels_p[len(input_ids):] == [IGNORE] * 7
    assert IGNORE not in ids_p, 'input_ids 里不能出现 -100'

    # 对照:labels 补 PAD 的错误写法
    wrong = labels + [PAD] * (16 - len(labels))
    diff = sum(1 for a, b in zip(labels_p, wrong) if a != b)
    print('\n标签补 PAD(错误)会让 %d 个位置变成「要模型学着输出 padding」' % diff)
    assert diff == 7

    # 统计参与损失的 token 占比 —— 数据质量的一个硬指标
    valid = sum(1 for t in labels_p if t != IGNORE)
    print('\n参与损失的 token: %d / %d = %.1f%%' % (valid, len(labels_p), 100 * valid / len(labels_p)))
    print('这个比例太低(比如 5%)说明提示太长、答案太短,训练信号被稀释')

接上真实分词器之后,就是项目里的编码函数。它比 BERT 版简单:没有 token_type_ids,也没有 attention_mask——ChatGLM 的 forward 会根据 <sop> 的位置自己构造注意力掩码,我们只负责给对两个字段:

data_handle.py —— 一条 jsonl 到 input_ids 与 labels核心逻辑
# -*- coding:utf-8 -*-
"""把一条 {context, target} 变成 ChatGLM 能吃的 input_ids / labels。

生成式微调与 BERT 分类最大的认知差:
输入和标签是**同一条序列**,只是标签把「提示部分」全部涂成 -100,
让损失只落在模型自己要写出来的那一段上。
"""
import numpy as np


def convert_example(examples: dict,
                    tokenizer,
                    max_source_seq_len: int,
                    max_target_seq_len: int):
    """
    参数:
        examples: {'text': ['{"context": "...", "target": "..."}', ...]},每行一条 jsonl
        max_source_seq_len: 提示部分最长 token 数
        max_target_seq_len: 回答部分最长 token 数
    返回:
        {'input_ids': [...], 'labels': [...]},两者等长
    """
    import json

    tokenized_output = {'input_ids': [], 'labels': []}
    max_seq_length = max_source_seq_len + max_target_seq_len

    for example in examples['text']:
        try:
            example = json.loads(example)
            context = example['context']
            target = example['target']

            # prompt 部分:不加特殊 token,后面手动拼
            prompts_ids = tokenizer.encode(text=context, add_special_tokens=False)
            target_ids = tokenizer.encode(text=target, add_special_tokens=False)

            # 超长就截断。截 prompt 会丢掉 Instruction 的尾部,
            # 截 target 会让模型学到「答案写一半就停」——两种都要在数据阶段先统计好
            if len(prompts_ids) >= max_source_seq_len:
                prompts_ids = prompts_ids[:max_source_seq_len - 1]
            if len(target_ids) >= max_target_seq_len - 1:
                target_ids = target_ids[:max_target_seq_len - 2]

            # build_inputs_with_special_tokens 会补上 [gMASK] 与 <sop>,
            # 并在结尾补 <eop>,这是 GLM 系列的生成格式,别自己手拼
            input_ids = tokenizer.build_inputs_with_special_tokens(prompts_ids, target_ids)

            # <sop> 的下标 = 提示部分的长度。labels 从这里开始才是真答案
            context_length = input_ids.index(tokenizer.bos_token_id)
            mask_position = context_length - 1

            labels = [-100] * context_length + input_ids[mask_position + 1:]

            pad_len = max_seq_length - len(input_ids)
            input_ids = input_ids + [tokenizer.pad_token_id] * pad_len
            # 标签这边补的是 -100 而不是 pad_token_id:
            # 补 pad_token_id 会让模型努力去学「输出一串 padding」
            labels = labels + [-100] * pad_len

            tokenized_output['input_ids'].append(input_ids)
            tokenized_output['labels'].append(labels)
        except Exception as e:
            print('[跳过] 解析失败:', e)
            continue

    for k, v in tokenized_output.items():
        tokenized_output[k] = np.array(v)
    return tokenized_output


if __name__ == '__main__':
    from transformers import AutoTokenizer

    tokenizer = AutoTokenizer.from_pretrained('./ChatGLM-6B', trust_remote_code=True)
    out = convert_example(
        {'text': ['{"context": "Instruction: 你是阅读理解器\\nAnswer: ", "target": "[\\"水果\\"]"}']},
        tokenizer, max_source_seq_len=100, max_target_seq_len=100)
    print('input_ids 形状:', out['input_ids'].shape)
    print('labels    形状:', out['labels'].shape)
    print('labels 里 -100 的个数:', int((out['labels'] == -100).sum()))
截断要分开看两头 代码里 prompt 和 target 分别截断。截 prompt 丢掉的是 Instruction 的尾部,模型可能不知道要输出什么格式;截 target 更糟——模型学到的是「答案写一半就停」,上线后会稳定地输出残缺 JSON。两种都必须在数据阶段统计出来,不能等训练完再发现。

04完整案例:新媒体评论的两个任务

一套数据管线,同时喂饱分类和信息抽取

业务场景

新媒体运营每天要处理几万条评论,需求有两个:

  • 分类——这条评论在说哪个品类,用来把评论分流到不同的运营组。
  • 信息抽取——把评论里提到的实体关系抽成三元组(主语、谓语、宾语),沉淀成可查询的知识库。

用判别式方案要做两个模型:一个分类头、一套 NER/关系抽取的标注体系。用生成式方案,两件事共用一份数据格式、一次训练、一个模型。

图③ 一套数据格式同时服务分类与抽取两种任务
图③ 一套数据格式同时服务分类与抽取两种任务

配置

glm_config.py —— 数据与训练的全部配置配置
# -*- coding:utf-8 -*-
"""ChatGLM + LoRA 的工程配置。

和 BERT 路线最大的差别:这里不再有「类别数」这种概念。
任务是什么,全靠 instruction 文本说清楚;换任务不改代码,只改数据。
"""
import os

import torch


class ProjectConfig(object):
    def __init__(self):
        self.device = 'cuda:0' if torch.cuda.is_available() else 'cpu'
        self.root = os.environ.get(
            'GLM_PROJECT_ROOT',
            os.path.dirname(os.path.abspath(__file__)),
        )
        # 底座:ChatGLM-6B,62 亿参数,GLM 架构,28 层,hidden_size 4096
        self.pre_model = os.path.join(self.root, 'ChatGLM-6B')
        self.train_path = os.path.join(self.root, 'data', 'mixed_train_dataset.jsonl')
        self.dev_path = os.path.join(self.root, 'data', 'mixed_dev_dataset.jsonl')

        # LoRA 三件套
        self.use_lora = True
        self.lora_rank = 8          # 秩:A 是 (hidden, 8),B 是 (8, out)
        self.lora_alpha = 32        # 缩放系数,实际缩放 = alpha / rank = 4
        self.lora_dropout = 0.1

        self.use_ptuning = False    # 与 LoRA 二选一,不要同时开
        self.pre_seq_len = 128
        self.prefix_projection = False

        # 显存敏感项:batch 只能给到 2,靠梯度累积把等效 batch 撑起来
        self.batch_size = 2
        self.gradient_accumulation_steps = 8
        self.epochs = 2
        self.learning_rate = 1e-4   # 比全参微调的 5e-5 大一个量级:只训 0.06% 的参数,学慢了学不动
        self.weight_decay = 0
        self.warmup_ratio = 0.06

        # 输入输出各留 100 个 token。超了直接截断,不会报错
        self.max_source_seq_len = 100
        self.max_target_seq_len = 100

        self.logging_steps = 10
        self.save_freq = 1000
        self.save_dir = os.path.join(self.root, 'checkpoints', 'ptune')


if __name__ == '__main__':
    pc = ProjectConfig()
    print('设备          :', pc.device)
    print('等效 batch    :', pc.batch_size * pc.gradient_accumulation_steps)
    print('LoRA 缩放系数 :', pc.lora_alpha / pc.lora_rank)
    print('单条最长 token:', pc.max_source_seq_len + pc.max_target_seq_len)

与前三页的 BERT 配置对比,多出来和少掉的都值得注意:

配置项取值为什么
batch_size26B 模型半精度就要十几 GB,batch 只能给到个位数
gradient_accumulation_steps8攒 8 步再更新,等效 batch 是 16,把显存换成时间
learning_rate1e-4比 BERT 全参微调的 5e-5 大一个量级——只训 0.06% 的参数,学慢了学不动
epochs2生成式微调很容易过拟合,轮数远少于 BERT 的 10
max_source_seq_len100工单 + 正文的预算;固定 Instruction 头就吃掉一半
max_target_seq_len100按最长的那个任务定,SPO 的 JSON 比分类长一个数量级
类别数没有任务靠 Instruction 文本说清,不靠结构写死

开训前必做:截断体检

max_source_seq_len=100 是配置里的默认值,不是普适真理。那句固定的 Instruction 头本身就占掉五十几个 token,正文只剩四十来个。评论类短文本勉强够,SPO 抽取的 target 是一串 JSON,两个三元组就能顶到 75 个 token。

truncation_audit.py —— 统计有多少条会被截断可直接运行
# -*- coding:utf-8 -*-
"""开训前必做:统计有多少条样本会被 max_source/target_seq_len 截断。

max_source_seq_len=100 是配置里写死的默认值,不是普适真理。
新媒体评论普遍短,但 SPO 抽取任务的 target 是一串 JSON,
很容易超过 100 个 token —— 被截断的样本会教模型「写一半就交卷」。
"""
import json

MAX_SOURCE, MAX_TARGET = 100, 100


def rough_token_len(text: str) -> int:
    """粗估 token 数:中文按字算,ASCII 连续段按 4 个字符 1 token 估。

    真实统计请换成 tokenizer.encode(text, add_special_tokens=False) 的长度;
    这里用粗估是为了在没有模型权重的机器上也能先跑一遍体检。
    """
    n, ascii_run = 0, 0
    for ch in text:
        if ord(ch) < 128:
            ascii_run += 1
        else:
            n += 1 + (ascii_run + 3) // 4
            ascii_run = 0
    return n + (ascii_run + 3) // 4


def audit(samples):
    over_src = over_tgt = 0
    src_lens, tgt_lens = [], []
    for s in samples:
        sl, tl = rough_token_len(s['context']), rough_token_len(s['target'])
        src_lens.append(sl)
        tgt_lens.append(tl)
        over_src += sl >= MAX_SOURCE
        over_tgt += tl >= MAX_TARGET - 1
    return {
        'total': len(samples),
        'src_max': max(src_lens), 'src_avg': sum(src_lens) / len(src_lens),
        'tgt_max': max(tgt_lens), 'tgt_avg': sum(tgt_lens) / len(tgt_lens),
        'over_src': over_src, 'over_tgt': over_tgt,
    }


if __name__ == '__main__':
    head = ('Instruction: 你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。\n'
            'Input: 找到句子中的三元组信息并输出成json给我:\n\n')
    samples = [
        {'context': head + '这款平板续航很顶\nAnswer: ', 'target': '["平板"]'},
        {'context': head + '《霸王别姬》是由张国荣主演的一部电影\nAnswer: ',
         'target': '```json\n[{"predicate": "主演", "object_type": "人物", '
                   '"subject_type": "影视作品", "object": "张国荣", "subject": "霸王别姬"}]\n```'},
        {'context': head + '《活着》是余华在1992年发表的长篇小说,讲述了福贵一生的故事\nAnswer: ',
         'target': '```json\n[{"predicate": "作者", "object_type": "人物", '
                   '"subject_type": "图书作品", "object": "余华", "subject": "活着"}, '
                   '{"predicate": "出版时间", "object_type": "时间", '
                   '"subject_type": "图书作品", "object": "1992年", "subject": "活着"}]\n```'},
    ]

    stat = audit(samples)
    print('样本数        :', stat['total'])
    print('source 平均/最长: %.1f / %d  (上限 %d)' % (stat['src_avg'], stat['src_max'], MAX_SOURCE))
    print('target 平均/最长: %.1f / %d  (上限 %d)' % (stat['tgt_avg'], stat['tgt_max'], MAX_TARGET))
    print('会被截断的 source: %d 条' % stat['over_src'])
    print('会被截断的 target: %d 条' % stat['over_tgt'])

    # 光是这条固定的 Instruction 头就吃掉多少预算
    head_len = rough_token_len(head)
    print('\n固定 Instruction 头占 %d 个 token,正文只剩 %d 个'
          % (head_len, MAX_SOURCE - head_len))
    assert head_len > 40, 'Instruction 头本身就很长,这是新手最容易忽略的一笔开销'

    # 两条 SPO 的样本,target 比分类长一个数量级
    cls_t = rough_token_len(samples[0]['target'])
    spo_t = rough_token_len(samples[2]['target'])
    print('分类 target %d token vs 双三元组 target %d token,差 %.1f 倍'
          % (cls_t, spo_t, spo_t / cls_t))
    assert spo_t > cls_t * 5
    print('\n结论:同一份 max_target_seq_len 同时服务两个任务时,按长的那个任务定')

体检要看四个数:source 的平均与最长、target 的平均与最长、会被截断的条数、固定头占用的比例。任何一项超标,要么调长度上限,要么精简 Instruction,不要直接开训。

粗估与精确统计 脚本里的 rough_token_len() 是粗估(中文按字算、ASCII 按 4 字符 1 token),目的是在没有模型权重的机器上也能先跑一遍体检。拿到模型后换成 tokenizer.encode(text, add_special_tokens=False) 的长度,结论更准。

DataLoader

data_loader.py —— 比 BERT 版更简单,只有两个字段核心逻辑
# -*- coding:utf-8 -*-
"""生成式微调的 DataLoader。

比 BERT 路线简单:没有 token_type_ids,没有 attention_mask —— 
ChatGLM 的 forward 会根据 input_ids 里的 <sop> 位置自己构造注意力掩码。
我们只负责给对 input_ids 与 labels。
"""
from functools import partial

from datasets import load_dataset
from torch.utils.data import DataLoader
from transformers import AutoTokenizer, default_data_collator

from data_handle import convert_example
from glm_config import ProjectConfig

pc = ProjectConfig()
# trust_remote_code=True:ChatGLM 的分词器实现放在模型仓库里,不在 transformers 本体
tokenizer = AutoTokenizer.from_pretrained(pc.pre_model, trust_remote_code=True)


def get_data():
    dataset = load_dataset('text', data_files={'train': pc.train_path,
                                               'dev': pc.dev_path})
    new_func = partial(convert_example,
                       tokenizer=tokenizer,
                       max_source_seq_len=pc.max_source_seq_len,
                       max_target_seq_len=pc.max_target_seq_len)

    # batched=True:一次处理一批,比逐条快很多。
    # 注意 convert_example 内部是按条循环的,这里的 batch 只是为了减少函数调用开销
    dataset = dataset.map(new_func, batched=True)

    train_dataloader = DataLoader(dataset['train'],
                                  shuffle=True,
                                  collate_fn=default_data_collator,
                                  batch_size=pc.batch_size)
    dev_dataloader = DataLoader(dataset['dev'],
                                collate_fn=default_data_collator,
                                batch_size=pc.batch_size)
    return train_dataloader, dev_dataloader


if __name__ == '__main__':
    train_dataloader, dev_dataloader = get_data()
    print('train batch 数:', len(train_dataloader))
    print('dev   batch 数:', len(dev_dataloader))
    for batch in train_dataloader:
        print('字段:', list(batch.keys()))
        print('形状:', {k: tuple(v.shape) for k, v in batch.items()})
        # 训练开始前最值得看的一眼:这一批里有多少 token 真的参与了损失
        valid = int((batch['labels'] != -100).sum())
        total = batch['labels'].numel()
        print('参与损失的 token: %d / %d = %.1f%%' % (valid, total, 100 * valid / total))
        break

三个要点:

  • trust_remote_code=True 不能省。ChatGLM 的分词器和建模代码放在模型仓库里,不在 transformers 本体。
  • 只有 input_idslabels 两个字段。attention_mask 由模型内部按 <sop> 位置构造。
  • 拿到第一个 batch 先打印参与损失的 token 占比。这个数比 batch 形状更能说明数据质量。

从业务数据到 jsonl

真实项目里拿到的是 Excel 导出或标注平台的结果,离能开训还差一个转换脚本。它要做四件事:拼工单、序列化答案、固定种子打散、切分训练与验证集。

skeleton_jsonl_builder.py —— 业务数据转 jsonl,含回读校验可复用模板
# -*- coding:utf-8 -*-
"""骨架模板:把你自己的标注数据转成这套 jsonl。

拿到一份业务数据(Excel 导出、爬虫结果、标注平台导出),
到能开训之间就差这一个脚本。四个 TODO 填完即可。
"""
import json
import random

# TODO-1: 系统角色描述。定了之后训练与推理都不能再改
SYSTEM = '你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。'
# TODO-2: 每个任务一句话指令
INSTRUCTIONS = {
    'classify': '下面句子中的主语是什么类别,输出成列表形式。',
    'spo': '找到句子中的三元组信息并输出成json给我:',
}
# TODO-3: 验证集比例
DEV_RATIO = 0.1
SEED = 42


def build_context(task: str, sentence: str) -> str:
    return ('Instruction: %s\nInput: %s\n\n%s\nAnswer: '
            % (SYSTEM, INSTRUCTIONS[task], sentence))


def build_target(task: str, answer) -> str:
    """分类返回裸 JSON 列表;抽取包一层 ```json 围栏,与原项目保持一致。"""
    body = json.dumps(answer, ensure_ascii=False)
    if task == 'spo':
        return '```json\n%s\n```' % body
    return body


def iter_raw():
    """TODO-4: 换成你自己的数据源,yield (task, sentence, answer)。"""
    yield 'classify', '朋友说京东上买的水果很新鲜,我就也下单了一箱', ['水果']
    yield 'classify', '这款平板的续航是真顶,出差两天没充电', ['平板']
    yield 'spo', '《霸王别姬》是由张国荣主演的一部电影', [
        {'predicate': '主演', 'subject': '霸王别姬', 'object': '张国荣'}]


def main(train_path='mixed_train_dataset.jsonl', dev_path='mixed_dev_dataset.jsonl'):
    samples = []
    for task, sentence, answer in iter_raw():
        if task not in INSTRUCTIONS:
            print('[跳过] 未知任务', task)
            continue
        samples.append({'context': build_context(task, sentence),
                        'target': build_target(task, answer)})

    # 固定随机种子再打散:两个任务混在一起训练时,顺序必须是乱的,
    # 否则前半程全是分类、后半程全是抽取,模型会在后半程「忘掉」前一个任务
    random.Random(SEED).shuffle(samples)

    n_dev = max(1, int(len(samples) * DEV_RATIO))
    dev, train = samples[:n_dev], samples[n_dev:]

    for path, rows in ((train_path, train), (dev_path, dev)):
        with open(path, 'w', encoding='utf8') as f:
            for r in rows:
                f.write(json.dumps(r, ensure_ascii=False) + '\n')
        print('写出 %s: %d 条' % (path, len(rows)))
    return len(train), len(dev)


if __name__ == '__main__':
    import os
    import tempfile

    tmp = tempfile.mkdtemp()
    n_train, n_dev = main(os.path.join(tmp, 'train.jsonl'), os.path.join(tmp, 'dev.jsonl'))
    assert n_train + n_dev == 3

    # 回读校验:每一行都必须是合法 JSON 且字段齐全
    with open(os.path.join(tmp, 'train.jsonl'), encoding='utf8') as f:
        for line in f:
            row = json.loads(line)
            assert set(row) == {'context', 'target'}
            assert row['context'].endswith('Answer: ')
    print('回读校验通过:每行都是合法 JSON,字段齐全,context 以 Answer: 收尾')

脚本最后带一段回读校验:写完再逐行读回来,确认每行都是合法 JSON、字段齐全、contextAnswer: 收尾。写文件的脚本自己检查自己的产物,成本几乎为零,能挡掉的却是「训练跑了两小时才发现数据有问题」。

05骨架模板

换一个业务,改四个 TODO

skeleton_jsonl_builder.py —— 数据构造骨架可复用模板
# -*- coding:utf-8 -*-
"""骨架模板:把你自己的标注数据转成这套 jsonl。

拿到一份业务数据(Excel 导出、爬虫结果、标注平台导出),
到能开训之间就差这一个脚本。四个 TODO 填完即可。
"""
import json
import random

# TODO-1: 系统角色描述。定了之后训练与推理都不能再改
SYSTEM = '你现在是一个很厉害的阅读理解器,严格按照人类指令进行回答。'
# TODO-2: 每个任务一句话指令
INSTRUCTIONS = {
    'classify': '下面句子中的主语是什么类别,输出成列表形式。',
    'spo': '找到句子中的三元组信息并输出成json给我:',
}
# TODO-3: 验证集比例
DEV_RATIO = 0.1
SEED = 42


def build_context(task: str, sentence: str) -> str:
    return ('Instruction: %s\nInput: %s\n\n%s\nAnswer: '
            % (SYSTEM, INSTRUCTIONS[task], sentence))


def build_target(task: str, answer) -> str:
    """分类返回裸 JSON 列表;抽取包一层 ```json 围栏,与原项目保持一致。"""
    body = json.dumps(answer, ensure_ascii=False)
    if task == 'spo':
        return '```json\n%s\n```' % body
    return body


def iter_raw():
    """TODO-4: 换成你自己的数据源,yield (task, sentence, answer)。"""
    yield 'classify', '朋友说京东上买的水果很新鲜,我就也下单了一箱', ['水果']
    yield 'classify', '这款平板的续航是真顶,出差两天没充电', ['平板']
    yield 'spo', '《霸王别姬》是由张国荣主演的一部电影', [
        {'predicate': '主演', 'subject': '霸王别姬', 'object': '张国荣'}]


def main(train_path='mixed_train_dataset.jsonl', dev_path='mixed_dev_dataset.jsonl'):
    samples = []
    for task, sentence, answer in iter_raw():
        if task not in INSTRUCTIONS:
            print('[跳过] 未知任务', task)
            continue
        samples.append({'context': build_context(task, sentence),
                        'target': build_target(task, answer)})

    # 固定随机种子再打散:两个任务混在一起训练时,顺序必须是乱的,
    # 否则前半程全是分类、后半程全是抽取,模型会在后半程「忘掉」前一个任务
    random.Random(SEED).shuffle(samples)

    n_dev = max(1, int(len(samples) * DEV_RATIO))
    dev, train = samples[:n_dev], samples[n_dev:]

    for path, rows in ((train_path, train), (dev_path, dev)):
        with open(path, 'w', encoding='utf8') as f:
            for r in rows:
                f.write(json.dumps(r, ensure_ascii=False) + '\n')
        print('写出 %s: %d 条' % (path, len(rows)))
    return len(train), len(dev)


if __name__ == '__main__':
    import os
    import tempfile

    tmp = tempfile.mkdtemp()
    n_train, n_dev = main(os.path.join(tmp, 'train.jsonl'), os.path.join(tmp, 'dev.jsonl'))
    assert n_train + n_dev == 3

    # 回读校验:每一行都必须是合法 JSON 且字段齐全
    with open(os.path.join(tmp, 'train.jsonl'), encoding='utf8') as f:
        for line in f:
            row = json.loads(line)
            assert set(row) == {'context', 'target'}
            assert row['context'].endswith('Answer: ')
    print('回读校验通过:每行都是合法 JSON,字段齐全,context 以 Answer: 收尾')
TODO填什么注意
TODO-1 角色描述Instruction 的第一句定下来就不能改;改了必须重新训练
TODO-2 任务指令每个任务一句话措辞要能区分开不同任务,别写成两句近义句
TODO-3 验证集比例通常 0.1样本少时至少保证每个任务都有验证样本
TODO-4 数据源iter_raw() 换成你的读取逻辑yield 流式产出,大文件不必全部读进内存

答案格式要不要包代码围栏

本讲的原始数据里,SPO 抽取的 target 外面包了一层 ```json 围栏,分类任务的没有。这是两种风格,各有理由:

写法好处代价
裸 JSON解析直接 json.loads,少一步模型偶尔会自己加围栏,下游还得兼容
包 ```json 围栏和模型的自然倾向一致,边界清晰解析前必须先剥围栏

选哪个都行,但一份数据集里必须统一。混着来,模型学到的是「有时候加有时候不加」,下游解析永远要兼容两种,还没法用「是否带围栏」当质量信号。

加第三个任务要动什么

步骤动作检查点
1INSTRUCTIONS 里加一条措辞与已有两条有明显区分度
2iter_raw() 产出新任务的样本答案格式与已有任务风格统一
3重跑截断体检新任务的 target 是不是更长
4重新 shuffle 并切分三个任务在训练集里交替出现
5训练代码不用改
这就是生成式方案最实在的好处 判别式方案加一个任务要加一个模型、一套标注体系、一条部署链路。生成式方案加一个任务,是往 jsonl 里多写几行。代价是输出不再受结构约束,必须自己在下游设校验闸——那是下一页的内容。

06易错点汇总

按「提示一致性 / labels / 长度 / 数据构造」四类归并

⚠️ 一、提示一致性

  • 训练与推理的工单差一个空格。 Answer: 后面那个空格、Input 与正文之间那两个换行,任何一处不一致,对模型来说就是另一场考试。指标掉得莫名其妙,日志里没有任何异常。唯一可靠的防法是把拼接固化成函数,两边都调它。
  • Instruction 改了却没重新训练。 角色描述那一句是模型行为的锚点,改一个字就该重训。线上悄悄改文案是很常见的事故源。
  • Answer: 这个锚点被去掉。 推理时要靠它切出模型写的部分。去掉之后只能靠猜边界,模型复读的工单内容会混进结果里。
  • 两个任务的 Instruction 写成近义句。 「找出三元组」和「抽取三元组信息」区分度太低,模型分不清该走哪套输出格式。措辞要有明显差异。

⚠️ 二、labels 对齐

  • labels 直接等于 input_ids,没有涂 -100。 模型把整张工单一起背下来,推理时会把你的 Instruction 一字不差地复读一遍。这是生成式微调最典型的症状。
  • padding 时给 labels 补了 pad_token_id 顺着上一行复制粘贴就会这么写。后果是模型努力学习「答案写完再输出一串 padding」。labels 补的必须是 -100
  • <eop> 也涂成 -100。 结束符不计损失,模型学不会停笔,推理时会一直写到 max_new_tokens 上限,后面全是重复内容。
  • 手拼 [gMASK]<sop> 不同版本的 GLM 在标记数量和顺序上有差异。用 build_inputs_with_special_tokens(),别自己拼。
  • context_lengthlen(prompts_ids) 代替。 特殊 token 补进来之后,提示段的实际长度已经变了。必须用 input_ids.index(bos_token_id) 去定位。
  • 参与损失的 token 占比没看过。 这个数太低(比如 5%)说明工单太长、答案太短,训练信号被稀释。开训前打印一个 batch 的这个比例,几秒钟的事。

⚠️ 三、长度与截断

  • 照抄 max_source_seq_len=100 不做体检。 固定的 Instruction 头就吃掉五十几个 token,正文只剩四十来个。长一点的评论直接被腰斩。
  • target 被截断。 比 source 被截更糟:模型学到的是「答案写一半就停」,上线后稳定输出残缺 JSON,而且训练日志里看不出任何异常。
  • 两个任务共用一份 max_target_seq_len 却按短的那个定。 分类的 target 只有几个 token,SPO 的 JSON 能到七八十个。必须按长的那个任务定。
  • 用字符数当 token 数估。 中文大致一字一 token,英文和数字是几个字符一个 token。混排文本用字符数估会偏得很多,拿到分词器后要用真实编码长度重算。

⚠️ 四、数据构造

  • 混合任务没打散顺序。 前半程全分类、后半程全抽取,模型在后半程会「忘掉」前一个任务。必须 shuffle,而且用固定种子以便复现。
  • 答案格式不统一。 一部分包 ```json 围栏、一部分不包,模型学到的是「有时候加有时候不加」,下游解析永远要兼容两种。一份数据集里必须统一。
  • jsonl 里出现真实换行。 一条样本被读成两行,后一行不是合法 JSON,要么报错要么被静默跳过。json.dumps 会自动转义,手写文件时才会踩到。
  • ensure_ascii 用了默认的 True。 中文全被转成 \uXXXX,文件能用但完全没法人工看,出问题时排查成本剧增。写中文数据一律 ensure_ascii=False
  • 写完不回读校验。 逐行读回来确认是合法 JSON、字段齐全、contextAnswer: 收尾,几乎零成本,能挡掉「训练两小时才发现数据有问题」。
  • 验证集里没有覆盖到某个任务。 切分时不分层,随机切可能把某个任务的样本全分到训练集,那个任务的验证指标完全空白。

07自测题

点击题目展开答案;这 9 题过了,生成式微调的数据侧就通了

一、数据形态
生成式微调与判别式微调,在「类别」这件事上最大的差别是什么?

判别式必须事先把类别数定死,输出天然落在类别集合内;生成式没有类别这个概念,任务靠 Instruction 文本说清,换任务只改文字。代价是输出不再受结构约束,可能写出解析不了的东西——下游必须自己设解析与字段校验闸

context 的三段分别是什么?哪一段是换任务时唯一要改的?

Instruction(角色与总体约束,所有任务共用一句)、Input(具体任务描述 + 空行 + 待处理句子)、Answer: (固定收尾,留空给模型写)。换任务只改 Input 段的任务描述

为什么两个任务可以混在一个文件里一起训练?要注意什么?

因为它们共用同一套字段结构(context / target),模型按 Instruction 措辞自动切换行为。要注意必须用固定种子 shuffle 打散顺序——前半程全分类、后半程全抽取会让模型在后半程忘掉前一个任务。

二、labels 对齐
labels 里的 -100 是什么?分界线在哪?

-100 是 PyTorch 交叉熵的默认 ignore_index,落在这个值的位置完全不计损失。分界线是 <sop> 的下标:之前全部涂 -100,之后照抄 input_ids(包括结尾的 <eop>)。

labels 没涂 -100 会出现什么现象?

模型把整张工单一起背下来,推理时把你的 Instruction 一字不差复读一遍再写答案。这是生成式微调最典型的症状,看到复读先查 labels。

padding 时 input_idslabels 分别补什么?补错会怎样?

input_idspad_token_idlabels-100。把 labels 也补成 pad_token_id,模型会努力学习「答案写完之后再输出一串 padding」。另外 <eop> 必须计损失,涂成 -100 会让模型学不会停笔。

三、长度与工程
max_source_seq_len=100 时,正文实际能用多少?

远不到 100。那句固定的 Instruction 头本身就占五十几个 token,正文只剩四十来个。所以这个默认值必须做截断体检之后再决定沿用还是调大,不能照抄。

source 被截断和 target 被截断,哪个后果更严重?

target 被截更严重。 截 source 丢的是 Instruction 尾部,模型可能不知道输出格式;截 target 让模型学到「答案写一半就停」,上线后稳定输出残缺 JSON,而训练日志里毫无异常。

为什么本讲的 DataLoader 里没有 attention_mask

ChatGLM 的 forward 会根据 input_ids<sop> 的位置自己构造注意力掩码,不需要外部传。我们只负责给对 input_idslabels 两个字段。这和 BERT 路线必须手工重算掩码是相反的。

术语表

术语英文原形含义
指令微调instruction tuning用「指令 + 输入 + 答案」的样本训练生成式模型,让它按指令产出结构化输出
工单 / 提示段contextInstruction + Input + Answer: 三段式;训练与推理必须逐字一致
目标段target标准答案,通常是一段严格 JSON
忽略标记-100PyTorch 交叉熵的默认 ignore_index,该位置完全不计损失
生成起始符<sop>GLM 的「从这里开始生成」标记;labels 的分界线就是它的下标
生成结束符<eop>生成结束标记,必须参与损失,否则模型学不会停笔
全局掩码符[gMASK]GLM 架构的特殊标记,由分词器自动补上,不要手拼
三元组抽取SPO extraction从文本里抽出 subject / predicate / object 三元组
行式 JSONjsonl一行一条 JSON 的文本格式,换行被转义,可流式按行读
梯度累积gradient accumulation攒若干步的梯度再更新一次,用时间换显存,撑起等效 batch
远程代码信任trust_remote_code允许加载模型仓库里自带的建模与分词代码;ChatGLM 必需