
1. 从脚本到服务为什么部署这一步总卡人LangGraph 这个框架写过 demo 的人都知道本地跑起来是真舒服。一个StateGraph几个节点函数边一连graph.invoke()一调控制台刷刷打印状态流转清清楚楚。但只要你想把它从“我电脑上能跑”变成“别人也能用”问题就来了脚本怎么变成服务服务怎么打包打包完怎么放到服务器上服务器上怎么保证它一直活着我见过太多人卡在这一步。不是 LangGraph 本身难而是从脚本到服务之间隔着一整套工程化的东西。你写脚本的时候输入是硬编码的输出是print的状态是内存里的跑完就没了。但做成服务输入得从 HTTP 请求来输出得序列化成 JSON 返回状态得考虑并发和持久化进程得考虑崩溃重启资源得考虑隔离和限制。这篇文章就是冲着这个问题来的。我会把 LangGraph 从脚本到服务的三条部署路径拆开讲清楚本地进程直跑、FastAPI 包装成 HTTP 服务、Docker 容器化再上 K8s 编排。三条路径对应三种场景复杂度递增但每一步的动机和取舍我都会说明白。如果你现在手里有一个 LangGraph 脚本想把它变成别人能调用的服务或者你正在纠结要不要上 Docker、要不要上 K8s那这篇内容应该能帮你省下不少试错时间。先给一个全局的判断不是所有 LangGraph 项目都需要上 K8s。我见过有人为了一个日调用量不到一百次的小工具硬是搭了一套 K8s 集群结果运维成本比开发成本还高。部署路径的选择核心看三个维度调用量、并发要求、团队运维能力。下面我会按这三条路径逐一展开每条路径都给出具体的操作步骤、参数配置和踩坑记录。2. 三条部署路径的整体设计与选型逻辑2.1 路径一本地进程直跑适合验证和单机工具这是最轻的一条路。你的 LangGraph 脚本写完之后不包装任何 Web 框架直接用一个入口脚本启动通过命令行参数或者读取本地文件来接收输入结果写到文件或者打印到终端。适合的场景很明确个人本地工具、一次性批处理任务、算法验证阶段。这条路径的核心优势是零额外依赖。你不需要装 FastAPI不需要配 Uvicorn不需要写 Dockerfile。Python 环境里装好 LangGraph 和相关模型依赖直接python run.py就完事。对于还在调 prompt、调图结构、调状态定义的阶段这条路径效率最高因为改完代码直接重跑没有构建、没有镜像、没有部署。但它的局限也很明显。第一没有并发能力同一时间只能处理一个请求第二个请求得排队。第二没有服务发现别人想用你的能力只能你把脚本拷给他或者他 SSH 到你的机器上跑。第三没有健康检查进程挂了没人知道得手动重启。所以这条路径只适合验证阶段和纯本地工具一旦要对外提供服务就得往路径二走。2.2 路径二FastAPI 包装成 HTTP 服务适合中小规模对外服务这是目前最主流的一条路。LangGraph 负责编排逻辑FastAPI 负责 HTTP 接口层Uvicorn 负责跑 ASGI 服务。三者分工明确LangGraph 管“怎么思考”FastAPI 管“怎么接收和返回”Uvicorn 管“怎么监听端口和处理连接”。为什么选 FastAPI 而不是 Flask核心原因是异步支持。LangGraph 的很多操作尤其是调用大模型 API 的时候是 IO 密集型的用异步能显著提升并发吞吐。Flask 是同步框架虽然也能跑但在高并发场景下线程池会被迅速占满。FastAPI 原生支持async def配合httpx或者各家模型 SDK 的异步客户端单进程就能扛住不错的并发量。另外 FastAPI 自带 OpenAPI 文档接口定义完自动生成/docs页面调试和对接都方便。这条路径的典型架构是客户端发 HTTP 请求到 FastAPI 的某个路由路由函数里调用 LangGraph 的graph.ainvoke()或者graph.astream()拿到结果后序列化成 JSON 返回。状态管理上如果是无状态请求每次调用传入完整输入即可如果需要多轮对话就得引入会话 ID 和外部存储比如 Redis来保存状态。2.3 路径三Docker 容器化加 K8s 编排适合规模化生产环境当你的服务需要多实例、需要自动扩缩容、需要滚动更新、需要资源隔离的时候就得上容器和编排。Docker 解决的是“环境一致性”问题你的 LangGraph 服务依赖哪些 Python 包、哪个版本的 CUDA、哪些系统库全部打包进镜像换台机器跑起来行为一致。K8s 解决的是“编排”问题多少个副本、怎么负载均衡、挂了怎么重启、资源不够怎么扩容、怎么灰度发布。这条路径的复杂度是前两条的好几倍。你需要写 Dockerfile、构建镜像、推送到镜像仓库、写 K8s 的 Deployment 和 Service 配置、配 Ingress、配 ConfigMap 和 Secret、配健康检查探针、配资源限制。如果团队里没有专门的运维这条路的维护成本会很高。所以我的建议是日调用量稳定超过一万次或者有明确的弹性伸缩需求再考虑上 K8s。否则路径二加个进程守护工具比如 systemd就够用了。三条路径的对比我整理成了一张表方便你快速判断自己该走哪条维度路径一本地直跑路径二FastAPI 服务路径三Docker K8s适用场景验证、单机工具中小规模对外服务规模化生产环境并发能力无中等异步高多副本环境一致性依赖本机依赖本机镜像保证运维成本极低低高扩缩容不支持手动自动健康检查无可加原生支持推荐调用量个人使用日千到万级日万级以上3. 路径二实操用 FastAPI 把 LangGraph 脚本包成服务3.1 项目目录结构怎么设计才不乱很多人写 FastAPI 项目所有代码堆在一个main.py里几百行下去自己都找不到东西。LangGraph 本身就有图定义、节点函数、状态类型、工具函数这些模块再加上 FastAPI 的路由、依赖、配置不分开根本没法维护。我推荐的结构是这样的langgraph-service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口注册路由 │ ├── config.py # 配置管理读环境变量 │ ├── graph/ │ │ ├── __init__.py │ │ ├── state.py # 状态类型定义 │ │ ├── nodes.py # 节点函数 │ │ └── builder.py # 图构建逻辑 │ ├── api/ │ │ ├── __init__.py │ │ ├── routes.py # 路由定义 │ │ └── schemas.py # 请求/响应模型 │ └── services/ │ └── graph_service.py # 业务逻辑封装 ├── tests/ ├── requirements.txt ├── Dockerfile └── .env这个结构的关键在于分层。graph/目录只关心 LangGraph 的图逻辑不关心 HTTPapi/目录只关心请求解析和响应构造不关心图怎么跑services/目录做中间层把图的调用封装成业务方法。这样改图逻辑不影响接口改接口不影响图逻辑。3.2 状态定义与请求模型的分离这里有个容易踩的坑很多人直接把 LangGraph 的State类型拿来做 FastAPI 的请求体模型。这俩东西看起来都是数据结构但职责完全不同。LangGraph 的State是图内部流转的状态可能包含中间变量、临时标记、内部 ID而 API 的请求模型是外部契约只应该包含客户端需要传的字段。我的做法是分开定义。graph/state.py里定义图状态from typing import TypedDict, Annotated from langgraph.graph.message import add_messages class GraphState(TypedDict): messages: Annotated[list, add_messages] session_id: str user_input: str intermediate_result: strapi/schemas.py里定义请求和响应from pydantic import BaseModel, Field class ChatRequest(BaseModel): session_id: str Field(..., description会话标识) message: str Field(..., min_length1, description用户输入) class ChatResponse(BaseModel): session_id: str reply: str status: str ok然后在services/graph_service.py里做转换把ChatRequest转成GraphState调图再把结果转成ChatResponse。这层转换看起来多此一举但等你需要改接口字段而不想动图逻辑的时候就知道它的价值了。3.3 路由设计与异步调用路由这块核心是把 LangGraph 的调用正确地异步化。LangGraph 编译后的图对象支持ainvoke和astream两种异步调用方式。ainvoke是一次性返回最终结果astream是流式返回中间状态。如果你的场景需要流式输出比如打字机效果就用astream配合 FastAPI 的StreamingResponse。先看非流式的写法from fastapi import APIRouter, HTTPException from app.api.schemas import ChatRequest, ChatResponse from app.services.graph_service import run_graph router APIRouter() router.post(/chat, response_modelChatResponse) async def chat(req: ChatRequest): try: result await run_graph(req.session_id, req.message) return ChatResponse(session_idreq.session_id, replyresult) except Exception as e: raise HTTPException(status_code500, detailstr(e))流式的写法稍微复杂一点from fastapi.responses import StreamingResponse router.post(/chat/stream) async def chat_stream(req: ChatRequest): async def event_generator(): async for chunk in stream_graph(req.session_id, req.message): yield fdata: {chunk}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)注意流式接口用 SSE 的时候记得在 Nginx 或者网关层关掉缓冲否则客户端会等到全部生成完才收到数据流式就白做了。3.4 启动配置与 Uvicorn 参数调优启动命令看起来简单但参数配不对性能和稳定性都会受影响。最基础的启动方式uvicorn app.main:app --host 0.0.0.0 --port 8000生产环境我一般会加上这些参数uvicorn app.main:app \ --host 0.0.0.0 \ --port 8000 \ --workers 4 \ --loop uvloop \ --http httptools \ --timeout-keep-alive 30 \ --log-level info--workers 4表示起 4 个工作进程一般设为 CPU 核数。但这里有个坑如果你的 LangGraph 图里持有大量内存状态多 worker 会导致内存翻倍。这种情况下要么减少 worker 数量要么把状态外置到 Redis。--loop uvloop用 uvloop 替换默认事件循环IO 性能有明显提升。--http httptools用 httptools 做 HTTP 解析比默认的快。还有一个常见问题是Uvicorn 日志丢失。如果你发现访问日志打不出来检查两点一是--log-level是不是设成了warning以上二是如果你在代码里用了logging模块确认 logger 的 handler 和 Uvicorn 的配置没有冲突。我一般会在main.py里显式配置 loggingimport logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s )3.5 进程守护与开机自启FastAPI 服务跑起来之后你得保证它挂了能自动重启、机器重启能自动拉起。最省事的方案是 systemd。写一个 service 文件[Unit] DescriptionLangGraph FastAPI Service Afternetwork.target [Service] Userappuser WorkingDirectory/opt/langgraph-service ExecStart/opt/langgraph-service/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 Restartalways RestartSec5 EnvironmentPYTHONUNBUFFERED1 [Install] WantedBymulti-user.target放到/etc/systemd/system/langgraph.service然后systemctl daemon-reload systemctl enable --now langgraph。Restartalways保证进程崩溃后 5 秒自动重启enable保证开机自启。这套组合在单机部署场景下非常稳我用了好几年没出过问题。4. 路径三实操Docker 容器化与 K8s 编排要点4.1 Dockerfile 怎么写才能又小又快LangGraph 服务的镜像最容易犯的错是直接FROM python:3.11然后pip install -r requirements.txt结果镜像两个 G。问题出在基础镜像太大、构建缓存没利用、依赖装了一堆用不上的。我的做法是分阶段构建加精简基础镜像。先看一个典型的 DockerfileFROM python:3.11-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --no-cache-dir --prefix/install -r requirements.txt FROM python:3.11-slim WORKDIR /app COPY --frombuilder /install /usr/local COPY app/ ./app/ ENV PYTHONUNBUFFERED1 ENV PYTHONDONTWRITEBYTECODE1 EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000, --workers, 2]几个关键点python:3.11-slim比完整版小很多分阶段构建把编译工具留在 builder 阶段最终镜像不带--no-cache-dir避免 pip 缓存占空间PYTHONDONTWRITEBYTECODE1避免生成.pyc文件。如果你的服务需要 GPU 跑本地模型基础镜像要换成nvidia/cuda系列的并且需要装对应的 PyTorch 版本。这种情况下镜像会大很多构建时间也长建议把模型文件通过挂载卷的方式外置不要打进镜像。4.2 容器运行的资源限制与网络配置镜像构建好之后本地跑起来验证docker run -d \ --name langgraph-svc \ -p 8000:8000 \ --memory 2g \ --cpus 2 \ -e MODEL_API_KEYyour_key \ -v /data/models:/app/models \ langgraph-service:v1--memory 2g和--cpus 2是资源限制防止单个容器吃光宿主机资源。-e传环境变量敏感信息不要写进镜像。-v挂载模型目录避免镜像过大。网络这块如果多个容器需要互相通信比如 LangGraph 服务要连 Redis 存会话状态建议用自定义 bridge 网络docker network create langgraph-net docker run -d --name redis --network langgraph-net redis:7-alpine docker run -d --name langgraph-svc --network langgraph-net langgraph-service:v1同一个网络里的容器可以用容器名直接互相访问不用管 IP。4.3 K8s 部署的核心配置拆解上了 K8s核心是三个资源对象Deployment、Service、Ingress。Deployment 管副本和更新Service 管内部负载均衡Ingress 管外部访问。Deployment 的关键配置apiVersion: apps/v1 kind: Deployment metadata: name: langgraph-svc spec: replicas: 3 selector: matchLabels: app: langgraph-svc template: metadata: labels: app: langgraph-svc spec: containers: - name: app image: registry.example.com/langgraph-service:v1 ports: - containerPort: 8000 resources: requests: memory: 1Gi cpu: 500m limits: memory: 2Gi cpu: 2 readinessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 10 periodSeconds: 5 livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 10 env: - name: MODEL_API_KEY valueFrom: secretKeyRef: name: langgraph-secrets key: model-api-keyreplicas: 3起三个副本。resources里的requests是调度依据limits是硬上限。readinessProbe决定 Pod 什么时候可以接流量livenessProbe决定 Pod 什么时候重启。这两个探针必须配否则流量可能打到还没启动完的 Pod 上或者挂死的 Pod 一直不重启。Service 配置apiVersion: v1 kind: Service metadata: name: langgraph-svc spec: selector: app: langgraph-svc ports: - port: 80 targetPort: 8000 type: ClusterIPIngress 配置apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: langgraph-ingress annotations: nginx.ingress.kubernetes.io/proxy-read-timeout: 300 spec: rules: - host: langgraph.example.com http: paths: - path: / pathType: Prefix backend: service: name: langgraph-svc port: number: 80注意流式接口的proxy-read-timeout要调大默认 60 秒可能不够长文本生成容易超时断开。4.4 配置与密钥管理K8s 里配置和密钥要分开管。普通配置用 ConfigMap敏感信息用 Secret。创建 Secretkubectl create secret generic langgraph-secrets \ --from-literalmodel-api-keyyour_key \ --from-literalredis-passwordyour_password然后在 Deployment 里通过valueFrom.secretKeyRef引用。这样密钥不会出现在镜像里也不会出现在代码仓库里。ConfigMap 类似用来存非敏感的配置项比如模型名称、超时时间、日志级别。4.5 滚动更新与回滚K8s 的滚动更新是默认行为改镜像版本后kubectl apply就会触发。关键参数是maxSurge和maxUnavailablestrategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxSurge: 1表示更新时最多多起一个 PodmaxUnavailable: 0表示更新过程中可用 Pod 数量不能减少。这样能保证更新期间服务不中断。如果新版本有问题kubectl rollout undo deployment/langgraph-svc一键回滚到上一版。5. 常见问题与排查技巧实录5.1 服务启动类问题问题一Uvicorn 启动报Address already in use端口被占了。先lsof -i :8000或者netstat -tlnp | grep 8000找到占用进程要么杀掉要么换端口。Docker 场景下检查是不是有同名容器还在跑docker ps -a看一下。问题二Docker Desktop 启动失败提示虚拟化未开启Windows 上装 Docker Desktop如果 BIOS 里没开虚拟化会报virtualization support not detected。进 BIOS 打开 VT-x 或者 AMD-V然后在 Windows 功能里确认Hyper-V和虚拟机平台都勾上了。如果还是不行检查是不是装了其他虚拟化软件冲突。问题三容器内服务启动正常但外部访问不通先确认端口映射对不对docker run -p 8000:8000前面是宿主机端口后面是容器端口。然后确认服务监听的是0.0.0.0而不是127.0.0.1监听127.0.0.1的话容器外访问不到。最后检查防火墙规则。5.2 性能与稳定性问题问题四并发一高就超时先看是不是 worker 数量不够。Uvicorn 单 worker 是单进程异步虽然能处理并发但 CPU 密集操作会阻塞事件循环。如果图里有大量同步计算考虑加 worker 或者把计算部分放到线程池里跑。另外检查模型 API 调用是不是同步的同步调用会阻塞整个事件循环必须换成异步客户端。问题五内存持续增长不释放LangGraph 的图如果持有大量状态多轮对话场景下内存会累积。检查是不是把会话状态存在了进程内存里如果是改成存 Redis 或者数据库。另外检查有没有循环引用导致 GC 回收不掉可以用tracemalloc或者objgraph排查。问题六K8s 里 Pod 频繁重启先kubectl describe pod pod-name看事件常见原因是livenessProbe失败。如果服务启动慢initialDelaySeconds要调大。如果是内存超限被 OOMKill调大limits.memory或者排查内存泄漏。还有一种情况是探针路径写错了服务根本没这个接口那肯定一直失败。5.3 排查速查表现象可能原因排查动作启动报端口占用端口被其他进程占用lsof -i :端口找进程外部访问不通监听地址或端口映射错误检查0.0.0.0和-p参数并发超时worker 不足或同步阻塞加 worker换异步客户端内存增长状态未外置或内存泄漏状态存 Redis用 tracemalloc 排查Pod 频繁重启探针失败或 OOMkubectl describe pod看事件流式输出卡顿网关缓冲未关闭关 Nginx/Ingress 缓冲日志丢失日志级别或 handler 冲突显式配置 logging5.4 几条踩坑心得第一条不要在容器里跑模型训练或者大模型推理除非你有 GPU 直通。容器里的 GPU 支持需要额外配置而且资源隔离做不好会影响其他容器。模型推理建议单独部署LangGraph 服务通过 API 调用。第二条K8s 的 ConfigMap 更新不会自动重启 Pod。改了 ConfigMap 之后要么手动kubectl rollout restart deployment要么用工具做自动 reload。这个坑我踩过改完配置发现没生效排查半天才发现 Pod 没重启。第三条Docker 镜像的 tag 不要用latest。生产环境用latest会导致回滚困难因为你不知道上一个latest是哪个版本。用语义化版本号或者 git commit hash 做 tag回滚的时候明确知道回哪个。第四条健康检查接口要轻量。/health接口不要做复杂逻辑就返回个 200 就行。如果健康检查里去连数据库、调模型探针超时会误判导致 Pod 被反复重启。6. 三条路径的迁移时机与扩展方向6.1 什么时候该从路径一升到路径二判断标准很简单当第二个人需要调用你的 LangGraph 能力时。如果只有你自己用本地脚本足够了。但一旦有同事、有前端、有其他服务需要调就得包成 HTTP 服务。另一个信号是需要并发本地脚本一次只能跑一个任务两个请求就得排队这时候 FastAPI 的异步能力就体现出价值了。从路径一升路径二改动量其实不大。核心工作是把脚本里的输入输出改成请求响应模型把graph.invoke()改成graph.ainvoke()然后加一层路由。图本身的逻辑基本不用动这也是 LangGraph 设计得好的地方编排逻辑和运行方式解耦。6.2 什么时候该从路径二升到路径三三个信号调用量稳定增长、需要弹性伸缩、需要多环境一致性。如果日调用量到了万级单机 FastAPI 即使加 worker 也扛不住就得考虑多实例加负载均衡。如果流量有明显波峰波谷比如白天高晚上低K8s 的 HPA 能自动扩缩容省资源。如果团队有多套环境开发、测试、生产Docker 镜像能保证环境一致避免“我本地能跑”的问题。但升级之前算一笔账K8s 集群本身的资源开销、运维人力、学习成本。如果这些成本超过了你节省的服务器费用和运维时间那就先别升。我见过小团队硬上 K8s结果一半时间在修集群一半时间在写业务得不偿失。6.3 后续可以扩展的方向第一个方向是状态持久化。目前路径二和路径三的会话状态如果存在内存里多副本场景下会丢。可以接 Redis 或者 PostgreSQL 做 checkpointerLangGraph 本身支持SqliteSaver和PostgresSaver换成分布式的就行。第二个方向是可观测性。加 LangSmith 或者 OpenTelemetry把每次图执行的链路、耗时、token 消耗都记录下来。生产环境没有可观测性就是盲跑出了问题只能猜。第三个方向是多模型路由。在 LangGraph 的节点里根据任务类型路由到不同的模型简单任务走小模型复杂任务走大模型成本和效果都能兼顾。第四个方向是灰度发布。K8s 里可以用两个 Deployment 加 Service 的权重配置做灰度新版本先接 10% 流量观察没问题再全量。这个在路径三里是原生支持的路径二就得自己写网关逻辑。部署这件事没有一步到位的方案只有适合当前阶段的方案。我自己的习惯是先用路径一把逻辑跑通确定图结构和 prompt 稳定了再升路径二对外服务等调用量真的上来了再考虑路径三。每次升级都只解决当前最痛的问题不提前过度设计。这套节奏用了几年踩坑最少返工也最少。