构建Agent时,工具调用策略往往决定了整体响应速度和稳定性。很多实现采用逐步推理的方式:模型先思考,选择工具,等待结果,再决定下一步。这种模式容易理解,但在存在多个相互独立步骤时,会带来明显的串行等待。LLMCompiler的思路是把执行计划显式建模成有向无环图,先让规划器一次性生成任务节点和依赖关系,再由执行器并行调度无依赖的工具调用,从而把原本线性累积的耗时压缩到关键路径长度。

一、LLMCompiler的核心思路:把执行计划画成DAG
传统Agent执行过程可以看作一个顺序状态机。用户输入后,LLM生成一个动作,工具执行,结果填充回上下文,再次调用LLM。对于需要多次检索或计算的场景,串行延迟尤其突出。例如同时查询三只股票行情再汇总时,实际三次API调用完全可以并发。LLMCompiler的规划器会接收任务描述与可用工具列表,输出结构化的任务列表,每个任务包括编号、工具名、参数和依赖编号。依赖关系自然形成DAG。无依赖的任务可以被执行器放入并发批次,依赖任务必须等待前置节点完成。
为什么用DAG而不是普通列表?因为DAG明确了哪些数据从哪个节点产出,避免执行器猜测依赖。假设任务A和任务B互相独立,任务C依赖A和B的结果,那么总执行时间接近A与B中的较大值加上C,而不是A、B、C依次相加。这种优化在多步骤Agent里非常可观,尤其是网络IO密集的工具调用。用户问“北京今天天气怎么样?顺便看看AI行业动态,然后帮我写一段简报”时,前两个检索任务可以并行处理,汇总任务必须等两者都完成。
[
{
"id": 1,
"tool": "get_weather",
"args": { "city": "北京" },
"deps": []
},
{
"id": 2,
"tool": "search_news",
"args": { "topic": "AI行业" },
"deps": []
},
{
"id": 3,
"tool": "write_brief",
"args": { "weather": "$1", "news": "$2" },
"deps": [1, 2]
}
]
上面的规划描述了三个任务:查天气、查新闻和写简报。执行器看到前两个任务没有依赖,会同时发起调用,而不是先查天气再等结果再查新闻。两个结果返回后,$1和$2会被替换成对应任务的输出,然后执行写简报任务。这个简单例子已经把串行三步压缩成了两个执行批次。
二、关键组件拆解:Planner、Executor和Joiner如何协作
LLMCompiler通常分为三个核心组件。Planner负责把自然语言任务和工具签名转换成DAG,它不是直接调用工具,而是输出计划。Executor是执行引擎,维护任务状态,按依赖关系调度工具调用,并把返回值填充到任务上下文中。Joiner则检查所有已执行任务得到的结果是否足以回答原始问题,如果足够就生成最终答案,如果信息不足或有歧义,它会生成一个新的任务列表回传给Planner继续规划。
规划器输出质量直接影响整体效果。实际工程中需要在提示词里明确要求模型输出合法JSON,并说明字段含义和依赖格式。如果模型输出格式错误,常见做法是先进行解析修复,修复失败再重新生成。相比逐步函数调用,LLMCompiler偏向一次生成较完整的DAG,因此对结构化输出能力要求更高。执行器则要解决依赖解析、并发调度和结果替换三个问题。一个最小化的执行器可以维护结果字典和已完成集合,不断找出所有依赖均已满足的任务进行并发执行。
from concurrent.futures import ThreadPoolExecutor, as_completed
class Task:
def __init__(self, task_id, tool, args, deps):
self.id = task_id
self.tool = tool
self.args = args
self.deps = deps
def run_dag(tasks):
results = {}
done = set()
while len(results) < len(tasks):
ready = [
t for t in tasks
if t.id not in results and all(d in done for d in t.deps)
]
if not ready:
raise RuntimeError("DAG中存在循环依赖或无法满足的依赖")
with ThreadPoolExecutor(max_workers=len(ready)) as pool:
futures = {
pool.submit(t.tool, **substitute_args(t.args, results)): t
for t in ready
}
for future in as_completed(futures):
task = futures[future]
results[task.id] = future.result()
done.add(task.id)
return results
def substitute_args(args, results):
resolved = {}
for key, value in args.items():
if isinstance(value, str) and value.startswith("$"):
dep_id = int(value[1:])
resolved[key] = results[dep_id]
else:
resolved[key] = value
return resolved
Joiner的作用经常被低估。并行执行不是万能,有时结果冲突或缺失,需要Joiner决定是否继续。例如查天气和查新闻都成功后,写简报任务可能因为缺少新闻来源链接而需要补充信息,Joiner可以生成一个补充任务,让Planner重新处理。这样可以避免一次性规划失败导致整个任务卡死。后续修正机制让系统在保持并行优势的同时,也能应对部分工具返回质量不高的情况。
三、LLMCompiler与传统ReAct模式对比
传统ReAct模式下,模型每做一次工具调用需要生成思考、动作、等待观察。优点是实现简单、动态调整灵活。缺点是每轮只产生一个动作,多个独立任务无法并行,而且需要多轮LLM推理,token成本增加,延迟也更高。LLMCompiler通过一次规划生成完整DAG,将多个工具调用批量提交,减少LLM调用轮次,更适合IO密集型任务。
| 维度 | 传统ReAct | LLMCompiler |
|---|---|---|
| 执行方式 | 逐步推理并调用工具 | 先规划DAG再并行调度 |
| 独立任务处理 | 通常串行 | 自动并行 |
| LLM调用轮次 | 较多 | 相对较少 |
| 动态调整能力 | 较强 | 依赖Joiner重新规划 |
| 实现复杂度 | 较低 | 中等 |
从延迟角度看,假设两个独立工具调用各耗时2秒,汇总任务耗时1秒。ReAct串行总耗时约5秒工具时间,外加3轮LLM推理时间。LLMCompiler前两个工具并行,关键路径为约3秒工具时间,且可能只调用两次LLM。实际差异会受模型生成时间和工具响应影响,但趋势明显。成本方面,单次规划输出更长,单次token可能更高,但总轮数少,长任务通常更省。
适用边界需要认清。强顺序推理问题中,每一步结论直接影响下一步,LLMCompiler并不能突破逻辑顺序,它只是把无依赖部分并行。若大多数工具调用环环相扣,DAG的并行度低,优化有限。此外,并行调用可能给外部API造成更高瞬时压力,需要注意限流。动态环境也有风险:如果后续步骤依赖外部状态变化,提前规划可能失效。
四、落地实践与优化注意事项
在LangChain或自建Agent中引入LLMCompiler,建议先抽象工具层,让每个工具返回结构化结果,并在参数中明确依赖占位符,例如$1、$2。规划器提示词要展示规范示例,指定输出JSON数组。为了减少解析失败,可以使用JSON mode或结构化输出能力。执行器需要实现依赖解析、超时和错误重试。
planner_prompt = """ 你是任务规划器。根据用户目标和工具列表生成JSON数组。 每个元素包含id、tool、args、deps字段。 deps表示依赖的前置任务id,无依赖则为空数组。 工具参数中引用其他任务结果时使用$加任务id,例如$1。 不要生成JSON之外的内容。 """
调度实现要处理部分节点失败。常见策略是对失败任务进行重试,若仍失败则把错误信息交给Joiner,判断是否需要生成替代任务。同时设置超时,防止某个工具长时间挂起。对于可变参数替换,要明确只允许字符串引用,避免将依赖值复杂嵌套导致解析错误。也可以使用有向图库如networkx做拓扑排序,但简单任务自实现即可。
LLMCompiler适合那些工具调用数量多、相互独立明显的场景,比如行情查询、多源搜索、报表生成等。它把Agent从一步一步思考变成先规划再并行执行,在延迟和成本控制上提供了新的思路。但也不应盲目追求并行,规划质量、工具稳定性和错误恢复机制同样关键。通过合理的任务拆分和依赖建模,可以在现有Agent框架中逐步引入DAG调度能力。
LLMCompilerAgent规划器执行计划优化修改时间:2026-09-19 00:18:15