你的 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 性能的关键,但它不是一个可以「打开就好」的开关。每一个陷阱的背后,都是分布式系统的经典问题:
- 执行层没跟上 → 模型并发,框架串行
- 隐式依赖 → 并发撕开顺序假设
- 非幂等重试 → 超时变双倍副作用
- 并发写入 → 竞态产生错误状态
- 部分失败 → 没有补偿,系统卡在中间态
这些问题在 LLM 之前就存在,LLM 只是把它们带到了更多人的面前。解法也早就有了:幂等键、乐观锁、Saga 补偿------工具都是现成的,缺的是把它们带进 AI 工程实践的意识。
下次你的 Agent 要调用 process_refund,先问自己:如果这个调用超时了,重试安全吗?
参考资料
- Parallel Tool Calls in LLM Agents: The Coupling Test You Didn't Know You Were Running --- tianpan.co, Apr 2026
- The Idempotency Problem in Agentic Tool Calling --- tianpan.co, Apr 2026
- What Are Parallel Tool Calls in LLMs? --- Airbyte, Mar 2026
- AI Agent Tool Calling in Production: A Reliability Guide --- MetaCTO, Jun 2026
- LLMCompiler: An LLM Compiler for Parallel Function Calling --- Kim et al., 2024