07 · 代码即行动:把动作写成代码
前置:01 ReAct。本篇是对 01 的一次替换 —— 同一个循环,换掉「动作」的表达方式。
关于名字:这个范式学术上叫 CodeAct(出自 arXiv:2402.01030),但工业界几乎不用这个词 —— HuggingFace 叫 Code Agents,Anthropic 叫 code execution with MCP,Cloudflare 叫 Code Mode,各家一个叫法。
所以你很可能见过实物却没听过名字:Claude Code 主要靠 bash 干活、Manus 在沙箱里写脚本,走的都是这条路。
一、问题:JSON 表达不了组合
01 里模型的动作长这样:
{"name": "bash", "input": {"command": "find . -name '*.py'"}}
一次一个工具,一个参数字典。现在给它一个稍复杂的任务:
「在这三个网站上分别搜同一个关键词,把结果去重后取前十条。」
用 JSON 工具调用,模型要跑至少 5 轮:搜 A → 搜 B → 搜 C → 拿到三份结果 → 再想办法合并。而「去重取前十」这件事,JSON 根本没法表达 —— 模型只能把三份原始结果全部读进上下文,靠自己心算。
同样的事写成代码是一轮:
JSON 动作:5 轮,且「去重取前十」无处安放
代码动作:1 轮,合并逻辑由解释器执行
results = []
for url in ["a.com", "b.com", "c.com"]:
results += web_search(url, "关键词")
final = sorted(set(results))[:10]
print(final)
差别不在于代码更短,而在于JSON 缺三样东西:
| 缺什么 | 具体表现 |
|---|---|
| 组合 | 你没法把一个 JSON 动作嵌套进另一个,也没法定义一组动作复用 |
| 对象管理 | generate_image 返回一张图,JSON 怎么存住它给下一步用? |
| 控制流 | 循环、条件、异常处理,JSON 里全都没有 |
而这三样恰恰是编程语言被发明出来要解决的事。smolagents 官方文档把这个论点说得最直白:
我们的 Agent 要写程序来解决用户的问题:你觉得用 Python 写更容易,还是用 JSON 写更容易?
二、CodeAct 是什么
同一个 ReAct 循环,只换动作的表达形式:
| 01 ReAct(ToolCalling) | CodeAct | |
|---|---|---|
| 模型输 出 | 结构化的 tool_use 块 | 一段 Python 代码 |
| 谁解析 | API 直接给你结构化对象 | 你要从自由文本里抠出代码块 |
| 谁执行 | 你的 if name == "bash" 分发 | Python 解释器 |
| Observation | 工具的返回值 | 代码的 stdout |
| 一轮能做几件事 | 一次一个(或并行几个独立的) | 任意组合,含循环和条件 |
论文依据是 Wang 等的 Executable Code Actions Elicit Better LLM Agents(arXiv:2402.01030):同样的任务,代码动作比 JSON 动作少用约 30% 的步数 —— 步数少即模型调用次数少。

图 7-1 代码动作与 JSON 动作的对比(出自 arXiv:2402.01030)
图片来源:smolagents 官方文档(Apache-2.0)
它不是「让 Agent 帮你写代码」。 那是任务内容;CodeAct 说的是动作的编码格式。一个做数据分析的 CodeAct Agent,写的代码是它自己的动作,用户根本看不到。
三、源码:smolagents 的 CodeAgent
huggingface/smolagents(28,875★,Apache-2.0)的 README 第一条特性就是「First-class support for Code Agents」。agents.py 里 CodeAgent 和 ToolCallingAgent 是并列的两个类,共享同一个 MultiStepAgent 基类 —— 这正说明两者只差在动作格式上。
3.1 一轮里发生了什么
### 解析输出 ###
code_action = parse_code_blobs(output_text, self.code_block_tags)
code_action = fix_final_answer_code(code_action)
### 执行动作 ###
code_output = self.python_executor(code_action)
observation = "Execution logs:\n" + code_output.logs
observation += "Last output from code snippet:\n" + truncate_content(str(code_output.output))
四行里有三个关键点:
① parse_code_blobs —— 解析责任转移到了你身上
ReAct 里 API 直接返回结构化的 tool_use 块,格式由协议保证。CodeAct 里模型吐的是自由文本,你得自己从 ```py ... ``` 里把代码抠出来。这是 CodeAct 多出来的第一份工程成本。
② observation = "Execution logs:" + logs —— stdout 就是 Observation
这是和 01 最本质的差别。ReAct 的 Observation 是工具返回值;CodeAct 的 Observation 是代码打印出来的东西。
推论很实际:模型必须记得 print(),否则它什么都看不到。 代码正确执行但忘了打印,这一轮就白跑了。所以 CodeAct 的系统提示词里必然有一条「记得把要观察的结果打印出来」。
注意它还额外带回了 code_output.output(最后一个表达式的值),这是给忘了 print 的情况留的兜底。
③ 一个诱导模型停止生成的小技巧
# 把结束标签补进历史,诱导后续调用也以它结尾,从而高效地停止生成
if output_text and not output_text.strip().endswith(self.code_block_tags[1]):
output_text += self.code_block_tags[1]
模型没写收尾的 ``` 时,框架替它补上再存进历史。下一轮模型看到历史里每段代码都以它结尾,就会照做 —— 用历史的格式一致性来控制生成的停止位置,比调 stop sequence 更省事。
3.2 安全:白名单,不是黑名单
回想 01 的 6.5 节,玩具版用的是关键词黑名单,rm -rf /* 就能绕过。CodeAct 因为直接跑 Python,风险面更大,所以做法完全不同:
self.authorized_imports = sorted(set(BASE_BUILTIN_MODULES) | set(self.additional_authorized_imports))
能 import 什么 是白名单,默认只有一组基础模块。 想让 Agent 用 pandas,你得显式加进 additional_authorized_imports。
而且失败时给的是可操作的错误 —— 又一次印证 08 的 4.1 条:
if "Import of " in error_msg and " is not allowed" in error_msg:
self.logger.log(
"Warning to user: Code execution failed due to an unauthorized import - "
"Consider passing said import under `additional_authorized_imports` ...")
传 "*" 可以放开全部,框架会打一条警告日志。别在生产里这么干。
沙箱是一等公民参数,不是事后补的:
executor_type: Literal["local", "blaxel", "e2b", "modal", "docker"] = "local"
默认 local 只适合你自己机器上跑着玩。只要代码来源是模型,生产就必须换成远程沙箱或 Docker。 这也是为什么 CodeAgent 实现了 __enter__/__exit__/cleanup —— 远程执行器用完要回收。
3.3 变量在轮次之间存活
self.state.update(additional_args)
self.python_executor.send_variables(variables=self.state)
这是 CodeAct 独有、ReAct 拿不到的 能力:第 1 轮定义的变量,第 5 轮还能直接用。
# 第 1 轮
df = pd.read_csv("data.csv") # 十万行,留在解释器内存里
print(df.shape) # Observation 只有 "(100000, 12)"
# 第 4 轮
print(df.groupby("city").size()) # 直接用,不必重新读盘、更不必进上下文
大对象留在解释器里,只有你 print 的摘要进上下文。 这是第一节说的「对象管理」在实现层面的样子,也是 CodeAct 在数据分析类任务上优势明显的原因。