把 LangGraph 的图画完、脚本跑通,其实只完成了整个交付里最轻松的那 20%。真正让人头疼的是后面这 80%:同事和下游系统怎么调它?多个用户同时跑同一个图,状态会不会互相串?进程一重启,对话历史和中间上下文还在不在?这些问题不解决,LangGraph 就永远只是个“开发机上的玩具”。我最早用 LangGraph 做内部自动化工具时就是这样,脚本在本地跑得飞起,一说要部署就卡壳。前前后后试了脚本直跑、官方 LangGraph Server、自己用 FastAPI 封装微服务这三条路,踩了不少坑,也算摸出了一套比较清晰的选型逻辑。这篇就把这三条部署路径完整拆开讲一遍,适合已经把图跑通、正琢磨怎么把 LangGraph 从脚本变成服务的朋友。
1. 部署之前,先把“脚本”和“服务”的差距想清楚
1.1 脚本能跑,不等于业务能用
“脚本能跑”和“服务能用”之间,隔着的东西比大多数人想象的多。脚本阶段你只需要考虑一件事:调用图、拿结果。但一旦把图暴露给外部,变成常驻进程里的 API,你立刻要面对的是另一套问题——请求进来怎么鉴权?同一个用户的多次请求怎么归到同一个会话?进程重启之后用户的上下文还在不在?两个用户同时跑同一个图,会不会发生资源竞争?
举个我实际接过的例子:某团队做了一个设备老化测试的全自动执行脚本,最初用定时任务触发,一台机器跑完接着跑下一台,完全没有并发需求。后来业务方要求在 Web 界面上实时查看每台设备的测试进度,还希望不同测试台位能同时启停,这时候原来那个“跑完就算完”的脚本就彻底不够用了。它需要变成一个有接口、有状态、能并发、可观测的服务。这个从脚本到服务的转变,本质上就是 LangGraph 部署要做的事。
1.2 三条路径的定位差异
经常有人问我:“LangGraph 到底怎么部署?”其实答案不是一个,而是三个。按从轻到重排,分别是脚本直跑、LangGraph Server、自定义微服务。这三条路径不是谁替代谁的关系,而是不同阶段、不同约束下的选择。我在跑通图之后的第一反应是找“官方部署教程”,结果发现官方给的是 LangGraph Server,但它并不是所有场景的最优解。
| 路径 | 形态 | 上手成本 | 功能完整度 | 运维负担 | 典型场景 |
|---|---|---|---|---|---|
| 脚本直跑 | 进程内直接调用 | 最低 | 基础,invoke/stream 都有,状态靠开发自己落盘 | 无 | 开发调试、批处理、定时任务、内部工具 |
| LangGraph Server | REST API 服务 | 中 | 高,内置线程会话、持久化、流式、多图管理 | 中 | 快速上线、需要标准 API、不想重复造轮子 |
| 自定义微服务 | 自封装 HTTP/gRPC 服务 | 高 | 取决于你,从鉴权到调度都可以定制 | 高 | 需要对接企业网关、特殊鉴权、融合现有微服务架构 |
简单说:脚本帮你快速验证业务逻辑,LangGraph Server 帮你省掉大部分工程化体力活,自定义微服务则把控制权完整拿回自己手里。明确自己在哪个阶段,选路径就不会犹豫。
2. 路径一:脚本直跑,开发阶段永远的最优解
2.1 脚本直跑能覆盖哪些场景
脚本直跑最容易被低估,但它其实是三条路里性价比最高的一条。只要你的使用方是“人”而不是“系统”,脚本就足够了。比如:本地调试新增的节点逻辑、跑一批离线数据处理、每天早上用 cron 触发一个 LangGraph agent 做巡检和摘要、给团队内部提供一个命令行工具。这些场景的共同特点是:没有外部系统实时调用,不需要常驻进程,不追求 7x24 高可用。
我在一个定时巡检项目里就是先用脚本跑的。每天早上九点一个 cron 任务拉起 Python 脚本,构建图、注入昨天的设备日志、让 agent 输出诊断建议、把结果写进数据库。整个流程跑了一个多月非常稳,没有引入任何服务化成本。如果你做的事本质上是“定时把事情做完”,脚本直跑就是最合理的部署形态。
2.2 脚本直跑的正确姿势
脚本直跑虽然简单,但有一些姿势必须摆对,否则后面迁到服务时会吃大亏。
第一,环境变量必须走配置文件。不要在源码里写死模型 API key,用 python-dotenv 或者 pydantic-settings 读取.env,这样脚本能跑,将来容器化之后也能平滑切换到容器环境变量。
第二,如果你想在脚本里保留 agent 的“记忆”,一定要显式配置 checkpointer。很多人写脚本时图编译完直接graph.invoke(),感觉“也能跑”,但进程一结束,所有会话状态就全丢了。对单次任务可能无所谓,可一旦要做多轮对话或从断点恢复,就必须把状态存下来。
from langgraph.checkpoint.sqlite import SqliteSaver with SqliteSaver.from_conn_string("memory.db") as saver: app = builder.compile(checkpointer=saver) config = {"configurable": {"thread_id": "batch-01"}} result = app.invoke( {"messages": [{"role": "user", "content": "开始执行今天的巡检任务"}]}, config=config )这里的thread_id相当于给每次会话发了一把钥匙,相同thread_id的调用共享上下文,不同thread_id天然隔离。这个习惯从脚本阶段就养成,后面切换任何服务方案都会顺畅很多。
第三,需要看中间过程时用graph.stream()而不是invoke()。脚本阶段养成用 stream 观察节点执行顺序的习惯,对排查问题非常有帮助。
2.3 脚本直跑的三大天花板
脚本直跑能覆盖的场景很广,但它有绕不开的三个天花板。
第一个是没有网络边界。脚本只能在你那台机器上跑,别的服务通过 HTTP 完全调不到你的图,除非你再手工包一层接口。第二个是状态默认在进程内或本地文件。单实例没问题,一旦要多副本部署做负载均衡,内存里的状态就会散落在不同进程里,用户请求打到不同机器上,上下文就串不起来了。第三个是没有鉴权和限流。脚本谁拿到就能跑,内部开发环境无所谓,但只要涉及业务数据,就必须考虑访问控制和调用配额。
另外还有一点常被忽略:脚本是一次性进程,没法优雅处理长任务。一个 run 跑五分钟,脚本进程就干等五分钟,这在批处理场景下可以接受,但在线场景完全不行。当你发现这三个天花板已经开始卡住你的业务时,就是该往第二条路径迁移的时候了。
3. 路径二:LangGraph Server,用官方方案快速服务化
3.1 官方 Server 帮你解决掉了什么
LangGraph Server 是 LangGraph 官方提供的服务化方案,核心思路是声明式配置加一行命令启动。它把你写好的图自动包装成一套 REST API,并且把服务化最脏最累的活基本包完了。
具体来说,它解决了我在脚本阶段最头疼的四件事。第一是线程机制:服务器端天然管理 thread_id,每个对话一个线程,状态隔离和持久化都不需要自己写。第二是执行生命周期:一次运行从排队、执行到完成、失败,整个生命周期有标准 API 可查。第三是流式输出:官方方案直接支持 SSE 流式返回,前端打字机效果开箱即用。第四是多图服务:你可以在一个 Server 里注册多个 graph,通过不同的名字暴露出来,不用为每个图单独起服务。
说人话就是:你把图画好,HTTP 协议那套、状态存储那套、并发调度那套,官方 Server 基本都替你兜住了。
3.2 本地跑起来的完整步骤
第一步是安装命令行工具。在干净的环境里执行pip install langgraph-cli。这里提醒一句,Windows PowerShell 下如果提示无法将“pip”项识别为 cmdlet、函数、脚本文件或可运行程序的名称,基本就是 Python Scripts 目录没进 PATH,或者装了多个 Python 版本,最快的临时方案是改用python -m pip install langgraph-cli。
第二步是在项目根目录创建langgraph.json配置文件,声明你的图在哪、用什么依赖、环境变量怎么引用。
{ "node_version": "20", "graphs": { "agent": "./src/agent/graph.py:graph" }, "dependencies": ["requirements.txt"], "env": { "OPENAI_API_KEY": "${OPENAI_API_KEY}" } }graphs字段是核心,格式是“服务名: 文件路径: 变量名”,告诉 Server 你的图对象在哪个模块的哪个变量里。env里用${}引用宿主机环境变量,避免把 key 硬编码进配置文件。这里有个细节:不同版本的 langgraph-cli 对node_version和dockerfile_lines字段的处理有差异,建议装完 CLI 后先跑一遍自带的 init 模板,再照着模板改自己的配置。
第三步是启动。开发调试用langgraph dev,它默认起在 8123 端口,支持代码热更新;生产构建用langgraph up,会基于配置构建 Docker 镜像。启动后可以先用 curl 验证接口通不通,整体模型是“先建线程,再在线程上跑运行”:
curl -X POST http://localhost:8123/threads \ -H "Content-Type: application/json" curl -X POST http://localhost:8123/threads/{thread_id}/runs \ -H "Content-Type: application/json" \ -d '{"assistant_id": "agent", "input": {"messages": [{"role": "user", "content": "你好"}]}}'具体字段在不同版本里略有调整,但“thread 管会话、run 管执行”的设计思想是稳定的。把这两个概念吃透,兰图上手会快很多。
3.3 开发模式、自托管和云平台的取舍
langgraph dev启动的是开发模式,功能上不设限,但没有鉴权,只适合本机联调。真正上生产,主流做法是langgraph up构建镜像之后自托管到自己的 Docker 环境或者 K8s 集群。自托管需要自己准备持久化存储,官方推荐生产环境用 Postgres 保存 checkpoint,这样多副本部署时状态才能真正共享。
云平台托管版则是另一个选择。如果团队不想维护任何基础设施,直接用托管服务,把图 push 上去就能获得一个带鉴权和可观测性的生产 API。但托管版有授权和费用上的考量,数据合规要求严格的企业一般还是会选择自托管。我的建议是:先在本机用langgraph dev跑通 API,让业务方确认接口设计没问题,再决定自托管还是用托管版。顺便提一句,启动开发模式时你可能遇到版本新加的 license 校验逻辑,不同版本要求不一样,装完 CLI 先确认一下当前版本对开发模式有没有额外鉴权要求,省得在启动环节卡半天。
3.4 什么时候别选 LangGraph Server
官方方案很省事,但它是一套“标准服务”,不是万能服务。遇到下面几类需求时,我会直接考虑第三条路。
第一,企业里往往有统一的 API 网关和鉴权体系,比如基于 OAuth 的 SSO、自定义签名、多租户权限模型。LangGraph Server 自带的鉴权只管“谁能调用这个 Server”,管不了“这个用户能不能访问这一批数据”,深度集成时你会在网关层做大量适配,甚至觉得官方 API 碍手碍脚。
第二,你的图依赖特殊运行环境。比如模型要内置到同一进程里跑推理,或者需要 GPU 资源,官方 Docker 镜像的构建方式不一定满足你。
第三,你需要的是事件驱动和任务队列。如果业务流程不是简单的请求-响应,而是“消息进来、排队、异步执行、完成后回调”,LangGraph Server 的 run 模型会比较受限,这时候自己封装一层往往更顺手。
4. 路径三:自定义微服务,把控制权握在自己手里
4.1 为什么还需要自己封装一层
既然官方有现成的 Server,为什么还有人愿意自己封装?我总结下来就三个原因:标准之外的需求、现有架构的约束、以及长期成本的控制。
在企业里我经常遇到一个场景:团队已经有成熟的微服务架构,服务之间走公司自研的 RPC 协议,每个服务要接入统一注册中心、统一日志平台、统一灰度发布。这时候你把一个 LangGraph Server 独立丢在旁边,它跟整个微服务架构格格不入。与其让团队为 LangGraph 单独搞一套接入规范,不如直接用 FastAPI 把它封装成一个标准的微服务,融入既有的框架和规范。这其实就是“微服务拆分”思维的延续:技术选型要让位于组织架构和运维体系。
另外,自定义封装在多租户隔离和业务鉴权上有着绝对优势。你可以直接在入口层根据 token 解析出用户身份和权限,再把 thread_id 映射到业务实体上,这在官方 Server 里做起来要别扭很多。
4.2 一个能上线的 FastAPI 封装案例
用 FastAPI 封装 LangGraph 的骨架非常简洁,核心就三步:选一个持久化方案、编译一次图、暴露接口。
# app.py from fastapi import FastAPI from langgraph.checkpoint.sqlite import SqliteSaver from your_graph import build_graph app = FastAPI(title="LangGraph Agent Service") DB_PATH = "runtime/checkpoints.sqlite" def get_agent(): saver = SqliteSaver.from_conn_string(DB_PATH) return build_graph().compile(checkpointer=saver) agent = get_agent() @app.post("/v1/invoke") def invoke(body: dict): thread_id = body.get("thread_id", "default") config = {"configurable": {"thread_id": thread_id}} result = agent.invoke({"messages": body["messages"]}, config=config) return {"thread_id": thread_id, "output": result}注意一个关键点:agent必须在模块加载时编译一次并复用,绝不能放在请求处理函数里每次重新构建。我见过有人图省事这么干,结果并发一上来,每个请求都重新编译图,模型调用频率和内存占用直接爆掉。
如果追求更好的并发性能,可以把 SqliteSaver 换成 Postgres 版本。SQLite 在单机低并发下够用,但多实例部署时多个进程写同一个 SQLite 文件会产生锁竞争,生产环境建议直接用 Postgres 存储 checkpoint,所有实例共享同一份状态。
4.3 流式输出、异步和长任务的追加设计
只做同步的/invoke接口通常不够用。LangGraph 的价值很大一部分在于“边跑边看中间结果”,所以流式接口基本是标配。FastAPI 里用 SSE 很容易实现:
from fastapi.responses import StreamingResponse import json @app.post("/v1/stream") def stream(body: dict): config = {"configurable": {"thread_id": body.get("thread_id", "default")}} def gen(): for event in agent.stream({"messages": body["messages"]}, config=config): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" return StreamingResponse(gen(), media_type="text/event-stream")前端拿到这个 SSE 流,就可以像 ChatGPT 那样逐字输出。这里有一个很隐蔽的坑:如果你们前面还有一层 Nginx 或 API 网关,一定要确认它没有开启响应缓冲,否则流式接口会被网关攒住,用户看到的就是“一直转圈然后一次性吐出一大段”。
再往深一层说,如果单个 run 的执行时间超过了外部网关的超时限制,比如网关固定 60 秒超时,agent 一跑就是三分钟,那同步接口根本扛不住。这时候就得引入任务队列了。我常用的模式是:接口收到请求后丢进 RabbitMQ 或者 Celery,立刻返回一个 task_id,后台 worker 执行 LangGraph run,前端轮询 task 状态,跑完后按需取结果或由回调推送。用 LangGraph 做真正的业务 agent,这几乎是绕不开的一步。
4.4 自封装要付出的代价
上面这些设计都是可控的,但必须承认,自封装是一条“收获大、账单一目了然”的路。你自己要对鉴权、限流、日志、监控、健康检查、优雅停机、配置管理负责。写服务的人往往只把心思放在“把图包上 HTTP”这一步,结果上线第一周就被各种问题教做人:内存泄漏、线程安全、孤儿进程、依赖冲突。
我的经验是:如果团队没有专职 SRE,或者项目周期很紧,先别急着自封装。如果必须自封装,优先把两件事做掉——第一是结构化日志,每个请求都要能跟踪 request_id、thread_id、run_id,方便出问题时回溯;第二是健康检查接口,比如/health返回当前进程的存活状态和关键依赖的连通性。这两件事不做,后面排查问题的成本会让你后悔当初的“图省事”。
5. 三条路径怎么选,我的实战决策逻辑
5.1 一张决策表
说了这么多,落地的时候到底怎么选?我给自己总结了一张决策表,每次做技术方案时直接对照:
| 你的现状 | 推荐路径 | 核心理由 |
|---|---|---|
| 还在验证业务逻辑,单人单机 | 脚本直跑 | 零改造成本,迭代最快,别过早抽象 |
| 需要尽快给业务方一个可调用的 API | LangGraph Server | 官方一站,状态持久化和流式都现成 |
| 团队已有微服务架构,要对齐规范 | 自定义微服务 | 能融入网关、鉴权、注册中心 |
| 流量不大但要求稳定高可用 | LangGraph Server 自托管 | 运维成本可控,官方功能全 |
| 流量大、任务长、调度复杂 | 自定义微服务加消息队列 | 定制能力和扩展性最强 |
这张表是我在几个项目里反复调整后定下来的,核心逻辑是:先算总拥有成本,再谈技术优劣。官方方案虽然省代码,但如果它和你现有架构的集成成本很高,那它反而是贵的。
5.2 从脚本迁到服务,我踩过的三个坑
从脚本直跑切到服务化是我跌跟头最多的一段路,复盘下来有三个坑几乎每个项目都会踩到。
第一个坑是Graph 对象重复初始化。脚本阶段每次跑完进程就退出,重建 graph 没有代价。但服务是常驻进程,如果在每个请求里都重新build_graph().compile(),等于每次请求都把整个图的定义重新解析一遍,还要加载模型配置,性能和内存都是灾难。正确做法是模块加载时编译并缓存,进程内无状态复用。
第二个坑是状态没落盘。脚本阶段用内存 checkpointer 省事,迁到服务后依然这么干,结果服务一重启,所有用户的对话上下文全丢。排查了半天最后发现编译时压根没传 checkpointer,或者传了一个:memory:的实例。这个坑特别隐蔽,因为服务不重启时一切正常,一重启就露馅。
第三个坑是环境变量没传进容器。本地脚本读.env没问题,用langgraph up或者 Docker 打包后,容器里的进程根本读不到宿主机上的.env。最典型的症状是服务启动一切正常,一调用就报鉴权失败或模型 401。排查思路也很简单:先进容器里env看环境变量在不在,再检查代码里读 key 的方式。
5.3 我的建议排序
如果是小团队或者刚起步的项目,我的排序非常明确:先脚本,再 LangGraph Server,最后才是自封装。不要一上来就自己造轮子,部署层面的过度设计是很多项目延期和返工的头号原因。
我见过太多团队把大量时间花在“设计一个完美的微服务架构”上,结果业务逻辑本身还没验证。正确的节奏应该是:第一天用脚本把 agent 的业务跑通,跑通之后立刻用 LangGraph Server 快速暴露 API 给业务方,等真的遇到官方方案覆盖不了的场景,再启动自封装。这样每一步的投入都对应明确的收益,不会在部署上无限内耗。
6. 部署过程常见问题速查
6.1 环境启动类问题
环境问题占了部署初期的低头大多数。最经典的当然是 pip 相关的问题。Windows 下pip命令不被识别,报无法将“pip”项识别为 cmdlet、函数、脚本文件或可运行程序的名称,这几乎每天都有新手来问。原因无非两个:Python 的 Scripts 目录没有加进 PATH,或者系统里装了多个 Python 版本,pip 装到了另一个版本之下。最快的临时方案是用python -m pip install xxx,一劳永逸的方案是把正确的 Scripts 目录加进用户 PATH 并重启终端。
另一个高频问题是版本不一致。langgraph 生态由主库、cli、checkpoint 插件等多个包组成,如果主库和 CLI 版本跨度很大,Server 启动后功能会表现得非常诡异。经验做法是装完所有依赖后跑一下pip freeze | grep langgraph,然后手动把相关包钉到一个经测试的版本组合上。
端口占用也值得提一句。langgraph dev默认端口是 8123,本地同时调试多个服务时很容易撞。用--port指定端口,或者先查占用进程再处理,都能快速解决。
6.2 服务运行类问题
服务一旦跑起来,问题就从“起不起来”升级成“能不能稳定地做正确的事”。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 调用就超时 | 上游模型服务网络不通 | 在容器内直接 curl 模型 API,检查网络白名单和代理配置 |
| 响应正常但上下文不连续 | 没配置 checkpointer,或 thread_id 在多个请求间变化 | 确认编译时传了持久化 checkpointer,在日志里跟踪 thread_id |
| 流式接口一直不输出 | SSE 被网关或代理缓冲了 | 关闭网关的 response buffering,或改用 WebSocket |
| 多个用户的数据串了 | config 里 thread_id 写死成了同一个值 | 每个请求从参数或用户 token 里动态解析 thread_id |
这些问题的共性是:看起来是 LangGraph 的问题,实际上是你自己的工程配置问题。排查时不要急于改代码,先看日志,再查配置,最后才动业务逻辑。
6.3 服务生命周期的运维习惯
从脚本变成服务之后,“进程活着”只是起点,不是终点。很多从 Windows 环境开始接触服务管理的人,习惯把所有服务都想象成 services.msc 里的那种“系统服务”形态——能添加删除、能设置启动模式。这个思维是对的,只是 Linux 和容器环境里的对应工具不一样。
Linux 环境部署自封装 FastAPI 服务时,我推荐直接用 systemd 托管,配好开机自启和崩溃重启:
[Unit] Description=langgraph-agent-service After=network.target [Service] User=demo WorkingDirectory=/opt/agent ExecStart=/opt/agent/.venv/bin/uvicorn app:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=5 EnvironmentFile=/etc/agent.env [Install] WantedBy=multi-user.target如果你用 Docker 部署,则用容器重启策略来兜底,比如--restart unless-stopped,再配一个进程级健康检查。这个习惯太容易被忽略了。我接手过不少“服务偶发挂掉”的问题,最后发现根源就是启动脚本里没有配Restart=always,进程一崩,整个服务就静默宕机,直到有人发现才手动拉起来。服务化之后,你至少要让进程具备自愈能力,这是和脚本阶段最大的区别之一。
6.4 上线前的自查清单
最后整理一份我自己每次上线 LangGraph 服务前都会过一遍的清单。先从业务层面确认:checkpointer 是否使用了独立存储,多实例部署时用的是数据库共享状态还是进程内状态;会话隔离是否验证过,两个不同 thread_id 并行跑同一个图时数据互不干扰。再从运维层面确认:结构化日志里有没有带上 thread_id、request_id,出问题时能不能快速定位到具体节点;健康检查接口有没有配好,监控和告警能不能覆盖到。最后从安全层面确认:模型 API key 是通过运行环境注入的,而不是直接写死在镜像层里,否则docker history一查就能翻出所有人的密钥;入口处有没有基本的限流和鉴权,哪怕是团队内部使用也值得加一层保险。
这个清单看起来都是小事,但它们决定了你的 LangGraph 服务是从“能跑”变成“能用”,还是从“能跑”变成“时常出事”。我见过太多项目在部署后的一两个月里被这些看似琐碎的问题反复折磨,提前把清单过一遍,能省掉后面的大量返工。
写到这里,我最大的体会是:LangGraph 部署这件事,难的从来不是 LangGraph 本身,而是你愿不愿意把“脚本思维”切换成“服务思维”。我在实际项目里反复建议团队:第一版就用脚本直跑,把业务逻辑彻底验证完;第二版切到 LangGraph Server,用一个标准 API 尽快给到业务方;等确实碰到官方方案覆盖不了的需求,再动手自封装微服务。最后分享一个小技巧:无论走哪条路径,一定在图的所有节点都打上日志,并且给每一次运行统一标注 thread_id 和 run_id。否则一旦出了事,你连“它到底执行到哪个节点”都说不清楚。这条经验帮我省下的排查时间,远远超过当初写日志花掉的那点功夫。