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
163 changed files with 5555 additions and 8643 deletions
+16 -54
View File
@@ -5,9 +5,9 @@
SERVER_URL = http://localhost:9900
UPLOAD_API = $(SERVER_URL)/api/upload
DOCS_DIR = aiops-docs
HEALTH_CHECK_API = $(SERVER_URL)/milvus/health
DOCKER_COMPOSE_FILE = vector-database.yml
MILVUS_CONTAINER = milvus-standalone
# 服务就绪探测:9900 端口有 HTTP 响应即视为就绪
HEALTH_CHECK = curl -s -o /dev/null --connect-timeout 2 $(SERVER_URL)
DOCKER_COMPOSE_FILE = docker-compose.yml
# 颜色输出
GREEN = \033[0;32m
@@ -23,7 +23,7 @@ help:
@echo ""
@echo "可用命令:"
@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 status$(NC) - 查看 Docker 容器状态"
@echo " $(YELLOW)make start$(NC) - 启动 Spring Boot 服务(后台运行)"
@@ -42,7 +42,7 @@ help:
init:
@echo "$(GREEN)🚀 开始一键初始化 SuperBizAgent...$(NC)"
@echo ""
@echo "$(YELLOW)步骤 1/4: 启动 Docker Compose(Milvus 向量数据库)$(NC)"
@echo "$(YELLOW)步骤 1/4: 启动 Docker Compose(MySQL/Redis)$(NC)"
@$(MAKE) up
@echo ""
@echo "$(YELLOW)步骤 2/4: 启动 Spring Boot 服务$(NC)"
@@ -51,23 +51,23 @@ init:
@echo "$(YELLOW)步骤 3/4: 等待服务就绪$(NC)"
@$(MAKE) wait
@echo ""
@echo "$(YELLOW)步骤 4/4: 上传 AIOps 文档到向量数据库$(NC)"
@echo "$(YELLOW)步骤 4/4: 上传 AIOps 文档(经 py-rag 入库)$(NC)"
@$(MAKE) upload
@echo ""
@echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)"
@echo "$(GREEN)✅ 初始化完成!所有文档已成功向量化存储到数据库$(NC)"
@echo "$(GREEN)✅ 初始化完成!所有文档已成功入库(py-rag)$(NC)"
@echo "$(GREEN)═══════════════════════════════════════════════════════$(NC)"
@echo ""
@echo "$(GREEN)🌐 服务访问地址:$(NC)"
@echo " API 服务: $(SERVER_URL)"
@echo " Attu (Web UI): http://localhost:8000"
@echo "$(YELLOW)💡 提示: 知识检索/入库由 py-rag 服务承担,请在其仓库单独启动$(NC)"
@echo ""
@echo "$(YELLOW)💡 提示: 服务正在后台运行,查看日志: tail -f server.log$(NC)"
# 启动 Spring Boot 服务(后台运行)
start:
@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)"; \
else \
echo "$(YELLOW)📦 正在启动服务(后台运行)...$(NC)"; \
@@ -84,7 +84,7 @@ wait:
@max_attempts=60; \
attempt=0; \
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)"; \
exit 0; \
fi; \
@@ -100,7 +100,7 @@ wait:
# 检查服务器是否运行
check:
@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)"; \
else \
echo "$(RED)❌ 服务器未运行或无法连接!$(NC)"; \
@@ -205,38 +205,14 @@ test-upload:
echo "$(RED)测试文件不存在$(NC)"; \
fi
# 启动 Docker Compose(智能检测,避免重复启动)
# 启动 Docker Compose(MySQL/Redis;py-rag 服务在其仓库单独启动)
up:
@echo "$(YELLOW)🐳 检查 Docker 容器状态...$(NC)"
@echo "$(YELLOW)🐳 启动 Docker Compose(MySQL/Redis)...$(NC)"
@if [ ! -f "$(DOCKER_COMPOSE_FILE)" ]; then \
echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \
exit 1; \
fi
@if docker ps --format '{{.Names}}' | grep -q "^$(MILVUS_CONTAINER)$$"; then \
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 -f $(DOCKER_COMPOSE_FILE) up -d && echo "$(GREEN)✅ Docker Compose 启动完成$(NC)"
# 停止 Docker Compose
down:
@@ -245,24 +221,10 @@ down:
echo "$(RED)❌ Docker Compose 文件不存在: $(DOCKER_COMPOSE_FILE)$(NC)"; \
exit 1; \
fi
@if docker ps --format '{{.Names}}' | grep -q "milvus"; then \
docker-compose -f $(DOCKER_COMPOSE_FILE) down; \
echo "$(GREEN)✅ Docker Compose 已停止$(NC)"; \
else \
echo "$(YELLOW)⚠️ 没有运行中的 Milvus 容器$(NC)"; \
fi
@docker-compose -f $(DOCKER_COMPOSE_FILE) down && echo "$(GREEN)✅ Docker Compose 已停止$(NC)"
# 查看 Docker 容器状态
status:
@echo "$(YELLOW)📊 Docker 容器状态:$(NC)"
@echo ""
@if docker ps -a --format '{{.Names}}' | grep -q "milvus"; then \
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
@docker ps -a --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+2 -58
View File
@@ -40,68 +40,12 @@ services:
timeout: 5s
retries: 5
# Milvus 向量数据库(Standalone 模式)
# 注意:生产环境建议使用 Zilliz Cloud 或 Milvus 集群
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
# 向量检索与知识入库由独立的 py-rag 服务承担(见 py-rag 仓库),
# 其依赖的 Milvus/etcd/MinIO 随 py-rag 部署,不再由本 compose 管理。
volumes:
mysql-data:
redis-data:
etcd-data:
minio-data:
milvus-data:
networks:
default:
+1 -1
View File
@@ -14,7 +14,7 @@
| [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 |
| [architecture/harness-quality-gates.md](architecture/harness-quality-gates.md) | Harness 与质量门禁 |
| [architecture/session-trace-lifecycle.md](architecture/session-trace-lifecycle.md) | 会话与 Trace 生命周期 |
| [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前 hybrid 检索架构 |
| [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前检索架构(py-rag 知识服务接入) |
| [issues/README.md](issues/README.md) | MVP issue 索引 |
| [tables/README.md](tables/README.md) | 当前 MySQL 表说明 |
| [demo/README.md](demo/README.md) | Demo 运行和演示材料 |
@@ -1,9 +1,15 @@
# RAG 检索可观测性、审计与 Trace(现行)
**更新日期**:2026-07-28
**更新日期**:2026-09-29
**状态**:当前可运行
**关联**:`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. 三层边界
@@ -119,21 +125,21 @@ flowchart TB
| 字段 | 含义 |
|------|------|
| `originalQuery` | 原始查询 |
| `rewrittenQuery` | L0/变换后用于检索的 query |
| `categoryFilter` | 首次过滤的 category(可 null) |
| `rewrittenQuery` | 用于检索的 query(L0 下沉后恒等于 originalQuery) |
| `categoryFilter` | 首次过滤的 category(L0 下沉后恒为 null) |
| `selectedAttempt` | 最终采用的 attempt 名 |
| `fallbackReason` | 如 `filtered_vector_low_quality`;未降级为 null |
| `evidenceStatus` | 内部:`supported` / `no_evidence` 等 |
| `queryHints` | L0:domains、keywords、entities、l0_match_count… |
| `queryHints` | L0 提示(下沉后恒为空 domains/keywords/entities 与 l0_match_count=0) |
| `attempts[]` | 每次检索尝试快照 |
**常见 `selectedAttempt`:**
**`selectedAttempt` 取值:**
| 值 | 含义 |
|----|------|
| `FILTERED_VECTOR` | 带 category 的首次检索即采用 |
| `UNFILTERED_VECTOR` | 无 category,直接全库检索 |
| `UNFILTERED_VECTOR_RETRY` | filtered 低质/无证据后去掉 category 重试 |
| `UNFILTERED_VECTOR` | **当前唯一会出现**:无 category,直传 py-rag 检索 |
| `FILTERED_VECTOR` | (历史)带 category 的首次检索即采用;L0 下沉后不再产生 |
| `UNFILTERED_VECTOR_RETRY` | (历史)filtered 低质/无证据后去掉 category 重试;分支保留但不可达,仅见于旧 Run 回放 |
**单次 `attempts[]` 元素:**
@@ -148,21 +154,18 @@ flowchart TB
| `durationMs` | 耗时 |
| `errorMessage` | 失败时 |
### 3.3 一次典型路径(含 filter fallback)
### 3.3 一次典型路径(当前:单 attempt 直传)
```mermaid
flowchart TB
Q[query] --> L0[L0 hint → 可选 categoryFilter]
L0 --> A1[attempt FILTERED_VECTOR]
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
Q[query 原始句直传] --> A1[attempt UNFILTERED_VECTOR<br/>PyRagKnowledgeSearchAdapter → py-rag]
A1 --> POST[PostProcess · evidenceBlocks · relevanceLevel]
POST --> LR[LookupResult 完整 Trace]
```
> 历史 filter fallback 路径(L0 → FILTERED_VECTOR → 低质 → UNFILTERED_VECTOR_RETRY)的流程图已随 L0 下沉移除;
> 旧 Run 的 Trace 回放仍可见该结构,字段含义见 §3.2。
### 3.4 与 Agent 投影的关系
```mermaid
@@ -208,35 +211,27 @@ flowchart LR
| `output_preview` | level/attempt 摘要 | status=… |
| `duration_ms` / `success` | 有 | 有 |
### 4.3 `retrieval_details`(rag_lookup_v1)示例
### 4.3 `retrieval_details`(rag_lookup_v1)示例(当前形态)
```json
{
"audit_schema": "rag_lookup_v1",
"search_mode": "hybrid",
"selected_attempt": "UNFILTERED_VECTOR_RETRY",
"fallback_reason": "filtered_vector_low_quality",
"category_filter": "overfilter-decoy",
"evidence_keys": ["doc#chunk-0"],
"sources": ["doc"],
"evidence_candidate_count": 8,
"selected_attempt": "UNFILTERED_VECTOR",
"fallback_reason": null,
"category_filter": null,
"evidence_keys": ["e2e-gateway-b9c1fa12-md-34223174#chunk-1"],
"sources": ["e2e-gateway-b9c1fa12-md"],
"evidence_candidate_count": 5,
"evidence_block_count": 2,
"l0_hints": { "domains": ["mysql"], "matched_keywords": ["pool"] },
"l0_hints": { "domains": [], "matched_keywords": [] },
"attempts": [
{
"name": "FILTERED_VECTOR",
"category_filter": "overfilter-decoy",
"candidate_count": 2,
"usable": false,
"top_similarity": 0.3,
"duration_ms": 12
},
{
"name": "UNFILTERED_VECTOR_RETRY",
"name": "UNFILTERED_VECTOR",
"candidate_count": 5,
"usable": true,
"top_similarity": 0.9,
"duration_ms": 20
"top_similarity": 0.91,
"duration_ms": 640
}
],
"truncated": false,
@@ -248,6 +243,7 @@ flowchart LR
```
**默认不落库:** 原始 query 全文、chunk 正文 excerpt、完整 rerankTrace(体积与隐私)。
历史 Run 中 `selected_attempt=FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` 与非空 `l0_hints` 为 L0 下沉前的旧数据形态。
### 4.4 Trace API:人怎么读 RAG
@@ -304,12 +300,12 @@ flowchart TB
| 现象 | 优先看 |
|------|--------|
| 为何走了 retry | `fallback_reason` + 两次 `attempts` |
| 是否 hybrid | `search_mode` |
| 滤错域 | `category_filter` + L0 domains |
| 是否 hybrid | `search_mode`(hybrid/semantic 对应 py-rag 融合/纯向量) |
| 滤错域 | (历史)`category_filter` + L0 domains;L0 下沉后恒为 null |
| 相关度档 | 列 `relevanceLevel`(PRECISE/REFERENCE) |
| 返回了哪些块 | `evidence_keys` / `sources`(无正文) |
| Agent 是否被截断 | `truncated` / `returned_count` |
| 为何走了 retry | (历史)`fallback_reason` + 两次 `attempts`;L0 下沉后单 attempt,不再产生 |
### 4.6 diagnosis_trace 事件 vs tool_invocation 行
@@ -352,10 +348,11 @@ flowchart LR
| 旧(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` + 列回填 |
| `relevance_level` 混用 evidence_status | **列仅 RAG 等级**;契约状态在 details |
| 未写清 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 知识检索架构
**更新日期**:2026-07-28
**更新日期**:2026-09-29
**状态**:当前可运行架构
**关联实现**:`lookup_knowledge`、`MilvusHybridKnowledgeStore`、`KnowledgeSearchPort`
**关联运维**:`scripts/rebuild_hybrid_knowledge.py`、`POST /api/knowledge/rebuild-hybrid`
**关联实现**:`lookup_knowledge`、`KnowledgeSearchPort`、`PyRagKnowledgeSearchAdapter`、`PyRagClient`
**关联契约**:py-rag 仓库 `docs/Java接入文档.md`(API v1,冻结面)
**关联运维**:py-rag `/api/v1/collections:rebuild`(全量重建)、py-rag `/api/v1/documents:ingest`(单文档入库)
## 1. 定位
知识检索是 Diagnosis Agent 的显式证据工具,不是隐式 Advisor。
检索算法(dense+BM25 融合、rerank、判级)与文档入库(解析、frontmatter、分块、向量化)
**全部由独立的 py-rag 知识服务承担**;Java 侧只保留 harness 消费面与 HTTP 客户端。
```text
Diagnosis Agent
@@ -19,7 +22,7 @@ Diagnosis Agent
目标:
- 保留 Agent 可见的工具调用与证据边界
- 用单一向量后端完成 dense + BM25 hybrid 检索
- 检索/入库基础设施外置为独立服务,Java 侧不感知引擎细节
- 用 chunk 级证据身份保证同文档多片段可同时进入上下文
- 检索行为可配置、可重建、可审计
@@ -41,336 +44,193 @@ flowchart LR
subgraph RetrievalBoundary["Retrieval boundary"]
Backend["LookupKnowledgeTool"]
Port["KnowledgeSearchPort"]
Store["MilvusHybridKnowledgeStore"]
Remote["PyRagKnowledgeSearchAdapter"]
Service["py-rag 知识服务 (HTTP /api/v1)"]
end
Agent --> Tool
Tool --> Adapter
Adapter --> Backend
Backend --> Port
Port --> Store
Port --> Remote
Remote -->|HTTP| Service
Adapter --> Projector
Adapter --> Canonical
```
| 边界 | 职责 | 不负责 |
|---|---|---|
| Agent | 决定何时检索、如何用证据写报告 | 不直接访问 Milvus / MySQL 元数据表 |
| Agent | 决定何时检索、如何用证据写报告 | 不直接访问 py-rag / MySQL 元数据表 |
| Harness | Tool 校验、投影裁剪、canonical 存证 | 不改写检索排序算法 |
| Retrieval | L0 hint、dense/BM25 召回、后处理、打包 | 不绕过 ACI 直接给 Agent 原始库响应 |
| Retrieval | 请求映射、后处理、打包;py-rag 承担召回/融合/rerank/判级 | 不绕过 ACI 直接给 Agent 原始库响应 |
## 3. 当前主链路
```mermaid
flowchart TD
A["lookup_knowledge(query)"] --> B["KnowledgeQueryTransformer"]
B --> C["L0 hint: domain / keywords / categoryFilter"]
C --> D["KnowledgeDocumentRetriever"]
D --> E["KnowledgeSearchPort"]
E --> F["VectorSearchService"]
F --> G{"retrieval.search.mode"}
G -->|dense| H["MilvusHybridKnowledgeStore.searchDense"]
G -->|hybrid| I["MilvusHybridKnowledgeStore.searchHybrid"]
H --> J["candidates + chunk identity"]
I --> J
J --> K["KnowledgeEvidencePostProcessor"]
K --> L["evidenceKey dedup / maxChunksPerDocument / return-n"]
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"]
A["lookup_knowledge(query)"] --> B["原始 query 直传(L0 已下沉 py-rag)"]
B --> C["KnowledgeDocumentRetriever"]
C --> D["KnowledgeSearchPort"]
D --> E["PyRagKnowledgeSearchAdapter"]
E -->|POST /api/v1/search| F["py-rag: dense+BM25 融合 / rerank / 判级"]
F --> G["hits + evidenceKey(docId#chunk-N)"]
G --> H["KnowledgeEvidencePostProcessor"]
H --> I["evidenceKey dedup / maxChunksPerDocument / return-n"]
I --> J["KnowledgeContextPacker"]
J --> K["LookupResultAssembler"]
K --> L["RagResultProjector"]
L --> M["Agent-facing RagToolResult"]
```
对应代码:
| 阶段 | 类 | 职责 |
|---|---|---|
| Tool 编排 | `LookupKnowledgeTool` | 串联 transform / retrieve / post / pack |
| Query 理解 | `KnowledgeQueryTransformer` + `KnowledgeIndexService` | L0 只产 hint 与可选 category filter |
| 检索端口 | `KnowledgeSearchPort` / `VectorKnowledgeSearchAdapter` | 屏蔽底层存储细节 |
| 检索门面 | `VectorSearchService` | `dense` 或 `hybrid` 路由 |
| 向量后端 | `MilvusHybridKnowledgeStore` | 唯一知识库读写后端(MilvusClientV2) |
| 后处理 | `KnowledgeEvidencePostProcessor` | 归一化、规则 boost、chunk 去重、相关度等级 |
| Tool 编排 | `LookupKnowledgeTool` | 串联 retrieve / post / pack;UNFILTERED 单 attempt 主路径 |
| 检索端口 | `KnowledgeSearchPort` / `PyRagKnowledgeSearchAdapter` | 防腐层;请求映射 + 命中归一化 |
| HTTP 客户端 | `PyRagClient` | API v1 调用、错误信封(`E_*`)、`X-Request-ID`、分端点超时 |
| 后处理 | `KnowledgeEvidencePostProcessor` | qualityScore(RERANK 直传)、chunk 去重、相关度等级 |
| 打包 | `KnowledgeContextPacker` | 有界 context pack |
| 投影 | `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
- `retrieval.vector-store.mode=sdk|spring|auto`
- Spring AI `VectorStore` 作为知识检索主路径
| Java(KnowledgeSearchRequest) | py-rag(/api/v1/search) | 说明 |
|---|---|---|
| `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
写入:
VectorIndexService
-> MilvusHybridKnowledgeStore.upsertChunk
业务上传(DocumentController /api/documents/upload)
-> MySQL api_document(业务元数据:faultSource 等)+ 本地原件保存
-> PyRagClient.ingest(multipart 透传原件 + category)
-> py-rag:解析 / frontmatter 校验 / 分块 / 向量化 / 索引
读取:
VectorSearchService
-> MilvusHybridKnowledgeStore.searchDense
-> MilvusHybridKnowledgeStore.searchHybrid
简单上传(FileUploadController /api/upload)
-> 本地保存 + PyRagClient.ingest(category 可选参数,缺省 default;入库失败不影响上传成功语义)
```
默认 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
milvus:
collection: biz
pyrag:
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
`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:
## 7. 证据身份与去重(不变)
```text
BM25(search_text -> sparse_vector)
```
索引:
```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}
evidenceKey = docId#chunk-{chunkIndex}
fallback: vector:{id}
fallback: rank:{n}
```
规则:
- 去重按 `evidenceKey`,同文档不同 chunk 可同时保留
- `rag.max-chunks-per-document` / `rag.return-n` 在 Java 后处理器生效
- Agent 投影中的 `document_id` 使用 chunk 级 evidenceKey,EvidenceGuard 据此验真
- 去重按 `evidenceKey`,不是按 source 文档路径
- 同文档不同 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 可见契约
## 8. Agent 可见契约(不变)
Agent 只看到有界 `RagToolResult`:
- `evidence_status`
- `tool_call_id`
- `query`
- `evidence_status` / `tool_call_id` / `query`
- `evidence[]`:`document_id` / `source` / `title` / `breadcrumb` / `excerpt`
- `relevance_level`
- `relevance_level`(PRECISE / REFERENCE;`RagRelevanceLevel.HIGHLY_RELEVANT` 为保留档)
- `truncated` / `returned_count`
不暴露:
不暴露:raw score、retrievalTrace / rerankTrace、contextPack 全文、py-rag 地址与凭据。
完整内部结果仍在 `LookupResult` 中,供审计与调试使用。Trace / 审计读法见
[RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)。
- raw score / fused score
- retrievalTrace / rerankTrace
- contextPack 全文
- Milvus 内部字段与凭据
## 9. L0 下沉
完整内部结果仍在 `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)**
要点:
- 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. 与旧文档的差异
| 旧描述(已归档) | 当前实现 |
| 旧描述(2026-07-28 版) | 当前实现(2026-09-29 抽离后) |
|---|---|
| Spring AI VectorStore 主路径 + SDK fallback | 单一 MilvusClientV2 后端 |
| `retrieval.vector-store.mode=auto/sdk/spring` | 已移除;改为 `retrieval.search.mode=dense/hybrid` |
| source 级 evidence 去重 | chunk 级 `evidenceKey` 去重 |
| 应用层 sparse-lite lexical 伪 hybrid | 库内 dense ANN + BM25 + RRFRanker |
| 新建 `biz_hybrid` 过渡 collection | 默认使用并重建 `biz` |
| 进程内 MilvusClientV2,dense+BM25+RRF | py-rag 服务端承担;Java 经 `KnowledgeSearchPort` → HTTP |
| `retrieval.search.mode` 切 Milvus 查询算法 | 同名配置映射 py-rag `hybrid` / `semantic` |
| scoreLabel 仅 dense \| hybrid,L2/rank 归一化 | 新增 `RERANK`:py-rag rerank 绝对分直传 |
| L0 hint + categoryFilter + filtered/unfiltered retry | L0 下沉;原始 query 直传,单 attempt |
| 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/modular-rag-pipeline.md`
- `mvp/architecture/archive/2026-07-22-legacy/rag-architecture.md`(Milvus 前身)
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md`(进程内 Milvus hybrid 接入纪要,已过时)
- 本仓库 git 历史:`refactor/extract-rag-module` 分支,71 文件 / 约 -8000 行
## 12. 当前已知边界
## 11. 当前已知边界
- hybrid 依赖云端/实例支持 BM25 Function 与 sparse index
- 全量重建受 embedding API 与 Milvus 写入延迟影响,可能较慢
- L0 关键词匹配仍较粗,只作 hint,不作主召回
- 尚未做邻块上下文自动扩展、cross-encoder rerank、真 query rewrite
- `totalVectors` 统计接口仍可能返回 0,不代表 collection 为空;以 rebuild/init 结果与检索命中为准
- `retrieval.search.mode=dense` 仅作召回对照,不是第二套主路径
- 同一 hybrid schema 数据可被 dense / hybrid 两种查询复用;从纯旧 dense-only collection 升级必须 rebuild
- hybrid 质量闸门优先用 `denseDistance` 绝对 L2;无 dense 时 rank 回退;排序仍跟 RRF
- 后处理不再用 L0 关键词 boost 改序;词面信号以库内 BM25+RRF 为准
- py-rag 判级阈值(0.75/0.5/0.3)未校准,`quality_score` 仅供排序与展示参考(契约已知边界)
- frontmatter 的 keywords/summary/covers/when_to_retrieve 当前仅上传方提供;Java 侧 LLM 补全(原 `DocumentFieldEnricher`)已随抽离移除,待 py-rag 开放
- `knowledge_base/` 历史原料需在 py-rag 侧完成一次性 ingest 迁移后方可检索
- 无单文档删除;下线文档靠 py-rag 全量重建
- eval/rag-retrieval 离线基线基于旧 L0/scoreLabel 语义构建,抽离后需重新校准(见 `mvp/engineering/rag/RAG离线评测-基线设计.md` 顶部说明)
- Trace/审计细节与限制见 [RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)
+10 -3
View File
@@ -1,6 +1,6 @@
# MVP 架构文档
**更新日期**:2026-07-29
**更新日期**:2026-09-29
**状态**:当前单 Diagnosis Agent + Harness 架构
当前文档入口:
@@ -12,12 +12,19 @@
| [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 生命周期 |
| [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 读法 |
**工程纪要**(问题 / 决策 / 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`)使用独立存储和独立接口。
DeepSeek thinking 捕获路径与 V015–V017 字段已 live 验证(2026-07-28)。Reasoning 访问控制、保留期限、加密要求仍由 ISS-015 跟踪,不能把“数据已分表 + 能抓到 thinking”理解为“治理已经完成”。
+16 -14
View File
@@ -1,13 +1,13 @@
# 当前 MVP 架构
**更新日期**:2026-07-28
**更新日期**:2026-09-29
**状态**:当前可运行架构
## 1. 系统定位
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. 分层
@@ -71,22 +71,22 @@ Agent 只看到三个固定 Tool:
Agent
-> RagToolAdapter / ToolBoundary
-> LookupKnowledgeTool
-> L0 hint(可选 category filter)
-> 原始 query 直传(L0 已下沉 py-rag)
-> KnowledgeSearchPort
-> VectorSearchService
-> MilvusHybridKnowledgeStore # 唯一知识向量后端
-> PyRagKnowledgeSearchAdapter # HTTP 客户端(PyRagClient)
-> py-rag 知识服务 /api/v1/search # 融合 / rerank / 判级
-> KnowledgeEvidencePostProcessor # chunk 去重 / return-n / 判级(Java 侧)
-> RagResultProjector # 有界 Agent 投影
```
要点:
- 默认 `retrieval.search.mode=hybrid`:dense ANN + BM25 sparse ANN + RRFRanker。
- 也可切 `dense`:仅 dense ANN。
- 已移除知识路径上的 legacy `MilvusServiceClient` search 与 `vector-store.mode=sdk|spring|auto` 路由。
- 默认 `retrieval.search.mode=hybrid`:映射 py-rag `hybrid`(dense+BM25 融合);`dense` 映射 `semantic` 作对照。
- 证据按 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 与持久化
@@ -117,10 +117,12 @@ Reasoning endpoint 是敏感审计面,不属于普通业务 API。数据和查
与知识检索相关的独立 API:
- `POST /api/knowledge/init`:导入/增量初始化 `knowledge_base`
- `POST /api/knowledge/rebuild-hybrid?confirm=REBUILD`:清空并重建 dense+BM25 collection(默认 `biz`)
- `GET /api/knowledge/stats`:文档元数据统计
- `GET /milvus/health`:MilvusClientV2 健康检查与 knowledge collection 名
- `POST /api/documents/upload`:文档上传(MySQL 业务元数据 + 本地原件 + py-rag ingest)
- `POST /api/upload`:简单上传(本地保存 + py-rag ingest,可选 `category`)
- `GET /api/documents/{docId}` / `GET /api/documents/status/{status}` / `GET /api/documents/faultSource/{faultSource}`:文档元数据查询
- `DELETE /api/documents/{docId}`:删除 MySQL 元数据与本地原件(py-rag 侧索引需全量重建生效)
知识库检索/入库/重建的服务端健康与统计由 py-rag 提供:`GET /api/v1/health`、`GET /api/v1/stats`(`{pyrag.base-url}`)。
## 7. 安全边界
+12 -7
View File
@@ -20,14 +20,19 @@
## 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-Hybrid质量分与后处理.md](rag/RAG-Hybrid质量分与后处理.md) | qualityScore 统一、L2 伪装废止、后处理 |
| [rag/RAG-Agent如何读relevance_level.md](rag/RAG-Agent如何读relevance_level.md) | Agent 侧相关度标签含义与误读 |
| [rag/RAG离线评测-基线设计.md](rag/RAG离线评测-基线设计.md) | Golden/Fixture、hybrid 评测与闸门 |
| [rag/Milvus-Hybrid接入清单.md](rag/Milvus-Hybrid接入清单.md) | Hybrid 交付拆分与接入清单 |
| [rag/RAG审计补丁-stepid-query-E2E验收.md](rag/RAG审计补丁-stepid-query-E2E验收.md) | step_id / query 审计 live 验收 |
| [rag/RAG证据链探索笔记-从py-rag响应到引用验真.md](rag/RAG证据链探索笔记-从py-rag响应到引用验真.md) | **抽离后链路首发导读**:数据逐站形态、关键字段、设计哲学与化石清单 |
| [rag/RAG排序-多路召回与RRF.md](rag/RAG排序-多路召回与RRF.md) | K、多路融合、RRF、L0 边界(实现已下沉 py-rag,判断框架仍有效) |
| [rag/RAG-Hybrid质量分与后处理.md](rag/RAG-Hybrid质量分与后处理.md) | qualityScore 统一、后处理(分数语义现为 RERANK 直传) |
| [rag/RAG-Agent如何读relevance_level.md](rag/RAG-Agent如何读relevance_level.md) | Agent 侧相关度标签含义与误读(现行) |
| [rag/RAG离线评测-基线设计.md](rag/RAG离线评测-基线设计.md) | Golden/Fixture、hybrid 评测与闸门(需按新语义重新校准) |
| [rag/Milvus-Hybrid接入清单.md](rag/Milvus-Hybrid接入清单.md) | Hybrid 交付拆分与接入清单(已过时,仅历史追溯) |
| [rag/RAG审计补丁-stepid-query-E2E验收.md](rag/RAG审计补丁-stepid-query-E2E验收.md) | step_id / query 审计 live 验收(现行) |
架构对照:
@@ -36,7 +41,7 @@
相关 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,问题前提不复存在)
---
@@ -4,6 +4,10 @@
**状态**:SUCCESS 完整诊断主文档;工具阶段按**现行**审计能力(`step_id` / `query`)说明
**文档路径**:`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 来源)
| 项 | 值 |
@@ -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` |
@@ -30,7 +30,7 @@
### 0.4 当前会话的起始上下文(供追溯)
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ 取消广播与打断机制 → RunBudget 深挖 → retry(设计+实现+超时+幂等性)→ 状态流预备(枚举归属)。当前停在「执行控制面已闭环,下一步 progress」的位置。
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ RunBudget 深挖 → retry → progress(设计+代码双视角)→ tool 域(49 文件全注释 + 注册调用执行链路 + Tool 调用链旅程)→ RAG 检索体系(lookup_knowledge 后端:L0/多路召回+RRF/qualityScore/降级/契约/审计/离线评测,已闭环)。当前停在「tool 域只差 MySQL 沙箱线,下一步 tool 收尾」的位置。
### 0.5 面试准备策略(学习目标)
@@ -82,14 +82,14 @@
|---|---|---|---|---|
| `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 / 状态枚举,防字符串漂移 | ⬜ 部分 | RunState / ReleaseOutcome / SseOutcome / PublishedResult(只摸过枚举) | 状态流(未系统学) |
| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ⬜ 部分 | HarnessModelInterceptor(预算/Token 记账) | — |
| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ⬜ 部分 | ModelCallLedger / ModelCallAuditor | — |
| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ⬜ 部分 | ChatApplicationUseCase 入口(cancel 链路) | — |
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ⬜ 部分 | GuardModelCall(预算/超时/取消订阅) | — |
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ⬜ 部分 | DiagnosisReleaseResult(结果类型) | — |
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ⬜ 空白 | JdbcMysqlReadOnlyExecutor(取消订阅) | — |
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ⬜ 空白 | — | — |
| `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 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
@@ -100,32 +100,49 @@
| [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/>类型化语言 ⬜"]
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
下一个:progress(信息增益停止)——14 个文件,小而独立
和刚学完的预算组成"双停止机制":预算管"能不能花",信息增益管"继续查有没有价值"
主线九域全部 ✅(含 agent 域收尾)+ 面试五步复习 ✅(共沉淀 14 篇笔记)
之后顺序:
tool(49 个文件,按四层理解:Boundary → Canonical → Projector → Adapter)
guard(15 个文件:EvidenceGuard + SemanticGuard)
release(6 个文件,小而关键:唯一发布点)
补 application(路由/执行器/SSE 收尾)和 audit(Trace 回放)
最后状态流(RunState ↔ ReleaseOutcome ↔ SseOutcome 正交全景)
面试前一天建议:
1. 重读「面试速查」§1-2 + §8(30 秒陈述 / 一张图 / 六易错点)
2. 默画三张白板图(复习笔记 §3)
3. 背诵每域 30 秒话术(复习笔记 §5)
4. 过一遍纠正的认知清单(复习笔记 §6,最容易踩的坑)
5. 2 分钟支付超时案例(复习笔记 §7)
可选深化(不阻塞面试):
1. audit 域深化:RagLookupAuditEnricher 检索审计明细(已覆盖大半)
2. LLM Judge 设计(已沉淀:面试问答 + 追问应对)
3. 整体架构补充(已沉淀:装配/入口/记忆体系/知识库写入)
```
## 5. 建议每次学完一个域后更新
@@ -1,5 +1,10 @@
# Milvus Hybrid Search 接入对照清单
> **已过时(2026-09-29)**:RAG 模块已抽离为独立 py-rag 知识服务,进程内 Milvus
> (`MilvusHybridKnowledgeStore` / `VectorSearchService`)与本文描述的接入路径已整体删除。
> 当前检索架构与契约映射见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
> 本文仅作历史决策追溯。
**日期**:2026-07-27
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
@@ -1,5 +1,12 @@
# 混合检索上线之后:为什么还要统一 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
**范围**:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定
**读者**:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学
@@ -1,5 +1,10 @@
# 诊断 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
**范围**:知识检索排序、多路召回、分数融合、Rerank 选型
**读者**:需要在 Agent 系统里落地 RAG,而不是只做 Demo 问答的工程同学
@@ -1,5 +1,10 @@
# 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
**范围**:`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 加固
**状态**:开放,暂缓实施(保持现网行为)
**状态**:已失效(2026-09-29 RAG 抽离,L0 整体下沉 py-rag,问题前提不复存在)
**严重程度**:中
**发现时间**:2026-07-28
**更新日期**:2026-07-28
**更新日期**:2026-09-29
**来源**: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
**状态**:当前表
**状态**:孤儿表(2026-09-29 RAG 抽离后无写入方)
**来源**:`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。
+6 -17
View File
@@ -76,12 +76,6 @@
<artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>
<!-- Embedding: SiliconFlow BGE-M3 (需要 OpenAI 模块的 OpenAiEmbeddingModel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-agent-framework</artifactId>
@@ -91,19 +85,14 @@
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--
spring-boot-devtools removed on purpose.
Classpath restart (restartedMain) recreates beans without reliably closing
MilvusClientV2 gRPC channels, causing orphan channels and long hybrid RPC retries.
Prefer full process restart: stop then `mvn spring-boot:run`.
spring-boot-devtools removed on purpose (bean recreation on classpath restart
is unreliable). Prefer full process restart: stop then `mvn spring-boot:run`.
-->
<!-- okhttp:DashScopeConfig 的 RestClient.Builder 使用(此前由 milvus-sdk 传递引入) -->
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
<version>2.6.10</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-milvus</artifactId>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<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;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -19,11 +17,11 @@ import org.springframework.context.annotation.Primary;
* <pre>{@code
* model-routing:
* chat: deepseek
* embedding: siliconflow
* }</pre>
* <p>
* 匹配优先级:Bean 名 > 类名(均不区分大小写)。
* 切换模型只改 yml + pom + 对应 api-key,Java 代码不动。
* (Embedding 路由已随 RAG 模块抽离至 py-rag 服务端,此处仅路由 Chat。)
*/
@Configuration
public class ModelRoutingConfig {
@@ -33,9 +31,6 @@ public class ModelRoutingConfig {
@Value("${model-routing.chat:deepseek}")
private String chatKeyword;
@Value("${model-routing.embedding:siliconflow}")
private String embeddingKeyword;
@Bean
@Primary
public ChatModel chatModel(List<ChatModel> chatModels) {
@@ -53,33 +48,6 @@ public class ModelRoutingConfig {
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) {
return containsIgnoreCase(clazz.getName(), 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() {
// 工具类,禁止实例化
}
}
@@ -1,8 +1,8 @@
package com.superbiz.agent.controller;
import com.superbiz.agent.client.PyRagClient;
import com.superbiz.agent.config.FileUploadConfig;
import com.superbiz.agent.dto.FileUploadRes;
import com.superbiz.agent.service.VectorIndexService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -29,10 +29,11 @@ public class FileUploadController {
private FileUploadConfig fileUploadConfig;
@Autowired
private VectorIndexService vectorIndexService;
private PyRagClient pyRagClient;
@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()) {
return ResponseEntity.badRequest().body("文件不能为空");
}
@@ -68,15 +69,17 @@ public class FileUploadController {
logger.info("文件上传成功: {}", filePath);
// 文件上传成功后,自动调用向量索引服务
// 转发 py-rag 入库(同内容重传返回 unchanged)。入库失败不影响上传成功语义。
try {
logger.info("开始为上传文件创建向量索引: {}", filePath);
vectorIndexService.indexSingleFile(filePath.toString());
logger.info("向量索引创建成功: {}", filePath);
String ingestCategory = (category == null || category.isBlank()) ? "default" : category;
logger.info("开始 py-rag 入库: {}, category={}", filePath, ingestCategory);
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) {
logger.error("向量索引创建失败: {}, 错误: {}", filePath, e.getMessage(), e);
// 注意:即使索引失败,文件上传仍然成功,只是记录错误日志
// 可以根据业务需求决定是否要删除文件或返回错误
logger.error("py-rag 入库失败: {}, 错误: {}", filePath, e.getMessage(), e);
// 注意:即使入库失败,文件上传仍然成功,只是记录错误日志
}
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;
/**
* 检索前 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>
*/
@Data
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 拦截器名称(框架注册用)。
*/
@Override
public String getName() {
return "harness_evidence_tool_interceptor";
}
/**
* 拦截框架 ReAct loop 的每次 Tool Call——progress 协议的主战场。
*
* <p>只拦截证据类 Tool(RAG / 日志 / MySQL),其余 Tool 原样放行。对证据 Tool 依次执行:
* <ol>
* <li>已停止检查:停止指令交付后仍请求 → 受控停止;</li>
* <li>解析 + 评价:严格解析 Envelope,应用模型对上一轮的 GAINED/NO_GAIN;</li>
* <li>重复检测:参数级规范化 scope,backend 执行前拒绝重复查询;</li>
* <li>执行:交给 ToolBoundary(预算 / canonical / 审计统一门禁),并对结果做双源交叉验证;</li>
* <li>收尾:NO_EVIDENCE 自动计 NO_GAIN,饱和时交付一次 STOP_REQUIRED,返回有界 observation。</li>
* </ol>
*
* <p>模型侧观察(observation)共有三副面孔:正常执行结果、STOP_REQUIRED(含 reason)、
* 可修复协议错误(repair_required)。
*/
@Override
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
Objects.requireNonNull(request, "request must not be null");
Objects.requireNonNull(handler, "handler must not be null");
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
if (!evidenceTools.supports(request.getToolName())) {
return handler.call(request);
}
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
DiagnosisProgressSnapshotState before = context.progress().snapshot();
if (before.collectionState() == DiagnosisCollectionState.SATURATED
&& before.stopInstructionDelivered()) {
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
ParsedAgentToolCall call;
String normalizedScope;
try {
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
context.progress().applyPreviousObservation(call.previousObservation());
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
// 审计:模型回传的评价进入 Trace(producer=MODEL)
recordModelProgress(before, call, evaluated);
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
recordRejection(request, "INFORMATION_SATURATED");
return stopRequired(request, evaluated.stopReason());
}
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
} catch (ProgressProtocolViolationException exception) {
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
return handleProgressProtocolViolation(request, exception);
} catch (IllegalArgumentException | IllegalStateException exception) {
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
return handleProgressProtocolViolation(request,
new ProgressProtocolViolationException(
ProgressProtocolViolationType.INVALID_ENVELOPE,
"Tool Call Envelope is invalid", null, null, exception));
}
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
recordRejection(request, "DUPLICATE_SCOPE");
context.progress().recordDuplicateScope();
context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", duplicate);
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return duplicateScope(request);
}
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
ToolBoundaryResult result = evidenceTools.invoke(
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
if (result.status() == InvocationStatus.READY) {
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
ToolControlView control = viewProjector.controlView(result.agentResult());
if (control.evidenceStatus() != result.evidenceStatus()) {
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
}
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
context.progress().recordCompleted(
new CompletedToolCall(
request.getToolCallId(), request.getToolName(), normalizedScope),
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
InformationGain.NO_GAIN, "HARNESS", completed);
}
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
&& context.progress().claimStopInstruction();
if (stopRequired) {
traceRecorder.record(TraceAuditEvents.collectionStop(
context, request.getToolCallId(), request.getToolName(), completed));
}
// 有界 observation 返回给模型(含停止指令/停止原因)
String observation = viewProjector.modelObservation(
request.getToolName(), result.agentResult(), normalizedScope,
stopRequired, completed.stopReason());
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
}
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
context.progress().markBudgetLimitReached();
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 交付「必须停止」观察(observation 三副面孔之一)。
*
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
*/
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
if (!context.progress().claimStopInstruction()) {
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
throw new DiagnosisCollectionStoppedException(reason);
}
traceRecorder.record(TraceAuditEvents.collectionStop(
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
*/
private ToolCallResponse duplicateScope(ToolCallRequest request) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("tool_call_id", request.getToolCallId());
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
request.getToolCallId(), request.getToolName(), writeObservation(observation));
}
/**
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
*/
private ToolCallResponse handleProgressProtocolViolation(
ToolCallRequest request,
ProgressProtocolViolationException exception) {
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
return repairableProtocolError(request, exception, state);
}
/**
* 可修复协议错误观察(observation 三副面孔之一):
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
*/
private ToolCallResponse repairableProtocolError(
ToolCallRequest request,
ProgressProtocolViolationException exception,
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
* 用于契约不一致等非协议类拒绝。
*/
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", "ERROR");
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
.build();
}
/**
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
*/
private void recordRejection(ToolCallRequest request, String errorCode) {
recordRejection(request, errorCode, null, false, context.progress().snapshot());
}
/**
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
*
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
* 不消耗 Tool 预算、不计入实际执行数。
*/
private void recordRejection(
ToolCallRequest request,
String errorCode,
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
violationType, repairPromptDelivered, state));
}
/**
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
*/
private String writeObservation(Map<String, Object> observation) {
try {
return objectMapper.writeValueAsString(observation);
@@ -258,6 +329,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
}
}
/**
* 把模型回传的评价写入 Trace(producer=MODEL):在 before 的已完成调用里
* 找到被评价的那次,记录其 tool_call_id + information_gain + 评价后的状态。
*/
private void recordModelProgress(
DiagnosisProgressSnapshotState before,
ParsedAgentToolCall call,
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
call.previousObservation().informationGain(), "MODEL", after));
}
/**
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
*/
private void recordProgress(
String toolCallId,
String toolName,
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
informationGain, producer, state));
}
/**
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
*/
private String scopeSummary(String toolName, String normalizedScope) {
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
}
/**
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
*/
private String errorObservation(ToolBoundaryResult result) {
Map<String, Object> observation = new LinkedHashMap<>();
observation.put("evidence_status", result.evidenceStatus());
@@ -4,15 +4,41 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* 安全回退契约(Release 层 FALLBACK 的对外载荷):不发布根因报告时,
* 把「为什么降级 + 已验证事实 + 下一步建议」结构化地交给用户。
*
* <p>设计要点:
* <ul>
* <li>{@code conclusion} 恒为 null:降级绝不发布未证明的根因结论;</li>
* <li>诚实降级:verified_sources / observed_facts 保留已验证事实
* (供用户继续排查),validation_issues 给出失败原因;</li>
* <li>有界冻结契约:全部列表不可变,内容经 SafeFallbackFactory 截断去重
* (来源 12 条以内、摘要 320 字),绝不泄露 raw / 敏感正文。</li>
* </ul>
*
* <p>构造:仅 {@code SafeFallbackFactory}(release 域);
* 包装对外:{@code FallbackContent}(application 域);
* 审计提取:{@code RunConclusionExtractor}(audit 域)。
*/
public record SafeFallback(
/** 降级细分类型:EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / ... */
@JsonProperty("type") FallbackType type,
/** 恒为 null(降级不发布根因结论,保留字段仅为契约完整性)。 */
@JsonProperty("conclusion") String conclusion,
/** 一句话降级原因(用户可读)。 */
@JsonProperty("message") String message,
/** 已验证来源(去重):查过哪些来源。 */
@JsonProperty("verified_sources") List<VerifiedSource> verifiedSources,
/** 限制声明:检查范围 / 缺失项 / 降级原因。 */
@JsonProperty("limitations") List<String> limitations,
/** 下一步建议(用户可执行)。 */
@JsonProperty("next_steps") List<String> nextSteps,
/** 失败阶段标识:DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION。 */
@JsonProperty("failure_stage") String failureStage,
/** 已观察事实(去重、有界):每条 = 来源 + 范围 + 摘要,供继续排查。 */
@JsonProperty("observed_facts") List<ObservedFact> observedFacts,
/** 验证违规明细(EVIDENCE_VALIDATION_FAILED 时携带 code + target)。 */
@JsonProperty("validation_issues") List<ValidationIssue> validationIssues) {
public SafeFallback {
@@ -33,12 +59,14 @@ public record SafeFallback(
null, List.of(), List.of());
}
/** 已验证来源:来源类型 + 来源 + 范围(发布层可对外展示的最小来源单位)。 */
public record VerifiedSource(
@JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source,
@JsonProperty("scope") String scope) {
}
/** 已观察事实:来源类型 + 来源 + 范围 + 有界摘要(一条工具调用结果投影)。 */
public record ObservedFact(
@JsonProperty("source_type") String sourceType,
@JsonProperty("source") String source,
@@ -46,6 +74,7 @@ public record SafeFallback(
@JsonProperty("summary") String summary) {
}
/** 验证违规明细:违规码 + 目标字段(EVIDENCE_VALIDATION_FAILED 时携带)。 */
public record ValidationIssue(
@JsonProperty("code") String code,
@JsonProperty("target") String target) {
@@ -26,6 +26,27 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* 证据机械验真(证据安全链第 1 道闸):不信任模型自述,以 Canonical Store 账本为准。
*
* <p>职责:校验 DiagnosisDraft 结构合法 + 每个 tool_call_id 引用真实可查
* + 投影内部自洽,并把账本投影「重读重建」为 VerifiedEvidence 快照。
*
* <p>三个阶段:
* <ol>
* <li>{@link #validateDraft}:草稿结构完整性(analysis 非空、id 唯一、kind/text/引用齐全、limitations 必填);</li>
* <li>{@link #verifyInvocation}:引用验真(key=runId+toolCallId 查账本、记录必须 READY、
* 同 Run、agent_result 非空、kind 与证据语义匹配、工具受支持);</li>
* <li>readRag/readLogs/readMysql:严格反序列化投影并校验内部一致性,
* 重读重建 VerifiedEvidence(证据内容来自账本,不是模型复述)。</li>
* </ol>
*
* <p>纯规则门控、不调大模型:20 个违规码全部可枚举可审计;
* 产出 {@link VerifiedEvidenceSnapshot} 供 SemanticGuard 语义裁决与 release 发布。
*
* <p>被 {@code DiagnosisReleaseUseCase} 调用(validate / validateNoConclusionReferences),
* 是「唯一发布点」的第一道门。
*/
public final class EvidenceGuard {
private final CanonicalInvocationStore store;
@@ -35,6 +56,12 @@ public final class EvidenceGuard {
private final ObjectReader mysqlRequestReader;
private final ObjectReader mysqlResultReader;
/**
* 构造:注入账本(canonical store)+ key 工厂 + 严格模式 reader。
*
* <p>四种 reader 全部开启 FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS——
* 多余字段、尾随内容一律解析失败(fail closed,不接受「看起来差不多」的投影)。
*/
public EvidenceGuard(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) {
@@ -55,6 +82,12 @@ public final class EvidenceGuard {
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
}
/**
* 主入口:draft 结构校验 → 逐条 tool_call 引用验真 → 重读投影重建证据。
*
* <p>任何违规都收集到 violations(不中断,尽量报全);全部通过才产出快照。
* 每个 analysis 只保留「证据非空」的条目——引用为空/无效的分析不进快照。
*/
public EvidenceGuardResult validate(RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null");
List<EvidenceViolation> violations = validateDraft(draft);
@@ -79,6 +112,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations);
}
/**
* 无结论场景的引用校验(conclusion == null 时由 release 调用):
* 只验引用真实性,不产出快照(返回 empty)——没有结论就没有「是否被支持」可判。
* 供 {@code DiagnosisReleaseUseCase.releaseNoConclusion} 发布前兜底验引用。
*/
public EvidenceGuardResult validateNoConclusionReferences(
RunContext context, DiagnosisDraft draft) {
Objects.requireNonNull(context, "context must not be null");
@@ -113,6 +151,11 @@ public final class EvidenceGuard {
: EvidenceGuardResult.invalid(violations);
}
/**
* 阶段 A:草稿结构完整性校验(纯规则,不碰 store)。
* 先收集全部 analysis id 到集合,再校验报告级引用只能指向这些已登记 id
* (封死「结论引用不存在的分析」路径)。
*/
private List<EvidenceViolation> validateDraft(DiagnosisDraft draft) {
List<EvidenceViolation> violations = new ArrayList<>();
if (draft == null) {
@@ -149,6 +192,10 @@ public final class EvidenceGuard {
return violations;
}
/**
* 报告级引用校验:conclusion / action_plan / recommendations 的
* based_on_analysis_ids 必须存在且指向已登记 analysis id;limitations 必填。
*/
private void validateReportReferences(DiagnosisDraft draft, Set<String> ids,
List<EvidenceViolation> violations) {
if (draft.conclusion() != null) {
@@ -178,6 +225,10 @@ public final class EvidenceGuard {
}
}
/**
* 单条报告文本 + 其 based_on_analysis_ids 的合法性:
* 文本非空、引用列表非空、每个引用都必须指向已登记 analysis id。
*/
private void validateTextAndReferences(String target, String text, List<String> references,
Set<String> ids, List<EvidenceViolation> violations) {
if (isBlank(text)) {
@@ -200,6 +251,18 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 B(核心):验真单条 tool_call 引用。链条逐环检查,任何一环不过即记违规并跳过:
*
* <pre>
* id 非空 → key 构造(runId 绑定,防跨 Run 引用)→ 账本可查 → id 一致
* → isReferencableBy(READY + 同 Run + agent_result 非空 + 合法证据语义)
* → kind 匹配证据语义(NORMAL↔FOUND / NEGATIVE_OBSERVATION↔NO_EVIDENCE)
* → 工具受支持(RAG / LOGS / MYSQL)
* </pre>
*
* 该引用不被采信不代表整体失败:继续检查其余引用,违规全部汇总。
*/
private void verifyInvocation(RunContext context, DiagnosisDraft.AnalysisItem analysis,
int analysisIndex, String toolCallId,
List<VerifiedEvidence> evidence,
@@ -250,6 +313,11 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 C(RAG):严格反序列化 RagToolResult 投影,校验内部一致性
* (toolCallId / evidenceStatus / returnedCount==evidence.size() / NO_EVIDENCE 时证据必须为空)
* 后,把每条命中重建为 VerifiedEvidence。
*/
private void readRag(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
RagToolResult result;
@@ -301,6 +369,11 @@ public final class EvidenceGuard {
}
}
/**
* 阶段 C(LOGS):校验 QueryLogsToolResult 结构(topic/query/时间窗/matchCount 齐全、
* returnedCount==events.size()、NO_EVIDENCE 时 matchCount 必须为 0 且无 patterns/events),
* 把日志模式与事件重建为 VerifiedEvidence。
*/
private void readLogs(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
QueryLogsToolResult result;
@@ -364,6 +437,7 @@ public final class EvidenceGuard {
}
}
/** 投影通用一致性校验:toolCallId 与 evidenceStatus 必须与账本记录一致(防投影与账本脱节)。 */
private boolean projectionMatches(CanonicalToolInvocation invocation, String toolCallId,
com.superbiz.agent.harness.contract.EvidenceStatus evidenceStatus,
String target, List<EvidenceViolation> violations) {
@@ -378,6 +452,10 @@ public final class EvidenceGuard {
return true;
}
/**
* 阶段 C(MYSQL):校验 MysqlToolResult(列唯一非空、returnedCount==rows.size()、
* 每行 keySet 必须恰好等于 columns),把每一行重建为 VerifiedEvidence(带 _row_number)。
*/
private void readMysql(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
String target, List<EvidenceViolation> violations) {
MysqlToolRequest request;
@@ -7,6 +7,10 @@ public record EvidenceGuardResult(
List<EvidenceViolation> violations,
VerifiedEvidenceSnapshot snapshot) {
/**
* 结构不变量:valid 必须有 snapshot、invalid 必须有 violations,二选一无中间态
* (有效结果不可能带违规,无效结果不可能带快照)。
*/
public EvidenceGuardResult {
violations = violations == null ? List.of() : List.copyOf(violations);
if (violations.isEmpty() == (snapshot == null)) {
@@ -24,6 +24,19 @@ import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* 守卫模型调用器:通用「隔离判定模型」的受控调用(SemanticGuard 等守卫用)。
*
* <p>与主 Agent 调用不同:守卫模型单轮、无工具、强约束输出,但同样受 Run 生命周期管:
* <ul>
* <li>core.beforeModelCall / checkActive:与 core 门禁对齐;</li>
* <li>auditor.begin / recordUsage:Token 记账(ModelCallLedger);</li>
* <li>executor.submit + future.get(timeout):独立线程 + 超时截断;</li>
* <li>context.cancellation().onCancel → future.cancel(true):Run 取消强杀在途调用;</li>
* <li>输出限制:非文本 / 带 tool_calls / 超 maxOutputBytes → SCHEMA_INVALID 重试;</li>
* <li>终态异常(RunAborted / BudgetExceeded)原样穿出,不吞。</li>
* </ul>
*/
public final class GuardModelCall {
private final DiagnosisHarnessCore core;
@@ -44,6 +57,11 @@ public final class GuardModelCall {
this.auditor = Objects.requireNonNull(auditor, "auditor must not be null");
}
/**
* 受控调用:beforeModelCall 门禁 → 记账开始 → 提交执行 → 注册取消回调
* → future.get(timeout) 等待。超时/取消/中断/执行异常分别归类映射 RetryFailure;
* RunAbortedException / BudgetExceededException 原样穿出(Run 终态事实,不可重试)。
*/
public String call(RunContext context, ModelCallComponent component,
Prompt prompt, Duration timeout, long maxOutputBytes) {
Objects.requireNonNull(context, "context must not be null");
@@ -91,6 +109,11 @@ public final class GuardModelCall {
}
}
/**
* 执行侧:真实 chatModel.call,成功则记账 usage 并 checkActive;
* 输出形状违规(null/空白/带 tool_calls)或超 maxOutputBytes 归类 SCHEMA_INVALID;
* 输出字节也 reserveRunBytes 计入预算(守卫模型的花费不是无底洞)。
*/
private String invoke(RunContext context, ModelCallLedger.Call call,
Prompt prompt, long maxOutputBytes) {
ChatResponse response;
@@ -119,6 +142,7 @@ public final class GuardModelCall {
return output.getText();
}
/** 记账 usage;缺 metadata/usage 时按 0 记账(保持账本完整性,可对账)。 */
private void recordUsage(RunContext context, ModelCallLedger.Call call, ChatResponse response) {
if (response == null || response.getMetadata() == null) {
auditor.recordUsage(context, call, 0, 0, false);
@@ -24,6 +24,24 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
/**
* 语义裁决(证据安全链第 2 道闸):判「结论是否被已验证证据支持」。
*
* <p>在 EvidenceGuard 机械验真通过后调用——结构错、引用假根本到不了这里。
* 职责:把用户可见视图(SemanticDraftView,不含内部 id)+ 已验证证据交给
* 隔离的守卫模型,硬校验输出(恰好 {verdict, reason} 两个字段),
* 裁决 SUPPORTED / UNSUPPORTED。
*
* <p>与 Harness 全栈衔接:
* <ul>
* <li>预算:输入输出字节都 {@code core.reserveRunBytes} 计入 Run 预算;</li>
* <li>重试:走 {@code context.retryPolicies().semanticGuard()},每次 attempt 递减剩余超时;</li>
* <li>取消:GuardModelCall 内 onCancel → future.cancel(true);</li>
* <li>审计:ModelCallLedger 记账 + TraceAuditEvents.semanticAttempt 落 trace。</li>
* </ul>
*
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用(唯一入口)。
*/
public final class SemanticGuard {
private static final Set<String> OUTPUT_FIELDS = Set.of("verdict", "reason");
@@ -65,6 +83,12 @@ public final class SemanticGuard {
this.prompt = SemanticGuardPrompt.load();
}
/**
* 主入口:序列化输入(超限即拒绝 SCHEMA_INVALID)→ 计入预算
* → 构造 System(prompt)+User(输入 JSON) 双消息
* → 经 HarnessRetryExecutor 按 semanticGuard 策略执行模型调用
* → 硬校验输出 schema → 返回裁决。每次 attempt 都记审计 trace。
*/
public SemanticGuardDecision review(RunContext context, SemanticGuardInput input) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(input, "input must not be null");
@@ -91,6 +115,11 @@ public final class SemanticGuard {
});
}
/**
* 硬校验模型输出:必须是 JSON、字段恰好 {verdict, reason}、verdict 合法枚举、
* reason 非空;否则按失败类型抛 GuardModelCallException(可重试:
* PARSE_ERROR / SCHEMA_INVALID)。防止模型夹带多余字段或输出不完整。
*/
private SemanticGuardDecision parse(String output) {
JsonNode root;
try {
@@ -115,6 +144,10 @@ public final class SemanticGuard {
return new SemanticGuardDecision(verdict, root.path("reason").asText());
}
/**
* 每次 attempt 的剩余超时 = min(总超时 - 已用, 单次上限);
* 总超时耗尽即抛 TIMEOUT(不再重试)——守卫判定有硬截止线。
*/
private Duration remainingTimeout(long startedNanos) {
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
long remaining = limits.totalTimeout().toNanos() - elapsed;
@@ -125,6 +158,10 @@ public final class SemanticGuard {
return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos()));
}
/**
* 失败分类:GuardModelCallException 自带 RetryFailure;
* 其余未知异常归 UNKNOWN(不重试,直接失败)。
*/
private RetryFailure classify(Exception exception) {
if (exception instanceof GuardModelCallException guardFailure) {
return guardFailure.failure();
@@ -16,16 +16,42 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 停止后的「安全投影」:把 Tracker 里保存的调用 identity 回读 Canonical Store 的
* READY 记录,投影成可发布的有界快照 {@link DiagnosisProgressSnapshot}。
*
* <p>设计要点:
* <ul>
* <li>输入:Tracker 只存 identity(toolCallId + toolName + normalizedScope),无 payload;
* 完整事实按 key(runId + toolCallId)从 Canonical Store 回读——避免出现第二份 Tool 真相;</li>
* <li>三重校验(isReferencableBy / toolCallId / toolName)通过才发布;
* 无法验真、不可读、格式非法的记录一律排除,只形成 limitation;</li>
* <li>空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是有价值信息;</li>
* <li>硬截断:最多 12 条事实、摘要/范围各 320 字符、来源 160 字符,绝不输出 raw。</li>
* </ul>
*
* <p>产出被 {@code DiagnosisReleaseUseCase} 消费,是发布 INSUFFICIENT_EVIDENCE 类
* FALLBACK 的全部原料(progress 与 release 的交汇点)。
*/
public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
private static final int MAX_FACTS = 12;
/** 每条事实摘要的最大字符数。 */
private static final int MAX_SUMMARY_CHARS = 320;
/** 查询范围(scope)的最大字符数。 */
private static final int MAX_SCOPE_CHARS = 320;
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
private final CanonicalInvocationStore store;
/** 生成 canonical key(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory;
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
private final ObjectMapper objectMapper;
/**
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
*/
public DiagnosisProgressProjector(CanonicalInvocationStore store,
ToolCallKeyFactory keyFactory,
ObjectMapper objectMapper) {
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
}
/**
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
* limitations + stopReason),供 Release 发布。
*/
@Override
public DiagnosisProgressSnapshot project(RunContext context) {
Objects.requireNonNull(context, "context must not be null");
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
for (CompletedToolCall completed : state.completedToolCalls()) {
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
if (invocation == null) {
// 无法验真/不可读:已记 limitation,跳过
continue;
}
try {
projectInvocation(completed, invocation, sources, facts);
} catch (RuntimeException exception) {
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
}
if (facts.size() >= MAX_FACTS) {
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
state.stopReason());
}
/**
* 按 identity 回读 canonical 记录,做三重校验:
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
*/
private CanonicalToolInvocation resolve(RunContext context,
CompletedToolCall completed,
List<String> limitations) {
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
return invocation;
} catch (RuntimeException exception) {
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
return null;
}
}
/**
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
*/
private void projectInvocation(CompletedToolCall completed,
CanonicalToolInvocation invocation,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -97,12 +140,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* RAG 投影:evidence 数组空 → 有界事实「未发现可用文档证据」;
* 非空 → 逐条投影 source(source/title/document_id 取其一)+ excerpt。
*/
private void projectRag(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts) {
JsonNode evidence = root.path("evidence");
if (!evidence.isArray() || evidence.isEmpty()) {
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
addFact(sources, facts, "RAG", "knowledge_base", scope,
"该知识检索范围内未发现可用文档证据");
return;
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
* source 取自 source_kind(缺省 logs)。
*/
private void projectLogs(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -132,6 +184,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* MySQL 投影:rows 空 → 「未发现匹配记录」;非空 → 逐行 toString。
* source 从公开 scope 的 data_source 提取。
*/
private void projectMysql(JsonNode root,
String scope,
Map<String, SafeFallback.VerifiedSource> sources,
@@ -148,6 +204,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/**
* 公开 scope:从 agent_result / normalizedScope 提取对外展示的查询范围,
* 按 Tool 类型不同(RAG=query、LOG=scope 对象、MYSQL=data_source),
* 统一截断到 MAX_SCOPE_CHARS——不泄露完整参数。
*/
private String publicScope(String toolName, String normalizedScope, JsonNode result) {
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS);
@@ -164,11 +225,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 从公开 scope("data_source=xxx")中提取 MySQL 数据源名。 */
private String mysqlSource(String scope) {
int separator = scope.indexOf('=');
return separator < 0 ? "mysql" : scope.substring(separator + 1);
}
/**
* 添加一条有界事实:三重截断(source 160 / scope 320 / summary 320)后
* 写入去重 Map——同「来源类型 + 来源 + 范围」只发布一次 source,
* 同「sourceKey + 摘要」只发布一次 fact(LinkedHashMap 保持顺序)。
*/
private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
Map<String, SafeFallback.ObservedFact> facts,
String sourceType,
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
}
/**
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
* (由调用方捕获后记为 limitation,不发布)。
*/
private JsonNode readObject(String value) {
try {
JsonNode root = objectMapper.readTree(value);
@@ -201,12 +272,14 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
}
}
/** 追加 limitation(按文案去重,避免同一条限制重复出现)。 */
private static void addLimitation(List<String> limitations, String value) {
if (!limitations.contains(value)) {
limitations.add(value);
}
}
/** 取第一个非空值,全空返回 "unknown"。 */
private static String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
return "unknown";
}
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
private static String text(JsonNode node, String field) {
JsonNode value = node == null ? null : node.get(field);
return value == null || value.isNull() ? "" : value.asText("");
}
/** 截断到 max 字符(null 视为空串)。 */
private static String bounded(String value, int max) {
String safe = value == null ? "" : value;
return safe.length() <= max ? safe : safe.substring(0, max);
@@ -4,12 +4,29 @@ import com.superbiz.agent.harness.contract.SafeFallback;
import java.util.List;
/**
* 停止后的「安全进展快照」:Run 已完成的验证事实 + 限制声明 + 停止原因。
*
* <p>由 {@link DiagnosisProgressProjector} 在 Agent 停止后投影生成:
* 把 Tracker 里的调用 identity 回读 Canonical Store 的 READY 记录,
* 重读投影成有界、去重的事实——不是模型自述,是账本背书。
*
* <p>被 {@code DiagnosisReleaseUseCase} 消费:
* 受控停止 / 无结论 / 非法 Draft 三条降级路径都靠它决定发布形态
* (有 observed_facts 才允许发布 INSUFFICIENT_EVIDENCE,否则 fail closed)。
*/
public record DiagnosisProgressSnapshot(
/** 已验证来源(去重后):只到「查过哪些来源」粒度,供 FALLBACK 展示。 */
List<SafeFallback.VerifiedSource> verifiedSources,
/** 已观察事实(去重后):每条 = 来源类型 + 来源 + 范围 + 有界摘要,
* 是「Run 真的查过什么、结果如何」的证据性记录(空查询也算事实)。 */
List<SafeFallback.ObservedFact> observedFacts,
/** 限制声明:无法验真/不可读/截断等原因的诚实说明。 */
List<String> limitations,
/** 停止原因(受控停止时):信息饱和 / 预算耗尽 / 协议违规。 */
DiagnosisStopReason stopReason) {
/** 防御:三列表全部转不可变,null 视为空列表。 */
public DiagnosisProgressSnapshot {
verifiedSources = verifiedSources == null ? List.of() : List.copyOf(verifiedSources);
observedFacts = observedFacts == null ? List.of() : List.copyOf(observedFacts);
@@ -20,6 +37,11 @@ public record DiagnosisProgressSnapshot(
return new DiagnosisProgressSnapshot(List.of(), List.of(), List.of(), null);
}
/**
* 「是否有安全进展」的判断依据:observedFacts 非空即视为有已验真事实。
* release 域的 fail-closed 分支全靠它——没有事实就不能把
* 「没查到」伪装成业务结果发布。
*/
public boolean hasObservedFacts() {
return !observedFacts.isEmpty();
}
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Progress 层的核心状态机:判定「继续收集证据是否还有价值」。
*
* <p>与 RunBudget 的分工(双停止机制):
* <ul>
* <li>RunBudget 管「能不能花」——模型次数 / Tool 次数 / Token / bytes 等硬资源上限;</li>
* <li>本 Tracker 管「继续查有没有价值」——连续 NO_GAIN、重复 scope、协议违规都会推动
* 收集状态走向 SATURATED,进而在硬预算之前让 Agent 受控停止。</li>
* </ul>
*
* <p>设计要点:
* <ul>
* <li>只保存做停止决策需要的最小状态(identity + 计数),不保存 request / raw /
* agent result,避免出现第二份 Tool 真相(完整事实在 Canonical Store);</li>
* <li>所有状态读写 synchronized,是 RunContext 中的线程安全单一所有者;</li>
* <li>Tool 提供客观结果,模型判断语义增益(GAINED/NO_GAIN),但最终停止权归 Harness。</li>
* </ul>
*/
public final class DiagnosisProgressTracker {
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
private final int stopAfterConsecutiveNoGain;
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
private final int stopAfterConsecutiveProgressProtocolViolations;
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
/** 连续无增益次数;GAINED 清零。 */
private int consecutiveNoGain;
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
private int consecutiveProgressProtocolViolations;
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
private DiagnosisStopReason stopReason;
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
private String pendingToolCallId;
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
private boolean stopInstructionDelivered;
/**
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
*/
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
this(stopAfterConsecutiveNoGain, 2);
}
/**
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
*
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
*/
public DiagnosisProgressTracker(
int stopAfterConsecutiveNoGain,
int stopAfterConsecutiveProgressProtocolViolations) {
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用模型在下次 Tool Call 中回传的对上一轮观察的评价。
*
* <p>三类协议违规会被拒绝并抛 {@link ProgressProtocolViolationException}:
* <ul>
* <li>{@link ProgressProtocolViolationType#UNEXPECTED_PREVIOUS_OBSERVATION}——没有 pending
* 时却带了 previous_observation(如首次调用);</li>
* <li>{@link ProgressProtocolViolationType#MISSING_PREVIOUS_OBSERVATION}——有 pending 却
* 没带评价;</li>
* <li>{@link ProgressProtocolViolationType#OUT_OF_ORDER_PREVIOUS_OBSERVATION}——带的
* tool_call_id 与 pending 不符(乱序/指向未知调用)。</li>
* </ul>
*
* <p>校验通过后才清协议违规计数并应用 GAINED/NO_GAIN。协议错误与无增益是两件事:
* 前者 Tool 根本没执行,后者 Tool 执行了但没推进诊断,因此必须分开统计。
*/
public synchronized void applyPreviousObservation(PreviousObservation observation) {
if (pendingToolCallId == null) {
if (observation != null) {
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
"previous_observation",
null);
}
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
clearProtocolViolations();
return;
}
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
"previous_observation.tool_call_id",
pendingToolCallId);
}
// 校验通过:清空 pending,评价生效
pendingToolCallId = null;
clearProtocolViolations();
applyGain(observation.informationGain());
}
/**
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
*/
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
}
/**
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
*/
public synchronized void recordDuplicateScope() {
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
}
/**
* 记录一次成功的 Tool 完成。
*
* <ul>
* <li>SATURATED 后禁止再记录完成(饱和即停止收集);</li>
* <li>只接受 {@link EvidenceStatus#EVIDENCE_FOUND} 或 {@link EvidenceStatus#NO_EVIDENCE};
* 失败走技术失败流程,不进入进度统计;</li>
* <li>重复 scope 抛 IllegalStateException(应在此之前被 isDuplicate 拦截);</li>
* <li>{@link EvidenceStatus#NO_EVIDENCE}:空结果由 Harness 直接判 NO_GAIN,不需要模型评价;</li>
* <li>{@link EvidenceStatus#EVIDENCE_FOUND}:设置 pendingToolCallId,等模型在下次
* Tool Call 的 previous_observation 中评价语义增益。</li>
* </ul>
*/
public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Cannot record Tool completion after saturation");
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
}
completedToolCalls.add(call);
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
// 空结果无需模型评价:立即累计 NO_GAIN
clearProtocolViolations();
applyGain(InformationGain.NO_GAIN);
} else {
// 非空结果:挂起等待模型在下一轮评价语义增益
pendingToolCallId = call.toolCallId();
}
}
/**
* 记录一次 progress 协议违规,返回最新快照。
*
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
*/
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
if (collectionState == DiagnosisCollectionState.SATURATED) {
// 已饱和:不再累计,直接返回当前快照
return snapshot();
}
consecutiveProgressProtocolViolations++;
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
return snapshot();
}
/**
* 领取一次停止指令(STOP_REQUIRED)。
*
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
*/
public synchronized boolean claimStopInstruction() {
if (collectionState != DiagnosisCollectionState.SATURATED) {
return false;
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
return true;
}
/**
* 标记预算触顶(由 RunBudget 侧调用)。
*
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
* 信息饱和不能伪装成预算耗尽。
*/
public synchronized void markBudgetLimitReached() {
if (stopReason == null) {
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
}
}
/**
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
*/
public synchronized DiagnosisProgressSnapshotState snapshot() {
return new DiagnosisProgressSnapshotState(
consecutiveNoGain,
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
return stopAfterConsecutiveProgressProtocolViolations;
}
/**
* 应用单次增益判定(核心状态迁移):
* <ul>
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
* </ul>
* 饱和后禁止再次应用(停止权只行使一次)。
*/
private void applyGain(InformationGain gain) {
if (collectionState == DiagnosisCollectionState.SATURATED) {
throw new IllegalStateException("Collection is already saturated");
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
}
}
/**
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
*/
private void clearProtocolViolations() {
consecutiveProgressProtocolViolations = 0;
}
@@ -7,12 +7,23 @@ import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import java.util.Objects;
/**
* 发布裁决结果(Release 域唯一出口的结果类型)。
*
* <p>结构不变量:SUCCESS 必须有 draft 且不允许带 fallback;
* FALLBACK 必须有 fallback 且不允许带 draft——
* 成功只能带验证过的草稿、降级只能带安全回退,绝无「半真半假」的中间产物。
*/
public record DiagnosisReleaseResult(
ReleaseOutcome outcome,
DiagnosisDraft draft,
SafeFallback fallback,
VerifiedEvidenceSnapshot verifiedEvidence) {
/**
* 结构不变量:outcome 必填;SUCCESS ↔ draft、FALLBACK ↔ fallback 严格互斥;
* 本域只支持 SUCCESS / FALLBACK 两个出口(FAILED/CANCELLED 由 Application 层写)。
*/
public DiagnosisReleaseResult {
Objects.requireNonNull(outcome, "outcome must not be null");
Objects.requireNonNull(verifiedEvidence, "verifiedEvidence must not be null");
@@ -24,14 +24,41 @@ import com.superbiz.agent.harness.retry.RetryFailure;
import java.util.Objects;
import java.util.List;
/**
* 对外结果的「唯一发布点」:把 Agent 执行结果(Draft / 受控停止 / 非法 Draft)
* 裁决为 {@code SUCCESS / FALLBACK},并保证任何对外发布内容都经过
* 证据验证(EvidenceGuard)+ 语义裁决(SemanticGuard)。
*
* <p>决策树(与 progress 的衔接在这里):
* <pre>
* execute(execution)
* ├─ draft == null → 受控停止(progress + stopReason)→ INSUFFICIENT_EVIDENCE
* ├─ draft.conclusion == null → 无结论 Draft → 有已验真事实 ? INSUFFICIENT_EVIDENCE
* │ : missing_info ? MISSING_REQUIRED_CONTEXT
* │ : fail closed(抛异常)
* └─ 有结论 Draft → evidenceGuard 验引用 → repair 重试 → semanticGuard 裁决
* → SUPPORTED ? SUCCESS : FALLBACK(SEMANTIC_UNSUPPORTED)
* </pre>
*
* <p>任何 FALLBACK 都通过 SafeFallbackFactory 构造有界安全回退(不泄露 raw/敏感正文),
* 终态异常(取消/预算耗尽)向上传播不吞掉。
*/
public final class DiagnosisReleaseUseCase {
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
private final EvidenceGuard evidenceGuard;
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
private final EvidenceRepair evidenceRepair;
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
private final SemanticGuard semanticGuard;
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
private final SafeFallbackFactory fallbackFactory;
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
private final DiagnosisTraceRecorder traceRecorder;
/**
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
DiagnosisTraceRecorder.noop());
}
/**
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
*/
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
EvidenceRepair evidenceRepair,
SemanticGuard semanticGuard,
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
}
/**
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
* 包装成 completed execution(progress 为空),走完整裁决。
*/
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
return execute(context, query, DiagnosisAgentExecution.completed(
Objects.requireNonNull(draft, "draft must not be null"),
DiagnosisProgressSnapshot.empty()));
}
/**
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
*
* <ul>
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
* <li>conclusion == null:模型明确无结论,校验其引用后按
* 有无已验真事实 / missing_info 决定发布类型;</li>
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
* </ul>
*/
public DiagnosisReleaseResult execute(
RunContext context, String query, DiagnosisAgentExecution execution) {
Objects.requireNonNull(context, "context must not be null");
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
DiagnosisDraft draft = execution.draft();
if (draft == null) {
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
return releaseControlledStop(context, execution.progress(), execution.stopReason());
}
if (draft.conclusion() == null) {
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
return releaseNoConclusion(context, draft, execution.progress());
}
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
return releaseConclusion(context, query, draft);
}
/**
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
*/
public DiagnosisReleaseResult releaseInvalidDraft(
RunContext context, DiagnosisProgressSnapshot progress) {
Objects.requireNonNull(context, "context must not be null");
Objects.requireNonNull(progress, "progress must not be null");
if (!progress.hasObservedFacts()) {
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
throw new IllegalStateException(
"Invalid Diagnosis Draft has no verified publishable progress");
}
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 有结论 Draft 的完整发布链:
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
*/
private DiagnosisReleaseResult releaseConclusion(
RunContext context, String query, DiagnosisDraft draft) {
DiagnosisDraft candidate = draft;
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
if (!evidence.valid()) {
try {
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
evidence = evidenceGuard.validate(context, candidate);
traceRecorder.record(TraceAuditEvents.evidenceValidation(
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
return evidenceFailure(context, evidence);
}
if (!evidence.valid()) {
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
return evidenceFailure(context, evidence);
}
}
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
decision = semanticGuard.review(
context, SemanticGuardInput.from(query, candidate, snapshot));
} catch (RuntimeException exception) {
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
propagateTerminal(exception);
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
}
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
return DiagnosisReleaseResult.success(candidate, snapshot);
}
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
traceRecorder.record(TraceAuditEvents.releaseDecision(
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
FallbackType.SEMANTIC_UNSUPPORTED));
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
}
/**
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
* 都没有 → 不变量被破坏,fail closed 抛异常。
*/
private DiagnosisReleaseResult releaseNoConclusion(
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
List<String> missingInfo = missingInfo(draft);
if (progress.hasObservedFacts()) {
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, missingInfo),
FallbackType.INSUFFICIENT_EVIDENCE);
}
if (!missingInfo.isEmpty()) {
// 零 Tool 直接声明缺上下文:合法,不强制空查
return progressFallback(context,
fallbackFactory.missingRequiredContext(missingInfo),
FallbackType.MISSING_REQUIRED_CONTEXT);
}
// 既无进展又无缺失声明 → 状态非法,fail closed
throw new IllegalStateException(
"No-conclusion Diagnosis has neither verified progress nor missing context");
}
/**
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
*/
private DiagnosisReleaseResult releaseControlledStop(
RunContext context,
DiagnosisProgressSnapshot progress,
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
throw new IllegalStateException("Unsupported Diagnosis stop reason");
}
if (!progress.hasObservedFacts()) {
// 没有已验证进展 → fail closed(对外不可发布任何结论)
throw new IllegalStateException(
"Controlled Diagnosis stop has no verified publishable progress");
}
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
return progressFallback(context,
fallbackFactory.insufficientEvidence(progress, List.of()),
FallbackType.INSUFFICIENT_EVIDENCE);
}
/**
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
*/
private DiagnosisReleaseResult progressFallback(
RunContext context,
com.superbiz.agent.harness.contract.SafeFallback fallback,
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
return DiagnosisReleaseResult.fallback(fallback);
}
/**
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
*/
private List<String> missingInfo(DiagnosisDraft draft) {
return draft.limitations() == null
? List.of() : draft.limitations().missingInfo();
}
/**
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
* (违规明细进 fallback,不发布模型原文)。
*/
private DiagnosisReleaseResult evidenceFailure(
RunContext context, EvidenceGuardResult evidence) {
traceRecorder.record(TraceAuditEvents.releaseDecision(
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
fallbackFactory.evidenceValidationFailed(evidence.violations()));
}
/**
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
*/
private void propagateTerminal(RuntimeException exception) {
if (exception instanceof RunAbortedException
|| exception instanceof BudgetExceededException) {
@@ -26,6 +26,25 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* 引用修复器(证据安全链的一环):EvidenceGuard 验真失败后,「只修引用、不修结论」。
*
* <p>核心约束:
* <ul>
* <li>prompt 锁死:只能改 analysis_id / tool_call_ids / based_on_analysis_ids
* 三个引用字段,结论、分析正文、kind、limitations 一律禁止动;</li>
* <li>语义不变性检查:修复前后 {@link SemanticDraftView#hasSameUserVisibleSemantics}
* 逐字段比对——用户可见内容一个字节不许变,变了判 SCHEMA_INVALID 重试;</li>
* <li>受控调用:与 SemanticGuard 同款全栈衔接(输入计预算、独立重试策略
* evidenceRepair、超时限制、GuardModelCall 受控调用、审计 trace)。</li>
* </ul>
*
* <p>为什么调大模型而不是 Harness 机械替换:修引用需要理解语义
* (哪条 analysis 该锚哪个调用),机械替换做不到;但修复器的自由度
* 被 prompt + 语义不变性双重锁死。
*
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用。
*/
public final class EvidenceRepair {
private final DiagnosisHarnessCore core;
@@ -69,6 +88,14 @@ public final class EvidenceRepair {
this.prompt = EvidenceRepairPrompt.load();
}
/**
* 主入口:输入(query + 原 draft + 违规清单)序列化并计预算
* → 构造 System(prompt)+User(输入) 双消息
* → 按 evidenceRepair 重试策略执行模型修复
* → 解析修复结果并做语义不变性检查(变了即失败)。
*
* @return 修复后的 DiagnosisDraft(仅引用字段可能变化)
*/
public DiagnosisDraft repair(RunContext context, String query, DiagnosisDraft original,
List<EvidenceViolation> violations) {
Objects.requireNonNull(context, "context must not be null");
@@ -110,6 +137,7 @@ public final class EvidenceRepair {
});
}
/** 严格反序列化修复输出(FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS),失败归类 PARSE_ERROR。 */
private DiagnosisDraft parse(String output) {
try {
return draftReader.readValue(output);
@@ -119,6 +147,7 @@ public final class EvidenceRepair {
}
}
/** 失败分类:GuardModelCallException 自带 RetryFailure;其余归 UNKNOWN。 */
private RetryFailure classify(Exception exception) {
return exception instanceof GuardModelCallException failure
? failure.failure() : RetryFailure.UNKNOWN;
@@ -3,6 +3,10 @@ package com.superbiz.agent.harness.release;
import java.time.Duration;
import java.util.Objects;
/**
* EvidenceRepair 的限额:输入/输出字节 + 单次修复超时。
* 防修复器本身成为无底洞(超大 draft 或无限重试)。
*/
public record EvidenceRepairLimits(
long maxInputBytes,
long maxOutputBytes,
@@ -13,11 +13,28 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 安全回退工厂:构造所有 FALLBACK 形态的有界、去重、诚实降级。
*
* <p>设计要点:
* <ul>
* <li>诚实降级:降级不抹掉进展——SEMANTIC_UNSUPPORTED / INSUFFICIENT_EVIDENCE
* 保留已验证事实(observed_facts / verified_sources)供用户继续排查;</li>
* <li>有界:observed_facts 最多 12 条、摘要 320 字符,missing_info 最多 8 条
* (绝不泄露 raw / 敏感正文,空摘要降级为受限审计说明文案);</li>
* <li>conclusion 恒为 null:降级不发布根因结论;</li>
* <li>fail closed:insufficientEvidence 要求 progress 必须有已验真事实,
* missingRequiredContext 要求 missing_info 非空,否则拒绝构造。</li>
* </ul>
*
* <p>被 {@code DiagnosisReleaseUseCase} 的各个降级出口调用。
*/
public final class SafeFallbackFactory {
private static final int MAX_OBSERVED_FACTS = 12;
private static final int MAX_SUMMARY_CHARS = 320;
/** 引用验真失败(含 repair 后仍失败):只给违规明细,零事实。 */
public SafeFallback evidenceValidationFailed(List<EvidenceViolation> violations) {
return fallback(
FallbackType.EVIDENCE_VALIDATION_FAILED,
@@ -30,6 +47,7 @@ public final class SafeFallbackFactory {
issues(violations));
}
/** 结论不被证据支撑(verdict=UNSUPPORTED):保留已验证事实与来源。 */
public SafeFallback semanticUnsupported(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNSUPPORTED,
@@ -42,6 +60,7 @@ public final class SafeFallbackFactory {
List.of());
}
/** 语义评审技术不可用(超时等):不发布根因,保留已验证事实。 */
public SafeFallback semanticUnavailable(VerifiedEvidenceSnapshot snapshot) {
return fallback(
FallbackType.SEMANTIC_UNAVAILABLE,
@@ -54,6 +73,10 @@ public final class SafeFallbackFactory {
List.of());
}
/**
* 有限检查但证据不足(受控停止/无结论/非法 draft 降级的共同出口):
* 必须已有已验真事实(否则 fail closed),展示检查过的范围 + 缺失项。
*/
public SafeFallback insufficientEvidence(
DiagnosisProgressSnapshot progress, List<String> missingInfo) {
Objects.requireNonNull(progress, "progress must not be null");
@@ -78,6 +101,7 @@ public final class SafeFallbackFactory {
List.of());
}
/** 缺上下文未开始有效查询:只列缺失项,无事实。 */
public SafeFallback missingRequiredContext(List<String> missingInfo) {
List<String> safeMissingInfo = boundedMissingInfo(missingInfo);
if (safeMissingInfo.isEmpty()) {
@@ -112,6 +136,10 @@ public final class SafeFallbackFactory {
return Objects.requireNonNull(snapshot, "snapshot must not be null").verifiedSources();
}
/**
* 从验证快照提取去重后的可观察事实:key = 来源类型+来源+范围+摘要,
* 最多 12 条、摘要 320 字符;空摘要降级为受限审计说明(不泄露 raw)。
*/
private List<SafeFallback.ObservedFact> facts(VerifiedEvidenceSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot must not be null");
Map<String, SafeFallback.ObservedFact> unique = new LinkedHashMap<>();
@@ -133,6 +161,7 @@ public final class SafeFallbackFactory {
return List.copyOf(unique.values());
}
/** 把违规明细映射为对外可用的 ValidationIssue 列表(code + target)。 */
private List<SafeFallback.ValidationIssue> issues(List<EvidenceViolation> violations) {
List<SafeFallback.ValidationIssue> result = new ArrayList<>();
for (EvidenceViolation violation : violations == null ? List.<EvidenceViolation>of() : violations) {
@@ -15,13 +15,20 @@ import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
import java.util.Objects;
/** Validates and runs the logical MySQL Tool through the canonical boundary. */
/**
* MySQL 逻辑工具的接线员:反序列化请求 → SQL 沙箱校验(fail-closed)→
* 把只读执行器(executor)和投影器(projector)组装进 ToolBoundary 统一门禁。
* 安全/参数异常映射为 INVALID_REQUEST(不泄露内部细节)。
*/
public final class MysqlToolAdapter {
private final ToolBoundary boundary;
private final ObjectMapper objectMapper;
/** SQL 沙箱:白名单表列 + fail-closed 策略。 */
private final MysqlSqlValidator validator;
/** 只读执行器(JDBC 只读连接 + 超时 + 行数 + 取消)。 */
private final MysqlReadOnlyExecutor executor;
/** 投影器:raw 行 → 有界脱敏契约。 */
private final MysqlResultProjector projector;
public MysqlToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
@@ -34,12 +41,19 @@ public final class MysqlToolAdapter {
this.projector = Objects.requireNonNull(projector, "projector must not be null");
}
/**
* 执行入口:解析请求 → SQL 沙箱校验(生成执行计划)→ 组装 executor/projector
* 交给 ToolBoundary。任何安全/参数异常统一映射 INVALID_REQUEST。
*/
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
try {
MysqlToolRequest request = objectMapper.readValue(envelope.requestJson(), MysqlToolRequest.class);
// 沙箱校验:表列白名单 + fail-closed 策略 → 规范化执行计划
MysqlQueryPlan plan = validator.validate(request);
return boundary.execute(context, envelope,
// executor:只读执行器返回 raw 行 JSON
ignored -> objectMapper.writeValueAsString(executor.execute(plan, context)),
// projector:有界脱敏投影(用该数据源的限制)
raw -> projector.project(request, envelope.toolCallId(), raw, plan.dataSource().limits()));
} catch (MysqlSecurityException | IllegalArgumentException e) {
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
@@ -17,9 +17,14 @@ import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
/** Bridges logical query-log requests and the existing Mock tool through ToolBoundary. */
/**
* 逻辑日志请求与既有 Mock 工具的接线员:
* 反序列化请求 → 校验 topic/query/lookback → 构造查询范围 scope →
* 把 legacy executor 和投影器组装进 ToolBoundary 统一门禁执行。
*/
public final class QueryLogsToolAdapter {
/** legacy backend 执行端口(region + 旧主题 + 关键词 + 条数)。 */
@FunctionalInterface
public interface LegacyExecutor {
String execute(String region, String legacyTopic, String query, Integer limit) throws Exception;
@@ -58,25 +63,33 @@ public final class QueryLogsToolAdapter {
this.legacyLimit = legacyLimit;
}
/**
* 执行入口:解析请求 → 校验 → 构造 scope → 组装 executor/projector 交给 ToolBoundary。
* 业务参数非法返回 INVALID_REQUEST(不抛异常打断 ReAct)。
*/
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
try {
QueryLogsRequest request = objectMapper.readValue(envelope.requestJson(), QueryLogsRequest.class);
if (request.topic() == null || request.query() == null || request.query().isBlank()) {
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
}
// 回看窗口:缺省 30 分钟,上限 24 小时
int lookback = request.lookbackMinutes() == null
? DEFAULT_LOOKBACK_MINUTES : request.lookbackMinutes();
if (lookback <= 0 || lookback > 24 * 60) {
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
}
Instant end = clock.instant();
// 实际查询范围(审计/公开 scope 用)
LogQueryScope scope = new LogQueryScope(
request.topic(), request.query(),
DateTimeFormatter.ISO_INSTANT.format(end.minus(Duration.ofMinutes(lookback))),
DateTimeFormatter.ISO_INSTANT.format(end));
String legacyTopic = legacyTopic(request.topic());
return boundary.execute(context, envelope,
// executor:调用 legacy 日志 backend
ignored -> legacyExecutor.execute(region, legacyTopic, request.query(), legacyLimit),
// projector:投影成冻结契约(含 scope)
raw -> projector.project(request, envelope.toolCallId(), scope, raw));
} catch (Exception e) {
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
@@ -84,6 +97,7 @@ public final class QueryLogsToolAdapter {
}
}
/** 逻辑主题 → legacy 日志主题名映射。 */
private static String legacyTopic(LogTopic topic) {
return switch (topic) {
case APPLICATION -> "application-logs";
@@ -4,6 +4,10 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.Objects;
/**
* Projector 的产出:有界 agent_result 文本 + 客观 evidence status。
* ToolBoundary 只接受 FOUND/NO_EVIDENCE(ERROR 走错误路径,不产生投影结果)。
*/
public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceStatus) {
public ProjectedToolResult {
@@ -11,6 +15,7 @@ public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceSta
throw new IllegalArgumentException("agentResult must not be blank");
}
Objects.requireNonNull(evidenceStatus, "evidenceStatus must not be null");
// 投影结果只能是合法证据语义(空不空),错误状态不从这里出
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
throw new IllegalArgumentException("projected result must be evidence or no-evidence");
@@ -25,18 +25,46 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
/**
* 所有证据 Tool 的统一执行门卫(tool 域核心):每个 Adapter 不再自己实现
* 授权、预算、store 和审计,而是统一走这里。
*
* <p>职责(对一次 Tool 执行):
* <ol>
* <li>preflight:run 匹配 / 授权 / 只读意图 / JSON 合法性 / key 生成;</li>
* <li>预算门禁:Tool 预算(beforeToolCall)+ Run bytes 预留(request → raw → agent_result 三笔);</li>
* <li>canonical 状态机:begin(PROJECTING) → 执行/投影 → markReady(READY) 或 markError(ERROR);</li>
* <li>审计:best-effort 记录 ToolInvocationAuditEvent(失败不阻断业务)。</li>
* </ol>
*
* <p>边界(明确不做):
* <ul>
* <li>不理解 Tool 业务内容——raw → agent_result 的投影由调用方传入的
* {@code ToolResultProjector} 完成;</li>
* <li>不做信息增益判断(那是 progress 层的事);</li>
* <li>只允许 READY / ERROR 离开(PROJECTING 不对外暴露)。</li>
* </ul>
*/
public final class ToolBoundary {
private static final Logger log = LoggerFactory.getLogger(ToolBoundary.class);
/** Harness 核心:Run bytes 预留(reserveRunBytes)、Tool 预算检查(beforeToolCall)。 */
private final DiagnosisHarnessCore core;
/** canonical key 生成(runId + toolCallId)。 */
private final ToolCallKeyFactory keyFactory;
/** canonical 持久化(唯一真相源)。 */
private final CanonicalInvocationStore store;
/** request JSON 合法性校验。 */
private final ObjectMapper objectMapper;
/** 时间戳(begin/ready/error/audit 统一时钟)。 */
private final Clock clock;
/** 持久化审计 sink(可 noop)。 */
private final ToolInvocationAuditSink auditSink;
/** 当前 step id 追踪(可 null:无 step 场景不记录)。 */
private final AgentStepAuditTracker stepTracker;
/** 最简构造:审计 noop + stepTracker null(测试/轻量场景)。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -45,6 +73,7 @@ public final class ToolBoundary {
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
}
/** 带审计构造:持久化 Tool 审计,无 stepTracker。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -54,6 +83,7 @@ public final class ToolBoundary {
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
}
/** 全参构造:七个依赖全部必填(null 直接 NPE 暴露装配错误)。 */
public ToolBoundary(DiagnosisHarnessCore core,
ToolCallKeyFactory keyFactory,
CanonicalInvocationStore store,
@@ -70,6 +100,12 @@ public final class ToolBoundary {
this.stepTracker = stepTracker;
}
/**
* 统一执行入口:计算耗时 → 执行 canonical 状态机 → best-effort 审计 → 返回结果。
*
* @param executor 具体 backend 的 raw 执行函数(如 MySQL/日志 adapter)
* @param projector 该 Tool 的投影器(raw → 有界 agent_result + evidence status)
*/
public ToolBoundaryResult execute(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
@@ -80,6 +116,11 @@ public final class ToolBoundary {
return outcome.result();
}
/**
* canonical 状态机主流程:所有成功/失败路径都映射为
* ToolBoundaryResult.ready / ToolBoundaryResult.error,中间状态不外泄;
* 任何异常都先尝试 markError 落库(best-effort),再返回稳定错误码。
*/
private ExecutionOutcome executeCanonical(RunContext context,
ToolCallRequestEnvelope request,
ToolExecutor executor,
@@ -87,10 +128,12 @@ public final class ToolBoundary {
String toolCallId = request == null ? null : request.toolCallId();
String key;
try {
// ── 阶段一:preflight + Tool 预算 + request bytes 预留,通过后写 PROJECTING ──
key = preflight(context, request);
core.beforeToolCall(context, request.toolName());
long requestBytes = store.limits().utf8Bytes(request.requestJson());
if (requestBytes > store.limits().maxRecordBytes()) {
// 请求体超限:不落库直接拒绝
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
}
core.reserveRunBytes(context, requestBytes);
@@ -98,18 +141,22 @@ public final class ToolBoundary {
request.toolCallId(), request.runId(), request.toolName(),
request.requestJson(), clock.instant()));
} catch (DuplicateInvocationException e) {
// 同 key 重复 begin(同一 run+toolCallId 调两次)→ 拒绝
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
} catch (RunAbortedException | BudgetExceededException e) {
// Run 已取消/预算耗尽:对应终态错误码
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
e instanceof BudgetExceededException
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
: ToolBoundaryErrorCode.RUN_INACTIVE));
} catch (IllegalArgumentException e) {
// preflight 非法:按异常类型归类错误码
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
} catch (CanonicalStoreException e) {
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
}
// ── 阶段二:真正执行 backend(raw 响应)──
String rawResponse;
try {
rawResponse = Objects.requireNonNull(executor, "executor must not be null")
@@ -118,10 +165,12 @@ public final class ToolBoundary {
throw new IllegalArgumentException("executor returned null");
}
} catch (Exception e) {
// backend 执行失败:落 ERROR(无 raw)
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
}
// ── 阶段三:raw 大小校验 + Run bytes 预留 ──
try {
store.limits().validateRawCandidate(request.requestJson(), rawResponse);
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
@@ -139,6 +188,7 @@ public final class ToolBoundary {
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
}
// ── 阶段四:投影(raw → 有界 agent_result,Projector 计算 evidence status)──
ProjectedToolResult projected;
try {
projected = Objects.requireNonNull(projector, "projector must not be null")
@@ -147,11 +197,13 @@ public final class ToolBoundary {
throw new IllegalArgumentException("projector returned null");
}
} catch (Exception e) {
// 投影失败(raw 无法解析等):落 ERROR
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
}
// ── 阶段五:agent_result 校验 + bytes 预留 → 迁移 READY(唯一成功出口)──
try {
store.limits().validateAgentResult(projected.agentResult());
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
@@ -172,6 +224,7 @@ public final class ToolBoundary {
return new ExecutionOutcome(
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
} catch (CanonicalStoreException e) {
// store 持久化失败:错误码归类(结果过大 vs 投影问题)
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
: ToolBoundaryErrorCode.PROJECTION_ERROR;
@@ -180,17 +233,24 @@ public final class ToolBoundary {
}
}
/**
* 执行前门禁:run 匹配 / 授权 / 只读意图 / 字段与 JSON 合法性 / key 生成。
* 任何一项不过都会抛特定异常,由调用方归类为稳定错误码。
*/
private String preflight(RunContext context, ToolCallRequestEnvelope request) {
if (context == null || request == null) {
throw new IllegalArgumentException("request/context must not be null");
}
if (!context.runId().equals(request.runId())) {
// 信封 run 与当前 context 不一致 → RUN_MISMATCH
throw new RunMismatchException();
}
if (!request.authorized()) {
// 未授权调用 → UNAUTHORIZED
throw new UnauthorizedException();
}
if (!request.readOnly()) {
// 非只读意图(诊断 Tool 必须是只读)→ NOT_READ_ONLY
throw new NotReadOnlyException();
}
if (request.toolName() == null || request.toolName().isBlank()
@@ -198,6 +258,7 @@ public final class ToolBoundary {
throw new IllegalArgumentException("tool name and request must not be blank");
}
try {
// request 必须是合法 JSON 对象
JsonNode root = objectMapper.readTree(request.requestJson());
if (root == null || !root.isObject()) {
throw new IllegalArgumentException("request must be a JSON object");
@@ -208,10 +269,14 @@ public final class ToolBoundary {
try {
return keyFactory.create(request.runId(), request.toolCallId());
} catch (IllegalArgumentException e) {
// toolCallId 非法 → INVALID_TOOL_CALL_ID
throw new InvalidToolCallIdException(e);
}
}
/**
* 安全落 ERROR(best-effort):持久化失败只记 warn 日志,不阻断返回错误结果。
*/
private void markErrorSafely(String key, String rawResponse, ToolBoundaryErrorCode code) {
try {
store.markError(key, rawResponse, code.name(), clock.instant());
@@ -220,10 +285,12 @@ public final class ToolBoundary {
}
}
/** 请求体超限等「无需落库」的错误出口(尚未 begin,无记录可标记)。 */
private ToolBoundaryResult errorAndNoRecord(String toolCallId, ToolBoundaryErrorCode code) {
return ToolBoundaryResult.error(toolCallId, code);
}
/** 把 preflight 抛出的 IllegalArgumentException 归类为稳定错误码。 */
private ToolBoundaryErrorCode classifyPreflightError(IllegalArgumentException exception) {
if (exception instanceof InvalidToolCallIdException) {
return ToolBoundaryErrorCode.INVALID_TOOL_CALL_ID;
@@ -240,6 +307,10 @@ public final class ToolBoundary {
return ToolBoundaryErrorCode.INVALID_REQUEST;
}
/**
* 持久化 Tool 审计事件(best-effort):记录调用元数据(status/耗时/bytes/enrichments),
* 不保存完整 request/raw/agent result(敏感正文不进审计);失败只记 warn 不阻断业务。
*/
private void auditSafely(RunContext context,
ToolCallRequestEnvelope request,
ToolBoundaryResult result,
@@ -268,38 +339,45 @@ public final class ToolBoundary {
}
}
/** UTF-8 字节数(null 视为 0),用于 bytes 预算与审计。 */
private static int utf8Bytes(String value) {
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
}
/** 防溢出的 int 封顶转换。 */
private static int saturatingInt(long value) {
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
}
/** 执行结果 + 原始响应(审计用),rawResponse 仅成功/已产出时携带。 */
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
static ExecutionOutcome of(ToolBoundaryResult result) {
return new ExecutionOutcome(result, null);
}
}
/** preflight 专用异常:toolCallId 非法。 */
private static final class InvalidToolCallIdException extends IllegalArgumentException {
private InvalidToolCallIdException(Throwable cause) {
super("Invalid tool call ID", cause);
}
}
/** preflight 专用异常:信封 runId 与 context 不一致。 */
private static final class RunMismatchException extends IllegalArgumentException {
private RunMismatchException() {
super("Run ID does not match context");
}
}
/** preflight 专用异常:调用未授权。 */
private static final class UnauthorizedException extends IllegalArgumentException {
private UnauthorizedException() {
super("Tool call is not authorized");
}
}
/** preflight 专用异常:意图非只读(诊断 Tool 只读红线)。 */
private static final class NotReadOnlyException extends IllegalArgumentException {
private NotReadOnlyException() {
super("Tool call is not read-only");
@@ -4,6 +4,23 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.InvocationStatus;
/**
* ToolBoundary 向上(Harness)返回的结果:只允许 READY 或 ERROR 两种状态离开 Boundary。
*
* <p>状态-字段约束(构造时强校验):
* <ul>
* <li>READY:必须携带有界 agent_result + 合法证据语义(FOUND/NO_EVIDENCE),
* 不得带 error_code;</li>
* <li>ERROR:evidence_status 必须 ERROR + 稳定 error_code 必填,
* 不得带 agent_result(错误时没有可发布的投影结果);</li>
* <li>PROJECTING 等中间状态绝不对外暴露(Boundary 内部状态机专用)。</li>
* </ul>
*
* <p>两个工厂方法对应两条出口:{@link #ready}(成功投影后)与 {@link #error}(失败)。
*
* <p>拦截器拿到它后还会做双源交叉验证(ToolResultViewProjector 重算 evidence status),
* 因此 READY 的声明值与内容必须一致。
*/
public record ToolBoundaryResult(
@JsonProperty("status") InvocationStatus status,
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@@ -12,6 +29,7 @@ public record ToolBoundaryResult(
@JsonProperty("error_code") String errorCode) {
public ToolBoundaryResult {
// READY:必须有界证据结果(agent_result + FOUND/NO_EVIDENCE),禁止带错误码
if (status == InvocationStatus.READY) {
if (agentResult == null || evidenceStatus == null
|| (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
@@ -22,6 +40,7 @@ public record ToolBoundaryResult(
throw new IllegalArgumentException("READY result must not contain errorCode");
}
} else if (status == InvocationStatus.ERROR) {
// ERROR:稳定错误码必填 + evidence 必须 ERROR,禁止带投影结果
if (evidenceStatus != EvidenceStatus.ERROR || errorCode == null || errorCode.isBlank()) {
throw new IllegalArgumentException("ERROR result requires errorCode and ERROR evidence status");
}
@@ -29,10 +48,15 @@ public record ToolBoundaryResult(
throw new IllegalArgumentException("ERROR result must not contain agent result");
}
} else {
// PROJECTING 等中间状态不允许离开 Boundary
throw new IllegalArgumentException("ToolBoundaryResult must be READY or ERROR");
}
}
/**
* READY 出口:投影成功后调用,携带有界 agent_result 与客观证据语义
* (evidence 由 Projector 按「证据数组空不空」计算)。
*/
public static ToolBoundaryResult ready(String toolCallId,
String agentResult,
EvidenceStatus evidenceStatus) {
@@ -40,6 +64,9 @@ public record ToolBoundaryResult(
InvocationStatus.READY, evidenceStatus, toolCallId, agentResult, null);
}
/**
* ERROR 出口:失败时调用,携带稳定错误码(不携带 raw/agent_result)。
*/
public static ToolBoundaryResult error(String toolCallId, ToolBoundaryErrorCode errorCode) {
return new ToolBoundaryResult(
InvocationStatus.ERROR, EvidenceStatus.ERROR, toolCallId, null, errorCode.name());
@@ -2,11 +2,22 @@ package com.superbiz.agent.harness.tool.boundary;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 一次 Tool 执行的内部信封:同时证明 Run、调用 ID、工具、参数、授权意图和只读意图。
* 由 Adapter 在调 ToolBoundary 前构造(HarnessEvidenceTools 的 bridge 固定 authorized/readOnly=true)。
* 与模型侧 progress Envelope(previous_observation + input)不同:这是边界内部信封。
*/
public record ToolCallRequestEnvelope(
/** 所属 Run。 */
@JsonProperty("run_id") String runId,
/** 本次调用 ID(canonical key 的一部分)。 */
@JsonProperty("tool_call_id") String toolCallId,
/** 工具名。 */
@JsonProperty("tool_name") String toolName,
/** 业务请求 JSON(纯业务参数,无协议字段)。 */
@JsonProperty("request") String requestJson,
/** 是否授权(bridge 恒为 true;策略拒绝走 UNAUTHORIZED)。 */
@JsonProperty("authorized") boolean authorized,
/** 是否只读意图(诊断 Tool 必须只读,否则 NOT_READ_ONLY)。 */
@JsonProperty("read_only") boolean readOnly) {
}
@@ -1,6 +1,11 @@
package com.superbiz.agent.harness.tool.boundary;
/**
* 具体 backend 的 raw 执行函数端口(函数式):输入业务请求 JSON,输出原始响应文本。
* ToolBoundary 不依赖具体 backend,只认这个端口——Adapter 把各自 backend 接进来。
*/
@FunctionalInterface
public interface ToolExecutor {
/** 执行 backend,返回原始响应(非 null);失败抛异常由边界映射错误码。 */
String execute(String requestJson) throws Exception;
}
@@ -1,6 +1,12 @@
package com.superbiz.agent.harness.tool.boundary;
/**
* raw → 有界 agent 契约的投影端口(函数式):输入原始响应,输出投影结果
* (有界 agent_result + 客观 evidence status)。每个 Tool 一个实现
* (Rag/QueryLogs/Mysql ResultProjector),ToolBoundary 通过它解耦投影逻辑。
*/
@FunctionalInterface
public interface ToolResultProjector {
/** 投影 raw:必须返回非 null 的有界结果;失败抛异常由边界映射 PROJECTION_ERROR。 */
ProjectedToolResult project(String rawResponse) throws Exception;
}
@@ -1,21 +1,33 @@
package com.superbiz.agent.harness.tool.contract;
/**
* 三个证据 Tool 的「名字 + 模型可见描述」的单一事实源(冻结契约)。
*
* <p>所有地方(拦截器判重、Normalizer 分派、Projector 分派、注册表)都引用这里的
* 常量而不是字符串字面量——避免工具名拼错导致跨层漂移。
*/
public final class AgentToolContracts {
/** 知识库检索工具名。 */
public static final String LOOKUP_KNOWLEDGE = "lookup_knowledge";
/** 日志查询工具名。 */
public static final String QUERY_LOGS = "query_logs";
/** MySQL 只读查询工具名。 */
public static final String QUERY_MYSQL = "query_mysql";
/** 模型可见的 RAG 工具描述:稳定背景知识,不用于实时日志/指标。 */
public static final String LOOKUP_KNOWLEDGE_DESCRIPTION =
"查询内部知识库中的文档、接口说明、错误码和排障手册。"
+ "适用于稳定背景知识,不用于查询实时日志、指标或数据库状态。"
+ "输入 query:需要查询的问题或关键词。";
/** 模型可见的日志工具描述:应用错误/慢查询/系统事件,不用于指标或表。 */
public static final String QUERY_LOGS_DESCRIPTION =
"查询指定逻辑日志主题在时间窗口内与目标相关的日志证据。"
+ "适用于应用错误、慢查询和系统事件,不用于查询指标或数据库表。"
+ "输入 topic、query、lookback_minutes。";
/** 模型可见的 MySQL 工具描述:授权数据源的参数化只读 SELECT,禁止发现表结构/写操作。 */
public static final String QUERY_MYSQL_DESCRIPTION =
"在授权的逻辑数据源上执行参数化只读查询,获取业务数据库事实。"
+ "只用于已知库表字段的 SELECT,不用于发现表结构或执行写操作。"
@@ -2,6 +2,9 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 单条日志事件(冻结契约):时间/级别/服务/消息四元组。
*/
public record LogEvent(
@JsonProperty("timestamp") String timestamp,
@JsonProperty("level") String level,
@@ -2,11 +2,17 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 日志模式聚合(冻结契约):把相似事件压缩成一条「模式」,
* 供模型快速了解事件全貌而不用读每条原始事件。
*/
public record LogPattern(
/** 该模式出现次数。 */
@JsonProperty("count") long count,
@JsonProperty("first_seen") String firstSeen,
@JsonProperty("last_seen") String lastSeen,
@JsonProperty("level") String level,
@JsonProperty("service") String service,
/** 示例事件(有界)。 */
@JsonProperty("example") String example) {
}
@@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 实际日志查询范围(冻结契约):反映「这次到底查了什么」,
* 供审计、ProgressProjector 的公开 scope、以及重复检测参考。
*/
public record LogQueryScope(
@JsonProperty("topic") LogTopic topic,
@JsonProperty("query") String query,
@@ -1,5 +1,9 @@
package com.superbiz.agent.harness.tool.contract;
/**
* 日志来源类型(冻结契约)。当前只有 MOCK(演示/评估环境),
* 后续可扩展 ES/ClickHouse 等真实来源。
*/
public enum LogSourceKind {
MOCK
}
@@ -1,7 +1,13 @@
package com.superbiz.agent.harness.tool.contract;
/**
* 逻辑日志主题(冻结契约):模型只能在这三个主题内查询,不能自由指定任意来源。
*/
public enum LogTopic {
/** 应用错误/业务日志。 */
APPLICATION,
/** 数据库慢查询。 */
DATABASE_SLOW_QUERY,
/** 系统事件。 */
SYSTEM_EVENTS
}
@@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.progress.PreviousObservation;
/**
* 模型发起的 MySQL 只读查询 Tool 调用 Envelope(Agent-facing 冻结契约):
* 协议字段 previous_observation + 业务输入 input。
*/
public record MysqlToolCall(
/** 对上一轮观察的评价(首次调用可为 null)。 */
@JsonProperty("previous_observation") PreviousObservation previousObservation,
/** 业务输入:data_source + sql + params。 */
@JsonProperty("input") MysqlToolRequest input) {
}
@@ -4,9 +4,16 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* MySQL 只读查询业务输入(冻结契约):授权逻辑数据源 + 参数化 SQL + 绑定参数。
* 判重指纹 = {data_source, sql, params}。
*/
public record MysqlToolRequest(
/** 授权数据源名(不是任意 JDBC URL)。 */
@JsonProperty("data_source") String dataSource,
/** 参数化 SQL(只允许 SELECT,沙箱校验)。 */
@JsonProperty("sql") String sql,
/** 绑定参数(防注入)。 */
@JsonProperty("params") List<Object> params) {
public MysqlToolRequest {
@@ -6,12 +6,21 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
import java.util.Map;
/**
* MySQL 查询结果(冻结契约):Projector 投影后的有界结果。
* 列名 + 行数据(嵌套不可变),供模型看事实、Harness 看 returned_count/truncated。
*/
public record MysqlToolResult(
/** 证据语义:rows 空不空(客观判定)。 */
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
/** 列名列表(有界)。 */
@JsonProperty("columns") List<String> columns,
/** 行数据(有界、截断过,每行不可变 Map)。 */
@JsonProperty("rows") List<Map<String, Object>> rows,
/** 返回的行数。 */
@JsonProperty("returned_count") int returnedCount,
/** 是否因预算截断。 */
@JsonProperty("truncated") boolean truncated) {
public MysqlToolResult {
@@ -2,8 +2,15 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 日志查询业务输入(冻结契约):逻辑主题 + 关键词 + 回看窗口。
* 判重指纹 = {topic, query, lookback_minutes(缺省 30)}。
*/
public record QueryLogsRequest(
/** 逻辑日志主题(APPLICATION / DATABASE_SLOW_QUERY / SYSTEM_EVENTS)。 */
@JsonProperty("topic") LogTopic topic,
/** 查询关键词。 */
@JsonProperty("query") String query,
/** 回看分钟数;null 时 Normalizer 用默认 30。 */
@JsonProperty("lookback_minutes") Integer lookbackMinutes) {
}
@@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.progress.PreviousObservation;
/**
* 模型发起的日志查询 Tool 调用 Envelope(Agent-facing 冻结契约):
* 协议字段 previous_observation + 业务输入 input。
*/
public record QueryLogsToolCall(
/** 对上一轮观察的评价(首次调用可为 null)。 */
@JsonProperty("previous_observation") PreviousObservation previousObservation,
/** 业务输入:topic + query + lookback_minutes。 */
@JsonProperty("input") QueryLogsRequest input) {
}
@@ -5,15 +5,28 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
/**
* 日志查询结果(冻结契约):Projector 投影后的有界结果。
* 包含聚合 pattern(压缩)与原始 event(有界、截断过),
* 供模型看事件、Harness 看 match_count/truncated。
*/
public record QueryLogsToolResult(
/** 证据语义:events 空不空(客观判定)。 */
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
/** 日志来源类型(当前 MOCK)。 */
@JsonProperty("source_kind") LogSourceKind sourceKind,
/** 实际查询范围(topic/query/时间窗)。 */
@JsonProperty("scope") LogQueryScope scope,
/** 匹配总数(可能大于 returned_count)。 */
@JsonProperty("match_count") long matchCount,
/** 实际返回的事件条数。 */
@JsonProperty("returned_count") int returnedCount,
/** 压缩后的模式聚合(有界)。 */
@JsonProperty("patterns") List<LogPattern> patterns,
/** 事件明细(有界、截断过)。 */
@JsonProperty("events") List<LogEvent> events,
/** 是否因预算截断。 */
@JsonProperty("truncated") boolean truncated) {
public QueryLogsToolResult {
@@ -1,7 +1,15 @@
package com.superbiz.agent.harness.tool.contract;
/**
* RAG 检索相关度(冻结契约):Projector 客观计算,供 Harness/Release 参考。
* 注意:REFERENCE(一般相关)不能自动映射为信息 NO_GAIN——可能仍排除一个假设,
* 需要模型结合诊断上下文判断。
*/
public enum RagRelevanceLevel {
/** 精确匹配。 */
PRECISE,
/** 高度相关。 */
HIGHLY_RELEVANT,
/** 一般相关(参考级)。 */
REFERENCE
}
@@ -3,7 +3,14 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.progress.PreviousObservation;
/**
* 模型发起的 RAG 工具调用 Envelope(Agent-facing 冻结契约):
* 协议字段 previous_observation + 业务输入 input。
* 拦截器解析后剥离 previous_observation,只把 input 传给业务执行。
*/
public record RagToolCall(
/** 对上一轮观察的评价(首次调用可为 null)。 */
@JsonProperty("previous_observation") PreviousObservation previousObservation,
/** 业务输入:检索 query。 */
@JsonProperty("input") RagToolRequest input) {
}
@@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* RAG 业务输入(冻结契约):单个检索查询关键词。
*/
public record RagToolRequest(
/** 检索 query(判重指纹的一部分)。 */
@JsonProperty("query") String query) {
}
@@ -6,20 +6,31 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
/**
* RAG 工具结果(冻结契约):Projector 投影后的有界结果。
* 是模型看到的 observation 与 canonical 记录的 agent_result 的统一形态。
*/
public record RagToolResult(
/** 证据语义:证据数组空不空(客观判定)。 */
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
/** 原始检索 query。 */
@JsonProperty("query") String query,
/** 投影后的证据列表(有界、截断过)。 */
@JsonProperty("evidence") List<RagEvidence> evidence,
/** 返回的证据条数。 */
@JsonProperty("returned_count") int returnedCount,
/** 检索相关度(仅 EVIDENCE_FOUND 时有值;NO_EVIDENCE 时为 null 不序列化)。 */
@JsonProperty("relevance_level") @JsonInclude(JsonInclude.Include.NON_NULL)
RagRelevanceLevel relevanceLevel,
/** 是否因预算截断。 */
@JsonProperty("truncated") boolean truncated) {
public RagToolResult {
evidence = ToolContractCollections.immutable(evidence);
}
/** 便捷构造:无相关度(NO_EVIDENCE / 内部使用)。 */
public RagToolResult(EvidenceStatus evidenceStatus,
String toolCallId,
String query,
@@ -5,15 +5,21 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 契约对象的不可变集合工具(包私有):所有 Result 的列表字段统一用这里保证不可变,
* 防止投影层/消费方意外修改冻结契约。
*/
final class ToolContractCollections {
private ToolContractCollections() {
}
/** 列表不可变拷贝(null → 空列表)。 */
static <T> List<T> immutable(List<T> values) {
return values == null ? List.of() : List.copyOf(values);
}
/** 行集合不可变拷贝:每行 Map 也做深拷贝(嵌套不可变)。 */
static List<Map<String, Object>> immutableRows(List<Map<String, Object>> rows) {
if (rows == null) {
return List.of();
@@ -23,6 +29,7 @@ final class ToolContractCollections {
.toList();
}
/** 单行不可变拷贝(null → 空 Map)。 */
private static Map<String, Object> immutableRow(Map<String, Object> row) {
if (row == null) {
return Map.of();
@@ -20,11 +20,15 @@ import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
/** JDBC implementation with read-only, timeout, row and cancellation controls. */
/**
* JDBC 只读执行器:连接强制只读 + 查询超时 + 行数上限 + Run 取消联动。
* 是 MysqlReadOnlyExecutor 的唯一实现——沙箱的「执行侧」防线。
*/
public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
private static final Logger log = LoggerFactory.getLogger(JdbcMysqlReadOnlyExecutor.class);
/** 逻辑数据源 id → 真实 DataSource 映射(配置时注入)。 */
private final Map<String, DataSource> dataSources;
private final Clock clock;
@@ -41,13 +45,16 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
}
MysqlToolLimits limits = plan.dataSource().limits();
try (Connection connection = dataSource.getConnection()) {
// 强制只读连接(双保险:Validator 语义层 + JDBC 连接层)
connection.setReadOnly(true);
try (PreparedStatement statement = connection.prepareStatement(
plan.normalizedSql(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
statement.setQueryTimeout(limits.queryTimeoutSeconds());
// 多取一行用于检测截断
statement.setMaxRows(limits.maxRows() + 1);
bind(statement, plan.params());
// 注册取消回调:Run 取消时同步 cancel 正在执行的语句
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
context.cancellation().onCancel(ignored -> cancel(statementRef.get()));
checkRun(context);
@@ -61,6 +68,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
java.util.ArrayList<Map<String, Object>> rows = new java.util.ArrayList<>();
boolean truncated = false;
while (resultSet.next()) {
// 每行前检查 Run 终态/取消/超时
checkRun(context);
if (rows.size() >= limits.maxRows()) {
truncated = true;
@@ -74,6 +82,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
truncated |= cell.truncated();
}
rows.add(row);
// 结果字节预算:超限移除最后一行并标记截断
if (estimatedBytes(rows) > limits.maxResultBytes()) {
rows.remove(rows.size() - 1);
truncated = true;
@@ -86,6 +95,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
}
}
} catch (MysqlSecurityException e) {
// 安全异常原样穿出(Adapter 映射稳定错误码)
throw e;
} catch (SQLException e) {
log.debug("MySQL read-only execution failed: sqlState={}", e.getSQLState());
@@ -93,18 +103,21 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
}
}
/** 绑定参数(PreparedStatement 参数化,防注入)。 */
private static void bind(PreparedStatement statement, List<Object> params) throws SQLException {
for (int i = 0; i < params.size(); i++) {
statement.setObject(i + 1, params.get(i));
}
}
/** 执行前/每行检查:Run 已取消或过 deadline 则中止(与 core 终态联动)。 */
private void checkRun(RunContext context) throws SQLException {
if (context.cancellation().isCancelled() || !clock.instant().isBefore(context.deadline())) {
throw new SQLException("run cancelled or deadline exceeded");
}
}
/** 取消正在执行的语句(Run 取消回调)。 */
private static void cancel(Statement statement) {
if (statement == null) {
return;
@@ -116,6 +129,9 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
}
}
/**
* 单元格 JSON 安全化:数字/布尔原样;byte[] 转 Base64;字符串截断到 maxCellChars。
*/
private static CellValue jsonSafe(Object value, int maxCellChars) {
if (value == null || value instanceof Number || value instanceof Boolean) {
return new CellValue(value, false);
@@ -131,6 +147,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
: new CellValue(text.substring(0, maxCellChars), true);
}
/** 行集预估字节数(结果预算用)。 */
private static int estimatedBytes(List<Map<String, Object>> rows) {
return rows.toString().getBytes(StandardCharsets.UTF_8).length;
}
@@ -7,11 +7,18 @@ import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
/** Logical datasource metadata and exact schema/table/column authorization. */
/**
* 逻辑数据源元数据 + 精确的 schema/表/列授权白名单:
* 模型只能查询白名单内的表与列——这是 MySQL 只读沙箱的「访问边界」。
*/
public record MysqlDataSourceDefinition(
/** 逻辑数据源 id(模型用这个,不暴露真实 JDBC)。 */
String id,
/** 默认 schema(必须出现在白名单里)。 */
String defaultSchema,
/** 授权白名单:schema → table → 允许的列集合。 */
Map<String, Map<String, Set<String>>> allowedSchemas,
/** 该数据源的查询限制。 */
MysqlToolLimits limits) {
public MysqlDataSourceDefinition {
@@ -19,6 +26,7 @@ public record MysqlDataSourceDefinition(
requireText(defaultSchema, "defaultSchema");
Objects.requireNonNull(allowedSchemas, "allowedSchemas must not be null");
Objects.requireNonNull(limits, "limits must not be null");
// 深拷贝白名单(列集合 TreeSet 排序保证确定性),防止外部修改
Map<String, Map<String, Set<String>>> schemas = new LinkedHashMap<>();
allowedSchemas.forEach((schema, tables) -> {
requireText(schema, "schema");
@@ -35,10 +43,12 @@ public record MysqlDataSourceDefinition(
}
}
/** 表是否被授权。 */
public boolean allowsTable(String schema, String table) {
return allowedSchemas.containsKey(schema) && allowedSchemas.get(schema).containsKey(table);
}
/** 列是否被授权。 */
public boolean allowsColumn(String schema, String table, String column) {
return allowsTable(schema, table) && allowedSchemas.get(schema).get(table).contains(column);
}
@@ -4,10 +4,18 @@ import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
import java.util.List;
/**
* 一次 MySQL 查询的执行计划:请求 + 解析出的逻辑数据源 + 规范化 SQL + 绑定参数。
* 由 MysqlToolAdapter 在 SQL 校验后构造,交给只读执行器执行。
*/
public record MysqlQueryPlan(
/** 模型原始请求(data_source/sql/params)。 */
MysqlToolRequest request,
/** 解析出的授权数据源定义(含 schema/表/列白名单与限制)。 */
MysqlDataSourceDefinition dataSource,
/** 校验并规范化后的 SQL(单条、无尾分号、禁注释等)。 */
String normalizedSql,
/** 绑定参数(防注入)。 */
List<Object> params) {
public MysqlQueryPlan {
@@ -5,7 +5,10 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/** Harness-only raw query result; never returned directly to an Agent. */
/**
* Harness-only raw 查询结果(不直接给 Agent):列 + 行 + 是否截断。
* 之后由 MysqlResultProjector 投影成冻结的 MysqlToolResult 才对外。
*/
public record MysqlRawResult(
List<String> columns,
List<Map<String, Object>> rows,
@@ -2,7 +2,12 @@ package com.superbiz.agent.harness.tool.mysql;
import com.superbiz.agent.harness.core.RunContext;
/**
* MySQL 只读执行端口(函数式):输入已校验的查询计划 + Run 上下文,输出 raw 结果。
* JdbcMysqlReadOnlyExecutor 是唯一实现(只读连接 + 超时 + 行数 + 取消控制)。
*/
@FunctionalInterface
public interface MysqlReadOnlyExecutor {
/** 执行只读查询,返回 Harness-only raw 结果;失败抛异常(安全/超时/SQL)。 */
MysqlRawResult execute(MysqlQueryPlan plan, RunContext context) throws Exception;
}
@@ -14,7 +14,16 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Projects raw JDBC rows into the bounded Agent-facing MySQL contract. */
/**
* 把 raw JDBC 行投影成有界、脱敏的 Agent 可见 MySQL 契约。
*
* <p>关键职责:
* <ul>
* <li>脱敏:列名含 password/token/secret/api_key 等敏感 token 时单元格置为 [REDACTED];</li>
* <li>有界:行数(maxRows)+ 单元格字符(maxCellChars)+ 总字节(maxResultBytes)三重截断;</li>
* <li>客观证据语义:rows 空不空 → NO_EVIDENCE / EVIDENCE_FOUND。</li>
* </ul>
*/
public final class MysqlResultProjector {
private static final List<String> SENSITIVE_TOKENS = List.of(
@@ -37,6 +46,12 @@ public final class MysqlResultProjector {
return project(request, toolCallId, rawResponse, limits);
}
/**
* 主入口:raw JDBC 行 JSON → 冻结的 MysqlToolResult。
*
* <p>流程:校验 columns/rows 结构 → 列名去重 → 逐行逐列投影(敏感列脱敏 + 字符截断)
* → 行数/字节截断 → 判定 evidence status → fitBudget 总字节兜底。
*/
public ProjectedToolResult project(MysqlToolRequest request, String toolCallId,
String rawResponse, MysqlToolLimits projectionLimits) throws Exception {
if (request == null || toolCallId == null || toolCallId.isBlank()) {
@@ -49,6 +64,7 @@ public final class MysqlResultProjector {
}
List<String> columns = new ArrayList<>();
java.util.LinkedHashSet<String> uniqueColumns = new java.util.LinkedHashSet<>();
// 列名必须非空且唯一(避免歧义投影)
root.path("columns").forEach(node -> {
String column = node.asText();
if (column.isBlank() || !uniqueColumns.add(column)) {
@@ -59,6 +75,7 @@ public final class MysqlResultProjector {
List<Map<String, Object>> rows = new ArrayList<>();
boolean truncated = root.path("truncated").asBoolean(false);
for (JsonNode rowNode : root.path("rows")) {
// 行数上限:超出置 truncated 并停止
if (rows.size() >= projectionLimits.maxRows()) {
truncated = true;
break;
@@ -71,12 +88,14 @@ public final class MysqlResultProjector {
truncated |= cell.truncated();
}
rows.add(row);
// 结果字节预算:超限移除最后一行并标记截断
if (utf8Bytes(rows.toString()) > projectionLimits.maxResultBytes()) {
rows.remove(rows.size() - 1);
truncated = true;
break;
}
}
// 客观证据语义:行空不空
MysqlToolResult result = new MysqlToolResult(
rows.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
toolCallId, columns, rows, rows.size(), truncated);
@@ -84,6 +103,10 @@ public final class MysqlResultProjector {
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
}
/**
* 总字节兜底:超过 maxResultBytes 时逐行裁掉尾部;裁空则诚实降级为 NO_EVIDENCE;
* 仍超限则抛异常(fail closed)。
*/
private MysqlToolResult fitBudget(MysqlToolResult result, int maxResultBytes) throws Exception {
MysqlToolResult current = result;
while (utf8Bytes(objectMapper.writeValueAsString(current)) > maxResultBytes
@@ -100,11 +123,16 @@ public final class MysqlResultProjector {
return current;
}
/**
* 单单元格投影:null 原样;敏感列 → [REDACTED](含脱敏标记);
* 数字/布尔原样;字符串截断到 maxCellChars。
*/
private CellProjection projectCell(String column, JsonNode value, int maxCellChars) {
if (value == null || value.isNull()) {
return new CellProjection(null, false);
}
if (isSensitive(column)) {
// 敏感列(password/token/secret 等):绝不把真实值给 Agent
return new CellProjection("[REDACTED]", true);
}
if (value.isNumber()) {
@@ -119,6 +147,7 @@ public final class MysqlResultProjector {
: new CellProjection(text.substring(0, maxCellChars), true);
}
/** 列名是否含敏感 token(password/passwd/token/secret/api_key/apikey/credential)。 */
private static boolean isSensitive(String column) {
String normalized = column == null ? "" : column.toLowerCase(Locale.ROOT);
return SENSITIVE_TOKENS.stream().anyMatch(normalized::contains);
@@ -1,5 +1,9 @@
package com.superbiz.agent.harness.tool.mysql;
/**
* MySQL 沙箱安全异常:触发只读红线/未授权访问/危险 SQL 时抛出,
* 由 Adapter 映射为稳定的错误码(而非泄露内部细节)。
*/
public final class MysqlSecurityException extends RuntimeException {
public MysqlSecurityException(String message) {
@@ -41,7 +41,18 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** Fail-closed SQL policy for the Agent-facing MySQL Tool. */
/**
* MySQL 工具的 fail-closed SQL 策略(沙箱的「语义层」防线):
* 用 JSqlParser 解析 AST,逐一拒绝所有不安全形态。
*
* <p>禁止:非 SELECT / 多条语句 / WITH / 子查询 / 通配符投影(*)/
* 窗口函数 / CASE / EXISTS / 分层查询 / 字面量(必须参数化)/ 未授权表/列 /
* 锁读 / 复杂子句(OFFSET/FETCH/TOP 等)。
*
* <p>允许:白名单内表与列的 INNER/LEFT JOIN、聚合函数
* (COUNT/SUM/AVG/MIN/MAX)、占位符参数——且占位符数量必须与 params 匹配。
* 任何解析/校验异常统一转 MysqlSecurityException(fail closed,不泄露细节)。
*/
public final class MysqlSqlValidator {
private static final Set<String> ALLOWED_FUNCTIONS = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX");
@@ -53,11 +64,15 @@ public final class MysqlSqlValidator {
this.dataSources = Map.copyOf(dataSources);
}
/**
* 校验并生成执行计划。全流程 fail-closed:任何一步不满足直接抛 MysqlSecurityException。
*/
public MysqlQueryPlan validate(MysqlToolRequest request) {
if (request == null || request.dataSource() == null || request.dataSource().isBlank()
|| request.sql() == null || request.sql().isBlank()) {
throw new MysqlSecurityException("data_source and sql are required");
}
// 数据源必须存在于授权映射
MysqlDataSourceDefinition dataSource = dataSources.get(request.dataSource());
if (dataSource == null) {
throw new MysqlSecurityException("unknown logical data source");
@@ -66,6 +81,7 @@ public final class MysqlSqlValidator {
throw new MysqlSecurityException("SQL exceeds policy length");
}
try {
// 必须恰好一条语句,且是 SELECT
Statements statements = CCJSqlParserUtil.parseStatements(request.sql());
if (statements.getStatements() == null || statements.getStatements().size() != 1) {
throw new MysqlSecurityException("exactly one SQL statement is required");
@@ -78,6 +94,7 @@ public final class MysqlSqlValidator {
throw new MysqlSecurityException("WITH is not allowed");
}
SelectBody body = select.getSelectBody();
// 只允许普通 PlainSelect(无集合操作/值语句)
if (!(body instanceof PlainSelect plainSelect)
|| body instanceof SetOperationList
|| body instanceof ValuesStatement) {
@@ -97,10 +114,12 @@ public final class MysqlSqlValidator {
throw new MysqlSecurityException("unsupported SELECT clause");
}
// 表注册:FROM 必须是白名单内的实体表(禁子查询/非表来源),重复别名拒绝
Map<String, TableRef> tables = new LinkedHashMap<>();
registerTable(plainSelect.getFromItem(), dataSource, tables);
List<Join> joins = plainSelect.getJoins() == null ? List.of() : plainSelect.getJoins();
for (Join join : joins) {
// 只允许 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER)
if (join.isCross() || join.isRight() || join.isFull() || join.isOuter()
|| (!join.isInner() && !join.isLeft())) {
throw new MysqlSecurityException("only INNER/LEFT JOIN is allowed");
@@ -117,6 +136,7 @@ public final class MysqlSqlValidator {
}
}
// 投影必须显式(禁 * / t.*),所有表达式逐节点校验
if (plainSelect.getSelectItems() == null || plainSelect.getSelectItems().isEmpty()) {
throw new MysqlSecurityException("projection must be explicit");
}
@@ -129,6 +149,7 @@ public final class MysqlSqlValidator {
}
validateExpression(expressionItem.getExpression(), tables, dataSource);
}
// WHERE/HAVING/GROUP BY/ORDER BY 全表达式校验
validateExpression(plainSelect.getWhere(), tables, dataSource);
validateExpression(plainSelect.getHaving(), tables, dataSource);
if (plainSelect.getGroupBy() != null) {
@@ -140,6 +161,7 @@ public final class MysqlSqlValidator {
plainSelect.getOrderByElements().forEach(order ->
validateExpression(order.getExpression(), tables, dataSource));
}
// 占位符数量必须与 params 匹配(防止参数错位/少传)
int placeholders = countPlaceholders(plainSelect);
int provided = request.params() == null ? 0 : request.params().size();
if (placeholders != provided) {
@@ -148,12 +170,15 @@ public final class MysqlSqlValidator {
return new MysqlQueryPlan(request, dataSource, statement.toString(),
request.params() == null ? List.of() : request.params());
} catch (MysqlSecurityException e) {
// 业务规则违规:原样穿出(已分类)
throw e;
} catch (Exception e) {
// 解析/其他异常:统一 fail closed(不泄露内部细节)
throw new MysqlSecurityException("SQL cannot be safely validated", e);
}
}
/** 注册 FROM 表:必须是白名单内实体表,别名唯一。 */
private void registerTable(FromItem item, MysqlDataSourceDefinition dataSource,
Map<String, TableRef> tables) {
if (!(item instanceof Table table)) {
@@ -174,6 +199,7 @@ public final class MysqlSqlValidator {
}
}
/** 表达式逐节点校验:列白名单 / 函数白名单 / 禁字面量、子查询、通配符、窗口函数等。 */
private void validateExpression(Expression expression, Map<String, TableRef> tables,
MysqlDataSourceDefinition dataSource) {
if (expression == null) {
@@ -282,6 +308,10 @@ public final class MysqlSqlValidator {
});
}
/**
* 列校验:限定表时查该表列白名单;未限定时必须在已注册表中唯一匹配
* (否则视为歧义或未授权)。
*/
private void validateColumn(Column column, Map<String, TableRef> tables,
MysqlDataSourceDefinition dataSource) {
String name = column.getColumnName();
@@ -290,12 +320,14 @@ public final class MysqlSqlValidator {
}
Table table = column.getTable();
if (table != null && table.getName() != null && !table.getName().isBlank()) {
// 限定表:必须在白名单内
TableRef ref = tables.get(table.getName().toLowerCase(Locale.ROOT));
if (ref == null || !dataSource.allowsColumn(ref.schema(), ref.table(), name)) {
throw new MysqlSecurityException("column is not allowlisted");
}
return;
}
// 未限定表:必须在已注册表中恰好一个白名单命中
List<TableRef> matches = tables.values().stream()
.filter(ref -> dataSource.allowsColumn(ref.schema(), ref.table(), name))
.toList();
@@ -304,6 +336,10 @@ public final class MysqlSqlValidator {
}
}
/**
* 统计 AST 中的占位符数(必须与 params 数量一致):
* 只在 AST 接受后按节点计数,引号内的问号不会被误计。
*/
private static int countPlaceholders(PlainSelect plainSelect) {
// Parser assigns JdbcParameter nodes; use the canonical SQL token count only after
// the AST has been accepted, so quoted question marks are not counted.
@@ -337,6 +373,7 @@ public final class MysqlSqlValidator {
return counter.count;
}
/** 占位符计数 visitor:统计 JdbcParameter;子查询继续拒绝。 */
private static final class PlaceholderCounter extends ExpressionVisitorAdapter {
private int count;
@@ -351,6 +388,7 @@ public final class MysqlSqlValidator {
}
}
/** 已注册表引用(schema + 表名),用于列白名单校验。 */
private record TableRef(String schema, String table) {
}
}
@@ -1,9 +1,17 @@
package com.superbiz.agent.harness.tool.mysql;
/**
* MySQL 工具限制:行数 / 单元格字符 / 结果字节 / 查询超时。
* 防止超大结果进 Agent 上下文,防长时间占用连接。
*/
public record MysqlToolLimits(
/** 最大返回行数。 */
int maxRows,
/** 单单元格最大字符数(超长截断)。 */
int maxCellChars,
/** 结果总字节上限。 */
int maxResultBytes,
/** 查询超时秒数。 */
int queryTimeoutSeconds) {
public MysqlToolLimits {
@@ -12,6 +20,7 @@ public record MysqlToolLimits(
}
}
/** 默认:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时。 */
public static MysqlToolLimits defaults() {
return new MysqlToolLimits(100, 2_000, 64 * 1024, 5);
}

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