LLM 并发工具调用的 5 个生产陷阱:幂等性、竞态与失败补偿

你的 Agent 会「双倍退款」吗?并发 Tool Call 让一切隐藏的耦合立刻暴露。


引言:那次凌晨的退款事故

一个客户支持 Agent 在生产跑了三个月,eval 分数不错,trace 看起来干净。然后某天,一位用户的退款被处理了三次。五次。

排查后发现:Agent 在调用 process_refund 时遇到网络超时,框架层重试了一次,Agent 自己也重试了一次。没有幂等键。每次重试都变成了真实退款。

这不是模型幻觉。这是 LLM Tool Calling 的工程问题:当你把工具调用当成普通函数,而不是有副作用的分布式操作时,分布式系统几十年踩过的坑,全都在你的 Agent 里重新踩一遍。

本文聚焦于并发(Parallel Tool Calls)场景下的五个核心生产陷阱,以及对应的工程解法。每个陷阱都附可运行代码。


陷阱 1:你以为并发在跑,其实还是串行

问题

模型确实在一次响应里返回了多个 tool_use block,但你的执行层还是一个个顺序调用。

这是最常见的「并发优化」误区。LLMCompiler 的 benchmark 显示,正确实现并发调用可以带来 1.4x~3.7x 的延迟降低。但如果执行层没跟上,这个收益完全消失。

根本原因

模型「请求」并发,框架决定是否真正并发执行。很多 LangChain 的早期版本默认串行处理 tool_calls 列表:

python 复制代码
# ❌ 串行执行------latency = sum(all tool times)
for tool_call in message.tool_calls:
    result = tool_executor.invoke(tool_call)
    results.append(result)

正确实现

python 复制代码
import asyncio
from typing import Any

async def execute_tool_calls_parallel(
    tool_calls: list[dict],
    tool_registry: dict[str, callable]
) -> list[Any]:
    """并发执行所有工具调用,latency = max(single tool time)"""
    
    async def run_single(tc: dict) -> dict:
        tool_fn = tool_registry[tc["name"]]
        try:
            result = await asyncio.to_thread(tool_fn, **tc["input"])
            return {"tool_use_id": tc["id"], "content": str(result), "is_error": False}
        except Exception as e:
            return {"tool_use_id": tc["id"], "content": str(e), "is_error": True}
    
    tasks = [run_single(tc) for tc in tool_calls]
    return await asyncio.gather(*tasks, return_exceptions=False)

# 调用方
tool_results = await execute_tool_calls_parallel(
    message.tool_calls,
    tool_registry={"search_web": search_web, "query_db": query_db}
)

注意事项

部分海外大模型厂商的 API 提供 parallel_tool_calls 参数,默认为 true,但某些推理系列模型会直接返回 400 错误如果你显式设置这个参数。国内主流模型(DeepSeek、Qwen)在多 tool_call 响应格式上同样存在差异。路由多个模型时,执行层必须能处理多 tool_call 响应,无论你有没有主动开启并发。


陷阱 2:并发暴露了你从不知道存在的顺序依赖

问题

串行执行天然强制执行了工具之间的顺序。切换到并发后,原来隐藏的依赖立刻破坏系统。

典型场景:

ini 复制代码
顺序执行时:
  1. create_order(items=[...])         → order_id = "ord_789"
  2. update_inventory(order_id="ord_789") → OK
  3. send_confirmation(order_id="ord_789") → OK

并发执行时:
  [create_order, update_inventory, send_confirmation] 同时跑
  → update_inventory 和 send_confirmation 在 order_id 存在前就执行
  → 静默失败,或者引用到过期数据

检测方法

在启用并发前,用以下方式做「依赖审计」:

python 复制代码
def audit_tool_dependencies(tool_calls: list[dict]) -> dict:
    """
    检测工具调用之间是否存在数据依赖。
    规则:如果 tool B 的参数中引用了 tool A 输出的字段名,则 B 依赖 A。
    """
    output_fields: dict[str, str] = {}  # field_name -> tool_name
    dependencies: dict[str, list[str]] = {}
    
    # 简化版:通过 schema 注解声明输出字段
    for tc in tool_calls:
        tool_name = tc["name"]
        schema = TOOL_SCHEMAS.get(tool_name, {})
        
        # 检查本工具的输入是否依赖前一个工具的输出
        deps = []
        for param, value in tc.get("input", {}).items():
            if isinstance(value, str) and value.startswith("$ref:"):
                source_tool = value.split(":")[1]
                deps.append(source_tool)
        
        dependencies[tool_name] = deps
        
        # 注册本工具的输出字段
        for out_field in schema.get("output_fields", []):
            output_fields[out_field] = tool_name
    
    return dependencies

# 实际决策:有依赖的顺序跑,无依赖的并发跑
def build_execution_plan(tool_calls: list[dict]) -> list[list[dict]]:
    """返回执行阶段列表,同一阶段内可并发"""
    deps = audit_tool_dependencies(tool_calls)
    
    # 拓扑排序(简化版)
    stages: list[list[dict]] = []
    completed = set()
    remaining = list(tool_calls)
    
    while remaining:
        ready = [tc for tc in remaining 
                 if all(dep in completed for dep in deps.get(tc["name"], []))]
        if not ready:
            raise ValueError("Circular dependency detected in tool calls")
        stages.append(ready)
        completed.update(tc["name"] for tc in ready)
        remaining = [tc for tc in remaining if tc not in ready]
    
    return stages

实际经验

tianpan.co 的分析指出:「并发工具调用不是性能特性,它是一次强制的架构审计。」启用并发的第一周,几乎所有团队都会发现自己从没意识到的隐式依赖。


陷阱 3:没有幂等键,重试即双倍副作用

问题

Agent 框架在 LLM 层重试(模型认为工具失败了,再调用一次),执行层也在网络层重试(HTTP 超时后再发一次请求)。两层重试叠加,任何非幂等的写操作都会执行多次。

实测数据 :在 P99 延迟 > 500ms 的工具调用场景下,约 3~8% 的请求会触发至少一次重试。对于 create/charge/send 类操作,这意味着每 100 次调用里有 3~8 次副作用会执行两遍。

解法:强制幂等键

typescript 复制代码
import { createHash } from "crypto";

interface ToolCallContext {
  agentRunId: string;      // Agent 运行 ID,全局唯一
  toolCallId: string;      // 模型返回的 tool_use.id
  attemptNumber: number;   // 第几次尝试(初次=0)
}

function generateIdempotencyKey(ctx: ToolCallContext): string {
  // 同一次 Agent Run + 同一个 tool_call_id + 同一次尝试 = 同一个 key
  const raw = `${ctx.agentRunId}:${ctx.toolCallId}:${ctx.attemptNumber}`;
  return createHash("sha256").update(raw).digest("hex").slice(0, 32);
}

async function callToolWithIdempotency(
  toolName: string,
  args: Record<string, unknown>,
  ctx: ToolCallContext
): Promise<unknown> {
  const idempotencyKey = generateIdempotencyKey(ctx);
  
  // 检查是否已执行(服务端 KV 或 DB)
  const cached = await kv.get(`idem:${idempotencyKey}`);
  if (cached) {
    console.log(`[idempotency] Cache hit for ${toolName}, returning cached result`);
    return JSON.parse(cached);
  }
  
  // 执行工具
  const result = await tools[toolName](args);
  
  // 缓存结果(TTL 24h,防止无限膨胀)
  await kv.set(`idem:${idempotencyKey}`, JSON.stringify(result), { ex: 86400 });
  
  return result;
}

服务端强制(不可绕过)

客户端幂等键只能防止「相同调用重发」。服务端还必须对危险操作加唯一约束:

sql 复制代码
-- 订单表加唯一约束
ALTER TABLE orders 
ADD CONSTRAINT uq_agent_run_tool 
UNIQUE (agent_run_id, tool_call_id);

-- 插入时使用 ON CONFLICT DO NOTHING
INSERT INTO orders (agent_run_id, tool_call_id, ...)
VALUES ($1, $2, ...)
ON CONFLICT (agent_run_id, tool_call_id) DO NOTHING
RETURNING id;

陷阱 4:并发写入引发竞态,「成功」返回的是错误状态

问题

两个并发工具调用同时读取同一份数据,各自基于旧值计算新值,然后各自写回。其中一次写入会覆盖另一次的结果,系统最终状态取决于哪个写入先到。

经典场景:库存扣减

ini 复制代码
并发调用:
  Tool A: deduct_inventory(product_id=42, qty=3)
  Tool B: deduct_inventory(product_id=42, qty=5)

时序:
  T1: A 读取库存 = 10
  T2: B 读取库存 = 10  (还没被 A 修改)
  T3: A 写入 10 - 3 = 7
  T4: B 写入 10 - 5 = 5  (覆盖了 A 的写入)

实际库存应该是 2,结果是 5。超卖了 3 件。

解法:乐观锁 + 版本号

python 复制代码
import asyncio
from dataclasses import dataclass

@dataclass
class InventoryRecord:
    product_id: int
    quantity: int
    version: int  # 乐观锁版本号

async def deduct_inventory_safe(
    product_id: int,
    qty: int,
    max_retries: int = 3
) -> InventoryRecord:
    """带乐观锁的幂等库存扣减"""
    
    for attempt in range(max_retries):
        # 读取当前版本
        record = await db.fetch_one(
            "SELECT quantity, version FROM inventory WHERE product_id = $1",
            product_id
        )
        
        if record.quantity < qty:
            raise ValueError(f"Insufficient inventory: {record.quantity} < {qty}")
        
        # 带版本号的条件更新
        updated = await db.execute(
            """
            UPDATE inventory 
            SET quantity = quantity - $1, version = version + 1
            WHERE product_id = $2 AND version = $3
            """,
            qty, product_id, record.version
        )
        
        if updated.rowcount == 1:
            return InventoryRecord(
                product_id=product_id,
                quantity=record.quantity - qty,
                version=record.version + 1
            )
        
        # 版本冲突,等待后重试
        await asyncio.sleep(0.05 * (2 ** attempt))
    
    raise RuntimeError(f"Optimistic lock conflict after {max_retries} retries")

何时用悲观锁

对于高冲突率(>20% 的写操作会冲突)或者不可重试的操作(如付款),乐观锁的重试代价超过了其优势。这时改用悲观锁:

python 复制代码
async def deduct_inventory_pessimistic(product_id: int, qty: int):
    async with db.transaction():
        # SELECT FOR UPDATE 持有行锁
        record = await db.fetch_one(
            "SELECT quantity FROM inventory WHERE product_id = $1 FOR UPDATE",
            product_id
        )
        if record.quantity < qty:
            raise ValueError("Insufficient inventory")
        await db.execute(
            "UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2",
            qty, product_id
        )

陷阱 5:部分成功没有补偿,Agent 看不见不一致状态

问题

5 个并发工具调用,3 个成功,2 个失败。Agent 收到混合结果,但没有补偿机制------那 3 个已成功的操作已经产生了副作用,而整体任务失败了。系统处于中间状态,没人知道该怎么办。

真实案例:旅行预订 Agent 并发调用了「锁定机票座位」、「预扣酒店房间」、「创建行程记录」三个工具。机票成功,酒店失败(满房),行程记录成功。结果:用户没有完成预订,但座位被锁了 24 小时,行程记录里有个没有酒店的残缺数据。

解法:Saga 补偿模式

typescript 复制代码
interface SagaStep<T> {
  name: string;
  execute: () => Promise<T>;
  compensate: (result: T) => Promise<void>;  // 回滚操作
}

class ToolCallSaga {
  private completedSteps: Array<{ step: SagaStep<any>; result: any }> = [];
  
  async executeParallel<T>(steps: SagaStep<T>[]): Promise<T[]> {
    const results: T[] = [];
    const failed: Error[] = [];
    
    // 并发执行所有步骤
    const outcomes = await Promise.allSettled(
      steps.map(async (step) => {
        const result = await step.execute();
        return { step, result };
      })
    );
    
    // 收集成功步骤
    for (const outcome of outcomes) {
      if (outcome.status === "fulfilled") {
        this.completedSteps.push(outcome.value);
        results.push(outcome.value.result);
      } else {
        failed.push(outcome.reason);
      }
    }
    
    // 如果有任何失败,执行已完成步骤的补偿
    if (failed.length > 0) {
      console.error(`${failed.length}/${steps.length} steps failed, compensating...`);
      await this.compensateAll();
      throw new SagaFailedError(
        `Saga failed: ${failed.map(e => e.message).join("; ")}`,
        failed
      );
    }
    
    return results;
  }
  
  private async compensateAll(): Promise<void> {
    // 逆序补偿(后执行的先补偿)
    const toCompensate = [...this.completedSteps].reverse();
    
    for (const { step, result } of toCompensate) {
      try {
        await step.compensate(result);
        console.log(`Compensated: ${step.name}`);
      } catch (err) {
        // 补偿失败需要人工介入,记录告警
        console.error(`Compensation failed for ${step.name}:`, err);
        await alertOncall({
          severity: "critical",
          title: `Saga compensation failed: ${step.name}`,
          details: { result, error: String(err) }
        });
      }
    }
  }
}

// 使用示例:旅行预订
const saga = new ToolCallSaga();

await saga.executeParallel([
  {
    name: "lock_flight_seat",
    execute: () => lockFlightSeat(flightId, userId),
    compensate: ({ lockId }) => releaseFlightSeat(lockId)
  },
  {
    name: "reserve_hotel",
    execute: () => reserveHotel(hotelId, checkIn, checkOut),
    compensate: ({ reservationId }) => cancelHotelReservation(reservationId)
  },
  {
    name: "create_itinerary",
    execute: () => createItinerary(userId, tripDetails),
    compensate: ({ itineraryId }) => deleteItinerary(itineraryId)
  }
]);

Saga vs 两阶段提交

特性 Saga 补偿 两阶段提交 (2PC)
适用场景 跨服务的长事务 同一数据库的短事务
失败处理 逆向补偿(业务逻辑) 回滚(数据库保证)
性能影响 低(无全局锁) 高(持有锁直到提交)
最终一致性 否(强一致)
Agent 适用性 推荐 不适合(工具调用跨系统)

工程清单:并发 Tool Call 上生产前要检查什么

根据上述五个陷阱,总结一份「上线前必过」的检查清单:

层级 检查项 风险
执行层 确认框架真正并发执行 tool_calls,而非串行 并发收益完全丢失
依赖分析 所有工具对声明输入/输出字段,运行依赖审计 并发暴露隐式依赖
幂等性 所有写操作带 idempotency key,服务端加唯一约束 重试导致重复副作用
并发写入 同一资源的并发写入加乐观锁或悲观锁 竞态导致数据错误
部分失败 多工具调用实现 Saga 补偿,或改为串行 系统进入不一致中间态
可观测性 每次 tool call 记录 agent_run_id/tool_call_id/attempt 事故后无法重建现场
告警 补偿失败(compensation_failed)接入 oncall 不一致状态无人感知

性能 vs 可靠性的取舍框架

不是所有工具调用都应该并发。这是一个决策框架:

css 复制代码
IF 所有工具都是「只读操作」(GET/查询/搜索)
  → 无条件并发,没有额外风险

IF 存在写操作 AND 写操作之间无数据依赖
  → 并发 + 幂等键 + 竞态保护

IF 写操作之间存在数据依赖(A 的输出是 B 的输入)
  → 串行执行,或者按依赖图分阶段并发

IF 任何写操作失败后需要业务回滚
  → 实现 Saga,或者改为串行 + 事务
  
IF 操作不可撤销(发送短信、提交付款)
  → 强制串行 + 幂等键 + 人工审批 gate

总结

并发 Tool Calling 是提升 Agent 性能的关键,但它不是一个可以「打开就好」的开关。每一个陷阱的背后,都是分布式系统的经典问题:

  1. 执行层没跟上 → 模型并发,框架串行
  2. 隐式依赖 → 并发撕开顺序假设
  3. 非幂等重试 → 超时变双倍副作用
  4. 并发写入 → 竞态产生错误状态
  5. 部分失败 → 没有补偿,系统卡在中间态

这些问题在 LLM 之前就存在,LLM 只是把它们带到了更多人的面前。解法也早就有了:幂等键、乐观锁、Saga 补偿------工具都是现成的,缺的是把它们带进 AI 工程实践的意识。

下次你的 Agent 要调用 process_refund,先问自己:如果这个调用超时了,重试安全吗?


参考资料

相关推荐
leeyi2 小时前
Graph 执行引擎源码:Eino 的节点调度是怎么工作的(第56篇-E42)
agent·ai编程·graphql
TrisighT2 小时前
让 Claude 自己管自己:Hooks 拦截 rm -rf 的那一次救场
aigc·agent·ai编程
土土哥tutuge2 小时前
开源项目 Claude Code Notifier Plus:让 Claude Code 真正需要你时再提醒你
ai编程·visual studio code
玉宇夕落2 小时前
skills简单学习
ai编程
吃饱了得干活2 小时前
LangChain 提示词模板:从拼接字符串到组合流水线
langchain·llm
唐老板2 小时前
AI 时代,Code Review 的重点彻底变了
ai编程
潘锦2 小时前
从碎片检索到深度推理:如何重塑 AI Agent 的知识引擎
agent
潘锦2 小时前
如何打造 AI-Native 软件产品研发组织
ai编程