256 lines
13 KiB
Markdown
256 lines
13 KiB
Markdown
# 从一次诊断 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 摘要<br/>这次执行最终怎样结束?"]
|
||
OUT --> TL["决策 Timeline<br/>先后做过哪些决定?"]
|
||
OUT --> STEP["Agent Step<br/>模型每一轮做了什么?"]
|
||
OUT --> TOOL["Tool Invocation<br/>实际调用过什么?"]
|
||
OUT --> LEDGER["Token 账本<br/>资源消耗能否对上?"]
|
||
```
|
||
|
||
这五个视角不是重复保存同一份内容。它们分别回答不同的问题,并在 `run_id` 下汇合。
|
||
|
||
## 3. 审计的正确阅读顺序:从结果向前倒推
|
||
|
||
回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。
|
||
|
||
```mermaid
|
||
flowchart RL
|
||
FIN["RUN_FINISHED<br/>SUCCESS,Token 已对账"] --> REL["RELEASE_DECISION<br/>允许发布"]
|
||
REL --> SG["SEMANTIC_GUARD_DECISION<br/>SUPPORTED"]
|
||
SG --> EG["EVIDENCE_GUARD_INITIAL<br/>PASSED"]
|
||
EG --> A2["Agent Step 1<br/>生成报告草稿"]
|
||
A2 --> T1["Tool Invocation<br/>RAG 返回候选证据"]
|
||
T1 --> A1["Agent Step 0<br/>发出 Tool Call"]
|
||
A1 --> ROUTE["ROUTING_DECISION<br/>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<br/>同一个多轮会话"] --> R1["diagnosis_run A<br/>第一轮问题"]
|
||
S --> R2["diagnosis_run B<br/>第二轮问题"]
|
||
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<br/>发出 lookup_knowledge"] -->|"step_id"| T["tool_invocation<br/>READY / EVIDENCE_FOUND"]
|
||
T --> O["有界 Observation"]
|
||
O --> S1["agent_step #1<br/>生成 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):当前验收边界与兼容镜像说明。
|
||
|