工具系统
本章讲 SDK 怎么把一个普通 Python 函数变成 LLM 能调用的工具:从「读函数签名生成 schema」到「拿 LLM 给的 JSON 参数、校验、执行、容错」的完整链路。
1. 它要解决的小问题
LLM 调工具靠的是「function calling」:你得先告诉模型有哪些工具、每个工具叫什么、参数是什么样的 JSON schema;模型返回一段 JSON 参数;你得把这段 JSON 校验、解析、调用真实函数、把返回值喂回模型。
手写这套很烦。SDK 的目标是:你只写一个带类型注解和 docstring 的普通 Python 函数,加个 @function_tool,剩下全自动。
2. 思路 / 直觉:函数即 schema
核心洞察:一个带类型注解的 Python 函数签名,本身就包含了生成 JSON schema 所需的全部信息。
def get_weather(city: str, days: int = 1) -> str:
"""查天气。
Args:
city: 城市名。
days: 预报天数。
"""
│ │ │
函数名→工具名 参数+类型→schema docstring→工具描述+参数描述
所以 @function_tool 做的就是:用 inspect 读签名、用 griffe 解析 docstring、用 pydantic 把参数变成一个校验模型,最后吐出 JSON schema。
3. 第一步:function_schema —— 把函数变成 schema
function_schema(function_schema.py:224)产出一个 FuncSchema(function_schema.py:23),包含工具名、描述、参数 JSON schema、一个 pydantic 校验模型、以及「函数是否需要 context 作首参」等元数据。
几个关键动作:
取 docstring 信息(function_schema.py:256):自动检测 docstring 风格(Google/Numpy/Sphinx),抽出每个参数的描述。
识别 context 参数(function_schema.py:292):如果函数第一个参数的类型是 RunContextWrapper 或 ToolContext,标记 takes_context=True 并把它从「LLM 可见参数」里剔除——这个参数由 SDK 注入,不暴露给模型:
# function_schema.py:296 —— 首参是 context 就标记并跳过
if ann != inspect._empty:
origin = get_origin(ann) or ann
if origin is RunContextWrapper or origin is ToolContext:
takes_context = True # 由 SDK 注入,不进 schema
else:
filtered_params.append((first_name, first_param))
强制约束: context 参数只能在第一位,出现在其他位置直接抛 UserError(function_schema.py:306-312)。
4. 第二步:function_tool 装饰器 —— 包成 FunctionTool
function_tool(tool.py:1899)是个可带参/不带参两用的装饰器。它内部定义一个 _on_invoke_tool_impl,这就是「LLM 调这个工具时实际执行的逻辑」(tool.py:1978):
# 示意,提炼自 tool.py:1978 的 _on_invoke_tool_impl
async def _on_invoke_tool_impl(ctx, input_json): # input_json 是 LLM 给的 JSON 字符串
json_data = _parse_function_tool_json_input(...) # 1. 解析 JSON(失败→ModelBehaviorError)
try:
parsed = schema.params_pydantic_model(**json_data) # 2. pydantic 校验参数
except ValidationError as e:
raise ModelBehaviorError(f"Invalid JSON input ...") from e
args, kwargs = schema.to_call_args(parsed) # 3. 转成位置/关键字参数
if takes_context: # 4. 按需注入 context、按同步/异步分别调用
result = await the_func(ctx, *args, **kwargs)
return result
同步函数怎么办? SDK 用 asyncio.to_thread 把同步工具函数丢到线程池跑,避免阻塞事件循环(tool.py:2004):
# tool.py:2002 —— 同步工具丢线程池,异步工具直接 await
if not is_sync_function_tool:
result = await the_func(ctx, *args, **kwargs_dict) if schema.takes_context else await the_func(*args, **kwargs_dict)
else:
result = await asyncio.to_thread(the_func, ctx, *args, **kwargs_dict) ...
最终它调 _build_wrapped_function_tool(tool.py:599)产出 FunctionTool 这个 dataclass(tool.py:381)。
5. FunctionTool 的字段:工具的全部旋钮
FunctionTool(tool.py:381)就是工具的运行时表示。值得记的字段:
| 字段 | 作用 | 行号 |
|---|---|---|
name / description / params_json_schema | 暴露给 LLM 的三件套 | tool.py:386-393 |
on_invoke_tool | 实际执行逻辑(上一节那个) | tool.py:395 |
strict_json_schema | 是否用 OpenAI 的 strict schema(强烈建议 True) | tool.py:408 |
is_enabled | bool 或回调,运行时动态开关工具(关掉的工具对 LLM 不可见) | tool.py:412 |
needs_approval | bool 或回调,True 则执行前中断等人类审批(见 04 章) | tool.py:426 |
tool_input_guardrails / tool_output_guardrails | 工具级护栏 | tool.py:420-423 |
timeout_seconds / timeout_behavior | 单次调用超时 + 超时处理(返回错误字符串 or 抛异常) | tool.py:436-439 |
is_enabled 在 AgentBase.get_all_tools(agent.py:246)里被求值:每次 run 都并发检查所有工具是否启用,只把启用的暴露出去。
6. 容错:工具报错不一定让 run 崩
一个重要设计:工具抛异常时,默认不让整个 run 失败,而是把错误信息当成工具输出喂回给 LLM,让模型自己看到「这个工具报错了」并重试或换路。
这由 failure_error_function 控制(tool.py:1938):默认用 default_tool_error_function 生成一句给模型看的错误消息;如果你显式传 None,则改为直接抛异常让 run 失败。这个权衡——「对模型容错 vs 对调用者暴露」——是 agent 框架里很关键的一个旋钮。
7. agent 即工具(as_tool)
Agent.as_tool()(agent.py:508)把一个 agent 包装成一个 FunctionTool。它和 handoff 的区别(agent.py:533):
- handoff: 新 agent 接管整个对话历史,接管控制权。
- as_tool: 新 agent 像被调用的一个函数——父 agent 生成它的输入,它跑完把结果返回,父 agent 继续掌控对话。
实现上,as_tool 内部的 _run_agent_impl(agent.py:599)就是「在工具执行里再起一个 Runner.run」,把嵌套 run 的 final_output 当工具结果返回。它还细致处理了嵌套审批:如果被当工具的 agent 内部触发了工具审批中断,父 run 的审批决定会被镜像到嵌套 run(agent.py:694 的 _apply_nested_approvals)。
8. 其它工具类型(托管工具)
除了 FunctionTool(本地 Python 函数),SDK 还支持一批托管工具——这些工具的执行发生在模型服务端,SDK 只负责声明和接收结果:
| 工具类 | 干什么 | 行号 |
|---|---|---|
FileSearchTool | 向量检索文件 | tool.py:662 |
WebSearchTool | 联网搜索 | tool.py:688 |
ComputerTool | 控制电脑(截图/点击/输入) | tool.py:715 |
HostedMCPTool | 托管的 MCP 服务器工具 | tool.py:960 |
CodeInterpreterTool / ImageGenerationTool | 代码解释器 / 生图 | tool.py:981 / tool.py:993 |
LocalShellTool / ShellTool / ApplyPatchTool | shell 执行 / 打补丁(sandbox 相关) | tool.py:1020 等 |
在 process_model_response(见 01 章)里,这些类型各有专门的分拣分支;托管工具大多「已经在服务端跑完了」,本地只是把结果纳入历史。
9. 巧妙之处
- 签名即契约。 用 pydantic + 类型注解,既生成了给 LLM 的 JSON schema,又复用同一个模型在运行时校验 LLM 返回的参数——schema 和校验逻辑永远一致,不会漂移(
tool.py:1969同一个schema既出params_json_schema又出params_pydantic_model)。 - context 注入对模型透明。 首参是
RunContextWrapper就自动从 schema 里抹掉、运行时注入,让你能在工具里访问运行上下文而不污染给模型的接口。 - 默认对模型容错。 工具报错变成「给模型看的消息」而非崩溃,贴合 agent「试错—重试」的工作方式。
10. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 装饰器入口 | src/agents/tool.py | function_tool |
| 工具运行时表示 | src/agents/tool.py | FunctionTool、FunctionToolResult |
| 实际执行逻辑 | src/agents/tool.py | _on_invoke_tool_impl(在 function_tool 内)、_build_wrapped_function_tool |
| 签名→schema | src/agents/function_schema.py | function_schema、FuncSchema |
| 工具启用检查 | src/agents/agent.py | AgentBase.get_all_tools |
| agent 即工具 | src/agents/agent.py | Agent.as_tool(内部 _run_agent_impl) |
| 工具执行(并发跑) | src/agents/run_internal/tool_execution.py | execute_function_tool_calls |
| 托管工具 | src/agents/tool.py | WebSearchTool、ComputerTool、HostedMCPTool 等 |