AI 与大模型

Function Calling 的实现原理

模型从来没有执行过你的函数,它只是输出了一段结构化 JSON。把 Function Calling 拆成六步来看,你会发现它的本质是「结构化输出 + 客户端分发」,理解了这点,并行调用、参数校验、失败重试的边界就都清楚了。

2026-05-07 约 17 分钟 6792 字 Function Calling 大模型 Agent 工具调用

一、先纠正一个最常见的误解

让大模型去查一下实时库存,很多人的第一反应是:「给模型注册一个函数,模型需要的时候自己会去调」。

这个理解是错的。

模型从头到尾没有执行过任何一行代码。它做的唯一一件事是:输出一段符合约定格式的 JSON,告诉你「我想调这个函数,参数是这些」。真正连数据库、发 HTTP 请求、拿到结果再塞回对话里的,是你自己的后端代码。

这直接决定了后面所有工程问题的归属:

环节 谁负责 意味着什么
判断「要不要调工具」 大模型 这是模型能力,不可靠、可能漏判
决定「调哪个、参数是什么」 大模型 参数可能编造、可能类型不对
真正执行 你的代码 超时、重试、鉴权、幂等都在这一侧
拿到结果后继续推理 大模型 需要把结果按协议回传

一句话:模型负责「说」,你的代码负责「做」。

所有关于安全、超时、幂等、权限的担心,落点都在「做」这一侧——因为只有那一侧真的碰得到数据。

二、完整流程:六步

把一次带工具调用的对话拆开,是这六步:

① 定义 Function
   写出函数名、作用描述、参数 schema(JSON Schema)

② 封装进请求
   把上面的定义 + 用户问题一起发给模型
   → 注意:数组叫 tools,不叫 prompt,是独立的协议字段

③ 模型判断
   模型读用户这句话,判断「需不需要调工具」

④ 返回函数名 + 参数
   需要 → 返回一个结构化的 tool_calls 列表(JSON)
   不需要 → 直接返回自然语言,流程结束

⑤ 客户端解析并执行
   你的代码按 name 找到本地实现,反序列化参数,执行它
   把执行结果按协议格式再发回给模型

⑥ 模型继续
   模型拿着工具结果继续推理,可能再调别的工具,也可能直接作答

第 ⑤ 步和第 ⑥ 步可能来回很多轮,直到模型给出最终答复。这个循环就是 Agent 的最小骨架——所谓 Agent,无非是在这个循环上加上了规划、记忆和终止条件。

第 ① 步:定义长什么样

函数定义不是随手写的注释,它是给模型看的结构化文档,标准做法是 JSON Schema:

{
  "type": "function",
  "function": {
    "name": "get_product_price",
    "description": "查询指定 SKU 的实时价格与库存。知识库里的价格可能过期,凡涉及价格一律调用此工具。",
    "parameters": {
      "type": "object",
      "properties": {
        "sku_id": {
          "type": "string",
          "description": "商品 SKU 编号,形如 SKU12345678"
        },
        "city": {
          "type": "string",
          "description": "可选,用于查区域库存,不传则查全国总库存"
        }
      },
      "required": ["sku_id"]
    }
  }
}

有两个细节特别容易写坏:

  • description 是提示词的一部分,不是给人看的注释。 模型判断「该不该调」几乎完全依赖它。写清「什么时候该调」「什么时候不该调」「返回值是什么含义」,比把参数名起得多漂亮有用得多。
  • 参数类型要收紧。 能用 enum 就别用自由字符串,能用 integer 就别用 string。约束越强,模型编造的空间越小。

第 ②③④ 步:协议长什么样

把工具定义和用户消息一起发出去后,模型的返回不再是一段纯文本,而是带 finish_reason 的结构化响应:

{
  "choices": [{
    "finish_reason": "tool_calls",
    "message": {
      "role": "assistant",
      "content": null,
      "tool_calls": [{
        "id": "call_a1b2c3",
        "type": "function",
        "function": {
          "name": "get_product_price",
          "arguments": "{\"sku_id\":\"SKU12345678\",\"city\":\"杭州\"}"
        }
      }]
    }
  }]
}

几个必须注意的点:

字段 含义 坑
finish_reason tool_calls 表示「这次不是最终答复」 只看 content 会拿到 null
tool_calls[].id 这一次调用的唯一标识 回传结果时必须原样带回
arguments 是字符串,不是对象 需要二次 JSON.parse,且可能解析失败

arguments 是字符串这一点,是大量线上事故的来源。它可能因为模型生成长度截断而少一个大括号,也可能因为转义处理不当而多一层引号。任何生产实现都必须把「解析 arguments」当成一个会抛异常的操作来处理,而不是想当然地当成 Map 直接取。

第 ⑤ 步:Java 侧怎么接

模型返回之后,客户端的职责是「按名字分发」:

// 1. 解析模型返回的 tool_calls
List<ToolCall> calls = response.getMessage().getToolCalls();

for (ToolCall call : calls) {
    String name = call.getFunction().getName();
    String rawArgs = call.getFunction().getArguments();

    // 2. 参数反序列化 —— 这一步会失败,必须兜住
    JsonNode args;
    try {
        args = objectMapper.readTree(rawArgs);
    } catch (JsonProcessingException e) {
        // 不抛给用户,而是把「参数不合法」当成工具结果回传,让模型自己修一次
        appendToolError(call.getId(), "arguments 不是合法 JSON,请重新按 schema 输出");
        continue;
    }

    // 3. 分发执行,结果统一包成 ToolMessage
    String result;
    try {
        result = toolRegistry.get(name).execute(args);   // 内部自带超时
    } catch (ToolTimeoutException e) {
        result = "{\"error\":\"工具调用超时,数据暂不可用\"}";
    } catch (Exception e) {
        result = "{\"error\":\"工具执行失败\"}";
    }

    // 4. 回传:role=tool + tool_call_id 必须配对
    messages.add(new ToolMessage(call.getId(), result));
}

第 4 步的配对关系是硬性协议要求:每个 tool_call_id 都必须有一条对应结果,哪怕是报错。少一条,下一次请求就会被服务端直接拒绝——因为协议上 assistant 说了要调 N 个工具,就必须有 N 个结果。

重要原则:工具执行失败,不要抛异常中断整条链路,而是把「失败」作为结果回传给模型。 模型看到「超时/无权限/参数不合法」,往往能自己换一个参数重试,或者改用礼貌的兜底回复告知用户。把异常抛给前端,用户体验反而更差。

三、和 RAG 的分工:一个必须分清的边界

Function Calling 和 RAG 经常一起出现,但两者解决的是完全不同的问题,混淆它们是新手最容易犯的设计错误。

维度 RAG Function Calling
解决什么 知识问题:模型不知道的事 能力/数据问题:模型拿不到实时数据或做不了动作
数据来源 预先建好的知识库(向量库 + 关键词索引) 任意后端接口:数据库、内部服务、第三方 API
数据时效 建库时快照,可能过期 实时,每次现查
触发时机 每轮几乎都做,是请求链路的一环 由模型决定要不要调、调哪个
写操作 只能读 可以写:下单、发券、改地址
失败表现 召回不准 → 答得偏 参数编造 → 调错工具、传错 ID

举一个能说清边界的例子:用户问「2000 块以内、家里有宠物,有没有合适的洗地机」。

  • 「适合养宠家庭的洗地机有哪些型号、各自卖点」→ RAG,这是相对稳定的商品知识。
  • 「现在多少钱、有没有货」→ Function Calling,这必须是实时数据,RAG 里的价格随时会过期。

判据:这份数据会不会变?会变就走工具调用,不会变才进知识库。

把实时价格塞进向量库,是 AI 应用里最常见的架构性错误——用户会看到一个月前的价签。

两条路也不是互斥的。生产做法通常是:先用 RAG 召回一批商品,再由模型决定对其中几个调工具拿实时价格。RAG 负责缩小范围,工具负责拿准数据。

四、并行调用:一次往返做完 N 件事

模型在一次响应里返回多个 tool_calls,就是并行调用。这不是什么高级特性,协议天然支持——tool_calls 本来就是数组。

用户问「对比一下 A 和 B 这两款的价格」,如果模型分两轮返回,链路就是:

串行(两轮):
  请求1 → 调 A → 回传 → 请求2 → 调 B → 回传 → 请求3 → 生成答案
  用户等待 = 3 次模型往返 + 2 次工具耗时

并行(一轮):
  请求1 → 同时调 A 和 B → 一起回传 → 请求2 → 生成答案
  用户等待 = 2 次模型往返 + max(工具A, 工具B)

省掉的是一次完整的模型往返(通常 1~3 秒),工具本身可以并发执行,总耗时取最慢那一路。

客户端侧要做到并行,有三个要点:

// 并行执行:多个工具之间互不阻塞
List<CompletableFuture<ToolResult>> futures = calls.stream()
        .map(call -> CompletableFuture
                .supplyAsync(() -> executeSafely(call), toolExecutor)  // 独立线程池
                .orTimeout(2, TimeUnit.SECONDS)                        // 每路独立超时
                .exceptionally(e -> ToolResult.error(call.getId(), e)))
        .toList();

// 等全部完成 —— 不要用 allOf 后就撒手,必须 join 拿结果
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
要点 为什么
用独立线程池,不要共用主业务线程池 工具里有阻塞 IO,共用会把主链路拖垮
每一路独立设超时 一个慢工具不能拖死整批结果
超时/失败也要回传一条 error 结果 满足「N 个调用 N 个结果」的协议约束

另外,并行调用能不能触发取决于模型。同一个问题,不同模型、不同提示词下可能一轮返回两个,也可能分两轮返回。想提高并行率,通常要在工具描述里明确写出「可以同时查询多个 SKU」这类引导。

五、参数校验与安全边界

这是 Function Calling 里最容易被低估的部分。要记住一个前提:

模型给的参数是「用户可控输入」,不是「可信内部调用」。

用户完全可以在对话里注入 '; DROP TABLE--,或者诱导模型去调一个它本不该调的写操作。所以客户端侧必须有一层和「外部 API 网关」同样严格的校验:

校验层 做什么 防什么
白名单 只允许调用注册表里存在的函数名 模型幻觉出不存在的函数
Schema 校验 类型、必填、枚举、长度、正则 类型错、越界、注入字符
业务校验 ID 格式合法、金额上限、日期区间 语义上不合理的参数
权限校验 当前用户能不能操作这个资源 越权:查到别人的订单
写操作二次确认 涉及下单/退款/改地址的,必须用户确认 模型自作主张产生副作用

权限这一条最要命。工具的参数里如果带了 user_id / order_no,而校验只做了「格式对不对」,那么用户换个订单号就能查到别人的数据——这本质上是越权访问漏洞,和大模型没有关系。

正确做法是:调用方身份不来自模型参数,而来自服务端会话。

// ❌ 危险:信任模型给的 user_id
public String queryOrder(String orderNo, String userIdFromModel) { ... }

// ✅ 安全:身份由服务端上下文注入,模型无权指定
public String queryOrder(String orderNo, String userIdFromSession) {
    Order o = orderMapper.selectByNo(orderNo);
    if (o == null || !o.getUserId().equals(userIdFromSession)) {
        return "{\"error\":\"订单不存在\"}";   // 统一模糊返回,不泄露存在性
    }
    ...
}

写操作还有一条独立原则:能不做成工具就不做成工具。凡是「一次误判就造成资金损失」的动作,宁可让模型输出一段自然语言 + 一个前端按钮,由用户点击触发,也不要暴露成模型可直接调用的函数。

六、失败重试:四类失败,四种处理

工具调用的失败方式不止一种,区分开,重试策略才不会互相打架:

失败类型 例子 处理方式 能不能重试
参数不合法 JSON 解析失败、缺必填字段 把错误信息作为工具结果回传,让模型自修正一次 可以让模型修,但最多一次
工具执行失败 下游 500、超时 有限重试(指数退避)+ 失败后告知模型「数据不可用」 幂等读操作可以;写操作绝不能盲目重试
模型限流 429、超载 指数退避 + 备用模型 + 最终降级回复 可以
图跑飞 模型反复调同一个工具 限制最大轮次,超限强制终止并返回兜底答复 不重试,直接熔断

几个实践细节:

  1. 自修正只给一次机会。 模型拿着报错信息重试一次成功率不低;但给它无限次,它会陷入「改参数 → 还是错 → 再改」的循环,把延迟和 token 一起烧掉。
  2. 写操作必须有幂等键。 如果超时重试,而第一次其实已经执行成功,没有幂等键就会重复下单。做法和普通接口一样:调用方生成唯一 ID,服务端按 ID 去重。
  3. 重试次数和轮次上限是两个维度。 「单个工具重试 3 次」和「整个循环最多 10 轮」要分别设置——前者防下游抖动,后者防模型死循环。
  4. 失败的返回值要写成模型能读懂的话。 {"error":"timeout"} 不如 {"error":"库存接口超时,无法获取实时库存,请告知用户稍后再试"}——后者模型才知道接下来该说什么。

完整的兜底链路通常长这样:

工具调用
  ├─ 成功          → 结果回传,模型继续
  ├─ 参数错        → 回传错误 → 模型自修正(限 1 次) → 仍失败则跳过
  ├─ 超时/500     → 退避重试(限 N 次) → 仍失败 → 回传「数据不可用」
  └─ 轮次超限      → 熔断,返回预设兜底答复 + 记录日志供排查

七、小结

  • 模型从不执行函数,它只输出结构化的 tool_calls;执行、鉴权、超时、重试全在客户端
  • 完整链路是六步:定义 → 封装请求 → 模型判断 → 返回函数名与参数 → 客户端解析执行并回传 → 模型继续
  • arguments 是字符串,解析失败是常态而非异常,必须显式兜住
  • N 个 tool_calls 必须有 N 条结果回传,报错也要回传,否则下一次请求直接被拒
  • 工具执行失败应该作为结果回传让模型处理,而不是抛异常中断链路
  • RAG 管「相对不变的知识」,Function Calling 管「会变的实时数据和动作」——会变的数据不要进向量库
  • 并行调用省的是模型往返次数,工具本身可以并发,总耗时取最慢那路
  • 模型给的参数等同于用户输入:白名单、Schema 校验、越权校验、写操作二次确认,一层都不能少;身份必须来自服务端会话,绝不能来自模型参数
  • 四类失败分开治:参数错让模型修一次、执行错退避重试、限流切备用模型、跑飞就限轮次熔断

Function Calling 最反直觉的地方在于它的「轻」——协议本身只是几个 JSON 字段。正因为协议这么轻,那些看起来像「模型能力」的问题(调错工具、编造参数、重复调用),最后都要在客户端用最朴素的工程手段解决:校验、超时、幂等、限流。把模型当成一个不可信的外部调用方,设计反而会变得清晰。

留下点什么

评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。