LangGraph 部署实战:从脚本直跑到 FastAPI 与 K8s 容器化

📅 发布时间:2026/10/7 13:43:23
LangGraph 部署实战:从脚本直跑到 FastAPI 与 K8s 容器化
1. 从脚本到服务为什么部署这一步最容易卡住人LangGraph 这个框架写过 demo 的人都知道本地跑起来是真舒服。一个StateGraph几个节点函数compile()一下invoke()一调状态在节点之间流转图跑通了逻辑也就验证完了。但问题往往出在下一步怎么把这个跑在 Jupyter Notebook 或者python xxx.py里的东西变成一个别人能访问、能持续运行、挂了能自动重启的服务我见过太多项目卡在这个坎上。脚本阶段一切正常一旦要部署问题就来了状态怎么持久化并发请求怎么处理多个用户同时调用同一个图状态会不会串进程崩了怎么办要不要上容器上了容器之后 K8s 那套东西又该怎么配这些问题的本质其实是从能跑到能稳定对外服务之间的鸿沟。LangGraph 本身是个编排框架它不负责网络层、不负责进程管理、不负责容器编排。这些都得你自己选、自己搭。而选择多了反而容易懵。这篇文章就是想把这条路径讲清楚。我会按三条部署路径来拆本地脚本直跑、FastAPI 封装成 HTTP 服务、Docker K8s 容器化编排。每一条路径适合什么场景、核心要解决什么问题、具体怎么落地、有哪些坑我都会结合自己的实操经验说透。不管你是刚写完第一个 LangGraph demo 想给别人试用还是已经在做多租户的 Agent 平台应该都能找到对应的参考。先说清楚一个前提LangGraph 的部署核心矛盾从来不是图怎么跑而是图跑起来之后状态、并发、生命周期这三件事怎么管。三条路径的差异本质上就是在这三件事上投入的复杂度不同。理解了这一点后面的选型就不会盲目。2. 三条部署路径的整体设计与选型逻辑2.1 路径划分的底层依据状态管理复杂度为什么我把部署路径分成三条而不是按开发/测试/生产这种常规维度分因为对 LangGraph 这类有状态编排框架来说决定部署复杂度的核心变量是状态管理的需求不是环境本身。一个无状态的 HTTP 接口你随便怎么部署都行多开几个实例做负载均衡就完事。但 LangGraph 的图是有状态的StateGraph里的 state 在节点间传递如果用了checkpointer比如MemorySaver或SqliteSaver状态还要跨请求持久化。这就带来几个连锁问题状态存在哪内存里、SQLite 文件里、还是 Postgres 里多个实例部署时状态怎么共享一个长流程跑到一半进程重启了状态还在不在这三个问题的答案直接决定了你该走哪条路径。我整理了一张对照表先把三条路径的定位说清楚路径状态存储并发模型适用场景运维成本脚本直跑进程内存 / 本地文件单进程串行个人验证、内部演示极低FastAPI 封装内存 / SQLite / Redis异步并发单实例小团队内部服务、API 对外中等Docker K8s外部存储Redis/PG多实例水平扩展多租户平台、生产环境高这张表不是让你对号入座而是帮你判断你现在真正需要解决的是哪一层的问题。很多人一上来就想上 K8s结果发现自己的状态管理根本没设计好上了 K8s 反而更乱——因为多实例之间状态不共享请求打到哪个 Pod 全看运气用户体验直接崩掉。2.2 为什么 FastAPI 是中间路径的最优解三条路径里FastAPI 这条是绝大多数项目的甜点区。原因有几个我逐个说。第一LangGraph 是 Python 生态的FastAPI 也是两者天然亲和。你不需要跨语言做序列化图的输入输出直接就是 Python 对象Pydantic 模型一套请求校验和响应格式化全搞定。相比之下如果你用 Flask异步支持弱处理 LangGraph 里可能出现的异步节点比如调用 LLM 的ainvoke会很别扭用 Django 又太重为了一个图服务引入整个 ORM 和 admin不划算。第二FastAPI 的异步模型和 LangGraph 的流式输出天然匹配。LangGraph 支持astream和astream_events可以边跑边吐 token。FastAPI 的StreamingResponse配合async def生成器能把流式输出直接透传给前端。这个组合我在实际项目里用过SSEServer-Sent Events推流非常顺用户能实时看到 Agent 的思考过程体验比等一个完整响应好太多。第三FastAPI 自带 OpenAPI 文档。你部署完访问/docs就能看到所有接口的交互式文档调试和对接都省事。对于内部服务来说这等于白送了一套接口说明。当然FastAPI 不是没有代价。它的异步模型要求你对async/await有基本理解如果图里有阻塞操作比如同步的数据库查询、CPU 密集计算直接写在async def里会阻塞事件循环整个服务的并发能力就废了。这个坑后面会专门讲怎么绕。2.3 Docker 和 K8s 各自解决什么问题别混为一谈很多人把 Docker 和 K8s 当成一回事其实它们解决的是完全不同层面的问题我经常用这个类比来解释Docker 解决的是这个服务在我机器上能跑在你机器上也能跑的问题它管的是单个容器的打包和运行。K8s 解决的是这个服务有 10 个副本挂了 2 个要自动补上流量要均匀分发的问题它管的是容器的编排和调度。对 LangGraph 服务来说Docker 的价值在于环境一致性。LangGraph 依赖的包不少——langgraph、langchain-core、各种 LLM SDK、可能还有向量库客户端版本冲突是家常便饭。打成镜像之后Python 版本、依赖版本、系统库全部锁定换台机器docker run就能起不用再折腾环境。K8s 的价值则在于弹性伸缩和高可用。当你的 Agent 服务要面对不确定的流量或者需要保证 99.9% 的可用性时K8s 的 Deployment、Service、HPA水平自动扩缩容才有意义。但前提是你的状态必须外置——因为 K8s 随时可能把 Pod 调度到别的节点本地文件存储的状态会丢。所以选型逻辑很清晰先问状态存哪再问要不要多实例最后才决定上不上 K8s。状态还在进程内存里就别急着上 K8s先把状态外置这件事解决了再说。3. 路径一脚本直跑最快验证但别拿去见客户3.1 什么情况下脚本直跑就够了脚本直跑不是低级方案它在特定场景下是最优解。我列几个典型情况个人验证阶段你刚写完图想快速跑几个 case 看看逻辑对不对这时候搞一套 FastAPI 纯属浪费时间。内部演示给同事或领导演示一下 Agent 能干什么本地跑个脚本终端里看输出足够了。批处理任务图是用来处理一批数据的跑完就结束不需要常驻服务。比如批量给文档打标签、批量生成摘要。定时任务配合 cron 或系统的任务计划每天跑一次跑完退出。这些场景的共同点是没有并发需求没有持续在线的需求状态不需要跨请求共享。满足这三点脚本直跑就是最省事的方案。3.2 脚本直跑的核心写法与状态处理脚本直跑的关键是把图的编译和调用写清楚。我拿一个最简的 ReAct 风格 Agent 举例说明几个容易忽略的点。from langgraph.graph import StateGraph, START, END from langgraph.checkpoint.memory import MemorySaver from typing import TypedDict, Annotated import operator class AgentState(TypedDict): messages: Annotated[list, operator.add] next_step: str def planner_node(state: AgentState): # 这里通常是调用 LLM 做规划 return {next_step: tool} def tool_node(state: AgentState): # 执行工具调用 return {messages: [{role: tool, content: result}]} def should_continue(state: AgentState): if state[next_step] tool: return tool return END builder StateGraph(AgentState) builder.add_node(planner, planner_node) builder.add_node(tool, tool_node) builder.add_edge(START, planner) builder.add_conditional_edges(planner, should_continue, {tool: tool, END: END}) builder.add_edge(tool, planner) # 关键点一checkpointer 决定状态存哪 memory MemorySaver() graph builder.compile(checkpointermemory) # 关键点二config 里的 thread_id 是状态隔离的钥匙 config {configurable: {thread_id: user-001}} result graph.invoke({messages: [{role: user, content: 帮我查一下天气}]}, config) print(result)这段代码里有两个点值得展开。第一checkpointer的选择。MemorySaver把状态存在进程内存里进程一退状态全没。如果你需要状态在脚本多次运行之间保留得换成SqliteSaverfrom langgraph.checkpoint.sqlite import SqliteSaver import sqlite3 conn sqlite3.connect(checkpoints.db, check_same_threadFalse) memory SqliteSaver(conn)SqliteSaver把状态写进本地 SQLite 文件脚本重启后还能接着上次的thread_id继续跑。这个在批处理场景里很有用——比如处理到一半中断了重跑时能跳过已完成的。第二thread_id的作用。它是状态隔离的标识同一个thread_id的多次invoke会共享状态历史不同thread_id之间互不干扰。脚本直跑时如果你要处理多个用户的数据记得给每个用户分配独立的thread_id否则状态会串。3.3 脚本直跑的注意事项脚本直跑有几个坑我踩过这里提醒一下。注意MemorySaver在多进程环境下完全失效。如果你用multiprocessing或者gunicorn多 worker 跑脚本每个进程有自己独立的内存状态不共享。这时候必须换SqliteSaver或外部存储。另一个坑是异常处理。脚本直跑时图里某个节点抛异常整个脚本就挂了。如果你在跑批处理一个 case 失败导致整批中断很烦。建议在调用层包一层 try-except把失败的 case 记下来继续跑下一个for item in batch: try: result graph.invoke({messages: [item]}, config) except Exception as e: print(f处理 {item} 失败: {e}) continue还有一点脚本直跑时日志要打全。因为没有服务层的监控出问题只能靠日志排查。建议在关键节点加日志记录输入、输出、耗时。LangGraph 本身支持通过callbacks接入 LangSmith 之类的追踪工具但如果你不想引入外部依赖自己用logging模块打点也够用。4. 路径二FastAPI 封装把图变成能对外服务的 API4.1 FastAPI 项目目录结构怎么设计才不乱从脚本到服务第一件事是把代码组织好。我见过太多项目所有代码堆在一个main.py里几百行改一处牵全身。LangGraph 服务因为涉及图定义、节点逻辑、工具、API 路由、配置更需要清晰的目录结构。我常用的结构是这样的langgraph-service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口挂载路由 │ ├── config.py # 配置管理环境变量、常量 │ ├── graph/ │ │ ├── __init__.py │ │ ├── builder.py # 图的构建逻辑 │ │ ├── state.py # State 定义 │ │ └── nodes.py # 各节点函数 │ ├── tools/ │ │ └── __init__.py # 工具定义 │ ├── api/ │ │ ├── __init__.py │ │ ├── routes.py # 路由定义 │ │ └── schemas.py # Pydantic 请求/响应模型 │ └── services/ │ └── agent_service.py # 业务逻辑封装图的调用 ├── tests/ ├── requirements.txt ├── Dockerfile └── .env这个结构的分层逻辑是graph 层只管图怎么建api 层只管请求怎么接services 层把两者粘起来。这样改图逻辑不影响 API改 API 不影响图测试也好写。config.py里集中管理配置比如 LLM 的 API key、模型名、checkpointer 的连接串。用pydantic-settings从环境变量读本地开发用.env部署时用环境变量注入一套代码两种环境都能跑。4.2 用 FastAPI 封装 LangGraph 的核心代码先看 Pydantic 模型定义请求和响应from pydantic import BaseModel, Field from typing import Optional class ChatRequest(BaseModel): message: str Field(..., description用户输入) thread_id: str Field(..., description会话标识用于状态隔离) stream: bool Field(False, description是否流式返回) class ChatResponse(BaseModel): thread_id: str reply: str status: str ok再看路由核心是把图的调用封装成 HTTP 接口from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from app.api.schemas import ChatRequest, ChatResponse from app.services.agent_service import AgentService router APIRouter(prefix/api/v1) agent_service AgentService() router.post(/chat, response_modelChatResponse) async def chat(req: ChatRequest): try: reply await agent_service.run(req.message, req.thread_id) return ChatResponse(thread_idreq.thread_id, replyreply) except Exception as e: raise HTTPException(status_code500, detailstr(e)) router.post(/chat/stream) async def chat_stream(req: ChatRequest): async def event_generator(): async for chunk in agent_service.stream(req.message, req.thread_id): yield fdata: {chunk}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)AgentService是关键它把图的调用包起来对外暴露简单的run和stream方法from app.graph.builder import build_graph from langgraph.checkpoint.memory import MemorySaver class AgentService: def __init__(self): self.graph build_graph(checkpointerMemorySaver()) async def run(self, message: str, thread_id: str) - str: config {configurable: {thread_id: thread_id}} result await self.graph.ainvoke( {messages: [{role: user, content: message}]}, config ) return result[messages][-1][content] async def stream(self, message: str, thread_id: str): config {configurable: {thread_id: thread_id}} async for event in self.graph.astream_events( {messages: [{role: user, content: message}]}, config, versionv2 ): if event[event] on_chat_model_stream: chunk event[data][chunk].content if chunk: yield chunk这里有几个设计决策值得说。为什么用ainvoke而不是invoke因为 FastAPI 的路由是async def在异步上下文里调用同步的invoke会阻塞事件循环。ainvoke是异步版本能让出控制权其他请求可以并发处理。这个区别在高并发下非常明显我实测过同步调用在 10 个并发请求时就开始排队异步调用能轻松扛住几十个。为什么用astream_events而不是astreamastream返回的是每个节点执行完后的状态快照粒度太粗。astream_events能拿到更细粒度的事件包括 LLM 的 token 流适合做打字机效果。versionv2是必须的v1 的事件格式不一样容易踩坑。4.3 状态持久化从 MemorySaver 到 RedisMemorySaver在单实例 FastAPI 里能用但有两个问题一是进程重启状态丢失二是多 worker 部署时状态不共享。生产环境得换外部存储。LangGraph 官方支持多种 checkpointer常用的有SqliteSaver、PostgresSaver、RedisSaver。选哪个看你的场景单机小服务SqliteSaver够用零依赖一个文件搞定。多实例部署PostgresSaver或RedisSaver状态集中存储所有实例共享。需要 TTL 自动过期RedisSaver更合适可以给会话状态设过期时间。以RedisSaver为例配置大概是这样from langgraph.checkpoint.redis import RedisSaver import redis redis_client redis.Redis(hostlocalhost, port6379, db0) checkpointer RedisSaver(redis_client) graph builder.compile(checkpointercheckpointer)注意换 checkpointer 之后thread_id的语义不变但状态的存储位置变了。如果你之前用MemorySaver跑了一些会话换到 Redis 后这些会话的历史就找不回来了。迁移时要么接受历史丢失要么写个脚本把内存状态导出再导入。4.4 FastAPI 部署的实操心得与常见坑FastAPI 封装这条路径我踩过的坑主要集中在几个地方整理成速查表问题现象根本原因解决方法并发请求变慢像排队在 async 路由里调了同步阻塞函数改用ainvoke或用run_in_executor包同步调用日志丢失看不到请求记录uvicorn 默认日志配置和自定义 logging 冲突配置log_config统一日志格式流式输出断断续续中间有代理缓冲或没设X-Accel-Buffering响应头加X-Accel-Buffering: no状态串了A 用户看到 B 用户的历史thread_id没做隔离或用了固定值每个会话生成唯一thread_id从请求里传内存持续增长最后 OOMMemorySaver无限累积状态换外部存储或定期清理旧会话关于日志丢失这个问题我单独说一下。uvicorn 启动时会自己配置 logging如果你在代码里用logging.basicConfig()或者自定义 logger很容易被 uvicorn 的配置覆盖。正确的做法是在启动时传入log_configimport uvicorn if __name__ __main__: uvicorn.run( app.main:app, host0.0.0.0, port8000, log_configlog_config.yaml # 自定义日志配置 )log_config.yaml里定义好 formatter、handler、logger这样 uvicorn 和你的应用日志能统一格式排查问题时不会一个有一个没有。还有一个实操技巧用lifespan管理图的初始化。图的构建、checkpointer 的连接这些应该在服务启动时做一次而不是每个请求都做。FastAPI 的lifespan上下文管理器正好干这个from contextlib import asynccontextmanager from fastapi import FastAPI asynccontextmanager async def lifespan(app: FastAPI): # 启动时初始化 app.state.agent_service AgentService() yield # 关闭时清理 await app.state.agent_service.close() app FastAPI(lifespanlifespan)这样图只构建一次checkpointer 连接也只建一次请求来了直接用性能好很多。5. 路径三Docker K8s多实例编排的完整落地5.1 Dockerfile 怎么写才又小又快LangGraph 服务的镜像我建议用多阶段构建把构建依赖和运行时依赖分开镜像能小不少。基础镜像选python:3.11-slim比完整版小几百 MB。# 构建阶段 FROM python:3.11-slim AS builder WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --user -r requirements.txt # 运行阶段 FROM python:3.11-slim WORKDIR /app COPY --frombuilder /root/.local /root/.local COPY ./app ./app ENV PATH/root/.local/bin:$PATH EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]几个优化点--no-cache-dir不缓存 pip 下载的包镜像更小。--user把包装到用户目录方便从构建阶段复制。只复制app目录测试、文档、.env都不进镜像。用CMD而不是ENTRYPOINT方便运行时覆盖命令。.dockerignore也要配好把__pycache__、.git、tests、*.md排除掉构建上下文小构建快__pycache__ *.pyc .git .env tests *.md .venv注意不要把 API key 写进 Dockerfile 或镜像里。用环境变量在运行时注入或者用 K8s 的 Secret 挂载。镜像一旦推到仓库里面的内容就藏不住了。5.2 K8s 部署清单Deployment、Service、ConfigMapK8s 部署 LangGraph 服务核心是三个资源Deployment 管 PodService 管网络ConfigMap/Secret 管配置。Deployment 的清单apiVersion: apps/v1 kind: Deployment metadata: name: langgraph-agent spec: replicas: 3 selector: matchLabels: app: langgraph-agent template: metadata: labels: app: langgraph-agent spec: containers: - name: agent image: your-registry/langgraph-agent:v1.0.0 ports: - containerPort: 8000 env: - name: REDIS_HOST valueFrom: configMapKeyRef: name: agent-config key: redis_host - name: LLM_API_KEY valueFrom: secretKeyRef: name: agent-secret key: llm_api_key resources: requests: memory: 512Mi cpu: 250m limits: memory: 1Gi cpu: 1000m livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 10 periodSeconds: 5这里有几个关键配置。replicas: 3表示起 3 个副本。但前提是状态已经外置到 Redis 或 Postgres否则 3 个副本各自有各自的内存状态请求打到哪个 Pod 状态就不一样用户体验会崩。resources的 requests 和 limits 要设。requests 是调度依据limits 是硬上限。LangGraph 服务如果调 LLM内存占用主要在等待响应时的连接和缓冲512Mi 到 1Gi 是常见范围。CPU 方面如果图里有本地计算给足纯调 API 的话250m 到 500m 够用。livenessProbe和readinessProbe是保命的。liveness 探针失败K8s 会重启 Podreadiness 探针失败K8s 会把 Pod 从 Service 的端点里摘掉不再转发流量。所以你的服务要提供一个/health接口返回 200 表示健康。这个接口最好能检查关键依赖比如 Redis 连接而不只是返回一个静态的 ok。Service 的清单apiVersion: v1 kind: Service metadata: name: langgraph-agent-svc spec: selector: app: langgraph-agent ports: - port: 80 targetPort: 8000 type: ClusterIPClusterIP是集群内访问如果要对集群外暴露用NodePort或LoadBalancer或者通过 Ingress 转发。生产环境一般用 Ingress配合域名和证书。5.3 多实例下的状态一致性怎么保证这是 K8s 部署 LangGraph 最核心的问题。3 个副本用户请求可能打到任意一个如果状态存在 Pod 本地就会出现这次对话在这个 Pod下次对话在另一个 Pod历史全没了的情况。解决方案只有一个状态必须外置所有 Pod 共享同一个存储。具体做法checkpointer 用RedisSaver或PostgresSaver连接串通过环境变量注入。所有 Pod 连同一个 Redis/Postgres 实例或集群。thread_id作为状态隔离的 key无论请求打到哪个 Pod都能从共享存储里读到同一个会话的状态。这样配置之后Pod 是无状态的可以随意扩缩容、重启、迁移状态始终一致。注意Redis 单点也是故障点。生产环境建议用 Redis 集群或哨兵模式避免 Redis 挂了整个服务不可用。如果对持久化要求高用 Postgres 更稳但延迟比 Redis 高一些。5.4 K8s 部署的常见故障与排查K8s 部署的坑不少我挑几个高频的说。Pod 一直CrashLoopBackOff。最常见的原因是启动命令报错或者依赖连不上。排查步骤kubectl logs pod-name看日志kubectl describe pod pod-name看事件。如果是连不上 Redis检查 ConfigMap 里的地址对不对网络策略有没有放行。the api server is not healthy after 4m。这个报错在初始化 K8s 控制节点时出现通常是网络插件没装好或者容器运行时配置有问题。检查kubelet状态、containerd状态确认网络插件如 Calico、Flannel的 Pod 是否正常运行。Service 访问不通。先确认 Pod 的 readiness 探针是否通过kubectl get endpoints svc-name看有没有端点。如果端点是空的说明没有 Pod 通过就绪检查。再检查 Service 的selector和 Pod 的labels是否匹配这个经常因为拼写错误导致。镜像拉取失败ImagePullBackOff。私有仓库要配imagePullSecrets把仓库的认证信息做成 Secret在 Deployment 里引用。另外确认镜像 tag 存在别写了个不存在的版本。排查 K8s 问题我习惯按这个顺序kubectl get pods看状态 →kubectl describe pod看事件 →kubectl logs看应用日志 →kubectl exec进容器手动验证。大部分问题在前两步就能定位。6. 三条路径的选型决策与迁移时机6.1 什么时候该从脚本升级到 FastAPI脚本直跑够不够用判断标准很简单有没有别人要调用的需求。如果只有你自己跑脚本就行一旦有第二个人要用或者要接入前端、接入其他系统就该上 FastAPI 了。具体信号包括需要 HTTP 接口让前端或其他服务调用。需要并发处理多个请求。需要流式输出做打字机效果。需要统一的鉴权、限流、日志。这些需求出现任何一个脚本直跑就不够了。升级到 FastAPI 的成本其实不高核心工作就是把图的调用包一层 HTTP代码量不大但收益明显。6.2 什么时候该从 FastAPI 升级到 K8sFastAPI 单实例能扛的量其实不小我实测过一个 4 核 8G 的机器跑 LangGraph 服务QPS 能到几十取决于图里 LLM 调用的耗时。所以不是一上来就得上 K8s。该上 K8s 的信号需要高可用单实例挂了服务就断了业务不能接受。需要弹性伸缩流量波动大高峰期要自动扩容低谷期要缩容省钱。需要多环境隔离开发、测试、生产要用同一套部署方式环境之间隔离。团队规模上来了多个人协作需要标准化的部署流程和资源管理。如果只是内部小工具日活几十人FastAPI 单实例加个systemd守护进程就够了上 K8s 是过度设计。K8s 的学习成本和运维成本都不低别为了技术而技术。6.3 迁移过程中的状态迁移策略从一条路径迁到另一条最大的风险是状态丢失。我建议的迁移策略是双写过渡新路径部署好checkpointer 指向新的存储。旧路径暂时保留但把新请求都导到新路径。旧路径上的存量会话等自然结束后下线。确认新路径稳定后彻底关掉旧路径。如果存量会话很重要不能等它自然结束那就写个迁移脚本把旧存储的状态读出来写到新存储。LangGraph 的 checkpointer 接口是统一的get和put方法都有写迁移脚本不难但要注意状态格式的兼容性——不同 checkpointer 的序列化方式可能不一样迁移前先在测试环境验证。7. 实操中踩过的坑与独家经验7.1 流式输出在生产环境的三个陷阱流式输出在本地跑得好好的一上生产就出问题我遇到过三次每次原因都不一样。第一次是 Nginx 缓冲。Nginx 默认会缓冲响应导致 SSE 的 chunk 攒够一批才发出去用户看到的还是卡一下出一大段。解决方法是加响应头X-Accel-Buffering: no或者在 Nginx 配置里对 SSE 的 location 关掉proxy_buffering。第二次是 uvicorn 的 worker 数。uvicorn 多 worker 模式下SSE 连接可能被分配到不同 worker如果状态在内存里流式过程中读不到之前的上下文。这个的根因还是状态没外置换成 Redis 之后就好了。第三次是客户端超时。SSE 连接如果长时间没有数据某些客户端或中间层会主动断开。解决方法是加心跳每隔 15 秒发一个空注释: heartbeat\n\n保持连接活跃。7.2 资源限制设多少才合理K8s 里resources设多少很多人拍脑袋。我的经验是先观察再设。本地跑的时候用docker stats看内存和 CPU 占用取峰值上浮 50% 作为 limits取平均值的 1.2 倍作为 requests。LangGraph 服务的资源占用主要看图的复杂度纯 LLM 调用无本地计算内存 512Mi 够CPU 250m 够。有向量检索、本地 embedding内存 1Gi 起CPU 500m 起。有本地模型推理那得按模型大小算另当别论。注意limits 设太低会导致 Pod 被 OOMKilled设太高会浪费资源。建议先用宽松的 limits 跑一段时间看监控数据再收紧。7.3 日志和监控怎么配才够用服务上线后出问题第一件事是看日志。LangGraph 服务的日志我建议分三层访问日志记录每个请求的thread_id、耗时、状态码。用 FastAPI 的中间件统一打。业务日志记录图的关键节点执行情况比如进入 planner 节点、工具调用返回。错误日志记录异常堆栈带上thread_id方便定位是哪个会话出的问题。监控方面至少要有 QPS、响应时间、错误率、Pod 数量这几个指标。Prometheus Grafana 是标配FastAPI 可以用prometheus-fastapi-instrumentator快速接入几行代码就能暴露指标。7.4 常见问题速查表问题可能原因排查方向服务启动报端口占用8000 端口被占lsof -i:8000找进程换端口或杀进程请求超时图里有慢节点或 LLM 响应慢加超时配置给 LLM 调用设 timeout状态读不到checkpointer 配置不一致确认所有实例连的是同一个存储内存泄漏MemorySaver 无限累积换外部存储或加 TTL 清理镜像构建慢依赖多没利用缓存先 COPY requirements.txt 再装依赖Pod 频繁重启liveness 探针太敏感调大initialDelaySeconds和failureThreshold8. 写在最后的一点个人体会三条路径走下来我最大的感受是部署这件事复杂度不在技术本身而在对需求的判断。很多人一上来就想上最重的方案结果发现根本用不上反而被 K8s 的运维拖累。也有人一直用脚本直跑等到业务量上来了才手忙脚乱地重构。我的建议是按需演进别提前优化。先用脚本验证逻辑逻辑通了上 FastAPI 对外服务服务稳定了、有高可用需求了再上 K8s。每一步的迁移成本都不高因为 LangGraph 的图定义和状态管理是解耦的换部署方式不用改图本身。最后分享一个小技巧不管走哪条路径把图的构建逻辑和部署逻辑彻底分开。图定义放在graph/目录部署相关的代码放在api/、deploy/目录。这样你从 FastAPI 迁到 K8s 时图代码一行不用动只改部署配置。这个习惯能帮你省下大量重构时间。