为什么 Agent 需要语法检查:HippoBuddy 的选型之路
桌面端 Agent 产品里,几乎找不到第二个专门做语法检查的工具。这个"没人做"的功能,恰恰是 Agent 编码闭环里最划算的一笔投资。
为什么需要:Agent 编码闭环里缺一把"尺子"
一个被忽略的缺口
如果你去观察市面上的桌面端 Agent 产品——除了 Cursor、Trae、Windsurf 这类 AI 原生的 IDE——在编码场景下,几乎很少有产品设计了专门的代码语法检查工具,而他们即便设计了语法检查工具,很大概率也是依托 IDE 自带语法检查能力。
这不是因为"不需要"。恰恰相反,这是 AI 编程场景里最高频的操作之一:Agent 写完代码,需要一把轻量的尺子,在几毫秒内拿回"哪里缺了括号、哪里少了分号"的清单,然后基于清单把代码改对。
HippoBuddy 在这里做了一个不一样的选择:专门设计了一个代码检查工具(lint_diagnostics)。这篇文章想讲清楚的,就是这个工具为什么值得存在、又为什么长成了现在这个样子。
缘起:大模型也会犯低级错误
一个很自然的直觉是:大模型写的代码,基本都是正确的,怎么会出错呢?
事实也确实如此——今天的模型,写一段小代码的正确率高得惊人。但如果你真的用 Agent 编程足够久,就会发现一个反直觉的分布:模型最容易犯的错误,往往不是逻辑错误,甚至不是"语法错误",而是一类更低级的错误。
在早期模型能力还没有现在强的时候,这个问题尤其明显。回顾实际使用过程,模型写代码时最常犯的几类错误是:
- 一个括号没闭合
- 一个句末的分号
;没写 - 一个包忘了导入
都是这种"低级"到让人哭笑不得的问题。今天的主流模型确实很少再犯这类错误了,但注意——是"很少",不是"没有"。尤其是在长文件编辑的场景下,发生概率会明显上升。
为什么大模型会犯这种错误?这与模型的底层输出机制和"聪明程度"有关。LLM 是逐 token 生成内容的,序列越长,注意力在长距离上的衰减越明显,在接近文件末尾的地方漏掉一个字符、少写一个分号,本质上是一种概率性的失误——它不是"不会",而是"偶尔忘"。模型越聪明,这个概率越低,但永远不会归零。
成本账:Agentic Loop 的低效买单
既然错误不可避免,那问题就来了:在一轮会话中,大模型是如何发现这些错误的?
答案往往不是"自己看出来的"——模型会自信地说"没问题",然后漏掉一个分号。大多数时候,错误是通过代码的编译或运行才暴露出来的:
编辑完代码 → 编译/运行 → 报错 → 回到源码里找问题 → 修改 → 再编译运行验证 → 通过 ✅
↑________________________________________|
这就是所谓的 Agentic Loop。
问题恰恰出在这里:一个如此低级的失误,却要花费这么多轮工具调用去买单,显然不划算,也不合理。
- 编译失败 → 读报错 → 定位 → 修改 → 重编译 → 可能还有下一个错……一个分号引发的血案,可能烧掉七八次工具调用
- 更糟的是,这些工具调用全部要经过 LLM 的往返,每一步都有 token 成本和延迟成本
- 而错误本身,也许只是少写了一个字符
从架构设计角度看,这里有一个非常明显的缺口:大模型已经有了"读"和"写"的能力,却没有一个"快速检查"的能力。
LLM 已有:
read → 读文件(grep / glob / read_file)
write → 改文件(write_file / edit_file)
LLM 缺少:
check → 快速验证编辑结果(语法诊断)
当然,用 bash 工具去编译、测试、运行,确实也能检测出这类错误——但这对一个项目来说有点"大材小用"了:为了验证一个分号,把整个编译链路跑一遍,不值得。
所以结论很清晰:对于 LLM 会犯的这类低级错误,能不能设计一个轻量的工具,专门检测它们?
答案是:可以。
怎么选:四轮淘汰,只剩 tree-sitter
一段搁置的往事:JNI
其实 HippoBuddy 最早的设计里,就有代码诊断的打算——在 LLM 编辑完代码之后,立刻让它知道编辑后的代码有没有语法错误;如果有,编辑完就直接把错误返回给 LLM,而不是等编译报错。
早期尝试过用 JNI(Java Native Interface)来做这件事——当时选的是 java-tree-sitter 库,一个封装了 Tree-sitter C 接口的 JNI 绑定。但很快发现一个绕不过去的坎:不可能每一个语言都去引入一个对应的原生依赖,或者编写一套对应的诊断规则。
JNI 的本质是"Java 进程内加载原生共享库"——Windows 要 .dll,macOS 要 .dylib,Linux 要 .so,每个平台都要单独编译一份,而且每个用户的机器上都要有对应版本。对桌面应用来说,这是灾难:
- 用户换了平台,解析器就得跟着换二进制
- 原生库版本和 JVM 位数(32/64 位)必须匹配
- 一个
.so加载失败,整个 Java 进程都可能崩 - 换一种语言,又是另一套原生库、另一套规则——而且 JNI 绑定只支持 Java 一种语言
每种语言都要单独维护,工程量线性膨胀,扩展性极差。于是这个方向暂时搁置了。但搁置不等于放弃,需求还在,只是缺一个合适的实现路径。
三张候选牌:CLI / tree-sitter / LSP
当"语法检测能力"重新被提上日程时,问题变成了:有没有现成的工具?
有的。最终进入视野、也最主流的,是三个方向:
| 方案 | 是什么 | 一句话概括 |
|---|---|---|
| 官方 CLI 工具 | 每种语言官方自带的检查/编译命令 | javac、eslint、flake8、go vet、cargo check |
| tree-sitter | 解析器生成工具 + 增量解析库 | 把代码解析成语法树,检测结构错误 |
| LSP | 语言服务协议 | 现代 IDE 的基石,跳转/引用/诊断全包 |
三个方案各有拥趸,也各有代价。HippoBuddy 实际把三条路都走了一遍——其中两条是踩坑踩出来的。
CLI:看似简单,处处是坑
第一反应肯定是:CLI 工具这么简单,直接调不就行了?哪有这么麻烦。
最初的 lint_diagnostics 工具,确实就是采用的官方 CLI 方案:javac -Xlint 查 Java、eslint 查 JavaScript、flake8 查 Python、go vet 查 Go、cargo check 查 Rust……思路很干净:Agent 不自己解析,让各语言最权威的解析器替它做。
但设计完、真正用起来之后,问题就暴露了。这版工具膨胀到了 925 行,其中大部分不是"诊断逻辑",而是"伺候外部工具的胶水代码":
// 找 Java 项目的 classpath(从 pom.xml 解析,~150 行)
private Path findJavaProjectRoot(Path target) { ... }
private String resolveMavenClasspathFromPom(Path pomFile) { ... }
// 检查工具在不在(where / which),不在就报错
private String checkToolAvailable(String language) { ... }
// 每个语言一套正则,从工具输出里抠错误
Pattern GO_VET_LINE = Pattern.compile("^(.+\\.go):(\\d+):(\\d+):\\s*(.+)$", Pattern.MULTILINE);
问题远不止"代码难看",每一个都致命:
- 依赖本机安装:用户机器上得有对应的工具和运行环境。没有?那得先把环境全部装一遍。Java 还得配好 classpath,否则
javac连依赖都找不到。 - 输出格式五花八门:javac 是纯文本、eslint 是 JSON、go vet 是另一套文本……每种语言都要单独写一套正则解析。
- 配置地狱:为了让工具跑起来,得处理 PATH 增强、Maven classpath 解析、运行时路径配置……为"伺候工具"写的代码比"诊断"本身还多。
- 进程开销:每次诊断都要冷启动一个新进程,动辄几百毫秒。
- 跨平台不一致:Windows 和 Linux 连命令名都可能不一样。
- 跨语言检查割裂:同一种语言可以一次检查多个文件,但跨语言必须每种语言单独检查一次。
实际使用下来,结果并不理想。CLI 方案把"部署问题"换成了"集成问题"——问题没有消失,只是换了个位置。
LSP:全面,但不属于这个场景
CLI 出局后,剩下两名选手:tree-sitter 和 LSP。
LSP 看起来是最"正确"的答案——它太全面了:代码跳转、查看引用、补全、诊断,现代 IDE 开发不同语言,基本上都是内嵌一个对应的 LSP 服务。你看 Claude Code、Codex 这些产品,也设计了类似的工具。集成 LSP,岂不是一步到位,跟 IDE 一样了?
HippoBuddy 中途确实引入过 LSP,利用它的代码跳转、查找引用等能力,设计成了专门的工具。但实际使用中发现了几道过不去的坎:
- 模型并不倾向调用 LSP:这是最致命的一点。实际使用中,大模型很多时候用
grep、glob、read就已经能完成基本的代码调研任务,根本轮不到 LSP 出场。花大力气集成的能力,调用率极低。 - 每种语言一套服务:每一种语言都得下载对应的 LSP 软件和插件,繁琐且复杂。支持的语言越多,要维护的服务就越多。
- 内存开销巨大:LSP 运行时要建立全局索引,一个 LSP 服务就差不多会消耗掉几百 MB 的内存。作为常驻进程,对桌面应用的资源占用是不可接受的。
这里有一个很重要的判断:HippoBuddy 的目标从来不是做成一个 IDE。 IDE 需要的是"人类开发者使用"的完整能力集,而 Agent 需要的是"给自己把关"的轻量校验。LSP 的能力严重超配,还带着超配的代价。于是,LSP 方案被砍掉了。
tree-sitter:最后剩下的选手
三轮淘汰之后,只剩下一名选手:tree-sitter。
tree-sitter 是什么?官网的定义是:一个解析器生成工具(parser generator tool)和增量解析库(incremental parsing library)。它的核心目标,是为编程工具提供一套快速、健壮、通用且易于嵌入的代码解析能力。
用它来干什么?答案很直接:利用它解析代码的能力。
对比 LSP,tree-sitter 轻量了非常多:
- 不维护全局索引,没有常驻服务
- 解析完就出结果,用完即走
- 纯内存操作,毫秒级响应
- 每个语言一个语法定义文件,但加载成本极低
而回到我们要解决的场景——检测大模型在代码编辑场景下出现的低级错误——tree-sitter 恰好就是为此而生的:
| 需求 | tree-sitter 的能力 |
|---|---|
| 括号不匹配 | 解析树结构错误检测 ✅ |
| 缺少分号 | 解析树 MISSING 节点检测 ✅ |
| 结构非法 | ERROR 节点检测 ✅ |
它抓的是"代码能不能被解析"——结构错误。至于类型不匹配、未定义变量这类语义错误,本就不该由它负责,那是模型自审 + 运行测试兜底的领域。
方向,就这么定了。
方向定了,那集成呢?
选型到此结束,但故事才刚刚开始——tree-sitter 的核心库是用 C 写的,而 HippoBuddy 的后端是 Java。
难道又要回到 JNI 那条被搁置的老路吗?又要为每一种语言去编译原生库、为每一个平台去适配 .dll/.so 吗?
这一问,引出了另一个技术:WASM。它如何让 Java 优雅地调用 C 写的解析器、中间又踩过哪些坑,是另一篇文章的主题——也就是本系列的第二篇:《从适配到定义:HippoBuddy 的 WASM 演进之路》。