AI Agent 工具调用的 DAG 并行调度实践:从依赖解析到失败传播

作者:袖梨 2026-09-18

当一个 AI Agent 需要连续调用多个外部工具时,执行顺序很快就会成为影响正确性与延迟的关键因素。逐个等待虽然稳妥,却浪费了可并行的时间;一次性全部启动又可能触发竞态和脏数据。要兼顾速度与依赖约束,就需要把工具调用建模为可调度的 DAG。

一个让我印象深刻的生产事故

去年我们团队维护的一个 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)

Tool1 ─┐
Tool2 ─┤→ join → 下一步
Tool3 ─┘

优点:最短总等待时间(理论上)。缺点:破坏有依赖的工具之间的数据流,引入竞态条件。

DAG 调度(Dependency-Aware Parallel)

Tool1 ──────────────────┐
Tool2 ─→ Tool4 ─────────┤→ join → 下一步
Tool3 ─→ Tool5 ─→ Tool6 ─┘

按依赖拓扑排序,没有依赖的工具并发跑,有依赖的工具等上游完成再启动。这是唯一同时满足"最快执行"和"依赖正确"的方案。

现实 Agent 的工具调用图几乎都是 DAG,不是线性序列,也不是全平行扇出。但大多数工程实现选了最省事的两端之一,而不是中间那个正确的选项。


DAG 工具调度的核心概念

1. 依赖声明与自动推断

工具之间有两类依赖:

显式依赖(Explicit Dependency):Tool B 的输入直接引用 Tool A 的输出字段。这可以从工具 schema 的 $ref 或参数绑定中静态推断。

# 工具调用计划(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 上显式标注:

@tool(
    writes=["user_profile.preferences"],
    reads=["user_profile.history"]
)
def update_user_preferences(user_id: str, new_prefs: dict) -> dict:
    ...

如果两个工具都声明了相同的 writes 字段,调度器必须强制串行化它们。

2. 拓扑排序与关键路径

有了依赖图之后,用 Kahn 算法做拓扑排序,得到合法的执行层(layer):

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)是从源到汇的最长耗时路径,决定了理论最短完成时间。生产中要监控关键路径上的工具是否有优化空间——优化非关键路径上的工具,对总延迟没有任何贡献。

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]

生产调度器的完整实现

理论明白了,来看一个可以真正用于生产的调度器:

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 调度

# 错误示范:假设"都没有依赖"
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 或数据错误,反而产生更难排查的次生故障。

正确策略是依赖感知的失败传播

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 传递:

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:

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 里你能直接看出:

  1. 关键路径实际是哪条(最长的连续 Span 链)
  2. 某个工具是否因依赖等待白白损失了时间
  3. 并行效果有多少(层内最宽的 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 模式,但工具级依赖需要自己建模:

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 是更合适的选择:

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 个,无强依赖,单请求 <10sasyncio + 手写依赖检查轻量,够用
工具 5-20 个,有复杂依赖图,单请求 10-60s本文 DAGToolScheduler通用,可扩展
工具 >20 个,或需要跨请求持久化Temporal / Prefect持久化、重试、可见性
框架已经是 LangGraphLangGraph super-step原生支持,不引入新依赖
工具调用图在运行时动态变化DAGToolScheduler + 动态计划重生成静态图无法适应

小结

工具调用并行化的核心不是"把所有工具扔进 gather",而是先把依赖关系弄清楚,再让没有依赖关系的工具并发跑。

具体落地建议:

  1. 在 LLM 工具计划生成阶段就要求 deps 字段,不要事后推断。
  2. 用 Kahn 拓扑排序把计划分层,同层并发,跨层串行。
  3. 失败传播要区分 required/optional 依赖,避免一个工具失败导致整个计划崩掉或产生次生错误。
  4. 用剩余 deadline 而不是固定 timeout 分配每层时间
  5. 给每个工具打 Span,从 Trace 里看关键路径,优化有数据支撑。

最后是一个值得记住的数字:我们内部测量,正确的 DAG 调度比全串行快 2.5 倍,同时比"错误的全并发"只慢了不到 20%。这 20% 的代价是值得的——它换来的是零竞态故障,和在 Trace 里真正能看懂执行过程的可调试性。

相关文章

精彩推荐