前言
面对大模型应用开发,许多前端同学容易陷入两个极端:要么沉迷于调 API 做简单的流式打字机,止步于"套壳聊天";要么直接搬来 LangChain、Vercel AI SDK 等重型框架,面对成百上千行的抽象概念不知所措。
真正的 AI 智能体(Agent),并不是魔法,它的本质是一个带有工具调度能力的有限状态机 。
在 W2 中,我们亲手打造了生产级的 Web Streams 流式解析引擎;而在 W3 中,我们将兑现在 W2 底层预埋的接口约定,以"极小侵入、增量演进"的工程化设计,将纯聊天的流式引擎升级为一个能查实时网络、能执行本地数学运算、具备错误自省重试能力的 AI Agent!
为什么需要 Function Calling ?
1. 纯文本大模型的局限性
在 W1 和 W2 中,我们构建的应用本质上是"文本进、文本出"。即便模型拥有数千亿参数,受限于其纯文本概率预测机制,它依旧存在三大不可逆缺陷:
- 时效盲区:模型的权重在预训练完成后即被冻结,它不知道今天纳斯达克的点位、今天的气温,甚至不知道当前现实世界的精确时间。
- 计算黑洞 :大模型本质是基于 Token 的统计推断,不擅长精密计算。面对
8794351234.12 * 9642875678.32这样的数学乘除,极易产生"看似合理但实际错误"的幻觉。 - 隔离孤岛:大模型生活在独立的沙箱里,它无法读写你的私有数据库、不能发送企业飞书告警,更不能代替用户调用第三方 RESTful API。
2. Function Calling 的底层本质
许多人误以为 Function Calling 是大模型直接在服务端的沙箱里把 JavaScript/Python 代码给执行了。这是极其严重的认知误区。大模型永远不执行任何代码,它只负责"做决策"并输出"结构化的行动指令"。其真实工作链路如下:
-
你告诉模型可调用的工具清单:给它一份标准 JSON Schema,描述工具有什么用、接受什么参数。
-
模型根据用户意图判断是否需要工具:
- 如果用户说"讲个笑话",模型认为不需要工具,直接输出文本内容。
- 如果用户说"查下深圳天气",模型会发现自然语言无法准确回答,于是停止输出普通文字,转而输出结构化的工具调用请求(包含工具名
get_weather和提取出来的参数{"city": "深圳"})。
-
系统执行代码并回传结果 :宿主程序(客户端或者后端代码)捕获到这个指令,真正发起网络请求或执行本地函数,拿到返回值后作为
role: "tool"消息送回大模型。 -
模型总结归纳输出最终答案:模型结合工具执行的结果,组织成通顺的自然语言反馈给用户。
Function Calling 协议与流式分片结构
1. 请求参数:tools 与 tool_choice
在调用 OpenAI 兼容接口时,我们通过请求体中的 tools 参数向大模型声明能力:
json
{
"model": "gpt-6-soul",
"messages": [
{ "role": "user", "content": "帮我查一下北京的天气" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取指定城市的实时天气数据(气温、湿度、风力)",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名称,例如北京" },
"unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
},
"required": ["city"],
"additionalProperties": false
}
}
}
],
"tool_choice": "auto"
}
-
parameters:遵循 JSON Schema 规范定义入参结构。 -
tool_choice的 4 种模式:"auto"(默认):由大模型根据上下文自主决定是直接说话还是调用工具。"none":强制模型不得调用工具,只返回纯文本。"required":强制模型必须在 tools 列表中挑选至少一个工具进行调用。{"type": "function", "function": {"name": "get_weather"}}:强制模型只能调用指定的特定工具。
2. 响应结构与 finish_reason: "tool_calls"
如果大模型决定调用工具,在非流式场景下,它的响应结构如下:
json
{
"choices": [
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_98xAbc01",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"北京\"}"
}
}
]
}
}
]
}
这里有两个细节需要注意:
- 当触发工具调用时,
content字段通常为null或空字符串。 function.arguments是一个 JSON 格式的字符串,而不是序列化后的对象 。这是因为生成过程是 Token 逐字吐出的,使用时必须进行JSON.parse。
3. 上下文闭环
当工具在本地执行完成后,必须按照 OpenAI 规定的消息格式送回上下文:
json
[
{ "role": "user", "content": "北京天气如何?" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" }
}
]
},
{
"role": "tool",
"tool_call_id": "call_1",
"name": "get_weather",
"content": "{\"temperature\":22,\"condition\":\"晴\"}"
}
]
4. 流式场景的挑战
但在流式传输下,协议变得格外复杂。大模型是一边思考一边逐字吐出参数的,而且可能并行调用多个工具。一个典型的流式工具调用序列如下:
http
Chunk 1: choices[0].delta = { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_weather", arguments: "" } }] }
Chunk 2: choices[0].delta = { tool_calls: [{ index: 0, function: { arguments: "{\"ci" } }] }
Chunk 3: choices[0].delta = { tool_calls: [{ index: 1, id: "call_2", type: "function", function: { name: "calculate", arguments: "" } }] }
Chunk 4: choices[0].delta = { tool_calls: [{ index: 0, function: { arguments: "ty\": \"北京\"}" } }] }
Chunk 5: choices[0].delta = { tool_calls: [{ index: 1, function: { arguments: "{\"expr\": \"2+3\"}" } }] }
Chunk 6: choices[0].finish_reason = "tool_calls"
工具 0 和工具 1 的参数碎片可能穿插到达,只能通过 index 字段作为分组标识。并且 id 和函数名 name 通常仅在对应 index 的第一帧提供,后续帧只包含 arguments 碎片。在流尚未结束前,局部 arguments 片段(如 {"ci)根本不是合法的 JSON,任何提早的 JSON.parse 都会瞬间引发程序崩溃。
项目实战
W3 的项目仅在 W2 基础上新增了 zod(运行时类型验证)与 zod-to-json-schema(自动生成 OpenAI 兼容的参数定义)。无需重新创建新项目,将 W2 的项目复制一份即可。以下主要说明核心修改,读者需要自行拉取源码进行阅读理解。
1. 聚合算法实现
为了攻破上述流式分片交错的问题,我们在 W3 中设计了专门的组件:ToolCallAccumulator。
在编码前,我们在 lib/agent/types.ts 中定义完整的类型:
ts
// lib/agent/types.ts
/**
* 完整组装后的工具调用(从流式分片聚合拼装完成)
*/
export interface AssembledToolCall {
id: string; // 工具调用唯一 ID(如 call_abc123)
name: string; // 目标工具函数名(如 get_weather)
arguments: string; // 完整拼装后的原始 JSON 字符串,等待后续反序列化
}
为了解决并行工具交错的问题,我们选用 Map<number, AssembledToolCall>,以 index 为 Key 进行线性分组。请查看源码 lib/agent/tool-call-accumulator.ts:
ts
// lib/agent/tool-call-accumulator.ts
import { ToolCallDelta } from '@/lib/stream-parser';
import { AssembledToolCall } from './types';
/**
* 流式工具调用分片聚合器
* 核心职责:处理并聚合多路并发的流式 tool_call 分片,按 index 进行字符重组
*/
export class ToolCallAccumulator {
// 使用 Map 以流式 index 为键进行线性分组
private toolCalls: Map<number, AssembledToolCall> = new Map();
/**
* 接收来自流式消费者的每一个增量分片
* @param delta 流式工具分片数据
*/
addDelta(delta: ToolCallDelta): void {
const existing = this.toolCalls.get(delta.index);
if (existing) {
// 若当前 index 已初始化,只需持续拼接后续追加的 arguments 参数字符串
if (delta.arguments) {
existing.arguments += delta.arguments;
}
} else {
// 首次出现当前 index:捕获首帧特有的 id 与 name
this.toolCalls.set(delta.index, {
id: delta.id || '',
name: delta.name || '',
arguments: delta.arguments || '',
});
}
}
/**
* 流完全结束后调用:按 index 正序输出组装完毕的完整工具调用清单
*/
getAssembled(): AssembledToolCall[] {
return Array.from(this.toolCalls.entries())
.sort(([indexA], [indexB]) => indexA - indexB)
.map(([, toolCall]) => toolCall);
}
/**
* 判定当前轮次是否触发了工具调用
*/
hasToolCalls(): boolean {
return this.toolCalls.size > 0;
}
/**
* 清空内部缓冲区,保障下一轮 Agent 交互干净纯粹
*/
reset(): void {
this.toolCalls.clear();// lib/agent/tool-registry.ts
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ToolDefinition } from './types';
/**
* 类型安全的工具注册中心
*/
export class ToolRegistry {
private tools: Map<string, ToolDefinition<any, any>> = new Map();
/**
* 注册工具
* @param tool 工具定义对象
*/
register<TParams, TResult>(tool: ToolDefinition<TParams, TResult>): void {
this.tools.set(tool.name, tool);
}
/**
* 按名称获取工具定义
*/
get(name: string): ToolDefinition<any, any> | undefined {
return this.tools.get(name);
}
/**
* 导出 OpenAI 标准的 tools 参数数组
*
* 为什么要有 additionalProperties: false?
* OpenAI Strict 模式要求每个 object 必须显式声明 additionalProperties: false,
* 这样可以从数学上约束大模型严格按照定义的字段生成,杜绝生成不存在的字段。
*/
getOpenAITools(): any[] {
return Array.from(this.tools.values()).map((tool) => {
const jsonSchema = zodToJsonSchema(tool.schema as any, { target: 'jsonSchema7' });
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: {
...jsonSchema,
additionalProperties: false, // 严格模式规范
},
},
};
});
}
/**
* 执行工具调用(包含参数解析、Zod 验证与容错回传)
*/
async execute(name: string, argsString: string): Promise<string> {
const tool = this.tools.get(name);
if (!tool) {
// 1. 如果大模型输出了不存在的工具名,不抛异常崩溃,而是返回错误信息
return JSON.stringify({ error: `未找到工具: ${name}` });
}
try {
// 2. 解析 JSON 字符串入参
const argsObj = JSON.parse(argsString || '{}');
// 3. 使用 Zod 进行严格的运行时类型断言与校验
const parsedArgs = tool.schema.parse(argsObj);
// 4. 执行业务函数
const result = await tool.execute(parsedArgs);
return JSON.stringify(result);
} catch (error) {
// 核心理念:捕获一切异常,作为普通 JSON 数据回传给大模型触发自省纠错
const errorMessage = error instanceof Error ? error.message : String(error);
return JSON.stringify({ error: `工具执行失败: ${errorMessage}` });
}
}
}
}
}
2. 工具注册中心
封装了工具的注册、获取以及执行。查看完整源码 lib/agent/tool-registry.ts:
ts
// lib/agent/tool-registry.ts
import { zodToJsonSchema } from 'zod-to-json-schema';
import { ToolDefinition } from './types';
/**
* 类型安全的工具注册中心
*/
export class ToolRegistry {
private tools: Map<string, ToolDefinition<any, any>> = new Map();
/**
* 注册工具
* @param tool 工具定义对象
*/
register<TParams, TResult>(tool: ToolDefinition<TParams, TResult>): void {
this.tools.set(tool.name, tool);
}
/**
* 按名称获取工具定义
*/
get(name: string): ToolDefinition<any, any> | undefined {
return this.tools.get(name);
}
/**
* 导出 OpenAI 标准的 tools 参数数组
*
* 为什么要有 additionalProperties: false?
* OpenAI Strict 模式要求每个 object 必须显式声明 additionalProperties: false,
* 这样可以从数学上约束大模型严格按照定义的字段生成,杜绝生成不存在的字段。
*/
getOpenAITools(): any[] {
return Array.from(this.tools.values()).map((tool) => {
const jsonSchema = zodToJsonSchema(tool.schema as any, { target: 'jsonSchema7' });
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: {
...jsonSchema,
additionalProperties: false, // 严格模式规范
},
},
};
});
}
/**
* 执行工具调用(包含参数解析、Zod 验证与容错回传)
*/
async execute(name: string, argsString: string): Promise<string> {
const tool = this.tools.get(name);
if (!tool) {
// 1. 如果大模型输出了不存在的工具名,不抛异常崩溃,而是返回错误信息
return JSON.stringify({ error: `未找到工具: ${name}` });
}
try {
// 2. 解析 JSON 字符串入参
const argsObj = JSON.parse(argsString || '{}');
// 3. 使用 Zod 进行严格的运行时类型断言与校验
const parsedArgs = tool.schema.parse(argsObj);
// 4. 执行业务函数
const result = await tool.execute(parsedArgs);
return JSON.stringify(result);
} catch (error) {
// 核心理念:捕获一切异常,作为普通 JSON 数据回传给大模型触发自省纠错
const errorMessage = error instanceof Error ? error.message : String(error);
return JSON.stringify({ error: `工具执行失败: ${errorMessage}` });
}
}
}
在 Agent 调用大模型时,它可能会拼错工具名(把 get_weather 写成 weather_search),还可能会漏传必填参数,或者把数字传成字符串。如果你直接 throw,会导致整个 HTTP 请求断开,前端异常,用户的会话当场中断。这里我们在 catch 中捕获了 JSON 解析的异常以及 Zod 的校验异常,将错误捕获并序列化为 {"error": "具体错误原因"} 作为常规结果回传给大模型。大模型在下一轮看到这个报错后,会触发自我反思(Reflection) ,自动修正参数并重试。
3. npm 查询工具
有了注册中心,现在我们来实现一个 npm 的查询工具。很多初学者写工具,调完外部 API 直接把整包原始数据塞给大模型:
ts
// 直接回传原始数据
const res = await fetch(`https://registry.npmjs.org/${pkg}`);
return await res.json(); // 假如包含了历史 500 个版本的所有信息,数据可能长达 2MB
这 2MB 数据会瞬间占用几十万 Token,不仅单次调用产生巨额费用,还会直接超出大模型的最大上下文窗口限制触发 400 报错。我们必须进行数据瘦身(Data Pruning) ,在 Node.js 端把 2MB 的巨型数据裁剪提炼为 300 字节的核心字段(最新版本、周下载量、直接依赖数、仓库地址),以最小的 Token 换取最精准的回答。
完整源码 lib/agent/tools/npm-search.ts:
ts
// lib/agent/tools/npm-search.ts
import { z } from 'zod';
import type { ToolDefinition } from '../types';
export const npmSearchParamsSchema = z.object({
packageName: z.string().describe('npm 包名,例如 "react"、"next"、"zod"'),
});
export type NpmSearchParams = z.infer<typeof npmSearchParamsSchema>;
export interface NpmSearchResult {
name?: string;
latestVersion?: string;
description?: string;
license?: string;
weeklyDownloads?: number;
dependencyCount?: number;
homepage?: string | null;
repository?: string | null;
keywords?: string[];
lastPublished?: string;
error?: string;
}
export const npmSearchTool: ToolDefinition<NpmSearchParams, NpmSearchResult> = {
name: 'search_npm_package',
description: '查询 npm 包的详细信息,包括最新版本、描述、周下载量、依赖数量、仓库地址等',
schema: npmSearchParamsSchema,
execute: async (params: NpmSearchParams): Promise<NpmSearchResult> => {
const { packageName } = params;
try {
// 1. 发起真实 HTTP 请求拉取基础元数据
const registryRes = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`);
if (!registryRes.ok) {
if (registryRes.status === 404) return { error: `未找到 npm 包: ${packageName}` };
return { error: `Registry 接口响应失败: ${registryRes.statusText}` };
}
const registryData = await registryRes.json();
const latestVersionString = registryData['dist-tags']?.latest;
if (!latestVersionString) {
return { error: `无法获取包 ${packageName} 的 latest 版本标签` };
}
const latestVersionData = registryData.versions?.[latestVersionString] || {};
// 2. 并行调用官方下载量 API 获取上一周统计
let weeklyDownloads = 0;
try {
const downloadRes = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`);
if (downloadRes.ok) {
const downloadData = await downloadRes.json();
weeklyDownloads = downloadData.downloads || 0;
}
} catch (err) {
console.warn(`获取包 ${packageName} 下载量降级`, err);
}
// 3. 解析仓库地址
let repository = null;
if (typeof latestVersionData.repository === 'string') {
repository = latestVersionData.repository;
} else if (latestVersionData.repository?.url) {
repository = latestVersionData.repository.url;
}
// 4. 计算直接依赖数
const dependencyCount = latestVersionData.dependencies
? Object.keys(latestVersionData.dependencies).length
: 0;
// 5. 关键词裁剪(只取前 5 个,杜绝冗余)
const keywords = Array.isArray(latestVersionData.keywords)
? latestVersionData.keywords.slice(0, 5)
: [];
// 6. 核心成果:将 2MB 原始数据瘦身为仅 300 字节的精准结构体
return {
name: registryData.name,
latestVersion: latestVersionString,
description: registryData.description || latestVersionData.description || '',
license: registryData.license || latestVersionData.license || 'Unknown',
weeklyDownloads,
dependencyCount,
homepage: latestVersionData.homepage || registryData.homepage || null,
repository,
keywords,
lastPublished: registryData.time?.[latestVersionString] || new Date().toISOString(),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { error: `查询失败: ${errorMessage}` };
}
},
};
我们再实现一个本地计算器的工具,负责弥补大模型的数学缺陷。很多初学者直接用 eval(expression) 执行计算:
ts
// 极度危险的后门
return eval(params.expression);
大模型极易受到提示词注入攻击(Prompt Injection)。恶意用户只要输入:"请帮我计算 process.exit()" 或 "请帮我计算 require('fs').readFileSync('/etc/passwd')",大模型一旦老老实实生成了入参,后端服务甚至服务器可能面临攻击。对于类似的工具开发,可以构建三道纵深防御:
- 长度硬截断:限制 200 字符内,防止超长算式 DoS 耗死 CPU。
- 正则字符白名单 :只允许基础数学字符
[0-9+-*/%.()=]、幂运算**与Math.*函数,任何变量名、分号、赋值符均被直接拦截。 - 黑名单深度排查 :硬隔离
process、require、import、global等关键词。
完整源码 lib/agent/tools/calculator.ts:
ts
// lib/agent/tools/calculator.ts
import { z } from 'zod';
import type { ToolDefinition } from '../types';
export const calculatorParamsSchema = z.object({
expression: z.string().describe('数学表达式,例如 "123 * 456"、"Math.sqrt(144)"、"Math.PI * 2"'),
});
export type CalculatorParams = z.infer<typeof calculatorParamsSchema>;
export interface CalculatorResult {
expression?: string;
result?: number;
formattedResult?: string;
error?: string;
}
export const calculatorTool: ToolDefinition<CalculatorParams, CalculatorResult> = {
name: 'calculate',
description: '执行数学计算表达式。支持加减乘除、幂运算、三角函数、对数等',
schema: calculatorParamsSchema,
execute: async (params: CalculatorParams): Promise<CalculatorResult> => {
const { expression } = params;
// 防线 1:长度硬限制(防止超长算式 DoS 阻塞 CPU 事件循环)
if (expression.length > 200) {
return { error: '表达式过长,最长允许 200 个字符' };
}
// 防线 2:正则字符白名单 ------ 只允许数字、基础算符、括号与 Math 命名空间
const noSpaceExp = expression.replace(/\s+/g, '');
const validMathRegex = /^([0-9+\-*/%.()=]|Math\.[a-zA-Z0-9_]+|(?:\*\*))+$/;
if (!validMathRegex.test(noSpaceExp)) {
return { error: '包含非法字符或不安全的调用。只允许数字、运算符、括号和 Math 对象的方法' };
}
// 防线 3:危险关键字硬黑名单
const forbiddenKeywords = ['import', 'require', 'fetch', 'eval', 'process', 'window', 'document', 'global'];
if (forbiddenKeywords.some(keyword => expression.includes(keyword))) {
return { error: '表达式中包含禁止的系统级关键词' };
}
try {
// 4. 在独立沙箱作用域中安全求值
const result = new Function('return ' + expression)();
if (typeof result !== 'number' || isNaN(result)) {
return { error: '表达式计算结果不是有效的数字' };
}
return {
expression,
result,
// 浮点数修剪:避免 0.1 + 0.2 = 0.30000000000000004 精度杂音
formattedResult: Number.isInteger(result) ? result.toString() : result.toFixed(4).replace(/\.?0+$/, ''),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { error: `计算出错: ${errorMessage}` };
}
},
};
上述两个工具的实现都不难,主要是学习如何处理上下文数据以及对用户输入进行防御等。
4. 通用 Agent Loop 状态机引擎
在很多 Agent 项目中,循环逻辑和 React 组件代码死死捆绑在一起。这样的话:根本无法为 Agent 核心状态机编写自动化单元测试 。必须启动浏览器、配置 DOM 甚至模拟点击才能测试。 我们需要提炼一个纯逻辑的 createAgentLoop 函数。通过依赖注入传入 callLLM,使其既能在服务端脚本、CLI 或其他非 React 场景中复用。
为了实现纯逻辑与模型调用的完全解耦,我们定义了两个核心类型:
-
CallLLMFunction:抽象的 LLM 调用函数签名。不论底层是调 OpenAI SDK、直连 fetch 还是单元测试里的 Mock 函数,只要满足"输入消息历史与工具列表,输出内容与工具调用"即可。 -
AgentLoopOptions:状态机配置对象。调用方正是通过它将messages、tools以及具体的callLLM驱动函数注入到状态机中。
ts
// lib/agent/types.ts
/**
* 抽象的 LLM 函数签名:定义 callLLM 函数接收什么、返回什么
*/
export type CallLLMFunction = (
messages: AgentMessage[],
tools: any[]
) => Promise<{
content: string | null;
tool_calls: AssembledToolCall[] | null;
finish_reason: string;
}>;
/**
* Agent 循环启动配置:这里的 callLLM 属性就是上面定义的函数类型
*/
export interface AgentLoopOptions {
messages: AgentMessage[]; // 当前对话历史
tools: ToolDefinition<any, any>[]; // 可用工具列表
callLLM: CallLLMFunction; // 依赖注入的核心:大模型调用驱动函数
model?: string; // 模型名称
maxSteps?: number; // 最大循环轮次防御
signal?: AbortSignal; // 中断信号
onToolCallStart?: (name: string, args: Record<string, unknown>) => void;
onToolCallEnd?: (name: string, result: unknown) => void;
onStepComplete?: (messages: AgentMessage[]) => void;
}
状态机只专注做两件事:
- 模型决定调工具时 :从响应中提取
tool_calls,用Promise.all并发执行工具 -> 结果封装为role: 'tool'消息回传 -> 循环继续。 - 模型直接回答时 :
tool_calls为空,返回最终文本 -> 循环正常结束。
我们来看一下状态机源码逐行拆解(请查看完整源码 lib/agent/agent-loop.ts):
ts
// lib/agent/agent-loop.ts
import { AgentLoopOptions, AgentLoopResult, AgentMessage, AssembledToolCall } from './types';
import { ToolRegistry } from './tool-registry';
/**
* Agent 执行循环引擎,封装核心状态机逻辑、并行工具调度与多轮自省
*/
export async function createAgentLoop(options: AgentLoopOptions): Promise<AgentLoopResult> {
const {
messages: initialMessages,
tools,
callLLM,
maxSteps = 5,
signal,
onToolCallStart,
onToolCallEnd,
onStepComplete,
} = options;
const messages = [...initialMessages];
let stepCount = 0;
// 1. 初始化工具注册中心并导出 Schema
const registry = new ToolRegistry();
tools.forEach((tool) => registry.register(tool));
const openAiTools = registry.getOpenAITools();
// 2. 状态机驱动循环
while (stepCount < maxSteps) {
if (signal?.aborted) {
throw new Error('Agent loop aborted');
}
stepCount++;
// a. 触发模型推理
const response = await callLLM(messages, openAiTools);
// b. 判定模型意图
if (response.tool_calls && response.tool_calls.length > 0) {
// 意图:需要调用工具
// 将 assistant 的调用意图存入上下文
const assistantMessage: AgentMessage = {
role: 'assistant',
content: response.content,
tool_calls: response.tool_calls.map((tc: AssembledToolCall) => ({
id: tc.id,
type: 'function',
function: {
name: tc.name,
arguments: tc.arguments,
},
})),
};
messages.push(assistantMessage);
// 并行执行本轮所有工具(利用 Promise.all 享受 I/O 并发红利)
const toolPromises = response.tool_calls.map(async (toolCall: AssembledToolCall) => {
let argsObj: Record<string, unknown> = {};
try {
argsObj = JSON.parse(toolCall.arguments || '{}');
} catch {
// JSON 解析错误会由 registry.execute 内部优雅处理
}
onToolCallStart?.(toolCall.name, argsObj);
// 执行工具(异常不会抛出,会被安全包装为 JSON 字符串)
const resultStr = await registry.execute(toolCall.name, toolCall.arguments);
try {
onToolCallEnd?.(toolCall.name, JSON.parse(resultStr));
} catch {
onToolCallEnd?.(toolCall.name, resultStr);
}
// 将工具执行结果构造为标准 role: 'tool' 消息
const toolMessage: AgentMessage = {
role: 'tool',
tool_call_id: toolCall.id,
name: toolCall.name,
content: resultStr,
};
return toolMessage;
});
const toolResults = await Promise.all(toolPromises);
messages.push(...toolResults);
onStepComplete?.([...messages]);
// 循环继续,自动进入下一轮 while
} else {
// 意图:无需工具,输出最终自然语言回答,顺利结束循环!
messages.push({
role: 'assistant',
content: response.content,
});
onStepComplete?.([...messages]);
return {
messages,
finalContent: response.content || '',
totalSteps: stepCount,
};
}
}
// 防御死循环:当模型连续反复调用工具达到上限时主动截断
throw new Error(`Agent loop exceeded max steps (${maxSteps})`);
}
5. 服务端改造
在全栈设计中,有些同学喜欢把工具执行也塞进 /api/chat 里面,这样写看似代码少了一个文件,其实会存在一些设计上的问题:
-
破坏了流式纯粹性 :
/api/chat是一个高吞吐的 SSE 流式透传代理。如果在该路由内部执行耗时的工具(比如发网络爬虫或查数据库),整个 SSE 连接就会处于假死挂起状态,极难调试。 -
职责混乱:流式代理关心的是 Token 传输与双向 Abort;工具执行网关关心的是权限、入参并发与微服务调用。两者职责截然不同。
因此我们清晰地拆分为:
/api/chat:负责将前端请求安全转给大模型供应商的接口,并将 SSE 响应实时流回前端。/api/tools:负责接收前端聚合好的工具清单,在服务端环境中批量并发执行。
首先我们先修改 app/api/chat/route.ts 文件,对我们的流式代理进行升级以支持把注册中心注册的工具传给大模型。由于我们在 W2 的项目上增量演进,修改的代码实际只有几行。
ts
// app/api/chat/route.ts
import { NextRequest } from 'next/server';
import { ToolRegistry } from '@/lib/agent/tool-registry';
import { weatherTool } from '@/lib/agent/tools/weather';
import { npmSearchTool } from '@/lib/agent/tools/npm-search';
import { calculatorTool } from '@/lib/agent/tools/calculator';
export const runtime = 'nodejs'; // 保证原生 Node.js 流式性能
export const dynamic = 'force-dynamic';
/** W3 新增:初始化工具注册中心(服务端单例) */
const registry = new ToolRegistry();
registry.register(weatherTool);
registry.register(npmSearchTool);
registry.register(calculatorTool);
export async function POST(req: NextRequest) {
const { messages, enableTools } = await req.json();
const apiKey = process.env.OPENAI_API_KEY;
const baseUrl = (process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/+$/, '');
const model = process.env.OPENAI_MODEL || 'gpt-4o-mini';
if (!apiKey) {
return new Response(
JSON.stringify({ error: '服务端未配置 OPENAI_API_KEY,请在 .env.local 中配置' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// W2 原生请求体
const upstreamBody: Record<string, unknown> = {
model,
messages,
stream: true,
};
// ========== W3 增量核心代码 ==========
if (enableTools) {
upstreamBody.tools = registry.getOpenAITools();
upstreamBody.tool_choice = 'auto';
}
// ===================================
const upstreamRes = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(upstreamBody),
signal: req.signal, // W2 双向 Abort 级联联动
});
if (!upstreamRes.ok || !upstreamRes.body) {
const errText = await upstreamRes.text().catch(() => '');
return new Response(
JSON.stringify({ error: `上游模型接口错误 (${upstreamRes.status}): ${errText}` }),
{ status: upstreamRes.status, headers: { 'Content-Type': 'application/json' } }
);
}
// W2 原生透传响应:配置 X-Accel-Buffering 杜绝 Nginx 缓存
return new Response(upstreamRes.body, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
下面我们再实现工具执行端点,完整源码 app/api/tools/route.ts:
ts
// app/api/tools/route.ts
import { NextRequest } from 'next/server';
import { ToolRegistry } from '@/lib/agent/tool-registry';
import { weatherTool } from '@/lib/agent/tools/weather';
import { npmSearchTool } from '@/lib/agent/tools/npm-search';
import { calculatorTool } from '@/lib/agent/tools/calculator';
export const runtime = 'nodejs';
/** 复用工具注册中心 */
const registry = new ToolRegistry();
registry.register(weatherTool);
registry.register(npmSearchTool);
registry.register(calculatorTool);
interface ToolCallRequest {
id: string;
name: string;
arguments: string;
}
export async function POST(req: NextRequest) {
let body: { calls?: ToolCallRequest[] };
try {
body = await req.json();
} catch {
return new Response(
JSON.stringify({ error: '请求体 JSON 解析失败' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const calls = body.calls ?? [];
if (calls.length === 0) {
return new Response(
JSON.stringify({ error: '未提供工具调用列表' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// 利用 Promise.all 并发执行本轮所有的工具调用
const results = await Promise.all(
calls.map(async (call) => {
const resultStr = await registry.execute(call.name, call.arguments);
return {
tool_call_id: call.id,
name: call.name,
content: resultStr,
};
})
);
return new Response(JSON.stringify({ results }), {
headers: { 'Content-Type': 'application/json' },
});
}
6. 客户端 Agent Loop 状态机
Agent Loop 在客户端驱动,为什么不在服务端驱动呢?如果把多轮循环放在服务端,服务端就会处于一个长时间的 while 阻塞中。
- 用户看着界面足足 6 秒没有任何反应(不知道是网络断了还是模型在干活)。比如 Agent 运行了一个耗时 20 秒的
mvn compile或npm run build,客户端驱动可以实时读取终端的stdout/stderr数据流,直接在本地界面逐行滚动展示构建日志,用户能清晰看到进度,绝不会产生"假死"焦虑。 - 一旦中间某个工具耗时超过网关超时限制,整个 HTTP 连接直接断裂报 504 Gateway Timeout;
- 用户中途想点击"停止"都无法细粒度掐断,客户端驱动的话,用户在界面上看到 Agent 改错了方向、或者终端卡死了,随时点击"停止"。客户端立刻中断本地进程,并掐断当前与大模型的 SSE 请求,一秒都不会多烧你的 Token。
所以我们选择客户端驱动 Agent Loop。每一轮都使用 W2 的 createStreamConsumer 独立消费流。一旦发现有工具调用,界面立刻修改状态为"正在执行",并调起工具网关;如果直接回答,立刻打字机实时渲染。
完整源码 app/page.tsx:
ts
// app/page.tsx
'use client';
import { useRef, useState } from 'react';
import { createStreamConsumer, type StreamConsumerHandle } from '@/lib/stream-parser';
import { ToolCallAccumulator } from '@/lib/agent/tool-call-accumulator';
// ==================== 类型契约定义 ====================
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
/** W3 新增:工具调用状态卡片列表 */
toolCalls?: ToolCallStatus[];
}
interface ToolCallStatus {
id: string;
name: string;
args: Record<string, unknown>;
status: 'executing' | 'done';
result?: unknown;
}
interface ApiMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | null;
tool_calls?: {
id: string;
type: 'function';
function: { name: string; arguments: string };
}[];
tool_call_id?: string;
name?: string;
}
let msgIdCounter = 0;
function genId(): string {
return `msg_${Date.now()}_${msgIdCounter++}`;
}
const MAX_AGENT_STEPS = 5;
// ==================== 页面主组件 ====================
export default function ChatPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [thinking, setThinking] = useState('');
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const consumerRef = useRef<StreamConsumerHandle | null>(null);
const abortRef = useRef<AbortController | null>(null);
/**
* 发送消息并驱动客户端 Agent 状态机循环
*/
async function handleSend() {
const text = input.trim();
if (!text || busy) return;
const userMsg: ChatMessage = { id: genId(), role: 'user', content: text };
setMessages((prev) => [...prev, userMsg]);
setInput('');
setThinking('');
setBusy(true);
const ac = new AbortController();
abortRef.current = ac;
// 维护发送给上游大模型的完整上下文序列
const apiMessages: ApiMessage[] = [
...messages.map((m) => ({ role: m.role, content: m.content }) as ApiMessage),
{ role: 'user', content: text },
];
let step = 0;
try {
// ========== 核心:Agent 状态机 while 循环 ==========
while (step < MAX_AGENT_STEPS) { // MAX_AGENT_STEPS = 5 是防止大模型陷入思维死胡同、无休止调用工具烧光 Token 的安全界限
// 在每次进入新一轮循环前,先嗅探用户是否已经点击了"停止",若已中断则立刻跳出循环
if (ac.signal.aborted) break;
step++;
// 1. 本轮助手消息占位(异步任务更新数据利用唯一 ID 精确定位,仅前端使用)
const assistantMsgId = genId();
setMessages((prev) => [...prev, { id: assistantMsgId, role: 'assistant', content: '' }]);
// 2. 初始化本轮工具分片聚合器,因为上一轮的工具调用已经执行完毕,本轮必须从一个干净的空 Map 开始接收可能出现的新工具分片。
const accumulator = new ToolCallAccumulator();
let textContent = '';
// 3. 发起流式请求 (W2 路由 + enableTools 标识)
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: apiMessages, enableTools: true }),
signal: ac.signal,
});
if (!res.ok || !res.body) {
let errorMsg = `网络请求异常 (${res.status})`;
try {
const errData = await res.json();
if (errData?.error) errorMsg = errData.error;
} catch {}
throw new Error(errorMsg);
}
// 4. 【核心接入】使用 W2 的 createStreamConsumer 消费流
const consumer = createStreamConsumer(res.body, {
provider: 'openai',
batchStrategy: 'raf', // W2 的 60fps 平滑渲染
// 文本打字机
onToken: (tokenChunk) => {
textContent += tokenChunk;
setMessages((prev) => {
const copy = [...prev];
const idx = copy.findIndex((m) => m.id === assistantMsgId);
if (idx !== -1) {
copy[idx] = { ...copy[idx], content: copy[idx].content + tokenChunk };
}
return copy;
});
},
// DeepSeek R1 深度思维链展示
onReasoning: (reasoningChunk) => {
setThinking((prev) => prev + reasoningChunk);
},
// 兑现 W2 第 171 行预留接口:将分片喂入聚合器
onToolCall: (delta) => {
accumulator.addDelta(delta);
},
onError: (err) => {
console.error('流异常:', err);
},
});
consumerRef.current = consumer;
await consumer.done; // 阻塞等待这一轮流传输彻底完成
consumerRef.current = null;
// 5. 检查流结束时的决策状态。当前流彻底关闭后,状态机来到命运的十字路口:大模型刚才到底是说话了,还是想调工具?
if (accumulator.hasToolCalls()) {
const toolCalls = accumulator.getAssembled();
// 将拼装好的参数字符串反序列化为对象,卡片状态设为 'executing'(黄色正在执行中),立即更新界面,让用户看到 Agent 正试图调用的工具名及其入参,彻底告别黑屏焦虑。
const toolCallStatuses: ToolCallStatus[] = toolCalls.map((tc) => {
let args: Record<string, unknown> = {};
try { args = JSON.parse(tc.arguments || '{}'); } catch {}
return { id: tc.id, name: tc.name, args, status: 'executing' as const };
});
setMessages((prev) => {
const copy = [...prev];
const idx = copy.findIndex((m) => m.id === assistantMsgId);
if (idx !== -1) {
copy[idx] = { ...copy[idx], toolCalls: toolCallStatuses };
}
return copy;
});
// 委托服务端工具网关批量并发执行
const toolRes = await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calls: toolCalls }),
signal: ac.signal,
});
if (!toolRes.ok) throw new Error('工具执行网关异常');
const { results } = await toolRes.json();
// 将工具卡片的状态就地置为 'done'(变绿),并把执行得到的 JSON 数据填入卡片内部供用户点击展开查阅。
setMessages((prev) => {
const copy = [...prev];
const idx = copy.findIndex((m) => m.id === assistantMsgId);
if (idx !== -1 && copy[idx].toolCalls) {
copy[idx] = {
...copy[idx],
toolCalls: copy[idx].toolCalls!.map((tc, i) => ({
...tc,
status: 'done' as const,
result: (() => {
try { return JSON.parse(results[i].content); }
catch { return results[i].content; }
})(),
})),
};
}
return copy;
});
// 推入一条 role: 'assistant' 消息,告诉大模型:"你上一轮决定调了这些工具";
apiMessages.push({
role: 'assistant',
content: textContent || null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: tc.arguments },
})),
});
// 推入多条 role: 'tool' 消息,把真实的运行结果按 tool_call_id 匹配推进上下文;
for (const result of results) {
apiMessages.push({
role: 'tool',
content: result.content,
tool_call_id: result.tool_call_id,
name: result.name,
});
}
// 这一轮代码执行完毕,没有 break,自动回到第 106 行的 while 顶部,带着这些工具结果发起下一轮 /api/chat 请求,向大模型索取下一步指令。
continue;
} else {
// 模型输出了最终回答,未产生新的工具调用,成功完成任务!
break;
}
}
// 防御提示
if (step >= MAX_AGENT_STEPS) {
setMessages((prev) => {
const copy = [...prev];
const last = copy[copy.length - 1];
if (last?.role === 'assistant') {
copy[copy.length - 1] = {
...last,
content: last.content + '\n\n Agent 循环已达最大轮次限制',
};
}
return copy;
});
}
} catch (e) {
if (!(e instanceof DOMException && e.name === 'AbortError')) {
const errMsg = e instanceof Error ? e.message : '网络异常';
setMessages((prev) => {
const copy = [...prev];
const last = copy[copy.length - 1];
if (last?.role === 'assistant') {
copy[copy.length - 1] = {
...last,
content: last.content ? `${last.content}\n\n[异常: ${errMsg}]` : `[异常: ${errMsg}]`,
};
}
return copy;
});
}
} finally {
setBusy(false);
consumerRef.current = null;
abortRef.current = null;
}
}
function handleStop() {
consumerRef.current?.abort();
abortRef.current?.abort();
}
return (
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 20px', fontFamily: 'sans-serif' }}>
<h1>W3 · AI Agent 工具调用实战</h1>
<p style={{ color: '#666', fontSize: 14 }}>
基于 W2 生产级流式解析引擎 + 客户端状态机驱动。内置 3 个工具:🌤️ 天气查询(模拟)· 📦 npm 包查询(真实 API)· 🧮 计算器(本地安全沙箱)
</p>
{/* 思维链独立折叠面板 */}
{thinking && (
<details open style={{ background: '#f8fafc', padding: 12, borderRadius: 6, marginBottom: 16, border: '1px solid #e2e8f0' }}>
<summary style={{ cursor: 'pointer', color: '#64748b', fontSize: 13, fontWeight: 'bold' }}>
💭 DeepSeek 深度思考过程 (流式输出中...)
</summary>
<div style={{ color: '#475569', fontSize: 13, marginTop: 8, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{thinking}
</div>
</details>
)}
{/* 消息历史列表 */}
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, minHeight: 300, padding: 16, marginBottom: 16 }}>
{messages.length === 0 && (
<div style={{ color: '#94a3b8', textAlign: 'center', marginTop: 80 }}>
<div style={{ fontSize: 32, marginBottom: 12 }}>🤖</div>
<div>你可以试着问我:</div>
<div style={{ marginTop: 8, fontSize: 13, lineHeight: 2 }}>
「北京今天天气怎么样」<br />
「查一下 react 这个 npm 包的信息」<br />
「123 乘以 456 等于多少」
</div>
</div>
)}
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 4, color: m.role === 'user' ? '#2563eb' : '#059669' }}>
{m.role === 'user' ? '👤 你' : '🤖 Agent'}
</div>
{/* 工具调用可视化折叠卡片 */}
{m.toolCalls && m.toolCalls.length > 0 && (
<div style={{ marginBottom: 8 }}>
{m.toolCalls.map((tc) => (
<details
key={tc.id}
style={{
background: tc.status === 'executing' ? '#fefce8' : '#f0fdf4',
border: `1px solid ${tc.status === 'executing' ? '#fde047' : '#86efac'}`,
borderRadius: 6,
padding: '8px 12px',
marginBottom: 6,
}}
>
<summary style={{ cursor: 'pointer', fontSize: 13, fontWeight: 500 }}>
{tc.status === 'executing' ? '🔄' : '✅'}{' '}
工具调用: <code style={{ background: '#e2e8f0', padding: '1px 4px', borderRadius: 3 }}>{tc.name}</code>
{tc.status === 'executing' && <span style={{ color: '#ca8a04', marginLeft: 8 }}>执行中...</span>}
</summary>
<div style={{ marginTop: 8, fontSize: 12 }}>
<div style={{ color: '#64748b', marginBottom: 4 }}>调用入参:</div>
<pre style={{ background: '#f8fafc', padding: 8, borderRadius: 4, overflow: 'auto', fontSize: 11, margin: 0 }}>
{JSON.stringify(tc.args, null, 2)}
</pre>
{tc.status === 'done' && tc.result !== undefined && (
<>
<div style={{ color: '#64748b', marginBottom: 4, marginTop: 8 }}>返回值:</div>
<pre style={{ background: '#f0fdf4', padding: 8, borderRadius: 4, overflow: 'auto', fontSize: 11, margin: 0 }}>
{JSON.stringify(tc.result, null, 2)}
</pre>
</>
)}
</div>
</details>
))}
</div>
)}
{/* 消息文本 */}
{m.content ? (
<div style={{
display: 'inline-block',
padding: '8px 14px',
borderRadius: 8,
background: m.role === 'user' ? '#2563eb' : '#f1f5f9',
color: m.role === 'user' ? '#fff' : '#0f172a',
whiteSpace: 'pre-wrap',
maxWidth: '85%',
}}>
{m.content}
</div>
) : (
m.role === 'assistant' && busy && (
<div style={{ color: '#94a3b8', fontSize: 13 }}>
{m.toolCalls && m.toolCalls.length > 0 ? '🔧 工具执行完成,整理回答中...' : '⏳ 思考中...'}
</div>
)
)}
</div>
))}
</div>
{/* 控制栏 */}
<div style={{ display: 'flex', gap: 8 }}>
<input
style={{ flex: 1, padding: '10px 14px', borderRadius: 6, border: '1px solid #cbd5e1' }}
value={input}
placeholder="输入消息,体验 AI Agent 工具调用..."
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
disabled={busy}
/>
{busy ? (
<button style={{ padding: '10px 20px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }} onClick={handleStop}>
停止
</button>
) : (
<button style={{ padding: '10px 20px', background: '#059669', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }} onClick={handleSend}>
发送
</button>
)}
</div>
</main>
);
}
整个 Agent Loop 的核心逻辑都在 handleSend 方法中,这里不好单独逐行描述过程。我尽量把注释都写全了。如果有疑问欢迎评论区讨论~
总结与展望
回顾整个实战系列,我们的代码架构逐渐演变:
scss
┌─────────────────────────────────────────────────────────────┐
│ W1 · 最小闭环 Chat Demo │
│ └── 60 行裸写 fetch 流式消费,摸清 SSE 基本通信逻辑 │
└──────────────────────────────┬──────────────────────────────┘
│ 提炼重构
▼
┌─────────────────────────────────────────────────────────────┐
│ W2 · 生产级流式解析引擎 (w2-stream-parser) │
│ ├── WHATWG 规范 TransformStream 管道链 (零乱码/防粘包) │
│ ├── 工业级全防御多厂商适配层 (预留 ToolCallDelta) │
│ └── rAF 60fps 攒批平滑渲染调度器 (预留 onToolCall 回调) │
└──────────────────────────────┬──────────────────────────────┘
│ 增量演进
▼
┌─────────────────────────────────────────────────────────────┐
│ W3 · AI Agent 工具调用引擎 (w3-tool-chat) │
│ ├── ToolCallAccumulator:解决流式分片拼接难题 │
│ ├── ToolRegistry:借助 Zod 实现严格类型校验与自愈回传 │
│ ├── /api/chat:仅增改 4 行代码完成透明 tools 注入 │
│ └── 客户端 Agent Loop:状态机驱动多轮闭环与优雅 UI │
└─────────────────────────────────────────────────────────────┘
在完成 W3 的学习与手写之后,你已经掌握了:
- 洞察了 Function Calling 的本质,理解了为什么大模型本身不运行代码。
- 掌握了流式场景下按 index 分组拼接 JSON 碎片的算法机制。
- 掌握了 Zod Schema 与 JSON Schema 的互转、数据瘦身技术、以及防注入沙箱攻防。
- 深刻领悟了客户端驱动多轮会话、精确消息定位与异常自省的工程奥秘。
在拥有了"手和眼"(调用工具能力)之后,面对几十万字的企业私有产品文档、代码库、PDF 手册,大模型的上下文窗口依旧放不下怎么办?
下一阶段,我们将开启 W4. 本地向量知识库与 RAG 落地实战:
- 深入拆解文本切块(Chunking)的工程艺术;
- 手写向量嵌入(Embedding)与余弦相似度计算算法;