在普通聊天系统中,一个 conversation_id 加一张消息表,通常就能满足需求。
但当系统引入 LangGraph、Checkpoint、流式输出、多 Graph 路由、知识库问答、Jira、本地工具以及 Human-in-the-loop 以后,"一次对话"就不再等于"一次执行"。如果继续沿用一个 ID 表示所有概念,很快会遇到下面这些问题:
- 同一个问题第一次执行失败,第二次重试成功,应该覆盖还是保留第一次记录?
- 用户补充提示词要求重新回答时,应该如何创建新的对话轮次?
- 用户编辑最新一轮问题后重新生成时,旧问题及其执行记录应该如何处理?
- Graph 在审批节点中断,用户点击批准以后,应该继续原 Run,还是创建新 Run?
- 用户发送"继续刚才的 Jira 操作",前端应该新增一轮消息吗?
- 同一个会话中运行不同 Graph,应该共用哪个状态链?
- Checkpoint 中的
thread_id能否直接使用前端会话 ID? - 服务重启或网络重发以后,怎样避免本地工具重复产生副作用?
这篇文章以 DevMind 为例,设计一套适合复杂 Agent 系统的 ID 模型,并说明每个 ID 的职责、生命周期和关联关系。设计不依赖某个具体前端框架,重点是把产品交互、顶层执行、Graph 恢复、工具幂等和内部可观测性拆开。
一、先明确四个不同的世界
复杂 Agent 系统至少同时存在四个层次:
css
产品交互层:Conversation、Turn、Message
执行管理层:Run、Parent Run、Run Type
Graph 状态层:Graph Thread、Checkpoint、Interrupt
可观测与副作用层:Trace、Span、Tool Call、Tool Execution
这四层关注的问题不同,因此不应该共用同一个 ID。DevMind 采用以下核心标识:
markdown
conversation_id
一个聊天会话,也就是前端会话列表中的一个聊天窗口
turn_id
一轮用户交互,从一条新的用户输入开始
message_id
一条用户、AI、工具或系统消息
run_id
一次具有明确开始和结束边界的顶层 Agent 执行
parent_run_id
当前 Run 的恢复、重试或子任务来源
graph_thread_id
一条可以跨 Run 持续写入和恢复的 LangGraph 状态链
checkpoint_id
Graph 状态链中的某个具体状态快照
interrupt_id
一次等待用户输入、审批或外部事件的中断
tool_call_id
一次逻辑工具调用的稳定幂等标识
trace_id / span_id
一次 Run 内部的模型、节点、检索和工具调用记录
聊天窗口看
conversation_id,用户交互看turn_id,消息展示看message_id,一次执行看run_id,执行血缘看parent_run_id,任务恢复看graph_thread_id,具体状态快照看checkpoint_id,审批恢复看interrupt_id,工具幂等看tool_call_id,内部链路看trace_id/span_id。
二、整体 ID 层级
一个包含人工审批的会话可以表示为:
ini
Conversation C1
│
├── Turn T1:修改 Jira 任务
│ ├── User Message M1
│ ├── Run R1
│ │ ├── Graph Thread G1
│ │ ├── Checkpoint CP1
│ │ ├── Interrupt I1
│ │ └── status = INTERRUPTED
│ ├── Run R2
│ │ ├── parent_run_id = R1
│ │ ├── graph_thread_id = G1
│ │ ├── source_checkpoint_id = CP1
│ │ └── status = SUCCEEDED
│ └── Assistant Message M2
│
└── Turn T2:新的知识库问题
├── User Message M3
├── Run R3
│ ├── Graph Thread G2
│ └── status = SUCCEEDED
└── Assistant Message M4
这组关系不是一棵严格的一对一树,而是一组有明确职责的关联:
- 一个 Conversation 包含多个 Turn;
- 一个 Turn 可以包含多个 Message 和 Run;
- 一个有效 Turn 只展示一组最终用户可见消息,不维护回答版本;
- 一个 Run 指向一条 Graph Thread;
- 一条 Graph Thread 可以被多个 Run 复用;
- 一条 Graph Thread 可以产生多个 Checkpoint;
- 一个 Run 可以通过
parent_run_id关联另一个 Run; - 一个 Run 内可以包含多个 Span 和 Tool Execution。
正常问答中,一个 Turn、一个 Run 和一条 Graph Thread 经常是一一对应的;失败重试、人工审批和跨轮继续任务会打破这种对应关系。用户补充提示词要求重新回答属于新的输入,因此创建新 Turn;只有使用"编辑问题"功能修改最新一轮时,才逻辑删除旧 Turn 并创建替代 Turn。
三、conversation_id:前端聊天窗口
conversation_id 表示一个持续存在的产品聊天会话。
erlang
会话标题:DevMind 权限架构讨论
conversation_id:conv_01J...
它负责回答:
- 这些用户消息和 AI 回答是否显示在同一个聊天窗口?
- 会话标题、创建人、创建时间是什么?
- 会话摘要覆盖到了哪个 Turn?
- 最近一轮有效对话是什么?
- 当前会话是否还有未完成任务?
它不负责回答当前 Graph 执行到哪个节点、某次模型调用为什么失败,或者应该从哪个 Checkpoint 恢复。同一个 Conversation 可以先后创建多条独立 Graph Thread,但这些 Turn 仍显示在同一个聊天窗口中。
lua
conversation
------------
id -- conversation_id
user_id
title
summary -- 压缩后的会话摘要
summary_through_seq -- 摘要已经覆盖到哪个有效 Turn
last_turn_seq
status
created_at
updated_at
conversation_id 的生命周期通常最长。只要用户没有删除该会话,它就可以一直存在。
四、turn_id 与 message_id
turn_id 表示产品层的一轮用户交互。它从一条新的用户输入开始,但不要求 AI 已经完成回答。用户每新增一条问题、追问或补充提示词,都创建新的 Turn。
sql
Turn T1
├── User Message M1
├── Run R1:执行到审批并中断
├── Run R2:批准后恢复并完成
└── Assistant Message M2
按钮审批可以让同一个 Turn 产生第二个 Run,但不会产生新的用户消息,所以页面仍然只显示一轮对话。
为什么不能只使用 message_id
message_id 适合标识一条具体消息,用于流式内容拼接、消息更新、排序、删除和引用;turn_id 则提供稳定的"问题---回答"业务边界。一个 Turn 可以包含一条用户消息、一个或多个 AI 或工具展示消息,以及多个 Run。
AI 最终回答直接保存为 role = ASSISTANT 的 Message,不再维护独立的回答实体或同一问题的多个回答版本。前端通过 Turn 和 Message 渲染内容,通过 active_run_id 关联当前正在执行或最终产生有效内容的 Run。
用户补充提示词要求重新回答
"换一种方式回答""再简洁一点""增加代码示例重新回答"等输入都属于新的用户消息,因此保留原 Turn,并在同一个 Conversation 中创建新 Turn。这不是替换,也不设置 replaces_turn_id。
sql
Turn T1:解释 LangGraph Checkpoint
├── User Message M1
└── Assistant Message M2
Turn T2:请增加一个代码示例重新回答
├── User Message M3
└── Assistant Message M4
编辑最新一轮问题
编辑问题属于替换操作。首期只允许编辑当前 Conversation 中最新的未删除 Turn。提交编辑后,旧 Turn 被逻辑删除,系统创建全新的 Turn、用户 Message、Run 和 Graph Thread;新 Turn 通过 replaces_turn_id 指向旧 Turn。
ini
旧 Turn T1
deleted_at = 当前时间
delete_reason = EDIT_REGENERATE
新 Turn T2
replaces_turn_id = T1
新 user_message_id
新 run_id
新 graph_thread_id
被替换的 Turn 不再显示,也不再进入会话上下文、摘要和最近 Turn 缓存,但其 Message、Run、Trace、Checkpoint 和工具执行记录继续保留,用于审计与排障。已经发生的外部工具副作用不会因为逻辑删除自动撤销。
推荐数据结构
lua
conversation_turn
-----------------
id -- turn_id
conversation_id
sequence_no
user_message_id
active_run_id
parent_turn_id -- 用户输入"继续"时关联上一 Turn
replaces_turn_id -- 编辑问题时指向被替换的 Turn
status -- PENDING / RUNNING / WAITING_APPROVAL / WAITING_USER_INPUT / COMPLETED / FAILED / CANCELLED
deleted_at -- 非空表示该 Turn 已逻辑删除
delete_reason -- EDIT_REGENERATE / USER_DELETE / ...
created_at
completed_at
推荐使用 deleted_at 作为唯一逻辑删除标识,避免同时维护 is_deleted 和 deleted_at 导致状态不一致。
lua
conversation_message
--------------------
id -- message_id
conversation_id
turn_id
run_id -- 可为空;用户消息通常没有对应 Run
role -- USER / ASSISTANT / TOOL / SYSTEM
content
status
created_at
updated_at
五、run_id:一次有明确起止边界的顶层执行
run_id 表示后端一次可以独立开始、结束、取消、统计和审计的顶层 Agent 执行。一次 Run 从被系统接受开始,以成功、中断、失败或取消结束;结束后的 Run 不再重新进入运行状态。
sql
QUEUED → RUNNING → SUCCEEDED
QUEUED → RUNNING → INTERRUPTED
QUEUED → RUNNING → FAILED
QUEUED → RUNNING → CANCELLED
如果任务被人工审批、失败恢复或用户继续执行,系统创建新的 Run,并通过 parent_run_id、graph_thread_id、source_checkpoint_id 和 interrupt_id 连接原执行。
它负责回答:
- 这一次执行何时开始、何时结束?
- 由什么动作触发,是普通请求、审批恢复、失败重试还是继续任务?
- 使用了哪个 Graph 和哪个模型?
- 从哪个 Run 或 Checkpoint 派生?
- 执行成功、失败、中断还是取消?
- 消耗了多少 Token、耗时和费用?
- 关联了哪些 Trace、工具调用和错误?
markdown
agent_run
---------
id -- run_id
conversation_id
turn_id
parent_run_id
graph_thread_id
source_checkpoint_id
interrupt_id
run_type -- NORMAL / RESUME / RETRY / CONTINUE / CHILD
trigger_type -- USER_MESSAGE / APPROVAL / RETRY_BUTTON / CONTINUE_MESSAGE / SYSTEM / PARENT_AGENT
graph_type -- chat / knowledge_qa / jira / desktop
status -- QUEUED / RUNNING / SUCCEEDED / INTERRUPTED / FAILED / CANCELLED
turn_run_sequence
model_name
input_token_count
output_token_count
error_code
error_message
started_at
finished_at
turn_id 和 run_id 最核心的区别
ini
turn_id = 用户产生了几轮输入
run_id = 后端实际启动了几次顶层执行
大多数成功场景是一个 Turn 对应一个 Run;按钮审批和失败重试会形成"一个 Turn 对应多个 Run"。用户补充提示词要求重新回答会创建新 Turn;编辑问题也会创建新 Turn,并逻辑删除被替换的旧 Turn。
parent_run_id:执行血缘
parent_run_id 表示当前 Run 从哪个 Run 派生。它只表达执行来源,具体关系由 run_type 表达。
ini
审批恢复
R2.parent_run_id = R1
R2.run_type = RESUME
失败重试
R3.parent_run_id = R2
R3.run_type = RETRY
独立子任务
Child Run.parent_run_id = Parent Run
Child Run.run_type = CHILD
编辑问题的替换关系不使用 parent_run_id,而由新 Turn 的 replaces_turn_id 表达。
六、graph_thread_id:一条可恢复的 Graph 状态链
graph_thread_id 是 DevMind 业务层对 LangGraph configurable.thread_id 的命名。之所以不直接叫 thread_id,是因为系统中已经存在聊天会话、任务线程等概念,单独写 thread_id 很容易让人误以为它等于 conversation_id。
它的职责是找到同一条 Graph 状态链上的最新 Checkpoint:
Graph Thread G1
├── Checkpoint CP1:完成意图识别
├── Checkpoint CP2:完成 Jira 查询
├── Checkpoint CP3:等待用户审批
└── Checkpoint CP4:审批后完成任务
调用 LangGraph 时仍然使用框架规定的配置键:
arduino
config = {
"configurable": {
"thread_id": graph_thread_id,
}
}
run_id 表示一次执行边界,graph_thread_id 表示可跨执行延续的状态边界。因此多个 Run 指向同一个 graph_thread_id 是正常情况。
为什么不直接使用 conversation_id
一个聊天会话可能先后运行普通问答、知识检索和 Jira 操作。不同 Graph 的 State Schema、计划、工具状态和审批状态可能完全不同。如果全部使用同一个 conversation_id 作为 LangGraph 状态链 ID,可能产生 State Schema 冲突、错误继承内部状态、意外恢复旧任务和并发写入竞争。
markdown
新独立问题或补充提示词
→ 新 turn_id + 新 run_id + 新 graph_thread_id
编辑最新问题
→ 旧 turn_id 逻辑删除 + 新 turn_id + 新 run_id + 新 graph_thread_id
审批恢复
→ 原 turn_id + 新 run_id + 原 graph_thread_id
失败后从 Checkpoint 恢复
→ 原 turn_id + 新 run_id + 原 graph_thread_id
用户输入"继续"
→ 新 turn_id + 新 run_id + 原 graph_thread_id
失败后完整重跑
→ 原 turn_id + 新 run_id + 新 graph_thread_id
七、checkpoint_id、interrupt_id 与 tool_call_id
checkpoint_id:Graph 状态快照
一个 graph_thread_id 下通常会跨多个 Run 产生多个 Checkpoint:
markdown
Graph Thread G1
│
├── Run R1
│ ├── Checkpoint CP1
│ └── Checkpoint CP2:产生 Interrupt
│
└── Run R2
├── 从 CP2 恢复
├── Checkpoint CP3
└── Checkpoint CP4:任务完成
Checkpoint 保存当前 Graph State、节点进度、中断信息、Pending writes,以及恢复当前任务所需的消息和工具状态。checkpoint_id 通常由 Checkpointer 管理,业务表只在需要精确恢复、审计或分叉时记录 source_checkpoint_id。
interrupt_id:等待外部输入的中断
interrupt_id 表示一次等待外部输入的中断,例如审批、参数补充、用户选择、风险确认、外部系统回调或设备重新上线。
ini
Run R1
├── Graph Thread G1
├── Checkpoint CP2
├── Interrupt I1
└── status = INTERRUPTED
Run R2
├── parent_run_id = R1
├── source_checkpoint_id = CP2
├── interrupt_id = I1
├── graph_thread_id = G1
└── run_type = RESUME
tool_call_id:工具调用幂等
tool_call_id 表示一次逻辑工具调用。它必须在副作用发生前生成并持久化。
markdown
第一次下发工具
→ 创建 tool_call_id
网络重发
→ 复用 tool_call_id
服务恢复后重新对账
→ 复用 tool_call_id
审批后继续同一工具
→ 复用 tool_call_id
完整重跑并重新决定调用工具
→ 新 tool_call_id
graph_thread_id 只能帮助恢复 Graph State,不能证明工具副作用是否已经发生。真正阻止本地工具重复执行的是稳定的 tool_call_id、参数摘要和持久化执行记录。逻辑删除 Turn 也不会撤销已经发生的工具副作用。
八、不同场景下应该如何分配 ID
场景一:用户提出一个全新的问题
sql
conversation_id 不变
新 turn_id
新 user message_id
新 run_id
新 graph_thread_id
这是新的产品交互、新的顶层执行和新的独立 Graph 状态链。
场景二:用户补充提示词要求重新回答
sql
保留原 turn_id
新 turn_id
新 user message_id
新 run_id
通常新 graph_thread_id
不设置 replaces_turn_id
只要用户新增了"再简洁一点""增加示例""换个角度回答"等输入,就属于新一轮对话。原问题和原回答继续保留。
场景三:用户编辑最新一轮问题并重新生成
ini
只允许编辑最新的未删除 Turn
旧 turn_id.deleted_at = 当前时间
旧 turn_id.delete_reason = EDIT_REGENERATE
新 turn_id
新 user message_id
新 turn_id.replaces_turn_id = 旧 turn_id
新 run_id
新 graph_thread_id
编辑问题是替换操作,不是新增回答版本。旧 Turn 从页面和上下文中隐藏,但其执行与审计数据继续保留。
场景四:Run 失败后从 Checkpoint 恢复
ini
turn_id 不变
新 run_id
parent_run_id = 失败 Run
run_type = RETRY
复用 graph_thread_id
记录 source_checkpoint_id
创建新 Run 可以保留两次执行各自的错误、耗时、Token、工具调用和 Trace,而不覆盖第一次失败记录。
场景五:Run 失败后完整重跑
ini
turn_id 不变
新 run_id
parent_run_id = 失败 Run
run_type = RETRY
新 graph_thread_id
完整重跑不恢复旧内部状态,只通过执行血缘保留来源关系。
场景六:模型、节点或工具在 Run 内部自动重试
turn_id 不变
run_id 不变
graph_thread_id 不变
使用 trace_id、span_id、attempt_no、tool_execution_id 记录内部重试
只有重新开始一次可独立结束、取消、统计和审计的顶层执行,才创建新的 run_id。
场景七:用户点击审批按钮
ini
Turn T1
├── Run R1
│ ├── status = INTERRUPTED
│ ├── Graph Thread G1
│ ├── Checkpoint CP1
│ └── Interrupt I1
│
└── Run R2
├── parent_run_id = R1
├── run_type = RESUME
├── graph_thread_id = G1
├── source_checkpoint_id = CP1
├── interrupt_id = I1
└── status = SUCCEEDED
审批按钮不是新的用户消息,因此不创建新 Turn;但它会重新开始一次顶层执行,因此创建新 Run。恢复同一任务时复用原 graph_thread_id、interrupt_id 和尚未完成的 tool_call_id。
场景八:用户发送"继续刚才的 Jira 操作"
ini
新 turn_id
新 user message_id
新 run_id
parent_run_id = 原任务最后一个 Run
run_type = CONTINUE
复用原 graph_thread_id
记录 source_checkpoint_id
"继续"是一条新的聊天消息,因此前端需要新增一轮;它恢复原任务的状态链,但不复用旧 Run。
场景九:同一 Runtime 内部调用辅助步骤
如果只是同一 Agent Runtime 内部的节点、查询改写、检索、重排序或辅助模型调用,不创建新的顶层 Run,只创建新的 Span。
场景十:主 Agent 调用独立 Knowledge Agent
Knowledge Agent 运行在独立服务中,拥有独立数据库、Run、Graph Thread 和 Checkpoint。主 Agent 通过封装好的知识库能力调用它,而不是把它当成主 Graph 的子图。
css
DevMind Main Run R1
└── Tool / Span:调用 Knowledge 服务
└── Knowledge Invocation KI1
└── Knowledge Run KR1
└── Knowledge Graph Thread KG1
两个 Agent 不共享 run_id、graph_thread_id 或 Checkpoint,只通过 caller_conversation_id、caller_turn_id、caller_run_id、request_id 和 trace_id 关联。
九、场景决策表
| 场景 | 新 Turn | 逻辑删除旧 Turn | replaces_turn_id | 新 Run | Graph Thread | 页面新增一轮 |
|---|---|---|---|---|---|---|
| 用户提出新问题 | 是 | 否 | 无 | 是 | 新建 | 是 |
| 补充提示词要求重新回答 | 是 | 否 | 无 | 是 | 通常新建 | 是 |
| 编辑最新问题并重新生成 | 是 | 是 | 旧 Turn | 是 | 新建 | 替换最新一轮 |
| 失败后从快照恢复 | 否 | 否 | 无 | 是 | 复用 | 否 |
| 失败后完整重跑 | 否 | 否 | 无 | 是 | 新建 | 否 |
| 模型或工具内部重试 | 否 | 否 | 无 | 否 | 复用 | 否 |
| 点击审批按钮 | 否 | 否 | 无 | 是 | 复用 | 否 |
| 用户输入"继续" | 是 | 否 | 无 | 是 | 复用 | 是 |
| 同一 Runtime 内部步骤 | 否 | 否 | 无 | 否 | 复用 | 否 |
| 独立 Agent 子任务 | 否 | 否 | 无 | 是 | 通常新建 | 否 |
是否创建新 Turn,取决于用户是否产生新的聊天输入;编辑问题是例外,它会逻辑删除最新 Turn 并创建替代 Turn。是否创建新 Run,取决于系统是否重新开始了一次可独立结束、取消、统计和审计的顶层执行;是否创建新 Graph Thread,取决于是否启动新的独立状态链。
十、编辑最新一轮的事务与并发控制
首期只允许编辑当前 Conversation 中最新的未删除 Turn。后端处理流程如下:
- 校验目标 Turn 属于当前 Conversation。
- 锁定并校验它是最新的未删除 Turn。
- 取消旧 Turn 尚未结束的 Run。
- 将旧 Turn 标记为逻辑删除。
- 创建新 Turn,并设置
replaces_turn_id。 - 创建新的用户 Message、Run 和 Graph Thread。
- 提交事务后清理会话缓存,并开始生成回答。
sql
SELECT id
FROM conversation_turn
WHERE conversation_id = ?
AND deleted_at IS NULL
ORDER BY sequence_no DESC
LIMIT 1
FOR UPDATE;
只有传入的待编辑 turn_id 等于查询结果时才允许继续,否则返回"只能编辑最新一轮"。逻辑删除旧 Turn、创建替代 Turn、用户 Message 和初始 Run 应尽量放在同一个数据库事务中,并使用 idempotency_key 防止重复提交。
旧 Run 可能仍在流式输出。SSE 或 WebSocket 事件必须携带 turn_id 和 run_id;前端丢弃已删除 Turn 的迟到事件,服务端在写入 AI 消息前也应再次检查 Turn 是否已被删除。
十一、跨 Graph 的会话上下文怎么处理
DevMind 的一个 Conversation 可以依次运行不同 Graph。不同 Graph 仍然需要理解同一个会话中的公开上下文,但不应该互相继承完整内部 State。
Conversation C1
├── Turn T1 → 普通问答 Graph → Graph Thread G1
├── Turn T2 → 知识检索任务 → Graph Thread G2
├── Turn T3 → Jira Graph → Graph Thread G3
└── Turn T4 → 继续 Jira Graph → 复用 Graph Thread G3
产品会话上下文
产品会话上下文包括用户之前说过什么、Agent 最终回答了什么、当前主题、已经确认的业务约束和历史摘要。这些数据存入 PostgreSQL 的产品表:
conversation
conversation_turn
conversation_message
新 Run 启动时只加载未逻辑删除的 Turn:
diff
会话摘要
+ 最近 N 个有效 Turn
+ 当前用户问题
+ 必要的长期记忆
Graph 内部任务状态
Graph 内部状态包括执行节点、工具调用、中断、查询改写、检索评估、当前计划和中间变量。这些数据由 LangGraph State 和 Checkpoint 管理,仅在继续对应任务时恢复。
markdown
新独立问题或补充提示词
读取公开会话上下文
创建新 Run 和新 Graph Thread
不恢复旧 Graph State
继续旧任务
读取公开会话上下文
创建新 Run 并复用旧 Graph Thread
从指定 Checkpoint 恢复
conversation_id 决定公开上下文范围,graph_thread_id 决定内部任务恢复范围。新建 Graph Thread 不会导致前端创建新的聊天窗口。
十二、为什么不从 Checkpoint 拼接整个会话历史
Checkpoint 是运行时状态存储,不是产品聊天记录的唯一事实来源。如果用 Checkpoint 直接承担完整聊天历史,会产生大量重复状态,混入工具参数和内部消息,并让消息逻辑删除、前端分页和运行时恢复强耦合。
合理的职责边界是:
markdown
conversation_message
用户可见的完整消息事实
conversation_turn
问题与回答的产品边界,以及逻辑删除和替换关系
conversation.summary
压缩后的跨 Turn 会话语境,仅覆盖有效 Turn
agent_run
每次顶层执行的状态、来源、成本和结果
LangGraph Checkpoint
某条 Graph 状态链的中断恢复数据
interrupt
等待外部输入的中断
tool_execution
工具副作用和幂等记录
Trace / Span
Run 内部的模型、节点、检索和工具调用
十三、高并发下如何加载历史上下文
每次新 Run 都不应该读取整个会话的全部历史。DevMind 使用四层职责分离:
markdown
PostgreSQL
完整会话事实来源
Redis
活跃会话最近 20 个有效 Turn 的缓存
Checkpoint
Graph 可恢复运行状态
Mem0
跨会话长期记忆
正常上下文组装为:
diff
conversation.summary
+ 最近 20 个有效 Turn
+ 当前用户问题
+ 必要的长期记忆
产品查询必须统一排除逻辑删除的 Turn:
vbnet
SELECT *
FROM conversation_turn
WHERE conversation_id = ?
AND deleted_at IS NULL
ORDER BY sequence_no;
最近 Turn 使用 Cache-Aside:
markdown
根据 user_id + conversation_id 查询 Redis
↓
命中:返回最近 20 个有效 Turn
↓
未命中:查询 PostgreSQL
↓
回填 Redis,并设置 TTL 与随机抖动
PostgreSQL 是完整会话的唯一事实来源,Redis 只是可淘汰缓存。编辑最新一轮后,应立即删除或更新对应会话缓存;如果会话摘要已经包含被删除 Turn,则需要将摘要标记失效并重新生成。
Graph 恢复读取 Checkpoint,不读取 Redis 的会话窗口;跨会话长期偏好和稳定事实由 Mem0 管理,不与完整聊天记录混为一体。
十四、推荐的数据关系
| 关系 | 说明 |
|---|---|
| Conversation 1:N Turn | 一个聊天窗口包含多轮用户交互 |
| Turn 1:N Message | 一轮交互包含用户消息、AI 消息和必要的工具展示消息 |
| Turn 1:N Run | 审批和失败重试可以让一个 Turn 拥有多个 Run |
| Turn 0..1 Replaced Turn | replaces_turn_id 仅记录编辑最新问题产生的替换关系 |
| Run N:1 Graph Thread | 多个 Run 可以恢复同一条状态链 |
| Graph Thread 1:N Checkpoint | 状态链持续追加快照 |
| Run 0..1 Parent Run | 记录恢复、失败重试和子任务来源 |
| Run 0..1 Interrupt | 中断 Run 记录等待的外部输入 |
| Run 1:N Trace / Span | 记录模型、节点、检索和内部工具调用 |
| Run 1:N Tool Execution | 记录工具状态、参数摘要、结果和幂等 |
| Tool Call 1:N Execution Attempt | 网络重试可以有多次执行尝试,但逻辑工具调用 ID 不变 |
replaces_turn_id 只用于编辑问题后的替换关系,不用于普通追问、补充提示或继续任务。跨服务 Agent 不共享数据库主键和 Checkpoint。调用方 Run 与被调用方 Run 通过调用记录、caller_run_id、request_id 和 trace_id 建立关联,不建立跨数据库外键。
十五、ID 生成建议
业务 ID 推荐使用 UUIDv7 或 ULID,而不是把数据库自增 ID 暴露给前端。
erlang
conv_01J...
turn_01J...
msg_01J...
run_01J...
gth_01J...
cp_01J...
int_01J...
tool_01J...
trace_01J...
前缀只用于提高日志可读性,不要把会变化的业务状态编码进 ID。数据库可以使用原生 UUID,也可以统一存储带前缀字符串。
| ID | 推荐生成方 |
|---|---|
conversation_id |
Server,或客户端预生成后由 Server 确认 |
turn_id |
Server |
message_id |
客户端可预生成,Server 确认 |
run_id |
Server,在每次顶层执行开始前生成 |
graph_thread_id |
Server |
checkpoint_id |
Checkpointer |
interrupt_id |
Agent Runtime |
tool_call_id |
Server,在工具副作用发生前生成并持久化 |
trace_id / span_id |
可观测系统 |
可以增加 client_request_id 或 idempotency_key,防止断线重连、重复点击或网络重试导致同一条用户输入或同一次编辑被重复创建。它们用于请求去重,不能代替 turn_id、run_id 或 tool_call_id。
十六、DevMind 的最终约定
DevMind 采用以下约定:
markdown
conversation_id
一个前端聊天窗口
turn_id
一次用户交互,负责页面展示和问题---回答边界
message_id
一条独立消息,负责流式拼接和消息更新
run_id
一次有明确起止边界的顶层 Agent 执行
parent_run_id
当前 Run 的恢复、失败重试或子任务来源
graph_thread_id
一条可以跨 Run 恢复的 LangGraph 状态链
checkpoint_id
状态链中的具体快照,由 Checkpointer 管理
interrupt_id
一次等待外部输入的中断
tool_call_id
一次逻辑工具调用的幂等 ID
trace_id / span_id
Run 内部的模型、节点、检索和工具调用
运行规则如下:
markdown
普通新问题
新 Turn + 新 Run + 新 Graph Thread
补充提示词要求重新回答
保留历史 + 新 Turn + 新 Run + 通常新 Graph Thread
编辑最新问题
旧 Turn 逻辑删除 + 新 Turn + replaces_turn_id + 新 Run + 新 Graph Thread
失败后从快照恢复
原 Turn + 新 Run + Parent Run + 原 Graph Thread
失败后完整重跑
原 Turn + 新 Run + Parent Run + 新 Graph Thread
按钮审批
原 Turn + 新 Run + Parent Run + 原 Graph Thread + 原 Tool Call
用户输入"继续"
新 Turn + 新 Run + Parent Run + 原 Graph Thread
模型或工具内部重试
原 Run + 新 Span / Attempt
同一 Runtime 内部步骤
原 Run + 子 Span
独立 Agent 调用
调用方 Run + Invocation + 独立 Agent Run
这套设计的核心不是"ID 越多越专业",而是让每个 ID 只回答一个问题:
属于哪个聊天窗口? → conversation_id
用户产生了几轮输入? → turn_id
这是哪一条消息? → message_id
系统启动了几次顶层执行? → run_id
这次执行从哪里派生? → parent_run_id
编辑后替换了哪一轮? → replaces_turn_id
应该恢复哪条状态链? → graph_thread_id
应该恢复到哪个快照? → checkpoint_id
正在响应哪个中断? → interrupt_id
如何阻止工具重复执行? → tool_call_id
一次运行内部发生了什么? → trace_id / span_id
当产品交互、执行管理、Graph 恢复、工具幂等和内部可观测性被清晰拆开以后,多 Graph 路由、失败重试、编辑问题、补充提示词、Human-in-the-loop、任务续跑和独立 Agent 协作就不再互相冲突。