Day 59 / 共 60 天 · 阶段 10 运行时·生产·收官

Runtime 与部署:把图从「能跑」变成「能上线」

前 58 天我们把图内部拆到了骨头缝:状态、通道、Pregel、持久化、中断、流式。今天换个视角——图运行时,外部世界怎么把「依赖」和「控制信号」递进来Runtime),出错时抛什么异常(errors.py),以及最后一步:怎么用一份 langgraph.json + CLI 把它打包成一个能部署的 API 服务。这是从「Demo」到「生产」的最后一公里。

📍 阶段 10 · 运行时·生产·收官(2 天)你在这里
…D57 流式5模式 D58 流式底层 D59 Runtime·部署 D60 收官·知识地图
💡 用一个类比先兜住今天 想象节点是一个个「工位」。state 是流水线上传递的零件(每步都在变)。但工位还需要一些不变的东西:谁是当前用户、数据库连接、日志句柄、还有一个「厂长喊下班了赶紧收尾」的广播喇叭。这些不该塞进零件里跟着流水线跑——LangGraph 把它们打包成一个 Runtime 对象,单独塞给每个工位。今天先看透这个「工位工具箱」,再看图崩了会抛什么错,最后看怎么把整条流水线装箱发货(部署)。
L01

痛点:user_id、db 连接该放哪?

🤔 痛点我的节点要用 user_id 查数据库、要用一个 http client 调外部 API。这些东西整个 run 里都不变。我该把它们塞进 state 吗?塞进去的话:① 它们会被 checkpoint 存进数据库(db 连接根本没法序列化!);② 它们会出现在每次 stream 的输出里,噪音很大;③ reducer 还得给它们写规则。这明显不对,可又必须让节点能拿到它们。
💡 本质:state 是「会变的数据」,Runtime 是「不变的依赖 + 控制面」LangGraph v0.6 起把这两类东西彻底分开:会随流程演化、需要存档回放的 → 走 state(前面 40 天讲的通道那套);整个 run 固定不变的「运行依赖」(context)、以及框架给你的工具(store、stream_writer、心跳、停机信号)→ 打包成 Runtime不进 checkpoint、不进 state schema,只在运行时注入。类比:零件走流水线,工具箱固定在工位上。

类定义在 runtime.py:124,它是个带泛型的 dataclass

# runtime.py:124
@dataclass(**_DC_KWARGS)
class Runtime(Generic[ContextT]):
    """Convenience class that bundles run-scoped context and other runtime utilities.
    This class is injected into graph nodes and middleware."""

    context: ContextT = field(default=None)         # 你的运行依赖:user_id/db_conn...
    store: BaseStore | None = field(default=None)   # 长期记忆库(D40)
    stream_writer: StreamWriter = field(default=_no_op_stream_writer)  # 自定义流(D57)
    heartbeat: Callable[[], None] = field(default=_no_op_heartbeat)    # 心跳(防 idle 超时)
    previous: Any = field(default=None)             # 函数式 API 上一次返回值(D47)
    execution_info: ExecutionInfo | None = field(default=None)        # 只读元信息
    server_info: ServerInfo | None = field(default=None)              # Platform 注入
    control: RunControl | None = field(default=None)                  # 停机控制面
👶 大白话Generic[ContextT] 就是说「context 的类型由你决定」——你写 Runtime[MyContext],IDE 就知道 runtime.context.user_id 有没有拼错。其余七个字段都是框架预留好的「工具位」,你用不到的就是默认空实现(_no_op_* 是「啥也不干」的占位函数,省得每次判空)。
📝 真实用法节点签名多写一个 runtime 参数,框架会自动填:
def greet(state: State, runtime: Runtime[Context]):
  uid = runtime.context.user_id  # 拿运行依赖
  mem = runtime.store.get(("users",), uid)  # 拿长期记忆
调用时:graph.invoke({}, context=Context(user_id="u_123"))——context 从 invoke 传入,不进 state。
L02

Runtime 的七个字段:各管一摊

逐个看清楚每个字段是干嘛的(字段文档就写在源码里,runtime.py:198-238):

字段类型作用(大白话)
contextContextT你的「运行依赖」:user_id、db_conn、租户 id。整个 run 不变,从 invoke 传入。
storeBaseStore?跨线程的长期记忆库(D40)。可 get/put/search
stream_writerCallablecustom 流写数据(D57),前端能实时收到你写的进度。
heartbeatCallable长任务里手动喊「我还活着」,防止被 idle_timeout 判死(见 L06 超时)。
previousAny函数式 API(@entrypoint)下,本线程上一次的返回值。
execution_infoExecutionInfo?只读元信息:checkpoint_id、task_id、thread_id、第几次重试……
server_infoServerInfo?只有跑在 LangGraph Server 上才有:assistant_id、graph_id、登录用户。
controlRunControl?停机控制面(L05):厂长喊下班的喇叭。

其中 ExecutionInfo 是个只读快照(runtime.py:26)——注意它用 frozen=True, slots=True

# runtime.py:26
@dataclass(frozen=True, slots=True)
class ExecutionInfo:
    """Read-only execution info/metadata for the execution of current thread/run/node."""
    checkpoint_id: str
    checkpoint_ns: str
    task_id: str
    thread_id: str | None = None    # None 表示没配 checkpointer(无持久化)
    run_id: str | None = None
    node_attempt: int = 1           # 当前是第几次重试(1 起)
    node_first_attempt_time: float | None = None
    def patch(self, **overrides: Any) -> ExecutionInfo:
        return replace(self, **overrides)   # 只读→改要生成新对象
frozen=True冻结:字段不可改。它是「元信息快照」,节点只该读不该写,冻结从语言层面杜绝误改。
slots=True__slots__ 省内存、加速属性访问——每个 task 都会造一个,量大,省一点是一点。
node_attempt: int = 1重试次数,1 起步。你可以在节点里 if runtime.execution_info.node_attempt > 1: 打日志
patch(**overrides)冻结对象要「改」只能造新的:replace() 复制一份换掉几个字段。这是不可变数据的标准玩法。
数据结构:Runtime 工具箱(run 级,注入每个节点) Runtime[ContextT] ← 你/调用方提供 context:user_id / db_conn(不进 state) store:长期记忆库(compile 传入) (context 由 invoke(..., context=) 传) → 固定不变,run 全程共享 ← 框架运行时填 stream_writer / heartbeat:流与心跳 execution_info:checkpoint/task/重试 server_info:Platform 才有 control:停机信号(L05)
图注:Runtime 一半由你给(context/store),一半由框架运行时填(execution_info/control 等),合成一个工具箱注入节点。
💡 设计取舍①:为什么不把 context 直接塞进 state,非要多一个 Runtime?朴素做法:把 user_id/db_conn 也当成 state 字段。看着省事,但会踩三个雷:① 持久化崩溃——checkpoint 要序列化整个 state,而 db 连接、http client 根本不可 pickle,一存就炸;② 回放语义错乱——时间旅行(D44)会把「旧的 user_id」也回放出来,但依赖本该是「当下环境」而非「历史数据」;③ 流输出噪音——每次 values 流都带上一坨不变的连接对象。Runtime 把「依赖」和「数据」分家后:state 只留可序列化的业务数据,依赖每次运行重新注入、永不落盘。用一个额外对象,换来了持久化的干净和语义的正确。
L03

get_runtime:节点没写参数也能拿到

上面是「节点声明 runtime 参数」的写法。但有时你在一个被节点调用的普通函数深处想拿 store,总不能一层层往下传参。LangGraph 提供了「随地取用」的入口 runtime.py:296

# runtime.py:296
def get_runtime(context_schema: type[ContextT] | None = None) -> Runtime[ContextT]:
    """Get the runtime for the current graph run."""
    runtime = cast(Runtime[ContextT], get_config()[CONF].get(CONFIG_KEY_RUNTIME))
    return runtime

它靠的是 get_config()config.py:17),这是理解「随地取用」魔法的关键:

# config.py:17
def get_config() -> RunnableConfig:
    if sys.version_info < (3, 11):
        try:
            if asyncio.current_task():
                raise RuntimeError("Python 3.11 or later required to use this in an async context")
        except RuntimeError:
            pass
    if var_config := var_child_runnable_config.get():   # ← 从 contextvar 里取当前 config
        return var_config
    else:
        raise RuntimeError("Called get_config outside of a runnable context")
var_child_runnable_config.get()关键:config 存在一个 contextvar(上下文变量)里。LangChain 在执行节点前把当前 config「设」进这个变量,节点内任意深度的代码都能「取」出来——不用传参。
get_config()[CONF].get(CONFIG_KEY_RUNTIME)Runtime 就藏在 config 的 configurableCONF)字典里,键是 CONFIG_KEY_RUNTIME。get_runtime 只是帮你把它挖出来。
else: raise RuntimeError不在图运行里调 get_config,直接报错——防止你在普通脚本里误用。

同一套机制还派生出两个便捷函数:get_store()config.py:32)和 get_stream_writer()config.py:126),本质都是 get_config()[CONF][CONFIG_KEY_RUNTIME].store / .stream_writer

⚠️ 边界:Python < 3.11 的异步下 get_runtime/get_store 会失效contextvar 在异步任务间的「自动传播」需要 Python 3.11+(asyncio.create_task 才会复制上下文)。所以源码在 get_config 开头专门检查:3.11 以下且在 async 上下文里,直接抛错提示升级。get_store() 的 docstring 也用大字警告了这一点(config.py:53)。解法:要么升级到 3.11+,要么老老实实在节点签名里显式声明 runtime: Runtime[...] 参数(这条路不依赖 contextvar,永远可用)。
💡 设计取舍②:显式参数 vs get_runtime() 全局取,为什么两条路都留?显式参数(节点写 runtime 形参):依赖关系一目了然、可测试性好、不挑 Python 版本,缺点是要一层层传。get_runtime():任意深度随地取用、代码干净,缺点是「隐式依赖」+挑版本(<3.11 async 失效)。LangGraph 两条都给,让你按场景选:节点顶层用显式参数(清晰);工具函数/中间件深处用 get_runtime(省传参)。这是「显式 vs 便利」的经典权衡,框架不替你独断,而是都提供。
L04

Runtime 如何被注入到每个节点

那 Runtime 到底是什么时候「填好塞进 config」的?答案在任务准备阶段(D21 讲过的 prepare_next_tasks),pregel/_algo.py:688

# pregel/_algo.py:688
runtime = cast(Runtime, configurable.get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME))
runtime = runtime.override(                          # ① 在基础 runtime 上叠加本任务的信息
    previous=checkpoint["channel_values"].get(PREVIOUS, None),
    store=store,
    execution_info=ExecutionInfo(                    # ② 现造一份只读元信息
        checkpoint_id=checkpoint["id"],
        checkpoint_ns=task_checkpoint_ns,
        task_id=task_id,
        thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
        run_id=str(rid) if (rid := config.get("run_id")) else None,
    ),
)
# ... 随后 patch_config(..., configurable={ ..., CONFIG_KEY_RUNTIME: runtime })  # ③ 写回 config
configurable.get(..., DEFAULT_RUNTIME)先取「上层传下来」的 runtime(含你给的 context/store);没有就用 DEFAULT_RUNTIME(全空实现,runtime.py:285)兜底。
runtime.override(...)核心:在基础 runtime 上叠加本任务专属的 previous、store、execution_info。override 内部是 replace(self, **overrides)(runtime.py:260),生成新对象不改旧的。
ExecutionInfo(checkpoint_id=..., task_id=...)元信息是每个 task 现算的——所以你在节点里读到的 checkpoint_id/task_id 精确对应「当前这一步」。
CONFIG_KEY_RUNTIME: runtime最后把 runtime 塞回将要传给节点的 config.configurable。于是 L03 的 get_runtime() 就能从 contextvar 里挖到它。闭环。

还有个方法值得一提——多个 Runtime 怎么合并?mergeruntime.py:240)遵循「对方有就用对方、没有就保留自己」的规则,且对 _no_op 占位有专门判断:

# runtime.py:240
def merge(self, other: Runtime[ContextT]) -> Runtime[ContextT]:
    return Runtime(
        context=other.context or self.context,
        stream_writer=other.stream_writer
            if other.stream_writer is not _no_op_stream_writer else self.stream_writer,
        previous=self.previous if other.previous is None else other.previous,
        ... )
👶 大白话为什么 stream_writer 不能简单用 other or self?因为函数对象永远是「真值」(不为 None),or 判不出「这是不是空实现」。所以要显式比 is not _no_op_stream_writer——「对方给的是真 writer 才用对方,否则保留自己」。这就是子图和父图 runtime 合并时的规则:子图没配的,继承父图。
L05

RunControl:SIGTERM 来了怎么优雅收尾

🤔 痛点生产环境滚动发布时,k8s 会给 pod 发 SIGTERM 让它退出。如果我的图正跑到一半直接被 kill,这一步的写可能没落盘,恢复时状态就乱了。能不能「跑完当前超步、存好档、再优雅退出」?

这就是 RunControl 的活儿(runtime.py:79)——一个极简的「停机广播喇叭」:

# runtime.py:79
class RunControl:
    """Run-scoped control surface for cooperative draining."""
    __slots__ = ("_drain_reason",)
    def __init__(self) -> None:
        self._drain_reason: str | None = None
    def request_drain(self, reason: str = "shutdown") -> None:
        self._drain_reason = reason          # ← 一次「原子」写:喊一声「下班」
    @property
    def drain_requested(self) -> bool:
        return self._drain_reason is not None
    @property
    def drain_reason(self) -> str | None:
        return self._drain_reason
request_drain(reason)外部(信号处理器)喊一声「该收尾了」。就是给一个字段赋值,不需要锁——注释解释:单个属性写是原子的,任何线程都能安全调。
drain_requested图的主循环在每个超步边界会检查这个标志。为 True 就不再开新超步,存好档、抛 GraphDrained 退出。
__slots__只有一个字段,用 slots 极致精简。设计上刻意「越简单越可靠」——它是并发敏感的信号,越少可变状态越好。

节点里怎么感知?Runtime 上有两个转发属性(runtime.py:276):

# runtime.py:276
@property
def drain_requested(self) -> bool:
    return self.control.drain_requested if self.control is not None else False

于是长节点可以在循环里 if runtime.drain_requested: 提前保存并返回。而框架层面,收尾会抛出一个专门的异常(errors.py:54):

# errors.py:54
class GraphDrained(GraphBubbleUp):
    """Raised when a graph run exits early due to a drain request.
    This indicates the graph stopped cooperatively at a superstep boundary
    because `RunControl.request_drain()` was called (e.g., in response to SIGTERM).
    The checkpoint is saved and the run can be resumed later."""
💡 设计取舍③:合作式停机(cooperative drain)vs 直接 kill朴素做法:收到 SIGTERM 立刻 sys.exit。快,但当前超步的写可能只做了一半,checkpoint 处于「半更新」状态,恢复时要么丢数据要么重复执行。LangGraph 选合作式:信号只是「设个标志」,真正的退出发生在超步边界——因为 Pregel 的 BSP 模型(D19)保证超步之间状态是一致的、可安全落盘的。代价是 SIGTERM 后不能秒退,要等当前超步跑完(所以 k8s 要给足 terminationGracePeriodSeconds)。用「多等一个超步」换「状态永远一致、可无损恢复」——对有状态的持久执行系统,这笔账非常划算。
L06

错误类型总览:图会抛哪些异常

errors.py(共 241 行)定义了图运行时的全部异常家族。先看一个巧妙的分类基类 errors.py:50

# errors.py:50
class GraphBubbleUp(Exception):
    pass                          # “需要向上冒泡、由框架接住”的控制流异常基类

class GraphDrained(GraphBubbleUp): ...      # 停机(L05)
class GraphInterrupt(GraphBubbleUp): ...    # 中断(D41),子图抛出被根图接住
class ParentCommand(GraphBubbleUp): ...     # Command(graph=PARENT) 跨图跳转(D15)
💡 本质:GraphBubbleUp 是「不是错误的异常」中断、停机、跨图跳转——它们用 raise 实现,但不是 bug,是正常的控制流。给它们一个共同基类 GraphBubbleUp,框架就能用 except GraphBubbleUp 一把接住「这类是控制信号、要特殊处理」,而普通 except Exception(真报错)走另一条重试/上报路径。用异常继承树,把「控制流」和「故障」在类型层面分开。

再看几个「真故障」类的错误,各有讲究:

异常file:line什么时候抛 / 设计点
GraphRecursionErrorerrors.py:67超步数超过 recursion_limit(防死循环,D17)。继承内置 RecursionError
InvalidUpdateErrorerrors.py:90同一超步对一个无 reducer 的通道并发写(D10 讲的冲突)。
NodeTimeoutErrorerrors.py:190节点超过 idle/run 超时。故意不继承内置 TimeoutError(见下)。
NodeCancelledErrorerrors.py:168用户节点自己抛了 asyncio.CancelledError,转成它以走正常故障路径。
EmptyInputErrorerrors.py:136图收到空输入。

还有个面向用户体验的小设计——错误码 + 文档直链(errors.py:34):

# errors.py:34
class ErrorCode(Enum):
    GRAPH_RECURSION_LIMIT = "GRAPH_RECURSION_LIMIT"
    INVALID_CONCURRENT_GRAPH_UPDATE = "INVALID_CONCURRENT_GRAPH_UPDATE"
    ...
def create_error_message(*, message: str, error_code: ErrorCode) -> str:
    return (f"{message}\n"
        "For troubleshooting, visit: https://docs.langchain.com/oss/python/langgraph/"
        f"errors/{error_code.value}")
👶 大白话报错信息里自动拼一条「排障文档链接」,用户看到错直接点进去看解决方案。这是把「常见错误」当产品来做的细节——错误码是枚举,链接是按码拼的,永远对得上。
⚠️ 边界:NodeTimeoutError 为什么故意不继承内置 TimeoutError?源码注释说得很直白(errors.py:190):内置 TimeoutErrorOSError 的子类,而默认 RetryPolicy(D25)会把 OSError 之类当成「网络抖动,不该重试的系统错」而不重试。但节点超时恰恰应该重试(可能只是这次慢了)。如果 NodeTimeoutError 继承了 TimeoutError,就会被重试策略误判为「别重试」。所以它只继承普通 Exception,从而落进「可重试」的默认桶里。一个继承关系的选择,直接决定了超时后会不会自动重试——这就是类型设计的暗雷,改错一行语义全变。
L07

Platform / CLI:一份 json 打包上线 + 今日小结

图写好了,怎么部署成一个带 REST API、持久化、任务队列的服务?LangGraph Platform 的做法是:你只写一份 langgraph.json,CLI 负责把它变成 Docker 镜像。配置 schema 定义在 libs/cli/langgraph_cli/schemas.py:615

# schemas.py:615
class Config(TypedDict, total=False):
    python_version: str          # "3.11"
    dependencies: list[str]      # ["."] 或 ["langchain", "git+https://..."]
    graphs: dict[str, str | GraphDef]  # {"agent": "./agent.py:graph"}
    env: dict[str, str] | str    # {"OPENAI_API_KEY": "..."} 或 ".env"
    store: StoreConfig | None    # 长期记忆 + 向量检索配置
    auth: AuthConfig | None      # 鉴权
    http: HttpConfig | None      # CORS、自定义路由...
    dockerfile_lines: list[str]  # 追加到 Dockerfile 的额外指令

对应的 langgraph.json 长这样(最小可用版):

{
  "dependencies": ["."],
  "graphs": { "agent": "./my_agent.py:graph" },
  "env": ".env"
}
graphs最关键字段:"名字": "文件路径:对象名"。CLI 会 import 这个对象(须是编译好的图/@entrypoint),暴露成一个 assistant。校验逻辑在 config.py:866 一带。
dependencies"." 表示把当前项目当本地包装进镜像;也可列 PyPI 包、git 依赖。
total=False所有字段可选——只 graphs 是必须的(校验时 config.py:462 会检查「至少一个 graph」)。

CLI 命令一览(libs/cli/langgraph_cli/cli.py):

命令line干什么
langgraph devcli.py:763本地开发模式:热重载 + Studio 调试,内存跑,不用 Docker。最常用。
langgraph upcli.py:276本地用 docker-compose 起完整服务(含 Postgres + Redis)。
langgraph buildcli.py:419把项目打成 Docker 镜像,准备推到生产。
langgraph dockerfilecli.py:550只生成 Dockerfile,交给你自己的 CI 构建。
langgraph newcli.py:920从模板脚手架建新项目。
langgraph validatecli.py:873只校验 langgraph.json 合法性(数出几个 graph)。
控制流:从代码到线上服务 你的图 .py + langgraph.json CLI validate 校验 graphs/deps CLI build → Docker 镜像 LangGraph Server REST API+Postgres+队列 本地开发直接 langgraph dev(跳过打包,热重载 + Studio)
图注:Server 在你的图外面包了一层 REST API + Postgres 持久化(D37)+ Store(D40)+ 任务队列,Runtime.server_info 就是它注入的。

👶 那 D33-40 讲的 checkpointer 和这里的 Server 什么关系?

👨‍🏫 Server 就是把你手动配的那套「持久化 + Store」变成托管服务:它自带 Postgres 做 checkpointer、自带 Store、自带线程/run 的 REST 接口。你本地手写 compile(checkpointer=PostgresSaver(...)) 的活,Server 帮你托管了。所以前 40 天不是白学——那是 Server 的地基,理解了地基才知道 Server 在替你干什么。

🧠 今天你应该能回答

  • 为什么 user_id/db 连接要走 Runtime.context 而不是 state?(不可序列化、回放语义、流噪音)
  • Runtime 的七个字段各管什么?(context/store/stream_writer/heartbeat/previous/execution_info/server_info/control)
  • get_runtime() 靠什么随地取用?(contextvar 存 config,节点执行前被 set 进去)
  • 为什么 Python<3.11 async 下 get_store 会失效?(contextvar 跨异步任务传播需 3.11+)
  • Runtime 何时被填好?(_algo.py 任务准备阶段 override 出 execution_info 再塞回 config)
  • RunControl 如何做优雅停机?(设标志→超步边界检查→存档→抛 GraphDrained)
  • NodeTimeoutError 为什么不继承 TimeoutError?(避免被 RetryPolicy 当不可重试的 OSError)
  • langgraph.json 的 graphs 字段是什么?(名字→"文件:对象",CLI import 后暴露成 assistant)

✋ 10 分钟动手

# 1. 通读三个核心文件(都不长)
sed -n '124,160p' libs/langgraph/langgraph/runtime.py    # Runtime 类
sed -n '50,66p'   libs/langgraph/langgraph/errors.py     # GraphBubbleUp 家族
sed -n '615,660p' libs/cli/langgraph_cli/schemas.py      # 部署 Config

# 2. 亲手用 Runtime.context 注入依赖
python - <<'PY'
from dataclasses import dataclass
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.runtime import Runtime
@dataclass
class Ctx: user_id: str
class S(TypedDict, total=False): out: str
def n(state: S, runtime: Runtime[Ctx]):
    return {"out": f"hi {runtime.context.user_id}, attempt={runtime.execution_info}"}
g=StateGraph(S, context_schema=Ctx).add_node(n).add_edge(START,"n").compile()
print(g.invoke({}, context=Ctx(user_id="u_123")))
PY

# 3. 校验一份 langgraph.json(先随便写一个)
# langgraph validate -c langgraph.json
明天预告 · Day 60(收官):60 天走到终点。我们把 10 个阶段串成一张知识大地图,回看从「一个 StateGraph」到「一个可部署 Server」的完整链路;再横向对比 LangGraph 与其它五个 Agent 框架的取舍,最后给一条学习/求职的下一步建议。
← Day 58 流式底层 Day 60 · 收官·知识地图 →