用户先告诉出行助手:“我想坐靠窗的位置。”接着问:“我刚才说想坐哪里?”
要回答第二个问题,助手必须拿到前一轮的消息。我们可以自己保存聊天记录,每次调用模型时取出历史消息一起发送。
接入 LangGraph 的 Checkpointer 后,如果把消息保存在图的状态中,图运行时就能在第一轮结束后保留这些消息,并在第二轮开始时读回来。这样,每轮请求只需提交新消息,处理逻辑就能接着上一轮的状态继续工作,不必再自行查询和回填历史。调用模型的节点再把状态中的消息交给模型,模型才有依据回答“你刚才说想坐靠窗的位置”。
在 LangGraph1 中,thread_id 告诉运行时应该读取哪条状态历史。因此,一段对话对应一份持续演进的图状态时,可以直接让 thread_id 等于业务的 conversation_id。
接着设想第二轮遇到网络超时,客户端重新提交了一遍;稍后用户又主动点击“重新生成”。这两次操作都沿用原对话,但前者通常不应重复执行,后者恰恰要求重新执行。只靠一个对话 ID,系统无法区分这两种意图。
要设计这些 ID,先要解释 LangGraph 用什么恢复状态,再决定业务中哪些东西算“同一个对象”、哪些算“另一次尝试”。
同一份图,为什么还需要另一个 ID
图定义描述如何处理输入。例如 START → reply → END 表示收到输入后执行 reply 节点,再结束本轮。服务器可以让许多对话共用这份图定义,但每段对话需要自己的消息和中间结果。仅知道“调用了哪张图”,无法判断该加载哪段历史。
thread_id 就是这条状态历史的标识。它不是操作系统线程,也不要求一次 HTTP 连接持续存在。同一个 thread 可以接收多轮调用;一轮处理结束后,下一轮仍然使用原来的 ID。
假设第一轮以 demo-chat-a 保存了靠窗偏好,第二轮继续使用这个 ID,运行时便能读取该 thread 的状态。如果第二轮改成 demo-chat-b,它查找的是另一条历史。即使请求来自同一个浏览器,也不会自动继承 A 的状态。
一次普通的新消息调用可以按下面的顺序理解。图中的存取是逻辑顺序,具体写入时机还受持久化模式影响。
查看 Mermaid 源码
sequenceDiagram
participant API as 业务接口
participant Graph as 图运行时
participant Saver as Checkpointer
participant Node as reply 节点
API->>Graph: 新消息 + thread_id
Graph->>Saver: 读取该 thread 的最新检查点
Saver-->>Graph: 已保存状态,或没有历史
Note over Graph: 按 reducer 合并新输入
Graph->>Saver: 保存输入阶段检查点
Graph->>Node: 传入合并后的 State
Node-->>Graph: 返回状态更新
Note over Graph: 按 reducer 应用节点更新
Graph->>Saver: 保存执行后的检查点
Graph-->>API: 返回本轮结果这条链路也解释了为什么 thread_id 要放在运行配置里:加载状态发生在业务节点读取 State 之前。只把对话 ID 塞进消息正文或某个 State 字段,不能替代 Checkpointer 所需的配置。官方线程说明
先观察状态,再接入模型
要验证状态是否接上,不必先引入模型的随机输出。下面的 reply 是一个明确的规则节点:扫描当前 State 中的用户消息,提取最后一次座位偏好,再返回一条答复。它没有模型调用、网络请求或业务写入;这里验证的是实际 LangGraph 状态机制。
MessagesState 定义消息字段,并使用 add_messages 合并更新:新消息 ID 追加,已有 ID 的消息更新原位置。reply 只返回本次新增答复;运行时负责把它合并回 State。普通字段如果没有配置 reducer,更新通常覆盖旧值,Checkpointer 不会替字段决定合并规则。官方 State 与消息说明
本地验证环境为 Python 3.14.6、langgraph==1.0.5、langgraph-checkpoint==3.0.1、langchain-core==1.6.2,验证日期为 2026-09-08。示例不依赖顶层 langchain 包。以下固定版本用于复现实验,不代表最新版推荐。
在本地终端创建独立环境,然后将下方完整代码保存为 checkpoint_demo.py。它依赖本地 LangGraph,博客页面只提供高亮和复制。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install "langgraph==1.0.5" "langgraph-checkpoint==3.0.1" "langchain-core==1.6.2"
python checkpoint_demo.pyfrom langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph
def reply(state: MessagesState):
preference = None
for message in state["messages"]:
if message.type == "human":
text = str(message.content)
if text.startswith("座位偏好:"):
preference = text.removeprefix("座位偏好:")
answer = f"已记录:{preference}" if preference else "尚未记录座位偏好"
return {"messages": [{"role": "assistant", "content": answer}]}
builder = StateGraph(MessagesState)
builder.add_node("reply", reply)
builder.add_edge(START, "reply")
builder.add_edge("reply", END)
saver = InMemorySaver()
graph = builder.compile(checkpointer=saver)
config = {"configurable": {"thread_id": "demo-chat-a"}}
first = graph.invoke(
{"messages": [{"role": "user", "content": "座位偏好:靠窗"}]},
config=config,
)
question = {
"messages": [{"role": "user", "content": "我的座位偏好是什么?"}]
}
second = graph.invoke(question, config=config)
print("第一轮:", first["messages"][-1].content)
print("同一 thread:", second["messages"][-1].content)
print("消息数量:", len(second["messages"]))
print("下一步:", graph.get_state(config).next)
other = graph.invoke(
question,
config={"configurable": {"thread_id": "demo-chat-b"}},
)
print("换 thread:", other["messages"][-1].content)
fresh_graph = builder.compile(checkpointer=InMemorySaver())
fresh = fresh_graph.invoke(question, config=config)
print("换内存存储:", fresh["messages"][-1].content)
history = list(graph.get_state_history(config))
print("A 的检查点数:", len(history))
assert len(second["messages"]) == 4
assert second["messages"][-1].content == "已记录:靠窗"
assert other["messages"][-1].content == "尚未记录座位偏好"
assert fresh["messages"][-1].content == "尚未记录座位偏好"上述版本的实际输出为:
第一轮: 已记录:靠窗
同一 thread: 已记录:靠窗
消息数量: 4
下一步: ()
换 thread: 尚未记录座位偏好
换内存存储: 尚未记录座位偏好
A 的检查点数: 6第二轮传入的只有一条问题,但节点最终处理了两轮累积的消息。四条消息分别是第一次输入、第一次答复、第二次输入和第二次答复。这里没有把 first 手动交给第二次调用,跨调用的数据来自 Checkpointer。
如果将规则节点替换为模型节点,仍然需要把 state["messages"] 传给模型。状态已保存,不等于模型已看到状态。 模型节点若只发送最后一条问题,历史即使存在,也没有参与本轮生成。官方多轮模型调用示例
实验还留下一个问题:只有两轮、四条消息,为什么出现了六个检查点?
检查点记录了哪些执行事实
六个检查点说明,保存粒度不是“一条用户消息一个记录”。在这个单节点实验中,每轮都有输入准备、输入应用和节点完成等状态边界。可以把下面代码追加到同一个脚本末尾,按从旧到新的顺序观察它们:
for snapshot in reversed(history):
print(len(snapshot.values.get("messages", [])), snapshot.next)上述版本的输出为:
0 ('__start__',)
1 ('reply',)
2 ()
2 ('__start__',)
3 ('reply',)
4 ()第二轮开始时仍有两条旧消息;应用新输入后变成三条,reply 返回后变成四条。next=() 表示当前没有待执行节点,并不表示整个 thread 被删除。下一轮可以继续向这个 thread 提交输入。
如果只存最终答复,运行时无法判断“输入已经接收,但答复节点尚未完成”。Checkpoint(检查点)保留状态和调度所需信息,运行时据此构造 StateSnapshot,供应用读取 values、next、任务与中断等信息。它不是 Python 调用栈或进程内存的整体快照。
官方将检查点边界描述为 super-step:同一步内的节点可以并行执行,串行节点分属不同步。Checkpointer 的 get_tuple 读取检查点,put 保存检查点,put_writes 保存任务中间写入,list 查询历史;具体实现负责序列化与后端存储。并行步骤中已完成任务的中间写入,可用于恢复时避免重新执行成功任务。官方 Checkpointer 接口与机制
需要区分逻辑检查点与物理落盘。InMemorySaver 的检查点存在 RAM 中,更换 saver 或重启进程都会失去旧内存;多个 worker 各自创建 saver 也不会自动共享数据。跨进程继续需要共享的持久化后端。数据库后端的实际持久性还与写入模式、存储可靠性有关,存在一个状态边界,并不意味着任意时刻强制终止进程都能无损恢复。
从 State 往下看:运行时保存的是数据和调度依据
业务节点看见的是 state["messages"],但运行时还需要知道哪一个节点该执行、哪些更新已经消费。设想消息值已经落盘,却丢掉“这条输入尚未被 reply 处理”的信息,恢复时就可能漏掉答复;如果忘了已经处理过,又可能重复调用。
LangGraph 的 Pregel 运行时把执行拆成计划、执行和更新三个阶段。计划阶段选择被触发的节点;执行阶段运行本步任务;更新阶段将节点返回的写入应用到通道(channel)。同一 super-step 内,节点不能通过通道读到其他节点刚返回的新值,要到下一步才可见。因此,节点返回更新与其他节点读取新 State 之间,有一个明确的步骤边界。官方运行时模型
编译 StateGraph 时,业务字段和控制流被连接到通道与任务。通道同时保存数据并定义更新规则;节点的触发也可能来自内部调度通道,而不只是消息字段。下面是本例所用检查点包的真实字段,省略了值,避免把内部版本字符串误当成业务 API:
| 原始 Checkpoint 字段 | 运行时为什么需要它 |
|---|---|
channel_values | 重建各通道的值,包含业务数据,也可能包含调度信息 |
channel_versions | 记录通道更新到哪个版本,判断数据是否变化 |
versions_seen | 记录节点已经消费过哪些通道版本,用于选择后续任务 |
updated_channels | 标记本检查点更新过的通道,辅助调度 |
id、ts、v | 标识检查点、记录时间和检查点格式版本 |
v 是检查点序列化格式版本,不是部署的业务图版本。channel_versions 也是运行时内部版本,不能拿来当用户消息序号。一个很具体的源码观察是:本例结束时可能只有消息通道仍保有值,而 reply 执行前的检查点还含有内部调度通道;通道内容会随执行位置变化。
保留上一节的 saver,可以直接读取反序列化后的检查点。以下代码接在两轮示例后执行:
saved = saver.get_tuple(config)
assert saved is not None
print(sorted(saved.checkpoint))
print("messages" in saved.checkpoint["channel_values"])
print("reply" in saved.checkpoint["versions_seen"])实测输出为:
['channel_values', 'channel_versions', 'id', 'ts', 'updated_channels', 'v', 'versions_seen']
True
True恢复时,SyncPregelLoop.__enter__ 先调用 saver 的 get_tuple,再用保存的通道值重建通道对象;prepare_next_tasks 结合触发通道、版本信息和待处理写入准备任务;apply_writes 应用节点输出并推进通道版本。随后 _put_checkpoint 形成下一份检查点。这条调用链来自固定版本源码,而不是要求应用去调用这些内部函数。运行循环源码、任务调度源码
由此可以区分三个对象:Checkpoint 保存运行事实,Checkpointer 存取这些事实,图运行时解释事实并继续执行。 StateSnapshot.next 是运行时给应用的可读视图,不能简单理解成底层只保存了一个 next_node 字符串。恢复也依赖当前可用的图定义和状态 schema,检查点不会把旧版业务代码一起封存。长时间暂停的流程需要另行设计图版本兼容或部署路由。
thread_id 在底层到底参与了哪次查找
知道恢复需要哪些数据后,才能准确解释 thread_id:它负责选中要恢复的状态集合。以本例的 InMemorySaver 为准,检查点存储按 thread_id → checkpoint_ns → checkpoint_id 组织;未指定 checkpoint 时,在对应集合内选择最新的一份。通道值还会按通道版本单独存放,读取时再组装,物理存储并不等于“每个 ID 对应一整份 JSON”。固定版本内存实现
这几个键分别缩小查找范围:thread 选状态历史,namespace 区分根图与子图执行空间,checkpoint 选某个状态版本。父检查点关系连接历史版本;从旧版本重放可以生成分支,所以一个 thread 的历史也不必永远是一条没有分叉的链。
对上一节两轮实验,我们补做了以下实际对照,完整入口见文末实验索引:
| 只改变哪一项 | 实测结果 | 业务含义 |
|---|---|---|
配置顶层 run_id | get_state 仍读取 A 原来的检查点 | 跟踪执行的 ID 不负责选择状态 |
自定义配置 tenant_id | 该 saver 仍读取 A 原来的检查点 | 多传租户字段不会自动建立存储隔离 |
thread_id 改成 B | 读取另一条历史 | thread 是状态查找范围的一部分 |
| 显式选择 A 中 reply 之前的 checkpoint,再重放 | 留在 A 内生成新检查点,旧检查点不变 | 选择历史版本与创建新 thread 是两件事 |
第二行尤其影响业务设计。假设两个租户都产生了局部对话编号 42,把 tenant_id 放进普通配置并继续使用 thread_id="42",并不会让这个 saver 自动按租户分区。可选方案是使用全局唯一的 thread,或由服务端做可靠的命名空间映射,或隔离存储。无论选哪一种,访问前仍要验证当前主体对该对话的权限。
同理,graph_id、模型名称和普通 metadata 都不能被假定为检查点键的一部分。两个不兼容的根图若共享这个 saver,并复用相同 thread 与根 namespace,可能读到彼此的状态。checkpoint_ns 服务图内部执行结构,建议让运行时管理,不把它改造成租户权限字段。
为什么还要保存 task_id:一次并行失败的恢复
只保存步骤结束时的快照,还存在一个空隙。出行助手在同一步并行查询座位和票价:座位查询已经成功并写入结果,票价查询随后超时。整个步骤没有成功完成,但重新执行座位查询可能浪费资源,甚至重复产生副作用。
Checkpointer 的中间写入记录填补这个空隙。本例内存实现先按 (thread_id, checkpoint_ns, checkpoint_id) 找到这一步,再按 task_id 和写入索引区分任务输出。任务 ID 让运行时能将恢复后的任务与已经保存的写入对应起来。它标识内部任务,不等于业务的“预订任务编号”,应用不应自行替换它的生成算法。
实验将 lookup_seat 与 lookup_fare 放进同一个 super-step,并在确认座位中间写入已保存后,让票价节点抛出一次模拟超时。然后执行 graph.invoke(None, config=config, durability="sync") 恢复。这里用同步信号固定实验顺序;两个节点没有调用网络服务,也没有真实预订动作。
模拟票价查询超时
失败后保存了 seat 中间写入: True
恢复后状态: {'seat': '靠窗', 'fare': 100}
节点执行次数: {'lookup_seat': 1, 'lookup_fare': 2}票价节点执行两次,座位节点只执行一次,说明恢复确实使用了保存的成功任务写入。这个结论有条件:成功写入必须已经被保存。若外部服务已完成预订、节点却在保存输出前崩溃,运行时仍可能再次执行该节点;因此还需要外部系统理解的业务幂等键。
durability="sync" 会在继续下一步前等待检查点保存,"async" 可以让写入与后续步骤重叠,"exit" 主要在执行退出时保存。模式选择改变恢复窗口和开销,不会把内存 saver 变成磁盘数据库,也不会让外部预订与检查点写入自动成为同一个事务。官方持久化模式
人工中断使用同一套持久化基础,但恢复输入不同:应携带原 thread,传入 Command(resume=...)。中断节点会从开头重新执行,不能把它理解为恢复任意 Python 指令位置;中断前的业务副作用同样需要防止重复。官方中断语义
把 thread 放回业务:它是工作流实例的状态身份
现在可以从业务角度重新命名它:thread_id 表示一份需要独立继续、恢复和管理的图状态。 可以将它理解为工作流实例的状态身份。用户是谁、页面上是哪段对话、这次要执行哪个业务动作,则是另外的问题。
对于只聊天的出行助手,一段对话从第一句到最后一句始终使用同一份图状态,conversation_id = thread_id 很自然。相同值表达一个明确的一对一约束,不需要为了“分层”再生成第二个没有独立用途的 UUID。但如果后来要重建图状态而保留聊天记录,就要承认二者生命周期已经分开。
设想同一聊天窗口同时发起“去程查询”和“返程查询”,两条查询可独立暂停、取消和重试。我会把它们建模为两个业务工作流实例,分别关联自己的 thread;对话只是展示与交互的入口。下面是这种业务建模选择,箭头表示关联关系,不是 LangGraph 强制的表结构:
查看 Mermaid 源码
flowchart TD
user[用户 user_id] -->|拥有| conversation[对话 conversation_id]
conversation -->|发起| outbound[去程工作流 workflow_id A]
conversation -->|发起| inbound[返程工作流 workflow_id B]
outbound -->|绑定| threadA[状态 thread_id A]
inbound -->|绑定| threadB[状态 thread_id B]
threadA -->|累积| checkpointsA[多个 checkpoint_id]
threadB -->|累积| checkpointsB[多个 checkpoint_id]业务 workflow_id 是否需要与 thread 再分开,取决于是否允许同一个业务工作流迁移、重建或拥有多份执行状态。始终一对一时可以复用值,甚至省掉独立 workflow 表;需要保存多次状态重建记录时,再建立映射。类似地,跨对话共享偏好应该按用户从 Store 或业务数据库读取,而不是把该用户的所有对话强行合到一个 thread。
判断是否拆 ID,可以先问两个问题:其中一个对象消失或重建时,另一个是否应继续存在?其中一个对象是否可能关联多个另一个对象? 如果答案都是“否”,复用值通常可行;只要出现独立生命周期或一对多关系,就有了分开的具体理由。
一次超时重试,逼出了哪些 ID
返回开篇的第二轮问题。服务端可能已经生成了答案,只是 HTTP 响应在途中丢失。客户端重发时,希望查到原来的处理结果;而点击“重新生成”时,希望启动一轮新的处理。两次请求的正文可以完全相同,因此不能只按消息文本做去重。
下面采用一套明确的业务约定:command_id 表示一次被用户确认的逻辑操作,run_id 表示后端为它创建的一次执行尝试,request_id 表示一趟 HTTP 请求。名称可以换成 submission_id、job_id 或 attempt_id,但生命周期应先写清楚。下表中 C、T、M、K、R、Q 都是阅读用缩写,不是实际 ID 格式:
| 事件 | 不变的业务身份 | 新产生的身份 | 服务端应做什么 |
|---|---|---|---|
| 首次提交第二轮 | 对话 C、thread T | 消息 M、命令 K1、请求 Q1、运行 R1 | 接受命令并安排执行 |
| Q1 超时,原样重试 | C、T、M、K1,以及已有 R1 | 请求 Q2 | 命中 K1,返回已有运行或结果 |
| 用户主动重新生成 | C、T、原始用户消息 M | 命令 K2、请求 Q3、运行 R2 | 明确接受一次新生成 |
| 执行器为 K2 再尝试一次 | C、T、M、K2 | 运行尝试 R3 | 按约定恢复,保留尝试关系 |
| 将历史分支保存成独立对话 | 所选历史作为来源 | 新对话 C2、新 thread T2 | 显式复制或初始化所需状态 |
这张表区分了“传输重试”和“业务重做”。重发 Q1 不应自动创建 R2;如果 R1 仍在运行,应返回处理中。如果用户真的要求再生成,则必须允许新的 K2。后端内部重试是沿用 run 还是新建 attempt 也可以有不同约定;本文选择新建运行尝试,不能把这个约定当成所有平台的默认行为。
“重新生成”还需要选择输入状态。例如,从上一条答复之前的 checkpoint 重放,或者按业务消息重新组织输入。只更换 run_id 不会自动退回历史;重发一条无 ID 的相同消息还可能追加重复消息。上一节实测的重放留在原 thread 内生成了新分支;要成为独立对话,必须显式创建新 thread 并迁移所需状态,换个 ID 本身不会复制历史。
这些 ID 回答的问题也可以按责任分组:
| 业务身份 | 回答什么 | 谁通常创建 |
|---|---|---|
tenant_id / user_id | 哪个组织、哪个主体拥有数据 | 组织与身份系统 |
conversation_id | 界面上是哪段对话 | 业务后端在创建对话时 |
message_id | 哪一条可编辑、引用的消息 | 客户端或后端在消息首次创建时 |
command_id / 幂等键 | 是否仍是同一次提交意图 | 首次提交前生成,重试沿用 |
operation_id | 哪一次外部业务动作,例如一次预订 | 业务层在执行副作用前持久化 |
operation_id 不是每个聊天应用都需要。只有一次外部动作时,可以与命令 ID 复用;一个命令同时创建去程与返程预订时,两次预订需要各自的操作身份。用 thread 当预订幂等键会把整段对话中不同预订误判成重复,用 request 当幂等键则挡不住网络重试。
| 执行与诊断身份 | 回答什么 | 谁通常创建 |
|---|---|---|
thread_id | 恢复哪份图状态 | 本地库集成由应用分配,服务模式按其 API 创建 |
checkpoint_id / 内部 task_id | 哪个状态版本、哪项运行时任务 | LangGraph 运行时管理 |
run_id / attempt_id | 哪一次执行尝试 | 执行器或运行平台 |
request_id | 哪一趟接口调用 | 网关或应用请求入口 |
trace_id / span_id | 哪条追踪链及其中哪段调用 | 追踪 SDK,按追踪协议传播 |
尤其要留意同名的 run_id:本文业务表中的运行 ID、Agent Server 的 run 资源,以及 Runnable 配置中的追踪运行 ID,属于不同 API 层,不能仅凭名字认为会自动关联。嵌入式 graph.invoke 也不会替应用建立一张带状态、重试次数和调度策略的业务运行表。若使用 Agent Server,应以创建运行接口返回的资源 ID 管理该 run;若需要关联业务命令,显式保存映射。官方后台运行接口示例
消息没有重复,为什么节点还是执行了两次
前面的示例用 MessagesState 合并消息,很容易产生一个误解:只要消息有稳定 ID,就已经做好请求幂等。为验证这个判断,实验向同一 thread 连续调用两次 invoke,两次都传入同一个 message_id,并在 reply 内记录执行次数。
实测结果是:用户消息只有一条,节点执行了两次。 add_messages 处理的是列表中同一个消息对象的更新,不负责拒绝本次图执行。即使把第二条助手答复也覆盖掉,模型计费和工具副作用仍可能已经发生两次。
我会在图执行之前处理业务幂等。业务数据库为 (tenant_id, command_id) 建唯一约束,记录目标对话、请求内容指纹、处理状态和运行 ID。第一次插入成功的请求负责安排执行;重复命中时检查目标与载荷是否一致,一致则返回已有状态,不一致则拒绝复用这个键。不能先查询“不存在”再无约束插入,否则两个并发请求仍可能同时进入图。
数据库写入与任务投递之间还可能失败。若接口写入了命令记录却没能投递任务,需要可重试的调度恢复,或在同一数据库事务中记录待投递事件,再由独立发送器投递。这里的数据库唯一约束解决“谁接受了这个命令”,任务投递恢复解决“接受后是否最终执行”;两个问题不会因为 ID 唯一就同时消失。
同一个 thread 的不同命令仍需定义顺序。对于顺序聊天,我建议按 thread 排队;两个合法的新命令应依次执行,而不是当成重复请求拒绝。Agent Server 提供自己的重复输入处理策略,嵌入式库接入应由宿主实现所需调度。官方并发输入说明
这些 ID 怎样生成,生成之后放在哪里
先给前面的业务约定一个最小存储形状。下面是建议的关联字段,不是 LangGraph 自带的数据表;只有需要后台执行与可靠重试时,才需要完整采用:
| 业务记录 | 主键与关联 | 要保留的约束 |
|---|---|---|
| conversations | id、tenant_id、owner_id、thread_id | 当前方案中 conversation 与 thread 一对一 |
| messages | id、conversation_id、role、content | 编辑引用同一消息 ID;内容相同不代表同一消息 |
| commands | id、tenant_id、conversation_id、message_id、payload_hash、status | (tenant_id, id) 唯一;重试载荷必须匹配 |
| runs | id、command_id、thread_id、attempt_no、status | 一个命令可关联多个执行尝试 |
这里 commands.id 就是上文的 command_id,不再另造一个重复主键。runs 可以保存起始 checkpoint 与结束 checkpoint,用于追查执行来源,但它不拥有或代替 Checkpointer 的内部表。消息业务表和 State 中的消息也要明确谁负责更新,避免两套数据各自变更后失去关联。
ID 设计先确定生命周期,再选择编码。conversation_id 每段对话生成一次,message_id 每条消息生成一次,command_id 每次新意图生成一次;相同意图的网络重试复用原命令。request_id 则每次请求生成一个。把 UUID 生成器放进所有入口第一行,会抹掉这些语义差异。
对于没有既有编号体系的新应用,我通常选 UUIDv4;如果数据库索引的时间局部性确实重要,再考虑 UUIDv7。两者都保留数据库唯一约束。以下是选择依据,不是要求一个系统同时实现所有方案:
| 生成方式 | 适合的约束 | 需要接受的代价 |
|---|---|---|
| UUIDv4 | 无需集中分配、实现简单,不要求按 ID 排时序 | 随机值缺乏时间局部性,创建时间需另存 |
| UUIDv7 | 希望 ID 大致按生成时间排列,改善时间局部性 | 包含时间信息;跨机器排序不能代替业务顺序 |
| 数据库序列 | 已有集中式数据库编号体系 | 唯一性通常局限该分配域,对外值容易枚举 |
| 确定性映射,例如 UUIDv5 | 同一规范化业务键必须映射到同一个 ID | 输入范围、namespace 和版本规则必须稳定;不是加密或匿名化 |
UUIDv4 使用随机位,UUIDv7 带有毫秒时间字段,UUIDv5 基于 namespace 与名称生成。这些格式由 RFC 9562 定义。格式保证不了“哪次执行应该在前”,若业务需要严格消息顺序,应另设数据库分配的序号;不要用 UUID 的字符串大小实现并发控制。UUID 标准
下面是完整的本地生成实验,沿用前面的出行对话语义。它只演示生成与复用,不模拟数据库、鉴权或幂等服务。Python 3.14 标准库提供 uuid7;需要兼容旧版 Python 时可统一使用 uuid4,无需改变 ID 的职责。Python UUID 文档
from uuid import uuid4, uuid7
conversation_id = str(uuid7()) # 创建对话时生成并持久化
thread_id = conversation_id # 当前业务明确采用一对一映射
message_id = str(uuid4()) # 创建用户消息时生成
command_id = str(uuid4()) # 首次提交前生成
first_request = {
"conversation_id": conversation_id,
"thread_id": thread_id,
"message_id": message_id,
"command_id": command_id,
"request_id": str(uuid4()),
}
retry_request = {**first_request, "request_id": str(uuid4())}
regenerate_request = {
**first_request,
"command_id": str(uuid4()),
"request_id": str(uuid4()),
}
assert retry_request["command_id"] == first_request["command_id"]
assert retry_request["request_id"] != first_request["request_id"]
assert regenerate_request["command_id"] != first_request["command_id"]
assert regenerate_request["message_id"] == first_request["message_id"]
print("重试复用命令;重新生成创建命令;两者都保留原对话与用户消息")网络重试需要跨进程或页面刷新时,原 command_id 也必须保留下来,不能只存在临时变量里。可由客户端持久保存待提交命令,或先向服务端创建操作资源,再对该资源执行后续请求。服务端生成幂等键并非不行,但如果键只存在已经丢失的第一次响应里,客户端就没有办法在重试时带回它。
业务后端仍应从已授权的对话记录解析真正的 thread_id,不要无条件信任客户端传来的 thread。上述字典用于展示关系,外部接口完全可以只接收 conversation_id,由后端读取绑定关系。数据库保存 tenant_id、owner_id 和 thread_id 的关联,权限判断依赖这些记录;拥有 UUID 不等于拥有访问权。
我会优先使用不承载个人信息的 ID,并把租户、用户、环境、图版本放在独立字段。需要派生 thread 时,采用固定编码和全局唯一业务键,例如服务端从已保存的工作流 UUID 生成带用途前缀的字符串;同时验证目标后端的长度和格式限制。平台 API 要求 UUID 时就使用纯 UUID,不假定所有后端都接受相同格式。不要把邮箱、手机号、姓名或输入正文拼进 ID,也不要指望对低熵个人信息做普通哈希就能匿名化。
对于 checkpoint_id、内部任务 ID 和追踪 span ID,应用读取并关联已有值即可。它们的生成规则由对应运行时或 SDK 管理,可能随版本变化;本文核对的检查点包使用自己的有序 ID 实现,不应该把业务 UUIDv7 的选择套到这些内部字段上。
最少需要几种 ID,取决于要区分几种事情
对简单聊天,我会从对话 ID 开始,并直接将它作为 thread。需要消息编辑、引用或去重合并时,加入消息 ID;需要可靠提交与重试时,加入命令幂等键;后台执行与故障排查需要独立运行记录时,再加入运行 ID。请求与追踪 ID 通常可以沿用已有基础设施。
如果业务暂时没有独立工作流、没有外部副作用,就不必为了照抄某张架构图加入 workflow_id 和 operation_id。反过来,出现同一对话内两次独立预订时,就不应继续拿对话 ID 代表预订。多个 ID 的价值在于保留不同对象的身份和不同操作的生命周期;仅仅多生成几个随机字符串,没有形成这些约束。
在这个出行助手里,“继续聊”保留 conversation 和 thread;“重发刚才的提交”保留 command;“再生成一次”创建 command 与执行尝试;“另开一段独立状态”创建 thread;“恢复某个历史位置”选择 checkpoint。先把这些动作写成产品行为,数据库字段与生成位置通常就能随之确定。
配套实验与源码入口
本文所在项目的实验统一放在 examples/langgraph-checkpointer/。安装 requirements.txt 后,先运行 user_code,再按 core/README.md 的符号映射阅读正式安装包:
| 从仓库根目录执行 | 观察结果 |
|---|---|
python examples/langgraph-checkpointer/user_code/conversation.py | 两轮消息与六个检查点、更换 thread / saver |
python examples/langgraph-checkpointer/user_code/inspect_checkpoint.py | 原始字段、配置键、历史重放、消息 ID 与执行次数 |
python examples/langgraph-checkpointer/user_code/recover_parallel.py | 成功任务写入保存后的并行失败恢复 |
python examples/langgraph-checkpointer/user_code/id_lifecycle.py | 网络重试与重新生成的 ID 生成及复用 |
固定版本组合会输出序列化默认值未来变更的警告,不影响本文断言。并行失败实验明确控制写入先后;它验证的是“已保存写入能够复用”,不能替代真实进程崩溃与业务副作用验证。
文中官方文档核对日期:2026-09-08。输出对应上文固定依赖的本地实验;真实数据库恢复、模型调用、业务幂等服务和人工中断不计入本例实测范围。