跳转到正文
莫尔索随笔
返回

提示词是代码,.json/.md 文件是状态

预计 12 分钟

第一时间捕获有价值的信号

本文译自 Prompts are code, .json/.md files are state。本文提出了一个新颖的观点:将 LLM 视为”糟糕的通用计算机”,用自然语言作为编程语言,通过结构化提示和外部状态管理来实现可复现、可恢复的 Agent 工作流。

你好,计算机

像你们大多数人一样,我一直在涉猎人们所谓的”Agent 工程”。说实话,没有太多工程在发生。我们基本上是把东西扔到墙上,希望有什么东西粘住。

使用像 Claude Code 这样的 LLM 编码工具来快速启动一次性绿地项目或完成临时脚本?非常棒的体验。但尝试在大的、已建立的代码库上使用它们,或者那个曾经被称为绿地项目的生产应用,而不破坏一切?这就是事情变得痛苦的地方。

感受痛苦

对于更大的代码库,主要问题是上下文,或者更确切地说,缺乏上下文。这些工具没有你的项目的完整图景。也许你没有给它们那个概述,或者它们的上下文窗口太小而无法容纳你所有的互联组件。但还有更多。

即使有最近的改进,如”推理”——这实际上只是旧的”逐步思考”技巧,有更多的暂存空间来关注——LLM 仍然不能很好地跟随执行流程。它们对于除了顺序脚本之外的任何东西都特别迷失:多个进程、IPC、客户端-服务器架构、同一进程内的并发执行。即使你设法塞进它们需要的所有上下文,它们仍然会生成不符合你的系统实际架构的代码。

LLM 也缺乏品味。在网络上的所有代码上训练(可能还有一些私有代码),它们生成,简单来说,它们所看到的统计平均值。虽然高级工程师追求优雅、极简的解决方案来减少错误和复杂性,但 LLM 却伸手去拿”最佳实践”并吐出过度工程化的垃圾。让它们自由运行,你会得到难以维护、难以理解、并且充满 bug 藏身之处的代码。

然后是上下文退化。随着你的会话进行并引入更多文件、工具输出和其他数据,事情在大约 100k Token 左右开始分崩离析。基准测试该死的。无论 LLM 提供商使用什么技巧来实现那些巨大的上下文窗口,在实践中都不起作用。模型失去了埋在所有那些上下文中间的重要细节的踪迹。

更糟糕的是,许多工具不让你控制进入你上下文的内容。像 Cursor 这样本身不是 LLM 提供商的公司需要在你支付给它们的和它们为 Token 支付的之间赚取利润。它们的动机?削减你的上下文以节省资金,这意味着 LLM 可能会错过关键信息或以次优格式获取它。

Claude Code 不同。它直接来自 Anthropic,没有中间人试图挤压利润。使用 Max 计划,你基本上得到无限的 Token(尽管像 Peter 这样的人即使有三四个账户也设法达到速率限制)。你仍然没有完全的控制权:有一个你无法更改的系统提示词,额外的指令被偷偷注入到你的第一条消息中,VS Code 集成添加了不需要的垃圾,并且所有工具定义都消耗了上下文并给模型足够的绳子来吊死自己。但这是我们得到的最好的交易,所以我们用我们拥有的东西工作。(Anthropic,请开源 Claude Code。你的模型是你的护城河,不是 Claude Code。)

我们如何驯服这个 Agent 混乱?

当在更大的代码库上使用编码 Agent 时,我们需要的是一种结构化的方法来工程上下文。由此我指的是:只保留修改或生成代码任务所需的信息,最小化模型需要调用工具或向我们报告的回合数,并确保没有重要的东西缺失。我们想要可复现的工作流。我们想要确定性,在这些本质上不确定的模型的限制范围内尽可能多。

我是个程序员。你可能也是个程序员。我们用系统、确定性工作流和抽象来思考。有什么比将 LLM 视为一种极其缓慢的不可靠计算机,我们用自然语言来编程,对我们来说更自然的呢?

这是一种奇怪的元编程形式:我们以提示词的形式写”代码”,在 LLM 上执行以生成在真实 CPU 上运行的实际代码。

是的,我知道 LLM 实际上不是计算机(尽管 arXiv 上有一些论文……)。这个比喻有点牵强。但这是事情:作为开发者,我们习惯于用精确的编程语言编码规范。当我们与 LLM 交互时,自然语言的模糊性让我们忘记我们可以应用同样的结构化思考。这个框架弥合了这个差距:思考”输入、状态、输出”而不是”与 AI 聊天”,突然你就更接近工程解决方案而不是只是希望最好的。

将 LLM 视为糟糕的通用计算机

在传统软件中,我们通过写代码和导入库来创建程序。一个程序接受输入,操纵状态,并产生输出。我们可以将这些概念映射到我们的 LLM 作为糟糕计算机的比喻,像这样:

程序是你的提示词,用自然语言编写。它指定初始输入、通过工具描述”导入”外部函数,并通过控制流实现业务逻辑:顺序步骤、循环、条件,以及是的,甚至 goto。工具调用和用户输入是 I/O。

输入来自三个来源:准备好的信息(代码库文档、风格指南、架构概述)要么烘焙到提示词中要么从磁盘加载,执行期间的用户输入(澄清、更正、新需求),以及工具输出(文件内容、命令结果、API 响应)。

状态随着程序运行而演进。一些存在于上下文中,但我们将其视为短暂的:压缩最终会清除它(trololo)。另外,对于任何实质性状态,你很快就会遇到上下文限制。所以我们使用 LLM 处理良好的格式序列化到磁盘:JSON 用于结构化数据,LLM 可以通过 jq 精确读取和更新特定字段。Markdown 用于较小的非结构化数据,如果需要我们可以完全加载到上下文中。回报是什么?你可以用新鲜的上下文从任何点恢复,完全绕过可怕的压缩问题。

输出不限于生成的代码。就像传统程序产生控制台输出、写入文件或显示 GUI 一样,我们的 LLM 程序使用工具调用来创建各种输出:实际代码、差异、在编辑器中为我们打开文件、代码库统计、变更摘要,或任何其他记录程序做了什么的工件。这些输出有多个目的:帮助你审查工作、为工作流中的下一步提供输入,或只是显示程序的进度。

让我们看看这在实践中如何发挥作用。

真实世界示例:移植 Spine 运行时

在玩了玩具项目之后,我觉得准备好将这种方法应用到真实代码库:Spine 运行时

Spine 是 2D 骨骼动画软件。你在编辑器中创建动画,将它们导出为运行时格式,然后使用众多运行时之一在你的应用或游戏中显示它们。我们维护 C、C++、C#、Haxe、Java、Dart、Swift 和 TypeScript 的运行时。在这些之上,我们为 Unity、Unreal、Godot、Phaser、Pixi、ThreeJS、iOS、Android、Web 等构建了集成。

这是痛苦的部分:在发布之间,运行时代码会变化。我们在我们的参考实现(spine-libgdx 在 Java 中,为编辑器提供动力)中实现新功能,然后手动将这些更改移植到每个其他语言运行时。这是乏味、容易出错的工作。数学繁重的代码需要精确翻译,经过数小时的移植后,你的大脑会变成糊状。错误会悄悄潜入,这是地狱般的追踪。

变更集

而且不,转译器不适用于这个(相信我,兄弟,我通过做编译器赚钱了)。我们需要惯用的移植,以一种对每种语言感觉自然的方式保持相同的 API 表面。

在版本 4.2 和 4.3-beta 之间,Java 参考实现看到了显著的变化:

$ git diff --stat 4.2..4.3-beta -- '*.java' | tail -1
  79 files changed, 4820 insertions(+), 4679 deletions(-)

这是我用我的手动工作流处理这个问题的方法:

  1. 在 Fork(我的 git 客户端)中打开变更集并扫描所有已更改的文件
  2. 根据依赖图规划移植顺序:首先是接口和枚举(它们通常是独立的),然后尝试在使用它们的类之前移植依赖项,希望保持一些可编译性
  3. 选择一个在 Java 中要移植的类型,打开并排差异,检查该类型是否已存在于目标运行时中或需要从头创建
  4. 将更改逐行、逐方法移植到目标语言
  5. 看着秩序的幻觉崩溃:依赖图是循环的,所以没有完美的移植顺序可以保持一切编译(自我笔记:如果我们有一个非循环类型依赖图会很好)
  6. 无法单独测试各个部分,因为骨骼动画系统需要所有部分协同工作
  7. 盲目移植一切,然后面对一堵编译错误和因为我的大脑经过数小时的人类转译而变得糊状而引入的错误的墙

当从 Java 移植到 C 时,这特别有趣,这是类型系统和内存管理不匹配最大的语言对。

使这易于处理的是,我们在所有运行时实现中保持相同的 API 表面。如果 Java 中有一个类 Animation,那么 C#、C++ 和每个其他运行时中在相应文件中也有一个类 Animation。这种一对一映射存在于 99% 的类型中。当然,有像包含数十个内部类的 Java 文件这样的怪癖,但结构一致性在那里。

这是一个数学更繁重的类型,PhysicsConstraint 的示例:

(由于篇幅原因,Java 和 C++ 代码示例在此省略)

很多这种移植工作是机械的,可以自动化,比如 getter 和 setter、将文档从 Javadoc 传输到 docstrings,或确保数学匹配。一些移植工作需要人脑,比如将 Java 泛型翻译为 C++ 模板,这是 LLM 不太擅长的任务。LLM 擅长帮助我仔细检查我忠实地移植了每一行。这呈现了完美的机会,将我的小工作流实验应用到真实世界的真实代码库任务上。

“将 Java 移植到 X”程序

是时候写我们的程序了。我们基本上是将我的手动工作流编码到结构化的 LLM 程序中。目标:逐个类型地移植两个提交之间在 Spine 运行时参考实现(Java)中更改的每个类型,与用户协作移植到目标运行时(如 C++)。

与其我手动打开差异、追踪依赖项并在我的大脑融化时逐行移植,我们会让 LLM 处理机械部分,而我保持对重要决策的控制。

这个程序设计的最终结果可以在 spine-port 仓库中找到。程序本身存储在一个名为 port.md 的文件中。当我想要开始或继续移植时,我在 spine-port 目录中启动 Claude Code 并告诉它完整读取 port.md 文件并执行工作流。这就启动了”程序”。

port.md 文件是我和 Claude 之间的迭代协作过程。在以下部分中,我们将逐步介绍这个程序的每个部分。

(文章后续部分由于篇幅原因在此省略,完整内容请参考原文。核心观点是:通过将提示词视为代码、将外部文件视为状态,我们可以实现可复现、可恢复的 Agent 工作流。)

总结

基准测试结果很有趣,但真正的证明在于实际使用。而我的证明就是我的日常工作,pi 在其中一直表现出色。Twitter 上充满了上下文工程的帖子和博客,但我觉得我们目前拥有的框架中没有一个真正让你进行上下文工程。pi 是我为自己构建一个尽可能让我掌控的工具的尝试。

我对 pi 的现状相当满意。还有一些我想添加的功能,比如压缩工具结果流式输出,但我认为我个人不会需要更多东西了。缺少压缩对我个人来说不是问题。出于某种原因,我能够在单个会话中塞进[数百次交流],这在没有压缩的情况下我用 Claude Code 是做不到的。

也就是说,我欢迎贡献。但就像我所有的开源项目一样,我倾向于独裁。这是我多年来从我的更大项目中艰难学到的教训。如果我关闭了你发送的问题或 PR,我希望没有硬 feelings。我也会尽我最大的努力给你原因。我只是想保持这个专注和可维护。如果 pi 不满足你的需求,我恳请你 Fork 它。我是认真的。如果你创造了更符合我需求的东西,我会很高兴加入你的努力。

我认为上面的一些经验也可以转移到其他框架。让我知道你的进展如何。