跳到主要内容

工具调用与 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:163 / :80
ToolInfo一份 JSON-Schema 兼容的工具规格,直接发给模型 APIsrc/inspect_ai/tool/_tool_info.py:24
_parse_tool_info_shared从签名 + docstring 反射抽出 schemasrc/inspect_ai/tool/_tool_info.py:103
ToolDef统一载体:把 Tool/裸函数规范成 name+desc+params+行为src/inspect_ai/tool/_tool_def.py:35
execute_tools执行最后一条 assistant 消息里的所有 tool_callsrc/inspect_ai/model/_call_tools.py:103
call_tool单个调用的落地:查找→审批→校验→反序列化→调用src/inspect_ai/model/_call_tools.py:606
Agent / AgentStateAgent 协议 + 它读写的对话状态src/inspect_ai/agent/_agent.py:92 / :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 主线走一遍(高层)

  1. react(tools=[add(), bash()])。react 把工具收集好,还会自动加一个 submit 工具。
  2. 循环第一轮:react 调模型生成(_agent_generate),把工具的 ToolInfo 清单一并发过去。
  3. 模型回来一条 assistant 消息,里面可能带 tool_calls
  4. react 调 execute_tools(state.messages, tools):对每个 tool_callcall_tool,把结果包成 ChatMessageTool 追加进对话。
  5. 若模型调了 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:80Tool)。

ToolResult 限定了合法返回类型:str | int | float | bool | Content...,模型 API 只吃这些(src/inspect_ai/tool/_tool.py:35)。

@tool 做的三件事src/inspect_ai/tool/_tool.py:163tool):

  • 确定工具名(显式 name= 或函数名)。
  • @wraps 包一层 tool_wrapper:每次实例化工具时,把元信息标(tag)到返回的对象上——并行性 TOOL_PARALLEL、自定义 viewer、model_input 等(:247registry_tag)。
  • 把工厂注册进全局 registry,这样能按名字重建(用于日志复现)。

注意这里的分层:外层 add(工厂)负责配置,内层 execute 才是模型真正调用的实现。元信息挂在实例上,配置项写在 @tool(...) 的参数里。

一个关键默认值:parallel 默认为 False:228tool_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 用它的 parametersinput_schema

schema 是怎么抽出来的? 核心是 _parse_tool_info_sharedsrc/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_callssrc/inspect_ai/model/_call_tools.py:103;真正实现 _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_toolsrc/inspect_ai/model/_call_tools.py:606)是"把模型那句话落到真实函数"的核心。按顺序:

  1. 解析错误先行tool_call 本身解析失败(call.parse_error)→ 直接 ToolParsingError:632)。
  2. 查找工具:按 call.functionToolDef 列表里找;找不到 → "Tool X not found"(:636)。
  3. 审批apply_tool_approval:643)。未批准就报 ToolApprovalError;若审批策略要求 terminate,抛 TerminateSampleError 直接结束整条样本(:648)。审批还能改写调用参数(:653)。
  4. schema 校验validate_tool_input(§4.4)。
  5. 反序列化参数tool_params 把 JSON 值构造成真实 Python 对象(§4.3)。
  6. 调用:若是 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),但你的函数签名可能要的是 datetimeEnumdataclass、pydantic model。tool_param 递归地按类型注解把 JSON 值构造成真实对象src/inspect_ai/model/_call_tools.py:1026):

注解类型怎么构造
int/str/float/bool直接构造,失败抛 ToolParsingError
datetime/date/timeISO 解析(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:1112)。校验失败把所有错误拼成一条消息,走 ToolParsingError 回给模型让它重试——而不是崩溃。这是"容错落地"的第一道闸。

4.5 错误处理:哪些回给模型、哪些终结样本

这是整个执行循环最讲究的地方。原则:工具的"业务错误"要变成给模型的反馈让它自我修复,而真正的 bug 要让样本失败。

call_tool_task:147)用一长串 except 把异常分成两类(:193:249):

捕获的异常变成结果
ToolError(及其子类 ToolParsingError/ToolApprovalErrorToolCallError("parsing"/"approval"/"unknown")回给模型,可恢复
TimeoutError / PermissionError / FileNotFoundError / IsADirectoryError / UnicodeDecodeError对应类型的 ToolCallError回给模型
LimitExceededError / OutputLimitExceededErrorToolCallError("limit")回给模型
ValueError("embedded null byte")ToolCallError("parsing")回给模型(防子进程崩)
其它任意 Exception记为 tool_exception重新抛出,取消同批兄弟调用、终结样本

这套设计对应 Tool 的契约(_tool.py:50ToolError 的 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):

  1. is_parallel(call):查该调用对应 ToolDef.parallel,未知工具默认串行(:336)。
  2. 把连续的 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 供日志展示。


5. Agent 协议与运行

有了工具,接下来是编排。先讲 Agent 的三个基础件。

5.1 Agent 协议 与 AgentState

Agent 就是一个约定:async def (state: AgentState, *args, **kwargs) -> AgentStatesrc/inspect_ai/agent/_agent.py:92)。它比工具更"重"——它是对话的参与者,能往对话里追加消息和输出。

AgentState 是 Agent 读写的两样东西(src/inspect_ai/agent/_agent.py:35):

  • messages:对话历史(内部用 ChatMessageList 存,便于限长)。
  • output:最后一次模型输出。有个巧思:若还没有 output,它会从最后一条 assistant 消息即时合成一个:52:74),这样调用方总能拿到非空 output。

5.2 @agent 装饰器

@tool 类似,@agentsrc/inspect_ai/agent/_agent.py:142)把 agent 工厂注册进 registry,并区分两个名字:

  • registry name:可复现的查找标识,永远是注册键。
  • display name:给人和模型看的名字(用于 transcript span、handoff 话术、transfer_to_<name> 工具名)。存在元信息里,由 agent_with(name=...)@agent(name=) 设(AGENT_NAME:312)。

它还处理了 from __future__ import annotations 下类型注解变字符串的坑:用原函数的 __globals__ 重新解析注解并回填签名(:219)——否则后面基于签名反射(把 agent 当工具抽 schema)会挂。

5.3 run():在外部跑一个 agent

run(agent, input, limits=...)src/inspect_ai/agent/_run.py:35)是从 solver 或代码里手动跑 agent 的入口。要点:

  • 输入先拷贝,不原地改(:76);str/消息列表/AgentState 都能当输入。
  • apply_limits(..., catch_errors=True) 里跑:agent 自己的 limit 超了会被捕获并作为返回值,而不是抛出(:97)。传了 limits 就返回 (state, error) 元组,没传就只返回 state(重载见 :11/:23)。
  • agent 的对话只在返回的 AgentState,不会回灌到 input——想反映到样本得自己拷回去(as_solver 会自动做,§7.3)。

6. react 智能体循环(本章核心)

reactsrc/inspect_ai/agent/_react.py:50)是 Inspect 内置的 ReAct 循环 agent——"推理(Reason) + 行动(Act)"交替。它是绝大多数评测里 agent 的默认骨架。

6.1 循环全景

先看这张图(execute 主循环在 :193:390),从上往下是一轮,箭头回到顶端表示继续转:

┌──────────────────────────────────────────────┐
│ while True: │
│ ① 拉取 operator 消息 / checkpoint (可选) │
│ ② _agent_generate → 追加 assistant 消息 │ §6.5
│ │ │
│ ├─ stop=model_length → _handle_overflow │ §6.4 → continue / break
│ ├─ stop=content_filter ×3 → break │
│ │ │
│ ③ 有 tool_calls? │
│ 是 → execute_tools → 追加结果 │ §4
│ │ │
│ └─ 调了 submit? → 写 output.completion│ §6.2
│ attempts 用尽/答对 → break
│ 否 → on_continue 钩子 / 回一句"请继续" │ §6.3
└───────────────┬──────────────────────────────┘
│ 循环
退出后:从历史里移除 submit 工具调用 (_remove_submit_tool)

6.2 submit:怎么算"交答案"

react 默认会自动加一个 submit 工具:147default_submit_tool),它只是把 answer 原样返回。

每轮执行完工具后,submission():180)扫结果里有没有一个无错误的 submit 调用

  • 有 → 把答案写进 state.output.completion:281),供打分用;并把答案也拼进消息文本(因为 submit 调用稍后会被移除)。
  • 然后处理 attempts(多次尝试,:299):没到上限就当场打分score(state):304),答对 break,答错就回一条"你错了,再试"的用户消息继续转。

循环结束时 _remove_submit_tool:743)把 submit 相关的消息/调用从历史里删掉——让模型的最终回答看起来就是普通 assistant 消息。这在多智能体里尤其重要:否则父 agent 看到子 agent 的 submit 调用会误以为自己该结束了(AgentSubmit.keep_in_messages 的 docstring,_types.py:118)。

submit=False,则走 react_no_submit:395):没有提交工具,模型一旦不调工具就直接终止

6.3 continue:不调工具时怎么办

模型有时会"光说不做"(不调任何工具)。on_continue 决定此时怎么办(:340:381):

on_continue 的返回/取值行为
None(默认)若这轮没调工具,回一句 DEFAULT_CONTINUE_PROMPT("请继续,完成了就调 submit")继续转
str用这条自定义消息代替默认继续语
async 函数返回 True继续(无工具调用时补默认继续语)
返回 False
返回 AgentState用这个新状态继续

注意 on_continue 每轮都被调用,不是只在"没调工具"时——想只在没调工具时插话得自己判断(docstring :99)。

6.4 overflowcompaction:上下文要爆了

对话越滚越长,迟早撑爆模型上下文窗口。react 有两道防线:

① compaction(预测式压缩)_model_generate:685)在每次生成,若接近溢出就先 compact.compact_input() 压一压历史;生成后用真实 token 数更新压缩基线(record_output:720)。这是"未雨绸缪"。

② overflow(事后兜底):如果还是溢出了(stop_reason == "model_length"),走 _handle_overflow:574):

检测到 model_length
│ 丢掉那条没用的失败 assistant 消息

先试"强制压缩" compact.compact_input(force=True) ← 尽量保留意图
│ 成功 → continue(继续转)
│ 失败/无压缩策略

再试 overflow 过滤器(truncation="auto" 用 trim_messages 截断)
│ 真的截短了 → continue
│ 没截短 / 没策略

终止:"model context window exceeded"

顺序是刻意的:压缩(保意图)优先于截断(纯删),截断是最后手段(:589 注释)。truncation 默认 "disabled"(不截,直接终止)。

6.5 _agent_generate:把"模型"抽象掉

_agent_generate:647)有个漂亮的抽象:react 的 model 参数可以是模型名/Model也可以是另一个 Agent。若是前者,包成一个内部 generate agent(_model_generate:685);若本来就是 agent,直接用。统一成 async model(state, tools) 调用(:682)。

它会校验这个"模型 agent"必须有 tools 参数(:676)——因为 react 要把工具清单传进去。这让你能把"如何调用模型"整个替换掉(比如加自定义重试、缓存、路由),而 react 循环逻辑不用改。


7. 多智能体编排:handoff / as_tool / as_solver

一个 agent 不够用时,Inspect 提供三种把 agent 包成可调用单元的方式。先看对比,再深入两个核心。

7.1 三者对比

handoff()as_tool()as_solver()
包成什么一个 transfer_to_<name> 移交工具一个普通工具(收 input: str一个 Solver(顶层求解步骤)
谁触发模型主动调用移交工具模型像调普通工具一样调评测框架当 solver 跑
会话是否共享共享:子 agent 看到并追加到主对话隔离:子 agent 独立跑,只回一段文本TaskState 的消息启动
输出怎么回子 agent 的新消息注入主对话只返回最终 assistant 文本当工具结果回灌进 TaskState.messages/output
典型用途协调者把控制权交给专家 agent把子 agent 当"一次问答"黑盒把 agent 直接当整个任务的解法
代码agent/_handoff.py:19agent/_as_tool.py:22agent/_as_solver.py:24

一句话记忆:handoff = 交出方向盘(共享会话);as_tool = 打个电话问一句(隔离);as_solver = 直接当整份答卷。

7.2 handoff 深入

handoff(agent, ...)src/inspect_ai/agent/_handoff.py:19)把 agent 包成一个特殊的 AgentTool:83),工具名默认 transfer_to_<agent_name>。这个 AgentTool 不能直接调用:104 直接 raise)——它是个哨兵,靠 execute_tools 里的 call_tool 识别并转交(_call_tools.py:668)。

真正的移交逻辑在 agent_handoffsrc/inspect_ai/model/_call_tools.py:684),几个关键动作:

  1. 摘掉并行兄弟调用:模型可能同一条消息里既 handoff 又调别的工具;这里把 assistant 消息里其它 tool_call 删掉,只留这次移交,保证对话合法(:701)。
  2. 补一条 ChatMessageTool 作边界:"Successfully transferred to X."——既满足了那个 tool_call,又成为后面"哪些是新消息"的分界线(:715)。
  3. input_filter:调子 agent 前可过滤历史(比如 remove_tools 去掉工具调用)(:728)。系统消息一律移除(子 agent 的系统指令不适用)(:754)。
  4. 带 limit 跑子 agent,limit 每次移交独立深拷贝(:773,避免多次移交共用同一 limit 实例)。
  5. 只取回新消息:按边界找出子 agent 新产生的消息,给 assistant 消息加上 [agent_name] 前缀再注入主对话(prepend_agent_name:826);output_filter(默认 content_only)再洗一遍,使其它模型读起来安全(:802)。
  6. 收尾接力:若子 agent 停在 assistant 消息或超了 limit,补一条 user 消息让主 agent 知道"子 agent 干完了/超限了",好继续转(:805:821)。

7.3 as_toolas_solver

as_toolsrc/inspect_ai/agent/_as_tool.py:22)把 agent 变普通工具。模型看到的参数是 agent 的参数,但 state 被替换成一个 input: str:87)。内部 execute 用这个 input 起一个全新隔离 AgentState、带 limit 跑、然后只返回最终 output 或最后 assistant 消息的文本(:76:84)。子 agent 的中间对话不进主对话——这就是"黑盒问答"。

agent_kwargs 可以柯里化掉 agent 的部分参数(给定默认值),这些参数就不呈现给模型(agent_tool_info:102)。

as_solversrc/inspect_ai/agent/_as_solver.py:24)把 agent 当顶层 solver。它用 TaskState.messagesAgentState,跑完在 finally无条件把消息和 output 回灌 TaskState——即使中途异常也要回灌,保证日志和打分能看到(:74)。它还在创建时校验:agent 除 state 外的必填参数你都得通过 agent_kwargs 给齐(:52)。


8. 内置沙箱工具举例

Inspect 自带一批"跑在沙箱里"的工具作为工具体系的真实范例。沙箱本身怎么建、RPC 怎么进容器归 06;这里只看它们作为"工具"的定义。

bash / pythonsrc/inspect_ai/tool/_tools/_execute.py:64/:125):最典型的工具。三个看点:

  • 都标了 parallel=True——每次调用开全新子进程、无状态,可并发(§4.6)。
  • 都配了自定义 viewercode_viewer:9),在日志里把命令渲染成语法高亮代码块。
  • bash(background=True)ToolDef(...).as_tool()(§3.3)换一份鼓励后台跑长命令的描述,但不改行为——只影响模型看到的说明(:117)。刻意不提"sandbox/评测"字样,免得暗示模型它在被评测(:29 注释)。

text_editorsrc/inspect_ai/tool/_tools/_text_editor.py):一个工具内含子命令的范例。单个 executecommand: Literal["view","create","str_replace","insert","undo_edit"] 分派(:85),把参数打包成 JSON-RPC 请求发进沙箱容器执行。展示了"一个工具、多种动作"如何用字面量类型 + 判别式建模。

bash_sessionsrc/inspect_ai/tool/_tools/_bash_session.py):有状态的交互式 shell。和无状态的 bash 对照鲜明:它保持一个长期会话,动作有 type/type_submit/read/interrupt/restart:118),靠 instance 区分不同进程,store_as 存会话 id。因为有状态、顺序敏感,它没有 parallel=True——正好印证 §4.6 的串行栅栏设计。


9. 巧妙之处(可借鉴的技术)

  • 默认串行、并行 opt-in + 栅栏分段:把"顺序依赖的副作用"当一等公民,用串行调用作 barrier 保住模型声明的先后语义,同时让无状态工具并发提速。_call_tools.py:336:356
  • 错误语义二分ToolError → 回给模型自我修复;其它异常 → 终结样本。一条清晰的红线让工具作者精确控制"可恢复 vs 致命"。_tool.py:50_call_tools.py:193
  • 结果按声明序回拼:并发执行但结果顺序严格对齐 tool_use 块,绕过 Anthropic 等 provider 的顺序约束。_call_tools.py:519
  • model 可以是另一个 Agent:react 把"如何生成"抽象成 model(state, tools),让你整体替换生成逻辑而循环不变。_react.py:647
  • 压缩优先于截断:溢出恢复先试保意图的强制压缩,截断只作最后兜底。_react.py:574
  • as_tool() 反向贴标:给裸函数当场贴 registry 信息变成合法 Tool,使"同一实现、多份描述"(如 bash 背景模式)零成本。_tool_def.py:144
  • display name 与 registry name 分离:可复现标识和给人/模型看的名字解耦,改显示名不破坏日志复现。_agent.py:312

10. 边界与局限(诚实)

  • execute_tools 只看最后一条 assistant 消息:它不是遍历全对话,只处理最新一轮的 tool_calls_call_tools.py:139)。
  • call_tools()(复数、旧接口)已废弃:不支持 handoff 工具,用 execute_tools 替代(_call_tools.py:1221 会 warn)。
  • parallel 安全性靠人审计:框架不验证并发安全,标 parallel=True 是你担保"无共享写、无顺序依赖"(_tool.py:181 docstring)。
  • handoff 多次移交的 limit 语义:同一 agent 被多次移交时 limit 会被多次应用,代码靠深拷贝每个 limit 规避,但"多次移交同一 agent"本身不是被支持的一等场景(_call_tools.py:771 注释)。
  • text_editor 无 Subtask 隔离:一个 Subtask 改的文件对另一个可见(_text_editor.py docstring 明说)。
  • compaction 与"agent 当 model"不兼容:若把自定义 agent 当 model 又开了 compaction,compaction 被忽略并 warn(agent 需自己处理压缩,_react.py:655)。
  • **checkpointer / agent_channel(resume、operator 介入)**本章只带过:它们让 react 能断点续跑、接收操作者中途消息,细节属于 06 可观测性

11. 代码地图(导航索引)

主题文件路径符号名
@tool 装饰器 / Tool 协议 / ToolResult / ToolErrorsrc/inspect_ai/tool/_tool.pytool · Tool · ToolResult · ToolError
工具 JSON schema 抽取src/inspect_ai/tool/_tool_info.pyToolInfo · _parse_tool_info_shared · python_type_to_json_type
统一工具定义 / 反向贴标src/inspect_ai/tool/_tool_def.pyToolDef · tool_def_fields · ToolDef.as_tool · validate_tool_parameters
工具调用自由度src/inspect_ai/tool/_tool_choice.pyToolChoice · ToolFunction
参数类型src/inspect_ai/tool/_tool_params.pyToolParams · ToolParam
执行入口 / 并行分段src/inspect_ai/model/_call_tools.pyexecute_tools · _execute_tools_impl · call_tool_task
单调用落地 / 错误分类src/inspect_ai/model/_call_tools.pycall_tool · agent_handoff
参数反序列化 / schema 校验 / 截断src/inspect_ai/model/_call_tools.pytool_params · tool_param · validate_tool_input · truncate_tool_output
模型侧工具准备src/inspect_ai/model/_call_tools.pyprepare_tools · resolve_tools
Agent 协议 / 状态 / 装饰器src/inspect_ai/agent/_agent.pyAgent · AgentState · agent · agent_with
外部运行 agentsrc/inspect_ai/agent/_run.pyrun
react 循环 / 溢出 / 生成抽象src/inspect_ai/agent/_react.pyreact · _agent_generate · _handle_overflow · _model_generate · _remove_submit_tool
react 配置类型src/inspect_ai/agent/_types.pyAgentPrompt · AgentSubmit · AgentAttempts · AgentContinue
移交 / as_tool / as_solversrc/inspect_ai/agent/_handoff.py · _as_tool.py · _as_solver.pyhandoff · AgentTool · as_tool · as_solver
内置沙箱工具src/inspect_ai/tool/_tools/_execute.py · _text_editor.py · _bash_session.pybash · python · text_editor · bash_session

相邻章节: 模型如何产出 tool_call03 统一模型层;工具真正执行的沙箱环境见 06 日志与沙箱;Solver 与 TaskState(as_solver 的落点)见 02 Solver 与 TaskState;评测主循环如何驱动这一切见 01 评测主循环