他训练了一个 360M 的模型,让它假装自己是一台终端
Simon Edwardsson 用逐次按键的方式,让 360M 参数的模型直接预测终端画面,背后没有 shell。行并行解码把整屏重绘从 6.7 秒压到 0.87 秒,最后整套模型以 208 MB 跑在浏览器标签页里。
中文
复制

TL;DR: 我微调了一个 360M 参数的语言模型,让它充当终端。它背后没有 shell,也没有文件系统:每次按键输入进去,模型预测屏幕会怎么变化。它能创建文件、运行 vim 和 tmux、玩贪吃蛇,而且通常能记住你几条命令之前写了什么。收益最大的两件事,是教它修复自己的错误,以及并行解码屏幕的每一行。在浏览器里试试(208 MB),输入 pythoon 就能看到它并不是真正的 shell。
这个 demo 是为支持 WebGPU、配有物理键盘的桌面浏览器设计的。最好用 Chrome 或 Edge 试。现代手机上应该也能跑,只是慢得让人难受。

去年第一次看到 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 容器里运行真实命令,记录每一次按键以及按键之后的屏幕内容,以此生成可信的终端会话。
合成数据的配方是这样的:
- 随机化用户名、主机名、提示符样式、终端尺寸。
- 根据虚构的人物设定(开发者、系统管理员等)随机化目录和文件。
- 挑选 6–24 个带权重的活动(修改文件、vim、python、tmux 等)。
- 跟踪状态,让会话打开的文件大多是真实存在的。
- 像人一样打字:一次一个按键,带 tab 补全等。我把错字率设为 1.2%,这对我已经算宽松了。
- 35% 的会话从中间开始,迫使模型学会从任意状态接着往下做。
终端尺寸主要是 32×96,也有一部分 24×80 和 40×120,免得模型对某一种尺寸过拟合。
随着模型能力提升,我不断扩充数据集,最终定在 336k 个 episode,大约对应 2B token。作为对比,基座模型在训练中见过约 18T token。
训练
起点是 Qwen2.5-Coder-0.5B。我需要一个小模型才能接近实时性能。它本身是代码模型,也已经见过大量 CLI 用法。
一开始就是常规的监督微调:给一段屏幕历史和一个按键,预测真实终端产生的屏幕变化。
普通打字很快就学会了:字母出现、退格删掉它、光标移动。
但在较长的会话里,错误开始累积:文件名打错一次,就会一路影响后面的会话。
从自己的错误中学习
为了解决这个问题,我借鉴了 DAgger 的思路:让模型从自己实际访问到的状态中学习,而不只是从理想示范中学习。2
对每个训练样本:
- 在录制的会话中选一个点,通常靠近产生输出的那条命令。
- 让模型预测接下来的 1-4 个事件。
- 把得到的屏幕与真实录制的屏幕作比较。
- 如果差异足够小,就构造一次修正,在下一个按键时把模型的屏幕修复回来。
- 在修正后的画面和随后几个真实事件上继续训练。
这个差值把模型出错的屏幕带向正确的下一屏。本质上,它学的是“修复”自己的错误。
有一条重要限制:模型必须掌握足够的信息才能推断出修正内容。修正中引入的任何词,都必须在此前真实出现过的屏幕上或用户的输入中出现过,而不能只来自模型自己的预测。
举例来说,如果模型拼错了此前出现过的文件名,我可以教它改正。但如果它列出一个从未见过的文件名,我就不能指望它写对。
拿这类样本训练,实际上是在奖励幻觉,而我实在不需要更多幻觉了。
抽样的会话中约有三分之一产出了有用的修正样本。我把这个过程重复了三轮,每轮都从最新模型上收集错误。
自我纠正是比较有效的训练改动之一。再加上一些额外的训练数据,它把 PresetBench(定义见下文)在 int4 下的成绩从 24/47 提升到了 38/47。
总计下来,训练模型及其后续改进在 RTX 4090 上花了大约 75 GPU 小时。
测试
我用三个测试。ShellBench 包含 24 个手写 bash 会话中的 106 条命令,按输出是否完全一致评分。PresetBench 包含 47 个脚本化用例,取自我在 demo 中遇到的失败。两个测试都不在训练数据里。
记忆测试包含 32 个世界。每个世界把两个随机值写入两个文件,然后在不同的会话中读回。只有两次读回都完全一致,这个世界才算通过,因此总共有 64 次读回。名称和值从不出现在训练中。3
微调能带来多大帮助?
| 模型4 | ShellBench /106 | 文件读回 /13 |
|---|---|---|
| Qwen2.5-Coder-0.5B,prompted | 52 | 2 |
| Qwen3-0.6B,prompted | 60 | 3 |
| WorldTUI | 80 | 9 |
差别最大的是记忆: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 读到更新后的内容时,我感觉自己就是弗兰肯斯坦博士:它活了!

其他应用场景
对我来说,这套方案必须能用在 bash 提示符之外,所以我在 vim、python、man 手册、snake,甚至 tmux 上都做了训练。
在 vim 里,它能处理 esc 上的模式切换,让我可以保存、退出或回到插入模式。

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

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

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

行不通的地方
我没有让 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 里效果最好。试着把它搞坏,然后告诉我你发现了什么。
权重很快会发布,下载链接也会加在这里。