一个让我印象深刻的生产事故
去年我们团队维护的一个 AI 研报生成 Agent,每次生成一份报告需要调用 7 个工具:拉市场数据、查竞品信息、搜新闻摘要、获取财务数据、分析技术指标、拿行业基准、读用户历史偏好。
上线初期,这 7 个工具调用是顺序执行的。平均一份报告要等 22 秒。用户投诉"太慢",产品经理盯着 P95 延迟跳脚。我们做了第一版优化:把全部 7 个工具改成 asyncio.gather 同时并发。延迟从 22 秒降到 6 秒------但随即出现了一类新故障:偶发性数据不一致。
问题出在两个工具之间有隐式依赖:get_financial_data 需要用 get_company_profile 返回的 ticker symbol。当这两个工具同时启动,get_financial_data 偶尔会先于 get_company_profile 完成并使用旧缓存的 ticker,最终拿到错误公司的财务数据。
这个 bug 在测试环境从未复现,因为测试时每次 ticker 都相同。生产里 ticker 变化时,两个工具的时序不确定,bug 就出来了。
这就是工具调用 DAG 编排问题的典型起点:你以为的"可以并行",其实隐藏着依赖关系;而你以为的"串行安全",其实是把大量可以重叠执行的时间白白丢掉了。
为什么顺序调用和全并行都不够
LLM 工具调用的执行模式大致分三种:
顺序执行(Sequential)
Tool1 → Tool2 → Tool3 → Tool4
优点:实现简单,无依赖问题。缺点:完全放弃了并发机会。如果 Tool1 需要 800ms,Tool2 需要 600ms,Tool3 需要 700ms,三者之间没有依赖,你却等了整整 2100ms。
全量并行(Bulk Parallel)
bash
Tool1 ─┐
Tool2 ─┤→ join → 下一步
Tool3 ─┘
优点:最短总等待时间(理论上)。缺点:破坏有依赖的工具之间的数据流,引入竞态条件。
DAG 调度(Dependency-Aware Parallel)
bash
Tool1 ──────────────────┐
Tool2 ─→ Tool4 ─────────┤→ join → 下一步
Tool3 ─→ Tool5 ─→ Tool6 ─┘
按依赖拓扑排序,没有依赖的工具并发跑,有依赖的工具等上游完成再启动。这是唯一同时满足"最快执行"和"依赖正确"的方案。
现实 Agent 的工具调用图几乎都是 DAG,不是线性序列,也不是全平行扇出。但大多数工程实现选了最省事的两端之一,而不是中间那个正确的选项。
DAG 工具调度的核心概念
1. 依赖声明与自动推断
工具之间有两类依赖:
显式依赖(Explicit Dependency) :Tool B 的输入直接引用 Tool A 的输出字段。这可以从工具 schema 的 $ref 或参数绑定中静态推断。
python
# 工具调用计划(LLM 生成)
tool_plan = [
{
"id": "t1",
"tool": "get_company_profile",
"args": {"company_name": "{{user_query.company}}"},
"deps": []
},
{
"id": "t2",
"tool": "get_financial_data",
"args": {"ticker": "{{t1.result.ticker}}"}, # 依赖 t1
"deps": ["t1"]
},
{
"id": "t3",
"tool": "get_news",
"args": {"query": "{{user_query.company}}"},
"deps": [] # 不依赖 t1,可以并发
},
{
"id": "t4",
"tool": "analyze_sentiment",
"args": {
"news": "{{t3.result.articles}}", # 依赖 t3
"financials": "{{t2.result}}" # 依赖 t2
},
"deps": ["t2", "t3"] # 需要 t2 和 t3 都完成
}
]
隐式依赖(Implicit Dependency):两个工具写同一个外部状态(如数据库、文件),需要排序以保证一致性。这类依赖不能从参数引用中推断,必须在工具 schema 上显式标注:
python
@tool(
writes=["user_profile.preferences"],
reads=["user_profile.history"]
)
def update_user_preferences(user_id: str, new_prefs: dict) -> dict:
...
如果两个工具都声明了相同的 writes 字段,调度器必须强制串行化它们。
2. 拓扑排序与关键路径
有了依赖图之后,用 Kahn 算法做拓扑排序,得到合法的执行层(layer):
python
from collections import defaultdict, deque
def compute_execution_layers(tool_plan: list[dict]) -> list[list[str]]:
"""将工具计划按依赖关系分成执行层,同层内可并行。"""
in_degree = {t["id"]: 0 for t in tool_plan}
graph = defaultdict(list)
for tool in tool_plan:
for dep in tool.get("deps", []):
graph[dep].append(tool["id"])
in_degree[tool["id"]] += 1
# Kahn 算法
queue = deque([tid for tid, deg in in_degree.items() if deg == 0])
layers = []
while queue:
layer = []
for _ in range(len(queue)):
node = queue.popleft()
layer.append(node)
for neighbor in graph[node]:
in_degree[neighbor] -= 1
if in_degree[neighbor] == 0:
queue.append(neighbor)
layers.append(layer)
if sum(len(l) for l in layers) != len(tool_plan):
raise ValueError("工具依赖图中存在循环依赖!")
return layers
# 示例输出
# Layer 0: ["t1", "t3"] <- 无依赖,并发执行
# Layer 1: ["t2"] <- 依赖 t1 完成
# Layer 2: ["t4"] <- 依赖 t2 和 t3 完成
关键路径(Critical Path)是从源到汇的最长耗时路径,决定了理论最短完成时间。生产中要监控关键路径上的工具是否有优化空间------优化非关键路径上的工具,对总延迟没有任何贡献。
python
def compute_critical_path(
tool_plan: list[dict],
estimated_durations: dict[str, float]
) -> tuple[list[str], float]:
"""返回关键路径的工具序列和总耗时。"""
# 动态规划:est[id] = 最早完成时间
est = {}
pred = {} # 前驱追踪
tool_by_id = {t["id"]: t for t in tool_plan}
def earliest_finish(tid: str) -> float:
if tid in est:
return est[tid]
deps = tool_by_id[tid].get("deps", [])
if not deps:
est[tid] = estimated_durations.get(tid, 1.0)
pred[tid] = None
return est[tid]
max_dep_finish = 0
max_dep_id = None
for dep in deps:
dep_finish = earliest_finish(dep)
if dep_finish > max_dep_finish:
max_dep_finish = dep_finish
max_dep_id = dep
est[tid] = max_dep_finish + estimated_durations.get(tid, 1.0)
pred[tid] = max_dep_id
return est[tid]
for tool in tool_plan:
earliest_finish(tool["id"])
# 从最晚完成的节点回溯
end_node = max(est, key=est.get)
path = []
node = end_node
while node is not None:
path.append(node)
node = pred.get(node)
return list(reversed(path)), est[end_node]
生产调度器的完整实现
理论明白了,来看一个可以真正用于生产的调度器:
python
import asyncio
import time
import traceback
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
from enum import Enum
class ToolStatus(Enum):
PENDING = "pending"
RUNNING = "running"
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped" # 上游失败导致跳过
@dataclass
class ToolResult:
tool_id: str
status: ToolStatus
result: Any = None
error: Exception | None = None
started_at: float = 0.0
finished_at: float = 0.0
@property
def duration_ms(self) -> float:
return (self.finished_at - self.started_at) * 1000
class DAGToolScheduler:
"""
生产级 DAG 工具调度器。
支持:层级并行执行、参数模板绑定、失败传播策略、超时、观测。
"""
def __init__(
self,
failure_policy: str = "stop_on_required_failure",
global_timeout_s: float = 30.0,
on_tool_start: Callable | None = None,
on_tool_done: Callable | None = None,
):
self.failure_policy = failure_policy
self.global_timeout_s = global_timeout_s
self.on_tool_start = on_tool_start
self.on_tool_done = on_tool_done
def _resolve_args(self, args: dict, context: dict) -> dict:
"""将 {{tool_id.field.path}} 占位符替换为实际值。"""
import re
def resolve_value(val):
if not isinstance(val, str):
return val
pattern = r'\{\{([^}]+)\}\}'
matches = re.findall(pattern, val)
if not matches:
return val
# 如果整个值就是一个占位符,返回原始类型(不强转 str)
if val.strip() == f"{{{{{matches[0]}}}}}":
path = matches[0].strip()
return self._get_nested(context, path.split("."))
# 字符串插值
def replacer(m):
path = m.group(1).strip()
value = self._get_nested(context, path.split("."))
return str(value) if value is not None else m.group(0)
return re.sub(pattern, replacer, val)
def resolve_dict(d):
return {k: resolve_value(v) if not isinstance(v, dict)
else resolve_dict(v) for k, v in d.items()}
return resolve_dict(args)
def _get_nested(self, obj: Any, path: list[str]) -> Any:
for key in path:
if obj is None:
return None
if isinstance(obj, dict):
obj = obj.get(key)
else:
obj = getattr(obj, key, None)
return obj
def _should_skip(
self,
tool: dict,
results: dict[str, ToolResult]
) -> bool:
"""判断是否因上游失败而需要跳过该工具。"""
if self.failure_policy == "continue_all":
return False
for dep_id in tool.get("deps", []):
dep_result = results.get(dep_id)
if dep_result is None:
return True # 上游还没跑完(不应发生)
if dep_result.status in (ToolStatus.FAILED, ToolStatus.SKIPPED):
# 检查依赖是否被标记为 required(默认 required)
if tool.get("dep_required", {}).get(dep_id, True):
return True
return False
async def _execute_tool(
self,
tool: dict,
executor: Callable[[str, dict], Awaitable[Any]],
context: dict,
results: dict[str, ToolResult],
) -> ToolResult:
tool_id = tool["id"]
tool_result = ToolResult(tool_id=tool_id, status=ToolStatus.RUNNING, started_at=time.time())
if self._should_skip(tool, results):
tool_result.status = ToolStatus.SKIPPED
tool_result.finished_at = time.time()
return tool_result
if self.on_tool_start:
self.on_tool_start(tool_id, tool["tool"])
try:
resolved_args = self._resolve_args(tool.get("args", {}), context)
timeout = tool.get("timeout_s", self.global_timeout_s)
result = await asyncio.wait_for(
executor(tool["tool"], resolved_args),
timeout=timeout
)
tool_result.result = result
tool_result.status = ToolStatus.SUCCESS
except asyncio.TimeoutError:
tool_result.error = TimeoutError(f"工具 {tool_id} 超时(>{timeout}s)")
tool_result.status = ToolStatus.FAILED
except Exception as e:
tool_result.error = e
tool_result.status = ToolStatus.FAILED
finally:
tool_result.finished_at = time.time()
if self.on_tool_done:
self.on_tool_done(tool_id, tool_result)
return tool_result
async def run(
self,
tool_plan: list[dict],
executor: Callable[[str, dict], Awaitable[Any]],
initial_context: dict | None = None,
) -> dict[str, ToolResult]:
"""
按 DAG 拓扑执行工具计划。
返回每个工具的 ToolResult,调用方可从中读取 result 或 error。
"""
results: dict[str, ToolResult] = {}
context = {"user_query": initial_context or {}}
layers = compute_execution_layers(tool_plan)
async def run_layer(layer: list[str]):
tool_by_id = {t["id"]: t for t in tool_plan}
tasks = [
asyncio.create_task(
self._execute_tool(tool_by_id[tid], executor, context, results)
)
for tid in layer
]
layer_results = await asyncio.gather(*tasks, return_exceptions=False)
for tool_result in layer_results:
results[tool_result.tool_id] = tool_result
# 把成功结果注入 context,供后续层参数绑定
if tool_result.status == ToolStatus.SUCCESS:
context[tool_result.tool_id] = {
"result": tool_result.result
}
try:
await asyncio.wait_for(
asyncio.gather(*[run_layer(layer) for layer in [layers[0]]]),
timeout=self.global_timeout_s
)
# 逐层执行(不能 gather 所有层------层间有顺序依赖)
for layer in layers:
await run_layer(layer)
except asyncio.TimeoutError:
# 全局超时:所有未完成的标记为 FAILED
for tool in tool_plan:
if tool["id"] not in results:
results[tool["id"]] = ToolResult(
tool_id=tool["id"],
status=ToolStatus.FAILED,
error=TimeoutError("全局超时"),
started_at=time.time(),
finished_at=time.time(),
)
return results
三个让系统炸掉的生产陷阱
陷阱一:把 asyncio.gather 误当成 DAG 调度
python
# 错误示范:假设"都没有依赖"
results = await asyncio.gather(
call_tool("get_company_profile", {"name": company}),
call_tool("get_financial_data", {"ticker": ???}), # ticker 从哪来?
call_tool("get_news", {"query": company}),
)
当 LLM 生成的工具调用列表里有参数引用关系时,你需要先解析依赖图,再决定哪些可以并发,而不是无脑 gather。
修复 :在工具调用计划生成阶段,要求 LLM 显式输出 deps 字段(或参数 {{ref}} 格式),调度器在执行前静态分析。
陷阱二:失败传播策略不对称
假设你有 5 个工具,其中 t2 失败了。两种极端策略都有问题:
- 立即全停(fail-fast all):t3、t4、t5(与 t2 无关)也被取消。最终报告连无关数据都没了。
- 继续全部(continue all) :t4 依赖 t2 的输出,t2 失败后 t4 拿到
None,产生 NullPointerError 或数据错误,反而产生更难排查的次生故障。
正确策略是依赖感知的失败传播:
python
failure_policy_matrix = {
"stop_on_required_failure": True, # 依赖失败 → 跳过下游(默认)
"continue_all": False, # 不传播,下游用 None 继续
"stop_all": True, # 任何失败 → 取消所有任务
}
# 工具级别的 required/optional 标注
tool_plan = [
{
"id": "t2",
"tool": "get_financial_data",
...
},
{
"id": "t4",
"tool": "analyze_sentiment",
"deps": ["t2", "t3"],
"dep_required": {
"t2": True, # t2 失败 → t4 跳过
"t3": False, # t3 失败 → t4 仍然执行(只用 t2 结果)
}
}
]
陷阱三:超时预算没有向下传递
全局 timeout 设了 30 秒,但没人记得在每一层执行时扣掉已用时间。结果:
- Layer 0 执行了 25 秒
- Layer 1 还给每个工具分配了 30 秒 timeout
- 实际请求链路超时 55 秒,上游的 deadline 早就过了
正确做法是使用剩余 deadline 传递:
python
import time
async def run_with_deadline(
tool_plan: list[dict],
executor,
deadline: float, # absolute timestamp
):
layers = compute_execution_layers(tool_plan)
results = {}
context = {}
for layer in layers:
remaining = deadline - time.time()
if remaining <= 0:
# 超出 deadline,标记所有未执行工具为 SKIPPED
for t in tool_plan:
if t["id"] not in results:
results[t["id"]] = ToolResult(
tool_id=t["id"],
status=ToolStatus.SKIPPED,
error=TimeoutError("Deadline exceeded before execution"),
started_at=time.time(),
finished_at=time.time(),
)
break
# 每个工具最多用 remaining / layer_parallelism 时间
per_tool_timeout = min(remaining * 0.8, 10.0)
tasks = [
run_tool_with_timeout(t, executor, context, per_tool_timeout)
for t in layer_tools(layer, tool_plan)
]
layer_results = await asyncio.gather(*tasks)
for r in layer_results:
results[r.tool_id] = r
if r.status == ToolStatus.SUCCESS:
context[r.tool_id] = {"result": r.result}
return results
可观测性:让调度过程真正可排障
一个工具调用图跑完以后,你需要知道:哪些工具并行执行了?哪个是关键路径?哪里花了最多时间?
用 OpenTelemetry 给每层和每个工具打 Span:
python
from opentelemetry import trace
tracer = trace.get_tracer("tool-dag-scheduler")
async def _execute_tool_traced(self, tool, executor, context, results):
with tracer.start_as_current_span(
f"tool.{tool['tool']}",
attributes={
"tool.id": tool["id"],
"tool.name": tool["tool"],
"tool.deps": ",".join(tool.get("deps", [])),
}
) as span:
result = await self._execute_tool(tool, executor, context, results)
span.set_attribute("tool.status", result.status.value)
span.set_attribute("tool.duration_ms", result.duration_ms)
if result.error:
span.record_exception(result.error)
return result
打出来的 Trace 在 Jaeger 里会显示为嵌套 Span,同一层的工具会出现在同一个时间段,依赖关系体现在 Span 的先后顺序里。从 Trace 里你能直接看出:
- 关键路径实际是哪条(最长的连续 Span 链)
- 某个工具是否因依赖等待白白损失了时间
- 并行效果有多少(层内最宽的 Span 宽度 vs 如果串行的假设总宽度)
一个实际 Trace 对比数字(我们内部测量):
| 场景 | 7 工具总延迟 | 并行度 |
|---|---|---|
| 全串行 | 18.4 秒 | 1.0x |
| 全并行(错误) | 6.1 秒(+ 数据错误) | 3.0x |
| DAG 调度 | 7.3 秒(无错误) | 2.5x |
DAG 调度在正确性无损的前提下,只比"错误的全并行"慢 1.2 秒,但把之前的竞态故障完全消除了。
与主流框架的集成
LangGraph
LangGraph 原生支持 fan-out/fan-in 模式,但工具级依赖需要自己建模:
python
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
class AgentState(TypedDict):
company_name: str
company_profile: dict | None
financial_data: dict | None
news_articles: list | None
sentiment_analysis: dict | None
def fetch_company_profile(state: AgentState) -> AgentState:
# t1: 无依赖
result = get_company_profile(state["company_name"])
return {"company_profile": result}
def fetch_financial_data(state: AgentState) -> AgentState:
# t2: 依赖 t1(通过 state 传递)
ticker = state["company_profile"]["ticker"]
result = get_financial_data(ticker)
return {"financial_data": result}
def fetch_news(state: AgentState) -> AgentState:
# t3: 不依赖 t1,可与 t2 并发
result = get_news(state["company_name"])
return {"news_articles": result}
def analyze_sentiment(state: AgentState) -> AgentState:
# t4: 依赖 t2 和 t3
result = analyze(state["financial_data"], state["news_articles"])
return {"sentiment_analysis": result}
# 建图
builder = StateGraph(AgentState)
builder.add_node("fetch_profile", fetch_company_profile)
builder.add_node("fetch_financials", fetch_financial_data)
builder.add_node("fetch_news", fetch_news)
builder.add_node("analyze", analyze_sentiment)
# 拓扑边
builder.set_entry_point("fetch_profile")
builder.add_edge("fetch_profile", "fetch_financials")
builder.add_edge("fetch_profile", "fetch_news") # fan-out
# LangGraph 会在 fetch_financials 和 fetch_news 都完成后才进入 analyze
builder.add_edge("fetch_financials", "analyze")
builder.add_edge("fetch_news", "analyze") # fan-in
builder.add_edge("analyze", END)
graph = builder.compile()
LangGraph 的 super-step 机制会自动识别同一批被调度的节点(fetch_financials 和 fetch_news 都只依赖 fetch_profile,所以在 fetch_profile 完成后的同一个 super-step 里并发执行)。注意:这不是严格的 asyncio 并发------同一个 super-step 里的节点在不同线程里执行,但仍然受 Python GIL 约束,对 CPU 密集型任务没有帮助。
Temporal Workflow(生产重型任务)
对于需要持久化和跨重启恢复的工具 DAG,Temporal 是更合适的选择:
python
import asyncio
from temporalio import workflow, activity
from temporalio.common import RetryPolicy
@activity.defn
async def get_company_profile(company_name: str) -> dict:
...
@activity.defn
async def get_financial_data(ticker: str) -> dict:
...
@activity.defn
async def get_news(query: str) -> list:
...
@workflow.defn
class ResearchReportWorkflow:
@workflow.run
async def run(self, company_name: str) -> dict:
retry = RetryPolicy(maximum_attempts=3, backoff_coefficient=2.0)
# t1: 串行
profile = await workflow.execute_activity(
get_company_profile,
company_name,
retry_policy=retry,
start_to_close_timeout=timedelta(seconds=10),
)
# t2 和 t3: 并发
financial_task = asyncio.ensure_future(
workflow.execute_activity(
get_financial_data,
profile["ticker"],
retry_policy=retry,
start_to_close_timeout=timedelta(seconds=15),
)
)
news_task = asyncio.ensure_future(
workflow.execute_activity(
get_news,
company_name,
retry_policy=retry,
start_to_close_timeout=timedelta(seconds=8),
)
)
financials, news = await asyncio.gather(financial_task, news_task)
# t4: 等 t2、t3 完成
sentiment = await workflow.execute_activity(
analyze_sentiment,
args=[financials, news],
retry_policy=retry,
start_to_close_timeout=timedelta(seconds=10),
)
return {"profile": profile, "financials": financials,
"news": news, "sentiment": sentiment}
Temporal 的好处是:工作流状态持久化到 Event History,即使进程崩溃,重启后从最后一个 checkpoint 继续,已完成的 Activity 不会重复执行。对于超过 10 秒、涉及外部 API 调用的工具 DAG,这是比纯 asyncio 更可靠的选择。
何时用哪种方案:决策矩阵
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 工具 <=5 个,无强依赖,单请求 <10s | asyncio + 手写依赖检查 | 轻量,够用 |
| 工具 5-20 个,有复杂依赖图,单请求 10-60s | 本文 DAGToolScheduler | 通用,可扩展 |
| 工具 >20 个,或需要跨请求持久化 | Temporal / Prefect | 持久化、重试、可见性 |
| 框架已经是 LangGraph | LangGraph super-step | 原生支持,不引入新依赖 |
| 工具调用图在运行时动态变化 | DAGToolScheduler + 动态计划重生成 | 静态图无法适应 |
小结
工具调用并行化的核心不是"把所有工具扔进 gather",而是先把依赖关系弄清楚,再让没有依赖关系的工具并发跑。
具体落地建议:
- 在 LLM 工具计划生成阶段就要求
deps字段,不要事后推断。 - 用 Kahn 拓扑排序把计划分层,同层并发,跨层串行。
- 失败传播要区分 required/optional 依赖,避免一个工具失败导致整个计划崩掉或产生次生错误。
- 用剩余 deadline 而不是固定 timeout 分配每层时间。
- 给每个工具打 Span,从 Trace 里看关键路径,优化有数据支撑。
最后是一个值得记住的数字:我们内部测量,正确的 DAG 调度比全串行快 2.5 倍,同时比"错误的全并发"只慢了不到 20%。这 20% 的代价是值得的------它换来的是零竞态故障,和在 Trace 里真正能看懂执行过程的可调试性。