前言

前面几篇我们把大模型接进了前端:聊天、流式输出、RAG 知识库,一路走来模型越来越”博学”。但用着用着你会发现,它始终停留在”动嘴”层面——用户说”帮我把页面切成深色模式”,模型只能回复”您可以点击右上角的设置按钮……”。它不能做事,因为它没有手。这篇要讲的 Function Calling(函数调用)就是给模型装上手的过程:模型负责理解意图、决定调什么工具、给出参数,而执行权始终在我们前端代码手里。这是从聊天机器人迈向 AI 应用、乃至 Agent 的关键一步。

1. 从”会说话”到”会做事”

回顾一下我们已有的能力边界:

  • 普通对话:模型只能生成文本,建议用户”自己去做某事”;
  • RAG:模型能基于检索资料回答,但依然只输出文本;
  • Function Calling:模型可以输出一个结构化的”我要调用某工具 + 参数”,由我们的代码执行后把结果喂回去,它再基于真实结果继续对话。

一个直观的对比:用户问”北京今天适合跑步吗”——普通模式下模型会说”建议查看天气预报”(或者干脆编一个天气);Function Calling 模式下,模型会返回”请调用 getWeather 工具,参数 city=北京”,我们执行真实的天气查询,把结果交回模型,它基于真实数据回答”今天 18-26 度,多云,湿度适中,很适合跑步”。同一个模型,接入工具前后,能力是物种级的差异。

这里要先破除一个常见误解:模型并不”执行”任何函数。它只是通过训练学会了在合适的时机输出一段结构化 JSON,表达”我想调用 XX 函数,参数是 XX”。真正按下执行按钮的,永远是我们的代码。这个设计保证了安全性——模型永远只是”建议者”。

2. Function Calling 的工作原理

Function Calling 的调用循环

一次完整的工具调用,时序是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
用户                前端/后端应用                LLM
│ │ │
│── 提问 ──────────────▶│ │
│ │── messages + tools ──▶│
│ │ │ (模型决定需要调用工具)
│ │◀─ tool_calls(JSON) ───│
│ │ │
│ │ 校验参数 → 执行本地函数 │
│ │ 计算结果 │
│ │ │
│ │── tool 结果回填 ──────▶│
│ │ │ (模型基于结果组织自然语言)
│◀── 最终回答 ──────────│◀─ 最终 content ───────│

几个关键点:

  1. 请求时除了 messages,还要带上 tools 数组——每个工具的名字、描述、参数 JSON Schema;
  2. 模型返回的 finish_reason 是 tool_calls 而不是 stop,message.tool_calls 里带着函数名和参数字符串;
  3. 我们执行完后,把结果作为 role: 'tool' 的消息追加到 messages 末尾,再次请求;
  4. 模型可能连续调用多个工具(比如先查天气再查空气),所以要写一个循环而不是单次 if。

3. 实战:一个能查天气、画图表、换主题的智能助手

我们来做一个跑在前端页面里的助手,它有三个工具:查天气(模拟 API)、画图表(渲染 ECharts)、切换页面主题。麻雀虽小,五脏俱全。

3.1 定义 tools schema

Schema 的质量直接决定模型调用得准不准。description 不是装饰,是模型的”工具说明书”,要写清楚什么时候该用这个工具、参数什么含义:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const tools = [
{
type: 'function',
function: {
name: 'getWeather',
description: '查询指定城市的实时天气。当用户询问天气、温度、是否适合外出时调用。',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名称,如:北京' },
unit: { type: 'string', enum: ['celsius', 'fahrenheit'], description: '温度单位,默认 celsius' },
},
required: ['city'],
},
},
},
{
type: 'function',
function: {
name: 'renderChart',
description: '在页面上渲染一张柱状图或折线图。当用户要求可视化数据、画图、看趋势时调用。',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: '图表标题' },
type: { type: 'string', enum: ['bar', 'line'], description: '图表类型' },
labels: { type: 'array', items: { type: 'string' }, description: 'X 轴标签数组' },
values: { type: 'array', items: { type: 'number' }, description: '与 labels 对应的数值数组' },
},
required: ['title', 'type', 'labels', 'values'],
},
},
},
{
type: 'function',
function: {
name: 'setTheme',
description: '切换页面主题。当用户要求换成深色/浅色模式时调用。',
parameters: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['light', 'dark'], description: '目标主题' },
},
required: ['mode'],
},
},
},
];

3.2 工具执行器与处理循环

先在本地实现真正的执行逻辑,注意每个函数返回结构化数据而不是漂亮话——这部分是给模型看的,不是给用户看的:

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
const toolExecutor = {
async getWeather({ city, unit = 'celsius' }) {
// 实际项目中这里调真实天气 API
const mockData = { city: '北京', temp: 22, unit, condition: '多云', humidity: 45, aqi: 58 };
return mockData;
},

async renderChart({ title, type, labels, values }) {
const dom = document.getElementById('chart');
// 同一个 DOM 重复 init 会触发 ECharts 警告,优先复用已有实例
const chart = echarts.getInstanceByDom(dom) || echarts.init(dom);
chart.setOption(
{
title: { text: title },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [{ type, data: values }],
},
true // notMerge:避免上一次图表的配置残留
);
return { success: true, message: `图表《${title}》已渲染到页面` };
},

async setTheme({ mode }) {
document.documentElement.dataset.theme = mode;
return { success: true, message: `已切换到${mode === 'dark' ? '深色' : '浅色'}模式` };
},
};

然后是核心的处理循环,逻辑不复杂,但每一步都有讲究:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
const messages = [];

async function chat(userInput) {
messages.push({ role: 'user', content: userInput });

// 循环:模型可能连续发起多次工具调用
for (let round = 0; round < 5; round++) {
const res = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages,
tools,
});
const msg = res.choices[0].message;
messages.push(msg);

// 没有 tool_calls 说明模型已给出最终回答
if (!msg.tool_calls?.length) {
return msg.content;
}

// 执行模型请求的每一个工具
for (const call of msg.tool_calls) {
const fnName = call.function.name;
let result;
try {
// arguments 是字符串,小模型偶尔会吐不合法 JSON,parse 和执行一起兜底
const args = JSON.parse(call.function.arguments);
console.log(`[tool] ${fnName}(${JSON.stringify(args)})`);
result = await toolExecutor[fnName]?.(args) ?? { error: `未知工具: ${fnName}` };
} catch (e) {
result = { error: e.message }; // 把错误信息交给模型,它通常会向用户解释
}

// 关键:回填消息,tool_call_id 必须与请求一一对应
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result),
});
}
// 继续下一轮请求,让模型基于工具结果继续
}
return '任务过于复杂,已中断。';
}

实战里三个细节最容易被坑:

  • function.arguments 是 JSON 字符串,必须 JSON.parse,而且偶尔模型会生成不合法 JSON(尤其小模型),parse 一定要包 try-catch;
  • tool_call_id 必须严格对应,回填消息时对不上号,部分模型会直接报 400;
  • 循环上限。不加轮数上限的话,模型可能陷入”调用 - 结果 - 再调用”的死循环,token 哗哗烧。

如果你和我一样在用流式接口(stream: true),还有第四个坑:tool_calls 是分片到达的。增量 chunk 里的 delta.tool_calls 只有函数名的碎片和 arguments 的零散字符,必须自己按 index 拼接完整后才能执行:

1
2
3
4
5
6
7
8
9
10
11
// 流式场景:把 tool_calls 分片按 index 拼成完整对象
const toolBuffers = {};
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta?.tool_calls) continue;
for (const tc of delta.tool_calls) {
toolBuffers[tc.index] ??= { name: '', args: '' };
if (tc.function?.name) toolBuffers[tc.index].name += tc.function.name;
if (tc.function?.arguments) toolBuffers[tc.index].args += tc.function.arguments;
}
}

所以我的建议是:可能触发工具调用的请求直接关掉 stream(这个阶段用户本来就在看”正在执行…”步骤条),只在最终回答阶段开流式——拼接逻辑省了,打字机效果也不受影响。

3.3 让 AI 操控 UI:路由跳转与表单填充

工具的想象空间远不止”查数据”。把前端 Router、表单 API 封装成工具,AI 就能真正”操作”你的应用:

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
32
33
// 注册到 tools 数组
{
type: 'function',
function: {
name: 'navigate',
description: '页面内路由跳转。当用户说"打开设置页""带我去文章列表"时调用。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '站内路径,如 /settings、/posts' },
},
required: ['path'],
},
},
},
{
type: 'function',
function: {
name: 'fillForm',
description: '填充指定表单字段。当用户提供信息并要求帮忙填写时调用。',
parameters: {
type: 'object',
properties: {
fields: {
type: 'object',
description: '字段名到值的映射,如 { "title": "xxx", "tags": ["ai"] }',
additionalProperties: true,
},
},
required: ['fields'],
},
},
},

对应的执行器就是几行我们写了几年的老代码:

1
2
3
4
5
6
7
8
9
10
11
// 执行器补充
async navigate({ path }) {
const allowed = ['/posts', '/settings', '/about']; // 白名单,见下文安全边界
if (!allowed.includes(path)) return { error: `不允许跳转到 ${path}` };
router.push(path);
return { success: true, message: `已跳转到 ${path}` };
},
async fillForm({ fields }) {
formStore.patch(fields); // 配合响应式状态管理,表单自动联动
return { success: true, message: `已填充 ${Object.keys(fields).length} 个字段` };
},

这种”对话即操作”的体验一旦做出来就回不去了:新用户不再需要摸索导航菜单,直接说人话就行。

4. 安全边界与参数校验

模型输出的参数是”尽力而为”的——它会理解错、会拼错、偶尔会”越权”。任何工具执行前必须校验参数,这是红线。我现在的标配是 zod:

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
import { z } from 'zod';

const schemas = {
navigate: z.object({
path: z.string().refine((p) => p.startsWith('/') && !p.includes('..'), {
message: '仅允许站内路径',
}),
}),
setTheme: z.object({
mode: z.enum(['light', 'dark']),
}),
renderChart: z.object({
title: z.string().max(50),
type: z.enum(['bar', 'line']),
labels: z.array(z.string()).max(100),
values: z.array(z.number().finite()).max(100),
}),
fillForm: z.object({
fields: z.record(z.unknown()),
}),
};

function validateArgs(fnName, args) {
const schema = schemas[fnName];
if (!schema) return { ok: false, error: '未注册的工具' };
const parsed = schema.safeParse(args);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues.map((i) => i.message).join('; ') };
}
return { ok: true, data: parsed.data };
}

除了参数校验,几条安全原则请刻进 DNA:

  • 最小权限:AI 能调用的工具集合单独维护白名单,绝不能把 eval、任意 HTTP 请求、文件读写这类能力暴露给它;
  • 写操作加确认:跳转、填表单这类可撤销操作可以自动执行,但删除、支付、发送这类动作,务必在 UI 上弹确认框(下篇会展开这个设计);
  • 把错误还给模型:校验失败不要静默失败,把错误信息作为 tool 结果回填,模型通常会自己修正参数重试。

5. Function Calling 与 Agent:一步之遥

回头看我们的实现:模型提议 → 代码校验执行 → 结果回填 → 模型再决策。如果把”循环”做得更健壮——支持多步规划、任务拆解、失败重试、记忆管理——它就成了 Agent 的雏形。

Function Calling 是”单次工具调用”,Agent 是”自主的多步任务循环”:给模型一个目标,它自己决定调用哪个工具、调多少次、何时结束。我们的 chat() 函数其实已经是个最简单的 Agent 循环了,只是上限 5 轮、没有规划能力。从”工具箱”到”员工”,中间隔的是上下文管理、错误恢复和权限设计这几块硬骨头。

这就是下一篇的主题:当 AI Agent 时代到来,前端 UI 该怎么设计才能承接”长任务、不确定、可干预”的新交互形态。流式界面、步骤可视化、Generative UI,我们下篇见。

写在最后

Function Calling 是我 2024 年做 AI 应用感触最深的一个机制:它把模型的”语言智能”和应用的”真实能力”用一个极简的协议缝合在一起,而且把执行权、否决权牢牢留在开发者手里。这个设计的优雅之处在于——模型再强,也只是一个提建议的顾问;拍板和动手的,始终是我们的代码。

给想做尝试的同学一个起步建议:从你自己应用里挑 2~3 个高频操作(搜索、跳转、主题切换)封装成工具,一两天就能搭出可用的 Demo。当你第一次看到用户对着输入框说”帮我把这周的访问量画成图”,然后图表真的出现在页面上——那一刻的成就感,比调通十个组件都强。