Files
SuperBizAgent-java/mvp/engineering/audit/审计系统设计-从调试日志到可回放的决策证据.md

17 KiB
Raw Permalink Blame History

审计系统设计:从调试日志到可回放的决策证据

第一篇文章跟随一条真实 Run,展示了怎样从最终报告倒推出 Router、Agent、Tool、Guard 和 Release。这一篇换一个角度:为什么这件事不能靠普通日志完成,系统又为什么选择了现在这套审计结构?

先说核心判断:Agent 系统的审计对象不是代码执行过程,而是一次运行中产生的关键决定。日志可以帮助开发者定位异常,但审计必须让系统回答:这个决定属于哪次执行、由谁作出、依据是什么、先后关系怎样、最终结果是否与过程对得上。

第一次阅读只看第 1、2、3、6 和 9 节即可。它们构成最小设计主线;其余章节用于展开具体取舍。

1. 根问题:系统给出了答案,但无法证明答案怎样产生

一个最简单的实现可能只留下这样的日志:

start diagnosis
call lookup_knowledge success
semantic check passed
finish diagnosis

这些日志能说明代码大概运行过,却回答不了几个关键问题:

  • 它们是否属于同一个请求,还是混入了同一 Session 的另一轮执行?
  • success 表示 Tool 正常返回、找到证据,还是结论已经被证据支持?
  • 哪一轮 Agent 发出了 Tool Call,后续草稿是否真的使用了这次结果?
  • semantic check passed 前是否完成了引用真实性检查?
  • 最终报告、Run 终态和 SSE outcome 是否一致?
  • Router、Agent 和 Guard 的 Token 能否与总数对上?

问题不在于日志太少。即使增加更多日志,仍然缺少稳定的身份、类型、顺序和关联关系。

普通日志擅长回答 审计必须回答
哪段代码报错了 哪一次 Run 在哪个决策阶段停止
某方法耗时多久 Router、Agent、Tool、Guard 各自消耗多少
某次调用返回成功 调用是否产生证据,证据是否支持发布
当前进程发生了什么 持久化后能否精确回放同一次执行
给开发者阅读文本 给 API、评测和对账提供稳定结构

因此审计不是“更详细的日志”,而是一套独立的数据责任。

2. 设计目标:把非确定性执行变成可验证记录

这套审计设计需要满足六条不变量。

2.1 每条记录都有 exact Run 身份

sessionId 可以跨多轮复用,runId 只属于一次执行。Agent Step、Tool Invocation、Timeline 和最终结果必须落在同一个 runId 下。

2.2 决定在发生的位置被记录

Router 记录路由决定,ToolBoundary 记录真实 Tool 调用,Guard 记录校验结论,Release 记录最终发布决定。审计层不应在事后根据日志文本猜测发生了什么。

2.3 先后顺序可以稳定重放

同一个 Run 内,Timeline 使用单调 sequence_no 表达决定顺序。回放不依赖不同线程日志的打印时间,也不依赖数据库自增 ID 恰好连续。

2.4 不同记录只承担一种责任

Run 保存终态摘要,Timeline 保存决策脊柱,Agent Step 保存模型轮次,Tool Invocation 保存调用元数据,Reasoning 保存独立敏感正文。任何一种记录都不应成为无限扩张的万能 JSON。

2.5 长期记录必须有数据边界

普通 Trace 不永久复制完整 Prompt、Tool raw 和任意嵌套参数。缺少 Provider usage 时记录“不可用”,而不是伪造为 0;Reasoning 需要独立治理。

2.6 审计缺口必须可见,但不能随意改变业务语义

长期审计写入失败不应把一条本可安全完成的请求变成业务失败;与此同时,查询结果必须通过计数、对账状态和缺失标记暴露审计并不完整。

可以把这六条压缩成一句话:

审计记录必须属于精确执行、来自原始决定、顺序稳定、职责单一、内容有界,并且能够诚实表达缺失。

3. 整体设计:写入时分工,查询时聚合

审计没有设计成一个集中式拦截器抓取所有内容。真正知道“发生了什么”的组件,在自己的决策点产生结构化记录。

flowchart TB
    REQ["一次 Chat 请求"] --> RUN["创建 exact Run"]

    subgraph owners["决策所有者"]
        ROUTER["Router<br/>路由尝试与决定"]
        AGENT["Agent Hook<br/>模型步骤与 Reasoning"]
        TOOL["ToolBoundary<br/>真实 Tool 调用"]
        GUARD["Evidence / Semantic Guard<br/>验证决定"]
        RELEASE["Release<br/>发布结果"]
    end

    RUN --> ROUTER
    RUN --> AGENT
    RUN --> TOOL
    RUN --> GUARD
    RUN --> RELEASE

    ROUTER --> TL[("Decision Timeline")]
    AGENT --> STEP[("Agent Step")]
    AGENT --> RSN[("Reasoning Audit")]
    TOOL --> INV[("Tool Invocation")]
    AGENT --> TL
    TOOL --> TL
    GUARD --> TL
    RELEASE --> TL

    RUN --> SUMMARY[("Diagnosis Run")]

    SUMMARY --> QUERY["Trace 聚合查询"]
    TL --> QUERY
    STEP --> QUERY
    INV --> QUERY
    RSN -.->|"独立受限端点"| RQUERY["Reasoning 查询"]

写入侧保持分工,读取侧由 Trace 服务形成一个面向复盘的 read model:

观察面 主要回答
diagnosis_run 这次执行最终怎样结束、用了多少资源、发布了什么
diagnosis_trace_event 决策按什么顺序发生
agent_step Diagnosis Agent 每一轮模型调用做了什么
tool_invocation 哪些 Tool 被真实调用,状态、耗时和有界结果怎样
agent_reasoning_audit Provider 是否返回 Reasoning,以及独立保存的敏感正文

这是一种“写入分散、读取聚合”的设计。分散不是随意写表,而是让记录权归属于最了解该决定的组件;聚合则把跨组件复杂度集中在 Trace 查询边界。

4. 为什么记录 typed decision,而不是事后解析日志

系统当前的 Timeline 使用稳定的 Phase 和 EventType,例如:

RUN_STARTED
ROUTING_DECISION
MODEL_TOKEN_USAGE
AGENT_MODEL_STEP
TOOL_INVOCATION
EVIDENCE_GUARD_INITIAL
SEMANTIC_GUARD_DECISION
RELEASE_DECISION
RUN_FINISHED

这些名字表达的是领域事实,而不是实现细节。SEMANTIC_GUARD_DECISION 可以稳定表示语义门控的结果,即使以后底层模型客户端或方法名发生变化。

如果改为事后解析日志,会产生三个问题:

  1. 日志文案改变就可能破坏审计;
  2. 多线程和异步输出使顺序不可靠;
  3. “Tool 执行成功”和“Tool 找到证据”很容易被同一个 success 混淆。

typed event 让状态语义在写入时就确定。它的代价是新增事件类型需要维护协议和测试,不能随意写一段字符串就算完成审计。

这不是 Event Sourcing

Timeline 虽然是追加式事件序列,但系统不会依靠它重建业务状态:

  • diagnosis_run 仍保存当前终态和发布结果;
  • Agent Step 与 Tool Invocation 仍有自己的领域记录;
  • Timeline 用于解释“决定怎样发生”,不是整个系统的唯一事实源。

选择完整 Event Sourcing 会引入事件版本、状态重放、快照和迁移复杂度,当前 MVP 没有这项需求。

5. 为什么 Timeline 和领域明细必须分开

一种看起来更简单的方案,是把所有信息都写进 diagnosis_trace_event.details。这样只查询一张表就能得到全部内容。

但一张万能事件表会同时承担:

  • 模型轮次和 Token 字段;
  • Tool 请求、状态和 RAG 检索详情;
  • Guard 决策;
  • Run 终态和最终内容;
  • Reasoning 与 assistant text。

最后所有约束都会退化为“不同 EventType 对应不同 JSON 结构”,数据库无法清楚表达关联、索引和数据治理。

当前设计把两类问题分开:

flowchart LR
    Q1["什么时候做了什么决定?"] --> TL["Timeline<br/>有序、稳定、轻量"]
    Q2["这个对象的详细事实是什么?"] --> DETAIL["Run / Step / Tool<br/>领域字段与关联"]
    TL --> TRACE["Trace Read Model"]
    DETAIL --> TRACE

例如,Timeline 的 TOOL_INVOCATION 只需要表达某次调用在决策链中的位置;tool_invocation 才负责 step_id、Tool 名、状态、耗时、bytes、错误码和 RAG 派生字段。

代价是查询时需要跨表聚合和计数对账,但数据所有权和长期演进更清楚。

6. 六个关键决策及其代价

决策一:Trace 使用独立只读 API

  • 问题:Chat SSE 面向实时用户体验,Trace 面向复盘、评测和排障,数据量与生命周期不同。
  • 选择:通过 GET /api/diagnosis/{sessionId}/trace?runId={runId} 聚合持久化记录。
  • 未选:把完整 Trace 嵌入 /api/chat 响应。
  • 代价:客户端必须保存 metadata 中的 runId,需要第二次请求才能读取 Trace。

这项决策在最初 MVP Trace 建设时就已确定:可观测性不侵入 Chat 输出协议。

决策二:runId 而不是 sessionId 定义审计边界

  • 问题:同一 Session 连续两轮执行时,主记录覆盖而 Step/Tool 追加,曾导致 Trace、Feedback 和评分跨轮污染。
  • 选择:chat_session 表示多轮目录,diagnosis_run 表示一次执行,所有明细按 run_id 隔离。
  • 未选:继续在旧主表上追加字段,或用“最新一轮”推断明细归属。
  • 代价:接口、反馈、评测和历史迁移都要理解 Session/Run 两级身份。

决策三:在原始决策点生成记录

  • 问题:集中式审计器无法准确知道 Router 为什么选择意图、Tool 是否真正执行、Guard 做出了什么领域判断。
  • 选择:让 Application、Router、Agent Hook、ToolBoundary、Guard 和 Release 各自在原始位置记录 typed fact。
  • 未选:请求结束后解析日志或根据最终结果反推中间过程。
  • 代价:每个新决策路径都必须显式接入审计,遗漏不会被“万能拦截器”自动补齐。

决策四:Timeline 与领域明细分离

  • 问题:既需要一条简单的决策脊柱,也需要可查询的 Step、Tool 和 Run 字段。
  • 选择:Timeline 记录顺序,领域表记录详情,读取时聚合。
  • 未选:单一超大 Trace 表或全部 JSON event。
  • 代价:需要维护 exact identity、顺序和 persisted/returned count 对账。

决策五:普通 Trace 只持久化有界信息

  • 问题:永久保存完整 Prompt、Tool raw、Reasoning 和参数,会放大敏感数据、体积和访问治理风险。
  • 选择:Tool durable audit 只保存有界元数据;完整 Tool truth 只在当前 Run 的 canonical store 中短期存在;Reasoning 使用独立表和独立端点。
  • 未选:为了排障方便,把所有上下文复制到 MySQL Trace。
  • 代价:长期 Trace 不能还原所有原始正文;Reasoning 还需要单独的权限、保留和加密治理。

决策六:durable audit fail-open,canonical truth fail-closed

  • 问题:审计数据库不可用时是否让业务失败,以及证据真理源不可用时是否仍允许发布。
  • 选择:长期审计写入失败只告警,不改变业务结果;用于当前 Run 验真的 canonical truth 缺失时不能继续证明证据。
  • 未选:所有审计失败一律中断请求,或所有审计失败都静默忽略。
  • 代价:业务成功不保证审计绝对完整,必须显式暴露 audit gap;canonical store 则成为发布安全的关键依赖。

7. 最重要的失败边界:同样叫“记录”,失败语义不同

Tool 执行后会形成两个用途完全不同的数据面:

flowchart TB
    TOOL["Tool 执行结果"] --> CAN["Canonical Truth<br/>当前 Run 的完整验真依据"]
    TOOL --> DUR["Durable Audit<br/>长期有界元数据"]

    CAN -->|"可用"| GUARD["EvidenceGuard 验真"]
    CAN -->|"写入失败"| CLOSED["fail-closed<br/>不能证明就不能发布"]

    DUR -->|"可用"| TRACE["长期 Trace 可回放"]
    DUR -->|"写入失败"| OPEN["fail-open<br/>告警并暴露审计缺口"]

为什么不能统一成一种策略?

  • canonical truth 参与当前业务决策。没有它,EvidenceGuard 无法独立验证 Agent 引用;继续发布会改变安全语义。
  • durable audit 服务长期复盘。它很重要,但让数据库短暂故障覆盖一条已经安全完成的业务结果,会把可观测性变成新的业务单点。

fail-open 不等于“失败无所谓”。审计记录器需要告警,Trace summary 需要对比持久化数量与返回数量,Token 账本需要给出 tokens_reconciled,Provider usage 缺失需要写明 usage_available=false。

8. 查询模型:普通 Trace 与敏感 Reasoning 分开

普通回放入口聚合:

chat_session metadata
  + exact diagnosis_run
  + agent_step metadata
  + tool_invocation metadata
  + ordered diagnosis_trace_event
  = DiagnosisTraceResponse

Reasoning 使用独立入口:

GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}

它要求显式 runId,并校验该 Run 属于 path 中的 sessionId。Provider 没有返回 Reasoning 时也记录 reasoning_available=false,不能用 assistant text 或人工摘要伪造。

分开读取的目的不是让敏感正文“换一张表就安全”,而是为后续访问控制、加密和保留期限提供独立治理边界。

9. 当前设计没有假装解决所有问题

当前仍有四类明确债务:

  1. 兼容查询债务:普通 Trace 不传 runId 时仍可读取 latest run,无 run-backed 数据时还能回退 legacy diagnosis_session。这是迁移能力,不是推荐契约。
  2. Reasoning 兼容镜像:独立 Reasoning 表已经存在,但 agent_step.thought 仍可能保存 reasoning 或 assistant text 的兼容镜像,普通 Trace metadata-only 目标尚未完全收口。
  3. 敏感治理未完成:Reasoning 端点的身份认证、权限模型、保留期限和加密仍在 ISS-015 中,不能把“分表”描述成完整安全闭环。
  4. 审计天然可能缺失:durable audit fail-open 意味着必须把缺记录与业务失败分开诊断,不能默认数据库里没有就代表运行中没有发生。

这些不是文章末尾附带的 TODO,而是当前架构代价的一部分。审计系统的可信度来自诚实表达边界,不是把所有状态都包装成完整。

10. 方案对比:为什么没有选择看起来更简单的办法

方案 短期优势 没有采用的主要原因
只增加业务日志 实现快、开发者熟悉 缺少稳定身份、类型、关联和对账协议
Chat 响应携带完整 Trace 一次请求拿到全部数据 污染 SSE 协议,扩大响应和敏感数据暴露
一张万能 Trace 表 查询表面简单 JSON 结构失控,领域约束、索引和治理困难
完整 Event Sourcing 理论上可重放全部状态 当前只需解释决策,引入事件版本和状态重建过重
永久保存全部 raw 排障最直接 敏感数据、成本和保留治理不可控
所有审计失败都 fail-closed 记录最完整 长期可观测性会成为业务可用性的单点
所有审计失败都 fail-open 业务最不易被阻断 canonical truth 缺失时仍发布会破坏证据安全

11. 先记住这张图

flowchart LR
    EXEC["非确定性 Agent 执行"] --> ID["exact Run 身份"]
    ID --> DEC["原始决策点产生 typed record"]
    DEC --> SPLIT["Timeline 与领域明细分工"]
    SPLIT --> BOUND["普通审计有界<br/>Reasoning 独立"]
    BOUND --> READ["Trace 聚合、计数与 Token 对账"]
    READ --> EXPLAIN["可以回放,也能说明缺口"]

用一句话概括:

这套审计设计不是复制运行内容,而是让每个决策所有者在 exact Run 内留下有界、可排序、可关联的结构化事实,再通过独立查询模型把这些事实聚合成可回放、可对账的证据链。

12. 事实来源与延伸阅读