生成系统三讲
元胞自动机、马尔可夫链与程序化生成
生成系统三讲:元胞、马尔可夫与程序化
生成系统有一个共同信条:复杂不必被设计,它可以被长出来。第一讲看元胞自动机如何用只顾邻居的局部规则涌现出全局秩序;第二讲用马尔可夫链把语言压成一张计数表;第三讲看程序化生成如何给纯粹的随机性架上一层受控的相关性。三讲共用一套工具——栅格、随机数、采样与映射——也共用同一条工程底线:可复现。
元胞自动机:局部规则涌现全局秩序
全局行为。滑翔机的漂移、火线的推进、疫情的波前。没有任何一处写在代码里,它们全部是局部规则的涌现物。本章沿确定性元胞自动机、概率栅格模型、仓室方程这条递进的线索,看三类模型各自擅长回答什么问题,以及动手实现时的关键陷阱。
四件套
任何元胞自动机(Cellular Automaton, CA)都由四件事定义。
网格。一维、二维或更高。
状态集。每个格子的取值,比如生/死两态。
邻域。每个格子能看到谁。冯·诺依曼邻域取上下左右四格,摩尔邻域加上四个对角共八格。
更新规则。根据自身与邻居的状态,决定下一时刻自己的状态。
全部规则都是局部的。一个格子不认识千里之外的格子,信息只能一格一格传。
还有一条容易被忽略但极其关键的约定:同步更新。
所有格子基于同一时刻的快照同时计算下一状态,而不是从左到右扫过去改一个算一个。
这条约定的后果留到实现一节再说,先记住:同步性是 CA 语义的一部分,漏掉它模拟的就不是同一个系统。
生命游戏
1970 年康威设计的生命游戏(Game of Life)把四件套压缩到极致。
二维方格、两态(生/死)、摩尔八邻域、三条规则。活细胞邻居中恰有 2 或 3 个活细胞则存活,死细胞恰有 3 个活邻居则复活,其余死亡。
这就是著名的 B3/S23 记法。
规则里没有任何随机数,没有隐藏状态,每个初始布局的命运在数学上完全确定。
然而确定性和可预测性在这里彻底分家。
给定规则,你能不看演化过程就判断一个 50×50 的布局一万步之后是死是活吗?没有任何捷径。唯一的办法是跑一遍。
沃尔弗拉姆把元胞自动机的长期行为分成四类。归于单一(全灭或全稳)、陷入周期、完全混沌、以及最迷人的第四类:局部结构在混沌背景上稳定存在并相互作用。
生命游戏属于第四类。滑翔机(Glider)是五格小图案平移自身周期前进,脉冲星(Pulsar)是周期 3 的振荡子,滑翔机对撞可以湮灭、可以反弹、可以产生新的结构。图灵完备性就是在这些「结构化学反应」里被证明的。
站内的元胞自动机游戏把两条线都做了进去。
LIFE 标签页是 50×50 的生命游戏,内置滑翔机、脉冲星等预设图案,可以随机填充、手动画格、调速播放。
CA 标签页则是一维初等元胞自动机。每个格子只看左右和自己三个格子,规则用 0 到 255 的编号编码(8 个邻域组合,每个组合对应输出 0 或 1)。
这里有几个编号值得专门看。
Rule 30。从一个单独的黑格出发,长出无序的混沌花边。它是生成伪随机数的老办法,Mathematica 曾真的用它做默认随机源。
Rule 90。异或规则,演化图案恰好是杨辉三角对 2 取模,谢尔宾斯基三角形从规则里自己长出来。
Rule 110。结构足够丰富,2004 年被 Cook 证明图灵完备。一维初等自动机里的一条规则,能算一切可计算的东西。
沃尔弗拉姆给这类系统起了个名字:计算不可约性(computational irreducibility)。
预测一个不可约系统的最快方法就是让它跑。没有比仿真本身更短的公式。
这不是算力问题,是复杂度的本体。
概率栅格
生命游戏里每条规则都是确定的,但真实世界的小格子大多带骰子。这棵树会不会被点着、这个人会不会被传染,都是概率事件。
把更新规则里的确定性判定换成伯努利试验,就得到概率栅格模型。随机的方法:从撒点算 π 到马尔可夫链里说的「用随机数装下随机过程」,在这里落到每一格。
火势蔓延模拟是最纯粹的标本。
网格上是空地、树、燃烧、焦土四态。每一步,燃烧格化为焦土,每棵树检查八邻域。只要有一个燃烧的邻居,就以概率 被点燃。焦土以小概率缓慢长回树。
蔓延概率 用滑条可调(约 0.05 到 0.7),点击任意一棵树即可点火,界面实时显示燃烧中的格子数。
这个玩具的深刻之处在于相变。
把树看作以密度 随机撒在格子上、火看作沿连通路径传播的流体,问题立刻连上统计物理里的渗流理论(percolation)。
渗流有两类。键渗流(每条边以概率开通)与站点渗流(每个格子以概率存在)。二维方格上四邻域的键渗流阈值恰好是 0.5,站点渗流阈值约 0.593,而本文场景的八邻域站点渗流阈值约为 0.407。邻居越多、连通越容易,阈值越低。
树密度低于阈值时,森林里不存在横贯整片区域的连通树链,火烧一阵必然自熄在碎片区。高于阈值,一条从点到边的无限连通集团存在,火迟早烧穿全场。
站内的模拟初始树密度约 0.7,远超阈值,所以任何一次点火都可能燎原。
把蔓延概率滑条压到很低,相当于给每条「物理上连通」的路径附加上随机阻断,有效连通性下降,火线会碎裂成孤立的火点。
宏观结果对微观参数不是线性响应,而是阈值式的突变。这是概率栅格能演示、而确定性 CA 演示不了的现象。
还应该补一句有限尺寸的诚实声明。
0.407 这类阈值是无限格子的严格极限,60×60 的模拟里测到的「临界密度」会因边界与涨落而偏移。而且每次运行的燃烧面积本身分布极宽(同样的参数,有的种子烧掉 5%,有的烧掉 60%)。
在格点模拟里报告结论,永远要附上「跑了多少次、分布长什么样」。单个数字会撒谎。
概率栅格与 CA 的分界
注意分界线在哪里。结构完全一样(网格、邻域、同步更新),差别只在更新规则里有没有随机项。
确定性的生命游戏里,同一个初始布局永远演化出同一条轨迹。概率栅格里,每一次运行都是一条新轨迹。
于是「预测」的含义从「算出结局」变成了「算出结局的分布」。要回答「这次大火能烧掉几成森林」,正确的做法是跑一百次点火然后画直方图,而不是找一个公式。
蒙特卡洛的思维方式从这里顺势接入。单次模拟是一个样本,多次模拟才是统计。
仓室模型
概率栅格再往前走一步就是仓室模型。不再追踪每一个格子,只统计各状态的总数,把个体间的接触平均化成一个比例项。
这就是传染病学的经典 SIR 方程:
、、 分别是易感、感染、移出(康复或死亡)的人数, 是传染率, 是移出率。
模型的全部预警能力浓缩在一个无量纲数里:。一个感染者在全程易感的人群里平均传染几人。
时指数增长, 时疫情自灭,没有第三种可能。
阈值本身可以一行推出。感染者在康复前的平均传染期是 ,期内单位时间接触 个易感者,所以每个感染者平均制造 个新感染者。这就是 。
它立即给出干预的定量标尺。疫苗(或隔离)覆盖比例 把有效易感比例压到 ,要让有效再生数 ,解出:
这就是「群体免疫阈值」的出处。 时需要覆盖三分之二以上的人群。
一个 ODE 模型给不出波前的形状,但这一行不等式曾是(并且仍是)公共卫生决策的核心量。粗糙模型的解析判据,常常比精细模拟的曲线更有行动价值。
站内的传染病 SIR模拟选了一条中间路线:仓室的状态语义 + 栅格的空间结构。
60×60 的格子上每个格子是 S、I、R 三态之一,感染者每步以概率 康复、以概率 逐一尝试感染四个方向的易感邻居。点击任意格子释放初始感染,界面同时给出三态计数和右上角的实时曲线。
它和纯 ODE 版本有两处耐人寻味的偏差,恰恰是空间结构的价值所在。
早期随机灭绝。ODE 里只要 ,无论初始感染是 1 人还是 1 万人,曲线必然起飞。栅格版里,最初的几个感染者可能恰好都康复、周围邻居恰好都没被染上。疫情在萌芽期就自然死亡。
这个「死里逃生」的概率在 ODE 里根本不存在,在栅格模拟里清晰可见。现实中为什么不是每个喷嚏都引发流行病,这就是答案。
波前而非匀质混合。ODE 假设任何人等概率接触任何人(完全混合),栅格版里感染只能逐格推进。你会看到一圈清晰的波前以近似恒定的速度向外推,波前后面是耗尽的免疫区。
空间结构把「匀质混合」的高估修正了。邻居重复暴露给同一个感染者,有效传播被局部聚集稀释。
这两处偏差其实指向同一个技术名词:均值场近似(mean-field)。
ODE 里的 项假设每个易感者接触到的感染概率就是全局感染密度,等于把网格「搅拌均匀」后取平均。也就是它被称作仓室模型的原因:三个仓室之间只看总量流向。
均值场抹掉的东西,恰好是空间结构最值钱的产出(波前、团簇、边界效应)。所以建模选择本质上是在「解析可处理性」与「空间保真度」之间定价。
生态缸方程
如果连空间都彻底不要,就得到第三类模型的极端形态。纯仓室方程的离散积分。
生态模拟工具就是一台这样的系统。
植物、草食动物、肉食动物三个存量,光照、水量、食物三个环境滑条。每按一次回合,程序按逻辑斯蒂增长加捕食项的方程组算一步。
植物的增长率被自身规模压制(逻辑斯蒂项 ,环境容纳量 由光照与水量决定),被草食动物按饱和函数取食。草食动物的增长来自进食、死亡率受食物与水量调节、损失于被捕食。肉食动物同理。
数值上它就是洛特卡–沃尔泰拉捕食者–猎物方程的现代改编版。那对方程最著名的性质是极限环。捕食者与猎物的数量此消彼长、永不静止。
栅格版 SIR 的曲线是单次冲向终点的波,而这里的种群曲线是可以一直绕圈的闭环。
极限环为什么会转起来,值得拆开看一眼动力学。
猎物多 → 捕食者食物充裕、数量滞后上升 → 捕食压力压垮猎物 → 猎物锐减后捕食者饿肚子、数量滞后下滑 → 捕食压力减轻、猎物反弹。
两个关键词是滞后与饱和。
捕食者的响应永远比猎物慢半拍(繁殖需要时间,体现在方程里是捕食项只作用于捕食者的增长率)。而捕食量随猎物密度趋于饱和(体现在 这类饱和函数上)。
把这半拍滞后拿掉,环就塌缩成直线趋向平衡。振荡是结构性质,不是参数巧合。
生态缸里把光照拉满、食物拉满,往往能看到植物量冲顶后把整个食物链带崩。草食动物暴增→植物崩盘→肉食动物连锁饿死→缸内重置。那是逻辑斯蒂天花板与捕食滞后联手制造的过冲。
现实温室里的「爆发-崩溃」管理难题,在方程层面就是这么一回事。
三类模型
把三条线放在一起,取舍就非常清楚了。
| 模型 | 随机性 | 空间 | 擅长回答 | 典型盲区 |
|---|---|---|---|---|
| 确定性 CA | 无 | 有 | 复杂性如何从简单规则涌现 | 给不出统计规律 |
| 概率栅格 | 每格伯努利 | 有 | 阈值、相变、单次疫情的命运分布 | 需要多次运行才能下结论 |
| 仓室方程 | 无(均值场) | 无 | 长期趋势、平衡点、 式的解析判据 | 抹掉空间与随机,早期灭绝不可见 |
三者不是进步关系而是分工关系。
确定性 CA 回答「结构从哪来」。概率栅格回答「概率阈值在哪」。仓室方程回答「长期看大势如何」。
流行的建模顺序也正好是这条线。先用 ODE 抓主要矛盾( 一算就知道要不要干预),再上概率栅格看空间与随机性带来的偏离(波前、团簇、早期灭绝),只在涌现机制本身就是研究对象时才动用确定性 CA。
抹掉的维度越多,模型越便宜、越可解析。留下的维度越多,模型越真实、越依赖仿真。
实现要点
自己动手写任何一个栅格模拟,有三处坑几乎必踩。
同步更新必须双缓冲。
所有格子要基于同一时刻的旧状态计算新状态,所以先分配一块 next 网格、把整轮结果写进去,最后一次性换指针。直接在原网格上边扫边改,先被扫到的格子就会「污染」还没扫到的格子的邻居视图。
这个坑有多深值得看个具体例子。火沿左右两个方向蔓延概率相同,但单缓冲按行从左到右扫描时,左侧格子先被点燃,且当轮新点燃的火会立刻参与右侧格子的判定(它读到的已经是更新后的值),火会肉眼可见地只往右跑。物理上毫无理由的方向偏置,纯粹是遍历顺序的伪影。
正确实现只有十来行:
function step(grid, W, H) { const next = new Uint8Array(grid.length); // 新世界 for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { let n = 0; // 邻居计数:只读旧世界 for (const [dx, dy] of NEIGHBORS) { const nx = (x + dx + W) % W, ny = (y + dy + H) % H; if (grid[ny * W + nx]) n++; } next[y * W + x] = rule(grid[y * W + x], n); // 只写新世界 } } return next; // 整体替换}本文提到的几个模拟无一例外都是这个模式:读旧、写新、整体替换。
边界语义要想清楚。
左上角的格子少两个邻居,它遵循什么规则?
常见选择有两种。环绕(把网格上下左右接成环面,上面代码里的取模就是在做这件事)或墙(出界视为空地/死格)。
两种都没错,但会给出不同的相变阈值。渗流阈值 0.407 是无限平面的值,小网格加上墙边界后测到的临界密度会偏高。
做实验时,边界语义要写进结论的脚注。
渲染与演化解耦。
模拟步进和画面刷新频率不匹配,常规做法是动画循环里计数、每若干帧才走一步模拟。格子多、规则贵时这一招保住帧率。
数据结构用定长类型数组(如 Uint8Array)而不是嵌套数组,一维下标 直接寻址,对几十万格子的模拟是数量级的差距。
规模再往上(城市级、亿级格子),两件武器是稀疏存储与并行。只维护活跃格子及其邻居的哈希集合(生命游戏里绝大多数格子长期是死区),或者把整个网格搬进 GPU,让每个格子由一个线程独立算。
「每格只依赖局部邻域」恰好是 CA 的定义,因此它天然适配大规模并行。这也是它至今活跃在流体、交通、材料模拟第一线的工程原因。
这三条都是工程细节,但栅格模拟的「科学性」就藏在里面。双缓冲错了,你观察到的就不是模型的性质而是 bug 的性质。
把「下一状态」从确定性判定换成按概率抽样,格子里就长得出语言。第二讲把状态从格子换成词。
马尔可夫链:下一词预测的最小实现
用马尔可夫链生成的文本有一种独特的气质。每个词都接得天衣无缝,通读三句却不知所云。「听君一席话,如听一席话」的废话文学,在统计上恰好是这类模型的精准写照。
但这个诞生于 1950 年代的玩具值得今天的工程师认真对待。它用几十行代码完整演示了「下一词预测」这架大机器的每一个齿轮,而大模型并没有更换这些齿轮,只是把驱动方式从数计数换成了神经网络。
一条链,一个矩阵
马尔可夫链的核心假设只有一条:下一个状态的概率只依赖当前状态。
搬到文本上,状态就是词(或字),「当前状态」是最近的一两个词,转移概率从语料里数出来。
构造一个文本生成器只需要五个动作:切分、计数、抽样、成句、补漏。这五步的完整讨论(粒度怎么选、阶数换通顺度为什么会撞上稀疏、死胡同怎么回退)见随机的方法:从语料到生成器的五步。
JavaScript 实现短到可以整段贴出来:
function train(tokens, order = 1) { const table = new Map(); for (let i = 0; i < tokens.length - order; i++) { const key = tokens.slice(i, i + order).join(" "); const next = tokens[i + order]; if (!table.has(key)) table.set(key, {}); table.get(key)[next] = (table.get(key)[next] || 0) + 1; } return table;}
function generate(table, start, order = 1, maxLen = 30) { const words = start.split(" "); for (let i = 0; i < maxLen; i++) { const key = words.slice(-order).join(" "); const dist = table.get(key); if (!dist) break; // 死胡同,回退或终止 words.push(weightedPick(dist)); // 按计数加权抽样 } return words.join("");}矩阵的骨架长什么样,可以在马尔可夫链文本生成里直接看。
它提供一张可编辑的三状态转移矩阵(每行概率自动归一化),点一下就生成 30 步的状态序列,并统计每个状态在这条轨迹里出现的次数。
把矩阵当文本生成器的语料来理解。某一行的概率越集中,对应「这个词后面接什么越固定」。行与行越均匀,生成越飘忽。
改一格数字再点一次生成,你就能亲眼看到转移概率的微小改动如何在长序列里被放大成完全不同的轨迹。
四个旋钮
生成质量的全部秘密在四个旋钮上,每个旋钮都是一组明码标价的取舍。
阶数
一阶链只看前一个词,局部搭配松散。二阶看前两个词,通顺度跃升。三阶看三个词。
通顺度继续涨,但状态数按词表规模指数膨胀。五万词的语料,二阶理论上限是 亿个状态。
更致命的是稀疏。语料里没见过的组合概率为零,生成器一走进没背过的路径就撞墙。
所以实际工程永远在「阶数换通顺度」和「稀疏换覆盖率」之间讨价还价。这个跷跷板在神经语言模型里换了名字(上下文窗口、参数量)继续存在。
平滑
零概率是计数模型的死穴,平滑是对策的总称。
最简单的是加一平滑(Laplace smoothing)。每个计数加 1 再归一化,等于从总概率里匀出一小块分给「没见过」的组合,代价是高频组合被压低。
工程上更常用插值平滑。三阶模型查不到就退回二阶,二阶再退回一阶,按固定权重混合三个阶的分布。backoff 的思想。
平滑参数调得好坏,直接决定生成文本是「偶尔破格」还是「胡言乱语」。
温度
采样温度是这套玩具留给大模型最直接的遗产。
对归一化前的 logits(这里就是计数的对数)做变换:
是原分布。 调小,概率向最高频词收缩, 退化为永远选最大者(贪心)。 调大,分布被压平,冷门词的机会增加, 变成均匀瞎抽。
三行代码,一个从「复读机」到「疯子」连续可调的创造力旋钮。
今天在任何一个大模型 API 里看到的 temperature 参数,就是同一个数学,同一个用途。只是 logits 从计数表换成了神经网络的输出层。
温度的作用用一组具体数字看得最清楚。
设某状态下三个候选词的计数是 5、3、1,取对数得 logits ,除以不同温度再归一化:
| 候选 | |||
|---|---|---|---|
| A(计数 5) | 63.2% | 55.6% | 45.0% |
| B(计数 3) | 30.5% | 33.3% | 34.9% |
| C(计数 1) | 6.3% | 11.1% | 20.1% |
低温放大差距、高温压缩差距,而排序永远不变。温度只重塑置信度,不改变偏好顺序。
这解释了实践中两个现象。低温生成稳定但很快露馅(同样的开头走向同样的句子),高温生成新鲜但崩坏率上升。
调温度本质上是在「对语料的忠实度」与「跳出语料的能力」之间滑动。
截断
温度重塑分布的形状,却动不了候选集本身。词表里几万个词都还在参与抽样,长尾再冷门也攥着一点概率。
截断(truncation)补的就是这一刀。
top-k 只保留得分前 k 名,其余概率质量清零后重新归一化。
top-p(核采样,nucleus sampling)更进一步。把候选按得分降序排列,从最高分起累加概率,累加到 p(比如 0.9)即截止,只在这「核心集合」里抽。
两者的差别在自适应性。k 是定值,分布尖锐时(「长江」后面的「大」几乎垄断)k 个名额养着一堆接近零概率的陪跑,分布平坦时(句首自由发挥处)k 又可能不够用。
top-p 的候选数随分布形状伸缩,尖锐处只剩两三个候选、平坦处自动放开几十个,因此成了今天大模型 API 的默认选项之一。
截断与温度的分工值得单独说一句。截断决定谁有资格上场(改变排序,被截掉的直接出局),温度决定上场的人怎么分票(不改排序,只改差距)。
先截断、后调温,是大模型解码的标配流水线。
而这条流水线的每一节都能在 n-gram 生成器上原样搭出来。按计数排序砍掉低频尾巴就是 top-k,把计数取 次幂再归一化就是温度, 的倒数正是 。
统计玩具与大模型在此完全同构,区别只是候选规模。
动手走一遍
把构造五步落在一个真实的小语料上,规模小到可以全程手算。
语料六句:
猫 追 球猫 吃 鱼狗 追 猫狗 吃 骨头猫 睡 沙发球 滚 动按字/词切分后(这里以空格分词为例),统计一阶 bigram 计数表:
| 当前词 | 下一词分布(计数) |
|---|---|
| 猫 | 追 1,吃 1,睡 1 |
| 狗 | 追 1,吃 1 |
| 追 | 球 1,猫 1 |
| 吃 | 鱼 1,骨头 1 |
| 睡 | 沙发 1 |
| 球 | 滚 1 |
| 滚 | 动 1 |
| 鱼 / 骨头 / 沙发 / 动 | (无后继,死胡同) |
从「猫」出发采样一条路径。查「猫」行等概率抽到「追」。查「追」行抽到「球」。抽到「滚」。抽到「动」。撞上死胡同,触发补漏规则(回退到随机重启),再从任意词继续。
生成的「猫追球滚动」在语料里其实从未完整出现过。它是合法片段的重组,也就是马尔可夫生成既「新鲜」又「胡说」的机制根源。
同时留意死胡同的普遍性。十一个词里有四个无后继(鱼、骨头、沙发、动),小语料下补漏策略被触发的频率超过采样本身。
实际工程里「句号作为普通 token 进表」或者「语料按句切分、每句独立成链」就是为这个准备的。
废话文学的信息论诊断
先给这套玩具做个体检,工具是信息论。
香农 1948 年用 阶近似估计英语的熵,1951 年又设计了一个巧妙的真人实验。让被试逐字母猜测一段英文文本,猜对即进下一字母,所需猜测次数的分布直接对应文本在每个位置上的「不可预测度」。
结果按模型阶数排列是一根漂亮的下降楼梯。零阶(字母频率已知)约每字母 4.14 比特,一阶(看前一个字母)约 3.56,二阶约 2.89。而真人利用全部上下文与常识,把数字压到 1 比特上下。
楼梯在低阶处陡、高阶处缓,意味着统计规律吃掉了冗余的大头。但最后那一段「语义与常识」的空间,正是 n-gram 模型永远够不着的地带。
香农实验还有个至今有效的副产品。他用同一套高阶近似手工「生成」的文本序列(每一步请被试凭语感补全),已经能骗过读者对「这是英文」的预期。生成质量与上下文利用率的对应关系,在那个年代就被定量钉死了。
大量信息冗余是语言可被统计模型模仿的原因。只要模仿对了冗余,听起来就像话。
现在可以精确回答「为什么它像话但没有内容」了。
马尔可夫链保住的是局部统计。二阶模型记住了语料里所有相邻搭配,所以每个词的衔接都符合语感。
它丢掉的是长程依赖。句子开了什么头、段落立了什么论、第三章的主语是谁,这些信息无法塞进「前两个词」的状态里。
生成的文本是一个在语义空间里做随机游走的轨迹。每一步都不越轨,整体上漫无目的。
主题漂移、人称错乱、「答非所问地流畅」,全是同一个缺陷的不同侧脸。转移矩阵里的强回边还会让轨迹周期性打转。语料里「某某某」高频出现时,「某某某说、某某某笑、某某某说」式的循环短语就会成串冒出来,那是随机游走掉进了吸引子。
于是改进的方向天然明确:扩大状态。
把「最近两个词」换成「全部上文的一个向量表示」,把「查表」换成「对这个向量做一次非线性映射」。下一词预测的框架纹丝未动,但状态的容量从词表平方变成了网络参数量。
n-gram 的「上一个词」是离散索引,两个不同的上文要么全等、要么全不同,没有任何中间地带。
神经语言模型的上下文向量则是一个连续空间。「周末去公园野餐」和「假期到郊外露营」会被映射到相近的位置,于是见过其中一个,就能对另一个做出像样的预测。泛化能力来自这种连续性,这是查表模型在原理上就拿不到的。
注意力机制的贡献则是在「哪些上文值得看」上加了可学习的权重。不用固定窗口,而是按相关性动态选取,长程依赖从「塞不进状态」变成「按需检索」。
还有一处容易被忽略的衔接:token 是怎么来的。
马尔可夫链时代的切分(按字、按词)是人工决定的,神经语言模型用的是 BPE(Byte Pair Encoding)这类子词算法。从字符出发,迭代合并语料中最高频的相邻对,频率高的词保持完整、低频词拆成子词片段。
它继承了 n-gram 的统计灵魂(合并依据就是共现频率),同时解决了词表大小与未登录词的两难。任何新词都能拆成已知子词的拼盘。
从香农的字母级实验到 BPE,「把文本切成多大粒度」这个问题被反复回答了七十年,答案随算力与数据规模摇摆,问题本身从未变过。
这条跃迁后的技术栈今天在大模型应用框架里被层层封装。此前在Eino 学习笔记里记录过 Go 生态的编排方式,ChatModel 的一问一答背后,模型层交付的依然是「下一个 token 的概率分布」。
温度、top-k、top-p 这些采样参数原样继承自统计语言模型时代。
大模型淘汰的是查表,没淘汰框架。
为什么它没被淘汰
既然大模型全面碾压,为什么这个玩具还活在生产系统里?三个理由,一个比一个硬。
零算力,可本地
一条链的全部开销是一张计数表加一次加权抽样。
浏览器里几十毫秒建表,单片机上照跑不误,没有网络请求、没有 GPU、没有按 token 计费。
游戏 NPC 的随机台词、名字生成器、测试数据的语料变形、隐私敏感场景的本地文本变换。这些场景对「智能」的要求远低于对「零依赖」的要求,n-gram 是它们的最优解而非妥协。
两个高频用例值得点名。
模糊测试(fuzzing)里用目标系统的输入语料训练一条链,生成的「像那么回事」的畸形报文比纯随机噪声更容易探进解析器的深层分支。
游戏内容生成里,NPC 名字、酒馆告示、随机诗句这类需要「风格对味但永不重复」的短文本,一条小型链比调用大模型便宜四个数量级。
可解释,可审计
神经网络的转移概率埋在亿级参数里,n-gram 的转移概率是表里的一格。每一个数都能被人直接读出、质疑、修改。
要审查模型行为(「为什么它总生成这个词」),查表即可。要注入先验(「这个词后面永远不许接那个词」),改一格就行。
可解释不只是美德,是某些行业的准入证。合规审查要回答「这个输出是怎么来的」,查表给得出证据链,神经网络给得出的大多是「梯度很大」。
内容安全同理。敏感词过滤在 n-gram 上就是删除或降权几行表项,行为完全可预期。在黑盒模型上则是一场持续的对抗。
可控制,可注入
这正是它与人工创作工具的分界面。
站内的写作工具箱走的是另一条路。起名、世界观卡片、角色卡、灵感骰子、情节模板、字数统计、码字冲刺计时。每个模块都是人工整理的素材库加确定性组合,没有采样,没有随机游走,你要什么它给什么。
把两条路对照着看非常有启发。
马尔可夫链是「机器自己在语料里发现概率」,写作工具箱是「人把概率预先写死」。前者失控但常有意外之喜,后者可控但需要持续的素材喂养。
实践中的生成系统往往两头都要。先验控制结构(大纲、约束、模板),随机填充血肉(用词、变体、意外)。
这个混合架构里 n-gram 依然承担着所有「低成本但要随机」的环节。约束在人的手里,链只负责在约束之内把可能性走遍。
动手边界
最后是几个写出来就省你半天时间的细节。
中文切分。字级模型零依赖但破碎,词级需要分词器,折中方案是「二字组」(bigram of characters)起步。
UTF-16 陷阱。JavaScript 的 split("") 会把 emoji 和生僻字切成孤立代理对,必须用 Array.from() 或 Intl.Segmenter 切分,否则生成器会输出乱码。
句界即状态。把句号当普通 token 混进链里,生成的句子会粘成一段。更好的做法是按句切语料、每句独立成链,让终止变成结构的一部分。
循环检测。转移矩阵里有强回边时(语料里高频短语多),生成会陷入「某某某某某某」的复读。记录最近 步、命中即重抽或强制换路,可以兜住最尴尬的场面。
性能。语料一大,稠密的二维计数表就是内存黑洞(词表 5 万的二阶模型理论上限 25 亿格)。稀疏 Map 按需建键是唯一可行解,建表阶段单遍扫描、把语料读成游标数组而不是复制字符串,内存占用能差出一个数量级。
评估也别只靠肉眼。
语言模型有一把现成的尺子:困惑度(perplexity)。拿一段预留的测试文本,让模型逐词预测,对每次预测的真实词概率取负对数再平均,最后取指数。
直觉上它是「模型平均在每个分岔口的有效候选数」。困惑度 20 意味着模型眼中每个位置都像有 20 个词在竞争。
它正是香农猜字母实验的数学化。1951 年靠人肉数猜测次数,如今一行代码换成一个可复现的指标。
生成文本好不好看是主观的,困惑度降没降是客观的。先盯指标再调体验,才不会把调参做成玄学。
计数表生成得了像话的句子,生成不了像样的地形:随机性的角色从这里开始倒转,从「放出来」变成「管起来」。第三讲的问题正是——给随机性加多少相关性,噪声才能长成值得看的东西。
程序化生成:噪声、分形与地形
程序化生成要解决的核心矛盾只有一句话:随机带来丰富性,但也带来破碎。
纯随机的像素图是沙暴,而游戏需要的地形要有绵延的山脉与连贯的海岸线,纹理要有自然的斑驳而不是均匀的碎点。
整个领域的地基按三步铺开:先造平滑的噪声,再把多个尺度的噪声按比例叠成分形,最后把数值场映射成颜色或几何,每一步都可以在浏览器里亲手拨弄。
动手版本:纹理生成器、分形、3D 地形、Voronoi、Lissajous、Plasma、流体光晕。
从白噪声说起
先看那个最便宜的反面教材。
纹理生成器有两种模式。「噪点」模式对 240×240 的每个像素独立掷一次均匀随机数,这就是白噪声:任何两个像素之间零相关,功率谱在所有频段上平得像白光(名字由此而来)。
它作为纹理的观感是「电视机雪花」,作为地形是灾难,作为素材却是刚需。测试贴图、抖动采样、颗粒滤镜都靠它。
另一种「波浪」模式则滑向另一个极端:,两个正交正弦的乘积。全场只有一个频率、一个周期,可完美平铺。这是规则纹理,好看但假,一眼就是机器画的。
自然纹理住在两个极端之间。相邻像素相关(不是雪花),但频率成分连续分布在一段区间里(不是单一正弦)。
度量这个「中间态」的两把尺子是相关长度(隔多远两个像素才开始互相不认识)与频谱形状(能量按频率怎么衰减)。
程序化生成的全部工程学,就是用尽量低的算力造出「指定相关结构」的场。接下来两节是两条主流路线:插值造相关,叠加造频谱。
有一个诱人但错误的捷径值得先排除:把白噪声模糊一下不就有相关性了吗?
模糊确实是低通滤波,能消灭高频碎点,但代价是把频谱砍成「低通以内白、以外全无」的两段式。真实自然纹理的频谱是幂律衰减,任何频率都在场。模糊过的白噪声看起来像一团没精神的棉絮,既丢掉了细颗粒,也没有长程结构。
正确路线反过来:从低频开始,逐层往上添细节。也就是下一节插值与分形叠加做的事。
频率合成的另一条路是信号处理的:直接在频域摆好各频率的随机相位与幅度谱,做逆傅里叶变换,一步得到指定频谱的场。它数学上最精确,但需要 FFT 且难以局部求值,实时渲染里几乎总让位给逐像素的噪声函数。
插值是灵魂
把白噪声变成有结构的噪声,最直接的思路是先随机、后平滑。
在一个稀疏的格点上存随机数(称为格点或晶格),任意一点的值由它周围四个格点的随机数插出来。
站内 3D 地形的源码走的就是这条路线。先按种子填一张 80×80 的随机格表,采样时对格点做双线性插值:先沿 把上下两条边线性插成两个中间值,再沿 把两个中间值插成最终值。
这套东西叫值噪声(value noise)。值连续了,但它是 连续而非 。在格线穿越处,导数会跳变,视觉上留下与晶格对齐的网格状伪影,行家一眼就能认出「这是值噪声」。
柏林噪声(Perlin noise,Ken Perlin 为 1982 年的电影《电子世界争霸战》开发、1985 年随论文发表,后来拿了奥斯卡技术奖,图形学里罕见的荣誉)换掉的不是插值公式,而是格点上存的东西。
不存随机数值,存随机梯度向量。
任意点的噪声值 = 邻近四个格点的梯度向量与「格点指向该点的偏移向量」做点积,再加权混合。
点积在格点本身处恒为零,噪声场天然拥有过每个格点的零值等高线。而且方向信息让场在格线两侧的过渡由梯度连续衔接。配合一条五次平滑曲线 替换线性插值(保证二阶导也连续),伪影基本消失。
一句话总结两代噪声的分野:值噪声插的是高度,柏林噪声插的是坡度。后者在同等格点数下各向同性更好、更接近「无方向的自然」。
工程上还有两个必答的问题。
其一,格表只有 80×80,采样坐标一旦翻倍越界怎么办?
正式实现不给「无限格表」留内存,而是把格点值换成整数坐标的散列:。任意大的坐标都能即时算出格点值,场的定义域变成整个平面。散列函数的质量直接决定噪声里有没有肉眼可辨的周期。
其二,纹理要平铺怎么办?
最省事的办法是让格点坐标对周期取模,插值在边界处自动首尾相接,代价是场带上了人为的周期。游戏贴图几乎都用这招,因为显卡采样本来就是循环的。
顺带解一个考据:地形工具的介绍文案写的是「Perlin 噪声生成」,但实现是值噪声加后面的分形叠加。这不算 bug,反而是个最好的注脚:叠够了八度之后,两种噪声在最终画面上的差异会被细节淹没,工程上常常不必较真。
柏林后来推出的 Simplex 噪声把方格换成单纯形网格,计算量更低、高维表现更好,但那是优化,不是范式更替。
分形布朗运动
单一频率的噪声,无论是值噪声还是柏林噪声,都还不够像自然。
真实地形既有大尺度的山峦起伏,也有小尺度的岩石粗糙。
补救办法是把同一个噪声在多个尺度上采样后加权求和,这就是分形布朗运动(fBm, fractal Brownian motion)。
地形工具里的核心循环简化后只有八行:
let amp = 1, freq = 1, sum = 0, norm = 0;for (let o = 0; o < 5; o++) { // 五个八度 sum += bilinear(lattice, x * freq, y * freq) * amp; norm += amp; amp *= 0.5; // 振幅减半(persistence) freq *= 2; // 频率翻倍(lacunarity)}height = sum / norm; // 归一化每一层「八度」把采样坐标翻倍(细节尺寸减半)、把权重减半(贡献变小)。五个八度之后,场里同时含有尺度为 1、1/2、1/4、1/8、1/16 的起伏,权重比是 。
这套参数的通名来自信号处理。
persistence 控制高频成分保留多少。源码界面上那根「粗糙度」滑杆(0.2 到 1)直接乘在最终叠加结果上,拉高它相当于整体调对比度。
lacunarity 固定取 2 是惯例,因为 2 的幂让不同八度在网格上正好错位重用同一张格表。
persistence 取 0.5 时能量按 衰减,也就是布朗运动的谱特征。名字里的「布朗」由此而来。调到接近 1,高频权重追平低频,画面变毛刺。压到 0.2 以下,只剩大骨架,山变成缓丘。
这个环节还有一个容易被忽略的工程决定:确定性。
地形工具没有用 Math.random(),而是实现了一个 32 位 xorshift 发生器(x^=x<<13; x^=x>>>17; x^=x<<5)从种子生成整张格表。所以界面上那根「随机种子」滑杆(1 到 999)拨到同一个数,地形一像素不差地重现。
程序化生成系统的第一条工程铁律就是可复现性。内容必须能由(种子,参数)二元组唯一确定,否则测试无法断言、多人无法共享、改动无法回滚。
随机数发生器的选择反而是次要的。xorshift 只需要几十个时钟周期和 4 字节状态,在这种「每像素五次插值」的热循环里,速度就是功能。
从高度场到地形
有了高度场 ,剩下的是把它变成「看起来像地形」的画面。
地形工具选的是等距投影(isometric)。格点 被映射到屏幕坐标:
其中 是每格的屏幕尺寸(界面上那根「网格尺寸」滑杆,8 到 26)。
第一行把网格旋转 45° 压扁成菱形。第二行的妙处在减号:高度 越大,屏幕 越小,山是画在负方向上的。1.1 倍是故意夸张的垂直缩放(否则 80×80 格的米粒级高度差根本看不出来)。
绘制顺序按 升序,画家算法的老规矩。先画远的(左上)再画近的(右下),后画的方块自然盖住先画的,等距地形不需要深度缓冲就能正确遮挡。
着色是四个系数的线性映射。暗色主题下颜色为 , 是 0 到 255 的高度字节。三通道斜率不同,低处偏深蓝、高处偏亮青,绿色通道斜率最大,让「高度感」主要靠绿色亮度承载。
这套 2D 伪 3D 离真正的 3D 渲染只差两步,理解这两步就理解了地形渲染的完整谱系。
第一步是把「每格一个色块」换成「每格一个面」。把相邻四个高度值抬成空间四点、连成网格,就成了 mesh,能进 WebGL 打光照。
第二步是把高度直接映射成地貌语义。海平面阈值(低于它的填水色)、坡度着色(坡陡的露岩石)、按高度的植被带(雪线、林线)。真实的地形生成器几乎全是在这个映射函数里做文章,高度场本身反而是最便宜的部分。
映射之上还可以叠物理模拟,这是现代地形生成的进阶课。
高度场是「提线木偶」,两条经典模拟线给它装上肌肉。
水系侵蚀(droplet erosion)让虚拟水滴从高处出发沿梯度下坡,沿途冲走泥土、在低处沉积。几千滴之后,山脊被刻出冲沟,山谷里冲积出扇形的缓坡,那种「被水雕过」的真实感是任何纯噪声都调不出来的。
构造抬升则按大尺度的低频场做分区域抬升与沉降,制造山脉的走向感。
模拟的输出会反馈回高度场,于是管线变成「噪声打底、模拟塑形、映射上色」的三段式。站内这个工具停在第一段,但打开任何一款带地形的世界编辑器,你都能认出这三段骨架。
另一个值得记的小结论是着色与投影解耦。高度场是数据、等距投影是视图,换一个投影(俯视、斜 45°)不需要重算场。数据与视图分离这条工程常识,在图形学里同样值钱。
分形的两种画法
分形工具把七种分形装进一个标签页,恰好对应生成分形的两大流派。
第一派是逃逸时间,主角是曼德博集。
对平面上每个像素取一个复数 ,从 开始反复迭代 ,数它迭代多少步后模长平方超过 4(逃逸半径 2):
while (iter < maxIter) { const x2 = zx * zx, y2 = zy * zy; if (x2 + y2 > 4) break; // 逃逸 zy = 2 * zx * zy + ci; zx = x2 - y2 + cr; iter++;}不逃逸的点涂深色(集合内部),逃逸的按步数上色。源码里色相取 度, 是迭代数与上限 150 的比值,这就是照片里那些蓝紫金渐变环带的来历。
初始视窗以 为中心、宽 3.5,可以拖拽平移、滚轮缩放,放大到百万倍仍能找到新细节。「任意放大仍有结构」正是分形的定义性特征。
迭代上限是这一派的第一个性能旋钮。上限决定「边界分辨率」:放大越深,边界处的点需要越多次迭代才能判定去留,上限不够时边界会糊成色带。而每次放大都是对全部像素重跑整个循环,平方级的逐像素计算让「渐进式渲染」(先粗格后细化)成了标配。
第二个隐形天花板是浮点精度。64 位双精度的有效数字约十五六位,视窗缩到 量级后,相邻像素的坐标差已经小于一个浮点步长,画面会被量化成马赛克。再往深处走就要换双精度扩展或定点数,这是所有分形放大器的共同边界。
旁边的 Julia 集用同一条迭代式,只是角色对调。 固定(两根滑杆在 间可调,或点五个预设 Classic、Dragon、Spiral、Rabbit、Star), 从像素坐标出发。
曼德博集与 Julia 集的关系是图形学里最美的结果之一:曼德博集是所有 Julia 集的参数地图。 取在曼德博集内部,对应 Julia 集连通;取在外部,Julia 集碎成尘埃。你在 Julia 页调 看到的形状突变,都对应着 点在曼德博集上的进出移动。
第二派是递归替换。
从谢尔宾斯基三角形(三份半尺寸的自己)与地毯(九宫格挖掉中心后对剩下八块递归)到分形树(每根枝按角度 25°、长度比 0.72 分叉两次,深度 9 层,随机化按钮就在这个参数空间里乱抽)。
它们与 L 系统共享同一套形式语言。一条改写规则反复替换字符串,再由「海龟」按字符解释画线:F 前进、+/- 转向、[ 与 ] 压栈弹栈实现分叉。
工具里的科赫雪花就是一行规则 加 ±60° 转角改写四代的产物。龙形曲线 用 90° 转角。植物 preset 的规则更繁,但骨架相同。
这些形状的「分形维数」可以按盒计数公式 精确算出。科赫曲线每段换成 4 份、每份缩到 1/3,。谢尔宾斯基三角形 。地毯 。
维数不再是整数,这条曲线「比一维长、比二维面积小」,也就是海岸线悖论的数学版本。
用距离场作画
最后一组组件共享同一个内核:对每个像素求值一个标量场或距离场,颜色只是求值结果的可视化。
Voronoi的场最简单。一堆随机游走的种子点,每个像素涂上「离它最近的种子」的颜色。
暴力最近邻,每帧对 120×120 个像素各做一次 次距离比较(点数滑杆 3 到 40),缩放后就是那种不断蠕动分裂的细胞图。
它的数学定义(每个种子的细胞是到该种子比到其他任何种子都近的点集)等价于半平面的交集。而工程实现里暴力法几乎是默认: 在几十的量级时,扫像素比建图论结构更快也更不容易出 bug。
Voronoi 的应用清单长得惊人。地图的省份与势力范围划分、蜂窝纹理与干裂泥地的模拟(Worley 噪声就是拿 Voronoi 距离当颜色)、乃至机器学习里最近邻分类器的决策边界。那正是一张 Voronoi 图。
它还有个著名的后处理叫 Lloyd 松弛。把每个种子搬到自己细胞的重心,迭代几轮后点分布趋向均匀。程序化生成里「先随机撒点、再松弛抹匀」是得到自然又均匀的采样的标准两步,星空、资源点、植被分布都这么撒。
流体光晕把「距离场」换成了势场。
十个带固定色相的圆球在场里游动,每个像素对十颗球求和:
超过阈值的像素才算「浸在流体里」。
妙处在于平方反比式衰减。两颗球单独都不够阈值,但靠得近时中间区域的和先过线,于是在两球之间拉出一座「桥」。球靠近时融合、拉远时拉丝断开,这就是 1982 年布林(Jim Blinn)为分子可视化发明元球时的全部初衷。
源码里还有一处精细活。像素的色相取十颗球影响力的平方加权和,靠近哪颗球就偏向哪颗的颜色,融合区因此出现自然的色彩过渡。边界处的 边缘因子让饱和度与透明度在轮廓上渐变,避免出现硬边。
鼠标移进画布会临时加入一颗球。你也在参与这个场。
与逐像素涂色相对的另一条路是先提取等值线再描边。marching squares 算法在每个网格单元里按四个角的场值查表连线,把 的等值面变成一条真正的矢量轮廓,放大不糊、还能拿去做碰撞检测。三维里对应的是 marching cubes,也是从标量场抽取表面的通用算法。
逐像素求值赢得快,等值线提取赢得精,两条路的取舍贯穿整个场渲染领域。
Plasma是九十年代 demoscene 的招牌效果,内核是四个相位不同的正弦场叠加:
四项分别沿水平、垂直、对角、径向铺周期。第四项的 让画面中心泛起同心涟漪。
求出的 过一个三通道调色板。红绿蓝各取 的一个相位(彼此错开 ),于是 的连续变化扫出一个色环。「相位错开的正弦调色板」是伪彩色渲染的通用技巧,从地形高程图到医学影像都在用。
频率滑杆(scale 0.03 到 0.3)调的是空间周期,速度滑杆调的是时间项的推进速率。
Lissajous则把同样的「参数化求值」用在曲线上。、,两个正交方向各挂一个频率。
频率比 决定图形的瓣数结构(1:1 是椭圆,3:2 是三瓣图形),相位差 让整个图形像被拧转的硬币一样翻面。物理学家后来在示波器上拿它测频率比,本质是「两个振荡的正交投影」。
这里还藏着一条与有理数有关的分界线。 是有理数时轨迹闭合成曲线,是无理数时永不闭合、随时间在整个环面上稠密缠绕。把频率滑杆停在 1.5 与 2 之间任意非整数比,让动画跑上几分钟,你能亲眼看到「闭合」与「稠密」之间只隔着一次有理数的取整。
噪声场是随机的函数,分形是自相似的函数,Voronoi 与元球是距离与势的函数,Plasma 和 Lissajous 是正弦的组合。都是「隐式定义 + 逐像素求值」,和复古信号与像素渲染里逐像素画扫描线是同一条路线。