跳到主要内容

为什么你的智能体应该编写代码,而不是 JSON

· 阅读需 12 分钟
Tian Pan
Software Engineer

大多数 Agent 框架都默认采用同一种动作模型:LLM 输出一个 JSON 块,宿主系统对其进行解析,调用工具,然后返回结果。如此循环。这种方式整洁、可审计,且几乎被普遍使用——而这恰恰是问题所在。对于超出单一工具调用的任何场景,这种架构都会迫使你编写脚手架代码来解决 Agent 本可以自行解决的问题——前提是如果允许它编写代码。

还有另一种方法:给 Agent 一个 Python 解释器,让它输出可执行代码作为其动作。一项已发布的基准测试显示,与 JSON 工具调用相比,其 任务成功率高出 20%。内部基准测试显示,平均 LLM 往返次数减少了 30%。一个围绕这一理念构建的框架在发布后不久便登顶 GAIA 排行榜榜首(验证集准确率为 44.2%)。权衡在于执行环境更加复杂——但所需的工程量是可控的,而且带来的行为增益是实实在在的。

JSON 工具调用的结构性限制

考虑一个任务:Agent 需要同时搜索餐饮方案和派对主题,然后合并结果。在 JSON 工具调用的 Agent 中,这是两次独立的 LLM 调用,两次往返:

// 步骤 1
{"name": "web_search", "arguments": "Best catering services in Gotham City"}
// 步骤 2(在观察步骤 1 之后)
{"name": "web_search", "arguments": "Party theme ideas for superheroes"}

代码动作 Agent 将此压缩为一个步骤:

results = {}
for query in ["Best catering services in Gotham City", "Party theme ideas for superheroes"]:
results[query] = web_search(query)
print(results)

即使操作是相互独立的,JSON 方法也会强制采用顺序的、逐次调用的结构。这是一个根本性的约束,而非实现细节。JSON 无法表达循环、条件判断、变量赋值,或者将输出作为一等公民在工具调用之间传递。代码则可以。

这在实践中非常重要。如果工具返回一个图像张量、一个 pandas DataFrame 或任何不透明对象,JSON 无法在下一个动作中引用该输出——你需要框架对其进行序列化和反序列化,这对于复杂类型来说会失效。而代码则将其存储在变量中。如果你需要使用不同的输入运行同一个工具十几次并汇总结果,JSON 需要十次 LLM 往返。代码一次就能搞定。

基准测试说明了什么

代码动作的实证案例主要不在于直觉——它是可衡量的。

最严谨的证据来自 2024 年发表在 ICML 上的一篇论文,该论文引入了 CodeAct 框架,并在多个基准测试中将其与 JSON 和基于文本的工具调用进行了对比。CodeAct 实现了 高达 20% 的任务成功率提升,这并非通过使用更好的 LLM,而是通过将动作表示从 JSON 更改为 Python 实现的。

另一个基于相同原理构建的框架对数百个基准测试任务进行了自己的分析。结论是:在相同的模型上,CodeAgent 使用的 步骤始终比同等的 ToolCallingAgent 少约 30%。更少的步骤意味着更少的 LLM API 调用、更低的延迟和更低的成本——这并不是因为任务变简单了,而是因为代码可以批量处理 JSON 强制序列化的内容。

第三个数据点:一个基于代码动作原则构建的系统在 GAIA 验证集排行榜上排名第一 (44.2%),在测试集上排名第二。GAIA 的问题旨在要求多步工具使用,紧密追踪现实世界的助手任务。代码动作方法跨过了这一门槛,包括大多数系统完全失败的 Level 3(最难)问题。

性能优势的存在是因为 LLM 在 Python 上经过了极其出色的训练。代码是它们在预训练中看到最多的内容。要求 LLM 编写 JSON 动作模式是要求它使用一种为机器解析优化的格式,而不是为了表达 LLM 自身的推理逻辑。相比之下,代码是思维过程的自然延伸——更接近模型内部表示计算的方式。

实践中的执行循环

代码动作 Agent 的循环遵循“思考 → 代码 → 观察”(Thought → Code → Observation, TCO)循环,而不是 JSON Agent 标准的“思考 → 动作 → 观察”(Thought → Action → Observation, TAO)。

在实践中:

  1. LLM 接收任务和上下文,进行推理,并输出一个 Python 代码块。
  2. 执行器在受控环境中运行代码。
  3. 标准输出 (stdout)、返回值和任何错误作为观察结果返回。
  4. LLM 处理观察结果并决定是输出更多代码还是返回最终答案。

观察结果非常重要。错误也会返回——回溯信息 (tracebacks)、类型错误 (type errors)、属性错误 (AttributeErrors)。这意味着 Agent 可以进行自我调试。如果 search_tool("query") 返回了一个具有意外模式的对象,Agent 可以检查它、调整代码并重试。JSON 工具调用 Agent 会无声失败或直接报错;代码 Agent 则会进行迭代。

一个实际问题:解析错误。如果 LLM 生成了无效的 Python(未闭合的括号、错误的缩进),执行器在运行任何一行代码之前就会失败。对超过 15,000 个 Agent 追踪记录的分析显示,第一次调用的解析错误会使整体成功率下降超过 21 个百分点,并将平均步数从 3.18 提高到 4.63。解决方法是 结构化输出:将代码块封装在 JSON 外壳中,这样即使内部代码存在语法问题,LLM 的完整输出始终可以作为 JSON 解析。这让框架可以干净地提取并重试代码块。对于性能强大的模型,这平均能带来 2–7 个百分点的提升。

警告:较小的模型(约 32B 参数以下)难以承受双重格式化的压力——即同时产生有效的 JSON 结构、有效的 Python 语法和正确的任务推理。对于小模型,非结构化代码输出的表现通常优于结构化代码输出,因为消除一个格式约束可以为解决实际问题释放容量。

安全是难点

让 LLM 编写并执行任意代码会产生真实的攻击面。威胁模型分为四个层级:

LLM 错误 是最良性的——模型生成了一个填满磁盘空间的循环,或者无意中调用了 os.remove()。虽然少见,但确实发生过。

供应链劫持 指的是被污染的模型权重系统性地生成恶意代码。对于大多数部署来说这还停留在理论层面,但在受监管的环境中值得注意。

提示词注入 是最实际的威胁。一个浏览网页的智能体(Agent)会阅读页面,其中一些页面可能包含嵌入在内容中的对抗性指令。这些指令被添加到智能体的上下文(Context)中,并可能影响后续的代码生成——指示智能体窃取数据、调用异常端点或采取破坏性行动。具有代码执行能力的智能体比仅具有检索能力的智能体要危险得多。

对抗性用户 对任何面向公众的智能体都很重要——用户专门构建输入来利用代码执行漏洞。

朴素的防御方案是使用基于 AST 的解释器,而不是原始的 exec()。解释器不直接运行代码,而是将其解析为抽象语法树(Abstract Syntax Tree)并逐个操作进行遍历,强制执行导入白名单、阻止白名单之外的子模块访问,并限制总操作数以终止无限循环。这可以在不阻碍合法使用已批准库的情况下,防止最直接的攻击。

但 AST 解释器并不是沙箱。即使是完全允许的包也可以被利用来耗尽资源。对于暴露在任何外部输入下的生产环境部署,正确的隔离级别是 microVM 或更高级别:

  • 基于 Firecracker 的虚拟机 提供强大的内核级隔离,冷启动时间约为 150 毫秒。
  • 基于休眠的 microVM 通过在调用之间持久化热状态,可以实现低于 25 毫秒的启动时间——适用于对延迟敏感的用户路径。
  • gVisor 在智能体代码和宿主机之间介入了一个用户空间内核,以亚秒级的启动速度捕捉大多数逃逸向量。
  • 仅靠 Docker 是不够的,无法应对对抗性工作负载。共享内核容器存在已知的逃逸路径;请仅将其用于可信的内部工作流。

一个使沙箱化变得复杂的架构决策是:多智能体系统。如果你只沙箱化代码执行(方案 1),那么在该沙箱内运行的受管子智能体将缺乏对模型 API 凭据的访问权限,无法进行 LLM 调用。如果你沙箱化整个智能体系统(方案 2),你解决了凭据问题,但必须在沙箱内显式配置 API 密钥,这增加了运维复杂度。目前没有完美的解决方案——请根据你的威胁模型进行选择。

规模参考:AI 智能体的代码执行沙箱从 2024 年初的每月数万次会话增长到 2025 年初的每月数千万次,并在企业中得到了广泛应用。这现在是一个已解决的基础设施问题——沙箱服务已经存在并经过了生产验证。工程问题在于你的用例需要哪种隔离级别。

何时不应使用代码操作

代码操作智能体并非在所有情况下都更好。它的失败模式与 JSON 工具调用不同,但并非没有失败模式。

对于简单的单工具工作流,JSON 方法更容易审计、调试并向非工程师解释。如果你的智能体只做一件事——搜索、检索、分类——代码机制会增加开销而无益处。

对于小模型,生成有效的 Python 加上有效的 JSON 以及正确的推理所带来的结构性消耗通常太高了。在投入使用前请进行实证测试。在小模型上,ToolCallingAgent 的表现可能会优于同一模型上的 CodeAgent。

对于整个任务图已预先确定的工作流,你根本不需要智能体。智能体会引入非确定性、延迟波动和成本波动。如果你可以将任务表达为固定的流水线,那就这样做。请将智能体保留给那些动作序列确实无法预先确定的任务。

上下文窗口饱和 是代码操作循环中的一个复合问题。每一步都会将观察结果(工具输出、代码跟踪、错误消息)附加到上下文中。运行时间过长会填满窗口,淹没相关的早期上下文。这并非代码智能体所特有,但代码智能体往往运行时间更长并产生冗长的观察结果。生产环境部署需要显式的上下文管理(总结、选择性保留),而大多数框架在这方面的默认设置都很糟糕。

为生产环境构建

一些在实践中至关重要的设计决策:

设置显式的步骤上限并优雅地失败。 失控循环是最常见的生产环境故障模式。在编排层设置 max_iterations,并将限制作为一等退出条件(First-class exit condition)处理,而不是作为异常。

记录完整的执行跟踪。 代码操作循环比 JSON 工具调用更难调试,因为动作空间是不受限的。完整的跟踪——包括生成的代码、返回的观察结果以及模型的推理过程——对于事后诊断故障至关重要。

仔细考虑要暴露哪些工具。 代码智能体可以使用其执行环境中可用的任何 Python 库,以及你显式提供的任何工具。提供过多的工具会给上下文增加噪音。提供过少的工具会迫使智能体从头开始编写所有内容。正确的集合是针对特定任务的,但应倾向于提供更少、文档更齐全的工具,而不是更多。

在实际的任务分布上测试这两种智能体类型。 基准测试显示代码智能体平均胜出,但平均值掩盖了特定任务的差异。你的特定工作负载可能比 GAIA 更简单、更统一。在选择架构之前,请先根据你的数据进行衡量。

代码操作模型并不是一个可以应用到任何地方的通用升级。这是一场结构性的豪赌:赌你需要的动作空间用 Python 表达比预定义模式(Schema)更好。对于具有动态工具组合需求的复杂多步任务,这场赌注会以可衡量的速率获得回报。对于其他一切,请保持简单。

References:Let's stay in touch and Follow me for more thoughts and updates