docs(mvp): add remaining harness audit guides and engineering index

This commit is contained in:
zhuyongxin
2026-07-30 19:04:59 +08:00
parent e9f1c48d34
commit b39a625e5b
9 changed files with 2629 additions and 2 deletions
@@ -0,0 +1,328 @@
# 审计系统设计:从调试日志到可回放的决策证据
第一篇文章跟随一条真实 Run,展示了怎样从最终报告倒推出 Router、Agent、Tool、Guard 和 Release。这一篇换一个角度:**为什么这件事不能靠普通日志完成,系统又为什么选择了现在这套审计结构?**
先说核心判断:Agent 系统的审计对象不是代码执行过程,而是一次运行中产生的关键决定。日志可以帮助开发者定位异常,但审计必须让系统回答:这个决定属于哪次执行、由谁作出、依据是什么、先后关系怎样、最终结果是否与过程对得上。
第一次阅读只看第 1、2、3、6 和 9 节即可。它们构成最小设计主线;其余章节用于展开具体取舍。
## 1. 根问题:系统给出了答案,但无法证明答案怎样产生
一个最简单的实现可能只留下这样的日志:
```text
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. 整体设计:写入时分工,查询时聚合
审计没有设计成一个集中式拦截器抓取所有内容。真正知道“发生了什么”的组件,在自己的决策点产生结构化记录。
```mermaid
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,例如:
```text
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 结构”,数据库无法清楚表达关联、索引和数据治理。
当前设计把两类问题分开:
```mermaid
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 执行后会形成两个用途完全不同的数据面:
```mermaid
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 分开
普通回放入口聚合:
```text
chat_session metadata
+ exact diagnosis_run
+ agent_step metadata
+ tool_invocation metadata
+ ordered diagnosis_trace_event
= DiagnosisTraceResponse
```
Reasoning 使用独立入口:
```http
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. 先记住这张图
```mermaid
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. 事实来源与延伸阅读
- [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md):用真实 SUCCESS Run 查看这些设计怎样落到记录中;
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前身份、生命周期和查询契约;
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立 Trace API 的初始问题与决策;
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线证据和 exact Run 决策;
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool audit 与 canonical/durable 失败边界;
- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Reasoning 当前完成度和剩余治理缺口。