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_profilefetch_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 文档)

你可以把它分成这几种情况理解:

  1. 普通结束:return {...}
    这是最常见的。

    def node_a(state):
        return {"x": 1}
    

    这个 return 一发生,node_a 就结束了,LangGraph 会把这次返回的更新合并进 state,然后按照边(edge)决定下一步。(LangChain 文档)

  2. 异步节点:async defreturn {...}
    异步节点也是一样,只不过是协程跑完。

    async def node_a(state):
        await some_io()
        return {"x": 1}
    

    这里节点结束的标志就是:协程执行完成并返回。然后你在图外层用 await graph.ainvoke(...)astream(...)。(LangChain 文档)

  3. 返回 Command(...)
    不只是返回 dict。节点也可以返回 Command,同时做两件事:

    • update:更新 state
    • goto:直接指定跳到哪个节点
    from langgraph.types import Command
    
    def node_a(state):
        return Command(update={"x": 1}, goto="node_b")
    

    这种情况下,节点也算在 return Command(...) 时结束,只是结束时顺带把“下一步去哪里”也一起交代了。(LangChain 文档)

  4. 不是正常结束,而是“暂停”:interrupt()
    如果节点里调用了 interrupt(),图会在那个位置暂停,保存状态,等待你之后用 Command(resume=...) 恢复。这个不是普通的“return 结束”,而是“挂起等待恢复”。(LangChain 文档)

  5. 异常退出:抛错
    如果节点里抛异常且没有被内部处理,这一步就不是正常完成,而是失败退出。官方也专门提到可以给节点配 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(状态) 传递。最常见的方式不是“把参数直接传给下一个节点”,而是:

  1. 先在图里定义一个共享的 State schema
  2. 当前节点 return 一个字典,写入某些字段
  3. 下一个节点从 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_clientmysql_clientllm_client 放进 state,而是放进 LangGraph 的 runtime context
LangGraph 官方把这类“数据库连接、API client、用户元数据”归为运行时上下文,通过调用图时的 context= 传入;而 state 用来保存图执行过程中会变化的业务数据和中间结果。节点里可以通过 runtime.context 读取这些依赖。(LangChain 文档)

在 FastAPI 里,通常是:

  • lifespan 在应用启动时初始化共享 client / 连接池
  • 放到 app.state
  • DependsRequest 取出来
  • 组装成一个 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 文档里的 stateruntime 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_clientmysql_clientllm_client 给每个节点,推荐套路是:

FastAPI lifespan 初始化资源 → 放 app.stateDepends 取出 → 组装 GraphContextgraph.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 接口把 chunk yield 给浏览器。


推荐实现

方案结构

  • 节点里:

    • 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_mysqlquery_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_draft
  • query_mysql -> mysql_data
  • query_ck -> ck_data

这样最清晰,也不用 reducer。
只有在多个并行分支必须共同更新同一个字段时,才需要 reducer 来定义合并行为;否则会有冲突风险。这个是 LangGraph 的常规状态设计习惯。(LangChain 文档)


第二部分:ctxgraph 能不能都通过依赖注入拿?哪种更好?

能,都可以。
但我建议你这样分层:

  • graph:应用级对象

    • 在 FastAPI lifespan 里创建一次
    • 放到 app.state.graph
    • 路由里通过一个很薄的 dependency 取出来,或者直接 request.app.state.graph
  • 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 中变化的数据应该放在 stateRuntime 里也提供了 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 用 yield dependency 创建和关闭
  • 再把这个 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 文档)

关于 ctxgraph 的注入方式

都能通过接口依赖注入拿到,但更好的分工是:

  • graph:在 lifespan 初始化成应用级单例
  • ctx:用 Depends() 按请求构造
  • 请求级 session:用 yield dependency

这套分法最符合 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

请求体里的业务字段,比如:

  • question
  • user_id
  • session_id
  • filters
  • messages
  • trace_id(如果图里多个节点都会用)

这类数据本来就是这次 graph run 的输入,后续多个节点都要读,最适合直接作为 graph.ainvoke(...) 的输入 state。LangGraph 文档把这类“单次运行中会变化/被读取的数据”归为动态 runtime context,也就是 graph state。(LangChain 文档)

适合放进 context

请求级但更偏“依赖/环境”的东西,比如:

  • mysql_client
  • clickhouse_client
  • llm_client
  • 当前登录用户对象
  • 权限信息
  • 原始 Request 的少量衍生信息,比如 client_ipheaders 里某些值

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 执行数据的一部分

比如:
questionuser_idmessagesfilters

放进 context

当满足下面任意一点时:

  • 它是依赖,不是业务状态
  • 它在这次 run 中不变
  • 它更像配置/连接/用户元数据
  • 你不想让它混进 graph 状态

比如:
mysql_clientllm_clientclient_iprequest_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_idscenefilters,就继续把它们也放进 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"]}

这类写法有两个问题:

  1. 原地修改,违背 LangGraph 对 state update 的推荐方式。(LangChain 文档)
  2. 两个并行节点都返回 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 的可变异步对象
  • 依赖实时共享内存语义的运行时队列

你这个设计为什么别扭

你设想的是:

  1. producer_nodestate.queueput
  2. consumer_nodestate.queueget

这更像 共享内存 + 实时消息队列

但 LangGraph 更像:

  1. 节点读取当前 step 的 state 快照
  2. 节点返回“更新”
  3. 运行时在 step 结束后把更新应用到 channels/state
  4. 下一步节点再读到新值 (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 模型。官方文档列出了:

其中 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 的几个核心能力冲突:

所以这条路工程上不稳。


你要的是哪种“队列”语义?

这点很关键:

如果你要“先产出,后消费”

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,然后让 node1node2 都在各自内部流式写出内容。
因为 LangGraph 里普(LangChain 文档)s** 时,下一步目标节点才会并行执行。(docs.langchain.com )
而自定义流式数据要通过 writer 发出,并在外层用 stream_mode="custom"["custom","updates"] 接收。(docs.langchain.com)

所以你要的语义其实是:

  1. node1 内部一边生成一边 writer(...)
  2. node1 流完并 return
  3. LangGraph 才开始执行 node2
  4. node2 再一边生成一边 writer(...)
  5. 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 时,可以 yield ServerSentEvent。(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 先全部出来
  • node1 return 后,你会收到它的 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_start
  • phase_end
  • progress
  • 非 LLM 的业务消息

什么时候用 updates,什么时候用 custom

custom

适合:

  • token 流
  • 进度条
  • phase 开始/结束
  • 自定义结构化事件

updates

适合:

  • 节点 return 之后的最终 state update
  • 告诉前端“这个节点已经跑完了”

LangGraph streaming 文档明确区分了:

  • updates:每个 graph step 后的 state update
  • custom:节点内部通过 stream writer 发出的自定义数据
  • 也可以同时开多个模式。(LangChain 文档)
    在 FastAPI 里实现“一个节点先流式返回,结束后另一个节点再流式返回”,做法就是:

把 LangGraph 连成串行链路 START -> node1 -> node2 -> END,让两个节点都通过 writercustom 流;然后在 FastAPI 的 EventSourceResponse 里用 graph.astream(..., stream_mode=["custom","updates"], version="v2") 顺序转发成 SSE。
只要 node2node1 的下游普通边,并且 node1 在内部把自己的流完整 await 完才 return,就会天然满足“node1 流完后,node2 再开始流”。(LangChain 文档)代码改成 “OpenAI 异步流 + FastAPI SSE + LangGraph 两阶段输出” 的可运行版本。

posted @ 2026-05-24 15:44  X1OO  阅读(56)  评论(0)    收藏  举报