diff --git a/mvp/engineering/README.md b/mvp/engineering/README.md index ac52cc4..1fe7812 100644 --- a/mvp/engineering/README.md +++ b/mvp/engineering/README.md @@ -1,6 +1,6 @@ # MVP 工程纪要(Engineering Notes) -**更新日期**:2026-07-29 +**更新日期**:2026-07-30 **定位**:项目推进中真实遇到的问题、决策、解决思路与 E2E 验收叙事。 与其它目录分工: @@ -11,7 +11,7 @@ | **engineering/**(本目录) | **为何这样定**、踩坑、方案取舍、live 验收导读 | | [issues/](../issues/) | 未完成事项与状态 | | [tables/](../tables/) | 表结构说明 | -| `docs/learning/` 等 | 早期学习/分析(可能过时,不以之为现行口径) | + | | `devflow/projects/` | 单次变更的 brief/decisions/evidence 切片 | 本文档**不是** API 规范的唯一真理源;冲突时以 `architecture/` 与代码为准。 @@ -45,6 +45,10 @@ | 文档 | 内容 | |---|---| | [harness/README.md](harness/README.md) | **从这里开始**:用一次诊断请求理解 Harness,不要求先掌握组件和状态 | +| [harness/Harness面试速查-一张图讲清设计.md](harness/Harness面试速查-一张图讲清设计.md) | **收尾速查**:一张架构图、一条请求主链、三个核心决策和常见面试追问 | +| [harness/案例-从一次支付超时诊断看Harness如何控制Agent.md](harness/案例-从一次支付超时诊断看Harness如何控制Agent.md) | **案例导读**:跟随一次真实支付超时 Run,看 Agent、Tool、Guard 和 Release 如何协作 | +| [harness/Harness设计演进-从多Agent编排到确定性控制边界.md](harness/Harness设计演进-从多Agent编排到确定性控制边界.md) | **设计演进**:从多 Agent、Gatekeeper 和 StateGraph 逐步收敛到 Single ReAct + Harness | +| [harness/Harness失败图谱-异常-停止-降级与终态.md](harness/Harness失败图谱-异常-停止-降级与终态.md) | **失败图谱**:用可继续性、安全进展和发布资格解释异常、停止、降级与终态 | | [harness/components/README.md](harness/components/README.md) | **组件渐进式导读**:沿一次请求分四步理解运行控制、Agent 收敛、Tool 事实边界和验证发布 | | [harness/CONTEXT.md](harness/CONTEXT.md) | Harness 统一术语、命名规则、状态维度与已知命名债务 | | [harness/Harness生命周期与状态.md](harness/Harness生命周期与状态.md) | Run、Tool、Progress、Guard、Release、SSE 和持久化生命周期及状态映射 | @@ -56,6 +60,17 @@ --- +## 审计 + +| 文档 | 内容 | +|---|---| +| [audit/README.md](audit/README.md) | **从这里开始**:从“这份答案为什么可信”理解审计系统,不要求先掌握表和事件类型 | +| [audit/从一次诊断Run看审计系统如何记录决策.md](audit/从一次诊断Run看审计系统如何记录决策.md) | **真实 Run 案例**:从最终结果倒推 Run、Timeline、Agent Step、Tool、Token 与 Release 决策 | +| [audit/审计系统设计-从调试日志到可回放的决策证据.md](audit/审计系统设计-从调试日志到可回放的决策证据.md) | **主设计**:日志为何不够、设计不变量、typed decision、数据分层、失败语义与当前代价 | +| [audit/审计设计演进-从SessionTrace到ExactRun.md](audit/审计设计演进-从SessionTrace到ExactRun.md) | **设计演进**:从 Session Trace、Evidence 语义和多轮串线,演进到 canonical/durable 分层、Timeline、Token 与 Reasoning | + +--- + ## 诊断 / E2E | 文档 | 内容 | diff --git a/mvp/engineering/audit/README.md b/mvp/engineering/audit/README.md new file mode 100644 index 0000000..9a53b04 --- /dev/null +++ b/mvp/engineering/audit/README.md @@ -0,0 +1,95 @@ +# 审计系统:先从一份已经返回的答案开始 + +这不是审计表结构手册,而是一页渐进式入门导读。 + +第一次阅读时,不需要记 `diagnosis_run`、`agent_step` 或事件类型。先回答一个问题:**系统已经给出了诊断答案,为什么还需要审计?** + +## 1. 如果只有答案和日志,会发生什么 + +假设用户收到一份“数据库连接池可能耗尽”的诊断报告。业务日志也显示 Agent 和 Tool 都执行成功。 + +但系统仍然无法直接证明: + +- 报告是否属于本轮请求,而不是混入同一 Session 的上一轮数据; +- Agent 是否真的调用了它声称使用的 Tool; +- Tool 成功是否等于找到了证据; +- EvidenceGuard 和 SemanticGuard 是否真正执行; +- 最终发布结果与 Run 终态、SSE outcome 是否一致; +- Router、Agent 和 Guard 的 Token 能否与总数对上。 + +这些不是“多打印几行日志”就能稳定解决的问题。它们需要明确的执行身份、决策类型、顺序和关联关系。 + +## 2. 审计在一次请求中做了什么 + +```mermaid +flowchart LR + Q["一次诊断请求"] --> R["建立 exact Run
固定本轮身份"] + R --> D["在原始决策点记录
Router / Agent / Tool / Guard / Release"] + D --> S["Run、Timeline、Step、Tool
分别保存各自事实"] + S --> T["Trace 聚合回放
计数与 Token 对账"] + T --> A["回答答案为何产生
也诚实暴露审计缺口"] +``` + +顺着这条线,审计只做三类事情: + +1. **固定身份**:用 `runId` 把一次执行与多轮 Session 分开。 +2. **记录决定**:让真正做决定的组件留下有类型、有顺序的结构化事实。 +3. **聚合对账**:从最终结果倒推 Timeline、Agent Step、Tool 和 Token,检查它们是否一致。 + +审计不会替 Agent 诊断,也不会替 Harness 决定能否发布。它负责让这些行为在事后可以被准确解释。 + +## 3. 先建立这个最小心智模型 + +```mermaid +flowchart TB + EXEC["一次 Agent 执行"] --> RUN["Run
结果封面"] + EXEC --> TL["Timeline
决策脊柱"] + EXEC --> DETAIL["Step / Tool
行为明细"] + EXEC --> SENSITIVE["Reasoning
独立敏感审计面"] + + RUN --> TRACE["普通 Trace"] + TL --> TRACE + DETAIL --> TRACE + SENSITIVE -.-> RTRACE["独立受限查询"] +``` + +第一次阅读只需要记住: + +> Run 告诉我们结果,Timeline 告诉我们决定怎样发生,Step 和 Tool 告诉我们模型与外部世界实际做过什么;它们通过 exact `runId` 组合成一条可回放的证据链。 + +到这里可以先停下,不需要继续记表名。 + +## 4. 推荐阅读顺序 + +```mermaid +flowchart LR + START["先建立直觉"] --> CASE["01 真实 Run 案例
审计怎样使用"] + CASE --> DESIGN["02 审计主设计
为什么这样设计"] + DESIGN --> EVOLUTION["03 设计演进
为什么变成现在这样"] + EVOLUTION --> NEXT["后续专题
数据模型、Token、Tool、Reasoning、失败"] +``` + +| 顺序 | 先回答的问题 | 阅读 | +|---|---|---| +| 01 | 拿到一份结果后,怎样从后向前回放整次执行? | [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md) | +| 02 | 为什么日志不够,为什么需要 exact Run、typed event 和分层数据? | [审计系统设计:从调试日志到可回放的决策证据](审计系统设计-从调试日志到可回放的决策证据.md) | +| 03 | 这套设计经历了哪些真实问题,哪些阶段性方案后来被替换? | [审计设计演进:从 Session Trace 到 Exact Run](审计设计演进-从SessionTrace到ExactRun.md) | + +建议一次只读一篇。第一篇建立使用直觉,第二篇理解设计取舍,第三篇再理解这些边界如何被真实问题一步步推出来;数据表、完整事件类型和代码类名都不是第一次阅读的前置知识。 + +## 5. 遇到具体问题时再往下读 + +| 当你想知道 | 当前资料 | +|---|---| +| 一次 SUCCESS 诊断的业务链路、字段和真实数值 | [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md) | +| Session、Run、Trace 和 Reasoning 当前生命周期 | [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md) | +| exact Run 为什么出现,曾经发生过什么串线问题 | [Session-Run-Trace 隔离工程纪要](../diagnosis/Session-Run-Trace隔离-从串线到可回放.md) | +| 如何人工验收一条 Trace 是否完整和安全 | [Trace 检查清单](../../demo/trace-inspection-checklist.md) | + +后续本目录会继续补充数据模型、Token、Tool、Reasoning、失败图谱和面试速查。它们会继续保持同样的渐进式结构,不要求从目录头到尾顺序阅读。 + +## 6. 与 Harness 文档的分工 + +- [Harness 入门](../harness/README.md)回答“如何让一次非确定性 Agent 执行受控并安全发布”。 +- 审计文档回答“这些控制和决定如何被记录、回放和对账”。 +- Harness 是执行控制边界,Audit 是决策证据边界;二者协作,但不是同一个职责。 diff --git a/mvp/engineering/audit/从一次诊断Run看审计系统如何记录决策.md b/mvp/engineering/audit/从一次诊断Run看审计系统如何记录决策.md new file mode 100644 index 0000000..e850ee9 --- /dev/null +++ b/mvp/engineering/audit/从一次诊断Run看审计系统如何记录决策.md @@ -0,0 +1,255 @@ +# 从一次诊断 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):当前验收边界与兼容镜像说明。 + diff --git a/mvp/engineering/audit/审计系统设计-从调试日志到可回放的决策证据.md b/mvp/engineering/audit/审计系统设计-从调试日志到可回放的决策证据.md new file mode 100644 index 0000000..4bba25f --- /dev/null +++ b/mvp/engineering/audit/审计系统设计-从调试日志到可回放的决策证据.md @@ -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
路由尝试与决定"] + AGENT["Agent Hook
模型步骤与 Reasoning"] + TOOL["ToolBoundary
真实 Tool 调用"] + GUARD["Evidence / Semantic Guard
验证决定"] + RELEASE["Release
发布结果"] + 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
有序、稳定、轻量"] + Q2["这个对象的详细事实是什么?"] --> DETAIL["Run / Step / Tool
领域字段与关联"] + 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
当前 Run 的完整验真依据"] + TOOL --> DUR["Durable Audit
长期有界元数据"] + + CAN -->|"可用"| GUARD["EvidenceGuard 验真"] + CAN -->|"写入失败"| CLOSED["fail-closed
不能证明就不能发布"] + + DUR -->|"可用"| TRACE["长期 Trace 可回放"] + DUR -->|"写入失败"| OPEN["fail-open
告警并暴露审计缺口"] +``` + +为什么不能统一成一种策略? + +- 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["普通审计有界
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 当前完成度和剩余治理缺口。 + diff --git a/mvp/engineering/audit/审计设计演进-从SessionTrace到ExactRun.md b/mvp/engineering/audit/审计设计演进-从SessionTrace到ExactRun.md new file mode 100644 index 0000000..feb732c --- /dev/null +++ b/mvp/engineering/audit/审计设计演进-从SessionTrace到ExactRun.md @@ -0,0 +1,398 @@ +# 审计设计演进:从 Session Trace 到 Exact Run + +今天看到的审计系统包含 exact Run、决策 Timeline、Agent Step、Tool Invocation、Token 对账和独立 Reasoning。它看起来像一套预先设计好的完整架构,但真实过程并不是这样。 + +这套设计是被一系列具体问题推出来的:先是答案无法回放,然后是 Tool 记录语义不一致,再后来是同一 Session 多轮串线,最后 Single ReAct 重构又让旧审计链失去所有权。每次变化都解决了当时最紧迫的问题,也留下了下一阶段才看得见的新缺口。 + +这篇文章不按提交逐条记流水账,而是解释六次能力跃迁。第一次阅读只看第 1、2、4、7 和 10 节,就能抓住主线。 + +## 1. 先看完整演进 + +```mermaid +flowchart LR + A["只能看到最终答案"] --> B["01 可查询 Trace
答案可以回放"] + B --> C["02 Evidence 与质量语义
记录开始可比较"] + C --> D["03 exact Run
多轮不再串线"] + D --> E["04 Canonical Tool Truth
事实与长期审计分层"] + E --> F["05 Single ReAct 审计重建
责任回到 Harness"] + F --> G["06 Timeline / Token / Reasoning
决策、成本与敏感正文分治"] +``` + +六个阶段分别改变了审计系统回答问题的能力: + +| 阶段 | 审计开始能够回答 | +|---|---| +| 01 可查询 Trace | 一次诊断大致执行过哪些 Agent Step 和 Tool | +| 02 语义加固 | Tool 成功、无证据、失败以及 Prompt/规则版本分别是什么 | +| 03 exact Run | 这些记录究竟属于同一 Session 中的哪一轮 | +| 04 Canonical Tool Truth | 当前发布校验依赖的完整事实,与长期审计元数据如何分开 | +| 05 Single ReAct 重建 | 新 Harness 中谁负责记录 Agent 和 Tool,审计失败如何处理 | +| 06 决策、成本与敏感正文 | 决策顺序、模型成本、Reasoning 可用性和治理缺口是什么 | + +这不是从“简单”机械升级到“复杂”。每一步都在重新定义审计的边界。 + +## 2. 第一阶段:先让最终答案可以被回放 + +### 当时的问题 + +系统已经能执行诊断并返回结果,但演示者只能展示最终答案。面试官如果继续问“Agent 调了哪些 Tool、Verifier 做了什么、证据在哪里”,只能翻数据库或日志临时解释。 + +### 当时的选择 + +2026-07-03 的 MVP Trace 变更增加了独立只读 API: + +```http +GET /api/diagnosis/{sessionId}/trace +``` + +它直接聚合已有持久化数据: + +```mermaid +flowchart LR + API["Trace API"] --> SESSION[("diagnosis_session")] + API --> STEP[("agent_step")] + API --> TOOL[("tool_invocation")] + SESSION --> RESP["聚合 Trace Response"] + STEP --> RESP + TOOL --> RESP +``` + +这个阶段没有改变 Chat 主流程,也没有新增数据库表。目标很克制:先把已经存在的执行记录变成一个稳定、只读、可演示的查询入口。 + +### 没有选择什么 + +- 没有把 Trace 塞进 `/api/chat` 响应; +- 没有为了演示制造全离线假运行时; +- 没有先设计通用事件平台; +- 没有重写已有 Agent 和 Tool 持久化。 + +### 解决了什么 + +系统第一次可以从最终答案回到 Agent Step 和 Tool 记录,演示流程也形成了“Chat → Trace → Feedback”的闭环。 + +### 新暴露的问题 + +Trace 能查出来,不代表记录语义已经可靠: + +- 不同 Tool 的持久化路径并不统一; +- `success`、无结果和失败的含义不稳定; +- Trace 仍以 `sessionId` 为唯一身份; +- 记录能展示,但还不能稳定支撑评测。 + +第一阶段解决的是“有没有回放入口”,不是“审计是否已经正确”。 + +## 3. 第二阶段:从“有记录”走向“有稳定语义” + +### 当时的问题 + +评测系统准备使用 Tool Trace 计算证据质量时,发现不同 Tool 对状态的表达不一致:有的调用统一走 recorder,有的在本地 helper 中写表;无命中、失败和降级路径也可能被混成一个模糊结果。 + +与此同时,AIOps 还是一条独立入口。它能执行自动诊断,却没有与 Chat 相同的 Trace 故事。 + +### 当时的选择 + +2026-07-04 到 07-09 的一组变化没有急着增加新表,而是先收紧契约: + +1. 统一 Tool recorder 和 evidence summary 语义; +2. 区分调用成功、没有证据和真正失败; +3. 覆盖 Verifier 缺失、非法输出和 degraded path; +4. 让 AIOps 复用已有 Trace 基础设施; +5. 用 compact `prompt_audit` 和 Gatekeeper rule-set version 记录决策版本,不保存完整 Prompt。 + +```mermaid +flowchart TB + TOOL["Tool 调用"] --> S1["执行是否成功"] + S1 --> S2["是否找到 Evidence"] + S2 --> S3["Verifier / Gatekeeper 如何判断"] + S3 --> S4["使用哪一版 Prompt / Rule"] + S4 --> EVAL["稳定 Trace 与确定性评测"] +``` + +### 没有选择什么 + +- 没有把完整 Prompt 存进 Trace; +- 没有使用 Prompt 内容 hash 作为频繁变化的版本协议; +- 没有引入 LLM-as-judge 代替确定性 fixture; +- 没有为 AIOps 另建一套 Trace 数据模型。 + +### 解决了什么 + +审计记录开始拥有可比较的语义。评测可以区分“Tool 正常但无证据”和“Tool 本身失败”,也能知道某次判断使用了哪版 Prompt 与规则。 + +AIOps 当时被定义为 Chat 的兄弟入口:不同触发方式,共享同一套持久化 Trace。这在当时是合理的复用选择。 + +### 新暴露的问题 + +两个入口共享 Trace,并没有解决最根本的身份问题。只要同一个 `sessionId` 连续执行多轮,所有 Step 和 Tool 仍会混到一起。 + +而且审计维度越丰富,跨轮污染的破坏越大:不仅回放错误,Feedback、Verifier、评分和案例沉淀都会读错对象。 + +## 4. 第三阶段:Session 不是一次执行,必须引入 exact Run + +### 触发它的真实故障 + +2026-07-10 的 E2E 使用同一个 `sessionId` 连续请求两轮。Redis 多轮上下文表现正常,但 MySQL 出现了另一种现实: + +```text +diagnosis_session + query / answer 被后一轮覆盖 + +agent_step + 第一轮 + 第二轮持续追加 + +tool_invocation + 第一轮 + 第二轮持续追加 +``` + +主记录表达最新一轮,明细却表达多轮混合。Trace 不再代表某一次诊断,Feedback 也不知道评价的是哪一轮结果。 + +### 当时的选择 + +系统没有只在旧表上补一个轮次字段,而是拆分两种生命周期: + +```mermaid +flowchart TB + S["chat_session
多轮对话目录"] --> R1["diagnosis_run 1
第一轮执行"] + S --> R2["diagnosis_run 2
第二轮执行"] + R1 --> D1["step / tool by run_id"] + R2 --> D2["step / tool by run_id"] +``` + +核心决策包括: + +- `sessionId` 表示多轮会话; +- `runId` 成为正式 API 字段,表示一次可回放执行; +- `agent_step` 和 `tool_invocation` 增加 `run_id`; +- Trace、Feedback、Evaluation 和案例来源优先绑定 Run; +- 指定 `runId` 时必须校验它属于 path 中的 `sessionId`。 + +### 没有选择什么 + +- 没有继续让 `diagnosis_session` 同时承担会话与执行; +- 没有尝试根据时间戳把历史混合 Trace 伪造成多个真实 Run; +- 没有在这个阶段引入 `diagnosis_trace_event`; +- 没有立即删除旧表和旧客户端兼容。 + +“当时没有引入 Timeline”很重要。这个阶段只解决身份隔离,复用 `agent_step` 与 `tool_invocation` 表达 Trace;typed Timeline 是后续才增长出来的能力。 + +### 解决了什么 + +两轮 E2E 得到同一个 Session 下两个不同 Run: + +- run1 exact Trace 只返回 run1; +- run2 exact Trace 只返回 run2; +- step/tool mixed row check 为 0; +- 多轮对话上下文仍然连续。 + +审计终于拥有稳定的最小单位:**一次 Run 可以独立回放、评分、反馈和沉淀。** + +### 新暴露的问题 + +Run 隔离修复了“记录属于谁”,但没有回答“Tool 的完整事实由谁保管”。旧 JPA ToolInvocation 更像长期 preview,无法成为 EvidenceGuard 的独立验真来源。 + +兼容策略也留下了债务:不传 `runId` 时读取 latest run、旧表保留、历史 mixed trace 只能映射为 compatibility run。 + +## 5. 第四阶段:长期 Trace 不能同时充当证据真理源 + +### 当时的问题 + +Single ReAct + Harness 设计要求 EvidenceGuard 独立验证 Agent 引用。如果 Guard 只能读取 Agent 已经看过的裁剪结果,等于让模型用自己的输入证明自己。 + +旧 `tool_invocation` 又不能直接升级为完整事实存储:长期保存 raw response 会扩大敏感数据、存储体积和保留治理范围。 + +### 当时的选择 + +2026-07-21 的 ToolBoundary 设计把一次 Tool 调用拆成不同用途的数据面: + +```mermaid +flowchart TB + RAW["Tool Raw Result"] --> CAN["Canonical Invocation
Redis 短期完整真相"] + CAN --> OBS["Agent Observation
有界投影视图"] + CAN --> GUARD["EvidenceGuard
独立验真"] + CAN -.-> DUR["Durable Audit
MySQL 长期元数据"] +``` + +Canonical Invocation 保存当前 Run 所需的 request、raw response、Agent projection、状态和时间,并拥有 `PROJECTING / READY / ERROR` 生命周期。Agent 只能获得投影结果,不能访问 Redis 或完整 raw。 + +### 没有选择什么 + +- 没有让 JPA `tool_invocation` 保存全部 raw; +- 没有让 Agent 获取 Redis key 或 canonical record; +- 没有把无证据和调用失败合并; +- 没有让 raw 超限时静默截断后继续充当完整真相。 + +### 解决了什么 + +系统第一次明确区分: + +- **当前 Run 的验真事实**:完整、短期、Harness-only; +- **Agent 的推理观察**:有界、稳定、可消费; +- **长期审计记录**:适合复盘,但不复制完整 raw。 + +这也奠定了后来的失败语义:canonical store 缺失时无法验真,需要 fail-closed;durable audit 写入失败不应改变 Tool observation,可以 fail-open。 + +### 新暴露的问题 + +新 ToolBoundary 有了 canonical store,却尚未接回长期 `tool_invocation` 审计。与此同时,旧 recorder 依赖 ThreadLocal 和旧 Tool 副作用,不能直接带进新 Harness。 + +换句话说,新架构已经有了“事实”,但一度失去了“长期记录”。 + +## 6. 第五阶段:Single ReAct 后,审计必须重新确定所有权 + +### 当时的问题 + +旧系统的审计依附于多 Agent、`@Tool`、ThreadLocal 和旧 `AgentLoggingHook`。Single ReAct 清理删除 Planner、Executor、Verifier、Composer 和独立 AIOps 入口后,继续保留这些记录路径会出现三个问题: + +- 审计逻辑仍依赖已经退出生产链的结构; +- Tool backend 可能通过旧 annotation/recorder 产生重复副作用; +- Agent hook 可能长期保存正文和 Thought,违反新的数据边界。 + +### 当时的选择 + +2026-07-22 的收尾没有给旧 recorder 增加更多兼容分支,而是重建 Harness-native 审计: + +| 旧方式 | 新方式 | +|---|---| +| 多 Agent 名称和旧 Hook 决定步骤语义 | `HarnessAgentAuditHook` 记录单体 Diagnosis Agent 步骤 | +| ThreadLocal 回退传递身份 | exact RunContext / run-scoped tracker | +| Tool 自身带 recorder 副作用 | ToolBoundary 统一触发 durable audit port | +| Agent 输入输出正文与 Thought 混入普通记录 | 普通 Step 以 roles、count、Tool names、bytes 等 metadata 为主 | +| JPA 审计与 canonical truth 概念混合 | Redis canonical fail-closed,JPA durable audit fail-open | +| Chat 与 AIOps 两条公开诊断入口 | 统一 `/api/chat` + Intent Router | + +### 没有选择什么 + +- 没有把 `/api/ai_ops` 迁移成第二个 Harness use case; +- 没有保留旧 `@Tool` 与新 ACI 双注册; +- 没有让 audit DB 故障直接改变 Agent Observation; +- 没有为了兼容继续使用旧多 Agent 身份。 + +### 解决了什么 + +审计责任终于与当前执行架构一致:Agent Step 由 Agent Hook 负责,Tool durable audit 由 ToolBoundary 负责,最终 Run 由应用和 Release 收口。 + +这一步也说明演进不是只做加法。07-04 保留 AIOps 兄弟入口是当时的合理方案;07-22 删除它,则是“唯一入口、唯一 Harness”新目标下的必要替换。 + +### 新暴露的问题 + +metadata-only 解决了普通 Trace 的泄漏风险,却无法满足“为什么 Agent 选择这个 Tool”的深度审计需求。Run 虽然有 Step 和 Tool,也仍缺一条统一的决策 Timeline 和完整模型成本账本。 + +## 7. 第六阶段:从执行记录扩展到决策、成本与 Reasoning + +### 新架构暴露的新问题 + +真实 E2E 出现过一条失败 Run:Diagnosis Agent 重复调用 `lookup_knowledge`,累计 12 次 Tool、45,087 Token 后才进入 `BUDGET_EXHAUSTED`;Evidence Repair 还出现过结构解析失败。 + +旧 Trace 可以看见许多 Step 和 Tool,却不容易直接回答: + +- Agent 为什么继续查询,什么时候没有信息增益; +- Token 消耗来自 Router、Agent、Repair 还是 SemanticGuard; +- Evidence、Semantic 和 Release 决策按什么顺序发生; +- Provider 是否真的返回 Reasoning,还是系统只保存了 assistant text。 + +### 当前选择 + +2026-07-23 之后,审计继续分化为三个正交能力: + +1. **typed Decision Timeline**:用 RUN、ROUTING、AGENT、TOOL、EVIDENCE、SEMANTIC、RELEASE 阶段记录决定顺序; +2. **Model Token Ledger**:按组件和轮次记录 usage,在 Run 结束执行总额对账; +3. **Reasoning Audit**:独立保存 Provider Reasoning 与 assistant text,普通 Trace 只暴露可用性和 bytes 等 metadata。 + +```mermaid +flowchart TB + RUN["exact Run"] --> TL["Decision Timeline
决定如何发生"] + RUN --> STEP["Agent Step
模型轮次"] + RUN --> TOOL["Tool Invocation
外部行为"] + RUN --> TOKEN["Token Ledger
组件成本"] + RUN --> RSN["Reasoning Audit
敏感正文"] + TL --> TRACE["普通 Trace"] + STEP --> TRACE + TOOL --> TRACE + TOKEN --> TRACE + RSN -.-> RAPI["独立受限查询"] +``` + +### 没有选择什么 + +- 没有把 Reasoning 塞回普通 Trace 或 SSE; +- 没有在 Provider 不返回 Reasoning 时伪造思考过程; +- 没有只在 Run 结束写一个无法解释的 Token 总数; +- 没有把 Timeline 变成用于重建业务状态的完整 Event Sourcing。 + +### 解决了什么 + +当前 Trace 不仅能展示“发生过哪些调用”,还可以解释决定顺序、组件成本和发布依据。`tokens_reconciled` 能检查分项与总额,`usage_available=false` 能区分真实零消耗与供应商未返回 usage。 + +### 仍未完成什么 + +- Reasoning 访问控制、保留期限和加密尚未闭环; +- 非 DeepSeek Provider 和 reasoning unavailable live 样本仍需补充; +- `agent_step.thought` 仍存在兼容镜像语义; +- latest-run 与 legacy Trace fallback 仍然存在; +- durable audit fail-open 意味着业务成功仍可能伴随审计缺口。 + +演进到这里,系统不再把“记录越多”当作审计完成,而开始治理哪些记录应该存在、谁能读取、缺失时如何表达。 + +## 8. 哪些设计被保留,哪些被替换 + +| 早期设计 | 当前结果 | 判断 | +|---|---|---| +| Trace 与 Chat 响应分离 | 保留独立只读 Trace API | 核心边界正确,持续保留 | +| 聚合持久化 Step/Tool | 保留,并增加 Run、Timeline 和对账 | 基础能力被扩展 | +| Session 作为 Trace 身份 | 改为 exact Run,Session 只作目录 | 被真实串线问题替换 | +| AIOps 作为兄弟入口 | 删除,统一 `/api/chat` | 阶段性合理,后续架构收敛后退出 | +| ToolInvocation 同时承担事实与审计 | 拆为 canonical truth 与 durable audit | 责任过载,被数据分层替换 | +| 旧 ThreadLocal/Tool 副作用 recorder | 改为 Harness-native Hook、Tracker 和 Audit Port | 与当前执行架构重新对齐 | +| 普通 Step 保存 Thought/正文 | 转向 metadata + 独立 Reasoning | 安全边界收紧,但兼容镜像尚未完全清除 | +| 一个 Token 总数 | 组件轮次账本 + Run 对账 | 从统计值升级为可解释成本 | + +演进中真正稳定下来的不是某张表,而是三条原则:执行身份要精确、数据责任要分层、缺失和失败要诚实可见。 + +## 9. 为什么不能一开始就设计成现在这样 + +站在今天回看,很容易认为最初就应该有 Run、Timeline、Reasoning 和 canonical store。但当时缺少三个关键事实: + +1. 没有多轮 E2E,就无法证明 Session 身份会造成怎样的跨轮污染; +2. 没有 EvidenceGuard,就无法看清长期 Tool audit 与当前验真 truth 的责任冲突; +3. 没有真实 Token 和 Reasoning 样本,就无法确定哪些字段应该对账、哪些正文必须隔离。 + +过早一次性设计完整审计平台,很可能得到通用事件总线、万能 JSON 和全量 raw 存储,却没有解决 MVP 当时最重要的问题。 + +这套演进采用的是另一种方式:先围绕可观察故障收紧最小契约,再让新的真实运行暴露下一层边界。 + +## 10. 用一张图记住设计为什么变成现在这样 + +```mermaid +flowchart TB + P1["答案无法解释"] --> D1["独立 Trace API"] + P2["Tool 状态语义不一致"] --> D2["Evidence / Quality 契约"] + P3["同一 Session 多轮串线"] --> D3["exact Run"] + P4["长期审计不能独立验真"] --> D4["Canonical Truth / Durable Audit 分层"] + P5["旧审计依赖旧多 Agent 与 ThreadLocal"] --> D5["Harness-native Audit"] + P6["决策顺序、成本和 Reasoning 不可解释"] --> D6["Timeline / Token / Reasoning 分治"] + + D1 --> NOW["当前可回放、可对账、有数据边界的审计系统"] + D2 --> NOW + D3 --> NOW + D4 --> NOW + D5 --> NOW + D6 --> NOW +``` + +一句话概括: + +> 审计系统最初只是把已有 Session 记录聚合出来;真实 E2E 先迫使它建立稳定 Evidence 语义,再用 exact Run 修复多轮身份,随后 Single ReAct 重构又把 Tool 真理源与长期审计分开,最终才发展出统一 Timeline、Token 对账和独立 Reasoning 治理。 + +## 11. 事实来源与延伸阅读 + +- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立只读 Trace API 的起点; +- `devflow/projects/2026-07-04-evidence-trace-hardening/`:Evidence 状态和 degraded path 契约加固; +- `devflow/projects/2026-07-04-aiops-traceable-diagnosis-entry/`:早期 AIOps 兄弟入口与共享 Trace; +- `devflow/projects/2026-07-09-interview-demo-quality-audit/`:Prompt/Rule version 与确定性质量审计; +- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线、exact Run 决策和两轮 E2E; +- `devflow/projects/2026-07-21-single-react-tool-invocation-store/`:canonical Tool truth 与 durable audit 分层; +- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Single ReAct 审计所有权重建; +- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Timeline、Token、Reasoning 和当前缺口; +- [审计主设计](审计系统设计-从调试日志到可回放的决策证据.md):当前设计不变量与关键取舍; +- [真实 Run 案例](从一次诊断Run看审计系统如何记录决策.md):当前审计能力的具体回放方式。 + diff --git a/mvp/engineering/harness/Harness失败图谱-异常-停止-降级与终态.md b/mvp/engineering/harness/Harness失败图谱-异常-停止-降级与终态.md new file mode 100644 index 0000000..e386572 --- /dev/null +++ b/mvp/engineering/harness/Harness失败图谱-异常-停止-降级与终态.md @@ -0,0 +1,535 @@ +# Harness 失败图谱:异常、停止、降级与终态如何对应 + +**更新日期**:2026-07-30 + +**主题**:失败分类、停止决策、安全降级、Run 终态与发布结果 + +**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md) + +在 Harness 中,“没有找到根因”“Tool 报错”“证据不够”“超时”和“客户端断开”不是同一种失败。如果把它们都压成一个 `FAILED`,用户无法知道系统是否完成了有效检查,开发者也无法判断应该重试、换 Tool、发布 Fallback,还是立即终止。 + +这篇文章不从状态枚举开始,而是用三个问题建立一张失败图谱: + +1. 问题发生后,这次 Run 还能继续吗? +2. 已经产生的内容中,有没有可验证的安全事实? +3. 最终能公开正常报告、安全说明,还是只能失败? + +第一次阅读只看第 1、2、8、10 和 13 节即可。先掌握判断方法和几个典型场景,不需要记住全部状态。 + +## 1. 先记住:Harness 的失败处理不是“捕获异常” + +假设一次支付超时诊断中连续发生这些事情: + +```text +日志 Tool 查询成功,但指定时间段没有记录 +RAG Tool 第一次调用网络超时 +Agent 换了一个查询范围,找到一条可验证的配置事实 +Agent 据此声称“数据库连接池耗尽” +EvidenceGuard 发现报告引用的证据并不支持这个结论 +``` + +如果只看局部,既出现了空结果、技术异常、有效事实,又出现了发布门禁失败。系统不能把其中任何一个事件直接等同于整次 Run 的终态。 + +Harness 真正要做的是分层决策: + +```mermaid +flowchart TB + X["某个问题发生"] --> Q1{"Run 是否仍可继续?"} + Q1 -->|"可以"| C["继续诊断或改查其他 Tool"] + Q1 -->|"不可以"| Q2{"是否已有可验证的安全进展?"} + C --> Q3{"最终报告能否通过发布门禁?"} + Q3 -->|"可以"| S["ReleaseOutcome.SUCCESS
发布诊断报告"] + Q3 -->|"不可以,但有安全说明"| F["ReleaseOutcome.FALLBACK
发布 SafeFallback"] + Q3 -->|"没有任何安全内容"| E["ReleaseOutcome.FAILED
发布 failure"] + Q2 -->|"有"| F + Q2 -->|"无"| E +``` + +这张图背后的核心决策是: + +> 局部事件决定下一步动作,只有 Run Lifecycle 和 Release 才能决定整次请求如何结束、什么可以公开。 + +因此,失败处理不是一个全局 `try/catch`,而是执行控制、安全事实和发布策略共同完成的结果。 + +## 2. 第一类:没有答案,但系统没有坏 + +最容易被误判为失败的场景,是 Tool 正常执行却没有返回证据。 + +当前 Tool 结果使用两个正交状态: + +```text +InvocationStatus:PROJECTING / READY / ERROR +EvidenceStatus:EVIDENCE_FOUND / NO_EVIDENCE / ERROR +``` + +它们回答不同问题: + +| 组合 | 含义 | 是否是技术失败 | +|---|---|---:| +| `READY + EVIDENCE_FOUND` | Tool 成功,当前 scope 有候选证据 | 否 | +| `READY + NO_EVIDENCE` | Tool 成功,当前 scope 没有候选证据 | 否 | +| `ERROR + ERROR` | Tool 执行、投影或 canonical 保存失败 | 是 | + +`READY + NO_EVIDENCE` 只能证明“本次查询范围为空”,不能证明“整个系统不存在该问题”。例如查询 10:00 至 10:10 的支付日志为空,不代表全天没有超时,也不代表日志系统之外没有故障。 + +```mermaid +flowchart LR + T["执行 Tool"] --> I{"InvocationStatus"} + I -->|"ERROR"| X["技术失败路径"] + I -->|"READY"| E{"EvidenceStatus"} + E -->|"EVIDENCE_FOUND"| G["交给 Agent 判断信息增益"] + E -->|"NO_EVIDENCE"| N["记录限定 scope 的空事实
累计 NO_GAIN"] + N --> Q{"还值得继续查询吗?"} + Q -->|"值得"| R["调整假设或 scope"] + Q -->|"连续无增益"| P["CollectionState.SATURATED"] +``` + +类似的正常无结果还包括: + +- 用户没有提供企业、服务、时间范围等必要上下文; +- 多次查询都合法,但连续没有推进诊断; +- Agent 遵循协议主动输出 `conclusion=null`; +- 已完成有限检查,但证据只够描述现象,不够支持根因。 + +这些场景不应该伪装成系统异常。只要请求被正常处理并形成安全说明,就可以是: + +```text +RunState.SUCCESS + ReleaseOutcome.FALLBACK +``` + +### 为什么不把 NO_EVIDENCE 设计成异常 + +如果空结果抛异常,系统会产生三个问题: + +- Agent 无法区分“查询为空”和“查询服务不可用”; +- 监控会把正常业务分布统计成技术故障; +- Release 无法向用户解释已经检查的范围。 + +因此放弃了“Tool 无数据即失败”的简化方案,代价是状态维度增加,但换来了可解释的控制语义。 + +## 3. 第二类:技术异常可能可以重试 + +并非所有技术异常都应立即结束 Run。对于瞬时、明确、可能恢复的 Provider 故障,Harness 可以执行有限的显式重试。 + +当前重试策略遵循两个原则: + +```text +只有稳定分类为可恢复的技术失败才重试 +所有 attempt 都由 Harness 计数、计费并记录 +``` + +典型策略如下: + +| 组件 | 可重试场景 | 最大 attempt | 不重试场景 | +|---|---|---:|---| +| Intent Router | timeout、transport、非法输出 | 2 | 已得到合法路由结果 | +| SemanticGuard | timeout、transport、parse/schema failure | 2 | `UNSUPPORTED` | +| Diagnosis Agent | 不执行隐藏 retry | 1 个受控业务循环 | 正常下一轮 ReAct 不是 retry | +| Tool | 不执行隐藏 retry | 每个 Tool Call 一次 | Agent 可以基于结果选择其他 Tool | +| EvidenceRepair | 一次显式修复机会 | 1 | 不是无限修复循环 | + +```mermaid +sequenceDiagram + participant H as Harness + participant P as Router / Semantic Provider + participant B as Budget & Trace + + H->>B: reserve attempt #1 + H->>P: request #1 + P-->>H: timeout / transport / invalid output + H->>B: record classified failure + H->>B: reserve attempt #2 + H->>P: request #2 + alt 得到合法结果 + P-->>H: valid result + H->>B: record success + else 再次技术失败 + P-->>H: unavailable + H->>B: record final failure + H-->>H: 进入 Fallback 或 FAILED 决策 + end +``` + +### 为什么不使用 SDK 的默认隐藏重试 + +隐藏重试会让系统无法准确回答: + +- 这次请求实际调用了 Provider 几次; +- Token、deadline 和 attempt 消耗在哪里; +- Trace 中的一次调用为什么延迟异常; +- 客户端取消后是否还在后台继续重试。 + +所以重试必须是 Harness 的控制行为,而不是各组件自行决定。代价是需要维护失败分类和 attempt 协议,但预算与审计仍然闭合。 + +### `UNSUPPORTED` 为什么不重试 + +SemanticGuard 返回 `UNSUPPORTED`,表示它成功完成了判断,只是证据不支持报告。这是业务结果,不是技术不可用。重试同一份报告只是在要求模型重新投票,既不能创造新证据,也会削弱门禁的一致性。 + +## 4. 第三类:一个 Tool 失败,不等于整个 Run 失败 + +Diagnosis Agent 通常拥有多个只读 Tool。某个 Tool 出现 `ERROR` 后,Agent 可能仍然可以: + +- 改查另一个数据源; +- 缩小或调整查询范围; +- 使用已经取得的其他 canonical 事实; +- 明确说明某个数据源不可用,并结束为 Fallback。 + +```mermaid +flowchart TB + E["单个 Tool ERROR"] --> A{"Run 仍 active 且预算允许?"} + A -->|"否"| T["进入终止决策"] + A -->|"是"| O{"是否还有合法替代动作?"} + O -->|"换 Tool / 换 scope"| C["Agent 继续诊断"] + O -->|"没有"| P{"已有安全进展?"} + P -->|"有"| F["ReleaseOutcome.FALLBACK"] + P -->|"无"| X["ReleaseOutcome.FAILED"] + C --> D{"最终是否形成可发布报告?"} + D -->|"是"| S["ReleaseOutcome.SUCCESS"] + D -->|"否"| P +``` + +这里不能建立一条简单映射: + +```text +Tool ERROR -> RunState.FAILED +``` + +真正必须终止的情况,是错误破坏了 Harness 的安全前提,或者已经没有合法的恢复路径。例如: + +- canonical store 无法保存或回读 Tool 真相; +- Tool raw 或 Agent result 超过硬 bytes 上限; +- 路由经过允许的 attempts 后仍不可用; +- Agent 输出非法 Draft,且不存在可验证的 ProgressSnapshot; +- Harness 自身出现无法分类、无法形成安全响应的内部错误。 + +### 为什么不让 Tool 自己决定 Run 失败 + +Tool 只知道一次 invocation 是否成功,不知道整个诊断还拥有多少预算、其他 Tool 是否可用、是否已有安全事实,也不知道最终发布策略。让 Tool 抛出全局终止异常,会把局部职责扩大成 Run 决策权。 + +当前设计的代价是 Agent 和 Application 必须处理结构化 Tool 错误,而不是依赖异常一路冒泡;收益是局部故障不会无条件摧毁整次诊断。 + +## 5. 第四类:执行成功,发布仍可能被拒绝 + +Agent 完成 Draft 并不表示用户一定能看到这份报告。发布前还要经过两类门禁: + +```text +EvidenceGuard:引用是否真实、是否属于当前 Run、是否来自 READY invocation +SemanticGuard:这些真实证据是否支持用户可见结论 +``` + +```mermaid +flowchart LR + D["DiagnosisDraft"] --> E{"EvidenceGuard"} + E -->|"通过"| S{"SemanticGuard"} + E -->|"失败"| EF["EVIDENCE_VALIDATION_FAILED
SafeFallback"] + S -->|"SUPPORTED"| R["发布 Diagnosis Report"] + S -->|"UNSUPPORTED"| SU["SEMANTIC_UNSUPPORTED
SafeFallback"] + S -->|"技术不可用"| SA["SEMANTIC_UNAVAILABLE
SafeFallback"] +``` + +对应的典型结果是: + +| 发布门禁结果 | RunState | ReleaseOutcome | FallbackType | +|---|---|---|---| +| 两层门禁通过 | `SUCCESS` | `SUCCESS` | 无 | +| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` | +| SemanticGuard 判断不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` | +| SemanticGuard 技术不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` | + +这里最反直觉的一点是:Guard 拒绝发布原报告,通常仍然是 `RunState.SUCCESS`。因为 Harness 成功执行了安全策略,并向用户发布了诚实的降级结果;失败的是“原报告获得发布资格”,不是“控制系统无法完成请求”。 + +### 为什么 Guard 不能直接改写结论 + +项目放弃了让 Verifier 或 SemanticGuard 顺手生成“更正确答案”的方案。Guard 没有业务 Tool、完整 ReAct 上下文和重新取证能力,改写报告会让审查者同时成为证据生产者。 + +因此 Guard 只能批准或拒绝,Release 只能发布原报告或确定性 SafeFallback。代价是部分“看起来只差一点”的报告也会降级,但发布责任保持清晰。 + +## 6. 第五类:停止收集,不等于 Run 已终止 + +当连续查询没有信息增益,或 Agent 持续违反 progress 协议时,Harness 会把证据收集状态切换为: + +```text +CollectionState.SATURATED +``` + +可能的停止原因包括: + +```text +INFORMATION_SATURATED +PROGRESS_PROTOCOL_VIOLATED +BUDGET_LIMIT_REACHED +``` + +`SATURATED` 只表示不再允许新的 evidence Tool,不是 Run 终态。Harness 仍然要给 Agent 一次机会输出 Draft,或者根据 canonical records 投影 `ProgressSnapshot`。 + +```mermaid +stateDiagram-v2 + [*] --> COLLECTING + COLLECTING --> COLLECTING: GAINED + COLLECTING --> COLLECTING: NO_GAIN / 未达阈值 + COLLECTING --> SATURATED: 连续 NO_GAIN 或协议违规 + SATURATED --> DRAFTING: 交付一次 STOP_REQUIRED + DRAFTING --> RELEASE: Agent 正常结束 + DRAFTING --> TERMINATED: Agent 再次请求 Tool + RELEASE --> [*] + TERMINATED --> [*] +``` + +这项区分解决了一个早期问题:系统过去只能依靠预算把空转“撞停”,最终把证据不足表达成 `BUDGET_EXHAUSTED` 或内部失败。引入 Collection 状态后,业务收敛可以发生在硬资源终止之前。 + +## 7. 第六类:超时、预算和取消是三种不同终止 + +Run 的终态只有: + +```text +RUNNING +SUCCESS +FAILED +CANCELLED +TIMED_OUT +BUDGET_EXHAUSTED +``` + +`RunLifecycle` 使用 first-terminal-wins:第一个成功设置的终态不可被后来返回的 Provider、Tool 或异步回调覆盖。 + +### Deadline 到期 + +deadline 回答“这次 Run 还能否继续占用时间”。到期后终态是 `TIMED_OUT`。如果没有可发布的安全内容,Release 为 `FAILED`;当前实现不会为了美化结果在超时后继续调用模型生成说明。 + +### Budget 耗尽 + +预算可能限制模型 attempt、Tool 调用、Token 或 bytes。终态是 `BUDGET_EXHAUSTED`,但发布结果取决于是否已有安全进展: + +```text +有 ProgressSnapshot -> ReleaseOutcome.FALLBACK +没有安全进展 -> ReleaseOutcome.FAILED +``` + +这说明 `RunState` 和 `ReleaseOutcome` 不能一一对应。 + +### 客户端断开 + +客户端断开意味着公开通道已经不存在,Run 转为 `CANCELLED`。取消是协作式的:已经发出的同步 Provider 调用可能无法被物理中止,但返回后必须经过 active check 和 SSE state 检查,迟到结果不能再发布。 + +```mermaid +sequenceDiagram + participant C as Client + participant S as ChatSseSession + participant H as Harness Core + participant P as Provider / Tool + + H->>P: 已发出的同步调用 + C--xS: 断开连接 + S->>H: cancel Run + H->>H: first terminal = CANCELLED + P-->>H: 迟到结果 + H->>H: checkActive 拒绝继续 + H-->>S: 不发送 content / done + S->>S: DISCONNECTED 拒绝迟到事件 +``` + +客户端断开时不可靠地发送 `done(CANCELLED)`,因为连接已经不可用。取消事实应由服务端 Trace 和 Run 状态观察,而不是假设客户端还能收到终止事件。 + +## 8. “安全进展”决定 FALLBACK 还是 FAILED + +停止之后,系统不能把 Agent 的未验证草稿直接当作降级内容。所谓安全进展,必须来自当前 Run 的 canonical READY records,并经过有界投影。 + +`ProgressSnapshot` 可以包含: + +- 已实际执行的查询 scope; +- 可验证的 observed facts; +- 限定范围内的 `NO_EVIDENCE`; +- 数据源不可用、结果截断或投影失败等 limitations; +- 推荐补充的上下文或下一步检查。 + +它不能包含未经支持的根因结论。 + +```mermaid +flowchart TD + T["Run 无法继续或 Agent 无结论"] --> P["从 canonical records
构建 ProgressSnapshot"] + P --> V{"存在可验证的 observed facts?"} + V -->|"有"| F["FALLBACK
说明已检查内容、限制和下一步"] + V -->|"没有"| M{"是否明确缺少必要上下文?"} + M -->|"是"| C["FALLBACK
MISSING_REQUIRED_CONTEXT"] + M -->|"否"| E["FAILED
不公开未验证内容"] +``` + +因此,下面两次预算耗尽可以有不同结果: + +| 场景 | RunState | ReleaseOutcome | 用户看到什么 | +|---|---|---|---| +| 已验证支付服务在目标时段无日志,随后预算耗尽 | `BUDGET_EXHAUSTED` | `FALLBACK` | 已检查范围、空结果限制和下一步 | +| 第一次模型调用前预算检查失败,没有任何安全事实 | `BUDGET_EXHAUSTED` | `FAILED` | failure | + +### 为什么不为所有失败生成一段“友好回答” + +如果失败后再调用模型组织解释,会继续消耗已经耗尽的预算,也可能根据异常文本编造业务结论。确定性模板虽然表达能力有限,但不会把未知包装成答案。 + +这项设计选择了 fail closed:有安全事实才降级,没有就失败。代价是用户体验不总是“自然语言很完整”,但不会为了完整感牺牲可信度。 + +## 9. 同一个结果,要从四个视图理解 + +Harness 没有一条能容纳全部语义的总状态机。一次请求结束后,至少要区分四个观察面: + +```mermaid +flowchart LR + R["RunState
执行为什么停止"] --> X["一次请求"] + O["ReleaseOutcome
最终公开什么"] --> X + D["diagnosis_run.status
请求是否被安全处理"] --> X + S["SSE 事件
客户端实际收到什么"] --> X +``` + +### RunState:执行为什么停止 + +它由 Harness Core 拥有,表达正常完成、内部失败、取消、超时或预算耗尽。 + +### ReleaseOutcome:最终公开什么 + +```text +SUCCESS / FALLBACK / FAILED / CANCELLED +``` + +它由 Release 决定,表达正常诊断报告、安全降级、失败或取消。 + +### 数据库 status:请求是否被安全处理 + +`JpaChatRunStore` 当前映射为: + +| ReleaseOutcome | diagnosis_run.status | +|---|---| +| `SUCCESS` | `SUCCESS` | +| `FALLBACK` | `SUCCESS` | +| `FAILED` | `FAILED` | +| `CANCELLED` | `CANCELLED` | + +因此: + +```text +status=SUCCESS + release_outcome=FALLBACK +``` + +表示请求被正常、安全地处理并发布了降级内容,不表示系统找到了根因。 + +只有 `DIAGNOSIS + ReleaseOutcome.SUCCESS + published_result` 才能进入下一轮 `PreviousTurn`。Fallback 不进入后续上下文,避免把“证据不足”当作已确认结论继续传播。 + +### SSE:客户端实际收到什么 + +公开事件顺序是: + +```text +metadata -> status* -> content | failure -> done +``` + +- `content` 最多一次; +- `content` 与 `failure` 互斥; +- `TERMINAL / DISCONNECTED` 后拒绝迟到结果; +- 当前 `done` 使用 `ReleaseOutcome`,不是另一套遗留终态。 + +四个视图回答不同问题,排障时不能拿数据库 `SUCCESS` 推断用户收到了一份成功诊断报告。 + +## 10. 典型场景总表 + +| 场景 | RunState | ReleaseOutcome | 公开内容或 FallbackType | +|---|---|---|---| +| EvidenceGuard、SemanticGuard 全部通过 | `SUCCESS` | `SUCCESS` | Diagnosis report | +| 缺少企业、服务、时间等必要上下文 | `SUCCESS` | `FALLBACK` | `MISSING_REQUIRED_CONTEXT` | +| 有限检查后仍然证据不足 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` | +| 信息饱和且有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` | +| Progress 协议停止且有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` | +| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` | +| SemanticGuard 判断不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` | +| SemanticGuard attempts 后不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` | +| 预算耗尽但有安全进展 | `BUDGET_EXHAUSTED` | `FALLBACK` | 当前发布为 `INSUFFICIENT_EVIDENCE` | +| 超时且没有安全内容 | `TIMED_OUT` | `FAILED` | failure | +| 预算耗尽且没有安全进展 | `BUDGET_EXHAUSTED` | `FAILED` | failure | +| 不可恢复内部失败 | `FAILED` | `FAILED` | failure | +| 客户端断开 | `CANCELLED` | `CANCELLED` | 不可靠发送 `done` | + +这张表不是一个双向转换规则。例如看到 `ReleaseOutcome.FALLBACK`,不能单独推断 Run 是正常完成还是预算耗尽;仍要结合 Run termination 和 Trace。 + +## 11. 排障时按什么顺序看 + +面对“用户为什么没有看到诊断结论”,不要先搜索所有异常日志。按发布结果向前追踪更容易定位: + +```mermaid +flowchart LR + U["用户实际收到的 SSE"] --> O["release_outcome
fallback_type"] + O --> R["Run termination
deadline / budget / cancel"] + O --> G["Evidence / Semantic
Release Trace"] + R --> T["Tool Invocation
Progress / canonical record"] + G --> T +``` + +推荐顺序: + +1. 看 SSE 是 `content`、`failure`,还是连接提前断开。 +2. 看 `release_outcome` 和 `FallbackType`,确认是正常报告、降级还是失败。 +3. 看 Run termination,区分正常完成、超时、预算耗尽和取消。 +4. 看 Release、EvidenceGuard、SemanticGuard Trace,确认原报告为什么不能发布。 +5. 看 Tool 的 `InvocationStatus + EvidenceStatus`,不要只看一个 `success`。 +6. 最后查看 canonical record、scope、bytes、budget usage 和 progress stop reason。 + +这个顺序从“用户看到什么”追到“内部为什么这样决定”,比从第一条异常开始阅读整条 Timeline 更容易建立因果关系。 + +## 12. 这套设计放弃了哪些更简单的方案 + +### 一个 `status` 表示一切 + +放弃原因:`SUCCESS` 无法同时表达 Tool 执行、Run 终止、报告发布和数据库处理结果。单状态简单,但必然丢失原因。 + +### 任意异常直接终止整个 Run + +放弃原因:局部 Tool 故障仍可能有替代路径;空结果也不是异常。这样做会降低系统可用性并掩盖有限检查的价值。 + +### 所有错误都自动重试 + +放弃原因:业务拒绝、容量上限、非法输入和证据不支持不会因为重试自动恢复;隐藏重试还会破坏预算和审计。 + +### Agent 自己决定何时降级 + +放弃原因:Agent 无法读取 canonical truth,也不能验证自己的引用。让它同时生成结论和批准发布,会形成自证循环。 + +### 失败后继续调用模型润色 Fallback + +放弃原因:终止后继续消耗资源,且无法保证新文本只包含已验证事实。当前使用确定性 Release Policy 和安全模板。 + +### 取消时强制等待所有底层调用结束 + +放弃原因:同步 Provider 未必支持真正中断,等待会延长资源占用。当前采用协作式取消、active check、first-terminal-wins 和 SSE 状态拒绝迟到发布。 + +## 13. 面试时如何讲这套失败设计 + +可以用下面这段话概括: + +> 我们没有把 Harness 的失败处理设计成一个全局异常捕获器,因为 Agent 系统里“没有证据、单个 Tool 失败、证据不支持结论、预算耗尽和客户端取消”代表完全不同的控制语义。系统先判断 Run 是否还能继续,再从当前 Run 的 canonical records 判断是否已有可验证进展,最后由唯一的 Release Policy 决定发布正常报告、SafeFallback 还是 failure。Tool 使用 InvocationStatus 和 EvidenceStatus 区分技术失败与正常空结果;RunState 解释执行为什么停止,ReleaseOutcome 解释用户最终看到什么,两者不做一一映射。可恢复的 Provider 故障只允许 Harness 做有限、可审计的显式重试,Guard 拒绝发布通常降级而不是把整次请求标成失败,超时、预算和取消则通过 first-terminal-wins 与 SSE 状态阻止迟到结果。这样做的目标不是让每次诊断都成功,而是保证任何结束方式都可解释、可审计,并且不会把未经验证的内容发布给用户。 + +这段回答要表达的不是“系统定义了很多状态”,而是:**每个状态都对应不同的决策权和失败责任。** + +## 14. 当前边界与代价 + +1. 多套正交状态提高了准确性,也增加了学习成本,必须通过 Context、映射表和 Trace 查询规范维持统一口径。 +2. 内存中的 `RunTermination.state/reason` 当前没有完整独立持久化;精确判断 `TIMED_OUT / BUDGET_EXHAUSTED` 仍需结合 Trace、异常路径和预算记录。 +3. 协作式取消能阻止迟到发布,但不保证立即终止已经发出的同步 Provider 计算。 +4. Tool ERROR 后是否继续由 Agent 在 Harness 门禁内选择,因此模型可能做出次优恢复动作;硬预算和停止协议负责限制损失。 +5. 有安全进展才能 Fallback 的策略会让部分请求直接失败,但这是避免发布未验证内容的主动取舍。 +6. `FallbackType` 枚举仍保留 `BUDGET_EXHAUSTED`,但当前预算受控停止实际统一发布 `INSUFFICIENT_EVIDENCE`;这是已知命名债务。未来若要区分资源不足与业务证据不足,需要先明确对外语义和兼容策略,不能只替换当前映射。 + +## 15. 事实来源与延伸阅读 + +本文对应的主要实现边界: + +- `ChatApplicationUseCase`:Application 生命周期、Router、Run 终止和 Release 协作; +- `DiagnosisHarnessCore`:Run active check、预算、终态与控制边界; +- `DiagnosisAgentUseCase`:Tool loop、受控停止和 Draft 形成; +- `DiagnosisReleaseUseCase`:EvidenceGuard、SemanticGuard 与唯一发布策略; +- `JpaChatRunStore`:ReleaseOutcome 到数据库 status 的映射; +- `ChatSseSession`:SSE 单内容、终态和断开规则。 + +继续阅读: + +- [Harness 入门](README.md) +- [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) +- [Harness 生命周期与状态](Harness生命周期与状态.md) +- [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) +- [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) +- [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) diff --git a/mvp/engineering/harness/Harness设计演进-从多Agent编排到确定性控制边界.md b/mvp/engineering/harness/Harness设计演进-从多Agent编排到确定性控制边界.md new file mode 100644 index 0000000..b91b706 --- /dev/null +++ b/mvp/engineering/harness/Harness设计演进-从多Agent编排到确定性控制边界.md @@ -0,0 +1,371 @@ +# Harness 设计演进:从多 Agent 编排到确定性控制边界 + +Harness 不是一开始就被完整设计出来的。它来自几轮真实重构:系统先拆出多个 Agent 角色,又增加 Gatekeeper 保证证据真实性,随后用 StateGraph 显式管理状态和分支,最终才发现一个更根本的问题:**外层系统正在重复实现 Agent 本身已经具备的 ReAct 生命周期。** + +这篇文章不按提交逐条记流水账,而是追踪每一次设计变化背后的问题:当时为什么这样做、它解决了什么、为什么后来仍然不够,以及哪些思想最终保留了下来。 + +第一次阅读只看第 1、2、6、8 和 10 节即可。先建立演进主线,再理解最终边界和可复用经验。 + +## 1. 先看完整演进路线 + +```mermaid +flowchart LR + M["多 Agent 分工
Planner / Executor / Verifier / Composer"] + G["Gatekeeper
在模型审查前机械验真"] + S["StateGraph
显式状态、条件边和终态"] + H["Single ReAct + Harness
推理与控制分离"] + P["Progress Control
从预算止损到正常收敛"] + + M -->|"证据引用可能伪造"| G + G -->|"状态藏在 Service、Hook 和 ThreadLocal"| S + S -->|"显式了编排,但仍重复 ReAct"| H + H -->|"预算能止损,不能判断继续是否有价值"| P +``` + +这几次变化不是简单地“旧方案错、新方案对”。每一阶段都解决了当时最明显的问题,同时也让下一个更深层的问题暴露出来。 + +| 阶段 | 当时解决的核心问题 | 后来暴露的核心问题 | +|---|---|---| +| 多 Agent | 复杂诊断如何分工 | 一次 ReAct 被拆成多个模型角色和协议 | +| Gatekeeper | 如何阻止伪造证据引用 | 验真依赖旧输出结构、Trace 和隐式上下文 | +| StateGraph | 如何显式表达状态、重试和 Fallback | Graph 仍在编排多个重复的推理角色 | +| Single ReAct + Harness | 如何分离业务推理与确定性控制 | Agent 仍可能在证据不足时空转 | +| Progress Control | 如何让无证据诊断正常停止 | 阈值和信息增益仍需持续校准 | + +## 2. 第一阶段:把复杂诊断拆成多个 Agent + +项目早期采用过 Supervisor 和 Sequential 两类多 Agent 编排。Chat 诊断最终形成了一条固定链路: + +```mermaid +flowchart LR + Q["用户问题"] --> P["Planner
制定排查计划"] + P --> E["Executor
调用 Tool 收集证据"] + E --> G["Gatekeeper
检查证据引用"] + G --> V["Verifier
判断 Claim 是否可信"] + V --> C["Composer
组织最终回答"] +``` + +这个方案有合理动机:复杂诊断既要规划、执行,又要验证和表达,把职责拆开比让一个 Prompt 包办所有事情更容易理解。 + +它也确实建立了几项重要能力: + +- Planner 不直接编造执行结果; +- Executor 专注 Tool 调用和微观事实; +- Verifier 不再负责重新检索; +- Composer 只能表达经过允许的 Claim; +- 每个角色都有自己的结构化输出和测试入口。 + +问题出在拆分粒度。Planner、Executor、Verifier 和 Composer 看似是不同业务岗位,实际上刚好覆盖了一次完整 ReAct: + +```text +思考 -> Planner +行动观察 -> Executor + Tool +自我检查 -> Verifier +最终回答 -> Composer +``` + +Agent 框架本来已经支持“思考、调用 Tool、读取 Observation、继续推理、生成答案”。外层再把它拆成四个 Agent 后,系统必须额外维护: + +- 四套 Prompt 和输出 Schema; +- Agent 间的 JSON 转换; +- 证据和上下文的重复搬运; +- PASS、LOW_CONFID、REJECT 与重试分支; +- 每个角色各自的 Token、timeout 和错误语义; +- Composer 是否严格遵守 Verifier 输出的新风险。 + +第一阶段真正留下的经验不是“多 Agent 一定不好”,而是: + +> 只有当角色拥有不同数据权限、不同工具或真正独立的业务目标时,拆成多个 Agent 才可能值得。仅仅把一次 ReAct 的内部步骤外置成多个角色,会放大协议成本。 + +## 3. 第二阶段:Gatekeeper 把确定性验真从模型中拿出来 + +多 Agent 链路很快遇到另一个问题:Verifier 可以判断一段 evidence excerpt 看起来是否支持 Claim,却不能证明 `source_invocation_id`、`raw_path` 和 excerpt 真正对应某次 Tool 调用。 + +如果仍然让模型检查这些字段,就会出现“模型验证模型”的循环。于是系统在 Executor 和 Verifier 之间加入 `ExecutorGatekeeperService`: + +```mermaid +flowchart LR + E["Executor 输出引用"] --> G["Gatekeeper
查 Tool Invocation 和 raw path"] + G -->|"真实"| V["Verifier
判断语义支持关系"] + G -->|"伪造或错配"| R["拒绝进入 Verifier"] +``` + +这是演进中一个非常重要、并且最终被保留的决策: + +```text +代码能够机械证明的事实,不交给模型判断。 +``` + +Gatekeeper 能拒绝伪造 invocation、错误 raw path 和不匹配的 evidence excerpt,使“引用真实”和“语义成立”第一次成为两个独立问题。 + +但旧 Gatekeeper 仍然耦合在多 Agent 协议上: + +- 它读取 Executor 特定的 `executor_evidence_v2`; +- 引用协议包含 `source_invocation_id + raw_path + excerpt`; +- Verifier 输入依赖 Hook 组装; +- Tool Trace 和 Agent 上下文通过 ThreadLocal 等隐式状态关联; +- 它只能保护旧 Executor 到 Verifier 的这一段链路。 + +后来的 `EvidenceGuard` 不是凭空出现的。它继承了 Gatekeeper 的核心思想,但把真理源改为当前 Run 的 canonical Tool Invocation,并把验证对象改为最终 `DiagnosisDraft`。 + +## 4. 第三阶段:StateGraph 让隐式编排变得可见 + +随着重试、低置信分支、Fallback、Run Trace 和多个 Agent 输出不断增加,旧 `ChatService + SequentialAgent + Hook + ThreadLocal` 很难回答一个简单问题:**当前诊断到底处于哪个状态,下一步为什么走这条分支?** + +2026-07-17 到 2026-07-20,项目完成了一轮 StateGraph 改造。它将节点、条件边、共享状态和终态显式化,并切换公开 Chat 诊断入口。 + +```mermaid +flowchart LR + P["Planner Node"] --> E["Executor Node"] + E --> G["Gatekeeper Node"] + G --> V["Verifier Node"] + V -->|"PASS"| C["Composer Node"] + V -->|"补证据"| RP["Evidence Retry Prepare"] + RP --> P + V -->|"拒绝"| F["Fallback Node"] +``` + +StateGraph 解决了几个真实问题: + +- 分支不再隐藏在大段 Service `if/else` 中; +- Graph State 显式携带 Run 级数据; +- Node 和条件边可以独立测试; +- Fallback 和 retry 路径可以画出来并验证; +- 旧 `VerifierContextHolder`、部分 Hook 和 ThreadLocal 状态得以清理; +- `ChatService` 从直接拥有全部诊断细节转为调用 Graph Runtime。 + +因此,StateGraph 不是一次无效重构。它提高了旧多 Agent 架构的可见性和可测试性。 + +但它解决的是“怎样更清楚地编排这些角色”,没有重新质疑“这些角色是否都应该存在”。结果是: + +- Planner、Executor、Verifier、Composer 仍然各自调用模型; +- Graph State 继续搬运多个角色的结构化上下文; +- Node Adapter、Result Mapper、条件边和业务协议形成第二套控制结构; +- 外层 Graph 决定何时计划、执行、补证据和回答,而框架内 Agent 也在做相似的 ReAct 控制; +- 状态机显式了复杂度,却没有消除复杂度。 + +这一阶段带来的关键认识是: + +> 显式状态机能够治理复杂流程,但不能证明流程本身有必要。如果核心流程只是一个 Agent 的自然 ReAct,Graph 可能只是把重复实现变得更整齐。 + +## 5. 转折点:问题不是编排写得不好,而是重复实现了 ReAct + +ISS-014 对旧架构做了一个更根本的判断:Planner、Executor、Verifier、Composer 并不是四个真正独立的业务主体,而是把一个完整 ReAct 生命周期拆到了 Agent 外部。 + +这使设计问题从: + +```text +怎样把多 Agent 编排得更清楚? +``` + +转变为: + +```text +哪些决定必须由模型做,哪些约束必须由代码拥有? +``` + +这个问题带来了新的职责划分: + +| 决定 | 所有者 | +|---|---| +| 提出故障假设、选择 Tool、解释证据、写 Draft | Diagnosis Agent | +| Run 身份、deadline、预算、取消、retry、唯一终态 | Harness Core | +| Tool 权限、只读、bytes、canonical truth | ToolBoundary | +| 引用是否真实 | EvidenceGuard | +| 真实证据是否支持报告 | 隔离的 SemanticGuard | +| 发布原 Draft 还是 SafeFallback | Release | + +这不是把所有能力重新塞回一个“大 Agent”。相反,它按**判断性质**而不是按“岗位名称”拆分: + +- 非确定性的业务推理留给 Agent; +- 可以机械证明的控制规则交给代码; +- 必须使用模型的语义审查被隔离成单轮、无 Tool、无记忆的 Guard。 + +## 6. 第四阶段:一个 Diagnosis Agent,加一层 Harness + +最终架构只保留一个拥有业务 Tool loop 的 Diagnosis Agent: + +```mermaid +flowchart TB + U["用户问题"] --> APP["Chat Application"] + APP --> H["Harness Core
Run、预算、取消、终态"] + H --> A["Diagnosis ReAct Agent
假设、Tool、Observation、Draft"] + A --> TB["ToolBoundary
执行与 canonical truth"] + TB --> A + A --> EG["EvidenceGuard
确定性引用验真"] + EG --> SG["SemanticGuard
隔离语义审查"] + SG --> REL["Release
SUCCESS 或 SafeFallback"] +``` + +这里做了几项明确取舍。 + +### 不再保留业务 StateGraph + +项目不再用外层 Graph 编排 Planner、Executor、Verifier 和 Composer。底层 ReactAgent 框架内部是否使用 Graph 属于框架实现细节,不再成为项目业务协议。 + +### 不自己重写 ReAct loop + +Diagnosis Agent 使用框架原生 Tool Calling 和 ReAct。Harness 通过 Model/Tool Interceptor、Hook 和显式 RunContext 接入,不维护第二套 `while` 循环。 + +### 不让单 Agent 获得全部权力 + +Agent 合并的是业务推理职责,不是安全职责。它不能管理预算、读取 canonical raw、验证自己引用、调用 SemanticGuard 或决定最终发布。 + +### SemanticGuard 不是第二个业务 Agent + +它只接收原始问题、Draft 的用户可见语义和 verified evidence,输出 `SUPPORTED / UNSUPPORTED`。它无 Tool、无记忆、不回调主 Agent,也不能改写报告。 + +### 迁移按边界而不是按页面完成 + +实施顺序先冻结 Contract,再建立 RunContext/Retry Core、Tool Boundary、各类投影、单 Diagnosis Agent、Evidence/Semantic Guard,最后切换 Application/SSE 并删除旧架构。这避免了“先切入口,再补安全边界”的过渡风险。 + +## 7. Harness 建成后,问题继续暴露 + +单 Agent + Harness 解决了外层重复编排,但真实运行又暴露了几类更细的问题。 + +### Tool 成功不等于有证据 + +旧代码常用一个 `success` 表达所有含义。后来拆为: + +```text +InvocationStatus:Tool 调用是否完成 +EvidenceStatus:当前 scope 是否返回候选证据 +SemanticVerdict:证据是否支持报告 +ReleaseOutcome:最终向用户发布什么 +``` + +状态变多不是为了复杂,而是为了避免 `SUCCESS` 在四层中表达四种不同意思。 + +### Agent Observation 不能充当真理源 + +Agent 需要的是有界、清洗后的 Tool 结果;EvidenceGuard 需要的是独立、可回读的调用真相;长期 Audit 又不能复制全部敏感 raw。于是形成 canonical truth、Model Observation 和 metadata audit 三层数据责任。 + +### 引用真实不等于结论成立 + +Gatekeeper 思想被升级为 EvidenceGuard,但仅验引用仍不足以拦截“真实日志被过度解释”。因此保留隔离的 SemanticGuard,并由 Release 掌握唯一出口。 + +### 隐藏 retry 会破坏预算和审计 + +SDK、HTTP Client 和各模型组件各自重试会让 Token、延迟和 attempt 无法解释。最终只允许 Harness 根据稳定失败分类执行显式 retry;正常 ReAct 下一轮和重新调用 Tool 都不叫 retry。 + +## 8. 第五阶段:预算能止损,但不能让诊断正常完成 + +单 Agent 运行后又出现一个问题:当知识库未知、日志为空或查询条件不足时,预算只能限制最大调用次数,不能判断继续搜索是否还有价值。 + +模型可能不断改写查询,直到: + +```text +BUDGET_EXHAUSTED +或 INTERNAL_FAILURE +``` + +用户最终只看到通用错误,却不知道系统已经检查了什么、为什么没有结论。 + +于是 Harness 增加 ProgressTracker 和信息增益停止协议: + +```mermaid +flowchart LR + T["Tool Result"] --> I{"是否推进当前诊断?"} + I -->|"GAINED"| C["继续收集"] + I -->|"NO_GAIN"| N["连续无增益计数"] + N -->|"未达阈值"| C + N -->|"达到阈值"| S["SATURATED / STOP_REQUIRED"] + S --> P["ProgressSnapshot"] + P --> F["INSUFFICIENT_EVIDENCE Fallback"] +``` + +这次演进补上了资源控制与任务完成之间的差距: + +- Budget 回答“还能不能继续消耗”; +- Information Gain 回答“继续查询是否推进诊断”; +- ProgressSnapshot 回答“没有结论时,哪些已检查事实仍可安全告诉用户”。 + +最重要的行为变化是:**证据不足成为合法完成,而不是只能撞到预算后失败。** + +## 9. 哪些设计被放弃,哪些思想被保留 + +| 曾经的设计 | 最终处理 | 保留下来的思想 | +|---|---|---| +| Supervisor 调度多个诊断角色 | Chat 主链不再使用 | 复杂任务需要清晰职责边界 | +| Planner / Executor / Verifier / Composer | 合并业务推理到一个 Diagnosis Agent | 规划、执行、审查、表达仍需明确责任,只是不必都是 Agent | +| Executor Gatekeeper | 旧实现删除 | 确定性验真先于语义审查,演化为 EvidenceGuard | +| SequentialAgent | 删除 | 固定业务步骤必须可测试、可观测 | +| 业务 StateGraph | 删除 | 状态和终态必须显式,转化为 typed contracts、RunLifecycle 和 Release | +| ThreadLocal 上下文 | 删除 | exact Run 归属仍必须传播,改为显式 RunContext | +| PASS / LOW_CONFID / REJECT + 补证据循环 | 删除 | 不支持的结论不能发布,改为二元语义门禁和确定性 Fallback | +| Tool raw 直接参与上下文与审计 | 分层 | Tool 结果必须可追溯,但不同消费者使用不同视图 | + +好的重构通常不是把过去全部推翻,而是把有效思想从不合适的实现形式中提取出来。 + +## 10. 这段演进真正说明了什么 + +Harness 最终形成,不是因为团队一开始就知道所有组件,而是逐步回答了四个问题: + +1. **业务推理应该由谁负责?** 一个完整的 Diagnosis ReAct Agent。 +2. **哪些约束不能依赖 Prompt?** 身份、预算、取消、权限、容量、验真和唯一发布。 +3. **哪些模型判断必须隔离?** 证据是否支持用户可见报告的 SemanticGuard。 +4. **证据不足怎样成为正常结果?** ProgressTracker、ProgressSnapshot 和 SafeFallback。 + +最终边界可以浓缩为: + +```mermaid +flowchart LR + B["需要理解业务语义和提出假设"] --> A["交给 Diagnosis Agent"] + M["能够由代码机械证明"] --> H["交给 Harness"] + S["必须使用模型但不能拥有业务循环"] --> G["交给隔离 Guard"] + O["决定什么可以公开"] --> R["只交给 Release"] +``` + +这也是本项目对 Agent 系统最核心的工程判断: + +> 不要围绕模型的“角色感”设计系统,而要围绕决策权、真理源和失败责任设计边界。 + +## 11. 这套演进的代价和未完成问题 + +当前方案不是没有代价: + +- Harness 类型和状态较多,需要统一 Context 防止误读; +- canonical store 引入 Redis TTL、容量和访问控制成本; +- EvidenceGuard 与 SemanticGuard 增加发布延迟; +- 信息增益依赖模型对非空结果的二元评价,仍可能误判; +- `NO_GAIN` 阈值、Token 和 timeout 需要根据 Trace 持续校准; +- 当前 Mock 日志和未配置的业务 MySQL 数据源限制了真实诊断覆盖面。 + +但这些复杂度与旧多 Agent/Graph 的复杂度性质不同:旧复杂度主要用于搬运推理过程,当前复杂度主要用于保护身份、资源、事实和发布边界。前者会随角色数增长,后者围绕稳定的不变量增长。 + +## 12. 面试时如何讲这段演进 + +可以用下面这段话概括: + +> 项目最初把复杂诊断拆成 Planner、Executor、Verifier 和 Composer,并通过 Gatekeeper 验证证据引用;为了治理 Service、Hook 和 ThreadLocal 中的隐式分支,又引入 StateGraph 显式管理状态和终态。Graph 提高了可测试性,但没有解决根问题:外层仍在重复框架已有的 ReAct 生命周期,并产生多套 Prompt、Schema、重试和上下文搬运。后来我们按决策性质重新划分责任,只保留一个拥有 Tool loop 的 Diagnosis Agent,把 Run、预算、取消、Tool 真相、证据验真和发布权放进确定性 Harness,语义审查则隔离成无 Tool、无记忆的单轮 Guard。最后又通过信息增益协议,让证据不足从预算失败变成可解释的正常 Fallback。 + +这段回答的重点不是“我们用了哪些框架”,而是展示:系统怎样从症状修补逐步走到责任边界重构。 + +## 13. 事实来源与延伸阅读 + +关键演进节点可由 Git 提交确认: + +| 日期 | 代表提交 | 含义 | +|---|---|---| +| 2026-07-03 | `f01866c` | Chat 切换 Sequential Agent | +| 2026-07-08 | `c5e496e`、`1b31e78`、`6015bcb` | Gatekeeper、Verifier、Composer 完成 | +| 2026-07-17 | `581daff`、`99e490f` | StateGraph 设计冻结并切换 Chat | +| 2026-07-20 | `190013c` | StateGraph 清理与验收完成 | +| 2026-07-21 | `58c3910` 至 `f8809cb` | Single ReAct、Harness、Tool、Guard 和 Application 分阶段落地 | +| 2026-07-22 | `8ee7cc0` | 删除旧 Agent 架构 | +| 2026-07-27 | `d045218`、`38f781b` | 信息增益停止与协议修复完成 | + +主要历史资料: + +- `mvp/architecture/archive/2026-07-22-legacy/agent-orchestration.md`; +- `mvp/issues/archived/ISS-014-single-react-agent-harness-aci-ptk-refactor.md`; +- `devflow/projects/2026-07-21-single-react-design-freeze/decisions.md`; +- `openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`。 + +继续阅读: + +- [Harness 入门](README.md) +- [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) +- [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) +- [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) +- [组件渐进式导读](components/README.md) diff --git a/mvp/engineering/harness/Harness面试速查-一张图讲清设计.md b/mvp/engineering/harness/Harness面试速查-一张图讲清设计.md new file mode 100644 index 0000000..34a88ca --- /dev/null +++ b/mvp/engineering/harness/Harness面试速查-一张图讲清设计.md @@ -0,0 +1,304 @@ +# Harness 面试速查:用一张图讲清设计 + +这篇文章是 Harness 系列的收尾,不增加新的组件和状态。它把现有设计压缩成一套可在面试中逐层展开的叙事:先用一句话定义,再用一张图说明边界,最后根据追问进入事实、停止、发布和失败设计。 + +如果只剩 10 分钟,阅读第 1、2、4 和 8 节即可。 + +## 1. 30 秒回答:什么是 Harness + +> Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。 + +这段回答包含三个重点: + +```text +Agent 负责业务推理 +Harness 负责确定性约束 +Release 决定什么可以公开 +``` + +不要一开始列 10 个职责域。面试官追问“具体怎么做”时,再沿下面的总图展开。 + +## 2. 一张图讲清完整设计 + +```mermaid +flowchart LR + U["用户问题"] --> APP["Chat Application
创建 Run、路由、持久化、SSE"] + + subgraph CONTROL["一、运行控制"] + CORE["Harness Core
identity / deadline / budget
cancel / lifecycle / retry"] + PROGRESS["Progress Control
重复、信息增益、停止"] + end + + subgraph REASONING["二、业务推理"] + AGENT["Diagnosis ReAct Agent
假设、选 Tool、解释 Observation、写 Draft"] + end + + subgraph TRUTH["三、事实边界"] + TB["ToolBoundary
授权、只读、容量、状态迁移"] + CAN["Canonical Invocation
当前 Run 的短期完整真相"] + OBS["Model Observation
模型可见的有界投影"] + AUDIT["Metadata Audit
长期可观测账本"] + end + + subgraph PUBLICATION["四、验证发布"] + EG["EvidenceGuard
引用是否真实"] + SG["SemanticGuard
证据是否支持结论"] + REL["Release
原报告或 SafeFallback"] + end + + APP --> CORE + APP --> AGENT + CORE -.->|"RunContext 控制句柄"| AGENT + CORE -.->|"active / budget 门禁"| TB + AGENT --> PROGRESS + AGENT -->|"Tool Call"| TB + TB --> CAN + CAN --> OBS --> AGENT + TB -.-> AUDIT + AGENT -->|"DiagnosisDraft"| REL + REL --> EG + CAN --> EG --> SG --> REL + PROGRESS -->|"无结论或受控停止"| REL + REL --> APP --> U +``` + +这张图不表示 Harness 替 Agent 安排固定步骤。Agent 自己决定查什么、何时形成 Draft;Harness 只在每个边界回答: + +- 这一步是否属于当前 active Run; +- 是否仍有时间、预算和调用权限; +- Tool 事实应该保存在哪里,模型可以看到多少; +- Agent 的引用能否从当前 Run 的真实调用中验出; +- 现有证据是否足以支持对用户公开的结论; +- 无法继续时,应该发布安全说明还是失败。 + +## 3. 一次请求怎样穿过 Harness + +```mermaid +sequenceDiagram + participant U as Client + participant APP as Chat Application + participant CORE as Harness Core + participant A as Diagnosis Agent + participant I as Tool Interceptor + participant T as ToolBoundary + participant C as Canonical Store + participant R as Release Pipeline + + U->>APP: 支付服务为什么超时? + APP->>CORE: startRun(sessionId) + CORE-->>APP: RunContext(runId, deadline, handles) + APP->>A: query + bounded PreviousTurn + + loop 框架原生 ReAct + A->>I: Tool Call Envelope + I->>I: progress / duplicate / saturation + I->>T: business input + RunContext + T->>T: active / auth / readonly / budget / bytes + T->>C: PROJECTING -> READY or ERROR + T-->>I: canonical projected result + I-->>A: 有界 Model Observation + end + + A-->>R: DiagnosisDraft + R->>C: 回读当前 Run 的 Tool 真相 + R->>R: EvidenceGuard + SemanticGuard + alt 验证通过 + R-->>APP: 原始安全报告 / SUCCESS + else 证据不足或门禁失败 + R-->>APP: SafeFallback / FALLBACK + end + APP->>CORE: first terminal wins + APP-->>U: content or failure + done +``` + +讲这条链路时只需要抓住四个时间点: + +1. Agent 运行前先建立 Run 边界。 +2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定。 +3. Agent 只看到有界观察,完整事实由系统独立保管。 +4. Draft 必须经过唯一发布出口,不能直接发送给用户。 + +## 4. 三个最核心的设计决策 + +### 决策一:按决策权拆分,而不是按角色拆分 + +项目早期使用过 Planner、Executor、Verifier、Composer 和 StateGraph。它提高了职责可见性,但也把一次自然 ReAct 拆成多个模型调用、Prompt、Schema 和状态搬运。 + +最终选择是: + +| 决策 | 所有者 | +|---|---| +| 提出假设、选择 Tool、解释证据、撰写 Draft | Diagnosis Agent | +| 身份、预算、取消、权限、容量、唯一终态 | Harness | +| 引用能否被代码机械证明 | EvidenceGuard | +| 真实证据是否支持用户可见结论 | 隔离的 SemanticGuard | +| 发布正常报告还是 SafeFallback | Release | + +这里不是把多个 Agent 粗暴合并成一个“大 Agent”。业务推理合并了,安全权力反而被拆得更清楚:Agent 没有 canonical raw 读取权、不能验证自己的引用,也没有最终发布权。 + +放弃的方案:在外层继续编排多 Agent 或重新实现一套 ReAct loop。 + +获得的能力:唯一业务上下文、唯一 Draft 作者、控制规则可测试。 + +付出的代价:Harness 契约和门禁必须完整,不能再依赖角色之间“互相提醒”。 + +### 决策二:系统事实、模型观察和长期审计不能共用一份数据 + +同一份 Tool 结果要服务三个互相冲突的目标: + +```mermaid +flowchart TB + RAW["Tool backend raw result"] --> TB["ToolBoundary + Projector"] + TB --> CAN["Canonical truth
当前 Run、短 TTL、可验真"] + CAN --> OBS["Model Observation
白名单、有界、服务推理"] + CAN --> GUARD["EvidenceGuard
独立回读、验证引用"] + TB -.-> META["Metadata Audit
身份、状态、耗时、bytes"] +``` + +如果 raw 直接给模型,上下文、敏感数据和 prompt injection 风险不可控;如果只保存裁剪后的 Observation,EvidenceGuard 无法独立证明 Agent 引用了真实结果;如果把完整 raw 永久写入审计库,又会制造敏感数据副本。 + +因此当前设计将数据责任拆开: + +- Redis canonical invocation 保存当前 Run 的短期完整 Tool 真相; +- Model Observation 只包含 Agent 下一步推理所需字段; +- 长期 Audit 只保存 identity、状态、耗时、Token 和 bytes 等元数据; +- EvidenceGuard 从 canonical store 验证物理真实性; +- SemanticGuard 只在 verified evidence 上判断语义支持关系。 + +放弃的方案:一份 Tool JSON 在 Agent、Guard 和数据库之间直接流转。 + +获得的能力:模型不能靠自己看到的内容完成自证,长期审计也不必复制全部敏感正文。 + +付出的代价:每类 Tool 都需要 projector、canonical contract 和容量策略,Redis TTL 也成为验证可用性的边界。 + +### 决策三:把“如何结束”设计成一等能力 + +Agent 系统最常见的问题不只是错误,而是无法正常结束:空日志、通用知识和相似查询都可能让模型持续尝试,最后撞上预算。 + +当前 Harness 使用两套不同机制: + +```text +Budget:还能不能继续消耗资源 +Information Gain:继续查询是否推进诊断 +``` + +```mermaid +flowchart TD + X["一次 Tool 结果或执行问题"] --> C{"Run 还能继续?"} + C -->|"可以"| G{"结果是否推进诊断?"} + G -->|"GAINED"| N["继续 ReAct"] + G -->|"连续 NO_GAIN"| S["SATURATED
停止新增 Tool"] + C -->|"不可以"| P{"已有可验证进展?"} + S --> P + P -->|"有"| F["SafeFallback
已检查内容、限制和下一步"] + P -->|"无"| E["FAILED
不发布未验证内容"] + N --> D{"最终 Draft 可发布?"} + D -->|"是"| OK["SUCCESS"] + D -->|"否"| F +``` + +`READY + NO_EVIDENCE` 表示查询成功但当前 scope 为空,不是技术异常;`conclusion=null` 是合法 Draft,不是模型失败;`SATURATED` 只停止收集,不是 Run 终态;`FALLBACK` 表示请求被安全处理但没有正常报告,也不等于 `RunState.FAILED`。 + +放弃的方案:强制 Agent 必须给出根因,或者只依靠硬预算和全局异常结束。 + +获得的能力:证据不足可以成为诚实、可解释的产品结果,局部 Tool 故障也不必立即摧毁整个 Run。 + +付出的代价:RunState、Tool 状态、CollectionState 和 ReleaseOutcome 必须保持正交,排障不能只看一个 `status`。 + +## 5. 这套设计最难的地方是什么 + +面试中不要把难点回答成“接入了 Spring AI”或“写了很多 Interceptor”。真正困难的是确定责任边界,并让这些边界在异常和竞态下仍成立。 + +| 难点 | 核心问题 | 当前答案 | +|---|---|---| +| Run 归属 | 异步调用和多轮 Session 中,事实到底属于哪次执行 | 显式 `RunContext` 和 exact `runId` | +| 事实可信 | Agent 引用的 Tool 内容如何独立验真 | canonical invocation + EvidenceGuard | +| 结论可信 | 引用真实但推论牵强怎么办 | 隔离的 SemanticGuard | +| 正常收敛 | 没有证据时如何避免反复查询 | Information Gain + SATURATED + ProgressSnapshot | +| 失败一致 | 超时、预算、Tool ERROR 和断开如何对应结果 | first-terminal-wins + 唯一 Release Policy | +| 可观测性 | 如何回放决策又不永久保存敏感正文 | metadata audit + 短期 canonical truth | + +如果面试官只允许选一个,回答“事实可信”最能代表这套设计:它要求系统同时解决 Tool 身份、Run 归属、数据视图、证据引用和最终发布,而不是只调一个模型接口。 + +## 6. 用真实案例讲 2 分钟 + +可以使用支付超时案例: + +> 用户要求诊断支付服务超时。Application 先创建独立 Run,并为 Router、Agent、Tool 和 Guard 共享同一套 deadline、预算和取消能力。Diagnosis Agent 自主调用知识库和日志 Tool;ToolBoundary 执行调用并把完整事实保存为当前 Run 的 canonical invocation,只把有界 Observation 返回给 Agent。Agent 根据两个 Tool 结果生成 Draft,但 Draft 没有直接发给用户。EvidenceGuard 回读 canonical store 后发现引用无法完成真实性校验,因此 Release 没有继续让模型润色或猜测,而是发布 `EVIDENCE_VALIDATION_FAILED` SafeFallback。最终数据库记录请求处理成功、ReleaseOutcome 为 FALLBACK,SSE 也完整结束,但未经验证的根因没有离开系统。 + +这个案例的价值不在于“最终失败了”,而在于证明: + +```text +Tool READY != 引用已验真 +引用已验真 != 结论被支持 +Agent 生成 Draft != 报告允许发布 +``` + +完整数据和过程见[支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)。 + +## 7. 常见追问怎样展开 + +| 面试官追问 | 回答主线 | 深入阅读 | +|---|---|---| +| 为什么不继续使用多 Agent? | 多角色重复实现 ReAct;改为按决策权拆分 | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) | +| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) | +| 全部组件有哪些? | 先讲四组,再按需展开 10 个职责域 | [组件全景](Harness组件全景-职责-设计原因与边界.md) | +| Tool 结果为什么不直接给模型? | 真相、观察和长期审计有不同数据责任 | [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) | +| 如何防止 Agent 编造证据? | framework Tool ID、canonical store、EvidenceGuard | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) | +| EvidenceGuard 已经通过,为什么还要 SemanticGuard? | 引用真实不等于结论被支持 | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) | +| Agent 为什么不会无限调用 Tool? | 预算止损,信息增益负责正常收敛 | [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) | +| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 | [失败图谱](Harness失败图谱-异常-停止-降级与终态.md) | +| FALLBACK 算成功还是失败? | RunState、ReleaseOutcome、数据库和 SSE 是不同视图 | [生命周期与状态](Harness生命周期与状态.md) | +| 如何验证不是纸面设计? | focused tests 证明不变量,live E2E 验证组合契约 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) | +| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、Reasoning 治理 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) | + +## 8. 面试中最容易讲错的六件事 + +### 不要说:Harness 负责安排 Agent 的执行步骤 + +应说:ReAct Agent 自己选择 Tool 和下一步,Harness 负责运行边界、事实边界和发布边界。 + +### 不要说:SemanticGuard 是第二个诊断 Agent + +应说:它是无 Tool、无记忆、单轮二值判断的隔离审查器,不能探索事实或改写报告。 + +### 不要说:Tool 返回 SUCCESS 就找到了证据 + +应说:`InvocationStatus=READY` 只表示调用完成,还要结合 `EvidenceStatus`;存在候选证据也不代表支持结论。 + +### 不要说:FALLBACK 就是 Run 失败 + +应说:Fallback 是安全发布结果。典型证据不足场景可以是 `RunState.SUCCESS + ReleaseOutcome.FALLBACK`。 + +### 不要说:Redis 是完整的长期审计库 + +应说:Redis canonical store 保存当前 Run 的短期完整 Tool 真相;长期审计只保存有界元数据。 + +### 不要说:取消能立刻杀死所有模型调用 + +应说:当前是协作式取消;first-terminal-wins、active check 和 SSE 状态保证迟到结果不能发布,但同步 Provider 计算未必立即停止。 + +## 9. 当前设计的代价和边界 + +一套可信的面试叙事不能只讲收益,还要主动说明代价: + +1. 正交状态较多,必须用统一 Context 防止 `SUCCESS / READY / FALLBACK` 被混读。 +2. canonical store、Projector 和 Guard 增加了实现复杂度与发布延迟。 +3. Redis TTL 过期后只能保留元数据回放,不能恢复完整 Tool 正文。 +4. 自然语言近义查询目前不能被确定性去重,只能依赖 Agent 的信息增益义务。 +5. SemanticGuard 仍是模型判断,只是被收缩到最小、隔离、无 Tool 的范围。 +6. 协作式取消保护逻辑终态和发布,不等于强制终止 Provider 计算。 +7. 信息增益阈值和各类预算仍需依靠固定评测集持续校准。 + +主动说出这些边界,会让设计从“组件介绍”变成可讨论的工程决策。 + +## 10. 最后只记住四句话 + +```text +Agent 决定如何诊断,Harness 决定诊断必须遵守什么边界。 +系统保管 Tool 真相,模型只读取完成下一步所需的有界观察。 +引用真实与结论成立是两个问题,必须由不同门禁处理。 +Harness 不保证每次找到答案,但保证任何结束方式都诚实、可审计、不会越权发布。 +``` + +到这里,Harness 文档的主线已经闭合。需要回忆某个细节时,通过第 7 节进入专题即可,不需要重新从组件清单开始阅读。 diff --git a/mvp/engineering/harness/案例-从一次支付超时诊断看Harness如何控制Agent.md b/mvp/engineering/harness/案例-从一次支付超时诊断看Harness如何控制Agent.md new file mode 100644 index 0000000..5f6ae75 --- /dev/null +++ b/mvp/engineering/harness/案例-从一次支付超时诊断看Harness如何控制Agent.md @@ -0,0 +1,326 @@ +# 从一次支付超时诊断看 Harness 如何控制 Agent + +这篇文章不从组件清单开始,而是跟随一次真实请求,看 Agent 如何完成诊断,以及 Harness 在每个关键节点控制了什么。 + +先说最终结果:这次请求正常执行了两轮 Agent、调用了两个 Tool,两个 Tool 都返回了候选证据,但最终没有发布诊断结论,而是安全地返回了 Fallback。 + +这不是一次“什么都没做成”的失败。恰恰相反,它展示了 Harness 最重要的价值:**即使 Agent 已经写出答案,只要证据无法完成验真,答案就不能越过发布出口。** + +第一次阅读只看第 1、2、8、9 和 13 节即可:先知道问题和主流程,再看为什么被挡下、用户最终收到什么。其余章节用于展开每一步的组件设计。 + +## 1. 这次诊断从什么问题开始 + +用户请求是: + +> 支付接口最近出现超时。在给出结论前必须实际调用 `lookup_knowledge` 和 `query_logs` 各一次,日志范围使用 APPLICATION 并查询 `payment-service error slow database`;随后结束诊断,证据不足时明确说明缺口。 + +这是一个很典型的 AIOps 问题。用户看到的是“支付超时”,但可能原因很多: + +- 数据库连接池耗尽; +- 下游服务响应过慢; +- Redis 或网络超时; +- JVM、线程池或 CPU 资源异常; +- 只是历史知识中的相似案例,并非当前生产故障。 + +如果只有 Agent,它可以查询资料、阅读日志并写出一个听起来合理的解释。但系统还必须回答:查询是否属于本次请求、结果能否被引用、引用是否支持结论,以及证据不足时应该怎样结束。 + +## 2. 先看完整故事 + +```mermaid +flowchart LR + Q["用户报告支付超时"] --> R["创建 Run
建立身份、预算和取消"] + R --> I["Router 判定为 DIAGNOSIS"] + I --> A1["Agent 第 1 轮
选择知识库和日志 Tool"] + A1 --> T["ToolBoundary
执行、投影并保存调用真相"] + T --> A2["Agent 第 2 轮
根据观察结果生成 Draft"] + A2 --> E["EvidenceGuard
验证引用真实性"] + E -->|"本次未通过"| F["SafeFallback
不发布未经验证的根因"] + F --> U["SSE content + done FALLBACK"] +``` + +沿着这条线,可以把双方职责简单分开: + +| 阶段 | Agent 在做什么 | Harness 在控制什么 | +|---|---|---| +| 请求开始 | 尚未参与 | 创建 Run,固定身份、deadline、预算和取消 | +| 意图路由 | 尚未诊断 | 限制 Router 输入、调用次数和重试 | +| 选择 Tool | 提出查询计划 | 检查 Run、进展协议、重复 scope 和 Tool 权限 | +| Tool 返回 | 阅读有界观察 | 保存 canonical truth,限制 bytes,只投影必要字段 | +| 生成 Draft | 写出候选报告 | 限制输出结构和大小,Draft 尚不可发布 | +| 验证发布 | 不再拥有决定权 | 验引用、验语义,决定报告或 Fallback | +| 请求结束 | 执行结束 | 固化终态、持久化结果、记录 Trace 和 Token | + +下面逐步展开。 + +## 3. 第一步:Harness 先创建 Run + +请求进入 `ChatApplicationUseCase` 后,系统不会立即调用 Agent,而是先创建本次执行的 `RunContext`。 + +```mermaid +flowchart TB + S["sessionId
多轮对话容器"] --> R["runId
本次独立执行"] + R --> D["deadline"] + R --> B["RunBudget"] + R --> C["RunCancellation"] + R --> L["RunLifecycle"] + R --> P["ProgressTracker"] +``` + +为什么不能只使用 sessionId?因为同一个会话可以连续提出多个问题。Tool Call、Agent Step、Evidence 和最终结果都必须属于某一个精确 Run,否则上一轮日志可能被下一轮报告误引用。 + +为什么要在 Agent 之前创建预算和取消能力?因为 Router、Agent、Tool 和 SemanticGuard 都会消耗时间或资源。只有共享同一个 RunContext,系统才能统一回答“还能不能继续执行”。 + +这一阶段最重要的不是创建了几个对象,而是确定了一条规则: + +> 后续任何模型调用、Tool 调用和发布动作,都必须证明自己仍属于这个 active Run。 + +## 4. 第二步:Router 只决定走哪条路 + +用户问题先经过 Intent Router。它只判断请求属于: + +```text +SYSTEM_CHAT +KNOWLEDGE_QUERY +DIAGNOSIS +``` + +本次结果是 `DIAGNOSIS`,于是 `ChatApplicationUseCase` 将同一个 RunContext 交给 `DiagnosisChatExecutor`。 + +Router 不读取完整历史,不调用业务 Tool,也不尝试回答支付超时的根因。这样设计是为了避免“路由”在不知不觉中变成一个简化版诊断 Agent。 + +即便只是路由,Harness 仍然控制它的输入大小、timeout、Token、显式 retry 和 Trace。因为一次隐藏的模型重试,同样会造成成本和延迟无法解释。 + +## 5. 第三步:Agent 决定查什么,Harness 决定能不能查 + +Diagnosis Agent 第 1 轮读取用户问题和服务端注册的 Tool schema,随后发出两个 Tool Call: + +```text +lookup_knowledge +query_logs +``` + +这里仍然由 Agent 负责业务判断:它认为需要先查排障知识,再查看应用日志。Harness 不会用固定工作流替它安排“先 RAG、再日志”。 + +但 Agent 发出 Tool Call 不等于后端会立即执行。调用先经过 `HarnessToolInterceptor`: + +```mermaid +flowchart LR + TC["Agent Tool Call"] --> A{"Run 仍 active?"} + A --> P{"上一轮进展协议正确?"} + P --> D{"scope 是否重复或已饱和?"} + D --> B{"ToolBoundary 权限与预算允许?"} + B -->|"全部通过"| X["执行 Tool backend"] + A -->|"否"| STOP["拒绝调用"] + P -->|"否"| STOP + D -->|"否"| STOP + B -->|"否"| STOP +``` + +这就是 Harness 与工作流引擎的区别: + +- 工作流引擎决定下一步必须调用什么; +- Harness 不替 Agent 选下一步,只检查这一步是否满足执行条件。 + +## 6. 第四步:Tool 返回的不是一份数据,而是三种视图 + +两个 Tool 都通过 `ToolBoundary`。它统一完成 Run 归属、Tool 授权、只读约束、调用预算、结果 bytes、canonical 状态和长期审计检查。 + +后端原始结果不会直接返回给 Agent,而是形成三种用途不同的视图: + +```mermaid +flowchart TB + RAW["Tool Raw Result"] --> C["Canonical Invocation
当前 Run 的短期完整真相"] + C --> CV["Control View
状态、证据数量、scope"] + C --> MO["Model Observation
Agent 可见的有界内容"] + C --> EG["EvidenceGuard
独立验真来源"] + C -.-> AU["Durable Audit
长期只保存元数据"] +``` + +为什么要分开? + +- Agent 需要的是能继续推理的少量事实,不需要 Redis key、预算阈值和全部 raw; +- EvidenceGuard 需要独立于 Agent 上下文读取真实调用记录; +- 线上审计需要状态、耗时和 bytes,但不应该永久复制日志正文。 + +本次真实 Run 中: + +| Tool | InvocationStatus | EvidenceStatus | 说明 | +|---|---|---|---| +| `lookup_knowledge` | `READY` | `EVIDENCE_FOUND` | 找到了候选排障知识 | +| `query_logs` | `READY` | `EVIDENCE_FOUND` | 返回了候选日志内容,但数据源明确为 Mock | + +这两个结果只能证明“查询成功并返回候选内容”,不能证明“已经找到支付超时的生产根因”。尤其 Mock 日志不能被包装成生产环境事实。 + +## 7. 第五步:Agent 写出 Draft,但 Draft 还不是报告 + +Tool Observation 回到 ReAct 上下文后,Agent 进入第 2 轮模型调用并生成结构化 `DiagnosisDraft`。 + +真实 Trace 中可以看到: + +```text +Agent Step 0:has_text=false,发出 lookup_knowledge 和 query_logs +Agent Step 1:has_text=true,不再调用 Tool +``` + +到这一步,Agent 的职责已经完成:它收集了观察结果,并根据这些内容写出候选结论、分析和限制。 + +但 Harness 把它称为 Draft,而不是 Report。原因是模型输出还没有证明: + +- 引用的 `tool_call_id` 属于当前 Run; +- 引用对应的调用已经 `READY`; +- 引用内容确实存在于 canonical result; +- 真实证据足以支持用户可见结论。 + +如果此时直接通过 SSE 返回,前面所有 canonical store 和 Guard 设计都失去了意义。 + +## 8. 第六步:EvidenceGuard 挡住了这次发布 + +Release Pipeline 首先调用 EvidenceGuard。它不调用模型,而是通过代码检查 Draft 的结构、引用闭包、Run 归属、Tool 状态和 evidence 内容。 + +本次结果没有通过 EvidenceGuard。公开 SSE 没有泄露具体内部违规字段,只给出了稳定结果: + +```text +FallbackType = EVIDENCE_VALIDATION_FAILED +message = 当前证据无法完成真实性校验,无法确认根因 +verified_sources = [] +``` + +这意味着系统不能把 Agent Draft 中的引用提升为已验真证据。既然第一层确定性验证都没有通过,后续 SemanticGuard 就没有可信输入,也不应继续讨论结论是否“语义上合理”。 + +```mermaid +flowchart LR + D["DiagnosisDraft"] --> E{"EvidenceGuard"} + E -->|"通过"| S["SemanticGuard
检查证据是否支持结论"] + E -->|"本次未通过"| F["EVIDENCE_VALIDATION_FAILED"] + S -->|"SUPPORTED"| OK["发布原 Draft"] + S -->|"UNSUPPORTED"| SF["语义不支持 Fallback"] +``` + +这里最值得注意的是:两个 Tool 都成功了,Agent 也成功输出了 Draft,但 Release 仍然拒绝发布。 + +```text +Tool READY + 不等于 EvidenceGuard 通过 +EvidenceGuard 通过 + 不等于 SemanticGuard SUPPORTED +SemanticGuard SUPPORTED + 才可能发布正常诊断报告 +``` + +## 9. 第七步:Fallback 是安全结果,不是技术失败 + +EvidenceGuard 未通过后,`SafeFallbackFactory` 由代码构造公开内容。它没有让另一个模型“重新写得保守一点”,也没有复用 Draft 中未经验证的根因。 + +用户最终收到: + +```json +{ + "content_type": "SAFE_FALLBACK", + "fallback": { + "type": "EVIDENCE_VALIDATION_FAILED", + "conclusion": null, + "message": "当前证据无法完成真实性校验,无法确认根因", + "verified_sources": [], + "limitations": ["证据引用校验未通过"], + "next_steps": ["重新收集当前诊断范围内的证据后再发起诊断"] + } +} +``` + +SSE 顺序完整结束: + +```text +metadata +-> status ROUTING +-> status DIAGNOSIS_RUNNING +-> status SAFETY_VALIDATING +-> content SAFE_FALLBACK +-> done FALLBACK +``` + +数据库记录则是: + +```text +diagnosis_run.status = SUCCESS +diagnosis_run.release_outcome = FALLBACK +``` + +两者并不矛盾:`status=SUCCESS` 表示请求被系统正常、安全地处理完毕;`release_outcome=FALLBACK` 表示没有正常诊断结论可以发布。 + +## 10. 这次 Run 最终留下了什么 + +| 项目 | 真实结果 | +|---|---| +| sessionId | `mvp-demo-payment-timeout-stage7-20260722-1741` | +| runId | `363f481c-33b8-42e7-8699-428a6ec61806` | +| 总耗时 | 35,996 ms | +| 总 Token | 11,764 | +| Agent Step | 2 | +| Tool Invocation | 2 | +| Tool 结果 | 两次均 `READY / EVIDENCE_FOUND` | +| 最终内容 | `SAFE_FALLBACK` | +| ReleaseOutcome | `FALLBACK` | +| FallbackType | `EVIDENCE_VALIDATION_FAILED` | + +长期审计保留的是 Run、Agent Step、Tool 状态、耗时和 Token 等有界信息。模型 Thought 保持为空,完整 Tool raw 也不会因为排障方便就永久进入普通 Trace。 + +因此这次 Run 虽然没有根因结论,却仍然可以回答:谁执行了什么、用了多少资源、在哪一层停止、为什么没有发布以及用户最终收到了什么。 + +## 11. 如果验证通过,后续会发生什么 + +本次真实路径在 EvidenceGuard 结束。为了理解完整设计,可以继续看通过分支: + +1. EvidenceGuard 生成 `VerifiedEvidenceSnapshot`; +2. SemanticGuard 只接收用户问题、Draft 语义和已验真证据; +3. SemanticGuard 输出 `SUPPORTED / UNSUPPORTED`,不能改写报告; +4. 只有 `SUPPORTED` 才发布 Agent 原 Draft; +5. `UNSUPPORTED` 或 Guard 不可用时发布对应 SafeFallback。 + +这条分支不是本次支付超时 Run 的真实结果,因此这里只说明当前代码协议,不把它写成已经发生的事实。 + +## 12. 这次案例体现了哪些设计决策 + +### 决策一:Agent 拥有推理权,不拥有发布权 + +Agent 可以选择 Tool、解释 Observation 和撰写 Draft,但不能决定 Draft 是否直接交给用户。否则模型既是作者又是最终审批者。 + +### 决策二:Tool 成功与结论成立必须分层 + +`READY + EVIDENCE_FOUND` 只描述一次 Tool 调用。EvidenceGuard 和 SemanticGuard 分别处理引用真实性与结论支持度,避免一个含糊的 `SUCCESS` 贯穿全链路。 + +### 决策三:系统保留事实,模型只拿观察 + +Canonical Invocation 为当前 Run 保存完整真相;Agent Observation 只服务推理。Guard 不依赖模型看到的裁剪版本进行自证。 + +### 决策四:证据不足也应正常结束 + +不是所有诊断都能找到根因。Fallback 把“不能安全下结论”转换成稳定产品行为,而不是抛出技术异常或让模型猜一个答案。 + +### 决策五:审计记录控制事实,不复制思维过程 + +系统需要可回放,但不需要永久存储 Chain of Thought、完整 Prompt 和所有 raw payload。可观测性本身也必须有数据边界。 + +## 13. 用一句话复述这次案例 + +> 用户提出支付超时问题后,Harness 为请求建立独立 Run,允许 Diagnosis Agent 自主调用知识库和日志 Tool,并将完整调用事实保存在系统边界内;Agent 根据有界 Observation 写出 Draft 后,EvidenceGuard 发现引用无法完成真实性校验,Release 因此拒绝发布根因,转而返回确定性的 SafeFallback。整个请求正常结束、过程可回放,但未经验证的结论没有离开系统。 + +理解这句话,就理解了 Harness 的核心:**它不保证 Agent 每次都找到答案,但保证系统只对能够证明的答案负责。** + +## 14. 事实来源与延伸阅读 + +本文案例数据来自: + +- `mvp/demo/requests/payment-timeout-chat.json`; +- `mvp/demo/output/stage7-20260722-1741/chat-sse.txt`; +- `mvp/demo/output/stage7-20260722-1741/trace-response.json`; +- `devflow/projects/2026-07-22-single-react-cleanup-e2e/evidence.md`。 + +继续理解具体机制: + +- [Harness 入门](README.md) +- [组件渐进式导读](components/README.md) +- [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) +- [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) +- [生命周期与状态](Harness生命周期与状态.md) + +需要查看另一条真实 `SUCCESS` 路径及详细 Token、RAG 和 Trace 数据,阅读[一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md)。