2024 年我做一个智能客服助手。需求很常见:用户问"我那个订单 A1234 发货了吗",助手得去查订单系统,把真实状态告诉用户。第一版我做得很直接:把用户的问题发给大模型,模型回答。本地一试——模型张口就编了个发货状态,它根本没有我的订单数据。我意识到:模型查不了实时数据,得让它调我的接口。我想了个办法:在 prompt 里告诉模型"如果用户问订单,你就返回一个 JSON,格式是 {"action":"query_order","order_id":"xxx"}",我再用代码把模型输出的字符串解析出来,拿到 order_id 去查库。本地一试,行!可一上线,解析就开始各种报错:有时模型在 JSON 外面包了一层 ```json 代码块;有时模型在 JSON 前面加了一句"好的,我帮您查询:";有时模型把字段名写成了 orderId 而不是 order_id;有时用户只是闲聊,模型却也硬挤出一个 JSON;还有时模型干脆用中文标点写 JSON,json.loads 直接抛异常。我在解析代码里打满了补丁——strip 掉代码块、正则抠出大括号、容错各种字段名——补丁越打越多,还是防不胜防。我盯着这堆补丁想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"让模型按格式输出,我自己解析字符串就行"。可模型的输出,本质是自由文本——它会"尽量"按你说的格式来,但它没有任何义务、也没有任何机制保证它真的严格守约。我在用字符串解析,去赌一个概率。真正的解法,是 Function Calling(工具调用):你不是在 prompt 里"请求"模型按格式输出,而是通过 API 的专门通道,把你的函数用结构化的 schema 声明给模型;模型想调用时,返回的也不是混在文本里的 JSON,而是 API 层面一个结构化的 tool_call 对象——名字、参数,清清楚楚,由 API 保证结构。我以为做工具调用不过是"换个 prompt 写法",结果真做下来才发现完全是另一套机制。这篇文章就把它梳理一遍:为什么让模型"按格式输出再解析"根本不可靠、Function Calling 的本质是什么、怎么用 JSON Schema 定义一个工具、模型返回的 tool_call 长什么样、怎么执行工具并把结果喂回模型,以及多工具分发、参数校验、死循环防护这些把工具调用真正做对要避开的坑。
问题背景
先把那次的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:一个智能客服助手,要让大模型去查订单、查天气这类它自己做不到的事。第一版在 prompt 里要求模型"返回 JSON 格式",再用代码解析这个 JSON。本地能跑,一上线,模型的输出五花八门——包代码块、加废话、改字段名、用中文标点——解析代码补丁打不完,还是频繁报错。
我当时的错误认知:"在 prompt 里把格式说清楚,模型就会按这个格式输出,我用 json.loads 解析字符串就能拿到结构化结果。"
真相:模型的文本输出是给人看的自由文本,它对你规定的格式只是"尽量遵守",没有任何强制力。用字符串解析它的输出,等于拿代码去赌概率,补丁永远打不完。正确的做法是 Function Calling:通过 API 的 tools 参数,用 JSON Schema 把你的函数声明给模型;模型决定调用时,会在响应里返回一个结构化的 tool_calls 字段(不是文本),里面是函数名和参数;你执行函数,把结果用 role=tool 的消息喂回对话,模型再据此生成最终回答。
要把工具调用做对,需要几块认知:
- 为什么"让模型输出格式化文本再解析"不可靠,补丁为什么打不完;
- Function Calling 的本质——把"能力"用结构化 schema 声明给模型;
- 怎么用 JSON Schema 定义一个工具,description 为什么是关键;
- 模型返回的
tool_call是结构化对象,以及怎么执行它、把结果喂回; - 多工具分发、参数校验、轮数上限这些工程坑怎么处理。
一、为什么"让模型按格式输出、自己解析字符串"行不通
先把这件最根本的事钉死:大模型 content 字段里的文本输出,是给人阅读的自由文本;它会"尽量"贴合你要求的格式,但它从不"保证"格式,你不能拿它当结构化数据来解析。
下面这段代码,就是我那个"上线就开始报错"的第一版——它在 prompt 里请求模型返回 JSON,然后天真地用 json.loads 去解析:
import re, json
from openai import OpenAI
client = OpenAI()
def query_order_naive(user_msg: str) -> dict:
# 反面教材:在 prompt 里"请求"模型返回 JSON,再用字符串解析。
prompt = (f'如果用户在问订单,请只返回 JSON:'
f'{{"action":"query_order","order_id":"订单号"}}\n'
f'用户说:{user_msg}')
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
text = resp.choices[0].message.content
# 噩梦从这里开始:模型的输出是自由文本,它【不保证】严格守约。
return json.loads(text)
# 问题:模型可能把 JSON 包进 ```json``` 代码块、
# 在前面加一句"好的我帮您查"、把字段名写成 orderId、
# 用中文标点……任何一种,这行 json.loads 都会当场抛异常。
这段代码本身没有语法错误,在你运气好、模型恰好规规矩矩输出纯 JSON 时,它完全能用。它的问题是一个错误的假设:"我让模型返回 JSON,模型就一定会返回干净的 JSON"。而这个假设,模型从未答应过你。于是我做了每个人都会做的事——给解析打补丁:
def query_order_patched(user_msg: str) -> dict:
# 反面教材升级版:给解析打满补丁,试图兜住模型的"不守约"。
text = call_model(user_msg).strip()
# 补丁 1:剥掉 ```json ... ``` 代码块外壳
if text.startswith("```"):
text = re.sub(r"^```[a-z]*\n?|\n?```$", "", text).strip()
# 补丁 2:模型爱在 JSON 前后加废话,正则硬抠出第一个大括号块
m = re.search(r"\{.*\}", text, re.S)
if m:
text = m.group(0)
data = json.loads(text)
# 补丁 3:容错字段名 orderId / order_id……补丁还在继续加
return {"order_id": data.get("order_id") or data.get("orderId")}
# 问题:你在用补丁去赌模型的输出形态,
# 而模型的"花样"是无穷的,这场仗你永远打不赢。
这个打满补丁的版本,比第一版"能扛"一些,但它本质上还是错的。因为你每加一个补丁,都是在事后追认模型的一种"新花样"。模型下次可能用单引号、可能漏个逗号、可能把数字写成中文"一千二百三十四"——你永远慢一步,永远在打补丁。根子在于:你选错了通道。你想要的是结构化数据,却去模型那个专门产出自由文本的 content 字段里捞。要根治,不是把补丁打得更厚,而是换一条专门为结构化数据设计的通道。
二、Function Calling 的本质:把能力声明给模型
上一节的死结是:模型的文本输出天生不是结构化通道。Function Calling(工具调用,也叫 Tool Calling)的破局点就一句话:API 专门为"结构化调用"开了一条独立通道,你用它,就再也不用解析文本了。
它的工作方式,和"在 prompt 里求模型"完全不同。你不再把格式要求塞进 prompt 文本,而是通过 API 的一个专门参数(OpenAI 里叫 tools),把你的每个函数,用一份标准的 JSON Schema 声明出去:这个函数叫什么、是干什么用的、需要哪些参数。模型拿到这份声明后,它的任务就变了——它不再是"生成一段贴合格式的文本",而是"判断要不要调工具、调哪个、传什么参数"。
而当模型决定调用时,它的回复也走另一条道:不再混在 content 文本里,而是放进响应里一个专门的、结构化的 tool_calls 字段。这个字段里,函数名是独立的字符串,参数是 API 保证过的合法 JSON——你直接读这个对象就行,不需要任何 strip、任何正则、任何容错补丁。这就是 Function Calling 的全部精妙:它把"模型决定做什么"和"你的代码怎么拿到这个决定"之间,从一条靠猜的文本缝隙,换成了一条由 API 担保的结构化管道。理解了这个,剩下的就是工程问题——第一个问题是:怎么写出那份"函数声明"?
三、怎么定义一个工具:用 JSON Schema 描述函数
要用 Function Calling,先得有真正干活的函数。它们就是普通的 Python 函数,和大模型没有任何关系——查天气的就查天气,查订单的就查订单:
def get_weather(city: str) -> dict:
"""真正干活的函数:查某城市的天气(这里用假数据示意)。"""
fake = {"北京": "晴 12℃", "上海": "多云 18℃"}
return {"city": city, "weather": fake.get(city, "暂无数据")}
def query_order(order_id: str) -> dict:
"""真正干活的函数:按订单号查订单状态。"""
fake = {"A1234": "已发货", "A5678": "待付款"}
return {"order_id": order_id, "status": fake.get(order_id, "查无此单")}
有了函数,关键的一步是把它们"声明"给模型。声明用的是一份 JSON Schema,它包含三个要件:name 是函数名;description 是给模型看的说明,告诉它"什么时候该用这个函数";parameters 用标准 JSON Schema 描述每个参数的名字、类型、是否必填。
# 把每个函数用 JSON Schema "声明"给模型。
# 注意 description —— 它是模型判断"何时调哪个工具"的唯一依据,
# 写得含糊,模型就调错;写得清楚,模型才调得准。
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的实时天气。用户问天气时调用。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string",
"description": "城市名,如 北京"},
},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "query_order",
"description": "按订单号查询订单的发货状态。用户问订单时调用。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string",
"description": "订单号,如 A1234"},
},
"required": ["order_id"],
},
},
},
]
这份 TOOLS 声明里,最该用心写的是 description。模型看不到你的函数实现,它判断"这个用户的问题该不该调工具、调哪个",全靠 description 这句话。写成"查天气"太干瘪,写成"用户问到某个城市的天气、气温、下不下雨时调用"就清楚得多。声明备好了,下一步就是把它连同问题一起发给模型,看模型怎么回。
四、模型返回的不是文本,是结构化的 tool_call
调用方式只比普通对话多一个参数:把 TOOLS 通过 tools= 传进去。模型收到后,它的回复会是两种形态之一:
def first_call(user_msg: str):
"""第一轮:把工具声明连同问题发给模型,看它要不要调工具。"""
messages = [{"role": "user", "content": user_msg}]
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=TOOLS, # 关键:把工具声明传进去
)
msg = resp.choices[0].message
# 模型的回复有两种形态:
# 1. msg.content 有值 —— 它直接用文字回答了,没调工具
# 2. msg.tool_calls 有值 —— 它决定调工具,这里是【结构化对象】
return messages, msg
这里是和第一节最本质的区别。第一节里,模型要调工具的"意图",是混在 content 文本里、要你去解析的。而现在,这个意图被放进了 msg.tool_calls——一个结构化的对象列表。你不用解析它,直接读字段:
def show_tool_calls(msg):
"""看看模型决定调用的工具 —— 这是结构化对象,不是要你解析的文本。"""
if not msg.tool_calls:
print("模型没调工具,直接回答:", msg.content)
return
for tc in msg.tool_calls:
# tc.function.name :模型要调的函数名,API 保证是字符串
# tc.function.arguments :参数,API 保证是一段合法 JSON 字符串
# tc.id :这次调用的唯一 id,回填结果时要用它对上
name = tc.function.name
args = json.loads(tc.function.arguments)
print(f"模型要调 {name},参数 {args},call_id={tc.id}")
注意这里还有一次 json.loads——解析 tc.function.arguments。但它和第一节那个 json.loads 性质完全不同:第一节解析的是整段自由文本,模型可能在任何地方塞进任何东西;而这里解析的 arguments,是 API 已经保证过的、只包含参数的合法 JSON 字符串——它不会有代码块外壳、不会有"好的我帮您查"的废话。它是干净的。模型点完名了,接下来就该你的代码上场:执行这个函数。
五、执行工具、把结果喂回模型
模型在第一轮告诉你"我要调 query_order,参数是 A1234",但模型自己执行不了这个函数——执行,是你的代码的事。执行完,还要把结果喂回给模型,让它第二轮用这个结果组织出人话。
# 工具名 -> 真正的函数,用一张表分发,别写一长串 if-else
TOOL_FUNCS = {"get_weather": get_weather, "query_order": query_order}
def run_tool_and_reply(messages, msg):
"""第二轮:执行模型点名的工具,把结果喂回去,让它生成最终回答。"""
messages.append(msg) # 先把模型那条"我要调工具"记进对话
for tc in msg.tool_calls:
func = TOOL_FUNCS[tc.function.name]
args = json.loads(tc.function.arguments)
result = func(**args) # 你的代码真正执行函数
messages.append({
"role": "tool", # 角色是 tool,表示这是工具返回
"tool_call_id": tc.id, # 用 id 对上是哪一次调用的结果
"content": json.dumps(result, ensure_ascii=False),
})
# 带着工具结果再问一次模型,这次它会用结果组织成人话
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS)
return resp.choices[0].message.content
这段代码里有两个顺序不能乱。第一,要先 messages.append(msg)——把模型那条"我要调工具"的消息本身追加进对话历史,再追加工具结果;少了这一步,模型会对不上。第二,工具结果的消息,角色必须是 tool,而且必须带上 tool_call_id——它和第一轮 tc.id 一一对应,模型靠它知道"这个结果是我刚才哪一次调用的回执"。整个过程是一次清清楚楚的两轮对话:第一轮模型点名,第二轮模型用回执作答。但真实场景里,模型可能不止调一轮——这就引出了工程坑。
六、工程坑:多工具分发、参数校验与死循环防护
两轮对话是最简单的情况。真实场景里,模型查完订单可能还要再查物流,于是要连着调好几轮。把这种多轮串起来,就需要一个循环——而循环,就必须有上限:
def chat(user_msg: str, max_rounds: int = 5) -> str:
"""完整的工具调用循环:模型可能要连着调好几轮工具。"""
messages = [{"role": "user", "content": user_msg}]
for _ in range(max_rounds): # 关键:必须有轮数上限
resp = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS)
msg = resp.choices[0].message
if not msg.tool_calls: # 模型不调工具了 = 给出最终答案
return msg.content
messages.append(msg)
for tc in msg.tool_calls:
func = TOOL_FUNCS.get(tc.function.name)
if func is None: # 坑:模型可能调一个不存在的工具
result = {"error": f"未知工具 {tc.function.name}"}
else:
try:
result = func(**json.loads(tc.function.arguments))
except Exception as e: # 坑:参数缺失或类型不对都要兜住
result = {"error": f"工具执行失败: {e}"}
messages.append({
"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False),
})
return "处理超时:工具调用轮数过多" # 兜底:防止无限循环
这段代码里藏着几个不处理就出事的坑。坑 1:必须有 max_rounds 轮数上限。模型偶尔会抽风——它拿到工具结果后,又决定调一次同样的工具,然后又调一次……如果你的循环写成 while True,这就是一个无限循环,会一直烧钱、把请求永久挂住。一个有限的 for 循环加兜底返回,是底线。坑 2:模型可能调不存在的工具。模型偶尔会幻觉出一个你根本没声明的函数名。所以分发时要用 TOOL_FUNCS.get(),取不到就返回一个 error 喂回去,而不是让 KeyError 直接崩掉整个对话。坑 3:参数可能有问题。模型给的参数可能缺字段、可能类型不对,所以 func(**args) 一定要包在 try 里;把错误信息喂回模型,模型往往能自己纠正、下一轮重调。
坑 4:模型该调工具时却偷懒、直接用文字答了。有时用户明明在问订单,模型却没调 query_order,自己编了个回答。这时可以用 tool_choice 参数强制它调:
def chat_force_tool(user_msg: str, tool_name: str):
"""坑 4 的解法:用 tool_choice 控制模型调不调、调哪个工具。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": user_msg}],
tools=TOOLS,
# tool_choice 的三种取值:
# "auto" :默认,模型自己决定调不调(大多数场景用这个)
# "none" :禁止调工具,只准用文字回答
# 指定函数:强制模型这一轮必须调用这个指定的工具
tool_choice={"type": "function",
"function": {"name": tool_name}},
)
return resp.choices[0].message.tool_calls
坑 5:description 写不好,模型就调不准。这是最容易被轻视的坑。模型选哪个工具、传什么参数,唯一的依据就是 TOOLS 里的 description。两个功能相近的工具,如果 description 写得含混,模型就会张冠李戴。把它当成写给模型看的接口文档来认真写,工具调用的准确率才有保证。下面这张图,把一次带工具调用的完整对话流程串起来:
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 自由文本不可靠 | 让模型按格式输出文本再解析,模型不保证守约,补丁永远打不完 |
| Function Calling | 通过 API 的 tools 通道把函数声明给模型,模型返回结构化 tool_call 而非文本 |
| 工具声明 | 用 JSON Schema 描述函数名、用途、参数,description 告诉模型何时该用 |
| tool_calls 字段 | 模型决定调工具时返回的结构化字段,含函数名、参数 JSON、调用 id |
| role=tool 消息 | 工具执行结果用 role 为 tool 的消息追加回对话,靠 tool_call_id 对上调用 |
| 两轮对话 | 第一轮模型点名工具,执行后把结果喂回,第二轮模型用结果组织最终回答 |
| 工具分发表 | 用函数名到函数的字典分发,避免一长串 if-else,新增工具只改一处 |
| 轮数上限 | 模型可能连续调多轮工具,必须设 max_rounds 防止无限循环烧钱 |
| 未知工具与参数错 | 模型可能调不存在的工具或传错参数,都要兜住并把错误喂回模型 |
| tool_choice | auto 让模型自决,none 禁用工具,指定函数则强制模型调用该工具 |
避坑清单
- 别在 prompt 里请求模型返回 JSON 再用字符串解析,模型输出是自由文本不保证守约,补丁永远打不完。
- Function Calling 是 API 的专门通道,模型返回结构化的 tool_calls 字段,结构由 API 保证,不用你解析文本。
- 用 JSON Schema 声明工具,name 是函数名,description 写清楚何时该调,parameters 描述每个参数。
- description 是模型判断调哪个工具的唯一依据,要当成写给模型的接口文档来认真写,含糊就调错。
- 模型回复有两种形态:content 有值是直接回答,tool_calls 有值是要调工具,两者要分别处理。
- 执行工具后结果要用 role 为 tool 的消息追加回对话,并用 tool_call_id 对上是哪一次调用。
- 要先把模型那条"我要调工具"的消息追加进对话历史,再追加工具结果,顺序不能颠倒。
- 工具分发用函数名到函数的字典表,别写一长串 if-else,新增工具只需改字典一处。
- 模型可能连续调好几轮工具,主循环必须设最大轮数上限,否则会无限循环一直烧钱。
- 模型可能调不存在的工具、或传缺参数错类型的参数,都要兜住并把错误喂回模型让它自己纠正。
总结
回头看那次"模型编了个发货状态、我用字符串解析去赌它的输出"的事故,以及我后来在工具调用上接连踩的坑,最该记住的不是某一段 TOOLS 声明,而是我动手前那个想当然的判断——"让模型按格式输出,我自己解析就行"。这句话错在它用错了通道。大模型的 content 字段,是一个专门产出"给人读的自由文本"的出口;它会礼貌地尽量贴合你的格式要求,但"尽量"和"保证"之间,隔着一条你的代码永远填不平的鸿沟。我在那条鸿沟上,用一块又一块的正则补丁搭桥,搭得越长,越发现它没有尽头。Function Calling 想清楚的,正是这件事:既然你要的是结构化数据,那就别去文本出口里捞——走 API 为结构化调用专门开的那条管道,让结构由 API 来担保,而不是由你的运气来担保。
所以做工具调用,真正的工程量不在"tools=TOOLS 传进去"那一下。那一下,API 帮你做完了。真正的工程量,全在这条管道的两端:声明端,你的 description 写得够不够清楚,模型才知道"什么时候、该调哪个";执行端,模型点名了一个工具,你的代码分发对了吗?它点名了一个不存在的工具呢?它传来的参数缺了一个字段呢?它拿到结果后又调、又调、没完没了呢?这每一个"呢",都是一个你不处理就会在生产上出事的坑。这篇文章的几节,其实就是顺着这条管道展开的:先想清楚文本通道为什么不可靠,再看清 Function Calling 这条结构化通道的本质,然后是声明工具、读取 tool_call、执行并喂回这三段主干,最后是多轮循环、参数校验、轮数上限这几个把工具调用真正做对的工程细节。
你会发现,Function Calling 的思路,和现实里"把活派给一个能干但记性差的助手"完全相通。你要这个助手帮你查个东西,不靠谱的做法是:递给他一张纸条,上面写"查完按这个格式回我",然后你对着他潦草的笔迹连蒙带猜——他今天用横排、明天用竖排,你永远在辨认。靠谱的做法是:给他一沓标准表单(这是工具声明),表单上每个格子填什么写得清清楚楚(这是 description 和 parameters);他要查什么,就抽一张表、把格子填好递给你(这是 tool_call),格子是固定的,你一眼就读到;你查完,把结果夹回那张表对应的回执栏(这是 role=tool 加 tool_call_id)。整个协作,没有一个环节需要"辨认笔迹"。Function Calling 做的,就是把你和模型之间那张潦草的纸条,换成了一沓格子固定的标准表单。
最后想说,工具调用做没做扎实,差距永远不会在 Demo 里暴露——Demo 里你自己问那几个"查北京天气""查 A1234 订单"的标准问题,模型每次都乖乖调对工具,你只会觉得"工具调用嘛,不就这样"。它只在真实的、用户问题千奇百怪、模型偶尔抽风的生产环境里才显形。那时候它会用最难堪的方式给你结账:做不好,你会像我当初一样,在解析字符串的代码里打满补丁还频频报错;或者模型幻觉出一个不存在的工具名,你的代码一个 KeyError 崩掉整个会话;或者模型陷进反复调同一个工具的怪圈,你的账单悄悄翻倍。而做对了,它会安安静静地,把用户每一个需要外部能力的问题,准确地分发到对应的函数,把函数结果妥帖地喂回模型,最后吐出一句有真实数据兜底的回答——用户问"我的订单发货了吗",得到的是查询过的真相,而不是一句编出来的安慰。所以别等满屏的解析报错找上门,在你第一次想"让模型按格式输出"的时候就该停下来想清楚:我要的到底是给人读的文本,还是给代码用的结构化数据?如果是后者,就别在文本里捞——走 Function Calling 那条由 API 担保的管道。这个问题想清楚了,你的 AI 应用才不只是 Demo 里那个问标准问题就能跑的样子,而是一个无论用户怎么问、模型怎么抽风,都能稳稳地把活派对、把结果接住的可靠系统。
—— 别看了 · 2026