AI Agent 的会话与任务状态怎么设计?一套适用于 LangGraph 的 ID 架构

在普通聊天系统中,一个 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。后端处理流程如下:

  1. 校验目标 Turn 属于当前 Conversation。
  2. 锁定并校验它是最新的未删除 Turn。
  3. 取消旧 Turn 尚未结束的 Run。
  4. 将旧 Turn 标记为逻辑删除。
  5. 创建新 Turn,并设置 replaces_turn_id。
  6. 创建新的用户 Message、Run 和 Graph Thread。
  7. 提交事务后清理会话缓存,并开始生成回答。
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 协作就不再互相冲突。

相关推荐
小小张说故事1 小时前
XGBoost 入门指南:Python 梯度提升实战
python·机器学习
围炉聊科技1 小时前
Herdr智能体多路复用让你的编程工具协作起来——智能体基建系列
agent·ai编程·命令行
sarasuki1 小时前
为什么 Agent 工具需要一个「协议」而不是「框架」:MCP 成为标准的底层逻辑
设计模式·agent·mcp
databook1 小时前
在 DuckDB 中执行假设检验
python·数据分析·nosql
阿里云大数据AI技术1 小时前
云栖2026|湖生万物,助力 AI — 面向 Agent 的全模态数据平台
大数据·人工智能·agent
夏天要喝冰可乐1 小时前
Trae 每天自动签到:Serverless 定时任务完整复盘
前端·python
武子康1 小时前
Claude Code + Codex 怎么分工?别把“允许结束”当成“可以提交”
人工智能·llm·agent
吴佳浩1 小时前
多智能体系统的通信风暴与死锁治理:生产级降级与容灾方案
人工智能·agent·ai编程