做Agent开发,最绕不开的一个需求就是:让模型自己去获取实时信息。很多开发者第一次接触OpenAI SDK里的Agentic AI开发时,第一课几乎都是tools(工具)的使用,而其中最有代表性的落地方向,就是让Agent具备网页搜索能力——也就是常说的WebSearch Agent。这篇文章我就用openai-python SDK从零讲清楚工具的定义、调用机制,以及一个完整的网页搜索智能体实例。整个过程我会把踩过的坑一起写出来,适合刚开始学Agent、或者已经会调API但没搞明白工具循环的读者。
1. 智能体的"工具"到底是什么:先想清楚再动手
1.1 模型的能力边界:为什么非要用tools
先说一个经常被忽略的事实:大语言模型本质上是一个"知识回忆器",它的所有知识都停留在训练数据截止的那一天。你问它今年某个月某场比赛的比分,它大概率会一本正经地编一个数字;你让它查某个域名的当前解析状态,它更是无从下手——不是模型不够聪明,而是它压根没有联网通道。
那怎么破局?思路其实很简单:既然模型自己没法获取外部信息,我们就给它提供"工具",让它写代码一样去调用。模型负责理解用户意图、决定调用哪个工具、解读工具的返回值;工具负责真正执行动作,比如发一个HTTP请求、查数据库、执行一段Python脚本。这个"模型决策+工具执行"的循环,就是Agentic AI的基本形态。
所以你在OpenAI SDK里看到的tools参数,本质上是给模型挂了一双手。模型自己不会去搜索,但它能输出一个结构化的"搜索请求",剩下的交给你的代码去完成。理解这个分工,后面看tool_calls就不会迷糊。
1.2 Agentic AI的核心循环:模型决定,工具执行
我们可以把一次带工具调用的OpenAI请求拆成四个阶段:
- 你发送消息给模型,并在
tools里声明有哪些工具可用,以及每个工具的参数结构。 - 模型判断"这个问题我没把握、需要工具帮忙",于是回复一段特殊的结构——
tool_calls,里面包含工具名和参数(注意是JSON字符串)。 - 你的代码收到
tool_calls后,自己执行对应的函数(比如调用搜索接口、查数据库),拿到结果。 - 你把执行结果作为一条
role="tool"的消息传回给模型,模型看到结果后再继续生成最终回答。如果它觉得信息还不够,可能会再发起一轮工具调用,直到它满意为止。
这个循环就是很多人说的ReAct模式。它的关键点在于:模型从来不会"真的"去执行工具,它只是告诉你"我想用这个工具,参数是这样"。执行动作必须由你的代码完成。这一点和某些支持原生插件调用的框架不同,是OpenAI SDK函数调用的底层设计,理解它之后,你就能自己实现任意Agent循环。
1.3 技术选型的岔路口:内置工具还是自定义函数
OpenAI SDK的tools参数里可以放两类东西。
一类是内置工具(hosted tool),比如Responses API里的web_search_preview。这类工具由OpenAI的服务器直接执行,你不需要自己写搜索逻辑、不需要申请搜索API,一行声明就能用。优点是省事,缺点是灵活性低——你用不了自定义搜索引擎,也拿不到原始搜索结果做二次处理。
另一类是自定义函数工具(custom function)。工具本身只是JSON Schema定义,真正干活的是你写的Python函数。你可以在函数里做很多事情:拼接查询参数、过滤结果、拼接Prompt、甚至对返回内容做文本截断。这是最通用、最值得花时间学会的做法,也是本文实例的核心方式。
我的建议是:如果只是快速Demo、要求不高,直接上内置web_search_preview;如果要做正经Agent产品,最好自己实现工具函数。下面我把自定义函数讲透,再顺便演示内置工具的用法。
2. OpenAI SDK中的工具定义:一步步搞懂JSON Schema与tool_calls
2.1 工具定义的标准格式
在openai-python SDK里,自定义函数工具的标准格式是下面这样:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如:杭州、上海" } }, "required": ["city"] } } } ]这里的parameters必须是一个符合JSON Schema的object。模型正是根据这段Schema来生成参数值的。有一点容易被忽略:description写得越清楚,模型就越容易正确填参。比如上面我写"城市名,例如:杭州、上海",模型就会知道该填中文城市名而不是拼音。
还要注意,模型生成的参数是一个JSON字符串,不是Python字典,所以后面一定要用json.loads()解析。这是新手最常踩的坑之一——直接把字符串当字典用,结果报TypeError。
2.2 一次函数调用的完整生命周期
带上tools参数后,模型有两种可能。第一种:它觉得不需要工具,正常回答,返回值里message.tool_calls是None。第二种:它认为需要工具,message.tool_calls就会是一个列表,列表里每一项包含id、function.name、function.arguments。
下面这段代码可以观察到原始的返回结构:
from openai import OpenAI import json client = OpenAI() messages = [ {"role": "system", "content": "你是天气助手。需要天气信息时使用get_weather工具。"}, {"role": "user", "content": "今天杭州热吗?"} ] resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) message = resp.choices[0].message print("tool_calls:", message.tool_calls)如果模型决定调用工具,打印结果大致是:
tool_calls: [ChatCompletionMessageToolCall(id='call_abc123', function=Function(arguments='{"city": "杭州"}', name='get_weather'), type='function')]注意,这里是模型"请求调用工具",不代表工具已经执行。你需要自己写代码去检测tool_calls,执行对应的函数,再把函数结果回传。
2.3 一个最小可运行Demo:让模型调用时间查询工具
我做了一个最简单的时间查询工具,把完整流程串起来:
from datetime import datetime from openai import OpenAI import json client = OpenAI() def get_current_time(timezone: str) -> str: # 仅作演示,实际应该根据timezone做时区换算 return datetime.now().strftime("%Y-%m-%d %H:%M:%S") tools = [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前日期和时间,参数为时区标识,如Asia/Shanghai", "parameters": { "type": "object", "properties": { "timezone": {"type": "string"} }, "required": ["timezone"] } } } ] messages = [ {"role": "system", "content": "你是时间助手。用户问时间时必须使用get_current_time工具。"}, {"role": "user", "content": "现在几点?"} ] resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) message = resp.choices[0].message if message.tool_calls: messages.append({ "role": "assistant", "content": message.content or "", "tool_calls": [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments } } for tc in message.tool_calls ] }) for tc in message.tool_calls: if tc.function.name == "get_current_time": args = json.loads(tc.function.arguments) result = get_current_time(args["timezone"]) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result }) second_response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=tools, ) print(second_response.choices[0].message.content)这里有个细节容易出错:把带有tool_calls的assistant消息追加进messages时,要保留tool_calls的完整结构,包括id和type。直接在message对象上append,部分版本的SDK能自动序列化,但为了稳妥我建议手动构造字典,这样不管SDK怎么升级行为都可控。
3. 实例:从零搭建WebSearch Agent网页搜索智能体
3.1 需求拆解与整体设计
假设我们要做一个能回答"近期发生了什么事"的网页搜索智能体。用户会问类似"OpenAI最近发布了什么新功能""某赛事昨天结果如何"这种问题,模型的知识库没有答案,必须实时去搜索。
整体设计分成三层:
- 工具层:一个
web_search(query)函数,接收查询词,调用搜索服务的API,返回若干条带标题、摘要、链接的文本结果。 - 策略层:模型在系统提示词中看到工具说明,自主决定是否搜索。用户问实时问题时,模型会输出
web_search调用;问常识问题时,可能直接回答而不用工具。 - 编排层:我们自己写一个while循环,反复把工具结果回传给模型,直到模型不再请求调用工具,输出最终答案。
这里的web_search不绑定特定搜索服务商。你只需要一个能返回JSON结果的网页搜索API,申请到Key后把地址替换掉即可。我把接口的接入处做了占位,方便你自己换。
3.2 第一步:实现web_search搜索工具函数
工具函数要做的很简单:发请求、拿结果、把结果整理成模型容易读的纯文本。下面是我在项目里常用的写法:
import requests import json SEARCH_API_ENDPOINT = "YOUR_SEARCH_API_ENDPOINT" SEARCH_API_KEY = "YOUR_SEARCH_API_KEY" def web_search(query: str, max_results: int = 5) -> str: headers = { "Authorization": f"Bearer {SEARCH_API_KEY}", "Content-Type": "application/json" } params = { "q": query, "count": max_results } try: resp = requests.get(SEARCH_API_ENDPOINT, headers=headers, params=params, timeout=10) resp.raise_for_status() items = resp.json().get("results", []) except Exception as e: return f"搜索服务暂时不可用,请稍后再试。错误信息:{e}" if not items: return "未搜索到相关结果,建议换个关键词。" chunks = [] for idx, item in enumerate(items, 1): title = item.get("title", "无标题") snippet = item.get("snippet", item.get("content", "无摘要")) url = item.get("url", "") chunks.append(f"{idx}. 标题:{title}\n 摘要:{snippet}\n 链接:{url}") return "\n".join(chunks)这里做了两件事。一是把异常降级成了可读文本,而不是直接抛异常,这样即使搜索挂了,模型也能根据提示给出一个完整的"搜索失败"回答,不会整个Agent崩溃。二是把结果压成编号列表,每一条都带上标题、摘要、链接——模型看到这种结构化文本,理解起来比一堆JSON舒服得多。
提示:如果没有现成的搜索API,可以用一个本地索引或静态文档列表来模拟这个函数的返回。Agent循环的测试重点在结构而不再搜索结果本身,先把链路跑通,后面再切换成真实搜索服务。
3.3 第二步:实现Agent循环
核心循环我封装成一个函数,接收用户输入,返回最终答案。完整代码如下:
import json from openai import OpenAI client = OpenAI() TOOLS = [ { "type": "function", "function": { "name": "web_search", "description": "搜索网页获取最新信息。当用户问题涉及实时新闻、近期事件、当前状态时,使用此工具。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "用于搜索引擎的核心关键词,建议控制在10个字以内" } }, "required": ["query"] } } } ] SYSTEM_PROMPT = """你是网页搜索智能体。你的任务是回答用户问题。 规则: 1. 如果问题涉及实时信息、近期新闻、你不知道的内容,先调用web_search搜索,再基于搜索结果回答。 2. 如果问题属于常识,可以直接回答。 3. 回答时必须引用搜索结果,并标注信息可能具有时效性。""" def run_agent(user_input: str, max_rounds: int = 3) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for _ in range(max_rounds): resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=TOOLS, ) message = resp.choices[0].message # 没有工具调用:模型给出最终答案 if not message.tool_calls: return message.content or "模型没有返回内容。" # 保留带工具调用的assistant消息 messages.append({ "role": "assistant", "content": message.content or "", "tool_calls": [ { "id": tc.id, "type": "function", "function": { "name": tc.function.name, "arguments": tc.function.arguments } } for tc in message.tool_calls ] }) # 逐个执行工具调用,把结果回传 for tc in message.tool_calls: if tc.function.name == "web_search": args = json.loads(tc.function.arguments) result_text = web_search(args.get("query", "OpenAI")) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result_text }) return "达到最大工具调用轮数,仍无法给出最终回答。建议放宽max_rounds或检查工具。" print(run_agent("OpenAI最近发布了什么新功能?请搜索后回答。"))这个循环的精髓是max_rounds限制。Agent循环里最常见的失控表现,就是模型为了一个简单问题连续调四五次工具,导致请求量翻倍、上下文膨胀。设置上限,既保护了钱包也保护了响应速度,是生产环境里一定要养成的习惯。
3.4 第三步:跑起来的调用日志与结果解读
我实际跑了一次,把关键日志贴出来给你感受一下。请求顺序大概是这样的:
第 1 次请求: assistant -> tool_calls=[web_search(query="OpenAI 最近 发布 新功能")] 执行web_search函数: 返回 5 条结果,内容包含某产品发布新闻、官方博客更新等 第 2 次请求: assistant,带上搜索结果后,模型给出最终回答: "根据搜索到的信息,OpenAI最近在开发者大会上发布了多款新功能,包括……"你可能会好奇:为什么第一次请求模型没有直接回答,而是"执着"地去搜索?因为我在系统提示词里写死了规则:"涉及实时信息时先搜索再回答"。这个描述直接影响模型的决策频率。想让模型更爱用工具,就在描述里多写触发条件;想让它少调用,就反过来强调它应该直接回答。模型的"工具倾向"本质上是由提示词+工具描述共同塑造的。
跑通之后我还发现一个现象:就算我提供了搜索结果,模型偶尔还是会一本正经地把摘要内容加工成看似合理的答案,这时如果搜索引擎返回的摘要本身质量差,模型会把垃圾信息也当成事实。所以工具函数里我尽量多保留原始链接和摘要原文,少做改写,让模型有更多上下文判断。
3.5 扩展方案:如果只想用一行代码,试试内置的web_search工具
如果你的项目不需要自定义搜索引擎,也不想自己维护Agent循环,OpenAI的Responses API里直接提供了内置的网页搜索工具。用法很简洁:
from openai import OpenAI client = OpenAI() response = client.responses.create( model="gpt-4o-mini", tools=[{ "type": "web_search_preview", "search_context_size": "medium", "user_location": { "type": "approximate", "country": "CN", "city": "Hangzhou" } }], input="最近AI行业有什么值得关注的新闻?请搜索后回答。" ) print(response.output_text)search_context_size可以填small、medium、large,控制搜索返回的上下文量,直接影响调用价格和回答质量。user_location是可选的地理信息,让搜索厂商可以对结果做区域相关排序。我建议按照"先小样本、再扩大"的思路调参,因为搜索页数越大,成本越高,很多场景用small就够了。
内置搜索工具返回的结果里还带了sources来源,方便你后续核对模型回答依赖了哪些网页。这在做合规、溯源时非常重要。缺点是无法自定义过滤规则,如果搜索引擎返回的低质结果较多,你只能靠模型自己判断。生产项目里我更喜欢自定义函数,就是这个原因——结果流的每一步我都能控制。
4. 常见问题与避坑经验
4.1 工具输出过长,上下文被塞爆
我有一次把搜索结果一股脑塞进上下文,结果单次对话还没结束,token就超限了。工具返回的原始HTML可能有几万字,而模型真正用到的有效信息往往只有开头几段。
解决办法是在工具函数里做截断和格式化。我现在习惯给所有工具函数的返回加一个MAX_CHARS上限,超过的文本统一截断,并保留重要字段(标题、摘要、链接)。另外还要注意,多次工具调用的结果会一直留在messages里,轮数越多,上下文越长,所以max_rounds限制不只是为了防止死循环,更是为了控制token成本。
4.2 模型反复调用同一个工具
最常见的原因是工具函数返回了模糊的"空结果"。比如搜索API没返回内容,工具只返回了"未找到结果",模型看到后觉得"没搜到就再搜一次",于是换个关键词继续搜,周而复始。
我在工具函数里会做两层防护。第一层是超时和异常降级,返回"搜索服务暂时不可用"这类明确错误状态,模型看到后会选择回答用户而不是继续搜索。第二层是在Agent循环里统计连续调用次数,如果同一个工具的调用次数超过了阈值(比如3次),就直接中断并把已拿到的所有结果交给模型,强制它生成最终回答。
4.3 搜索结果里全是噪音,模型还在复述
搜索引擎返回的摘要不一定和问题相关。比如你搜"某产品性能评测",前几条全是营销页,模型不会识别营销属性,只会照单全收。这是网页搜索智能体最难搞的部分。
我现在会在工具函数的截断逻辑里保留一部分元信息,比如score(如果有)、domain、published_time,然后在系统提示词里加一句规则:"如果多个内容来源领域差异明显,优先采信带有明确时间标注和可验证链接的信息"。这不能根治问题,但实测下来能让模型在不确定时更加谨慎。真正要治本,就得接入更高质量的搜索数据源,或者在工具层对结果做更细的过滤,比如排除特定广告域名。
4.4 其他容易踩的坑:错误处理、频率限制、并发
再聊几个零碎但影响很大的点:
- 工具调用参数解析一定要加异常处理。模型偶尔会生成非法的JSON字符串,
json.loads直接抛异常会把整个Agent打断。我习惯写一个safe_parse函数,解析失败时返回空字典,并把这个异常情况作为一个工具结果回传给模型,让它自己修正。 - 注意API频率限制。如果智能体循环较快,一次对话可能连续触发多个工具调用,如果工具又依赖第三方API,很容易触发对方的Rate Limit。建议给外部调用加上简单的限速和指数退避重试。
- 日志里一定要打印
tool_call_id和arguments。工具调用一旦出错,没有日志根本定位不了是哪一次调用、传了什么参数。我一般在每次调用前后各打印一行,兼顾观察和排障。 - 并发场景下注意
messages列表的线程安全。如果你在一个服务里同时处理多个用户会话,千万别共用一个全局messages列表,每个会话都要有自己独立的上下文数组。
4.5 常见问题速查表
| 问题 | 典型表现 | 优先排查项 |
|---|---|---|
| 工具没被触发 | 模型直接回答,无tool_calls | 检查工具description是否写清触发条件;系统提示词是否允许用工具 |
| 工具参数解析失败 | JSONDecodeError | 查看arguments原始字符串,确认是否为合法JSON;用safe_parse兜底 |
| 上下文超限 | 请求报输入超长 | 检查工具返回内容是否过长;增加MAX_CHARS截断;减少max_rounds |
| 死循环调工具 | 多次请求同一工具 | 检查空结果返回格式;增加轮数上限和连续调用中断 |
| 回答质量差 | 编造链接或过期信息 | 保留原始搜索摘要和链接;提示词明确要求引用来源和标注时效性 |
| 第三方API限流 | 工具函数报429 | 加限速、加退避重试、降低搜索频率 |
注意:OpenAI SDK的版本更新较快,Responses API和内置工具的参数在不同版本可能有细微差异。本文示例基于较新的openai-python版本编写,如果你遇到属性不存在或参数名对不上,优先查看官方文档对应版块的示例,结构不会有太大变化。
最后再分享一个我个人的习惯:抓包式调试Agent。所谓抓包式调试,就是把Agent每一轮的消息、工具调用、返回结果全部打印出来,一字不落地看。刚开始做WebSearch Agent时我经常觉得"代码没问题",结果就是模型不调工具或者瞎调,后来发现所有玄学问题,百分之九十九都能在完整日志里找到答案。这个习惯帮我省了无数排查时间,也让我在几次线上异常回滚时,能快速定位到是哪个工具的返回格式破坏了模型的判断。工具类Agent的开发,本质上就是把"模型怎么想"和"你的代码怎么执行"彻底打通的过程,一旦打通,你会发现AI应用能做的事情立刻上了一个台阶。