跳到主要内容

工具调用一多,日志和幂等为什么先崩

· 阅读需 6 分钟
一介布衣
全栈开发者 / 技术写作者

补档说明:本文属于「AI 工程落地周记」系列,计划发布时间为 2025-03-18 11:40。当前先保留为草稿,后续补充真实案例、代码片段和复盘细节后再发布。

如果只允许我用一个事故来解释这篇文章,我会选“重复建单”。

场景很简单:我们做了一个退款助手,模型拿到用户问题后,会先调 searchOrder 确认订单状态,再调 createRefundTicket 创建退款工单,最后调 notifyAgent 给人工客服发提醒。这条链路在演示时很顺,因为每次只跑一单,也很少超时。

真正的问题发生在灰度流量上来以后。有一类请求会卡在 createRefundTicket 这一步,工具其实已经成功落库,但响应在网关层超时了。系统把这次调用当成失败,又自动重试了一次。于是第二张退款工单被建出来了。

最糟糕的地方在于,当时我们第一眼根本看不出来问题在哪。业务方看到的是“怎么有重复工单”,模型侧看到的是“工具偶尔失败”,后端看到的是“数据库写入成功”。如果没有一条完整的 trace,这个问题会像鬼一样,到处出现,但没人知道是谁干的。

如果没有 trace,这个问题会怎么看都像“偶发异常”

先看没有 trace 时你能看到什么。

业务同学看到:

  • 用户申请了一次退款
  • 系统里却出现了两张工单

模型侧看到:

  • 第一步查订单成功
  • 第二步创建工单超时

数据库侧看到:

  • 第一张工单在 11:40:03 成功写入
  • 第二张工单在 11:40:08 再次写入

如果这三段信息彼此没串起来,团队很容易得出三个完全不同的解释:

  • “模型乱调工具”
  • “数据库幂等没做好”
  • “超时配置太激进”

而真相其实是三者共同作用。

这类事故为什么总是先从日志开始失控

因为传统日志默认是一跳一条,而工具链是多跳多状态。

在这条退款链路里,我们后来补出来的最小 trace 长这样:

{
"requestId": "req_9f2c",
"sessionId": "sess_8841",
"promptVersion": "refund-agent-v3",
"steps": [
{
"toolCallId": "tc_001",
"tool": "searchOrder",
"status": "success",
"latencyMs": 182
},
{
"toolCallId": "tc_002",
"tool": "createRefundTicket",
"status": "timeout",
"latencyMs": 3001,
"serverAccepted": true,
"idempotencyKey": "refund:req_9f2c:order_1288"
},
{
"toolCallId": "tc_003",
"tool": "createRefundTicket",
"status": "success",
"latencyMs": 241,
"retryOf": "tc_002",
"idempotencyKey": "refund:req_9f2c:order_1288"
}
]
}

这段 trace 一出来,问题就不再抽象:

  • 第一次写单其实服务端已经接受了
  • 网关因为超时没拿到确认
  • 第二次重试复用了同一业务意图
  • 但下游没有真正按幂等键拒绝重复写入

这就是为什么我一直说,工具调用一多,日志不是“辅助材料”,而是定位系统问题的唯一公共语言。

日志为什么必须至少拆成三层

经过这类事故之后,我现在几乎是强迫要求三层日志同时存在。

1. 请求层

负责告诉你这是谁发起的一次什么任务:

  • requestId
  • userId
  • sessionId
  • promptVersion
  • 最终状态

2. 工具层

负责告诉你每次调用具体发生了什么:

  • toolCallId
  • 工具名
  • 入参摘要
  • 返回结果摘要
  • 耗时
  • 错误类型

3. 流程层

负责告诉你工具调用为什么会发生:

  • 这是第几步
  • 是正常执行还是重试
  • 有没有 fallback
  • 有没有转人工

只要少一层,排查时就一定会出现盲区。

幂等真正难的不是“去重”,而是“确认边界”

很多人谈幂等时,会直接想到“加一个幂等键”。这当然没错,但实际最难的是先回答清楚:哪一步算成功,谁负责确认成功。

拿这次事故来说,真正麻烦的点不是“有没有 key”,而是:

  • 下游服务在超时前已经写成功了
  • 上游却拿不到成功确认

也就是说,系统面对的是“已执行但未确认”,而不是“根本没执行”。这两种情况如果混为一谈,重试就特别容易出事故。

所以我现在会把写操作分成三类:

1. 纯查询型

查订单、查库存、查文档。重点是缓存和限流,不是幂等。

2. 可覆盖写入型

比如把状态更新成某个确定值。重复执行通常相对可接受。

3. 创建 / 触发型

建单、发消息、发起审批、扣费。这类必须把幂等键、确认机制和补偿策略一起设计。

最危险的永远是第三类。

一个更靠谱的幂等实现应该怎么想

后来我们把这条链路改成:

  1. 在请求入口生成业务级幂等键
  2. 工具执行前先查是否已有结果
  3. 如果是“已执行但未确认”,优先查回执,不直接重复写
  4. 重试必须带上同一幂等键

对应代码大致像这样:

async function createRefundTicketGuarded({requestId, orderId, payload}) {
const idempotencyKey = `refund:${requestId}:${orderId}`

const existing = await ticketRepo.findByIdempotencyKey(idempotencyKey)
if (existing) {
return {source: 'deduplicated', ticketId: existing.id}
}

const accepted = await ticketRepo.create({
...payload,
idempotencyKey,
})

return {source: 'created', ticketId: accepted.id}
}

这段代码本身不复杂,但它背后的前提很重要:你得先承认写操作不是“调一下工具”这么简单,而是一个需要被唯一标识的业务动作。

我现在会强制团队回答的 5 个问题

只要一个工具可能产生副作用,我现在都会在评审里问:

  1. 这次动作的业务唯一键是什么?
  2. 上游超时但下游成功时怎么办?
  3. 同一请求重试时,是否还能重复写?
  4. trace 能不能把每一步串起来?
  5. 出现重复副作用后,是否有补偿路径?

只要这 5 个问题答不上来,我就默认这条自动化链路还不够上线。

总结

工具调用一多,日志和幂等之所以先崩,不是因为团队粗心,而是因为多工具系统天然会把“谁调用了什么、是否真的成功、重试会不会重复执行”这些问题同时放大。

真正的分水岭在于,你是把工具调用当成一个普通接口,还是把它当成一个有状态、有副作用、需要被追踪的业务动作。前者能跑 Demo,后者才更接近生产。