发布于 2025年11月24日

译:Agent 设计依然很难

原文: https://lucumr.pocoo.org/2025/11/21/agents-are-hard/
作者: Armin Ronacher
译者: Gemini 2.5 Pro

我觉得是时候写一些我学到的新东西了。内容主要关于构建 agent,也会稍微谈谈如何使用 agentic 编码工具。

TL;DR:构建 agent 依然很乱。一旦涉及真正的 tool use,SDK 的抽象就不好用了。自己管理 caching 效果更好,但不同模型之间有差异。Reinforcement 承担的责任比预想的要多,而且失败需要严格隔离,以避免整个循环跑偏。通过类似文件系统的层来共享状态是一个重要的构件。输出工具的设计出乎意料地棘手,而模型的选择仍然取决于具体任务。

该选择哪个 Agent SDK?

当你构建自己的 agent 时,你可以选择基于底层的 SDK,如 OpenAI SDK 或 Anthropic SDK,也可以选择更高层次的抽象,如 Vercel AI SDK 或 Pydantic。我们不久前做的选择是采用 Vercel AI SDK,但只用它的 provider 抽象,基本上是自己来驱动 agent loop。现在来看,我们不会再做同样的选择。Vercel AI SDK 本身没有任何问题,但当你尝试构建一个 agent 时,会发生两件我们当初没预料到的事:

首先,不同模型之间的差异非常大,你需要构建自己的 agent 抽象。我们没能在这些 SDK 中找到任何一个能为 agent 构建出正确抽象的方案。我认为部分原因在于,尽管 agent 的基本设计只是一个循环,但根据你提供的 tools,会出现一些微妙的差异。这些差异会影响你找到正确抽象的难易程度(缓存控制、对 reinforcement 的不同要求、tool prompts、provider-side tools 等)。因为正确的抽象尚不明确,所以使用各大平台自己的原生 SDK 能让你完全掌控一切。而使用那些更高层次的 SDK,你必须在它们已有的抽象之上构建,而这些抽象最终可能并不是你想要的。

我们还发现,在使用 Vercel SDK 处理 provider-side tools 时挑战极大。它试图统一消息格式,但效果并不理想。例如,Anthropic 的网页搜索工具在使用 Vercel SDK 时,经常会破坏消息历史,我们至今还没完全搞清楚原因。此外,对于 Anthropic,直接使用它的 SDK 进行缓存管理,要比通过 Vercel SDK 容易得多。当你搞砸了什么事,前者给出的错误信息也清晰得多。

这种情况未来可能会改变,但就目前而言,在构建 agent 时我们可能不会使用抽象层,至少要等到尘埃落定一些。对我们来说,这样做的好处还不足以抵消其成本。

可能有人已经找到了更好的方法。如果你读到这里,觉得我说的不对,请给我发邮件。我很想学习。

关于 Caching 的教训

不同平台对 caching 的处理方式大相径庭。关于这一点已经有很多讨论了,但 Anthropic 的 caching 是要收费的。它让你显式地管理缓存点,这从 agent 工程的角度彻底改变了你与它交互的方式。起初我觉得这种手动管理的方式很蠢。平台为什么不替我做了?但我现在完全转变了看法,更偏爱显式的缓存管理。它让成本和缓存利用率变得更加可预测。

显式缓存能让你做到一些在其他情况下很难实现的事情。例如,你可以将一个对话分叉,让它同时朝两个不同的方向发展。你还有机会进行 context editing。这里的最佳策略尚不明确,但你显然拥有了更多的控制权,而我真的很喜欢这种控制权。它还让你更容易理解底层 agent 的成本。你可以更准确地预测缓存的利用情况,而在其他平台上,我们发现这事儿全凭运气。

我们用 Anthropic 在 agent 中做 caching 的方式非常直接。一个缓存点设置在 system prompt 之后。两个缓存点放在对话的开头,其中后一个缓存点会随着对话的推进而向前移动。此外,你还可以在过程中做一些优化。

因为 system prompt 和 tool selection 现在必须基本保持静态,我们会稍后传入一条动态消息来提供当前时间等信息。否则,缓存就会被破坏。我们也在循环中更多地利用了 reinforcement。

Agent 循环中的 Reinforcement

每当 agent 运行一个 tool,你不仅有机会返回 tool 产生的数据,还可以向循环中反馈更多信息。例如,你可以提醒 agent 它的总体目标和各个任务的当前状态。当一个 tool 调用失败时,你还可以提供一些关于如何才能成功的提示。Reinforcement 的另一个用途是告知系统后台发生的状态变化。如果你的 agent 使用并行处理,你可以在每次 tool 调用后,当状态发生变化且与完成任务相关时,注入相关信息。

有时,agent 进行 self-reinforce 就足够了。例如,在 Claude Code 中,“todo write” tool 就是一个 self-reinforcement tool。它所做的,只是从 agent 那里接收一个它认为应该做的任务列表,然后把接收到的内容再原样输出。它基本上就是一个 echo tool;真的不干别的。但这足以更好地驱动 agent 前进,效果比只在 context 开头给出任务和子任务,而中间又发生了太多事情要好得多。

我们还使用 reinforcement 来告知系统,执行期间环境是否发生了对 agent 不利的变化。例如,如果我们的 agent 失败了,并从某一步开始重试,但恢复操作是基于已损坏的数据,我们会注入一条消息,告知它可能需要退几步,重做一个早先的步骤。

隔离失败

如果你预计代码执行过程中会有很多失败,就有机会将这些失败从 context 中隐藏起来。这可以通过两种方式实现。一种是单独运行那些可能需要迭代的任务。你可以在一个 subagent 中运行它们,直到成功,然后只报告成功的结果,或许再加上一个关于哪些方法行不通的简短总结。让 agent 了解子任务中哪些方法失败了是很有帮助的,因为它可以将这些信息反馈到下一个任务中,从而有望避开那些失败。

第二种选择并非在所有 agent 或基础模型中都存在,但通过 Anthropic 你可以进行 context editing。到目前为止,我们在这方面还没有取得太多成功,但我们认为这是一个值得深入探索的有趣方向。我们也很想了解是否有人在这方面取得了成功。Context editing 的有趣之处在于,你应该能够为迭代循环的后续步骤保留 token。你可以从 context 中移除某些并未导向成功完成循环、而只是对执行过程中的某些尝试产生了负面影响的失败。但正如我前面提到的:让 agent 了解哪些方法行不通也很有用,但也许它并不需要所有失败的完整状态和全部输出。

不幸的是,context editing 会自动让缓存失效。这真的没办法避免。所以,很难说清什么时候这样做的好处能抵消掉破坏缓存带来的额外成本。

Sub Agent / Sub Inference

我已经在博客里提过几次,我们的大部分 agent 都基于代码执行和代码生成。这就确实需要一个公共的地方让 agent 存储数据。我们的选择是一个文件系统——在我们的例子里是一个虚拟文件系统——但这需要不同的工具来访问它。这一点在你有 subagent 或 subinference 这类东西时尤其重要。

你应该努力构建一个没有“死胡同”的 agent。“死胡同”指的是一个任务只能在你构建的 sub-tool 内部继续执行。例如,你可能构建了一个生成图片的 tool,但它只能将图片反馈给另一个特定的 tool。这就是个问题,因为你可能之后想用代码执行工具把这些图片打包成一个 zip 压缩文件。所以,需要有一个系统,能让图片生成工具把图片写入到代码执行工具可以读取的同一个地方。本质上,这就是一个文件系统。

显然,反向操作也必须可行。你可能想用代码执行工具解压一个 zip 压缩包,然后回到 inference 步骤来描述所有图片,以便下一步能再回到代码执行,如此循环。文件系统就是我们用来实现这一点的机制。但这要求工具在构建时,就能接受指向虚拟文件系统的文件路径并进行操作。

所以基本上,一个 ExecuteCode tool 应该能访问与 RunInference tool 相同的文件系统,后者可以接受一个指向该虚拟文件系统上某个文件的 path

Output Tool 的使用

我们构建 agent 的一个有趣之处在于,它并不代表一个聊天会话。它最终会向用户或外部世界传达一些信息,但它在中间过程发送的所有消息通常是不可见的。问题是:它如何创建最终那条消息?我们有一个专门的 tool,就是 output tool。Agent 会明确地使用它来与人沟通。然后我们用一个 prompt 来指示它何时使用这个 tool。在我们的案例中,这个 output tool 会发送一封电子邮件。

但这又带来了其他一些挑战。其中之一是,与直接使用主 agent 循环的文本输出来与用户对话相比,要控制这个 output tool 的措辞和语气,出乎意料地困难。我说不清为什么,但我认为这可能与这些模型的训练方式有关。

一个不太成功的尝试是,让 output tool 运行另一个轻快的 LLM,比如 Gemini 2.5 Flash,来调整语气以符合我们的偏好。但这增加了延迟,实际上还降低了输出的质量。部分原因我认为是,模型本身措辞就不准确,而这个 subtool 又没有足够的上下文。把主 agent context 的更多片段提供给 subtool 会让成本变高,也未能完全解决问题。而且它有时还会在最终输出中透露我们不希望出现的信息,比如得出最终结果的步骤。

output tool 的另一个问题是,有时它就是不调用这个 tool。我们强制它调用的方法之一是,我们会记录 output tool 是否被调用过。如果循环结束时 output tool 还没被调用,我们就注入一条 reinforcement 消息,鼓励它使用 output tool。

模型的选择

总的来说,我们对模型的选择至今没有发生戏剧性的变化。我认为 Haiku 和 Sonnet 仍然是市面上最好的 tool caller,因此它们是 agent 循环中的绝佳选择。它们在 RL 方面也相对透明。其他显而易见的选择是 Gemini 系列模型。到目前为止,我们还没能在主循环中成功地大量使用 GPT 系列模型。

对于那些本身可能也需要 inference 的独立 sub-tool,如果我们有总结长文档或处理 PDF 等需求,目前的选择是 Gemini 2.5。它在从图片中提取信息方面也相当不错,特别是因为 Sonnet 系列模型动不动就触发安全过滤器,挺烦人的。

还有一个可能非常显而易见的认识是,单看 token 成本并不能真正定义一个 agent 有多贵。一个更好的 tool caller 会用更少的 token 完成工作。现在市面上有些模型比 Sonnet 更便宜,但在一个循环中,它们不一定更省钱。

但总的来说,过去几周并没有太多变化。

测试与评估 (Evals)

我们发现测试和评估 (evals) 是这里最难的问题。这不完全出人意料,但 agent 的特性让这件事变得更难。与 prompts 不同,你不能只在某个外部系统中进行评估,因为你需要向它输入太多东西。这意味着你需要基于可观测性数据或通过监控实际的测试运行来进行评估。到目前为止,我们尝试过的所有解决方案都没能让我们相信它们找到了正确的方法。不幸的是,我必须承认,目前我们还没有找到一个真正让我们满意的方案。我希望我们能找到解决办法,因为这正成为构建 agent 过程中一个越来越令人沮丧的方面。

编码 Agent 的新动向

至于我使用编码 agent 的体验,其实并没有太大变化。主要的新进展是我正在更多地试用 Amp。如果你好奇为什么:并不是说它客观上就比我正在用的 agent 更好,但我非常欣赏他们从博文中所透露出的对 agent 的思考方式。像 Oracle 这样的不同 sub agent 与主循环的交互设计得非常漂亮,如今没有多少其他框架能做到这一点。对我来说,这也是验证不同 agent 设计如何工作的好方法。Amp 和 Claude Code 很像,都让人感觉是创始团队自己也在用的产品。我感觉业内并非所有 agent 都能做到这一点。

我读到和发现的一些零碎东西

这里只是一些我随手收集的、觉得或许值得分享的东西:

  • 如果你根本不需要 MCP 呢?
    Mario 认为许多 MCP 服务器被过度设计了,包含了大量消耗 context 的工具集。他为浏览器 agent 的用例提出了一种极简主义方法,即依赖通过 Bash 执行的简单 CLI 工具(例如,启动、导航、执行 JS、截图),这能保持较低的 token 使用量和灵活的工作流。我用它做了一个 Claude/Amp Skill
  • “小型”开源的命运
    作者认为,那些小而专的开源库的时代正在走向终结,这主要是因为内置的平台 API 和 AI 工具现在可以按需生成简单的实用程序。谢天谢地
  • Tmux 是真爱。这没有对应的文章,但长话短说就是 Tmux 很棒。如果你有任何看起来像是 agent 应该与之交互的系统,你应该给它一些 Tmux skills
  • LLM API 是一个同步问题。这是一个独立的感悟,对于这篇文章来说太长了,所以我单独写了一篇。

评论 (0)

请登录后发表评论

暂无评论,快来发表第一条评论吧!