# 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)