# 从一次诊断 Run 看审计系统如何记录决策
这篇文章不从表结构和类名开始,而是从一份已经返回给用户的诊断报告开始,倒着追问:**这份答案为什么可以被系统发布?**
先说结论:审计系统不是把运行日志存下来,而是为每一次诊断建立一个独立的 `Run`,再分别记录决策顺序、模型步骤、Tool 调用和最终结果。查询时,这些记录才被重新聚合成一条可以回放、可以对账的执行证据链。
第一次阅读只看第 1、2、3、7 和 8 节即可。先建立直觉,再回来理解为什么要拆成多种记录。
## 1. 只有最终答案,为什么还不够
假设系统返回:
> 当前证据更支持数据库连接池耗尽这一排查方向,但缺少生产指标和实时日志,暂时不能确认最终根因。
这段话看起来很谨慎,但面试官、开发者或评测系统仍然会继续追问:
- 这是本轮请求产生的答案,还是混入了同一会话的上一轮数据?
- Agent 实际调用了什么 Tool,还是只在文本里声称自己查过?
- Tool 返回了候选资料以后,哪个模型步骤使用了它?
- EvidenceGuard 和 SemanticGuard 是否真的执行,最终是谁决定放行?
- 这次请求到底调用了几次模型、消耗多少 Token,数字能否对上?
普通应用日志可以告诉我们“某段代码运行过”,但很难稳定回答这些领域问题。日志行也没有天然的 Run 归属、决策类型和对账关系。
所以审计系统要解决的根问题不是“多记一些信息”,而是:
> 把一次非确定性的 Agent 执行,转换成一组具有明确身份、顺序、责任和边界的可验证记录。
## 2. 先看这次真实 Run
本文使用已有 SUCCESS E2E 样本:
| 项目 | 结果 |
|---|---|
| `session_id` | `e2e-rag-success-20260728162949` |
| `run_id` | `27415045-e674-41af-9cea-de01f27ce040` |
| 最终结果 | `SUCCESS / DIAGNOSIS_REPORT` |
| Agent Step | 2 |
| Tool Invocation | 1 次 `lookup_knowledge` |
| 模型调用 | Router 1 次、Agent 2 次、SemanticGuard 1 次 |
| Timeline | 15 个有序事件 |
| 总 Token | 13,236,`tokens_reconciled=true` |
| 总耗时 | 约 25 秒 |
业务视角看到的是一份诊断报告,审计视角看到的是报告背后的五个问题:
```mermaid
flowchart TB
OUT["用户收到 DIAGNOSIS_REPORT"]
OUT --> RUN["Run 摘要
这次执行最终怎样结束?"]
OUT --> TL["决策 Timeline
先后做过哪些决定?"]
OUT --> STEP["Agent Step
模型每一轮做了什么?"]
OUT --> TOOL["Tool Invocation
实际调用过什么?"]
OUT --> LEDGER["Token 账本
资源消耗能否对上?"]
```
这五个视角不是重复保存同一份内容。它们分别回答不同的问题,并在 `run_id` 下汇合。
## 3. 审计的正确阅读顺序:从结果向前倒推
回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。
```mermaid
flowchart RL
FIN["RUN_FINISHED
SUCCESS,Token 已对账"] --> REL["RELEASE_DECISION
允许发布"]
REL --> SG["SEMANTIC_GUARD_DECISION
SUPPORTED"]
SG --> EG["EVIDENCE_GUARD_INITIAL
PASSED"]
EG --> A2["Agent Step 1
生成报告草稿"]
A2 --> T1["Tool Invocation
RAG 返回候选证据"]
T1 --> A1["Agent Step 0
发出 Tool Call"]
A1 --> ROUTE["ROUTING_DECISION
DIAGNOSIS"]
ROUTE --> START["RUN_STARTED"]
```
沿着这条链可以得到一个比“请求成功”更具体的结论:
1. 本次 Run 最终正常结束,并发布了诊断报告;
2. Release 放行前,SemanticGuard 给出 `SUPPORTED`;
3. SemanticGuard 之前,EvidenceGuard 已确认引用结构有效;
4. 报告草稿来自 Agent 第 2 轮;
5. 草稿使用的候选证据来自第 1 轮发出的真实 RAG Tool Call;
6. 整条链都属于同一个 exact `run_id`。
审计的价值就在这里:它不是保存一份“成功日志”,而是让最终结果可以逐层找到前置依据。
## 4. 第一层:Run 是这次执行的封面
`sessionId` 表示多轮对话目录,`runId` 才表示一次独立执行。
```mermaid
flowchart TB
S["chat_session
同一个多轮会话"] --> R1["diagnosis_run A
第一轮问题"]
S --> R2["diagnosis_run B
第二轮问题"]
R1 --> D1["自己的 Step / Tool / Timeline"]
R2 --> D2["自己的 Step / Tool / Timeline"]
```
这个拆分来自真实问题:早期系统只使用 `sessionId`。同一会话连续执行两轮后,主记录会被后一轮覆盖,而 Agent Step 和 Tool Invocation 继续追加,最终造成 Trace、Feedback 和评测跨轮混合。
因此当前模型把两种身份分开:
- `sessionId` 回答“这些问题属于哪段对话”;
- `runId` 回答“这条记录属于哪一次执行”。
本次样本的 `diagnosis_run` 像一张封面,保存查询、状态、意图、发布结果、总耗时、总 Token 和最终安全内容。看到它,我们先知道故事结尾,但还不知道过程细节。
## 5. 第二层:Timeline 记录决定,不复制所有正文
本次 Run 的 15 个事件可以压缩成七个阶段:
| 阶段 | 关键事件 | 它证明什么 |
|---|---|---|
| RUN | `RUN_STARTED` | 一次独立执行已经建立 |
| ROUTING | Token、Attempt、Decision | Router 被真实调用,并选择 `DIAGNOSIS` |
| AGENT | 两轮 Token 与 Model Step | Agent 先规划 Tool,后生成草稿 |
| TOOL | `TOOL_INVOCATION` | `lookup_knowledge` 被实际执行 |
| EVIDENCE | `EVIDENCE_GUARD_INITIAL` | 引用和证据结构检查通过 |
| SEMANTIC | Token、Attempt、Decision | SemanticGuard 被调用并判断 `SUPPORTED` |
| RELEASE / RUN | Release、Finish | 报告被允许发布,Run 正常收尾 |
Timeline 只承担“什么时候做了什么决定”。它不保存完整 Tool raw,也不试图替代 Agent Step 和 Tool Invocation 的详细字段。
这是一个有意的设计取舍:如果把所有信息都塞进一张巨大的事件表,查询一条时间线会很方便,但模型步骤、Tool 证据和资源账本都会退化成难以约束的 JSON。当前方案让 Timeline 保持稳定的决策语义,领域明细继续由各自记录负责。
代价是读取时必须做聚合,不能只查一张表。但这个复杂度被集中在 Trace 查询服务中,而不是扩散给每个调用方。
## 6. 第三层:Step 与 Tool 共同证明“模型真的做过什么”
这次诊断有两个 Agent Step:
| Step | 模型行为 | Token | 关联结果 |
|---|---|---:|---|
| 0 | 没有正文,发出 `lookup_knowledge` Tool Call | 2,657 | 产生 1 条 Tool Invocation |
| 1 | 读取 Tool Observation,生成报告草稿 | 4,703 | 进入 EvidenceGuard |
`AgentStepAuditTracker` 在 Step 落库后绑定 `step_id`,Tool 执行时再把这个 ID 写入 `tool_invocation`。因此系统不只知道“这一轮有 Tool Call”和“某处有一次 Tool 调用”,还可以把两者连起来。
```mermaid
flowchart LR
S0["agent_step #0
发出 lookup_knowledge"] -->|"step_id"| T["tool_invocation
READY / EVIDENCE_FOUND"]
T --> O["有界 Observation"]
O --> S1["agent_step #1
生成 Draft"]
```
Tool 审计也没有永久复制完整原始结果。长期记录的是 exact identity、Tool 名称、状态、耗时、请求和结果字节数、稳定错误码,以及 RAG 的检索模式、命中数和相关度等有界元数据。
完整 Tool 结果属于当前 Run 的短期 canonical truth,由 Harness 用于 EvidenceGuard 验真;它与长期 durable audit 的目的不同:
- canonical truth 要回答“当前发布校验依据的事实到底是什么”;
- durable audit 要回答“长期复盘时,这次调用发生了什么”。
把完整 raw 同时写进长期 Trace 虽然排障直接,但会扩大敏感数据、存储体积和保留治理范围,因此没有采用。
## 7. 第四层:Token 不是一个总数,而是一套可对账账本
如果只在 Run 结束时写一个 `total_token_count`,我们无法判断数字是否漏掉 Router、Guard 或隐藏重试。
本次 Run 的模型账本是:
| 组件 | Token |
|---|---:|
| Intent Router | 906 |
| Diagnosis Agent 第 1 轮 | 2,657 |
| Diagnosis Agent 第 2 轮 | 4,703 |
| SemanticGuard | 4,970 |
| 合计 | 13,236 |
对账关系为:
```text
sum(MODEL_TOKEN_USAGE.total_tokens)
= diagnosis_run.total_token_count
= RUN_FINISHED.run_total_tokens
= 13236
```
因此 `tokens_reconciled=true` 不是“记录了一个 Token 数”,而是三个独立视角得到相同结果。
还有一个容易误读的点:`agent_step.tokenCount` 之和只有 7,360,因为它只统计 Diagnosis Agent 的两轮;Run 总额还包括 Router 和 SemanticGuard。审计把组件和轮次分开,就是为了让成本和延迟可以定位,而不是只得到一个无法解释的总数。
Provider 没有返回 usage 时,系统会记录 `usage_available=false`,而不是把缺失伪造成 0 Token。缺数据本身也是需要被审计的事实。
## 8. 这套设计做了哪些关键选择
### 选择一:Trace 独立查询,不塞进 Chat 响应
- **问题**:Chat 面向用户流式返回结果,Trace 面向复盘和评测,生命周期与数据量不同。
- **决策**:通过只读 Trace API 按 `sessionId + runId` 聚合持久化证据。
- **放弃方案**:把完整 Trace 嵌入 `/api/chat` SSE。
- **代价**:调用方需要在收到 metadata 后保存 `runId`,再进行第二次查询。
### 选择二:exact Run 是审计边界
- **问题**:仅按 Session 查询曾造成多轮 Step、Tool、Feedback 和评测串线。
- **决策**:每次有效执行创建独立 Run,所有明细携带同一个 `run_id`。
- **兼容代价**:当前 API 仍保留“不传 `runId` 时读取 latest run”和 legacy 回退;这是迁移兼容,不是推荐的新调用方式。
### 选择三:决策 Timeline 与领域明细分开
- **问题**:单看 Run 摘要不知道过程,单看 Step 或 Tool 又不知道整体决策顺序。
- **决策**:Timeline 保存 typed decision event,Step 和 Tool 保存各自明细,查询时聚合。
- **放弃方案**:一张万能 Trace 表承载所有正文和字段。
- **代价**:需要维护事件与明细之间的计数、身份和顺序一致性。
### 选择四:普通审计长期保存有界信息
- **问题**:完整 Prompt、Reasoning 和 Tool raw 虽然方便排障,却会把敏感数据永久扩散到普通 Trace。
- **决策**:普通 Trace 以 metadata 为主;Reasoning 使用独立审计面,Tool raw 只在当前 Run 的 canonical store 中短期存在。
- **当前缺口**:Reasoning 的认证、权限、加密和保留期限仍未完全闭环;`agent_step.thought` 还存在兼容镜像语义,不能把当前状态描述成彻底隔离。
### 选择五:审计写入失败不改变业务结果
- **问题**:如果长期审计数据库短暂不可用,是否应该让一次本可安全完成的诊断直接失败?
- **决策**:durable audit 采用 fail-open,写入失败告警,但不改变业务结果。
- **边界**:用于 EvidenceGuard 验真的 canonical truth 不是普通审计;它缺失时无法证明证据,必须 fail-closed。
- **代价**:一次业务成功的 Run 可能存在审计缺口,所以 Trace summary 和计数对账必须显式暴露缺失,而不能假装记录完整。
## 9. 审计能证明什么,不能证明什么
审计可以证明:
- 某个结果属于哪个 exact Run;
- 哪些模型和 Tool 被实际调用;
- 决策以什么顺序发生;
- Tool Call 属于哪个 Agent Step;
- Guard 和 Release 给出了什么结果;
- Token、步骤数、Tool 数和 Timeline 数量是否对账。
审计不能自动证明:
- Tool 返回的外部数据本身一定正确;
- `SUPPORTED` 永远不会发生模型误判;
- 没有记录的 Reasoning 可以被事后还原;
- durable audit 写入失败时,缺失的事件仍然存在;
- 有了 Trace 就可以忽略权限、脱敏、保留期限和加密。
这也是为什么审计系统的目标不是“绝对正确”,而是让执行过程的身份、决定、依据和缺口都变得可见。
## 10. 先记住这一句话
> 一次诊断结束后,Run 告诉我们结果,Timeline 告诉我们决定是怎样发生的,Agent Step 和 Tool Invocation 告诉我们模型与外部世界实际做过什么,Token 账本负责对账;它们通过 exact `runId` 组合成一条可回放的决策证据链。
理解这句话,就已经抓住了审计系统的主干。表结构、事件全集、Reasoning 和失败治理都可以在需要时再展开。
## 11. 事实来源与延伸阅读
本文没有重新从源码推导设计,主要依据现有工程资料:
- [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md):本文 SUCCESS 样本、15 个 Timeline 事件和 Token 数据来源;
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前 exact-run、普通 Trace 与 Reasoning 边界;
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:为什么建设独立 Trace API;
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:Session/Run 串线问题与身份拆分决策;
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool durable audit 和 canonical/durable 边界;
- [Trace 检查清单](../../demo/trace-inspection-checklist.md):当前验收边界与兼容镜像说明。