【LangGraph】House_Agent 实战(五):持久化、流式输出与部署

【LangGraph】LangGraph 实战(五):持久化、流式输出与部署

  • 前言
    • 一、持久化机制
      • [1.1 两层持久化](#1.1 两层持久化)
      • [1.2 Checkpoint:自动状态快照](#1.2 Checkpoint:自动状态快照)
      • [1.3 Store:用户数据存储](#1.3 Store:用户数据存储)
      • [1.4 Runtime 上下文](#1.4 Runtime 上下文)
    • 二、流式输出
      • [2.1 SSE 协议](#2.1 SSE 协议)
      • [2.2 House_Agent 的流式输出流程](#2.2 House_Agent 的流式输出流程)
      • [2.3 前端 SSE 解析](#2.3 前端 SSE 解析)
      • [2.4 interrupt 的前端处理](#2.4 interrupt 的前端处理)
    • [三、FastAPI 代理服务器](#三、FastAPI 代理服务器)
      • [3.1 为什么需要代理?](#3.1 为什么需要代理?)
      • [3.2 server.py 核心代码](#3.2 server.py 核心代码)
      • [3.3 启动方式](#3.3 启动方式)
    • 四、部署方案
      • [4.1 Docker 部署](#4.1 Docker 部署)
      • [4.2 开发 vs 生产](#4.2 开发 vs 生产)
    • 五、代码中的知识点
      • [5.1 State 与 MessagesState](#5.1 State 与 MessagesState)
      • [5.2 条件路由](#5.2 条件路由)
      • [5.3 工具调用](#5.3 工具调用)
      • [5.4 interrupt 与循环验证](#5.4 interrupt 与循环验证)
      • [5.5 持久化存储](#5.5 持久化存储)
      • [5.6 结构化输出](#5.6 结构化输出)
      • [5.7 条件边控制循环](#5.7 条件边控制循环)
    • 六、系列回顾
      • [6.1 整体架构回顾](#6.1 整体架构回顾)
      • [6.2 核心知识点回顾](#6.2 核心知识点回顾)
      • [6.3 架构图总览](#6.3 架构图总览)
      • [6.4 面试高频问题](#6.4 面试高频问题)

上一章------>【LangGraph】House_Agent 实战(四):预定流程 ------ 中断与人工干预

前言

上一篇我们完成了预定流程的实现,学习了 interrupt 机制和循环验证,

本篇是系列最后一篇,将把 House_Agent 项目跑起来------讲解持久化、流式输出、前端对接和部署方案,最后对整个系列做一个回顾,也是对前面知识点的一个回顾


一、持久化机制

1.1 两层持久化

LangGraph 提供了两层持久化机制,House_Agent 项目中都有用到:

机制 用途 House_Agent 中的使用
Checkpoint 保存图的执行状态 interrupt 暂停时自动保存,支持恢复执行
Store 存储用户数据 用户偏好(预算)、预定工单记录

1.2 Checkpoint:自动状态快照

Checkpoint 是 LangGraph 自动保存的状态快照,每当一个节点执行完毕,当前状态就会被保存。

在 House_Agent 中的作用

预定流程中,get_titleget_phoneget_id 三个节点都使用了 interrupt()每次 interrupt 暂停时,图的状态自动保存到 Checkpoint

用户输入后,LangGraph 从 Checkpoint 恢复状态,继续执行

默认配置builder.compile() 不传参数时,使用 InMemorySaver,数据保存在内存中

开发测试足够用,服务重启后丢失

生产环境:需要持久化存储,比如 PostgreSQL:

python 复制代码
from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string(

    "postgresql://user:password@localhost:5432/langgraph"
)
graph = builder.compile(checkpointer=checkpointer)

1.3 Store:用户数据存储

Store 用于存储跨会话 的用户数据

与 Checkpoint 不同,Store 需要手动读写

在 House_Agent 中的使用

场景一:用户偏好读取get_store_info 节点):

python 复制代码
def get_store_info(state: State, runtime: Runtime[ContextSchema], *, store: BaseStore):
    user_id = runtime.context.get("user_id")
    namespace = (user_id, "preferences")
    prefs_result = store.search(namespace)
    if prefs_result and prefs_result[0]:
        return {"user_preferences": prefs_result[0].value}
    return {"user_preferences": {}}

场景二:推荐时更新偏好collect_user_info 节点):

python 复制代码
# 提取到预算信息后,持久化保存
if prefs_result:
    prefs = prefs_result[0].value
    if user_info.budget_min:
        prefs['budget_min'] = user_info.budget_min
    if user_info.budget_max:
        prefs['budget_max'] = user_info.budget_max
    store.put(namespace, prefs_result[0].key, prefs)

场景三:预定工单持久化generate_orders 工具):

python 复制代码
@tool
def generate_orders(phone_number, id_card, title,
                    runtime: ToolRuntime, store: Annotated[Any, InjectedStore()]):
    order_id = str(uuid.uuid4())
    reserved_info = ReservedInfo(order_id=order_id, phone_number=phone_number, title=title)

    user_id = runtime.context.get("user_id")
    namespace = (user_id, "preferences")
    prefs_result = store.search(namespace)

    if len(prefs_result) == 0:
        # 首次预定,新建记录
        prefs = UserPreferences(reserved_info=[reserved_info])
        store.put(namespace, str(uuid.uuid4()), prefs.model_dump(exclude_none=True))
    else:
        # 已有记录,追加预定信息
        prefs = prefs_result[0].value or {}
        prefs.setdefault("reserved_info", []).append(reserved_info.model_dump())
        store.put(namespace, prefs_result[0].key, prefs)

    return f"已预定房源为:{title},预定工单号为:{order_id}"

Store 的 namespace 设计

复制代码
namespace: (user_id, "preferences")
    │
    ├── key: uuid-1
    └── value: {
            "budget_min": 3000,
            "budget_max": 5000,
            "reserved_info": [
                {"order_id": "xxx", "title": "长安花园", "phone_number": "138..."},
                {"order_id": "yyy", "title": "翠苑小区", "phone_number": "138..."}
            ]
        }

使用 user_id 作为 namespace 的第一层,实现用户级数据隔离


1.4 Runtime 上下文

user_id 从哪里来?通过 Runtime 上下文传入:

python 复制代码
# context.py
class ContextSchema(TypedDict):
    user_id: str

# 编译图时指定 context_schema
builder = StateGraph(State, context_schema=ContextSchema)

# 客户端请求时传入 config
config = {"configurable": {"user_id": "web_user"}}

节点中通过 runtime.context["user_id"] 访问,

工具中通过 runtime.context.get("user_id") 访问


二、流式输出

2.1 SSE 协议

LangGraph 使用 SSE(Server-Sent Events) 协议实现流式输出

客户端发起 HTTP 请求后,服务端通过长连接持续推送事件

每个 SSE 事件包含两部分:

复制代码
event: values
data: {"messages": [...], "user_intent": "recommend_house", ...}

2.2 House_Agent 的流式输出流程

以"帮我推荐北京 3000-5000 的两室一厅"为例,SSE 事件流如下:

复制代码
event: metadata          ← 运行 ID
event: values            ← get_store_info 完成(加载用户偏好)
event: values            ← identify_question 完成(识别为 recommend_house)
event: values            ← collect_user_info 完成(提取城市、预算)
event: values            ← list_tables 完成
event: values            ← call_get_schema 完成
event: values            ← generate_query 完成(生成 SQL)
event: values            ← check_query 完成
event: values            ← run_query 完成(执行 SQL)
event: values            ← generate_query 完成(生成最终回复 + interrupt)

每个 values 事件包含完整的当前状态,前端需要从中提取消息和 interrupt 信息


2.3 前端 SSE 解析

前端通过 fetch + ReadableStream 消费 SSE:

javascript 复制代码
const resp = await fetch(`/api/threads/${threadId}/runs/stream`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        assistant_id: 'house_agent',
        config: { configurable: { user_id: 'web_user' } },
        input: { messages: [{ role: 'user', content: text }] }
    })
});

const reader = resp.body.getReader();
const decoder = new TextDecoder();

while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    // 解析 event: 和 data: 行
    // 提取 AI 消息和 interrupt 信息
}

关键点

  1. 多次 values 事件 :图经过多个节点,每个节点完成都会触发一次 values 事件
  2. 最后一条 AI 消息:需要从所有事件中找到最后一条有效的 AI 回复
  3. interrupt 处理 :当 data.__interrupt__ 存在时,需要显示提示并等待用户输入

2.4 interrupt 的前端处理

当推荐流程完成后,need_reserve 节点触发 interrupt,前端收到:

json 复制代码
{
    "messages": [...],
    "__interrupt__": [{
        "value": "已经为您推荐合适的房源,是否需要帮您预定房源?\n如果不需要,请输入'**不需要**'\n如果需要,请输入'**需要**'"
    }]
}

前端处理逻辑:

  1. messages 中提取 AI 的推荐结果,显示为聊天气泡
  2. __interrupt__ 中提取提示信息,显示为交互卡片
  3. 用户点击"需要"或"不需要"后,发送 resume 请求
javascript 复制代码
// 恢复执行
await fetch(`/api/threads/${threadId}/runs/stream`, {
    method: 'POST',
    body: JSON.stringify({
        assistant_id: 'house_agent',
        command: { resume: "需要" },  // 或 "不需要"
        config: { configurable: { user_id: 'web_user' } }
    })
});

三、FastAPI 代理服务器

3.1 为什么需要代理?

LangGraph Server 运行在 2024 端口,前端页面需要一个统一的入口FastAPI 代理负责:

  1. 页面服务GET / 返回前端 HTML
  2. 静态资源/static/* 提供图片等静态文件
  3. API 代理/api/* 转发到 LangGraph Server

3.2 server.py 核心代码

python 复制代码
# server.py
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

LANGGRAPH_URL = "http://localhost:2024"

@app.get("/", response_class=HTMLResponse)
async def index():
    with open("frontend/index.html", encoding="utf-8") as f:
        return f.read()

@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def proxy(path: str, request: Request):
    url = f"{LANGGRAPH_URL}/{path}"
    body = await request.body()
    headers = dict(request.headers)
    headers.pop("host", None)

    # SSE 流式转发
    if "stream" in path:
        client = httpx.AsyncClient(timeout=None)
        req = client.build_request(request.method, url, content=body, headers=headers)
        resp = await client.send(req, stream=True)

        async def stream():
            async for chunk in resp.aiter_bytes():
                yield chunk
            await resp.aclose()
            await client.aclose()

        return StreamingResponse(stream(), status_code=resp.status_code,
                                 media_type="text/event-stream")

    # 普通请求转发
    async with httpx.AsyncClient(timeout=60.0) as client:
        resp = await client.request(request.method, url, content=body, headers=headers)
        return Response(content=resp.content, status_code=resp.status_code,
                        headers=dict(resp.headers))

SSE 转发的关键

  • timeout=None:SSE 连接可能持续很长时间
  • stream=True:启用流式传输,逐块转发字节
  • media_type="text/event-stream":告诉浏览器这是 SSE 流

3.3 启动方式

需要两个终端分别启动:

bash 复制代码
# 终端 1:启动 LangGraph Server
langgraph dev --port 2024

# 终端 2:启动 FastAPI 代理
python server.py

访问 http://localhost:8000 即可使用前端页面


四、部署方案

4.1 Docker 部署

Dockerfile

dockerfile 复制代码
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

EXPOSE 8000 2024
CMD ["sh", "-c", "langgraph dev --port 2024 & uvicorn server:app --host 0.0.0.0 --port 8000"]

docker-compose.yml

yaml 复制代码
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8000:8000"
      - "2024:2024"
    environment:
      - DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
      - DB_HOST=mysql
      - DB_PORT=3306
      - DB_NAME=houser_agent
      - DB_USER=root
      - DB_PASSWORD=${DB_PASSWORD}
    depends_on:
      - mysql

  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: houser_agent
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

4.2 开发 vs 生产

维度 开发环境 生产环境
Checkpoint InMemorySaver PostgresSaver
Store InMemoryStore PostgresStore
LLM qwen-turbo 按需选择
前端 FastAPI 直接返回 Nginx + CDN
监控 LangSmith

五、代码中的知识点

House_Agent 项目的每一段代码都在体现 LangGraph 的核心概念。下面按知识点分类,看看它们在代码中是怎么用的。

5.1 State 与 MessagesState

体现位置state/main.pystate/recommend.pystate/reserve.py

python 复制代码
# 主图状态 ------ 继承 MessagesState,自动拥有 messages 字段
class State(MessagesState):
    user_intent: str          # 意图识别结果
    reserve: str              # 是否需要预定
    user_preferences: dict    # 用户偏好

# 推荐子图状态 ------ 子图私有字段不影响主图
class RecommendState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]  # 共享字段
    city: Optional[str]           # 私有字段
    budget_min: Optional[float]

# 预定流程状态
class ReserveState(MessagesState):
    title: Optional[str] = None
    phone_number: Optional[str] = None
    id_card: Optional[str] = None

知识点

  • MessagesState 提供 messages 字段和 add_messages reducer
  • Annotated[list, add_messages] 让新消息自动追加而非覆盖
  • 子图状态的私有字段与主图隔离

5.2 条件路由

体现位置graph.py

python 复制代码
# 路由函数:根据状态决定下一步去哪
def router_message(state: State) -> Literal["recommend_graph", "get_title", "extend_graph", "get_user_preferences"]:
    user_intent = state["user_intent"]
    if user_intent == "recommend_house":
        return "recommend_graph"
    elif user_intent == "reserve_house":
        return "get_title"
    ...

# 注册条件边
builder.add_conditional_edges(
    "identify_question",    # 源节点
    router_message,         # 路由函数
    ["recommend_graph", "get_title", "extend_graph", "get_user_preferences"]
)

知识点

  • add_conditional_edges 实现动态路由
  • 路由函数读取 state 中的字段做决策
  • Literal 类型约束返回值,确保路由目标合法

5.3 工具调用

体现位置node/recommend.pynode/reserve.py

python 复制代码
# 推荐子图:SQLDatabaseToolkit 提供的内置工具
toolkit = SQLDatabaseToolkit(db=db, llm=model)
tools = toolkit.get_tools()
get_schema_tool = next(tool for tool in tools if tool.name == "sql_db_schema")

# 预定流程:自定义工具
@tool
def generate_orders(phone_number: str, id_card: str, title: str,
                    runtime: ToolRuntime, store: Annotated[Any, InjectedStore()]) -> str:
    ...

# LLM 绑定工具
llm_with_tools = model.bind_tools([run_query_tool])                    # 非强制
llm_with_tools = model.bind_tools([get_schema_tool], tool_choice=...)  # 强制调用

# 工具节点
run_query_node = ToolNode([run_query_tool], name="run_query")
tool_node = ToolNode([generate_orders])

知识点

  • @tool 装饰器定义自定义工具
  • bind_tools 将工具绑定到 LLM,tool_choice 控制是否强制调用
  • ToolNode 自动解析 LLM 的工具调用并执行
  • ToolRuntimeInjectedStore 注入运行时参数,不暴露给 LLM

5.4 interrupt 与循环验证

体现位置node/reserve.py

python 复制代码
def get_title(state: ReserveState):
    prompt = '请输入要预定的房源名称(输入"取消"可退出)'
    while True:                                    # 循环验证
        title = interrupt(prompt)                  # 暂停等待用户输入
        if _is_cancel(title):                      # 取消检测
            return {"cancel": True}
        if not title or not title.strip():         # 空值检测
            prompt = "房源名称不能为空,请重新输入。"
        else:
            return {"title": title.strip()}        # 有效输入,退出循环

知识点

  • interrupt() 暂停图执行,等待用户输入
  • while True 实现节点内的验证-重试循环
  • 状态保存依赖 Checkpoint,恢复时从 interrupt() 处继续

5.5 持久化存储

体现位置node/main.pynode/recommend.pynode/reserve.py

python 复制代码
# 读取:节点中通过 store 参数访问
def get_store_info(state, runtime, *, store: BaseStore):
    namespace = (runtime.context["user_id"], "preferences")
    prefs = store.search(namespace)

# 写入:推荐时更新预算
store.put(namespace, prefs_result[0].key, prefs)

# 写入:工具中持久化工单
@tool
def generate_orders(..., store: Annotated[Any, InjectedStore()]):
    store.put(namespace, str(uuid.uuid4()), prefs.model_dump())

知识点

  • store 作为关键字参数注入节点
  • InjectedStore 注入到工具中
  • namespace 用 (user_id, "preferences") 实现用户级隔离
  • store.search() 查询,store.put() 写入

5.6 结构化输出

体现位置node/main.pynode/recommend.py

python 复制代码
# 意图识别:LLM 返回固定的枚举类型
class UserIntendMessage(BaseModel):
    type: Literal["recommend_house", "reserve_house", "get_info", "others"]

user_intent = model.with_structured_output(UserIntendMessage).invoke(...)

# 信息提取:LLM 返回结构化的用户信息
class UserInfo(BaseModel):
    city: Optional[str]
    budget_min: Optional[float]
    ...

user_info = model.with_structured_output(UserInfo).invoke(...)

知识点

  • with_structured_output() 让 LLM 返回 Pydantic 模型
  • Literal 限制字段取值范围
  • Optional 允许字段为空,渐进式填充

5.7 条件边控制循环

体现位置recommend.pygraph.py

python 复制代码
# 推荐子图:SQL 生成→检查→执行的循环
def should_continue(state: RecommendState) -> Literal[END, "check_query"]:
    last_message = state["messages"][-1]
    if not last_message.tool_calls:
        return END              # LLM 不再调用工具,结束循环
    else:
        return "check_query"    # 继续检查并执行

builder.add_conditional_edges("generate_query", should_continue, [END, "check_query"])

# 预定流程:每步检查取消
def _should_continue(state: ReserveState):
    return "cancel" if state.get("cancel") else "continue"

builder.add_conditional_edges("get_title", _should_continue, {"continue": "get_phone", "cancel": "cancel_node"})

知识点

  • should_continue 读取最后一条消息的 tool_calls 判断是否继续
  • 预定流程中每个节点后都用条件边检查 cancel 标志
  • tools_condition 是内置的工具调用判断函数

六、系列回顾

6.1 整体架构回顾

回顾整个 House_Agent 项目,我们走过了这样一条路:

最后完成了整个项目

6.2 核心知识点回顾

  1. State(状态)
  • 主图 State 继承 MessagesState,添加 user_intentreserve 等字段
  • 推荐子图 RecommendState 有自己的私有字段:citybudget_min
  • 预定流程的 ReserveState 添加 titlephone_numberid_card
  1. Node(节点)
  • 普通节点:处理逻辑,返回状态更新
  • ToolNode:执行工具,由 tools_condition 路由
  • 子图节点:独立编译的图,嵌入主图

Edge(边)

  • 普通边:builder.add_edge(A, B)
  • 条件边:builder.add_conditional_edges(A, router, [B, C])
  • 预定流程中每个节点后都用条件边检查取消

interrupt(中断)

  • interrupt() 暂停执行,等待用户输入
  • 依赖 Checkpoint 保存状态
  • 预定流程中 get_titleget_phoneget_id 都使用 interrupt
  1. 工具调用
  • @tool 装饰器定义工具
  • model.bind_tools() 绑定工具到 LLM
  • ToolRuntimeInjectedStore 注入运行时参数
  • tools_condition 判断是否需要执行工具
  1. 持久化
  • Checkpoint 自动保存,支持 interrupt 恢复
  • Store 手动读写,支持跨会话数据共享
  • namespace 设计实现用户级数据隔离

6.3 架构图总览

这里用LangSmith 展示一下最终图:

6.4 面试高频问题

Q1:LangGraph 的 interrupt 和普通 input() 有什么区别?

interrupt 将状态保存到 Checkpoint,支持跨会话恢复;

input() 只是阻塞当前线程

Q2:主图和子图如何共享状态?

通过 messages 字段共享,子图的私有字段不影响主图

Q3:Checkpoint 和 Store 的区别?

Checkpoint 自动保存图的执行状态,用于恢复;

Store 手动存储用户数据,用于跨会话共享

Q4:tools_condition 的作用?

检查 LLM 最后一条消息是否包含工具调用,决定执行工具还是结束流程

Q5:为什么预定流程不做成子图?

interrupt 事件需要从子图传递到主图再到客户端,链路长、调试难

直接放在主图中,事件转发更可靠

Q6:SSE 流式输出中如何处理 interrupt?

values 事件的 __interrupt__ 字段提取提示信息,同时从 messages 中提取最后一条 AI 回复

用户输入后发送 command.resume 恢复执行


本文是 House_Agent 实战系列的第五篇,也是最后一篇
感谢阅读,希望这个系列能帮助你掌握 LangGraph 的核心概念和实战技巧!,再见再见~~

相关推荐
txg6662 小时前
机器人领域简报(2026年5月15日—5月21日)
人工智能·机器人
码上滚雪球2 小时前
Flink Agents 深度解读:当实时数据流遇上 AI 智能体
大数据·人工智能·flink·滚雪球
PNP Robotics2 小时前
PNP机器人亮相南京学术论坛,分享具身智能多模态数据采集前沿成果
人工智能·深度学习·学习·机器学习·virtualenv
少年强则国强2 小时前
安装配置Claude
python
threelab2 小时前
Three.js 银河星系效果 | 三维可视化 / AI 提示词
开发语言·javascript·人工智能
机汇五金_2 小时前
深圳电脑机箱厂家
python
Trouvaille ~2 小时前
【Redis篇】为什么需要 Redis:从单机到分布式的架构演进之路
数据库·redis·分布式·缓存·中间件·架构·后端开发
想你依然心痛2 小时前
HarmonyOS 6(API 23)实战:基于悬浮导航、沉浸光感与HMAF的“译界智脑“——PC端AI智能体沉浸式智能翻译与跨语言协作工作台
人工智能·华为·ar·harmonyos
几司2 小时前
OpenISP 模块拆解 · 第11讲:非局部均值降噪 (NLM)
人工智能·算法·均值算法·isp