Agent 开发还是很难:一线实践者的真实感受
写于 2025 年 11 月 21 日
原文来源:Agent Design Is Still Hard 作者:Armin Ronacher 本文为中文润色版,结合具体场景和案例进行了重新讲解
开场白
去年这个时候,AI Agent 的概念刚刚火起来。那时候大家都在说:"Agent 不就是个循环吗?让模型调用工具,拿到结果,再继续推理,有什么难的?"
一年过去了,我想说:真的很难。
不是说实现不了——我们确实做出来了,也在生产环境跑着。但这一路踩的坑、推翻的设计、重构的代码,远比当初想象的多得多。SDK 的抽象在真实场景下会崩溃,缓存策略需要精细控制,强化反馈比预想的重要,失败隔离是个大问题,模型选择依然很玄学。
这篇文章不是教程,也不是最佳实践指南。我只是想把这一年的经验教训记录下来,给同样在做 Agent 的人一些参考。如果你也在这条路上,可能会有共鸣。
核心观点速览:Agent 开发依然混乱。SDK 抽象在真实工具调用场景下会失效。缓存在自己管理时效果更好,但不同模型差异很大。强化反馈的作用超出预期,失败需要严格隔离以免拖垮整个循环。通过类文件系统的共享状态层是重要的基础设施。输出工具出乎意料地棘手,模型选择仍然取决于具体任务。
该用哪个 SDK?一个艰难的选择
当你开始构建自己的 Agent 时,第一个问题就是:用哪个 SDK?
市面上的选择不少:OpenAI SDK、Anthropic SDK,或者更高层的抽象,比如 Vercel AI SDK、Pydantic AI。我们当时选择了 Vercel AI SDK,但只用了它的 provider 抽象层,Agent 循环自己写。现在回头看,我们不会再这么选了。
不是说 Vercel AI SDK 不好——它确实很优秀。问题在于,当你真正开始构建 Agent 时,会遇到两个意想不到的挑战。
第一个挑战:模型之间的差异太大了
虽然表面上看,所有模型都是"输入 prompt,输出 response,中间可以调用工具",但实际使用中,差异大到你必须为每个模型单独设计抽象层。
缓存控制就是个典型例子。Anthropic 要求你手动标记缓存点,OpenAI 是自动的。工具提示的格式不一样,强化反馈的最佳实践不一样,甚至 provider 自带的工具(比如 Anthropic 的 Web Search)在不同 SDK 下的行为也不一样。
我们还没找到一个 SDK 能提供"正确"的 Agent 抽象。这可能是因为 Agent 的核心虽然只是个循环,但细节取决于你提供的工具。这些细节——缓存控制、强化反馈的时机、工具提示的写法、provider 自带工具的集成——会影响你需要什么样的抽象。而现在这个抽象还没有清晰的共识。
第二个挑战:provider 自带工具会破坏抽象
高层 SDK 试图统一不同 provider 的消息格式,但这在遇到 provider 自带工具时就不灵了。
举个例子:Anthropic 的 Web Search 工具在 Vercel SDK 下会经常破坏消息历史,我们到现在都没完全搞清楚原因。而且,Anthropic 的缓存管理在直接用他们自己的 SDK 时要清晰得多——你能精确控制缓存点,错误信息也更明确。用了抽象层之后,这些都变得模糊了。
所以我们现在的想法是:在 Agent 稳定下来之前,直接用各家的原生 SDK 可能更好。好处是你能完全掌控,坏处是要为每个 provider 写适配代码。但至少你知道自己在做什么,而不是在抽象层的黑盒里猜测。
当然,这可能会变。如果你已经找到了好用的抽象,请一定告诉我。我真的很想学习。
缓存的教训:手动控制的价值
不同平台的缓存策略差异很大。Anthropic 让你为缓存付费,而且必须手动标记缓存点。一开始我觉得这很蠢:为什么不自动帮我做?
但现在我完全改变了想法。手动缓存管理虽然麻烦,但它让成本和缓存利用率变得可预测。
自动缓存听起来很美好,但实际上是碰运气。你不知道什么会被缓存,什么不会。有时候缓存命中率很高,有时候莫名其妙就掉下来了。这对成本估算和性能优化都是噩梦。
手动缓存则不同。你可以做一些自动缓存做不到的事情:
分叉对话:你可以从同一个缓存点分出两条不同的对话路径,同时运行。这在需要并行探索多个方案时非常有用。
上下文编辑:你可以修改历史消息,同时保留部分缓存。虽然最优策略还不清楚,但至少你有这个选项。
成本可控:你能准确预测缓存会被利用多少次,从而估算成本。这对生产环境的 Agent 来说至关重要。
我们在 Anthropic 上的缓存策略很简单:
- 第一个缓存点:放在系统提示之后
- 第二个缓存点:放在对话开头,随着对话尾部向上移动
- 动态优化:根据对话长度调整缓存点位置
因为系统提示和工具定义现在必须是静态的(否则会破坏缓存),我们把动态信息(比如当前时间)放在后面的消息里注入。这样就不会破坏缓存了。
这也带来了另一个变化:我们更多地依赖强化反馈。
强化反馈:比想象中重要得多
每次 Agent 调用工具,你都有机会不只是返回工具的输出,还可以注入额外的信息。这就是强化反馈(reinforcement)。
一开始我们没太重视这个。后来发现,强化反馈是驱动 Agent 前进的关键机制。
你可以用强化反馈做很多事情:
提醒总体目标:Agent 在执行了很多步骤后,可能会忘记最初的目标。在每次工具调用后提醒一下,能让它保持方向感。
报告任务状态:告诉 Agent 哪些子任务完成了,哪些还在进行中。这对复杂任务特别有用。
提供失败提示:当工具调用失败时,不只是返回错误信息,还可以提示可能的解决方案。比如"这个 API 调用失败了,可能是因为参数格式不对,试试用 JSON 格式"。
通知状态变化:如果你的 Agent 有并行处理能力,可以在工具调用后注入"后台任务 X 已完成"这样的信息。
有时候,Agent 甚至可以自我强化。在 Claude Code 中,我们有个 todo 工具,它其实就是个回声工具——Agent 把它认为应该做的任务列表发给这个工具,工具原样返回。听起来没用,对吧?
但这个工具的作用是让 Agent 明确自己的计划。把计划说出来,比只在脑子里想要有效得多。这就像你写代码前先写注释,虽然注释本身不执行任何逻辑,但它帮你理清思路。
我们还用强化反馈来处理环境变化。比如 Agent 从某个步骤重试,但重试时的数据已经损坏了,我们会注入一条消息:"注意,当前数据可能有问题,建议回退几步重新执行"。
隔离失败:别让错误拖垮整个循环
如果你的 Agent 会执行代码,那失败是常态。编译错误、运行时错误、测试失败……这些都会产生大量的错误信息。
问题是:这些失败信息会污染上下文。
想象一下:Agent 尝试了 10 种方法,前 9 种都失败了,每次失败都产生几百行错误日志。到第 10 次尝试时,上下文里已经塞满了无用的错误信息,真正有用的信息反而被挤出去了。
有两种方法可以隔离失败:
方法一:用子 Agent
把可能需要多次迭代的任务交给子 Agent。子 Agent 在自己的上下文里反复尝试,直到成功。然后只把成功的结果(加上简短的失败总结)报告给主 Agent。
这样主 Agent 的上下文就保持干净。它知道"子任务 X 尝试了 A、B、C 三种方法,最后 C 成功了",但不需要看到 A 和 B 的详细错误日志。
方法二:上下文编辑
Anthropic 支持编辑历史消息。理论上,你可以把那些没有贡献的失败尝试从上下文中删除,只保留有价值的信息。
但这有个问题:上下文编辑会破坏缓存。而且很难判断哪些失败是"有价值的"(可以帮助 Agent 避免类似错误),哪些是"无价值的"(纯粹的噪音)。
我们还在探索这个方向。如果你有成功经验,请分享给我。
共享状态:文件系统是个好抽象
我们的 Agent 主要做代码生成和执行。这意味着不同的工具需要共享数据:
- 代码执行工具生成了一个图片
- 推理工具需要分析这个图片
- 然后代码执行工具要把图片打包成 zip
如果这些工具之间没有共享状态,就会出现死胡同。比如图片生成工具只能把图片传给特定的分析工具,但不能传给打包工具。
解决方案是:给所有工具提供一个共享的文件系统。
在我们的实现中,这是个虚拟文件系统。所有工具都可以读写这个文件系统:
ExecuteCode工具可以写文件RunInference工具可以读文件GenerateImage工具可以写文件PackageFiles工具可以读文件
这样就没有死胡同了。任何工具的输出都可以被任何其他工具使用,只要它们都支持文件路径作为参数。
这个设计看似简单,但它是整个 Agent 系统的基础。没有共享状态层,工具之间的协作会变得非常困难。
输出工具:一个意外的难题
我们的 Agent 不是聊天机器人。它在后台执行任务,最后通过一个专门的"输出工具"向用户发送结果(在我们的场景里是发邮件)。
听起来很简单,对吧?但实际上,让 Agent 用对输出工具,比想象中难得多。
问题一:语气和措辞很难控制
当 Agent 直接在对话中输出文本时,语气和措辞通常还不错。但当它调用一个专门的输出工具时,语气就变得很奇怪。
我们试过用另一个快速模型(比如 Gemini 2.5 Flash)来调整语气,但这增加了延迟,而且效果并不好。子模型没有足够的上下文,有时候会改错,有时候会泄露不该泄露的信息(比如中间步骤的细节)。
问题二:Agent 有时候就是不调用输出工具
更糟糕的是,有时候 Agent 完成了任务,但就是不调用输出工具。它觉得任务完成了,循环就结束了,但用户什么都没收到。
我们的解决方案是:在循环结束时检查输出工具是否被调用。如果没有,就注入一条强化消息:"任务已完成,请使用输出工具向用户报告结果"。
这不是完美的解决方案,但至少能保证用户不会被晾在那里。
模型选择:还是那几个老面孔
过去一年,模型选择的格局没有太大变化。
对于 Agent 主循环:Anthropic 的 Haiku 和 Sonnet 依然是最好的工具调用者。它们的工具调用准确率高,而且对强化学习的响应很透明——你能比较清楚地预测它们会怎么反应。
Gemini 系列也不错。但我们在 GPT 系列上没有太多成功经验,至少在主循环里是这样。
对于子工具:如果需要处理大文档、PDF、图片,Gemini 2.5 是个好选择。它的上下文窗口大,对图片的理解也不错。
但 Gemini 有个问题:它比较容易触发安全过滤器。Sonnet 系列在这方面要宽容一些。
关于成本:不要只看单次调用的 token 成本。一个更好的工具调用者,虽然单价可能高一点,但它能用更少的轮次完成任务,总成本反而更低。
我们发现,Sonnet 虽然比一些便宜的模型贵,但在 Agent 循环里,它的总成本往往更低,因为它能更快地完成任务。
测试和评估:最难的部分
坦白说,这是我们最头疼的问题。
测试和评估 Agent 比测试普通代码难太多了。你不能只是写几个单元测试就完事——Agent 的行为是动态的,依赖于上下文、工具调用的结果、甚至模型的随机性。
而且,你不能在外部系统里做评估,因为需要注入的东西太多了:工具定义、环境状态、历史上下文……这意味着你必须基于可观测性数据或者在真实运行中做评估。
我们试过几个解决方案,但都不太满意。这是我们现在最想解决的问题。
如果你有好的 Agent 测试和评估方案,请一定告诉我。
关于编码 Agent 的一些更新
最后说说我最近在用的编码 Agent。
我现在在试用 Amp。不是因为它客观上比我之前用的更好,而是因为我很喜欢他们的设计思路。他们的子 Agent 系统(比如 Oracle)和主循环的交互方式很优雅,这在其他 Agent 里不常见。
而且,Amp 和 Claude Code 一样,都给人一种"开发者在用自己的工具"的感觉。不是每个 Agent 产品都有这种感觉。
一些值得一读的东西
最后分享几篇我最近读到的有意思的文章:
《你可能根本不需要 MCP》:Mario 认为很多 MCP 服务器过度设计了,包含了大量工具,消耗了太多上下文。他提出了一个极简方案:对于浏览器 Agent,只需要几个简单的 CLI 工具(启动、导航、执行 JS、截图),通过 Bash 调用就够了。这样 token 消耗少,工作流也更灵活。我基于这个思路做了一个 Claude/Amp Skill。
《"小型"开源的命运》:作者认为,那种小而美的单一用途开源库的时代正在结束。因为现在平台内置的 API 和 AI 工具可以按需生成这些简单的工具函数。谢天谢地。
Tmux 是个好东西:没有配套文章,但简单说就是:Tmux 很棒。如果你的 Agent 需要和交互式系统打交道,给它一些 Tmux 技能会很有用。
《LLM API 是个同步问题》:这是我单独写的一篇文章,因为这个话题太长了,放不进这篇里。
结语
一年前,我们以为 Agent 就是个简单的循环。现在我们知道,魔鬼在细节里。
SDK 抽象、缓存策略、强化反馈、失败隔离、共享状态、输出控制、模型选择、测试评估……每一个环节都有坑,每一个坑都需要时间去填。
但这也是有趣的地方。我们正在探索一个新的领域,没有标准答案,没有最佳实践,只有不断的尝试和学习。
如果你也在做 Agent,希望这篇文章能给你一些启发。如果你有不同的经验,或者找到了更好的解决方案,请一定告诉我。我们都在学习。
标签:AI, Agent, 工程实践
© 版权所有 2026
本文采用知识共享署名-非商业性使用-相同方式共享许可协议。