先写 Linter 和工具,再写代码

HN Code LLM Research 2026-09-17T06:44:23.189949

为什么必须让 linter 和工具先就位,再交给不确定性极强的 AI 智能体(agent)去干那些苦力活。

概述

语言模型(LLM)的本质是逐个采样 token。同一个提示词跑两遍,可能产出两份不同的文档,而且差别往往不只是措辞。差异会体现在结构上:跳了一级标题、代码块没写语言、该用直引号的地方出现了弯引号、frontmatter 字段顺序排错。我们总把这种波动当成提示词工程(prompt engineering)的问题,其实不是。它就是模型的固有运行状态,真正站得住脚的工作流,都是围绕这个前提搭起来的。

更有价值的做法,是别再让模型自己凭空造结构,而是给它一个现成的结构去填。这个结构必须是可执行的:一个解析器(parser)、一个 linter、一个 schema 校验器、一个构建器。先把这些写出来,再让智能体去干苦力活。

不确定性智能体

LLM 是个非常好的局部写手,却是个非常糟糕的全局记账员。你让它写一段话,它能写对。但要它在上千行文档里始终守住某个不变量,它就做不到——因为它没有持久化的程序状态,也没有「文件剩下部分」这个概念。它唯一的内存就是上下文窗口,而这个窗口里的注意力分布是不均匀的:开头和结尾的指令权重最高,中间部分会逐渐衰减。

调低 temperature 能减小波动,但永远消不掉。同一个模型、同一个提示词,它有时还是会给你一段无序列表,而你要的是段落;或者甩出一个 #### 标题,而渲染器只认 ###

这些都不是通常意义上的「幻觉」。它们只是一个约束不足的问题所给出的合理续写。模型回答的是你实际问的那个问题——「给我点像样的东西」,而不是「给我这个精确的结构」。

生成出来的文档就烂在这个缺口上:内容没问题,外壳错了。靠反复追问、把提示词写得更狠是修不好的。正确的做法,是让这个外壳能被机器校验,并且在结果通过验证之前一直报错。

Linter 即可执行的架构

风格指南只是建议,Linter 才是约束。只停留在文字描述里的架构,模型随时可能偏离——因为 prompt 里的文字属于上下文,而上下文只是建议。Linter 则本质不同:它会解析产物、套用规则集、输出带行号的错误,并以非零状态码退出。

这就是它全部的价值。Linter 把「这份文档好不好」变成一个确定性的判断标准(oracle):只有通过和不通过两种结果,外加一份精确的原因清单。非确定性系统可以针对确定性的判断标准来优化,却没法针对品味和风格来优化。

对于任何目标格式,至少要把下面这些内容写成规则:

你写的每一条规则,都是模型在生成过程中不必再做的决定。

决策与苦力活儿

可以把一次生成想象成在决策树上行走。每个决策是一个分叉,非确定性系统在任何一处都可能走错。错误概率会随决策数量累加,这正是长文档质量下滑的原因——分叉变多了,而模型每次都要从上下文重新推导,而不是按固定计划走。

解决办法是把决策从模型里搬出来,交给工具。Linter 管结构决策,schema(模式定义)管数据决策,formatter(格式化工具)管呈现决策,builder(构建工具)管字节层面的决策。

留给模型的,只剩苦力活儿:描述一个概念、总结一份资料、填写某个章节、把规格说明转成文字。

而这正是 agent(智能代理)擅长的领域:量大、分叉少、可验证。

你把越多架构规则写进 linter 和工具里,干活就越像机械劳动,而产出反而越好。整篇文章的主张,就这一句话。

Markdown 格式的典型案例

Markdown 是最糟的情况,因为它天生就很宽松。它没有统一的语法,每个渲染器各实现一套方言,几乎任何字节序列都能解析出点东西来。AI 智能体(agent)并非有意,却总会踩进这种歧义里。

网站要求只有一个 h2 标题,它却输出 h1;标题层级从 h2 直接跳到 h4;围栏代码块不标明语言;用 Unicode 制表符画表格,而不是用真正的 Markdown 表格;生成的印刷体引号和破折号,严格流水线不接受;还会把必需的章节和 frontmatter 整个漏掉。

这些问题在下游出故障之前都看不出来。一个强力的 linter 能立刻让它们现形,而一条好的 linter 报错,比任何风格指南都更像有效的提示词,因为它会点明具体哪一行、违反了哪条规则。

举个例子,本站背后的工具链对每篇文章都强制一套不大但也不算简单的规则:

你正在读的这篇文章就是按这些规则写的,也通过了这个 linter 的检查。这不是口味和风格的小事,而是把架构与结构编码进了文档格式本身。

这些约束塑造了能生成什么,也让结果比自由发挥式的生成更加一致。

这个 linter 只有一条命令,可以按单篇文档运行,也可以按文件夹批量运行,取决于大语言模型(LLM)智能体是要生成一篇还是一批文档:

go run toolchain/lint.go ../weblog/public/weblog/articles/write-linters-and-tools-before-code.md;
go run toolchain/lint.go ../weblog/public/wiki/articles/first-chapter/*.md;

它要么以 0 退出,要么把每处违规连同行号打印出来,再以 1 退出。没有中间状态,模型也没法绕开这套规则。

二进制格式需要模式

Markdown 很宽容,Office 格式则不然。

一个 docx、xlsx 或 pptx 文件,本质上是个 zip 包,里面是一棵 Open Packaging Conventions(开放打包约定)的 OOXML 部件树,每个部件都要对照 XSD 模式校验。PDF 是一张带交叉引用表的编号对象图。CSV 有方言和列约定。JSON 或 YAML 文档则有一份模式——希望你已经把它写进了解析器或 linter 里。

如果放任模型直接输出原始字节,得到的产物往往“看起来”足够合理——统计上说得通——但一碰到真正的应用打开它就会立刻失败。第一道防线是模式校验器,配合往返检查。

还有第二种工具,比校验器、解析器和构建器都更重要。

根本不要让模型写出字节,并且强制使用同一个解析器——渲染和 lint 都用它。

让 LLM agent 通过 API 调用输出结构化数据,再由确定性工具把它转换成最终产物。模型负责选值,工具负责序列化。这样就消除了一整类故障,因为序列化恰恰是非确定性系统最不擅长、而库最擅长的事情。

同样的纪律也适用于字节本身。这些排版格式天生就很混乱。元素顺序可以任意,命名空间和前缀可有可无,空白可以随意替换,同一份文档可以有几十种等价序列化方式。单靠模式无法捕捉这些,因为混乱出在布局上,而不是词汇表里。

你需要的是一个词法分析器(lexer)和一个语法分析器(parser),把字节流转成语法树;再加一个 linter,用一套高级规则遍历同一棵树。那个负责读取格式的工具,同时也成了判断格式是否合规的工具。在 Forooxml 项目里,我就是在 godocx 之上造了这样一个 linter——复用它的 parser,而不是另写一套。

正是这套规则集,把乱七八糟的排版强行拉回预期结构。

一套实用的工作流

操作的顺序,比任何单个工具都重要:

  1. 把格式定义成语法、schema 和一组不变量。
  2. 写一个 parser 和一个 linter,报错要带行号,出错时以非零状态退出。
  3. 凡是能自动规范化的地方,都配上确定性的修复器。
  4. 给二进制格式写一套构建器(builder),让模型调用的是 API,而不是直接拼字节。
  5. 让 agent 去干苦力活,然后循环 lint、修复,直到错误归零。
  6. 在仓库层面用 linter 设卡,这样契约就没法被悄悄削弱。

前四步是架构活,后两步才见收益。先把这些关卡搭起来,感觉上是更慢——确实慢,直到某天 agent 一夜之间产出两百份文档,而且每一份在结构上都是合法的。

这个循环,故意做得很无聊:

while true; do
    agent write --out article.md;
    go run toolchain/lint.go article.md && break;
    agent revise article.md < lint.log;
done;

整个循环里,没有任何一步依赖模型记住某条规则。规则活在 linter 里,而 linter 是唯一说了算的权威。

Linter 和工具,就是把审美转化成规范、再把规范转化成裁判的手段。对确定性软件来说,这是个不错的性质;对非确定性的 agent 来说,这是唯一能规模化的做法,因为模型的方差永远不会消失,它只会随上下文漂移。把架构编码下来,方差就会落在能被抓住的地方。

参考资料

查看原文