Files
SuperBizAgent-java/mvp/engineering/audit/从一次诊断Run看审计系统如何记录决策.md
T

13 KiB
Raw Blame History

从一次诊断 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 秒

业务视角看到的是一份诊断报告,审计视角看到的是报告背后的五个问题:

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. 审计的正确阅读顺序:从结果向前倒推

回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。

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 才表示一次独立执行。

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 调用”,还可以把两者连起来。

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

对账关系为:

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 导读:本文 SUCCESS 样本、15 个 Timeline 事件和 Token 数据来源;
  • Session、Run 与 Trace 生命周期:当前 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 检查清单:当前验收边界与兼容镜像说明。