Function Calling 全链路实现:工具注册表、参数解析与并发派发
本节目标
- 掌握大模型 Function Calling(Tool Use)的完整协议生命周期与消息配对规则
- 设计支持权限分级、超时控制、参数校验与幂等缓存的工业级
ToolRegistry(工具注册中心) - 实现单轮多工具并发调用(Parallel Tool Calling)的
Promise.allSettled派发与顺序对齐
Function Calling 的本质:模型只做「决策签名」,宿主负责「本地执行」
很多初学者误以为开启 Function Calling 后,大模型自己连上了数据库或发起了网络请求。实际上,大模型自始至终只做一件事:根据工具的 JSON Schema 描述,生成一段包含函数名和 JSON 参数的声明文本。真正执行代码、读取文件或查询数据库的,是你运行在 Node.js 或浏览器里的宿主程序(Host Runtime)。
[Host JS Runtime] ──(1. 发送 messages + tools JSON Schema)──► [LLM API]
[Host JS Runtime] ◄─(2. 返回 finish_reason: "tool_calls" )── [LLM API]
tool_calls: [
{ id: "call_1", name: "getWeather", args: '{"city":"Seoul"}' },
{ id: "call_2", name: "getStock", args: '{"ticker":"AAPL"}' }
]
[Host JS Runtime] ──(3. 本地并行执行 getWeather & getStock )
[Host JS Runtime] ──(4. 附带对应 tool_call_id 写回两条 role:"tool" 消息)──► [LLM API]协议铁律:tool_call_id 必须严格一一对应
当模型在一条 assistant 消息里同时发起 N 个 tool_calls 时,宿主必须在紧随其后的消息序列中,为每一个 tool_call.id 补充一条对应的 role: 'tool' 消息。哪怕其中某个工具执行超时或报错,也必须返回一条带有该 tool_call_id 的错误 JSON 消息;一旦漏传任何一个 id,下一轮请求大模型 API 会直接返回 400 Bad Request。
手写支持并发隔离、超时熔断与参数校验的 ToolRegistry
export class ToolRegistry {
constructor() {
this.tools = new Map()
}
register({ name, description, schema, execute, timeoutMs = 10000, readOnly = true }) {
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
throw new Error(`非法工具名 "${name}":仅允许字母、数字、下划线与连字符`)
}
this.tools.set(name, { name, description, schema, execute, timeoutMs, readOnly })
return this
}
// 导出供 OpenAI / 兼容接口使用的 tools 声明数组
toOpenAiTools() {
return [...this.tools.values()].map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: {
type: 'object',
properties: t.schema.properties || {},
required: t.schema.required || []
}
}
}))
}
// 带超时保护与 Schema 校验的单工具执行器
async invokeOne(toolCall, context = {}) {
const start = Date.now()
const { id, function: fn } = toolCall
const tool = this.tools.get(fn?.name)
if (!tool) {
return {
role: 'tool',
tool_call_id: id,
name: fn?.name || 'unknown',
content: JSON.stringify({ ok: false, error: `未知工具 "${fn?.name}"` })
}
}
let args
try {
args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments || '{}') : (fn.arguments || {})
} catch (err) {
return {
role: 'tool',
tool_call_id: id,
name: tool.name,
content: JSON.stringify({ ok: false, error: `工具参数 JSON 解析失败: ${err.message}` })
}
}
if (tool.schema?.validate) {
const validationErrors = tool.schema.validate(args)
if (validationErrors.length > 0) {
return {
role: 'tool',
tool_call_id: id,
name: tool.name,
content: JSON.stringify({ ok: false, error: '参数校验未通过', details: validationErrors })
}
}
}
try {
const result = await Promise.race([
Promise.resolve(tool.execute(args, context)),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`工具 ${tool.name} 执行超过 ${tool.timeoutMs}ms 超时上限`)), tool.timeoutMs)
)
])
return {
role: 'tool',
tool_call_id: id,
name: tool.name,
content: JSON.stringify({ ok: true, data: result, durationMs: Date.now() - start })
}
} catch (err) {
return {
role: 'tool',
tool_call_id: id,
name: tool.name,
content: JSON.stringify({ ok: false, error: err.message, durationMs: Date.now() - start })
}
}
}
// 并行派发模型在单轮中请求的所有 tool_calls,严格保持原顺序返回
async dispatchAll(toolCalls = [], context = {}) {
return Promise.all(toolCalls.map((tc) => this.invokeOne(tc, context)))
}
}课后练习
练习 1:当模型在单轮回复中同时返回了 3 个 tool_calls(例如查询 3 个不同微服务的健康状态)时,为什么在 dispatchAll 中使用 Promise.all 并发执行,同时在 invokeOne 内部捕获所有异常而不向外 reject?
答案:并发执行可以将 3 个 IO 密集型工具的总耗时从 T1 + T2 + T3 缩短为 $\max(T_1, T_2, T_3)$;而在 invokeOne 内部捕获异常并封装为 { ok: false, error } 的 role: 'tool' 消息,能保证即便其中 1 个工具超时失败,另外 2 个成功的结果依然能正常返回,且不会破坏大模型 API 要求的「每个 tool_call_id 必须有对应响应」的协议契约。
练习 2:为什么工具名称必须用正则 /^[a-zA-Z0-9_-]{1,64}$/ 严格校验,且工具的 description 质量直接决定了 Agent 的准确率?
答案:主流大模型 API(OpenAI/Anthropic)对工具名有严格的字符集与 64 字符长度限制,非法字符会直接触发接口 400 报错;同时模型完全依赖 description 与参数 description 来理解何时该选用该工具以及参数格式(如日期格式是 YYYY-MM-DD 还是毫秒时间戳)。