我问这个工具调用 agent 一个不存在城市的天气,原本以为会先在地理编码工具里报错。结果更早失败的是模型请求本身,脚本直接抛了堆栈。后来我只加了一层 try/except,同样的坏输入就不再崩溃,而是返回一条结构化错误,清楚告诉我是哪一步坏了。
这件事把边界讲得很明白:模型请求失败,和工具执行失败,是两类不同的问题,不能共用一条错误路径。
这篇文章讲的就是这条可观察的循环。一个工具调用 agent 不是看最终答案有多像样,而是要同时看模型请求、Schema 校验、Python 执行、紧凑工具结果、错误路径和最终回答。少了这些,调试时你看到的只是一个故事,不是一次运行。
这个循环要回答的四个问题
一个工具调用 agent 常常会说"我查过了",但真正有用的问题在这句话之后:
-
模型请求了哪个工具?
-
它传了什么参数?
-
Python 实际返回了什么?
-
最终答案有没有真的用到这些返回值?

本文用一个天气例子把这四个问题串起来。用户问:"明天去 Lagos 要不要带伞?" 模型先请求地理编码,拿到经纬度,再请求天气接口,拿到降雨概率和降水量,最后根据返回数据给出结论。
工具调用 Agent 到底是什么
在 Python 里,工具调用 agent 本质上就是一个循环:LLM 决定是否需要工具,你的程序执行工具,工具结果回到消息历史里,模型再决定下一步。
它和普通聊天机器人不一样。聊天机器人只收文本、回文本;工具调用 agent 会先发起动作请求,再等你的应用执行完,把结果塞回去继续推理。
这里有几个核心对象:
• 模型决定是否需要工具
• 工具是你自己的 Python 函数
• Schema 定义参数格式
• 消息记录用户输入、工具请求、工具结果和最终回答
• 循环代码负责把这些步骤连起来,直到模型不再请求工具
为什么先自己写一版
先手写一个小循环,比一上来就套框架更有价值。你会先看到哪些东西是可观察的,哪些东西被抽象层藏起来了。对 MCP 也一样:标准化接口能帮你统一工具分发,但参数约束、返回结构、错误形态这些问题,还是得你自己设计。
这就是为什么本文先走 OpenAI SDK 的直连路径,而不是先上 agent framework。
示例里的两个工具
示例只用两个服务:
• geocode_city:把城市名转成经纬度和国家
• get_weather:用经纬度拿到紧凑天气结果
为了让例子更真实,我用了 Nominatim 和 Open-Meteo 这两个公开 API。它们不需要额外密钥,所以整篇文章只依赖 OpenAI 的 API key。
先看完整脚本
下面是完整脚本,保存为 openai_tool_calling_agent.py。
import argparse
import json
import os
from typing import Any
import requests
from jsonschema import ValidationError, validate
from openai import OpenAI, OpenAIError
try:
import weave
except ImportError:
weave = None
REQUEST_TIMEOUT = 10
USER_AGENT = "tool-calling-agent-python/1.0"
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1")
def geocode_city(city: str) -> dictstr, Any:
response = requests.get(
"https://nominatim.openstreetmap.org/search",
params={"q": city, "format": "jsonv2", "limit": 1, "addressdetails": 1},
headers={"User-Agent": USER_AGENT},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
results = response.json()
if not results:
return {"error": f"City not found: {city}"}
first = results0
address = first.get("address", {})
return {
"city": first.get("name", city),
"country": address.get("country"),
"latitude": float(first"lat"),
"longitude": float(first"lon"),
}
def get_weather(latitude: float, longitude: float, city: str) -> dictstr, Any:
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,precipitation,rain,weather_code",
"daily": (
"weather_code,temperature_2m_max,temperature_2m_min,"
"precipitation_sum,precipitation_probability_max"
),
"forecast_days": 2,
"timezone": "auto",
},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
current = data.get("current", {})
daily = data.get("daily", {})
def tomorrow_value(field: str) -> Any:
values = daily.get(field) or \[\]
return values1 if len(values) > 1 else None
return {
"city": city,
"temperature_c": current.get("temperature_2m"),
"precipitation_mm": current.get("precipitation"),
"rain_mm": current.get("rain"),
"weather_code": current.get("weather_code"),
"tomorrow_weather_code": tomorrow_value("weather_code"),
"tomorrow_temperature_max_c": tomorrow_value("temperature_2m_max"),
"tomorrow_temperature_min_c": tomorrow_value("temperature_2m_min"),
"tomorrow_precipitation_sum_mm": tomorrow_value("precipitation_sum"),
"tomorrow_rain_chance_percent": tomorrow_value("precipitation_probability_max"),
}
TOOLS = [
{
"type": "function",
"function": {
"name": "geocode_city",
"description": "Find latitude, longitude, and country for a supported city.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, such as Lagos, London, or New York.",
}
},
"required": "city",
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get a compact weather report for a known location.",
"parameters": {
"type": "object",
"properties": {
"latitude": {"type": "number"},
"longitude": {"type": "number"},
"city": {"type": "string"},
},
"required": "latitude", "longitude", "city",
"additionalProperties": False,
},
},
},
]
TOOL_REGISTRY = {
"geocode_city": geocode_city,
"get_weather": get_weather,
}
SCHEMAS_BY_TOOL = {
tool"function""name": tool"function""parameters"
for tool in TOOLS
}
def compact_tool_result(result: dictstr, Any) -> dictstr, Any:
if "error" in result:
return {"error": result"error"}
allowed_keys = {
"city",
"country",
"latitude",
"longitude",
"temperature_c",
"precipitation_mm",
"rain_mm",
"weather_code",
"tomorrow_weather_code",
"tomorrow_temperature_max_c",
"tomorrow_temperature_min_c",
"tomorrow_precipitation_sum_mm",
"tomorrow_rain_chance_percent",
}
return {key: value for key, value in result.items() if key in allowed_keys}
def execute_tool_call(tool_name: str, tool_args: dictstr, Any) -> dictstr, Any:
if tool_name not in TOOL_REGISTRY:
return {"error": f"Unknown tool: {tool_name}"}
try:
validate(instance=tool_args, schema=SCHEMAS_BY_TOOLtool_name)
except ValidationError as exc:
return {"error": "Invalid tool arguments", "details": exc.message}
try:
return TOOL_REGISTRYtool_name(**tool_args)
except Exception as exc:
return {"error": "Tool execution failed", "details": str(exc)}
def maybe_trace(name):
if weave is None:
return lambda fn: fn
return weave.op(name=name)
@maybe_trace("run_agent")
def run_agent(user_prompt: str, max_turns: int = 4) -> dictstr, Any:
client = OpenAI()
messages = [
{
"role": "system",
"content": (
"You are a concise weather assistant. "
"Call tools only when they add facts needed for the answer."
),
},
{"role": "user", "content": user_prompt},
]
transcript: listdict\[str, Any] = \[\]
for turn in range(max_turns):
try:
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
except OpenAIError as exc:
return {
"model": MODEL,
"user_prompt": user_prompt,
"answer": "",
"error": {
"type": "model_request_failed",
"details": str(exc),
},
"transcript": transcript,
}
assistant_message = response.choices0.message
messages.append(assistant_message)
tool_calls = assistant_message.tool_calls or \[\]
if not tool_calls:
return {
"model": MODEL,
"user_prompt": user_prompt,
"answer": assistant_message.content or "",
"transcript": transcript,
}
for tool_call in tool_calls:
tool_name = tool_call.function.name
try:
tool_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as exc:
tool_args = {"raw_arguments": tool_call.function.arguments}
raw_result = {
"error": "Malformed tool arguments",
"details": str(exc),
}
else:
raw_result = execute_tool_call(tool_name, tool_args)
tool_result = compact_tool_result(raw_result)
transcript.append(
{
"turn": turn + 1,
"tool": tool_name,
"arguments": tool_args,
"result": tool_result,
}
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result),
}
)
return {
"model": MODEL,
"user_prompt": user_prompt,
"answer": "I could not finish because the agent reached its tool call limit.",
"transcript": transcript,
}
def verify() -> dictstr, Any:
bad_arguments = execute_tool_call("get_weather", {"city": "Lagos"})
unknown_tool = execute_tool_call("lookup_package", {"tracking_id": "123"})
schema_names = sorted(SCHEMAS_BY_TOOL)
return {
"status": "ok",
"model": MODEL,
"tools": schema_names,
"bad_arguments_check": bad_arguments,
"unknown_tool_check": unknown_tool,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices="verify", "run", default="run")
parser.add_argument(
"--prompt",
default="Should I carry an umbrella in Lagos tomorrow?",
)
parser.add_argument("--weave-project", default="")
args = parser.parse_args()
if args.mode == "verify":
print(json.dumps(verify(), indent=2))
return
if args.weave_project:
if weave is None:
raise RuntimeError("Install weave before using --weave-project.")
weave.init(args.weave_project)
result = run_agent(args.prompt)
print(json.dumps(result, indent=2))
if name == "main":
main()
先跑预检
先别花 token,直接跑这个:
python openai_tool_calling_agent.py --mode verify
它会检查导入、工具注册、Schema 校验和未知工具处理。输出应该类似这样:
{
"status": "ok",
"model": "gpt-4.1",
"tools": [
"geocode_city",
"get_weather"
],
"bad_arguments_check": {
"error": "Invalid tool arguments",
"details": "'latitude' is a required property"
},
"unknown_tool_check": {
"error": "Unknown tool: lookup_package"
}
}
这一步的意义很直接:在模型参与之前,先确认你的 Python 层会拒绝未知工具,也会拦住错误参数。
再跑真实请求
准备好 OPENAI_API_KEY 后,运行:
python openai_tool_calling_agent.py --mode run --prompt "Should I carry an umbrella in Lagos tomorrow?"
成功时,输出里会看到:
• 模型先请求 geocode_city
• Python 返回 Lagos 的经纬度和国家
• 模型再请求 get_weather
• Python 返回紧凑天气结果
• 最终回答基于这些返回值生成
这才是你真正要审计的链路,而不是只看最后一句答复。
跑一个坏输入
我最开始是拿一个不存在的城市做测试,想让故障出现在工具层。结果先炸的是模型请求本身。这个差异很有价值,因为它告诉你,工具调用 agent 的第一道边界不是工具,是模型请求。
修复方式也很简单:给 OpenAI 请求加 try/except OpenAIError,返回结构化错误,而不是让脚本直接退出。
再看另一个失败面
还有一类失败是工具参数格式不对。真实 API 一般不会这么坏,所以我用故障注入去验证:把真实参数截断,强行让 json.loads 失败。结果是,循环没有崩,先返回了一条 Malformed tool arguments,然后模型自己重试了同一个工具。
这说明结构化错误不仅能保住程序,还能成为模型可读的反馈。
如果要接 Weave
加上 Weave 后,整个运行就更容易回看。你能按顺序看到:
-
用户输入
-
模型的工具请求
-
Python 的校验和执行
-
紧凑工具结果
-
最终回答
这比一段最终答案更有用,因为它保留了判断依据。
这段代码怎么分工
这份脚本里最重要的不是"能跑",而是每一层各做各的事:
• geocode_city 和 get_weather 只负责业务调用
• TOOLS 负责把参数约束告诉模型
• TOOL_REGISTRY 和 execute_tool_call 负责控制执行边界
• compact_tool_result 负责把返回值压缩成模型真正需要的内容
• run_agent 负责循环、停止条件和错误路径
• verify 负责在不消耗模型请求的前提下做预检
结论
工具调用 agent 的可靠性,不是在"最后一句像不像样"上体现的,而是在每个边界都能不能被看见。
先自己写一版最小循环,再决定要不要上框架,会更稳。你会先知道哪些东西必须保持可观察,哪些抽象只是把复杂度藏起来了。如果这套Agent 只用于本地调试,当前脚本已经够用;如果要让它长期运行,定时调用外部服务或者对外提供API,那么就可以考虑迁移到Hostease的服务器或者VPS上,并补上进程守护、日志轮转和密钥管理。
最后一句其实很简单:如果一个 agent 说自己查过了,真正该问的是,它到底执行了什么,返回了什么,你又是怎么知道的。