🛠️ 第2章 AI 编程的最小原理——知道边界在哪里
📄 一句话概括

它干的事只有一件:根据前面的内容,猜下一个 token 是什么。 代码特别适合这种猜法——括号必须配平、关键字后面只能跟那几样、缩进有含义。 约束越多,能猜的就越少,所以代码比自然语言「好猜」得多。

📝 本章和前面几章的关系

上一篇讲完「它是什么」,这一篇往下拆一层:它凭什么能处理代码。 这一篇不讲神经网络、不讲注意力机制—— 那些是 22 课 的内容。 这一篇只讲一个最小的原理,因为它足够解释后面所有的毛病。 这个原理就是「预测下一个 token」。 它解释了为什么它能写代码、为什么它会编 API、为什么它写完就忘、为什么它需要你把边界说清楚。 一篇顶四篇,所以它放在第 2 章开头。 这一篇的 Demo 是自己搭一个 2-gram 模型——真跑,真算困惑度,真让它生成一段代码, 然后真拿去跑,看它报什么错。 这个模型比真的小几亿倍,但它的毛病和真模型是同一类。

一、预测下一个 token

先把「token」这个词说清。 它不是字,也不是词,是「模型眼里的最小单位」。英文里常见词可能是一个 token, 罕见词会被拆成几段;中文一个字可能一个 token,也可能两个字一个。 代码里的 def、return、( 基本都是单独的 token。 模型做的事,用一句话说完:给它前面的一串 token,它算出「下一个 token 最可能是什么」, 挑一个,接上去,再算下一个。 就这样一个循环,重复几百次,一段代码就出来了。 这里有个容易被忽略的点:它每次只做一次选择,不做全局规划。 它没有「先把整个函数想好再写」这一步—— 它是写一个字算一次,写下一个字的时候,前面写的东西已经变成「既定事实」了。 这就是为什么它一开始写歪了,后面会一路歪下去。 也正因为这样,它没法「回头改」。 它改代码的方式是「把前面那段重新生成一遍」—— 在你看来是「它改了第三行」,在它看来是「从第三行开始,后面全部重写」。 这个视角一换,很多行为就说得通了。

二、代码为什么特别适合这种预测

「猜下一个」这件事,难易程度取决于「有多少种合理的下一个」。 候选越少,越好猜;候选越多,越容易猜错。

位置 下一个能是什么 候选数
def 后面 函数名(几乎只能是标识符) 很少
( 后面 参数名或右括号 少
: 换行后 缩进 + 一条语句 多
自然语言里「我觉得」后面 什么都有可能 极多

代码的候选少,是因为它有大量硬约束: 括号必须配平、引号必须成对、缩进有语法含义、关键字后面只能跟特定几类东西。 这些约束把「可能性空间」压得很小——小到统计规律就能抓住。 自然语言没有这种硬约束。「我今天很」后面可以是高兴、累、忙、想你、 不想说话……每一种都合法,而且没有语法能排除掉任何一种。 所以同一个模型,在代码上显得聪明,在闲聊上显得平庸—— 不是它偏科,是这两件事的难度本来就不一样。

三、训练数据里有什么、没有什么

它见过的东西,大致是这些:GitHub 上的公开代码、技术文档、问答网站、博客、教程、issue 讨论。 这些量非常大,大到「常见的写法」它都见过。 但它没见过的东西同样关键:

没见过 后果
你公司的私有代码 你项目里的约定、封装、命名习惯,它一概不知
上周刚发布的 API 它记的是旧版本,写出来的是过期写法
你脑子里的业务规则 「退款要审批」这种规则,代码里没有,它也无从得知
真实运行环境 你机器上装了什么版本、有没有代理,它看不见

这张表要记住,因为它解释了后面所有的「它怎么会这样」。 它犯的错,绝大部分不是「不会」,是「没见过」或者「见过旧的」。 还有一个更微妙的问题:它见过「坏代码」。 训练数据里既有好代码也有烂代码,而且烂代码往往更多(因为烂代码不需要通过审查就能上传)。 所以它默认写出来的东西,是「平均水平的代码」,不是「好代码」。 想要好代码,得靠规矩和审查,不能靠它自觉。

四、动手 Demo:用 2-gram 猜下一个 token,代码比中文好猜多少

这个 Demo 自己搭一个最小的语言模型:2-gram。 它的全部本事就是「数一数 A 后面出现过什么,挑最常见的那个」。 真模型只是把「前一个」换成「前几千个」,把「数数」换成「神经网络」——原理没变,毛病也没变。 要量两个数:困惑度(平均在几个候选里犹豫)、生成出来的东西能不能跑。 第二个数是这个 Demo 的重点——它生成的代码看着特别像代码,然后一跑就报错。

import collections, math, re

def tok(s):
    return re.findall(r"[A-Za-z_]\w*|\d+|[^\s\w]", s)

class NG:
    def __init__(self):
        self.c = collections.defaultdict(collections.Counter)

    def fit(self, t):
        for i in range(len(t) - 1):
            self.c[t[i]][t[i + 1]] += 1

    def guess(self, w):
        if w in self.c:
            return self.c[w].most_common(1)[0][0]

# 代码语料 vs 中文语料,各训一个,比困惑度与生成结果

实跑输出:

--------------------------------------------------------------------
用 2-gram 猜下一个 token,代码比中文好猜多少
--------------------------------------------------------------------


  ① 先把两段文字切成 token
  --------------------------------------------------------------------
  代码切出 43 个 token,中文切出 57 个 token。
  代码前 16 个:def total ( items ) : s = 0 for x in items : s =
  中文前 16 个:这 个 函 数 先 把 所 有 元 素 加 起 来 , 然 后

  ② 一个极简的 2-gram:只看前一个 token 猜下一个
  --------------------------------------------------------------------
  这是语言模型的最小形态:数一数「A 后面出现过什么」,按频率挑最常见的那个。
  真模型只是把「前一个」换成「前几千个」,把「数数」换成「神经网络」。原理没变。

  ③ 同一个位置,几种可能
  --------------------------------------------------------------------
  代码里 def      后面出现过 2 种不同 token,最常见的是 ['total']
  代码里 return   后面出现过 3 种不同 token,最常见的是 ['s']
  代码里 =        后面出现过 2 种不同 token,最常见的是 ['0']
  代码里 (        后面出现过 1 种不同 token,最常见的是 ['items']

  ④ 猜得准不准:困惑度
  --------------------------------------------------------------------
  代码语料的困惑度 8.40
  中文语料的困惑度 23.12
  困惑度的意思是「平均在几个候选里犹豫」。数值越小越好猜。
  代码这边小 2.8 倍。
  ! 这个差距不是「代码简单」,是「代码的约束多」——括号必须配平、缩进有含义、
  关键字后面只能跟那几样。

  ⑤ 让它自己往下写 24 个 token
  --------------------------------------------------------------------
  生成:def total ( items ) : s = 0 for x in items ) : s = 0 for x in items ) : s
  ! 看着像代码,但它不是代码——它是「形状对的字符串」。

  ⑥ 拿去真跑
  --------------------------------------------------------------------
  写进 gen.py,跑一遍:退出码 1
  报错:SyntaxError: invalid decimal literal
  这就是语言模型和程序的区别:它保证「下一个 token 像那么回事」,不保证「整段能跑」。
  形状是学出来的,语义不是。

困惑度是 8.40 对 23.12,代码这边小 2.8 倍。 这个数说明的是「代码的局部规律强」,不是「模型懂代码」。 2-gram 只往前看一个 token,它依然「猜得很准」—— 因为它只需要知道「def 后面跟标识符」这种局部规律。 生成那一段更值得看:def total ( items ) : s = 0 for x in items ) : s = 0 …。 它完美地复现了代码的形状——有函数头、有冒号、有赋值、有循环关键字。 但它把括号配错了(items ) 前面没有左括号),而且 for 写在了冒号后面。 第六步是这一篇的落点:把这段生成的代码存成 gen.py 真跑一遍,退出码 1, 报 SyntaxError: invalid decimal literal。 形状是学出来的,语义不是。 它保证「下一个 token 像那么回事」,不保证「整段能跑」。 这个 2-gram 的毛病和真模型是同一类:局部看着很对,全局一跑就崩。 区别只在概率——2-gram 几乎必崩,真模型偶尔崩。 「偶尔崩」比「必崩」危险得多,因为它会让你放松警惕。

30 秒实验

  1. 随便找一段你熟悉的代码,遮住下一行,自己猜两次。 猜对的那一刻,你就体会到了「为什么代码好猜」—— 因为你脑子里也在做同一件事:按约束排除候选。
  2. 把同一段代码里的注释删掉再让模型读,看它的理解差多少。 注释是唯一「用自然语言写代码意图」的地方——删掉之后,很多决定就没法解释了。
  3. 想一想:你项目里有哪些「它一定没见过」的东西? 自研的框架、内部约定、私有的依赖——这些东西你如果不说,它只能按公开世界的惯例猜。

常见误区

  1. 「它会思考,只是我们看不见过程。」 它没有「先想好再写」这一步——它是写一个算一个,写下的就是既定事实。 所谓「推理」,是它在 token 序列里复现了推理的写法,不是脑子里真有个推理器。
  2. 「2-gram 这种玩具说明不了真模型。」 它说明的是「预测下一个」这个原理本身的局限,而这个局限真模型也有。 真模型只是把这个局限推到了很少发生的位置——很少发生不等于不发生。
  3. 「它训练时见过那么多代码,肯定懂编程。」 见过不等于懂。 它见过大量「能跑的代码」,也见过大量「看起来能跑其实不能跑的代码」—— 而它分不出这两类的区别,因为它在训练时只学「下一个是什么」,没人告诉它「这段能不能跑」。
自测三问(点开对答案) 1. 「预测下一个 token」这个原理,能解释它的哪些毛病? —— **至少四个:** 会编 API(见过类似的写法就往上凑)、写完就忘(它没有记忆,只有当前上下文)、 一路歪到底(前面写错了后面改不回来)、需要你说清边界(它没法自己判断你要什么)。 2. 为什么代码比自然语言「好猜」? —— **因为代码的硬约束多**——括号配平、缩进有含义、关键字后面只能跟特定几类东西。 **约束多,候选就少,统计规律就够用。** 自然语言没有这种约束。 3. 它默认写出来的是「好代码」还是「平均代码」?为什么? —— **平均代码。** 训练数据里好代码和烂代码都有, **而且烂代码往往更多**(烂代码不需要通过审查就能上传)。 **想要好代码得靠规矩和审查,不能靠它自觉。**

下一篇 2.2 讲它做一件事还需要什么:光会猜下一个 token 不够, 还得看得见上下文、能伸手读文件、能跑命令看结果——四样凑齐才能干活。

相关笔记