ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从FC到MCP:Node+TS开发多工具调用Agent实战,TaoToken统一Key接入

从FC到MCP:Node+TS开发多工具调用Agent实战,TaoToken统一Key接入 1. 为什么多工具 Agent 总是调错工具、传错参数先说结论Agent 调用外部工具这件事难点从来不在“能不能调通”而在“调得对不对”。你注册了五六个 MCP 工具之后模型面对一句“帮我算下这组数据的平均值”它得先判断该用哪个工具再按这个工具要求的格式把参数拼出来。这两步任何一步出错整条链路就断了。我在实际项目里踩过的坑基本集中在两类。第一类是工具选择错乱注册了数据处理、天气查询、文件读写三个 MCP用户说“处理一下这批数字”Agent 却匹配到了文件读写因为“处理”这个词在多个工具的标签里都出现过。第二类是入参格式不对Schema 要求data是number[]模型却传了个字符串[1,2,3]或者漏掉了必填字段调用直接抛异常。这两个问题的本质是 Agent 缺少一层“协议约束”。Function Calling 解决的是“模型知道有哪些函数可以调”但它不负责校验参数、不负责在多个工具间做精准路由。MCPModel Context Protocol补的正是这一层它把每个工具的入参 Schema、功能描述、特征标签标准化让 Agent 在调用前能做匹配和校验。所以这篇内容要交付的是一条完整链路用 Node TypeScript 写一个轻量 Agent先实现 FC函数计算工具再把它包装成符合 MCP 协议的适配层最后通过 TaoToken 的统一 Key 和 API 通道完成多工具的鉴权与调用验证。适合已经写过基础 Node 服务、想搞明白 Agent 工具调度到底怎么落地的开发者。整套代码可以直接复制跑通不需要你从零设计架构。核心检索词先摆出来Node TS 开发多工具调用 Agent、Function Calling 到 MCP 协议集成、TaoToken 统一 Key 接入。这三个词贯穿全文你按顺序跟下来就能跑通端到端流程。2. TaoToken 统一 Key 与 API 通道前置准备在写 Agent 代码之前得先把模型调用这条链路打通。因为 Agent 的“任务解析”和“工具匹配”最终都要靠模型来完成你需要一个稳定的 API 通道。TaoToken 在这里的作用是用一个 Key 统一接入多个模型省掉你在不同厂商之间来回切换配置的麻烦。先说清楚它是什么。TaoToken 提供的是兼容 OpenAI 接口规范的 API 通道你拿到的 Key 可以直接用在任何支持baseURL配置的 SDK 里。对 Node TS 项目来说这意味着你不需要为每个模型单独写适配代码改一下baseURL和model字段就能切换。前置准备分三步。第一步去官网注册并拿到 API Key。地址是https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content注册后在控制台的 API Keys 页面生成一个 Key格式通常是sk-开头的一串字符。这个 Key 就是后面所有请求的凭证。第二步确认 API 端点。基础地址是https://taotoken.net/api注意这个地址不带任何查询参数直接作为baseURL使用。如果你用的是 OpenAI 官方 SDK它会自动在末尾拼接/chat/completions所以你在代码里只需要填到/api这一层。第三步选模型。TaoToken 支持多种模型你在控制台的模型列表里能看到可用的 Model ID。对于 Agent 场景建议选一个指令遵循能力强的模型因为工具匹配和参数生成都依赖它对 Schema 的理解。把 Model ID 记下来后面配置里要用。这里有个容易踩的坑很多人拿到 Key 之后直接写死在代码里然后提交到 Git。正确做法是用环境变量管理。在项目根目录建一个.env文件写入TAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODEL你的ModelID然后在.gitignore里加上.env。这样你的 Key 不会泄露团队协作时每个人用自己的 Key 也不会冲突。再强调一下三件套的概念Base URL、Key、Model ID。这三个东西在任何模型接入场景里都是必须配齐的。Base URL 决定请求发到哪里Key 决定你有没有权限Model ID 决定用哪个模型。后面在 Agent 代码里调用模型时这三个值会一起出现在配置对象里。如果你还没生成 Key现在可以去控制台的 API Keys 页面操作。生成之后先别急着写代码用 curl 测一下通道是否通curl https://taotoken.net/api/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -d { model: $TAOTOKEN_MODEL, messages: [{role: user, content: 回复ok}] }如果返回里有choices字段说明通道正常。如果返回 401检查 Key 是否复制完整如果返回 404检查 baseURL 是否写成了https://taotoken.net/api/末尾多斜杠有时会导致路径拼接错误。这一步验证通过之后再进入 Agent 开发环节。3. Node TS 工程配置与 FC/MCP 代码实现这一节是全文的技术核心我会把完整的工程配置和代码实现拆成可复制的步骤。你跟着做就能得到一个能跑的多工具 Agent。3.1 初始化项目与依赖安装先建目录、初始化 npm、装依赖mkdir agent-fc-mcp-demo cd agent-fc-mcp-demo npm init -y npm install typescript ts-node types/node zod openai dotenv npm install -D types/express npx tsc --init这里的关键依赖是zod做参数校验和openai调用模型因为 TaoToken 兼容 OpenAI 接口。dotenv用来加载.env文件。然后改tsconfig.json把关键配置对齐{ compilerOptions: { target: ES2020, module: CommonJS, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, resolveJsonModule: true }, include: [src/**/*], exclude: [node_modules] }strict: true很重要它会让 TypeScript 在编译期就帮你抓出类型问题减少运行时报错。3.2 目录结构与模块职责项目结构设计成三层职责清晰src/ ├── agent/ │ ├── agent.ts # Agent 调度核心 │ └── matcher.ts # MCP 匹配算法 ├── mcp/ │ ├── mcp.ts # MCP 基类 │ ├── schema.ts # 入参 Schema 定义 │ └── fc.mcp.ts # FC 的 MCP 适配实现 ├── fc/ │ └── fc.ts # FC 业务函数 ├── llm/ │ └── client.ts # TaoToken 模型客户端 └── index.ts # 入口Agent 负责调度MCP 负责协议和校验FC 负责业务逻辑。这个分层的好处是你新增一个工具时只需要写一个 FC 函数和一个对应的 MCP 适配类Agent 层不用改。3.3 定义 MCP Schema参数校验src/mcp/schema.tsimport { z } from zod; export const FcDataProcessSchema z.object({ data: z.array(z.number()).min(1, 数据数组不能为空), type: z.enum([sum, average]).default(sum), keepDecimal: z.boolean().default(false) }); export type FcDataProcessParams z.infertypeof FcDataProcessSchema;Zod 的.default()会在参数缺失时自动补全.min(1)会在数组为空时直接拦截。这就是“确保入参正确”的第一道防线。3.4 实现 FC 业务函数src/fc/fc.tsimport { FcDataProcessParams } from ../mcp/schema; export async function dataProcessFc(params: FcDataProcessParams): Promisenumber { const { data, type, keepDecimal } params; let result: number; if (type sum) { result data.reduce((acc, curr) acc curr, 0); } else { result data.reduce((acc, curr) acc curr, 0) / data.length; } return keepDecimal ? parseFloat(result.toFixed(2)) : Math.round(result); }3.5 MCP 基类与 FC 适配src/mcp/mcp.tsimport { ZodSchema } from zod; export abstract class BaseMCP { constructor( public readonly id: string, public readonly tags: string[], public readonly schema: ZodSchema, public readonly description: string ) {} public validateParams(params: unknown): any { const result this.schema.safeParse(params); if (!result.success) { throw new Error( 参数校验失败${result.error.issues.map(i i.message).join(, )} ); } return result.data; } public abstract execute(params: unknown): Promiseany; }src/mcp/fc.mcp.tsimport { BaseMCP } from ./mcp; import { FcDataProcessSchema, FcDataProcessParams } from ./schema; import { dataProcessFc } from ../fc/fc; export class FcDataProcessMCP extends BaseMCP { constructor() { super( fc-data-process, [数据处理, 求和, 平均值, 数值计算], FcDataProcessSchema, 用于处理数值数组支持求和或计算平均值 ); } public async execute(params: unknown): Promisenumber { const validated this.validateParams(params) as FcDataProcessParams; return await dataProcessFc(validated); } }3.6 Agent 匹配算法与调度核心src/agent/matcher.tsimport { BaseMCP } from ../mcp/mcp; function similarity(text1: string, text2: string): number { const w1 text1.toLowerCase().split(/\s||。|、/).filter(Boolean); const w2 text2.toLowerCase().split(/\s||。|、/).filter(Boolean); const inter w1.filter(w w2.includes(w)); return inter.length / Math.max(w1.length, w2.length); } export function matchMcp(task: string, mcpList: BaseMCP[]): BaseMCP { if (mcpList.length 0) throw new Error(未注册任何MCP工具); const scored mcpList.map(mcp { const tagScore Math.max(...mcp.tags.map(t similarity(task, t))); const descScore similarity(task, mcp.description); return { mcp, score: tagScore * 0.7 descScore * 0.3 }; }); scored.sort((a, b) b.score - a.score); if (scored[0].score 0.1) { throw new Error(无匹配的MCP工具最高匹配度${scored[0].score}); } return scored[0].mcp; }src/agent/agent.tsimport { BaseMCP } from ../mcp/mcp; import { matchMcp } from ./matcher; export class Agent { private mcps: BaseMCP[] []; public registerMcp(mcp: BaseMCP): void { if (this.mcps.some(m m.id mcp.id)) { console.warn(MCP ${mcp.id} 已注册跳过); return; } this.mcps.push(mcp); console.log(MCP ${mcp.id} 注册成功); } public async runTask(task: string, params: unknown): Promiseany { try { const target matchMcp(task, this.mcps); console.log(匹配到MCP${target.id}); const validated target.validateParams(params); const result await target.execute(validated); return { success: true, mcpId: target.id, result }; } catch (error) { return { success: false, result: null, message: (error as Error).message }; } } }3.7 接入 TaoToken 模型客户端src/llm/client.tsimport OpenAI from openai; import dotenv from dotenv; dotenv.config(); export const llmClient new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL }); export const MODEL_ID process.env.TAOTOKEN_MODEL!;这里三件套齐了baseURL指向https://taotoken.net/apiapiKey从环境变量读model用MODEL_ID。任何支持 OpenAI 接口的调用都能复用这个客户端。3.8 入口文件与运行src/index.tsimport { Agent } from ./agent/agent; import { FcDataProcessMCP } from ./mcp/fc.mcp; async function main() { const agent new Agent(); agent.registerMcp(new FcDataProcessMCP()); const r1 await agent.runTask(计算数组[1,2,3,4,5]的平均值保留两位小数, { data: [1, 2, 3, 4, 5], type: average, keepDecimal: true }); console.log(任务1, r1); const r2 await agent.runTask(计算数组的和, { type: sum }); console.log(任务2, r2); const r3 await agent.runTask(计算数组[10,20,30]的和, { data: [10, 20, 30] }); console.log(任务3, r3); } main();运行npx ts-node src/index.ts预期输出里任务1 返回result: 3任务2 返回参数校验失败缺data任务3 返回result: 60。这说明匹配和校验都生效了。4. 验证请求与成功结果对照代码跑通之后你需要确认两件事模型通道是否正常以及 Agent 的工具调用链路是否端到端可用。这一节给出具体的验证方法和预期结果。4.1 验证 TaoToken 通道先单独测模型调用排除 Agent 逻辑的干扰。写一个最小脚本import { llmClient, MODEL_ID } from ./llm/client; async function testLLM() { const res await llmClient.chat.completions.create({ model: MODEL_ID, messages: [{ role: user, content: 用一句话说明什么是函数调用 }] }); console.log(res.choices[0].message.content); } testLLM();运行npx ts-node src/llm/test.ts如果控制台打印出模型回复说明 Key、Base URL、Model ID 三件套配置正确。如果报 401检查.env里的 Key 是否有多余空格如果报model not found去控制台确认 Model ID 拼写。4.2 验证 Agent 端到端调用把模型接入 Agent 的匹配环节让模型来解析任务并生成参数。改造agent.ts增加一个runTaskWithLLM方法public async runTaskWithLLM(task: string): Promiseany { const toolDescriptions this.mcps.map(m - ${m.id}: ${m.description}标签${m.tags.join(/)} ).join(\n); const prompt 你是一个工具调度器。可用工具\n${toolDescriptions}\n 用户任务${task}\n 请只返回 JSON格式{mcpId:工具id,params:{...}}; const res await llmClient.chat.completions.create({ model: MODEL_ID, messages: [{ role: user, content: prompt }], response_format: { type: json_object } }); const parsed JSON.parse(res.choices[0].message.content!); const target this.mcps.find(m m.id parsed.mcpId); if (!target) throw new Error(模型选择了不存在的MCP${parsed.mcpId}); const validated target.validateParams(parsed.params); const result await target.execute(validated); return { success: true, mcpId: target.id, result }; }调用agent.runTaskWithLLM(帮我算一下[1,2,3,4,5]的平均值)预期返回{ success: true, mcpId: fc-data-process, result: 3 }。这一步验证的是模型能根据工具描述选对 MCP并且生成的参数能通过 Zod 校验。4.3 成功结果的判断标准一次成功的端到端调用输出里应该同时满足三个条件。第一mcpId和任务语义匹配比如“平均值”任务匹配到fc-data-process而不是其他工具。第二result数值正确[1,2,3,4,5]的平均值四舍五入后是 3。第三没有抛异常说明参数校验通过。如果模型返回的 JSON 解析失败通常是response_format没生效或者模型不支持。这时候可以在 prompt 里加一句“不要输出 markdown 代码块只输出纯 JSON”。如果模型选错了 MCP说明工具描述不够区分把description写得更具体比如加上“仅用于数值数组的求和与平均值计算”。5. 常见报错排查401、local proxy failed、reading choices这一节对照真实报错给出排查路径。这些错误我在调试过程中基本都遇到过按顺序检查能省不少时间。5.1 401 Unauthorized报错长这样Error: 401 Unauthorized {error:{message:Invalid API key provided,type:invalid_request_error}}原因通常是 Key 不对。检查三处.env文件里的TAOTOKEN_API_KEY是否完整复制有时候复制会漏掉末尾字符代码里是否真的加载了.env确认dotenv.config()在new OpenAI()之前执行环境变量名是否拼写一致。如果用的是 shell 直接 export确认没有多余引号。5.2 local proxy failed / connection refused报错Error: connect ECONNREFUSED 127.0.0.1:7890这是本地网络配置问题。检查你的HTTP_PROXY/HTTPS_PROXY环境变量是否指向了一个没启动的本地端口。在 Node 里OpenAI SDK 会读取这些环境变量。解决办法是在.env里显式清空HTTP_PROXY HTTPS_PROXY或者在代码里创建客户端时传入自定义httpAgent。确认baseURL是https://taotoken.net/api不要写成http://。5.3 reading choices of undefined报错TypeError: Cannot read properties of undefined (reading choices)这说明res本身是 undefined通常是请求抛异常被吞掉了。检查chat.completions.create是否被 try/catch 包住但没打印错误。把 catch 里的error完整打印出来你会看到真正的底层错误可能是 401 或超时。另一个原因是MODEL_ID为空导致请求体里model字段缺失服务端返回错误结构SDK 解析失败。5.4 OAuth / token 相关报错如果你在 Claude Code 或类似工具里配置可能遇到OAuth token expired or invalid这类报错说明你用的是 OAuth 流程而不是 API Key。在 TaoToken 场景下统一用 API Key 鉴权不需要走 OAuth。检查你的配置文件里是否误填了oauth相关字段。正确的配置应该只有apiKey和baseURL。5.5 参数校验失败报错参数校验失败数据数组不能为空这是 Zod 拦截生效了说明模型生成的参数缺了必填字段。解决办法有两个在 prompt 里把 Schema 的必填项明确列出来或者在 Agent 里加一层“参数补全”当校验失败时把错误信息回传给模型让它重新生成。后者更健壮但会增加一次模型调用。5.6 工具匹配度低于阈值报错无匹配的MCP工具最高匹配度0.05说明用户任务和所有工具的标签、描述都不沾边。检查你的tags是否覆盖了用户可能用的同义词。比如用户说“算一下”你的标签里只有“求和”“平均值”匹配度就会很低。把常见口语表达加进标签或者降低阈值到 0.05 并增加人工确认环节。6. 从 FC 到 MCP 的工程化落地建议代码跑通只是第一步真正要放到项目里用还得考虑扩展性和稳定性。这一节给几个实操方向。第一个方向是增强匹配算法。现在的文本相似度匹配在工具少的时候够用但工具超过十个之后同义词和歧义会明显增多。你可以把工具选择交给模型来做就像 4.2 节里的runTaskWithLLM让模型读工具描述后直接输出mcpId。模型对语义的理解比关键词匹配强得多代价是多一次 API 调用。折中方案是先用关键词做粗筛把候选工具缩到三个以内再让模型精选。第二个方向是支持多工具串行调用。真实任务往往需要多个工具配合比如先查数据库拿到数据再调数据处理工具算指标。你可以在 Agent 里加一个runPipeline方法接收一个任务数组按顺序执行前一个工具的输出作为后一个的输入。MCP 基类不用改只需要在调度层做编排。第三个方向是 MCP 注册中心。现在工具是硬编码注册的新增工具要改入口文件。你可以把 MCP 的元信息id、tags、description、schema抽成 JSON 配置启动时动态加载。这样非开发人员也能通过改配置来增减工具。配置格式大概长这样{ id: fc-data-process, tags: [数据处理, 求和, 平均值], description: 用于处理数值数组, endpoint: http://localhost:3001/fc/data-process }第四个方向是日志与监控。每次工具调用都记录mcpId、入参、耗时、结果状态。这些日志在排查“为什么这次调用失败了”时非常有用。你可以用一个简单的中间件包住execute方法在前后打时间戳。第五个方向是错误重试。模型生成的参数偶尔会不合规与其直接报错不如把校验错误回传给模型让它修正最多重试两次。实现上就是在runTaskWithLLM里加一个循环捕获validateParams抛出的错误把错误信息拼进下一轮 prompt。最后说下 Key 管理。项目里所有模型调用都走 TaoToken 的统一 Key好处是你只需要维护一份凭证。但要注意别把 Key 写进代码或提交到仓库。生产环境建议用密钥管理服务开发环境用.env加.gitignore。如果你需要给不同环境配不同的 Key可以在.env里用TAOTOKEN_API_KEY_DEV和TAOTOKEN_API_KEY_PROD区分代码里根据NODE_ENV选择。整套链路的核心就三件事用 Schema 约束参数用匹配算法或模型选工具用统一 Key 打通模型调用。把这三件事做扎实你的 Agent 就能稳定地调度多个工具而不是每次调用都靠运气。
返回列表