从适配到定义:HippoBuddy 的 WASM 演进之路
本系列第二篇。第一篇《为什么 Agent 需要语法检查》回答了"为什么做"(选型),这一篇回答"怎么做"(实现):从 JNI 到外部 CLI,从捡现成的 WASM 到自己编译 WASM——四代方案的更迭,本质是同一个问题的答案不断变好:如何让 Java 优雅地调用别人写的解析器。
问题与转折:从两代失败到 WASM
承接上篇:方向定了,问题还剩一半
上一篇《为什么 Agent 需要语法检查》讲完了"为什么做":大模型会犯括号未闭合、缺分号这类低级错误,Agentic Loop 为它们买单太贵,于是 HippoBuddy 决定设计一个轻量的 lint_diagnostics 工具,并最终在 CLI、LSP、tree-sitter 三张候选牌里选中了 tree-sitter——一个快速、健壮、通用且易于嵌入的解析器。
但选型只回答了问题的一半。另一半是:tree-sitter 的核心库是用 C 写的,而 Agent 的宿主是 Java——Java 怎么调用一个 C 写的库?
这个问题,HippoBuddy 回答了四次。每一次答案都推翻了上一次。
前情提要:第一代 JNI 与第二代 CLI
在进入正题之前,先快速回顾一下前两次失败——它们在上一篇《为什么 Agent 需要语法检查》里已经详细讲过,这里只留一个概要:
第一代:JNI。 用 java-tree-sitter 库,通过 JNI 调用 C 写的 Tree-sitter。能用,但只在一个平台上能用:Windows 要 .dll、macOS 要 .dylib、Linux 要 .so,每平台单独编译,JVM 位数必须匹配,一个 .so 加载失败整个进程都可能崩;而且只支持 Java 一种语言。
第二代:外部 CLI。 lint_diagnostics 改为调用各语言官方工具(javac / eslint / flake8 / go vet / cargo check),用进程隔离换取跨平台。代价是这版工具膨胀到了 925 行——找 classpath、查工具是否安装、每语言一套正则解析输出——全是为"让工具跑起来"写的胶水,还依赖本机安装、跨平台行为不一致。
两条路都走不通。剩下的问题变成:有没有一种方式,能同时拿到"进程内调用"的效率和"跨平台分发"的便利? 这时,WASM 进入了视野。
为什么是 WASM?
前情提要留下的问题是:有没有一种方式,能同时拿到"进程内调用"的效率和"跨平台分发"的便利? WASM 恰好同时给出了两个答案。先看它是什么——
WASM(WebAssembly)是 W3C 标准化的可移植字节码格式,设计目标是一次编译、到处运行,体积小、加载快、沙箱隔离。今天它早已不是实验室技术——你在互联网上接触过的不少东西背后都有它:
- Unity、Unreal 导出的 H5 游戏(WebGL),核心逻辑就是 WASM
- 微信、抖音等平台的小游戏,也支持用它跑高性能计算
- Figma、Zoom 这类重度应用,用它做浏览器里的图像、视频处理
- 连 SQLite 都有官方的 WASM 版本
可以说,凡是"想在浏览器里跑重逻辑"的场景,WASM 都是默认答案——而 HippoBuddy 想做的,是在 JVM 里也拿到这份能力。
对 HippoBuddy 的场景来说,它有几条决定性的优势:
| 对比 | JNI | 外部 CLI | WASM |
|---|---|---|---|
| 跨平台 | 每平台编译一份 .dll/.dylib/.so,JVM 位数必须匹配 | 命令名和行为随平台不同 | 一次编译,任何平台通用 |
| 部署 | 用户机器要装对应版本原生库 | 用户机器要装对应语言工具链 | 纯字节码文件,随应用分发 |
| 进程安全 | 原生库加载失败会崩整个 JVM | 进程隔离,但冷启动几百毫秒 | 沙箱隔离,崩溃不影响主进程 |
| 调用开销 | 进程内调用 | 每次冷启动新进程 | 常驻内存,毫秒级调用 |
也就是说:WASM 同时解决了"跨平台分发"和"进程内可调"两个问题——这正是第二代方案最痛的两处。它不进原生库(不像 JNI 那样依赖平台二进制)、不起进程(不像 CLI 那样冷启动),而是以字节码的形式常驻在 Java 进程内,由运行时加载执行。
但注意,这里说的是"用运行时加载执行"——Java 里跑 WASM 需要专门的运行时。HippoBuddy 选的是 Chicory(JVM 内的 WASM 运行时)。当时备选的其实还有浏览器引擎(如 V8/QuickJS 嵌入)和 GraalVM,但最终都没有选,理由会在后文"同一个项目的另一个 WASM"一节展开——简单说就是:诊断是后端能力,结果要喂给 LLM,必须留在 JVM 进程内,所以用 JVM 内嵌的运行时,而不是浏览器引擎。
方向似乎很清晰了:把 C 写的 Tree-sitter 编译成 WASM,用 Chicory 在 JVM 里加载执行——一次编译到处跑,不进原生库、不起进程、不依赖本机安装。听起来完美。
演进:从捡现成到自己定义
第三代:捡现成的 WASM——"看起来完美的答案"
于是第三代方案选择了官方 NPM 包里的现成 WASM 文件——tree-sitter.wasm 核心 + 9 个语言插件,直接下载来用。
这些 WASM 是官方用 Emscripten 编译器为浏览器场景编译的:配合官方 web-tree-sitter 绑定,在网页里做代码的语法高亮、代码折叠、结构分析——在线编辑器、代码分享站点这类产品,都是这么用的。换句话说,它们的宿主是 JavaScript 环境,从未设想过会有一个 Java 程序在自己的 JVM 进程里加载它们。
但 HippoBuddy 需要的,恰恰就是这个"从未设想过"的用法。
然后噩梦开始了。
这些 WASM 是 Emscripten 编译器编译的,而 Emscripten 有自己的一套运行时约定——它需要 16 个系统级导入,包括 WASI 函数、Emscripten 运行时函数、全局变量、内存、函数表。要让它跑起来,Java 侧必须手写全部 16 个精确匹配的 stubs:
// 16 个 stubs 之一 —— 签名必须和 Emscripten ABI 完全一致
// fd_seek 的 Emscripten 签名与 WASI 标准不一致!
Store.addFunction("fd_seek", ...); // (i32,i32,i32,i32,i32)→i32
更折磨的是:
- 注册顺序必须严格匹配 WASM 的 Import Section,错一位就
UnlinkableException - 核心运行时和语言插件要双层链接(Store 机制),语言插件依赖核心的 memory/table
- 所有
ts_*函数名都带_wasm后缀,与官方文档对不上 - 一个回调 stub 实现错了,解析永远返回空树——不报错,但结果全错
为了验证这条链路,写了 576 行的链接测试,结果只跑通了一半。官方 NPM 包里"拿来即用"的 WASM,实际上是一台需要你读懂它整个内部约定的精密仪器。
第三代方案的教训非常深刻:WASM 不是库,是编译产物。谁编译了它,它就带着谁的 ABI 包袱。 捡现成的 WASM,等于接受了别人编译器强加给你的全部约定。
第四代:自己编译的 WASM——"协议由自己定义"
前三次失败的共同点是:都在适配别人的东西——JNI 适配原生库 ABI,CLI 适配外部命令输出,Emscripten 适配别人编译器的导入约定。
第四代方案做了个关键转变:不再适配,自己定义。
那用什么语言写这个中间层?答案是 Rust——一个很说明问题的事实是:tree-sitter 官方 CLI 本身就是用 Rust 写的。
各语言的 grammar 在 crates.io 上也有现成的 Rust crate(tree-sitter-java、tree-sitter-javascript……)直接可用,wasm32-wasip1 更是 Rust 的成熟编译目标。
用 Rust 写中间层,等于站在 tree-sitter 官方工具链的同一侧——最少的胶水,最快的接入。
于是,用 Rust 写一个薄薄的中间层(约 290 行 lib.rs),把 Tree-sitter 包起来,自己决定导出什么函数、用什么协议——只导出三个函数:
#[no_mangle] pub extern "C" fn alloc(size: i32) -> *mut u8 // 分配内存
#[no_mangle] pub extern "C" fn dealloc(ptr: *mut u8, size: i32) // 释放内存
#[no_mangle] pub extern "C" fn parse( // 解析,返回 JSON
code_ptr: i32, code_len: i32, lang_ptr: i32, lang_len: i32,
) -> i64
编译目标从 Emscripten 换成 wasm32-wasip1——WASI 标准。这个选择是关键:WASI 是 WebAssembly 的标准系统接口,而 Chicory(Java 的 WASM 运行时)原生实现了 WASI。于是:
旧:捡 Emscripten WASM → 手写 16 个非标准 stubs → 双层链接 → 崩溃边缘
新:自己编 wasip1 WASM → Chicory 的 WasiPreview1 自动提供所有导入 → 零 stubs
代价对比是惊人的:
| 维度 | 第三代(Emscripten) | 第四代(Rust wasip1) |
|---|---|---|
| stubs | 16 个手写,签名须精确匹配 | 0 个,WASI 自动提供 |
| WASM 文件 | 1 核心 + 9 语言插件,双层链接 | 1 个自包含文件 |
| 链接测试 | 576 行,只跑通一半 | 删除 |
| LintDiagnosticsTool | — | 925 行 → 287 行(核心逻辑约 80 行) |
| 支持语言 | — | 9 种(全编译进一个文件) |
925 行胶水代码消失的真相:那些代码本来就不该存在。findJavaProjectRoot、resolveMavenClasspathFromPom、PATH 增强、正则解析、工具可用性检查——它们全是为了"让别人的解析器跑起来"而写的,一旦解析器变成自己编译的 WASM、通过标准接口调用,这些代码的生存理由就消失了。
最终形态是一层干净得多的架构:
Java 侧(Chicory 运行时)
├─ 加载 tree-sitter-parser.wasm(5.6MB,一次)
├─ 写入源码到共享线性内存
├─ 调用 parse() → Rust 代码真正解析 → 返回 JSON
└─ 读取 JSON → 错误清单
Rust 负责"干活"(真正解析),WASM 负责"交付"(跨平台格式),Chicory 负责"环境"(JVM 内执行)。三方各司其职,协议完全由自己定义,不再迁就任何人的 ABI。
把上面这些拼起来,一次语法诊断调用的完整链路是这样的:
LLM 决定验证代码 → 调用 lint_diagnostics(paths)
→ Java 按扩展名收集文件、按语言分组
→ 源码写入 WASM 线性内存 → 调用 parse(code, lang)
→ Rust 解析语法树 → 收集 ERROR / MISSING 节点 → 返回 JSON
→ Java 解析 JSON → 格式化成错误清单(文件、行号、列号、错误信息)
→ 清单回到 LLM 上下文 → LLM 据此直接修改代码
全程在 JVM 进程内完成,毫秒级响应——LLM 拿到的是一份结构化的"哪里缺了括号、哪里少了分号"的清单,可以就地修改,不需要再走"编辑 → 编译/运行 → 找错 → 再编辑"的多轮往返。这正是篇1 里说的那个缺口(有读、有写、缺检查)被补上的样子:检查,现在也有了。
边界与对照
边界:纯语法,不是语义
要诚实地说清这条链路的边界:Tree-sitter 是语法解析器,不是编译器前端。
- ✅ 能抓:括号不匹配、缺少分号、语法非法——结构错误
- ❌ 抓不了:类型不匹配、未定义变量、调用不存在的方法——语义错误
lint_diagnostics 兜住的是"代码能不能被解析","代码逻辑对不对"由模型自审 + 运行测试兜底。这是定位选择而非缺陷——它正好命中 AI 编程场景错误分布的密集区(模型生成的代码,最高发的恰恰是漏括号、缺分号这类语法错误),又不需要背起编译器前端的重量。
同一个项目的另一个 WASM:OOXML 预览
故事到这里还没有结束。HippoBuddy 里还有第二条 WASM 链路——Office 文件预览(@silurus/ooxml)。
有意思的是,这条链路走了完全不同的路径:
| 维度 | 语法诊断 | OOXML 预览 |
|---|---|---|
| 能力归属 | LLM 工具链(后端) | UI 交互(前端) |
| 结果去向 | LLM 上下文(文本 JSON) | 屏幕(Canvas 渲染) |
| WASM 运行时 | Chicory(JVM 内嵌) | 浏览器原生引擎 |
| 获取方式 | 自己编译 | 直接采用现成库 |
语法诊断是后端能力——结果要喂给 LLM,必须留在 JVM 进程内,所以用 Chicory;文件预览是前端能力——结果要画到 Canvas,天生属于渲染进程,所以用浏览器自带引擎。
两个决策放在一起,正好是同一个判断的两面:运行时不是技术偏好,是"能力属于谁"决定的。后端要的(结果进模型)用 JVM 内嵌,前端要的(结果上屏)用浏览器原生——各归其位。
一个自然的疑问:既然 Tree-sitter 在浏览器跑完全可行(官方
web-tree-sitter绑定就是现成的),为什么不把诊断放前端、直接复用官方 WASM、省掉自己编译?技术上可行——前端解析完把错误清单经 IPC 传回后端即可。但这条路把问题从"怎么解析"转移到了"能力归谁":lint 是后端能力(结果要进 LLM 上下文),放前端意味着测试、API 调用、headless 用法全部失效,还要多一次跨进程往返。而"省掉编译"也只是把适配 Emscripten 的活外包给了官方 JS 绑定,代价换成了后端依赖 UI 存在。
所以诊断链路当初没选浏览器引擎,不是没想过,而是能力归谁,决定了它必须留在 JVM 进程内。
总结与展望
结语:四代方案教会了什么
回顾这条演进之路:
| 代次 | 方案 | 本质 | 结局 |
|---|---|---|---|
| 一 | JNI | 适配原生库 ABI | 跨平台噩梦,只支持 Java |
| 二 | 外部 CLI | 适配外部命令输出 | 925 行胶水代码,依赖本机 |
| 三 | 捡现成 WASM | 适配别人编译器的约定 | 16 个 stubs,卡死 |
| 四 | 自己编译 WASM | 定义自己的协议 | 零依赖,核心逻辑约 80 行 |
前三次失败有个共同点:都在别人的规则里打转——别人的 ABI、别人的输出格式、别人的编译约定。第四次成功的关键不是技术选型更聪明,而是把协议的定义权拿回了自己手里。
这背后是一条更通用的判断:
当你需要调用别人写的代码时,优先问的不是"怎么调",而是"协议能不能由我定义"。
如果只能适配——你会被对方 ABI、输出格式、编译约定中的任何一个细节绑架。 如果能自己定义——一层薄薄的中间层,就能把复杂度挡在边界之外。
WASM 之所以是这条链路的终点,不是因为它比 JNI 或 CLI "高级",而是因为它是唯一让协议定义权真正回到自己手里的方案——Rust 写逻辑、WASM 交付、Chicory 执行,每一层的接口都是自己定的。925 行胶水代码的消失,就是协议定义权回归的最好证明。
两篇文章放在一起,正好构成一个完整的决策闭环:《为什么 Agent 需要语法检查》回答"为什么做"——大模型的低级错误是概率性的,永远无法根除,工程能做的,是把它们挡在闭环之外;本篇回答"怎么做"——用"协议由自己定义"的方式,让 Java 优雅地调起了 C 写的解析器。
延伸:可判定的正确,与不可判定的正确
这条链路的成立,依赖一个隐含的前提:代码的正确性是"规定死的"——语法有规范、结构有规则,所以可以在工程层面验证。lint_diagnostics 这种工具能存在,靠的就是这份"确定性"。
但换个场景,就没有这么幸运了:对于大模型的知识问答型回答,我们无法在工程层面判断其正确性。 一段代码可以解析、可以编译、可以测试,但一段"解释 Spring 的 AOP 原理"的回答,没有任何语法树能告诉你它对不对。
这延伸出了市面上 RAG 方向的一个核心难题:如何保证 LLM 的回答符合预期且正确? 代码检查工具解决的,是"可判定的正确";而知识问答面对的,是"不可判定的正确"——后者没有 tree-sitter 这样的"尺子"可用。这个问题,我们后续会单独写一篇文章来聊。