LangGraph 异步案例
给我写一个langgraph异步的案例
下面给你一个LangGraph 异步案例:
场景是“并发查询两个异步数据源,再汇总结果”。
LangGraph 现在的异步写法核心是三点:节点用 async def、图编译后用 await graph.ainvoke(...) 或 async for ... in graph.astream(...) 调用;而且图在使用前必须先 compile()。(LangChain 文档)
安装
pip install -U langgraph
官方文档当前就是这样安装。(LangChain 文档)
完整示例
import asyncio
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
# 1) 定义状态
class State(TypedDict):
query: str
results: Annotated[list[str], operator.add] # 并行分支的结果会累加
summary: str
# 2) 定义异步节点
async def prepare(state: State):
print(f"[prepare] 开始处理查询: {state['query']}")
await asyncio.sleep(0.1)
return {}
async def fetch_user_profile(state: State):
print("[fetch_user_profile] 查询用户画像中...")
await asyncio.sleep(1) # 模拟异步 IO,例如数据库 / HTTP 请求
return {"results": [f"用户画像:{state['query']} 是高活跃用户"]}
async def fetch_order_history(state: State):
print("[fetch_order_history] 查询订单历史中...")
await asyncio.sleep(1.5) # 模拟另一个异步 IO
return {"results": [f"订单历史:{state['query']} 最近 30 天有 3 笔订单"]}
async def summarize(state: State):
print("[summarize] 汇总结果中...")
await asyncio.sleep(0.2)
summary = ";".join(state["results"])
return {"summary": f"最终汇总 -> {summary}"}
# 3) 构建图
builder = StateGraph(State)
builder.add_node("prepare", prepare)
builder.add_node("fetch_user_profile", fetch_user_profile)
builder.add_node("fetch_order_history", fetch_order_history)
builder.add_node("summarize", summarize)
# 4) 连边
builder.add_edge(START, "prepare")
# fan-out:prepare 之后并行执行两个异步节点
builder.add_edge("prepare", "fetch_user_profile")
builder.add_edge("prepare", "fetch_order_history")
# fan-in:两个分支都完成后,再进入 summarize
builder.add_edge("fetch_user_profile", "summarize")
builder.add_edge("fetch_order_history", "summarize")
builder.add_edge("summarize", END)
# 5) 编译
graph = builder.compile()
# 6) 异步运行
async def main():
input_state = {
"query": "张三",
"results": [], # 初始值
}
print("\n=== 方式 1:流式查看执行过程 ===")
async for chunk in graph.astream(
input_state,
stream_mode="updates",
version="v2",
):
print("stream chunk:", chunk)
print("\n=== 方式 2:直接拿最终结果(会再次执行一遍图)===")
result = await graph.ainvoke(input_state)
print("final result:", result)
if __name__ == "__main__":
asyncio.run(main())
你会看到什么
这个例子里:
fetch_user_profile和fetch_order_history会并发执行。results用了Annotated[list[str], operator.add],这样两个分支返回的列表会被合并,而不是互相覆盖。官方并行分支示例也是这么处理的。(LangChain 文档)summarize会在两个并行分支都完成后再执行;官方文档把这种模式称为 fan-out / fan-in,并说明同一 superstep 的并行更新顺序不保证固定。(LangChain 文档)astream()是异步流式输出接口,version="v2"是当前统一输出格式,文档说明该格式需要 LangGraph>= 1.1。(LangChain 文档)
一个常见改造:接大模型
如果你想把其中一个节点改成调用 LLM,官方推荐的异步模式就是在节点里直接 await llm.ainvoke(...),然后整个图继续用 await graph.ainvoke(...)。(LangChain 文档)
例如把 summarize 改成:
from langchain.chat_models import init_chat_model
llm = init_chat_model("gpt-5.2")
async def summarize(state: State):
prompt = "请把下面信息整理成客服摘要:\n" + "\n".join(state["results"])
resp = await llm.ainvoke(prompt)
return {"summary": resp.content}
你可以怎么理解这个例子
这段代码很适合拿来改造成这些真实场景:
- 并发请求多个外部 API
- 并发查数据库 + 向量库
- 并发做“召回 / 重排 / 总结”
- 多工具并行后统一汇总
如果你要,我下一条可以直接给你再写一个 “LangGraph + OpenAI 异步调用” 的版本。
每个节点结束的标志是什么,return吗
对,通常就是这个节点函数执行到 return 时,节点这一步就算结束了。
更准确一点说,LangGraph 里节点本质上就是一个函数:它接收当前 state,做计算/副作用,然后返回对 state 的更新。官方文档的表述也是“nodes take state, do work, and return updates”,并且建议节点返回 state update,而不是原地修改 state。(LangChain 文档)
你可以把它分成这几种情况理解:
-
普通结束:
return {...}
这是最常见的。def node_a(state): return {"x": 1}这个
return一发生,node_a就结束了,LangGraph 会把这次返回的更新合并进 state,然后按照边(edge)决定下一步。(LangChain 文档) -
异步节点:
async def里return {...}
异步节点也是一样,只不过是协程跑完。async def node_a(state): await some_io() return {"x": 1}这里节点结束的标志就是:协程执行完成并返回。然后你在图外层用
await graph.ainvoke(...)或astream(...)。(LangChain 文档) -
返回
Command(...)
不只是返回 dict。节点也可以返回Command,同时做两件事:update:更新 stategoto:直接指定跳到哪个节点
from langgraph.types import Command def node_a(state): return Command(update={"x": 1}, goto="node_b")这种情况下,节点也算在 return
Command(...)时结束,只是结束时顺带把“下一步去哪里”也一起交代了。(LangChain 文档) -
不是正常结束,而是“暂停”:
interrupt()
如果节点里调用了interrupt(),图会在那个位置暂停,保存状态,等待你之后用Command(resume=...)恢复。这个不是普通的“return 结束”,而是“挂起等待恢复”。(LangChain 文档) -
异常退出:抛错
如果节点里抛异常且没有被内部处理,这一步就不是正常完成,而是失败退出。官方也专门提到可以给节点配RetryPolicy来处理临时错误。(LangChain 文档)
再补一个你很容易观察到的现象:
- 用
stream_mode="updates"时,流里看到的是“每个 step 结束后,该节点返回的更新”。也就是说,只有节点这一步跑完、返回更新后,你才会在流里看到这个节点的 update。(LangChain 文档)
你可以直接记一句:
LangGraph 节点结束 = 节点函数执行完并返回结果;异步节点则是协程执行完并返回结果。
一个最小例子:
async def fetch_data(state):
print("开始")
await asyncio.sleep(1)
print("结束")
return {"data": "ok"}
这里 fetch_data 的“结束标志”就是最后这句:
return {"data": "ok"}
而整个图什么时候结束,不是看单个节点的 return,而是看流程有没有走到 END,或者没有后续可执行节点了。像 quickstart 里的条件路由就是返回某个节点名或 END 来决定图是否停止。(LangChain 文档)
你要的话,我下一条可以直接画一个:
“单个节点结束” vs “整个 graph 结束” 的执行流程图给你。
如果每个一个节点的信息需要传递给下一个节点,用什么传递呢
是通过 state(状态) 传递。最常见的方式不是“把参数直接传给下一个节点”,而是:
- 先在图里定义一个共享的
Stateschema - 当前节点
return一个字典,写入某些字段 - 下一个节点从
state[...]里读取这些字段。(LangChain 文档)
最小理解就是这句:
上一个节点 return 的更新,会合并进图的 state;下一个节点从 state 里拿数据。 (LangChain 文档)
例如:
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
question: str
rewritten_question: str
answer: str
def rewrite_question(state: State):
# 把结果写回 state
return {"rewritten_question": f"请更正式地表达:{state['question']}"}
def answer_question(state: State):
# 从 state 里读取上一个节点写入的数据
return {"answer": f"回答:{state['rewritten_question']}"}
builder = StateGraph(State)
builder.add_node("rewrite_question", rewrite_question)
builder.add_node("answer_question", answer_question)
builder.add_edge(START, "rewrite_question")
builder.add_edge("rewrite_question", "answer_question")
builder.add_edge("answer_question", END)
graph = builder.compile()
result = graph.invoke({"question": "langgraph 怎么传值?"})
print(result)
这里的数据流就是:
- 输入先进入
state["question"] rewrite_question返回{"rewritten_question": ...}- 这个值被写进 state
answer_question再从state["rewritten_question"]读取。(LangChain 文档)
你可以把它类比成:
- 节点之间不是直接传参
- 而是都在读写同一个“共享上下文 / 状态容器”state。(LangChain 文档)
还有两个你很快会遇到的点:
1) 只想给图内部节点传,不想暴露到输入输出
可以定义 internal/private state channels。官方文档专门提到:除了输入/输出 schema,还可以有内部 schema,供节点之间做内部通信。(LangChain 文档)
也就是说:
- 用户输入不一定要包含这个字段
- 最终输出也不一定要返回这个字段
- 但图内部节点仍然可以靠它传信息。(LangChain 文档)
2) 并行分支同时写同一个字段
这时要给该字段定义 reducer,不然多个分支更新同一 key 时会冲突。官方示例里常用 Annotated[..., operator.add] 来把多个分支结果合并起来。(LangChain 文档)
比如:
import operator
from typing import Annotated
from typing_extensions import TypedDict
class State(TypedDict):
results: Annotated[list[str], operator.add]
这样两个并行节点都返回:
{"results": ["A"]}
{"results": ["B"]}
最后会合并成:
["A", "B"]
而不是互相覆盖。(LangChain 文档)
你可以直接记这个结论
在 LangGraph 里,节点之间传信息主要靠 state:
- 当前节点:
return {"某字段": 某值} - 下一个节点:
state["某字段"]读取。(LangChain 文档)
Command(goto=...) 更偏向控制流程跳转;真正的数据传递,还是以 state 更新为主。LangGraph 底层运行时也把这种通信抽象成 channels,默认常见的是保存“最后一个值”的方式。(LangChain 文档)
你这个问题再往下走一步,通常就是想问:
“那我该怎么设计 State 字段,才不会乱?”
最实用的经验是把 State 分成三类字段:输入字段、过程字段、输出字段。
如果在一个fastapi服务中,我要把clickhouse_client、mysql_client、 以及大模型的client传入到每个节点中,该如何做
最推荐的做法是:
不要把 clickhouse_client、mysql_client、llm_client 放进 state,而是放进 LangGraph 的 runtime context。
LangGraph 官方把这类“数据库连接、API client、用户元数据”归为运行时上下文,通过调用图时的 context= 传入;而 state 用来保存图执行过程中会变化的业务数据和中间结果。节点里可以通过 runtime.context 读取这些依赖。(LangChain 文档)
在 FastAPI 里,通常是:
- 用 lifespan 在应用启动时初始化共享 client / 连接池
- 放到
app.state - 用
Depends或Request取出来 - 组装成一个
GraphContext - 调
await graph.ainvoke(..., context=graph_context)。FastAPI 官方推荐用lifespan处理启动/关闭资源;依赖注入用Depends()。(FastAPI)
下面给你一个可以直接照着改的结构。
1)定义 LangGraph 的 State 和 Context
from dataclasses import dataclass
from typing import Any
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.runtime import Runtime
# 图里流转的业务数据
class GraphState(TypedDict, total=False):
user_id: int
question: str
clickhouse_rows: list[dict]
mysql_rows: list[dict]
answer: str
# 传给每个节点的外部依赖
@dataclass
class GraphContext:
clickhouse_client: Any
mysql_client: Any
llm_client: Any
这里 GraphState 放“数据”,GraphContext 放“依赖”。这正好对应 LangGraph 文档里的 state 和 runtime context 分工。(LangChain 文档)
2)节点里通过 runtime.context 取 client
async def query_clickhouse(
state: GraphState,
runtime: Runtime[GraphContext],
) -> dict:
ch = runtime.context.clickhouse_client
user_id = state["user_id"]
# 示例:按你的 clickhouse SDK 改写
rows = await ch.query(
"SELECT event, ts FROM user_events WHERE user_id = %(user_id)s LIMIT 10",
{"user_id": user_id},
)
return {"clickhouse_rows": rows}
async def query_mysql(
state: GraphState,
runtime: Runtime[GraphContext],
) -> dict:
mysql = runtime.context.mysql_client
user_id = state["user_id"]
# 示例:按你的 mysql SDK / ORM 改写
rows = await mysql.fetch_all(
"SELECT id, name, level FROM users WHERE id = %s",
[user_id],
)
return {"mysql_rows": rows}
async def call_llm(
state: GraphState,
runtime: Runtime[GraphContext],
) -> dict:
llm = runtime.context.llm_client
prompt = f"""
用户问题:{state['question']}
ClickHouse 数据:
{state.get('clickhouse_rows', [])}
MySQL 数据:
{state.get('mysql_rows', [])}
请基于以上信息给出简洁回答。
""".strip()
# 示例:按你的大模型 SDK 改写
resp = await llm.ainvoke(prompt)
answer = getattr(resp, "content", str(resp))
return {"answer": answer}
LangGraph Graph API 官方示例就是这种签名:def node(state, runtime: Runtime[ContextSchema]),在节点里读 runtime.context.xxx。(LangChain 文档)
3)构建图时声明 context_schema
builder = StateGraph(GraphState, context_schema=GraphContext)
builder.add_node("query_clickhouse", query_clickhouse)
builder.add_node("query_mysql", query_mysql)
builder.add_node("call_llm", call_llm)
builder.add_edge(START, "query_clickhouse")
builder.add_edge("query_clickhouse", "query_mysql")
builder.add_edge("query_mysql", "call_llm")
builder.add_edge("call_llm", END)
graph = builder.compile()
要点是这句:
StateGraph(GraphState, context_schema=GraphContext)
这样 LangGraph 才知道运行时会注入什么上下文。调用时再通过 context= 传进去。官方文档就是这么用的。(LangChain 文档)
4)FastAPI 里初始化 client
from contextlib import asynccontextmanager
from fastapi import FastAPI
# 下面这些 create_* 只是占位,换成你自己的初始化逻辑
async def create_clickhouse_client():
...
# return client
async def create_mysql_client():
...
# return client
async def create_llm_client():
...
# return llm
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.clickhouse_client = await create_clickhouse_client()
app.state.mysql_client = await create_mysql_client()
app.state.llm_client = await create_llm_client()
app.state.graph = graph
yield
# 按你的 SDK 关闭
if hasattr(app.state.clickhouse_client, "close"):
maybe = app.state.clickhouse_client.close()
if hasattr(maybe, "__await__"):
await maybe
if hasattr(app.state.mysql_client, "close"):
maybe = app.state.mysql_client.close()
if hasattr(maybe, "__await__"):
await maybe
if hasattr(app.state.llm_client, "close"):
maybe = app.state.llm_client.close()
if hasattr(maybe, "__await__"):
await maybe
app = FastAPI(lifespan=lifespan)
FastAPI 官方现在推荐用 lifespan 而不是旧的 startup/shutdown 事件来管理这类应用级资源。(FastAPI)
5)通过依赖注入组装 GraphContext
from typing import Annotated
from fastapi import Depends, Request
def get_graph_context(request: Request) -> GraphContext:
return GraphContext(
clickhouse_client=request.app.state.clickhouse_client,
mysql_client=request.app.state.mysql_client,
llm_client=request.app.state.llm_client,
)
FastAPI 的 Depends() 就是干这个的:把外部组件整合进你的接口处理函数。(FastAPI)
6)接口里调用图
from pydantic import BaseModel
class AskRequest(BaseModel):
user_id: int
question: str
@app.post("/ask")
async def ask(
body: AskRequest,
request: Request,
ctx: Annotated[GraphContext, Depends(get_graph_context)],
):
result = await request.app.state.graph.ainvoke(
{
"user_id": body.user_id,
"question": body.question,
},
context=ctx,
)
return result
这一步就是把 FastAPI 注入出来的依赖,传给 LangGraph 的 context=。LangGraph 官方文档也说明了:运行时依赖是在 invoke/stream 时通过 context 传入。(LangChain 文档)
为什么不建议把这些 client 直接塞进 state
因为 state 的职责是本次图运行中会变化的数据,例如消息、SQL 查询结果、中间决策结果;而运行时上下文是单次调用中不变的依赖,比如数据库连接、API client、配置。LangGraph 官方对这两类东西的职责划分很明确。(LangChain 文档)
实际工程里,把 client 放进 state 还有几个坏处:
- 状态语义会变脏,不再只是业务数据
- 做 checkpoint / 调试时不友好
- 测试替换依赖会更麻烦
如果你的是“每个请求一个 DB Session / Transaction”
那就把连接池放到 app.state,把session / transaction 做成 FastAPI 的 yield 依赖,然后再拼成 GraphContext 传给图。
比如:
from typing import AsyncIterator
async def get_mysql_session(request: Request) -> AsyncIterator[Any]:
pool = request.app.state.mysql_pool
session = await pool.session()
try:
yield session
finally:
await session.close()
FastAPI 官方支持这种 yield 依赖模式,用来做“进入时创建、退出时清理”的资源管理。(FastAPI)
然后你再把 session 放进 GraphContext:
def build_graph_context(
request: Request,
mysql_session: Annotated[Any, Depends(get_mysql_session)],
) -> GraphContext:
return GraphContext(
clickhouse_client=request.app.state.clickhouse_client,
mysql_client=mysql_session,
llm_client=request.app.state.llm_client,
)
这个模式适合需要请求级事务的 MySQL 场景。
你可以直接记这个结论
在 FastAPI + LangGraph 里传 clickhouse_client、mysql_client、llm_client 给每个节点,推荐套路是:
FastAPI lifespan 初始化资源 → 放 app.state → Depends 取出 → 组装 GraphContext → graph.ainvoke(..., context=ctx) → 节点里 runtime.context.xxx 读取。
这也是 LangGraph 官方推荐的 runtime context 用法:把数据库连接、API client 这类依赖作为运行时上下文传给节点,而不是放进 state。(LangChain 文档)
如果你愿意,我可以下一条直接给你写一个“FastAPI + LangGraph + MySQL/ClickHouse/OpenAI 的完整可运行模板”。
如果接口是sse协议,如何流式返回一个节点yield的值呢
可以,但要先区分两件事:
1. LangGraph 节点里的“中间流”
在 Graph API 里,节点本身仍然应该 return 最终的 state update。
如果你想把节点执行过程中的中间值一边产生一边推给前端,不要指望节点本身的 Python yield 直接透传到 SSE;官方推荐的是用 custom stream,在节点内部通过 get_stream_writer() 或 writer: StreamWriter 发流式数据,然后在外层 graph.astream(..., stream_mode="custom") 接出来。(LangChain 文档)
2. FastAPI 的 SSE
FastAPI 现在官方支持 SSE,用 response_class=EventSourceResponse,在接口函数里 yield 每个事件;每次 yield 的内容会被编码成 SSE 的 data: 事件。(FastAPI)
所以正确套路是:
节点内部用 stream writer 发 chunk → 外层
graph.astream()收到 chunk → FastAPI SSE 接口把 chunkyield给浏览器。
推荐实现
方案结构
-
节点里:
writer({...})发中间块return {...}发最终状态
-
接口里:
async for chunk in graph.astream(..., stream_mode=["custom","updates"])- 把
custom/updates转成 SSE event
完整示例
1)图定义
from dataclasses import dataclass
from typing import Any
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.runtime import Runtime
from langgraph.types import StreamWriter
class GraphState(TypedDict, total=False):
question: str
answer: str
@dataclass
class GraphContext:
llm_client: Any
2)节点里“流式写出中间值”
这里演示一个节点调用大模型流式输出 token。
重点是:不是 yield token,而是 writer({...})。
async def stream_llm_answer(
state: GraphState,
runtime: Runtime[GraphContext],
writer: StreamWriter,
) -> dict:
llm = runtime.context.llm_client
question = state["question"]
full_text = ""
# 这里替换成你自己的模型 SDK
# 假设它支持 async for chunk in llm.stream(...)
async for chunk in llm.stream(question):
token = chunk["content"]
full_text += token
# 发给 LangGraph custom stream
writer({
"event": "token",
"node": "stream_llm_answer",
"content": token,
})
# 节点最终结束时再 return 最终 state
return {"answer": full_text}
LangGraph 官方文档现在明确支持这种模式:
节点内用 get_stream_writer() 或 writer: StreamWriter 发自定义数据,然后在 stream_mode="custom" 下从 stream/astream 拿到这些数据。(LangChain 文档)
3)构图
builder = StateGraph(GraphState, context_schema=GraphContext)
builder.add_node("stream_llm_answer", stream_llm_answer)
builder.add_edge(START, "stream_llm_answer")
builder.add_edge("stream_llm_answer", END)
graph = builder.compile()
4)FastAPI SSE 接口
from collections.abc import AsyncIterable
from fastapi import FastAPI, Request
from fastapi.sse import EventSourceResponse
from pydantic import BaseModel
app = FastAPI()
class ChatRequest(BaseModel):
question: str
def get_graph_context(request: Request) -> GraphContext:
return GraphContext(
llm_client=request.app.state.llm_client,
)
@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(
body: ChatRequest,
request: Request,
) -> AsyncIterable[dict]:
ctx = get_graph_context(request)
async def event_generator():
async for chunk in graph.astream(
{"question": body.question},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
):
# 客户端断开就结束
if await request.is_disconnected():
break
if chunk["type"] == "custom":
# 节点中 writer(...) 发出来的中间值
yield {
"event": "token",
"data": chunk["data"],
}
elif chunk["type"] == "updates":
# 节点 return 后的最终 state update
for node_name, update in chunk["data"].items():
yield {
"event": "node_done",
"data": {
"node": node_name,
"update": update,
},
}
yield {
"event": "done",
"data": {"ok": True},
}
return EventSourceResponse(event_generator())
FastAPI 官方 SSE 文档说明,SSE 路由可以直接 yield 事件对象,使用 EventSourceResponse 返回。(FastAPI)
前端收到的大概会是这样
如果你用浏览器 EventSource:
const es = new EventSource("/chat/stream");
es.addEventListener("token", (e) => {
const data = JSON.parse(e.data);
console.log("token:", data.content);
});
es.addEventListener("node_done", (e) => {
console.log("node done:", JSON.parse(e.data));
});
es.addEventListener("done", () => {
es.close();
});
服务端会一条条推:
event: token
data: {"event":"token","node":"stream_llm_answer","content":"你"}
event: token
data: {"event":"token","node":"stream_llm_answer","content":"好"}
event: node_done
data: {"node":"stream_llm_answer","update":{"answer":"你好"}}
event: done
data: {"ok":true}
你这个问题里最容易踩的坑
坑 1:在节点里直接 yield
如果你用的是 LangGraph Graph API,节点应该是普通函数/协程,核心输出仍是 return。
想传“过程值”,走 writer(...),不是节点函数直接生成器式 yield。官方自定义流文档就是这个模式。(LangChain 文档)
坑 2:只用 updates
updates 只能拿到节点结束后的 state update。
如果你要“边生成边返回”,必须加 custom;如果是 LLM 标准 token 流,还可以用 messages 模式。LangGraph 文档把这些 stream mode 区分得很清楚。(LangChain 文档)
坑 3:Python < 3.11
官方文档特别说明:在 Python < 3.11 的异步代码里,get_stream_writer() 不能正常用于 async 节点/工具;这时要在函数签名里直接接收 writer: StreamWriter。(LangChain 文档)
也就是你现在这种写法更稳:
async def node(state: State, writer: StreamWriter):
writer({"msg": "hello"})
return {...}
如果你还想把 MySQL / ClickHouse 查询过程也流出来
也一样:
async def query_mysql(
state: GraphState,
runtime: Runtime[GraphContext],
writer: StreamWriter,
) -> dict:
writer({"event": "progress", "node": "query_mysql", "message": "开始查 MySQL"})
rows = await runtime.context.mysql_client.fetch_all("SELECT ...")
writer({"event": "progress", "node": "query_mysql", "message": f"查到 {len(rows)} 条"})
return {"mysql_rows": rows}
然后 SSE 外层不用改,照样从 custom 拿。
一句话结论
如果接口是 SSE,想把“节点里的流式值”返回给前端,不要直接依赖节点 yield;正确做法是:
节点内部 writer(...) 发 custom stream → 外层 graph.astream(..., stream_mode="custom") 接收 → FastAPI EventSourceResponse 把每个 chunk yield 成 SSE 事件。 (LangChain 文档)
你要的话,我可以下一条直接给你写一个 “FastAPI + LangGraph + OpenAI 流式 SSE 完整可运行版”。
首先,如果有一个节点需要writer内容,其余的节点运行完毕后,汇总信息再return怎么做。其次,ctx和graph是不是可以通过接口依赖注入的方式,哪种方式好呢
可以,分开看:
第一部分:一个节点流式写,其他节点跑完,最后再统一汇总
这类图最稳的写法是:
- 只有需要流式输出的节点加
writer: StreamWriter - 这个节点在执行过程中
writer({...})发中间事件 - 它结束时仍然
return {...},把最终结果写回 state - 其他节点正常
return {...} - 最后再接一个 汇总节点,读取前面所有节点写进 state 的字段,统一
return最终结果
LangGraph 官方现在就是这么定义 custom streaming 的:在节点或 tool 内用 stream writer 发自定义数据,外层用 stream_mode="custom" 或 ["updates","custom"] 接收;而节点本身最后还是返回 state update。异步节点可以直接把 writer: StreamWriter 放在函数签名里,LangGraph 会自动传入。(LangChain 文档)
推荐结构
from dataclasses import dataclass
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.runtime import Runtime
from langgraph.types import StreamWriter
class State(TypedDict, total=False):
question: str
llm_draft: str
mysql_data: list[dict]
ck_data: list[dict]
final_answer: str
@dataclass
class Ctx:
mysql_client: object
clickhouse_client: object
llm_client: object
1)只有这个节点流式写
async def stream_draft(
state: State,
runtime: Runtime[Ctx],
writer: StreamWriter,
) -> dict:
llm = runtime.context.llm_client
full_text = ""
async for chunk in llm.stream(state["question"]):
token = chunk["content"]
full_text += token
writer({
"event": "token",
"node": "stream_draft",
"content": token,
})
return {"llm_draft": full_text}
2)其余节点正常 return
async def query_mysql(state: State, runtime: Runtime[Ctx]) -> dict:
rows = await runtime.context.mysql_client.fetch_all(
"SELECT id, name FROM users WHERE question = %s",
[state["question"]],
)
return {"mysql_data": rows}
async def query_ck(state: State, runtime: Runtime[Ctx]) -> dict:
rows = await runtime.context.clickhouse_client.query(
"SELECT event, ts FROM logs WHERE question = %(q)s LIMIT 20",
{"q": state["question"]},
)
return {"ck_data": rows}
3)最后统一汇总
async def finalize(state: State, runtime: Runtime[Ctx]) -> dict:
final_answer = f"""
草稿:
{state.get("llm_draft", "")}
MySQL:
{state.get("mysql_data", [])}
ClickHouse:
{state.get("ck_data", [])}
""".strip()
return {"final_answer": final_answer}
4)图连接方式
如果你希望三个节点都先完成,再进入 finalize,可以这样连:
builder = StateGraph(State, context_schema=Ctx)
builder.add_node("stream_draft", stream_draft)
builder.add_node("query_mysql", query_mysql)
builder.add_node("query_ck", query_ck)
builder.add_node("finalize", finalize)
builder.add_edge(START, "stream_draft")
builder.add_edge(START, "query_mysql")
builder.add_edge(START, "query_ck")
builder.add_edge("stream_draft", "finalize")
builder.add_edge("query_mysql", "finalize")
builder.add_edge("query_ck", "finalize")
builder.add_edge("finalize", END)
graph = builder.compile()
这样运行时:
stream_draft会一边执行一边往外发custom事件query_mysql、query_ck正常完成并写 state- 三个都结束后,
finalize才会执行,并返回最终结果
LangGraph 文档把这种模式当作 fan-out / fan-in,updates 会在节点完成后流出该节点的 state update,而 custom 可以在节点执行过程中不断发。(LangChain 文档)
SSE 外层怎么收
async for chunk in graph.astream(
{"question": body.question},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
):
...
其中:
custom:拿到writer(...)发出来的中间事件updates:拿到每个节点return后写入的状态更新,包括最后finalize的结果。(LangChain 文档)
一个关键建议
汇总节点最好读不同字段,不要让多个并行节点写同一个 key。
比如:
stream_draft -> llm_draftquery_mysql -> mysql_dataquery_ck -> ck_data
这样最清晰,也不用 reducer。
只有在多个并行分支必须共同更新同一个字段时,才需要 reducer 来定义合并行为;否则会有冲突风险。这个是 LangGraph 的常规状态设计习惯。(LangChain 文档)
第二部分:ctx 和 graph 能不能都通过依赖注入拿?哪种更好?
能,都可以。
但我建议你这样分层:
-
graph:应用级对象- 在 FastAPI
lifespan里创建一次 - 放到
app.state.graph - 路由里通过一个很薄的 dependency 取出来,或者直接
request.app.state.graph
- 在 FastAPI
-
ctx:请求级对象- 用
Depends()在每次请求里构造 - 里面放当次请求需要的 user 信息、session、db client、request-scoped 资源
- 用
这是因为 FastAPI 官方把两类场景分得很清楚:
lifespan适合应用启动/关闭时初始化和释放共享资源,是官方推荐方式;如果提供了lifespan,就不再建议混用旧的 startup/shutdown 事件。(FastAPI)Depends()适合依赖注入、共享数据库连接逻辑、认证、复用请求处理逻辑;如果某个依赖需要请求结束后清理资源,可以用yield依赖。(FastAPI)
同时,LangGraph 官方对 context 的定位也很明确:
context 是单次运行的静态 runtime context,适合放 user metadata、tools、db connections 这类依赖;而会在一次 run 中变化的数据应该放在 state。Runtime 里也提供了 stream_writer。(LangChain 文档)
我推荐的实践
方案 A:最实用
1)graph 在 lifespan 初始化
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.mysql_client = await create_mysql_client()
app.state.ck_client = await create_ck_client()
app.state.llm_client = await create_llm_client()
app.state.graph = build_graph()
yield
await app.state.mysql_client.close()
await app.state.ck_client.close()
FastAPI 官方推荐 lifespan 做这类启动/关闭资源管理。(FastAPI)
2)graph 用薄依赖拿
from fastapi import Depends, Request
from typing import Annotated
def get_graph(request: Request):
return request.app.state.graph
3)ctx 每次请求构造
def get_graph_context(request: Request) -> Ctx:
return Ctx(
mysql_client=request.app.state.mysql_client,
clickhouse_client=request.app.state.ck_client,
llm_client=request.app.state.llm_client,
)
4)接口里注入
@app.post("/chat/stream")
async def chat_stream(
body: ChatRequest,
request: Request,
graph = Depends(get_graph),
ctx: Ctx = Depends(get_graph_context),
):
async def event_generator():
async for chunk in graph.astream(
{"question": body.question},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
):
yield {
"event": chunk["type"],
"data": chunk["data"],
}
return EventSourceResponse(event_generator())
这个组合最顺手。
为什么我不建议把 graph 也做成“每请求现建”
工程上通常不划算,原因很简单:
- graph 的结构通常是固定的
- 每次请求重新 build/compile 没必要
- 共享一个应用级 graph,更符合
lifespan的资源初始化思路
这条更多是工程实践建议。
而 ctx 不一样,它天生就是一次 run 的上下文,LangGraph 也是通过 invoke/stream(..., context=...) 来传它的。(LangChain 文档)
什么时候 ctx 也要拆成更细的依赖
如果你的 MySQL 用的是请求级 session / transaction,那不要把 session 常驻在 app.state。
更好的做法是:
- 连接池 / client 放
app.state - 每请求 session 用
yielddependency 创建和关闭 - 再把这个 session 装进
ctx
FastAPI 官方专门推荐用 yield 依赖做这种“创建后注入、请求结束后关闭”的数据库资源。(FastAPI)
像这样:
async def get_mysql_session(request: Request):
session = await request.app.state.mysql_pool.session()
try:
yield session
finally:
await session.close()
然后:
def get_graph_context(
request: Request,
mysql_session = Depends(get_mysql_session),
) -> Ctx:
return Ctx(
mysql_client=mysql_session,
clickhouse_client=request.app.state.ck_client,
llm_client=request.app.state.llm_client,
)
最后给你一个明确结论
关于“一个节点写流,其他节点最后汇总”
可以,推荐模式是:
- 只有那个节点接
writer: StreamWriter - 它用
writer(...)发中间事件 - 它和其他节点都正常
return - 再加一个
finalize节点统一读取 state 并return最终结果 - 外层
graph.astream(..., stream_mode=["custom","updates"])做 SSE 转发。(LangChain 文档)
关于 ctx 和 graph 的注入方式
都能通过接口依赖注入拿到,但更好的分工是:
- graph:在
lifespan初始化成应用级单例 - ctx:用
Depends()按请求构造 - 请求级 session:用
yielddependency
这套分法最符合 FastAPI 和 LangGraph 对资源与上下文的职责划分。(FastAPI)
我可以直接把你现在这段 ctx = get_graph_context(request) / graph.astream(...) 改成一份完整的 FastAPI SSE 模板。
如果这些节点需要读取reqeust请求体的内容,该如何做
最稳的做法是:
把“请求体里真正参与图内流转的数据”放进 state,让节点从 state 读取;只有少量不适合进 state 的请求级信息,才放进 context。
这是因为 LangGraph 把会在单次运行中流转、演化的数据放在 state,而把静态的运行时依赖放在 context;FastAPI 这边也推荐先用 Pydantic 模型声明请求体,而不是让下游逻辑自己去直接读原始 Request。(LangChain 文档)
先给结论
适合放进 state 的
请求体里的业务字段,比如:
questionuser_idsession_idfiltersmessagestrace_id(如果图里多个节点都会用)
这类数据本来就是这次 graph run 的输入,后续多个节点都要读,最适合直接作为 graph.ainvoke(...) 的输入 state。LangGraph 文档把这类“单次运行中会变化/被读取的数据”归为动态 runtime context,也就是 graph state。(LangChain 文档)
适合放进 context 的
请求级但更偏“依赖/环境”的东西,比如:
mysql_clientclickhouse_clientllm_client- 当前登录用户对象
- 权限信息
- 原始
Request的少量衍生信息,比如client_ip、headers里某些值
LangGraph 官方把数据库连接、user metadata、配置这类内容放在 context。(LangChain 文档)
不建议直接传给节点的
原始 Request 对象本身。
能传,但一般不推荐,因为它会让 graph 节点和 FastAPI/Starlette 强耦合,而且这类对象不适合作为图内主要数据载体。FastAPI 官方也说明:直接从 Request 读数据时,FastAPI 就不会帮你做验证、转换和 OpenAPI 文档生成。(FastAPI)
你这个场景的推荐写法
1)先定义请求体模型
from pydantic import BaseModel
class ChatRequest(BaseModel):
user_id: int
question: str
scene: str | None = None
filters: dict | None = None
FastAPI 官方推荐用 Pydantic 模型声明 request body,这样有校验、类型转换和自动文档。(FastAPI)
2)把“节点都要用的请求体字段”放进 State
from typing_extensions import TypedDict
class State(TypedDict, total=False):
user_id: int
question: str
scene: str | None
filters: dict | None
llm_draft: str
mysql_data: list[dict]
ck_data: list[dict]
final_answer: str
3)调用 graph 时,把 body 映射成初始 state
result = await graph.ainvoke(
{
"user_id": body.user_id,
"question": body.question,
"scene": body.scene,
"filters": body.filters,
},
context=ctx,
)
这样你的节点就直接读:
async def query_mysql(state: State, runtime: Runtime[Ctx]) -> dict:
rows = await runtime.context.mysql_client.fetch_all(
"SELECT id, name FROM users WHERE id = %s",
[state["user_id"]],
)
return {"mysql_data": rows}
async def stream_draft(
state: State,
runtime: Runtime[Ctx],
writer: StreamWriter,
) -> dict:
prompt = f"问题:{state['question']}\n场景:{state.get('scene')}"
full_text = ""
async for chunk in runtime.context.llm_client.stream(prompt):
token = chunk["content"]
full_text += token
writer({"event": "token", "content": token})
return {"llm_draft": full_text}
也就是说,节点读取请求体内容,本质上就是读取 state 里的输入字段。
如果某些请求信息不想放进 state
比如你有这些需求:
- 只想给节点用,但不想让它成为 graph 的业务状态
- 这个值不会在图执行中变化
- 它更像“本次请求的上下文信息”
那就放进 context。
例子:把 header / client ip / request_id 放进 context
from dataclasses import dataclass
from fastapi import Request
@dataclass
class Ctx:
mysql_client: object
clickhouse_client: object
llm_client: object
client_ip: str | None
request_id: str | None
def get_graph_context(request: Request) -> Ctx:
return Ctx(
mysql_client=request.app.state.mysql_client,
clickhouse_client=request.app.state.ck_client,
llm_client=request.app.state.llm_client,
client_ip=request.client.host if request.client else None,
request_id=request.headers.get("x-request-id"),
)
FastAPI 确实支持你在路由或依赖里直接拿 Request 对象。(FastAPI)
然后节点里:
async def audit_node(state: State, runtime: Runtime[Ctx]) -> dict:
client_ip = runtime.context.client_ip
request_id = runtime.context.request_id
# 做审计日志
return {}
SSE 场景下怎么写
和普通接口一样,请求体先由 FastAPI 解析成 body,然后再塞进 state。
SSE 只是返回方式不同,不影响你读取请求体。
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.sse import EventSourceResponse
@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(
body: ChatRequest,
request: Request,
graph = Depends(get_graph),
ctx: Annotated[Ctx, Depends(get_graph_context)],
):
async def event_generator():
async for chunk in graph.astream(
{
"user_id": body.user_id,
"question": body.question,
"scene": body.scene,
"filters": body.filters,
},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
):
if await request.is_disconnected():
break
yield {
"event": chunk["type"],
"data": chunk["data"],
}
return EventSourceResponse(event_generator())
这里 body 就是已经校验好的请求体;图里的所有节点都通过 state 读这些字段。
什么时候可以直接把 Request 放进 ctx
可以,但只建议在这类场景:
- 你只需要少量原始能力,比如读 cookie、header、client host
- 某个节点必须依赖底层 request 上下文
- 你明确接受 graph 和 FastAPI 耦合
例如:
@dataclass
class Ctx:
request: Request
mysql_client: object
llm_client: object
然后:
def get_graph_context(request: Request) -> Ctx:
return Ctx(
request=request,
mysql_client=request.app.state.mysql_client,
llm_client=request.app.state.llm_client,
)
节点里:
async def some_node(state: State, runtime: Runtime[Ctx]) -> dict:
lang = runtime.context.request.headers.get("accept-language")
return {}
但这不是首选。
更好的方式通常是:在 FastAPI 层先把你需要的内容提取出来,再放进 ctx 的普通字段里。这样 graph 更干净,也更好测。
你可以按这个原则判断
放进 state
当满足下面任意一点时:
- 多个节点都要读
- 它属于业务输入
- 它可能参与后续节点推理/汇总
- 它本来就应该成为 graph 执行数据的一部分
比如:
question、user_id、messages、filters
放进 context
当满足下面任意一点时:
- 它是依赖,不是业务状态
- 它在这次 run 中不变
- 它更像配置/连接/用户元数据
- 你不想让它混进 graph 状态
比如:
mysql_client、llm_client、client_ip、request_id
对你前面那段代码,直接怎么改
你前面这些节点:
async def stream_draft(...):
...
async for chunk in llm.stream(state["question"]):
async def query_mysql(...):
...
[state["question"]]
async def query_ck(...):
...
{"q": state["question"]}
已经是对的。
如果这些节点还要读取请求体里的 user_id、scene、filters,就继续把它们也放进 State:
class State(TypedDict, total=False):
user_id: int
question: str
scene: str | None
filters: dict | None
llm_draft: str
mysql_data: list[dict]
ck_data: list[dict]
final_answer: str
然后路由里:
await graph.astream(
{
"user_id": body.user_id,
"question": body.question,
"scene": body.scene,
"filters": body.filters,
},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
)
节点里直接读:
state["user_id"]
state["question"]
state.get("scene")
state.get("filters")
一句话总结:
节点要读取 request body,最推荐的方式不是把 Request 传进每个节点,而是让 FastAPI 先把请求体解析成 Pydantic 模型,再把需要的字段作为 graph 的初始 state 传进去;只有数据库连接、header 衍生值、client_ip 这类静态请求上下文,才放进 context。 (LangChain 文档)
我可以接着把你这套代码直接整理成一份完整的 FastAPI + LangGraph + SSE 示例。
可以两个节点同时对state.mbsz_list:List[Dict]的元素添加键值嘛
可以,但不建议让两个并行节点直接去“改同一个 state.mbsz_list 里的元素”。更准确地说:
- 如果两个节点在同一个并行 step / super-step里都更新同一个 state key(这里是
mbsz_list),没有 reducer 时,LangGraph 会报INVALID_CONCURRENT_GRAPH_UPDATE。(LangChain 文档) - LangGraph 官方还明确建议:节点应该返回 state update,而不是原地 mutate state。这条对嵌套的
list[dict]也一样适用。(LangChain 文档) - reducer 的粒度是 state 的某个 key,也就是“整个
mbsz_list怎么合并”,不是“自动帮你安全地 merge 列表里某个 dict 的某个字段”。(LangChain 文档)
所以结论是:
结论
能并行更新同一个 mbsz_list key,但前提是你自己定义清楚这个 key 的合并规则;不能指望两个节点同时原地给同一个 dict 加键值就自然安全。 (LangChain 文档)
不推荐的写法
def node_a(state):
state["mbsz_list"][0]["a"] = 1
return {"mbsz_list": state["mbsz_list"]}
def node_b(state):
state["mbsz_list"][0]["b"] = 2
return {"mbsz_list": state["mbsz_list"]}
这类写法有两个问题:
- 是原地修改,违背 LangGraph 对 state update 的推荐方式。(LangChain 文档)
- 两个并行节点都返回
mbsz_list,如果没有 reducer,会冲突报错;即使有 reducer,默认也不会懂你想按“列表元素里的字段”来 merge。(LangChain 文档)
最稳的两种方案
方案一:并行节点写不同 key,最后汇总
这是最推荐的。
from typing_extensions import TypedDict
class State(TypedDict, total=False):
mbsz_list: list[dict]
enrich_a: dict[str, dict]
enrich_b: dict[str, dict]
并行节点分别产出 patch:
def node_a(state: State):
return {
"enrich_a": {
item["id"]: {"field_a": "A"}
for item in state["mbsz_list"]
}
}
def node_b(state: State):
return {
"enrich_b": {
item["id"]: {"field_b": "B"}
for item in state["mbsz_list"]
}
}
最后一个 finalize 节点统一 merge:
def finalize(state: State):
out = []
enrich_a = state.get("enrich_a", {})
enrich_b = state.get("enrich_b", {})
for item in state["mbsz_list"]:
item_id = item["id"]
merged = {
**item,
**enrich_a.get(item_id, {}),
**enrich_b.get(item_id, {}),
}
out.append(merged)
return {"mbsz_list": out}
这个方案的优点是:
- 并行阶段不抢同一个 key
- 逻辑最清晰
- 最容易排查冲突
这也最符合 LangGraph “节点返回 update、最后通过图控制流整合状态”的用法。(LangChain 文档)
方案二:给 mbsz_list 定义自定义 reducer
如果你就是想两个节点都更新 mbsz_list,那要自己定义 reducer,明确“怎么合并两个并行更新”。LangGraph 支持给每个 state key 单独定义 reducer。(LangChain 文档)
例如按 id merge:
from typing import Annotated
from typing_extensions import TypedDict
def merge_mbsz_list(left: list[dict], right: list[dict]) -> list[dict]:
by_id = {item["id"]: dict(item) for item in left}
for patch in right:
item_id = patch["id"]
if item_id not in by_id:
by_id[item_id] = dict(patch)
else:
by_id[item_id] = {**by_id[item_id], **patch}
return list(by_id.values())
class State(TypedDict):
mbsz_list: Annotated[list[dict], merge_mbsz_list]
并行节点返回“补丁列表”:
def node_a(state: State):
return {
"mbsz_list": [
{"id": item["id"], "field_a": "A"}
for item in state["mbsz_list"]
]
}
def node_b(state: State):
return {
"mbsz_list": [
{"id": item["id"], "field_b": "B"}
for item in state["mbsz_list"]
]
}
这样 reducer 会把两个节点返回的 mbsz_list 合成一个新列表。
这个方案要注意
- reducer 合并的是整个 key 的值
- 你最好用稳定主键(如
id),不要靠列表下标 - reducer 要返回新值,不要在原对象上乱改
这些都和 LangGraph 的 reducer 模型一致。(LangChain 文档)
我给你的实际建议
如果你的 mbsz_list: List[Dict] 是这种结构:
[
{"id": "1", "name": "x"},
{"id": "2", "name": "y"}
]
并且两个节点只是分别补充不同字段,比如:
- 节点 A 补
score - 节点 B 补
tag
那最好用方案一:
- A 写
score_by_id - B 写
tag_by_id - 最后统一 merge 回
mbsz_list
因为它比“并行 reducer merge 嵌套 list[dict]”更稳、更容易维护。
一句话回答你的问题
可以并行“补充同一批元素的信息”,但不要让两个节点直接原地改 state.mbsz_list 里的 dict。
在 LangGraph 里,正确做法要么是:
- 并行写不同 state key,最后汇总
要么是 - 给
mbsz_list定义自定义 reducer,让两个节点返回可合并的 patch 列表。 (LangChain 文档)
你贴一下你现在 mbsz_list 的结构,我可以直接按你的数据结构给你写一个 reducer 版本。
langgraph可以定义一个状态,其中一个属性是异步队列,然后一个节点向队列中添加内容,然后另一个节点从State的队列中取出内容嘛
不建议,一般不要把 asyncio.Queue 这类异步队列放进 LangGraph 的 State 里,让一个节点 put、另一个节点再 get。
更直接地说:
- LangGraph 的节点应该返回 state update,而不是去原地修改 state 对象。官方文档明确这么写。(LangChain 文档)
- LangGraph 运行时是 Plan → Execution → Update 的 super-step 模型;在一个 step 里,节点写出的更新对其他节点不可见,要到下一步才会应用。官方文档原文就是 “During this phase, channel updates are invisible to actors until the next step.” (LangChain 文档)
- 如果你启用了持久化/checkpointer,checkpoint 会保存 state channel values;文档里也展示了 checkpoint 保存每一步的 state,并默认通过序列化器存取。
asyncio.Queue这类运行时对象天然不适合当持久状态。(LangChain 文档)
所以,从 LangGraph 的设计上看,State 适合放:
- 会被节点读取和返回更新的业务数据
- 可合并/可重放/可持久化的状态
而不是放:
- 需要节点原地
put/get的可变异步对象 - 依赖实时共享内存语义的运行时队列
你这个设计为什么别扭
你设想的是:
producer_node往state.queue里putconsumer_node从state.queue里get
这更像 共享内存 + 实时消息队列。
但 LangGraph 更像:
- 节点读取当前 step 的 state 快照
- 节点返回“更新”
- 运行时在 step 结束后把更新应用到 channels/state
- 下一步节点再读到新值 (LangChain 文档)
所以它不是那种“两个节点拿着同一个队列对象协同运行”的模型。
正确替代方案有 3 种
方案 1:把“队列”建模成 state 里的普通数据
这是 Graph API 里最贴近你需求的方式。
比如把队列表示成一个列表:
from typing import Annotated
import operator
from typing_extensions import TypedDict
class State(TypedDict):
queue_items: Annotated[list[dict], operator.add]
processed: list[dict]
生产节点:
def producer(state: State):
return {
"queue_items": [{"id": 1, "payload": "hello"}]
}
消费节点在后续 step读取:
def consumer(state: State):
items = state["queue_items"]
if not items:
return {}
first = items[0]
remaining = items[1:]
return {
"processed": state.get("processed", []) + [first],
"queue_items": remaining, # 用覆盖式更新实现“出队”
}
这个本质上是“用可持久化状态模拟队列”,而不是用真实 asyncio.Queue。
它符合 LangGraph 的状态更新模型。官方也说明每个 state key 的更新是通过 reducer 或覆盖规则处理的。(LangChain 文档)
方案 2:如果你需要真正的“消息通道”,用底层 Pregel channels
LangGraph 运行时底层本来就是 channels 模型。官方文档列出了:
LastValueTopicBinaryOperatorAggregateEphemeralValue(LangChain 文档)
其中 Topic 明确适合“发送多个值”或累积输出:
Topic(..., accumulate=True)可以积累多个值EphemeralValue适合跨 step 的临时传递 (LangChain 文档)
如果你真的想做“生产者写消息、消费者下一步读消息”,Pregel channel 比把 asyncio.Queue 塞进 state 更像官方推荐路线。
方案 3:如果你要真实异步队列,放到图外或外部依赖里
如果你的真实需求是:
- 一个节点一边产出
- 另一个协程/服务一边消费
- 甚至和 SSE / websocket / 后台任务联动
那这个“队列”更适合放在:
- FastAPI 层
- 外部 worker
- 或图外的运行时对象
而不是 State。
你前面在 FastAPI 场景里已经有 context/依赖注入了。更合理的是:
- 图内节点通过
writer(...)发流式事件 - FastAPI 层或外部协程消费这些事件
- 真正的
asyncio.Queue放在图外的应用逻辑里
能不能“技术上硬塞”进去?
勉强可以,但不推荐。
比如你用 dataclass state,塞一个 asyncio.Queue 字段,在无持久化、无重放、无并行冲突、严格顺序执行的极窄场景下,也许你能让某个节点拿到这个对象。
但这会和 LangGraph 的几个核心能力冲突:
- 节点应返回更新,不应原地 mutate state (LangChain 文档)
- super-step 中写入对同 step 其他节点不可见 (LangChain 文档)
- checkpoint/重放需要 state 可保存、可恢复 (LangChain 文档)
所以这条路工程上不稳。
你要的是哪种“队列”语义?
这点很关键:
如果你要“先产出,后消费”
用 state 列表/patch + 后续节点读取 就够了。
如果你要“多值消息通道”
看 Pregel 的 Topic / EphemeralValue 更合适。(LangChain 文档)
如果你要“真正实时的 asyncio 生产者/消费者”
把 asyncio.Queue 放到 图外,不要放 State。
最后给你一个明确结论
不建议把异步队列定义成 LangGraph 的 state 属性,然后让一个节点 put、另一个节点 get。
因为 LangGraph 的 state 是按 step 读写和合并的共享状态,不是节点共享的可变队列;节点应该返回 state update,且一个 step 里的写入要到下一步才对其他节点可见。启用持久化时,state 还会被 checkpoint 保存,这也让 asyncio.Queue 这类运行时对象更不适合作为 state。(LangChain 文档)
更合适的替代是:
- 用 state 里的列表/字段 模拟队列
- 或用 Pregel channels(如
Topic/EphemeralValue) - 真正的
asyncio.Queue放在图外
如果你愿意,我可以直接按你的目标,给你写两版最小示例:
一版是 Graph API 用列表模拟队列,一版是 Pregel channel 做生产者/消费者。
在fastapi中,对于一个接口,我希望一个节点先流式返回内容,返回结束后,另一个节点再流式返回内容,该如何做
可以,最直接的做法就是:
把图连成串行:START -> node1 -> node2 -> END,然后让 node1 和 node2 都在各自内部流式写出内容。
因为 LangGraph 里普(LangChain 文档)s** 时,下一步目标节点才会并行执行。(docs.langchain.com )
而自定义流式数据要通过 writer 发出,并在外层用 stream_mode="custom" 或 ["custom","updates"] 接收。(docs.langchain.com)
所以你要的语义其实是:
node1内部一边生成一边writer(...)node1流完并return- LangGraph 才开始执行
node2 node2再一边生成一边writer(...)- FastAPI SSE 把这些 chunk 按收到的顺序发给前端
只要图是串行边,天然就是先 node1 流完,再 node2 流。(docs.langchain.com)
一份完整可套的写法
1)定义 State / Context
from dataclasses import dataclass
from typing import Any
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.runtime import Runtime
from langgraph.types import StreamWriter
class State(TypedDict, total=False):
question: str
draft: str
final_answer: str
@dataclass
class Ctx:
llm_client: Any
2)第一个节点先流式返回
这里假设你的模型客户端支持:
async for chunk in llm.stream(prompt):
...
async def node1_generate_draft(
state: State,
runtime: Runtime[Ctx],
writer: StreamWriter,
) -> dict:
llm = runtime.context.llm_client
full_text = ""
# 告诉前端:node1 开始
writer({
"event": "phase_start",
"node": "node1_generate_draft",
})
async for chunk in llm.stream(f"先写一个草稿:{state['question']}"):
token = chunk["content"]
full_text += token
writer({
"event": "token",
"node": "node1_generate_draft",
"content": token,
})
# 告诉前端:node1 结束
writer({
"event": "phase_end",
"node": "node1_generate_draft",
})
return {"draft": full_text}
LangGraph 官方说明,异步节点可以直接把 writer: StreamWriter 放在函数签名里,LangGraph 会自动注入;通过 stream_mode="custom" 可以收到节点内部发出的自定义数据。(LangChain 文档)节点等第一个结束后再流
node2 读取 state["draft"],这说明它一定是在 node1 return 之后才开始的。
async def node2_rewrite(
state: State,
runtime: Runtime[Ctx],
writer: StreamWriter,
) -> dict:
llm = runtime.context.llm_client
full_text = ""
writer({
"event": "phase_start",
"node": "node2_rewrite",
})
prompt = f"把下面草稿润色成最终答案:\n\n{state['draft']}"
async for chunk in llm.stream(prompt):
token = chunk["content"]
full_text += token
writer({
"event": "token",
"node": "node2_rewrite",
"content": token,
})
writer({
"event": "phase_end",
"node": "node2_rewrite",
})
return {"final_answer": full_text}
4)图要严格串行连接
builder = StateGraph(State, context_schema=Ctx)
builder.add_node("node1_generate_draft", node1_generate_draft)
builder.add_node("node2_rewrite", node2_rewrite)
builder.add_edge(START, "node1_generate_draft")
builder.add_edge("node1_generate_draft", "node2_rewrite")
builder.add_edge("node2_rewrite", END)
graph = builder.compile()
这里是最关键的一行:
builder.add_edge("node1_generate_draft", "node2_rewrite")
普通 edge 是顺序执行;而一个节点有多个 outgoing edges 才会在下一 superstep 并行执行。(LangChain 文档)API SSE 接口
FastAPI 现在官方支持 SSE,做法是:
response_class=EventSourceResponse- 在路径函数里
yield - 每个 yield 的对象会被发成 SSE 事件
- 需要自定义
event/id/retry时,可以 yieldServerSentEvent。(fastapi.tiangolo.com)
from collections.abc import AsyncIterable
from fastapi import FastAPI, Request
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel
app = FastAPI()
class ChatRequest(BaseModel):
question: str
def get_graph():
return graph
def get_graph_context(request: Request) -> Ctx:
return Ctx(llm_client=request.app.state.llm_client)
@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(
body: ChatRequest,
request: Request,
) -> AsyncIterable[ServerSentEvent]:
graph = get_graph()
ctx = get_graph_context(request)
async def event_generator():
async for chunk in graph.astream(
{"question": body.question},
context=ctx,
stream_mode=["custom", "updates"],
version="v2",
):
if await request.is_disconnected():
break
if chunk["type"] == "custom":
data = chunk["data"]
yield ServerSentEvent(
event=data.get("event", "message"),
data=data,
)
elif chunk["type"] == "updates":
for node_name, update in chunk["data"].items():
yield ServerSentEvent(
event="node_done",
data={
"node": node_name,
"update": update,
},
)
yield ServerSentEvent(event="done", data={"ok": True})
return EventSourceResponse(event_generator())
FastAPI 官方文档明确说明:
- SSE 可以用
EventSourceResponse - 路由里直接
yield - 也支持
ServerSentEvent - SSE 也可以走
POST,不只限GET。(FastAPI)序会是什么
如果图是串行的,事件顺序就会类似这样:
event: phase_start
data: {"event":"phase_start","node":"node1_generate_draft"}
event: token
data: {"event":"token","node":"node1_generate_draft","content":"第"}
event: token
data: {"event":"token","node":"node1_generate_draft","content":"一"}
...
event: phase_end
data: {"event":"phase_end","node":"node1_generate_draft"}
event: node_done
data: {"node":"node1_generate_draft","update":{"draft":"..."}}
event: phase_start
data: {"event":"phase_start","node":"node2_rewrite"}
event: token
data: {"event":"token","node":"node2_rewrite","content":"第"}
...
也就是:
node1的 token 先全部出来node1return后,你会收到它的updates- 然后
node2才开始流
最容易踩的坑
1)不要把两个节点都接在 START 上
这种写法会并行:
builder.add_edge(START, "node1")
builder.add_edge(START, "node2")
因为同一个节点有多个 outgoing edges 时,目标节点会在下一 superstep 并行执行。(LangChain 文档)-
2)不要在 node1 里开后台任务再立刻 return
如果你在 node1 里偷偷开了后台协程去流 token,然后 node1 先 return,LangGraph 就会继续执行 node2。
这样前端就会看到两个节点的内容交错。
你要的是:
- node1 内部完整 await 它自己的流
- 全部流完后再 return
这样才有严格的“先 A 后 B”。
3)如果你用的是 LangChain 标准模型,也可以用 messages 模式
如果节点里是 model.ainvoke(...) / model.astream(...) 这类 LangChain 兼容调用,LangGraph 也支持 stream_mode="messages" 来流 token,并且 metadata 里会带 langgraph_node,可以区分 token 来自哪个节点。(docs.langchain.com)
但你这个场景里,custom 更灵活,因为你还能同时发:
phase_startphase_endprogress- 非 LLM 的业务消息
什么时候用 updates,什么时候用 custom
custom
适合:
- token 流
- 进度条
- phase 开始/结束
- 自定义结构化事件
updates
适合:
- 节点
return之后的最终 state update - 告诉前端“这个节点已经跑完了”
LangGraph streaming 文档明确区分了:
updates:每个 graph step 后的 state updatecustom:节点内部通过 stream writer 发出的自定义数据- 也可以同时开多个模式。(LangChain 文档)
在 FastAPI 里实现“一个节点先流式返回,结束后另一个节点再流式返回”,做法就是:
把 LangGraph 连成串行链路 START -> node1 -> node2 -> END,让两个节点都通过 writer 发 custom 流;然后在 FastAPI 的 EventSourceResponse 里用 graph.astream(..., stream_mode=["custom","updates"], version="v2") 顺序转发成 SSE。
只要 node2 是 node1 的下游普通边,并且 node1 在内部把自己的流完整 await 完才 return,就会天然满足“node1 流完后,node2 再开始流”。(LangChain 文档)代码改成 “OpenAI 异步流 + FastAPI SSE + LangGraph 两阶段输出” 的可运行版本。

浙公网安备 33010602011771号