🚀🚀【封闭结束4】Agent 工具调用工程化:把 Tool Runtime 做扎实

前言 :封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可。尽量用几篇文章讲清,这篇文章是系列第4篇:模型说出「要调哪个工具」之后,真正决定稳不稳的,是参数对不对、有没有权限、同一件事会不会执行两遍、结果怎么安全写回对话------这一层叫 Tool Runtime(工具执行层)。

用户说:"查一下生产环境 payment-api 最近半小时的错误日志。"

上一篇把循环怎么转、怎么停讲清楚了。这篇盯住:模型已经说出要调某个工具之后,你的代码怎么把这次工具调用做稳。

接上 Function Calling(模型按约定格式说出调哪个工具、带什么参数),还不等于有了工具系统。模型输出一个工具名和一段 JSON 参数之后,麻烦才开始:

  1. 参数合不合法;
  2. 有没有权限;
  3. 能不能一起跑;
  4. 超时了能不能重试;
  5. 结果怎么写回下一轮对话。

很多线上事故不是模型不会选工具,而是「真去跑工具」那一层缺关键步骤:

  • 模型给的参数几乎不检查,就直接拿去调业务接口;
  • 重启、创建这类写操作一超时或失败,不管对面成没成又立刻再调一次(容易执行两遍);
  • 把报错堆栈原文写回 Prompt;
  • 把用户原文里的「我是管理员」当成已鉴权。

继续用案例 OpsPilot(企业运维助手,虚构案例),技术栈仍是 TypeScript + Zod。下面按一次调用的顺序写:注册、约定参数、校验、鉴权、并行还是串行、超时重试、结果写进对话。

先分清两件事(重要)

模型说出要调哪个工具 ,和代码真去跑这个工具,是两回事:

text 复制代码
Function Calling / Tool Use
  → 模型决定「要不要调、调哪个、参数是什么」(还没真执行)

Tool Runtime(工具执行层)
  → 应用决定「允不允许调、怎么调、失败怎么办、结果怎么写回对话」

模型负责说出要调哪个工具,执行层负责把关。这个服务是不是当前租户的、这个用户有没有权限,都要你自己验。LangChain 的 bind_tools / @tool 主要解决「怎么让模型看见工具」;鉴权、防重复执行、写操作串行、结果去掉敏感信息,仍要你自己的 Tool Runtime 做。

1、每次工具调用,建议按照下面固定几步,事后能看出卡在哪:

text 复制代码
按名字找到工具
  → 解析模型参数
  → 先按表校验,再做业务校验
  → 用登录态鉴权
  → 高风险?先让人确认
  → 执行(超时 / 取消 / 沙箱)
  → 整理结果(结构化 + 去掉敏感信息)
  → 写进对话 / 推前端 / 记审计

2、对应一条调用记录:

ts 复制代码
// 一次工具调用的落库/日志记录,方便事后排查卡在哪一步
type ToolInvocation = {
  invocationId: string       // 你们自己生成的记录 id,跟数据库行对应
  callId: string             // 模型那次「要调工具」自带的 id;写回对话时必须原样带回,少一条会 400
  toolName: string           // 调的是哪个工具,如 query_service_logs
  status:                    // 当前走到哪:校验过了?被拒了?还在跑?
    | 'validated'            // 参数校验通过,还没真正执行
    | 'denied'               // 没权限,被拦住了
    | 'running'              // 正在调下游
    | 'succeeded'            // 成功
    | 'failed'               // 失败(业务错、下游错等)
    | 'timed_out'            // 超时
    | 'canceled'             // 用户点了停止
  args: unknown              // 校验通过后的参数(可以落库)
  result?: unknown           // 成功时的结果;太大就截断或只存摘要
  errorCode?: string         // 失败时的错误码,如 NOT_FOUND,方便分类、别只存一句话
  startedAt?: string         // 开始时间
  endedAt?: string           // 结束时间
  durationMs?: number        // 耗了多少毫秒
}

注册与参数约定

工具集中注册,别散落 if

把工具登记在一张表里,别在 Agent 循环里到处写 if (name === 'xxx')。

ts 复制代码
import { z } from 'zod'

// 跑工具时带上的上下文:身份从登录态来,不从模型参数里读
type ToolContext = {
  auth: { userId: string; tenantId: string; roles: string[] } // 当前登录用户/租户
  conversationId: string     // 哪个会话
  requestId: string          // 哪一次请求,写审计、做幂等键时用
  abortSignal: AbortSignal   // 用户点「停止」时,靠它打断正在跑的工具
}

// 一件工具:给模型看的说明 + 给程序用的规矩 + 真正执行函数
type ToolDefinition<TArgs, TResult> = {
  name: string               // 工具名,全局唯一,模型靠这个点名
  description: string        // 写给模型看:什么时候该用、什么时候别用
  inputSchema: z.ZodType<TArgs> // 参数长什么样(Zod);执行前要 parse 一遍
  outputSchema?: z.ZodType<TResult> // 可选:返回值长什么样,方便自测
  sideEffect: 'read' | 'write' // read=只查;write=会改数据(影响能不能一起跑、要不要审批)
  parallelizable: boolean    // true=可以和别的只读工具同时跑;false=必须单独跑
  timeoutMs: number          // 这一次最多等多久(毫秒)
  retry: {
    maxAttempts: number      // 最多试几次(含第一次)
    retryableErrors: string[] // 只有列在这里的错误码才允许再试,别见错就重试
  }
  permission: string         // 需要什么权限,如 'logs:read'
  requiresApproval?: boolean // true=先弹确认给用户点,再执行
  execute: (args: TArgs, ctx: ToolContext) => Promise<TResult> // 真正调业务 API
}

const registry = new Map<string, ToolDefinition<any, any>>() // 名字 → 工具定义

function registerTool<TArgs, TResult>(tool: ToolDefinition<TArgs, TResult>) {
  if (registry.has(tool.name)) {
    // 同名再注册会悄悄盖掉旧实现,线上很难查;直接抛错更安全
    throw new Error(`duplicate tool: ${tool.name}`)
  }
  registry.set(tool.name, tool)
}

注册时,顺便生成给模型看的 tools 列表。description 写清楚何时用、何时不用,比在主 Prompt 里再抄一遍更管用(强烈建议):

ts 复制代码
// 示例:注册一个「查服务日志」的只读工具
registerTool({
  name: 'query_service_logs',
  description:
    '查询指定服务在某环境、某时间窗内的日志。仅用于实时排障,不用于解释"日志功能是什么"。',
  inputSchema: z.object({
    serviceId: z.string().min(1), // 服务 id,不能空
    environment: z.enum(['development', 'testing', 'production']), // 只能三选一
    timeRange: z.enum(['15m', '30m', '1h', '6h']).default('30m'), // 默认最近 30 分钟
    level: z.enum(['error', 'warn', 'info']).optional() // 可选:按级别过滤
  }),
  sideEffect: 'read',        // 只查,不改数据
  parallelizable: true,      // 可以和其他只读工具一起跑
  timeoutMs: 8_000,          // 建议起点 8 秒,按你们下游接口自测再调
  retry: {
    maxAttempts: 2,          // 算上第一次,最多 2 次
    retryableErrors: ['TIMEOUT', 'DEPENDENCY_UNAVAILABLE', 'RATE_LIMITED'] // 只有超时/下游不可用/被限流才重试
  },
  permission: 'logs:read',   // 没这个权限就别执行
  async execute(args, ctx) {
    // 租户只信登录上下文;就算模型参数里塞了 tenantId,也以 ctx 为准
    return logService.query({ ...args, tenantId: ctx.auth.tenantId })
  }
})

为什么呢?因为工具一多,把几百个工具的说明每次都放进 Prompt------又贵,模型也不容易选对。常见做法是两档:

  • 每轮都带上的:查日志、查资源这类天天用的,固定给模型看十几个就够(具体个数按场景自测)。
  • 偶尔才用的:比如冷门的导出报表、某条历史排障脚本。平时不放进 Prompt;真要用时,先用一个搜工具的小步骤按名字/描述找到它,再把这份工具的完整说明临时加进去,让模型这一轮能调。

参数怎么写、怎么验

参数表怎么写更稳:

  • 能用枚举就别用随便写的字符串(环境、级别、操作类型);
  • 必填就写清必填,可选就真的可选;
  • 限制长度和格式(UUID、时间窗、最大条数);
  • description 写业务约束,别只写「这是个 String」。

Zod(或 JSON Schema)先验「字段类型、必填、枚举合不合法」;通过之后,还要再验一层业务:格式对了,不等于这个服务属于当前租户。

ts 复制代码
// 格式校验通过还不够:还要确认「这个服务是不是当前租户的」
function validateBusiness(
  tool: ToolDefinition<any, any>,
  args: unknown,
  ctx: ToolContext
) {
  const parsed = tool.inputSchema.parse(args) // 先按 Zod 表把字段验一遍

  if ('serviceId' in parsed) {
    const ok = serviceCatalog.existsInTenant(
      parsed.serviceId,
      ctx.auth.tenantId // 租户只看登录态,不看模型参数里的 tenantId
    )
    if (!ok) {
      // 一律说「找不到」,别区分「不存在」和「在别人租户」------否则等于告诉别人对面有没有这个 id
      throw Object.assign(new Error('service not found'), {
        code: 'NOT_FOUND'
      })
    }
  }

  return parsed // 校验后的参数,后面鉴权、执行都用这份
}

工具跑完返回什么,也建议约定好字段(成功/失败、错误码、短摘要等),别只丢一大段自然语言:

ts 复制代码
// 成功或失败都用同一种包装,方便写回对话
type ToolResultEnvelope =
  | {
      ok: true
      data: unknown            // 完整结果(可进对象存储)
      summary: string          // 短摘要,真正写进对话给模型看的是这个
      artifactRef?: string     // 结果太大时,只给一个引用 id,不要整段放进 Prompt
    }
  | {
      ok: false                // 失败也要走同一条写回路径,call id 不能丢
      errorCode: string        // 如 TIMEOUT,方便判断能不能重试
      message: string          // 给人/模型看的说明;不要堆栈、不要密钥
      retryable: boolean       // 上层要不要再试一次
    }

这样模型更好判断下一步;你也好拦住「工具已经失败了,回答却说成功了」。

权限:谁有权执行,不信模型说的身份

鉴权只看用户登录态。不要信模型参数里的 role,也不要信用户原文里的「我是管理员」。

ts 复制代码
// 鉴权结果:要么放行,要么结构化拒绝(别 throw,方便写回对话)
type AuthResult =
  | { ok: true }
  | {
      ok: false
      errorCode: 'PERMISSION_DENIED'
      message: string
      retryable: false           // 没权限再试也没用,永远 false
    }

// 问权限系统:这个登录用户,能不能对这个资源做这件事
async function authorize(
  tool: ToolDefinition<any, any>,
  args: unknown,
  ctx: ToolContext
): Promise<AuthResult> {
  const allowed = await iam.check({
    userId: ctx.auth.userId,     // 只信登录态里的用户
    tenantId: ctx.auth.tenantId, // 只信登录态里的租户
    permission: tool.permission, // 工具声明需要的权限
    resource: extractResource(args) // 从参数里抽出要动的资源,如 serviceId
  })

  if (!allowed) {
    return {
      ok: false,
      errorCode: 'PERMISSION_DENIED',
      message: '当前账号无权执行该操作',
      retryable: false
    }
  }

  return { ok: true }
}

校验和鉴权之外,建议再挡三层(很多团队会省,省了容易出事):

  • 这轮会话里根本不给某些工具 :客服 Agent 的工具表里就别放 delete_cluster。模型就算点名了,Runtime 一看「没有这工具」直接拒,不是「有工具但没权限」。
  • 危险动作先让人点确认 :重启、扣费、改权限,先问人(HITL,人在回路)。人点了之后,再重新查一遍权限 再真跑。LangGraph 的 interrupt 暂停再继续,就是这类。
  • 就算批准了,也要限制执行边界:批准「跑测试」≠ 能读本机家目录、随便上网。权限管「这类事能不能做」;沙箱管「做的时候能碰哪些目录、网络」。

高风险写操作(删、改、扣费)一次只跑一个,并关掉并行 tool calls,避免查询和删除混在同一批里。

并行、重试与幂等

只读可并行,写操作串行

模型一轮可能点名多个工具;能不能一起跑,由你决定。LangChain / LangGraph 的工具节点可以并行,但不替你区分只读 / 写操作。

  • 互不依赖的只读(查监控 + 查日志):可并行。
  • 有先后依赖(先拿到服务 id,再查日志):必须串行。
  • 写操作 / 要审批的 :串行,通常关掉 parallel_tool_calls。
ts 复制代码
async function runToolCalls(
  calls: Array<{ callId: string; name: string; args: unknown }>,
  ctx: ToolContext
) {
  const tools = calls.map(call => {
    const tool = registry.get(call.name)
    if (!tool) {
      throw Object.assign(new Error('unknown tool'), { code: 'UNKNOWN_TOOL' })
    }
    return { call, tool }
  })

  const hasWrite = tools.some(item => item.tool.sideEffect === 'write')
  const allParallelizable = tools.every(item => item.tool.parallelizable)

  // 有写操作或不可并行 → 排队;否则并行
  if (hasWrite || !allParallelizable || tools.length === 1) {
    const results = []
    for (const item of tools) {
      results.push(await invokeOne(item.call, item.tool, ctx))
    }
    return results
  }

  return Promise.all(tools.map(item => invokeOne(item.call, item.tool, ctx)))
}

固定的过滤、聚合、去重,可以用代码合并结果再写回对话,少让模型多跑几轮。写操作和要审批的,仍建议走直接 tool calling,权限留在你这边。

重试与幂等

失败后不要一律「再试三次」。先分清错误,再决定要不要重试;会改数据的操作还要防止重复执行。

哪种错能重试:

错误 重试? 说明
参数错 / 没权限 / 找不到资源 否 再调也解决不了;写回对话,让模型改参数或换查询
超时 / 限流 / 下游暂时不可用 有限次 间隔后再试,不要立刻连续请求
写操作超时、成败不明 否(先查状态) 可能已经成功;确认未成功后再带同一幂等键重试
ts 复制代码
// 按错误类型决定能不能重试
type ErrorClass =
  | 'invalid_arguments'
  | 'permission_denied'
  | 'not_found'
  | 'timeout'
  | 'rate_limited'
  | 'dependency_unavailable'
  | 'side_effect_unknown' // 写操作超时、成败未知
  | 'execution_failed'

function classifyError(err: unknown): { code: ErrorClass; retryable: boolean } {
  // 按你们自己的错误码映射即可
  ...
}

幂等键: 给会改数据的那次操作一个固定编号。下游收到相同编号两次,只执行一次。

举例:重启 API 等了 30 秒没返回,对面可能已经重启成功,只是响应丢了。再调一次就可能重启两次。做法:

  1. 第一次就把幂等键传到真正执行的 API(只记在 Agent 内存里不够)。
  2. 超时后先查实例状态;确认没做成,再用同一个幂等键重试。

只读一般不需要幂等键。LangChain 一类的 retry 只负责「再调一次」;幂等和「超时先查状态」要自己接到下游。

执行一个工具时(校验 → 鉴权 → 可选审批 → 执行):

ts 复制代码
async function invokeOne(
  call: { callId: string; name: string; args: unknown },
  tool: ToolDefinition<any, any>,
  ctx: ToolContext
) {
  let attempt = 0
  const idempotencyKey =
    tool.sideEffect === 'write'
      ? `${ctx.requestId}:${call.callId}`
      : undefined

  while (true) {
    attempt += 1
    const signal = AbortSignal.any([
      ctx.abortSignal,
      AbortSignal.timeout(tool.timeoutMs)
    ])

    try {
      const args = validateBusiness(tool, call.args, ctx)
      const auth = await authorize(tool, args, ctx)
      if (!auth.ok) {
        return {
          callId: call.callId,
          ok: false as const,
          errorCode: auth.errorCode,
          message: auth.message,
          retryable: auth.retryable
        }
      }

      if (tool.requiresApproval) {
        await waitForApproval({ tool, args, ctx })
        const again = await authorize(tool, args, ctx) // 确认后再验一次权限
        if (!again.ok) {
          return {
            callId: call.callId,
            ok: false as const,
            errorCode: again.errorCode,
            message: again.message,
            retryable: again.retryable
          }
        }
      }

      const data = await tool.execute(args, {
        ...ctx,
        abortSignal: signal,
        idempotencyKey
      } as any)

      return {
        callId: call.callId,
        ok: true as const,
        summary: summarizeForModel(data),
        data
      }
    } catch (err) {
      const { code, retryable } = classifyError(err)
      const canRetry =
        retryable &&
        attempt < tool.retry.maxAttempts &&
        tool.retry.retryableErrors.includes(code)

      if (!canRetry) {
        return {
          callId: call.callId,
          ok: false as const,
          errorCode: code,
          message: toSafeMessage(err),
          retryable
        }
      }

      await sleep(backoff(attempt))
    }
  }
}

失败时怎么跟用户说:

  • 主查询失败:说明没查到或只查到一部分;不要编造数据。
  • 次要工具失败:仍可回答,但要说清「哪一块没查到」。
  • 全部失败:根据结构化错误说明办不了;不要说「已处理完成」。

结果写进对话,以及统一入口

工具跑完,要把结果写进对话,再交给模型看------不是把终端输出原样贴回去。LangChain 里常见是 ToolMessage(带上原来的 tool_call_id);名字因框架而异,要求一样:每个 id 都要对上一条结果,成功失败都算。

ts 复制代码
// 把执行结果收成一条 role=tool 的消息,写回对话给下一轮模型看
function toModelToolMessage(result: {
  callId: string
  ok: boolean
  summary?: string
  data?: unknown
  errorCode?: string
  message?: string
}) {
  if (!result.ok) {
    return {
      role: 'tool' as const,
      tool_call_id: result.callId, // 必须等于模型那次 tool_call 的 id,对不上接口会 400
      content: JSON.stringify({
        ok: false,
        errorCode: result.errorCode,
        message: result.message
      })
    }
  }

  return {
    role: 'tool' as const,
    tool_call_id: result.callId,
    content: JSON.stringify({
      ok: true,
      summary: result.summary,
      data: trimForModel(result.data), // 裁短、去敏感字段,不要把几万字日志整段放进上下文
      artifactRef: maybeStore(result.data) // 完整大结果存别处,这里只留一个引用 id
    })
  }
}

注意这几条:

  1. 保留 call_id / tool_call_id,和模型那次「要调工具」的 id 对齐。
  2. 控制体积:日志限制条数、只留该留的字段、超长截断;完整结果进对象存储。
  3. 去掉敏感信息:Token、手机号、内部 URL、堆栈,默认不进模型上下文,也不进 SSE(服务端推给前端的事件)。
  4. 失败也要有固定字段 :ok / errorCode / retryable 比一段散文好用,模型和下一步代码都认得。
  5. 前端进度 :用 tool_started / tool_finished 这类业务事件推,不要让 UI 靠解析模型输出文本来猜。

工具失败后,最终回答那一层必须能看到结构化失败。否则最常见的幻觉就是:工具超时了,回答却写「已重启成功」。

执行层做成一个统一入口,主循环只往这里丢:

ts 复制代码
// 主循环只把模型输出的 tool_calls 交给这里:解析 → 未知工具也写失败 → 执行 → 审计 → 全部变成 tool 消息
async function handleModelToolCalls(
  toolCalls: Array<{
    id: string
    name: string
    arguments: string
  }>,
  ctx: ToolContext
) {
  // 模型给的 arguments 常是 JSON 字符串,先解析成对象
  const parsedCalls = toolCalls.map(item => ({
    callId: item.id,
    name: item.name,
    args: JSON.parse(item.arguments)
  }))

  const known: typeof parsedCalls = []
  const earlyFailures = []

  // 每个 call id 都要有一条结果。名字不认识也不能整批丢掉,否则下一轮对不齐
  for (const call of parsedCalls) {
    if (!registry.has(call.name)) {
      earlyFailures.push(
        toModelToolMessage({
          callId: call.callId,
          ok: false,
          errorCode: 'UNKNOWN_TOOL',
          message: `未注册的工具: ${call.name}`
        })
      )
    } else {
      known.push(call)
    }
  }

  const results = known.length ? await runToolCalls(known, ctx) : []

  // 记一笔审计:谁、哪次请求、调了啥、成没成(排障和合规都靠这个)
  for (const result of results) {
    await audit.write({
      requestId: ctx.requestId,
      userId: ctx.auth.userId,
      toolName: known.find(c => c.callId === result.callId)?.name,
      ok: result.ok,
      errorCode: result.ok ? undefined : result.errorCode
    })
  }

  // 未知工具的失败 + 真正执行的结果,一起交回主循环写进对话
  return [
    ...earlyFailures,
    ...results.map(toModelToolMessage)
  ]
}

Agent 主循环只做:

text 复制代码
模型输出
  → 若有 tool_calls:交给执行层
  → 把 tool 消息追加进对话
  → 再调模型
  → 直到最终回答或停下来问用户

别在主循环里写具体调哪个业务 API。工具一多,那是最先烂掉的地方。

怎么测、后面再加什么

先把这些场景盖住:

  • 缺字段 / 多字段 / 枚举写错
  • 跨租户的 serviceId
  • 没权限的用户去调写工具
  • 只读是否真并行、写操作是否被强制串行
  • 超时后:读操作可重试;写操作先确认成没成,再决定要不要再调
  • 同一个幂等键重复调用,不会产生两次副作用
  • 失败结果写进对话后,最终答案不会宣称成功
  • SSE / 日志里看不到敏感原文
  • 未知工具名返回结构化错误,且每个 call id 都有结果

第一阶段把上面跑通就够用。后面按需加:

  • 工具版本怎么兼容(旧会话不因新参数挂掉)
  • 按角色裁剪可见工具列表
  • 只读结果缓存(键里要带租户和权限)
  • 用代码合并多次只读结果,少烧几轮 Token
  • 更细的沙箱(文件、出网、凭证)

小结

就这三条:

  1. 权限看登录用户,不看模型怎么说。 用户自称「我是管理员」、模型参数里带了 role,都不能当真;能不能调工具,只认登录态里的身份。
  2. 会改数据的操作,要带固定编号(幂等键),并且分清哪种错能重试。 参数错、没权限 → 不要再试;超时不知道成没成 → 先查状态,不要盲目再调一次,否则可能执行两次。
  3. 写回给模型的,是裁短、去掉敏感信息后的结果。 不要把报错堆栈、Token、手机号整段放进 Prompt;失败也要写成固定字段,不要让模型在工具已经失败时还说「已成功」。

Function Calling 解决「模型怎么调用工具」;能不能上生产,看执行层:注册、校验、鉴权、失败分类、安全写回对话。

下一篇继续:《Agent 多轮对话:SSE 与 HITL 中断恢复》。

希望这篇文章对你有所帮助、有所借鉴。

相关推荐
jerrywus3 小时前
chrome-devtools-mcp 总弹授权、抢标签页?我是这样一步步配好的
前端·agent·mcp
浪潮BB机3 小时前
钉钉闪记后续AI处理太弱?2026主流智能工具实测横评
人工智能·钉钉·语音识别·效率工具·ai工具·录音转写·会议纪要
清华kenny3 小时前
中医馆如何参考安徽亳州中医AI健康辨识仪品牌排名前十
大数据·人工智能·安徽晓得养
Jsundoku3 小时前
第七章 机器学习 -- 其他经典算法
人工智能·算法·机器学习
会编程的吕洞宾3 小时前
LangChain4j 流式输出实战:让 AI 回复像打字机一样逐字显示
java·开发语言·人工智能·后端
不脱发的程序猿3 小时前
红利ETF量化策略
人工智能·python·ai·程序员·量化交易·etf·策略回测
鬓戈3 小时前
llmproxy 系统开发目的和基本功能
人工智能·python
武子康3 小时前
CUDA OOM 不只是“显存不够”
人工智能·后端·agent
见叶之秋3 小时前
【C++】红与黑的边界:红黑树从插入到自检
数据结构·c++·算法·面试