工具调用一多,日志和幂等为什么先崩
补档说明:本文属于「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. 请求层
负责告诉你这是谁发起的一次什么任务:
requestIduserIdsessionIdpromptVersion- 最终状态
2. 工具层
负责告诉你每次调用具体发生了什么:
toolCallId- 工具名
- 入参摘要
- 返回结果摘要
- 耗时
- 错误类型
3. 流程层
负责告诉你工具调用为什么会发生:
- 这是第几步
- 是正常执行还是重试
- 有没有 fallback
- 有没有转人工
只要少一层,排查时就一定会出现盲区。
幂等真正难的不是“去重”,而是“确认边界”
很多人谈幂等时,会直接想到“加一个幂等键”。这当然没错,但实际最难的是先回答清楚:哪一步算成功,谁负责确认成功。
拿这次事故来说,真正麻烦的点不是“有没有 key”,而是:
- 下游服务在超时前已经写成功了
- 上游却拿不到成功确认
也就是说,系统面对的是“已执行但未确认”,而不是“根本没执行”。这两种情况如果混为一谈,重试就特别容易出事故。
所以我现在会把写操作分成三类:
1. 纯查询型
查订单、查库存、查文档。重点是缓存和限流,不是幂等。
2. 可覆盖写入型
比如把状态更新成某个确定值。重复执行通常相对可接受。
3. 创建 / 触发型
建单、发消息、发起审批、扣费。这类必须把幂等键、确认机制和补偿策略一起设计。
最危险的永远是第三类。
一个更靠谱的幂等实现应该怎么想
后来我们把这条链路改成:
- 在请求入口生成业务级幂等键
- 工具执行前先查是否已有结果
- 如果是“已执行但未确认”,优先查回执,不直接重复写
- 重试必须带上同一幂等键
对应代码大致像这样:
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 个问题
只要一个工具可能产生副作用,我现在都会在评审里问:
- 这次动作的业务唯一键是什么?
- 上游超时但下游成功时怎么办?
- 同一请求重试时,是否还能重复写?
- trace 能不能把每一步串起来?
- 出现重复副作用后,是否有补偿路径?
只要这 5 个问题答不上来,我就默认这条自动化链路还不够上线。
总结
工具调用一多,日志和幂等之所以先崩,不是因为团队粗心,而是因为多工具系统天然会把“谁调用了什么、是否真的成功、重试会不会重复执行”这些问题同时放大。
真正的分水岭在于,你是把工具调用当成一个普通接口,还是把它当成一个有状态、有副作用、需要被追踪的业务动作。前者能跑 Demo,后者才更接近生产。
