工具执行器设计

要点

  • 工具执行器是 Agent 系统中「真正做事」的那一层——LLM 决定调用哪个工具、传什么参数,执行器负责验证参数、运行工具函数、格式化返回结果
  • 如果把工具调用比作一次函数调用,执行器就是运行时环境,处理调用前后的所有脏活
  • 执行器接口设计的核心是让「新增一个工具」只意味着「写一个符合接口的对象」,不需要改动执行器本身
  • 工具注册机制把工具定义(给 LLM 看的 Schema)和工具实现(实际执行的函数)绑在一起,避免两处维护
  • 错误处理分三层:参数验证失败、执行异常、超时,每层需要不同的处理策略
  • 超时控制不可省略——没有超时限制的工具调用可能卡住整个 Agent 循环
  • 多个工具之间没有依赖关系时,并发执行能显著减少总耗时

1. 从一个粗糙的调用开始

前面几节讲了工具 Schema 怎么设计、LLM 怎么决定调用哪个工具。到了执行这一步,也许你会先把逻辑写成这样:

// src/agent/naive.ts
app.post('/agent', async (c) => {
  const { messages } = await c.req.json()
 
  const response = await c.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
    messages,
    tools: [
      { name: 'get_weather', description: '获取天气', parameters: { ... } },
      { name: 'search_web', description: '搜索网页', parameters: { ... } }
    ]
  })
 
  // 手动判断调了哪个工具
  if (response.tool_calls) {
    for (const call of response.tool_calls) {
      if (call.name === 'get_weather') {
        const result = await getWeather(call.arguments)
        // 把 result 塞回 messages,再调一次 LLM ...
      }
      if (call.name === 'search_web') {
        const result = await searchWeb(call.arguments)
        // ...
      }
    }
  }
  return c.json(response)
})

这段代码能跑,但几个问题已经冒出来了:

  1. 每新增一个工具,就要在 if 链里加一个分支
  2. 没有参数验证——LLM 传来的 call.arguments 直接传给函数,模型传错类型就会崩溃
  3. 没有错误处理——工具函数抛异常,整个请求就挂了
  4. 没有超时控制——某个工具卡住,用户只能一直等
  5. 工具定义和工具实现分开放在两块,改了一处容易忘改另一处

这些问题的共同指向是:需要一个独立的执行器层,把这些关注点从业务逻辑中剥离出来。

2. 执行器的职责

在写代码之前,先把执行器要做的事情列清楚。它承担四项职责:

  1. 参数验证 — 检查 LLM 传来的参数是否符合 Schema 定义
  2. 执行工具 — 调用对应的工具函数,拿到原始结果
  3. 错误处理 — 捕获执行过程中的异常,转成 LLM 能理解的错误信息
  4. 结果格式化 — 把工具函数的返回值转成统一格式,送回 LLM

把这四项职责封装到一个独立模块里,业务代码就不用关心工具怎么执行了。它只需要告诉执行器:「LLM 说要调用这个工具,参数是这些」,执行器返回一个可以送回 LLM 的结果。

3. 定义执行器接口

先把单个工具的结构定下来。每个工具包含两部分:给 LLM 看的 Schema 定义,以及实际执行的函数。

// src/tools/types.ts
import type { z } from 'zod'
 
export type ToolDefinition = {
  name: string
  description: string
  parameters: {
    type: 'object'
    properties: Record<string, unknown>
    required?: string[]
  }
}
 
// 单个工具的完整定义:Schema + 实现
export interface Tool<TInput = unknown, TOutput = unknown> {
  definition: ToolDefinition
  // 参数验证用的 Zod Schema
  schema: z.ZodType<TInput>
  // 实际执行函数
  execute: (input: TInput, context: ToolContext) => Promise<TOutput>
}
 
// 执行上下文,透传给每个工具
export type ToolContext = {
  userId: string
  signal: AbortSignal
  env: Record<string, string>
}

这里用 Zod 做参数验证。definition.parameters 是给 LLM 读的纯 JSON 描述,schema 是给执行器做运行时校验的。两者描述同一份参数结构,只是用途不同。

ToolContext 是一个容易忽略的设计点。工具函数执行时往往需要运行时信息——当前用户是谁、调用是否已超时、环境变量。把这些统一放在 context 里传递,避免给 execute 加一堆散装参数。

执行器本身的结构:

// src/tools/executor.ts
export type ToolCallRequest = { name: string; arguments: unknown }
 
export type ToolCallResult = {
  toolCallId: string
  name: string
  status: 'success' | 'error'
  result: unknown
}
 
export class ToolExecutor {
  private tools = new Map<string, Tool>()
 
  register(tool: Tool): void {
    if (this.tools.has(tool.definition.name)) {
      throw new Error(`工具「${tool.definition.name}」已注册`)
    }
    this.tools.set(tool.definition.name, tool)
  }
 
  // 获取所有工具 Schema(发给 LLM)
  getDefinitions(): ToolDefinition[] {
    return Array.from(this.tools.values()).map((t) => t.definition)
  }
 
  async execute(call: ToolCallRequest, ctx: ToolContext): Promise<ToolCallResult> {
    const tool = this.tools.get(call.name)
 
    // 工具不存在
    if (!tool) {
      return { toolCallId: '', name: call.name, status: 'error',
        result: { error: `未知工具:${call.name}` } }
    }
 
    // 参数验证
    const parsed = tool.schema.safeParse(call.arguments)
    if (!parsed.success) {
      return { toolCallId: '', name: call.name, status: 'error',
        result: { error: '参数验证失败',
          details: parsed.error.issues.map((i) => ({
            path: i.path.join('.'), message: i.message
          }))
        } }
    }
 
    // 执行工具
    try {
      const output = await tool.execute(parsed.data, ctx)
      return { toolCallId: '', name: call.name, status: 'success', result: output }
    } catch (err) {
      return { toolCallId: '', name: call.name, status: 'error',
        result: { error: err instanceof Error ? err.message : '工具执行失败' } }
    }
  }
}

execute 方法的逻辑对应第 2 节的四项职责:查工具存不存在,验证参数,调用 execute,把结果包装成 ToolCallResult。任何一步出错都返回 status: 'error',而不是抛异常。原因是 LLM 需要知道工具调用失败了,才能决定要不要重试。如果执行器直接把异常抛出去,Agent 循环就断了。

4. 注册机制:定义和实现放在一起

前面的粗糙示例里,工具 Schema 和实现分开放在两个地方。工具一多,Schema 改了忘改实现的情况几乎必然出现。更稳妥的做法是让每个工具文件同时导出 definitionexecute

// src/tools/get-weather.ts
import { z } from 'zod'
import type { Tool } from './types'
 
const inputSchema = z.object({
  city: z.string().min(1).describe('城市名称,例如:北京')
})
 
export const getWeatherTool: Tool<z.infer<typeof inputSchema>> = {
  definition: {
    name: 'get_weather',
    description: '获取指定城市的当前天气信息,返回温度和天气状况',
    parameters: {
      type: 'object',
      properties: {
        city: { type: 'string', description: '城市名称,例如:北京、上海' }
      },
      required: ['city']
    }
  },
  schema: inputSchema,
  async execute(input) {
    const response = await fetch(
      `https://api.weather.com/v1/${encodeURIComponent(input.city)}`
    )
    if (!response.ok) {
      throw new Error(`天气 API 请求失败:${response.status}`)
    }
    return response.json()
  }
}

注册时逐个调用 register

// src/tools/registry.ts
import { ToolExecutor } from './executor'
import { getWeatherTool } from './get-weather'
import { searchWebTool } from './search-web'
 
export function createExecutor(): ToolExecutor {
  const executor = new ToolExecutor()
  executor.register(getWeatherTool)
  executor.register(searchWebTool)
  return executor
}

新增一个工具只需要三步:新建工具文件、写 Tool 对象、在 registry.ts 加一行 register。执行器代码本身不需要改动。

5. 在 Hono 路由中使用执行器

把执行器接入 Hono 路由,Agent 接口的代码会变成这样:

// src/routes/agent.ts
import { Hono } from 'hono'
import { createExecutor } from '../tools/registry'
import type { ToolContext } from '../tools/executor'
 
const app = new Hono<{ Bindings: { AI: Ai } }>()
 
app.post('/agent', async (c) => {
  const { messages, userId } = await c.req.json<{
    messages: Array<{ role: string; content: string }>
    userId: string
  }>()
  const executor = createExecutor()
  const controller = new AbortController()
 
  const context: ToolContext = {
    userId: userId ?? 'anonymous',
    signal: controller.signal,
    env: c.env as unknown as Record<string, string>
  }
 
  // Agent 循环
  let currentMessages = [...messages]
 
  for (let i = 0; i < 10; i++) {
    const response = await c.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
      messages: currentMessages,
      tools: executor.getDefinitions()
    })
 
    // 没有工具调用,LLM 给出了最终回答
    if (!response.tool_calls?.length) {
      return c.json({ reply: response.content })
    }
 
    const toolCalls = response.tool_calls as Array<{
      id: string; name: string; arguments: unknown
    }>
 
    // 并发执行所有工具调用
    const results = await Promise.all(
      toolCalls.map((call) =>
        executor.execute({ name: call.name, arguments: call.arguments }, context)
          .then((r) => ({ ...r, toolCallId: call.id }))
      )
    )
 
    // 把工具结果追加到消息列表,进入下一轮循环
    currentMessages = [
      ...currentMessages,
      { role: 'assistant', content: response.content, tool_calls: toolCalls },
      ...results.map((r) => ({
        role: 'tool' as const,
        tool_call_id: r.toolCallId,
        content: JSON.stringify(r.result)
      }))
    ]
  }
 
  return c.json({ error: '达到最大迭代次数' }, 500)
})

几个设计决策值得拆开看。

为什么用 Promise.all 并发执行? LLM 一次可能返回多个工具调用,比如同时查天气和搜索网页。这两个调用没有依赖关系,串行执行的总耗时是两者之和,并发执行取决于最慢的那个。在工具涉及外部 API 调用的场景下,差异明显。

为什么限制循环次数? Agent 循环可能出现这种情况:工具返回了 LLM 不满意的结果,LLM 又发起同样的工具调用,反复循环。maxIterations 是兜底保护。生产环境中还需要配合日志记录,在循环次数异常时报警。

6. 超时控制

超时控制是执行器里不能省略的一环。一个没有超时限制的工具调用可能带来两种后果:外部 API 持续不响应,用户的请求一直挂着;Agent 循环被卡在某一步,整个系统陷入等待。

超时可以在两个层次实现。

在单次执行上加超时

Promise.race 给每次工具执行设一个时间上限:

// src/tools/executor.ts
async executeWithTimeout(
  call: ToolCallRequest,
  context: ToolContext,
  timeoutMs = 10_000
): Promise<ToolCallResult> {
  const timeoutPromise = new Promise<ToolCallResult>((_, reject) => {
    setTimeout(
      () => reject(new Error(`工具「${call.name}」执行超时(${timeoutMs}ms)`)),
      timeoutMs
    )
  })
 
  try {
    return await Promise.race([this.execute(call, context), timeoutPromise])
  } catch (err) {
    return {
      toolCallId: '', name: call.name, status: 'error',
      result: { error: err instanceof Error ? err.message : '执行超时' }
    }
  }
}

用 AbortController 传递取消信号

Promise.race 只能让调用方不再等待结果,但被超时的工具函数本身可能还在运行。要真正中止执行,需要把 AbortSignal 传给工具函数:

// src/tools/get-weather.ts
async execute(input, context) {
  const response = await fetch(
    `https://api.weather.com/v1/${encodeURIComponent(input.city)}`,
    { signal: context.signal }  // 传入 AbortSignal
  )
  if (!response.ok) {
    throw new Error(`天气 API 请求失败:${response.status}`)
  }
  return response.json()
}

当超时触发时,执行器调用 controller.abort()fetch 会收到 abort 信号并立即抛出 AbortError,而不是继续等待网络响应。

两个层次的关系:Promise.race 控制「等多久」,AbortController 控制「怎么停」。只用前者不用后者,超时的请求仍然在后台占用资源;只用后者不用前者,没有统一的超时入口。

7. 错误处理的三个层次

工具执行过程中可能出错的地方不止一个。按出错阶段来分,可以分为三层。

第一层:参数验证失败

LLM 生成的参数不符合 Schema 定义。比如 city 要求字符串,模型传了数字;limit 要求正整数,模型传了负数。

这一层把验证错误的具体信息返回给 LLM。模型看到「limit 字段必须大于 0」之后,通常能自行修正参数并重试。执行器在 execute 方法里已经做了这一步。

第二层:执行过程异常

工具函数本身抛出了错误。外部 API 返回 500、数据库连接失败、JSON 解析出错,都属于这一层。

处理策略是捕获异常,把错误信息包装成结构化结果。不要让异常直接冒泡到 Agent 循环——那会导致整个请求崩溃。执行器里的 try/catch 块已经覆盖了这种情况。

第三层:超时

第 6 节已经处理过。超时本质上是一种特殊的执行异常,触发条件不是函数内部报错,而是等待时间超过了阈值。

这三层错误的共同点是:都以 status: 'error' 的形式返回给 Agent 循环,由 LLM 决定下一步怎么处理。执行器的职责到此为止——它不重试、不降级、不替 LLM 做决策。它只负责把「发生了什么」忠实地告诉 LLM。

8. 并发执行的注意事项

第 5 节用了 Promise.all 来并发执行多个工具调用。这在大多数场景下是对的,但有几个细节需要注意。

并发数量控制

如果 LLM 一次返回了 10 个工具调用,Promise.all 会同时发起 10 个请求,对外部 API 来说可能触发限流。可以用 p-limit 这样的库来控制并发度:

// src/tools/agent.ts
import pLimit from 'p-limit'
 
const limit = pLimit(3)  // 最多同时执行 3 个
 
const results = await Promise.all(
  toolCalls.map((call) =>
    limit(() => executor.execute(
      { name: call.name, arguments: call.arguments },
      context
    ))
  )
)

部分失败的处理

Promise.all 的特性是其中一个 Promise reject,整个 Promise.all 就 reject。但工具调用的场景里,不希望一个工具失败导致其他工具的结果也丢失。

解决办法是让每个工具的 Promise 都不会 reject——在执行器的 execute 方法里,所有异常已经被 try/catch 捕获并转成了 status: 'error' 的结果。所以 Promise.all 拿到的永远是一组 resolved 的结果,每个结果里包含了自己的成功或失败状态。

这也是前面执行器设计里「所有错误都返回结果而不是抛异常」的一个重要原因。

总结

回到第 1 节开头那段粗糙的代码,对比一下现在的设计:

  • 注册机制:每个工具是一个独立的 Tool 对象,definitionexecute 放在同一个文件里,Schema 和实现不会脱节
  • 执行器ToolExecutor 承担参数验证、执行、错误处理、结果格式化四项职责,业务代码不需要关心这些
  • 接口统一:新增工具只需要写一个 Tool 对象并调用 register,执行器和路由代码不需要改动
  • 错误处理:分三层(参数验证失败、执行异常、超时),都以结构化结果返回给 LLM,不会中断 Agent 循环
  • 超时控制Promise.race 控制等待时间,AbortSignal 控制实际取消,两层配合使用
  • 并发执行Promise.all 并发调用多个工具,执行器保证每个调用不会 reject,避免一个失败拖垮全部

工具执行器在 Agent 系统里的位置,类似于后端服务里的 Service 层——它不决定做什么(那是 LLM 的事),它只负责把 LLM 的决策安全、可靠地执行掉,然后把结果交回去。