feat(trace): add session run isolation schema

This commit is contained in:
zhuyongxin
2026-07-10 17:47:56 +08:00
parent 841437fa06
commit 52bf0302c6
21 changed files with 1718 additions and 1 deletions
@@ -0,0 +1,559 @@
# ISS-010 同 session 多轮诊断 Trace 隔离
**状态**:方案已确认,待 OpenSpec
**严重程度**:高
**发现时间**:2026-07-10
**来源**:同一 `sessionId` 多轮 Chat E2E 验证
---
## 背景
当前 Chat 链路同时存在两类“会话”语义:
```text
Redis SessionContext
-> 保存同一 sessionId 的多轮对话历史
-> 用于下一轮模型上下文
MySQL diagnosis_session / agent_step / tool_invocation
-> 保存诊断 Trace
-> 用于 Trace API、Verifier、Evidence score、Feedback 和评测
```
多轮对话需要继续复用 `sessionId`,否则无法保留上下文。但一次诊断 Trace 应该是可独立回放、可独立评分、可独立反馈的执行单元。
当前实现只按 `sessionId` 关联 Trace,导致同一个 `sessionId` 下多轮诊断的 step/tool 记录混在一起。
---
## E2E 证据
本次使用 `mvp-demo` profile 通过 Maven 启动服务,并用同一个 `sessionId` 连续请求两轮 `/api/chat`:
```text
sessionId = e2e-multiturn-codex-20260710-1615
round 1 = 支付接口最近出现超时,请结合知识库、日志和指标判断可能原因,并给出修复建议。
round 2 = 基于上一轮结论,只列出目前最缺的三类证据,以及下一步应该优先查哪个系统。
```
验证结果:
- 第一轮成功,走多 Agent:`planner -> executor -> verifier -> composer`。
- 第二轮成功,日志显示进入请求时 `会话历史消息对数: 1`,说明 Redis 历史上下文被复用。
- `/api/chat/session/{sessionId}` 返回 `messagePairCount=2`。
- `diagnosis_session` 只有一行,`query` 被第二轮问题覆盖。
- `agent_step` 返回 14 行,包含第一轮多 Agent step 和第二轮 `intelligent_assistant` step。
- `tool_invocation` 返回 19 行,包含两轮工具调用。
- `self_evaluation.verifier_evaluation` 仍保留第一轮 Verifier 结果;第二轮简单问答没有新的 Verifier,但 rule evaluation 会基于同 session 全部工具调用重新计算。
关键入库形态:
```text
diagnosis_session
session_id = e2e-multiturn-codex-20260710-1615
query = round 2 question
status = SUCCESS
step_count = 14
tool_call_count = 19
agent_step
round 1: planner, executor..., verifier, composer
round 2: intelligent_assistant...
tool_invocation
round 1 tools + round 2 tools all under same session_id
```
本次验证产物保存在:
- `target/e2e/request-round1.json`
- `target/e2e/response-round1.json`
- `target/e2e/request-round2.json`
- `target/e2e/response-round2.json`
- `target/e2e/trace-after-round2.json`
---
## 核心问题
### P0:Trace 不是单次诊断的稳定回放
`GET /api/diagnosis/{sessionId}/trace` 会聚合同一 `sessionId` 下所有 `agent_step` 和 `tool_invocation`。
多轮之后,Trace 不再表示某一轮诊断,而是混合历史执行轨迹。
### P0:Verifier 和评分可能读取跨轮证据
Verifier、Gatekeeper、`ToolTraceSummaryService` 和 `EvaluationService` 当前主要按 `sessionId` 查询工具调用。
如果上一轮和当前轮证据混在一起,当前轮可能引用或评分到历史工具结果。
### P1:反馈语义不清晰
`feedback` 当前在 `diagnosis_session` 上按 `sessionId` 保存。
多轮之后,用户反馈的是哪一轮答案不再明确。`useful` 反馈沉淀到 `case_library` 时也可能关联到最新主表答案,而不是用户实际评价的那一轮。
### P1:`diagnosis_session` 字段被覆盖但子表追加
主表 `query/answer/status/self_evaluation/step_count/tool_call_count` 表示最新运行或混合统计,子表却保留多轮历史。
这会让 Trace summary、数据库统计和人工排查产生歧义。
---
## 已确认决策
### D1:`runId` 是正式 API 字段
`/api/chat` 和 `/api/ai_ops` 的响应或 SSE 消息需要暴露本次执行的 `runId`。
```text
sessionId = 多轮对话上下文 ID
runId = 本轮诊断执行 ID
```
新客户端应优先用 `runId` 查询 Trace 和提交 Feedback。旧客户端只传 `sessionId` 时,服务端兼容解析该 session 的最新 run。
### D2:拆分会话态和运行态
不再把 session、run、trace 全部塞进 `diagnosis_session` 一张主表。
新增两张主表:
```text
chat_session
-> 多轮对话上下文主表
diagnosis_run
-> 单次诊断执行主表
```
Trace 继续使用现有明细表表达:
```text
agent_step
tool_invocation
```
暂不新增单独的 `diagnosis_trace` 或 `trace_event` 主表。
### D3:`runId` 格式
使用 `run-` + UUID 全量字符串。
```text
run-550e8400-e29b-41d4-a716-446655440000
```
### D4:Trace API 兼容旧路径
```text
GET /api/diagnosis/{sessionId}/trace
-> 查该 session 最新 run
GET /api/diagnosis/{sessionId}/trace?runId=run-xxx
-> 查指定 run
```
指定 `runId` 时必须校验该 run 属于 path 中的 `sessionId`。
最新 run 建议按 `diagnosis_run.created_at DESC, id DESC` 解析,避免旧 run 因反馈或异步评分更新 `updated_at` 后被误认为最新。
### D5:Feedback 优先绑定 run
Feedback request 支持 `runId`。
- 有 `runId`:绑定指定 run。
- 无 `runId`:短期兼容绑定该 `sessionId` 最新 run,并显式标记 fallback。
- `case_library.diagnosis_id` 新数据保存 `run_id`。
兼容语义:历史 `case_library.diagnosis_id` 可能保存 `diagnosis_session.session_id`;本 change 之后自动沉淀的新数据保存 `diagnosis_run.run_id`。查询、幂等和文档需要在过渡期识别两种来源,避免把旧案例误判为无效数据。
### D6:所有 `/api/chat` 执行请求都创建 run
只要请求通过参数校验并进入 `ChatService.executeChatWithStrategy`,就创建新的 diagnosis run。
- 简单问答也创建 run。
- 复杂诊断也创建 run。
- 空问题等参数校验失败不创建 run。
### D7:AIOps 同步纳入 run 隔离
每次 `/api/ai_ops` 执行也创建新的 diagnosis run。AIOps 的 step、tool invocation 和 rule evaluation 都按 `runId` 隔离。
阶段说明:AIOps 可作为独立实现切片排在 Chat 之后,但必须在本 change 整体完成前落地;Chat-only 的中间状态只能作为过渡验证状态,不能作为生产完成状态归档。
### D8:`chat_session` 第一阶段只保存会话元数据
`chat_session` 是会话目录/索引表,不保存完整对话历史正文。
建议保存:
```text
session_id
status
message_pair_count
created_at
last_active_at
expires_at
```
完整多轮对话历史继续放在 Redis `SessionContext.messageHistory`,用于下一轮 prompt 上下文。
每轮需要长期审计的用户问题和最终回答保存到 `diagnosis_run.query` / `diagnosis_run.answer`。
`chat_session.expires_at` 只表示 MySQL 会话目录的过期/清理元数据;Redis TTL 到期后,`SessionContext.messageHistory` 可能不再存在,但已经持久化的 `diagnosis_run`、`agent_step` 和 `tool_invocation` 仍作为审计记录保留。
如果未来需要长期保存完整聊天历史,再单独设计 `chat_message` 表,不在本阶段引入。
### D9:旧 `diagnosis_session` 表保留但新代码不再写入
新增 `diagnosis_run` 后,旧 `diagnosis_session` 不立即删除、不立即改造成 view、不直接重命名。
迁移策略:
1. 新增 `chat_session` / `diagnosis_run`。
2. 为 `agent_step` / `tool_invocation` 新增 nullable `run_id`。
3. 将旧 `diagnosis_session` 数据迁移/复制为 `diagnosis_run` 兼容记录。
4. 为旧 `agent_step` / `tool_invocation` 回填对应 `run_id`。
5. 增加必要索引和查询方法,先保持兼容读取。
6. 新代码切换为只写 `chat_session` 和 `diagnosis_run`,并为新 step/tool 写入 `run_id`。
7. 验证新旧数据 `run_id` 覆盖情况后,再将新写路径要求 `run_id` 非空,并补充索引/约束。
8. 旧 `diagnosis_session` 暂时保留,用于历史核对和回滚窗口。
9. 后续确认无依赖后,再单独归档或删除旧表。
### D10:提供轻量 run 列表 API
新增轻量查询接口,用于查看一个 Chat Session 下有哪些 Diagnosis Run。
```text
GET /api/chat/session/{sessionId}/runs
```
建议返回字段:
```text
runId
sessionId
query
status
agentFlow
answerPreview
stepCount
toolCallCount
createdAt
updatedAt
```
该接口只读 `diagnosis_run` 主表,不展开 `agent_step` / `tool_invocation` 大字段。
### D11:Feedback 缺少 `runId` 时短期兼容,长期收紧
Feedback 新协议优先要求 `runId`。
短期兼容策略:
- 有 `runId`:绑定指定 run。
- 无 `runId`:绑定该 `sessionId` 最新 run。
- 无 `runId` fallback 时,在响应或日志中明确标记 `fallbackToLatestRun=true`,并返回实际绑定的 `runId`。
长期收紧策略:
- 当前端、demo 脚本和外部调用方都完成 `runId` 传递后,再评估是否将缺少 `runId` 改为参数错误。
### D12:同步更新 demo 脚本和 Trace UI 的 `runId` 最小支持
本 issue 实施范围包含 demo 脚本和 Trace UI 的最小协议适配。
范围:
- Demo 脚本读取 `/api/chat` 或 `/api/ai_ops` 返回的 `runId`。
- Demo 脚本查询 Trace 时传 `?runId=...`。
- Trace UI 支持 URL 参数 `?sessionId=...&runId=...`。
- Trace UI 查询时如果有 `runId`,带上 `runId`。
- 不在本阶段实现完整 run 列表 UI。
---
## 目标语义
引入明确的 `sessionId` / `runId` 分层:
```text
sessionId = 多轮对话上下文
runId = 单次诊断执行 / 单次可回放 Trace
```
目标关系:
```text
chat_session(sessionId)
-> one conversation context
-> conversation metadata / TTL / last active state
Redis SessionContext(sessionId)
-> hot messageHistory cache
-> supports prompt context window
diagnosis_run(runId, sessionId)
-> one diagnosis run
agent_step(runId, sessionId)
-> steps of one run
tool_invocation(runId, sessionId)
-> tool calls of one run
```
---
## 建议方案
采用“拆分主表 + 复用现有 Trace 明细表”的方案:
1. 新增 `chat_session`。
2. 新增 `diagnosis_run`。
3. 逐步迁移当前 `diagnosis_session` 语义到 `diagnosis_run`。
4. `agent_step` 新增 `run_id`,继续保留 `session_id` 作为冗余筛选和兼容字段。
5. `tool_invocation` 新增 `run_id`,继续保留 `session_id` 作为冗余筛选和兼容字段。
6. Trace API 聚合 `diagnosis_run + agent_step + tool_invocation`。
建议核心字段:
```text
chat_session
id
session_id unique
status
message_pair_count
created_at
last_active_at
expires_at
diagnosis_run
id
run_id unique
session_id
query
status
agent_flow
answer
self_evaluation
feedback
total_duration_ms
total_token_count
step_count
tool_call_count
created_at
updated_at
agent_step(run_id, step_index)
tool_invocation(run_id, id)
```
理由:
- `chat_session` 只表达会话态,避免会话上下文和诊断结果混在一起。
- `diagnosis_run` 只表达一次执行,天然隔离每轮 Trace、评分和反馈。
- `agent_step` / `tool_invocation` 已足够表达 Trace 明细,暂不需要额外 trace 主表。
- 后续如果需要统一时间线,再增加 `trace_event`,不阻塞本次隔离。
---
## 分阶段计划
### Phase 0:协议基线和数据边界
目标:先把语义定死,避免实现中反复。
已确认基线:
1. `/api/chat` 是否返回 `runId`。
2. `GET /api/diagnosis/{sessionId}/trace` 默认查最新 run 还是要求显式传 `runId`。
3. Feedback 是否优先绑定 `runId`,只有旧请求缺失 `runId` 时才回退最新 run。
4. AIOps 是否和 Chat 同步接入 `runId`。
5. 简单问答是否也创建 diagnosis run。
建议默认:
- `/api/chat` 返回 `sessionId + runId`。
- `GET /api/diagnosis/{sessionId}/trace` 兼容查最新 run。
- `GET /api/diagnosis/{sessionId}/trace?runId=...` 查指定 run。
- Feedback 优先按 `runId` 绑定。
- Chat 简单问答也创建 run。
- AIOps 同步接入 run 隔离。
### Phase 1:Schema 迁移和历史数据兼容
目标:引入 `chat_session` / `diagnosis_run`,并保留旧数据可查询。
任务:
- Flyway 新增 `chat_session`。
- Flyway 新增 `diagnosis_run`。
- 为旧 `diagnosis_session` 生成兼容 `diagnosis_run` 记录。
- 为旧 `agent_step` / `tool_invocation` 回填对应 `run_id`。
- 增加 `find latest run by sessionId` 查询。
- 增加 `find by runId` 查询。
- 保留旧 `diagnosis_session` 一段时间,新代码不再写入。
验收:
- 旧 session 的 Trace 仍可查。
- 新索引存在。
- 不改变旧 `/api/chat` 必需字段。
- `agent_step` / `tool_invocation` 支持 nullable `run_id` 并完成旧数据回填。
- 新增 repository 查询可以按 `sessionId` 找最新 run、按 `runId` 找指定 run。
### Phase 2:Chat 写入切到 runId
目标:每轮 `/api/chat` 创建一个新的 run,step/tool 按 run 隔离。
任务:
- `ChatService` 每次执行生成新的 `runId`。
- `ChatController` 确保 `chat_session` 存在并更新会话态。
- `ChatService` 按 `runId` 创建 `diagnosis_run`。
- `AgentLoggingHook` 写入 `agent_step.run_id`。
- `ToolInvocationRecorder` 写入 `tool_invocation.run_id`。
- `SessionContextHolder` 或新的上下文 holder 同时携带 `sessionId + runId`。
- `backfillSessionMetrics` 按 `runId` 统计。
- `EvaluationService` 按 `runId` 读取工具调用。
验收:
- 同一 `sessionId` 连续两轮后,`diagnosis_run` 有两行不同 `run_id`。
- `/api/chat` 响应增加正式字段 `runId`。
- 两轮 `agent_step` / `tool_invocation` 分别按各自 `run_id` 查询。
- Redis `messagePairCount` 仍为 2,证明上下文不被破坏。
### Phase 3:Trace API 兼容和精确查询
目标:Trace API 可查最新 run,也可查指定 run,并能列出一个 session 下的 run。
任务:
- `GET /api/diagnosis/{sessionId}/trace` 从 `diagnosis_run` 默认解析最新 run。
- 增加 `runId` query 参数。
- Trace response 增加 `runId`。
- 新增 `GET /api/chat/session/{sessionId}/runs`。
- Demo 脚本读取响应中的 `runId` 并查询精确 Trace。
- Trace UI 支持 `sessionId + runId` 最小查询。
验收:
- 不传 `runId` 返回最新 run。
- 传第一轮 `runId` 只返回第一轮 step/tool。
- 传第二轮 `runId` 只返回第二轮 step/tool。
- run 列表 API 只返回轻量 run 摘要,不展开 trace 明细。
- Demo 脚本输出摘要包含 `runId`。
- Trace UI 可以通过 URL 参数打开指定 run。
### Phase 4:Feedback 和 CaseLibrary 绑定 run
目标:反馈明确评价哪一轮诊断。
任务:
- Feedback request 支持 `runId`。
- 旧请求只有 `sessionId` 时短期绑定最新 run,并显式标记 fallback。
- `case_library.diagnosis_id` 新数据保存 `run_id`。
- `CaseLibraryService` 以 run 为来源生成 case,并用 `run_id` 做新数据幂等键。
- 文档说明 `diagnosis_id` 的过渡语义:旧数据可能是 `session_id`,新数据是 `run_id`。
验收:
- 同 session 多轮后,对第一轮提交 feedback 不会覆盖第二轮。
- useful 生成 case 时能定位到对应 run 的 query/answer。
### Phase 5:AIOps 同步 run 隔离
目标:AIOps 使用同样的 run 语义,避免另一条入口继续混杂。
任务:
- `AiOpsService` 生成并返回/透出 `runId`。
- AIOps `agent_step` / `tool_invocation` / rule evaluation 按 `runId` 隔离。
- AIOps Trace 查询兼容 `sessionId + runId`。
验收:
- 同一 AIOps `sessionId` 重跑不会混合 step/tool。
- AIOps rule evaluation 只读取当前 run 工具调用。
---
## 暂不做
1. 暂不新增 `diagnosis_trace` 或 `trace_event` 主表。
2. 暂不做完整 run 列表 UI。
3. 暂不删除历史 Trace 数据。
4. 暂不改变 Redis 多轮上下文窗口策略。
5. 暂不立即物理删除旧 `diagnosis_session` 表。
---
## 风险
### 1. 兼容风险
现有脚本、Trace 页面和反馈接口可能只知道 `sessionId`。
缓解:保留 `sessionId` 默认查最新 run 的行为。
### 2. 异步上下文风险
工具调用和 Agent hook 依赖 ThreadLocal / RunnableConfig 传递上下文。
缓解:统一上下文对象,明确 `sessionId` 和 `runId` 必须同时传递。
### 3. 历史数据回填风险
旧数据没有真实 run 边界,只能按当前 `diagnosis_session` 生成一条兼容 `diagnosis_run`。
缓解:旧数据视为单 run,不尝试拆分历史混合数据。
### 4. 评分口径变化风险
按 `runId` 隔离后,工具调用数和 evidence score 可能下降,但语义更正确。
缓解:更新 eval fixture 和 baseline,记录这是预期行为变化。
---
## 已收敛问题
本 issue 当前已经收敛以下设计边界:
- `runId` 是正式 API 字段。
- `chat_session` 和 `diagnosis_run` 拆分为两张主表。
- Trace 明细继续由 `agent_step` / `tool_invocation` 承载。
- `chat_session` 只保存元数据,不保存完整对话历史。
- 旧 `diagnosis_session` 保留但新代码不再写入。
- 提供轻量 run 列表 API。
- Feedback 缺少 `runId` 时短期兼容、长期收紧。
- Demo 脚本和 Trace UI 做 `runId` 最小支持。
---
## 相关文件
- `mvp/architecture/session-trace-lifecycle.md`
- `mvp/architecture/data-model.md`
- `mvp/tables/诊断会话表-diagnosis_session.md`
- `mvp/tables/Agent步骤表-agent_step.md`
- `mvp/tables/工具调用表-tool_invocation.md`
- `mvp/tables/案例库表-case_library.md`
- `src/main/java/com/superbiz/agent/controller/ChatController.java`
- `src/main/java/com/superbiz/agent/service/ChatService.java`
- `src/main/java/com/superbiz/agent/service/AiOpsService.java`
- `src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java`
- `src/main/java/com/superbiz/agent/service/EvaluationService.java`
- `src/main/java/com/superbiz/agent/service/FeedbackService.java`
- `src/main/java/com/superbiz/agent/service/CaseLibraryService.java`
- `src/main/java/com/superbiz/agent/hook/AgentLoggingHook.java`
- `src/main/java/com/superbiz/agent/service/ToolInvocationRecorder.java`
- `src/main/java/com/superbiz/agent/util/SessionContextHolder.java`
- `src/main/resources/db/migration/V005__create_session_storage.sql`