Function Calling 的实现原理
模型从来没有执行过你的函数,它只是输出了一段结构化 JSON。把 Function Calling 拆成六步来看,你会发现它的本质是「结构化输出 + 客户端分发」,理解了这点,并行调用、参数校验、失败重试的边界就都清楚了。
一、先纠正一个最常见的误解
让大模型去查一下实时库存,很多人的第一反应是:「给模型注册一个函数,模型需要的时候自己会去调」。
这个理解是错的。
模型从头到尾没有执行过任何一行代码。它做的唯一一件事是:输出一段符合约定格式的 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、超载 | 指数退避 + 备用模型 + 最终降级回复 | 可以 |
| 图跑飞 | 模型反复调同一个工具 | 限制最大轮次,超限强制终止并返回兜底答复 | 不重试,直接熔断 |
几个实践细节:
- 自修正只给一次机会。 模型拿着报错信息重试一次成功率不低;但给它无限次,它会陷入「改参数 → 还是错 → 再改」的循环,把延迟和 token 一起烧掉。
- 写操作必须有幂等键。 如果超时重试,而第一次其实已经执行成功,没有幂等键就会重复下单。做法和普通接口一样:调用方生成唯一 ID,服务端按 ID 去重。
- 重试次数和轮次上限是两个维度。 「单个工具重试 3 次」和「整个循环最多 10 轮」要分别设置——前者防下游抖动,后者防模型死循环。
- 失败的返回值要写成模型能读懂的话。
{"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_* 四个变量后自动开启。