数据截至 (上游 commit d8fc8fcbde74)
工具调用与 Agent 智能体循环
30 秒导读: 模型只会「说」——它输出一段结构化的
tool_call("我要调用bash,参数command="ls"")。本章讲 Inspect 怎么给模型装上手脚:把一个普通 Python 函数变成模型能看懂的工具(抽 JSON schema)、把模型说的那段调用精确、可容错地落 到真实函数上(查找 / 审批 / 校验 / 反序列化 / 执行 / 收结果),再把这些工具编成一个会自己转的 Agent 循环(react),最后讲多个 Agent 怎么互相移交(handoff)与嵌套(as_tool)。
本章在全书里的位置:
- 模型如何产出
tool_call(把工具列表发给 OpenAI/Anthropic、解析回来的函数调用)——归 03 统一模型层。 - 工具真正跑在哪(docker/沙箱、RPC 到容器)——归 06 日志与沙箱。
- 本章只管中间那一段:工具的定义、
tool_call的落地执行、以及 Agent 的编排与循环。
1. 这是什么(零基础也能懂)
一句话定义: 工具(Tool)是一个模型可以请求调用的异步 Python 函数;Agent 是一个"读对话、调工具、再读对话"反复循环直到交答案的自动程序。
1.1 为什么需要"手脚"
模型本身是个纯文本函数:给它一段对话,它回一段话。它不能真的读文件、跑命令、查数据库。
要让它干实事,得给它工具。但难点从来不是"调用模型",而是这三件事:
| 要解决的问题 | 白话 | 本章对应 |
|---|---|---|
| 让模型知道有哪些工具、每个怎么用 | 把 Python 函数翻译成模型能读的 JSON schema | §3 工具定义 |
| 把模型说的调用落到真实函数上 | 查找函数、校验参数、容错、跑、收结果 | §4 执行循环 |
| 让这一切自动转起来、还能多智能体协作 | 循环 + 移交 | §5–§7 Agent |
1.2 用起来什么样
定义一个工具,就是写个带 @tool 的工厂函数,返回内部的 execute:
from inspect_ai.tool import tool, Tool, ToolResult
@tool
def add() -> Tool: # 外层:工具工厂
async def execute(x: int, y: int) -> int: # 内层:真正被调用的实现
"""把两个整数相加。
Args:
x: 第一个加数
y: 第二个加数
"""
return x + y
return execute
把它交给一个 react Agent,模型就会在需要时自己调用它:
from inspect_ai.agent import react
agent = react(tools=[add()]) # 一个会自主循环调工具的 Agent
# agent 内部:generate → 模型说"调 add(x=2,y=3)" → 执行得 5 → 再 generate → submit 答案
关键直觉:@tool 只是把函数注册并附上元信息;模型真正看到的是从这个函数的类型签名 + docstring 自动抽出来的一份 JSON schema。你写 Python,Inspect 负责翻译。
1.3 一句话类比
- 工具 = 给模型的一根遥控器按钮:按钮上印着名字和说明(schema),按下去(
tool_call)真的会动(execute)。 - react Agent = 一个不知疲倦的操作员:看一眼屏幕(对话)、按一个按钮(工具)、再看屏幕、再按……直到按下"提交"(submit)。
本节不出现底层细节。记住一件事:模型说的和真实世界之间隔着一层"翻译 + 落地",这层就是本章的主角。
2. 顶层全景(它大概怎么转)
2.1 一次工具调用的生命周期
下面这张图从左到右是时间顺序,一次跑完停在"结果回到对话":
你写的 Python 函数 模型侧 真实执行
┌──────────────────┐ ①抽schema ┌──────────────────┐ ③tool_call ┌──────────────────┐
│ @tool def add(): │ ────────────▶ │ 模型看到工具清单 │ ───────────▶ │ execute_tools │
│ async execute │ ToolInfo │ 决定调用哪个 │ (函数名+参数)│ 逐个落地 │
└──────────────────┘ (JSON schema)└──────────────────┘ └───────┬──────────┘
│ ④call_tool
▼
⑥ ChatMessageTool ┌───────────────────────── ──────────┐
┌──────────────────┐ (结果回到对话) │ 找函数→审批→schema校验→反序列化参数 │
│ react 循环 │ ◀────────────────────────────── │ →调用 execute()→截断→包成结果 │
│ 再 generate… │ ⑤结果 └───────────────────────────────────┘
└──────────────────┘
怎么读:①②是定义(§3),③由 03 模型层 产出,④⑤⑥是执行(§4),最外圈的"react 循环"是 Agent(§6)。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
@tool / Tool | 注册工具、挂元信息(并行性、viewer 等) | src/inspect_ai/tool/_tool.py:164 / :80 |
ToolInfo | 一份 JSON-Schema 兼容的工具规格,直接发给模型 API | src/inspect_ai/tool/_tool_info.py:24 |
_parse_tool_info_shared | 从签名 + docstring 反射抽出 schema | src/inspect_ai/tool/_tool_info.py:103 |
ToolDef | 统一载体:把 Tool/裸函数规范成 name+desc+params+行为 | src/inspect_ai/tool/_tool_def.py:35 |
execute_tools | 执行最后一条 assistant 消息里的所有 tool_call | src/inspect_ai/model/_call_tools.py:105 |
call_tool | 单个调用的落地:查找→审批→校验→反序列化→调用 | src/inspect_ai/model/_call_tools.py:614 |
Agent / AgentState | Agent 协议 + 它读写的对话状态 | src/inspect_ai/agent/_agent.py:93 / :35 |
react | 内置的 ReAct 循环 agent(本章核心) | src/inspect_ai/agent/_react.py:50 |
handoff / as_tool | 把一个 Agent 包成"移交工具" / "普通工具" | src/inspect_ai/agent/_handoff.py:19 / _as_tool.py:22 |
2.3 主线走一遍(高层)
- 你
react(tools=[add(), bash()])。react 把工具收集好,还会自动加一个submit工具。 - 循环第一轮:react 调模型生成(
_agent_generate),把工具的ToolInfo清单一并发过去。 - 模型回来一条 assistant 消息,里面可能带
tool_calls。 - react 调
execute_tools(state.messages, tools):对每个tool_call走call_tool,把结果包成ChatMessageTool追加进对话。 - 若模型调了
submit,react 把答案写进output.completion,退出循环;否则回到第 2 步继续转。
3. 工具定义体系:从 Python 函数到模型能读的 schema
这一节讲图里的①②:怎么把一个函数变成模型看得懂的工具规格。 由浅入深分五层。
3.1 Tool 协议 与 @tool 装饰器
Tool 是什么? 就是一个"可 await、返回 ToolResult 的可调用对象"这一约定(Protocol),不是基类。任何 async def execute(...) 都天然满足它(src/inspect_ai/tool/_tool.py:81,Tool)。
ToolResult 限定了合法返回类型:str | int | float | bool | Content...,模型 API 只吃这些(src/inspect_ai/tool/_tool.py:36)。
@tool 做的三件事(src/inspect_ai/tool/_tool.py:164,tool):
- 确定工具名(显式
name=或函数名)。 - 用
@wraps包一层tool_wrapper:每次实例化工具时,把元信息标(tag)到返回的对象上——并行性TOOL_PARALLEL、自定义 viewer、model_input等(:247的registry_tag)。 - 把工厂注册进全局 registry,这样能按名字重建(用于日志复现)。
注意这里的分层:外层 add(工厂)负责配置,内层 execute 才是模型真正调用的实现。元信息挂在实例上,配置项写在 @tool(...) 的参数里。
一个关键默认值:parallel 默认为 False(:228,tool_parallel: bool = parallel is True)——工具默认串行,要并行必须显式 opt-in。原因见 §4.6。
3.2 ToolInfo:抽出来的 schema,直接喂模型
ToolInfo 是最终发给模型 API 的那份规格:name + description + parameters(JSON Schema)(src/inspect_ai/tool/_tool_info.py:24)。它的 docstring 里直接给了 provider 用法示例——OpenAI 把它 model_dump() 塞进 function=,Anthropic 用它的 parameters 当 input_schema。
schema 是怎么抽出来的? 核心是 _parse_tool_info_shared(src/inspect_ai/tool/_tool_info.py:103)。它按这个优先级取信息:
① 已有的"描述覆盖"(set_tool_description / tool_with 设过的)—— 最优先,从不缓存
│ 没有
▼
② 缓存(按 func id,命中且同一函数就直接返回)
│ 未命中
▼
③ 反射:inspect.signature + get_type_hints + docstring 解析
反射这步(③)的要点:
- 是普通函数就
get_type_hints(func);是可调用实例(带__call__的对象)就取type(func).__call__的类型(:117)——这让类形式的工具也能抽签名。 - 每个参数:优先用类型注解
json_schema(hint)转成 JSON 类型;注解缺失才回退到 docstring 里写的类型(python_type_to_json_type,:207)。 - 没有默认值的参数进
required(:158)。 - 函数级 description 取自 docstring 的描述段(
:170)。
一句话:你的类型注解决定 schema 结构,你的 docstring 决定人话说明。 两者都缺,后面校验会报错。
3.3 ToolDef:统一的规范化载体
ToolInfo 是"发给模型的静态规格",而 ToolDef 是"执行期要用的完整定义"——它多带了可调用体本身和行为属性(src/inspect_ai/tool/_tool_def.py:35)。
ToolDef 的价值在于把三种输入抹平成一种:
| 你给的东西 | ToolDef 怎么处理 |
|---|---|
已注册的 Tool | 从 registry 元信息 + 反射抽全部字段(tool_def_fields,:206) |
裸 callable(没 @tool) | 直接 _parse_tool_info_shared 抽 schema(:98) |
| 你显式传的 name/description/parameters | 覆盖自动抽取的值 |
反向操作是 as_tool()(:144):给一个裸函数贴上 registry 信息和描述,让它当场变成合法 Tool。§8 的 bash(background=True) 就用这招给同一个 execute 换一份描述。
tool_def_fields(:206)里有个容易忽略的细节:description 的兜底顺序是 doc comment → 已废弃的 prompt= 参数;两者都没有直接 raise——工具必须有描述,否则模型无从判断何时用它。参数描述缺失同样会在 validate_tool_parameters(:277)报错。
3.4 ToolChoice:让不让、让谁调
ToolChoice 控制模型的调用自由度(src/inspect_ai/tool/_tool_choice.py:13):
| 值 | 含义 |
|---|---|
"auto" | 模型自己决定要不要调 |
"any" | 必须至少调一个工具 |
"none" | 禁止调工具 |
ToolFunction(name=...) | 强制调某个指定函数 |
这个值最终由模型层发给 provider,属于③号边界,本章不展开。
3.5 描述从哪来、谁覆盖谁
汇总一下参数/描述的优先级(agent 排障常踩):
显式传参 (ToolDef(name=, description=, parameters=))
▶ 覆盖 ▶ set_tool_description / tool_with 设的
▶ 覆盖 ▶ @tool(name=) / @agent(description=)
▶ 兜底 ▶ 函数签名 + docstring 自动抽取
apply_description_overrides(_tool_def.py:170)只允许覆盖已存在的参数,传错名字直接 ValueError——防止你以为改了描述其实打错了参数名。
4. 工具执行循环:把 tool_call 精确落地
这是本章工程含量最高的一段,对应图里的④⑤⑥。入口是 execute_tools。
4.1 execute_tools 全景
execute_tools(messages, tools, ...) 只处理最后一条 assistant 消息里的 tool_calls(src/inspect_ai/model/_call_tools.py:105;真正实现 _execute_tools_impl 在 :134)。它返回一个 ExecuteToolsResult:追加进对话的消息列表 + 可选的 output(只有 handoff 触发子 agent 生成时才有,:87)。
高层流程:
最后一条 assistant 消息
│ 取出 message.tool_calls
▼
把 tool 列表解析成 ToolDef(tool_defs)
│
▼
按"并行标记"把 tool_calls 切成有序的执行阶段 stages ← §4.6
│
▼
逐个 stage 跑:
每个 call → call_tool_task → call_tool(真正落地,§4.2)
收集结果、finalize ToolEvent、异常分类(§4.5)
│
▼
按模型声明的原始顺序把结果拼回 result_messages(§4.6 末)
4.2 call_tool:单个调用的六步落地
call_tool(src/inspect_ai/model/_call_tools.py:614)是"把模型那句话落到真实函数"的核心。按顺序:
- 解析错误先行:
tool_call本身解析失败(call.parse_error)→ 直接ToolParsingError(:632)。 - 查找工具:按
call.function在ToolDef列表里找;找不到 → "Tool X not found"(:636)。 - 审批:
apply_tool_approval(:643)。未批准就报ToolApprovalError;若审批策略要求terminate,抛TerminateSampleError直接结束整条样本(:648)。审批还能改写调用参数(:653)。 - schema 校验:
validate_tool_input(§4.4)。 - 反序列化参数:
tool_params把 JSON 值构造成真实 Python 对象(§4.3)。 - 调用:若是
AgentTool(handoff)走agent_handoff(§7.2);否则就await tool_def.tool(**arguments)(:679)。
细节:这个函数故意自己负责记录 transcript 事件,因为它要把事件放进正确的"外壳"(tool / handoff / agent span)里——所以每条早退路径也要先补记事件再抛(record_pending_tool_event,:623)。
4.3 参数反序列化:tool_params / tool_param
模型给的是 JSON(字符串、数字、嵌套 dict),但你的函数签名可能要的是 datetime、Enum、dataclass、pydantic model。tool_param 递归地按类型注解把 JSON 值构造成真实对象(src/inspect_ai/model/_call_tools.py:1034):
| 注解类型 | 怎么构造 |
|---|---|
int/str/float/bool | 直接构造,失败抛 ToolParsingError |
datetime/date/time | ISO 解析(Z→+00:00) |
dataclass / TypedDict / pydantic BaseModel / Enum | 按字段递归构造 |
list/set/tuple/dict | 逐元素递归 tool_param |
tool_params(:980)在外层做参数匹配与兜底:函数若声明 **kwargs: Any 就整包透传;参数缺失但有默认值用默认值;Optional 缺失填 None;否则抛"Required parameter not provided"。
4.4 schema 校验:validate_tool_input
在真正调用前,用 JSON Schema(Draft7)把参数对着 工具的 parameters 校一遍(src/inspect_ai/model/_call_tools.py:1120)。校验失败把所有错误拼成一条消息,走 ToolParsingError 回给模型让它重试——而不是崩溃。这是"容错落地"的第一道闸。
4.5 错误处理:哪些回给模型、哪些终结样本
这是整个执行循环最讲究的地方。原则:工具的"业务错误"要变成给模型的反馈让它自我修复,而真正的 bug 要让样本失败。
call_tool_task(:147)用一长串 except 把异常分成两类(:193–:249):
| 捕获的异常 | 变成 | 结果 |
|---|---|---|
ToolError(及其子类 ToolParsingError/ToolApprovalError) | ToolCallError("parsing"/"approval"/"unknown") | 回给模型,可恢复 |
TimeoutError / PermissionError / FileNotFoundError / IsADirectoryError / UnicodeDecodeError | 对应类型的 ToolCallError | 回给模型 |
LimitExceededError / OutputLimitExceededError | ToolCallError("limit") | 回给模型 |
ValueError("embedded null byte") | ToolCallError("parsing") | 回给模型(防子进程崩) |
其它任意 Exception | 记为 tool_exception | 重新抛出,取消同批兄弟调用、终结样本 |
这套设计对应 Tool 的契约(tool/_tool.py:51,ToolError 的 docstring 说得很清楚):想让错误回给模型就抛 ToolError;想让样本致命失败就抛标准异常(RuntimeError/ValueError 等)。
# 示意,非源码:工具作者如何选择错误语义
async def execute(path: str) -> str:
if not path.startswith("/repo/"):
raise ToolError("只能访问 /repo 下的文件") # → 回给模型,它会改参数重试
data = open(path).read() # 真出 bug(比如权限)→ 标准异常 → 样本失败
return data
4.6 并行 vs 串行:stages 分段
模型一条消息里可能并排放好几个 tool_call。能并发跑吗?看每个工具的 parallel 标记。
execute_tools 的做法(:336–:356):
is_parallel(call):查该调用对应ToolDef.parallel,未知工具默认串行(:336)。- 把连续的 parallel 调用合并成一个并发阶段;每个串行调用单独成段,充当栅栏(barrier)(
:345)。
模型给的调用顺序: [bash(∥)] [bash(∥)] [store_write(串)] [python(∥)] [python(∥)]
└──── stage 0 并发 ────┘ └ stage 1 ┘ └──── stage 2 并发 ────┘
(栅栏,隔开前后)
为什么栅栏很重要:串行工具往往有顺序依赖的副作用(改 Store、改沙箱)。把它作为屏障,就保住了模型声明的先后语义——有状态和无状态的调用不会乱序交错。
parallel 默认 False 也是这个原因:只有审计过"并发安全"(无共享 Store/沙箱写、无顺序依赖副作用)的工具才该开。bash/python 显式标了 parallel=True,因为每次调用都开一个全新子进程、不留状态(_execute.py:64/:125)。
结果顺序:并发跑完后,结果按模型声明的原始顺序拼回消息列表(:519 的 splice 逻辑)——因为 Anthropic 等 provider 要求 tool_result 块和 tool_use 块顺序一一对应。快的兄弟调用不会插到慢的前面。
4.7 输出截断
工具输出可能极长(一个 cat 就爆上下文)。truncate_tool_output(:1134)按字节上限(默认 16*1024,可由 GenerateConfig.max_tool_output 调)截断,并包一段"输出太长,这是截断版"的说明给模型(:1147)。截断量记进 ToolEvent.truncated 供日志展示。