开发者用 Claude Code 完成编程语言全链路实现,展示 AI 辅助编程在复杂项目中的实际能力。
在一月和二月的四周里,我使用 Claude Code 构建了一个新的编程语言。我以我的猫的名字将其命名为 Cutlet。这完全没有问题。你可以在 GitHub 上找到源代码,以及构建说明和示例程序。
自从 2021 年 GitHub Copilot 发布以来,我一直在使用 LLM 辅助编程,但到目前为止,我的 LLM 使用仅限于生成样板代码和对项目进行特定的、有针对性的改动。然而,在开发 Cutlet 时,我让 Claude 生成了每一行代码。我甚至没有阅读任何代码。相反,我构建了护栏来确保它能正确工作(稍后会详细介绍)。
我对这个实验的结果感到惊讶。Cutlet 现在存在。它可以在 macOS 和 Linux 上构建和运行。它可以执行真实的程序。其内部可能隐藏着一些 bug,但它们可能不会比世界上其他任何四周大的编程语言中发现的问题更糟。
关于这一切及其对我的职业生涯的意义,我有很多想法,但在我发表意见之前,我想先带你了解一下这种语言。
如果你想跟随,从源代码构建 Cutlet 解释器并使用 /path/to/cutlet repl 进入 REPL。
数组和字符串的工作方式与任何动态语言中的一样。变量使用 my 关键字声明。
cutlet> my cities = ["Tokyo", "Paris", "New York", "London", "Sydney"]
=> [Tokyo, Paris, New York, London, Sydney]
变量名可以包含破折号。与 Raku 的语法规则相同。(到目前为止)唯一的数字类型是 double。
cutlet> my temps-c = [28, 22, 31, 18, 15]
=> [28, 22, 31, 18, 15]
这里有一些很酷的东西:@ 元操作符将任何常规二元操作符变成对数组的矢量化操作。在下一行中,我们将 temps-c 的每个元素乘以 1.8,然后将 32 加到结果数组的每个元素上。
cutlet> my temps-f = (temps-c @* 1.8) @+ 32
=> [82.4, 71.6, 87.8, 64.4, 59]
@: 操作符是一个 zip 操作。它将两个数组压缩成一个 map。
cutlet> my cities-to-temps = cities @: temps-f
=> {Tokyo: 82.4, Paris: 71.6, New York: 87.8, London: 64.4, Sydney: 59}
使用内置的 say 函数输出文本。这个函数返回 nothing,这是 Cutlet 的 null 版本。
cutlet> say(cities-to-temps)
{Tokyo: 82.4, Paris: 71.6, New York: 87.8, London: 64.4, Sydney: 59}
=> nothing
@ 元操作符也适用于比较。
cutlet> my greater-than-seventy-five = temps-f @> 75
=> [true, false, true, false, false]
这里有另一个很酷的东西:你可以使用布尔数组索引数组。这是一个过滤操作。它选择与 true 对应的元素索引,并丢弃对应于 false 的元素。
cutlet> cities[greater-than-seventy-five]
=> [Tokyo, New York]
这是一个更简洁的写法。
cutlet> cities[temps-f @> 75]
=> [Tokyo, New York]
让我们用用户友好的消息打印出来。++ 操作符连接字符串和数组。str 内置函数将事物转换为字符串。
cutlet> say("Pack light for: " ++ str(cities[temps-f @> 75]))
Pack light for: [Tokyo, New York]
=> nothing
前缀位置的 @ 元操作符充当 reduce 操作。
cutlet> my total-temp = @+ temps-c
=> 114
让我们找到平均温度。@+ 将所有温度相加,len() 内置函数找到数组的长度。
cutlet> (@+ temps-c) / len(temps-c)
=> 22.8
让我们也以漂亮的方式打印出来。
cutlet> say("Average: " ++ str((@+ temps-c) / len(temps-c)) ++ "°C")
Average: 22.8°C
=> nothing
函数使用 fn 声明。Cutlet 中的一切都是表达式,包括函数和条件语句。函数中表达式产生的最后一个值成为其返回值。
cutlet> fn max(a, b) is
... if a > b then a else b
... end
=> <fn max>
你自己的函数也可以使用 @ 工作。让我们用我们的 max 函数 reduce 温度以找到最高温度。
cutlet> my hottest = @max temps-c
=> 31
Cutlet 可以做得更多。它具有你从动态语言中期望的所有常用功能:循环、对象、原型继承、mixins、标记清除垃圾回收器和友好的 REPL。我们还没有文件 I/O,一些基本的构造如错误处理仍然缺失,但我们正在努力!
查看 git 仓库中的 TUTORIAL.md 获取完整文档。
我是前端工程师和(偶尔的)设计师。我试过使用 LLM 来构建网络应用程序,但我总是遇到限制。
根据我的经验,Claude 和类似的工具在编写复杂业务逻辑方面非常出色,但在任何需要视觉设计技能的任务上表现不佳。
事实证明,用英文描述响应式布局和动画并不容易。任何数量的屏幕截图和线框图都无法向 LLM 传达流体布局和动画。我浪费了数小时与 Claude 争论布局问题,它发誓已经修复了,但我仍然能通过我凡人的眼睛清楚地看到问题。
我还发现这些工具擅长生成他们在公开可用的代码库中之前看过的千篇一律的界面,但当我想做任何新颖的东西时,它们就无法胜任。我经常与客户合作为利基领域构建复杂的数据可视化,而 LLM 在这些项目上完全无法产生有用的输出。
另一方面,我在过去几个月里看到人们用 LLM 完成了令人难以置信的事情,我想自己复制那些实验。但我之前与 LLM 的经历表明,我必须小心选择我的项目。
我不想解决一个特别新颖的问题,但我希望有能力有时将 LLM 引导到有趣的方向。
我不想手动验证 LLM 生成的代码。我想给 LLM 规范、测试用例、文档和示例输出,并让它做所有困难的工作来确定它是否在做正确的事情。
我想给智能体一个强大的反馈循环,使其能够自主运行。
我不喜欢 MCP。我不想处理它们。所以任何需要连接到浏览器、获取屏幕截图或通过网络与 API 通信的东西都会自动被排除。
我想使用一种简洁的语言,尽可能少的外部依赖。
一个小的、动态的编程语言满足了我所有的要求。
LLM 知道如何构建语言实现,因为它们的训练数据包含数千个现有实现、论文和计算机科学书籍。我被创建一个"混合"语言的想法所吸引,通过从各种现有语言中挑选和选择我喜欢的功能来实现。
我可以编写一堆小的确定性程序及其预期输出来测试实现。我甚至可以让 Claude 为我编写它们,给我一个可能无限数量的测试用例来验证语言是否正常工作。
语言实现可以从命令行进行测试,具有纯文本输入和输出。无需获取屏幕截图、视频或设置脆弱的 MCP。对于智能体来说,没有比"运行 make test 和 make check 直到没有更多错误"更好的反馈循环。
C 是最简洁的,有大量用 C 构建的语言实现。
最后,这也是一个实验,以找出我能将 AI 智能体工程推进多远。我能否将六个月的工作压缩成几周?我能否构建一些超越我自己能力的东西?如果我全力投入 LLM 驱动的编程,我的日常工作生活会是什么样?我想回答所有这些问题。
我带着一些怀疑进行了这个实验。我之前完全使用 Claude Code 构建某些东西的尝试没有成功。但这次尝试不仅成功了,而且产生了超出我想象的可能性的结果。我不相信未来所有软件都将由 LLM 编写。但我相信有一大部分可以部分或大部分外包给这些新工具。
构建 Cutlet 教了我一些重要的东西:使用 LLM 生成代码并不意味着你忘记了关于构建软件的一切。AI 智能体工程需要仔细规划、技能、手工技艺和纪律,就像生成式 AI 之前值得构建的任何软件一样。与编码智能体合作所需的技能可能看起来与逐行将代码输入编辑器不同,但它们仍然是我们在整个职业生涯中一直在磨练的相同工程技能。
获得 LLM 的良好输出涉及大量工作。AI 智能体工程并不意味着将模糊的指令转储到聊天框中并收获生成的代码。
我认为,要想在今天高效地与编程智能体协作,你必须掌握四项主要技能:
理解哪些问题可以使用 LLM 有效解决,哪些问题需要人类参与其中,哪些问题应该完全由人类处理。
清晰地传达你的意图,并定义成功标准。
创建一个能让 LLM 发挥最佳能力的环境。
监控并优化智能体循环,使智能体能够高效工作。
模型和智能体框架正在迅速变化,因此,要弄清楚 LLM 擅长解决哪些问题,你需要培养自己的直觉、与同行交流,并持续关注行业动向。
不过,如果你不想时刻追踪这个飞速变化的领域——我完全不会因此评判你,外面的变化实在太疯狂了——可以问自己以下两个问题,判断你的问题是否适合交给 LLM:
对于你想解决的问题,能否以自动化方式定义并验证成功标准?
以前是否有人解决过这个问题或类似问题?换句话说,你的问题是否很可能存在于 LLM 的训练数据中?
如果其中任何一个问题的答案是“否”,那么把 AI 用在这个问题上不太可能取得好的结果。如果两个问题的答案都是“是”,那么你或许能通过智能体工程获得成功。
好消息是,弄清楚这一点的成本,只是一份 Claude Code 订阅,以及团队里一位愿意牺牲一个月、在你的代码库上试用它的“小白鼠”。
LLM 使用自然语言工作,因此,学会用文字表达自己的想法已经变得至关重要。如果你无法以书面形式向同事解释自己的想法,也就无法高效地与编程智能体协作。
即使只使用简单、模糊、过度宽泛的提示词,你也能从 Claude Code 中获得很多价值。但这样做时,你其实是把大量思考和决策工作外包给了机器人。对于用完即弃的项目来说,这没什么问题;但如果你正在构建一个准备投入生产并维护多年的东西,可能就需要更加谨慎。
你应该向编程智能体提供措辞精准的规格说明,尽可能完整地覆盖问题空间。在开发 Cutlet 的过程中,我的大部分时间都花在编写、生成、阅读和修正规格文档上。
对我来说,这是一种全新的体验。我主要与早期创业公司合作,因此在职业生涯的大部分时间里,我都把代码本身当作规格说明。编写正式的规格说明让我感到非常陌生。
幸运的是,我可以依靠 Claude 帮助我编写其中的大部分规格说明。我之所以愿意这么做,只是因为 Cutlet 是一个实验。如果是一个我要拿自己的声誉作担保的项目,我可能会彻底把智能体排除在这个环节之外,亲自编写规格说明。
在对 Cutlet 进行任何修改时,我通常采用以下工作流程:
首先,我会向 LLM 提出一个新功能(例如循环)或一次重构(例如从树遍历解释器迁移到字节码虚拟机)。然后,我会与它讨论这项变更在 Cutlet 的语境中应该如何运作、其他语言是如何实现它的、有哪些设计上的考量,以及能否从一些有趣或小众的语言中借鉴创意,等等。这只是随意的来回交流,就像你与同事讨论一样。
当我对这项功能或变更的大致形态有了充分认识后,我会让 LLM 给出一份拆分成若干小步骤的实施计划。
我会审查这份计划,并继续与 LLM 反复讨论,对它进行完善。我们会探索各种边界情况、容易踩坑的设计、陷阱、遗漏之处和改进空间。
当我对计划感到满意后,会让 LLM 把它写入一个文件,并放进 plans/doing/ 目录。有时,一个功能最终会产生 3~4 个计划文件。这是有意为之的:我需要确保计划便于人类阅读,也需要让每份计划都成为一个原子单元,以便在结果不理想时单独回滚。它们同时也记录了项目的演进历史。你可以在 Cutlet 仓库中找到所有历史计划文件。
我会阅读并审查生成的计划文件,再次与 LLM 反复讨论并修改它;等一切看起来都没问题后,再提交这个文件。
最后,我会启动一个 Docker 容器,运行拥有全部权限(包括 sudo 权限)的 Claude,然后让它实施我的计划。
这种工作流程把每次修改语言所需的认知工作都前置了。所有思考都发生在写下第一行代码之前,而我平时几乎从不这样做。对我而言,编程意味着在实际工作过程中自然地探索问题的形态。然而,我发现这种工作方式很难与 LLM 配合。它们非常擅长对代码库进行大范围修改,却非常不擅长快速、迭代式、自然演进的开发流程。
随着推理速度变快、模型能力提升,我的工作流程或许也会继续演进;但在此之前,这种瀑布式模型最适合我。
我认为,这是与编程智能体协作过程中最有趣、最好玩的部分。这是一类全新的待解问题!
核心原则是:编程智能体是计算机程序,因此对自身所处世界的认知范围有限。它们了解你所要解决问题的唯一窗口,就是它们能够访问的代码目录。这无法为它们提供足够的自主行动能力和信息,使它们把工作做好。因此,为了帮助它们充分发挥能力,你必须以工具的形式赋予它们这种行动能力和信息,让它们能够借助这些工具接触更广阔的外部世界。
这在实践中意味着什么?不同项目的做法各不相同,下面是我为 Cutlet 所做的事情:
全面的测试套件。 我的项目指令要求 Claude 编写测试,并在编写任何新代码之前确保测试会失败。同时,我要求它在完成重大代码变更或合并任何分支之后运行测试。凭借一套不断扩充的测试套件,Claude 能够迅速识别并修复它引入代码库的任何回归问题。这些测试也充当了文档和规格说明。
输入与输出示例。 这些就是我的集成测试。我在 Cutlet 仓库中添加了许多示例程序,其中大部分由 Claude 自己编写。它们不仅为人类提供文档,也组成了一套端到端测试。项目指令要求 Claude 在每次修改代码后运行所有示例,并验证其输出。
代码检查器、格式化工具和静态分析工具。 Cutlet 使用 clang-tidy 和 clang-format 来确保基本的代码质量。与测试一样,项目指令要求 LLM 在每次重大代码变更后运行这些工具。我注意到,clang-tidy 经常会生成一些诊断信息,迫使 Claude 重写部分代码。如果我能使用一些价格更高的静态分析工具(例如 Coverity),也会把它们加入我的开发流程。
内存安全工具。 我让 Claude 创建了一个 make test-sanitize 目标,用启用了 ASan 和 UBSan 的配置重新构建整个项目和测试套件(LSan 通过 ASan 一并启用),然后在插桩构建版本下运行所有测试。项目指令要求在实施完一份计划后运行这项检查。它能捕获测试和代码检查器都无法发现的内存错误,例如 use-after-free、缓冲区溢出和未定义行为。运行这些测试需要时间,会极大拖慢智能体,但它们发现的问题甚至比 clang-tidy 还要多。
符号索引。 智能体可以使用 ctags 和 cscope 浏览源代码。我不确定这有多大用处,因为我几乎从未看到它使用这些工具。大多数时候,它只是用 grep 在代码中搜索符号。我将来可能会移除这部分配置。
运行时内省工具。 在项目早期,我让 Claude 为 Cutlet 添加了一项能力:在执行任意一段代码之前,把它的词法单元流、AST 和字节码转储到标准输出。借助这项能力,智能体无须浏览源代码或进入调试器,就能快速判断自己是否在执行流水线的某个环节引入了错误。
流水线追踪。 我让 Claude 编写了一个 Python 脚本:它会使用调试标志,把一个 Cutlet 程序送入解释器,以捕获完整的编译流水线,包括词法单元流、AST 和字节码反汇编结果。然后,它会把每种词法单元类型、AST 节点和操作码,映射回解析器、编译器和虚拟机中负责处理它们的精确源码位置。当智能体需要添加新的语言功能时,可以让追踪器处理一个类似的现有功能示例,从而准确了解需要修改哪些文件和函数。我对这套机制非常自豪,但也从未看到 Claude 真正充分利用它。
以所有可能的权限运行。我希望 AI 智能体能够自主工作,并能使用它可能需要的所有调试工具。为此,我让它在 Docker 容器内运行,启用 --dangerously-skip-permissions,并授予完整的 sudo 权限。我认为,这是在大型项目中使用编程智能体唯一切实可行的方式。当五个智能体并行工作时,逐一回应权限提示会耗费大量认知精力;而限制它们随心所欲采取行动的能力,也会降低它们的工作效率。赋予 LLM 完全控制系统的能力会引发各种安全问题,我们需要想办法解决这些问题。但在这个项目中,我愿意接受 YOLO 模式带来的风险。
所有这些工具和能力保证了代码的任何更新至少都会产生一个能够编译和执行的项目。但更重要的是,它们增加了 Claude 能够获取的信息以及自主行动的能力,使它无需我的干预,就能更有效地发现和调试问题。如果我继续推进这个项目,我的主要关注点将是:让我的智能体对它们正在构建的产物拥有更深入的洞察,获得更多调试工具、更大的自由度,以及更多有用信息。
你需要针对自己的具体项目,设计一套适合自己的工具。如果你在构建 Django 应用,可能会希望让 AI 智能体访问预发布数据库。如果你在构建 React 应用,可能会希望让它访问无头浏览器。不存在一种适用于所有项目的标准答案。我敢肯定,人们将会创造出一些非常有趣的工具,让 LLM 能够在现实世界中观察其工作成果。
编程智能体有时无法高效地使用你提供给它们的工具。
例如,在这个项目的开发过程中,Claude 有时会运行一条命令,判断其输出太长、无法放入上下文窗口,然后再次运行同一条命令,并将输出通过管道传给 head -n 10。还有些时候,它会运行 make check,忘记用 grep 从输出中筛选错误,于是为了捕获输出而再次运行。这会导致一次代码修改过程中,同一项代价高昂的检查被执行多次。这些错误显著拖慢了智能体循环。
其中一些性能瓶颈可以通过编辑 CLAUDE.md 或修改自定义脚本的输出来解决。但另一些问题则需要付出更多精力才能发现和修复。
我很快养成了观察 AI 智能体工作的习惯:留意它一遍又一遍重复执行的命令序列,然后把这些序列做成脚本,让它直接调用。Cutlet 的 scripts 目录中有许多脚本就是这样产生的。
这项工作非常手动,也非常无趣。我希望随着时间推移,它能变得更加自动化。也许未来某个版本的 Claude Code 可以审查自己的工具调用输出,并建议你为它编写哪些脚本?
当然,最有效的优化是让 Claude 在 Docker 中运行,并授予它 --dangerously-skip-permissions 和 sudo 权限。这样一来,我就把自己从智能体循环中移除了。生成计划文件后,我不想继续待在那里照看智能体,更不想每次它们想运行 ls 时都回答一次 Yes。
随着 Cutlet 不断演进,我为 Claude 构建的基础设施也在不断演进。最终,我将 Claude 自然遵循的许多工作流固化成了脚本、斜杠命令或 CLAUDE.md 中的指令。我也逐渐了解了 AI 智能体最容易在哪些地方出错,并通过提供更好的指令或可供运行的脚本,提前防止这些错误发生。
我为 Claude 构建的基础设施对身为项目开发者的人类自己也很有价值。那些帮助 Claude 实现工作自动化的脚本,也帮助我快速完成常见任务。
随着项目不断增长,这套基础设施也会与它一同演进。模型始终在变化,项目需求和工作流也是如此。我把所有这些项目基础设施视为一种有机的事物:只要项目仍然活跃,它就会持续变化。
既然个人开发者如今能在如此短的时间内完成这么多工作,那么软件工程这一职业是否已经走向终结?
我的答案是:没有,完全没有。今天的软件工程技能和语言模型变得强大之前一样有价值。如果我没有在大学里修过编译器课程,也没有认真研读过《Crafting Interpreters》,我就无法构建 Cutlet。我仍然必须做出技术决策,而这些决策只有凭借我所具备的(一些)领域知识和经验才能做出。
此外,为了有效地开发 Cutlet,我还必须学习许多新技能。这些新技能同样需要技术知识。那是一种陌生、崭新且不同以往的技术知识,但归根结底,它仍然是技术知识。
在开发这个项目之前,我曾担心五年后自己是否还有工作。但如今我确信,未来的世界仍然需要软件工程师。我们的工作会发生转变——有些人可能不再喜欢转变后的新工作——但仍然会有大量工作等待我们去做。或许我们的工作甚至会比以前更多,因为 LLM 让我们能够以快得多的速度构建多得多的软件。
至于那些永远不想接触 LLM 的人,也依然会有一些 LLM 始终无法涉足的领域。与构建 Web 应用的人相比,我那些从事底层多媒体系统开发的朋友发现,使用 LLM 所取得的成效要小得多。未来很多年里,情况很可能都会如此。最终,这些工作也会发生转变,但变化速度会慢得多。
说 Cutlet 是我构建的,公平吗?毕竟,大部分工作都是 Claude 完成的。除了编写提示词,我在这里究竟做出了什么贡献?
此外,这项实验之所以能够成功,只是因为 Claude 的训练数据中包含多种语言运行时和计算机科学书籍。如果没有数百名程序员、学者和作者完成并无偿贡献的工作,