大模型 Function Calling 完全指南:从一次 AI 客服乱调工具看懂工具编排

2024 年我给公司做一个客服 AI Agent,用 Function Calling 给模型挂了 query_order、query_logistics、send_coupon 三个工具,demo 行云流水老板满意。上线没两天投诉就来了:用户问"我的订单到哪了"它不调 query_logistics 反而自己编了个物流状态、用户给了订单号它调工具时 order_id 却填了一串瞎编的数字、还有一次陷入循环反复调同一个工具几十次撑爆对话轮数超时。我又以为模型不够聪明想换更贵的,直到把每轮模型返回的原始 tool_calls 结构打印出来一行行读才看明白——绝大多数问题不在模型推理能力,而在我写的那份工具说明书 description 含糊、schema 松垮、没说"没有就别编"、没有循环上限。梳理:Function Calling 最大误解是以为模型会执行函数,真相是模型只决定不执行 —— 它输出的是结构化 JSON 意图,真正执行永远是你的代码,完整一轮是发给模型→模型返回 tool_calls→你的代码解析执行→结果回灌四步接力。工具定义里 description 和 JSON Schema 是模型唯一的说明书,要写清做什么、什么场景用、和别的工具怎么区分,参数 type/enum/required 都要卡死,system prompt 还要明令"没有就别编"。解析 tool_calls 要当结构化数据:它是数组要遍历不能只取

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 条

  1. 记住模型不执行函数,只输出意图。模型返回的 tool_calls 是一张"待办清单",真正的执行永远是你的代码。想不通流程时,回到"模型动脑、代码动手"这句话。
  2. description 是写给模型的,不是写给程序员的。"查询物流"四个字不够。要写清做什么、什么场景用、和别的工具怎么区分,把用户可能的各种口语问法都点到。
  3. 参数 schema 要逐字段卡死。type 写准,有限取值用 enum 锁死,必填项标 required,每个字段写 description 告诉模型"这个值从哪来"。schema 松一寸,模型瞎填一尺。
  4. system prompt 里明令"没有就别编"。斩钉截铁写明:涉及业务数据必须调工具、严禁猜测;用户没给的参数要反问而不是编造。这一句堵死大半幻觉。
  5. tool_calls 是数组,必须遍历。只取 [0] 会悄悄丢掉模型一轮里要求的其他工具调用,用户的部分问题永远得不到回应。
  6. arguments 是 JSON 字符串,解析要容错。模型偶尔生成坏 JSON。json.loads 必须包 try,失败时把错误回灌给模型让它重新生成,而不是让程序崩溃。
  7. 用白名单拦截幻觉工具名。模型会要求调用你根本没定义的工具。执行前必须校验 name 在不在工具表里,绝不能拿模型给的名字去动态反射调用。
  8. 回灌 messages 的顺序极严格。assistant(带 tool_calls)那条必须原样塞回,每条 tool 消息的 tool_call_id 要对上。漏一条或顺序错,API 直接报错。
  9. 必须有循环上限 + 原地打转检测。模型会陷入语义层面的死循环。max_rounds 是保险丝;再用"工具名+参数"指纹识别重复调用,提前把它拽出来。
  10. 工具内部做业务校验,并设超时。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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Redis 缓存完全指南:从一次缓存雪崩看懂穿透、击穿、雪崩怎么防

2026-5-21 13:49:55

技术教程

消息队列完全指南:从一次库存扣三次的事故看懂丢失、重复、顺序怎么治

2026-5-21 16:31:19

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索