与 AI 协作:一个具体案例

</> htmx - high power tools for html 2026-10-27T03:55:44.748728

我对 AI 的态度一直比较矛盾。毫无疑问,过去一年里它已经成为非常强大的开发工具,但同时也带来不少风险:对个人来说,比如思维会慢慢变钝;对群体来说,比如环境压力、个人计算成本越来越高,等等。在《Code is Cheap(er)》一文中,我提到过「魔法师的学徒」问题:开发者一旦依赖 AI,就可能无法理解、也无法妥善处理自己正在构建的系统里出现的问题。本文我想复盘一次维护 hyperscript 时与 AI 的具体交互,既展示 AI 总体上的长处和短板,也具体说明「魔法师的学徒」问题——我这次差点就栽了进去。

Hyperscript 的解析器

先交代一点背景:hyperscript 是面向 Web 的另一种解释型脚本语言。讽刺的是,它完全用 JavaScript 写成。这个软件有点古怪:我写它的时候故意打破了很多解析规则,把它当作一场实验,看看最后会变成什么样。举几个例子:

我不建议大多数编程语言采用这种做法,但在这个项目上效果还不错。这也再次说明,做软件确实不止一种办法。

一个 Bug 报告

故事从一位用户报告回归问题开始:升级到 0.9.91 版本后,下面这个表达式无法正常解析了:

fetch `{% url 'trade:get_symbol_data' %}?symbol=${symbol}` as JSON

具体来说,as JSON 绑定得太紧,在字符串字面量交给 fetch 之前就试图把它转成 JSON;而用户期望的是(之前的行为也是)请求给定 URL,并把返回结果当作 JSON 处理。这类绑定冲突是解析里的经典问题。因为 hyperscript 是一门 xTalk 风格的语言,而且

接下来要调查问题的根源。首先要弄清楚为什么会出现这个回归。这种时候,我通常会依赖 AI 帮忙。我用的是 Claude,它表现相当出色,找到了问题的根源:在 0.9.91 版本中,我在重构 go 命令、想让它和 fetch 命令共用逻辑时,下手太狠了。我提取了一个公共方法 parseURLOrExpression() 给这两个命令使用,但这么一改,我无意中把 fetch 命令后面的语法扩展了,让它也能接受通用的 expression(也就是表达式)。

as 这个关键字在表达式里有特殊含义:它是一种转换表达式,可以在不同类型之间转换:

set x to "42" as Int

但 as 同时也是 fetch 命令的修饰符,用来告诉它如何转换响应:

fetch https://hyperscript.org as Text

(也许这个事实会让你有点想吐。没关系。)

问题的关键就在于:重构时,我无意中让解析器在 fetch 关键字之后去解析一个表达式,结果它把 as 当成了表达式的一部分消耗掉,而不是让它作为 fetch 的修饰符。在 Claude 的帮助下,我几分钟就搞清楚了,比自己从头查快多了。

修复问题

AI 在找问题根源上帮了大忙。但真要修复问题时,它的表现就差多了。我得承认,我当时有点懒,直接让 AI 给解决方案,所以再抱怨这些方案不好,确实有点……懒。但我还是觉得整个过程挺有启发性,下面详细说说到底发生了什么。

方案一:打补丁

它给出的第一个建议是:先解析所谓的“类字符串”(string-like)叶子节点,

然后回退到完整表达式:return this.parseElement("stringLike") || this.requireElement("expression"); 这个修复能解决用户当前遇到的直接问题,但它太针对所报告的 bug,无法处理一般情况,比如有人用变量作为 fetch 的目标:fetch $url as JSON。我拒绝了这个方案,理由是:太 hacky,不够通用。(注意,hyperscript 解析器里本来就有一堆自然形成的 hack,所以这话可能算是五十步笑百步。)

修复方案 2:更好,但不必要的复杂度

第二个方案更有意思:在解析器上加一个 noConversions 标志,在解析 URL 时把它设上,让 AsExpression.parse 在标志开启时直接返回:

// AsExpression.parse()

if (parser.noConversions) return;

这会让很多解析器工程师震惊,因为它让 hyperscript 解析器变成了上下文敏感的。很好——hyperscript 解析器本来就是上下文敏感的。我看了一眼这个方案,想了想,意识到我们已经有现成的 hacky 上下文敏感基础设施,根本不需要给解析器加新标志,只是 Claude 没注意到而已。

hyperscript 解析器中的“follows”

在 hyperscript 解析器里,有一个“follows”的概念:某些 token 被“更上层”的解析元素声明为跟随标记(follow token)。hyperscript 解析器是一个(有点奇怪的)递归下降解析器,这个机制允许解析元素(通常是命令)“认领”某个关键字,解析表达式时就不会去匹配它们。

举个例子,when 特性在声明中把 or 当作分隔符使用,而不是逻辑连接词:

<div _="when $x or $y changes put it into me"></div>

(我能听到很多解析器工程师愤怒地关掉这个窗口了。很好。)

事实证明,这个机制正好可以实现我们想要的效果:不用给解析器加新标志——

我们可以把它压入 follow 集合,然后解析表达式,再把它弹出 follow 集合。这样就能阻止 AsExpression 被解析,同时仍然允许大多数普通表达式(比如变量)正常工作。

修复方案三:很接近,但还差一点

我把这个想法告诉了 Claude,它兴奋地说我“完全正确!”,然后开始用这个思路修复 bug。Claude 把正确的代码加进了 parseURLOrExpression(),从通用层面解决了问题,没有额外增加解析器基础设施。看起来可以收工了。

最终版:半自动修复

不过,在审查这次改动时,我发现新修复的范围太宽了:fetch 和 go 共用这个方法,但只有 fetch 用 as 来表示修饰符。现有的修复把 go 命令里完全合法的 as 转换表达式也一并阻止了。

于是我自己动手做了最终修复,改在 FetchCommand#parse() 里:

parser.pushFollow("as");
try {
    var url = parser.parseURLOrExpression();
} finally {
    parser.popFollow();
}
if (parser.matchToken("as")) {
    ...

这样就把特殊处理的范围收窄到只有 fetch 命令,go 的解析不受影响。这才是这个 bug 的最终答案。

测试

在这个过程中,我让 Claude 为各种情况生成了一些测试。hyperscript 本来就有一套不错的测试套件,Claude 也很擅长写小而聚焦的测试,既能复现问题,也能验证修复是否生效。这又是 AI 表现不错的一个方面。

故事的启示

好了,这个平平无奇的 bug 修复故事有什么意思呢?我觉得有意思的是:AI 在调查和写测试方面表现很好,但在提出干净解决方案方面却不怎么样。如果我不熟悉 hyperscript 的解析器和它的基础设施,这个修复很容易给项目留下技术债:又一个拼凑出来的解析边界情况,又一点解析器上的状态,等等。我虽然没有证据[^dream],但我敢说技术债是指数级增长的,因此非常……

在项目中尽量把它降到最低很重要。这个故事说明,让人类参与其中、与 AI 代理协作并理解底层基础设施,比放任 AI 自行其是,能更有效地控制复杂度。

有些人看到 hyperscript 的代码库,可能会对“考虑过控制复杂度”这种说法嗤之以鼻。我理解这种看法。不过在这个例子里,我们能看到一个具体场景:一个懂行的人与 AI 代理合作,在修复一个确实挺尴尬的 bug 时,至少在一定程度上约束了复杂度。

这种情况里,我没有像魔法师的学徒那样盲目接受 AI 提出的方案,而是像魔法师一样(希望这么说不会显得太自大!),要求它给出一个更符合现有代码库架构的正确解法。我理解问题,看到了正确的方向,能和 AI 一起把它实现,然后借助 AI 生成的测试来验证。

我希望这能和眼下某些被鼓吹的“氛围编程”形成鲜明对比——那些开发者(或者随便怎么称呼他们)似乎以搞不懂实际发生了什么为荣。

旁白:AI 与年长开发者

回顾这段经历时,我还想到另一件事。我年纪不小了,今年 50 岁。开发者年纪渐长,现实就是往往会“丢掉快球”,至少在一定程度上如此。对我来说,这实际意味着两件事:

  1. 记忆力不如从前;
  2. 工作时间不如从前长。

而 AI 恰好能直接解决这两个问题。

记忆力方面,虽然我记不住以前能记住的所有东西,但只要给点合适的提示,我就能很快重新理解。AI 在这方面很擅长帮我,让我在开源项目和工作项目之间切换的效率高得多。

工作时间方面,AI 能持续高强度地运转,这种劲头即使在我年轻时,我也很难做到。

……这些内容我一度很难跟上。也就是说,比如我的项目现在可以有一套比以往全面得多的测试套件。看看 Claude 这次生成的测试,比我靠自己那点精力大概能写出来的要全面不少。所以,AI 补上了我作为一名年长开发者逐渐形成的两个(相对而言的)短板。

可另一方面,我也很担心,它正在让我整体的思维能力出现更普遍的退化。这种事随着年龄增长本来就会发生,但依赖 AI 可能会加速它。我得承认,回头看这段经历,在亲自动手把该做的事做对之前,我依赖 Claude 的时间实在太长了,这让我有点羞愧。这方面我自己也还在摸索。

结论

我想把这一连串交互写下来,是因为我觉得它既体现了 AI 辅助编程好的一面,也体现了坏的一面。它说明了一位能力尚可的开发者留在流程中、与 AI 智能体协作的价值,也暴露了一种危险:不加判断就接受 AI 智能体针对某个问题给出的第一个(或第二个)方案。希望你在形成自己对 AI 智能体的看法和策略时,这篇文章能派上用场。

-- [^dream]: 这是我在梦里得到的启示。

在这篇文章里,Carson Gross 详细讲述了一个具体的 hyperscript bug 修复过程,用来说明 AI 在哪些地方帮上了忙、在哪些地方掉了链子,以及为什么让一位懂行的人留在流程中,才是把复杂度控制住的关键。

查看原文