他训练了一个 360M 的模型,让它假装自己是一台终端

Simon Edwardsson 用逐次按键的方式,让 360M 参数的模型直接预测终端画面,背后没有 shell。行并行解码把整屏重绘从 6.7 秒压到 0.87 秒,最后整套模型以 208 MB 跑在浏览器标签页里。

中文
复制
深色演示页面上,worldtui 的终端里跑着 cowsay,一头用 ASCII 画的奶牛,气泡里写着 I'm running in a model

TL;DR: 我微调了一个 360M 参数的语言模型,让它充当终端。它背后没有 shell,也没有文件系统:每次按键输入进去,模型预测屏幕会怎么变化。它能创建文件、运行 vim 和 tmux、玩贪吃蛇,而且通常能记住你几条命令之前写了什么。收益最大的两件事,是教它修复自己的错误,以及并行解码屏幕的每一行。在浏览器里试试(208 MB),输入 pythoon 就能看到它并不是真正的 shell。

这个 demo 是为支持 WebGPU、配有物理键盘的桌面浏览器设计的。最好用 Chrome 或 Edge 试。现代手机上应该也能跑,只是慢得让人难受。

浏览器中运行的演示:一个 96×32 的终端窗口,插入模式下已输入“hell”,最后一个字母后有一个块状光标,角落处显示每次按键的延迟读数。

去年第一次看到 Genie 3 时,我开始想象这样一个未来:应用不是写出来的,而是学出来的。渲染场景的不再是游戏引擎,而是模型根据用户输入预测下一帧最可能的样子。

我自然想训练一个自己的世界模型。但我既没有大规模 GPU 集群,也没有成堆的视频数据,于是把野心缩小了一点:不做一个游戏世界,做一个终端。

终端其实挺合适。它只是一格一格的字符,而不是像素,而且我可以运行真实的 shell,想要多少标注完美的训练数据就能生成多少。预训练语言模型本来也大致知道 ls 或 cat 应该打印出什么。

我真正想知道的是:它能不能维持一个前后一致的世界,还是只会生成看起来像样的终端输出。

最终模型基于 Qwen2.5-Coder-0.5B,在 Docker 中运行真实程序录制的终端会话上做了微调。跳到示例视频

它眼中的终端

标准 LLM 接收一个 prompt,然后一个接一个地往末尾追加 token。这种方式套不到终端上。一次按键本身就可能触发动作,比如 vim 里的 esc;输出也不总是往底部走:clear 会清屏并把光标送回顶部。所以我要预测的不是一串文本,而是屏幕的变化。

我把终端看成一个字符网格,每个单元格放一个字符。1 模型并不直接看到这个网格,而是看到一串描述网格内容及其变化的 token。

一次会话

每次会话都从一个快照开始:屏幕尺寸、屏幕内容和光标位置。

尺寸是输入的一部分,并不固定为 32×96。在演示里,创建自定义起始屏幕时可以自己设置行数和列数。

BOS SCREEN_START 32 96
ROW_0 0 'simedw@laptop:~$ '
CURSOR 0 17 1
SCREEN_END

这也让我能从某个应用的中途开始。给它一个 bash 提示符、一个 vim 缓冲区或者一局贪吃蛇,模型会试着接着往下走。(从 bash 开始效果更好。)

之后,每次按键都以真实终端会发送的原始字节形式输入。字母就是字母本身,Enter 是 \r,Escape 是 0x1b,← 是 ESC [ D,以此类推。

INPUT_START 'l' INPUT_END

模型会回答要重写哪些行,以及光标最终落在哪里:

OUTPUT_START
ROW_0 17 'l'
CURSOR 0 18 1
OUTPUT_END

用文字说就是:在第 0 行、从第 17 列开始写入 l,然后把光标移到第 0 行第 18 列。末尾的 1 表示光标可见,因为全屏应用有时会把它藏起来。

事先不会清屏。新字符直接画在旧字符上面。

字符不需要新的 token。Qwen 本来就有覆盖全部 256 个字节值的 token,我把它们复用于字符、坐标和其他小数字。所以像 17 这样的坐标只占一个 token,而不是两个。

时间也是一种输入。如果什么都没按,模型收到的不是按键,而是一个带时长的 WAIT token,snake 就是这样自己动起来的。

复制行

当你在终端窗口底部按下回车,每一行都得往上挪一格。我本可以让模型把每一行在新位置重新输出一遍,但一次按键就要花掉大约 3,000 个 token。

于是我加了一个 COPY 操作:

ROW_0 COPY 1
ROW_1 COPY 2
ROW_2 COPY 3
...

每个操作从上一屏复制一行。在这个例子里,原来的第 1 行变成第 0 行,第 2 行变成第 1 行,依此类推。

现在滚动一次每行只花三个 token,而不是大约 96 个。

同样的办法也覆盖了清屏、在 vim 里插入和删除行,以及浏览 man 手册。你还可以先复制一行,再覆盖掉其中一部分。

模型总共只用了一个 333 token 的极小词表:256 个字节 token、64 个行 token,外加少量特殊操作。解码受语法约束,所以每一次预测都是合法的屏幕更新。

这个极小的词表后面还会派上用场,就在我把模型塞进浏览器的时候。

生成训练数据

我一开始去找现成的终端录制数据集,包括 asciinema,但没找到符合我需要的、按键与屏幕精确对齐的那种。

幸好自己动手做并不算太难。

基本思路是:在 Docker 容器里运行真实命令,记录每一次按键以及按键之后的屏幕内容,以此生成可信的终端会话。

合成数据的配方是这样的:

  1. 随机化用户名、主机名、提示符样式、终端尺寸。
  2. 根据虚构的人物设定(开发者、系统管理员等)随机化目录和文件。
  3. 挑选 6–24 个带权重的活动(修改文件、vim、python、tmux 等)。
  4. 跟踪状态,让会话打开的文件大多是真实存在的。
  5. 像人一样打字:一次一个按键,带 tab 补全等。我把错字率设为 1.2%,这对我已经算宽松了。
  6. 35% 的会话从中间开始,迫使模型学会从任意状态接着往下做。

终端尺寸主要是 32×96,也有一部分 24×80 和 40×120,免得模型对某一种尺寸过拟合。

随着模型能力提升,我不断扩充数据集,最终定在 336k 个 episode,大约对应 2B token。作为对比,基座模型在训练中见过约 18T token。

训练

起点是 Qwen2.5-Coder-0.5B。我需要一个小模型才能接近实时性能。它本身是代码模型,也已经见过大量 CLI 用法。

一开始就是常规的监督微调:给一段屏幕历史和一个按键,预测真实终端产生的屏幕变化。

普通打字很快就学会了:字母出现、退格删掉它、光标移动。

但在较长的会话里,错误开始累积:文件名打错一次,就会一路影响后面的会话。

从自己的错误中学习

为了解决这个问题,我借鉴了 DAgger 的思路:让模型从自己实际访问到的状态中学习,而不只是从理想示范中学习。2

对每个训练样本:

  1. 在录制的会话中选一个点,通常靠近产生输出的那条命令。
  2. 让模型预测接下来的 1-4 个事件。
  3. 把得到的屏幕与真实录制的屏幕作比较。
  4. 如果差异足够小,就构造一次修正,在下一个按键时把模型的屏幕修复回来。
  5. 在修正后的画面和随后几个真实事件上继续训练。

这个差值把模型出错的屏幕带向正确的下一屏。本质上,它学的是“修复”自己的错误。

有一条重要限制:模型必须掌握足够的信息才能推断出修正内容。修正中引入的任何词,都必须在此前真实出现过的屏幕上或用户的输入中出现过,而不能只来自模型自己的预测。

举例来说,如果模型拼错了此前出现过的文件名,我可以教它改正。但如果它列出一个从未见过的文件名,我就不能指望它写对。

拿这类样本训练,实际上是在奖励幻觉,而我实在不需要更多幻觉了。

抽样的会话中约有三分之一产出了有用的修正样本。我把这个过程重复了三轮,每轮都从最新模型上收集错误。

自我纠正是比较有效的训练改动之一。再加上一些额外的训练数据,它把 PresetBench(定义见下文)在 int4 下的成绩从 24/47 提升到了 38/47。

总计下来,训练模型及其后续改进在 RTX 4090 上花了大约 75 GPU 小时。

测试

我用三个测试。ShellBench 包含 24 个手写 bash 会话中的 106 条命令,按输出是否完全一致评分。PresetBench 包含 47 个脚本化用例,取自我在 demo 中遇到的失败。两个测试都不在训练数据里。

记忆测试包含 32 个世界。每个世界把两个随机值写入两个文件,然后在不同的会话中读回。只有两次读回都完全一致,这个世界才算通过,因此总共有 64 次读回。名称和值从不出现在训练中。3

微调能带来多大帮助?

模型4ShellBench /106文件读回 /13
Qwen2.5-Coder-0.5B,prompted522
Qwen3-0.6B,prompted603
WorldTUI809

差别最大的是记忆:WorldTUI 读回了 9/13,而两个 prompted 模型分别是 2/13 和 3/13。

它离可靠的文件系统还差得远。熟悉的命令它处理得相当不错,但用新方式把它们组合起来,很快就会出问题。比如执行 echo green >> colors 之后,再运行 cat colors 往往看不到追加的那一行。

让它变快

有一段时间,我完全避开了自回归模型。一次完整重绘大约要 3000 个 token,而逐个生成这些 token,对于一个本该像终端一样即时响应的东西来说,怎么看都没戏。

但它的表现实在比试过的其他方案好太多,我只好妥协了。逐 token 生成时,打字速度还行,每个键约 38 ms;但任何需要重绘整个屏幕的操作(vim test.txt、man、clear)在我的 RTX 4090 上要花上 12 秒,和担心的完全一样。这段时间足够我重新考虑到底要不要写这份手册了。而且我希望它能在我的 Mac 上近乎实时地跑起来。

并行处理各行

我漏掉的一点是:一帧里的大多数行彼此并不依赖,所以没理由一行接一行地生成。我给每一行分配一个 batch 元素。每个元素拿到相同的历史(包括 KV cache),但只生成自己那一行的更新。

这样,解码开销就从所有行之和(最多 3000 步)变成了最长一行的长度,大约 100 步。

光标则变成最后单独的一步,可以看到所有行的更新。

这要求在训练时使用与之匹配的 attention mask,但换来的速度提升值得。大帧的 p95 延迟从 6.7 秒降到了 0.87 秒。

解码方式大帧 p95 延迟,RTX 4090
串行6.7 秒
并行各行0.87 秒

各行之间无法通信

这听起来好得不像真的,事实也确实如此。问题在于,各行既然相互独立,就无法协调,于是出现了 vim 在第 3 行而不是第 0 行打开,或者好几行同时给 snake 加食物的情况。

解决办法是投机解码。

通常,投机解码用一个小模型起草 token,再用一个大模型验证。得益于 causal attention,一次前向传播就能验证多个 token。

这里我让同一个模型兼任两者:并行各行先产出草稿,模型再按顺序验证。

验证器保留到第一个分歧之前的 token。从那里开始,它自己生成修正,并重新提出剩余的草稿。

在这台 M4 Mac 的浏览器里,输入一个字符大约需要 60 ms。像 man test 这样更大的更新,串行执行需要 2.5 s,而并行起草加验证则只需 0.9 s。5

把它做小

我从 Qwen2.5-Coder-0.5B 起步,它大约有 494M 参数。我很想做一个网页 demo,所以必须把它缩小。它 151,936 词表的嵌入表占了 136M 参数。我只需要自己的 333 个 token6,所以几乎可以整张丢掉,模型从 494M 降到约 360M 参数(990 MB → 716 MB)。

然后我把它量化到 int4,体积降到 208 MB。

浏览器 demo 用自定义的 JavaScript 实现 Qwen 解码器,计算着色器用 WGSL 编写,通过 WebGPU 运行。着色器在推理时解包 4-bit 权重。激活值和注意力缓存使用 32 位浮点数。所有推理都在标签页里本地运行。

蒸馏

把它蒸馏成更少的层,让我对记忆依赖哪些层有了些认识。教师模型是一个更早的 checkpoint,在 ShellBench 上得分 78/106。只去掉第 7 层、不做重训练,下一 token 准确率几乎没变:99.3% 降到 98.9%。但精确的记忆回读从 64/64 掉到 0/64。这些测试输入的是真实历史,所以失败不是模型自身输出上的误差累积造成的。

我第一个蒸馏出的学生模型保留了所有偶数层,但用的是最后一层第 23 层,而不是第 22 层。它仍然能写出像样的终端输出,但在 64 次记忆回读里只有 31 次返回了正确的值。它总是打印出自己写过的两个值之一,但大约一半的时候选错。那就是随机水平,而且训练了 480M token 之后它也没恢复过来。

那个学生模型去掉了第 1、7、15 和 22 层。一个保留这四层的 16 层学生模型通过了全部 32 个记忆测试世界,精确回读 64/64。在 4-bit 下、约 138 MB 时,它仍然通过了全部 32 个世界。不过别处有代价:它做对了 65 条 ShellBench 命令,教师模型是 78。所以一个学生模型可以看起来没问题,却已经悄悄丢掉了自己的记忆。

最后我还是放弃了把它蒸馏出来。

它能做什么

记忆

这是我最感兴趣的部分。生成一份像模像样的目录列表并不难。我想要的是:新建一个文件、编辑它、走开一会儿,回来发现它还是原样。

第一次启动 tmux,用 vim 打开一个文件、改动、保存,然后在另一个 tmux 窗口里用 cat 读到更新后的内容时,我感觉自己就是弗兰肯斯坦博士:它活了!

模型的 vim 在 copy.txt 上打开,第一行显示“one”,光标位于 o 上,状态行显示“copy.txt” 1L, 5B。

其他应用场景

对我来说,这套方案必须能用在 bash 提示符之外,所以我在 vim、python、man 手册、snake,甚至 tmux 上都做了训练。

在 vim 里,它能处理 esc 上的模式切换,让我可以保存、退出或回到插入模式。

在终端窗口内用 ASCII 字符绘制的贪吃蛇游戏,有得分行,蛇头标记为 oo@,食物标记为 *。
可能是世界上最耗算力的蛇。

真正让我大开眼界的是 tmux:我可以创建多个窗格,在每个窗格里做不同的事,并在它们之间来回切换。它并不完美,但毕竟只是个小模型。

终端内有两个 tmux 窗格,右侧窗格在 bash 提示符下运行 pwd。

它甚至能管理环境变量和变量展开。

一个会话先导出 NAME=Ada,然后运行 pwd 和 ls:模型回答 /home/simedw,并打印出一个看似合理的家目录。

Python

基座模型本身是代码模型,所以我很好奇它在 Python REPL 上的表现。它尝试得还算像样:简单的加法通常能算对,列表推导式也会返回列表,只是内容经常不对。它对拼写错误也稍微宽容一些:pythoon 能启动 Python,ython 则不行。

模型的 Python REPL:2 + 3 返回 5,10 - 4 返回 -1。

行不通的地方

我没有让 Docker 容器联网,所以 lynx 这类工具相当无聊。Python 能应付非常简单的操作,但稍微难一点的任务,给出的答案就让我想起 GPT-3 时代的幻觉。

有时我创建嵌套目录,它会迷失方向,突然就跑到别的路径下去了。一致性显然还有很大的提升空间。

在我的测试中,这个 demo 在 Firefox 里比在 Chrome 里慢了大约 20 倍。两者用的都是 WebGPU。我还没能确定原因。

相关工作

做这类项目我一向如此:先自己想办法解决,然后才去看别人是怎么做的。

早在 2022 年,就已经有人让 ChatGPT 扮演 Linux 终端。再近一些,GameNGen 用扩散模型模拟了 Doom,NeuralOS 则挑战了桌面 GUI。

WorldTUI 更接近最早那个 ChatGPT 终端的玩法,区别在于它是专门针对按键和屏幕更新训练的,包括 vim、tmux 这类全屏应用。

总结

做这个东西我玩得很开心。它有用吗?没什么用。但作为一个跑在浏览器标签页里的 360M 参数模型,它比我预期的好得多。

最让我感兴趣的是,它在底层没有实现任何规则的情况下,居然维持住了一个大致自洽的世界。

它显然是个很弱的世界模型。我很想看看,如果一致性更好、屏幕更新的生成方式也换一种(比如用扩散模型代替自回归解码)这条路能走多远。

接下来我想做的一个实验,是在数千个随机生成的应用上训练,每个应用都有自己的控件和规则。目的是看看模型能否在上下文线索足够的情况下,学会预测一个它从未见过的应用的行为。

点这里试试。下载大小 208 MB,完全在本地运行,在 Chrome 里效果最好。试着把它搞坏,然后告诉我你发现了什么。

权重很快会发布,下载链接也会加在这里。

Footnotes

  1. 它是单色的:只有字符,没有颜色或文本样式。Unicode 字符(包括 tmux 的制表边框)在编码前会映射为 ASCII,因此这些边框不需要多字节字符 token。 ↩

  2. 和我那篇 midi 文章 里的 scheduled sampling 差别不算大。 ↩

  3. 这些是短间隔测试,中间只隔 0–1 条命令。ShellBench 还测试了跨 18 条命令的持久性。 ↩

  4. 两个 prompt 模型各给了三个示例。它们预测的是整条命令的输出,而不是逐次屏幕更新,所以任务并不完全相同。 ↩

  5. 投机解码在 4090 上的加速比在我的 M4 上大得多。我猜瓶颈在算力和内存带宽之间转移,但还没认真查过。 ↩

  6. 为了迁就 GPU,补齐到了 384。 ↩

来源: SimEdw’s Blog← 返回首页