ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LangGraph 部署实战:从 FastAPI 到 Docker 再到 K8s 的三条路径

LangGraph 部署实战:从 FastAPI 到 Docker 再到 K8s 的三条路径 1. 从脚本到服务LangGraph 的三条部署路径很多人在本地把 LangGraph 的图跑通之后下一步就卡住了——脚本在终端里跑得好好的怎么把它变成一个别人也能访问的服务我一开始也在这个环节踩了不少坑最早直接把python main.py挂在服务器上结果进程一崩就得手动重启日志散落在各处并发一上来就各种超时。后来陆续试了三种部署方式从最简单的 FastAPI 包装到 Docker 容器化再到 K8s 集群编排每一层解决的都是上一层的痛点。这篇文章就把这三条路径完整拆一遍包括每一步为什么这么做、参数怎么定、以及我实际踩过的坑。如果你已经能用 LangGraph 写出一个能跑的 Agent 图但不知道怎么把它变成稳定的后端服务那这篇内容就是给你准备的。三种路径不是互斥的而是递进关系——你可以根据团队规模、访问量、运维能力选择停在某一层也可以一路走到 K8s。下面按复杂度从低到高逐个展开。2. 路径一FastAPI 包装最快让图变成 HTTP 服务2.1 为什么选 FastAPI 而不是 FlaskLangGraph 本身是 Python 生态的东西包装成 HTTP 服务最直接的选择就是 Python 的 Web 框架。Flask 和 FastAPI 我都用过最后选 FastAPI 的原因很实际LangGraph 的调用天然是异步的ainvoke、astream这些方法都是 async 的FastAPI 原生支持 async 路由而 Flask 要额外折腾。另外 FastAPI 自带 Pydantic 校验和自动生成的 Swagger 文档对于要暴露给前端或其他服务调用的 Agent 接口来说省掉了大量写文档和参数校验的时间。还有一个容易被忽略的点LangGraph 的流式输出streaming在 Agent 场景里几乎是刚需用户希望看到 token 一个个蹦出来而不是等十几秒一次性返回。FastAPI 配合StreamingResponse或者 SSE 做流式非常顺手这一点 Flask 做起来要别扭得多。2.2 项目目录结构怎么组织我见过太多人把所有代码堆在一个main.py里图定义、路由、工具函数全混在一起跑通没问题但一旦要改就痛苦。下面是我现在用的目录结构实测下来扩展性最好langgraph-service/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口注册路由 │ ├── api/ │ │ ├── __init__.py │ │ └── routes.py # 所有 HTTP 路由 │ ├── graph/ │ │ ├── __init__.py │ │ ├── builder.py # LangGraph 图的构建 │ │ ├── nodes.py # 各个节点函数 │ │ └── state.py # State 定义 │ ├── core/ │ │ ├── config.py # 配置管理 │ │ └── logging.py # 日志配置 │ └── schemas/ │ └── request.py # 请求/响应模型 ├── requirements.txt ├── .env └── Dockerfile这个结构的关键在于把「图」和「API」彻底分开。graph/目录里的东西不依赖任何 Web 框架你可以单独写脚本测试它api/目录只负责接收请求、调用图、返回结果。这样以后要换 Web 框架或者要把图复用到别的地方都不用动核心逻辑。2.3 核心代码把图挂到路由上先看图的构建部分这里假设你已经有一个基本的 Agent 图# app/graph/builder.py from langgraph.graph import StateGraph, END from app.graph.state import AgentState from app.graph.nodes import call_model, should_continue, tool_node def build_graph(): workflow StateGraph(AgentState) workflow.add_node(agent, call_model) workflow.add_node(tools, tool_node) workflow.set_entry_point(agent) workflow.add_conditional_edges( agent, should_continue, {continue: tools, end: END} ) workflow.add_edge(tools, agent) return workflow.compile()然后在路由里调用它。这里有个关键决策图实例应该全局只编译一次而不是每次请求都compile()。编译是有开销的每次请求都编译会白白浪费资源。我的做法是在应用启动时编译好挂到app.state上# app/main.py from contextlib import asynccontextmanager from fastapi import FastAPI from app.graph.builder import build_graph from app.api.routes import router asynccontextmanager async def lifespan(app: FastAPI): app.state.graph build_graph() yield app FastAPI(lifespanlifespan) app.include_router(router)路由部分同步调用和流式调用要分开处理# app/api/routes.py from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse from app.schemas.request import ChatRequest router APIRouter() router.post(/chat) async def chat(req: ChatRequest, request: Request): graph request.app.state.graph result await graph.ainvoke( {messages: [(user, req.message)]}, config{configurable: {thread_id: req.session_id}} ) return {reply: result[messages][-1].content} router.post(/chat/stream) async def chat_stream(req: ChatRequest, request: Request): graph request.app.state.graph async def event_generator(): async for chunk in graph.astream( {messages: [(user, req.message)]}, config{configurable: {thread_id: req.session_id}}, stream_modemessages ): if chunk[0].content: yield fdata: {chunk[0].content}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)thread_id这个参数是 LangGraph 做多轮对话记忆的关键同一个thread_id的请求会共享对话历史。生产环境里这个值通常用用户 ID 或者会话 ID千万别用随机数否则每次请求都是新对话。2.4 启动与日志uvicorn 的那些坑启动命令看起来简单uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4但这里有个很多人踩过的坑uvicorn 的--workers和 LangGraph 的内存状态是冲突的。如果你用的是MemorySaver做对话记忆多 worker 意味着每个进程有独立的内存用户请求被负载均衡到不同 worker 时对话历史就丢了。解决办法有两个要么用--workers 1配合异步并发对于 IO 密集的 LLM 调用其实够用要么把记忆存储换成 Redis 或 Postgres 这类外部存储。另一个坑是日志丢失。uvicorn 默认会接管 logging 配置你自己用logging.getLogger()配的 handler 可能不生效。我的做法是在app/core/logging.py里显式配置并且在启动时禁用 uvicorn 的默认配置uvicorn app.main:app --log-config app/core/logging.yaml或者更简单粗暴在代码里logging.basicConfig(levellogging.INFO)之后把 uvicorn 的 logger 也指向同一个 handler。这个细节不处理线上排查问题时会发现关键日志全没了。提示FastAPI 的/chat接口如果直接暴露公网一定要加鉴权和限流。Agent 调用 LLM 是有成本的被人刷接口就是真金白银的损失。3. 路径二Docker 容器化解决环境一致性3.1 为什么 FastAPI 跑通了还要上 DockerFastAPI 直接跑在服务器上短期没问题但很快会遇到几个现实问题。第一是环境依赖你的服务器上 Python 版本、系统库、CUDA 驱动这些和开发机不一致时各种诡异报错。第二是部署流程每次更新代码要手动git pull、pip install、重启进程容易出错。第三是多服务协作Agent 服务往往还要配 Redis、Postgres手动装这些数据库的体验一言难尽。Docker 解决的核心就是「一次构建到处运行」。把 Python 环境、依赖、代码全部打包进镜像服务器上只要有 Dockerdocker run就能起来环境差异被彻底抹平。3.2 Dockerfile 怎么写才不臃肿新手写 Dockerfile 最常见的错误是用python:3.11这种完整镜像构建出来动辄 1.5G。我现在的写法是用 slim 基础镜像加多阶段构建# 构建阶段 FROM python:3.11-slim AS builder WORKDIR /build COPY requirements.txt . RUN pip install --no-cache-dir --user -r requirements.txt # 运行阶段 FROM python:3.11-slim WORKDIR /app # 创建非 root 用户 RUN useradd -m -u 1000 appuser COPY --frombuilder /root/.local /home/appuser/.local COPY --chownappuser:appuser app/ ./app/ USER appuser ENV PATH/home/appuser/.local/bin:$PATH ENV PYTHONUNBUFFERED1 EXPOSE 8000 CMD [uvicorn, app.main:app, --host, 0.0.0.0, --port, 8000]几个关键点解释一下。--no-cache-dir避免 pip 缓存撑大镜像--user装到用户目录方便复制到运行阶段PYTHONUNBUFFERED1让 Python 输出不缓冲这样docker logs能实时看到日志不加这个参数日志会延迟很久才出现非 root 用户运行是安全底线容器逃逸的风险能降低不少。这样构建出来的镜像大概 300-400M比完整镜像小了一大截。3.3 docker-compose 编排 Agent 服务全家桶单个容器跑起来简单但 Agent 服务通常需要 Redis 做会话存储、Postgres 做持久化。用 docker-compose 把这些串起来version: 3.9 services: agent: build: . ports: - 8000:8000 environment: - REDIS_URLredis://redis:6379/0 - DATABASE_URLpostgresql://user:passpostgres:5432/agent depends_on: redis: condition: service_healthy postgres: condition: service_healthy restart: unless-stopped redis: image: redis:7-alpine healthcheck: test: [CMD, redis-cli, ping] interval: 5s timeout: 3s retries: 5 postgres: image: postgres:16-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: agent volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U user] interval: 5s timeout: 3s retries: 5 volumes: pgdata:depends_on配合condition: service_healthy很重要。如果只写depends_onDocker 只保证容器启动顺序不保证服务真的就绪Agent 服务可能在 Redis 还没准备好时就连接失败。加上 healthcheck 才能真正等到依赖可用。3.4 镜像构建和部署的实操命令# 构建镜像 docker build -t langgraph-agent:v1.0 . # 本地测试 docker run -p 8000:8000 --env-file .env langgraph-agent:v1.0 # compose 启动 docker compose up -d # 查看日志 docker compose logs -f agent # 更新服务 docker compose build agent docker compose up -d agent注意.env文件里如果有 API Key千万别COPY . .打进镜像。用.dockerignore排除掉.env、.git、__pycache__这些密钥通过运行时环境变量注入。3.5 Docker Desktop 在 Windows 上的常见问题很多人在 Windows 上用 Docker Desktop 会遇到Virtualization support not detected或者failed to start。这通常是两个原因一是 BIOS 里没开虚拟化Intel VT-x 或 AMD-V二是和 Hyper-V、WSL2 的配置冲突。我的建议是直接用 WSL2 后端在 Docker Desktop 设置里勾选 Use WSL 2 based engine然后在 WSL2 里装 Docker性能和兼容性都比 Hyper-V 后端好。如果docker desktop failed to start because virtualization support not detected先去任务管理器看「虚拟化」那一栏是不是「已启用」没启用就进 BIOS 开。开了还不行检查是不是装了其他虚拟化软件比如某些安卓模拟器占用了 Hyper-V。4. 路径三K8s 编排应对规模化与高可用4.1 什么时候真的需要 K8s先说结论单机 Docker 能扛住的量别上 K8s。K8s 的学习成本和运维复杂度是实打实的如果你的 Agent 服务每天就几百个请求docker-compose 完全够用。真正需要 K8s 的场景是需要多副本横向扩展、需要滚动更新不中断服务、需要自动故障转移、需要精细的资源配额管理。当你的服务从「一个实例」变成「一组实例」时K8s 的价值才体现出来。4.2 K8s 和 Docker 的关系别搞混经常有人问 K8s 和 Docker 的区别。简单说Docker 是「造容器和跑容器」的工具K8s 是「管理一堆容器」的编排系统。K8s 不负责构建镜像它负责决定镜像跑在哪个节点、跑几个副本、挂了怎么重启、流量怎么分发。你可以把 Docker 理解成单个工人K8s 是包工头。现在 K8s 默认用 containerd 作为容器运行时Docker 只是构建镜像的工具这个分工要清楚。4.3 部署清单Deployment 和 Service把 Agent 服务部署到 K8s核心是两个资源对象。Deployment 定义「跑什么、跑几个」Service 定义「怎么访问」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: registry.example.com/langgraph-agent:v1.0 ports: - containerPort: 8000 env: - name: REDIS_URL valueFrom: secretKeyRef: name: agent-secrets key: redis-url resources: requests: memory: 512Mi cpu: 500m 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 --- apiVersion: v1 kind: Service metadata: name: langgraph-agent-svc spec: selector: app: langgraph-agent ports: - port: 80 targetPort: 8000 type: ClusterIPresources里的 requests 和 limits 必须设。requests 是调度依据K8s 根据它决定 Pod 放哪个节点limits 是硬上限超了会被 OOM Kill。Agent 服务因为要加载模型和维持连接内存给足一点我一般 requests 512Mi、limits 1Gi 起步。livenessProbe和readinessProbe的区别要搞清楚liveness 失败会重启 Podreadiness 失败只是把 Pod 从 Service 的负载均衡里摘掉。启动慢的服务initialDelaySeconds要给够否则 Pod 还没起来就被判定失败反复重启。4.4 多副本下的会话一致性前面提过多副本部署时MemorySaver会失效。K8s 里 3 个副本用户请求随机打到哪个 Pod 不确定对话历史必须外置。用 Redis 做 checkpointer 是标准做法from langgraph.checkpoint.redis import RedisSaver checkpointer RedisSaver.from_conn_string(redis://redis:6379) graph workflow.compile(checkpointercheckpointer)这样无论请求打到哪个副本都能从 Redis 读到同一份对话状态。这是从单机到集群必须做的改造不做的话多副本就是灾难。4.5 K8s 集群初始化的坑自己搭 K8s 集群比如用 kubeadm时kubeadm init经常卡在the api server is not healthy after 4m0.00747357s。这个报错信息很模糊实际原因通常是这几种容器运行时没配好、kubelet 没起来、防火墙挡了 6443 端口、或者 swap 没关。排查顺序是先systemctl status kubelet看 kubelet 状态再journalctl -xeu kubelet看详细日志十有八九能定位到具体原因。swap 必须关掉swapoff -a并注释/etc/fstab里的 swap 行这是 K8s 的硬性要求。如果是学习目的我建议直接用 minikube 或者 kind省掉集群初始化的折腾把精力放在理解 Deployment、Service、Ingress 这些概念上。生产环境用云厂商的托管 K8sEKS、ACK、TKE 之类控制面交给他们维护自己只管工作负载。5. 三条路径怎么选一张表说清楚维度FastAPI 直跑Docker ComposeK8s上手难度低中高环境一致性差好好横向扩展手动手动自动高可用无有限强滚动更新手动重启手动自动适用规模开发/小流量中小流量中大规模运维成本低中高我的建议是分阶段演进先用 FastAPI 把服务跑起来验证功能稳定后用 Docker Compose 解决部署和环境问题等访问量真的上来了、或者对可用性有硬要求了再迁移到 K8s。别一上来就 K8s那是给自己找罪受。6. 实操中反复踩的坑与排查技巧6.1 流式输出中断SSE 流式返回时如果中间有反向代理Nginx默认会缓冲响应导致前端收不到实时数据。解决办法是在 Nginx 配置里加proxy_buffering off;和proxy_cache off;并且把proxy_read_timeout调大因为 LLM 生成慢默认 60 秒可能不够。6.2 容器内时区和编码问题slim 镜像默认没有时区数据日志时间戳会是 UTC。需要apt-get install -y tzdata并设置TZAsia/Shanghai。中文乱码通常是 locale 没配加ENV LANGC.UTF-8解决。6.3 健康检查接口别偷懒/health接口不要只返回{status: ok}最好真的检查一下依赖Redis 连不连得上、图能不能编译。否则 Pod 显示健康实际请求全失败K8s 还以为一切正常不会重启。6.4 常见问题速查表现象可能原因排查方向对话历史丢失多副本 MemorySaver换 Redis checkpointer日志不输出Python 缓冲加 PYTHONUNBUFFERED1流式无数据代理缓冲关 proxy_bufferingPod 反复重启探针太严调大 initialDelaySeconds镜像过大用了完整基础镜像换 slim 多阶段构建连接 Redis 失败依赖未就绪加 healthcheck depends_on7. 关于配置和密钥管理的一点经验三种路径都会遇到配置管理的问题。我的原则是代码里不出现任何密钥全部走环境变量。本地开发用.env文件记得加进.gitignoreDocker 用--env-file或 compose 的environmentK8s 用 Secret 对象。K8s 的 Secret 默认只是 base64 编码不是加密敏感信息建议开启 etcd 加密或者用外部密钥管理服务。配置项我习惯用 Pydantic 的BaseSettings统一管理这样类型校验、默认值、环境变量读取一站式搞定比散落各处的os.getenv清爽太多。这个习惯在三种部署路径下都通用值得一开始就养成。最后分享一个我自己的判断标准部署方案的复杂度应该匹配当前的团队运维能力而不是匹配想象中的未来规模。我见过太多项目为了「以后可能要扩展」提前上了 K8s结果团队没人会维护出问题排查半天反而拖慢了迭代。先把服务跑稳等真的遇到瓶颈了再升级这条路走下来最踏实。
返回列表