WorldTUI:我训练了一个 360M 模型来假装成终端
WorldTUI: I trained a 360M model to pretend to be a terminal
作者基于 Qwen2.5-Coder-0.5B 微调出 360M 参数的 WorldTUI,让模型直接预测终端屏幕变化,背后没有 shell 和文件系统。模型能运行 vim、tmux、贪吃蛇并记住几轮前写入的内容,但组合新命令时仍会出错。
TL;DR:我微调了一个 360M 参数的语言模型,让它充当终端。它背后没有 shell 或文件系统:每次按键输入进去,模型预测屏幕如何变化。它能创建文件、运行 vim 和 tmux、玩贪吃蛇,而且通常能记住你几条命令之前写了什么。最大的收获是教会它修复自己的错误,以及并行解码屏幕行。在浏览器中试用(208 MB),输入 pythoon 看看它并不是真正的 shell。
这个演示是为配备 WebGPU 和物理键盘的桌面浏览器设计的。Chrome 或 Edge 是最佳试用环境。它应该也能在现代手机上运行,但会慢得让人难受。
去年我第一次看到 Genie 3 时,就开始想象一个应用不再用代码编写、而是被学习出来的未来。不是由游戏引擎渲染场景,而是由模型根据用户输入预测下一个最可能的画面。
自然,我也想训练自己的世界模型。但我没有庞大的 GPU 集群,也没有成堆的视频数据,所以我缩小了野心:不做游戏世界,改做终端。
终端结果很合适。它只是字符网格而非像素,而且我可以通过运行真实 shell 生成任意多完美标注的训练数据。预训练语言模型也已经相当清楚 ls 或 cat 应该打印什么。
我真正想知道的是,它能否维持一个一致的世界,还是只是生成看起来像样的终端输出。
最终模型基于 Qwen2.5-Coder-0.5B,在 Docker 内运行真实程序录制的终端会话上微调。 跳到示例视频
它如何看待终端
标准 LLM 接收一个提示,然后一次一个地向末尾追加 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,而不是按键,蛇就是这样自行持续移动的。
复制行
当你到达终端窗口底部并按下回车时,每一行都必须向上移动一行。我本可以直接让模型在新位置重新输出每一行,但一次按键大约要消耗 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 个片段,对应大约 2B 个 token。作为对比,基础模型在训练期间大约见过 18T 个 token。
训练
我使用 Qwen2.5-Coder-0.5B 作为起点。我需要一个小模型来接近实时性能。作为一个代码模型,它也已经见过大量 CLI 用法。
我首先进行直接的监督微调:给定屏幕历史和一次按键,预测真实终端产生的屏幕增量。
普通打字很快就学会了:一个字母出现,退格键将其删除,光标移动。
然而,在较长的会话中,错误开始累积:文件名中的一个错误会传播到会话的其余部分。
从自己的错误中学习
为了解决这个问题,我借鉴了 DAgger 的思路,即模型从它访问过的状态中学习,而不仅仅是从理想演示中学习。2
对于每个训练样本:
- 在录制的会话中选一个点,通常靠近产生输出的命令。
- 让模型预测接下来的 1-4 个事件。
- 将结果屏幕与真实录制的屏幕进行比较。
- 如果差异足够小,就构造一个修正,在下次按键时修复模型的屏幕。
- 在修正后的帧和随后几个真实事件上继续训练。
delta 将模型错误的屏幕状态引导到正确的下一个屏幕。本质上,它学会了“修复”自己的错误。
有一个重要的限制:模型必须有足够的信息来推断出修正内容。修正引入的任何词语都必须出现在之前的真实屏幕或用户的输入中,而不能只出现在模型自己的预测里。
例如,如果模型拼错了之前出现过的文件名,我可以教它修正。但如果它列出了一个从未见过的文件名,我就不能指望它做对。
在这些样本上训练实际上会奖励幻觉,而我实在不需要更多幻觉了。
大约三分之一的采样会话产生了有用的修正样本。我重复了三轮,每轮都从最新模型中收集错误。
自我纠正是更有效的训练改动之一。再加上一些额外的训练数据,它将 PresetBench(定义见下文)在 int4 下从 24/47 提升到了 38/47。
总共,训练模型及其后续改进在 RTX 4090 上大约花费了 75 GPU 小时。
测试
我使用了三项测试。ShellBench 包含 24 个手写 bash 会话中的 106 条命令,按精确输出评分。PresetBench 包含 47 个脚本化用例,取自我在演示中遇到的失败。两项测试都不在训练数据中。
记忆测试包含 32 个世界。每个世界将两个随机值写入两个文件,然后在不同的会话中读回。只有两次读回都精确无误,该世界才算通过,总共有 64 次读回。名称和值从未出现在训练中。3
微调有多大帮助?
| 模型4 | ShellBench /106 | 文件读回 /13 |
|---|---|---|
| Qwen2.5-Coder-0.5B,提示 | 52 | 2 |
| Qwen3-0.6B,提示 | 60 | 3 |
| WorldTUI | 80 | 9 |
最大的差异在记忆方面:WorldTUI 正确完成了 9/13 次读回,而两个提示模型分别为 2/13 和 3/13。
它仍然远不是一个可靠的文件系统。它能较好地处理熟悉的命令,但以新方式组合它们时,很快就会出问题。例如,在 echo green >> colors 之后,运行 cat colors 通常不会显示追加的那一行。
让它变快
有一段时间,我完全避开了自回归模型。一次完整重绘大约需要 3,000 个 token,而逐个生成这些 token 对于本应像终端一样的东西来说似乎毫无希望。
但它们的表现就是比我尝试过的替代方案好得多,所以我让步了。逐个 token 生成时,打字感觉还行,约 38 毫秒/键,但任何需要重绘整个屏幕的操作(vim test.txt、man、clear)在我的 RTX 4090 上最多需要 12 秒,正如所担心的那样。有足够的时间重新考虑我是否需要手册。而且我希望它能在我的 Mac 上近乎实时地运行。
并行行
我忽略的是,一帧中的大多数行彼此不依赖,所以没有理由一个接一个地生成它们。我为每一行运行一个批次元素。每个元素获得相同的先前历史(包括 KV 缓存),但只生成自己的更新。
这将解码成本从所有行的总和(最多 3,000 步)变为最长行的长度(大约 100 步)。
然后光标在最后成为自己的一个步骤,可以看到整个行更新集合。
这需要在训练期间使用匹配的注意力掩码,但速度提升是值得的。大帧 p95 延迟从 6.7 秒降至 0.87 秒。
大帧 p95 延迟(RTX 4090)↓
顺序
6.7 秒
并行行
0.87 秒
无法相互通信的行
这听起来可能好得不像真的,而事实也确实如此。
问题在于,由于各行是独立的,它们无法协调,这导致 vim 在第 3 行而不是第 0 行打开,或者多行同时为蛇添加食物。
解决方案是推测解码。
通常,推测解码使用一个小模型来起草 token,再用一个更大的模型来验证它们。得益于因果注意力,可以在单次前向传播中验证多个 token。
在这里,我对两者使用同一个模型:并行行生成一份草稿,然后模型按顺序对其进行验证。
验证器保留 token 直到第一个不一致处。从那里开始,它生成自己的修正,并重新提出剩余的草稿。
在我的 M4 Mac 上的浏览器中,输入单个字符大约需要 60ms。像 man test 这样较大的更新按顺序执行需要 2.5s,而使用并行起草和验证则只需 0.9s。5
把它变小
我从 Qwen2.5-Coder-0.5B 开始,它大约有 494M 参数。我非常想要一个网页演示,所以我需要把它缩小。其 151,936 个 token 的词表对应的嵌入表就有 136M 参数。我只需要我的 333 个 token,6 所以我可以几乎全部丢弃它,这使模型从 494M 降到约 360M 参数(990MB → 716MB)。
然后我将其量化为 int4,使其降至 208 MB。
浏览器演示使用 Qwen 解码器的自定义 JavaScript 实现,计算着色器用 WGSL 编写并通过 WebGPU 运行。着色器在推理过程中解包 4 位权重。激活值和注意力缓存使用 32 位浮点数。所有推理都在标签页中本地运行。
蒸馏
将其蒸馏到更少的层让我了解到记忆依赖于哪些层。教师模型是一个较早的检查点,在 ShellBench 上得分为 78/106。仅移除第 7 层而不重新训练,几乎不改变下一 token 准确率:99.3% 降至 98.9%。但精确记忆回读从 64/64 降至 0/64。这些测试输入的是真实历史,所以失败并非由模型自身输出上的错误累积导致。
我的第一个蒸馏学生保留了所有偶数层,但使用了最后一层,即第 23 层,而不是第 22 层。它仍然写出了看似合理的终端输出,但仅在 31/64 次记忆回读中返回了正确的值。它总是打印出它写入的两个值之一,但大约一半的时间选错了。那是随机的,而且在训练了 480M 个 token 后它仍未恢复。
那个学生丢弃了第 1、7、15 和 22 层。一个保留了这四层的 16 层学生通过了全部 32 个记忆测试世界,精确回读为 64/64。在 4 位量化下,它仍然通过了全部 32 个世界,大小约为 138MB。不过在其他方面付出了代价:它正确完成了 65 条 ShellBench 命令,而教师模型为 78 条。所以一个学生可能看起来不错,却悄悄失去了记忆。
最终我放弃了蒸馏它。
它能做什么
记忆
这是我最感兴趣的部分。 生成一个令人信服的目录列表很容易。我想要的是创建一个文件,编辑它,离开一会儿,再回来发现它没有变化。
我第一次启动 tmux,在 vim 中打开一个文件,修改它,保存它,然后在另一个 tmux 窗口中用 cat 读取更新后的内容时,我感觉自己像弗兰肯斯坦博士:它活了!
其他应用
对我来说,这能在 bash 提示符之外工作非常重要,所以我训练了 vim、python、man 手册页、贪吃蛇甚至 tmux。
在 vim 中,它处理 esc 上的模式切换,并让我保存、退出或回到插入模式。
真正让我惊叹的是 tmux:我可以创建多个窗格,在每个窗格里做不同的事,并在它们之间跳转。它并不完美,但毕竟只是个小模型。
它甚至能管理环境变量和变量展开。
Python
由于基础模型是一个编码模型,我很好奇它会如何处理 Python REPL。它做得相当不错:小的加法通常是对的,列表推导式会返回一个列表,尽管内容往往是错的。它对拼写错误也略有容忍:pythoon 能启动 Python,但 ython 不行。
哪些地方不行
我没有允许 Docker 容器访问互联网,所以像 lynx 这样的工具相当无聊。 Python 能处理非常简单的操作,但稍微难一点的操作就会产生让我想起 GPT-3 时代幻觉的答案。
有时,当我创建嵌套目录时,它会迷失方向,突然间我就到了另一个路径下。 显然,一致性方面还有很大的改进空间。
在我的测试中,这个演示在 Firefox 中运行比在 Chrome 中慢约 20 倍。两者都使用 WebGPU。我还没能确定原因。
和这类项目一贯的做法一样,我先尝试自己解决,然后才去看别人是怎么做的。
早在 2022 年,人们就已经在提示 ChatGPT 充当 Linux 终端了。最近,GameNGen 用扩散模型模拟了 Doom,而 NeuralOS 则着手处理桌面 GUI。
WorldTUI 更接近最初的 ChatGPT 终端技巧,不同之处在于它专门针对按键和屏幕更新进行训练,包括 vim 和 tmux 这类全屏应用。
总结
我构建这个项目时玩得很开心。它有用吗?并不真的有用。但对于一个在浏览器标签页中运行的 360M 参数模型来说,它远超我的预期。
最让我感兴趣的是,它在底层没有实现任何规则的情况下,竟然能维持一个大致一致的世界。
它显然是一个相当弱的世界模型,我很想看看这种方法在更好的一致性以及不同的屏幕更新生成方式(也许用扩散模型代替自回归解码)下能走多远。
我接下来想尝试的一个实验是在数千个随机生成的应用上进行训练,每个应用都有自己的控件和规则。想法是看看模型能否在获得足够上下文线索的情况下,学会预测一个它从未见过的应用的行为。
在这里试试。下载大小为 208 MB,完全在本地运行,在 Chrome 中效果最佳。试着把它弄坏,然后告诉我你的发现。
我很快会发布权重,并在这里添加下载链接。
来源:Hacker News · simedw.com