很多人第一次接工具调用,卡的不是概念,而是字段:tools 里的 type 和 function 谁包谁、arguments 是一段字符串还是对象、模型说有 tool_calls 之后我要回什么。这篇把 OpenAI 兼容接口这条链路上的字段逐个拆开,给你一份能照着调、出错能照着查的顺序。
先分清两套写法:tools 与旧的 functions
先搞清楚一件事:你手上这份请求体,到底是新版还是旧版。新版把工具统一塞进 tools 数组,功能对等;旧版是 functions 配 function_call。两套字段名不一样,服务端能不能认,取决于目标平台实现的是哪一套。发之前先确认自己在发哪一套,比事后猜为什么没回工具调用要省事得多。
| 位置 | 新版写法 | 旧版写法 |
|---|---|---|
| 工具声明放在哪 | tools 数组,一次声明一个或多个 | functions 数组,同样可以放多个 |
| 每个工具的结构 | 先有 type,再在该类型下写具体字段 | 直接就是 name、description、parameters |
| 怎么让模型决定调用 | 在请求里通过 tool_choice 表达意图 | 通过 function_call 表达意图 |
| 模型回给你的东西 | 返回工具调用结果,含工具名和参数 | 返回函数调用结果,含函数名和参数 |
- 新版每一项至少要有 type,现在最常见的就是 function 类型,别漏;
- 真正的函数详情写在 type 下面那一层,name、description、parameters 都在里面,层级别拍平;
- description 写清楚这个工具干什么、什么时候用,模型挑不挑得中它,这句话占大头;
- parameters 用一份 schema 描述入参,字段名、类型、哪些必填,缺一个都可能让模型猜参数;
- 旧版用 functions 加 function_call,字段名和层级都不同,两套混着写,服务端大概率解析不到你想要的工具。
混用是排查里最常踩的坑:你声明用了 tools,决定调用却写成 function_call,或者反过来,服务端只认其中一套,另一套直接当无效字段忽略,表现就是模型像没看见工具一样。所以一旦出问题,第一步不是改业务逻辑,是回看请求体,确认完整地走的是同一套写法,而不是新旧各拿一半。
- 确认目标平台的口径不同中转或自建兼容层对字段的支持程度不一样,别拿 A 平台的写法直接套到 B 平台。先去站内 /stacks/agent-apis 看该平台在工具调用上的说明,确认它吃的是 tools 还是 functions。
- 对照官方写法再打开 /official-api/providers/openai 核对官方请求体的层级:type 在哪、function 在哪、parameters 长什么样,逐字段对一遍,而不是凭记忆写。
- 本地做一次最小请求只声明一个最简单的工具,跑一次看响应里有没有工具调用结果。有结果,说明这套字段平台认;没有,先怀疑字段写错或混用,再怀疑别的原因。
- 切换写法时整段替换从旧版换新版,或者反向切换,整段结构一起换,别只改一处。混着的请求体最难定位,看起来哪都对,就是不通。
把函数声明写对:name、description、parameters
函数声明这一段是工具调用里最容易被写坏的地方。模型看不到你的后端代码,它只看到你递给它的这段 JSON。name、description、parameters 三个字段决定了两件事:模型知不知道「有这么一个工具可用」,以及它会不会「在该用的时候用、该传的参数传对」。下面按字段拆开讲,每个都给出可执行的检查点。
name:英文短名,下划线分隔,不带空格和中文
- 只用小写英文字母、数字和下划线,例如 get_order_status、send_email,别用 get-order-status 这种带横杠的写法
- 不要出现空格、中文、表情或大小写混排,有些兼容层对这类字符的转义处理不一致,会在回传参数时报错
- 长度控制在能看懂就行,别把整个动作描述塞进去;名字是标识符,不是说明
- 同一个请求里所有工具名不能重复,重复时有的实现是后者覆盖前者,排查起来很费时间
- 如果站点本身支持命名空间前缀(如 crm_search),前缀也用下划线拼接,别用冒号或斜杠
什么情况下不适用:如果你对接的是自己的私有网关并且明确知道它对特殊字符做了兼容,那横杠也能跑通。但只要你的请求可能经过第三方兼容层,就按上面这套最保守的写法来,省掉一类不必要的问题。
description:写「什么时候该调它」,不是写「这个函数做了什么」
模型主要靠 description 判断要不要动手。写成「查询订单」这种名词短语,模型只能猜;写成「当用户询问某个订单的发货状态、物流进度时调用本工具;如果用户只是问商品价格,不要调用」,模型才有一个可执行的判断条件。把「触发场景」和「不要用的场景」都写进去,比堆一堆功能描述有用得多。
| 写法 | 模型看到后的反应 | 建议 |
|---|---|---|
| 查询订单 | 不确定什么时候用,可能乱调 | 改成带场景的条件句 |
| 当用户询问订单发货状态或物流进度时调用;仅问价格时不要调用 | 触发条件清晰,误调少 | 推荐这种写法 |
| 根据传入的 order_id 返回该订单的完整信息 | 只说了输入输出,没说触发时机 | 补上「什么时候用」这一句 |
| 这是一个非常有用的订单工具,可以处理各种订单相关的问题 | 语义太泛,几乎等于没限制 | 收窄到具体动作和触发词 |
parameters:一份标准 JSON Schema,尽量别留自由发挥的字段
- 顶层写成 objecttype 固定为 object,properties 里逐个列出每个入参。别的类型(比如直接写 string)在这类接口里基本不被接受。
- 每个属性带类型和说明每个入参写清 type(string、integer、boolean、array 等),并加一句 description 说明这个值代表什么、格式是什么,例如 order_id 是「纯数字的订单编号,不带前缀」。
- required 只列真正必填的required 是一个字符串数组,把必填字段名列进去。能不填的别放进 required,否则模型可能为了凑齐字段编一个值出来。
- 能收窄就收窄状态、类型这类有限取值的参数,用 enum 把可选值列全,比让模型自由生成字符串靠谱得多。
- 别留自由发挥的开放字段像 extra、note、anything 这种让模型随便填的字段,能删就删;确实需要备注就用 description 限定格式和长度。
{"name":"get_order_status","description":"当用户询问某个订单的发货状态或物流进度时调用;用户只问价格时不要调用。","parameters":{"type":"object","properties":{"order_id":{"type":"string","description":"纯数字订单编号,不带前缀"},"channel":{"type":"string","enum":["web","app","phone"],"description":"下单渠道,不确认时传 web"}},"required":["order_id"]}}参数越少越好,能拆就别塞成一个巨无霸
一个函数塞进七八个参数,模型要在一次生成里同时判断「调不调」和「每个值填什么」,错一个就整体失败。更稳的做法是按动作拆开:查状态一个函数,改地址一个函数,取消订单一个函数。每个函数的参数控制在少数几个必填项,可选参数用默认值兜底。判断标准很简单——如果你自己写代码时都要翻文档才能填对这个参数,模型大概率也填不对。
下一步字段写完之后,还要确认各家兼容实现对 tools 的结构要求是否一致,可以到站内的接口与模型资料库逐项核对当时的字段说明。→读懂模型返回:tool_calls 的每一层
模型决定调工具时,返回体里最先变的是 finish_reason,它一般会告诉你这一轮不是普通回答。紧接着多出一个 tool_calls 数组,每一项至少带 id、type,以及 function.name 和 function.arguments。id 是你回传结果时的对账凭据,type 通常说明这是函数类工具,name 是模型想调哪个函数,arguments 才是它给这个函数准备的入参。
| 字段 | 你可以怎么理解 | 处理动作 |
|---|---|---|
| finish_reason | 这一轮为什么结束 | 等于工具调用标识时,别急着当终稿展示 |
| tool_calls[].id | 这次调用的唯一编号 | 原样保存,回传工具结果时要对上 |
| tool_calls[].type | 调用类型 | 先判断是不是你要处理的那类 |
| function.name | 模型选的函数名 | 和本地函数表比对,找不到就报错 |
| function.arguments | 入参,一段 JSON 字符串 | 先解析成对象,再校验字段 |
这里最容易踩的坑是 arguments:它是一段 JSON 字符串,不是已经展开的对象。你直接对它取键,或者拿它当对象遍历,大概率拿到 undefined 或者直接抛错。正确顺序是先解析、再校验、最后才把字段喂给本地函数。解析失败也不要慌,多半是模型把参数写成了带转义或换行的形式,先看原始串再决定是重试还是提示模型修正。
import json;raw = choice.message.tool_calls[0].function.arguments;try:; args = json.loads(raw);except json.JSONDecodeError:; args = {} # 这里先记日志,把 raw 打出来看,不要静默吞掉;required = ["city"];missing = [k for k in required if k not in args];if missing:; # 缺参数就回一条工具错误,让模型补- 一轮可能返回多个 tool_calls,代表模型想并行调几个函数,遍历处理,别只取第一个
- 每个 tool_call 的 id 要和回传的工具结果一一对应,错位会让模型把结果贴到别的调用上
- 回传时报的是 tool 角色消息,带上 tool_call_id 和函数执行结果,结构别自己发挥
- 同名函数被并行调多次是正常的,按 id 区分,不要用函数名当唯一键
- 执行失败也要回一条结果说明原因,比直接丢掉这次调用更好排查
兼容层是这个环节最不整齐的地方。各家兼容实现程度不一样,有的字段名对得上但嵌套层级有出入,有的 finish_reason 用的字面量不同,有的 arguments 偶尔返回空串。所以别照着文档硬写解析逻辑,先在开发环境把原始响应完整打印出来看一眼,用真实返回去写容错分支。判断类内容都有边界:如果你用的服务在文档里明确写了自己返回结构不同,那就以你实际打印出来的响应为准,不要拿标准字段去套。
- 打印原始响应在开发环境把整段响应落成日志,重点看 finish_reason、tool_calls 的层级和 arguments 的原始形态,确认不是自己想当然的结构
- 解析并校验对 arguments 做 JSON 解析,再检查必填字段和类型,缺参数或类型不对就带着原始串去排查,而不是直接调用本地函数
- 按 id 逐个回传遍历每一个 tool_call,执行完把结果配同一个 id 回传,多个调用也要逐条对上,避免只处理首个导致模型上下文错位
把执行结果回填:tool 角色消息怎么写
模型发起工具调用后,你这边的活才刚开始一半:真正决定这轮对话能不能继续的,是那条回填消息。规则很朴素,每个 tool_calls 里列出的调用,都要单独回一条 role 为 tool 的消息,content 放工具跑出来的结果,tool_call_id 填它对应的那个 id。一次要调三个函数,就回三条,一条都不能少。
| 字段 | 填什么 | 常见错法 |
|---|---|---|
| role | 固定写 tool | 写成 function、assistant 或不写 |
| tool_call_id | 照抄对应那次调用的 id | 漏填、填成别的调用的 id、自己编一个 |
| content | 工具返回的文本(建议是 JSON 字符串) | 塞对象、塞数组、留空 |
| name | 多数兼容实现可省略 | 填错名字导致对不上号 |
顺序和 id 的对应关系是硬约束。有些实现按顺序配对,有些严格按 id 配对,你别赌,两个都对上最稳:id 一一对应,顺序也跟 tool_calls 里出现的顺序一致。返回结果里的数字、字段名不要自己加工,模型读的就是你回填的原文,你改一层它可能就理解歪一层。
工具报错也要回,别让对话断在半路
最常见的翻车是把异常直接抛出去,请求返回 500,前端拿不到 assistant 消息,这轮对话就断在那儿,用户只看到转圈。正确做法是把错误当成一种正常结果回填:content 里写一句人能看懂的话,比如「查询订单失败:该订单号不存在」,再带上错误码。模型看到这句话,会自己判断是换个参数重试、换用另一个工具,还是直接告诉用户查不到。你不回,它就只能干等。
- 先接住异常工具函数外面套一层 try,把异常转成结构化的错误文本,别让异常冒到 HTTP 层
- 再按原样回填用这条调用的 tool_call_id 发一条 role 为 tool 的消息,content 写清「失败了 + 为什么」,不写空字符串
- 最后验证闭环把原始 assistant 消息(含 tool_calls)连同这条 tool 消息一起再发一轮,看模型的下一步是重试、换工具还是收尾回答
参数问题尽量在自己这侧拦掉
工具执行失败里,有一大半不是业务错,是模型给的参数本身就不对:类型不符、必填字段缺失、本该是对象却多包了一层。这类错误回填给模型也行,但来回一轮浪费一次调用和一点延迟。建议在调用本地工具之前先做一次校验,类型、必填、嵌套层级都过一遍,不合格就按错误结果回填,合格再真正执行。想省事的话,对照站内 /tools 里的本地检测工具跑一遍参数,能提前揪出不少低级问题。
- 类型不符:要数字给了字符串,要数组给了单个值
- 必填缺失:少 key,或值是 null、空字符串
- 多包一层:把参数又套进一个 arguments 字段里
- 枚举越界:给了不存在的选项值
- 编码问题:中文、转义、双重 JSON 序列化
这套做法在单轮单工具的场景下基本够用;如果你的场景是并行调用加长链路,回填顺序和错误重试策略会复杂不少,建议单独做一次压测再上线。判断标准和更细的兼容差异,可以到 /guide/api 核对,那里按实现分别列了容易踩的点。
调不通时的排查顺序
工具调用调不通,绝大多数不是玄学,而是四层里某一层断了:你发出去的请求体、你收到的原始返回、你回填给模型的第二条消息、以及你选的模型本身支不支持。建议按这个顺序一层层过,别一上来就怀疑模型笨或者中转站坏了——顺序错了容易在错误的层反复改代码。
- 第一步:先把请求体打印出来看层级在发请求前把最终 body 原样打到日志里,确认三件事:tools 是一个数组而不是单个对象;每个元素的 type 是 function,真正的函数定义嵌在它下面的 function 字段里,而不是把 name、parameters 直接摊在数组元素上;parameters 是一段合法的 JSON Schema,能被解析器吃下去。验证办法:把这段 body 直接扔进 JSON 校验工具,能解析通过说明语法层没问题。
- 第二步:看返回里到底有没有 tool_calls拿到响应后别急着用 SDK 封装好的对象,先存原始 JSON。检查 choices 里那条消息有没有 tool_calls 字段、arguments 是不是字符串(有些实现会给你对象,你得自己转)、id 有没有一起返回。验证办法:把原始 JSON 和模型复述给你的「它想调什么」对照,两者不一致就说明中间某层被改写过了,以原始 JSON 为准。
- 第三步:检查回填消息对不对得上把工具执行结果塞回去时,role 要用固定的那个工具角色,tool_call_id 必须和上一步拿到的 id 一字不差,并且要把带 tool_calls 的那条 assistant 消息一起带上,不能只发结果。验证办法:把完整对话数组打出来,看 id 是否成对出现、顺序是不是 assistant 在前结果在后。
- 第四步:换一个已知支持的型号做对照不是所有模型都稳定支持工具调用,有些只是「能返回但格式漂」,有些干脆忽略 tools 字段。找同接口下另一个明确支持的型号,把同一段 body 原样发过去:换了就通,问题在模型能力;换了还不通,问题在请求体或回填链路。型号支持情况到 /models 查当时的清单,别凭印象。
| 现象 | 最可能的层 | 先查什么 |
|---|---|---|
| 模型完全不提工具,直接闲聊 | 模型能力 | 换型号对照,去 /models 核对支持清单 |
| 报错说参数不是合法对象 | 请求体 | parameters 是不是合法 JSON Schema |
| 返回里没有 tool_calls | 请求体或模型 | tools 是不是数组、type 层级对不对 |
| 工具跑了但模型说没收到结果 | 回填 | tool_call_id 与 role 是否匹配 |
| 时好时坏、格式飘 | 模型能力 | 是否用了稳定性较差的型号 |
这套顺序的适用边界也要说清楚:如果你的请求经过中转站转发,有些平台会重写字段或做格式归一,这时第四步的对照要固定同一个入口再换型号,否则换了型号又换了通道,变量没控制住,结论就不成立。反过来,如果你只用一个型号、只走一条链路,排查范围可以缩到前三步。
常见问题
tools 和 functions 到底用哪个?
能发 tools 就发 tools,官方主推这套,新模型和兼容层基本都认。functions 是旧写法,只在对接方明确只支持它时才用。两者别混在一个请求里,否则有的兼容层会只解析其中一套,报错信息还看不出来是字段问题。
模型只返回文字,不返回 tool_calls 是怎么回事?
先确认 tools 字段真的发出去了、description 写清了调用场景,再确认模型本身支不支持工具调用。有些兼容层对 tools 是「收下但不转发」,看起来请求成功却没效果,这时换一个已知支持的模型或换条通道做对照最快。
arguments 是空对象或截断的怎么办?
先做解析兜底:解析失败就当参数不合法处理,别让程序直接崩。截断多半出在输出长度限制或流式拼接上,流式场景要把分片拼完再解析。可以在提示里要求参数精简,并用 enum 和必填项把范围收窄,减少跑偏。