本节摘要:本节论证全书最核心的命题:为什么把 action 写成 Python 代码,比业界主流的 JSON 工具调用少约 30% 步。README 的原话是"Writing actions as code snippets is demonstrated to work better than the current industry practice of letting the LLM output a dictionary of the tools it wants to call: uses 30% fewer steps (thus 30% fewer LLM calls) and reaches higher performance on difficult benchmarks"——出处是 CodeAct 论文《Executable Code Actions Elicit Better LLM Agents》(arXiv 2402.01030)与《CodeAgent》(arXiv 2411.01747)。本节把论文结论拆成四个本质优势(组合函数/变量复用/控制流/预训练分布),盘点 JSON 范式的四个痛点,给出两类 agent 的完整对照表,并用同一个任务跑两种范式的轨迹对比,让你"看见"那 30% 省在哪。
内容来源:原项目
README.md(结论引用)、docs/source/en/conceptual_guides/intro_agents.md(Code agents 一节)、src/smolagents/prompts/code_agent.yaml与toolcalling_agent.yaml(两范式的 few-shot 示例,本节用作轨迹素材)。
⚠️ 注意:"少 30% 步"是论文在特定基准(如 GAIA 类多步任务)上的统计结论,不是物理定律。对单步问答、或工具极少且互不依赖的任务,两种范式步数差异很小;优势随"任务需要的组合与中间状态数量"放大。本节第六节会专门讨论收益的边界与代价——高潮章节更要把话说全。
阅读完本节,你应当能够:
论文结论的原话(README 第 239 行引用):代码 action 相比"让 LLM 输出想调用的工具字典"的业界现行做法,uses 30% fewer steps (thus 30% fewer LLM calls) 且 reaches higher performance on difficult benchmarks。两篇依据:
官方文档 intro_agents.md 的论证起点很朴素:"我们专门为表达计算机要执行的动作而设计了编程语言——agent 要写程序来解决问题,你觉得是写 Python 块容易还是写 JSON 容易?"文档列出四个代码优势(官方用词是 Composability/Object management/Generality/Representation in LLM training data),本节把它们整理为更工程化的四条,并逐一给出机制解释。
"少 30% 步"为什么重要:agent 的成本与延迟近似等于 步数 × (LLM 调用 + 动作执行)。每一步都要把全部记忆重新发给 LLM(上下文越长越贵),所以省一步省的是一整次 LLM 调用;更少的步数还意味着更短的错误暴露面——每一步都是一次"LLM 可能跑偏"的机会。步数下降 30%,大致对应 token 成本与端到端延迟同比例下降,而成功率反而上升:少出错的机会,多纠错的抓手。
先给出本章的论证路线图,四个优势不是并列的口号,而是层层递进的因果链:组合与变量消除了"轮次"(第一、二条),控制流消除了"循环展开"(第三条),分布内形式降低了"每轮的失败率"(第四条)——前三条砍步数,最后一条提成功率,合起来才是论文看到的"又快又好"。
JSON 范式一步只能表达"N 个互相独立的工具调用"(并行执行的那些);一旦 B 依赖 A 的输出,必须拆成两轮:先调 A、观察、再调 B。代码里依赖关系就是表达式嵌套:
# CodeAgent:一步,两个搜索 + 比较 + 出答案 a = web_search("population Guangzhou") b = web_search("population Shanghai") final_answer("Shanghai" if parse(b) > parse(a) else "Guangzhou")
# ToolCallingAgent:同样的任务,至少四轮 Action: {"name": "web_search", "arguments": {"query": "population Guangzhou"}} Observation: ['Guangzhou has a population of 15 million...'] Action: {"name": "web_search", "arguments": {"query": "population Shanghai"}} Observation: '26 million (2019)' Action: {"name": "final_answer", "arguments": "Shanghai"}
注意上面 CodeAgent 的例子正来自 code_agent.yaml 的 few-shot(city population 任务):官方提示词示例本身就在示范"循环 + 组合一步做完"。而 toolcalling_agent.yaml 里同一个任务的示例,如实地用了三轮——两份模板对同一任务的不同写法,就是两范式差距的最直观证据。
为什么 JSON 范式做不到?看它的数据流:Action 与 Observation 之间的通道是字符串文本——B 需要 A 的输出,就必须等 A 的 Observation 回到 LLM 上下文、由 LLM 在下一步把值"抄"进新 Action 的 arguments。这个"回 LLM→抄写→再下发"的往返,就是每次组合的固定开销。代码范式里 A 的输出是内存中的对象,B 直接拿变量引用,往返次数为零。ToolCallingAgent 唯一的"并行"机会是互不依赖的同批调用(ThreadPoolExecutor 那条路径),依赖一旦出现,立刻退化为串行多轮。
上节源码精读看到:CodeAgent 的解释器 state 跨步持久。第一步 pages = web_search(...) 之后,pages 这个 Python 对象(字符串/列表/dataFrame/图像)一直在命名空间里,后续步骤直接引用变量名。
JSON 范式没有变量:第 N 步要用第 2 步的结果,只能把它的字符串形式塞进第 N 步的 arguments(或靠 LLM 在上下文里"抄写")。三个后果:①大结果(整页 HTML/大表格)每轮重复传输,上下文膨胀;②结构化数据退化成字符串,后处理要 LLM 重新解析;③ToolCallingAgent 为此专门写了 _substitute_state_variables(第 2 章读过的 agents.py:1444)做"文件名字符串 → state 里的真实对象"替换——这是在 JSON 协议上打补丁模拟变量机制,而代码范式里它免费。
官方文档还点出一个相关优势 Object management(对象管理):JSON 里怎么"存"一个 generate_image 的返回?只能退化成文件名字符串 "image.png" 再指望框架找回对象;代码里 image = image_generator(...) 之后,image 就是那个对象,image.size、把它传给下一个工具、存进列表,都是正常 Python 语义。多模态任务(图像→编辑→再理解)在代码范式里是自然的数据流,在 JSON 范式里是反复的"序列化碰运气"。
"对这 10 个 URL 逐个抓取摘要再汇总"这类任务:代码范式一个 for 循环 + 列表推导,一步完成,循环次数 LLM 都不用预知;JSON 范式必须把循环展开成 N 轮"调用→观察→调用",每轮一次 LLM 调用,步数与元素数线性相关。条件分支同理:if len(results) == 0: 换查询重搜 在代码里是一行,JSON 里是"观察→LLM 重新决策→新动作"一整轮。code_agent.yaml 的 Ulam 访谈示例就示范了"搜索无果后换更宽泛查询"的自适应重试,这在代码里只是一次新的赋值。
一个数据处理任务最能体现这条优势的叠加效应——"抓 20 个网页,过滤出含关键词的,统计平均长度":
# CodeAgent:一步。循环+条件+聚合全部内联 lengths = [len(visit_webpage(u)) for u in urls if keyword in visit_webpage(u)] final_answer(sum(lengths) / len(lengths))
JSON 范式里这段逻辑没有任何一处能"打包":20 次访问(假设可并行的批次只有几个)、逐个判断(观察回 LLM 再决策)、求平均(还得再调 python_interpreter 或靠心算)——步数轻易上两位数。控制流是程序的骨架,JSON 协议里没有骨架的位置,于是骨架的每一节都要用一整轮对话来充当。
LLM 预训练语料里有海量 StackOverflow 风格的"调库解决问题"代码,而"输出 {"name":..., "arguments":...} 的 JSON blob"是人为发明的协议,每种框架还不一样(OpenAI 的 function calling、各家 ReAct 变体……)。让模型写代码,是让它做预训练里练过千万遍的事;让模型写工具 JSON,是让它现学一套方言。表现就是:代码格式错误率低、修复能力强(报错栈是模型熟悉的 Python traceback,下一轮自己就能改),JSON 格式错误则更依赖 parse_json_blob 这类兜底与"把纠错指令写进异常"的提示词工程。官方文档对此的表述是 "plenty of quality code actions are already included in LLMs' training data which means they're already trained for this"。
一个耐人寻味的细节藏在 toolcalling_agent.yaml 的 few-shot 里:教 ToolCallingAgent 算 5 + 3 + 1294.678 时,示例不得不引入一个名叫 python_interpreter 的工具,把代码塞进 JSON——
Action: { "name": "python_interpreter", "arguments": {"code": "5 + 3 + 1294.678"} }
JSON 范式一旦遇到真正的计算与逻辑,最终还是要借助代码——只不过把代码降格成了字符串参数。这从反面承认:代码才是表达"让计算机做事"的更基础语言,JSON 只是它的一种受限封装。CodeAgent 不过是把这层封装拆掉了。
truncate_content 的 2 万字符上限),截掉的信息 LLM 就永远看不见了。def helper(): ... 自造工具,这正是等级表 ★★★ 那行 "can define its own tools" 的含义)。parse_json_blob 直接抛 ValueError);代码哪怕第 10 行崩了,前 9 行的 print 与变量都已生效(上节源码 1735-1742 行的"崩溃抢救日志"),增量重试成本低——把第 10 行改成 try/except 重跑即可,前面的搜索结果不用重来。| 维度 | CodeAgent(代码即 action) | ToolCallingAgent(JSON 工具调用) |
|---|---|---|
| 动作格式 | ```python 代码块(默认 <code> 标签) |
{"name":..., "arguments":{...}} blob / 原生 tool call |
| 解析器 | parse_code_blobs(四级降级) |
parse_json_blob + 模型原生 tool calling |
| 执行方式 | AST 解释器执行代码(工具=命名空间函数) | 框架查表调度函数,可并行多调用 |
| 一步能做的事 | 任意多函数组合 + 变量 + 循环 + 条件 | 一批相互独立的工具调用 |
| 中间结果 | 变量持久化(state 跨步),零拷贝 | 字符串进上下文,或 state 替换补丁 |
| 典型步数 | 基准少约 30% | 基线 |
| 出错恢复 | traceback + 已执行前缀可增量重试 | 整步重试,靠异常文本提示纠错 |
| 对模型要求 | 会写 Python(分布内,几乎都会) | 原生 tool calling 质量(各模型参差) |
| 安全边界 | 需要代码执行器,必须考虑沙箱 | 不执行生成代码,天然无代码执行面 |
| 生态兼容 | 独立小生态 + Hub 工具 | 与 OpenAI function calling 生态兼容顺滑 |
| 适用场景 | 多步/多工具组合/数据处理/研究型任务 | 强 tool-calling 模型、禁执行代码环境、简单直连工具任务 |
任务:"查询三座城市的人口,找出最高的,并算出它与最低者的比值(保留两位小数)"。
ToolCallingAgent 轨迹(约 7 步):search 城 1 → 观察 → search 城 2 → 观察 → search 城 3 → 观察 →(LLM 心算比较与除法,数值精度存疑,或再调 python_interpreter 工具一步)→ final_answer。每步一次 LLM 调用,人口数字在上下文里以字符串形式流转。
CodeAgent 轨迹(约 2 步):
# 第 1 步 pops = {} for city in ["Guangzhou", "Shanghai", "Beijing"]: pops[city] = web_search(f"{city} population") print(city, pops[city]) # 第 2 步(拿到观察后) lo, hi = min(vals), max(vals) final_answer(round(hi / lo, 2))
三次搜索在一个 for 循环里完成(依赖消除),数值计算由解释器精确执行(不占步数),final_answer 收尾。两种范式的轨迹形状用一张图直观对照(每个方框 = 一次 LLM 调用):

步数 7 → 2 的缩减,不是巧合而是机制:凡是"任务内在的循环/依赖/计算",代码都把它从"LLM 决策轮次"搬进了"一轮内的一次执行"。这就是 30% 的来源——论文统计的是平均效应,机制就是你在这里亲眼看到的三处搬运:
还有一层隐藏收益:每次 LLM 调用都要重新"读题"(整段记忆进上下文),轮次越少,跑偏与遗忘的累积概率越低——所以步数下降的同时成功率反而上升,这正是论文"higher performance on difficult benchmarks"的来源。任务越难、工具越多、中间状态越复杂,四条优势的杠杆越长;这也解释了 CodeAct 论文"更强模型收益更大"的观察:模型代码能力越强,能搬进单轮的逻辑越多。
冷静的工程师还要看另一半账本——代码范式的优势不是免费的:
authorized_imports)、AST 解释器拦截危险属性/模块(第 6 章)、远程沙箱(E2B/Docker/Modal/Blaxel)。安全等级上去了,但心智负担也从"选对工具"升级为"管住一台机器"。parse_code_blobs 与崩溃抢救把代价压得较低)。JSON 范式反而对弱模型更"宽容"——输出空间小,格式错也不至于逻辑错。state 里,但 LLM 下一步"看不见"——这是为什么提示词规则反复强调 print 的重要性(下一节精读)。按任务类型归纳收益(经验判断,供选型参考):
| 任务类型 | 代码范式收益 | 原因 |
|---|---|---|
| 多实体同类操作(N 个 URL/城市/文件) | 极大 | 循环进代码,N 步变 1 步 |
| 工具链式依赖(A→B→C) | 大 | 依赖进表达式 |
| 数值计算/统计/格式化 | 大 | 计算进解释器,精确且零步数 |
| 多模态对象流转(图像→编辑→理解) | 大 | 对象不序列化 |
| 单步问答/单工具查询 | 小 | 无组合空间 |
| 工具极少且互不依赖 | 小 | 两种范式步数接近 |
这张表反过来读就是 ToolCallingAgent 的生存空间:任务简单、模型 tool-calling 强、环境不允许执行生成代码时,JSON 范式依旧是稳妥选择——smolagents 两个类并存,正是把决定权交还给你。
💡 阶梯要点:四个优势可压缩成一句:代码把"组合、状态、控制流"从 LLM 的多轮决策降维成单轮内的语法结构,且这种表达恰是 LLM 预训练的母语。JSON 范式不是不行,而是它把本该属于"程序"的复杂度错误地摊派给了"对话轮次"。代价要记牢:代码范式引入执行面,安全责任从"选对工具"升级为"管住一个解释器"。
下一节拆解 CodeAgent 的"大脑说明书"——
prompts/code_agent.yaml四段提示词模板:system_prompt 如何只靠 11 条规则约束 LLM 写出可执行代码,以及 smolagents 提示词工程著名的"克制"哲学。