《AI Agent 高级编程》4.2 smolagents:CodeAgent「代码即行动」重写子任务

smolagents 的 CodeAgent 让模型直接写 Python 代码来行动,而不是吐结构化工具调用。本节现场核对 1.26.0 的 API,用本地假模型驱动 CodeAgent 离线真跑,并实测 CodeAgent 与 ToolCallingAgent 在工具可见性上的差别。

4.2 smolagents:CodeAgent「代码即行动」重写子任务

4.1 的 OpenAI Agents SDK 让模型调工具:模型吐出一个结构化的 {"tool": ..., "args": ...},框架负责执行。这一节看一个反着来的框架——smolagents 的 CodeAgent 让模型写代码:模型输出一段 Python,框架执行这段代码,工具就是代码里能直接调用的函数。这条「代码即行动(Code as Action)」路线是本卷里最特别的一支,值得单独拆开。

本节把「框架的行动层」推进到可运行:核对 smolagents 1.26.0 的 API,写一个自定义 Model 子类做本地假模型,让 CodeAgent 完全离线真跑,并实测 CodeAgent 与 ToolCallingAgent 在工具可见性上的差别。全程零网络、零凭据。

4.2.1 代码即行动:与「工具调用」的根本差别

先讲清这个范式。传统工具调用(OpenAI 的 function calling、PydanticAI 的 ToolCallPart)里,模型的动作空间是离散的工具名,每次只能选一个、参数是扁平 JSON。smolagents 的 CodeAgent 把动作空间换成图灵完备的 Python,带来三个直接后果:

  • 一次能调多个工具:模型写 for q in queries: hits.append(search(q)),一次代码块完成多次检索,省掉了「调用 → 回灌 → 再调用」的多轮往返。
  • 能在本地做中间计算:过滤、聚合、条件分支都在代码里做,不用把中间结果塞回上下文再让模型决策。
  • 代价是安全:执行模型写的代码风险极高。smolagents 用 additional_authorized_imports 白名单 + 本地/远程执行器(executor_type)来收窄,但这仍是最需要警惕的范式。

对照卷一 minagent:minagent 是彻底的「工具调用」派——Message.tool_calls 是 list[ToolCall],模型每轮吐结构化的调用。CodeAgent 则连 tool_calls 这个概念都没有,它的输出就是一段代码文本。

先核对版本:

import importlib.metadata as m
print(m.version("smolagents"))
1.26.0

4.2.2 API 表面现场核对

看两个 Agent 的构造签名(inspect.signature 实测):

import inspect
from smolagents import CodeAgent, ToolCallingAgent, tool
print(inspect.signature(CodeAgent.__init__))
print(inspect.signature(ToolCallingAgent.__init__))
print(inspect.signature(tool))
(self, tools: list[smolagents.tools.Tool], model: smolagents.models.Model, prompt_templates: smolagents.agents.PromptTemplates | None = None, additional_authorized_imports: list[str] | None = None, planning_interval: int | None = None, executor: smolagents.local_python_executor.PythonExecutor = None, executor_type: Literal['local', 'blaxel', 'e2b', 'modal', 'docker'] = 'local', executor_kwargs: dict[str, Any] | None = None, max_print_outputs_length: int | None = None, stream_outputs: bool = False, use_structured_outputs_internally: bool = False, code_block_tags: str | tuple[str, str] | None = None, **kwargs)
(self, tools: list[smolagents.Tool], model: smolagents.models.Model, prompt_templates: smolagents.agents.PromptTemplates | None = None, planning_interval: int | None = None, stream_outputs: bool = False, max_tool_threads: int | None = None, **kwargs)
(tool_function: 'Callable') -> 'Tool'

从签名能读出两件事。第一,CodeAgent 有 executor_type(五档:local / blaxel / e2b / modal / docker)和 additional_authorized_imports——这是「代码即行动」范式的安全补丁;ToolCallingAgent 完全没有这些参数,因为它不执行代码。第二,CodeAgent 的 code_block_tags 默认是 ("<code>", "</code>")(读 __init__ 源码实测),也可以传 "markdown" 改成 ```python 围栏——这一点对写假模型至关重要:假模型必须按这个标签吐代码。

再核对模型基类的接口。Model 是所有模型的抽象基类:

import inspect, smolagents.models as M
print(inspect.signature(M.Model.generate))
print("abstract:", sorted(M.Model.__abstractmethods__))
(self, messages: list[smolagents.models.ChatMessage], stop_sequences: list[str] | None = None, response_format: dict[str, str] | None = None, tools_to_call_from: list[smolagents.tools.Tool] | None = None, **kwargs) -> smolagents.models.ChatMessage
abstract: []

__abstractmethods__ 是空集——这意味着 Model 不强制你实现任何方法,只要覆盖 generate() 就能造一个能跑的模型。这正是我们做本地假模型的入口。

4.2.3 本地假模型:让 CodeAgent 离线跑起来

红线上写得很清楚:smolagents 的示例必须用本地假模型驱动,不连真实 API。好在 Model 基类给了我们干净的切口。假模型的职责很简单:每次 generate() 被调用时,从脚本里取下一段代码,用 <code>...</code> 包起来返回。

from smolagents.models import Model, ChatMessage
from smolagents import CodeAgent, tool


class ScriptedModel(Model):
    """本地假模型:按脚本逐步吐出 <code> 块,零网络、零凭据。"""

    def __init__(self, script):
        super().__init__(model_id="scripted-local")
        self.script = list(script)
        self.calls = []          # (当时的消息条数, 可见工具名)
        self.n = 0

    def generate(self, messages, stop_sequences=None, response_format=None,
                 tools_to_call_from=None, **kwargs):
        self.calls.append((len(messages), [t.name for t in (tools_to_call_from or [])]))
        code = self.script[min(self.n, len(self.script) - 1)]
        self.n += 1
        return ChatMessage(
            role="assistant",
            content=f"Thought: 第 {self.n} 步\n<code>\n{code}\n</code>",
        )


@tool
def search(query: str) -> str:
    """检索本地知识库。
    Args:
        query: 查询词。
    """
    return f"[hit] {query} => 本地知识库命中 3 条"


model = ScriptedModel([
    'res = search("smolagents")\nprint(res)',
    'final_answer("结论:CodeAgent 把工具调用写成了 Python 代码。")',
])
agent = CodeAgent(tools=[search], model=model, max_steps=5)
out = agent.run("用 search 查 smolagents,再给出结论")

print("=== RESULT ===")
print(out)
print("=== model.calls(消息条数, 可见工具名)===")
for c in model.calls:
    print("  ", c)

这里有几个必须踩对的细节。第一,@tool 装饰器把普通函数变成 Tool,函数必须有类型标注和 docstring——smolagents 靠它们生成给模型的工具说明(search(query: str) 加 Args: 段是标准写法)。第二,final_answer 是 CodeAgent 内置的终止工具,代码里调用它就结束循环并返回结果,不需要你在 tools= 里声明。第三,脚本第二项直接是 final_answer(...)——在「代码即行动」范式里,「给最终答案」也是一行代码,而不是一个特殊的返回状态。

4.2.4 真跑输出与解读

上面这段代码在本机真跑(/tmp/agentbook/venv3/bin/python),输出如下(为节省版面去掉了 smolagents 的装饰性边框行,其余逐字照抄):

╭────────────────────────────────── New run ───────────────────────────────────╮
│ 用 search 查 smolagents,再给出结论                                          │
╰─ ScriptedModel - scripted-local ─────────────────────────────────────────────╯
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Step 1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ─────────────────────────────────────────────────────
  res = search("smolagents")
  print(res)
 ──────────────────────────────────────────────────────────────────────────────
Execution logs:
[hit] smolagents => 本地知识库命中 3 条

Out: None
[Step 1: Duration 0.02 seconds]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Step 2 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 ─ Executing parsed code: ─────────────────────────────────────────────────────
  final_answer("结论:CodeAgent 把工具调用写成了 Python 代码。")
 ──────────────────────────────────────────────────────────────────────────────
Final answer: 结论:CodeAgent 把工具调用写成了 Python 代码。
[Step 2: Duration 0.00 seconds]
=== RESULT ===
结论:CodeAgent 把工具调用写成了 Python 代码。
=== model.calls(消息条数, 可见工具名)===
   (2, [])
   (5, [])

输出里有几个点值得抠。第一,Step 1 的执行日志显示 Out: None——因为代码最后一行是 print(res),print 返回 None,而 res 的值是通过 stdout 被框架捕获成 Execution logs 的。这个细节说明 CodeAgent 是把标准输出当作观察(Observation)回灌的。第二,Step 2 的 final_answer(...) 被识别成终止动作,直接产出 Final answer。第三,model.calls 里两次调用的可见工具名都是空列表 []——这是个反直觉但极其重要的事实,下一节展开。

4.2.5 CodeAgent vs ToolCallingAgent:一个实测差异

model.calls 里的 (2, []) 和 (5, []) 说明:CodeAgent 调用模型时,tools_to_call_from 是空的。工具不是以 schema 形式传给模型的,而是被写进系统提示——模型在 prompt 里看到「你有这些函数可用」,然后在代码里直接调用。

对照 ToolCallingAgent 就看得更清楚。用一个会返回 ChatMessageToolCall 的假模型跑一遍,calls 里记录了工具可见性:

ToolCallingAgent 的 model.calls:
   (2, ['search', 'final_answer'])
   (5, ['search', 'final_answer'])

ToolCallingAgent 每次调用模型都显式传入了工具 schema(['search', 'final_answer']),模型必须吐结构化调用;CodeAgent 传的是空列表,模型靠 prompt 里的函数说明写代码。同一个框架、同一个 Model 基类,两种范式在「工具怎么抵达模型」这一步就分岔了。

维度CodeAgentToolCallingAgent
模型输出Python 代码文本结构化 tool_calls
工具传递写进系统提示tools_to_call_from 传 schema
单步能力一次代码块可调多个工具一次一个(或并行列表)
中间计算代码内完成必须回灌上下文
安全面需执行器隔离 + import 白名单无代码执行风险

这个差异决定了两者的适用场景:需要多步计算、聚合、条件分支的任务适合 CodeAgent;只需选工具、参数固定的任务适合 ToolCallingAgent(也更安全)。

4.2.6 执行器与授权导入:把安全面收窄

「代码即行动」的代价既然是把执行权交出去,就必须有一套收窄机制。smolagents 的本地执行器 LocalPythonExecutor 内置了多重防线(本机实测常量):

import smolagents.local_python_executor as E
print("DANGEROUS_MODULES", sorted(E.DANGEROUS_MODULES))
print("DANGEROUS_FUNCTIONS", sorted(E.DANGEROUS_FUNCTIONS))
print("MAX_OPERATIONS", E.MAX_OPERATIONS, "MAX_EXECUTION_TIME_SECONDS", E.MAX_EXECUTION_TIME_SECONDS)
print("BASE_BUILTIN_MODULES", sorted(E.BASE_BUILTIN_MODULES))
DANGEROUS_MODULES ['builtins', 'io', 'multiprocessing', 'os', 'pathlib', 'pty', 'shutil', 'socket', 'subprocess', 'sys']
DANGEROUS_FUNCTIONS ['builtins.__import__', 'builtins.compile', 'builtins.eval', 'builtins.exec', 'builtins.globals', 'builtins.locals', 'os.popen', 'os.system', 'posix.system']
MAX_OPERATIONS 10000000 MAX_EXECUTION_TIME_SECONDS 30
BASE_BUILTIN_MODULES ['collections', 'datetime', 'itertools', 'math', 'queue', 'random', 're', 'stat', 'statistics', 'time', 'unicodedata']

这张清单信息量很大。危险模块被拉黑(os / subprocess / socket / shutil 全在内),危险函数被拉黑(eval / exec / __import__),默认只放行 11 个纯计算模块(math / re / itertools 这类)。再加两道资源闸:单次执行最多 1000 万次操作、最多 30 秒。想放行更多模块,必须显式写进 additional_authorized_imports。

但要说清楚:这些防线是「本地执行器」的,不是「绝对安全」的。executor_type="local" 仍在本进程里跑代码,黑名单只是抬高门槛,不是沙箱。真正要隔离,得换 executor_type="docker" 或 "e2b"——把代码丢到容器里跑,本地进程只拿结果。这是「代码即行动」范式在部署时必须额外付的成本:minagent 的工具调用范式里,模型永远无法执行任意代码,所以这一整类风险根本不存在。

4.2.7 用 CodeAgent 重写「检索 + 汇总」子任务

回到 ResearchCopilot。卷二里「检索多个关键词 → 汇总」这条子任务,原本是 supervisor 拆成多次工具调用、逐次回灌。用 CodeAgent 重写,它天然就是一段代码:

model = ScriptedModel([
    'hits = [search(q) for q in ["smolagents", "CodeAgent", "tool calling"]]\n'
    'merged = " | ".join(hits)\nprint(merged)',
    'final_answer("汇总:" + merged)',
])
agent = CodeAgent(tools=[search], model=model, max_steps=5)
print(agent.run("检索三个关键词并汇总"))

在真实模型下,这段代码会被模型一次写出:一个列表推导完成三次 search,join 完成聚合。对照「工具调用」范式,这需要三轮「调用 → 回灌 → 再调用」;在 CodeAgent 里是一轮。省下的往返就是省下的延迟和 token——这是「代码即行动」最实在的收益。

但要清醒:收益的另一面是你把代码执行权交给了模型。executor_type="local" 意味着模型写的代码在本机 Python 里跑;生产里应换成 docker 或 e2b 之类的隔离执行器,并用 additional_authorized_imports 收窄可导入模块。卷一的 minagent 没有这个问题——它根本不让模型写代码。

4.2.8 这套框架替你做了什么、你又让渡了什么

能力minagent(卷一自研)smolagents CodeAgent
动作表达结构化 tool_calls模型写 Python 代码
多工具一次完成需多轮循环一段代码内完成
中间计算回灌上下文代码内本地完成
执行安全无代码执行,天然安全需执行器隔离 + import 白名单
模型接入自写 Model 协议继承 Model 基类即可
可观测自己埋 on_stepstep_callbacks + 内置日志

让渡的清单也很清楚。第一,安全边界被打破了——只要模型能写代码,你就必须有隔离执行方案,这是自研工具调用范式完全不存在的问题。第二,输出不再结构化——工具调用的参数是 JSON、可校验;代码是自由文本,出错要靠执行器兜。第三,锁定的是范式而非供应商——smolagents 的 Model 基类对供应商中立(可接 OpenAI、Anthropic、本地 vLLM),所以锁定度比 OpenAI Agents SDK 低,但换来的是「必须接受代码执行」这个前提。

对 ResearchCopilot 而言,取舍点在于:如果子任务的本质是「多步取数 + 计算 + 聚合」,CodeAgent 的表达力远超工具调用;如果是「选一个工具、传一组参数」,CodeAgent 是杀鸡用牛刀,还多背了执行安全的责任。下一节 4.3 三框架同题对照:同评测集比分数与成本 会把 PydanticAI、smolagents 与 minagent 拉到同一道题上,用同一份假模型量出步数、工具调用次数与代码量;想回看 handoff 与 guardrails,见 4.1 OpenAI Agents SDK:handoff/guardrails/tracing 实现委派 。

阅读导航:上一节:4.1 OpenAI Agents SDK:handoff/guardrails/tracing 实现委派 · 下一节:4.3 三框架同题对照:同评测集比分数与成本 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距