Skip to content
Go back

面向 AI 代理的编程语言

面向 AI 代理的编程语言

去年我开始思考,在 AI 代理工程日益发展的今天,编程语言的未来会是什么样子。最初我认为大量已有代码会让现有语言的地位更加稳固,但现在我开始认为事实恰恰相反。在这里,我想阐述为什么我们会看到更多新编程语言出现,以及为什么这个领域还有很大的创新空间。如果有人想开始构建新语言,这里有一些我对设计目标的思考。

为什么新语言会成功

AI 代理在它训练权重中包含的语言上表现会更好吗?显然是的。但还有一些不那么明显的因素会影响代理编程能力:周边工具的质量以及语言变化的频率。

Zig 在训练权重中的代表性似乎不足(至少在我使用的模型中),而且变化很快。这种组合不太理想,但仍然可以接受:如果你把代理指向正确的文档,它甚至可以使用即将发布的 Zig 版本编程。但效果不够好。

另一方面,有些语言在权重中代表性很好,但代理仍然成功率不高,因为工具链的问题。Swift 就是一个很好的例子:根据我的经验,构建 Mac 或 iOS 应用所涉及的工具链可能非常痛苦,以至于代理难以驾驭。这也不理想。

所以,语言存在不代表代理就能成功,语言新也不意味着代理一定会遇到困难。我相信如果你不想一次性全面改变,可以逐步构建对新语言的支持。

新语言可能成功的最大原因是编码成本正在急剧下降。结果就是生态系统的广度变得不那么重要了。我现在经常在以前会用 Python 的地方选择 JavaScript,不是因为我喜欢它或者它的生态系统更好,而是因为代理在 TypeScript 上表现更好。

可以这样理解:如果我选择的语言中缺少重要功能,我只需要把代理指向其他语言的库,让它构建一个移植版本。举个具体例子,我最近用 JavaScript 构建了一个以太网驱动程序,为我们的沙箱实现主机控制器。Rust、C 和 Go 中都有实现,但我想要在 JavaScript 中实现一个可插拔和可自定义的版本。让代理重新实现它比让构建系统和发布流程支持原生绑定更容易。

新语言如果价值主张足够强,并且在设计时考虑到 LLM 的训练方式,就会成功。人们会采用它们,尽管它们在训练权重中代表性不足。如果它们设计得能很好地配合代理工作,那么它们可能会采用已经被证明有效的熟悉语法。

为什么需要新语言

那么我们为什么需要新语言呢?思考这个问题很有意思,因为今天许多语言的设计都基于一个假设:敲键盘很费力,所以我们为了简洁性牺牲了某些东西。例如,许多语言(特别是现代语言)严重依赖类型推断,这样你就不必写出类型。缺点是你现在需要 LSP 或编译器错误信息才能弄清楚表达式的类型是什么。代理也面临这个困扰,在代码审查中这也很令人沮丧,因为复杂操作可能使得很难弄清楚实际类型是什么。完全动态的语言在这方面更糟糕。

编写代码的成本在下降,但因为我们也在产生更多代码,理解代码的作用变得更加重要。如果这意味着在审查时减少歧义,我们实际上可能希望编写更多代码。

我还想指出,我们正在走向这样一个世界:有些代码永远不会被人类看到,只会被机器使用。即使在这种情况下,我们仍然希望向用户(可能是非程序员)说明正在发生什么。我们希望能够向用户解释代码将做什么,而不必深入了解如何实现的细节。

因此,新语言的理由归结为:鉴于编程者和代码成本的根本性变化,我们至少应该考虑一下。

AI 代理想要什么

很难说代理想要什么,因为代理会对你撒谎,而且它们受到所见代码的影响。但估计它们表现如何的一种方法是查看它们需要对文件进行多少次更改,以及完成常见任务需要多少次迭代。

有些事情我发现在短期内应该是正确的。

无需 LSP 的上下文

语言服务器协议(LSP)让 IDE 能够根据代码库的语义知识推断光标下的内容或应该自动补全什么。这是一个很棒的系统,但它带来了一个对代理来说很棘手的特定成本:LSP 必须运行。

有些情况代理就是不会运行 LSP,不是因为技术限制,而是因为它也很懒,如果不必要就会跳过这一步。如果你给它文档中的示例,没有简单的方法运行 LSP,因为那只是一个可能不完整的片段。如果你把它指向 GitHub 仓库,它拉取单个文件,它只会查看代码。它不会为类型信息设置 LSP。

不分裂为两种不同体验(有 LSP 和无 LSP)的语言对代理是有益的,因为这给了它们一种统一的工作方式,适用于更多情况。

花括号、方括号和圆括号

作为 Python 开发者,说这话让我很痛苦,但基于空格的缩进是个问题。正确处理空格的底层令牌效率很棘手,带有显著空格的语言对 LLM 来说更难处理。如果你尝试让 LLM 在没有辅助工具的情况下进行手术式修改,这一点尤其明显。它们通常会故意忽略空格,添加标记来启用或禁用代码,然后依赖代码格式化器稍后清理缩进。

另一方面,没有空格分隔的花括号也可能导致问题。根据分词器的不同,一连串的右括号可能会以令人惊讶的方式分割成令牌(有点像”草莓”计数问题),LLM 很容易把 Lisp 或 Scheme 搞错,因为它无法跟踪已经发出或正在查看多少个右括号。未来的 LLM 可以修复吗?当然,但这也是人类在没有工具的情况下很难做对的事情。

流程上下文但要明确

这个博客的读者可能知道,我非常相信异步本地变量和流程执行上下文,基本上就是通过每次调用传递数据的能力,这些数据可能只在调用链的许多层之后才需要。在可观测性公司工作让我对这一点有了深刻的体会。

挑战在于,任何隐式流动的东西可能都没有配置。例如当前时间。你可能想隐式地将计时器传递给所有函数。但如果没有配置计时器,突然出现了新的依赖关系怎么办?显式传递所有内容对人类和代理都很繁琐,会做出糟糕的妥协。

我尝试过的一件事是在函数上添加效应标记,这些标记通过代码格式化步骤添加。函数可以声明它需要当前时间或数据库,但如果它没有明确标记,这基本上是一个自动格式化修复的 linting 警告。LLM 可以在函数中开始使用当前时间之类的东西,任何现有调用者都会得到警告;格式化会传播注解。

这很好,因为当 LLM 构建测试时,它可以精确地模拟这些副作用——它从错误消息中了解必须提供什么。

例如:

fn issue(sub: UserId, scopes: []Scope) -> Token
    needs { time, rng }
{
    return Token{
        sub,
        exp: time.now().add(24h),
        scopes,
    }
}

test "issue creates exp in the future" {
    using time = time.fixed("2026-02-06T23:00:00Z");
    using rng  = rng.deterministic(seed: 1);

    let t = issue(user("u1"), ["read"]);
    assert(t.exp > time.now());
}

结果优于异常

代理在处理异常时很困难,它们害怕异常。我不确定这在多大程度上可以通过强化学习来解决,但现在代理会尝试捕获它们能捕获的所有东西,记录下来,然后进行相当糟糕的恢复。考虑到关于错误路径的实际可用信息很少,这是有道理的。检查型异常是一种方法,但它们会一直传播到调用链的顶部,并没有显著改善情况。即使它们最终成为提示,linter 跟踪可能飞过的错误,仍然有许多调用点需要调整。就像为上下文数据提议的自动传播一样,这可能不是正确的解决方案。

也许正确的方法是更多地使用类型化结果,但如果没有支持它的类型和对象系统,这对组合性仍然很棘手。

最小差异和行读取

代理今天用来将文件读入内存的一般方法是基于行的,这意味着它们经常选择跨越多行字符串的块。一个很容易看到这种问题的方法:让代理处理一个 2000 行的文件,该文件还包含长的嵌入代码字符串,基本上是一个代码生成器。代理有时会在多行字符串内编辑,以为那是真正的代码,而实际上只是多行字符串中的嵌入代码。对于多行字符串,我知道唯一有好解决方案的语言是 Zig,但它基于前缀的语法对大多数人来说相当陌生。

重新格式化还经常导致结构移动到不同的行。在许多语言中,列表中的尾随逗号要么不支持(JSON),要么不常见。如果你想要差异稳定性,你会追求一种需要更少重新格式化且大多避免多行结构的语法。

让它可以 grep

Go 的一个非常好的地方是,你基本上不能在不给每次使用加上包名前缀的情况下将符号从另一个包导入到作用域。例如:context.Context 而不是 Context。有一些逃生舱(导入别名和点导入),但它们相对罕见,通常不受欢迎。

这极大地帮助代理理解它正在查看什么。一般来说,通过最基本的工具使代码可查找是很好的——它适用于未索引的外部文件,并且意味着由即时生成的代码驱动的大规模自动化有更少的误报(例如:sedperl 调用)。

局部推理

我所说的很多内容归结为:代理真的很喜欢局部推理。它们希望它能分部分工作,因为它们通常只在上下文中加载几个文件,对代码库没有太多空间意识。它们依赖像 grep 这样的外部工具来查找东西,任何难以 grep 或隐藏在其他地方的信息都很棘手。

依赖感知构建

让代理在许多语言中失败或成功的是构建工具有多好。许多语言很难确定实际需要重建或重新测试什么,因为有太多交叉引用。Go 在这方面真的很好:它禁止包之间的循环依赖(导入循环),包有清晰的布局,测试结果被缓存。

AI 代理讨厌什么

代理经常在宏上挣扎。已经很清楚人类也在宏上挣扎,但支持宏的论点主要是代码生成是减少代码编写的好方法。既然这不再是个问题,我们应该追求对宏依赖更少的语言。

关于泛型和编译时计算还有一个单独的问题。我认为它们表现稍好一些,因为它们主要用不同的占位符生成相同的结构,代理更容易理解。

重新导出和桶文件

与可 grep 性相关:代理经常难以理解桶文件,它们不喜欢它们。无法快速弄清楚类或函数来自哪里会导致从错误的地方导入,或者完全遗漏,并通过读取太多文件浪费上下文。从声明位置到导入位置的一对一映射很好。

而且它也不必过于严格。Go 在某种程度上是这样做的,但不太极端。目录中的任何文件都可以定义一个函数,这不是最优的,但很快就能找到,而且你不需要搜索太远。它之所以有效,是因为包被强制保持足够小,可以用 grep 找到所有内容。

最糟糕的情况是到处都有自由的重新导出,完全将实现与磁盘上任何可轻易重建的位置解耦。或者更糟:别名。

别名

代理经常讨厌涉及别名的情况。事实上,如果你让它们重构使用大量别名的东西,你可以在思考块中看到它们抱怨。理想情况下,语言鼓励良好的命名,因此在导入时不鼓励使用别名。

不稳定测试和开发环境差异

没有人喜欢不稳定的测试,但代理更不喜欢。讽刺的是,代理特别擅长首先创建不稳定的测试。这是因为代理目前喜欢模拟,而大多数语言不能很好地支持模拟。所以许多测试最终意外地不是并发安全的,或者依赖于开发环境状态,然后在 CI 或生产中出现差异。

大多数编程语言和框架使编写不稳定的测试比编写稳定的测试容易得多。这是因为它们到处鼓励不确定性。

多个失败条件

在理想的世界中,代理有一个命令,可以 lint 和编译,并告诉代理是否一切正常。也许还有另一个命令来运行所有需要运行的测试。实际上,大多数环境不是这样工作的。例如在 TypeScript 中,你经常可以运行代码,即使它未能通过类型检查。这可能会迷惑代理。同样,不同的打包器设置可能导致一件事成功,而稍有不同的 CI 设置稍后失败。工具越统一越好。

理想情况下,它要么运行,要么不运行,对于尽可能多的 linting 失败有机械化的修复,这样代理就不必手动修复。

我们会看到新语言吗

我认为会的。我们现在编写的软件比以往任何时候都多——更多的网站、更多的开源项目、更多的一切。即使新语言的比例保持不变,绝对数量也会上升。但我也真心相信,更多的人会愿意重新思考软件工程的基础和我们使用的语言。这是因为虽然多年来感觉你需要为语言构建大量基础设施才能成功,但现在你可以针对一个相当狭窄的用例:确保代理满意,然后从那里扩展到人类。

我只希望我们看到两件事。首先,一些局外人艺术:以前没有构建过语言的人尝试一下,向我们展示新东西。其次,更有意识地努力从第一性原理记录什么有效,什么无效。我们实际上已经学到了很多关于什么造就好语言以及如何将软件工程扩展到大型团队。然而,要找到以可使用的良好和不良语言设计概述的形式写下来的内容非常困难。太多内容被关于相当无意义的事情的观点所塑造,而不是硬性事实。

但现在,我们正在慢慢达到事实更重要的地步,因为你实际上可以通过观察代理的表现来衡量什么有效。没有人愿意接受调查,但代理不在乎。我们可以看到它们在哪里成功,在哪里挣扎。


Tags


Previous

结构化上下文工程与文件原生代理系统

Next

Playwright CLI:面向编码代理的高效浏览器自动化工具