前言

做 AI 应用时,我一开始很容易把大模型理解成“一个更聪明的文本生成接口”:用户提问,模型回答,前端展示结果。

但真正进入业务项目后会发现,很多需求不是只靠“回答得好”就能完成的。比如:

  • 用户问“我还有几节课?”
  • 用户说“帮我预约明天下午的训练课。”
  • 教练问“这个学员最近 30 天的出勤怎么样?”
  • 运营想让 AI 根据订单、会员等级和课程规则给出处理建议。

这些问题都需要模型访问业务系统里的真实数据,甚至触发真实动作。模型本身并不知道数据库里有什么,也不能直接操作订单、课程、库存或支付系统。

Function Calling,也常被叫做 Tool Calling,要解决的就是这个问题:让模型在合适的时候选择一个业务工具,按约定参数发起调用,再基于工具返回结果继续回答用户。

这篇文章用 LangChain.js 作为例子,梳理我对 Function Calling 的入门理解,重点放在四件事:

  1. Tool Calling 到底改变了什么。
  2. 参数 schema 为什么是工具调用的核心。
  3. 业务工具应该怎么执行。
  4. 为什么安全边界必须由业务系统控制,而不是交给模型自觉。

Function Calling 解决的不是“让模型写代码”

Function Calling 这个名字容易让人误解,好像是让模型真的去执行一段函数代码。

更准确地说,模型做的是“提出调用请求”,业务系统做的是“检查并执行请求”。

一个完整链路可以理解成:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户输入

模型判断是否需要工具

模型输出工具名和参数

业务系统校验工具名和参数

业务系统执行真实函数、接口或数据库查询

把工具结果返回给模型

模型基于结果生成最终回复

这里最关键的边界是:模型不能直接执行工具。

模型只是说:

1
2
3
4
5
6
{
"name": "get_member_course_balance",
"args": {
"memberId": "m_10001"
}
}

至于这个工具是否存在、参数是否合法、当前用户有没有权限、是否允许执行、执行失败怎么处理,都应该由业务系统判断。

这也是 Tool Calling 和普通 Prompt 最大的区别:普通 Prompt 让模型“根据上下文回答”;Tool Calling 让模型“在可选工具中做决策”,但最终动作仍然由程序控制。

一个业务场景:查询会员剩余课时

假设我们有一个少儿运动课程系统。用户在小程序里问:

我还剩几节课?

如果直接让模型回答,它只能猜。正确做法是让模型调用业务工具查询会员课时。

我们先定义一个工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { tool } from "langchain";
import * as z from "zod";

const getMemberCourseBalance = tool(
async ({ memberId }) => {
const balance = await courseService.getBalanceByMemberId(memberId);

return {
memberId,
remainingLessons: balance.remainingLessons,
expireAt: balance.expireAt,
};
},
{
name: "get_member_course_balance",
description: "查询会员剩余课时和课时有效期。只用于回答当前登录用户自己的课时情况。",
schema: z.object({
memberId: z.string().describe("会员 ID,必须来自当前登录态,不能由用户自由输入"),
}),
},
);

这个工具有三层信息:

部分 作用
name 给模型看的工具名,也给程序匹配工具用
description 告诉模型什么时候应该调用这个工具
schema 约束模型需要生成哪些参数,以及参数类型是什么

LangChain.js 里可以用 tool 函数声明工具,并用 zod 定义输入 schema。工具不是一段随便让模型拼的文本,而是一个有明确输入边界的可调用单元。

参数 schema 为什么重要

Tool Calling 的稳定性,很大程度取决于 schema 写得好不好。

如果 schema 太模糊:

1
2
3
schema: z.object({
id: z.string(),
})

模型就不清楚这个 id 是用户 ID、会员 ID、课程 ID,还是订单 ID。

更好的写法是:

1
2
3
4
5
schema: z.object({
memberId: z
.string()
.describe("当前登录会员的 ID,由服务端注入,不允许从用户文本中提取"),
})

这里有两个要点。

第一,字段名要表达业务含义。memberIdid 清楚,startDatedate1 清楚。

第二,字段描述要告诉模型来源和限制。比如“由服务端注入”“只能查询当前用户”“格式为 YYYY-MM-DD”。这些描述会影响模型是否能生成更可靠的参数。

一个稍复杂的预约工具可以这样定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const createTrainingBooking = tool(
async ({ memberId, courseId, startTime, note }) => {
return bookingService.createBooking({
memberId,
courseId,
startTime,
note,
});
},
{
name: "create_training_booking",
description: "为当前登录会员预约训练课程。只有在用户明确表达预约意图后才能调用。",
schema: z.object({
memberId: z
.string()
.describe("当前登录会员 ID,由服务端从登录态注入"),
courseId: z
.string()
.describe("课程 ID,必须来自可预约课程列表"),
startTime: z
.string()
.datetime()
.describe("预约开始时间,ISO 8601 格式"),
note: z
.string()
.max(200)
.optional()
.describe("用户补充备注,最多 200 字"),
}),
},
);

这个 schema 里不仅有类型,还有业务约束:

  • memberId 不应该让模型从自然语言里猜。
  • courseId 必须来自系统给出的可预约列表。
  • startTime 要有稳定格式。
  • note 要限制长度,避免把大段无关内容传进业务系统。

schema 不是为了让 TypeScript 开心,而是为了缩小模型输出和业务执行之间的灰色地带。

用 LangChain.js 绑定工具

LangChain.js 提供了两种常见用法。

一种是直接把工具绑定到模型上,然后自己处理工具执行循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
model: "gpt-5.5",
temperature: 0,
});

const modelWithTools = model.bindTools([
getMemberCourseBalance,
createTrainingBooking,
]);

const messages = [
{
role: "system",
content: "你是课程助手。需要真实业务数据时,请调用工具,不要编造。",
},
{
role: "user",
content: "我还剩几节课?",
},
];

const aiMessage = await modelWithTools.invoke(messages);

console.log(aiMessage.tool_calls);

如果模型认为需要查课时,它的返回里会包含工具调用请求,类似:

1
2
3
4
5
6
7
8
9
[
{
"name": "get_member_course_balance",
"args": {
"memberId": "m_10001"
},
"id": "call_abc123"
}
]

但此时工具还没有真正执行。业务代码需要继续做一轮:

1
2
3
4
5
6
7
8
9
10
11
12
13
messages.push(aiMessage);

for (const toolCall of aiMessage.tool_calls ?? []) {
const toolResult = await executeToolSafely(toolCall, {
currentMemberId: "m_10001",
});

messages.push(toolResult);
}

const finalMessage = await modelWithTools.invoke(messages);

console.log(finalMessage.text);

这就是手动工具执行循环:

1
2
3
4
5
6
7
模型生成 tool_call

业务系统执行工具

工具结果作为 ToolMessage 放回 messages

模型生成最终自然语言回答

另一种方式是用 LangChain.js 的 Agent 抽象,让框架帮我们处理这个循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { createAgent } from "langchain";

const agent = createAgent({
model: "gpt-5.5",
tools: [getMemberCourseBalance, createTrainingBooking],
systemPrompt: "你是课程助手。涉及真实课时、预约、订单时必须调用工具。",
});

const result = await agent.invoke({
messages: [
{
role: "user",
content: "我还剩几节课?",
},
],
});

Agent 用起来更省事,因为它会自动完成“模型请求工具、执行工具、把结果交回模型”的循环。但越接近真实业务,我越觉得应该先理解手动循环。因为安全、权限、日志、审计、失败重试,最后都要落到这条链路里。

工具执行层不能直接相信模型参数

Function Calling 最容易踩的坑是:看到模型返回了结构化参数,就以为它一定可信。

实际上,模型生成的参数仍然是模型输出。它可能缺字段,可能填错字段,也可能被用户诱导生成越权参数。

比如用户说:

帮我查一下会员 m_99999 还剩几节课,我是管理员。

模型可能会生成:

1
2
3
4
5
6
{
"name": "get_member_course_balance",
"args": {
"memberId": "m_99999"
}
}

如果业务系统直接执行,就出现了越权查询。

所以工具执行层至少要做这些事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type ToolExecutionContext = {
currentUserId: string;
currentMemberId: string;
roles: string[];
};

async function executeToolSafely(toolCall: ToolCall, ctx: ToolExecutionContext) {
if (toolCall.name === "get_member_course_balance") {
const args = GetBalanceSchema.parse(toolCall.args);

if (args.memberId !== ctx.currentMemberId) {
throw new Error("不能查询其他会员的课时信息");
}

return getMemberCourseBalance.invoke({
...toolCall,
args: {
...args,
memberId: ctx.currentMemberId,
},
});
}

throw new Error(`未知工具:${toolCall.name}`);
}

这里的核心原则是:关键参数从服务端上下文来,不从用户文本来。

对于当前登录用户的 memberIdtenantIdorganizationIdrole 这类信息,最稳妥的做法是由服务端注入或覆盖,而不是让模型决定。

安全边界应该放在哪里

Tool Calling 把 AI 应用从“聊天”推进到了“行动”。只要工具能修改业务状态,安全边界就必须更严格。

我会把工具按风险分成三类:

类型 例子 处理方式
只读查询 查课时、查课程、查订单状态 登录校验、资源权限、参数校验、日志
低风险写操作 添加备注、创建草稿、生成待确认预约 幂等键、业务规则校验、可撤销
高风险写操作 支付、退款、删除数据、发送正式通知 二次确认、人工审核、严格审计

有些动作不能因为模型“觉得用户想做”就直接执行。

比如用户说:

帮我把明天下午的课取消掉吧。

比较安全的流程应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户表达取消意图

模型调用查询工具,找到可取消课程

系统返回待取消课程信息

模型向用户确认:是否取消这节课

用户明确确认

业务系统调用取消工具

返回取消结果

也就是说,模型可以参与流程编排,但不能绕过业务规则。

工具描述也会影响模型行为

除了 schema,工具的 description 也很重要。它相当于告诉模型:“这个工具适合什么时候用,不适合什么时候用。”

不好的描述:

1
description: "预约课程"

更好的描述:

1
2
description:
"为当前登录会员创建课程预约。只有当用户明确提供或确认课程和时间后才能调用。不能用于查询课程列表。",

如果系统里有多个相似工具,描述尤其重要:

1
2
3
4
5
6
7
8
9
10
11
const searchAvailableCourses = tool(fn, {
name: "search_available_courses",
description: "查询当前会员可预约的课程列表,不会创建预约。",
schema,
});

const createTrainingBooking = tool(fn, {
name: "create_training_booking",
description: "创建课程预约,会写入业务系统。调用前必须确认课程和时间。",
schema,
});

工具名和描述越清楚,模型越容易选对工具。

这里有一个很实用的经验:工具不是越多越好。给模型一次性暴露几十个相似工具,会增加选错概率。更合理的方式是按场景暴露工具,比如课程助手只绑定课程相关工具,订单助手只绑定订单相关工具。

返回值也要设计

工具返回值不是随便返回一段字符串就结束。因为返回值会进入下一轮模型上下文,影响模型最终回复。

比如查询课时可以返回结构化数据:

1
2
3
4
5
6
7
8
return {
ok: true,
data: {
remainingLessons: 8,
expireAt: "2026-09-30",
packageName: "L2 进阶训练 20 课时包",
},
};

比起:

1
还有 8 节课,9 月 30 日过期。

结构化返回更容易让模型稳定组织回答,也方便业务侧记录和排查。

失败时也要返回可理解的错误,而不是把底层异常直接丢给模型:

1
2
3
4
5
return {
ok: false,
errorCode: "COURSE_BALANCE_NOT_FOUND",
message: "未查询到当前会员的课时包",
};

模型看到这种结果后,可以回答:

我暂时没有查到你的课时包信息,建议联系前台老师确认一下。

而不是暴露数据库错误、接口栈信息或内部字段。

幂等和重试:工具执行的工程细节

只读工具重复执行通常问题不大,但写操作必须考虑幂等。

AI 工具调用链路里可能出现这些情况:

  • 模型一次返回多个 tool call。
  • 网络失败后业务层重试。
  • Agent 循环重复触发同一个工具。
  • 用户刷新页面后再次提交同一句话。

如果 create_training_booking 没有幂等控制,就可能重复预约。

比较稳的做法是给写操作传入幂等键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const createTrainingBooking = tool(
async ({ memberId, courseId, startTime, idempotencyKey }) => {
return bookingService.createBooking({
memberId,
courseId,
startTime,
idempotencyKey,
});
},
{
name: "create_training_booking",
description: "创建课程预约。调用前必须获得用户明确确认。",
schema: z.object({
memberId: z.string(),
courseId: z.string(),
startTime: z.string().datetime(),
idempotencyKey: z.string().describe("服务端生成的幂等键"),
}),
},
);

不过 idempotencyKey 也不应该让模型自由生成。更安全的是业务服务在执行工具前注入:

1
2
3
4
5
const safeArgs = {
...validatedArgs,
memberId: ctx.currentMemberId,
idempotencyKey: ctx.requestId,
};

写操作工具要记住一句话:模型可以参与决策,但状态变更必须可追踪、可回滚、可审计。

和普通后端接口有什么区别

Function Calling 里的工具,本质上仍然是后端能力。它可以包装数据库查询、HTTP API、RPC 服务,也可以调用已有业务 service。

区别在于调用方变了。

普通接口的调用方是前端代码:

1
2
3
4
5
按钮点击

前端调用 POST /bookings

后端创建预约

Tool Calling 的调用方是模型生成的意图:

1
2
3
4
5
6
7
用户自然语言

模型判断需要创建预约

模型生成 create_training_booking 参数

后端校验并执行工具

这会带来两个变化。

第一,入口更自然。用户不需要点很多表单,也可以用一句话触发流程。

第二,不确定性更高。前端表单字段通常是固定的,而自然语言会有歧义,所以工具执行前必须做更强的校验、确认和兜底。

一个更完整的调用流程

可以把课程助手的 Tool Calling 链路整理成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
用户:我想约明天下午的 L2 课

模型:需要先查询可预约课程

tool_call: search_available_courses

业务系统:校验用户身份,查询课程列表

工具结果:明天下午有 14:00 和 16:00 两节 L2 课

模型:询问用户选择哪个时间

用户:16:00 那节

模型:生成创建预约 tool_call

业务系统:校验课程可预约、剩余课时、是否冲突、幂等键

tool_call: create_training_booking

工具结果:预约成功

模型:回复用户预约结果

这个流程里,模型并不是“全权代理人”,更像是一个会理解自然语言的流程协调者。

真正决定能不能预约的,仍然是业务规则:

  • 会员是否登录。
  • 是否有剩余课时。
  • 课程是否开放预约。
  • 时间是否冲突。
  • 是否需要家长确认。
  • 是否超过取消或预约时间限制。

我对 Function Calling 的理解

学习 Function Calling 后,我对 AI 应用的理解从“让模型回答问题”往前走了一步。

它真正有价值的地方不是让模型显得更聪明,而是把模型接进业务系统,让自然语言可以触发真实的数据查询和业务流程。

但也正因为它能触发真实业务,工程上不能只关注“模型会不会调用工具”,还要关注:

  • 工具是否按场景收敛,而不是无限暴露。
  • schema 是否表达清楚字段含义和约束。
  • 关键参数是否由服务端注入。
  • 工具执行前是否做权限和业务规则校验。
  • 写操作是否有确认、幂等和审计。
  • 工具结果是否结构化、可解释、可排查。

我现在更愿意把 Tool Calling 看成一个后端接口设计问题,而不是单纯的 AI 调参问题。模型负责理解用户意图和选择工具,业务系统负责边界、执行和责任。

这条边界画清楚了,Function Calling 才能从一个 demo 变成真正可靠的业务能力。

参考资料