Appearance
07|工具调用:模型只发请求
上一篇让模型能按 schema 返回结构化数据。这个能力有个自然延伸:agent 排查要查本地数据,但模型不能直接读文件。
同事问"张三负责哪些服务?",负责人信息存在 data/contacts.json 里。直接把整份文件塞进 input,查一个人也要把整份表发出去,联系人表越大越不合适。本篇讲工具调用:模型需要资料时,只说"要查张三",Python 去本地查,再把结果回填给模型。
模型只发请求,客户端执行
工具调用的核心分工:模型不会执行函数,它只输出一段结构化请求,说明想调用哪个工具、参数是什么。真正执行函数的是 Python。

先写普通 Python 函数,data/contacts.json:
json
[
{"name": "张三", "services": ["order-api", "payment-worker"]},
{"name": "李四", "services": ["user-api"]}
]python
import json
from pathlib import Path
def find_owner(name: str) -> dict:
contacts = json.loads(Path("data/contacts.json").read_text(encoding="utf-8"))
for item in contacts:
if item["name"] == name:
return {"ok": True, "owner": item}
return {"ok": False, "error": f"联系人不存在: {name}"}先验证成功和失败两条路径:find_owner("张三") 返回记录,find_owner("王五") 返回 ok=false。这个失败结果后面也要回填给模型,防止编造联系人。
告诉模型可以用这个工具
模型不会主动知道 find_owner 的存在。要告诉它有这个工具可用,以及工具接受什么参数。工具定义就是上一篇的 strict schema:
python
tools = [
{
"type": "function",
"name": "find_owner",
"description": "按姓名查询服务负责人,只返回该负责人的服务列表。",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "负责人姓名"}
},
"required": ["name"],
"additionalProperties": False,
},
}
]description 是模型决定"要不要用这个工具"的依据,要写清楚工具做什么、什么时候用。parameters 是 strict schema:additionalProperties: false、字段进 required、可选字段用 null 类型。
第一次请求:
python
response = client.responses.create(
model=settings.model,
instructions="需要查询服务负责人时使用工具。回答只能基于工具返回结果。",
input="张三负责哪些服务?",
tools=tools,
)
tool_call = response.output[0]
print(tool_call.name) # "find_owner"
print(tool_call.arguments) # '{"name": "张三"}'这一轮模型不直接回答,而是返回工具调用请求。它只说"我要调 find_owner,参数 name=张三"。
白名单执行
工具名来自模型输出,不能直接动态执行。先写白名单,把"模型想调用什么"和"程序允许执行什么"分开:

python
import json
TOOL_HANDLERS = {
"find_owner": find_owner,
}
def run_tool_call(tool_call) -> dict:
name = tool_call.name
if name not in TOOL_HANDLERS:
return {"ok": False, "error": f"未知工具: {name}"}
args = json.loads(tool_call.arguments)
return TOOL_HANDLERS[name](**args)白名单是安全底线。模型可能因为幻觉输出一个不存在的工具名,白名单直接挡掉。后面接入命令工具时,这个习惯更重要——execute_command 这种工具绝不能让模型随便传个命令就执行。
回填结果给模型
执行完函数,把结果交回模型。这里有个关键设计点:失败结果也必须回填,不能静默丢弃。

python
tool_call = response.output[0]
tool_result = run_tool_call(tool_call)
follow_up = client.responses.create(
model=settings.model,
instructions="根据工具结果回答。工具返回 ok=false 时说明查不到,不要编造。",
input=[
{"role": "user", "content": "张三负责哪些服务?"},
tool_call,
{
"type": "function_call_output",
"call_id": tool_call.call_id,
"output": json.dumps(tool_result, ensure_ascii=False),
},
],
)
print(follow_up.output_text)call_id 把结果和请求对应起来。把问题改成"王五负责哪些服务?",工具返回 {"ok": false, "error": "联系人不存在: 王五"},回答应当说明查不到。只要开始编造服务名,就说明工具结果没正确约束回答。
有些系统只在工具成功时回填,失败时直接返回错误给外层。这会让模型在下一轮失去上下文——它不知道自己刚才请求的工具失败了,可能再次请求同一个工具,陷入循环。失败结果回填,模型才知道"这条路走不通,换一个思路"。
并行调用
模型可能一次返回多个工具调用。查 nginx 502 时,它可能同时要查 nginx 日志和 upstream 端口连通性,两个查询互不依赖,一轮里同时发起:
python
tool_calls = [item for item in response.output if item.type == "function_call"]
results = []
for tc in tool_calls:
result = run_tool_call(tc)
results.append({
"type": "function_call_output",
"call_id": tc.call_id,
"output": json.dumps(result, ensure_ascii=False),
})
follow_up = client.responses.create(
model=settings.model,
instructions="根据所有工具结果回答。",
input=[{"role": "user", "content": question}, *tool_calls, *results],
)Responses API 默认支持并行调用(parallel_tool_calls 默认开)。模型自己会判断多个查询有没有依赖——"先查 A 再根据 A 查 B"这种有依赖的,它不会拆成并行。但模型的依赖判断不是绝对可靠,某些工具有隐含的执行顺序约束(后一个会修改前一个读取的状态)时,parallel_tool_calls=false 作为硬开关关掉并行,把判断权收回代码侧。
工具多了怎么办
工具少的时候,模型选得清楚。工具一多,问题就来了:read_log 和 read_service_log 长得像,模型可能在两者之间犹豫甚至选错;几十个工具全列出来,光工具定义就吃掉一大块上下文。
工具不多时,做法简单:只把相关的几个暴露给模型。工具实在太多,换个思路——不把所有工具一次性都给模型。平时只放几个最常用的,模型遇到这几个解决不了的问题时,主动去检索更多工具,找到合适的再调用。这种"按需加载"的做法叫延迟加载(defer loading),配合 tool_search 实现。两种方式可以一起用:常用的工具一直放着,不常用的用到再找。
工具定义的顺序也会影响选择。模型对排在列表前面的工具关注度更高,顺序还和 prompt cache 的缓存断点相关(第 4 篇讲过)。把最常用、最可靠的工具放前面,边缘工具放后面。改动工具列表顺序前在测试集上验证选择准确率是否变化。
参数二次校验
schema 约束在生成阶段生效,但工具执行前仍要二次校验。read_log 的 lines 参数虽然 schema 限制了 maximum: 100,执行前仍要判断 if lines > 100: raise ValueError。
schema 约束和代码校验是两层独立防线。schema 防的是模型生成非法参数,代码校验防的是 schema 没覆盖到的业务规则——比如 service="nginx" 在类型上合法,但当前环境根本没有 nginx 实例,这个参数就是无意义的。业务规则校验必须在工具函数内部执行。
工具调用之后
模型现在能调工具了:发请求、客户端执行、结果回填。单轮的工具调用跑通了。
但真实排查是多轮的:查一步、看结果、决定下一步,再查、再看,直到得出结论。多轮就带来一个新问题——第二轮怎么记得第一轮说了什么?工具返回的大段日志要不要每轮都塞进上下文?这些属于"模型面前放什么"的问题,是下一阶段上下文设计要解决的。至于"调模型 → 执行工具 → 回填 → 再调模型"这个循环本身怎么安全收敛,是再后面循环与控制设计阶段的事。
第一阶段到这里:agent 已经能听懂任务、按格式输出、调工具拿数据。接下来让它能跨多轮工作。