# 从一次诊断 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):当前验收边界与兼容镜像说明。