做智能体开发这段时间,我最大的感受是:OpenAI SDK里那个不起眼的tools参数,才是从"会聊天"到"会干活"的真正分水岭。标题里的Agentic AI,说白了就是让大模型不再只靠记忆回答,而是学会调用工具去查资料、跑计算、操作外部系统。这篇博文我会从最基础的tools定义开始,拆解Function Calling的完整工作流程,然后带大家手把手实现一个WebSearch Agent(网页搜索智能体),把搜索能力装进模型里。适合正在学习Prompt Engineering、准备接触Agent开发、或者被各种工具调用参数搞晕的开发者。
1. 为什么需要 tools:一次问答和一只 Agent 的差别
1.1 单轮对话的边界
如果不使用tools,一个标准的对话请求长这样:你把用户问题丢给模型,模型基于训练时学到的知识和上下文直接生成答案。这在很多场景下够用,但你很快会发现三个致命问题。
第一,模型的知识有截止日期。你今天问它"某明星最近有什么绯闻",它很可能一本正经地编一个答案,因为它训练数据里没有这个信息。第二,模型只能"说"不能"做"。你让它"帮我查一下这个订单的物流轨迹",它没有权限、也没有接口去查物流系统,只能告诉你"请联系客服"。第三,即使是最擅长数学的模型,在复杂计算上也可能出错,因为它更像是一个语言预测器,而不是精确的计算器。
这就引出了Agentic AI的核心假设:真正有用的智能体,不应该只是一个会说话的脑袋,而应该是一套"感知—思考—行动—再感知"的闭环系统。而tools工具,就是给模型装上的手和脚。
1.2 Agentic AI 的核心循环
可以把一次Agent调用想象成一个新员工入职。你告诉它目标(system prompt + 用户消息),它先判断自己需要哪些信息。如果信息不够,它会"申请"调用某个外部工具——比如搜索引擎、内部数据库、计算器——拿到结果后,再把这些结果作为新的事实基础,继续推理,直到最终给出答复。
这个大循环里,OpenAI SDK的tool_calls机制扮演了关键角色。模型并不真正执行你的函数,它只是输出一个结构化的调用请求:调用哪个函数,参数是什么。真正执行函数的是你的代码。执行完之后,你把结果以role: "tool"的消息传回给模型,模型看到结果后再继续回答。这种设计的好处是安全可控,你不会把API key或者敏感逻辑直接暴露给模型,模型拿到的只是你允许它调用的工具描述。
1.3 WebSearch Agent 是最容易上手的入口
在众多工具类型里,网页搜索智能体是最典型、也最容易出效果的入门项目。原因很实际:搜索能力可以瞬间弥补模型的知识盲区,让回答里出现真实的、新鲜的信息。以前我做一个"最新AI新闻汇总"的Demo,每次都需要自己爬新闻、清洗、塞进prompt,麻烦得要死。换成WebSearch Agent之后,用户只需要问一句"帮我整理一下最近五天的AI行业大事件",模型就会自己去搜索网页,再总结成结构化的输出。
虽然市面上已经有各种Agent框架,但我还是建议你先手写一遍OpenAI SDK的原生调用。因为框架封装了太多细节,出了问题你往往不知道是框架的锅还是模型本身的锅。手写一遍之后,你对tool定义、消息格式、返回结果的理解都会透彻很多,后面再上框架完全来得及。
2. OpenAI SDK tools 工具的基础用法
2.1 function tool:把函数变成模型的手脚
先看最常见的自定义工具,也就是type: "function"。假设我们想给模型一个查询天气的能力。我不能真的去实现一个天气API,但教学目的够了。Tools的JSON结构长这样:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市当前的天气情况,包括温度、湿度和风速", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,比如 北京、上海、广州" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位" } }, "required": ["city"] } } } ]这里有几个点需要特别留意。description字段非常关键,模型其实看不懂你的代码逻辑,它只能通过这些描述来理解什么时候该调用这个工具、应该传什么参数。我见过很多人把description写得特别简短,比如"查询天气",结果模型在用户问"带什么衣服合适"时完全没想到调用天气工具。后来我把描述改成"查询指定城市当前天气情况,包括温度、湿度,适合用于回答穿衣建议、出行计划等问题",调用准确率立刻上来了,真的不夸张。
第二个点是parameters必须符合JSON Schema规范。type: "object"包裹,properties里每个参数都要声明类型和描述。required数组决定哪些参数是必填的。如果不写enum,模型在生成unit参数时就可能填一个"celsius"以外的奇怪值,虽然不一定会报错,但会增加你解析参数时的负担。
2.2 内置工具:WebSearch、代码解释器这类开箱即用的能力
除了自定义函数,OpenAI SDK还提供了几个内置工具,典型的像web_search_preview和code_interpreter。这些工具不需要你提供函数的实现,模型在生成回复时会自动调用后台服务。不过要注意,内置工具目前在Responses API里用起来最顺手,而旧版Chat Completions API里没有直接开放的web_search工具参数,这也是很多人在社区里吐槽的点。
我自己写WebSearch Agent,优先走Responses API。代码非常短:
from openai import OpenAI client = OpenAI() response = client.responses.create( model="gpt-4o", tools=[{"type": "web_search_preview"}], input="帮我搜索最近一周内关于人形机器人的重要进展,并总结三个趋势" ) print(response.output_text)responses.create的输入直接就是用户消息,不需要自己组装messages数组的初始chat格式。模型收到问题后,会自动发起一次网页搜索,然后把搜索结果和回答一起生成到output_text里。如果想让输出更规范,可以加一句instructions,比如"请用中文回答,并且每条要点后附上来源链接"。
2.3 关键参数:tool_choice 和 parallel_tool_calls
tools数组只是定义了"有哪些工具可选",真正控制模型行为的是tool_choice参数。
tool_choice: "auto":模型自主判断是否调用工具,这是最常用的。tool_choice: "none":禁用工具,相当于纯问答。tool_choice: "required":强制模型必须调用至少一个工具。tool_choice: {"type": "function", "function": {"name": "get_weather"}}:指定必须调用某个函数。
我在测试中遇到过一种场景:我希望模型先搜索再回答,但模型有时候觉得"自己知道"就直接回答了,没有触发搜索。当时的解决方式就是给tool_choice传{"type": "function", "function": {"name": "search_web"}},强制第一步必须是搜索。当然,这样会在所有请求里都触发搜索,成本会高一些,适合需要确保时效性的场景。
另一个参数parallel_tool_calls默认是True,意思是模型可以在一次回复里同时生成多个tool_calls。比如用户问"对比北京和上海的天气",模型可能会同时调用两次get_weather,分别传不同的城市。如果你的工具之间没有依赖关系,并行调用能显著减少请求轮次。但要注意,如果你的函数实现不是线程安全的,或者多个工具之间可能有逻辑冲突,最好把它设为False,让模型一次只调用一个工具。
3. 实战:网页搜索智能体 WebSearch Agent
3.1 方案选型:内置 WebSearch 还是自定义 Function Calling
做WebSearch Agent有两条常见路线:直接使用官方内置的web_search_preview工具,或者通过Function Calling自己包装一个搜索函数。怎么选?我的经验是看你要不要对"搜索过程"做控制。
用内置工具,最大的好处是省心,你不用管搜索API、结果解析、引用格式,模型会自动带着搜索结果回答问题。缺点是黑盒,你很难决定它用的什么搜索源、结果怎么排序。如果只是做一个新闻汇总、知识问答类的智能体,内置WebSearch完全够用。
自定义Function Calling,适合已经有自己的搜索服务(比如企业内部文档检索、私有数据库、或者已经对接好的第三方搜索服务)的情况。你只需要把用户问题提取成关键词,调用自己的接口,把结构化结果喂回模型。这条路更灵活,但需要自己管理搜索质量、结果长度、错误处理——不过这也正是我们理解Agent机制最好的练习方式。下面两种我都写一遍。
3.2 用内置 web_search_preview 实现三行代码搜索
先说内置方案。你需要在Python环境里安装OpenAI SDK,并确保版本不要太老。我自己目前用的是支持Responses API的版本。
import os from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) def ask_with_search(user_prompt: str) -> str: response = client.responses.create( model="gpt-4o", instructions="你是擅长检索和总结的助手,回答要简洁,并尽量透露来源信息。", tools=[{"type": "web_search_preview"}], input=user_prompt, ) return response.output_text if __name__ == "__main__": result = ask_with_search("2026年世界人工智能大会定在什么时间举办?有哪些亮点?") print(result)执行这个函数,你会看到一个完整的回答,里面通常包含了从搜索结果中提取的事实。如果你想拿到原始搜索结果对象,可以遍历response.output,里面会有一个类型为web_search_call的输出项,结构类似于调用了搜索工具的日志。我一般会在调试阶段把它打印出来,确认模型确实执行了搜索,而不是凭记忆乱答。
这里有个细节:output_text是模型最终生成的文本,但搜索引用不一定都出现在最后那段文字里。有些版本会把引用链接作为消息里的annotations返回。所以如果你的产品需要展示参考链接,不要只依赖output_text,建议同时检查response.output里的message对象,找到content类型为output_text或output_ref的条目,再提取annotations中的URL。不同SDK版本字段命名可能会有调整,以官方文档的模型字段为准。
3.3 用 Function Calling 实现可控的搜索循环
下面我们进入重头戏,手写一个基于Function Calling的搜索循环。假设你有一个内部的搜索服务,或者一个包装好的search_web函数,它的输入是关键词,返回是一个JSON列表。
先定义工具:
from openai import OpenAI import json client = OpenAI() tools = [ { "type": "function", "function": { "name": "search_web", "description": "搜索互联网上的公开信息,返回包含标题、链接、摘要的列表。当用户需要最新信息、动态、新闻时,使用此工具。", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "用于搜索的准确关键词,不要过长,一般控制在10个字以内效果更好" } }, "required": ["query"] } } } ]然后写一个模拟的搜索引擎函数。这里注意,我没有接入某个具体服务,而是返回了演示结构。你完全可以把函数体替换成对你自己搜索API的请求:
def search_web(query: str): # 这里替换成真实搜索接口 # 示例:resp = requests.get("https://your-search-service/api", params={"q": query}) # return resp.json()["results"] return [ { "title": "关于" + query + "的示例新闻", "url": "https://example.com/news/1", "snippet": "这是关于" + query + "的一段摘要,包含关键信息。" } ]接下来是完整的Agent循环。核心流程是:先发一条用户消息,模型返回后检查tool_calls;如果有,就执行对应的函数,然后把结果以tool消息塞回历史,再次调用模型;直到模型不再返回tool_calls,就拿到了最终答案。
def run_search_agent(user_message: str): messages = [{"role": "user", "content": user_message}] response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto", ) while True: assistant_message = response.choices[0].message # 如果没有工具调用,说明模型准备好直接回答了 if not assistant_message.tool_calls: return assistant_message.content # 1. 先把带tool_calls的assistant消息追加到历史中 messages.append(assistant_message) # 2. 处理每一个tool_call for tool_call in assistant_message.tool_calls: if tool_call.function.name == "search_web": args = json.loads(tool_call.function.arguments) result = search_web(args["query"]) # 3. 把工具执行结果作为tool消息追加 messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) # 4. 再次请求模型,带着工具返回的结果 response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto", ) result = run_search_agent("帮我搜索最近关于开源大模型的重要发布,并总结成三点。") print(result)这段代码是整个智能体的骨架,它把Function Calling的多轮交互完整地串了起来。我特别想强调一个容易出错的地方:当assistant_message带有tool_calls时,你必须把这条assistant消息原封不动地追加到messages里,不要丢掉。有些同学习惯只保存content,结果下一步模型就找不到对应tool_call_id了,然后报错"invalid tool_call_id"。另外,tool消息里的content必须是字符串。如果你的搜索结果是一个Python对象,记得用json.dumps序列化,而不是直接塞dict。
3.4 把 Agent 做得更像"人":连招和上下文管理
上面的循环已经是一个可用的WebSearch Agent,但实际场景中用户可能会提出多个需要搜索的问题。比如"帮我分别搜一下某公司和某公司的最新财报,并从营收、利润、增长三方面对比。"这时模型可能会在同一轮里返回两个tool_calls,一个搜A,一个搜B。我们的for循环天然支持这种并行调用,逐个执行后各回一条tool消息即可。
不过并行调用也会暴露一个设计问题:如果两个搜索结果非常长,拼到消息里会占用大量上下文窗口。所以我建议在search_web返回结果后,不要把所有原始内容都塞给模型,而是先做一层精简。比如把摘要截断到100个字符,或者只传top 5条结果。我在实际项目里就踩过坑:搜索返回了二十几条冗长条目,直接塞进去后,模型反而抓不住重点,输出变得啰嗦,还容易跑题。后来我让搜索服务返回时只保留标题、链接和两行摘要,回答质量立刻稳定了。
还有一个上下文管理的细节:随着工具调用次数增加,messages数组会越来越长,导致后续请求变慢且费用上升。如果工具循环超过两轮,可以考虑把早期的消息做摘要,或者丢弃一些无关的搜索结果。如果你用的是Responses API,它会自动维护一部分上下文,但自定义Function Calling就得自己处理。
4. 避坑指南与排查套路
4.1 高频报错速查表
开发过程中我前前后后踩了不少坑,很多问题报错信息并不直观。整理了一张表,按下述顺序排查基本都能解决:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型不调用任何工具 | tool_choice设为none,或prompt没有暗示需要外部信息 | 改成auto,并在system提示"实时信息需要搜索" |
报错Invalid tool_call_id | tool消息里的tool_call_id和assistant消息里的对不上 | 打印所有tool_calls的id,确认追加时逐一对应用;不要自己造id |
报错Function not found | tools里没有定义该函数名,或名字大小写不一致 | 检查name字段和调用时的tool_call.function.name完全一致 |
报错Bad Request | JSON Schema格式不正确,或parameters缺type | 把tools单独拿出来做校验,用json.loads测试是否合法 |
| 搜索请求返回超时 | 搜索服务响应过慢,或HTTP请求无超时设置 | 给requests加timeout=10,并对超时做重试 |
| 模型回答内容很泛,像没搜过 | search_web返回内容为空,或摘要截断过短 | 打印工具返回内容,检查搜索词是否被正确提取 |
| 429限流 | 并发请求太多或触达额度 | 用指数退避重试,或降低parallel_tool_calls |
| 上下文开销太大 | 多轮循环中消息不断堆积 | 对旧消息做摘要,或丢弃低质量搜索结果 |
4.2 API 测试与调试技巧
我在调试这类Agent时,最常用的技巧是先写一个mock_search_web函数,不真的去请求网络,而是返回一段固定的模拟结果。先用它跑通整个循环,确认消息流无误,再换成真实搜索服务。这样能隔离问题——如果mock状态一切正常,但真实环境报错,问题大概率在搜索接口那边,而不是Agent逻辑出错。
另一个建议是在每次chat.completions.create调用后,打印出response.choices[0].message.tool_calls的结构。你会看到模型生成的id、function.name、function.arguments是什么。arguments看起来像一个JSON字符串,但它可能是带转义的空格、或者非常规引号。直接json.loads有时候会失败,我遇到过模型返回"query":" "带前后空格的情况。保险的做法是在loads之前对字符串做一次strip(),或者用json.loads(args if args else "{}")防止空字符串。
tool_calls的索引细节也值得注意。如果一次返回了多个tool_calls,你在追加assistant消息时,必须是把整条message追加进去,然后再逐个追加tool消息。很多人写成"先把每个tool_call对应的tool消息都追加完,再追加上一条assistant消息",这个顺序是错的。模型需要先看到它自己提出了哪些调用,再看到每个调用的结果,顺序反了会导致上下文混乱。
4.3 工具设计的三条价值观
模块折腾久了,你会慢慢总结出一些比写代码更重要的经验。我觉得工具设计有三条原则,基本可以套用在所有Agent项目上。
第一,工具粒度要小。尽可能让每个函数只做一件事。我一开始把"搜索+解析+总结"封装成一个工具,结果模型调用了一次后直接要结果,反而跳过了中间的思考步骤。后来拆分成search_web和calc两个独立工具,模型的规划能力反而被激发了出来,它会判断什么时候该搜索,什么时候该计算。
第二,参数描述要像在训练客服。描述要包含"什么时候用"和"怎么填参数",最好附带一两个典型场景例子。比如query参数的描述,我写的是"用于搜索的准确关键词,不要过长,一般控制在10个字以内效果更好",这句描述几乎每次都能让模型生成更合理的搜索词。
第三,返回结果要"干净"。工具返回的是数据,不是文章。返回给模型的字符串应该一目了然,不要带一堆模板广告、网页噪音、无关标签。你可以把结果设计成结构化的短JSON,让模型直接基于JSON字段进行总结。我在某个项目里曾经把HTML原文塞给模型,结果它开始分析HTML标签,回答得非常离谱。从那之后,所有搜索结果我一定先清洗再投喂。
最后再分享一个细节
如果你在跑上面代码时发现模型总是"自作聪明"地回答,不搜网页,除了检查tool_choice之外,还有个容易被忽略的原因:instructions或system prompt默认告诉模型"你是AI助手,根据知识回答问题",这句话没有提到可以利用搜索工具。可以在prompt里明确加一句:"你的知识截止于训练日期,涉及动态信息时必须调用search_web工具,不得凭空编造。"这一句话,往往比调半天参数都管用。
我最初做WebSearch Agent的时候,最大的收获不是学会了写tools数组,而是理解了Agent本质上是一次"模型与工具的协作对话"。模型负责判断、规划、表达,工具负责提供事实、执行动作,而我们开发者真正要做的,是把两者之间的接口设计得足够清晰、足够安全。希望这篇手记能让你少走一点弯路,直接动手把自己的搜索智能体跑起来,然后在真实使用中体会那些文档里写不出来的手感。