logo

Tool Calling 的可靠性:Schema、重试与幂等

Published on

Tool Calling 的可靠性:Schema、重试与幂等

第一次让模型调用工具,体验通常很好。你给它一个天气函数,写清楚 city 参数,它就能生成一段调用请求;服务端返回结果,模型再把天气说给用户听。整个过程像变魔术,几行代码就让聊天应用拥有了行动能力。

然后你把工具换成真实业务:创建订单、发邮件、退款、发布代码。魔术很快变成事故演练。模型少传了一个字段,工具重试了两次,第一次请求其实已经成功但响应在网络中丢了,第二次重试又扣了一次库存。最后大家会发现,Tool Calling 的核心问题不是“模型能不能生成 JSON”,而是“一个不可靠的调用者,怎样被放进一个必须可靠的业务系统”。

这篇文章是我反复看调用日志后留下的结论:Schema 负责约束形状,执行层负责验证语义,重试负责处理暂时性故障,幂等负责抵挡重复动作,审批负责守住高风险边界。少了任何一层,系统都可能在看似正常的时候悄悄出错。

一、工具描述不是实现,Schema 也不是护身符

工具描述至少要说清楚四件事:它做什么、什么时候使用、输入是什么、会不会产生副作用。不要只写一个函数名和一句“调用接口”,模型需要知道能力边界。

const createRefundTool = {
  name: 'create_refund',
  description: [
    '为已支付且符合政策的订单创建退款申请。',
    '这是有副作用的操作,执行前必须获得用户确认。',
    '不要用于查询退款状态,也不要重复提交同一订单。',
  ].join(' '),
  inputSchema: {
    type: 'object',
    additionalProperties: false,
    required: ['orderId', 'amount', 'reason'],
    properties: {
      orderId: { type: 'string', pattern: '^ORD-[0-9]+$' },
      amount: { type: 'number', exclusiveMinimum: 0 },
      reason: { type: 'string', minLength: 2, maxLength: 200 },
    },
  },
}

additionalProperties: false 很有价值,它可以挡住模型随手增加的字段。但 Schema 只能证明“字段长得像对的”,不能证明“业务上真的允许”。订单存在不代表当前用户能操作,退款金额是数字也不代表没有超过可退余额。因此参数至少要经过三道检查:格式检查、权限检查、业务状态检查。

二、把调用拆成准备、确认、执行三个阶段

我不建议收到模型的 tool_call 后直接执行。更清楚的流程是:

模型提出调用
解析与 Schema 校验
权限和业务预检查
生成执行预览
用户确认(高风险操作)
带幂等键执行
记录结果并返回模型

查询天气可以跳过人工确认,发邮件、删除文件和付款则不应该跳过。预览阶段应告诉用户将要对哪个资源做什么、影响范围多大、预计产生什么后果。确认不是一句“好的”,而应该与具体的操作意图绑定,避免用户确认了一个模糊计划,系统却执行了另一件事。

准备阶段还可以把模型的自然语言参数转换为内部类型。例如模型传来“下周一”,不要直接写入数据库;先根据用户时区解析出明确的时间,再把解析结果展示给用户或交给业务规则验证。机器系统不应该把含糊的人话一路传到底层。

三、错误分类决定重试策略

最糟糕的重试逻辑是:只要抛异常,就再调用一次。因为不同错误的含义完全不同。

type ToolErrorCode =
  | 'INVALID_ARGUMENT'
  | 'PERMISSION_DENIED'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'RATE_LIMITED'
  | 'TIMEOUT'
  | 'UPSTREAM_UNAVAILABLE'
  | 'UNKNOWN'

type ToolError = {
  code: ToolErrorCode
  message: string
  retryable: boolean
  requestId: string
}

参数错误、权限拒绝和资源不存在,重试没有意义,应该把可理解的错误交回模型,让它修正参数或向用户提问。限流、超时和上游暂时不可用,通常可以重试,但要使用指数退避和随机抖动,避免一群请求同时再次撞向故障服务。

冲突错误更微妙。两个 Agent 同时修改同一份资料时,简单重试可能覆盖别人的更新;这时应重新读取最新版本、重新判断,或者直接交给人工。未知错误也不应该默认可重试,先记录证据再决定。

function shouldRetry(error: ToolError, attempt: number) {
  const retryable = new Set<ToolErrorCode>([
    'RATE_LIMITED',
    'TIMEOUT',
    'UPSTREAM_UNAVAILABLE',
  ])

  return error.retryable && retryable.has(error.code) && attempt < 3
}

function backoffMs(attempt: number) {
  const base = Math.min(1000 * 2 ** attempt, 8000)
  return base + Math.floor(Math.random() * 300)
}

重试次数也应该是预算的一部分。一个 Agent 循环里可能调用多个工具,如果每个工具都允许重试三次,整体请求的最坏成本会迅速膨胀。除了单工具上限,还要有任务级的总调用次数、总 Token 和总时间限制。

四、幂等是副作用工具的生命线

网络世界里,“没有收到响应”不等于“服务没有执行”。请求可能已经写入数据库,只是在返回途中超时。此时重试会遇到一个危险问题:到底是继续,还是可能重复?

解决办法是让每次业务意图带一个稳定的幂等键:

type ExecuteContext = {
  requestId: string
  idempotencyKey: string
  userId: string
}

async function createRefund(
  input: { orderId: string; amount: number; reason: string },
  context: ExecuteContext,
) {
  const previous = await refundStore.findByIdempotencyKey(
    context.idempotencyKey,
  )
  if (previous) return previous.result

  const result = await database.transaction(async (tx) => {
    const order = await tx.orders.lockAndGet(input.orderId)
    if (!order || order.status !== 'PAID') {
      throw toolError('CONFLICT', '订单当前不可退款')
    }

    const refund = await tx.refunds.insert({
      ...input,
      userId: context.userId,
      idempotencyKey: context.idempotencyKey,
    })
    return refund
  })

  await refundStore.saveResult(context.idempotencyKey, result)
  return result
}

实际系统里,幂等键应在数据库中建立唯一约束,并且“检查旧结果”和“写入新结果”要处于可靠的事务或原子操作中。只在应用内存里放一个 Set 不算幂等,服务重启、水平扩展或并发请求都会让它失效。

幂等键的生命周期也要想清楚。它不能只用用户 ID,因为同一个用户会有多次合法操作;也不能每次重试都随机生成,否则服务端无法识别重复。通常可以由一次用户意图、一次审批记录或一次任务步骤生成,并在整个重试周期内保持不变。

五、模型错误与系统错误要分开记录

线上排查 Tool Calling,最怕看到一条笼统日志:“工具调用失败”。我们至少要知道:模型生成了什么、Schema 哪里不对、权限检查结果怎样、业务状态是什么、实际请求是否发出、上游返回了什么,以及最后一次重试用了哪个幂等键。

可以为每次调用记录结构化事件:

type ToolTrace = {
  traceId: string
  toolName: string
  modelCallId: string
  inputHash: string
  validation: 'passed' | 'failed'
  approval: 'not_required' | 'pending' | 'approved' | 'rejected'
  attempts: number
  idempotencyKey?: string
  outcome: 'succeeded' | 'rejected' | 'timed_out' | 'failed'
  latencyMs: number
}

敏感参数不能不加思考地原文落日志,订单号、邮箱、访问令牌和用户输入都应按场景脱敏。排障需要的是可关联的证据,不是把生产数据复制到所有人的日志平台。

还要区分两类失败:模型提出了错误工具或错误参数,这是模型行为问题;工具已经收到合法请求但上游不可用,这是基础设施问题。两个问题都显示为“最终回答失败”,但修复路径完全不同。没有这层区分,团队很容易花时间调 Prompt,实际上坏的是数据库连接池。

六、返回给模型的错误要能帮助它做下一步

错误消息不是给开发者看的堆栈,也不是一句“系统异常”。如果错误可以恢复,就应该告诉模型下一步能做什么;如果不可恢复,就明确停止。

{
  "isError": true,
  "error": {
    "code": "PERMISSION_DENIED",
    "message": "你无权操作该订单",
    "nextAction": "ask_user_for_authorized_order"
  }
}

当然,nextAction 也不能让模型绕过权限。它只是帮助编排层理解状态,真正的权限判断仍然必须由服务端完成。返回错误时不要把内部 SQL、令牌或堆栈暴露出去;给模型的上下文应足够行动,但不应扩大泄露面。

对于部分成功的批量操作,不能只返回“失败”。例如十个文件中八个已经处理成功,结果应包含每个项目的状态,后续重试只处理未完成的两个。把部分成功压扁成一个布尔值,会迫使 Agent 重做全部工作,也会制造重复副作用。

七、什么时候应该拒绝让模型调用

有些场景不是加一层重试就能解决的。以下情况我会直接让执行层拒绝自动调用:请求目标不明确、权限无法确认、金额或影响范围超过阈值、操作不可逆、输入来自不可信网页且包含指令、或者调用链已经超出任务预算。

拒绝不是系统无能,而是可靠性的一部分。Agent 的价值不是永远说“可以”,而是知道什么时候应该停下来。对高风险动作,人工确认、双人审批或离线队列都比一次“模型看起来很确定”的调用更合适。

八、我的总结:让模型负责提议,让系统负责承诺

研究 Tool Calling 一段时间后,我越来越不愿意把模型输出叫作“执行命令”。它更像一次提议:模型根据上下文提出“我认为应该调用这个能力,并传入这些参数”。真正的承诺——扣款、写库、发信、删除、发布——必须由确定性的执行层作出。

Schema 让提议有形状,业务校验让提议符合现实,审批让用户知道后果,幂等让重复不至于造成灾难,错误分类和重试让暂时性故障能够恢复,Trace 则让我们在出错后知道发生了什么。

如果你的 Tool Calling Demo 只有“模型生成 JSON,函数执行,结果返回”三步,它当然可以作为起点,但不要把它误认为生产方案。真正成熟的系统,会认真处理那些最不戏剧化、却最容易出事故的时刻:响应丢了、请求重复了、权限变了、库存被别人抢先改了、模型把不确定的日期当成了确定日期。

把这些边界一层层补上,工具调用才不只是让模型“会做事”,而是让它在真实系统里以可控、可追踪、可恢复的方式做事。这才是 Agent 工程真正值得学习的地方。

🤪 您也可以编辑此页: