Varian Blog

Back

EVA 风格的城市落日插画EVA 风格的城市落日插画

一个 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 在此基础上实现具体的工具调用策略。

先不用纠结每个类的具体实现。

我们先抓住一条主线:

Agent 接收任务 → 模型决定行动 → Runtime 执行行动 → 结果返回模型 → 继续决策。

接下来就沿着这条主线进入源码。


2. Agent Loop:整个 Agent 的执行循环#

先想象一个场景。

你让一个人独立去办一件事情,他不可能在出发之前就知道所有细节。走到第一个路口,他需要观察情况,再决定下一步;遇到问题,还得调整路线。

Agent 其实也有点类似。

它不是提前写好一条固定路线,而是每完成一步,就根据新的信息决定下一步。

2.1 MultiStepAgent:为什么需要循环?#

我们先看 agents.py 中的 _run_stream()。

注:保留关键执行逻辑,省略部分异常处理和辅助代码。

这里最重要的就是 while。

普通 LLM 调用通常是:

输入 → 模型推理 → 输出 → 结束
text

Agent 则是:

输入
 │
 ▼
模型决策
 │
 ▼
执行行动
 │
 ▼
获取结果
 │
 ▼
再次决策
 │
 └──────────↺
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 output
python

因此:

  • _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 = True
python

于是循环结束。

这里有一个值得注意的设计点:

模型决定什么时候提交最终答案,但程序决定如何识别、校验和结束执行。

而且,提交最终答案不代表任务一定成功。

例如,用户要求整理五篇论文,模型只找到两篇就提交答案。

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):
        pass
python

这里使用了 Python 的抽象基类机制。

它规定了具体工具需要实现统一的调用入口。

而 Tool 在此基础上增加了一套工具元数据:

class Tool(BaseTool):
    name: str
    description: str
    inputs: dict
    output_type: str
    output_schema: dict | None
python

这些属性分别描述:

属性 含义
name 工具名称
description 工具功能描述
inputs 输入参数规范
output_type 输出类型
output_schema 可选的结构化输出规范

真正实现工具功能的是 forward()。

例如:

这样,工具的功能描述与实际执行逻辑就被封装在同一个对象中。

3.2 __call__() 和 forward() 有什么区别?#

看 Tool.__call__():

这里的 __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 + b
python

它会把普通 Python 函数转换成一个动态创建的 Tool 子类实例。

整个过程可以概括为:

这里不展开装饰器内部的动态类生成细节。

对理解 Agent Runtime 来说,先记住一点就够了:

**@tool** 让开发者可以用普通函数的形式定义工具,而框架负责将其转换成统一的 Tool 对象。

3.4 JSON Schema:模型怎么知道工具需要什么参数?#

假设我们定义:

def search(query: str, limit: int):
    """
    搜索互联网信息。

    Args:
        query: 搜索关键词
        limit: 返回结果数量
    """
python

框架可以通过类型注解和 Docstring 提取:

  • 工具名称和用途。

  • 参数名称。

  • 参数类型。

  • 参数描述。

然后生成结构化的 JSON Schema。

例如:

注:这是帮助理解的简化示意结构,并非完整的源码输出。

这就解决了一个问题:

模型不需要知道 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

这里有两个重要输入:

  1. input_messages:当前任务和历史上下文。

  2. 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"
  }
}
json

Python 怎么知道应该执行哪个函数?

我们来看 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"
python

Runtime 就可以通过:

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"}
json

Runtime 就可以将这个字符串引用替换成实际保存的对象。

这样,模型就不必每次都重新传递完整对象。

随后,框架会对参数进行校验。

如果参数不符合工具规范,会抛出相应的调用错误,而不是直接执行。

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。

下一轮执行时,之前的执行历史又会重新进入模型上下文。

这样就形成了完整闭环:

这才是 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,并总结它的核心功能。

一次典型的多步执行可以这样理解:

这里最重要的是:第 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 如何行动开始,再逐渐理解它如何记忆、执行和扩展。


参考资料#

从 Agent Loop 到 Tool Calling:拆解 smolagents 的 Agent 执行机制
https://morrowave.me/blog/smolagents-agent-loop-tool-calling
Author Varian
Published at November 8, 2026
Comment seems to stuck. Try to refresh?✨