用 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 说自己查过了,真正该问的是,它到底执行了什么,返回了什么,你又是怎么知道的。

相关推荐
我命由我123452 小时前
Android 控件 - CardView(快速实现圆角)
android·java·java-ee·kotlin·android studio·android-studio·android runtime
2401_881828322 小时前
简易文本处理网页工具|AI 通识课第三次作业
前端·javascript·html
2601_963869952 小时前
【计算机毕业设计】基于Java的潮牌购物网站系统的设计与实现
java·开发语言·课程设计
203号居民2 小时前
LeetCode hot 100 —560. 和为 K 的子数组
java·算法·leetcode
障碍的枫子3 小时前
Vue组件导出&渲染
前端·javascript·vue.js
开开心心就好3 小时前
视频播放器完美解码集成三款播放器切换使用
前端·人工智能·智能手机·电脑·音视频·virtualenv·pygame
Sterting3 小时前
组件通信:Props 与 Emit
前端·vue.js
雾时之林3 小时前
Linux--介绍及管理
linux·运维·服务器
浪客川3 小时前
使用 IDEA 生成API文档
java·ide·intellij-idea