工具执行器设计
要点
- 工具执行器是 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)
})这段代码能跑,但几个问题已经冒出来了:
- 每新增一个工具,就要在
if链里加一个分支 - 没有参数验证——LLM 传来的
call.arguments直接传给函数,模型传错类型就会崩溃 - 没有错误处理——工具函数抛异常,整个请求就挂了
- 没有超时控制——某个工具卡住,用户只能一直等
- 工具定义和工具实现分开放在两块,改了一处容易忘改另一处
这些问题的共同指向是:需要一个独立的执行器层,把这些关注点从业务逻辑中剥离出来。
2. 执行器的职责
在写代码之前,先把执行器要做的事情列清楚。它承担四项职责:
- 参数验证 — 检查 LLM 传来的参数是否符合 Schema 定义
- 执行工具 — 调用对应的工具函数,拿到原始结果
- 错误处理 — 捕获执行过程中的异常,转成 LLM 能理解的错误信息
- 结果格式化 — 把工具函数的返回值转成统一格式,送回 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 改了忘改实现的情况几乎必然出现。更稳妥的做法是让每个工具文件同时导出 definition 和 execute:
// 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对象,definition和execute放在同一个文件里,Schema 和实现不会脱节 - 执行器:
ToolExecutor承担参数验证、执行、错误处理、结果格式化四项职责,业务代码不需要关心这些 - 接口统一:新增工具只需要写一个
Tool对象并调用register,执行器和路由代码不需要改动 - 错误处理:分三层(参数验证失败、执行异常、超时),都以结构化结果返回给 LLM,不会中断 Agent 循环
- 超时控制:
Promise.race控制等待时间,AbortSignal控制实际取消,两层配合使用 - 并发执行:
Promise.all并发调用多个工具,执行器保证每个调用不会 reject,避免一个失败拖垮全部
工具执行器在 Agent 系统里的位置,类似于后端服务里的 Service 层——它不决定做什么(那是 LLM 的事),它只负责把 LLM 的决策安全、可靠地执行掉,然后把结果交回去。