Files
SuperBizAgent-java/mvp/engineering/diagnosis/Session-Run-Trace隔离-从串线到可回放.md

14 KiB
Raw Permalink Blame History

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. 可复用的设计判断

这次问题可以归纳为四条通用经验:

  1. 生命周期不同的对象,不应共享同一个聚合身份。
  2. 上下文复用不代表证据、状态和审计记录也可以复用。
  3. 兼容 fallback 必须可观察、可退出,不能静默猜测。
  4. 无法恢复的历史边界应明确降级,不能伪造精确性。

判断类似系统是否存在同类问题,可以直接问:

  • 一次请求是否有独立于 Session 的执行 ID?
  • 所有 Step、Tool、Event、Feedback 是否都能精确归属一次执行?
  • “查询最新”是否被误当成“查询指定执行”?
  • 主表覆盖写、明细追加写是否使用了同一个过宽的关联键?
  • 历史迁移是在保留不确定性,还是通过猜测制造精确性?

如果这些问题没有明确答案,那么 Trace 即使看起来完整,也未必能作为可信审计证据。


11. 资料索引