Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83193bdf4a | ||
|
|
de56551fea | ||
|
|
da18fdf4e1 | ||
|
|
e1b8d1fb2c | ||
|
|
074d1aa5a9 | ||
|
|
f26d395650 | ||
|
|
ff0752a16c | ||
|
|
7844bcea40 | ||
|
|
e564863c43 | ||
|
|
d084202166 | ||
|
|
5b2fb985d9 | ||
|
|
b39a625e5b | ||
|
|
e9f1c48d34 | ||
|
|
3a7eee8af4 | ||
|
|
584639fa2a | ||
|
|
bdac35567c |
@@ -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
@@ -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/` |
|
||||
|
||||
@@ -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
@@ -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 总览(若存在) |
|
||||
|
||||
@@ -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`)使用独立存储和独立接口。
|
||||
|
||||
@@ -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)
|
||||
@@ -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 主线 |
|
||||
@@ -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 节进入专题即可,不需要重新从组件清单开始阅读。
|
||||
@@ -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 同规划」** 为基线
|
||||
|
||||
---
|
||||
+2
-2
@@ -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` |
|
||||
+1
-1
@@ -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` | 主规格 |
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+54
@@ -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
Reference in New Issue
Block a user