2024 年我给公司做一个客服 AI Agent。需求不复杂:让大模型能「动手」——用户问订单,它去查订单系统;问物流,它去查物流接口;符合条件,它还能直接发一张优惠券。实现它的核心技术,就是 Function Calling(函数调用,也叫 Tool Use)。我给模型挂了三个工具:query_order、query_logistics、send_coupon,demo 演示给老板看,问一句答一句,行云流水,老板很满意。可上线没两天,投诉就来了。有用户问「我的订单到哪了」,它不去调 query_logistics,反而自己张口编了一个物流状态——「您的包裹已到达本市分拣中心」,而那个订单号根本不存在。有用户明明给了订单号,它调 query_order 时,order_id 参数里却填了一串它自己瞎编的数字。还有一次更离谱:它陷入了循环,反反复复调用同一个 query_order,调了几十次,把对话轮数撑爆,最后超时。我的第一反应,又是「模型不够聪明」,想换个更贵的模型。直到我把每一轮模型返回的原始 tool_calls 结构原样打印出来,一行行读,才终于看明白:绝大多数问题,根本不在模型的「推理能力」。它该不该调工具、调哪个、参数填什么——这些判断,模型唯一的依据,就是我写给它的那份工具「说明书」。而我那份说明书,description 含糊、参数 schema 松垮、没告诉它「查不到就别编」、没有循环上限。模型不是不听话,是我根本没把话说清楚。本文是我把 Function Calling 这套机制——它到底是什么、工具该怎么定义、返回结果怎么解析、多轮怎么编排、循环怎么终止——彻底重做一遍后的完整复盘。
问题背景:一个"会自己编工具结果"的 AI 客服
背景:给客服 AI Agent 挂了 3 个工具(Function Calling)
- query_order 查订单
- query_logistics 查物流
- send_coupon 发优惠券
demo 行云流水,老板满意 -> 上线
上线后投诉不断:
- ★★ 用户问"订单到哪了",它不调 query_logistics,
直接编了个物流状态"已到达本市分拣中心"
- ★★ 用户给了订单号,它调 query_order 时 order_id
参数却填了一串自己瞎编的数字
- ★★ 陷入循环:反复调用同一个 query_order 几十次,
撑爆对话轮数,最后超时
★ 我的反应:又觉得"模型不够聪明",想换更贵的模型
★★ 把每轮模型返回的【原始 tool_calls 结构】打印出来,
一行行读 —— 才看明白:
- 问题不在模型的推理能力
- 模型"该不该调、调哪个、参数填什么"的唯一依据,
是我写的那份工具【说明书】(description + schema)
- 而我的说明书:描述含糊、schema 松垮、没说"查不到
别编"、没有循环上限
★ 本文要做的:把 Function Calling 是什么、工具怎么定义、
返回怎么解析、多轮怎么编排、循环怎么终止,彻底讲透。
先搞懂:Function Calling 里,模型其实"什么都不执行"
# === ★ 90% 的误解,都源于以为"模型会执行函数" ===
# === ★ 真相:模型只会"决定",不会"执行" ===
# ★ ★ Function Calling 这个名字有误导性。它给人的感觉
# 是"模型调用了我的函数"。但模型【根本碰不到】你的
# 代码,它跑在 OpenAI / Anthropic 的服务器上,它既
# 连不上你的数据库,也发不出你的 HTTP 请求。
# ★ ★★ 模型唯一能做的,是【输出一段结构化的 JSON】,
# 意思是:"我建议你,用这些参数,去调用那个叫
# query_order 的工具。" 它输出的是一个【意图】,
# 一张【待办清单】,不是执行结果。
# === ★ 真正的执行,永远是你的代码在做 ===
# ★ ★ 完整的一轮,是这样四步接力:
# - ① 你把"用户问题 + 工具清单"发给模型;
# - ② 模型返回:要么是普通回答,要么是一个 tool_calls
# —— "请帮我调 query_order(order_id='A123')";
# - ③ ★【你的代码】解析这个 JSON,真正去执行那个函数
# —— 查数据库、发请求,拿到结果;
# - ④ 你把执行结果【再发回】给模型,模型据此组织出
# 给用户的最终回答。
# ★ ★ 模型负责"动脑"(决定调什么),你的代码负责
# "动手"(真的去调)。这是一场严格的【接力】。
# === ★★ 为什么"编工具结果"这个 bug 会发生 ===
# ★ ★ 现在就能解释开头那个 bug 了:模型在第 ② 步,
# 本该输出一个 tool_calls 让你去查物流。但如果它
# "觉得"自己不调工具也能答(比如工具描述太含糊、
# 它没意识到该用),它就会跳过 ②,直接进入"普通
# 回答"模式 —— 而它脑子里没有真实物流数据,于是
# 就【凭语言能力编了一个】。
# ★ ★ 它不是恶意撒谎,是大模型的天性:你给它一个
# 它答不上来的问题、又没有一个清晰的"该走工具"的
# 信号,它就会用最像答案的话把空填上。
# === 小结 ===
# ★ 90% 的误解源于以为"模型会执行函数"。真相是模型
# 只会决定不会执行 —— 它跑在厂商服务器上连不到你的
# 数据库、发不出你的 HTTP 请求,它唯一能做的是输出
# 一段结构化 JSON,意思是"我建议你用这些参数去调那个
# 工具",输出的是意图、是待办清单不是执行结果。★ 真正
# 执行永远是你的代码:完整一轮四步接力 —— ① 你把用户
# 问题+工具清单发给模型 ② 模型返回普通回答或一个
# tool_calls ③ 你的代码解析 JSON 真正执行函数拿到结果
# ④ 你把结果再发回模型让它组织最终回答;模型动脑(决定
# 调什么)你的代码动手(真去调)。★★ 为什么会编工具
# 结果:模型在第②步本该输出 tool_calls,但若它觉得不
# 调工具也能答(工具描述太含糊没意识到该用)就跳过、
# 直接进普通回答模式,而它脑子里没真实数据于是凭语言
# 能力编一个 —— 不是恶意撒谎,是你给它答不上的问题又
# 没给清晰的"该走工具"信号,它就用最像答案的话填空。
# ★ Function Calling 一轮完整流程:看清"模型决定 / 代码执行"的接力
from openai import OpenAI
client = OpenAI()
# ★ 这就是真正干活的函数 —— 它属于【你的代码】,模型碰不到它
def query_order(order_id: str) -> dict:
# 真实场景这里会查数据库;demo 里先写死
return {"order_id": order_id, "status": "已发货", "amount": 199}
def run_one_turn(user_message: str, tools: list):
messages = [{"role": "user", "content": user_message}]
# === ① 把"用户问题 + 工具清单"发给模型 ===
resp = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools,
)
choice = resp.choices[0].message
# === ② 模型返回的可能是普通回答,也可能是 tool_calls ===
if not choice.tool_calls:
# ★ 模型没要求调工具 —— 直接就是给用户的回答
return choice.content
# === ③ ★【你的代码】解析 JSON,真正去执行那个函数 ===
tool_call = choice.tool_calls[0]
import json
args = json.loads(tool_call.function.arguments) # ★ 模型给的是 JSON 字符串
result = query_order(**args) # ★ 这一步才是真执行
# === ④ 把执行结果再发回模型,让它据此组织最终回答 ===
messages.append(choice) # 模型那一轮的 tool_calls
messages.append({
"role": "tool",
"tool_call_id": tool_call.id, # ★ 必须对上 id
"content": json.dumps(result, ensure_ascii=False),
})
final = client.chat.completions.create(model="gpt-4o", messages=messages)
return final.choices[0].message.content
# ★ 记住:模型从头到尾没"执行"过任何东西,它只是决定 + 措辞
工具定义:description 和 JSON Schema 是模型唯一的说明书
# === ★ 模型选不选这个工具、参数填得对不对,全看定义 ===
# === ★ description:别写给程序员看,要写给模型看 ===
# ★ ★ 我最初的 query_logistics 描述,写的是"查询物流"。
# 四个字。我自己当然知道它是干嘛的 —— 但模型不知道
# 用户问"东西到哪了""啥时候到""发货了吗"这些口语,
# 到底算不算"查询物流"。
# ★ ★★ description 是模型判断"现在该不该用这个工具"的
# 唯一依据。它要写清楚三件事:
# - 这个工具【做什么】;
# - 【什么场景】该用它(把用户可能的各种问法都点到);
# - 【什么时候不该用】(划清和别的工具的边界)。
# 写充分了,开头那个"该调物流却没调"的 bug,大半
# 就消失了。
# === ★★ 参数 Schema:每个字段都要"卡死" ===
# ★ ★ 模型瞎填参数(order_id 填了串编造的数字),根因是
# schema 太松。一个严谨的参数定义,要做到:
# - ★ type 写准:该是 string 就 string,该是 integer
# 就 integer,别全用 string 糊弄;
# - ★ description 逐字段写:告诉模型这个字段【从哪来】
# —— "用户提供的订单号,通常是 ABC 开头的 10 位字符";
# - ★ enum 能枚举就枚举:状态、类型这种有限取值,
# 用 enum 锁死,模型就【不可能】填出范围外的值;
# - ★ required 标清楚:哪些参数必填,一个都不能少。
# === ★ 最关键一条:告诉模型"没有就别编" ===
# ★ ★ 光定义工具还不够。你得在 system prompt 里,用
# 斩钉截铁的话写明工具的使用纪律:
# - "涉及订单、物流、优惠券的问题,【必须】调用对应
# 工具获取真实数据,【严禁】凭记忆或猜测回答。"
# - "如果用户没有提供订单号,【不要】自己编造一个去
# 调用工具,而要【反问用户】索取订单号。"
# ★ ★ 这两句话,直接堵死了"编物流结果"和"瞎填参数"
# 两个最致命的 bug。
# === 小结 ===
# ★ 模型选不选这个工具、参数对不对全看定义。★ description
# 别写给程序员看要写给模型看:最初我把 query_logistics
# 描述写成"查询物流"四个字,自己知道干嘛但模型不知道
# "东西到哪了""啥时候到"这些口语算不算查物流;description
# 是模型判断"该不该用这工具"的唯一依据,要写清三件事 ——
# 做什么、什么场景该用(把各种问法都点到)、什么时候
# 不该用(划清和别的工具边界)。★★ 参数 Schema 每个
# 字段卡死:模型瞎填参数根因是 schema 太松,严谨定义要
# type 写准(该 string 就 string 该 integer 就 integer)、
# description 逐字段写(告诉模型这字段从哪来)、enum 能
# 枚举就枚举(状态类型这种有限取值锁死模型就不可能填
# 范围外的值)、required 标清楚。★ 最关键一条告诉模型
# "没有就别编":还要在 system prompt 用斩钉截铁的话写
# 工具使用纪律 —— 涉及订单物流必须调工具获取真实数据
# 严禁凭猜测回答、用户没给订单号不要自己编一个去调工具
# 而要反问索取,这两句直接堵死编结果和瞎填参数两个 bug。
# ★ 工具定义:对比"松垮版"和"严谨版" —— 差别全在这里
# ────────── ✗ 松垮版:模型会选错、会瞎填 ──────────
bad_tool = {
"type": "function",
"function": {
"name": "query_logistics",
"description": "查询物流", # ★✗ 太含糊
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"}, # ★✗ 没说从哪来
},
}, # ★✗ 没标 required
},
}
# ────────── ✓ 严谨版:把每一处都"卡死" ──────────
good_tool = {
"type": "function",
"function": {
"name": "query_logistics",
# ★★ description 写清:做什么 / 什么场景用 / 什么时候不用
"description": (
"根据订单号查询包裹的实时物流状态与轨迹。"
"当用户询问『东西到哪了』『什么时候到』『发货了吗』"
"『物流到哪一站』等与配送进度相关的问题时,调用此工具。"
"注意:本工具只查物流,不查订单金额/状态,那些用 query_order。"
),
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
# ★★ 逐字段 description:告诉模型这个值"从哪来"
"description": "用户提供的订单号,通常是 ABC 开头的 "
"10 位字符;若用户未提供,不要编造。",
},
"detail_level": {
"type": "string",
# ★★ enum 锁死取值 —— 模型不可能填出范围外的值
"enum": ["brief", "full"],
"description": "brief 只返回当前状态,full 返回完整轨迹。",
},
},
"required": ["order_id"], # ★★ 必填项标清楚
},
},
}
# ★ system prompt 里再补上"使用纪律" —— 堵死"编结果 / 瞎填参数"
SYSTEM_PROMPT = (
"你是订单客服助手。涉及订单、物流、优惠券的问题,必须调用对应工具"
"获取真实数据,严禁凭记忆或猜测回答。"
"如果用户没有提供订单号,不要自己编造一个去调用工具,而要反问用户索取。"
)
解析模型返回:tool_calls 是结构,不是自然语言
# === ★ 第 ② 步的产物,要当"结构化数据"来对待 ===
# === ★ tool_calls 是个数组,不是一个 ===
# ★ ★ 很多人(包括当初的我)默认 choice.tool_calls[0]
# 只取第一个。但模型【一轮可以要求调用多个工具】——
# 用户说"查一下我的订单,顺便看看物流",模型可能一次
# 返回两个 tool_call。只取 [0],第二个就被你【悄悄
# 丢掉】了,用户的物流问题永远得不到答案。
# ★ ★ 正确做法:把 tool_calls 当数组,【遍历】它,
# 每一个都执行、每一个都回灌。
# === ★ arguments 是 JSON 字符串,且模型可能写坏 ===
# ★ ★ tool_call.function.arguments 不是字典,是一段
# 【JSON 字符串】,你得自己 json.loads 解析。
# ★ ★★ 而模型生成的 JSON,【偶尔会是坏的】—— 少个引号、
# 多个逗号、中文引号混进来。如果你直接 json.loads 不
# 做 try,一旦解析失败,整个请求就崩了。
# 必须 try/except 兜住:解析失败时,不要让程序挂掉,
# 而是把"参数解析失败,请重新生成"作为工具结果回灌
# 给模型,给它一次自我修正的机会。
# === ★★ 幻觉工具名:模型会调一个"不存在的工具" ===
# ★ ★ 这是最隐蔽的坑。模型有时会一本正经地要求调用一个
# 你【根本没定义】的工具,比如 cancel_order —— 它觉得
# "客服应该能取消订单",就自作主张造了一个出来。
# ★ ★ 你的执行代码,在真正调用前,【必须先查一下】:
# 这个 name 在我的工具表里吗?不在,就【绝不能执行】,
# 而是回灌一句"不存在名为 X 的工具"给模型,让它换条
# 路。绝不能用模型给的 name 去动态反射调用 —— 那等于
# 把执行权交给了一个会幻觉的东西,是危险的。
# === 小结 ===
# ★ 第②步的产物要当结构化数据来对待。★ tool_calls 是个
# 数组不是一个:很多人默认只取 [0],但模型一轮可以要求
# 调多个工具(用户说"查订单顺便看物流"可能返回两个
# tool_call),只取 [0] 第二个就被悄悄丢掉、用户的物流
# 问题永远得不到答案,正确做法是当数组遍历、每个都执行
# 每个都回灌。★ arguments 是 JSON 字符串且模型可能写坏:
# 它不是字典是一段 JSON 字符串要自己 json.loads,而模型
# 生成的 JSON 偶尔是坏的(少引号、多逗号、中文引号),
# 直接 loads 不做 try 一旦失败整个请求就崩,必须 try
# 兜住 —— 失败时别让程序挂,把"参数解析失败请重新生成"
# 作为工具结果回灌给模型给它自我修正的机会。★★ 幻觉
# 工具名最隐蔽:模型有时会要求调一个你根本没定义的工具
# (觉得"客服应该能取消订单"就自作主张造个 cancel_order),
# 执行代码真正调用前必须先查这个 name 在不在工具表里、
# 不在就绝不能执行而是回灌"不存在名为 X 的工具"让它换
# 路,绝不能用模型给的 name 去动态反射调用 —— 那等于把
# 执行权交给一个会幻觉的东西。
# ★ 解析 tool_calls:遍历数组 + JSON 容错 + 幻觉工具名拦截
import json
# ★ 你真实拥有的工具,name -> 函数 的映射表。这张表是"白名单"
TOOL_REGISTRY = {
"query_order": query_order,
"query_logistics": query_logistics,
"send_coupon": send_coupon,
}
def dispatch_tool_calls(tool_calls: list) -> list:
"""把模型返回的 tool_calls 逐个执行,产出可回灌的 tool 消息列表。"""
tool_messages = []
# ★★ 关键:遍历整个数组,不要只取 [0] —— 一轮可能有多个
for call in tool_calls:
name = call.function.name
result = None
# === ① 幻觉工具名拦截:name 不在白名单 -> 绝不执行 ===
if name not in TOOL_REGISTRY:
result = {"error": f"不存在名为 {name} 的工具,请从可用工具中选择"}
else:
# === ② JSON 容错:模型生成的 arguments 可能是坏的 ===
try:
args = json.loads(call.function.arguments)
except json.JSONDecodeError as e:
# ★ 不让程序崩 —— 把错误作为结果回灌,给模型自我修正机会
result = {"error": f"参数不是合法 JSON({e}),请重新生成"}
else:
# === ③ 走到这里才是真正、安全地执行 ===
try:
result = TOOL_REGISTRY[name](**args)
except TypeError as e:
# ★ 参数对不上函数签名(少了必填项等)
result = {"error": f"参数与工具不匹配({e})"}
# ★ 每一个 call 都要回灌一条 tool 消息,tool_call_id 必须对上
tool_messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return tool_messages
执行与回灌:把结果塞回对话,让模型接着往下走
# === ★ 工具执行完,这一轮还没结束 ===
# === ★ 回灌:模型需要"看到结果"才能继续 ===
# ★ ★ 工具跑完拿到结果,不能直接把结果丢给用户。结果
# 是机器格式的(一个 JSON),用户要的是人话。你得把
# 结果【再发回模型】,让模型用自然语言组织成回答。
# ★ ★★ 回灌时,messages 列表的顺序【极其严格】,少一条
# 或顺序错,API 直接报错。一轮带工具的对话,messages
# 必须是这个顺序:
# - ① system / user 消息(原始问题);
# - ② assistant 消息(模型那一轮的输出,【带 tool_calls】)
# —— 这条必须原样塞回去,不能漏;
# - ③ tool 消息(每个工具一条结果,tool_call_id 要对上 ②
# 里的每个 id)。
# ★ ★ 漏了 ②,模型就不知道"是我自己要调工具的";③ 的
# id 对不上 ②,API 会报"tool_call_id 找不到对应调用"。
# === ★ 一次回灌之后,模型可能"还要再调一次" ===
# ★ ★ 把结果回灌回去,模型【不一定】就直接回答了。它
# 可能看了结果,觉得"还需要再查一个东西"——比如查完
# 订单发现是 VIP,它接着要求调 send_coupon。
# ★ ★★ 所以执行 + 回灌不是"一次性"的,它本质是一个
# 【循环】:发给模型 -> 看返回 -> 有 tool_calls 就执行、
# 回灌、再发 -> 直到模型返回的是【纯文本答案】(没有
# tool_calls 了),循环才结束。
# === ★ 工具结果要"如实"回灌,失败也要回灌 ===
# ★ ★ 工具执行失败了(数据库超时、订单不存在),怎么办?
# 不要静默吞掉,也不要假装成功。要把【失败这个事实】
# 如实回灌:"query_order 执行失败:订单不存在。"
# ★ ★ 模型拿到这句,就能正确地告诉用户"没查到这个订单,
# 请核对单号";你要是吞了,模型又会回到"没数据就编"
# 的老路上。
# === 小结 ===
# ★ 工具执行完这一轮还没结束。★ 回灌 —— 模型需要看到结果
# 才能继续:工具跑完的结果是机器格式 JSON,用户要的是
# 人话,得把结果再发回模型让它用自然语言组织成回答;
# 回灌时 messages 顺序极其严格少一条或错一条 API 直接
# 报错,一轮带工具的对话必须是 ① system/user 原始问题
# ② assistant 模型那轮带 tool_calls 的输出(必须原样塞
# 回不能漏)③ tool 消息(每个工具一条结果 tool_call_id
# 对上②里每个 id),漏了②模型不知道是自己要调工具的、
# ③的 id 对不上②API 会报错。★ 一次回灌后模型可能还要
# 再调:回灌后模型不一定直接回答,可能看了结果觉得还需
# 再查(查完订单发现是 VIP 接着要调 send_coupon),所以
# 执行+回灌不是一次性的、本质是个循环 —— 发给模型→看
# 返回→有 tool_calls 就执行回灌再发→直到模型返回纯文本
# 答案循环才结束。★ 工具结果要如实回灌失败也要回灌:
# 执行失败(数据库超时、订单不存在)别静默吞掉别假装
# 成功,把失败这个事实如实回灌,模型才能正确告诉用户
# "没查到请核对单号",吞了模型又回到"没数据就编"老路。
# ★ 执行 + 回灌的完整循环:直到模型返回纯文本答案才停
from openai import OpenAI
client = OpenAI()
def run_agent(user_message: str, tools: list, max_rounds: int = 8) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
# ★★ 执行+回灌本质是个循环,不是一次性的
for round_no in range(max_rounds):
resp = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools,
)
choice = resp.choices[0].message
# ★ 模型返回纯文本答案(没有 tool_calls)-> 循环结束
if not choice.tool_calls:
return choice.content
# === ① 把 assistant 那条(带 tool_calls)原样塞回去 ===
# ★★ 这条不能漏,漏了模型就不知道"是我自己要调工具的"
messages.append(choice)
# === ② 执行所有工具,把每条结果回灌 ===
# ★ dispatch_tool_calls 见上一节:已处理幻觉名 / JSON 容错
tool_messages = dispatch_tool_calls(choice.tool_calls)
messages.extend(tool_messages) # ★ tool_call_id 已在内部对好
# ★ 回灌完进入下一轮:模型看了结果,可能直接答,也可能再调
# ★★ 兜底:超过 max_rounds 还没收敛 —— 见下一节"循环终止"
return "抱歉,这个问题处理起来有点复杂,已为您转接人工客服。"
多轮编排与终止:别让 Agent 陷进死循环
# === ★ 开头那个"调几十次同一工具"的 bug,根因在这 ===
# === ★ 为什么 Agent 会陷入死循环 ===
# ★ ★ 还原一下:模型调了 query_order,你回灌了结果。
# 但如果那个结果模型"没看懂"、或者结果里有它觉得
# 矛盾的地方,它可能【再次】要求调 query_order ——
# 想"再确认一下"。你又执行、又回灌,它又调……
# ★ ★★ 这就是死循环。它不像代码里的 while True 那么
# 显眼,它是【模型在语义层面的反复横跳】。如果你的
# 循环没有硬上限,它能一直转到把 token 烧光、把超时
# 撑爆为止。
# === ★ 防线一:硬性的循环轮数上限 ===
# ★ ★ 这是【必须】有的底线。给整个"执行+回灌"循环
# 设一个 max_rounds(比如 8 轮)。到顶了,无论模型
# 还想不想调工具,都【强制停止】,走降级 ——
# 返回兜底话术、或转人工。
# ★ ★ 这道防线不防"为什么循环",它只保证"再坏也不会
# 无限坏下去"。是系统的保险丝。
# === ★★ 防线二:识别"原地打转"提前掐断 ===
# ★ ★ 光有轮数上限不够 —— 8 轮也可能是 8 轮无效调用。
# 更聪明的做法:记录每一次工具调用的【name + 参数】
# 指纹。如果发现模型【用完全相同的参数,调用同一个
# 工具第二次】,这就是明确的"原地打转"信号。
# ★ ★ 这时不要再傻乎乎地执行第二次。直接回灌一句:
# "你已经用相同参数调用过该工具,结果见上;请基于
# 已有结果回答,不要重复调用。" —— 把它从打转里
# 拽出来。
# === ★ 什么时候算"正常结束" ===
# ★ ★ 一轮编排的正常终点只有一个:模型某一轮返回的
# 消息里【没有 tool_calls】,只有纯文本 content。
# 这说明它认为"信息够了,可以回答了"。
# ★ ★ 所以循环的判断是:有 tool_calls -> 继续转;
# 没有 tool_calls -> 拿到答案、跳出。其余的(超轮数、
# 原地打转)都属于【异常终止】,要走降级而不是
# 把半成品丢给用户。
# === 小结 ===
# ★ 开头"调几十次同一工具"的 bug 根因在这。★ 为什么会
# 陷死循环:模型调了 query_order 你回灌结果,但若结果
# 它没看懂或觉得矛盾,可能再次要求调 query_order 想
# "再确认一下",你又执行又回灌它又调 —— 这就是死循环,
# 不像代码 while True 那么显眼,是模型在语义层面的反复
# 横跳,循环没硬上限它能转到把 token 烧光、超时撑爆。
# ★ 防线一硬性循环轮数上限:必须有的底线,给"执行+
# 回灌"循环设 max_rounds(如 8 轮),到顶无论模型还想
# 不想调都强制停止走降级,这道防线不防"为什么循环"
# 只保证"再坏也不会无限坏",是系统保险丝。★★ 防线二
# 识别原地打转提前掐断:光有轮数上限不够,8 轮也可能是
# 8 轮无效调用,更聪明的做法是记录每次工具调用的 name+
# 参数指纹,发现模型用完全相同参数调同一工具第二次就是
# 明确的原地打转信号,这时别再执行第二次、直接回灌
# "你已用相同参数调用过结果见上请基于已有结果回答"。
# ★ 什么时候算正常结束:正常终点只有一个 —— 模型某轮
# 返回的消息里没有 tool_calls 只有纯文本,说明它认为
# 信息够了;超轮数、原地打转都属异常终止,要走降级
# 而不是把半成品丢给用户。
# ★ 多轮编排:轮数上限 + 原地打转检测,双重防死循环
import json
from openai import OpenAI
client = OpenAI()
def run_agent_safe(user_message: str, tools: list, max_rounds: int = 8) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
# ★★ 防线二:记录调用指纹,识别"用相同参数调同一工具"
seen_calls = set()
for round_no in range(max_rounds): # ★★ 防线一:硬轮数上限
resp = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools,
)
choice = resp.choices[0].message
# ★ 正常结束:没有 tool_calls,只有纯文本 -> 这就是答案
if not choice.tool_calls:
return choice.content
messages.append(choice)
tool_messages = []
for call in choice.tool_calls:
# ★★ 指纹 = 工具名 + 参数。完全相同 = 原地打转
fingerprint = f"{call.function.name}::{call.function.arguments}"
if fingerprint in seen_calls:
# ★ 不再执行第二次,直接回灌"别重复调"把它拽出来
content = ("你已用相同参数调用过该工具,结果见上文;"
"请基于已有结果直接回答,不要重复调用。")
else:
seen_calls.add(fingerprint)
result = dispatch_tool_calls([call])[0]["content"]
content = result
tool_messages.append({
"role": "tool", "tool_call_id": call.id, "content": content,
})
messages.extend(tool_messages)
# ★★ 异常终止(超轮数):走降级,不要把半成品丢给用户
return "抱歉,这个问题我暂时没能处理好,已为您转接人工客服。"
工程坑:并行调用、参数二次校验、超时与成本
# === ★ 跑通主流程后,还有四个坑会在生产环境咬你 ===
# === ★ 坑一:并行工具调用,别串行傻等 ===
# ★ ★ 模型一轮返回 3 个 tool_calls,如果你 for 循环一个
# 一个执行,每个工具调用是 500ms 的网络 IO,3 个串起来
# 就是 1.5 秒。用户在干等。
# ★ ★ 这 3 个工具之间没有依赖,完全可以【并发执行】
# (线程池 / asyncio)。3 个一起跑,总耗时约等于最慢的
# 那一个 500ms。回灌时再按 tool_call_id 对回去即可。
# === ★★ 坑二:参数校验,别全信模型给的值 ===
# ★ ★ 哪怕 schema 写得再严,模型给的参数仍可能"合法但
# 危险"。比如 send_coupon 的 amount 字段,schema 是
# integer,模型填了个 99999 —— 类型完全合法,但发一张
# 9 万块的优惠券是事故。
# ★ ★★ 把模型的输出,当成【不可信的外部输入】来对待。
# 工具函数内部,必须再做一层【业务校验】:金额有没有
# 超上限、order_id 是不是当前用户的、优惠券该用户领过
# 没有。schema 防的是"格式",业务校验防的是"越权和
# 越界"。这一层绝不能省。
# === ★ 坑三:工具执行要有超时和降级 ===
# ★ ★ 工具背后是真实的数据库、HTTP 接口,它们会慢、会
# 挂。如果 query_logistics 调的物流接口卡了 30 秒,
# 你的整个 Agent 就被它拖死 30 秒。
# ★ ★ 每个工具调用都要包一个【超时】(比如 3 秒)。
# 超时了,不要抛异常崩掉,而是把"工具调用超时"作为
# 结果回灌给模型 —— 模型就能告诉用户"物流系统繁忙,
# 请稍后再试",而不是让用户对着转圈干等。
# === ★ 坑四:Function Calling 很烧 token,要心里有数 ===
# ★ ★ 一个常被忽略的成本:你定义的【所有工具的完整
# schema】,每一轮请求都会【随着 messages 一起发给
# 模型】。10 个工具、每个 schema 几百 token,就是几千
# token 的固定开销 —— 而且多轮循环里,每一轮都要付
# 一次。
# ★ ★ 优化:别把几十个工具一股脑全挂上。可以先用一个
# 轻量分类(甚至规则)判断用户意图,只把【这次可能
# 用得上】的那几个工具,传给模型。
# === 认知 ===
# ★ 跑通主流程后还有四个坑会在生产环境咬你。★ 坑一并行
# 工具调用别串行傻等:模型一轮返回 3 个 tool_calls,
# for 循环一个个执行每个 500ms 网络 IO 串起来就 1.5 秒
# 用户干等,这 3 个工具没依赖完全可并发执行(线程池/
# asyncio)总耗时约等于最慢那个 500ms,回灌时按
# tool_call_id 对回去。★★ 坑二参数校验别全信模型给的
# 值:哪怕 schema 再严模型给的参数仍可能"合法但危险"
# (send_coupon 的 amount 是 integer 模型填 99999 类型
# 合法但发 9 万优惠券是事故),要把模型输出当不可信
# 外部输入、工具函数内部必须再做业务校验(金额超没超
# 上限、order_id 是不是当前用户的),schema 防格式
# 业务校验防越权越界这层绝不能省。★ 坑三工具执行要有
# 超时降级:工具背后是真实数据库/HTTP 会慢会挂,物流
# 接口卡 30 秒整个 Agent 被拖死 30 秒,每个工具调用包
# 超时(如 3 秒),超时别抛异常崩掉而是把"超时"作为
# 结果回灌让模型告诉用户"系统繁忙稍后再试"。★ 坑四
# Function Calling 很烧 token:你定义的所有工具完整
# schema 每轮请求都随 messages 发给模型,10 个工具每个
# 几百 token 就是几千 token 固定开销且多轮每轮都付一次,
# 优化是别把几十个工具全挂上、先用轻量分类判断意图只传
# 这次可能用得上的那几个。
# ★ 工程加固:并行执行 + 超时 + 工具内业务校验
import json
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
_pool = ThreadPoolExecutor(max_workers=8)
# === ★ 坑二:工具函数内部做"业务校验",别只信 schema ===
def send_coupon(user_id: str, amount: int) -> dict:
# ★★ schema 只保证 amount 是 integer,挡不住"99999 元"
MAX_COUPON = 50
if amount > MAX_COUPON:
# ★ 把模型输出当不可信外部输入 —— 越界直接拒绝
return {"error": f"优惠券金额 {amount} 超出上限 {MAX_COUPON}"}
if amount <= 0:
return {"error": "优惠券金额必须为正数"}
# ...真正发券逻辑...
return {"ok": True, "user_id": user_id, "amount": amount}
# === ★ 坑一 + 坑三:并行执行 + 单个工具超时 ===
def dispatch_parallel(tool_calls: list, timeout: float = 3.0) -> list:
def _run(call):
name = call.function.name
if name not in TOOL_REGISTRY:
return {"error": f"不存在名为 {name} 的工具"}
try:
args = json.loads(call.function.arguments)
return TOOL_REGISTRY[name](**args)
except Exception as e:
return {"error": str(e)}
# ★★ 坑一:3 个工具一起跑,总耗时 ≈ 最慢的那一个
futures = {call: _pool.submit(_run, call) for call in tool_calls}
messages = []
for call, fut in futures.items():
try:
# ★★ 坑三:每个工具调用都设超时,超时不崩、回灌降级信息
result = fut.result(timeout=timeout)
except FutTimeout:
result = {"error": "工具调用超时,请稍后再试"}
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result, ensure_ascii=False),
})
return messages
一张图:一次带工具调用的对话全流程
关键字段速查
┌─────────────────────────┬──────────────────────────────────────┐
│ 字段 / 概念 │ 说明 │
├─────────────────────────┼──────────────────────────────────────┤
│ tools │ 请求里传的工具清单,每轮都要带 │
│ tool.function.name │ 工具名,执行前必须用白名单校验 │
│ tool.function.description│ 模型选不选这个工具的唯一依据,写充分 │
│ parameters (JSON Schema)│ 参数定义,type/enum/required 都要卡死 │
│ choice.tool_calls │ 模型要求调用的工具,是【数组】要遍历 │
│ tool_call.id │ 回灌时 tool 消息的 tool_call_id 要对上 │
│ function.arguments │ JSON【字符串】,需 json.loads 且要容错 │
│ role:"tool" │ 回灌工具结果的消息角色 │
│ role:"assistant"+calls │ 模型那轮带 tool_calls 的消息,必须塞回 │
│ finish_reason │ tool_calls=要调工具 / stop=已是最终答案 │
└─────────────────────────┴──────────────────────────────────────┘
★ messages 回灌顺序:system/user -> assistant(带tool_calls) -> tool(每个结果一条)
★ 终止条件:正常=返回无 tool_calls;异常=超 max_rounds / 原地打转 -> 走降级
★ 排查第一步:把每一轮的原始 tool_calls 结构 print 出来,人眼读一遍
避坑清单:接入 Function Calling 前过一遍这 10 条
- 记住模型不执行函数,只输出意图。模型返回的 tool_calls 是一张"待办清单",真正的执行永远是你的代码。想不通流程时,回到"模型动脑、代码动手"这句话。
- description 是写给模型的,不是写给程序员的。"查询物流"四个字不够。要写清做什么、什么场景用、和别的工具怎么区分,把用户可能的各种口语问法都点到。
- 参数 schema 要逐字段卡死。type 写准,有限取值用 enum 锁死,必填项标 required,每个字段写 description 告诉模型"这个值从哪来"。schema 松一寸,模型瞎填一尺。
- system prompt 里明令"没有就别编"。斩钉截铁写明:涉及业务数据必须调工具、严禁猜测;用户没给的参数要反问而不是编造。这一句堵死大半幻觉。
- tool_calls 是数组,必须遍历。只取 [0] 会悄悄丢掉模型一轮里要求的其他工具调用,用户的部分问题永远得不到回应。
- arguments 是 JSON 字符串,解析要容错。模型偶尔生成坏 JSON。json.loads 必须包 try,失败时把错误回灌给模型让它重新生成,而不是让程序崩溃。
- 用白名单拦截幻觉工具名。模型会要求调用你根本没定义的工具。执行前必须校验 name 在不在工具表里,绝不能拿模型给的名字去动态反射调用。
- 回灌 messages 的顺序极严格。assistant(带 tool_calls)那条必须原样塞回,每条 tool 消息的 tool_call_id 要对上。漏一条或顺序错,API 直接报错。
- 必须有循环上限 + 原地打转检测。模型会陷入语义层面的死循环。max_rounds 是保险丝;再用"工具名+参数"指纹识别重复调用,提前把它拽出来。
- 工具内部做业务校验,并设超时。schema 只防格式,防不了"合法但越界"的参数(如 9 万元优惠券)。把模型输出当不可信输入校验;每个工具调用设超时,超时回灌降级信息而不是崩溃。
总结:Function Calling 的难点,不在模型在工程
那个 AI 客服的事故复盘完,我把开头那几个 bug 又看了一遍,心态完全变了。「编物流结果」「瞎填订单号」「死循环调几十次」——上线那几天,我一直觉得这是模型「不够聪明」,满脑子想的是换个更贵的模型。可当我把每一轮模型返回的原始 tool_calls 结构老老实实打印出来、一行行读完,才发现一个有点扎心的事实:模型每一步的「决策」,其实都相当合理——它是在我给的那份残缺说明书的基础上,做出的合理反应。description 含糊,它就猜;schema 松垮,它就乱填;我没说「没有就别编」,它就编;我没设循环上限,它就一直转。它不是不听话,是我根本没把规则讲清楚。
所以 Function Calling 这套技术,真正的难点根本不在「模型能不能调用函数」——那部分,API 几行代码就跑通了。难点全在它周围那一圈工程:工具的 description 和 JSON Schema 写得够不够清楚,这决定了模型「选得对不对」;返回的 tool_calls 解析有没有容错,能不能拦住坏 JSON 和幻觉工具名,这决定了系统「崩不崩」;多轮编排有没有循环上限和打转检测,这决定了它「会不会失控」;工具内部有没有业务校验和超时,这决定了它「安不安全、稳不稳」。模型只提供了一个「会决策」的大脑,而把这个大脑接进真实业务、让它既能干活又不闯祸的那一整套护栏,得你自己一根一根焊上去。
如果把这件事收敛成一句话,我会说:在 Function Calling 里,要把模型的每一个输出,都当成「不可信的外部输入」来对待。它给的工具名,可能是它幻觉出来的;它给的参数,可能格式是坏的、或者数值是越界的;它的下一步意图,可能是要陷进死循环。你不会拿用户从前端传来的参数直接去查数据库——同样,你也不该拿模型的输出直接去执行。校验、容错、限流、降级,这些你为「不可信用户输入」准备的老办法,一个都不能少地,也要为模型的输出准备一份。
这个客服 Agent 重做之后,稳定跑了下来。它偶尔还是会有判断不到位的时候,但「编一个不存在的物流状态」这种致命错误,再没出现过——因为现在,就算模型想编,白名单会拦、业务校验会拦、查不到时回灌的那句「订单不存在」也会把它拉回正轨。我后来跟团队说:做 AI Agent,你写的代码量里,真正「调用大模型」的可能只占一成,剩下九成,都是在给这个聪明但不可控的大脑,搭一个让它既能放开手脚、又摔不出格的笼子。那一成是能力,这九成,才是产品。
—— 别看了 · 2026