Hacker News 中文摘要

RSS订阅

WorldTUI: I trained a 360M model to pretend to be a terminal

去年第一次看到 Genie 3 后,我开始想象一个未来:应用不再用代码编写,而是被学习出来——模型根据用户操作预测最可能的下一帧,而不是由游戏引擎渲染场景。由于没有大型 GPU 集群和大量视频数据,我把野心缩小到终端:终端只是一格字符组成的网格,很适合训练自己的世界模型。

正文

演示针对支持 WebGPU 和实体键盘的桌面浏览器设计。Chrome 或 Edge 是最佳尝试环境。它应该也能在现代手机上运行,但慢得令人痛苦。

WorldTUI 演示导览视频

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

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

结果终端很合适。它只是字符网格而不是像素,而且我可以通过在 Docker 里运行真实 shell,生成任意多完美标注的训练数据。预训练语言模型也已经对 ls 或 cat 应该输出什么有不错的直觉。

我真正想知道的是,它能否保持一致的世界,还是只会生成看起来像终端的输出。

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

它如何看待一个终端

标准 LLM 接收提示,然后一个一个往末尾追加 token。这不太适合终端。单次按键本身就能起作用,比如 vim 里的 esc,而且输出不一定总在底部:clear 会清屏并把光标送回顶部。所以我不需要预测文本流,而需要预测屏幕的变化。

我把终端当作一个字符网格,每个单元格一个字符。1 模型不直接看到网格,而是看到一串描述其内容和所做更改的 token。

一次会话

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

尺寸是输入的一部分,不固定为 32×96。在演示中,创建自定义起始屏幕时可以设置行数和列数。例如,一个起始快照的 token 序列是: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,而不是按键;贪吃蛇就是这样自己持续移动的。

复制行

当你到达终端窗口底部并按回车时,每一行都必须上移一行。我可以让模型把每一行在新位置重新输出一遍,但一次按键就要大约 3000 个 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 个脚本化用例,来自我在演示里遇到的失败。两个测试都不在训练数据中。

记忆测试有 32 个世界。每个世界把两个随机值写入两个文件,然后在分开的会话中读回。一个世界只有在两次读回都精确时才通过,总共 64 次读回。名字和值从不出现在训练中。3

微调有多大帮助?

| 模型4 | ShellBench /106 | 文件读回 /13 | | --- | --- | --- | | Qwen2.5-Coder-0.5B,提示式 | 52 | 2 | | Qwen3-0.6B,提示式 | 60 | 3 | | WorldTUI | 80 | 9 |

最大的差别是记忆:WorldTUI 在 13 次读回中做对 9 次,而提示式模型分别是 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 步。

光标随后成为最后单独一步,能看到整组行更新。

这需要在训练时配一个对应的注意力掩码,但速度提升值得。大帧 p95 延迟(RTX 4090)↓:顺序执行 6.7 秒,并行行 0.87 秒。

无法互相通信的行

这听起来好得不像真的,确实如此。问题在于行之间独立,无法协调,导致 vim 在第 3 行而不是第 0 行打开,或者几行同时给贪吃蛇加食物。

解决办法是投机解码(speculative decoding)。

通常投机解码用一个小模型起草 token,再用更大的模型验证它们。由于因果注意力,可以在一次前向传播中验证多个 token。

这里我对起草和验证用了同一个模型:并行的行产生一个草稿,然后模型顺序验证它。

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

你在 cat numbers.txt 后按回车。屏幕上几乎每一行都要变。$ cat numbers.txt:一次一个 token 需要 43 步;草稿加验证是 0 步——先一个涵盖所有行的草稿步,再整份草稿验证通过,也就是单步。

在浏览器里的 M4 Mac 上,输入单个字符大约要 60 ms。像 man test 这样更大的更新,顺序执行要 2.5 秒,用并行起草和验证是 0.9 秒。5

让它变小

我从 Qwen2.5-Coder-0.5B 开始,它有大约 494M 参数。我很想要网页演示,所以需要缩小它。它 151,936 token 词表的嵌入表是 136M 参数。我只需要我的 333 个 token,6 所以几乎可以丢掉全部嵌入表,这把模型从 494M 降到大约 360M 参数(990MB → 716MB)。

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

浏览器演示用 JavaScript 实现了 Qwen 解码器,计算着色器用 WGSL 编写并通过 WebGPU 运行。着色器在推理时解包 4 位权重。激活值和注意力缓存用 32 位浮点。所有推理都在标签页本地运行。

蒸馏

把它蒸馏到更少层数让我明白了记忆依赖哪些层。教师是一个更早的检查点,在 ShellBench 上得 78/106。不重新训练直接去掉第 7 层,next-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 读到更新后的内容时,我觉得自己像 Frankenstein 博士:它活了!

在 tmux 中用 vim 修改文件并在另一个窗口读取的视频

其他应用

我非常希望它不止能用在 bash 提示符下,所以我训练了 vim、python、man 页面、贪吃蛇甚至 tmux。

在 vim 里,它处理 esc 的模式切换,让我能保存、退出或重新进入插入模式。

贪吃蛇游戏视频

可能是世界上计算最密集的贪吃蛇。

真正让我震惊的是 tmux:我可以创建多个窗格,在每个里做不同的事并在它们之间跳转。它不完美,但它只是个小模型。

tmux 多窗格操作视频

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

环境变量与变量展开演示视频

Python

因为基座模型是代码模型,我好奇它会怎么处理 Python REPL。它做得还可以:小加法通常是对的,列表推导式会返回一个列表,尽管内容常常错。它对拼写错误也稍微宽容:pythoon 能启动 Python,但 ython 不行。

Python REPL 演示视频

哪些地方不行

我没给 Docker 容器联网权限,所以 lynx 这类工具相当无聊。Python 能处理非常简单的操作,但更难的东西产生的回答让我想起 GPT-3 时代的幻觉。

有时创建嵌套目录时它会迷路,突然我就在另一个路径里了。一致性显然还有很大提升空间。

在我的测试中,演示在 Firefox 里比在 Chrome 里慢大约 20 倍。两者都用 WebGPU。我没能确定原因。

和这类项目时一样,我先自己尝试解决,然后才看别人怎么做。

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

WorldTUI 更接近最初 ChatGPT 终端的把戏,只不过它专门在按键和屏幕更新上训练,包括 vim 和 tmux 这类全屏应用。

总结

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

我最感兴趣的是,它能在不实现任何底层规则的情况下,维持一个多少一致的世界。

它显然是个很弱的世界模型,我想看看这种方法在更好的一致性和不同的屏幕更新生成方式(也许用扩散而不是自回归解码)下能走多远。

我想试的下一个实验是:在数千个随机生成的应用程序上训练,每个都有自己的控件和规则。想法是看模型能否学会预测一个从未遇到过的应用的行为,只要有足够的上下文线索。

在这里试用。它是 208 MB 下载,完全本地运行,在 Chrome 里效果最好。试着弄坏它,并告诉我你发现了什么。

我很快会发布权重,并把下载链接加到这里。


  1. 它是单色的:只有字符,没有颜色或文字样式。Unicode 字符(包括 tmux 的制表符边框)在编码前会映射为 ASCII,因此这些边框不需要多字节字符 token。↩
  2. 这和我 midi post 里的 scheduled sampling 没有太大不同。↩
  3. 这些是短间隔测试,中间只有 0–1 条命令。ShellBench 也测试跨 18 条命令的持久性。↩
  4. 两个提示式模型各看了三个示例。它们预测整条命令的输出,而不是逐个屏幕更新,所以任务不完全一样。↩
  5. 投机解码在 4090 上带来的加速比在我的 M4 上大得多。我怀疑瓶颈在计算和内存带宽之间转移,但我没好好研究过。↩
  6. 填充到 384 是为了让 GPU 高兴。↩

评论

讨论整体氛围是好奇与赞赏居多,但分歧主轴很清楚:不少人被「小模型仅靠微调就能实时模拟终端」惊艳到,也有人实测后认为它只是没有真实状态、在当前上下文里编造输出的玩具。换言之,争议集中在「这是下一代用户界面的雏形,还是仅仅是个有趣的无状态幻觉」。

认为小模型靠微调就能实时模拟终端,打开了新想象空间

  • 论据:大家反复提到,亮点不在于命令行本身,而在于用很小的模型(如 Qwen2.5-Coder-0.5B)并通过微调,就能接近实时地生成像模像样的终端交互;有人一开始甚至把模拟的 tmux 当成真 tmux,也有人回忆起早年用 GPT-3 做过类似尝试并真的跑通过,因此觉得“应用整体就是一个模型”有很大探索空间。(较多人)
  • 引用: > 非常非常酷,没想到只靠微调就能做到。(@nico)

把它看作未来用户界面的雏形,但门槛是本地推理速度

  • 论据:有人半认真半遗憾地推测,这可能就是用户界面的未来:界面不再由传统代码绘制,而是由模型实时生成;只要本地小模型足够聪明、推理快到能维持帧率,这条路长期似乎不可避免,但眼下速度仍是硬门槛。(少数人)
  • 引用: > 我遗憾地怀疑,这就是用户界面的未来,只要本地模型足够聪明且快到能维持帧率,长期看似乎不可避免。(@thorum)

质疑意义:已经有真实终端,为什么还要模拟

  • 论据:有人直接质疑项目价值,认为如果用户本来就有终端,再做一个模拟终端的界面似乎没有明确用途;这类意见不多,但点出了“模拟”与“真实工具”的边界。(少数人)
  • 引用: > 如果我已经有终端了,这个项目到底有什么意义?(@htoz1)

实测后失望:并没有真实状态,只是靠上下文编造

  • 论据:有用户试用后发现,它并不能真正维护文件系统状态,会坚持说要建的文件夹已存在,又接受拼写错误的创建命令,但列表时却看不到该目录;本质上没有状态、不守规则,只是在当前上下文里生成看似合理的输出,远没有演示动图那么“魔法”。(少数人)
  • 引用: > 我试了,效果和演示动图差得远,因为它没有状态、不守规则,只是在当前上下文里编造。(@donperignon)

追问模型如何假装拥有时间和状态

  • 论据:有人疑惑 sleep 3 是怎么执行的,因为大语言模型并没有内在时间概念;也有人好奇模型是否知道终端窗口宽度。这类追问把讨论引向一个反直觉之处:看起来像真实命令的时序和状态,可能只是模型生成的一场“表演”。(少数人)
  • 引用: > 等等,我很好奇它到底是怎么执行 sleep 3 的,毕竟我还以为大语言模型没有时间概念。(@yjftsjthsd-h)

认为它不是孤例,而是“整个应用就是一个模型”的同类尝试

  • 论据:不少人把它和微软的 Quake II 实时世界模型、早年的伪图形界面操作系统、以及把网页服务器全部请求喂给大语言模型的网页应用相提并论;共同点是不写传统业务逻辑,而是让模型直接生成整个环境或界面。还有人做了“有损 Git”,用神经网络训练后再生成代码,思路一脉相承。(较多人)
  • 引用: > 这让我想起微软的一个项目:一个 Quake II 的模拟模型,整个应用本身就只是一个模型。(@psandor)

有价值的补充

  • 有评论提出一个更可靠的改进方向:给小模型接上工具调用 API,包含一个文件系统、一个内置 busybox,再留一个专门输出“busybox 在这里会做什么”的词元;这样模型参数只需学习 busybox 处理不了的部分,可能大幅提升终端行为的准确性。
  • 微调不仅让终端交互更像样,还显著改善了模型的记忆能力,这点让评论者意外;他还用了“怎么去卡内基音乐厅?——练,老兄,练”的类比,暗示这种能力提升更像大量训练而非显式编程的结果。
  • 在 Firefox 下存在兼容问题:每次按键都会触发一次缓慢的解码,像慢动作一样,只有按回车才像发送命令,说明按键与终端刷新的处理还需要适配。