英语考试的完形填空,就是大模型Tab写代码的全部方法

2026-08-06 · Steve Chan

完形填空做了十几年:一段话挖掉几个词,你要根据前后文把它补回去。 老师说的技巧永远是那一句——"别只看空前面,一定要往后读两行"。

你可能没意识到,今天你在 VS Code 里按下补全键、光标中间自动冒出三行代码, 用的是一模一样的做题方法:看前面写了什么,看后面接着什么,然后把中间补上。

麻烦在于:GPT 这类模型天生只会"接龙"——只能用左边猜右边,被明令禁止偷看后文。 让它学会做完形填空的那个办法,叫 FIM(Fill-in-the-Middle),思路简单到有点耍赖。

这篇文章不看论文、不推公式,全部用能跑起来的 PyTorch 代码, 从英语试卷上的那几个括号,一路接到 Copilot 补代码的真实机制。顺手回答四个灵魂拷问:

  • GPT 号称只会往后接龙,为什么它能补你光标中间的代码?
  • 因果掩码明明规定"不许看后文",FIM 是怎么合法作弊的?
  • BERT 天生就会做完形填空,为什么不干脆用它来补代码?
  • <|fim_prefix|> <|fim_suffix|> <|fim_middle|> 这三个怪 token 到底在干嘛?

0. 一句话主线

如果只能留一句话,那就是这句:

完形填空的灵魂是"用两边猜中间";GPT 的天性是"用左边猜右边"。 FIM 干的事情只有一件——把右边那段剪下来、搬到左边去。 于是"偷看后文"这件被禁止的事,摇身一变成了完全合法的"回看前文"。

不改模型结构、不改损失函数、不加任何新参数,只改数据的摆放顺序。 这就是今天 Copilot、Cursor、通义灵码在你光标处补全代码的全部秘密。

先感受一下"往后读两行"到底值多少钱——用信息熵量一量:

import torch

cand   = ["学校", "公园", "书店", "医院"]
p_left = torch.tensor([0.30, 0.25, 0.25, 0.20])   # 只看左文「我昨天去 ___」(示意分布)
p_both = torch.tensor([0.02, 0.01, 0.95, 0.02])   # 再看到右文「___ 买了一本书」(示意分布)

entropy = lambda p: -(p * p.log()).sum()          # 熵 = 还剩多少不确定性
print(f"只看左边  熵 = {entropy(p_left):.3f}  -> 四个地方都说得通")
print(f"两边都看  熵 = {entropy(p_both):.3f}  -> 几乎锁定「书店」")
# 只看左边 1.376 / 两边都看 0.251 —— 后文一句话,把不确定性砍掉了 82%

那 82% 就是"后文"的价值。代码里这个价值只会更高——你光标下面那行 return result, 几乎单枪匹马就决定了中间该写什么。下面所有内容都挂在这一点上。


1. "接龙"和"完形填空",是两道不同的题

先把两道题的形状摆清楚:

给你什么 让你猜什么 谁擅长
接龙(自回归) 左文 下一个词 GPT
完形填空 左文 + 右文 中间那段 人类考生 / BERT

代码补全天生是第二种题。你在 IDE 里写代码,光标从来不在文件末尾:

code = """
def load_config(path):
    with open(path) as f:
        raw = f.read()
    ▮                       # ← 光标在这里,上有 raw,下有 return cfg
    return cfg
"""

光标上面告诉你"有个字符串叫 raw",光标下面告诉你"必须造出一个叫 cfg 的东西"。 只看上文,你能写出一万种合法的下一行;看了下文,答案基本只剩 cfg = json.loads(raw) 这一类。

所以问题变成:一个只会接龙的模型,怎么去做一道完形填空题?


2. GPT 的考场纪律:因果掩码,明令禁止偷看后面

GPT 做不了完形填空,不是因为笨,是因为规矩不许。 它的注意力里有一张"下三角"的掩码矩阵,规定每个位置只能看到自己和左边:

import torch

toks = ["def", "add", "(a,b)", ":", "result", "=", "a+b", "return", "result"]
T = len(toks)
mask = torch.tril(torch.ones(T, T, dtype=torch.int))   # 下三角 = 只能往左看

print(mask)
print("第 4 个位置(result)能看到:", [toks[i] for i in mask[4].nonzero().flatten()])
# ['def', 'add', '(a,b)', ':', 'result'] —— 后面的 return 它一眼都看不到

这张下三角矩阵就是考试纪律:答第 4 题时,第 5 题往后的卷面全被盖住。

为什么要这么严?因为训练时模型在同一批数据里同时学"第 1 个字→第 2 个字""第 2 个字→第 3 个字"…… 如果允许它看后面,它一低头就抄到了标准答案,训练直接作废。 因果掩码是自回归模型能高效训练的地基,动不得。

于是矛盾摆在这里:

代码补全必须看后文,而 GPT 的地基禁止看后文。


3. 那为什么不用 BERT?它天生就会做完形填空

好问题。BERT 的训练任务(MLM,掩码语言模型)字面意思就是完形填空: 把句子里 15% 的词换成 [MASK],模型看着左右两边把它们猜回来。它天生双向。

但 BERT 有个致命短板:它只会填"给定个数"的空。

# BERT 式填空:挖了几个空是题目给定的,模型只在这几个坑上出答案
masked = ["def", "add", "(a,b)", ":", "[MASK]", "[MASK]", "[MASK]", "return", "result"]
print("必须提前告诉它挖了几个空:", masked.count("[MASK]"))   # 3

# 自回归式生成:写几个词是模型自己决定的,写到 <eos> 才停
draft, out = ["result", "=", "a", "+", "b", "<eos>"], []      # 示意:模型逐个吐出的 token
for t in draft:
    if t == "<eos>":
        break
    out.append(t)
print("模型自己决定写多长:", out)                              # ['result','=','a','+','b']

差别看着小,实际是天堑。你在 IDE 里按下补全,没有人知道该补几个 token—— 可能是半个变量名,也可能是十七行的错误处理。BERT 要求你先说"我要填 3 个词",这根本没法用。

一句话总结这场对垒:

BERT 会填空,但不会写作文;GPT 会写作文,但不会填空。 而代码补全要的是一个会写作文的填空高手

FIM 的答案是:不换模型,让 GPT 学会做填空题。


4. FIM 的绝招:不改模型,改座位

思路简单到令人发指——既然模型只许往左看,那就把"右边"搬到左边去。

一段代码被切成三份:光标前的 prefix、要补的 middle、光标后的 suffix。 原来的顺序是 P → M → S,FIM 把它重排成:

<|fim_prefix|> P <|fim_suffix|> S <|fim_middle|> M

看清楚:suffix 被挪到了 middle 前面。 十行代码就能实现:

def apply_fim(tokens, lo, hi):
    """把 tokens[lo:hi] 挖成待填的 middle,并按 PSM 顺序重排"""
    prefix, middle, suffix = tokens[:lo], tokens[lo:hi], tokens[hi:]
    return (["<|fim_prefix|>"] + prefix
          + ["<|fim_suffix|>"] + suffix
          + ["<|fim_middle|>"] + middle)

code = ["def", "add", "(a,b)", ":", "result", "=", "a+b", "return", "result"]
fim  = apply_fim(code, 4, 7)          # 挖掉 result = a+b
print(" ".join(fim))
# <|fim_prefix|> def add (a,b) : <|fim_suffix|> return result <|fim_middle|> result = a+b

对着这行输出念一遍,你就懂了整个 FIM:

前面是def add(a,b):后面是return result现在,把中间补上:」

这不就是一道完形填空的题干格式吗?前情、后情、然后开始作答。 模型从头到尾还是在老老实实地"用左边猜右边"——只是这一次,左边里已经包含了后文。


🤔 疑惑点一:把后文搬到前面,不就把代码顺序搞乱了吗?模型不会学疯吗?

不会。因为那三个 <|fim_*|> 标记就是"题干格式"本身——模型在训练里见过几百亿次,它学会的不是"代码就长这样",而是"看到这个格式,就该做填空题"。

打个比方:英语试卷上的完形填空,选项 A/B/C/D 印在文章下面,这并不意味着英语句子的语序错了。 A. tell B. told C. telling D. to tell题目的排版,不是语言本身。你的大脑一眼就能区分二者。

模型也一样。<|fim_prefix|> 这些 token 在正常代码里永远不会出现, 所以它们是绝对无歧义的信号灯——一亮起来,模型就知道"接下来是填空题的排版,不是代码的语序"。

而且训练时并不是所有样本都做 FIM 变换。实践中通常留一半左右保持原样:

  • 一半样本走 FIM:学会"看到题干格式就填中间"
  • 一半样本走普通接龙:保住原本的续写能力,别把作文本事忘了

OpenAI 2022 年那篇 FIM 论文里最漂亮的结论就是:加了 FIM 训练,模型原本的续写能力几乎零损失—— 论文标题里那句 "FIM-for-free"(白送的填空能力)说的就是这件事。 今天 StarCoder、CodeLlama、DeepSeek-Coder、Qwen-Coder 全都这么训。


5. 用代码证明:重排之后,middle 真的能"合法"看到 suffix

嘴上说没用,直接查位置索引。因果掩码只认一件事:你的下标比我小,我就能看见你。

import torch

i_suf = fim.index("<|fim_suffix|>")
i_mid = fim.index("<|fim_middle|>")

print("suffix 段占据的位置:", list(range(i_suf + 1, i_mid)))     # [6, 7]
print("middle 段占据的位置:", list(range(i_mid + 1, len(fim))))  # [9, 10, 11]

T = len(fim)
mask = torch.tril(torch.ones(T, T, dtype=torch.int))
first_mid = i_mid + 1                                   # 要生成的第一个 middle token
print("它能看到:", [fim[i] for i in mask[first_mid].nonzero().flatten()])

输出的最后一行里,returnresult 赫然在列

对比第 2 节:在原始顺序下,第 4 个位置(要填的空)看不到任何后文; 重排之后,同样的因果掩码、同样的模型、一个参数没改,后文已经堂堂正正地躺在它的视野里了。

这就是全部的"作弊"手法——它甚至称不上作弊,因为规矩一条都没破。 规矩说"只能往左看",FIM 说"那我把它放到左边不就行了"。


6. loss 只算 middle:只批改被挖空的那几个格子

还有一个容易忽略但很关键的细节:训练时只在 middle 段上计算损失。

道理很直白——prefixsuffix题干,是白送给模型的条件, 让它去"预测题干"毫无意义,还会稀释真正的学习信号。只有 middle答案,只批改答案。

import torch
import torch.nn.functional as F

vocab = {t: i for i, t in enumerate(dict.fromkeys(fim))}
ids   = torch.tensor([vocab[t] for t in fim])

labels = ids.clone()
labels[: i_mid + 1] = -100          # 题干(含三个标记)全部屏蔽,-100 = 不计 loss
print(labels.tolist())              # 前面一串 -100,只有末尾 middle 保留真实 id

torch.manual_seed(0)
logits = torch.randn(len(ids), len(vocab))                   # 假装这是模型输出
loss = F.cross_entropy(logits, labels, ignore_index=-100)
print(f"只在 {int((labels != -100).sum())} 个 middle token 上算 loss -> {loss:.3f}")
# 为了看清"哪些位置参与批改",这里省略了真实训练中输入/标签错开一格的细节

ignore_index=-100 这一个参数,就是"只批改被挖空的格子"的全部实现。


🤔 疑惑点二:还有个 SPM 模式,为什么要有两种排法?

因为把 suffix 放前面还是把 prefix 放前面,决定了模型在"补全刚开始打的那半个词"时能不能接得住。

除了上面的 PSM(Prefix-Suffix-Middle),还有一种排法叫 SPM:

PSM:  <prefix> P <suffix> S <middle> M      # 前情 → 后情 → 作答
SPM:  <suffix> S <prefix> P <middle> M      # 后情 → 前情 → 作答

区别在于谁紧挨着 middle。SPM 里 prefix 就贴在答案前面,中间不隔任何东西—— 这在一种极常见的场景下很重要:你已经敲了半个词。

比如你打到 cfg = json.lo▮,模型要补的是 ads(raw)。 这时 prefix 的最后几个字符(json.lo)和答案的第一个字符是咬死在一起的, 中间隔着一段 suffix 会让模型更容易接崩。SPM 把它俩贴紧,续得更顺。

实践中的做法是两种排法混着训(各占一半),推理时按场景挑一种用。 说到底还是那句话:变的只是座位表,模型一个参数都没动。


🎬 动手:亲眼看着"后文"被搬到左边

把上面所有画面缝成一个能跑的最小 FIM 数据管线——注意 rate=0.5, 一半样本做填空、一半保持接龙,这是真实训练里的标准配比:

import random

def make_sample(tokens, rate=0.5, seed=0):
    rng = random.Random(seed)
    if rng.random() > rate:
        return tokens                                  # 保持原样:普通接龙样本
    lo, hi = sorted(rng.sample(range(len(tokens) + 1), 2))
    return apply_fim(tokens, lo, hi)                   # 随机挖一段:FIM 样本

code = "def add ( a , b ) : result = a + b return result".split()
for s in range(5):
    print(f"seed={s}:", " ".join(make_sample(code, seed=s)))
# 你会看到有的行原封不动,有的行被剪开重排 —— 同一个模型两种题都练

仓库里的动画脚本把这个"剪切—搬运"的过程画了出来:

python cloze_fim_visualization.py

左图是原始顺序:光标处那一格向左射出一道视野,后文那几格被灰掉,打上"看不见"。 中图是 FIM 重排后:同一道视野,因为 suffix 已经被搬到左边,它现在亮着被包含在内。 右图是动画:middle 一个 token 一个 token 地被生成,你能看到它每一步的可见窗口—— 始终只往左看,却始终看得见后文。


缝合:把所有画面接起来

完形填空里的东西 英语课怎么讲 这篇文章怎么看(画面) 在大模型里是什么
挖掉的那个括号 光标所在的位置(第 1 节) middle,唯一要生成的部分
空前面那半句 上文 已经写好的代码(第 1 节) prefix
"一定要往后读两行" 下文 砍掉 82% 不确定性(第 0 节) suffix,FIM 的全部价值所在
不许翻到后一页 考场纪律 下三角掩码(第 2 节) 因果掩码,自回归的地基
题干的排版 A/B/C/D 印在文章下面 排版 ≠ 语序(疑惑点一) <\|fim_prefix\|> 等特殊 token
只批改填空题 阅读理解不算这题的分 只在答案格上算分(第 6 节) ignore_index=-100

三句话总结这篇文章:

代码补全天生是完形填空题,因为光标下面那行几乎决定了中间该写什么(第 0、1 节); 但 GPT 被因果掩码钉死在"只能往左看"上,它天生只会接龙、不会填空(第 2、3 节); 于是 FIM 把后文剪下来搬到左边——规矩一条没破,填空能力凭空长出来(第 4、5 节)。

英语老师当年反复念叨的"一定要往后读两行",你大概觉得那只是个应试技巧。 十几年后,同一句话被写成了三个特殊 token,塞进每一个代码大模型的训练数据里, 成了你光标中间那三行代码凭空冒出来的原因。

下次按下补全键的时候,你可以想一想: 它正在做的,是一道你做过几百遍的完形填空——只不过这一次,它把答案纸剪开重新排了个座位。


备注(选题/标题): 这篇走"学生时代的日常经验其实是 AI 机制"的钩子, 比"高中数学"系列门槛更低(文理科通吃)。若要并入高中系列,标题可换成: 《英语卷子上做了十几年的完形填空,原来是大模型补代码的唯一办法》。