Compare commits

...
3 Commits
37 changed files with 3741 additions and 8 deletions
+17 -2
View File
@@ -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
| 文档 | 内容 |
+95
View File
@@ -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<br/>固定本轮身份"]
R --> D["在原始决策点记录<br/>Router / Agent / Tool / Guard / Release"]
D --> S["Run、Timeline、Step、Tool<br/>分别保存各自事实"]
S --> T["Trace 聚合回放<br/>计数与 Token 对账"]
T --> A["回答答案为何产生<br/>也诚实暴露审计缺口"]
```
顺着这条线,审计只做三类事情:
1. **固定身份**:用 `runId` 把一次执行与多轮 Session 分开。
2. **记录决定**:让真正做决定的组件留下有类型、有顺序的结构化事实。
3. **聚合对账**:从最终结果倒推 Timeline、Agent Step、Tool 和 Token,检查它们是否一致。
审计不会替 Agent 诊断,也不会替 Harness 决定能否发布。它负责让这些行为在事后可以被准确解释。
## 3. 先建立这个最小心智模型
```mermaid
flowchart TB
EXEC["一次 Agent 执行"] --> RUN["Run<br/>结果封面"]
EXEC --> TL["Timeline<br/>决策脊柱"]
EXEC --> DETAIL["Step / Tool<br/>行为明细"]
EXEC --> SENSITIVE["Reasoning<br/>独立敏感审计面"]
RUN --> TRACE["普通 Trace"]
TL --> TRACE
DETAIL --> TRACE
SENSITIVE -.-> RTRACE["独立受限查询"]
```
第一次阅读只需要记住:
> Run 告诉我们结果,Timeline 告诉我们决定怎样发生,Step 和 Tool 告诉我们模型与外部世界实际做过什么;它们通过 exact `runId` 组合成一条可回放的证据链。
到这里可以先停下,不需要继续记表名。
## 4. 推荐阅读顺序
```mermaid
flowchart LR
START["先建立直觉"] --> CASE["01 真实 Run 案例<br/>审计怎样使用"]
CASE --> DESIGN["02 审计主设计<br/>为什么这样设计"]
DESIGN --> EVOLUTION["03 设计演进<br/>为什么变成现在这样"]
EVOLUTION --> NEXT["后续专题<br/>数据模型、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 是决策证据边界;二者协作,但不是同一个职责。
@@ -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 摘要<br/>这次执行最终怎样结束?"]
OUT --> TL["决策 Timeline<br/>先后做过哪些决定?"]
OUT --> STEP["Agent Step<br/>模型每一轮做了什么?"]
OUT --> TOOL["Tool Invocation<br/>实际调用过什么?"]
OUT --> LEDGER["Token 账本<br/>资源消耗能否对上?"]
```
这五个视角不是重复保存同一份内容。它们分别回答不同的问题,并在 `run_id` 下汇合。
## 3. 审计的正确阅读顺序:从结果向前倒推
回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。
```mermaid
flowchart RL
FIN["RUN_FINISHED<br/>SUCCESS,Token 已对账"] --> REL["RELEASE_DECISION<br/>允许发布"]
REL --> SG["SEMANTIC_GUARD_DECISION<br/>SUPPORTED"]
SG --> EG["EVIDENCE_GUARD_INITIAL<br/>PASSED"]
EG --> A2["Agent Step 1<br/>生成报告草稿"]
A2 --> T1["Tool Invocation<br/>RAG 返回候选证据"]
T1 --> A1["Agent Step 0<br/>发出 Tool Call"]
A1 --> ROUTE["ROUTING_DECISION<br/>DIAGNOSIS"]
ROUTE --> START["RUN_STARTED"]
```
沿着这条链可以得到一个比“请求成功”更具体的结论:
1. 本次 Run 最终正常结束,并发布了诊断报告;
2. Release 放行前,SemanticGuard 给出 `SUPPORTED`;
3. SemanticGuard 之前,EvidenceGuard 已确认引用结构有效;
4. 报告草稿来自 Agent 第 2 轮;
5. 草稿使用的候选证据来自第 1 轮发出的真实 RAG Tool Call;
6. 整条链都属于同一个 exact `run_id`。
审计的价值就在这里:它不是保存一份“成功日志”,而是让最终结果可以逐层找到前置依据。
## 4. 第一层:Run 是这次执行的封面
`sessionId` 表示多轮对话目录,`runId` 才表示一次独立执行。
```mermaid
flowchart TB
S["chat_session<br/>同一个多轮会话"] --> R1["diagnosis_run A<br/>第一轮问题"]
S --> R2["diagnosis_run B<br/>第二轮问题"]
R1 --> D1["自己的 Step / Tool / Timeline"]
R2 --> D2["自己的 Step / Tool / Timeline"]
```
这个拆分来自真实问题:早期系统只使用 `sessionId`。同一会话连续执行两轮后,主记录会被后一轮覆盖,而 Agent Step 和 Tool Invocation 继续追加,最终造成 Trace、Feedback 和评测跨轮混合。
因此当前模型把两种身份分开:
- `sessionId` 回答“这些问题属于哪段对话”;
- `runId` 回答“这条记录属于哪一次执行”。
本次样本的 `diagnosis_run` 像一张封面,保存查询、状态、意图、发布结果、总耗时、总 Token 和最终安全内容。看到它,我们先知道故事结尾,但还不知道过程细节。
## 5. 第二层:Timeline 记录决定,不复制所有正文
本次 Run 的 15 个事件可以压缩成七个阶段:
| 阶段 | 关键事件 | 它证明什么 |
|---|---|---|
| RUN | `RUN_STARTED` | 一次独立执行已经建立 |
| ROUTING | Token、Attempt、Decision | Router 被真实调用,并选择 `DIAGNOSIS` |
| AGENT | 两轮 Token 与 Model Step | Agent 先规划 Tool,后生成草稿 |
| TOOL | `TOOL_INVOCATION` | `lookup_knowledge` 被实际执行 |
| EVIDENCE | `EVIDENCE_GUARD_INITIAL` | 引用和证据结构检查通过 |
| SEMANTIC | Token、Attempt、Decision | SemanticGuard 被调用并判断 `SUPPORTED` |
| RELEASE / RUN | Release、Finish | 报告被允许发布,Run 正常收尾 |
Timeline 只承担“什么时候做了什么决定”。它不保存完整 Tool raw,也不试图替代 Agent Step 和 Tool Invocation 的详细字段。
这是一个有意的设计取舍:如果把所有信息都塞进一张巨大的事件表,查询一条时间线会很方便,但模型步骤、Tool 证据和资源账本都会退化成难以约束的 JSON。当前方案让 Timeline 保持稳定的决策语义,领域明细继续由各自记录负责。
代价是读取时必须做聚合,不能只查一张表。但这个复杂度被集中在 Trace 查询服务中,而不是扩散给每个调用方。
## 6. 第三层:Step 与 Tool 共同证明“模型真的做过什么”
这次诊断有两个 Agent Step:
| Step | 模型行为 | Token | 关联结果 |
|---|---|---:|---|
| 0 | 没有正文,发出 `lookup_knowledge` Tool Call | 2,657 | 产生 1 条 Tool Invocation |
| 1 | 读取 Tool Observation,生成报告草稿 | 4,703 | 进入 EvidenceGuard |
`AgentStepAuditTracker` 在 Step 落库后绑定 `step_id`,Tool 执行时再把这个 ID 写入 `tool_invocation`。因此系统不只知道“这一轮有 Tool Call”和“某处有一次 Tool 调用”,还可以把两者连起来。
```mermaid
flowchart LR
S0["agent_step #0<br/>发出 lookup_knowledge"] -->|"step_id"| T["tool_invocation<br/>READY / EVIDENCE_FOUND"]
T --> O["有界 Observation"]
O --> S1["agent_step #1<br/>生成 Draft"]
```
Tool 审计也没有永久复制完整原始结果。长期记录的是 exact identity、Tool 名称、状态、耗时、请求和结果字节数、稳定错误码,以及 RAG 的检索模式、命中数和相关度等有界元数据。
完整 Tool 结果属于当前 Run 的短期 canonical truth,由 Harness 用于 EvidenceGuard 验真;它与长期 durable audit 的目的不同:
- canonical truth 要回答“当前发布校验依据的事实到底是什么”;
- durable audit 要回答“长期复盘时,这次调用发生了什么”。
把完整 raw 同时写进长期 Trace 虽然排障直接,但会扩大敏感数据、存储体积和保留治理范围,因此没有采用。
## 7. 第四层:Token 不是一个总数,而是一套可对账账本
如果只在 Run 结束时写一个 `total_token_count`,我们无法判断数字是否漏掉 Router、Guard 或隐藏重试。
本次 Run 的模型账本是:
| 组件 | Token |
|---|---:|
| Intent Router | 906 |
| Diagnosis Agent 第 1 轮 | 2,657 |
| Diagnosis Agent 第 2 轮 | 4,703 |
| SemanticGuard | 4,970 |
| 合计 | 13,236 |
对账关系为:
```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):当前验收边界与兼容镜像说明。
@@ -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 当前完成度和剩余治理缺口。
@@ -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<br/>答案可以回放"]
B --> C["02 Evidence 与质量语义<br/>记录开始可比较"]
C --> D["03 exact Run<br/>多轮不再串线"]
D --> E["04 Canonical Tool Truth<br/>事实与长期审计分层"]
E --> F["05 Single ReAct 审计重建<br/>责任回到 Harness"]
F --> G["06 Timeline / Token / Reasoning<br/>决策、成本与敏感正文分治"]
```
六个阶段分别改变了审计系统回答问题的能力:
| 阶段 | 审计开始能够回答 |
|---|---|
| 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<br/>多轮对话目录"] --> R1["diagnosis_run 1<br/>第一轮执行"]
S --> R2["diagnosis_run 2<br/>第二轮执行"]
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<br/>Redis 短期完整真相"]
CAN --> OBS["Agent Observation<br/>有界投影视图"]
CAN --> GUARD["EvidenceGuard<br/>独立验真"]
CAN -.-> DUR["Durable Audit<br/>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<br/>决定如何发生"]
RUN --> STEP["Agent Step<br/>模型轮次"]
RUN --> TOOL["Tool Invocation<br/>外部行为"]
RUN --> TOKEN["Token Ledger<br/>组件成本"]
RUN --> RSN["Reasoning Audit<br/>敏感正文"]
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):当前审计能力的具体回放方式。
@@ -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<br/>发布诊断报告"]
Q3 -->|"不可以,但有安全说明"| F["ReleaseOutcome.FALLBACK<br/>发布 SafeFallback"]
Q3 -->|"没有任何安全内容"| E["ReleaseOutcome.FAILED<br/>发布 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 的空事实<br/>累计 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<br/>SafeFallback"]
S -->|"SUPPORTED"| R["发布 Diagnosis Report"]
S -->|"UNSUPPORTED"| SU["SEMANTIC_UNSUPPORTED<br/>SafeFallback"]
S -->|"技术不可用"| SA["SEMANTIC_UNAVAILABLE<br/>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<br/>构建 ProgressSnapshot"]
P --> V{"存在可验证的 observed facts?"}
V -->|"有"| F["FALLBACK<br/>说明已检查内容、限制和下一步"]
V -->|"没有"| M{"是否明确缺少必要上下文?"}
M -->|"是"| C["FALLBACK<br/>MISSING_REQUIRED_CONTEXT"]
M -->|"否"| E["FAILED<br/>不公开未验证内容"]
```
因此,下面两次预算耗尽可以有不同结果:
| 场景 | RunState | ReleaseOutcome | 用户看到什么 |
|---|---|---|---|
| 已验证支付服务在目标时段无日志,随后预算耗尽 | `BUDGET_EXHAUSTED` | `FALLBACK` | 已检查范围、空结果限制和下一步 |
| 第一次模型调用前预算检查失败,没有任何安全事实 | `BUDGET_EXHAUSTED` | `FAILED` | failure |
### 为什么不为所有失败生成一段“友好回答”
如果失败后再调用模型组织解释,会继续消耗已经耗尽的预算,也可能根据异常文本编造业务结论。确定性模板虽然表达能力有限,但不会把未知包装成答案。
这项设计选择了 fail closed:有安全事实才降级,没有就失败。代价是用户体验不总是“自然语言很完整”,但不会为了完整感牺牲可信度。
## 9. 同一个结果,要从四个视图理解
Harness 没有一条能容纳全部语义的总状态机。一次请求结束后,至少要区分四个观察面:
```mermaid
flowchart LR
R["RunState<br/>执行为什么停止"] --> X["一次请求"]
O["ReleaseOutcome<br/>最终公开什么"] --> X
D["diagnosis_run.status<br/>请求是否被安全处理"] --> X
S["SSE 事件<br/>客户端实际收到什么"] --> 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<br/>fallback_type"]
O --> R["Run termination<br/>deadline / budget / cancel"]
O --> G["Evidence / Semantic<br/>Release Trace"]
R --> T["Tool Invocation<br/>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)
@@ -0,0 +1,468 @@
# Harness 异常处理:Loop 内 vs Loop 外
**日期**:2026-07-30
**范围**:诊断路径(`intent=DIAGNOSIS`)上 ReactAgent ReAct 循环内外的异常、受控停止、降级与终态
**读者**:已读 E2E 全流程 / 运行控制,需要把「失败时系统到底怎么走」串成一张图
**关联文档**:
- [Harness 失败图谱](./Harness失败图谱-异常-停止-降级与终态.md)(失败类型总览)
- [Harness 生命周期与状态](./Harness生命周期与状态.md)(RunState / CollectionState)
- [信息增益停止](./Harness信息增益停止-让无证据诊断正常收敛.md)(SATURATED / STOP_REQUIRED)
---
## 0. 先记住一张总图
```mermaid
flowchart TB
subgraph app["Application 编排"]
UC[ChatApplicationUseCase]
EX[DiagnosisChatExecutor]
end
subgraph agent["Agent 用例"]
UC2[DiagnosisAgentUseCase]
CE[controlledExecution]
RF[recoverInvalidDraft]
end
subgraph loop["ReactAgent ReAct Loop"]
MI[HarnessModelInterceptor]
LLM[ChatModel]
TI[HarnessToolInterceptor]
TB[ToolBoundary]
end
subgraph release["Release"]
REL[DiagnosisReleaseUseCase]
FB[SafeFallback / FALLBACK]
OK[SUCCESS 报告]
end
UC --> EX
EX --> UC2
UC2 -->|agent.call| loop
MI --> LLM
LLM -->|tool_call| TI
TI --> TB
TB -->|observation| LLM
loop -->|受控异常穿出| CE
CE -->|stopped draft=null| REL
loop -->|正常返回坏 draft| UC2
UC2 -->|DiagnosisAgentOutputException| RF
RF -->|有 observedFacts| REL
REL --> FB
REL --> OK
CE -->|取消/超时再抛| UC
RF -->|无 facts 再抛| UC
```
**一句话**:
| 区域 | 异常怎么处理 |
|------|----------------|
| **Loop 内** | 多数变成 **tool/model 侧 observation 或拦截**;少数 **受控异常穿出 loop** |
| **Loop 边界** | `controlledExecution`:受控停止 → `stopped`;不可控 → 包装/再抛 |
| **Loop 外(Executor)** | `recoverInvalidDraft`:坏 draft + 有事实 → FALLBACK;否则失败 |
| **Release** | 有 draft 走 Guard;无 draft / 非法 draft 走专用降级 |
| **Application** | 未消化异常 → `ChatFailureCode` + Run 终态落库 |
---
## 1. 状态有三层,不要混
异常处理会同时碰到三套状态,职责不同:
```mermaid
flowchart LR
subgraph core["Core 执行面"]
RS[RunState<br/>RUNNING / SUCCESS / FAILED<br/>CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED]
end
subgraph prog["Progress 收集面"]
CS[CollectionState<br/>COLLECTING / SATURATED]
SR[DiagnosisStopReason]
end
subgraph out["对外发布面"]
RO[ReleaseOutcome<br/>SUCCESS / FALLBACK / FAILED / CANCELLED]
FT[FallbackType]
CF[ChatFailureCode]
end
RS -.->|"预算耗尽可并存"| RO
SR -.->|"有 facts 常映射"| FT
RO -.->|"失败粗码"| CF
```
| 层 | 枚举 | 回答的问题 |
|----|------|------------|
| Core | `RunState` | 这次 Run 技术上还能不能继续? |
| Progress | `DiagnosisStopReason` / `CollectionState` | 证据收集为何停、是否已饱和? |
| 发布 | `ReleaseOutcome` / `FallbackType` | 用户看到报告、降级还是失败? |
| 协议失败 | `ChatFailureCode` | SSE failure 的粗粒度原因? |
**典型组合**:
| 场景 | RunState | StopReason | ReleaseOutcome |
|------|----------|------------|----------------|
| 正常成功 | SUCCESS | — | SUCCESS |
| 信息饱和且有事实 | 常仍可 SUCCESS 收尾* | INFORMATION_SATURATED | FALLBACK / INSUFFICIENT_EVIDENCE |
| 预算耗尽且有事实 | BUDGET_EXHAUSTED | BUDGET_LIMIT_REACHED | FALLBACK / INSUFFICIENT_EVIDENCE |
| 预算耗尽且无事实 | BUDGET_EXHAUSTED | BUDGET_LIMIT_REACHED | FAILED |
| 客户端断开 | CANCELLED | — | CANCELLED / 可能无 done |
\*饱和后若 Agent 合法写完 draft 并过 Guard,也可能 SUCCESS;若 stopped 无 draft 则走 FALLBACK。
---
## 2. 架构位置:异常在哪一层被「接住」
```mermaid
flowchart TB
subgraph L0["L0 协议"]
CTRL[ChatController / SSE]
end
subgraph L1["L1 Application"]
APP[ChatApplicationUseCase<br/>统一 catch → ChatFailureCode]
DEX[DiagnosisChatExecutor<br/>recoverInvalidDraft]
end
subgraph L2["L2 Agent 用例"]
DAU[DiagnosisAgentUseCase<br/>controlledExecution]
end
subgraph L3["L3 框架 Loop"]
RA[ReactAgent.call]
end
subgraph L4["L4 Interceptor / Boundary"]
MI[ModelInterceptor]
TI[ToolInterceptor]
TB[ToolBoundary]
CORE[DiagnosisHarnessCore]
end
CTRL --> APP --> DEX --> DAU --> RA
RA --> MI --> CORE
RA --> TI --> TB --> CORE
MI -.->|BudgetExceeded / RunAborted 上抛| DAU
TI -.->|多数 error observation 留在 loop| RA
TI -.->|CollectionStopped 上抛| DAU
DAU -.->|stopped| DEX
DAU -.->|Draft 契约异常| DEX
DEX -.->|未恢复| APP
```
---
## 3. Loop 内:一次 ReAct 轮次里发生什么
### 3.1 正常成功路径(对照)
```mermaid
sequenceDiagram
participant DAU as DiagnosisAgentUseCase
participant RA as ReactAgent
participant MI as ModelInterceptor
participant LLM as ChatModel
participant TI as ToolInterceptor
participant TB as ToolBoundary
DAU->>RA: agent.call(input, config)
RA->>MI: interceptModel
MI->>MI: beforeModelCall 预算闸
MI->>LLM: handler.call
LLM-->>MI: tool_call
MI-->>RA: ModelResponse
RA->>TI: interceptToolCall
TI->>TI: 协议/重复/饱和检查
TI->>TB: invoke
TB-->>TI: READY + agentResult
TI-->>RA: 投影 observation
RA->>MI: interceptModel 第 2 轮
MI->>LLM: 写 Draft
LLM-->>MI: 文本 JSON
MI-->>RA: ModelResponse
RA-->>DAU: AssistantMessage
DAU->>DAU: 解析 DiagnosisDraft
DAU-->>DAU: completed(draft, progress)
```
### 3.2 Loop 内:Model 路径异常(会穿出 loop)
**触发点**:`HarnessModelInterceptor.interceptModel`
```text
beforeModelCall / handler.call / checkActive
→ BudgetExceededException
→ RunAbortedException(已终态、超时、取消)
→ 其它 RuntimeException(供应商错误等)
```
```mermaid
sequenceDiagram
participant RA as ReactAgent
participant MI as ModelInterceptor
participant Core as DiagnosisHarnessCore
participant DAU as DiagnosisAgentUseCase
RA->>MI: interceptModel(第 N 次模型)
MI->>Core: beforeModelCall
Core-->>MI: throw BudgetExceeded / RunAborted
Note over MI: 不吞异常,不转 observation
MI-->>RA: 异常上抛
RA-->>DAU: agent.call 失败
DAU->>DAU: controlledExecution(e)
```
| 异常 | Loop 内是否消化 | 穿出后 |
|------|-----------------|--------|
| `BudgetExceededException` | 否 | → `stopped(BUDGET_LIMIT_REACHED)` |
| `RunAbortedException(BUDGET_EXHAUSTED)` | 否 | → 同上 |
| `RunAbortedException(CANCELLED/TIMED_OUT/…)` | 否 | controlledExecution **再抛** → Application |
| 其它未识别 | 否 | → `DiagnosisAgentOutputException(EXECUTION_FAILED)` |
### 3.3 Loop 内:Tool 路径(多数不穿出)
**触发点**:`HarnessToolInterceptor.interceptToolCall` + `ToolBoundary`
```mermaid
flowchart TD
TC[收到 tool_call] --> SUP{是否证据工具?}
SUP -->|否| H[handler.call 旁路]
SUP -->|是| SAT{已 SATURATED 且 stop 已交付?}
SAT -->|是| EX[throw DiagnosisCollectionStoppedException]
SAT -->|否| PARSE[解析 Envelope / previous_observation]
PARSE -->|协议违规| REP[可修复 error observation<br/>或连续违规后 stop]
PARSE --> DUP{重复 scope?}
DUP -->|是| DUPR[DUPLICATE_SCOPE observation]
DUP -->|否| INV[ToolBoundary.invoke]
INV -->|READY| OBS[投影 modelObservation 回注]
INV -->|BUDGET_EXHAUSTED 等 error| ERR[error observation<br/>markBudgetLimitReached]
EX --> OUT[穿出 ReactAgent loop]
OBS --> LOOP[留在 loop,模型继续]
ERR --> LOOP
REP --> LOOP
DUPR --> LOOP
```
**设计选择**:
| 情况 | 策略 | 原因 |
|------|------|------|
| 工具执行失败、投影失败、结果过大 | **error observation 留在 loop** | 给模型一次感知/改写机会,不立刻整 run 崩 |
| 预算在 ToolBoundary 触顶 | **先 error observation** + progress 标记预算 | 本轮不强制撕开 loop;下一轮 model 的 `beforeModelCall` 会硬闸 |
| 信息饱和后仍要工具 | **`DiagnosisCollectionStoppedException` 穿出** | 收集已无价值,禁止空转 |
| 协议违规(未达阈值) | **repairable error observation** | 要求模型补 previous_observation 等 |
`ToolBoundary` 对预算的处理(**吞异常 → 错误码**,不抛给框架):
```text
catch (RunAbortedException | BudgetExceededException)
→ ToolBoundaryResult.error(BUDGET_EXHAUSTED | RUN_INACTIVE)
```
---
## 4. Loop 边界:`controlledExecution`
**位置**:`DiagnosisAgentUseCase`,包住 `agent.call(...)`。
**职责**:把「Harness 约定的受控停止」从异常栈(含 cause 链)捞出来,转成 **`DiagnosisAgentExecution.stopped`**;其余失败交给外层。
```mermaid
flowchart TD
E[catch Exception from agent.call] --> F1{cause 含 CollectionStopped?}
F1 -->|是| S1[stopped progress + stopReason]
F1 -->|否| F2{cause 含 RunAborted?}
F2 -->|是且 BUDGET_EXHAUSTED| S2[markBudgetLimitReached<br/>stopped BUDGET_LIMIT_REACHED]
F2 -->|是且其它终态| R1[再抛 RunAborted]
F2 -->|否| F3{BudgetExceeded 或 lifecycle 已预算耗尽?}
F3 -->|是| S2
F3 -->|否| N[return null]
N --> W[包装 DiagnosisAgentOutputException EXECUTION_FAILED]
S1 --> RET[正常 return 给 Executor]
S2 --> RET
```
| 输入信号 | 输出 |
|----------|------|
| `DiagnosisCollectionStoppedException` | `stopped(stopReason)`,`draft=null` |
| 预算耗尽类 | `stopped(BUDGET_LIMIT_REACHED)` |
| 取消 / 超时类 `RunAborted` | **不转 stopped,再抛** |
| 未知 | `null` → 包装执行失败异常 |
**结果形态**:
```text
completed(draft, progress) // 有合法草稿
stopped(progress, stopReason) // 无草稿,仅有进度快照
```
---
## 5. Loop 外:坏 Draft 与 `recoverInvalidDraft`
**时机**:`agent.call` **已经返回**(或解析阶段),最终文本 **不是**合法 `DiagnosisDraft`。
**位置**:`DiagnosisChatExecutor` catch `DiagnosisAgentOutputException`。
```mermaid
flowchart TD
A[DiagnosisAgentOutputException] --> K{isDraftContractFailure?}
K -->|否 EXECUTION_FAILED| P1[再抛 → Application FAILED]
K -->|是 EMPTY/INVALID_JSON/SCHEMA| H{hasObservedFacts?}
H --> AUD[审计 agentDraftInvalid]
AUD --> H2{hasObservedFacts?}
H2 -->|否| P1
H2 -->|是| SSE[status SAFETY_VALIDATING]
SSE --> RID[releaseInvalidDraft progress]
RID --> FB[FALLBACK INSUFFICIENT_EVIDENCE]
```
要点(与常见误解对照):
| 误解 | 实际 |
|------|------|
| recover 里再验 draft 完不完整 | draft 合法性已在 Agent 用例判定;此处只看 **异常 Kind** |
| hasProgress = 有过 tool_call | = **`progress.observedFacts` 非空**(可发布观察事实) |
| 降级会带上坏 JSON | **丢弃非法正文**,只根据 progress 生成 SafeFallback |
**专用发布**:`DiagnosisReleaseUseCase.releaseInvalidDraft`
- 不再跑 Evidence/Semantic Guard(没有可校验 draft)
- 要求 `hasObservedFacts()`,否则 `IllegalStateException`
- 产出 `FallbackType.INSUFFICIENT_EVIDENCE`
---
## 6. Release:按 execution 形态分叉
```mermaid
flowchart TD
EX[DiagnosisAgentExecution] --> D{draft == null?}
D -->|是 stopped| CS[releaseControlledStop<br/>需有 observedFacts]
D -->|否| C{conclusion == null?}
C -->|是| NC[releaseNoConclusion<br/>部分 Evidence + progress]
C -->|否| RC[releaseConclusion<br/>Evidence → 可选 Repair → Semantic]
CS --> FB[FALLBACK]
NC --> FB
RC -->|SUPPORTED| OK[SUCCESS]
RC -->|其它| FB
```
| 入口 | 典型来源 |
|------|----------|
| `releaseControlledStop` | `controlledExecution` → stopped |
| `releaseInvalidDraft` | `recoverInvalidDraft`(loop 结束坏 draft) |
| `releaseConclusion` | 正常 completed + 有结论 |
| `releaseNoConclusion` | draft 合法但无 conclusion |
---
## 7. Application 统一失败出口
未被转成 FALLBACK / SUCCESS 的异常,进入 `ChatApplicationUseCase`:
```mermaid
sequenceDiagram
participant EX as DiagnosisChatExecutor
participant APP as ChatApplicationUseCase
participant Core as DiagnosisHarnessCore
participant Store as ChatRunStore
participant SSE as ChatSseSession
EX-->>APP: 抛 ChatApplicationException 或 RuntimeException
APP->>Core: terminalOutcome / completeFailure
APP->>Store: finish(terminal, ...)
APP->>APP: safeFailure → ChatFailureCode
APP-->>SSE: fail(code, 安全文案)
```
常见映射直觉:
| 情况 | ChatFailureCode 倾向 |
|------|----------------------|
| 取消 | `RUN_CANCELLED` |
| 路由失败 | `ROUTING_UNAVAILABLE` |
| 落库失败 | `RUN_PERSISTENCE_FAILED` |
| 其它 | `INTERNAL_FAILURE` |
---
## 8. 端到端对照表(按场景)
| # | 场景 | 发生位置 | 关键信号 | 第一处理点 | 用户侧 |
|---|------|----------|----------|------------|--------|
| 1 | 第 N 次模型前预算没了 | Loop 内 Model | `BudgetExceeded` | controlledExecution → stopped | 有 facts→FALLBACK;无→FAILED |
| 2 | 工具执行失败 | Loop 内 Tool | `TOOL_EXECUTION_ERROR` observation | 留在 loop | 模型可能改写或再试 |
| 3 | 工具触顶预算 | Loop 内 ToolBoundary | error + markBudget | 常留在 loop,下轮 model 硬闸 | 同预算结局 |
| 4 | 饱和后仍要工具 | Loop 内 Tool | `DiagnosisCollectionStopped` | controlledExecution → stopped | 有 facts→FALLBACK |
| 5 | 取消/超时 | Core → 任意 checkActive | `RunAborted` | controlledExecution **再抛** | CANCELLED / FAILED |
| 6 | 模型返回烂 JSON | Loop 外解析 | `INVALID_JSON` 等 | recoverInvalidDraft | 有 facts→FALLBACK;无→FAILED |
| 7 | 执行崩溃未识别 | Loop 边界 | `EXECUTION_FAILED` | recover 不恢复,上抛 | FAILED |
| 8 | Guard 引用失败 | Loop 外 Release | Evidence 违规 | repair 或 FALLBACK | FALLBACK EVIDENCE_* |
| 9 | 语义不支持 | Loop 外 Release | UNSUPPORTED | FALLBACK | FALLBACK SEMANTIC_* |
---
## 9. 两条主恢复路径对比(必记)
```mermaid
flowchart LR
subgraph mid["Loop 中途打断"]
A1[Interceptor / Core 异常] --> B1[controlledExecution]
B1 --> C1[stopped draft=null]
C1 --> D1[releaseControlledStop]
end
subgraph end["Loop 正常结束但输出坏"]
A2[解析 Draft 失败] --> B2[DiagnosisAgentOutputException]
B2 --> C2[recoverInvalidDraft]
C2 --> D2[releaseInvalidDraft]
end
D1 --> E[INSUFFICIENT_EVIDENCE FALLBACK<br/>前提 hasObservedFacts]
D2 --> E
```
| | `controlledExecution` | `recoverInvalidDraft` |
|--|----------------------|------------------------|
| 时机 | loop **中途** | loop **结束后** |
| 输入 | `Throwable` cause 链 | `DiagnosisAgentOutputException` |
| 成功产物 | `Execution.stopped` | 直接 `DiagnosisExecutionResult(FALLBACK)` |
| 发布入口 | `releaseControlledStop` | `releaseInvalidDraft` |
| 共同点 | 都依赖 **可发布 observedFacts**;都 **fail closed** |
---
## 10. 代码索引
| 组件 | 路径 |
|------|------|
| Model 拦截 | `harness/agent/HarnessModelInterceptor.java` |
| Tool 拦截 | `harness/agent/HarnessToolInterceptor.java` |
| 工具边界 | `harness/tool/boundary/ToolBoundary.java` |
| 受控停止转换 | `harness/agent/DiagnosisAgentUseCase#controlledExecution` |
| 坏 Draft 恢复 | `harness/application/executor/DiagnosisChatExecutor#recoverInvalidDraft` |
| 非法 draft 发布 | `harness/release/DiagnosisReleaseUseCase#releaseInvalidDraft` |
| 受控停止发布 | `DiagnosisReleaseUseCase#releaseControlledStop` |
| 应用失败出口 | `harness/application/ChatApplicationUseCase` |
| 错误码注释 | `ChatFailureCode` / `ReleaseOutcome` / `FallbackType` / `RunState` / `DiagnosisStopReason` / `ToolBoundaryErrorCode` 等 |
---
## 11. 阅读检查清单
1. 这个失败是 **还在 loop 里**,还是 **已经穿出 agent.call**?
2. 是 **资源/取消**(Core),还是 **收集收敛**(Progress),还是 **输出契约**(Draft Kind)?
3. 最终有没有 **`observedFacts`**?有才能谈 FALLBACK。
4. `RunState` 与 `ReleaseOutcome` 是否一致理解(预算耗尽 ≠ 一定 FAILED)?
5. Tool 错误是 **observation** 还是 **异常穿出**?不要默认「有 error 就崩 run」。
---
## 12. 一句话总结
> **Loop 内:能安全回注的变成 observation,必须停收集或硬预算的穿出异常。**
> **Loop 边界:controlledExecution 把受控停止收成 stopped。**
> **Loop 外:坏 draft 用 recoverInvalidDraft,只认 observedFacts 做降级。**
> **全程 fail closed:没有可验证事实,就不发「看起来友好」的假成功。**
@@ -0,0 +1,371 @@
# Harness 设计演进:从多 Agent 编排到确定性控制边界
Harness 不是一开始就被完整设计出来的。它来自几轮真实重构:系统先拆出多个 Agent 角色,又增加 Gatekeeper 保证证据真实性,随后用 StateGraph 显式管理状态和分支,最终才发现一个更根本的问题:**外层系统正在重复实现 Agent 本身已经具备的 ReAct 生命周期。**
这篇文章不按提交逐条记流水账,而是追踪每一次设计变化背后的问题:当时为什么这样做、它解决了什么、为什么后来仍然不够,以及哪些思想最终保留了下来。
第一次阅读只看第 1、2、6、8 和 10 节即可。先建立演进主线,再理解最终边界和可复用经验。
## 1. 先看完整演进路线
```mermaid
flowchart LR
M["多 Agent 分工<br/>Planner / Executor / Verifier / Composer"]
G["Gatekeeper<br/>在模型审查前机械验真"]
S["StateGraph<br/>显式状态、条件边和终态"]
H["Single ReAct + Harness<br/>推理与控制分离"]
P["Progress Control<br/>从预算止损到正常收敛"]
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<br/>制定排查计划"]
P --> E["Executor<br/>调用 Tool 收集证据"]
E --> G["Gatekeeper<br/>检查证据引用"]
G --> V["Verifier<br/>判断 Claim 是否可信"]
V --> C["Composer<br/>组织最终回答"]
```
这个方案有合理动机:复杂诊断既要规划、执行,又要验证和表达,把职责拆开比让一个 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<br/>查 Tool Invocation 和 raw path"]
G -->|"真实"| V["Verifier<br/>判断语义支持关系"]
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<br/>Run、预算、取消、终态"]
H --> A["Diagnosis ReAct Agent<br/>假设、Tool、Observation、Draft"]
A --> TB["ToolBoundary<br/>执行与 canonical truth"]
TB --> A
A --> EG["EvidenceGuard<br/>确定性引用验真"]
EG --> SG["SemanticGuard<br/>隔离语义审查"]
SG --> REL["Release<br/>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)
@@ -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<br/>创建 Run、路由、持久化、SSE"]
subgraph CONTROL["一、运行控制"]
CORE["Harness Core<br/>identity / deadline / budget<br/>cancel / lifecycle / retry"]
PROGRESS["Progress Control<br/>重复、信息增益、停止"]
end
subgraph REASONING["二、业务推理"]
AGENT["Diagnosis ReAct Agent<br/>假设、选 Tool、解释 Observation、写 Draft"]
end
subgraph TRUTH["三、事实边界"]
TB["ToolBoundary<br/>授权、只读、容量、状态迁移"]
CAN["Canonical Invocation<br/>当前 Run 的短期完整真相"]
OBS["Model Observation<br/>模型可见的有界投影"]
AUDIT["Metadata Audit<br/>长期可观测账本"]
end
subgraph PUBLICATION["四、验证发布"]
EG["EvidenceGuard<br/>引用是否真实"]
SG["SemanticGuard<br/>证据是否支持结论"]
REL["Release<br/>原报告或 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<br/>当前 Run、短 TTL、可验真"]
CAN --> OBS["Model Observation<br/>白名单、有界、服务推理"]
CAN --> GUARD["EvidenceGuard<br/>独立回读、验证引用"]
TB -.-> META["Metadata Audit<br/>身份、状态、耗时、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<br/>停止新增 Tool"]
C -->|"不可以"| P{"已有可验证进展?"}
S --> P
P -->|"有"| F["SafeFallback<br/>已检查内容、限制和下一步"]
P -->|"无"| E["FAILED<br/>不发布未验证内容"]
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 节进入专题即可,不需要重新从组件清单开始阅读。
+5
View File
@@ -63,6 +63,11 @@ flowchart TB
| 当你想知道 | 再阅读 |
|---|---|
| 准备面试,想用一张图快速复习完整设计 | [Harness 面试速查](Harness面试速查-一张图讲清设计.md) |
| 想看 Harness 如何处理一次真实支付超时诊断 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) |
| 想知道这套设计如何从多 Agent 和 StateGraph 演进而来 | [Harness 设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
| 想知道异常、停止、降级和最终状态如何对应 | [Harness 失败图谱](Harness失败图谱-异常-停止-降级与终态.md) |
| 想分清 ReactAgent loop 内外异常怎么走、和状态如何对应 | [Loop 内外异常处理](Harness异常处理-Loop内外与状态流.md) |
| 想逐步认识 Harness 的各组组件 | [组件渐进式导读](components/README.md) |
| 为什么选这种控制边界,而不是工作流编排 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
| 一次请求从开始到结束经历什么 | [生命周期与状态](Harness生命周期与状态.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<br/>建立身份、预算和取消"]
R --> I["Router 判定为 DIAGNOSIS"]
I --> A1["Agent 第 1 轮<br/>选择知识库和日志 Tool"]
A1 --> T["ToolBoundary<br/>执行、投影并保存调用真相"]
T --> A2["Agent 第 2 轮<br/>根据观察结果生成 Draft"]
A2 --> E["EvidenceGuard<br/>验证引用真实性"]
E -->|"本次未通过"| F["SafeFallback<br/>不发布未经验证的根因"]
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<br/>多轮对话容器"] --> R["runId<br/>本次独立执行"]
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<br/>当前 Run 的短期完整真相"]
C --> CV["Control View<br/>状态、证据数量、scope"]
C --> MO["Model Observation<br/>Agent 可见的有界内容"]
C --> EG["EvidenceGuard<br/>独立验真来源"]
C -.-> AU["Durable Audit<br/>长期只保存元数据"]
```
为什么要分开?
- 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<br/>检查证据是否支持结论"]
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)。
@@ -24,12 +24,24 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
/** HTTP and SSE protocol adapter for Chat. */
/**
* HTTP / SSE 协议适配层(Harness 入口的最外层)。
*
* <p>职责边界:
* <ul>
* <li>只负责:校验请求、打开 SSE、异步投递、把结果/失败写回客户端</li>
* <li>不负责:意图判断、诊断推理、工具调用、门控发布</li>
* </ul>
*
* <p>真正的一次请求编排在 {@link ChatApplicationUseCase}。
*/
@RestController
@RequestMapping("/api")
public class ChatController {
/** 应用编排入口:建 Run → 路由 → 分叉执行 → 收尾。 */
private final ChatApplicationUseCase chatApplication;
/** Chat 专用工作线程池;避免在 HTTP 线程上阻塞跑 LLM。 */
private final ThreadPoolExecutor chatWorkerExecutor;
private final ChatHarnessProperties harnessProperties;
@@ -41,6 +53,12 @@ public class ChatController {
this.harnessProperties = harnessProperties;
}
/**
* POST /api/chat:立即返回 SSE 流;业务在 worker 线程执行。
*
* <p>SSE 事件由 {@link ChatSseSession} 发出(metadata / status / content / done)。
* 客户端断开时 session.disconnect,后续可经 RunControl 取消 Run。
*/
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> chat(@RequestBody ChatRequest request) {
if (request == null || request.getQuestion() == null || request.getQuestion().isBlank()) {
@@ -48,12 +66,14 @@ public class ChatController {
}
SseEmitter emitter = new SseEmitter(harnessProperties.getSseTimeout().toMillis());
// session 同时是 ChatApplicationObserver:编排过程中的 status/取消都经它推 SSE
ChatSseSession session = new ChatSseSession(emitter);
emitter.onTimeout(session::disconnect);
emitter.onError(ignored -> session.disconnect());
emitter.onCompletion(session::disconnect);
try {
// 异步执行:HTTP 线程只持有 SSE 连接,不跑模型
chatWorkerExecutor.execute(() -> executeChat(request, session));
} catch (RejectedExecutionException rejected) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
@@ -63,8 +83,10 @@ public class ChatController {
.body(emitter);
}
/** 工作线程:把协议请求转成 Application 请求,统一成功/失败出口。 */
private void executeChat(ChatRequest request, ChatSseSession session) {
try {
// Id=sessionId(可多轮复用),Question=本轮 query → 一问一 run
ChatApplicationResult result = chatApplication.execute(
new ChatApplicationRequest(request.getQuestion(), request.getId()), session);
session.complete(result);
@@ -12,6 +12,15 @@ import org.springframework.ai.chat.model.ChatModel;
import java.util.List;
import java.util.Objects;
/**
* 组装 Diagnosis 用的 Spring AI Alibaba {@link ReactAgent}。
*
* <p>这里是「框架能力」与「Harness 控制面」的粘合点:
* <ul>
* <li>框架:ChatModel、tools、ReAct 循环、outputSchema</li>
* <li>Harness:Model/Tool Interceptor、审计 Hook、禁止并行工具</li>
* </ul>
*/
public final class DiagnosisAgentFactory {
public static final String AGENT_NAME = "diagnosis_agent";
@@ -62,22 +71,27 @@ public final class DiagnosisAgentFactory {
this.prompt = DiagnosisAgentPrompt.load();
}
/**
* 为当前 Run 构建 ReactAgent 实例(与 RunContext 绑定,不可跨 run 复用)。
*/
public ReactAgent create(RunContext context) {
Objects.requireNonNull(context, "context must not be null");
return ReactAgent.builder()
.name(AGENT_NAME)
.description("Collects bounded evidence and authors one diagnosis draft")
.model(chatModel)
.model(chatModel) // Spring AI ChatModel
.systemPrompt(prompt)
.tools(evidenceTools.callbacks())
.tools(evidenceTools.callbacks()) // 证据工具(如 lookup_knowledge)
.interceptors(
// 每次模型调用前:checkActive + 预算 + token 审计
new HarnessModelInterceptor(core, context, modelCallAuditor),
// 每次工具调用:边界投影给 Agent + 完整轨迹进审计
new HarnessToolInterceptor(
context, evidenceTools, objectMapper, traceRecorder))
.hooks(auditHooks)
.hooks(auditHooks) // 如 agent_step 落库
.outputSchema(new DiagnosisDraftOutputSchema(objectMapper).getFormat())
.returnReasoningContents(true)
.parallelToolExecution(false)
.parallelToolExecution(false) // 串行工具:预算与 step 绑定可解释
.releaseThread(true)
.build();
}
@@ -4,12 +4,28 @@ import com.superbiz.agent.harness.progress.DiagnosisProgressSnapshot;
import java.util.Objects;
/**
* Diagnosis Agent 输出/执行失败异常。
*
* <p>由 {@code DiagnosisAgentUseCase} 抛出;{@code DiagnosisChatExecutor} 仅对
* {@link #isDraftContractFailure()} 为 true 的 kind 尝试 {@code recoverInvalidDraft}。
*/
public final class DiagnosisAgentOutputException extends RuntimeException {
/**
* Agent 失败细分。
*
* <p>前三种(空/JSON/schema)算 Draft 契约失败,有 observed facts 时可 FALLBACK;
* {@link #EXECUTION_FAILED} 是 loop/框架执行失败,不走非法 draft 恢复。
*/
public enum Kind {
/** agent.call 抛错且非受控停止,或未归类执行失败。 */
EXECUTION_FAILED,
/** 模型返回空文本,没有 draft。 */
EMPTY_DRAFT,
/** 输出不是合法 JSON。 */
INVALID_JSON,
/** JSON 可解析但不符合 DiagnosisDraft schema。 */
SCHEMA_INVALID
}
@@ -21,8 +21,21 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Diagnosis Agent 用例:在 Harness 边界内跑一次 Spring AI Alibaba {@link ReactAgent}。
*
* <p>框架负责 ReAct 多轮(模型 ↔ tool_call);Harness 负责:
* <ul>
* <li>输入/输出字节上限与 Run 预算</li>
* <li>通过 Factory 注入 Model/Tool Interceptor 卡住每次消耗</li>
* <li>把预算耗尽/信息无增益等可控停止转成 stopped 执行结果</li>
* </ul>
*
* <p>由 {@code DiagnosisChatExecutor} 调用;成功产出 {@link DiagnosisDraft} 草稿(尚未对外发布)。
*/
public final class DiagnosisAgentUseCase {
/** 写入 RunnableConfig metadata,供 hook/interceptor 取回同一 RunContext。 */
public static final String RUN_CONTEXT_METADATA = "runContext";
private final DiagnosisHarnessCore core;
@@ -55,6 +68,11 @@ public final class DiagnosisAgentUseCase {
progressProjection, "progressProjection must not be null");
}
/**
* 在当前 Run 内执行 Diagnosis Agent。
*
* <p>返回 completed(draft) 或 stopped(stopReason);草稿仍需经 Release 才能对外 SUCCESS。
*/
public DiagnosisAgentExecution execute(RunContext context, DiagnosisAgentInput input) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(input, "input must not be null");
@@ -71,6 +89,7 @@ public final class DiagnosisAgentUseCase {
checkLimit("input", inputBytes, limits.maxInputBytes());
core.reserveRunBytes(context, inputBytes);
// 框架线程/配置:把 RunContext 显式挂进 metadata,避免隐式 ThreadLocal
RunnableConfig config = RunnableConfig.builder()
.threadId(context.runId())
.addMetadata("sessionId", context.sessionId())
@@ -78,12 +97,15 @@ public final class DiagnosisAgentUseCase {
.addMetadata(RUN_CONTEXT_METADATA, context)
.addMetadata("_stream_", false)
.build();
// 每次 run 新建 Agent,绑定本 run 的 interceptor(预算/投影/审计)
ReactAgent agent = agentFactory.create(context);
AssistantMessage response;
try {
// ★ Spring AI Alibaba:内部多轮 model + tool,直到产出最终文本或被 interceptor 打断
response = agent.call(inputJson, config);
core.checkActive(context);
} catch (Exception e) {
// 预算/收敛等可控停止 → stopped;其它异常包装为 Agent 输出失败
DiagnosisAgentExecution controlled = controlledExecution(context, e);
if (controlled != null) {
return controlled;
@@ -127,13 +149,37 @@ public final class DiagnosisAgentUseCase {
}
}
/**
* 把 ReactAgent / interceptor 抛出的「受控停止」从异常栈里捞出来,转成正常返回值。
*
* <p>不是笼统的「业务异常 → 正常」;只识别 Harness 约定的可控信号(沿 cause 链查找,
* 因为框架可能再包一层):
* <ol>
* <li>{@link DiagnosisCollectionStoppedException}:信息饱和等收集该停,
* 仍强行 tool → {@code stopped(stopReason)},draft=null</li>
* <li>{@link RunAbortedException} 且终态为 {@code BUDGET_EXHAUSTED}:
* 标记 progress 预算停 + {@code stopped(BUDGET_LIMIT_REACHED)}</li>
* <li>其它 {@link RunAbortedException}(取消/超时/内部失败终态):
* <b>原样再抛</b>,不转 stopped——留给 Application 写 CANCELLED/FAILED</li>
* <li>{@link BudgetExceededException} 或 lifecycle 已是预算耗尽:同预算 stopped</li>
* <li>都不匹配:返回 {@code null},调用方包装为 {@link DiagnosisAgentOutputException}</li>
* </ol>
*
* <p>与 {@code DiagnosisChatExecutor#recoverInvalidDraft} 的分工:
* 本方法处理「loop 被预算/收敛打断、往往还没有合法 draft」;
* recoverInvalidDraft 处理「loop 跑完了,但输出不是合法 DiagnosisDraft」。
*
* @return 可交给 Release 的 stopped 执行结果;无法识别时 null
*/
private DiagnosisAgentExecution controlledExecution(RunContext context, Throwable failure) {
// 1) 收集侧受控停止(ToolInterceptor 在 SATURATED 后仍收到 tool 请求)
DiagnosisCollectionStoppedException stopped = findCause(
failure, DiagnosisCollectionStoppedException.class);
if (stopped != null) {
return DiagnosisAgentExecution.stopped(
progressProjection.project(context), stopped.stopReason());
}
// 2) Run 已终态:仅预算耗尽可降为 stopped;取消/超时必须继续上抛
RunAbortedException aborted = findCause(failure, RunAbortedException.class);
if (aborted != null) {
if (aborted.termination().state() == RunState.BUDGET_EXHAUSTED) {
@@ -143,15 +189,18 @@ public final class DiagnosisAgentUseCase {
}
throw aborted;
}
// 3) 预算异常(可能尚未被包成 RunAborted,或 lifecycle 已先置 BUDGET_EXHAUSTED)
BudgetExceededException budget = findCause(failure, BudgetExceededException.class);
if (budget != null || context.lifecycle().state() == RunState.BUDGET_EXHAUSTED) {
context.progress().markBudgetLimitReached();
return DiagnosisAgentExecution.stopped(
progressProjection.project(context), DiagnosisStopReason.BUDGET_LIMIT_REACHED);
}
// 4) 未知失败:交给外层当 Agent 执行失败
return null;
}
/** 沿 cause 链查找目标异常类型(框架包装后根因仍在链上)。 */
private static <T extends Throwable> T findCause(Throwable failure, Class<T> type) {
Throwable current = failure;
while (current != null) {
@@ -1,11 +1,28 @@
package com.superbiz.agent.harness.application;
/**
* Chat 应用进度状态(SSE status 事件),不是错误码。
*
* <p>只表示「当前走到哪一阶段」,成功/失败结局看 ReleaseOutcome / ChatFailureCode。
*/
public enum ChatApplicationStatus {
/** Intent Router 识别请求类型。 */
ROUTING("正在识别请求类型"),
/** 系统闲聊路径生成回答。 */
SYSTEM_RESPONDING("正在生成回答"),
/** 知识库检索中。 */
KNOWLEDGE_SEARCHING("正在查询知识库"),
/** 知识答案整理中。 */
KNOWLEDGE_ANSWERING("正在整理知识答案"),
/** 诊断 Agent 收集证据 / 写草稿(ReAct 循环中)。 */
DIAGNOSIS_RUNNING("正在收集诊断证据"),
/** Evidence / Semantic 门控或无效 draft 的安全发布阶段。 */
SAFETY_VALIDATING("正在进行安全校验");
private final String message;
@@ -14,6 +31,7 @@ public enum ChatApplicationStatus {
this.message = message;
}
/** 面向用户的简短进度文案。 */
public String message() {
return message;
}
@@ -23,16 +23,34 @@ import java.util.Optional;
import java.util.function.Supplier;
import java.util.regex.Pattern;
/**
* Chat 应用编排(Harness Application 层):一次请求从创建到公开结果的负责人。
*
* <p>调用链:
* <pre>
* Controller → execute()
* → core.startRun() // 建 RunContext 边界
* → router.route() // Intent Router(Spring AI 单次模型调用)
* → executePath(intent) // 按意图分叉
* DIAGNOSIS → DiagnosisChatExecutor(Agent → Release)
* → completePath / persistFinish
* </pre>
*
* <p>它编排路径,但不做业务根因判断;也不把 HTTP/SSE 细节塞进 Core。
*/
public final class ChatApplicationUseCase {
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
/** 执行规则:预算、deadline、取消、终态(first-terminal-wins)。 */
private final DiagnosisHarnessCore core;
private final Supplier<String> sessionIdSupplier;
private final ChatRunStore runStore;
/** 意图路由:只产出 IntentType,不执行诊断。 */
private final IntentRouting router;
private final SystemChatOperation systemChat;
private final KnowledgeQueryOperation knowledgeQuery;
/** 诊断子路径:Agent 收集证据写草稿 + Release 门控发布。 */
private final DiagnosisOperation diagnosis;
private final ObjectMapper objectMapper;
private final DiagnosisTraceRecorder traceRecorder;
@@ -74,12 +92,18 @@ public final class ChatApplicationUseCase {
return execute(request, ChatApplicationObserver.noop());
}
/**
* 一次 Chat 请求的主编排。
*
* <p>observer 通常是 SSE session:onStarted 推 metadata,onStatus 推进度。
*/
public ChatApplicationResult execute(ChatApplicationRequest request,
ChatApplicationObserver observer) {
Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(observer, "observer must not be null");
String sessionId = resolveSessionId(request.sessionId());
// 会话级上下文:上一路由结果 + 上一轮诊断摘要(给 Router / Diagnosis 用)
Optional<RoutingHistory> history;
Optional<PreviousTurn> previousTurn;
try {
@@ -90,14 +114,19 @@ public final class ChatApplicationUseCase {
ChatFailureCode.RUN_PERSISTENCE_FAILED,
"无法读取会话上下文,请稍后重试", exception);
}
// ★ 步骤1:创建 Run 边界(runId/deadline/budget/cancel/lifecycle),显式向下传递
RunContext context = core.startRun(sessionId);
long startedNanos = System.nanoTime();
IntentType intent = null;
try {
// ★ 步骤2:落库 RUN 开始 + 对外/对内可观测
persistStart(context, request.query());
traceRecorder.record(TraceAuditEvents.runStarted(context));
observer.onStarted(new CoreRunControl(core, context));
observer.onStarted(new CoreRunControl(core, context)); // SSE metadata + 取消句柄
observer.onStatus(ChatApplicationStatus.ROUTING);
// ★ 步骤3:意图路由——只回答「走哪条应用分支」,不调业务工具
intent = router.route(context, new IntentRouterInput(
request.query(),
history.map(RoutingHistory::intent).orElse(null),
@@ -105,8 +134,11 @@ public final class ChatApplicationUseCase {
traceRecorder.record(TraceAuditEvents.routingDecision(context, intent));
persistIntent(context.runId(), intent);
// ★ 步骤4:按 intent 分叉执行(编排决策点)
PathResult path = executePath(
intent, context, request.query(), previousTurn.orElse(null), observer);
// ★ 步骤5:写入 Run 终态(成功)并持久化公开结果
completePath(context, intent, path);
String safeJson = write(path.content());
persistFinish(context, intent, path.outcome(), safeJson,
@@ -117,6 +149,7 @@ public final class ChatApplicationUseCase {
context.sessionId(), context.runId(), intent, path.outcome(),
path.content().contentType(), path.content());
} catch (RuntimeException exception) {
// 统一失败出口:尽量落终态,再映射成安全的对外失败码
ReleaseOutcome terminal = terminalOutcome(context);
try {
runStore.finish(context, intent, terminal, null, null,
@@ -130,6 +163,12 @@ public final class ChatApplicationUseCase {
}
}
/**
* 路径执行完成后的 Run 终态处理。
*
* <p>诊断预算耗尽且已由子路径产出 FALLBACK 时,不二次 completeSuccess
*(lifecycle 已是 BUDGET_EXHAUSTED)。
*/
private void completePath(RunContext context, IntentType intent, PathResult path) {
if (path.handledBudgetTermination()) {
if (intent != IntentType.DIAGNOSIS
@@ -144,6 +183,12 @@ public final class ChatApplicationUseCase {
core.completeSuccess(context);
}
/**
* 根据 Router 产出的 intent 选择执行分支。
*
* <p>这是 Application 的核心编排决策:Router 只给枚举,分支执行权在这里。
* DIAGNOSIS 继续进入 {@link com.superbiz.agent.harness.application.executor.DiagnosisChatExecutor}。
*/
private PathResult executePath(IntentType intent,
RunContext context,
String query,
@@ -162,6 +207,7 @@ public final class ChatApplicationUseCase {
ReleaseOutcome.SUCCESS, knowledgeQuery.execute(context, query), null, false);
}
case DIAGNOSIS -> {
// 诊断子编排:Agent(ReAct) → Guard/Release,不在本类展开
DiagnosisExecutionResult result = diagnosis.execute(
context, query, previousTurn, observer::onStatus);
yield new PathResult(result.outcome(), result.content(), result.publishedResult(),
@@ -1,11 +1,38 @@
package com.superbiz.agent.harness.application;
/**
* Chat 应用层对外失败码(给 SSE failure / 客户端看的粗粒度原因)。
*
* <p>层级:Application 出口。不要和下列内部码混淆:
* <ul>
* <li>{@code RunState}:Run 内存生命周期终态</li>
* <li>{@code ReleaseOutcome}:诊断发布裁决(SUCCESS/FALLBACK/...)</li>
* <li>{@code FallbackType}:FALLBACK 时的细分原因</li>
* <li>{@code ToolBoundaryErrorCode}:单次工具边界错误</li>
* </ul>
*
* <p>由 {@link ChatApplicationUseCase} 在 catch 中映射,文案对用户安全,不暴露内部堆栈。
*/
public enum ChatFailureCode {
/** Intent Router 不可用或输出无法解析,无法决定走哪条路径。 */
ROUTING_UNAVAILABLE,
/** 系统闲聊分支暂时无法回答。 */
SYSTEM_CHAT_UNAVAILABLE,
/** 知识问答分支暂时无法完成检索/作答。 */
KNOWLEDGE_UNAVAILABLE,
/** 诊断分支整体不可用(非具体 FALLBACK 细分)。 */
DIAGNOSIS_UNAVAILABLE,
/** session/run 读写落库失败(start/intent/finish 等)。 */
RUN_PERSISTENCE_FAILED,
/** Run 被取消(客户端断开、用户取消等),对应 RunState.CANCELLED。 */
RUN_CANCELLED,
/** 未归类的内部失败;兜底码,应尽量少用、并靠 Trace 排查。 */
INTERNAL_FAILURE
}
@@ -23,9 +23,22 @@ import com.superbiz.agent.harness.release.DiagnosisReleaseUseCase;
import java.util.Objects;
import java.util.function.Consumer;
/**
* 诊断路径子编排(Application 在 intent=DIAGNOSIS 时的执行器)。
*
* <p>两段式流水线,本身不实现 ReAct 循环:
* <ol>
* <li>{@link DiagnosisAgentUseCase}:Spring AI Alibaba ReactAgent 收集证据并写 Draft</li>
* <li>{@link DiagnosisReleaseUseCase}:Evidence/Semantic 门控 + 发布 SUCCESS 或 FALLBACK</li>
* </ol>
*
* <p>由 {@code ChatApplicationUseCase.executePath} 在路由完成后调用。
*/
public final class DiagnosisChatExecutor implements DiagnosisOperation {
/** Agent 接入:内部创建 ReactAgent 并 agent.call。 */
private final DiagnosisAgentUseCase diagnosisAgent;
/** 验证与发布:未证明的结论不能 SUCCESS 出口。 */
private final DiagnosisReleaseUseCase releaseUseCase;
private final PublishedResultPolicy publishedPolicy;
private final DiagnosisTraceRecorder traceRecorder;
@@ -46,27 +59,38 @@ public final class DiagnosisChatExecutor implements DiagnosisOperation {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 诊断子路径:Agent 出草稿 → Release 裁决对外形态。
*
* @param statusSink 回写 SSE 进度(DIAGNOSIS_RUNNING / SAFETY_VALIDATING)
*/
@Override
public DiagnosisExecutionResult execute(RunContext context,
String query,
PreviousTurn previousTurn,
Consumer<ChatApplicationStatus> statusSink) {
Objects.requireNonNull(statusSink, "statusSink must not be null");
// SSE:诊断 Agent 运行中(内部可能多轮模型 + 工具)
statusSink.accept(ChatApplicationStatus.DIAGNOSIS_RUNNING);
DiagnosisAgentExecution execution;
try {
// ★ 段1:ReAct Agent——规划 tool_call、收证据、产出 DiagnosisDraft
execution = diagnosisAgent.execute(
context, new DiagnosisAgentInput(query, previousTurn));
} catch (DiagnosisAgentOutputException exception) {
// Draft 契约失败:有观察事实可降级 FALLBACK,否则上抛
return recoverInvalidDraft(context, exception, statusSink);
}
// SSE:进入门控(Evidence 结构 + Semantic 语义)
statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING);
// ★ 段2:验证与发布——决定 SUCCESS 报告还是 SAFE_FALLBACK
DiagnosisReleaseResult released = releaseUseCase.execute(context, query, execution);
if (released.outcome() == ReleaseOutcome.FALLBACK) {
return new DiagnosisExecutionResult(
ReleaseOutcome.FALLBACK,
new FallbackContent(released.fallback()),
null,
// 预算耗尽导致的 FALLBACK:上层 completePath 不再 completeSuccess
execution.stopReason()
== com.superbiz.agent.harness.progress.DiagnosisStopReason.BUDGET_LIMIT_REACHED);
}
@@ -80,22 +104,52 @@ public final class DiagnosisChatExecutor implements DiagnosisOperation {
published);
}
/**
* Agent「已经跑完」但最终文本不是合法 {@code DiagnosisDraft} 时的恢复路径。
*
* <p>触发条件(见 {@link DiagnosisAgentOutputException#isDraftContractFailure()}):
* 空输出 / 非法 JSON / schema 不符。真正的执行崩溃({@code EXECUTION_FAILED})不进恢复,直接再抛。
*
* <p>除「记审计 + 包装 FALLBACK 返回」外,还承担:
* <ol>
* <li><b>失败分类闸门</b>:只有 Draft 契约失败可恢复;其它 Agent 异常保持失败语义上抛</li>
* <li><b>fail-closed 安全门</b>:必须 {@code progress.hasObservedFacts()},
* 否则再抛——禁止在「什么都没查到」时用模板假装一次有依据的降级</li>
* <li><b>丢弃非法 draft 正文</b>:不把空串/烂 JSON/错 schema 送进 Evidence/Semantic Guard,
* 也不可能走出 SUCCESS;发布只基于已验真 progress(canonical 工具事实)</li>
* <li><b>走专用 Release 入口</b>:{@link DiagnosisReleaseUseCase#releaseInvalidDraft},
* 而不是 {@code execute(context, query, execution)}——因为没有可校验的 draft</li>
* <li><b>SSE 阶段对齐</b>:推 {@code SAFETY_VALIDATING},与正常门控路径对外进度一致</li>
* <li><b>固定对外形态</b>:{@code FALLBACK + FallbackContent},{@code publishedResult=null}
* (不落可回放的成功发布快照)</li>
* </ol>
*
* <p>与 {@code DiagnosisAgentUseCase#controlledExecution} 的分工:
* controlledExecution 处理 loop <b>中途</b>被预算/收集收敛打断(常无 draft,转 stopped);
* 本方法处理 loop <b>结束后</b>输出契约失败(有/无 progress 决定 FALLBACK 还是失败)。
*/
private DiagnosisExecutionResult recoverInvalidDraft(
RunContext context,
DiagnosisAgentOutputException exception,
Consumer<ChatApplicationStatus> statusSink) {
// 1) 仅 Draft 契约失败可恢复;EXECUTION_FAILED 等保持原异常
if (!exception.isDraftContractFailure()) {
throw exception;
}
// 2) 是否已有可发布的观察事实(来自工具 canonical,不是模型胡写的 draft)
boolean hasProgress = exception.progress().hasObservedFacts();
// 3) 审计:记录 kind / 输出字节 / 有无 progress,便于区分「模型格式烂」vs「彻底空跑」
traceRecorder.record(TraceAuditEvents.agentDraftInvalid(
context, exception.kind(), exception.outputBytes(), hasProgress));
// 4) 无安全事实 → fail closed,交给 Application 写 FAILED
if (!hasProgress) {
throw exception;
}
// 5) 有事实:对齐 SSE 阶段,走「无合法 draft」专用发布(INSUFFICIENT_EVIDENCE 类 FALLBACK)
statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING);
DiagnosisReleaseResult released = releaseUseCase.releaseInvalidDraft(
context, exception.progress());
// 6) 对外只给安全 Fallback;非法 draft 正文永不出现在 content 里
return new DiagnosisExecutionResult(
ReleaseOutcome.FALLBACK,
new FallbackContent(released.fallback()),
@@ -29,12 +29,26 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
/**
* 意图路由:判断本轮走 SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS。
*
* <p>在 Harness 中的位置:Application 主编排里的「路由节点」,不是业务执行器。
* <ul>
* <li>使用 Spring AI 的 {@link Prompt} / Message 构造请求</li>
* <li>经 {@link GuardModelCall} 调 ChatModel(单次结构化输出,不是 ReactAgent)</li>
* <li>预算、超时、重试、审计由 Harness 包裹</li>
* </ul>
*
* <p>只返回 {@link IntentType};由 {@code ChatApplicationUseCase.executePath} 决定后续分支。
*/
public final class IntentRouter implements IntentRouting {
/** 输出契约:JSON 只能有 intent 一个字段。 */
private static final Set<String> OUTPUT_FIELDS = Set.of("intent");
private final DiagnosisHarnessCore core;
private final HarnessRetryExecutor retryExecutor;
/** 统一模型调用壳:入账 token、受 RunContext 约束。 */
private final GuardModelCall modelCall;
private final ObjectMapper objectMapper;
private final IntentRouterLimits limits;
@@ -69,6 +83,11 @@ public final class IntentRouter implements IntentRouting {
this.systemPrompt = IntentRouterPrompt.load();
}
/**
* 对当前 query(+ 可选历史 intent/query)做一次意图分类。
*
* @return 仅 IntentType;Application 据此 switch 到对应 Executor
*/
@Override
public IntentType route(RunContext context, IntentRouterInput input) {
Objects.requireNonNull(context, "context must not be null");
@@ -78,11 +97,14 @@ public final class IntentRouter implements IntentRouting {
if (bytes > limits.maxInputBytes()) {
throw new IntentRoutingException(new IllegalArgumentException("Router input exceeded limit"));
}
// 先占 Run 字节预算,再调模型
core.reserveRunBytes(context, bytes);
// Spring AI Prompt:system 规则 + user 侧结构化输入 JSON
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt), new UserMessage(json)));
long started = System.nanoTime();
try {
// 技术失败可按 policy 重试;取消/预算耗尽不能被重试吞掉
return retryExecutor.execute(
context,
context.retryPolicies().intentRouter(),
@@ -103,6 +125,7 @@ public final class IntentRouter implements IntentRouting {
}
}
/** 严格解析:字段集合必须恰好为 {intent},值必须是 IntentType 枚举名。 */
private IntentType parse(String output) {
JsonNode root;
try {
@@ -1,7 +1,23 @@
package com.superbiz.agent.harness.contract;
/**
* 单次工具调用在「证据语义」上的结果状态(工具契约 / 进度)。
*
* <p>与 {@link InvocationStatus} 分工:
* <ul>
* <li>InvocationStatus:调用生命周期(投影中/就绪/错误)</li>
* <li>EvidenceStatus:这次调用有没有拿到可引用证据</li>
* </ul>
* NO_EVIDENCE 仍可能是 success 的工具执行(查了但空),常记为信息无增益。
*/
public enum EvidenceStatus {
/** 返回了可被引用的证据块。 */
EVIDENCE_FOUND,
/** 执行完成但范围内无证据(空结果,不是必然系统故障)。 */
NO_EVIDENCE,
/** 工具侧错误或无法形成合法证据观察。 */
ERROR
}
@@ -1,10 +1,43 @@
package com.superbiz.agent.harness.contract;
/**
* FALLBACK 细分类型:在 {@link ReleaseOutcome#FALLBACK} 时说明「为什么降级」。
*
* <p>层级:Release 内容契约(SafeFallback.type)。
* 出现在对外 Fallback 载荷与 Trace 的 RELEASE_DECISION 中。
*
* <p>注意:
* <ul>
* <li>{@link #BUDGET_EXHAUSTED} 枚举值仍保留,但当前诊断主路径对预算受控停止
* 实际多发布 {@link #INSUFFICIENT_EVIDENCE}(见 harness CONTEXT 命名债务说明)</li>
* <li>与 {@code DiagnosisStopReason} 不同:StopReason 是收集阶段为何停;
* FallbackType 是发布给用户的降级分类</li>
* </ul>
*/
public enum FallbackType {
/** Evidence Guard:引用/结构校验未通过(含 repair 后仍失败)。 */
EVIDENCE_VALIDATION_FAILED,
/** Semantic Guard:结论不被证据支撑(verdict=UNSUPPORTED)。 */
SEMANTIC_UNSUPPORTED,
/** Semantic Guard:技术上无法完成语义评审(超时/不可用等),不发布根因。 */
SEMANTIC_UNAVAILABLE,
/**
* 历史/枚举保留:预算耗尽类降级。
* 当前主路径预算受控停止有安全进展时,通常映射为 {@link #INSUFFICIENT_EVIDENCE}。
*/
BUDGET_EXHAUSTED,
/**
* 已做有限检查,但证据不足以确认根因。
* 典型来源:信息饱和 stopped、预算 stopped 有 facts、非法 draft 有 progress 的
* {@code releaseInvalidDraft} / {@code releaseControlledStop}。
*/
INSUFFICIENT_EVIDENCE,
/** 缺少定向诊断所需上下文(对象/时间窗等),尚未形成有效查询进展。 */
MISSING_REQUIRED_CONTEXT
}
@@ -1,7 +1,19 @@
package com.superbiz.agent.harness.contract;
/**
* 工具调用在 canonical store 中的生命周期状态。
*
* <p>READY 的记录才可作为 Evidence Guard 引用目标;
* PROJECTING 表示尚未完成投影落库;ERROR 表示本调用失败收尾。
*/
public enum InvocationStatus {
/** 已 begin,正在执行/投影,尚不可作为最终引用。 */
PROJECTING,
/** 投影完成且可引用(agentResult + evidenceStatus 已固化)。 */
READY,
/** 本调用以错误结束。 */
ERROR
}
@@ -1,8 +1,28 @@
package com.superbiz.agent.harness.contract;
/**
* 诊断发布裁决结果(Release 层):这一次诊断「对外给什么结局」。
*
* <p>层级:Release / Application 结果。注意与 {@link com.superbiz.agent.harness.core.RunState} 不同:
* <ul>
* <li>{@code RunState} 回答 Run 是否还在跑、因何技术终态停下(含 TIMED_OUT、BUDGET_EXHAUSTED)</li>
* <li>{@code ReleaseOutcome} 回答用户侧内容形态:正常报告 / 安全降级 / 失败 / 取消</li>
* </ul>
*
* <p>常见组合:RunState=BUDGET_EXHAUSTED 且有安全进展 → ReleaseOutcome=FALLBACK;
* 无进展 → 往往落到 FAILED。
*/
public enum ReleaseOutcome {
/** 通过门控,可发布 DIAGNOSIS_REPORT。 */
SUCCESS,
/** 不发布根因报告,发布 SafeFallback(见 {@link FallbackType})。 */
FALLBACK,
/** 无法形成可发布的安全内容(含无 observed facts 的预算停等)。 */
FAILED,
/** 运行被取消,通常不再保证客户端能收到完整 done。 */
CANCELLED
}
@@ -1,6 +1,17 @@
package com.superbiz.agent.harness.contract;
/**
* Semantic Guard 对「结论是否被证据支撑」的裁决。
*
* <p>SUPPORTED → 可走向 Release SUCCESS;
* UNSUPPORTED → 通常 FallbackType.SEMANTIC_UNSUPPORTED。
* (Guard 技术失败走 SEMANTIC_UNAVAILABLE,不一定落本枚举。)
*/
public enum SemanticVerdict {
/** 语义上认为草稿结论被已验证证据支持。 */
SUPPORTED,
/** 证据不足以支持当前根因/结论表述。 */
UNSUPPORTED
}
@@ -1,7 +1,19 @@
package com.superbiz.agent.harness.contract;
/**
* SSE {@code done} 事件上的粗粒度结局(协议层)。
*
* <p>与 {@link ReleaseOutcome} 对齐的对外三态;取消场景连接可能已断,
* 不一定能发出 done(CANCELLED),故此处通常只有 SUCCESS / FALLBACK / FAILED。
*/
public enum SseOutcome {
/** 对应成功报告 content。 */
SUCCESS,
/** 对应安全降级 content。 */
FALLBACK,
/** 对应 failure 事件或失败 done(实现以 ChatSseSession 为准)。 */
FAILED
}
@@ -1,11 +1,31 @@
package com.superbiz.agent.harness.core;
/**
* 硬预算维度:哪一类资源超限(Core / RunBudget)。
*
* <p>超限时抛 {@link BudgetExceededException},并常将 RunState 置为 {@code BUDGET_EXHAUSTED}。
* 这是资源账,不是「信息是否还有增益」(后者看 progress / DiagnosisStopReason)。
*/
public enum BudgetKind {
/** 整次 Run 允许的模型调用次数。 */
MODEL_CALLS,
/** 整次 Run 允许的工具调用总次数。 */
TOOL_CALLS,
/** 单个工具名的调用次数上限。 */
TOOL_CALLS_PER_TOOL,
/** 累计 input tokens。 */
INPUT_TOKENS,
/** 累计 output tokens。 */
OUTPUT_TOKENS,
/** 累计 total tokens。 */
TOTAL_TOKENS,
/** Run 级 UTF-8 字节预算(输入、工具结果、draft 等占用)。 */
RUN_BYTES
}
@@ -10,6 +10,17 @@ import java.time.Instant;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Harness Core:一次 Run 的执行规则(不是业务编排器)。
*
* <p>与 Application 的分工:
* <ul>
* <li>Application:走哪条路径、何时持久化、返回什么内容</li>
* <li>Core:是否仍可执行、资源是否允许、哪个终态生效</li>
* </ul>
*
* <p>不依赖 Spring AI;Router/Agent/Tool 在每次消耗前调用本类 API。
*/
public final class DiagnosisHarnessCore {
private final Clock clock;
@@ -66,6 +77,12 @@ public final class DiagnosisHarnessCore {
return startRun(sessionId, runIdSupplier.get());
}
/**
* 创建本次请求的 RunContext(显式上下文,不用 ThreadLocal)。
*
* <p>携带:身份(session/run)、deadline、预算、取消、生命周期、模型账本、进度收敛器。
* 后续所有模型与 Tool 调用必须传入同一个 context。
*/
public RunContext startRun(String sessionId, String runId) {
Instant deadline = clock.instant().plus(maxRunDuration);
RunCancellation cancellation = new RunCancellation();
@@ -81,10 +98,15 @@ public final class DiagnosisHarnessCore {
lifecycle,
new DiagnosisProgressTracker(stopAfterConsecutiveNoGain,
stopAfterConsecutiveProgressProtocolViolations));
// 取消信号与终态联动:第一个终态获胜,迟到结果不能覆盖
cancellation.onCancel(reason -> lifecycle.finish(terminalState(reason), reason.name()));
return context;
}
/**
* 执行前闸门:已终态 / 超时 / 已取消 → 抛 RunAbortedException。
* Router、Agent、Tool、Guard 在关键步骤前都会走到这里。
*/
public void checkActive(RunContext context) {
Objects.requireNonNull(context, "context must not be null");
context.lifecycle().termination().ifPresent(termination -> {
@@ -102,11 +124,13 @@ public final class DiagnosisHarnessCore {
}
}
/** 模型调用前:检查 active + 预留模型调用预算。 */
public void beforeModelCall(RunContext context) {
checkActive(context);
applyBudget(context, context.budget()::reserveModelCall);
}
/** 工具调用前:检查 active + 按工具名预留工具预算。 */
public void beforeToolCall(RunContext context, String toolName) {
checkActive(context);
applyBudget(context, () -> context.budget().reserveToolCall(toolName));
@@ -1,9 +1,30 @@
package com.superbiz.agent.harness.core;
/**
* 触发 Run 取消 / 与取消联动写终态的原因(Core)。
*
* <p>由 {@link RunCancellation#cancel} 携带;Core 会映射到对应 {@link RunState}:
* <ul>
* <li>CLIENT_DISCONNECTED / USER_REQUESTED → CANCELLED</li>
* <li>DEADLINE_EXCEEDED → TIMED_OUT</li>
* <li>BUDGET_EXHAUSTED → BUDGET_EXHAUSTED</li>
* <li>INTERNAL_FAILURE → FAILED</li>
* </ul>
*/
public enum RunCancellationReason {
/** SSE/HTTP 客户端断开。 */
CLIENT_DISCONNECTED,
/** 显式用户取消(若产品支持)。 */
USER_REQUESTED,
/** 墙钟超过 Run deadline。 */
DEADLINE_EXCEEDED,
/** 硬预算耗尽(与 BudgetExceededException 联动)。 */
BUDGET_EXHAUSTED,
/** 应用判定内部失败并收尾。 */
INTERNAL_FAILURE
}
@@ -1,11 +1,34 @@
package com.superbiz.agent.harness.core;
/**
* 一次 Run 的内存生命周期状态(Core / RunLifecycle)。
*
* <p>层级:执行控制面。first-terminal-wins:第一个写入的终态不可被迟到结果覆盖。
*
* <p>不要直接当成用户看到的结果:
* <ul>
* <li>用户内容结局看 {@code ReleaseOutcome} / SSE</li>
* <li>本枚举回答「Run 技术上是否还允许继续执行」</li>
* </ul>
*/
public enum RunState {
/** 仍可执行(未终态)。 */
RUNNING(false),
/** 正常完成(Application completeSuccess)。 */
SUCCESS(true),
/** 内部失败终态(completeFailure 等)。 */
FAILED(true),
/** 取消终态(客户端断开、用户请求等)。 */
CANCELLED(true),
/** 超过 Run deadline。 */
TIMED_OUT(true),
/** 模型/工具/Token/字节等硬预算耗尽。可与 ReleaseOutcome.FALLBACK 并存。 */
BUDGET_EXHAUSTED(true);
private final boolean terminal;
@@ -14,6 +37,7 @@ public enum RunState {
this.terminal = terminal;
}
/** 是否已是终态(终态后 checkActive 会 abort)。 */
public boolean isTerminal() {
return terminal;
}
@@ -1,25 +1,76 @@
package com.superbiz.agent.harness.guard.evidence;
/**
* Evidence Guard 结构/引用违规码(规则门控,通常不调大模型)。
*
* <p>层级:Guard。校验 DiagnosisDraft 是否引用真实 tool_call、字段是否齐全等。
* 失败时可尝试 EvidenceRepair;仍失败则 FallbackType.EVIDENCE_VALIDATION_FAILED。
*
* <p>与 SemanticVerdict 不同:这里只问「引用是否真实、结构是否合法」,
* 不问「结论语义是否夸大」。
*/
public enum EvidenceViolationCode {
/** 缺少整份 draft。 */
DRAFT_MISSING,
/** 缺少 analysis 列表或为空。 */
ANALYSIS_MISSING,
/** analysis 条目缺少 id。 */
ANALYSIS_ID_MISSING,
/** analysis id 重复。 */
ANALYSIS_ID_DUPLICATE,
/** analysis 缺少 kind。 */
ANALYSIS_KIND_MISSING,
/** analysis 缺少正文。 */
ANALYSIS_TEXT_MISSING,
/** 需要工具引用但未提供。 */
TOOL_REFERENCE_MISSING,
/** 报告级必填文本缺失。 */
REPORT_TEXT_MISSING,
/** 结论等引用了 analysis,但引用列表缺失。 */
ANALYSIS_REFERENCE_MISSING,
/** 引用了不存在的 analysis id。 */
ANALYSIS_REFERENCE_UNKNOWN,
/** 缺少 limitations(诊断契约要求声明范围/缺口)。 */
LIMITATIONS_MISSING,
/** tool 引用字段非法。 */
TOOL_REFERENCE_INVALID,
/** 引用的 tool_call 在本 Run 账本中不存在。 */
INVOCATION_MISSING,
/** tool_call_id 与账本记录不匹配。 */
INVOCATION_ID_MISMATCH,
/** 该 invocation 状态不可被引用(非 READY 等)。 */
INVOCATION_NOT_REFERENCABLE,
/** 证据 kind 与工具/投影约定不符。 */
EVIDENCE_KIND_MISMATCH,
/** 引用了不支持的工具名。 */
TOOL_UNSUPPORTED,
/** 投影结果不可用于校验。 */
PROJECTION_INVALID,
/** 投影 id 与引用不一致。 */
PROJECTION_ID_MISMATCH,
/** 投影状态与引用期望不一致。 */
PROJECTION_STATUS_MISMATCH,
/** 从 canonical store 查找调用记录失败。 */
CANONICAL_LOOKUP_FAILED
}
@@ -1,6 +1,16 @@
package com.superbiz.agent.harness.progress;
/**
* 证据收集阶段状态机(Progress 层,与 RunState 独立)。
*
* <p>SATURATED 表示「再查也难有信息增益」,会触发 stop_required;
* 不等于 Run 已经 BUDGET_EXHAUSTED 或对外已经 FALLBACK。
*/
public enum DiagnosisCollectionState {
/** 仍允许在预算内发起工具调用(需遵守进度协议)。 */
COLLECTING,
/** 已饱和:交付一次 STOP 指示后,再要工具可抛 DiagnosisCollectionStoppedException。 */
SATURATED
}
@@ -1,7 +1,26 @@
package com.superbiz.agent.harness.progress;
/**
* 诊断「证据收集」为何受控停止(Progress 层)。
*
* <p>层级:Agent 收集收敛,不是对外 FallbackType。
* 出现在 {@code DiagnosisAgentExecution.stopped(...)} 与 Tool 侧 stop_required 观察里。
*
* <p>与预算的关系:
* <ul>
* <li>INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED:业务上继续查已无价值</li>
* <li>BUDGET_LIMIT_REACHED:硬资源没了(可与 RunState.BUDGET_EXHAUSTED 对应)</li>
* </ul>
* 有安全 observed facts 时,Release 常映射为 FallbackType.INSUFFICIENT_EVIDENCE。
*/
public enum DiagnosisStopReason {
/** 连续无信息增益(或等价饱和策略),收集状态进入 SATURATED。 */
INFORMATION_SATURATED,
/** 模型次数/工具次数/Token/bytes 等硬预算触顶。 */
BUDGET_LIMIT_REACHED,
/** 进度协议连续违规达到阈值(envelope / previous_observation 等)。 */
PROGRESS_PROTOCOL_VIOLATED
}
@@ -1,6 +1,16 @@
package com.superbiz.agent.harness.progress;
/**
* 单次工具观察相对已有收集是否带来新信息(Progress 协议字段)。
*
* <p>模型在后续 tool envelope 的 previous_observation 中声明;
* 连续 NO_GAIN 可推动 CollectionState → SATURATED。
*/
public enum InformationGain {
/** 相对已完成查询有新的可用信息。 */
GAINED,
/** 无新增信息(含重复 scope、空证据等由 Harness 判定的情况)。 */
NO_GAIN
}
@@ -1,9 +1,26 @@
package com.superbiz.agent.harness.progress;
/**
* 工具调用「进度协议」违规类型(Progress / ToolInterceptor)。
*
* <p>Agent 每次调证据工具应带合法 Envelope(business input + 可选 previous_observation)。
* 违规通常先返回可修复的 error observation;连续违规可导致
* {@link DiagnosisStopReason#PROGRESS_PROTOCOL_VIOLATED}。
*/
public enum ProgressProtocolViolationType {
/** 非首次工具调用缺少对上一观察的 previous_observation。 */
MISSING_PREVIOUS_OBSERVATION,
/** previous_observation 指向的 tool_call_id 与账本顺序不符。 */
OUT_OF_ORDER_PREVIOUS_OBSERVATION,
/** 出现了协议不允许的 previous_observation(例如首次就带、或指向未知 id)。 */
UNEXPECTED_PREVIOUS_OBSERVATION,
/** 缺少必填 business input。 */
MISSING_INPUT,
/** 整体 Envelope 结构非法(JSON/字段形态不对)。 */
INVALID_ENVELOPE
}
@@ -1,14 +1,43 @@
package com.superbiz.agent.harness.retry;
/**
* 重试框架对失败的分类(Retry 层)。
*
* <p>决定某次 attempt 是否允许再试:仅 TIMEOUT/TRANSPORT 等技术类通常可重试;
* CANCELLED / BUDGET_EXHAUSTED / 业务拒绝等必须透出,不能被重试吞掉。
*
* <p>用于 Intent Router、Semantic Guard 等包在 {@code HarnessRetryExecutor} 里的模型调用,
* 不是 Diagnosis Agent 主 ReAct 的自动重试分类(Agent 默认不做隐藏重试)。
*/
public enum RetryFailure {
/** 单次 attempt 或总超时。 */
TIMEOUT,
/** 网络/传输层失败。 */
TRANSPORT,
/** 模型输出内容不符合约定(字段/枚举等)。 */
INVALID_OUTPUT,
/** JSON 等解析失败。 */
PARSE_ERROR,
/** 结构符合 JSON 但 schema/字段约束失败。 */
SCHEMA_INVALID,
/** 业务上判定无可用证据(若某组件使用该分类)。 */
NO_EVIDENCE,
/** 业务策略拒绝,不可靠重试改变结果。 */
BUSINESS_REJECTION,
/** Run 已取消,禁止重试。 */
CANCELLED,
/** 预算耗尽,禁止重试。 */
BUDGET_EXHAUSTED,
/** 未归类。 */
UNKNOWN
}
@@ -1,17 +1,52 @@
package com.superbiz.agent.harness.tool.boundary;
/**
* 工具边界(ToolBoundary)单次调用错误码。
*
* <p>层级:Tool 执行边界。出现在 ToolBoundaryResult.error / 投影给 Agent 的 error observation。
* 多数情况下 <b>不抛异常打断 ReAct</b>,而是把错误变成安全 observation 回注模型;
* 预算类还会 markBudgetLimitReached,后续模型轮次再由 ModelInterceptor 闸住。
*
* <p>与 {@code ChatFailureCode}、{@code FallbackType} 不同:这是单次 tool 级错误,不是整次 Chat 结局。
*/
public enum ToolBoundaryErrorCode {
/** 请求 JSON / 参数不合法。 */
INVALID_REQUEST,
/** tool_call_id 缺失或格式非法。 */
INVALID_TOOL_CALL_ID,
/** 请求中的 runId 与当前 RunContext 不一致。 */
RUN_MISMATCH,
/** 未授权调用该工具(策略拒绝)。 */
UNAUTHORIZED,
/** 工具被要求只读,但请求带有写副作用语义。 */
NOT_READ_ONLY,
/** Run 已终态/取消/超时,不再执行工具(对应 RunAborted)。 */
RUN_INACTIVE,
/** 本工具调用触达硬预算(次数/bytes 等)。 */
BUDGET_EXHAUSTED,
/** 相同 tool_call_id 重复执行(幂等/防重)。 */
DUPLICATE_TOOL_CALL,
/** 原始结果或投影结果超过大小上限。 */
RESULT_TOO_LARGE,
/** 底层 executor 执行失败或返回 null。 */
TOOL_EXECUTION_ERROR,
/** raw → agent 投影失败。 */
PROJECTION_ERROR,
/** 投影后的 evidence_status 与契约不符。 */
INVALID_EVIDENCE_STATUS,
/** canonical store 读写失败。 */
STORE_ERROR
}