14 KiB
Session、Run、Trace 隔离:一次身份建模错误的修复
更新日期:2026-07-29 性质:MVP 工程问题与架构决策复盘 结论状态:Session / Run 身份模型沿用至当前架构
1. 问题到底是什么
一句话定义:系统用同一个 sessionId,同时标识“多轮对话”和“一次诊断执行”,导致一次 Trace 不再对应一次真实执行。
问题由一个两轮 E2E 暴露。用户在同一会话中连续发起两次请求:
Round 1:诊断支付接口超时
Round 2:基于上一轮结论,列出还缺哪些证据
两轮复用 sessionId 是正确的,因为第二轮需要上一轮上下文。但当时持久化和 Trace 查询也只使用 sessionId:
diagnosis_session是覆盖写,第二轮把第一轮的 query、answer、status 覆盖掉;agent_step和tool_invocation是追加写,两轮明细累积在同一个sessionId下;- Trace API 再按
sessionId聚合主表和明细。
结果不是简单的“重复数据”,而是一个系统中从未真实发生过的混合执行:
flowchart LR
R1["Round 1<br/>query A / steps A / tools A"] --> SID["同一个 sessionId"]
R2["Round 2<br/>query B / steps B / tools B"] --> SID
SID --> Main["diagnosis_session<br/>只剩 query B / answer B"]
SID --> Steps["agent_step<br/>steps A + steps B"]
SID --> Tools["tool_invocation<br/>tools A + tools B"]
Main --> Trace["混合 Trace"]
Steps --> Trace
Tools --> Trace
Trace --> Error["无法回答:<br/>哪组证据支持了哪次回答?"]
这个错误会沿数据链继续放大:
| 消费方 | 错误结果 |
|---|---|
| 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. 核心设计
目标模型是一个清晰的一对多关系:
flowchart TB
Client["Client"] --> Session["chat_session<br/>sessionId:多轮对话目录"]
Session --> Run1["diagnosis_run<br/>runId 1:一次执行"]
Session --> Run2["diagnosis_run<br/>runId 2:另一次执行"]
Run2 --> Step["agent_step<br/>模型步骤 metadata"]
Run2 --> Tool["tool_invocation<br/>Tool 审计 metadata"]
Run2 --> Event["diagnosis_trace_event<br/>生命周期 Timeline"]
Run2 --> Reasoning["agent_reasoning_audit<br/>受限原文"]
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 只是迁移桥梁
选择:目标查询使用:
GET /api/diagnosis/{sessionId}/trace?runId={runId}
服务端同时验证 Run 存在且属于 path 中的 Session,防止跨 Session 串读。
旧客户端暂时只传 sessionId 时,可以解析 latest Run;但这是显式兼容路径,不是新的业务语义。如果必须计算 latest,应按:
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”,而是下面五个条件是否同时成立:
同一 Session 连续执行两轮
+ 两轮获得不同 runId
+ 每个 exact Trace 只返回本 Run 明细
+ 数据库不存在跨 Run 混合行
+ 第二轮仍能使用会话上下文
真实 E2E 使用:
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. 可复用的设计判断
这次问题可以归纳为四条通用经验:
- 生命周期不同的对象,不应共享同一个聚合身份。
- 上下文复用不代表证据、状态和审计记录也可以复用。
- 兼容 fallback 必须可观察、可退出,不能静默猜测。
- 无法恢复的历史边界应明确降级,不能伪造精确性。
判断类似系统是否存在同类问题,可以直接问:
- 一次请求是否有独立于 Session 的执行 ID?
- 所有 Step、Tool、Event、Feedback 是否都能精确归属一次执行?
- “查询最新”是否被误当成“查询指定执行”?
- 主表覆盖写、明细追加写是否使用了同一个过宽的关联键?
- 历史迁移是在保留不确定性,还是通过猜测制造精确性?
如果这些问题没有明确答案,那么 Trace 即使看起来完整,也未必能作为可信审计证据。