Working With AI: A concrete example
AI 深度解读
Working With AI: A Concrete Example
背景
Carson Gross 对 AI 持有一种矛盾的态度。不可否认,在过去一年中,AI 已成为开发领域极其强大的工具;但与此同时,它也带来了诸多隐患,既包括个人层面(如智力逐渐钝化),也包括集体层面(如环境担忧、个人计算成本日益增加等)。
在之前的文章《Code is Cheap(er)》中,Gross 曾警告过“魔法师的学徒”(The Sorcerer’s Apprentice)问题:开发者过度依赖 AI,导致无法理解并妥善解决自己在构建系统中遇到的突发问题。
本文旨在通过 Gross 在维护 hyperscript 项目时与 AI 的一次具体交互,展示 AI 在软件开发中的优势与劣势,并特别演示他是如何 narrowly avoided(勉强避免)陷入“魔法师的学徒”陷阱的。
Hyperscript 简介 Hyperscript 是一种用于 Web 的替代性解释型脚本语言。讽刺的是,它完全由 JavaScript 编写。这是一个奇特的软件项目:Gross 在编写解析器时故意打破了许多解析规则,作为一种实验,以观察其效果。例如:
- 解析逻辑与解析元素共存(colocated)。
- 解析器是可插拔的,语法是动态定义的。
- 支持多种属性访问语法。
虽然这种架构并不推荐用于大多数编程语言,但在 hyperscript 项目中运作良好。这再次证明了软件工程中“条条大路通罗马”。
核心内容
故事始于用户报告在升级到 0.9.91 版本后出现了一个回归错误(regression)。以下表达式无法正确解析:
fetch `{% url 'trade:get_symbol_data' %}?symbol=${symbol}` as JSON
问题现象
as JSON 的绑定过于紧密,导致解析器试图在将字符串传递给 fetch 之前将其转换为 JSON,而不是像用户预期(以及之前版本那样)那样:获取给定的 URL,并将结果视为 JSON。
这种绑定冲突是解析中的经典问题。由于 hyperscript 是一种类 xTalk 语言,继承了英语中的许多歧义性,这个问题变得更加复杂。
调查原因(AI 的优势领域)
Gross 通常会在调查阶段依赖 AI 协助。他使用 Claude,Claude 出色地找到了根本原因:在 0.9.91 版本中,Gross 在重构 go 命令以复用/共享 fetch 命令的逻辑时过于激进。
他提取了一个通用方法 parseURLOrExpression()供这两个命令使用,但在此过程中,他无意中扩大了 fetch 命令后的语法范围,使其包含了一般表达式。
这里存在一个关键字冲突:
- 在表达式中,
as是一个类型转换关键字(例如:set x to "42" as Int)。 - 在
fetch命令中,as是一个修饰符,告诉fetch如何转换响应(例如:fetch https://hyperscript.org as Text)。
问题的核心在于,在重构过程中,解析器在 fetch 关键字后解析了一个表达式,导致 as 被解析器当作表达式的一部分消耗掉,而不是作为 fetch 的修饰符。借助 Claude,Gross 在几分钟内就搞清楚了这一点,这比他自己摸索要快得多。
修复问题(AI 的劣势领域) 在寻找原因时 AI 很有帮助,但在提出修复方案时,AI 表现较弱。Gross 承认自己当时有些偷懒,直接让 AI 提供解决方案,但他认为这一过程具有警示意义。
提议修复方案 1:一个 Hack Claude 的第一个建议是优先解析所谓的“类字符串”叶节点,然后回退到完整表达式:
return this.parseElement("stringLike") || this.requireElement("expression");
虽然这解决了用户报告的即时问题,但它非常特定于该 bug,无法解决一般情况(例如当变量作为 fetch 的目标时:fetch $url as JSON)。Gross 拒绝了该方案,因为它太 hacky 且不够通用。
提议修复方案 2:更好但不必要的复杂性
第二个提议更具趣味性:在解析器上添加一个 noConversions 标志,在解析 URL 时设置该标志,并让 AsExpression.parse 在检测到该标志时退出:
// AsExpression.parse()
if (parser.noConversions) return;
这会让许多解析器工程师感到惊恐,因为它使 hyperscript 解析器变得上下文敏感(context-sensitive)。Gross 认为这是好事,因为 hyperscript 解析器已经是上下文敏感的。
在审视此方案时,Gross 意识到他们已经有了现成的、用于实现此目的的 hacky 上下文敏感基础设施,而 Claude 错过了它。
Hyperscript 解析器中的 “Follows” 机制 Hyperscript 解析器有一个“follows”(跟随)的概念,即被“上层”解析元素声明为跟随标记的令牌。 Hyperscript 解析器是一种(有些奇怪的)递归下降解析器。这允许一个解析元素(通常是命令)“占用”一个关键字,从而在解析表达式时避免匹配它们。
例如,when 特性使用 or 作为分隔符,而不是作为逻辑连接符:
<div _="when $x or $y changes put it into me"></div>
Gross 意识到可以利用这一特性:与其向解析器添加新标志,不如将 as 压入“follows”堆栈,然后解析表达式,最后将 as 从“follows”中弹出。这将阻止 AsExpression 进行解析,同时允许大多数通用表达式(如变量)正常工作。
提议修复方案 3:接近成功,但差一点
Gross 向 Claude 指出了这一点。Claude 兴奋地表示“完全正确!”,并开始使用此技术修复 bug。Claude 在 parseURLOrExpression() 中添加了正确的代码,普遍地修复了问题,且未添加额外的解析器基础设施。
最终的、半有机的修复
然而,在审查更改时,Gross 意识到新的修复方案过于宽泛:fetch 和 go 共享此方法,但只有 fetch 使用 as 来指示修饰符。现有的修复方案也阻止了 go 命令中完全有效的 as 类型转换表达式的使用。
因此,Gross 自己实施了最终修复,仅在 FetchCommand#parse() 中:
parser.pushFollow("as");
try {
var url = parser.parseURLOrExpression();
} finally {
parser.popFollow();
}
if (parser.matchToken("as")) {
...
这样,特殊情况仅局限于 fetch 命令,go 的解析不受影响。这就是他最终的修复方案。
测试 在此过程中,Gross 让 Claude 为各种情况生成测试用例。hyperscript 已有良好的现有测试套件,Claude 很好地创建了小型、专注的测试,展示了问题所在以及修复是否有效。这是 AI 表现良好的另一个领域。
关键要点
- AI 擅长调查与测试生成:在定位根本原因(Root Cause Analysis)和生成针对性测试用例方面,AI(如 Claude)表现出色,能显著加快排查速度。
- AI 在提出“干净”解决方案方面较弱:AI 提出的修复方案往往倾向于特定于当前 bug 的“Hack”或引入不必要的复杂性(如添加全局标志),缺乏对整体架构的深刻理解。
- “魔法师的学徒”风险真实存在:如果开发者不熟悉底层系统(如 hyperscript 解析器的内部机制),盲目采纳 AI 提供的通用修复方案,可能会引入技术债务(如增加解析器的状态复杂性或制造新的边缘情况)。
- 领域知识至关重要:Gross 之所以能纠正 AI 的错误并提出最终的正确方案,是因为他熟悉 hyperscript 解析器的“follows”机制。如果没有这些背景知识,他可能会接受一个次优甚至有害的修复。
- AI 是辅助工具,而非替代者:AI 可以加速过程,但最终的架构决策和代码审查仍需由具备深厚领域知识的人类开发者完成,以确保代码的整洁
