1. 引言
在构建基于大模型的智能体(Agent)应用时,一个绕不开的现实是:几乎所有耗时操作都是 I/O 密集型的。调用 LLM 接口、查询向量数据库、请求外部工具 API、读写文件......这些操作如果全部同步串行执行,一个稍复杂的 Agent 图跑下来动辄几十秒甚至几分钟。
LangGraph 作为 LangChain 生态中用于编排有状态工作流的框架,天然支持异步节点(async node)。但很多同学在从同步写法迁移到异步写法时,会遇到诸如「ainvoke 和 invoke 混用报错」「异步节点里调了同步工具导致阻塞」「并发分支没有真正并行」等问题。
本文将从为什么需要异步节点讲起,系统梳理 LangGraph 中异步 Node 的几种设计写法、并发控制、错误处理与常见坑,最后给出一个可直接运行的完整示例。
2. 为什么需要异步 Node
2.1 同步执行的瓶颈
假设一个 Agent 图包含三个节点:检索、LLM 推理、工具调用。每个节点平均耗时 2 秒,同步串行执行总耗时约 6 秒。如果图中有 5 个可以并行的检索分支,串行执行就是 10 秒,而并发执行理论上只需 2 秒。
2.2 异步带来的收益
- I/O 并发:多个 LLM 调用、工具调用可以同时进行,显著降低端到端延迟。
- 资源利用率:等待网络响应时释放事件循环,单进程可承载更多并发请求。
- 生态兼容 :LangChain 的
ainvoke、astream、abatch等异步接口是官方推荐的高性能路径。
2.3 LangGraph 的异步支持范围
LangGraph 的异步能力贯穿整个调用链:
| 层级 | 同步接口 | 异步接口 |
|---|---|---|
| 图执行 | graph.invoke() |
graph.ainvoke() |
| 流式输出 | graph.stream() |
graph.astream() |
| 批量执行 | graph.batch() |
graph.abatch() |
| 节点函数 | def node(state) |
async def node(state) |
关键原则:异步节点必须通过异步接口调用 。用 invoke() 去跑一个 async def 节点会直接报错。
3. 异步 Node 的基础写法
3.1 最小可运行示例
先看一个最基础的异步节点定义:
python
import asyncio
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
query: str
answer: str
async def fetch_context(state: State) -> dict:
"""模拟异步检索节点"""
await asyncio.sleep(0.5) # 模拟 I/O 等待
return {"answer": f"检索到的上下文:{state['query']}"}
async def generate(state: State) -> dict:
"""模拟异步 LLM 生成节点"""
await asyncio.sleep(0.5)
return {"answer": state["answer"] + " -> 已生成最终答案"}
builder = StateGraph(State)
builder.add_node("fetch_context", fetch_context)
builder.add_node("generate", generate)
builder.add_edge(START, "fetch_context")
builder.add_edge("fetch_context", "generate")
builder.add_edge("generate", END)
graph = builder.compile()
async def main():
result = await graph.ainvoke({"query": "LangGraph 异步节点怎么写?"})
print(result)
if __name__ == "__main__":
asyncio.run(main())
要点说明:
- 节点函数用
async def定义,内部使用await调用异步 I/O。 - 编译后的图必须用
await graph.ainvoke(...)执行。 - 返回值是状态增量(partial state),LangGraph 会自动合并到全局 State。
3.2 同步与异步节点混用
LangGraph 允许在同一个图中混用同步节点和异步节点。当使用 ainvoke() 执行时,同步节点会被放到线程池中执行,不会阻塞事件循环:
python
def sync_node(state: State) -> dict:
# 同步节点,ainvoke 时会自动在线程池中运行
return {"answer": state["answer"] + " [sync]"}
async def async_node(state: State) -> dict:
await asyncio.sleep(0.1)
return {"answer": state["answer"] + " [async]"}
不过要注意:同步节点内部如果做长时间阻塞操作(如 time.sleep、同步 HTTP 请求),仍然会占用线程池线程,高并发场景下建议统一改为异步。
4. 进阶设计:并发、流式与错误处理
4.1 并行分支的异步执行
LangGraph 中,一个节点连出多条边到多个节点时,这些节点会并发执行。在异步模式下,它们真正跑在同一个事件循环里:
python
from langgraph.graph import StateGraph, START, END
class MultiState(TypedDict):
query: str
results: list
async def search_web(state: MultiState) -> dict:
await asyncio.sleep(1)
return {"results": [f"web:{state['query']}"]}
async def search_db(state: MultiState) -> dict:
await asyncio.sleep(1)
return {"results": [f"db:{state['query']}"]}
async def search_cache(state: MultiState) -> dict:
await asyncio.sleep(1)
return {"results": [f"cache:{state['query']}"]}
builder = StateGraph(MultiState)
builder.add_node("search_web", search_web)
builder.add_node("search_db", search_db)
builder.add_node("search_cache", search_cache)
# 三个分支并行
builder.add_edge(START, "search_web")
builder.add_edge(START, "search_db")
builder.add_edge(START, "search_cache")
builder.add_edge(["search_web", "search_db", "search_cache"], END)
graph = builder.compile()
三个节点各耗时 1 秒,但由于并发执行,总耗时约 1 秒而非 3 秒。注意 results 字段使用了 list 类型,LangGraph 默认对列表字段做追加合并(需要配合 reducer,见 4.3)。
4.2 在节点内并发调用多个异步任务
有时并发粒度在节点内部,比如一个节点要同时调用多个工具。这时用 asyncio.gather:
python
async def multi_tool_node(state: State) -> dict:
async def call_tool(name: str):
await asyncio.sleep(0.3)
return f"{name} 结果"
results = await asyncio.gather(
call_tool("tool_a"),
call_tool("tool_b"),
call_tool("tool_c"),
)
return {"answer": " | ".join(results)}
如果需要对并发数做限制,可以用 asyncio.Semaphore:
python
sem = asyncio.Semaphore(2)
async def limited_call(name: str):
async with sem:
await asyncio.sleep(0.3)
return f"{name} 结果"
4.3 使用 reducer 正确合并并发结果
并行分支同时写同一个字段时,必须显式声明 reducer,否则会报 InvalidUpdateError:
python
from typing import Annotated
from operator import add
class MultiState(TypedDict):
query: str
results: Annotated[list, add]
Annotated[list, add] 表示多个节点返回的列表会被拼接合并,而不是互相覆盖。
4.4 异步流式输出
对于需要实时展示 LLM 输出的场景,用 astream 或 astream_events:
python
async def run_stream():
async for chunk in graph.astream(
{"query": "介绍一下 LangGraph"},
stream_mode="updates",
):
print(chunk)
stream_mode 常用取值:
"values":每次输出完整状态。"updates":只输出每个节点的状态增量。"messages":流式输出 LLM token(需配合 LangChain 的流式模型)。
4.5 异步节点的错误处理与重试
异步节点中的异常会中断整个图执行。推荐在节点内部做细粒度捕获,或使用 LangGraph 的 RetryPolicy:
python
from langgraph.pregel import RetryPolicy
builder.add_node(
"generate",
generate,
retry=RetryPolicy(max_attempts=3, initial_interval=1.0),
)
节点内部也可以自行捕获:
python
async def safe_node(state: State) -> dict:
try:
result = await risky_call()
return {"answer": result}
except Exception as e:
return {"answer": f"调用失败:{e}"}
5. 常见坑与最佳实践
5.1 常见坑
- 在异步节点里调用同步阻塞函数 :如
requests.get()、time.sleep(),会阻塞整个事件循环。应改用httpx.AsyncClient、asyncio.sleep(),或用asyncio.to_thread()包装。 - 用
invoke()执行含异步节点的图 :会抛出TypeError,必须用ainvoke()。 - 忘记声明 reducer :并行分支写同一字段时报
InvalidUpdateError。 - 在 Jupyter 中直接
asyncio.run():会报RuntimeError: asyncio.run() cannot be called from a running event loop,应改用await graph.ainvoke(...)。
5.2 最佳实践清单
- 统一异步风格 :整个图尽量全部使用
async def节点,避免混用带来的心智负担。 - 阻塞调用用
asyncio.to_thread包装:兼容无法改造的同步库。 - 并发分支显式声明 reducer:保证状态合并语义清晰。
- 合理设置超时 :用
asyncio.wait_for给每个外部调用加超时,避免单个节点拖垮整图。 - 配合
RetryPolicy做重试:对不稳定的外部 API 尤其重要。 - 用
astream做可观测性:实时输出节点进度,便于调试和前端展示。
python
async def robust_node(state: State) -> dict:
try:
result = await asyncio.wait_for(risky_call(), timeout=10)
return {"answer": result}
except asyncio.TimeoutError:
return {"answer": "调用超时"}
6. 完整示例:异步多路检索 Agent
下面把前面的知识点串起来,实现一个「多路并行检索 + 汇总生成」的异步 Agent:
python
import asyncio
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.pregel import RetryPolicy
class AgentState(TypedDict):
query: str
contexts: Annotated[list, add]
answer: str
async def web_search(state: AgentState) -> dict:
await asyncio.sleep(0.8)
return {"contexts": [f"[web] {state['query']} 的网页结果"]}
async def vector_search(state: AgentState) -> dict:
await asyncio.sleep(0.6)
return {"contexts": [f"[vector] {state['query']} 的向量检索结果"]}
async def kb_search(state: AgentState) -> dict:
await asyncio.sleep(0.4)
return {"contexts": [f"[kb] {state['query']} 的知识库结果"]}
async def synthesize(state: AgentState) -> dict:
await asyncio.sleep(0.5)
merged = "\n".join(state["contexts"])
return {"answer": f"基于以下上下文生成答案:\n{merged}"}
builder = StateGraph(AgentState)
builder.add_node("web_search", web_search, retry=RetryPolicy(max_attempts=2))
builder.add_node("vector_search", vector_search)
builder.add_node("kb_search", kb_search)
builder.add_node("synthesize", synthesize)
builder.add_edge(START, "web_search")
builder.add_edge(START, "vector_search")
builder.add_edge(START, "kb_search")
builder.add_edge(["web_search", "vector_search", "kb_search"], "synthesize")
builder.add_edge("synthesize", END)
graph = builder.compile()
async def main():
result = await graph.ainvoke({"query": "LangGraph 异步节点", "contexts": []})
print(result["answer"])
if __name__ == "__main__":
asyncio.run(main())
执行流程如下:
#mermaid-svg-Ay7DTrKFvR4GrVuu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ay7DTrKFvR4GrVuu .error-icon{fill:#552222;}#mermaid-svg-Ay7DTrKFvR4GrVuu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ay7DTrKFvR4GrVuu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .marker.cross{stroke:#333333;}#mermaid-svg-Ay7DTrKFvR4GrVuu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ay7DTrKFvR4GrVuu p{margin:0;}#mermaid-svg-Ay7DTrKFvR4GrVuu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster-label text{fill:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster-label span{color:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster-label span p{background-color:transparent;}#mermaid-svg-Ay7DTrKFvR4GrVuu .label text,#mermaid-svg-Ay7DTrKFvR4GrVuu span{fill:#333;color:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .node rect,#mermaid-svg-Ay7DTrKFvR4GrVuu .node circle,#mermaid-svg-Ay7DTrKFvR4GrVuu .node ellipse,#mermaid-svg-Ay7DTrKFvR4GrVuu .node polygon,#mermaid-svg-Ay7DTrKFvR4GrVuu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .rough-node .label text,#mermaid-svg-Ay7DTrKFvR4GrVuu .node .label text,#mermaid-svg-Ay7DTrKFvR4GrVuu .image-shape .label,#mermaid-svg-Ay7DTrKFvR4GrVuu .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ay7DTrKFvR4GrVuu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .rough-node .label,#mermaid-svg-Ay7DTrKFvR4GrVuu .node .label,#mermaid-svg-Ay7DTrKFvR4GrVuu .image-shape .label,#mermaid-svg-Ay7DTrKFvR4GrVuu .icon-shape .label{text-align:center;}#mermaid-svg-Ay7DTrKFvR4GrVuu .node.clickable{cursor:pointer;}#mermaid-svg-Ay7DTrKFvR4GrVuu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .arrowheadPath{fill:#333333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ay7DTrKFvR4GrVuu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ay7DTrKFvR4GrVuu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ay7DTrKFvR4GrVuu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster text{fill:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu .cluster span{color:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ay7DTrKFvR4GrVuu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ay7DTrKFvR4GrVuu rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ay7DTrKFvR4GrVuu .icon-shape,#mermaid-svg-Ay7DTrKFvR4GrVuu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ay7DTrKFvR4GrVuu .icon-shape p,#mermaid-svg-Ay7DTrKFvR4GrVuu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ay7DTrKFvR4GrVuu .icon-shape .label rect,#mermaid-svg-Ay7DTrKFvR4GrVuu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ay7DTrKFvR4GrVuu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ay7DTrKFvR4GrVuu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ay7DTrKFvR4GrVuu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} START
web_search 异步节点
vector_search 异步节点
kb_search 异步节点
synthesize 汇总节点
END
三个检索节点并行执行,总耗时约等于最慢的那个(0.8 秒),而不是三者之和(1.8 秒)。
7. 总结
LangGraph 的异步 Node 设计核心可以归纳为三点:
- 节点用
async def,执行用ainvoke/astream,保持调用链一致。 - 并发靠图的并行边 +
asyncio.gather,并用 reducer 保证状态正确合并。 - 健壮性靠超时、重试与异常捕获,避免单个节点拖垮整图。
掌握这些写法后,你就能把 LangGraph 工作流的延迟压到接近理论下限,同时保持代码的可读性与可维护性。