Compare commits

..
Author SHA1 Message Date
zhuyongxin 473bb5d004 docs(mvp): update RAG docs for py-rag extraction
- rewrite RAG architecture doc for py-rag service contract mapping, ingest and rebuild ops
- refresh observability/trace doc for post-L0 single-attempt semantics
- add py-rag chain exploration note; mark superseded Milvus/L0 notes with status banners
- close ISS-017 (superseded by L0 sinking); mark knowledge_domain table orphaned
- refresh architecture/mvp/engineering indexes
2026-09-30 17:03:50 +08:00
zhuyongxin 9cf162482d refactor(rag): extract retrieval and ingest to py-rag service
- replace in-process Milvus stack with PyRagClient + PyRagKnowledgeSearchAdapter behind KnowledgeSearchPort (RERANK score passthrough)
- move document ingest to py-rag /documents:ingest; DocumentManagementService keeps MySQL ledger + local files
- sink L0 query understanding to py-rag; drop KnowledgeQueryTransformer, single UNFILTERED_VECTOR attempt
- remove Milvus deps, config classes, dead demo services and obsolete rebuild scripts
- compose/Makefile reduced to MySQL/Redis; add pyrag.* config
2026-09-30 17:03:21 +08:00
zhuyongxin 83193bdf4a docs(harness): add overall architecture learning note
- Add architecture note: config assembly (three-layer organization),
  HTTP entry (thin controller + SSE state machine + disconnect cancel),
  session & memory system (PreviousTurn injection, terminology calibration,
  skill as procedural long-term memory), knowledge base write pipeline
2026-08-10 18:35:23 +08:00
zhuyongxin de56551fea docs(harness): add LLM judge design note (interview Q&A) 2026-08-10 11:34:37 +08:00
zhuyongxin da18fdf4e1 docs(harness): add agent domain learning note and mark all nine domains complete
- Add agent note: factory assembly, dual interceptors (model budget/token audit,
  tool five gates), use case loop shell, controlled stop recovery, dual-view projection
- Mark agent domain complete in roadmap (all nine domains done)
2026-08-10 10:12:04 +08:00
aruo e1b8d1fb2c docs(harness): add evidence-chain, application-audit, contract-state and interview review notes; annotate guard/release core classes 2026-08-08 00:12:25 +08:00
zhuyongxin 074d1aa5a9 docs(harness): add MySQL sandbox learning note and mark tool domain complete
- Add MySQL sandbox note: three defense layers (semantic/connection/output),
  fail-closed validation, allowlist, parameterization, cancellation, redaction
- Mark tool domain complete in roadmap; next: guard
2026-08-06 17:46:53 +08:00
zhuyongxin f26d395650 docs(harness): annotate RAG backend core classes and add retrieval learning note
- Annotate LookupKnowledgeTool, KnowledgeEvidencePostProcessor, RrfFusion, KnowledgeDocumentRetriever
- Add RAG retrieval learning note: L0 navigation, multi-recall + RRF, qualityScore,
  degradation, contract semantics, validation (audit + offline eval), discussion insights
2026-08-05 18:37:50 +08:00
zhuyongxin ff0752a16c docs(harness): annotate tool domain classes and add tool chain learning notes
- Annotate 43 tool domain classes (contract/projection/boundary/store/adapter/mysql)
- Add tool registration and execution chain learning note
- Add tool call chain runtime journey note (model decision to observation)
2026-08-04 18:37:17 +08:00
zhuyongxin 7844bcea40 docs(harness): annotate progress core classes and add code-level learning notes
- Annotate DiagnosisProgressTracker, HarnessToolInterceptor, DiagnosisProgressProjector,
  DiagnosisReleaseUseCase, ToolBoundary, ToolBoundaryResult, CanonicalToolInvocation
- Add progress code learning note: interceptor gates, tracker state machine,
  canonical lifecycle, execution gate, projection/release pipeline
- Update learning roadmap: progress marked as deeply learned, next is tool domain
2026-08-03 18:37:23 +08:00
wdm1802 e564863c43 docs(harness): add retry guide and learning roadmap; annotate retry core 2026-08-03 01:29:36 +08:00
wdm1802 d084202166 docs(harness): add budget flow sequence and execution control notes 2026-08-02 20:58:13 +08:00
zhuyongxin 5b2fb985d9 docs(harness): annotate entry orchestration, enums, and exception recovery paths 2026-07-30 19:08:03 +08:00
zhuyongxin b39a625e5b docs(mvp): add remaining harness audit guides and engineering index 2026-07-30 19:04:59 +08:00
zhuyongxin e9f1c48d34 docs(harness): add loop inner/outer exception handling guide 2026-07-30 18:55:43 +08:00
206 changed files with 10169 additions and 8628 deletions
+16 -54
View File
@@ -5,9 +5,9 @@
SERVER_URL = http://localhost:9900 SERVER_URL = http://localhost:9900
UPLOAD_API = $(SERVER_URL)/api/upload UPLOAD_API = $(SERVER_URL)/api/upload
DOCS_DIR = aiops-docs DOCS_DIR = aiops-docs
HEALTH_CHECK_API = $(SERVER_URL)/milvus/health # 服务就绪探测:9900 端口有 HTTP 响应即视为就绪
DOCKER_COMPOSE_FILE = vector-database.yml HEALTH_CHECK = curl -s -o /dev/null --connect-timeout 2 $(SERVER_URL)
MILVUS_CONTAINER = milvus-standalone DOCKER_COMPOSE_FILE = docker-compose.yml
# 颜色输出 # 颜色输出
GREEN = \033[0;32m GREEN = \033[0;32m
@@ -23,7 +23,7 @@ help:
@echo "" @echo ""
@echo "可用命令:" @echo "可用命令:"
@echo " $(YELLOW)make init$(NC) - 🚀 一键初始化(启动Docker → 启动服务 → 上传文档)" @echo " $(YELLOW)make init$(NC) - 🚀 一键初始化(启动Docker → 启动服务 → 上传文档)"
@echo " $(YELLOW)make up$(NC) - 启动 Docker Compose(Milvus 向量数据库)" @echo " $(YELLOW)make up$(NC) - 启动 Docker Compose(MySQL/Redis)"
@echo " $(YELLOW)make down$(NC) - 停止 Docker Compose" @echo " $(YELLOW)make down$(NC) - 停止 Docker Compose"
@echo " $(YELLOW)make status$(NC) - 查看 Docker 容器状态" @echo " $(YELLOW)make status$(NC) - 查看 Docker 容器状态"
@echo " $(YELLOW)make start$(NC) - 启动 Spring Boot 服务(后台运行)" @echo " $(YELLOW)make start$(NC) - 启动 Spring Boot 服务(后台运行)"
@@ -42,7 +42,7 @@ help:
init: init:
@echo "$(GREEN)🚀 开始一键初始化 SuperBizAgent...$(NC)" @echo "$(GREEN)🚀 开始一键初始化 SuperBizAgent...$(NC)"
@echo "" @echo ""
@echo "$(YELLOW)步骤 1/4: 启动 Docker Compose(Milvus 向量数据库)$(NC)" @echo "$(YELLOW)步骤 1/4: 启动 Docker Compose(MySQL/Redis)$(NC)"
@$(MAKE) up @$(MAKE) up
@echo "" @echo ""
@echo "$(YELLOW)步骤 2/4: 启动 Spring Boot 服务$(NC)" @echo "$(YELLOW)步骤 2/4: 启动 Spring Boot 服务$(NC)"
@@ -51,23 +51,23 @@ init:
@echo "$(YELLOW)步骤 3/4: 等待服务就绪$(NC)" @echo "$(YELLOW)步骤 3/4: 等待服务就绪$(NC)"
@$(MAKE) wait @$(MAKE) wait
@echo "" @echo ""
@echo "$(YELLOW)步骤 4/4: 上传 AIOps 文档到向量数据库$(NC)" @echo "$(YELLOW)步骤 4/4: 上传 AIOps 文档(经 py-rag 入库)$(NC)"
@$(MAKE) upload @$(MAKE) upload
@echo "" @echo ""
@echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)" @echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)"
@echo "$(GREEN)✅ 初始化完成!所有文档已成功向量化存储到数据库$(NC)" @echo "$(GREEN)✅ 初始化完成!所有文档已成功入库(py-rag)$(NC)"
@echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)" @echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)"
@echo "" @echo ""
@echo "$(GREEN)🌐 服务访问地址:$(NC)" @echo "$(GREEN)🌐 服务访问地址:$(NC)"
@echo " API 服务: $(SERVER_URL)" @echo " API 服务: $(SERVER_URL)"
@echo " Attu (Web UI): http://localhost:8000" @echo "$(YELLOW)💡 提示: 知识检索/入库由 py-rag 服务承担,请在其仓库单独启动$(NC)"
@echo "" @echo ""
@echo "$(YELLOW)💡 提示: 服务正在后台运行,查看日志: tail -f server.log$(NC)" @echo "$(YELLOW)💡 提示: 服务正在后台运行,查看日志: tail -f server.log$(NC)"
# 启动 Spring Boot 服务(后台运行) # 启动 Spring Boot 服务(后台运行)
start: start:
@echo "$(YELLOW)🚀 启动 Spring Boot 服务...$(NC)" @echo "$(YELLOW)🚀 启动 Spring Boot 服务...$(NC)"
@if curl -s -f $(HEALTH_CHECK_API) > /dev/null 2>&1; then \ @if curl -s -o /dev/null --connect-timeout 2 $(SERVER_URL); then \
echo "$(GREEN)✅ 服务已经在运行中 ($(SERVER_URL))$(NC)"; \ echo "$(GREEN)✅ 服务已经在运行中 ($(SERVER_URL))$(NC)"; \
else \ else \
echo "$(YELLOW)📦 正在启动服务(后台运行)...$(NC)"; \ echo "$(YELLOW)📦 正在启动服务(后台运行)...$(NC)"; \
@@ -84,7 +84,7 @@ wait:
@max_attempts=60; \ @max_attempts=60; \
attempt=0; \ attempt=0; \
while [ $$attempt -lt $$max_attempts ]; do \ while [ $$attempt -lt $$max_attempts ]; do \
if curl -s -f $(HEALTH_CHECK_API) > /dev/null 2>&1; then \ if curl -s -o /dev/null --connect-timeout 2 $(SERVER_URL); then \
echo "$(GREEN)✅ 服务器已就绪!($(SERVER_URL))$(NC)"; \ echo "$(GREEN)✅ 服务器已就绪!($(SERVER_URL))$(NC)"; \
exit 0; \ exit 0; \
fi; \ fi; \
@@ -100,7 +100,7 @@ wait:
# 检查服务器是否运行 # 检查服务器是否运行
check: check:
@echo "$(YELLOW)🔍 检查服务器状态...$(NC)" @echo "$(YELLOW)🔍 检查服务器状态...$(NC)"
@if curl -s -f $(HEALTH_CHECK_API) > /dev/null 2>&1; then \ @if curl -s -o /dev/null --connect-timeout 2 $(SERVER_URL); then \
echo "$(GREEN)✅ 服务器运行正常 ($(SERVER_URL))$(NC)"; \ echo "$(GREEN)✅ 服务器运行正常 ($(SERVER_URL))$(NC)"; \
else \ else \
echo "$(RED)❌ 服务器未运行或无法连接!$(NC)"; \ echo "$(RED)❌ 服务器未运行或无法连接!$(NC)"; \
@@ -205,38 +205,14 @@ test-upload:
echo "$(RED)测试文件不存在$(NC)"; \ echo "$(RED)测试文件不存在$(NC)"; \
fi fi
# 启动 Docker Compose(智能检测,避免重复启动) # 启动 Docker Compose(MySQL/Redis;py-rag 服务在其仓库单独启动)
up: up:
@echo "$(YELLOW)🐳 检查 Docker 容器状态...$(NC)" @echo "$(YELLOW)🐳 启动 Docker Compose(MySQL/Redis)...$(NC)"
@if [ ! -f "$(DOCKER_COMPOSE_FILE)" ]; then \ @if [ ! -f "$(DOCKER_COMPOSE_FILE)" ]; then \
echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \ echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \
exit 1; \ exit 1; \
fi fi
@if docker ps --format '{{.Names}}' | grep -q "^$(MILVUS_CONTAINER)$$"; then \ @docker-compose -f $(DOCKER_COMPOSE_FILE) up -d && echo "$(GREEN)✅ Docker Compose 启动完成$(NC)"
echo "$(GREEN)✅ Milvus 容器已经在运行中$(NC)"; \
echo "$(YELLOW)📋 当前运行的容器:$(NC)"; \
docker ps --filter "name=milvus" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"; \
else \
echo "$(YELLOW)🚀 启动 Docker Compose...$(NC)"; \
docker-compose -f $(DOCKER_COMPOSE_FILE) up -d; \
echo ""; \
echo "$(YELLOW)⏳ 等待容器启动...$(NC)"; \
sleep 5; \
if docker ps --format '{{.Names}}' | grep -q "^$(MILVUS_CONTAINER)$$"; then \
echo "$(GREEN)✅ Docker Compose 启动成功!$(NC)"; \
echo ""; \
echo "$(GREEN)📋 运行中的容器:$(NC)"; \
docker ps --filter "name=milvus" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"; \
echo ""; \
echo "$(GREEN)🌐 服务访问地址:$(NC)"; \
echo " Milvus: localhost:19530"; \
echo " Attu (Web UI): http://localhost:8000"; \
echo " MinIO: http://localhost:9001 (admin/minioadmin)"; \
else \
echo "$(RED)❌ 容器启动失败,请检查日志: docker-compose -f $(DOCKER_COMPOSE_FILE) logs$(NC)"; \
exit 1; \
fi; \
fi
# 停止 Docker Compose # 停止 Docker Compose
down: down:
@@ -245,24 +221,10 @@ down:
echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \ echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \
exit 1; \ exit 1; \
fi fi
@if docker ps --format '{{.Names}}' | grep -q "milvus"; then \ @docker-compose -f $(DOCKER_COMPOSE_FILE) down && echo "$(GREEN)✅ Docker Compose 已停止$(NC)"
docker-compose -f $(DOCKER_COMPOSE_FILE) down; \
echo "$(GREEN)✅ Docker Compose 已停止$(NC)"; \
else \
echo "$(YELLOW)⚠️ 没有运行中的 Milvus 容器$(NC)"; \
fi
# 查看 Docker 容器状态 # 查看 Docker 容器状态
status: status:
@echo "$(YELLOW)📊 Docker 容器状态:$(NC)" @echo "$(YELLOW)📊 Docker 容器状态:$(NC)"
@echo "" @echo ""
@if docker ps -a --format '{{.Names}}' | grep -q "milvus"; then \ @docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker ps -a --filter "name=milvus" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"; \
echo ""; \
running=$$(docker ps --filter "name=milvus" --format '{{.Names}}' | wc -l | tr -d ' '); \
total=$$(docker ps -a --filter "name=milvus" --format '{{.Names}}' | wc -l | tr -d ' '); \
echo "$(GREEN)运行中: $$running / $$total$(NC)"; \
else \
echo "$(YELLOW)⚠️ 没有找到 Milvus 相关容器$(NC)"; \
echo "$(YELLOW)提示: 运行 'make docker-up' 启动容器$(NC)"; \
fi
+2 -58
View File
@@ -40,68 +40,12 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# Milvus 向量数据库(Standalone 模式) # 向量检索与知识入库由独立的 py-rag 服务承担(见 py-rag 仓库),
# 注意:生产环境建议使用 Zilliz Cloud 或 Milvus 集群 # 其依赖的 Milvus/etcd/MinIO 随 py-rag 部署,不再由本 compose 管理。
etcd:
image: quay.io/coreos/etcd:v3.5.5
container_name: superbiz-etcd
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- etcd-data:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
container_name: superbiz-minio
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- minio-data:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
milvus:
image: milvusdb/milvus:v2.3.3
container_name: superbiz-milvus
depends_on:
- etcd
- minio
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- milvus-data:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
command: ["milvus", "run", "standalone"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
volumes: volumes:
mysql-data: mysql-data:
redis-data: redis-data:
etcd-data:
minio-data:
milvus-data:
networks: networks:
default: default:
+1 -1
View File
@@ -14,7 +14,7 @@
| [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 | | [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 |
| [architecture/harness-quality-gates.md](architecture/harness-quality-gates.md) | Harness 与质量门禁 | | [architecture/harness-quality-gates.md](architecture/harness-quality-gates.md) | Harness 与质量门禁 |
| [architecture/session-trace-lifecycle.md](architecture/session-trace-lifecycle.md) | 会话与 Trace 生命周期 | | [architecture/session-trace-lifecycle.md](architecture/session-trace-lifecycle.md) | 会话与 Trace 生命周期 |
| [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前 hybrid 检索架构 | | [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前检索架构(py-rag 知识服务接入) |
| [issues/README.md](issues/README.md) | MVP issue 索引 | | [issues/README.md](issues/README.md) | MVP issue 索引 |
| [tables/README.md](tables/README.md) | 当前 MySQL 表说明 | | [tables/README.md](tables/README.md) | 当前 MySQL 表说明 |
| [demo/README.md](demo/README.md) | Demo 运行和演示材料 | | [demo/README.md](demo/README.md) | Demo 运行和演示材料 |
@@ -1,9 +1,15 @@
# RAG 检索可观测性、审计与 Trace(现行) # RAG 检索可观测性、审计与 Trace(现行)
**更新日期**:2026-07-28 **更新日期**:2026-09-29
**状态**:当前可运行 **状态**:当前可运行
**关联**:`lookup_knowledge`、Harness `ToolBoundary`、`tool_invocation`、`DiagnosisTraceService`、离线 eval **关联**:`lookup_knowledge`、Harness `ToolBoundary`、`tool_invocation`、`DiagnosisTraceService`、离线 eval
> **2026-09-29 RAG 抽离影响**:检索后端切换为 py-rag 服务(见 [RAG知识检索架构.md](./RAG知识检索架构.md))。
> Trace / 审计的三层边界与读写接口**不变**;变化仅在内容语义:
> L0 已下沉(`queryHints` 恒为空结构、`categoryFilter` 恒为 null、attempt 只剩 `UNFILTERED_VECTOR`),
> 质量分统一为 py-rag rerank 绝对分(scoreLabel=RERANK)。
> 文中涉及 FILTERED/RETRY attempt 的示例为历史数据读法,保留供回放旧 Run。
--- ---
## 1. 三层边界 ## 1. 三层边界
@@ -119,21 +125,21 @@ flowchart TB
| 字段 | 含义 | | 字段 | 含义 |
|------|------| |------|------|
| `originalQuery` | 原始查询 | | `originalQuery` | 原始查询 |
| `rewrittenQuery` | L0/变换后用于检索的 query | | `rewrittenQuery` | 用于检索的 query(L0 下沉后恒等于 originalQuery) |
| `categoryFilter` | 首次过滤的 category(可 null) | | `categoryFilter` | 首次过滤的 category(L0 下沉后恒为 null) |
| `selectedAttempt` | 最终采用的 attempt 名 | | `selectedAttempt` | 最终采用的 attempt 名 |
| `fallbackReason` | 如 `filtered_vector_low_quality`;未降级为 null | | `fallbackReason` | 如 `filtered_vector_low_quality`;未降级为 null |
| `evidenceStatus` | 内部:`supported` / `no_evidence` 等 | | `evidenceStatus` | 内部:`supported` / `no_evidence` 等 |
| `queryHints` | L0:domains、keywords、entities、l0_match_count… | | `queryHints` | L0 提示(下沉后恒为空 domains/keywords/entities 与 l0_match_count=0) |
| `attempts[]` | 每次检索尝试快照 | | `attempts[]` | 每次检索尝试快照 |
**常见 `selectedAttempt`:** **`selectedAttempt` 取值:**
| 值 | 含义 | | 值 | 含义 |
|----|------| |----|------|
| `FILTERED_VECTOR` | 带 category 的首次检索即采用 | | `UNFILTERED_VECTOR` | **当前唯一会出现**:无 category,直传 py-rag 检索 |
| `UNFILTERED_VECTOR` | 无 category,直接全库检索 | | `FILTERED_VECTOR` | (历史)带 category 的首次检索即采用;L0 下沉后不再产生 |
| `UNFILTERED_VECTOR_RETRY` | filtered 低质/无证据后去掉 category 重试 | | `UNFILTERED_VECTOR_RETRY` | (历史)filtered 低质/无证据后去掉 category 重试;分支保留但不可达,仅见于旧 Run 回放 |
**单次 `attempts[]` 元素:** **单次 `attempts[]` 元素:**
@@ -148,21 +154,18 @@ flowchart TB
| `durationMs` | 耗时 | | `durationMs` | 耗时 |
| `errorMessage` | 失败时 | | `errorMessage` | 失败时 |
### 3.3 一次典型路径(含 filter fallback) ### 3.3 一次典型路径(当前:单 attempt 直传)
```mermaid ```mermaid
flowchart TB flowchart TB
Q[query] --> L0[L0 hint → 可选 categoryFilter] Q[query 原始句直传] --> A1[attempt UNFILTERED_VECTOR<br/>PyRagKnowledgeSearchAdapter → py-rag]
L0 --> A1[attempt FILTERED_VECTOR] A1 --> POST[PostProcess · evidenceBlocks · relevanceLevel]
A1 --> PQ{isLowQuality?}
PQ -->|否| USE1[selectedAttempt = FILTERED_VECTOR]
PQ -->|是| A2[attempt UNFILTERED_VECTOR_RETRY]
A2 --> USE2[selectedAttempt = RETRY<br/>fallbackReason = low_quality / no_evidence]
USE1 --> POST[PostProcess · evidenceBlocks · relevanceLevel]
USE2 --> POST
POST --> LR[LookupResult 完整 Trace] POST --> LR[LookupResult 完整 Trace]
``` ```
> 历史 filter fallback 路径(L0 → FILTERED_VECTOR → 低质 → UNFILTERED_VECTOR_RETRY)的流程图已随 L0 下沉移除;
> 旧 Run 的 Trace 回放仍可见该结构,字段含义见 §3.2。
### 3.4 与 Agent 投影的关系 ### 3.4 与 Agent 投影的关系
```mermaid ```mermaid
@@ -208,35 +211,27 @@ flowchart LR
| `output_preview` | level/attempt 摘要 | status=… | | `output_preview` | level/attempt 摘要 | status=… |
| `duration_ms` / `success` | 有 | 有 | | `duration_ms` / `success` | 有 | 有 |
### 4.3 `retrieval_details`(rag_lookup_v1)示例 ### 4.3 `retrieval_details`(rag_lookup_v1)示例(当前形态)
```json ```json
{ {
"audit_schema": "rag_lookup_v1", "audit_schema": "rag_lookup_v1",
"search_mode": "hybrid", "search_mode": "hybrid",
"selected_attempt": "UNFILTERED_VECTOR_RETRY", "selected_attempt": "UNFILTERED_VECTOR",
"fallback_reason": "filtered_vector_low_quality", "fallback_reason": null,
"category_filter": "overfilter-decoy", "category_filter": null,
"evidence_keys": ["doc#chunk-0"], "evidence_keys": ["e2e-gateway-b9c1fa12-md-34223174#chunk-1"],
"sources": ["doc"], "sources": ["e2e-gateway-b9c1fa12-md"],
"evidence_candidate_count": 8, "evidence_candidate_count": 5,
"evidence_block_count": 2, "evidence_block_count": 2,
"l0_hints": { "domains": ["mysql"], "matched_keywords": ["pool"] }, "l0_hints": { "domains": [], "matched_keywords": [] },
"attempts": [ "attempts": [
{ {
"name": "FILTERED_VECTOR", "name": "UNFILTERED_VECTOR",
"category_filter": "overfilter-decoy",
"candidate_count": 2,
"usable": false,
"top_similarity": 0.3,
"duration_ms": 12
},
{
"name": "UNFILTERED_VECTOR_RETRY",
"candidate_count": 5, "candidate_count": 5,
"usable": true, "usable": true,
"top_similarity": 0.9, "top_similarity": 0.91,
"duration_ms": 20 "duration_ms": 640
} }
], ],
"truncated": false, "truncated": false,
@@ -248,6 +243,7 @@ flowchart LR
``` ```
**默认不落库:** 原始 query 全文、chunk 正文 excerpt、完整 rerankTrace(体积与隐私)。 **默认不落库:** 原始 query 全文、chunk 正文 excerpt、完整 rerankTrace(体积与隐私)。
历史 Run 中 `selected_attempt=FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` 与非空 `l0_hints` 为 L0 下沉前的旧数据形态。
### 4.4 Trace API:人怎么读 RAG ### 4.4 Trace API:人怎么读 RAG
@@ -304,12 +300,12 @@ flowchart TB
| 现象 | 优先看 | | 现象 | 优先看 |
|------|--------| |------|--------|
| 为何走了 retry | `fallback_reason` + 两次 `attempts` | | 是否 hybrid | `search_mode`(hybrid/semantic 对应 py-rag 融合/纯向量) |
| 是否 hybrid | `search_mode` | | 滤错域 | (历史)`category_filter` + L0 domains;L0 下沉后恒为 null |
| 滤错域 | `category_filter` + L0 domains |
| 相关度档 | 列 `relevanceLevel`(PRECISE/REFERENCE) | | 相关度档 | 列 `relevanceLevel`(PRECISE/REFERENCE) |
| 返回了哪些块 | `evidence_keys` / `sources`(无正文) | | 返回了哪些块 | `evidence_keys` / `sources`(无正文) |
| Agent 是否被截断 | `truncated` / `returned_count` | | Agent 是否被截断 | `truncated` / `returned_count` |
| 为何走了 retry | (历史)`fallback_reason` + 两次 `attempts`;L0 下沉后单 attempt,不再产生 |
### 4.6 diagnosis_trace 事件 vs tool_invocation 行 ### 4.6 diagnosis_trace 事件 vs tool_invocation 行
@@ -352,10 +348,11 @@ flowchart LR
| 旧(archive `retrieval-observability`) | 现 | | 旧(archive `retrieval-observability`) | 现 |
|----------------------------------------|-----| |----------------------------------------|-----|
| `vector-store.mode` 多后端 | `search_mode` dense\|hybrid,单一 V2 store | | `vector-store.mode` 多后端 | `search_mode` dense\|hybrid(现映射 py-rag semantic\|hybrid) |
| sink 理想化未落地 | `RagLookupAuditEnricher` + 列回填 | | sink 理想化未落地 | `RagLookupAuditEnricher` + 列回填 |
| `relevance_level` 混用 evidence_status | **列仅 RAG 等级**;契约状态在 details | | `relevance_level` 混用 evidence_status | **列仅 RAG 等级**;契约状态在 details |
| 未写清 Trace API 读法 | 本文 §4.4–4.5 | | 未写清 Trace API 读法 | 本文 §4.4–4.5 |
| L0 hint / FILTERED-RETRY attempt(2026-07-28 形态) | 2026-09-29 L0 下沉 py-rag:单 attempt、queryHints 恒空、质量分 RERANK 直传 |
--- ---
+131 -271
View File
@@ -1,13 +1,16 @@
# RAG 知识检索架构 # RAG 知识检索架构
**更新日期**:2026-07-28 **更新日期**:2026-09-29
**状态**:当前可运行架构 **状态**:当前可运行架构
**关联实现**:`lookup_knowledge`、`MilvusHybridKnowledgeStore`、`KnowledgeSearchPort` **关联实现**:`lookup_knowledge`、`KnowledgeSearchPort`、`PyRagKnowledgeSearchAdapter`、`PyRagClient`
**关联运维**:`scripts/rebuild_hybrid_knowledge.py`、`POST /api/knowledge/rebuild-hybrid` **关联契约**:py-rag 仓库 `docs/Java接入文档.md`(API v1,冻结面)
**关联运维**:py-rag `/api/v1/collections:rebuild`(全量重建)、py-rag `/api/v1/documents:ingest`(单文档入库)
## 1. 定位 ## 1. 定位
知识检索是 Diagnosis Agent 的显式证据工具,不是隐式 Advisor。 知识检索是 Diagnosis Agent 的显式证据工具,不是隐式 Advisor。
检索算法(dense+BM25 融合、rerank、判级)与文档入库(解析、frontmatter、分块、向量化)
**全部由独立的 py-rag 知识服务承担**;Java 侧只保留 harness 消费面与 HTTP 客户端。
```text ```text
Diagnosis Agent Diagnosis Agent
@@ -19,7 +22,7 @@ Diagnosis Agent
目标: 目标:
- 保留 Agent 可见的工具调用与证据边界 - 保留 Agent 可见的工具调用与证据边界
- 用单一向量后端完成 dense + BM25 hybrid 检索 - 检索/入库基础设施外置为独立服务,Java 侧不感知引擎细节
- 用 chunk 级证据身份保证同文档多片段可同时进入上下文 - 用 chunk 级证据身份保证同文档多片段可同时进入上下文
- 检索行为可配置、可重建、可审计 - 检索行为可配置、可重建、可审计
@@ -41,336 +44,193 @@ flowchart LR
subgraph RetrievalBoundary["Retrieval boundary"] subgraph RetrievalBoundary["Retrieval boundary"]
Backend["LookupKnowledgeTool"] Backend["LookupKnowledgeTool"]
Port["KnowledgeSearchPort"] Port["KnowledgeSearchPort"]
Store["MilvusHybridKnowledgeStore"] Remote["PyRagKnowledgeSearchAdapter"]
Service["py-rag 知识服务 (HTTP /api/v1)"]
end end
Agent --> Tool Agent --> Tool
Tool --> Adapter Tool --> Adapter
Adapter --> Backend Adapter --> Backend
Backend --> Port Backend --> Port
Port --> Store Port --> Remote
Remote -->|HTTP| Service
Adapter --> Projector Adapter --> Projector
Adapter --> Canonical Adapter --> Canonical
``` ```
| 边界 | 职责 | 不负责 | | 边界 | 职责 | 不负责 |
|---|---|---| |---|---|---|
| Agent | 决定何时检索、如何用证据写报告 | 不直接访问 Milvus / MySQL 元数据表 | | Agent | 决定何时检索、如何用证据写报告 | 不直接访问 py-rag / MySQL 元数据表 |
| Harness | Tool 校验、投影裁剪、canonical 存证 | 不改写检索排序算法 | | Harness | Tool 校验、投影裁剪、canonical 存证 | 不改写检索排序算法 |
| Retrieval | L0 hint、dense/BM25 召回、后处理、打包 | 不绕过 ACI 直接给 Agent 原始库响应 | | Retrieval | 请求映射、后处理、打包;py-rag 承担召回/融合/rerank/判级 | 不绕过 ACI 直接给 Agent 原始库响应 |
## 3. 当前主链路 ## 3. 当前主链路
```mermaid ```mermaid
flowchart TD flowchart TD
A["lookup_knowledge(query)"] --> B["KnowledgeQueryTransformer"] A["lookup_knowledge(query)"] --> B["原始 query 直传(L0 已下沉 py-rag)"]
B --> C["L0 hint: domain / keywords / categoryFilter"] B --> C["KnowledgeDocumentRetriever"]
C --> D["KnowledgeDocumentRetriever"] C --> D["KnowledgeSearchPort"]
D --> E["KnowledgeSearchPort"] D --> E["PyRagKnowledgeSearchAdapter"]
E --> F["VectorSearchService"] E -->|POST /api/v1/search| F["py-rag: dense+BM25 融合 / rerank / 判级"]
F --> G{"retrieval.search.mode"} F --> G["hits + evidenceKey(docId#chunk-N)"]
G -->|dense| H["MilvusHybridKnowledgeStore.searchDense"] G --> H["KnowledgeEvidencePostProcessor"]
G -->|hybrid| I["MilvusHybridKnowledgeStore.searchHybrid"] H --> I["evidenceKey dedup / maxChunksPerDocument / return-n"]
H --> J["candidates + chunk identity"] I --> J["KnowledgeContextPacker"]
I --> J J --> K["LookupResultAssembler"]
J --> K["KnowledgeEvidencePostProcessor"] K --> L["RagResultProjector"]
K --> L["evidenceKey dedup / maxChunksPerDocument / return-n"] L --> M["Agent-facing RagToolResult"]
L --> M{"filtered low quality?"}
M -->|yes and had categoryFilter| N["unfiltered retry"]
N --> K
M -->|no| O["KnowledgeContextPacker"]
O --> P["LookupResultAssembler"]
P --> Q["RagResultProjector"]
Q --> R["Agent-facing RagToolResult"]
``` ```
对应代码: 对应代码:
| 阶段 | 类 | 职责 | | 阶段 | 类 | 职责 |
|---|---|---| |---|---|---|
| Tool 编排 | `LookupKnowledgeTool` | 串联 transform / retrieve / post / pack | | Tool 编排 | `LookupKnowledgeTool` | 串联 retrieve / post / pack;UNFILTERED 单 attempt 主路径 |
| Query 理解 | `KnowledgeQueryTransformer` + `KnowledgeIndexService` | L0 只产 hint 与可选 category filter | | 检索端口 | `KnowledgeSearchPort` / `PyRagKnowledgeSearchAdapter` | 防腐层;请求映射 + 命中归一化 |
| 检索端口 | `KnowledgeSearchPort` / `VectorKnowledgeSearchAdapter` | 屏蔽底层存储细节 | | HTTP 客户端 | `PyRagClient` | API v1 调用、错误信封(`E_*`)、`X-Request-ID`、分端点超时 |
| 检索门面 | `VectorSearchService` | `dense` 或 `hybrid` 路由 | | 后处理 | `KnowledgeEvidencePostProcessor` | qualityScore(RERANK 直传)、chunk 去重、相关度等级 |
| 向量后端 | `MilvusHybridKnowledgeStore` | 唯一知识库读写后端(MilvusClientV2) |
| 后处理 | `KnowledgeEvidencePostProcessor` | 归一化、规则 boost、chunk 去重、相关度等级 |
| 打包 | `KnowledgeContextPacker` | 有界 context pack | | 打包 | `KnowledgeContextPacker` | 有界 context pack |
| 投影 | `RagResultProjector` | 只暴露 Agent 可见 evidence 字段 | | 投影 | `RagResultProjector` | 只暴露 Agent 可见 evidence 字段 |
## 4. 唯一向量后端:MilvusClientV2 2026-09-29 抽离时删除的 Java 侧组件:`MilvusHybridKnowledgeStore`、`VectorSearchService`、
`VectorKnowledgeSearchAdapter`、`RrfFusion` / `LexicalRanker`(融合评分下沉)、
`KnowledgeIndexService` / `KnowledgeDomainService`(L0 索引)、
`DocumentChunkService` / `FrontmatterParser` / `TextExtractorService`(入库解析)、
`KnowledgeQueryTransformer`(L0 query 理解)。
### 4.1 已废弃路径 ## 4. py-rag 检索契约映射
以下路径**不再**用于 `lookup_knowledge`: 请求映射(`PyRagKnowledgeSearchAdapter`):
- legacy `MilvusServiceClient` search / insert | Java(KnowledgeSearchRequest) | py-rag(/api/v1/search) | 说明 |
- `retrieval.vector-store.mode=sdk|spring|auto` |---|---|---|
- Spring AI `VectorStore` 作为知识检索主路径 | `query` | `query` | 原始检索句直传,服务端自行处理边界与精排 |
| `mode=DENSE` | `mode=semantic` | 纯向量,对照/排障用 |
| `mode=HYBRID` | `mode=hybrid` | dense+BM25 融合,线上主路径(`retrieval.search.mode: hybrid`) |
| `topK` | `retrieve_k` / `return_n` / `max_chunks_per_document` | 三者同置 topK:chunk 去重截断由 Java 后处理器统一负责,避免服务端预截断 |
| `categoryFilter` | `category` | L0 下沉后恒为 null(不过滤) |
| — | `kb_scope` | 不传,由 py-rag 部署配置决定 |
### 4.2 当前后端 响应映射:
| py-rag | Java(KnowledgeSearchHit) | 说明 |
|---|---|---|
| `evidence_key` | `evidenceKey` / `docId` / `chunkIndex` | `docId#chunk-N`,与 EvidenceGuard 验真约定一致 |
| `excerpt` | `content` | 进入 context pack 的正文 |
| `quality_score` | `score` / `rawScore` | rerank 绝对相关分 [0,1],越大越好 |
| — | `scoreLabel=RERANK` | `RetrievalScoreNormalizer` 对 RERANK 分支 quality 原样 clamp,不走 L2/rank 归一化 |
| `relevance_level` | (参考值) | Java 后处理按同阈值(0.75/0.5)独立判级,语义一致 |
| `evidence_status=no_evidence` | 空列表 | 正常业务响应(服务端保证 hits=[]),Agent 侧按"无知识可用"处理 |
超时与重试矩阵见 py-rag 仓库 `docs/Java接入文档.md` 第 6 节;Java 侧由 `pyrag.*` 配置承载。
## 5. 入库与重建
### 5.1 日常写入
```text ```text
写入: 业务上传(DocumentController /api/documents/upload)
VectorIndexService -> MySQL api_document(业务元数据:faultSource 等)+ 本地原件保存
-> MilvusHybridKnowledgeStore.upsertChunk -> PyRagClient.ingest(multipart 透传原件 + category)
-> py-rag:解析 / frontmatter 校验 / 分块 / 向量化 / 索引
读取: 简单上传(FileUploadController /api/upload)
VectorSearchService -> 本地保存 + PyRagClient.ingest(category 可选参数,缺省 default;入库失败不影响上传成功语义)
-> MilvusHybridKnowledgeStore.searchDense
-> MilvusHybridKnowledgeStore.searchHybrid
``` ```
默认 collection: MySQL `api_document.docId` 取 py-rag 返回的 `doc_id`(路径 slug + 内容 SHA-256 前 8 位),
与检索 `evidence_key` 的 docId 段对齐;`chunk_count` 取 ingest 响应。
### 5.2 全量重建
```text
POST /api/v1/collections:rebuild?confirm=REBUILD (py-rag 服务端)
GET /api/v1/tasks/{task_id} (任务状态)
```
- rebuild 为 py-rag 异步任务;执行期间 ingest 返回 409(`E_REBUILD_IN_PROGRESS`),search 不受影响
- 原料为 py-rag 服务端 `data/knowledge_base/` 下历次 ingest 落盘的 md
- 旧 Java 侧 `POST /api/knowledge/rebuild-hybrid` 与 `scripts/rebuild_hybrid_knowledge.py` 已删除
### 5.3 单文档删除
py-rag API v1 没有单文档删除端点。`DELETE /api/documents/{docId}` 只删 MySQL 元数据与本地原件;
py-rag 侧已入库内容需全量重建后才会消失(见 `DocumentManagementService.deleteDocument` 注释)。
## 6. 部署与配置
```yaml ```yaml
milvus: pyrag:
collection: biz base-url: ${PYRAG_BASE_URL:http://localhost:8000}
connect-timeout-ms: 3000
search-read-timeout-ms: 5000 # 正常 300–800ms(含 rerank 外呼)
ingest-read-timeout-ms: 30000
default-read-timeout-ms: 10000
``` ```
重建时会 drop + recreate 该 collection,并按 dense + BM25 schema 重建。 - Milvus / etcd / MinIO 随 py-rag 部署,不再由本仓库 `docker-compose.yml` 管理(compose 仅剩 MySQL/Redis)
- `vector-database.yml` 已删除;Makefile 的 up/down/status 只管 MySQL/Redis
- `retrieval.search.mode: hybrid` 语义保留:映射 py-rag 的 `hybrid` / `semantic`
## 5. Collection Schema ## 7. 证据身份与去重(不变)
`biz`(可配置)逻辑字段:
| 字段 | 类型 | 用途 |
|---|---|---|
| `id` | VarChar PK | chunk 级主键 |
| `content` | VarChar | 返回给 Agent 的原文片段 |
| `search_text` | VarChar + analyzer | BM25 输入文本 |
| `sparse_vector` | SparseFloatVector | BM25 Function 输出 |
| `vector` | FloatVector | dense embedding |
| `metadata` | JSON | docId / chunkIndex / category / kb_scope / title / breadcrumb 等 |
Function:
```text ```text
BM25(search_text -> sparse_vector) evidenceKey = docId#chunk-{chunkIndex}
```
索引:
```text
vector -> IVF_FLAT + L2
sparse_vector -> SPARSE_INVERTED_INDEX + BM25
```
写入时:
- `content` 保存原始 chunk 正文
- `search_text` / dense embedding 使用 `Title + Path + Content` 拼装文本
- metadata 必须带 `docId`、`chunkIndex`,供 chunk 级证据身份使用
## 6. 检索模式
配置:
```yaml
retrieval:
search:
mode: hybrid # dense | hybrid(见 6.0 用途约定)
hybrid:
rrf-k: 60
kb-scope: ""
rag:
retrieve-k: 20
return-n: 5
max-chunks-per-document: 2
```
### 6.0 模式用途约定(保留双 mode 的原因)
知识库 **只维护一套** dense + BM25 schema 数据(默认 collection `biz`)。
`retrieval.search.mode` 切换的是**同库上的查询算法**,不是两套互斥索引、也不是两套写入路径。
| 模式 | 定位 | 说明 |
|---|---|---|
| **hybrid** | **线上主路径 / 默认** | dense ANN + 服务端 BM25 + RRF;`lookup_knowledge` 正式召回只认此模式 |
| **dense** | **对照 / 评测 / 排障** | 仅 dense ANN,用于和 hybrid 对比召回效果(命中文档/chunk、排名差异等) |
约定:
1. 生产配置保持 `mode: hybrid`;不要把 dense 当成第二套长期并行的线上策略。
2. 需要看「去掉 BM25+RRF 后召回差在哪」时,临时切 `mode: dense`,其它参数(`retrieve-k`、`return-n`、category filter、query 集)尽量固定,再切回 hybrid。
3. hybrid 入库的数据 **完全适用于** dense-only 查询:每条 chunk 都写了 `vector`;dense 模式只是不使用 `sparse_vector` / BM25 子路。
4. 代码里 `@Value` 在配置缺失时的兜底仍可能是 `dense`(历史兼容);**以 `application.yml` 的 hybrid 为准**。若做回归,确认运行配置而不是只看注解默认值。
不建议的用法:
- 按请求/按租户在 dense 与 hybrid 之间当产品功能随意切换(当前也无稳定的 per-call mode 覆盖)。
- 把 dense 模式的相关度表现直接当成 hybrid 的最终质量结论(hybrid 排序信 RRF,后处理分数仍多 L2 兼容,见下节)。
### 6.1 dense(对照基线)
```text
query
-> embedding
-> dense ANN on vector
-> topK
```
仅走 `vector` 字段的 L2 ANN。用于基线对比,不作为正式主路径。
### 6.2 hybrid(当前默认 / 主路径)
```text
query
-> path A: dense ANN(query embedding)
-> path B: BM25 sparse ANN(raw query text)
-> Milvus hybridSearch + RRFRanker(k)
-> topK fused hits
```
说明:hybrid **内部**的 dense 子路是融合的一部分,与配置项 mode=dense(整次检索只跑单路 ANN)不是同一概念。
分数与后处理(quality 统一,2026-07-28):
- 一级 scoreLabel 仅 **dense | hybrid**(旧别名 canonicalize)。
- **dense**:score = L2;qualityScore = 1 - clamp(L2)/maxL2Distance。
- **hybrid**:返回序 = RRF 序;qualityScore 由 **本轮 rank 线性映射**(不把 RRF 原分当 L2;不做 dense L2 回填覆盖主分;无 m25_only_* 一级 label)。
- 后处理:**统一**消费 qualityScore;排序主序 = originalRank;**不做** L0 关键词/domain contains 加分改序(重叠仅可写 hitReasons 解释)。
-
elevance_level / category 低质 unfiltered retry:只看 top qualityScore 与阈值。
- 实现:RetrievalScoreNormalizer、KnowledgeEvidencePostProcessor;详见 OpenSpec
ag-quality-score-unify。
### 6.3 category filter 与降级
```text
if L0 给出唯一 domain:
先 filtered 检索
if 无证据或 topSimilarity < referenceThreshold:
再 unfiltered retry
else:
直接 unfiltered
```
这里的 filter 是 metadata category / kb_scope 约束,不是第二套向量库。
## 7. 证据身份与去重
Delivery 1 已落地:
```text
evidenceKey =
docId#chunk-{chunkIndex}
fallback: vector:{id} fallback: vector:{id}
fallback: rank:{n} fallback: rank:{n}
``` ```
规则: - 去重按 `evidenceKey`,同文档不同 chunk 可同时保留
- `rag.max-chunks-per-document` / `rag.return-n` 在 Java 后处理器生效
- Agent 投影中的 `document_id` 使用 chunk 级 evidenceKey,EvidenceGuard 据此验真
- 去重按 `evidenceKey`,不是按 source 文档路径 ## 8. Agent 可见契约(不变)
- 同文档不同 chunk 可同时保留
- `rag.max-chunks-per-document` 限制单文档最多进入结果的 chunk 数
- `rag.return-n` 限制后处理后最多返回条数
- Agent 投影中的 `document_id` 使用 chunk 级 evidenceKey
这保证 hybrid 召回的多片段不会在后处理/投影阶段被文档级折叠吞掉。
## 8. L0 / L1 职责
| 层 | 做什么 | 不做什么 |
|---|---|---|
| L0 | domain/keyword hint、可选 category filter、trace 解释、轻规则 boost | 不直接当事实 evidence |
| L1 dense/BM25 | 事实证据召回 | 不依赖 frontmatter 关键词命中才返回正文 |
L0 命中文档正文不会在 L1 失败时兜底成 evidence。
## 9. Agent 可见契约
Agent 只看到有界 `RagToolResult`: Agent 只看到有界 `RagToolResult`:
- `evidence_status` - `evidence_status` / `tool_call_id` / `query`
- `tool_call_id`
- `query`
- `evidence[]`:`document_id` / `source` / `title` / `breadcrumb` / `excerpt` - `evidence[]`:`document_id` / `source` / `title` / `breadcrumb` / `excerpt`
- `relevance_level` - `relevance_level`(PRECISE / REFERENCE;`RagRelevanceLevel.HIGHLY_RELEVANT` 为保留档)
- `truncated` / `returned_count` - `truncated` / `returned_count`
不暴露: 不暴露:raw score、retrievalTrace / rerankTrace、contextPack 全文、py-rag 地址与凭据。
完整内部结果仍在 `LookupResult` 中,供审计与调试使用。Trace / 审计读法见
[RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)。
- raw score / fused score ## 9. L0 下沉
- retrievalTrace / rerankTrace
- contextPack 全文
- Milvus 内部字段与凭据
完整内部结果仍在 `LookupResult` 中,供审计与调试使用。 L0 query 理解(domain/keyword hint → 可选 categoryFilter)随本次抽离**整体下沉 py-rag**:
### 9.1 Trace 与审计(现行入口) - `KnowledgeQueryTransformer` / `KnowledgeIndexService.analyzeQuery` 已删除
- `lookup_knowledge` 直传原始 query;`categoryFilter` 恒为 null,走 UNFILTERED 单 attempt
- `LookupKnowledgeTool` 中 filtered→unfiltered 降级分支保留但不可达(作为未来 Java 侧过滤策略的兜底骨架)
- `RetrievalTrace.queryHints` 恒为空结构;`attempt` 命名只剩 `UNFILTERED_VECTOR`
请求内 `retrievalTrace` / 落库 `tool_invocation` / Trace API 读法见: ## 10. 与旧文档的差异
**[RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)** | 旧描述(2026-07-28 版) | 当前实现(2026-09-29 抽离后) |
要点:
- Agent **看不到**完整 retrievalTrace;人通过 `GET /api/diagnosis/{sessionId}/trace` 的 `toolInvocations[].retrievalDetails` 回放。
- `relevance_level` 列存 RAG 等级(PRECISE/REFERENCE);`evidence_status` 在 details JSON。
- 默认审计不落原始 query 全文与 excerpt 正文。
## 10. 写入与重建
### 10.1 日常写入
文档上传 / 知识库初始化:
```text
markdown
-> frontmatter + body
-> DocumentChunkService
-> dense embedding + search_text
-> MilvusHybridKnowledgeStore.upsertChunk
-> MySQL api_document + L0 memory index
```
### 10.2 全量重建
危险操作,需显式确认:
```bash
python scripts/rebuild_hybrid_knowledge.py --confirm REBUILD
```
等价 API:
```text
POST /api/knowledge/rebuild-hybrid?confirm=REBUILD
```
服务端顺序:
1. drop + recreate `milvus.collection`(默认 `biz`)
2. 清空 MySQL `api_document`
3. 清空内存 L0
4. 扫描 `knowledge_base/**/*.md`(跳过 `README.md`)force 导入
不会修改磁盘上的 `knowledge_base/` 源文件。
## 11. 与旧文档的差异
| 旧描述(已归档) | 当前实现 |
|---|---| |---|---|
| Spring AI VectorStore 主路径 + SDK fallback | 单一 MilvusClientV2 后端 | | 进程内 MilvusClientV2,dense+BM25+RRF | py-rag 服务端承担;Java 经 `KnowledgeSearchPort` → HTTP |
| `retrieval.vector-store.mode=auto/sdk/spring` | 已移除;改为 `retrieval.search.mode=dense/hybrid` | | `retrieval.search.mode` 切 Milvus 查询算法 | 同名配置映射 py-rag `hybrid` / `semantic` |
| source 级 evidence 去重 | chunk 级 `evidenceKey` 去重 | | scoreLabel 仅 dense \| hybrid,L2/rank 归一化 | 新增 `RERANK`:py-rag rerank 绝对分直传 |
| 应用层 sparse-lite lexical 伪 hybrid | 库内 dense ANN + BM25 + RRFRanker | | L0 hint + categoryFilter + filtered/unfiltered retry | L0 下沉;原始 query 直传,单 attempt |
| 新建 `biz_hybrid` 过渡 collection | 默认使用并重建 `biz` | | Java 侧 frontmatter 解析 / 分块 / embedding 入库 | py-rag `documents:ingest`;Java 只做 MySQL 元数据 + 原件保存 |
| `POST /api/knowledge/rebuild-hybrid` 重建 | py-rag `POST /api/v1/collections:rebuild` |
| `GET /milvus/health` 健康检查 | py-rag `GET /api/v1/health`(含 milvus/embedding/rerank 探针) |
| 删除文档同步删向量索引 | 仅删 MySQL+本地文件;py-rag 侧靠全量重建生效 |
历史材料见: 历史材料见:
- `mvp/architecture/archive/2026-07-22-legacy/rag-architecture.md` - `mvp/architecture/archive/2026-07-22-legacy/rag-architecture.md`(Milvus 前身)
- `mvp/architecture/archive/2026-07-22-legacy/modular-rag-pipeline.md` - `mvp/engineering/rag/Milvus-Hybrid接入清单.md`(进程内 Milvus hybrid 接入纪要,已过时)
- 本仓库 git 历史:`refactor/extract-rag-module` 分支,71 文件 / 约 -8000 行
## 12. 当前已知边界 ## 11. 当前已知边界
- hybrid 依赖云端/实例支持 BM25 Function 与 sparse index - py-rag 判级阈值(0.75/0.5/0.3)未校准,`quality_score` 仅供排序与展示参考(契约已知边界)
- 全量重建受 embedding API 与 Milvus 写入延迟影响,可能较慢 - frontmatter 的 keywords/summary/covers/when_to_retrieve 当前仅上传方提供;Java 侧 LLM 补全(原 `DocumentFieldEnricher`)已随抽离移除,待 py-rag 开放
- L0 关键词匹配仍较粗,只作 hint,不作主召回 - `knowledge_base/` 历史原料需在 py-rag 侧完成一次性 ingest 迁移后方可检索
- 尚未做邻块上下文自动扩展、cross-encoder rerank、真 query rewrite - 无单文档删除;下线文档靠 py-rag 全量重建
- `totalVectors` 统计接口仍可能返回 0,不代表 collection 为空;以 rebuild/init 结果与检索命中为准 - eval/rag-retrieval 离线基线基于旧 L0/scoreLabel 语义构建,抽离后需重新校准(见 `mvp/engineering/rag/RAG离线评测-基线设计.md` 顶部说明)
- `retrieval.search.mode=dense` 仅作召回对照,不是第二套主路径
- 同一 hybrid schema 数据可被 dense / hybrid 两种查询复用;从纯旧 dense-only collection 升级必须 rebuild
- hybrid 质量闸门优先用 `denseDistance` 绝对 L2;无 dense 时 rank 回退;排序仍跟 RRF
- 后处理不再用 L0 关键词 boost 改序;词面信号以库内 BM25+RRF 为准
- Trace/审计细节与限制见 [RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md) - Trace/审计细节与限制见 [RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)
+10 -3
View File
@@ -1,6 +1,6 @@
# MVP 架构文档 # MVP 架构文档
**更新日期**:2026-07-29 **更新日期**:2026-09-29
**状态**:当前单 Diagnosis Agent + Harness 架构 **状态**:当前单 Diagnosis Agent + Harness 架构
当前文档入口: 当前文档入口:
@@ -12,12 +12,19 @@
| [harness-quality-gates.md](harness-quality-gates.md) | Run、Tool、Evidence、Semantic、Release 与 Trace Recorder 门禁 | | [harness-quality-gates.md](harness-quality-gates.md) | Run、Tool、Evidence、Semantic、Release 与 Trace Recorder 门禁 |
| [session-trace-lifecycle.md](session-trace-lifecycle.md) | sessionId/runId、SSE、统一 Timeline 和 reasoning audit 生命周期 | | [session-trace-lifecycle.md](session-trace-lifecycle.md) | sessionId/runId、SSE、统一 Timeline 和 reasoning audit 生命周期 |
| [diagnosis-information-gain-stop-architecture.md](diagnosis-information-gain-stop-architecture.md) | 已实施的信息增益评价、Harness 饱和检测、Draft 合同失败降级与证据不足停止设计 | | [diagnosis-information-gain-stop-architecture.md](diagnosis-information-gain-stop-architecture.md) | 已实施的信息增益评价、Harness 饱和检测、Draft 合同失败降级与证据不足停止设计 |
| [RAG知识检索架构.md](RAG知识检索架构.md) | 当前 `lookup_knowledge` 检索:MilvusClientV2 dense+BM25 hybrid、chunk 证据身份、重建运维 | | [RAG知识检索架构.md](RAG知识检索架构.md) | 当前 `lookup_knowledge` 检索:py-rag 知识服务接入、契约映射、chunk 证据身份、入库与重建运维 |
| [RAG检索可观测性与审计.md](RAG检索可观测性与审计.md) | RAG Trace / 审计:请求内 retrievalTrace、tool_invocation 富字段、Trace API 读法 | | [RAG检索可观测性与审计.md](RAG检索可观测性与审计.md) | RAG Trace / 审计:请求内 retrievalTrace、tool_invocation 富字段、Trace API 读法 |
**工程纪要**(问题 / 决策 / E2E,非架构规范正文)见 [../engineering/README.md](../engineering/README.md)。 **工程纪要**(问题 / 决策 / 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)。 2026-07-22 前的多角色编排、双入口和旧证据链文档已移动到 `archive/2026-07-22-legacy/`,仅用于历史决策追溯,不代表当前运行时。
RAG 架构经历两次更替,均以 [RAG知识检索架构.md](RAG知识检索架构.md) 为准:
1. 2026-07-28:进程内 MilvusClientV2 hybrid(取代更早的 Spring AI VectorStore 主路径 + Milvus SDK fallback);
2. **2026-09-29(当前)**:RAG 模块抽离为独立 py-rag 知识服务,Java 经 `KnowledgeSearchPort` → `PyRagKnowledgeSearchAdapter` → HTTP `/api/v1` 调用;进程内 Milvus/embedding/L0/分块全部移除。
检索可观测与 Trace 以 [RAG检索可观测性与审计.md](RAG检索可观测性与审计.md) 为准(勿再依赖 archive 内旧 retrieval-observability)。
当前普通 Trace 与 LLM 步骤审计(`agent_reasoning_audit`:`reasoning_content` + `assistant_text`)使用独立存储和独立接口。 当前普通 Trace 与 LLM 步骤审计(`agent_reasoning_audit`:`reasoning_content` + `assistant_text`)使用独立存储和独立接口。
DeepSeek thinking 捕获路径与 V015–V017 字段已 live 验证(2026-07-28)。Reasoning 访问控制、保留期限、加密要求仍由 ISS-015 跟踪,不能把“数据已分表 + 能抓到 thinking”理解为“治理已经完成”。 DeepSeek thinking 捕获路径与 V015–V017 字段已 live 验证(2026-07-28)。Reasoning 访问控制、保留期限、加密要求仍由 ISS-015 跟踪,不能把“数据已分表 + 能抓到 thinking”理解为“治理已经完成”。
+17 -15
View File
@@ -1,13 +1,13 @@
# 当前 MVP 架构 # 当前 MVP 架构
**更新日期**:2026-07-28 **更新日期**:2026-09-29
**状态**:当前可运行架构 **状态**:当前可运行架构
## 1. 系统定位 ## 1. 系统定位
SuperBizAgent 是面向故障诊断的可追踪 Agent 应用。当前系统只保留一个拥有 Tool loop 的 `Diagnosis Agent`;Harness 负责确定性的预算、取消、工具边界、证据验真、语义审查和安全发布。 SuperBizAgent 是面向故障诊断的可追踪 Agent 应用。当前系统只保留一个拥有 Tool loop 的 `Diagnosis Agent`;Harness 负责确定性的预算、取消、工具边界、证据验真、语义审查和安全发布。
知识检索当前为显式 `lookup_knowledge` 工具 + 单一 MilvusClientV2 后端(dense / dense+BM25 hybrid)。详细链路见 [RAG知识检索架构.md](RAG知识检索架构.md)。 知识检索当前为显式 `lookup_knowledge` 工具 + 独立 py-rag 知识服务(HTTP `/api/v1`);检索算法(dense+BM25 融合、rerank、判级)与文档入库全部在 py-rag 侧,Java 只保留 harness 消费面与 HTTP 客户端。详细链路见 [RAG知识检索架构.md](RAG知识检索架构.md)。
## 2. 分层 ## 2. 分层
@@ -71,22 +71,22 @@ Agent 只看到三个固定 Tool:
Agent Agent
-> RagToolAdapter / ToolBoundary -> RagToolAdapter / ToolBoundary
-> LookupKnowledgeTool -> LookupKnowledgeTool
-> L0 hint(可选 category filter) -> 原始 query 直传(L0 已下沉 py-rag)
-> KnowledgeSearchPort -> KnowledgeSearchPort
-> VectorSearchService -> PyRagKnowledgeSearchAdapter # HTTP 客户端(PyRagClient)
-> MilvusHybridKnowledgeStore # 唯一知识向量后端 -> py-rag 知识服务 /api/v1/search # 融合 / rerank / 判级
-> RagResultProjector # 有界 Agent 投影 -> KnowledgeEvidencePostProcessor # chunk 去重 / return-n / 判级(Java 侧)
-> RagResultProjector # 有界 Agent 投影
``` ```
要点: 要点:
- 默认 `retrieval.search.mode=hybrid`:dense ANN + BM25 sparse ANN + RRFRanker。 - 默认 `retrieval.search.mode=hybrid`:映射 py-rag `hybrid`(dense+BM25 融合);`dense` 映射 `semantic` 作对照。
- 也可切 `dense`:仅 dense ANN。
- 已移除知识路径上的 legacy `MilvusServiceClient` search 与 `vector-store.mode=sdk|spring|auto` 路由。
- 证据按 chunk 级 `evidenceKey` 去重;Agent 侧 `document_id` 为 chunk 级身份。 - 证据按 chunk 级 `evidenceKey` 去重;Agent 侧 `document_id` 为 chunk 级身份。
- 知识库全量重建:`python scripts/rebuild_hybrid_knowledge.py --confirm REBUILD`,默认操作 collection `biz`。 - py-rag `evidence_status=no_evidence` 按正常"无知识可用"处理,不是错误。
- 知识库全量重建:py-rag `POST /api/v1/collections:rebuild?confirm=REBUILD`(异步任务)。
完整 schema、模式、重建与历史差异见 [RAG知识检索架构.md](RAG知识检索架构.md)。 完整契约映射、入库、重建与历史差异见 [RAG知识检索架构.md](RAG知识检索架构.md)。
## 5. Trace 与持久化 ## 5. Trace 与持久化
@@ -117,10 +117,12 @@ Reasoning endpoint 是敏感审计面,不属于普通业务 API。数据和查
与知识检索相关的独立 API: 与知识检索相关的独立 API:
- `POST /api/knowledge/init`:导入/增量初始化 `knowledge_base` - `POST /api/documents/upload`:文档上传(MySQL 业务元数据 + 本地原件 + py-rag ingest)
- `POST /api/knowledge/rebuild-hybrid?confirm=REBUILD`:清空并重建 dense+BM25 collection(默认 `biz`) - `POST /api/upload`:简单上传(本地保存 + py-rag ingest,可选 `category`)
- `GET /api/knowledge/stats`:文档元数据统计 - `GET /api/documents/{docId}` / `GET /api/documents/status/{status}` / `GET /api/documents/faultSource/{faultSource}`:文档元数据查询
- `GET /milvus/health`:MilvusClientV2 健康检查与 knowledge collection 名 - `DELETE /api/documents/{docId}`:删除 MySQL 元数据与本地原件(py-rag 侧索引需全量重建生效)
知识库检索/入库/重建的服务端健康与统计由 py-rag 提供:`GET /api/v1/health`、`GET /api/v1/stats`(`{pyrag.base-url}`)。
## 7. 安全边界 ## 7. 安全边界
+29 -9
View File
@@ -1,6 +1,6 @@
# MVP 工程纪要(Engineering Notes) # MVP 工程纪要(Engineering Notes)
**更新日期**:2026-07-29 **更新日期**:2026-07-30
**定位**:项目推进中真实遇到的问题、决策、解决思路与 E2E 验收叙事。 **定位**:项目推进中真实遇到的问题、决策、解决思路与 E2E 验收叙事。
与其它目录分工: 与其它目录分工:
@@ -11,7 +11,7 @@
| **engineering/**(本目录) | **为何这样定**、踩坑、方案取舍、live 验收导读 | | **engineering/**(本目录) | **为何这样定**、踩坑、方案取舍、live 验收导读 |
| [issues/](../issues/) | 未完成事项与状态 | | [issues/](../issues/) | 未完成事项与状态 |
| [tables/](../tables/) | 表结构说明 | | [tables/](../tables/) | 表结构说明 |
| `docs/learning/` 等 | 早期学习/分析(可能过时,不以之为现行口径) | |
| `devflow/projects/` | 单次变更的 brief/decisions/evidence 切片 | | `devflow/projects/` | 单次变更的 brief/decisions/evidence 切片 |
本文档**不是** API 规范的唯一真理源;冲突时以 `architecture/` 与代码为准。 本文档**不是** API 规范的唯一真理源;冲突时以 `architecture/` 与代码为准。
@@ -20,14 +20,19 @@
## RAG ## RAG
> 2026-09-29 RAG 模块已抽离为独立 py-rag 知识服务(架构见
> [../architecture/RAG知识检索架构.md](../architecture/RAG知识检索架构.md))。
> 下列纪要保留决策过程价值,涉及进程内 Milvus / L0 的实现细节以各文顶部说明为准。
| 文档 | 内容 | | 文档 | 内容 |
|---|---| |---|---|
| [rag/RAG排序-多路召回与RRF.md](rag/RAG排序-多路召回与RRF.md) | K、多路融合、RRF、L0 边界 | | [rag/RAG证据链探索笔记-从py-rag响应到引用验真.md](rag/RAG证据链探索笔记-从py-rag响应到引用验真.md) | **抽离后链路首发导读**:数据逐站形态、关键字段、设计哲学与化石清单 |
| [rag/RAG-Hybrid质量分与后处理.md](rag/RAG-Hybrid质量分与后处理.md) | qualityScore 统一、L2 伪装废止、后处理 | | [rag/RAG排序-多路召回与RRF.md](rag/RAG排序-多路召回与RRF.md) | K、多路融合、RRF、L0 边界(实现已下沉 py-rag,判断框架仍有效) |
| [rag/RAG-Agent如何读relevance_level.md](rag/RAG-Agent如何读relevance_level.md) | Agent 侧相关度标签含义与误读 | | [rag/RAG-Hybrid质量分与后处理.md](rag/RAG-Hybrid质量分与后处理.md) | qualityScore 统一、后处理(分数语义现为 RERANK 直传) |
| [rag/RAG离线评测-基线设计.md](rag/RAG离线评测-基线设计.md) | Golden/Fixture、hybrid 评测与闸门 | | [rag/RAG-Agent如何读relevance_level.md](rag/RAG-Agent如何读relevance_level.md) | Agent 侧相关度标签含义与误读(现行) |
| [rag/Milvus-Hybrid接入清单.md](rag/Milvus-Hybrid接入清单.md) | Hybrid 交付拆分与接入清单 | | [rag/RAG离线评测-基线设计.md](rag/RAG离线评测-基线设计.md) | Golden/Fixture、hybrid 评测与闸门(需按新语义重新校准) |
| [rag/RAG审计补丁-stepid-query-E2E验收.md](rag/RAG审计补丁-stepid-query-E2E验收.md) | step_id / query 审计 live 验收 | | [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 验收(现行) |
架构对照: 架构对照:
@@ -36,7 +41,7 @@
相关 Issue: 相关 Issue:
- [ISS-017 L0 过滤收窄与 Fallback 加固](../issues/active/ISS-017-rag-l0-filter-fallback-hardening.md)(暂缓,保持现网) - [ISS-017 L0 过滤收窄与 Fallback 加固](../issues/active/ISS-017-rag-l0-filter-fallback-hardening.md)(已失效:L0 下沉 py-rag,问题前提不复存在)
--- ---
@@ -45,6 +50,10 @@
| 文档 | 内容 | | 文档 | 内容 |
|---|---| |---|---|
| [harness/README.md](harness/README.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/components/README.md](harness/components/README.md) | **组件渐进式导读**:沿一次请求分四步理解运行控制、Agent 收敛、Tool 事实边界和验证发布 |
| [harness/CONTEXT.md](harness/CONTEXT.md) | Harness 统一术语、命名规则、状态维度与已知命名债务 | | [harness/CONTEXT.md](harness/CONTEXT.md) | Harness 统一术语、命名规则、状态维度与已知命名债务 |
| [harness/Harness生命周期与状态.md](harness/Harness生命周期与状态.md) | Run、Tool、Progress、Guard、Release、SSE 和持久化生命周期及状态映射 | | [harness/Harness生命周期与状态.md](harness/Harness生命周期与状态.md) | Run、Tool、Progress、Guard、Release、SSE 和持久化生命周期及状态映射 |
@@ -56,6 +65,17 @@
--- ---
## 审计
| 文档 | 内容 |
|---|---|
| [audit/README.md](audit/README.md) | **从这里开始**:从“这份答案为什么可信”理解审计系统,不要求先掌握表和事件类型 |
| [audit/从一次诊断Run看审计系统如何记录决策.md](audit/从一次诊断Run看审计系统如何记录决策.md) | **真实 Run 案例**:从最终结果倒推 Run、Timeline、Agent Step、Tool、Token 与 Release 决策 |
| [audit/审计系统设计-从调试日志到可回放的决策证据.md](audit/审计系统设计-从调试日志到可回放的决策证据.md) | **主设计**:日志为何不够、设计不变量、typed decision、数据分层、失败语义与当前代价 |
| [audit/审计设计演进-从SessionTrace到ExactRun.md](audit/审计设计演进-从SessionTrace到ExactRun.md) | **设计演进**:从 Session Trace、Evidence 语义和多轮串线,演进到 canonical/durable 分层、Timeline、Token 与 Reasoning |
---
## 诊断 / E2E ## 诊断 / E2E
| 文档 | 内容 | | 文档 | 内容 |
+95
View File
@@ -0,0 +1,95 @@
# 审计系统:先从一份已经返回的答案开始
这不是审计表结构手册,而是一页渐进式入门导读。
第一次阅读时,不需要记 `diagnosis_run`、`agent_step` 或事件类型。先回答一个问题:**系统已经给出了诊断答案,为什么还需要审计?**
## 1. 如果只有答案和日志,会发生什么
假设用户收到一份“数据库连接池可能耗尽”的诊断报告。业务日志也显示 Agent 和 Tool 都执行成功。
但系统仍然无法直接证明:
- 报告是否属于本轮请求,而不是混入同一 Session 的上一轮数据;
- Agent 是否真的调用了它声称使用的 Tool;
- Tool 成功是否等于找到了证据;
- EvidenceGuard 和 SemanticGuard 是否真正执行;
- 最终发布结果与 Run 终态、SSE outcome 是否一致;
- Router、Agent 和 Guard 的 Token 能否与总数对上。
这些不是“多打印几行日志”就能稳定解决的问题。它们需要明确的执行身份、决策类型、顺序和关联关系。
## 2. 审计在一次请求中做了什么
```mermaid
flowchart LR
Q["一次诊断请求"] --> R["建立 exact Run<br/>固定本轮身份"]
R --> D["在原始决策点记录<br/>Router / Agent / Tool / Guard / Release"]
D --> S["Run、Timeline、Step、Tool<br/>分别保存各自事实"]
S --> T["Trace 聚合回放<br/>计数与 Token 对账"]
T --> A["回答答案为何产生<br/>也诚实暴露审计缺口"]
```
顺着这条线,审计只做三类事情:
1. **固定身份**:用 `runId` 把一次执行与多轮 Session 分开。
2. **记录决定**:让真正做决定的组件留下有类型、有顺序的结构化事实。
3. **聚合对账**:从最终结果倒推 Timeline、Agent Step、Tool 和 Token,检查它们是否一致。
审计不会替 Agent 诊断,也不会替 Harness 决定能否发布。它负责让这些行为在事后可以被准确解释。
## 3. 先建立这个最小心智模型
```mermaid
flowchart TB
EXEC["一次 Agent 执行"] --> RUN["Run<br/>结果封面"]
EXEC --> TL["Timeline<br/>决策脊柱"]
EXEC --> DETAIL["Step / Tool<br/>行为明细"]
EXEC --> SENSITIVE["Reasoning<br/>独立敏感审计面"]
RUN --> TRACE["普通 Trace"]
TL --> TRACE
DETAIL --> TRACE
SENSITIVE -.-> RTRACE["独立受限查询"]
```
第一次阅读只需要记住:
> Run 告诉我们结果,Timeline 告诉我们决定怎样发生,Step 和 Tool 告诉我们模型与外部世界实际做过什么;它们通过 exact `runId` 组合成一条可回放的证据链。
到这里可以先停下,不需要继续记表名。
## 4. 推荐阅读顺序
```mermaid
flowchart LR
START["先建立直觉"] --> CASE["01 真实 Run 案例<br/>审计怎样使用"]
CASE --> DESIGN["02 审计主设计<br/>为什么这样设计"]
DESIGN --> EVOLUTION["03 设计演进<br/>为什么变成现在这样"]
EVOLUTION --> NEXT["后续专题<br/>数据模型、Token、Tool、Reasoning、失败"]
```
| 顺序 | 先回答的问题 | 阅读 |
|---|---|---|
| 01 | 拿到一份结果后,怎样从后向前回放整次执行? | [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md) |
| 02 | 为什么日志不够,为什么需要 exact Run、typed event 和分层数据? | [审计系统设计:从调试日志到可回放的决策证据](审计系统设计-从调试日志到可回放的决策证据.md) |
| 03 | 这套设计经历了哪些真实问题,哪些阶段性方案后来被替换? | [审计设计演进:从 Session Trace 到 Exact Run](审计设计演进-从SessionTrace到ExactRun.md) |
建议一次只读一篇。第一篇建立使用直觉,第二篇理解设计取舍,第三篇再理解这些边界如何被真实问题一步步推出来;数据表、完整事件类型和代码类名都不是第一次阅读的前置知识。
## 5. 遇到具体问题时再往下读
| 当你想知道 | 当前资料 |
|---|---|
| 一次 SUCCESS 诊断的业务链路、字段和真实数值 | [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md) |
| Session、Run、Trace 和 Reasoning 当前生命周期 | [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md) |
| exact Run 为什么出现,曾经发生过什么串线问题 | [Session-Run-Trace 隔离工程纪要](../diagnosis/Session-Run-Trace隔离-从串线到可回放.md) |
| 如何人工验收一条 Trace 是否完整和安全 | [Trace 检查清单](../../demo/trace-inspection-checklist.md) |
后续本目录会继续补充数据模型、Token、Tool、Reasoning、失败图谱和面试速查。它们会继续保持同样的渐进式结构,不要求从目录头到尾顺序阅读。
## 6. 与 Harness 文档的分工
- [Harness 入门](../harness/README.md)回答“如何让一次非确定性 Agent 执行受控并安全发布”。
- 审计文档回答“这些控制和决定如何被记录、回放和对账”。
- Harness 是执行控制边界,Audit 是决策证据边界;二者协作,但不是同一个职责。
@@ -0,0 +1,255 @@
# 从一次诊断 Run 看审计系统如何记录决策
这篇文章不从表结构和类名开始,而是从一份已经返回给用户的诊断报告开始,倒着追问:**这份答案为什么可以被系统发布?**
先说结论:审计系统不是把运行日志存下来,而是为每一次诊断建立一个独立的 `Run`,再分别记录决策顺序、模型步骤、Tool 调用和最终结果。查询时,这些记录才被重新聚合成一条可以回放、可以对账的执行证据链。
第一次阅读只看第 1、2、3、7 和 8 节即可。先建立直觉,再回来理解为什么要拆成多种记录。
## 1. 只有最终答案,为什么还不够
假设系统返回:
> 当前证据更支持数据库连接池耗尽这一排查方向,但缺少生产指标和实时日志,暂时不能确认最终根因。
这段话看起来很谨慎,但面试官、开发者或评测系统仍然会继续追问:
- 这是本轮请求产生的答案,还是混入了同一会话的上一轮数据?
- Agent 实际调用了什么 Tool,还是只在文本里声称自己查过?
- Tool 返回了候选资料以后,哪个模型步骤使用了它?
- EvidenceGuard 和 SemanticGuard 是否真的执行,最终是谁决定放行?
- 这次请求到底调用了几次模型、消耗多少 Token,数字能否对上?
普通应用日志可以告诉我们“某段代码运行过”,但很难稳定回答这些领域问题。日志行也没有天然的 Run 归属、决策类型和对账关系。
所以审计系统要解决的根问题不是“多记一些信息”,而是:
> 把一次非确定性的 Agent 执行,转换成一组具有明确身份、顺序、责任和边界的可验证记录。
## 2. 先看这次真实 Run
本文使用已有 SUCCESS E2E 样本:
| 项目 | 结果 |
|---|---|
| `session_id` | `e2e-rag-success-20260728162949` |
| `run_id` | `27415045-e674-41af-9cea-de01f27ce040` |
| 最终结果 | `SUCCESS / DIAGNOSIS_REPORT` |
| Agent Step | 2 |
| Tool Invocation | 1 次 `lookup_knowledge` |
| 模型调用 | Router 1 次、Agent 2 次、SemanticGuard 1 次 |
| Timeline | 15 个有序事件 |
| 总 Token | 13,236,`tokens_reconciled=true` |
| 总耗时 | 约 25 秒 |
业务视角看到的是一份诊断报告,审计视角看到的是报告背后的五个问题:
```mermaid
flowchart TB
OUT["用户收到 DIAGNOSIS_REPORT"]
OUT --> RUN["Run 摘要<br/>这次执行最终怎样结束?"]
OUT --> TL["决策 Timeline<br/>先后做过哪些决定?"]
OUT --> STEP["Agent Step<br/>模型每一轮做了什么?"]
OUT --> TOOL["Tool Invocation<br/>实际调用过什么?"]
OUT --> LEDGER["Token 账本<br/>资源消耗能否对上?"]
```
这五个视角不是重复保存同一份内容。它们分别回答不同的问题,并在 `run_id` 下汇合。
## 3. 审计的正确阅读顺序:从结果向前倒推
回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。
```mermaid
flowchart RL
FIN["RUN_FINISHED<br/>SUCCESS,Token 已对账"] --> REL["RELEASE_DECISION<br/>允许发布"]
REL --> SG["SEMANTIC_GUARD_DECISION<br/>SUPPORTED"]
SG --> EG["EVIDENCE_GUARD_INITIAL<br/>PASSED"]
EG --> A2["Agent Step 1<br/>生成报告草稿"]
A2 --> T1["Tool Invocation<br/>RAG 返回候选证据"]
T1 --> A1["Agent Step 0<br/>发出 Tool Call"]
A1 --> ROUTE["ROUTING_DECISION<br/>DIAGNOSIS"]
ROUTE --> START["RUN_STARTED"]
```
沿着这条链可以得到一个比“请求成功”更具体的结论:
1. 本次 Run 最终正常结束,并发布了诊断报告;
2. Release 放行前,SemanticGuard 给出 `SUPPORTED`;
3. SemanticGuard 之前,EvidenceGuard 已确认引用结构有效;
4. 报告草稿来自 Agent 第 2 轮;
5. 草稿使用的候选证据来自第 1 轮发出的真实 RAG Tool Call;
6. 整条链都属于同一个 exact `run_id`。
审计的价值就在这里:它不是保存一份“成功日志”,而是让最终结果可以逐层找到前置依据。
## 4. 第一层:Run 是这次执行的封面
`sessionId` 表示多轮对话目录,`runId` 才表示一次独立执行。
```mermaid
flowchart TB
S["chat_session<br/>同一个多轮会话"] --> R1["diagnosis_run A<br/>第一轮问题"]
S --> R2["diagnosis_run B<br/>第二轮问题"]
R1 --> D1["自己的 Step / Tool / Timeline"]
R2 --> D2["自己的 Step / Tool / Timeline"]
```
这个拆分来自真实问题:早期系统只使用 `sessionId`。同一会话连续执行两轮后,主记录会被后一轮覆盖,而 Agent Step 和 Tool Invocation 继续追加,最终造成 Trace、Feedback 和评测跨轮混合。
因此当前模型把两种身份分开:
- `sessionId` 回答“这些问题属于哪段对话”;
- `runId` 回答“这条记录属于哪一次执行”。
本次样本的 `diagnosis_run` 像一张封面,保存查询、状态、意图、发布结果、总耗时、总 Token 和最终安全内容。看到它,我们先知道故事结尾,但还不知道过程细节。
## 5. 第二层:Timeline 记录决定,不复制所有正文
本次 Run 的 15 个事件可以压缩成七个阶段:
| 阶段 | 关键事件 | 它证明什么 |
|---|---|---|
| RUN | `RUN_STARTED` | 一次独立执行已经建立 |
| ROUTING | Token、Attempt、Decision | Router 被真实调用,并选择 `DIAGNOSIS` |
| AGENT | 两轮 Token 与 Model Step | Agent 先规划 Tool,后生成草稿 |
| TOOL | `TOOL_INVOCATION` | `lookup_knowledge` 被实际执行 |
| EVIDENCE | `EVIDENCE_GUARD_INITIAL` | 引用和证据结构检查通过 |
| SEMANTIC | Token、Attempt、Decision | SemanticGuard 被调用并判断 `SUPPORTED` |
| RELEASE / RUN | Release、Finish | 报告被允许发布,Run 正常收尾 |
Timeline 只承担“什么时候做了什么决定”。它不保存完整 Tool raw,也不试图替代 Agent Step 和 Tool Invocation 的详细字段。
这是一个有意的设计取舍:如果把所有信息都塞进一张巨大的事件表,查询一条时间线会很方便,但模型步骤、Tool 证据和资源账本都会退化成难以约束的 JSON。当前方案让 Timeline 保持稳定的决策语义,领域明细继续由各自记录负责。
代价是读取时必须做聚合,不能只查一张表。但这个复杂度被集中在 Trace 查询服务中,而不是扩散给每个调用方。
## 6. 第三层:Step 与 Tool 共同证明“模型真的做过什么”
这次诊断有两个 Agent Step:
| Step | 模型行为 | Token | 关联结果 |
|---|---|---:|---|
| 0 | 没有正文,发出 `lookup_knowledge` Tool Call | 2,657 | 产生 1 条 Tool Invocation |
| 1 | 读取 Tool Observation,生成报告草稿 | 4,703 | 进入 EvidenceGuard |
`AgentStepAuditTracker` 在 Step 落库后绑定 `step_id`,Tool 执行时再把这个 ID 写入 `tool_invocation`。因此系统不只知道“这一轮有 Tool Call”和“某处有一次 Tool 调用”,还可以把两者连起来。
```mermaid
flowchart LR
S0["agent_step #0<br/>发出 lookup_knowledge"] -->|"step_id"| T["tool_invocation<br/>READY / EVIDENCE_FOUND"]
T --> O["有界 Observation"]
O --> S1["agent_step #1<br/>生成 Draft"]
```
Tool 审计也没有永久复制完整原始结果。长期记录的是 exact identity、Tool 名称、状态、耗时、请求和结果字节数、稳定错误码,以及 RAG 的检索模式、命中数和相关度等有界元数据。
完整 Tool 结果属于当前 Run 的短期 canonical truth,由 Harness 用于 EvidenceGuard 验真;它与长期 durable audit 的目的不同:
- canonical truth 要回答“当前发布校验依据的事实到底是什么”;
- durable audit 要回答“长期复盘时,这次调用发生了什么”。
把完整 raw 同时写进长期 Trace 虽然排障直接,但会扩大敏感数据、存储体积和保留治理范围,因此没有采用。
## 7. 第四层:Token 不是一个总数,而是一套可对账账本
如果只在 Run 结束时写一个 `total_token_count`,我们无法判断数字是否漏掉 Router、Guard 或隐藏重试。
本次 Run 的模型账本是:
| 组件 | Token |
|---|---:|
| Intent Router | 906 |
| Diagnosis Agent 第 1 轮 | 2,657 |
| Diagnosis Agent 第 2 轮 | 4,703 |
| SemanticGuard | 4,970 |
| 合计 | 13,236 |
对账关系为:
```text
sum(MODEL_TOKEN_USAGE.total_tokens)
= diagnosis_run.total_token_count
= RUN_FINISHED.run_total_tokens
= 13236
```
因此 `tokens_reconciled=true` 不是“记录了一个 Token 数”,而是三个独立视角得到相同结果。
还有一个容易误读的点:`agent_step.tokenCount` 之和只有 7,360,因为它只统计 Diagnosis Agent 的两轮;Run 总额还包括 Router 和 SemanticGuard。审计把组件和轮次分开,就是为了让成本和延迟可以定位,而不是只得到一个无法解释的总数。
Provider 没有返回 usage 时,系统会记录 `usage_available=false`,而不是把缺失伪造成 0 Token。缺数据本身也是需要被审计的事实。
## 8. 这套设计做了哪些关键选择
### 选择一:Trace 独立查询,不塞进 Chat 响应
- **问题**:Chat 面向用户流式返回结果,Trace 面向复盘和评测,生命周期与数据量不同。
- **决策**:通过只读 Trace API 按 `sessionId + runId` 聚合持久化证据。
- **放弃方案**:把完整 Trace 嵌入 `/api/chat` SSE。
- **代价**:调用方需要在收到 metadata 后保存 `runId`,再进行第二次查询。
### 选择二:exact Run 是审计边界
- **问题**:仅按 Session 查询曾造成多轮 Step、Tool、Feedback 和评测串线。
- **决策**:每次有效执行创建独立 Run,所有明细携带同一个 `run_id`。
- **兼容代价**:当前 API 仍保留“不传 `runId` 时读取 latest run”和 legacy 回退;这是迁移兼容,不是推荐的新调用方式。
### 选择三:决策 Timeline 与领域明细分开
- **问题**:单看 Run 摘要不知道过程,单看 Step 或 Tool 又不知道整体决策顺序。
- **决策**:Timeline 保存 typed decision event,Step 和 Tool 保存各自明细,查询时聚合。
- **放弃方案**:一张万能 Trace 表承载所有正文和字段。
- **代价**:需要维护事件与明细之间的计数、身份和顺序一致性。
### 选择四:普通审计长期保存有界信息
- **问题**:完整 Prompt、Reasoning 和 Tool raw 虽然方便排障,却会把敏感数据永久扩散到普通 Trace。
- **决策**:普通 Trace 以 metadata 为主;Reasoning 使用独立审计面,Tool raw 只在当前 Run 的 canonical store 中短期存在。
- **当前缺口**:Reasoning 的认证、权限、加密和保留期限仍未完全闭环;`agent_step.thought` 还存在兼容镜像语义,不能把当前状态描述成彻底隔离。
### 选择五:审计写入失败不改变业务结果
- **问题**:如果长期审计数据库短暂不可用,是否应该让一次本可安全完成的诊断直接失败?
- **决策**:durable audit 采用 fail-open,写入失败告警,但不改变业务结果。
- **边界**:用于 EvidenceGuard 验真的 canonical truth 不是普通审计;它缺失时无法证明证据,必须 fail-closed。
- **代价**:一次业务成功的 Run 可能存在审计缺口,所以 Trace summary 和计数对账必须显式暴露缺失,而不能假装记录完整。
## 9. 审计能证明什么,不能证明什么
审计可以证明:
- 某个结果属于哪个 exact Run;
- 哪些模型和 Tool 被实际调用;
- 决策以什么顺序发生;
- Tool Call 属于哪个 Agent Step;
- Guard 和 Release 给出了什么结果;
- Token、步骤数、Tool 数和 Timeline 数量是否对账。
审计不能自动证明:
- Tool 返回的外部数据本身一定正确;
- `SUPPORTED` 永远不会发生模型误判;
- 没有记录的 Reasoning 可以被事后还原;
- durable audit 写入失败时,缺失的事件仍然存在;
- 有了 Trace 就可以忽略权限、脱敏、保留期限和加密。
这也是为什么审计系统的目标不是“绝对正确”,而是让执行过程的身份、决定、依据和缺口都变得可见。
## 10. 先记住这一句话
> 一次诊断结束后,Run 告诉我们结果,Timeline 告诉我们决定是怎样发生的,Agent Step 和 Tool Invocation 告诉我们模型与外部世界实际做过什么,Token 账本负责对账;它们通过 exact `runId` 组合成一条可回放的决策证据链。
理解这句话,就已经抓住了审计系统的主干。表结构、事件全集、Reasoning 和失败治理都可以在需要时再展开。
## 11. 事实来源与延伸阅读
本文没有重新从源码推导设计,主要依据现有工程资料:
- [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md):本文 SUCCESS 样本、15 个 Timeline 事件和 Token 数据来源;
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前 exact-run、普通 Trace 与 Reasoning 边界;
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:为什么建设独立 Trace API;
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:Session/Run 串线问题与身份拆分决策;
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool durable audit 和 canonical/durable 边界;
- [Trace 检查清单](../../demo/trace-inspection-checklist.md):当前验收边界与兼容镜像说明。
@@ -0,0 +1,328 @@
# 审计系统设计:从调试日志到可回放的决策证据
第一篇文章跟随一条真实 Run,展示了怎样从最终报告倒推出 Router、Agent、Tool、Guard 和 Release。这一篇换一个角度:**为什么这件事不能靠普通日志完成,系统又为什么选择了现在这套审计结构?**
先说核心判断:Agent 系统的审计对象不是代码执行过程,而是一次运行中产生的关键决定。日志可以帮助开发者定位异常,但审计必须让系统回答:这个决定属于哪次执行、由谁作出、依据是什么、先后关系怎样、最终结果是否与过程对得上。
第一次阅读只看第 1、2、3、6 和 9 节即可。它们构成最小设计主线;其余章节用于展开具体取舍。
## 1. 根问题:系统给出了答案,但无法证明答案怎样产生
一个最简单的实现可能只留下这样的日志:
```text
start diagnosis
call lookup_knowledge success
semantic check passed
finish diagnosis
```
这些日志能说明代码大概运行过,却回答不了几个关键问题:
- 它们是否属于同一个请求,还是混入了同一 Session 的另一轮执行?
- `success` 表示 Tool 正常返回、找到证据,还是结论已经被证据支持?
- 哪一轮 Agent 发出了 Tool Call,后续草稿是否真的使用了这次结果?
- `semantic check passed` 前是否完成了引用真实性检查?
- 最终报告、Run 终态和 SSE outcome 是否一致?
- Router、Agent 和 Guard 的 Token 能否与总数对上?
问题不在于日志太少。即使增加更多日志,仍然缺少稳定的身份、类型、顺序和关联关系。
| 普通日志擅长回答 | 审计必须回答 |
|---|---|
| 哪段代码报错了 | 哪一次 Run 在哪个决策阶段停止 |
| 某方法耗时多久 | Router、Agent、Tool、Guard 各自消耗多少 |
| 某次调用返回成功 | 调用是否产生证据,证据是否支持发布 |
| 当前进程发生了什么 | 持久化后能否精确回放同一次执行 |
| 给开发者阅读文本 | 给 API、评测和对账提供稳定结构 |
因此审计不是“更详细的日志”,而是一套独立的数据责任。
## 2. 设计目标:把非确定性执行变成可验证记录
这套审计设计需要满足六条不变量。
### 2.1 每条记录都有 exact Run 身份
`sessionId` 可以跨多轮复用,`runId` 只属于一次执行。Agent Step、Tool Invocation、Timeline 和最终结果必须落在同一个 `runId` 下。
### 2.2 决定在发生的位置被记录
Router 记录路由决定,ToolBoundary 记录真实 Tool 调用,Guard 记录校验结论,Release 记录最终发布决定。审计层不应在事后根据日志文本猜测发生了什么。
### 2.3 先后顺序可以稳定重放
同一个 Run 内,Timeline 使用单调 `sequence_no` 表达决定顺序。回放不依赖不同线程日志的打印时间,也不依赖数据库自增 ID 恰好连续。
### 2.4 不同记录只承担一种责任
Run 保存终态摘要,Timeline 保存决策脊柱,Agent Step 保存模型轮次,Tool Invocation 保存调用元数据,Reasoning 保存独立敏感正文。任何一种记录都不应成为无限扩张的万能 JSON。
### 2.5 长期记录必须有数据边界
普通 Trace 不永久复制完整 Prompt、Tool raw 和任意嵌套参数。缺少 Provider usage 时记录“不可用”,而不是伪造为 0;Reasoning 需要独立治理。
### 2.6 审计缺口必须可见,但不能随意改变业务语义
长期审计写入失败不应把一条本可安全完成的请求变成业务失败;与此同时,查询结果必须通过计数、对账状态和缺失标记暴露审计并不完整。
可以把这六条压缩成一句话:
> 审计记录必须属于精确执行、来自原始决定、顺序稳定、职责单一、内容有界,并且能够诚实表达缺失。
## 3. 整体设计:写入时分工,查询时聚合
审计没有设计成一个集中式拦截器抓取所有内容。真正知道“发生了什么”的组件,在自己的决策点产生结构化记录。
```mermaid
flowchart TB
REQ["一次 Chat 请求"] --> RUN["创建 exact Run"]
subgraph owners["决策所有者"]
ROUTER["Router<br/>路由尝试与决定"]
AGENT["Agent Hook<br/>模型步骤与 Reasoning"]
TOOL["ToolBoundary<br/>真实 Tool 调用"]
GUARD["Evidence / Semantic Guard<br/>验证决定"]
RELEASE["Release<br/>发布结果"]
end
RUN --> ROUTER
RUN --> AGENT
RUN --> TOOL
RUN --> GUARD
RUN --> RELEASE
ROUTER --> TL[("Decision Timeline")]
AGENT --> STEP[("Agent Step")]
AGENT --> RSN[("Reasoning Audit")]
TOOL --> INV[("Tool Invocation")]
AGENT --> TL
TOOL --> TL
GUARD --> TL
RELEASE --> TL
RUN --> SUMMARY[("Diagnosis Run")]
SUMMARY --> QUERY["Trace 聚合查询"]
TL --> QUERY
STEP --> QUERY
INV --> QUERY
RSN -.->|"独立受限端点"| RQUERY["Reasoning 查询"]
```
写入侧保持分工,读取侧由 Trace 服务形成一个面向复盘的 read model:
| 观察面 | 主要回答 |
|---|---|
| `diagnosis_run` | 这次执行最终怎样结束、用了多少资源、发布了什么 |
| `diagnosis_trace_event` | 决策按什么顺序发生 |
| `agent_step` | Diagnosis Agent 每一轮模型调用做了什么 |
| `tool_invocation` | 哪些 Tool 被真实调用,状态、耗时和有界结果怎样 |
| `agent_reasoning_audit` | Provider 是否返回 Reasoning,以及独立保存的敏感正文 |
这是一种“写入分散、读取聚合”的设计。分散不是随意写表,而是让记录权归属于最了解该决定的组件;聚合则把跨组件复杂度集中在 Trace 查询边界。
## 4. 为什么记录 typed decision,而不是事后解析日志
系统当前的 Timeline 使用稳定的 Phase 和 EventType,例如:
```text
RUN_STARTED
ROUTING_DECISION
MODEL_TOKEN_USAGE
AGENT_MODEL_STEP
TOOL_INVOCATION
EVIDENCE_GUARD_INITIAL
SEMANTIC_GUARD_DECISION
RELEASE_DECISION
RUN_FINISHED
```
这些名字表达的是领域事实,而不是实现细节。`SEMANTIC_GUARD_DECISION` 可以稳定表示语义门控的结果,即使以后底层模型客户端或方法名发生变化。
如果改为事后解析日志,会产生三个问题:
1. 日志文案改变就可能破坏审计;
2. 多线程和异步输出使顺序不可靠;
3. “Tool 执行成功”和“Tool 找到证据”很容易被同一个 `success` 混淆。
typed event 让状态语义在写入时就确定。它的代价是新增事件类型需要维护协议和测试,不能随意写一段字符串就算完成审计。
### 这不是 Event Sourcing
Timeline 虽然是追加式事件序列,但系统不会依靠它重建业务状态:
- `diagnosis_run` 仍保存当前终态和发布结果;
- Agent Step 与 Tool Invocation 仍有自己的领域记录;
- Timeline 用于解释“决定怎样发生”,不是整个系统的唯一事实源。
选择完整 Event Sourcing 会引入事件版本、状态重放、快照和迁移复杂度,当前 MVP 没有这项需求。
## 5. 为什么 Timeline 和领域明细必须分开
一种看起来更简单的方案,是把所有信息都写进 `diagnosis_trace_event.details`。这样只查询一张表就能得到全部内容。
但一张万能事件表会同时承担:
- 模型轮次和 Token 字段;
- Tool 请求、状态和 RAG 检索详情;
- Guard 决策;
- Run 终态和最终内容;
- Reasoning 与 assistant text。
最后所有约束都会退化为“不同 EventType 对应不同 JSON 结构”,数据库无法清楚表达关联、索引和数据治理。
当前设计把两类问题分开:
```mermaid
flowchart LR
Q1["什么时候做了什么决定?"] --> TL["Timeline<br/>有序、稳定、轻量"]
Q2["这个对象的详细事实是什么?"] --> DETAIL["Run / Step / Tool<br/>领域字段与关联"]
TL --> TRACE["Trace Read Model"]
DETAIL --> TRACE
```
例如,Timeline 的 `TOOL_INVOCATION` 只需要表达某次调用在决策链中的位置;`tool_invocation` 才负责 `step_id`、Tool 名、状态、耗时、bytes、错误码和 RAG 派生字段。
代价是查询时需要跨表聚合和计数对账,但数据所有权和长期演进更清楚。
## 6. 六个关键决策及其代价
### 决策一:Trace 使用独立只读 API
- **问题**:Chat SSE 面向实时用户体验,Trace 面向复盘、评测和排障,数据量与生命周期不同。
- **选择**:通过 `GET /api/diagnosis/{sessionId}/trace?runId={runId}` 聚合持久化记录。
- **未选**:把完整 Trace 嵌入 `/api/chat` 响应。
- **代价**:客户端必须保存 metadata 中的 `runId`,需要第二次请求才能读取 Trace。
这项决策在最初 MVP Trace 建设时就已确定:可观测性不侵入 Chat 输出协议。
### 决策二:`runId` 而不是 `sessionId` 定义审计边界
- **问题**:同一 Session 连续两轮执行时,主记录覆盖而 Step/Tool 追加,曾导致 Trace、Feedback 和评分跨轮污染。
- **选择**:`chat_session` 表示多轮目录,`diagnosis_run` 表示一次执行,所有明细按 `run_id` 隔离。
- **未选**:继续在旧主表上追加字段,或用“最新一轮”推断明细归属。
- **代价**:接口、反馈、评测和历史迁移都要理解 Session/Run 两级身份。
### 决策三:在原始决策点生成记录
- **问题**:集中式审计器无法准确知道 Router 为什么选择意图、Tool 是否真正执行、Guard 做出了什么领域判断。
- **选择**:让 Application、Router、Agent Hook、ToolBoundary、Guard 和 Release 各自在原始位置记录 typed fact。
- **未选**:请求结束后解析日志或根据最终结果反推中间过程。
- **代价**:每个新决策路径都必须显式接入审计,遗漏不会被“万能拦截器”自动补齐。
### 决策四:Timeline 与领域明细分离
- **问题**:既需要一条简单的决策脊柱,也需要可查询的 Step、Tool 和 Run 字段。
- **选择**:Timeline 记录顺序,领域表记录详情,读取时聚合。
- **未选**:单一超大 Trace 表或全部 JSON event。
- **代价**:需要维护 exact identity、顺序和 persisted/returned count 对账。
### 决策五:普通 Trace 只持久化有界信息
- **问题**:永久保存完整 Prompt、Tool raw、Reasoning 和参数,会放大敏感数据、体积和访问治理风险。
- **选择**:Tool durable audit 只保存有界元数据;完整 Tool truth 只在当前 Run 的 canonical store 中短期存在;Reasoning 使用独立表和独立端点。
- **未选**:为了排障方便,把所有上下文复制到 MySQL Trace。
- **代价**:长期 Trace 不能还原所有原始正文;Reasoning 还需要单独的权限、保留和加密治理。
### 决策六:durable audit fail-open,canonical truth fail-closed
- **问题**:审计数据库不可用时是否让业务失败,以及证据真理源不可用时是否仍允许发布。
- **选择**:长期审计写入失败只告警,不改变业务结果;用于当前 Run 验真的 canonical truth 缺失时不能继续证明证据。
- **未选**:所有审计失败一律中断请求,或所有审计失败都静默忽略。
- **代价**:业务成功不保证审计绝对完整,必须显式暴露 audit gap;canonical store 则成为发布安全的关键依赖。
## 7. 最重要的失败边界:同样叫“记录”,失败语义不同
Tool 执行后会形成两个用途完全不同的数据面:
```mermaid
flowchart TB
TOOL["Tool 执行结果"] --> CAN["Canonical Truth<br/>当前 Run 的完整验真依据"]
TOOL --> DUR["Durable Audit<br/>长期有界元数据"]
CAN -->|"可用"| GUARD["EvidenceGuard 验真"]
CAN -->|"写入失败"| CLOSED["fail-closed<br/>不能证明就不能发布"]
DUR -->|"可用"| TRACE["长期 Trace 可回放"]
DUR -->|"写入失败"| OPEN["fail-open<br/>告警并暴露审计缺口"]
```
为什么不能统一成一种策略?
- canonical truth 参与当前业务决策。没有它,EvidenceGuard 无法独立验证 Agent 引用;继续发布会改变安全语义。
- durable audit 服务长期复盘。它很重要,但让数据库短暂故障覆盖一条已经安全完成的业务结果,会把可观测性变成新的业务单点。
fail-open 不等于“失败无所谓”。审计记录器需要告警,Trace summary 需要对比持久化数量与返回数量,Token 账本需要给出 `tokens_reconciled`,Provider usage 缺失需要写明 `usage_available=false`。
## 8. 查询模型:普通 Trace 与敏感 Reasoning 分开
普通回放入口聚合:
```text
chat_session metadata
+ exact diagnosis_run
+ agent_step metadata
+ tool_invocation metadata
+ ordered diagnosis_trace_event
= DiagnosisTraceResponse
```
Reasoning 使用独立入口:
```http
GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}
```
它要求显式 `runId`,并校验该 Run 属于 path 中的 `sessionId`。Provider 没有返回 Reasoning 时也记录 `reasoning_available=false`,不能用 assistant text 或人工摘要伪造。
分开读取的目的不是让敏感正文“换一张表就安全”,而是为后续访问控制、加密和保留期限提供独立治理边界。
## 9. 当前设计没有假装解决所有问题
当前仍有四类明确债务:
1. **兼容查询债务**:普通 Trace 不传 `runId` 时仍可读取 latest run,无 run-backed 数据时还能回退 legacy `diagnosis_session`。这是迁移能力,不是推荐契约。
2. **Reasoning 兼容镜像**:独立 Reasoning 表已经存在,但 `agent_step.thought` 仍可能保存 reasoning 或 assistant text 的兼容镜像,普通 Trace metadata-only 目标尚未完全收口。
3. **敏感治理未完成**:Reasoning 端点的身份认证、权限模型、保留期限和加密仍在 ISS-015 中,不能把“分表”描述成完整安全闭环。
4. **审计天然可能缺失**:durable audit fail-open 意味着必须把缺记录与业务失败分开诊断,不能默认数据库里没有就代表运行中没有发生。
这些不是文章末尾附带的 TODO,而是当前架构代价的一部分。审计系统的可信度来自诚实表达边界,不是把所有状态都包装成完整。
## 10. 方案对比:为什么没有选择看起来更简单的办法
| 方案 | 短期优势 | 没有采用的主要原因 |
|---|---|---|
| 只增加业务日志 | 实现快、开发者熟悉 | 缺少稳定身份、类型、关联和对账协议 |
| Chat 响应携带完整 Trace | 一次请求拿到全部数据 | 污染 SSE 协议,扩大响应和敏感数据暴露 |
| 一张万能 Trace 表 | 查询表面简单 | JSON 结构失控,领域约束、索引和治理困难 |
| 完整 Event Sourcing | 理论上可重放全部状态 | 当前只需解释决策,引入事件版本和状态重建过重 |
| 永久保存全部 raw | 排障最直接 | 敏感数据、成本和保留治理不可控 |
| 所有审计失败都 fail-closed | 记录最完整 | 长期可观测性会成为业务可用性的单点 |
| 所有审计失败都 fail-open | 业务最不易被阻断 | canonical truth 缺失时仍发布会破坏证据安全 |
## 11. 先记住这张图
```mermaid
flowchart LR
EXEC["非确定性 Agent 执行"] --> ID["exact Run 身份"]
ID --> DEC["原始决策点产生 typed record"]
DEC --> SPLIT["Timeline 与领域明细分工"]
SPLIT --> BOUND["普通审计有界<br/>Reasoning 独立"]
BOUND --> READ["Trace 聚合、计数与 Token 对账"]
READ --> EXPLAIN["可以回放,也能说明缺口"]
```
用一句话概括:
> 这套审计设计不是复制运行内容,而是让每个决策所有者在 exact Run 内留下有界、可排序、可关联的结构化事实,再通过独立查询模型把这些事实聚合成可回放、可对账的证据链。
## 12. 事实来源与延伸阅读
- [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md):用真实 SUCCESS Run 查看这些设计怎样落到记录中;
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前身份、生命周期和查询契约;
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立 Trace API 的初始问题与决策;
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线证据和 exact Run 决策;
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool audit 与 canonical/durable 失败边界;
- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Reasoning 当前完成度和剩余治理缺口。
@@ -0,0 +1,398 @@
# 审计设计演进:从 Session Trace 到 Exact Run
今天看到的审计系统包含 exact Run、决策 Timeline、Agent Step、Tool Invocation、Token 对账和独立 Reasoning。它看起来像一套预先设计好的完整架构,但真实过程并不是这样。
这套设计是被一系列具体问题推出来的:先是答案无法回放,然后是 Tool 记录语义不一致,再后来是同一 Session 多轮串线,最后 Single ReAct 重构又让旧审计链失去所有权。每次变化都解决了当时最紧迫的问题,也留下了下一阶段才看得见的新缺口。
这篇文章不按提交逐条记流水账,而是解释六次能力跃迁。第一次阅读只看第 1、2、4、7 和 10 节,就能抓住主线。
## 1. 先看完整演进
```mermaid
flowchart LR
A["只能看到最终答案"] --> B["01 可查询 Trace<br/>答案可以回放"]
B --> C["02 Evidence 与质量语义<br/>记录开始可比较"]
C --> D["03 exact Run<br/>多轮不再串线"]
D --> E["04 Canonical Tool Truth<br/>事实与长期审计分层"]
E --> F["05 Single ReAct 审计重建<br/>责任回到 Harness"]
F --> G["06 Timeline / Token / Reasoning<br/>决策、成本与敏感正文分治"]
```
六个阶段分别改变了审计系统回答问题的能力:
| 阶段 | 审计开始能够回答 |
|---|---|
| 01 可查询 Trace | 一次诊断大致执行过哪些 Agent Step 和 Tool |
| 02 语义加固 | Tool 成功、无证据、失败以及 Prompt/规则版本分别是什么 |
| 03 exact Run | 这些记录究竟属于同一 Session 中的哪一轮 |
| 04 Canonical Tool Truth | 当前发布校验依赖的完整事实,与长期审计元数据如何分开 |
| 05 Single ReAct 重建 | 新 Harness 中谁负责记录 Agent 和 Tool,审计失败如何处理 |
| 06 决策、成本与敏感正文 | 决策顺序、模型成本、Reasoning 可用性和治理缺口是什么 |
这不是从“简单”机械升级到“复杂”。每一步都在重新定义审计的边界。
## 2. 第一阶段:先让最终答案可以被回放
### 当时的问题
系统已经能执行诊断并返回结果,但演示者只能展示最终答案。面试官如果继续问“Agent 调了哪些 Tool、Verifier 做了什么、证据在哪里”,只能翻数据库或日志临时解释。
### 当时的选择
2026-07-03 的 MVP Trace 变更增加了独立只读 API:
```http
GET /api/diagnosis/{sessionId}/trace
```
它直接聚合已有持久化数据:
```mermaid
flowchart LR
API["Trace API"] --> SESSION[("diagnosis_session")]
API --> STEP[("agent_step")]
API --> TOOL[("tool_invocation")]
SESSION --> RESP["聚合 Trace Response"]
STEP --> RESP
TOOL --> RESP
```
这个阶段没有改变 Chat 主流程,也没有新增数据库表。目标很克制:先把已经存在的执行记录变成一个稳定、只读、可演示的查询入口。
### 没有选择什么
- 没有把 Trace 塞进 `/api/chat` 响应;
- 没有为了演示制造全离线假运行时;
- 没有先设计通用事件平台;
- 没有重写已有 Agent 和 Tool 持久化。
### 解决了什么
系统第一次可以从最终答案回到 Agent Step 和 Tool 记录,演示流程也形成了“Chat → Trace → Feedback”的闭环。
### 新暴露的问题
Trace 能查出来,不代表记录语义已经可靠:
- 不同 Tool 的持久化路径并不统一;
- `success`、无结果和失败的含义不稳定;
- Trace 仍以 `sessionId` 为唯一身份;
- 记录能展示,但还不能稳定支撑评测。
第一阶段解决的是“有没有回放入口”,不是“审计是否已经正确”。
## 3. 第二阶段:从“有记录”走向“有稳定语义”
### 当时的问题
评测系统准备使用 Tool Trace 计算证据质量时,发现不同 Tool 对状态的表达不一致:有的调用统一走 recorder,有的在本地 helper 中写表;无命中、失败和降级路径也可能被混成一个模糊结果。
与此同时,AIOps 还是一条独立入口。它能执行自动诊断,却没有与 Chat 相同的 Trace 故事。
### 当时的选择
2026-07-04 到 07-09 的一组变化没有急着增加新表,而是先收紧契约:
1. 统一 Tool recorder 和 evidence summary 语义;
2. 区分调用成功、没有证据和真正失败;
3. 覆盖 Verifier 缺失、非法输出和 degraded path;
4. 让 AIOps 复用已有 Trace 基础设施;
5. 用 compact `prompt_audit` 和 Gatekeeper rule-set version 记录决策版本,不保存完整 Prompt。
```mermaid
flowchart TB
TOOL["Tool 调用"] --> S1["执行是否成功"]
S1 --> S2["是否找到 Evidence"]
S2 --> S3["Verifier / Gatekeeper 如何判断"]
S3 --> S4["使用哪一版 Prompt / Rule"]
S4 --> EVAL["稳定 Trace 与确定性评测"]
```
### 没有选择什么
- 没有把完整 Prompt 存进 Trace;
- 没有使用 Prompt 内容 hash 作为频繁变化的版本协议;
- 没有引入 LLM-as-judge 代替确定性 fixture;
- 没有为 AIOps 另建一套 Trace 数据模型。
### 解决了什么
审计记录开始拥有可比较的语义。评测可以区分“Tool 正常但无证据”和“Tool 本身失败”,也能知道某次判断使用了哪版 Prompt 与规则。
AIOps 当时被定义为 Chat 的兄弟入口:不同触发方式,共享同一套持久化 Trace。这在当时是合理的复用选择。
### 新暴露的问题
两个入口共享 Trace,并没有解决最根本的身份问题。只要同一个 `sessionId` 连续执行多轮,所有 Step 和 Tool 仍会混到一起。
而且审计维度越丰富,跨轮污染的破坏越大:不仅回放错误,Feedback、Verifier、评分和案例沉淀都会读错对象。
## 4. 第三阶段:Session 不是一次执行,必须引入 exact Run
### 触发它的真实故障
2026-07-10 的 E2E 使用同一个 `sessionId` 连续请求两轮。Redis 多轮上下文表现正常,但 MySQL 出现了另一种现实:
```text
diagnosis_session
query / answer 被后一轮覆盖
agent_step
第一轮 + 第二轮持续追加
tool_invocation
第一轮 + 第二轮持续追加
```
主记录表达最新一轮,明细却表达多轮混合。Trace 不再代表某一次诊断,Feedback 也不知道评价的是哪一轮结果。
### 当时的选择
系统没有只在旧表上补一个轮次字段,而是拆分两种生命周期:
```mermaid
flowchart TB
S["chat_session<br/>多轮对话目录"] --> R1["diagnosis_run 1<br/>第一轮执行"]
S --> R2["diagnosis_run 2<br/>第二轮执行"]
R1 --> D1["step / tool by run_id"]
R2 --> D2["step / tool by run_id"]
```
核心决策包括:
- `sessionId` 表示多轮会话;
- `runId` 成为正式 API 字段,表示一次可回放执行;
- `agent_step` 和 `tool_invocation` 增加 `run_id`;
- Trace、Feedback、Evaluation 和案例来源优先绑定 Run;
- 指定 `runId` 时必须校验它属于 path 中的 `sessionId`。
### 没有选择什么
- 没有继续让 `diagnosis_session` 同时承担会话与执行;
- 没有尝试根据时间戳把历史混合 Trace 伪造成多个真实 Run;
- 没有在这个阶段引入 `diagnosis_trace_event`;
- 没有立即删除旧表和旧客户端兼容。
“当时没有引入 Timeline”很重要。这个阶段只解决身份隔离,复用 `agent_step` 与 `tool_invocation` 表达 Trace;typed Timeline 是后续才增长出来的能力。
### 解决了什么
两轮 E2E 得到同一个 Session 下两个不同 Run:
- run1 exact Trace 只返回 run1;
- run2 exact Trace 只返回 run2;
- step/tool mixed row check 为 0;
- 多轮对话上下文仍然连续。
审计终于拥有稳定的最小单位:**一次 Run 可以独立回放、评分、反馈和沉淀。**
### 新暴露的问题
Run 隔离修复了“记录属于谁”,但没有回答“Tool 的完整事实由谁保管”。旧 JPA ToolInvocation 更像长期 preview,无法成为 EvidenceGuard 的独立验真来源。
兼容策略也留下了债务:不传 `runId` 时读取 latest run、旧表保留、历史 mixed trace 只能映射为 compatibility run。
## 5. 第四阶段:长期 Trace 不能同时充当证据真理源
### 当时的问题
Single ReAct + Harness 设计要求 EvidenceGuard 独立验证 Agent 引用。如果 Guard 只能读取 Agent 已经看过的裁剪结果,等于让模型用自己的输入证明自己。
旧 `tool_invocation` 又不能直接升级为完整事实存储:长期保存 raw response 会扩大敏感数据、存储体积和保留治理范围。
### 当时的选择
2026-07-21 的 ToolBoundary 设计把一次 Tool 调用拆成不同用途的数据面:
```mermaid
flowchart TB
RAW["Tool Raw Result"] --> CAN["Canonical Invocation<br/>Redis 短期完整真相"]
CAN --> OBS["Agent Observation<br/>有界投影视图"]
CAN --> GUARD["EvidenceGuard<br/>独立验真"]
CAN -.-> DUR["Durable Audit<br/>MySQL 长期元数据"]
```
Canonical Invocation 保存当前 Run 所需的 request、raw response、Agent projection、状态和时间,并拥有 `PROJECTING / READY / ERROR` 生命周期。Agent 只能获得投影结果,不能访问 Redis 或完整 raw。
### 没有选择什么
- 没有让 JPA `tool_invocation` 保存全部 raw;
- 没有让 Agent 获取 Redis key 或 canonical record;
- 没有把无证据和调用失败合并;
- 没有让 raw 超限时静默截断后继续充当完整真相。
### 解决了什么
系统第一次明确区分:
- **当前 Run 的验真事实**:完整、短期、Harness-only;
- **Agent 的推理观察**:有界、稳定、可消费;
- **长期审计记录**:适合复盘,但不复制完整 raw。
这也奠定了后来的失败语义:canonical store 缺失时无法验真,需要 fail-closed;durable audit 写入失败不应改变 Tool observation,可以 fail-open。
### 新暴露的问题
新 ToolBoundary 有了 canonical store,却尚未接回长期 `tool_invocation` 审计。与此同时,旧 recorder 依赖 ThreadLocal 和旧 Tool 副作用,不能直接带进新 Harness。
换句话说,新架构已经有了“事实”,但一度失去了“长期记录”。
## 6. 第五阶段:Single ReAct 后,审计必须重新确定所有权
### 当时的问题
旧系统的审计依附于多 Agent、`@Tool`、ThreadLocal 和旧 `AgentLoggingHook`。Single ReAct 清理删除 Planner、Executor、Verifier、Composer 和独立 AIOps 入口后,继续保留这些记录路径会出现三个问题:
- 审计逻辑仍依赖已经退出生产链的结构;
- Tool backend 可能通过旧 annotation/recorder 产生重复副作用;
- Agent hook 可能长期保存正文和 Thought,违反新的数据边界。
### 当时的选择
2026-07-22 的收尾没有给旧 recorder 增加更多兼容分支,而是重建 Harness-native 审计:
| 旧方式 | 新方式 |
|---|---|
| 多 Agent 名称和旧 Hook 决定步骤语义 | `HarnessAgentAuditHook` 记录单体 Diagnosis Agent 步骤 |
| ThreadLocal 回退传递身份 | exact RunContext / run-scoped tracker |
| Tool 自身带 recorder 副作用 | ToolBoundary 统一触发 durable audit port |
| Agent 输入输出正文与 Thought 混入普通记录 | 普通 Step 以 roles、count、Tool names、bytes 等 metadata 为主 |
| JPA 审计与 canonical truth 概念混合 | Redis canonical fail-closed,JPA durable audit fail-open |
| Chat 与 AIOps 两条公开诊断入口 | 统一 `/api/chat` + Intent Router |
### 没有选择什么
- 没有把 `/api/ai_ops` 迁移成第二个 Harness use case;
- 没有保留旧 `@Tool` 与新 ACI 双注册;
- 没有让 audit DB 故障直接改变 Agent Observation;
- 没有为了兼容继续使用旧多 Agent 身份。
### 解决了什么
审计责任终于与当前执行架构一致:Agent Step 由 Agent Hook 负责,Tool durable audit 由 ToolBoundary 负责,最终 Run 由应用和 Release 收口。
这一步也说明演进不是只做加法。07-04 保留 AIOps 兄弟入口是当时的合理方案;07-22 删除它,则是“唯一入口、唯一 Harness”新目标下的必要替换。
### 新暴露的问题
metadata-only 解决了普通 Trace 的泄漏风险,却无法满足“为什么 Agent 选择这个 Tool”的深度审计需求。Run 虽然有 Step 和 Tool,也仍缺一条统一的决策 Timeline 和完整模型成本账本。
## 7. 第六阶段:从执行记录扩展到决策、成本与 Reasoning
### 新架构暴露的新问题
真实 E2E 出现过一条失败 Run:Diagnosis Agent 重复调用 `lookup_knowledge`,累计 12 次 Tool、45,087 Token 后才进入 `BUDGET_EXHAUSTED`;Evidence Repair 还出现过结构解析失败。
旧 Trace 可以看见许多 Step 和 Tool,却不容易直接回答:
- Agent 为什么继续查询,什么时候没有信息增益;
- Token 消耗来自 Router、Agent、Repair 还是 SemanticGuard;
- Evidence、Semantic 和 Release 决策按什么顺序发生;
- Provider 是否真的返回 Reasoning,还是系统只保存了 assistant text。
### 当前选择
2026-07-23 之后,审计继续分化为三个正交能力:
1. **typed Decision Timeline**:用 RUN、ROUTING、AGENT、TOOL、EVIDENCE、SEMANTIC、RELEASE 阶段记录决定顺序;
2. **Model Token Ledger**:按组件和轮次记录 usage,在 Run 结束执行总额对账;
3. **Reasoning Audit**:独立保存 Provider Reasoning 与 assistant text,普通 Trace 只暴露可用性和 bytes 等 metadata。
```mermaid
flowchart TB
RUN["exact Run"] --> TL["Decision Timeline<br/>决定如何发生"]
RUN --> STEP["Agent Step<br/>模型轮次"]
RUN --> TOOL["Tool Invocation<br/>外部行为"]
RUN --> TOKEN["Token Ledger<br/>组件成本"]
RUN --> RSN["Reasoning Audit<br/>敏感正文"]
TL --> TRACE["普通 Trace"]
STEP --> TRACE
TOOL --> TRACE
TOKEN --> TRACE
RSN -.-> RAPI["独立受限查询"]
```
### 没有选择什么
- 没有把 Reasoning 塞回普通 Trace 或 SSE;
- 没有在 Provider 不返回 Reasoning 时伪造思考过程;
- 没有只在 Run 结束写一个无法解释的 Token 总数;
- 没有把 Timeline 变成用于重建业务状态的完整 Event Sourcing。
### 解决了什么
当前 Trace 不仅能展示“发生过哪些调用”,还可以解释决定顺序、组件成本和发布依据。`tokens_reconciled` 能检查分项与总额,`usage_available=false` 能区分真实零消耗与供应商未返回 usage。
### 仍未完成什么
- Reasoning 访问控制、保留期限和加密尚未闭环;
- 非 DeepSeek Provider 和 reasoning unavailable live 样本仍需补充;
- `agent_step.thought` 仍存在兼容镜像语义;
- latest-run 与 legacy Trace fallback 仍然存在;
- durable audit fail-open 意味着业务成功仍可能伴随审计缺口。
演进到这里,系统不再把“记录越多”当作审计完成,而开始治理哪些记录应该存在、谁能读取、缺失时如何表达。
## 8. 哪些设计被保留,哪些被替换
| 早期设计 | 当前结果 | 判断 |
|---|---|---|
| Trace 与 Chat 响应分离 | 保留独立只读 Trace API | 核心边界正确,持续保留 |
| 聚合持久化 Step/Tool | 保留,并增加 Run、Timeline 和对账 | 基础能力被扩展 |
| Session 作为 Trace 身份 | 改为 exact Run,Session 只作目录 | 被真实串线问题替换 |
| AIOps 作为兄弟入口 | 删除,统一 `/api/chat` | 阶段性合理,后续架构收敛后退出 |
| ToolInvocation 同时承担事实与审计 | 拆为 canonical truth 与 durable audit | 责任过载,被数据分层替换 |
| 旧 ThreadLocal/Tool 副作用 recorder | 改为 Harness-native Hook、Tracker 和 Audit Port | 与当前执行架构重新对齐 |
| 普通 Step 保存 Thought/正文 | 转向 metadata + 独立 Reasoning | 安全边界收紧,但兼容镜像尚未完全清除 |
| 一个 Token 总数 | 组件轮次账本 + Run 对账 | 从统计值升级为可解释成本 |
演进中真正稳定下来的不是某张表,而是三条原则:执行身份要精确、数据责任要分层、缺失和失败要诚实可见。
## 9. 为什么不能一开始就设计成现在这样
站在今天回看,很容易认为最初就应该有 Run、Timeline、Reasoning 和 canonical store。但当时缺少三个关键事实:
1. 没有多轮 E2E,就无法证明 Session 身份会造成怎样的跨轮污染;
2. 没有 EvidenceGuard,就无法看清长期 Tool audit 与当前验真 truth 的责任冲突;
3. 没有真实 Token 和 Reasoning 样本,就无法确定哪些字段应该对账、哪些正文必须隔离。
过早一次性设计完整审计平台,很可能得到通用事件总线、万能 JSON 和全量 raw 存储,却没有解决 MVP 当时最重要的问题。
这套演进采用的是另一种方式:先围绕可观察故障收紧最小契约,再让新的真实运行暴露下一层边界。
## 10. 用一张图记住设计为什么变成现在这样
```mermaid
flowchart TB
P1["答案无法解释"] --> D1["独立 Trace API"]
P2["Tool 状态语义不一致"] --> D2["Evidence / Quality 契约"]
P3["同一 Session 多轮串线"] --> D3["exact Run"]
P4["长期审计不能独立验真"] --> D4["Canonical Truth / Durable Audit 分层"]
P5["旧审计依赖旧多 Agent 与 ThreadLocal"] --> D5["Harness-native Audit"]
P6["决策顺序、成本和 Reasoning 不可解释"] --> D6["Timeline / Token / Reasoning 分治"]
D1 --> NOW["当前可回放、可对账、有数据边界的审计系统"]
D2 --> NOW
D3 --> NOW
D4 --> NOW
D5 --> NOW
D6 --> NOW
```
一句话概括:
> 审计系统最初只是把已有 Session 记录聚合出来;真实 E2E 先迫使它建立稳定 Evidence 语义,再用 exact Run 修复多轮身份,随后 Single ReAct 重构又把 Tool 真理源与长期审计分开,最终才发展出统一 Timeline、Token 对账和独立 Reasoning 治理。
## 11. 事实来源与延伸阅读
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立只读 Trace API 的起点;
- `devflow/projects/2026-07-04-evidence-trace-hardening/`:Evidence 状态和 degraded path 契约加固;
- `devflow/projects/2026-07-04-aiops-traceable-diagnosis-entry/`:早期 AIOps 兄弟入口与共享 Trace;
- `devflow/projects/2026-07-09-interview-demo-quality-audit/`:Prompt/Rule version 与确定性质量审计;
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线、exact Run 决策和两轮 E2E;
- `devflow/projects/2026-07-21-single-react-tool-invocation-store/`:canonical Tool truth 与 durable audit 分层;
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Single ReAct 审计所有权重建;
- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Timeline、Token、Reasoning 和当前缺口;
- [审计主设计](审计系统设计-从调试日志到可回放的决策证据.md):当前设计不变量与关键取舍;
- [真实 Run 案例](从一次诊断Run看审计系统如何记录决策.md):当前审计能力的具体回放方式。
@@ -4,6 +4,10 @@
**状态**:SUCCESS 完整诊断主文档;工具阶段按**现行**审计能力(`step_id` / `query`)说明 **状态**:SUCCESS 完整诊断主文档;工具阶段按**现行**审计能力(`step_id` / `query`)说明
**文档路径**:`mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md` **文档路径**:`mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md`
> **现状说明(2026-09-29)**:本文基于 2026-07 的 live Run 编写,图中 `VectorSearchService` /
> `MilvusHybridKnowledgeStore`(进程内 Milvus)已替换为 py-rag 服务调用;审计/Trace 结构不变。
> 当前检索链路见 [../architecture/RAG知识检索架构.md](../architecture/RAG知识检索架构.md)。
### 主样本(正文数值与 timeline 来源) ### 主样本(正文数值与 timeline 来源)
| 项 | 值 | | 项 | 值 |
@@ -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,352 @@
# Harness RAG 检索体系学习笔记:从 query 到可验证证据
> **现状说明(2026-09-29)**:RAG 模块抽离后,本文「检索前 L0 导航」与
> `MilvusHybridKnowledgeStore` / `KnowledgeQueryTransformer` 相关章节描述的类已删除
> (L0 与向量检索下沉 py-rag 服务端);检索后处理/打包/投影/审计部分仍然现行。
> 当前架构见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**更新日期**: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,228 @@
# Harness 整体架构学习笔记:从装配到入口到记忆到知识库写入
> **现状说明(2026-09-29)**:RAG 模块抽离后,本文「知识库写入链路」章节描述的
> `KnowledgeIndexService` / Milvus 写入路径已删除(入库下沉 py-rag `documents:ingest`);
> 其余装配/入口/记忆章节仍现行。当前架构见
> [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**更新日期**: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,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,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,371 @@
# Harness 设计演进:从多 Agent 编排到确定性控制边界
Harness 不是一开始就被完整设计出来的。它来自几轮真实重构:系统先拆出多个 Agent 角色,又增加 Gatekeeper 保证证据真实性,随后用 StateGraph 显式管理状态和分支,最终才发现一个更根本的问题:**外层系统正在重复实现 Agent 本身已经具备的 ReAct 生命周期。**
这篇文章不按提交逐条记流水账,而是追踪每一次设计变化背后的问题:当时为什么这样做、它解决了什么、为什么后来仍然不够,以及哪些思想最终保留了下来。
第一次阅读只看第 1、2、6、8 和 10 节即可。先建立演进主线,再理解最终边界和可复用经验。
## 1. 先看完整演进路线
```mermaid
flowchart LR
M["多 Agent 分工<br/>Planner / Executor / Verifier / Composer"]
G["Gatekeeper<br/>在模型审查前机械验真"]
S["StateGraph<br/>显式状态、条件边和终态"]
H["Single ReAct + Harness<br/>推理与控制分离"]
P["Progress Control<br/>从预算止损到正常收敛"]
M -->|"证据引用可能伪造"| G
G -->|"状态藏在 Service、Hook 和 ThreadLocal"| S
S -->|"显式了编排,但仍重复 ReAct"| H
H -->|"预算能止损,不能判断继续是否有价值"| P
```
这几次变化不是简单地“旧方案错、新方案对”。每一阶段都解决了当时最明显的问题,同时也让下一个更深层的问题暴露出来。
| 阶段 | 当时解决的核心问题 | 后来暴露的核心问题 |
|---|---|---|
| 多 Agent | 复杂诊断如何分工 | 一次 ReAct 被拆成多个模型角色和协议 |
| Gatekeeper | 如何阻止伪造证据引用 | 验真依赖旧输出结构、Trace 和隐式上下文 |
| StateGraph | 如何显式表达状态、重试和 Fallback | Graph 仍在编排多个重复的推理角色 |
| Single ReAct + Harness | 如何分离业务推理与确定性控制 | Agent 仍可能在证据不足时空转 |
| Progress Control | 如何让无证据诊断正常停止 | 阈值和信息增益仍需持续校准 |
## 2. 第一阶段:把复杂诊断拆成多个 Agent
项目早期采用过 Supervisor 和 Sequential 两类多 Agent 编排。Chat 诊断最终形成了一条固定链路:
```mermaid
flowchart LR
Q["用户问题"] --> P["Planner<br/>制定排查计划"]
P --> E["Executor<br/>调用 Tool 收集证据"]
E --> G["Gatekeeper<br/>检查证据引用"]
G --> V["Verifier<br/>判断 Claim 是否可信"]
V --> C["Composer<br/>组织最终回答"]
```
这个方案有合理动机:复杂诊断既要规划、执行,又要验证和表达,把职责拆开比让一个 Prompt 包办所有事情更容易理解。
它也确实建立了几项重要能力:
- Planner 不直接编造执行结果;
- Executor 专注 Tool 调用和微观事实;
- Verifier 不再负责重新检索;
- Composer 只能表达经过允许的 Claim;
- 每个角色都有自己的结构化输出和测试入口。
问题出在拆分粒度。Planner、Executor、Verifier 和 Composer 看似是不同业务岗位,实际上刚好覆盖了一次完整 ReAct:
```text
思考 -> Planner
行动观察 -> Executor + Tool
自我检查 -> Verifier
最终回答 -> Composer
```
Agent 框架本来已经支持“思考、调用 Tool、读取 Observation、继续推理、生成答案”。外层再把它拆成四个 Agent 后,系统必须额外维护:
- 四套 Prompt 和输出 Schema;
- Agent 间的 JSON 转换;
- 证据和上下文的重复搬运;
- PASS、LOW_CONFID、REJECT 与重试分支;
- 每个角色各自的 Token、timeout 和错误语义;
- Composer 是否严格遵守 Verifier 输出的新风险。
第一阶段真正留下的经验不是“多 Agent 一定不好”,而是:
> 只有当角色拥有不同数据权限、不同工具或真正独立的业务目标时,拆成多个 Agent 才可能值得。仅仅把一次 ReAct 的内部步骤外置成多个角色,会放大协议成本。
## 3. 第二阶段:Gatekeeper 把确定性验真从模型中拿出来
多 Agent 链路很快遇到另一个问题:Verifier 可以判断一段 evidence excerpt 看起来是否支持 Claim,却不能证明 `source_invocation_id`、`raw_path` 和 excerpt 真正对应某次 Tool 调用。
如果仍然让模型检查这些字段,就会出现“模型验证模型”的循环。于是系统在 Executor 和 Verifier 之间加入 `ExecutorGatekeeperService`:
```mermaid
flowchart LR
E["Executor 输出引用"] --> G["Gatekeeper<br/>查 Tool Invocation 和 raw path"]
G -->|"真实"| V["Verifier<br/>判断语义支持关系"]
G -->|"伪造或错配"| R["拒绝进入 Verifier"]
```
这是演进中一个非常重要、并且最终被保留的决策:
```text
代码能够机械证明的事实,不交给模型判断。
```
Gatekeeper 能拒绝伪造 invocation、错误 raw path 和不匹配的 evidence excerpt,使“引用真实”和“语义成立”第一次成为两个独立问题。
但旧 Gatekeeper 仍然耦合在多 Agent 协议上:
- 它读取 Executor 特定的 `executor_evidence_v2`;
- 引用协议包含 `source_invocation_id + raw_path + excerpt`;
- Verifier 输入依赖 Hook 组装;
- Tool Trace 和 Agent 上下文通过 ThreadLocal 等隐式状态关联;
- 它只能保护旧 Executor 到 Verifier 的这一段链路。
后来的 `EvidenceGuard` 不是凭空出现的。它继承了 Gatekeeper 的核心思想,但把真理源改为当前 Run 的 canonical Tool Invocation,并把验证对象改为最终 `DiagnosisDraft`。
## 4. 第三阶段:StateGraph 让隐式编排变得可见
随着重试、低置信分支、Fallback、Run Trace 和多个 Agent 输出不断增加,旧 `ChatService + SequentialAgent + Hook + ThreadLocal` 很难回答一个简单问题:**当前诊断到底处于哪个状态,下一步为什么走这条分支?**
2026-07-17 到 2026-07-20,项目完成了一轮 StateGraph 改造。它将节点、条件边、共享状态和终态显式化,并切换公开 Chat 诊断入口。
```mermaid
flowchart LR
P["Planner Node"] --> E["Executor Node"]
E --> G["Gatekeeper Node"]
G --> V["Verifier Node"]
V -->|"PASS"| C["Composer Node"]
V -->|"补证据"| RP["Evidence Retry Prepare"]
RP --> P
V -->|"拒绝"| F["Fallback Node"]
```
StateGraph 解决了几个真实问题:
- 分支不再隐藏在大段 Service `if/else` 中;
- Graph State 显式携带 Run 级数据;
- Node 和条件边可以独立测试;
- Fallback 和 retry 路径可以画出来并验证;
- 旧 `VerifierContextHolder`、部分 Hook 和 ThreadLocal 状态得以清理;
- `ChatService` 从直接拥有全部诊断细节转为调用 Graph Runtime。
因此,StateGraph 不是一次无效重构。它提高了旧多 Agent 架构的可见性和可测试性。
但它解决的是“怎样更清楚地编排这些角色”,没有重新质疑“这些角色是否都应该存在”。结果是:
- Planner、Executor、Verifier、Composer 仍然各自调用模型;
- Graph State 继续搬运多个角色的结构化上下文;
- Node Adapter、Result Mapper、条件边和业务协议形成第二套控制结构;
- 外层 Graph 决定何时计划、执行、补证据和回答,而框架内 Agent 也在做相似的 ReAct 控制;
- 状态机显式了复杂度,却没有消除复杂度。
这一阶段带来的关键认识是:
> 显式状态机能够治理复杂流程,但不能证明流程本身有必要。如果核心流程只是一个 Agent 的自然 ReAct,Graph 可能只是把重复实现变得更整齐。
## 5. 转折点:问题不是编排写得不好,而是重复实现了 ReAct
ISS-014 对旧架构做了一个更根本的判断:Planner、Executor、Verifier、Composer 并不是四个真正独立的业务主体,而是把一个完整 ReAct 生命周期拆到了 Agent 外部。
这使设计问题从:
```text
怎样把多 Agent 编排得更清楚?
```
转变为:
```text
哪些决定必须由模型做,哪些约束必须由代码拥有?
```
这个问题带来了新的职责划分:
| 决定 | 所有者 |
|---|---|
| 提出故障假设、选择 Tool、解释证据、写 Draft | Diagnosis Agent |
| Run 身份、deadline、预算、取消、retry、唯一终态 | Harness Core |
| Tool 权限、只读、bytes、canonical truth | ToolBoundary |
| 引用是否真实 | EvidenceGuard |
| 真实证据是否支持报告 | 隔离的 SemanticGuard |
| 发布原 Draft 还是 SafeFallback | Release |
这不是把所有能力重新塞回一个“大 Agent”。相反,它按**判断性质**而不是按“岗位名称”拆分:
- 非确定性的业务推理留给 Agent;
- 可以机械证明的控制规则交给代码;
- 必须使用模型的语义审查被隔离成单轮、无 Tool、无记忆的 Guard。
## 6. 第四阶段:一个 Diagnosis Agent,加一层 Harness
最终架构只保留一个拥有业务 Tool loop 的 Diagnosis Agent:
```mermaid
flowchart TB
U["用户问题"] --> APP["Chat Application"]
APP --> H["Harness Core<br/>Run、预算、取消、终态"]
H --> A["Diagnosis ReAct Agent<br/>假设、Tool、Observation、Draft"]
A --> TB["ToolBoundary<br/>执行与 canonical truth"]
TB --> A
A --> EG["EvidenceGuard<br/>确定性引用验真"]
EG --> SG["SemanticGuard<br/>隔离语义审查"]
SG --> REL["Release<br/>SUCCESS 或 SafeFallback"]
```
这里做了几项明确取舍。
### 不再保留业务 StateGraph
项目不再用外层 Graph 编排 Planner、Executor、Verifier 和 Composer。底层 ReactAgent 框架内部是否使用 Graph 属于框架实现细节,不再成为项目业务协议。
### 不自己重写 ReAct loop
Diagnosis Agent 使用框架原生 Tool Calling 和 ReAct。Harness 通过 Model/Tool Interceptor、Hook 和显式 RunContext 接入,不维护第二套 `while` 循环。
### 不让单 Agent 获得全部权力
Agent 合并的是业务推理职责,不是安全职责。它不能管理预算、读取 canonical raw、验证自己引用、调用 SemanticGuard 或决定最终发布。
### SemanticGuard 不是第二个业务 Agent
它只接收原始问题、Draft 的用户可见语义和 verified evidence,输出 `SUPPORTED / UNSUPPORTED`。它无 Tool、无记忆、不回调主 Agent,也不能改写报告。
### 迁移按边界而不是按页面完成
实施顺序先冻结 Contract,再建立 RunContext/Retry Core、Tool Boundary、各类投影、单 Diagnosis Agent、Evidence/Semantic Guard,最后切换 Application/SSE 并删除旧架构。这避免了“先切入口,再补安全边界”的过渡风险。
## 7. Harness 建成后,问题继续暴露
单 Agent + Harness 解决了外层重复编排,但真实运行又暴露了几类更细的问题。
### Tool 成功不等于有证据
旧代码常用一个 `success` 表达所有含义。后来拆为:
```text
InvocationStatus:Tool 调用是否完成
EvidenceStatus:当前 scope 是否返回候选证据
SemanticVerdict:证据是否支持报告
ReleaseOutcome:最终向用户发布什么
```
状态变多不是为了复杂,而是为了避免 `SUCCESS` 在四层中表达四种不同意思。
### Agent Observation 不能充当真理源
Agent 需要的是有界、清洗后的 Tool 结果;EvidenceGuard 需要的是独立、可回读的调用真相;长期 Audit 又不能复制全部敏感 raw。于是形成 canonical truth、Model Observation 和 metadata audit 三层数据责任。
### 引用真实不等于结论成立
Gatekeeper 思想被升级为 EvidenceGuard,但仅验引用仍不足以拦截“真实日志被过度解释”。因此保留隔离的 SemanticGuard,并由 Release 掌握唯一出口。
### 隐藏 retry 会破坏预算和审计
SDK、HTTP Client 和各模型组件各自重试会让 Token、延迟和 attempt 无法解释。最终只允许 Harness 根据稳定失败分类执行显式 retry;正常 ReAct 下一轮和重新调用 Tool 都不叫 retry。
## 8. 第五阶段:预算能止损,但不能让诊断正常完成
单 Agent 运行后又出现一个问题:当知识库未知、日志为空或查询条件不足时,预算只能限制最大调用次数,不能判断继续搜索是否还有价值。
模型可能不断改写查询,直到:
```text
BUDGET_EXHAUSTED
或 INTERNAL_FAILURE
```
用户最终只看到通用错误,却不知道系统已经检查了什么、为什么没有结论。
于是 Harness 增加 ProgressTracker 和信息增益停止协议:
```mermaid
flowchart LR
T["Tool Result"] --> I{"是否推进当前诊断?"}
I -->|"GAINED"| C["继续收集"]
I -->|"NO_GAIN"| N["连续无增益计数"]
N -->|"未达阈值"| C
N -->|"达到阈值"| S["SATURATED / STOP_REQUIRED"]
S --> P["ProgressSnapshot"]
P --> F["INSUFFICIENT_EVIDENCE Fallback"]
```
这次演进补上了资源控制与任务完成之间的差距:
- Budget 回答“还能不能继续消耗”;
- Information Gain 回答“继续查询是否推进诊断”;
- ProgressSnapshot 回答“没有结论时,哪些已检查事实仍可安全告诉用户”。
最重要的行为变化是:**证据不足成为合法完成,而不是只能撞到预算后失败。**
## 9. 哪些设计被放弃,哪些思想被保留
| 曾经的设计 | 最终处理 | 保留下来的思想 |
|---|---|---|
| Supervisor 调度多个诊断角色 | Chat 主链不再使用 | 复杂任务需要清晰职责边界 |
| Planner / Executor / Verifier / Composer | 合并业务推理到一个 Diagnosis Agent | 规划、执行、审查、表达仍需明确责任,只是不必都是 Agent |
| Executor Gatekeeper | 旧实现删除 | 确定性验真先于语义审查,演化为 EvidenceGuard |
| SequentialAgent | 删除 | 固定业务步骤必须可测试、可观测 |
| 业务 StateGraph | 删除 | 状态和终态必须显式,转化为 typed contracts、RunLifecycle 和 Release |
| ThreadLocal 上下文 | 删除 | exact Run 归属仍必须传播,改为显式 RunContext |
| PASS / LOW_CONFID / REJECT + 补证据循环 | 删除 | 不支持的结论不能发布,改为二元语义门禁和确定性 Fallback |
| Tool raw 直接参与上下文与审计 | 分层 | Tool 结果必须可追溯,但不同消费者使用不同视图 |
好的重构通常不是把过去全部推翻,而是把有效思想从不合适的实现形式中提取出来。
## 10. 这段演进真正说明了什么
Harness 最终形成,不是因为团队一开始就知道所有组件,而是逐步回答了四个问题:
1. **业务推理应该由谁负责?** 一个完整的 Diagnosis ReAct Agent。
2. **哪些约束不能依赖 Prompt?** 身份、预算、取消、权限、容量、验真和唯一发布。
3. **哪些模型判断必须隔离?** 证据是否支持用户可见报告的 SemanticGuard。
4. **证据不足怎样成为正常结果?** ProgressTracker、ProgressSnapshot 和 SafeFallback。
最终边界可以浓缩为:
```mermaid
flowchart LR
B["需要理解业务语义和提出假设"] --> A["交给 Diagnosis Agent"]
M["能够由代码机械证明"] --> H["交给 Harness"]
S["必须使用模型但不能拥有业务循环"] --> G["交给隔离 Guard"]
O["决定什么可以公开"] --> R["只交给 Release"]
```
这也是本项目对 Agent 系统最核心的工程判断:
> 不要围绕模型的“角色感”设计系统,而要围绕决策权、真理源和失败责任设计边界。
## 11. 这套演进的代价和未完成问题
当前方案不是没有代价:
- Harness 类型和状态较多,需要统一 Context 防止误读;
- canonical store 引入 Redis TTL、容量和访问控制成本;
- EvidenceGuard 与 SemanticGuard 增加发布延迟;
- 信息增益依赖模型对非空结果的二元评价,仍可能误判;
- `NO_GAIN` 阈值、Token 和 timeout 需要根据 Trace 持续校准;
- 当前 Mock 日志和未配置的业务 MySQL 数据源限制了真实诊断覆盖面。
但这些复杂度与旧多 Agent/Graph 的复杂度性质不同:旧复杂度主要用于搬运推理过程,当前复杂度主要用于保护身份、资源、事实和发布边界。前者会随角色数增长,后者围绕稳定的不变量增长。
## 12. 面试时如何讲这段演进
可以用下面这段话概括:
> 项目最初把复杂诊断拆成 Planner、Executor、Verifier 和 Composer,并通过 Gatekeeper 验证证据引用;为了治理 Service、Hook 和 ThreadLocal 中的隐式分支,又引入 StateGraph 显式管理状态和终态。Graph 提高了可测试性,但没有解决根问题:外层仍在重复框架已有的 ReAct 生命周期,并产生多套 Prompt、Schema、重试和上下文搬运。后来我们按决策性质重新划分责任,只保留一个拥有 Tool loop 的 Diagnosis Agent,把 Run、预算、取消、Tool 真相、证据验真和发布权放进确定性 Harness,语义审查则隔离成无 Tool、无记忆的单轮 Guard。最后又通过信息增益协议,让证据不足从预算失败变成可解释的正常 Fallback。
这段回答的重点不是“我们用了哪些框架”,而是展示:系统怎样从症状修补逐步走到责任边界重构。
## 13. 事实来源与延伸阅读
关键演进节点可由 Git 提交确认:
| 日期 | 代表提交 | 含义 |
|---|---|---|
| 2026-07-03 | `f01866c` | Chat 切换 Sequential Agent |
| 2026-07-08 | `c5e496e`、`1b31e78`、`6015bcb` | Gatekeeper、Verifier、Composer 完成 |
| 2026-07-17 | `581daff`、`99e490f` | StateGraph 设计冻结并切换 Chat |
| 2026-07-20 | `190013c` | StateGraph 清理与验收完成 |
| 2026-07-21 | `58c3910` 至 `f8809cb` | Single ReAct、Harness、Tool、Guard 和 Application 分阶段落地 |
| 2026-07-22 | `8ee7cc0` | 删除旧 Agent 架构 |
| 2026-07-27 | `d045218`、`38f781b` | 信息增益停止与协议修复完成 |
主要历史资料:
- `mvp/architecture/archive/2026-07-22-legacy/agent-orchestration.md`;
- `mvp/issues/archived/ISS-014-single-react-agent-harness-aci-ptk-refactor.md`;
- `devflow/projects/2026-07-21-single-react-design-freeze/decisions.md`;
- `openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`。
继续阅读:
- [Harness 入门](README.md)
- [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)
- [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md)
- [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md)
- [组件渐进式导读](components/README.md)
@@ -0,0 +1,304 @@
# Harness 面试速查:用一张图讲清设计
这篇文章是 Harness 系列的收尾,不增加新的组件和状态。它把现有设计压缩成一套可在面试中逐层展开的叙事:先用一句话定义,再用一张图说明边界,最后根据追问进入事实、停止、发布和失败设计。
如果只剩 10 分钟,阅读第 1、2、4 和 8 节即可。
## 1. 30 秒回答:什么是 Harness
> Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。
这段回答包含三个重点:
```text
Agent 负责业务推理
Harness 负责确定性约束
Release 决定什么可以公开
```
不要一开始列 10 个职责域。面试官追问“具体怎么做”时,再沿下面的总图展开。
## 2. 一张图讲清完整设计
```mermaid
flowchart LR
U["用户问题"] --> APP["Chat Application<br/>创建 Run、路由、持久化、SSE"]
subgraph CONTROL["一、运行控制"]
CORE["Harness Core<br/>identity / deadline / budget<br/>cancel / lifecycle / retry"]
PROGRESS["Progress Control<br/>重复、信息增益、停止"]
end
subgraph REASONING["二、业务推理"]
AGENT["Diagnosis ReAct Agent<br/>假设、选 Tool、解释 Observation、写 Draft"]
end
subgraph TRUTH["三、事实边界"]
TB["ToolBoundary<br/>授权、只读、容量、状态迁移"]
CAN["Canonical Invocation<br/>当前 Run 的短期完整真相"]
OBS["Model Observation<br/>模型可见的有界投影"]
AUDIT["Metadata Audit<br/>长期可观测账本"]
end
subgraph PUBLICATION["四、验证发布"]
EG["EvidenceGuard<br/>引用是否真实"]
SG["SemanticGuard<br/>证据是否支持结论"]
REL["Release<br/>原报告或 SafeFallback"]
end
APP --> CORE
APP --> AGENT
CORE -.->|"RunContext 控制句柄"| AGENT
CORE -.->|"active / budget 门禁"| TB
AGENT --> PROGRESS
AGENT -->|"Tool Call"| TB
TB --> CAN
CAN --> OBS --> AGENT
TB -.-> AUDIT
AGENT -->|"DiagnosisDraft"| REL
REL --> EG
CAN --> EG --> SG --> REL
PROGRESS -->|"无结论或受控停止"| REL
REL --> APP --> U
```
这张图不表示 Harness 替 Agent 安排固定步骤。Agent 自己决定查什么、何时形成 Draft;Harness 只在每个边界回答:
- 这一步是否属于当前 active Run;
- 是否仍有时间、预算和调用权限;
- Tool 事实应该保存在哪里,模型可以看到多少;
- Agent 的引用能否从当前 Run 的真实调用中验出;
- 现有证据是否足以支持对用户公开的结论;
- 无法继续时,应该发布安全说明还是失败。
## 3. 一次请求怎样穿过 Harness
```mermaid
sequenceDiagram
participant U as Client
participant APP as Chat Application
participant CORE as Harness Core
participant A as Diagnosis Agent
participant I as Tool Interceptor
participant T as ToolBoundary
participant C as Canonical Store
participant R as Release Pipeline
U->>APP: 支付服务为什么超时?
APP->>CORE: startRun(sessionId)
CORE-->>APP: RunContext(runId, deadline, handles)
APP->>A: query + bounded PreviousTurn
loop 框架原生 ReAct
A->>I: Tool Call Envelope
I->>I: progress / duplicate / saturation
I->>T: business input + RunContext
T->>T: active / auth / readonly / budget / bytes
T->>C: PROJECTING -> READY or ERROR
T-->>I: canonical projected result
I-->>A: 有界 Model Observation
end
A-->>R: DiagnosisDraft
R->>C: 回读当前 Run 的 Tool 真相
R->>R: EvidenceGuard + SemanticGuard
alt 验证通过
R-->>APP: 原始安全报告 / SUCCESS
else 证据不足或门禁失败
R-->>APP: SafeFallback / FALLBACK
end
APP->>CORE: first terminal wins
APP-->>U: content or failure + done
```
讲这条链路时只需要抓住四个时间点:
1. Agent 运行前先建立 Run 边界。
2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定。
3. Agent 只看到有界观察,完整事实由系统独立保管。
4. Draft 必须经过唯一发布出口,不能直接发送给用户。
## 4. 三个最核心的设计决策
### 决策一:按决策权拆分,而不是按角色拆分
项目早期使用过 Planner、Executor、Verifier、Composer 和 StateGraph。它提高了职责可见性,但也把一次自然 ReAct 拆成多个模型调用、Prompt、Schema 和状态搬运。
最终选择是:
| 决策 | 所有者 |
|---|---|
| 提出假设、选择 Tool、解释证据、撰写 Draft | Diagnosis Agent |
| 身份、预算、取消、权限、容量、唯一终态 | Harness |
| 引用能否被代码机械证明 | EvidenceGuard |
| 真实证据是否支持用户可见结论 | 隔离的 SemanticGuard |
| 发布正常报告还是 SafeFallback | Release |
这里不是把多个 Agent 粗暴合并成一个“大 Agent”。业务推理合并了,安全权力反而被拆得更清楚:Agent 没有 canonical raw 读取权、不能验证自己的引用,也没有最终发布权。
放弃的方案:在外层继续编排多 Agent 或重新实现一套 ReAct loop。
获得的能力:唯一业务上下文、唯一 Draft 作者、控制规则可测试。
付出的代价:Harness 契约和门禁必须完整,不能再依赖角色之间“互相提醒”。
### 决策二:系统事实、模型观察和长期审计不能共用一份数据
同一份 Tool 结果要服务三个互相冲突的目标:
```mermaid
flowchart TB
RAW["Tool backend raw result"] --> TB["ToolBoundary + Projector"]
TB --> CAN["Canonical truth<br/>当前 Run、短 TTL、可验真"]
CAN --> OBS["Model Observation<br/>白名单、有界、服务推理"]
CAN --> GUARD["EvidenceGuard<br/>独立回读、验证引用"]
TB -.-> META["Metadata Audit<br/>身份、状态、耗时、bytes"]
```
如果 raw 直接给模型,上下文、敏感数据和 prompt injection 风险不可控;如果只保存裁剪后的 Observation,EvidenceGuard 无法独立证明 Agent 引用了真实结果;如果把完整 raw 永久写入审计库,又会制造敏感数据副本。
因此当前设计将数据责任拆开:
- Redis canonical invocation 保存当前 Run 的短期完整 Tool 真相;
- Model Observation 只包含 Agent 下一步推理所需字段;
- 长期 Audit 只保存 identity、状态、耗时、Token 和 bytes 等元数据;
- EvidenceGuard 从 canonical store 验证物理真实性;
- SemanticGuard 只在 verified evidence 上判断语义支持关系。
放弃的方案:一份 Tool JSON 在 Agent、Guard 和数据库之间直接流转。
获得的能力:模型不能靠自己看到的内容完成自证,长期审计也不必复制全部敏感正文。
付出的代价:每类 Tool 都需要 projector、canonical contract 和容量策略,Redis TTL 也成为验证可用性的边界。
### 决策三:把“如何结束”设计成一等能力
Agent 系统最常见的问题不只是错误,而是无法正常结束:空日志、通用知识和相似查询都可能让模型持续尝试,最后撞上预算。
当前 Harness 使用两套不同机制:
```text
Budget:还能不能继续消耗资源
Information Gain:继续查询是否推进诊断
```
```mermaid
flowchart TD
X["一次 Tool 结果或执行问题"] --> C{"Run 还能继续?"}
C -->|"可以"| G{"结果是否推进诊断?"}
G -->|"GAINED"| N["继续 ReAct"]
G -->|"连续 NO_GAIN"| S["SATURATED<br/>停止新增 Tool"]
C -->|"不可以"| P{"已有可验证进展?"}
S --> P
P -->|"有"| F["SafeFallback<br/>已检查内容、限制和下一步"]
P -->|"无"| E["FAILED<br/>不发布未验证内容"]
N --> D{"最终 Draft 可发布?"}
D -->|"是"| OK["SUCCESS"]
D -->|"否"| F
```
`READY + NO_EVIDENCE` 表示查询成功但当前 scope 为空,不是技术异常;`conclusion=null` 是合法 Draft,不是模型失败;`SATURATED` 只停止收集,不是 Run 终态;`FALLBACK` 表示请求被安全处理但没有正常报告,也不等于 `RunState.FAILED`。
放弃的方案:强制 Agent 必须给出根因,或者只依靠硬预算和全局异常结束。
获得的能力:证据不足可以成为诚实、可解释的产品结果,局部 Tool 故障也不必立即摧毁整个 Run。
付出的代价:RunState、Tool 状态、CollectionState 和 ReleaseOutcome 必须保持正交,排障不能只看一个 `status`。
## 5. 这套设计最难的地方是什么
面试中不要把难点回答成“接入了 Spring AI”或“写了很多 Interceptor”。真正困难的是确定责任边界,并让这些边界在异常和竞态下仍成立。
| 难点 | 核心问题 | 当前答案 |
|---|---|---|
| Run 归属 | 异步调用和多轮 Session 中,事实到底属于哪次执行 | 显式 `RunContext` 和 exact `runId` |
| 事实可信 | Agent 引用的 Tool 内容如何独立验真 | canonical invocation + EvidenceGuard |
| 结论可信 | 引用真实但推论牵强怎么办 | 隔离的 SemanticGuard |
| 正常收敛 | 没有证据时如何避免反复查询 | Information Gain + SATURATED + ProgressSnapshot |
| 失败一致 | 超时、预算、Tool ERROR 和断开如何对应结果 | first-terminal-wins + 唯一 Release Policy |
| 可观测性 | 如何回放决策又不永久保存敏感正文 | metadata audit + 短期 canonical truth |
如果面试官只允许选一个,回答“事实可信”最能代表这套设计:它要求系统同时解决 Tool 身份、Run 归属、数据视图、证据引用和最终发布,而不是只调一个模型接口。
## 6. 用真实案例讲 2 分钟
可以使用支付超时案例:
> 用户要求诊断支付服务超时。Application 先创建独立 Run,并为 Router、Agent、Tool 和 Guard 共享同一套 deadline、预算和取消能力。Diagnosis Agent 自主调用知识库和日志 Tool;ToolBoundary 执行调用并把完整事实保存为当前 Run 的 canonical invocation,只把有界 Observation 返回给 Agent。Agent 根据两个 Tool 结果生成 Draft,但 Draft 没有直接发给用户。EvidenceGuard 回读 canonical store 后发现引用无法完成真实性校验,因此 Release 没有继续让模型润色或猜测,而是发布 `EVIDENCE_VALIDATION_FAILED` SafeFallback。最终数据库记录请求处理成功、ReleaseOutcome 为 FALLBACK,SSE 也完整结束,但未经验证的根因没有离开系统。
这个案例的价值不在于“最终失败了”,而在于证明:
```text
Tool READY != 引用已验真
引用已验真 != 结论被支持
Agent 生成 Draft != 报告允许发布
```
完整数据和过程见[支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)。
## 7. 常见追问怎样展开
| 面试官追问 | 回答主线 | 深入阅读 |
|---|---|---|
| 为什么不继续使用多 Agent? | 多角色重复实现 ReAct;改为按决策权拆分 | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
| 全部组件有哪些? | 先讲四组,再按需展开 10 个职责域 | [组件全景](Harness组件全景-职责-设计原因与边界.md) |
| Tool 结果为什么不直接给模型? | 真相、观察和长期审计有不同数据责任 | [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) |
| 如何防止 Agent 编造证据? | framework Tool ID、canonical store、EvidenceGuard | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
| EvidenceGuard 已经通过,为什么还要 SemanticGuard? | 引用真实不等于结论被支持 | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
| Agent 为什么不会无限调用 Tool? | 预算止损,信息增益负责正常收敛 | [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) |
| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 | [失败图谱](Harness失败图谱-异常-停止-降级与终态.md) |
| FALLBACK 算成功还是失败? | RunState、ReleaseOutcome、数据库和 SSE 是不同视图 | [生命周期与状态](Harness生命周期与状态.md) |
| 如何验证不是纸面设计? | focused tests 证明不变量,live E2E 验证组合契约 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) |
| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、Reasoning 治理 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
## 8. 面试中最容易讲错的六件事
### 不要说:Harness 负责安排 Agent 的执行步骤
应说:ReAct Agent 自己选择 Tool 和下一步,Harness 负责运行边界、事实边界和发布边界。
### 不要说:SemanticGuard 是第二个诊断 Agent
应说:它是无 Tool、无记忆、单轮二值判断的隔离审查器,不能探索事实或改写报告。
### 不要说:Tool 返回 SUCCESS 就找到了证据
应说:`InvocationStatus=READY` 只表示调用完成,还要结合 `EvidenceStatus`;存在候选证据也不代表支持结论。
### 不要说:FALLBACK 就是 Run 失败
应说:Fallback 是安全发布结果。典型证据不足场景可以是 `RunState.SUCCESS + ReleaseOutcome.FALLBACK`。
### 不要说:Redis 是完整的长期审计库
应说:Redis canonical store 保存当前 Run 的短期完整 Tool 真相;长期审计只保存有界元数据。
### 不要说:取消能立刻杀死所有模型调用
应说:当前是协作式取消;first-terminal-wins、active check 和 SSE 状态保证迟到结果不能发布,但同步 Provider 计算未必立即停止。
## 9. 当前设计的代价和边界
一套可信的面试叙事不能只讲收益,还要主动说明代价:
1. 正交状态较多,必须用统一 Context 防止 `SUCCESS / READY / FALLBACK` 被混读。
2. canonical store、Projector 和 Guard 增加了实现复杂度与发布延迟。
3. Redis TTL 过期后只能保留元数据回放,不能恢复完整 Tool 正文。
4. 自然语言近义查询目前不能被确定性去重,只能依赖 Agent 的信息增益义务。
5. SemanticGuard 仍是模型判断,只是被收缩到最小、隔离、无 Tool 的范围。
6. 协作式取消保护逻辑终态和发布,不等于强制终止 Provider 计算。
7. 信息增益阈值和各类预算仍需依靠固定评测集持续校准。
主动说出这些边界,会让设计从“组件介绍”变成可讨论的工程决策。
## 10. 最后只记住四句话
```text
Agent 决定如何诊断,Harness 决定诊断必须遵守什么边界。
系统保管 Tool 真相,模型只读取完成下一步所需的有界观察。
引用真实与结论成立是两个问题,必须由不同门禁处理。
Harness 不保证每次找到答案,但保证任何结束方式都诚实、可审计、不会越权发布。
```
到这里,Harness 文档的主线已经闭合。需要回忆某个细节时,通过第 7 节进入专题即可,不需要重新从组件清单开始阅读。
+9
View File
@@ -63,6 +63,15 @@ flowchart TB
| 当你想知道 | 再阅读 | | 当你想知道 | 再阅读 |
|---|---| |---|---|
| 准备面试,想用一张图快速复习完整设计 | [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 的各组组件 | [组件渐进式导读](components/README.md) |
| 为什么选这种控制边界,而不是工作流编排 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) | | 为什么选这种控制边界,而不是工作流编排 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
| 一次请求从开始到结束经历什么 | [生命周期与状态](Harness生命周期与状态.md) | | 一次请求从开始到结束经历什么 | [生命周期与状态](Harness生命周期与状态.md) |
@@ -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,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)。
@@ -1,5 +1,10 @@
# Milvus Hybrid Search 接入对照清单 # Milvus Hybrid Search 接入对照清单
> **已过时(2026-09-29)**:RAG 模块已抽离为独立 py-rag 知识服务,进程内 Milvus
> (`MilvusHybridKnowledgeStore` / `VectorSearchService`)与本文描述的接入路径已整体删除。
> 当前检索架构与契约映射见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
> 本文仅作历史决策追溯。
**日期**:2026-07-27 **日期**:2026-07-27
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础 **前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF **目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
@@ -1,5 +1,12 @@
# 混合检索上线之后:为什么还要统一 qualityScore,以及上一代后处理错在哪 # 混合检索上线之后:为什么还要统一 qualityScore,以及上一代后处理错在哪
> **现状说明(2026-09-29)**:本文讨论的后处理排序/去重/判级仍在 Java 侧
> (`KnowledgeEvidencePostProcessor` / `RetrievalScoreNormalizer`);
> 但分数语义已变:py-rag 服务端返回 rerank 绝对分(scoreLabel=`RERANK`,[0,1] 越大越好),
> quality 直传,不再走本文所述 dense L2 / hybrid rank 归一化分支(分支保留作兼容)。
> 判级阈值 0.75/0.5 与 py-rag 契约一致。当前架构见
> [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**日期**:2026-07-28 **日期**:2026-07-28
**范围**:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定 **范围**:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定
**读者**:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学 **读者**:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学
@@ -1,5 +1,10 @@
# 诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF # 诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF
> **现状说明(2026-09-29)**:本文的排序判断框架(多路召回、RRF、Rerank 选型)仍是理解
> py-rag 服务端检索设计的背景材料;但 RRF 融合、BM25、rerank 的**实现**已下沉 py-rag 服务端,
> Java 侧不再有 `RrfFusion` / `MilvusHybridKnowledgeStore`。
> 当前架构见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**日期**:2026-07-27 **日期**:2026-07-27
**范围**:知识检索排序、多路召回、分数融合、Rerank 选型 **范围**:知识检索排序、多路召回、分数融合、Rerank 选型
**读者**:需要在 Agent 系统里落地 RAG,而不是只做 Demo 问答的工程同学 **读者**:需要在 Agent 系统里落地 RAG,而不是只做 Demo 问答的工程同学
@@ -1,5 +1,10 @@
# RAG 离线评测:讨论、设计与落地 # RAG 离线评测:讨论、设计与落地
> **需重新校准(2026-09-29)**:RAG 模块抽离后,L0 hint / categoryFilter 已下沉 py-rag、
> 质量分改为 RERANK 直传、attempt 只剩 `UNFILTERED_VECTOR`;本文设计的 fixture/baseline
> 基于旧 L0/scoreLabel 语义构建,回归评测需按新语义重新生成 fixture 并校准闸门。
> 当前架构见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**日期**:2026-07-28 **日期**:2026-07-28
**范围**:`eval/rag-retrieval` 离线 baseline、fixture 生成、与 hybrid/quality 主路径对齐 **范围**:`eval/rag-retrieval` 离线 baseline、fixture 生成、与 hybrid/quality 主路径对齐
**读者**:要维护或扩展知识库回归评测的工程同学 **读者**:要维护或扩展知识库回归评测的工程同学
@@ -0,0 +1,336 @@
# RAG 证据链探索笔记:从 py-rag 响应到引用验真
**日期**:2026-09-29
**范围**:RAG 模块抽离后的完整数据链路——每个站点"数据长什么样、字段怎么来、为什么这样设计"
**读者**:要理解或维护 `lookup_knowledge` 证据链的工程同学
**关联文档**:
- 架构规范:[../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)(本次抽离后的权威口径)
- Trace / 审计:[../../architecture/RAG检索可观测性与审计.md](../../architecture/RAG检索可观测性与审计.md)
- 契约原文:py-rag 仓库 `docs/Java接入文档.md`(API v1 冻结面)
- 前置知识:`RAG-Hybrid质量分与后处理.md`(分数语义演进史)
> 本文按一次真实代码探索的顺序组织:从 py-rag 返回的 JSON 出发,沿着数据走过的每一站,
> 讲清关键字段、加工规则与设计取舍,最后到 Agent 引用验真收口。
---
## 0. 全景路线图
```text
py-rag JSON ──► KnowledgeSearchHit 站点1-2:数据形态与防腐层映射
│
① 后处理 ──► EvidencePostprocessResult 站点3:去重/限流/判级
│
② 打包 ──► ContextPack 站点4:文本储备(非 Agent 口粮)
│
③ 组装 ──► LookupResult 站点5:内部真相全集
│
④ 投影 ──► RagToolResult ──► Agent 站点6-7:冻结契约与 Agent 视图
│
⑤ Agent 写报告引用 toolCallId
│
⑥ EvidenceGuard 对账验真 ──► 语义审查 ──► 发布 站点8:证据安全链
(旁路:每站关键字段 ──► tool_invocation 审计表,供人排障)
```
一句话定位:**py-rag 负责"把对的片段找出来",Java 侧负责"把找到的证据管起来"**;
检索质量可以整体外换,证据治理一寸不动——这次抽离(71 文件 / 约 -8000 行)本身就是证明。
---
## 1. 站点一:py-rag 返回的数据结构
响应 JSON 按职责分四块,四块数据两条去路(①②③喂后处理,④喂审计):
```json
{
"query": "网关超时怎么排查", // ① 入参回显
"mode": "hybrid", // ① 查询模式(hybrid | semantic)
"hits": [ // ② 命中数组(检索的基本单位是 chunk)
{
"evidence_key": "e2e-gateway-b9c1fa12-md-34223174#chunk-1", // chunk 级身份
"document_id": "e2e-gateway-b9c1fa12-md-34223174", // 所属文档
"source": "e2e-gateway-b9c1fa12-md", // 来源路径
"title": "网关超时排查",
"breadcrumb": "网关超时排查 > 处理步骤",
"excerpt": "网关超时先检查 upstream 配置…", // 正文
"quality_score": 0.9147, // rerank 绝对分
"relevance_level": "PRECISE" // py-rag 自己的判级(Java 不用)
}
],
"relevance_level": "PRECISE", // ③ 顶层判级(top1)
"evidence_status": "supported", // ③ 业务状态
"retrieval_trace": { // ④ 过程记录(不参与后处理,进审计)
"mode": "hybrid", "filters": {"category": "gateway"},
"recall_count": 20, "rerank_model": "BAAI/bge-reranker-v2-m3",
"no_evidence_basis": null
}
}
```
关键语义:
- **`evidence_status` 只有两类**:`supported`(正常)/ `no_evidence`(查到了但都是垃圾或无候选,
此时 `hits` 恒为空)。它是 **200 正常业务响应**,Java 侧直接走"无知识可用"分支,不重试不报错。
- **`relevance_level` 是分数的档位化**:`quality_score ≥0.75 → PRECISE`,`≥0.5 → REFERENCE`,
`<0.5` 不输出。阈值当前未校准(契约已知边界)。**它是给模型的,分数是给系统的**——
同一信息两种表达,服务两种消费者。
- **命中没有独立 metadata map**(旧 Milvus 方案遗留概念),文档归属信息就是那几个平铺字段。
---
## 2. 站点二:防腐层映射——`KnowledgeSearchHit`
`PyRagKnowledgeSearchAdapter` 把每个 hit 映射成可移植结构。从此全 Java 侧只认这个类型,
py-rag 字段再怎么变只改 adapter 一处。后处理实际只消费其中 **7 个字段**:
```text
evidenceKey → 去重键(docId#chunk-N,原样采纳)
docId / chunkIndex → 文档分桶(单文档上限);chunkIndex 从 "#chunk-N" 解析
content ← excerpt,最终证据正文
score + scoreLabel ← quality_score + 常量 "rerank"(quality 直传)
originalRank ← 数组下标 +1,排序的权威
source / title / breadcrumb → 透传展示
```
`retrieval_trace` 不进这条链——它走审计旁路。**进入后处理时,每个 hit 被浓缩成
"身份 + 分数 + 名次 + 正文"四组信息,四步加工全部围绕它们转。**
---
## 3. 站点三:后处理四步——`EvidencePostprocessResult`
```text
① 打分 score + scoreLabel → qualityScore(RERANK 分支直传,clamp [0,1])
② 排序 只按 originalRank —— ★ 分数不参与排序,只用于判级
③ 去重截断 evidenceKey 去重 → 单文档 chunk ≤2 → 总数 ≤5(return-n)
④ 判级 顶分 ≥0.75 PRECISE / ≥0.5 REFERENCE / 否则无档
```
输出结构逐字段(7 条命中进、5 块存出的例子):
| 字段 | 规则 | 例值 |
|---|---|---|
| `candidateCount` | 输入候选数 | 7 |
| `evidenceBlockCount` | 存活块数(差值 = 被治理掉的) | 5 |
| `topSimilarity` | 排序后第一名的 qualityScore | 0.91 |
| `relevanceLevel` | Java 按本地阈值独立判定(**不用 py-rag 返回的档位**,那个字段映射时已丢弃) | PRECISE |
| `completenessHint` | 档位绑定的天花板提示文案 | "知识库中不存在比上述结果更精准的文档" |
| `rerankTrace[]` | 每个**存活**块的最终序账本 | finalRank 1~5 |
### 3.1 去重辨析:三条规则别混淆
| 层 | 键 | 规则 | 目的 |
|---|---|---|---|
| 去重 | `evidenceKey = docId#chunk-N` | 同一块再现 → **合并**(hitReasons 取并集) | 同一片段只出现一次 |
| 单文档上限 | `docId`(分桶) | 同文档**不同** chunk 可共存,最多 2 | 防单文档刷屏 |
| 总条数 | — | 最多 5 | 上下文预算 |
**合并键是 evidenceKey 而不是 docId**——同文档的第 0 段和第 1 段是两份不同证据,按 docId
合并会把多片段证据文档级折叠掉。单次检索正常不会返回同一个 chunk 两次(每个 chunk 是唯一
索引条目),合并分支是给"索引脏数据 / 未来多路归并"准备的**防御性兜底**,成本一次 map 查询。
### 3.2 为什么会同文档多块命中——根源在分块
入库时文档切成多个 chunk,每块独立 embedding、独立索引;检索按 chunk 算相似度。
这是刻意的颗粒度选择:整篇文档一个向量会稀释语义,且上下文也塞不下全文。
**切块是为了检索得准、取得少;同文档多块命中是切块的自然结果;
"chunk 级身份 + 单文档上限"就是为管理这个现象而生的。**
### 3.3 `rerankTrace`:最终序账本
```java
Item { finalRank; source; baseScore; finalScore; boostReasons }
```
- 条数 = 存活块数(finalRank 连续编号);被合并/截断的块不产生条目;
- `baseScore == finalScore` **恒相等**——双字段是规则加分时代的化石(当年关键词加分,
现已废除防操纵);`boostReasons` 同理,装的已是纯解释标签;
- 只记到**文档级**(无 evidenceKey),chunk 身份要看 `evidenceBlocks[]`;
- Agent 看不到它,审计默认不落库——活在内存 LookupResult 与评测快照里。
### 3.4 附带闸门
`isLowQuality()`:无可用块或顶分 <0.5 即低质。原触发 filtered→unfiltered 重试,
L0 下沉后分支休眠、闸门保留——将来 Java 侧重引过滤策略可直接接上。
---
## 4. 站点四:打包——`ContextPack`(不是 Agent 口粮)
`KnowledgeContextPacker` 把证据块压成**一段有字符预算的文本**(默认 4000 字符):
```text
策略 ranked_evidence_char_budget:名次即优先级,先到先得
[Evidence 1]
source: gateway-timeout.md
breadcrumb: 网关超时排查 > 处理步骤
reasons: semantic_rank:1, attempt:UNFILTERED_VECTOR ← 召回溯源标签
content:
网关超时先检查 upstream 配置…
预算见底:装得下 header → 正文截断加 "...";连 header 都放不下 → 整条进 omittedSources
```
```java
ContextPack { packedText; strategy; charBudget; usedChars; includedSources; omittedSources }
```
**必须澄清的定位**:主诊断链路里 **Agent 不消费 packedText**(主代码零调用)——Agent 拿的是
站点六投影后的结构化列表。它的价值:人工回放可读、评测快照存证、以及将来任何
"证据进 prompt"路径的现成格式化出口(字符预算是与后处理"块数预算"互补的物理闸门)。
`reasons:` 行是证据的"简历":`semantic_rank:N`(本次检索第几名)+ `attempt:X`(哪次尝试产出,
当前只剩 `UNFILTERED_VECTOR`;历史值 `FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` 已随
L0 下沉绝迹)。
> 小化石:`ContextPack` 的 javadoc 仍写 "Agent-facing",与 packer 侧注释矛盾——
> 它诞生时确实面向 Agent,投影路线成为主路径后退居内部,注释没跟上身份变化。
---
## 5. 站点五:组装——`LookupResult` 真相全集
`LookupResultAssembler` 把三样东西合体成内部契约出口:
```text
EvidencePostprocessResult(证据集+档位+trace)┐
ContextPack(打包文本) ├─► LookupResult
RetrievalTrace(检索路径) ┘
```
只有两个字段是"算"出来的:`found = hasUsableEvidence()`(false 时附固定兜底文案
"知识库未检索到可用证据,请结合日志、指标、告警继续排查");两个 count 把"进多少/出多少"
带给审计。其余字段一一搬运。
---
## 6. 站点六:投影——`RagResultProjector`(加工链最后一站)
**输入是 LookupResult 的 JSON 字符串而非对象**——内部结构随便演化,冻结契约纹丝不动,
解耦的关键就是这层"字符串边界"(字段名还带防御性别名对:`evidenceBlocks`/`evidence_blocks`)。
```text
① query 截断(≤500 字) 动了 → truncated
② 逐块过三道闸:
条数 ≤8(超出 truncated+停止)
身份去重(evidenceKey → document_id → docId#chunk-N → 序号兜底 的回退链)
摘录 ≤1200 字(截断 → truncated)
③ evidence_status 客观判定:数组空不空(EVIDENCE_FOUND / NO_EVIDENCE)
④ relevance_level:有证据才读,解析不出 → null
⑤ fitBudget 总字节兜底:整个 JSON 超 16KB → 从尾部逐条裁
裁到空 → 诚实降级 NO_EVIDENCE;还超 → 抛异常(fail closed)
```
**三道递进预算闸**(条数管语义 / 片段管局部 / 字节管整体)集中在 `ToolProjectionLimits`
一个 record(8 条 / 1200 字 / 16KB,与日志、MySQL 工具共用)。**`truncated` 只要任何一处
动过就置位——Agent 永远知道"看到的可能不全"**,不会把截断结果当全量。
---
## 7. 站点七:Agent 视图——模型实际看到的 JSON
```json
{
"evidence_status": "EVIDENCE_FOUND",
"tool_call_id": "call_x1",
"query": "网关超时怎么排查",
"evidence": [
{ "document_id": "e2e-gateway-…-34223174#chunk-1",
"source": "e2e-gateway-b9c1fa12-md",
"title": "网关超时排查",
"breadcrumb": "网关超时排查 > 处理步骤",
"excerpt": "网关超时先检查 upstream 配置…" }
],
"returned_count": 5,
"relevance_level": "PRECISE",
"truncated": false
}
```
| 字段 | 模型的正确用法 |
|---|---|
| `evidence_status` | `NO_EVIDENCE` → 老实换工具(日志/指标/MySQL),不许编 |
| `evidence[].excerpt` | 结论唯一的内容依据 |
| `evidence[].document_id` | **引用坐标**——报告里逐字引用它,EvidenceGuard 只认这个 |
| `relevance_level` | PRECISE 可放心下结论;REFERENCE 结合上下文判断,必要时说清还缺什么维度 |
| `truncated` | true → 看到的可能不全,可收窄 query 重搜 |
**三个"看不到"**:分数(防未校准数字诱导过度自信)、过程(attempt/trace 留给人)、
其他站的内部字段。一句话:**留坐标、留内容、留诚实,剥掉一切会误导或撑爆上下文的东西。**
---
## 8. 站点八:验真——`EvidenceGuard`(证据安全链第一道闸)
Agent 写完诊断产出结构化草稿 `DiagnosisDraft`(analysis 带引用的 toolCallIds,
conclusion/action_plan 带 basedOnAnalysisIds,limitations 必填)。EvidenceGuard 纯规则验真:
```text
A 结构校验 id 唯一、正文非空、报告引用必须指向已登记分析、limitations 必填
B 引用验真 runId+toolCallId → Redis 账本可查 → READY 状态 → 同 Run(防跨 Run 挪用)
→ kind 语义匹配:NORMAL↔EVIDENCE_FOUND / NEGATIVE_OBSERVATION↔NO_EVIDENCE
C 重读重建 从账本严格反序列化投影(多一个字段都违规)→ 用账本内容重建证据快照
```
四个设计点:
1. **证据内容从账本重读,不信模型复述**——模型转述的"检索结果"进不了快照;
2. **kind 匹配堵两头撒谎**——"查到了装没查到"与"没查到装查到了"都过不去;
3. **runId 绑定 + READY**——别的 Run 的证据、失败/过期的调用不可引用;
4. **纯规则、20 个违规码全枚举**——便宜、确定、可审计;"结论是否夸大"留给下一道
SemanticGuard,这里只回答"引用是否真实、结构是否合法"。
---
## 9. 附:模型怎么知道要输出那份 JSON
四层合力,没有一刻依赖模型"自觉":
| 层 | 机制 |
|---|---|
| 格式 | `DiagnosisDraftOutputSchema`(BeanOutputConverter)从 Java 类自动生成 JSON Schema 注入 prompt;解析失败抛异常 |
| 语义 | `diagnosis-agent-prompt.md`:证据充分 → 全填并建立完整引用链;证据不足 → `conclusion=null` 是合法成功(schema 层把 conclusion 类型改成 `object\|null`);limitations 无条件必填 |
| 时机 | 模型自主停止(无有效查询范围即停)+ Harness 强制(连续 NO_GAIN / 预算耗尽 → STOP_REQUIRED 后必须直接交卷) |
| 兜底 | 格式错/引用违规 → evidence-repair-prompt 修复重试 → 仍败 → 固定降级 FALLBACK |
---
## 10. 设计思路总结(五条哲学)
1. **防腐层:换引擎不换证据链**——`KnowledgeSearchPort` 是接缝,抽离时消费面零改动、
250 个测试原样通过,这是接口设计价值的最硬证明。
2. **分数给系统,档位给模型**——未校准的连续分数会诱导过度自信;`quality_score` 在
Java 侧做闸门和审计,Agent 只见 PRECISE/REFERENCE。
3. **chunk 级身份贯穿始终**——入库分块、检索按块、去重按 `docId#chunk-N`、引用坐标到块、
验真对块。颗粒度统一,才有多片段证据共存与伪引用无处遁形。
4. **诚实标记**——`truncated`、`no_evidence`、`unchanged`、`no_evidence_basis`:
系统从不假装"看到的即全部",每个不完整/为空都有显式信号与原因。
5. **所见即所证**——投影结果是"模型看到的"与"Redis 存证的"同一份;EvidenceGuard 对账
没有翻译损耗,伪引用无处遁形。
### 化石清单(读代码时的辨认指南)
| 化石 | 现状 |
|---|---|
| `RerankTrace.baseScore/finalScore` 双字段 | 恒相等(规则加分已废),保留兼容旧审计格式 |
| `boostReasons` 字段名 | 装的是纯解释标签,不再加分 |
| `ContextPack` javadoc "Agent-facing" | 身份已变(内部/储备),注释未跟上 |
| `LookupResultAssembler.deduped()` | 会话级去重回包的历史占位,主链路不再调用 |
| `FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` attempt | L0 下沉后不可达,仅存于旧 Run 回放 |
| `KnowledgeQuery` 的 L0 hint 字段 | 恒空结构,供后处理与 trace 兼容保留 |
---
## 11. 关键字段速查
| 字段 | 哪一站 | 一句话 |
|---|---|---|
| `evidence_key` | py-rag → 全程 | chunk 级身份 `docId#chunk-N`,去重与验真的锚 |
| `quality_score` | py-rag → 后处理 | rerank 绝对分 [0,1],quality 直传 |
| `evidence_status` | py-rag / 投影 | 两类:supported / no_evidence(正常业务响应) |
| `relevance_level` | 后处理 → Agent | PRECISE/REFERENCE 档位,Java 按本地阈值独立判定 |
| `originalRank` | adapter → 后处理 | 排序唯一权威,分数不动序 |
| `candidateCount / evidenceBlockCount` | 后处理 | 进多少 / 出多少,差值即治理幅度 |
| `truncated` | 投影 | 任何截断都置位,诚实标记 |
| `tool_call_ids` | Draft → EvidenceGuard | 引用验真的入口,runId 绑定 + READY 校验 |
@@ -1,11 +1,18 @@
# ISS-017 RAG L0 过滤收窄与 Fallback 加固 # ISS-017 RAG L0 过滤收窄与 Fallback 加固
**状态**:开放,暂缓实施(保持现网行为) **状态**:已失效(2026-09-29 RAG 抽离,L0 整体下沉 py-rag,问题前提不复存在)
**严重程度**:中 **严重程度**:中
**发现时间**:2026-07-28 **发现时间**:2026-07-28
**更新日期**:2026-07-28 **更新日期**:2026-09-29
**来源**:hybrid + qualityScore 收口后的评测/设计讨论;`chat-l0-filter-fallback` golden case **来源**:hybrid + qualityScore 收口后的评测/设计讨论;`chat-l0-filter-fallback` golden case
**关联**:`LookupKnowledgeTool`、`KnowledgeQueryTransformer`、`KnowledgeEvidencePostProcessor`、`eval/rag-retrieval`、历史 `rag/rag-l0-domain-entity-hint.md` **关联**:`LookupKnowledgeTool`、`KnowledgeQueryTransformer`(已删除)、`KnowledgeEvidencePostProcessor`、`eval/rag-retrieval`、历史 `rag/rag-l0-domain-entity-hint.md`
> **处理记录(2026-09-29)**:RAG 模块抽离为 py-rag 知识服务时,L0 query 理解
> (`KnowledgeQueryTransformer` / `KnowledgeIndexService.analyzeQuery`)整体下沉服务端,
> `categoryFilter` 恒为 null,`FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` 分支不再可达。
> 本 issue 讨论的「硬过滤赌一把 + 失败整页替换」路径已不存在,无需加固,关闭。
> 若未来在 Java 侧重新引入检索过滤策略,filtered→unfiltered 的降级骨架仍在
> `LookupKnowledgeTool` 中保留,届时可参考本文的改进方向与回归动机。
--- ---
+5 -1
View File
@@ -1,8 +1,12 @@
# 知识域表:knowledge_domain # 知识域表:knowledge_domain
**状态**:当前表 **状态**:孤儿表(2026-09-29 RAG 抽离后无写入方)
**来源**:`V009__add_knowledge_domain.sql`、`KnowledgeDomain` **来源**:`V009__add_knowledge_domain.sql`、`KnowledgeDomain`
> **2026-09-29 变更**:本表的写入方 `KnowledgeDomainService` 已随 RAG 模块抽离删除
> (L0 domain 分析下沉 py-rag)。表结构由 Flyway 保留(`ddl-auto: validate`),当前无读写方,
> 待后续迁移清理。历史数据仅供追溯。
## 定位 ## 定位
`knowledge_domain` 保存知识库领域级元数据,为 RAG backend 的 domain hint、检索选择和可观测性提供基础信息;它不是 Agent-facing Tool contract。 `knowledge_domain` 保存知识库领域级元数据,为 RAG backend 的 domain hint、检索选择和可观测性提供基础信息;它不是 Agent-facing Tool contract。
+6 -17
View File
@@ -76,12 +76,6 @@
<artifactId>spring-ai-starter-model-deepseek</artifactId> <artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency> </dependency>
<!-- Embedding: SiliconFlow BGE-M3 (需要 OpenAI 模块的 OpenAiEmbeddingModel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.alibaba.cloud.ai</groupId> <groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-agent-framework</artifactId> <artifactId>spring-ai-alibaba-agent-framework</artifactId>
@@ -91,19 +85,14 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- <!--
spring-boot-devtools removed on purpose. spring-boot-devtools removed on purpose (bean recreation on classpath restart
Classpath restart (restartedMain) recreates beans without reliably closing is unreliable). Prefer full process restart: stop then `mvn spring-boot:run`.
MilvusClientV2 gRPC channels, causing orphan channels and long hybrid RPC retries.
Prefer full process restart: stop then `mvn spring-boot:run`.
--> -->
<!-- okhttp:DashScopeConfig 的 RestClient.Builder 使用(此前由 milvus-sdk 传递引入) -->
<dependency> <dependency>
<groupId>io.milvus</groupId> <groupId>com.squareup.okhttp3</groupId>
<artifactId>milvus-sdk-java</artifactId> <artifactId>okhttp</artifactId>
<version>2.6.10</version> <version>4.12.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
-106
View File
@@ -1,106 +0,0 @@
# 重建 hybrid 知识库(dense + BM25)
面向当前 `knowledge_base/` 目录文档,**清空并重建**配置中的 Milvus collection(默认 **`biz`**)。
## 前提
1. 应用已启动(默认 `http://localhost:9900`)
2. `MILVUS_TOKEN` 等连接配置可用
3. `application.yml` 已配置:
```yaml
milvus:
collection: biz
retrieval:
search:
mode: hybrid
knowledge:
base-path: knowledge_base/
```
## 一键脚本(Python)
在项目根目录执行:
```bash
python scripts/rebuild_hybrid_knowledge.py --confirm REBUILD
```
指定服务地址:
```bash
python scripts/rebuild_hybrid_knowledge.py --base-url http://127.0.0.1:9900 --confirm REBUILD
```
跳过前后 stats:
```bash
python scripts/rebuild_hybrid_knowledge.py --confirm REBUILD --skip-stats
```
依赖:Python 3.9+ 标准库即可(无需 pip 包)。
## 脚本会做什么
| 步骤 | 动作 |
|---|---|
| 1 | 检查 `/milvus/health` |
| 2 | 打印重建前 `/api/knowledge/stats` |
| 3 | `POST /api/knowledge/rebuild-hybrid?confirm=REBUILD` |
| 4 | 打印重建后 stats |
服务端 `rebuild-hybrid` 内部顺序:
1. **Drop + recreate** Milvus collection(`milvus.collection`,默认 `biz`)
- 原有向量数据会被删除
- 按 dense + BM25 schema 重建
2. **清空** MySQL `api_document`
3. **清空** 内存 L0 索引
4. **扫描** `knowledge_base/**/*.md`(跳过 `README.md`)并 force 全量导入
- 写 MySQL 元数据
- 切片
- 写 dense 向量 + BM25 `search_text`
- 更新 L0
## 不会做什么
- **不会**动 `knowledge_base/` 源文件
- **不会**在未传 `--confirm REBUILD` 时执行
## 手动 curl 等价命令
```bash
# 重建(危险:会清空 biz collection + api_document)
curl -X POST "http://localhost:9900/api/knowledge/rebuild-hybrid?confirm=REBUILD"
# 仅强制导入(不 drop collection)
curl -X POST "http://localhost:9900/api/knowledge/init?force=true"
# 统计
curl "http://localhost:9900/api/knowledge/stats"
```
## 成功判据
响应中大致应有:
```json
{
"success": true,
"collection": "biz",
"inserted": 15,
"failed": 0,
"milvus": { "recreated": true, "loaded": true }
}
```
然后用一条知识库里真实存在的术语/故障词走 `lookup_knowledge` 或 chat 验证 hybrid 命中。
## 失败排查
| 现象 | 可能原因 |
|---|---|
| connect / token 错误 | `MILVUS_TOKEN`、host、database |
| BM25 / analyzer 相关报错 | 云端 Milvus/Zilliz 版本不支持 BM25 Function |
| inserted=0 | `knowledge_base` 路径不对,或 md 缺 frontmatter/title |
| failed>0 | 看响应 `details` 与应用日志 |
-292
View File
@@ -1,292 +0,0 @@
#!/usr/bin/env python3
"""Live acceptance runner for post-reindex RAG retrieval checks.
This script calls the running Spring Boot retrieval endpoint. It is intentionally
separate from the offline fixture baseline because it depends on live service and
Milvus/Zilliz state.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_BASE_URL = "http://127.0.0.1:9900"
DEFAULT_JSON_REPORT = Path("eval/rag-retrieval/reports/live-post-reindex.json")
DEFAULT_MD_REPORT = Path("eval/rag-retrieval/reports/live-post-reindex.md")
DEFAULT_CASES: list[dict[str, Any]] = [
{
"caseId": "breadcrumb-rag-chunk-context",
"query": "If a long RAG section is split into multiple chunks, how do we keep retrieval context?",
"topK": 5,
"purpose": "Breadcrumb-sensitive RAG chunk context retrieval.",
},
{
"caseId": "breadcrumb-diagnosis-flow",
"query": "What is the standard troubleshooting flow for an application incident?",
"topK": 5,
"purpose": "Process-style retrieval where section path matters.",
},
{
"caseId": "core-err-timeout",
"query": "ERR_TIMEOUT",
"topK": 3,
"purpose": "Exact error-code retrieval should remain stable.",
},
{
"caseId": "core-mysql-connection-pool",
"query": "MySQL connection pool is exhausted. How should I diagnose it?",
"topK": 3,
"purpose": "Core infrastructure troubleshooting retrieval.",
},
{
"caseId": "aiops-payment-latency",
"query": "Alert HighLatency on payment-service with p95 latency above threshold",
"topK": 3,
"purpose": "AIOps alert-style retrieval.",
},
]
@dataclass
class LiveCase:
case_id: str
query: str
top_k: int
purpose: str
category: str | None = None
@classmethod
def from_json(cls, raw: dict[str, Any]) -> "LiveCase":
return cls(
case_id=str(raw["caseId"]),
query=str(raw["query"]),
top_k=int(raw.get("topK") or 3),
purpose=str(raw.get("purpose") or raw.get("notes") or ""),
category=(
str(raw.get("category"))
if raw.get("category") not in (None, "")
else None
),
)
def load_cases(path: Path | None) -> list[LiveCase]:
if path is None:
return [LiveCase.from_json(item) for item in DEFAULT_CASES]
with path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
raw_cases = payload.get("cases", payload)
return [LiveCase.from_json(item) for item in raw_cases]
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
def write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
def request_case(base_url: str, case: LiveCase, timeout_seconds: float) -> dict[str, Any]:
endpoint = base_url.rstrip("/") + "/api/search/similar"
params: dict[str, str] = {
"query": case.query,
"topK": str(case.top_k),
}
if case.category:
params["category"] = case.category
url = endpoint + "?" + urllib.parse.urlencode(params)
started_at = datetime.now(timezone.utc)
try:
with urllib.request.urlopen(url, timeout=timeout_seconds) as response:
body = response.read().decode("utf-8")
payload = json.loads(body)
status = int(getattr(response, "status", 200))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
return {
"caseId": case.case_id,
"query": case.query,
"topK": case.top_k,
"category": case.category,
"purpose": case.purpose,
"url": url,
"ok": False,
"error": str(exc),
"resultCount": 0,
"topCandidates": [],
"rawResponse": None,
"startedAt": started_at.isoformat(),
}
data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, list):
data = []
ok = status == 200 and payload.get("code") == 200
return {
"caseId": case.case_id,
"query": case.query,
"topK": case.top_k,
"category": case.category,
"purpose": case.purpose,
"url": url,
"ok": ok,
"httpStatus": status,
"responseCode": payload.get("code"),
"responseMessage": payload.get("message"),
"resultCount": len(data),
"topCandidates": [summarize_candidate(item, index + 1) for index, item in enumerate(data)],
"rawResponse": payload,
"startedAt": started_at.isoformat(),
}
def summarize_candidate(raw: dict[str, Any], rank: int) -> dict[str, Any]:
metadata = parse_metadata(raw.get("metadata"))
return {
"rank": rank,
"id": raw.get("id"),
"title": metadata.get("title"),
"breadcrumb": metadata.get("breadcrumb"),
"category": metadata.get("category"),
"source": metadata.get("_source") or metadata.get("source"),
"score": raw.get("score"),
"rawScore": raw.get("rawScore"),
"scoreLabel": raw.get("scoreLabel"),
"contentPreview": preview(raw.get("content")),
}
def parse_metadata(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def preview(value: Any, limit: int = 180) -> str:
text = " ".join(str(value or "").split())
if len(text) <= limit:
return text
return text[: limit - 3] + "..."
def render_markdown(report: dict[str, Any]) -> str:
lines = [
"# RAG Live Post-Reindex Acceptance",
"",
f"Generated at: `{report['generatedAt']}`",
f"Base URL: `{report['baseUrl']}`",
"",
"> Reindex prerequisite: this report only reflects breadcrumb-aware embedding if the knowledge base was reindexed after the embedding-text change.",
"",
"## Summary",
"",
"| Metric | Value |",
"|---|---:|",
f"| Cases | {report['caseCount']} |",
f"| Successful calls | {report['successfulCalls']} |",
f"| Empty result cases | {report['emptyResultCases']} |",
"",
"## Cases",
"",
"| Case | Purpose | Results | Top Candidates |",
"|---|---|---:|---|",
]
for item in report["results"]:
top = "<br>".join(format_candidate(candidate) for candidate in item["topCandidates"])
if not top and item.get("error"):
top = "ERROR: " + str(item["error"])
lines.append(
"| {case} | {purpose} | {count} | {top} |".format(
case=item["caseId"],
purpose=item.get("purpose") or "",
count=item["resultCount"],
top=top,
)
)
lines.append("")
return "\n".join(lines)
def format_candidate(candidate: dict[str, Any]) -> str:
label = candidate.get("title") or candidate.get("source") or candidate.get("id") or ""
breadcrumb = candidate.get("breadcrumb") or ""
score_label = candidate.get("scoreLabel") or ""
score = candidate.get("score")
raw_score = candidate.get("rawScore")
details = f"score={score}"
if raw_score is not None:
details += f", raw={raw_score}"
if score_label:
details += f", label={score_label}"
if breadcrumb:
return f"{candidate['rank']}. {label} ({breadcrumb}; {details})"
return f"{candidate['rank']}. {label} ({details})"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--cases", type=Path, default=None)
parser.add_argument("--json-report", type=Path, default=DEFAULT_JSON_REPORT)
parser.add_argument("--markdown-report", type=Path, default=DEFAULT_MD_REPORT)
parser.add_argument("--timeout-seconds", type=float, default=10.0)
return parser.parse_args()
def main() -> int:
args = parse_args()
cases = load_cases(args.cases)
results = [
request_case(args.base_url, case, args.timeout_seconds)
for case in cases
]
successful = [item for item in results if item["ok"]]
empty = [item for item in results if item["ok"] and item["resultCount"] == 0]
report = {
"generatedAt": datetime.now(timezone.utc).isoformat(),
"baseUrl": args.base_url,
"caseCount": len(results),
"successfulCalls": len(successful),
"emptyResultCases": len(empty),
"reindexPrerequisite": "Run or trigger knowledge-base reindex before treating this as breadcrumb-aware embedding evidence.",
"results": results,
}
write_json(args.json_report, report)
write_text(args.markdown_report, render_markdown(report))
print(
"Ran {total} live cases: successful={successful}, empty={empty}".format(
total=len(results),
successful=len(successful),
empty=len(empty),
)
)
return 1 if len(successful) != len(results) else 0
if __name__ == "__main__":
raise SystemExit(main())
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""Rebuild knowledge into the configured Milvus collection (default: biz).
Clears:
- milvus.collection (drop + recreate dense+BM25 schema)
- MySQL api_document
- in-memory L0 index
Then force-imports all markdown under server-side knowledge.base-path
(default: knowledge_base/).
Usage:
# start Spring Boot first, then:
python scripts/rebuild_hybrid_knowledge.py --confirm REBUILD
python scripts/rebuild_hybrid_knowledge.py --base-url http://127.0.0.1:9900 --confirm REBUILD
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.request
from typing import Any
DEFAULT_BASE_URL = "http://localhost:9900"
def http_json(method: str, url: str, timeout: float = 3600.0) -> tuple[int, Any]:
req = urllib.request.Request(url=url, method=method.upper())
req.add_header("Accept", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
status = getattr(resp, "status", 200)
if not raw.strip():
return status, None
return status, json.loads(raw)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
body: Any
try:
body = json.loads(raw) if raw.strip() else None
except json.JSONDecodeError:
body = raw
raise RuntimeError(f"HTTP {method} {url} failed status={exc.code}: {body}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"HTTP {method} {url} failed: {exc}") from exc
def pretty(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, indent=2)
def step(title: str) -> None:
print()
print(f"==> {title}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Drop/recreate milvus.collection (default biz), clear MySQL api_document + L0, "
"and reimport knowledge_base markdown into dense+BM25."
)
parser.add_argument(
"--base-url",
default=DEFAULT_BASE_URL,
help=f"Service base URL (default: {DEFAULT_BASE_URL})",
)
parser.add_argument(
"--confirm",
required=True,
choices=["REBUILD"],
help="Must be REBUILD to execute destructive rebuild",
)
parser.add_argument(
"--skip-stats",
action="store_true",
help="Skip before/after /api/knowledge/stats",
)
parser.add_argument(
"--timeout",
type=float,
default=7200.0,
help="Rebuild request timeout seconds (default: 7200)",
)
args = parser.parse_args(argv)
base_url = args.base_url.rstrip("/")
print("Hybrid knowledge rebuild")
print(f" BaseUrl : {base_url}")
print(f" Confirm : {args.confirm}")
print(" Source : knowledge_base/ (server-side knowledge.base-path)")
print()
print("This will DESTROY data in:")
print(" - Milvus collection milvus.collection (default: biz)")
print(" - MySQL table api_document")
print(" - In-memory L0 knowledge index")
print("Then re-import all markdown under knowledge_base.")
print()
# 1) health
step("Check service health")
try:
status, body = http_json("GET", f"{base_url}/milvus/health", timeout=30)
print(f" milvus health status={status}")
print(pretty(body))
except Exception as exc: # noqa: BLE001 - ops script should continue on soft health failure
print(f" WARN: /milvus/health failed: {exc}")
print(" Continue if app is up but milvus health endpoint has issues.")
# 2) stats before
if not args.skip_stats:
step("Knowledge stats (before)")
try:
_, body = http_json("GET", f"{base_url}/api/knowledge/stats", timeout=30)
print(pretty(body))
except Exception as exc: # noqa: BLE001
print(f" WARN: stats before failed: {exc}")
# 3) rebuild
step("POST /api/knowledge/rebuild-hybrid?confirm=REBUILD")
rebuild_url = f"{base_url}/api/knowledge/rebuild-hybrid?confirm={args.confirm}"
try:
status, body = http_json("POST", rebuild_url, timeout=args.timeout)
except RuntimeError as exc:
print(str(exc))
return 1
print(f" HTTP {status}")
print(pretty(body))
if not isinstance(body, dict):
print("Unexpected rebuild response type", file=sys.stderr)
return 1
inserted = int(body.get("inserted") or 0)
failed = int(body.get("failed") or 0)
success = bool(body.get("success"))
if not success:
if inserted <= 0:
print()
print("Rebuild reported failure and inserted=0. Inspect details above.", file=sys.stderr)
return 2
print()
print(f"Rebuild finished with failed={failed} inserted={inserted}. Review details.")
else:
print()
print(f"Rebuild OK: inserted={inserted}, failed={failed}")
# 4) stats after
if not args.skip_stats:
step("Knowledge stats (after)")
try:
_, body = http_json("GET", f"{base_url}/api/knowledge/stats", timeout=30)
print(pretty(body))
except Exception as exc: # noqa: BLE001
print(f" WARN: stats after failed: {exc}")
print()
print("Done.")
print("Next:")
print(" 1) Ensure application.yml has:")
print(" milvus.collection: biz")
print(" retrieval.search.mode: hybrid")
print(" 2) Smoke test lookup_knowledge / chat with a known doc query")
return 0 if success or inserted > 0 else 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,196 +0,0 @@
package com.superbiz.agent.client;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.DataType;
import io.milvus.param.ConnectParam;
import io.milvus.param.IndexType;
import io.milvus.param.MetricType;
import io.milvus.param.R;
import io.milvus.param.RpcStatus;
import io.milvus.param.collection.*;
import io.milvus.param.index.CreateIndexParam;
import com.superbiz.agent.config.MilvusProperties;
import com.superbiz.agent.constant.MilvusConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Milvus 客户端工厂类
* 负责创建和初始化 Milvus 客户端连接
*/
@Component
public class MilvusClientFactory {
private static final Logger logger = LoggerFactory.getLogger(MilvusClientFactory.class);
@Autowired
private MilvusProperties milvusProperties;
/**
* 创建并初始化 Milvus 客户端
*
* 简化版本:直接连接并创建 collection
*
* @return MilvusServiceClient 实例
* @throws RuntimeException 如果连接或初始化失败
*/
public MilvusServiceClient createClient() {
MilvusServiceClient client = null;
try {
// 1. 连接到 Milvus
logger.info("正在连接到 Milvus: {}:{}", milvusProperties.getHost(), milvusProperties.getPort());
client = connectToMilvus();
logger.info("成功连接到 Milvus");
// 2. 检查并创建 biz collection(如果不存在)
if (!collectionExists(client, MilvusConstants.MILVUS_COLLECTION_NAME)) {
logger.info("collection '{}' 不存在,正在创建...", MilvusConstants.MILVUS_COLLECTION_NAME);
createBizCollection(client);
logger.info("成功创建 collection '{}'", MilvusConstants.MILVUS_COLLECTION_NAME);
// 创建索引
createIndexes(client);
logger.info("成功创建索引");
} else {
logger.info("collection '{}' 已存在", MilvusConstants.MILVUS_COLLECTION_NAME);
}
// 3. 加载 collection 到内存(搜索必须)
logger.info("正在加载 collection '{}' 到内存...", MilvusConstants.MILVUS_COLLECTION_NAME);
R<RpcStatus> loadResp = client.loadCollection(LoadCollectionParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.build());
if (loadResp.getStatus() == 0) {
logger.info("collection '{}' 已加载", MilvusConstants.MILVUS_COLLECTION_NAME);
} else {
logger.warn("collection '{}' 加载失败: {}", MilvusConstants.MILVUS_COLLECTION_NAME, loadResp.getMessage());
}
return client;
} catch (Exception e) {
logger.error("创建 Milvus 客户端失败", e);
if (client != null) {
client.close();
}
throw new RuntimeException("创建 Milvus 客户端失败: " + e.getMessage(), e);
}
}
/**
* 连接到 Milvus
*/
private MilvusServiceClient connectToMilvus() {
ConnectParam.Builder builder = ConnectParam.newBuilder()
.withHost(milvusProperties.getHost())
.withPort(milvusProperties.getPort())
.withDatabaseName(milvusProperties.getDatabase())
.withConnectTimeout(milvusProperties.getTimeout(), TimeUnit.MILLISECONDS);
// Zilliz Cloud: token + SSL
if (milvusProperties.getToken() != null && !milvusProperties.getToken().isEmpty()) {
builder.withToken(milvusProperties.getToken());
builder.withSecure(true);
}
// 本地 Milvus: username + password
else if (milvusProperties.getUsername() != null && !milvusProperties.getUsername().isEmpty()) {
builder.withAuthorization(milvusProperties.getUsername(), milvusProperties.getPassword());
}
return new MilvusServiceClient(builder.build());
}
/**
* 检查 collection 是否存在
*/
private boolean collectionExists(MilvusServiceClient client, String collectionName) {
R<Boolean> response = client.hasCollection(HasCollectionParam.newBuilder()
.withCollectionName(collectionName)
.build());
if (response.getStatus() != 0) {
throw new RuntimeException("检查 collection 失败: " + response.getMessage());
}
return response.getData();
}
/**
* 创建 biz collection
*/
private void createBizCollection(MilvusServiceClient client) {
// 定义字段
FieldType idField = FieldType.newBuilder()
.withName("id")
.withDataType(DataType.VarChar)
.withMaxLength(MilvusConstants.ID_MAX_LENGTH)
.withPrimaryKey(true)
.build();
FieldType vectorField = FieldType.newBuilder()
.withName("vector")
.withDataType(DataType.FloatVector) // 改为 FloatVector
.withDimension(milvusProperties.getVectorDim())
.build();
FieldType contentField = FieldType.newBuilder()
.withName("content")
.withDataType(DataType.VarChar)
.withMaxLength(MilvusConstants.CONTENT_MAX_LENGTH)
.build();
FieldType metadataField = FieldType.newBuilder()
.withName("metadata")
.withDataType(DataType.JSON)
.build();
// 创建 collection schema
CollectionSchemaParam schema = CollectionSchemaParam.newBuilder()
.withEnableDynamicField(false)
.addFieldType(idField)
.addFieldType(vectorField)
.addFieldType(contentField)
.addFieldType(metadataField)
.build();
// 创建 collection
CreateCollectionParam createParam = CreateCollectionParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withDescription("Business knowledge collection")
.withSchema(schema)
.withShardsNum(MilvusConstants.DEFAULT_SHARD_NUMBER)
.build();
R<RpcStatus> response = client.createCollection(createParam);
if (response.getStatus() != 0) {
throw new RuntimeException("创建 collection 失败: " + response.getMessage());
}
}
/**
* 为 collection 创建索引
*/
private void createIndexes(MilvusServiceClient client) {
// 为 vector 字段创建索引(FloatVector 使用 IVF_FLAT 和 L2 距离)
CreateIndexParam vectorIndexParam = CreateIndexParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withFieldName("vector")
.withIndexType(IndexType.IVF_FLAT)
.withMetricType(MetricType.L2) // L2 距离(欧氏距离)
.withExtraParam("{\"nlist\":128}")
.withSyncMode(Boolean.FALSE)
.build();
R<RpcStatus> response = client.createIndex(vectorIndexParam);
if (response.getStatus() != 0) {
throw new RuntimeException("创建 vector 索引失败: " + response.getMessage());
}
logger.info("成功为 vector 字段创建索引");
}
}
@@ -0,0 +1,286 @@
package com.superbiz.agent.client;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import com.superbiz.agent.config.PyRagProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Supplier;
/**
* py-rag 知识服务 HTTP 客户端(API v1,契约见 py-rag 仓库 docs/Java接入文档.md)。
*
* <p>错误信封:4xx/5xx 一律 {@code {"error":{"code":"E_XXX","message":…,"details":[…]}}},
* 统一抛出 {@link PyRagClientException};网络异常包装为 {@code E_NETWORK}。
* {@code evidence_status=no_evidence} 是 200 正常业务响应,不作为错误。</p>
*
* <p>每个请求携带 {@code X-Request-ID}(UUID)用于跨服务日志关联;
* 超时按接入文档矩阵分端点配置(见 {@link PyRagProperties})。</p>
*/
@Slf4j
@Component
public class PyRagClient {
private static final String REQUEST_ID_HEADER = "X-Request-ID";
private final PyRagProperties properties;
private final ObjectMapper objectMapper;
private final RestClient searchClient;
private final RestClient ingestClient;
private final RestClient defaultClient;
public PyRagClient(PyRagProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
this.searchClient = buildRestClient(properties.getSearchReadTimeoutMs());
this.ingestClient = buildRestClient(properties.getIngestReadTimeoutMs());
this.defaultClient = buildRestClient(properties.getDefaultReadTimeoutMs());
}
private RestClient buildRestClient(int readTimeoutMs) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(properties.getConnectTimeoutMs());
factory.setReadTimeout(readTimeoutMs);
return RestClient.builder()
.baseUrl(properties.getBaseUrl())
.requestFactory(factory)
.build();
}
// ── 检索 ────────────────────────────────────────────────
public PyRagSearchResponse search(PyRagSearchRequest request) {
return exchange(() -> searchClient.post()
.uri("/api/v1/search")
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.body(request), PyRagSearchResponse.class);
}
// ── 文档入库 ────────────────────────────────────────────
/**
* multipart 文档入库。category 为契约必填;title/breadcrumb/kbScope 可选(null 不传)。
* 同内容重复上传返回 unchanged(幂等),网络超时可安全重试。
*/
public PyRagIngestResponse ingest(String filename,
byte[] content,
String contentType,
String category,
String title,
String breadcrumb,
String kbScope) {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new ByteArrayResource(content) {
@Override
public String getFilename() {
return filename;
}
});
body.add("category", category);
if (title != null && !title.isBlank()) {
body.add("title", title);
}
if (breadcrumb != null && !breadcrumb.isBlank()) {
body.add("breadcrumb", breadcrumb);
}
if (kbScope != null && !kbScope.isBlank()) {
body.add("kb_scope", kbScope);
}
return exchange(() -> ingestClient.post()
.uri("/api/v1/documents:ingest")
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString())
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(body), PyRagIngestResponse.class);
}
// ── 全量重建(异步任务) ────────────────────────────────
/** 202 返回任务号;已有 rebuild 执行中抛 E_REBUILD_IN_PROGRESS。 */
public PyRagTaskAccepted rebuild() {
return exchange(() -> defaultClient.post()
.uri("/api/v1/collections:rebuild?confirm=REBUILD")
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of()), PyRagTaskAccepted.class);
}
public PyRagTaskStatus task(String taskId) {
return exchange(() -> defaultClient.get()
.uri("/api/v1/tasks/{id}", taskId)
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString()), PyRagTaskStatus.class);
}
// ── 统计与健康 ──────────────────────────────────────────
public PyRagStats stats() {
return exchange(() -> defaultClient.get()
.uri("/api/v1/stats")
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString()), PyRagStats.class);
}
public PyRagHealth health() {
return exchange(() -> defaultClient.get()
.uri("/api/v1/health")
.header(REQUEST_ID_HEADER, UUID.randomUUID().toString()), PyRagHealth.class);
}
// ── 内部 ────────────────────────────────────────────────
private <T> T exchange(Supplier<RestClient.RequestHeadersSpec<?>> spec, Class<T> type) {
try {
return spec.get().exchange((request, response) -> {
if (response.getStatusCode().isError()) {
throw toClientException(response.getStatusCode().value(), response.getBody());
}
return objectMapper.readValue(response.getBody(), type);
});
} catch (PyRagClientException e) {
throw e;
} catch (Exception e) {
log.error("py-rag 调用失败: {}", e.getMessage(), e);
throw new PyRagClientException("E_NETWORK",
"py-rag 调用失败: " + e.getMessage(), null, e);
}
}
private PyRagClientException toClientException(int httpStatus, java.io.InputStream body) {
String code = "E_HTTP_" + httpStatus;
String message = "HTTP " + httpStatus;
try {
PyRagErrorEnvelope envelope = objectMapper.readValue(body, PyRagErrorEnvelope.class);
if (envelope != null && envelope.error() != null) {
code = envelope.error().code() == null ? code : envelope.error().code();
message = envelope.error().message() == null ? message : envelope.error().message();
}
} catch (Exception ignored) {
// 错误响应体不是契约信封(如网关 502 页面),保留 HTTP 默认语义
}
return new PyRagClientException(code, message, httpStatus, null);
}
// ── 契约 DTO(snake_case 对齐 py-rag API) ──────────────
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record PyRagSearchRequest(
String query,
/** hybrid=dense+BM25 融合;semantic=纯向量 */
String mode,
Integer retrieveK,
Integer returnN,
Integer maxChunksPerDocument,
String category,
String kbScope
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagSearchHit(
String evidenceKey,
String documentId,
String source,
String title,
String breadcrumb,
String excerpt,
Double qualityScore,
String relevanceLevel
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagRetrievalTrace(
String mode,
Map<String, Object> filters,
Integer recallCount,
String rerankModel,
String noEvidenceBasis
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagSearchResponse(
String query,
String mode,
List<PyRagSearchHit> hits,
String relevanceLevel,
/** supported | no_evidence(no_evidence 时 hits=[],属正常业务响应) */
String evidenceStatus,
PyRagRetrievalTrace retrievalTrace
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagIngestResponse(
String docId,
String source,
/** created | updated | unchanged */
String status,
Integer chunkCount,
List<String> warnings,
Map<String, Object> frontmatter
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagTaskAccepted(
String taskId,
String status
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagTaskStatus(
String taskId,
String status,
Integer documents,
String detail,
String createdAt,
String finishedAt
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagStats(
String collection,
Integer rowCount
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagHealth(
String status,
String version,
Map<String, String> checks
) {
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonIgnoreProperties(ignoreUnknown = true)
public record PyRagErrorEnvelope(ErrorBody error) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ErrorBody(String code, String message, List<Map<String, Object>> details) {
}
}
}
@@ -0,0 +1,25 @@
package com.superbiz.agent.client;
import lombok.Getter;
/**
* py-rag 调用异常:携带契约错误码(E_*)与 HTTP 状态。
*
* <p>调用方按错误码分支:422 参数/数据问题不重试;
* 409 E_REBUILD_IN_PROGRESS 延迟重试;网络异常(E_NETWORK)可安全重试。</p>
*/
@Getter
public class PyRagClientException extends RuntimeException {
/** 契约错误码:E_INVALID_REQUEST / E_FRONTMATTER_INVALID / E_REBUILD_IN_PROGRESS / E_NETWORK 等 */
private final String code;
/** HTTP 状态码;网络层异常(未拿到响应)为 null */
private final Integer httpStatus;
public PyRagClientException(String code, String message, Integer httpStatus, Throwable cause) {
super("[" + code + "] " + message, cause);
this.code = code;
this.httpStatus = httpStatus;
}
}
@@ -1,52 +0,0 @@
package com.superbiz.agent.config;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 文档分片配置
*/
@Getter
@Configuration
@ConfigurationProperties(prefix = "document.chunk")
public class DocumentChunkConfig {
/**
* 每个分片的最大字符数(保留向后兼容)
*/
private int maxSize = 800;
/**
* 分片之间的重叠字符数
*/
private int overlap = 100;
/**
* 每个分片的最大 token 数(中文~1:1,英文~0.25:1)
* 替代 maxSize 作为切割触发器
*/
private int maxTokens = 500;
/**
* 硬上限 token 数 = maxTokens × 1.2
* 仅在不可中断上下文(列表、代码块)内触发
*/
private int maxTokensHard = 600;
public void setMaxSize(int maxSize) {
this.maxSize = maxSize;
}
public void setOverlap(int overlap) {
this.overlap = overlap;
}
public void setMaxTokens(int maxTokens) {
this.maxTokens = maxTokens;
}
public void setMaxTokensHard(int maxTokensHard) {
this.maxTokensHard = maxTokensHard;
}
}
@@ -1,27 +0,0 @@
package com.superbiz.agent.config;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
/**
* Milvus 知识路径配置说明(无额外 Bean 装配)。
*
* <p>知识库 RAG 唯一实现:{@link MilvusHybridKnowledgeStore}({@code MilvusClientV2})。</p>
* <ul>
* <li>支持 dense 与 dense+BM25 {@code hybridSearch}+RRF。</li>
* <li>不再为知识路径创建 legacy {@code MilvusServiceClient} Bean。</li>
* <li>Spring AI {@code VectorStore} starter 仍可存在于 classpath,但只作 sidecar,
* 不作 lookup_knowledge 主路径(starter 无 BM25 hybrid API)。</li>
* </ul>
*/
@Configuration
public class MilvusConfig {
private static final Logger logger = LoggerFactory.getLogger(MilvusConfig.class);
public MilvusConfig() {
logger.info("Milvus knowledge path: MilvusClientV2 hybrid store only (legacy SDK search disabled)");
}
}
@@ -1,95 +0,0 @@
package com.superbiz.agent.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "milvus")
public class MilvusProperties {
private String host = "localhost";
private Integer port = 19530;
private String username = "";
private String password = "";
private String database = "default";
private Long timeout = 10000L;
private String token = "";
private boolean secure = false;
private int vectorDim = 1024;
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public Integer getPort() {
return port;
}
public void setPort(Integer port) {
this.port = port;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getDatabase() {
return database;
}
public void setDatabase(String database) {
this.database = database;
}
public Long getTimeout() {
return timeout;
}
public void setTimeout(Long timeout) {
this.timeout = timeout;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public boolean isSecure() {
return secure;
}
public void setSecure(boolean secure) {
this.secure = secure;
}
public int getVectorDim() {
return vectorDim;
}
public void setVectorDim(int vectorDim) {
this.vectorDim = vectorDim;
}
public String getAddress() {
return host + ":" + port;
}
}
@@ -1,12 +1,10 @@
package com.superbiz.agent.config; package com.superbiz.agent.config;
import java.util.List; import java.util.List;
import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -19,11 +17,11 @@ import org.springframework.context.annotation.Primary;
* <pre>{@code * <pre>{@code
* model-routing: * model-routing:
* chat: deepseek * chat: deepseek
* embedding: siliconflow
* }</pre> * }</pre>
* <p> * <p>
* 匹配优先级:Bean 名 > 类名(均不区分大小写)。 * 匹配优先级:Bean 名 > 类名(均不区分大小写)。
* 切换模型只改 yml + pom + 对应 api-key,Java 代码不动。 * 切换模型只改 yml + pom + 对应 api-key,Java 代码不动。
* (Embedding 路由已随 RAG 模块抽离至 py-rag 服务端,此处仅路由 Chat。)
*/ */
@Configuration @Configuration
public class ModelRoutingConfig { public class ModelRoutingConfig {
@@ -33,9 +31,6 @@ public class ModelRoutingConfig {
@Value("${model-routing.chat:deepseek}") @Value("${model-routing.chat:deepseek}")
private String chatKeyword; private String chatKeyword;
@Value("${model-routing.embedding:siliconflow}")
private String embeddingKeyword;
@Bean @Bean
@Primary @Primary
public ChatModel chatModel(List<ChatModel> chatModels) { public ChatModel chatModel(List<ChatModel> chatModels) {
@@ -53,33 +48,6 @@ public class ModelRoutingConfig {
return chatModels.get(0); return chatModels.get(0);
} }
@Bean
@Primary
public EmbeddingModel embeddingModel(Map<String, EmbeddingModel> embeddingBeans) {
log.info("Embedding 路由: keyword='{}', 可用: {}", embeddingKeyword, embeddingBeans.keySet());
// 先按 Bean 名匹配
for (Map.Entry<String, EmbeddingModel> entry : embeddingBeans.entrySet()) {
if (containsIgnoreCase(entry.getKey(), embeddingKeyword)) {
log.info(" → Bean 名匹配: {} → {}", entry.getKey(),
entry.getValue().getClass().getSimpleName());
return entry.getValue();
}
}
// 再按类名匹配
for (EmbeddingModel em : embeddingBeans.values()) {
if (matches(em.getClass(), embeddingKeyword)) {
log.info(" → 类名匹配: {}", em.getClass().getSimpleName());
return em;
}
}
var first = embeddingBeans.values().iterator().next();
log.warn(" → 未匹配, 回退到 {}", first.getClass().getSimpleName());
return first;
}
private boolean matches(Class<?> clazz, String keyword) { private boolean matches(Class<?> clazz, String keyword) {
return containsIgnoreCase(clazz.getName(), keyword) return containsIgnoreCase(clazz.getName(), keyword)
|| containsIgnoreCase(clazz.getSimpleName(), keyword); || containsIgnoreCase(clazz.getSimpleName(), keyword);
@@ -0,0 +1,34 @@
package com.superbiz.agent.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* py-rag 知识服务接入配置。
*
* <p>超时矩阵来自《py-rag 知识服务 · Java 接入文档》第 6 节:
* 服务含 embedding/rerank 外呼,search 正常 300–800ms、ingest 正常 1–5s。</p>
*/
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "pyrag")
public class PyRagProperties {
/** py-rag 服务根地址,如 http://py-rag:8000 */
private String baseUrl = "http://localhost:8000";
/** 连接超时(毫秒),全端点统一 */
private int connectTimeoutMs = 3000;
/** /api/v1/search 读取超时(毫秒) */
private int searchReadTimeoutMs = 5000;
/** /api/v1/documents:ingest 读取超时(毫秒) */
private int ingestReadTimeoutMs = 30000;
/** rebuild/tasks/stats/health 读取超时(毫秒) */
private int defaultReadTimeoutMs = 10000;
}
@@ -1,23 +0,0 @@
package com.superbiz.agent.config;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Configuration
@ConfigurationProperties(prefix = "rag.sidecar.spring-ai")
public class RagSidecarProperties {
private boolean enabled = false;
private int contentPreviewLimit = 300;
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setContentPreviewLimit(int contentPreviewLimit) {
this.contentPreviewLimit = contentPreviewLimit;
}
}
@@ -1,54 +0,0 @@
package com.superbiz.agent.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
/**
* SiliconFlow Embedding 配置(BGE-M3, OpenAI 兼容协议, 1024维)
* <p>
* Chat 走 DeepSeek、Embedding 走 SiliconFlow,两者都是 OpenAI 兼容但地址不同,
* 因此单独为 SiliconFlow 创建 OpenAiApi + EmbeddingModel Bean。
*/
@Configuration
public class SiliconFlowEmbeddingConfig {
private static final Logger log = LoggerFactory.getLogger(SiliconFlowEmbeddingConfig.class);
@Value("${siliconflow.api-key}")
private String apiKey;
@Value("${siliconflow.base-url}")
private String baseUrl;
@Value("${siliconflow.embedding.model}")
private String model;
@Bean
public OpenAiApi siliconFlowApi(RestClient.Builder restClientBuilder, WebClient.Builder webClientBuilder) {
log.info("创建 SiliconFlow OpenAiApi: {}", baseUrl);
return OpenAiApi.builder()
.baseUrl(baseUrl)
.apiKey(apiKey)
.restClientBuilder(restClientBuilder)
.build();
}
@Bean
public EmbeddingModel siliconFlowEmbeddingModel(OpenAiApi siliconFlowApi) {
log.info("创建 SiliconFlow EmbeddingModel, model: {}", model);
return new OpenAiEmbeddingModel(siliconFlowApi, MetadataMode.EMBED,
OpenAiEmbeddingOptions.builder()
.model(model)
.build());
}
}
@@ -1,44 +0,0 @@
package com.superbiz.agent.constant;
public class MilvusConstants {
/**
* Milvus 数据库名称
*/
public static final String MILVUS_DB_NAME = "default";
/**
* Default knowledge collection name (dense + BM25).
* Overridable via {@code milvus.collection}.
*/
public static final String MILVUS_COLLECTION_NAME = "biz";
/**
* Alias kept for readability in hybrid-related code.
*/
public static final String MILVUS_HYBRID_COLLECTION_NAME = MILVUS_COLLECTION_NAME;
/**
* 向量维度(豆包 embedding 模型的维度)
*/
public static final int VECTOR_DIM = 1024; // 豆包模型返回1024维向量
/**
* ID字段最大长度
*/
public static final int ID_MAX_LENGTH = 256;
/**
* Content字段最大长度
*/
public static final int CONTENT_MAX_LENGTH = 8192;
/**
* 默认分片数
*/
public static final int DEFAULT_SHARD_NUMBER = 2;
private MilvusConstants() {
// 工具类,禁止实例化
}
}
@@ -24,12 +24,24 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor; 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 @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class ChatController { public class ChatController {
/** 应用编排入口:建 Run → 路由 → 分叉执行 → 收尾。 */
private final ChatApplicationUseCase chatApplication; private final ChatApplicationUseCase chatApplication;
/** Chat 专用工作线程池;避免在 HTTP 线程上阻塞跑 LLM。 */
private final ThreadPoolExecutor chatWorkerExecutor; private final ThreadPoolExecutor chatWorkerExecutor;
private final ChatHarnessProperties harnessProperties; private final ChatHarnessProperties harnessProperties;
@@ -41,6 +53,12 @@ public class ChatController {
this.harnessProperties = harnessProperties; 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) @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<SseEmitter> chat(@RequestBody ChatRequest request) { public ResponseEntity<SseEmitter> chat(@RequestBody ChatRequest request) {
if (request == null || request.getQuestion() == null || request.getQuestion().isBlank()) { if (request == null || request.getQuestion() == null || request.getQuestion().isBlank()) {
@@ -48,12 +66,14 @@ public class ChatController {
} }
SseEmitter emitter = new SseEmitter(harnessProperties.getSseTimeout().toMillis()); SseEmitter emitter = new SseEmitter(harnessProperties.getSseTimeout().toMillis());
// session 同时是 ChatApplicationObserver:编排过程中的 status/取消都经它推 SSE
ChatSseSession session = new ChatSseSession(emitter); ChatSseSession session = new ChatSseSession(emitter);
emitter.onTimeout(session::disconnect); emitter.onTimeout(session::disconnect);
emitter.onError(ignored -> session.disconnect()); emitter.onError(ignored -> session.disconnect());
emitter.onCompletion(session::disconnect); emitter.onCompletion(session::disconnect);
try { try {
// 异步执行:HTTP 线程只持有 SSE 连接,不跑模型
chatWorkerExecutor.execute(() -> executeChat(request, session)); chatWorkerExecutor.execute(() -> executeChat(request, session));
} catch (RejectedExecutionException rejected) { } catch (RejectedExecutionException rejected) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
@@ -63,8 +83,10 @@ public class ChatController {
.body(emitter); .body(emitter);
} }
/** 工作线程:把协议请求转成 Application 请求,统一成功/失败出口。 */
private void executeChat(ChatRequest request, ChatSseSession session) { private void executeChat(ChatRequest request, ChatSseSession session) {
try { try {
// Id=sessionId(可多轮复用),Question=本轮 query → 一问一 run
ChatApplicationResult result = chatApplication.execute( ChatApplicationResult result = chatApplication.execute(
new ChatApplicationRequest(request.getQuestion(), request.getId()), session); new ChatApplicationRequest(request.getQuestion(), request.getId()), session);
session.complete(result); session.complete(result);
@@ -1,8 +1,8 @@
package com.superbiz.agent.controller; package com.superbiz.agent.controller;
import com.superbiz.agent.client.PyRagClient;
import com.superbiz.agent.config.FileUploadConfig; import com.superbiz.agent.config.FileUploadConfig;
import com.superbiz.agent.dto.FileUploadRes; import com.superbiz.agent.dto.FileUploadRes;
import com.superbiz.agent.service.VectorIndexService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -29,10 +29,11 @@ public class FileUploadController {
private FileUploadConfig fileUploadConfig; private FileUploadConfig fileUploadConfig;
@Autowired @Autowired
private VectorIndexService vectorIndexService; private PyRagClient pyRagClient;
@PostMapping(value = "/api/upload", consumes = "multipart/form-data") @PostMapping(value = "/api/upload", consumes = "multipart/form-data")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) { public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "category", required = false) String category) {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().body("文件不能为空"); return ResponseEntity.badRequest().body("文件不能为空");
} }
@@ -68,15 +69,17 @@ public class FileUploadController {
logger.info("文件上传成功: {}", filePath); logger.info("文件上传成功: {}", filePath);
// 文件上传成功后,自动调用向量索引服务 // 转发 py-rag 入库(同内容重传返回 unchanged)。入库失败不影响上传成功语义。
try { try {
logger.info("开始为上传文件创建向量索引: {}", filePath); String ingestCategory = (category == null || category.isBlank()) ? "default" : category;
vectorIndexService.indexSingleFile(filePath.toString()); logger.info("开始 py-rag 入库: {}, category={}", filePath, ingestCategory);
logger.info("向量索引创建成功: {}", filePath); var ingest = pyRagClient.ingest(originalFilename, file.getBytes(), file.getContentType(),
ingestCategory, null, null, null);
logger.info("py-rag 入库完成: docId={}, status={}, chunks={}",
ingest.docId(), ingest.status(), ingest.chunkCount());
} catch (Exception e) { } catch (Exception e) {
logger.error("向量索引创建失败: {}, 错误: {}", filePath, e.getMessage(), e); logger.error("py-rag 入库失败: {}, 错误: {}", filePath, e.getMessage(), e);
// 注意:即使索引失败,文件上传仍然成功,只是记录错误日志 // 注意:即使入库失败,文件上传仍然成功,只是记录错误日志
// 可以根据业务需求决定是否要删除文件或返回错误
} }
FileUploadRes response = new FileUploadRes( FileUploadRes response = new FileUploadRes(
@@ -1,147 +0,0 @@
package com.superbiz.agent.controller;
import com.superbiz.agent.service.KnowledgeBaseInitService;
import lombok.Data;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 知识库管理控制器
* 提供知识库初始化、查询等接口
*/
@RestController
@RequestMapping("/api/knowledge")
public class KnowledgeBaseController {
private static final Logger logger = LoggerFactory.getLogger(KnowledgeBaseController.class);
@Autowired
private KnowledgeBaseInitService initService;
/**
* 初始化知识库
* 扫描 knowledge_base 目录下的所有文档,去重后批量导入到数据库和 Milvus
*
* @param force 是否强制重新导入(跳过去重检查)
* @return 初始化结果
*/
@PostMapping("/init")
public ResponseEntity<?> initKnowledgeBase(@RequestParam(defaultValue = "false") boolean force) {
logger.info("收到知识库初始化请求, force={}", force);
try {
KnowledgeBaseInitService.InitResult result = initService.initializeKnowledgeBase(force);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("message", "知识库初始化完成");
response.put("scanned", result.getScanned());
response.put("skipped", result.getSkipped());
response.put("inserted", result.getInserted());
response.put("failed", result.getFailed());
response.put("details", result.getDetails());
logger.info("知识库初始化成功: 扫描={}, 跳过={}, 新增={}, 失败={}",
result.getScanned(), result.getSkipped(), result.getInserted(), result.getFailed());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("知识库初始化失败", e);
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "初始化失败: " + e.getMessage());
return ResponseEntity.internalServerError().body(response);
}
}
/**
* 清空 hybrid collection + MySQL api_document + L0 内存索引,
* 再从 knowledge_base 全量重建 dense+BM25 索引。
*
* <p>危险操作:会删除 {@code milvus.collection}(默认 {@code biz})与文档元数据表数据。
* 需要显式 confirm=REBUILD。</p>
*/
@PostMapping("/rebuild-hybrid")
public ResponseEntity<?> rebuildHybrid(
@RequestParam(defaultValue = "") String confirm) {
if (!"REBUILD".equals(confirm)) {
Map<String, Object> rejected = new HashMap<>();
rejected.put("success", false);
rejected.put("message", "拒绝执行:请传 confirm=REBUILD 以确认清空并重建");
rejected.put("hint", "POST /api/knowledge/rebuild-hybrid?confirm=REBUILD");
return ResponseEntity.badRequest().body(rejected);
}
logger.warn("收到 hybrid 知识库全量重建请求 confirm={}", confirm);
try {
KnowledgeBaseInitService.RebuildResult result = initService.rebuildHybridFromKnowledgeBase();
Map<String, Object> response = new HashMap<>();
response.put("success", result.isSuccess());
response.put("message", result.isSuccess()
? "hybrid 知识库重建完成"
: "hybrid 知识库重建结束,但存在失败项");
response.put("collection", result.getCollection());
response.put("basePath", result.getBasePath());
response.put("milvus", result.getMilvus());
response.put("mysqlDocumentsBefore", result.getMysqlDocumentsBefore());
response.put("mysqlDocumentsAfterClear", result.getMysqlDocumentsAfterClear());
response.put("mysqlDocumentsAfterInit", result.getMysqlDocumentsAfterInit());
response.put("l0IndexSizeAfterClear", result.getL0IndexSizeAfterClear());
response.put("l0IndexSizeAfterInit", result.getL0IndexSizeAfterInit());
if (result.getInit() != null) {
response.put("scanned", result.getInit().getScanned());
response.put("skipped", result.getInit().getSkipped());
response.put("inserted", result.getInit().getInserted());
response.put("failed", result.getInit().getFailed());
response.put("details", result.getInit().getDetails());
}
return result.isSuccess()
? ResponseEntity.ok(response)
: ResponseEntity.status(500).body(response);
} catch (Exception e) {
logger.error("hybrid 知识库重建失败", e);
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "重建失败: " + e.getMessage());
return ResponseEntity.internalServerError().body(response);
}
}
/**
* 查询知识库统计信息
*
* @return 统计信息
*/
@GetMapping("/stats")
public ResponseEntity<?> getStats() {
try {
KnowledgeBaseInitService.Stats stats = initService.getStats();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("totalDocuments", stats.getTotalDocuments());
response.put("totalVectors", stats.getTotalVectors());
response.put("categories", stats.getCategoryCount());
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.error("查询统计信息失败", e);
Map<String, Object> response = new HashMap<>();
response.put("success", false);
response.put("message", "查询失败: " + e.getMessage());
return ResponseEntity.internalServerError().body(response);
}
}
}
@@ -1,39 +0,0 @@
package com.superbiz.agent.controller;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import io.milvus.v2.service.collection.response.ListCollectionsResp;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* Milvus health check using the single V2 knowledge backend.
*/
@RestController
@RequestMapping("/milvus")
public class MilvusCheckController {
@Autowired
private MilvusHybridKnowledgeStore knowledgeStore;
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> simpleHealth() {
Map<String, Object> result = new HashMap<>();
try {
ListCollectionsResp response = knowledgeStore.client().listCollections();
result.put("message", "ok");
result.put("backend", "milvus-client-v2");
result.put("knowledgeCollection", knowledgeStore.collectionName());
result.put("collections", response == null ? null : response.getCollectionNames());
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("error", e.getMessage());
return ResponseEntity.status(503).body(result);
}
}
}
@@ -1,42 +0,0 @@
package com.superbiz.agent.controller;
import com.superbiz.agent.dto.Result;
import com.superbiz.agent.service.VectorSearchService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 文档检索控制器(测试用)
*/
@Slf4j
@RestController
@RequestMapping("/api/search")
public class SearchController {
@Autowired
private VectorSearchService vectorSearchService;
/**
* 搜索相似文档
*/
@GetMapping("/similar")
public Result<List<VectorSearchService.SearchResult>> searchSimilar(
@RequestParam("query") String query,
@RequestParam(value = "topK", defaultValue = "5") int topK,
@RequestParam(value = "category", required = false) String category
) {
try {
log.info("收到检索请求,query: {}, topK: {}, category: {}", query, topK, category);
List<VectorSearchService.SearchResult> results = vectorSearchService.searchSimilarDocuments(query, topK, category);
log.info("检索完成,返回 {} 条结果", results.size());
return Result.success(results);
} catch (Exception e) {
log.error("检索失败", e);
return Result.error(500, "检索失败: " + e.getMessage());
}
}
}
@@ -1,47 +0,0 @@
package com.superbiz.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 文档分片
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DocumentChunk {
/**
* 分片内容
*/
private String content;
/**
* 分片在原文档中的起始位置
*/
private int startOffset;
/**
* 分片在原文档中的结束位置
*/
private int endOffset;
/**
* 分片序号(从0开始)
*/
private int chunkIndex;
/**
* 分片标题或上下文信息
*/
private String title;
/**
* 面包屑导航(完整标题层级路径)
* 例如: "故障诊断流程规范 > 应急响应流程 > 1. 初步评估"
*/
private String breadcrumb;
}
@@ -1,80 +0,0 @@
package com.superbiz.agent.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* Frontmatter 数据模型
* 用于解析 Markdown 文件头的 YAML frontmatter
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Frontmatter {
/**
* 文档标题(必填)
*/
private String title;
/**
* 关键词列表(必填,用于 L0 精确匹配)
*/
private List<String> keywords;
/**
* 文档摘要(必填)
*/
private String summary;
/**
* 文档类别(可选)
*/
private String category;
private String source;
private String breadcrumb;
@JsonProperty("kb_scope")
private String kbScope;
/**
* 章节锚点(预留字段,MVP 不使用)
* Key: 章节标题,Value: 章节 Markdown 标题
*/
private Map<String, String> sections;
/**
* 版本号(预留字段)
*/
private String version;
/**
* 作者(预留字段)
*/
private String author;
/**
* 最后更新日期(预留字段)
*/
private LocalDate lastUpdated;
/**
* 业务场景标签,供 Planner 决策用(LLM 上传时自动生成)
*/
private List<String> covers;
/**
* 文档级检索时机(LLM 上传时自动生成)
*/
private String whenToRetrieve;
}
@@ -1,58 +0,0 @@
package com.superbiz.agent.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 知识库索引条目
* L0 内存索引使用的数据结构
*/
@Data
@Builder
public class KnowledgeEntry {
/**
* 文件路径(如:knowledge_base/api/payment-errors.md)
*/
private String filePath;
/**
* 文档标题
*/
private String title;
/**
* 关键词列表(用于精确匹配)
*/
private List<String> keywords;
/**
* 文档摘要
*/
private String summary;
/**
* 文档类别(如:api、domain、troubleshooting)
*/
private String category;
private String kbScope;
/**
* 章节锚点(预留字段,MVP 不使用)
*/
private Map<String, String> sections;
/**
* 业务场景标签,供 Planner 决策用
*/
private List<String> covers;
/**
* 文档级检索时机
*/
private String whenToRetrieve;
}
@@ -6,9 +6,10 @@ import lombok.Data;
import java.util.List; import java.util.List;
/** /**
* 检索前 query understanding 的输出(L0 -&gt; pipeline 控制面)。 * 检索 pipeline 控制面参数。
* *
* <p>由 {@code KnowledgeQueryTransformer} 生成,供 L1 过滤、规则 rerank 与 trace 使用。 * <p>L0 query 理解已下沉 py-rag 服务端;当前 {@code originalQuery} = {@code rewrittenQuery}、
* hint 字段恒为空、{@code categoryFilter} 恒为 null,结构保留供后处理与 trace 使用。
* 不是 Agent 可见契约。</p> * 不是 Agent 可见契约。</p>
*/ */
@Data @Data
@@ -12,6 +12,15 @@ import org.springframework.ai.chat.model.ChatModel;
import java.util.List; import java.util.List;
import java.util.Objects; 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 final class DiagnosisAgentFactory {
public static final String AGENT_NAME = "diagnosis_agent"; public static final String AGENT_NAME = "diagnosis_agent";
@@ -62,22 +71,27 @@ public final class DiagnosisAgentFactory {
this.prompt = DiagnosisAgentPrompt.load(); this.prompt = DiagnosisAgentPrompt.load();
} }
/**
* 为当前 Run 构建 ReactAgent 实例(与 RunContext 绑定,不可跨 run 复用)。
*/
public ReactAgent create(RunContext context) { public ReactAgent create(RunContext context) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
return ReactAgent.builder() return ReactAgent.builder()
.name(AGENT_NAME) .name(AGENT_NAME)
.description("Collects bounded evidence and authors one diagnosis draft") .description("Collects bounded evidence and authors one diagnosis draft")
.model(chatModel) .model(chatModel) // Spring AI ChatModel
.systemPrompt(prompt) .systemPrompt(prompt)
.tools(evidenceTools.callbacks()) .tools(evidenceTools.callbacks()) // 证据工具(如 lookup_knowledge)
.interceptors( .interceptors(
// 每次模型调用前:checkActive + 预算 + token 审计
new HarnessModelInterceptor(core, context, modelCallAuditor), new HarnessModelInterceptor(core, context, modelCallAuditor),
// 每次工具调用:边界投影给 Agent + 完整轨迹进审计
new HarnessToolInterceptor( new HarnessToolInterceptor(
context, evidenceTools, objectMapper, traceRecorder)) context, evidenceTools, objectMapper, traceRecorder))
.hooks(auditHooks) .hooks(auditHooks) // 如 agent_step 落库
.outputSchema(new DiagnosisDraftOutputSchema(objectMapper).getFormat()) .outputSchema(new DiagnosisDraftOutputSchema(objectMapper).getFormat())
.returnReasoningContents(true) .returnReasoningContents(true)
.parallelToolExecution(false) .parallelToolExecution(false) // 串行工具:预算与 step 绑定可解释
.releaseThread(true) .releaseThread(true)
.build(); .build();
} }
@@ -4,12 +4,28 @@ import com.superbiz.agent.harness.progress.DiagnosisProgressSnapshot;
import java.util.Objects; import java.util.Objects;
/**
* Diagnosis Agent 输出/执行失败异常。
*
* <p>由 {@code DiagnosisAgentUseCase} 抛出;{@code DiagnosisChatExecutor} 仅对
* {@link #isDraftContractFailure()} 为 true 的 kind 尝试 {@code recoverInvalidDraft}。
*/
public final class DiagnosisAgentOutputException extends RuntimeException { public final class DiagnosisAgentOutputException extends RuntimeException {
/**
* Agent 失败细分。
*
* <p>前三种(空/JSON/schema)算 Draft 契约失败,有 observed facts 时可 FALLBACK;
* {@link #EXECUTION_FAILED} 是 loop/框架执行失败,不走非法 draft 恢复。
*/
public enum Kind { public enum Kind {
/** agent.call 抛错且非受控停止,或未归类执行失败。 */
EXECUTION_FAILED, EXECUTION_FAILED,
/** 模型返回空文本,没有 draft。 */
EMPTY_DRAFT, EMPTY_DRAFT,
/** 输出不是合法 JSON。 */
INVALID_JSON, INVALID_JSON,
/** JSON 可解析但不符合 DiagnosisDraft schema。 */
SCHEMA_INVALID SCHEMA_INVALID
} }
@@ -21,8 +21,21 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Objects; 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 { public final class DiagnosisAgentUseCase {
/** 写入 RunnableConfig metadata,供 hook/interceptor 取回同一 RunContext。 */
public static final String RUN_CONTEXT_METADATA = "runContext"; public static final String RUN_CONTEXT_METADATA = "runContext";
private final DiagnosisHarnessCore core; private final DiagnosisHarnessCore core;
@@ -55,6 +68,11 @@ public final class DiagnosisAgentUseCase {
progressProjection, "progressProjection must not be null"); progressProjection, "progressProjection must not be null");
} }
/**
* 在当前 Run 内执行 Diagnosis Agent。
*
* <p>返回 completed(draft) 或 stopped(stopReason);草稿仍需经 Release 才能对外 SUCCESS。
*/
public DiagnosisAgentExecution execute(RunContext context, DiagnosisAgentInput input) { public DiagnosisAgentExecution execute(RunContext context, DiagnosisAgentInput input) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(input, "input 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()); checkLimit("input", inputBytes, limits.maxInputBytes());
core.reserveRunBytes(context, inputBytes); core.reserveRunBytes(context, inputBytes);
// 框架线程/配置:把 RunContext 显式挂进 metadata,避免隐式 ThreadLocal
RunnableConfig config = RunnableConfig.builder() RunnableConfig config = RunnableConfig.builder()
.threadId(context.runId()) .threadId(context.runId())
.addMetadata("sessionId", context.sessionId()) .addMetadata("sessionId", context.sessionId())
@@ -78,12 +97,15 @@ public final class DiagnosisAgentUseCase {
.addMetadata(RUN_CONTEXT_METADATA, context) .addMetadata(RUN_CONTEXT_METADATA, context)
.addMetadata("_stream_", false) .addMetadata("_stream_", false)
.build(); .build();
// 每次 run 新建 Agent,绑定本 run 的 interceptor(预算/投影/审计)
ReactAgent agent = agentFactory.create(context); ReactAgent agent = agentFactory.create(context);
AssistantMessage response; AssistantMessage response;
try { try {
// ★ Spring AI Alibaba:内部多轮 model + tool,直到产出最终文本或被 interceptor 打断
response = agent.call(inputJson, config); response = agent.call(inputJson, config);
core.checkActive(context); core.checkActive(context);
} catch (Exception e) { } catch (Exception e) {
// 预算/收敛等可控停止 → stopped;其它异常包装为 Agent 输出失败
DiagnosisAgentExecution controlled = controlledExecution(context, e); DiagnosisAgentExecution controlled = controlledExecution(context, e);
if (controlled != null) { if (controlled != null) {
return controlled; 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) { private DiagnosisAgentExecution controlledExecution(RunContext context, Throwable failure) {
// 1) 收集侧受控停止(ToolInterceptor 在 SATURATED 后仍收到 tool 请求)
DiagnosisCollectionStoppedException stopped = findCause( DiagnosisCollectionStoppedException stopped = findCause(
failure, DiagnosisCollectionStoppedException.class); failure, DiagnosisCollectionStoppedException.class);
if (stopped != null) { if (stopped != null) {
return DiagnosisAgentExecution.stopped( return DiagnosisAgentExecution.stopped(
progressProjection.project(context), stopped.stopReason()); progressProjection.project(context), stopped.stopReason());
} }
// 2) Run 已终态:仅预算耗尽可降为 stopped;取消/超时必须继续上抛
RunAbortedException aborted = findCause(failure, RunAbortedException.class); RunAbortedException aborted = findCause(failure, RunAbortedException.class);
if (aborted != null) { if (aborted != null) {
if (aborted.termination().state() == RunState.BUDGET_EXHAUSTED) { if (aborted.termination().state() == RunState.BUDGET_EXHAUSTED) {
@@ -143,15 +189,18 @@ public final class DiagnosisAgentUseCase {
} }
throw aborted; throw aborted;
} }
// 3) 预算异常(可能尚未被包成 RunAborted,或 lifecycle 已先置 BUDGET_EXHAUSTED)
BudgetExceededException budget = findCause(failure, BudgetExceededException.class); BudgetExceededException budget = findCause(failure, BudgetExceededException.class);
if (budget != null || context.lifecycle().state() == RunState.BUDGET_EXHAUSTED) { if (budget != null || context.lifecycle().state() == RunState.BUDGET_EXHAUSTED) {
context.progress().markBudgetLimitReached(); context.progress().markBudgetLimitReached();
return DiagnosisAgentExecution.stopped( return DiagnosisAgentExecution.stopped(
progressProjection.project(context), DiagnosisStopReason.BUDGET_LIMIT_REACHED); progressProjection.project(context), DiagnosisStopReason.BUDGET_LIMIT_REACHED);
} }
// 4) 未知失败:交给外层当 Agent 执行失败
return null; return null;
} }
/** 沿 cause 链查找目标异常类型(框架包装后根因仍在链上)。 */
private static <T extends Throwable> T findCause(Throwable failure, Class<T> type) { private static <T extends Throwable> T findCause(Throwable failure, Class<T> type) {
Throwable current = failure; Throwable current = failure;
while (current != null) { while (current != null) {
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null"); this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
} }
/**
* 拦截器名称(框架注册用)。
*/
@Override @Override
public String getName() { public String getName() {
return "harness_evidence_tool_interceptor"; 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 @Override
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) { public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
Objects.requireNonNull(request, "request must not be null"); Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(handler, "handler must not be null"); Objects.requireNonNull(handler, "handler must not be null");
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
if (!evidenceTools.supports(request.getToolName())) { if (!evidenceTools.supports(request.getToolName())) {
return handler.call(request); return handler.call(request);
} }
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
DiagnosisProgressSnapshotState before = context.progress().snapshot(); DiagnosisProgressSnapshotState before = context.progress().snapshot();
if (before.collectionState() == DiagnosisCollectionState.SATURATED if (before.collectionState() == DiagnosisCollectionState.SATURATED
&& before.stopInstructionDelivered()) { && before.stopInstructionDelivered()) {
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
ParsedAgentToolCall call; ParsedAgentToolCall call;
String normalizedScope; String normalizedScope;
try { try {
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper); call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
context.progress().applyPreviousObservation(call.previousObservation()); context.progress().applyPreviousObservation(call.previousObservation());
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot(); DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
// 审计:模型回传的评价进入 Trace(producer=MODEL)
recordModelProgress(before, call, evaluated); recordModelProgress(before, call, evaluated);
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) { if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
recordRejection(request, "INFORMATION_SATURATED"); recordRejection(request, "INFORMATION_SATURATED");
return stopRequired(request, evaluated.stopReason()); return stopRequired(request, evaluated.stopReason());
} }
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput()); normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
} catch (ProgressProtocolViolationException exception) { } catch (ProgressProtocolViolationException exception) {
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
return handleProgressProtocolViolation(request, exception); return handleProgressProtocolViolation(request, exception);
} catch (IllegalArgumentException | IllegalStateException exception) { } catch (IllegalArgumentException | IllegalStateException exception) {
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
return handleProgressProtocolViolation(request, return handleProgressProtocolViolation(request,
new ProgressProtocolViolationException( new ProgressProtocolViolationException(
ProgressProtocolViolationType.INVALID_ENVELOPE, ProgressProtocolViolationType.INVALID_ENVELOPE,
"Tool Call Envelope is invalid", null, null, exception)); "Tool Call Envelope is invalid", null, null, exception));
} }
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) { if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
recordRejection(request, "DUPLICATE_SCOPE"); recordRejection(request, "DUPLICATE_SCOPE");
context.progress().recordDuplicateScope(); context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot(); DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope, recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", duplicate); InformationGain.NO_GAIN, "HARNESS", duplicate);
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return duplicateScope(request); return duplicateScope(request);
} }
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
ToolBoundaryResult result = evidenceTools.invoke( ToolBoundaryResult result = evidenceTools.invoke(
context, request.getToolName(), request.getToolCallId(), call.businessArguments()); context, request.getToolName(), request.getToolCallId(), call.businessArguments());
if (result.status() == InvocationStatus.READY) { if (result.status() == InvocationStatus.READY) {
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
ToolControlView control = viewProjector.controlView(result.agentResult()); ToolControlView control = viewProjector.controlView(result.agentResult());
if (control.evidenceStatus() != result.evidenceStatus()) { if (control.evidenceStatus() != result.evidenceStatus()) {
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH"); recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH"); return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
} }
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
context.progress().recordCompleted( context.progress().recordCompleted(
new CompletedToolCall( new CompletedToolCall(
request.getToolCallId(), request.getToolName(), normalizedScope), request.getToolCallId(), request.getToolName(), normalizedScope),
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope, recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", completed); InformationGain.NO_GAIN, "HARNESS", completed);
} }
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
&& context.progress().claimStopInstruction(); && context.progress().claimStopInstruction();
if (stopRequired) { if (stopRequired) {
traceRecorder.record(TraceAuditEvents.collectionStop( traceRecorder.record(TraceAuditEvents.collectionStop(
context, request.getToolCallId(), request.getToolName(), completed)); context, request.getToolCallId(), request.getToolName(), completed));
} }
// 有界 observation 返回给模型(含停止指令/停止原因)
String observation = viewProjector.modelObservation( String observation = viewProjector.modelObservation(
request.getToolName(), result.agentResult(), normalizedScope, request.getToolName(), result.agentResult(), normalizedScope,
stopRequired, completed.stopReason()); stopRequired, completed.stopReason());
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation); return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
} }
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) { if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
context.progress().markBudgetLimitReached(); context.progress().markBudgetLimitReached();
traceRecorder.record(TraceAuditEvents.collectionStop( traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build(); .build();
} }
/**
* 交付「必须停止」观察(observation 三副面孔之一)。
*
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
*/
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) { private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
if (!context.progress().claimStopInstruction()) { if (!context.progress().claimStopInstruction()) {
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
throw new DiagnosisCollectionStoppedException(reason); throw new DiagnosisCollectionStoppedException(reason);
} }
traceRecorder.record(TraceAuditEvents.collectionStop( traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation)); request.getToolCallId(), request.getToolName(), writeObservation(observation));
} }
/**
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
*/
private ToolCallResponse duplicateScope(ToolCallRequest request) { private ToolCallResponse duplicateScope(ToolCallRequest request) {
Map<String, Object> observation = new LinkedHashMap<>(); Map<String, Object> observation = new LinkedHashMap<>();
observation.put("tool_call_id", request.getToolCallId()); observation.put("tool_call_id", request.getToolCallId());
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation)); request.getToolCallId(), request.getToolName(), writeObservation(observation));
} }
/**
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
*/
private ToolCallResponse handleProgressProtocolViolation( private ToolCallResponse handleProgressProtocolViolation(
ToolCallRequest request, ToolCallRequest request,
ProgressProtocolViolationException exception) { ProgressProtocolViolationException exception) {
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return repairableProtocolError(request, exception, state); return repairableProtocolError(request, exception, state);
} }
/**
* 可修复协议错误观察(observation 三副面孔之一):
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
*/
private ToolCallResponse repairableProtocolError( private ToolCallResponse repairableProtocolError(
ToolCallRequest request, ToolCallRequest request,
ProgressProtocolViolationException exception, ProgressProtocolViolationException exception,
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build(); .build();
} }
/**
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
* 用于契约不一致等非协议类拒绝。
*/
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) { private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
Map<String, Object> observation = new LinkedHashMap<>(); Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", "ERROR"); observation.put("evidence_status", "ERROR");
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build(); .build();
} }
/**
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
*/
private void recordRejection(ToolCallRequest request, String errorCode) { private void recordRejection(ToolCallRequest request, String errorCode) {
recordRejection(request, errorCode, null, false, context.progress().snapshot()); recordRejection(request, errorCode, null, false, context.progress().snapshot());
} }
/**
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
*
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
* 不消耗 Tool 预算、不计入实际执行数。
*/
private void recordRejection( private void recordRejection(
ToolCallRequest request, ToolCallRequest request,
String errorCode, String errorCode,
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
violationType, repairPromptDelivered, state)); violationType, repairPromptDelivered, state));
} }
/**
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
*/
private String writeObservation(Map<String, Object> observation) { private String writeObservation(Map<String, Object> observation) {
try { try {
return objectMapper.writeValueAsString(observation); 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( private void recordModelProgress(
DiagnosisProgressSnapshotState before, DiagnosisProgressSnapshotState before,
ParsedAgentToolCall call, ParsedAgentToolCall call,
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
call.previousObservation().informationGain(), "MODEL", after)); call.previousObservation().informationGain(), "MODEL", after));
} }
/**
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
*/
private void recordProgress( private void recordProgress(
String toolCallId, String toolCallId,
String toolName, String toolName,
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
informationGain, producer, state)); informationGain, producer, state));
} }
/**
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
*/
private String scopeSummary(String toolName, String normalizedScope) { private String scopeSummary(String toolName, String normalizedScope) {
return toolName + "#" + String.format("%08x", normalizedScope.hashCode()); return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
} }
/**
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
*/
private String errorObservation(ToolBoundaryResult result) { private String errorObservation(ToolBoundaryResult result) {
Map<String, Object> observation = new LinkedHashMap<>(); Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", result.evidenceStatus()); observation.put("evidence_status", result.evidenceStatus());
@@ -1,11 +1,28 @@
package com.superbiz.agent.harness.application; package com.superbiz.agent.harness.application;
/**
* Chat 应用进度状态(SSE status 事件),不是错误码。
*
* <p>只表示「当前走到哪一阶段」,成功/失败结局看 ReleaseOutcome / ChatFailureCode。
*/
public enum ChatApplicationStatus { public enum ChatApplicationStatus {
/** Intent Router 识别请求类型。 */
ROUTING("正在识别请求类型"), ROUTING("正在识别请求类型"),
/** 系统闲聊路径生成回答。 */
SYSTEM_RESPONDING("正在生成回答"), SYSTEM_RESPONDING("正在生成回答"),
/** 知识库检索中。 */
KNOWLEDGE_SEARCHING("正在查询知识库"), KNOWLEDGE_SEARCHING("正在查询知识库"),
/** 知识答案整理中。 */
KNOWLEDGE_ANSWERING("正在整理知识答案"), KNOWLEDGE_ANSWERING("正在整理知识答案"),
/** 诊断 Agent 收集证据 / 写草稿(ReAct 循环中)。 */
DIAGNOSIS_RUNNING("正在收集诊断证据"), DIAGNOSIS_RUNNING("正在收集诊断证据"),
/** Evidence / Semantic 门控或无效 draft 的安全发布阶段。 */
SAFETY_VALIDATING("正在进行安全校验"); SAFETY_VALIDATING("正在进行安全校验");
private final String message; private final String message;
@@ -14,6 +31,7 @@ public enum ChatApplicationStatus {
this.message = message; this.message = message;
} }
/** 面向用户的简短进度文案。 */
public String message() { public String message() {
return message; return message;
} }
@@ -23,16 +23,34 @@ import java.util.Optional;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.regex.Pattern; 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 { public final class ChatApplicationUseCase {
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); 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 DiagnosisHarnessCore core;
private final Supplier<String> sessionIdSupplier; private final Supplier<String> sessionIdSupplier;
private final ChatRunStore runStore; private final ChatRunStore runStore;
/** 意图路由:只产出 IntentType,不执行诊断。 */
private final IntentRouting router; private final IntentRouting router;
private final SystemChatOperation systemChat; private final SystemChatOperation systemChat;
private final KnowledgeQueryOperation knowledgeQuery; private final KnowledgeQueryOperation knowledgeQuery;
/** 诊断子路径:Agent 收集证据写草稿 + Release 门控发布。 */
private final DiagnosisOperation diagnosis; private final DiagnosisOperation diagnosis;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DiagnosisTraceRecorder traceRecorder; private final DiagnosisTraceRecorder traceRecorder;
@@ -74,12 +92,18 @@ public final class ChatApplicationUseCase {
return execute(request, ChatApplicationObserver.noop()); return execute(request, ChatApplicationObserver.noop());
} }
/**
* 一次 Chat 请求的主编排。
*
* <p>observer 通常是 SSE session:onStarted 推 metadata,onStatus 推进度。
*/
public ChatApplicationResult execute(ChatApplicationRequest request, public ChatApplicationResult execute(ChatApplicationRequest request,
ChatApplicationObserver observer) { ChatApplicationObserver observer) {
Objects.requireNonNull(request, "request must not be null"); Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(observer, "observer must not be null"); Objects.requireNonNull(observer, "observer must not be null");
String sessionId = resolveSessionId(request.sessionId()); String sessionId = resolveSessionId(request.sessionId());
// 会话级上下文:上一路由结果 + 上一轮诊断摘要(给 Router / Diagnosis 用)
Optional<RoutingHistory> history; Optional<RoutingHistory> history;
Optional<PreviousTurn> previousTurn; Optional<PreviousTurn> previousTurn;
try { try {
@@ -90,14 +114,19 @@ public final class ChatApplicationUseCase {
ChatFailureCode.RUN_PERSISTENCE_FAILED, ChatFailureCode.RUN_PERSISTENCE_FAILED,
"无法读取会话上下文,请稍后重试", exception); "无法读取会话上下文,请稍后重试", exception);
} }
// ★ 步骤1:创建 Run 边界(runId/deadline/budget/cancel/lifecycle),显式向下传递
RunContext context = core.startRun(sessionId); RunContext context = core.startRun(sessionId);
long startedNanos = System.nanoTime(); long startedNanos = System.nanoTime();
IntentType intent = null; IntentType intent = null;
try { try {
// ★ 步骤2:落库 RUN 开始 + 对外/对内可观测
persistStart(context, request.query()); persistStart(context, request.query());
traceRecorder.record(TraceAuditEvents.runStarted(context)); traceRecorder.record(TraceAuditEvents.runStarted(context));
observer.onStarted(new CoreRunControl(core, context)); observer.onStarted(new CoreRunControl(core, context)); // SSE metadata + 取消句柄
observer.onStatus(ChatApplicationStatus.ROUTING); observer.onStatus(ChatApplicationStatus.ROUTING);
// ★ 步骤3:意图路由——只回答「走哪条应用分支」,不调业务工具
intent = router.route(context, new IntentRouterInput( intent = router.route(context, new IntentRouterInput(
request.query(), request.query(),
history.map(RoutingHistory::intent).orElse(null), history.map(RoutingHistory::intent).orElse(null),
@@ -105,8 +134,11 @@ public final class ChatApplicationUseCase {
traceRecorder.record(TraceAuditEvents.routingDecision(context, intent)); traceRecorder.record(TraceAuditEvents.routingDecision(context, intent));
persistIntent(context.runId(), intent); persistIntent(context.runId(), intent);
// ★ 步骤4:按 intent 分叉执行(编排决策点)
PathResult path = executePath( PathResult path = executePath(
intent, context, request.query(), previousTurn.orElse(null), observer); intent, context, request.query(), previousTurn.orElse(null), observer);
// ★ 步骤5:写入 Run 终态(成功)并持久化公开结果
completePath(context, intent, path); completePath(context, intent, path);
String safeJson = write(path.content()); String safeJson = write(path.content());
persistFinish(context, intent, path.outcome(), safeJson, persistFinish(context, intent, path.outcome(), safeJson,
@@ -117,6 +149,7 @@ public final class ChatApplicationUseCase {
context.sessionId(), context.runId(), intent, path.outcome(), context.sessionId(), context.runId(), intent, path.outcome(),
path.content().contentType(), path.content()); path.content().contentType(), path.content());
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
// 统一失败出口:尽量落终态,再映射成安全的对外失败码
ReleaseOutcome terminal = terminalOutcome(context); ReleaseOutcome terminal = terminalOutcome(context);
try { try {
runStore.finish(context, intent, terminal, null, null, 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) { private void completePath(RunContext context, IntentType intent, PathResult path) {
if (path.handledBudgetTermination()) { if (path.handledBudgetTermination()) {
if (intent != IntentType.DIAGNOSIS if (intent != IntentType.DIAGNOSIS
@@ -144,6 +183,12 @@ public final class ChatApplicationUseCase {
core.completeSuccess(context); core.completeSuccess(context);
} }
/**
* 根据 Router 产出的 intent 选择执行分支。
*
* <p>这是 Application 的核心编排决策:Router 只给枚举,分支执行权在这里。
* DIAGNOSIS 继续进入 {@link com.superbiz.agent.harness.application.executor.DiagnosisChatExecutor}。
*/
private PathResult executePath(IntentType intent, private PathResult executePath(IntentType intent,
RunContext context, RunContext context,
String query, String query,
@@ -162,6 +207,7 @@ public final class ChatApplicationUseCase {
ReleaseOutcome.SUCCESS, knowledgeQuery.execute(context, query), null, false); ReleaseOutcome.SUCCESS, knowledgeQuery.execute(context, query), null, false);
} }
case DIAGNOSIS -> { case DIAGNOSIS -> {
// 诊断子编排:Agent(ReAct) → Guard/Release,不在本类展开
DiagnosisExecutionResult result = diagnosis.execute( DiagnosisExecutionResult result = diagnosis.execute(
context, query, previousTurn, observer::onStatus); context, query, previousTurn, observer::onStatus);
yield new PathResult(result.outcome(), result.content(), result.publishedResult(), yield new PathResult(result.outcome(), result.content(), result.publishedResult(),
@@ -1,11 +1,38 @@
package com.superbiz.agent.harness.application; 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 { public enum ChatFailureCode {
/** Intent Router 不可用或输出无法解析,无法决定走哪条路径。 */
ROUTING_UNAVAILABLE, ROUTING_UNAVAILABLE,
/** 系统闲聊分支暂时无法回答。 */
SYSTEM_CHAT_UNAVAILABLE, SYSTEM_CHAT_UNAVAILABLE,
/** 知识问答分支暂时无法完成检索/作答。 */
KNOWLEDGE_UNAVAILABLE, KNOWLEDGE_UNAVAILABLE,
/** 诊断分支整体不可用(非具体 FALLBACK 细分)。 */
DIAGNOSIS_UNAVAILABLE, DIAGNOSIS_UNAVAILABLE,
/** session/run 读写落库失败(start/intent/finish 等)。 */
RUN_PERSISTENCE_FAILED, RUN_PERSISTENCE_FAILED,
/** Run 被取消(客户端断开、用户取消等),对应 RunState.CANCELLED。 */
RUN_CANCELLED, RUN_CANCELLED,
/** 未归类的内部失败;兜底码,应尽量少用、并靠 Trace 排查。 */
INTERNAL_FAILURE INTERNAL_FAILURE
} }
@@ -23,9 +23,22 @@ import com.superbiz.agent.harness.release.DiagnosisReleaseUseCase;
import java.util.Objects; import java.util.Objects;
import java.util.function.Consumer; 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 { public final class DiagnosisChatExecutor implements DiagnosisOperation {
/** Agent 接入:内部创建 ReactAgent 并 agent.call。 */
private final DiagnosisAgentUseCase diagnosisAgent; private final DiagnosisAgentUseCase diagnosisAgent;
/** 验证与发布:未证明的结论不能 SUCCESS 出口。 */
private final DiagnosisReleaseUseCase releaseUseCase; private final DiagnosisReleaseUseCase releaseUseCase;
private final PublishedResultPolicy publishedPolicy; private final PublishedResultPolicy publishedPolicy;
private final DiagnosisTraceRecorder traceRecorder; private final DiagnosisTraceRecorder traceRecorder;
@@ -46,27 +59,38 @@ public final class DiagnosisChatExecutor implements DiagnosisOperation {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null"); this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
} }
/**
* 诊断子路径:Agent 出草稿 → Release 裁决对外形态。
*
* @param statusSink 回写 SSE 进度(DIAGNOSIS_RUNNING / SAFETY_VALIDATING)
*/
@Override @Override
public DiagnosisExecutionResult execute(RunContext context, public DiagnosisExecutionResult execute(RunContext context,
String query, String query,
PreviousTurn previousTurn, PreviousTurn previousTurn,
Consumer<ChatApplicationStatus> statusSink) { Consumer<ChatApplicationStatus> statusSink) {
Objects.requireNonNull(statusSink, "statusSink must not be null"); Objects.requireNonNull(statusSink, "statusSink must not be null");
// SSE:诊断 Agent 运行中(内部可能多轮模型 + 工具)
statusSink.accept(ChatApplicationStatus.DIAGNOSIS_RUNNING); statusSink.accept(ChatApplicationStatus.DIAGNOSIS_RUNNING);
DiagnosisAgentExecution execution; DiagnosisAgentExecution execution;
try { try {
// ★ 段1:ReAct Agent——规划 tool_call、收证据、产出 DiagnosisDraft
execution = diagnosisAgent.execute( execution = diagnosisAgent.execute(
context, new DiagnosisAgentInput(query, previousTurn)); context, new DiagnosisAgentInput(query, previousTurn));
} catch (DiagnosisAgentOutputException exception) { } catch (DiagnosisAgentOutputException exception) {
// Draft 契约失败:有观察事实可降级 FALLBACK,否则上抛
return recoverInvalidDraft(context, exception, statusSink); return recoverInvalidDraft(context, exception, statusSink);
} }
// SSE:进入门控(Evidence 结构 + Semantic 语义)
statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING); statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING);
// ★ 段2:验证与发布——决定 SUCCESS 报告还是 SAFE_FALLBACK
DiagnosisReleaseResult released = releaseUseCase.execute(context, query, execution); DiagnosisReleaseResult released = releaseUseCase.execute(context, query, execution);
if (released.outcome() == ReleaseOutcome.FALLBACK) { if (released.outcome() == ReleaseOutcome.FALLBACK) {
return new DiagnosisExecutionResult( return new DiagnosisExecutionResult(
ReleaseOutcome.FALLBACK, ReleaseOutcome.FALLBACK,
new FallbackContent(released.fallback()), new FallbackContent(released.fallback()),
null, null,
// 预算耗尽导致的 FALLBACK:上层 completePath 不再 completeSuccess
execution.stopReason() execution.stopReason()
== com.superbiz.agent.harness.progress.DiagnosisStopReason.BUDGET_LIMIT_REACHED); == com.superbiz.agent.harness.progress.DiagnosisStopReason.BUDGET_LIMIT_REACHED);
} }
@@ -80,22 +104,52 @@ public final class DiagnosisChatExecutor implements DiagnosisOperation {
published); 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( private DiagnosisExecutionResult recoverInvalidDraft(
RunContext context, RunContext context,
DiagnosisAgentOutputException exception, DiagnosisAgentOutputException exception,
Consumer<ChatApplicationStatus> statusSink) { Consumer<ChatApplicationStatus> statusSink) {
// 1) 仅 Draft 契约失败可恢复;EXECUTION_FAILED 等保持原异常
if (!exception.isDraftContractFailure()) { if (!exception.isDraftContractFailure()) {
throw exception; throw exception;
} }
// 2) 是否已有可发布的观察事实(来自工具 canonical,不是模型胡写的 draft)
boolean hasProgress = exception.progress().hasObservedFacts(); boolean hasProgress = exception.progress().hasObservedFacts();
// 3) 审计:记录 kind / 输出字节 / 有无 progress,便于区分「模型格式烂」vs「彻底空跑」
traceRecorder.record(TraceAuditEvents.agentDraftInvalid( traceRecorder.record(TraceAuditEvents.agentDraftInvalid(
context, exception.kind(), exception.outputBytes(), hasProgress)); context, exception.kind(), exception.outputBytes(), hasProgress));
// 4) 无安全事实 → fail closed,交给 Application 写 FAILED
if (!hasProgress) { if (!hasProgress) {
throw exception; throw exception;
} }
// 5) 有事实:对齐 SSE 阶段,走「无合法 draft」专用发布(INSUFFICIENT_EVIDENCE 类 FALLBACK)
statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING); statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING);
DiagnosisReleaseResult released = releaseUseCase.releaseInvalidDraft( DiagnosisReleaseResult released = releaseUseCase.releaseInvalidDraft(
context, exception.progress()); context, exception.progress());
// 6) 对外只给安全 Fallback;非法 draft 正文永不出现在 content 里
return new DiagnosisExecutionResult( return new DiagnosisExecutionResult(
ReleaseOutcome.FALLBACK, ReleaseOutcome.FALLBACK,
new FallbackContent(released.fallback()), new FallbackContent(released.fallback()),
@@ -29,12 +29,26 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; 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 { public final class IntentRouter implements IntentRouting {
/** 输出契约:JSON 只能有 intent 一个字段。 */
private static final Set<String> OUTPUT_FIELDS = Set.of("intent"); private static final Set<String> OUTPUT_FIELDS = Set.of("intent");
private final DiagnosisHarnessCore core; private final DiagnosisHarnessCore core;
private final HarnessRetryExecutor retryExecutor; private final HarnessRetryExecutor retryExecutor;
/** 统一模型调用壳:入账 token、受 RunContext 约束。 */
private final GuardModelCall modelCall; private final GuardModelCall modelCall;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final IntentRouterLimits limits; private final IntentRouterLimits limits;
@@ -69,6 +83,11 @@ public final class IntentRouter implements IntentRouting {
this.systemPrompt = IntentRouterPrompt.load(); this.systemPrompt = IntentRouterPrompt.load();
} }
/**
* 对当前 query(+ 可选历史 intent/query)做一次意图分类。
*
* @return 仅 IntentType;Application 据此 switch 到对应 Executor
*/
@Override @Override
public IntentType route(RunContext context, IntentRouterInput input) { public IntentType route(RunContext context, IntentRouterInput input) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
@@ -78,11 +97,14 @@ public final class IntentRouter implements IntentRouting {
if (bytes > limits.maxInputBytes()) { if (bytes > limits.maxInputBytes()) {
throw new IntentRoutingException(new IllegalArgumentException("Router input exceeded limit")); throw new IntentRoutingException(new IllegalArgumentException("Router input exceeded limit"));
} }
// 先占 Run 字节预算,再调模型
core.reserveRunBytes(context, bytes); core.reserveRunBytes(context, bytes);
// Spring AI Prompt:system 规则 + user 侧结构化输入 JSON
Prompt prompt = new Prompt(List.of( Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt), new UserMessage(json))); new SystemMessage(systemPrompt), new UserMessage(json)));
long started = System.nanoTime(); long started = System.nanoTime();
try { try {
// 技术失败可按 policy 重试;取消/预算耗尽不能被重试吞掉
return retryExecutor.execute( return retryExecutor.execute(
context, context,
context.retryPolicies().intentRouter(), context.retryPolicies().intentRouter(),
@@ -103,6 +125,7 @@ public final class IntentRouter implements IntentRouting {
} }
} }
/** 严格解析:字段集合必须恰好为 {intent},值必须是 IntentType 枚举名。 */
private IntentType parse(String output) { private IntentType parse(String output) {
JsonNode root; JsonNode root;
try { try {
@@ -1,7 +1,23 @@
package com.superbiz.agent.harness.contract; package com.superbiz.agent.harness.contract;
/**
* 单次工具调用在「证据语义」上的结果状态(工具契约 / 进度)。
*
* <p>与 {@link InvocationStatus} 分工:
* <ul>
* <li>InvocationStatus:调用生命周期(投影中/就绪/错误)</li>
* <li>EvidenceStatus:这次调用有没有拿到可引用证据</li>
* </ul>
* NO_EVIDENCE 仍可能是 success 的工具执行(查了但空),常记为信息无增益。
*/
public enum EvidenceStatus { public enum EvidenceStatus {
/** 返回了可被引用的证据块。 */
EVIDENCE_FOUND, EVIDENCE_FOUND,
/** 执行完成但范围内无证据(空结果,不是必然系统故障)。 */
NO_EVIDENCE, NO_EVIDENCE,
/** 工具侧错误或无法形成合法证据观察。 */
ERROR ERROR
} }
@@ -1,10 +1,43 @@
package com.superbiz.agent.harness.contract; 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 { public enum FallbackType {
/** Evidence Guard:引用/结构校验未通过(含 repair 后仍失败)。 */
EVIDENCE_VALIDATION_FAILED, EVIDENCE_VALIDATION_FAILED,
/** Semantic Guard:结论不被证据支撑(verdict=UNSUPPORTED)。 */
SEMANTIC_UNSUPPORTED, SEMANTIC_UNSUPPORTED,
/** Semantic Guard:技术上无法完成语义评审(超时/不可用等),不发布根因。 */
SEMANTIC_UNAVAILABLE, SEMANTIC_UNAVAILABLE,
/**
* 历史/枚举保留:预算耗尽类降级。
* 当前主路径预算受控停止有安全进展时,通常映射为 {@link #INSUFFICIENT_EVIDENCE}。
*/
BUDGET_EXHAUSTED, BUDGET_EXHAUSTED,
/**
* 已做有限检查,但证据不足以确认根因。
* 典型来源:信息饱和 stopped、预算 stopped 有 facts、非法 draft 有 progress 的
* {@code releaseInvalidDraft} / {@code releaseControlledStop}。
*/
INSUFFICIENT_EVIDENCE, INSUFFICIENT_EVIDENCE,
/** 缺少定向诊断所需上下文(对象/时间窗等),尚未形成有效查询进展。 */
MISSING_REQUIRED_CONTEXT MISSING_REQUIRED_CONTEXT
} }
@@ -1,7 +1,19 @@
package com.superbiz.agent.harness.contract; package com.superbiz.agent.harness.contract;
/**
* 工具调用在 canonical store 中的生命周期状态。
*
* <p>READY 的记录才可作为 Evidence Guard 引用目标;
* PROJECTING 表示尚未完成投影落库;ERROR 表示本调用失败收尾。
*/
public enum InvocationStatus { public enum InvocationStatus {
/** 已 begin,正在执行/投影,尚不可作为最终引用。 */
PROJECTING, PROJECTING,
/** 投影完成且可引用(agentResult + evidenceStatus 已固化)。 */
READY, READY,
/** 本调用以错误结束。 */
ERROR ERROR
} }
@@ -1,8 +1,28 @@
package com.superbiz.agent.harness.contract; 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 { public enum ReleaseOutcome {
/** 通过门控,可发布 DIAGNOSIS_REPORT。 */
SUCCESS, SUCCESS,
/** 不发布根因报告,发布 SafeFallback(见 {@link FallbackType})。 */
FALLBACK, FALLBACK,
/** 无法形成可发布的安全内容(含无 observed facts 的预算停等)。 */
FAILED, FAILED,
/** 运行被取消,通常不再保证客户端能收到完整 done。 */
CANCELLED CANCELLED
} }
@@ -4,15 +4,41 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List; 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( public record SafeFallback(
/** 降级细分类型:EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / ... */
@JsonProperty("type") FallbackType type, @JsonProperty("type") FallbackType type,
/** 恒为 null(降级不发布根因结论,保留字段仅为契约完整性)。 */
@JsonProperty("conclusion") String conclusion, @JsonProperty("conclusion") String conclusion,
/** 一句话降级原因(用户可读)。 */
@JsonProperty("message") String message, @JsonProperty("message") String message,
/** 已验证来源(去重):查过哪些来源。 */
@JsonProperty("verified_sources") List<VerifiedSource> verifiedSources, @JsonProperty("verified_sources") List<VerifiedSource> verifiedSources,
/** 限制声明:检查范围 / 缺失项 / 降级原因。 */
@JsonProperty("limitations") List<String> limitations, @JsonProperty("limitations") List<String> limitations,
/** 下一步建议(用户可执行)。 */
@JsonProperty("next_steps") List<String> nextSteps, @JsonProperty("next_steps") List<String> nextSteps,
/** 失败阶段标识:DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION。 */
@JsonProperty("failure_stage") String failureStage, @JsonProperty("failure_stage") String failureStage,
/** 已观察事实(去重、有界):每条 = 来源 + 范围 + 摘要,供继续排查。 */
@JsonProperty("observed_facts") List<ObservedFact> observedFacts, @JsonProperty("observed_facts") List<ObservedFact> observedFacts,
/** 验证违规明细(EVIDENCE_VALIDATION_FAILED 时携带 code + target)。 */
@JsonProperty("validation_issues") List<ValidationIssue> validationIssues) { @JsonProperty("validation_issues") List<ValidationIssue> validationIssues) {
public SafeFallback { public SafeFallback {
@@ -33,12 +59,14 @@ public record SafeFallback(
null, List.of(), List.of()); null, List.of(), List.of());
} }
/** 已验证来源:来源类型 + 来源 + 范围(发布层可对外展示的最小来源单位)。 */
public record VerifiedSource( public record VerifiedSource(
@JsonProperty("source_type") String sourceType, @JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source, @JsonProperty("source") String source,
@JsonProperty("scope") String scope) { @JsonProperty("scope") String scope) {
} }
/** 已观察事实:来源类型 + 来源 + 范围 + 有界摘要(一条工具调用结果投影)。 */
public record ObservedFact( public record ObservedFact(
@JsonProperty("source_type") String sourceType, @JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source, @JsonProperty("source") String source,
@@ -46,6 +74,7 @@ public record SafeFallback(
@JsonProperty("summary") String summary) { @JsonProperty("summary") String summary) {
} }
/** 验证违规明细:违规码 + 目标字段(EVIDENCE_VALIDATION_FAILED 时携带)。 */
public record ValidationIssue( public record ValidationIssue(
@JsonProperty("code") String code, @JsonProperty("code") String code,
@JsonProperty("target") String target) { @JsonProperty("target") String target) {
@@ -1,6 +1,17 @@
package com.superbiz.agent.harness.contract; package com.superbiz.agent.harness.contract;
/**
* Semantic Guard 对「结论是否被证据支撑」的裁决。
*
* <p>SUPPORTED → 可走向 Release SUCCESS;
* UNSUPPORTED → 通常 FallbackType.SEMANTIC_UNSUPPORTED。
* (Guard 技术失败走 SEMANTIC_UNAVAILABLE,不一定落本枚举。)
*/
public enum SemanticVerdict { public enum SemanticVerdict {
/** 语义上认为草稿结论被已验证证据支持。 */
SUPPORTED, SUPPORTED,
/** 证据不足以支持当前根因/结论表述。 */
UNSUPPORTED UNSUPPORTED
} }
@@ -1,7 +1,19 @@
package com.superbiz.agent.harness.contract; package com.superbiz.agent.harness.contract;
/**
* SSE {@code done} 事件上的粗粒度结局(协议层)。
*
* <p>与 {@link ReleaseOutcome} 对齐的对外三态;取消场景连接可能已断,
* 不一定能发出 done(CANCELLED),故此处通常只有 SUCCESS / FALLBACK / FAILED。
*/
public enum SseOutcome { public enum SseOutcome {
/** 对应成功报告 content。 */
SUCCESS, SUCCESS,
/** 对应安全降级 content。 */
FALLBACK, FALLBACK,
/** 对应 failure 事件或失败 done(实现以 ChatSseSession 为准)。 */
FAILED FAILED
} }
@@ -1,11 +1,31 @@
package com.superbiz.agent.harness.core; package com.superbiz.agent.harness.core;
/**
* 硬预算维度:哪一类资源超限(Core / RunBudget)。
*
* <p>超限时抛 {@link BudgetExceededException},并常将 RunState 置为 {@code BUDGET_EXHAUSTED}。
* 这是资源账,不是「信息是否还有增益」(后者看 progress / DiagnosisStopReason)。
*/
public enum BudgetKind { public enum BudgetKind {
/** 整次 Run 允许的模型调用次数。 */
MODEL_CALLS, MODEL_CALLS,
/** 整次 Run 允许的工具调用总次数。 */
TOOL_CALLS, TOOL_CALLS,
/** 单个工具名的调用次数上限。 */
TOOL_CALLS_PER_TOOL, TOOL_CALLS_PER_TOOL,
/** 累计 input tokens。 */
INPUT_TOKENS, INPUT_TOKENS,
/** 累计 output tokens。 */
OUTPUT_TOKENS, OUTPUT_TOKENS,
/** 累计 total tokens。 */
TOTAL_TOKENS, TOTAL_TOKENS,
/** Run 级 UTF-8 字节预算(输入、工具结果、draft 等占用)。 */
RUN_BYTES RUN_BYTES
} }
@@ -10,6 +10,17 @@ import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.function.Supplier; 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 { public final class DiagnosisHarnessCore {
private final Clock clock; private final Clock clock;
@@ -66,6 +77,12 @@ public final class DiagnosisHarnessCore {
return startRun(sessionId, runIdSupplier.get()); return startRun(sessionId, runIdSupplier.get());
} }
/**
* 创建本次请求的 RunContext(显式上下文,不用 ThreadLocal)。
*
* <p>携带:身份(session/run)、deadline、预算、取消、生命周期、模型账本、进度收敛器。
* 后续所有模型与 Tool 调用必须传入同一个 context。
*/
public RunContext startRun(String sessionId, String runId) { public RunContext startRun(String sessionId, String runId) {
Instant deadline = clock.instant().plus(maxRunDuration); Instant deadline = clock.instant().plus(maxRunDuration);
RunCancellation cancellation = new RunCancellation(); RunCancellation cancellation = new RunCancellation();
@@ -81,10 +98,15 @@ public final class DiagnosisHarnessCore {
lifecycle, lifecycle,
new DiagnosisProgressTracker(stopAfterConsecutiveNoGain, new DiagnosisProgressTracker(stopAfterConsecutiveNoGain,
stopAfterConsecutiveProgressProtocolViolations)); stopAfterConsecutiveProgressProtocolViolations));
// 取消信号与终态联动:第一个终态获胜,迟到结果不能覆盖
cancellation.onCancel(reason -> lifecycle.finish(terminalState(reason), reason.name())); cancellation.onCancel(reason -> lifecycle.finish(terminalState(reason), reason.name()));
return context; return context;
} }
/**
* 执行前闸门:已终态 / 超时 / 已取消 → 抛 RunAbortedException。
* Router、Agent、Tool、Guard 在关键步骤前都会走到这里。
*/
public void checkActive(RunContext context) { public void checkActive(RunContext context) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
context.lifecycle().termination().ifPresent(termination -> { context.lifecycle().termination().ifPresent(termination -> {
@@ -102,11 +124,13 @@ public final class DiagnosisHarnessCore {
} }
} }
/** 模型调用前:检查 active + 预留模型调用预算。 */
public void beforeModelCall(RunContext context) { public void beforeModelCall(RunContext context) {
checkActive(context); checkActive(context);
applyBudget(context, context.budget()::reserveModelCall); applyBudget(context, context.budget()::reserveModelCall);
} }
/** 工具调用前:检查 active + 按工具名预留工具预算。 */
public void beforeToolCall(RunContext context, String toolName) { public void beforeToolCall(RunContext context, String toolName) {
checkActive(context); checkActive(context);
applyBudget(context, () -> context.budget().reserveToolCall(toolName)); applyBudget(context, () -> context.budget().reserveToolCall(toolName));
@@ -1,9 +1,30 @@
package com.superbiz.agent.harness.core; 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 { public enum RunCancellationReason {
/** SSE/HTTP 客户端断开。 */
CLIENT_DISCONNECTED, CLIENT_DISCONNECTED,
/** 显式用户取消(若产品支持)。 */
USER_REQUESTED, USER_REQUESTED,
/** 墙钟超过 Run deadline。 */
DEADLINE_EXCEEDED, DEADLINE_EXCEEDED,
/** 硬预算耗尽(与 BudgetExceededException 联动)。 */
BUDGET_EXHAUSTED, BUDGET_EXHAUSTED,
/** 应用判定内部失败并收尾。 */
INTERNAL_FAILURE INTERNAL_FAILURE
} }
@@ -1,11 +1,34 @@
package com.superbiz.agent.harness.core; 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 { public enum RunState {
/** 仍可执行(未终态)。 */
RUNNING(false), RUNNING(false),
/** 正常完成(Application completeSuccess)。 */
SUCCESS(true), SUCCESS(true),
/** 内部失败终态(completeFailure 等)。 */
FAILED(true), FAILED(true),
/** 取消终态(客户端断开、用户请求等)。 */
CANCELLED(true), CANCELLED(true),
/** 超过 Run deadline。 */
TIMED_OUT(true), TIMED_OUT(true),
/** 模型/工具/Token/字节等硬预算耗尽。可与 ReleaseOutcome.FALLBACK 并存。 */
BUDGET_EXHAUSTED(true); BUDGET_EXHAUSTED(true);
private final boolean terminal; private final boolean terminal;
@@ -14,6 +37,7 @@ public enum RunState {
this.terminal = terminal; this.terminal = terminal;
} }
/** 是否已是终态(终态后 checkActive 会 abort)。 */
public boolean isTerminal() { public boolean isTerminal() {
return terminal; return terminal;
} }
@@ -26,6 +26,27 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; 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 { public final class EvidenceGuard {
private final CanonicalInvocationStore store; private final CanonicalInvocationStore store;
@@ -35,6 +56,12 @@ public final class EvidenceGuard {
private final ObjectReader mysqlRequestReader; private final ObjectReader mysqlRequestReader;
private final ObjectReader mysqlResultReader; private final ObjectReader mysqlResultReader;
/**
* 构造:注入账本(canonical store)+ key 工厂 + 严格模式 reader。
*
* <p>四种 reader 全部开启 FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS——
* 多余字段、尾随内容一律解析失败(fail closed,不接受「看起来差不多」的投影)。
*/
public EvidenceGuard(CanonicalInvocationStore store, public EvidenceGuard(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory, ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
@@ -55,6 +82,12 @@ public final class EvidenceGuard {
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); .with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
} }
/**
* 主入口:draft 结构校验 → 逐条 tool_call 引用验真 → 重读投影重建证据。
*
* <p>任何违规都收集到 violations(不中断,尽量报全);全部通过才产出快照。
* 每个 analysis 只保留「证据非空」的条目——引用为空/无效的分析不进快照。
*/
public EvidenceGuardResult validate(RunContext context, DiagnosisDraft draft) { public EvidenceGuardResult validate(RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
List<EvidenceViolation> violations = validateDraft(draft); List<EvidenceViolation> violations = validateDraft(draft);
@@ -79,6 +112,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations); : EvidenceGuardResult.invalid(violations);
} }
/**
* 无结论场景的引用校验(conclusion == null 时由 release 调用):
* 只验引用真实性,不产出快照(返回 empty)——没有结论就没有「是否被支持」可判。
* 供 {@code DiagnosisReleaseUseCase.releaseNoConclusion} 发布前兜底验引用。
*/
public EvidenceGuardResult validateNoConclusionReferences( public EvidenceGuardResult validateNoConclusionReferences(
RunContext context, DiagnosisDraft draft) { RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
@@ -113,6 +151,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations); : EvidenceGuardResult.invalid(violations);
} }
/**
* 阶段 A:草稿结构完整性校验(纯规则,不碰 store)。
* 先收集全部 analysis id 到集合,再校验报告级引用只能指向这些已登记 id
* (封死「结论引用不存在的分析」路径)。
*/
private List<EvidenceViolation> validateDraft(DiagnosisDraft draft) { private List<EvidenceViolation> validateDraft(DiagnosisDraft draft) {
List<EvidenceViolation> violations = new ArrayList<>(); List<EvidenceViolation> violations = new ArrayList<>();
if (draft == null) { if (draft == null) {
@@ -149,6 +192,10 @@ public final class EvidenceGuard {
return violations; return violations;
} }
/**
* 报告级引用校验:conclusion / action_plan / recommendations 的
* based_on_analysis_ids 必须存在且指向已登记 analysis id;limitations 必填。
*/
private void validateReportReferences(DiagnosisDraft draft, Set<String> ids, private void validateReportReferences(DiagnosisDraft draft, Set<String> ids,
List<EvidenceViolation> violations) { List<EvidenceViolation> violations) {
if (draft.conclusion() != null) { 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, private void validateTextAndReferences(String target, String text, List<String> references,
Set<String> ids, List<EvidenceViolation> violations) { Set<String> ids, List<EvidenceViolation> violations) {
if (isBlank(text)) { 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, private void verifyInvocation(RunContext context, DiagnosisDraft.AnalysisItem analysis,
int analysisIndex, String toolCallId, int analysisIndex, String toolCallId,
List<VerifiedEvidence> evidence, 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, private void readRag(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) { String target, List<EvidenceViolation> violations) {
RagToolResult result; 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, private void readLogs(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) { String target, List<EvidenceViolation> violations) {
QueryLogsToolResult result; QueryLogsToolResult result;
@@ -364,6 +437,7 @@ public final class EvidenceGuard {
} }
} }
/** 投影通用一致性校验:toolCallId 与 evidenceStatus 必须与账本记录一致(防投影与账本脱节)。 */
private boolean projectionMatches(CanonicalToolInvocation invocation, String toolCallId, private boolean projectionMatches(CanonicalToolInvocation invocation, String toolCallId,
com.superbiz.agent.harness.contract.EvidenceStatus evidenceStatus, com.superbiz.agent.harness.contract.EvidenceStatus evidenceStatus,
String target, List<EvidenceViolation> violations) { String target, List<EvidenceViolation> violations) {
@@ -378,6 +452,10 @@ public final class EvidenceGuard {
return true; return true;
} }
/**
* 阶段 C(MYSQL):校验 MysqlToolResult(列唯一非空、returnedCount==rows.size()、
* 每行 keySet 必须恰好等于 columns),把每一行重建为 VerifiedEvidence(带 _row_number)。
*/
private void readMysql(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence, private void readMysql(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) { String target, List<EvidenceViolation> violations) {
MysqlToolRequest request; MysqlToolRequest request;
@@ -7,6 +7,10 @@ public record EvidenceGuardResult(
List<EvidenceViolation> violations, List<EvidenceViolation> violations,
VerifiedEvidenceSnapshot snapshot) { VerifiedEvidenceSnapshot snapshot) {
/**
* 结构不变量:valid 必须有 snapshot、invalid 必须有 violations,二选一无中间态
* (有效结果不可能带违规,无效结果不可能带快照)。
*/
public EvidenceGuardResult { public EvidenceGuardResult {
violations = violations == null ? List.of() : List.copyOf(violations); violations = violations == null ? List.of() : List.copyOf(violations);
if (violations.isEmpty() == (snapshot == null)) { if (violations.isEmpty() == (snapshot == null)) {
@@ -1,25 +1,76 @@
package com.superbiz.agent.harness.guard.evidence; package com.superbiz.agent.harness.guard.evidence;
/**
* Evidence Guard 结构/引用违规码(规则门控,通常不调大模型)。
*
* <p>层级:Guard。校验 DiagnosisDraft 是否引用真实 tool_call、字段是否齐全等。
* 失败时可尝试 EvidenceRepair;仍失败则 FallbackType.EVIDENCE_VALIDATION_FAILED。
*
* <p>与 SemanticVerdict 不同:这里只问「引用是否真实、结构是否合法」,
* 不问「结论语义是否夸大」。
*/
public enum EvidenceViolationCode { public enum EvidenceViolationCode {
/** 缺少整份 draft。 */
DRAFT_MISSING, DRAFT_MISSING,
/** 缺少 analysis 列表或为空。 */
ANALYSIS_MISSING, ANALYSIS_MISSING,
/** analysis 条目缺少 id。 */
ANALYSIS_ID_MISSING, ANALYSIS_ID_MISSING,
/** analysis id 重复。 */
ANALYSIS_ID_DUPLICATE, ANALYSIS_ID_DUPLICATE,
/** analysis 缺少 kind。 */
ANALYSIS_KIND_MISSING, ANALYSIS_KIND_MISSING,
/** analysis 缺少正文。 */
ANALYSIS_TEXT_MISSING, ANALYSIS_TEXT_MISSING,
/** 需要工具引用但未提供。 */
TOOL_REFERENCE_MISSING, TOOL_REFERENCE_MISSING,
/** 报告级必填文本缺失。 */
REPORT_TEXT_MISSING, REPORT_TEXT_MISSING,
/** 结论等引用了 analysis,但引用列表缺失。 */
ANALYSIS_REFERENCE_MISSING, ANALYSIS_REFERENCE_MISSING,
/** 引用了不存在的 analysis id。 */
ANALYSIS_REFERENCE_UNKNOWN, ANALYSIS_REFERENCE_UNKNOWN,
/** 缺少 limitations(诊断契约要求声明范围/缺口)。 */
LIMITATIONS_MISSING, LIMITATIONS_MISSING,
/** tool 引用字段非法。 */
TOOL_REFERENCE_INVALID, TOOL_REFERENCE_INVALID,
/** 引用的 tool_call 在本 Run 账本中不存在。 */
INVOCATION_MISSING, INVOCATION_MISSING,
/** tool_call_id 与账本记录不匹配。 */
INVOCATION_ID_MISMATCH, INVOCATION_ID_MISMATCH,
/** 该 invocation 状态不可被引用(非 READY 等)。 */
INVOCATION_NOT_REFERENCABLE, INVOCATION_NOT_REFERENCABLE,
/** 证据 kind 与工具/投影约定不符。 */
EVIDENCE_KIND_MISMATCH, EVIDENCE_KIND_MISMATCH,
/** 引用了不支持的工具名。 */
TOOL_UNSUPPORTED, TOOL_UNSUPPORTED,
/** 投影结果不可用于校验。 */
PROJECTION_INVALID, PROJECTION_INVALID,
/** 投影 id 与引用不一致。 */
PROJECTION_ID_MISMATCH, PROJECTION_ID_MISMATCH,
/** 投影状态与引用期望不一致。 */
PROJECTION_STATUS_MISMATCH, PROJECTION_STATUS_MISMATCH,
/** 从 canonical store 查找调用记录失败。 */
CANONICAL_LOOKUP_FAILED CANONICAL_LOOKUP_FAILED
} }
@@ -24,6 +24,19 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; 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 { public final class GuardModelCall {
private final DiagnosisHarnessCore core; private final DiagnosisHarnessCore core;
@@ -44,6 +57,11 @@ public final class GuardModelCall {
this.auditor = Objects.requireNonNull(auditor, "auditor must not be null"); 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, public String call(RunContext context, ModelCallComponent component,
Prompt prompt, Duration timeout, long maxOutputBytes) { Prompt prompt, Duration timeout, long maxOutputBytes) {
Objects.requireNonNull(context, "context must not be null"); 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, private String invoke(RunContext context, ModelCallLedger.Call call,
Prompt prompt, long maxOutputBytes) { Prompt prompt, long maxOutputBytes) {
ChatResponse response; ChatResponse response;
@@ -119,6 +142,7 @@ public final class GuardModelCall {
return output.getText(); return output.getText();
} }
/** 记账 usage;缺 metadata/usage 时按 0 记账(保持账本完整性,可对账)。 */
private void recordUsage(RunContext context, ModelCallLedger.Call call, ChatResponse response) { private void recordUsage(RunContext context, ModelCallLedger.Call call, ChatResponse response) {
if (response == null || response.getMetadata() == null) { if (response == null || response.getMetadata() == null) {
auditor.recordUsage(context, call, 0, 0, false); auditor.recordUsage(context, call, 0, 0, false);
@@ -24,6 +24,24 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Consumer; 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 { public final class SemanticGuard {
private static final Set<String> OUTPUT_FIELDS = Set.of("verdict", "reason"); private static final Set<String> OUTPUT_FIELDS = Set.of("verdict", "reason");
@@ -65,6 +83,12 @@ public final class SemanticGuard {
this.prompt = SemanticGuardPrompt.load(); this.prompt = SemanticGuardPrompt.load();
} }
/**
* 主入口:序列化输入(超限即拒绝 SCHEMA_INVALID)→ 计入预算
* → 构造 System(prompt)+User(输入 JSON) 双消息
* → 经 HarnessRetryExecutor 按 semanticGuard 策略执行模型调用
* → 硬校验输出 schema → 返回裁决。每次 attempt 都记审计 trace。
*/
public SemanticGuardDecision review(RunContext context, SemanticGuardInput input) { public SemanticGuardDecision review(RunContext context, SemanticGuardInput input) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(input, "input 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) { private SemanticGuardDecision parse(String output) {
JsonNode root; JsonNode root;
try { try {
@@ -115,6 +144,10 @@ public final class SemanticGuard {
return new SemanticGuardDecision(verdict, root.path("reason").asText()); return new SemanticGuardDecision(verdict, root.path("reason").asText());
} }
/**
* 每次 attempt 的剩余超时 = min(总超时 - 已用, 单次上限);
* 总超时耗尽即抛 TIMEOUT(不再重试)——守卫判定有硬截止线。
*/
private Duration remainingTimeout(long startedNanos) { private Duration remainingTimeout(long startedNanos) {
long elapsed = Math.max(0L, System.nanoTime() - startedNanos); long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
long remaining = limits.totalTimeout().toNanos() - elapsed; long remaining = limits.totalTimeout().toNanos() - elapsed;
@@ -125,6 +158,10 @@ public final class SemanticGuard {
return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos())); return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos()));
} }
/**
* 失败分类:GuardModelCallException 自带 RetryFailure;
* 其余未知异常归 UNKNOWN(不重试,直接失败)。
*/
private RetryFailure classify(Exception exception) { private RetryFailure classify(Exception exception) {
if (exception instanceof GuardModelCallException guardFailure) { if (exception instanceof GuardModelCallException guardFailure) {
return guardFailure.failure(); return guardFailure.failure();
@@ -1,6 +1,16 @@
package com.superbiz.agent.harness.progress; package com.superbiz.agent.harness.progress;
/**
* 证据收集阶段状态机(Progress 层,与 RunState 独立)。
*
* <p>SATURATED 表示「再查也难有信息增益」,会触发 stop_required;
* 不等于 Run 已经 BUDGET_EXHAUSTED 或对外已经 FALLBACK。
*/
public enum DiagnosisCollectionState { public enum DiagnosisCollectionState {
/** 仍允许在预算内发起工具调用(需遵守进度协议)。 */
COLLECTING, COLLECTING,
/** 已饱和:交付一次 STOP 指示后,再要工具可抛 DiagnosisCollectionStoppedException。 */
SATURATED SATURATED
} }
@@ -16,16 +16,42 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; 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 { public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
private static final int MAX_FACTS = 12; private static final int MAX_FACTS = 12;
/** 每条事实摘要的最大字符数。 */
private static final int MAX_SUMMARY_CHARS = 320; private static final int MAX_SUMMARY_CHARS = 320;
/** 查询范围(scope)的最大字符数。 */
private static final int MAX_SCOPE_CHARS = 320; private static final int MAX_SCOPE_CHARS = 320;
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
private final CanonicalInvocationStore store; private final CanonicalInvocationStore store;
/** 生成 canonical key(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory; private final ToolCallKeyFactory keyFactory;
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
/**
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
*/
public DiagnosisProgressProjector(CanonicalInvocationStore store, public DiagnosisProgressProjector(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory, ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
} }
/**
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
* limitations + stopReason),供 Release 发布。
*/
@Override @Override
public DiagnosisProgressSnapshot project(RunContext context) { public DiagnosisProgressSnapshot project(RunContext context) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
for (CompletedToolCall completed : state.completedToolCalls()) { for (CompletedToolCall completed : state.completedToolCalls()) {
CanonicalToolInvocation invocation = resolve(context, completed, limitations); CanonicalToolInvocation invocation = resolve(context, completed, limitations);
if (invocation == null) { if (invocation == null) {
// 无法验真/不可读:已记 limitation,跳过
continue; continue;
} }
try { try {
projectInvocation(completed, invocation, sources, facts); projectInvocation(completed, invocation, sources, facts);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实"); addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
} }
if (facts.size() >= MAX_FACTS) { if (facts.size() >= MAX_FACTS) {
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
state.stopReason()); state.stopReason());
} }
/**
* 按 identity 回读 canonical 记录,做三重校验:
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
*/
private CanonicalToolInvocation resolve(RunContext context, private CanonicalToolInvocation resolve(RunContext context,
CompletedToolCall completed, CompletedToolCall completed,
List<String> limitations) { List<String> limitations) {
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
} }
return invocation; return invocation;
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实"); addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
return null; return null;
} }
} }
/**
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
*/
private void projectInvocation(CompletedToolCall completed, private void projectInvocation(CompletedToolCall completed,
CanonicalToolInvocation invocation, CanonicalToolInvocation invocation,
Map<String, SafeFallback.VerifiedSource> sources, 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, private void projectRag(JsonNode root,
String scope, String scope,
Map<String, SafeFallback.VerifiedSource> sources, Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts) { Map<String, SafeFallback.ObservedFact> facts) {
JsonNode evidence = root.path("evidence"); JsonNode evidence = root.path("evidence");
if (!evidence.isArray() || evidence.isEmpty()) { if (!evidence.isArray() || evidence.isEmpty()) {
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
addFact(sources, facts, "RAG", "knowledge_base", scope, addFact(sources, facts, "RAG", "knowledge_base", scope,
"该知识检索范围内未发现可用文档证据"); "该知识检索范围内未发现可用文档证据");
return; return;
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
} }
} }
/**
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
* source 取自 source_kind(缺省 logs)。
*/
private void projectLogs(JsonNode root, private void projectLogs(JsonNode root,
String scope, String scope,
Map<String, SafeFallback.VerifiedSource> sources, 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, private void projectMysql(JsonNode root,
String scope, String scope,
Map<String, SafeFallback.VerifiedSource> sources, 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) { private String publicScope(String toolName, String normalizedScope, JsonNode result) {
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) { if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS); 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) { private String mysqlSource(String scope) {
int separator = scope.indexOf('='); int separator = scope.indexOf('=');
return separator < 0 ? "mysql" : scope.substring(separator + 1); 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, private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts, Map<String, SafeFallback.ObservedFact> facts,
String sourceType, String sourceType,
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary)); new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
} }
/**
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
* (由调用方捕获后记为 limitation,不发布)。
*/
private JsonNode readObject(String value) { private JsonNode readObject(String value) {
try { try {
JsonNode root = objectMapper.readTree(value); 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) { private static void addLimitation(List<String> limitations, String value) {
if (!limitations.contains(value)) { if (!limitations.contains(value)) {
limitations.add(value); limitations.add(value);
} }
} }
/** 取第一个非空值,全空返回 "unknown"。 */
private static String firstNonBlank(String... values) { private static String firstNonBlank(String... values) {
for (String value : values) { for (String value : values) {
if (value != null && !value.isBlank()) { if (value != null && !value.isBlank()) {
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
return "unknown"; return "unknown";
} }
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
private static String text(JsonNode node, String field) { private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field); JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText(""); return value == null || value.isNull() ? "" : value.asText("");
} }
/** 截断到 max 字符(null 视为空串)。 */
private static String bounded(String value, int max) { private static String bounded(String value, int max) {
String safe = value == null ? "" : value; String safe = value == null ? "" : value;
return safe.length() <= max ? safe : safe.substring(0, max); return safe.length() <= max ? safe : safe.substring(0, max);
@@ -4,12 +4,29 @@ import com.superbiz.agent.harness.contract.SafeFallback;
import java.util.List; 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( public record DiagnosisProgressSnapshot(
/** 已验证来源(去重后):只到「查过哪些来源」粒度,供 FALLBACK 展示。 */
List<SafeFallback.VerifiedSource> verifiedSources, List<SafeFallback.VerifiedSource> verifiedSources,
/** 已观察事实(去重后):每条 = 来源类型 + 来源 + 范围 + 有界摘要,
* 是「Run 真的查过什么、结果如何」的证据性记录(空查询也算事实)。 */
List<SafeFallback.ObservedFact> observedFacts, List<SafeFallback.ObservedFact> observedFacts,
/** 限制声明:无法验真/不可读/截断等原因的诚实说明。 */
List<String> limitations, List<String> limitations,
/** 停止原因(受控停止时):信息饱和 / 预算耗尽 / 协议违规。 */
DiagnosisStopReason stopReason) { DiagnosisStopReason stopReason) {
/** 防御:三列表全部转不可变,null 视为空列表。 */
public DiagnosisProgressSnapshot { public DiagnosisProgressSnapshot {
verifiedSources = verifiedSources == null ? List.of() : List.copyOf(verifiedSources); verifiedSources = verifiedSources == null ? List.of() : List.copyOf(verifiedSources);
observedFacts = observedFacts == null ? List.of() : List.copyOf(observedFacts); 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); return new DiagnosisProgressSnapshot(List.of(), List.of(), List.of(), null);
} }
/**
* 「是否有安全进展」的判断依据:observedFacts 非空即视为有已验真事实。
* release 域的 fail-closed 分支全靠它——没有事实就不能把
* 「没查到」伪装成业务结果发布。
*/
public boolean hasObservedFacts() { public boolean hasObservedFacts() {
return !observedFacts.isEmpty(); return !observedFacts.isEmpty();
} }
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Set; 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 { public final class DiagnosisProgressTracker {
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
private final int stopAfterConsecutiveNoGain; private final int stopAfterConsecutiveNoGain;
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
private final int stopAfterConsecutiveProgressProtocolViolations; private final int stopAfterConsecutiveProgressProtocolViolations;
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>(); private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>(); private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
/** 连续无增益次数;GAINED 清零。 */
private int consecutiveNoGain; private int consecutiveNoGain;
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
private int consecutiveProgressProtocolViolations; private int consecutiveProgressProtocolViolations;
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING; private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
private DiagnosisStopReason stopReason; private DiagnosisStopReason stopReason;
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
private String pendingToolCallId; private String pendingToolCallId;
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
private boolean stopInstructionDelivered; private boolean stopInstructionDelivered;
/**
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
*/
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) { public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
this(stopAfterConsecutiveNoGain, 2); this(stopAfterConsecutiveNoGain, 2);
} }
/**
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
*
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
*/
public DiagnosisProgressTracker( public DiagnosisProgressTracker(
int stopAfterConsecutiveNoGain, int stopAfterConsecutiveNoGain,
int stopAfterConsecutiveProgressProtocolViolations) { int stopAfterConsecutiveProgressProtocolViolations) {
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
stopAfterConsecutiveProgressProtocolViolations; 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) { public synchronized void applyPreviousObservation(PreviousObservation observation) {
if (pendingToolCallId == null) { if (pendingToolCallId == null) {
if (observation != null) { if (observation != null) {
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
"previous_observation", "previous_observation",
null); null);
} }
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
clearProtocolViolations(); clearProtocolViolations();
return; return;
} }
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
"previous_observation.tool_call_id", "previous_observation.tool_call_id",
pendingToolCallId); pendingToolCallId);
} }
// 校验通过:清空 pending,评价生效
pendingToolCallId = null; pendingToolCallId = null;
clearProtocolViolations(); clearProtocolViolations();
applyGain(observation.informationGain()); applyGain(observation.informationGain());
} }
/**
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
*/
public synchronized boolean isDuplicate(String toolName, String normalizedScope) { public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope)); return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
} }
/**
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
*/
public synchronized void recordDuplicateScope() { public synchronized void recordDuplicateScope() {
clearProtocolViolations(); clearProtocolViolations();
applyGain(InformationGain.NO_GAIN); 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) { public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
if (collectionState == DiagnosisCollectionState.SATURATED) { if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Cannot record Tool completion after saturation"); throw new IllegalStateException("Cannot record Tool completion after saturation");
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
} }
completedToolCalls.add(call); completedToolCalls.add(call);
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) { if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
// 空结果无需模型评价:立即累计 NO_GAIN
clearProtocolViolations(); clearProtocolViolations();
applyGain(InformationGain.NO_GAIN); applyGain(InformationGain.NO_GAIN);
} else { } else {
// 非空结果:挂起等待模型在下一轮评价语义增益
pendingToolCallId = call.toolCallId(); pendingToolCallId = call.toolCallId();
} }
} }
/**
* 记录一次 progress 协议违规,返回最新快照。
*
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
*/
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() { public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
if (collectionState == DiagnosisCollectionState.SATURATED) { if (collectionState == DiagnosisCollectionState.SATURATED) {
// 已饱和:不再累计,直接返回当前快照
return snapshot(); return snapshot();
} }
consecutiveProgressProtocolViolations++; consecutiveProgressProtocolViolations++;
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
return snapshot(); return snapshot();
} }
/**
* 领取一次停止指令(STOP_REQUIRED)。
*
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
*/
public synchronized boolean claimStopInstruction() { public synchronized boolean claimStopInstruction() {
if (collectionState != DiagnosisCollectionState.SATURATED) { if (collectionState != DiagnosisCollectionState.SATURATED) {
return false; return false;
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
return true; return true;
} }
/**
* 标记预算触顶(由 RunBudget 侧调用)。
*
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
* 信息饱和不能伪装成预算耗尽。
*/
public synchronized void markBudgetLimitReached() { public synchronized void markBudgetLimitReached() {
if (stopReason == null) { if (stopReason == null) {
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED; stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
} }
} }
/**
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
*/
public synchronized DiagnosisProgressSnapshotState snapshot() { public synchronized DiagnosisProgressSnapshotState snapshot() {
return new DiagnosisProgressSnapshotState( return new DiagnosisProgressSnapshotState(
consecutiveNoGain, consecutiveNoGain,
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
return stopAfterConsecutiveProgressProtocolViolations; return stopAfterConsecutiveProgressProtocolViolations;
} }
/**
* 应用单次增益判定(核心状态迁移):
* <ul>
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
* </ul>
* 饱和后禁止再次应用(停止权只行使一次)。
*/
private void applyGain(InformationGain gain) { private void applyGain(InformationGain gain) {
if (collectionState == DiagnosisCollectionState.SATURATED) { if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Collection is already saturated"); throw new IllegalStateException("Collection is already saturated");
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
} }
} }
/**
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
*/
private void clearProtocolViolations() { private void clearProtocolViolations() {
consecutiveProgressProtocolViolations = 0; consecutiveProgressProtocolViolations = 0;
} }
@@ -1,7 +1,26 @@
package com.superbiz.agent.harness.progress; 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 { public enum DiagnosisStopReason {
/** 连续无信息增益(或等价饱和策略),收集状态进入 SATURATED。 */
INFORMATION_SATURATED, INFORMATION_SATURATED,
/** 模型次数/工具次数/Token/bytes 等硬预算触顶。 */
BUDGET_LIMIT_REACHED, BUDGET_LIMIT_REACHED,
/** 进度协议连续违规达到阈值(envelope / previous_observation 等)。 */
PROGRESS_PROTOCOL_VIOLATED PROGRESS_PROTOCOL_VIOLATED
} }
@@ -1,6 +1,16 @@
package com.superbiz.agent.harness.progress; package com.superbiz.agent.harness.progress;
/**
* 单次工具观察相对已有收集是否带来新信息(Progress 协议字段)。
*
* <p>模型在后续 tool envelope 的 previous_observation 中声明;
* 连续 NO_GAIN 可推动 CollectionState → SATURATED。
*/
public enum InformationGain { public enum InformationGain {
/** 相对已完成查询有新的可用信息。 */
GAINED, GAINED,
/** 无新增信息(含重复 scope、空证据等由 Harness 判定的情况)。 */
NO_GAIN NO_GAIN
} }
@@ -1,9 +1,26 @@
package com.superbiz.agent.harness.progress; 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 { public enum ProgressProtocolViolationType {
/** 非首次工具调用缺少对上一观察的 previous_observation。 */
MISSING_PREVIOUS_OBSERVATION, MISSING_PREVIOUS_OBSERVATION,
/** previous_observation 指向的 tool_call_id 与账本顺序不符。 */
OUT_OF_ORDER_PREVIOUS_OBSERVATION, OUT_OF_ORDER_PREVIOUS_OBSERVATION,
/** 出现了协议不允许的 previous_observation(例如首次就带、或指向未知 id)。 */
UNEXPECTED_PREVIOUS_OBSERVATION, UNEXPECTED_PREVIOUS_OBSERVATION,
/** 缺少必填 business input。 */
MISSING_INPUT, MISSING_INPUT,
/** 整体 Envelope 结构非法(JSON/字段形态不对)。 */
INVALID_ENVELOPE INVALID_ENVELOPE
} }
@@ -7,12 +7,23 @@ import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import java.util.Objects; import java.util.Objects;
/**
* 发布裁决结果(Release 域唯一出口的结果类型)。
*
* <p>结构不变量:SUCCESS 必须有 draft 且不允许带 fallback;
* FALLBACK 必须有 fallback 且不允许带 draft——
* 成功只能带验证过的草稿、降级只能带安全回退,绝无「半真半假」的中间产物。
*/
public record DiagnosisReleaseResult( public record DiagnosisReleaseResult(
ReleaseOutcome outcome, ReleaseOutcome outcome,
DiagnosisDraft draft, DiagnosisDraft draft,
SafeFallback fallback, SafeFallback fallback,
VerifiedEvidenceSnapshot verifiedEvidence) { VerifiedEvidenceSnapshot verifiedEvidence) {
/**
* 结构不变量:outcome 必填;SUCCESS ↔ draft、FALLBACK ↔ fallback 严格互斥;
* 本域只支持 SUCCESS / FALLBACK 两个出口(FAILED/CANCELLED 由 Application 层写)。
*/
public DiagnosisReleaseResult { public DiagnosisReleaseResult {
Objects.requireNonNull(outcome, "outcome must not be null"); Objects.requireNonNull(outcome, "outcome must not be null");
Objects.requireNonNull(verifiedEvidence, "verifiedEvidence 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.Objects;
import java.util.List; 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 { public final class DiagnosisReleaseUseCase {
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
private final EvidenceGuard evidenceGuard; private final EvidenceGuard evidenceGuard;
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
private final EvidenceRepair evidenceRepair; private final EvidenceRepair evidenceRepair;
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
private final SemanticGuard semanticGuard; private final SemanticGuard semanticGuard;
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
private final SafeFallbackFactory fallbackFactory; private final SafeFallbackFactory fallbackFactory;
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
private final DiagnosisTraceRecorder traceRecorder; private final DiagnosisTraceRecorder traceRecorder;
/**
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard, public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair, EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard, SemanticGuard semanticGuard,
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
DiagnosisTraceRecorder.noop()); DiagnosisTraceRecorder.noop());
} }
/**
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard, public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair, EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard, SemanticGuard semanticGuard,
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null"); this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
} }
/**
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
* 包装成 completed execution(progress 为空),走完整裁决。
*/
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) { public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
return execute(context, query, DiagnosisAgentExecution.completed( return execute(context, query, DiagnosisAgentExecution.completed(
Objects.requireNonNull(draft, "draft must not be null"), Objects.requireNonNull(draft, "draft must not be null"),
DiagnosisProgressSnapshot.empty())); DiagnosisProgressSnapshot.empty()));
} }
/**
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
*
* <ul>
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
* <li>conclusion == null:模型明确无结论,校验其引用后按
* 有无已验真事实 / missing_info 决定发布类型;</li>
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
* </ul>
*/
public DiagnosisReleaseResult execute( public DiagnosisReleaseResult execute(
RunContext context, String query, DiagnosisAgentExecution execution) { RunContext context, String query, DiagnosisAgentExecution execution) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
DiagnosisDraft draft = execution.draft(); DiagnosisDraft draft = execution.draft();
if (draft == null) { if (draft == null) {
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
return releaseControlledStop(context, execution.progress(), execution.stopReason()); return releaseControlledStop(context, execution.progress(), execution.stopReason());
} }
if (draft.conclusion() == null) { if (draft.conclusion() == null) {
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
return releaseNoConclusion(context, draft, execution.progress()); return releaseNoConclusion(context, draft, execution.progress());
} }
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
return releaseConclusion(context, query, draft); return releaseConclusion(context, query, draft);
} }
/**
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
*/
public DiagnosisReleaseResult releaseInvalidDraft( public DiagnosisReleaseResult releaseInvalidDraft(
RunContext context, DiagnosisProgressSnapshot progress) { RunContext context, DiagnosisProgressSnapshot progress) {
Objects.requireNonNull(context, "context must not be null"); Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(progress, "progress must not be null"); Objects.requireNonNull(progress, "progress must not be null");
if (!progress.hasObservedFacts()) { if (!progress.hasObservedFacts()) {
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
throw new IllegalStateException( throw new IllegalStateException(
"Invalid Diagnosis Draft has no verified publishable progress"); "Invalid Diagnosis Draft has no verified publishable progress");
} }
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
FallbackType.INSUFFICIENT_EVIDENCE); FallbackType.INSUFFICIENT_EVIDENCE);
} }
/**
* 有结论 Draft 的完整发布链:
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
*/
private DiagnosisReleaseResult releaseConclusion( private DiagnosisReleaseResult releaseConclusion(
RunContext context, String query, DiagnosisDraft draft) { RunContext context, String query, DiagnosisDraft draft) {
DiagnosisDraft candidate = draft; DiagnosisDraft candidate = draft;
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate)); context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
if (!evidence.valid()) { if (!evidence.valid()) {
try { try {
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
candidate = evidenceRepair.repair(context, query, draft, evidence.violations()); candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
evidence = evidenceGuard.validate(context, candidate); evidence = evidenceGuard.validate(context, candidate);
traceRecorder.record(TraceAuditEvents.evidenceValidation( traceRecorder.record(TraceAuditEvents.evidenceValidation(
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
return evidenceFailure(context, evidence); return evidenceFailure(context, evidence);
} }
if (!evidence.valid()) { if (!evidence.valid()) {
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
return evidenceFailure(context, evidence); return evidenceFailure(context, evidence);
} }
} }
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
decision = semanticGuard.review( decision = semanticGuard.review(
context, SemanticGuardInput.from(query, candidate, snapshot)); context, SemanticGuardInput.from(query, candidate, snapshot));
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
propagateTerminal(exception); propagateTerminal(exception);
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context)); traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
traceRecorder.record(TraceAuditEvents.releaseDecision( traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
} }
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict())); traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
if (decision.verdict() == SemanticVerdict.SUPPORTED) { if (decision.verdict() == SemanticVerdict.SUPPORTED) {
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
traceRecorder.record(TraceAuditEvents.releaseDecision( traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null)); context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
return DiagnosisReleaseResult.success(candidate, snapshot); return DiagnosisReleaseResult.success(candidate, snapshot);
} }
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
traceRecorder.record(TraceAuditEvents.releaseDecision( traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK, context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
FallbackType.SEMANTIC_UNSUPPORTED)); FallbackType.SEMANTIC_UNSUPPORTED));
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot)); return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
} }
/**
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
* 都没有 → 不变量被破坏,fail closed 抛异常。
*/
private DiagnosisReleaseResult releaseNoConclusion( private DiagnosisReleaseResult releaseNoConclusion(
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) { RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft); EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
List<String> missingInfo = missingInfo(draft); List<String> missingInfo = missingInfo(draft);
if (progress.hasObservedFacts()) { if (progress.hasObservedFacts()) {
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
return progressFallback(context, return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, missingInfo), fallbackFactory.insufficientEvidence(progress, missingInfo),
FallbackType.INSUFFICIENT_EVIDENCE); FallbackType.INSUFFICIENT_EVIDENCE);
} }
if (!missingInfo.isEmpty()) { if (!missingInfo.isEmpty()) {
// 零 Tool 直接声明缺上下文:合法,不强制空查
return progressFallback(context, return progressFallback(context,
fallbackFactory.missingRequiredContext(missingInfo), fallbackFactory.missingRequiredContext(missingInfo),
FallbackType.MISSING_REQUIRED_CONTEXT); FallbackType.MISSING_REQUIRED_CONTEXT);
} }
// 既无进展又无缺失声明 → 状态非法,fail closed
throw new IllegalStateException( throw new IllegalStateException(
"No-conclusion Diagnosis has neither verified progress nor missing context"); "No-conclusion Diagnosis has neither verified progress nor missing context");
} }
/**
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
*/
private DiagnosisReleaseResult releaseControlledStop( private DiagnosisReleaseResult releaseControlledStop(
RunContext context, RunContext context,
DiagnosisProgressSnapshot progress, DiagnosisProgressSnapshot progress,
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
throw new IllegalStateException("Unsupported Diagnosis stop reason"); throw new IllegalStateException("Unsupported Diagnosis stop reason");
} }
if (!progress.hasObservedFacts()) { if (!progress.hasObservedFacts()) {
// 没有已验证进展 → fail closed(对外不可发布任何结论)
throw new IllegalStateException( throw new IllegalStateException(
"Controlled Diagnosis stop has no verified publishable progress"); "Controlled Diagnosis stop has no verified publishable progress");
} }
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
return progressFallback(context, return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, List.of()), fallbackFactory.insufficientEvidence(progress, List.of()),
FallbackType.INSUFFICIENT_EVIDENCE); FallbackType.INSUFFICIENT_EVIDENCE);
} }
/**
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
*/
private DiagnosisReleaseResult progressFallback( private DiagnosisReleaseResult progressFallback(
RunContext context, RunContext context,
com.superbiz.agent.harness.contract.SafeFallback fallback, com.superbiz.agent.harness.contract.SafeFallback fallback,
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
return DiagnosisReleaseResult.fallback(fallback); return DiagnosisReleaseResult.fallback(fallback);
} }
/**
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
*/
private List<String> missingInfo(DiagnosisDraft draft) { private List<String> missingInfo(DiagnosisDraft draft) {
return draft.limitations() == null return draft.limitations() == null
? List.of() : draft.limitations().missingInfo(); ? List.of() : draft.limitations().missingInfo();
} }
/**
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
* (违规明细进 fallback,不发布模型原文)。
*/
private DiagnosisReleaseResult evidenceFailure( private DiagnosisReleaseResult evidenceFailure(
RunContext context, EvidenceGuardResult evidence) { RunContext context, EvidenceGuardResult evidence) {
traceRecorder.record(TraceAuditEvents.releaseDecision( traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
fallbackFactory.evidenceValidationFailed(evidence.violations())); fallbackFactory.evidenceValidationFailed(evidence.violations()));
} }
/**
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
*/
private void propagateTerminal(RuntimeException exception) { private void propagateTerminal(RuntimeException exception) {
if (exception instanceof RunAbortedException if (exception instanceof RunAbortedException
|| exception instanceof BudgetExceededException) { || exception instanceof BudgetExceededException) {

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