

从 Agent Loop 到 Tool Calling:拆解 smolagents 的 Agent 执行机制
沿着 smolagents 源码,理解 Agent Loop、Tool System 与 Tool Calling 如何组成完整的 Agent 执行机制。
一个 LLM 是如何从生成文本,逐渐获得调用工具、执行行动,并根据环境反馈继续完成任务的?
项目: Hugging Face / smolagents
研究方向: Agent Runtime · Tool System · Tool Calling
系列: Agent 源码研究 · Part 01
TL;DR#
这次沿着 smolagents 的源码,主要研究了三个问题:
-
Agent Loop: Agent 如何持续执行任务,并判断什么时候结束?
-
Tool System: 普通 Python 函数如何变成 LLM 可以使用的工具?
-
Tool Calling: LLM 返回工具调用请求后,Runtime 如何找到对应函数并执行?
核心结论:
Agent 不只是一个接入了工具的 LLM,而是一套围绕模型决策建立起来的执行系统。
模型负责提出下一步行动,Runtime 负责将决策转化为实际执行,并将结果重新交还模型。
0. 写在前面#
最近开始尝试系统地阅读 Agent 框架的源码。
之前接触 Agent 时,我的注意力其实一直集中在模型能力上:模型能不能规划、能不能使用工具、能不能自主完成复杂任务。
但当我真正开始尝试阅读源码时,发现自己对这些能力背后的实现机制其实并不清楚。
比如:
-
Agent 为什么需要一个循环?
-
模型究竟是怎么知道自己可以调用哪些工具的?
-
LLM 返回一个 Tool Call 之后,Python 又是怎么找到对应函数的?
-
工具执行结果如何回到模型,影响下一步决策?
这些问题听起来并不复杂,但如果不沿着真实的代码调用链走一遍,很容易只记住几个概念。
于是,我选择了 Hugging Face 开源的 smolagents 作为第一个源码研究对象。
选择它主要是因为核心抽象相对集中,适合用来理解 Agent 的基本运行机制。
这次阅读源码,我没有直接从第一个文件开始逐行看,而是先建立整体架构,再围绕具体问题寻找关键函数。过程中也借助 AI 帮助解释代码、梳理调用关系,但最终还是需要回到源码,确认这些解释是否成立。
这篇文章记录的,就是我目前对 smolagents 核心执行机制的理解。
本期主要研究 ToolCallingAgent 的执行主干,不打算把它写成一份完整的框架使用手册。重点是理解模块之间的关系,以及几个关键设计为什么存在。
1. 先看全貌:smolagents 的核心架构#
在进入具体代码之前,先建立一张地图。
smolagents 的核心 Agent 结构,可以先从以下几个部分理解:
| 模块 | 核心职责 |
|---|---|
| MultiStepAgent | 管理 Agent 的执行生命周期 |
| ToolCallingAgent | 通过模型的 Tool Calling 完成行动 |
| Model | 与 LLM 交互,生成下一步行动 |
| Tool | 定义和封装外部能力 |
| Memory | 保存任务执行过程中的历史记录 |
其中,MultiStepAgent 是一个比较重要的入口。
它定义了多步执行的公共机制,而 ToolCallingAgent 在此基础上实现具体的工具调用策略。
smolagents Agent
│
├─ MultiStepAgent
│ ├─ 管理执行生命周期
│ ├─ 控制 step_number
│ ├─ 判断终止条件
│ └─ 管理执行记录
│
└─ ToolCallingAgent
├─ 继承多步执行机制
├─ 调用 Model 生成下一步行动
├─ 解析 Tool Call
└─ 执行工具并生成 Observation
其他核心组件
│
├─ Model ← 负责与 LLM 交互
├─ Tool ← 定义和封装外部能力
└─ Memory ← 保存任务执行历史text先不用纠结每个类的具体实现。
我们先抓住一条主线:
Agent 接收任务 → 模型决定行动 → Runtime 执行行动 → 结果返回模型 → 继续决策。
接下来就沿着这条主线进入源码。
2. Agent Loop:整个 Agent 的执行循环#
先想象一个场景。
你让一个人独立去办一件事情,他不可能在出发之前就知道所有细节。走到第一个路口,他需要观察情况,再决定下一步;遇到问题,还得调整路线。
Agent 其实也有点类似。
它不是提前写好一条固定路线,而是每完成一步,就根据新的信息决定下一步。
2.1 MultiStepAgent:为什么需要循环?#
我们先看 agents.py 中的 _run_stream()。
def _run_stream(self, task, max_steps, images=None):
self.step_number = 1
returned_final_answer = False
while (
not returned_final_answer
and self.step_number <= max_steps
):
# 创建当前行动步骤
action_step = ActionStep(
step_number=self.step_number,
...
)
# 执行当前步骤
for output in self._step_stream(action_step):
yield output
# 收到最终答案后,结束循环
if isinstance(output, ActionOutput) and output.is_final_answer:
...
returned_final_answer = Truepython注:保留关键执行逻辑,省略部分异常处理和辅助代码。
这里最重要的就是 while。
普通 LLM 调用通常是:
输入 → 模型推理 → 输出 → 结束textAgent 则是:
输入
│
▼
模型决策
│
▼
执行行动
│
▼
获取结果
│
▼
再次决策
│
└──────────↺text每一步都可能产生新的信息,而这些信息又会影响下一步决策。
因此,Agent 需要一个持续运行的循环。
2.2 两个终止条件:什么时候停?#
回到源码:
while (
not returned_final_answer
and self.step_number <= max_steps
):python这里有两个重要条件:
-
returned_final_answer:是否已经获得最终答案。 -
max_steps:最多允许执行多少步。
为什么需要两个条件?
如果模型一直没有决定结束任务,只依赖最终答案判断,Agent 可能持续运行。
而如果只依赖最大步数,即使任务提前完成,也没有必要继续执行。
所以两者共同构成了 Agent Loop 的基本终止机制。
此外,源码还提供了 interrupt_switch,用于外部中断。
如果达到最大步数仍然没有得到最终答案,框架会调用 _handle_max_steps_reached(),尝试生成一个收尾答案。
这里还有一个值得注意的区别:
最大步数限制的是执行步骤,不等于 Token 预算、API 请求次数或运行时间限制。
2.3 ActionStep:每一轮行动如何记录?#
继续看:
action_step = ActionStep(
step_number=self.step_number,
timing=Timing(start_time=action_step_start_time),
observations_images=images,
)python每次进入循环,框架都会创建一个新的 ActionStep。
它负责记录当前步骤的执行信息,例如:
-
模型输入和输出。
-
Token 使用情况。
-
工具调用。
-
Observation。
-
执行时间。
-
错误信息。
-
是否为最终答案。
可以简单理解为:
ActionStep 1
├─ 模型输入
├─ 模型输出
├─ 工具调用
└─ 执行结果
ActionStep 2
├─ 模型输入
├─ 模型输出
├─ 工具调用
└─ 执行结果text这里我一开始也容易把 ActionStep 和行动编排混淆。
但实际上,它主要负责记录,而不是决定下一步调用哪个工具。
真正执行当前一步的是:
for output in self._step_stream(action_step):
yield outputpython因此:
-
_run_stream()管理整个生命周期。 -
_step_stream()执行当前步骤。 -
ActionStep记录当前步骤。
这三个职责需要区分。
2.4 谁决定任务完成?#
在 ToolCallingAgent 中,模型可以通过特殊工具 final_answer 提交最终答案。
框架通过工具名称识别:
is_final_answer = tool_name == "final_answer"python当它被识别为最终答案后,工具结果会被标记为 is_final_answer=True。
随后 _step_stream() 产生 ActionOutput,交回外层 _run_stream()。
外层循环识别到最终答案后,将:
returned_final_answer = Truepython于是循环结束。
这里有一个值得注意的设计点:
模型决定什么时候提交最终答案,但程序决定如何识别、校验和结束执行。
而且,提交最终答案不代表任务一定成功。
例如,用户要求整理五篇论文,模型只找到两篇就提交答案。
Agent 可以正常结束,但任务显然没有完全完成。
所以:
-
Termination:执行是否结束。
-
Success:任务是否真正完成。
这两件事不能混为一谈。
Agent Loop 并不负责决定具体行动,而是提供一个持续执行的生命周期。具体行动由 Agent 子类实现,执行记录与生命周期控制也被分离。这种职责划分使不同类型的 Agent 能够复用相同的执行框架。
3. Tool System:Agent 如何获得外部能力?#
如果把 Agent 比作一个能够独立完成任务的人,那么 Tool 就像他可以使用的工具箱。
但工具箱里只有工具还不够。
他还需要知道每件工具叫什么、能做什么、应该怎么使用。
Tool System 解决的就是这个问题。
3.1 BaseTool 与 Tool#
smolagents 在 tools.py 中通过 BaseTool 和 Tool 建立统一的工具抽象。
class BaseTool(ABC):
name: str
@abstractmethod
def __call__(self, *args, **kwargs):
passpython这里使用了 Python 的抽象基类机制。
它规定了具体工具需要实现统一的调用入口。
而 Tool 在此基础上增加了一套工具元数据:
class Tool(BaseTool):
name: str
description: str
inputs: dict
output_type: str
output_schema: dict | Nonepython这些属性分别描述:
| 属性 | 含义 |
|---|---|
name |
工具名称 |
description |
工具功能描述 |
inputs |
输入参数规范 |
output_type |
输出类型 |
output_schema |
可选的结构化输出规范 |
真正实现工具功能的是 forward()。
例如:
class WeatherTool(Tool):
name = "weather"
description = "查询指定城市天气"
inputs = {
"city": {
"type": "string",
"description": "城市名称"
}
}
output_type = "string"
def forward(self, city: str):
# 这里才是真正的业务逻辑
return f"{city}:晴天,25℃"python这样,工具的功能描述与实际执行逻辑就被封装在同一个对象中。
3.2 __call__() 和 forward() 有什么区别?#
看 Tool.__call__():
def __call__(
self,
*args,
sanitize_inputs_outputs=False,
**kwargs
):
# 首次使用时执行初始化
if not self.is_initialized:
self.setup()
# 处理输入
if sanitize_inputs_outputs:
args, kwargs = handle_agent_input_types(*args, **kwargs)
# 执行真正的工具逻辑
outputs = self.forward(*args, **kwargs)
return outputspython这里的 __call__ 是 Python 的特殊方法,它让对象可以像函数一样被调用。
例如:
weather = WeatherTool()
# 看起来像普通函数调用
result = weather(city="宁波")python实际执行过程是:
weather(...)
│
▼
Tool.__call__()
│
├─ 检查初始化
├─ 处理输入
▼
Tool.forward()
│
▼
返回结果text这里还有一个小细节:setup()。
某些工具可能需要加载大型模型,或者执行比较昂贵的初始化操作。
smolagents 允许在第一次调用工具时再执行初始化,这就是延迟初始化(Lazy Initialization)。
3.3 @tool:普通函数也可以变成 Tool#
如果每个简单工具都需要继承 Tool,填写属性,再实现 forward(),就会有不少样板代码。
所以 smolagents 还提供了 @tool 装饰器。
@tool
def add(a: int, b: int) -> int:
"""
计算两个整数的和。
Args:
a: 第一个整数
b: 第二个整数
"""
return a + bpython它会把普通 Python 函数转换成一个动态创建的 Tool 子类实例。
整个过程可以概括为:
普通 Python 函数
│
▼
读取函数信息
│
▼
生成工具 Schema
│
▼
创建 Tool 子类
│
▼
绑定原函数到 forward()
│
▼
返回 Tool 实例text这里不展开装饰器内部的动态类生成细节。
对理解 Agent Runtime 来说,先记住一点就够了:
**@tool** 让开发者可以用普通函数的形式定义工具,而框架负责将其转换成统一的 Tool 对象。
3.4 JSON Schema:模型怎么知道工具需要什么参数?#
假设我们定义:
def search(query: str, limit: int):
"""
搜索互联网信息。
Args:
query: 搜索关键词
limit: 返回结果数量
"""python框架可以通过类型注解和 Docstring 提取:
-
工具名称和用途。
-
参数名称。
-
参数类型。
-
参数描述。
然后生成结构化的 JSON Schema。
例如:
{
"name": "search",
"description": "搜索互联网信息",
"parameters": {
"properties": {
"query": {
"type": "string",
"description": "搜索关键词"
},
"limit": {
"type": "integer",
"description": "返回结果数量"
}
}
}
}json注:这是帮助理解的简化示意结构,并非完整的源码输出。
这就解决了一个问题:
模型不需要知道 Python 函数内部怎么实现,只需要知道工具叫什么、能做什么、需要什么参数。
JSON Schema 是模型与程序之间的调用契约。
它负责描述工具,不负责执行工具。
3.5 参数校验:不能完全相信模型#
模型生成的参数不一定总是正确的。
例如:
{
"query": 123,
"limit": "很多"
}json但工具实际要求:
{
"query": "string",
"limit": "integer"
}json因此,smolagents 还提供了:
validate_tool_arguments(tool, arguments)python它会检查参数是否存在于工具定义中、必要参数是否缺失,以及实际类型是否符合要求。
这里需要区分两个层面:
-
工具定义时的校验:检查 Tool 自身的描述和输入规范。
-
工具调用时的校验:检查模型实际传入的参数。
前者保证工具定义合理,后者保证一次具体调用符合要求。
Tool System 的关键不只是封装函数,而是建立一份模型能够理解、Runtime 能够执行的统一接口规范。
4. Tool Calling:从模型决策到程序执行#
前面解决了两个问题:
-
Agent 如何持续运行?
-
工具如何被定义?
现在到了最关键的部分。
可以把这一过程想象成一个任务调度中心:模型提出任务请求,Runtime 拿到请求后,确认目标工具是否存在、参数是否合理,再把任务交给真正负责执行的程序,最后将结果送回来。
4.1 模型如何获得工具清单?#
回到 agents.py 中的 ToolCallingAgent._step_stream()。
首先:
# 将 Agent 历史记录转换为模型消息
memory_messages = self.write_memory_to_messages()
input_messages = memory_messages.copy()python接下来调用模型:
chat_message = self.model.generate(
input_messages,
stop_sequences=["Observation:", "Calling tools:"],
tools_to_call_from=self.tools_and_managed_agents,
)python这里有两个重要输入:
-
input_messages:当前任务和历史上下文。 -
tools_to_call_from:当前 Agent 可以使用的工具定义。
模型根据这些信息决定下一步行动。
它可以直接生成文本,也可以提出工具调用请求。
注意:
模型返回 Tool Call,只是提出行动请求,并不代表工具已经执行。
4.2 Tool Call:统一模型输出#
不同模型供应商的工具调用格式可能存在差异。
smolagents 会将模型输出转换成内部的 ToolCall 对象:
tool_call = ToolCall(
name=chat_tool_call.function.name,
arguments=chat_tool_call.function.arguments,
id=chat_tool_call.id
)python这样后续 Runtime 就可以使用统一的数据结构,而不必直接依赖某个模型供应商的原始格式。
4.3 Runtime 怎么找到真正的工具?#
假设模型返回:
{
"name": "search",
"arguments": {
"query": "smolagents"
}
}jsonPython 怎么知道应该执行哪个函数?
我们来看 execute_tool_call():
# 普通工具 + 被管理的 Agent
available_tools = {
**self.tools,
**self.managed_agents
}
# 根据名称找到实际工具对象
tool = available_tools[tool_name]python这里其实就是一个名称到对象的映射。
例如:
self.tools = {
"search": search_tool,
"calculator": calculator_tool,
"weather": weather_tool
}python模型返回:
tool_name = "search"pythonRuntime 就可以通过:
tool = self.tools["search"]python找到真正的工具对象。
这里可以将这套映射理解为 Tool Registry(工具注册表)。
需要说明的是,这是对源码中映射结构的架构概括,并不是源码中独立存在的一个 Registry 类。
另外,available_tools 还合并了 managed_agents,所以被管理的其他 Agent 也可以作为当前 Agent 的可调用对象。
4.4 参数替换与校验#
找到工具之后,还不能直接执行。
源码接下来会做:
# 将参数中引用的状态变量替换成实际对象
arguments = self._substitute_state_variables(arguments)
# 判断是不是 Managed Agent
is_managed_agent = tool_name in self.managed_agents
# 校验实际调用参数
validate_tool_arguments(tool, arguments)python这里的状态变量替换值得注意。
例如 Agent State 中保存了一个对象:
self.state = {
"image.png": image_object
}python如果模型传入的参数中包含:
{"image": "image.png"}jsonRuntime 就可以将这个字符串引用替换成实际保存的对象。
这样,模型就不必每次都重新传递完整对象。
随后,框架会对参数进行校验。
如果参数不符合工具规范,会抛出相应的调用错误,而不是直接执行。
4.5 真正执行:tool(**arguments)#
终于到这里了。
# 普通 Tool
return tool(
**arguments,
sanitize_inputs_outputs=True
)python这里的 **arguments 是 Python 的字典解包。
例如:
arguments = {
"query": "smolagents"
}python那么:
tool(**arguments)python就等价于:
tool(query="smolagents")python最终会进入之前研究的:
Tool.__call__()
│
▼
Tool.forward()
│
▼
真正的工具实现text至此,模型生成的结构化行动请求就被转换成了真实的 Python 函数调用。
4.6 Observation:工具执行结果如何返回?#
工具执行完毕之后,smolagents 会将结果封装成 ToolOutput。
return ToolOutput(
id=tool_call.id,
output=tool_call_result,
is_final_answer=is_final_answer,
observation=observation,
tool_call=tool_call,
)python这里有两个值得区分的字段:
-
output:工具实际返回的对象。 -
observation:提供给 Agent 后续处理的观察结果。
对于普通文本结果,框架会将它转换为字符串形式的 Observation。
对于 AgentImage、AgentAudio 等特定对象,则会保存到 Agent State,并通过引用名称生成观察描述。
随后,工具调用和 Observation 会被写入当前 ActionStep。
外层 _run_stream() 完成这一步之后,再将 ActionStep 加入 Memory。
下一轮执行时,之前的执行历史又会重新进入模型上下文。
这样就形成了完整闭环:
模型提出行动请求
│
▼
解析为 ToolCall
│
▼
Runtime 查找工具
│
▼
替换状态变量、校验参数
│
▼
调用 tool(**arguments)
│
▼
Tool.__call__() → Tool.forward()
│
▼
生成 ToolOutput
│
▼
记录 ActionStep / Observation
│
▼
进入下一轮模型决策text这才是 Tool Calling 真正有意思的地方。
它不是一次独立的函数调用,而是 Agent 多步执行机制中的一个环节。
4.7 一个额外的细节:并行工具调用#
继续往下看 process_tool_calls(),还能发现一个有意思的设计。
当模型一次只提出一个 Tool Call 时,框架直接执行。
但如果一次提出多个调用,就会使用:
ThreadPoolExecutor(self.max_tool_threads)python通过线程池并行执行。
例如,模型认为两个查询互不依赖:
-
查询项目许可证。
-
查询项目最新版本。
就可以在同一轮提出两个工具调用,让 Runtime 并行处理。
这里需要注意:并行执行需要模型在同一轮提出多个 Tool Call,并不是 Runtime 自动分析任务后决定并行。
这一机制实际上把两个问题分开了:模型负责决定哪些行动可以同时提出,Runtime 负责提供并行执行能力。
5. 把几个模块串起来:一次完整的 Agent 执行#
前面分别研究了 Agent Loop、Tool System 和 Tool Calling。
现在把它们放在一起。
假设用户要求:
找到 smolagents 项目,阅读 README,并总结它的核心功能。
一次典型的多步执行可以这样理解:
用户提出任务
│
├─ 1. Runtime 组装输入
│ ├─ 当前任务
│ ├─ 执行历史(Memory)
│ └─ 可用工具清单
│
├─ 2. LLM 决定下一步行动
│ ├─ 可能直接回答
│ └─ 也可能发起 Tool Call
│
├─ 3. 如果发起 Tool Call
│ ├─ Runtime 查找对应工具
│ ├─ 校验参数是否合法
│ └─ 执行工具
│
├─ 4. 工具返回结果
│ └─ 结果会被整理成 Observation
│
├─ 5. Observation 写回执行历史
│ └─ 供下一轮 LLM 决策使用
│
├─ 6. LLM 再次判断
│ ├─ 继续调用工具
│ └─ 或提交 final_answer
│
└─ 7. Runtime 返回最终结果text这里最重要的是:第 6 步并不是一个固定的后续流程。
模型可以根据工具返回的信息继续调用其他工具,也可以认为现有信息已经足够,直接提交最终答案。
因此,这是一套动态决策机制,而不是预先写死的工作流。
6. 几个值得进一步思考的设计问题#
读源码不应该只停留在知道代码做了什么,还需要继续问:为什么这样设计?
6.1 模型越来越强,还需要 Runtime 吗?#
我觉得需要先区分固定 Workflow 和自主 Agent。
固定 Workflow:
data = search(query)
result = analyze(data)
answer = summarize(result)python每一步执行顺序由程序预先规定。
自主 Agent:
while not finished:
action = llm.decide(memory)
result = execute(action)
memory.append(result)python下一步行动由模型动态决定。
这并不意味着程序不再需要控制。
即使模型足够强,系统仍然需要执行步数限制、工具权限、参数校验、中断和异常处理。
我的理解是:
模型能力决定 Agent 能做出多灵活的决策,Runtime 决定这些决策能否在可控的环境中执行。
6.2 为什么不直接把所有工具都交给模型?#
随着工具数量增加,模型需要面对越来越大的选择空间。
工具描述和参数 Schema 也会占用上下文。
可能出现:
-
上下文负担增加。
-
功能相似的工具容易混淆。
-
无关工具干扰决策。
-
工具管理与权限控制更加复杂。
因此,工具数量并不是越多越好。
对于工具规模更大的系统,可以进一步考虑工具发现、动态筛选和分层路由。
6.3 Termination 不等于 Success#
模型提交 final_answer,只能说明它决定结束行动。
这并不自动证明用户要求已经被满足。
一个能够停止运行的 Agent,不一定是一个能够可靠完成任务的 Agent。
因此,Agent 系统还需要考虑任务验收、结果验证和失败处理。
7. 写在最后#
这次研究让我对 Agent 的内部运行机制有了更具体的认识。
沿着 smolagents 的源码走下来,可以发现,Agent 的执行过程并不是模型单方面完成的。
从 MultiStepAgent 的执行循环,到 Tool 的统一抽象,再到 execute_tool_call() 将模型输出转化为真实的 Python 函数调用,每个模块都承担着相对明确的职责。
其中一个值得关注的设计是:模型的决策能力与程序的执行能力被分离了。
模型可以灵活地提出行动,但实际行动仍然需要经过 Runtime 的查找、校验和执行。工具产生的结果又会被重新组织为模型可以理解的信息,进入下一轮决策。
这套机制构成了 Agent 能够持续与外部环境交互的基础。
当然,这还只是 smolagents 的一部分。
目前主要研究了 ToolCallingAgent 的执行主干,Memory 的内部结构、上下文管理以及 CodeAgent 的代码执行机制还没有深入展开。
后续更新计划#
接下来准备继续沿着几个方向研究。
Part 02:Memory & Context
-
AgentMemory 的内部结构。
-
TaskStep、ActionStep 等执行记录。
-
Observation 如何进入 Memory。
-
write_memory_to_messages()如何重建模型上下文。 -
多轮执行如何保持连续性。
Part 03:CodeAgent & Executor
-
CodeAgent 与 ToolCallingAgent 的区别。
-
模型生成代码的执行流程。
-
Python Interpreter 的实现。
-
执行环境与安全边界。
Part 04:扩展机制与架构设计
-
Managed Agents。
-
MCP 工具接入。
-
Planning 机制。
-
Streaming 与事件处理。
-
smolagents 的架构取舍。
后面还想单独做一次完整的架构复盘,把前面分散研究的模块重新连接起来。
这次先到这里。
从 Agent 如何行动开始,再逐渐理解它如何记忆、执行和扩展。