# Session、Run、Trace 隔离:一次身份建模错误的修复
**更新日期**:2026-07-29
**性质**:MVP 工程问题与架构决策复盘
**结论状态**:Session / Run 身份模型沿用至当前架构
---
## 1. 问题到底是什么
一句话定义:**系统用同一个 `sessionId`,同时标识“多轮对话”和“一次诊断执行”,导致一次 Trace 不再对应一次真实执行。**
问题由一个两轮 E2E 暴露。用户在同一会话中连续发起两次请求:
```text
Round 1:诊断支付接口超时
Round 2:基于上一轮结论,列出还缺哪些证据
```
两轮复用 `sessionId` 是正确的,因为第二轮需要上一轮上下文。但当时持久化和 Trace 查询也只使用 `sessionId`:
- `diagnosis_session` 是覆盖写,第二轮把第一轮的 query、answer、status 覆盖掉;
- `agent_step` 和 `tool_invocation` 是追加写,两轮明细累积在同一个 `sessionId` 下;
- Trace API 再按 `sessionId` 聚合主表和明细。
结果不是简单的“重复数据”,而是一个系统中从未真实发生过的混合执行:
```mermaid
flowchart LR
R1["Round 1
query A / steps A / tools A"] --> SID["同一个 sessionId"]
R2["Round 2
query B / steps B / tools B"] --> SID
SID --> Main["diagnosis_session
只剩 query B / answer B"]
SID --> Steps["agent_step
steps A + steps B"]
SID --> Tools["tool_invocation
tools A + tools B"]
Main --> Trace["混合 Trace"]
Steps --> Trace
Tools --> Trace
Trace --> Error["无法回答:
哪组证据支持了哪次回答?"]
```
这个错误会沿数据链继续放大:
| 消费方 | 错误结果 |
|---|---|
| Trace 回放 | 一条 Trace 混合两轮步骤和 Tool |
| Verifier / Gatekeeper | 当前轮可能读到上一轮证据 |
| Evaluation | 评分对象和证据集合不再属于同一次执行 |
| Feedback | 无法确定用户评价的是哪一轮回答 |
| CaseLibrary | 可能把反馈沉淀到错误的 query/answer 上 |
因此,核心问题不是某个 Repository 的更新方式,而是**会话边界被错误地当成了证据与审计边界**。
---
## 2. 设计必须守住什么
修复前先定义四条不变量。后续方案不是凭表结构偏好选择,而是看能否同时满足这些约束。
### 不变量 1:一次执行只有一个稳定身份
从请求被接受到最终 `SUCCESS / FALLBACK / FAILED / CANCELLED`,必须有一个不可变 ID。模型步骤、Tool 调用、预算、最终答案和反馈都属于它。
### 不变量 2:一条 Trace 只描述一次执行
Trace 中的 Run、AgentStep、ToolInvocation 和生命周期事件必须使用同一个 exact ID 聚合,不能依赖时间邻近或“最新一条”猜测归属。
### 不变量 3:上下文连续不等于执行合并
同一个 Session 可以包含多个 Run。后一个 Run 可以读取前一轮安全发布结果,但不能继承前一轮的 Tool、Trace、评分或失败状态。
### 不变量 4:兼容不能伪造精度
旧客户端可以有迁移期 fallback,旧数据也可以保留;但系统必须让歧义可观察,不能把无法恢复的历史混合数据伪装成精确多轮记录。
这四条不变量共同导出一个结论:系统需要两个身份,而不是给 `sessionId` 增加更多解释。
---
## 3. 为什么不是在旧表上继续修
设计阶段考虑的不是“拆表还是不拆表”这一个问题,而是如何建立稳定的执行边界。
| 候选方案 | 能解决什么 | 为什么没有选择 |
|---|---|---|
| 继续复用 `sessionId`,修正覆盖逻辑 | 避免主表被覆盖 | 明细仍无法区分轮次,根因未解决 |
| 增加 `round_no` | 可以表示第几轮 | 并发请求、重试和多个执行入口下顺序不稳定;外部引用仍需复合身份 |
| 按时间窗口拆分历史 Step/Tool | 无需改协议 | 时间不能证明归属,会制造看似精确的错误 Trace |
| 每次请求插入一条新的 `diagnosis_session` | 形成一行一次执行 | 实际上已经引入 Run 概念,但名称和 Session 生命周期仍混淆,也缺少会话主实体 |
| 拆分 `chat_session` 与 `diagnosis_run` | 显式表达一对多生命周期 | 需要 Schema、API 和客户端迁移,但能满足全部不变量 |
最终选择最后一种。它的判断依据不是“范式更规范”,而是只有它能让对话连续性和执行可审计性同时成立。
---
## 4. 核心设计
目标模型是一个清晰的一对多关系:
```mermaid
flowchart TB
Client["Client"] --> Session["chat_session
sessionId:多轮对话目录"]
Session --> Run1["diagnosis_run
runId 1:一次执行"]
Session --> Run2["diagnosis_run
runId 2:另一次执行"]
Run2 --> Step["agent_step
模型步骤 metadata"]
Run2 --> Tool["tool_invocation
Tool 审计 metadata"]
Run2 --> Event["diagnosis_trace_event
生命周期 Timeline"]
Run2 --> Reasoning["agent_reasoning_audit
受限原文"]
Run2 --> Trace["普通 Trace API"]
Step --> Trace
Tool --> Trace
Event --> Trace
Reasoning --> Audit["独立 Reasoning API"]
```
这里有三个不同的职责:
- `chat_session` 回答“哪些 Run 属于同一段对话”;
- `diagnosis_run` 回答“这次请求的输入、状态、结果和资源消耗是什么”;
- Trace 回答“这个 Run 具体经历了什么”,它是按 Run 聚合的读模型。
---
## 5. 六项关键决策
### 决策 1:引入正式的 `runId`
**选择**:每次有效 Chat 执行创建新的 `runId`,并通过 SSE metadata 返回 `sessionId + runId`。
**理由**:Run 必须能被 API、数据库、日志、Feedback 和评测独立引用。数据库自增 ID 不适合作为外部协议;轮次编号又不能稳定处理并发和重试。
**代价**:客户端必须保存并向后续 Trace/Feedback 请求传递 `runId`。
**边界**:`runId` 是不透明标识。早期实现使用 `run-` + UUID,后续格式发生过演进,客户端不得解析其前缀或长度。
### 决策 2:拆分会话态与运行态
**选择**:新增 `chat_session` 和 `diagnosis_run`,旧 `diagnosis_session` 停止承载新的运行写入。
**理由**:两者生命周期不同。
| 对象 | 保存内容 | 更新特点 |
|---|---|---|
| `chat_session` | 会话状态、轮次数、最近活动时间等目录信息 | 跨多轮持续更新 |
| `diagnosis_run` | 单次 query、answer、终态、intent、release outcome、预算 | 一次执行内从 RUNNING 走向唯一终态 |
完整多轮正文没有因为拆表就复制到 MySQL。身份拆分解决的是审计归属,不应顺带扩大长期数据保存范围。
### 决策 3:Trace 是 Run 的聚合视图,不另造身份
**选择**:第一阶段复用 `agent_step` 和 `tool_invocation`,增加 `run_id`;不为了修复隔离问题再创建一个独立 `traceId`。
**理由**:隔离所缺的是执行外键,不是第三套身份。引入 `traceId` 只会产生 `sessionId / runId / traceId` 的映射问题。
后续单 Agent + Harness 重构新增 `diagnosis_trace_event`,用于表达统一生命周期 Timeline。这是 Run 下的新明细,不是新的聚合根,也没有改变 `runId` 的边界。
### 决策 4:exact Run 是目标协议,latest Run 只是迁移桥梁
**选择**:目标查询使用:
```http
GET /api/diagnosis/{sessionId}/trace?runId={runId}
```
服务端同时验证 Run 存在且属于 path 中的 Session,防止跨 Session 串读。
旧客户端暂时只传 `sessionId` 时,可以解析 latest Run;但这是显式兼容路径,不是新的业务语义。如果必须计算 latest,应按:
```text
created_at DESC, id DESC
```
而不是 `updated_at`。旧 Run 可能因 Feedback 或异步处理再次更新,最近修改不等于最近执行。
### 决策 5:所有下游语义绑定 Run
**选择**:Trace、Feedback、Evaluation、Tool evidence 和新 Case provenance 都以 `runId` 为执行边界。
**理由**:这些对象评价或引用的是一次回答,不是整段会话。
Feedback 在迁移期缺少 `runId` 时可以绑定 latest Run,但响应必须暴露 `fallbackToLatestRun=true`。兼容如果不可观察,就会从临时措施变成永久歧义。
`case_library.diagnosis_id` 因复用旧列,在过渡期存在历史 `session_id` 和新 `run_id` 两种语义。这是明确接受的迁移成本,而不是应被隐藏的数据一致性。
### 决策 6:历史混合数据不做推测性拆分
**选择**:每条旧 `diagnosis_session` 最多映射为一个 compatibility Run,不根据时间或 Agent 名称猜测真实轮次。
**理由**:旧数据没有记录边界,任何自动拆分都只能产生无法证明的归属。审计系统宁可明确“不知道”,也不能制造虚假的精确回放。
---
## 6. 协议和影响范围
这是一次有意的行为与协议变化,不是纯内部重构。
| 范围 | 变化 | 受影响方 |
|---|---|---|
| Chat / SSE | metadata 增加 `runId` | 前端、脚本、调用方 |
| Trace API | 支持 exact `runId` 查询 | Trace UI、排障工具、评测 |
| Run API | 提供 Session 下的 Run 列表 | 多轮历史浏览 |
| Feedback | request/response 增加 Run 绑定和 fallback 标志 | 前端、CaseLibrary |
| 数据库 | 新增两张主表,明细增加 `run_id` | 持久化、迁移、查询脚本 |
| 其它入口 | 当时的 AIOps 同步采用 Run 边界 | SSE 消费方、Trace |
之所以把当时的 AIOps 一并迁移,是因为它同样会产生可回放执行;只修 Chat 会留下第二条具有同类缺陷的数据链。后续 ISS-014 删除了旧 AIOps 双入口,但这不改变当时“所有执行入口必须共享 Run 边界”的设计判断。
---
## 7. 风险如何处理
### 风险 1:兼容路径继续产生歧义
控制方式是让 fallback 可观察,并把 exact `runId` 定义为目标协议。兼容是迁移机制,不能反向成为领域模型。
### 风险 2:异步链路丢失或串用身份
`sessionId` 与 `runId` 必须作为同一执行上下文传播。当前架构将二者放入显式 `RunContext`,ToolBoundary、审计 Hook 和持久化都校验当前 Run,避免只依赖线程隐式状态。
### 风险 3:历史和新 provenance 共用旧列
保留旧列降低了迁移破坏性,但查询和文档必须承认双语义,不能把旧 `session_id` 当作非法 `run_id` 清理。
### 风险 4:旧混合 Trace 永远无法恢复
这是明确接受的事实。系统保留 compatibility 访问和回滚能力,但不承诺不存在的历史精度。
---
## 8. 如何证明设计成立
验收问题不是“接口里有没有 `runId`”,而是下面五个条件是否同时成立:
```text
同一 Session 连续执行两轮
+ 两轮获得不同 runId
+ 每个 exact Trace 只返回本 Run 明细
+ 数据库不存在跨 Run 混合行
+ 第二轮仍能使用会话上下文
```
真实 E2E 使用:
```text
sessionId = e2e-phase6-chat-codex-20260710-2120
run1 = run-e2a97696-4398-4abc-90e4-28f45c838f92
run2 = run-76ce6a6e-92ab-40c9-800a-eca0c1bb5172
```
结果:
- 两轮 Chat 成功并复用同一个 `sessionId`;
- 两轮返回不同 `runId`;
- run1 exact Trace 只返回 run1,run2 exact Trace 只返回 run2;
- `diagnosis_run` 中存在两条独立运行记录;
- AgentStep:run1 为 10 行,run2 为 9 行;
- ToolInvocation:run1 为 14 行,run2 为 8 行;
- mixed row check 为 0;
- `chat_session.message_pair_count = 2`,上下文连续性没有因隔离而丢失;
- focused tests、baseline diff、日志和数据库核验通过,未观察到 baseline drift。
这组证据同时验证了“该分开的确实分开”和“该连续的仍然连续”。
---
## 9. 后续演进验证了什么
系统后来从多角色 Agent 编排重构为单 Diagnosis ReAct Agent + Harness。执行结构发生了大变化,但 Session / Run 模型没有被替换,反而成为新架构的基础:
- `ChatApplicationUseCase` 创建和结束 Run;
- `RunContext` 显式携带 `sessionId + runId`;
- ToolBoundary 校验 Tool 请求属于当前 Run;
- Redis canonical invocation 使用 `runId + toolCallId` 定位;
- EvidenceGuard 只接受当前 Run 的 READY invocation;
- AgentStep、ToolInvocation、TraceEvent 和 ReasoningAudit 都绑定 Run。
这说明当时解决的不是某一版代码的局部 bug,而是找到了稳定的领域边界。Agent 编排可以替换,Session 与 Run 的生命周期差异不会消失。
---
## 10. 可复用的设计判断
这次问题可以归纳为四条通用经验:
1. **生命周期不同的对象,不应共享同一个聚合身份。**
2. **上下文复用不代表证据、状态和审计记录也可以复用。**
3. **兼容 fallback 必须可观察、可退出,不能静默猜测。**
4. **无法恢复的历史边界应明确降级,不能伪造精确性。**
判断类似系统是否存在同类问题,可以直接问:
- 一次请求是否有独立于 Session 的执行 ID?
- 所有 Step、Tool、Event、Feedback 是否都能精确归属一次执行?
- “查询最新”是否被误当成“查询指定执行”?
- 主表覆盖写、明细追加写是否使用了同一个过宽的关联键?
- 历史迁移是在保留不确定性,还是通过猜测制造精确性?
如果这些问题没有明确答案,那么 Trace 即使看起来完整,也未必能作为可信审计证据。
---
## 11. 资料索引
- [ISS-010:同 session 多轮诊断 Trace 隔离](../../issues/archived/ISS-010-session-run-trace-isolation.md)
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md)
- [当前 MVP 架构](../../architecture/current-mvp-architecture.md)
- [数据表索引](../../tables/README.md)
- [devflow brief](../../../devflow/projects/2026-07-10-session-run-trace-isolation/brief.md)
- [devflow decisions](../../../devflow/projects/2026-07-10-session-run-trace-isolation/decisions.md)
- [devflow acceptance](../../../devflow/projects/2026-07-10-session-run-trace-isolation/acceptance.md)
- [devflow evidence](../../../devflow/projects/2026-07-10-session-run-trace-isolation/evidence.md)