尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LangGraph 生产部署实战:FastAPI、Docker 与 K8s 三条路径

LangGraph 生产部署实战:FastAPI、Docker 与 K8s 三条路径 1. 从一段脚本到常驻服务中间隔了什么很多人第一次接触 LangGraph都是在 Jupyter Notebook 或者一个单文件脚本里跑通一个带状态的多轮对话流程。那种感觉很爽定义几个节点、连几条边、加一个条件路由一个能记住上下文、能调用工具的 Agent 就跑起来了。但当你兴冲冲地想把这段代码交给同事用、或者挂到线上让真实用户访问时问题就来了——脚本跑完就退出进程一关状态全丢多个人同时访问还会互相串数据。这时候你才意识到从能跑的脚本到能用的服务中间隔着的不是几行代码而是一整套工程化的东西。LangGraph 这个库本身解决的是编排问题它把复杂的、带循环和分支的 LLM 工作流抽象成图结构用状态机的方式管理每一步的输入输出。但它刻意没有解决部署问题——官方文档里翻来覆去讲的是 StateGraph、Checkpointer、ToolNode 这些概念至于怎么把它变成一个 HTTP 接口、怎么打包成容器、怎么在集群里横向扩展基本靠你自己摸索。这不是 LangGraph 的缺陷而是它的定位它是一个库不是一个平台。所以这篇内容我想聊的就是这件事把 LangGraph 从脚本推到生产环境到底有哪几条路可以走每条路适合什么场景以及我在实际踩坑过程中总结出来的那些文档里不会写的细节。三条路径分别是FastAPI 直接托管、Docker 容器化单机部署、K8s 集群编排。它们不是互斥的而是一个递进关系——你可以先跑通第一条等流量上来了再往第二条、第三条迁移。关键词里出现的 FastAPI、Docker、K8s、uvicorn、Checkpointer 这些都会在对应的路径里展开讲。如果你现在手里已经有一个能跑的 LangGraph 脚本正发愁怎么让别人也能用上那这篇内容应该能帮你少走不少弯路。如果你还没写过 LangGraph只是想先了解部署长什么样也可以先看看每条路径的适用边界心里有个谱。2. 路径一FastAPI 直接托管最快让脚本变成接口2.1 为什么第一站选 FastAPI 而不是 Flask把 LangGraph 包成 HTTP 服务最直觉的选择是 FastAPI。原因不复杂LangGraph 的调用天然是异步的节点里经常要 await 模型 API、await 工具调用而 FastAPI 原生支持 async/await事件循环和 LangGraph 的异步执行能对上。相比之下 Flask 是同步 WSGI 框架你要么用线程池硬扛要么上 Flask 的异步支持本质还是跑在别的循环里怎么都别扭。另一个现实原因是流式输出。LangGraph 支持astream和astream_events可以边生成边推给前端这对聊天类应用几乎是刚需。FastAPI 配合StreamingResponse或者 SSEServer-Sent Events能很自然地做这件事而 Flask 做流式要绕一圈生成器调试起来心累。我做过一个对比同样是包一个带工具调用的 AgentFastAPI 版本从零到能跑通流式接口大概半天Flask 版本光是处理异步和流式的兼容就花了一天多最后还留了个高并发下偶发阻塞的尾巴。所以除非你团队有强制的 Flask 技术栈否则第一站直接上 FastAPI。2.2 一个能直接抄的项目目录结构热词里有人搜fastapi项目目录结构说明这是很多人的痛点。我把自己用了几个项目的结构贴出来不复杂但够用langgraph-service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口挂路由 │ ├── graph/ │ │ ├── __init__.py │ │ ├── builder.py # 构建 StateGraph 的地方 │ │ ├── nodes.py # 各个节点函数 │ │ └── state.py # State 的 TypedDict 定义 │ ├── api/ │ │ ├── __init__.py │ │ └── routes.py # /chat /stream 等接口 │ ├── core/ │ │ ├── config.py # 环境变量、模型 key 读取 │ │ └── checkpointer.py # Checkpointer 初始化 │ └── schemas/ │ └── chat.py # Pydantic 请求/响应模型 ├── tests/ ├── requirements.txt ├── Dockerfile └── .env.example这个结构的关键在于把图逻辑和接口逻辑分开。graph/目录里只关心状态怎么流转、节点怎么执行完全不碰 HTTPapi/目录里只关心请求怎么进来、响应怎么出去不碰图内部。这样带来的好处是你想换 Web 框架比如从 FastAPI 换到别的只动api/你想改图逻辑只动graph/。我见过太多项目把两者揉在一个文件里改一个路由要翻半天图代码维护成本极高。2.3 把图编译一次别在每个请求里重建这是新手最容易犯的错也是性能杀手。很多人写接口是这样的app.post(/chat) async def chat(req: ChatRequest): graph builder.build_graph() # 每个请求都重新构建 result await graph.ainvoke({messages: [req.message]}) return {reply: result[messages][-1].content}看起来没问题但build_graph()里如果有编译步骤graph.compile()每次请求都要重新走一遍节点注册、边连接、编译校验。请求量小的时候感觉不出来一旦并发上来CPU 全耗在重复构建上了。正确做法是在应用启动时构建一次用 FastAPI 的 lifespan 机制挂上去from contextlib import asynccontextmanager from fastapi import FastAPI asynccontextmanager async def lifespan(app: FastAPI): # 启动时构建并编译图存到 app.state app.state.graph builder.build_graph().compile( checkpointercheckpointer ) yield # 关闭时清理资源 await checkpointer.aclose() app FastAPI(lifespanlifespan) app.post(/chat) async def chat(req: ChatRequest): graph app.state.graph # 直接用编译好的 result await graph.ainvoke( {messages: [req.message]}, config{configurable: {thread_id: req.session_id}} ) return {reply: result[messages][-1].content}这里顺带把thread_id也带上了。LangGraph 的 Checkpointer 靠thread_id区分不同会话你不传的话所有请求共享一个状态多用户场景下会串得乱七八糟。thread_id一般用前端生成的 session id或者后端根据用户 id 拼一个。2.4 uvicorn 日志丢失一个高频踩坑点热词里uvicorn fastapi 日志丢失问题被搜了很多次说明这是个普遍困扰。现象是你在节点函数里print或者logging.info打的东西跑起来在控制台看不到或者只在某些情况下能看到。根因通常有两个。第一个是 uvicorn 默认的日志配置会覆盖你自定义的 logger。uvicorn 启动时会调用logging.config.dictConfig把 root logger 的 handler 换掉你之前配的 handler 就失效了。解决办法是在启动 uvicorn 时传--log-config指定自己的配置或者干脆在应用启动后重新配置import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, forceTrue # 关键强制覆盖已有配置 )forceTrue这个参数是 Python 3.8 之后才有的作用就是清掉已有的 handler 重新配。很多人不知道这个参数配了半天没生效就是因为它。第二个原因是异步上下文里的日志被吞。如果你在async def节点里用了同步的 logging而事件循环正忙日志可能延迟输出甚至丢。稳妥的做法是节点里也用异步日志或者至少保证 logging 的 handler 是线程安全的。我一般会在core/config.py里统一初始化日志所有模块getLogger(__name__)这样格式统一、排查方便。2.5 流式接口怎么写才不卡聊天场景基本都要流式。LangGraph 的astream_events能拿到细粒度的事件但直接透传给前端会有一堆噪音。我的做法是只挑on_chat_model_stream事件里的 token拼成 SSE 推出去from fastapi.responses import StreamingResponse import json app.post(/stream) async def stream(req: ChatRequest): graph app.state.graph async def event_gen(): async for event in graph.astream_events( {messages: [req.message]}, config{configurable: {thread_id: req.session_id}}, versionv2 ): if event[event] on_chat_model_stream: chunk event[data][chunk] if chunk.content: yield fdata: {json.dumps({token: chunk.content})}\n\n yield data: [DONE]\n\n return StreamingResponse(event_gen(), media_typetext/event-stream)这里有个细节versionv2一定要显式指定。LangGraph 的事件格式在不同版本间变过不指定的话可能拿到旧格式字段名对不上。另外 SSE 的每条消息必须以\n\n结尾少一个换行前端就收不到这个坑我踩过。提示流式接口在 Nginx 反代后面容易被缓冲导致前端看到的是一次性吐出全部而不是逐字输出。记得在 Nginx 配置里加proxy_buffering off;和X-Accel-Buffering: no响应头。3. 路径二Docker 容器化让服务能搬得动3.1 为什么单机跑得好好的还要上 DockerFastAPI 直接跑在服务器上能用了。但很快你会遇到几个问题换台机器部署要重新装 Python、装依赖、配环境变量每次都可能因为版本差异出幺蛾子团队里每个人的本地环境不一样在我机器上是好的成了口头禅想同时跑两个版本做灰度端口和依赖打架。Docker 解决的就是环境一致性这件事。把 Python 版本、依赖、代码、启动命令全打进一个镜像到哪都是同一个东西。热词里docker安装教程docker desktopubuntu 安装docker搜得多说明很多人卡在第一步。安装本身不难Ubuntu 上就是apt install docker.io或者按官方脚本走Windows 上装 Docker Desktop。真正值得花时间的是怎么写一个靠谱的 Dockerfile。3.2 写一个体积小、启动快的 Dockerfile先看一个能用的版本再讲怎么优化FROM python:3.11-slim WORKDIR /app # 先拷依赖文件利用 Docker 层缓存 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷代码 COPY app/ ./app/ EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]这个 Dockerfile 有两个关键点。第一COPY requirements.txt和COPY app/分开写。Docker 构建是分层的只要 requirements.txt 没变改代码时pip install那层直接命中缓存构建从几分钟降到几秒。我见过把所有文件一次性 COPY 进去的写法每次改一行代码都要重装依赖构建慢到怀疑人生。第二用python:3.11-slim而不是python:3.11。完整版镜像接近 1GBslim 版只有 100 多 MB。slim 版缺一些编译工具如果你的依赖里有需要编译的包比如某些带 C 扩展的可能装不上这时候要么换回完整版要么在 RUN 里先apt install build-essential再装、装完再卸掉。LangGraph 的依赖基本都是纯 Pythonslim 版够用。3.3 环境变量和密钥千万别打进镜像新手常犯的错是把 API key 写死在代码里或者.env里然后 COPY 进镜像。镜像一旦推到仓库密钥就泄露了。正确做法是运行时通过环境变量注入docker run -d \ --name langgraph-service \ -p 8000:8000 \ -e OPENAI_API_KEYsk-xxx \ -e REDIS_URLredis://redis:6379 \ langgraph-service:latest代码里用os.getenv(OPENAI_API_KEY)读。本地开发时用.env文件配合python-dotenv但.env要写进.dockerignore绝不能进镜像。这个习惯一定要养成我见过不止一个项目因为镜像里带了.env被人扒出密钥。3.4 Checkpointer 选型内存、SQLite 还是 RedisLangGraph 的 Checkpointer 决定会话状态存哪。开发阶段用MemorySaver最省事进程内存里存重启就没了。但容器化部署后容器随时可能被重建内存态直接丢用户会话全断。所以生产环境必须用持久化的 Checkpointer。常见选择是 SQLite 和 Redis。SQLite 适合单实例、低并发一个文件搞定配置简单from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver checkpointer AsyncSqliteSaver.from_conn_string(/data/checkpoints.db)但 SQLite 在容器里要注意挂载卷不然容器一删数据就没了。而且 SQLite 写操作是串行的并发一高就成瓶颈。Redis 适合多实例、高并发状态存在外部容器随便扩缩容from langgraph.checkpoint.redis.aio import AsyncRedisSaver checkpointer AsyncRedisSaver.from_conn_string(redis://redis:6379)热词里docker安装redis主从k8s redis 集群说明大家对 Redis 的高可用有需求。单机测试用单实例 Redis 就够生产环境再考虑主从或集群。我的经验是会话状态这种数据读写频繁但单条不大Redis 比 SQLite 合适得多尤其是你后面要上 K8s 多副本的时候Redis 几乎是必选项。3.5 容器里访问外部服务网络这块最容易翻车容器化之后网络是最容易出问题的地方。几个典型场景容器里连宿主机的 MySQLlocalhost是不通的因为容器有自己的网络命名空间。要么用host.docker.internalDocker Desktop 支持要么用宿主机的实际 IP要么把 MySQL 也放进同一个 Docker 网络。热词里访问docker容器内的mysqldocker安装mysql失败多半就是网络没配对。我一般用自定义网络把服务串起来docker network create langgraph-net docker run -d --name redis --network langgraph-net redis:7 docker run -d --name langgraph-service \ --network langgraph-net \ -e REDIS_URLredis://redis:6379 \ langgraph-service:latest同一个网络里容器之间直接用容器名当主机名互相访问redis://redis:6379里的redis就是容器名。这样比记 IP 靠谱多了容器重建 IP 变了也不影响。注意容器里调用外部模型 API 时如果公司网络有代理记得在容器里也配HTTP_PROXY/HTTPS_PROXY环境变量否则请求会超时。这个坑在本地开发时不会遇到一上服务器就暴露。4. 路径三K8s 编排多副本与弹性伸缩4.1 什么时候该从 Docker 升级到 K8sDocker 单机跑服务挂了要手动重启流量涨了要手动加机器多副本之间还要自己搞负载均衡。当你的服务开始有这些需求时就该考虑 K8s 了需要多副本保证可用性、需要根据 CPU 或请求量自动扩缩容、需要滚动更新不中断服务、需要统一管理配置和密钥。但我要泼盆冷水如果你的服务日活就几百、单机 Docker 完全扛得住别为了用 K8s 而用 K8s。K8s 的学习曲线和运维成本是实打实的热词里k8s学习k8s安装部署k8s和docker区别搜得多说明很多人还在入门阶段。我的建议是先把 Docker 路径跑稳等真的遇到单机瓶颈再上 K8s不要一上来就搞集群。4.2 一个 LangGraph 服务的 Deployment 长什么样K8s 里部署无状态服务用 Deployment。LangGraph 服务本身是无状态的状态在 Redis 里所以很适合用 Deployment 多副本apiVersion: apps/v1 kind: Deployment metadata: name: langgraph-service spec: replicas: 3 selector: matchLabels: app: langgraph-service template: metadata: labels: app: langgraph-service spec: containers: - name: app image: langgraph-service:latest ports: - containerPort: 8000 env: - name: REDIS_URL value: redis://redis-service:6379 - name: OPENAI_API_KEY valueFrom: secretKeyRef: name: app-secrets key: openai-api-key resources: requests: cpu: 500m memory: 512Mi limits: cpu: 1000m memory: 1Gi readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 10 periodSeconds: 5几个关键点。replicas: 3表示三个副本K8s 会自动把流量分到三个 Pod 上。resources里的 requests 和 limits 一定要设requests 影响调度Pod 被分配到哪个节点limits 防止单个 Pod 吃光节点资源。不设的话一个内存泄漏的 Pod 能把整个节点拖垮。readinessProbe是就绪探针K8s 靠它判断 Pod 能不能接流量。你的服务启动时要加载图、连 Redis这些没完成之前不应该接请求所以探针要指向一个真正检查依赖的/health接口而不是简单返回 200。我一般让/health里 ping 一下 Redis连不上就返回 503这样 K8s 会等依赖就绪再放流量进来。4.3 密钥管理别把 key 写进 yaml上面 yaml 里用了secretKeyRef这是 K8s 管理密钥的标准做法。先把密钥存成 Secretkubectl create secret generic app-secrets \ --from-literalopenai-api-keysk-xxx然后在 Deployment 里引用。Secret 的内容是 base64 编码的不是加密的所以别以为存进 Secret 就安全了——任何能kubectl get secret的人都能解出来。生产环境要配合 RBAC 限制访问或者用外部的密钥管理服务。但至少比把 key 明文写在 yaml 里强yaml 进了 Git 仓库那才是真的灾难。4.4 多副本下的会话一致性这是 LangGraph 上 K8s 最需要注意的点。三个副本用户第一次请求打到 Pod A第二次打到 Pod B如果状态存在 Pod 本地内存里Pod B 根本不知道这个会话之前聊了什么直接失忆。所以多副本场景下Checkpointer 必须用外部存储Redis 是首选。所有 Pod 连同一个 Redis状态共享用户请求打到哪个 Pod 都能拿到完整上下文。这也是为什么我在路径二里强调生产环境别用 MemorySaver——它不只是重启丢数据的问题多副本下直接就是功能不可用。Redis 本身也要考虑高可用。单实例 Redis 挂了所有会话状态都没了。生产环境一般上 Redis 主从或者哨兵模式热词里k8s redis 集群就是这个方向。不过 Redis 集群配置比较复杂中小规模用主从加哨兵就够别一上来就搞 Cluster。4.5 滚动更新与优雅停机K8s 的滚动更新默认是逐个替换 Pod新 Pod 就绪后才停旧 Pod理论上不中断服务。但有个坑旧 Pod 收到停止信号后K8s 会先把它从 Service 的 Endpoints 里摘掉然后发 SIGTERM。如果你的应用收到 SIGTERM 立刻退出正在处理的请求就断了。正确做法是捕获 SIGTERM停止接收新请求等正在处理的请求跑完再退出。FastAPI 配合 uvicorn 的话uvicorn 本身会处理这个信号但你要给它留够时间spec: template: spec: terminationGracePeriodSeconds: 30 containers: - name: app lifecycle: preStop: exec: command: [sleep, 5]preStop里的 sleep 5 秒是给 K8s 摘 Endpoints 留时间避免信号发出后还有流量打进来。terminationGracePeriodSeconds: 30是给应用最多 30 秒处理完手头请求。这两个参数配合好滚动更新才能真正做到用户无感。5. 三条路径怎么选一张对照表和我的实际取舍5.1 对照表维度FastAPI 直跑Docker 单机K8s 集群上手成本最低中等最高环境一致性差好好多副本不支持手动原生支持自动扩缩容无无有状态存储内存/SQLiteRedis/SQLite必须 Redis适合场景内部工具、Demo中小流量生产高并发、多团队运维复杂度低中高5.2 我的实际迁移路径我自己的项目是这样走的最开始 FastAPI 直跑在一台 2C4G 的云主机上日请求几千完全够用。后来用户涨到几万单机 CPU 经常打满就上了 Docker把服务、Redis、Nginx 用 docker-compose 串起来一台机器跑得稳稳的。再后来要做灰度发布和多可用区容灾才迁到 K8s。每一步迁移都不是因为技术更先进而是因为遇到了上一层的真实瓶颈。我见过太多团队在日活几百的时候就上 K8s结果运维成本比开发成本还高得不偿失。技术选型要看当前阶段的实际需求不是越新越好。5.3 几个跨路径通用的经验不管走哪条路有几件事是共通的。第一配置全部走环境变量代码里不出现任何硬编码的地址和密钥这样从本地到 Docker 到 K8s 才能无缝迁移。第二日志输出到 stdout不要写文件Docker 和 K8s 都靠收集 stdout 来聚合日志写文件的话容器一删日志就没了。第三健康检查接口要真实别糊弄一个永远返回 200 的接口探针的意义就是发现异常糊弄它等于自欺欺人。还有一点关于 LangGraph 本身的图的构建和编译尽量在启动时完成运行时的每个请求只做invoke或astream。我见过把图构建放在请求里的写法QPS 一高 CPU 直接爆。这个原则在三条路径里都适用越早养成越好。6. 那些文档里不会写的排查细节6.1 容器启动就退出日志只有一行这是 Docker 新手最常见的现象。docker run之后docker ps看不到容器docker ps -a显示 Exited。原因通常是启动命令有问题要么 CMD 里的模块路径不对要么依赖没装全导致 import 失败。排查方法是docker logs 容器id看退出前的输出或者直接docker run -it 镜像 /bin/bash进去手动跑一遍启动命令错误信息一目了然。我遇到过一次是uvicorn app.main:app里的app.main路径写错本地跑没问题是因为工作目录不同容器里 WORKDIR 变了就找不到模块。这种问题看日志一眼就能定位但前提是你得知道去看日志。6.2 K8s Pod 一直 CrashLoopBackOffPod 反复重启kubectl get pods显示 CrashLoopBackOff。先用kubectl logs pod --previous看上一次崩溃的日志--previous这个参数很关键不加的话只能看到当前这次可能还没输出就崩了。常见原因环境变量没配导致启动报错、资源 limits 设太小被 OOMKilled、探针配置不当导致还没启动就被判定失败。如果是 OOMKilledkubectl describe pod pod里会看到Reason: OOMKilled。这时候要么调大 memory limits要么查代码里有没有内存泄漏。LangGraph 服务如果加载了大模型或者缓存了大量会话内存占用会比较高limits 别设太抠。6.3 流式输出在 K8s 里变成一次性返回本地 Docker 流式正常上了 K8s 就变成一次性吐出。八成是 Ingress 或 Service 的缓冲问题。Nginx Ingress 默认会缓冲响应需要在 Ingress 的 annotation 里关掉metadata: annotations: nginx.ingress.kubernetes.io/proxy-buffering: off nginx.ingress.kubernetes.io/proxy-read-timeout: 3600proxy-read-timeout也要调大默认 60 秒长对话场景流式输出可能超过这个时间超时连接就断了。这个坑很隐蔽因为本地测试时对话短根本触发不到超时。6.4 Redis 连接池耗尽多副本 高并发下如果每个请求都新建 Redis 连接连接数很快打满。LangGraph 的 Redis Checkpointer 内部一般有连接池但池大小要配合理。默认池大小可能只有 10三个副本各 10 就是 30 个连接Redis 默认最大连接数是 10000一般够用但如果你的并发特别高要适当调大池大小同时注意及时释放连接。排查方法是看 Redis 的INFO clients里的connected_clients如果持续接近上限就是连接没释放或者池太小。我一般会在应用关闭时显式aclose()Checkpointer确保连接归还。7. 写在最后的一点个人体会把 LangGraph 从脚本推到生产本质上不是 LangGraph 的问题而是所有从 Demo 到产品都要经历的工程化过程。三条路径没有优劣只有适不适合当前阶段。我自己的经验是能用简单方案解决就别上复杂方案等简单方案真的扛不住了再升级。FastAPI 直跑能撑住的场景硬上 K8s 只会让你把时间花在运维而不是业务上。另外提醒一句LangGraph 这个库迭代很快API 时有变动部署时一定要把版本锁死requirements.txt 里写langgraphx.y.z别用。我有次没锁版本重新构建镜像时拉到了新版事件格式变了流式接口直接挂掉排查了半天才发现是版本问题。锁版本这个习惯在快速迭代的库上尤其重要。
返回列表