AI 写了 7300 行 MoonBit:编译器能验的,和验不了的

文章来源声明: 原文作者:用户2157828396633; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689644399769796649; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

低资源语言 AI 编码的范式样本:可复现的坑清单加三层反馈回路,适合用 LLM 写小众语言、或想给 AI 生成代码加验证闸门的工程师参考。

> 定稿。所有数字都来自真实项目、可用文中命令复现。 项目仓库:[github.com/Careylq/spe…](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2FCareylq%2Fspell.mbt "https://github.com/Careylq/spell.mbt") | 完整坑清单:仓库内 `docs/MOONBIT_GOTCHAS.md`(43 条)

一个具体的瞬间

我最开始让 AI 写 MoonBit 时,它给了我这样一段代码:

<span>import</span> { <span>"moonbitlang/core/string"</span> }

看起来毫无问题——但 MoonBit 里没有这个语法。那次我的编译输出是 24 个错误,全部源于同一处写法。

这不是模型的错。这是"用 AI 写一个小众语言"的默认状态。


这不是感觉,是有数据的

IEEE TSE 录用的论文(arXiv 2606.16827)把 MoonBit 归类为 "no-resource language" ——LLM 几乎没有见过这门语言的训练数据。实测 McEval-Hard 零样本 pass@1:

语言类型零样本 pass@1
高资源语言59–89%
低资源语言27–84%
**MoonBit / Gleam****0–1%**

论文还提到,即使用 1,370 万 token 的 MoonBit 语料继续预训练,也只能提到约 15%。

MoonBit 官方自己的博客也承认过这件事。他们发布 Pilot(官方代码智能体)时写:

"由于缺乏 MoonBit 专用训练数据,模型第一次并没有产出正确输出;通过自动调用工具链拿到精确反馈,它才修复并优化了代码。"

换句话说:在 MoonBit 里,AI 能不能用,取决于你有没有把编译器接进回路。


43 个坑,分五类

我在一个真实项目里把这件事验证了一遍:用 MoonBit 从零实现一个 Hunspell 兼容的拼写检查库(.aff/.dic 格式解析 + 词缀形态学 + 拼写判定 + 建议生成)。全程 AI 辅助,最终产出 7,350 行实现 + 3,323 行测试。

过程中撞上了 43 个坑,全部由编译器、warning 或实际运行验证过。挑最典型的:

A. AI 最常写错、编译器直接拒绝的

坑真相代价
**`fn f(self : T, ...)` 当自由函数写**已废弃(0027),编译器把它注册成 `T::f`,同文件里调用 `f(...)` 就报"未绑定值"**一处写法 → 24 个报错**
**`unused_mut` 是 error,不是 warning**往 `Array` 里 push **不需要**字段是 `mut`一个"顺手加的 mut"直接编译失败
`&` 的优先级**低于** `==`必须写 `(b & 0xC0) == 0x80`逻辑悄悄错
`pub struct` 的**私有字段类型也必须是 `pub`**否则跨包报 4046加法式改动引发结构性报错

第一条特别典型:AI 按 Rust/Python 的直觉写 self,而 MoonBit 有自己的一套规则。它写得"很合理",但编译器不接受。

B. 已废弃的写法(AI 训练数据里的旧语法)

inspect → @debug.debug_inspect · not(x) → !x · StringView::to_string() → to_owned() · x.is_none() 已废弃 · substring 已废弃 → 用切片 · #| 多行字符串不能直接当调用参数

这类坑的特征是:AI 用的语法在某个旧版本里是对的。 它的知识过期了,而它自己不知道。

C. MoonBit 与主流语言不一样的地方(会静默出错)

最值得记的一条:

String 是 UTF-16 code unit 序列。 length() 数的是 code unit,char_length() 才是码点; get_char(i) 按 code unit 索引(切在代理对中间返回 None); 只有 to_array() 按码点给出 Char。

做 Unicode 正确的字符匹配必须用 to_array()/char_length() —— 否则遇到 emoji 或非 BMP 汉字就会错。

这一类不会报错,只会算错。所以我们给每个这类函数都配了测试。

D. 工程配置类(容易卡住半天)

  • moon.pkg 的测试包要单独写 import { ... } for "test" / for "wbtest"
  • blackbox 测试里要写 @包名. 前缀
  • moon fmt 会改写 moon.mod(插空行),可能弄脏你不想动的文件
  • 用 moon explain --diagnostic <码> 查清哪些 warning 实际是 error(例:unused_mut = error id 15)

E. 编译器根本不管的语义 bug

这两条不是语法错误——AI 写出来照样编译通过,只有测试能抓:

  1. 前缀规则的 add 必须前置,后缀才后置。 第一版把 PFX 0 re . 也写成追加,create 变成了 createre。
  2. 条件必须对「未 strip 的 stem」匹配。 SFX y ied [^aeiou]y 若先 strip 掉 y,剩下的 impl 永远不可能匹配以 y 结尾的模式。

编译器保证语法和类型,测试才保证语义。


但还有第三层:编译器和测试都验不了的

上面 A–E 五类,都有一个共同的解药——跑一遍。语法错跑 moon check,语义错跑 moon test。

真正让我改观的是最后一件事:有一大类 bug,moon check 干净、测试全绿、符合率一字不差,但项目已经偷偷慢了 2.3 倍。

收口时我顺手把 README 里记的性能数字重测了一遍——结果对不上:文档写的是直接命中 0.34 µs/词,实测是 0.75 µs/词。

排查的第一反应是怀疑算法。我读了一遍判定路径,排除了最可疑的那个循环(|| 短路,直接命中时根本不会执行到)。然后用了最笨也最可靠的办法:同一台机器、同一条命令、对真实 en_US 词典自带的 49,568 个词计时,再用 git stash 回退到改动前,A/B 对照:

版本直接命中未命中
改动前**0.746 µs/词**9.81 µs/词
只修第一处**0.403 µs/词**9.54 µs/词
再修第二处**0.323 µs/词**9.28 µs/词

两处都不是算法错,而是纯函数被放在了循环里、白付了成本:

  1. apply_conversion 在每个输入字符的循环里重新解析一遍转换规则、重新分配一个数组——而那个规则表在循环里根本不变。en_US 有一条 ICONV,于是每检查一个词都要分配"词长 × 规则数"个数组。最讽刺的是,这个文件自己的注释白纸黑字写着"per-word cost is a linear scan without re-parsing the patterns" ——设计意图在实现里从未落地。
  2. check_word_group 为了判断"这一行是不是多个词",先分配一个数组、一个字符串缓冲、再把整词复制一份,然后才发现只有一个词。而绝大多数输入就是一行一个词。

修完之后,2.31 倍回来了。

这件事的关键不在于"AI 写了慢代码"。关键在于:这三样东西全都发现不了它。

  • moon check --deny-warn → 0 error, 0 warning
  • moon test --target all → 176 个测试全绿
  • Hunspell 官方语料符合率 → 一个数都没变

因为它们全都在验"对不对",没有一个在验"贵不贵"。

还有一件事:度量本身也会骗你

同一轮收口里,我还发现自己引以为据很久的一个数字是错的。

符合率套件里有个 .good 文件(记录"必须被接受"的词),其中 16 行本身含空格(drink eat 这种)。而我的测试脚本用 paste 把"输入流"和"判定流"按位置粘起来配对——含空格的行一错位,后面全歪,于是这 16 行无论引擎答对答错都被记成失败。

改成"每个非空输入行恰好一个判定、直接计数"之后:

口径`.good` 通过率
按位置配对(我用了很久的)825/848 = **97.3%**
按判定计数(正确口径)841/848 = **99.2%**

差值 16,正好是那 16 行。引擎一个字都没改。

也就是说:我一直在用低估了 1.9 个百分点的数字向别人汇报自己的项目,而且每次"重跑确认未回归"都在重复同一个错误。

这两件事合起来是我这轮最大的收获:

"跑出来的数字"比"想出来的结论"可靠——但"跑的方法"本身也需要被怀疑。


所以我改成了什么工作流

一句话:AI 负责打字,编译器负责判定真假,而性能只能自己盯着。

具体是这几条:

  1. 一次只让 AI 写一个函数 + 它的测试,不一次生成上百行
  2. 写完立刻 moon check;报错就把原始报错文本贴回去
  3. moon test 通过才算这块完成,否则不进下一块
  4. AI 说"已修复"时,一律自己重跑 moon check
  5. 语法不确定时查官方文档或 core 源码,不接受 AI 的语法
  6. 改一个没有测试的函数之前,先给它补测试。 我这轮改的 apply_conversion 实现了 ICONV/OCONV 却一行单元测试都没有;补了 10 个之后,我还故意把快速路径改坏,确认其中 5 个会失败——测试本身也要被测试
  7. 收口时重测,不沿用文档里的数字——因为那 0.34 µs 就是这么变成 0.75 的

这套流程的关键在于:MoonBit 的工具链反馈非常精确。错误码、行号、moon explain 的解释都很到位——所以"AI 生成 → 编译器判定 → 修正"这个循环跑得很快。反馈质量决定 AI 能不能用。


结果

  • 176 个测试(native 180),wasm / wasm-gc / js / native 四个后端全部通过;moon check --deny-warn 0 warning
  • Hunspell 官方语料(154 个测试套件、848 个必须接受的判定): .good 841/848 = 99.2% | .wrong 611/613 = 99.7% | 建议质量 141/173 = 81.5%
  • 在真实的 LibreOffice en_US 词典(49,568 词条)上:49,565 个按预期接受, 被拒的 3 个带 ONLYINCOMPOUND、独立出现本就该拒
  • 与 C++ Hunspell 1.7.3 交叉验证:这 49,568 个词条上两个实现判定完全一致; 另在 235,976 个真实词(/usr/share/dict/words)上做差分测试,分歧 199 个 = 0.084%
  • 性能(原生二进制对原生二进制,同词典同词表):直接命中 0.38 µs/词 vs Hunspell 0.36 µs/词(持平) ; 未命中路径慢 6.44 倍,差距精确定位在那一处
  • wasm 产物 142.2 KiB,无 C++ 运行时

我的判断

低资源语言用 AI,不是"能不能用"的问题,而是"工作流必须怎么设计"的问题。

在 Python 或 TypeScript 里,你可以让 AI 写一大段然后跑起来看看。在 MoonBit 里,你必须假设它写的每一行都可能是错的——然后让编译器告诉你哪一行是错的。

但更重要的是下一句:编译器只能告诉你"哪一行是错的",它不会告诉你"哪一行是对的但很贵",也不会告诉你"你的测试在测一件错的事"。

所以完整的答案不是"把编译器接进回路",而是: 把编译器接进回路,再把度量接进回路,最后把"怀疑自己的度量"也接进回路。

一旦接受这个前提,剩下的就是工程问题:把反馈回路做短,把每一步都变成可验证的。

那 43 个坑我全部记在了项目的 docs/MOONBIT_GOTCHAS.md 里。如果你也在用 AI 写 MoonBit,那份清单可以直接拿去避坑。


项目地址:github.com/Careylq/spe… | 包:mooncakes.io/docs/Careyl…