Function Calling 全链路实现:工具注册表、参数解析与并发派发

本节目标

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 还是毫秒时间戳)。