☰
AI Agent工具调用实战:大模型接口接入与避坑指南
2026/10/7 5:26:08 网站建设 项目流程

写AI Agent系列教程写到第三篇,前两篇聊了Agent的骨架搭建和流程编排,今天这篇是承上启下的关键环节:大模型接口与工具调用。AI Agent圈里流传着一句话:没有工具调用的Agent只是聊天机器人,接上工具调用的Agent才能干活。这句话有点绝对,但方向是对的——一个Agent要完成"查天气、订机票、算一笔账、操作某个系统"这类实际任务,光靠模型自己生成文本是不够的,它必须有能力把"意图"翻译成"可执行的调用动作",再把调用结果拿回来继续推理。这篇教程就是围绕这条链路展开,我会先把工具调用的底层思路讲透,再说清楚接口接入时容易被忽略的参数和协议细节,最后给出可复现的代码和一张避坑清单。适合正在做Agent开发、但被工具调用卡住的开发者,看过之后你应该能自己把手上的模型接口变成一套真正能调工具的Agent能力。

1. 先搞懂工具调用到底解决什么问题

1.1 Agent的"脑"和"手"为什么必须分开

很多人第一次接触工具调用(Tool Calling / Function Calling)时,会误以为这玩意儿是"模型学会了一个新函数,然后自己调用它"。真实情况完全不是这样。模型本质上是一个文本生成器,它没有执行能力,它做的事情只有一件:根据上下文预测下一个token。所谓"工具调用",是让模型在生成文本的过程中,额外生成一段结构化的"调用意图",由我们自己的应用程序去解析这段意图、执行函数、再把结果塞回给模型。

举个例子。用户说:"帮我把'明天下午三点和张三开会'添加到日历。"如果只靠对话生成,模型最多回你一句"好的,我已经记住了"。但用户的真实需求是日历里真的多出一条日程。正确做法是:模型生成一个JSON结构,比如{"action": "create_event", "params": {"title": "和张三开会", "time": "明天下午三点"}},然后你的代码收到这个JSON之后,去调用日历API完成写入,再把写入结果返回给模型,让它组织一句人话回复给用户。

这个设计的关键在于"脑"和"手"分离。模型负责理解意图、拆解任务、决定调什么、参数怎么填——这是它的"脑";真正去操作日历系统、查数据库、发HTTP请求的,是外部的函数——这是Agent的"手"。模型永远不直接接触你的系统资源,它只是输出"调用意图",这既安全又解耦。一旦你决定换一个模型供应商,只要对方的接口支持同样的协议,你的整套执行逻辑一行都不用改。

1.2 一次完整工具调用的六步链路

把一次工具调用拆开,大概有六个步骤。这个链路我反复跟团队强调:写代码之前先在脑子里把这六步画熟,因为80%的开发问题都出在某一步被跳过了或者顺序搞反了。

第一步,把你准备好的工具列表作为结构化描述传给模型。工具不仅仅是函数名,而是一份完整的JSON Schema,包含函数名、描述、每个参数的名称、类型、是否必填、以及参数的说明文字。第二步,模型读完用户提问和工具列表,在内部做推理,决定"现在该不该调用某个工具"。如果该调,它就在输出中携带一个工具调用指令(tool_calls),里面写清楚调用哪个工具、参数填什么。第三步,你的代码收到这个指令,注意这里要做一层校验,确认指令里的函数名确实存在、参数数量齐全,然后去执行真实的函数。第四步,把执行结果(成功或失败、返回的数据)以一条role为"tool"的消息回传给模型。第五步,模型看到工具返回结果,开始下一轮推理:如果结果满足需求,就生成最终回复;如果还需要其他信息,可能再次发起新的工具调用。第六步,循环第三步到第五步,直到模型生成最终回复或达到上限。

这个链路里,最反直觉的是第四步。很多人以为工具调用是"模型自动完成了一切",实际上每一步都需要你的代码显式参与,尤其是"把结果返回给模型"这个动作。如果忘记把工具结果回传,模型就不知道自己刚才的调用发生了什么,会一直卡在等待状态,或者开始胡编乱造。

1.3 为什么非用结构化参数不可

一个更容易被忽视的问题是:为什么要费劲把参数写成JSON Schema?直接让模型在聊天回复里写一句"调用get_weather(Shanghai)"不就行了吗?

试试就知道行不通。模型生成的文本有随机性,今天它可能输出"get_weather(SH)",明天变成"get_weather('上海')",后天干脆中英混杂。你的业务代码如果依赖自然语言指令去解析,迟早被各种变体折磨到崩溃。JSON Schema的价值在于它把参数格式固定成了一套严格的约定:字段名固定、类型固定、必填与否固定、枚举范围固定。模型只要在这套约定里生成,你的代码就能用现成的JSON解析器、校验器、类型转换器安全地处理,这就是为什么现代模型平台的工具调用能力全部建立在JSON Schema之上。

这也是"把规则交给格式、把理解交给模型"的思路。你不要指望模型记住你的业务规则,但你要相信,只要格式足够明确,模型就有很大概率产出正确的结构。举个我实际遇到的例子,一个Agent需要调查询订单接口,参数要求order_id必须是字符串,但我们早期工具定义里把类型随手写成了number,结果模型死活不肯用引号包裹订单号,反而在参数里填了科学计数法sample,最后解析失败。后来把类型改回string,问题立刻消失。这就是结构化格式对效果的直接影响,细节里的偏差最终都会体现在结果上。

2. 接入大模型接口前,这些参数值得先过一遍

2.1 协议差异:原生SDK与兼容接口怎么选

现在市面上的大模型接口,主流分两类。一类是各家的原生SDK,比如OpenAI的Python/Node SDK、Anthropic的SDK,封装很友好,生态也成熟;另一类是兼容协议接口,不少模型服务商直接声明"兼容OpenAI API格式",意味着你可以在不换SDK的前提下,只改base_url和api_key就接入另一家的模型。做Agent项目时,除非你确定一辈子只锁定一家模型厂商,否则更推荐用通用协议+兼容接口的方式,把模型提供商变成配置项而不是写死在代码里的依赖。这样以后想换模型、做模型间对比、甚至做路由分发,都只是改个配置的事。

我自己在项目里会多封装一层"模型客户端工厂",接口统一暴露chat_completion(messages, tools, ...)方法,底层根据配置切换不同厂商。这层封装看着不起眼,但等到你需要同时测三个模型谁的函数调用能力更稳定时,就会庆幸当初没把SDK调用散落得到处都是。

2.2 七个直接影响工具调用效果的参数

接入接口不只是填一个API Key的事。同样的工具定义和提示词,参数设置不同,调用效果可能天差地别。这里列出我在实际调试中验证过的七个关键参数。

第一个是temperature。决定生成随机性,取值范围通常是0到2。工具调用场景下我强烈建议调低,0.1到0.3为宜。原因很好理解:你希望模型优先选择确定性的工具调用方案,而不是天马行空编参数。实测下来temperature偏高时,模型偶尔会把时间格式写歪、把必填字段漏掉。

第二个是max_tokens(或max_completion_tokens)。有些平台上的这个参数直接影响模型能生成多长的输出,如果你的工具定义很长、上下文里又有大量历史消息,max_tokens设太小说不定模型的答复在生成半截就被截断,导致JSON不完整、解析失败。一般给800到3000之间比较合理,具体看业务复杂度。

第三个是top_p。不少开发者会在调temperature的同时把top_p也调低,认为"低随机性双保险"。其实没必要,官方文档也建议这两个参数不要同时改,改一个就行。我通常只动temperature,top_p保持默认1.0。

第四个是tool_choice。默认是auto,表示让模型自己决定调不调工具。但有些场景你需要强制模型调用某个工具,这时可以传{"type": "function", "function": {"name": "search_order"}}或对应的字符串写法。比如一个查订单的Agent,用户问"我那个快递怎么回事",你希望它直接就调查询工具,而不是先回复一段"好的让我帮您查一下"。强制工具模式在客服类Agent里很实用。

第五个是parallel_tool_calls。现代模型支持一次回复中并行发起多个工具调用,比如同时查天气和查航班。默认开启时,模型会在一次输出里生成多个工具指令,这能极大提升多工具场景的效率。但如果你的工具之间有依赖关系,比如第二个工具的参数要等第一个工具的返回值才能确定,那就必须把并行关掉,否则模型会瞎填参数。

第六个是stream。流式输出和工具调用可以共存,但处理起来要比非流式复杂一个量级。非流式模式下,你等完整响应拿一次JSON就完事;流式模式下,工具调用参数会被切成碎片陆续到达,需要自己拼装。如果你的项目对打字机效果没有硬性要求,前期可以先不开流,把逻辑跑通再优化体验。

第七个是超时和重试参数。模型接口调用受网络波动影响很大,尤其是工具调用这种需要多轮往返的场景,单次请求超时建议设置45秒以上,重试要配合指数退避。后面第四章我会讲超时和重试的具体实现。

2.3 System提示词怎么和工具定义相互配合

工具调用不是只靠JSON Schema就能做好的,System提示词也在暗中起大作用。一个常见做法是把Agent的"行为准则"写进System提示词:什么时候该调用工具、调不到预期结果时要怎么说、不允许臆造工具返回数据。这些规则跟JSON Schema的字段描述配合起来,模型的理解会更准确。

比如我做日历Agent时,JSON Schema里描述了create_event的参数,同时在System里明确了规则:"当用户提到安排日程时,必须调用create_event;若用户只问日程查询,直接回答而不调用create_event。"这样区分在有的场景下很必要,因为模型可能一看到时间相关信息就兴奋地去调用创建事件的工具,实际上用户只是想问个时间。把规则和工具描述分开写,比混在一起更容易让模型各取所需。

3. 手把手写一个支持工具调用的Agent

3.1 工具描述的正确写法

这里直接给一套我反复验证过的工具描述模板,照着套就能降低模型理解偏差。一个完整的函数工具定义包括type(固定为function)、function对象里的name、description、parameters。核心是description和parameters里的每个字段描述,这两处是模型决定"何时调用"和"参数怎么填"的依据。

description的写法有讲究。不要只写"查询天气",要写清楚什么时候用、用了能解决什么。比如:"当用户询问某个城市现在或未来几天的天气情况时使用。该工具返回温度、天气状况、风力等实时气象信息。"这样模型就知道触发条件是"询问天气"而不是"闲聊提到下雨"。

参数描述同样要具体。时间字段要写明格式:"支持YYYY-MM-DD格式,可以传'今天'、'明天'等相对日期。"城市字段写明约定:"优先使用中文城市名,若用户提到当地地名且不在系统全国城市表时,默认返回最近地级市数据。"别小看这些补充说明,模型真的会按照字段描述里的格式要求去填参数,描述写清楚比事后写一堆参数清洗逻辑要省力得多。

下面是一个实际可用的天气工具定义简化版,保留了核心结构:

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市当前天气或未来天气预报。适用于用户询问温度、天气状况、风力的场景。", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,使用中文完整名称,例如'北京''上海'。" }, "date": { "type": "string", "description": "查询日期,取值'今天''明天'或'YYYY-MM-DD'格式,默认为今天。" } }, "required": ["city"] } } } ]

3.2 消息循环:四类角色的正确拼法

工具调用和普通对话最大的区别,在于消息列表里多了两类角色。理解这四类的职责,是写好Agent的根基。

第一类是system,固定不变,用来设定模型的身份和行为准则。第二类是user,存放真实用户的输入。第三类是assistant,存放模型的答复和工具调用指令,注意模型生成的tool_calls要原样返回给模型,历史轮次里不能删掉。第四类是tool,存放工具执行结果,每条tool消息必须带一个tool_call_id,用来关联它回应的那一次调用指令。

关键点在于:一次工具调用完成后,你带回去的历史记录里,上一轮assistant消息要能够完整覆盖模型当时的行为。如果你的代码没有把assistant消息原样保存,下一轮request里模型就搞不清楚"这是我调用的"还是"用户说的",轻则逻辑混乱,重则工具循环调用停不下来。我踩过最典型的一次:在对话压缩逻辑里把assistant的tool_calls字段剥离了,只保留了文本部分,结果模型每次都说"好的我来查一下"然后又发起同样的调用,形成一个死循环。后来对比日志才发现,就是历史里少了tool_calls结构。

3.3 完整代码:用OpenAI兼容接口实现一个带工具调用的Agent

下面给一个只要改base_url和api_key就能跑通的最小实现。我用的是OpenAI兼容协议的Python写法,通用于大多数支持该协议的模型服务商,可以理解为一个通用的示例。

import json from openai import OpenAI client = OpenAI( base_url="https://your-model-provider.example.com/v1", api_key="your-api-key" ) def get_weather(city: str, date: str = "今天") -> str: # 这里替换成真实的天气查询逻辑 # 这里仅作为演示示例返回 return json.dumps({"city": city, "date": date, "weather": "晴", "temperature": 18}, ensure_ascii=False) tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市当前天气或未来天气预报。适用于用户询问温度、天气状况、风力的场景。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,使用中文完整名称,例如'北京''上海'。"}, "date": {"type": "string", "description": "查询日期,取值'今天''明天'或'YYYY-MM-DD'格式,默认为今天。"} }, "required": ["city"] } } } ] def run_agent(user_input: str): messages = [ {"role": "system", "content": "你是生活助手,需要调用工具回答用户问题。调用工具时使用返回的数据进行总结,不要编造数据。"}, {"role": "user", "content": user_input} ] max_rounds = 5 for _ in range(max_rounds): response = client.chat.completions.create( model="your-model-name", messages=messages, tools=tools, temperature=0.2 ) message = response.choices[0].message messages.append({ "role": "assistant", "content": message.content or "", "tool_calls": [tc.model_dump() for tc in message.tool_calls] if message.tool_calls else None }) if not message.tool_calls: return message.content for tool_call in message.tool_calls: fn_name = tool_call.function.name fn_args = json.loads(tool_call.function.arguments) if fn_name == "get_weather": result = get_weather(**fn_args) else: result = json.dumps({"error": f"unknown tool {fn_name}"}) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) return "达到最大轮数,无法完成请求。" if __name__ == "__main__": print(run_agent("明天上海的温度怎么样?"))

这段代码的核心循环在for _ in range(max_rounds)里:调用模型、把assistant消息完整塞回历史、判断有没有tool_calls、有则逐个执行并回传结果、没有则返回最终文本。max_rounds是安全上限,防止某些边缘场景下模型反复发起调用造成死循环。代码里做了两个小处理:assistant消息即使没有tool_calls也要显式放进去,保证历史完整;tools参数在不强制传时默认让模型自行决定空调用,前期调试很顺手。

3.4 流式输出和工具调用的兼容处理

流式模式是很多项目绕不开的坎,尤其做聊天交互想带打字机效果时。工具调用在流式模式下最常见的坑在于:模型先流出一段文本,然后突然开始流出工具调用的参数碎块。参数是增量推送的,比如{"cit、y":、"上海、"}这样一片一片过来,你不能每收到一片就解析一次JSON,那样必然报错。

标准做法是设一个缓冲区。参数到达时只做拼接,直到收到一个表示该工具调用消息结束的标记,或者检测到本轮响应完成,才把拼接好的字符串整体解析。OpenAI SDK的流式接口里,每个chunk.choices[0].delta里可能带tool_calls字段,每个delta.tool_calls[0].index表示这是第几个工具调用的片段,你需要按index分桶拼接。

tool_call_buffers = {} stream = client.chat.completions.create( model="your-model-name", messages=messages, tools=tools, stream=True ) for chunk in stream: delta = chunk.choices[0].delta if not delta or not delta.tool_calls: continue for tc in delta.tool_calls: idx = tc.index if idx not in tool_call_buffers: tool_call_buffers[idx] = {"id": tc.id or "", "name": "", "arguments": ""} if tc.id: tool_call_buffers[idx]["id"] = tc.id if tc.function: if tc.function.name: tool_call_buffers[idx]["name"] = tc.function.name if tc.function.arguments: tool_call_buffers[idx]["arguments"] += tc.function.arguments

循环结束后,遍历tool_call_buffers,把arguments字段一次性json.loads。这个方案我在生产环境用过一阵子,稳定且直观。要提醒的是,不要试图在拼接中途做JSON校验,那样既浪费性能又容易误判。

4. 真实项目里工具调用的高频翻车点

4.1 模型不调用工具,先查这五个原因

最常遇到的问题是:明明定义了工具,模型就是不用,要么直接回答,要么假装做完了。排查顺序很重要,我一般按照下面的优先级来看。

第一,工具名的可用性。某些模型平台对工具名有长度限制和字符限制,如果你用了超过64个字符的名字,或者带了大写字母、特殊符号,一些模型会直接忽略。规范命名,全小写加下划线最稳。第二,工具描述有没有写清触发场景。描述太抽象,模型不知道什么时候该用。把"查询天气"改成"当用户提出天气相关问题(温度、降水、风力、空气质量等)时使用",触发率立刻上升。第三,temperature是不是太高。前面说过,高随机性会让模型更"发散",倾向于编造回答而不是走工具流程,调到0.3以内。第四,工具列表是不是太长太杂。一次传20个工具,模型的选择负担很大,容易串线或干脆不选。把不相关的工具从当前轮次的调用列表中过滤掉,能显著提升准确度。第五,用户messages里有没有包含足够的"引导"。在system里写明"回答实际问题前,先判断是否需要调用工具",有时候一条提示就能把模型从聊天模式拽回工具模式。

4.2 参数解析失败和JSON格式错误

工具调用的返回参数是模型生成的JSON字符串,但你绝不能假设它百分百是合法JSON。模型偶尔会在JSON前面加一段解释文字,或者把字符串值写成没转义的单引号形式,更有甚者会在数字后面加单位。所以执行任何工具前,都先json.loads一把,兜底逻辑里可以做一层"提取第一个{到最后一个}"的清洗,再尝试解析。

还有个容易踩的细节是类型校验。模型按Schema填参数,不代表填的值一定符合你的业务语义。比如Schema要求quantity是integer,模型填了0,逻辑上合法,业务上可能是无效操作。所以工具执行器里除了做JSON解析,还要做一层业务校验,没通过的让工具返回一个明确错误文本给模型,让它修正参数后重新尝试,而不是直接抛异常终止整个Agent。

4.3 死循环和调用次数失控

模型在"对话-调用-回传"循环里卡住,是Agent实战里非常常见的失控现象。表现是模型一遍又一遍请求同一个工具,或者反复请求一组工具,永远给不出最终回答。原因通常是两个:一个是工具返回的错误信息不明确,模型看不懂执行失败的原因,只能瞎试;另一个是工具返回结果过于复杂,模型消化不了,于是通过重复调用来规避总结。

控制手段要有三道:第一道,max_rounds全局限制,我一般设置在4到6之间,超过就中止并提示用户"步骤太多,建议直接人工处理";第二道,单次循环内限制"同名工具连续调用次数",比如同一个搜索工具连续被调用3次还没结果,就返回上限提示;第三道,工具返回错误时,在content里写清楚失败原因和正确的参数写法,帮助模型走出错误分支。调试时可以把每一步的messages列表输出到日志,死循环在日志里非常直观,基本一眼定位是哪一轮开始的重复。

4.4 超时、并发和上下文膨胀问题

工具调用多轮往返,最直观的感受就是慢。每一轮模型推理都要处理越来越长的上下文,调用的工具越多,消息列表越长,推理时间越长,成本也越高。应对方案有两个方向。一个是裁剪历史:不把完整对话全部丢给模型,只保留最近几轮对话和与其强相关的工具结果,前面那些"已经完成了的查询"可以压缩成一条摘要。另一个是缩短工具返回:工具返回结果往往是一个很大的JSON,模型其实只需要里面的几个关键字段,在工具执行器里就把结果精简成文本摘要再回传,既省钱又省延迟。

并发方面,单个用户的请求内部通常没有并发冲突,但多个用户同时跑Agent时,要考虑模型接口的QPS限制。一般做法是做一个轻量级限流器,按API Key维度做令牌桶,超限的请求走队列或者直接降级返回。另外,所有需要长耗时的工具调用都建议打成异步,不要让一个慢工具阻塞整个Agent流程。

5. 再说说我的一点点经验

工具调用这个东西,代码写起来不难,难的是让模型"稳定地"按你的预期动作。从我自己做Agent项目的体会来说,一个铁律是:你给工具的信息越具体,模型的表现就越可靠。所谓"信息"包括工具描述、参数描述、返回结果的格式说明,也包括system提示词里的触发条件。前期把平台提供的调试工具用起来,比如某些模型服务商的响应日志里会展示模型每次生成的tool_calls原始内容,分析这个比瞎猜模型心思高效得多。还有个小技巧:开发阶段给工具名加个特殊前缀,比如dev_,这样测试时只要看调用日志里的函数名,就能一眼分辨是真实调用还是模型幻觉出来的错误函数名。等整个链路稳定后再去掉前缀。最后再分享一句话:当你怀疑自己是不是"加的工具太多了"或者"参数描述太重了"的时候,通常就是对的,真实业务里,两三把精心打磨的"刀"远比二十把锈铁皮有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询