深入ChatGLM的基石:GLM自回归填空技术实战与架构解析

如果你最近在关注开源大语言模型的进展,尤其是那些在中文语境下表现亮眼的选手,那么ChatGLM系列模型的名字你一定不会陌生。它让很多开发者和研究者第一次能在消费级显卡上,相对流畅地体验到一个具备对话能力的“类ChatGPT”模型。但当我们赞叹其对话的连贯性与对中文的友好时,是否曾好奇,支撑起ChatGLM如此能力的底层核心技术究竟是什么?答案就藏在它的前身——GLM(General Language Model)模型之中。GLM并非又一个简单的“微调版GPT”,它提出了一套名为“自回归填空”的独特预训练范式,试图用一个统一的架构,优雅地解决自然语言理解、条件生成和无条件生成这三类看似迥异的任务。今天,我们就抛开论文综述式的泛泛而谈,直接深入到技术实现的腹地,结合代码层面的思考,来拆解这套“秘密武器”究竟是如何工作的。

1. 为何需要GLM:统一架构的野心与挑战

在GLM出现之前,预训练语言模型的江湖大致由三大门派瓜分,它们各自为政,擅长不同的领域。

  • 自编码模型(如BERT):通过“完形填空”(掩码语言模型,MLM)的方式学习文本的双向上下文表示。它就像一个严谨的文本分析师,能深刻理解句子中每个词与上下文的关系,因此在情感分析、命名实体识别等自然语言理解任务上表现卓越。但它的“填空”是独立预测每个掩码位置,缺乏生成文本所需的序列依赖关系,让它难以胜任流畅的文本生成工作。
  • 自回归模型(如GPT系列):严格遵循从左到右的生成模式,根据上文预测下一个词。它是一位才华横溢的作家,擅长续写故事、生成文章。然而,其单向的注意力机制意味着在预测时无法“看到”下文,这在需要全局理解的任务(如文本分类)上存在天然劣势。
  • 编码器-解码器模型(如T5、BART):采用两段式架构,编码器理解输入,解码器生成输出。它是专业的翻译官和总结者,在机器翻译、文本摘要等条件生成任务上得心应手。但这种架构通常参数量更大,且编码器和解码器的能力割裂,在纯理解或纯生成任务上可能并非最优。

这就带来了一个现实的困境:在实际应用中,我们往往希望一个模型能“文武双全”——既能准确理解用户意图(NLU),又能根据指令生成高质量的回答或内容(NLG)。传统的做法是维护多个模型,或者在一个庞大的编码器-解码器模型上进行多任务学习,但这无疑增加了部署和优化的复杂度。

GLM的核心动机,正是要打破这种藩篱。它提出了一个大胆的设想:能否只用一个Transformer架构(具体来说是编码器),通过设计巧妙的预训练任务,让它同时掌握双向理解能力和自回归生成能力? 自回归填空技术,就是实现这一野心的关键钥匙。它不像BERT那样独立预测离散的掩码,也不像GPT那样严格单向生成,而是将文本生成过程巧妙地融入到一个可控的“填空”框架中。

2. 自回归填空:核心机制深度拆解

自回归填空听起来有些抽象,我们可以将其想象成一个更智能、更具挑战性的“完形填空”。传统的完形填空是给你一篇文章,其中挖掉几个孤立的词让你猜。而GLM的填空,则是挖掉一个或多个连续的文本片段(span),然后要求模型按照被打乱的顺序,以自回归(逐个token生成) 的方式,把这些片段重新“写”回去。

2.1 任务构建流程:从原始文本到模型输入

让我们用一个具体的例子,一步步拆解这个过程。假设原始句子是: [x1, x2, x3, x4, x5, x6]

第一步:随机掩码连续片段 GLM不会像BERT那样随机掩码15%的单个token。它会从泊松分布(λ=3)中采样片段的长度,随机选择文本中的连续区间进行掩码,直到累计被掩码的token数达到文本总长度的约15%。比如,它可能选中了两个片段:[x3][x5, x6]。掩码后,原句变为: [x1, x2, [M], x4, [M]],这里的 [M] 代表一个被挖空的片段占位符。

第二步:打乱片段顺序并构造输入 这是GLM区别于T5等模型的关键一步。模型并非按原顺序(从左到右)预测片段,而是将选中的片段顺序随机打乱。假设打乱后顺序为:先预测 [x5, x6],再预测 [x3]。然后,我们将打乱后的片段序列附在掩码后文本的后面,并用特殊的 [S] token作为每个片段的起始标记。最终,模型的输入序列构造为: [x1, x2, [M], x4, [M], [S], x5, x6, [S], x3]

这个序列被分为两部分:

  • Part A: [x1, x2, [M], x4, [M]] — 包含原始上下文和被掩码的位置。
  • Part B: [ [S], x5, x6, [S], x3 ] — 包含被打乱顺序的答案片段,用于让模型学习预测。

注意:在预训练时,Part B中的答案(x5, x6, x3)对模型是可见的,模型的任务是学习如何根据Part A的上下文,以自回归的方式生成Part B。在推理时,Part B的答案部分会被替换为 [MASK] token,由模型生成。

2.2 二维位置编码:空间与顺序的智慧

传统的Transformer使用一维位置编码来标记token在序列中的顺序。但在GLM的填空任务中,一个token同时具有两种身份:它在原始句子中的位置,以及它在被预测的片段内部的位置。GLM引入了二维位置编码来同时编码这两种信息。

  • 位置1(原始位置):编码每个token在原始输入文本(即Part A中未被掩码的部分)中的绝对或相对位置。这有助于模型保持对原始句子结构的理解。
  • 位置2(片段内位置):对于Part A中的token,其片段内位置被设为0(表示不属于待预测片段)。对于Part B中每个片段内的token,则从1开始重新编号。这明确告知了模型当前正在生成的是哪个片段,以及生成到了该片段的第几个词。

这种设计使得模型能够清晰地区分“上下文token”和“待生成token”,并理解生成任务中的层次结构,是统一理解和生成能力的关键。

2.3 注意力掩码:可控的信息流

为了实现自回归生成,同时又能利用双向上下文信息,GLM设计了一种分区的注意力掩码机制。这可能是整个架构中最精妙的部分。

我们可以通过一个简化的注意力掩码矩阵来理解:

能否看到 x1 x2 [M] x4 [M] [S] x5 x6 [S] x3
x1
x2
[M]
x4
[M]
[S]
x5
x6
[S]
x3

规则解读:

  1. Part A 内部:所有token(包括 [M])之间是双向可见的。这意味着在预测片段时,模型可以充分考虑到整个原文的上下文信息,这是其拥有强大NLU能力的基础。
  2. Part B 内部:遵循标准的自回归因果掩码。每个token只能看到它自己以及它之前的token(包括Part A的所有token)。这确保了生成过程的因果性,使其能够进行序列生成。
  3. 从Part B 看 Part A:允许。Part B中的token可以充分“回顾”Part A的全部信息。
  4. 从Part A 看 Part B(答案部分):禁止。Part A中的token不能“偷看”Part B的答案,否则预训练任务就失去了意义。

这种设计创造了一种“混合注意力”模式:在编码上下文时是双向的,在生成答案时是单向自回归的。一个单一的Transformer编码器,通过不同的注意力掩码模式,动态地切换了它的“工作模式”。

3. 从预训练到微调:GLM的任务适配哲学

GLM的野心不止于一个新颖的预训练任务,更在于一套统一的“任务框架”。其核心理念是:将所有下游任务都转化为“填空”问题。这种思想与T5的“Text-to-Text”有异曲同工之妙,但实现方式更贴合其自回归填空的架构。

3.1 多粒度预训练目标

为了应对不同性质的下游任务,GLM在预训练阶段就设计了三种不同粒度的填空目标,并交替进行训练:

预训练目标 掩码策略 片段长度 主要针对任务
Token-Level (词汇级) 掩码短片段,总计覆盖原文~15%token 泊松分布采样(λ=3),多为短span 自然语言理解(NLU),如分类、NER
Sentence-Level (句子级) 掩码完整句子,覆盖原文~15%token 片段为整个句子 条件生成(Seq2Seq),如摘要、翻译
Document-Level (文档级) 掩码一个很长的连续片段 长度为原文的50%-100% 无条件生成(Language Modeling)

这种多目标预训练确保了模型既能处理细粒度的理解,也能驾驭长文本的生成。

3.2 下游任务微调范式

在微调阶段,GLM通过设计“提示模板”(Prompt Template),将各类任务都规整到填空框架下。

分类任务示例(情感分析):

  • 原始样本(文本="这部电影太精彩了!", 标签="积极")
  • 转化为填空:设计模板 “这部电影太精彩了!它的情感是[MASK]的。”
  • 标签映射:将“积极”、“消极”等标签映射为具体的填空词,如 v(“积极”) = “好”v(“消极”) = “糟”
  • 模型操作:模型预测 [MASK] 位置为“好”的概率,即对应“积极”类别的概率。通过一个线性层将词汇表概率映射到类别概率。

生成任务示例(文本摘要):

  • 原始样本(长文档=“...”, 摘要=“...”)
  • 转化为填空:将长文档作为Part A,在末尾添加一个 [MASK] token,然后将摘要作为Part B(在训练时可见,推理时由模型生成)。
  • 模型操作:模型基于长文档的完整上下文(双向注意力),以自回归的方式(单向生成)生成 [MASK] 对应的摘要内容。

这种统一的微调方式极大地简化了任务适配流程,你不需要为分类任务额外添加一个分类头,也不需要为生成任务切换成编码器-解码器结构。同一个模型,同一种前向传播方式,通过不同的输入构造,就能应对多种任务。

4. 代码实战:窥探GLM实现的关键片段

理论固然重要,但代码能让我们看得更真切。我们无法在此重现完整的GLM训练代码,但可以剖析几个关键实现环节,来加深理解。以下示例基于对GLM开源代码的解读和简化。

4.1 构造掩码与片段打乱

这是数据预处理的核心步骤。假设我们有一个已经token化的文本 input_ids

import torch
import numpy as np

def create_autoregressive_blank_infill(input_ids, mask_ratio=0.15, poisson_lambda=3):
    """
    简化版的GLM自回归填空样本构造函数。
    Args:
        input_ids: 原始token id序列 [seq_len]
        mask_ratio: 目标掩码比例
        poisson_lambda: 泊松分布参数,控制片段平均长度
    Returns:
        processed_ids: 处理后的输入ID
        target_ids: 需要被预测的target ID(对应Part B的答案部分)
        attention_mask: 二维注意力掩码矩阵
        position_ids_1d: 原始位置ID(简化,实际为2D)
    """
    seq_len = len(input_ids)
    # 1. 确定要掩码的token总数
    num_to_mask = max(1, int(seq_len * mask_ratio))
    
    masked_spans = []
    total_masked = 0
    # 2. 使用泊松分布采样片段长度,直到掩码足够数量的token
    while total_masked < num_to_mask:
        span_length = np.random.poisson(lam=poisson_lambda)
        span_length = max(1, span_length) # 至少为1
        if total_masked + span_length > seq_len:
            span_length = seq_len - total_masked
        start = np.random.randint(0, seq_len - span_length + 1)
        # 简单起见,避免重叠,实际代码更复杂
        masked_spans.append((start, start+span_length))
        total_masked += span_length
    
    # 3. 打乱片段顺序
    np.random.shuffle(masked_spans)
    
    # 4. 构建Part A (上下文+掩码占位符) 和 Part B (打乱的答案)
    # 这里大幅简化了[M]和[S] token的插入逻辑,仅示意流程
    # 实际中,Part A的[M]是一个特殊token,Part B的每个片段前有[S]
    part_a_tokens = []
    part_b_tokens = []
    last_end = 0
    for start, end in masked_spans:
        # 添加未被掩码的上下文
        part_a_tokens.extend(input_ids[last_end:start])
        # 添加掩码占位符 [MASK]
        part_a_tokens.append(MASK_TOKEN_ID) # 假设MASK_TOKEN_ID已定义
        # 将被掩码的片段加入Part B
        part_b_tokens.append(S_TOKEN_ID) # 片段开始符
        part_b_tokens.extend(input_ids[start:end])
        last_end = end
    # 添加剩余未被掩码的上下文
    part_a_tokens.extend(input_ids[last_end:])
    
    # 5. 拼接最终输入
    processed_ids = part_a_tokens + part_b_tokens
    # target_ids 对应 part_b_tokens 中答案部分(不含S_TOKEN_ID)
    target_ids = [tid for tid in part_b_tokens if tid != S_TOKEN_ID]
    
    return processed_ids, target_ids

4.2 注意力掩码矩阵的实现

在Transformer的前向传播中,我们需要根据输入构造对应的注意力掩码矩阵。

def get_glm_attention_mask(processed_ids, mask_token_id=MASK_TOKEN_ID, s_token_id=S_TOKEN_ID):
    """
    生成GLM分区注意力掩码。
    这是一个概念性实现,展示了分区逻辑。
    """
    seq_len = len(processed_ids)
    # 初始化一个全0矩阵(表示不可见)
    attention_mask = torch.zeros((seq_len, seq_len), dtype=torch.bool)
    
    # 找到Part A和Part B的分界点(第一个[S] token的位置)
    try:
        split_idx = processed_ids.index(s_token_id)
    except ValueError:
        split_idx = seq_len # 如果没有Part B(某些任务模式)
    
    # Part A (索引 0 到 split_idx-1)
    # Part A内部全连通(双向)
    attention_mask[:split_idx, :split_idx] = 1
    
    # Part B (索引 split_idx 到 seq_len-1)
    for i in range(split_idx, seq_len):
        # Part B的token i 可以看到所有Part A的token
        attention_mask[i, :split_idx] = 1
        # Part B的token i 可以看到Part B中它自己及之前的token(自回归)
        attention_mask[i, split_idx:i+1] = 1
    
    # 注意:实际实现中,mask_token_id和s_token_id可能用于更精细的控制,
    # 例如确保Part A的[MASK]位置在计算loss时被忽略等。
    return attention_mask

4.3 模型前向传播的简化视图

在GLM的Transformer层中,前向传播需要结合2D位置编码和分区注意力掩码。

class GLMTransformerLayer(nn.Module):
    # ... 初始化部分省略 ...
    
    def forward(self, hidden_states, attention_mask, position_ids):
        """
        hidden_states: 输入嵌入 [batch, seq_len, hidden_size]
        attention_mask: 上述函数生成的掩码矩阵 [batch, 1, seq_len, seq_len] 或 [batch, seq_len, seq_len]
        position_ids: 二维位置编码,形状为 [batch, seq_len, 2]
        """
        # 1. 应用2D位置编码(通常是可学习的嵌入层)
        pos_emb_1 = self.position_embeddings_1(position_ids[:, :, 0]) # 原始位置
        pos_emb_2 = self.position_embeddings_2(position_ids[:, :, 1]) # 片段内位置
        hidden_states = hidden_states + pos_emb_1 + pos_emb_2
        
        # 2. 自注意力层,使用GLM特有的注意力掩码
        # 注意力掩码确保了Part A的双向和Part B的自回归属性
        attention_output = self.attention(
            hidden_states,
            attention_mask=attention_mask
        )
        
        # 3. 前馈网络等后续层...
        # GLM通常采用Pre-LN (LayerNorm在残差连接之前) 的变体
        return output

运行这些代码片段需要完整的模型上下文,但它们清晰地勾勒出了GLM实现的核心骨架:动态的样本构造、分区的注意力控制、以及融合了两种位置信息的编码方式。在实际的ChatGLM中,这些机制被高度优化,并集成了旋转位置编码(RoPE)等更先进的技术,但其灵魂依然是这套自回归填空的范式。

理解GLM,不仅是为了读懂一篇论文,更是为了掌握一种构建通用语言模型的思维方式。它告诉我们,通过精心设计预训练任务和注意力控制,模型的潜力可以被更灵活地激发。当你下次与ChatGLM对话时,或许可以想象,它正在内部进行着无数个巧妙的“填空”游戏,将你的问题转化为它最擅长的生成任务,从而给出那些令人惊喜的回答。这种统一而优雅的架构思想,正是其能够在众多开源模型中脱颖而出的深层原因。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐