Compare commits

...
Author SHA1 Message Date
zhuyongxin 83193bdf4a docs(harness): add overall architecture learning note
- Add architecture note: config assembly (three-layer organization),
  HTTP entry (thin controller + SSE state machine + disconnect cancel),
  session & memory system (PreviousTurn injection, terminology calibration,
  skill as procedural long-term memory), knowledge base write pipeline
2026-08-10 18:35:23 +08:00
zhuyongxin de56551fea docs(harness): add LLM judge design note (interview Q&A) 2026-08-10 11:34:37 +08:00
zhuyongxin da18fdf4e1 docs(harness): add agent domain learning note and mark all nine domains complete
- Add agent note: factory assembly, dual interceptors (model budget/token audit,
  tool five gates), use case loop shell, controlled stop recovery, dual-view projection
- Mark agent domain complete in roadmap (all nine domains done)
2026-08-10 10:12:04 +08:00
aruo e1b8d1fb2c docs(harness): add evidence-chain, application-audit, contract-state and interview review notes; annotate guard/release core classes 2026-08-08 00:12:25 +08:00
zhuyongxin 074d1aa5a9 docs(harness): add MySQL sandbox learning note and mark tool domain complete
- Add MySQL sandbox note: three defense layers (semantic/connection/output),
  fail-closed validation, allowlist, parameterization, cancellation, redaction
- Mark tool domain complete in roadmap; next: guard
2026-08-06 17:46:53 +08:00
zhuyongxin f26d395650 docs(harness): annotate RAG backend core classes and add retrieval learning note
- Annotate LookupKnowledgeTool, KnowledgeEvidencePostProcessor, RrfFusion, KnowledgeDocumentRetriever
- Add RAG retrieval learning note: L0 navigation, multi-recall + RRF, qualityScore,
  degradation, contract semantics, validation (audit + offline eval), discussion insights
2026-08-05 18:37:50 +08:00
zhuyongxin ff0752a16c docs(harness): annotate tool domain classes and add tool chain learning notes
- Annotate 43 tool domain classes (contract/projection/boundary/store/adapter/mysql)
- Add tool registration and execution chain learning note
- Add tool call chain runtime journey note (model decision to observation)
2026-08-04 18:37:17 +08:00
zhuyongxin 7844bcea40 docs(harness): annotate progress core classes and add code-level learning notes
- Annotate DiagnosisProgressTracker, HarnessToolInterceptor, DiagnosisProgressProjector,
  DiagnosisReleaseUseCase, ToolBoundary, ToolBoundaryResult, CanonicalToolInvocation
- Add progress code learning note: interceptor gates, tracker state machine,
  canonical lifecycle, execution gate, projection/release pipeline
- Update learning roadmap: progress marked as deeply learned, next is tool domain
2026-08-03 18:37:23 +08:00
wdm1802 e564863c43 docs(harness): add retry guide and learning roadmap; annotate retry core 2026-08-03 01:29:36 +08:00
wdm1802 d084202166 docs(harness): add budget flow sequence and execution control notes 2026-08-02 20:58:13 +08:00
zhuyongxin 5b2fb985d9 docs(harness): annotate entry orchestration, enums, and exception recovery paths 2026-07-30 19:08:03 +08:00
zhuyongxin b39a625e5b docs(mvp): add remaining harness audit guides and engineering index 2026-07-30 19:04:59 +08:00
zhuyongxin e9f1c48d34 docs(harness): add loop inner/outer exception handling guide 2026-07-30 18:55:43 +08:00
zhuyongxin 3a7eee8af4 docs(mvp): add harness design and progressive guides 2026-07-29 19:04:44 +08:00
zhuyongxin 584639fa2a docs(mvp): move engineering notes under mvp/engineering
Relocate RAG and diagnosis decision/E2E writeups from docs/ root into
mvp/engineering so architecture, issues, and engineering narrative stay
together. Update indexes and cross-links; leave docs/learning as legacy.
2026-07-29 10:49:45 +08:00
zhuyongxin bdac35567c docs(issues): add ISS-017 L0 filter fallback hardening
Track L0 hard-filter + unfiltered retry brittleness. Keep current
behavior; prefer A+C later and defer soft-constraint redesign (D).
2026-07-29 10:48:47 +08:00
153 changed files with 12620 additions and 183 deletions
@@ -14,7 +14,7 @@ lookup_knowledge 同文档多 chunk 在后处理与投影阶段被 source 级去
## 范围
Delivery 1 only(见 `docs/Milvus-Hybrid接入清单.md` §1.1)。
Delivery 1 only(见 `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1)。
## 非目标
@@ -93,7 +93,7 @@ Reference files:
- `RagResultProjector.java`
- `LookupKnowledgeToolTest.java`
- `RagResultProjectorTest.java`
- `docs/Milvus-Hybrid接入清单.md`
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md`
Stack notes:
@@ -9,7 +9,7 @@
## 规格证据
- 旧 `openspec/specs/rag-knowledge-retrieval` 要求 source 级 dedup(本 change 以 delta 修正)
- `docs/Milvus-Hybrid接入清单.md` §1.1 定义 Delivery 1 地基
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1 定义 Delivery 1 地基
## 验证证据
+56 -101
View File
@@ -1,120 +1,75 @@
# 文档索引
## 📂 目录结构
**更新日期**:2026-07-29
```
## 目录结构
```text
docs/
├── README.md # 项目文档总览
├── INDEX.md # 本索引文件
│
├── learning/ # 📚 学习笔记(个人学习理解)
│ ├── 00-项目学习路径.md
│ ├── 01~08-*.md # 按学习顺序编号
│ └── README.md
│
├── analysis/ # 🔍 分析笔记(代码/问题分析)
│ ├── essence-report-*.md
│ ├── explore-report.md
│ ├── chunking-issues-analysis.md
│ └── 功能分析报告.md
│
├── reports/ # 📝 临时报告(修复/验证报告)
│ ├── 修复报告-*.md
│ ├── 验证报告-*.md
│ └── 日志配置完成总结.md
│
└── guides/ # 📖 指南文档
└── 日志配置与分析指南.md
├── INDEX.md # 本索引
├── learning/ # 早期学习笔记(可能过时)
├── analysis/ # 早期代码/问题分析
├── reports/ # 历史修复/验证报告
└── guides/ # 操作指南
mvp/ # 现行 MVP 文档(主入口)
├── architecture/ # 现行架构:系统现在怎么跑
├── engineering/ # 工程纪要:问题 / 决策 / E2E
├── issues/ # 未完成事项
├── tables/ # 表结构
├── demo/ # Demo
└── eval/ # 诊断评测材料
```
**⚠️ 注意:MVP 架构设计文档已移至项目根目录 `../mvp/`**
查看 [mvp/README.md](../mvp/README.md) 了解 MVP 架构、数据库设计、实施计划等。
**现行架构、工程纪要、表结构、Issue 均以 [mvp/README.md](../mvp/README.md) 为准。**
本目录 `learning/` / `analysis/` / `reports/` 偏早期学习与历史记录,**可能与当前实现不一致**。
---
## 🚀 快速导航
## 快速导航
### 我是新人/学习者
1. [项目学习路径](learning/00-项目学习路径.md) - 从这里开始
2. [learning/README.md](learning/README.md) - 学习笔记索引
3. 按编号顺序阅读 `learning/` 目录下的文档
### 我是开发者(优先)
### 我是开发者
👉 **MVP 架构设计文档已移至 `../mvp/`**
1. [mvp/README.md](../mvp/README.md) — MVP 总入口
2. [mvp/architecture/](../mvp/architecture/) — 现行架构
3. [mvp/engineering/](../mvp/engineering/) — 工程纪要(RAG / 诊断 E2E)
4. [mvp/issues/](../mvp/issues/) — 活跃 Issue
请查看 [mvp/README.md](../mvp/README.md) 了解:
- MVP 架构设计
- 数据库设计和表结构
- 实施规划(Phase 1/2/3)
- 会话管理设计
### RAG 工程纪要(已迁入 mvp)
### 我要查看分析报告
1. [分析笔记目录](analysis/) - 代码分析和问题分析
2. [临时报告目录](reports/) - 修复和验证报告
1. [RAG 排序:多路召回与 RRF](../mvp/engineering/rag/RAG排序-多路召回与RRF.md)
2. [Hybrid 之后的 qualityScore 与后处理](../mvp/engineering/rag/RAG-Hybrid质量分与后处理.md)
3. [Agent 如何读 relevance_level](../mvp/engineering/rag/RAG-Agent如何读relevance_level.md)
4. [RAG 离线评测:基线设计](../mvp/engineering/rag/RAG离线评测-基线设计.md)
5. [Milvus hybrid 接入清单](../mvp/engineering/rag/Milvus-Hybrid接入清单.md)
6. [RAG 审计补丁 E2E](../mvp/engineering/rag/RAG审计补丁-stepid-query-E2E验收.md)
7. 架构对照:[RAG 知识检索架构](../mvp/architecture/RAG知识检索架构.md)、[RAG 检索可观测性与审计](../mvp/architecture/RAG检索可观测性与审计.md)
### RAG 设计讨论(docs 根目录)
1. [RAG 排序:多路召回与 RRF](RAG排序-多路召回与RRF.md) - K、融合、L0 边界
2. [Hybrid 之后的 qualityScore 与后处理](RAG-Hybrid质量分与后处理.md) - 上一代问题、L2 伪装、统一归一化
3. [Agent 如何读 relevance_level](RAG-Agent如何读relevance_level.md) - 粗相关度标签的含义与误读
4. [RAG 离线评测:讨论、设计与落地](RAG离线评测-基线设计.md) - Golden/Fixture、流程图、hybrid 对齐与闸门修复
5. [Milvus hybrid 接入清单](Milvus-Hybrid接入清单.md)
6. [RAG Trace / 审计(架构)](../mvp/architecture/RAG检索可观测性与审计.md) - 请求内 trace、tool_invocation、Trace API
### 诊断全流程(已迁入 mvp)
### 诊断全流程(E2E 导读)
1. [一次诊断到底发生了什么](一次诊断全流程-E2E导读.md) - SUCCESS 全流程:阶段拆解、token、timeline、字段词典
2. [RAG 审计补丁 E2E:step_id + query](RAG审计补丁-stepid-query-E2E验收.md) - 审计字段 live 验收(含业务 FALLBACK 样本)
1. [一次诊断到底发生了什么](../mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md)
### 我是新人 / 想看早期学习笔记
1. [项目学习路径](learning/00-项目学习路径.md)
2. [learning/README.md](learning/README.md)
3. 注意:内容可能过时,实现以 `mvp/architecture` 为准
### 分析 / 历史报告 / 指南
- [analysis/](analysis/) — 早期分析
- [reports/](reports/) — 历史修复与验证报告
- [guides/日志配置与分析指南.md](guides/日志配置与分析指南.md)
---
## 📚 学习笔记 (learning/)
## 文档维护
按学习顺序编号,建议按顺序阅读:
1. [00-项目学习路径](learning/00-项目学习路径.md)
2. [01-AI-Ops-核心设计-Essence报告](learning/01-AI-Ops-核心设计-Essence报告.md)
3. [02-outputKey-深度解析](learning/02-outputKey-深度解析.md)
4. [03-核心疑问解答](learning/03-核心疑问解答.md)
5. [04-RAG-分块策略-Essence报告](learning/04-RAG-分块策略-Essence报告.md)
6. [05-文件上传自动索引-Essence报告](learning/05-文件上传自动索引-Essence报告.md)
7. [06-RAG查询流程-Essence报告](learning/06-RAG查询流程-Essence报告.md)
8. [07-Tool定义方式对比与优化](learning/07-Tool定义方式对比与优化.md)
9. [08-MethodToolCallback-vs-ToolCallingManager深度分析](learning/08-MethodToolCallback-vs-ToolCallingManager深度分析.md)
---
## 🔍 分析笔记 (analysis/)
代码分析和问题分析文档:
- [essence-report-rag.md](analysis/essence-report-rag.md)
- [essence-report-rag-chunking.md](analysis/essence-report-rag-chunking.md)
- [explore-report.md](analysis/explore-report.md)
- [chunking-issues-analysis.md](analysis/chunking-issues-analysis.md)
- [功能分析报告.md](analysis/功能分析报告.md)
---
## 📝 临时报告 (reports/)
修复报告和验证报告:
- [修复报告-多轮对话时间查询缓存问题](reports/修复报告-多轮对话时间查询缓存问题.md)
- [验证报告-时间查询问题](reports/验证报告-时间查询问题.md)
- [日志配置完成总结](reports/日志配置完成总结.md)
---
## 📖 指南文档 (guides/)
- [日志配置与分析指南](guides/日志配置与分析指南.md)
---
## 🔄 文档维护
- **学习笔记** 放在 `learning/` 目录,按编号顺序命名
- **分析笔记** 放在 `analysis/` 目录
- **临时报告** 放在 `reports/` 目录
- **指南文档** 放在 `guides/` 目录
- **MVP 架构设计** 已移至项目根目录 `../mvp/`(包含架构、数据库、实施计划)
| 类型 | 位置 |
|---|---|
| 现行架构 | `mvp/architecture/` |
| 工程纪要(问题/决策/E2E) | `mvp/engineering/` |
| Issue | `mvp/issues/` |
| 表结构 | `mvp/tables/` |
| 早期学习 | `docs/learning/`(归档向,不充当现行规范) |
| 操作指南 | `docs/guides/` |
+2 -2
View File
@@ -9,8 +9,8 @@ Production knowledge path: `MilvusHybridKnowledgeStore` with `retrieval.search.m
Related design notes:
- `docs/RAG-Hybrid质量分与后处理.md`
- `docs/RAG-Agent如何读relevance_level.md`
- `mvp/engineering/rag/RAG-Hybrid质量分与后处理.md`
- `mvp/engineering/rag/RAG-Agent如何读relevance_level.md`
- `mvp/architecture/RAG知识检索架构.md` §6
## Offline vs live
+11 -30
View File
@@ -1,60 +1,41 @@
# SuperBizAgent MVP 文档
**更新日期**:2026-07-23
**更新日期**:2026-07-29
本目录保存 MVP 阶段的架构、问题、演示、评测和数据表说明。当前材料按“当前入口”和“历史归档”拆开,避免把早期设计稿当成当前实现。
本目录保存 MVP 阶段的架构、工程纪要、问题、演示、评测和数据表说明。当前材料按“当前入口”和“历史归档”拆开,避免把早期设计稿当成当前实现。
## 当前入口
| 目录/文档 | 用途 |
|---|---|
| [architecture/README.md](architecture/README.md) | 当前 MVP 架构入口 |
| [architecture/README.md](architecture/README.md) | **现行架构**(系统现在怎么跑) |
| [engineering/README.md](engineering/README.md) | **工程纪要**(问题 / 决策 / 思路 / E2E 导读) |
| [architecture/current-mvp-architecture.md](architecture/current-mvp-architecture.md) | 当前可运行系统架构 |
| [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 |
| [architecture/harness-quality-gates.md](architecture/harness-quality-gates.md) | Harness 与质量门禁 |
| [architecture/session-trace-lifecycle.md](architecture/session-trace-lifecycle.md) | 会话与 Trace 生命周期 |
| [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前 hybrid 检索架构 |
| [issues/README.md](issues/README.md) | MVP issue 索引 |
| [tables/README.md](tables/README.md) | 当前 MySQL 表说明 |
| [demo/README.md](demo/README.md) | Demo 运行和演示材料 |
| [eval/README.md](eval/README.md) | 诊断评测材料 |
当前架构、运行链路和后续规划分别以 `architecture/`、`issues/README.md`、`tables/README.md` 以及 OpenSpec/devflow 的最新记录为准。
**读法**:改行为先看 `architecture/`;要理解「为什么这样定、踩过什么坑」再看 `engineering/`。
早期个人学习笔记仍在仓库根目录 `docs/learning/` 等,**可能过时**,不以之为现行口径。
## 文档结构
```text
mvp/
architecture/
architecture/ # 现行架构规范
engineering/ # 工程纪要(问题/决策/E2E)
README.md
current-mvp-architecture.md
agent-orchestration.md
harness-quality-gates.md
session-trace-lifecycle.md
archive/
2026-07-05-legacy/
2026-07-22-legacy/
issues/
README.md
active/
archived/
design-notes/
rag/
diagnosis/
issues/
tables/
README.md
*表-*.md
archive/
demo/
README.md
ten-minute-interview-demo.md
requests/
scripts/
output/
eval/
README.md
schema.md
cases/
fixtures/
reports/
archive/
```
@@ -388,7 +388,7 @@ flowchart LR
| 文档 | 内容 |
|------|------|
| `mvp/architecture/RAG知识检索架构.md` | 检索主架构 |
| `docs/RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `docs/RAG-Hybrid质量分与后处理.md` | quality / 排序闸门 |
| `docs/RAG离线评测-基线设计.md` | 离线评测(非运行时 Trace) |
| `../engineering/rag/RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `../engineering/rag/RAG-Hybrid质量分与后处理.md` | quality / 排序闸门 |
| `../engineering/rag/RAG离线评测-基线设计.md` | 离线评测(非运行时 Trace) |
| `mvp/architecture/session-trace-lifecycle.md` | 会话/run Trace 总览(若存在) |
+3 -1
View File
@@ -1,6 +1,6 @@
# MVP 架构文档
**更新日期**:2026-07-28
**更新日期**:2026-07-29
**状态**:当前单 Diagnosis Agent + Harness 架构
当前文档入口:
@@ -15,6 +15,8 @@
| [RAG知识检索架构.md](RAG知识检索架构.md) | 当前 `lookup_knowledge` 检索:MilvusClientV2 dense+BM25 hybrid、chunk 证据身份、重建运维 |
| [RAG检索可观测性与审计.md](RAG检索可观测性与审计.md) | RAG Trace / 审计:请求内 retrievalTrace、tool_invocation 富字段、Trace API 读法 |
**工程纪要**(问题 / 决策 / E2E,非架构规范正文)见 [../engineering/README.md](../engineering/README.md)。
2026-07-22 前的多角色编排、双入口和旧证据链文档已移动到 `archive/2026-07-22-legacy/`,仅用于历史决策追溯,不代表当前运行时。其中旧 RAG 描述(Spring AI VectorStore 主路径 + Milvus SDK fallback)已被当前 hybrid 实现取代,请以 [RAG知识检索架构.md](RAG知识检索架构.md) 为准。检索可观测与 Trace 以 [RAG检索可观测性与审计.md](RAG检索可观测性与审计.md) 为准(勿再依赖 archive 内旧 retrieval-observability)。
当前普通 Trace 与 LLM 步骤审计(`agent_reasoning_audit`:`reasoning_content` + `assistant_text`)使用独立存储和独立接口。
+85
View File
@@ -0,0 +1,85 @@
# MVP 工程纪要(Engineering Notes)
**更新日期**:2026-07-30
**定位**:项目推进中真实遇到的问题、决策、解决思路与 E2E 验收叙事。
与其它目录分工:
| 目录 | 读什么 |
|---|---|
| [architecture/](../architecture/) | **现行架构**(系统现在怎么跑) |
| **engineering/**(本目录) | **为何这样定**、踩坑、方案取舍、live 验收导读 |
| [issues/](../issues/) | 未完成事项与状态 |
| [tables/](../tables/) | 表结构说明 |
|
| `devflow/projects/` | 单次变更的 brief/decisions/evidence 切片 |
本文档**不是** API 规范的唯一真理源;冲突时以 `architecture/` 与代码为准。
---
## RAG
| 文档 | 内容 |
|---|---|
| [rag/RAG排序-多路召回与RRF.md](rag/RAG排序-多路召回与RRF.md) | K、多路融合、RRF、L0 边界 |
| [rag/RAG-Hybrid质量分与后处理.md](rag/RAG-Hybrid质量分与后处理.md) | qualityScore 统一、L2 伪装废止、后处理 |
| [rag/RAG-Agent如何读relevance_level.md](rag/RAG-Agent如何读relevance_level.md) | Agent 侧相关度标签含义与误读 |
| [rag/RAG离线评测-基线设计.md](rag/RAG离线评测-基线设计.md) | Golden/Fixture、hybrid 评测与闸门 |
| [rag/Milvus-Hybrid接入清单.md](rag/Milvus-Hybrid接入清单.md) | Hybrid 交付拆分与接入清单 |
| [rag/RAG审计补丁-stepid-query-E2E验收.md](rag/RAG审计补丁-stepid-query-E2E验收.md) | step_id / query 审计 live 验收 |
架构对照:
- [architecture/RAG知识检索架构.md](../architecture/RAG知识检索架构.md)
- [architecture/RAG检索可观测性与审计.md](../architecture/RAG检索可观测性与审计.md)
相关 Issue:
- [ISS-017 L0 过滤收窄与 Fallback 加固](../issues/active/ISS-017-rag-l0-filter-fallback-hardening.md)(暂缓,保持现网)
---
## Harness
| 文档 | 内容 |
|---|---|
| [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 和持久化生命周期及状态映射 |
| [harness/Harness设计-非确定性Agent的确定性控制边界.md](harness/Harness设计-非确定性Agent的确定性控制边界.md) | Harness 根问题、设计不变量、方案取舍、关键决策、代价与真实问题反推 |
| [harness/Harness组件全景-职责-设计原因与边界.md](harness/Harness组件全景-职责-设计原因与边界.md) | 当前 Harness 10 个职责域、全部生产类型、设计原因、作用与边界 |
| [harness/Harness-Tool双视图-从原始结果到可验证证据.md](harness/Harness-Tool双视图-从原始结果到可验证证据.md) | canonical truth、Control View、Agent Observation 与 metadata audit 的数据边界 |
| [harness/Harness证据安全链-从引用真实到结论可发布.md](harness/Harness证据安全链-从引用真实到结论可发布.md) | EvidenceGuard、EvidenceRepair、SemanticGuard 和唯一 Release Policy |
| [harness/Harness信息增益停止-让无证据诊断正常收敛.md](harness/Harness信息增益停止-让无证据诊断正常收敛.md) | GAINED/NO_GAIN、重复检测、协议停止、ProgressSnapshot 与过程型 Fallback |
---
## 审计
| 文档 | 内容 |
|---|---|
| [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
| 文档 | 内容 |
|---|---|
| [diagnosis/一次诊断全流程-E2E导读.md](diagnosis/一次诊断全流程-E2E导读.md) | SUCCESS 全流程:阶段、token、timeline、字段 |
| [diagnosis/Session-Run-Trace隔离-从串线到可回放.md](diagnosis/Session-Run-Trace隔离-从串线到可回放.md) | 多轮串线问题、身份拆分、协议迁移与 exact-run E2E |
架构对照:
- [architecture/current-mvp-architecture.md](../architecture/current-mvp-architecture.md)
- [architecture/session-trace-lifecycle.md](../architecture/session-trace-lifecycle.md)
- [architecture/agent-orchestration.md](../architecture/agent-orchestration.md)
+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,315 @@
# Session、Run、Trace 隔离:一次身份建模错误的修复
**更新日期**:2026-07-29
**性质**:MVP 工程问题与架构决策复盘
**结论状态**:Session / Run 身份模型沿用至当前架构
---
## 1. 问题到底是什么
一句话定义:**系统用同一个 `sessionId`,同时标识“多轮对话”和“一次诊断执行”,导致一次 Trace 不再对应一次真实执行。**
问题由一个两轮 E2E 暴露。用户在同一会话中连续发起两次请求:
```text
Round 1:诊断支付接口超时
Round 2:基于上一轮结论,列出还缺哪些证据
```
两轮复用 `sessionId` 是正确的,因为第二轮需要上一轮上下文。但当时持久化和 Trace 查询也只使用 `sessionId`:
- `diagnosis_session` 是覆盖写,第二轮把第一轮的 query、answer、status 覆盖掉;
- `agent_step` 和 `tool_invocation` 是追加写,两轮明细累积在同一个 `sessionId` 下;
- Trace API 再按 `sessionId` 聚合主表和明细。
结果不是简单的“重复数据”,而是一个系统中从未真实发生过的混合执行:
```mermaid
flowchart LR
R1["Round 1<br/>query A / steps A / tools A"] --> SID["同一个 sessionId"]
R2["Round 2<br/>query B / steps B / tools B"] --> SID
SID --> Main["diagnosis_session<br/>只剩 query B / answer B"]
SID --> Steps["agent_step<br/>steps A + steps B"]
SID --> Tools["tool_invocation<br/>tools A + tools B"]
Main --> Trace["混合 Trace"]
Steps --> Trace
Tools --> Trace
Trace --> Error["无法回答:<br/>哪组证据支持了哪次回答?"]
```
这个错误会沿数据链继续放大:
| 消费方 | 错误结果 |
|---|---|
| Trace 回放 | 一条 Trace 混合两轮步骤和 Tool |
| Verifier / Gatekeeper | 当前轮可能读到上一轮证据 |
| Evaluation | 评分对象和证据集合不再属于同一次执行 |
| Feedback | 无法确定用户评价的是哪一轮回答 |
| CaseLibrary | 可能把反馈沉淀到错误的 query/answer 上 |
因此,核心问题不是某个 Repository 的更新方式,而是**会话边界被错误地当成了证据与审计边界**。
---
## 2. 设计必须守住什么
修复前先定义四条不变量。后续方案不是凭表结构偏好选择,而是看能否同时满足这些约束。
### 不变量 1:一次执行只有一个稳定身份
从请求被接受到最终 `SUCCESS / FALLBACK / FAILED / CANCELLED`,必须有一个不可变 ID。模型步骤、Tool 调用、预算、最终答案和反馈都属于它。
### 不变量 2:一条 Trace 只描述一次执行
Trace 中的 Run、AgentStep、ToolInvocation 和生命周期事件必须使用同一个 exact ID 聚合,不能依赖时间邻近或“最新一条”猜测归属。
### 不变量 3:上下文连续不等于执行合并
同一个 Session 可以包含多个 Run。后一个 Run 可以读取前一轮安全发布结果,但不能继承前一轮的 Tool、Trace、评分或失败状态。
### 不变量 4:兼容不能伪造精度
旧客户端可以有迁移期 fallback,旧数据也可以保留;但系统必须让歧义可观察,不能把无法恢复的历史混合数据伪装成精确多轮记录。
这四条不变量共同导出一个结论:系统需要两个身份,而不是给 `sessionId` 增加更多解释。
---
## 3. 为什么不是在旧表上继续修
设计阶段考虑的不是“拆表还是不拆表”这一个问题,而是如何建立稳定的执行边界。
| 候选方案 | 能解决什么 | 为什么没有选择 |
|---|---|---|
| 继续复用 `sessionId`,修正覆盖逻辑 | 避免主表被覆盖 | 明细仍无法区分轮次,根因未解决 |
| 增加 `round_no` | 可以表示第几轮 | 并发请求、重试和多个执行入口下顺序不稳定;外部引用仍需复合身份 |
| 按时间窗口拆分历史 Step/Tool | 无需改协议 | 时间不能证明归属,会制造看似精确的错误 Trace |
| 每次请求插入一条新的 `diagnosis_session` | 形成一行一次执行 | 实际上已经引入 Run 概念,但名称和 Session 生命周期仍混淆,也缺少会话主实体 |
| 拆分 `chat_session` 与 `diagnosis_run` | 显式表达一对多生命周期 | 需要 Schema、API 和客户端迁移,但能满足全部不变量 |
最终选择最后一种。它的判断依据不是“范式更规范”,而是只有它能让对话连续性和执行可审计性同时成立。
---
## 4. 核心设计
目标模型是一个清晰的一对多关系:
```mermaid
flowchart TB
Client["Client"] --> Session["chat_session<br/>sessionId:多轮对话目录"]
Session --> Run1["diagnosis_run<br/>runId 1:一次执行"]
Session --> Run2["diagnosis_run<br/>runId 2:另一次执行"]
Run2 --> Step["agent_step<br/>模型步骤 metadata"]
Run2 --> Tool["tool_invocation<br/>Tool 审计 metadata"]
Run2 --> Event["diagnosis_trace_event<br/>生命周期 Timeline"]
Run2 --> Reasoning["agent_reasoning_audit<br/>受限原文"]
Run2 --> Trace["普通 Trace API"]
Step --> Trace
Tool --> Trace
Event --> Trace
Reasoning --> Audit["独立 Reasoning API"]
```
这里有三个不同的职责:
- `chat_session` 回答“哪些 Run 属于同一段对话”;
- `diagnosis_run` 回答“这次请求的输入、状态、结果和资源消耗是什么”;
- Trace 回答“这个 Run 具体经历了什么”,它是按 Run 聚合的读模型。
---
## 5. 六项关键决策
### 决策 1:引入正式的 `runId`
**选择**:每次有效 Chat 执行创建新的 `runId`,并通过 SSE metadata 返回 `sessionId + runId`。
**理由**:Run 必须能被 API、数据库、日志、Feedback 和评测独立引用。数据库自增 ID 不适合作为外部协议;轮次编号又不能稳定处理并发和重试。
**代价**:客户端必须保存并向后续 Trace/Feedback 请求传递 `runId`。
**边界**:`runId` 是不透明标识。早期实现使用 `run-` + UUID,后续格式发生过演进,客户端不得解析其前缀或长度。
### 决策 2:拆分会话态与运行态
**选择**:新增 `chat_session` 和 `diagnosis_run`,旧 `diagnosis_session` 停止承载新的运行写入。
**理由**:两者生命周期不同。
| 对象 | 保存内容 | 更新特点 |
|---|---|---|
| `chat_session` | 会话状态、轮次数、最近活动时间等目录信息 | 跨多轮持续更新 |
| `diagnosis_run` | 单次 query、answer、终态、intent、release outcome、预算 | 一次执行内从 RUNNING 走向唯一终态 |
完整多轮正文没有因为拆表就复制到 MySQL。身份拆分解决的是审计归属,不应顺带扩大长期数据保存范围。
### 决策 3:Trace 是 Run 的聚合视图,不另造身份
**选择**:第一阶段复用 `agent_step` 和 `tool_invocation`,增加 `run_id`;不为了修复隔离问题再创建一个独立 `traceId`。
**理由**:隔离所缺的是执行外键,不是第三套身份。引入 `traceId` 只会产生 `sessionId / runId / traceId` 的映射问题。
后续单 Agent + Harness 重构新增 `diagnosis_trace_event`,用于表达统一生命周期 Timeline。这是 Run 下的新明细,不是新的聚合根,也没有改变 `runId` 的边界。
### 决策 4:exact Run 是目标协议,latest Run 只是迁移桥梁
**选择**:目标查询使用:
```http
GET /api/diagnosis/{sessionId}/trace?runId={runId}
```
服务端同时验证 Run 存在且属于 path 中的 Session,防止跨 Session 串读。
旧客户端暂时只传 `sessionId` 时,可以解析 latest Run;但这是显式兼容路径,不是新的业务语义。如果必须计算 latest,应按:
```text
created_at DESC, id DESC
```
而不是 `updated_at`。旧 Run 可能因 Feedback 或异步处理再次更新,最近修改不等于最近执行。
### 决策 5:所有下游语义绑定 Run
**选择**:Trace、Feedback、Evaluation、Tool evidence 和新 Case provenance 都以 `runId` 为执行边界。
**理由**:这些对象评价或引用的是一次回答,不是整段会话。
Feedback 在迁移期缺少 `runId` 时可以绑定 latest Run,但响应必须暴露 `fallbackToLatestRun=true`。兼容如果不可观察,就会从临时措施变成永久歧义。
`case_library.diagnosis_id` 因复用旧列,在过渡期存在历史 `session_id` 和新 `run_id` 两种语义。这是明确接受的迁移成本,而不是应被隐藏的数据一致性。
### 决策 6:历史混合数据不做推测性拆分
**选择**:每条旧 `diagnosis_session` 最多映射为一个 compatibility Run,不根据时间或 Agent 名称猜测真实轮次。
**理由**:旧数据没有记录边界,任何自动拆分都只能产生无法证明的归属。审计系统宁可明确“不知道”,也不能制造虚假的精确回放。
---
## 6. 协议和影响范围
这是一次有意的行为与协议变化,不是纯内部重构。
| 范围 | 变化 | 受影响方 |
|---|---|---|
| Chat / SSE | metadata 增加 `runId` | 前端、脚本、调用方 |
| Trace API | 支持 exact `runId` 查询 | Trace UI、排障工具、评测 |
| Run API | 提供 Session 下的 Run 列表 | 多轮历史浏览 |
| Feedback | request/response 增加 Run 绑定和 fallback 标志 | 前端、CaseLibrary |
| 数据库 | 新增两张主表,明细增加 `run_id` | 持久化、迁移、查询脚本 |
| 其它入口 | 当时的 AIOps 同步采用 Run 边界 | SSE 消费方、Trace |
之所以把当时的 AIOps 一并迁移,是因为它同样会产生可回放执行;只修 Chat 会留下第二条具有同类缺陷的数据链。后续 ISS-014 删除了旧 AIOps 双入口,但这不改变当时“所有执行入口必须共享 Run 边界”的设计判断。
---
## 7. 风险如何处理
### 风险 1:兼容路径继续产生歧义
控制方式是让 fallback 可观察,并把 exact `runId` 定义为目标协议。兼容是迁移机制,不能反向成为领域模型。
### 风险 2:异步链路丢失或串用身份
`sessionId` 与 `runId` 必须作为同一执行上下文传播。当前架构将二者放入显式 `RunContext`,ToolBoundary、审计 Hook 和持久化都校验当前 Run,避免只依赖线程隐式状态。
### 风险 3:历史和新 provenance 共用旧列
保留旧列降低了迁移破坏性,但查询和文档必须承认双语义,不能把旧 `session_id` 当作非法 `run_id` 清理。
### 风险 4:旧混合 Trace 永远无法恢复
这是明确接受的事实。系统保留 compatibility 访问和回滚能力,但不承诺不存在的历史精度。
---
## 8. 如何证明设计成立
验收问题不是“接口里有没有 `runId`”,而是下面五个条件是否同时成立:
```text
同一 Session 连续执行两轮
+ 两轮获得不同 runId
+ 每个 exact Trace 只返回本 Run 明细
+ 数据库不存在跨 Run 混合行
+ 第二轮仍能使用会话上下文
```
真实 E2E 使用:
```text
sessionId = e2e-phase6-chat-codex-20260710-2120
run1 = run-e2a97696-4398-4abc-90e4-28f45c838f92
run2 = run-76ce6a6e-92ab-40c9-800a-eca0c1bb5172
```
结果:
- 两轮 Chat 成功并复用同一个 `sessionId`;
- 两轮返回不同 `runId`;
- run1 exact Trace 只返回 run1,run2 exact Trace 只返回 run2;
- `diagnosis_run` 中存在两条独立运行记录;
- AgentStep:run1 为 10 行,run2 为 9 行;
- ToolInvocation:run1 为 14 行,run2 为 8 行;
- mixed row check 为 0;
- `chat_session.message_pair_count = 2`,上下文连续性没有因隔离而丢失;
- focused tests、baseline diff、日志和数据库核验通过,未观察到 baseline drift。
这组证据同时验证了“该分开的确实分开”和“该连续的仍然连续”。
---
## 9. 后续演进验证了什么
系统后来从多角色 Agent 编排重构为单 Diagnosis ReAct Agent + Harness。执行结构发生了大变化,但 Session / Run 模型没有被替换,反而成为新架构的基础:
- `ChatApplicationUseCase` 创建和结束 Run;
- `RunContext` 显式携带 `sessionId + runId`;
- ToolBoundary 校验 Tool 请求属于当前 Run;
- Redis canonical invocation 使用 `runId + toolCallId` 定位;
- EvidenceGuard 只接受当前 Run 的 READY invocation;
- AgentStep、ToolInvocation、TraceEvent 和 ReasoningAudit 都绑定 Run。
这说明当时解决的不是某一版代码的局部 bug,而是找到了稳定的领域边界。Agent 编排可以替换,Session 与 Run 的生命周期差异不会消失。
---
## 10. 可复用的设计判断
这次问题可以归纳为四条通用经验:
1. **生命周期不同的对象,不应共享同一个聚合身份。**
2. **上下文复用不代表证据、状态和审计记录也可以复用。**
3. **兼容 fallback 必须可观察、可退出,不能静默猜测。**
4. **无法恢复的历史边界应明确降级,不能伪造精确性。**
判断类似系统是否存在同类问题,可以直接问:
- 一次请求是否有独立于 Session 的执行 ID?
- 所有 Step、Tool、Event、Feedback 是否都能精确归属一次执行?
- “查询最新”是否被误当成“查询指定执行”?
- 主表覆盖写、明细追加写是否使用了同一个过宽的关联键?
- 历史迁移是在保留不确定性,还是通过猜测制造精确性?
如果这些问题没有明确答案,那么 Trace 即使看起来完整,也未必能作为可信审计证据。
---
## 11. 资料索引
- [ISS-010:同 session 多轮诊断 Trace 隔离](../../issues/archived/ISS-010-session-run-trace-isolation.md)
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md)
- [当前 MVP 架构](../../architecture/current-mvp-architecture.md)
- [数据表索引](../../tables/README.md)
- [devflow brief](../../../devflow/projects/2026-07-10-session-run-trace-isolation/brief.md)
- [devflow decisions](../../../devflow/projects/2026-07-10-session-run-trace-isolation/decisions.md)
- [devflow acceptance](../../../devflow/projects/2026-07-10-session-run-trace-isolation/acceptance.md)
- [devflow evidence](../../../devflow/projects/2026-07-10-session-run-trace-isolation/evidence.md)
@@ -2,7 +2,7 @@
**日期**:2026-07-28
**状态**:SUCCESS 完整诊断主文档;工具阶段按**现行**审计能力(`step_id` / `query`)说明
**文档路径**:`docs/一次诊断全流程-E2E导读.md`
**文档路径**:`mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md`
### 主样本(正文数值与 timeline 来源)
@@ -19,14 +19,14 @@
**现行审计**(`step_id` 挂 step、`input_params.query` 等)已合入主路径,见 [§3.4.1](#341-step_id--query-审计如何写入2026-07-28-改造)。
专项 live 验收(含一次业务 FALLBACK 样本)见独立文档:
→ [RAG 审计补丁 E2E:step_id + query](RAG审计补丁-stepid-query-E2E验收.md)
→ [RAG 审计补丁 E2E:step_id + query](../rag/RAG审计补丁-stepid-query-E2E验收.md)
**关联文档**:
- [RAG Trace / 审计架构](../mvp/architecture/RAG检索可观测性与审计.md)
- [RAG qualityScore 与后处理](RAG-Hybrid质量分与后处理.md)
- [Agent 如何读 relevance_level](RAG-Agent如何读relevance_level.md)
- [知识检索架构](../mvp/architecture/RAG知识检索架构.md)
- [RAG Trace / 审计架构\](../../architecture/RAG检索可观测性与审计.md)
- [RAG qualityScore 与后处理](../rag/RAG-Hybrid质量分与后处理.md)
- [Agent 如何读 relevance_level](../rag/RAG-Agent如何读relevance_level.md)
- [知识检索架构\](../../architecture/RAG知识检索架构.md)
**回放 API**:
@@ -520,7 +520,7 @@ sequenceDiagram
| `tool_invocation.step_id` | 指向上述 step |
| `input_params.query` | 工具检索 query(安全截断后) |
live 数字级验收与 FALLBACK 对照见 → [审计补丁 E2E 专项](RAG审计补丁-stepid-query-E2E验收.md)。
live 数字级验收与 FALLBACK 对照见 → [审计补丁 E2E 专项](../rag/RAG审计补丁-stepid-query-E2E验收.md)。
#### 3.5 分数语义(避免误读)
@@ -949,7 +949,7 @@ flowchart TB
| Token 对账 | 通过 | 13236,`tokens_reconciled=true` |
| Trace 闭环 | 通过 | 2 steps / 1 tool / 15 events,persisted=returned |
| SUCCESS 报告 | 通过 | `DIAGNOSIS_REPORT`,action/rec 为空,refs=RAG |
| 现行审计能力 | 已合入代码 | 见 §3.4.1;专项 live 数字见 [审计补丁 E2E](RAG审计补丁-stepid-query-E2E验收.md) |
| 现行审计能力 | 已合入代码 | 见 §3.4.1;专项 live 数字见 [审计补丁 E2E](../rag/RAG审计补丁-stepid-query-E2E验收.md) |
### 9.2 仍未关闭的缺口
@@ -957,7 +957,7 @@ flowchart TB
|---|------|------|--------|
| 1 | 检索 top 混入弱相关源(如 payment-service-latency) | hybrid 生效但纯度一般;报告可克制,sources 仍可能挂噪声 | 后续过滤/重排 |
| 2 | Semantic Guard 约占 5k tokens | 正确但贵 | 后评估是否可降 |
| 3 | 偶发 hybrid 空结果 → FALLBACK | 环境/Milvus 抖动 | 见 [审计验收页 §4](RAG审计补丁-stepid-query-E2E验收.md#4-业务-fallback-原因与审计无关) |
| 3 | 偶发 hybrid 空结果 → FALLBACK | 环境/Milvus 抖动 | 见 [审计验收页 §4](../rag/RAG审计补丁-stepid-query-E2E验收.md#4-业务-fallback-原因与审计无关) |
缺口 1 示意:
@@ -1038,4 +1038,5 @@ python scripts/query_mysql.py "SELECT id, step_index, agent_name, has_tool_call,
| 2026-07-28 | 初版:SUCCESS 全流程、字段词典与多图 |
| 2026-07-28 | 审计补丁代码与 §3.4.1 能力说明 |
| 2026-07-28 | 文档迁入 `docs/`;INDEX 挂接 |
| 2026-07-28 | **拆分**:FALLBACK 审计验收样本迁至 [RAG审计补丁-stepid-query-E2E验收.md](RAG审计补丁-stepid-query-E2E验收.md);本文只保留 SUCCESS 主线 |
| 2026-07-29 | 迁入 `mvp/engineering/diagnosis/`,与 RAG 工程纪要集中到 mvp |
| 2026-07-28 | **拆分**:FALLBACK 审计验收样本迁至 [RAG审计补丁-stepid-query-E2E验收.md](../rag/RAG审计补丁-stepid-query-E2E验收.md);本文只保留 SUCCESS 主线 |
+378
View File
@@ -0,0 +1,378 @@
# Harness Context:统一术语与命名边界
**更新日期**:2026-07-29
**状态**:当前实现口径
**适用范围**:`com.superbiz.agent.harness`、Chat SSE、Diagnosis Run 持久化与相关工程文档
> 本文是术语词典,不建议第一次接触 Harness 时顺序阅读。入门请从 [README.md](README.md) 开始,遇到名词歧义时再回到本文查询。
## 1. 为什么需要这份 Context
当前系统同时存在 Run 状态、发布结果、Tool 状态、证据状态、收集状态、停止原因和 Fallback 原因。它们都使用了 `SUCCESS`、`ERROR`、`FAILED`、`READY` 等相近词汇,但回答的是不同问题。
如果把这些词排成一条“大状态机”,会产生错误理解,例如:
- `NO_EVIDENCE` 被理解为 Tool 调用失败;
- `FALLBACK` 被理解为 Run 执行失败;
- `SATURATED` 被理解为预算耗尽;
- `READY` 被理解为证据足以支持根因;
- 数据库 `status=SUCCESS` 被理解为已经找到根因。
本文件是 Harness 工程文档的术语入口。阅读其他文章前,先以这里的定义区分身份、数据、状态和组件责任。代码与现行架构文档仍是最终事实来源;本文件不创建新的运行协议。
## 2. 一句话定义 Harness
**Harness** 是包围非确定性模型执行的确定性控制边界:Agent 负责业务推理和 Draft,Harness 负责 Run 身份、生命周期、预算、取消、Tool 门禁、证据验真、停止控制、发布和审计。
Harness 不是:
- 业务工作流引擎;
- Planner / Executor / Verifier / Composer 编排图;
- 框架 ReAct loop 的第二份实现;
- 判断业务根因的规则引擎;
- 用于存放所有 Agent 相关代码的泛化名称。
## 3. 三层范围:不要把 Harness、Core 和 Application 当成同义词
| 名称 | 定义 | 包含 | 不包含 |
|---|---|---|---|
| Chat Application | 一次 Chat 请求的应用用例 | Session/Run 创建、路由、分支执行、持久化、公开结果 | HTTP/SSE 连接本身、业务推理细节 |
| Diagnosis Harness | Diagnosis Agent 外部的确定性控制系统 | Core、Interceptor、ToolBoundary、Progress、Guard、Release、Audit | 根因推理和 ReAct 规划 |
| Harness Core | 最小运行控制内核 | RunContext、deadline、budget、cancel、lifecycle、retry policy | 路由、Tool backend、Guard、持久化、SSE |
代码包 `com.superbiz.agent.harness` 同时包含 Chat Application 和 Diagnosis Harness 的实现,这是代码组织范围,不代表所有类都属于 Harness Core。
`DiagnosisHarnessCore` 目前也被 System Chat、Knowledge Query 和 Router 复用预算与生命周期能力。类名前缀保留了演进历史,概念上应理解为当前 Chat Run 的 Harness Core。
## 4. 身份术语
```mermaid
flowchart TB
S["Chat Session<br/>sessionId,多轮容器"] -->|"1:N"| R1["Run<br/>runId,一次请求"]
S -->|"1:N"| R2["Run<br/>下一次请求"]
R1 -->|"1:N"| AS["Agent Step<br/>一次模型步骤"]
R1 -->|"1:N"| TC["Tool Call / Invocation<br/>framework tool_call_id"]
R1 -->|"1:N"| TE["Trace Event<br/>sequence_no"]
TC --> CI["Canonical Invocation<br/>Run 内短期 Tool 真相"]
AS --> TR["Diagnosis Trace<br/>按 exact Run 聚合"]
TC --> TR
TE --> TR
R1 -.->|"SUCCESS diagnosis only"| PT["PublishedResult<br/>可投影为下一轮 PreviousTurn"]
```
图中的所有明细都必须绑定 exact runId。Session 只负责组织多轮,不能替代 Run 归属;Trace 是聚合视图,不能反过来成为执行身份。
### 4.1 Chat Session
一次多轮对话容器,由 `sessionId` 标识。同一个 Session 可以包含多次 Run。
Session 用于:
- 组织多轮请求;
- 查找安全的 PreviousTurn;
- 查询历史 Run。
Session 不代表一次执行,不拥有 Tool Call 或模型步骤的终态。
### 4.2 Run
一次独立的 Chat Application 执行,由 `runId` 标识。每次请求创建新 Run,无论 intent 是 System Chat、Knowledge Query 还是 Diagnosis。
代码和表名中仍大量使用 `DiagnosisRun` / `diagnosis_run`,但当前 Chat Application 会为三种 intent 都创建该记录。因此文档中优先使用 **Run**;只有引用 Java 实体或数据库表时才写 `DiagnosisRun`。
### 4.3 Agent Step
Diagnosis ReAct Agent 的一次模型步骤。多个 Agent Step 属于一个 Run,用于记录每轮模型调用的有界元数据和 Token。
Agent Step 不是 Run,也不是 retry attempt。ReAct 的下一轮模型调用是业务循环;retry 是同一技术操作的再次 attempt。
### 4.4 Tool Call / Tool Invocation
- **Tool Call**:模型通过框架产生的调用请求,由 framework `tool_call_id` 标识。
- **Tool Invocation**:该请求进入 ToolBoundary 后的一次实际执行记录。
- **Tool Request Rejection**:在 progress、重复或停止门禁处被拒绝,backend 没有执行,不算 Tool Invocation。
Harness 不生成第二套 Tool Call ID。
### 4.5 Trace
按 exact `sessionId + runId` 聚合的可回放观察视图,包含 Run、Agent Step、Tool metadata 和统一 Timeline。
Trace 是观察结果,不是新的执行上下文或状态所有者。`TraceEventStatus` 只描述单个事件,不能替代 RunState 或 ReleaseOutcome。
## 5. 核心执行术语
### 5.1 RunContext
一次 Run 的显式执行上下文。结构不可变地携带:
```text
sessionId
runId
deadline
RunCancellation
RunBudget
ModelCallLedger
HarnessRetryPolicies
RunLifecycle
DiagnosisProgressTracker
```
“结构不可变”表示 record 字段引用不变化;预算、取消、生命周期和进展通过各自线程安全句柄在 Run 内变化。
### 5.2 Run Lifecycle
Run 的内存执行终态,由 `RunLifecycle` 所有,状态类型是 `RunState`。它采用 first-terminal-wins,后到达的成功、失败或取消不能覆盖第一个终态。
### 5.3 Run Cancellation
请求停止 Run 的协作机制,由 `RunCancellationReason` 记录第一个原因。取消会阻止后续边界和迟到发布,但不承诺一定能立即物理中断已发送给 Provider 的同步请求。
### 5.4 Run Budget
单 Run 的资源账本和门禁,包括模型调用、Tool 调用、单 Tool 次数、输入/输出/总 Token 和 Run bytes。
Budget 只回答“还能不能消耗资源”,不回答“继续诊断是否有价值”。后者属于 Information Gain 和 Collection State。
### 5.5 Retry Attempt
同一个技术操作因允许的技术失败而再次执行。当前 Router 和 SemanticGuard 最多 2 次 attempt,Diagnosis Agent、Tool 和 EvidenceRepair 只有 1 次。
以下不是 retry:
- ReAct Agent 的下一轮思考;
- 改用另一个 Tool;
- `NO_EVIDENCE` 后继续查询;
- 用户发起下一次 Run。
## 6. Agent 与输出术语
### 6.1 Diagnosis Agent
唯一拥有业务 ReAct Tool loop 的 Agent,负责提出假设、选择 Tool、评价非空结果的信息增益并生成 `DiagnosisDraft`。
它不负责 Run 生命周期、Tool 授权、证据物理验真、SemanticGuard 或最终发布。
### 6.2 DiagnosisDraft
Diagnosis Agent 的结构化草稿,是发布链输入,不是已经发布的报告。Draft 可以有结论,也可以 `conclusion=null`。
Draft 中的 Tool 引用和结论必须经过 Release Pipeline 后才能成为公开内容。
### 6.3 Agent Result
当前 Tool 代码中的 `agent_result` 指 Tool-specific Projector 生成、存入 canonical invocation 的标准化有界结果。
它不是:
- Diagnosis Agent 的最终 Draft;
- Chat Application 的最终结果;
- 直接进入模型上下文的完整内容。
更准确的理解是 **Canonical Projected Tool Result**。当前字段名因协议兼容保留。
### 6.4 Model Observation
从 canonical `agent_result` 再次白名单投影后,真正作为 Tool Response 进入 Diagnosis Agent 上下文的内容。
预算、阈值、重复指纹、raw response 和完整 Harness 控制状态不进入 Model Observation。
### 6.5 PreviousTurn 与 PublishedResult
- `PublishedResult`:只有 `DIAGNOSIS + ReleaseOutcome.SUCCESS` 才能持久化的安全诊断结果。
- `PreviousTurn`:从 PublishedResult 生成的有界下一轮上下文。
Fallback、失败、取消、raw evidence 和完整历史不会进入 PreviousTurn。
`PublishedResult` 不等于当前请求直接返回的 `ChatApplicationResult`。
## 7. Tool 与证据术语
### 7.1 ToolBoundary
所有业务 Tool 的统一执行边界,负责 Run/ID/授权/只读校验、预算、bytes、canonical 状态迁移和 metadata audit。
ToolBoundary 不判断信息增益或业务根因。
### 7.2 Canonical Invocation
Redis TTL 内的完整 Tool 调用真相,包含 request、raw response、标准化 `agent_result`、调用状态和证据状态。
它用于当前 Run 的 EvidenceGuard 和 ProgressSnapshot,不是长期审计记录。
### 7.3 Durable Audit
长期保存的有界元数据:Run/Tool identity、状态、耗时、bytes、模型步骤和 Token。它不保存 Prompt、完整 Tool 参数、raw response 或 canonical record。
### 7.4 Evidence
Tool 在特定 scope 下返回、经过 Projector 标准化并能由当前 Run canonical record 验真的事实或负向观察。
`EVIDENCE_FOUND` 只代表存在候选内容,不代表它支持根因。
### 7.5 Negative Observation
`READY + NO_EVIDENCE` 形成的限定范围事实,例如“在时间窗 T、服务 S、查询 Q 下没有匹配日志”。
它不能被解释为“故障不存在”或“系统健康”。Draft 中使用 `AnalysisKind.NEGATIVE_OBSERVATION` 表达这种分析。
### 7.6 VerifiedEvidenceSnapshot
EvidenceGuard 从 canonical invocation 中投影出的已验真、最小证据集合,供 SemanticGuard 使用。它不包含 raw response。
### 7.7 ProgressSnapshot
Tool loop 结束后,根据已完成 Tool Call identity 回读 canonical records 生成的有界过程视图,用于受控停止或无结论 Fallback。
VerifiedEvidenceSnapshot 面向“有结论报告的语义审查”;ProgressSnapshot 面向“没有可发布结论时说明已经检查了什么”,两者用途不同。
## 8. Guard 与发布术语
### 8.1 EvidenceGuard
确定性引用验真器。检查 Draft 结构、analysis 引用闭包、当前 Run 所有权、READY 状态、EvidenceStatus 和 typed projection 自洽性。
不调用模型,不判断结论是否被证据支持。
### 8.2 EvidenceRepair
一次性的模型修复步骤,只修结构和引用。修复前后 `SemanticDraftView` 必须保持用户可见语义一致,之后重新执行 EvidenceGuard。
它不是新的报告作者,也不是 retry Agent。
### 8.3 SemanticGuard
隔离的单轮语义审查器,只判断 verified evidence 是否支持 Draft,输出 `SUPPORTED / UNSUPPORTED`。
它使用模型,但无 Tool、无记忆、无 ReAct loop、无报告改写权,因此文档中不要称它为第二个业务 Agent。
### 8.4 Release Pipeline
从 DiagnosisDraft 或受控停止输入,到 `DiagnosisReleaseResult` 的安全决策链:EvidenceGuard、可选 Repair/Recheck、SemanticGuard 和 SafeFallback。
### 8.5 ReleaseOutcome
应用层最终处理结果:
- `SUCCESS`:发布安全正常内容;
- `FALLBACK`:请求已被安全处理,但没有发布正常诊断结论;
- `FAILED`:无法形成安全业务结果;
- `CANCELLED`:Run 被取消。
ReleaseOutcome 不等于 RunState。尤其 `FALLBACK` 不是 RunState。
### 8.6 SafeFallback 与 FallbackType
SafeFallback 是确定性公开内容;FallbackType 解释为什么没有发布正常诊断结论,例如:
- `EVIDENCE_VALIDATION_FAILED`;
- `SEMANTIC_UNSUPPORTED`;
- `SEMANTIC_UNAVAILABLE`;
- `INSUFFICIENT_EVIDENCE`;
- `MISSING_REQUIRED_CONTEXT`。
FallbackType 是 `ReleaseOutcome.FALLBACK` 的原因,不是新的生命周期状态。
## 9. 状态维度速查
| 类型 | 所有者 | 回答的问题 | 不能回答的问题 |
|---|---|---|---|
| `RunState` | RunLifecycle | Run 的内存执行是否终止、如何终止 | 发布了正常内容还是 Fallback |
| `RunCancellationReason` | RunCancellation | 谁首先请求取消、为什么 | 最终公开结果是什么 |
| `ChatApplicationStatus` | Application Observer | 当前向用户展示哪个处理阶段 | Run 是否已经终止 |
| `InvocationStatus` | Canonical Invocation | Tool 调用记录是否完成 | 是否找到候选证据 |
| `EvidenceStatus` | Tool Projector | 当前 scope 是否有候选证据 | 是否支持根因 |
| `InformationGain` | Harness/Diagnosis Agent | 结果是否推进当前诊断 | Tool 是否技术成功 |
| `DiagnosisCollectionState` | ProgressTracker | 是否允许继续调用证据 Tool | Run 是否终止 |
| `DiagnosisStopReason` | ProgressTracker | 为什么停止继续收集 | 对外发布什么内容 |
| `SemanticVerdict` | SemanticGuard | 已验真证据是否支持 Draft | Run 是否成功执行 |
| `ReleaseOutcome` | Application/Release | 对外处理结果属于成功、降级、失败还是取消 | Tool 或收集过程的内部状态 |
| `FallbackType` | SafeFallbackFactory | FALLBACK 的业务/安全原因 | 整个 Run 的执行终态 |
| `TraceEventStatus` | 单个 Trace Event | 某条事件的局部结果 | 全局生命周期 |
| SSE Session `State` | ChatSseSession | 连接能否继续发送事件 | Harness Run 的业务结果 |
```mermaid
flowchart LR
subgraph Execution["执行控制维度"]
RS["RunState<br/>RUNNING -> terminal"]
CS["CollectionState<br/>COLLECTING / SATURATED"]
SR["StopReason<br/>停止收集原因"]
end
subgraph Tool["单次 Tool 维度"]
IS["InvocationStatus<br/>PROJECTING / READY / ERROR"]
ES["EvidenceStatus<br/>FOUND / NO_EVIDENCE / ERROR"]
IG["InformationGain<br/>GAINED / NO_GAIN"]
end
subgraph Validation["验证维度"]
EV["EvidenceGuardResult<br/>valid / violations"]
SV["SemanticVerdict<br/>SUPPORTED / UNSUPPORTED"]
end
subgraph Publication["发布与观察维度"]
RO["ReleaseOutcome<br/>SUCCESS / FALLBACK / FAILED / CANCELLED"]
FT["FallbackType<br/>FALLBACK 原因"]
SSE["SSE Session State<br/>连接发送状态"]
DB["diagnosis_run.status<br/>持久化通用状态"]
end
IS --> ES
ES --> IG --> CS
CS --> SR
ES --> EV --> SV
SR --> RO
SV --> RO
RS --> RO
RO --> FT
RO --> SSE
RO --> DB
```
箭头表示信息参与后续决策,不表示枚举之间一一转换。例如 `READY + EVIDENCE_FOUND` 仍可能得到 `NO_GAIN`,`RunState.SUCCESS` 也可能对应 `ReleaseOutcome.FALLBACK`。
完整状态转换和跨层映射见[Harness生命周期与状态.md](Harness生命周期与状态.md)。
## 10. 命名规则
后续代码和文档遵守以下用词:
1. 说“Run 成功/失败/取消/超时/预算耗尽”时,明确写 `RunState`。
2. 说“发布正常内容/Fallback/失败/取消”时,明确写 `ReleaseOutcome`。
3. 不单独写“Tool 成功”,改写为 `InvocationStatus=READY`,并同时说明 EvidenceStatus。
4. 不写“找到有效证据”,除非已经说明是候选内容、已验真事实还是足以支持结论。
5. `NO_EVIDENCE` 必须附带 scope,不得写成全局否定。
6. `SATURATED` 只用于 Collection State;预算耗尽使用 `BUDGET_LIMIT_REACHED` 或 `RunState.BUDGET_EXHAUSTED`。
7. `Fallback` 只指安全发布降级,不用来泛指异常兜底代码。
8. `Agent` 默认指 Diagnosis Agent;SemanticGuard 和 EvidenceRepair 分别称“语义审查器”和“引用修复步骤”。
9. `agent_result` 引用字段名时保留原名,概念说明使用“canonical projected Tool result”。
10. `status` 单独出现没有意义,必须注明所属类型或存储字段。
## 11. 已知命名债务
### 11.1 `DiagnosisRun` 名称大于实际诊断范围
当前 Chat Application 对三种 intent 都写入 `diagnosis_run`。文档统一称 Run;是否重命名实体和表属于单独的协议/迁移决策,本次不修改代码。
### 11.2 `SseOutcome` 当前未被运行时使用
`SseOutcome` 枚举存在,但当前 `ChatSseEvent.Done` 直接携带 `ReleaseOutcome`,并禁止公开 `CANCELLED`。因此当前 SSE 真理源是 `ReleaseOutcome`,不要再基于 `SseOutcome` 推导协议。
### 11.3 `FallbackType.BUDGET_EXHAUSTED` 不是当前诊断发布主路径
枚举值仍存在,但当前 Diagnosis Release 对预算受控停止的行为是:有已验真进展时发布 `INSUFFICIENT_EVIDENCE`,无安全进展时保持失败。文档不能仅因枚举存在就声称系统会发布 `BUDGET_EXHAUSTED` Fallback。
### 11.4 数据库 `status=SUCCESS` 不等于找到根因
`JpaChatRunStore` 将 `ReleaseOutcome.SUCCESS` 和 `FALLBACK` 都映射为数据库 `status=SUCCESS`,表示请求被正常处理。是否发布根因必须结合 `release_outcome`、`content_type` 和 FallbackType 判断。
### 11.5 RunState 没有直接作为独立字段持久化
当前持久化主记录保存通用 `status` 和 `release_outcome`,Trace 保存阶段事件;内存 `RunTermination.state/reason` 不是独立数据库字段。排查时不能只靠数据库 `status` 反推 TIMED_OUT 或 BUDGET_EXHAUSTED 的精确内部终态。
## 12. 如何使用本文
本文不是必读的第一章,而是遇到名词歧义时使用的词典。第一次接触 Harness,请先读 [README.md](README.md) 建立最小心智模型;需要理解某个状态或概念时,再回到本文对应章节查询。
@@ -0,0 +1,140 @@
# Harness LLM Judge 设计笔记:从不可信判定到可信裁决
**更新日期**:2026-08-04
**主题**:SemanticGuard + EvidenceRepair + GuardModelCall = LLM-as-a-judge 模式在证据安全链的完整落地(面试问答版)
**代码位置**:`src/main/java/com/superbiz/agent/harness/guard/semantic/` + `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java`
## 1. 定位:三个角色
```text
SemanticGuard → 典型 LLM judge:判「结论是否被已验证证据支持」,输出 verdict + reason
EvidenceRepair → judge 的修复延伸(rewriter):验真失败后「只修引用、不修结论」
GuardModelCall → 受控 LLM 调用底座:judge 类调用的基础设施(共用)
```
## 2. 面试五段式回答稿(完整叙事)
### ① 动机(先讲问题,不报组件名)
> 我们的证据安全链里有一道「机械验真」——检查模型引用的每条证据是不是真实来自工具结果,这个用规则就能做。但光验真不够:模型可能引用真实的证据,结论却是「站不住」的——比如证据只支持 A 场景,它却拿去支撑 B 结论。这个「结论被没被证据支持」是**语义判断**,规则引擎做不了,必须靠模型。所以我们需要一个「裁判模型」来判——但裁判模型本身是不可信的,它可能乱判、可能输出奇怪的形状、可能跑很久。所以核心问题是:**怎么让一个不可信的模型做可信的判定**。
### ② 决策(方案 + 放弃了什么)
> 我的方案是:用**隔离的轻量判定模型**——单轮、无工具、输出被强约束,跟主 Agent 的循环完全分离。这里放弃了两条路:第一,让主 Agent 自己判——不行,它已经写了自己的结论,有偏向;第二,纯规则判——语义判断规则做不到。同时有个关键决策:**判读的输入是「视图」不是原始内容**——裁判只看到用户将看到的内容和已验证证据,看不到内部 id 这些实现细节,防止信息污染影响裁判的客观性。
### ③ 实现(关键机制)
> 三个关键机制:
> **输入视图化**:把 draft 投影成「用户可见视图」再交给裁判,剥离内部引用 id;
> **输出硬校验**:裁判的输出必须是恰好两个字段——verdict 和 reason,verdict 必须是合法枚举,reason 不能为空。多一个字段都不接受——我们不信任模型输出的形状,只信它在一个极小的空间里做选择;
> **受控调用**:裁判跑在独立线程、有硬超时、Run 取消能强杀它、它的输入输出都计入预算和 Token 账本——裁判的花费不是无底洞,它也是 Run 的一部分。
### ④ 边界(诚实说不做什么)
> 裁判不判「内容对不对」——那是事实问题,由证据链负责;裁判不自己调工具,单轮无工具;裁判有硬截止线,超时就放弃判定;裁判失败走降级,**不阻塞主结论的发布路径**——我们宁可没有裁决,也不让裁决失败卡死整个流程。
### ⑤ 30 秒话术
> "LLM judge 的完整设计:**动机**是结论的支持度是语义判断、规则做不了,但裁判模型不可信,所以核心是让不可信的模型做可信的判定。**方案**是隔离的轻量判定模型——单轮、无工具、强约束输出。三个关键机制:输入视图化(裁判只看用户可见内容,防信息污染)、输出硬校验(恰好 {verdict, reason} 两字段,多一个都不接受)、受控调用(独立线程、硬超时、取消强杀、计预算记账)。**边界**:裁判不判事实、不调工具、超时即放弃、失败走降级不阻塞主路径。总结一句话——judge 不是追加一个模型调用,而是把『不可信判定』关进笼子里:限定输入、锁死输出、受控运行、失败降级。"
## 3. 追问应对大全
### Q1:为什么 judge 不判事实?(最容易混的边界)
```text
分工:事实由证据链保证,judge 只判「支持关系」
事实真伪 → 证据来自真实工具结果 + EvidenceGuard 验引用真实(根在数据源)
支持关系 → SemanticGuard 判结论与证据的逻辑/相关性
judge 判不了事实的三个原因:
① 没有事实源——它只看「视图 + 已验证证据」,不能查库,判事实只能猜
② 事实真伪需要权威源复核(真实值在哪),judge 拿不到
③ 如果 judge 判事实,它成了第二个事实来源——两个来源可能打架
例子 1(judge 能判的——判支持不是判真伪):
证据:mysql 返回 count(*)=1000;结论:「user 表有 2000 条」
EvidenceGuard 验引用真实 → 通过;SemanticGuard → UNSUPPORTED(数字不一致)
注意:judge 不知道真实值是多少,它只发现「结论与证据不一致」
例子 2(支持关系成立,但事实未必对):
证据:慢查询日志显示 DB 全表扫描;结论:「延迟由 DB 全表扫描导致」
SemanticGuard → SUPPORTED(逻辑上站得住)
但真实原因可能是网络抖动——judge 判不了(没有网络数据源)
→ judge 只能保证「在现有证据下结论站得住」,不能保证「事实就是如此」
一句话:EvidenceGuard 保证「引用的证据是真的」,SemanticGuard 保证
「基于这些证据结论说得通」——事实的真伪从来不是 judge 的职责。
```
### Q2:为什么重试 2 次(semanticGuard 策略)?
```text
可重试性分析:语义审查单轮、无副作用(幂等)——多试几次不会造成破坏
但也不能无限重试:判定有硬截止线(总超时耗尽即放弃)
→ 2 次 = 一次失败的成本 × 收益的平衡点;judge 失败走 Fallback,不影响主路径
```
### Q3:语义不变性怎么保证(EvidenceRepair)?
```text
双重锁死:prompt(只能改 analysis_id / tool_call_ids / based_on_analysis_ids 三字段)
+ SemanticDraftView.hasSameUserVisibleSemantics(修复前后逐字段比对)
关键:比的是「用户可读的内容」不是内部引用 id——
Conclusion 只比 text,不比 basedOnAnalysisIds
Action 只比 action + requiresHumanConfirmation,不比 basedOnAnalysisIds
→ 引用 id 允许变(这正是修复目标),用户看到的文字不许动(碰了判 SCHEMA_INVALID 重试)
```
### Q4:judge 判错了怎么办?
```text
judge 不是最终真相源,是「安全链的一道闸」:
① judge 判 UNSUPPORTED → 不发布,走 Fallback(宁可保守)
② judge 判 SUPPORTED 但事实错 → 那是事实问题,不在 judge 职责(见 Q1)
③ judge 自身失败 → 降级(不阻塞主路径)
→ 设计哲学:judge 的角色是「挡住明显不成立的结论」,不是「证明结论正确」
```
### Q5:为什么独立线程 + 单独超时?
```text
judge 调用不能阻塞主流程(主 Agent 循环)
独立线程 + future.get(timeout) = 硬超时截断
Run 取消 → future.cancel(true) 强杀在途判定(judge 也是 Run 的一部分)
```
### Q6:输入为什么视图化?
```text
judge 只看该看的:用户可见内容 + 已验证证据
剥离内部 id(tool_call_id 等)——防止 judge 用内部信息做「看起来合理」的裁决
(信息污染:judge 看到内部 id 可能产生不当关联,或泄露内部结构到裁决)
```
## 4. 通用 LLM Judge 设计要素(可迁移)
| 通用要素 | 本项目实现 |
|---|---|
| 判什么(judgment task) | 结论是否被已验证证据支持 |
| 输入视图(该看什么) | SemanticDraftView(剥离内部 id)+ 已验证证据 |
| 输出约束(schema) | 恰好 {verdict, reason} + 枚举合法 + reason 非空 |
| 硬校验 | 字段集 equals({verdict, reason})——不多不少 |
| 隔离 | 单轮、无工具、独立线程——judge 不能自己调工具 |
| 硬超时 | 每次 attempt 剩余超时递减,总超时耗尽即放弃 |
| 重试策略 | 可重试性分析:单轮无副作用 → 2 次 |
| 失败降级 | judge 失败 → Fallback(不卡死主路径) |
| 可审计 | ModelCallLedger 记账 + semanticAttempt/evidenceRepairAttempt trace |
| 成本控制 | 输入/输出字节限制 + reserveRunBytes 计入 Run 预算 |
## 5. 代码位置索引
| 类 | 文件 |
|---|---|
| `GuardModelCall` | `src/main/java/com/superbiz/agent/harness/guard/semantic/GuardModelCall.java` |
| `SemanticGuard` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticGuard.java` |
| `SemanticDraftView` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticDraftView.java` |
| `SemanticGuardInput` / `SemanticGuardLimits` | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
| `EvidenceRepair` | `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java` |
| `EvidenceRepairLimits` / `EvidenceRepairPrompt` | `src/main/java/com/superbiz/agent/harness/release/` |
| 重试策略(semanticGuard/evidenceRepair) | `src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java` |
@@ -0,0 +1,158 @@
# Harness MySQL 沙箱学习笔记:从 SQL 校验到脱敏投影
**更新日期**:2026-08-04
**主题**:query_mysql 工具完整链路——三层防线(语义/连接/输出)
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(注册/调用/执行全链路)
## 1. 定位:可查询、不可破坏、不可越界、不可拖库
query_mysql 让模型查询授权数据库,但封死三种攻击面:
```text
破坏:写/删/改(非 SELECT)→ 语义层拒绝
越界:未授权表/列 → 白名单拒绝
拖库:全表通配(*)/无界读取 → 禁通配符 + 三重有界截断
```
**为什么 MySQL 要安全层而 RAG 不要**:Milvus 天然只读检索无破坏面;MySQL 直接连数据库,SELECT 之外全是风险面——**安全设计随攻击面走**。
## 2. 架构总览(三层防线 + 接线员)
```mermaid
flowchart LR
E["MysqlToolAdapter<br/>接线员"] -->|"parse 请求"| V["第 1 层 语义层<br/>MysqlSqlValidator<br/>AST fail-closed"]
V -->|"MysqlQueryPlan"| X["第 2 层 连接层<br/>JdbcMysqlReadOnlyExecutor<br/>JDBC 只读+超时+取消"]
X -->|"MysqlRawResult<br/>(Harness-only)"| P["第 3 层 输出层<br/>MysqlResultProjector<br/>脱敏+有界"]
P -->|"MysqlToolResult<br/>(冻结契约)"| B["ToolBoundary<br/>统一门禁"]
```
**接线员**:Adapter 把 validator/executor/projector 组装进 ToolBoundary;任何安全/参数异常统一映射 INVALID_REQUEST(不泄露内部细节)。
## 3. 定义层(6 个小文件)
| 类 | 作用 |
|---|---|
| `MysqlReadOnlyExecutor` | 函数式接口(执行端口):plan + RunContext → raw 结果 |
| `MysqlQueryPlan` | 执行计划:request + dataSource + normalizedSql + params(params 深拷贝) |
| `MysqlRawResult` | Harness-only raw 结果:columns + rows + truncated(不直接给 Agent) |
| `MysqlSecurityException` | 安全异常:Adapter 映射稳定错误码 |
| `MysqlToolLimits` | 限额:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时 |
| `MysqlDataSourceDefinition` | 逻辑数据源 + **schema → table → 列三级白名单**(访问边界) |
**白名单深拷贝**(TreeSet 保证确定性)+ `defaultSchema 必须出现在白名单里`——配置即边界。
## 4. 第 1 层:Validator——语义层(fail-closed)
### 4.1 禁止清单(JSqlParser 解析 AST,逐节点拒绝)
```text
非 SELECT / 多条语句 / WITH / 子查询(SubSelect) / 通配符投影(* 和 t.*)
窗口函数(Analytic) / CASE / EXISTS / 分层查询(OracleHierarchical)
锁读(FOR UPDATE / SKIP LOCKED) / OFFSET/FETCH/TOP/SKIP/FIRST/OPTIMIZE FOR
字面量:StringValue/LongValue/DoubleValue/HexValue/DateValue/TimeValue/TimestampValue
—— 全部必须参数化(防注入的最强形态)
```
### 4.2 允许清单
```text
白名单内表列的 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER)
聚合函数:COUNT/SUM/AVG/MIN/MAX(COUNT(*) 只允许 COUNT)
占位符参数 ?(PreparedStatement 绑定)
```
### 4.3 附加校验
```text
恰好一条语句 + 必须是 Select + 无 WITH + 普通 PlainSelect(禁集合操作/值语句)
FROM 必须是白名单内实体表(禁子查询来源);重复别名拒绝(不区分大小写)
列校验:限定表 → 查该表列白名单;未限定 → 已注册表恰好一个命中(防歧义/未授权)
占位符数量 == params 数量(防参数错位/少传)
```
### 4.4 fail-closed 原则
```text
任何解析/校验异常 → 统一转 MysqlSecurityException(默认拒绝,不是默认放行)
业务规则违规原样穿出;解析/未知异常包装统一信息(不泄露内部细节)
→ 安全策略是「拒绝清单外的全允许」的反面:「允许清单外的全拒绝」
```
## 5. 第 2 层:Executor——连接层(双保险)
```text
connection.setReadOnly(true) ← JDBC 连接层强制只读(语义层之外的物理防线)
PreparedStatement 参数化 ← 占位符绑定(防注入第二道)
setQueryTimeout(5s) ← 慢查询截断
setMaxRows(maxRows+1) ← 多取一行用于检测截断
context.cancellation().onCancel → statement.cancel() ← Run 取消联动
checkRun 每行检查 ← 取消/超时即刻中止(与 core 终态联动)
jsonSafe:byte[] → Base64;字符串截断 maxCellChars
estimatedBytes:字节预算超限移除末行并标记截断
```
**关键**:查询不是独立资源——**受 Run 生命周期管**(取消 → 立即 cancel 语句),与 RAG 检索同理(checkRun 与 Harness core 终态联动)。
## 6. 第 3 层:Projector——输出层
### 6.1 脱敏(输出时,不是查询时)
```text
列名含 password/passwd/token/secret/api_key/apikey/credential → [REDACTED]
→ raw 保留真实值,只对 Agent 可见层脱敏(查询照常执行,输出才遮)
```
### 6.2 有界(三重截断 + 兜底)
```text
行数(maxRows) + 单元格字符(maxCellChars) + 总字节(maxResultBytes)
列名必须非空且唯一(防歧义投影)
fitBudget 兜底:逐行裁掉尾部 → 裁空诚实降级 NO_EVIDENCE → 仍超限 fail closed
```
### 6.3 客观证据语义
```text
rows 空 → NO_EVIDENCE;非空 → EVIDENCE_FOUND
→ 与 RAG 的 evidence_status 同一套契约(证据状态由「有没有内容」客观决定)
```
## 7. 与 RAG 对照(同类架构,不同复杂度)
| | query_mysql | lookup_knowledge |
|---|---|---|
| 安全层 | 有(Validator + 只读连接 + 脱敏) | 无(Milvus 天然只读检索) |
| 后端复杂度 | 简单(Validator→Executor→Projector) | 复杂(三段 + 降级 + 双 trace) |
| raw → 契约 | MysqlRawResult → MysqlToolResult | LookupResult → RagToolResult |
| 数量限制 | maxRows=100 / 64KB | returnN=5 / 8 条 / 16KB |
| 证据语义 | rows 空不空(NO_EVIDENCE/EVIDENCE_FOUND) | evidenceBlocks + relevance_level |
| 与 Harness 衔接 | 同为 Boundary/Projector/Adapter 模式 | 同为 Boundary/Projector/Adapter 模式 |
## 8. 易错点
| 易错 | 正确 |
|---|---|
| Validator 只查 SELECT | 还有白名单表列、禁字面量、禁通配符、占位符计数 |
| 语义层够了 | 连接层 setReadOnly + 参数化是物理防线(纵深防御) |
| 查询独立于 Run | 查询受 Run 取消/超时联动(onCancel → statement.cancel) |
| 脱敏在查询层 | 脱敏在投影层(raw 保留真实值,只对 Agent 脱敏) |
| 有界只限行数 | 行 + 单元格 + 字节三重截断 + fitBudget 兜底 |
| 安全异常抛原样 | 统一映射 INVALID_REQUEST(不泄露内部细节) |
| 字面量可以清洗放行 | 字面量全拒必须参数化(清洗是弱防线,参数化是强防线) |
## 9. 面试话术(30 秒)
> "query_mysql 是三层防线的只读沙箱:**语义层**(JSqlParser 解析 AST,fail-closed 拒绝一切不安全形态——非 SELECT、多语句、子查询、通配符、字面量、未授权表列、锁读全部拒绝,只允许白名单表列的 INNER/LEFT JOIN 和聚合 + 参数化占位符,且占位符数量必须与 params 匹配);**连接层**(JDBC setReadOnly + PreparedStatement 参数化 + 超时 + maxRows + 取消联动——Run 取消立即 cancel 语句);**输出层**(敏感列脱敏 [REDACTED] + 行/单元格/字节三重截断 + rows 空不空定证据状态)。安全异常统一映射稳定错误码,不泄露内部细节。"
## 10. 代码位置索引
| 类 | 文件 |
|---|---|
| `MysqlToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java` |
| `MysqlSqlValidator` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java` |
| `JdbcMysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java` |
| `MysqlResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java` |
| `MysqlDataSourceDefinition` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java` |
| `MysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java` |
| `MysqlQueryPlan` / `MysqlRawResult` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java` 等 |
| 契约(MysqlToolRequest/Result) | `src/main/java/com/superbiz/agent/harness/tool/contract/MysqlTool*.java` |
@@ -0,0 +1,347 @@
# Harness RAG 检索体系学习笔记:从 query 到可验证证据
**更新日期**:2026-08-03
**主题**:lookup_knowledge 完整后端链路——检索前/检索/检索后/打包/组装/降级/契约/验证
**设计文档**:`mvp/engineering/rag/`(RAG 排序、Hybrid 质量分、relevance_level 等)
**代码视角**:[Harness tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链衔接)
## 1. 定位与骨架
一次 `lookup_knowledge` 从 query 到证据的旅程(模块化三段 + 收尾):
```mermaid
flowchart LR
Q["query"] --> A["检索前<br/>KnowledgeQueryTransformer<br/>L0 导航(分类/域/关键词)"]
A --> B["检索<br/>KnowledgeDocumentRetriever<br/>dense + BM25 → RRF 融合"]
B --> C["检索后<br/>KnowledgeEvidencePostProcessor<br/>qualityScore/去重/判级/闸门"]
C -->|"低质"| B2["降级重试<br/>UNFILTERED_VECTOR_RETRY<br/>(去过滤 + 原始 query)"]
C --> D["打包<br/>KnowledgeContextPacker"]
D --> E["组装<br/>LookupResultAssembler<br/>→ LookupResult"]
E --> F["投影<br/>RagResultProjector<br/>→ RagToolResult(Agent 契约)"]
```
**关键特征**:模块化三段各自独立 Service、降级是阶段间控制流、双轨可观测(RetrievalTrace + RerankTrace)、LookupResult 是内部契约(Agent 看到的是投影后的 RagToolResult)。
## 2. 检索前:L0 导航(缩短边界,不决定边界)
- **产出**:categoryFilter / domainHints / matchedKeywords / entities / l0Titles(从 query 语义推导)
- **只缩短边界**:categoryFilter 限定「搜哪些分类」(FILTERED_VECTOR)
- **不决定边界**:低质 → 降级去掉过滤重查(L0 边界可被推翻)
- **只解释不打分**:L0 命中只写 hitReasons(l0_domain_overlap),不改 qualityScore 和排序——防关键词碰瓷
- **语义差异应对**:降级用原始 query(非 rewritten)——抹掉 L0 推导误差
## 3. 检索:多路召回 + RRF
**为什么混合检索**:旧方案(dense 语义 + L0 关键词加权重排)有词频碰瓷误差——词频高但相关性不高的排前面。
```mermaid
flowchart LR
subgraph 召回
D["dense ANN(L2)<br/>抓语义相似"]
S["sparse BM25<br/>抓精确匹配"]
end
D --> R["RRF 融合<br/>score = Σ w/(k + rank)"]
S --> R
R --> F["融合排序(originalRank)"]
```
**关键决策**:
- **RRF 用排名不用分数**——屏蔽跨路分数尺度不可比(dense 的 L2 vs BM25 的稀疏分)
- **k=60**(`retrieval.hybrid.rrf-k`)——平滑参数,排名差异对分数的影响平缓
- **加权是预留能力**:RrfFusion 支持 `w/(k+rank)`,但当前 Milvus 服务端走等权 RRFRanker(只传 k)——想让某路更可信时再调旋钮
- **召回优先**(RAG 排序文档观点):候选池只有 3 条时,精排只能换座位,召不回的内容永远排不上来
## 4. 检索后:qualityScore 统一 + 质量闸门
### 4.1 为什么需要统一分数
三路返回三种分数(L2 距离 / BM25 稀疏分 / RRF 融合分)——不可比,必须统一成 qualityScore ∈ [0,1]。
### 4.2 打分(RetrievalScoreNormalizer)
```text
DENSE: l2ToQuality(score) = 1 - clamp(L2)/maxL2 (maxL2 默认 2.0)
HYBRID: denseDistance != null ? l2ToQuality(denseDistance) ← 恢复绝对质量
: rankToQuality(rank, batchSize) ← BM25-only 保守回退
```
**denseDistance 的来源**(隐藏机制):hybrid 融合后**再单独跑一次 searchDense**,按 id 把 L2 补到融合结果上——因为服务端 RRF 只输出融合分,原始 L2 信息丢了。`attachDenseDistances` 只填充不改 score/label/order(排序评估分离的又一体现)。
### 4.3 排序与评估分离(核心设计)
```text
originalRank(RRF 融合序)→ 排序:谁在前面(相对序)
qualityScore(L2/rank) → 评估:够不够格、要不要降级(绝对度)
→ 排序不用 quality 重排(防 boost 操纵)
→ quality 只被判级和闸门消费
```
**为什么排名不能证明质量**:排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)——排第 1 只代表「这批里最好」,不代表「够好」(候选池全是低质时排第 1 的也低质);RRF 分本身不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别。
### 4.4 五步流程(process)
```
① 打分(toQualityScore)→ ② 排序(originalRank,不用 quality 重排)
③ 去重/截断:evidenceKey 合并 + maxChunksPerDocument=2 + returnN=5
④ 判级:top qualityScore ≥0.75→PRECISE / ≥0.5→REFERENCE
⑤ 闸门:topSimilarity <0.5 → 低质 → 降级重查
```
**两级去重**(粒度不同):
```
evidenceKey 去重(chunk 级):同 chunk(docId#chunkIndex)被两路召回 → mergeEvidence 合并
—— mergeEvidence 只合并 hitReasons + 补 breadcrumb,不处理 content(同 chunk 内容相同)
maxChunksPerDocument(文档级):同文档不同 chunk 最多 2 个 → 防单文档垄断证据槽位
```
**判级用 ranked(全量)不是 deduped**——判级评估「整体质量」(全量 top),去重决定「输出内容」(合并片段),两件事平行。
**BM25-only 的弱点**:无 dense 邻居 → rankToQuality 回退(排第 1 恒为 1.0)——整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)。改善方向:文本相似度兜底(绝对信号)+ 批次一致性检查(整体水平),而非返回 BM25 分(统计度量无绝对语义)。
## 5. 契约语义:relevance_level 是什么、不是什么
- **是什么**:一次 lookup_knowledge 调用整体有多相关的**粗档标签**(判级产出:PRECISE/REFERENCE/null)
- **不是什么**:不是单条 evidence 的分数、不是相似度数值、不是「结论可发布」判据
- **Agent 正确用法**:evidence_status 管有没有证据,relevance_level 管这批评据多硬/要不要再查,实际写诊断引用的是 evidence[].excerpt
- **REFERENCE ≠ NO_GAIN**:一般相关可能仍排除一个假设——语义价值由模型判断(progress 原则)
## 6. 打包与组装
### ContextPacker(打包)
```
输入顺序即优先级 → 逐条塞进 4000 字符预算
单条放不下 → content 截断(+"...")
连 header 都放不下 → 整条省略(记 omittedSources)
输出:packedText + strategy + charBudget/usedChars + included/omittedSources
```
**当前定位**:Agent 主要看结构化 evidence 列表,packedText 更多用于内部/调试/审计(Harness 用结构化列表因为可验真——evidence_ref 引用 document_id)。
### LookupResultAssembler(组装)
- **内部契约出口**:found / evidenceBlocks / 双数量 / 双 trace / relevanceLevel / completenessHint / message
- **found 在这里综合判定**:evidence.hasUsableEvidence()
- **message 语义**:found=false 时「知识库未检索到可用证据,请结合日志、指标、告警继续排查」——证据不足不是失败,是换方向引导
- **与投影的关系**:LookupResult 是后端完整出口,RagResultProjector 再裁剪成 Agent 契约(两层契约)
## 7. 降级:突破 L0 边界的兜底
```
触发:categoryFilter != null && isLowQuality(无证据 或 topSimilarity < 0.5)
动作:原始 query(非 rewritten)+ 去掉分类过滤 + 覆盖选择(不合并两轮)
原因分类:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(完全没查到)
有限降级:只一次(防重查风暴);retry 低质也接受(降级失败直接返回)
trace:attempts 记录全部尝试(FILTERED/UNFILTERED/UNFILTERED_RETRY)
+ selectedAttempt + fallbackReason → 可对比「过滤 vs 全域」判断 L0 过滤是否过度
```
## 8. 投影衔接(RagResultProjector)
- **契约转换**:LookupResult JSON → RagToolResult(evidence[] + relevance_level + evidence_status + truncated)
- **四重有界**:query 500 字 / excerpt 1200 字 / 条数 8(maxEvidence)/ 总字节 16KB(fitBudget)
- **优先级链去重**:evidenceKey → document_id → docId#chunk-idx → legacy 序号(chunk 级身份)
- **诚实标记**:一切有损(截断/去重丢弃/query 截断)都置 truncated;fitBudget 裁空诚实降级 NO_EVIDENCE + relevance 置 null(没有证据就没有相关度,自洽)
- **两级数量限制**:后端 returnN=5(业务目标值)vs maxEvidence=8(Harness 护栏)——5<8 时护栏休眠,后端配置失控时兜底
## 9. 验证:审计 + 离线评测
### 9.1 审计 vs Trace(两套记录系统)
| | DiagnosisTrace(事件流) | ToolInvocationAudit(调用档案) |
|---|---|---|
| 粒度 | 事件(一次调用多个事件) | 记录(一次调用一行) |
| 覆盖 | Run 全生命周期 | 仅工具调用 |
| 内容 | metadata-only(不存 raw) | 完整(raw + agentResult + enrichments) |
| 用途 | 时序回放 / Token 对账 | 单次调用深查 |
```
RAG 后端双 trace(Retrieval/Rerank)→ 进 LookupResult → Harness
→ 安全字段提取 → tool_invocation 审计(enrichments)
→ 轻量事件 → DiagnosisTrace
审计在投影之后、给模型之前(ToolBoundary.execute 内先落库)
```
### 9.2 离线评测设计(eval/rag-retrieval)
```
三层:offline(fixtures × golden-cases,无真实栈)/ snapshot 生成(真实跑一次冻结)/ live smoke
断言:行为契约(expectedDocIds/Breadcrumbs/Keywords/SelectedAttempt/FallbackReason/EvidenceStatus)
+ 不断言 raw scores / chunkId / 全序(脆或内部实现)
基线:baseline.json + diff(区分有意改进 vs 无意回归)
隔离:seed-docs + kb_scope=rag-eval
```
**断言设计原则**:
- 断言「用户可感知的结果 + 管道行为」,不断言「实现细节」(chunk/分数/全序)
- 双信号:fail 抓行为回归 + diff 抓「绿了但漂了」(通过但退化可见)
- **期望来自设计意图,不从当前输出反推**(否则固化 bug)
- golden set 是演进的:初期种子(设计意图)→ 中期真实数据(**必须人工验证**——成功案例只是观测,不是契约)→ 持续事故固化
### 9.3 指标(缺的下一步)
```
检索质量:recall@k / Hit@k / MRR(复用 expectedDocIds + rank,低成本)
管道行为:降级触发正确率 / 降级有效率 / 过滤误伤率(复用 trace 字段)
质量闸门:低质识别准确率 / 降级误杀率(BM25-only 弱信号代价可测)
需要新标注:precision@k(负例)/ nDCG(相关度分级)
```
## 10. 易错点
| 易错 | 正确 |
|---|---|
| RRF 排序了就不用打分 | 排序(RRF)与评估(qualityScore)分离——RRF 管谁在前,L2 管够不够格 |
| 排第 1 = 质量好 | 排第 1 只代表「这批里最好」——候选池全低质时排第 1 也低质 |
| 返回 BM25 分能解决 BM25-only | BM25 是统计度量(无界/依赖集合),无绝对语义——用文本相似度/一致性检查 |
| denseDistance 是融合分 | 是融合后再跑一次 dense 探测的 L2(RRF 丢了原始 L2) |
| 去重和判级有先后 | 平行:去重管输出(deduped),判级管评估(ranked 全量 top) |
| 后端 returnN=5,maxEvidence=8 多余 | returnN 是业务目标值,maxEvidence 是 Harness 护栏(防配置失控) |
| 线上成功案例可直接当 golden | 成功只是「当前实现没出错」的观测——必须人工确认设计意图 |
| 审计在给模型之后 | 审计在投影后、给模型前(ToolBoundary 内先落库) |
## 11. 讨论沉淀:值得记住的问题与洞见
本节收录学习过程中的关键问答——按价值分层,面试准备直接翻这里。
### 11.1 触及设计本质(第一梯队)
**① 「RRF 已经排序了,为什么还要打分」——排序与评估分离**
```text
RRF 管「谁在前面」(融合排序,相对序)
qualityScore 管「够不够格」(质量评估,绝对度)
为什么排名不能证明质量:
排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)
排第 1 只代表「这批里最好」,不代表「够好」——候选池全是低质时排第 1 也低质
RRF 分不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别
```
**② 「最终落地到 dense 决定,RRF 白用了吗」——谁被评估 vs 评估够不够**
```text
RRF/BM25 管:召回 + 排序(BM25 路召回 dense 召不回的候选,RRF 让两路共识靠前)
dense L2 管:质量评估的绝对标尺(唯一有绝对语义的)
→ 分工:RRF 决定「谁能被评估」,dense 决定「评估结果够不够」
→ 「排第一但 dense 低质 → 降级」不是矛盾,是排序与评估分离的价值(发现域选错)
```
**③ 「能不能返回 BM25 分当质量」——度量类型决定能否设阈值**
```text
几何度量(L2):embedding 空间稳定 → 能设 0.75/0.5 绝对阈值
统计度量(BM25):无界、依赖集合 IDF、随集合演进漂移 → 设不了稳定阈值
→ 质量评估需要绝对标尺,只能来自几何度量或可解释相似度(字符重叠)
→ 改善 BM25-only:文本相似度兜底 / 批次一致性检查 / fail-closed,而非返回 BM25 分
```
**④ 「BM25-only 质量有问题」(自己发现的设计弱项)**
```text
rank 回退:排第 1 恒为 1.0 → 整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)
根因:rank 是相对序(第 1 名不代表够 0.5),顶位给满分是「排序最好 = 质量满分」的错误等价
改进:rank 顶位保守化 / 文本相似度兜底 / 批次一致性检查
```
### 11.2 隐藏机制(第二梯队)
**⑤ 「denseDistance 是融合分还是 dense 分」**
```text
是 dense 那一路的 L2——RRF 服务端融合只输出融合分,原始 L2 信息丢了
→ attachDenseDistances 融合后再单独跑一次 searchDense,按 id 把 L2 补到融合结果
→ 只填充不改 score/label/order(排序评估分离的又一体现)
```
**⑥ 「为什么降级只一次」——有限降级**
```text
降级 = 突破 L0 边界重查(原始 query + 去过滤 + 覆盖选择)
只降级一次:预算约束(retrieveK × 2 检索成本)防重查风暴
fallbackReason 区分:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(没查到)
```
**⑦ 「returnN=5 为什么 maxEvidence=8」——目标值 vs 护栏**
```text
returnN=5:RAG 业务目标值(rag.return-n)——打算给 5 条
maxEvidence=8:Harness 安全上限(ToolProjectionLimits)——最多允许多少
两级解耦:业务层和安全层各自配置;5<8 时护栏休眠,后端配置失控时兜底
```
### 11.3 方法论沉淀(第三梯队,可迁移)
**⑧ 从 0 设计离线评测的八步**
```text
目标(回归保护)→ 粒度(工具级)→ 输入(冻结快照)→ 断言(行为契约)
→ 用例(行为维度覆盖)→ 隔离(种子数据)→ 基线(区分有意/无意变化)→ 成本(分层运行)
```
**⑨ golden set 怎么设计**
```text
行为清单 → 每个行为一个 case → query 拟真(能触发目标行为)
→ 期望来自设计意图(不从当前输出反推——否则固化 bug)→ 补负例/边界
→ 演进:初期种子打底 → 中期真实数据(人工验证后转契约)→ 持续事故固化
```
**⑩ 「线上成功案例能不能直接用」——观测 ≠ 契约**
```text
线上成功只是「当前实现没出错」的观测:可能恰好没触发 bug 路径、结果碰巧对
→ 必须人工确认「结果确实符合设计意图」后才从观测升级为契约
→ 失败案例则明确「期望应该怎样」作回归保护
```
**⑪ 「不用 chunk 断言也是数据原因吗」——不是**
```text
chunk 边界是切分实现细节:算法优化/文档微调都让 chunk 偏移 → 合法重构被误判回归
契约语义的证据单位是文档级(document_id 常等于 source)——chunk 模型都看不到
→ 即使数据充足也不该断言 chunk(和数据量无关)
```
### 11.4 三个核心洞见(最值得记住)
```text
① 排序与评估分离:RRF 管「序」(相对),L2 管「度」(绝对)——排名不能证明质量
② 度量类型决定能不能设阈值:几何(L2)可以,统计(BM25)不行
③ 期望来自设计意图,不从实现反推——这是评测和 golden set 的分水岭
```
## 12. 面试话术(30 秒)
### 11.1 排序与评估为什么分离
> "RRF 管『谁在前面』(融合排序),qualityScore 管『这批结果够不够格』(质量评估)——打分不是重排,是排序后的质量校验。RRF 分是排名派生的相对值,没法设绝对阈值(排第 1 不代表够 0.5,候选池全是低质时排第 1 的也低质);qualityScore 把 dense L2 归一化成 [0,1] 的绝对质量,用于判级(0.75/0.5 阈值)和闸门(<0.5 触发降级)。排序决定看哪些,评估决定够不够好。"
### 11.2 为什么 BM25 分不能当质量
> "质量评估需要绝对标尺,绝对标尺只能来自几何度量(L2 距离——embedding 空间稳定)或可解释的相似度(字符重叠),不能来自统计度量(BM25——无界、依赖文档集合的 IDF、随集合演进漂移)。BM25-only 命中用排名回退估质量(保守),但顶位给满分是设计弱项——改进方向是文本相似度兜底或批次一致性检查,而不是返回 BM25 分。"
### 11.3 降级设计
> "降级是突破 L0 过滤边界的兜底:带分类过滤检索结果低质(无证据或 topSimilarity<0.5)时,用原始 query + 去掉分类过滤重查一次(UNFILTERED_VECTOR_RETRY),结果覆盖选择、记录进 trace。fallbackReason 区分『查到了但低质』vs『完全没查到』;只降级一次(预算约束防重查风暴),降级失败也直接以低质结果返回。attempts 列表让审计能对比过滤 vs 全域检索差异,判断 L0 过滤是否过度。"
### 11.4 golden set 怎么设计
> "golden set 是行为契约的清单:先列要保护的行为,每个行为一个 case(不耦合可定位);query 用能触发目标行为的真实形态;期望来自设计意图(我知道这个文档属于这个场景),绝不从当前输出反推(否则固化 bug);补负例与边界;golden set 是演进的——初期人为种子打底,中期真实数据必须人工验证后才能转契约,持续事故修复固化。核心:断言用户可感知的结果 + 管道行为,不断言实现细节。"
## 13. 代码位置索引
| 类 | 文件 |
|---|---|
| `LookupKnowledgeTool` | `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java` |
| `KnowledgeQueryTransformer` | `src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java` |
| `KnowledgeDocumentRetriever` | `src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java` |
| `KnowledgeEvidencePostProcessor` | `src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java` |
| `KnowledgeContextPacker` | `src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java` |
| `LookupResultAssembler` | `src/main/java/com/superbiz/agent/service/LookupResultAssembler.java` |
| `RetrievalScoreNormalizer` | `src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java` |
| `RrfFusion` | `src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java` |
| `MilvusHybridKnowledgeStore` | `src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java` |
| `RagResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java` |
| 审计链路 | `src/main/java/com/superbiz/agent/harness/audit/`(RagLookupAuditEnricher / JpaToolInvocationAuditSink) |
| 离线评测 | `eval/rag-retrieval/` + `scripts/eval_rag_retrieval.py` |
@@ -0,0 +1,203 @@
# Harness Tool 调用链:一次工具调用的完整旅程
**更新日期**:2026-08-03
**主题**:从「模型决定调用工具」到「模型收到观察」的运行时完整链路——拦截器 → invoke → Adapter → ToolBoundary → 返回 → 二次加工 → ToolCallResponse
**结构篇**:[Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(讲装配/注册/静态结构)
**本文**:动态时序(一次调用怎么跑完)
## 1. 旅程全景(一张图)
```mermaid
sequenceDiagram
participant M as 模型
participant F as 框架 ReactAgent
participant I as HarnessToolInterceptor(per-Run)
participant ET as HarnessEvidenceTools(单例)
participant AD as RagToolAdapter(单例)
participant TB as ToolBoundary(单例)
participant P as DiagnosisProgressTracker
rect rgb(240, 248, 255)
Note over M,I: 阶段 A:模型决定 → 拦截器(执行前)
M->>F: 输出 tool_call(工具名 + 参数 JSON)
F->>I: 回调 interceptToolCall(request, handler)
I->>I: ① supports 注册检查
I->>ET: ② parse(typed 严格契约)
ET-->>I: ParsedAgentToolCall(previous_observation + input)
I->>P: ③ 协议校验(pending 评价)+ 判重
end
rect rgb(255, 250, 240)
Note over I,TB: 阶段 B:invoke → 执行(backend + 投影)
I->>ET: ④ invoke(context, toolName, toolCallId, args)
ET->>AD: bridge 闭包 → adapter.execute(context, envelope)
AD->>TB: boundary.execute(context, envelope, executor, projector)
TB->>TB: ⑤ 五阶段:preflight/预算/begin → executor 跑 backend → 校验 → projector 投影 → markReady
TB-->>I: ToolBoundaryResult(READY/ERROR)
end
rect rgb(245, 255, 245)
Note over I,M: 阶段 C:返回 → 模型(执行后)
I->>I: ⑥ 双源校验(controlView 重读 evidence_status)
I->>P: ⑦ recordCompleted(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
I->>I: ⑧ modelObservation 加工(有界观察 + stop_required/reason)
I-->>F: ToolCallResponse.of(toolCallId, toolName, observation)
F-->>M: observation 作为本轮 tool 结果
end
```
**三个阶段**:A 执行前(模型决定→门禁)→ B 执行中(invoke→backend→投影)→ C 执行后(校验→记账→成型)。
---
## 2. 阶段 A:模型决定 → 拦截器(执行前)
### 2.1 模型怎么知道有这个工具
```
模型 → callbacks 里看到工具(名字+描述+Schema)→ 决定调用 lookup_knowledge
→ 输出 tool_call JSON(工具名 + 参数)
```
工具名是**模型决定的**——框架把模型输出包成 `ToolCallRequest`(含 toolName + arguments),回调拦截器。
### 2.2 拦截器的三道执行前门
```mermaid
flowchart LR
A["① supports(toolName)?"] -->|"否(非证据工具)"| X["handler.call 透传"]
A -->|"是"| B["② parse:typed 严格契约<br/>FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS"]
B -->|"违规"| Y["协议处理(不执行)"]
B --> C["③ 协议校验(pending 评价)+ 判重"]
C -->|"重复"| Z["recordDuplicateScope(不执行)"]
C -->|"通过"| D["进入阶段 B:invoke"]
```
关键:**不是「拿到名字就执行」**——parse(模型输出必须精确匹配 `RagToolCall{previous_observation, input}`,多一个字段都炸 INVALID_ENVELOPE)、协议校验、判重,三道门不通过都不执行 backend。
---
## 3. 阶段 B:invoke → 执行(backend + 投影)
### 3.1 invoke 的委托链
```
I.invoke(context, "lookup_knowledge", "call-1", args)
→ ET.invokers.get("lookup_knowledge") ← 注册表取 bridge 闭包
→ bridge lambda:adapter.execute(context,
new ToolCallRequestEnvelope(runId, "call-1", "lookup_knowledge", args, true, true))
→ ragAdapter.execute(context, envelope)
→ boundary.execute(context, envelope, executor, projector)
```
**envelope 是 bridge 里现造的**:`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」。
### 3.2 Adapter 组装两个函数(接线员)
```java
return boundary.execute(context, envelope,
// executor:跑 backend 拿 raw(LookupResult 序列化成 JSON 文本)
ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())),
// projector:raw → 有界契约 + evidenceStatus
raw -> projector.project(request, envelope.toolCallId(), raw));
```
| 端口 | 干什么 | 产物 |
|---|---|---|
| `executor` | 调具体后端 | rawResponse(JSON 文本,执行链「货币」) |
| `projector` | 净化定型 | ProjectedToolResult(agentResult, evidenceStatus) |
**模型永远看不到 raw**——raw 只用于校验、落 canonical、投影。
### 3.3 ToolBoundary 五阶段
```mermaid
flowchart TD
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
B["② executor.execute(requestJson) → backend raw"]
C["③ raw 大小校验 + Run bytes 预留"]
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
E["⑤ agent_result 校验 + bytes → markReady(READY) 或 markError(ERROR)"]
A --> B --> C --> D --> E
```
返回 `ToolBoundaryResult(READY/ERROR)`——PROJECTING 永不外泄。
---
## 4. 阶段 C:返回 → 模型(执行后)
### 4.1 拦截器的二次加工(不是直接返回)
```mermaid
flowchart LR
A["ToolBoundaryResult"] --> B{"status == READY?"}
B -->|"否"| E1["error observation<br/>BUDGET_EXHAUSTED 额外 markBudgetLimitReached"]
B -->|"是"| C["⑥ 双源校验:controlView 重读 evidence_status"]
C -->|"不一致"| E2["OBSERVATION_CONTRACT_MISMATCH 拒绝"]
C -->|"一致"| D["⑦ recordCompleted<br/>NO_EVIDENCE → 立即 NO_GAIN<br/>FOUND → 挂 pending"]
D --> F["⑧ modelObservation 加工<br/>(有界观察 + stop_required/reason)"]
F --> G["ToolCallResponse.of(...) → 框架 → 模型"]
```
### 4.2 双源校验(自洽性防线)
```text
源1:result.evidenceStatus() ← Projector 投影时计算的声明值
源2:controlView(agentResult).evidenceStatus() ← 从 agent_result 内容重读
一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
```
防止「声明有证据但内容空 / 声明无证据但内容有」的不一致状态进入 progress 记账。
### 4.3 给模型的对象形态
```
ToolCallResponse.of(toolCallId, toolName, observation)
observation = 有界观察:
正常结果:脱敏后的契约内容(可能裁剪)
饱和时: 附加 stop_required:true + reason
协议错误:repair_required:true + violation_type/期望ID/指令
```
框架把 observation 作为本轮 tool 结果给模型——**模型下一轮读取它,决定继续调用(带评价)还是输出 Draft 收尾**。
---
## 5. 旅程的衔接点(模型视角的闭环)
```mermaid
flowchart LR
A["模型调工具"] --> B["观察(有界契约)"]
B --> C{"模型决定"}
C -->|"继续"| D["下次 Tool Call + previous_observation 评价"]
C -->|"收尾"| E["输出 Draft → Release 发布"]
D --> B
```
**progress 协议的闭环**:模型每次继续调用,都要在 Envelope 里回带对上一轮的 GAINED/NO_GAIN 评价——这就是拦截器 ③ 校验的 pending 逻辑(可回看 progress 笔记)。
---
## 6. 关键点总结
| 阶段 | 关键认知 |
|---|---|
| A 执行前 | 工具名是模型决定的;parse 是 typed 严格契约(输出必须匹配 Schema);三道门不通过不执行 |
| B 执行中 | executor/projector 是 Adapter 组装进 boundary 的**参数**;执行链货币是 JSON 文本;模型永远看不到 raw |
| C 执行后 | 拦截器不直接返回——双源校验 + progress 记账 + modelObservation 成型;ToolCallResponse 才是模型拿到的对象 |
## 7. 面试 30 秒说法
> "一次工具调用的完整旅程分三段:执行前,模型从 callbacks 看到工具并决定调用,拦截器做 supports 分流、typed 严格 parse、协议校验和判重——三道门不通过都不执行 backend;执行中,invoke 经 bridge 到 Adapter,Adapter 把 executor(跑 backend 拿 raw)和 projector(raw 投影成有界脱敏契约)组装进 ToolBoundary 的五阶段门禁,返回 ToolBoundaryResult;执行后,拦截器不直接返回——先双源校验 evidence_status,再 recordCompleted 记进度,再 modelObservation 加工成有界观察,最后包装成 ToolCallResponse 给模型。模型看到的永远是脱敏后有界的观察,raw 只进 canonical 供审计验真。"
## 8. 代码位置索引
| 环节 | 文件 |
|---|---|
| 拦截器(A/C 阶段) | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
| 注册表 + parse + invoke | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
| Adapter 组装(B 阶段) | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
| 五阶段门禁 | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
| 双源校验 + 观察成型 | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
| 模型观察形态 | `src/main/java/com/superbiz/agent/harness/agent/ToolControlView.java` |
@@ -0,0 +1,186 @@
# Harness agent 域学习笔记:从框架 ReAct 接入到受控停止
**更新日期**:2026-08-04
**主题**:agent 域完整链路——装配(Factory)/ 双拦截器(Model/Tool)/ 循环外壳(UseCase)/ 受控停止 / 双视图投影
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链)、[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(Tool 拦截器五道门)
## 1. 定位:框架 ReAct 接入层(粘合点)
```text
框架(spring-ai-alibaba ReactAgent):负责 ReAct 多轮(模型 ↔ tool_call)
Harness:不复制 loop,只通过 Interceptor 卡住【每次消耗】
→ Model Interceptor:每次模型调用(预算/审计/Token)
→ Tool Interceptor:每次工具调用(五道门)
原则:拦截器是挂点,不是 loop 实现——Harness 不需要知道框架内部怎么循环
```
## 2. 装配图(DiagnosisAgentFactory——粘合点)
```mermaid
flowchart LR
subgraph 框架能力
M["ChatModel"]
T["tools<br/>evidenceTools.callbacks()"]
L["ReactAgent 循环"]
end
subgraph Harness 控制面
I1["HarnessModelInterceptor<br/>预算+Token 审计"]
I2["HarnessToolInterceptor<br/>五道门+投影"]
H["Hooks<br/>agent_step 落库"]
O["outputSchema<br/>DiagnosisDraft(conclusion 可 null)"]
end
M --> L
T --> L
L --> I1
L --> I2
L --> H
L --> O
```
**关键装配决策**:
```text
.parallelToolExecution(false) ← 串行工具:预算与 step 绑定可解释
.returnReasoningContents(true) ← 推理内容返回
.releaseThread(true)
每次 run 新建 Agent(create(context))——拦截器持有 RunContext,不可跨 run 复用
```
## 3. HarnessModelInterceptor(模型拦截器)
```text
interceptModel(request, handler):
core.beforeModelCall(context) ← 预算门(模型调用前扣预算)
call = auditor.begin(...) ← 审计开始(Token 记账)
response = handler.call(request) ← 框架实际调用
recordUsage(call, response) ← 记 prompt/completion tokens
core.checkActive(context) ← 终态检查(预算耗尽在此打断)
return response
异常:记 0 token + 上抛(不吞)
```
**三个动作**:预算(beforeModelCall)→ 记账(auditor.begin/recordUsage)→ 终态(checkActive)——每次模型调用都被 Harness 卡住一次。Usage 字段 null/负数安全兜底(nonNegative)。
## 4. HarnessToolInterceptor(工具拦截器,已深学)
五道门(progress 会话已沉淀):证据工具必炸 handler → 拦截器唯一执行路径 → 边界投影 → 审计落库 → 返回。本会话只补装配视角:`interceptors` 列表里第二个,构造时注入 context + evidenceTools + objectMapper + traceRecorder。
## 5. DiagnosisAgentUseCase(循环外壳)
### 5.1 执行流程
```text
execute(context, input):
checkActive → 输入限制(query/previous_turn/input 字节)
reserveRunBytes(input) ← 输入也占 Run 预算
RunnableConfig.metadata 挂 RunContext ← 显式传递(避免隐式 ThreadLocal)
agent = factory.create(context) ← 每次 run 新建
response = agent.call(inputJson, config) ← ★ 框架跑完整个 ReAct 循环
output → 字节限制 → reserveRunBytes(draft) → parse DiagnosisDraft
→ completed(draft) 或 受控停止
```
### 5.2 受控停止(controlledExecution)——从异常栈捞回可控信号转正常返回值
```text
① DiagnosisCollectionStoppedException(信息饱和后仍强 tool)
→ stopped(stopReason) ← 收集该停(draft=null)
② RunAbortedException + BUDGET_EXHAUSTED
→ markBudgetLimitReached + stopped(BUDGET_LIMIT_REACHED)
③ 其他 RunAborted(取消/超时/内部失败终态)→ 原样再抛
← 留给 Application 写 CANCELLED/FAILED(不降级为正常停止)
④ BudgetExceededException 或 lifecycle 已 BUDGET_EXHAUSTED → 预算 stopped
⑤ 都识别不了 → 包装 DiagnosisAgentOutputException(Agent 执行失败)
```
**关键**:不是笼统「业务异常 → 正常」——**只识别 Harness 约定的可控信号**(沿 cause 链找,因框架可能再包一层);取消/超时必须上抛(诚实终态)。
### 5.3 与 recoverInvalidDraft 的分工
```text
controlledExecution:loop 被预算/收敛打断(往往还没有合法 draft)→ stopped
recoverInvalidDraft:loop 跑完了,但输出不是合法 DiagnosisDraft → 恢复/重试
```
### 5.4 输出解析(严格)
```text
draftReader = FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS(严格模式)
JsonParseException → INVALID_JSON / SchemaInvalid → SCHEMA_INVALID(分类错误码)
空输出 → EMPTY_DRAFT(分类错误码)
```
## 6. 双视图投影(ToolResultViewProjector)
```text
modelObservation() → 模型观察:只含该工具的内容字段
lookup_knowledge → scope.query + evidence + relevance_level
query_logs → source_kind + scope + patterns + events
query_mysql → scope + columns + rows
+ stop_required/reason(需要停止时附加)
controlView() → 控制视图:evidence_status / relevance_level / returned_count / truncated
(Harness 控制面读,模型看不到)
分工:模型看到「内容」,Harness 看到「控制信息」(判级/截断/进度消费)
```
## 7. 定义层小件
| 类 | 作用 |
|---|---|
| `DiagnosisAgentInput` | query + previous_turn(query 必填) |
| `DiagnosisAgentLimits` | maxQuery/PreviousTurn/Input/DraftBytes 四类字节上限 |
| `DiagnosisAgentPrompt` | classpath 加载系统提示词(prompts/diagnosis-agent-prompt.md) |
| `DiagnosisAgentExecution` | completed(draft, progress) / stopped(progress, stopReason) 双形态 |
| `DiagnosisDraftOutputSchema` | BeanOutputConverter postProcess:conclusion 允许 object/null(无结论合法) |
| `EvidenceToolInvoker` | 函数式:RunContext + toolCallId + arguments → ToolBoundaryResult |
| `ParsedAgentToolCall` | 解析出的工具调用(previousObservation + businessInput + arguments) |
## 8. 关键设计点(面试)
| 设计 | 为什么 |
|---|---|
| **不复制 loop** | 框架 ReAct 是标准能力;Harness 用拦截器挂在每次消耗点,不需要知道框架内部怎么循环 |
| 每次 run 新建 Agent | 拦截器持有 RunContext——Agent 与 run 绑定,防跨 run 串状态 |
| RunContext 显式传(metadata) | 避免隐式 ThreadLocal(框架线程池/异步下 ThreadLocal 不可靠) |
| 串行工具(parallel=false) | 预算与 step 绑定可解释(并行会让「哪一步花多少钱」不可审计) |
| 受控停止只认约定信号 | 取消/超时绝不降级为正常停止(诚实终态) |
| conclusion 允许 null | 无结论也是合法 Draft(FALLBACK 路径) |
| 双视图 | 模型观察 vs 控制视图分离——控制信息(判级/截断)不进模型上下文 |
| 输出严格解析 | FAIL_ON_UNKNOWN/TRAILING——防止模型输出混入意外字段 |
## 9. 易错点
| 易错 | 正确 |
|---|---|
| Harness 自己实现 Agent loop | 框架跑 loop,拦截器挂消耗点(不复制 loop) |
| 任何异常都转 stopped | 只认约定信号(CollectionStopped/预算);取消/超时原样上抛 |
| Agent 复用 | 每次 run 新建(拦截器绑定 RunContext) |
| ThreadLocal 传 context | RunnableConfig metadata 显式传 |
| 并行工具省时间 | 串行(预算与 step 绑定可解释) |
| 模型看到控制信息 | 双视图:模型看内容,Harness 看控制 |
| 输出宽容解析 | FAIL_ON_UNKNOWN + FAIL_ON_TRAILING(严格模式) |
## 10. 面试话术(30 秒)
> "agent 域是框架 ReAct 的接入层:不复制 loop——spring-ai-alibaba 的 ReactAgent 负责多轮循环,Harness 通过两个拦截器卡住每次消耗:Model Interceptor(每次模型调用前 checkActive + 预算,调用后记 Token 审计)、Tool Interceptor(工具调用五道门)。装配在 DiagnosisAgentFactory,每次 run 新建 Agent(拦截器绑定 RunContext,RunnableConfig metadata 显式传递避免 ThreadLocal)。循环外壳 DiagnosisAgentUseCase 做输入/输出字节限制 + Run 预算预留,并实现受控停止——只把 Harness 约定的可控信号(信息饱和、预算耗尽)从异常栈捞回转成 stopped,取消/超时原样上抛留给 Application 写 CANCELLED/FAILED。串行工具保证预算与 step 绑定可解释。"
## 11. 代码位置索引
| 类 | 文件 |
|---|---|
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
| `HarnessModelInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessModelInterceptor.java` |
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
| `DiagnosisAgentUseCase` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentUseCase.java` |
| `ToolResultViewProjector` | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
| `DiagnosisAgentExecution` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentExecution.java` |
| `DiagnosisAgentOutputException` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentOutputException.java` |
| 契约(DiagnosisDraft/PreviousTurn) | `src/main/java/com/superbiz/agent/harness/contract/` |
@@ -0,0 +1,166 @@
# Harness application + audit 学习笔记:从 Run 编排到可回放审计
**更新日期**:2026-08-06
**主题**:application 域(Run 全生命周期编排 + 安全落库)+ audit 域(可观测账本)——重点讲清 audit 与 trace 的设计与区别
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
## 1. 一句话定位
```text
application = Run 应用所有者:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出
audit = 可观测账本:Trace 时序回放 + Token 对账 + 各明细审计表(metadata-only)
```
## 2. application 域:ChatApplicationUseCase 六步编排
```mermaid
flowchart TD
A["Controller → execute(request, observer)"] --> B["① 读会话上下文<br/>RoutingHistory + PreviousTurn"]
B --> C["② core.startRun<br/>创建 Run 边界"]
C --> D["③ persistStart + observer.onStarted<br/>(SSE metadata + 取消句柄 CoreRunControl)"]
D --> E["④ router.route<br/>意图路由(单次模型调用)"]
E --> F["⑤ executePath 按 intent 分叉<br/>SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS"]
F --> G["⑥ completePath + persistFinish + 返回"]
G -.异常.-> H["统一失败出口<br/>terminalOutcome + safeFailure"]
```
### 2.1 关键设计点
| 设计 | 代码事实 | 意义 |
|---|---|---|
| 取消句柄 | `observer.onStarted(new CoreRunControl(core, context))` → `core.cancel(context, CLIENT_DISCONNECTED)` | 客户端断连 → 取消广播 → 强杀 in-flight 调用 |
| 取消有原因 | `RunCancellationReason.CLIENT_DISCONNECTED` | 区分断连/用户取消,可审计 |
| sessionId 白名单 | `SAFE_ID = [A-Za-z0-9][A-Za-z0-9._-]{0,63}` | 信任边界校验 |
| 多轮记忆有界 | RoutingHistory(intent+query) + PreviousTurn(PublishedResult 有界化) | 上一轮只传安全摘要,无 tool ids / raw evidence |
| 预算终态特殊处理 | `handledBudgetTermination()` 时不二次 completeSuccess | 不破坏 first-terminal-wins |
| 统一失败出口 | terminalOutcome → CANCELLED/FAILED + ChatFailureCode(文案安全) | 不暴露内部堆栈 |
| 路由输出契约 | `OUTPUT_FIELDS = {intent}`,值必须枚举名 | 防模型夹带 |
### 2.2 持久化(JpaChatRunStore + PublishedResultPolicy)
- start / markIntent / finish(@Transactional),finish 写 outcome + safeContentJson + publishedResult;
- PublishedResultPolicy:**只有 SUCCESS 且 draft 有结论才构造 PublishedResult**;sanitize 全套有界(query 2000/结论 2000/scope 1000/limitations 10×500/文档 10);
- sourceDocuments 只收 RAG 类型证据的 document_id(去重)——MySQL/日志证据不进发布文档列表;
- PreviousTurn = sanitize 后的有界摘要(多轮记忆来源)。
## 3. audit 域:可观测账本的层次
```mermaid
flowchart LR
subgraph 写入侧["写入侧(不阻断主流程)"]
H1["HarnessAgentAuditHook<br/>每模型步 → agent_step + agent_reasoning_audit"]
H2["ToolInvocationAuditSink<br/>工具 → tool_invocation"]
H3["ModelCallAuditor<br/>Token → ledger + core + agent_step 回写"]
H4["JpaDiagnosisTraceRecorder<br/>事件 → diagnosis_trace_event"]
H5["JpaChatRunStore<br/>Run → diagnosis_run"]
end
subgraph 读取侧["读取侧(回放)"]
S["DiagnosisTraceService"]
S --> R["DiagnosisTraceResponse<br/>timeline + steps + toolInvocations + run + summary"]
end
H1 --> DB[(MySQL 各表)]
H2 --> DB
H3 --> DB
H4 --> DB
H5 --> DB
DB --> S
```
### 3.1 各落库点(谁写哪张表)
| 落库点 | 表 | 内容 |
|---|---|---|
| HarnessAgentAuditHook | agent_step | 每模型步摘要(stepIndex/耗时/token/工具计划) |
| HarnessAgentAuditHook | agent_reasoning_audit | 推理 + assistant_text 正文(受限) |
| ToolInvocationAuditSink | tool_invocation | 工具入参/输出预览/检索明细 |
| JpaDiagnosisTraceRecorder | diagnosis_trace_event | 全链路事件时序线 |
| ModelCallAuditor | agent_step.token_count | Token 回写(仅 DIAGNOSIS_AGENT) |
| JpaChatRunStore | diagnosis_run / chat_session | Run 生命周期 + 发布契约 |
### 3.2 audit 的三个核心边界(fail-safe / metadata-only / 对账)
1. **审计不阻断主流程**:三个落库点全部 try-catch + log.warn——审计挂了不能让 Run 跟着挂;
2. **metadata-only 分层**:正文只允许出现在 agent_reasoning_audit(受限)和 tool_invocation(入参/输出预览),其余全部摘要化;
3. **Token 三写闭环**:ledger 分账 → core.recordTokens(Run 预算)→ agent_step.token_count 回写——审计与预算同源可对账。
## 4. audit vs trace:设计与区别(重点)
### 4.1 核心区别:包含关系,不是并列
```text
audit = 域(可观测账本的总集合,17 个文件)
├─ ★ trace = 域内的事件回放子体系(诊断时序线)
├─ ModelCallLedger / Auditor(Token 记账)
├─ HarnessAgentAuditHook(模型步审计)
├─ ToolInvocationAuditSink(工具审计)
├─ RagLookupAuditEnricher(RAG 检索审计)
└─ RunConclusionExtractor(结论提取)
```
**常见误解修正**:trace 不是「agent 执行记录」,而是**全链路七阶段时序线**(RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);audit 也不只是「tool 审计」,tool 审计只是其中一小块。
### 4.2 一张表讲清区别
| 维度 | trace(diagnosis_trace_event) | audit(各明细账本) |
|---|---|---|
| 本质 | 时序事件流(按 sequence_no 排序) | 实体化明细记录 |
| 回答 | 发生了什么、按什么顺序 | 每个细节落在哪本账上 |
| 粒度 | 每帧只带摘要 + 关联键(step_id 等) | 完整字段(入参/输出/token/耗时) |
| 结构 | 一张表、一个序列 | 多张表(agent_step/tool_invocation/...) |
| 排序保证 | sequence_no 单调(ConcurrentHashMap 分配) | step_index / id / createdAt 排序 |
| 典型查询 | `findByRunIdOrderBySequenceNoAscIdAsc` | `findByRunIdOrderByStepIndex` 等 |
| 与明细的关系 | 靠 step_id / run_id 互链,不重复存储 | 承载被关联的实体数据 |
### 4.3 真实数据对照(run 1b584a01)
```text
trace(15 帧时序线):
RUN_STARTED → ROUTING_DECISION → AGENT_MODEL_STEP×2 → TOOL_INVOCATION
→ EVIDENCE_GUARD_INITIAL → SEMANTIC_GUARD_DECISION → RELEASE_DECISION → RUN_FINISHED
audit 各账本(同 Run):
agent_step 2 行(token 2461/4415,耗时 1418/8542ms)
agent_reasoning_audit 2 行(step1 = 完整 Draft JSON)
tool_invocation 1 行(lookup_knowledge,step_id=979)
diagnosis_run outcome=SUCCESS + published_result 完整 JSON
```
**关联示例**:trace 第 7 帧 TOOL_INVOCATION 的 details 里 `step_id=979` = agent_step.id=979 = tool_invocation.step_id——时序帧与明细账本通过 id 互链。
## 5. 可回放机制(DiagnosisTraceService)
```text
GET /api/diagnosis/{sessionId}/trace?runId=xxx
→ DiagnosisTraceResponse 七块:runId / chatSession / session / run /
steps[] / toolInvocations[] / timeline[] / summary
GET /api/diagnosis/{sessionId}/trace/reasoning?runId=xxx(受限:runId 必填)
→ agent_reasoning_audit(reasoning + assistantText)
```
三级回放深度:**时间线(timeline)→ 明细(steps/toolInvocations)→ 推理(reasoning,按需受限读取)**。
回放能成立的四个保证:
1. sequence_no 单调(索引 idx_trace_event_run_sequence);
2. 事件与明细靠 step_id 互链;
3. summary 的 persisted vs returned 双计数对账(发现落库不完整);
4. 双查询入口兼容新旧会话(buildLegacyTraceResponse)。
## 6. 面试话术(30 秒)
> "application 域是 Run 的应用所有者:一次请求六步编排——建 Run 边界、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。**audit 域是可观测账本,trace 是它内部的事件回放子体系**:trace 用一张 diagnosis_trace_event 表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),audit 的明细账本(agent_step / tool_invocation / agent_reasoning_audit / diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账 → Run 预算 → 明细回写)。回放由 DiagnosisTraceService 按 runId 聚合排序,三级深度:时间线 → 明细 → 推理。"
## 7. 代码位置索引
| 组件 | 文件 |
|---|---|
| ChatApplicationUseCase / ChatFailureCode / ChatApplicationStatus | `src/main/java/com/superbiz/agent/harness/application/` |
| DiagnosisChatExecutor / 其他 executor | `.../application/executor/` |
| IntentRouter / IntentRouterPrompt | `.../application/routing/` |
| JpaChatRunStore / PublishedResultPolicy / RoutingHistory | `.../application/persistence/` |
| Trace 体系(Recorder/Event/Type/Status/AuditEvents) | `src/main/java/com/superbiz/agent/harness/audit/`(前半) |
| ModelCallLedger / ModelCallAuditor / HarnessAgentAuditHook / RunConclusionExtractor | `.../audit/`(后半) |
| DiagnosisTraceService / DiagnosisTraceController | `src/main/java/com/superbiz/agent/service/` + `controller/` |
| V014 建表(diagnosis_trace_event) | `src/main/resources/db/migration/V014__create_diagnosis_trace_event.sql` |
@@ -0,0 +1,105 @@
# Harness contract 状态流学习笔记:11 个状态枚举的正交全景
**更新日期**:2026-08-06
**主题**:contract 域状态枚举全景——五层状态 / 正交维度 / 纵向映射链 / 真实数据案例 / 面试讲法
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md)
## 1. 一句话定位
**contract 状态流 = 11 个状态枚举按五层正交组织,每层回答一个独立问题;层间通过显式映射链串联——从技术终态到用户结局,从工具调用到发布裁决,全部类型化,杜绝字符串漂移。**
## 2. 五层状态全景
| 层 | 枚举 | 值 | 回答的问题 |
|---|---|---|---|
| ① 技术层(core) | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许执行 |
| ② 证据层(tool/guard) | `InvocationStatus` | PROJECTING / READY / ERROR | 调用生命周期走到哪 |
| | `EvidenceStatus` | EVIDENCE_FOUND / NO_EVIDENCE / ERROR | 这次调用有没有拿到可引用证据 |
| | `AnalysisKind` | NORMAL / NEGATIVE_OBSERVATION | 分析条目是正向还是负向观察 |
| ③ 收集层(progress) | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROTOCOL_VIOLATED | 证据收集为何受控停止 |
| | `SemanticVerdict` | SUPPORTED / UNSUPPORTED | 结论是否被证据支持 |
| ④ 发布层(release) | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局 |
| | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | 为什么降级 |
| ⑤ 协议层 + 失败层 | `ChatApplicationStatus` | ROUTING / SYSTEM_RESPONDING / KNOWLEDGE_SEARCHING / KNOWLEDGE_ANSWERING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING | 当前走到哪一阶段(进行中) |
| | `SseOutcome` | SUCCESS / FALLBACK / FAILED | done 事件粗粒度结局(**未接线**) |
| | `ChatFailureCode` | ROUTING_UNAVAILABLE / SYSTEM_CHAT_UNAVAILABLE / KNOWLEDGE_UNAVAILABLE / DIAGNOSIS_UNAVAILABLE / RUN_PERSISTENCE_FAILED / RUN_CANCELLED / INTERNAL_FAILURE | 失败时给客户端的粗粒度原因 |
## 3. 正交维度(四个独立轴)
```text
轴 1:RunState(技术终态)⊥ ReleaseOutcome(用户结局)
一个 Run 技术停了,用户看到的可能是降级(有安全进展)或失败(没进展)
轴 2:InvocationStatus(调用生命周期)⊥ EvidenceStatus(证据语义)
注释原话:一个是「投影中/就绪/错误」,一个是「有没有拿到可引用证据」
NO_EVIDENCE 仍可能是 success 的工具执行(查了但空)
轴 3:ChatApplicationStatus(进度,进行中)⊥ 结局(终态)
进度回答「走到哪」,结局回答「最终给什么」
轴 4:IntentType(路由)——每次请求一个,决定走哪条分支
```
## 4. 纵向映射链(代码事实)
```text
RunState.BUDGET_EXHAUSTED ──hasObservedFacts()==true──▶ FALLBACK(INSUFFICIENT_EVIDENCE)
└──无 facts──▶ FAILED(fail closed)
RunState.CANCELLED ──▶ ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
内部失败 ──▶ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
SemanticVerdict.SUPPORTED(guard 全过)──▶ ReleaseOutcome.SUCCESS(唯一出口)
FallbackType 任意值 ──▶ ReleaseOutcome.FALLBACK
证据层内部约束:
AnalysisKind.NORMAL.accepts(EVIDENCE_FOUND)
AnalysisKind.NEGATIVE_OBSERVATION.accepts(NO_EVIDENCE)
InvocationStatus.READY 是 EvidenceGuard 可引用前提(isReferencableBy)
EvidenceStatus.ERROR 不是证据,不能支持分析/结论(prompt 原话)
```
## 5. 真实数据案例(run 1b584a01 的状态流转)
| 阶段 | 状态值(真实 trace 佐证) |
|---|---|
| 路由 | IntentType=DIAGNOSIS(ROUTING_DECISION 事件 details) |
| Agent 执行 | RunState=RUNNING,ChatApplicationStatus: ROUTING → DIAGNOSIS_RUNNING → SAFETY_VALIDATING |
| 工具调用 | InvocationStatus: PROJECTING → READY;EvidenceStatus=EVIDENCE_FOUND |
| 分析 | AnalysisKind=NORMAL × 3(accepts EVIDENCE_FOUND) |
| 验真 | EVIDENCE_GUARD_INITIAL=PASSED(violations=0) |
| 语义裁决 | SEMANTIC_GUARD_DECISION=SUPPORTED |
| 发布 | RELEASE_DECISION=SUCCESS(= ReleaseOutcome.SUCCESS) |
| 终态 | RunState=SUCCESS |
## 6. 面试怎么讲这个状态流设计
### 6.1 叙事模板(① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术)
**① 动机**:Agent 系统里最容易被搞混的就是「状态」。技术停了(预算耗尽)不等于用户看到失败;查了没查到不等于系统出错;进行中不等于终态。如果所有状态塞进一个枚举,语义就糊了。
**② 决策**:**分层 + 正交 + 显式映射**——每个枚举只回答一个问题(技术/证据/收集/发布/协议各管各的),层间不隐式耦合,用明确的映射链串联。
**③ 实现**:11 个枚举五层,两个最典型的正交轴 + 一条纵向映射链(如上);全部类型化(enum/record),杜绝字符串漂移。
**④ 边界**:SSE 取消场景连接可能已断、发不出 done,所以协议层只有三态;`SseOutcome` 目前未接线(实现直接复用 ReleaseOutcome);`FallbackType.BUDGET_EXHAUSTED` 是命名债务(实际发布 INSUFFICIENT_EVIDENCE)。
**⑤ 话术(30 秒)**:
> "Harness 的状态设计核心是**分层正交**:技术终态(RunState)和用户结局(ReleaseOutcome)是两个正交轴——预算耗尽且有安全进展时用户看到 FALLBACK 降级,没进展才是 FAILED,这样同一个技术终态可以诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空结果(NO_EVIDENCE)仍是成功执行。层间是显式映射链:BUDGET_EXHAUSTED+有事实→FALLBACK,CANCELLED→CANCELLED+RUN_CANCELLED,guard 全过→SUCCESS 唯一出口。协议层(SSE)复用 ReleaseOutcome 三态并拒绝 CANCELLED(取消时连接已断),SseOutcome 是预留未启用的抽象。"
### 6.2 高频追问应对
| 追问 | 答 |
|---|---|
| RunState 和 ReleaseOutcome 有什么区别? | 技术 vs 用户:RunState 回答 Run 是否还允许执行;ReleaseOutcome 回答用户侧内容形态。BUDGET_EXHAUSTED 可以有 FALLBACK 或 FAILED 两种用户结局 |
| 为什么预算耗尽既可能 FALLBACK 又可能 FAILED? | `progress.hasObservedFacts()` 安全阀——有已验真事实才能发布降级,没有就 fail closed |
| NO_EVIDENCE 算失败吗? | 不算。NO_EVIDENCE 是成功执行但范围内无证据(查了但空),常记为信息无增益;ERROR 才是工具侧失败 |
| CANCELLED 为什么不在 SSE done 里? | 取消时客户端连接可能已断,发不出 done;所以协议层只有 SUCCESS/FALLBACK/FAILED 三态 |
| 这些状态为什么不直接用一个枚举? | 塞一个枚举语义就糊了——技术、证据、发布、协议回答的是不同问题,正交分层后各层可独立演进,映射显式可审计 |
| InvocationStatus 和 EvidenceStatus 会不会重复? | 分工明确:生命周期(投影中/就绪/错误)vs 证据语义(有没有证据);READY+NO_EVIDENCE 是完全合法的组合 |
## 7. 代码位置索引
| 组件 | 文件 |
|---|---|
| 全部状态枚举与数据契约 | `src/main/java/com/superbiz/agent/harness/contract/` |
| RunState | `.../harness/core/RunState.java` |
| DiagnosisStopReason | `.../harness/progress/DiagnosisStopReason.java` |
| ChatApplicationStatus / ChatFailureCode | `.../harness/application/` |
| SSE 会话(done 事件拒绝 CANCELLED) | `.../controller/sse/ChatSseEvent.java` |
@@ -0,0 +1,415 @@
# Harness progress 代码学习笔记:从拦截器五道门到唯一发布点
**更新日期**:2026-08-03
**主题**:Progress 子系统的代码落地——拦截器五道门、Tracker 状态机、canonical 生命周期、执行门禁、投影与发布闭环
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**设计视角**:[Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md)(讲「为什么这样设计」)
**本文视角**:代码里怎么落地(类地图、调用链、生命周期、状态机、门禁、易错点、面试话术)
## 1. 定位:双停止机制与三方判断权
### 1.1 双停止机制(progress 存在的根本理由)
预算管「能不能花」,progress 管「值不值得继续查」。让预算充当正常停止策略,会把「当前证据不足」错误表达成「系统执行失败」——这是语义错误,不是资源问题。
| 机制 | 问的问题 | 归属 |
|---|---|---|
| RunBudget | 这次 Run 最多允许消耗多少? | core 域 |
| Progress | 继续查询是否仍可能推进当前诊断? | progress 域 |
### 1.2 三方判断权(信任边界)
```mermaid
flowchart LR
T["Tool / Projector<br/>客观结果"] -->|"evidence_status:空不空(代码判)"| H
M["Diagnosis Agent<br/>语义价值"] -->|"information_gain:有没有用(模型判)"| H
H["Harness<br/>最终停止权"] -->|"scope 重复 / 连续 NO_GAIN / 协议合规"| R["停止裁决"]
```
- **谁判空**:`EVIDENCE_FOUND / NO_EVIDENCE` 是 Harness 代码判(证据数组空不空),Projector 投影时客观计算,不需要模型。
- **谁判价值**:`GAINED / NO_GAIN` 的语义价值是模型判——非空结果「有没有用」只有结合诊断上下文才能判断。
- **谁决定停止**:Harness。模型可以主动结束(输出 Draft),但不能用继续发 Tool Call 绕过 Harness 已定的饱和状态。
**两层信任边界分开**:模型输出乱来(瞎报增益)时,Harness 手里仍有 evidenceStatus 这个与模型无关的事实层(canonical 记录、审计、验真都建立在它之上)。
## 2. 类地图与调用链
### 2.1 14 个文件分类
| 类别 | 类 | 作用 |
|---|---|---|
| 状态机核心 | `DiagnosisProgressTracker` | 记账(双计数/pending/identity)+ 停止裁决 |
| 判重 | `ToolScopeNormalizer` + `ToolScopeIdentity` | 业务输入 → 稳定 scope 指纹 |
| 投影 | `DiagnosisProgressProjector` + `DiagnosisProgressProjection` | identity → 回读 canonical → 有界快照 |
| 枚举 | `InformationGain` / `DiagnosisCollectionState` / `DiagnosisStopReason` / `ProgressProtocolViolationType` | GAINED·NO_GAIN / COLLECTING·SATURATED / 三种停止原因 / 五类违规 |
| record | `PreviousObservation` / `CompletedToolCall` / `DiagnosisProgressSnapshot` / `DiagnosisProgressSnapshotState` | 协议字段 / 完成 identity / 对外快照 / 内部快照 |
| 异常 | `ProgressProtocolViolationException` | 受控协议违规 |
### 2.2 一次 Tool Call 的完整链路
```mermaid
sequenceDiagram
participant I as HarnessToolInterceptor(per-Run)
participant ET as HarnessEvidenceTools(注册表)
participant AD as Adapter(接线员)
participant TB as ToolBoundary(门禁)
participant S as CanonicalInvocationStore(真相)
participant P as Projector(投影)
I->>ET: invoke(context, toolName, toolCallId, args)
ET->>AD: bridge 包装的 invoker
AD->>TB: boundary.execute(context, envelope, executor, projector)
TB->>S: begin(PROJECTING)
TB->>TB: executor.execute(requestJson)(backend raw)
TB->>P: projector.project(raw) → agent_result + evidenceStatus
TB->>S: markReady(READY) 或 markError(ERROR)
TB-->>AD: ToolBoundaryResult(READY/ERROR)
AD-->>I: 一路 return
I->>I: 双源交叉验证 → recordCompleted → 返回有界 observation
```
### 2.3 三层关系
**持有关系**:
```
HarnessToolInterceptor ──持有──▶ RunContext(含 Tracker)/ HarnessEvidenceTools
HarnessEvidenceTools ──持有──▶ Map<toolName, EvidenceToolInvoker>(bridge 注册表)
Adapter ──持有──▶ ToolBoundary + 具体 backend + 具体 Projector
ToolBoundary ──持有──▶ DiagnosisHarnessCore / CanonicalInvocationStore / ToolCallKeyFactory
```
**接口-实现关系**:
| 接口 | 实现 |
|---|---|
| `EvidenceToolInvoker`(@FunctionalInterface) | `HarnessEvidenceTools.fromAdapters` 的 bridge |
| `AdapterCall`(内部 @FunctionalInterface) | 三个 Adapter 的 execute 方法 |
| `ToolExecutor`(@FunctionalInterface) | Adapter 里 `ignored -> legacyExecutor.execute(query)` |
| `ToolResultProjector` | `RagResultProjector` / `QueryLogsResultProjector` / `MysqlResultProjector` |
| `CanonicalInvocationStore` | `JpaCanonicalInvocationStore` 等 |
## 3. 生命周期:单例 vs per-Run
```mermaid
flowchart TD
subgraph 应用启动(一次)
A["Spring 容器装配单例 bean"]
A --> B["DiagnosisHarnessCore / ToolBoundary / Adapter / HarnessEvidenceTools / DiagnosisAgentFactory"]
end
subgraph 每次请求(多次)
C["core.startRun() → RunContext(含 new DiagnosisProgressTracker)"]
C --> D["DiagnosisAgentFactory.create(context)"]
D --> E["new HarnessModelInterceptor / HarnessToolInterceptor(绑定本 Run context)"]
E --> F["ReactAgent.call() → ReAct loop"]
F --> G["Run 结束:拦截器/ReactAgent 变成垃圾"]
end
```
| 对象 | 生命周期 | 原因 |
|---|---|---|
| core / ToolBoundary / Adapter / EvidenceTools / AgentFactory | 单例 | 无状态,只存依赖与规则 |
| RunContext | per-Run | 状态容器(Tracker/Budget/Lifecycle) |
| 两个 Interceptor | per-Run | 持有本 Run 的 RunContext |
| ReactAgent | per-Run | 框架有状态对象(loop/memory) |
**无状态体现**:单例 bean 的字段全是构造注入的依赖/配置(创建后不变);可变状态全部外置到 RunContext 和持久化存储。方法一律以 `context` 参数显式传入——同一个 ToolBoundary 实例可并发服务多个 Run,各算各的账,**状态外置是并发正确性的硬要求**(若在 bean 里存「当前预算计数」,并发 Run 会互相覆盖)。
## 4. 拦截器五道门(代码核心)
### 4.1 门卫 + 执行者合一
证据工具的 `ToolCallback` 被**故意定义为直接抛异常**(`"Harness evidence Tools require the framework Tool interceptor"`)——如果框架绕过拦截器执行 `handler.call()`,直接爆炸。这从结构上保证:**执行不可绕过门禁,校验+执行是原子操作**。
```mermaid
flowchart LR
REQ["Tool Call 到达"] --> CHK{"evidenceTools.supports?"}
CHK -->|"非证据工具"| HANDLER["handler.call() 透传"]
CHK -->|"证据工具"| GATES["五道门(校验+执行+记账)"]
```
**为什么不在 definition 里写**(四个原因):
1. 拦截器接管执行时**根本不经过 ToolCallback**——写在 definition 里永远不会执行;
2. ToolCallback 是纯函数(input→output),**拿不到 RunContext**,调不了 `context.progress()`;
3. ToolCallback 本身就是执行,**没有「执行前」时机**——判重必须在 backend 前,只有拦截器同时拥有执行前/后两个观察点;
4. progress 逻辑横跨所有证据工具,写在每工具 callback 会**复制漂移**。
### 4.2 五道门流程
```mermaid
flowchart TD
A["① 已停止检查"] -->|"SATURATED && stopInstructionDelivered"| B["抛 DiagnosisCollectionStoppedException(硬停止)"]
A --> C["② 解析 + 协议校验"]
C -->|"违规"| D["recordProgressProtocolViolation → 可修复反馈 / 达阈值饱和"]
C -->|"评价导致饱和"| E["stopRequired(软停止)"]
C --> F["③ 判重:isDuplicate(toolName, normalizedScope)"]
F -->|"重复"| G["recordDuplicateScope(直接 NO_GAIN)→ 饱和? stopRequired : 返回 DUPLICATE_SCOPE 观察"]
F --> H["④ ToolBoundary 执行(预算/canonical/审计/checkActive)"]
H -->|"READY"| I["双源交叉验证 → recordCompleted → ⑤ 收尾(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending / 饱和交付 stop_required)"]
H -->|"非 READY"| J["error observation(BUDGET_EXHAUSTED 额外 markBudgetLimitReached)"]
```
### 4.3 协议三类违规(第二道门)
校验的是 **Envelope 与 Tracker pending 状态的协议关系**(不是 JSON 结构——JSON 结构在 parse 用严格反序列化做了):
| 违规 | 含义 | 判定条件 |
|---|---|---|
| `UNEXPECTED_PREVIOUS_OBSERVATION` | 没欠账却带评价 | pending == null 且 observation != null |
| `MISSING_PREVIOUS_OBSERVATION` | 欠账不还 | pending != null 且 observation == null |
| `OUT_OF_ORDER_PREVIOUS_OBSERVATION` | 还错账 | observation.toolCallId() != pendingToolCallId |
另两类(parse 阶段):`MISSING_INPUT`(缺业务输入)、`INVALID_ENVELOPE`(JSON/字段非法)。
**可修复反馈**:首次违规(未达阈值)返回 `repair_required:true` 观察——带 `violation_type` / `missing_field` / `expected_previous_tool_call_id` / `allowed_information_gain` / `instruction`,让模型下一轮自愈;**连续**违规达独立阈值才饱和(PROGRESS_PROTOCOL_VIOLATED)。
### 4.4 双源交叉验证(第四道门,READY 后)
```text
源1:result.evidenceStatus() ← Projector 投影时计算并携带的声明值
源2:controlView.evidenceStatus() ← 从 agent_result 内容里解析 "evidence_status" 字段
比较:一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
```
本质是「自洽性防线」:同一状态被两处描述(结果对象字段 vs 内容 JSON 字段),必须一致——防止投影 bug / 数据损坏 / 构造不一致导致 progress 基于错误状态决策(如声明 FOUND 但内容空 → 挂 pending 等评价不存在的证据;声明 NO_EVIDENCE 但内容有证据 → 误记 NO_GAIN)。
### 4.5 三副 observation 面孔
| 面孔 | 触发 | 关键字段 |
|---|---|---|
| 正常执行结果 | READY 且未饱和 | 有界观察 + 可选 `stop_required` |
| `STOP_REQUIRED` | 饱和后交付一次 | `stop_required:true` + `reason` |
| 可修复协议错误 | 协议违规未达阈值 | `repair_required:true` + violation_type/missing_field/expected id/instruction |
## 5. Tracker 状态机
### 5.1 11 个字段
| 字段 | 含义 |
|---|---|
| `stopAfterConsecutiveNoGain` | 连续 NO_GAIN 阈值(默认 2,yml 可配) |
| `stopAfterConsecutiveProgressProtocolViolations` | 连续协议违规阈值(默认 2) |
| `completedScopes` | `toolName + normalizedScope` 去重集合 |
| `completedToolCalls` | 已完成调用 identity 列表(无 payload) |
| `consecutiveNoGain` | 连续无增益计数(GAINED 清零) |
| `consecutiveProgressProtocolViolations` | 连续协议违规计数(合法评价清零) |
| `collectionState` | `COLLECTING` / `SATURATED` |
| `stopReason` | `INFORMATION_SATURATED` / `BUDGET_LIMIT_REACHED` / `PROGRESS_PROTOCOL_VIOLATED` |
| `pendingToolCallId` | 待评价调用 ID(同一时刻最多一个) |
| `stopInstructionDelivered` | STOP_REQUIRED 是否已交付(只一次) |
### 5.2 两条独立计数(重点:协议违规 ≠ NO_GAIN)
```text
NO_GAIN 路径(applyGain)—— backend 执行了但没增益:
入口:applyPreviousObservation(NO_GAIN) / recordDuplicateScope() / recordCompleted(NO_EVIDENCE)
阈值:stopAfterConsecutiveNoGain(2) → SATURATED + INFORMATION_SATURATED
GAINED 清零连续计数
协议违规路径(recordProgressProtocolViolation)—— backend 根本没执行:
入口:拦截器 catch 分支
阈值:stopAfterConsecutiveProgressProtocolViolations(2) → SATURATED + PROGRESS_PROTOCOL_VIOLATED
一次合法评价(applyPreviousObservation 通过)清零
```
**为什么分开**:`NO_GAIN` 表示「Tool 执行了但没推进诊断」;协议错误表示「模型没守契约,Tool 根本没执行」。混淆会让真实空转无法归因(真实 E2E:9 次协议拒绝 + 13 轮模型调用空转)。
### 5.3 pending 协议(锚点)
```mermaid
flowchart LR
A["recordCompleted(EVIDENCE_FOUND)"] --> B["pendingToolCallId = toolCallId(挂账)"]
B --> C["模型下一轮回带 previous_observation"]
C --> D{"ID == pending ?"}
D -->|"是"| E["清 pending → applyGain"]
D -->|"否"| F["OUT_OF_ORDER 违规"]
```
- 只对**非空成功**结果设 pending;NO_EVIDENCE 不设(Harness 已自己判 NO_GAIN)。
- pending 是「唯一欠账」——保证协议逐轮、不乱序、不重评。
- 模型读完 observation 直接输出 Draft → pending 不消费也合法(不需要评价最后一轮)。
### 5.4 收集状态机
```mermaid
stateDiagram-v2
[*] --> COLLECTING
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
COLLECTING --> COLLECTING: NO_GAIN 未达阈值
COLLECTING --> SATURATED: NO_GAIN 达阈值 / 协议违规达阈值
SATURATED --> SATURATED: claimStopInstruction 交付一次(软停止)
SATURATED --> [*]: 模型再请求 Tool → DiagnosisCollectionStoppedException(硬停止)
```
**软/硬停止两段式**(易错点:stopReason 设置 ≠ 软停止):
```text
饱和瞬间 → stopReason 已设置(状态事实)
软停止 = 第一次交付 stop_required 观察(claimStopInstruction 返回 true,流程不中断)
→ 给模型合法输出 Draft 的机会
硬停止 = 模型无视指令再次请求 Tool → 入口检查 SATURATED && delivered
→ 抛 DiagnosisCollectionStoppedException 穿出框架 loop
```
### 5.5 为什么需要 Tracker(三层)
1. **progress 本身需要**:预算只止损,不能当正常停止策略;
2. **必须单一所有者**:「连续无增益」是跨轮次、跨入口(模型评价/代码判空/重复检测)的全局判断,分散记账会状态漂移;
3. **最小状态**:不存 payload(真相在 Canonical Store),避免第二份真相。
## 6. canonical 生命周期(tool 域衔接)
### 6.1 状态机
```mermaid
stateDiagram-v2
[不存在] --> PROJECTING: store.begin(preflight+预算通过后)
PROJECTING --> READY: store.markReady(backend 成功+投影成功)
PROJECTING --> ERROR: store.markError(任何异常)
note right of PROJECTING: 仅身份+request+startedAt
note right of READY: raw + agent_result + evidence + completedAt
note right of ERROR: raw + error_code + completedAt(禁 agent_result)
```
### 6.2 为什么分 begin/markReady/markError 三段
| 动机 | 说明 |
|---|---|
| 崩溃恢复 | backend 执行可能耗时数秒,一次写入会丢「已开始」的事实;begin 先留痕(PROJECTING = WAL) |
| 审计耗时 | 需要 startedAt(begin 记)和 completedAt(迁移记)算调用耗时 |
| 状态合法 | 每种状态只允许合法字段组合,由构造校验兜底 |
| 防篡改 | record 不可变,迁移生成新实例——READY 一旦写入不可改(证据可验真的前提) |
| 幂等 | begin 抛 `DuplicateInvocationException`(同 key 重复 begin 拒绝)→ `DUPLICATE_TOOL_CALL` |
**两类失败不落库**:requestBytes 超限、preflight 非法、重复 begin——都发生在 begin 之前,canonical 里**无记录**。
### 6.3 Store vs Tracker 分层
```text
Store(事实层):request / raw_response / agent_result / 状态 / 时间戳 —— 完整真相
Tracker(判断层):identity(引用)+ 双计数 + pending + 状态 —— 决策状态
NO_GAIN 计数不是「第二份真相」:它无法从 Store 重建
(模型评价是瞬时的、不落 Store),是独立增量状态
```
## 7. ToolBoundary 执行门禁
### 7.1 executeCanonical 五阶段
```mermaid
flowchart TD
A["阶段一:preflight + Tool 预算 + request bytes 预留 → begin(PROJECTING)"]
B["阶段二:executor.execute(requestJson)(backend raw)"]
C["阶段三:raw 大小校验 + Run bytes 预留"]
D["阶段四:projector.project(raw) → 有界 agent_result + evidenceStatus"]
E["阶段五:agent_result 校验 + Run bytes 预留 → markReady(READY)"]
A --> B --> C --> D --> E
```
**三笔 bytes 预留**:request(阶段一)/ raw(阶段三)/ agent_result(阶段五)——Run 的 bytes 预算分三个时间点消耗,任何一笔超限触发对应错误码。
### 7.2 失败路径错误码
| 失败点 | 是否已 begin | canonical | 错误码 |
|---|---|---|---|
| requestBytes 超限 / preflight 非法 / 重复 begin / Run 终态 | 否 | 无记录 | `RESULT_TOO_LARGE` / 分类错误码 / `DUPLICATE_TOOL_CALL` / `BUDGET_EXHAUSTED`·`RUN_INACTIVE` |
| executor 抛错 | 是 | ERROR | `TOOL_EXECUTION_ERROR` |
| raw 过大 / 预算 / 取消 | 是 | ERROR | `RESULT_TOO_LARGE` / `BUDGET_EXHAUSTED` / `RUN_INACTIVE` |
| 投影失败 | 是 | ERROR | `PROJECTION_ERROR` |
| agent_result 过大 / store 失败 | 是 | ERROR | `RESULT_TOO_LARGE` / `PROJECTION_ERROR` / `STORE_ERROR` |
### 7.3 checkActive 在哪
progress 拦截器流程里看不到显式 checkActive——它在 **ToolBoundary/core 层**隐式执行:`core.beforeToolCall` / `core.reserveRunBytes` 内部若 Run 已终态,抛 `RunAbortedException` → 映射为 `RUN_INACTIVE` 错误码。
## 8. 投影与发布闭环
### 8.1 停止后的数据流
```mermaid
flowchart LR
T["Tracker(identity 列表)"] --> P["DiagnosisProgressProjector"]
P --> S["CanonicalInvocationStore(回读 READY)"]
S --> P
P --> SN["ProgressSnapshot(verifiedSources + observedFacts + limitations + stopReason)"]
SN --> R["DiagnosisReleaseUseCase"]
R --> O["SUCCESS / FALLBACK(INSUFFICIENT_EVIDENCE 等)"]
```
### 8.2 Projector 有界投影
- 三重校验(`isReferencableBy` / toolCallId / toolName)通过才发布;无法验真/不可读/格式非法 → limitation,**绝不输出 raw**;
- 空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是「已检查」的证明;
- 硬截断:`MAX_FACTS=12`、summary/scope 320 字符、source 160 字符;
- 去重:`sourceKey = type + source + scope`,`factKey = sourceKey + summary`(LinkedHashMap 保序)。
### 8.3 Release 决策树
```mermaid
flowchart TD
EX["execute(execution)"] --> D1{"draft == null?"}
D1 -->|"是"| CS["releaseControlledStop<br/>需 hasObservedFacts → INSUFFICIENT_EVIDENCE"]
D1 -->|"否"| D2{"conclusion == null?"}
D2 -->|"是"| NC["releaseNoConclusion<br/>有事实 → INSUFFICIENT_EVIDENCE<br/>missing_info → MISSING_REQUIRED_CONTEXT<br/>都没有 → fail closed"]
D2 -->|"否"| C["releaseConclusion<br/>EvidenceGuard 验引用 → repair → SemanticGuard 裁决<br/>SUPPORTED ? SUCCESS : FALLBACK"]
```
## 9. 易错点清单
| 易错 | 正确 |
|---|---|
| EVIDENCE_FOUND 累计 NO_GAIN | **NO_EVIDENCE**(空结果)才立即累计;FOUND 挂 pending 等模型评价 |
| 重复调用计入协议违规 | 重复走 **NO_GAIN** 路径(recordDuplicateScope),不是违规 |
| 软停止 = 设置 stopReason | stopReason 饱和时就设了;软停止是**交付 stop_required 观察** |
| checkActive 应该在拦截器 | 在 ToolBoundary/core 层,`RUN_INACTIVE` 错误码 |
| 拦截器直接 invoke = 绕过设计 | 是**唯一执行通道**(证据工具 handler 必炸) |
| 数据都在 Tracker | 判断层在 Tracker,**事实层在 Canonical Store** |
| 判重对比裸入参 | 对比的是**规范化指纹**(字段顺序/格式/缺省值统一后) |
| 首次协议违规直接终止 | 首次返回**可修复反馈**,连续违规才饱和 |
## 10. 面试话术合集(30 秒)
### 10.1 双停止机制
> "Harness 有两套停止机制。预算是资源门禁,管『能不能花』;Progress 是收敛门禁,管『继续查有没有价值』。核心设计是:Tool 只提供客观结果,模型负责判断语义增益,但停止权归 Harness。信息增益故意只保留 GAINED/NO_GAIN 两值,靠模型在下次 Tool Call 里用 previous_observation 回传评价;连续两次 NO_GAIN 就进入饱和,交付一次 STOP_REQUIRED 给模型合法收尾的机会,再纠缠就抛受控异常穿出框架。"
### 10.2 为什么需要 Tracker(单一所有者)
> "ProgressTracker 是 Run 内收敛控制的单一决策者:它不存证据内容,只保存最小账——已完成调用的 identity、连续 NO_GAIN 和协议违规两条计数、一个待评价的 pending ID、以及 COLLECTING/SATURATED 状态。三路输入(模型评价、代码判空、重复检测)汇入计数,GAINED 清零、NO_GAIN 累加,达阈值进入饱和,交付一次停止指令。所有方法 synchronized,是并发安全的单一所有者。"
### 10.3 拦截器为什么自己执行(必炸路径)
> "Harness 的证据工具不是『拦截后放行』——拦截器对它们既是门卫又是执行者。证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为必炸路径,从结构上保证执行不可绕过门禁;校验(判重/协议/预算)和执行(ToolBoundary/canonical)内联在同一个流程里,保证『执行前判重、执行后记录』的时机原子性,且模型拿到的永远是有界观察而不是 raw。"
### 10.4 为什么 begin/markReady/markError 三段式
> "Tool 执行是跨门禁、backend IO、校验、投影多个不可靠阶段的流程,所以 canonical 记录用 begin → markReady/markError 的追加式状态机:begin 先落 PROJECTING 保证已开始的调用必有痕迹,backend 成功且投影成功才 markReady 定案为 READY,任何异常 markError 收尾;三个状态各校验合法字段组合,record 不可变保证一旦写入不可篡改——这样崩溃可恢复、审计可对账(startedAt/completedAt 算耗时)、证据可验真(READY 不可造假),重复 begin 还能幂等拦截。"
### 10.5 谁判空谁判价值
> "空不空是客观事实,代码可判,所以 evidenceStatus 归 Harness 的 Projector;有没有用是语义判断,代码判不了(一段通用知识可能看似相关实则无用),所以 information_gain 归模型。Harness 绝不把客观状态交给模型声明,模型也绝不替 Harness 做停止裁决——两层的信任边界是分开的。"
### 10.6 无状态 bean
> "Harness 的单例 bean(core/ToolBoundary/Adapter)全部无状态:字段只有构造注入的依赖和配置,创建后不变;所有可变状态外置到每个 Run 的 RunContext(预算计数、进度 Tracker、生命周期)和持久化存储里。方法一律以 context 参数显式传入,所以同一个 bean 实例能并发服务多个 Run 而不串账——状态在参数里,不在实例里。"
## 11. 代码位置索引
| 类 | 文件 |
|---|---|
| `DiagnosisProgressTracker` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressTracker.java` |
| `ToolScopeNormalizer` | `src/main/java/com/superbiz/agent/harness/progress/ToolScopeNormalizer.java` |
| `DiagnosisProgressProjector` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjector.java` |
| `DiagnosisProgressProjection` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjection.java` |
| progress 枚举/record/异常 | `src/main/java/com/superbiz/agent/harness/progress/`(其余 9 个文件) |
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
| `RagToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
| `ToolBoundary` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
| `ToolBoundaryResult` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundaryResult.java` |
| `CanonicalToolInvocation` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalToolInvocation.java` |
| `CanonicalInvocationStore` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java` |
| `DiagnosisReleaseUseCase` | `src/main/java/com/superbiz/agent/harness/release/DiagnosisReleaseUseCase.java` |
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
@@ -0,0 +1,382 @@
# Harness tool 域代码学习笔记:工具的注册、调用与执行链路
**更新日期**:2026-08-03
**主题**:tool 域 49 个文件的完整链路——装配 → 注册 → 调用 → 执行 → 返回,拆分阶段讲,最后合并
**设计视角**:[Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) §7 Tool
**代码视角**:[Harness progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(progress 域衔接,本笔记是 tool 域)
## 1. 定位:tool 域管什么
**职责**:工具如何安全执行、保存真相并只暴露必要内容。
| 问题 | 不解决会怎样 | 催生的层 |
|---|---|---|
| 每个 Adapter 自己写授权/预算/审计 → 漂移 | 三个工具三种行为 | **Boundary**(统一门禁) |
| raw 结果直接给模型 | 敏感数据泄露、超大响应、无结构 | **Projector**(有界投影) |
| 模型可能编造证据 | 结论无法验真、审计黑洞 | **Store**(canonical 真相) |
| MySQL 查询不可控 | 写库、删库、危险 SQL | **MySQL 沙箱**(只读红线) |
**49 文件分 6 组**:
| 组 | 数量 | 角色 |
|---|---|---|
| Contract | 18 | 跨层类型化语言(Call/Request/Result) |
| Boundary | 7 | 统一门禁(ToolBoundary + 配套) |
| Projection | 3 | raw → 有界 agent 契约 |
| Store | 9 | canonical 真相持久化 |
| Adapter | 3 | 接线(boundary + 后端 + 投影器) |
| MySQL 沙箱 | 9 | 只读执行 + fail-closed 校验 |
**核心设计**:几乎不用继承——用「接口 + 组合 + 函数式接口」三件套解耦。
---
## 2. 阶段一:装配(config → bean 注入链)
### 2.1 注入链
```mermaid
flowchart TD
subgraph 底层
R["RedisCanonicalInvocationStore"]
B["ToolBoundary"]
RP["RagResultProjector"]
LP["QueryLogsResultProjector"]
end
subgraph 中层
RA["RagToolAdapter"]
QA["QueryLogsToolAdapter"]
MA["MysqlToolAdapter"]
end
subgraph 顶层
ET["HarnessEvidenceTools"]
end
R --> B
B --> RA
B --> QA
B --> MA
RP --> RA
LP --> QA
ET --> RA
ET --> QA
ET --> MA
```
注入规律:所有 `@Bean` 方法参数 = 依赖注入点;**没有任何类 extends 别人**。
### 2.2 为什么不用继承
```
❌ 继承方案(没采用):
abstract class BaseToolAdapter { ... }
RagToolAdapter extends BaseToolAdapter { ... }
→ 加一个工具就得改基类,横切逻辑散落
✅ 组合方案(实际):
Adapter = ToolBoundary(门禁) + 后端(执行) + Projector(投影)
↑ 构造注入持有引用,不是继承
→ 每个 Adapter 独立组装,改一个不影响其他
```
组合的优势:
1. **ToolBoundary 对三种工具完全无感知**——只认 `ToolExecutor` / `ToolResultProjector` 两个端口,三个工具共用同一个实例;
2. **后端各不相同**(LookupKnowledgeTool / QueryLogsTools / JDBC),无法抽象成共同基类,用函数式接口适配;
3. **开闭原则**:加新工具 = 新写 Adapter + Projector + config 注册,**不动已有类**(门禁/真相/进度自动继承)。
---
## 3. 阶段二:注册(HarnessEvidenceTools 门面)
### 3.1 两个平行的注册表
```mermaid
flowchart LR
subgraph fromAdapters
RAG["ragAdapter::execute"]
LOGS["logsAdapter::execute"]
MYSQL["mysqlAdapter::execute"]
end
subgraph HarnessEvidenceTools
direction TB
CALL["callbacks(List)<br/>模型可见 Schema + 必炸"]
INV["invokers(Map)<br/>toolName → bridge 闭包"]
end
RAG -->|bridge| INV
LOGS -->|bridge| INV
MYSQL -->|bridge| INV
INV -.同一个工具名串起.-> CALL
CALL --> MODEL["模型(可见工具目录)"]
INV --> INTERCEPTOR["拦截器(执行入口)"]
```
**同一工具名字符串串起两个表**:模型从 callbacks 决定调 `lookup_knowledge` → 拦截器用同一个名字去 invokers 取执行器。
### 3.2 bridge:方法引用绑定实际调用者
```java
private static EvidenceToolInvoker bridge(String toolName, AdapterCall adapter) {
// lambda 闭包捕获 adapter 实例 + 固定的 toolName
return (context, toolCallId, arguments) -> adapter.execute(
context,
new ToolCallRequestEnvelope(
context.runId(), toolCallId, toolName, arguments, true, true));
}
```
关联链(三层绑定):
```
① config:new RagToolAdapter(boundary, mapper, projector, backend)
→ adapter 实例已组合好 boundary + projector + backend
② fromAdapters:ragAdapter::execute 是「绑定实例的方法引用」
→ bridge lambda 捕获它 —— invoker 与 Adapter 的关联在此固化
③ 构造方法:按工具名常量 put 进 invokers —— "lookup_knowledge" → 捕获了 ragAdapter 的 lambda
```
**invoker 与调用者的关联 = 方法引用绑定**:取出来直接 `adapter.execute(...)`,不需要再查表找调用者。
### 3.3 三个关键设计点
**① callbacks 的必炸保护**:
```java
FunctionToolCallback.builder(name, ignored -> {
throw new IllegalStateException(
"Harness evidence Tools require the framework Tool interceptor");
})
```
| 场景 | callback 行为 |
|---|---|
| 正常(拦截器接管) | 不执行(拦截器直接 evidenceTools.invoke) |
| 异常(某处 handler.call / 直接调) | **抛异常** → 暴露「绕过门禁」的 bug |
结构性保证:唯一能执行证据工具的路径 = 拦截器接管 → 门禁永远在线;绕过不可能静默成功(fail-fast)。
**② mysql 条件注册**:
```java
// config
boolean mysqlEnabled = 数据源配置了 jdbcUrl ? true : false;
return fromAdapters(rag, logs, mysqlEnabled ? mysql : null);
// fromAdapters
EvidenceToolInvoker mysql = mysqlAdapter == null ? null : bridge(QUERY_MYSQL, mysqlAdapter::execute);
// 构造方法里 null 也不注册 invokers / callbacks
```
没配数据源 → query_mysql 从模型视野和执行注册表**都消失**(不暴露「必死工具」)。RAG/日志后端内置,无条件注册。
**③ definition 的 typed 输入类**:
```java
definition(AgentToolContracts.LOOKUP_KNOWLEDGE, ..., RagToolCall.class)
```
输入类型 = 模型必须匹配的 Schema——`RagToolCall{previous_observation, input}`。parse 时用 `FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS` 强制匹配,**模型输出多一个字段都炸**(INVALID_ENVELOPE)。
---
## 4. 阶段三:调用(拦截器 → 注册表)
```mermaid
sequenceDiagram
participant M as 模型
participant F as 框架 ReactAgent
participant I as HarnessToolInterceptor
participant ET as HarnessEvidenceTools
M->>F: 决定调用 lookup_knowledge(输出 tool_call JSON)
F->>I: 回调 interceptToolCall(request, handler)
I->>I: supports(toolName) ? 注册检查
I->>ET: parse(toolName, arguments, mapper) → typed Envelope
ET-->>I: ParsedAgentToolCall(previous_observation + input)
I->>I: 协议校验 / 判重(不通过不执行)
I->>ET: invoke(context, toolName, toolCallId, args)
ET->>I: bridge lambda → adapter.execute
```
调用链要点:
| 点 | 说明 |
|---|---|
| **工具名是模型决定的** | `request.getToolName()` 来自模型输出,拦截器拿它查 invokers |
| **invoke 前有三道门** | supports 分流 → parse 严格契约 → 协议/判重——判重不通过不执行 |
| **envelope 现造** | bridge 里构造,`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」 |
| **工具名被闭包捕获** | 即使调用方传错名字,envelope 里也是正确的工具名(防混淆) |
---
## 5. 阶段四:执行(Adapter → ToolBoundary → 后端)
### 5.1 Adapter = 接线员
```mermaid
flowchart LR
AD["Adapter.execute"] -->|"boundary.execute(context, envelope,"| TB["ToolBoundary"]
AD -->|"executor = ignored -> legacyExecutor.execute(query)"| TB
AD -->|"projector = raw -> projector.project(...)"| TB
TB -->|"executor 跑 backend"| BK["具体后端<br/>LookupKnowledgeTool / QueryLogsTools / JDBC"]
TB -->|"projector 投影"| PR["RagResultProjector / QueryLogsResultProjector / MysqlResultProjector"]
TB -->|"markReady"| ST["CanonicalInvocationStore"]
```
**两个端口**:executor(跑 backend 拿 raw)+ projector(raw → 有界脱敏契约)。**模型永远看不到 raw**——这是执行链的核心目的。
### 5.2 LegacyExecutor vs 专用 Executor
| | RAG/日志 | MySQL |
|---|---|---|
| 后端来源 | 重构前旧类(LookupKnowledgeTool / QueryLogsTools) | 全新实现(JdbcMysqlReadOnlyExecutor) |
| 端口位置 | Adapter **内部**定义 LegacyExecutor | mysql **包**里定义 MysqlReadOnlyExecutor |
| 注入方式 | `backend::lookupKnowledge` 方法引用 / lambda | 直接注入专用实现 |
| 为什么 | 复用成熟旧代码 | 新工具直接面向沙箱设计 |
```
RAG: Adapter → LegacyExecutor(Adapter内部) → LookupKnowledgeTool(旧后端)
MySQL: Adapter → MysqlReadOnlyExecutor(mysql包) → JdbcMysqlReadOnlyExecutor(新实现)
```
### 5.3 ToolBoundary 五阶段(执行门禁)
```mermaid
flowchart TD
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
B["② executor.execute(requestJson) → backend raw"]
C["③ raw 大小校验 + Run bytes 预留"]
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
E["⑤ agent_result 校验 + bytes → markReady(READY)"]
A --> B --> C --> D --> E
```
**三笔 bytes 预留**:request(①)/ raw(③)/ agent_result(⑤)。
### 5.4 脱敏与有界(投影器)
- **日志**:sanitize 抹掉密码/token/主机/Pod/IP/PID/SQL 字面量;均匀采样 + 模式聚合;
- **MySQL**:敏感列(password/token/secret 等)单元格 → `[REDACTED]`;行数/字符/字节三重截断;
- **RAG**:chunk 级去重 + 摘录截断 + fitBudget 总字节兜底。
---
## 6. 阶段五:返回(双源校验 → 记账 → 有界观察)
```mermaid
sequenceDiagram
participant TB as ToolBoundary
participant I as HarnessToolInterceptor
participant P as DiagnosisProgressTracker
participant M as 模型
TB-->>I: ToolBoundaryResult(READY/ERROR)
I->>I: 双源交叉验证(声明值 vs 内容重算 evidence_status)
alt 不一致
I->>M: OBSERVATION_CONTRACT_MISMATCH(拒绝)
else 一致
I->>P: recordCompleted(call, evidenceStatus)
P-->>I: 快照(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
I->>I: modelObservation 加工(含 stop_required / stopReason)
I-->>M: 有界 observation(模型永远看不到 raw)
end
```
**错误处理三种形态**:
| 场景 | 处理 |
|---|---|
| Adapter 业务/参数异常 | catch → `INVALID_REQUEST`(不泄露内部细节) |
| MySQL 安全异常 | `MysqlSecurityException` 单独 catch → `INVALID_REQUEST` |
| 日志后端缺失 | `ObjectProvider.getIfAvailable()` → 返回空结果 JSON(不炸) |
---
## 7. 全链路合起来
```mermaid
sequenceDiagram
participant C as config(启动)
participant ET as HarnessEvidenceTools(单例)
participant I as 拦截器(per-Run)
participant AD as Adapter(单例)
participant TB as ToolBoundary(单例)
participant ST as CanonicalStore(单例)
participant M as 模型
rect rgb(240, 248, 255)
Note over C,ET: ① 装配(应用启动一次)
C->>C: 建 boundary / 3 个 Adapter(注入 boundary+后端+投影器)
C->>ET: fromAdapters(rag, logs, mysql?)
ET->>ET: bridge → invokers + definition → callbacks(平行,同名串起)
end
rect rgb(255, 250, 240)
Note over M,AD: ② 调用+执行(每次 Tool Call)
M->>I: 模型决定工具名 → 框架回调拦截器
I->>I: supports 分流 → parse(typed 严格契约)→ 协议/判重
I->>ET: invoke → invokers.get(名字) → bridge 闭包
ET->>AD: adapter.execute(context, envelope[现造,授权只读写死])
AD->>TB: boundary.execute(context, envelope, executor, projector)
TB->>ST: begin(PROJECTING) → executor 跑 raw → projector 投影 → markReady(READY)
TB-->>I: ToolBoundaryResult
I->>I: 双源校验 → recordCompleted → modelObservation
I-->>M: 有界 observation(无 raw)
end
```
**四阶段汇总**:
| 阶段 | 做什么 | 关键类 |
|---|---|---|
| 装配 | Spring 组合依赖(无继承) | config / Adapter / ToolBoundary |
| 注册 | bridge 成 invoker + definition 成 callback(平行同名串起) | HarnessEvidenceTools |
| 调用 | supports → parse 严格契约 → 协议/判重 → invoke | Interceptor / HarnessEvidenceTools |
| 执行+返回 | boundary 五阶段 → 投影脱敏 → canonical → 双源校验 → 记账 → 有界观察 | Adapter / ToolBoundary / Projector / Store |
---
## 8. 易错点
| 易错 | 正确 |
|---|---|
| 必炸 = 死工具 | 必炸是**保护**:模型通过拦截器正常执行,只有绕过路径才炸 |
| LegacyExecutor 是通用 executor | 它只服务于「复用旧后端」;新工具直接注入专用 Executor(如 MysqlReadOnlyExecutor) |
| callbacks 是执行器 | 它是「模型可见目录」+ 必炸占位;真执行走 invokers |
| 执行链只有 executor | 还有 **projector**(raw → 有界契约)——模型永远看不到 raw |
| MySQL 没有 tool 类 | `JdbcMysqlReadOnlyExecutor` 就是它的后端执行类,只是不叫 Tool |
| 工具注册是静态列表 | **配置驱动**:没配数据源 → query_mysql 从两表消失 |
| 返回就是 ToolBoundaryResult | 返回后还有双源校验 → recordCompleted → modelObservation |
## 9. 面试话术合集(30 秒)
### 9.1 为什么不用继承
> "tool 域刻意不用继承:ToolBoundary 通过 ToolExecutor/ToolResultProjector 两个函数式端口接收执行和投影逻辑,三个 Adapter 各自用构造注入组合 boundary + 后端 + projector,HarnessEvidenceTools 再用 bridge 把 Adapter 包成统一的 EvidenceToolInvoker 注册表。类图里没有 extends 箭头——全是 has-a(组合)和函数适配(函数式接口),扩展新工具不改任何已有类。"
### 9.2 为什么必炸保护
> "必炸保护是执行不可绕过的结构性保证:证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为死路。唯一能执行证据工具的路径就是拦截器接管——预算、canonical、进度协议、脱敏门禁永远在线;任何绕过尝试要么抛异常暴露 bug(fail-fast),要么根本不执行(fail-closed)。非证据工具不需要门禁,所以拦截器放行、callback 正常。"
### 9.3 LegacyExecutor 是什么
> "LegacyExecutor 是 Adapter 内部定义的旧后端端口:RAG 和日志是重构前就有的工具,后端实现(backend::lookupKnowledge)被方法引用注入复用,通过 Adapter 包进 Harness 门禁——『旧后端复用,新门禁外挂』。它和 ToolBoundary 的 ToolExecutor 是两层:LegacyExecutor 是具体后端怎么查,ToolExecutor 是边界统一端口,Adapter 把前者包成后者。MySQL 是全新工具,没有 legacy,直接用新写的 MysqlReadOnlyExecutor。"
### 9.4 模型为什么看不到 raw
> "执行链是两个端口:executor 跑 backend 拿 raw,projector 把 raw 投影成有界脱敏契约(截断 + 脱敏 + 冻结 Schema)。ToolBoundary 只让 READY/ERROR 离开,模型拿到的是 modelObservation 加工后的有界观察——raw 只进 canonical Store 供审计和验真,永远不进入模型上下文。"
### 9.5 新工具怎么加(开闭原则)
> "新工具按 MySQL 模板:写 Contract 三件套 + 专用执行器 + 投影器 + Adapter,config 注册。要改的只有 AgentToolContracts 常量、HarnessEvidenceTools 构造、config;不用改 ToolBoundary、canonical、拦截器——新工具自动获得预算门禁、真相记录、脱敏投影、进度收敛、证据验真全套管控。"
## 10. 代码位置索引
| 类 | 文件 |
|---|---|
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java`(agent 包,tool 域门面) |
| `RagToolAdapter` / `QueryLogsToolAdapter` / `MysqlToolAdapter` | `.../tool/adapter/` |
| `ToolBoundary` / `ToolBoundaryResult` / `ToolCallRequestEnvelope` / `ToolExecutor` / `ToolResultProjector` / `ProjectedToolResult` | `.../tool/boundary/` |
| `RagResultProjector` / `QueryLogsResultProjector` / `ToolProjectionLimits` | `.../tool/projection/` |
| `CanonicalInvocationStore` / `RedisCanonicalInvocationStore` / `CanonicalToolInvocation` / `ToolCallKeyFactory` / `CanonicalInvocationLimits` | `.../tool/store/` |
| Contract 18 个 | `.../tool/contract/` |
| `MysqlSqlValidator` / `JdbcMysqlReadOnlyExecutor` / `MysqlResultProjector` / `MysqlDataSourceDefinition` 等 | `.../tool/mysql/` |
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
@@ -0,0 +1,223 @@
# Harness 整体架构学习笔记:从装配到入口到记忆到知识库写入
**更新日期**:2026-08-04
**主题**:整体架构五块补充(了解层面)——配置装配中心 / HTTP 入口层 / 会话系统与记忆体系 / 知识库写入链路
**配套**:九域主线笔记(core/retry/progress/tool/guard/release/agent/audit/contract 全 ✅)
## 1. 配置装配中心(整体怎么搭起来)
### 1.1 装配全景(Bean 拓扑)
```mermaid
flowchart LR
C["ChatHarnessProperties<br/>配置集中(yml)"] --> K["DiagnosisHarnessCore<br/>总闸门:超时/预算/重试/收敛"]
K --> B["ToolBoundary<br/>工具底座:canonical/门禁/投影"]
B --> A1["RagToolAdapter"]
B --> A2["QueryLogsToolAdapter"]
B --> A3["MysqlToolAdapter<br/>(可选装配)"]
A1 --> E["HarnessEvidenceTools<br/>组装注册"]
A2 --> E
A3 --> E
K --> G["GuardModelCall<br/>(守卫/修复/路由共用底座)"]
G --> SG["SemanticGuard"]
G --> ER["EvidenceRepair"]
G --> R["IntentRouter"]
E --> F["DiagnosisAgentFactory"]
F --> UC["DiagnosisAgentUseCase"]
UC --> D["DiagnosisChatExecutor"]
R --> D
R --> S["SystemChatExecutor"]
R --> KQ["KnowledgeQueryExecutor"]
D --> A["ChatApplicationUseCase<br/>应用入口"]
S --> A
KQ --> A
```
### 1.2 三层组织(话术版)
```text
① 总闸门(core):能花多少钱/跑多久/怎么重试/何时停——配置集中,改一处全局生效
② 工具底座:所有工具统一留痕(canonical)/拦截(ToolBoundary)/裁剪(投影)——行为整齐划一
③ 具体工具:RAG/Logs/MySQL 按需装配(没配数据源不装死工具)→ 组装注册给 Agent
串联:先判意图(路由)→ 走对应分支 → 全程在总闸门管辖下
一句话:边界集中、执行统一、工具可插拔
```
### 1.3 关键设计点
| 设计 | 为什么 |
|---|---|
| 单一装配入口(HarnessChatConfiguration) | 读 Bean 签名 = 读架构拓扑 |
| 一切围绕 core | 所有链路共享同一套门禁(超时/预算/重试/收敛) |
| 配置属性集中(@EnableConfigurationProperties) | 一处改全局生效,不会有的环节漏管 |
| 工具可选装配(mysqlEnabled 判断) | 没配置不装死工具;ObjectProvider 可选后端 |
| 守卫/修复/路由共用 GuardModelCall | LLM judge 模式:同一轻量模型底座 |
| Redis 存 canonical | 跨实例共享 + TTL 过期 |
| 线程池 AbortPolicy | 队列满直接拒绝(fail fast) |
## 2. HTTP 入口层(薄协议适配)
### 2.1 请求流时序
```text
POST /api/chat {Id, Question}
→ 校验 → new SseEmitter + ChatSseSession(= ChatApplicationObserver)
→ chatWorkerExecutor.execute(...) ← 异步:HTTP 线程不跑模型
→ 立即返回 200 + TEXT_EVENT_STREAM
→ worker 线程执行编排,经 session 推事件
→ 队列满 → 503(RejectedExecutionException)
```
### 2.2 SSE 状态机 + 五类事件
```text
状态机:NEW → OPEN → TERMINAL(收尾)/ DISCONNECTED(断连)
每个方法 requireState 校验顺序——防乱序推送
事件协议:
metadata → {session_id, run_id}(首推)
status → 编排进度(ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING…)
content → 最终内容(content_type + payload)
failure → 失败码 + 消息
done → 终态(SUCCESS/FALLBACK/FAILED)★ CANCELLED 对外不可见
```
### 2.3 断连取消链路(贯穿到 Harness)
```text
客户端断开 → emitter.onTimeout/onError/onCompletion → session.disconnect()
→ 状态 DISCONNECTED → runControl.cancelClientDisconnect()
→ Harness 取消机制接管(checkActive / 拦截器 / 线程池 cancel)
onStarted 时若已断连:直接取消——不白跑
```
### 2.4 失败两层出口
```text
SSE 通道:ChatApplicationException → session.fail(failure + done(FAILED))
其他 RuntimeException → INTERNAL_FAILURE 通用信息(不泄露细节)
REST 通道:GlobalExceptionHandler → 404(SessionNotFound)/ 400(参数/文档/文件超限)/ 500(兜底)
→ 编排异常走 SSE failure,REST 异常走 HTTP 状态码——都不暴露内部细节
```
## 3. 会话系统与记忆体系(术语精确校准)
### 3.1 会话存储:不存历史,存「可重放的发布结果」
```text
ChatSession(chat_session 表):只存元数据(status/messagePairCount/时间戳)
——「message history is not persisted here」
DiagnosisSession:诊断快照(query/answer/selfEvaluation/feedback)
真正的历史:DiagnosisRun(每次运行一行)+ PublishedResult(JSON 落库)
```
### 3.2 PreviousTurn 注入链路(短期记忆)
```text
ChatApplicationUseCase 开头读 findPreviousTurn(sessionId)
→ 查最近 SUCCESS+DIAGNOSIS+publishedResult 非空的 Run
→ 反序列化 PublishedResult → PublishedResultPolicy 生成【有界】摘要
(limitations 10 条×500 字 / 源文档 10 个 / 字段限长——有界在生成时)
→ 传 executePath → DiagnosisChatExecutor
→ new DiagnosisAgentInput(query, previous_turn)
→ 序列化成输入 JSON → agent.call(inputJson) → 模型从输入读到
设计三决策(话术版):
诊断短流程 → 只取上一轮(更早记忆靠多轮逐层传递)
非 SUCCESS 误导 → SUCCESS 才准入(且非 SUCCESS 轮次根本没写 PublishedResult)
token 爆炸 → 有界摘要(PreviousTurnLimits)
补充:可回放——模型看有界摘要,审计看全量 JSON(两层分离)
```
### 3.3 记忆术语校准(重要认知)
```text
判定标准:记忆 = 会被【注入 prompt/上下文】的东西(不是存了什么)
PreviousTurn → 注入输入 JSON(user message)→ ✅ 短期记忆(当前会话)
lookup_knowledge → 工具调用动态获取(tool result)→ ❌ 记忆,是检索增强(RAG)
知识库 → 检索源,规模太大无法全量注入 → 归检索侧(工具检索是正确形态)
案例库 → 有结构有写入、缺检索注入 → 长期记忆的【候选原料】
运行档案 → 元数据层永不注入 → 审计数据
项目真实情况:只有短期记忆(PreviousTurn)+ 检索增强(RAG),【没有】长期记忆层
```
### 3.4 长期记忆设计路径(如果要做)
```text
筛选标准:规模可控 + 跨会话价值 + 可注入形态
案例库最符合:root_cause+solution 结构化摘要、注入 top 2-3 条、相似故障复用解法
PreviousTurn 扩展:最近 N 轮结论摘要(短期 → 中期记忆)
Feedback 偏好:用户偏好摘要
知识库不符合:全量太大 → 保持工具检索
案例 → skill 提炼(项目已实现):
6 个 SKILL.md(diagnose-mysql-connection-pool 等)
结构:Workflow / Required Evidence / Stop Conditions / Report Rules / Eval Anchor
注入:ClasspathSkillRegistry → SystemPromptTemplate → system prompt(程序性长期记忆)
情景记忆(案例)→ 程序记忆(skill)→ 常驻注入 ✅ 长期记忆的正确形态
现有缺口:单技能激活(只放行 1 个)/ 静态加载(无按 query 自动匹配)/ skill 与案例库断开
```
## 4. 知识库写入链路(RAG 写半边)
```text
上传(/api/documents/upload)
→ TextExtractorService(文本提取)
→ DocumentChunkService.chunkDocument(分块)★
→ VectorEmbeddingService(dense embedding)
→ VectorIndexService.indexDocumentChunks(写 Milvus)★
→ 检索侧(lookup_knowledge)读同一份索引
```
### 关键设计
```text
① 分块:按章节分块(非定长硬切)+ 相邻 chunk 保留 overlap(减轻边界断裂)
双条件限制:maxSize(字符)+ maxTokens(token)
② hybrid 写入:dense(应用侧 embedding → vector 字段)
+ BM25(buildSearchText → search_text 字段)
★ 关键:dense embedding 输入 与 BM25 search_text 【同源】——
同一个「增强文本」既喂 embedding 又写 BM25 字段
→ 两路召回看到完全一致的文档内容,混合检索才公平
③ 弃用:legacy MilvusServiceClient(旧 SDK)/ Spring AI VectorStore#add(无 hybrid schema)
唯一后端:MilvusHybridKnowledgeStore(Milvus SDK v2)
```
## 5. 易错点
| 易错 | 正确 |
|---|---|
| Controller 做业务编排 | 薄适配层:校验+开 SSE+异步+写回,编排在 Application |
| SSE 顺序不重要 | requireState 状态机校验——防乱序推送 |
| 取消对外可见 | Done 拒绝 CANCELLED——客户端只看到 SUCCESS/FALLBACK/FAILED |
| 知识库 = 长期记忆 | 是检索源(工具动态获取);记忆 = prompt 注入——项目无长期记忆层 |
| 案例 = 长期记忆 | 是候选原料——缺检索注入;skill 才是程序性长期记忆(已实现) |
| 工具预算在工具层 | maxToolCalls/收敛参数都在 core(总闸门) |
| 分块定长硬切 | 按章节 + overlap + 双条件限制 |
## 6. 面试话术(30 秒)
### 6.1 整体架构怎么组织
> "整个系统从下往上三层:最底层一套全局规则(超时/预算/重试/收敛,配置集中改一处全局生效);中间一层工具共用的底座(调用留痕、统一拦截、结果裁剪);上层按需装配具体工具(知识库/日志/数据库,配了才装)。最后串成应用入口——先判意图再走分支,全程在总闸门管辖下。一句话:边界集中、执行统一、工具可插拔。"
### 6.2 记忆体系
> "记忆的判定标准是会不会被注入上下文:PreviousTurn 是短期记忆(注入输入 JSON,上一轮 SUCCESS 的有界摘要);知识库是检索增强不是记忆(工具动态获取);项目没有长期记忆层——skill(案例提炼的诊断方法)注入 system prompt 是程序性长期记忆的正确形态;案例库是候选原料,缺检索注入。"
## 7. 代码位置索引
| 块 | 文件 |
|---|---|
| 装配中心 | `config/HarnessChatConfiguration.java`(+ `config/ChatHarnessProperties.java`) |
| HTTP 入口 | `controller/ChatController.java` + `controller/sse/ChatSseSession.java` / `ChatSseEvent.java` / `SseEmitterChatSink.java` |
| 异常映射 | `exception/GlobalExceptionHandler.java` |
| 会话存储 | `harness/application/persistence/JpaChatRunStore.java` / `PreviousTurnLimits.java` / `PublishedResultPolicy.java` |
| 记忆注入 | `harness/application/ChatApplicationUseCase.java` + `harness/application/executor/DiagnosisChatExecutor.java` + `harness/agent/DiagnosisAgentUseCase.java` |
| skill 机制 | `config/SkillConfig.java` + `src/main/resources/skills/*/SKILL.md` |
| 知识库写入 | `service/DocumentChunkService.java` / `VectorIndexService.java` / `VectorEmbeddingService.java` / `KnowledgeBaseInitService.java` + `controller/DocumentController.java` / `KnowledgeBaseController.java` |
@@ -0,0 +1,164 @@
# Harness 证据安全链学习笔记:从收敛控制到唯一发布点
**更新日期**:2026-08-06
**主题**:progress → guard → release 三域联动——双通道验证架构 + 状态流转全景 + 关键字段来源与使用
**配套**:[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)、[Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
## 1. 一句话定位
**证据安全链 = progress(执行期收敛控制)→ guard(验证)→ release(唯一发布点)**:
任何对外发布的内容,必须能追溯到 canonical 账本的已验证事实;任何无法证明的内容,只能以有界、诚实的 SafeFallback 降级形态出现。
## 2. 主链路图
```mermaid
flowchart LR
subgraph 执行期["Agent 执行期(core 控资源 + progress 控收敛)"]
TC["HarnessToolInterceptor<br/>工具调用完成"]
TC -->|"完整记录"| CS["Canonical Store<br/>(唯一真相源, TTL 2h)"]
TC -->|"identity"| TR["Progress Tracker<br/>(toolCallId+toolName+scope)"]
end
subgraph 停止["Agent 结束(所有出口触发投影)"]
TR -->|"回读验真"| PP["DiagnosisProgressProjector"]
PP --> PS["ProgressSnapshot<br/>(observedFacts+limitations+stopReason)"]
end
subgraph 裁决["Release 裁决(唯一发布点)"]
EX["DiagnosisAgentExecution<br/>(draft / stopped)"]
EX --> RC["releaseConclusion<br/>(有结论)"]
RC -->|"tool_call_ids"| EG["EvidenceGuard<br/>机械验引用"]
EG -->|"失败"| ER["EvidenceRepair<br/>只修引用→复查"]
EG -->|"通过"| VES["VerifiedEvidenceSnapshot"]
VES --> SG["SemanticGuard<br/>判支持度"]
SG -->|"SUPPORTED"| SUCCESS["SUCCESS<br/>(唯一出口)"]
SG -->|"UNSUPPORTED/不可用"| FB["SafeFallback 降级"]
EG -->|"仍失败"| FB
EX -->|"stopped / 无结论"| FB
PS -->|"降级原料"| FB
end
CS -.->|"回读"| EG
CS -.->|"回读"| PP
```
## 3. 双通道验证架构(核心)
两条并行的「账本背书」通道,合起来覆盖所有结局:
| | progress 快照 | guard 快照 |
|---|---|---|
| 类 | `DiagnosisProgressSnapshot` | `VerifiedEvidenceSnapshot` |
| 组装时机 | agent 结束那一刻(所有出口) | release 验引用通过后 |
| 原料 | Tracker 的调用 identity | draft 里模型写的 tool_call_ids |
| 回读者 | `DiagnosisProgressProjector` | `EvidenceGuard` |
| 需要 draft | **不需要** | **必须** |
| 服务谁 | 受控停止/无结论/非法 draft 降级 | 有结论 draft 证据链 + SemanticGuard |
| 共同点 | 都从 canonical 回读、三重校验、去重有界、绝不输出 raw | 同左 |
**设计意义**:agent 没给出合法 draft(预算打断、输出烂)时,靠 progress 快照降级;给出合法 draft 时,靠 guard 快照支撑。**任何情况下对外发布都有据可依。**
## 4. 状态流转全景(技术终态 vs 用户终态)
### 4.1 六层状态(从内到外)
| 层 | 类型 | 值 | 回答的问题 |
|---|---|---|---|
| core 生命周期 | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许继续执行 |
| progress 收集停止 | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED | 证据收集为何受控停止 |
| release 发布裁决 | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局是什么 |
| release 降级细分 | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | FALLBACK 为什么降级 |
| SSE 进度 | `ChatApplicationStatus` | ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING ... | 当前走到哪一阶段(不是结局) |
| 对外失败码 | `ChatFailureCode` | RUN_CANCELLED / INTERNAL_FAILURE / ... | 失败时给客户端的粗粒度原因 |
### 4.2 关键映射规则(代码事实)
```text
RunState.BUDGET_EXHAUSTED + progress.hasObservedFacts()==true
→ ReleaseOutcome.FALLBACK(FallbackType.INSUFFICIENT_EVIDENCE)
RunState.BUDGET_EXHAUSTED + 无 facts
→ FAILED(fail closed:没有安全内容可发布)
RunState.CANCELLED → ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
内部失败(completeFailure)→ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
guard 全过 → RunState.SUCCESS + ReleaseOutcome.SUCCESS(唯一出口)
预算耗尽且子路径已产出 FALLBACK → 不二次 completeSuccess
```
### 4.3 两个正交维度的区分(高频面试点)
- **RunState** 回答「Run 技术上是否还在跑、因何技术终态停下」;
- **ReleaseOutcome** 回答「用户侧内容形态:正常报告 / 安全降级 / 失败 / 取消」;
- 常见组合:`RunState=BUDGET_EXHAUSTED` 且有安全进展 → `ReleaseOutcome=FALLBACK`;无进展 → `FAILED`。
### 4.4 终态异常透传
`DiagnosisReleaseUseCase.propagateTerminal`:`RunAbortedException` / `BudgetExceededException` / `RetryFailure.CANCELLED|BUDGET_EXHAUSTED` **原样上抛**,不吞、不伪装成业务 FALLBACK——取消和预算耗尽是 Run 的技术终态事实,用户侧必须知道「被取消了」而不是「诊断结论是不足」。
## 5. 关键字段的来源与使用
### 5.1 CanonicalToolInvocation(唯一真相源,执行时落库)
| 字段 | 来源 | 使用 |
|---|---|---|
| tool_call_id + run_id | ToolBoundary 执行时生成 | key = runId + toolCallId(两个投影器都按它回读) |
| request / raw_response | 工具请求与原始返回 | 审计/验真,不外发 |
| agent_result | Projector 投影后的有界结果 | Agent 可见的唯一形态;EvidenceGuard 重读它 |
| status | PROJECTING → READY / ERROR(单向迁移) | isReferencableBy 要求 READY |
| evidence_status | FOUND / NO_EVIDENCE / ERROR | kind.accepts() 匹配、投影一致性校验 |
| error_code | 仅 ERROR 携带 | 稳定错误码 |
| started_at / completed_at | 生命周期时间戳 | TTL / 审计 |
### 5.2 DiagnosisProgressSnapshot(progress 快照,agent 结束时组装)
| 字段 | 来源 | 使用 |
|---|---|---|
| verifiedSources | Projector 回读 canonical,去重 | 降级时展示「查过哪些来源」 |
| observedFacts | 同上(≤12 条,摘要 320 字,空查询也算) | 「查了查到什么」;`hasObservedFacts()` 安全阀 |
| limitations | 无法验真/截断的诚实说明 | 降级时展示限制 |
| stopReason | Tracker 状态 | release 检查白名单后决定降级 |
### 5.3 VerifiedEvidenceSnapshot(guard 快照,验真通过后组装)
| 字段 | 来源 | 使用 |
|---|---|---|
| analyses[] | EvidenceGuard 重读 canonical agent_result 重建 | SemanticGuard.review 输入 |
| verifiedSources() | 方法去重(sourceType+source+scope) | SUCCESS 的 published_result.source_documents、FALLBACK 的来源 |
### 5.4 SafeFallback(降级载荷,release 降级出口构造)
| 字段 | 来源 | 使用 |
|---|---|---|
| type | SafeFallbackFactory 按场景 | 用户/审计区分降级原因 |
| conclusion | 恒 null | 降级绝不发布根因结论 |
| verified_sources / observed_facts | progress 或 guard 快照投影 | 保留已验证事实供用户继续排查 |
| limitations / next_steps | 工厂按场景拼装 | 诚实说明 + 下一步 |
| failure_stage | DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION | 定位失败阶段 |
| validation_issues | EvidenceViolation 映射 | EVIDENCE_VALIDATION_FAILED 时的违规明细 |
## 6. 设计要点(贯穿全链的规律)
1. **fail closed 贯穿每一层**:guard 验引用默认拒绝、读投影严格反序列化、release 无 facts 抛异常、SafeFallback 无事实拒绝构造——「不确定 → 默认拒绝,没查到永不伪装成结果」。
2. **负向证据被完整建模**:progress 空查询投影成事实、NEGATIVE_OBSERVATION 配 NO_EVIDENCE、降级诚实声明「查到了但不够」。
3. **canonical 唯一真相源**:链上不存在第二份工具真相;两个投影器共用同一套三重校验。
4. **全链路可审计回放**:EVIDENCE_GUARD_INITIAL/RECHECK、SEMANTIC_ATTEMPT/DECISION、EVIDENCE_REPAIR_ATTEMPT、RELEASE_DECISION 全落 trace。
5. **语义不变性**:EvidenceRepair 只修引用不修结论(prompt 锁死 + hasSameUserVisibleSemantics 校验)。
6. **命名债务**:`FallbackType.BUDGET_EXHAUSTED` 枚举保留,但预算停实际发布 `INSUFFICIENT_EVIDENCE`(注释自认)。
7. **重复验证**:同一 tool_call_id 被多条 analysis 引用会重复验 N 次(引用级独立校验的代价,账本查询便宜可接受)。
## 7. 面试话术(30 秒)
> "Harness 的证据安全链是 progress → guard → release 三段联动。**执行期**:progress 管信息增益收敛(预算归 core),工具调用实时落 canonical 账本、Tracker 只记 identity;**验证期**:agent 结束后 release 编排——有结论的 draft 先进 EvidenceGuard 机械验引用(每个 tool_call_id 从账本回读、READY 且 kind 匹配,失败则 EvidenceRepair 只修引用再验),通过后 SemanticGuard 判结论是否被证据支持;**发布期**:SUPPORTED 是唯一 SUCCESS 出口,其余全部经 SafeFallbackFactory 构造有界诚实的降级。关键设计是**双通道**:没有合法 draft 时靠 progress 快照(observedFacts)降级,有 draft 时靠 guard 快照支撑——任何情况对外发布都有据可依;以及**状态正交**:RunState 回答技术终态(预算耗尽/取消),ReleaseOutcome 回答用户结局(降级/失败),取消和预算耗尽经 propagateTerminal 原样上抛,绝不伪装成业务降级。"
## 8. 代码位置索引
| 组件 | 文件 |
|---|---|
| EvidenceGuard / EvidenceViolationCode | `src/main/java/com/superbiz/agent/harness/guard/evidence/` |
| SemanticGuard / GuardModelCall | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `src/main/java/com/superbiz/agent/harness/release/` |
| DiagnosisProgressProjector / Tracker / Snapshot | `src/main/java/com/superbiz/agent/harness/progress/` |
| CanonicalToolInvocation / Store | `src/main/java/com/superbiz/agent/harness/tool/store/` |
| RunState | `src/main/java/com/superbiz/agent/harness/core/RunState.java` |
| DiagnosisStopReason | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisStopReason.java` |
| ReleaseOutcome / FallbackType / SafeFallback | `src/main/java/com/superbiz/agent/harness/contract/` |
| ChatApplicationStatus / ChatFailureCode | `src/main/java/com/superbiz/agent/harness/application/` |
@@ -0,0 +1,307 @@
# Harness 面试复习笔记:五步复习与白板图沉淀(详细版)
**更新日期**:2026-08-06
**主题**:面试总复习成果固化——30 秒电梯陈述 / 三张白板图 / 九域五段式面试讲法 / 六个易错点 / 追问应对大全 / 支付超时案例
**配套**:[面试速查](Harness面试速查-一张图讲清设计.md)、[设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md)、各域学习笔记
## 1. 五步复习路径
```text
① 30 秒电梯陈述 + 一张图
② 默画三张白板图(主链路 / 职责迁移 / 数据三层)
③ 六个易错点
④ 2 分钟真实案例(支付超时)
⑤ 每域面试话术背诵(九域五段式)
```
## 2. 30 秒电梯陈述(详细版)
### 2.1 一句话版本
> "Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。"
### 2.2 逐句展开(面试官追问「具体怎么做」时用)
```text
「确定性控制边界」展开为三层边界:
运行边界:RunContext(身份/截止/预算/取消)+ 唯一终态(first-terminal-wins)
事实边界:ToolBoundary(权限/只读/容量)+ canonical 真相 + 有界观察
发布边界:EvidenceGuard 验引用 → SemanticGuard 判支持度 → Release 唯一出口
```
### 2.3 三个重点(背的时候盯住)
```text
Agent 负责业务推理(出草稿,不是出报告)
Harness 负责确定性约束(真相与观察分离)
Release 决定什么可以公开(引用真实与结论支持是两个独立门禁)
```
### 2.4 不要一开始列 10 个职责域
先给一句定义 + 三句话,面试官追问「具体怎么做」再沿三张白板图展开。
## 3. 三张白板图(详细版)
### 3.1 图一:主链路(含分支,不是单轮)
```text
chat 接口
→ 创建 RunContext(core.startRun:runId/deadline/budget/cancel/lifecycle)
→ 意图识别(IntentRouter,单次模型调用,输出契约恰好 {intent} 枚举)
→ 诊断 Agent(ReAct 多轮循环)
├─ agent 决策 → ToolBoundary
│ ├─ preflight(run 匹配/授权/只读/JSON/key)→ 失败不落库
│ ├─ 预算门禁(beforeToolCall + bytes 三笔预留)
│ ├─ canonical 状态机(begin PROJECTING → READY/ERROR)
│ ├─ 执行 + Projector 投影(严格校验 + 脱敏 + 截断)
│ └─ 有界观察返回 Agent(模型永远看不到 raw)
├─ progress 判 GAINED/NO_GAIN(连续 NO_GAIN → 饱和停止)
└─ ↺ 循环直到:出草稿 或 受控停止(预算/饱和/协议违规)
→ 分支 A(有结论草稿):
EvidenceGuard 验引用(key=runId+toolCallId 查账本,READY + kind 匹配)
├─ 失败 → EvidenceRepair 只修引用(prompt 锁死 + 语义不变性)→ 复查
│ └─ 仍失败 → SafeFallback(EVIDENCE_VALIDATION_FAILED)
└─ 通过 → SemanticGuard 判支持度(隔离守卫模型)
├─ SUPPORTED → Release → SUCCESS(唯一出口)
└─ UNSUPPORTED/不可用 → SafeFallback(SEMANTIC_UNSUPPORTED/UNAVAILABLE)
→ 分支 B(无草稿/无结论):Release 凭 progress 快照 → SafeFallback(INSUFFICIENT_EVIDENCE 等)
└─ 无安全进展 → fail closed 抛异常 → FAILED
```
**讲解要点**(讲主链路时抓四个时间点):
1. Agent 运行前先建立 Run 边界;
2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定;
3. Agent 只看到有界观察,完整事实由系统独立保管;
4. Draft 必须经过唯一发布出口,不能直接发送给用户。
**三个词记忆**:循环(多轮 ReAct + 收敛)/ 分支(验真失败有修复、无草稿也能降级)/ 分离(真相在 canonical,Agent 只见有界观察)。
### 3.2 图二:职责迁移(推理合并,权力拆分)
**为什么迁移**:早期多 Agent(Planner/Executor/Verifier/Composer)加上 Gatekeeper、StateGraph,代价是四套 Prompt/JSON/上下文策略。后来发现四个角色**加起来正好是一次完整 ReAct**:
```text
思考 → Planner
行动观察 → Executor + Tool
自我检查 → Verifier
最终回答 → Composer
```
外层在重复实现框架已有的循环。于是收敛成单个 React Agent + 控制面拆分:
| 早期角色 | 干什么 | 现在迁移到哪 |
|---|---|---|
| Planner | 制定排查计划 | Diagnosis Agent(ReAct 思考) |
| Executor | 调用 Tool 收集证据 | Diagnosis Agent(ReAct tool 调用) |
| Gatekeeper | 机械验真证据引用 | EvidenceGuard(真理源改为 canonical store,对象改为 DiagnosisDraft) |
| Verifier | 判断 Claim 是否可信 | SemanticGuard(隔离,无 Tool 无记忆) |
| Composer | 组织最终回答 | Release(唯一发布点) |
| StateGraph | 显式状态/分支/终态 | Harness 状态分层(正交枚举 + first-terminal-wins) |
| 预算止损 | 防止空转 | Progress Control(信息增益收敛,从止损升级为正常收敛) |
**两个洞察**:
1. 代码能机械证明的事实,不交给模型判断(Gatekeeper → EvidenceGuard 的思想延续);
2. 只有拥有不同数据权限、不同工具或真正独立业务目标的角色,拆成多 Agent 才值得——把一次 ReAct 的内部步骤外置成多角色,只会放大协议成本。
**结果**:业务推理合并回单个 Agent,但安全权力拆得更清楚——Agent 没有 canonical 读取权、不能自证引用、没有发布权。
### 3.3 图三:数据三层(一份工具结果,三个职责)
```text
┌─ 第 1 层:canonical(Redis,TTL 2h,key=runId+toolCallId)────────┐
│ request + raw_response + agent_result + status + evidenceStatus │
│ 用途:EvidenceGuard 回读验真 / 短期完整真相 / 排查 │
├─ 第 2 层:Model Observation(Projector 有界投影,冻结契约)──────┤
│ 白名单 / 脱敏 / 截断,只含 Agent 下一步推理所需字段 │
│ 用途:服务模型推理(不给 raw,防上下文膨胀/prompt injection) │
├─ 第 3 层:Metadata Audit(MySQL 长期)────────────────────────────┤
│ 只存 identity/状态/耗时/token/bytes,无正文无 raw 副本 │
│ 用途:长期回放(配合 trace 时序线) │
└───────────────────────────────────────────────────────────────────┘
```
**三分字段**(一次调用):
```text
request = 我问了什么(模型入参)
raw_response = 工具回了什么(完整返回,存 canonical 不外发)
agent_result = 我能信什么 / 模型能看到什么(投影后有界,供推理 + 验真)
```
**为什么不能共用一份数据**:raw 直接给模型 → 上下文膨胀 + 敏感泄漏 + prompt injection;只存裁剪观察 → EvidenceGuard 无法独立验真;raw 永久进审计 → 制造敏感副本。三个目标冲突,所以真相、观察、元数据各存各的。
**关键事实**:MySQL 不存完整工具返回和 agent_result——`JpaToolInvocationAuditSink` 落库时只提取 `output_preview`(默认仅 status/evidence_status)和 `output_length`(字节长度)。TTL 过期后只能元数据回放。
## 4. 九域五段式面试讲法
每域固定叙事结构:**① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术**,外加高频追问。
### 4.1 core:执行控制
- **① 动机**:非确定性 Agent 执行时,身份归属、截止时间、预算、取消、终态必须确定——异步调用和多轮会话中,事实到底属于哪次执行?迟到结果能不能发布?
- **② 决策**:RunContext 显式传递(不用 ThreadLocal);唯一终态 first-terminal-wins。
- **③ 实现**:`checkActive` 三道闸(模型调用前/工具调用前/工具执行中逐行);取消广播(onCancel → future.cancel);deadline;RunBudget;预算耗尽/取消走终态。
- **④ 边界**:协作式取消——同步 Provider 计算未必立即停止;终态防迟到发布但不物理强杀。
- **⑤ 话术**:
> "core 管一次 Run 的确定性边界:显式 RunContext 跨线程传递(挂进 config metadata,防并发串线),first-terminal-wins 保证唯一终态——第一个写入的终态不可被迟到结果覆盖;checkActive 在模型前、工具前、工具执行中逐行检查,预算/取消/超时到点即 abort;取消是协作式的,逻辑终态和发布被保护,但同步 Provider 计算未必立即停。"
- **追问**:取消是强杀吗?(协作式,检查点中止 + 终态防迟到)RunContext 为什么显式?(跨线程 + 并发隔离)预算和 Ledger 区别?(Run 资源门禁 vs 审计账本)
### 4.2 retry:显式可计量重试
- **① 动机**:框架/Agent 自带的重试是盲目重试——同一请求无限重试、不计量、不可审计,一个不可靠的工具能把整个 Run 预算耗光。
- **② 决策**:重试权从框架收归 Harness,做成显式可计量的 attempt 循环。
- **③ 实现**:分类裁决(技术性失败可重试 / 业务性失败不重试);次数/时间/成本三重封顶;剩余超时递减(总超时耗尽不再重试);attempt 可审计。
- **④ 边界**:业务性失败不重试(重试也没用);SDK 关闭后由 Harness 全权控制。
- **⑤ 话术**:
> "重试归 Harness 因为它是成本行为:框架自带的盲目重试不可计量不可审计,Harness 做成显式 attempt 循环——技术性失败才重试、业务性失败不重试,次数/时间/成本三重封顶,每次尝试有分类有记录可审计。Agent 和 Tool 不重试,它们只负责执行,要不要再来一次由 Harness 裁决。"
- **追问**:为什么 Agent/Tool 不重试?(重试是成本裁决权,执行层只管执行)
### 4.3 progress:信息增益收敛
- **① 动机**:预算只能止损(不能继续消耗资源),不能判断「继续查是否有价值」——Agent 可能拿着通用知识、相似查询、空日志反复空转,最后撞预算。
- **② 决策**:预算之外的第二套停止机制——信息增益控制。
- **③ 实现**:GAINED/NO_GAIN 判定(结果是否推进诊断);重复检测;Tracker 双计数/pending;连续 NO_GAIN → SATURATED 饱和停止(软/硬停止);拦截器五道门。
- **④ 边界**:SATURATED 只停收集,不是 Run 终态;`READY + NO_EVIDENCE` 是成功执行但空结果,不是技术异常。
- **⑤ 话术**:
> "progress 是预算之外的第二套停止机制:预算管能不能继续消耗资源,progress 管继续查是否推进诊断。它用信息增益判定(GAINED/NO_GAIN)+ 重复检测 + 饱和停止——连续 NO_GAIN 就停,防止 Agent 拿通用知识或空日志空转;空查询(NO_EVIDENCE)也是被完整建模的负向观察,不是技术异常。"
- **追问**:Agent 为什么不会无限调用 Tool?(预算止损 + 信息增益收敛双保险)
### 4.4 tool:事实边界
- **① 动机**:工具是证据边界——能查什么、查到多少、看到什么必须封死;工具直接连数据库/检索库有破坏面。
- **② 决策**:ToolBoundary 统一执行规则 + canonical 存真相 + Projector 有界投影;数据三层分离。
- **③ 实现**:preflight 五项(run 匹配/授权/只读/JSON/key)失败不落库;预算门禁(beforeToolCall + bytes 三笔);canonical 状态机(PROJECTING→READY/ERROR);审计 best-effort;每类工具一个 Projector(严格校验 + 脱敏 + 截断)。
- **④ 边界**:不理解业务内容(投影交给 ToolResultProjector);不做信息增益判断(progress 的事);只允许 READY/ERROR 离开。
- **⑤ 话术**:
> "tool 域是证据边界:ToolBoundary 统一四项职责——preflight(run 匹配/授权/只读/JSON 合法性/key 生成,失败不落库)、预算门禁(Tool 预算 + request→raw→agent_result 三笔字节预留)、canonical 状态机(PROJECTING→READY/ERROR)、审计。执行结果分三层:完整真相存 Redis canonical(2h),有界投影给模型,长期审计只留元数据。它明确不做业务投影和信息增益判断——那是 Projector 和 progress 的事。"
- **追问**:Tool 结果为什么不直接给模型?(三层数据责任:推理/验真/留存冲突);MySQL 沙箱怎么防?(语义/连接/输出三层防线)
### 4.5 guard:证据安全链双闸
- **① 动机**:Agent 会撒谎——编造工具调用、夸大结论。不信任模型自述。
- **② 决策**:双闸分离——EvidenceGuard 机械验引用真实(规则、可审计、不调模型),SemanticGuard 隔离判结论支持度(无工具无记忆的单轮二值判断)。
- **③ 实现**:EvidenceGuard 三层校验(结构校验 → 逐条引用验真 key=runId+toolCallId 查账本 + isReferencableBy + kind 匹配 → 重读投影重建证据,20 个违规码);SemanticGuard 严格 schema(恰好 {verdict, reason})+ 预算/重试/取消全栈衔接。
- **④ 边界**:EvidenceGuard 只问引用真不真,不问语义;SemanticGuard 不能探索事实、不能改写报告。
- **⑤ 话术**:
> "防编造证据用双闸:EvidenceGuard 是机械验真——模型草稿里每个 tool_call_id 都要去 canonical 账本查到真实记录(key 绑定 runId 防跨 Run、记录必须 READY、kind 与证据语义匹配、投影内部自洽),纯规则可审计不调模型;SemanticGuard 是隔离语义审查——无工具、无记忆、单轮二值判断,只判结论是否被已验证证据支持,输出硬校验为 {verdict, reason} 两个字段。先机械后语义:引用假的直接拦,不浪费模型调用。"
- **追问**:为什么 EvidenceGuard 通过还要 SemanticGuard?(引用真实 ≠ 结论被支持:一个防编造证据,一个防夸大结论)
### 4.6 release:唯一发布点
- **① 动机**:模型输出的是未经证明的断言,不能直接当答案返回。
- **② 决策**:唯一发布点——SUCCESS 只有一条路径(验真过 + 语义支持),其余全降级 SafeFallback。
- **③ 实现**:三分支决策树(受控停止凭 progress 快照 / 无结论验引用按进展降级 / 有结论走 Evidence→Repair→Semantic 链);fail closed(无安全进展抛异常);EvidenceRepair 只修引用(prompt 锁死 + 语义不变性检查);SafeFallbackFactory 五种降级(有界/去重/诚实)。
- **④ 边界**:终态异常透传(取消/预算耗尽不伪装成业务 FALLBACK);SafeFallback conclusion 恒 null。
- **⑤ 话术**:
> "release 是唯一发布点:任何对外内容必须经过验证。它按 Draft 形态三分支——受控停止(无草稿)凭 progress 快照发布 INSUFFICIENT_EVIDENCE,且必须有已验真事实否则 fail closed;无结论只验引用按进展降级;有结论走完整链——EvidenceGuard 验引用,失败则 EvidenceRepair 只修引用(prompt 锁死只能改引用字段 + 语义不变性保证用户可见内容不变)再复查,仍失败降级 EVIDENCE_VALIDATION_FAILED;验真通过后 SemanticGuard 判支持度,SUPPORTED 是唯一 SUCCESS 出口,其余降级。所有降级走 SafeFallbackFactory:有界、去重、诚实,保留已验证事实但不发布未证明的根因。"
- **追问**:FALLBACK 算成功还是失败?(正交:可 RunState.SUCCESS + FALLBACK,是安全发布结果不是失败)
### 4.7 application:Run 应用所有者
- **① 动机**:一次请求从创建到公开结果需要编排:建 Run、路由、执行分支、持久化、SSE 输出。
- **② 决策**:ChatApplicationUseCase 六步编排,不做业务判断,不把 HTTP/SSE 细节塞 Core。
- **③ 实现**:startRun → 读会话上下文(RoutingHistory + PreviousTurn)→ 路由 → executePath 分支 → completePath + persistFinish;统一失败出口(terminalOutcome + safeFailure);取消句柄(CoreRunControl → core.cancel)。
- **④ 边界**:路由只给枚举不执行;预算耗尽的 FALLBACK 不二次 completeSuccess。
- **⑤ 话术**:
> "application 是 Run 的应用所有者:六步编排——建 Run 边界(core.startRun)、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。路由只回答走哪条分支,分支执行权在 executePath。"
- **追问**:多轮记忆怎么实现?(RoutingHistory + PreviousTurn,只传发布后的安全摘要)
### 4.8 audit:可观测账本(含 trace)
- **① 动机**:要能回放决策过程,又不永久保存敏感正文——两个目标冲突。
- **② 决策**:metadata-only + trace 时序线 + Token 对账账本;audit 是域,trace 是域内子体系。
- **③ 实现**:trace 17 种事件按 sequence_no 单调落 diagnosis_trace_event(七阶段:RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段;Token 三写闭环(ledger 分账 → Run 预算 → agent_step 回写);DiagnosisTraceService 三级回放。
- **④ 边界**:审计不阻断主流程(fail-safe);正文只在受限审计表;TTL 过期后只能元数据回放。
- **⑤ 话术**:
> "audit 是可观测账本,trace 是它内部的事件回放子体系。trace 用一张表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账→Run 预算→明细回写)。回放三级:时间线→明细→推理。"
- **追问**:audit 和 trace 什么关系?(包含关系:trace 是 audit 域内的时序事件流,audit 还含 ledger/工具审计/推理审计)
### 4.9 contract:状态流正交
- **① 动机**:技术停了不等于用户看到失败;查了没查到不等于系统出错——状态语义混在一个枚举里就糊了。
- **② 决策**:分层 + 正交 + 显式映射。
- **③ 实现**:11 个状态枚举五层(技术 RunState / 证据 InvocationStatus+EvidenceStatus / 收集 StopReason / 发布 ReleaseOutcome+FallbackType / 协议 ChatApplicationStatus+SseOutcome+ChatFailureCode);四个正交轴;纵向映射链。
- **④ 边界**:SSE 只有三态(取消连接已断发不出 done);SseOutcome 未接线;FallbackType.BUDGET_EXHAUSTED 命名债务。
- **⑤ 话术**:
> "状态设计核心是分层正交:RunState(技术终态)和 ReleaseOutcome(用户结局)是正交轴——预算耗尽有安全进展→FALLBACK、没进展→FAILED,同一技术终态诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空仍是成功执行。映射链:CANCELLED→CANCELLED+RUN_CANCELLED,SUPPORTED→SUCCESS 唯一出口;协议层(SSE)复用 ReleaseOutcome 三态拒绝 CANCELLED。"
- **追问**:这些状态为什么不合并成一个枚举?(不同层回答不同问题,正交后独立演进、映射显式可审计)
## 5. 六个易错点(带「为什么」)
| # | ❌ 不说 | ✅ 应说 | 为什么 |
|---|---|---|---|
| 1 | Harness 安排 Agent 执行步骤 | ReAct Agent 自己选 Tool 和下一步;Harness 只管边界 | 边界 ≠ 编排:Harness 不替 Agent 决定查什么 |
| 2 | SemanticGuard 是第二个诊断 Agent | 无 Tool、无记忆、单轮二值判断的隔离审查器 | 职责 ≠ 角色:它没有探索能力,判完就走 |
| 3 | Tool 返回 SUCCESS 就找到证据 | READY 只表示调用完成,还要看 EvidenceStatus | 生命周期 ≠ 证据:调完成功和有没有证据是两回事 |
| 4 | FALLBACK 就是 Run 失败 | Fallback 是安全发布结果,可 RunState.SUCCESS + FALLBACK | 技术 ≠ 用户:两个正交维度 |
| 5 | Redis 是长期审计库 | canonical 只存当前 Run 短期真相(TTL 2h);长期审计只留元数据 | 短期真相 ≠ 长期审计:可验真 vs 不留敏感副本 |
| 6 | 取消能立刻杀死所有模型调用 | 协作式取消:终态防迟到发布,同步 Provider 未必立即停 | 逻辑保护 ≠ 物理强杀:终态定了但线程未必立刻停 |
## 6. 高频追问应对大全
| 追问 | 回答主线 |
|---|---|
| 什么是 Harness?30 秒讲清 | 确定性控制边界:运行/事实/发布三层边界 |
| 为什么不用多 Agent? | 四角色加起来是一次 ReAct;推理合并、权力拆分 |
| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 |
| RunContext 为什么显式传递? | 跨线程异步链 + 并发隔离,ThreadLocal 会丢/串 |
| 取消是强杀吗? | 协作式:检查点中止 + first-terminal-wins 防迟到 |
| 预算和 Ledger 区别? | Run 资源门禁 vs 审计账本(不同域) |
| FALLBACK 算成功还是失败? | 正交:技术终态(RunState)与用户结局(ReleaseOutcome) |
| 重试为什么归 Harness? | 盲目重试不可计量;显式可计量 attempt 循环 |
| 为什么 Agent/Tool 不重试? | 重试是成本裁决权,执行层只管执行 |
| 如何防止 Agent 编造证据? | framework Tool ID + canonical store + EvidenceGuard |
| 为什么双闸? | 引用真实(机械)≠ 结论被支持(语义) |
| Tool 结果为什么不直接给模型? | 真相/观察/审计三个数据责任冲突 |
| Agent 为什么不会无限调 Tool? | 预算止损 + 信息增益收敛双保险 |
| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 |
| 如何回放决策? | metadata audit + trace 时序线 + 三级回放 |
| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、阈值校准 |
## 7. 支付超时案例(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 也完整结束,但未经验证的根因没有离开系统。"
**三个不等于**:Tool READY ≠ 引用已验真 / 引用已验真 ≠ 结论被支持 / Agent 生成 Draft ≠ 报告允许发布。
## 8. 复习中纠正的认知清单(最容易踩的坑)
| 错误认知 | 纠正为 |
|---|---|
| Harness 顶层所以控制 retry/progress | 不只是位置——重试是成本行为必须可计量封顶;progress 解决「预算不能判断价值」 |
| RunContext 因为「回调」显式传 | 跨线程异步链 + 并发隔离;显式挂 config metadata |
| ToolBoundary 管 token/收敛/重试次数 | 那些归 core/retry/progress;ToolBoundary 只四项(preflight/预算/状态机/审计) |
| 工具失败抛异常 | ToolBoundary 转 ERROR 状态 + 错误观察,Agent 可继续换工具;Run 是否失败看 hasObservedFacts |
| FALLBACK 可能因超时 | 超时(TIMED_OUT)通常走 FAILED;FALLBACK 前提是有已验证事实 |
| agent_result 也存 MySQL | 不存——MySQL 只留 output_preview + output_length;完整 agent_result 在 Redis canonical(2h) |
| 注入 skill/知识域给 agent | 注入的是 query + PreviousTurn + 系统 prompt;知识靠工具主动查 |
| 非法 draft 由 release 捕捉 | 反序列化在 agent 出口(recoverInvalidDraft);release 只做验证+裁决 |
| preflight 失败也落库 | 失败不落库(errorAndNoRecord)——只有 preflight 全过才写 PROJECTING |
| 多 Agent 一定不好 | 只有不同数据权限/独立业务目标的角色才值得拆;拆 ReAct 内部步骤只会放大协议成本 |
## 9. 面试前一天 Checklist
```text
□ 30 秒电梯陈述背熟(§2.1),三个重点不丢(§2.3)
□ 默画三张白板图(§3):主链路含分支 / 职责迁移对照 / 数据三层
□ 六个易错点扫一遍(§5)——重点看「为什么」列
□ 九域话术:挑 3 个最可能被追问的(guard/release/contract)背熟
□ 2 分钟支付超时案例 + 三个不等于(§7)
□ 过一遍纠正认知清单(§8)——这些是踩过的坑
□ 读一遍面试速查 §7 追问表,心里有数
```
## 10. 代码位置索引
| 组件 | 文件 |
|---|---|
| ToolBoundary(preflight/预算/状态机/审计) | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
| JpaToolInvocationAuditSink(output_preview 落库) | `.../audit/JpaToolInvocationAuditSink.java` |
| DiagnosisAgentUseCase(RunContext 挂 config metadata) | `.../agent/DiagnosisAgentUseCase.java` |
| EvidenceGuard / SemanticGuard | `.../guard/evidence/` + `.../guard/semantic/` |
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `.../release/` |
| DiagnosisProgressProjector / Tracker / Snapshot | `.../progress/` |
| ChatApplicationUseCase / IntentRouter | `.../application/` |
| DiagnosisTraceService / DiagnosisTraceController | `.../service/` + `.../controller/` |
| 状态枚举(RunState/ReleaseOutcome/FallbackType/...) | `.../contract/` + `.../core/RunState.java` |
@@ -0,0 +1,280 @@
# Harness Tool 双视图:从原始结果到可验证证据
**更新日期**:2026-07-29
**主题**:ToolBoundary、Canonical Invocation、Harness Control View 与 Agent Observation
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**上位设计**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
## 1. 要解决的不是 Tool 调用,而是 Tool 结果的所有权
Agent 调用 Tool 后,最直接的实现是把 backend 返回的 JSON 原样放进模型上下文。这在 Demo 中可以工作,但进入可验证的诊断系统后会出现一个根本矛盾:同一份结果需要同时服务推理、控制、验真和审计,而这些消费者需要的数据范围完全不同。
以日志查询为例:
- Agent 需要少量匹配事件、模式、实际时间范围和是否截断;
- Harness 需要返回数量、规范化 scope、重复身份和客观证据状态;
- EvidenceGuard 需要证明这份结果确实来自当前 Run 的某次 READY 调用;
- 审计需要 Tool 名、调用 ID、状态、耗时和字节数;
- 安全边界又要求密码、Token、主机、IP、SQL 字面量和无限日志正文不能进入模型或长期审计。
如果只保留一份 JSON,只能在两个错误方向中选择:要么信息过多导致泄露和上下文膨胀,要么信息过少导致后续无法验真。
因此这项设计的核心不是“增加一个 Redis Store”,而是重新定义 Tool 数据所有权:
> backend raw 属于 Harness;模型只能获得为推理目的生成的有界观察;长期审计只保留允许运营保存的元数据。
## 2. 三个消费者,三种数据责任
虽然实现中常称为“Tool 双视图”,完整的数据分层实际上包含三种用途:
| 数据形态 | 消费者 | 解决的问题 | 生命周期 |
|---|---|---|---|
| Canonical Invocation | ToolBoundary、EvidenceGuard、ProgressProjector | 这次 Tool 真实执行了什么、属于哪个 Run、能否引用 | Redis 短 TTL |
| Harness Control View / Agent Observation | Harness / Diagnosis Agent | 是否重复、是否为空、模型下一步需要看到什么 | 当前 Run / 模型上下文 |
| Durable Audit | Trace、运营排障 | 何时调用、状态、耗时、大小、关联 Step | MySQL 长期 metadata |
所谓“双视图”,特指 canonical 标准结果被进一步分成:
1. Harness 使用的 Control View;
2. 模型使用的 Agent Observation。
Durable Audit 是第三个持久化面,但它不是 Tool 内容视图,不参与推理或证据验真。
```mermaid
flowchart LR
A["Typed Tool Request"] --> B["ToolBoundary"]
B --> C["Backend Raw Response"]
C --> D["Tool-specific Projector"]
D --> E["Canonical agent_result"]
B --> F["Redis Canonical Invocation<br/>request + raw + agent_result"]
E --> F
E --> G["Harness Control View<br/>count / status / scope"]
E --> H["Agent Observation<br/>白名单、有界、脱敏"]
B --> I["Durable Audit<br/>identity / status / latency / bytes"]
G --> J["重复检测、NO_GAIN、停止"]
H --> K["Diagnosis Agent Context"]
F --> L["EvidenceGuard / ProgressSnapshot"]
```
## 3. 为什么不能让 Agent Observation 充当真相源
Agent Observation 是为了控制上下文而生成的投影,它可能:
- 只保留前 N 条结果;
- 截断单条文本;
- 对敏感列和日志字段做脱敏;
- 把大量事件聚合成模式;
- 只暴露粗粒度相关度,不暴露检索轨迹和原始分数。
这意味着它适合帮助模型推理,却不适合作为“后台真实返回”的完整证明。如果 EvidenceGuard 反过来验证 Agent 自己收到的 observation,就相当于用被审查对象提供的摘要证明其自身真实性。
Canonical Invocation 解决的正是这个独立性问题。它以 exact `runId + tool_call_id` 建立记录,并保存:
```text
tool_call_id
run_id
tool_name
request
raw_response
agent_result
invocation status
evidence status
error code
started_at / completed_at
```
EvidenceGuard 不信任 Draft 中的引用,也不从模型历史反推 Tool 结果,而是重新按当前 Run 构造 key,读取 canonical record 并验证状态和投影内容。
## 4. ToolBoundary 为什么必须是统一入口
RAG、日志和 MySQL 的业务执行方式不同,但以下控制规则完全相同:
- Run 必须仍然 active;
- envelope 的 runId 必须等于当前 Run;
- `tool_call_id` 必须合法且不能重复;
- Tool 必须已授权并声明只读;
- 调用前必须预占 Tool 预算和 request bytes;
- raw response 和 agent result 必须分别检查容量;
- canonical 状态只能按合法路径迁移;
- 对 Agent 只返回稳定错误码;
- durable audit 失败不能改变已经得到的 Tool 结果。
如果把这些逻辑复制到三个 Adapter,任何新增 Tool 都可能漏掉其中一项。因此统一由 `ToolBoundary` 编排一次调用,具体 Adapter 只负责 typed request、backend 和 projector 的连接。
```mermaid
sequenceDiagram
participant I as Tool Interceptor
participant B as ToolBoundary
participant C as Harness Core
participant S as Canonical Store
participant T as Backend
participant P as Projector
participant A as Audit Sink
I->>B: RunContext + ToolCallRequestEnvelope
B->>B: run / id / authorization / readonly / JSON preflight
B->>C: reserve Tool call + request bytes
B->>S: begin PROJECTING
B->>T: execute typed request
T-->>B: raw response
B->>C: reserve raw bytes
B->>P: project raw response
P-->>B: bounded agent_result + evidence_status
B->>C: reserve projection bytes
B->>S: mark READY
B-->>I: ToolBoundaryResult
B-->>A: best-effort metadata audit
```
错误发生时,已经创建的 canonical record 会尽力迁移到 ERROR;如果连 Store 都不可用,则向上只返回 `STORE_ERROR` 等稳定码,不把 Redis 或 backend 异常正文交给模型。
## 5. 两套状态为什么不能合并
Tool 调用同时有两个正交维度:
| 维度 | 状态 | 回答的问题 |
|---|---|---|
| Invocation lifecycle | `PROJECTING / READY / ERROR` | 这次调用是否完成并形成了可引用记录 |
| Evidence semantics | `EVIDENCE_FOUND / NO_EVIDENCE / ERROR` | 客观结果中是否存在候选证据 |
例如日志查询成功返回 0 条:
```text
InvocationStatus = READY
EvidenceStatus = NO_EVIDENCE
```
它不是 `ERROR`。这条负向观察可以支持“在指定时间、服务和查询条件下没有匹配日志”,但不能支持“故障不存在”。
合法组合被类型约束为:
| InvocationStatus | EvidenceStatus | 是否允许引用 |
|---|---|---:|
| `PROJECTING` | `null` | 否 |
| `READY` | `EVIDENCE_FOUND` | 是 |
| `READY` | `NO_EVIDENCE` | 是,但只能作为限定范围负向观察 |
| `ERROR` | `ERROR` | 否 |
把两者合成一个 `SUCCESS / FAILED` 会丢失最重要的信息:技术成功但业务范围内没有证据。
## 6. 三类 Tool 如何投影
### 6.1 RAG
`RagResultProjector` 从 backend 结果中提取有界 evidence block,稳定文档身份,限制 excerpt 数量和长度,并兼容 `relevanceLevel / relevance_level` 后统一为 `relevance_level`。
这里有一个刻意保留的区分:
```text
evidence 非空 -> EVIDENCE_FOUND
relevance_level=REFERENCE -> 相关度一般
```
`EVIDENCE_FOUND` 只说明存在候选内容,`REFERENCE` 也不自动表示 `NO_GAIN`。内容是否推进当前诊断假设,需要模型结合上下文判断。
### 6.2 Logs
`QueryLogsResultProjector` 不只是截取前几条日志,它还会:
- 脱敏 password、token、secret、API key;
- 脱敏 pod、host、PID、IP 和 SQL literal;
- 去掉堆栈尾部噪声;
- 对数字做模式归一化并聚合重复事件;
- 对事件做跨范围采样,而不是只保留开头;
- 保存实际时间、topic、query scope 和截断标记。
如果 backend 执行成功且日志数组为空,投影结果是 READY + NO_EVIDENCE。backend 明确返回失败,才是 Tool 执行或投影错误。
### 6.3 MySQL
MySQL 在投影之前还有独立的只读安全链:`MysqlSqlValidator` 根据逻辑数据源、schema、table 和 column allowlist 生成 `MysqlQueryPlan`,`JdbcMysqlReadOnlyExecutor` 只执行该 Plan。
`MysqlResultProjector` 再负责:
- 限制最大行数、单元格长度和总 bytes;
- 保持 number、boolean 和 null 类型;
- 对 password、token、secret、credential 等敏感列强制脱敏;
- 结果缩减时标记 `truncated=true`。
Validator 负责“能不能执行”,Projector 负责“模型能看到什么”,两者不能合并。
## 7. Control View 与 Model Observation 的字段边界
| 字段 | Harness | Agent | 原因 |
|---|---:|---:|---|
| `tool_call_id` | 是 | 是 | 引用和上一轮评价都需要 |
| 实际 scope | 是 | 是 | Harness 去重;模型理解负向观察边界 |
| 有界 evidence/events/rows | 是 | 是 | 模型推理所需事实 |
| `evidence_status` | 是 | 是 | 区分候选证据、空结果和错误 |
| `relevance_level` | 是 | 是 | 给模型粗粒度检索语义 |
| `truncated` | 是 | 是 | 防止模型误以为结果完整 |
| `returned_count` | 是 | 否 | Harness 统计,不必消耗模型上下文 |
| normalized scope / duplicate identity | 是 | 否 | 内部控制实现 |
| 连续 NO_GAIN、阈值和剩余预算 | 是 | 否 | 防止模型围绕限制博弈 |
| raw response / 检索轨迹 / 原始分数 | 是 | 否 | 敏感且体积不可控 |
只有 Harness 必须改变模型行为时,才注入 `STOP_REQUIRED`、错误码或已检查范围等有限控制信息。
## 8. 存储决策:为什么是 Redis canonical + MySQL metadata
### 8.1 不把完整 raw 长期写 MySQL
完整 Tool 结果可能包含日志、SQL 查询结果和内部知识内容。长期保存会扩大泄露半径,也会让 JPA audit 成为第二个事实源。MySQL 只保存运营和对账需要的字段,可以长时间保留而不复制正文。
### 8.2 Canonical 读取不续期
Redis record 在创建时设置 TTL,读取或状态更新不恢复初始 TTL。原因是:如果一次历史查询就能续期,敏感 raw 可能因审计访问而永久存在。
状态更新保留当前剩余 TTL,代价是 read-TTL-write 存在小的并发窗口,但它比无界续期更符合数据治理目标。
### 8.3 raw 超限为什么不截断
raw 是内部事实。如果静默截断后仍标记 READY,系统无法区分“backend 只返回这些内容”和“Harness 丢掉了内容”。因此 raw 或完整 record 超限时调用进入 ERROR。
Agent projection 可以截断,因为它本来就是面向消费的摘要,但必须通过 `truncated=true` 明示不完整。
## 9. 真实问题如何改变设计
| 真实问题 | 暴露的错误假设 | 最终修正 |
|---|---|---|
| Tool raw 直接进入 Agent | backend 输出天然适合模型消费 | 增加 Tool-specific projector 和白名单 observation |
| Trace、raw、ContextPack、重复正文同时存在 | 多保存几份可以提高可观测性 | canonical、model view、metadata audit 明确分层 |
| Mock 日志 0 命中返回 `success=false` | 没数据等于调用失败 | 技术执行状态与 NO_EVIDENCE 分离 |
| RAG 的 `REFERENCE` 在投影中丢失 | 只要 evidence 非空就够了 | 保留 relevance_level,但不提升为根因证据 |
| 生产 ObjectMapper 未注册 Java Time 模块,Redis 全部 STORE_ERROR | 单元测试序列化环境等同生产 | 使用生产装配验证 canonical record,并增加 live E2E |
| backend 日志打印 raw/rewritten query | 排障信息可以直接进入普通日志 | 普通日志和 durable audit 只保留安全摘要 |
## 10. 代价与边界
这项设计不是免费的:
1. 每增加一种 Tool,都要同时定义 typed request/result、Adapter、Projector 和 EvidenceGuard 读取规则。
2. Redis 在 TTL 内成为证据校验依赖;不可用时系统应 fail closed,而不是相信 Draft。
3. Agent 看到的是有损投影,Projector 设计不当可能丢掉模型真正需要的诊断信号。
4. durable audit 不能替代 canonical replay;TTL 到期后只能解释调用元数据,无法恢复完整正文。
但这些成本换来了明确的责任:Backend 决定原始事实,Projector 决定模型可见范围,Canonical Store 决定短期验真事实,Audit 决定长期允许保存什么。
## 11. 如何验证
| 验证内容 | 代表性测试或证据 |
|---|---|
| Run mismatch、未授权、非只读、重复 ID、预算和超限 | `ToolBoundaryTest` |
| PROJECTING / READY / ERROR 和 TTL 行为 | `CanonicalInvocationStoreTest` |
| RAG evidence identity、relevance 和 bytes | `RagResultProjectorTest` |
| 日志脱敏、模式聚合、空结果和采样 | `QueryLogsResultProjectorTest` |
| MySQL 行列/敏感字段/容量边界 | `MysqlResultProjectorTest` |
| typed Tool contract 不漂移 | 三类 `*ToolContractTest` |
| 生产 Redis serializer 与 ObjectMapper | single-react cleanup live E2E |
单元测试只能证明数据变换和状态机;生产 Redis serializer、实际 Tool Calling ID 和 backend 返回格式仍必须通过 live E2E 验证。
## 12. 与后续机制的关系
Tool 双视图只解决“什么是可验证的 Tool 事实、模型允许看到什么”,并不解决:
- 这些事实是否支持最终结论:见[Harness证据安全链-从引用真实到结论可发布.md](Harness证据安全链-从引用真实到结论可发布.md);
- Agent 是否应该继续查询:见[Harness信息增益停止-让无证据诊断正常收敛.md](Harness信息增益停止-让无证据诊断正常收敛.md)。
@@ -0,0 +1,302 @@
# Harness 信息增益停止:让无证据诊断正常收敛
**更新日期**:2026-07-29
**主题**:Information Gain、Progress Tracker、STOP_REQUIRED 与过程型 Fallback
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**上位设计**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
## 1. 预算能限制损失,不能判断何时完成
在知识库只有通用说明、日志持续为空或查询条件不足时,ReAct Agent 容易出现一种看似合理的行为:不断修改关键词、时间范围或查询表达,再调用一次 Tool。
每一次调用单独看都可能合法,但整个 Run 没有获得新信息。旧系统只能等到模型次数、Tool 次数、Token 或 deadline 耗尽,再以 `BUDGET_EXHAUSTED` 或 `INTERNAL_FAILURE` 结束。
这暴露了两个被混淆的问题:
```text
资源预算:这次 Run 最多允许消耗多少?
业务收敛:继续查询是否仍可能推进当前诊断?
```
预算是最后一道资源保护,不能承担正常停止策略。否则“当前证据不足”会被错误表达成“系统执行失败”。
信息增益停止契约的目标是:在硬预算之前识别连续无进展,使 Agent 停止调用 Tool,并把已经完成的有限检查发布为诚实、可验证的业务结果。
## 2. 先划清三方判断权
设计停止机制时最危险的做法,是让某一层承担它无法可靠完成的判断。
| 参与者 | 能可靠判断什么 | 不能判断什么 |
|---|---|---|
| Tool / Projector | 是否执行成功、结果是否为空、返回数量、实际 scope、是否截断 | 内容是否支持当前诊断假设 |
| Diagnosis Agent | 非空内容是否确认、排除或缩小当前假设 | 是否还能绕过预算、重复和饱和门禁 |
| Harness | scope 是否重复、连续 NO_GAIN、协议是否合规、是否允许继续 | 业务根因是什么 |
| Release | 已有进展可以发布成哪种安全结果 | 是否应该重新调用 Tool |
最终原则是:
> Tool 提供客观结果,模型判断语义价值,Harness 拥有最终停止权。
模型可以选择主动结束,但不能通过继续发 Tool Call 绕过 Harness 已经确定的饱和或预算状态。
## 3. 为什么信息增益只有两个值
当前契约只保留:
```text
information_gain = GAINED | NO_GAIN
```
没有 `HIGH / MEDIUM / LOW`,也没有置信分数。停止控制只需要知道结果是否推进了当前诊断;引入更多等级会带来阈值解释、跨 Tool 标定和模型输出不稳定,却不一定改变最终动作。
判定标准是:
| 结果 | Information Gain | 生产者 |
|---|---|---|
| Tool 执行失败 | 不产生 | 进入技术失败流程 |
| `evidence_status=NO_EVIDENCE` | `NO_GAIN` | Harness |
| 相同 `tool_name + normalized_scope` | `NO_GAIN` | Harness,并拒绝重复执行 |
| 成功非空,确认/排除/缩小假设 | `GAINED` | Diagnosis Agent |
| 成功非空,但只是通用知识、重复内容或无关事实 | `NO_GAIN` | Diagnosis Agent |
RAG 的 `REFERENCE` 不能自动映射为 `NO_GAIN`。它只表示检索相关度一般;一段一般相关的资料可能仍排除一个假设,也可能完全无用,需要模型结合诊断上下文判断。
## 4. 为什么模型在下一次 Tool Call 中评价上一轮
模型只有在读到 Tool Observation 后,才能判断它是否有语义增益。但如果要求模型单独输出一条 progress 消息,就必须增加新的协议轮次或 Progress Judge。
当前设计利用模型已经要做的下一步行为:
- 输出 DiagnosisDraft,表示主动结束;
- 发起下一次 Tool Call,表示希望继续。
当模型选择继续时,下一次 Tool Call Envelope 必须携带对上一轮的评价:
```json
{
"previous_observation": {
"tool_call_id": "call-123",
"information_gain": "NO_GAIN"
},
"input": {
"query": "新的业务查询参数"
}
}
```
Interceptor 在调用业务 Tool 之前完成三件事:
1. 校验 `previous_observation.tool_call_id` 是否正好是当前 pending 调用;
2. 应用 `GAINED / NO_GAIN`,更新连续计数和收集状态;
3. 剥离 `previous_observation`,只把 `input` 传给原业务 Tool。
这是 Agent-facing Tool Schema 的协议变化,但 RAG、日志和 MySQL 的业务 request 并没有被控制字段污染。
```mermaid
sequenceDiagram
participant M as Diagnosis Agent
participant I as Tool Interceptor
participant P as Progress Tracker
participant T as Business Tool
M->>I: Tool Call #1 + input
I->>P: no pending observation
I->>T: business input
T-->>I: successful non-empty observation
I->>P: mark call #1 pending evaluation
I-->>M: bounded observation
M->>I: Tool Call #2 + previous_observation(#1, NO_GAIN)
I->>P: validate ID and apply NO_GAIN
alt 仍为 COLLECTING
I->>T: strip control fields, execute input #2
else 达到 SATURATED
I-->>M: STOP_REQUIRED
end
```
如果模型读完 observation 后直接输出 Draft,就不需要额外评价最后一轮。因为它已经通过实际行为表达“停止”,Harness 也不需要为收集一个统计字段强迫模型再调用 Tool。
## 5. ProgressTracker 保存什么,不保存什么
`DiagnosisProgressTracker` 是 `RunContext` 中的线程安全状态句柄,保存:
- 已完成的 `tool_name + normalized_scope`;
- 已完成 Tool Call identity;
- 当前等待模型评价的 `pendingToolCallId`;
- 连续 `NO_GAIN` 次数;
- 连续 progress protocol violation 次数;
- `COLLECTING / SATURATED` 状态;
- stop reason;
- STOP_REQUIRED 是否已经交付。
它不保存 request、raw response、agent result 或模型 thought。完整 Tool 事实仍属于 Canonical Store。Tracker 只保存作出停止决策需要的最小 identity 和计数,避免出现第二份 Tool 真相。
结束时,`DiagnosisProgressProjector` 根据 completed identity 回读 canonical READY records,投影成有界 `ProgressSnapshot`。无法验证、不可读取或格式非法的记录不会被发布,只会形成安全 limitation。
## 6. 重复检测为什么只做参数级
Harness 使用 `ToolScopeNormalizer` 将业务输入转换成稳定 scope,再比较:
```text
tool_name + normalized_scope
```
这可以识别字段顺序、格式差异下的完全相同查询,并在调用 backend 前拒绝重复执行。
首版没有做自然语言语义去重,例如以下两条 query 可能语义相同,但不会被代码证明为同一 scope:
```text
“查询支付超时日志”
“查找支付请求 timeout 记录”
```
原因是语义去重需要 embedding、模型判断或跨 Tool 指纹,会引入新的不确定性和误杀风险。当前边界选择了可以确定性证明的参数重复;语义近似由模型的 `NO_GAIN` 义务约束。
## 7. 收集状态机
连续无增益阈值由配置控制,当前默认值为 2。`GAINED` 会清零连续计数,避免一次早期空查使后续有效取证被过早停止。
```mermaid
stateDiagram-v2
[*] --> COLLECTING
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
COLLECTING --> SATURATED: NO_GAIN / 达到阈值
SATURATED --> STOP_CHANCE: 交付一次 STOP_REQUIRED
STOP_CHANCE --> RELEASE: 模型输出 Draft
STOP_CHANCE --> TERMINATED: 模型再次请求 Tool
```
当某次 READY 结果是 NO_EVIDENCE 时,Harness 可以立即累计 `NO_GAIN`。当成功非空结果需要模型评价时,Tracker 设置 pending ID;上一轮未评价前,新的 Tool Call 不会被执行。
达到 `SATURATED` 后,Harness 给模型一次合法完成机会,而不是在 Tool response 中立刻抛出通用错误。STOP_REQUIRED 只交付一次;模型仍继续请求 Tool 时,`DiagnosisCollectionStoppedException` 将受控停止穿出框架 ReAct loop。
## 8. 协议错误为什么不能计为 NO_GAIN
真实 E2E 曾出现 9 次 `INVALID_PROGRESS_PROTOCOL` Tool 请求拒绝和 13 轮 Diagnosis Agent 模型调用。模型没有正确回传上一轮评价,但这些拒绝也没有增加 NO_GAIN,最终持续空转。
一种看似简单的修复是把协议错误也计为 NO_GAIN。但这会混淆两个事实:
- `NO_GAIN` 表示 Tool 结果没有推进诊断;
- 协议错误表示模型没有遵守控制契约,Tool 根本没有执行。
因此引入独立的协议错误计数和 `PROGRESS_PROTOCOL_VIOLATED`:
1. 第一次错误返回可修正 observation,包含 violation type、缺失字段、期望的上一轮 ID 和允许值;
2. 连续错误达到配置阈值后进入 SATURATED;
3. 交付一次 `STOP_REQUIRED / PROGRESS_PROTOCOL_VIOLATED`;
4. 再次请求 Tool 时受控停止。
协议错误 Trace 使用 `TOOL_REQUEST_REJECTED`,不能记录成 `TOOL_INVOCATION`,因为 backend 从未被调用,Tool 预算和实际执行数也不应被污染。
## 9. 三种停止原因必须分开
| Stop Reason | 含义 | 是否等于技术失败 |
|---|---|---:|
| `INFORMATION_SATURATED` | 连续结果没有推进诊断 | 否 |
| `BUDGET_LIMIT_REACHED` | 达到模型、Tool、Token 或 bytes 预算边界 | 不一定;有安全进展时可发布过程 Fallback |
| `PROGRESS_PROTOCOL_VIOLATED` | 模型连续违反 progress Envelope | 协议失败;有安全进展时仍可保留过程价值 |
信息饱和不能伪装成预算耗尽,否则无法判断阈值是否合理;预算耗尽也不能伪装成信息饱和,因为可能是在持续获得有效证据时资源不足。
这些 stop reason 是 Harness 内部控制语义,不直接作为第二套公开生命周期。最终仍由 Release 映射为 `SUCCESS / FALLBACK / FAILED / CANCELLED`。
## 10. 停止以后如何形成用户结果
停止本身不是答案。系统需要把已经完成的检查转换成可发布内容,又不能依赖预算耗尽后额外调用模型。
`DiagnosisProgressProjector` 从 canonical READY results 生成:
- verified sources;
- observed facts,包括限定范围的空结果;
- 实际查询 scope;
- 投影失败、截断或不可读取形成的 limitations;
- stop reason。
Release 根据现有进展决定:
```mermaid
flowchart TD
S["Agent 主动无结论<br/>或 Harness 受控停止"] --> P["ProgressSnapshot"]
P --> Q{"存在已验真 observed facts?"}
Q -->|"是"| I["INSUFFICIENT_EVIDENCE<br/>展示已检查内容和下一步"]
Q -->|"否"| M{"Draft 声明 missing_info?"}
M -->|"是"| C["MISSING_REQUIRED_CONTEXT"]
M -->|"否"| F["FAILED / fail closed"]
```
`conclusion=null` 是合法 Draft。它允许 Agent 在缺少企业、时间、服务或错误信息时零次调用 Tool,直接报告 `missing_info`,避免为了表现“已经排查”而执行无明确范围的查询。
## 11. 为什么没有引入更多控制字段
### 11.1 不使用 `new_count`
“新记录数量”需要跨 RAG 文档、日志事件和数据库行建立稳定指纹,而且新数据不等于对当前假设有用。它增加了复杂度,却不能替代语义增益判断。
### 11.2 不使用 `next_action`
模型发起 Tool Call已经表示继续,输出 Draft 已经表示结束。再要求 `CONTINUE / STOP` 只会形成一套可能与实际行为冲突的声明状态。
### 11.3 不增加 Progress Judge
独立 Judge 会为每轮 Tool 结果增加模型调用、延迟和失败面。空结果和完全重复 scope 本可由代码判断;其他内容由正在做诊断的 Agent 评价即可。
### 11.4 不向模型公开剩余预算和阈值
模型只需要知道是否必须停止,不需要围绕“还剩几次”规划消耗。阈值、计数和预算属于 Harness Control View,只有 STOP_REQUIRED 等必要指令进入模型上下文。
## 12. Prompt 与硬门禁如何分工
Prompt 仍然需要告诉模型:
- 不必须得出根因;
- `conclusion=null` 是合法完成;
- 缺少必要上下文时可以零 Tool 结束;
- 正确但不能推进假设的内容也是 NO_GAIN;
- 不要通过改写相似关键词重复查询;
- 收到 STOP_REQUIRED 后必须停止。
但 Prompt 只是帮助模型做出正确选择,不构成系统保证。重复 scope、pending evaluation、饱和状态、预算和一次性 STOP_REQUIRED 都由代码门禁执行。
## 13. 真实问题如何改变设计
| 真实现象 | 被证伪的假设 | 设计修正 |
|---|---|---|
| 空日志、通用知识仍不断改写查询 | 模型会自然意识到没有进展 | 明确信息增益义务和 Harness 饱和状态 |
| 最终以 BUDGET_EXHAUSTED 结束 | 硬预算可以充当正常停止 | 预算与信息饱和分离 |
| `REFERENCE` 非空结果持续触发查询 | 非空候选就是有价值证据 | 检索相关度与诊断增益分离 |
| 相同 scope 被反复执行 | Prompt 足以禁止重复 | Harness 参数级去重并在 backend 前拒绝 |
| 9 次协议拒绝仍消耗 13 轮模型 | 错误 observation 会让模型自修复 | 可修正反馈 + 独立协议阈值 + STOP_REQUIRED |
| 非法 Draft 使已完成检查丢失 | 只有合法最终 Draft 才有用户价值 | 已验真 ProgressSnapshot 可形成过程型 Fallback |
| 缺少企业/时间仍被迫调用 Tool | Tool 调用次数大于零才算诊断 | 允许零 Tool、missing context 合法结束 |
## 14. 代价与当前边界
1. 模型侧 Tool Schema 增加了 `previous_observation + input`,这是明确的 Agent-facing 协议变化。
2. 首版只能确定性识别参数相同的重复 scope,不能阻止所有自然语言近义改写。
3. 默认连续 NO_GAIN 阈值 2 是工程起点,需要依靠固定评测集校准;太小会过早停止,太大会增加空转。
4. 最后一轮非空 Tool 结果如果模型直接输出 Draft,Tracker 不强制收集其 information gain;这是减少无意义协议轮次的主动取舍。
5. ProgressSnapshot 依赖 canonical record 仍在 TTL 内且可解析,无法验真的进展不会被发布。
6. 受控预算停止只有在已有安全进展时才能转为 Fallback;没有可验证内容仍然 fail closed。
## 15. 如何验证
| 需要证明 | 代表性测试或 E2E |
|---|---|
| NO_EVIDENCE 自动累计 NO_GAIN,GAINED 清零 | `DiagnosisProgressTrackerTest` |
| 重复 scope 在 backend 前被拒绝 | `HarnessToolInterceptorTest`、`ToolScopeNormalizerTest` |
| pending evaluation 的缺失、乱序和意外回传被拒绝 | Interceptor protocol focused cases |
| 连续协议错误达到阈值并只交付一次 STOP_REQUIRED | Tracker + Interceptor tests |
| 模型主动停止、饱和停止和预算停止都能进入 Release | `DiagnosisAgentUseCaseTest`、`DiagnosisReleaseUseCaseTest` |
| 非法 Draft 只有在存在安全进展时降级 | `DiagnosisChatExecutorTest` |
| Tool 拒绝与实际 Tool 执行分开审计 | exact-run Trace |
| 未知 Query 不再以通用内部错误结束 | ISS-016 named SSE E2E |
其中一条 live E2E 曾准确暴露“协议拒绝不累计 NO_GAIN”的盲区。这说明停止机制不能只验证最终 SSE,还要核对模型轮次、Tool 实际执行数、Tool 拒绝数、Token 和 Timeline 序列。
## 16. 与另外两项设计的关系
信息增益依赖 [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)提供客观 evidence status、scope 和有界 observation;停止后的 ProgressSnapshot 和最终 Fallback 依赖[证据安全链](Harness证据安全链-从引用真实到结论可发布.md)确保只发布当前 Run 可验证的事实。
三者组合后,Harness 才能完整回答:模型看到了什么、为什么继续或停止、最终哪些内容可以发布。
@@ -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,165 @@
# Harness 执行控制笔记:终态检查与取消广播
**用途**:面试复习用。回答"一次 Run 的执行如何被控制、取消如何生效、为什么是协作式"。
**代码基线**:`com.superbiz.agent.harness.core` + `guard/semantic/GuardModelCall` + `tool/mysql/JdbcMysqlReadOnlyExecutor`
**配套**:[RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md)
## 1. 一句话核心
> 执行控制由三个句柄组成:RunBudget 管"还能不能花"、RunCancellation 管"要不要停"、RunLifecycle 管"最终怎么定"。判断停止的机制有两套:**checkActive 轮询检查点**(读终态)和 **onCancel 订阅广播**(推送中断)——前者让"到了检查点的调用"被拒绝,后者让"正在阻塞的操作"被实时打断。
## 2. 执行控制三件套
| 句柄 | 回答的问题 | 关键机制 | 本质 |
|---|---|---|---|
| RunBudget | 还能不能继续消耗 | 调用前预扣,超限抛异常 | 门禁(止损) |
| RunCancellation | 是否要求停止 | first-reason-wins + 回调广播 | 事件(信号) |
| RunLifecycle | 最终哪个终态生效 | first-terminal-wins(CAS) | 事实(结果) |
**预扣 vs 记账 vs 落定**:预算在调用前拦,取消在运行中广播,终态在结束时定死。
## 3. checkActive:三层闸门(轮询)
`DiagnosisHarnessCore.checkActive` 在**每次模型/Tool 调用前**执行,顺序固定:
```java
① termination 已存在 → 抛 RunAbortedException // 已终态,无论什么原因
② deadline 已过 → finish(TIMED_OUT) + cancel(DEADLINE_EXCEEDED) + 抛异常
// 超时是主动动作:自己写终态、自己广播,不是等别人来
③ cancellation.isCancelled() → 抛 RunAbortedException
```
- 调用点:`beforeModelCall`(每轮模型)、`beforeToolCall`(每次 Tool)、`reserveRunBytes`(canonical 体积)
- 它是**轮询**:只在检查点生效。正在阻塞的操作(模型等待、SQL 查询)不会自己撞上它。
## 4. termination:终结事实快照
`RunLifecycle` 持有 `AtomicReference<RunTermination>`:
```java
record RunTermination(RunState state, String reason, Instant completedAt)
// 构造校验:state 必须 isTerminal(),reason 非空
// null = 还在 RUNNING;非 null = 已终结,不可变
```
- 终态只有 5 个:`SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED`(`RUNNING` 非终态)
- 写入后永远定格,只能靠 CAS 换整个引用 → first-terminal-wins 的物理基础
- checkActive 第一道闸就是读它
## 5. 两个正交的 CAS
| 门 | 保护什么 | 语义 |
|---|---|---|
| `RunCancellation.reason`(AtomicReference) | **原因**:谁要求停、为什么停 | first-reason-wins |
| `RunLifecycle.termination`(AtomicReference) | **结果**:最终终态 | first-terminal-wins |
```java
// cancel() 两件事:写原因(CAS)+ 遍历 callbacks 广播
public boolean cancel(RunCancellationReason reason) {
if (!this.reason.compareAndSet(null, reason)) return false; // first-reason-wins
callbacks.forEach(...); // 广播给订阅者
return true;
}
// finish() 一件事:写终态(CAS)
public boolean finish(RunState state, String reason) {
return termination.compareAndSet(null, new RunTermination(state, reason, now));
}
```
**为什么不能合并**:
- 取消是"意图/原因"(可被多线程同时请求、可被订阅),终态是"结果/事实"(只读、不可变)
- 原因→终态是**多对一**映射:`DEADLINE_EXCEEDED→TIMED_OUT`、`INTERNAL_FAILURE→FAILED`、`CLIENT_DISCONNECTED/USER_REQUESTED→CANCELLED`
- 完成路径(`completeSuccess`/`completeFailure`)根本不经过 cancel;run 已终态时取消请求被拒绝(不翻案)
## 6. 取消广播:为什么必须有它(推送 vs 轮询)
只设置终态,只能让**下一次 checkActive** 拒绝——正在阻塞的操作不会自己醒来。广播通过 **onCancel 回调**直接打断阻塞中的操作。
代码里真实的订阅者只有三处:
| 订阅者 | 回调动作 | 打断机制 |
|---|---|---|
| Core `startRun` | `lifecycle.finish(terminalState(reason))` | 终态联动 |
| `GuardModelCall` | `future.cancel(true)` | 线程 interrupt |
| `JdbcMysqlReadOnlyExecutor` | `statement.cancel()` | JDBC 协议取消 |
## 7. 打断机制:两种物理中断
**机制一:Java 线程中断(`Future.cancel(true)`)**
```java
Future<String> future = executor.submit(() -> invoke(...)); // Guard 任务在独立线程池
context.cancellation().onCancel(ignored -> future.cancel(true)); // 订阅
return future.get(timeout, TimeUnit.NANOSECONDS); // 业务线程阻塞等待
```
取消线程执行回调 → `future.cancel(true)` → 向执行任务的线程发 `Thread.interrupt()` → 目标线程若阻塞在可中断等待则立刻抛 `InterruptedException` / `CancellationException` 醒来 → catch 后 `checkActive` → `RunAbortedException`。
**机制二:JDBC 协议取消(`Statement.cancel()`)**
```java
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
context.cancellation().onCancel(ignored -> cancel(statementRef.get())); // 订阅
... executeQuery() ...
finally { statementRef.set(null); }
```
取消线程 → `statement.cancel()` → 向 MySQL 服务器发取消请求 → 服务器终止查询 → 客户端 `executeQuery` 抛 `SQLException` 醒来。不走线程中断,走数据库协议,更"物理"。
**细节**:
- `statementRef` 用 AtomicReference 包:回调可能在执行前/中/后触发,`finally` 里 `set(null)`,读到 null 说明已结束、跳过 cancel
- `future.cancel()` 幂等,对已完成 Future 调用无害,Guard 侧无需此保护
- 被打断不是"裸死":Guard catch `CancellationException` → `checkActive`;MySQL 抛 `SQLException` → ToolBoundary 记 ERROR——**醒来后仍走统一状态机**,取消不会产生绕过 Harness 的野异常
## 8. 协作式取消的精确边界
能否推送中断,取决于 **Harness 是否持有该调用的执行句柄**:
| 调用 | 谁发起 | Harness 有句柄吗 | 取消时 |
|---|---|---|---|
| Agent 模型调用 | 框架 ReAct 内部 | 无(拦截器只环绕) | 只能等下一次 checkActive 轮询 |
| Guard 模型调用 | Harness `executor.submit` | 有 Future | `future.cancel(true)` 推送中断 |
| MySQL 查询 | Harness 自己执行 | 有 Statement | `statement.cancel()` 推送中断 |
> "协作式" = 愿意被打断的(订阅了 onCancel 且持有句柄)实时打断;框架持有的 Agent loop 物理上无法打断,只能等检查点。但无论哪种,最终结果都受 first-terminal-wins 保护。
## 9. 为什么"先 finish 再 cancel"(二次 finish 无害)
`exhaustBudget` / 超时路径都执行"自己设终态 + 自己广播":
```java
lifecycle.finish(BUDGET_EXHAUSTED, ...); // ① 先固化事实(主操作,不依赖回调)
cancellation.cancel(BUDGET_EXHAUSTED); // ② 再广播信号(副作用)
```
- cancel 触发回调 → 回调里再次 `finish` → **CAS 失败返回 false** → 无害、被静默吸收
- 顺序意义:终态不依赖回调注册/执行;即使回调异常或重复触发,终态都已正确
- 两个 CAS 各自 first-wins,最终状态永远一致(见下表,任何组合都无害):
| finish CAS | cancel CAS | 结果 |
|---|---|---|
| 成功 | 成功 | 正常 |
| 成功 | 失败 | 终态正确,广播已由先前 cancel 触发过 |
| 失败 | 成功 | 已有更早终态,回调里 finish 失败无害 |
| 失败 | 失败 | 早已终结,本次调用本就不该发生 |
## 10. 面试话术(三段式)
**执行控制**:
> 执行控制由预算、取消、终态三个句柄组成,统一走 Core 的检查链:每轮模型或 Tool 调用前先 checkActive——终态存在就拒绝,超时就主动写 TIMED_OUT 并广播取消,已取消就拒绝;然后预算预扣,超限时把 BUDGET_EXHAUSTED 固化为终态并广播取消。预算管"能不能花",取消管"要不要停",终态管"最终怎么定"。
**取消广播**:
> 取消是协作式的。取消信号可能由容器线程注入(SseEmitter 断连回调调 core.cancel),CAS 写原因后同步遍历订阅者回调:Guard 模型调用中断自己的 Future、MySQL 中断自己的 Statement,让业务线程从阻塞中立刻醒来,再在下一个检查点被 RunAbortedException 拒绝。能否推送中断取决于 Harness 是否持有该调用的句柄——自己提交的调用(Guard/MySQL)能打断,框架持有的 Agent 模型调用只能等下一次 checkActive。
**为什么两个 CAS**:
> cancel 和 finish 是两条正交的通道:cancel 传原因和广播信号,finish 落最终事实。取消必须走 cancel 是因为要保留原因维度、要广播给运行中的组件;但终态又必须由 finish 直接保证,不能依赖回调。所以预算耗尽时两个都调——事实先定,信号随后,重复写终态被 CAS 吸收。
## 11. 代码位置索引
| 内容 | 位置 |
|---|---|
| RunContext 九成员 | `harness/core/RunContext.java` |
| checkActive / startRun / exhaustBudget | `harness/core/DiagnosisHarnessCore.java` |
| 终态 CAS | `harness/core/RunLifecycle.java` + `RunTermination.java` |
| 原因 CAS + 广播 | `harness/core/RunCancellation.java` |
| 预算预扣/记账 | `harness/core/RunBudget.java` + `RunCapacityCounter.java` |
| Guard 模型取消订阅 | `harness/guard/semantic/GuardModelCall.java:58` |
| MySQL 查询取消订阅 | `harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java:52` |
| 断连取消入口 | `controller/sse/ChatSseSession.java:44` → `harness/application/ChatApplicationUseCase.java:340` |
@@ -0,0 +1,440 @@
# Harness 生命周期与状态
**更新日期**:2026-07-29
**状态**:当前实现口径
**术语前置**:[CONTEXT.md](CONTEXT.md)
## 1. 先澄清:系统不存在一条包含所有状态的总状态机
当前 Harness 有多组正交状态:
- RunState 控制内存执行终态;
- ChatApplicationStatus 表示用户可见处理阶段;
- InvocationStatus 表示单次 Tool invocation 生命周期;
- EvidenceStatus 表示 Tool 客观结果;
- CollectionState 表示能否继续收集证据;
- StopReason 表示停止收集的内部原因;
- SemanticVerdict 表示结论支持度;
- ReleaseOutcome 表示最终发布结果;
- SSE Session State 表示连接能否继续发送。
它们在一次请求中并行演进,只在少数边界发生映射。生命周期文档的目标不是把它们合并,而是说明谁先发生、由谁拥有、在哪里汇合。
## 2. 一次 Run 的主时间线
```mermaid
sequenceDiagram
participant C as Client / SSE
participant A as ChatApplicationUseCase
participant H as Harness Core
participant R as Intent Router
participant D as Diagnosis Runtime
participant L as Release Pipeline
participant P as Persistence / Trace
C->>A: query + optional sessionId
A->>A: 读取 routing history / PreviousTurn
A->>H: startRun(sessionId)
H-->>A: RUNNING RunContext
A->>P: diagnosis_run=RUNNING + RUN_STARTED
A-->>C: metadata(sessionId, runId)
A->>R: route(query, bounded history)
R-->>A: IntentType
alt SYSTEM_CHAT
A->>A: 单轮受控模型回答
else KNOWLEDGE_QUERY
A->>A: 一次 RAG + 单轮答案
else DIAGNOSIS
A->>D: Agent ReAct / Tool / Progress
D-->>A: Draft 或 Controlled Stop
A->>L: release(Draft/Progress/StopReason)
L-->>A: SUCCESS Draft 或 SafeFallback
end
alt 正常完成
A->>H: completeSuccess(预算 Fallback 特例除外)
A->>P: 持久化 release outcome / safe content / usage
A->>P: RUN_FINISHED
A-->>C: content + done
else 失败
A->>H: completeFailure 或读取既有终态
A->>P: 持久化 FAILED/CANCELLED
A->>P: RUN_FINISHED
A-->>C: failure + done(FAILED)
end
```
创建顺序很重要:Application 先读取会话上下文,再创建 RunContext、持久化 RUNNING、记录 RUN_STARTED,之后才进入路由和执行。SSE 的 metadata 在 `onStarted` 中发布 exact sessionId/runId,后续结果必须匹配这组身份。
## 3. Run 生命周期
### 3.1 状态
```mermaid
stateDiagram-v2
[*] --> RUNNING
RUNNING --> SUCCESS: 正常路径完成
RUNNING --> FAILED: 不可恢复内部失败
RUNNING --> CANCELLED: 客户端断开或用户取消
RUNNING --> TIMED_OUT: deadline 到达
RUNNING --> BUDGET_EXHAUSTED: 任一硬预算超限
SUCCESS --> [*]
FAILED --> [*]
CANCELLED --> [*]
TIMED_OUT --> [*]
BUDGET_EXHAUSTED --> [*]
```
`RunLifecycle.finish` 使用原子 compare-and-set:只有从“尚无 termination”到某个终态的第一次转换成功。所有终态都不可再次转换。
### 3.2 状态含义
| RunState | 含义 | 常见来源 |
|---|---|---|
| `RUNNING` | 尚未产生 RunTermination | `startRun` 后默认状态 |
| `SUCCESS` | Application 已形成正常可持久化结果 | 正常内容,或非预算类 SafeFallback |
| `FAILED` | 不可恢复执行失败 | 路由不可用、模型失败、无安全进展的非法 Draft 等 |
| `CANCELLED` | 协作取消已成为第一个终态 | 客户端断开、用户请求 |
| `TIMED_OUT` | `checkActive` 发现 deadline 已到 | 模型/Tool/Guard 边界前后检查 |
| `BUDGET_EXHAUSTED` | 模型、Tool、Token 或 bytes 预算超限 | `DiagnosisHarnessCore.exhaustBudget` |
### 3.3 RunState 与 ReleaseOutcome 不一一对应
最重要的反例是 Fallback:
- 证据不足、缺少上下文、语义不支持等正常降级,RunState 最终为 `SUCCESS`,ReleaseOutcome 为 `FALLBACK`;
- 预算耗尽后已经有安全 ProgressSnapshot,RunState 保持 `BUDGET_EXHAUSTED`,但 ReleaseOutcome 可以为 `FALLBACK`;
- 超时或预算耗尽且无法形成安全内容时,RunState 分别保持 `TIMED_OUT / BUDGET_EXHAUSTED`,ReleaseOutcome 映射为 `FAILED`。
所以不能从 ReleaseOutcome 反推出精确 RunState,也不能把 `FALLBACK` 当作 RunState。
## 4. 取消生命周期
`RunCancellation` 使用 first-reason-wins。取消原因包括:
| Reason | RunState 映射 |
|---|---|
| `CLIENT_DISCONNECTED` | `CANCELLED` |
| `USER_REQUESTED` | `CANCELLED` |
| `DEADLINE_EXCEEDED` | `TIMED_OUT` |
| `BUDGET_EXHAUSTED` | `BUDGET_EXHAUSTED` |
| `INTERNAL_FAILURE` | `FAILED` |
生命周期和取消句柄互相配合:取消回调尝试完成 RunLifecycle;deadline 和预算路径也会先确定终态,再触发取消阻止后续工作。
取消语义是协作式的:
1. 后续模型、Tool、Guard 边界调用 `checkActive` 时立即失败;
2. SSE 断开后不再发送 content;
3. first-terminal-wins 阻止迟到成功覆盖 CANCELLED/TIMED_OUT;
4. 已经进入 Provider 的同步调用是否物理停止,取决于底层客户端,Harness 不作虚假保证。
## 5. Application 阶段不是生命周期状态
`ChatApplicationStatus` 用于 SSE status 事件:
```text
ROUTING
SYSTEM_RESPONDING
KNOWLEDGE_SEARCHING
KNOWLEDGE_ANSWERING
DIAGNOSIS_RUNNING
SAFETY_VALIDATING
```
这些值只是用户可见的处理阶段:
- 不是严格完备的状态机;
- 不表示终态;
- 不持有取消或预算;
- 不保证每个 Run 都经过全部阶段。
例如 Diagnosis Run 通常经过 `ROUTING -> DIAGNOSIS_RUNNING -> SAFETY_VALIDATING`,System Chat 则经过 `ROUTING -> SYSTEM_RESPONDING`。
## 6. Diagnosis Agent 执行生命周期
`DiagnosisAgentExecution` 只有两种合法形态,不额外定义一套枚举:
| 形态 | 字段 | 含义 |
|---|---|---|
| Completed | `draft != null, stopReason=null` | Agent 输出严格合法 DiagnosisDraft |
| Controlled Stop | `draft=null, stopReason != null` | Tool loop 因信息饱和、预算或协议错误受控停止 |
其他情况通过异常表达:
- 空 Draft;
- 非法 JSON;
- Schema 不合法;
- 模型调用失败;
- RunAborted。
Draft 合同失败不是 DiagnosisStopReason。非法 Draft 会被丢弃;只有异常携带的 ProgressSnapshot 已包含可验真 facts,Application 才允许 Release 生成过程型 Fallback。
## 7. 单次 Tool Invocation 生命周期
### 7.1 状态转换
```mermaid
stateDiagram-v2
[*] --> PREFLIGHT
PREFLIGHT --> REJECTED: invalid run/id/auth/readonly/request/budget/store
PREFLIGHT --> PROJECTING: canonical begin 成功
PROJECTING --> READY: backend + projection + store 成功
PROJECTING --> ERROR: execution/projection/size/budget/store error
READY --> [*]
ERROR --> [*]
REJECTED --> [*]
```
`PREFLIGHT` 和 `REJECTED` 是本文用于解释流程的阶段,不是 `InvocationStatus` 枚举值。canonical record 只有:
- `PROJECTING`:已创建,尚未形成可引用结果;
- `READY`:执行和投影完成,可以被当前 Run 引用;
- `ERROR`:调用失败,不可引用。
部分 preflight 失败发生在 canonical begin 之前,因此可能只有 `ToolBoundaryResult.ERROR` 和审计事件,没有 canonical ERROR record。
### 7.2 EvidenceStatus 是另一条轴
| InvocationStatus | EvidenceStatus | 语义 |
|---|---|---|
| `PROJECTING` | `null` | 未完成 |
| `READY` | `EVIDENCE_FOUND` | 技术完成,存在候选内容 |
| `READY` | `NO_EVIDENCE` | 技术完成,当前 scope 为空 |
| `ERROR` | `ERROR` | 技术失败 |
`READY + EVIDENCE_FOUND` 仍不表示证据足以支持结论;后续还要经过 InformationGain、EvidenceGuard 和 SemanticGuard。
## 8. 信息收集生命周期
### 8.1 Collection State
```mermaid
stateDiagram-v2
[*] --> COLLECTING
COLLECTING --> COLLECTING: GAINED / 清零 no-gain
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
COLLECTING --> SATURATED: 连续 NO_GAIN 达阈值
COLLECTING --> SATURATED: 连续 progress protocol violation 达阈值
SATURATED --> SATURATED: 终态,不再允许新 evidence Tool
```
`SATURATED` 只表示证据收集不再允许继续。它不是整个 Run 的终态,Agent 仍有一次机会输出 Draft,之后进入 Release。
预算超限不会把 CollectionState 改成 SATURATED。它将 RunState 置为 `BUDGET_EXHAUSTED`,并把内部 DiagnosisStopReason 标记为 `BUDGET_LIMIT_REACHED`。
### 8.2 InformationGain
- `NO_EVIDENCE` 和完全重复的 `tool_name + normalized_scope` 由 Harness 产生 `NO_GAIN`;
- 其他成功非空结果由 Diagnosis Agent 评价 `GAINED / NO_GAIN`;
- `GAINED` 清零连续 NO_GAIN;
- 技术失败和 progress 协议错误不产生 InformationGain。
### 8.3 StopReason
| DiagnosisStopReason | 触发条件 | 对 CollectionState 的影响 |
|---|---|---|
| `INFORMATION_SATURATED` | 连续 NO_GAIN 达阈值 | 进入 SATURATED |
| `PROGRESS_PROTOCOL_VIOLATED` | 连续协议错误达阈值 | 进入 SATURATED |
| `BUDGET_LIMIT_REACHED` | RunBudget 超限 | 不要求进入 SATURATED;Run 已终止 |
STOP_REQUIRED 是否已经交付由独立 boolean 记录,它不是第四种 CollectionState。一次指令交付后仍请求 Tool,会抛出受控停止异常。
## 9. Guard 与 Release 生命周期
```mermaid
flowchart TD
I["DiagnosisAgentExecution"] --> D{"有合法 Draft?"}
D -->|"否,受控停止"| PS["验证 ProgressSnapshot"]
D -->|"是,无 conclusion"| NC["验证已有 Tool references"]
D -->|"是,有 conclusion"| EG["EvidenceGuard initial"]
EG --> EV{"evidence valid?"}
EV -->|"否"| ER["EvidenceRepair once"]
ER --> RE["EvidenceGuard recheck"]
EV -->|"是"| SG["SemanticGuard"]
RE -->|"valid"| SG
RE -->|"invalid"| EF["EVIDENCE_VALIDATION_FAILED"]
SG -->|"SUPPORTED"| SU["ReleaseOutcome.SUCCESS"]
SG -->|"UNSUPPORTED"| SF["SEMANTIC_UNSUPPORTED"]
SG -->|"unavailable"| UF["SEMANTIC_UNAVAILABLE"]
NC -->|"有 progress"| IF["INSUFFICIENT_EVIDENCE"]
NC -->|"无 progress,有 missing_info"| MF["MISSING_REQUIRED_CONTEXT"]
NC -->|"两者都无"| FAIL["FAILED"]
PS -->|"有 verified facts"| IF
PS -->|"无 verified facts"| FAIL
EF --> FB["ReleaseOutcome.FALLBACK"]
SF --> FB
UF --> FB
IF --> FB
MF --> FB
```
`DiagnosisReleaseResult` 只表达 `SUCCESS` 或 `FALLBACK`。真正不可恢复的 `FAILED / CANCELLED` 由 Chat Application 异常路径映射。
## 10. RunState、ReleaseOutcome 和 FallbackType 映射
| 场景 | RunState | ReleaseOutcome | FallbackType / 内容 |
|---|---|---|---|
| 有结论且 Guards 通过 | `SUCCESS` | `SUCCESS` | Diagnosis report |
| 缺少必要上下文,零 Tool 合法结束 | `SUCCESS` | `FALLBACK` | `MISSING_REQUIRED_CONTEXT` |
| 有限检查后证据不足 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
| 信息饱和后有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
| 协议停止后有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` |
| SemanticGuard 判定不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` |
| SemanticGuard 技术不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` |
| 预算耗尽但有安全进展 | `BUDGET_EXHAUSTED` | `FALLBACK` | 当前发布 `INSUFFICIENT_EVIDENCE` |
| 超时,无法形成安全内容 | `TIMED_OUT` | `FAILED` | failure |
| 预算耗尽且无安全进展 | `BUDGET_EXHAUSTED` | `FAILED` | failure |
| 不可恢复内部失败 | `FAILED` | `FAILED` | failure |
| 客户端断开 | `CANCELLED` | `CANCELLED` | 不公开 CANCELLED done |
这张表解释了为什么不能只问“最后 status 是什么”。必须先确定是在问内存执行、发布结果还是 Fallback 原因。
## 11. SSE 连接生命周期
`ChatSseSession` 有自己的连接状态机:
```mermaid
stateDiagram-v2
[*] --> NEW
NEW --> OPEN: onStarted / metadata
NEW --> DISCONNECTED: 客户端提前断开
OPEN --> OPEN: status events
OPEN --> TERMINAL: content + done
OPEN --> TERMINAL: failure + done(FAILED)
OPEN --> DISCONNECTED: send failure / disconnect
TERMINAL --> [*]
DISCONNECTED --> [*]
```
公开事件顺序是:
```text
metadata -> status* -> content | failure -> done
```
边界规则:
- content 最多发送一次;
- result 的 sessionId/runId 必须匹配 metadata;
- TERMINAL 或 DISCONNECTED 后拒绝迟到内容;
- `ReleaseOutcome.CANCELLED` 不作为公开 done outcome;客户端已经断开时没有可靠发送目标;
- 当前 `SseOutcome` 枚举未参与运行时协议,`ChatSseEvent.Done` 使用 `ReleaseOutcome`。
## 12. 持久化生命周期
### 12.1 创建
RunContext 创建后,Application 写入:
```text
diagnosis_run.status = RUNNING
session_id / run_id / query
```
之后记录 intent。
### 12.2 完成映射
`JpaChatRunStore` 根据 ReleaseOutcome 映射数据库通用 status:
| ReleaseOutcome | diagnosis_run.status |
|---|---|
| `SUCCESS` | `SUCCESS` |
| `FALLBACK` | `SUCCESS` |
| `FAILED` | `FAILED` |
| `CANCELLED` | `CANCELLED` |
这里的 `status=SUCCESS` 表示请求已被正常处理并形成安全内容,不表示一定有诊断 conclusion。
同时保存:
- `release_outcome`;
- safe answer JSON;
- 从 safe content 提取的 conclusion;
- duration、Token、Agent step 数和实际 Tool call 数;
- 仅在 `DIAGNOSIS + SUCCESS` 时保存 `published_result`。
Fallback 不进入下一轮 PreviousTurn。
### 12.3 当前可观测缺口
内存 `RunTermination.state/reason` 当前没有独立字段直接持久化。`RUN_FINISHED` 主要记录 ReleaseOutcome 和预算对账,精确的 TIMED_OUT/BUDGET_EXHAUSTED 原因需要结合异常路径和其他 Trace 事件判断。
因此数据库 `status`、ReleaseOutcome 和 Trace 都是必要观察面,任何一个都不是完整替代品。
## 13. Trace Timeline 如何对应生命周期
典型 Diagnosis SUCCESS:
```text
RUN_STARTED
ROUTING_ATTEMPT
ROUTING_DECISION
AGENT_MODEL_STEP
TOOL_INVOCATION / TOOL_PROGRESS ...
EVIDENCE_GUARD_INITIAL
SEMANTIC_GUARD_ATTEMPT
SEMANTIC_GUARD_DECISION
RELEASE_DECISION SUCCESS
RUN_FINISHED SUCCESS
```
典型信息不足 Fallback:
```text
RUN_STARTED
ROUTING_ATTEMPT
ROUTING_DECISION
AGENT_MODEL_STEP
TOOL_INVOCATION / TOOL_PROGRESS ...
COLLECTION_STOP(可选)
EVIDENCE_GUARD_INITIAL(无结论引用检查)
RELEASE_DECISION FALLBACK
RUN_FINISHED FALLBACK
```
`TracePhase` 和 `TraceEventStatus` 用来组织 Timeline。它们描述事件发生在哪一阶段、该事件结果如何,不构成新的 Run 生命周期。
## 14. 并发和迟到结果规则
一次 Run 的终止安全依赖三层协作:
1. `RunLifecycle`:first-terminal-wins,终态不可覆盖;
2. `DiagnosisHarnessCore.checkActive`:每个关键边界阻止终止后的新工作;
3. `ChatSseSession`:TERMINAL/DISCONNECTED 后拒绝迟到 content。
这能保证逻辑上的“取消后不再发布”。它不等于强制终止底层线程或 Provider 计算;底层调用返回后仍要经过 active 和 SSE state 检查,迟到结果才会被丢弃。
## 15. 排障时应该先看哪个状态
| 问题 | 首先看 | 然后看 |
|---|---|---|
| 用户为什么收到 Fallback | `release_outcome + FallbackType` | RELEASE/EVIDENCE/SEMANTIC Trace |
| Agent 为什么停止调用 Tool | `DiagnosisStopReason` | TOOL_PROGRESS、TOOL_REQUEST_REJECTED、COLLECTION_STOP |
| Tool 为什么没有证据 | `InvocationStatus + EvidenceStatus` | canonical record 和 Tool audit error code |
| Run 是超时还是预算耗尽 | 内存 termination(运行中)或相关 Trace/异常 | budget usage、collection stop、失败码 |
| 数据库为什么 status=SUCCESS 但没有结论 | `release_outcome` | answer 的 content type / FallbackType |
| 为什么没有 SSE done | `ChatSseSession.State` | client disconnect/send failure 和 Run cancellation |
| 为什么下一轮没有上一轮上下文 | 是否 `DIAGNOSIS + ReleaseOutcome.SUCCESS + published_result` | PublishedResultPolicy |
## 16. 生命周期不变量
1. 一个 Run 只能有一个 RunTermination。
2. 一个公开 SSE 最多有一次 content/failure 和一次 done。
3. Tool Invocation 只有 READY 才能被引用。
4. READY 必须同时拥有 `EVIDENCE_FOUND` 或 `NO_EVIDENCE`。
5. `NO_EVIDENCE` 不能被提升为全局否定。
6. SATURATED 后不再执行新的 evidence Tool。
7. StopReason 不直接决定公开内容,必须经过 Release。
8. 有 conclusion 才执行完整 EvidenceGuard/Repair/SemanticGuard 链。
9. FALLBACK 是正常发布结果,不等于 RunState.FAILED。
10. 数据库 status、ReleaseOutcome 和 RunState 不能互相替代。
@@ -0,0 +1,374 @@
# Harness 组件全景:职责、设计原因与边界
**更新日期**:2026-07-29
**适用代码**:`src/main/java/com/superbiz/agent/harness`
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**配套主文**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
> 本文是完整组件参考手册,不建议第一次接触 Harness 时顺序阅读。入门请先读 [Harness 阅读入口](README.md),需要逐组理解组件时使用[组件渐进式导读](components/README.md)。
## 1. 这份文档怎样定义“全部组件”
当前 `harness` 目录包含 10 个一级职责域、189 个 Java 源文件。它们并不都是独立运行的“服务”:
- **执行组件**拥有行为,例如 Core、Interceptor、Boundary、Guard、Release、Executor;
- **端口与适配器**隔离框架、Redis、JPA、JDBC 和业务 Tool;
- **状态与契约类型**固定跨组件语言,防止字符串协议漂移;
- **Limits、Prompt 和异常类型**把边界配置与失败语义显式化。
因此本篇先解释 10 个职责域为什么存在,再列出每个生产类型。判断某个类应该放在哪里时,只问三个问题:它拥有什么状态、它能作出什么决定、它绝不能决定什么。
## 2. 组件地图
| 职责域 | 文件数 | 解决的问题 | 核心组件 |
|---|---:|---|---|
| `application` | 34 | 谁创建 Run、路由请求、持久化和映射公开结果 | `ChatApplicationUseCase`、三个 Executor、`ChatRunStore` |
| `core` | 14 | deadline、取消、预算和唯一终态由谁拥有 | `DiagnosisHarnessCore`、`RunContext`、`RunBudget`、`RunLifecycle` |
| `agent` | 17 | 如何把框架 ReAct 接入 Harness,而不复制 ReAct | `DiagnosisAgentUseCase`、Factory、Model/Tool Interceptor |
| `progress` | 14 | 如何识别无增益、重复和协议空转 | `DiagnosisProgressTracker`、Projector、scope normalizer |
| `tool` | 49 | Tool 如何安全执行、保存真相并只暴露必要内容 | `ToolBoundary`、Adapters、Projectors、Canonical Store、MySQL sandbox |
| `guard` | 15 | 如何分开验证引用真实性和结论支持度 | `EvidenceGuard`、`SemanticGuard`、`GuardModelCall` |
| `release` | 6 | 谁拥有最终 SUCCESS / FALLBACK 决策 | `DiagnosisReleaseUseCase`、`EvidenceRepair`、`SafeFallbackFactory` |
| `retry` | 8 | 哪些失败允许重试、attempt 如何可见 | `HarnessRetryExecutor`、Policies、Failure taxonomy |
| `audit` | 17 | 如何重放决策而不复制敏感正文 | Trace、Tool audit、Model ledger、Agent hook |
| `contract` | 15 | 跨层公开语言如何保持类型化 | Draft、PublishedResult、Fallback、状态枚举 |
```mermaid
flowchart LR
APP["application<br/>Run 与公开用例"] --> CORE["core<br/>执行不变量"]
APP --> AGENT["agent<br/>ReAct 接入"]
AGENT --> PROGRESS["progress<br/>收敛控制"]
AGENT --> TOOL["tool<br/>证据边界"]
APP --> RELEASE["release<br/>唯一发布"]
RELEASE --> GUARD["guard<br/>真实性与支持度"]
CORE --> RETRY["retry<br/>显式 attempt"]
CORE -.-> AUDIT["audit<br/>可观测账本"]
AGENT -.-> AUDIT
TOOL -.-> AUDIT
RELEASE -.-> AUDIT
CONTRACT["contract<br/>类型化语言"] -.-> APP
CONTRACT -.-> AGENT
CONTRACT -.-> TOOL
CONTRACT -.-> GUARD
CONTRACT -.-> RELEASE
```
## 3. Application:Run 的应用所有者
### 为什么需要
Core 只知道一次 Run 是否活跃,并不知道 HTTP、SSE、意图路由、数据库持久化和上一轮上下文。若这些职责塞进 Core,Harness 会变成业务工作流引擎;若散落在 Controller,则每个入口都可能产生不同的终态和 Fallback。
### 核心组件
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `ChatApplicationUseCase` | 为一次请求建立唯一应用事务边界 | 解析 session、读取历史、创建 Run、路由、执行分支、落终态和安全输出 | 不做诊断推理,不自行构造诊断 Fallback |
| `IntentRouter` | 路由也会消耗模型、超时并返回非法 JSON | 在有界输入、timeout 和显式 retry 下输出唯一 `IntentType` | 不执行业务 Tool,不生成最终回答 |
| `SystemChatExecutor` | 系统问答不需要 ReAct,但仍必须受模型预算控制 | 单轮回答产品能力和闲聊 | 不声称查询了实时数据 |
| `KnowledgeQueryExecutor` | 知识问答需要一次 RAG 和一次受控生成,但不需要完整诊断链 | 调用知识 Tool、校验 Tool result、生成带来源答案 | 首版不进入 SemanticGuard,不执行多 Tool 诊断 |
| `DiagnosisChatExecutor` | Agent 执行和安全发布需要一个明确接合点 | 执行 Agent、处理合法停止/非法 Draft、调用 Release、映射公开内容 | 不重复 Guard 或 Release 决策 |
| `ChatRunStore` / `JpaChatRunStore` | 内存 Run 状态与长期数据库状态职责不同 | 保存 Run 开始、intent、结果、预算摘要;读取安全上一轮 | 不保存 canonical raw;只允许安全发布结果进入 PreviousTurn |
| `PublishedResultPolicy` | 直接复用上一轮完整结果会让上下文无限增长并传播失败内容 | 生成可持久化 PublishedResult 和有界 PreviousTurn | Fallback、失败、raw evidence 不进入下一轮 |
### 类型清单
| 类型 | 分类与功能 |
|---|---|
| `ChatApplicationRequest`、`ChatApplicationResult` | 应用入口和出口 DTO;固定 session、run、intent、outcome 和内容类型 |
| `ChatApplicationContent`、`ChatContentType` | 公开内容的 sealed/typed 边界,避免任意对象直接发给 SSE |
| `DiagnosisContent`、`KnowledgeContent`、`SystemChatContent`、`FallbackContent` | 四种公开内容载体;分别包装安全诊断、知识答案、系统回答和 Fallback |
| `ChatApplicationStatus`、`ChatApplicationObserver` | 向 SSE 报告有界阶段,不泄露模型内部步骤 |
| `ChatRunControl` | 只向入口暴露 exact session/run 和客户端断开取消能力 |
| `ChatApplicationException`、`ChatFailureCode` | 把内部异常映射为稳定、可公开的失败语义 |
| `IntentRouting`、`SystemChatOperation`、`KnowledgeQueryOperation`、`DiagnosisOperation` | 四个应用端口;使主用例不依赖具体模型或执行器 |
| `DiagnosisExecutionResult` | Diagnosis 分支的内部返回,携带 outcome、content、published result 和预算已处理标记 |
| `IntentRouterInput`、`IntentRouterLimits`、`IntentRouterPrompt`、`IntentRoutingException` | 路由输入、边界、Prompt 和失败类型 |
| `KnowledgeQueryLimits`、`SingleTurnExecutorLimits` | 知识与单轮模型路径的输入、输出、timeout 上限 |
| `ChatRunStore`、`RoutingHistory` | 持久化端口及最小路由历史 |
| `PreviousTurnLimits`、`PublishedResultPolicy` | 安全历史的字段/大小限制与投影策略 |
## 4. Core:每个 Run 的执行不变量
### 为什么需要
模型、Tool、Guard 和 Application 都要检查取消、deadline 和预算。如果每层各自维护计数或终态,会出现多个真相源;如果依赖 ThreadLocal,则异步线程无法可靠继承。
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `DiagnosisHarnessCore` | 所有执行边界需要同一套 active / budget / terminal 规则 | 创建 RunContext,执行模型/Tool/Token/bytes 门禁,处理取消和终态 | 不持久化,不调用 Agent/Tool,不维护全局 Run Map |
| `RunContext` | Run 身份和状态句柄必须一起显式传播 | 固定 sessionId、runId、deadline 及 per-run handles | record 结构不可变,不代表内部计数不变化 |
| `RunLifecycle` | 成功、失败、取消可能竞态到达 | 原子 `compareAndSet` 实现 first-terminal-wins | 不映射公开 ReleaseOutcome |
| `RunCancellation` | 取消原因和回调只能被第一个请求确定 | first-reason-wins,并通知 lifecycle/资源回调 | 不承诺强杀同步 Provider 请求 |
| `RunBudget` | 多维预算必须原子地先检查再计数 | 模型、Tool、单 Tool、输入/输出/总 Token 和 bytes 计量 | 不判断信息是否有价值 |
| `RunCapacityCounter` | bytes 可能由并发边界累计 | CAS 方式维护 Run 总容量 | 不负责字段级截断策略 |
### 类型清单
| 类型 | 分类与功能 |
|---|---|
| `RunBudgetLimits`、`RunBudgetUsage` | 预算配置和值快照;区分限制与已使用量 |
| `BudgetKind`、`BudgetExceededException` | 明确指出耗尽的是模型、Tool、Token 还是 bytes |
| `RunState`、`RunTermination` | 内存执行状态与不可变终止快照 |
| `RunCancellationReason` | 客户端断开、用户请求、deadline、预算和内部失败的取消分类 |
| `RunAbortedException` | 将已经确定的 RunTermination 穿过深层调用栈,不丢失终态 |
## 5. Agent:框架 ReAct 与 Harness 的接合层
### 为什么需要
业务需要框架原生 Tool Calling 和 ReAct loop,但框架默认并不知道项目的 RunContext、预算、审计、Tool 双视图和停止协议。接合层的目标是“拦截边界”,不是重新实现 Agent 循环。
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `DiagnosisAgentFactory` | 每个 Run 的 interceptor 和 metadata 不同 | 为当前 Run 创建 ReactAgent,注册 Tool callback、Prompt、Hook 和 interceptor | 不缓存跨 Run Agent 状态 |
| `DiagnosisAgentUseCase` | 框架输入输出是字符串,业务要求严格 Draft 和 bytes 边界 | 序列化输入、显式注入 Run metadata、调用 Agent、严格解析 Draft、映射受控停止 | 不执行证据和语义校验 |
| `HarnessModelInterceptor` | 每一轮 ReAct 模型调用都必须进入预算和 Token 账本 | 调用前 reserve,调用后记录 Provider usage 并再次检查 active | 不重试模型 |
| `HarnessToolInterceptor` | 模型 Tool Call 中混有 Harness 进展协议和业务参数 | 校验 Envelope、上一轮增益、重复/饱和、调用 Tool、投影 observation、交付 STOP_REQUIRED | 不执行 backend,不复制 ToolBoundary 预算 |
| `HarnessEvidenceTools` | Tool schema 必须由服务端原生注册,且业务 Tool 可选启用 | 注册 RAG/log/MySQL callback,严格解析通用 Envelope,桥接 Adapter | Prompt 不写死 Tool schema;未配置 MySQL 时不暴露死 Tool |
| `ToolResultViewProjector` | canonical agent_result 仍含 Harness 控制字段 | 生成 Control View 和白名单 Model Observation | 不读取 raw response,不判断根因 |
### 类型清单
| 类型 | 分类与功能 |
|---|---|
| `DiagnosisAgentInput`、`DiagnosisAgentExecution` | Agent 用例输入,以及 Draft/ProgressSnapshot/stop reason 的执行结果 |
| `DiagnosisAgentLimits` | query、previous turn、总输入和 Draft 的 UTF-8 bytes 上限 |
| `DiagnosisAgentPrompt`、`DiagnosisDraftOutputSchema` | 最小职责 Prompt 与严格结构化输出 Schema |
| `EvidenceToolInvoker` | Agent 层到具体 Adapter 的函数端口 |
| `ParsedAgentToolCall` | 解包后的 previous observation、typed business input 和 JSON 参数 |
| `ToolControlView` | Harness 消费的 evidence status、count、scope 等控制视图 |
| `DiagnosisAgentLimitException` | Agent 输入或输出越界 |
| `DiagnosisAgentOutputException` | 空、非法 JSON、Schema 不合格 Draft,并可携带安全 ProgressSnapshot |
| `DiagnosisCollectionStoppedException` | STOP_REQUIRED 后仍请求 Tool 时,把受控停止穿出框架 loop |
## 6. Progress:从资源上限到正常收敛
### 为什么需要
预算只能阻止无限消耗,不能识别“连续查询没有推进诊断”。Progress 子系统只保存 Run 内最小控制状态,不复制完整证据。
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `DiagnosisProgressTracker` | 连续无增益、待评价调用和协议错误需要线程安全单一所有者 | 记录 completed scope、pending evaluation、NO_GAIN、协议错误、饱和和一次停止指令 | 不保存 raw/agent_result,不判断非空内容的业务价值 |
| `ToolScopeNormalizer` | 字段顺序或无关格式不应绕过重复检测 | 将各 Tool typed input 规范化为稳定 scope | 首版不做自然语言语义去重 |
| `DiagnosisProgressProjector` | 受控停止或非法 Draft 后仍需安全说明已检查内容 | 按 Tracker identity 回读 canonical READY 记录,生成有界事实、来源和限制 | 无法验真的记录直接排除,不输出 raw |
| `DiagnosisProgressProjection` | Agent 用例不应依赖具体 Redis projector | 定义 Run 到安全快照的端口,并提供 empty 实现 | 不决定 Fallback 类型 |
### 类型清单
| 类型 | 分类与功能 |
|---|---|
| `InformationGain` | 仅 `GAINED / NO_GAIN`,避免引入含混质量等级 |
| `DiagnosisCollectionState` | `COLLECTING / SATURATED`,只描述信息收集状态 |
| `DiagnosisStopReason` | 区分信息饱和、预算限制和进展协议错误 |
| `PreviousObservation` | 模型在下一次 Tool Call 回传上一轮 `tool_call_id + information_gain` |
| `CompletedToolCall`、`ToolScopeIdentity` | 保存已完成调用的 identity 和规范化 scope,不保存 payload |
| `DiagnosisProgressSnapshotState` | Tracker 的内部控制快照,含计数、pending ID 和停止指令状态 |
| `DiagnosisProgressSnapshot` | Release 可消费的安全快照,含 verified sources、observed facts 和 limitations |
| `ProgressProtocolViolationType`、`ProgressProtocolViolationException` | 缺字段、乱序评价、意外评价和非法 Envelope 的稳定分类 |
## 7. Tool:执行、真相、投影与后端安全
Tool 是文件最多的职责域,但可以按四层理解。
### 7.1 Boundary:所有 Tool 共用的确定性入口
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `ToolBoundary` | 每个 Adapter 自己实现授权、预算、store 和 audit 会产生漂移 | 统一 preflight、active、read-only、Tool budget、bytes、canonical 状态迁移和 audit | 不理解 Tool 业务内容;不做信息增益判断 |
| `ToolCallRequestEnvelope` | 调用必须同时证明 Run、ID、Tool、参数、授权和只读意图 | Boundary 的内部调用信封 | 不等同于模型侧 progress Envelope |
| `ToolExecutor` | Boundary 不依赖具体 backend | raw 执行函数端口 | 不投影结果 |
| `ToolResultProjector` | raw 到 canonical agent_result 的逻辑因 Tool 而异 | 标准化、限制、计算客观 evidence status | 不判断结论支持度 |
| `ToolBoundaryResult` | 只允许 READY 或 ERROR 离开 Boundary | 向上返回 ID、状态、agent result、evidence status 或稳定错误码 | PROJECTING 不对外暴露 |
| `ProjectedToolResult` | projector 同时返回有界 agent result 与客观状态 | Boundary/store 的中间值 | 不是最终 Model Observation |
| `ToolBoundaryErrorCode` | 不能把内部异常正文交给 Agent | 固定非法 ID、Run mismatch、未授权、非只读、超限、执行/投影/store 等错误 | 不包含敏感原因 |
### 7.2 Canonical Store:TTL 内的完整 Tool 真相
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `CanonicalInvocationStore` | Guard 需要独立于 Agent 上下文读取原始调用真相 | 定义 begin/find/markReady/markError 状态端口 | 不负责长期审计 |
| `RedisCanonicalInvocationStore` | 完整 request/raw 有敏感性和容量,适合短期 TTL 存储 | 原子创建,保持剩余 TTL 的状态更新,读取不续期 | 只有该 Adapter 访问 Redis |
| `CanonicalToolInvocation` | request、raw、agent_result 和两个状态必须形成合法组合 | 封装 PROJECTING -> READY/ERROR 转换及 Run 可引用判断 | ERROR/PROJECTING 不可被 EvidenceGuard 引用 |
| `CanonicalInvocationLimits` | Redis record、raw 候选和 agent result 需要独立硬限制 | 统一 UTF-8 bytes 校验 | 不执行截断 |
| `ToolCallKeyFactory` | Key 要隔离 Run 且保留框架 ID | 校验安全 segment,生成 prefix/runId/toolCallId | 不生成或改写 tool_call_id |
| `DuplicateInvocationException`、`InvocationStateException`、`CanonicalStoreException`、`ResultTooLargeException` | Store 失败需要可分类而不是字符串猜测 | 区分重复、非法迁移、基础设施错误和容量错误 | 最终对 Agent 仍映射为安全错误码 |
### 7.3 Adapter 与 Projector:隔离业务后端
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `RagToolAdapter` | 检索实现会演进,但 Agent schema 和 Boundary 不应随之变化 | 解析 RAG request,经 Boundary 调 backend 和 `RagResultProjector` | 不把检索轨迹直接给模型 |
| `QueryLogsToolAdapter` | 现有日志 Tool 的返回和时间语义需要规范化 | 校验范围、桥接 backend、投影日志事件 | 0 命中是 `NO_EVIDENCE`,不是技术失败 |
| `MysqlToolAdapter` | LLM 生成 SQL 必须经过授权和只读沙箱 | 解析 request、先校验 SQL,再经 Boundary 执行和投影 | 未配置数据源时 Tool 不注册 |
| `RagResultProjector` | 上游 evidence、score、relevance 表达不稳定 | 输出有界证据,并保留粗粒度 `relevance_level` | 非空/REFERENCE 不等于支持根因 |
| `QueryLogsResultProjector` | raw 日志不可直接进入上下文 | 生成有界 events、pattern、scope 和截断标记 | 不泄露无限日志正文 |
| `MysqlResultProjector` | JDBC rows 和元数据需要稳定 Agent contract | 限制行列、单元格和 bytes,输出 rows/columns/scope | 不执行 SQL 安全判断 |
| `ToolProjectionLimits`、`MysqlToolLimits` | 各投影边界必须集中、可测试 | 配置证据数、文本、行列和结果上限 | 不改变 Run 总 bytes 预算 |
### 7.4 MySQL 只读沙箱
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `MysqlSqlValidator` | `readOnly=true` 声明不能证明 SQL 安全 | 解析并限制单条 SELECT、数据源、schema/table/column、limit 等 | 不执行查询 |
| `MysqlDataSourceDefinition` | 连接存在不代表 Agent 可访问所有表列 | 保存逻辑数据源和 allowlist | 不携带密码 |
| `MysqlQueryPlan` | 校验后的内容不应在 Executor 再解析原始请求 | 固定已批准数据源、SQL 和限制 | 只能由 Validator 产生 |
| `MysqlReadOnlyExecutor` / `JdbcMysqlReadOnlyExecutor` | JDBC 细节与 Harness Boundary 解耦 | 在只读连接、timeout 和 row limit 下执行 plan | 不接收未经验证的 request |
| `MysqlRawResult` | JDBC 原始但结构化的执行结果 | 携带 columns、rows、truncated、duration | 仍需 Projector 后才能进入 canonical agent_result |
| `MysqlSecurityException` | 安全拒绝必须与基础设施错误区分 | 表达非法 SQL、未授权对象等 | 不向 Agent泄露详细策略 |
### 7.5 Tool contract 完整清单
| 类型 | 设计原因与功能 |
|---|---|
| `AgentToolContracts` | Tool 名、描述和服务端注册契约的唯一常量源,防止 Prompt/代码漂移 |
| `RagToolCall`、`QueryLogsToolCall`、`MysqlToolCall` | 模型侧统一 Envelope:`previous_observation + input` |
| `RagToolRequest`、`QueryLogsRequest`、`MysqlToolRequest` | 业务 Tool 的 typed input;Interceptor 解包后仍保持原业务协议 |
| `RagToolResult`、`QueryLogsToolResult`、`MysqlToolResult` | canonical agent-facing 标准结果,不等同于 raw backend response |
| `RagEvidence`、`SourceDocument` | 有界知识证据及文档身份 |
| `RagRelevanceLevel` | `PRECISE / HIGHLY_RELEVANT / REFERENCE` 等客观检索相关度,不是诊断置信度 |
| `LogQueryScope`、`LogEvent`、`LogPattern` | 日志查询实际范围、事件和聚合模式 |
| `LogSourceKind`、`LogTopic` | 限制日志来源和主题的可选集合 |
| `ToolContractCollections` | 对 contract 集合做 defensive copy 和空值规范化 |
## 8. Guard:把两个验证命题分开
### 8.1 Evidence Guard
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `EvidenceGuard` | 模型不能被信任去验证自己引用的 ID 和 Run 归属 | 严格验证 Draft、analysis ID、canonical READY、当前 Run 所有权、evidence status 和引用闭包 | 不调用模型,不判断结论语义是否成立 |
| `EvidenceGuardResult` | 校验只能是 verified snapshot 或 violations | 阻止半有效结果继续发布 | 不包含原始 Draft 修复逻辑 |
| `VerifiedEvidenceSnapshot` | SemanticGuard 只能看到已验真的证据投影 | 汇总 verified analyses 和 sources | 不包含 raw Tool payload |
| `VerifiedAnalysisEvidence`、`VerifiedEvidence` | 保持 analysis 到证据的归属关系 | 提供来源、scope、excerpt 等安全证据 | 不提升为业务结论 |
| `EvidenceViolation`、`EvidenceViolationCode` | Repair 和 Fallback 需要稳定失败原因 | 标识缺 analysis、未知调用、Run mismatch、非 READY、引用不闭合等 | 不保存内部异常栈 |
### 8.2 Semantic Guard 与模型调用边界
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `SemanticGuard` | 引用真实仍可能无法支持结论 | 用隔离单轮调用输出 `SUPPORTED / UNSUPPORTED`,严格解析并有限 retry | 无 Tool、无记忆、不访问 Redis、不改写 Draft |
| `GuardModelCall` | Router、单轮回答、Repair、Semantic 都需一致的模型预算、timeout 和 bytes 控制 | 在线程池中执行单轮 ChatModel,记录 usage,超时取消 future | 不决定业务 retry policy |
| `SemanticDraftView` | Guard/Repair 只应比较用户可见语义 | 从 Draft 提取稳定语义视图,并判断修复前后是否一致 | 不包含引用实现细节 |
| `SemanticGuardInput`、`SemanticGuardDecision` | 固定 Guard 输入和带 verdict 的输出 | 只传 query、Draft view 和 verified snapshot | 不传 Prompt 历史或 raw Tool response |
| `SemanticGuardLimits`、`SemanticGuardPrompt` | 输入、输出、单次/总 timeout 和判定职责可测试 | 限定一次语义审查的成本与 Prompt | 不暴露给 Diagnosis Agent |
| `GuardModelCallException` | 单轮模型失败要按 timeout、transport、parse、schema 分类 | 为 Harness retry 提供类型化信号 | 不直接映射用户内容 |
## 9. Release:唯一公开决策点
### 为什么需要
如果 Agent、Guard、Application 都可以各自构造结果,同一个失败会出现不同用户语义,迟到内容也可能绕过安全校验。Release 必须集中回答一个问题:当前 Run 有哪些内容可以公开?
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `DiagnosisReleaseUseCase` | Draft、停止、Guard 和 Repair 的组合分支必须只有一个所有者 | 处理有结论、无结论、受控停止、非法 Draft;协调 Guard/Repair/Semantic;返回成功或 Fallback | 不持久化、不发送 SSE、不写新结论 |
| `EvidenceRepair` | 引用或结构小错不应总是丢掉语义正确的 Draft | 单轮修复引用,严格解析,并用 `SemanticDraftView` 保证用户语义不变 | 只 attempt 一次;不新增事实、不改结论 |
| `SafeFallbackFactory` | 失败文案若交给模型生成会再次引入幻觉 | 确定性构造 evidence failed、semantic unsupported/unavailable、insufficient evidence、missing context | 只使用已验真事实和有界问题码 |
| `DiagnosisReleaseResult` | 下游不能同时收到 Draft 和 Fallback | 类型化承载 outcome、Draft、verified evidence 或 SafeFallback | 不等同于 RunState |
| `EvidenceRepairLimits`、`EvidenceRepairPrompt` | Repair 的成本与职责必须比 Diagnosis Agent 更窄 | 限制输入/输出/timeout,固定只修引用的指令 | 不允许 Tool Calling |
## 10. Retry:显式、类型化、可审计的 attempt
### 为什么需要
重试会改变成本、延迟和副作用,必须是调用者的有意识决策。统一 Executor 负责循环,但具体组件持有自己的 Policy。
| 类型 | 设计原因与功能 |
|---|---|
| `HarnessRetryExecutor` | 在每个 attempt 前检查 Run active,统一记录成功/失败,并绝不吞掉取消和预算耗尽 |
| `HarnessRetryPolicies` | 集中定义 Router/SemanticGuard 最多两次,其余一次的严格策略 |
| `RetryPolicy` | `maxAttempts + retryable failures` 的不可变值,避免布尔 `retry=true` |
| `RetryFailure` | timeout、transport、parse、schema、invalid output、cancel、budget 等稳定分类 |
| `RetryAttempt` | 记录 attempt 序号、是否成功和失败类型,供 Trace 使用 |
| `RetryOperation`、`RetryFailureClassifier` | 将执行和异常分类作为端口注入,Executor 不依赖具体模型组件 |
| `RetryExecutionException` | 重试终止时保留最终 attempt 和失败分类 |
## 11. Audit:记录控制事实,而不是复制业务正文
### 为什么需要
诊断系统需要回答“为什么停止、调用了什么、Token 是否对账、发布为何降级”,但普通观察面不应长期保存 Prompt、SQL、日志 query、raw response 或 reasoning。
| 组件 | 为什么设计 | 作用 | 明确边界 |
|---|---|---|---|
| `DiagnosisTraceRecorder` / `JpaDiagnosisTraceRecorder` | 所有阶段需要同一 exact-run timeline | 按 Run 分配 sequence,追加安全事件;失败 best-effort | 不改变业务结果,不保存敏感正文 |
| `TraceAuditEvents` | 各组件手写 details 容易字段漂移或泄露 | 集中构造 run/routing/model/tool/progress/guard/release 事件 | 只接受有界、安全字段 |
| `DiagnosisTraceAuditEvent` | Recorder 与业务组件解耦 | 统一事件 identity、phase、type、status、details | 不是领域事件总线 |
| `ToolInvocationAuditSink` / `JpaToolInvocationAuditSink` | canonical raw 不能长期保存,但调用元数据要留存 | 保存 exact Run、Tool ID、状态、耗时、bytes 和有界 enrichments | 不保存完整 request/raw/agent result |
| `RagLookupAuditEnricher` | RAG 需要有限的质量诊断字段 | 从受限输入提取 step/query/relevance 等安全摘要 | 不打印完整 rewritten/raw query |
| `HarnessAgentAuditHook` | 框架每轮 Agent 模型步骤需关联数据库 step 和 Provider reasoning 可用性 | 写 AgentStep metadata,并把 reasoning/assistant text 放独立受限存储 | reasoning 不进普通 Timeline、Guard 或下一轮上下文 |
| `AgentStepAuditTracker` | Tool audit 需要关联当前 Agent step,但不能使用 ThreadLocal | 按 runId 显式绑定/查询/清理 stepId | 不保存 Step 实体 |
| `ModelCallAuditor` / `ModelCallLedger` | 所有模型入口都要按组件、轮次对账 Token | begin call、记录 Provider usage、写 MODEL_TOKEN_USAGE、生成 Run reconciliation | Usage 缺失时标 unavailable,不估算 |
| `RunConclusionExtractor` | Trace/DB 常需直接读取安全发布结论 | 从 public JSON 提取 conclusion | 不读取 Provider reasoning |
### Audit 类型清单
| 类型 | 分类与功能 |
|---|---|
| `ModelCallComponent` | Router、System、Knowledge、Diagnosis、Repair、Semantic 的统一组件枚举,并映射 Trace phase |
| `ToolInvocationAuditEvent` | durable Tool metadata 事件 |
| `TracePhase`、`TraceEventType`、`TraceEventStatus` | Timeline 的阶段、事件和结果词汇表 |
## 12. Contract:跨组件唯一语言
### 为什么需要
Harness 横跨模型 JSON、Java 对象、Redis、JPA 和 SSE。若状态以自由字符串在各层重复定义,`SUCCESS`、`READY`、`EVIDENCE_FOUND` 很容易被混为一谈。Contract 包将不同维度保持正交。
| 类型 | 为什么存在与表达什么 |
|---|---|
| `DiagnosisDraft` | Diagnosis Agent 唯一结构化输出;analysis 绑定 Tool Call 引用,允许 `conclusion=null` |
| `KnowledgeAnswerDraft` | Knowledge Query 单轮模型输出,答案项绑定文档来源 |
| `PublishedResult` | 只有成功、安全的诊断结果可持久化为下一轮候选 |
| `PreviousTurn` | PublishedResult 的有界历史投影,不是完整会话记录 |
| `SafeFallback` | 确定性公开降级结构,包含 verified sources、observed facts、limitations、next steps 和 validation issues |
| `SourceDocument` | 知识回答中的文档身份与来源 |
| `IntentType` | `SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS` 路由结果 |
| `InvocationStatus` | Tool invocation 生命周期:`PROJECTING / READY / ERROR` |
| `EvidenceStatus` | Tool 客观结果:`EVIDENCE_FOUND / NO_EVIDENCE / ERROR` |
| `SemanticVerdict` | 最终推论支持度:`SUPPORTED / UNSUPPORTED` |
| `ReleaseOutcome` | Harness 发布结果:`SUCCESS / FALLBACK / FAILED / CANCELLED` |
| `SseOutcome` | 当前未被运行时消费的遗留枚举;公开 `done` 实际使用 `ReleaseOutcome`,不要把它作为 SSE 协议真理源 |
| `FallbackType` | evidence validation、semantic、insufficient evidence、missing context 等降级原因 |
| `AnalysisKind` | 区分正向证据分析与限定范围的负向观察 |
| `ContractCollections` | 对公共 contract 集合做 defensive copy、去空和不可变处理 |
## 13. 配置装配:组件如何真正连起来
`HarnessChatConfiguration` 不在 `harness` 包内,但它是运行时组件图的 Composition Root:
- 构造两个有界线程池:Chat worker 与 Harness model executor;
- 从 `ChatHarnessProperties` 创建 Run、预算、停止阈值和各模型调用 limits;
- 装配 Redis canonical store、ToolBoundary、三类 Adapter/Projector;
- 仅在存在有效逻辑数据源时注册 `query_mysql`;
- 为每个 Run 创建带 Model/Tool interceptor 和 Audit Hook 的 Agent;
- 装配 Guard、Repair、Release、Router、三个执行分支和顶层 Application UseCase。
它存在的原因是让所有限制和替换点在一个地方可见。组件内部不应自行读取 Spring 配置或寻找全局 Bean,否则 focused test 很难证明其边界。
## 14. 用调用链快速定位组件
| 想回答的问题 | 首先阅读 | 接着阅读 |
|---|---|---|
| 一次请求如何创建并结束 Run | `ChatApplicationUseCase` | `DiagnosisHarnessCore`、`JpaChatRunStore` |
| ReAct 每轮如何计预算 | `DiagnosisAgentFactory` | `HarnessModelInterceptor`、`ModelCallAuditor` |
| Tool 为什么被拒绝 | `HarnessToolInterceptor` | `DiagnosisProgressTracker`、`ToolBoundary` |
| Tool 结果为何没有原样给模型 | `ToolBoundary` | 具体 ResultProjector、`ToolResultViewProjector` |
| 一条 evidence_ref 如何验真 | `EvidenceGuard` | `CanonicalToolInvocation`、具体 ToolResult contract |
| 为什么最终是 FALLBACK | `DiagnosisReleaseUseCase` | `SafeFallbackFactory`、Trace 的 RELEASE 事件 |
| 为什么诊断停止继续查 | `DiagnosisProgressTracker` | Tool progress / rejection / collection stop Trace |
| Token 为什么对不上 | `ModelCallAuditor` | `ModelCallLedger`、`RUN_FINISHED` reconciliation |
## 15. 组件边界自检
未来新增能力时,可以用下面的问题判断放置位置:
1. 它是在判断业务根因吗?应留在 Diagnosis Agent,而不是 Core/Tool/Guard。
2. 它能由代码机械证明吗?应放在 Boundary、Tracker 或 EvidenceGuard。
3. 它需要完整 Tool 真相吗?读取 canonical store,不要从 Agent observation 反推。
4. 它要决定公开内容吗?只能进入 Release,不要在 Application 或 Guard 私自构造。
5. 它是短期验真数据还是长期运营元数据?前者 canonical,后者 metadata audit。
6. 它会再次调用模型吗?必须进入 Core budget、ModelCallAuditor、timeout 和显式 retry policy。
7. 它引入了新的状态吗?先确认是否只是 error code、stop reason 或 fallback reason,避免再造全局生命周期。
@@ -0,0 +1,163 @@
# Harness 组件学习路线(进度追踪)
**用途**:记录面试准备过程中已了解的 Harness 组件,标记进度,规划下一步。每次学完一个职责域后更新本表。
**依据**:`mvp/engineering/harness/Harness组件全景-职责-设计原因与边界.md`(10 个职责域、189 个文件)
## 0. 学习交流方式与衔接说明(新会话请先读本节)
### 0.1 目标
为**面试准备**深入理解 Harness:不只是知道有哪些组件,要能讲清「为什么这样设计」——每个设计点都有动机(问题)→ 决策 → 代价 → 面试话术。
### 0.2 交流模式(用户与 AI 的协作方式)
1. **逐域学习**:按[学习主线](#3-一次请求的完整学习主线)顺序,一次一个职责域;进度见[第 1 节](#1-进度总览)。
2. **讲解顺序固定**:设计动机(为什么重试权归 Harness)→ 实现细节(真实代码)→ 面试话术。
3. **用户会用自己的话复述理解**(「我理解下...」)——AI 需逐条核对:基本正确就确认 + 精修表述;有偏差要明确指出并给出修正后的说法。
4. **用户会追问**(「为什么...」「如果...那...」)——AI 必须基于源码事实回答(`src/main/java/com/superbiz/agent/harness`),先读代码再答,不凭印象。
5. **概念分不清时用户会要求回到底层概念**(如「副作用幂等是什么」)——用类比 + 具体例子讲透再回到主线。
6. **每学完一个主题沉淀成 mermaid 文档**,放本目录 `mvp/engineering/harness/`(与已有笔记同风格:用途/图/表/面试话术/代码位置),并更新本路线图。
7. **终端对话中不输出 mermaid**(用户终端显示不了,用 ASCII 树/表格);落地文档中用 mermaid。
8. 回复用中文、不用 emoji、重要内容(代码/表/推理)不截断。
### 0.3 新会话衔接步骤
```text
1. 读本路线图:第 0 节(交流方式)+ 第 1 节(进度)+ 第 4 节(下一步)
2. 读「已产出笔记」里的文档,了解已学内容的深度(尤其是 core / retry)
3. 从第 4 节「下一步规划」继续,保持 0.2 的交流模式
```
### 0.4 当前会话的起始上下文(供追溯)
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ RunBudget 深挖 → retry → progress(设计+代码双视角)→ tool 域(49 文件全注释 + 注册调用执行链路 + Tool 调用链旅程)→ RAG 检索体系(lookup_knowledge 后端:L0/多路召回+RRF/qualityScore/降级/契约/审计/离线评测,已闭环)。当前停在「tool 域只差 MySQL 沙箱线,下一步 tool 收尾」的位置。
### 0.5 面试准备策略(学习目标)
**学每个域的达标标准**(不只是「看懂了」):
```text
1. 能 2 分钟讲清该域:为什么存在 → 核心机制 → 边界/代价
2. 能接住 3 个追问:动机追问(为什么这样)→ 细节追问(怎么实现)→ 边界追问(什么不做)
3. 有一句背得出的面试话术(每篇笔记都有「面试话术」章节)
```
**每个域的面试讲法模板(固定叙事结构)**:
```text
① 动机:不这么做会出什么问题(问题驱动,不要先报组件名)
② 决策:选了什么方案、放弃了什么(对比)
③ 实现:关键机制 + 代码事实(一句话带过实现细节)
④ 边界:明确不做什么、代价是什么(诚实)
⑤ 话术:一段 30 秒可背诵的回答
```
**高频追问地图**(面试被问到时先答哪篇):
| 面试问题 | 答案指向 |
|---|---|
| 什么是 Harness?30 秒讲清 | [面试速查](Harness面试速查-一张图讲清设计.md) §1-2 |
| 为什么不用多 Agent? | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
| RunContext 为什么要显式传递? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §2 |
| 取消是强杀吗? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §6-8 |
| 预算和 Ledger 有什么区别? | [RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) §5 |
| FALLBACK 算成功还是失败? | 状态流(未沉淀,学完后补) |
| 重试为什么归 Harness 管? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §2 |
| 为什么 Agent/Tool 不重试? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §9 |
| 如何防止 Agent 编造证据? | 证据安全链(tool/guard 学完后补) |
**面试总复习路径**(面试前一天):
```text
1. 30 秒电梯陈述 + 一张图(面试速查 §1-2)
2. 默画三张白板图:主链路、职责迁移、数据三层(面试速查 §2)
3. 过一遍六个易错点(面试速查 §8)
4. 2 分钟真实案例(支付超时)
5. 每篇笔记的「面试话术」章节快速背诵
```
## 1. 进度总览
| 职责域 | 作用摘要 | 状态 | 已深入了解 | 对应文档 |
|---|---|---|---|---|
| `core` | **执行控制**:身份 / deadline / 预算 / 取消 / 唯一终态,checkActive 三道闸 | ✅ 深入 | RunContext、budget、cancel、lifecycle、checkActive、termination | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)、[RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
| `retry` | **显式可计量重试**:分类裁决(技术/业务)、次数/时间/成本三重封顶、attempt 可审计 | ✅ 深入 | 设计动机、分类裁决、剩余超时、幂等性、SDK 关闭 | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
| `contract` | **跨层类型化语言**:Draft / PublishedResult / SafeFallback / 状态枚举,防字符串漂移 | ✅ 深入 | 11 个状态枚举五层全景、四个正交轴(RunState⊥ReleaseOutcome、InvocationStatus⊥EvidenceStatus)、纵向映射链、SseOutcome 未接线发现 | [状态流笔记](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) |
| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ✅ 深入 | 装配(Factory 粘合点)、双拦截器(Model:预算+Token 审计;Tool:五道门)、UseCase 循环外壳(字节/预算限制)、受控停止(异常栈捞回可控信号)、双视图投影(模型观察 vs 控制视图)、串行工具 | [agent 域学习笔记](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) |
| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ✅ 深入 | trace 时序线(15 帧真实数据)、Ledger 分账、模型步审计 hook、RunConclusionExtractor、DiagnosisTraceService 三级回放 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ✅ 深入 | 六步编排、取消句柄(CoreRunControl)、统一失败出口、多轮记忆有界化、PublishedResultPolicy 落库 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ✅ 深入 | 20 个违规码、三层校验(结构/验真/重读投影)、语义不变性、守卫模型受控调用、全栈衔接 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ✅ 深入 | 三分支决策树、fail closed、终态透传、EvidenceRepair 语义不变性、SafeFallbackFactory 五种降级 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ✅ 深入 | 49 文件全注释、Boundary/Contract/Projection/Store/Adapter/注册链、RAG 后端(L0/RRF/qualityScore/降级)、MySQL 沙箱(Validator/Executor/Projector 三层防线) | [tool 域注册调用执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)、[Tool 调用链旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md)、[RAG 检索体系](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md)、[MySQL 沙箱](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) |
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ✅ 深入 | 设计动机、Tracker 双计数/pending/软硬停止、拦截器五道门、canonical 生命周期、Projector 投影、Release 消费 | [代码学习笔记](Harness progress 代码学习笔记-从拦截器五道门到唯一发布点.md)(与[设计视角](Harness信息增益停止-让无证据诊断正常收敛.md)配套) |
图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
## 2. 已产出笔记
| 文档 | 内容 | 状态 |
|---|---|---|
| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | progress 设计视角:双停止机制 / 三方判断权 / 状态机 / 协议 / 真实问题 | ✅ 已沉淀(设计视角) |
| [Harness progress 代码学习笔记-从拦截器五道门到唯一发布点](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md) | progress 代码视角:类地图 / 五道门 / Tracker 状态机 / canonical 生命周期 / 门禁 / 投影发布 / 易错点 / 面试话术 | ✅ 已沉淀(代码视角) |
| [Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md) | tool 域:装配/注册(bridge/callbacks)/调用/执行(ToolBoundary)/返回全链路 + 面试话术 | ✅ 已沉淀 |
| [Harness Tool 调用链-一次工具调用的完整旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md) | 动态时序:模型决定 → 拦截器 → invoke → Adapter → ToolBoundary → 返回 → 模型观察 | ✅ 已沉淀 |
| [Harness RAG 检索体系学习笔记-从 query 到可验证证据](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md) | RAG 后端:L0/多路召回+RRF/qualityScore/去重判级/降级/契约/审计/离线评测/讨论沉淀 | ✅ 已沉淀 |
| [Harness MySQL 沙箱学习笔记-从 SQL 校验到脱敏投影](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) | MySQL 工具:三层防线(语义/连接/输出)/白名单/参数化强制/取消联动/脱敏/与 RAG 对照 | ✅ 已沉淀 |
| [Harness 证据安全链学习笔记-从收敛控制到唯一发布点](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) | progress→guard→release 联动:双通道验证架构/六层状态流转与映射/关键字段来源与使用/设计要点/面试话术 | ✅ 已沉淀 |
| [Harness application+audit 学习笔记-从 Run 编排到可回放审计](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) | application 六步编排/取消句柄/持久化策略;audit 域层次(trace 子体系/Ledger/审计表);**audit vs trace 区别**(真实数据对照)/三级回放 | ✅ 已沉淀 |
| [Harness contract 状态流学习笔记-11个状态枚举的正交全景](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) | 五层状态/四正交轴/纵向映射链/真实数据案例/面试叙事模板与追问应对 | ✅ 已沉淀 |
| [Harness 面试复习笔记-五步复习与白板图沉淀](Harness%20面试复习笔记-五步复习与白板图沉淀.md) | **详细版**:30 秒陈述展开/三张白板图/九域五段式讲法(动机→决策→实现→边界→话术)/六易错点带原因/追问应对大全/支付超时案例/纠正认知清单/面试 Checklist | ✅ 已沉淀 |
| [Harness agent 域学习笔记-从框架 ReAct 接入到受控停止](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) | agent 域:装配(Factory 粘合点)/双拦截器(Model 预算+Token 审计、Tool 五道门)/UseCase 循环外壳/受控停止/双视图投影/串行工具 | ✅ 已沉淀 |
| [Harness LLM Judge 设计笔记-从不可信判定到可信裁决](Harness%20LLM%20Judge%20设计笔记-从不可信判定到可信裁决.md) | LLM-as-a-judge 模式:SemanticGuard(判支持度)+ EvidenceRepair(修引用)+ GuardModelCall(受控底座);面试五段式回答稿 + 六追问应对 + 通用要素 | ✅ 已沉淀 |
| [Harness 整体架构学习笔记-从装配到入口到记忆到知识库写入](Harness%20整体架构学习笔记-从装配到入口到记忆到知识库写入.md) | 整体架构补充:配置装配中心(三层组织)/ HTTP 入口层(薄 Controller + SSE 状态机 + 断连取消)/ 会话与记忆体系(PreviousTurn 注入 + 术语校准 + skill 长期记忆)/ 知识库写入链路(分块 + hybrid 同源) | ✅ 已沉淀 |
## 3. 一次请求的完整学习主线
```mermaid
flowchart LR
A["core<br/>执行控制 ✅"] --> B["retry<br/>重试 ✅"]
B --> C["progress<br/>信息增益 ✅"]
C --> D["tool<br/>事实边界 ✅"]
D --> E["guard<br/>验证 ✅"]
E --> F["release<br/>发布 ✅"]
F --> G["application + audit<br/>收尾 ✅"]
G --> H["contract<br/>类型化语言 ✅"]
```
## 4. 下一步规划
```text
主线九域全部 ✅(含 agent 域收尾)+ 面试五步复习 ✅(共沉淀 14 篇笔记)
面试前一天建议:
1. 重读「面试速查」§1-2 + §8(30 秒陈述 / 一张图 / 六易错点)
2. 默画三张白板图(复习笔记 §3)
3. 背诵每域 30 秒话术(复习笔记 §5)
4. 过一遍纠正的认知清单(复习笔记 §6,最容易踩的坑)
5. 2 分钟支付超时案例(复习笔记 §7)
可选深化(不阻塞面试):
1. audit 域深化:RagLookupAuditEnricher 检索审计明细(已覆盖大半)
2. LLM Judge 设计(已沉淀:面试问答 + 追问应对)
3. 整体架构补充(已沉淀:装配/入口/记忆体系/知识库写入)
```
## 5. 建议每次学完一个域后更新
```text
1. 把本表"状态"从 ⬜ 改为 ✅/⬜
2. 在"已深入了解"列补充该域的关键类
3. 如产出笔记,加入"已产出笔记"表
```
## 6. 参考资料索引
| 文档 | 用途 |
|---|---|
| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事(30 秒回答、三大决策、易错点) |
| [Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) | 全部组件的参考手册(需要查类时用) |
| [components/README.md](components/README.md) | 组件渐进式导读入口(02-04 对应 progress/tool/guard+release) |
| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 设计主文档(决策总表、不变量) |
@@ -0,0 +1,503 @@
# Harness 设计:非确定性 Agent 的确定性控制边界
**更新日期**:2026-07-29
**适用范围**:当前 MVP Chat / Diagnosis Harness
**代码基线**:`com.superbiz.agent.harness`
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**组件索引**:[Harness组件全景-职责-设计原因与边界.md](Harness组件全景-职责-设计原因与边界.md)
## 1. 结论先行
这个系统真正需要解决的,不是“怎样让模型多调用几个 Tool”,而是:
> 当业务推理由非确定性模型完成时,怎样保证每一次执行仍然有身份、有边界、有停止条件、有证据闭包,并且只发布系统能够负责的内容。
当前 Harness 给出的答案是职责分治:
- Diagnosis Agent 负责提出假设、选择 Tool、理解结果和撰写 Draft。
- Harness 负责所有必须确定的事情:Run 生命周期、预算、取消、Tool 授权、结果隔离、停止、验真、发布和审计。
- Tool 只报告客观执行结果,不宣称业务根因。
- Guard 不重新做诊断,只回答受限的验证问题。
- Release 是唯一对外发布决策点,只能发布原始安全 Draft 或确定性 SafeFallback。
这不是一个新的工作流引擎。Harness 不复制 ReAct 循环,不维护 Planner / Executor / Composer 图,也不替模型判断根因。它包围 ReAct 的不确定部分,在所有外部副作用和最终发布点建立确定性门禁。
## 2. 根问题:模型能推理,但系统必须能够负责
### 2.1 旧架构把“推理角色”当成了“安全边界”
旧方案使用 Planner、Executor、Verifier、Composer 等多个 Agent 串联。表面上每个角色各司其职,实际上每个角色都拥有一部分 Prompt、模型调用、Schema 转换、重试和 Fallback 逻辑。结果是:
1. 一次诊断的控制权分散在多个模型角色和业务 Service 中。
2. 同一种错误可能被不同层重试、改写或吞掉。
3. Verifier 既检查引用,又判断语义,还可能重写报告,成为第二个诊断者。
4. Tool 原始结果、Agent 上下文和长期审计没有明确的数据边界。
5. Run 身份依赖 ThreadLocal 传播,跨线程后无法证明一次调用属于哪个 Run。
```mermaid
flowchart LR
U["用户请求"] --> P["Planner Agent"]
P --> E["Executor Agent"]
E --> T["Tool / raw result"]
E --> V["Verifier Agent"]
V --> C["Composer Agent"]
C --> O["公开结果"]
P -.-> X1["独立 Prompt / retry / schema"]
E -.-> X2["独立 Prompt / retry / fallback"]
V -.-> X3["验真、语义判断、改写混合"]
C -.-> X4["再次生成用户事实"]
T -.-> X5["raw、trace、正文边界不清"]
H["ThreadLocal context"] -.-> P
H -.-> E
H -.-> V
classDef risk fill:#fff1f0,stroke:#cf1322,color:#5c0011;
class X1,X2,X3,X4,X5,H risk;
```
问题并不在于“Agent 数量多”本身,而在于控制职责没有单一所有者。只要预算、重试、证据和发布仍分散,多 Agent 换成 Graph 也不会自然变得可靠。
### 2.2 单 Agent 仍然不等于受控系统
把多 Agent 合并成一个 ReAct Agent,只消除了重复推理链,并没有自动解决以下问题:
- 模型可能无限改写相似查询;
- SDK 可能在 Harness 之外自动重试;
- Tool 可能返回过大、敏感或不可引用的原始数据;
- 模型可以引用其他 Run 或不存在的 Tool Call;
- 证据引用真实,不代表结论被证据支持;
- 客户端断开后,迟到的模型结果仍可能覆盖终态;
- “没有足够证据”可能被错误地发布为技术失败。
所以重构的核心不是“单 Agent”,而是“单 Agent + 确定性 Harness”。
## 3. 设计约束与不变量
以下不变量是组件拆分的依据,不是实现后的总结。
| 不变量 | 系统含义 | 由谁保证 |
|---|---|---|
| 一个 Run 只有一个终态 | 成功、失败、取消、超时、预算耗尽不能相互覆盖 | `RunLifecycle` 的 first-terminal-wins |
| 所有边界显式携带 Run 身份 | 不依赖线程绑定的隐式上下文 | `RunContext` 和 framework `tool_call_id` |
| 所有模型与 Tool 消耗可计量 | SDK 隐式 retry 不能绕过预算 | Core、Model/Tool interceptor、Harness retry |
| Tool raw 不直接进入模型 | 内部数据、体积和控制字段不能污染上下文 | ToolBoundary、canonical store、双视图投影 |
| 只有当前 Run 的 READY 调用可被引用 | 防止伪造、串 Run 和引用失败调用 | EvidenceGuard |
| 引用真实性与结论支持度分开 | 确定性规则和语义判断不互相冒充 | EvidenceGuard、SemanticGuard |
| Guard 不写报告 | 防止验证器成为第二个业务 Agent | Release Policy |
| 停止权属于 Harness | Prompt 建议不能替代系统保证 | ProgressTracker、预算和 interceptor |
| 没有根因是合法业务结果 | 证据不足不应伪装成内部故障 | `conclusion=null`、ProgressSnapshot、SafeFallback |
| 普通审计不保存敏感正文 | 可观测性不能以泄露 Prompt、raw、reasoning 为代价 | metadata-only audit、reasoning 独立存储 |
## 4. 当前总体架构
```mermaid
flowchart TB
subgraph Entry["应用入口与 Run 所有权"]
APP["ChatApplicationUseCase"]
ROUTER["IntentRouter"]
EXEC["DiagnosisChatExecutor"]
STORE["ChatRunStore"]
end
subgraph Harness["确定性 Harness 边界"]
CORE["Core<br/>context / lifecycle / budget / cancel"]
MI["Model Interceptor<br/>调用预算与 Token"]
TI["Tool Interceptor<br/>协议、重复、停止"]
TB["ToolBoundary<br/>授权、执行、canonical"]
PROGRESS["ProgressTracker<br/>信息增益与饱和"]
EG["EvidenceGuard<br/>引用真实性"]
SG["SemanticGuard<br/>结论支持度"]
RELEASE["Release Policy<br/>SUCCESS / FALLBACK"]
AUDIT["Audit / Trace<br/>metadata-only"]
end
subgraph Nondeterministic["非确定性区域"]
AGENT["Diagnosis ReAct Agent"]
MODEL["Chat Model"]
end
subgraph Evidence["证据执行与存储"]
ADAPTER["RAG / Logs / MySQL Adapter"]
BACKEND["Evidence Backend"]
CANON["Redis Canonical Invocation"]
META["MySQL Durable Metadata"]
end
APP --> CORE
APP --> ROUTER
ROUTER --> MODEL
APP --> EXEC
EXEC --> AGENT
AGENT --> MI --> MODEL
AGENT --> TI
TI --> PROGRESS
TI --> TB --> ADAPTER --> BACKEND
TB --> CANON
TB --> META
TB --> TI --> AGENT
EXEC --> EG --> SG --> RELEASE
PROGRESS --> RELEASE
RELEASE --> APP --> STORE
CORE -.-> MI
CORE -.-> TI
CORE -.-> TB
CORE -.-> EG
CORE -.-> SG
APP -.-> AUDIT
MI -.-> AUDIT
TI -.-> AUDIT
TB -.-> AUDIT
EG -.-> AUDIT
SG -.-> AUDIT
RELEASE -.-> AUDIT
```
图中的关键边界有三条:
1. **Agent 之前**:Application 创建 Run,Core 固定身份、预算和终止语义。
2. **Tool 前后**:Interceptor 管控制协议,ToolBoundary 管执行与真相,Projector 管模型可见内容。
3. **发布之前**:EvidenceGuard 验引用,SemanticGuard 验推论,Release 决定唯一公开内容。
## 5. 关键决策及设计推导
### 5.1 决策一:只保留一个拥有 Tool loop 的 Diagnosis Agent
**问题**:多角色 Agent 把同一份业务上下文在多个模型之间传递,每一跳都可能增加信息损失、重试和幻觉面。
**候选方案**:
| 方案 | 优点 | 主要问题 |
|---|---|---|
| 保留 Planner / Executor / Verifier / Composer | 角色概念直观 | 控制权分散;多套 Prompt 和重试;Verifier/Composer 会产生新事实 |
| 外层自建 ReAct / StateGraph | 流程显式 | 与框架 ReAct 重复;状态和异常路径翻倍 |
| 单 ReAct Agent + Harness | 推理上下文连续;控制边界集中 | 对 Harness 的契约和门禁设计要求更高 |
**决策**:Diagnosis Agent 是唯一业务推理者和 Draft 作者,使用框架 `ReactAgent` 自己完成 Thought / Action / Observation。项目不在外层复制循环。
**代价**:单 Agent 并不能通过“角色互审”获得表面冗余,因此必须把可机械验证的安全要求下沉到 Guard,把语义审查收缩成隔离的单轮判断。
### 5.2 决策二:RunContext 显式传播,结构不可变、状态句柄线程安全
**问题**:旧 ThreadLocal 可以在同步调用中工作,但异步 Tool、线程池和取消回调跨线程后,调用方无法证明读到的是当前 Run 的上下文。
**容易走错的方案**:把所有字段都做成不可变值对象。这样 deadline 和 identity 很干净,但预算、取消和终态不得不在外部另建全局 Map,反而形成第二真相源。
**决策**:`RunContext` 本身是 record,固定 `sessionId / runId / deadline` 和各状态句柄引用;预算、取消、生命周期、模型账本和进展 Tracker 在各自线程安全对象内变化。
```mermaid
flowchart LR
RC["RunContext<br/>结构不可变"] --> ID["sessionId / runId / deadline"]
RC --> C["RunCancellation<br/>first-reason-wins"]
RC --> B["RunBudget<br/>同步复合计数"]
RC --> L["RunLifecycle<br/>first-terminal-wins"]
RC --> M["ModelCallLedger<br/>组件/轮次账本"]
RC --> P["ProgressTracker<br/>Run 内进展"]
```
**为什么不是全局 Run Registry**:Harness 只控制当前调用,不承担 Run 查询和持久化;持久化真相仍由 `diagnosis_run` 所有,避免 Core 演变成工作流引擎。
### 5.3 决策三:关闭 SDK 隐式 retry,由 Harness 按失败类型拥有 retry
**问题**:Spring AI 默认 `maxAttempts=10`。如果 SDK 在模型边界内部自动重试,Harness 看到的一次调用可能对应多个 Provider attempt,预算、延迟、Trace 和取消都失真。
**决策**:底层 SDK retry 设为 1;仅在 Harness 中使用类型化策略:
- IntentRouter:超时、传输、非法输出最多 2 次 attempt;
- SemanticGuard:超时、传输、解析或 Schema 问题最多 2 次 attempt;
- Diagnosis Agent、业务 Tool、EvidenceRepair:1 次,不自动重试;
- 取消、预算耗尽、`NO_EVIDENCE`、业务拒绝:从不重试。
**设计理由**:重试不是通用容错开关。只有调用者知道一次失败是否幂等、是否还在 deadline 内、是否应该再次消耗预算。
**代价**:Provider 瞬时抖动更容易直接暴露,但真实 attempt 终于可计算、可审计,且不会把业务无证据误判为技术重试条件。
### 5.4 决策四:Tool 使用 canonical truth 与 Agent projection 双视图
**问题**:同一份 Tool 返回同时服务三个目标,而三个目标互相冲突:
- 证据验真需要完整、稳定、按 Run 归属的记录;
- Agent 只需要完成下一步推理的最小内容;
- 长期审计应保留身份和耗时,但不能长期保存敏感 raw。
**决策**:把 Tool 数据分成三层,而不是让一个 JSON 到处流转。
```mermaid
flowchart LR
REQ["framework tool_call_id<br/>typed request"] --> B["ToolBoundary"]
B --> RAW["backend raw response"]
RAW --> CAN["Redis canonical invocation<br/>request + raw + agent_result<br/>短 TTL"]
RAW --> PRJ["Tool-specific projector"]
PRJ --> CTRL["Harness control view"]
PRJ --> OBS["Agent observation<br/>白名单、有界"]
B --> META["Durable audit<br/>identity / status / latency / bytes"]
CTRL --> STOP["重复、NO_GAIN、停止控制"]
OBS --> AGENT["Diagnosis Agent"]
CAN --> GUARD["EvidenceGuard"]
```
**关键取舍**:
- 使用框架 `tool_call_id`,Harness 不生成第二套 ID;
- `PROJECTING / READY / ERROR` 表达调用生命周期;
- `EVIDENCE_FOUND / NO_EVIDENCE / ERROR` 表达客观结果语义;
- raw 超限直接失败,不静默截断;Agent projection 可以有界截断,但必须标记 `truncated`;
- Redis 是 TTL 内完整 Tool 真相源,MySQL 只做长期 metadata audit;
- Redis 读取不续期,避免一次历史读取无限延长敏感 raw 生命周期。
**代价**:同一次 Tool 调用需要 projector、store 和 audit 三套表达。但它们不是重复数据模型,而是分别回答“真实发生了什么”“模型允许看到什么”“长期允许保留什么”。
### 5.5 决策五:EvidenceGuard、SemanticGuard 和 Release 三段分工
**问题**:“引用是真实的”和“结论被引用支持”是两个不同命题。只做前者会放过牵强推论;都交给模型则无法确定性防止伪造 ID、跨 Run 引用或修改报告。
**决策**:
1. `EvidenceGuard` 使用纯代码检查 Draft 结构、analysis ID、Tool Call 当前 Run 所有权、READY 状态、evidence status 和引用闭包。
2. 首次引用失败时,`EvidenceRepair` 只允许修复结构和引用;修复前后用户可见语义必须一致,然后重新执行 EvidenceGuard。
3. `SemanticGuard` 只接收 query、完整 Draft 和 verified snapshot,进行无 Tool、无记忆的单轮 `SUPPORTED / UNSUPPORTED` 判断。
4. `DiagnosisReleaseUseCase` 是唯一发布决策点。Guard 无权改写最终报告。
```mermaid
sequenceDiagram
participant A as Diagnosis Agent
participant R as Release UseCase
participant E as EvidenceGuard
participant C as Canonical Store
participant X as EvidenceRepair
participant S as SemanticGuard
participant P as Public Result
A->>R: DiagnosisDraft
R->>E: validate draft references
E->>C: read exact run/tool_call_id
C-->>E: READY agent_result
alt 引用或结构错误
E-->>R: violations
R->>X: 原 Draft + violations
X-->>R: 语义不变的修复 Draft
R->>E: revalidate once
end
alt 证据闭包有效
E-->>R: verified snapshot
R->>S: query + draft + snapshot
S-->>R: SUPPORTED / UNSUPPORTED
end
alt SUPPORTED
R-->>P: 原始安全 Draft
else 任一门禁失败
R-->>P: deterministic SafeFallback
end
```
**为什么 Repair 不能修正文义**:一旦 Repair 可以修改结论,它就成为新的报告作者;此时最终内容不再是 Diagnosis Agent 的 Draft,也无法证明修复只解决了引用问题。
**为什么 SemanticGuard 不是第二个 Agent**:它没有 Tool、历史记忆或 ReAct loop,只回答一个受约束的二值审查问题,不能探索新事实或生成新结论。
### 5.6 决策六:资源预算和信息增益是两套停止机制
**问题**:预算只能回答“还能不能花资源”,不能回答“继续查询还有没有价值”。知识库只有通用资料、日志持续为空时,Agent 可以在预算范围内不断换关键词,最终以 `BUDGET_EXHAUSTED` 结束。用户得到的是技术失败,而系统实际已经知道“当前范围证据不足”。
**决策**:
- Tool 负责客观事实:是否执行成功、是否为空、实际 scope;
- Harness 机械判定空结果和完全重复的 `tool_name + normalized_scope` 为 `NO_GAIN`;
- 其他成功非空结果由模型在下一次 Tool Call Envelope 中声明 `GAINED / NO_GAIN`;
- `DiagnosisProgressTracker` 维护连续 `NO_GAIN`,达到阈值后进入 `SATURATED`;
- 连续 Envelope 协议错误使用独立计数和 `PROGRESS_PROTOCOL_VIOLATED`,不伪装成无信息增益;
- STOP_REQUIRED 只交付一次,再次请求 Tool 直接受控终止;
- `INFORMATION_SATURATED` 与 `BUDGET_LIMIT_REACHED` 始终分开记录。
```mermaid
stateDiagram-v2
[*] --> COLLECTING
COLLECTING --> COLLECTING: GAINED / 清零 NO_GAIN
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
COLLECTING --> SATURATED: 连续 NO_GAIN 达阈值
COLLECTING --> SATURATED: 连续协议错误达阈值
COLLECTING --> STOPPED: 硬预算到达
SATURATED --> DRAFT_CHANCE: 单次 STOP_REQUIRED
DRAFT_CHANCE --> STOPPED: 再次请求 Tool
DRAFT_CHANCE --> RELEASE: 输出合法 Draft
STOPPED --> RELEASE: ProgressSnapshot
```
**为什么不用 `new_count`**:跨 RAG、日志和数据库建立统一内容指纹成本高,而且“新记录”不等于“对假设有价值”。
**为什么不用独立 Progress Judge**:它会增加模型成本和新的失败点,还会把简单的空结果、重复 scope 判断模型化。
**首版边界**:重复检测只比较规范化参数,不承诺识别自然语言语义等价查询。
### 5.7 决策七:`conclusion=null` 是合法完成,不是模型失败
**问题**:如果成功的唯一含义是“必须给出根因”,Agent 在缺少企业、时间范围或错误信息时只能继续盲查,或者编造结论。
**决策**:DiagnosisDraft 允许 `conclusion=null`:
- 缺少开始查询所需信息:`MISSING_REQUIRED_CONTEXT`;
- 已完成有限检查但证据不足:`INSUFFICIENT_EVIDENCE`;
- 有结论:才进入完整 EvidenceGuard、Repair、SemanticGuard 链。
最终公开生命周期仍只有 `SUCCESS / FALLBACK / FAILED / CANCELLED`。证据不足是 `FALLBACK` 的原因,不再引入一套与 Run 终态平行的诊断状态机。
如果模型输出非法 Draft,系统会丢弃非法正文;只有当前 Run 已存在可验真的 ProgressSnapshot,才允许降级成过程型 Fallback,否则保持 fail closed。
### 5.8 决策八:可观测性记录决策证据,不复制敏感上下文
**问题**:为了调试 Agent,最直接的做法是保存 Prompt、模型正文、Tool arguments 和 raw response。但这会让普通 Trace 变成敏感数据仓库,也会造成多份事实副本。
**决策**:
- `diagnosis_trace_event` 只保存追加式、按 exact Run 排序的事件和有界 metadata;
- `ToolInvocation` 长期保存 Tool 身份、状态、耗时和字节数,不保存完整请求/响应;
- `AgentStep` 保存步骤元数据和 Token,不保存 Prompt、Tool payload;
- Provider reasoning 与 assistant text 放入独立受限审计表和接口;
- Trace 写入失败不得改变业务结果;
- Model usage 按组件和轮次入账,Run 结束做 Token 对账;
- Tool 请求被 Harness 拒绝时记录 `TOOL_REQUEST_REJECTED`,不能伪装成一次真实 `TOOL_INVOCATION`。
**尚未完成的治理**:Reasoning 的访问控制、保留期限和加密仍由 ISS-015 跟踪。已有隔离不代表完整合规闭环。
## 6. 一次诊断的完整控制流程
```mermaid
sequenceDiagram
participant U as Client
participant App as Chat Application
participant Core as Harness Core
participant Agent as Diagnosis Agent
participant TI as Tool Interceptor
participant TB as ToolBoundary
participant Store as Canonical Store
participant Guard as Guards
participant Release as Release
U->>App: query + optional sessionId
App->>Core: startRun(sessionId)
Core-->>App: RunContext(runId, deadline, handles)
App->>Agent: query + bounded safe previousTurn
loop ReAct 由框架拥有
Agent->>TI: Tool Call Envelope
TI->>TI: 应用上一轮信息增益、检查重复/饱和/协议
alt 门禁允许
TI->>TB: framework id + business input + RunContext
TB->>TB: active / auth / readonly / budget / size
TB->>Store: PROJECTING -> READY or ERROR
TB-->>TI: bounded canonical agent_result
TI-->>Agent: whitelist observation
else 信息饱和或协议停止
TI-->>Agent: one-shot STOP_REQUIRED
end
end
Agent-->>App: DiagnosisDraft 或受控停止
App->>Release: Draft + ProgressSnapshot + stop reason
alt 有结论
Release->>Guard: evidence truth + semantic support
Guard-->>Release: verified / unsupported
else 无结论或受控停止
Release->>Release: 构造确定性 Fallback
end
Release-->>App: original safe Draft or SafeFallback
App->>Core: first terminal wins
App-->>U: content/failure + done
```
## 7. 真实问题如何反向修正设计
这些不是零散 Bug 清单。每个问题都暴露了一个原设计假设不成立,并促成了边界调整。
| 现场问题 | 被证伪的假设 | 设计修正 | 固化位置 |
|---|---|---|---|
| Spring AI 默认 10 次 retry | 一次 Harness 模型调用等于一次 Provider attempt | SDK retry=1,retry 所有权上移 | Core / Retry / 配置测试 |
| ThreadLocal 跨异步边界不稳定 | 同线程上下文足以表示 Run 所有权 | 显式 `RunContext` 贯穿调用链 | Core / framework metadata |
| Tool raw 直接进入 Agent | Tool 返回可同时服务推理、验真和审计 | canonical / control / observation 三层拆分 | ToolBoundary / Projector / Store |
| 0 条日志被表达为 `success=false` | 空结果等于技术失败 | 技术执行与 `NO_EVIDENCE` 分离 | Tool contract / Projector |
| `REFERENCE` 被当作诊断证据 | 非空候选等于支持结论 | 保留相关度,语义增益交给模型 | RAG projector / Progress |
| Redis canonical 全部 `STORE_ERROR` | 单测 ObjectMapper 与生产配置行为一致 | 使用生产 ObjectMapper 能力并增加 live E2E | Store wiring / E2E |
| raw/rewritten query 出现在日志 | 可观测性可以直接打印检索输入 | 普通日志和 Trace 仅保存安全 metadata | Audit boundary |
| 空查反复改写直到预算耗尽 | 硬预算可以承担正常收敛 | 引入信息增益和饱和停止 | ProgressTracker / Interceptor |
| 9 次协议拒绝仍消耗 13 轮模型 | 协议错误会被模型自然修正 | 独立协议错误阈值和一次 STOP_REQUIRED | Progress protocol |
| 非法 Draft 导致已完成检查全部丢失 | Draft 失败意味着整个 Run 没有安全价值 | 仅在已有验真进展时发布过程型 Fallback | ProgressSnapshot / Release |
## 8. 关键决策总表
| 决策 | 选择 | 放弃的方案 | 获得的能力 | 付出的代价 |
|---|---|---|---|---|
| 推理拓扑 | 单 Diagnosis ReAct Agent | 多 Agent Graph、外层 ReAct | 上下文连续、唯一 Draft 作者 | Harness 门禁必须完整 |
| Run 上下文 | 显式 record + 状态句柄 | ThreadLocal、全局 Registry | 异步可证明、状态所有权清楚 | 参数需要显式传递 |
| Tool ID | framework `tool_call_id` | Harness 二次生成 ID | 引用链唯一 | 依赖框架 ID 契约 |
| Tool 真相 | Redis canonical,短 TTL | JPA 保存完整 raw | 可验真且限制敏感数据寿命 | Redis 可用性成为验证依赖 |
| Agent 输入 | 白名单 projection | raw / 完整 canonical 直传 | 上下文有界、减少泄露 | 需为每类 Tool 维护 projector |
| 长期审计 | metadata-only | 永久保存请求和响应 | 降低泄露与重复真相 | 深度回放受 TTL 限制 |
| 证据安全 | 确定性 EvidenceGuard + 隔离 SemanticGuard | 单一 Verifier Agent | 分清真实性和支持度 | 两段门禁增加延迟 |
| 修复 | 只修引用且语义必须不变 | Guard 重写报告 | 保持唯一作者 | 部分报告只能 Fallback |
| 停止 | 预算 + 信息增益双机制 | 只靠 Prompt 或硬上限 | 正常无证据收敛 | 首版只能做参数级重复判断 |
| 无结论 | 合法 Draft + SafeFallback | 强制根因 | 避免盲查和编造 | 调用方需理解 FALLBACK 是业务结果 |
| 发布 | 唯一 Release Policy | 各层自行 fallback | 对外语义一致 | Release 成为关键集中组件 |
## 9. Harness 明确不做什么
边界是否清晰,既看它做什么,也看它拒绝做什么:
- 不判断业务根因;
- 不实现 Planner / Executor / Verifier / Composer 角色图;
- 不在框架外复制 ReAct while-loop;
- 不让 Tool 声称结果是否支持诊断结论;
- 不把检索分数直接提升为结论可信度;
- 不依赖 Prompt 作为唯一预算或停止机制;
- 不自动重试 Diagnosis Agent 和业务 Tool;
- 不允许 Guard 生成新事实或改写结论;
- 不把 Redis canonical 变成永久审计库;
- 不承诺同步 Provider 调用一定能被立即物理中断;
- 不在首版做自然语言语义去重或跨 Tool 内容指纹。
这些非目标是在防止 Harness 再次长成一套不可维护的业务编排系统。
## 10. 如何验证设计成立
验证重点不是某个类是否被调用,而是上述不变量能否在失败和竞态下保持:
| 验证层 | 需要证明的事实 | 代表性测试/证据 |
|---|---|---|
| Core 单测 | deadline、取消、预算、first-terminal-wins、异步显式 context | `RunContextTest`、`RunBudgetTest`、`DiagnosisHarnessCoreTest` |
| Tool 单测 | cross-run、重复 ID、只读、大小、状态迁移、投影 | `ToolBoundaryTest`、`CanonicalInvocationStoreTest`、各 Projector test |
| Agent loop | framework ID、Envelope、STOP_REQUIRED、非法 Draft | `DiagnosisAgentUseCaseTest`、`HarnessToolInterceptorTest` |
| Guard / Release | 引用闭包、Repair 语义不变、Unsupported Fallback | `EvidenceGuardTest`、`SemanticGuardTest`、`DiagnosisReleaseUseCaseTest` |
| Audit | metadata 边界、Token 对账、拒绝与执行分离 | audit 包 focused tests、exact-run Trace |
| Live E2E | 生产序列化、Redis、数据库、模型和 SSE 的组合契约 | `devflow/projects/2026-07-22-single-react-cleanup-e2e/`、ISS-016 evidence |
单元测试能证明局部状态机,不能证明生产 `ObjectMapper`、Redis serializer、Provider Tool Calling 和 SSE 串联正确。因此 canonical store 的 Java Time 问题、零日志语义和协议空转都必须依靠 live E2E 反证,而不能只看 mock tests。
## 11. 当前边界与后续治理
当前设计已经建立可运行的确定性边界,但仍有明确限制:
1. 重复检测是 `tool_name + normalized_scope` 的参数等价,不识别语义近似查询。
2. Redis canonical 受 TTL 约束;TTL 过后只能依赖 metadata audit,不能重建完整证据正文。
3. 同步模型请求的取消主要阻止后续边界和迟到发布,不承诺 Provider 已执行计算立即停止。
4. SemanticGuard 仍是模型判断,只是被限制在无 Tool、无记忆、二值输出的最小范围内。
5. Reasoning 已与普通 Trace 隔离,但访问控制、保留期限和加密仍需完成。
6. 信息增益阈值默认为 2,仍需通过固定评测集持续校准,不能通过线上直觉随意调大。
## 12. 可复用的设计原则
这套 Harness 最值得复用的不是某个 Java 类,而是以下判断顺序:
1. 先列出系统必须保证的确定性不变量,再决定组件。
2. 将推理权留给模型,将授权、预算、归属、验真和发布权留给代码。
3. 不让一个数据表示同时承担内部真相、模型上下文和长期审计。
4. 不用资源耗尽代替业务收敛,也不用 Prompt 代替硬门禁。
5. 不让验证器成为第二个作者;验证失败时降级,而不是偷偷改写。
6. 把“没有足够证据”设计成一等业务结果,系统才不需要用幻觉换取成功率。
## 13. 代码与文档入口
- 完整组件说明:[Harness组件全景-职责-设计原因与边界.md](Harness组件全景-职责-设计原因与边界.md)
- 当前质量门禁:[harness-quality-gates.md](../../architecture/harness-quality-gates.md)
- Diagnosis Agent 架构:[agent-orchestration.md](../../architecture/agent-orchestration.md)
- 信息增益停止:[diagnosis-information-gain-stop-architecture.md](../../architecture/diagnosis-information-gain-stop-architecture.md)
- 核心重构 Issue:[ISS-014-single-react-agent-harness-aci-ptk-refactor.md](../../issues/archived/ISS-014-single-react-agent-harness-aci-ptk-refactor.md)
- 信息增益 Issue:[ISS-016-diagnosis-information-gain-stop-contract.md](../../issues/active/ISS-016-diagnosis-information-gain-stop-contract.md)
@@ -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,276 @@
# Harness 证据安全链:从引用真实到结论可发布
**更新日期**:2026-07-29
**主题**:EvidenceGuard、EvidenceRepair、SemanticGuard 与 Diagnosis Release
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
**前置阅读**:[Harness-Tool双视图-从原始结果到可验证证据.md](Harness-Tool双视图-从原始结果到可验证证据.md)
## 1. 证据存在,不代表结论成立
诊断报告至少可能出现三类不同错误:
1. **伪造引用**:Draft 引用了不存在、失败或属于其他 Run 的 Tool Call。
2. **引用断裂**:analysis 有证据,但 conclusion、action plan 或 recommendation 没有绑定对应 analysis。
3. **牵强推论**:引用和结构都真实,但证据并不足以支持所写根因。
例如,系统确实查到了一条支付超时日志,但报告把根因写成“数据库连接池耗尽”。此时 Tool Call 真实、日志真实、引用格式也正确,结论仍然不成立。
因此“报告是否安全”不能由一个笼统的 Verifier 回答。它至少包含两个不同命题:
```text
P1:引用的证据是否真实、属于当前 Run,并形成结构闭包?
P2:这些真实证据是否足以支持报告中的结论?
```
P1 可以由代码确定性证明;P2 是语义判断。把两者都交给模型,会让本可机械验证的身份和状态也变成概率结果;全部交给规则,则规则代码会逐渐变成另一套业务诊断引擎。
## 2. 设计目标:验证链不能产生新事实
证据安全链遵守四个约束:
- Diagnosis Agent 是唯一报告作者;
- EvidenceGuard 只做确定性验真;
- SemanticGuard 只做受限语义审查;
- Release 是唯一公开决策点。
任何 Guard 都不能调用业务 Tool、探索新证据或重写最终报告。否则验证链会变成第二条隐式 Agent 链,重新引入多 Agent 架构的问题。
```mermaid
flowchart LR
D["DiagnosisDraft<br/>唯一业务 Draft"] --> R["DiagnosisReleaseUseCase<br/>发布编排所有者"]
R --> E["EvidenceGuard<br/>确定性引用验真"]
E -->|"invalid"| X["EvidenceRepair<br/>只修结构/引用"]
X --> E2["EvidenceGuard Recheck"]
E -->|"valid"| V["VerifiedEvidenceSnapshot"]
E2 -->|"valid"| V
V --> S["SemanticGuard<br/>SUPPORTED / UNSUPPORTED"]
S -->|"SUPPORTED"| OK["发布原始或等义修复 Draft"]
E -->|"repair 失败"| F["SafeFallback"]
E2 -->|"invalid"| F
S -->|"UNSUPPORTED / unavailable"| F
```
## 3. 第一层:EvidenceGuard 证明引用真实性
`EvidenceGuard` 不调用模型。它从当前 `RunContext` 出发,对 Draft 进行两阶段检查。
### 3.1 Draft 结构与引用闭包
首先检查报告自身结构:
- Draft、analysis、analysis ID、kind 和正文是否存在;
- analysis ID 是否重复;
- 每条有结论分析是否绑定 Tool Call;
- conclusion、action plan 和 recommendation 是否绑定已存在的 analysis ID;
- limitations 是否存在并声明报告范围。
引用链的目标不是让 JSON 看起来完整,而是形成下面的闭包:
```text
Conclusion / Action / Recommendation
|
v
Analysis Item
|
v
framework tool_call_id
|
v
Canonical READY Invocation
```
只要其中一跳缺失,公开报告就不能证明其来源。
### 3.2 Canonical Invocation 验真
对每个 `tool_call_id`,Guard 使用当前 `runId` 重新生成 canonical key,然后检查:
1. ID 格式是否合法;
2. canonical record 是否存在;
3. record 内 ID 是否与引用一致;
4. record 是否属于当前 Run;
5. invocation 是否 READY;
6. analysis kind 是否接受该 evidence status;
7. Tool 类型是否受支持;
8. `agent_result` 是否能严格解析为对应 typed result;
9. typed result 中的 ID、状态、count 和集合是否自洽。
因此,即使模型猜中了另一个 Run 的 Tool Call ID,也无法通过当前 Run key 和 ownership 检查。
### 3.3 正向证据和负向观察不能混用
`AnalysisKind` 与 `EvidenceStatus` 的匹配是关键约束。
一条 READY + NO_EVIDENCE 日志结果,只能支持如下陈述:
> 在企业 A、时间窗 T、服务 S、查询条件 Q 下没有匹配事件。
它不能支持:
> 系统没有发生故障。
EvidenceGuard 会把空结果投影为带 source 和 scope 的负向观察,而不是把它提升为支持任意根因的正向证据。
## 4. VerifiedEvidenceSnapshot 为什么是必要中间产物
EvidenceGuard 验证通过后不会把 canonical raw 直接交给 SemanticGuard,而是生成 `VerifiedEvidenceSnapshot`。它保存:
- analysis ID、正文和 kind;
- 已验证证据的 source type、source、scope、timestamp、excerpt;
- Tool-specific 的有限结构化 values;
- 去重后的 verified sources。
Snapshot 的作用是形成一条新的最小信任边界:SemanticGuard 不需要访问 Redis,也不能看到 request、raw response 或内部错误。它只判断“这组已经验真的事实是否支持 Draft”。
这也避免了语义审查阶段重新解释 backend 私有格式。
## 5. 第二层:EvidenceRepair 只允许修引用
### 5.1 为什么需要 Repair
模型可能生成业务语义正确、但引用结构存在局部问题的 Draft,例如:
- conclusion 漏写 `based_on_analysis_ids`;
- analysis 引用了错误的 Tool Call ID;
- 输出字段结构不符合严格 Schema。
如果所有结构错误都直接 Fallback,会丢掉部分可修复结果。但让 Repair 自由重写又会产生更严重的问题:最终结论不再来自 Diagnosis Agent,Repair 事实上成为第二个报告作者。
### 5.2 Repair 的不变量
`EvidenceRepair` 只获得:
- 原始 query;
- 原始 Draft;
- EvidenceGuard 给出的 violations。
它没有 Tool,也不能获取新证据。修复输出必须再次严格解析,并满足:
```text
SemanticDraftView(original)
==
SemanticDraftView(repaired)
```
即用户可见的 analysis、conclusion、action plan、recommendations 和 limitations 语义不变,只允许修复引用和结构。之后必须重新执行 EvidenceGuard,不能因为“已经 Repair”就跳过验真。
当前策略只给 EvidenceRepair 一次 attempt。Repair 失败、改变语义或复检仍不通过,直接进入 `EVIDENCE_VALIDATION_FAILED` Fallback。
## 6. 第三层:SemanticGuard 判断结论支持度
EvidenceGuard 证明的是“证据是真的”,SemanticGuard 判断的是“推论是否成立”。为了不让它变成第二个 Agent,系统主动削减了它的能力:
| 约束 | 目的 |
|---|---|
| 无 Tool | 不能自行寻找新事实 |
| 无会话记忆 | 不能引入当前输入之外的信息 |
| 单轮调用 | 不形成新的 ReAct loop |
| 输入固定 | 只接收 query、Draft 和 VerifiedEvidenceSnapshot |
| 输出严格 | 只能返回 `verdict + reason` |
| 二值 verdict | 只允许 `SUPPORTED / UNSUPPORTED` |
| 无发布权限 | 不能改写或直接返回用户报告 |
技术超时、传输失败、解析失败和 Schema 非法可以按完全相同输入进行一次显式重试。仍失败时不假设“可能支持”,而是 fail closed,发布 `SEMANTIC_UNAVAILABLE` Fallback。
这里需要准确描述“确定性边界”:SemanticGuard 的语义判断仍然是概率性的;确定的是它的权限、输入、输出、成本、重试次数和失败后的发布行为。
## 7. Release 为什么必须是唯一发布入口
如果 Application、EvidenceGuard 和 SemanticGuard 都能构造公开结果,同一种失败会出现多个含义,甚至可能绕过前置校验。`DiagnosisReleaseUseCase` 集中处理以下分支:
| 输入情况 | 验证路径 | 发布结果 |
|---|---|---|
| Draft 有 conclusion | EvidenceGuard -> 可选 Repair/Recheck -> SemanticGuard | 原 Draft/等义修复 Draft,或安全 Fallback |
| Draft 无 conclusion,有已验真进展 | 只验证其中已有 Tool 引用 | `INSUFFICIENT_EVIDENCE` |
| Draft 无 conclusion,无进展但声明缺失信息 | 检查引用边界 | `MISSING_REQUIRED_CONTEXT` |
| Harness 受控停止,有已验真进展 | ProgressSnapshot | `INSUFFICIENT_EVIDENCE` |
| Draft 非法,但有已验真进展 | 丢弃非法 Draft,使用 ProgressSnapshot | `INSUFFICIENT_EVIDENCE` |
| Draft 非法且没有安全进展 | 无可发布事实 | FAILED,保持 fail closed |
有结论时,只有 `SUPPORTED` 可以发布 Draft。发布的是 Diagnosis Agent 原始 Draft,或者经证明用户可见语义相同的 Repair Draft,不让 SemanticGuard生成“更好的答案”。
## 8. `conclusion=null` 为什么不进入完整语义审查
当模型明确表示无法确认根因时,不存在需要验证的根因结论。继续调用 EvidenceRepair 或 SemanticGuard,不仅浪费 Token,还可能让验证模型反向补出一个原本不存在的结论。
因此无结论路径只做必要的引用真实性检查,然后根据安全进展决定:
```text
有 verified progress
-> INSUFFICIENT_EVIDENCE
没有 progress,但有 limitations.missing_info
-> MISSING_REQUIRED_CONTEXT
两者都没有
-> 不是合法业务结果,fail closed
```
这项设计把“没有找到根因”从模型失败中分离出来,但没有降低证据要求。
## 9. SafeFallback 不是通用错误文案
SafeFallback 是结构化、确定性的发布结果。它可以包含:
- `verified_sources`:通过当前 Run 验真的来源;
- `observed_facts`:有界、去重后的事实或负向观察;
- `limitations`:为什么不能确认根因;
- `next_steps`:下一步需要补充的信息或检查;
- `validation_issues`:稳定违规码和目标字段;
- `failure_stage`:失败发生在收集、证据还是语义阶段。
它不能包含 Prompt、原始 Draft、raw Tool payload、内部异常或模型 reasoning。Fallback 不是“把错误吞掉”,而是只发布系统已经能够证明的部分。
## 10. 为什么不采用其他方案
### 10.1 单个 Verifier Agent
优点是实现表面简单,缺点是把 ID、Run ownership、Schema 和业务支持度混在同一次模型判断中。本来可以 100% 用代码拒绝的跨 Run 引用,也会变成概率审查。
### 10.2 Guard 自动改写报告
可以提高表面成功率,但破坏唯一作者原则。Guard 为了“修好”结论,往往会引入新解释;此时必须重新验证新内容,最终形成循环。
### 10.3 只做引用存在检查
能阻止伪造 ID,却无法阻止“真实证据 + 错误根因”。这正是 SemanticGuard 存在的原因。
### 10.4 语义失败直接技术报错
丢失了已经验真的事实,也把“结论不受支持”错误表达为基础设施故障。SafeFallback 可以保留过程价值,同时拒绝发布根因。
## 11. 真实问题如何改变设计
| 问题 | 暴露的设计缺陷 | 修正 |
|---|---|---|
| 旧 Verifier 同时验引用、判断语义和改写答案 | 验证职责没有边界 | EvidenceGuard、SemanticGuard、Release 三段拆分 |
| Tool 结果面向开发者,含 raw 和重复正文 | Guard 与 Agent 没有独立事实面 | 先建立 canonical truth 和 verified snapshot |
| `NO_EVIDENCE` 被当成一般失败或正向证据 | 负向观察没有范围约束 | AnalysisKind 与 EvidenceStatus 匹配校验 |
| Repair 可能改变结论 | 修复者变成新的报告作者 | SemanticDraftView 前后等义检查 |
| 非法 Draft 使已完成 Tool 检查全部丢失 | Draft 是唯一可发布价值来源 | 只在 ProgressSnapshot 已验真时允许过程型 Fallback |
| SemanticGuard 技术失败时行为不一致 | 各层自行决定降级 | Release 统一生成 `SEMANTIC_UNAVAILABLE` |
## 12. 代价与剩余风险
1. SemanticGuard 增加一次模型调用和延迟;这是语义安全与成本之间的明确取舍。
2. Semantic verdict 不是形式化证明,仍可能误判;当前设计限制的是权限和失败影响,而不是宣称模型绝对正确。
3. EvidenceGuard 需要理解每种 Tool 的 typed result;新增 Tool 必须同步增加验证规则。
4. Repair 的语义等价由结构化视图定义,无法证明两个自然语言文本在所有解释下完全等价,因此 Repair 能力被刻意限制。
5. Canonical record TTL 到期后无法重新完成完整验真,所以公开决策必须在当前 Run 内完成。
## 13. 如何验证
| 需要证明 | 代表性验证 |
|---|---|
| 缺失、重复、未知 analysis 引用被拒绝 | `EvidenceGuardTest` |
| cross-run、非 READY、ID/status 不一致被拒绝 | `EvidenceGuardTest` + canonical fixtures |
| NO_EVIDENCE 只能形成限定负向观察 | Evidence kind focused cases |
| Repair 不得改变用户可见语义 | `DiagnosisReleaseUseCaseTest`、Repair tests |
| Semantic 非法输出只有限重试并安全降级 | `SemanticGuardTest` |
| Guard 不改写报告,Release 只发布原 Draft 或 Fallback | `DiagnosisReleaseUseCaseTest` |
| 无结论、非法 Draft、受控停止正确映射 | `DiagnosisChatExecutorTest`、Release focused cases |
| exact-run Trace 能解释每一道门禁 | live E2E Timeline |
## 14. 与前后链路的关系
证据安全链依赖 [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)提供独立 canonical truth;它解决的是“哪些内容能够发布”,不负责判断 Agent 是否还应该继续取证。正常收敛由[Harness信息增益停止-让无证据诊断正常收敛.md](Harness信息增益停止-让无证据诊断正常收敛.md)负责。
@@ -0,0 +1,304 @@
# Harness 面试速查:用一张图讲清设计
这篇文章是 Harness 系列的收尾,不增加新的组件和状态。它把现有设计压缩成一套可在面试中逐层展开的叙事:先用一句话定义,再用一张图说明边界,最后根据追问进入事实、停止、发布和失败设计。
如果只剩 10 分钟,阅读第 1、2、4 和 8 节即可。
## 1. 30 秒回答:什么是 Harness
> Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。
这段回答包含三个重点:
```text
Agent 负责业务推理
Harness 负责确定性约束
Release 决定什么可以公开
```
不要一开始列 10 个职责域。面试官追问“具体怎么做”时,再沿下面的总图展开。
## 2. 一张图讲清完整设计
```mermaid
flowchart LR
U["用户问题"] --> APP["Chat Application<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 节进入专题即可,不需要重新从组件清单开始阅读。
+86
View File
@@ -0,0 +1,86 @@
# Harness:先从一次诊断请求开始
这不是 Harness 的完整说明,而是一页入门导读。
第一次阅读时,不需要记组件名,不需要看状态枚举,也不需要理解所有边界。先回答一个问题:**为什么 Diagnosis Agent 外面还需要一层 Harness?**
## 1. 如果只有 Agent,会发生什么
假设用户问:
> 为什么支付服务在 10:00 到 10:10 大量超时?
Diagnosis Agent 会自己决定查什么、调用哪些 Tool、什么时候停止,并根据返回结果写出结论。这里有四个不能只靠 Prompt 解决的问题:
- 它可能查询过多,耗尽时间和 Token;
- Tool 原始结果可能包含敏感或超大内容,不能直接交给模型;
- 它引用了某条日志,不代表这条日志真的来自本次查询;
- 它写出了一个看似合理的根因,不代表证据足以支持这个根因。
这些都不是“诊断能力”问题,而是**执行是否受控、结果是否可信**的问题。Harness 就是为此存在的。
## 2. Harness 在一次请求中做了什么
```mermaid
flowchart LR
Q["用户提出诊断问题"] --> R["给本次执行建立 Run<br/>限制时间和资源"]
R --> A["Agent 分析问题<br/>选择 Tool"]
A --> T["Harness 执行 Tool<br/>保存事实,只给 Agent 安全视图"]
T --> A
A --> D["Agent 写出诊断草稿"]
D --> G["Harness 检查<br/>引用是否真实、证据是否支持结论"]
G --> P["发布报告<br/>或安全地说明证据不足"]
```
顺着这条线看,Harness 只做三类事情:
1. **执行前设边界**:为本次请求建立身份、deadline、预算和取消能力。
2. **执行中管事实**:所有 Tool 经过统一入口,完整事实由系统保管,模型只看到安全、有限的内容。
3. **发布前做验证**:先验证引用,再判断证据是否支持结论;不满足条件就发布 Fallback,而不是让未经验证的结论出去。
Agent 仍然负责“问题的根因是什么”。Harness 不替 Agent 推理,它负责的是:**让这次推理有边界、有证据、能停止。**
## 3. 先建立这个最小心智模型
```mermaid
flowchart TB
A["Diagnosis Agent<br/>负责业务推理"]
H["Harness<br/>负责确定性控制"]
U["用户最终看到的结果"]
A -->|"提出 Tool 请求和诊断草稿"| H
H -->|"返回受控的 Tool 观察"| A
H -->|"验证通过后发布,失败则 Fallback"| U
```
到这里,第一次阅读就可以停下。只要能说清下面这句话,就已经抓住了主干:
> Agent 决定如何诊断,Harness 决定这次诊断可以消耗什么、可以看到什么、何时必须停止,以及什么结果允许发布。
## 4. 需要时再往下读
不要按目录顺序阅读。遇到具体问题时,只进入对应文档:
| 当你想知道 | 再阅读 |
|---|---|
| 准备面试,想用一张图快速复习完整设计 | [Harness 面试速查](Harness面试速查-一张图讲清设计.md) |
| 想看一次 Run 的资源预算如何被门禁控制 | [RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
| 想复习终态检查与取消广播(checkActive / onCancel) | [Harness 执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) |
| 想复习重试机制(分类裁决 / 剩余超时 / 幂等性) | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
| 想看当前组件学习进度与规划 | [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) |
| Tool 为什么要保存一份事实、给模型另一份视图 | [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) |
| 如何阻止“引用是真的,但结论是错的” | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
| Agent 为什么不会无限查询 | [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) |
| 某个类属于哪里、负责什么 | [组件全景](Harness组件全景-职责-设计原因与边界.md) |
| 某个名词或状态是什么意思 | [Context 词典](CONTEXT.md) |
如果准备系统学习,建议先读组件渐进式导读的四篇文章。每次只读一篇,读到“先记住这些”就可以停止。
“组件全景”和“Context”都是参考手册,不需要从头读,也不需要背。
@@ -0,0 +1,242 @@
# Harness Retry 重试机制:显式、可计量、可审计的 attempt 循环
**用途**:面试讲解与复习 Harness 重试设计的完整文档。回答"为什么重试权归 Harness、重试如何被分类裁决、如何防重试失控"。
**代码基线**:`com.superbiz.agent.harness.retry` + `guard/semantic/GuardModelCall`
**配套**:[Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md)
## 1. 一句话核心
> 重试不是通用的容错开关,而是**显式的、分类驱动的、可审计的 attempt 循环**:HarnessRetryExecutor 统一执行,RetryFailure 分类决定"哪种失败能重试",RetryPolicy 决定"最多试几次",每次 attempt 都受 Run 门禁控制、真实消耗预算、并记录到 Trace。SDK 的隐式重试被关闭(`spring.ai.retry.max-attempts: 1`),因为重试必须是调用者的有意识决策,且必须可计量。
## 2. 设计动机:为什么重试权归 Harness
### 2.1 问题:SDK 在内部悄悄重试
Spring AI 默认 `maxAttempts=10`,RetryTemplate 包在 `ChatModel.call()` 内部。Harness 拦截器在**外面**,只看到一次调用入口,实际却发生了多次 Provider attempt:
```text
Harness 视角: "我调了一次模型,扣了一次预算"
实际发生: Provider 内部悄悄试了 10 次(9 次失败 + 1 次成功)
```
后果:预算失真、Trace 失真、取消失效、成本失控——**"重试"这个决定被藏在 SDK 内部,Harness 看不见、管不着、记不了账**。
### 2.2 钩子只能观察,不能控制
框架确实提供观察钩子(Spring Retry 的 `RetryListener`:open/onError/onSuccess),但钩子只能"看见"重试,不能"控制"重试:
| 需要的能力 | RetryListener 能吗 |
|---|---|
| attempt 之间检查 Run 是否还 active(取消/预算/超时后立刻停) | 不能(只是通知,不能中断循环) |
| 每个 attempt 前扣预算 | 不能 |
| 根据 Run 状态决定放弃重试 | 不能(不知道 RunContext) |
**SDK 一旦开始重试,即使 Run 已取消或预算耗尽也会继续**。所以取舍是"关闭 SDK 重试 + 重试外移到 Harness 自己控制",让每个 attempt 都成为完整可控点。
### 2.3 决策
```text
① 关闭 SDK 隐式重试:spring.ai.retry.max-attempts: 1(测试锁定,防回归)
② 失败分类:RetryFailure——只有技术类失败才可能重试
③ 策略与执行分离:RetryPolicy(静态配置) + HarnessRetryExecutor(执行循环)
④ 每次 attempt 都 checkActive、扣预算、记录——完全可控可计量
```
## 3. 架构:Retry 在调用链中的位置
```mermaid
flowchart LR
CALLER["IntentRouter / SemanticGuard / EvidenceRepair"]
CALLER -->|"execute(context, policy, operation, classifier, recorder)"| R["HarnessRetryExecutor<br/>attempt 循环 + 双条件裁决"]
R -->|"每次 attempt: operation.execute()"| G["GuardModelCall<br/>单次调用边界"]
G -->|"beforeModelCall"| C["HarnessCore<br/>checkActive + 预算预扣"]
G --> M["ChatModel(SDK retry=1)"]
R -.->|"每次 attempt 收据"| T["Trace / Audit<br/>RetryAttempt"]
style R fill:#e6f4ff,stroke:#0958d9
style G fill:#f6ffed,stroke:#389e0d
```
## 4. 核心组件
| 类型 | 角色 | 内容 |
|---|---|---|
| `RetryFailure` | 失败分类枚举(10 种) | 可重试组(技术类)vs 绝不重试组(业务/系统事实) |
| `RetryPolicy` | 不可变策略 | `maxAttempts(1或2) + retryableFailures`,不是布尔 `retry=true` |
| `HarnessRetryExecutor` | 统一重试循环 | 每个 attempt 前 checkActive,4 条异常路径分支 |
| `RetryAttempt` | 单次 attempt 收据 | 序号 + 成败 + 失败类型;经 recorder 送 Trace |
| `RetryExecutionException` | 重试终止异常 | `attempts + failure`,保留最终失败 |
## 5. 时序图:一次带重试的调用(失败→重试→成功)
```mermaid
sequenceDiagram
participant R as HarnessRetryExecutor
participant G as GuardModelCall
participant C as HarnessCore
participant M as ChatModel(Provider)
participant T as Trace/Audit
R->>R: attempt=1
R->>C: checkActive(Run 仍可执行)
R->>G: operation.execute()(模型调用 + 严格解析)
G->>C: beforeModelCall(扣 1 次模型预算)
G->>M: chatModel.call(prompt, timeout=remaining)
M--xG: 超时 / 传输失败
G-->>R: GuardModelCallException(TIMEOUT)
R->>R: classify → TIMEOUT
R->>T: recorder.failed(1, TIMEOUT)
R->>R: policy.allowsRetry(1, TIMEOUT)?→ 是
R->>C: checkActive(第二次 attempt 前再查)
R->>G: operation.execute()(attempt=2)
G->>C: beforeModelCall(再扣 1 次预算)
G->>M: chatModel.call(prompt, timeout=remaining 递减)
M-->>G: 合法响应
G-->>R: 解析成功
R->>T: recorder.succeeded(2)
R-->>调用方: 返回业务结果(T)
```
## 6. 失败分类与双条件裁决
### 6.1 RetryFailure:什么失败能重试
```text
可重试组(技术类,重试可能成功):
TIMEOUT / TRANSPORT / INVALID_OUTPUT / PARSE_ERROR / SCHEMA_INVALID
绝不重试组(业务/系统事实,重试不会改变结果):
NO_EVIDENCE / BUSINESS_REJECTION / CANCELLED / BUDGET_EXHAUSTED / UNKNOWN
```
关键:**"业务无证据"(NO_EVIDENCE)不是技术失败**——重试不会让证据出现。取消、预算耗尽更不能被重试吞掉。
### 6.2 双条件裁决
```mermaid
flowchart TD
A["attempt 开始"] --> B{"checkActive?"}
B -->|"Run 已终止"| X1["抛 RunAbortedException<br/>不重试"]
B -->|"可执行"| C["operation.execute()"]
C -->|"成功"| S["return 业务结果"]
C -->|"RunAborted"| X2["分类 BUDGET_EXHAUSTED / CANCELLED<br/>立即抛,不重试"]
C -->|"BudgetExceeded"| X3["BUDGET_EXHAUSTED<br/>立即抛,不重试"]
C -->|"其他异常"| CL["classifier.classify()<br/>null → UNKNOWN"]
CL --> P{"allowsRetry?<br/>attempt < maxAttempts<br/>&& failure ∈ retryableFailures"}
P -->|"是"| A
P -->|"否"| X4["RetryExecutionException<br/>(attempts, failure)"]
```
```java
public boolean allowsRetry(int completedAttempts, RetryFailure failure) {
return completedAttempts < maxAttempts // 条件1:还有剩余次数
&& retryableFailures.contains(failure); // 条件2:失败类型可重试
}
```
## 7. 剩余超时递减:两层超时防撑爆总时间
每个可重试组件有两套超时:`perAttemptTimeout`(单次)+ `totalTimeout`(整体)。
```mermaid
flowchart LR
T["totalTimeout(总封顶)<br/>Router:25s / Semantic:45s"] -->|"每次计算剩余"| R["remaining = total - elapsed<br/>elapsed = now - 固定起点"]
R -->|"attempt 实际超时"| A["min(remaining, perAttemptTimeout)"]
A -->|"剩余 <= 0"| E["直接抛 TIMEOUT<br/>不发起注定失败的调用"]
```
```text
Router(perAttempt=10s, total=25s):
第一次 attempt:remaining = min(25, 10) = 10s → 用 9s 失败
第二次 attempt:remaining = 25 - 9 = 16 → min(16, 10) = 10s → 用 10s 失败
第三次 attempt:remaining = 25 - 19 = 6s → 6s 到点直接 TIMEOUT
总耗时 = 25s,被 totalTimeout 精确封顶
```
要点:
- **起点固定**:`startedNanos` 在 execute 前取一次,operation lambda 捕获它——每次 attempt 用同一起点算 elapsed,之前 attempt 的耗时自然累计
- **单次超时管"一次别太久",剩余递减管"总共别太久"**
- 时间计算用 `System.nanoTime()`(单调时钟),不受系统时间调整影响
## 8. 三重止损:次数 / 时间 / 成本
```mermaid
flowchart TB
B["次数封顶<br/>RetryPolicy.maxAttempts(1 或 2)"] --- S["重试不会失控"]
T["时间封顶<br/>totalTimeout + 剩余递减"] --- S
C["成本封顶<br/>每个 attempt 扣预算"] --- S
S["三层独立、互相兜底"]
```
- 即使未来把 maxAttempts 调大,总时间仍然被封死
- 预算一耗尽(`BudgetExceededException`)重试立即停止——不重试是防止继续烧预算
## 9. 幂等性假设:为什么 Agent / Tool 不重试
重试的前提是**副作用幂等**(执行 N 次 = 执行 1 次的外部效果)。但幂等是必要条件,不是充分条件:
| 组件 | 副作用幂等 | 重试策略 | 原因 |
|---|---|---|---|
| IntentRouter | ✅(单轮无状态判定) | 2 次 | Harness 拥有调用控制权、成本低、重试结果都是合法判定 |
| SemanticGuard | ✅(单轮无状态判定) | 2 次 | 同上 |
| Diagnosis Agent | ❌(多轮有状态 loop) | 1 次 | 轮级重试需侵入框架;失败走受控停止 → Fallback |
| 业务 Tool | ✅(只读)但**有成本** | 1 次 | 重试决策权在 Agent(入参可能不同);后端执行昂贵;Agent 自有重试语义 |
| EvidenceRepair | 状态相关 | 1 次 | 失败走 Fallback 更安全 |
关键区分:
```text
副作用幂等 vs 结果幂等:
Router 重试结果可能不同(模型非确定性),但每次都只是"一次独立判定"——无副作用、不破坏状态
→ "结果变了也没关系"的正确表述:重试只在第一次失败(无结果)时发生,重试结果是唯一判定,不存在覆盖
只读 ≠ 免费:
业务 Tool 技术上幂等(只读),但每次执行消耗真实后端资源——重试是成本决策,不是安全决策
```
## 10. 与预算 / 取消的关系
```text
预算:每个 attempt 都走 GuardModelCall → beforeModelCall → 扣 1 次模型调用额度
2 次 attempt = 2 次配额;配额耗尽 → BUDGET_EXHAUSTED → 不重试
取消:每次 attempt 前 checkActive——取消发生在 attempt 之间时,第二次调用被拦下
GuardModelCall 挂 onCancel → future.cancel(true) → 正在等待的 attempt 可被打断
```
## 11. 面试话术
**为什么重试权归 Harness**:
> SDK 默认在 ChatModel 外包装 RetryTemplate 悄悄重试 10 次,Harness 只看到一次入口、计量却失真。我们通过 spring.ai.retry.max-attempts: 1 关掉它,并用专门的配置测试锁死防回归——这样一次 ChatModel 调用对应一次真实 Provider attempt,预算和 Trace 才可计量。框架的 RetryListener 钩子只能观察不能控制,所以重试外移到 Harness 自己的 RetryExecutor。
**分类与裁决**:
> 重试先分类再决策:RetryFailure 区分技术失败(超时、传输、解析、schema)和业务失败(无证据、业务拒绝、取消、预算耗尽),只有技术类才允许重试;RetryPolicy 限定每个组件最多 2 次。每次 attempt 前 checkActive、每个 attempt 真实扣预算并记录,所以"试了几次、为什么停"完全可审计。
**为什么 Agent / Tool 不重试**:
> Router 和 SemanticGuard 是 Harness 自己发起的单轮无副作用判定,重试便宜且结果独立;Diagnosis Agent 是多轮有状态 loop,重试某一轮会破坏循环上下文,整个重试成本翻倍且破坏收敛——失败走受控停止到 Fallback 是设计好的结局;业务 Tool 虽只读但重试决策权在 Agent(下一轮入参可能不同),且后端执行昂贵。
## 12. 自测
1. 为什么关闭 SDK 隐式重试?不关会发生什么计量失真?(一次入口 vs 10 次 Provider attempt,预算/Trace/取消/成本)
2. RetryPolicy 的双条件裁决是哪两个?NO_EVIDENCE 为什么永不重试?
3. 剩余超时递减怎么防止重试撑爆总时间?为什么起点必须固定?
4. 为什么 Agent 不重试?"保留前 2 轮重试第 3 轮"技术上可行为什么系统不做?
5. 业务 Tool 是只读的(幂等),为什么不重试?
## 13. 代码位置
| 内容 | 位置 |
|---|---|
| 重试循环(4 条路径) | `harness/retry/HarnessRetryExecutor.java` |
| 双条件裁决 | `harness/retry/RetryPolicy.java` |
| 失败分类 | `harness/retry/RetryFailure.java` |
| 组件策略 | `harness/retry/HarnessRetryPolicies.java` |
| attempt 收据 | `harness/retry/RetryAttempt.java` |
| 单次调用边界 | `harness/guard/semantic/GuardModelCall.java` |
| 剩余超时计算 | `harness/application/routing/IntentRouter.java`(remaining) |
| 关闭 SDK 重试 | `src/main/resources/application.yml` + `SpringAiRetryConfigurationTest` |
| 行为契约测试 | `src/test/.../retry/HarnessRetryExecutorTest.java` |
@@ -0,0 +1,251 @@
# RunBudget 预算流程:一次 Run 的资源门禁时序图
**用途**:面试讲解 RunBudget 用的聚焦时序图,回答"一次 Run 的资源消耗是如何被门禁控制的"。
**代码基线**:`RunContext` → `RunBudget` + `RunBudgetLimits` + `RunCapacityCounter`
## 1. 在完整 Harness 中的位置(极简上下文)
RunBudget 是 `RunContext` 里的一个执行控制句柄,两个门禁经过它:
```mermaid
flowchart LR
MI["ModelInterceptor<br/>每轮模型调用前"] -->|"beforeModelCall"| CORE["DiagnosisHarnessCore"]
TI["ToolInterceptor / ToolBoundary<br/>每次 Tool 执行前"] -->|"beforeToolCall"| CORE
CORE --> B["RunBudget<br/>预扣 + 超限升级"]
T["Canonical 写入前"] -->|"reserveRunBytes"| CORE
B --> E["BudgetExceededException →<br/>finish(BUDGET_EXHAUSTED) + cancel"]
```
## 2. RunBudget 流程时序图(核心)
```mermaid
sequenceDiagram
participant APP as ChatApplication
participant CORE as DiagnosisHarnessCore
participant MI as ModelInterceptor
participant TI as ToolInterceptor
participant T as ToolBoundary
participant B as RunBudget
participant C as RunCapacityCounter(CAS)
Note over APP,C: 启动:startRun(sessionId) → new RunBudget(RunBudgetLimits)<br/>句柄挂到 RunContext,随请求显式传递
loop 每一轮模型调用
MI->>CORE: beforeModelCall(context)
CORE->>CORE: checkActive() 先确认 Run 还能跑
CORE->>B: reserveModelCall() 预扣 1 轮
alt 超限
B-->>CORE: BudgetExceededException(MODEL_CALLS)
CORE->>CORE: exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel()
CORE-->>MI: 抛异常,之后所有 checkActive 拒绝
end
CORE->>B: recordTokens(input, output) 调用后记账
B->>B: 三档检查 input / output / total
end
loop 每一次 Tool 调用
TI->>CORE: beforeToolCall(context, toolName)
CORE->>CORE: checkActive()
CORE->>B: reserveToolCall(toolName)
alt 超限(总量 或 单 Tool 独立限额)
B-->>CORE: BudgetExceededException(TOOL_CALLS / TOOL_CALLS_PER_TOOL)
CORE->>CORE: exhaustBudget(...)
end
T->>CORE: reserveRunBytes(bytes) canonical 体积预扣
CORE->>C: capacity.reserve(bytes) AtomicLong CAS 自旋
alt 超限
C-->>CORE: BudgetExceededException(RUN_BYTES)
CORE->>CORE: exhaustBudget(...)
end
end
APP->>B: snapshot() → RunBudgetUsage
Note over APP,B: Run 结束对账:各维度实际用量(模型轮数/工具次数/Token/字节)
```
## 3. 五个流程节点
1. **创建**:`startRun` 里 `new RunBudget(RunBudgetLimits)`,限额不可变,消耗状态可变,句柄随 RunContext 显式传递。
2. **模型调用前**:`reserveModelCall()` synchronized 预扣,超限抛异常。
3. **Tool 调用前**:`reserveToolCall(toolName)` 双重限额——总次数 + 单 Tool 次数。
4. **Canonical 写入前**:`reserveRunBytes(bytes)` 走 CAS 计数器。
5. **调用后**:`recordTokens` 三档 Token 上限记账。
**统一超限出口**:任何维度超限都抛带 `BudgetKind` 的 `BudgetExceededException` → Core `exhaustBudget`(固化终态 + 广播取消)→ 后续所有调用被 checkActive 拒绝。预算失败是 Run 级事实,不是局部异常。
## 4. 四个维度的时机对照表
| 维度 | 时机 | 预扣/记账 | 超限 BudgetKind |
|---|---|---|---|
| 模型轮数 | 模型调用前 | 预扣 | `MODEL_CALLS` |
| Tool 次数 | Tool 调用前 | 预扣 | `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` |
| Token | 模型调用后 | 记账(实际用量) | `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` |
| 字节 | canonical 写入前 | 预扣 | `RUN_BYTES` |
## 5. 自测:对着图能回答这四个问题吗
1. 第 7 轮模型调用时 `reserveModelCall` 超限——哪个组件抛异常、Run 变成什么终态、后续调用为什么全部被拒?
(ModelInterceptor 调 beforeModelCall → Core reserveModelCall 抛 BudgetExceededException → exhaustBudget 写 BUDGET_EXHAUSTED + cancel → 之后 checkActive 见终态直接抛 RunAbortedException)
2. 为什么 Tool 需要"总次数 + 单 Tool 次数"双重限额?
(总量防"调用太多",单 Tool 限额防"死磕同一个工具",比如反复查同一份日志)
3. 为什么字节预算用 CAS 自旋,计数预算用 synchronized?
(字节是高频原子累加,AtomicLong + compareAndSet 无锁乐观并发;计数是"读-判-写"复合操作,synchronized 保证原子性)
4. RunBudget 和 ModelCallLedger 都是记消耗,区别在哪?
(Budget 调用前预扣、管允不允许、超限会停止 Run;Ledger 调用后记账、管记了多少、幂等去重,只服务审计对账)
## 6. RunBudget 字段与组件速查
### 6.1 RunBudget 状态字段
| 字段 | 类型 | 含义 |
|---|---|---|
| `limits` | `RunBudgetLimits` | 限额定义(不可变) |
| `capacity` | `RunCapacityCounter` | 字节 CAS 计数器 |
| `modelCalls` | int | 累计模型调用轮数 |
| `toolCalls` | int | 累计工具调用次数 |
| `toolCallsByName` | `Map<String,Integer>` | 单工具名次数(防死磕) |
| `inputTokens` / `outputTokens` / `totalTokens` | long | 三档累计 token |
### 6.2 RunBudgetLimits:限额定义(7 字段 + 默认值)
| 字段 | 默认值 | 来源 |
|---|---|---|
| `maxModelCalls` | 24 | 配置 `harness.chat.*` |
| `maxToolCalls` | 24 | 配置 |
| `maxCallsPerTool` | 8 | 配置 |
| `maxInputTokens` | 100_000 | 配置 |
| `maxOutputTokens` | 100_000 | 配置 |
| `maxTotalTokens` | 200_000 | 配置 |
| `maxRunBytes` | 1_000_000(1MB) | 配置 |
### 6.3 支撑类型
| 类型 | 字段/枚举 | 作用 |
|---|---|---|
| `RunCapacityCounter` | `maxBytes` + `usedBytes`(AtomicLong) | 字节 CAS 自旋计数 |
| `RunBudgetUsage` | modelCalls / toolCalls / toolCallsByName / 三档 token / runBytes | `snapshot()` 只读快照,Run 结束对账 |
| `BudgetExceededException` | `kind` / `limit` / `attempted` | 超限异常,携带具体维度 |
| `BudgetKind` | 7 个枚举 | `MODEL_CALLS` / `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` / `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` / `RUN_BYTES` |
### 6.4 持有与消费组件
| 组件 | 与 budget 的关系 |
|---|---|
| `DiagnosisHarnessCore` | **门禁枢纽**:持有 `RunBudgetLimits`,创建 `RunBudget`;`beforeModelCall` / `beforeToolCall` / `recordTokens` / `reserveRunBytes` 统一入口;超限走 `exhaustBudget` 升级终态 |
| `RunContext` | 持有 `RunBudget` 句柄,随请求显式传递 |
| `HarnessModelInterceptor` | 每轮模型调用:`beforeModelCall`(预扣)+ 调用后 `ModelCallAuditor.recordUsage`(→ `core.recordTokens`) |
| `HarnessToolInterceptor` | 工具请求:`beforeToolCall` 预扣 |
| `ToolBoundary` | `beforeToolCall` + **3 处** `reserveRunBytes`:request 字节、raw response 字节、agent_result 字节 |
| `GuardModelCall` / `DiagnosisAgentUseCase` / `EvidenceRepair` | 各自 `beforeModelCall` + `reserveRunBytes`(输入/输出/Draft/Repair 输入) |
Token 记账链路:`HarnessModelInterceptor.recordUsage` → `ModelCallAuditor.recordUsage` → `core.recordTokens` → `RunBudget.recordTokens`(三档检查)。
### 6.5 配置来源:两层字节控制
字节控制有两层,作用域不同:
```text
单次 payload 上限(每类内容独立闸门,不在 RunBudgetLimits 内):
diagnosis-max-query-bytes: 16384 查询输入
diagnosis-max-previous-turn-bytes: 16384 历史轮次
diagnosis-max-input-bytes: 49152 诊断输入合计
diagnosis-max-draft-bytes: 49152 Draft
semantic-max-input/output-bytes: 100000 / 10000
repair-max-input/output-bytes: 100000 / 48000
canonical-max-record-bytes: 1048576 单条 canonical 记录
canonical-max-agent-result-bytes: 65536 agent_result
Run 累计上限:
max-run-bytes: 1000000 整个 Run 累计预扣
```
**注意**:`canonicalMaxRecordBytes(1MB)` 是"单条记录"上限,`maxRunBytes(1MB)` 是整个 Run 累计上限——两者都是 1MB 但作用域不同,一条记录就能占满 Run 预算的一半以上。
## 7. 预算异常与终态对应
### 7.1 异常 → 终态对应表
| 异常 | 抛出处 | 携带信息 | 写入/对应终态 |
|---|---|---|---|
| `BudgetExceededException` | `RunBudget`(reserve/record) | `kind` / `limit` / `attempted` | **BUDGET_EXHAUSTED**(写入) |
| `RunAbortedException`(deadline 超时) | `checkActive` 第二道闸 | `RunTermination(TIMED_OUT, ...)` | **TIMED_OUT**(主动写入后抛出) |
| `RunAbortedException`(已取消) | `checkActive` 第三道闸 | 已有终态(如 CANCELLED) | **读取**已有终态,不新写 |
| `RunAbortedException`(终态已存在) | `checkActive` 第一道闸 | 已有终态(可能是任何终态) | **读取**已有终态,不新写 |
| `IllegalArgumentException` | `RunBudgetLimits` 构造 / `RunBudget` 参数 | 校验信息 | **无终态**(Run 开始前 fail fast) |
### 7.2 两阶段异常:预算超限后的完整路径
```text
第一次(reserve/record 超限):
RunBudget 抛 BudgetExceededException(kind/limit/attempted)
→ Core 捕获 → exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel
→ 异常继续向上抛(可审计"哪个维度爆了")
之后(任何 checkActive):
termination 已存在 → 抛 RunAbortedException(携带 BUDGET_EXHAUSTED 终态)
```
第一枪是预算异常(带维度),之后所有拦截是终止异常(带终态)——两者配合。
### 7.3 各消费组件的异常处理
| 组件 | 处理 |
|---|---|
| `DiagnosisHarnessCore.applyBudget` | catch `BudgetExceededException` → `exhaustBudget` → 再 throw |
| `GuardModelCall` | `ExecutionException` 的 cause 判断:`instanceof BudgetExceededException` → 原样 rethrow;`CancellationException` → `checkActive` → 可能抛 `RunAbortedException` |
| `HarnessRetryExecutor` | `BudgetExceededException` / `RunAbortedException` 属于**从不重试**类(取消、预算耗尽、协议错误不能被重试吞掉) |
## 8. 异常三要素:kind / limit / attempted
### 8.1 三个字段
| 字段 | 含义 | 例子 |
|---|---|---|
| `kind` | **哪个资源维度**超限(BudgetKind 枚举) | `MODEL_CALLS` |
| `limit` | 该维度的**限额**(来自 RunBudgetLimits) | `maxModelCalls=24` |
| `attempted` | 本次**试图达到的值**(尝试后的总量,不是超出差额) | `25` |
### 8.2 attempted 是"尝试后的总量",不是"超出的部分"
```java
int attempted = modelCalls + 1; // 尝试让计数变成多少
if (attempted > limits.maxModelCalls()) {
throw new BudgetExceededException(BudgetKind.MODEL_CALLS,
limits.maxModelCalls(), attempted);
}
modelCalls = attempted;
```
```text
已调用 24 次(正好达到上限)→ 第 25 次尝试:attempted=25 > 24
→ 抛异常:kind=MODEL_CALLS, limit=24, attempted=25
```
### 8.3 各维度实际值举例
| kind | limit(配置) | attempted | 含义 |
|---|---|---|---|
| `MODEL_CALLS` | 24 | 25 | 第 25 轮模型调用被拒 |
| `TOOL_CALLS` | 24 | 25 | 第 25 次工具调用被拒 |
| `TOOL_CALLS_PER_TOOL` | 8 | 9 | 某个工具第 9 次调用被拒(死磕拦截) |
| `INPUT_TOKENS` | 100_000 | 100_003 | 累计输入 token 超出 3 个 |
| `TOTAL_TOKENS` | 200_000 | 200_500 | 累计总 token 超出 |
| `RUN_BYTES` | 1_000_000 | 1_000_001 | Run 累计字节超出 1 字节 |
### 8.4 审计价值
- `kind` → 定位哪一类资源(token / 次数 / 字节)
- `limit` → 知道配置上限(是否配置太紧)
- `attempted` → 知道差多少爆的(贴线超限说明要调配置,暴涨说明有失控路径)
## 9. 关联文档
| 文档 | 用途 |
|---|---|
| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事 + 常见追问 |
| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 决策五:预算与信息增益双机制 |
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | 预算之外的第二套停止机制 |
| [Harness 异常处理-Loop 内外与状态流](Harness异常处理-Loop内外与状态流.md) | 预算耗尽如何落终态 |
@@ -0,0 +1,121 @@
# 01 运行控制:让一次请求有边界
这一篇只回答一个问题:**一次诊断请求由谁负责到底?**
## 1. 先看一个具体问题
用户发起支付超时诊断。请求开始后,Router 调了一次模型,Diagnosis Agent 又调用多轮模型和 Tool,SemanticGuard 最后还要调用一次模型。与此同时,客户端可能断开,某次调用可能超时,两个线程也可能同时报告成功和失败。
如果没有统一的运行控制,每个组件都会有自己的理解:
- Controller 认为连接断开了,后台 Agent 却还在继续查询;
- Agent 认为还可以调用 Tool,Run 的总预算其实已经耗尽;
- 超时线程先写入失败,迟到的模型结果又把它覆盖为成功;
- SDK 在内部偷偷重试,系统无法解释多出来的延迟和 Token;
- 线上只看到最终失败,却不知道请求在哪一步、因为什么停止。
所以运行控制不是一个计时器,而是由几个职责不同的组件共同完成。
## 2. 四组组件怎样协作
```mermaid
sequenceDiagram
participant APP as Application
participant CORE as Core / RunContext
participant WORK as Agent、Tool、Guard
participant RETRY as Retry
participant AUDIT as Audit
APP->>CORE: 创建 RunContext
CORE-->>APP: runId、deadline、budget、cancel、lifecycle
APP->>WORK: 显式传入 RunContext
WORK->>CORE: 每次消耗前检查 active 和预算
WORK->>RETRY: 仅允许策略声明的技术重试
RETRY->>AUDIT: 记录每个 attempt
WORK->>AUDIT: 记录模型、Tool 和阶段元数据
APP->>CORE: 尝试写入最终终态
CORE-->>APP: first-terminal-wins
APP->>AUDIT: 记录公开结果和预算对账
```
第一次阅读可以把它们理解为:
- **Application 是负责人**:组织一次请求从创建到公开结果。
- **Core 是执行规则**:统一管理身份、deadline、预算、取消和唯一终态。
- **Retry 是重做规则**:明确什么失败允许再试一次。
- **Audit 是运行账本**:记录发生过什么,但不保存敏感正文。
## 3. Application:负责人,而不是推理者
Application 负责建立一次请求的应用边界:读取安全历史、创建 Run、判断 intent、调用对应执行分支、持久化结果,并把安全内容交给 SSE。
设计它的原因,是这些步骤必须由一个地方协调。如果散落在 Controller、Agent 和 Guard 中,会出现多个结果出口和不同的失败语义。
它不负责判断支付超时的根因,也不负责自己拼一份诊断 Fallback。它只负责把正确的执行组件按顺序接起来,并确保结果经过统一出口。
主要代码入口:`ChatApplicationUseCase`、`DiagnosisChatExecutor`、`KnowledgeQueryExecutor`、`SystemChatExecutor`、`ChatRunStore`。
## 4. Core:一次 Run 的共同规则
Core 创建 `RunContext`。这个上下文显式携带:
```text
这是谁的请求:sessionId + runId
最晚执行到何时:deadline
还能消耗多少:RunBudget
是否要求停止:RunCancellation
最终如何结束:RunLifecycle
模型消耗如何对账:ModelCallLedger
信息收集是否仍有价值:DiagnosisProgressTracker
```
这里最重要的决策是**显式传递 RunContext**,而不是使用 ThreadLocal 或让每个组件自己查询全局状态。原因是模型和 Tool 可能跨线程运行;隐式上下文很容易丢失、串线,也很难在测试中证明归属关系。
`RunLifecycle` 使用 first-terminal-wins:第一个成功写入的终态不可被迟到结果覆盖。它解决的是并发一致性,不是公开结果的业务含义。
主要代码入口:`DiagnosisHarnessCore`、`RunContext`、`RunBudget`、`RunCancellation`、`RunLifecycle`。
## 5. Retry:不能让“再试一次”藏起来
重试会增加成本和延迟,也可能重复副作用,因此不能由 SDK、HTTP Client 和各组件各自决定。当前策略是:
- Router 和 SemanticGuard 的特定技术失败最多尝试两次;
- Diagnosis Agent、业务 Tool 和 EvidenceRepair 不做隐藏自动重试;
- 取消、预算耗尽和协议错误不能被重试吞掉。
这里没有设计通用重试 DSL。首版只需要不可变的 `RetryPolicy`、稳定的失败分类和统一的 `HarnessRetryExecutor`,复杂配置系统反而会掩盖真实执行路径。
## 6. Audit:留下解释,而不是留下全部内容
线上排障需要知道:调用了哪个组件、用了多少 Token、Tool 是否完成、为什么停止、为什么发布 Fallback。但这不等于长期保存 Prompt、SQL、日志原文、Tool raw response 和模型 reasoning。
因此 Audit 长期保留的是有界元数据,完整 Tool 真相只在 canonical store 中短期存在。这个决策同时满足可回放和最小暴露原则。
主要代码入口:`DiagnosisTraceRecorder`、`TraceAuditEvents`、`ToolInvocationAuditSink`、`HarnessAgentAuditHook`、`ModelCallAuditor`。
## 7. 为什么没有合并成一个 RunManager
把上述职责都放进一个 `RunManager` 看起来更简单,但它会同时知道 HTTP、路由、预算、模型、Tool、数据库和 SSE,最后变成新的业务工作流引擎。
当前拆分依据不是“代码越细越好”,而是决策权不同:
| 组件 | 它拥有的决定 | 它不能决定 |
|---|---|---|
| Application | 请求走哪条应用分支、何时持久化和返回 | 业务根因是否成立 |
| Core | 是否仍可执行、资源是否允许、哪个终态生效 | 返回正常报告还是 Fallback |
| Retry | 某类技术失败是否允许下一 attempt | 修改业务结果 |
| Audit | 记录哪些安全元数据 | 影响执行结果 |
## 8. 先记住这些
第一次阅读只需记住:
1. Application 对一次请求负责,Core 对执行不变量负责。
2. RunContext 必须显式传播,所有模型和 Tool 共用同一预算与取消信号。
3. 第一个终态获胜,迟到结果不能翻案。
4. Retry 必须显式、分类、可审计。
5. Audit 记录控制事实,不长期复制敏感正文。
下一篇:[02 Agent 接入与收敛](02-Agent接入与收敛-让推理可以工作也可以停止.md)。
需要查完整状态转换时,阅读[生命周期与状态](../Harness生命周期与状态.md);需要查全部类型时,阅读[组件全景](../Harness组件全景-职责-设计原因与边界.md)。
@@ -0,0 +1,148 @@
# 02 Agent 接入与收敛:让推理可以工作,也可以停止
这一篇只回答一个问题:**怎样保留 Agent 的自主推理,同时阻止它重复查询或无限空转?**
## 1. 先看一个具体问题
Diagnosis Agent 第一次查询 10:00 到 10:10 的支付日志,没有发现异常;第二次换了关键词,仍然没有新信息;第三次又提交了与第一次等价的查询。
仅设置“最多调用 10 次 Tool”只能限制最坏损失,却回答不了:
- 上一次结果有没有推进诊断?
- 这次查询是否已经做过?
- 连续多少次没有新信息后应该停止?
- Agent 已经收到停止要求,为什么还能继续调用 Tool?
- 停止后,怎样安全地向用户说明已经检查过什么?
这就是 Agent 接入层和 Progress 组件共同解决的问题。
## 2. 一轮 ReAct 怎样经过 Harness
```mermaid
sequenceDiagram
participant A as Diagnosis Agent
participant MI as Model Interceptor
participant TI as Tool Interceptor
participant P as Progress Tracker
participant T as Tool Boundary
A->>MI: 发起一轮模型调用
MI->>MI: 检查 Run、预留预算、记录 Token
A->>TI: 请求调用 Tool
TI->>P: 评价上一轮是否有信息增益
P-->>TI: 可继续 / 重复 / 已饱和 / 协议错误
alt 允许继续
TI->>T: 执行业务 Tool
T-->>TI: Control View + Model Observation
TI->>P: 登记已完成调用和 scope
TI-->>A: 只返回 Model Observation
else 必须停止
TI-->>A: STOP_REQUIRED
end
```
第一次阅读可以把它们理解为:
- **Agent 接入层是检查站**:把框架原生 ReAct loop 接入预算、Tool 和审计边界。
- **Progress Tracker 是收敛记录器**:判断是否重复、是否连续无增益、是否必须停止。
## 3. 为什么复用框架 ReAct,而不是自己重写循环
Diagnosis Agent 需要模型原生 Tool Calling 和多轮 ReAct。项目没有再写一套 `while` 循环,而是通过 Factory、Model Interceptor、Tool Interceptor 和 Audit Hook 接入框架。
这样做的决策依据是:
- ReAct 的业务推理由框架和模型负责;
- 预算、Tool 授权、事实保存和停止协议由 Harness 负责;
- 两者通过明确边界连接,不互相复制实现。
如果 Harness 自己维护另一套 ReAct 状态机,就会同时出现“框架认为的下一步”和“Harness 认为的下一步”,调试时很难确定谁才是真理源。
主要代码入口:`DiagnosisAgentFactory`、`DiagnosisAgentUseCase`、`HarnessModelInterceptor`、`HarnessToolInterceptor`、`HarnessEvidenceTools`。
## 4. Model Interceptor:每一轮模型调用都必须记账
一个 Diagnosis Agent 执行不等于一次模型调用。ReAct 可能经历多轮思考和 Tool 返回,因此每一轮都要:
1. 检查 Run 是否仍然 active;
2. 在调用前预留模型预算;
3. 调用后记录 Provider 返回的 Token usage;
4. 再次检查取消或迟到结果。
Interceptor 不负责重试模型,也不判断输出是否支持根因。它只确保框架内部的每轮调用无法绕过 Harness。
## 5. Tool Interceptor:先检查进展,再允许查询
模型提交的 Tool Call 不只是业务参数,还携带对上一轮结果的评价。Tool Interceptor 会依次检查:
- previous observation 是否完整、顺序是否正确;
- 上一轮是 `GAINED` 还是 `NO_GAIN`;
- 当前 Tool scope 是否已经完成过;
- 收集状态是否已经 `SATURATED`;
- 通过检查后,才把业务请求交给 ToolBoundary。
它不执行后端查询,也不再次扣 Tool 预算。实际执行属于 ToolBoundary;收敛状态属于 ProgressTracker。Interceptor 只是两者与 ReAct 框架之间的接合点。
## 6. 为什么“有结果”不等于“有信息增益”
Tool 可以客观判断是否返回候选内容,却不知道这些内容是否推进了当前假设。例如同一条超时日志再次出现:
```text
InvocationStatus = READY
EvidenceStatus = EVIDENCE_FOUND
InformationGain = NO_GAIN
```
前三个状态回答不同问题:查询是否完成、是否有候选内容、内容是否推进当前诊断。把它们合成一个 `SUCCESS` 会让系统无法正常收敛。
当前由 Agent 在**下一次 Tool Call** 中评价上一轮的信息增益。这样评价发生在它真正做出下一步行动时,Harness 也能机械检查调用顺序,而不需要再增加一个 Progress Judge 模型。
## 7. Progress Tracker 保存什么
Tracker 只保存控制所需的最小状态:
- 已完成的 `tool_call_id` 和规范化 scope;
- 哪个结果仍等待 Agent 评价;
- 连续 `NO_GAIN` 次数;
- 收集状态和停止原因;
- 停止指令是否已经交付。
它不保存 raw response,也不判断日志是否证明支付线程池耗尽。业务价值判断仍由 Diagnosis Agent 负责,事实内容仍由 canonical store 负责。
主要代码入口:`DiagnosisProgressTracker`、`ToolScopeNormalizer`、`DiagnosisProgressProjector`。
## 8. 为什么停止不直接等于失败
连续无增益后停止,是一次正常的受控收敛,不是技术异常。Harness 会从已经完成的 canonical Tool 记录中投影 `ProgressSnapshot`,只保留可验真的检查范围、观察事实和限制,再形成安全 Fallback。
```mermaid
flowchart LR
N["连续 NO_GAIN 或重复查询"] --> S["CollectionState = SATURATED"]
S --> X["拒绝新的证据 Tool"]
X --> P["生成安全 ProgressSnapshot"]
P --> F["发布证据不足的 Fallback"]
```
因此,“没有找到足够证据”可以正常结束;只有违反进展协议、预算耗尽且没有安全进展等情况,才可能升级为失败。
## 9. 为什么没有采用其他方案
| 方案 | 没有采用的原因 |
|---|---|
| 只设 Tool 次数上限 | 只能止损,不能识别查询已经没有价值 |
| Harness 根据结果条数判断增益 | 条数是客观统计,不代表是否推进业务假设 |
| 增加 Progress Judge Agent | 增加模型成本和新的非确定性判断点 |
| 用自然语言相似度判断重复 | 首版难以稳定解释误判,当前只做 typed 参数级 scope 规范化 |
| 把剩余预算告诉模型 | 容易让模型围绕阈值博弈,硬限制应由 Harness 保持 |
## 10. 先记住这些
1. 框架负责 ReAct loop,Harness 通过 Interceptor 接入控制能力。
2. 预算回答“还能不能查”,信息增益回答“继续查有没有价值”。
3. Tool 是否有结果与结果是否推进诊断是两件事。
4. ProgressTracker 只保存控制状态,不保存完整证据。
5. 信息饱和可以正常发布 Fallback,不等于 Run 执行失败。
下一篇:[03 Tool 事实边界](03-Tool事实边界-让模型看到必要信息而系统保留真相.md)。
需要深入停止协议时,阅读[信息增益停止专题](../Harness信息增益停止-让无证据诊断正常收敛.md)。
@@ -0,0 +1,135 @@
# 03 Tool 事实边界:让模型看到必要信息,系统保留真相
这一篇只回答一个问题:**Tool 返回的数据应该由谁保管,模型究竟可以看到多少?**
## 1. 先看一个具体问题
Agent 查询支付日志,后端一次返回了几千行内容,其中包含重复堆栈、敏感字段和远超上下文窗口的正文。
直接把原始结果塞回模型会产生几个问题:
- 敏感数据进入模型上下文;
- 大结果挤占 Token,真正关键的证据反而被淹没;
- 模型后续引用经过裁剪的文本,系统无法证明它对应哪次真实调用;
- 若只保存裁剪结果,EvidenceGuard 又失去了独立验真的事实来源;
- 若把完整 raw 长期写入数据库,泄露面和存储成本都会扩大。
所以 Tool 设计的核心不是“怎样调用后端”,而是**谁拥有原始事实,以及不同消费者应该看到哪一层数据**。
## 2. 一次 Tool 调用的数据怎样变化
```mermaid
flowchart LR
REQ["Typed Tool Request"] --> B["ToolBoundary<br/>权限、只读、预算、大小检查"]
B --> RAW["Backend Raw Result"]
RAW --> P["Tool-specific Projector"]
P --> C["Canonical Invocation<br/>短期保存 request、raw、agent_result 和状态"]
C --> CV["Control View<br/>供 Harness 判断状态和进展"]
C --> MO["Model Observation<br/>供 Agent 推理的白名单内容"]
C --> EG["EvidenceGuard<br/>按当前 Run 独立验真"]
B -.-> AU["Durable Audit<br/>长期只留有界元数据"]
```
第一次阅读只需区分三份内容:
- **Canonical Invocation**:系统在当前 Run 内短期保管的完整调用真相。
- **Control View**:Harness 用来判断状态、scope 和证据数量的控制字段。
- **Model Observation**:模型真正看到的安全、有限内容。
## 3. ToolBoundary:所有 Tool 的统一入口
RAG、日志和 MySQL 后端完全不同,但它们都必须遵守相同的不变量:
1. Run 仍然 active,调用 ID 与当前 Run 匹配;
2. Tool 已授权,请求声明和实际行为保持只读;
3. 调用前预算允许,结果大小没有越界;
4. canonical 状态只能从 `PROJECTING` 进入 `READY` 或 `ERROR`;
5. 长期 Audit 不保存完整请求和 raw response。
如果每个 Adapter 自己实现这些规则,三种 Tool 很快会出现不同的错误码、预算顺序和存储行为。ToolBoundary 集中处理共同规则,Adapter 只处理业务协议。
ToolBoundary 不判断信息增益,也不判断某条日志是否支持根因。它只保证调用合法、状态可信、结果有界。
主要代码入口:`ToolBoundary`、`ToolCallRequestEnvelope`、`ToolBoundaryResult`、`ToolBoundaryErrorCode`。
## 4. Projector:把后端结果变成稳定事实
不同后端的 raw 数据不能直接成为 Agent 契约:
- RAG 需要限制证据数量和摘录长度,并保留文档身份;
- Logs 需要限制事件、聚合 pattern,明确实际时间范围和是否截断;
- MySQL 需要限制行、列、单元格和总 bytes,并保留查询 scope。
每种 Tool 使用自己的 `ToolResultProjector`。Projector 负责标准化和计算客观 `EvidenceStatus`,但它不调用模型,也不判断这些事实是否足以支持最终结论。
这是刻意保留的业务差异。强行做一个“万能 JSON Cleaner”,往往会丢掉每类证据真正需要的身份和范围信息。
## 5. Canonical Store:为什么要保留独立真相
Agent Observation 是为推理优化的,它会被裁剪和白名单投影,不能反过来充当验真依据。Canonical Store 保存当前调用的 request、raw、标准化 `agent_result`、状态和时间,使 EvidenceGuard 能绕开 Agent 上下文独立读取事实。
当前选择 Redis TTL,是因为完整调用记录:
- 只在当前 Run 的验证阶段需要;
- 可能包含敏感内容,不应永久保存;
- 需要按 `runId + tool_call_id` 快速定位;
- 容量必须有硬上限,读取不能自动续期。
raw 超限时选择失败,而不是悄悄截断。因为一旦截断,canonical record 就不再代表后端真实返回,后续验真会建立在不完整事实之上。
主要代码入口:`CanonicalInvocationStore`、`RedisCanonicalInvocationStore`、`CanonicalToolInvocation`、`CanonicalInvocationLimits`。
## 6. Model Observation:模型只拿完成任务所需的内容
即使 canonical `agent_result` 已经标准化,其中仍可能包含 Harness 控制字段。`ToolResultViewProjector` 会进一步拆成:
```text
Control View:status、evidence count、scope、截断状态等
Model Observation:有界证据正文、可读来源和下一步推理所需字段
```
模型不会看到 raw response、Redis key、预算阈值、重复指纹和完整 Harness 状态。这样既减少上下文噪声,也避免模型利用或复述内部控制信息。
## 7. MySQL 为什么还需要单独的只读沙箱
`readOnly=true` 只是请求声明,不能证明模型生成的 SQL 安全。MySQL Tool 在进入真实执行前还要经过:
- JSqlParser AST 解析;
- 单条 SELECT 和保守语法子集限制;
- 数据源、schema、table、column 精确 allowlist;
- 禁止投影通配符和元数据探测;
- 只读账号、timeout、LIMIT 与最大行数。
Validator 产生已经批准的 `MysqlQueryPlan`,Executor 只接受这个 plan,不重新信任原始字符串。这是纵深防御:任何一层都不能单独被当作完整授权。
## 8. 为什么短期真相和长期审计要分开
```mermaid
flowchart TB
C["Canonical Store<br/>完整、敏感、短期"] -->|"用于"| V["当前 Run 验真"]
A["Durable Audit<br/>有界、元数据、长期"] -->|"用于"| O["排障、统计、成本对账"]
```
把两者合并会走向两个极端:要么长期复制所有敏感正文,要么为了安全只存元数据,导致当前 Run 无法验真。分开后,每种存储只承担自己的责任。
## 9. 为什么没有采用其他方案
| 方案 | 没有采用的原因 |
|---|---|
| raw 直接返回 Agent | 泄露、超限、噪声和无法独立验真 |
| 只保存 Agent Observation | 投影内容不是完整事实,Guard 会依赖模型看到的版本 |
| 完整 raw 永久写 MySQL | 扩大敏感数据暴露面和长期存储成本 |
| raw 超限后静默截断 | canonical truth 会变成不完整真相 |
| 所有 Tool 共用万能 Projector | 无法保持日志、RAG、表格各自的身份和 scope 语义 |
## 10. 先记住这些
1. ToolBoundary 统一执行规则,Adapter 处理具体后端。
2. Canonical Invocation 是系统真相,Model Observation 是模型视图。
3. Agent 看到的内容不能反过来成为 EvidenceGuard 的事实来源。
4. 完整真相短期保存,长期 Audit 只留有界元数据。
5. Tool 找到候选内容,不代表它支持最终根因。
下一篇:[04 验证与发布](04-验证与发布-让未经证明的结论无法越过出口.md)。
需要深入字段和存储取舍时,阅读[Tool 双视图专题](../Harness-Tool双视图-从原始结果到可验证证据.md)。
@@ -0,0 +1,162 @@
# 04 验证与发布:让未经证明的结论无法越过出口
这一篇只回答一个问题:**Agent 写完诊断草稿以后,为什么还不能直接返回给用户?**
## 1. 先看一个具体问题
Agent 在报告中写道:
> 10:03 出现数据库连接池耗尽,因此支付接口大量超时。
它同时引用了一次日志 Tool Call。系统即使确认这个调用真实存在、属于当前 Run,而且日志中确实出现“connection timeout”,仍然不能直接发布上述结论。
因为这里至少有三个不同问题:
1. Agent 引用的 Tool Call 是不是真的?
2. 真实日志是否足以证明“数据库连接池耗尽导致支付超时”?
3. 验证失败后,系统最终应该向用户发布什么?
它们分别属于 EvidenceGuard、SemanticGuard 和 Release。
## 2. 草稿怎样通过发布链
```mermaid
flowchart LR
D["DiagnosisDraft<br/>Agent 写出的草稿"] --> E["EvidenceGuard<br/>机械验证引用和归属"]
E -->|"结构或引用可修复"| R["EvidenceRepair<br/>只修引用,不改语义"]
R --> E
E -->|"得到已验真证据"| S["SemanticGuard<br/>判断证据是否支持报告"]
S -->|"SUPPORTED"| P["Release SUCCESS<br/>发布原始安全 Draft"]
E -->|"无法验真"| F["SafeFallback"]
S -->|"UNSUPPORTED 或不可用"| F
F --> O["Release FALLBACK"]
```
第一次阅读只需记住:
- **EvidenceGuard 验真**:证明引用确实来自当前 Run 的已完成 Tool 调用。
- **SemanticGuard 验义**:判断这些真实证据是否支持报告中的结论。
- **Release 决定出口**:只发布验证通过的原 Draft,或者发布确定性的 SafeFallback。
## 3. EvidenceGuard:代码能证明的事交给代码
EvidenceGuard 会检查:
- Draft 结构和 analysis ID 是否合法;
- 所有结论分析是否形成完整引用闭包;
- `tool_call_id` 是否属于当前 Run;
- canonical invocation 是否为 `READY`;
- `EvidenceStatus` 与引用类型是否一致;
- 引用的 evidence 是否确实存在于标准化 Tool 结果中。
这些都是确定性问题,不需要模型判断。让模型验证自己的引用,会让同一个非确定性来源同时当作者和裁判。
验证通过后,EvidenceGuard 生成 `VerifiedEvidenceSnapshot`。它只包含 SemanticGuard 所需的已验真证据、来源和 scope,不包含 Tool raw response。
主要代码入口:`EvidenceGuard`、`EvidenceGuardResult`、`VerifiedEvidenceSnapshot`、`EvidenceViolation`。
## 4. EvidenceRepair:为什么允许修,又为什么只能修一次
有些 Draft 的业务语义可能没有问题,只是引用结构出错,例如漏写一个 analysis ID。直接丢弃会浪费一次昂贵诊断,因此系统允许一次 EvidenceRepair。
但 Repair 不是第二个报告作者。它必须满足:
```text
修复前用户可见语义 == 修复后用户可见语义
```
它只能修结构和引用,不能新增事实、改变结论或补写建议;修复后还必须重新经过 EvidenceGuard。若语义视图发生变化,Repair 立即失败。
只允许一次,是为了避免形成“修复失败再修复”的隐式 Agent loop,也让成本和执行路径保持可解释。
## 5. SemanticGuard:引用真实不等于推论成立
一条真实的 `connection timeout` 日志,可能来自下游网络问题,也可能只是故障结果,不能自动证明数据库连接池耗尽。
这类支持关系无法完全用规则判断,因此 SemanticGuard 使用一次隔离的模型调用,只接收:
- 用户原始问题;
- Draft 的用户可见语义;
- EvidenceGuard 产生的 verified snapshot。
它没有 Tool、没有记忆、没有 ReAct loop、不访问 Redis,也不能改写报告。输出只有 `SUPPORTED / UNSUPPORTED` 和审计原因。
这个设计没有追求一个看似精确的置信度分数。首版真正需要的是发布门禁,而未经校准的 0.73 并不能形成比二元判定更可靠的协议。
主要代码入口:`SemanticGuard`、`SemanticGuardInput`、`SemanticGuardDecision`、`GuardModelCall`。
## 6. Release:为什么必须只有一个出口
如果 Agent、Guard、Application 都能各自构造最终结果,会出现:
- 同一验证失败被不同层解释成不同文案;
- 某个分支忘记经过 SemanticGuard;
- 迟到的 Draft 绕过已经确定的取消或失败;
- Fallback 混入未经验证的 Agent 内容。
`DiagnosisReleaseUseCase` 因此拥有唯一发布决策。它协调 EvidenceGuard、可选 Repair、SemanticGuard 和 SafeFallbackFactory,但自己不写新根因。
```mermaid
flowchart TB
V{"可以安全发布正常报告吗?"}
V -->|"引用真实且语义受支持"| OK["ReleaseOutcome.SUCCESS<br/>发布原 Draft"]
V -->|"证据不足或验证不通过"| FB["ReleaseOutcome.FALLBACK<br/>发布 SafeFallback"]
V -->|"无法形成安全业务结果"| ER["ReleaseOutcome.FAILED"]
```
这里必须区分:
```text
RunState.SUCCESS + ReleaseOutcome.FALLBACK
```
这表示系统完整、安全地处理了请求,但证据不足以发布正常诊断结论。Fallback 是安全产品结果,不等于执行失败。
## 7. SafeFallback:不是让另一个模型重新回答
验证失败后再次让模型“写得保守一点”,仍然可能产生新事实。SafeFallback 因此由代码确定性构造,只允许使用:
- 已验真的来源和检查范围;
- 可以安全表达的观察事实;
- 当前证据的限制;
- 面向补充数据的下一步建议;
- 稳定、可公开的问题类型。
它不会包含 Agent 原 Draft 的未验证结论、SemanticGuard 内部原因、Provider 错误或异常栈。
主要代码入口:`DiagnosisReleaseUseCase`、`EvidenceRepair`、`SafeFallbackFactory`、`DiagnosisReleaseResult`。
## 8. Contract:为什么状态和结果必须类型化
发布链横跨模型 JSON、Java、Redis、数据库和 SSE。如果各层都使用自由字符串 `SUCCESS`,很快就无法区分:
- Tool 调用完成;
- Tool 找到候选证据;
- 语义审查通过;
- Run 执行成功;
- 最终发布正常报告。
Contract 包用不同类型保持这些问题正交,例如 `InvocationStatus`、`EvidenceStatus`、`SemanticVerdict`、`RunState` 和 `ReleaseOutcome`。类型多不是为了复杂,而是为了阻止一个含糊的 `status` 穿过整个系统。
## 9. 为什么没有采用其他方案
| 方案 | 没有采用的原因 |
|---|---|
| 只检查 Tool Call ID 存在 | 只能证明引用存在,不能证明归属、状态和内容一致 |
| 一个 Verifier Agent 同时验引用和语义 | 混合确定性与非确定性判断,失败后难以定位责任 |
| Guard 自动改写报告 | Guard 会变成第二个作者,并可能引入未验证事实 |
| SemanticGuard 使用 Tool 再查证 | 会形成第二条诊断链,预算和证据归属变复杂 |
| 验证失败直接抛技术错误 | 证据不足是正常业务结果,用户仍需要安全说明 |
| 用置信度阈值发布 | 未校准分数不能充当可靠安全协议 |
## 10. 先记住这些
1. Draft 是候选结果,不是已发布报告。
2. EvidenceGuard 用代码证明引用真实,SemanticGuard 判断证据是否支持语义。
3. Repair 只能修引用,不能改变用户可见语义,而且只尝试一次。
4. Release 是唯一出口,只发布原 Draft 或确定性 SafeFallback。
5. `FALLBACK` 可以对应一次正常完成的 Run。
四篇组件导读到这里结束。需要回看整体路径时返回[组件学习地图](README.md)。
需要深入验证规则时,阅读[证据安全链专题](../Harness证据安全链-从引用真实到结论可发布.md);需要查所有 contract 类型时,阅读[组件全景](../Harness组件全景-职责-设计原因与边界.md)。
@@ -0,0 +1,22 @@
# Harness 组件:从一次请求逐步认识
这里不按照 Java 包逐个介绍组件,而是沿着一次诊断请求,分四步回答四个问题。
```mermaid
flowchart LR
Q["一个诊断请求来了"] --> C1["01 运行控制<br/>怎样保证这次执行受控"]
C1 --> C2["02 Agent 接入与收敛<br/>怎样让 Agent 工作但不空转"]
C2 --> C3["03 Tool 事实边界<br/>怎样安全地取得事实"]
C3 --> C4["04 验证与发布<br/>怎样决定结果能否交给用户"]
```
| 顺序 | 先回答的问题 | 涉及的职责域 |
|---|---|---|
| [01 运行控制](01-运行控制-让一次请求有边界.md) | 谁创建 Run,谁限制资源,谁记录它如何结束? | Application、Core、Retry、Audit |
| [02 Agent 接入与收敛](02-Agent接入与收敛-让推理可以工作也可以停止.md) | 怎样使用框架 ReAct,同时阻止重复查询和无增益空转? | Agent、Progress |
| [03 Tool 事实边界](03-Tool事实边界-让模型看到必要信息而系统保留真相.md) | Tool 原始结果由谁保存,模型究竟能看到什么? | Tool |
| [04 验证与发布](04-验证与发布-让未经证明的结论无法越过出口.md) | 引用真实是否等于结论成立,最终由谁决定发布? | Guard、Release、Contract |
建议一次只读一篇。每篇读到“先记住这些”就可以停下;类名只在最后用于定位代码。
需要查全部生产类型时,再使用[组件全景参考手册](../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)。
@@ -4,7 +4,7 @@
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
**关联文档**:
- `docs/RAG排序-多路召回与RRF.md`(排序与多路召回判断框架)
- `RAG排序-多路召回与RRF.md`(排序与多路召回判断框架)
- 本文后续实现讨论以本节 **「交付拆分:分块去重 + Hybrid 同规划」** 为基线
---
@@ -7,8 +7,8 @@
- ACI 契约:`RagToolResult.relevance_level` / `RagRelevanceLevel`
- 计算:`KnowledgeEvidencePostProcessor` → `qualityScore` 阈值
- 质量统一:`docs/RAG-Hybrid质量分与后处理.md`
- 多路与 RRF:`docs/RAG排序-多路召回与RRF.md`
- 质量统一:`RAG-Hybrid质量分与后处理.md`
- 多路与 RRF:`RAG排序-多路召回与RRF.md`
- **运行时 Trace / 审计**:`mvp/architecture/RAG检索可观测性与审计.md`
---
@@ -9,7 +9,7 @@
- `MilvusHybridKnowledgeStore`(dense / hybrid)
- `RetrievalScoreNormalizer` / `KnowledgeEvidencePostProcessor`
- OpenSpec / devflow:`rag-quality-score-unify`
- 前置讨论:`docs/RAG排序-多路召回与RRF.md`
- 前置讨论:`RAG排序-多路召回与RRF.md`
- 架构:`mvp/architecture/RAG知识检索架构.md` §6
---
@@ -585,7 +585,7 @@ flowchart TB
| 材料 | 路径 |
|------|------|
| 多路与 RRF 讨论 | `docs/RAG排序-多路召回与RRF.md` |
| 多路与 RRF 讨论 | `RAG排序-多路召回与RRF.md` |
| 当前架构 | `mvp/architecture/RAG知识检索架构.md` |
| OpenSpec 归档 | `openspec/changes/archive/2026-07-28-rag-quality-score-unify/` |
| 主规格 | `openspec/specs/rag-retrieval-quality-score/spec.md` |
@@ -2,7 +2,7 @@
**日期**:2026-07-28
**状态**:审计字段 live 验收记录
**关联主文档**:[一次诊断到底发生了什么(SUCCESS 全流程)](一次诊断全流程-E2E导读.md)
**关联主文档**:[一次诊断到底发生了什么(SUCCESS 全流程)](../diagnosis/一次诊断全流程-E2E导读.md)
> 主文档只保留 **SUCCESS 完整诊断** 与 **现行审计能力说明**。
> 本页单独记录:改造后的一次 live 验收——**审计字段 PASS**,业务因 Milvus 空结果走了 **FALLBACK**。
@@ -8,7 +8,7 @@
- 目录:`eval/rag-retrieval/`
- 脚本:`scripts/eval_rag_retrieval.py`、`generate_rag_lookup_snapshots.ps1`、`prepare_rag_eval_seed.ps1`
- OpenSpec / devflow:`rag-eval-hybrid-baseline`(已归档)
- 前置:`docs/RAG-Hybrid质量分与后处理.md`、`docs/RAG-Agent如何读relevance_level.md`
- 前置:`RAG-Hybrid质量分与后处理.md`、`RAG-Agent如何读relevance_level.md`
---
@@ -576,8 +576,8 @@ python scripts\eval_rag_retrieval.py
| 文档 | 内容 |
|------|------|
| `eval/rag-retrieval/README.md` | 操作说明(以仓库为准) |
| `docs/RAG-Hybrid质量分与后处理.md` | quality / 后处理 |
| `docs/RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `docs/RAG排序-多路召回与RRF.md` | 多路与 RRF |
| `RAG-Hybrid质量分与后处理.md` | quality / 后处理 |
| `RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `RAG排序-多路召回与RRF.md` | 多路与 RRF |
| `devflow/projects/2026-07-28-rag-eval-hybrid-baseline/` | 本 change 档案 |
| `openspec/specs/rag-eval-offline-baseline/spec.md` | 主规格 |
+2 -1
View File
@@ -1,6 +1,6 @@
# MVP Issues 索引
**更新日期**:2026-07-27
**更新日期**:2026-07-28
**状态**:按活跃问题、设计笔记、RAG 问题集和已归档问题整理
## 目录约定
@@ -18,6 +18,7 @@
|---|---|---|---|---|
| ISS-015 | 诊断运行质量与 Reasoning 审计收敛 | 高 | 部分实施 | [active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md](active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md) |
| ISS-016 | 诊断 Agent 缺少基于信息增益的停止契约 | 高 | 已实施,待归档 | [active/ISS-016-diagnosis-information-gain-stop-contract.md](active/ISS-016-diagnosis-information-gain-stop-contract.md) |
| ISS-017 | RAG L0 过滤收窄与 Fallback 加固 | 中 | 开放,暂缓实施(保持现网) | [active/ISS-017-rag-l0-filter-fallback-hardening.md](active/ISS-017-rag-l0-filter-fallback-hardening.md) |
## 设计笔记
@@ -0,0 +1,159 @@
# ISS-017 RAG L0 过滤收窄与 Fallback 加固
**状态**:开放,暂缓实施(保持现网行为)
**严重程度**:中
**发现时间**:2026-07-28
**更新日期**:2026-07-28
**来源**:hybrid + qualityScore 收口后的评测/设计讨论;`chat-l0-filter-fallback` golden case
**关联**:`LookupKnowledgeTool`、`KnowledgeQueryTransformer`、`KnowledgeEvidencePostProcessor`、`eval/rag-retrieval`、历史 `rag/rag-l0-domain-entity-hint.md`
---
## 1. 背景
当前 `lookup_knowledge` 在 L0 命中唯一 domain 时会生成 `categoryFilter`,第一次只在该 category 内检索(`FILTERED_VECTOR`),意图是 **缩小边界、降噪声**。若后处理判定低质,则去掉 filter 用原 query 再搜一次(`UNFILTERED_VECTOR_RETRY`),并记录 `filtered_vector_low_quality` / `filtered_vector_no_evidence`。
该设计方向正确,但机制偏「硬过滤赌一把 + 失败整页替换」:
```text
唯一 domain → 硬 categoryFilter
→ isLowQuality?
→ 是:全库 retry,selectedEvidence 直接换成 retry 结果
→ 否:采用过滤结果
```
hybrid 与统一 `qualityScore` 上线后,单点 `isLowQuality` 阈值更敏感;中间评测快照曾出现「该 retry 未 retry、decoy 占 top」。随后 fixture/baseline 已在特定种子下恢复 7/7 全绿,**不代表路径已稳健**,只说明当前快照可过。
**本 Issue 决策(2026-07-28)**:
- **先保留原样**,不改生产行为。
- 记录改进方向,待 RAG 主干稳定、有明确回归动机时再实施。
- **不采用**全面「软约束替代硬过滤」(原讨论方案 D)作为近期目标(复杂度高、与 RRF 权威排序边界纠缠)。
---
## 2. 用户 / 系统体验问题
1. L0 指错 category 时,诊断可能只看到 decoy 或窄池噪声,正确 runbook 进不了上下文。
2. 即使触发 retry,第一次过滤结果整页丢弃,窄路里偶然有用的 chunk 无法保留。
3. fallback 是否触发高度依赖单一质量闸门,阈值或分数语义一变,行为抖动,golden case 脆弱。
4. 审计上 `selectedAttempt` 二元切换,难以表达「窄路 + 宽路共同贡献」。
---
## 3. 现状与验证基线
### 3.1 实现位置
| 组件 | 职责 |
|---|---|
| `KnowledgeQueryTransformer` | L0 hint;唯一 domain → `categoryFilter` |
| `LookupKnowledgeTool` | `FILTERED_VECTOR` → 可选 `UNFILTERED_VECTOR_RETRY`;retry 时覆盖 `selectedEvidence` |
| `KnowledgeEvidencePostProcessor#isLowQuality` | 是否触发 fallback 的主闸门 |
| `RetrievalTrace` | `selectedAttempt` / `fallbackReason` / attempts |
### 3.2 评测
- Golden:`eval/rag-retrieval/cases/golden-cases.json` → `chat-l0-filter-fallback`
- 期望:`UNFILTERED_VECTOR_RETRY` + fallbackReason 之一 + top/source = `rag-l0-filter-fallback`
- 种子:`rag-l0-filter-fallback`(真文档)、`rag-l0-filter-decoy`(`category: overfilter-decoy`)
- 当前 **accepted baseline**(约 2026-07-28T06:55Z)对该 case 为 pass;仓库内更早的 `baseline-diff.*` 可能仍是中间失败快照,**不以陈旧 diff 为现状**。
### 3.3 非目标(本 Issue 不解决)
- 重做 hybrid / RRF / qualityScore 统一(已收口)。
- EvidenceGuard / Agent 如何用证据写结论。
- 全面取消 L0 或永远全库检索。
- **方案 D**:弱化 category 硬过滤、改为 domain 软加权全面替代(明确搁置)。
---
## 4. 目标(实施时)
在 **保留「缩小边界」先验** 的前提下,降低 over-filter 脆性:
1. 只有 **高置信** L0 才使用硬 `categoryFilter`;中/低置信不锁门。
2. 一旦触发宽路 rescue,**合并** 窄路与宽路候选后再后处理,禁止无条件整页替换。
3. fallback 触发使用 **多信号**(空/不可用、过少候选、低质且实体重叠差等),避免单阈值独裁。
4. Trace/golden 能表达 rescue/merge,而不只绑死某一个 attempt 字符串。
5. `chat-l0-filter-fallback` 与主路径 case 在改动后仍可回归;允许演进断言语义(见 §6)。
---
## 5. 推荐方案(实施优先级)
### 5.1 方案 A — 置信度分级收窄(优先,低复杂度)
| L0 置信 | 行为 |
|---|---|
| 高(唯一 domain + 强关键词/标题等可标定信号) | 硬 `categoryFilter`(保持收窄) |
| 中 | 不硬过滤;domain 仅 hint/审计 |
| 低/无 | 直接全库 hybrid |
可选:多信号触发 retry(空结果、candidateCount 过低、低质等)。
### 5.2 方案 C — Retry 后 merge(优先,低~中复杂度)
触发 `UNFILTERED_VECTOR_RETRY` 后:
- 合并两次候选(按 `evidenceKey` dedup)
- 再跑统一 `EvidencePostProcessor` / pack
- 不要 `selectedEvidence = retryEvidence` 直接覆盖
Trace 可增加 `rescue_used` / `merged` 类字段,或扩展 `selectedAttempt` 语义。
### 5.3 方案 B — 两路并行(可选,中复杂度)
高置信硬过滤时同时跑窄路 + 较小 topK 宽路,merge 后处理。用延迟换稳定;可在 A+C 之后视延迟预算决定。
### 5.4 方案 D — 全面软约束(搁置)
不作为本 Issue 实施范围。仅当知识库噪声结构变化、证明硬过滤长期帮倒忙时再单独立项。
---
## 6. 验收标准(实施阶段)
- [ ] 默认/高置信路径仍体现「可收窄」;中低置信不再误锁唯一 category。
- [ ] Over-filter 构造下,最终 context **包含** 期望文档(如 `rag-l0-filter-fallback`),且存在可审计的 rescue/merge 痕迹。
- [ ] Retry/merge 不丢弃窄路中仍有价值的 `evidenceKey`(在宽路未覆盖时)。
- [ ] Offline eval:`chat-l0-filter-fallback` 与其它 golden 全绿或仅含已文档化的 intentional diff。
- [ ] 断言可演进为「发生过 rescue + 最终命中真文档」,不必永久绑定旧 attempt 枚举字面量。
- [ ] 普通 SUCCESS 诊断路径无回归(时延、top 证据、token 量级可接受)。
- [ ] **不**重新引入 keyword boost 颠覆 RRF/qualityScore 主序。
---
## 7. 实施阶段建议(未开工)
| 阶段 | 内容 | 状态 |
|---|---|---|
| 0 | 本 Issue 记录;**保持现网原样** | 已完成 |
| 1 | 方案 A:高置信才硬过滤 + 触发条件澄清 | 未开始 |
| 2 | 方案 C:retry merge + Trace 字段 | 未开始 |
| 3 | 更新 golden/fixture/baseline;可选方案 B | 未开始 |
| — | 方案 D | 明确不做(本 Issue) |
每阶段独立 focused tests + offline eval;不与 ISS-015 Repair Schema 捆绑。
---
## 8. 风险与约束
- 放宽硬过滤可能增加噪声进入 pack → 需靠 topK/pack 预算与现有 quality 闸门兜住。
- Merge 可能略增后处理成本;双路并行增加一次检索延迟。
- 改 attempt 语义会动审计/文档/eval,需同步 `RagLookupAuditEnricher` 与 MVP 检索文档。
- 在未实施前,运维上依赖:eval 种子 category 正确、decoy 隔离、baseline 以最新 accepted 报告为准。
---
## 9. 决议摘要
| 项 | 决议 |
|---|---|
| 现网行为 | **保持原样** |
| 近期实施 | **否**(开放跟踪) |
| 首选方向 | **A + C** |
| 可选后续 | B |
| 不做 | D(全面软约束) |
| 严重程度 | 中(路径敏感,非主链路阻断) |
@@ -3,7 +3,7 @@
## Context
- Modular RAG pipeline already exists (`KnowledgeQueryTransformer` → retriever → post-processor → packer → assembler → projector).
- Delivery baseline: `docs/Milvus-Hybrid接入清单.md` §1.1 Delivery 1.
- Delivery baseline: `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1 Delivery 1.
- Historical decision (modular RAG): L0 is hint-only; L1 is fact evidence. This change keeps that boundary.
- Legacy Milvus SDK search path will be abandoned later; this change must not thicken SDK-specific logic.
@@ -9,7 +9,7 @@
As a result, L1 can recall multiple useful chunks from one document, but Agent often sees only one. This blocks multi-path / hybrid retrieval benefits and hurts long runbook completeness.
This change is **Delivery 1** from `docs/Milvus-Hybrid接入清单.md`. Hybrid schema/search is **out of scope** and will be a separate change after this is archived.
This change is **Delivery 1** from `mvp/engineering/rag/Milvus-Hybrid接入清单.md`. Hybrid schema/search is **out of scope** and will be a separate change after this is archived.
## What Changes
@@ -47,7 +47,7 @@ This change is **Delivery 1** from `docs/Milvus-Hybrid接入清单.md`. Hybrid s
- Code: retrieval DTO/services, post-processor, lookup tool config, projector, tests
- Agent-visible: more evidence items possible for same logical document when multiple chunks are relevant
- Interface level: **L2/L3** — Agent `document_id` semantics become chunk-scoped evidence id (often `docId#chunk-N`); EvidenceGuard still validates against tool projection ids
- Docs baseline: `docs/Milvus-Hybrid接入清单.md` §1.1 Delivery 1
- Docs baseline: `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1 Delivery 1
## Risks
@@ -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) {
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 拦截器名称(框架注册用)。
*/
@Override
public String getName() {
return "harness_evidence_tool_interceptor";
}
/**
* 拦截框架 ReAct loop 的每次 Tool Call——progress 协议的主战场。
*
* <p>只拦截证据类 Tool(RAG / 日志 / MySQL),其余 Tool 原样放行。对证据 Tool 依次执行:
* <ol>
* <li>已停止检查:停止指令交付后仍请求 → 受控停止;</li>
* <li>解析 + 评价:严格解析 Envelope,应用模型对上一轮的 GAINED/NO_GAIN;</li>
* <li>重复检测:参数级规范化 scope,backend 执行前拒绝重复查询;</li>
* <li>执行:交给 ToolBoundary(预算 / canonical / 审计统一门禁),并对结果做双源交叉验证;</li>
* <li>收尾:NO_EVIDENCE 自动计 NO_GAIN,饱和时交付一次 STOP_REQUIRED,返回有界 observation。</li>
* </ol>
*
* <p>模型侧观察(observation)共有三副面孔:正常执行结果、STOP_REQUIRED(含 reason)、
* 可修复协议错误(repair_required)。
*/
@Override
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(handler, "handler must not be null");
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
if (!evidenceTools.supports(request.getToolName())) {
return handler.call(request);
}
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
DiagnosisProgressSnapshotState before = context.progress().snapshot();
if (before.collectionState() == DiagnosisCollectionState.SATURATED
&& before.stopInstructionDelivered()) {
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
ParsedAgentToolCall call;
String normalizedScope;
try {
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
context.progress().applyPreviousObservation(call.previousObservation());
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
// 审计:模型回传的评价进入 Trace(producer=MODEL)
recordModelProgress(before, call, evaluated);
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
recordRejection(request, "INFORMATION_SATURATED");
return stopRequired(request, evaluated.stopReason());
}
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
} catch (ProgressProtocolViolationException exception) {
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
return handleProgressProtocolViolation(request, exception);
} catch (IllegalArgumentException | IllegalStateException exception) {
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
return handleProgressProtocolViolation(request,
new ProgressProtocolViolationException(
ProgressProtocolViolationType.INVALID_ENVELOPE,
"Tool Call Envelope is invalid", null, null, exception));
}
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
recordRejection(request, "DUPLICATE_SCOPE");
context.progress().recordDuplicateScope();
context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", duplicate);
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return duplicateScope(request);
}
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
ToolBoundaryResult result = evidenceTools.invoke(
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
if (result.status() == InvocationStatus.READY) {
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
ToolControlView control = viewProjector.controlView(result.agentResult());
if (control.evidenceStatus() != result.evidenceStatus()) {
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
}
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
context.progress().recordCompleted(
new CompletedToolCall(
request.getToolCallId(), request.getToolName(), normalizedScope),
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", completed);
}
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
&& context.progress().claimStopInstruction();
if (stopRequired) {
traceRecorder.record(TraceAuditEvents.collectionStop(
context, request.getToolCallId(), request.getToolName(), completed));
}
// 有界 observation 返回给模型(含停止指令/停止原因)
String observation = viewProjector.modelObservation(
request.getToolName(), result.agentResult(), normalizedScope,
stopRequired, completed.stopReason());
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
}
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
context.progress().markBudgetLimitReached();
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 交付「必须停止」观察(observation 三副面孔之一)。
*
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
*/
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
if (!context.progress().claimStopInstruction()) {
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
throw new DiagnosisCollectionStoppedException(reason);
}
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
*/
private ToolCallResponse duplicateScope(ToolCallRequest request) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("tool_call_id", request.getToolCallId());
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
*/
private ToolCallResponse handleProgressProtocolViolation(
ToolCallRequest request,
ProgressProtocolViolationException exception) {
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return repairableProtocolError(request, exception, state);
}
/**
* 可修复协议错误观察(observation 三副面孔之一):
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
*/
private ToolCallResponse repairableProtocolError(
ToolCallRequest request,
ProgressProtocolViolationException exception,
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
* 用于契约不一致等非协议类拒绝。
*/
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", "ERROR");
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
*/
private void recordRejection(ToolCallRequest request, String errorCode) {
recordRejection(request, errorCode, null, false, context.progress().snapshot());
}
/**
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
*
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
* 不消耗 Tool 预算、不计入实际执行数。
*/
private void recordRejection(
ToolCallRequest request,
String errorCode,
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
violationType, repairPromptDelivered, state));
}
/**
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
*/
private String writeObservation(Map<String, Object> observation) {
try {
return objectMapper.writeValueAsString(observation);
@@ -258,6 +329,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
}
}
/**
* 把模型回传的评价写入 Trace(producer=MODEL):在 before 的已完成调用里
* 找到被评价的那次,记录其 tool_call_id + information_gain + 评价后的状态。
*/
private void recordModelProgress(
DiagnosisProgressSnapshotState before,
ParsedAgentToolCall call,
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
call.previousObservation().informationGain(), "MODEL", after));
}
/**
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
*/
private void recordProgress(
String toolCallId,
String toolName,
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
informationGain, producer, state));
}
/**
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
*/
private String scopeSummary(String toolName, String normalizedScope) {
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
}
/**
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
*/
private String errorObservation(ToolBoundaryResult result) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", result.evidenceStatus());
@@ -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
}
@@ -4,15 +4,41 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* 安全回退契约(Release 层 FALLBACK 的对外载荷):不发布根因报告时,
* 把「为什么降级 + 已验证事实 + 下一步建议」结构化地交给用户。
*
* <p>设计要点:
* <ul>
* <li>{@code conclusion} 恒为 null:降级绝不发布未证明的根因结论;</li>
* <li>诚实降级:verified_sources / observed_facts 保留已验证事实
* (供用户继续排查),validation_issues 给出失败原因;</li>
* <li>有界冻结契约:全部列表不可变,内容经 SafeFallbackFactory 截断去重
* (来源 12 条以内、摘要 320 字),绝不泄露 raw / 敏感正文。</li>
* </ul>
*
* <p>构造:仅 {@code SafeFallbackFactory}(release 域);
* 包装对外:{@code FallbackContent}(application 域);
* 审计提取:{@code RunConclusionExtractor}(audit 域)。
*/
public record SafeFallback(
/** 降级细分类型:EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / ... */
@JsonProperty("type") FallbackType type,
/** 恒为 null(降级不发布根因结论,保留字段仅为契约完整性)。 */
@JsonProperty("conclusion") String conclusion,
/** 一句话降级原因(用户可读)。 */
@JsonProperty("message") String message,
/** 已验证来源(去重):查过哪些来源。 */
@JsonProperty("verified_sources") List<VerifiedSource> verifiedSources,
/** 限制声明:检查范围 / 缺失项 / 降级原因。 */
@JsonProperty("limitations") List<String> limitations,
/** 下一步建议(用户可执行)。 */
@JsonProperty("next_steps") List<String> nextSteps,
/** 失败阶段标识:DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION。 */
@JsonProperty("failure_stage") String failureStage,
/** 已观察事实(去重、有界):每条 = 来源 + 范围 + 摘要,供继续排查。 */
@JsonProperty("observed_facts") List<ObservedFact> observedFacts,
/** 验证违规明细(EVIDENCE_VALIDATION_FAILED 时携带 code + target)。 */
@JsonProperty("validation_issues") List<ValidationIssue> validationIssues) {
public SafeFallback {
@@ -33,12 +59,14 @@ public record SafeFallback(
null, List.of(), List.of());
}
/** 已验证来源:来源类型 + 来源 + 范围(发布层可对外展示的最小来源单位)。 */
public record VerifiedSource(
@JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source,
@JsonProperty("scope") String scope) {
}
/** 已观察事实:来源类型 + 来源 + 范围 + 有界摘要(一条工具调用结果投影)。 */
public record ObservedFact(
@JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source,
@@ -46,6 +74,7 @@ public record SafeFallback(
@JsonProperty("summary") String summary) {
}
/** 验证违规明细:违规码 + 目标字段(EVIDENCE_VALIDATION_FAILED 时携带)。 */
public record ValidationIssue(
@JsonProperty("code") String code,
@JsonProperty("target") String target) {
@@ -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;
}
@@ -26,6 +26,27 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 证据机械验真(证据安全链第 1 道闸):不信任模型自述,以 Canonical Store 账本为准。
*
* <p>职责:校验 DiagnosisDraft 结构合法 + 每个 tool_call_id 引用真实可查
* + 投影内部自洽,并把账本投影「重读重建」为 VerifiedEvidence 快照。
*
* <p>三个阶段:
* <ol>
* <li>{@link #validateDraft}:草稿结构完整性(analysis 非空、id 唯一、kind/text/引用齐全、limitations 必填);</li>
* <li>{@link #verifyInvocation}:引用验真(key=runId+toolCallId 查账本、记录必须 READY、
* 同 Run、agent_result 非空、kind 与证据语义匹配、工具受支持);</li>
* <li>readRag/readLogs/readMysql:严格反序列化投影并校验内部一致性,
* 重读重建 VerifiedEvidence(证据内容来自账本,不是模型复述)。</li>
* </ol>
*
* <p>纯规则门控、不调大模型:20 个违规码全部可枚举可审计;
* 产出 {@link VerifiedEvidenceSnapshot} 供 SemanticGuard 语义裁决与 release 发布。
*
* <p>被 {@code DiagnosisReleaseUseCase} 调用(validate / validateNoConclusionReferences),
* 是「唯一发布点」的第一道门。
*/
public final class EvidenceGuard {
private final CanonicalInvocationStore store;
@@ -35,6 +56,12 @@ public final class EvidenceGuard {
private final ObjectReader mysqlRequestReader;
private final ObjectReader mysqlResultReader;
/**
* 构造:注入账本(canonical store)+ key 工厂 + 严格模式 reader。
*
* <p>四种 reader 全部开启 FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS——
* 多余字段、尾随内容一律解析失败(fail closed,不接受「看起来差不多」的投影)。
*/
public EvidenceGuard(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) {
@@ -55,6 +82,12 @@ public final class EvidenceGuard {
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
}
/**
* 主入口:draft 结构校验 → 逐条 tool_call 引用验真 → 重读投影重建证据。
*
* <p>任何违规都收集到 violations(不中断,尽量报全);全部通过才产出快照。
* 每个 analysis 只保留「证据非空」的条目——引用为空/无效的分析不进快照。
*/
public EvidenceGuardResult validate(RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null");
List<EvidenceViolation> violations = validateDraft(draft);
@@ -79,6 +112,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations);
}
/**
* 无结论场景的引用校验(conclusion == null 时由 release 调用):
* 只验引用真实性,不产出快照(返回 empty)——没有结论就没有「是否被支持」可判。
* 供 {@code DiagnosisReleaseUseCase.releaseNoConclusion} 发布前兜底验引用。
*/
public EvidenceGuardResult validateNoConclusionReferences(
RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null");
@@ -113,6 +151,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations);
}
/**
* 阶段 A:草稿结构完整性校验(纯规则,不碰 store)。
* 先收集全部 analysis id 到集合,再校验报告级引用只能指向这些已登记 id
* (封死「结论引用不存在的分析」路径)。
*/
private List<EvidenceViolation> validateDraft(DiagnosisDraft draft) {
List<EvidenceViolation> violations = new ArrayList<>();
if (draft == null) {
@@ -149,6 +192,10 @@ public final class EvidenceGuard {
return violations;
}
/**
* 报告级引用校验:conclusion / action_plan / recommendations 的
* based_on_analysis_ids 必须存在且指向已登记 analysis id;limitations 必填。
*/
private void validateReportReferences(DiagnosisDraft draft, Set<String> ids,
List<EvidenceViolation> violations) {
if (draft.conclusion() != null) {
@@ -178,6 +225,10 @@ public final class EvidenceGuard {
}
}
/**
* 单条报告文本 + 其 based_on_analysis_ids 的合法性:
* 文本非空、引用列表非空、每个引用都必须指向已登记 analysis id。
*/
private void validateTextAndReferences(String target, String text, List<String> references,
Set<String> ids, List<EvidenceViolation> violations) {
if (isBlank(text)) {
@@ -200,6 +251,18 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 B(核心):验真单条 tool_call 引用。链条逐环检查,任何一环不过即记违规并跳过:
*
* <pre>
* id 非空 → key 构造(runId 绑定,防跨 Run 引用)→ 账本可查 → id 一致
* → isReferencableBy(READY + 同 Run + agent_result 非空 + 合法证据语义)
* → kind 匹配证据语义(NORMAL↔FOUND / NEGATIVE_OBSERVATION↔NO_EVIDENCE)
* → 工具受支持(RAG / LOGS / MYSQL)
* </pre>
*
* 该引用不被采信不代表整体失败:继续检查其余引用,违规全部汇总。
*/
private void verifyInvocation(RunContext context, DiagnosisDraft.AnalysisItem analysis,
int analysisIndex, String toolCallId,
List<VerifiedEvidence> evidence,
@@ -250,6 +313,11 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 C(RAG):严格反序列化 RagToolResult 投影,校验内部一致性
* (toolCallId / evidenceStatus / returnedCount==evidence.size() / NO_EVIDENCE 时证据必须为空)
* 后,把每条命中重建为 VerifiedEvidence。
*/
private void readRag(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
RagToolResult result;
@@ -301,6 +369,11 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 C(LOGS):校验 QueryLogsToolResult 结构(topic/query/时间窗/matchCount 齐全、
* returnedCount==events.size()、NO_EVIDENCE 时 matchCount 必须为 0 且无 patterns/events),
* 把日志模式与事件重建为 VerifiedEvidence。
*/
private void readLogs(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
QueryLogsToolResult result;
@@ -364,6 +437,7 @@ public final class EvidenceGuard {
}
}
/** 投影通用一致性校验:toolCallId 与 evidenceStatus 必须与账本记录一致(防投影与账本脱节)。 */
private boolean projectionMatches(CanonicalToolInvocation invocation, String toolCallId,
com.superbiz.agent.harness.contract.EvidenceStatus evidenceStatus,
String target, List<EvidenceViolation> violations) {
@@ -378,6 +452,10 @@ public final class EvidenceGuard {
return true;
}
/**
* 阶段 C(MYSQL):校验 MysqlToolResult(列唯一非空、returnedCount==rows.size()、
* 每行 keySet 必须恰好等于 columns),把每一行重建为 VerifiedEvidence(带 _row_number)。
*/
private void readMysql(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
MysqlToolRequest request;
@@ -7,6 +7,10 @@ public record EvidenceGuardResult(
List<EvidenceViolation> violations,
VerifiedEvidenceSnapshot snapshot) {
/**
* 结构不变量:valid 必须有 snapshot、invalid 必须有 violations,二选一无中间态
* (有效结果不可能带违规,无效结果不可能带快照)。
*/
public EvidenceGuardResult {
violations = violations == null ? List.of() : List.copyOf(violations);
if (violations.isEmpty() == (snapshot == null)) {
@@ -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
}
@@ -24,6 +24,19 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* 守卫模型调用器:通用「隔离判定模型」的受控调用(SemanticGuard 等守卫用)。
*
* <p>与主 Agent 调用不同:守卫模型单轮、无工具、强约束输出,但同样受 Run 生命周期管:
* <ul>
* <li>core.beforeModelCall / checkActive:与 core 门禁对齐;</li>
* <li>auditor.begin / recordUsage:Token 记账(ModelCallLedger);</li>
* <li>executor.submit + future.get(timeout):独立线程 + 超时截断;</li>
* <li>context.cancellation().onCancel → future.cancel(true):Run 取消强杀在途调用;</li>
* <li>输出限制:非文本 / 带 tool_calls / 超 maxOutputBytes → SCHEMA_INVALID 重试;</li>
* <li>终态异常(RunAborted / BudgetExceeded)原样穿出,不吞。</li>
* </ul>
*/
public final class GuardModelCall {
private final DiagnosisHarnessCore core;
@@ -44,6 +57,11 @@ public final class GuardModelCall {
this.auditor = Objects.requireNonNull(auditor, "auditor must not be null");
}
/**
* 受控调用:beforeModelCall 门禁 → 记账开始 → 提交执行 → 注册取消回调
* → future.get(timeout) 等待。超时/取消/中断/执行异常分别归类映射 RetryFailure;
* RunAbortedException / BudgetExceededException 原样穿出(Run 终态事实,不可重试)。
*/
public String call(RunContext context, ModelCallComponent component,
Prompt prompt, Duration timeout, long maxOutputBytes) {
Objects.requireNonNull(context, "context must not be null");
@@ -91,6 +109,11 @@ public final class GuardModelCall {
}
}
/**
* 执行侧:真实 chatModel.call,成功则记账 usage 并 checkActive;
* 输出形状违规(null/空白/带 tool_calls)或超 maxOutputBytes 归类 SCHEMA_INVALID;
* 输出字节也 reserveRunBytes 计入预算(守卫模型的花费不是无底洞)。
*/
private String invoke(RunContext context, ModelCallLedger.Call call,
Prompt prompt, long maxOutputBytes) {
ChatResponse response;
@@ -119,6 +142,7 @@ public final class GuardModelCall {
return output.getText();
}
/** 记账 usage;缺 metadata/usage 时按 0 记账(保持账本完整性,可对账)。 */
private void recordUsage(RunContext context, ModelCallLedger.Call call, ChatResponse response) {
if (response == null || response.getMetadata() == null) {
auditor.recordUsage(context, call, 0, 0, false);
@@ -24,6 +24,24 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
/**
* 语义裁决(证据安全链第 2 道闸):判「结论是否被已验证证据支持」。
*
* <p>在 EvidenceGuard 机械验真通过后调用——结构错、引用假根本到不了这里。
* 职责:把用户可见视图(SemanticDraftView,不含内部 id)+ 已验证证据交给
* 隔离的守卫模型,硬校验输出(恰好 {verdict, reason} 两个字段),
* 裁决 SUPPORTED / UNSUPPORTED。
*
* <p>与 Harness 全栈衔接:
* <ul>
* <li>预算:输入输出字节都 {@code core.reserveRunBytes} 计入 Run 预算;</li>
* <li>重试:走 {@code context.retryPolicies().semanticGuard()},每次 attempt 递减剩余超时;</li>
* <li>取消:GuardModelCall 内 onCancel → future.cancel(true);</li>
* <li>审计:ModelCallLedger 记账 + TraceAuditEvents.semanticAttempt 落 trace。</li>
* </ul>
*
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用(唯一入口)。
*/
public final class SemanticGuard {
private static final Set<String> OUTPUT_FIELDS = Set.of("verdict", "reason");
@@ -65,6 +83,12 @@ public final class SemanticGuard {
this.prompt = SemanticGuardPrompt.load();
}
/**
* 主入口:序列化输入(超限即拒绝 SCHEMA_INVALID)→ 计入预算
* → 构造 System(prompt)+User(输入 JSON) 双消息
* → 经 HarnessRetryExecutor 按 semanticGuard 策略执行模型调用
* → 硬校验输出 schema → 返回裁决。每次 attempt 都记审计 trace。
*/
public SemanticGuardDecision review(RunContext context, SemanticGuardInput input) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(input, "input must not be null");
@@ -91,6 +115,11 @@ public final class SemanticGuard {
});
}
/**
* 硬校验模型输出:必须是 JSON、字段恰好 {verdict, reason}、verdict 合法枚举、
* reason 非空;否则按失败类型抛 GuardModelCallException(可重试:
* PARSE_ERROR / SCHEMA_INVALID)。防止模型夹带多余字段或输出不完整。
*/
private SemanticGuardDecision parse(String output) {
JsonNode root;
try {
@@ -115,6 +144,10 @@ public final class SemanticGuard {
return new SemanticGuardDecision(verdict, root.path("reason").asText());
}
/**
* 每次 attempt 的剩余超时 = min(总超时 - 已用, 单次上限);
* 总超时耗尽即抛 TIMEOUT(不再重试)——守卫判定有硬截止线。
*/
private Duration remainingTimeout(long startedNanos) {
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
long remaining = limits.totalTimeout().toNanos() - elapsed;
@@ -125,6 +158,10 @@ public final class SemanticGuard {
return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos()));
}
/**
* 失败分类:GuardModelCallException 自带 RetryFailure;
* 其余未知异常归 UNKNOWN(不重试,直接失败)。
*/
private RetryFailure classify(Exception exception) {
if (exception instanceof GuardModelCallException guardFailure) {
return guardFailure.failure();
@@ -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
}
@@ -16,16 +16,42 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 停止后的「安全投影」:把 Tracker 里保存的调用 identity 回读 Canonical Store 的
* READY 记录,投影成可发布的有界快照 {@link DiagnosisProgressSnapshot}。
*
* <p>设计要点:
* <ul>
* <li>输入:Tracker 只存 identity(toolCallId + toolName + normalizedScope),无 payload;
* 完整事实按 key(runId + toolCallId)从 Canonical Store 回读——避免出现第二份 Tool 真相;</li>
* <li>三重校验(isReferencableBy / toolCallId / toolName)通过才发布;
* 无法验真、不可读、格式非法的记录一律排除,只形成 limitation;</li>
* <li>空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是有价值信息;</li>
* <li>硬截断:最多 12 条事实、摘要/范围各 320 字符、来源 160 字符,绝不输出 raw。</li>
* </ul>
*
* <p>产出被 {@code DiagnosisReleaseUseCase} 消费,是发布 INSUFFICIENT_EVIDENCE 类
* FALLBACK 的全部原料(progress 与 release 的交汇点)。
*/
public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
private static final int MAX_FACTS = 12;
/** 每条事实摘要的最大字符数。 */
private static final int MAX_SUMMARY_CHARS = 320;
/** 查询范围(scope)的最大字符数。 */
private static final int MAX_SCOPE_CHARS = 320;
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
private final CanonicalInvocationStore store;
/** 生成 canonical key(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory;
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
private final ObjectMapper objectMapper;
/**
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
*/
public DiagnosisProgressProjector(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) {
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
}
/**
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
* limitations + stopReason),供 Release 发布。
*/
@Override
public DiagnosisProgressSnapshot project(RunContext context) {
Objects.requireNonNull(context, "context must not be null");
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
for (CompletedToolCall completed : state.completedToolCalls()) {
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
if (invocation == null) {
// 无法验真/不可读:已记 limitation,跳过
continue;
}
try {
projectInvocation(completed, invocation, sources, facts);
} catch (RuntimeException exception) {
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
}
if (facts.size() >= MAX_FACTS) {
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
state.stopReason());
}
/**
* 按 identity 回读 canonical 记录,做三重校验:
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
*/
private CanonicalToolInvocation resolve(RunContext context,
CompletedToolCall completed,
List<String> limitations) {
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
return invocation;
} catch (RuntimeException exception) {
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
return null;
}
}
/**
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
*/
private void projectInvocation(CompletedToolCall completed,
CanonicalToolInvocation invocation,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -97,12 +140,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* RAG 投影:evidence 数组空 → 有界事实「未发现可用文档证据」;
* 非空 → 逐条投影 source(source/title/document_id 取其一)+ excerpt。
*/
private void projectRag(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts) {
JsonNode evidence = root.path("evidence");
if (!evidence.isArray() || evidence.isEmpty()) {
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
addFact(sources, facts, "RAG", "knowledge_base", scope,
"该知识检索范围内未发现可用文档证据");
return;
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
* source 取自 source_kind(缺省 logs)。
*/
private void projectLogs(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -132,6 +184,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* MySQL 投影:rows 空 → 「未发现匹配记录」;非空 → 逐行 toString。
* source 从公开 scope 的 data_source 提取。
*/
private void projectMysql(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -148,6 +204,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 公开 scope:从 agent_result / normalizedScope 提取对外展示的查询范围,
* 按 Tool 类型不同(RAG=query、LOG=scope 对象、MYSQL=data_source),
* 统一截断到 MAX_SCOPE_CHARS——不泄露完整参数。
*/
private String publicScope(String toolName, String normalizedScope, JsonNode result) {
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS);
@@ -164,11 +225,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 从公开 scope("data_source=xxx")中提取 MySQL 数据源名。 */
private String mysqlSource(String scope) {
int separator = scope.indexOf('=');
return separator < 0 ? "mysql" : scope.substring(separator + 1);
}
/**
* 添加一条有界事实:三重截断(source 160 / scope 320 / summary 320)后
* 写入去重 Map——同「来源类型 + 来源 + 范围」只发布一次 source,
* 同「sourceKey + 摘要」只发布一次 fact(LinkedHashMap 保持顺序)。
*/
private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts,
String sourceType,
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
}
/**
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
* (由调用方捕获后记为 limitation,不发布)。
*/
private JsonNode readObject(String value) {
try {
JsonNode root = objectMapper.readTree(value);
@@ -201,12 +272,14 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 追加 limitation(按文案去重,避免同一条限制重复出现)。 */
private static void addLimitation(List<String> limitations, String value) {
if (!limitations.contains(value)) {
limitations.add(value);
}
}
/** 取第一个非空值,全空返回 "unknown"。 */
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
return "unknown";
}
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText("");
}
/** 截断到 max 字符(null 视为空串)。 */
private static String bounded(String value, int max) {
String safe = value == null ? "" : value;
return safe.length() <= max ? safe : safe.substring(0, max);
@@ -4,12 +4,29 @@ import com.superbiz.agent.harness.contract.SafeFallback;
import java.util.List;
/**
* 停止后的「安全进展快照」:Run 已完成的验证事实 + 限制声明 + 停止原因。
*
* <p>由 {@link DiagnosisProgressProjector} 在 Agent 停止后投影生成:
* 把 Tracker 里的调用 identity 回读 Canonical Store 的 READY 记录,
* 重读投影成有界、去重的事实——不是模型自述,是账本背书。
*
* <p>被 {@code DiagnosisReleaseUseCase} 消费:
* 受控停止 / 无结论 / 非法 Draft 三条降级路径都靠它决定发布形态
* (有 observed_facts 才允许发布 INSUFFICIENT_EVIDENCE,否则 fail closed)。
*/
public record DiagnosisProgressSnapshot(
/** 已验证来源(去重后):只到「查过哪些来源」粒度,供 FALLBACK 展示。 */
List<SafeFallback.VerifiedSource> verifiedSources,
/** 已观察事实(去重后):每条 = 来源类型 + 来源 + 范围 + 有界摘要,
* 是「Run 真的查过什么、结果如何」的证据性记录(空查询也算事实)。 */
List<SafeFallback.ObservedFact> observedFacts,
/** 限制声明:无法验真/不可读/截断等原因的诚实说明。 */
List<String> limitations,
/** 停止原因(受控停止时):信息饱和 / 预算耗尽 / 协议违规。 */
DiagnosisStopReason stopReason) {
/** 防御:三列表全部转不可变,null 视为空列表。 */
public DiagnosisProgressSnapshot {
verifiedSources = verifiedSources == null ? List.of() : List.copyOf(verifiedSources);
observedFacts = observedFacts == null ? List.of() : List.copyOf(observedFacts);
@@ -20,6 +37,11 @@ public record DiagnosisProgressSnapshot(
return new DiagnosisProgressSnapshot(List.of(), List.of(), List.of(), null);
}
/**
* 「是否有安全进展」的判断依据:observedFacts 非空即视为有已验真事实。
* release 域的 fail-closed 分支全靠它——没有事实就不能把
* 「没查到」伪装成业务结果发布。
*/
public boolean hasObservedFacts() {
return !observedFacts.isEmpty();
}
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Progress 层的核心状态机:判定「继续收集证据是否还有价值」。
*
* <p>与 RunBudget 的分工(双停止机制):
* <ul>
* <li>RunBudget 管「能不能花」——模型次数 / Tool 次数 / Token / bytes 等硬资源上限;</li>
* <li>本 Tracker 管「继续查有没有价值」——连续 NO_GAIN、重复 scope、协议违规都会推动
* 收集状态走向 SATURATED,进而在硬预算之前让 Agent 受控停止。</li>
* </ul>
*
* <p>设计要点:
* <ul>
* <li>只保存做停止决策需要的最小状态(identity + 计数),不保存 request / raw /
* agent result,避免出现第二份 Tool 真相(完整事实在 Canonical Store);</li>
* <li>所有状态读写 synchronized,是 RunContext 中的线程安全单一所有者;</li>
* <li>Tool 提供客观结果,模型判断语义增益(GAINED/NO_GAIN),但最终停止权归 Harness。</li>
* </ul>
*/
public final class DiagnosisProgressTracker {
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
private final int stopAfterConsecutiveNoGain;
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
private final int stopAfterConsecutiveProgressProtocolViolations;
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
/** 连续无增益次数;GAINED 清零。 */
private int consecutiveNoGain;
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
private int consecutiveProgressProtocolViolations;
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
private DiagnosisStopReason stopReason;
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
private String pendingToolCallId;
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
private boolean stopInstructionDelivered;
/**
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
*/
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
this(stopAfterConsecutiveNoGain, 2);
}
/**
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
*
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
*/
public DiagnosisProgressTracker(
int stopAfterConsecutiveNoGain,
int stopAfterConsecutiveProgressProtocolViolations) {
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用模型在下次 Tool Call 中回传的对上一轮观察的评价。
*
* <p>三类协议违规会被拒绝并抛 {@link ProgressProtocolViolationException}:
* <ul>
* <li>{@link ProgressProtocolViolationType#UNEXPECTED_PREVIOUS_OBSERVATION}——没有 pending
* 时却带了 previous_observation(如首次调用);</li>
* <li>{@link ProgressProtocolViolationType#MISSING_PREVIOUS_OBSERVATION}——有 pending 却
* 没带评价;</li>
* <li>{@link ProgressProtocolViolationType#OUT_OF_ORDER_PREVIOUS_OBSERVATION}——带的
* tool_call_id 与 pending 不符(乱序/指向未知调用)。</li>
* </ul>
*
* <p>校验通过后才清协议违规计数并应用 GAINED/NO_GAIN。协议错误与无增益是两件事:
* 前者 Tool 根本没执行,后者 Tool 执行了但没推进诊断,因此必须分开统计。
*/
public synchronized void applyPreviousObservation(PreviousObservation observation) {
if (pendingToolCallId == null) {
if (observation != null) {
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
"previous_observation",
null);
}
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
clearProtocolViolations();
return;
}
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
"previous_observation.tool_call_id",
pendingToolCallId);
}
// 校验通过:清空 pending,评价生效
pendingToolCallId = null;
clearProtocolViolations();
applyGain(observation.informationGain());
}
/**
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
*/
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
}
/**
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
*/
public synchronized void recordDuplicateScope() {
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
}
/**
* 记录一次成功的 Tool 完成。
*
* <ul>
* <li>SATURATED 后禁止再记录完成(饱和即停止收集);</li>
* <li>只接受 {@link EvidenceStatus#EVIDENCE_FOUND} 或 {@link EvidenceStatus#NO_EVIDENCE};
* 失败走技术失败流程,不进入进度统计;</li>
* <li>重复 scope 抛 IllegalStateException(应在此之前被 isDuplicate 拦截);</li>
* <li>{@link EvidenceStatus#NO_EVIDENCE}:空结果由 Harness 直接判 NO_GAIN,不需要模型评价;</li>
* <li>{@link EvidenceStatus#EVIDENCE_FOUND}:设置 pendingToolCallId,等模型在下次
* Tool Call 的 previous_observation 中评价语义增益。</li>
* </ul>
*/
public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Cannot record Tool completion after saturation");
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
}
completedToolCalls.add(call);
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
// 空结果无需模型评价:立即累计 NO_GAIN
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
} else {
// 非空结果:挂起等待模型在下一轮评价语义增益
pendingToolCallId = call.toolCallId();
}
}
/**
* 记录一次 progress 协议违规,返回最新快照。
*
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
*/
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
if (collectionState == DiagnosisCollectionState.SATURATED) {
// 已饱和:不再累计,直接返回当前快照
return snapshot();
}
consecutiveProgressProtocolViolations++;
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
return snapshot();
}
/**
* 领取一次停止指令(STOP_REQUIRED)。
*
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
*/
public synchronized boolean claimStopInstruction() {
if (collectionState != DiagnosisCollectionState.SATURATED) {
return false;
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
return true;
}
/**
* 标记预算触顶(由 RunBudget 侧调用)。
*
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
* 信息饱和不能伪装成预算耗尽。
*/
public synchronized void markBudgetLimitReached() {
if (stopReason == null) {
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
}
}
/**
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
*/
public synchronized DiagnosisProgressSnapshotState snapshot() {
return new DiagnosisProgressSnapshotState(
consecutiveNoGain,
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
return stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用单次增益判定(核心状态迁移):
* <ul>
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
* </ul>
* 饱和后禁止再次应用(停止权只行使一次)。
*/
private void applyGain(InformationGain gain) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Collection is already saturated");
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
}
}
/**
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
*/
private void clearProtocolViolations() {
consecutiveProgressProtocolViolations = 0;
}
@@ -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
}
@@ -7,12 +7,23 @@ import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import java.util.Objects;
/**
* 发布裁决结果(Release 域唯一出口的结果类型)。
*
* <p>结构不变量:SUCCESS 必须有 draft 且不允许带 fallback;
* FALLBACK 必须有 fallback 且不允许带 draft——
* 成功只能带验证过的草稿、降级只能带安全回退,绝无「半真半假」的中间产物。
*/
public record DiagnosisReleaseResult(
ReleaseOutcome outcome,
DiagnosisDraft draft,
SafeFallback fallback,
VerifiedEvidenceSnapshot verifiedEvidence) {
/**
* 结构不变量:outcome 必填;SUCCESS ↔ draft、FALLBACK ↔ fallback 严格互斥;
* 本域只支持 SUCCESS / FALLBACK 两个出口(FAILED/CANCELLED 由 Application 层写)。
*/
public DiagnosisReleaseResult {
Objects.requireNonNull(outcome, "outcome must not be null");
Objects.requireNonNull(verifiedEvidence, "verifiedEvidence must not be null");
@@ -24,14 +24,41 @@ import com.superbiz.agent.harness.retry.RetryFailure;
import java.util.Objects;
import java.util.List;
/**
* 对外结果的「唯一发布点」:把 Agent 执行结果(Draft / 受控停止 / 非法 Draft)
* 裁决为 {@code SUCCESS / FALLBACK},并保证任何对外发布内容都经过
* 证据验证(EvidenceGuard)+ 语义裁决(SemanticGuard)。
*
* <p>决策树(与 progress 的衔接在这里):
* <pre>
* execute(execution)
* ├─ draft == null → 受控停止(progress + stopReason)→ INSUFFICIENT_EVIDENCE
* ├─ draft.conclusion == null → 无结论 Draft → 有已验真事实 ? INSUFFICIENT_EVIDENCE
* │ : missing_info ? MISSING_REQUIRED_CONTEXT
* │ : fail closed(抛异常)
* └─ 有结论 Draft → evidenceGuard 验引用 → repair 重试 → semanticGuard 裁决
* → SUPPORTED ? SUCCESS : FALLBACK(SEMANTIC_UNSUPPORTED)
* </pre>
*
* <p>任何 FALLBACK 都通过 SafeFallbackFactory 构造有界安全回退(不泄露 raw/敏感正文),
* 终态异常(取消/预算耗尽)向上传播不吞掉。
*/
public final class DiagnosisReleaseUseCase {
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
private final EvidenceGuard evidenceGuard;
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
private final EvidenceRepair evidenceRepair;
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
private final SemanticGuard semanticGuard;
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
private final SafeFallbackFactory fallbackFactory;
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
private final DiagnosisTraceRecorder traceRecorder;
/**
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
DiagnosisTraceRecorder.noop());
}
/**
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
* 包装成 completed execution(progress 为空),走完整裁决。
*/
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
return execute(context, query, DiagnosisAgentExecution.completed(
Objects.requireNonNull(draft, "draft must not be null"),
DiagnosisProgressSnapshot.empty()));
}
/**
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
*
* <ul>
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
* <li>conclusion == null:模型明确无结论,校验其引用后按
* 有无已验真事实 / missing_info 决定发布类型;</li>
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
* </ul>
*/
public DiagnosisReleaseResult execute(
RunContext context, String query, DiagnosisAgentExecution execution) {
Objects.requireNonNull(context, "context must not be null");
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
DiagnosisDraft draft = execution.draft();
if (draft == null) {
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
return releaseControlledStop(context, execution.progress(), execution.stopReason());
}
if (draft.conclusion() == null) {
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
return releaseNoConclusion(context, draft, execution.progress());
}
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
return releaseConclusion(context, query, draft);
}
/**
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
*/
public DiagnosisReleaseResult releaseInvalidDraft(
RunContext context, DiagnosisProgressSnapshot progress) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(progress, "progress must not be null");
if (!progress.hasObservedFacts()) {
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
throw new IllegalStateException(
"Invalid Diagnosis Draft has no verified publishable progress");
}
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 有结论 Draft 的完整发布链:
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
*/
private DiagnosisReleaseResult releaseConclusion(
RunContext context, String query, DiagnosisDraft draft) {
DiagnosisDraft candidate = draft;
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
if (!evidence.valid()) {
try {
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
evidence = evidenceGuard.validate(context, candidate);
traceRecorder.record(TraceAuditEvents.evidenceValidation(
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
return evidenceFailure(context, evidence);
}
if (!evidence.valid()) {
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
return evidenceFailure(context, evidence);
}
}
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
decision = semanticGuard.review(
context, SemanticGuardInput.from(query, candidate, snapshot));
} catch (RuntimeException exception) {
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
propagateTerminal(exception);
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
}
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
return DiagnosisReleaseResult.success(candidate, snapshot);
}
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
FallbackType.SEMANTIC_UNSUPPORTED));
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
}
/**
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
* 都没有 → 不变量被破坏,fail closed 抛异常。
*/
private DiagnosisReleaseResult releaseNoConclusion(
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
List<String> missingInfo = missingInfo(draft);
if (progress.hasObservedFacts()) {
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, missingInfo),
FallbackType.INSUFFICIENT_EVIDENCE);
}
if (!missingInfo.isEmpty()) {
// 零 Tool 直接声明缺上下文:合法,不强制空查
return progressFallback(context,
fallbackFactory.missingRequiredContext(missingInfo),
FallbackType.MISSING_REQUIRED_CONTEXT);
}
// 既无进展又无缺失声明 → 状态非法,fail closed
throw new IllegalStateException(
"No-conclusion Diagnosis has neither verified progress nor missing context");
}
/**
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
*/
private DiagnosisReleaseResult releaseControlledStop(
RunContext context,
DiagnosisProgressSnapshot progress,
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
throw new IllegalStateException("Unsupported Diagnosis stop reason");
}
if (!progress.hasObservedFacts()) {
// 没有已验证进展 → fail closed(对外不可发布任何结论)
throw new IllegalStateException(
"Controlled Diagnosis stop has no verified publishable progress");
}
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, List.of()),
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
*/
private DiagnosisReleaseResult progressFallback(
RunContext context,
com.superbiz.agent.harness.contract.SafeFallback fallback,
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
return DiagnosisReleaseResult.fallback(fallback);
}
/**
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
*/
private List<String> missingInfo(DiagnosisDraft draft) {
return draft.limitations() == null
? List.of() : draft.limitations().missingInfo();
}
/**
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
* (违规明细进 fallback,不发布模型原文)。
*/
private DiagnosisReleaseResult evidenceFailure(
RunContext context, EvidenceGuardResult evidence) {
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
fallbackFactory.evidenceValidationFailed(evidence.violations()));
}
/**
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
*/
private void propagateTerminal(RuntimeException exception) {
if (exception instanceof RunAbortedException
|| exception instanceof BudgetExceededException) {
@@ -26,6 +26,25 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* 引用修复器(证据安全链的一环):EvidenceGuard 验真失败后,「只修引用、不修结论」。
*
* <p>核心约束:
* <ul>
* <li>prompt 锁死:只能改 analysis_id / tool_call_ids / based_on_analysis_ids
* 三个引用字段,结论、分析正文、kind、limitations 一律禁止动;</li>
* <li>语义不变性检查:修复前后 {@link SemanticDraftView#hasSameUserVisibleSemantics}
* 逐字段比对——用户可见内容一个字节不许变,变了判 SCHEMA_INVALID 重试;</li>
* <li>受控调用:与 SemanticGuard 同款全栈衔接(输入计预算、独立重试策略
* evidenceRepair、超时限制、GuardModelCall 受控调用、审计 trace)。</li>
* </ul>
*
* <p>为什么调大模型而不是 Harness 机械替换:修引用需要理解语义
* (哪条 analysis 该锚哪个调用),机械替换做不到;但修复器的自由度
* 被 prompt + 语义不变性双重锁死。
*
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用。
*/
public final class EvidenceRepair {
private final DiagnosisHarnessCore core;
@@ -69,6 +88,14 @@ public final class EvidenceRepair {
this.prompt = EvidenceRepairPrompt.load();
}
/**
* 主入口:输入(query + 原 draft + 违规清单)序列化并计预算
* → 构造 System(prompt)+User(输入) 双消息
* → 按 evidenceRepair 重试策略执行模型修复
* → 解析修复结果并做语义不变性检查(变了即失败)。
*
* @return 修复后的 DiagnosisDraft(仅引用字段可能变化)
*/
public DiagnosisDraft repair(RunContext context, String query, DiagnosisDraft original,
List<EvidenceViolation> violations) {
Objects.requireNonNull(context, "context must not be null");
@@ -110,6 +137,7 @@ public final class EvidenceRepair {
});
}
/** 严格反序列化修复输出(FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS),失败归类 PARSE_ERROR。 */
private DiagnosisDraft parse(String output) {
try {
return draftReader.readValue(output);
@@ -119,6 +147,7 @@ public final class EvidenceRepair {
}
}
/** 失败分类:GuardModelCallException 自带 RetryFailure;其余归 UNKNOWN。 */
private RetryFailure classify(Exception exception) {
return exception instanceof GuardModelCallException failure
? failure.failure() : RetryFailure.UNKNOWN;
@@ -3,6 +3,10 @@ package com.superbiz.agent.harness.release;
import java.time.Duration;
import java.util.Objects;
/**
* EvidenceRepair 的限额:输入/输出字节 + 单次修复超时。
* 防修复器本身成为无底洞(超大 draft 或无限重试)。
*/
public record EvidenceRepairLimits(
long maxInputBytes,
long maxOutputBytes,
@@ -13,11 +13,28 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 安全回退工厂:构造所有 FALLBACK 形态的有界、去重、诚实降级。
*
* <p>设计要点:
* <ul>
* <li>诚实降级:降级不抹掉进展——SEMANTIC_UNSUPPORTED / INSUFFICIENT_EVIDENCE
* 保留已验证事实(observed_facts / verified_sources)供用户继续排查;</li>
* <li>有界:observed_facts 最多 12 条、摘要 320 字符,missing_info 最多 8 条
* (绝不泄露 raw / 敏感正文,空摘要降级为受限审计说明文案);</li>
* <li>conclusion 恒为 null:降级不发布根因结论;</li>
* <li>fail closed:insufficientEvidence 要求 progress 必须有已验真事实,
* missingRequiredContext 要求 missing_info 非空,否则拒绝构造。</li>
* </ul>
*
* <p>被 {@code DiagnosisReleaseUseCase} 的各个降级出口调用。
*/
public final class SafeFallbackFactory {
private static final int MAX_OBSERVED_FACTS = 12;
private static final int MAX_SUMMARY_CHARS = 320;
/** 引用验真失败(含 repair 后仍失败):只给违规明细,零事实。 */
public SafeFallback evidenceValidationFailed(List<EvidenceViolation> violations) {
return fallback(
FallbackType.EVIDENCE_VALIDATION_FAILED,
@@ -30,6 +47,7 @@ public final class SafeFallbackFactory {
issues(violations));
}
/** 结论不被证据支撑(verdict=UNSUPPORTED):保留已验证事实与来源。 */
public SafeFallback semanticUnsupported(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNSUPPORTED,
@@ -42,6 +60,7 @@ public final class SafeFallbackFactory {
List.of());
}
/** 语义评审技术不可用(超时等):不发布根因,保留已验证事实。 */
public SafeFallback semanticUnavailable(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNAVAILABLE,
@@ -54,6 +73,10 @@ public final class SafeFallbackFactory {
List.of());
}
/**
* 有限检查但证据不足(受控停止/无结论/非法 draft 降级的共同出口):
* 必须已有已验真事实(否则 fail closed),展示检查过的范围 + 缺失项。
*/
public SafeFallback insufficientEvidence(
DiagnosisProgressSnapshot progress, List<String> missingInfo) {
Objects.requireNonNull(progress, "progress must not be null");
@@ -78,6 +101,7 @@ public final class SafeFallbackFactory {
List.of());
}
/** 缺上下文未开始有效查询:只列缺失项,无事实。 */
public SafeFallback missingRequiredContext(List<String> missingInfo) {
List<String> safeMissingInfo = boundedMissingInfo(missingInfo);
if (safeMissingInfo.isEmpty()) {
@@ -112,6 +136,10 @@ public final class SafeFallbackFactory {
return Objects.requireNonNull(snapshot, "snapshot must not be null").verifiedSources();
}
/**
* 从验证快照提取去重后的可观察事实:key = 来源类型+来源+范围+摘要,
* 最多 12 条、摘要 320 字符;空摘要降级为受限审计说明(不泄露 raw)。
*/
private List<SafeFallback.ObservedFact> facts(VerifiedEvidenceSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot must not be null");
Map<String, SafeFallback.ObservedFact> unique = new LinkedHashMap<>();
@@ -133,6 +161,7 @@ public final class SafeFallbackFactory {
return List.copyOf(unique.values());
}
/** 把违规明细映射为对外可用的 ValidationIssue 列表(code + target)。 */
private List<SafeFallback.ValidationIssue> issues(List<EvidenceViolation> violations) {
List<SafeFallback.ValidationIssue> result = new ArrayList<>();
for (EvidenceViolation violation : violations == null ? List.<EvidenceViolation>of() : violations) {
@@ -9,6 +9,20 @@ import com.superbiz.agent.harness.core.RunState;
import java.util.Objects;
import java.util.function.Consumer;
/**
* 统一重试执行器:把「失败分类 + 策略裁决 + attempt 记录」集中到一个循环里。
*
* <p>为什么重试必须在这里,而不是 SDK 内部:
* <ul>
* <li>SDK 隐式重试(Spring AI 默认 maxAttempts=10)已通过
* {@code spring.ai.retry.max-attempts: 1} 关闭,重试所有权上移到 Harness;</li>
* <li>每次 attempt 前都 {@code checkActive}——Run 已终止(取消/预算/超时)时立即停止,
* 不会在 Run 死后继续烧预算;</li>
* <li>{@code RunAbortedException} / {@code BudgetExceededException} 永不重试,直接透出;
* 其他异常先由 {@code classifier} 分类,再由 {@code policy} 裁决是否再试;</li>
* <li>每个 attempt 都经 {@code recorder} 记录,Trace 可回放「试了几次、为什么停」。</li>
* </ul>
*/
public final class HarnessRetryExecutor {
private final DiagnosisHarnessCore core;
@@ -17,6 +31,15 @@ public final class HarnessRetryExecutor {
this.core = Objects.requireNonNull(core, "core must not be null");
}
/**
* 执行带重试的操作。
*
* @param context RunContext(每次 attempt 前检查 active 用)
* @param policy 重试策略:maxAttempts + 可重试失败类型
* @param operation 一次操作,通常是「模型调用 + 严格解析」
* @param classifier 异常 → RetryFailure 分类器
* @param recorder 每次 attempt 的收据(写 Trace / 账本)
*/
public <T> T execute(RunContext context,
RetryPolicy policy,
RetryOperation<T> operation,
@@ -29,32 +52,39 @@ public final class HarnessRetryExecutor {
Objects.requireNonNull(recorder, "recorder must not be null");
for (int attempt = 1; attempt <= policy.maxAttempts(); attempt++) {
// 每个 attempt 前先确认 Run 仍可执行;Run 已死则这里直接抛 RunAbortedException
core.checkActive(context);
try {
T result = operation.execute();
recorder.accept(RetryAttempt.succeeded(attempt));
return result;
} catch (RunAbortedException exception) {
// Run 已终止:从终态快照区分预算耗尽还是取消,立即透出,绝不重试
RetryFailure failure = exception.termination().state() == RunState.BUDGET_EXHAUSTED
? RetryFailure.BUDGET_EXHAUSTED
: RetryFailure.CANCELLED;
recorder.accept(RetryAttempt.failed(attempt, failure));
throw new RetryExecutionException(attempt, failure, exception);
} catch (BudgetExceededException exception) {
// 预算超限:重试只会再烧预算,立即透出,绝不重试
recorder.accept(RetryAttempt.failed(attempt, RetryFailure.BUDGET_EXHAUSTED));
throw new RetryExecutionException(
attempt, RetryFailure.BUDGET_EXHAUSTED, exception);
} catch (Exception exception) {
// 其他异常:先分类(null → UNKNOWN),再由策略裁决是否允许下一轮 attempt
RetryFailure failure = classifier.classify(exception);
if (failure == null) {
failure = RetryFailure.UNKNOWN;
}
recorder.accept(RetryAttempt.failed(attempt, failure));
if (!policy.allowsRetry(attempt, failure)) {
// 裁决失败:要么次数用尽,要么失败类型不可重试(如业务拒绝/无证据)
throw new RetryExecutionException(attempt, failure, exception);
}
// 允许 → 继续下一轮循环
}
}
// 理论上不可达:policy.maxAttempts >= 1,且循环内要么 return 要么 throw
throw new IllegalStateException("retry loop exited without a result");
}
}
@@ -3,6 +3,16 @@ package com.superbiz.agent.harness.retry;
import java.util.Objects;
import java.util.Set;
/**
* 每个组件独立的重试策略集合(不可变)。
*
* <p>五个组件各有自己的 {@code maxAttempts + retryableFailures},
* 因为「是否允许重试」取决于调用者知道的信息:
* <ul>
* <li>Agent / 业务 Tool 可能有副作用或多轮上下文,不重试;</li>
* <li>Router / SemanticGuard 是单轮无副作用的技术判定,允许一次技术重试。</li>
* </ul>
*/
public record HarnessRetryPolicies(
RetryPolicy intentRouter,
RetryPolicy diagnosisAgent,
@@ -18,6 +28,17 @@ public record HarnessRetryPolicies(
Objects.requireNonNull(evidenceRepair, "evidenceRepair must not be null");
}
/**
* 严格默认策略:
*
* <pre>
* intentRouter : 2 次(超时 / 传输 / 非法输出可重试)——路由判据单轮无副作用
* semanticGuard : 2 次(超时 / 传输 / 解析 / schema 可重试)——语义审查单轮无副作用
* diagnosisAgent : 1 次——多轮 ReAct,失败会破坏循环上下文,不重试
* toolCall : 1 次——业务 Tool 可能有副作用,重试会重复副作用
* evidenceRepair : 1 次——只修引用,失败直接走 Fallback,不重试
* </pre>
*/
public static HarnessRetryPolicies strict() {
RetryPolicy oneAttempt = new RetryPolicy(1, Set.of());
return new HarnessRetryPolicies(
@@ -1,5 +1,18 @@
package com.superbiz.agent.harness.retry;
/**
* 单次重试 attempt 的不可变收据(记录):第几次、成败、失败类型。
*
* <p>由 {@link HarnessRetryExecutor} 在每次尝试后产生,经调用方的 recorder
* ({@code Consumer<RetryAttempt>})写入 Trace(routingAttempt / semanticAttempt /
* evidenceRepairAttempt 等事件),让「试了几次、每次什么失败」完全可回放。
*
* <p>与 {@link RetryExecutionException} 互补:RetryAttempt 是每一步的脚印(过程),
* RetryExecutionException 是最终定格(attempts 总数 + 最后失败类型)。
*
* <p>构造校验保证记录必然自洽:成功不能带失败类型、失败必须带失败类型,
* 避免把自相矛盾的脏记录写进 Trace。
*/
public record RetryAttempt(int attemptNumber, boolean success, RetryFailure failure) {
public RetryAttempt {
@@ -14,10 +27,12 @@ public record RetryAttempt(int attemptNumber, boolean success, RetryFailure fail
}
}
/** 成功收据:failure 固定为 null(构造校验保证)。 */
public static RetryAttempt succeeded(int attemptNumber) {
return new RetryAttempt(attemptNumber, true, null);
}
/** 失败收据:必须携带失败类型,供 Trace 和策略裁决参考。 */
public static RetryAttempt failed(int attemptNumber, RetryFailure failure) {
return new RetryAttempt(attemptNumber, false, failure);
}

Some files were not shown because too many files have changed in this diff Show More