用 Python 搭建工具调用 Agent 的调试过程

我问这个工具调用 agent 一个不存在城市的天气,原本以为会先在地理编码工具里报错。结果更早失败的是模型请求本身,脚本直接抛了堆栈。后来我只加了一层 try/except,同样的坏输入就不再崩溃,而是返回一条结构化错误,清楚告诉我是哪一步坏了。

这件事把边界讲得很明白:模型请求失败,和工具执行失败,是两类不同的问题,不能共用一条错误路径。

这篇文章讲的就是这条可观察的循环。一个工具调用 agent 不是看最终答案有多像样,而是要同时看模型请求、Schema 校验、Python 执行、紧凑工具结果、错误路径和最终回答。少了这些,调试时你看到的只是一个故事,不是一次运行。

这个循环要回答的四个问题

一个工具调用 agent 常常会说"我查过了",但真正有用的问题在这句话之后:

  1. 模型请求了哪个工具?

  2. 它传了什么参数?

  3. Python 实际返回了什么?

  4. 最终答案有没有真的用到这些返回值?

本文用一个天气例子把这四个问题串起来。用户问:"明天去 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 后,整个运行就更容易回看。你能按顺序看到:

  1. 用户输入

  2. 模型的工具请求

  3. Python 的校验和执行

  4. 紧凑工具结果

  5. 最终回答

这比一段最终答案更有用,因为它保留了判断依据。

这段代码怎么分工

这份脚本里最重要的不是"能跑",而是每一层各做各的事:

• geocode_city 和 get_weather 只负责业务调用

• TOOLS 负责把参数约束告诉模型

• TOOL_REGISTRY 和 execute_tool_call 负责控制执行边界

• compact_tool_result 负责把返回值压缩成模型真正需要的内容

• run_agent 负责循环、停止条件和错误路径

• verify 负责在不消耗模型请求的前提下做预检

结论

工具调用 agent 的可靠性,不是在"最后一句像不像样"上体现的,而是在每个边界都能不能被看见。

先自己写一版最小循环,再决定要不要上框架,会更稳。你会先知道哪些东西必须保持可观察,哪些抽象只是把复杂度藏起来了。如果这套Agent 只用于本地调试,当前脚本已经够用;如果要让它长期运行,定时调用外部服务或者对外提供API,那么就可以考虑迁移到Hostease的服务器或者VPS上,并补上进程守护、日志轮转和密钥管理。

最后一句其实很简单:如果一个 agent 说自己查过了,真正该问的是,它到底执行了什么,返回了什么,你又是怎么知道的。

相关推荐
郭小李几秒前
mybatis 动态 sql
java·sql·mybatis
瑞码空间6 分钟前
JSP与Servlet核心开发与CRUD实战笔记
java·笔记·servlet·jsp
知守观12 分钟前
技术考古:iBATIS 2.3.4 时代的持久层,是如何用 Map 玩转 307 个存储过程的?
java·后端·mybatis
captain3761 小时前
MyBatis Plus(1)
java·服务器·mybatis
科创致远1 小时前
ESOP 电子作业指导书 WiFi 网络应用实测报告
运维·服务器·网络
香蕉大香蕉(06敬畏市场)1 小时前
MySQL 单点详解(08)
java·数据库·mysql·面试·八股
重生之小比特1 小时前
【Linux系统】17 库制作与原理(下)
linux·运维·服务器
thefool1122661 小时前
HTTPS协议
java
门思科技2 小时前
LoRaWAN 网络排障的五层漏斗:从"上不到云"到"数值不对",每一层该用什么工具证明
前端
计算机魔术师2 小时前
Cursor 官方插件商城现恶意 Claude Code 扩展,约 11 小时影响最多 5000 名开发者
前端