Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473bb5d004 | ||
|
|
9cf162482d | ||
|
|
83193bdf4a | ||
|
|
de56551fea | ||
|
|
da18fdf4e1 | ||
|
|
e1b8d1fb2c | ||
|
|
074d1aa5a9 | ||
|
|
f26d395650 | ||
|
|
ff0752a16c | ||
|
|
7844bcea40 | ||
|
|
e564863c43 | ||
|
|
d084202166 | ||
|
|
5b2fb985d9 | ||
|
|
b39a625e5b | ||
|
|
e9f1c48d34 | ||
|
|
3a7eee8af4 | ||
|
|
584639fa2a | ||
|
|
bdac35567c | ||
|
|
7ae9707a3b | ||
|
|
2f40536248 | ||
|
|
729cd3544a | ||
|
|
1e532fb851 | ||
|
|
38f781b157 | ||
|
|
5c369f3b6c | ||
|
|
f035538531 | ||
|
|
376ad0c241 | ||
|
|
ac1f831903 |
+1
-1
@@ -65,8 +65,8 @@ uploads/
|
||||
|
||||
### Windows / Runtime Artifacts
|
||||
*.stackdump
|
||||
NUL
|
||||
|
||||
### MVP Demo Generated Outputs
|
||||
mvp/demo/output/*.json
|
||||
!mvp/demo/output/README.md
|
||||
.pi/extensions/emdash-hook.ts
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -189,11 +189,15 @@
|
||||
|
||||
### Collection State
|
||||
- 定义:Diagnosis Harness 对当前 Run 是否允许继续收集证据的控制状态,固定为 `COLLECTING` 或 `SATURATED`。
|
||||
- 边界:状态由 Harness 维护;`SATURATED` 只表示连续 `NO_GAIN` 达到配置阈值,不包括硬预算耗尽。模型可以请求新的 Tool 调用,但不能绕过 `SATURATED`。
|
||||
- 边界:状态由 Harness 维护;`SATURATED` 可因连续 `NO_GAIN` 或连续进展协议错误达到各自配置阈值而进入,不包括硬预算耗尽。模型可以请求新的 Tool 调用,但不能绕过 `SATURATED`。
|
||||
|
||||
### Diagnosis Stop Reason
|
||||
- 定义:Harness 停止当前 Run 继续调用 Tool 的内部原因,首版区分 `INFORMATION_SATURATED` 与 `BUDGET_LIMIT_REACHED`。
|
||||
- 边界:它用于控制、Trace 和 Release 输入,不是用户可见生命周期状态,也不进入模型上下文;真正的不可恢复技术故障走失败通道。
|
||||
- 定义:Harness 停止当前 Run 继续调用 Tool 的内部原因,首版区分 `INFORMATION_SATURATED`、`BUDGET_LIMIT_REACHED` 与 `PROGRESS_PROTOCOL_VIOLATED`。
|
||||
- 边界:它用于控制、Trace 和 Release 输入,不是用户可见生命周期状态,也不进入模型上下文;真正的不可恢复技术故障走失败通道。协议错误不累计为 `NO_GAIN`,使用独立阈值与 stop reason。
|
||||
|
||||
### Progress Protocol Violation
|
||||
- 定义:模型未遵守 Tool Call Envelope 进展协议时的安全错误分类,例如缺失/错序/意外 `previous_observation`、缺失 `input` 或非法 Envelope。
|
||||
- 边界:返回可修正 observation(`repair_required`、`violation_type`、期望上一轮 Tool Call ID、允许的 `information_gain`);连续错误达到阈值后交付一次 `STOP_REQUIRED/PROGRESS_PROTOCOL_VIOLATED`。不泄露业务参数、上一轮观察正文、raw response 或内部异常。
|
||||
|
||||
### Progress Snapshot
|
||||
- 定义:Tool Loop 结束时,从当前 Run 的 Canonical Tool Result 一次性投影出的有界发布视图,用于生成已检查范围和客观结果。
|
||||
|
||||
+9
-2
@@ -1,16 +1,23 @@
|
||||
# devflow 索引
|
||||
# devflow 索引
|
||||
|
||||
## Issue 生命周期
|
||||
|
||||
| Issue | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| ISS-014 | archived | 阶段 0-7 的单体 Diagnosis Agent、Harness、ACI、SSE、清理和最终 E2E 已完成并归档;阶段实现对应的 11 个 devflow/OpenSpec 项目均已 archived。 |
|
||||
| ISS-015 | active | 承接 ISS-014 E2E 后发现的 Agent 硬停止、Evidence Repair Schema、Reasoning 审计验证/治理和 Fallback 信息质量问题。 |
|
||||
| ISS-015 | active | 阶段 1 硬停止已由 ISS-016 收口;剩余 Evidence Repair Schema、Reasoning 审计验证/治理与最终综合验收。 |
|
||||
| ISS-016 | archived | Diagnosis 信息增益停止契约、协议修复反馈与统一 Release 已完成并归档。 |
|
||||
|
||||
## 项目
|
||||
|
||||
| 日期 | slug | 说明 | 领域 | 关键词 | 关联 OpenSpec | 状态 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2026-07-28 | rag-eval-hybrid-baseline | 离线 RAG eval 对齐 hybrid:search.mode 生成器、fixture meta、baseline 重刷;hybrid 质量闸门可用 denseDistance。 | RAG/eval/baseline | search.mode, fixture meta, kb_scope rag-eval, L0 filter fallback, denseDistance | openspec/changes/archive/2026-07-28-rag-eval-hybrid-baseline | archived |
|
||||
| 2026-07-28 | rag-quality-score-unify | 统一 dense/hybrid scoreLabel 与 qualityScore;保检索序;去掉关键词 boost 改序与 hybrid L2 伪装。 | RAG/质量分/后处理 | qualityScore, scoreLabel dense/hybrid, originalRank, RetrievalScoreNormalizer, no boost rerank | openspec/changes/archive/2026-07-28-rag-quality-score-unify | archived |
|
||||
| 2026-07-26 | diagnosis-information-gain-stop-contract | Diagnosis 信息增益停止、协议修复反馈、ProgressSnapshot 与统一 Release。 | Harness/Diagnosis stop/Release | ISS-016, GAINED, NO_GAIN, STOP_REQUIRED, ProgressSnapshot, PROGRESS_PROTOCOL_VIOLATED, INSUFFICIENT_EVIDENCE | openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract | archived |
|
||||
| 2026-07-27 | rag-chunk-evidence-identity-dedup | chunk 级证据身份、去重、retrieve-k/return-n 与 SearchPort 地基,为 hybrid 铺路。 | RAG/证据身份/去重 | evidenceKey, maxChunksPerDocument, retrieve-k, return-n, KnowledgeSearchPort, document_id chunk-scoped | openspec/changes/archive/2026-07-27-rag-chunk-evidence-identity-dedup | archived |
|
||||
| 2026-07-27 | rag-bm25-hybrid-drop-sdk | 真 dense+BM25 hybrid(MilvusClientV2),废弃知识路径旧 SDK 检索/写入。 | RAG/BM25/hybrid | MilvusClientV2, BM25, hybridSearch, RRFRanker, biz_hybrid, drop SDK path | openspec/changes/archive/2026-07-27-rag-bm25-hybrid-drop-sdk | archived |
|
||||
| 2026-07-27 | rag-hybrid-search-rrf | Delivery 2:可配置 hybrid 检索与 RRF 多路融合(不绑旧 SDK)。 | RAG/hybrid/RRF | hybrid mode, RRF, KnowledgeSearchPort, filtered+unfiltered fusion, sparse-lite lexical | openspec/changes/archive/2026-07-27-rag-hybrid-search-rrf | archived |
|
||||
| 2026-07-21 | single-react-tool-invocation-store | 建立统一 ToolBoundary 与 Redis canonical invocation store,集中生命周期、证据状态、TTL、容量和 Run 所有权。 | Harness/Tool boundary/Canonical store | ISS-014, ToolBoundary, canonical invocation, PROJECTING, READY, ERROR, TTL, RESULT_TOO_LARGE | openspec/changes/archive/2026-07-21-single-react-tool-invocation-store | archived |
|
||||
| 2026-07-21 | single-react-harness-run-context | 建立显式 RunContext、Harness Core、预算、取消、类型化重试和 Tool Store 基础。 | Harness/Run lifecycle/Budget | ISS-014, RunContext, deadline, cancellation, budget, retry, ToolCallKey | openspec/changes/archive/2026-07-21-single-react-harness-run-context | archived |
|
||||
| 2026-07-21 | single-react-aci-tool-contracts | 冻结 RAG、日志和 MySQL evidence Tool 的 Agent-facing ACI Schema、状态、框架调用引用和描述边界。 | Harness/Agent Tool contract | ISS-014, ACI, tool_call_id, evidence_status, RAG, query_logs, query_mysql, MOCK | openspec/changes/archive/2026-07-21-single-react-aci-tool-contracts | archived |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Diagnosis 信息增益停止契约 验收
|
||||
|
||||
## 结果
|
||||
|
||||
已接受。OpenSpec tasks 37/37 完成;用户确认归档 OpenSpec、提交并推送。
|
||||
|
||||
## 验证
|
||||
|
||||
### 静态验证
|
||||
|
||||
- 命令/检查:`openspec validate diagnosis-information-gain-stop-contract --strict`
|
||||
- 结果:passed
|
||||
- 备注:Committed OpenSpec 与最终 tasks 一致
|
||||
|
||||
- 命令/检查:OpenSpec delta → main specs 同步(6 个 capability)
|
||||
- 结果:passed
|
||||
- 备注:新建 `openspec/specs/diagnosis-information-gain-stop-contract/`,并更新 5 个既有 main specs
|
||||
|
||||
- 命令/检查:对照 OpenSpec 与代码路径(progress tracker、interceptor、release、trace)
|
||||
- 结果:passed
|
||||
- 备注:Task 8 行为与 design/spec 对齐
|
||||
|
||||
### 脚本验证
|
||||
|
||||
- 命令:`mvn -q "-Dtest=DiagnosisProgressTrackerTest,HarnessToolInterceptorTest,DiagnosisReleaseUseCaseTest,DiagnosisAgentUseCaseTest,HarnessChatConfigurationTest" test`
|
||||
- 结果:passed(exit 0)
|
||||
- 备注:覆盖协议 violation_type、可修正 observation、连续协议错误 STOP、Release fail-closed、Agent 受控停止
|
||||
|
||||
- 命令:历史全量回归 `mvn -q -Dtest='!MilvusConnectionTest' test`(tasks 6.2/7.5 阶段)
|
||||
- 结果:passed(`Tests=292, Failures=0, Errors=0, Skipped=3`)
|
||||
- 备注:未设置 `MILVUS_TOKEN` 时 `MilvusConnectionTest` 失败属外部凭据边界,非本变更回归
|
||||
|
||||
### 浏览器/人工验证
|
||||
|
||||
- 步骤:Maven 启动真实应用;Query `诊断切换企业失败的问题`;named SSE + logs + `scripts/query_mysql.py` 按 exact sessionId/runId 核对
|
||||
- 结果:passed
|
||||
- 备注:
|
||||
- `sessionId=iss016-final-20260726-a`
|
||||
- `runId=3ab22ed7-d0ed-45d8-b928-dce5790c0542`
|
||||
- SSE:`SAFE_FALLBACK` / `MISSING_REQUIRED_CONTEXT` / `done.outcome=FALLBACK`
|
||||
- DB:`status=SUCCESS`、`intent=DIAGNOSIS`、`release_outcome=FALLBACK`、`tool_call_count=0`、`total_token_count=2890`
|
||||
- Trace:`RUN_STARTED -> ROUTING_* -> AGENT_MODEL_STEP -> EVIDENCE_GUARD_INITIAL -> RELEASE_DECISION/FALLBACK -> RUN_FINISHED/FALLBACK`
|
||||
|
||||
### 未验证
|
||||
|
||||
- 本轮 Archive 未再重跑全量 `mvn test` 与完整 live SSE E2E;依赖 Apply 阶段记录与本轮 focused tests。
|
||||
- 真实 Provider 下连续协议错误的 live E2E 未单独复跑;协议停止由 focused/scripted loop 覆盖。
|
||||
- ISS-015 阶段 2(Evidence Repair Schema)与阶段 3(Reasoning 治理)不在本 change 范围。
|
||||
|
||||
## 已完成范围
|
||||
|
||||
- 信息增益停止、scope 去重、STOP_REQUIRED、ProgressSnapshot、统一 Release
|
||||
- Token 审计与 Tool 拒绝 Trace
|
||||
- 协议修复反馈 + `PROGRESS_PROTOCOL_VIOLATED` 兜底停止
|
||||
- 文档:ISS-015 阶段 1、ISS-016、架构文档、glossary 术语、devflow 档案
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 重复检测只比较确定性 `tool_name + normalized_scope`,不做自然语言语义去重。
|
||||
- 协议错误阈值默认 2,与无增益阈值独立配置。
|
||||
- 无安全 ProgressSnapshot 的受控停止继续 fail closed,不伪造用户可见事实。
|
||||
- 公开 SSE/前端协议无新增字段;模型侧 Tool Envelope 是已确认 L3 变更。
|
||||
|
||||
## 交接
|
||||
|
||||
- 下一步:OpenSpec 已用户确认归档;代码提交并推送到当前分支。
|
||||
- OpenSpec 归档确认:用户确认归档(“执行,完后提交推送”)
|
||||
- 归档位置:`openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`
|
||||
@@ -0,0 +1,45 @@
|
||||
# Diagnosis 信息增益停止契约 Brief
|
||||
|
||||
## 背景
|
||||
|
||||
- 用户目标:未知问题、空证据或缺少查询条件时,Diagnosis 能以正常业务 Fallback 结束,而不是空转到预算耗尽或 `INTERNAL_FAILURE`。
|
||||
- 当前问题:停止主要依赖模型自觉结束或硬预算;缺少信息增益回传、确定性饱和停止、协议修复反馈和统一 Release。
|
||||
- 关联 OpenSpec:`openspec/changes/diagnosis-information-gain-stop-contract/`(归档后见 `openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`)
|
||||
- 关联 Issue:ISS-016(承接 ISS-015 阶段 1 硬停止)
|
||||
- devflow 分档:complex
|
||||
- 接口影响:L3(模型可见 Tool Envelope 有意变更;公开 HTTP/SSE 与业务 Tool backend 不变)
|
||||
|
||||
## 范围
|
||||
|
||||
### 本次要做
|
||||
|
||||
- Run 内二值信息增益 `GAINED / NO_GAIN`、连续无增益阈值与 `COLLECTING / SATURATED`。
|
||||
- 服务端注册的 Tool Call Envelope:`previous_observation + input`。
|
||||
- 确定性 `NO_GAIN`(`NO_EVIDENCE`、重复 `tool_name + normalized_scope`)。
|
||||
- 一次 `STOP_REQUIRED` 收尾机会与受控停止异常。
|
||||
- Canonical 控制视图 / 模型白名单观察双视图;RAG 保留 `relevance_level`。
|
||||
- Tool loop 结束时一次性投影 `ProgressSnapshot`。
|
||||
- `DiagnosisReleaseUseCase` 统一有结论、无结论、信息饱和、预算终止、协议违规停止的发布。
|
||||
- 可修正 `INVALID_PROGRESS_PROTOCOL` observation,以及独立 `PROGRESS_PROTOCOL_VIOLATED` 兜底停止。
|
||||
- 模型 Token 组件/轮次审计与 `TOOL_REQUEST_REJECTED` 安全 Trace。
|
||||
- Prompt、配置、focused/回归测试与真实 named SSE E2E。
|
||||
|
||||
### 本次不做
|
||||
|
||||
- `new_count`、`next_action`、多级质量分数、独立 Judge。
|
||||
- 自然语言语义去重。
|
||||
- 第二套诊断生命周期状态。
|
||||
- 公开 HTTP/SSE 字段或前端进度协议新增。
|
||||
- ISS-015 Reasoning 原文审计治理与 Evidence Repair Schema 注入。
|
||||
|
||||
### 影响区域
|
||||
|
||||
- `harness.progress`、`HarnessToolInterceptor`、`HarnessEvidenceTools`
|
||||
- `DiagnosisAgentUseCase` / Prompt / Release / Application 预算兜底迁移
|
||||
- Trace 审计、配置绑定、ISS-015/016 与架构文档
|
||||
|
||||
## OpenSpec 对齐
|
||||
|
||||
- proposal 覆盖状态:已覆盖
|
||||
- specs 覆盖状态:已覆盖(6 个 capability delta,已同步 main specs)
|
||||
- tasks 覆盖状态:已覆盖(37/37 完成)
|
||||
@@ -155,3 +155,34 @@
|
||||
- 数据库核对:`status=SUCCESS`、`intent=DIAGNOSIS`、`release_outcome=FALLBACK`、`tool_call_count=0`、`total_token_count=2890`、answer 非空。
|
||||
- Trace 核对:`RUN_STARTED -> ROUTING_ATTEMPT -> ROUTING_DECISION -> AGENT_MODEL_STEP -> EVIDENCE_GUARD_INITIAL -> RELEASE_DECISION/FALLBACK -> RUN_FINISHED/FALLBACK`。
|
||||
- 兼容性:公开 HTTP/SSE 字段、前端 SafeFallback 消费结构、数据库表和业务 Tool request 均未新增字段;模型侧 Tool Envelope 是本变更已确认的 L3 协议变更。
|
||||
|
||||
## Apply Continuation: Task 8 Protocol Repair + Bounded Stop
|
||||
|
||||
- Checkpoint:Apply。
|
||||
- Capability source:`openspec-apply-change` + sm-flow apply 协议。
|
||||
- 背景:tasks 1–7 已完成;真实 E2E 暴露连续 `INVALID_PROGRESS_PROTOCOL` 不会累计 `NO_GAIN`,可能在硬预算前空转。Task 8 补齐协议修复反馈与独立兜底停止。
|
||||
- 实现事实(代码已在工作区,本轮补齐测试与收口):
|
||||
- `ProgressProtocolViolationType` / `ProgressProtocolViolationException` 覆盖 MISSING_PREVIOUS_OBSERVATION、OUT_OF_ORDER、UNEXPECTED、MISSING_INPUT、INVALID_ENVELOPE。
|
||||
- `DiagnosisProgressTracker` 独立累计连续协议错误,默认阈值 2,达到后 `stop_reason=PROGRESS_PROTOCOL_VIOLATED`。
|
||||
- `HarnessToolInterceptor` 返回可修正 observation(repair_required、violation_type、missing_field、expected_previous_tool_call_id、allowed_information_gain);达阈一次 STOP_REQUIRED,再请求抛 `DiagnosisCollectionStoppedException`。
|
||||
- `DiagnosisReleaseUseCase` 支持 `PROGRESS_PROTOCOL_VIOLATED`:有安全 ProgressSnapshot 发 `INSUFFICIENT_EVIDENCE`,无进展 fail closed。
|
||||
- `TOOL_REQUEST_REJECTED` 记录 violation_type、repair_prompt_delivered、consecutive_protocol_violations、stop_reason,不记录参数/观察正文/异常。
|
||||
- 验证:
|
||||
- Focused:`DiagnosisProgressTrackerTest`、`HarnessToolInterceptorTest`、`DiagnosisReleaseUseCaseTest`、`DiagnosisAgentUseCaseTest`、`HarnessChatConfigurationTest` 通过。
|
||||
- OpenSpec strict validate 通过。
|
||||
- 文档:ISS-016 剩余协议停止项勾选完成;ISS-015 阶段 1 标记已完成;架构文档同步协议错误独立停止语义。
|
||||
- OpenSpec tasks 8.1–8.6 全部完成。剩余 Apply 工作:无。可进入 Archive checkpoint(需用户确认是否归档 OpenSpec)。
|
||||
|
||||
## Archive
|
||||
|
||||
- Checkpoint:Archive。
|
||||
- Capability source:`sm-flow` archive 协议 + `openspec-archive-change`。
|
||||
- 用户确认:明确要求“执行(archive),完后提交推送”。
|
||||
- devflow 档案:
|
||||
- `brief.md`、`evidence.md`、`decisions.md`、`acceptance.md`
|
||||
- 更新 `devflow/index.md`、`devflow/glossary/CONTEXT.md`
|
||||
- OpenSpec:
|
||||
- delta specs 已同步到 main specs(含新建 `diagnosis-information-gain-stop-contract`)
|
||||
- change 归档至 `openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`
|
||||
- 不创建独立 ADR:决策已由 OpenSpec/ISS/架构文档承载,且可通过 OpenSpec 回滚。
|
||||
- 状态:archived。
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Diagnosis 信息增益停止契约 Evidence
|
||||
|
||||
## 证据
|
||||
|
||||
| 来源 | 证据 | 结论 | 是否已汇报 |
|
||||
| --- | --- | --- | --- |
|
||||
| `HarnessEvidenceTools` / request records | Agent-facing Tool 直接使用业务 request 生成 Schema | 要增加 `previous_observation + input` 必须显式演进 Tool Schema | 是 |
|
||||
| `HarnessToolInterceptor` | 曾把完整 `agentResult` 放入 Tool Response | 必须拆分控制视图与模型观察 | 是 |
|
||||
| `RunContext` / `DiagnosisHarnessCore.startRun` | 结构不可变 + 线程安全 handle | 进展 tracker 放 RunContext,不扩 Redis 枚举 | 是 |
|
||||
| `CanonicalInvocationStore` | 仅 begin/find/markReady/markError | tracker 只存 identity,结束时投影 | 是 |
|
||||
| `RagResultProjector` | 只按 evidence 空否生成状态 | 需兼容 `relevanceLevel/relevance_level` | 是 |
|
||||
| `DiagnosisReleaseUseCase` / `EvidenceGuard` | 强制 draft 非空且空 analysis=`ANALYSIS_MISSING` | 与合法无结论冲突,需统一 Release | 是 |
|
||||
| `ChatApplicationUseCase.recoverBudgetExhaustion` | 未提交预算 Fallback 绕过 Release | 迁移意图到 Diagnosis Release | 是 |
|
||||
| 前端 `app.js` | 已渲染 observed_facts/sources/limitations/next_steps | 不新增公开 SSE 字段 | 是 |
|
||||
| 真实 E2E(实施前) | 多轮空转后 `BUDGET_EXHAUSTED`/`INTERNAL_FAILURE` | 需要信息增益停止契约 | 是 |
|
||||
| 真实 E2E(实施后) | `iss016-final-20260726-a` → `MISSING_REQUIRED_CONTEXT` FALLBACK | 未知 Query 可正常业务结束 | 是 |
|
||||
| Token/拒绝审计 E2E | 9 次 `INVALID_PROGRESS_PROTOCOL` 拒绝不累计 NO_GAIN | 需独立协议错误阈值与 STOP | 是 |
|
||||
|
||||
## Evidence-driven 结论
|
||||
|
||||
- 结论:Tool Envelope 是 L3 模型侧协议变更,业务 request 在 interceptor 解包后保持不变。
|
||||
- 证据:三个 FunctionToolCallback inputType、adapter bridge 只收业务 JSON。
|
||||
- 风险:框架 Schema/拦截器假设不匹配。
|
||||
- 用户确认:不需要(技术事实)
|
||||
|
||||
- 结论:协议错误不得累计为 `NO_GAIN`,必须独立 `PROGRESS_PROTOCOL_VIOLATED`。
|
||||
- 证据:真实审计 9 次协议拒绝 + 13 Agent 轮次;OpenSpec design 6.1。
|
||||
- 风险:只返回通用错误码不足以自修复。
|
||||
- 用户确认:已通过 Task 8 OpenSpec 与实现收口
|
||||
|
||||
- 结论:Release 是业务 Fallback 唯一决策入口;Application 不重建业务内容。
|
||||
- 证据:`DiagnosisReleaseUseCase` 统一路径 + Application 窄化预算终态持久化。
|
||||
- 用户确认:已确认
|
||||
|
||||
## 实现期补充证据
|
||||
|
||||
- Draft 合同失败窄化降级:非法 Draft 丢弃;仅当 ProgressSnapshot 有已验真 facts 时发 `INSUFFICIENT_EVIDENCE`。
|
||||
- Task 8 focused tests:tracker / interceptor / release / agent-loop / config 全部通过。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Acceptance
|
||||
|
||||
## Done
|
||||
|
||||
- `MilvusHybridKnowledgeStore`: schema BM25 function + dense, hybridSearch+RRFRanker, dense search
|
||||
- `VectorSearchService` only routes dense|hybrid to V2 store
|
||||
- `VectorIndexService` writes via V2 store
|
||||
- Removed knowledge-path `MilvusServiceClient` bean wiring
|
||||
- Config: `milvus.collection=biz_hybrid`, `retrieval.search.mode=hybrid`
|
||||
|
||||
## Tests
|
||||
|
||||
```text
|
||||
mvn -Dtest=LookupKnowledgeToolTest,KnowledgeEvidencePostProcessorTest,RagResultProjectorTest,RrfFusionTest,VectorKnowledgeSearchAdapterHybridTest,VectorSearchServiceTest,VectorIndexServiceTest test
|
||||
```
|
||||
|
||||
EXIT:0
|
||||
|
||||
## Ops note
|
||||
|
||||
Reindex all knowledge docs into `biz_hybrid` before production hybrid search is meaningful.
|
||||
Legacy `biz` collection is unused by knowledge path.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Brief: rag-bm25-hybrid-drop-sdk
|
||||
|
||||
True dense+BM25 hybrid on a single MilvusClientV2 backend. Legacy SDK search/write for knowledge path removed. New collection `biz_hybrid` requires reindex.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Decisions
|
||||
|
||||
- Single backend: MilvusClientV2 only for knowledge RAG
|
||||
- Drop sdk/spring/auto retrieval routing
|
||||
- New collection biz_hybrid to avoid mutating legacy biz schema in place
|
||||
- Hybrid = dense ANN + BM25 sparse ANN + RRFRanker
|
||||
- Dense L2 enrichment for threshold compatibility on hybrid hits
|
||||
@@ -0,0 +1,44 @@
|
||||
# Acceptance: rag-chunk-evidence-identity-dedup
|
||||
|
||||
## 实现结果
|
||||
|
||||
Delivery 1 completed:
|
||||
|
||||
- chunk identity fields on candidates/evidence blocks
|
||||
- `KnowledgeSearchPort` + dense adapter
|
||||
- evidenceKey dedup + maxChunksPerDocument + return-n
|
||||
- retrieve-k on lookup tool
|
||||
- projector keeps same-source distinct chunks; `document_id` is chunk-scoped
|
||||
|
||||
## 验证
|
||||
|
||||
### 脚本验证
|
||||
|
||||
```text
|
||||
mvn -q "-Dtest=LookupKnowledgeToolTest,KnowledgeEvidencePostProcessorTest,RagResultProjectorTest" test
|
||||
```
|
||||
|
||||
结果:通过(exit 0)
|
||||
|
||||
### 静态验证
|
||||
|
||||
- Search/port wiring reviewed against OpenSpec tasks
|
||||
- No new legacy SDK dependency added
|
||||
|
||||
### 浏览器/人工验证
|
||||
|
||||
未运行(纯检索契约变更,无 UI)
|
||||
|
||||
### 未验证
|
||||
|
||||
- 全量 harness E2E / 真实 Milvus 联调(Delivery 2 前可补)
|
||||
- 生产配置默认 retrieve-k/return-n 调优
|
||||
|
||||
## 归档状态
|
||||
|
||||
- OpenSpec change ready to archive
|
||||
- User pre-authorized archive for sm-flow staged delivery
|
||||
|
||||
## 后续
|
||||
|
||||
- Delivery 2: milvus hybrid search (separate change)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Brief: rag-chunk-evidence-identity-dedup
|
||||
|
||||
## 背景
|
||||
|
||||
lookup_knowledge 同文档多 chunk 在后处理与投影阶段被 source 级去重吞掉。Hybrid 多路召回前必须先修证据身份与裁剪契约。
|
||||
|
||||
## 目标
|
||||
|
||||
- chunk 级 evidenceKey 身份
|
||||
- 按 evidenceKey 去重 + 每文档 chunk 上限
|
||||
- retrieve-k / return-n 分离
|
||||
- Projector 保留同 source 不同 chunk
|
||||
- 薄 KnowledgeSearchPort,为 Delivery 2 hybrid 铺路
|
||||
|
||||
## 范围
|
||||
|
||||
Delivery 1 only(见 `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1)。
|
||||
|
||||
## 非目标
|
||||
|
||||
hybrid schema、BM25、删 SDK、session dedup、邻块重建、模型 rerank。
|
||||
|
||||
## 分档
|
||||
|
||||
standard
|
||||
|
||||
## 关联 OpenSpec
|
||||
|
||||
`openspec/changes/rag-chunk-evidence-identity-dedup`
|
||||
@@ -0,0 +1,102 @@
|
||||
# Decisions: rag-chunk-evidence-identity-dedup
|
||||
|
||||
## Capability sources
|
||||
|
||||
- sm-flow orchestration
|
||||
- OpenSpec fallback protocol (file-based propose/apply/archive) — external openspec-propose/apply skills used as reference; execution via sm-flow fallback
|
||||
- grill: fallback built-in protocol
|
||||
- audit: fallback built-in protocol
|
||||
|
||||
## Scale
|
||||
|
||||
standard
|
||||
|
||||
## Clarify
|
||||
|
||||
- Problem: same-document multi-chunk evidence collapsed by source-level dedup.
|
||||
- Outcome: Delivery 1 foundation before hybrid Delivery 2.
|
||||
- Slug: `rag-chunk-evidence-identity-dedup`
|
||||
- User authorized apply + archive in advance for sm-flow staged changes.
|
||||
|
||||
## Context
|
||||
|
||||
- Read: `devflow/glossary/CONTEXT.md`, modular-rag-pipeline brief, `openspec/specs/rag-knowledge-retrieval`, `rag-log-projections`, checklist doc §1.1
|
||||
- Constraints into OpenSpec:
|
||||
- L0 hint-only remains
|
||||
- Do not thicken legacy SDK path
|
||||
- Agent tool name/input stable
|
||||
- Hybrid out of scope this change
|
||||
|
||||
## Question pool (grill)
|
||||
|
||||
| # | Dimension | Mode | Question | Status |
|
||||
|---|---|---|---|---|
|
||||
| Q1 | 术语 | evidence-driven | evidenceKey / document_id 语义? | Resolved: evidenceKey=chunk id; projected document_id=evidenceKey |
|
||||
| Q2 | 边界 | evidence-driven | Delivery 1 vs 2 边界? | Resolved: per checklist; no schema/hybrid/SDK delete |
|
||||
| Q3 | 验收 | evidence-driven | 如何验收多 chunk? | Resolved: unit tests multi-chunk keep + projector |
|
||||
| Q4 | 接口 | user-interview | document_id 改为 chunk 级是否可接受? | **Pre-authorized by user** via “apply/archive 直接授权” + prior design agreement on scheme A (document_id=evidenceKey). Recorded as accepted behavior change. |
|
||||
| Q5 | 技术 | evidence-driven | SearchPort 是否本 change 必须? | Resolved: thin port required as foundation |
|
||||
|
||||
### Evidence-driven conclusions (reported)
|
||||
|
||||
1. Current collapse points: `KnowledgeEvidencePostProcessor.sourceKey` and `RagResultProjector` source fallback.
|
||||
2. Metadata already has docId/chunkIndex on write path; not first-class on read path.
|
||||
3. Existing main-spec still says source-level dedup — this change intentionally deltas that requirement.
|
||||
|
||||
### User-interview
|
||||
|
||||
- Q4 accepted under prior design alignment (scheme A) and explicit apply authorization for this sm-flow run. No remaining open product preference questions for Delivery 1.
|
||||
|
||||
## Audit
|
||||
|
||||
Module chain:
|
||||
|
||||
```text
|
||||
LookupKnowledgeTool -> SearchPort -> Retriever -> PostProcessor -> Packer -> Assembler -> RagResultProjector
|
||||
```
|
||||
|
||||
Risks:
|
||||
|
||||
1. Agent payload growth — mitigated by return-n + maxChunksPerDocument + projector budgets.
|
||||
2. document_id semantic shift — documented L3 behavior change; tests updated.
|
||||
3. Old data without chunkIndex — vector id fallback.
|
||||
|
||||
No ADR conflict with modular RAG L0/L1 boundary.
|
||||
|
||||
## Cross-artifact alignment
|
||||
|
||||
| From | To | Status |
|
||||
|---|---|---|
|
||||
| brief goals | proposal | 已对齐 |
|
||||
| proposal scope | design decisions | 已对齐 |
|
||||
| design identity/dedup/port | specs | 已对齐 |
|
||||
| specs scenarios | tasks | 已对齐 |
|
||||
|
||||
## Interface impact
|
||||
|
||||
- L2 internal DTO
|
||||
- L3 Agent `document_id` chunk-scoped
|
||||
|
||||
## Commit gate
|
||||
|
||||
- proposal/design/specs/tasks present
|
||||
- no open user-interview blockers for Delivery 1
|
||||
- apply authorized by user at sm-flow start
|
||||
|
||||
## Pre-apply research
|
||||
|
||||
Reference files:
|
||||
|
||||
- `LookupKnowledgeTool.java`
|
||||
- `KnowledgeDocumentRetriever.java`
|
||||
- `KnowledgeEvidencePostProcessor.java`
|
||||
- `RagResultProjector.java`
|
||||
- `LookupKnowledgeToolTest.java`
|
||||
- `RagResultProjectorTest.java`
|
||||
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md`
|
||||
|
||||
Stack notes:
|
||||
|
||||
- No MQ/request envelope changes
|
||||
- Spring `@Value` config pattern for rag.* keys
|
||||
- Tests use ReflectionTestUtils + Mockito
|
||||
@@ -0,0 +1,16 @@
|
||||
# Evidence: rag-chunk-evidence-identity-dedup
|
||||
|
||||
## 代码证据(变更前)
|
||||
|
||||
- `KnowledgeEvidencePostProcessor.sourceKey` 使用 source/title 去重
|
||||
- `RagResultProjector` 用 source 回退 document_id 并 HashSet 去重
|
||||
- 写入路径 metadata 已有 docId/chunkIndex,读路径未一等化
|
||||
|
||||
## 规格证据
|
||||
|
||||
- 旧 `openspec/specs/rag-knowledge-retrieval` 要求 source 级 dedup(本 change 以 delta 修正)
|
||||
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md` §1.1 定义 Delivery 1 地基
|
||||
|
||||
## 验证证据
|
||||
|
||||
- 单测覆盖 multi-chunk keep / true-dup merge / maxChunksPerDocument / projector same-source multi-chunk
|
||||
@@ -0,0 +1,23 @@
|
||||
# Acceptance: rag-hybrid-search-rrf
|
||||
|
||||
## Result
|
||||
|
||||
Hybrid mode implemented on KnowledgeSearchPort:
|
||||
|
||||
- dense unfiltered + dense filtered + lexical rank over union
|
||||
- RRF fusion by evidenceKey
|
||||
- dense-compatible score preserved for thresholds
|
||||
- default mode remains dense
|
||||
|
||||
## Verification
|
||||
|
||||
```text
|
||||
mvn -q "-Dtest=RrfFusionTest,VectorKnowledgeSearchAdapterHybridTest,LookupKnowledgeToolTest" test
|
||||
```
|
||||
|
||||
Pass.
|
||||
|
||||
## Residual
|
||||
|
||||
- True Milvus BM25/sparse schema + reindex still follow-up
|
||||
- Lexical path only ranks dense-recalled candidates (does not expand pure-term misses outside dense topK)
|
||||
@@ -0,0 +1,3 @@
|
||||
# Brief: rag-hybrid-search-rrf
|
||||
|
||||
Delivery 2 after chunk identity. Enable hybrid multi-path + RRF on KnowledgeSearchPort without legacy SDK hybrid API. True BM25 schema rebuild is staged follow-up; this change ships sparse-lite lexical ranking over dense candidate union + filtered/unfiltered dense fusion.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Decisions: rag-hybrid-search-rrf
|
||||
|
||||
## Capability
|
||||
|
||||
sm-flow + OpenSpec fallback; apply pre-authorized.
|
||||
|
||||
## Depends
|
||||
|
||||
Delivery 1 archived.
|
||||
|
||||
## Grill (compressed, pre-authorized)
|
||||
|
||||
- Q: Full BM25 schema now? A: No — sparse-lite + RRF first; schema rebuild follow-up.
|
||||
- Q: Default mode? A: dense default; hybrid opt-in.
|
||||
- Q: Threshold score? A: keep dense-compatible L2 mapping.
|
||||
|
||||
## Design
|
||||
|
||||
Hybrid paths: dense unfiltered + dense filtered + lexical rank over union; RRF fuse by evidenceKey.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Evidence
|
||||
|
||||
- Delivery 1 identity/port foundation required
|
||||
- RRF utility and hybrid adapter unit tests green
|
||||
- Lexical sparse-lite intentionally intermediate until BM25 schema
|
||||
@@ -0,0 +1,39 @@
|
||||
# Acceptance: rag-eval-hybrid-baseline
|
||||
|
||||
## Tasks
|
||||
|
||||
All tasks in OpenSpec `tasks.md` checked, including apply-discovered 6.x quality-gate fix.
|
||||
|
||||
## 静态验证
|
||||
|
||||
- Snapshot generator path: no required `retrieval.vector-store.mode`.
|
||||
- README documents hybrid generation and offline/live split.
|
||||
|
||||
## 脚本验证
|
||||
|
||||
```text
|
||||
.\scripts\prepare_rag_eval_seed.ps1
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode hybrid -SkipEval
|
||||
python scripts\eval_rag_retrieval.py --json-report eval/rag-retrieval/reports/baseline.json --markdown-report eval/rag-retrieval/reports/baseline.md
|
||||
# Result: Evaluated 7 cases: passRate=1.0, recall@5=1.0, failed=0
|
||||
|
||||
mvn -Dtest=RetrievalScoreNormalizerTest,KnowledgeEvidencePostProcessorTest,LookupKnowledgeToolTest,VectorSearchServiceTest,VectorKnowledgeSearchAdapterHybridTest test
|
||||
# exit 0
|
||||
```
|
||||
|
||||
Fixture sample meta: `searchMode=hybrid`, `kbScope=rag-eval`.
|
||||
Fallback case: `UNFILTERED_VECTOR_RETRY` + `filtered_vector_low_quality`.
|
||||
|
||||
## 浏览器/人工
|
||||
|
||||
- 未做 UI 验证。
|
||||
|
||||
## 未验证 / 后续
|
||||
|
||||
- Dense vs hybrid dual-directory comparison report (knife-2).
|
||||
- CI wiring of offline eval as required gate (optional process).
|
||||
- Long-term calibration of hybrid PRECISE distribution under denseDistance quality.
|
||||
|
||||
## Specs
|
||||
|
||||
Main spec synced: `openspec/specs/rag-eval-offline-baseline/spec.md`.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Brief: rag-eval-hybrid-baseline
|
||||
|
||||
## Background
|
||||
|
||||
Offline RAG eval (golden × fixture × key-field baseline) existed but generator/docs still used dead `retrieval.vector-store.mode=spring`. Fixtures lacked search meta and did not reflect hybrid main path.
|
||||
|
||||
## Goals (knife-1 only)
|
||||
|
||||
- Snapshot generation uses `retrieval.search.mode` (default hybrid; dense override).
|
||||
- Fixtures record `searchMode` / `kbScope`.
|
||||
- README documents hybrid-era offline vs live loop.
|
||||
- Best-effort live seed + regenerate fixtures + update baseline.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Dense/hybrid dual fixture trees; golden mustNot/chunk/level hard gates; new eval frameworks.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Decisions: rag-eval-hybrid-baseline(最终版)
|
||||
|
||||
## Process
|
||||
|
||||
sm-flow standard-lean: Discover → Commit → Apply → Archive.
|
||||
|
||||
## Key decisions
|
||||
|
||||
1. Replace eval generator `vector-store.mode` with `retrieval.search.mode` (default hybrid).
|
||||
2. Fixture meta: `searchMode`, `kbScope` when set.
|
||||
3. Knife-2 (dual fixtures / mustNot golden) deferred.
|
||||
4. Live refresh succeeded in apply env; baseline updated to hybrid snapshots.
|
||||
5. **Quality gate refinement (apply-found):** hybrid absolute quality for `isLowQuality` / relevance uses optional dense L2 (`denseDistance`); does not overwrite hybrid scoreLabel or RRF order. Rank mapping remains fallback when dense missing.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- Extra dense ANN on hybrid path for gate calibration (latency) vs correct filter-fallback behavior.
|
||||
- relevance_level still not a hard golden assertion (ordinal vs absolute mix).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Evidence: rag-eval-hybrid-baseline
|
||||
|
||||
## Pre-change
|
||||
|
||||
- `generate_rag_lookup_snapshots.ps1` passed `-Dretrieval.vector-store.mode=spring`.
|
||||
- Fixtures had `caseId/query/retrievedAt/lookupResult` only.
|
||||
- Offline eval already supported Hit levels, recall@K, baseline diff.
|
||||
|
||||
## User decisions
|
||||
|
||||
- Scope: knife-1 only (no dual fixture dirs).
|
||||
- Acceptance: wiring required; fixture refresh best-effort (env allowed full refresh).
|
||||
|
||||
## Apply-discovered
|
||||
|
||||
- After hybrid refresh, `chat-l0-filter-fallback` failed: pure rank→quality made topSimilarity=1.0 on decoy-only filtered hits → no unfiltered retry.
|
||||
- Fix: optional `denseDistance` on hybrid hits; quality gate uses L2 when present; sort order remains RRF.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Acceptance: rag-quality-score-unify
|
||||
|
||||
## Tasks
|
||||
|
||||
OpenSpec `tasks.md` 全部 `[x]`(1.1–6.2)。
|
||||
|
||||
## 静态验证
|
||||
|
||||
- 生产路径 grep:无 `bm25_only_no_dense` 发射、无 hybrid L2 enrichment(仅 Labels canonicalize 兼容旧串)。
|
||||
- 架构文档 §6 与 `application.yml` 注释已对齐 quality 契约。
|
||||
|
||||
## 脚本验证
|
||||
|
||||
```text
|
||||
mvn -Dtest=RetrievalScoreNormalizerTest,KnowledgeEvidencePostProcessorTest,LookupKnowledgeToolTest,VectorSearchServiceTest,VectorKnowledgeSearchAdapterHybridTest test
|
||||
```
|
||||
|
||||
| 套件 | 结果 |
|
||||
|---|---|
|
||||
| RetrievalScoreNormalizerTest | 4 passed |
|
||||
| KnowledgeEvidencePostProcessorTest | 6 passed |
|
||||
| LookupKnowledgeToolTest | 7 passed |
|
||||
| VectorSearchServiceTest | 2 passed |
|
||||
| VectorKnowledgeSearchAdapterHybridTest | 1 passed |
|
||||
|
||||
(PowerShell 可能将 JVM warning 标为 exit 1;日志中为 BUILD SUCCESS / Failures: 0。)
|
||||
|
||||
## 浏览器 / 人工验证
|
||||
|
||||
- 未跑:live `lookup_knowledge` hybrid vs dense 对照、生产阈值标定。
|
||||
|
||||
## 未验证
|
||||
|
||||
| 项 | 风险 | 建议 |
|
||||
|---|---|---|
|
||||
| 真实 Milvus hybrid 联调 | 序/质量分布与单测 mock 有差 | 启动服务后固定 query 集切 mode 对比 |
|
||||
| 阈值 0.75/0.5 在 hybrid rank 分下的标定 | retry/PRECISE 偏多或偏少 | 看 trace topSimilarity 再调 yml |
|
||||
|
||||
## Specs 同步
|
||||
|
||||
- 主规格新增:`openspec/specs/rag-retrieval-quality-score/spec.md`(archive 时从 delta 同步)。
|
||||
@@ -0,0 +1,21 @@
|
||||
# Brief: rag-quality-score-unify
|
||||
|
||||
## Background
|
||||
|
||||
真 BM25 hybrid(dense + BM25 + RRF)已上线,但后处理仍把 hybrid 结果伪装成 L2 做 `normalizeL2`,并用 L0 domain/entity/keyword contains 加分改序。排序权威与质量闸门分裂,词面信号被 BM25 与后处理双重计分。
|
||||
|
||||
## Goals
|
||||
|
||||
- 一级 `scoreLabel` 仅 `dense` | `hybrid`
|
||||
- 唯一 `toQualityScore`;后处理 label-agnostic
|
||||
- 排序主序 = 检索 `originalRank`;去掉关键词 boost 改序
|
||||
- hybrid quality = 本轮 rank 纯映射(不做 max(rank, denseSim)、不为闸门回填 L2)
|
||||
- 保留 `mode=dense` 作同库召回对照;线上默认 hybrid
|
||||
|
||||
## Scope
|
||||
|
||||
内部 RAG:store 发射、normalizer、evidence post-process、单测、架构文档 §6。
|
||||
|
||||
## Non-goals
|
||||
|
||||
精排 / query rewrite / 邻块、schema rebuild、改 Agent ACI 字段名、删除 dense 对照 mode。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Decisions: rag-quality-score-unify(最终版)
|
||||
|
||||
## Scale / process
|
||||
|
||||
- sm-flow standard:Discover → Commit → Apply → Archive
|
||||
- Committed OpenSpec:`openspec/changes/rag-quality-score-unify/`(归档后见 archive 目录)
|
||||
- 废止:`rag-bm25-hybrid-drop-sdk` 中「dense L2 enrichment for threshold compatibility」
|
||||
|
||||
## Key decisions
|
||||
|
||||
1. **Label**:仅 `dense` | `hybrid`;旧别名 canonicalize。
|
||||
2. **Normalizer**:唯一 `toQualityScore`;dense=L2 公式;hybrid=rank 线性映射(batchSize)。
|
||||
3. **Store**:hybrid 不回填 L2、不发 `bm25_only_*`;返回序即 RRF 序。
|
||||
4. **Post-process**:`originalRank` ASC;L0 重叠只写 hitReasons;relevance/low-quality 只看 qualityScore;PRECISE 不要求 hint support。
|
||||
5. **Mode**:hybrid 主路径;dense 同库对照(架构 §6.0)。
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- hybrid quality 为序数分,跨 query 绝对值不可比;阈值可能需后续标定。
|
||||
- 去掉 boost 改序后,「词面热语义冷」不再被后处理抬升;词面交给 BM25+RRF。
|
||||
|
||||
## Risks accepted
|
||||
|
||||
- `relevance_level` / unfiltered retry 分布变化(产品已接受)。
|
||||
- 未做 live E2E / 人工 hybrid 对照评测(见 acceptance 未验证项)。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Evidence: rag-quality-score-unify
|
||||
|
||||
## Code (pre-change)
|
||||
|
||||
- `MilvusHybridKnowledgeStore.searchHybrid`:RRF 后并行 dense 回填 L2;BM25-only → `bm25_only_no_dense` + maxL2。
|
||||
- `KnowledgeEvidencePostProcessor`:一律 `normalizeL2(score)` + domain/entity/keyword/source_type 加分,按 `finalScore` 降序;PRECISE 需 `hasHintSupport`。
|
||||
|
||||
## User decisions (grill)
|
||||
|
||||
| ID | 结论 |
|
||||
|---|---|
|
||||
| Q1 | 一级 label 仅 dense/hybrid;bm25_only 不作正式 label |
|
||||
| Q2 | 后处理去掉 contains 加分改序,保 originalRank |
|
||||
| Q3 | 唯一 toQualityScore;后处理统一 |
|
||||
| Q4 | dense mode 保留作对照 |
|
||||
| Q7 | 接受 relevance_level / retry 分布变化 |
|
||||
| Q8 | hybrid quality = **纯 rank 映射** |
|
||||
|
||||
## Post-change anchors
|
||||
|
||||
- `RetrievalScoreLabels` / `RetrievalScoreNormalizer`
|
||||
- `MilvusHybridKnowledgeStore`(无 L2 overwrite / 无 bm25_only 发射)
|
||||
- `KnowledgeEvidencePostProcessor`(rank sort + explain-only L0 overlap)
|
||||
- OpenSpec delta:`rag-retrieval-quality-score`
|
||||
- 架构:`mvp/architecture/RAG知识检索架构.md` §6
|
||||
+2
-58
@@ -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:
|
||||
|
||||
+58
-91
@@ -1,108 +1,75 @@
|
||||
# 文档索引
|
||||
|
||||
## 📂 目录结构
|
||||
**更新日期**:2026-07-29
|
||||
|
||||
```
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── README.md # 项目文档总览
|
||||
├── INDEX.md # 本索引文件
|
||||
│
|
||||
├── learning/ # 📚 学习笔记(个人学习理解)
|
||||
│ ├── 00-项目学习路径.md
|
||||
│ ├── 01~08-*.md # 按学习顺序编号
|
||||
│ └── README.md
|
||||
│
|
||||
├── analysis/ # 🔍 分析笔记(代码/问题分析)
|
||||
│ ├── essence-report-*.md
|
||||
│ ├── explore-report.md
|
||||
│ ├── chunking-issues-analysis.md
|
||||
│ └── 功能分析报告.md
|
||||
│
|
||||
├── reports/ # 📝 临时报告(修复/验证报告)
|
||||
│ ├── 修复报告-*.md
|
||||
│ ├── 验证报告-*.md
|
||||
│ └── 日志配置完成总结.md
|
||||
│
|
||||
└── guides/ # 📖 指南文档
|
||||
└── 日志配置与分析指南.md
|
||||
├── INDEX.md # 本索引
|
||||
├── learning/ # 早期学习笔记(可能过时)
|
||||
├── analysis/ # 早期代码/问题分析
|
||||
├── reports/ # 历史修复/验证报告
|
||||
└── guides/ # 操作指南
|
||||
|
||||
mvp/ # 现行 MVP 文档(主入口)
|
||||
├── architecture/ # 现行架构:系统现在怎么跑
|
||||
├── engineering/ # 工程纪要:问题 / 决策 / E2E
|
||||
├── issues/ # 未完成事项
|
||||
├── tables/ # 表结构
|
||||
├── demo/ # Demo
|
||||
└── eval/ # 诊断评测材料
|
||||
```
|
||||
|
||||
**⚠️ 注意:MVP 架构设计文档已移至项目根目录 `../mvp/`**
|
||||
|
||||
查看 [mvp/README.md](../mvp/README.md) 了解 MVP 架构、数据库设计、实施计划等。
|
||||
**现行架构、工程纪要、表结构、Issue 均以 [mvp/README.md](../mvp/README.md) 为准。**
|
||||
本目录 `learning/` / `analysis/` / `reports/` 偏早期学习与历史记录,**可能与当前实现不一致**。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速导航
|
||||
## 快速导航
|
||||
|
||||
### 我是新人/学习者
|
||||
1. [项目学习路径](learning/00-项目学习路径.md) - 从这里开始
|
||||
2. [learning/README.md](learning/README.md) - 学习笔记索引
|
||||
3. 按编号顺序阅读 `learning/` 目录下的文档
|
||||
### 我是开发者(优先)
|
||||
|
||||
### 我是开发者
|
||||
👉 **MVP 架构设计文档已移至 `../mvp/`**
|
||||
1. [mvp/README.md](../mvp/README.md) — MVP 总入口
|
||||
2. [mvp/architecture/](../mvp/architecture/) — 现行架构
|
||||
3. [mvp/engineering/](../mvp/engineering/) — 工程纪要(RAG / 诊断 E2E)
|
||||
4. [mvp/issues/](../mvp/issues/) — 活跃 Issue
|
||||
|
||||
请查看 [mvp/README.md](../mvp/README.md) 了解:
|
||||
- MVP 架构设计
|
||||
- 数据库设计和表结构
|
||||
- 实施规划(Phase 1/2/3)
|
||||
- 会话管理设计
|
||||
### RAG 工程纪要(已迁入 mvp)
|
||||
|
||||
### 我要查看分析报告
|
||||
1. [分析笔记目录](analysis/) - 代码分析和问题分析
|
||||
2. [临时报告目录](reports/) - 修复和验证报告
|
||||
1. [RAG 排序:多路召回与 RRF](../mvp/engineering/rag/RAG排序-多路召回与RRF.md)
|
||||
2. [Hybrid 之后的 qualityScore 与后处理](../mvp/engineering/rag/RAG-Hybrid质量分与后处理.md)
|
||||
3. [Agent 如何读 relevance_level](../mvp/engineering/rag/RAG-Agent如何读relevance_level.md)
|
||||
4. [RAG 离线评测:基线设计](../mvp/engineering/rag/RAG离线评测-基线设计.md)
|
||||
5. [Milvus hybrid 接入清单](../mvp/engineering/rag/Milvus-Hybrid接入清单.md)
|
||||
6. [RAG 审计补丁 E2E](../mvp/engineering/rag/RAG审计补丁-stepid-query-E2E验收.md)
|
||||
7. 架构对照:[RAG 知识检索架构](../mvp/architecture/RAG知识检索架构.md)、[RAG 检索可观测性与审计](../mvp/architecture/RAG检索可观测性与审计.md)
|
||||
|
||||
### 诊断全流程(已迁入 mvp)
|
||||
|
||||
1. [一次诊断到底发生了什么](../mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md)
|
||||
|
||||
### 我是新人 / 想看早期学习笔记
|
||||
|
||||
1. [项目学习路径](learning/00-项目学习路径.md)
|
||||
2. [learning/README.md](learning/README.md)
|
||||
3. 注意:内容可能过时,实现以 `mvp/architecture` 为准
|
||||
|
||||
### 分析 / 历史报告 / 指南
|
||||
|
||||
- [analysis/](analysis/) — 早期分析
|
||||
- [reports/](reports/) — 历史修复与验证报告
|
||||
- [guides/日志配置与分析指南.md](guides/日志配置与分析指南.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 学习笔记 (learning/)
|
||||
## 文档维护
|
||||
|
||||
按学习顺序编号,建议按顺序阅读:
|
||||
|
||||
1. [00-项目学习路径](learning/00-项目学习路径.md)
|
||||
2. [01-AI-Ops-核心设计-Essence报告](learning/01-AI-Ops-核心设计-Essence报告.md)
|
||||
3. [02-outputKey-深度解析](learning/02-outputKey-深度解析.md)
|
||||
4. [03-核心疑问解答](learning/03-核心疑问解答.md)
|
||||
5. [04-RAG-分块策略-Essence报告](learning/04-RAG-分块策略-Essence报告.md)
|
||||
6. [05-文件上传自动索引-Essence报告](learning/05-文件上传自动索引-Essence报告.md)
|
||||
7. [06-RAG查询流程-Essence报告](learning/06-RAG查询流程-Essence报告.md)
|
||||
8. [07-Tool定义方式对比与优化](learning/07-Tool定义方式对比与优化.md)
|
||||
9. [08-MethodToolCallback-vs-ToolCallingManager深度分析](learning/08-MethodToolCallback-vs-ToolCallingManager深度分析.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 分析笔记 (analysis/)
|
||||
|
||||
代码分析和问题分析文档:
|
||||
|
||||
- [essence-report-rag.md](analysis/essence-report-rag.md)
|
||||
- [essence-report-rag-chunking.md](analysis/essence-report-rag-chunking.md)
|
||||
- [explore-report.md](analysis/explore-report.md)
|
||||
- [chunking-issues-analysis.md](analysis/chunking-issues-analysis.md)
|
||||
- [功能分析报告.md](analysis/功能分析报告.md)
|
||||
|
||||
---
|
||||
|
||||
## 📝 临时报告 (reports/)
|
||||
|
||||
修复报告和验证报告:
|
||||
|
||||
- [修复报告-多轮对话时间查询缓存问题](reports/修复报告-多轮对话时间查询缓存问题.md)
|
||||
- [验证报告-时间查询问题](reports/验证报告-时间查询问题.md)
|
||||
- [日志配置完成总结](reports/日志配置完成总结.md)
|
||||
|
||||
---
|
||||
|
||||
## 📖 指南文档 (guides/)
|
||||
|
||||
- [日志配置与分析指南](guides/日志配置与分析指南.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 文档维护
|
||||
|
||||
- **学习笔记** 放在 `learning/` 目录,按编号顺序命名
|
||||
- **分析笔记** 放在 `analysis/` 目录
|
||||
- **临时报告** 放在 `reports/` 目录
|
||||
- **指南文档** 放在 `guides/` 目录
|
||||
- **MVP 架构设计** 已移至项目根目录 `../mvp/`(包含架构、数据库、实施计划)
|
||||
| 类型 | 位置 |
|
||||
|---|---|
|
||||
| 现行架构 | `mvp/architecture/` |
|
||||
| 工程纪要(问题/决策/E2E) | `mvp/engineering/` |
|
||||
| Issue | `mvp/issues/` |
|
||||
| 表结构 | `mvp/tables/` |
|
||||
| 早期学习 | `docs/learning/`(归档向,不充当现行规范) |
|
||||
| 操作指南 | `docs/guides/` |
|
||||
|
||||
+89
-143
@@ -1,38 +1,65 @@
|
||||
# RAG Retrieval Baseline
|
||||
|
||||
This directory contains the offline retrieval baseline for the RAG refactor.
|
||||
Offline regression harness for `lookup_knowledge` **after** hybrid retrieval + qualityScore post-process.
|
||||
|
||||
The baseline is intentionally narrower than full diagnosis evaluation. It checks
|
||||
whether fixed retrieval queries can recover expected documents, breadcrumbs, and
|
||||
evidence keywords before changing L0 behavior, query augmentation, evidence
|
||||
post-processing, or Spring AI VectorStore integration.
|
||||
It checks whether fixed queries still recover expected documents, breadcrumbs, keywords, and pipeline behaviors (filter / unfiltered retry). It is **not** a full diagnosis-agent E2E.
|
||||
|
||||
Production knowledge path: `MilvusHybridKnowledgeStore` with `retrieval.search.mode=hybrid` (dense+BM25+RRF).
|
||||
`mode=dense` remains a same-collection baseline for recall comparison (not a second index).
|
||||
|
||||
Related design notes:
|
||||
|
||||
- `mvp/engineering/rag/RAG-Hybrid质量分与后处理.md`
|
||||
- `mvp/engineering/rag/RAG-Agent如何读relevance_level.md`
|
||||
- `mvp/architecture/RAG知识检索架构.md` §6
|
||||
|
||||
## Offline vs live
|
||||
|
||||
| Layer | What | Needs live stack? |
|
||||
|-------|------|-------------------|
|
||||
| **Offline** | `fixtures/*.json` × `golden-cases.json` → pass/fail + baseline diff | **No** (no Milvus/LLM/Boot) |
|
||||
| **Snapshot generate** | Real `LookupKnowledgeTool` writes fixtures | **Yes** (embedding + Milvus + DB/L0 as configured) |
|
||||
| **Live smoke** | optional `eval_rag_live_acceptance.py` | Yes (running app) |
|
||||
|
||||
Daily CI / local quick check: **offline only**.
|
||||
After changing retrieval, indexing, or search mode: **regenerate fixtures**, then offline eval, then update baseline if the diff is intentional.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
eval/rag-retrieval/
|
||||
cases/golden-cases.json Fixed retrieval golden cases
|
||||
seed-docs/*.md Canonical docs imported into the live KB for real-tool eval
|
||||
fixtures/*.json Saved retrieval fixtures for each case
|
||||
reports/baseline.json Machine-readable baseline report
|
||||
reports/baseline.md Human-readable baseline report
|
||||
reports/baseline-diff.* Optional diff reports
|
||||
reports/live-post-reindex.* Optional live acceptance reports
|
||||
cases/golden-cases.json Fixed queries + expectations
|
||||
seed-docs/*.md Canonical docs for live snapshot (kb_scope: rag-eval)
|
||||
fixtures/*.json Frozen lookupResult snapshots (+ searchMode meta)
|
||||
reports/baseline.json|md Last accepted offline report
|
||||
reports/baseline-diff.* Optional diff vs previous report
|
||||
```
|
||||
|
||||
## Seed Docs + Import/Reindex
|
||||
## Fixture shape (minimum)
|
||||
|
||||
The live-tool eval uses canonical seed documents so the real
|
||||
`LookupKnowledgeTool` can retrieve stable evidence from MySQL/Milvus instead of
|
||||
whatever ad hoc documents happen to exist in the local knowledge base.
|
||||
```text
|
||||
caseId
|
||||
query
|
||||
retrievedAt
|
||||
searchMode # hybrid | dense (required on newly generated fixtures)
|
||||
kbScope # e.g. rag-eval when generation used a scope
|
||||
lookupResult # found, evidenceBlocks, contextPack, retrievalTrace, rerankTrace, …
|
||||
```
|
||||
|
||||
Seed documents live in:
|
||||
Offline eval **ignores unknown top-level meta** and does **not** full-JSON-compare.
|
||||
It asserts golden key fields only (doc/source, keywords, attempt, fallback, …). Raw scores are not pass criteria.
|
||||
|
||||
Older fixtures may omit `searchMode`; regenerate to attach meta.
|
||||
|
||||
## Seed docs + import
|
||||
|
||||
Live snapshot generation should use seed docs so results do not depend on ad-hoc local KB junk:
|
||||
|
||||
```text
|
||||
eval/rag-retrieval/seed-docs/*.md
|
||||
```
|
||||
|
||||
Each seed doc uses frontmatter fields that are propagated into vector metadata:
|
||||
Frontmatter example:
|
||||
|
||||
```yaml
|
||||
source: mysql-connection-pool
|
||||
@@ -40,40 +67,29 @@ breadcrumb: Database > MySQL > Connection Pool
|
||||
kb_scope: rag-eval
|
||||
```
|
||||
|
||||
Import or reindex the seed docs through the real upload pipeline:
|
||||
Import via real upload pipeline:
|
||||
|
||||
```powershell
|
||||
.\scripts\prepare_rag_eval_seed.ps1
|
||||
```
|
||||
|
||||
The script runs `RagEvalSeedImporterTest` with `rag.seed.enabled=true`. It
|
||||
deletes the existing document with the same `source`/`docId`, uploads the seed
|
||||
doc through `DocumentManagementService`, updates DB metadata and L0, and rebuilds
|
||||
Milvus chunks.
|
||||
Isolation:
|
||||
|
||||
`kb_scope` isolates eval data:
|
||||
- App default may leave `retrieval.kb-scope` empty (all docs).
|
||||
- Eval generation passes `-Dretrieval.kb-scope=rag-eval`.
|
||||
- Category-filter fallback retries without L0 category filter only; **kb_scope still applies**.
|
||||
|
||||
- default application config leaves `retrieval.kb-scope` empty, so legacy docs
|
||||
without `kb_scope` remain searchable;
|
||||
- eval scripts pass `-Dretrieval.kb-scope=rag-eval`, so L0 query hints and L1
|
||||
vector retrieval both use only the canonical eval seed docs;
|
||||
- the fallback retry skips only the L0 category filter, not the `kb_scope`
|
||||
boundary.
|
||||
Body is chunked/embedded; frontmatter feeds metadata/L0 (decoy keywords in frontmatter alone should not become dense content).
|
||||
|
||||
Frontmatter is not embedded as chunk content during upload. It feeds metadata,
|
||||
L0, and document enrichment; only the Markdown body is chunked and embedded.
|
||||
This keeps controlled L0 decoys from becoming semantically relevant just because
|
||||
their frontmatter keywords matched the query.
|
||||
Seeds must live in the **current hybrid collection schema** (`milvus.collection`, default `biz`). If the collection was recreated for BM25 hybrid, re-import seeds after rebuild.
|
||||
|
||||
## Run
|
||||
|
||||
From the repository root:
|
||||
## Offline run (no live stack)
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_retrieval.py
|
||||
```
|
||||
|
||||
Custom paths are also supported:
|
||||
Custom paths:
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_retrieval.py \
|
||||
@@ -83,83 +99,53 @@ python scripts/eval_rag_retrieval.py \
|
||||
--markdown-report eval/rag-retrieval/reports/baseline.md
|
||||
```
|
||||
|
||||
## Generate Fixtures From LookupKnowledgeTool
|
||||
|
||||
Use the snapshot generator when fixtures should reflect the real
|
||||
`LookupKnowledgeTool` pipeline:
|
||||
|
||||
```powershell
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1
|
||||
```
|
||||
|
||||
For the intended live loop, run seed import first:
|
||||
## Generate fixtures (live stack)
|
||||
|
||||
```powershell
|
||||
.\scripts\prepare_rag_eval_seed.ps1
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1
|
||||
python scripts\eval_rag_retrieval.py
|
||||
# default: SearchMode=hybrid, KbScope=rag-eval, then offline eval
|
||||
```
|
||||
|
||||
The script runs a Spring test harness:
|
||||
|
||||
```text
|
||||
mvn -q -Dtest=RagLookupSnapshotGeneratorTest -Drag.snapshot.enabled=true -Dretrieval.kb-scope=rag-eval -Dretrieval.vector-store.mode=spring test
|
||||
```
|
||||
|
||||
The generator reads `golden-cases.json`, injects the real `LookupKnowledgeTool`
|
||||
bean, calls `lookupKnowledge(query)` for each case, writes
|
||||
`fixtures/{caseId}.json`, and then runs `eval_rag_retrieval.py` unless
|
||||
`-SkipEval` is provided. It defaults to Spring AI VectorStore mode; pass
|
||||
`-VectorStoreMode sdk` only when intentionally comparing the legacy SDK path.
|
||||
|
||||
Custom paths are supported:
|
||||
Dense baseline snapshot (same seed, comparison only):
|
||||
|
||||
```powershell
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 `
|
||||
-Cases eval\rag-retrieval\cases\golden-cases.json `
|
||||
-Fixtures eval\rag-retrieval\fixtures `
|
||||
-RetrievedAt 2026-07-06T00:00:00Z
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode dense -Fixtures eval\rag-retrieval\fixtures-dense -SkipEval
|
||||
```
|
||||
|
||||
The generator is disabled in normal test runs. It only executes when
|
||||
`rag.snapshot.enabled=true` is provided because it writes repository files and
|
||||
depends on the configured runtime retrieval stack.
|
||||
(Dual-directory comparison reports are optional / future; knife-1 only documents the override.)
|
||||
|
||||
If generated fixtures fail the offline baseline, treat that as a real alignment
|
||||
signal: either the golden expectations need to be adjusted to the current
|
||||
knowledge base, or the knowledge base/indexing path needs to be fixed.
|
||||
|
||||
## Modular RAG Contract
|
||||
|
||||
Fixtures must use the current `lookupResult` shape, which mirrors the
|
||||
`lookup_knowledge` output:
|
||||
Maven equivalent:
|
||||
|
||||
```text
|
||||
lookupResult.evidenceBlocks
|
||||
lookupResult.contextPack
|
||||
lookupResult.retrievalTrace
|
||||
lookupResult.rerankTrace
|
||||
mvn -q -Dtest=RagLookupSnapshotGeneratorTest \
|
||||
-Drag.snapshot.enabled=true \
|
||||
-Dretrieval.kb-scope=rag-eval \
|
||||
-Dretrieval.search.mode=hybrid \
|
||||
test
|
||||
```
|
||||
|
||||
Golden cases can assert both retrieval quality and pipeline behavior:
|
||||
Generator is **off** in normal tests; only runs when `rag.snapshot.enabled=true` (writes files).
|
||||
|
||||
If generated fixtures fail offline golden checks: either fix retrieval/index, or update golden/baseline **with an explicit reason** — do not silently overwrite.
|
||||
|
||||
## Golden assertions
|
||||
|
||||
Supported expectation fields include:
|
||||
|
||||
- `expectedSources` / `expectedDocIds`
|
||||
- `expectedBreadcrumbs`
|
||||
- `expectedKeywords`
|
||||
- `expectedBreadcrumbs` / `expectedKeywords`
|
||||
- `expectedSelectedAttempt`
|
||||
- `expectedFallbackReason`
|
||||
- `expectedFallbackReasons`
|
||||
- `expectedFallbackReason` / `expectedFallbackReasons`
|
||||
- `expectedEvidenceStatus`
|
||||
- `expectedContextSources`
|
||||
- `expectedRerankTopSource`
|
||||
|
||||
This lets the baseline catch regressions such as losing the expected evidence
|
||||
source, skipping context packing, changing the selected retrieval attempt, or
|
||||
breaking the filtered-vector to unfiltered-retry fallback.
|
||||
Catch regressions such as missing expected source, broken context pack sources, wrong selected attempt, or broken filtered → unfiltered retry.
|
||||
|
||||
## Baseline Diff
|
||||
**Note:** `relevance_level` is not a hard golden gate here (hybrid quality is rank-ordinal; see agent relevance-level doc).
|
||||
|
||||
To compare a freshly generated report against an existing baseline:
|
||||
## Baseline diff
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_retrieval.py \
|
||||
@@ -170,64 +156,24 @@ python scripts/eval_rag_retrieval.py \
|
||||
--diff-markdown-report eval/rag-retrieval/reports/baseline-diff.md
|
||||
```
|
||||
|
||||
The diff reports aggregate regressions and case-level changes for:
|
||||
Diff covers pass rate, recall@K, hit level, first expected rank, attempt, fallback, evidence status, rerank top source.
|
||||
Non-zero exit on case failure or regression in diff mode.
|
||||
|
||||
- pass rate, recall@K, strong hit rate, miss count
|
||||
- pass state
|
||||
- hit level
|
||||
- first expected rank
|
||||
- selected attempt
|
||||
- fallback reason
|
||||
- evidence status
|
||||
- rerank top source
|
||||
## Hit levels
|
||||
|
||||
The command exits non-zero when a case fails or the diff contains a regression.
|
||||
- `strong`: expected document found **and** breadcrumb or keyword coverage OK
|
||||
- `medium`: expected document found, coverage incomplete
|
||||
- `weak`: keyword hit without expected document
|
||||
- `miss`: neither
|
||||
|
||||
## Hit Levels
|
||||
`Recall@K` counts `strong` + `medium`.
|
||||
|
||||
- `strong`: expected document is found and breadcrumb or evidence keyword coverage is satisfied.
|
||||
- `medium`: expected document is found, but breadcrumb or keyword coverage is incomplete.
|
||||
- `weak`: expected evidence keyword is found, but expected document is missing.
|
||||
- `miss`: expected document and expected evidence are not found.
|
||||
## Optional live smoke (post-reindex)
|
||||
|
||||
`Recall@K` counts `strong` and `medium` as retrieved.
|
||||
|
||||
## Scope
|
||||
|
||||
This baseline runs fully offline and does not call MySQL, Redis, Milvus, an LLM,
|
||||
or the Spring Boot application. It is a regression harness for retrieval behavior,
|
||||
not a claim that live production retrieval accuracy is complete.
|
||||
|
||||
## Live Post-Reindex Acceptance
|
||||
|
||||
When embedding input changes, existing vectors do not update by themselves. For
|
||||
example, after adding `title` and `breadcrumb` to the embedding text, the live
|
||||
Milvus/Zilliz collection must be reindexed before retrieval can reflect that new
|
||||
semantic signal.
|
||||
|
||||
Use this optional live acceptance flow after the application is running and the
|
||||
knowledge base has been reindexed:
|
||||
After reindex, with app up:
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_live_acceptance.py
|
||||
python scripts/eval_rag_live_acceptance.py --base-url http://127.0.0.1:9900
|
||||
```
|
||||
|
||||
Custom service URL and output paths are supported:
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_live_acceptance.py \
|
||||
--base-url http://127.0.0.1:9900 \
|
||||
--json-report eval/rag-retrieval/reports/live-post-reindex.json \
|
||||
--markdown-report eval/rag-retrieval/reports/live-post-reindex.md
|
||||
```
|
||||
|
||||
The script calls:
|
||||
|
||||
```text
|
||||
GET /api/search/similar
|
||||
```
|
||||
|
||||
It writes JSON and Markdown reports with query, topK, result count, top
|
||||
results, breadcrumb, score labels, and raw response fields. This is a live
|
||||
smoke check for environment readiness and post-reindex behavior; it does not
|
||||
replace the deterministic offline baseline above.
|
||||
Calls `GET /api/search/similar`. Environment smoke only — does **not** replace offline baseline.
|
||||
|
||||
@@ -1,81 +1,122 @@
|
||||
{
|
||||
"caseId": "aiops-payment-latency-alert",
|
||||
"query": "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "payment-service-latency",
|
||||
"title": "Payment Service Latency Alert Playbook",
|
||||
"breadcrumb": "AIOps > Service Alerts > Payment Latency",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "For payment-service p95 latency alerts, check downstream dependency latency, thread pool saturation, gateway retries, and recent deployment changes.",
|
||||
"score": 0.84,
|
||||
"hitReasons": ["domain_match:+0.15", "entity_match:+0.20", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "mysql-connection-pool",
|
||||
"title": "MySQL Connection Pool Troubleshooting",
|
||||
"breadcrumb": "Database > MySQL > Connection Pool",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "Database connection pool saturation can increase payment latency when checkout paths wait for connections.",
|
||||
"score": 0.68,
|
||||
"hitReasons": ["keyword_match:+0.10"]
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] Payment Service Latency Alert Playbook\nAIOps > Service Alerts > Payment Latency\nFor payment-service p95 latency alerts, check downstream dependency latency, thread pool saturation, gateway retries, and recent deployment changes.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 236,
|
||||
"includedSources": ["payment-service-latency", "mysql-connection-pool"],
|
||||
"omittedSources": []
|
||||
"caseId" : "aiops-payment-latency-alert",
|
||||
"query" : "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "payment-service-latency",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "payment-service-latency#chunk-2",
|
||||
"source" : "payment-service-latency",
|
||||
"title" : "Payment Latency",
|
||||
"breadcrumb" : "AIOps > Service Alerts > Payment Latency",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Payment Latency\n\nFor `HighLatency` alerts on `payment-service`, treat p95 latency as the primary symptom.\n\nDiagnosis steps:\n\n1. Confirm whether p95 latency is isolated to payment-service or shared across upstream callers.\n2. Compare payment-service latency with downstream dependency latency for gateway, risk, and order services.\n3. Check connection pool wait time, retry spikes, and timeout rates.\n4. If downstream dependency latency increased first, classify payment-service as affected rather than root cause.\n\nThe expected evidence terms are p95 latency, payment-service, and downstream dependency.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "aiops-alert-scope-control",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "aiops-alert-scope-control#chunk-1",
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"title" : "Alert Scope Control",
|
||||
"breadcrumb" : "AIOps > Alert Scope Control",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Alert Scope Control\n\nWhen an AIOps request already includes an alert payload, the agent should diagnose that payload first.\nIt must not expand the task into unrelated active alerts unless the user asks for broad alert triage.\n\nScope rules:\n\n1. Treat the provided payload as the primary incident boundary.\n2. Use unrelated active alerts only as correlation evidence when they share service, dependency, time window, or trace context.\n3. Do not replace the requested alert with a louder but unrelated alert.\n\nThis runbook anchors payload, unrelated active alerts, and scope behavior.",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "payment-service-latency",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "payment-service-latency#chunk-1",
|
||||
"source" : "payment-service-latency",
|
||||
"title" : "Service Alerts",
|
||||
"breadcrumb" : "AIOps > Service Alerts > Payment Latency",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Service Alerts",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:3", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "aiops-alert-scope-control",
|
||||
"chunkIndex" : 0,
|
||||
"evidenceKey" : "aiops-alert-scope-control#chunk-0",
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"title" : "AIOps",
|
||||
"breadcrumb" : "AIOps > Alert Scope Control",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "# AIOps",
|
||||
"score" : 0.015384615398943424,
|
||||
"hitReasons" : [ "semantic_rank:5", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: payment-service-latency\ntitle: Payment Latency\nbreadcrumb: AIOps > Service Alerts > Payment Latency\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n### Payment Latency\n\nFor `HighLatency` alerts on `payment-service`, treat p95 latency as the primary symptom.\n\nDiagnosis steps:\n\n1. Confirm whether p95 latency is isolated to payment-service or shared across upstream callers.\n2. Compare payment-service latency with downstream dependency latency for gateway, risk, and order services.\n3. Check connection pool wait time, retry spikes, and timeout rates.\n4. If downstream dependency latency increased first, classify payment-service as affected rather than root cause.\n\nThe expected evidence terms are p95 latency, payment-service, and downstream dependency.\n\n[Evidence 2]\nsource: aiops-alert-scope-control\ntitle: Alert Scope Control\nbreadcrumb: AIOps > Alert Scope Control\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## Alert Scope Control\n\nWhen an AIOps request already includes an alert payload, the agent should diagnose that payload first.\nIt must not expand the task into unrelated active alerts unless the user asks for broad alert triage.\n\nScope rules:\n\n1. Treat the provided payload as the primary incident boundary.\n2. Use unrelated active alerts only as correlation evidence when they share service, dependency, time window, or trace context.\n3. Do not replace the requested alert with a louder but unrelated alert.\n\nThis runbook anchors payload, unrelated active alerts, and scope behavior.\n\n[Evidence 3]\nsource: payment-service-latency\ntitle: Service Alerts\nbreadcrumb: AIOps > Service Alerts > Payment Latency\nlayer: L1\nreasons: semantic_rank:3, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n## Service Alerts\n\n[Evidence 4]\nsource: aiops-alert-scope-control\ntitle: AIOps\nbreadcrumb: AIOps > Alert Scope Control\nlayer: L1\nreasons: semantic_rank:5, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n# AIOps",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 2108,
|
||||
"includedSources" : [ "payment-service-latency", "aiops-alert-scope-control", "payment-service-latency", "aiops-alert-scope-control" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"rewrittenQuery": "HighLatency payment-service p95 latency alert downstream dependency diagnosis",
|
||||
"categoryFilter": "AIOps",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["AIOps"],
|
||||
"matched_keywords": ["p95 latency", "payment-service", "downstream dependency"],
|
||||
"entities": ["payment-service", "HighLatency"],
|
||||
"l0_titles": ["Payment Service Latency Alert Playbook"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"rewrittenQuery" : "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"categoryFilter" : "aiops",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "aiops" ],
|
||||
"matched_keywords" : [ "HighLatency", "payment-service", "p95 latency" ],
|
||||
"entities" : [ "HighLatency", "payment-service", "p95 latency" ],
|
||||
"l0_titles" : [ "Payment Service Latency Alert" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "HighLatency payment-service p95 latency alert downstream dependency diagnosis",
|
||||
"categoryFilter": "AIOps",
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 11,
|
||||
"topScore": 0.84,
|
||||
"topSimilarity": 0.84
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"categoryFilter" : "aiops",
|
||||
"candidateCount" : 5,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 969,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.7736010700464249
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "payment-service-latency",
|
||||
"baseScore": 0.84,
|
||||
"finalScore": 1.29,
|
||||
"boostReasons": ["domain_match:+0.15", "entity_match:+0.20", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "mysql-connection-pool",
|
||||
"baseScore": 0.68,
|
||||
"finalScore": 0.78,
|
||||
"boostReasons": ["keyword_match:+0.10"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "payment-service-latency",
|
||||
"baseScore" : 0.7736010700464249,
|
||||
"finalScore" : 0.7736010700464249,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"baseScore" : 0.4683566689491272,
|
||||
"finalScore" : 0.4683566689491272,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 3,
|
||||
"source" : "payment-service-latency",
|
||||
"baseScore" : 0.4981400966644287,
|
||||
"finalScore" : 0.4981400966644287,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 4,
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"baseScore" : 0.3814886808395386,
|
||||
"finalScore" : 0.3814886808395386,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 5,
|
||||
"evidenceBlockCount" : 4,
|
||||
"relevanceLevel" : "PRECISE",
|
||||
"completenessHint" : "知识库中不存在比上述结果更精准的文档",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,122 @@
|
||||
{
|
||||
"caseId": "aiops-prometheus-alert-scope",
|
||||
"query": "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "aiops-alert-scope-control",
|
||||
"title": "AIOps Alert Scope Control",
|
||||
"breadcrumb": "AIOps > Alert Scope Control",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "When payload mode is used, diagnose the input alert payload and do not expand unrelated active alerts into the main diagnosis scope.",
|
||||
"score": 0.88,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] AIOps Alert Scope Control\nAIOps > Alert Scope Control\nWhen payload mode is used, diagnose the input alert payload and do not expand unrelated active alerts into the main diagnosis scope.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 188,
|
||||
"includedSources": ["aiops-alert-scope-control"],
|
||||
"omittedSources": []
|
||||
"caseId" : "aiops-prometheus-alert-scope",
|
||||
"query" : "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "aiops-alert-scope-control",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "aiops-alert-scope-control#chunk-1",
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"title" : "Alert Scope Control",
|
||||
"breadcrumb" : "AIOps > Alert Scope Control",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Alert Scope Control\n\nWhen an AIOps request already includes an alert payload, the agent should diagnose that payload first.\nIt must not expand the task into unrelated active alerts unless the user asks for broad alert triage.\n\nScope rules:\n\n1. Treat the provided payload as the primary incident boundary.\n2. Use unrelated active alerts only as correlation evidence when they share service, dependency, time window, or trace context.\n3. Do not replace the requested alert with a louder but unrelated alert.\n\nThis runbook anchors payload, unrelated active alerts, and scope behavior.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "payment-service-latency",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "payment-service-latency#chunk-1",
|
||||
"source" : "payment-service-latency",
|
||||
"title" : "Service Alerts",
|
||||
"breadcrumb" : "AIOps > Service Alerts > Payment Latency",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Service Alerts",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "payment-service-latency",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "payment-service-latency#chunk-2",
|
||||
"source" : "payment-service-latency",
|
||||
"title" : "Payment Latency",
|
||||
"breadcrumb" : "AIOps > Service Alerts > Payment Latency",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Payment Latency\n\nFor `HighLatency` alerts on `payment-service`, treat p95 latency as the primary symptom.\n\nDiagnosis steps:\n\n1. Confirm whether p95 latency is isolated to payment-service or shared across upstream callers.\n2. Compare payment-service latency with downstream dependency latency for gateway, risk, and order services.\n3. Check connection pool wait time, retry spikes, and timeout rates.\n4. If downstream dependency latency increased first, classify payment-service as affected rather than root cause.\n\nThe expected evidence terms are p95 latency, payment-service, and downstream dependency.",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:3", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "aiops-alert-scope-control",
|
||||
"chunkIndex" : 0,
|
||||
"evidenceKey" : "aiops-alert-scope-control#chunk-0",
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"title" : "AIOps",
|
||||
"breadcrumb" : "AIOps > Alert Scope Control",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "# AIOps",
|
||||
"score" : 0.03076923079788685,
|
||||
"hitReasons" : [ "semantic_rank:5", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: aiops-alert-scope-control\ntitle: Alert Scope Control\nbreadcrumb: AIOps > Alert Scope Control\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n## Alert Scope Control\n\nWhen an AIOps request already includes an alert payload, the agent should diagnose that payload first.\nIt must not expand the task into unrelated active alerts unless the user asks for broad alert triage.\n\nScope rules:\n\n1. Treat the provided payload as the primary incident boundary.\n2. Use unrelated active alerts only as correlation evidence when they share service, dependency, time window, or trace context.\n3. Do not replace the requested alert with a louder but unrelated alert.\n\nThis runbook anchors payload, unrelated active alerts, and scope behavior.\n\n[Evidence 2]\nsource: payment-service-latency\ntitle: Service Alerts\nbreadcrumb: AIOps > Service Alerts > Payment Latency\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## Service Alerts\n\n[Evidence 3]\nsource: payment-service-latency\ntitle: Payment Latency\nbreadcrumb: AIOps > Service Alerts > Payment Latency\nlayer: L1\nreasons: semantic_rank:3, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n### Payment Latency\n\nFor `HighLatency` alerts on `payment-service`, treat p95 latency as the primary symptom.\n\nDiagnosis steps:\n\n1. Confirm whether p95 latency is isolated to payment-service or shared across upstream callers.\n2. Compare payment-service latency with downstream dependency latency for gateway, risk, and order services.\n3. Check connection pool wait time, retry spikes, and timeout rates.\n4. If downstream dependency latency increased first, classify payment-service as affected rather than root cause.\n\nThe expected evidence terms are p95 latency, payment-service, and downstream dependency.\n\n[Evidence 4]\nsource: aiops-alert-scope-control\ntitle: AIOps\nbreadcrumb: AIOps > Alert Scope Control\nlayer: L1\nreasons: semantic_rank:5, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n# AIOps",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 2069,
|
||||
"includedSources" : [ "aiops-alert-scope-control", "payment-service-latency", "payment-service-latency", "aiops-alert-scope-control" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"rewrittenQuery": "AIOps alert payload scope unrelated active alerts diagnosis",
|
||||
"categoryFilter": "AIOps",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["AIOps"],
|
||||
"matched_keywords": ["payload", "unrelated active alerts", "scope"],
|
||||
"entities": ["alert payload"],
|
||||
"l0_titles": ["AIOps Alert Scope Control"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"rewrittenQuery" : "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"categoryFilter" : "aiops",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "aiops" ],
|
||||
"matched_keywords" : [ "alert payload", "unrelated active alerts" ],
|
||||
"entities" : [ "alert payload", "unrelated active alerts" ],
|
||||
"l0_titles" : [ "AIOps Alert Scope Control" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "AIOps alert payload scope unrelated active alerts diagnosis",
|
||||
"categoryFilter": "AIOps",
|
||||
"candidateCount": 1,
|
||||
"usable": true,
|
||||
"durationMs": 8,
|
||||
"topScore": 0.88,
|
||||
"topSimilarity": 0.88
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"categoryFilter" : "aiops",
|
||||
"candidateCount" : 5,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 1623,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.7561411112546921
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "aiops-alert-scope-control",
|
||||
"baseScore": 0.88,
|
||||
"finalScore": 1.13,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"baseScore" : 0.7561411112546921,
|
||||
"finalScore" : 0.7561411112546921,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "payment-service-latency",
|
||||
"baseScore" : 0.503810703754425,
|
||||
"finalScore" : 0.503810703754425,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 3,
|
||||
"source" : "payment-service-latency",
|
||||
"baseScore" : 0.5772626996040344,
|
||||
"finalScore" : 0.5772626996040344,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 4,
|
||||
"source" : "aiops-alert-scope-control",
|
||||
"baseScore" : 0.36770421266555786,
|
||||
"finalScore" : 0.36770421266555786,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 5,
|
||||
"evidenceBlockCount" : 4,
|
||||
"relevanceLevel" : "PRECISE",
|
||||
"completenessHint" : "知识库中不存在比上述结果更精准的文档",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,88 @@
|
||||
{
|
||||
"caseId": "chat-diagnosis-flow",
|
||||
"query": "What is the standard troubleshooting flow for an application incident?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "incident-diagnosis-flow",
|
||||
"title": "Incident Diagnosis Flow",
|
||||
"breadcrumb": "AIOps > Diagnosis Flow",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "The standard flow is to collect evidence, identify the suspected fault domain, verify the hypothesis, apply remediation, and confirm recovery.",
|
||||
"score": 0.82,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "rag-chunk-context-reconstruction",
|
||||
"title": "RAG Chunk Context Reconstruction",
|
||||
"breadcrumb": "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "Long sections may require neighbor chunk expansion and breadcrumb-aware packing.",
|
||||
"score": 0.55,
|
||||
"hitReasons": []
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] Incident Diagnosis Flow\nAIOps > Diagnosis Flow\nThe standard flow is to collect evidence, identify the suspected fault domain, verify the hypothesis, apply remediation, and confirm recovery.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 192,
|
||||
"includedSources": ["incident-diagnosis-flow", "rag-chunk-context-reconstruction"],
|
||||
"omittedSources": []
|
||||
"caseId" : "chat-diagnosis-flow",
|
||||
"query" : "What is the standard troubleshooting flow for an application incident?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "incident-diagnosis-flow",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "incident-diagnosis-flow#chunk-1",
|
||||
"source" : "incident-diagnosis-flow",
|
||||
"title" : "Diagnosis Flow",
|
||||
"breadcrumb" : "AIOps > Diagnosis Flow",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Diagnosis Flow\n\nThe standard troubleshooting flow is evidence first, hypothesis second, remediation last.\n\nRecommended sequence:\n\n1. Collect evidence from alerts, metrics, logs, traces, deployments, and recent configuration changes.\n2. Define a small hypothesis that explains the observed symptoms.\n3. Verify the hypothesis with a targeted metric, log query, or reproduction step.\n4. Choose remediation that directly addresses the verified cause.\n5. Record the outcome and the evidence used to make the decision.\n\nDo not skip collect evidence, verify, and remediation ordering during an application incident.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "incident-diagnosis-flow",
|
||||
"chunkIndex" : 0,
|
||||
"evidenceKey" : "incident-diagnosis-flow#chunk-0",
|
||||
"source" : "incident-diagnosis-flow",
|
||||
"title" : "AIOps",
|
||||
"breadcrumb" : "AIOps > Diagnosis Flow",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "# AIOps",
|
||||
"score" : 0.016129031777381897,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: incident-diagnosis-flow\ntitle: Diagnosis Flow\nbreadcrumb: AIOps > Diagnosis Flow\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n## Diagnosis Flow\n\nThe standard troubleshooting flow is evidence first, hypothesis second, remediation last.\n\nRecommended sequence:\n\n1. Collect evidence from alerts, metrics, logs, traces, deployments, and recent configuration changes.\n2. Define a small hypothesis that explains the observed symptoms.\n3. Verify the hypothesis with a targeted metric, log query, or reproduction step.\n4. Choose remediation that directly addresses the verified cause.\n5. Record the outcome and the evidence used to make the decision.\n\nDo not skip collect evidence, verify, and remediation ordering during an application incident.\n\n[Evidence 2]\nsource: incident-diagnosis-flow\ntitle: AIOps\nbreadcrumb: AIOps > Diagnosis Flow\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n# AIOps",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 1032,
|
||||
"includedSources" : [ "incident-diagnosis-flow", "incident-diagnosis-flow" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "What is the standard troubleshooting flow for an application incident?",
|
||||
"rewrittenQuery": "standard application incident troubleshooting flow collect evidence verify remediation",
|
||||
"categoryFilter": "AIOps",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["AIOps"],
|
||||
"matched_keywords": ["collect evidence", "verify", "remediation"],
|
||||
"entities": ["application incident"],
|
||||
"l0_titles": ["Incident Diagnosis Flow"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "What is the standard troubleshooting flow for an application incident?",
|
||||
"rewrittenQuery" : "What is the standard troubleshooting flow for an application incident?",
|
||||
"categoryFilter" : "ops",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "ops" ],
|
||||
"matched_keywords" : [ "standard troubleshooting flow", "application incident" ],
|
||||
"entities" : [ "standard troubleshooting flow", "application incident" ],
|
||||
"l0_titles" : [ "Incident Diagnosis Flow" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "standard application incident troubleshooting flow collect evidence verify remediation",
|
||||
"categoryFilter": "AIOps",
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 10,
|
||||
"topScore": 0.82,
|
||||
"topSimilarity": 0.82
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "What is the standard troubleshooting flow for an application incident?",
|
||||
"categoryFilter" : "ops",
|
||||
"candidateCount" : 2,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 1540,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.6828859150409698
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "incident-diagnosis-flow",
|
||||
"baseScore": 0.82,
|
||||
"finalScore": 1.07,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "rag-chunk-context-reconstruction",
|
||||
"baseScore": 0.55,
|
||||
"finalScore": 0.55,
|
||||
"boostReasons": []
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "incident-diagnosis-flow",
|
||||
"baseScore" : 0.6828859150409698,
|
||||
"finalScore" : 0.6828859150409698,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "incident-diagnosis-flow",
|
||||
"baseScore" : 0.3166210651397705,
|
||||
"finalScore" : 0.3166210651397705,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 2,
|
||||
"evidenceBlockCount" : 2,
|
||||
"relevanceLevel" : "REFERENCE",
|
||||
"completenessHint" : "当前结果为相关参考,如需更精准信息请明确缺少的具体维度",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,122 @@
|
||||
{
|
||||
"caseId": "chat-l0-domain-hint",
|
||||
"query": "Should L0 keyword matching decide the final retrieval result?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "rag-l0-domain-entity-hint",
|
||||
"title": "RAG L0 Domain Entity Hint",
|
||||
"breadcrumb": "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "L0 should be retained as a domain detector, entity extractor, metadata filter generator, and explainability signal, not as the final retrieval decision.",
|
||||
"score": 0.88,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "rag-l0-l1-fusion-ranking",
|
||||
"title": "RAG L0 L1 Fusion Ranking",
|
||||
"breadcrumb": "RAG > Ranking > Fusion",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "L0 and L1 candidates should eventually be fused rather than handled as an early-return branch.",
|
||||
"score": 0.75,
|
||||
"hitReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] RAG L0 Domain Entity Hint\nRAG > L0 > Domain Entity Hint\nL0 should be retained as a domain detector, entity extractor, metadata filter generator, and explainability signal, not as the final retrieval decision.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 219,
|
||||
"includedSources": ["rag-l0-domain-entity-hint", "rag-l0-l1-fusion-ranking"],
|
||||
"omittedSources": []
|
||||
"caseId" : "chat-l0-domain-hint",
|
||||
"query" : "Should L0 keyword matching decide the final retrieval result?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-2",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "Domain Entity Hint",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-chunk-context-reconstruction",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-chunk-context-reconstruction#chunk-2",
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"title" : "Context Reconstruction",
|
||||
"breadcrumb" : "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.",
|
||||
"score" : 0.032258063554763794,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-1",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "L0",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## L0",
|
||||
"score" : 0.0317460335791111,
|
||||
"hitReasons" : [ "semantic_rank:3", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-chunk-context-reconstruction",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "rag-chunk-context-reconstruction#chunk-1",
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"title" : "Chunking",
|
||||
"breadcrumb" : "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Chunking",
|
||||
"score" : 0.015625,
|
||||
"hitReasons" : [ "semantic_rank:4", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: rag-l0-domain-entity-hint\ntitle: Domain Entity Hint\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.\n\n[Evidence 2]\nsource: rag-chunk-context-reconstruction\ntitle: Context Reconstruction\nbreadcrumb: RAG > Chunking > Context Reconstruction\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.\n\n[Evidence 3]\nsource: rag-l0-domain-entity-hint\ntitle: L0\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:3, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## L0\n\n[Evidence 4]\nsource: rag-chunk-context-reconstruction\ntitle: Chunking\nbreadcrumb: RAG > Chunking > Context Reconstruction\nlayer: L1\nreasons: semantic_rank:4, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## Chunking",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 1965,
|
||||
"includedSources" : [ "rag-l0-domain-entity-hint", "rag-chunk-context-reconstruction", "rag-l0-domain-entity-hint", "rag-chunk-context-reconstruction" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "Should L0 keyword matching decide the final retrieval result?",
|
||||
"rewrittenQuery": "RAG L0 keyword matching domain entity hint final retrieval decision",
|
||||
"categoryFilter": "RAG",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["RAG"],
|
||||
"matched_keywords": ["domain detector", "entity extractor", "metadata filter"],
|
||||
"entities": ["L0"],
|
||||
"l0_titles": ["RAG L0 Domain Entity Hint"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "Should L0 keyword matching decide the final retrieval result?",
|
||||
"rewrittenQuery" : "Should L0 keyword matching decide the final retrieval result?",
|
||||
"categoryFilter" : "rag",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "rag" ],
|
||||
"matched_keywords" : [ "L0 keyword matching", "final retrieval result" ],
|
||||
"entities" : [ "L0 keyword matching", "final retrieval result" ],
|
||||
"l0_titles" : [ "RAG L0 Domain Entity Hint" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "RAG L0 keyword matching domain entity hint final retrieval decision",
|
||||
"categoryFilter": "RAG",
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 9,
|
||||
"topScore": 0.88,
|
||||
"topSimilarity": 0.88
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "Should L0 keyword matching decide the final retrieval result?",
|
||||
"categoryFilter" : "rag",
|
||||
"candidateCount" : 6,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 850,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.6438122987747192
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "rag-l0-domain-entity-hint",
|
||||
"baseScore": 0.88,
|
||||
"finalScore": 1.13,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "rag-l0-l1-fusion-ranking",
|
||||
"baseScore": 0.75,
|
||||
"finalScore": 0.9,
|
||||
"boostReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.6438122987747192,
|
||||
"finalScore" : 0.6438122987747192,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"baseScore" : 0.41426247358322144,
|
||||
"finalScore" : 0.41426247358322144,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 3,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.3964804410934448,
|
||||
"finalScore" : 0.3964804410934448,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 4,
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"baseScore" : 0.28678786754608154,
|
||||
"finalScore" : 0.28678786754608154,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 6,
|
||||
"evidenceBlockCount" : 4,
|
||||
"relevanceLevel" : "REFERENCE",
|
||||
"completenessHint" : "当前结果为相关参考,如需更精准信息请明确缺少的具体维度",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,149 @@
|
||||
{
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"query": "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "rag-l0-filter-fallback",
|
||||
"title": "RAG L0 Filter Fallback",
|
||||
"breadcrumb": "RAG > Fallback > Unfiltered Retry",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "When filtered vector retrieval is low quality, skip the L0 filter and run an unfiltered vector retry with the raw query before returning no evidence.",
|
||||
"score": 0.83,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "rag-l0-domain-entity-hint",
|
||||
"title": "RAG L0 Domain Entity Hint",
|
||||
"breadcrumb": "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "L0 supplies hints for metadata filtering and explanation, but it should not be treated as final fact evidence.",
|
||||
"score": 0.66,
|
||||
"hitReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] RAG L0 Filter Fallback\nRAG > Fallback > Unfiltered Retry\nWhen filtered vector retrieval is low quality, skip the L0 filter and run an unfiltered vector retry with the raw query before returning no evidence.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 214,
|
||||
"includedSources": ["rag-l0-filter-fallback", "rag-l0-domain-entity-hint"],
|
||||
"omittedSources": []
|
||||
"caseId" : "chat-l0-filter-fallback",
|
||||
"query" : "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "rag-l0-filter-fallback",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-l0-filter-fallback#chunk-2",
|
||||
"source" : "rag-l0-filter-fallback",
|
||||
"title" : "Unfiltered Retry",
|
||||
"breadcrumb" : "RAG > Fallback > Unfiltered Retry",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Unfiltered Retry\n\nIf the first vector search is over-constrained by an L0 metadata filter and returns low quality evidence,\nthe retriever should skip the L0 filter and run an unfiltered vector retry with the original query.\n\nThe fallback reason should be `filtered_vector_low_quality` when the filtered candidate exists but is below the\nreference threshold. If there is no usable evidence at all, use `filtered_vector_no_evidence`.\n\nThis document is the expected evidence for skip the L0 filter, unfiltered vector retry, and low quality behavior.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:UNFILTERED_VECTOR_RETRY", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-filter-decoy",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "rag-l0-filter-decoy#chunk-1",
|
||||
"source" : "rag-l0-filter-decoy",
|
||||
"title" : "Approval Window",
|
||||
"breadcrumb" : "RAG > Fallback > Decoy",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Approval Window\n\nThis document describes an unrelated release calendar approval window.\nIt intentionally avoids the real fallback instructions so the filtered retrieval\nattempt is low quality and the retriever must retry without the L0 category filter.",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:UNFILTERED_VECTOR_RETRY", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-2",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "Domain Entity Hint",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.",
|
||||
"score" : 0.0320020467042923,
|
||||
"hitReasons" : [ "semantic_rank:3", "attempt:UNFILTERED_VECTOR_RETRY" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-1",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "L0",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## L0",
|
||||
"score" : 0.03125,
|
||||
"hitReasons" : [ "semantic_rank:4", "attempt:UNFILTERED_VECTOR_RETRY" ]
|
||||
}, {
|
||||
"docId" : "rag-chunk-context-reconstruction",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-chunk-context-reconstruction#chunk-2",
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"title" : "Context Reconstruction",
|
||||
"breadcrumb" : "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.",
|
||||
"score" : 0.03053613007068634,
|
||||
"hitReasons" : [ "semantic_rank:5", "attempt:UNFILTERED_VECTOR_RETRY" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: rag-l0-filter-fallback\ntitle: Unfiltered Retry\nbreadcrumb: RAG > Fallback > Unfiltered Retry\nlayer: L1\nreasons: semantic_rank:1, attempt:UNFILTERED_VECTOR_RETRY, l0_entity_overlap, l0_keyword_overlap\ncontent:\n### Unfiltered Retry\n\nIf the first vector search is over-constrained by an L0 metadata filter and returns low quality evidence,\nthe retriever should skip the L0 filter and run an unfiltered vector retry with the original query.\n\nThe fallback reason should be `filtered_vector_low_quality` when the filtered candidate exists but is below the\nreference threshold. If there is no usable evidence at all, use `filtered_vector_no_evidence`.\n\nThis document is the expected evidence for skip the L0 filter, unfiltered vector retry, and low quality behavior.\n\n[Evidence 2]\nsource: rag-l0-filter-decoy\ntitle: Approval Window\nbreadcrumb: RAG > Fallback > Decoy\nlayer: L1\nreasons: semantic_rank:2, attempt:UNFILTERED_VECTOR_RETRY, l0_domain_overlap\ncontent:\n## Approval Window\n\nThis document describes an unrelated release calendar approval window.\nIt intentionally avoids the real fallback instructions so the filtered retrieval\nattempt is low quality and the retriever must retry without the L0 category filter.\n\n[Evidence 3]\nsource: rag-l0-domain-entity-hint\ntitle: Domain Entity Hint\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:3, attempt:UNFILTERED_VECTOR_RETRY\ncontent:\n### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.\n\n[Evidence 4]\nsource: rag-l0-domain-entity-hint\ntitle: L0\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:4, attempt:UNFILTERED_VECTOR_RETRY\ncontent:\n## L0\n\n[Evidence 5]\nsource: rag-chunk-context-reconstruction\ntitle: Context Reconstruction\nbreadcrumb: RAG > Chunking > Context Reconstruction\nlayer: L1\nreasons: semantic_rank:5, attempt:UNFILTERED_VECTOR_RETRY\ncontent:\n### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 2904,
|
||||
"includedSources" : [ "rag-l0-filter-fallback", "rag-l0-filter-decoy", "rag-l0-domain-entity-hint", "rag-l0-domain-entity-hint", "rag-chunk-context-reconstruction" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"rewrittenQuery": "RAG L0 filtered vector low quality fallback unfiltered retry",
|
||||
"categoryFilter": "RAG",
|
||||
"selectedAttempt": "UNFILTERED_VECTOR_RETRY",
|
||||
"fallbackReason": "filtered_vector_low_quality",
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["RAG"],
|
||||
"matched_keywords": ["L0", "low quality", "unfiltered vector retry"],
|
||||
"entities": ["L0"],
|
||||
"l0_titles": ["RAG L0 Domain Entity Hint"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"rewrittenQuery" : "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"categoryFilter" : "overfilter-decoy",
|
||||
"selectedAttempt" : "UNFILTERED_VECTOR_RETRY",
|
||||
"fallbackReason" : "filtered_vector_low_quality",
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "overfilter-decoy" ],
|
||||
"matched_keywords" : [ "over-filtered by L0", "filtered vector search", "low quality evidence" ],
|
||||
"entities" : [ "over-filtered by L0", "filtered vector search", "low quality evidence" ],
|
||||
"l0_titles" : [ "RAG L0 Filter Decoy" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "RAG L0 filtered vector low quality fallback unfiltered retry",
|
||||
"categoryFilter": "RAG",
|
||||
"candidateCount": 1,
|
||||
"usable": false,
|
||||
"durationMs": 7,
|
||||
"topScore": 1.35,
|
||||
"topSimilarity": 0.325
|
||||
},
|
||||
{
|
||||
"name": "UNFILTERED_VECTOR_RETRY",
|
||||
"query": "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"categoryFilter": null,
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 13,
|
||||
"topScore": 0.83,
|
||||
"topSimilarity": 0.83
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"categoryFilter" : "overfilter-decoy",
|
||||
"candidateCount" : 2,
|
||||
"usable" : false,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 722,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.47391992807388306
|
||||
}, {
|
||||
"name" : "UNFILTERED_VECTOR_RETRY",
|
||||
"query" : "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"categoryFilter" : null,
|
||||
"candidateCount" : 20,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 2606,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.7571567445993423
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "rag-l0-filter-fallback",
|
||||
"baseScore": 0.83,
|
||||
"finalScore": 1.08,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "rag-l0-domain-entity-hint",
|
||||
"baseScore": 0.66,
|
||||
"finalScore": 0.81,
|
||||
"boostReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "rag-l0-filter-fallback",
|
||||
"baseScore" : 0.7571567445993423,
|
||||
"finalScore" : 0.7571567445993423,
|
||||
"boostReasons" : [ "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "rag-l0-filter-decoy",
|
||||
"baseScore" : 0.47391992807388306,
|
||||
"finalScore" : 0.47391992807388306,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 3,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.5912361443042755,
|
||||
"finalScore" : 0.5912361443042755,
|
||||
"boostReasons" : [ ]
|
||||
}, {
|
||||
"finalRank" : 4,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.473749577999115,
|
||||
"finalScore" : 0.473749577999115,
|
||||
"boostReasons" : [ ]
|
||||
}, {
|
||||
"finalRank" : 5,
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"baseScore" : 0.4492502808570862,
|
||||
"finalScore" : 0.4492502808570862,
|
||||
"boostReasons" : [ ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 20,
|
||||
"evidenceBlockCount" : 5,
|
||||
"relevanceLevel" : "PRECISE",
|
||||
"completenessHint" : "知识库中不存在比上述结果更精准的文档",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,88 @@
|
||||
{
|
||||
"caseId": "chat-mysql-connection-pool",
|
||||
"query": "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "mysql-connection-pool",
|
||||
"title": "MySQL Connection Pool Troubleshooting",
|
||||
"breadcrumb": "Database > MySQL > Connection Pool",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "When the connection pool is exhausted, inspect HikariCP active connections, max_connections, slow SQL, leak detection, and database wait events.",
|
||||
"score": 0.86,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "incident-diagnosis-flow",
|
||||
"title": "Incident Diagnosis Flow",
|
||||
"breadcrumb": "AIOps > Diagnosis Flow",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "Collect evidence, compare metrics and logs, then verify remediation before closing the incident.",
|
||||
"score": 0.61,
|
||||
"hitReasons": []
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] MySQL Connection Pool Troubleshooting\nDatabase > MySQL > Connection Pool\nWhen the connection pool is exhausted, inspect HikariCP active connections, max_connections, slow SQL, leak detection, and database wait events.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 216,
|
||||
"includedSources": ["mysql-connection-pool", "incident-diagnosis-flow"],
|
||||
"omittedSources": []
|
||||
"caseId" : "chat-mysql-connection-pool",
|
||||
"query" : "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "mysql-connection-pool",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "mysql-connection-pool#chunk-2",
|
||||
"source" : "mysql-connection-pool",
|
||||
"title" : "Connection Pool",
|
||||
"breadcrumb" : "Database > MySQL > Connection Pool",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Connection Pool\n\nWhen MySQL connection pool is exhausted, first compare application pool usage with database `max_connections`.\nFor HikariCP, check `active`, `idle`, `pending`, and connection acquisition timeout metrics.\n\nRecommended diagnosis:\n\n1. Verify whether HikariCP active connections stay near maximum while pending threads grow.\n2. Check MySQL `Threads_connected`, `Threads_running`, and `max_connections`.\n3. Inspect slow SQL and long transactions that keep connections checked out.\n4. If the database is healthy, look for application connection leaks or missing transaction boundaries.\n\nUse this runbook as evidence for connection pool, max_connections, and HikariCP incidents.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "mysql-connection-pool",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "mysql-connection-pool#chunk-1",
|
||||
"source" : "mysql-connection-pool",
|
||||
"title" : "MySQL",
|
||||
"breadcrumb" : "Database > MySQL > Connection Pool",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## MySQL",
|
||||
"score" : 0.032258063554763794,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: mysql-connection-pool\ntitle: Connection Pool\nbreadcrumb: Database > MySQL > Connection Pool\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n### Connection Pool\n\nWhen MySQL connection pool is exhausted, first compare application pool usage with database `max_connections`.\nFor HikariCP, check `active`, `idle`, `pending`, and connection acquisition timeout metrics.\n\nRecommended diagnosis:\n\n1. Verify whether HikariCP active connections stay near maximum while pending threads grow.\n2. Check MySQL `Threads_connected`, `Threads_running`, and `max_connections`.\n3. Inspect slow SQL and long transactions that keep connections checked out.\n4. If the database is healthy, look for application connection leaks or missing transaction boundaries.\n\nUse this runbook as evidence for connection pool, max_connections, and HikariCP incidents.\n\n[Evidence 2]\nsource: mysql-connection-pool\ntitle: MySQL\nbreadcrumb: Database > MySQL > Connection Pool\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## MySQL",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 1135,
|
||||
"includedSources" : [ "mysql-connection-pool", "mysql-connection-pool" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"rewrittenQuery": "MySQL connection pool exhausted HikariCP max_connections diagnosis",
|
||||
"categoryFilter": "Database",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["Database", "MySQL"],
|
||||
"matched_keywords": ["connection pool", "HikariCP", "max_connections"],
|
||||
"entities": ["MySQL", "HikariCP"],
|
||||
"l0_titles": ["MySQL Connection Pool Troubleshooting"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"rewrittenQuery" : "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"categoryFilter" : "database",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "database" ],
|
||||
"matched_keywords" : [ "MySQL connection pool" ],
|
||||
"entities" : [ "MySQL connection pool" ],
|
||||
"l0_titles" : [ "MySQL Connection Pool Runbook" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "MySQL connection pool exhausted HikariCP max_connections diagnosis",
|
||||
"categoryFilter": "Database",
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 12,
|
||||
"topScore": 0.86,
|
||||
"topSimilarity": 0.86
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"categoryFilter" : "database",
|
||||
"candidateCount" : 3,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 5267,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.8114794194698334
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "mysql-connection-pool",
|
||||
"baseScore": 0.86,
|
||||
"finalScore": 1.11,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "incident-diagnosis-flow",
|
||||
"baseScore": 0.61,
|
||||
"finalScore": 0.61,
|
||||
"boostReasons": []
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "mysql-connection-pool",
|
||||
"baseScore" : 0.8114794194698334,
|
||||
"finalScore" : 0.8114794194698334,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "mysql-connection-pool",
|
||||
"baseScore" : 0.49219560623168945,
|
||||
"finalScore" : 0.49219560623168945,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 3,
|
||||
"evidenceBlockCount" : 2,
|
||||
"relevanceLevel" : "PRECISE",
|
||||
"completenessHint" : "知识库中不存在比上述结果更精准的文档",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,122 @@
|
||||
{
|
||||
"caseId": "chat-rag-chunk-context",
|
||||
"query": "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"retrievedAt": "2026-07-06T00:00:00Z",
|
||||
"lookupResult": {
|
||||
"found": true,
|
||||
"evidenceBlocks": [
|
||||
{
|
||||
"source": "rag-chunk-context-reconstruction",
|
||||
"title": "RAG Chunk Context Reconstruction",
|
||||
"breadcrumb": "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "After a chunk hit, expand to neighbor chunk candidates from the same section and preserve breadcrumb metadata in the evidence pack.",
|
||||
"score": 0.79,
|
||||
"hitReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"source": "rag-breadcrumb-embedding-gap",
|
||||
"title": "RAG Breadcrumb Embedding Gap",
|
||||
"breadcrumb": "RAG > Embedding > Breadcrumb",
|
||||
"retrievalLayer": "L1",
|
||||
"content": "Embedding title and breadcrumb with content helps recover section semantics.",
|
||||
"score": 0.72,
|
||||
"hitReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
],
|
||||
"contextPack": {
|
||||
"packedText": "[1] RAG Chunk Context Reconstruction\nRAG > Chunking > Context Reconstruction\nAfter a chunk hit, expand to neighbor chunk candidates from the same section and preserve breadcrumb metadata in the evidence pack.",
|
||||
"strategy": "top_evidence_blocks",
|
||||
"charBudget": 3500,
|
||||
"usedChars": 203,
|
||||
"includedSources": ["rag-chunk-context-reconstruction", "rag-breadcrumb-embedding-gap"],
|
||||
"omittedSources": []
|
||||
"caseId" : "chat-rag-chunk-context",
|
||||
"query" : "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"retrievedAt" : "2026-07-28T06:54:51.843450400Z",
|
||||
"searchMode" : "hybrid",
|
||||
"kbScope" : "rag-eval",
|
||||
"lookupResult" : {
|
||||
"found" : true,
|
||||
"evidenceBlocks" : [ {
|
||||
"docId" : "rag-chunk-context-reconstruction",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-chunk-context-reconstruction#chunk-2",
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"title" : "Context Reconstruction",
|
||||
"breadcrumb" : "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.",
|
||||
"score" : 0.032786883413791656,
|
||||
"hitReasons" : [ "semantic_rank:1", "attempt:FILTERED_VECTOR", "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 2,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-2",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "Domain Entity Hint",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.",
|
||||
"score" : 0.032258063554763794,
|
||||
"hitReasons" : [ "semantic_rank:2", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-chunk-context-reconstruction",
|
||||
"chunkIndex" : 1,
|
||||
"evidenceKey" : "rag-chunk-context-reconstruction#chunk-1",
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"title" : "Chunking",
|
||||
"breadcrumb" : "RAG > Chunking > Context Reconstruction",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "## Chunking",
|
||||
"score" : 0.01587301678955555,
|
||||
"hitReasons" : [ "semantic_rank:3", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
}, {
|
||||
"docId" : "rag-l0-domain-entity-hint",
|
||||
"chunkIndex" : 0,
|
||||
"evidenceKey" : "rag-l0-domain-entity-hint#chunk-0",
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"title" : "RAG",
|
||||
"breadcrumb" : "RAG > L0 > Domain Entity Hint",
|
||||
"retrievalLayer" : "L1",
|
||||
"content" : "# RAG",
|
||||
"score" : 0.015625,
|
||||
"hitReasons" : [ "semantic_rank:4", "attempt:FILTERED_VECTOR", "l0_domain_overlap" ]
|
||||
} ],
|
||||
"contextPack" : {
|
||||
"packedText" : "[Evidence 1]\nsource: rag-chunk-context-reconstruction\ntitle: Context Reconstruction\nbreadcrumb: RAG > Chunking > Context Reconstruction\nlayer: L1\nreasons: semantic_rank:1, attempt:FILTERED_VECTOR, l0_domain_overlap, l0_entity_overlap, l0_keyword_overlap\ncontent:\n### Context Reconstruction\n\nWhen a long section is split into multiple chunks, retrieval should keep enough local structure for the answer.\n\nRecommended behavior:\n\n1. Store the breadcrumb with every chunk.\n2. Preserve the same section identity across adjacent chunks.\n3. During context packing, include a neighbor chunk when the selected chunk depends on nearby setup or definitions.\n4. Prefer concise evidence blocks that show the breadcrumb and the relevant content span.\n\nThe key concepts are neighbor chunk, same section, and breadcrumb.\n\n[Evidence 2]\nsource: rag-l0-domain-entity-hint\ntitle: Domain Entity Hint\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:2, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n### Domain Entity Hint\n\nL0 keyword matching should not decide the final retrieval result.\nIn the modular RAG pipeline, L0 behaves like a lightweight domain detector and entity extractor.\n\nThe output can provide:\n\n1. Candidate domain hints.\n2. Matched entities and keywords.\n3. An optional metadata filter for the first vector retrieval attempt.\n\nFinal evidence still comes from L1 vector retrieval, post-retrieval normalization, rerank, and context packing.\nThe important terms are domain detector, entity extractor, and metadata filter.\n\n[Evidence 3]\nsource: rag-chunk-context-reconstruction\ntitle: Chunking\nbreadcrumb: RAG > Chunking > Context Reconstruction\nlayer: L1\nreasons: semantic_rank:3, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n## Chunking\n\n[Evidence 4]\nsource: rag-l0-domain-entity-hint\ntitle: RAG\nbreadcrumb: RAG > L0 > Domain Entity Hint\nlayer: L1\nreasons: semantic_rank:4, attempt:FILTERED_VECTOR, l0_domain_overlap\ncontent:\n# RAG",
|
||||
"strategy" : "ranked_evidence_char_budget",
|
||||
"charBudget" : 4000,
|
||||
"usedChars" : 1966,
|
||||
"includedSources" : [ "rag-chunk-context-reconstruction", "rag-l0-domain-entity-hint", "rag-chunk-context-reconstruction", "rag-l0-domain-entity-hint" ],
|
||||
"omittedSources" : [ ]
|
||||
},
|
||||
"retrievalTrace": {
|
||||
"originalQuery": "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"rewrittenQuery": "RAG chunk context reconstruction neighbor chunk same section breadcrumb",
|
||||
"categoryFilter": "RAG",
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"queryHints": {
|
||||
"domains": ["RAG"],
|
||||
"matched_keywords": ["neighbor chunk", "same section", "breadcrumb"],
|
||||
"entities": ["chunk", "breadcrumb"],
|
||||
"l0_titles": ["RAG Chunk Context Reconstruction"],
|
||||
"l0_match_count": 1
|
||||
"retrievalTrace" : {
|
||||
"originalQuery" : "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"rewrittenQuery" : "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"categoryFilter" : "rag",
|
||||
"selectedAttempt" : "FILTERED_VECTOR",
|
||||
"fallbackReason" : null,
|
||||
"evidenceStatus" : "supported",
|
||||
"queryHints" : {
|
||||
"domains" : [ "rag" ],
|
||||
"matched_keywords" : [ "split into multiple chunks", "retrieval context" ],
|
||||
"entities" : [ "split into multiple chunks", "retrieval context" ],
|
||||
"l0_titles" : [ "RAG Chunk Context Reconstruction" ],
|
||||
"l0_match_count" : 1
|
||||
},
|
||||
"attempts": [
|
||||
{
|
||||
"name": "FILTERED_VECTOR",
|
||||
"query": "RAG chunk context reconstruction neighbor chunk same section breadcrumb",
|
||||
"categoryFilter": "RAG",
|
||||
"candidateCount": 2,
|
||||
"usable": true,
|
||||
"durationMs": 9,
|
||||
"topScore": 0.79,
|
||||
"topSimilarity": 0.79
|
||||
}
|
||||
]
|
||||
"attempts" : [ {
|
||||
"name" : "FILTERED_VECTOR",
|
||||
"query" : "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"categoryFilter" : "rag",
|
||||
"candidateCount" : 6,
|
||||
"usable" : true,
|
||||
"errorMessage" : null,
|
||||
"durationMs" : 743,
|
||||
"topScore" : 0.032786883413791656,
|
||||
"topSimilarity" : 0.7487991750240326
|
||||
} ]
|
||||
},
|
||||
"rerankTrace": {
|
||||
"items": [
|
||||
{
|
||||
"finalRank": 1,
|
||||
"source": "rag-chunk-context-reconstruction",
|
||||
"baseScore": 0.79,
|
||||
"finalScore": 1.04,
|
||||
"boostReasons": ["domain_match:+0.15", "keyword_match:+0.10"]
|
||||
},
|
||||
{
|
||||
"finalRank": 2,
|
||||
"source": "rag-breadcrumb-embedding-gap",
|
||||
"baseScore": 0.72,
|
||||
"finalScore": 0.87,
|
||||
"boostReasons": ["domain_match:+0.15"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"rerankTrace" : {
|
||||
"items" : [ {
|
||||
"finalRank" : 1,
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"baseScore" : 0.7487991750240326,
|
||||
"finalScore" : 0.7487991750240326,
|
||||
"boostReasons" : [ "l0_domain_overlap", "l0_entity_overlap", "l0_keyword_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 2,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.4840593934059143,
|
||||
"finalScore" : 0.4840593934059143,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 3,
|
||||
"source" : "rag-chunk-context-reconstruction",
|
||||
"baseScore" : 0.42978107929229736,
|
||||
"finalScore" : 0.42978107929229736,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
}, {
|
||||
"finalRank" : 4,
|
||||
"source" : "rag-l0-domain-entity-hint",
|
||||
"baseScore" : 0.3859822154045105,
|
||||
"finalScore" : 0.3859822154045105,
|
||||
"boostReasons" : [ "l0_domain_overlap" ]
|
||||
} ]
|
||||
},
|
||||
"evidenceCandidateCount" : 6,
|
||||
"evidenceBlockCount" : 4,
|
||||
"relevanceLevel" : "REFERENCE",
|
||||
"completenessHint" : "当前结果为相关参考,如需更精准信息请明确缺少的具体维度",
|
||||
"retrievedDomainsThisSession" : null,
|
||||
"message" : null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"generatedAt": "2026-07-28T06:48:56.439554+00:00",
|
||||
"baselineReport": "eval/rag-retrieval/cases/golden-cases.json",
|
||||
"currentReport": "eval/rag-retrieval/cases/golden-cases.json",
|
||||
"baselineCaseCount": 7,
|
||||
"currentCaseCount": 7,
|
||||
"baselinePassRate": 1.0,
|
||||
"currentPassRate": 0.8571,
|
||||
"baselineRecallAtK": 1.0,
|
||||
"currentRecallAtK": 0.8571,
|
||||
"regressionCount": 6,
|
||||
"improvementCount": 0,
|
||||
"changedCount": 3,
|
||||
"hasRegression": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "aggregate",
|
||||
"caseId": null,
|
||||
"metric": "passRate",
|
||||
"baselineValue": "1.0",
|
||||
"currentValue": "0.8571",
|
||||
"delta": -0.14290000000000003,
|
||||
"message": "aggregate passRate changed"
|
||||
},
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "aggregate",
|
||||
"caseId": null,
|
||||
"metric": "recallAtK",
|
||||
"baselineValue": "1.0",
|
||||
"currentValue": "0.8571",
|
||||
"delta": -0.14290000000000003,
|
||||
"message": "aggregate recallAtK changed"
|
||||
},
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "aggregate",
|
||||
"caseId": null,
|
||||
"metric": "strongHitRate",
|
||||
"baselineValue": "1.0",
|
||||
"currentValue": "0.8571",
|
||||
"delta": -0.14290000000000003,
|
||||
"message": "aggregate strongHitRate changed"
|
||||
},
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "passed",
|
||||
"baselineValue": "True",
|
||||
"currentValue": "False",
|
||||
"delta": -1.0,
|
||||
"message": "chat-l0-filter-fallback passed changed"
|
||||
},
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "hitLevel",
|
||||
"baselineValue": "strong",
|
||||
"currentValue": "weak",
|
||||
"delta": -2.0,
|
||||
"message": "chat-l0-filter-fallback hitLevel changed"
|
||||
},
|
||||
{
|
||||
"type": "REGRESSION",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "firstExpectedRank",
|
||||
"baselineValue": "1",
|
||||
"currentValue": "-",
|
||||
"delta": null,
|
||||
"message": "chat-l0-filter-fallback firstExpectedRank changed"
|
||||
},
|
||||
{
|
||||
"type": "CHANGED",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "selectedAttempt",
|
||||
"baselineValue": "UNFILTERED_VECTOR_RETRY",
|
||||
"currentValue": "FILTERED_VECTOR",
|
||||
"delta": null,
|
||||
"message": "chat-l0-filter-fallback selectedAttempt changed"
|
||||
},
|
||||
{
|
||||
"type": "CHANGED",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "fallbackReason",
|
||||
"baselineValue": "filtered_vector_low_quality",
|
||||
"currentValue": "-",
|
||||
"delta": null,
|
||||
"message": "chat-l0-filter-fallback fallbackReason changed"
|
||||
},
|
||||
{
|
||||
"type": "CHANGED",
|
||||
"scope": "case",
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"metric": "rerankTopSource",
|
||||
"baselineValue": "rag-l0-filter-fallback",
|
||||
"currentValue": "rag-l0-filter-decoy",
|
||||
"delta": null,
|
||||
"message": "chat-l0-filter-fallback rerankTopSource changed"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# RAG Retrieval Baseline Diff
|
||||
|
||||
Generated at: `2026-07-28T06:48:56.439554+00:00`
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Baseline cases | 7 |
|
||||
| Current cases | 7 |
|
||||
| Baseline pass rate | 1.0 |
|
||||
| Current pass rate | 0.8571 |
|
||||
| Baseline recall@K | 1.0 |
|
||||
| Current recall@K | 0.8571 |
|
||||
| Regressions | 6 |
|
||||
| Improvements | 0 |
|
||||
| Changed | 3 |
|
||||
|
||||
## Items
|
||||
|
||||
| Type | Scope | Case | Metric | Baseline | Current | Delta | Message |
|
||||
|---|---|---|---|---|---|---:|---|
|
||||
| REGRESSION | aggregate | | passRate | 1.0 | 0.8571 | -0.14290000000000003 | aggregate passRate changed |
|
||||
| REGRESSION | aggregate | | recallAtK | 1.0 | 0.8571 | -0.14290000000000003 | aggregate recallAtK changed |
|
||||
| REGRESSION | aggregate | | strongHitRate | 1.0 | 0.8571 | -0.14290000000000003 | aggregate strongHitRate changed |
|
||||
| REGRESSION | case | chat-l0-filter-fallback | passed | True | False | -1.0 | chat-l0-filter-fallback passed changed |
|
||||
| REGRESSION | case | chat-l0-filter-fallback | hitLevel | strong | weak | -2.0 | chat-l0-filter-fallback hitLevel changed |
|
||||
| REGRESSION | case | chat-l0-filter-fallback | firstExpectedRank | 1 | - | | chat-l0-filter-fallback firstExpectedRank changed |
|
||||
| CHANGED | case | chat-l0-filter-fallback | selectedAttempt | UNFILTERED_VECTOR_RETRY | FILTERED_VECTOR | | chat-l0-filter-fallback selectedAttempt changed |
|
||||
| CHANGED | case | chat-l0-filter-fallback | fallbackReason | filtered_vector_low_quality | - | | chat-l0-filter-fallback fallbackReason changed |
|
||||
| CHANGED | case | chat-l0-filter-fallback | rerankTopSource | rag-l0-filter-fallback | rag-l0-filter-decoy | | chat-l0-filter-fallback rerankTopSource changed |
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-07-06T13:37:59.726351+00:00",
|
||||
"generatedAt": "2026-07-28T06:55:09.379164+00:00",
|
||||
"caseFile": "eval/rag-retrieval/cases/golden-cases.json",
|
||||
"fixtureDir": "eval/rag-retrieval/fixtures",
|
||||
"aggregate": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:mysql-connection-pool",
|
||||
"2:incident-diagnosis-flow"
|
||||
"2:mysql-connection-pool"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"connection pool",
|
||||
@@ -41,7 +41,7 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"mysql-connection-pool",
|
||||
"incident-diagnosis-flow"
|
||||
"mysql-connection-pool"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "mysql-connection-pool",
|
||||
@@ -57,7 +57,7 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:incident-diagnosis-flow",
|
||||
"2:rag-chunk-context-reconstruction"
|
||||
"2:incident-diagnosis-flow"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"collect evidence",
|
||||
@@ -70,7 +70,7 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"incident-diagnosis-flow",
|
||||
"rag-chunk-context-reconstruction"
|
||||
"incident-diagnosis-flow"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "incident-diagnosis-flow",
|
||||
@@ -86,7 +86,9 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:payment-service-latency",
|
||||
"2:mysql-connection-pool"
|
||||
"2:aiops-alert-scope-control",
|
||||
"3:payment-service-latency",
|
||||
"4:aiops-alert-scope-control"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"p95 latency",
|
||||
@@ -99,7 +101,9 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"payment-service-latency",
|
||||
"mysql-connection-pool"
|
||||
"aiops-alert-scope-control",
|
||||
"payment-service-latency",
|
||||
"aiops-alert-scope-control"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "payment-service-latency",
|
||||
@@ -114,7 +118,10 @@
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:aiops-alert-scope-control"
|
||||
"1:aiops-alert-scope-control",
|
||||
"2:payment-service-latency",
|
||||
"3:payment-service-latency",
|
||||
"4:aiops-alert-scope-control"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"payload",
|
||||
@@ -126,6 +133,9 @@
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"aiops-alert-scope-control",
|
||||
"payment-service-latency",
|
||||
"payment-service-latency",
|
||||
"aiops-alert-scope-control"
|
||||
],
|
||||
"omittedSources": [],
|
||||
@@ -142,7 +152,9 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:rag-chunk-context-reconstruction",
|
||||
"2:rag-breadcrumb-embedding-gap"
|
||||
"2:rag-l0-domain-entity-hint",
|
||||
"3:rag-chunk-context-reconstruction",
|
||||
"4:rag-l0-domain-entity-hint"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"neighbor chunk",
|
||||
@@ -155,7 +167,9 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-breadcrumb-embedding-gap"
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-l0-domain-entity-hint"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-chunk-context-reconstruction",
|
||||
@@ -171,7 +185,9 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:rag-l0-domain-entity-hint",
|
||||
"2:rag-l0-l1-fusion-ranking"
|
||||
"2:rag-chunk-context-reconstruction",
|
||||
"3:rag-l0-domain-entity-hint",
|
||||
"4:rag-chunk-context-reconstruction"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"domain detector",
|
||||
@@ -184,7 +200,9 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-l0-l1-fusion-ranking"
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-l0-domain-entity-hint",
|
||||
@@ -200,7 +218,10 @@
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:rag-l0-filter-fallback",
|
||||
"2:rag-l0-domain-entity-hint"
|
||||
"2:rag-l0-filter-decoy",
|
||||
"3:rag-l0-domain-entity-hint",
|
||||
"4:rag-l0-domain-entity-hint",
|
||||
"5:rag-chunk-context-reconstruction"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"skip the l0 filter",
|
||||
@@ -213,7 +234,10 @@
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-l0-filter-fallback",
|
||||
"rag-l0-domain-entity-hint"
|
||||
"rag-l0-filter-decoy",
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-l0-filter-fallback",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RAG Retrieval Baseline
|
||||
|
||||
Generated at: `2026-07-06T13:37:59.726351+00:00`
|
||||
Generated at: `2026-07-28T06:55:09.379164+00:00`
|
||||
|
||||
## Aggregate
|
||||
|
||||
@@ -24,10 +24,10 @@ Generated at: `2026-07-06T13:37:59.726351+00:00`
|
||||
|
||||
| Case | Scenario | Pass | Hit | Attempt | Fallback | Evidence | First Expected Rank | Top Candidates | Failed Checks |
|
||||
|---|---|---|---|---|---|---|---:|---|---|
|
||||
| chat-mysql-connection-pool | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:mysql-connection-pool<br>2:incident-diagnosis-flow | |
|
||||
| chat-diagnosis-flow | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:incident-diagnosis-flow<br>2:rag-chunk-context-reconstruction | |
|
||||
| aiops-payment-latency-alert | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:payment-service-latency<br>2:mysql-connection-pool | |
|
||||
| aiops-prometheus-alert-scope | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:aiops-alert-scope-control | |
|
||||
| chat-rag-chunk-context | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-chunk-context-reconstruction<br>2:rag-breadcrumb-embedding-gap | |
|
||||
| chat-l0-domain-hint | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-l0-domain-entity-hint<br>2:rag-l0-l1-fusion-ranking | |
|
||||
| chat-l0-filter-fallback | chat | true | strong | UNFILTERED_VECTOR_RETRY | filtered_vector_low_quality | supported | 1 | 1:rag-l0-filter-fallback<br>2:rag-l0-domain-entity-hint | |
|
||||
| chat-mysql-connection-pool | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:mysql-connection-pool<br>2:mysql-connection-pool | |
|
||||
| chat-diagnosis-flow | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:incident-diagnosis-flow<br>2:incident-diagnosis-flow | |
|
||||
| aiops-payment-latency-alert | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:payment-service-latency<br>2:aiops-alert-scope-control<br>3:payment-service-latency<br>4:aiops-alert-scope-control | |
|
||||
| aiops-prometheus-alert-scope | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:aiops-alert-scope-control<br>2:payment-service-latency<br>3:payment-service-latency<br>4:aiops-alert-scope-control | |
|
||||
| chat-rag-chunk-context | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-chunk-context-reconstruction<br>2:rag-l0-domain-entity-hint<br>3:rag-chunk-context-reconstruction<br>4:rag-l0-domain-entity-hint | |
|
||||
| chat-l0-domain-hint | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-l0-domain-entity-hint<br>2:rag-chunk-context-reconstruction<br>3:rag-l0-domain-entity-hint<br>4:rag-chunk-context-reconstruction | |
|
||||
| chat-l0-filter-fallback | chat | true | strong | UNFILTERED_VECTOR_RETRY | filtered_vector_low_quality | supported | 1 | 1:rag-l0-filter-fallback<br>2:rag-l0-filter-decoy<br>3:rag-l0-domain-entity-hint<br>4:rag-l0-domain-entity-hint<br>5:rag-chunk-context-reconstruction | |
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
{
|
||||
"generatedAt": "2026-07-28T06:48:56.421877+00:00",
|
||||
"caseFile": "eval/rag-retrieval/cases/golden-cases.json",
|
||||
"fixtureDir": "eval/rag-retrieval/fixtures",
|
||||
"aggregate": {
|
||||
"caseCount": 7,
|
||||
"topK": 5,
|
||||
"passedCount": 6,
|
||||
"failedCount": 1,
|
||||
"passRate": 0.8571,
|
||||
"lookupResultCaseCount": 7,
|
||||
"strongHitCount": 6,
|
||||
"mediumHitCount": 0,
|
||||
"weakHitCount": 1,
|
||||
"missCount": 0,
|
||||
"recallAtK": 0.8571,
|
||||
"strongHitRate": 0.8571,
|
||||
"averageFirstHitRank": 1.0
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"caseId": "chat-mysql-connection-pool",
|
||||
"scenario": "chat",
|
||||
"query": "MySQL connection pool is exhausted. How should I diagnose it?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:mysql-connection-pool",
|
||||
"2:mysql-connection-pool"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"connection pool",
|
||||
"max_connections",
|
||||
"hikaricp"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"mysql-connection-pool",
|
||||
"mysql-connection-pool"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "mysql-connection-pool",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "chat-diagnosis-flow",
|
||||
"scenario": "chat",
|
||||
"query": "What is the standard troubleshooting flow for an application incident?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:incident-diagnosis-flow",
|
||||
"2:incident-diagnosis-flow"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"collect evidence",
|
||||
"verify",
|
||||
"remediation"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"incident-diagnosis-flow",
|
||||
"incident-diagnosis-flow"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "incident-diagnosis-flow",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "aiops-payment-latency-alert",
|
||||
"scenario": "aiops",
|
||||
"query": "Alert HighLatency on payment-service with p95 latency above threshold",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:payment-service-latency",
|
||||
"2:aiops-alert-scope-control",
|
||||
"3:payment-service-latency",
|
||||
"4:aiops-alert-scope-control"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"p95 latency",
|
||||
"payment-service",
|
||||
"downstream dependency"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"payment-service-latency",
|
||||
"aiops-alert-scope-control",
|
||||
"payment-service-latency",
|
||||
"aiops-alert-scope-control"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "payment-service-latency",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "aiops-prometheus-alert-scope",
|
||||
"scenario": "aiops",
|
||||
"query": "When an AIOps request already includes alert payload, should the agent diagnose unrelated active alerts?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:aiops-alert-scope-control",
|
||||
"2:payment-service-latency",
|
||||
"3:payment-service-latency",
|
||||
"4:aiops-alert-scope-control"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"payload",
|
||||
"unrelated active alerts",
|
||||
"scope"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"aiops-alert-scope-control",
|
||||
"payment-service-latency",
|
||||
"payment-service-latency",
|
||||
"aiops-alert-scope-control"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "aiops-alert-scope-control",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "chat-rag-chunk-context",
|
||||
"scenario": "chat",
|
||||
"query": "If a long section is split into multiple chunks, how do we keep retrieval context?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:rag-chunk-context-reconstruction",
|
||||
"2:rag-l0-domain-entity-hint",
|
||||
"3:rag-chunk-context-reconstruction",
|
||||
"4:rag-l0-domain-entity-hint"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"neighbor chunk",
|
||||
"same section",
|
||||
"breadcrumb"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-l0-domain-entity-hint"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-chunk-context-reconstruction",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "chat-l0-domain-hint",
|
||||
"scenario": "chat",
|
||||
"query": "Should L0 keyword matching decide the final retrieval result?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "strong",
|
||||
"passed": true,
|
||||
"firstExpectedRank": 1,
|
||||
"topCandidates": [
|
||||
"1:rag-l0-domain-entity-hint",
|
||||
"2:rag-chunk-context-reconstruction",
|
||||
"3:rag-l0-domain-entity-hint",
|
||||
"4:rag-chunk-context-reconstruction"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"domain detector",
|
||||
"entity extractor",
|
||||
"metadata filter"
|
||||
],
|
||||
"breadcrumbMatched": true,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction",
|
||||
"rag-l0-domain-entity-hint",
|
||||
"rag-chunk-context-reconstruction"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-l0-domain-entity-hint",
|
||||
"failedChecks": []
|
||||
},
|
||||
{
|
||||
"caseId": "chat-l0-filter-fallback",
|
||||
"scenario": "chat",
|
||||
"query": "RAG query was over-filtered by L0 and filtered vector search returned low quality evidence. What should happen?",
|
||||
"dataShape": "lookupResult",
|
||||
"hitLevel": "weak",
|
||||
"passed": false,
|
||||
"firstExpectedRank": null,
|
||||
"topCandidates": [
|
||||
"1:rag-l0-filter-decoy",
|
||||
"2:rag-l0-filter-decoy"
|
||||
],
|
||||
"matchedKeywords": [
|
||||
"low quality"
|
||||
],
|
||||
"breadcrumbMatched": false,
|
||||
"selectedAttempt": "FILTERED_VECTOR",
|
||||
"fallbackReason": null,
|
||||
"evidenceStatus": "supported",
|
||||
"includedSources": [
|
||||
"rag-l0-filter-decoy",
|
||||
"rag-l0-filter-decoy"
|
||||
],
|
||||
"omittedSources": [],
|
||||
"rerankTopSource": "rag-l0-filter-decoy",
|
||||
"failedChecks": [
|
||||
"expected document not found",
|
||||
"selected attempt mismatch: expected UNFILTERED_VECTOR_RETRY, got FILTERED_VECTOR",
|
||||
"fallback reason mismatch: expected one of [filtered_vector_low_quality, filtered_vector_no_evidence], got <none>",
|
||||
"rerank top source mismatch: expected rag-l0-filter-fallback, got rag-l0-filter-decoy",
|
||||
"expected context sources missing: rag-l0-filter-fallback"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# RAG Retrieval Baseline
|
||||
|
||||
Generated at: `2026-07-28T06:48:56.421877+00:00`
|
||||
|
||||
## Aggregate
|
||||
|
||||
| Metric | Value |
|
||||
|---|---:|
|
||||
| Cases | 7 |
|
||||
| Top K | 5 |
|
||||
| Passed | 6 |
|
||||
| Failed | 1 |
|
||||
| Pass rate | 0.8571 |
|
||||
| LookupResult fixtures | 7 |
|
||||
| Recall@K | 0.8571 |
|
||||
| Strong hit rate | 0.8571 |
|
||||
| Strong hits | 6 |
|
||||
| Medium hits | 0 |
|
||||
| Weak hits | 1 |
|
||||
| Misses | 0 |
|
||||
| Average first hit rank | 1.0 |
|
||||
|
||||
## Cases
|
||||
|
||||
| Case | Scenario | Pass | Hit | Attempt | Fallback | Evidence | First Expected Rank | Top Candidates | Failed Checks |
|
||||
|---|---|---|---|---|---|---|---:|---|---|
|
||||
| chat-mysql-connection-pool | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:mysql-connection-pool<br>2:mysql-connection-pool | |
|
||||
| chat-diagnosis-flow | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:incident-diagnosis-flow<br>2:incident-diagnosis-flow | |
|
||||
| aiops-payment-latency-alert | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:payment-service-latency<br>2:aiops-alert-scope-control<br>3:payment-service-latency<br>4:aiops-alert-scope-control | |
|
||||
| aiops-prometheus-alert-scope | aiops | true | strong | FILTERED_VECTOR | | supported | 1 | 1:aiops-alert-scope-control<br>2:payment-service-latency<br>3:payment-service-latency<br>4:aiops-alert-scope-control | |
|
||||
| chat-rag-chunk-context | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-chunk-context-reconstruction<br>2:rag-l0-domain-entity-hint<br>3:rag-chunk-context-reconstruction<br>4:rag-l0-domain-entity-hint | |
|
||||
| chat-l0-domain-hint | chat | true | strong | FILTERED_VECTOR | | supported | 1 | 1:rag-l0-domain-entity-hint<br>2:rag-chunk-context-reconstruction<br>3:rag-l0-domain-entity-hint<br>4:rag-chunk-context-reconstruction | |
|
||||
| chat-l0-filter-fallback | chat | false | weak | FILTERED_VECTOR | | supported | | 1:rag-l0-filter-decoy<br>2:rag-l0-filter-decoy | expected document not found<br>selected attempt mismatch: expected UNFILTERED_VECTOR_RETRY, got FILTERED_VECTOR<br>fallback reason mismatch: expected one of [filtered_vector_low_quality, filtered_vector_no_evidence], got <none><br>rerank top source mismatch: expected rag-l0-filter-fallback, got rag-l0-filter-decoy<br>expected context sources missing: rag-l0-filter-fallback |
|
||||
+11
-30
@@ -1,60 +1,41 @@
|
||||
# SuperBizAgent MVP 文档
|
||||
|
||||
**更新日期**:2026-07-23
|
||||
**更新日期**:2026-07-29
|
||||
|
||||
本目录保存 MVP 阶段的架构、问题、演示、评测和数据表说明。当前材料按“当前入口”和“历史归档”拆开,避免把早期设计稿当成当前实现。
|
||||
本目录保存 MVP 阶段的架构、工程纪要、问题、演示、评测和数据表说明。当前材料按“当前入口”和“历史归档”拆开,避免把早期设计稿当成当前实现。
|
||||
|
||||
## 当前入口
|
||||
|
||||
| 目录/文档 | 用途 |
|
||||
|---|---|
|
||||
| [architecture/README.md](architecture/README.md) | 当前 MVP 架构入口 |
|
||||
| [architecture/README.md](architecture/README.md) | **现行架构**(系统现在怎么跑) |
|
||||
| [engineering/README.md](engineering/README.md) | **工程纪要**(问题 / 决策 / 思路 / E2E 导读) |
|
||||
| [architecture/current-mvp-architecture.md](architecture/current-mvp-architecture.md) | 当前可运行系统架构 |
|
||||
| [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 |
|
||||
| [architecture/harness-quality-gates.md](architecture/harness-quality-gates.md) | Harness 与质量门禁 |
|
||||
| [architecture/session-trace-lifecycle.md](architecture/session-trace-lifecycle.md) | 会话与 Trace 生命周期 |
|
||||
| [architecture/RAG知识检索架构.md](architecture/RAG知识检索架构.md) | 当前检索架构(py-rag 知识服务接入) |
|
||||
| [issues/README.md](issues/README.md) | MVP issue 索引 |
|
||||
| [tables/README.md](tables/README.md) | 当前 MySQL 表说明 |
|
||||
| [demo/README.md](demo/README.md) | Demo 运行和演示材料 |
|
||||
| [eval/README.md](eval/README.md) | 诊断评测材料 |
|
||||
|
||||
当前架构、运行链路和后续规划分别以 `architecture/`、`issues/README.md`、`tables/README.md` 以及 OpenSpec/devflow 的最新记录为准。
|
||||
**读法**:改行为先看 `architecture/`;要理解「为什么这样定、踩过什么坑」再看 `engineering/`。
|
||||
早期个人学习笔记仍在仓库根目录 `docs/learning/` 等,**可能过时**,不以之为现行口径。
|
||||
|
||||
## 文档结构
|
||||
|
||||
```text
|
||||
mvp/
|
||||
architecture/
|
||||
architecture/ # 现行架构规范
|
||||
engineering/ # 工程纪要(问题/决策/E2E)
|
||||
README.md
|
||||
current-mvp-architecture.md
|
||||
agent-orchestration.md
|
||||
harness-quality-gates.md
|
||||
session-trace-lifecycle.md
|
||||
archive/
|
||||
2026-07-05-legacy/
|
||||
2026-07-22-legacy/
|
||||
issues/
|
||||
README.md
|
||||
active/
|
||||
archived/
|
||||
design-notes/
|
||||
rag/
|
||||
diagnosis/
|
||||
issues/
|
||||
tables/
|
||||
README.md
|
||||
*表-*.md
|
||||
archive/
|
||||
demo/
|
||||
README.md
|
||||
ten-minute-interview-demo.md
|
||||
requests/
|
||||
scripts/
|
||||
output/
|
||||
eval/
|
||||
README.md
|
||||
schema.md
|
||||
cases/
|
||||
fixtures/
|
||||
reports/
|
||||
archive/
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# RAG 检索可观测性、审计与 Trace(现行)
|
||||
|
||||
**更新日期**: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. 三层边界
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph A["A. 请求内 Trace"]
|
||||
LR[LookupResult<br/>retrievalTrace / rerankTrace / relevanceLevel]
|
||||
end
|
||||
|
||||
subgraph B["B. 持久化审计 + Trace API"]
|
||||
TI[tool_invocation 表]
|
||||
DT[diagnosis_trace 事件摘要]
|
||||
API["GET /api/diagnosis/{sessionId}/trace"]
|
||||
end
|
||||
|
||||
subgraph C["C. 质量回归"]
|
||||
EV[eval/rag-retrieval offline baseline]
|
||||
end
|
||||
|
||||
subgraph agent["Agent 可见(非审计)"]
|
||||
RT[RagToolResult<br/>evidence + optional relevance_level]
|
||||
end
|
||||
|
||||
LK[LookupKnowledgeTool] --> LR
|
||||
LR --> PROJ[RagResultProjector]
|
||||
PROJ --> RT
|
||||
LR --> BOUND[ToolBoundary audit]
|
||||
BOUND --> TI
|
||||
BOUND --> DT
|
||||
TI --> API
|
||||
DT --> API
|
||||
EV -.->|不替代运行时 Trace| LK
|
||||
```
|
||||
|
||||
| 层 | 完善度 | 说明 |
|
||||
|----|--------|------|
|
||||
| A 请求内 | 高 | attempt / fallback / quality 齐全 |
|
||||
| B 持久化 + Trace API | 中高 | RAG 富字段入 `tool_invocation`,经 Trace API 回放 |
|
||||
| C 离线 eval | 高 | hybrid fixtures 回归 |
|
||||
|
||||
**Agent 看到的不是完整 Trace。** 完整检索轨迹在 A/B;Agent 只拿投影后的证据契约。
|
||||
|
||||
---
|
||||
|
||||
## 2. 端到端:从 lookup 到 Trace API
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent
|
||||
participant Adapter as RagToolAdapter
|
||||
participant Bound as ToolBoundary
|
||||
participant Tool as LookupKnowledgeTool
|
||||
participant Sink as JpaToolInvocationAuditSink
|
||||
participant DB as tool_invocation
|
||||
participant Trace as DiagnosisTraceService
|
||||
participant API as GET .../trace
|
||||
|
||||
Agent->>Adapter: lookup_knowledge(query)
|
||||
Adapter->>Bound: execute(legacy, projector)
|
||||
Bound->>Tool: execute(query)
|
||||
Tool-->>Bound: raw LookupResult JSON
|
||||
Note over Tool: 内含 retrievalTrace / rerankTrace / evidenceBlocks
|
||||
Bound->>Bound: project → RagToolResult
|
||||
Bound->>Sink: AuditEvent + rawResultJson + agentResultJson
|
||||
Sink->>Sink: RagLookupAuditEnricher
|
||||
Sink->>DB: 富字段行
|
||||
Bound-->>Agent: 投影后 agent_result(无完整 trace)
|
||||
|
||||
API->>Trace: sessionId + optional runId
|
||||
Trace->>DB: find tool_invocation by run/session
|
||||
Trace-->>API: DiagnosisTraceResponse.toolInvocations[]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. A 层:请求内 Trace(`LookupResult`)
|
||||
|
||||
一次成功的 `lookup_knowledge` 内部出口是 **`LookupResult`**(比 Agent 契约更富)。
|
||||
|
||||
### 3.1 结构总览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
LR[LookupResult]
|
||||
LR --> F[found]
|
||||
LR --> EB[evidenceBlocks[]]
|
||||
LR --> CP[contextPack]
|
||||
LR --> RT[retrievalTrace]
|
||||
LR --> RR[rerankTrace]
|
||||
LR --> RL[relevanceLevel]
|
||||
LR --> CH[completenessHint]
|
||||
LR --> CNT[evidenceCandidateCount / evidenceBlockCount]
|
||||
|
||||
RT --> ATT[attempts[]]
|
||||
RT --> SEL[selectedAttempt]
|
||||
RT --> FB[fallbackReason]
|
||||
RT --> HINT[queryHints L0]
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `found` | 是否有可用证据块 |
|
||||
| `evidenceBlocks` | 后处理后的 chunk 级证据(含 evidenceKey、source、content…) |
|
||||
| `contextPack` | 字符预算打包文本(内部/审计用) |
|
||||
| `retrievalTrace` | **检索路径 Trace**(见下) |
|
||||
| `rerankTrace` | 后处理排序/quality 痕迹(现多为保序后的 quality) |
|
||||
| `relevanceLevel` | PRECISE / REFERENCE / null |
|
||||
| `completenessHint` | 给模型的天花板提示文案 |
|
||||
|
||||
### 3.2 `retrievalTrace`(检索路径)
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `originalQuery` | 原始查询 |
|
||||
| `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=0) |
|
||||
| `attempts[]` | 每次检索尝试快照 |
|
||||
|
||||
**`selectedAttempt` 取值:**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `UNFILTERED_VECTOR` | **当前唯一会出现**:无 category,直传 py-rag 检索 |
|
||||
| `FILTERED_VECTOR` | (历史)带 category 的首次检索即采用;L0 下沉后不再产生 |
|
||||
| `UNFILTERED_VECTOR_RETRY` | (历史)filtered 低质/无证据后去掉 category 重试;分支保留但不可达,仅见于旧 Run 回放 |
|
||||
|
||||
**单次 `attempts[]` 元素:**
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `name` | attempt 名 |
|
||||
| `query` | 该次实际检索句 |
|
||||
| `categoryFilter` | 该次 filter |
|
||||
| `candidateCount` | 召回候选数 |
|
||||
| `usable` | 后处理阈值后是否可用 |
|
||||
| `topScore` / `topSimilarity` | 引擎分 / 归一化 quality(0~1) |
|
||||
| `durationMs` | 耗时 |
|
||||
| `errorMessage` | 失败时 |
|
||||
|
||||
### 3.3 一次典型路径(当前:单 attempt 直传)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
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
|
||||
flowchart LR
|
||||
LR[LookupResult 全量 Trace] --> PROJ[RagResultProjector]
|
||||
PROJ --> AG[RagToolResult]
|
||||
AG --> F1[evidence_status]
|
||||
AG --> F2[evidence excerpt]
|
||||
AG --> F3[relevance_level 可选]
|
||||
AG --> F4[truncated / returned_count]
|
||||
|
||||
LR -.->|不投影| X1[retrievalTrace]
|
||||
LR -.->|不投影| X2[rerankTrace]
|
||||
LR -.->|不投影| X3[raw scores / contextPack 全文]
|
||||
```
|
||||
|
||||
人/系统要「为什么这样检索」→ 看 **A 全量** 或 **B 落库摘要**,不要只看 Agent 字段。
|
||||
|
||||
---
|
||||
|
||||
## 4. B 层:持久化 + Trace API
|
||||
|
||||
### 4.1 写入路径
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `ToolBoundary` | 执行后发 `ToolInvocationAuditEvent`(含 raw LookupResult JSON + agent JSON) |
|
||||
| `RagLookupAuditEnricher` | 从 LookupResult 抽有界 RAG 字段 |
|
||||
| `JpaToolInvocationAuditSink` | 写入 `tool_invocation` |
|
||||
| `TraceAuditEvents.toolInvocation` | 另写一条 diagnosis_trace 摘要事件(不含全文 LookupResult) |
|
||||
|
||||
### 4.2 `tool_invocation` 列(RAG)
|
||||
|
||||
| 列 | lookup_knowledge | 其它工具 |
|
||||
|----|------------------|----------|
|
||||
| `tool_name` | `lookup_knowledge` | 各自工具名 |
|
||||
| `retrieval_layer` | 通常 `L1` | `HARNESS` |
|
||||
| `relevance_level` | **PRECISE / REFERENCE / …** | **null**(不再写 evidence_status) |
|
||||
| `l0_match_count` | queryHints | null |
|
||||
| `l1_match_count` | evidence 块数等 | null |
|
||||
| `is_truncated` | 投影 truncated | false |
|
||||
| `retrieval_details` | JSON `rag_lookup_v1` | 通用 status 元数据 |
|
||||
| `output_preview` | level/attempt 摘要 | status=… |
|
||||
| `duration_ms` / `success` | 有 | 有 |
|
||||
|
||||
### 4.3 `retrieval_details`(rag_lookup_v1)示例(当前形态)
|
||||
|
||||
```json
|
||||
{
|
||||
"audit_schema": "rag_lookup_v1",
|
||||
"search_mode": "hybrid",
|
||||
"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": [], "matched_keywords": [] },
|
||||
"attempts": [
|
||||
{
|
||||
"name": "UNFILTERED_VECTOR",
|
||||
"candidate_count": 5,
|
||||
"usable": true,
|
||||
"top_similarity": 0.91,
|
||||
"duration_ms": 640
|
||||
}
|
||||
],
|
||||
"truncated": false,
|
||||
"returned_count": 2,
|
||||
"evidence_status": "EVIDENCE_FOUND",
|
||||
"invocation_status": "READY",
|
||||
"tool_call_id": "call-…"
|
||||
}
|
||||
```
|
||||
|
||||
**默认不落库:** 原始 query 全文、chunk 正文 excerpt、完整 rerankTrace(体积与隐私)。
|
||||
历史 Run 中 `selected_attempt=FILTERED_VECTOR` / `UNFILTERED_VECTOR_RETRY` 与非空 `l0_hints` 为 L0 下沉前的旧数据形态。
|
||||
|
||||
### 4.4 Trace API:人怎么读 RAG
|
||||
|
||||
**接口:**
|
||||
|
||||
```http
|
||||
GET /api/diagnosis/{sessionId}/trace
|
||||
GET /api/diagnosis/{sessionId}/trace?runId={runId}
|
||||
```
|
||||
|
||||
**实现:** `DiagnosisTraceController` → `DiagnosisTraceService.getTrace`
|
||||
按 `sessionId`(可选精确 `runId`)拉 run、steps、**toolInvocations**、摘要等。
|
||||
|
||||
**响应中与 RAG 相关的核心块:** `DiagnosisTraceResponse.toolInvocations[]`
|
||||
|
||||
| API 字段 | 来源列 | 读法 |
|
||||
|----------|--------|------|
|
||||
| `toolName` | `tool_name` | 是否为 `lookup_knowledge` |
|
||||
| `retrievalLayer` | `retrieval_layer` | L1 / HARNESS |
|
||||
| `relevanceLevel` | `relevance_level` | RAG 粗相关度(非 evidence_status) |
|
||||
| `l0MatchCount` / `l1MatchCount` | 同名列 | L0/L1 规模提示 |
|
||||
| `truncated` | `is_truncated` | 证据是否被投影截断 |
|
||||
| `outputPreview` | `output_preview` | 一行摘要(level/attempt…) |
|
||||
| `retrievalDetails` | 解析自 `retrieval_details` | **RAG Trace 主阵地** |
|
||||
| `retrievalDetailsRaw` | 原始 JSON 字符串 | 调试 |
|
||||
| `durationMs` / `success` / `errorMessage` | 同名列 | 耗时与成败 |
|
||||
| `inputParams` | 通常仅 tool_call_id、request_bytes | **不含完整 query**(有意) |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
API["GET /api/diagnosis/{sessionId}/trace"] --> SVC[DiagnosisTraceService]
|
||||
SVC --> ROW[tool_invocation 行]
|
||||
ROW --> T1[列: relevanceLevel, L0/L1 count, layer…]
|
||||
ROW --> T2[retrievalDetails Map]
|
||||
T2 --> D1[search_mode]
|
||||
T2 --> D2[selected_attempt / fallback_reason]
|
||||
T2 --> D3[attempts[] / evidence_keys]
|
||||
T2 --> D4[evidence_status 契约状态]
|
||||
```
|
||||
|
||||
### 4.5 读 Trace 的推荐顺序(排查「这次知识库怎么检的」)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S1[找到 toolName=lookup_knowledge 的 invocation] --> S2{success?}
|
||||
S2 -->|否| E[看 errorMessage / evidence_status]
|
||||
S2 -->|是| S3[看 retrievalDetails.search_mode]
|
||||
S3 --> S4[看 selected_attempt + fallback_reason]
|
||||
S4 --> S5[看 attempts[] 每次 candidate_count / top_similarity / usable]
|
||||
S5 --> S6[看 evidence_keys / sources]
|
||||
S6 --> S7[看 relevanceLevel 列]
|
||||
S7 --> S8[需要原文?看 Agent 侧 evidence 或当时 canonical 存储 · 审计默认无 excerpt]
|
||||
```
|
||||
|
||||
| 现象 | 优先看 |
|
||||
|------|--------|
|
||||
| 是否 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 行
|
||||
|
||||
| 通道 | 内容 | 用途 |
|
||||
|------|------|------|
|
||||
| `tool_invocation` 行 | RAG 富字段完整摘要 | **主审计/回放** |
|
||||
| `diagnosis_trace` 中 `TOOL_INVOCATION` | tool_call_id、status、字节数、`has_raw_result` 等薄摘要 | 时间线事件,**不含**完整 retrieval_details |
|
||||
|
||||
查 RAG 细节以 **`toolInvocations[].retrievalDetails`** 为准。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与 Agent / Eval 的边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph human["人 / 运维 / 评测"]
|
||||
TRACE[Trace API]
|
||||
EVAL[Offline eval]
|
||||
end
|
||||
|
||||
subgraph model["模型"]
|
||||
AGENT[RagToolResult only]
|
||||
end
|
||||
|
||||
TI[(tool_invocation)] --> TRACE
|
||||
FX[fixtures] --> EVAL
|
||||
PROJ[Projector] --> AGENT
|
||||
```
|
||||
|
||||
| 消费者 | 能看到 |
|
||||
|--------|--------|
|
||||
| Agent | evidence + 可选 relevance_level,无 attempt 细节 |
|
||||
| Trace API | 落库摘要:mode/attempt/fallback/keys/level… |
|
||||
| Offline eval | 冻结 fixture 全量 LookupResult(含 trace),与 golden 比对 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与旧文档差异
|
||||
|
||||
| 旧(archive `retrieval-observability`) | 现 |
|
||||
|----------------------------------------|-----|
|
||||
| `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 直传 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 代码锚点
|
||||
|
||||
| 职责 | 类 / 路径 |
|
||||
|------|-----------|
|
||||
| 内建 Trace | `LookupKnowledgeTool`、`RetrievalTrace`、`LookupResult` |
|
||||
| 投影 | `RagResultProjector`、`RagToolResult` |
|
||||
| 审计事件 | `ToolInvocationAuditEvent`、`ToolBoundary` |
|
||||
| 富化 | `RagLookupAuditEnricher` |
|
||||
| 落库 | `JpaToolInvocationAuditSink`、`ToolInvocation` |
|
||||
| Trace API | `DiagnosisTraceController`、`DiagnosisTraceService`、`DiagnosisTraceResponse.ToolInvocationTrace` |
|
||||
| 离线回归 | `eval/rag-retrieval/`、`scripts/eval_rag_retrieval.py` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 已知限制
|
||||
|
||||
- 持久化 **不存** 完整 query/excerpt(有意);要正文需 Agent 侧证据或其它存储
|
||||
- `dedup_reason` 列可能仍为空
|
||||
- 非 `lookup_knowledge` 工具仍为薄审计
|
||||
- **历史** `tool_invocation` 行可能仍把 evidence_status 写进 `relevance_level`(旧 sink)
|
||||
- `diagnosis_trace` 时间线事件不替代 `retrieval_details`
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| `mvp/architecture/RAG知识检索架构.md` | 检索主架构 |
|
||||
| `../engineering/rag/RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
|
||||
| `../engineering/rag/RAG-Hybrid质量分与后处理.md` | quality / 排序闸门 |
|
||||
| `../engineering/rag/RAG离线评测-基线设计.md` | 离线评测(非运行时 Trace) |
|
||||
| `mvp/architecture/session-trace-lifecycle.md` | 会话/run Trace 总览(若存在) |
|
||||
@@ -0,0 +1,236 @@
|
||||
# RAG 知识检索架构
|
||||
|
||||
**更新日期**:2026-09-29
|
||||
**状态**:当前可运行架构
|
||||
**关联实现**:`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
|
||||
-> lookup_knowledge(query)
|
||||
-> Harness ToolBoundary / ACI projection
|
||||
-> EvidenceGuard 只认当前 Run 的 READY canonical 证据
|
||||
```
|
||||
|
||||
目标:
|
||||
|
||||
- 保留 Agent 可见的工具调用与证据边界
|
||||
- 检索/入库基础设施外置为独立服务,Java 侧不感知引擎细节
|
||||
- 用 chunk 级证据身份保证同文档多片段可同时进入上下文
|
||||
- 检索行为可配置、可重建、可审计
|
||||
|
||||
## 2. 稳定边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph AgentBoundary["Agent boundary"]
|
||||
Agent["Diagnosis Agent"]
|
||||
Tool["lookup_knowledge"]
|
||||
end
|
||||
|
||||
subgraph HarnessBoundary["Harness boundary"]
|
||||
Adapter["RagToolAdapter"]
|
||||
Projector["RagResultProjector"]
|
||||
Canonical["Redis canonical invocation"]
|
||||
end
|
||||
|
||||
subgraph RetrievalBoundary["Retrieval boundary"]
|
||||
Backend["LookupKnowledgeTool"]
|
||||
Port["KnowledgeSearchPort"]
|
||||
Remote["PyRagKnowledgeSearchAdapter"]
|
||||
Service["py-rag 知识服务 (HTTP /api/v1)"]
|
||||
end
|
||||
|
||||
Agent --> Tool
|
||||
Tool --> Adapter
|
||||
Adapter --> Backend
|
||||
Backend --> Port
|
||||
Port --> Remote
|
||||
Remote -->|HTTP| Service
|
||||
Adapter --> Projector
|
||||
Adapter --> Canonical
|
||||
```
|
||||
|
||||
| 边界 | 职责 | 不负责 |
|
||||
|---|---|---|
|
||||
| Agent | 决定何时检索、如何用证据写报告 | 不直接访问 py-rag / MySQL 元数据表 |
|
||||
| Harness | Tool 校验、投影裁剪、canonical 存证 | 不改写检索排序算法 |
|
||||
| Retrieval | 请求映射、后处理、打包;py-rag 承担召回/融合/rerank/判级 | 不绕过 ACI 直接给 Agent 原始库响应 |
|
||||
|
||||
## 3. 当前主链路
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
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` | 串联 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 字段 |
|
||||
|
||||
2026-09-29 抽离时删除的 Java 侧组件:`MilvusHybridKnowledgeStore`、`VectorSearchService`、
|
||||
`VectorKnowledgeSearchAdapter`、`RrfFusion` / `LexicalRanker`(融合评分下沉)、
|
||||
`KnowledgeIndexService` / `KnowledgeDomainService`(L0 索引)、
|
||||
`DocumentChunkService` / `FrontmatterParser` / `TextExtractorService`(入库解析)、
|
||||
`KnowledgeQueryTransformer`(L0 query 理解)。
|
||||
|
||||
## 4. py-rag 检索契约映射
|
||||
|
||||
请求映射(`PyRagKnowledgeSearchAdapter`):
|
||||
|
||||
| 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 部署配置决定 |
|
||||
|
||||
响应映射:
|
||||
|
||||
| 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
|
||||
业务上传(DocumentController /api/documents/upload)
|
||||
-> MySQL api_document(业务元数据:faultSource 等)+ 本地原件保存
|
||||
-> PyRagClient.ingest(multipart 透传原件 + category)
|
||||
-> py-rag:解析 / frontmatter 校验 / 分块 / 向量化 / 索引
|
||||
|
||||
简单上传(FileUploadController /api/upload)
|
||||
-> 本地保存 + PyRagClient.ingest(category 可选参数,缺省 default;入库失败不影响上传成功语义)
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
- 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`
|
||||
|
||||
## 7. 证据身份与去重(不变)
|
||||
|
||||
```text
|
||||
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 据此验真
|
||||
|
||||
## 8. Agent 可见契约(不变)
|
||||
|
||||
Agent 只看到有界 `RagToolResult`:
|
||||
|
||||
- `evidence_status` / `tool_call_id` / `query`
|
||||
- `evidence[]`:`document_id` / `source` / `title` / `breadcrumb` / `excerpt`
|
||||
- `relevance_level`(PRECISE / REFERENCE;`RagRelevanceLevel.HIGHLY_RELEVANT` 为保留档)
|
||||
- `truncated` / `returned_count`
|
||||
|
||||
不暴露:raw score、retrievalTrace / rerankTrace、contextPack 全文、py-rag 地址与凭据。
|
||||
完整内部结果仍在 `LookupResult` 中,供审计与调试使用。Trace / 审计读法见
|
||||
[RAG检索可观测性与审计.md](./RAG检索可观测性与审计.md)。
|
||||
|
||||
## 9. L0 下沉
|
||||
|
||||
L0 query 理解(domain/keyword hint → 可选 categoryFilter)随本次抽离**整体下沉 py-rag**:
|
||||
|
||||
- `KnowledgeQueryTransformer` / `KnowledgeIndexService.analyzeQuery` 已删除
|
||||
- `lookup_knowledge` 直传原始 query;`categoryFilter` 恒为 null,走 UNFILTERED 单 attempt
|
||||
- `LookupKnowledgeTool` 中 filtered→unfiltered 降级分支保留但不可达(作为未来 Java 侧过滤策略的兜底骨架)
|
||||
- `RetrievalTrace.queryHints` 恒为空结构;`attempt` 命名只剩 `UNFILTERED_VECTOR`
|
||||
|
||||
## 10. 与旧文档的差异
|
||||
|
||||
| 旧描述(2026-07-28 版) | 当前实现(2026-09-29 抽离后) |
|
||||
|---|---|
|
||||
| 进程内 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`(Milvus 前身)
|
||||
- `mvp/engineering/rag/Milvus-Hybrid接入清单.md`(进程内 Milvus hybrid 接入纪要,已过时)
|
||||
- 本仓库 git 历史:`refactor/extract-rag-module` 分支,71 文件 / 约 -8000 行
|
||||
|
||||
## 11. 当前已知边界
|
||||
|
||||
- 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)
|
||||
@@ -1,6 +1,6 @@
|
||||
# MVP 架构文档
|
||||
|
||||
**更新日期**:2026-07-26
|
||||
**更新日期**:2026-09-29
|
||||
**状态**:当前单 Diagnosis Agent + Harness 架构
|
||||
|
||||
当前文档入口:
|
||||
@@ -12,7 +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` 检索: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/`,仅用于历史决策追溯,不代表当前运行时。
|
||||
|
||||
当前普通 Trace 与 Provider reasoning 审计使用独立存储和独立接口。Reasoning 访问控制、保留期限、加密要求以及真实 Provider/V015 验证仍由 ISS-015 跟踪,不能把“数据已分表”理解为“治理已经完成”。
|
||||
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”理解为“治理已经完成”。
|
||||
|
||||
@@ -56,14 +56,36 @@ sequenceDiagram
|
||||
|
||||
PreviousTurn 只来自同一 Session 最近一个 `DIAGNOSIS + SUCCESS + published_result`。Fallback、失败、取消、raw evidence 和完整历史都不能进入下一轮;字段与字节上限由 Harness 配置控制。
|
||||
|
||||
## 5. Provider Reasoning 审计
|
||||
## 5. Provider Reasoning 与 Assistant 正文审计
|
||||
|
||||
`HarnessAgentAuditHook` 在每次模型步骤结束后检查 `AssistantMessage` metadata。当前识别 `reasoning_content`、`reasoningContent`、`reasoning` 和 `thinking`,但只接受 Provider 实际返回的非空文本:
|
||||
`HarnessAgentAuditHook` 在每次模型步骤结束后写入独立表 `agent_reasoning_audit`,**同时**尝试捕获:
|
||||
|
||||
- 有内容时写入 `agent_reasoning_audit`,单条最多保留 32000 个字符,并记录 UTF-8 `content_bytes`。
|
||||
- 无内容时写入 `reasoning_available=false`、`reasoning_content=NULL`、`content_bytes=0`,不得根据最终回答反推或生成 reasoning。
|
||||
- `agent_step.thought` 始终为空;步骤表只记录 message count、roles、是否有文本、Tool names、reasoning availability 和字节数等 metadata。
|
||||
- 普通 `diagnosis_trace_event` 的 `AGENT_MODEL_STEP` 只记录 reasoning availability/bytes,不保存 reasoning 原文。
|
||||
- Reasoning 只用于受限审计,不进入 Agent 后续上下文,不参与 EvidenceGuard、SemanticGuard 或 Release Policy 的事实判断。
|
||||
| 列 | 含义 |
|
||||
|---|---|
|
||||
| `reasoning_content` | Provider thinking / CoT |
|
||||
| `assistant_text` | 本步 assistant 可见正文,和/或 tool-call **计划**(不含 tool 结果) |
|
||||
| `content_source` | `PROVIDER_REASONING+ASSISTANT_TEXT` 等组合标记 |
|
||||
| `content_bytes` | 两列截断后 UTF-8 字节合计 |
|
||||
|
||||
当前查询隔离已经实现,完整访问治理和真实 Provider 行为验证仍属于 ISS-015。
|
||||
### 捕获路径(DeepSeek 生产)
|
||||
|
||||
当前 Chat 为 Spring AI 原生 `DeepSeekChatModel`(`deepseek-v4-flash`)。API 返回的 `message.reasoning_content` 被映射到 **`DeepSeekAssistantMessage.getReasoningContent()`**,而不是普通 `AssistantMessage.metadata`。Hook 优先读该专用字段,再反射 `getReasoningContent()`,最后才回退 metadata 键(`reasoning_content` / `thinking` 等)。
|
||||
|
||||
只接受 Provider 实际返回的非空文本;不得根据最终回答反推或生成伪 reasoning。
|
||||
|
||||
### 边界
|
||||
|
||||
- 单字段最多保留 32000 字符;tool **结果**只在 `tool_invocation`。
|
||||
- `agent_step.model_*` 仍为有界 metadata(含 `reasoning_available` / bytes / `content_source`)。
|
||||
- `agent_step.thought` 为兼容镜像:优先 reasoning,否则 assistant 正文;完整双字段以 `agent_reasoning_audit` 为准。
|
||||
- 普通 Timeline 的 `AGENT_MODEL_STEP` 不保存 reasoning/assistant 原文。
|
||||
- Reasoning / assistant 审计原文只用于受限审计 API,不进入 Agent 后续上下文,不参与 EvidenceGuard、SemanticGuard 或 Release Policy 的事实判断。
|
||||
|
||||
### 运行级结论读出
|
||||
|
||||
Run 结束时 `JpaChatRunStore` 从安全发布 JSON 提取 `diagnosis_run.conclusion`(与 `query` 并列),便于 Trace/DB 直接读结论;它不是 Provider thinking。
|
||||
|
||||
### 验证与治理
|
||||
|
||||
- **已 live 验证(2026-07-28)**:DeepSeek thinking 模式下 `reasoning_available=true`,且 reasoning 与 assistant 可同时非空。
|
||||
- 查询隔离已实现;访问控制、保留期限、加密等完整治理仍属 ISS-015。
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# 当前 MVP 架构
|
||||
|
||||
**更新日期**:2026-07-23
|
||||
**更新日期**:2026-09-29
|
||||
**状态**:当前可运行架构
|
||||
|
||||
## 1. 系统定位
|
||||
|
||||
SuperBizAgent 是面向故障诊断的可追踪 Agent 应用。当前系统只保留一个拥有 Tool loop 的 `Diagnosis Agent`;Harness 负责确定性的预算、取消、工具边界、证据验真、语义审查和安全发布。
|
||||
|
||||
知识检索当前为显式 `lookup_knowledge` 工具 + 独立 py-rag 知识服务(HTTP `/api/v1`);检索算法(dense+BM25 融合、rerank、判级)与文档入库全部在 py-rag 侧,Java 只保留 harness 消费面与 HTTP 客户端。详细链路见 [RAG知识检索架构.md](RAG知识检索架构.md)。
|
||||
|
||||
## 2. 分层
|
||||
|
||||
```mermaid
|
||||
@@ -63,6 +65,29 @@ Agent 只看到三个固定 Tool:
|
||||
|
||||
每次调用由框架提供 `tool_call_id`,Harness 校验 exact run、只读、Schema、预算和容量。Redis 保存 TTL 内完整 canonical invocation;MySQL `tool_invocation` 只保存长期有界 metadata,不保存完整参数、SQL/日志正文、raw response 或 Agent projection。
|
||||
|
||||
### 4.1 `lookup_knowledge` 检索边界
|
||||
|
||||
```text
|
||||
Agent
|
||||
-> RagToolAdapter / ToolBoundary
|
||||
-> LookupKnowledgeTool
|
||||
-> 原始 query 直传(L0 已下沉 py-rag)
|
||||
-> KnowledgeSearchPort
|
||||
-> PyRagKnowledgeSearchAdapter # HTTP 客户端(PyRagClient)
|
||||
-> py-rag 知识服务 /api/v1/search # 融合 / rerank / 判级
|
||||
-> KnowledgeEvidencePostProcessor # chunk 去重 / return-n / 判级(Java 侧)
|
||||
-> RagResultProjector # 有界 Agent 投影
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 默认 `retrieval.search.mode=hybrid`:映射 py-rag `hybrid`(dense+BM25 融合);`dense` 映射 `semantic` 作对照。
|
||||
- 证据按 chunk 级 `evidenceKey` 去重;Agent 侧 `document_id` 为 chunk 级身份。
|
||||
- py-rag `evidence_status=no_evidence` 按正常"无知识可用"处理,不是错误。
|
||||
- 知识库全量重建:py-rag `POST /api/v1/collections:rebuild?confirm=REBUILD`(异步任务)。
|
||||
|
||||
完整契约映射、入库、重建与历史差异见 [RAG知识检索架构.md](RAG知识检索架构.md)。
|
||||
|
||||
## 5. Trace 与持久化
|
||||
|
||||
```text
|
||||
@@ -75,26 +100,35 @@ chat_session(sessionId)
|
||||
```
|
||||
|
||||
- `chat_session` 是 JPA Run 目录与多轮 metadata,不保存完整对话历史。
|
||||
- `diagnosis_run` 是 Run 状态、intent、release outcome、安全发布结果和预算汇总真理源。
|
||||
- `agent_step` 只保存模型步骤 metadata,不保存 Prompt、消息正文、模型正文或 Thought。
|
||||
- `diagnosis_run` 是 Run 状态、intent、release outcome、安全发布结果、预算汇总真理源;另含与 `query` 并列的提取字段 `conclusion`(业务结论读出,非 thinking)。
|
||||
- `agent_step` 保存模型步骤有界 metadata;`thought` 可为 reasoning/assistant 的兼容镜像,完整双字段不在此表。
|
||||
- `tool_invocation` 只保存 Tool durable audit metadata;完整调用由 Redis canonical store 短期保存。
|
||||
- `diagnosis_trace_event` 是追加式统一 Timeline,记录 Run、Routing、Agent、Tool、Evidence、Semantic 和 Release 生命周期事件;`details` 只能保存有界安全 metadata。
|
||||
- `agent_reasoning_audit` 与普通 Trace 分表,只保存 Provider 实际返回的 reasoning 或明确的 unavailable 记录;reasoning 不属于事实证据。
|
||||
- `agent_reasoning_audit` 与普通 Trace 分表,按模型步骤保存 Provider `reasoning_content` 与 `assistant_text`(及 `content_source`),或明确的 unavailable;二者均不属于事实证据。
|
||||
|
||||
## 6. 公开 API
|
||||
|
||||
当前诊断执行入口只有 `POST /api/chat`。诊断审计读取分为:
|
||||
|
||||
- `GET /api/diagnosis/{sessionId}/trace?runId={runId}`:普通 Trace,返回 Run、步骤、Tool metadata 和统一 Timeline,不返回 reasoning 原文。
|
||||
- `GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}`:独立 reasoning 审计读取,`runId` 必填并校验其属于 path `sessionId`。
|
||||
- `GET /api/diagnosis/{sessionId}/trace?runId={runId}`:普通 Trace,返回 Run(含 `query`/`conclusion`)、步骤、Tool metadata 和统一 Timeline,**不**返回 reasoning/assistant 原文。
|
||||
- `GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}`:独立 LLM 步骤审计读取(`reasoningContent` + `assistantText`),`runId` 必填并校验其属于 path `sessionId`。
|
||||
|
||||
Reasoning endpoint 是敏感审计面,不属于普通业务 API。当前已完成数据和查询隔离;认证授权、保留期限、加密要求及真实 Provider 验证仍由 ISS-015 收敛。Feedback、文档与检索 API 保持独立;已删除的旧诊断和 Redis conversation Session endpoint 不提供兼容分支。
|
||||
Reasoning endpoint 是敏感审计面,不属于普通业务 API。数据和查询隔离、DeepSeek thinking 捕获路径已 live 验证;认证授权、保留期限、加密要求仍由 ISS-015 收敛。Feedback、文档与检索 API 保持独立;已删除的旧诊断和 Redis conversation Session endpoint 不提供兼容分支。
|
||||
|
||||
与知识检索相关的独立 API:
|
||||
|
||||
- `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. 安全边界
|
||||
|
||||
- 普通 SSE、Trace、Evidence Snapshot、业务结果和应用日志不输出或保存 reasoning 原文。
|
||||
- 仅当 Provider 在模型 metadata 中实际返回 reasoning 时,审计 Hook 才将其截断后写入独立表;Provider 未返回时不得伪造。
|
||||
- Reasoning 不能作为事实证据,也不能绕过 EvidenceGuard 或 SemanticGuard。
|
||||
- 普通 SSE、普通 Trace 的 steps/timeline、Evidence Snapshot、业务结果和应用日志不输出或保存 reasoning / assistant 审计原文。
|
||||
- 审计 Hook 仅当 Provider **实际返回** thinking(DeepSeek:`DeepSeekAssistantMessage.reasoningContent`;其它:metadata 键)时写入 `reasoning_content`;未返回时不得伪造。`assistant_text` 来自本步 assistant 可见输出与 tool-call 计划,不含 tool 结果。
|
||||
- Reasoning / assistant 审计原文不能作为事实证据,也不能绕过 EvidenceGuard 或 SemanticGuard。
|
||||
- 不向 Agent 暴露 Redis、canonical key、完整 Tool 请求/响应或数据库凭据。
|
||||
- EvidenceGuard 只接受当前 Run 的 READY canonical invocation。
|
||||
- SemanticGuard 无 Tool、无记忆、无回调主 Agent 能力。
|
||||
|
||||
@@ -581,7 +581,7 @@ Usage 不可用时不写 Token 字段,不把未知值记成零;Run 结束以
|
||||
|
||||
Tool 请求进入 `HarnessToolInterceptor` 后若被进展协议、重复 scope、信息饱和或观察合同门禁拒绝,写入 `TOOL_REQUEST_REJECTED`。事件只含安全 Tool Call ID、Tool name 和稳定错误码,不含业务参数、normalized scope 正文、原始响应或内部异常。实际执行仍只由 `TOOL_INVOCATION` 表达,因此两类数量不能混用。
|
||||
|
||||
真实审计发现:`INVALID_PROGRESS_PROTOCOL` 已可完整观察,但当前不会增加 `NO_GAIN` 或触发信息饱和。连续协议拒绝可能产生高 Token 空转,这属于待确认的停止行为改造,不属于 Token 审计本身。
|
||||
协议拒绝与信息增益相互独立:`INVALID_PROGRESS_PROTOCOL` 不累计 `NO_GAIN`,而是维护连续协议错误计数。未达阈值时返回可修正 observation(`repair_required`、`violation_type`、期望上一轮 Tool Call ID、允许的 `information_gain`);达到 `stop-after-consecutive-progress-protocol-violations`(默认 2)后交付一次 `STOP_REQUIRED/PROGRESS_PROTOCOL_VIOLATED`,继续请求 Tool 走受控停止。`TOOL_REQUEST_REJECTED` 额外记录安全 `violation_type`、`repair_prompt_delivered`、连续协议错误次数和 stop reason。
|
||||
|
||||
## 14. 验收标准
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ disconnect、timeout 与 send failure 通过同一个 `ChatRunControl` 请求取
|
||||
|
||||
Redis canonical invocation 不是 Trace API 的长期响应内容;它只供当前 Run EvidenceGuard 验真。
|
||||
|
||||
普通 Trace 不读取 `agent_reasoning_audit`,也不返回 reasoning 原文。Agent 模型步骤和 Timeline 只暴露 `reasoning_available`、`reasoning_bytes` 等有界 metadata。
|
||||
普通 Trace 不读取 `agent_reasoning_audit` 的 **原文**。Agent 模型步骤和 Timeline 只暴露 `reasoning_available`、`reasoning_bytes` / `assistant_bytes`、`content_source` 等有界 metadata。
|
||||
普通 Trace 的 `run` 可返回 `query` 与提取后的 `conclusion`(业务结论读出,非 thinking)。
|
||||
|
||||
## 4. PreviousTurn
|
||||
|
||||
@@ -45,11 +46,11 @@ Redis canonical invocation 不是 Trace API 的长期响应内容;它只供当
|
||||
|
||||
## 5. Reasoning 审计查询
|
||||
|
||||
`GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}` 提供独立 reasoning 审计读取:
|
||||
`GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}` 提供独立 LLM 步骤审计读取:
|
||||
|
||||
- `runId` 必填,服务端先验证 Run 存在且属于 path `sessionId`,禁止跨 Session 串读。
|
||||
- 结果按 `step_index` 返回 Agent、reasoning availability、受限原文、字节数和创建时间。
|
||||
- Provider 未返回 reasoning 时仍保留 unavailable 记录,以区分“没有返回”与“审计遗漏”。
|
||||
- Reasoning 数据不回流到 PreviousTurn,不进入普通 Trace、SSE、Evidence Snapshot 或发布结果。
|
||||
- 结果按 `step_index` 返回:`reasoningAvailable`、`reasoningContent`、`assistantText`、`contentSource`、`contentBytes`、创建时间等。
|
||||
- Provider 未返回 reasoning 时仍保留记录(`reasoningAvailable=false`,可仍有 `assistantText`),以区分“没有 thinking”与“审计遗漏”。
|
||||
- Reasoning / assistant 审计原文不回流到 PreviousTurn,不进入普通 Trace 的 steps/timeline 原文、SSE、Evidence Snapshot 或发布结果。
|
||||
|
||||
该端点属于敏感审计面。当前完成了分表、独立查询和归属校验;身份认证、权限模型、保留期限、加密及真实 Provider/V015 验证尚未完成,由 ISS-015 阶段 3 收敛。
|
||||
该端点属于敏感审计面。分表、独立查询、归属校验,以及 DeepSeek 真实 thinking 捕获路径(`DeepSeekAssistantMessage.reasoningContent`)与 V015–V017 迁移已验证;身份认证、权限模型、保留期限、加密仍由 ISS-015 阶段 3 收敛。
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
- [ ] SSE metadata 的 `session_id`、`run_id` 非空且与数据库完全一致。
|
||||
- [ ] `diagnosis_run.intent=DIAGNOSIS`,status/release_outcome 与 done outcome 一致。
|
||||
- [ ] `diagnosis_run.conclusion` 与发布结论一致(SUCCESS 时非空短结论;FALLBACK 可为 type/message 摘要)。
|
||||
- [ ] `agent_step.agent_name` 只出现 `diagnosis_agent`。
|
||||
- [ ] AgentStep model_input/model_output 只含 metadata,thought 为空。
|
||||
- [ ] ToolInvocation 全部属于 exact runId,Tool 名在 ACI allowlist 内。
|
||||
- [ ] ToolInvocation input/output/retrieval details 不含 SQL、日志 query、raw response 或 evidence body。
|
||||
- [ ] AgentStep `model_input`/`model_output` 只含有界 metadata(可含 `reasoning_available`/`content_source`/bytes);**不含** reasoning/assistant 原文。
|
||||
- [ ] `agent_step.thought` 若非空,应为 reasoning 或 assistant 的兼容镜像,不得替代 `/trace/reasoning` 双字段验收。
|
||||
- [ ] `/trace/reasoning`:DeepSeek thinking 开启时应有 `reasoningAvailable=true` 且 `reasoningContent` 非空;`assistantText` 有正文和/或 tool-call 计划;`contentSource` 合理;**无** tool 结果体。
|
||||
- [ ] ToolInvocation 全部属于 exact runId,Tool 名在 ACI allowlist 内;`step_id` 能挂到对应 `agent_step.id`。
|
||||
- [ ] ToolInvocation input/output/retrieval details 不含 SQL 全文、raw response 或 evidence body 泄露(query 等允许的有界字段除外)。
|
||||
- [ ] Run 的模型/Tool/Token/字节预算均未超过集中配置。
|
||||
- [ ] content 只出现一次并来自 Release Policy;failure 与 content 互斥。
|
||||
- [ ] `logs/application.log` 不含 Prompt、Thought、完整 Tool 参数、raw response、vendor exception 或 stack 泄漏。
|
||||
- [ ] query_logs 标记为 Mock;query_mysql 只使用隔离只读 datasource contract。
|
||||
- [ ] `logs/application.log` 不含 Prompt、完整 reasoning 原文、完整 Tool 参数、raw response、vendor exception 或 stack 泄漏。
|
||||
- [ ] query_logs 标记为 Mock;query_mysql 仅在配置了隔离 datasource 时注册。
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# MVP 工程纪要(Engineering Notes)
|
||||
|
||||
**更新日期**:2026-07-30
|
||||
**定位**:项目推进中真实遇到的问题、决策、解决思路与 E2E 验收叙事。
|
||||
|
||||
与其它目录分工:
|
||||
|
||||
| 目录 | 读什么 |
|
||||
|---|---|
|
||||
| [architecture/](../architecture/) | **现行架构**(系统现在怎么跑) |
|
||||
| **engineering/**(本目录) | **为何这样定**、踩坑、方案取舍、live 验收导读 |
|
||||
| [issues/](../issues/) | 未完成事项与状态 |
|
||||
| [tables/](../tables/) | 表结构说明 |
|
||||
|
|
||||
| `devflow/projects/` | 单次变更的 brief/decisions/evidence 切片 |
|
||||
|
||||
本文档**不是** API 规范的唯一真理源;冲突时以 `architecture/` 与代码为准。
|
||||
|
||||
---
|
||||
|
||||
## RAG
|
||||
|
||||
> 2026-09-29 RAG 模块已抽离为独立 py-rag 知识服务(架构见
|
||||
> [../architecture/RAG知识检索架构.md](../architecture/RAG知识检索架构.md))。
|
||||
> 下列纪要保留决策过程价值,涉及进程内 Milvus / L0 的实现细节以各文顶部说明为准。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [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 验收(现行) |
|
||||
|
||||
架构对照:
|
||||
|
||||
- [architecture/RAG知识检索架构.md](../architecture/RAG知识检索架构.md)
|
||||
- [architecture/RAG检索可观测性与审计.md](../architecture/RAG检索可观测性与审计.md)
|
||||
|
||||
相关 Issue:
|
||||
|
||||
- [ISS-017 L0 过滤收窄与 Fallback 加固](../issues/active/ISS-017-rag-l0-filter-fallback-hardening.md)(已失效:L0 下沉 py-rag,问题前提不复存在)
|
||||
|
||||
---
|
||||
|
||||
## Harness
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [harness/README.md](harness/README.md) | **从这里开始**:用一次诊断请求理解 Harness,不要求先掌握组件和状态 |
|
||||
| [harness/Harness面试速查-一张图讲清设计.md](harness/Harness面试速查-一张图讲清设计.md) | **收尾速查**:一张架构图、一条请求主链、三个核心决策和常见面试追问 |
|
||||
| [harness/案例-从一次支付超时诊断看Harness如何控制Agent.md](harness/案例-从一次支付超时诊断看Harness如何控制Agent.md) | **案例导读**:跟随一次真实支付超时 Run,看 Agent、Tool、Guard 和 Release 如何协作 |
|
||||
| [harness/Harness设计演进-从多Agent编排到确定性控制边界.md](harness/Harness设计演进-从多Agent编排到确定性控制边界.md) | **设计演进**:从多 Agent、Gatekeeper 和 StateGraph 逐步收敛到 Single ReAct + Harness |
|
||||
| [harness/Harness失败图谱-异常-停止-降级与终态.md](harness/Harness失败图谱-异常-停止-降级与终态.md) | **失败图谱**:用可继续性、安全进展和发布资格解释异常、停止、降级与终态 |
|
||||
| [harness/components/README.md](harness/components/README.md) | **组件渐进式导读**:沿一次请求分四步理解运行控制、Agent 收敛、Tool 事实边界和验证发布 |
|
||||
| [harness/CONTEXT.md](harness/CONTEXT.md) | Harness 统一术语、命名规则、状态维度与已知命名债务 |
|
||||
| [harness/Harness生命周期与状态.md](harness/Harness生命周期与状态.md) | Run、Tool、Progress、Guard、Release、SSE 和持久化生命周期及状态映射 |
|
||||
| [harness/Harness设计-非确定性Agent的确定性控制边界.md](harness/Harness设计-非确定性Agent的确定性控制边界.md) | Harness 根问题、设计不变量、方案取舍、关键决策、代价与真实问题反推 |
|
||||
| [harness/Harness组件全景-职责-设计原因与边界.md](harness/Harness组件全景-职责-设计原因与边界.md) | 当前 Harness 10 个职责域、全部生产类型、设计原因、作用与边界 |
|
||||
| [harness/Harness-Tool双视图-从原始结果到可验证证据.md](harness/Harness-Tool双视图-从原始结果到可验证证据.md) | canonical truth、Control View、Agent Observation 与 metadata audit 的数据边界 |
|
||||
| [harness/Harness证据安全链-从引用真实到结论可发布.md](harness/Harness证据安全链-从引用真实到结论可发布.md) | EvidenceGuard、EvidenceRepair、SemanticGuard 和唯一 Release Policy |
|
||||
| [harness/Harness信息增益停止-让无证据诊断正常收敛.md](harness/Harness信息增益停止-让无证据诊断正常收敛.md) | GAINED/NO_GAIN、重复检测、协议停止、ProgressSnapshot 与过程型 Fallback |
|
||||
|
||||
---
|
||||
|
||||
## 审计
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [audit/README.md](audit/README.md) | **从这里开始**:从“这份答案为什么可信”理解审计系统,不要求先掌握表和事件类型 |
|
||||
| [audit/从一次诊断Run看审计系统如何记录决策.md](audit/从一次诊断Run看审计系统如何记录决策.md) | **真实 Run 案例**:从最终结果倒推 Run、Timeline、Agent Step、Tool、Token 与 Release 决策 |
|
||||
| [audit/审计系统设计-从调试日志到可回放的决策证据.md](audit/审计系统设计-从调试日志到可回放的决策证据.md) | **主设计**:日志为何不够、设计不变量、typed decision、数据分层、失败语义与当前代价 |
|
||||
| [audit/审计设计演进-从SessionTrace到ExactRun.md](audit/审计设计演进-从SessionTrace到ExactRun.md) | **设计演进**:从 Session Trace、Evidence 语义和多轮串线,演进到 canonical/durable 分层、Timeline、Token 与 Reasoning |
|
||||
|
||||
---
|
||||
|
||||
## 诊断 / E2E
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [diagnosis/一次诊断全流程-E2E导读.md](diagnosis/一次诊断全流程-E2E导读.md) | SUCCESS 全流程:阶段、token、timeline、字段 |
|
||||
| [diagnosis/Session-Run-Trace隔离-从串线到可回放.md](diagnosis/Session-Run-Trace隔离-从串线到可回放.md) | 多轮串线问题、身份拆分、协议迁移与 exact-run E2E |
|
||||
|
||||
架构对照:
|
||||
|
||||
- [architecture/current-mvp-architecture.md](../architecture/current-mvp-architecture.md)
|
||||
- [architecture/session-trace-lifecycle.md](../architecture/session-trace-lifecycle.md)
|
||||
- [architecture/agent-orchestration.md](../architecture/agent-orchestration.md)
|
||||
@@ -0,0 +1,95 @@
|
||||
# 审计系统:先从一份已经返回的答案开始
|
||||
|
||||
这不是审计表结构手册,而是一页渐进式入门导读。
|
||||
|
||||
第一次阅读时,不需要记 `diagnosis_run`、`agent_step` 或事件类型。先回答一个问题:**系统已经给出了诊断答案,为什么还需要审计?**
|
||||
|
||||
## 1. 如果只有答案和日志,会发生什么
|
||||
|
||||
假设用户收到一份“数据库连接池可能耗尽”的诊断报告。业务日志也显示 Agent 和 Tool 都执行成功。
|
||||
|
||||
但系统仍然无法直接证明:
|
||||
|
||||
- 报告是否属于本轮请求,而不是混入同一 Session 的上一轮数据;
|
||||
- Agent 是否真的调用了它声称使用的 Tool;
|
||||
- Tool 成功是否等于找到了证据;
|
||||
- EvidenceGuard 和 SemanticGuard 是否真正执行;
|
||||
- 最终发布结果与 Run 终态、SSE outcome 是否一致;
|
||||
- Router、Agent 和 Guard 的 Token 能否与总数对上。
|
||||
|
||||
这些不是“多打印几行日志”就能稳定解决的问题。它们需要明确的执行身份、决策类型、顺序和关联关系。
|
||||
|
||||
## 2. 审计在一次请求中做了什么
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["一次诊断请求"] --> R["建立 exact Run<br/>固定本轮身份"]
|
||||
R --> D["在原始决策点记录<br/>Router / Agent / Tool / Guard / Release"]
|
||||
D --> S["Run、Timeline、Step、Tool<br/>分别保存各自事实"]
|
||||
S --> T["Trace 聚合回放<br/>计数与 Token 对账"]
|
||||
T --> A["回答答案为何产生<br/>也诚实暴露审计缺口"]
|
||||
```
|
||||
|
||||
顺着这条线,审计只做三类事情:
|
||||
|
||||
1. **固定身份**:用 `runId` 把一次执行与多轮 Session 分开。
|
||||
2. **记录决定**:让真正做决定的组件留下有类型、有顺序的结构化事实。
|
||||
3. **聚合对账**:从最终结果倒推 Timeline、Agent Step、Tool 和 Token,检查它们是否一致。
|
||||
|
||||
审计不会替 Agent 诊断,也不会替 Harness 决定能否发布。它负责让这些行为在事后可以被准确解释。
|
||||
|
||||
## 3. 先建立这个最小心智模型
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
EXEC["一次 Agent 执行"] --> RUN["Run<br/>结果封面"]
|
||||
EXEC --> TL["Timeline<br/>决策脊柱"]
|
||||
EXEC --> DETAIL["Step / Tool<br/>行为明细"]
|
||||
EXEC --> SENSITIVE["Reasoning<br/>独立敏感审计面"]
|
||||
|
||||
RUN --> TRACE["普通 Trace"]
|
||||
TL --> TRACE
|
||||
DETAIL --> TRACE
|
||||
SENSITIVE -.-> RTRACE["独立受限查询"]
|
||||
```
|
||||
|
||||
第一次阅读只需要记住:
|
||||
|
||||
> Run 告诉我们结果,Timeline 告诉我们决定怎样发生,Step 和 Tool 告诉我们模型与外部世界实际做过什么;它们通过 exact `runId` 组合成一条可回放的证据链。
|
||||
|
||||
到这里可以先停下,不需要继续记表名。
|
||||
|
||||
## 4. 推荐阅读顺序
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
START["先建立直觉"] --> CASE["01 真实 Run 案例<br/>审计怎样使用"]
|
||||
CASE --> DESIGN["02 审计主设计<br/>为什么这样设计"]
|
||||
DESIGN --> EVOLUTION["03 设计演进<br/>为什么变成现在这样"]
|
||||
EVOLUTION --> NEXT["后续专题<br/>数据模型、Token、Tool、Reasoning、失败"]
|
||||
```
|
||||
|
||||
| 顺序 | 先回答的问题 | 阅读 |
|
||||
|---|---|---|
|
||||
| 01 | 拿到一份结果后,怎样从后向前回放整次执行? | [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md) |
|
||||
| 02 | 为什么日志不够,为什么需要 exact Run、typed event 和分层数据? | [审计系统设计:从调试日志到可回放的决策证据](审计系统设计-从调试日志到可回放的决策证据.md) |
|
||||
| 03 | 这套设计经历了哪些真实问题,哪些阶段性方案后来被替换? | [审计设计演进:从 Session Trace 到 Exact Run](审计设计演进-从SessionTrace到ExactRun.md) |
|
||||
|
||||
建议一次只读一篇。第一篇建立使用直觉,第二篇理解设计取舍,第三篇再理解这些边界如何被真实问题一步步推出来;数据表、完整事件类型和代码类名都不是第一次阅读的前置知识。
|
||||
|
||||
## 5. 遇到具体问题时再往下读
|
||||
|
||||
| 当你想知道 | 当前资料 |
|
||||
|---|---|
|
||||
| 一次 SUCCESS 诊断的业务链路、字段和真实数值 | [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md) |
|
||||
| Session、Run、Trace 和 Reasoning 当前生命周期 | [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md) |
|
||||
| exact Run 为什么出现,曾经发生过什么串线问题 | [Session-Run-Trace 隔离工程纪要](../diagnosis/Session-Run-Trace隔离-从串线到可回放.md) |
|
||||
| 如何人工验收一条 Trace 是否完整和安全 | [Trace 检查清单](../../demo/trace-inspection-checklist.md) |
|
||||
|
||||
后续本目录会继续补充数据模型、Token、Tool、Reasoning、失败图谱和面试速查。它们会继续保持同样的渐进式结构,不要求从目录头到尾顺序阅读。
|
||||
|
||||
## 6. 与 Harness 文档的分工
|
||||
|
||||
- [Harness 入门](../harness/README.md)回答“如何让一次非确定性 Agent 执行受控并安全发布”。
|
||||
- 审计文档回答“这些控制和决定如何被记录、回放和对账”。
|
||||
- Harness 是执行控制边界,Audit 是决策证据边界;二者协作,但不是同一个职责。
|
||||
@@ -0,0 +1,255 @@
|
||||
# 从一次诊断 Run 看审计系统如何记录决策
|
||||
|
||||
这篇文章不从表结构和类名开始,而是从一份已经返回给用户的诊断报告开始,倒着追问:**这份答案为什么可以被系统发布?**
|
||||
|
||||
先说结论:审计系统不是把运行日志存下来,而是为每一次诊断建立一个独立的 `Run`,再分别记录决策顺序、模型步骤、Tool 调用和最终结果。查询时,这些记录才被重新聚合成一条可以回放、可以对账的执行证据链。
|
||||
|
||||
第一次阅读只看第 1、2、3、7 和 8 节即可。先建立直觉,再回来理解为什么要拆成多种记录。
|
||||
|
||||
## 1. 只有最终答案,为什么还不够
|
||||
|
||||
假设系统返回:
|
||||
|
||||
> 当前证据更支持数据库连接池耗尽这一排查方向,但缺少生产指标和实时日志,暂时不能确认最终根因。
|
||||
|
||||
这段话看起来很谨慎,但面试官、开发者或评测系统仍然会继续追问:
|
||||
|
||||
- 这是本轮请求产生的答案,还是混入了同一会话的上一轮数据?
|
||||
- Agent 实际调用了什么 Tool,还是只在文本里声称自己查过?
|
||||
- Tool 返回了候选资料以后,哪个模型步骤使用了它?
|
||||
- EvidenceGuard 和 SemanticGuard 是否真的执行,最终是谁决定放行?
|
||||
- 这次请求到底调用了几次模型、消耗多少 Token,数字能否对上?
|
||||
|
||||
普通应用日志可以告诉我们“某段代码运行过”,但很难稳定回答这些领域问题。日志行也没有天然的 Run 归属、决策类型和对账关系。
|
||||
|
||||
所以审计系统要解决的根问题不是“多记一些信息”,而是:
|
||||
|
||||
> 把一次非确定性的 Agent 执行,转换成一组具有明确身份、顺序、责任和边界的可验证记录。
|
||||
|
||||
## 2. 先看这次真实 Run
|
||||
|
||||
本文使用已有 SUCCESS E2E 样本:
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| `session_id` | `e2e-rag-success-20260728162949` |
|
||||
| `run_id` | `27415045-e674-41af-9cea-de01f27ce040` |
|
||||
| 最终结果 | `SUCCESS / DIAGNOSIS_REPORT` |
|
||||
| Agent Step | 2 |
|
||||
| Tool Invocation | 1 次 `lookup_knowledge` |
|
||||
| 模型调用 | Router 1 次、Agent 2 次、SemanticGuard 1 次 |
|
||||
| Timeline | 15 个有序事件 |
|
||||
| 总 Token | 13,236,`tokens_reconciled=true` |
|
||||
| 总耗时 | 约 25 秒 |
|
||||
|
||||
业务视角看到的是一份诊断报告,审计视角看到的是报告背后的五个问题:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
OUT["用户收到 DIAGNOSIS_REPORT"]
|
||||
OUT --> RUN["Run 摘要<br/>这次执行最终怎样结束?"]
|
||||
OUT --> TL["决策 Timeline<br/>先后做过哪些决定?"]
|
||||
OUT --> STEP["Agent Step<br/>模型每一轮做了什么?"]
|
||||
OUT --> TOOL["Tool Invocation<br/>实际调用过什么?"]
|
||||
OUT --> LEDGER["Token 账本<br/>资源消耗能否对上?"]
|
||||
```
|
||||
|
||||
这五个视角不是重复保存同一份内容。它们分别回答不同的问题,并在 `run_id` 下汇合。
|
||||
|
||||
## 3. 审计的正确阅读顺序:从结果向前倒推
|
||||
|
||||
回放一次诊断时,不应该从第一条日志开始逐行翻。更有效的顺序是:先确认终态,再沿决策链向前寻找依据。
|
||||
|
||||
```mermaid
|
||||
flowchart RL
|
||||
FIN["RUN_FINISHED<br/>SUCCESS,Token 已对账"] --> REL["RELEASE_DECISION<br/>允许发布"]
|
||||
REL --> SG["SEMANTIC_GUARD_DECISION<br/>SUPPORTED"]
|
||||
SG --> EG["EVIDENCE_GUARD_INITIAL<br/>PASSED"]
|
||||
EG --> A2["Agent Step 1<br/>生成报告草稿"]
|
||||
A2 --> T1["Tool Invocation<br/>RAG 返回候选证据"]
|
||||
T1 --> A1["Agent Step 0<br/>发出 Tool Call"]
|
||||
A1 --> ROUTE["ROUTING_DECISION<br/>DIAGNOSIS"]
|
||||
ROUTE --> START["RUN_STARTED"]
|
||||
```
|
||||
|
||||
沿着这条链可以得到一个比“请求成功”更具体的结论:
|
||||
|
||||
1. 本次 Run 最终正常结束,并发布了诊断报告;
|
||||
2. Release 放行前,SemanticGuard 给出 `SUPPORTED`;
|
||||
3. SemanticGuard 之前,EvidenceGuard 已确认引用结构有效;
|
||||
4. 报告草稿来自 Agent 第 2 轮;
|
||||
5. 草稿使用的候选证据来自第 1 轮发出的真实 RAG Tool Call;
|
||||
6. 整条链都属于同一个 exact `run_id`。
|
||||
|
||||
审计的价值就在这里:它不是保存一份“成功日志”,而是让最终结果可以逐层找到前置依据。
|
||||
|
||||
## 4. 第一层:Run 是这次执行的封面
|
||||
|
||||
`sessionId` 表示多轮对话目录,`runId` 才表示一次独立执行。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["chat_session<br/>同一个多轮会话"] --> R1["diagnosis_run A<br/>第一轮问题"]
|
||||
S --> R2["diagnosis_run B<br/>第二轮问题"]
|
||||
R1 --> D1["自己的 Step / Tool / Timeline"]
|
||||
R2 --> D2["自己的 Step / Tool / Timeline"]
|
||||
```
|
||||
|
||||
这个拆分来自真实问题:早期系统只使用 `sessionId`。同一会话连续执行两轮后,主记录会被后一轮覆盖,而 Agent Step 和 Tool Invocation 继续追加,最终造成 Trace、Feedback 和评测跨轮混合。
|
||||
|
||||
因此当前模型把两种身份分开:
|
||||
|
||||
- `sessionId` 回答“这些问题属于哪段对话”;
|
||||
- `runId` 回答“这条记录属于哪一次执行”。
|
||||
|
||||
本次样本的 `diagnosis_run` 像一张封面,保存查询、状态、意图、发布结果、总耗时、总 Token 和最终安全内容。看到它,我们先知道故事结尾,但还不知道过程细节。
|
||||
|
||||
## 5. 第二层:Timeline 记录决定,不复制所有正文
|
||||
|
||||
本次 Run 的 15 个事件可以压缩成七个阶段:
|
||||
|
||||
| 阶段 | 关键事件 | 它证明什么 |
|
||||
|---|---|---|
|
||||
| RUN | `RUN_STARTED` | 一次独立执行已经建立 |
|
||||
| ROUTING | Token、Attempt、Decision | Router 被真实调用,并选择 `DIAGNOSIS` |
|
||||
| AGENT | 两轮 Token 与 Model Step | Agent 先规划 Tool,后生成草稿 |
|
||||
| TOOL | `TOOL_INVOCATION` | `lookup_knowledge` 被实际执行 |
|
||||
| EVIDENCE | `EVIDENCE_GUARD_INITIAL` | 引用和证据结构检查通过 |
|
||||
| SEMANTIC | Token、Attempt、Decision | SemanticGuard 被调用并判断 `SUPPORTED` |
|
||||
| RELEASE / RUN | Release、Finish | 报告被允许发布,Run 正常收尾 |
|
||||
|
||||
Timeline 只承担“什么时候做了什么决定”。它不保存完整 Tool raw,也不试图替代 Agent Step 和 Tool Invocation 的详细字段。
|
||||
|
||||
这是一个有意的设计取舍:如果把所有信息都塞进一张巨大的事件表,查询一条时间线会很方便,但模型步骤、Tool 证据和资源账本都会退化成难以约束的 JSON。当前方案让 Timeline 保持稳定的决策语义,领域明细继续由各自记录负责。
|
||||
|
||||
代价是读取时必须做聚合,不能只查一张表。但这个复杂度被集中在 Trace 查询服务中,而不是扩散给每个调用方。
|
||||
|
||||
## 6. 第三层:Step 与 Tool 共同证明“模型真的做过什么”
|
||||
|
||||
这次诊断有两个 Agent Step:
|
||||
|
||||
| Step | 模型行为 | Token | 关联结果 |
|
||||
|---|---|---:|---|
|
||||
| 0 | 没有正文,发出 `lookup_knowledge` Tool Call | 2,657 | 产生 1 条 Tool Invocation |
|
||||
| 1 | 读取 Tool Observation,生成报告草稿 | 4,703 | 进入 EvidenceGuard |
|
||||
|
||||
`AgentStepAuditTracker` 在 Step 落库后绑定 `step_id`,Tool 执行时再把这个 ID 写入 `tool_invocation`。因此系统不只知道“这一轮有 Tool Call”和“某处有一次 Tool 调用”,还可以把两者连起来。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S0["agent_step #0<br/>发出 lookup_knowledge"] -->|"step_id"| T["tool_invocation<br/>READY / EVIDENCE_FOUND"]
|
||||
T --> O["有界 Observation"]
|
||||
O --> S1["agent_step #1<br/>生成 Draft"]
|
||||
```
|
||||
|
||||
Tool 审计也没有永久复制完整原始结果。长期记录的是 exact identity、Tool 名称、状态、耗时、请求和结果字节数、稳定错误码,以及 RAG 的检索模式、命中数和相关度等有界元数据。
|
||||
|
||||
完整 Tool 结果属于当前 Run 的短期 canonical truth,由 Harness 用于 EvidenceGuard 验真;它与长期 durable audit 的目的不同:
|
||||
|
||||
- canonical truth 要回答“当前发布校验依据的事实到底是什么”;
|
||||
- durable audit 要回答“长期复盘时,这次调用发生了什么”。
|
||||
|
||||
把完整 raw 同时写进长期 Trace 虽然排障直接,但会扩大敏感数据、存储体积和保留治理范围,因此没有采用。
|
||||
|
||||
## 7. 第四层:Token 不是一个总数,而是一套可对账账本
|
||||
|
||||
如果只在 Run 结束时写一个 `total_token_count`,我们无法判断数字是否漏掉 Router、Guard 或隐藏重试。
|
||||
|
||||
本次 Run 的模型账本是:
|
||||
|
||||
| 组件 | Token |
|
||||
|---|---:|
|
||||
| Intent Router | 906 |
|
||||
| Diagnosis Agent 第 1 轮 | 2,657 |
|
||||
| Diagnosis Agent 第 2 轮 | 4,703 |
|
||||
| SemanticGuard | 4,970 |
|
||||
| 合计 | 13,236 |
|
||||
|
||||
对账关系为:
|
||||
|
||||
```text
|
||||
sum(MODEL_TOKEN_USAGE.total_tokens)
|
||||
= diagnosis_run.total_token_count
|
||||
= RUN_FINISHED.run_total_tokens
|
||||
= 13236
|
||||
```
|
||||
|
||||
因此 `tokens_reconciled=true` 不是“记录了一个 Token 数”,而是三个独立视角得到相同结果。
|
||||
|
||||
还有一个容易误读的点:`agent_step.tokenCount` 之和只有 7,360,因为它只统计 Diagnosis Agent 的两轮;Run 总额还包括 Router 和 SemanticGuard。审计把组件和轮次分开,就是为了让成本和延迟可以定位,而不是只得到一个无法解释的总数。
|
||||
|
||||
Provider 没有返回 usage 时,系统会记录 `usage_available=false`,而不是把缺失伪造成 0 Token。缺数据本身也是需要被审计的事实。
|
||||
|
||||
## 8. 这套设计做了哪些关键选择
|
||||
|
||||
### 选择一:Trace 独立查询,不塞进 Chat 响应
|
||||
|
||||
- **问题**:Chat 面向用户流式返回结果,Trace 面向复盘和评测,生命周期与数据量不同。
|
||||
- **决策**:通过只读 Trace API 按 `sessionId + runId` 聚合持久化证据。
|
||||
- **放弃方案**:把完整 Trace 嵌入 `/api/chat` SSE。
|
||||
- **代价**:调用方需要在收到 metadata 后保存 `runId`,再进行第二次查询。
|
||||
|
||||
### 选择二:exact Run 是审计边界
|
||||
|
||||
- **问题**:仅按 Session 查询曾造成多轮 Step、Tool、Feedback 和评测串线。
|
||||
- **决策**:每次有效执行创建独立 Run,所有明细携带同一个 `run_id`。
|
||||
- **兼容代价**:当前 API 仍保留“不传 `runId` 时读取 latest run”和 legacy 回退;这是迁移兼容,不是推荐的新调用方式。
|
||||
|
||||
### 选择三:决策 Timeline 与领域明细分开
|
||||
|
||||
- **问题**:单看 Run 摘要不知道过程,单看 Step 或 Tool 又不知道整体决策顺序。
|
||||
- **决策**:Timeline 保存 typed decision event,Step 和 Tool 保存各自明细,查询时聚合。
|
||||
- **放弃方案**:一张万能 Trace 表承载所有正文和字段。
|
||||
- **代价**:需要维护事件与明细之间的计数、身份和顺序一致性。
|
||||
|
||||
### 选择四:普通审计长期保存有界信息
|
||||
|
||||
- **问题**:完整 Prompt、Reasoning 和 Tool raw 虽然方便排障,却会把敏感数据永久扩散到普通 Trace。
|
||||
- **决策**:普通 Trace 以 metadata 为主;Reasoning 使用独立审计面,Tool raw 只在当前 Run 的 canonical store 中短期存在。
|
||||
- **当前缺口**:Reasoning 的认证、权限、加密和保留期限仍未完全闭环;`agent_step.thought` 还存在兼容镜像语义,不能把当前状态描述成彻底隔离。
|
||||
|
||||
### 选择五:审计写入失败不改变业务结果
|
||||
|
||||
- **问题**:如果长期审计数据库短暂不可用,是否应该让一次本可安全完成的诊断直接失败?
|
||||
- **决策**:durable audit 采用 fail-open,写入失败告警,但不改变业务结果。
|
||||
- **边界**:用于 EvidenceGuard 验真的 canonical truth 不是普通审计;它缺失时无法证明证据,必须 fail-closed。
|
||||
- **代价**:一次业务成功的 Run 可能存在审计缺口,所以 Trace summary 和计数对账必须显式暴露缺失,而不能假装记录完整。
|
||||
|
||||
## 9. 审计能证明什么,不能证明什么
|
||||
|
||||
审计可以证明:
|
||||
|
||||
- 某个结果属于哪个 exact Run;
|
||||
- 哪些模型和 Tool 被实际调用;
|
||||
- 决策以什么顺序发生;
|
||||
- Tool Call 属于哪个 Agent Step;
|
||||
- Guard 和 Release 给出了什么结果;
|
||||
- Token、步骤数、Tool 数和 Timeline 数量是否对账。
|
||||
|
||||
审计不能自动证明:
|
||||
|
||||
- Tool 返回的外部数据本身一定正确;
|
||||
- `SUPPORTED` 永远不会发生模型误判;
|
||||
- 没有记录的 Reasoning 可以被事后还原;
|
||||
- durable audit 写入失败时,缺失的事件仍然存在;
|
||||
- 有了 Trace 就可以忽略权限、脱敏、保留期限和加密。
|
||||
|
||||
这也是为什么审计系统的目标不是“绝对正确”,而是让执行过程的身份、决定、依据和缺口都变得可见。
|
||||
|
||||
## 10. 先记住这一句话
|
||||
|
||||
> 一次诊断结束后,Run 告诉我们结果,Timeline 告诉我们决定是怎样发生的,Agent Step 和 Tool Invocation 告诉我们模型与外部世界实际做过什么,Token 账本负责对账;它们通过 exact `runId` 组合成一条可回放的决策证据链。
|
||||
|
||||
理解这句话,就已经抓住了审计系统的主干。表结构、事件全集、Reasoning 和失败治理都可以在需要时再展开。
|
||||
|
||||
## 11. 事实来源与延伸阅读
|
||||
|
||||
本文没有重新从源码推导设计,主要依据现有工程资料:
|
||||
|
||||
- [一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md):本文 SUCCESS 样本、15 个 Timeline 事件和 Token 数据来源;
|
||||
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前 exact-run、普通 Trace 与 Reasoning 边界;
|
||||
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:为什么建设独立 Trace API;
|
||||
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:Session/Run 串线问题与身份拆分决策;
|
||||
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool durable audit 和 canonical/durable 边界;
|
||||
- [Trace 检查清单](../../demo/trace-inspection-checklist.md):当前验收边界与兼容镜像说明。
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# 审计系统设计:从调试日志到可回放的决策证据
|
||||
|
||||
第一篇文章跟随一条真实 Run,展示了怎样从最终报告倒推出 Router、Agent、Tool、Guard 和 Release。这一篇换一个角度:**为什么这件事不能靠普通日志完成,系统又为什么选择了现在这套审计结构?**
|
||||
|
||||
先说核心判断:Agent 系统的审计对象不是代码执行过程,而是一次运行中产生的关键决定。日志可以帮助开发者定位异常,但审计必须让系统回答:这个决定属于哪次执行、由谁作出、依据是什么、先后关系怎样、最终结果是否与过程对得上。
|
||||
|
||||
第一次阅读只看第 1、2、3、6 和 9 节即可。它们构成最小设计主线;其余章节用于展开具体取舍。
|
||||
|
||||
## 1. 根问题:系统给出了答案,但无法证明答案怎样产生
|
||||
|
||||
一个最简单的实现可能只留下这样的日志:
|
||||
|
||||
```text
|
||||
start diagnosis
|
||||
call lookup_knowledge success
|
||||
semantic check passed
|
||||
finish diagnosis
|
||||
```
|
||||
|
||||
这些日志能说明代码大概运行过,却回答不了几个关键问题:
|
||||
|
||||
- 它们是否属于同一个请求,还是混入了同一 Session 的另一轮执行?
|
||||
- `success` 表示 Tool 正常返回、找到证据,还是结论已经被证据支持?
|
||||
- 哪一轮 Agent 发出了 Tool Call,后续草稿是否真的使用了这次结果?
|
||||
- `semantic check passed` 前是否完成了引用真实性检查?
|
||||
- 最终报告、Run 终态和 SSE outcome 是否一致?
|
||||
- Router、Agent 和 Guard 的 Token 能否与总数对上?
|
||||
|
||||
问题不在于日志太少。即使增加更多日志,仍然缺少稳定的身份、类型、顺序和关联关系。
|
||||
|
||||
| 普通日志擅长回答 | 审计必须回答 |
|
||||
|---|---|
|
||||
| 哪段代码报错了 | 哪一次 Run 在哪个决策阶段停止 |
|
||||
| 某方法耗时多久 | Router、Agent、Tool、Guard 各自消耗多少 |
|
||||
| 某次调用返回成功 | 调用是否产生证据,证据是否支持发布 |
|
||||
| 当前进程发生了什么 | 持久化后能否精确回放同一次执行 |
|
||||
| 给开发者阅读文本 | 给 API、评测和对账提供稳定结构 |
|
||||
|
||||
因此审计不是“更详细的日志”,而是一套独立的数据责任。
|
||||
|
||||
## 2. 设计目标:把非确定性执行变成可验证记录
|
||||
|
||||
这套审计设计需要满足六条不变量。
|
||||
|
||||
### 2.1 每条记录都有 exact Run 身份
|
||||
|
||||
`sessionId` 可以跨多轮复用,`runId` 只属于一次执行。Agent Step、Tool Invocation、Timeline 和最终结果必须落在同一个 `runId` 下。
|
||||
|
||||
### 2.2 决定在发生的位置被记录
|
||||
|
||||
Router 记录路由决定,ToolBoundary 记录真实 Tool 调用,Guard 记录校验结论,Release 记录最终发布决定。审计层不应在事后根据日志文本猜测发生了什么。
|
||||
|
||||
### 2.3 先后顺序可以稳定重放
|
||||
|
||||
同一个 Run 内,Timeline 使用单调 `sequence_no` 表达决定顺序。回放不依赖不同线程日志的打印时间,也不依赖数据库自增 ID 恰好连续。
|
||||
|
||||
### 2.4 不同记录只承担一种责任
|
||||
|
||||
Run 保存终态摘要,Timeline 保存决策脊柱,Agent Step 保存模型轮次,Tool Invocation 保存调用元数据,Reasoning 保存独立敏感正文。任何一种记录都不应成为无限扩张的万能 JSON。
|
||||
|
||||
### 2.5 长期记录必须有数据边界
|
||||
|
||||
普通 Trace 不永久复制完整 Prompt、Tool raw 和任意嵌套参数。缺少 Provider usage 时记录“不可用”,而不是伪造为 0;Reasoning 需要独立治理。
|
||||
|
||||
### 2.6 审计缺口必须可见,但不能随意改变业务语义
|
||||
|
||||
长期审计写入失败不应把一条本可安全完成的请求变成业务失败;与此同时,查询结果必须通过计数、对账状态和缺失标记暴露审计并不完整。
|
||||
|
||||
可以把这六条压缩成一句话:
|
||||
|
||||
> 审计记录必须属于精确执行、来自原始决定、顺序稳定、职责单一、内容有界,并且能够诚实表达缺失。
|
||||
|
||||
## 3. 整体设计:写入时分工,查询时聚合
|
||||
|
||||
审计没有设计成一个集中式拦截器抓取所有内容。真正知道“发生了什么”的组件,在自己的决策点产生结构化记录。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
REQ["一次 Chat 请求"] --> RUN["创建 exact Run"]
|
||||
|
||||
subgraph owners["决策所有者"]
|
||||
ROUTER["Router<br/>路由尝试与决定"]
|
||||
AGENT["Agent Hook<br/>模型步骤与 Reasoning"]
|
||||
TOOL["ToolBoundary<br/>真实 Tool 调用"]
|
||||
GUARD["Evidence / Semantic Guard<br/>验证决定"]
|
||||
RELEASE["Release<br/>发布结果"]
|
||||
end
|
||||
|
||||
RUN --> ROUTER
|
||||
RUN --> AGENT
|
||||
RUN --> TOOL
|
||||
RUN --> GUARD
|
||||
RUN --> RELEASE
|
||||
|
||||
ROUTER --> TL[("Decision Timeline")]
|
||||
AGENT --> STEP[("Agent Step")]
|
||||
AGENT --> RSN[("Reasoning Audit")]
|
||||
TOOL --> INV[("Tool Invocation")]
|
||||
AGENT --> TL
|
||||
TOOL --> TL
|
||||
GUARD --> TL
|
||||
RELEASE --> TL
|
||||
|
||||
RUN --> SUMMARY[("Diagnosis Run")]
|
||||
|
||||
SUMMARY --> QUERY["Trace 聚合查询"]
|
||||
TL --> QUERY
|
||||
STEP --> QUERY
|
||||
INV --> QUERY
|
||||
RSN -.->|"独立受限端点"| RQUERY["Reasoning 查询"]
|
||||
```
|
||||
|
||||
写入侧保持分工,读取侧由 Trace 服务形成一个面向复盘的 read model:
|
||||
|
||||
| 观察面 | 主要回答 |
|
||||
|---|---|
|
||||
| `diagnosis_run` | 这次执行最终怎样结束、用了多少资源、发布了什么 |
|
||||
| `diagnosis_trace_event` | 决策按什么顺序发生 |
|
||||
| `agent_step` | Diagnosis Agent 每一轮模型调用做了什么 |
|
||||
| `tool_invocation` | 哪些 Tool 被真实调用,状态、耗时和有界结果怎样 |
|
||||
| `agent_reasoning_audit` | Provider 是否返回 Reasoning,以及独立保存的敏感正文 |
|
||||
|
||||
这是一种“写入分散、读取聚合”的设计。分散不是随意写表,而是让记录权归属于最了解该决定的组件;聚合则把跨组件复杂度集中在 Trace 查询边界。
|
||||
|
||||
## 4. 为什么记录 typed decision,而不是事后解析日志
|
||||
|
||||
系统当前的 Timeline 使用稳定的 Phase 和 EventType,例如:
|
||||
|
||||
```text
|
||||
RUN_STARTED
|
||||
ROUTING_DECISION
|
||||
MODEL_TOKEN_USAGE
|
||||
AGENT_MODEL_STEP
|
||||
TOOL_INVOCATION
|
||||
EVIDENCE_GUARD_INITIAL
|
||||
SEMANTIC_GUARD_DECISION
|
||||
RELEASE_DECISION
|
||||
RUN_FINISHED
|
||||
```
|
||||
|
||||
这些名字表达的是领域事实,而不是实现细节。`SEMANTIC_GUARD_DECISION` 可以稳定表示语义门控的结果,即使以后底层模型客户端或方法名发生变化。
|
||||
|
||||
如果改为事后解析日志,会产生三个问题:
|
||||
|
||||
1. 日志文案改变就可能破坏审计;
|
||||
2. 多线程和异步输出使顺序不可靠;
|
||||
3. “Tool 执行成功”和“Tool 找到证据”很容易被同一个 `success` 混淆。
|
||||
|
||||
typed event 让状态语义在写入时就确定。它的代价是新增事件类型需要维护协议和测试,不能随意写一段字符串就算完成审计。
|
||||
|
||||
### 这不是 Event Sourcing
|
||||
|
||||
Timeline 虽然是追加式事件序列,但系统不会依靠它重建业务状态:
|
||||
|
||||
- `diagnosis_run` 仍保存当前终态和发布结果;
|
||||
- Agent Step 与 Tool Invocation 仍有自己的领域记录;
|
||||
- Timeline 用于解释“决定怎样发生”,不是整个系统的唯一事实源。
|
||||
|
||||
选择完整 Event Sourcing 会引入事件版本、状态重放、快照和迁移复杂度,当前 MVP 没有这项需求。
|
||||
|
||||
## 5. 为什么 Timeline 和领域明细必须分开
|
||||
|
||||
一种看起来更简单的方案,是把所有信息都写进 `diagnosis_trace_event.details`。这样只查询一张表就能得到全部内容。
|
||||
|
||||
但一张万能事件表会同时承担:
|
||||
|
||||
- 模型轮次和 Token 字段;
|
||||
- Tool 请求、状态和 RAG 检索详情;
|
||||
- Guard 决策;
|
||||
- Run 终态和最终内容;
|
||||
- Reasoning 与 assistant text。
|
||||
|
||||
最后所有约束都会退化为“不同 EventType 对应不同 JSON 结构”,数据库无法清楚表达关联、索引和数据治理。
|
||||
|
||||
当前设计把两类问题分开:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q1["什么时候做了什么决定?"] --> TL["Timeline<br/>有序、稳定、轻量"]
|
||||
Q2["这个对象的详细事实是什么?"] --> DETAIL["Run / Step / Tool<br/>领域字段与关联"]
|
||||
TL --> TRACE["Trace Read Model"]
|
||||
DETAIL --> TRACE
|
||||
```
|
||||
|
||||
例如,Timeline 的 `TOOL_INVOCATION` 只需要表达某次调用在决策链中的位置;`tool_invocation` 才负责 `step_id`、Tool 名、状态、耗时、bytes、错误码和 RAG 派生字段。
|
||||
|
||||
代价是查询时需要跨表聚合和计数对账,但数据所有权和长期演进更清楚。
|
||||
|
||||
## 6. 六个关键决策及其代价
|
||||
|
||||
### 决策一:Trace 使用独立只读 API
|
||||
|
||||
- **问题**:Chat SSE 面向实时用户体验,Trace 面向复盘、评测和排障,数据量与生命周期不同。
|
||||
- **选择**:通过 `GET /api/diagnosis/{sessionId}/trace?runId={runId}` 聚合持久化记录。
|
||||
- **未选**:把完整 Trace 嵌入 `/api/chat` 响应。
|
||||
- **代价**:客户端必须保存 metadata 中的 `runId`,需要第二次请求才能读取 Trace。
|
||||
|
||||
这项决策在最初 MVP Trace 建设时就已确定:可观测性不侵入 Chat 输出协议。
|
||||
|
||||
### 决策二:`runId` 而不是 `sessionId` 定义审计边界
|
||||
|
||||
- **问题**:同一 Session 连续两轮执行时,主记录覆盖而 Step/Tool 追加,曾导致 Trace、Feedback 和评分跨轮污染。
|
||||
- **选择**:`chat_session` 表示多轮目录,`diagnosis_run` 表示一次执行,所有明细按 `run_id` 隔离。
|
||||
- **未选**:继续在旧主表上追加字段,或用“最新一轮”推断明细归属。
|
||||
- **代价**:接口、反馈、评测和历史迁移都要理解 Session/Run 两级身份。
|
||||
|
||||
### 决策三:在原始决策点生成记录
|
||||
|
||||
- **问题**:集中式审计器无法准确知道 Router 为什么选择意图、Tool 是否真正执行、Guard 做出了什么领域判断。
|
||||
- **选择**:让 Application、Router、Agent Hook、ToolBoundary、Guard 和 Release 各自在原始位置记录 typed fact。
|
||||
- **未选**:请求结束后解析日志或根据最终结果反推中间过程。
|
||||
- **代价**:每个新决策路径都必须显式接入审计,遗漏不会被“万能拦截器”自动补齐。
|
||||
|
||||
### 决策四:Timeline 与领域明细分离
|
||||
|
||||
- **问题**:既需要一条简单的决策脊柱,也需要可查询的 Step、Tool 和 Run 字段。
|
||||
- **选择**:Timeline 记录顺序,领域表记录详情,读取时聚合。
|
||||
- **未选**:单一超大 Trace 表或全部 JSON event。
|
||||
- **代价**:需要维护 exact identity、顺序和 persisted/returned count 对账。
|
||||
|
||||
### 决策五:普通 Trace 只持久化有界信息
|
||||
|
||||
- **问题**:永久保存完整 Prompt、Tool raw、Reasoning 和参数,会放大敏感数据、体积和访问治理风险。
|
||||
- **选择**:Tool durable audit 只保存有界元数据;完整 Tool truth 只在当前 Run 的 canonical store 中短期存在;Reasoning 使用独立表和独立端点。
|
||||
- **未选**:为了排障方便,把所有上下文复制到 MySQL Trace。
|
||||
- **代价**:长期 Trace 不能还原所有原始正文;Reasoning 还需要单独的权限、保留和加密治理。
|
||||
|
||||
### 决策六:durable audit fail-open,canonical truth fail-closed
|
||||
|
||||
- **问题**:审计数据库不可用时是否让业务失败,以及证据真理源不可用时是否仍允许发布。
|
||||
- **选择**:长期审计写入失败只告警,不改变业务结果;用于当前 Run 验真的 canonical truth 缺失时不能继续证明证据。
|
||||
- **未选**:所有审计失败一律中断请求,或所有审计失败都静默忽略。
|
||||
- **代价**:业务成功不保证审计绝对完整,必须显式暴露 audit gap;canonical store 则成为发布安全的关键依赖。
|
||||
|
||||
## 7. 最重要的失败边界:同样叫“记录”,失败语义不同
|
||||
|
||||
Tool 执行后会形成两个用途完全不同的数据面:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TOOL["Tool 执行结果"] --> CAN["Canonical Truth<br/>当前 Run 的完整验真依据"]
|
||||
TOOL --> DUR["Durable Audit<br/>长期有界元数据"]
|
||||
|
||||
CAN -->|"可用"| GUARD["EvidenceGuard 验真"]
|
||||
CAN -->|"写入失败"| CLOSED["fail-closed<br/>不能证明就不能发布"]
|
||||
|
||||
DUR -->|"可用"| TRACE["长期 Trace 可回放"]
|
||||
DUR -->|"写入失败"| OPEN["fail-open<br/>告警并暴露审计缺口"]
|
||||
```
|
||||
|
||||
为什么不能统一成一种策略?
|
||||
|
||||
- canonical truth 参与当前业务决策。没有它,EvidenceGuard 无法独立验证 Agent 引用;继续发布会改变安全语义。
|
||||
- durable audit 服务长期复盘。它很重要,但让数据库短暂故障覆盖一条已经安全完成的业务结果,会把可观测性变成新的业务单点。
|
||||
|
||||
fail-open 不等于“失败无所谓”。审计记录器需要告警,Trace summary 需要对比持久化数量与返回数量,Token 账本需要给出 `tokens_reconciled`,Provider usage 缺失需要写明 `usage_available=false`。
|
||||
|
||||
## 8. 查询模型:普通 Trace 与敏感 Reasoning 分开
|
||||
|
||||
普通回放入口聚合:
|
||||
|
||||
```text
|
||||
chat_session metadata
|
||||
+ exact diagnosis_run
|
||||
+ agent_step metadata
|
||||
+ tool_invocation metadata
|
||||
+ ordered diagnosis_trace_event
|
||||
= DiagnosisTraceResponse
|
||||
```
|
||||
|
||||
Reasoning 使用独立入口:
|
||||
|
||||
```http
|
||||
GET /api/diagnosis/{sessionId}/trace/reasoning?runId={runId}
|
||||
```
|
||||
|
||||
它要求显式 `runId`,并校验该 Run 属于 path 中的 `sessionId`。Provider 没有返回 Reasoning 时也记录 `reasoning_available=false`,不能用 assistant text 或人工摘要伪造。
|
||||
|
||||
分开读取的目的不是让敏感正文“换一张表就安全”,而是为后续访问控制、加密和保留期限提供独立治理边界。
|
||||
|
||||
## 9. 当前设计没有假装解决所有问题
|
||||
|
||||
当前仍有四类明确债务:
|
||||
|
||||
1. **兼容查询债务**:普通 Trace 不传 `runId` 时仍可读取 latest run,无 run-backed 数据时还能回退 legacy `diagnosis_session`。这是迁移能力,不是推荐契约。
|
||||
2. **Reasoning 兼容镜像**:独立 Reasoning 表已经存在,但 `agent_step.thought` 仍可能保存 reasoning 或 assistant text 的兼容镜像,普通 Trace metadata-only 目标尚未完全收口。
|
||||
3. **敏感治理未完成**:Reasoning 端点的身份认证、权限模型、保留期限和加密仍在 ISS-015 中,不能把“分表”描述成完整安全闭环。
|
||||
4. **审计天然可能缺失**:durable audit fail-open 意味着必须把缺记录与业务失败分开诊断,不能默认数据库里没有就代表运行中没有发生。
|
||||
|
||||
这些不是文章末尾附带的 TODO,而是当前架构代价的一部分。审计系统的可信度来自诚实表达边界,不是把所有状态都包装成完整。
|
||||
|
||||
## 10. 方案对比:为什么没有选择看起来更简单的办法
|
||||
|
||||
| 方案 | 短期优势 | 没有采用的主要原因 |
|
||||
|---|---|---|
|
||||
| 只增加业务日志 | 实现快、开发者熟悉 | 缺少稳定身份、类型、关联和对账协议 |
|
||||
| Chat 响应携带完整 Trace | 一次请求拿到全部数据 | 污染 SSE 协议,扩大响应和敏感数据暴露 |
|
||||
| 一张万能 Trace 表 | 查询表面简单 | JSON 结构失控,领域约束、索引和治理困难 |
|
||||
| 完整 Event Sourcing | 理论上可重放全部状态 | 当前只需解释决策,引入事件版本和状态重建过重 |
|
||||
| 永久保存全部 raw | 排障最直接 | 敏感数据、成本和保留治理不可控 |
|
||||
| 所有审计失败都 fail-closed | 记录最完整 | 长期可观测性会成为业务可用性的单点 |
|
||||
| 所有审计失败都 fail-open | 业务最不易被阻断 | canonical truth 缺失时仍发布会破坏证据安全 |
|
||||
|
||||
## 11. 先记住这张图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
EXEC["非确定性 Agent 执行"] --> ID["exact Run 身份"]
|
||||
ID --> DEC["原始决策点产生 typed record"]
|
||||
DEC --> SPLIT["Timeline 与领域明细分工"]
|
||||
SPLIT --> BOUND["普通审计有界<br/>Reasoning 独立"]
|
||||
BOUND --> READ["Trace 聚合、计数与 Token 对账"]
|
||||
READ --> EXPLAIN["可以回放,也能说明缺口"]
|
||||
```
|
||||
|
||||
用一句话概括:
|
||||
|
||||
> 这套审计设计不是复制运行内容,而是让每个决策所有者在 exact Run 内留下有界、可排序、可关联的结构化事实,再通过独立查询模型把这些事实聚合成可回放、可对账的证据链。
|
||||
|
||||
## 12. 事实来源与延伸阅读
|
||||
|
||||
- [从一次诊断 Run 看审计系统如何记录决策](从一次诊断Run看审计系统如何记录决策.md):用真实 SUCCESS Run 查看这些设计怎样落到记录中;
|
||||
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md):当前身份、生命周期和查询契约;
|
||||
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立 Trace API 的初始问题与决策;
|
||||
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线证据和 exact Run 决策;
|
||||
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Harness-native Agent/Tool audit 与 canonical/durable 失败边界;
|
||||
- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Reasoning 当前完成度和剩余治理缺口。
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# 审计设计演进:从 Session Trace 到 Exact Run
|
||||
|
||||
今天看到的审计系统包含 exact Run、决策 Timeline、Agent Step、Tool Invocation、Token 对账和独立 Reasoning。它看起来像一套预先设计好的完整架构,但真实过程并不是这样。
|
||||
|
||||
这套设计是被一系列具体问题推出来的:先是答案无法回放,然后是 Tool 记录语义不一致,再后来是同一 Session 多轮串线,最后 Single ReAct 重构又让旧审计链失去所有权。每次变化都解决了当时最紧迫的问题,也留下了下一阶段才看得见的新缺口。
|
||||
|
||||
这篇文章不按提交逐条记流水账,而是解释六次能力跃迁。第一次阅读只看第 1、2、4、7 和 10 节,就能抓住主线。
|
||||
|
||||
## 1. 先看完整演进
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["只能看到最终答案"] --> B["01 可查询 Trace<br/>答案可以回放"]
|
||||
B --> C["02 Evidence 与质量语义<br/>记录开始可比较"]
|
||||
C --> D["03 exact Run<br/>多轮不再串线"]
|
||||
D --> E["04 Canonical Tool Truth<br/>事实与长期审计分层"]
|
||||
E --> F["05 Single ReAct 审计重建<br/>责任回到 Harness"]
|
||||
F --> G["06 Timeline / Token / Reasoning<br/>决策、成本与敏感正文分治"]
|
||||
```
|
||||
|
||||
六个阶段分别改变了审计系统回答问题的能力:
|
||||
|
||||
| 阶段 | 审计开始能够回答 |
|
||||
|---|---|
|
||||
| 01 可查询 Trace | 一次诊断大致执行过哪些 Agent Step 和 Tool |
|
||||
| 02 语义加固 | Tool 成功、无证据、失败以及 Prompt/规则版本分别是什么 |
|
||||
| 03 exact Run | 这些记录究竟属于同一 Session 中的哪一轮 |
|
||||
| 04 Canonical Tool Truth | 当前发布校验依赖的完整事实,与长期审计元数据如何分开 |
|
||||
| 05 Single ReAct 重建 | 新 Harness 中谁负责记录 Agent 和 Tool,审计失败如何处理 |
|
||||
| 06 决策、成本与敏感正文 | 决策顺序、模型成本、Reasoning 可用性和治理缺口是什么 |
|
||||
|
||||
这不是从“简单”机械升级到“复杂”。每一步都在重新定义审计的边界。
|
||||
|
||||
## 2. 第一阶段:先让最终答案可以被回放
|
||||
|
||||
### 当时的问题
|
||||
|
||||
系统已经能执行诊断并返回结果,但演示者只能展示最终答案。面试官如果继续问“Agent 调了哪些 Tool、Verifier 做了什么、证据在哪里”,只能翻数据库或日志临时解释。
|
||||
|
||||
### 当时的选择
|
||||
|
||||
2026-07-03 的 MVP Trace 变更增加了独立只读 API:
|
||||
|
||||
```http
|
||||
GET /api/diagnosis/{sessionId}/trace
|
||||
```
|
||||
|
||||
它直接聚合已有持久化数据:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
API["Trace API"] --> SESSION[("diagnosis_session")]
|
||||
API --> STEP[("agent_step")]
|
||||
API --> TOOL[("tool_invocation")]
|
||||
SESSION --> RESP["聚合 Trace Response"]
|
||||
STEP --> RESP
|
||||
TOOL --> RESP
|
||||
```
|
||||
|
||||
这个阶段没有改变 Chat 主流程,也没有新增数据库表。目标很克制:先把已经存在的执行记录变成一个稳定、只读、可演示的查询入口。
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有把 Trace 塞进 `/api/chat` 响应;
|
||||
- 没有为了演示制造全离线假运行时;
|
||||
- 没有先设计通用事件平台;
|
||||
- 没有重写已有 Agent 和 Tool 持久化。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
系统第一次可以从最终答案回到 Agent Step 和 Tool 记录,演示流程也形成了“Chat → Trace → Feedback”的闭环。
|
||||
|
||||
### 新暴露的问题
|
||||
|
||||
Trace 能查出来,不代表记录语义已经可靠:
|
||||
|
||||
- 不同 Tool 的持久化路径并不统一;
|
||||
- `success`、无结果和失败的含义不稳定;
|
||||
- Trace 仍以 `sessionId` 为唯一身份;
|
||||
- 记录能展示,但还不能稳定支撑评测。
|
||||
|
||||
第一阶段解决的是“有没有回放入口”,不是“审计是否已经正确”。
|
||||
|
||||
## 3. 第二阶段:从“有记录”走向“有稳定语义”
|
||||
|
||||
### 当时的问题
|
||||
|
||||
评测系统准备使用 Tool Trace 计算证据质量时,发现不同 Tool 对状态的表达不一致:有的调用统一走 recorder,有的在本地 helper 中写表;无命中、失败和降级路径也可能被混成一个模糊结果。
|
||||
|
||||
与此同时,AIOps 还是一条独立入口。它能执行自动诊断,却没有与 Chat 相同的 Trace 故事。
|
||||
|
||||
### 当时的选择
|
||||
|
||||
2026-07-04 到 07-09 的一组变化没有急着增加新表,而是先收紧契约:
|
||||
|
||||
1. 统一 Tool recorder 和 evidence summary 语义;
|
||||
2. 区分调用成功、没有证据和真正失败;
|
||||
3. 覆盖 Verifier 缺失、非法输出和 degraded path;
|
||||
4. 让 AIOps 复用已有 Trace 基础设施;
|
||||
5. 用 compact `prompt_audit` 和 Gatekeeper rule-set version 记录决策版本,不保存完整 Prompt。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
TOOL["Tool 调用"] --> S1["执行是否成功"]
|
||||
S1 --> S2["是否找到 Evidence"]
|
||||
S2 --> S3["Verifier / Gatekeeper 如何判断"]
|
||||
S3 --> S4["使用哪一版 Prompt / Rule"]
|
||||
S4 --> EVAL["稳定 Trace 与确定性评测"]
|
||||
```
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有把完整 Prompt 存进 Trace;
|
||||
- 没有使用 Prompt 内容 hash 作为频繁变化的版本协议;
|
||||
- 没有引入 LLM-as-judge 代替确定性 fixture;
|
||||
- 没有为 AIOps 另建一套 Trace 数据模型。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
审计记录开始拥有可比较的语义。评测可以区分“Tool 正常但无证据”和“Tool 本身失败”,也能知道某次判断使用了哪版 Prompt 与规则。
|
||||
|
||||
AIOps 当时被定义为 Chat 的兄弟入口:不同触发方式,共享同一套持久化 Trace。这在当时是合理的复用选择。
|
||||
|
||||
### 新暴露的问题
|
||||
|
||||
两个入口共享 Trace,并没有解决最根本的身份问题。只要同一个 `sessionId` 连续执行多轮,所有 Step 和 Tool 仍会混到一起。
|
||||
|
||||
而且审计维度越丰富,跨轮污染的破坏越大:不仅回放错误,Feedback、Verifier、评分和案例沉淀都会读错对象。
|
||||
|
||||
## 4. 第三阶段:Session 不是一次执行,必须引入 exact Run
|
||||
|
||||
### 触发它的真实故障
|
||||
|
||||
2026-07-10 的 E2E 使用同一个 `sessionId` 连续请求两轮。Redis 多轮上下文表现正常,但 MySQL 出现了另一种现实:
|
||||
|
||||
```text
|
||||
diagnosis_session
|
||||
query / answer 被后一轮覆盖
|
||||
|
||||
agent_step
|
||||
第一轮 + 第二轮持续追加
|
||||
|
||||
tool_invocation
|
||||
第一轮 + 第二轮持续追加
|
||||
```
|
||||
|
||||
主记录表达最新一轮,明细却表达多轮混合。Trace 不再代表某一次诊断,Feedback 也不知道评价的是哪一轮结果。
|
||||
|
||||
### 当时的选择
|
||||
|
||||
系统没有只在旧表上补一个轮次字段,而是拆分两种生命周期:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["chat_session<br/>多轮对话目录"] --> R1["diagnosis_run 1<br/>第一轮执行"]
|
||||
S --> R2["diagnosis_run 2<br/>第二轮执行"]
|
||||
R1 --> D1["step / tool by run_id"]
|
||||
R2 --> D2["step / tool by run_id"]
|
||||
```
|
||||
|
||||
核心决策包括:
|
||||
|
||||
- `sessionId` 表示多轮会话;
|
||||
- `runId` 成为正式 API 字段,表示一次可回放执行;
|
||||
- `agent_step` 和 `tool_invocation` 增加 `run_id`;
|
||||
- Trace、Feedback、Evaluation 和案例来源优先绑定 Run;
|
||||
- 指定 `runId` 时必须校验它属于 path 中的 `sessionId`。
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有继续让 `diagnosis_session` 同时承担会话与执行;
|
||||
- 没有尝试根据时间戳把历史混合 Trace 伪造成多个真实 Run;
|
||||
- 没有在这个阶段引入 `diagnosis_trace_event`;
|
||||
- 没有立即删除旧表和旧客户端兼容。
|
||||
|
||||
“当时没有引入 Timeline”很重要。这个阶段只解决身份隔离,复用 `agent_step` 与 `tool_invocation` 表达 Trace;typed Timeline 是后续才增长出来的能力。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
两轮 E2E 得到同一个 Session 下两个不同 Run:
|
||||
|
||||
- run1 exact Trace 只返回 run1;
|
||||
- run2 exact Trace 只返回 run2;
|
||||
- step/tool mixed row check 为 0;
|
||||
- 多轮对话上下文仍然连续。
|
||||
|
||||
审计终于拥有稳定的最小单位:**一次 Run 可以独立回放、评分、反馈和沉淀。**
|
||||
|
||||
### 新暴露的问题
|
||||
|
||||
Run 隔离修复了“记录属于谁”,但没有回答“Tool 的完整事实由谁保管”。旧 JPA ToolInvocation 更像长期 preview,无法成为 EvidenceGuard 的独立验真来源。
|
||||
|
||||
兼容策略也留下了债务:不传 `runId` 时读取 latest run、旧表保留、历史 mixed trace 只能映射为 compatibility run。
|
||||
|
||||
## 5. 第四阶段:长期 Trace 不能同时充当证据真理源
|
||||
|
||||
### 当时的问题
|
||||
|
||||
Single ReAct + Harness 设计要求 EvidenceGuard 独立验证 Agent 引用。如果 Guard 只能读取 Agent 已经看过的裁剪结果,等于让模型用自己的输入证明自己。
|
||||
|
||||
旧 `tool_invocation` 又不能直接升级为完整事实存储:长期保存 raw response 会扩大敏感数据、存储体积和保留治理范围。
|
||||
|
||||
### 当时的选择
|
||||
|
||||
2026-07-21 的 ToolBoundary 设计把一次 Tool 调用拆成不同用途的数据面:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RAW["Tool Raw Result"] --> CAN["Canonical Invocation<br/>Redis 短期完整真相"]
|
||||
CAN --> OBS["Agent Observation<br/>有界投影视图"]
|
||||
CAN --> GUARD["EvidenceGuard<br/>独立验真"]
|
||||
CAN -.-> DUR["Durable Audit<br/>MySQL 长期元数据"]
|
||||
```
|
||||
|
||||
Canonical Invocation 保存当前 Run 所需的 request、raw response、Agent projection、状态和时间,并拥有 `PROJECTING / READY / ERROR` 生命周期。Agent 只能获得投影结果,不能访问 Redis 或完整 raw。
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有让 JPA `tool_invocation` 保存全部 raw;
|
||||
- 没有让 Agent 获取 Redis key 或 canonical record;
|
||||
- 没有把无证据和调用失败合并;
|
||||
- 没有让 raw 超限时静默截断后继续充当完整真相。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
系统第一次明确区分:
|
||||
|
||||
- **当前 Run 的验真事实**:完整、短期、Harness-only;
|
||||
- **Agent 的推理观察**:有界、稳定、可消费;
|
||||
- **长期审计记录**:适合复盘,但不复制完整 raw。
|
||||
|
||||
这也奠定了后来的失败语义:canonical store 缺失时无法验真,需要 fail-closed;durable audit 写入失败不应改变 Tool observation,可以 fail-open。
|
||||
|
||||
### 新暴露的问题
|
||||
|
||||
新 ToolBoundary 有了 canonical store,却尚未接回长期 `tool_invocation` 审计。与此同时,旧 recorder 依赖 ThreadLocal 和旧 Tool 副作用,不能直接带进新 Harness。
|
||||
|
||||
换句话说,新架构已经有了“事实”,但一度失去了“长期记录”。
|
||||
|
||||
## 6. 第五阶段:Single ReAct 后,审计必须重新确定所有权
|
||||
|
||||
### 当时的问题
|
||||
|
||||
旧系统的审计依附于多 Agent、`@Tool`、ThreadLocal 和旧 `AgentLoggingHook`。Single ReAct 清理删除 Planner、Executor、Verifier、Composer 和独立 AIOps 入口后,继续保留这些记录路径会出现三个问题:
|
||||
|
||||
- 审计逻辑仍依赖已经退出生产链的结构;
|
||||
- Tool backend 可能通过旧 annotation/recorder 产生重复副作用;
|
||||
- Agent hook 可能长期保存正文和 Thought,违反新的数据边界。
|
||||
|
||||
### 当时的选择
|
||||
|
||||
2026-07-22 的收尾没有给旧 recorder 增加更多兼容分支,而是重建 Harness-native 审计:
|
||||
|
||||
| 旧方式 | 新方式 |
|
||||
|---|---|
|
||||
| 多 Agent 名称和旧 Hook 决定步骤语义 | `HarnessAgentAuditHook` 记录单体 Diagnosis Agent 步骤 |
|
||||
| ThreadLocal 回退传递身份 | exact RunContext / run-scoped tracker |
|
||||
| Tool 自身带 recorder 副作用 | ToolBoundary 统一触发 durable audit port |
|
||||
| Agent 输入输出正文与 Thought 混入普通记录 | 普通 Step 以 roles、count、Tool names、bytes 等 metadata 为主 |
|
||||
| JPA 审计与 canonical truth 概念混合 | Redis canonical fail-closed,JPA durable audit fail-open |
|
||||
| Chat 与 AIOps 两条公开诊断入口 | 统一 `/api/chat` + Intent Router |
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有把 `/api/ai_ops` 迁移成第二个 Harness use case;
|
||||
- 没有保留旧 `@Tool` 与新 ACI 双注册;
|
||||
- 没有让 audit DB 故障直接改变 Agent Observation;
|
||||
- 没有为了兼容继续使用旧多 Agent 身份。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
审计责任终于与当前执行架构一致:Agent Step 由 Agent Hook 负责,Tool durable audit 由 ToolBoundary 负责,最终 Run 由应用和 Release 收口。
|
||||
|
||||
这一步也说明演进不是只做加法。07-04 保留 AIOps 兄弟入口是当时的合理方案;07-22 删除它,则是“唯一入口、唯一 Harness”新目标下的必要替换。
|
||||
|
||||
### 新暴露的问题
|
||||
|
||||
metadata-only 解决了普通 Trace 的泄漏风险,却无法满足“为什么 Agent 选择这个 Tool”的深度审计需求。Run 虽然有 Step 和 Tool,也仍缺一条统一的决策 Timeline 和完整模型成本账本。
|
||||
|
||||
## 7. 第六阶段:从执行记录扩展到决策、成本与 Reasoning
|
||||
|
||||
### 新架构暴露的新问题
|
||||
|
||||
真实 E2E 出现过一条失败 Run:Diagnosis Agent 重复调用 `lookup_knowledge`,累计 12 次 Tool、45,087 Token 后才进入 `BUDGET_EXHAUSTED`;Evidence Repair 还出现过结构解析失败。
|
||||
|
||||
旧 Trace 可以看见许多 Step 和 Tool,却不容易直接回答:
|
||||
|
||||
- Agent 为什么继续查询,什么时候没有信息增益;
|
||||
- Token 消耗来自 Router、Agent、Repair 还是 SemanticGuard;
|
||||
- Evidence、Semantic 和 Release 决策按什么顺序发生;
|
||||
- Provider 是否真的返回 Reasoning,还是系统只保存了 assistant text。
|
||||
|
||||
### 当前选择
|
||||
|
||||
2026-07-23 之后,审计继续分化为三个正交能力:
|
||||
|
||||
1. **typed Decision Timeline**:用 RUN、ROUTING、AGENT、TOOL、EVIDENCE、SEMANTIC、RELEASE 阶段记录决定顺序;
|
||||
2. **Model Token Ledger**:按组件和轮次记录 usage,在 Run 结束执行总额对账;
|
||||
3. **Reasoning Audit**:独立保存 Provider Reasoning 与 assistant text,普通 Trace 只暴露可用性和 bytes 等 metadata。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RUN["exact Run"] --> TL["Decision Timeline<br/>决定如何发生"]
|
||||
RUN --> STEP["Agent Step<br/>模型轮次"]
|
||||
RUN --> TOOL["Tool Invocation<br/>外部行为"]
|
||||
RUN --> TOKEN["Token Ledger<br/>组件成本"]
|
||||
RUN --> RSN["Reasoning Audit<br/>敏感正文"]
|
||||
TL --> TRACE["普通 Trace"]
|
||||
STEP --> TRACE
|
||||
TOOL --> TRACE
|
||||
TOKEN --> TRACE
|
||||
RSN -.-> RAPI["独立受限查询"]
|
||||
```
|
||||
|
||||
### 没有选择什么
|
||||
|
||||
- 没有把 Reasoning 塞回普通 Trace 或 SSE;
|
||||
- 没有在 Provider 不返回 Reasoning 时伪造思考过程;
|
||||
- 没有只在 Run 结束写一个无法解释的 Token 总数;
|
||||
- 没有把 Timeline 变成用于重建业务状态的完整 Event Sourcing。
|
||||
|
||||
### 解决了什么
|
||||
|
||||
当前 Trace 不仅能展示“发生过哪些调用”,还可以解释决定顺序、组件成本和发布依据。`tokens_reconciled` 能检查分项与总额,`usage_available=false` 能区分真实零消耗与供应商未返回 usage。
|
||||
|
||||
### 仍未完成什么
|
||||
|
||||
- Reasoning 访问控制、保留期限和加密尚未闭环;
|
||||
- 非 DeepSeek Provider 和 reasoning unavailable live 样本仍需补充;
|
||||
- `agent_step.thought` 仍存在兼容镜像语义;
|
||||
- latest-run 与 legacy Trace fallback 仍然存在;
|
||||
- durable audit fail-open 意味着业务成功仍可能伴随审计缺口。
|
||||
|
||||
演进到这里,系统不再把“记录越多”当作审计完成,而开始治理哪些记录应该存在、谁能读取、缺失时如何表达。
|
||||
|
||||
## 8. 哪些设计被保留,哪些被替换
|
||||
|
||||
| 早期设计 | 当前结果 | 判断 |
|
||||
|---|---|---|
|
||||
| Trace 与 Chat 响应分离 | 保留独立只读 Trace API | 核心边界正确,持续保留 |
|
||||
| 聚合持久化 Step/Tool | 保留,并增加 Run、Timeline 和对账 | 基础能力被扩展 |
|
||||
| Session 作为 Trace 身份 | 改为 exact Run,Session 只作目录 | 被真实串线问题替换 |
|
||||
| AIOps 作为兄弟入口 | 删除,统一 `/api/chat` | 阶段性合理,后续架构收敛后退出 |
|
||||
| ToolInvocation 同时承担事实与审计 | 拆为 canonical truth 与 durable audit | 责任过载,被数据分层替换 |
|
||||
| 旧 ThreadLocal/Tool 副作用 recorder | 改为 Harness-native Hook、Tracker 和 Audit Port | 与当前执行架构重新对齐 |
|
||||
| 普通 Step 保存 Thought/正文 | 转向 metadata + 独立 Reasoning | 安全边界收紧,但兼容镜像尚未完全清除 |
|
||||
| 一个 Token 总数 | 组件轮次账本 + Run 对账 | 从统计值升级为可解释成本 |
|
||||
|
||||
演进中真正稳定下来的不是某张表,而是三条原则:执行身份要精确、数据责任要分层、缺失和失败要诚实可见。
|
||||
|
||||
## 9. 为什么不能一开始就设计成现在这样
|
||||
|
||||
站在今天回看,很容易认为最初就应该有 Run、Timeline、Reasoning 和 canonical store。但当时缺少三个关键事实:
|
||||
|
||||
1. 没有多轮 E2E,就无法证明 Session 身份会造成怎样的跨轮污染;
|
||||
2. 没有 EvidenceGuard,就无法看清长期 Tool audit 与当前验真 truth 的责任冲突;
|
||||
3. 没有真实 Token 和 Reasoning 样本,就无法确定哪些字段应该对账、哪些正文必须隔离。
|
||||
|
||||
过早一次性设计完整审计平台,很可能得到通用事件总线、万能 JSON 和全量 raw 存储,却没有解决 MVP 当时最重要的问题。
|
||||
|
||||
这套演进采用的是另一种方式:先围绕可观察故障收紧最小契约,再让新的真实运行暴露下一层边界。
|
||||
|
||||
## 10. 用一张图记住设计为什么变成现在这样
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P1["答案无法解释"] --> D1["独立 Trace API"]
|
||||
P2["Tool 状态语义不一致"] --> D2["Evidence / Quality 契约"]
|
||||
P3["同一 Session 多轮串线"] --> D3["exact Run"]
|
||||
P4["长期审计不能独立验真"] --> D4["Canonical Truth / Durable Audit 分层"]
|
||||
P5["旧审计依赖旧多 Agent 与 ThreadLocal"] --> D5["Harness-native Audit"]
|
||||
P6["决策顺序、成本和 Reasoning 不可解释"] --> D6["Timeline / Token / Reasoning 分治"]
|
||||
|
||||
D1 --> NOW["当前可回放、可对账、有数据边界的审计系统"]
|
||||
D2 --> NOW
|
||||
D3 --> NOW
|
||||
D4 --> NOW
|
||||
D5 --> NOW
|
||||
D6 --> NOW
|
||||
```
|
||||
|
||||
一句话概括:
|
||||
|
||||
> 审计系统最初只是把已有 Session 记录聚合出来;真实 E2E 先迫使它建立稳定 Evidence 语义,再用 exact Run 修复多轮身份,随后 Single ReAct 重构又把 Tool 真理源与长期审计分开,最终才发展出统一 Timeline、Token 对账和独立 Reasoning 治理。
|
||||
|
||||
## 11. 事实来源与延伸阅读
|
||||
|
||||
- `devflow/projects/2026-07-03-mvp-demo-trace-acceptance/`:独立只读 Trace API 的起点;
|
||||
- `devflow/projects/2026-07-04-evidence-trace-hardening/`:Evidence 状态和 degraded path 契约加固;
|
||||
- `devflow/projects/2026-07-04-aiops-traceable-diagnosis-entry/`:早期 AIOps 兄弟入口与共享 Trace;
|
||||
- `devflow/projects/2026-07-09-interview-demo-quality-audit/`:Prompt/Rule version 与确定性质量审计;
|
||||
- `devflow/projects/2026-07-10-session-run-trace-isolation/`:多轮串线、exact Run 决策和两轮 E2E;
|
||||
- `devflow/projects/2026-07-21-single-react-tool-invocation-store/`:canonical Tool truth 与 durable audit 分层;
|
||||
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/`:Single ReAct 审计所有权重建;
|
||||
- [ISS-015 诊断运行质量与 Reasoning 审计收敛](../../issues/active/ISS-015-diagnosis-runtime-quality-and-reasoning-audit.md):Timeline、Token、Reasoning 和当前缺口;
|
||||
- [审计主设计](审计系统设计-从调试日志到可回放的决策证据.md):当前设计不变量与关键取舍;
|
||||
- [真实 Run 案例](从一次诊断Run看审计系统如何记录决策.md):当前审计能力的具体回放方式。
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Session、Run、Trace 隔离:一次身份建模错误的修复
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**性质**:MVP 工程问题与架构决策复盘
|
||||
**结论状态**:Session / Run 身份模型沿用至当前架构
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题到底是什么
|
||||
|
||||
一句话定义:**系统用同一个 `sessionId`,同时标识“多轮对话”和“一次诊断执行”,导致一次 Trace 不再对应一次真实执行。**
|
||||
|
||||
问题由一个两轮 E2E 暴露。用户在同一会话中连续发起两次请求:
|
||||
|
||||
```text
|
||||
Round 1:诊断支付接口超时
|
||||
Round 2:基于上一轮结论,列出还缺哪些证据
|
||||
```
|
||||
|
||||
两轮复用 `sessionId` 是正确的,因为第二轮需要上一轮上下文。但当时持久化和 Trace 查询也只使用 `sessionId`:
|
||||
|
||||
- `diagnosis_session` 是覆盖写,第二轮把第一轮的 query、answer、status 覆盖掉;
|
||||
- `agent_step` 和 `tool_invocation` 是追加写,两轮明细累积在同一个 `sessionId` 下;
|
||||
- Trace API 再按 `sessionId` 聚合主表和明细。
|
||||
|
||||
结果不是简单的“重复数据”,而是一个系统中从未真实发生过的混合执行:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R1["Round 1<br/>query A / steps A / tools A"] --> SID["同一个 sessionId"]
|
||||
R2["Round 2<br/>query B / steps B / tools B"] --> SID
|
||||
|
||||
SID --> Main["diagnosis_session<br/>只剩 query B / answer B"]
|
||||
SID --> Steps["agent_step<br/>steps A + steps B"]
|
||||
SID --> Tools["tool_invocation<br/>tools A + tools B"]
|
||||
|
||||
Main --> Trace["混合 Trace"]
|
||||
Steps --> Trace
|
||||
Tools --> Trace
|
||||
|
||||
Trace --> Error["无法回答:<br/>哪组证据支持了哪次回答?"]
|
||||
```
|
||||
|
||||
这个错误会沿数据链继续放大:
|
||||
|
||||
| 消费方 | 错误结果 |
|
||||
|---|---|
|
||||
| Trace 回放 | 一条 Trace 混合两轮步骤和 Tool |
|
||||
| Verifier / Gatekeeper | 当前轮可能读到上一轮证据 |
|
||||
| Evaluation | 评分对象和证据集合不再属于同一次执行 |
|
||||
| Feedback | 无法确定用户评价的是哪一轮回答 |
|
||||
| CaseLibrary | 可能把反馈沉淀到错误的 query/answer 上 |
|
||||
|
||||
因此,核心问题不是某个 Repository 的更新方式,而是**会话边界被错误地当成了证据与审计边界**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计必须守住什么
|
||||
|
||||
修复前先定义四条不变量。后续方案不是凭表结构偏好选择,而是看能否同时满足这些约束。
|
||||
|
||||
### 不变量 1:一次执行只有一个稳定身份
|
||||
|
||||
从请求被接受到最终 `SUCCESS / FALLBACK / FAILED / CANCELLED`,必须有一个不可变 ID。模型步骤、Tool 调用、预算、最终答案和反馈都属于它。
|
||||
|
||||
### 不变量 2:一条 Trace 只描述一次执行
|
||||
|
||||
Trace 中的 Run、AgentStep、ToolInvocation 和生命周期事件必须使用同一个 exact ID 聚合,不能依赖时间邻近或“最新一条”猜测归属。
|
||||
|
||||
### 不变量 3:上下文连续不等于执行合并
|
||||
|
||||
同一个 Session 可以包含多个 Run。后一个 Run 可以读取前一轮安全发布结果,但不能继承前一轮的 Tool、Trace、评分或失败状态。
|
||||
|
||||
### 不变量 4:兼容不能伪造精度
|
||||
|
||||
旧客户端可以有迁移期 fallback,旧数据也可以保留;但系统必须让歧义可观察,不能把无法恢复的历史混合数据伪装成精确多轮记录。
|
||||
|
||||
这四条不变量共同导出一个结论:系统需要两个身份,而不是给 `sessionId` 增加更多解释。
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么不是在旧表上继续修
|
||||
|
||||
设计阶段考虑的不是“拆表还是不拆表”这一个问题,而是如何建立稳定的执行边界。
|
||||
|
||||
| 候选方案 | 能解决什么 | 为什么没有选择 |
|
||||
|---|---|---|
|
||||
| 继续复用 `sessionId`,修正覆盖逻辑 | 避免主表被覆盖 | 明细仍无法区分轮次,根因未解决 |
|
||||
| 增加 `round_no` | 可以表示第几轮 | 并发请求、重试和多个执行入口下顺序不稳定;外部引用仍需复合身份 |
|
||||
| 按时间窗口拆分历史 Step/Tool | 无需改协议 | 时间不能证明归属,会制造看似精确的错误 Trace |
|
||||
| 每次请求插入一条新的 `diagnosis_session` | 形成一行一次执行 | 实际上已经引入 Run 概念,但名称和 Session 生命周期仍混淆,也缺少会话主实体 |
|
||||
| 拆分 `chat_session` 与 `diagnosis_run` | 显式表达一对多生命周期 | 需要 Schema、API 和客户端迁移,但能满足全部不变量 |
|
||||
|
||||
最终选择最后一种。它的判断依据不是“范式更规范”,而是只有它能让对话连续性和执行可审计性同时成立。
|
||||
|
||||
---
|
||||
|
||||
## 4. 核心设计
|
||||
|
||||
目标模型是一个清晰的一对多关系:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Client["Client"] --> Session["chat_session<br/>sessionId:多轮对话目录"]
|
||||
Session --> Run1["diagnosis_run<br/>runId 1:一次执行"]
|
||||
Session --> Run2["diagnosis_run<br/>runId 2:另一次执行"]
|
||||
|
||||
Run2 --> Step["agent_step<br/>模型步骤 metadata"]
|
||||
Run2 --> Tool["tool_invocation<br/>Tool 审计 metadata"]
|
||||
Run2 --> Event["diagnosis_trace_event<br/>生命周期 Timeline"]
|
||||
Run2 --> Reasoning["agent_reasoning_audit<br/>受限原文"]
|
||||
|
||||
Run2 --> Trace["普通 Trace API"]
|
||||
Step --> Trace
|
||||
Tool --> Trace
|
||||
Event --> Trace
|
||||
Reasoning --> Audit["独立 Reasoning API"]
|
||||
```
|
||||
|
||||
这里有三个不同的职责:
|
||||
|
||||
- `chat_session` 回答“哪些 Run 属于同一段对话”;
|
||||
- `diagnosis_run` 回答“这次请求的输入、状态、结果和资源消耗是什么”;
|
||||
- Trace 回答“这个 Run 具体经历了什么”,它是按 Run 聚合的读模型。
|
||||
|
||||
---
|
||||
|
||||
## 5. 六项关键决策
|
||||
|
||||
### 决策 1:引入正式的 `runId`
|
||||
|
||||
**选择**:每次有效 Chat 执行创建新的 `runId`,并通过 SSE metadata 返回 `sessionId + runId`。
|
||||
|
||||
**理由**:Run 必须能被 API、数据库、日志、Feedback 和评测独立引用。数据库自增 ID 不适合作为外部协议;轮次编号又不能稳定处理并发和重试。
|
||||
|
||||
**代价**:客户端必须保存并向后续 Trace/Feedback 请求传递 `runId`。
|
||||
|
||||
**边界**:`runId` 是不透明标识。早期实现使用 `run-` + UUID,后续格式发生过演进,客户端不得解析其前缀或长度。
|
||||
|
||||
### 决策 2:拆分会话态与运行态
|
||||
|
||||
**选择**:新增 `chat_session` 和 `diagnosis_run`,旧 `diagnosis_session` 停止承载新的运行写入。
|
||||
|
||||
**理由**:两者生命周期不同。
|
||||
|
||||
| 对象 | 保存内容 | 更新特点 |
|
||||
|---|---|---|
|
||||
| `chat_session` | 会话状态、轮次数、最近活动时间等目录信息 | 跨多轮持续更新 |
|
||||
| `diagnosis_run` | 单次 query、answer、终态、intent、release outcome、预算 | 一次执行内从 RUNNING 走向唯一终态 |
|
||||
|
||||
完整多轮正文没有因为拆表就复制到 MySQL。身份拆分解决的是审计归属,不应顺带扩大长期数据保存范围。
|
||||
|
||||
### 决策 3:Trace 是 Run 的聚合视图,不另造身份
|
||||
|
||||
**选择**:第一阶段复用 `agent_step` 和 `tool_invocation`,增加 `run_id`;不为了修复隔离问题再创建一个独立 `traceId`。
|
||||
|
||||
**理由**:隔离所缺的是执行外键,不是第三套身份。引入 `traceId` 只会产生 `sessionId / runId / traceId` 的映射问题。
|
||||
|
||||
后续单 Agent + Harness 重构新增 `diagnosis_trace_event`,用于表达统一生命周期 Timeline。这是 Run 下的新明细,不是新的聚合根,也没有改变 `runId` 的边界。
|
||||
|
||||
### 决策 4:exact Run 是目标协议,latest Run 只是迁移桥梁
|
||||
|
||||
**选择**:目标查询使用:
|
||||
|
||||
```http
|
||||
GET /api/diagnosis/{sessionId}/trace?runId={runId}
|
||||
```
|
||||
|
||||
服务端同时验证 Run 存在且属于 path 中的 Session,防止跨 Session 串读。
|
||||
|
||||
旧客户端暂时只传 `sessionId` 时,可以解析 latest Run;但这是显式兼容路径,不是新的业务语义。如果必须计算 latest,应按:
|
||||
|
||||
```text
|
||||
created_at DESC, id DESC
|
||||
```
|
||||
|
||||
而不是 `updated_at`。旧 Run 可能因 Feedback 或异步处理再次更新,最近修改不等于最近执行。
|
||||
|
||||
### 决策 5:所有下游语义绑定 Run
|
||||
|
||||
**选择**:Trace、Feedback、Evaluation、Tool evidence 和新 Case provenance 都以 `runId` 为执行边界。
|
||||
|
||||
**理由**:这些对象评价或引用的是一次回答,不是整段会话。
|
||||
|
||||
Feedback 在迁移期缺少 `runId` 时可以绑定 latest Run,但响应必须暴露 `fallbackToLatestRun=true`。兼容如果不可观察,就会从临时措施变成永久歧义。
|
||||
|
||||
`case_library.diagnosis_id` 因复用旧列,在过渡期存在历史 `session_id` 和新 `run_id` 两种语义。这是明确接受的迁移成本,而不是应被隐藏的数据一致性。
|
||||
|
||||
### 决策 6:历史混合数据不做推测性拆分
|
||||
|
||||
**选择**:每条旧 `diagnosis_session` 最多映射为一个 compatibility Run,不根据时间或 Agent 名称猜测真实轮次。
|
||||
|
||||
**理由**:旧数据没有记录边界,任何自动拆分都只能产生无法证明的归属。审计系统宁可明确“不知道”,也不能制造虚假的精确回放。
|
||||
|
||||
---
|
||||
|
||||
## 6. 协议和影响范围
|
||||
|
||||
这是一次有意的行为与协议变化,不是纯内部重构。
|
||||
|
||||
| 范围 | 变化 | 受影响方 |
|
||||
|---|---|---|
|
||||
| Chat / SSE | metadata 增加 `runId` | 前端、脚本、调用方 |
|
||||
| Trace API | 支持 exact `runId` 查询 | Trace UI、排障工具、评测 |
|
||||
| Run API | 提供 Session 下的 Run 列表 | 多轮历史浏览 |
|
||||
| Feedback | request/response 增加 Run 绑定和 fallback 标志 | 前端、CaseLibrary |
|
||||
| 数据库 | 新增两张主表,明细增加 `run_id` | 持久化、迁移、查询脚本 |
|
||||
| 其它入口 | 当时的 AIOps 同步采用 Run 边界 | SSE 消费方、Trace |
|
||||
|
||||
之所以把当时的 AIOps 一并迁移,是因为它同样会产生可回放执行;只修 Chat 会留下第二条具有同类缺陷的数据链。后续 ISS-014 删除了旧 AIOps 双入口,但这不改变当时“所有执行入口必须共享 Run 边界”的设计判断。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险如何处理
|
||||
|
||||
### 风险 1:兼容路径继续产生歧义
|
||||
|
||||
控制方式是让 fallback 可观察,并把 exact `runId` 定义为目标协议。兼容是迁移机制,不能反向成为领域模型。
|
||||
|
||||
### 风险 2:异步链路丢失或串用身份
|
||||
|
||||
`sessionId` 与 `runId` 必须作为同一执行上下文传播。当前架构将二者放入显式 `RunContext`,ToolBoundary、审计 Hook 和持久化都校验当前 Run,避免只依赖线程隐式状态。
|
||||
|
||||
### 风险 3:历史和新 provenance 共用旧列
|
||||
|
||||
保留旧列降低了迁移破坏性,但查询和文档必须承认双语义,不能把旧 `session_id` 当作非法 `run_id` 清理。
|
||||
|
||||
### 风险 4:旧混合 Trace 永远无法恢复
|
||||
|
||||
这是明确接受的事实。系统保留 compatibility 访问和回滚能力,但不承诺不存在的历史精度。
|
||||
|
||||
---
|
||||
|
||||
## 8. 如何证明设计成立
|
||||
|
||||
验收问题不是“接口里有没有 `runId`”,而是下面五个条件是否同时成立:
|
||||
|
||||
```text
|
||||
同一 Session 连续执行两轮
|
||||
+ 两轮获得不同 runId
|
||||
+ 每个 exact Trace 只返回本 Run 明细
|
||||
+ 数据库不存在跨 Run 混合行
|
||||
+ 第二轮仍能使用会话上下文
|
||||
```
|
||||
|
||||
真实 E2E 使用:
|
||||
|
||||
```text
|
||||
sessionId = e2e-phase6-chat-codex-20260710-2120
|
||||
run1 = run-e2a97696-4398-4abc-90e4-28f45c838f92
|
||||
run2 = run-76ce6a6e-92ab-40c9-800a-eca0c1bb5172
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 两轮 Chat 成功并复用同一个 `sessionId`;
|
||||
- 两轮返回不同 `runId`;
|
||||
- run1 exact Trace 只返回 run1,run2 exact Trace 只返回 run2;
|
||||
- `diagnosis_run` 中存在两条独立运行记录;
|
||||
- AgentStep:run1 为 10 行,run2 为 9 行;
|
||||
- ToolInvocation:run1 为 14 行,run2 为 8 行;
|
||||
- mixed row check 为 0;
|
||||
- `chat_session.message_pair_count = 2`,上下文连续性没有因隔离而丢失;
|
||||
- focused tests、baseline diff、日志和数据库核验通过,未观察到 baseline drift。
|
||||
|
||||
这组证据同时验证了“该分开的确实分开”和“该连续的仍然连续”。
|
||||
|
||||
---
|
||||
|
||||
## 9. 后续演进验证了什么
|
||||
|
||||
系统后来从多角色 Agent 编排重构为单 Diagnosis ReAct Agent + Harness。执行结构发生了大变化,但 Session / Run 模型没有被替换,反而成为新架构的基础:
|
||||
|
||||
- `ChatApplicationUseCase` 创建和结束 Run;
|
||||
- `RunContext` 显式携带 `sessionId + runId`;
|
||||
- ToolBoundary 校验 Tool 请求属于当前 Run;
|
||||
- Redis canonical invocation 使用 `runId + toolCallId` 定位;
|
||||
- EvidenceGuard 只接受当前 Run 的 READY invocation;
|
||||
- AgentStep、ToolInvocation、TraceEvent 和 ReasoningAudit 都绑定 Run。
|
||||
|
||||
这说明当时解决的不是某一版代码的局部 bug,而是找到了稳定的领域边界。Agent 编排可以替换,Session 与 Run 的生命周期差异不会消失。
|
||||
|
||||
---
|
||||
|
||||
## 10. 可复用的设计判断
|
||||
|
||||
这次问题可以归纳为四条通用经验:
|
||||
|
||||
1. **生命周期不同的对象,不应共享同一个聚合身份。**
|
||||
2. **上下文复用不代表证据、状态和审计记录也可以复用。**
|
||||
3. **兼容 fallback 必须可观察、可退出,不能静默猜测。**
|
||||
4. **无法恢复的历史边界应明确降级,不能伪造精确性。**
|
||||
|
||||
判断类似系统是否存在同类问题,可以直接问:
|
||||
|
||||
- 一次请求是否有独立于 Session 的执行 ID?
|
||||
- 所有 Step、Tool、Event、Feedback 是否都能精确归属一次执行?
|
||||
- “查询最新”是否被误当成“查询指定执行”?
|
||||
- 主表覆盖写、明细追加写是否使用了同一个过宽的关联键?
|
||||
- 历史迁移是在保留不确定性,还是通过猜测制造精确性?
|
||||
|
||||
如果这些问题没有明确答案,那么 Trace 即使看起来完整,也未必能作为可信审计证据。
|
||||
|
||||
---
|
||||
|
||||
## 11. 资料索引
|
||||
|
||||
- [ISS-010:同 session 多轮诊断 Trace 隔离](../../issues/archived/ISS-010-session-run-trace-isolation.md)
|
||||
- [Session、Run 与 Trace 生命周期](../../architecture/session-trace-lifecycle.md)
|
||||
- [当前 MVP 架构](../../architecture/current-mvp-architecture.md)
|
||||
- [数据表索引](../../tables/README.md)
|
||||
- [devflow brief](../../../devflow/projects/2026-07-10-session-run-trace-isolation/brief.md)
|
||||
- [devflow decisions](../../../devflow/projects/2026-07-10-session-run-trace-isolation/decisions.md)
|
||||
- [devflow acceptance](../../../devflow/projects/2026-07-10-session-run-trace-isolation/acceptance.md)
|
||||
- [devflow evidence](../../../devflow/projects/2026-07-10-session-run-trace-isolation/evidence.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
# Harness Context:统一术语与命名边界
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**状态**:当前实现口径
|
||||
**适用范围**:`com.superbiz.agent.harness`、Chat SSE、Diagnosis Run 持久化与相关工程文档
|
||||
|
||||
> 本文是术语词典,不建议第一次接触 Harness 时顺序阅读。入门请从 [README.md](README.md) 开始,遇到名词歧义时再回到本文查询。
|
||||
|
||||
## 1. 为什么需要这份 Context
|
||||
|
||||
当前系统同时存在 Run 状态、发布结果、Tool 状态、证据状态、收集状态、停止原因和 Fallback 原因。它们都使用了 `SUCCESS`、`ERROR`、`FAILED`、`READY` 等相近词汇,但回答的是不同问题。
|
||||
|
||||
如果把这些词排成一条“大状态机”,会产生错误理解,例如:
|
||||
|
||||
- `NO_EVIDENCE` 被理解为 Tool 调用失败;
|
||||
- `FALLBACK` 被理解为 Run 执行失败;
|
||||
- `SATURATED` 被理解为预算耗尽;
|
||||
- `READY` 被理解为证据足以支持根因;
|
||||
- 数据库 `status=SUCCESS` 被理解为已经找到根因。
|
||||
|
||||
本文件是 Harness 工程文档的术语入口。阅读其他文章前,先以这里的定义区分身份、数据、状态和组件责任。代码与现行架构文档仍是最终事实来源;本文件不创建新的运行协议。
|
||||
|
||||
## 2. 一句话定义 Harness
|
||||
|
||||
**Harness** 是包围非确定性模型执行的确定性控制边界:Agent 负责业务推理和 Draft,Harness 负责 Run 身份、生命周期、预算、取消、Tool 门禁、证据验真、停止控制、发布和审计。
|
||||
|
||||
Harness 不是:
|
||||
|
||||
- 业务工作流引擎;
|
||||
- Planner / Executor / Verifier / Composer 编排图;
|
||||
- 框架 ReAct loop 的第二份实现;
|
||||
- 判断业务根因的规则引擎;
|
||||
- 用于存放所有 Agent 相关代码的泛化名称。
|
||||
|
||||
## 3. 三层范围:不要把 Harness、Core 和 Application 当成同义词
|
||||
|
||||
| 名称 | 定义 | 包含 | 不包含 |
|
||||
|---|---|---|---|
|
||||
| Chat Application | 一次 Chat 请求的应用用例 | Session/Run 创建、路由、分支执行、持久化、公开结果 | HTTP/SSE 连接本身、业务推理细节 |
|
||||
| Diagnosis Harness | Diagnosis Agent 外部的确定性控制系统 | Core、Interceptor、ToolBoundary、Progress、Guard、Release、Audit | 根因推理和 ReAct 规划 |
|
||||
| Harness Core | 最小运行控制内核 | RunContext、deadline、budget、cancel、lifecycle、retry policy | 路由、Tool backend、Guard、持久化、SSE |
|
||||
|
||||
代码包 `com.superbiz.agent.harness` 同时包含 Chat Application 和 Diagnosis Harness 的实现,这是代码组织范围,不代表所有类都属于 Harness Core。
|
||||
|
||||
`DiagnosisHarnessCore` 目前也被 System Chat、Knowledge Query 和 Router 复用预算与生命周期能力。类名前缀保留了演进历史,概念上应理解为当前 Chat Run 的 Harness Core。
|
||||
|
||||
## 4. 身份术语
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["Chat Session<br/>sessionId,多轮容器"] -->|"1:N"| R1["Run<br/>runId,一次请求"]
|
||||
S -->|"1:N"| R2["Run<br/>下一次请求"]
|
||||
|
||||
R1 -->|"1:N"| AS["Agent Step<br/>一次模型步骤"]
|
||||
R1 -->|"1:N"| TC["Tool Call / Invocation<br/>framework tool_call_id"]
|
||||
R1 -->|"1:N"| TE["Trace Event<br/>sequence_no"]
|
||||
|
||||
TC --> CI["Canonical Invocation<br/>Run 内短期 Tool 真相"]
|
||||
AS --> TR["Diagnosis Trace<br/>按 exact Run 聚合"]
|
||||
TC --> TR
|
||||
TE --> TR
|
||||
|
||||
R1 -.->|"SUCCESS diagnosis only"| PT["PublishedResult<br/>可投影为下一轮 PreviousTurn"]
|
||||
```
|
||||
|
||||
图中的所有明细都必须绑定 exact runId。Session 只负责组织多轮,不能替代 Run 归属;Trace 是聚合视图,不能反过来成为执行身份。
|
||||
|
||||
### 4.1 Chat Session
|
||||
|
||||
一次多轮对话容器,由 `sessionId` 标识。同一个 Session 可以包含多次 Run。
|
||||
|
||||
Session 用于:
|
||||
|
||||
- 组织多轮请求;
|
||||
- 查找安全的 PreviousTurn;
|
||||
- 查询历史 Run。
|
||||
|
||||
Session 不代表一次执行,不拥有 Tool Call 或模型步骤的终态。
|
||||
|
||||
### 4.2 Run
|
||||
|
||||
一次独立的 Chat Application 执行,由 `runId` 标识。每次请求创建新 Run,无论 intent 是 System Chat、Knowledge Query 还是 Diagnosis。
|
||||
|
||||
代码和表名中仍大量使用 `DiagnosisRun` / `diagnosis_run`,但当前 Chat Application 会为三种 intent 都创建该记录。因此文档中优先使用 **Run**;只有引用 Java 实体或数据库表时才写 `DiagnosisRun`。
|
||||
|
||||
### 4.3 Agent Step
|
||||
|
||||
Diagnosis ReAct Agent 的一次模型步骤。多个 Agent Step 属于一个 Run,用于记录每轮模型调用的有界元数据和 Token。
|
||||
|
||||
Agent Step 不是 Run,也不是 retry attempt。ReAct 的下一轮模型调用是业务循环;retry 是同一技术操作的再次 attempt。
|
||||
|
||||
### 4.4 Tool Call / Tool Invocation
|
||||
|
||||
- **Tool Call**:模型通过框架产生的调用请求,由 framework `tool_call_id` 标识。
|
||||
- **Tool Invocation**:该请求进入 ToolBoundary 后的一次实际执行记录。
|
||||
- **Tool Request Rejection**:在 progress、重复或停止门禁处被拒绝,backend 没有执行,不算 Tool Invocation。
|
||||
|
||||
Harness 不生成第二套 Tool Call ID。
|
||||
|
||||
### 4.5 Trace
|
||||
|
||||
按 exact `sessionId + runId` 聚合的可回放观察视图,包含 Run、Agent Step、Tool metadata 和统一 Timeline。
|
||||
|
||||
Trace 是观察结果,不是新的执行上下文或状态所有者。`TraceEventStatus` 只描述单个事件,不能替代 RunState 或 ReleaseOutcome。
|
||||
|
||||
## 5. 核心执行术语
|
||||
|
||||
### 5.1 RunContext
|
||||
|
||||
一次 Run 的显式执行上下文。结构不可变地携带:
|
||||
|
||||
```text
|
||||
sessionId
|
||||
runId
|
||||
deadline
|
||||
RunCancellation
|
||||
RunBudget
|
||||
ModelCallLedger
|
||||
HarnessRetryPolicies
|
||||
RunLifecycle
|
||||
DiagnosisProgressTracker
|
||||
```
|
||||
|
||||
“结构不可变”表示 record 字段引用不变化;预算、取消、生命周期和进展通过各自线程安全句柄在 Run 内变化。
|
||||
|
||||
### 5.2 Run Lifecycle
|
||||
|
||||
Run 的内存执行终态,由 `RunLifecycle` 所有,状态类型是 `RunState`。它采用 first-terminal-wins,后到达的成功、失败或取消不能覆盖第一个终态。
|
||||
|
||||
### 5.3 Run Cancellation
|
||||
|
||||
请求停止 Run 的协作机制,由 `RunCancellationReason` 记录第一个原因。取消会阻止后续边界和迟到发布,但不承诺一定能立即物理中断已发送给 Provider 的同步请求。
|
||||
|
||||
### 5.4 Run Budget
|
||||
|
||||
单 Run 的资源账本和门禁,包括模型调用、Tool 调用、单 Tool 次数、输入/输出/总 Token 和 Run bytes。
|
||||
|
||||
Budget 只回答“还能不能消耗资源”,不回答“继续诊断是否有价值”。后者属于 Information Gain 和 Collection State。
|
||||
|
||||
### 5.5 Retry Attempt
|
||||
|
||||
同一个技术操作因允许的技术失败而再次执行。当前 Router 和 SemanticGuard 最多 2 次 attempt,Diagnosis Agent、Tool 和 EvidenceRepair 只有 1 次。
|
||||
|
||||
以下不是 retry:
|
||||
|
||||
- ReAct Agent 的下一轮思考;
|
||||
- 改用另一个 Tool;
|
||||
- `NO_EVIDENCE` 后继续查询;
|
||||
- 用户发起下一次 Run。
|
||||
|
||||
## 6. Agent 与输出术语
|
||||
|
||||
### 6.1 Diagnosis Agent
|
||||
|
||||
唯一拥有业务 ReAct Tool loop 的 Agent,负责提出假设、选择 Tool、评价非空结果的信息增益并生成 `DiagnosisDraft`。
|
||||
|
||||
它不负责 Run 生命周期、Tool 授权、证据物理验真、SemanticGuard 或最终发布。
|
||||
|
||||
### 6.2 DiagnosisDraft
|
||||
|
||||
Diagnosis Agent 的结构化草稿,是发布链输入,不是已经发布的报告。Draft 可以有结论,也可以 `conclusion=null`。
|
||||
|
||||
Draft 中的 Tool 引用和结论必须经过 Release Pipeline 后才能成为公开内容。
|
||||
|
||||
### 6.3 Agent Result
|
||||
|
||||
当前 Tool 代码中的 `agent_result` 指 Tool-specific Projector 生成、存入 canonical invocation 的标准化有界结果。
|
||||
|
||||
它不是:
|
||||
|
||||
- Diagnosis Agent 的最终 Draft;
|
||||
- Chat Application 的最终结果;
|
||||
- 直接进入模型上下文的完整内容。
|
||||
|
||||
更准确的理解是 **Canonical Projected Tool Result**。当前字段名因协议兼容保留。
|
||||
|
||||
### 6.4 Model Observation
|
||||
|
||||
从 canonical `agent_result` 再次白名单投影后,真正作为 Tool Response 进入 Diagnosis Agent 上下文的内容。
|
||||
|
||||
预算、阈值、重复指纹、raw response 和完整 Harness 控制状态不进入 Model Observation。
|
||||
|
||||
### 6.5 PreviousTurn 与 PublishedResult
|
||||
|
||||
- `PublishedResult`:只有 `DIAGNOSIS + ReleaseOutcome.SUCCESS` 才能持久化的安全诊断结果。
|
||||
- `PreviousTurn`:从 PublishedResult 生成的有界下一轮上下文。
|
||||
|
||||
Fallback、失败、取消、raw evidence 和完整历史不会进入 PreviousTurn。
|
||||
|
||||
`PublishedResult` 不等于当前请求直接返回的 `ChatApplicationResult`。
|
||||
|
||||
## 7. Tool 与证据术语
|
||||
|
||||
### 7.1 ToolBoundary
|
||||
|
||||
所有业务 Tool 的统一执行边界,负责 Run/ID/授权/只读校验、预算、bytes、canonical 状态迁移和 metadata audit。
|
||||
|
||||
ToolBoundary 不判断信息增益或业务根因。
|
||||
|
||||
### 7.2 Canonical Invocation
|
||||
|
||||
Redis TTL 内的完整 Tool 调用真相,包含 request、raw response、标准化 `agent_result`、调用状态和证据状态。
|
||||
|
||||
它用于当前 Run 的 EvidenceGuard 和 ProgressSnapshot,不是长期审计记录。
|
||||
|
||||
### 7.3 Durable Audit
|
||||
|
||||
长期保存的有界元数据:Run/Tool identity、状态、耗时、bytes、模型步骤和 Token。它不保存 Prompt、完整 Tool 参数、raw response 或 canonical record。
|
||||
|
||||
### 7.4 Evidence
|
||||
|
||||
Tool 在特定 scope 下返回、经过 Projector 标准化并能由当前 Run canonical record 验真的事实或负向观察。
|
||||
|
||||
`EVIDENCE_FOUND` 只代表存在候选内容,不代表它支持根因。
|
||||
|
||||
### 7.5 Negative Observation
|
||||
|
||||
`READY + NO_EVIDENCE` 形成的限定范围事实,例如“在时间窗 T、服务 S、查询 Q 下没有匹配日志”。
|
||||
|
||||
它不能被解释为“故障不存在”或“系统健康”。Draft 中使用 `AnalysisKind.NEGATIVE_OBSERVATION` 表达这种分析。
|
||||
|
||||
### 7.6 VerifiedEvidenceSnapshot
|
||||
|
||||
EvidenceGuard 从 canonical invocation 中投影出的已验真、最小证据集合,供 SemanticGuard 使用。它不包含 raw response。
|
||||
|
||||
### 7.7 ProgressSnapshot
|
||||
|
||||
Tool loop 结束后,根据已完成 Tool Call identity 回读 canonical records 生成的有界过程视图,用于受控停止或无结论 Fallback。
|
||||
|
||||
VerifiedEvidenceSnapshot 面向“有结论报告的语义审查”;ProgressSnapshot 面向“没有可发布结论时说明已经检查了什么”,两者用途不同。
|
||||
|
||||
## 8. Guard 与发布术语
|
||||
|
||||
### 8.1 EvidenceGuard
|
||||
|
||||
确定性引用验真器。检查 Draft 结构、analysis 引用闭包、当前 Run 所有权、READY 状态、EvidenceStatus 和 typed projection 自洽性。
|
||||
|
||||
不调用模型,不判断结论是否被证据支持。
|
||||
|
||||
### 8.2 EvidenceRepair
|
||||
|
||||
一次性的模型修复步骤,只修结构和引用。修复前后 `SemanticDraftView` 必须保持用户可见语义一致,之后重新执行 EvidenceGuard。
|
||||
|
||||
它不是新的报告作者,也不是 retry Agent。
|
||||
|
||||
### 8.3 SemanticGuard
|
||||
|
||||
隔离的单轮语义审查器,只判断 verified evidence 是否支持 Draft,输出 `SUPPORTED / UNSUPPORTED`。
|
||||
|
||||
它使用模型,但无 Tool、无记忆、无 ReAct loop、无报告改写权,因此文档中不要称它为第二个业务 Agent。
|
||||
|
||||
### 8.4 Release Pipeline
|
||||
|
||||
从 DiagnosisDraft 或受控停止输入,到 `DiagnosisReleaseResult` 的安全决策链:EvidenceGuard、可选 Repair/Recheck、SemanticGuard 和 SafeFallback。
|
||||
|
||||
### 8.5 ReleaseOutcome
|
||||
|
||||
应用层最终处理结果:
|
||||
|
||||
- `SUCCESS`:发布安全正常内容;
|
||||
- `FALLBACK`:请求已被安全处理,但没有发布正常诊断结论;
|
||||
- `FAILED`:无法形成安全业务结果;
|
||||
- `CANCELLED`:Run 被取消。
|
||||
|
||||
ReleaseOutcome 不等于 RunState。尤其 `FALLBACK` 不是 RunState。
|
||||
|
||||
### 8.6 SafeFallback 与 FallbackType
|
||||
|
||||
SafeFallback 是确定性公开内容;FallbackType 解释为什么没有发布正常诊断结论,例如:
|
||||
|
||||
- `EVIDENCE_VALIDATION_FAILED`;
|
||||
- `SEMANTIC_UNSUPPORTED`;
|
||||
- `SEMANTIC_UNAVAILABLE`;
|
||||
- `INSUFFICIENT_EVIDENCE`;
|
||||
- `MISSING_REQUIRED_CONTEXT`。
|
||||
|
||||
FallbackType 是 `ReleaseOutcome.FALLBACK` 的原因,不是新的生命周期状态。
|
||||
|
||||
## 9. 状态维度速查
|
||||
|
||||
| 类型 | 所有者 | 回答的问题 | 不能回答的问题 |
|
||||
|---|---|---|---|
|
||||
| `RunState` | RunLifecycle | Run 的内存执行是否终止、如何终止 | 发布了正常内容还是 Fallback |
|
||||
| `RunCancellationReason` | RunCancellation | 谁首先请求取消、为什么 | 最终公开结果是什么 |
|
||||
| `ChatApplicationStatus` | Application Observer | 当前向用户展示哪个处理阶段 | Run 是否已经终止 |
|
||||
| `InvocationStatus` | Canonical Invocation | Tool 调用记录是否完成 | 是否找到候选证据 |
|
||||
| `EvidenceStatus` | Tool Projector | 当前 scope 是否有候选证据 | 是否支持根因 |
|
||||
| `InformationGain` | Harness/Diagnosis Agent | 结果是否推进当前诊断 | Tool 是否技术成功 |
|
||||
| `DiagnosisCollectionState` | ProgressTracker | 是否允许继续调用证据 Tool | Run 是否终止 |
|
||||
| `DiagnosisStopReason` | ProgressTracker | 为什么停止继续收集 | 对外发布什么内容 |
|
||||
| `SemanticVerdict` | SemanticGuard | 已验真证据是否支持 Draft | Run 是否成功执行 |
|
||||
| `ReleaseOutcome` | Application/Release | 对外处理结果属于成功、降级、失败还是取消 | Tool 或收集过程的内部状态 |
|
||||
| `FallbackType` | SafeFallbackFactory | FALLBACK 的业务/安全原因 | 整个 Run 的执行终态 |
|
||||
| `TraceEventStatus` | 单个 Trace Event | 某条事件的局部结果 | 全局生命周期 |
|
||||
| SSE Session `State` | ChatSseSession | 连接能否继续发送事件 | Harness Run 的业务结果 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Execution["执行控制维度"]
|
||||
RS["RunState<br/>RUNNING -> terminal"]
|
||||
CS["CollectionState<br/>COLLECTING / SATURATED"]
|
||||
SR["StopReason<br/>停止收集原因"]
|
||||
end
|
||||
|
||||
subgraph Tool["单次 Tool 维度"]
|
||||
IS["InvocationStatus<br/>PROJECTING / READY / ERROR"]
|
||||
ES["EvidenceStatus<br/>FOUND / NO_EVIDENCE / ERROR"]
|
||||
IG["InformationGain<br/>GAINED / NO_GAIN"]
|
||||
end
|
||||
|
||||
subgraph Validation["验证维度"]
|
||||
EV["EvidenceGuardResult<br/>valid / violations"]
|
||||
SV["SemanticVerdict<br/>SUPPORTED / UNSUPPORTED"]
|
||||
end
|
||||
|
||||
subgraph Publication["发布与观察维度"]
|
||||
RO["ReleaseOutcome<br/>SUCCESS / FALLBACK / FAILED / CANCELLED"]
|
||||
FT["FallbackType<br/>FALLBACK 原因"]
|
||||
SSE["SSE Session State<br/>连接发送状态"]
|
||||
DB["diagnosis_run.status<br/>持久化通用状态"]
|
||||
end
|
||||
|
||||
IS --> ES
|
||||
ES --> IG --> CS
|
||||
CS --> SR
|
||||
ES --> EV --> SV
|
||||
SR --> RO
|
||||
SV --> RO
|
||||
RS --> RO
|
||||
RO --> FT
|
||||
RO --> SSE
|
||||
RO --> DB
|
||||
```
|
||||
|
||||
箭头表示信息参与后续决策,不表示枚举之间一一转换。例如 `READY + EVIDENCE_FOUND` 仍可能得到 `NO_GAIN`,`RunState.SUCCESS` 也可能对应 `ReleaseOutcome.FALLBACK`。
|
||||
|
||||
完整状态转换和跨层映射见[Harness生命周期与状态.md](Harness生命周期与状态.md)。
|
||||
|
||||
## 10. 命名规则
|
||||
|
||||
后续代码和文档遵守以下用词:
|
||||
|
||||
1. 说“Run 成功/失败/取消/超时/预算耗尽”时,明确写 `RunState`。
|
||||
2. 说“发布正常内容/Fallback/失败/取消”时,明确写 `ReleaseOutcome`。
|
||||
3. 不单独写“Tool 成功”,改写为 `InvocationStatus=READY`,并同时说明 EvidenceStatus。
|
||||
4. 不写“找到有效证据”,除非已经说明是候选内容、已验真事实还是足以支持结论。
|
||||
5. `NO_EVIDENCE` 必须附带 scope,不得写成全局否定。
|
||||
6. `SATURATED` 只用于 Collection State;预算耗尽使用 `BUDGET_LIMIT_REACHED` 或 `RunState.BUDGET_EXHAUSTED`。
|
||||
7. `Fallback` 只指安全发布降级,不用来泛指异常兜底代码。
|
||||
8. `Agent` 默认指 Diagnosis Agent;SemanticGuard 和 EvidenceRepair 分别称“语义审查器”和“引用修复步骤”。
|
||||
9. `agent_result` 引用字段名时保留原名,概念说明使用“canonical projected Tool result”。
|
||||
10. `status` 单独出现没有意义,必须注明所属类型或存储字段。
|
||||
|
||||
## 11. 已知命名债务
|
||||
|
||||
### 11.1 `DiagnosisRun` 名称大于实际诊断范围
|
||||
|
||||
当前 Chat Application 对三种 intent 都写入 `diagnosis_run`。文档统一称 Run;是否重命名实体和表属于单独的协议/迁移决策,本次不修改代码。
|
||||
|
||||
### 11.2 `SseOutcome` 当前未被运行时使用
|
||||
|
||||
`SseOutcome` 枚举存在,但当前 `ChatSseEvent.Done` 直接携带 `ReleaseOutcome`,并禁止公开 `CANCELLED`。因此当前 SSE 真理源是 `ReleaseOutcome`,不要再基于 `SseOutcome` 推导协议。
|
||||
|
||||
### 11.3 `FallbackType.BUDGET_EXHAUSTED` 不是当前诊断发布主路径
|
||||
|
||||
枚举值仍存在,但当前 Diagnosis Release 对预算受控停止的行为是:有已验真进展时发布 `INSUFFICIENT_EVIDENCE`,无安全进展时保持失败。文档不能仅因枚举存在就声称系统会发布 `BUDGET_EXHAUSTED` Fallback。
|
||||
|
||||
### 11.4 数据库 `status=SUCCESS` 不等于找到根因
|
||||
|
||||
`JpaChatRunStore` 将 `ReleaseOutcome.SUCCESS` 和 `FALLBACK` 都映射为数据库 `status=SUCCESS`,表示请求被正常处理。是否发布根因必须结合 `release_outcome`、`content_type` 和 FallbackType 判断。
|
||||
|
||||
### 11.5 RunState 没有直接作为独立字段持久化
|
||||
|
||||
当前持久化主记录保存通用 `status` 和 `release_outcome`,Trace 保存阶段事件;内存 `RunTermination.state/reason` 不是独立数据库字段。排查时不能只靠数据库 `status` 反推 TIMED_OUT 或 BUDGET_EXHAUSTED 的精确内部终态。
|
||||
|
||||
## 12. 如何使用本文
|
||||
|
||||
本文不是必读的第一章,而是遇到名词歧义时使用的词典。第一次接触 Harness,请先读 [README.md](README.md) 建立最小心智模型;需要理解某个状态或概念时,再回到本文对应章节查询。
|
||||
@@ -0,0 +1,140 @@
|
||||
# Harness LLM Judge 设计笔记:从不可信判定到可信裁决
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:SemanticGuard + EvidenceRepair + GuardModelCall = LLM-as-a-judge 模式在证据安全链的完整落地(面试问答版)
|
||||
**代码位置**:`src/main/java/com/superbiz/agent/harness/guard/semantic/` + `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java`
|
||||
|
||||
## 1. 定位:三个角色
|
||||
|
||||
```text
|
||||
SemanticGuard → 典型 LLM judge:判「结论是否被已验证证据支持」,输出 verdict + reason
|
||||
EvidenceRepair → judge 的修复延伸(rewriter):验真失败后「只修引用、不修结论」
|
||||
GuardModelCall → 受控 LLM 调用底座:judge 类调用的基础设施(共用)
|
||||
```
|
||||
|
||||
## 2. 面试五段式回答稿(完整叙事)
|
||||
|
||||
### ① 动机(先讲问题,不报组件名)
|
||||
|
||||
> 我们的证据安全链里有一道「机械验真」——检查模型引用的每条证据是不是真实来自工具结果,这个用规则就能做。但光验真不够:模型可能引用真实的证据,结论却是「站不住」的——比如证据只支持 A 场景,它却拿去支撑 B 结论。这个「结论被没被证据支持」是**语义判断**,规则引擎做不了,必须靠模型。所以我们需要一个「裁判模型」来判——但裁判模型本身是不可信的,它可能乱判、可能输出奇怪的形状、可能跑很久。所以核心问题是:**怎么让一个不可信的模型做可信的判定**。
|
||||
|
||||
### ② 决策(方案 + 放弃了什么)
|
||||
|
||||
> 我的方案是:用**隔离的轻量判定模型**——单轮、无工具、输出被强约束,跟主 Agent 的循环完全分离。这里放弃了两条路:第一,让主 Agent 自己判——不行,它已经写了自己的结论,有偏向;第二,纯规则判——语义判断规则做不到。同时有个关键决策:**判读的输入是「视图」不是原始内容**——裁判只看到用户将看到的内容和已验证证据,看不到内部 id 这些实现细节,防止信息污染影响裁判的客观性。
|
||||
|
||||
### ③ 实现(关键机制)
|
||||
|
||||
> 三个关键机制:
|
||||
> **输入视图化**:把 draft 投影成「用户可见视图」再交给裁判,剥离内部引用 id;
|
||||
> **输出硬校验**:裁判的输出必须是恰好两个字段——verdict 和 reason,verdict 必须是合法枚举,reason 不能为空。多一个字段都不接受——我们不信任模型输出的形状,只信它在一个极小的空间里做选择;
|
||||
> **受控调用**:裁判跑在独立线程、有硬超时、Run 取消能强杀它、它的输入输出都计入预算和 Token 账本——裁判的花费不是无底洞,它也是 Run 的一部分。
|
||||
|
||||
### ④ 边界(诚实说不做什么)
|
||||
|
||||
> 裁判不判「内容对不对」——那是事实问题,由证据链负责;裁判不自己调工具,单轮无工具;裁判有硬截止线,超时就放弃判定;裁判失败走降级,**不阻塞主结论的发布路径**——我们宁可没有裁决,也不让裁决失败卡死整个流程。
|
||||
|
||||
### ⑤ 30 秒话术
|
||||
|
||||
> "LLM judge 的完整设计:**动机**是结论的支持度是语义判断、规则做不了,但裁判模型不可信,所以核心是让不可信的模型做可信的判定。**方案**是隔离的轻量判定模型——单轮、无工具、强约束输出。三个关键机制:输入视图化(裁判只看用户可见内容,防信息污染)、输出硬校验(恰好 {verdict, reason} 两字段,多一个都不接受)、受控调用(独立线程、硬超时、取消强杀、计预算记账)。**边界**:裁判不判事实、不调工具、超时即放弃、失败走降级不阻塞主路径。总结一句话——judge 不是追加一个模型调用,而是把『不可信判定』关进笼子里:限定输入、锁死输出、受控运行、失败降级。"
|
||||
|
||||
## 3. 追问应对大全
|
||||
|
||||
### Q1:为什么 judge 不判事实?(最容易混的边界)
|
||||
|
||||
```text
|
||||
分工:事实由证据链保证,judge 只判「支持关系」
|
||||
事实真伪 → 证据来自真实工具结果 + EvidenceGuard 验引用真实(根在数据源)
|
||||
支持关系 → SemanticGuard 判结论与证据的逻辑/相关性
|
||||
|
||||
judge 判不了事实的三个原因:
|
||||
① 没有事实源——它只看「视图 + 已验证证据」,不能查库,判事实只能猜
|
||||
② 事实真伪需要权威源复核(真实值在哪),judge 拿不到
|
||||
③ 如果 judge 判事实,它成了第二个事实来源——两个来源可能打架
|
||||
|
||||
例子 1(judge 能判的——判支持不是判真伪):
|
||||
证据:mysql 返回 count(*)=1000;结论:「user 表有 2000 条」
|
||||
EvidenceGuard 验引用真实 → 通过;SemanticGuard → UNSUPPORTED(数字不一致)
|
||||
注意:judge 不知道真实值是多少,它只发现「结论与证据不一致」
|
||||
|
||||
例子 2(支持关系成立,但事实未必对):
|
||||
证据:慢查询日志显示 DB 全表扫描;结论:「延迟由 DB 全表扫描导致」
|
||||
SemanticGuard → SUPPORTED(逻辑上站得住)
|
||||
但真实原因可能是网络抖动——judge 判不了(没有网络数据源)
|
||||
→ judge 只能保证「在现有证据下结论站得住」,不能保证「事实就是如此」
|
||||
|
||||
一句话:EvidenceGuard 保证「引用的证据是真的」,SemanticGuard 保证
|
||||
「基于这些证据结论说得通」——事实的真伪从来不是 judge 的职责。
|
||||
```
|
||||
|
||||
### Q2:为什么重试 2 次(semanticGuard 策略)?
|
||||
|
||||
```text
|
||||
可重试性分析:语义审查单轮、无副作用(幂等)——多试几次不会造成破坏
|
||||
但也不能无限重试:判定有硬截止线(总超时耗尽即放弃)
|
||||
→ 2 次 = 一次失败的成本 × 收益的平衡点;judge 失败走 Fallback,不影响主路径
|
||||
```
|
||||
|
||||
### Q3:语义不变性怎么保证(EvidenceRepair)?
|
||||
|
||||
```text
|
||||
双重锁死:prompt(只能改 analysis_id / tool_call_ids / based_on_analysis_ids 三字段)
|
||||
+ SemanticDraftView.hasSameUserVisibleSemantics(修复前后逐字段比对)
|
||||
|
||||
关键:比的是「用户可读的内容」不是内部引用 id——
|
||||
Conclusion 只比 text,不比 basedOnAnalysisIds
|
||||
Action 只比 action + requiresHumanConfirmation,不比 basedOnAnalysisIds
|
||||
→ 引用 id 允许变(这正是修复目标),用户看到的文字不许动(碰了判 SCHEMA_INVALID 重试)
|
||||
```
|
||||
|
||||
### Q4:judge 判错了怎么办?
|
||||
|
||||
```text
|
||||
judge 不是最终真相源,是「安全链的一道闸」:
|
||||
① judge 判 UNSUPPORTED → 不发布,走 Fallback(宁可保守)
|
||||
② judge 判 SUPPORTED 但事实错 → 那是事实问题,不在 judge 职责(见 Q1)
|
||||
③ judge 自身失败 → 降级(不阻塞主路径)
|
||||
→ 设计哲学:judge 的角色是「挡住明显不成立的结论」,不是「证明结论正确」
|
||||
```
|
||||
|
||||
### Q5:为什么独立线程 + 单独超时?
|
||||
|
||||
```text
|
||||
judge 调用不能阻塞主流程(主 Agent 循环)
|
||||
独立线程 + future.get(timeout) = 硬超时截断
|
||||
Run 取消 → future.cancel(true) 强杀在途判定(judge 也是 Run 的一部分)
|
||||
```
|
||||
|
||||
### Q6:输入为什么视图化?
|
||||
|
||||
```text
|
||||
judge 只看该看的:用户可见内容 + 已验证证据
|
||||
剥离内部 id(tool_call_id 等)——防止 judge 用内部信息做「看起来合理」的裁决
|
||||
(信息污染:judge 看到内部 id 可能产生不当关联,或泄露内部结构到裁决)
|
||||
```
|
||||
|
||||
## 4. 通用 LLM Judge 设计要素(可迁移)
|
||||
|
||||
| 通用要素 | 本项目实现 |
|
||||
|---|---|
|
||||
| 判什么(judgment task) | 结论是否被已验证证据支持 |
|
||||
| 输入视图(该看什么) | SemanticDraftView(剥离内部 id)+ 已验证证据 |
|
||||
| 输出约束(schema) | 恰好 {verdict, reason} + 枚举合法 + reason 非空 |
|
||||
| 硬校验 | 字段集 equals({verdict, reason})——不多不少 |
|
||||
| 隔离 | 单轮、无工具、独立线程——judge 不能自己调工具 |
|
||||
| 硬超时 | 每次 attempt 剩余超时递减,总超时耗尽即放弃 |
|
||||
| 重试策略 | 可重试性分析:单轮无副作用 → 2 次 |
|
||||
| 失败降级 | judge 失败 → Fallback(不卡死主路径) |
|
||||
| 可审计 | ModelCallLedger 记账 + semanticAttempt/evidenceRepairAttempt trace |
|
||||
| 成本控制 | 输入/输出字节限制 + reserveRunBytes 计入 Run 预算 |
|
||||
|
||||
## 5. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `GuardModelCall` | `src/main/java/com/superbiz/agent/harness/guard/semantic/GuardModelCall.java` |
|
||||
| `SemanticGuard` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticGuard.java` |
|
||||
| `SemanticDraftView` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticDraftView.java` |
|
||||
| `SemanticGuardInput` / `SemanticGuardLimits` | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
|
||||
| `EvidenceRepair` | `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java` |
|
||||
| `EvidenceRepairLimits` / `EvidenceRepairPrompt` | `src/main/java/com/superbiz/agent/harness/release/` |
|
||||
| 重试策略(semanticGuard/evidenceRepair) | `src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java` |
|
||||
@@ -0,0 +1,158 @@
|
||||
# Harness MySQL 沙箱学习笔记:从 SQL 校验到脱敏投影
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:query_mysql 工具完整链路——三层防线(语义/连接/输出)
|
||||
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(注册/调用/执行全链路)
|
||||
|
||||
## 1. 定位:可查询、不可破坏、不可越界、不可拖库
|
||||
|
||||
query_mysql 让模型查询授权数据库,但封死三种攻击面:
|
||||
|
||||
```text
|
||||
破坏:写/删/改(非 SELECT)→ 语义层拒绝
|
||||
越界:未授权表/列 → 白名单拒绝
|
||||
拖库:全表通配(*)/无界读取 → 禁通配符 + 三重有界截断
|
||||
```
|
||||
|
||||
**为什么 MySQL 要安全层而 RAG 不要**:Milvus 天然只读检索无破坏面;MySQL 直接连数据库,SELECT 之外全是风险面——**安全设计随攻击面走**。
|
||||
|
||||
## 2. 架构总览(三层防线 + 接线员)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["MysqlToolAdapter<br/>接线员"] -->|"parse 请求"| V["第 1 层 语义层<br/>MysqlSqlValidator<br/>AST fail-closed"]
|
||||
V -->|"MysqlQueryPlan"| X["第 2 层 连接层<br/>JdbcMysqlReadOnlyExecutor<br/>JDBC 只读+超时+取消"]
|
||||
X -->|"MysqlRawResult<br/>(Harness-only)"| P["第 3 层 输出层<br/>MysqlResultProjector<br/>脱敏+有界"]
|
||||
P -->|"MysqlToolResult<br/>(冻结契约)"| B["ToolBoundary<br/>统一门禁"]
|
||||
```
|
||||
|
||||
**接线员**:Adapter 把 validator/executor/projector 组装进 ToolBoundary;任何安全/参数异常统一映射 INVALID_REQUEST(不泄露内部细节)。
|
||||
|
||||
## 3. 定义层(6 个小文件)
|
||||
|
||||
| 类 | 作用 |
|
||||
|---|---|
|
||||
| `MysqlReadOnlyExecutor` | 函数式接口(执行端口):plan + RunContext → raw 结果 |
|
||||
| `MysqlQueryPlan` | 执行计划:request + dataSource + normalizedSql + params(params 深拷贝) |
|
||||
| `MysqlRawResult` | Harness-only raw 结果:columns + rows + truncated(不直接给 Agent) |
|
||||
| `MysqlSecurityException` | 安全异常:Adapter 映射稳定错误码 |
|
||||
| `MysqlToolLimits` | 限额:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时 |
|
||||
| `MysqlDataSourceDefinition` | 逻辑数据源 + **schema → table → 列三级白名单**(访问边界) |
|
||||
|
||||
**白名单深拷贝**(TreeSet 保证确定性)+ `defaultSchema 必须出现在白名单里`——配置即边界。
|
||||
|
||||
## 4. 第 1 层:Validator——语义层(fail-closed)
|
||||
|
||||
### 4.1 禁止清单(JSqlParser 解析 AST,逐节点拒绝)
|
||||
|
||||
```text
|
||||
非 SELECT / 多条语句 / WITH / 子查询(SubSelect) / 通配符投影(* 和 t.*)
|
||||
窗口函数(Analytic) / CASE / EXISTS / 分层查询(OracleHierarchical)
|
||||
锁读(FOR UPDATE / SKIP LOCKED) / OFFSET/FETCH/TOP/SKIP/FIRST/OPTIMIZE FOR
|
||||
字面量:StringValue/LongValue/DoubleValue/HexValue/DateValue/TimeValue/TimestampValue
|
||||
—— 全部必须参数化(防注入的最强形态)
|
||||
```
|
||||
|
||||
### 4.2 允许清单
|
||||
|
||||
```text
|
||||
白名单内表列的 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER)
|
||||
聚合函数:COUNT/SUM/AVG/MIN/MAX(COUNT(*) 只允许 COUNT)
|
||||
占位符参数 ?(PreparedStatement 绑定)
|
||||
```
|
||||
|
||||
### 4.3 附加校验
|
||||
|
||||
```text
|
||||
恰好一条语句 + 必须是 Select + 无 WITH + 普通 PlainSelect(禁集合操作/值语句)
|
||||
FROM 必须是白名单内实体表(禁子查询来源);重复别名拒绝(不区分大小写)
|
||||
列校验:限定表 → 查该表列白名单;未限定 → 已注册表恰好一个命中(防歧义/未授权)
|
||||
占位符数量 == params 数量(防参数错位/少传)
|
||||
```
|
||||
|
||||
### 4.4 fail-closed 原则
|
||||
|
||||
```text
|
||||
任何解析/校验异常 → 统一转 MysqlSecurityException(默认拒绝,不是默认放行)
|
||||
业务规则违规原样穿出;解析/未知异常包装统一信息(不泄露内部细节)
|
||||
→ 安全策略是「拒绝清单外的全允许」的反面:「允许清单外的全拒绝」
|
||||
```
|
||||
|
||||
## 5. 第 2 层:Executor——连接层(双保险)
|
||||
|
||||
```text
|
||||
connection.setReadOnly(true) ← JDBC 连接层强制只读(语义层之外的物理防线)
|
||||
PreparedStatement 参数化 ← 占位符绑定(防注入第二道)
|
||||
setQueryTimeout(5s) ← 慢查询截断
|
||||
setMaxRows(maxRows+1) ← 多取一行用于检测截断
|
||||
context.cancellation().onCancel → statement.cancel() ← Run 取消联动
|
||||
checkRun 每行检查 ← 取消/超时即刻中止(与 core 终态联动)
|
||||
jsonSafe:byte[] → Base64;字符串截断 maxCellChars
|
||||
estimatedBytes:字节预算超限移除末行并标记截断
|
||||
```
|
||||
|
||||
**关键**:查询不是独立资源——**受 Run 生命周期管**(取消 → 立即 cancel 语句),与 RAG 检索同理(checkRun 与 Harness core 终态联动)。
|
||||
|
||||
## 6. 第 3 层:Projector——输出层
|
||||
|
||||
### 6.1 脱敏(输出时,不是查询时)
|
||||
|
||||
```text
|
||||
列名含 password/passwd/token/secret/api_key/apikey/credential → [REDACTED]
|
||||
→ raw 保留真实值,只对 Agent 可见层脱敏(查询照常执行,输出才遮)
|
||||
```
|
||||
|
||||
### 6.2 有界(三重截断 + 兜底)
|
||||
|
||||
```text
|
||||
行数(maxRows) + 单元格字符(maxCellChars) + 总字节(maxResultBytes)
|
||||
列名必须非空且唯一(防歧义投影)
|
||||
fitBudget 兜底:逐行裁掉尾部 → 裁空诚实降级 NO_EVIDENCE → 仍超限 fail closed
|
||||
```
|
||||
|
||||
### 6.3 客观证据语义
|
||||
|
||||
```text
|
||||
rows 空 → NO_EVIDENCE;非空 → EVIDENCE_FOUND
|
||||
→ 与 RAG 的 evidence_status 同一套契约(证据状态由「有没有内容」客观决定)
|
||||
```
|
||||
|
||||
## 7. 与 RAG 对照(同类架构,不同复杂度)
|
||||
|
||||
| | query_mysql | lookup_knowledge |
|
||||
|---|---|---|
|
||||
| 安全层 | 有(Validator + 只读连接 + 脱敏) | 无(Milvus 天然只读检索) |
|
||||
| 后端复杂度 | 简单(Validator→Executor→Projector) | 复杂(三段 + 降级 + 双 trace) |
|
||||
| raw → 契约 | MysqlRawResult → MysqlToolResult | LookupResult → RagToolResult |
|
||||
| 数量限制 | maxRows=100 / 64KB | returnN=5 / 8 条 / 16KB |
|
||||
| 证据语义 | rows 空不空(NO_EVIDENCE/EVIDENCE_FOUND) | evidenceBlocks + relevance_level |
|
||||
| 与 Harness 衔接 | 同为 Boundary/Projector/Adapter 模式 | 同为 Boundary/Projector/Adapter 模式 |
|
||||
|
||||
## 8. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Validator 只查 SELECT | 还有白名单表列、禁字面量、禁通配符、占位符计数 |
|
||||
| 语义层够了 | 连接层 setReadOnly + 参数化是物理防线(纵深防御) |
|
||||
| 查询独立于 Run | 查询受 Run 取消/超时联动(onCancel → statement.cancel) |
|
||||
| 脱敏在查询层 | 脱敏在投影层(raw 保留真实值,只对 Agent 脱敏) |
|
||||
| 有界只限行数 | 行 + 单元格 + 字节三重截断 + fitBudget 兜底 |
|
||||
| 安全异常抛原样 | 统一映射 INVALID_REQUEST(不泄露内部细节) |
|
||||
| 字面量可以清洗放行 | 字面量全拒必须参数化(清洗是弱防线,参数化是强防线) |
|
||||
|
||||
## 9. 面试话术(30 秒)
|
||||
|
||||
> "query_mysql 是三层防线的只读沙箱:**语义层**(JSqlParser 解析 AST,fail-closed 拒绝一切不安全形态——非 SELECT、多语句、子查询、通配符、字面量、未授权表列、锁读全部拒绝,只允许白名单表列的 INNER/LEFT JOIN 和聚合 + 参数化占位符,且占位符数量必须与 params 匹配);**连接层**(JDBC setReadOnly + PreparedStatement 参数化 + 超时 + maxRows + 取消联动——Run 取消立即 cancel 语句);**输出层**(敏感列脱敏 [REDACTED] + 行/单元格/字节三重截断 + rows 空不空定证据状态)。安全异常统一映射稳定错误码,不泄露内部细节。"
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `MysqlToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java` |
|
||||
| `MysqlSqlValidator` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java` |
|
||||
| `JdbcMysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java` |
|
||||
| `MysqlResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java` |
|
||||
| `MysqlDataSourceDefinition` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java` |
|
||||
| `MysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java` |
|
||||
| `MysqlQueryPlan` / `MysqlRawResult` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java` 等 |
|
||||
| 契约(MysqlToolRequest/Result) | `src/main/java/com/superbiz/agent/harness/tool/contract/MysqlTool*.java` |
|
||||
@@ -0,0 +1,352 @@
|
||||
# Harness RAG 检索体系学习笔记:从 query 到可验证证据
|
||||
|
||||
> **现状说明(2026-09-29)**:RAG 模块抽离后,本文「检索前 L0 导航」与
|
||||
> `MilvusHybridKnowledgeStore` / `KnowledgeQueryTransformer` 相关章节描述的类已删除
|
||||
> (L0 与向量检索下沉 py-rag 服务端);检索后处理/打包/投影/审计部分仍然现行。
|
||||
> 当前架构见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:lookup_knowledge 完整后端链路——检索前/检索/检索后/打包/组装/降级/契约/验证
|
||||
**设计文档**:`mvp/engineering/rag/`(RAG 排序、Hybrid 质量分、relevance_level 等)
|
||||
**代码视角**:[Harness tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链衔接)
|
||||
|
||||
## 1. 定位与骨架
|
||||
|
||||
一次 `lookup_knowledge` 从 query 到证据的旅程(模块化三段 + 收尾):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["query"] --> A["检索前<br/>KnowledgeQueryTransformer<br/>L0 导航(分类/域/关键词)"]
|
||||
A --> B["检索<br/>KnowledgeDocumentRetriever<br/>dense + BM25 → RRF 融合"]
|
||||
B --> C["检索后<br/>KnowledgeEvidencePostProcessor<br/>qualityScore/去重/判级/闸门"]
|
||||
C -->|"低质"| B2["降级重试<br/>UNFILTERED_VECTOR_RETRY<br/>(去过滤 + 原始 query)"]
|
||||
C --> D["打包<br/>KnowledgeContextPacker"]
|
||||
D --> E["组装<br/>LookupResultAssembler<br/>→ LookupResult"]
|
||||
E --> F["投影<br/>RagResultProjector<br/>→ RagToolResult(Agent 契约)"]
|
||||
```
|
||||
|
||||
**关键特征**:模块化三段各自独立 Service、降级是阶段间控制流、双轨可观测(RetrievalTrace + RerankTrace)、LookupResult 是内部契约(Agent 看到的是投影后的 RagToolResult)。
|
||||
|
||||
## 2. 检索前:L0 导航(缩短边界,不决定边界)
|
||||
|
||||
- **产出**:categoryFilter / domainHints / matchedKeywords / entities / l0Titles(从 query 语义推导)
|
||||
- **只缩短边界**:categoryFilter 限定「搜哪些分类」(FILTERED_VECTOR)
|
||||
- **不决定边界**:低质 → 降级去掉过滤重查(L0 边界可被推翻)
|
||||
- **只解释不打分**:L0 命中只写 hitReasons(l0_domain_overlap),不改 qualityScore 和排序——防关键词碰瓷
|
||||
- **语义差异应对**:降级用原始 query(非 rewritten)——抹掉 L0 推导误差
|
||||
|
||||
## 3. 检索:多路召回 + RRF
|
||||
|
||||
**为什么混合检索**:旧方案(dense 语义 + L0 关键词加权重排)有词频碰瓷误差——词频高但相关性不高的排前面。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 召回
|
||||
D["dense ANN(L2)<br/>抓语义相似"]
|
||||
S["sparse BM25<br/>抓精确匹配"]
|
||||
end
|
||||
D --> R["RRF 融合<br/>score = Σ w/(k + rank)"]
|
||||
S --> R
|
||||
R --> F["融合排序(originalRank)"]
|
||||
```
|
||||
|
||||
**关键决策**:
|
||||
- **RRF 用排名不用分数**——屏蔽跨路分数尺度不可比(dense 的 L2 vs BM25 的稀疏分)
|
||||
- **k=60**(`retrieval.hybrid.rrf-k`)——平滑参数,排名差异对分数的影响平缓
|
||||
- **加权是预留能力**:RrfFusion 支持 `w/(k+rank)`,但当前 Milvus 服务端走等权 RRFRanker(只传 k)——想让某路更可信时再调旋钮
|
||||
- **召回优先**(RAG 排序文档观点):候选池只有 3 条时,精排只能换座位,召不回的内容永远排不上来
|
||||
|
||||
## 4. 检索后:qualityScore 统一 + 质量闸门
|
||||
|
||||
### 4.1 为什么需要统一分数
|
||||
|
||||
三路返回三种分数(L2 距离 / BM25 稀疏分 / RRF 融合分)——不可比,必须统一成 qualityScore ∈ [0,1]。
|
||||
|
||||
### 4.2 打分(RetrievalScoreNormalizer)
|
||||
|
||||
```text
|
||||
DENSE: l2ToQuality(score) = 1 - clamp(L2)/maxL2 (maxL2 默认 2.0)
|
||||
HYBRID: denseDistance != null ? l2ToQuality(denseDistance) ← 恢复绝对质量
|
||||
: rankToQuality(rank, batchSize) ← BM25-only 保守回退
|
||||
```
|
||||
|
||||
**denseDistance 的来源**(隐藏机制):hybrid 融合后**再单独跑一次 searchDense**,按 id 把 L2 补到融合结果上——因为服务端 RRF 只输出融合分,原始 L2 信息丢了。`attachDenseDistances` 只填充不改 score/label/order(排序评估分离的又一体现)。
|
||||
|
||||
### 4.3 排序与评估分离(核心设计)
|
||||
|
||||
```text
|
||||
originalRank(RRF 融合序)→ 排序:谁在前面(相对序)
|
||||
qualityScore(L2/rank) → 评估:够不够格、要不要降级(绝对度)
|
||||
→ 排序不用 quality 重排(防 boost 操纵)
|
||||
→ quality 只被判级和闸门消费
|
||||
```
|
||||
|
||||
**为什么排名不能证明质量**:排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)——排第 1 只代表「这批里最好」,不代表「够好」(候选池全是低质时排第 1 的也低质);RRF 分本身不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别。
|
||||
|
||||
### 4.4 五步流程(process)
|
||||
|
||||
```
|
||||
① 打分(toQualityScore)→ ② 排序(originalRank,不用 quality 重排)
|
||||
③ 去重/截断:evidenceKey 合并 + maxChunksPerDocument=2 + returnN=5
|
||||
④ 判级:top qualityScore ≥0.75→PRECISE / ≥0.5→REFERENCE
|
||||
⑤ 闸门:topSimilarity <0.5 → 低质 → 降级重查
|
||||
```
|
||||
|
||||
**两级去重**(粒度不同):
|
||||
```
|
||||
evidenceKey 去重(chunk 级):同 chunk(docId#chunkIndex)被两路召回 → mergeEvidence 合并
|
||||
—— mergeEvidence 只合并 hitReasons + 补 breadcrumb,不处理 content(同 chunk 内容相同)
|
||||
maxChunksPerDocument(文档级):同文档不同 chunk 最多 2 个 → 防单文档垄断证据槽位
|
||||
```
|
||||
|
||||
**判级用 ranked(全量)不是 deduped**——判级评估「整体质量」(全量 top),去重决定「输出内容」(合并片段),两件事平行。
|
||||
|
||||
**BM25-only 的弱点**:无 dense 邻居 → rankToQuality 回退(排第 1 恒为 1.0)——整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)。改善方向:文本相似度兜底(绝对信号)+ 批次一致性检查(整体水平),而非返回 BM25 分(统计度量无绝对语义)。
|
||||
|
||||
## 5. 契约语义:relevance_level 是什么、不是什么
|
||||
|
||||
- **是什么**:一次 lookup_knowledge 调用整体有多相关的**粗档标签**(判级产出:PRECISE/REFERENCE/null)
|
||||
- **不是什么**:不是单条 evidence 的分数、不是相似度数值、不是「结论可发布」判据
|
||||
- **Agent 正确用法**:evidence_status 管有没有证据,relevance_level 管这批评据多硬/要不要再查,实际写诊断引用的是 evidence[].excerpt
|
||||
- **REFERENCE ≠ NO_GAIN**:一般相关可能仍排除一个假设——语义价值由模型判断(progress 原则)
|
||||
|
||||
## 6. 打包与组装
|
||||
|
||||
### ContextPacker(打包)
|
||||
|
||||
```
|
||||
输入顺序即优先级 → 逐条塞进 4000 字符预算
|
||||
单条放不下 → content 截断(+"...")
|
||||
连 header 都放不下 → 整条省略(记 omittedSources)
|
||||
输出:packedText + strategy + charBudget/usedChars + included/omittedSources
|
||||
```
|
||||
|
||||
**当前定位**:Agent 主要看结构化 evidence 列表,packedText 更多用于内部/调试/审计(Harness 用结构化列表因为可验真——evidence_ref 引用 document_id)。
|
||||
|
||||
### LookupResultAssembler(组装)
|
||||
|
||||
- **内部契约出口**:found / evidenceBlocks / 双数量 / 双 trace / relevanceLevel / completenessHint / message
|
||||
- **found 在这里综合判定**:evidence.hasUsableEvidence()
|
||||
- **message 语义**:found=false 时「知识库未检索到可用证据,请结合日志、指标、告警继续排查」——证据不足不是失败,是换方向引导
|
||||
- **与投影的关系**:LookupResult 是后端完整出口,RagResultProjector 再裁剪成 Agent 契约(两层契约)
|
||||
|
||||
## 7. 降级:突破 L0 边界的兜底
|
||||
|
||||
```
|
||||
触发:categoryFilter != null && isLowQuality(无证据 或 topSimilarity < 0.5)
|
||||
动作:原始 query(非 rewritten)+ 去掉分类过滤 + 覆盖选择(不合并两轮)
|
||||
原因分类:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(完全没查到)
|
||||
有限降级:只一次(防重查风暴);retry 低质也接受(降级失败直接返回)
|
||||
trace:attempts 记录全部尝试(FILTERED/UNFILTERED/UNFILTERED_RETRY)
|
||||
+ selectedAttempt + fallbackReason → 可对比「过滤 vs 全域」判断 L0 过滤是否过度
|
||||
```
|
||||
|
||||
## 8. 投影衔接(RagResultProjector)
|
||||
|
||||
- **契约转换**:LookupResult JSON → RagToolResult(evidence[] + relevance_level + evidence_status + truncated)
|
||||
- **四重有界**:query 500 字 / excerpt 1200 字 / 条数 8(maxEvidence)/ 总字节 16KB(fitBudget)
|
||||
- **优先级链去重**:evidenceKey → document_id → docId#chunk-idx → legacy 序号(chunk 级身份)
|
||||
- **诚实标记**:一切有损(截断/去重丢弃/query 截断)都置 truncated;fitBudget 裁空诚实降级 NO_EVIDENCE + relevance 置 null(没有证据就没有相关度,自洽)
|
||||
- **两级数量限制**:后端 returnN=5(业务目标值)vs maxEvidence=8(Harness 护栏)——5<8 时护栏休眠,后端配置失控时兜底
|
||||
|
||||
## 9. 验证:审计 + 离线评测
|
||||
|
||||
### 9.1 审计 vs Trace(两套记录系统)
|
||||
|
||||
| | DiagnosisTrace(事件流) | ToolInvocationAudit(调用档案) |
|
||||
|---|---|---|
|
||||
| 粒度 | 事件(一次调用多个事件) | 记录(一次调用一行) |
|
||||
| 覆盖 | Run 全生命周期 | 仅工具调用 |
|
||||
| 内容 | metadata-only(不存 raw) | 完整(raw + agentResult + enrichments) |
|
||||
| 用途 | 时序回放 / Token 对账 | 单次调用深查 |
|
||||
|
||||
```
|
||||
RAG 后端双 trace(Retrieval/Rerank)→ 进 LookupResult → Harness
|
||||
→ 安全字段提取 → tool_invocation 审计(enrichments)
|
||||
→ 轻量事件 → DiagnosisTrace
|
||||
审计在投影之后、给模型之前(ToolBoundary.execute 内先落库)
|
||||
```
|
||||
|
||||
### 9.2 离线评测设计(eval/rag-retrieval)
|
||||
|
||||
```
|
||||
三层:offline(fixtures × golden-cases,无真实栈)/ snapshot 生成(真实跑一次冻结)/ live smoke
|
||||
断言:行为契约(expectedDocIds/Breadcrumbs/Keywords/SelectedAttempt/FallbackReason/EvidenceStatus)
|
||||
+ 不断言 raw scores / chunkId / 全序(脆或内部实现)
|
||||
基线:baseline.json + diff(区分有意改进 vs 无意回归)
|
||||
隔离:seed-docs + kb_scope=rag-eval
|
||||
```
|
||||
|
||||
**断言设计原则**:
|
||||
- 断言「用户可感知的结果 + 管道行为」,不断言「实现细节」(chunk/分数/全序)
|
||||
- 双信号:fail 抓行为回归 + diff 抓「绿了但漂了」(通过但退化可见)
|
||||
- **期望来自设计意图,不从当前输出反推**(否则固化 bug)
|
||||
- golden set 是演进的:初期种子(设计意图)→ 中期真实数据(**必须人工验证**——成功案例只是观测,不是契约)→ 持续事故固化
|
||||
|
||||
### 9.3 指标(缺的下一步)
|
||||
|
||||
```
|
||||
检索质量:recall@k / Hit@k / MRR(复用 expectedDocIds + rank,低成本)
|
||||
管道行为:降级触发正确率 / 降级有效率 / 过滤误伤率(复用 trace 字段)
|
||||
质量闸门:低质识别准确率 / 降级误杀率(BM25-only 弱信号代价可测)
|
||||
需要新标注:precision@k(负例)/ nDCG(相关度分级)
|
||||
```
|
||||
|
||||
## 10. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| RRF 排序了就不用打分 | 排序(RRF)与评估(qualityScore)分离——RRF 管谁在前,L2 管够不够格 |
|
||||
| 排第 1 = 质量好 | 排第 1 只代表「这批里最好」——候选池全低质时排第 1 也低质 |
|
||||
| 返回 BM25 分能解决 BM25-only | BM25 是统计度量(无界/依赖集合),无绝对语义——用文本相似度/一致性检查 |
|
||||
| denseDistance 是融合分 | 是融合后再跑一次 dense 探测的 L2(RRF 丢了原始 L2) |
|
||||
| 去重和判级有先后 | 平行:去重管输出(deduped),判级管评估(ranked 全量 top) |
|
||||
| 后端 returnN=5,maxEvidence=8 多余 | returnN 是业务目标值,maxEvidence 是 Harness 护栏(防配置失控) |
|
||||
| 线上成功案例可直接当 golden | 成功只是「当前实现没出错」的观测——必须人工确认设计意图 |
|
||||
| 审计在给模型之后 | 审计在投影后、给模型前(ToolBoundary 内先落库) |
|
||||
|
||||
## 11. 讨论沉淀:值得记住的问题与洞见
|
||||
|
||||
本节收录学习过程中的关键问答——按价值分层,面试准备直接翻这里。
|
||||
|
||||
### 11.1 触及设计本质(第一梯队)
|
||||
|
||||
**① 「RRF 已经排序了,为什么还要打分」——排序与评估分离**
|
||||
|
||||
```text
|
||||
RRF 管「谁在前面」(融合排序,相对序)
|
||||
qualityScore 管「够不够格」(质量评估,绝对度)
|
||||
|
||||
为什么排名不能证明质量:
|
||||
排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)
|
||||
排第 1 只代表「这批里最好」,不代表「够好」——候选池全是低质时排第 1 也低质
|
||||
RRF 分不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别
|
||||
```
|
||||
|
||||
**② 「最终落地到 dense 决定,RRF 白用了吗」——谁被评估 vs 评估够不够**
|
||||
|
||||
```text
|
||||
RRF/BM25 管:召回 + 排序(BM25 路召回 dense 召不回的候选,RRF 让两路共识靠前)
|
||||
dense L2 管:质量评估的绝对标尺(唯一有绝对语义的)
|
||||
→ 分工:RRF 决定「谁能被评估」,dense 决定「评估结果够不够」
|
||||
→ 「排第一但 dense 低质 → 降级」不是矛盾,是排序与评估分离的价值(发现域选错)
|
||||
```
|
||||
|
||||
**③ 「能不能返回 BM25 分当质量」——度量类型决定能否设阈值**
|
||||
|
||||
```text
|
||||
几何度量(L2):embedding 空间稳定 → 能设 0.75/0.5 绝对阈值
|
||||
统计度量(BM25):无界、依赖集合 IDF、随集合演进漂移 → 设不了稳定阈值
|
||||
→ 质量评估需要绝对标尺,只能来自几何度量或可解释相似度(字符重叠)
|
||||
→ 改善 BM25-only:文本相似度兜底 / 批次一致性检查 / fail-closed,而非返回 BM25 分
|
||||
```
|
||||
|
||||
**④ 「BM25-only 质量有问题」(自己发现的设计弱项)**
|
||||
|
||||
```text
|
||||
rank 回退:排第 1 恒为 1.0 → 整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)
|
||||
根因:rank 是相对序(第 1 名不代表够 0.5),顶位给满分是「排序最好 = 质量满分」的错误等价
|
||||
改进:rank 顶位保守化 / 文本相似度兜底 / 批次一致性检查
|
||||
```
|
||||
|
||||
### 11.2 隐藏机制(第二梯队)
|
||||
|
||||
**⑤ 「denseDistance 是融合分还是 dense 分」**
|
||||
|
||||
```text
|
||||
是 dense 那一路的 L2——RRF 服务端融合只输出融合分,原始 L2 信息丢了
|
||||
→ attachDenseDistances 融合后再单独跑一次 searchDense,按 id 把 L2 补到融合结果
|
||||
→ 只填充不改 score/label/order(排序评估分离的又一体现)
|
||||
```
|
||||
|
||||
**⑥ 「为什么降级只一次」——有限降级**
|
||||
|
||||
```text
|
||||
降级 = 突破 L0 边界重查(原始 query + 去过滤 + 覆盖选择)
|
||||
只降级一次:预算约束(retrieveK × 2 检索成本)防重查风暴
|
||||
fallbackReason 区分:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(没查到)
|
||||
```
|
||||
|
||||
**⑦ 「returnN=5 为什么 maxEvidence=8」——目标值 vs 护栏**
|
||||
|
||||
```text
|
||||
returnN=5:RAG 业务目标值(rag.return-n)——打算给 5 条
|
||||
maxEvidence=8:Harness 安全上限(ToolProjectionLimits)——最多允许多少
|
||||
两级解耦:业务层和安全层各自配置;5<8 时护栏休眠,后端配置失控时兜底
|
||||
```
|
||||
|
||||
### 11.3 方法论沉淀(第三梯队,可迁移)
|
||||
|
||||
**⑧ 从 0 设计离线评测的八步**
|
||||
|
||||
```text
|
||||
目标(回归保护)→ 粒度(工具级)→ 输入(冻结快照)→ 断言(行为契约)
|
||||
→ 用例(行为维度覆盖)→ 隔离(种子数据)→ 基线(区分有意/无意变化)→ 成本(分层运行)
|
||||
```
|
||||
|
||||
**⑨ golden set 怎么设计**
|
||||
|
||||
```text
|
||||
行为清单 → 每个行为一个 case → query 拟真(能触发目标行为)
|
||||
→ 期望来自设计意图(不从当前输出反推——否则固化 bug)→ 补负例/边界
|
||||
→ 演进:初期种子打底 → 中期真实数据(人工验证后转契约)→ 持续事故固化
|
||||
```
|
||||
|
||||
**⑩ 「线上成功案例能不能直接用」——观测 ≠ 契约**
|
||||
|
||||
```text
|
||||
线上成功只是「当前实现没出错」的观测:可能恰好没触发 bug 路径、结果碰巧对
|
||||
→ 必须人工确认「结果确实符合设计意图」后才从观测升级为契约
|
||||
→ 失败案例则明确「期望应该怎样」作回归保护
|
||||
```
|
||||
|
||||
**⑪ 「不用 chunk 断言也是数据原因吗」——不是**
|
||||
|
||||
```text
|
||||
chunk 边界是切分实现细节:算法优化/文档微调都让 chunk 偏移 → 合法重构被误判回归
|
||||
契约语义的证据单位是文档级(document_id 常等于 source)——chunk 模型都看不到
|
||||
→ 即使数据充足也不该断言 chunk(和数据量无关)
|
||||
```
|
||||
|
||||
### 11.4 三个核心洞见(最值得记住)
|
||||
|
||||
```text
|
||||
① 排序与评估分离:RRF 管「序」(相对),L2 管「度」(绝对)——排名不能证明质量
|
||||
② 度量类型决定能不能设阈值:几何(L2)可以,统计(BM25)不行
|
||||
③ 期望来自设计意图,不从实现反推——这是评测和 golden set 的分水岭
|
||||
```
|
||||
|
||||
## 12. 面试话术(30 秒)
|
||||
|
||||
### 11.1 排序与评估为什么分离
|
||||
|
||||
> "RRF 管『谁在前面』(融合排序),qualityScore 管『这批结果够不够格』(质量评估)——打分不是重排,是排序后的质量校验。RRF 分是排名派生的相对值,没法设绝对阈值(排第 1 不代表够 0.5,候选池全是低质时排第 1 的也低质);qualityScore 把 dense L2 归一化成 [0,1] 的绝对质量,用于判级(0.75/0.5 阈值)和闸门(<0.5 触发降级)。排序决定看哪些,评估决定够不够好。"
|
||||
|
||||
### 11.2 为什么 BM25 分不能当质量
|
||||
|
||||
> "质量评估需要绝对标尺,绝对标尺只能来自几何度量(L2 距离——embedding 空间稳定)或可解释的相似度(字符重叠),不能来自统计度量(BM25——无界、依赖文档集合的 IDF、随集合演进漂移)。BM25-only 命中用排名回退估质量(保守),但顶位给满分是设计弱项——改进方向是文本相似度兜底或批次一致性检查,而不是返回 BM25 分。"
|
||||
|
||||
### 11.3 降级设计
|
||||
|
||||
> "降级是突破 L0 过滤边界的兜底:带分类过滤检索结果低质(无证据或 topSimilarity<0.5)时,用原始 query + 去掉分类过滤重查一次(UNFILTERED_VECTOR_RETRY),结果覆盖选择、记录进 trace。fallbackReason 区分『查到了但低质』vs『完全没查到』;只降级一次(预算约束防重查风暴),降级失败也直接以低质结果返回。attempts 列表让审计能对比过滤 vs 全域检索差异,判断 L0 过滤是否过度。"
|
||||
|
||||
### 11.4 golden set 怎么设计
|
||||
|
||||
> "golden set 是行为契约的清单:先列要保护的行为,每个行为一个 case(不耦合可定位);query 用能触发目标行为的真实形态;期望来自设计意图(我知道这个文档属于这个场景),绝不从当前输出反推(否则固化 bug);补负例与边界;golden set 是演进的——初期人为种子打底,中期真实数据必须人工验证后才能转契约,持续事故修复固化。核心:断言用户可感知的结果 + 管道行为,不断言实现细节。"
|
||||
|
||||
## 13. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `LookupKnowledgeTool` | `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java` |
|
||||
| `KnowledgeQueryTransformer` | `src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java` |
|
||||
| `KnowledgeDocumentRetriever` | `src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java` |
|
||||
| `KnowledgeEvidencePostProcessor` | `src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java` |
|
||||
| `KnowledgeContextPacker` | `src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java` |
|
||||
| `LookupResultAssembler` | `src/main/java/com/superbiz/agent/service/LookupResultAssembler.java` |
|
||||
| `RetrievalScoreNormalizer` | `src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java` |
|
||||
| `RrfFusion` | `src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java` |
|
||||
| `MilvusHybridKnowledgeStore` | `src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java` |
|
||||
| `RagResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java` |
|
||||
| 审计链路 | `src/main/java/com/superbiz/agent/harness/audit/`(RagLookupAuditEnricher / JpaToolInvocationAuditSink) |
|
||||
| 离线评测 | `eval/rag-retrieval/` + `scripts/eval_rag_retrieval.py` |
|
||||
@@ -0,0 +1,203 @@
|
||||
# Harness Tool 调用链:一次工具调用的完整旅程
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:从「模型决定调用工具」到「模型收到观察」的运行时完整链路——拦截器 → invoke → Adapter → ToolBoundary → 返回 → 二次加工 → ToolCallResponse
|
||||
**结构篇**:[Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(讲装配/注册/静态结构)
|
||||
**本文**:动态时序(一次调用怎么跑完)
|
||||
|
||||
## 1. 旅程全景(一张图)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as 模型
|
||||
participant F as 框架 ReactAgent
|
||||
participant I as HarnessToolInterceptor(per-Run)
|
||||
participant ET as HarnessEvidenceTools(单例)
|
||||
participant AD as RagToolAdapter(单例)
|
||||
participant TB as ToolBoundary(单例)
|
||||
participant P as DiagnosisProgressTracker
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over M,I: 阶段 A:模型决定 → 拦截器(执行前)
|
||||
M->>F: 输出 tool_call(工具名 + 参数 JSON)
|
||||
F->>I: 回调 interceptToolCall(request, handler)
|
||||
I->>I: ① supports 注册检查
|
||||
I->>ET: ② parse(typed 严格契约)
|
||||
ET-->>I: ParsedAgentToolCall(previous_observation + input)
|
||||
I->>P: ③ 协议校验(pending 评价)+ 判重
|
||||
end
|
||||
|
||||
rect rgb(255, 250, 240)
|
||||
Note over I,TB: 阶段 B:invoke → 执行(backend + 投影)
|
||||
I->>ET: ④ invoke(context, toolName, toolCallId, args)
|
||||
ET->>AD: bridge 闭包 → adapter.execute(context, envelope)
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>TB: ⑤ 五阶段:preflight/预算/begin → executor 跑 backend → 校验 → projector 投影 → markReady
|
||||
TB-->>I: ToolBoundaryResult(READY/ERROR)
|
||||
end
|
||||
|
||||
rect rgb(245, 255, 245)
|
||||
Note over I,M: 阶段 C:返回 → 模型(执行后)
|
||||
I->>I: ⑥ 双源校验(controlView 重读 evidence_status)
|
||||
I->>P: ⑦ recordCompleted(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
|
||||
I->>I: ⑧ modelObservation 加工(有界观察 + stop_required/reason)
|
||||
I-->>F: ToolCallResponse.of(toolCallId, toolName, observation)
|
||||
F-->>M: observation 作为本轮 tool 结果
|
||||
end
|
||||
```
|
||||
|
||||
**三个阶段**:A 执行前(模型决定→门禁)→ B 执行中(invoke→backend→投影)→ C 执行后(校验→记账→成型)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段 A:模型决定 → 拦截器(执行前)
|
||||
|
||||
### 2.1 模型怎么知道有这个工具
|
||||
|
||||
```
|
||||
模型 → callbacks 里看到工具(名字+描述+Schema)→ 决定调用 lookup_knowledge
|
||||
→ 输出 tool_call JSON(工具名 + 参数)
|
||||
```
|
||||
|
||||
工具名是**模型决定的**——框架把模型输出包成 `ToolCallRequest`(含 toolName + arguments),回调拦截器。
|
||||
|
||||
### 2.2 拦截器的三道执行前门
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["① supports(toolName)?"] -->|"否(非证据工具)"| X["handler.call 透传"]
|
||||
A -->|"是"| B["② parse:typed 严格契约<br/>FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS"]
|
||||
B -->|"违规"| Y["协议处理(不执行)"]
|
||||
B --> C["③ 协议校验(pending 评价)+ 判重"]
|
||||
C -->|"重复"| Z["recordDuplicateScope(不执行)"]
|
||||
C -->|"通过"| D["进入阶段 B:invoke"]
|
||||
```
|
||||
|
||||
关键:**不是「拿到名字就执行」**——parse(模型输出必须精确匹配 `RagToolCall{previous_observation, input}`,多一个字段都炸 INVALID_ENVELOPE)、协议校验、判重,三道门不通过都不执行 backend。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段 B:invoke → 执行(backend + 投影)
|
||||
|
||||
### 3.1 invoke 的委托链
|
||||
|
||||
```
|
||||
I.invoke(context, "lookup_knowledge", "call-1", args)
|
||||
→ ET.invokers.get("lookup_knowledge") ← 注册表取 bridge 闭包
|
||||
→ bridge lambda:adapter.execute(context,
|
||||
new ToolCallRequestEnvelope(runId, "call-1", "lookup_knowledge", args, true, true))
|
||||
→ ragAdapter.execute(context, envelope)
|
||||
→ boundary.execute(context, envelope, executor, projector)
|
||||
```
|
||||
|
||||
**envelope 是 bridge 里现造的**:`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」。
|
||||
|
||||
### 3.2 Adapter 组装两个函数(接线员)
|
||||
|
||||
```java
|
||||
return boundary.execute(context, envelope,
|
||||
// executor:跑 backend 拿 raw(LookupResult 序列化成 JSON 文本)
|
||||
ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())),
|
||||
// projector:raw → 有界契约 + evidenceStatus
|
||||
raw -> projector.project(request, envelope.toolCallId(), raw));
|
||||
```
|
||||
|
||||
| 端口 | 干什么 | 产物 |
|
||||
|---|---|---|
|
||||
| `executor` | 调具体后端 | rawResponse(JSON 文本,执行链「货币」) |
|
||||
| `projector` | 净化定型 | ProjectedToolResult(agentResult, evidenceStatus) |
|
||||
|
||||
**模型永远看不到 raw**——raw 只用于校验、落 canonical、投影。
|
||||
|
||||
### 3.3 ToolBoundary 五阶段
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
|
||||
B["② executor.execute(requestJson) → backend raw"]
|
||||
C["③ raw 大小校验 + Run bytes 预留"]
|
||||
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["⑤ agent_result 校验 + bytes → markReady(READY) 或 markError(ERROR)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
返回 `ToolBoundaryResult(READY/ERROR)`——PROJECTING 永不外泄。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段 C:返回 → 模型(执行后)
|
||||
|
||||
### 4.1 拦截器的二次加工(不是直接返回)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ToolBoundaryResult"] --> B{"status == READY?"}
|
||||
B -->|"否"| E1["error observation<br/>BUDGET_EXHAUSTED 额外 markBudgetLimitReached"]
|
||||
B -->|"是"| C["⑥ 双源校验:controlView 重读 evidence_status"]
|
||||
C -->|"不一致"| E2["OBSERVATION_CONTRACT_MISMATCH 拒绝"]
|
||||
C -->|"一致"| D["⑦ recordCompleted<br/>NO_EVIDENCE → 立即 NO_GAIN<br/>FOUND → 挂 pending"]
|
||||
D --> F["⑧ modelObservation 加工<br/>(有界观察 + stop_required/reason)"]
|
||||
F --> G["ToolCallResponse.of(...) → 框架 → 模型"]
|
||||
```
|
||||
|
||||
### 4.2 双源校验(自洽性防线)
|
||||
|
||||
```text
|
||||
源1:result.evidenceStatus() ← Projector 投影时计算的声明值
|
||||
源2:controlView(agentResult).evidenceStatus() ← 从 agent_result 内容重读
|
||||
一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
|
||||
```
|
||||
|
||||
防止「声明有证据但内容空 / 声明无证据但内容有」的不一致状态进入 progress 记账。
|
||||
|
||||
### 4.3 给模型的对象形态
|
||||
|
||||
```
|
||||
ToolCallResponse.of(toolCallId, toolName, observation)
|
||||
observation = 有界观察:
|
||||
正常结果:脱敏后的契约内容(可能裁剪)
|
||||
饱和时: 附加 stop_required:true + reason
|
||||
协议错误:repair_required:true + violation_type/期望ID/指令
|
||||
```
|
||||
|
||||
框架把 observation 作为本轮 tool 结果给模型——**模型下一轮读取它,决定继续调用(带评价)还是输出 Draft 收尾**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 旅程的衔接点(模型视角的闭环)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["模型调工具"] --> B["观察(有界契约)"]
|
||||
B --> C{"模型决定"}
|
||||
C -->|"继续"| D["下次 Tool Call + previous_observation 评价"]
|
||||
C -->|"收尾"| E["输出 Draft → Release 发布"]
|
||||
D --> B
|
||||
```
|
||||
|
||||
**progress 协议的闭环**:模型每次继续调用,都要在 Envelope 里回带对上一轮的 GAINED/NO_GAIN 评价——这就是拦截器 ③ 校验的 pending 逻辑(可回看 progress 笔记)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键点总结
|
||||
|
||||
| 阶段 | 关键认知 |
|
||||
|---|---|
|
||||
| A 执行前 | 工具名是模型决定的;parse 是 typed 严格契约(输出必须匹配 Schema);三道门不通过不执行 |
|
||||
| B 执行中 | executor/projector 是 Adapter 组装进 boundary 的**参数**;执行链货币是 JSON 文本;模型永远看不到 raw |
|
||||
| C 执行后 | 拦截器不直接返回——双源校验 + progress 记账 + modelObservation 成型;ToolCallResponse 才是模型拿到的对象 |
|
||||
|
||||
## 7. 面试 30 秒说法
|
||||
|
||||
> "一次工具调用的完整旅程分三段:执行前,模型从 callbacks 看到工具并决定调用,拦截器做 supports 分流、typed 严格 parse、协议校验和判重——三道门不通过都不执行 backend;执行中,invoke 经 bridge 到 Adapter,Adapter 把 executor(跑 backend 拿 raw)和 projector(raw 投影成有界脱敏契约)组装进 ToolBoundary 的五阶段门禁,返回 ToolBoundaryResult;执行后,拦截器不直接返回——先双源校验 evidence_status,再 recordCompleted 记进度,再 modelObservation 加工成有界观察,最后包装成 ToolCallResponse 给模型。模型看到的永远是脱敏后有界的观察,raw 只进 canonical 供审计验真。"
|
||||
|
||||
## 8. 代码位置索引
|
||||
|
||||
| 环节 | 文件 |
|
||||
|---|---|
|
||||
| 拦截器(A/C 阶段) | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| 注册表 + parse + invoke | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| Adapter 组装(B 阶段) | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
|
||||
| 五阶段门禁 | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| 双源校验 + 观察成型 | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
|
||||
| 模型观察形态 | `src/main/java/com/superbiz/agent/harness/agent/ToolControlView.java` |
|
||||
@@ -0,0 +1,186 @@
|
||||
# Harness agent 域学习笔记:从框架 ReAct 接入到受控停止
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:agent 域完整链路——装配(Factory)/ 双拦截器(Model/Tool)/ 循环外壳(UseCase)/ 受控停止 / 双视图投影
|
||||
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链)、[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(Tool 拦截器五道门)
|
||||
|
||||
## 1. 定位:框架 ReAct 接入层(粘合点)
|
||||
|
||||
```text
|
||||
框架(spring-ai-alibaba ReactAgent):负责 ReAct 多轮(模型 ↔ tool_call)
|
||||
Harness:不复制 loop,只通过 Interceptor 卡住【每次消耗】
|
||||
→ Model Interceptor:每次模型调用(预算/审计/Token)
|
||||
→ Tool Interceptor:每次工具调用(五道门)
|
||||
|
||||
原则:拦截器是挂点,不是 loop 实现——Harness 不需要知道框架内部怎么循环
|
||||
```
|
||||
|
||||
## 2. 装配图(DiagnosisAgentFactory——粘合点)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 框架能力
|
||||
M["ChatModel"]
|
||||
T["tools<br/>evidenceTools.callbacks()"]
|
||||
L["ReactAgent 循环"]
|
||||
end
|
||||
subgraph Harness 控制面
|
||||
I1["HarnessModelInterceptor<br/>预算+Token 审计"]
|
||||
I2["HarnessToolInterceptor<br/>五道门+投影"]
|
||||
H["Hooks<br/>agent_step 落库"]
|
||||
O["outputSchema<br/>DiagnosisDraft(conclusion 可 null)"]
|
||||
end
|
||||
M --> L
|
||||
T --> L
|
||||
L --> I1
|
||||
L --> I2
|
||||
L --> H
|
||||
L --> O
|
||||
```
|
||||
|
||||
**关键装配决策**:
|
||||
|
||||
```text
|
||||
.parallelToolExecution(false) ← 串行工具:预算与 step 绑定可解释
|
||||
.returnReasoningContents(true) ← 推理内容返回
|
||||
.releaseThread(true)
|
||||
每次 run 新建 Agent(create(context))——拦截器持有 RunContext,不可跨 run 复用
|
||||
```
|
||||
|
||||
## 3. HarnessModelInterceptor(模型拦截器)
|
||||
|
||||
```text
|
||||
interceptModel(request, handler):
|
||||
core.beforeModelCall(context) ← 预算门(模型调用前扣预算)
|
||||
call = auditor.begin(...) ← 审计开始(Token 记账)
|
||||
response = handler.call(request) ← 框架实际调用
|
||||
recordUsage(call, response) ← 记 prompt/completion tokens
|
||||
core.checkActive(context) ← 终态检查(预算耗尽在此打断)
|
||||
return response
|
||||
异常:记 0 token + 上抛(不吞)
|
||||
```
|
||||
|
||||
**三个动作**:预算(beforeModelCall)→ 记账(auditor.begin/recordUsage)→ 终态(checkActive)——每次模型调用都被 Harness 卡住一次。Usage 字段 null/负数安全兜底(nonNegative)。
|
||||
|
||||
## 4. HarnessToolInterceptor(工具拦截器,已深学)
|
||||
|
||||
五道门(progress 会话已沉淀):证据工具必炸 handler → 拦截器唯一执行路径 → 边界投影 → 审计落库 → 返回。本会话只补装配视角:`interceptors` 列表里第二个,构造时注入 context + evidenceTools + objectMapper + traceRecorder。
|
||||
|
||||
## 5. DiagnosisAgentUseCase(循环外壳)
|
||||
|
||||
### 5.1 执行流程
|
||||
|
||||
```text
|
||||
execute(context, input):
|
||||
checkActive → 输入限制(query/previous_turn/input 字节)
|
||||
reserveRunBytes(input) ← 输入也占 Run 预算
|
||||
RunnableConfig.metadata 挂 RunContext ← 显式传递(避免隐式 ThreadLocal)
|
||||
agent = factory.create(context) ← 每次 run 新建
|
||||
response = agent.call(inputJson, config) ← ★ 框架跑完整个 ReAct 循环
|
||||
output → 字节限制 → reserveRunBytes(draft) → parse DiagnosisDraft
|
||||
→ completed(draft) 或 受控停止
|
||||
```
|
||||
|
||||
### 5.2 受控停止(controlledExecution)——从异常栈捞回可控信号转正常返回值
|
||||
|
||||
```text
|
||||
① DiagnosisCollectionStoppedException(信息饱和后仍强 tool)
|
||||
→ stopped(stopReason) ← 收集该停(draft=null)
|
||||
|
||||
② RunAbortedException + BUDGET_EXHAUSTED
|
||||
→ markBudgetLimitReached + stopped(BUDGET_LIMIT_REACHED)
|
||||
|
||||
③ 其他 RunAborted(取消/超时/内部失败终态)→ 原样再抛
|
||||
← 留给 Application 写 CANCELLED/FAILED(不降级为正常停止)
|
||||
|
||||
④ BudgetExceededException 或 lifecycle 已 BUDGET_EXHAUSTED → 预算 stopped
|
||||
|
||||
⑤ 都识别不了 → 包装 DiagnosisAgentOutputException(Agent 执行失败)
|
||||
```
|
||||
|
||||
**关键**:不是笼统「业务异常 → 正常」——**只识别 Harness 约定的可控信号**(沿 cause 链找,因框架可能再包一层);取消/超时必须上抛(诚实终态)。
|
||||
|
||||
### 5.3 与 recoverInvalidDraft 的分工
|
||||
|
||||
```text
|
||||
controlledExecution:loop 被预算/收敛打断(往往还没有合法 draft)→ stopped
|
||||
recoverInvalidDraft:loop 跑完了,但输出不是合法 DiagnosisDraft → 恢复/重试
|
||||
```
|
||||
|
||||
### 5.4 输出解析(严格)
|
||||
|
||||
```text
|
||||
draftReader = FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS(严格模式)
|
||||
JsonParseException → INVALID_JSON / SchemaInvalid → SCHEMA_INVALID(分类错误码)
|
||||
空输出 → EMPTY_DRAFT(分类错误码)
|
||||
```
|
||||
|
||||
## 6. 双视图投影(ToolResultViewProjector)
|
||||
|
||||
```text
|
||||
modelObservation() → 模型观察:只含该工具的内容字段
|
||||
lookup_knowledge → scope.query + evidence + relevance_level
|
||||
query_logs → source_kind + scope + patterns + events
|
||||
query_mysql → scope + columns + rows
|
||||
+ stop_required/reason(需要停止时附加)
|
||||
|
||||
controlView() → 控制视图:evidence_status / relevance_level / returned_count / truncated
|
||||
(Harness 控制面读,模型看不到)
|
||||
|
||||
分工:模型看到「内容」,Harness 看到「控制信息」(判级/截断/进度消费)
|
||||
```
|
||||
|
||||
## 7. 定义层小件
|
||||
|
||||
| 类 | 作用 |
|
||||
|---|---|
|
||||
| `DiagnosisAgentInput` | query + previous_turn(query 必填) |
|
||||
| `DiagnosisAgentLimits` | maxQuery/PreviousTurn/Input/DraftBytes 四类字节上限 |
|
||||
| `DiagnosisAgentPrompt` | classpath 加载系统提示词(prompts/diagnosis-agent-prompt.md) |
|
||||
| `DiagnosisAgentExecution` | completed(draft, progress) / stopped(progress, stopReason) 双形态 |
|
||||
| `DiagnosisDraftOutputSchema` | BeanOutputConverter postProcess:conclusion 允许 object/null(无结论合法) |
|
||||
| `EvidenceToolInvoker` | 函数式:RunContext + toolCallId + arguments → ToolBoundaryResult |
|
||||
| `ParsedAgentToolCall` | 解析出的工具调用(previousObservation + businessInput + arguments) |
|
||||
|
||||
## 8. 关键设计点(面试)
|
||||
|
||||
| 设计 | 为什么 |
|
||||
|---|---|
|
||||
| **不复制 loop** | 框架 ReAct 是标准能力;Harness 用拦截器挂在每次消耗点,不需要知道框架内部怎么循环 |
|
||||
| 每次 run 新建 Agent | 拦截器持有 RunContext——Agent 与 run 绑定,防跨 run 串状态 |
|
||||
| RunContext 显式传(metadata) | 避免隐式 ThreadLocal(框架线程池/异步下 ThreadLocal 不可靠) |
|
||||
| 串行工具(parallel=false) | 预算与 step 绑定可解释(并行会让「哪一步花多少钱」不可审计) |
|
||||
| 受控停止只认约定信号 | 取消/超时绝不降级为正常停止(诚实终态) |
|
||||
| conclusion 允许 null | 无结论也是合法 Draft(FALLBACK 路径) |
|
||||
| 双视图 | 模型观察 vs 控制视图分离——控制信息(判级/截断)不进模型上下文 |
|
||||
| 输出严格解析 | FAIL_ON_UNKNOWN/TRAILING——防止模型输出混入意外字段 |
|
||||
|
||||
## 9. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Harness 自己实现 Agent loop | 框架跑 loop,拦截器挂消耗点(不复制 loop) |
|
||||
| 任何异常都转 stopped | 只认约定信号(CollectionStopped/预算);取消/超时原样上抛 |
|
||||
| Agent 复用 | 每次 run 新建(拦截器绑定 RunContext) |
|
||||
| ThreadLocal 传 context | RunnableConfig metadata 显式传 |
|
||||
| 并行工具省时间 | 串行(预算与 step 绑定可解释) |
|
||||
| 模型看到控制信息 | 双视图:模型看内容,Harness 看控制 |
|
||||
| 输出宽容解析 | FAIL_ON_UNKNOWN + FAIL_ON_TRAILING(严格模式) |
|
||||
|
||||
## 10. 面试话术(30 秒)
|
||||
|
||||
> "agent 域是框架 ReAct 的接入层:不复制 loop——spring-ai-alibaba 的 ReactAgent 负责多轮循环,Harness 通过两个拦截器卡住每次消耗:Model Interceptor(每次模型调用前 checkActive + 预算,调用后记 Token 审计)、Tool Interceptor(工具调用五道门)。装配在 DiagnosisAgentFactory,每次 run 新建 Agent(拦截器绑定 RunContext,RunnableConfig metadata 显式传递避免 ThreadLocal)。循环外壳 DiagnosisAgentUseCase 做输入/输出字节限制 + Run 预算预留,并实现受控停止——只把 Harness 约定的可控信号(信息饱和、预算耗尽)从异常栈捞回转成 stopped,取消/超时原样上抛留给 Application 写 CANCELLED/FAILED。串行工具保证预算与 step 绑定可解释。"
|
||||
|
||||
## 11. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
|
||||
| `HarnessModelInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessModelInterceptor.java` |
|
||||
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| `DiagnosisAgentUseCase` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentUseCase.java` |
|
||||
| `ToolResultViewProjector` | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| `DiagnosisAgentExecution` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentExecution.java` |
|
||||
| `DiagnosisAgentOutputException` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentOutputException.java` |
|
||||
| 契约(DiagnosisDraft/PreviousTurn) | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
@@ -0,0 +1,166 @@
|
||||
# Harness application + audit 学习笔记:从 Run 编排到可回放审计
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:application 域(Run 全生命周期编排 + 安全落库)+ audit 域(可观测账本)——重点讲清 audit 与 trace 的设计与区别
|
||||
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
```text
|
||||
application = Run 应用所有者:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出
|
||||
audit = 可观测账本:Trace 时序回放 + Token 对账 + 各明细审计表(metadata-only)
|
||||
```
|
||||
|
||||
## 2. application 域:ChatApplicationUseCase 六步编排
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Controller → execute(request, observer)"] --> B["① 读会话上下文<br/>RoutingHistory + PreviousTurn"]
|
||||
B --> C["② core.startRun<br/>创建 Run 边界"]
|
||||
C --> D["③ persistStart + observer.onStarted<br/>(SSE metadata + 取消句柄 CoreRunControl)"]
|
||||
D --> E["④ router.route<br/>意图路由(单次模型调用)"]
|
||||
E --> F["⑤ executePath 按 intent 分叉<br/>SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS"]
|
||||
F --> G["⑥ completePath + persistFinish + 返回"]
|
||||
G -.异常.-> H["统一失败出口<br/>terminalOutcome + safeFailure"]
|
||||
```
|
||||
|
||||
### 2.1 关键设计点
|
||||
|
||||
| 设计 | 代码事实 | 意义 |
|
||||
|---|---|---|
|
||||
| 取消句柄 | `observer.onStarted(new CoreRunControl(core, context))` → `core.cancel(context, CLIENT_DISCONNECTED)` | 客户端断连 → 取消广播 → 强杀 in-flight 调用 |
|
||||
| 取消有原因 | `RunCancellationReason.CLIENT_DISCONNECTED` | 区分断连/用户取消,可审计 |
|
||||
| sessionId 白名单 | `SAFE_ID = [A-Za-z0-9][A-Za-z0-9._-]{0,63}` | 信任边界校验 |
|
||||
| 多轮记忆有界 | RoutingHistory(intent+query) + PreviousTurn(PublishedResult 有界化) | 上一轮只传安全摘要,无 tool ids / raw evidence |
|
||||
| 预算终态特殊处理 | `handledBudgetTermination()` 时不二次 completeSuccess | 不破坏 first-terminal-wins |
|
||||
| 统一失败出口 | terminalOutcome → CANCELLED/FAILED + ChatFailureCode(文案安全) | 不暴露内部堆栈 |
|
||||
| 路由输出契约 | `OUTPUT_FIELDS = {intent}`,值必须枚举名 | 防模型夹带 |
|
||||
|
||||
### 2.2 持久化(JpaChatRunStore + PublishedResultPolicy)
|
||||
|
||||
- start / markIntent / finish(@Transactional),finish 写 outcome + safeContentJson + publishedResult;
|
||||
- PublishedResultPolicy:**只有 SUCCESS 且 draft 有结论才构造 PublishedResult**;sanitize 全套有界(query 2000/结论 2000/scope 1000/limitations 10×500/文档 10);
|
||||
- sourceDocuments 只收 RAG 类型证据的 document_id(去重)——MySQL/日志证据不进发布文档列表;
|
||||
- PreviousTurn = sanitize 后的有界摘要(多轮记忆来源)。
|
||||
|
||||
## 3. audit 域:可观测账本的层次
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 写入侧["写入侧(不阻断主流程)"]
|
||||
H1["HarnessAgentAuditHook<br/>每模型步 → agent_step + agent_reasoning_audit"]
|
||||
H2["ToolInvocationAuditSink<br/>工具 → tool_invocation"]
|
||||
H3["ModelCallAuditor<br/>Token → ledger + core + agent_step 回写"]
|
||||
H4["JpaDiagnosisTraceRecorder<br/>事件 → diagnosis_trace_event"]
|
||||
H5["JpaChatRunStore<br/>Run → diagnosis_run"]
|
||||
end
|
||||
|
||||
subgraph 读取侧["读取侧(回放)"]
|
||||
S["DiagnosisTraceService"]
|
||||
S --> R["DiagnosisTraceResponse<br/>timeline + steps + toolInvocations + run + summary"]
|
||||
end
|
||||
|
||||
H1 --> DB[(MySQL 各表)]
|
||||
H2 --> DB
|
||||
H3 --> DB
|
||||
H4 --> DB
|
||||
H5 --> DB
|
||||
DB --> S
|
||||
```
|
||||
|
||||
### 3.1 各落库点(谁写哪张表)
|
||||
|
||||
| 落库点 | 表 | 内容 |
|
||||
|---|---|---|
|
||||
| HarnessAgentAuditHook | agent_step | 每模型步摘要(stepIndex/耗时/token/工具计划) |
|
||||
| HarnessAgentAuditHook | agent_reasoning_audit | 推理 + assistant_text 正文(受限) |
|
||||
| ToolInvocationAuditSink | tool_invocation | 工具入参/输出预览/检索明细 |
|
||||
| JpaDiagnosisTraceRecorder | diagnosis_trace_event | 全链路事件时序线 |
|
||||
| ModelCallAuditor | agent_step.token_count | Token 回写(仅 DIAGNOSIS_AGENT) |
|
||||
| JpaChatRunStore | diagnosis_run / chat_session | Run 生命周期 + 发布契约 |
|
||||
|
||||
### 3.2 audit 的三个核心边界(fail-safe / metadata-only / 对账)
|
||||
|
||||
1. **审计不阻断主流程**:三个落库点全部 try-catch + log.warn——审计挂了不能让 Run 跟着挂;
|
||||
2. **metadata-only 分层**:正文只允许出现在 agent_reasoning_audit(受限)和 tool_invocation(入参/输出预览),其余全部摘要化;
|
||||
3. **Token 三写闭环**:ledger 分账 → core.recordTokens(Run 预算)→ agent_step.token_count 回写——审计与预算同源可对账。
|
||||
|
||||
## 4. audit vs trace:设计与区别(重点)
|
||||
|
||||
### 4.1 核心区别:包含关系,不是并列
|
||||
|
||||
```text
|
||||
audit = 域(可观测账本的总集合,17 个文件)
|
||||
├─ ★ trace = 域内的事件回放子体系(诊断时序线)
|
||||
├─ ModelCallLedger / Auditor(Token 记账)
|
||||
├─ HarnessAgentAuditHook(模型步审计)
|
||||
├─ ToolInvocationAuditSink(工具审计)
|
||||
├─ RagLookupAuditEnricher(RAG 检索审计)
|
||||
└─ RunConclusionExtractor(结论提取)
|
||||
```
|
||||
|
||||
**常见误解修正**:trace 不是「agent 执行记录」,而是**全链路七阶段时序线**(RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);audit 也不只是「tool 审计」,tool 审计只是其中一小块。
|
||||
|
||||
### 4.2 一张表讲清区别
|
||||
|
||||
| 维度 | trace(diagnosis_trace_event) | audit(各明细账本) |
|
||||
|---|---|---|
|
||||
| 本质 | 时序事件流(按 sequence_no 排序) | 实体化明细记录 |
|
||||
| 回答 | 发生了什么、按什么顺序 | 每个细节落在哪本账上 |
|
||||
| 粒度 | 每帧只带摘要 + 关联键(step_id 等) | 完整字段(入参/输出/token/耗时) |
|
||||
| 结构 | 一张表、一个序列 | 多张表(agent_step/tool_invocation/...) |
|
||||
| 排序保证 | sequence_no 单调(ConcurrentHashMap 分配) | step_index / id / createdAt 排序 |
|
||||
| 典型查询 | `findByRunIdOrderBySequenceNoAscIdAsc` | `findByRunIdOrderByStepIndex` 等 |
|
||||
| 与明细的关系 | 靠 step_id / run_id 互链,不重复存储 | 承载被关联的实体数据 |
|
||||
|
||||
### 4.3 真实数据对照(run 1b584a01)
|
||||
|
||||
```text
|
||||
trace(15 帧时序线):
|
||||
RUN_STARTED → ROUTING_DECISION → AGENT_MODEL_STEP×2 → TOOL_INVOCATION
|
||||
→ EVIDENCE_GUARD_INITIAL → SEMANTIC_GUARD_DECISION → RELEASE_DECISION → RUN_FINISHED
|
||||
|
||||
audit 各账本(同 Run):
|
||||
agent_step 2 行(token 2461/4415,耗时 1418/8542ms)
|
||||
agent_reasoning_audit 2 行(step1 = 完整 Draft JSON)
|
||||
tool_invocation 1 行(lookup_knowledge,step_id=979)
|
||||
diagnosis_run outcome=SUCCESS + published_result 完整 JSON
|
||||
```
|
||||
|
||||
**关联示例**:trace 第 7 帧 TOOL_INVOCATION 的 details 里 `step_id=979` = agent_step.id=979 = tool_invocation.step_id——时序帧与明细账本通过 id 互链。
|
||||
|
||||
## 5. 可回放机制(DiagnosisTraceService)
|
||||
|
||||
```text
|
||||
GET /api/diagnosis/{sessionId}/trace?runId=xxx
|
||||
→ DiagnosisTraceResponse 七块:runId / chatSession / session / run /
|
||||
steps[] / toolInvocations[] / timeline[] / summary
|
||||
|
||||
GET /api/diagnosis/{sessionId}/trace/reasoning?runId=xxx(受限:runId 必填)
|
||||
→ agent_reasoning_audit(reasoning + assistantText)
|
||||
```
|
||||
|
||||
三级回放深度:**时间线(timeline)→ 明细(steps/toolInvocations)→ 推理(reasoning,按需受限读取)**。
|
||||
|
||||
回放能成立的四个保证:
|
||||
1. sequence_no 单调(索引 idx_trace_event_run_sequence);
|
||||
2. 事件与明细靠 step_id 互链;
|
||||
3. summary 的 persisted vs returned 双计数对账(发现落库不完整);
|
||||
4. 双查询入口兼容新旧会话(buildLegacyTraceResponse)。
|
||||
|
||||
## 6. 面试话术(30 秒)
|
||||
|
||||
> "application 域是 Run 的应用所有者:一次请求六步编排——建 Run 边界、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。**audit 域是可观测账本,trace 是它内部的事件回放子体系**:trace 用一张 diagnosis_trace_event 表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),audit 的明细账本(agent_step / tool_invocation / agent_reasoning_audit / diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账 → Run 预算 → 明细回写)。回放由 DiagnosisTraceService 按 runId 聚合排序,三级深度:时间线 → 明细 → 推理。"
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| ChatApplicationUseCase / ChatFailureCode / ChatApplicationStatus | `src/main/java/com/superbiz/agent/harness/application/` |
|
||||
| DiagnosisChatExecutor / 其他 executor | `.../application/executor/` |
|
||||
| IntentRouter / IntentRouterPrompt | `.../application/routing/` |
|
||||
| JpaChatRunStore / PublishedResultPolicy / RoutingHistory | `.../application/persistence/` |
|
||||
| Trace 体系(Recorder/Event/Type/Status/AuditEvents) | `src/main/java/com/superbiz/agent/harness/audit/`(前半) |
|
||||
| ModelCallLedger / ModelCallAuditor / HarnessAgentAuditHook / RunConclusionExtractor | `.../audit/`(后半) |
|
||||
| DiagnosisTraceService / DiagnosisTraceController | `src/main/java/com/superbiz/agent/service/` + `controller/` |
|
||||
| V014 建表(diagnosis_trace_event) | `src/main/resources/db/migration/V014__create_diagnosis_trace_event.sql` |
|
||||
@@ -0,0 +1,105 @@
|
||||
# Harness contract 状态流学习笔记:11 个状态枚举的正交全景
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:contract 域状态枚举全景——五层状态 / 正交维度 / 纵向映射链 / 真实数据案例 / 面试讲法
|
||||
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
**contract 状态流 = 11 个状态枚举按五层正交组织,每层回答一个独立问题;层间通过显式映射链串联——从技术终态到用户结局,从工具调用到发布裁决,全部类型化,杜绝字符串漂移。**
|
||||
|
||||
## 2. 五层状态全景
|
||||
|
||||
| 层 | 枚举 | 值 | 回答的问题 |
|
||||
|---|---|---|---|
|
||||
| ① 技术层(core) | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许执行 |
|
||||
| ② 证据层(tool/guard) | `InvocationStatus` | PROJECTING / READY / ERROR | 调用生命周期走到哪 |
|
||||
| | `EvidenceStatus` | EVIDENCE_FOUND / NO_EVIDENCE / ERROR | 这次调用有没有拿到可引用证据 |
|
||||
| | `AnalysisKind` | NORMAL / NEGATIVE_OBSERVATION | 分析条目是正向还是负向观察 |
|
||||
| ③ 收集层(progress) | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROTOCOL_VIOLATED | 证据收集为何受控停止 |
|
||||
| | `SemanticVerdict` | SUPPORTED / UNSUPPORTED | 结论是否被证据支持 |
|
||||
| ④ 发布层(release) | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局 |
|
||||
| | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | 为什么降级 |
|
||||
| ⑤ 协议层 + 失败层 | `ChatApplicationStatus` | ROUTING / SYSTEM_RESPONDING / KNOWLEDGE_SEARCHING / KNOWLEDGE_ANSWERING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING | 当前走到哪一阶段(进行中) |
|
||||
| | `SseOutcome` | SUCCESS / FALLBACK / FAILED | done 事件粗粒度结局(**未接线**) |
|
||||
| | `ChatFailureCode` | ROUTING_UNAVAILABLE / SYSTEM_CHAT_UNAVAILABLE / KNOWLEDGE_UNAVAILABLE / DIAGNOSIS_UNAVAILABLE / RUN_PERSISTENCE_FAILED / RUN_CANCELLED / INTERNAL_FAILURE | 失败时给客户端的粗粒度原因 |
|
||||
|
||||
## 3. 正交维度(四个独立轴)
|
||||
|
||||
```text
|
||||
轴 1:RunState(技术终态)⊥ ReleaseOutcome(用户结局)
|
||||
一个 Run 技术停了,用户看到的可能是降级(有安全进展)或失败(没进展)
|
||||
轴 2:InvocationStatus(调用生命周期)⊥ EvidenceStatus(证据语义)
|
||||
注释原话:一个是「投影中/就绪/错误」,一个是「有没有拿到可引用证据」
|
||||
NO_EVIDENCE 仍可能是 success 的工具执行(查了但空)
|
||||
轴 3:ChatApplicationStatus(进度,进行中)⊥ 结局(终态)
|
||||
进度回答「走到哪」,结局回答「最终给什么」
|
||||
轴 4:IntentType(路由)——每次请求一个,决定走哪条分支
|
||||
```
|
||||
|
||||
## 4. 纵向映射链(代码事实)
|
||||
|
||||
```text
|
||||
RunState.BUDGET_EXHAUSTED ──hasObservedFacts()==true──▶ FALLBACK(INSUFFICIENT_EVIDENCE)
|
||||
└──无 facts──▶ FAILED(fail closed)
|
||||
RunState.CANCELLED ──▶ ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
|
||||
内部失败 ──▶ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
|
||||
SemanticVerdict.SUPPORTED(guard 全过)──▶ ReleaseOutcome.SUCCESS(唯一出口)
|
||||
FallbackType 任意值 ──▶ ReleaseOutcome.FALLBACK
|
||||
|
||||
证据层内部约束:
|
||||
AnalysisKind.NORMAL.accepts(EVIDENCE_FOUND)
|
||||
AnalysisKind.NEGATIVE_OBSERVATION.accepts(NO_EVIDENCE)
|
||||
InvocationStatus.READY 是 EvidenceGuard 可引用前提(isReferencableBy)
|
||||
EvidenceStatus.ERROR 不是证据,不能支持分析/结论(prompt 原话)
|
||||
```
|
||||
|
||||
## 5. 真实数据案例(run 1b584a01 的状态流转)
|
||||
|
||||
| 阶段 | 状态值(真实 trace 佐证) |
|
||||
|---|---|
|
||||
| 路由 | IntentType=DIAGNOSIS(ROUTING_DECISION 事件 details) |
|
||||
| Agent 执行 | RunState=RUNNING,ChatApplicationStatus: ROUTING → DIAGNOSIS_RUNNING → SAFETY_VALIDATING |
|
||||
| 工具调用 | InvocationStatus: PROJECTING → READY;EvidenceStatus=EVIDENCE_FOUND |
|
||||
| 分析 | AnalysisKind=NORMAL × 3(accepts EVIDENCE_FOUND) |
|
||||
| 验真 | EVIDENCE_GUARD_INITIAL=PASSED(violations=0) |
|
||||
| 语义裁决 | SEMANTIC_GUARD_DECISION=SUPPORTED |
|
||||
| 发布 | RELEASE_DECISION=SUCCESS(= ReleaseOutcome.SUCCESS) |
|
||||
| 终态 | RunState=SUCCESS |
|
||||
|
||||
## 6. 面试怎么讲这个状态流设计
|
||||
|
||||
### 6.1 叙事模板(① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术)
|
||||
|
||||
**① 动机**:Agent 系统里最容易被搞混的就是「状态」。技术停了(预算耗尽)不等于用户看到失败;查了没查到不等于系统出错;进行中不等于终态。如果所有状态塞进一个枚举,语义就糊了。
|
||||
|
||||
**② 决策**:**分层 + 正交 + 显式映射**——每个枚举只回答一个问题(技术/证据/收集/发布/协议各管各的),层间不隐式耦合,用明确的映射链串联。
|
||||
|
||||
**③ 实现**:11 个枚举五层,两个最典型的正交轴 + 一条纵向映射链(如上);全部类型化(enum/record),杜绝字符串漂移。
|
||||
|
||||
**④ 边界**:SSE 取消场景连接可能已断、发不出 done,所以协议层只有三态;`SseOutcome` 目前未接线(实现直接复用 ReleaseOutcome);`FallbackType.BUDGET_EXHAUSTED` 是命名债务(实际发布 INSUFFICIENT_EVIDENCE)。
|
||||
|
||||
**⑤ 话术(30 秒)**:
|
||||
|
||||
> "Harness 的状态设计核心是**分层正交**:技术终态(RunState)和用户结局(ReleaseOutcome)是两个正交轴——预算耗尽且有安全进展时用户看到 FALLBACK 降级,没进展才是 FAILED,这样同一个技术终态可以诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空结果(NO_EVIDENCE)仍是成功执行。层间是显式映射链:BUDGET_EXHAUSTED+有事实→FALLBACK,CANCELLED→CANCELLED+RUN_CANCELLED,guard 全过→SUCCESS 唯一出口。协议层(SSE)复用 ReleaseOutcome 三态并拒绝 CANCELLED(取消时连接已断),SseOutcome 是预留未启用的抽象。"
|
||||
|
||||
### 6.2 高频追问应对
|
||||
|
||||
| 追问 | 答 |
|
||||
|---|---|
|
||||
| RunState 和 ReleaseOutcome 有什么区别? | 技术 vs 用户:RunState 回答 Run 是否还允许执行;ReleaseOutcome 回答用户侧内容形态。BUDGET_EXHAUSTED 可以有 FALLBACK 或 FAILED 两种用户结局 |
|
||||
| 为什么预算耗尽既可能 FALLBACK 又可能 FAILED? | `progress.hasObservedFacts()` 安全阀——有已验真事实才能发布降级,没有就 fail closed |
|
||||
| NO_EVIDENCE 算失败吗? | 不算。NO_EVIDENCE 是成功执行但范围内无证据(查了但空),常记为信息无增益;ERROR 才是工具侧失败 |
|
||||
| CANCELLED 为什么不在 SSE done 里? | 取消时客户端连接可能已断,发不出 done;所以协议层只有 SUCCESS/FALLBACK/FAILED 三态 |
|
||||
| 这些状态为什么不直接用一个枚举? | 塞一个枚举语义就糊了——技术、证据、发布、协议回答的是不同问题,正交分层后各层可独立演进,映射显式可审计 |
|
||||
| InvocationStatus 和 EvidenceStatus 会不会重复? | 分工明确:生命周期(投影中/就绪/错误)vs 证据语义(有没有证据);READY+NO_EVIDENCE 是完全合法的组合 |
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| 全部状态枚举与数据契约 | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
| RunState | `.../harness/core/RunState.java` |
|
||||
| DiagnosisStopReason | `.../harness/progress/DiagnosisStopReason.java` |
|
||||
| ChatApplicationStatus / ChatFailureCode | `.../harness/application/` |
|
||||
| SSE 会话(done 事件拒绝 CANCELLED) | `.../controller/sse/ChatSseEvent.java` |
|
||||
@@ -0,0 +1,415 @@
|
||||
# Harness progress 代码学习笔记:从拦截器五道门到唯一发布点
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:Progress 子系统的代码落地——拦截器五道门、Tracker 状态机、canonical 生命周期、执行门禁、投影与发布闭环
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**设计视角**:[Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md)(讲「为什么这样设计」)
|
||||
**本文视角**:代码里怎么落地(类地图、调用链、生命周期、状态机、门禁、易错点、面试话术)
|
||||
|
||||
## 1. 定位:双停止机制与三方判断权
|
||||
|
||||
### 1.1 双停止机制(progress 存在的根本理由)
|
||||
|
||||
预算管「能不能花」,progress 管「值不值得继续查」。让预算充当正常停止策略,会把「当前证据不足」错误表达成「系统执行失败」——这是语义错误,不是资源问题。
|
||||
|
||||
| 机制 | 问的问题 | 归属 |
|
||||
|---|---|---|
|
||||
| RunBudget | 这次 Run 最多允许消耗多少? | core 域 |
|
||||
| Progress | 继续查询是否仍可能推进当前诊断? | progress 域 |
|
||||
|
||||
### 1.2 三方判断权(信任边界)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Tool / Projector<br/>客观结果"] -->|"evidence_status:空不空(代码判)"| H
|
||||
M["Diagnosis Agent<br/>语义价值"] -->|"information_gain:有没有用(模型判)"| H
|
||||
H["Harness<br/>最终停止权"] -->|"scope 重复 / 连续 NO_GAIN / 协议合规"| R["停止裁决"]
|
||||
```
|
||||
|
||||
- **谁判空**:`EVIDENCE_FOUND / NO_EVIDENCE` 是 Harness 代码判(证据数组空不空),Projector 投影时客观计算,不需要模型。
|
||||
- **谁判价值**:`GAINED / NO_GAIN` 的语义价值是模型判——非空结果「有没有用」只有结合诊断上下文才能判断。
|
||||
- **谁决定停止**:Harness。模型可以主动结束(输出 Draft),但不能用继续发 Tool Call 绕过 Harness 已定的饱和状态。
|
||||
|
||||
**两层信任边界分开**:模型输出乱来(瞎报增益)时,Harness 手里仍有 evidenceStatus 这个与模型无关的事实层(canonical 记录、审计、验真都建立在它之上)。
|
||||
|
||||
## 2. 类地图与调用链
|
||||
|
||||
### 2.1 14 个文件分类
|
||||
|
||||
| 类别 | 类 | 作用 |
|
||||
|---|---|---|
|
||||
| 状态机核心 | `DiagnosisProgressTracker` | 记账(双计数/pending/identity)+ 停止裁决 |
|
||||
| 判重 | `ToolScopeNormalizer` + `ToolScopeIdentity` | 业务输入 → 稳定 scope 指纹 |
|
||||
| 投影 | `DiagnosisProgressProjector` + `DiagnosisProgressProjection` | identity → 回读 canonical → 有界快照 |
|
||||
| 枚举 | `InformationGain` / `DiagnosisCollectionState` / `DiagnosisStopReason` / `ProgressProtocolViolationType` | GAINED·NO_GAIN / COLLECTING·SATURATED / 三种停止原因 / 五类违规 |
|
||||
| record | `PreviousObservation` / `CompletedToolCall` / `DiagnosisProgressSnapshot` / `DiagnosisProgressSnapshotState` | 协议字段 / 完成 identity / 对外快照 / 内部快照 |
|
||||
| 异常 | `ProgressProtocolViolationException` | 受控协议违规 |
|
||||
|
||||
### 2.2 一次 Tool Call 的完整链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as HarnessToolInterceptor(per-Run)
|
||||
participant ET as HarnessEvidenceTools(注册表)
|
||||
participant AD as Adapter(接线员)
|
||||
participant TB as ToolBoundary(门禁)
|
||||
participant S as CanonicalInvocationStore(真相)
|
||||
participant P as Projector(投影)
|
||||
|
||||
I->>ET: invoke(context, toolName, toolCallId, args)
|
||||
ET->>AD: bridge 包装的 invoker
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>S: begin(PROJECTING)
|
||||
TB->>TB: executor.execute(requestJson)(backend raw)
|
||||
TB->>P: projector.project(raw) → agent_result + evidenceStatus
|
||||
TB->>S: markReady(READY) 或 markError(ERROR)
|
||||
TB-->>AD: ToolBoundaryResult(READY/ERROR)
|
||||
AD-->>I: 一路 return
|
||||
I->>I: 双源交叉验证 → recordCompleted → 返回有界 observation
|
||||
```
|
||||
|
||||
### 2.3 三层关系
|
||||
|
||||
**持有关系**:
|
||||
|
||||
```
|
||||
HarnessToolInterceptor ──持有──▶ RunContext(含 Tracker)/ HarnessEvidenceTools
|
||||
HarnessEvidenceTools ──持有──▶ Map<toolName, EvidenceToolInvoker>(bridge 注册表)
|
||||
Adapter ──持有──▶ ToolBoundary + 具体 backend + 具体 Projector
|
||||
ToolBoundary ──持有──▶ DiagnosisHarnessCore / CanonicalInvocationStore / ToolCallKeyFactory
|
||||
```
|
||||
|
||||
**接口-实现关系**:
|
||||
|
||||
| 接口 | 实现 |
|
||||
|---|---|
|
||||
| `EvidenceToolInvoker`(@FunctionalInterface) | `HarnessEvidenceTools.fromAdapters` 的 bridge |
|
||||
| `AdapterCall`(内部 @FunctionalInterface) | 三个 Adapter 的 execute 方法 |
|
||||
| `ToolExecutor`(@FunctionalInterface) | Adapter 里 `ignored -> legacyExecutor.execute(query)` |
|
||||
| `ToolResultProjector` | `RagResultProjector` / `QueryLogsResultProjector` / `MysqlResultProjector` |
|
||||
| `CanonicalInvocationStore` | `JpaCanonicalInvocationStore` 等 |
|
||||
|
||||
## 3. 生命周期:单例 vs per-Run
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 应用启动(一次)
|
||||
A["Spring 容器装配单例 bean"]
|
||||
A --> B["DiagnosisHarnessCore / ToolBoundary / Adapter / HarnessEvidenceTools / DiagnosisAgentFactory"]
|
||||
end
|
||||
subgraph 每次请求(多次)
|
||||
C["core.startRun() → RunContext(含 new DiagnosisProgressTracker)"]
|
||||
C --> D["DiagnosisAgentFactory.create(context)"]
|
||||
D --> E["new HarnessModelInterceptor / HarnessToolInterceptor(绑定本 Run context)"]
|
||||
E --> F["ReactAgent.call() → ReAct loop"]
|
||||
F --> G["Run 结束:拦截器/ReactAgent 变成垃圾"]
|
||||
end
|
||||
```
|
||||
|
||||
| 对象 | 生命周期 | 原因 |
|
||||
|---|---|---|
|
||||
| core / ToolBoundary / Adapter / EvidenceTools / AgentFactory | 单例 | 无状态,只存依赖与规则 |
|
||||
| RunContext | per-Run | 状态容器(Tracker/Budget/Lifecycle) |
|
||||
| 两个 Interceptor | per-Run | 持有本 Run 的 RunContext |
|
||||
| ReactAgent | per-Run | 框架有状态对象(loop/memory) |
|
||||
|
||||
**无状态体现**:单例 bean 的字段全是构造注入的依赖/配置(创建后不变);可变状态全部外置到 RunContext 和持久化存储。方法一律以 `context` 参数显式传入——同一个 ToolBoundary 实例可并发服务多个 Run,各算各的账,**状态外置是并发正确性的硬要求**(若在 bean 里存「当前预算计数」,并发 Run 会互相覆盖)。
|
||||
|
||||
## 4. 拦截器五道门(代码核心)
|
||||
|
||||
### 4.1 门卫 + 执行者合一
|
||||
|
||||
证据工具的 `ToolCallback` 被**故意定义为直接抛异常**(`"Harness evidence Tools require the framework Tool interceptor"`)——如果框架绕过拦截器执行 `handler.call()`,直接爆炸。这从结构上保证:**执行不可绕过门禁,校验+执行是原子操作**。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REQ["Tool Call 到达"] --> CHK{"evidenceTools.supports?"}
|
||||
CHK -->|"非证据工具"| HANDLER["handler.call() 透传"]
|
||||
CHK -->|"证据工具"| GATES["五道门(校验+执行+记账)"]
|
||||
```
|
||||
|
||||
**为什么不在 definition 里写**(四个原因):
|
||||
1. 拦截器接管执行时**根本不经过 ToolCallback**——写在 definition 里永远不会执行;
|
||||
2. ToolCallback 是纯函数(input→output),**拿不到 RunContext**,调不了 `context.progress()`;
|
||||
3. ToolCallback 本身就是执行,**没有「执行前」时机**——判重必须在 backend 前,只有拦截器同时拥有执行前/后两个观察点;
|
||||
4. progress 逻辑横跨所有证据工具,写在每工具 callback 会**复制漂移**。
|
||||
|
||||
### 4.2 五道门流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① 已停止检查"] -->|"SATURATED && stopInstructionDelivered"| B["抛 DiagnosisCollectionStoppedException(硬停止)"]
|
||||
A --> C["② 解析 + 协议校验"]
|
||||
C -->|"违规"| D["recordProgressProtocolViolation → 可修复反馈 / 达阈值饱和"]
|
||||
C -->|"评价导致饱和"| E["stopRequired(软停止)"]
|
||||
C --> F["③ 判重:isDuplicate(toolName, normalizedScope)"]
|
||||
F -->|"重复"| G["recordDuplicateScope(直接 NO_GAIN)→ 饱和? stopRequired : 返回 DUPLICATE_SCOPE 观察"]
|
||||
F --> H["④ ToolBoundary 执行(预算/canonical/审计/checkActive)"]
|
||||
H -->|"READY"| I["双源交叉验证 → recordCompleted → ⑤ 收尾(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending / 饱和交付 stop_required)"]
|
||||
H -->|"非 READY"| J["error observation(BUDGET_EXHAUSTED 额外 markBudgetLimitReached)"]
|
||||
```
|
||||
|
||||
### 4.3 协议三类违规(第二道门)
|
||||
|
||||
校验的是 **Envelope 与 Tracker pending 状态的协议关系**(不是 JSON 结构——JSON 结构在 parse 用严格反序列化做了):
|
||||
|
||||
| 违规 | 含义 | 判定条件 |
|
||||
|---|---|---|
|
||||
| `UNEXPECTED_PREVIOUS_OBSERVATION` | 没欠账却带评价 | pending == null 且 observation != null |
|
||||
| `MISSING_PREVIOUS_OBSERVATION` | 欠账不还 | pending != null 且 observation == null |
|
||||
| `OUT_OF_ORDER_PREVIOUS_OBSERVATION` | 还错账 | observation.toolCallId() != pendingToolCallId |
|
||||
|
||||
另两类(parse 阶段):`MISSING_INPUT`(缺业务输入)、`INVALID_ENVELOPE`(JSON/字段非法)。
|
||||
|
||||
**可修复反馈**:首次违规(未达阈值)返回 `repair_required:true` 观察——带 `violation_type` / `missing_field` / `expected_previous_tool_call_id` / `allowed_information_gain` / `instruction`,让模型下一轮自愈;**连续**违规达独立阈值才饱和(PROGRESS_PROTOCOL_VIOLATED)。
|
||||
|
||||
### 4.4 双源交叉验证(第四道门,READY 后)
|
||||
|
||||
```text
|
||||
源1:result.evidenceStatus() ← Projector 投影时计算并携带的声明值
|
||||
源2:controlView.evidenceStatus() ← 从 agent_result 内容里解析 "evidence_status" 字段
|
||||
比较:一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
|
||||
```
|
||||
|
||||
本质是「自洽性防线」:同一状态被两处描述(结果对象字段 vs 内容 JSON 字段),必须一致——防止投影 bug / 数据损坏 / 构造不一致导致 progress 基于错误状态决策(如声明 FOUND 但内容空 → 挂 pending 等评价不存在的证据;声明 NO_EVIDENCE 但内容有证据 → 误记 NO_GAIN)。
|
||||
|
||||
### 4.5 三副 observation 面孔
|
||||
|
||||
| 面孔 | 触发 | 关键字段 |
|
||||
|---|---|---|
|
||||
| 正常执行结果 | READY 且未饱和 | 有界观察 + 可选 `stop_required` |
|
||||
| `STOP_REQUIRED` | 饱和后交付一次 | `stop_required:true` + `reason` |
|
||||
| 可修复协议错误 | 协议违规未达阈值 | `repair_required:true` + violation_type/missing_field/expected id/instruction |
|
||||
|
||||
## 5. Tracker 状态机
|
||||
|
||||
### 5.1 11 个字段
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `stopAfterConsecutiveNoGain` | 连续 NO_GAIN 阈值(默认 2,yml 可配) |
|
||||
| `stopAfterConsecutiveProgressProtocolViolations` | 连续协议违规阈值(默认 2) |
|
||||
| `completedScopes` | `toolName + normalizedScope` 去重集合 |
|
||||
| `completedToolCalls` | 已完成调用 identity 列表(无 payload) |
|
||||
| `consecutiveNoGain` | 连续无增益计数(GAINED 清零) |
|
||||
| `consecutiveProgressProtocolViolations` | 连续协议违规计数(合法评价清零) |
|
||||
| `collectionState` | `COLLECTING` / `SATURATED` |
|
||||
| `stopReason` | `INFORMATION_SATURATED` / `BUDGET_LIMIT_REACHED` / `PROGRESS_PROTOCOL_VIOLATED` |
|
||||
| `pendingToolCallId` | 待评价调用 ID(同一时刻最多一个) |
|
||||
| `stopInstructionDelivered` | STOP_REQUIRED 是否已交付(只一次) |
|
||||
|
||||
### 5.2 两条独立计数(重点:协议违规 ≠ NO_GAIN)
|
||||
|
||||
```text
|
||||
NO_GAIN 路径(applyGain)—— backend 执行了但没增益:
|
||||
入口:applyPreviousObservation(NO_GAIN) / recordDuplicateScope() / recordCompleted(NO_EVIDENCE)
|
||||
阈值:stopAfterConsecutiveNoGain(2) → SATURATED + INFORMATION_SATURATED
|
||||
GAINED 清零连续计数
|
||||
|
||||
协议违规路径(recordProgressProtocolViolation)—— backend 根本没执行:
|
||||
入口:拦截器 catch 分支
|
||||
阈值:stopAfterConsecutiveProgressProtocolViolations(2) → SATURATED + PROGRESS_PROTOCOL_VIOLATED
|
||||
一次合法评价(applyPreviousObservation 通过)清零
|
||||
```
|
||||
|
||||
**为什么分开**:`NO_GAIN` 表示「Tool 执行了但没推进诊断」;协议错误表示「模型没守契约,Tool 根本没执行」。混淆会让真实空转无法归因(真实 E2E:9 次协议拒绝 + 13 轮模型调用空转)。
|
||||
|
||||
### 5.3 pending 协议(锚点)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["recordCompleted(EVIDENCE_FOUND)"] --> B["pendingToolCallId = toolCallId(挂账)"]
|
||||
B --> C["模型下一轮回带 previous_observation"]
|
||||
C --> D{"ID == pending ?"}
|
||||
D -->|"是"| E["清 pending → applyGain"]
|
||||
D -->|"否"| F["OUT_OF_ORDER 违规"]
|
||||
```
|
||||
|
||||
- 只对**非空成功**结果设 pending;NO_EVIDENCE 不设(Harness 已自己判 NO_GAIN)。
|
||||
- pending 是「唯一欠账」——保证协议逐轮、不乱序、不重评。
|
||||
- 模型读完 observation 直接输出 Draft → pending 不消费也合法(不需要评价最后一轮)。
|
||||
|
||||
### 5.4 收集状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
|
||||
COLLECTING --> COLLECTING: NO_GAIN 未达阈值
|
||||
COLLECTING --> SATURATED: NO_GAIN 达阈值 / 协议违规达阈值
|
||||
SATURATED --> SATURATED: claimStopInstruction 交付一次(软停止)
|
||||
SATURATED --> [*]: 模型再请求 Tool → DiagnosisCollectionStoppedException(硬停止)
|
||||
```
|
||||
|
||||
**软/硬停止两段式**(易错点:stopReason 设置 ≠ 软停止):
|
||||
|
||||
```text
|
||||
饱和瞬间 → stopReason 已设置(状态事实)
|
||||
软停止 = 第一次交付 stop_required 观察(claimStopInstruction 返回 true,流程不中断)
|
||||
→ 给模型合法输出 Draft 的机会
|
||||
硬停止 = 模型无视指令再次请求 Tool → 入口检查 SATURATED && delivered
|
||||
→ 抛 DiagnosisCollectionStoppedException 穿出框架 loop
|
||||
```
|
||||
|
||||
### 5.5 为什么需要 Tracker(三层)
|
||||
|
||||
1. **progress 本身需要**:预算只止损,不能当正常停止策略;
|
||||
2. **必须单一所有者**:「连续无增益」是跨轮次、跨入口(模型评价/代码判空/重复检测)的全局判断,分散记账会状态漂移;
|
||||
3. **最小状态**:不存 payload(真相在 Canonical Store),避免第二份真相。
|
||||
|
||||
## 6. canonical 生命周期(tool 域衔接)
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[不存在] --> PROJECTING: store.begin(preflight+预算通过后)
|
||||
PROJECTING --> READY: store.markReady(backend 成功+投影成功)
|
||||
PROJECTING --> ERROR: store.markError(任何异常)
|
||||
note right of PROJECTING: 仅身份+request+startedAt
|
||||
note right of READY: raw + agent_result + evidence + completedAt
|
||||
note right of ERROR: raw + error_code + completedAt(禁 agent_result)
|
||||
```
|
||||
|
||||
### 6.2 为什么分 begin/markReady/markError 三段
|
||||
|
||||
| 动机 | 说明 |
|
||||
|---|---|
|
||||
| 崩溃恢复 | backend 执行可能耗时数秒,一次写入会丢「已开始」的事实;begin 先留痕(PROJECTING = WAL) |
|
||||
| 审计耗时 | 需要 startedAt(begin 记)和 completedAt(迁移记)算调用耗时 |
|
||||
| 状态合法 | 每种状态只允许合法字段组合,由构造校验兜底 |
|
||||
| 防篡改 | record 不可变,迁移生成新实例——READY 一旦写入不可改(证据可验真的前提) |
|
||||
| 幂等 | begin 抛 `DuplicateInvocationException`(同 key 重复 begin 拒绝)→ `DUPLICATE_TOOL_CALL` |
|
||||
|
||||
**两类失败不落库**:requestBytes 超限、preflight 非法、重复 begin——都发生在 begin 之前,canonical 里**无记录**。
|
||||
|
||||
### 6.3 Store vs Tracker 分层
|
||||
|
||||
```text
|
||||
Store(事实层):request / raw_response / agent_result / 状态 / 时间戳 —— 完整真相
|
||||
Tracker(判断层):identity(引用)+ 双计数 + pending + 状态 —— 决策状态
|
||||
|
||||
NO_GAIN 计数不是「第二份真相」:它无法从 Store 重建
|
||||
(模型评价是瞬时的、不落 Store),是独立增量状态
|
||||
```
|
||||
|
||||
## 7. ToolBoundary 执行门禁
|
||||
|
||||
### 7.1 executeCanonical 五阶段
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["阶段一:preflight + Tool 预算 + request bytes 预留 → begin(PROJECTING)"]
|
||||
B["阶段二:executor.execute(requestJson)(backend raw)"]
|
||||
C["阶段三:raw 大小校验 + Run bytes 预留"]
|
||||
D["阶段四:projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["阶段五:agent_result 校验 + Run bytes 预留 → markReady(READY)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
**三笔 bytes 预留**:request(阶段一)/ raw(阶段三)/ agent_result(阶段五)——Run 的 bytes 预算分三个时间点消耗,任何一笔超限触发对应错误码。
|
||||
|
||||
### 7.2 失败路径错误码
|
||||
|
||||
| 失败点 | 是否已 begin | canonical | 错误码 |
|
||||
|---|---|---|---|
|
||||
| requestBytes 超限 / preflight 非法 / 重复 begin / Run 终态 | 否 | 无记录 | `RESULT_TOO_LARGE` / 分类错误码 / `DUPLICATE_TOOL_CALL` / `BUDGET_EXHAUSTED`·`RUN_INACTIVE` |
|
||||
| executor 抛错 | 是 | ERROR | `TOOL_EXECUTION_ERROR` |
|
||||
| raw 过大 / 预算 / 取消 | 是 | ERROR | `RESULT_TOO_LARGE` / `BUDGET_EXHAUSTED` / `RUN_INACTIVE` |
|
||||
| 投影失败 | 是 | ERROR | `PROJECTION_ERROR` |
|
||||
| agent_result 过大 / store 失败 | 是 | ERROR | `RESULT_TOO_LARGE` / `PROJECTION_ERROR` / `STORE_ERROR` |
|
||||
|
||||
### 7.3 checkActive 在哪
|
||||
|
||||
progress 拦截器流程里看不到显式 checkActive——它在 **ToolBoundary/core 层**隐式执行:`core.beforeToolCall` / `core.reserveRunBytes` 内部若 Run 已终态,抛 `RunAbortedException` → 映射为 `RUN_INACTIVE` 错误码。
|
||||
|
||||
## 8. 投影与发布闭环
|
||||
|
||||
### 8.1 停止后的数据流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Tracker(identity 列表)"] --> P["DiagnosisProgressProjector"]
|
||||
P --> S["CanonicalInvocationStore(回读 READY)"]
|
||||
S --> P
|
||||
P --> SN["ProgressSnapshot(verifiedSources + observedFacts + limitations + stopReason)"]
|
||||
SN --> R["DiagnosisReleaseUseCase"]
|
||||
R --> O["SUCCESS / FALLBACK(INSUFFICIENT_EVIDENCE 等)"]
|
||||
```
|
||||
|
||||
### 8.2 Projector 有界投影
|
||||
|
||||
- 三重校验(`isReferencableBy` / toolCallId / toolName)通过才发布;无法验真/不可读/格式非法 → limitation,**绝不输出 raw**;
|
||||
- 空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是「已检查」的证明;
|
||||
- 硬截断:`MAX_FACTS=12`、summary/scope 320 字符、source 160 字符;
|
||||
- 去重:`sourceKey = type + source + scope`,`factKey = sourceKey + summary`(LinkedHashMap 保序)。
|
||||
|
||||
### 8.3 Release 决策树
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
EX["execute(execution)"] --> D1{"draft == null?"}
|
||||
D1 -->|"是"| CS["releaseControlledStop<br/>需 hasObservedFacts → INSUFFICIENT_EVIDENCE"]
|
||||
D1 -->|"否"| D2{"conclusion == null?"}
|
||||
D2 -->|"是"| NC["releaseNoConclusion<br/>有事实 → INSUFFICIENT_EVIDENCE<br/>missing_info → MISSING_REQUIRED_CONTEXT<br/>都没有 → fail closed"]
|
||||
D2 -->|"否"| C["releaseConclusion<br/>EvidenceGuard 验引用 → repair → SemanticGuard 裁决<br/>SUPPORTED ? SUCCESS : FALLBACK"]
|
||||
```
|
||||
|
||||
## 9. 易错点清单
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| EVIDENCE_FOUND 累计 NO_GAIN | **NO_EVIDENCE**(空结果)才立即累计;FOUND 挂 pending 等模型评价 |
|
||||
| 重复调用计入协议违规 | 重复走 **NO_GAIN** 路径(recordDuplicateScope),不是违规 |
|
||||
| 软停止 = 设置 stopReason | stopReason 饱和时就设了;软停止是**交付 stop_required 观察** |
|
||||
| checkActive 应该在拦截器 | 在 ToolBoundary/core 层,`RUN_INACTIVE` 错误码 |
|
||||
| 拦截器直接 invoke = 绕过设计 | 是**唯一执行通道**(证据工具 handler 必炸) |
|
||||
| 数据都在 Tracker | 判断层在 Tracker,**事实层在 Canonical Store** |
|
||||
| 判重对比裸入参 | 对比的是**规范化指纹**(字段顺序/格式/缺省值统一后) |
|
||||
| 首次协议违规直接终止 | 首次返回**可修复反馈**,连续违规才饱和 |
|
||||
|
||||
## 10. 面试话术合集(30 秒)
|
||||
|
||||
### 10.1 双停止机制
|
||||
|
||||
> "Harness 有两套停止机制。预算是资源门禁,管『能不能花』;Progress 是收敛门禁,管『继续查有没有价值』。核心设计是:Tool 只提供客观结果,模型负责判断语义增益,但停止权归 Harness。信息增益故意只保留 GAINED/NO_GAIN 两值,靠模型在下次 Tool Call 里用 previous_observation 回传评价;连续两次 NO_GAIN 就进入饱和,交付一次 STOP_REQUIRED 给模型合法收尾的机会,再纠缠就抛受控异常穿出框架。"
|
||||
|
||||
### 10.2 为什么需要 Tracker(单一所有者)
|
||||
|
||||
> "ProgressTracker 是 Run 内收敛控制的单一决策者:它不存证据内容,只保存最小账——已完成调用的 identity、连续 NO_GAIN 和协议违规两条计数、一个待评价的 pending ID、以及 COLLECTING/SATURATED 状态。三路输入(模型评价、代码判空、重复检测)汇入计数,GAINED 清零、NO_GAIN 累加,达阈值进入饱和,交付一次停止指令。所有方法 synchronized,是并发安全的单一所有者。"
|
||||
|
||||
### 10.3 拦截器为什么自己执行(必炸路径)
|
||||
|
||||
> "Harness 的证据工具不是『拦截后放行』——拦截器对它们既是门卫又是执行者。证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为必炸路径,从结构上保证执行不可绕过门禁;校验(判重/协议/预算)和执行(ToolBoundary/canonical)内联在同一个流程里,保证『执行前判重、执行后记录』的时机原子性,且模型拿到的永远是有界观察而不是 raw。"
|
||||
|
||||
### 10.4 为什么 begin/markReady/markError 三段式
|
||||
|
||||
> "Tool 执行是跨门禁、backend IO、校验、投影多个不可靠阶段的流程,所以 canonical 记录用 begin → markReady/markError 的追加式状态机:begin 先落 PROJECTING 保证已开始的调用必有痕迹,backend 成功且投影成功才 markReady 定案为 READY,任何异常 markError 收尾;三个状态各校验合法字段组合,record 不可变保证一旦写入不可篡改——这样崩溃可恢复、审计可对账(startedAt/completedAt 算耗时)、证据可验真(READY 不可造假),重复 begin 还能幂等拦截。"
|
||||
|
||||
### 10.5 谁判空谁判价值
|
||||
|
||||
> "空不空是客观事实,代码可判,所以 evidenceStatus 归 Harness 的 Projector;有没有用是语义判断,代码判不了(一段通用知识可能看似相关实则无用),所以 information_gain 归模型。Harness 绝不把客观状态交给模型声明,模型也绝不替 Harness 做停止裁决——两层的信任边界是分开的。"
|
||||
|
||||
### 10.6 无状态 bean
|
||||
|
||||
> "Harness 的单例 bean(core/ToolBoundary/Adapter)全部无状态:字段只有构造注入的依赖和配置,创建后不变;所有可变状态外置到每个 Run 的 RunContext(预算计数、进度 Tracker、生命周期)和持久化存储里。方法一律以 context 参数显式传入,所以同一个 bean 实例能并发服务多个 Run 而不串账——状态在参数里,不在实例里。"
|
||||
|
||||
## 11. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `DiagnosisProgressTracker` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressTracker.java` |
|
||||
| `ToolScopeNormalizer` | `src/main/java/com/superbiz/agent/harness/progress/ToolScopeNormalizer.java` |
|
||||
| `DiagnosisProgressProjector` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjector.java` |
|
||||
| `DiagnosisProgressProjection` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjection.java` |
|
||||
| progress 枚举/record/异常 | `src/main/java/com/superbiz/agent/harness/progress/`(其余 9 个文件) |
|
||||
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
|
||||
| `RagToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
|
||||
| `ToolBoundary` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| `ToolBoundaryResult` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundaryResult.java` |
|
||||
| `CanonicalToolInvocation` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalToolInvocation.java` |
|
||||
| `CanonicalInvocationStore` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java` |
|
||||
| `DiagnosisReleaseUseCase` | `src/main/java/com/superbiz/agent/harness/release/DiagnosisReleaseUseCase.java` |
|
||||
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
|
||||
@@ -0,0 +1,382 @@
|
||||
# Harness tool 域代码学习笔记:工具的注册、调用与执行链路
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:tool 域 49 个文件的完整链路——装配 → 注册 → 调用 → 执行 → 返回,拆分阶段讲,最后合并
|
||||
**设计视角**:[Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) §7 Tool
|
||||
**代码视角**:[Harness progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(progress 域衔接,本笔记是 tool 域)
|
||||
|
||||
## 1. 定位:tool 域管什么
|
||||
|
||||
**职责**:工具如何安全执行、保存真相并只暴露必要内容。
|
||||
|
||||
| 问题 | 不解决会怎样 | 催生的层 |
|
||||
|---|---|---|
|
||||
| 每个 Adapter 自己写授权/预算/审计 → 漂移 | 三个工具三种行为 | **Boundary**(统一门禁) |
|
||||
| raw 结果直接给模型 | 敏感数据泄露、超大响应、无结构 | **Projector**(有界投影) |
|
||||
| 模型可能编造证据 | 结论无法验真、审计黑洞 | **Store**(canonical 真相) |
|
||||
| MySQL 查询不可控 | 写库、删库、危险 SQL | **MySQL 沙箱**(只读红线) |
|
||||
|
||||
**49 文件分 6 组**:
|
||||
|
||||
| 组 | 数量 | 角色 |
|
||||
|---|---|---|
|
||||
| Contract | 18 | 跨层类型化语言(Call/Request/Result) |
|
||||
| Boundary | 7 | 统一门禁(ToolBoundary + 配套) |
|
||||
| Projection | 3 | raw → 有界 agent 契约 |
|
||||
| Store | 9 | canonical 真相持久化 |
|
||||
| Adapter | 3 | 接线(boundary + 后端 + 投影器) |
|
||||
| MySQL 沙箱 | 9 | 只读执行 + fail-closed 校验 |
|
||||
|
||||
**核心设计**:几乎不用继承——用「接口 + 组合 + 函数式接口」三件套解耦。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段一:装配(config → bean 注入链)
|
||||
|
||||
### 2.1 注入链
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 底层
|
||||
R["RedisCanonicalInvocationStore"]
|
||||
B["ToolBoundary"]
|
||||
RP["RagResultProjector"]
|
||||
LP["QueryLogsResultProjector"]
|
||||
end
|
||||
subgraph 中层
|
||||
RA["RagToolAdapter"]
|
||||
QA["QueryLogsToolAdapter"]
|
||||
MA["MysqlToolAdapter"]
|
||||
end
|
||||
subgraph 顶层
|
||||
ET["HarnessEvidenceTools"]
|
||||
end
|
||||
R --> B
|
||||
B --> RA
|
||||
B --> QA
|
||||
B --> MA
|
||||
RP --> RA
|
||||
LP --> QA
|
||||
ET --> RA
|
||||
ET --> QA
|
||||
ET --> MA
|
||||
```
|
||||
|
||||
注入规律:所有 `@Bean` 方法参数 = 依赖注入点;**没有任何类 extends 别人**。
|
||||
|
||||
### 2.2 为什么不用继承
|
||||
|
||||
```
|
||||
❌ 继承方案(没采用):
|
||||
abstract class BaseToolAdapter { ... }
|
||||
RagToolAdapter extends BaseToolAdapter { ... }
|
||||
→ 加一个工具就得改基类,横切逻辑散落
|
||||
|
||||
✅ 组合方案(实际):
|
||||
Adapter = ToolBoundary(门禁) + 后端(执行) + Projector(投影)
|
||||
↑ 构造注入持有引用,不是继承
|
||||
→ 每个 Adapter 独立组装,改一个不影响其他
|
||||
```
|
||||
|
||||
组合的优势:
|
||||
1. **ToolBoundary 对三种工具完全无感知**——只认 `ToolExecutor` / `ToolResultProjector` 两个端口,三个工具共用同一个实例;
|
||||
2. **后端各不相同**(LookupKnowledgeTool / QueryLogsTools / JDBC),无法抽象成共同基类,用函数式接口适配;
|
||||
3. **开闭原则**:加新工具 = 新写 Adapter + Projector + config 注册,**不动已有类**(门禁/真相/进度自动继承)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段二:注册(HarnessEvidenceTools 门面)
|
||||
|
||||
### 3.1 两个平行的注册表
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph fromAdapters
|
||||
RAG["ragAdapter::execute"]
|
||||
LOGS["logsAdapter::execute"]
|
||||
MYSQL["mysqlAdapter::execute"]
|
||||
end
|
||||
subgraph HarnessEvidenceTools
|
||||
direction TB
|
||||
CALL["callbacks(List)<br/>模型可见 Schema + 必炸"]
|
||||
INV["invokers(Map)<br/>toolName → bridge 闭包"]
|
||||
end
|
||||
RAG -->|bridge| INV
|
||||
LOGS -->|bridge| INV
|
||||
MYSQL -->|bridge| INV
|
||||
INV -.同一个工具名串起.-> CALL
|
||||
CALL --> MODEL["模型(可见工具目录)"]
|
||||
INV --> INTERCEPTOR["拦截器(执行入口)"]
|
||||
```
|
||||
|
||||
**同一工具名字符串串起两个表**:模型从 callbacks 决定调 `lookup_knowledge` → 拦截器用同一个名字去 invokers 取执行器。
|
||||
|
||||
### 3.2 bridge:方法引用绑定实际调用者
|
||||
|
||||
```java
|
||||
private static EvidenceToolInvoker bridge(String toolName, AdapterCall adapter) {
|
||||
// lambda 闭包捕获 adapter 实例 + 固定的 toolName
|
||||
return (context, toolCallId, arguments) -> adapter.execute(
|
||||
context,
|
||||
new ToolCallRequestEnvelope(
|
||||
context.runId(), toolCallId, toolName, arguments, true, true));
|
||||
}
|
||||
```
|
||||
|
||||
关联链(三层绑定):
|
||||
|
||||
```
|
||||
① config:new RagToolAdapter(boundary, mapper, projector, backend)
|
||||
→ adapter 实例已组合好 boundary + projector + backend
|
||||
② fromAdapters:ragAdapter::execute 是「绑定实例的方法引用」
|
||||
→ bridge lambda 捕获它 —— invoker 与 Adapter 的关联在此固化
|
||||
③ 构造方法:按工具名常量 put 进 invokers —— "lookup_knowledge" → 捕获了 ragAdapter 的 lambda
|
||||
```
|
||||
|
||||
**invoker 与调用者的关联 = 方法引用绑定**:取出来直接 `adapter.execute(...)`,不需要再查表找调用者。
|
||||
|
||||
### 3.3 三个关键设计点
|
||||
|
||||
**① callbacks 的必炸保护**:
|
||||
|
||||
```java
|
||||
FunctionToolCallback.builder(name, ignored -> {
|
||||
throw new IllegalStateException(
|
||||
"Harness evidence Tools require the framework Tool interceptor");
|
||||
})
|
||||
```
|
||||
|
||||
| 场景 | callback 行为 |
|
||||
|---|---|
|
||||
| 正常(拦截器接管) | 不执行(拦截器直接 evidenceTools.invoke) |
|
||||
| 异常(某处 handler.call / 直接调) | **抛异常** → 暴露「绕过门禁」的 bug |
|
||||
|
||||
结构性保证:唯一能执行证据工具的路径 = 拦截器接管 → 门禁永远在线;绕过不可能静默成功(fail-fast)。
|
||||
|
||||
**② mysql 条件注册**:
|
||||
|
||||
```java
|
||||
// config
|
||||
boolean mysqlEnabled = 数据源配置了 jdbcUrl ? true : false;
|
||||
return fromAdapters(rag, logs, mysqlEnabled ? mysql : null);
|
||||
|
||||
// fromAdapters
|
||||
EvidenceToolInvoker mysql = mysqlAdapter == null ? null : bridge(QUERY_MYSQL, mysqlAdapter::execute);
|
||||
// 构造方法里 null 也不注册 invokers / callbacks
|
||||
```
|
||||
|
||||
没配数据源 → query_mysql 从模型视野和执行注册表**都消失**(不暴露「必死工具」)。RAG/日志后端内置,无条件注册。
|
||||
|
||||
**③ definition 的 typed 输入类**:
|
||||
|
||||
```java
|
||||
definition(AgentToolContracts.LOOKUP_KNOWLEDGE, ..., RagToolCall.class)
|
||||
```
|
||||
|
||||
输入类型 = 模型必须匹配的 Schema——`RagToolCall{previous_observation, input}`。parse 时用 `FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS` 强制匹配,**模型输出多一个字段都炸**(INVALID_ENVELOPE)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段三:调用(拦截器 → 注册表)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as 模型
|
||||
participant F as 框架 ReactAgent
|
||||
participant I as HarnessToolInterceptor
|
||||
participant ET as HarnessEvidenceTools
|
||||
|
||||
M->>F: 决定调用 lookup_knowledge(输出 tool_call JSON)
|
||||
F->>I: 回调 interceptToolCall(request, handler)
|
||||
I->>I: supports(toolName) ? 注册检查
|
||||
I->>ET: parse(toolName, arguments, mapper) → typed Envelope
|
||||
ET-->>I: ParsedAgentToolCall(previous_observation + input)
|
||||
I->>I: 协议校验 / 判重(不通过不执行)
|
||||
I->>ET: invoke(context, toolName, toolCallId, args)
|
||||
ET->>I: bridge lambda → adapter.execute
|
||||
```
|
||||
|
||||
调用链要点:
|
||||
|
||||
| 点 | 说明 |
|
||||
|---|---|
|
||||
| **工具名是模型决定的** | `request.getToolName()` 来自模型输出,拦截器拿它查 invokers |
|
||||
| **invoke 前有三道门** | supports 分流 → parse 严格契约 → 协议/判重——判重不通过不执行 |
|
||||
| **envelope 现造** | bridge 里构造,`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」 |
|
||||
| **工具名被闭包捕获** | 即使调用方传错名字,envelope 里也是正确的工具名(防混淆) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段四:执行(Adapter → ToolBoundary → 后端)
|
||||
|
||||
### 5.1 Adapter = 接线员
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AD["Adapter.execute"] -->|"boundary.execute(context, envelope,"| TB["ToolBoundary"]
|
||||
AD -->|"executor = ignored -> legacyExecutor.execute(query)"| TB
|
||||
AD -->|"projector = raw -> projector.project(...)"| TB
|
||||
TB -->|"executor 跑 backend"| BK["具体后端<br/>LookupKnowledgeTool / QueryLogsTools / JDBC"]
|
||||
TB -->|"projector 投影"| PR["RagResultProjector / QueryLogsResultProjector / MysqlResultProjector"]
|
||||
TB -->|"markReady"| ST["CanonicalInvocationStore"]
|
||||
```
|
||||
|
||||
**两个端口**:executor(跑 backend 拿 raw)+ projector(raw → 有界脱敏契约)。**模型永远看不到 raw**——这是执行链的核心目的。
|
||||
|
||||
### 5.2 LegacyExecutor vs 专用 Executor
|
||||
|
||||
| | RAG/日志 | MySQL |
|
||||
|---|---|---|
|
||||
| 后端来源 | 重构前旧类(LookupKnowledgeTool / QueryLogsTools) | 全新实现(JdbcMysqlReadOnlyExecutor) |
|
||||
| 端口位置 | Adapter **内部**定义 LegacyExecutor | mysql **包**里定义 MysqlReadOnlyExecutor |
|
||||
| 注入方式 | `backend::lookupKnowledge` 方法引用 / lambda | 直接注入专用实现 |
|
||||
| 为什么 | 复用成熟旧代码 | 新工具直接面向沙箱设计 |
|
||||
|
||||
```
|
||||
RAG: Adapter → LegacyExecutor(Adapter内部) → LookupKnowledgeTool(旧后端)
|
||||
MySQL: Adapter → MysqlReadOnlyExecutor(mysql包) → JdbcMysqlReadOnlyExecutor(新实现)
|
||||
```
|
||||
|
||||
### 5.3 ToolBoundary 五阶段(执行门禁)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
|
||||
B["② executor.execute(requestJson) → backend raw"]
|
||||
C["③ raw 大小校验 + Run bytes 预留"]
|
||||
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["⑤ agent_result 校验 + bytes → markReady(READY)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
**三笔 bytes 预留**:request(①)/ raw(③)/ agent_result(⑤)。
|
||||
|
||||
### 5.4 脱敏与有界(投影器)
|
||||
|
||||
- **日志**:sanitize 抹掉密码/token/主机/Pod/IP/PID/SQL 字面量;均匀采样 + 模式聚合;
|
||||
- **MySQL**:敏感列(password/token/secret 等)单元格 → `[REDACTED]`;行数/字符/字节三重截断;
|
||||
- **RAG**:chunk 级去重 + 摘录截断 + fitBudget 总字节兜底。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段五:返回(双源校验 → 记账 → 有界观察)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant TB as ToolBoundary
|
||||
participant I as HarnessToolInterceptor
|
||||
participant P as DiagnosisProgressTracker
|
||||
participant M as 模型
|
||||
|
||||
TB-->>I: ToolBoundaryResult(READY/ERROR)
|
||||
I->>I: 双源交叉验证(声明值 vs 内容重算 evidence_status)
|
||||
alt 不一致
|
||||
I->>M: OBSERVATION_CONTRACT_MISMATCH(拒绝)
|
||||
else 一致
|
||||
I->>P: recordCompleted(call, evidenceStatus)
|
||||
P-->>I: 快照(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
|
||||
I->>I: modelObservation 加工(含 stop_required / stopReason)
|
||||
I-->>M: 有界 observation(模型永远看不到 raw)
|
||||
end
|
||||
```
|
||||
|
||||
**错误处理三种形态**:
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| Adapter 业务/参数异常 | catch → `INVALID_REQUEST`(不泄露内部细节) |
|
||||
| MySQL 安全异常 | `MysqlSecurityException` 单独 catch → `INVALID_REQUEST` |
|
||||
| 日志后端缺失 | `ObjectProvider.getIfAvailable()` → 返回空结果 JSON(不炸) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 全链路合起来
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as config(启动)
|
||||
participant ET as HarnessEvidenceTools(单例)
|
||||
participant I as 拦截器(per-Run)
|
||||
participant AD as Adapter(单例)
|
||||
participant TB as ToolBoundary(单例)
|
||||
participant ST as CanonicalStore(单例)
|
||||
participant M as 模型
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over C,ET: ① 装配(应用启动一次)
|
||||
C->>C: 建 boundary / 3 个 Adapter(注入 boundary+后端+投影器)
|
||||
C->>ET: fromAdapters(rag, logs, mysql?)
|
||||
ET->>ET: bridge → invokers + definition → callbacks(平行,同名串起)
|
||||
end
|
||||
|
||||
rect rgb(255, 250, 240)
|
||||
Note over M,AD: ② 调用+执行(每次 Tool Call)
|
||||
M->>I: 模型决定工具名 → 框架回调拦截器
|
||||
I->>I: supports 分流 → parse(typed 严格契约)→ 协议/判重
|
||||
I->>ET: invoke → invokers.get(名字) → bridge 闭包
|
||||
ET->>AD: adapter.execute(context, envelope[现造,授权只读写死])
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>ST: begin(PROJECTING) → executor 跑 raw → projector 投影 → markReady(READY)
|
||||
TB-->>I: ToolBoundaryResult
|
||||
I->>I: 双源校验 → recordCompleted → modelObservation
|
||||
I-->>M: 有界 observation(无 raw)
|
||||
end
|
||||
```
|
||||
|
||||
**四阶段汇总**:
|
||||
|
||||
| 阶段 | 做什么 | 关键类 |
|
||||
|---|---|---|
|
||||
| 装配 | Spring 组合依赖(无继承) | config / Adapter / ToolBoundary |
|
||||
| 注册 | bridge 成 invoker + definition 成 callback(平行同名串起) | HarnessEvidenceTools |
|
||||
| 调用 | supports → parse 严格契约 → 协议/判重 → invoke | Interceptor / HarnessEvidenceTools |
|
||||
| 执行+返回 | boundary 五阶段 → 投影脱敏 → canonical → 双源校验 → 记账 → 有界观察 | Adapter / ToolBoundary / Projector / Store |
|
||||
|
||||
---
|
||||
|
||||
## 8. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| 必炸 = 死工具 | 必炸是**保护**:模型通过拦截器正常执行,只有绕过路径才炸 |
|
||||
| LegacyExecutor 是通用 executor | 它只服务于「复用旧后端」;新工具直接注入专用 Executor(如 MysqlReadOnlyExecutor) |
|
||||
| callbacks 是执行器 | 它是「模型可见目录」+ 必炸占位;真执行走 invokers |
|
||||
| 执行链只有 executor | 还有 **projector**(raw → 有界契约)——模型永远看不到 raw |
|
||||
| MySQL 没有 tool 类 | `JdbcMysqlReadOnlyExecutor` 就是它的后端执行类,只是不叫 Tool |
|
||||
| 工具注册是静态列表 | **配置驱动**:没配数据源 → query_mysql 从两表消失 |
|
||||
| 返回就是 ToolBoundaryResult | 返回后还有双源校验 → recordCompleted → modelObservation |
|
||||
|
||||
## 9. 面试话术合集(30 秒)
|
||||
|
||||
### 9.1 为什么不用继承
|
||||
|
||||
> "tool 域刻意不用继承:ToolBoundary 通过 ToolExecutor/ToolResultProjector 两个函数式端口接收执行和投影逻辑,三个 Adapter 各自用构造注入组合 boundary + 后端 + projector,HarnessEvidenceTools 再用 bridge 把 Adapter 包成统一的 EvidenceToolInvoker 注册表。类图里没有 extends 箭头——全是 has-a(组合)和函数适配(函数式接口),扩展新工具不改任何已有类。"
|
||||
|
||||
### 9.2 为什么必炸保护
|
||||
|
||||
> "必炸保护是执行不可绕过的结构性保证:证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为死路。唯一能执行证据工具的路径就是拦截器接管——预算、canonical、进度协议、脱敏门禁永远在线;任何绕过尝试要么抛异常暴露 bug(fail-fast),要么根本不执行(fail-closed)。非证据工具不需要门禁,所以拦截器放行、callback 正常。"
|
||||
|
||||
### 9.3 LegacyExecutor 是什么
|
||||
|
||||
> "LegacyExecutor 是 Adapter 内部定义的旧后端端口:RAG 和日志是重构前就有的工具,后端实现(backend::lookupKnowledge)被方法引用注入复用,通过 Adapter 包进 Harness 门禁——『旧后端复用,新门禁外挂』。它和 ToolBoundary 的 ToolExecutor 是两层:LegacyExecutor 是具体后端怎么查,ToolExecutor 是边界统一端口,Adapter 把前者包成后者。MySQL 是全新工具,没有 legacy,直接用新写的 MysqlReadOnlyExecutor。"
|
||||
|
||||
### 9.4 模型为什么看不到 raw
|
||||
|
||||
> "执行链是两个端口:executor 跑 backend 拿 raw,projector 把 raw 投影成有界脱敏契约(截断 + 脱敏 + 冻结 Schema)。ToolBoundary 只让 READY/ERROR 离开,模型拿到的是 modelObservation 加工后的有界观察——raw 只进 canonical Store 供审计和验真,永远不进入模型上下文。"
|
||||
|
||||
### 9.5 新工具怎么加(开闭原则)
|
||||
|
||||
> "新工具按 MySQL 模板:写 Contract 三件套 + 专用执行器 + 投影器 + Adapter,config 注册。要改的只有 AgentToolContracts 常量、HarnessEvidenceTools 构造、config;不用改 ToolBoundary、canonical、拦截器——新工具自动获得预算门禁、真相记录、脱敏投影、进度收敛、证据验真全套管控。"
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java`(agent 包,tool 域门面) |
|
||||
| `RagToolAdapter` / `QueryLogsToolAdapter` / `MysqlToolAdapter` | `.../tool/adapter/` |
|
||||
| `ToolBoundary` / `ToolBoundaryResult` / `ToolCallRequestEnvelope` / `ToolExecutor` / `ToolResultProjector` / `ProjectedToolResult` | `.../tool/boundary/` |
|
||||
| `RagResultProjector` / `QueryLogsResultProjector` / `ToolProjectionLimits` | `.../tool/projection/` |
|
||||
| `CanonicalInvocationStore` / `RedisCanonicalInvocationStore` / `CanonicalToolInvocation` / `ToolCallKeyFactory` / `CanonicalInvocationLimits` | `.../tool/store/` |
|
||||
| Contract 18 个 | `.../tool/contract/` |
|
||||
| `MysqlSqlValidator` / `JdbcMysqlReadOnlyExecutor` / `MysqlResultProjector` / `MysqlDataSourceDefinition` 等 | `.../tool/mysql/` |
|
||||
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
|
||||
@@ -0,0 +1,228 @@
|
||||
# Harness 整体架构学习笔记:从装配到入口到记忆到知识库写入
|
||||
|
||||
> **现状说明(2026-09-29)**:RAG 模块抽离后,本文「知识库写入链路」章节描述的
|
||||
> `KnowledgeIndexService` / Milvus 写入路径已删除(入库下沉 py-rag `documents:ingest`);
|
||||
> 其余装配/入口/记忆章节仍现行。当前架构见
|
||||
> [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:整体架构五块补充(了解层面)——配置装配中心 / HTTP 入口层 / 会话系统与记忆体系 / 知识库写入链路
|
||||
**配套**:九域主线笔记(core/retry/progress/tool/guard/release/agent/audit/contract 全 ✅)
|
||||
|
||||
## 1. 配置装配中心(整体怎么搭起来)
|
||||
|
||||
### 1.1 装配全景(Bean 拓扑)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["ChatHarnessProperties<br/>配置集中(yml)"] --> K["DiagnosisHarnessCore<br/>总闸门:超时/预算/重试/收敛"]
|
||||
K --> B["ToolBoundary<br/>工具底座:canonical/门禁/投影"]
|
||||
B --> A1["RagToolAdapter"]
|
||||
B --> A2["QueryLogsToolAdapter"]
|
||||
B --> A3["MysqlToolAdapter<br/>(可选装配)"]
|
||||
A1 --> E["HarnessEvidenceTools<br/>组装注册"]
|
||||
A2 --> E
|
||||
A3 --> E
|
||||
K --> G["GuardModelCall<br/>(守卫/修复/路由共用底座)"]
|
||||
G --> SG["SemanticGuard"]
|
||||
G --> ER["EvidenceRepair"]
|
||||
G --> R["IntentRouter"]
|
||||
E --> F["DiagnosisAgentFactory"]
|
||||
F --> UC["DiagnosisAgentUseCase"]
|
||||
UC --> D["DiagnosisChatExecutor"]
|
||||
R --> D
|
||||
R --> S["SystemChatExecutor"]
|
||||
R --> KQ["KnowledgeQueryExecutor"]
|
||||
D --> A["ChatApplicationUseCase<br/>应用入口"]
|
||||
S --> A
|
||||
KQ --> A
|
||||
```
|
||||
|
||||
### 1.2 三层组织(话术版)
|
||||
|
||||
```text
|
||||
① 总闸门(core):能花多少钱/跑多久/怎么重试/何时停——配置集中,改一处全局生效
|
||||
② 工具底座:所有工具统一留痕(canonical)/拦截(ToolBoundary)/裁剪(投影)——行为整齐划一
|
||||
③ 具体工具:RAG/Logs/MySQL 按需装配(没配数据源不装死工具)→ 组装注册给 Agent
|
||||
|
||||
串联:先判意图(路由)→ 走对应分支 → 全程在总闸门管辖下
|
||||
一句话:边界集中、执行统一、工具可插拔
|
||||
```
|
||||
|
||||
### 1.3 关键设计点
|
||||
|
||||
| 设计 | 为什么 |
|
||||
|---|---|
|
||||
| 单一装配入口(HarnessChatConfiguration) | 读 Bean 签名 = 读架构拓扑 |
|
||||
| 一切围绕 core | 所有链路共享同一套门禁(超时/预算/重试/收敛) |
|
||||
| 配置属性集中(@EnableConfigurationProperties) | 一处改全局生效,不会有的环节漏管 |
|
||||
| 工具可选装配(mysqlEnabled 判断) | 没配置不装死工具;ObjectProvider 可选后端 |
|
||||
| 守卫/修复/路由共用 GuardModelCall | LLM judge 模式:同一轻量模型底座 |
|
||||
| Redis 存 canonical | 跨实例共享 + TTL 过期 |
|
||||
| 线程池 AbortPolicy | 队列满直接拒绝(fail fast) |
|
||||
|
||||
## 2. HTTP 入口层(薄协议适配)
|
||||
|
||||
### 2.1 请求流时序
|
||||
|
||||
```text
|
||||
POST /api/chat {Id, Question}
|
||||
→ 校验 → new SseEmitter + ChatSseSession(= ChatApplicationObserver)
|
||||
→ chatWorkerExecutor.execute(...) ← 异步:HTTP 线程不跑模型
|
||||
→ 立即返回 200 + TEXT_EVENT_STREAM
|
||||
→ worker 线程执行编排,经 session 推事件
|
||||
→ 队列满 → 503(RejectedExecutionException)
|
||||
```
|
||||
|
||||
### 2.2 SSE 状态机 + 五类事件
|
||||
|
||||
```text
|
||||
状态机:NEW → OPEN → TERMINAL(收尾)/ DISCONNECTED(断连)
|
||||
每个方法 requireState 校验顺序——防乱序推送
|
||||
|
||||
事件协议:
|
||||
metadata → {session_id, run_id}(首推)
|
||||
status → 编排进度(ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING…)
|
||||
content → 最终内容(content_type + payload)
|
||||
failure → 失败码 + 消息
|
||||
done → 终态(SUCCESS/FALLBACK/FAILED)★ CANCELLED 对外不可见
|
||||
```
|
||||
|
||||
### 2.3 断连取消链路(贯穿到 Harness)
|
||||
|
||||
```text
|
||||
客户端断开 → emitter.onTimeout/onError/onCompletion → session.disconnect()
|
||||
→ 状态 DISCONNECTED → runControl.cancelClientDisconnect()
|
||||
→ Harness 取消机制接管(checkActive / 拦截器 / 线程池 cancel)
|
||||
onStarted 时若已断连:直接取消——不白跑
|
||||
```
|
||||
|
||||
### 2.4 失败两层出口
|
||||
|
||||
```text
|
||||
SSE 通道:ChatApplicationException → session.fail(failure + done(FAILED))
|
||||
其他 RuntimeException → INTERNAL_FAILURE 通用信息(不泄露细节)
|
||||
REST 通道:GlobalExceptionHandler → 404(SessionNotFound)/ 400(参数/文档/文件超限)/ 500(兜底)
|
||||
|
||||
→ 编排异常走 SSE failure,REST 异常走 HTTP 状态码——都不暴露内部细节
|
||||
```
|
||||
|
||||
## 3. 会话系统与记忆体系(术语精确校准)
|
||||
|
||||
### 3.1 会话存储:不存历史,存「可重放的发布结果」
|
||||
|
||||
```text
|
||||
ChatSession(chat_session 表):只存元数据(status/messagePairCount/时间戳)
|
||||
——「message history is not persisted here」
|
||||
DiagnosisSession:诊断快照(query/answer/selfEvaluation/feedback)
|
||||
真正的历史:DiagnosisRun(每次运行一行)+ PublishedResult(JSON 落库)
|
||||
```
|
||||
|
||||
### 3.2 PreviousTurn 注入链路(短期记忆)
|
||||
|
||||
```text
|
||||
ChatApplicationUseCase 开头读 findPreviousTurn(sessionId)
|
||||
→ 查最近 SUCCESS+DIAGNOSIS+publishedResult 非空的 Run
|
||||
→ 反序列化 PublishedResult → PublishedResultPolicy 生成【有界】摘要
|
||||
(limitations 10 条×500 字 / 源文档 10 个 / 字段限长——有界在生成时)
|
||||
→ 传 executePath → DiagnosisChatExecutor
|
||||
→ new DiagnosisAgentInput(query, previous_turn)
|
||||
→ 序列化成输入 JSON → agent.call(inputJson) → 模型从输入读到
|
||||
|
||||
设计三决策(话术版):
|
||||
诊断短流程 → 只取上一轮(更早记忆靠多轮逐层传递)
|
||||
非 SUCCESS 误导 → SUCCESS 才准入(且非 SUCCESS 轮次根本没写 PublishedResult)
|
||||
token 爆炸 → 有界摘要(PreviousTurnLimits)
|
||||
补充:可回放——模型看有界摘要,审计看全量 JSON(两层分离)
|
||||
```
|
||||
|
||||
### 3.3 记忆术语校准(重要认知)
|
||||
|
||||
```text
|
||||
判定标准:记忆 = 会被【注入 prompt/上下文】的东西(不是存了什么)
|
||||
|
||||
PreviousTurn → 注入输入 JSON(user message)→ ✅ 短期记忆(当前会话)
|
||||
lookup_knowledge → 工具调用动态获取(tool result)→ ❌ 记忆,是检索增强(RAG)
|
||||
知识库 → 检索源,规模太大无法全量注入 → 归检索侧(工具检索是正确形态)
|
||||
案例库 → 有结构有写入、缺检索注入 → 长期记忆的【候选原料】
|
||||
运行档案 → 元数据层永不注入 → 审计数据
|
||||
|
||||
项目真实情况:只有短期记忆(PreviousTurn)+ 检索增强(RAG),【没有】长期记忆层
|
||||
```
|
||||
|
||||
### 3.4 长期记忆设计路径(如果要做)
|
||||
|
||||
```text
|
||||
筛选标准:规模可控 + 跨会话价值 + 可注入形态
|
||||
|
||||
案例库最符合:root_cause+solution 结构化摘要、注入 top 2-3 条、相似故障复用解法
|
||||
PreviousTurn 扩展:最近 N 轮结论摘要(短期 → 中期记忆)
|
||||
Feedback 偏好:用户偏好摘要
|
||||
知识库不符合:全量太大 → 保持工具检索
|
||||
|
||||
案例 → skill 提炼(项目已实现):
|
||||
6 个 SKILL.md(diagnose-mysql-connection-pool 等)
|
||||
结构:Workflow / Required Evidence / Stop Conditions / Report Rules / Eval Anchor
|
||||
注入:ClasspathSkillRegistry → SystemPromptTemplate → system prompt(程序性长期记忆)
|
||||
情景记忆(案例)→ 程序记忆(skill)→ 常驻注入 ✅ 长期记忆的正确形态
|
||||
现有缺口:单技能激活(只放行 1 个)/ 静态加载(无按 query 自动匹配)/ skill 与案例库断开
|
||||
```
|
||||
|
||||
## 4. 知识库写入链路(RAG 写半边)
|
||||
|
||||
```text
|
||||
上传(/api/documents/upload)
|
||||
→ TextExtractorService(文本提取)
|
||||
→ DocumentChunkService.chunkDocument(分块)★
|
||||
→ VectorEmbeddingService(dense embedding)
|
||||
→ VectorIndexService.indexDocumentChunks(写 Milvus)★
|
||||
→ 检索侧(lookup_knowledge)读同一份索引
|
||||
```
|
||||
|
||||
### 关键设计
|
||||
|
||||
```text
|
||||
① 分块:按章节分块(非定长硬切)+ 相邻 chunk 保留 overlap(减轻边界断裂)
|
||||
双条件限制:maxSize(字符)+ maxTokens(token)
|
||||
② hybrid 写入:dense(应用侧 embedding → vector 字段)
|
||||
+ BM25(buildSearchText → search_text 字段)
|
||||
★ 关键:dense embedding 输入 与 BM25 search_text 【同源】——
|
||||
同一个「增强文本」既喂 embedding 又写 BM25 字段
|
||||
→ 两路召回看到完全一致的文档内容,混合检索才公平
|
||||
③ 弃用:legacy MilvusServiceClient(旧 SDK)/ Spring AI VectorStore#add(无 hybrid schema)
|
||||
唯一后端:MilvusHybridKnowledgeStore(Milvus SDK v2)
|
||||
```
|
||||
|
||||
## 5. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Controller 做业务编排 | 薄适配层:校验+开 SSE+异步+写回,编排在 Application |
|
||||
| SSE 顺序不重要 | requireState 状态机校验——防乱序推送 |
|
||||
| 取消对外可见 | Done 拒绝 CANCELLED——客户端只看到 SUCCESS/FALLBACK/FAILED |
|
||||
| 知识库 = 长期记忆 | 是检索源(工具动态获取);记忆 = prompt 注入——项目无长期记忆层 |
|
||||
| 案例 = 长期记忆 | 是候选原料——缺检索注入;skill 才是程序性长期记忆(已实现) |
|
||||
| 工具预算在工具层 | maxToolCalls/收敛参数都在 core(总闸门) |
|
||||
| 分块定长硬切 | 按章节 + overlap + 双条件限制 |
|
||||
|
||||
## 6. 面试话术(30 秒)
|
||||
|
||||
### 6.1 整体架构怎么组织
|
||||
|
||||
> "整个系统从下往上三层:最底层一套全局规则(超时/预算/重试/收敛,配置集中改一处全局生效);中间一层工具共用的底座(调用留痕、统一拦截、结果裁剪);上层按需装配具体工具(知识库/日志/数据库,配了才装)。最后串成应用入口——先判意图再走分支,全程在总闸门管辖下。一句话:边界集中、执行统一、工具可插拔。"
|
||||
|
||||
### 6.2 记忆体系
|
||||
|
||||
> "记忆的判定标准是会不会被注入上下文:PreviousTurn 是短期记忆(注入输入 JSON,上一轮 SUCCESS 的有界摘要);知识库是检索增强不是记忆(工具动态获取);项目没有长期记忆层——skill(案例提炼的诊断方法)注入 system prompt 是程序性长期记忆的正确形态;案例库是候选原料,缺检索注入。"
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 块 | 文件 |
|
||||
|---|---|
|
||||
| 装配中心 | `config/HarnessChatConfiguration.java`(+ `config/ChatHarnessProperties.java`) |
|
||||
| HTTP 入口 | `controller/ChatController.java` + `controller/sse/ChatSseSession.java` / `ChatSseEvent.java` / `SseEmitterChatSink.java` |
|
||||
| 异常映射 | `exception/GlobalExceptionHandler.java` |
|
||||
| 会话存储 | `harness/application/persistence/JpaChatRunStore.java` / `PreviousTurnLimits.java` / `PublishedResultPolicy.java` |
|
||||
| 记忆注入 | `harness/application/ChatApplicationUseCase.java` + `harness/application/executor/DiagnosisChatExecutor.java` + `harness/agent/DiagnosisAgentUseCase.java` |
|
||||
| skill 机制 | `config/SkillConfig.java` + `src/main/resources/skills/*/SKILL.md` |
|
||||
| 知识库写入 | `service/DocumentChunkService.java` / `VectorIndexService.java` / `VectorEmbeddingService.java` / `KnowledgeBaseInitService.java` + `controller/DocumentController.java` / `KnowledgeBaseController.java` |
|
||||
@@ -0,0 +1,164 @@
|
||||
# Harness 证据安全链学习笔记:从收敛控制到唯一发布点
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:progress → guard → release 三域联动——双通道验证架构 + 状态流转全景 + 关键字段来源与使用
|
||||
**配套**:[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)、[Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
**证据安全链 = progress(执行期收敛控制)→ guard(验证)→ release(唯一发布点)**:
|
||||
任何对外发布的内容,必须能追溯到 canonical 账本的已验证事实;任何无法证明的内容,只能以有界、诚实的 SafeFallback 降级形态出现。
|
||||
|
||||
## 2. 主链路图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 执行期["Agent 执行期(core 控资源 + progress 控收敛)"]
|
||||
TC["HarnessToolInterceptor<br/>工具调用完成"]
|
||||
TC -->|"完整记录"| CS["Canonical Store<br/>(唯一真相源, TTL 2h)"]
|
||||
TC -->|"identity"| TR["Progress Tracker<br/>(toolCallId+toolName+scope)"]
|
||||
end
|
||||
|
||||
subgraph 停止["Agent 结束(所有出口触发投影)"]
|
||||
TR -->|"回读验真"| PP["DiagnosisProgressProjector"]
|
||||
PP --> PS["ProgressSnapshot<br/>(observedFacts+limitations+stopReason)"]
|
||||
end
|
||||
|
||||
subgraph 裁决["Release 裁决(唯一发布点)"]
|
||||
EX["DiagnosisAgentExecution<br/>(draft / stopped)"]
|
||||
EX --> RC["releaseConclusion<br/>(有结论)"]
|
||||
RC -->|"tool_call_ids"| EG["EvidenceGuard<br/>机械验引用"]
|
||||
EG -->|"失败"| ER["EvidenceRepair<br/>只修引用→复查"]
|
||||
EG -->|"通过"| VES["VerifiedEvidenceSnapshot"]
|
||||
VES --> SG["SemanticGuard<br/>判支持度"]
|
||||
SG -->|"SUPPORTED"| SUCCESS["SUCCESS<br/>(唯一出口)"]
|
||||
SG -->|"UNSUPPORTED/不可用"| FB["SafeFallback 降级"]
|
||||
EG -->|"仍失败"| FB
|
||||
EX -->|"stopped / 无结论"| FB
|
||||
PS -->|"降级原料"| FB
|
||||
end
|
||||
|
||||
CS -.->|"回读"| EG
|
||||
CS -.->|"回读"| PP
|
||||
```
|
||||
|
||||
## 3. 双通道验证架构(核心)
|
||||
|
||||
两条并行的「账本背书」通道,合起来覆盖所有结局:
|
||||
|
||||
| | progress 快照 | guard 快照 |
|
||||
|---|---|---|
|
||||
| 类 | `DiagnosisProgressSnapshot` | `VerifiedEvidenceSnapshot` |
|
||||
| 组装时机 | agent 结束那一刻(所有出口) | release 验引用通过后 |
|
||||
| 原料 | Tracker 的调用 identity | draft 里模型写的 tool_call_ids |
|
||||
| 回读者 | `DiagnosisProgressProjector` | `EvidenceGuard` |
|
||||
| 需要 draft | **不需要** | **必须** |
|
||||
| 服务谁 | 受控停止/无结论/非法 draft 降级 | 有结论 draft 证据链 + SemanticGuard |
|
||||
| 共同点 | 都从 canonical 回读、三重校验、去重有界、绝不输出 raw | 同左 |
|
||||
|
||||
**设计意义**:agent 没给出合法 draft(预算打断、输出烂)时,靠 progress 快照降级;给出合法 draft 时,靠 guard 快照支撑。**任何情况下对外发布都有据可依。**
|
||||
|
||||
## 4. 状态流转全景(技术终态 vs 用户终态)
|
||||
|
||||
### 4.1 六层状态(从内到外)
|
||||
|
||||
| 层 | 类型 | 值 | 回答的问题 |
|
||||
|---|---|---|---|
|
||||
| core 生命周期 | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许继续执行 |
|
||||
| progress 收集停止 | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED | 证据收集为何受控停止 |
|
||||
| release 发布裁决 | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局是什么 |
|
||||
| release 降级细分 | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | FALLBACK 为什么降级 |
|
||||
| SSE 进度 | `ChatApplicationStatus` | ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING ... | 当前走到哪一阶段(不是结局) |
|
||||
| 对外失败码 | `ChatFailureCode` | RUN_CANCELLED / INTERNAL_FAILURE / ... | 失败时给客户端的粗粒度原因 |
|
||||
|
||||
### 4.2 关键映射规则(代码事实)
|
||||
|
||||
```text
|
||||
RunState.BUDGET_EXHAUSTED + progress.hasObservedFacts()==true
|
||||
→ ReleaseOutcome.FALLBACK(FallbackType.INSUFFICIENT_EVIDENCE)
|
||||
RunState.BUDGET_EXHAUSTED + 无 facts
|
||||
→ FAILED(fail closed:没有安全内容可发布)
|
||||
RunState.CANCELLED → ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
|
||||
内部失败(completeFailure)→ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
|
||||
guard 全过 → RunState.SUCCESS + ReleaseOutcome.SUCCESS(唯一出口)
|
||||
预算耗尽且子路径已产出 FALLBACK → 不二次 completeSuccess
|
||||
```
|
||||
|
||||
### 4.3 两个正交维度的区分(高频面试点)
|
||||
|
||||
- **RunState** 回答「Run 技术上是否还在跑、因何技术终态停下」;
|
||||
- **ReleaseOutcome** 回答「用户侧内容形态:正常报告 / 安全降级 / 失败 / 取消」;
|
||||
- 常见组合:`RunState=BUDGET_EXHAUSTED` 且有安全进展 → `ReleaseOutcome=FALLBACK`;无进展 → `FAILED`。
|
||||
|
||||
### 4.4 终态异常透传
|
||||
|
||||
`DiagnosisReleaseUseCase.propagateTerminal`:`RunAbortedException` / `BudgetExceededException` / `RetryFailure.CANCELLED|BUDGET_EXHAUSTED` **原样上抛**,不吞、不伪装成业务 FALLBACK——取消和预算耗尽是 Run 的技术终态事实,用户侧必须知道「被取消了」而不是「诊断结论是不足」。
|
||||
|
||||
## 5. 关键字段的来源与使用
|
||||
|
||||
### 5.1 CanonicalToolInvocation(唯一真相源,执行时落库)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| tool_call_id + run_id | ToolBoundary 执行时生成 | key = runId + toolCallId(两个投影器都按它回读) |
|
||||
| request / raw_response | 工具请求与原始返回 | 审计/验真,不外发 |
|
||||
| agent_result | Projector 投影后的有界结果 | Agent 可见的唯一形态;EvidenceGuard 重读它 |
|
||||
| status | PROJECTING → READY / ERROR(单向迁移) | isReferencableBy 要求 READY |
|
||||
| evidence_status | FOUND / NO_EVIDENCE / ERROR | kind.accepts() 匹配、投影一致性校验 |
|
||||
| error_code | 仅 ERROR 携带 | 稳定错误码 |
|
||||
| started_at / completed_at | 生命周期时间戳 | TTL / 审计 |
|
||||
|
||||
### 5.2 DiagnosisProgressSnapshot(progress 快照,agent 结束时组装)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| verifiedSources | Projector 回读 canonical,去重 | 降级时展示「查过哪些来源」 |
|
||||
| observedFacts | 同上(≤12 条,摘要 320 字,空查询也算) | 「查了查到什么」;`hasObservedFacts()` 安全阀 |
|
||||
| limitations | 无法验真/截断的诚实说明 | 降级时展示限制 |
|
||||
| stopReason | Tracker 状态 | release 检查白名单后决定降级 |
|
||||
|
||||
### 5.3 VerifiedEvidenceSnapshot(guard 快照,验真通过后组装)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| analyses[] | EvidenceGuard 重读 canonical agent_result 重建 | SemanticGuard.review 输入 |
|
||||
| verifiedSources() | 方法去重(sourceType+source+scope) | SUCCESS 的 published_result.source_documents、FALLBACK 的来源 |
|
||||
|
||||
### 5.4 SafeFallback(降级载荷,release 降级出口构造)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| type | SafeFallbackFactory 按场景 | 用户/审计区分降级原因 |
|
||||
| conclusion | 恒 null | 降级绝不发布根因结论 |
|
||||
| verified_sources / observed_facts | progress 或 guard 快照投影 | 保留已验证事实供用户继续排查 |
|
||||
| limitations / next_steps | 工厂按场景拼装 | 诚实说明 + 下一步 |
|
||||
| failure_stage | DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION | 定位失败阶段 |
|
||||
| validation_issues | EvidenceViolation 映射 | EVIDENCE_VALIDATION_FAILED 时的违规明细 |
|
||||
|
||||
## 6. 设计要点(贯穿全链的规律)
|
||||
|
||||
1. **fail closed 贯穿每一层**:guard 验引用默认拒绝、读投影严格反序列化、release 无 facts 抛异常、SafeFallback 无事实拒绝构造——「不确定 → 默认拒绝,没查到永不伪装成结果」。
|
||||
2. **负向证据被完整建模**:progress 空查询投影成事实、NEGATIVE_OBSERVATION 配 NO_EVIDENCE、降级诚实声明「查到了但不够」。
|
||||
3. **canonical 唯一真相源**:链上不存在第二份工具真相;两个投影器共用同一套三重校验。
|
||||
4. **全链路可审计回放**:EVIDENCE_GUARD_INITIAL/RECHECK、SEMANTIC_ATTEMPT/DECISION、EVIDENCE_REPAIR_ATTEMPT、RELEASE_DECISION 全落 trace。
|
||||
5. **语义不变性**:EvidenceRepair 只修引用不修结论(prompt 锁死 + hasSameUserVisibleSemantics 校验)。
|
||||
6. **命名债务**:`FallbackType.BUDGET_EXHAUSTED` 枚举保留,但预算停实际发布 `INSUFFICIENT_EVIDENCE`(注释自认)。
|
||||
7. **重复验证**:同一 tool_call_id 被多条 analysis 引用会重复验 N 次(引用级独立校验的代价,账本查询便宜可接受)。
|
||||
|
||||
## 7. 面试话术(30 秒)
|
||||
|
||||
> "Harness 的证据安全链是 progress → guard → release 三段联动。**执行期**:progress 管信息增益收敛(预算归 core),工具调用实时落 canonical 账本、Tracker 只记 identity;**验证期**:agent 结束后 release 编排——有结论的 draft 先进 EvidenceGuard 机械验引用(每个 tool_call_id 从账本回读、READY 且 kind 匹配,失败则 EvidenceRepair 只修引用再验),通过后 SemanticGuard 判结论是否被证据支持;**发布期**:SUPPORTED 是唯一 SUCCESS 出口,其余全部经 SafeFallbackFactory 构造有界诚实的降级。关键设计是**双通道**:没有合法 draft 时靠 progress 快照(observedFacts)降级,有 draft 时靠 guard 快照支撑——任何情况对外发布都有据可依;以及**状态正交**:RunState 回答技术终态(预算耗尽/取消),ReleaseOutcome 回答用户结局(降级/失败),取消和预算耗尽经 propagateTerminal 原样上抛,绝不伪装成业务降级。"
|
||||
|
||||
## 8. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| EvidenceGuard / EvidenceViolationCode | `src/main/java/com/superbiz/agent/harness/guard/evidence/` |
|
||||
| SemanticGuard / GuardModelCall | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
|
||||
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `src/main/java/com/superbiz/agent/harness/release/` |
|
||||
| DiagnosisProgressProjector / Tracker / Snapshot | `src/main/java/com/superbiz/agent/harness/progress/` |
|
||||
| CanonicalToolInvocation / Store | `src/main/java/com/superbiz/agent/harness/tool/store/` |
|
||||
| RunState | `src/main/java/com/superbiz/agent/harness/core/RunState.java` |
|
||||
| DiagnosisStopReason | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisStopReason.java` |
|
||||
| ReleaseOutcome / FallbackType / SafeFallback | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
| ChatApplicationStatus / ChatFailureCode | `src/main/java/com/superbiz/agent/harness/application/` |
|
||||
@@ -0,0 +1,307 @@
|
||||
# Harness 面试复习笔记:五步复习与白板图沉淀(详细版)
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:面试总复习成果固化——30 秒电梯陈述 / 三张白板图 / 九域五段式面试讲法 / 六个易错点 / 追问应对大全 / 支付超时案例
|
||||
**配套**:[面试速查](Harness面试速查-一张图讲清设计.md)、[设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md)、各域学习笔记
|
||||
|
||||
## 1. 五步复习路径
|
||||
|
||||
```text
|
||||
① 30 秒电梯陈述 + 一张图
|
||||
② 默画三张白板图(主链路 / 职责迁移 / 数据三层)
|
||||
③ 六个易错点
|
||||
④ 2 分钟真实案例(支付超时)
|
||||
⑤ 每域面试话术背诵(九域五段式)
|
||||
```
|
||||
|
||||
## 2. 30 秒电梯陈述(详细版)
|
||||
|
||||
### 2.1 一句话版本
|
||||
|
||||
> "Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。"
|
||||
|
||||
### 2.2 逐句展开(面试官追问「具体怎么做」时用)
|
||||
|
||||
```text
|
||||
「确定性控制边界」展开为三层边界:
|
||||
运行边界:RunContext(身份/截止/预算/取消)+ 唯一终态(first-terminal-wins)
|
||||
事实边界:ToolBoundary(权限/只读/容量)+ canonical 真相 + 有界观察
|
||||
发布边界:EvidenceGuard 验引用 → SemanticGuard 判支持度 → Release 唯一出口
|
||||
```
|
||||
|
||||
### 2.3 三个重点(背的时候盯住)
|
||||
|
||||
```text
|
||||
Agent 负责业务推理(出草稿,不是出报告)
|
||||
Harness 负责确定性约束(真相与观察分离)
|
||||
Release 决定什么可以公开(引用真实与结论支持是两个独立门禁)
|
||||
```
|
||||
|
||||
### 2.4 不要一开始列 10 个职责域
|
||||
|
||||
先给一句定义 + 三句话,面试官追问「具体怎么做」再沿三张白板图展开。
|
||||
|
||||
## 3. 三张白板图(详细版)
|
||||
|
||||
### 3.1 图一:主链路(含分支,不是单轮)
|
||||
|
||||
```text
|
||||
chat 接口
|
||||
→ 创建 RunContext(core.startRun:runId/deadline/budget/cancel/lifecycle)
|
||||
→ 意图识别(IntentRouter,单次模型调用,输出契约恰好 {intent} 枚举)
|
||||
→ 诊断 Agent(ReAct 多轮循环)
|
||||
├─ agent 决策 → ToolBoundary
|
||||
│ ├─ preflight(run 匹配/授权/只读/JSON/key)→ 失败不落库
|
||||
│ ├─ 预算门禁(beforeToolCall + bytes 三笔预留)
|
||||
│ ├─ canonical 状态机(begin PROJECTING → READY/ERROR)
|
||||
│ ├─ 执行 + Projector 投影(严格校验 + 脱敏 + 截断)
|
||||
│ └─ 有界观察返回 Agent(模型永远看不到 raw)
|
||||
├─ progress 判 GAINED/NO_GAIN(连续 NO_GAIN → 饱和停止)
|
||||
└─ ↺ 循环直到:出草稿 或 受控停止(预算/饱和/协议违规)
|
||||
→ 分支 A(有结论草稿):
|
||||
EvidenceGuard 验引用(key=runId+toolCallId 查账本,READY + kind 匹配)
|
||||
├─ 失败 → EvidenceRepair 只修引用(prompt 锁死 + 语义不变性)→ 复查
|
||||
│ └─ 仍失败 → SafeFallback(EVIDENCE_VALIDATION_FAILED)
|
||||
└─ 通过 → SemanticGuard 判支持度(隔离守卫模型)
|
||||
├─ SUPPORTED → Release → SUCCESS(唯一出口)
|
||||
└─ UNSUPPORTED/不可用 → SafeFallback(SEMANTIC_UNSUPPORTED/UNAVAILABLE)
|
||||
→ 分支 B(无草稿/无结论):Release 凭 progress 快照 → SafeFallback(INSUFFICIENT_EVIDENCE 等)
|
||||
└─ 无安全进展 → fail closed 抛异常 → FAILED
|
||||
```
|
||||
|
||||
**讲解要点**(讲主链路时抓四个时间点):
|
||||
1. Agent 运行前先建立 Run 边界;
|
||||
2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定;
|
||||
3. Agent 只看到有界观察,完整事实由系统独立保管;
|
||||
4. Draft 必须经过唯一发布出口,不能直接发送给用户。
|
||||
|
||||
**三个词记忆**:循环(多轮 ReAct + 收敛)/ 分支(验真失败有修复、无草稿也能降级)/ 分离(真相在 canonical,Agent 只见有界观察)。
|
||||
|
||||
### 3.2 图二:职责迁移(推理合并,权力拆分)
|
||||
|
||||
**为什么迁移**:早期多 Agent(Planner/Executor/Verifier/Composer)加上 Gatekeeper、StateGraph,代价是四套 Prompt/JSON/上下文策略。后来发现四个角色**加起来正好是一次完整 ReAct**:
|
||||
|
||||
```text
|
||||
思考 → Planner
|
||||
行动观察 → Executor + Tool
|
||||
自我检查 → Verifier
|
||||
最终回答 → Composer
|
||||
```
|
||||
|
||||
外层在重复实现框架已有的循环。于是收敛成单个 React Agent + 控制面拆分:
|
||||
|
||||
| 早期角色 | 干什么 | 现在迁移到哪 |
|
||||
|---|---|---|
|
||||
| Planner | 制定排查计划 | Diagnosis Agent(ReAct 思考) |
|
||||
| Executor | 调用 Tool 收集证据 | Diagnosis Agent(ReAct tool 调用) |
|
||||
| Gatekeeper | 机械验真证据引用 | EvidenceGuard(真理源改为 canonical store,对象改为 DiagnosisDraft) |
|
||||
| Verifier | 判断 Claim 是否可信 | SemanticGuard(隔离,无 Tool 无记忆) |
|
||||
| Composer | 组织最终回答 | Release(唯一发布点) |
|
||||
| StateGraph | 显式状态/分支/终态 | Harness 状态分层(正交枚举 + first-terminal-wins) |
|
||||
| 预算止损 | 防止空转 | Progress Control(信息增益收敛,从止损升级为正常收敛) |
|
||||
|
||||
**两个洞察**:
|
||||
1. 代码能机械证明的事实,不交给模型判断(Gatekeeper → EvidenceGuard 的思想延续);
|
||||
2. 只有拥有不同数据权限、不同工具或真正独立业务目标的角色,拆成多 Agent 才值得——把一次 ReAct 的内部步骤外置成多角色,只会放大协议成本。
|
||||
|
||||
**结果**:业务推理合并回单个 Agent,但安全权力拆得更清楚——Agent 没有 canonical 读取权、不能自证引用、没有发布权。
|
||||
|
||||
### 3.3 图三:数据三层(一份工具结果,三个职责)
|
||||
|
||||
```text
|
||||
┌─ 第 1 层:canonical(Redis,TTL 2h,key=runId+toolCallId)────────┐
|
||||
│ request + raw_response + agent_result + status + evidenceStatus │
|
||||
│ 用途:EvidenceGuard 回读验真 / 短期完整真相 / 排查 │
|
||||
├─ 第 2 层:Model Observation(Projector 有界投影,冻结契约)──────┤
|
||||
│ 白名单 / 脱敏 / 截断,只含 Agent 下一步推理所需字段 │
|
||||
│ 用途:服务模型推理(不给 raw,防上下文膨胀/prompt injection) │
|
||||
├─ 第 3 层:Metadata Audit(MySQL 长期)────────────────────────────┤
|
||||
│ 只存 identity/状态/耗时/token/bytes,无正文无 raw 副本 │
|
||||
│ 用途:长期回放(配合 trace 时序线) │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**三分字段**(一次调用):
|
||||
|
||||
```text
|
||||
request = 我问了什么(模型入参)
|
||||
raw_response = 工具回了什么(完整返回,存 canonical 不外发)
|
||||
agent_result = 我能信什么 / 模型能看到什么(投影后有界,供推理 + 验真)
|
||||
```
|
||||
|
||||
**为什么不能共用一份数据**:raw 直接给模型 → 上下文膨胀 + 敏感泄漏 + prompt injection;只存裁剪观察 → EvidenceGuard 无法独立验真;raw 永久进审计 → 制造敏感副本。三个目标冲突,所以真相、观察、元数据各存各的。
|
||||
|
||||
**关键事实**:MySQL 不存完整工具返回和 agent_result——`JpaToolInvocationAuditSink` 落库时只提取 `output_preview`(默认仅 status/evidence_status)和 `output_length`(字节长度)。TTL 过期后只能元数据回放。
|
||||
|
||||
## 4. 九域五段式面试讲法
|
||||
|
||||
每域固定叙事结构:**① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术**,外加高频追问。
|
||||
|
||||
### 4.1 core:执行控制
|
||||
|
||||
- **① 动机**:非确定性 Agent 执行时,身份归属、截止时间、预算、取消、终态必须确定——异步调用和多轮会话中,事实到底属于哪次执行?迟到结果能不能发布?
|
||||
- **② 决策**:RunContext 显式传递(不用 ThreadLocal);唯一终态 first-terminal-wins。
|
||||
- **③ 实现**:`checkActive` 三道闸(模型调用前/工具调用前/工具执行中逐行);取消广播(onCancel → future.cancel);deadline;RunBudget;预算耗尽/取消走终态。
|
||||
- **④ 边界**:协作式取消——同步 Provider 计算未必立即停止;终态防迟到发布但不物理强杀。
|
||||
- **⑤ 话术**:
|
||||
> "core 管一次 Run 的确定性边界:显式 RunContext 跨线程传递(挂进 config metadata,防并发串线),first-terminal-wins 保证唯一终态——第一个写入的终态不可被迟到结果覆盖;checkActive 在模型前、工具前、工具执行中逐行检查,预算/取消/超时到点即 abort;取消是协作式的,逻辑终态和发布被保护,但同步 Provider 计算未必立即停。"
|
||||
- **追问**:取消是强杀吗?(协作式,检查点中止 + 终态防迟到)RunContext 为什么显式?(跨线程 + 并发隔离)预算和 Ledger 区别?(Run 资源门禁 vs 审计账本)
|
||||
|
||||
### 4.2 retry:显式可计量重试
|
||||
|
||||
- **① 动机**:框架/Agent 自带的重试是盲目重试——同一请求无限重试、不计量、不可审计,一个不可靠的工具能把整个 Run 预算耗光。
|
||||
- **② 决策**:重试权从框架收归 Harness,做成显式可计量的 attempt 循环。
|
||||
- **③ 实现**:分类裁决(技术性失败可重试 / 业务性失败不重试);次数/时间/成本三重封顶;剩余超时递减(总超时耗尽不再重试);attempt 可审计。
|
||||
- **④ 边界**:业务性失败不重试(重试也没用);SDK 关闭后由 Harness 全权控制。
|
||||
- **⑤ 话术**:
|
||||
> "重试归 Harness 因为它是成本行为:框架自带的盲目重试不可计量不可审计,Harness 做成显式 attempt 循环——技术性失败才重试、业务性失败不重试,次数/时间/成本三重封顶,每次尝试有分类有记录可审计。Agent 和 Tool 不重试,它们只负责执行,要不要再来一次由 Harness 裁决。"
|
||||
- **追问**:为什么 Agent/Tool 不重试?(重试是成本裁决权,执行层只管执行)
|
||||
|
||||
### 4.3 progress:信息增益收敛
|
||||
|
||||
- **① 动机**:预算只能止损(不能继续消耗资源),不能判断「继续查是否有价值」——Agent 可能拿着通用知识、相似查询、空日志反复空转,最后撞预算。
|
||||
- **② 决策**:预算之外的第二套停止机制——信息增益控制。
|
||||
- **③ 实现**:GAINED/NO_GAIN 判定(结果是否推进诊断);重复检测;Tracker 双计数/pending;连续 NO_GAIN → SATURATED 饱和停止(软/硬停止);拦截器五道门。
|
||||
- **④ 边界**:SATURATED 只停收集,不是 Run 终态;`READY + NO_EVIDENCE` 是成功执行但空结果,不是技术异常。
|
||||
- **⑤ 话术**:
|
||||
> "progress 是预算之外的第二套停止机制:预算管能不能继续消耗资源,progress 管继续查是否推进诊断。它用信息增益判定(GAINED/NO_GAIN)+ 重复检测 + 饱和停止——连续 NO_GAIN 就停,防止 Agent 拿通用知识或空日志空转;空查询(NO_EVIDENCE)也是被完整建模的负向观察,不是技术异常。"
|
||||
- **追问**:Agent 为什么不会无限调用 Tool?(预算止损 + 信息增益收敛双保险)
|
||||
|
||||
### 4.4 tool:事实边界
|
||||
|
||||
- **① 动机**:工具是证据边界——能查什么、查到多少、看到什么必须封死;工具直接连数据库/检索库有破坏面。
|
||||
- **② 决策**:ToolBoundary 统一执行规则 + canonical 存真相 + Projector 有界投影;数据三层分离。
|
||||
- **③ 实现**:preflight 五项(run 匹配/授权/只读/JSON/key)失败不落库;预算门禁(beforeToolCall + bytes 三笔);canonical 状态机(PROJECTING→READY/ERROR);审计 best-effort;每类工具一个 Projector(严格校验 + 脱敏 + 截断)。
|
||||
- **④ 边界**:不理解业务内容(投影交给 ToolResultProjector);不做信息增益判断(progress 的事);只允许 READY/ERROR 离开。
|
||||
- **⑤ 话术**:
|
||||
> "tool 域是证据边界:ToolBoundary 统一四项职责——preflight(run 匹配/授权/只读/JSON 合法性/key 生成,失败不落库)、预算门禁(Tool 预算 + request→raw→agent_result 三笔字节预留)、canonical 状态机(PROJECTING→READY/ERROR)、审计。执行结果分三层:完整真相存 Redis canonical(2h),有界投影给模型,长期审计只留元数据。它明确不做业务投影和信息增益判断——那是 Projector 和 progress 的事。"
|
||||
- **追问**:Tool 结果为什么不直接给模型?(三层数据责任:推理/验真/留存冲突);MySQL 沙箱怎么防?(语义/连接/输出三层防线)
|
||||
|
||||
### 4.5 guard:证据安全链双闸
|
||||
|
||||
- **① 动机**:Agent 会撒谎——编造工具调用、夸大结论。不信任模型自述。
|
||||
- **② 决策**:双闸分离——EvidenceGuard 机械验引用真实(规则、可审计、不调模型),SemanticGuard 隔离判结论支持度(无工具无记忆的单轮二值判断)。
|
||||
- **③ 实现**:EvidenceGuard 三层校验(结构校验 → 逐条引用验真 key=runId+toolCallId 查账本 + isReferencableBy + kind 匹配 → 重读投影重建证据,20 个违规码);SemanticGuard 严格 schema(恰好 {verdict, reason})+ 预算/重试/取消全栈衔接。
|
||||
- **④ 边界**:EvidenceGuard 只问引用真不真,不问语义;SemanticGuard 不能探索事实、不能改写报告。
|
||||
- **⑤ 话术**:
|
||||
> "防编造证据用双闸:EvidenceGuard 是机械验真——模型草稿里每个 tool_call_id 都要去 canonical 账本查到真实记录(key 绑定 runId 防跨 Run、记录必须 READY、kind 与证据语义匹配、投影内部自洽),纯规则可审计不调模型;SemanticGuard 是隔离语义审查——无工具、无记忆、单轮二值判断,只判结论是否被已验证证据支持,输出硬校验为 {verdict, reason} 两个字段。先机械后语义:引用假的直接拦,不浪费模型调用。"
|
||||
- **追问**:为什么 EvidenceGuard 通过还要 SemanticGuard?(引用真实 ≠ 结论被支持:一个防编造证据,一个防夸大结论)
|
||||
|
||||
### 4.6 release:唯一发布点
|
||||
|
||||
- **① 动机**:模型输出的是未经证明的断言,不能直接当答案返回。
|
||||
- **② 决策**:唯一发布点——SUCCESS 只有一条路径(验真过 + 语义支持),其余全降级 SafeFallback。
|
||||
- **③ 实现**:三分支决策树(受控停止凭 progress 快照 / 无结论验引用按进展降级 / 有结论走 Evidence→Repair→Semantic 链);fail closed(无安全进展抛异常);EvidenceRepair 只修引用(prompt 锁死 + 语义不变性检查);SafeFallbackFactory 五种降级(有界/去重/诚实)。
|
||||
- **④ 边界**:终态异常透传(取消/预算耗尽不伪装成业务 FALLBACK);SafeFallback conclusion 恒 null。
|
||||
- **⑤ 话术**:
|
||||
> "release 是唯一发布点:任何对外内容必须经过验证。它按 Draft 形态三分支——受控停止(无草稿)凭 progress 快照发布 INSUFFICIENT_EVIDENCE,且必须有已验真事实否则 fail closed;无结论只验引用按进展降级;有结论走完整链——EvidenceGuard 验引用,失败则 EvidenceRepair 只修引用(prompt 锁死只能改引用字段 + 语义不变性保证用户可见内容不变)再复查,仍失败降级 EVIDENCE_VALIDATION_FAILED;验真通过后 SemanticGuard 判支持度,SUPPORTED 是唯一 SUCCESS 出口,其余降级。所有降级走 SafeFallbackFactory:有界、去重、诚实,保留已验证事实但不发布未证明的根因。"
|
||||
- **追问**:FALLBACK 算成功还是失败?(正交:可 RunState.SUCCESS + FALLBACK,是安全发布结果不是失败)
|
||||
|
||||
### 4.7 application:Run 应用所有者
|
||||
|
||||
- **① 动机**:一次请求从创建到公开结果需要编排:建 Run、路由、执行分支、持久化、SSE 输出。
|
||||
- **② 决策**:ChatApplicationUseCase 六步编排,不做业务判断,不把 HTTP/SSE 细节塞 Core。
|
||||
- **③ 实现**:startRun → 读会话上下文(RoutingHistory + PreviousTurn)→ 路由 → executePath 分支 → completePath + persistFinish;统一失败出口(terminalOutcome + safeFailure);取消句柄(CoreRunControl → core.cancel)。
|
||||
- **④ 边界**:路由只给枚举不执行;预算耗尽的 FALLBACK 不二次 completeSuccess。
|
||||
- **⑤ 话术**:
|
||||
> "application 是 Run 的应用所有者:六步编排——建 Run 边界(core.startRun)、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。路由只回答走哪条分支,分支执行权在 executePath。"
|
||||
- **追问**:多轮记忆怎么实现?(RoutingHistory + PreviousTurn,只传发布后的安全摘要)
|
||||
|
||||
### 4.8 audit:可观测账本(含 trace)
|
||||
|
||||
- **① 动机**:要能回放决策过程,又不永久保存敏感正文——两个目标冲突。
|
||||
- **② 决策**:metadata-only + trace 时序线 + Token 对账账本;audit 是域,trace 是域内子体系。
|
||||
- **③ 实现**:trace 17 种事件按 sequence_no 单调落 diagnosis_trace_event(七阶段:RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段;Token 三写闭环(ledger 分账 → Run 预算 → agent_step 回写);DiagnosisTraceService 三级回放。
|
||||
- **④ 边界**:审计不阻断主流程(fail-safe);正文只在受限审计表;TTL 过期后只能元数据回放。
|
||||
- **⑤ 话术**:
|
||||
> "audit 是可观测账本,trace 是它内部的事件回放子体系。trace 用一张表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账→Run 预算→明细回写)。回放三级:时间线→明细→推理。"
|
||||
- **追问**:audit 和 trace 什么关系?(包含关系:trace 是 audit 域内的时序事件流,audit 还含 ledger/工具审计/推理审计)
|
||||
|
||||
### 4.9 contract:状态流正交
|
||||
|
||||
- **① 动机**:技术停了不等于用户看到失败;查了没查到不等于系统出错——状态语义混在一个枚举里就糊了。
|
||||
- **② 决策**:分层 + 正交 + 显式映射。
|
||||
- **③ 实现**:11 个状态枚举五层(技术 RunState / 证据 InvocationStatus+EvidenceStatus / 收集 StopReason / 发布 ReleaseOutcome+FallbackType / 协议 ChatApplicationStatus+SseOutcome+ChatFailureCode);四个正交轴;纵向映射链。
|
||||
- **④ 边界**:SSE 只有三态(取消连接已断发不出 done);SseOutcome 未接线;FallbackType.BUDGET_EXHAUSTED 命名债务。
|
||||
- **⑤ 话术**:
|
||||
> "状态设计核心是分层正交:RunState(技术终态)和 ReleaseOutcome(用户结局)是正交轴——预算耗尽有安全进展→FALLBACK、没进展→FAILED,同一技术终态诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空仍是成功执行。映射链:CANCELLED→CANCELLED+RUN_CANCELLED,SUPPORTED→SUCCESS 唯一出口;协议层(SSE)复用 ReleaseOutcome 三态拒绝 CANCELLED。"
|
||||
- **追问**:这些状态为什么不合并成一个枚举?(不同层回答不同问题,正交后独立演进、映射显式可审计)
|
||||
|
||||
## 5. 六个易错点(带「为什么」)
|
||||
|
||||
| # | ❌ 不说 | ✅ 应说 | 为什么 |
|
||||
|---|---|---|---|
|
||||
| 1 | Harness 安排 Agent 执行步骤 | ReAct Agent 自己选 Tool 和下一步;Harness 只管边界 | 边界 ≠ 编排:Harness 不替 Agent 决定查什么 |
|
||||
| 2 | SemanticGuard 是第二个诊断 Agent | 无 Tool、无记忆、单轮二值判断的隔离审查器 | 职责 ≠ 角色:它没有探索能力,判完就走 |
|
||||
| 3 | Tool 返回 SUCCESS 就找到证据 | READY 只表示调用完成,还要看 EvidenceStatus | 生命周期 ≠ 证据:调完成功和有没有证据是两回事 |
|
||||
| 4 | FALLBACK 就是 Run 失败 | Fallback 是安全发布结果,可 RunState.SUCCESS + FALLBACK | 技术 ≠ 用户:两个正交维度 |
|
||||
| 5 | Redis 是长期审计库 | canonical 只存当前 Run 短期真相(TTL 2h);长期审计只留元数据 | 短期真相 ≠ 长期审计:可验真 vs 不留敏感副本 |
|
||||
| 6 | 取消能立刻杀死所有模型调用 | 协作式取消:终态防迟到发布,同步 Provider 未必立即停 | 逻辑保护 ≠ 物理强杀:终态定了但线程未必立刻停 |
|
||||
|
||||
## 6. 高频追问应对大全
|
||||
|
||||
| 追问 | 回答主线 |
|
||||
|---|---|
|
||||
| 什么是 Harness?30 秒讲清 | 确定性控制边界:运行/事实/发布三层边界 |
|
||||
| 为什么不用多 Agent? | 四角色加起来是一次 ReAct;推理合并、权力拆分 |
|
||||
| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 |
|
||||
| RunContext 为什么显式传递? | 跨线程异步链 + 并发隔离,ThreadLocal 会丢/串 |
|
||||
| 取消是强杀吗? | 协作式:检查点中止 + first-terminal-wins 防迟到 |
|
||||
| 预算和 Ledger 区别? | Run 资源门禁 vs 审计账本(不同域) |
|
||||
| FALLBACK 算成功还是失败? | 正交:技术终态(RunState)与用户结局(ReleaseOutcome) |
|
||||
| 重试为什么归 Harness? | 盲目重试不可计量;显式可计量 attempt 循环 |
|
||||
| 为什么 Agent/Tool 不重试? | 重试是成本裁决权,执行层只管执行 |
|
||||
| 如何防止 Agent 编造证据? | framework Tool ID + canonical store + EvidenceGuard |
|
||||
| 为什么双闸? | 引用真实(机械)≠ 结论被支持(语义) |
|
||||
| Tool 结果为什么不直接给模型? | 真相/观察/审计三个数据责任冲突 |
|
||||
| Agent 为什么不会无限调 Tool? | 预算止损 + 信息增益收敛双保险 |
|
||||
| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 |
|
||||
| 如何回放决策? | metadata audit + trace 时序线 + 三级回放 |
|
||||
| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、阈值校准 |
|
||||
|
||||
## 7. 支付超时案例(2 分钟完整版)
|
||||
|
||||
> "用户要求诊断支付服务超时。Application 先创建独立 Run,为 Router、Agent、Tool、Guard 共享同一套 deadline、预算和取消能力。Diagnosis Agent 自主调用知识库和日志 Tool;ToolBoundary 执行调用并把完整事实保存为当前 Run 的 canonical invocation,只把有界 Observation 返回给 Agent。Agent 根据两个 Tool 结果生成 Draft,但 Draft 没有直接发给用户。EvidenceGuard 回读 canonical store 后发现引用无法完成真实性校验,因此 Release 没有继续让模型润色或猜测,而是发布 EVIDENCE_VALIDATION_FAILED SafeFallback。最终数据库记录请求处理成功、ReleaseOutcome 为 FALLBACK,SSE 也完整结束,但未经验证的根因没有离开系统。"
|
||||
|
||||
**三个不等于**:Tool READY ≠ 引用已验真 / 引用已验真 ≠ 结论被支持 / Agent 生成 Draft ≠ 报告允许发布。
|
||||
|
||||
## 8. 复习中纠正的认知清单(最容易踩的坑)
|
||||
|
||||
| 错误认知 | 纠正为 |
|
||||
|---|---|
|
||||
| Harness 顶层所以控制 retry/progress | 不只是位置——重试是成本行为必须可计量封顶;progress 解决「预算不能判断价值」 |
|
||||
| RunContext 因为「回调」显式传 | 跨线程异步链 + 并发隔离;显式挂 config metadata |
|
||||
| ToolBoundary 管 token/收敛/重试次数 | 那些归 core/retry/progress;ToolBoundary 只四项(preflight/预算/状态机/审计) |
|
||||
| 工具失败抛异常 | ToolBoundary 转 ERROR 状态 + 错误观察,Agent 可继续换工具;Run 是否失败看 hasObservedFacts |
|
||||
| FALLBACK 可能因超时 | 超时(TIMED_OUT)通常走 FAILED;FALLBACK 前提是有已验证事实 |
|
||||
| agent_result 也存 MySQL | 不存——MySQL 只留 output_preview + output_length;完整 agent_result 在 Redis canonical(2h) |
|
||||
| 注入 skill/知识域给 agent | 注入的是 query + PreviousTurn + 系统 prompt;知识靠工具主动查 |
|
||||
| 非法 draft 由 release 捕捉 | 反序列化在 agent 出口(recoverInvalidDraft);release 只做验证+裁决 |
|
||||
| preflight 失败也落库 | 失败不落库(errorAndNoRecord)——只有 preflight 全过才写 PROJECTING |
|
||||
| 多 Agent 一定不好 | 只有不同数据权限/独立业务目标的角色才值得拆;拆 ReAct 内部步骤只会放大协议成本 |
|
||||
|
||||
## 9. 面试前一天 Checklist
|
||||
|
||||
```text
|
||||
□ 30 秒电梯陈述背熟(§2.1),三个重点不丢(§2.3)
|
||||
□ 默画三张白板图(§3):主链路含分支 / 职责迁移对照 / 数据三层
|
||||
□ 六个易错点扫一遍(§5)——重点看「为什么」列
|
||||
□ 九域话术:挑 3 个最可能被追问的(guard/release/contract)背熟
|
||||
□ 2 分钟支付超时案例 + 三个不等于(§7)
|
||||
□ 过一遍纠正认知清单(§8)——这些是踩过的坑
|
||||
□ 读一遍面试速查 §7 追问表,心里有数
|
||||
```
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| ToolBoundary(preflight/预算/状态机/审计) | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| JpaToolInvocationAuditSink(output_preview 落库) | `.../audit/JpaToolInvocationAuditSink.java` |
|
||||
| DiagnosisAgentUseCase(RunContext 挂 config metadata) | `.../agent/DiagnosisAgentUseCase.java` |
|
||||
| EvidenceGuard / SemanticGuard | `.../guard/evidence/` + `.../guard/semantic/` |
|
||||
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `.../release/` |
|
||||
| DiagnosisProgressProjector / Tracker / Snapshot | `.../progress/` |
|
||||
| ChatApplicationUseCase / IntentRouter | `.../application/` |
|
||||
| DiagnosisTraceService / DiagnosisTraceController | `.../service/` + `.../controller/` |
|
||||
| 状态枚举(RunState/ReleaseOutcome/FallbackType/...) | `.../contract/` + `.../core/RunState.java` |
|
||||
@@ -0,0 +1,280 @@
|
||||
# Harness Tool 双视图:从原始结果到可验证证据
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**主题**:ToolBoundary、Canonical Invocation、Harness Control View 与 Agent Observation
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**上位设计**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
|
||||
|
||||
## 1. 要解决的不是 Tool 调用,而是 Tool 结果的所有权
|
||||
|
||||
Agent 调用 Tool 后,最直接的实现是把 backend 返回的 JSON 原样放进模型上下文。这在 Demo 中可以工作,但进入可验证的诊断系统后会出现一个根本矛盾:同一份结果需要同时服务推理、控制、验真和审计,而这些消费者需要的数据范围完全不同。
|
||||
|
||||
以日志查询为例:
|
||||
|
||||
- Agent 需要少量匹配事件、模式、实际时间范围和是否截断;
|
||||
- Harness 需要返回数量、规范化 scope、重复身份和客观证据状态;
|
||||
- EvidenceGuard 需要证明这份结果确实来自当前 Run 的某次 READY 调用;
|
||||
- 审计需要 Tool 名、调用 ID、状态、耗时和字节数;
|
||||
- 安全边界又要求密码、Token、主机、IP、SQL 字面量和无限日志正文不能进入模型或长期审计。
|
||||
|
||||
如果只保留一份 JSON,只能在两个错误方向中选择:要么信息过多导致泄露和上下文膨胀,要么信息过少导致后续无法验真。
|
||||
|
||||
因此这项设计的核心不是“增加一个 Redis Store”,而是重新定义 Tool 数据所有权:
|
||||
|
||||
> backend raw 属于 Harness;模型只能获得为推理目的生成的有界观察;长期审计只保留允许运营保存的元数据。
|
||||
|
||||
## 2. 三个消费者,三种数据责任
|
||||
|
||||
虽然实现中常称为“Tool 双视图”,完整的数据分层实际上包含三种用途:
|
||||
|
||||
| 数据形态 | 消费者 | 解决的问题 | 生命周期 |
|
||||
|---|---|---|---|
|
||||
| Canonical Invocation | ToolBoundary、EvidenceGuard、ProgressProjector | 这次 Tool 真实执行了什么、属于哪个 Run、能否引用 | Redis 短 TTL |
|
||||
| Harness Control View / Agent Observation | Harness / Diagnosis Agent | 是否重复、是否为空、模型下一步需要看到什么 | 当前 Run / 模型上下文 |
|
||||
| Durable Audit | Trace、运营排障 | 何时调用、状态、耗时、大小、关联 Step | MySQL 长期 metadata |
|
||||
|
||||
所谓“双视图”,特指 canonical 标准结果被进一步分成:
|
||||
|
||||
1. Harness 使用的 Control View;
|
||||
2. 模型使用的 Agent Observation。
|
||||
|
||||
Durable Audit 是第三个持久化面,但它不是 Tool 内容视图,不参与推理或证据验真。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Typed Tool Request"] --> B["ToolBoundary"]
|
||||
B --> C["Backend Raw Response"]
|
||||
C --> D["Tool-specific Projector"]
|
||||
D --> E["Canonical agent_result"]
|
||||
|
||||
B --> F["Redis Canonical Invocation<br/>request + raw + agent_result"]
|
||||
E --> F
|
||||
|
||||
E --> G["Harness Control View<br/>count / status / scope"]
|
||||
E --> H["Agent Observation<br/>白名单、有界、脱敏"]
|
||||
|
||||
B --> I["Durable Audit<br/>identity / status / latency / bytes"]
|
||||
G --> J["重复检测、NO_GAIN、停止"]
|
||||
H --> K["Diagnosis Agent Context"]
|
||||
F --> L["EvidenceGuard / ProgressSnapshot"]
|
||||
```
|
||||
|
||||
## 3. 为什么不能让 Agent Observation 充当真相源
|
||||
|
||||
Agent Observation 是为了控制上下文而生成的投影,它可能:
|
||||
|
||||
- 只保留前 N 条结果;
|
||||
- 截断单条文本;
|
||||
- 对敏感列和日志字段做脱敏;
|
||||
- 把大量事件聚合成模式;
|
||||
- 只暴露粗粒度相关度,不暴露检索轨迹和原始分数。
|
||||
|
||||
这意味着它适合帮助模型推理,却不适合作为“后台真实返回”的完整证明。如果 EvidenceGuard 反过来验证 Agent 自己收到的 observation,就相当于用被审查对象提供的摘要证明其自身真实性。
|
||||
|
||||
Canonical Invocation 解决的正是这个独立性问题。它以 exact `runId + tool_call_id` 建立记录,并保存:
|
||||
|
||||
```text
|
||||
tool_call_id
|
||||
run_id
|
||||
tool_name
|
||||
request
|
||||
raw_response
|
||||
agent_result
|
||||
invocation status
|
||||
evidence status
|
||||
error code
|
||||
started_at / completed_at
|
||||
```
|
||||
|
||||
EvidenceGuard 不信任 Draft 中的引用,也不从模型历史反推 Tool 结果,而是重新按当前 Run 构造 key,读取 canonical record 并验证状态和投影内容。
|
||||
|
||||
## 4. ToolBoundary 为什么必须是统一入口
|
||||
|
||||
RAG、日志和 MySQL 的业务执行方式不同,但以下控制规则完全相同:
|
||||
|
||||
- Run 必须仍然 active;
|
||||
- envelope 的 runId 必须等于当前 Run;
|
||||
- `tool_call_id` 必须合法且不能重复;
|
||||
- Tool 必须已授权并声明只读;
|
||||
- 调用前必须预占 Tool 预算和 request bytes;
|
||||
- raw response 和 agent result 必须分别检查容量;
|
||||
- canonical 状态只能按合法路径迁移;
|
||||
- 对 Agent 只返回稳定错误码;
|
||||
- durable audit 失败不能改变已经得到的 Tool 结果。
|
||||
|
||||
如果把这些逻辑复制到三个 Adapter,任何新增 Tool 都可能漏掉其中一项。因此统一由 `ToolBoundary` 编排一次调用,具体 Adapter 只负责 typed request、backend 和 projector 的连接。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as Tool Interceptor
|
||||
participant B as ToolBoundary
|
||||
participant C as Harness Core
|
||||
participant S as Canonical Store
|
||||
participant T as Backend
|
||||
participant P as Projector
|
||||
participant A as Audit Sink
|
||||
|
||||
I->>B: RunContext + ToolCallRequestEnvelope
|
||||
B->>B: run / id / authorization / readonly / JSON preflight
|
||||
B->>C: reserve Tool call + request bytes
|
||||
B->>S: begin PROJECTING
|
||||
B->>T: execute typed request
|
||||
T-->>B: raw response
|
||||
B->>C: reserve raw bytes
|
||||
B->>P: project raw response
|
||||
P-->>B: bounded agent_result + evidence_status
|
||||
B->>C: reserve projection bytes
|
||||
B->>S: mark READY
|
||||
B-->>I: ToolBoundaryResult
|
||||
B-->>A: best-effort metadata audit
|
||||
```
|
||||
|
||||
错误发生时,已经创建的 canonical record 会尽力迁移到 ERROR;如果连 Store 都不可用,则向上只返回 `STORE_ERROR` 等稳定码,不把 Redis 或 backend 异常正文交给模型。
|
||||
|
||||
## 5. 两套状态为什么不能合并
|
||||
|
||||
Tool 调用同时有两个正交维度:
|
||||
|
||||
| 维度 | 状态 | 回答的问题 |
|
||||
|---|---|---|
|
||||
| Invocation lifecycle | `PROJECTING / READY / ERROR` | 这次调用是否完成并形成了可引用记录 |
|
||||
| Evidence semantics | `EVIDENCE_FOUND / NO_EVIDENCE / ERROR` | 客观结果中是否存在候选证据 |
|
||||
|
||||
例如日志查询成功返回 0 条:
|
||||
|
||||
```text
|
||||
InvocationStatus = READY
|
||||
EvidenceStatus = NO_EVIDENCE
|
||||
```
|
||||
|
||||
它不是 `ERROR`。这条负向观察可以支持“在指定时间、服务和查询条件下没有匹配日志”,但不能支持“故障不存在”。
|
||||
|
||||
合法组合被类型约束为:
|
||||
|
||||
| InvocationStatus | EvidenceStatus | 是否允许引用 |
|
||||
|---|---|---:|
|
||||
| `PROJECTING` | `null` | 否 |
|
||||
| `READY` | `EVIDENCE_FOUND` | 是 |
|
||||
| `READY` | `NO_EVIDENCE` | 是,但只能作为限定范围负向观察 |
|
||||
| `ERROR` | `ERROR` | 否 |
|
||||
|
||||
把两者合成一个 `SUCCESS / FAILED` 会丢失最重要的信息:技术成功但业务范围内没有证据。
|
||||
|
||||
## 6. 三类 Tool 如何投影
|
||||
|
||||
### 6.1 RAG
|
||||
|
||||
`RagResultProjector` 从 backend 结果中提取有界 evidence block,稳定文档身份,限制 excerpt 数量和长度,并兼容 `relevanceLevel / relevance_level` 后统一为 `relevance_level`。
|
||||
|
||||
这里有一个刻意保留的区分:
|
||||
|
||||
```text
|
||||
evidence 非空 -> EVIDENCE_FOUND
|
||||
relevance_level=REFERENCE -> 相关度一般
|
||||
```
|
||||
|
||||
`EVIDENCE_FOUND` 只说明存在候选内容,`REFERENCE` 也不自动表示 `NO_GAIN`。内容是否推进当前诊断假设,需要模型结合上下文判断。
|
||||
|
||||
### 6.2 Logs
|
||||
|
||||
`QueryLogsResultProjector` 不只是截取前几条日志,它还会:
|
||||
|
||||
- 脱敏 password、token、secret、API key;
|
||||
- 脱敏 pod、host、PID、IP 和 SQL literal;
|
||||
- 去掉堆栈尾部噪声;
|
||||
- 对数字做模式归一化并聚合重复事件;
|
||||
- 对事件做跨范围采样,而不是只保留开头;
|
||||
- 保存实际时间、topic、query scope 和截断标记。
|
||||
|
||||
如果 backend 执行成功且日志数组为空,投影结果是 READY + NO_EVIDENCE。backend 明确返回失败,才是 Tool 执行或投影错误。
|
||||
|
||||
### 6.3 MySQL
|
||||
|
||||
MySQL 在投影之前还有独立的只读安全链:`MysqlSqlValidator` 根据逻辑数据源、schema、table 和 column allowlist 生成 `MysqlQueryPlan`,`JdbcMysqlReadOnlyExecutor` 只执行该 Plan。
|
||||
|
||||
`MysqlResultProjector` 再负责:
|
||||
|
||||
- 限制最大行数、单元格长度和总 bytes;
|
||||
- 保持 number、boolean 和 null 类型;
|
||||
- 对 password、token、secret、credential 等敏感列强制脱敏;
|
||||
- 结果缩减时标记 `truncated=true`。
|
||||
|
||||
Validator 负责“能不能执行”,Projector 负责“模型能看到什么”,两者不能合并。
|
||||
|
||||
## 7. Control View 与 Model Observation 的字段边界
|
||||
|
||||
| 字段 | Harness | Agent | 原因 |
|
||||
|---|---:|---:|---|
|
||||
| `tool_call_id` | 是 | 是 | 引用和上一轮评价都需要 |
|
||||
| 实际 scope | 是 | 是 | Harness 去重;模型理解负向观察边界 |
|
||||
| 有界 evidence/events/rows | 是 | 是 | 模型推理所需事实 |
|
||||
| `evidence_status` | 是 | 是 | 区分候选证据、空结果和错误 |
|
||||
| `relevance_level` | 是 | 是 | 给模型粗粒度检索语义 |
|
||||
| `truncated` | 是 | 是 | 防止模型误以为结果完整 |
|
||||
| `returned_count` | 是 | 否 | Harness 统计,不必消耗模型上下文 |
|
||||
| normalized scope / duplicate identity | 是 | 否 | 内部控制实现 |
|
||||
| 连续 NO_GAIN、阈值和剩余预算 | 是 | 否 | 防止模型围绕限制博弈 |
|
||||
| raw response / 检索轨迹 / 原始分数 | 是 | 否 | 敏感且体积不可控 |
|
||||
|
||||
只有 Harness 必须改变模型行为时,才注入 `STOP_REQUIRED`、错误码或已检查范围等有限控制信息。
|
||||
|
||||
## 8. 存储决策:为什么是 Redis canonical + MySQL metadata
|
||||
|
||||
### 8.1 不把完整 raw 长期写 MySQL
|
||||
|
||||
完整 Tool 结果可能包含日志、SQL 查询结果和内部知识内容。长期保存会扩大泄露半径,也会让 JPA audit 成为第二个事实源。MySQL 只保存运营和对账需要的字段,可以长时间保留而不复制正文。
|
||||
|
||||
### 8.2 Canonical 读取不续期
|
||||
|
||||
Redis record 在创建时设置 TTL,读取或状态更新不恢复初始 TTL。原因是:如果一次历史查询就能续期,敏感 raw 可能因审计访问而永久存在。
|
||||
|
||||
状态更新保留当前剩余 TTL,代价是 read-TTL-write 存在小的并发窗口,但它比无界续期更符合数据治理目标。
|
||||
|
||||
### 8.3 raw 超限为什么不截断
|
||||
|
||||
raw 是内部事实。如果静默截断后仍标记 READY,系统无法区分“backend 只返回这些内容”和“Harness 丢掉了内容”。因此 raw 或完整 record 超限时调用进入 ERROR。
|
||||
|
||||
Agent projection 可以截断,因为它本来就是面向消费的摘要,但必须通过 `truncated=true` 明示不完整。
|
||||
|
||||
## 9. 真实问题如何改变设计
|
||||
|
||||
| 真实问题 | 暴露的错误假设 | 最终修正 |
|
||||
|---|---|---|
|
||||
| Tool raw 直接进入 Agent | backend 输出天然适合模型消费 | 增加 Tool-specific projector 和白名单 observation |
|
||||
| Trace、raw、ContextPack、重复正文同时存在 | 多保存几份可以提高可观测性 | canonical、model view、metadata audit 明确分层 |
|
||||
| Mock 日志 0 命中返回 `success=false` | 没数据等于调用失败 | 技术执行状态与 NO_EVIDENCE 分离 |
|
||||
| RAG 的 `REFERENCE` 在投影中丢失 | 只要 evidence 非空就够了 | 保留 relevance_level,但不提升为根因证据 |
|
||||
| 生产 ObjectMapper 未注册 Java Time 模块,Redis 全部 STORE_ERROR | 单元测试序列化环境等同生产 | 使用生产装配验证 canonical record,并增加 live E2E |
|
||||
| backend 日志打印 raw/rewritten query | 排障信息可以直接进入普通日志 | 普通日志和 durable audit 只保留安全摘要 |
|
||||
|
||||
## 10. 代价与边界
|
||||
|
||||
这项设计不是免费的:
|
||||
|
||||
1. 每增加一种 Tool,都要同时定义 typed request/result、Adapter、Projector 和 EvidenceGuard 读取规则。
|
||||
2. Redis 在 TTL 内成为证据校验依赖;不可用时系统应 fail closed,而不是相信 Draft。
|
||||
3. Agent 看到的是有损投影,Projector 设计不当可能丢掉模型真正需要的诊断信号。
|
||||
4. durable audit 不能替代 canonical replay;TTL 到期后只能解释调用元数据,无法恢复完整正文。
|
||||
|
||||
但这些成本换来了明确的责任:Backend 决定原始事实,Projector 决定模型可见范围,Canonical Store 决定短期验真事实,Audit 决定长期允许保存什么。
|
||||
|
||||
## 11. 如何验证
|
||||
|
||||
| 验证内容 | 代表性测试或证据 |
|
||||
|---|---|
|
||||
| Run mismatch、未授权、非只读、重复 ID、预算和超限 | `ToolBoundaryTest` |
|
||||
| PROJECTING / READY / ERROR 和 TTL 行为 | `CanonicalInvocationStoreTest` |
|
||||
| RAG evidence identity、relevance 和 bytes | `RagResultProjectorTest` |
|
||||
| 日志脱敏、模式聚合、空结果和采样 | `QueryLogsResultProjectorTest` |
|
||||
| MySQL 行列/敏感字段/容量边界 | `MysqlResultProjectorTest` |
|
||||
| typed Tool contract 不漂移 | 三类 `*ToolContractTest` |
|
||||
| 生产 Redis serializer 与 ObjectMapper | single-react cleanup live E2E |
|
||||
|
||||
单元测试只能证明数据变换和状态机;生产 Redis serializer、实际 Tool Calling ID 和 backend 返回格式仍必须通过 live E2E 验证。
|
||||
|
||||
## 12. 与后续机制的关系
|
||||
|
||||
Tool 双视图只解决“什么是可验证的 Tool 事实、模型允许看到什么”,并不解决:
|
||||
|
||||
- 这些事实是否支持最终结论:见[Harness证据安全链-从引用真实到结论可发布.md](Harness证据安全链-从引用真实到结论可发布.md);
|
||||
- Agent 是否应该继续查询:见[Harness信息增益停止-让无证据诊断正常收敛.md](Harness信息增益停止-让无证据诊断正常收敛.md)。
|
||||
@@ -0,0 +1,302 @@
|
||||
# Harness 信息增益停止:让无证据诊断正常收敛
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**主题**:Information Gain、Progress Tracker、STOP_REQUIRED 与过程型 Fallback
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**上位设计**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
|
||||
|
||||
## 1. 预算能限制损失,不能判断何时完成
|
||||
|
||||
在知识库只有通用说明、日志持续为空或查询条件不足时,ReAct Agent 容易出现一种看似合理的行为:不断修改关键词、时间范围或查询表达,再调用一次 Tool。
|
||||
|
||||
每一次调用单独看都可能合法,但整个 Run 没有获得新信息。旧系统只能等到模型次数、Tool 次数、Token 或 deadline 耗尽,再以 `BUDGET_EXHAUSTED` 或 `INTERNAL_FAILURE` 结束。
|
||||
|
||||
这暴露了两个被混淆的问题:
|
||||
|
||||
```text
|
||||
资源预算:这次 Run 最多允许消耗多少?
|
||||
业务收敛:继续查询是否仍可能推进当前诊断?
|
||||
```
|
||||
|
||||
预算是最后一道资源保护,不能承担正常停止策略。否则“当前证据不足”会被错误表达成“系统执行失败”。
|
||||
|
||||
信息增益停止契约的目标是:在硬预算之前识别连续无进展,使 Agent 停止调用 Tool,并把已经完成的有限检查发布为诚实、可验证的业务结果。
|
||||
|
||||
## 2. 先划清三方判断权
|
||||
|
||||
设计停止机制时最危险的做法,是让某一层承担它无法可靠完成的判断。
|
||||
|
||||
| 参与者 | 能可靠判断什么 | 不能判断什么 |
|
||||
|---|---|---|
|
||||
| Tool / Projector | 是否执行成功、结果是否为空、返回数量、实际 scope、是否截断 | 内容是否支持当前诊断假设 |
|
||||
| Diagnosis Agent | 非空内容是否确认、排除或缩小当前假设 | 是否还能绕过预算、重复和饱和门禁 |
|
||||
| Harness | scope 是否重复、连续 NO_GAIN、协议是否合规、是否允许继续 | 业务根因是什么 |
|
||||
| Release | 已有进展可以发布成哪种安全结果 | 是否应该重新调用 Tool |
|
||||
|
||||
最终原则是:
|
||||
|
||||
> Tool 提供客观结果,模型判断语义价值,Harness 拥有最终停止权。
|
||||
|
||||
模型可以选择主动结束,但不能通过继续发 Tool Call 绕过 Harness 已经确定的饱和或预算状态。
|
||||
|
||||
## 3. 为什么信息增益只有两个值
|
||||
|
||||
当前契约只保留:
|
||||
|
||||
```text
|
||||
information_gain = GAINED | NO_GAIN
|
||||
```
|
||||
|
||||
没有 `HIGH / MEDIUM / LOW`,也没有置信分数。停止控制只需要知道结果是否推进了当前诊断;引入更多等级会带来阈值解释、跨 Tool 标定和模型输出不稳定,却不一定改变最终动作。
|
||||
|
||||
判定标准是:
|
||||
|
||||
| 结果 | Information Gain | 生产者 |
|
||||
|---|---|---|
|
||||
| Tool 执行失败 | 不产生 | 进入技术失败流程 |
|
||||
| `evidence_status=NO_EVIDENCE` | `NO_GAIN` | Harness |
|
||||
| 相同 `tool_name + normalized_scope` | `NO_GAIN` | Harness,并拒绝重复执行 |
|
||||
| 成功非空,确认/排除/缩小假设 | `GAINED` | Diagnosis Agent |
|
||||
| 成功非空,但只是通用知识、重复内容或无关事实 | `NO_GAIN` | Diagnosis Agent |
|
||||
|
||||
RAG 的 `REFERENCE` 不能自动映射为 `NO_GAIN`。它只表示检索相关度一般;一段一般相关的资料可能仍排除一个假设,也可能完全无用,需要模型结合诊断上下文判断。
|
||||
|
||||
## 4. 为什么模型在下一次 Tool Call 中评价上一轮
|
||||
|
||||
模型只有在读到 Tool Observation 后,才能判断它是否有语义增益。但如果要求模型单独输出一条 progress 消息,就必须增加新的协议轮次或 Progress Judge。
|
||||
|
||||
当前设计利用模型已经要做的下一步行为:
|
||||
|
||||
- 输出 DiagnosisDraft,表示主动结束;
|
||||
- 发起下一次 Tool Call,表示希望继续。
|
||||
|
||||
当模型选择继续时,下一次 Tool Call Envelope 必须携带对上一轮的评价:
|
||||
|
||||
```json
|
||||
{
|
||||
"previous_observation": {
|
||||
"tool_call_id": "call-123",
|
||||
"information_gain": "NO_GAIN"
|
||||
},
|
||||
"input": {
|
||||
"query": "新的业务查询参数"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Interceptor 在调用业务 Tool 之前完成三件事:
|
||||
|
||||
1. 校验 `previous_observation.tool_call_id` 是否正好是当前 pending 调用;
|
||||
2. 应用 `GAINED / NO_GAIN`,更新连续计数和收集状态;
|
||||
3. 剥离 `previous_observation`,只把 `input` 传给原业务 Tool。
|
||||
|
||||
这是 Agent-facing Tool Schema 的协议变化,但 RAG、日志和 MySQL 的业务 request 并没有被控制字段污染。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as Diagnosis Agent
|
||||
participant I as Tool Interceptor
|
||||
participant P as Progress Tracker
|
||||
participant T as Business Tool
|
||||
|
||||
M->>I: Tool Call #1 + input
|
||||
I->>P: no pending observation
|
||||
I->>T: business input
|
||||
T-->>I: successful non-empty observation
|
||||
I->>P: mark call #1 pending evaluation
|
||||
I-->>M: bounded observation
|
||||
|
||||
M->>I: Tool Call #2 + previous_observation(#1, NO_GAIN)
|
||||
I->>P: validate ID and apply NO_GAIN
|
||||
alt 仍为 COLLECTING
|
||||
I->>T: strip control fields, execute input #2
|
||||
else 达到 SATURATED
|
||||
I-->>M: STOP_REQUIRED
|
||||
end
|
||||
```
|
||||
|
||||
如果模型读完 observation 后直接输出 Draft,就不需要额外评价最后一轮。因为它已经通过实际行为表达“停止”,Harness 也不需要为收集一个统计字段强迫模型再调用 Tool。
|
||||
|
||||
## 5. ProgressTracker 保存什么,不保存什么
|
||||
|
||||
`DiagnosisProgressTracker` 是 `RunContext` 中的线程安全状态句柄,保存:
|
||||
|
||||
- 已完成的 `tool_name + normalized_scope`;
|
||||
- 已完成 Tool Call identity;
|
||||
- 当前等待模型评价的 `pendingToolCallId`;
|
||||
- 连续 `NO_GAIN` 次数;
|
||||
- 连续 progress protocol violation 次数;
|
||||
- `COLLECTING / SATURATED` 状态;
|
||||
- stop reason;
|
||||
- STOP_REQUIRED 是否已经交付。
|
||||
|
||||
它不保存 request、raw response、agent result 或模型 thought。完整 Tool 事实仍属于 Canonical Store。Tracker 只保存作出停止决策需要的最小 identity 和计数,避免出现第二份 Tool 真相。
|
||||
|
||||
结束时,`DiagnosisProgressProjector` 根据 completed identity 回读 canonical READY records,投影成有界 `ProgressSnapshot`。无法验证、不可读取或格式非法的记录不会被发布,只会形成安全 limitation。
|
||||
|
||||
## 6. 重复检测为什么只做参数级
|
||||
|
||||
Harness 使用 `ToolScopeNormalizer` 将业务输入转换成稳定 scope,再比较:
|
||||
|
||||
```text
|
||||
tool_name + normalized_scope
|
||||
```
|
||||
|
||||
这可以识别字段顺序、格式差异下的完全相同查询,并在调用 backend 前拒绝重复执行。
|
||||
|
||||
首版没有做自然语言语义去重,例如以下两条 query 可能语义相同,但不会被代码证明为同一 scope:
|
||||
|
||||
```text
|
||||
“查询支付超时日志”
|
||||
“查找支付请求 timeout 记录”
|
||||
```
|
||||
|
||||
原因是语义去重需要 embedding、模型判断或跨 Tool 指纹,会引入新的不确定性和误杀风险。当前边界选择了可以确定性证明的参数重复;语义近似由模型的 `NO_GAIN` 义务约束。
|
||||
|
||||
## 7. 收集状态机
|
||||
|
||||
连续无增益阈值由配置控制,当前默认值为 2。`GAINED` 会清零连续计数,避免一次早期空查使后续有效取证被过早停止。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
|
||||
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
|
||||
COLLECTING --> SATURATED: NO_GAIN / 达到阈值
|
||||
SATURATED --> STOP_CHANCE: 交付一次 STOP_REQUIRED
|
||||
STOP_CHANCE --> RELEASE: 模型输出 Draft
|
||||
STOP_CHANCE --> TERMINATED: 模型再次请求 Tool
|
||||
```
|
||||
|
||||
当某次 READY 结果是 NO_EVIDENCE 时,Harness 可以立即累计 `NO_GAIN`。当成功非空结果需要模型评价时,Tracker 设置 pending ID;上一轮未评价前,新的 Tool Call 不会被执行。
|
||||
|
||||
达到 `SATURATED` 后,Harness 给模型一次合法完成机会,而不是在 Tool response 中立刻抛出通用错误。STOP_REQUIRED 只交付一次;模型仍继续请求 Tool 时,`DiagnosisCollectionStoppedException` 将受控停止穿出框架 ReAct loop。
|
||||
|
||||
## 8. 协议错误为什么不能计为 NO_GAIN
|
||||
|
||||
真实 E2E 曾出现 9 次 `INVALID_PROGRESS_PROTOCOL` Tool 请求拒绝和 13 轮 Diagnosis Agent 模型调用。模型没有正确回传上一轮评价,但这些拒绝也没有增加 NO_GAIN,最终持续空转。
|
||||
|
||||
一种看似简单的修复是把协议错误也计为 NO_GAIN。但这会混淆两个事实:
|
||||
|
||||
- `NO_GAIN` 表示 Tool 结果没有推进诊断;
|
||||
- 协议错误表示模型没有遵守控制契约,Tool 根本没有执行。
|
||||
|
||||
因此引入独立的协议错误计数和 `PROGRESS_PROTOCOL_VIOLATED`:
|
||||
|
||||
1. 第一次错误返回可修正 observation,包含 violation type、缺失字段、期望的上一轮 ID 和允许值;
|
||||
2. 连续错误达到配置阈值后进入 SATURATED;
|
||||
3. 交付一次 `STOP_REQUIRED / PROGRESS_PROTOCOL_VIOLATED`;
|
||||
4. 再次请求 Tool 时受控停止。
|
||||
|
||||
协议错误 Trace 使用 `TOOL_REQUEST_REJECTED`,不能记录成 `TOOL_INVOCATION`,因为 backend 从未被调用,Tool 预算和实际执行数也不应被污染。
|
||||
|
||||
## 9. 三种停止原因必须分开
|
||||
|
||||
| Stop Reason | 含义 | 是否等于技术失败 |
|
||||
|---|---|---:|
|
||||
| `INFORMATION_SATURATED` | 连续结果没有推进诊断 | 否 |
|
||||
| `BUDGET_LIMIT_REACHED` | 达到模型、Tool、Token 或 bytes 预算边界 | 不一定;有安全进展时可发布过程 Fallback |
|
||||
| `PROGRESS_PROTOCOL_VIOLATED` | 模型连续违反 progress Envelope | 协议失败;有安全进展时仍可保留过程价值 |
|
||||
|
||||
信息饱和不能伪装成预算耗尽,否则无法判断阈值是否合理;预算耗尽也不能伪装成信息饱和,因为可能是在持续获得有效证据时资源不足。
|
||||
|
||||
这些 stop reason 是 Harness 内部控制语义,不直接作为第二套公开生命周期。最终仍由 Release 映射为 `SUCCESS / FALLBACK / FAILED / CANCELLED`。
|
||||
|
||||
## 10. 停止以后如何形成用户结果
|
||||
|
||||
停止本身不是答案。系统需要把已经完成的检查转换成可发布内容,又不能依赖预算耗尽后额外调用模型。
|
||||
|
||||
`DiagnosisProgressProjector` 从 canonical READY results 生成:
|
||||
|
||||
- verified sources;
|
||||
- observed facts,包括限定范围的空结果;
|
||||
- 实际查询 scope;
|
||||
- 投影失败、截断或不可读取形成的 limitations;
|
||||
- stop reason。
|
||||
|
||||
Release 根据现有进展决定:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S["Agent 主动无结论<br/>或 Harness 受控停止"] --> P["ProgressSnapshot"]
|
||||
P --> Q{"存在已验真 observed facts?"}
|
||||
Q -->|"是"| I["INSUFFICIENT_EVIDENCE<br/>展示已检查内容和下一步"]
|
||||
Q -->|"否"| M{"Draft 声明 missing_info?"}
|
||||
M -->|"是"| C["MISSING_REQUIRED_CONTEXT"]
|
||||
M -->|"否"| F["FAILED / fail closed"]
|
||||
```
|
||||
|
||||
`conclusion=null` 是合法 Draft。它允许 Agent 在缺少企业、时间、服务或错误信息时零次调用 Tool,直接报告 `missing_info`,避免为了表现“已经排查”而执行无明确范围的查询。
|
||||
|
||||
## 11. 为什么没有引入更多控制字段
|
||||
|
||||
### 11.1 不使用 `new_count`
|
||||
|
||||
“新记录数量”需要跨 RAG 文档、日志事件和数据库行建立稳定指纹,而且新数据不等于对当前假设有用。它增加了复杂度,却不能替代语义增益判断。
|
||||
|
||||
### 11.2 不使用 `next_action`
|
||||
|
||||
模型发起 Tool Call已经表示继续,输出 Draft 已经表示结束。再要求 `CONTINUE / STOP` 只会形成一套可能与实际行为冲突的声明状态。
|
||||
|
||||
### 11.3 不增加 Progress Judge
|
||||
|
||||
独立 Judge 会为每轮 Tool 结果增加模型调用、延迟和失败面。空结果和完全重复 scope 本可由代码判断;其他内容由正在做诊断的 Agent 评价即可。
|
||||
|
||||
### 11.4 不向模型公开剩余预算和阈值
|
||||
|
||||
模型只需要知道是否必须停止,不需要围绕“还剩几次”规划消耗。阈值、计数和预算属于 Harness Control View,只有 STOP_REQUIRED 等必要指令进入模型上下文。
|
||||
|
||||
## 12. Prompt 与硬门禁如何分工
|
||||
|
||||
Prompt 仍然需要告诉模型:
|
||||
|
||||
- 不必须得出根因;
|
||||
- `conclusion=null` 是合法完成;
|
||||
- 缺少必要上下文时可以零 Tool 结束;
|
||||
- 正确但不能推进假设的内容也是 NO_GAIN;
|
||||
- 不要通过改写相似关键词重复查询;
|
||||
- 收到 STOP_REQUIRED 后必须停止。
|
||||
|
||||
但 Prompt 只是帮助模型做出正确选择,不构成系统保证。重复 scope、pending evaluation、饱和状态、预算和一次性 STOP_REQUIRED 都由代码门禁执行。
|
||||
|
||||
## 13. 真实问题如何改变设计
|
||||
|
||||
| 真实现象 | 被证伪的假设 | 设计修正 |
|
||||
|---|---|---|
|
||||
| 空日志、通用知识仍不断改写查询 | 模型会自然意识到没有进展 | 明确信息增益义务和 Harness 饱和状态 |
|
||||
| 最终以 BUDGET_EXHAUSTED 结束 | 硬预算可以充当正常停止 | 预算与信息饱和分离 |
|
||||
| `REFERENCE` 非空结果持续触发查询 | 非空候选就是有价值证据 | 检索相关度与诊断增益分离 |
|
||||
| 相同 scope 被反复执行 | Prompt 足以禁止重复 | Harness 参数级去重并在 backend 前拒绝 |
|
||||
| 9 次协议拒绝仍消耗 13 轮模型 | 错误 observation 会让模型自修复 | 可修正反馈 + 独立协议阈值 + STOP_REQUIRED |
|
||||
| 非法 Draft 使已完成检查丢失 | 只有合法最终 Draft 才有用户价值 | 已验真 ProgressSnapshot 可形成过程型 Fallback |
|
||||
| 缺少企业/时间仍被迫调用 Tool | Tool 调用次数大于零才算诊断 | 允许零 Tool、missing context 合法结束 |
|
||||
|
||||
## 14. 代价与当前边界
|
||||
|
||||
1. 模型侧 Tool Schema 增加了 `previous_observation + input`,这是明确的 Agent-facing 协议变化。
|
||||
2. 首版只能确定性识别参数相同的重复 scope,不能阻止所有自然语言近义改写。
|
||||
3. 默认连续 NO_GAIN 阈值 2 是工程起点,需要依靠固定评测集校准;太小会过早停止,太大会增加空转。
|
||||
4. 最后一轮非空 Tool 结果如果模型直接输出 Draft,Tracker 不强制收集其 information gain;这是减少无意义协议轮次的主动取舍。
|
||||
5. ProgressSnapshot 依赖 canonical record 仍在 TTL 内且可解析,无法验真的进展不会被发布。
|
||||
6. 受控预算停止只有在已有安全进展时才能转为 Fallback;没有可验证内容仍然 fail closed。
|
||||
|
||||
## 15. 如何验证
|
||||
|
||||
| 需要证明 | 代表性测试或 E2E |
|
||||
|---|---|
|
||||
| NO_EVIDENCE 自动累计 NO_GAIN,GAINED 清零 | `DiagnosisProgressTrackerTest` |
|
||||
| 重复 scope 在 backend 前被拒绝 | `HarnessToolInterceptorTest`、`ToolScopeNormalizerTest` |
|
||||
| pending evaluation 的缺失、乱序和意外回传被拒绝 | Interceptor protocol focused cases |
|
||||
| 连续协议错误达到阈值并只交付一次 STOP_REQUIRED | Tracker + Interceptor tests |
|
||||
| 模型主动停止、饱和停止和预算停止都能进入 Release | `DiagnosisAgentUseCaseTest`、`DiagnosisReleaseUseCaseTest` |
|
||||
| 非法 Draft 只有在存在安全进展时降级 | `DiagnosisChatExecutorTest` |
|
||||
| Tool 拒绝与实际 Tool 执行分开审计 | exact-run Trace |
|
||||
| 未知 Query 不再以通用内部错误结束 | ISS-016 named SSE E2E |
|
||||
|
||||
其中一条 live E2E 曾准确暴露“协议拒绝不累计 NO_GAIN”的盲区。这说明停止机制不能只验证最终 SSE,还要核对模型轮次、Tool 实际执行数、Tool 拒绝数、Token 和 Timeline 序列。
|
||||
|
||||
## 16. 与另外两项设计的关系
|
||||
|
||||
信息增益依赖 [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)提供客观 evidence status、scope 和有界 observation;停止后的 ProgressSnapshot 和最终 Fallback 依赖[证据安全链](Harness证据安全链-从引用真实到结论可发布.md)确保只发布当前 Run 可验证的事实。
|
||||
|
||||
三者组合后,Harness 才能完整回答:模型看到了什么、为什么继续或停止、最终哪些内容可以发布。
|
||||
@@ -0,0 +1,535 @@
|
||||
# Harness 失败图谱:异常、停止、降级与终态如何对应
|
||||
|
||||
**更新日期**:2026-07-30
|
||||
|
||||
**主题**:失败分类、停止决策、安全降级、Run 终态与发布结果
|
||||
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
|
||||
在 Harness 中,“没有找到根因”“Tool 报错”“证据不够”“超时”和“客户端断开”不是同一种失败。如果把它们都压成一个 `FAILED`,用户无法知道系统是否完成了有效检查,开发者也无法判断应该重试、换 Tool、发布 Fallback,还是立即终止。
|
||||
|
||||
这篇文章不从状态枚举开始,而是用三个问题建立一张失败图谱:
|
||||
|
||||
1. 问题发生后,这次 Run 还能继续吗?
|
||||
2. 已经产生的内容中,有没有可验证的安全事实?
|
||||
3. 最终能公开正常报告、安全说明,还是只能失败?
|
||||
|
||||
第一次阅读只看第 1、2、8、10 和 13 节即可。先掌握判断方法和几个典型场景,不需要记住全部状态。
|
||||
|
||||
## 1. 先记住:Harness 的失败处理不是“捕获异常”
|
||||
|
||||
假设一次支付超时诊断中连续发生这些事情:
|
||||
|
||||
```text
|
||||
日志 Tool 查询成功,但指定时间段没有记录
|
||||
RAG Tool 第一次调用网络超时
|
||||
Agent 换了一个查询范围,找到一条可验证的配置事实
|
||||
Agent 据此声称“数据库连接池耗尽”
|
||||
EvidenceGuard 发现报告引用的证据并不支持这个结论
|
||||
```
|
||||
|
||||
如果只看局部,既出现了空结果、技术异常、有效事实,又出现了发布门禁失败。系统不能把其中任何一个事件直接等同于整次 Run 的终态。
|
||||
|
||||
Harness 真正要做的是分层决策:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
X["某个问题发生"] --> Q1{"Run 是否仍可继续?"}
|
||||
Q1 -->|"可以"| C["继续诊断或改查其他 Tool"]
|
||||
Q1 -->|"不可以"| Q2{"是否已有可验证的安全进展?"}
|
||||
C --> Q3{"最终报告能否通过发布门禁?"}
|
||||
Q3 -->|"可以"| S["ReleaseOutcome.SUCCESS<br/>发布诊断报告"]
|
||||
Q3 -->|"不可以,但有安全说明"| F["ReleaseOutcome.FALLBACK<br/>发布 SafeFallback"]
|
||||
Q3 -->|"没有任何安全内容"| E["ReleaseOutcome.FAILED<br/>发布 failure"]
|
||||
Q2 -->|"有"| F
|
||||
Q2 -->|"无"| E
|
||||
```
|
||||
|
||||
这张图背后的核心决策是:
|
||||
|
||||
> 局部事件决定下一步动作,只有 Run Lifecycle 和 Release 才能决定整次请求如何结束、什么可以公开。
|
||||
|
||||
因此,失败处理不是一个全局 `try/catch`,而是执行控制、安全事实和发布策略共同完成的结果。
|
||||
|
||||
## 2. 第一类:没有答案,但系统没有坏
|
||||
|
||||
最容易被误判为失败的场景,是 Tool 正常执行却没有返回证据。
|
||||
|
||||
当前 Tool 结果使用两个正交状态:
|
||||
|
||||
```text
|
||||
InvocationStatus:PROJECTING / READY / ERROR
|
||||
EvidenceStatus:EVIDENCE_FOUND / NO_EVIDENCE / ERROR
|
||||
```
|
||||
|
||||
它们回答不同问题:
|
||||
|
||||
| 组合 | 含义 | 是否是技术失败 |
|
||||
|---|---|---:|
|
||||
| `READY + EVIDENCE_FOUND` | Tool 成功,当前 scope 有候选证据 | 否 |
|
||||
| `READY + NO_EVIDENCE` | Tool 成功,当前 scope 没有候选证据 | 否 |
|
||||
| `ERROR + ERROR` | Tool 执行、投影或 canonical 保存失败 | 是 |
|
||||
|
||||
`READY + NO_EVIDENCE` 只能证明“本次查询范围为空”,不能证明“整个系统不存在该问题”。例如查询 10:00 至 10:10 的支付日志为空,不代表全天没有超时,也不代表日志系统之外没有故障。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["执行 Tool"] --> I{"InvocationStatus"}
|
||||
I -->|"ERROR"| X["技术失败路径"]
|
||||
I -->|"READY"| E{"EvidenceStatus"}
|
||||
E -->|"EVIDENCE_FOUND"| G["交给 Agent 判断信息增益"]
|
||||
E -->|"NO_EVIDENCE"| N["记录限定 scope 的空事实<br/>累计 NO_GAIN"]
|
||||
N --> Q{"还值得继续查询吗?"}
|
||||
Q -->|"值得"| R["调整假设或 scope"]
|
||||
Q -->|"连续无增益"| P["CollectionState.SATURATED"]
|
||||
```
|
||||
|
||||
类似的正常无结果还包括:
|
||||
|
||||
- 用户没有提供企业、服务、时间范围等必要上下文;
|
||||
- 多次查询都合法,但连续没有推进诊断;
|
||||
- Agent 遵循协议主动输出 `conclusion=null`;
|
||||
- 已完成有限检查,但证据只够描述现象,不够支持根因。
|
||||
|
||||
这些场景不应该伪装成系统异常。只要请求被正常处理并形成安全说明,就可以是:
|
||||
|
||||
```text
|
||||
RunState.SUCCESS + ReleaseOutcome.FALLBACK
|
||||
```
|
||||
|
||||
### 为什么不把 NO_EVIDENCE 设计成异常
|
||||
|
||||
如果空结果抛异常,系统会产生三个问题:
|
||||
|
||||
- Agent 无法区分“查询为空”和“查询服务不可用”;
|
||||
- 监控会把正常业务分布统计成技术故障;
|
||||
- Release 无法向用户解释已经检查的范围。
|
||||
|
||||
因此放弃了“Tool 无数据即失败”的简化方案,代价是状态维度增加,但换来了可解释的控制语义。
|
||||
|
||||
## 3. 第二类:技术异常可能可以重试
|
||||
|
||||
并非所有技术异常都应立即结束 Run。对于瞬时、明确、可能恢复的 Provider 故障,Harness 可以执行有限的显式重试。
|
||||
|
||||
当前重试策略遵循两个原则:
|
||||
|
||||
```text
|
||||
只有稳定分类为可恢复的技术失败才重试
|
||||
所有 attempt 都由 Harness 计数、计费并记录
|
||||
```
|
||||
|
||||
典型策略如下:
|
||||
|
||||
| 组件 | 可重试场景 | 最大 attempt | 不重试场景 |
|
||||
|---|---|---:|---|
|
||||
| Intent Router | timeout、transport、非法输出 | 2 | 已得到合法路由结果 |
|
||||
| SemanticGuard | timeout、transport、parse/schema failure | 2 | `UNSUPPORTED` |
|
||||
| Diagnosis Agent | 不执行隐藏 retry | 1 个受控业务循环 | 正常下一轮 ReAct 不是 retry |
|
||||
| Tool | 不执行隐藏 retry | 每个 Tool Call 一次 | Agent 可以基于结果选择其他 Tool |
|
||||
| EvidenceRepair | 一次显式修复机会 | 1 | 不是无限修复循环 |
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant H as Harness
|
||||
participant P as Router / Semantic Provider
|
||||
participant B as Budget & Trace
|
||||
|
||||
H->>B: reserve attempt #1
|
||||
H->>P: request #1
|
||||
P-->>H: timeout / transport / invalid output
|
||||
H->>B: record classified failure
|
||||
H->>B: reserve attempt #2
|
||||
H->>P: request #2
|
||||
alt 得到合法结果
|
||||
P-->>H: valid result
|
||||
H->>B: record success
|
||||
else 再次技术失败
|
||||
P-->>H: unavailable
|
||||
H->>B: record final failure
|
||||
H-->>H: 进入 Fallback 或 FAILED 决策
|
||||
end
|
||||
```
|
||||
|
||||
### 为什么不使用 SDK 的默认隐藏重试
|
||||
|
||||
隐藏重试会让系统无法准确回答:
|
||||
|
||||
- 这次请求实际调用了 Provider 几次;
|
||||
- Token、deadline 和 attempt 消耗在哪里;
|
||||
- Trace 中的一次调用为什么延迟异常;
|
||||
- 客户端取消后是否还在后台继续重试。
|
||||
|
||||
所以重试必须是 Harness 的控制行为,而不是各组件自行决定。代价是需要维护失败分类和 attempt 协议,但预算与审计仍然闭合。
|
||||
|
||||
### `UNSUPPORTED` 为什么不重试
|
||||
|
||||
SemanticGuard 返回 `UNSUPPORTED`,表示它成功完成了判断,只是证据不支持报告。这是业务结果,不是技术不可用。重试同一份报告只是在要求模型重新投票,既不能创造新证据,也会削弱门禁的一致性。
|
||||
|
||||
## 4. 第三类:一个 Tool 失败,不等于整个 Run 失败
|
||||
|
||||
Diagnosis Agent 通常拥有多个只读 Tool。某个 Tool 出现 `ERROR` 后,Agent 可能仍然可以:
|
||||
|
||||
- 改查另一个数据源;
|
||||
- 缩小或调整查询范围;
|
||||
- 使用已经取得的其他 canonical 事实;
|
||||
- 明确说明某个数据源不可用,并结束为 Fallback。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
E["单个 Tool ERROR"] --> A{"Run 仍 active 且预算允许?"}
|
||||
A -->|"否"| T["进入终止决策"]
|
||||
A -->|"是"| O{"是否还有合法替代动作?"}
|
||||
O -->|"换 Tool / 换 scope"| C["Agent 继续诊断"]
|
||||
O -->|"没有"| P{"已有安全进展?"}
|
||||
P -->|"有"| F["ReleaseOutcome.FALLBACK"]
|
||||
P -->|"无"| X["ReleaseOutcome.FAILED"]
|
||||
C --> D{"最终是否形成可发布报告?"}
|
||||
D -->|"是"| S["ReleaseOutcome.SUCCESS"]
|
||||
D -->|"否"| P
|
||||
```
|
||||
|
||||
这里不能建立一条简单映射:
|
||||
|
||||
```text
|
||||
Tool ERROR -> RunState.FAILED
|
||||
```
|
||||
|
||||
真正必须终止的情况,是错误破坏了 Harness 的安全前提,或者已经没有合法的恢复路径。例如:
|
||||
|
||||
- canonical store 无法保存或回读 Tool 真相;
|
||||
- Tool raw 或 Agent result 超过硬 bytes 上限;
|
||||
- 路由经过允许的 attempts 后仍不可用;
|
||||
- Agent 输出非法 Draft,且不存在可验证的 ProgressSnapshot;
|
||||
- Harness 自身出现无法分类、无法形成安全响应的内部错误。
|
||||
|
||||
### 为什么不让 Tool 自己决定 Run 失败
|
||||
|
||||
Tool 只知道一次 invocation 是否成功,不知道整个诊断还拥有多少预算、其他 Tool 是否可用、是否已有安全事实,也不知道最终发布策略。让 Tool 抛出全局终止异常,会把局部职责扩大成 Run 决策权。
|
||||
|
||||
当前设计的代价是 Agent 和 Application 必须处理结构化 Tool 错误,而不是依赖异常一路冒泡;收益是局部故障不会无条件摧毁整次诊断。
|
||||
|
||||
## 5. 第四类:执行成功,发布仍可能被拒绝
|
||||
|
||||
Agent 完成 Draft 并不表示用户一定能看到这份报告。发布前还要经过两类门禁:
|
||||
|
||||
```text
|
||||
EvidenceGuard:引用是否真实、是否属于当前 Run、是否来自 READY invocation
|
||||
SemanticGuard:这些真实证据是否支持用户可见结论
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["DiagnosisDraft"] --> E{"EvidenceGuard"}
|
||||
E -->|"通过"| S{"SemanticGuard"}
|
||||
E -->|"失败"| EF["EVIDENCE_VALIDATION_FAILED<br/>SafeFallback"]
|
||||
S -->|"SUPPORTED"| R["发布 Diagnosis Report"]
|
||||
S -->|"UNSUPPORTED"| SU["SEMANTIC_UNSUPPORTED<br/>SafeFallback"]
|
||||
S -->|"技术不可用"| SA["SEMANTIC_UNAVAILABLE<br/>SafeFallback"]
|
||||
```
|
||||
|
||||
对应的典型结果是:
|
||||
|
||||
| 发布门禁结果 | RunState | ReleaseOutcome | FallbackType |
|
||||
|---|---|---|---|
|
||||
| 两层门禁通过 | `SUCCESS` | `SUCCESS` | 无 |
|
||||
| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` |
|
||||
| SemanticGuard 判断不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` |
|
||||
| SemanticGuard 技术不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` |
|
||||
|
||||
这里最反直觉的一点是:Guard 拒绝发布原报告,通常仍然是 `RunState.SUCCESS`。因为 Harness 成功执行了安全策略,并向用户发布了诚实的降级结果;失败的是“原报告获得发布资格”,不是“控制系统无法完成请求”。
|
||||
|
||||
### 为什么 Guard 不能直接改写结论
|
||||
|
||||
项目放弃了让 Verifier 或 SemanticGuard 顺手生成“更正确答案”的方案。Guard 没有业务 Tool、完整 ReAct 上下文和重新取证能力,改写报告会让审查者同时成为证据生产者。
|
||||
|
||||
因此 Guard 只能批准或拒绝,Release 只能发布原报告或确定性 SafeFallback。代价是部分“看起来只差一点”的报告也会降级,但发布责任保持清晰。
|
||||
|
||||
## 6. 第五类:停止收集,不等于 Run 已终止
|
||||
|
||||
当连续查询没有信息增益,或 Agent 持续违反 progress 协议时,Harness 会把证据收集状态切换为:
|
||||
|
||||
```text
|
||||
CollectionState.SATURATED
|
||||
```
|
||||
|
||||
可能的停止原因包括:
|
||||
|
||||
```text
|
||||
INFORMATION_SATURATED
|
||||
PROGRESS_PROTOCOL_VIOLATED
|
||||
BUDGET_LIMIT_REACHED
|
||||
```
|
||||
|
||||
`SATURATED` 只表示不再允许新的 evidence Tool,不是 Run 终态。Harness 仍然要给 Agent 一次机会输出 Draft,或者根据 canonical records 投影 `ProgressSnapshot`。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED
|
||||
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
|
||||
COLLECTING --> SATURATED: 连续 NO_GAIN 或协议违规
|
||||
SATURATED --> DRAFTING: 交付一次 STOP_REQUIRED
|
||||
DRAFTING --> RELEASE: Agent 正常结束
|
||||
DRAFTING --> TERMINATED: Agent 再次请求 Tool
|
||||
RELEASE --> [*]
|
||||
TERMINATED --> [*]
|
||||
```
|
||||
|
||||
这项区分解决了一个早期问题:系统过去只能依靠预算把空转“撞停”,最终把证据不足表达成 `BUDGET_EXHAUSTED` 或内部失败。引入 Collection 状态后,业务收敛可以发生在硬资源终止之前。
|
||||
|
||||
## 7. 第六类:超时、预算和取消是三种不同终止
|
||||
|
||||
Run 的终态只有:
|
||||
|
||||
```text
|
||||
RUNNING
|
||||
SUCCESS
|
||||
FAILED
|
||||
CANCELLED
|
||||
TIMED_OUT
|
||||
BUDGET_EXHAUSTED
|
||||
```
|
||||
|
||||
`RunLifecycle` 使用 first-terminal-wins:第一个成功设置的终态不可被后来返回的 Provider、Tool 或异步回调覆盖。
|
||||
|
||||
### Deadline 到期
|
||||
|
||||
deadline 回答“这次 Run 还能否继续占用时间”。到期后终态是 `TIMED_OUT`。如果没有可发布的安全内容,Release 为 `FAILED`;当前实现不会为了美化结果在超时后继续调用模型生成说明。
|
||||
|
||||
### Budget 耗尽
|
||||
|
||||
预算可能限制模型 attempt、Tool 调用、Token 或 bytes。终态是 `BUDGET_EXHAUSTED`,但发布结果取决于是否已有安全进展:
|
||||
|
||||
```text
|
||||
有 ProgressSnapshot -> ReleaseOutcome.FALLBACK
|
||||
没有安全进展 -> ReleaseOutcome.FAILED
|
||||
```
|
||||
|
||||
这说明 `RunState` 和 `ReleaseOutcome` 不能一一对应。
|
||||
|
||||
### 客户端断开
|
||||
|
||||
客户端断开意味着公开通道已经不存在,Run 转为 `CANCELLED`。取消是协作式的:已经发出的同步 Provider 调用可能无法被物理中止,但返回后必须经过 active check 和 SSE state 检查,迟到结果不能再发布。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as ChatSseSession
|
||||
participant H as Harness Core
|
||||
participant P as Provider / Tool
|
||||
|
||||
H->>P: 已发出的同步调用
|
||||
C--xS: 断开连接
|
||||
S->>H: cancel Run
|
||||
H->>H: first terminal = CANCELLED
|
||||
P-->>H: 迟到结果
|
||||
H->>H: checkActive 拒绝继续
|
||||
H-->>S: 不发送 content / done
|
||||
S->>S: DISCONNECTED 拒绝迟到事件
|
||||
```
|
||||
|
||||
客户端断开时不可靠地发送 `done(CANCELLED)`,因为连接已经不可用。取消事实应由服务端 Trace 和 Run 状态观察,而不是假设客户端还能收到终止事件。
|
||||
|
||||
## 8. “安全进展”决定 FALLBACK 还是 FAILED
|
||||
|
||||
停止之后,系统不能把 Agent 的未验证草稿直接当作降级内容。所谓安全进展,必须来自当前 Run 的 canonical READY records,并经过有界投影。
|
||||
|
||||
`ProgressSnapshot` 可以包含:
|
||||
|
||||
- 已实际执行的查询 scope;
|
||||
- 可验证的 observed facts;
|
||||
- 限定范围内的 `NO_EVIDENCE`;
|
||||
- 数据源不可用、结果截断或投影失败等 limitations;
|
||||
- 推荐补充的上下文或下一步检查。
|
||||
|
||||
它不能包含未经支持的根因结论。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T["Run 无法继续或 Agent 无结论"] --> P["从 canonical records<br/>构建 ProgressSnapshot"]
|
||||
P --> V{"存在可验证的 observed facts?"}
|
||||
V -->|"有"| F["FALLBACK<br/>说明已检查内容、限制和下一步"]
|
||||
V -->|"没有"| M{"是否明确缺少必要上下文?"}
|
||||
M -->|"是"| C["FALLBACK<br/>MISSING_REQUIRED_CONTEXT"]
|
||||
M -->|"否"| E["FAILED<br/>不公开未验证内容"]
|
||||
```
|
||||
|
||||
因此,下面两次预算耗尽可以有不同结果:
|
||||
|
||||
| 场景 | RunState | ReleaseOutcome | 用户看到什么 |
|
||||
|---|---|---|---|
|
||||
| 已验证支付服务在目标时段无日志,随后预算耗尽 | `BUDGET_EXHAUSTED` | `FALLBACK` | 已检查范围、空结果限制和下一步 |
|
||||
| 第一次模型调用前预算检查失败,没有任何安全事实 | `BUDGET_EXHAUSTED` | `FAILED` | failure |
|
||||
|
||||
### 为什么不为所有失败生成一段“友好回答”
|
||||
|
||||
如果失败后再调用模型组织解释,会继续消耗已经耗尽的预算,也可能根据异常文本编造业务结论。确定性模板虽然表达能力有限,但不会把未知包装成答案。
|
||||
|
||||
这项设计选择了 fail closed:有安全事实才降级,没有就失败。代价是用户体验不总是“自然语言很完整”,但不会为了完整感牺牲可信度。
|
||||
|
||||
## 9. 同一个结果,要从四个视图理解
|
||||
|
||||
Harness 没有一条能容纳全部语义的总状态机。一次请求结束后,至少要区分四个观察面:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R["RunState<br/>执行为什么停止"] --> X["一次请求"]
|
||||
O["ReleaseOutcome<br/>最终公开什么"] --> X
|
||||
D["diagnosis_run.status<br/>请求是否被安全处理"] --> X
|
||||
S["SSE 事件<br/>客户端实际收到什么"] --> X
|
||||
```
|
||||
|
||||
### RunState:执行为什么停止
|
||||
|
||||
它由 Harness Core 拥有,表达正常完成、内部失败、取消、超时或预算耗尽。
|
||||
|
||||
### ReleaseOutcome:最终公开什么
|
||||
|
||||
```text
|
||||
SUCCESS / FALLBACK / FAILED / CANCELLED
|
||||
```
|
||||
|
||||
它由 Release 决定,表达正常诊断报告、安全降级、失败或取消。
|
||||
|
||||
### 数据库 status:请求是否被安全处理
|
||||
|
||||
`JpaChatRunStore` 当前映射为:
|
||||
|
||||
| ReleaseOutcome | diagnosis_run.status |
|
||||
|---|---|
|
||||
| `SUCCESS` | `SUCCESS` |
|
||||
| `FALLBACK` | `SUCCESS` |
|
||||
| `FAILED` | `FAILED` |
|
||||
| `CANCELLED` | `CANCELLED` |
|
||||
|
||||
因此:
|
||||
|
||||
```text
|
||||
status=SUCCESS + release_outcome=FALLBACK
|
||||
```
|
||||
|
||||
表示请求被正常、安全地处理并发布了降级内容,不表示系统找到了根因。
|
||||
|
||||
只有 `DIAGNOSIS + ReleaseOutcome.SUCCESS + published_result` 才能进入下一轮 `PreviousTurn`。Fallback 不进入后续上下文,避免把“证据不足”当作已确认结论继续传播。
|
||||
|
||||
### SSE:客户端实际收到什么
|
||||
|
||||
公开事件顺序是:
|
||||
|
||||
```text
|
||||
metadata -> status* -> content | failure -> done
|
||||
```
|
||||
|
||||
- `content` 最多一次;
|
||||
- `content` 与 `failure` 互斥;
|
||||
- `TERMINAL / DISCONNECTED` 后拒绝迟到结果;
|
||||
- 当前 `done` 使用 `ReleaseOutcome`,不是另一套遗留终态。
|
||||
|
||||
四个视图回答不同问题,排障时不能拿数据库 `SUCCESS` 推断用户收到了一份成功诊断报告。
|
||||
|
||||
## 10. 典型场景总表
|
||||
|
||||
| 场景 | RunState | ReleaseOutcome | 公开内容或 FallbackType |
|
||||
|---|---|---|---|
|
||||
| EvidenceGuard、SemanticGuard 全部通过 | `SUCCESS` | `SUCCESS` | Diagnosis report |
|
||||
| 缺少企业、服务、时间等必要上下文 | `SUCCESS` | `FALLBACK` | `MISSING_REQUIRED_CONTEXT` |
|
||||
| 有限检查后仍然证据不足 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| 信息饱和且有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| Progress 协议停止且有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` |
|
||||
| SemanticGuard 判断不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` |
|
||||
| SemanticGuard attempts 后不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` |
|
||||
| 预算耗尽但有安全进展 | `BUDGET_EXHAUSTED` | `FALLBACK` | 当前发布为 `INSUFFICIENT_EVIDENCE` |
|
||||
| 超时且没有安全内容 | `TIMED_OUT` | `FAILED` | failure |
|
||||
| 预算耗尽且没有安全进展 | `BUDGET_EXHAUSTED` | `FAILED` | failure |
|
||||
| 不可恢复内部失败 | `FAILED` | `FAILED` | failure |
|
||||
| 客户端断开 | `CANCELLED` | `CANCELLED` | 不可靠发送 `done` |
|
||||
|
||||
这张表不是一个双向转换规则。例如看到 `ReleaseOutcome.FALLBACK`,不能单独推断 Run 是正常完成还是预算耗尽;仍要结合 Run termination 和 Trace。
|
||||
|
||||
## 11. 排障时按什么顺序看
|
||||
|
||||
面对“用户为什么没有看到诊断结论”,不要先搜索所有异常日志。按发布结果向前追踪更容易定位:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户实际收到的 SSE"] --> O["release_outcome<br/>fallback_type"]
|
||||
O --> R["Run termination<br/>deadline / budget / cancel"]
|
||||
O --> G["Evidence / Semantic<br/>Release Trace"]
|
||||
R --> T["Tool Invocation<br/>Progress / canonical record"]
|
||||
G --> T
|
||||
```
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. 看 SSE 是 `content`、`failure`,还是连接提前断开。
|
||||
2. 看 `release_outcome` 和 `FallbackType`,确认是正常报告、降级还是失败。
|
||||
3. 看 Run termination,区分正常完成、超时、预算耗尽和取消。
|
||||
4. 看 Release、EvidenceGuard、SemanticGuard Trace,确认原报告为什么不能发布。
|
||||
5. 看 Tool 的 `InvocationStatus + EvidenceStatus`,不要只看一个 `success`。
|
||||
6. 最后查看 canonical record、scope、bytes、budget usage 和 progress stop reason。
|
||||
|
||||
这个顺序从“用户看到什么”追到“内部为什么这样决定”,比从第一条异常开始阅读整条 Timeline 更容易建立因果关系。
|
||||
|
||||
## 12. 这套设计放弃了哪些更简单的方案
|
||||
|
||||
### 一个 `status` 表示一切
|
||||
|
||||
放弃原因:`SUCCESS` 无法同时表达 Tool 执行、Run 终止、报告发布和数据库处理结果。单状态简单,但必然丢失原因。
|
||||
|
||||
### 任意异常直接终止整个 Run
|
||||
|
||||
放弃原因:局部 Tool 故障仍可能有替代路径;空结果也不是异常。这样做会降低系统可用性并掩盖有限检查的价值。
|
||||
|
||||
### 所有错误都自动重试
|
||||
|
||||
放弃原因:业务拒绝、容量上限、非法输入和证据不支持不会因为重试自动恢复;隐藏重试还会破坏预算和审计。
|
||||
|
||||
### Agent 自己决定何时降级
|
||||
|
||||
放弃原因:Agent 无法读取 canonical truth,也不能验证自己的引用。让它同时生成结论和批准发布,会形成自证循环。
|
||||
|
||||
### 失败后继续调用模型润色 Fallback
|
||||
|
||||
放弃原因:终止后继续消耗资源,且无法保证新文本只包含已验证事实。当前使用确定性 Release Policy 和安全模板。
|
||||
|
||||
### 取消时强制等待所有底层调用结束
|
||||
|
||||
放弃原因:同步 Provider 未必支持真正中断,等待会延长资源占用。当前采用协作式取消、active check、first-terminal-wins 和 SSE 状态拒绝迟到发布。
|
||||
|
||||
## 13. 面试时如何讲这套失败设计
|
||||
|
||||
可以用下面这段话概括:
|
||||
|
||||
> 我们没有把 Harness 的失败处理设计成一个全局异常捕获器,因为 Agent 系统里“没有证据、单个 Tool 失败、证据不支持结论、预算耗尽和客户端取消”代表完全不同的控制语义。系统先判断 Run 是否还能继续,再从当前 Run 的 canonical records 判断是否已有可验证进展,最后由唯一的 Release Policy 决定发布正常报告、SafeFallback 还是 failure。Tool 使用 InvocationStatus 和 EvidenceStatus 区分技术失败与正常空结果;RunState 解释执行为什么停止,ReleaseOutcome 解释用户最终看到什么,两者不做一一映射。可恢复的 Provider 故障只允许 Harness 做有限、可审计的显式重试,Guard 拒绝发布通常降级而不是把整次请求标成失败,超时、预算和取消则通过 first-terminal-wins 与 SSE 状态阻止迟到结果。这样做的目标不是让每次诊断都成功,而是保证任何结束方式都可解释、可审计,并且不会把未经验证的内容发布给用户。
|
||||
|
||||
这段回答要表达的不是“系统定义了很多状态”,而是:**每个状态都对应不同的决策权和失败责任。**
|
||||
|
||||
## 14. 当前边界与代价
|
||||
|
||||
1. 多套正交状态提高了准确性,也增加了学习成本,必须通过 Context、映射表和 Trace 查询规范维持统一口径。
|
||||
2. 内存中的 `RunTermination.state/reason` 当前没有完整独立持久化;精确判断 `TIMED_OUT / BUDGET_EXHAUSTED` 仍需结合 Trace、异常路径和预算记录。
|
||||
3. 协作式取消能阻止迟到发布,但不保证立即终止已经发出的同步 Provider 计算。
|
||||
4. Tool ERROR 后是否继续由 Agent 在 Harness 门禁内选择,因此模型可能做出次优恢复动作;硬预算和停止协议负责限制损失。
|
||||
5. 有安全进展才能 Fallback 的策略会让部分请求直接失败,但这是避免发布未验证内容的主动取舍。
|
||||
6. `FallbackType` 枚举仍保留 `BUDGET_EXHAUSTED`,但当前预算受控停止实际统一发布 `INSUFFICIENT_EVIDENCE`;这是已知命名债务。未来若要区分资源不足与业务证据不足,需要先明确对外语义和兼容策略,不能只替换当前映射。
|
||||
|
||||
## 15. 事实来源与延伸阅读
|
||||
|
||||
本文对应的主要实现边界:
|
||||
|
||||
- `ChatApplicationUseCase`:Application 生命周期、Router、Run 终止和 Release 协作;
|
||||
- `DiagnosisHarnessCore`:Run active check、预算、终态与控制边界;
|
||||
- `DiagnosisAgentUseCase`:Tool loop、受控停止和 Draft 形成;
|
||||
- `DiagnosisReleaseUseCase`:EvidenceGuard、SemanticGuard 与唯一发布策略;
|
||||
- `JpaChatRunStore`:ReleaseOutcome 到数据库 status 的映射;
|
||||
- `ChatSseSession`:SSE 单内容、终态和断开规则。
|
||||
|
||||
继续阅读:
|
||||
|
||||
- [Harness 入门](README.md)
|
||||
- [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)
|
||||
- [Harness 生命周期与状态](Harness生命周期与状态.md)
|
||||
- [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md)
|
||||
- [证据安全链](Harness证据安全链-从引用真实到结论可发布.md)
|
||||
- [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)
|
||||
@@ -0,0 +1,468 @@
|
||||
# Harness 异常处理:Loop 内 vs Loop 外
|
||||
|
||||
**日期**:2026-07-30
|
||||
**范围**:诊断路径(`intent=DIAGNOSIS`)上 ReactAgent ReAct 循环内外的异常、受控停止、降级与终态
|
||||
**读者**:已读 E2E 全流程 / 运行控制,需要把「失败时系统到底怎么走」串成一张图
|
||||
|
||||
**关联文档**:
|
||||
|
||||
- [Harness 失败图谱](./Harness失败图谱-异常-停止-降级与终态.md)(失败类型总览)
|
||||
- [Harness 生命周期与状态](./Harness生命周期与状态.md)(RunState / CollectionState)
|
||||
- [信息增益停止](./Harness信息增益停止-让无证据诊断正常收敛.md)(SATURATED / STOP_REQUIRED)
|
||||
|
||||
---
|
||||
|
||||
## 0. 先记住一张总图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph app["Application 编排"]
|
||||
UC[ChatApplicationUseCase]
|
||||
EX[DiagnosisChatExecutor]
|
||||
end
|
||||
|
||||
subgraph agent["Agent 用例"]
|
||||
UC2[DiagnosisAgentUseCase]
|
||||
CE[controlledExecution]
|
||||
RF[recoverInvalidDraft]
|
||||
end
|
||||
|
||||
subgraph loop["ReactAgent ReAct Loop"]
|
||||
MI[HarnessModelInterceptor]
|
||||
LLM[ChatModel]
|
||||
TI[HarnessToolInterceptor]
|
||||
TB[ToolBoundary]
|
||||
end
|
||||
|
||||
subgraph release["Release"]
|
||||
REL[DiagnosisReleaseUseCase]
|
||||
FB[SafeFallback / FALLBACK]
|
||||
OK[SUCCESS 报告]
|
||||
end
|
||||
|
||||
UC --> EX
|
||||
EX --> UC2
|
||||
UC2 -->|agent.call| loop
|
||||
MI --> LLM
|
||||
LLM -->|tool_call| TI
|
||||
TI --> TB
|
||||
TB -->|observation| LLM
|
||||
|
||||
loop -->|受控异常穿出| CE
|
||||
CE -->|stopped draft=null| REL
|
||||
loop -->|正常返回坏 draft| UC2
|
||||
UC2 -->|DiagnosisAgentOutputException| RF
|
||||
RF -->|有 observedFacts| REL
|
||||
REL --> FB
|
||||
REL --> OK
|
||||
CE -->|取消/超时再抛| UC
|
||||
RF -->|无 facts 再抛| UC
|
||||
```
|
||||
|
||||
**一句话**:
|
||||
|
||||
| 区域 | 异常怎么处理 |
|
||||
|------|----------------|
|
||||
| **Loop 内** | 多数变成 **tool/model 侧 observation 或拦截**;少数 **受控异常穿出 loop** |
|
||||
| **Loop 边界** | `controlledExecution`:受控停止 → `stopped`;不可控 → 包装/再抛 |
|
||||
| **Loop 外(Executor)** | `recoverInvalidDraft`:坏 draft + 有事实 → FALLBACK;否则失败 |
|
||||
| **Release** | 有 draft 走 Guard;无 draft / 非法 draft 走专用降级 |
|
||||
| **Application** | 未消化异常 → `ChatFailureCode` + Run 终态落库 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 状态有三层,不要混
|
||||
|
||||
异常处理会同时碰到三套状态,职责不同:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph core["Core 执行面"]
|
||||
RS[RunState<br/>RUNNING / SUCCESS / FAILED<br/>CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED]
|
||||
end
|
||||
subgraph prog["Progress 收集面"]
|
||||
CS[CollectionState<br/>COLLECTING / SATURATED]
|
||||
SR[DiagnosisStopReason]
|
||||
end
|
||||
subgraph out["对外发布面"]
|
||||
RO[ReleaseOutcome<br/>SUCCESS / FALLBACK / FAILED / CANCELLED]
|
||||
FT[FallbackType]
|
||||
CF[ChatFailureCode]
|
||||
end
|
||||
RS -.->|"预算耗尽可并存"| RO
|
||||
SR -.->|"有 facts 常映射"| FT
|
||||
RO -.->|"失败粗码"| CF
|
||||
```
|
||||
|
||||
| 层 | 枚举 | 回答的问题 |
|
||||
|----|------|------------|
|
||||
| Core | `RunState` | 这次 Run 技术上还能不能继续? |
|
||||
| Progress | `DiagnosisStopReason` / `CollectionState` | 证据收集为何停、是否已饱和? |
|
||||
| 发布 | `ReleaseOutcome` / `FallbackType` | 用户看到报告、降级还是失败? |
|
||||
| 协议失败 | `ChatFailureCode` | SSE failure 的粗粒度原因? |
|
||||
|
||||
**典型组合**:
|
||||
|
||||
| 场景 | RunState | StopReason | ReleaseOutcome |
|
||||
|------|----------|------------|----------------|
|
||||
| 正常成功 | SUCCESS | — | SUCCESS |
|
||||
| 信息饱和且有事实 | 常仍可 SUCCESS 收尾* | INFORMATION_SATURATED | FALLBACK / INSUFFICIENT_EVIDENCE |
|
||||
| 预算耗尽且有事实 | BUDGET_EXHAUSTED | BUDGET_LIMIT_REACHED | FALLBACK / INSUFFICIENT_EVIDENCE |
|
||||
| 预算耗尽且无事实 | BUDGET_EXHAUSTED | BUDGET_LIMIT_REACHED | FAILED |
|
||||
| 客户端断开 | CANCELLED | — | CANCELLED / 可能无 done |
|
||||
|
||||
\*饱和后若 Agent 合法写完 draft 并过 Guard,也可能 SUCCESS;若 stopped 无 draft 则走 FALLBACK。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构位置:异常在哪一层被「接住」
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph L0["L0 协议"]
|
||||
CTRL[ChatController / SSE]
|
||||
end
|
||||
subgraph L1["L1 Application"]
|
||||
APP[ChatApplicationUseCase<br/>统一 catch → ChatFailureCode]
|
||||
DEX[DiagnosisChatExecutor<br/>recoverInvalidDraft]
|
||||
end
|
||||
subgraph L2["L2 Agent 用例"]
|
||||
DAU[DiagnosisAgentUseCase<br/>controlledExecution]
|
||||
end
|
||||
subgraph L3["L3 框架 Loop"]
|
||||
RA[ReactAgent.call]
|
||||
end
|
||||
subgraph L4["L4 Interceptor / Boundary"]
|
||||
MI[ModelInterceptor]
|
||||
TI[ToolInterceptor]
|
||||
TB[ToolBoundary]
|
||||
CORE[DiagnosisHarnessCore]
|
||||
end
|
||||
|
||||
CTRL --> APP --> DEX --> DAU --> RA
|
||||
RA --> MI --> CORE
|
||||
RA --> TI --> TB --> CORE
|
||||
|
||||
MI -.->|BudgetExceeded / RunAborted 上抛| DAU
|
||||
TI -.->|多数 error observation 留在 loop| RA
|
||||
TI -.->|CollectionStopped 上抛| DAU
|
||||
DAU -.->|stopped| DEX
|
||||
DAU -.->|Draft 契约异常| DEX
|
||||
DEX -.->|未恢复| APP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Loop 内:一次 ReAct 轮次里发生什么
|
||||
|
||||
### 3.1 正常成功路径(对照)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant DAU as DiagnosisAgentUseCase
|
||||
participant RA as ReactAgent
|
||||
participant MI as ModelInterceptor
|
||||
participant LLM as ChatModel
|
||||
participant TI as ToolInterceptor
|
||||
participant TB as ToolBoundary
|
||||
|
||||
DAU->>RA: agent.call(input, config)
|
||||
RA->>MI: interceptModel
|
||||
MI->>MI: beforeModelCall 预算闸
|
||||
MI->>LLM: handler.call
|
||||
LLM-->>MI: tool_call
|
||||
MI-->>RA: ModelResponse
|
||||
RA->>TI: interceptToolCall
|
||||
TI->>TI: 协议/重复/饱和检查
|
||||
TI->>TB: invoke
|
||||
TB-->>TI: READY + agentResult
|
||||
TI-->>RA: 投影 observation
|
||||
RA->>MI: interceptModel 第 2 轮
|
||||
MI->>LLM: 写 Draft
|
||||
LLM-->>MI: 文本 JSON
|
||||
MI-->>RA: ModelResponse
|
||||
RA-->>DAU: AssistantMessage
|
||||
DAU->>DAU: 解析 DiagnosisDraft
|
||||
DAU-->>DAU: completed(draft, progress)
|
||||
```
|
||||
|
||||
### 3.2 Loop 内:Model 路径异常(会穿出 loop)
|
||||
|
||||
**触发点**:`HarnessModelInterceptor.interceptModel`
|
||||
|
||||
```text
|
||||
beforeModelCall / handler.call / checkActive
|
||||
→ BudgetExceededException
|
||||
→ RunAbortedException(已终态、超时、取消)
|
||||
→ 其它 RuntimeException(供应商错误等)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant RA as ReactAgent
|
||||
participant MI as ModelInterceptor
|
||||
participant Core as DiagnosisHarnessCore
|
||||
participant DAU as DiagnosisAgentUseCase
|
||||
|
||||
RA->>MI: interceptModel(第 N 次模型)
|
||||
MI->>Core: beforeModelCall
|
||||
Core-->>MI: throw BudgetExceeded / RunAborted
|
||||
Note over MI: 不吞异常,不转 observation
|
||||
MI-->>RA: 异常上抛
|
||||
RA-->>DAU: agent.call 失败
|
||||
DAU->>DAU: controlledExecution(e)
|
||||
```
|
||||
|
||||
| 异常 | Loop 内是否消化 | 穿出后 |
|
||||
|------|-----------------|--------|
|
||||
| `BudgetExceededException` | 否 | → `stopped(BUDGET_LIMIT_REACHED)` |
|
||||
| `RunAbortedException(BUDGET_EXHAUSTED)` | 否 | → 同上 |
|
||||
| `RunAbortedException(CANCELLED/TIMED_OUT/…)` | 否 | controlledExecution **再抛** → Application |
|
||||
| 其它未识别 | 否 | → `DiagnosisAgentOutputException(EXECUTION_FAILED)` |
|
||||
|
||||
### 3.3 Loop 内:Tool 路径(多数不穿出)
|
||||
|
||||
**触发点**:`HarnessToolInterceptor.interceptToolCall` + `ToolBoundary`
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
TC[收到 tool_call] --> SUP{是否证据工具?}
|
||||
SUP -->|否| H[handler.call 旁路]
|
||||
SUP -->|是| SAT{已 SATURATED 且 stop 已交付?}
|
||||
SAT -->|是| EX[throw DiagnosisCollectionStoppedException]
|
||||
SAT -->|否| PARSE[解析 Envelope / previous_observation]
|
||||
PARSE -->|协议违规| REP[可修复 error observation<br/>或连续违规后 stop]
|
||||
PARSE --> DUP{重复 scope?}
|
||||
DUP -->|是| DUPR[DUPLICATE_SCOPE observation]
|
||||
DUP -->|否| INV[ToolBoundary.invoke]
|
||||
INV -->|READY| OBS[投影 modelObservation 回注]
|
||||
INV -->|BUDGET_EXHAUSTED 等 error| ERR[error observation<br/>markBudgetLimitReached]
|
||||
EX --> OUT[穿出 ReactAgent loop]
|
||||
OBS --> LOOP[留在 loop,模型继续]
|
||||
ERR --> LOOP
|
||||
REP --> LOOP
|
||||
DUPR --> LOOP
|
||||
```
|
||||
|
||||
**设计选择**:
|
||||
|
||||
| 情况 | 策略 | 原因 |
|
||||
|------|------|------|
|
||||
| 工具执行失败、投影失败、结果过大 | **error observation 留在 loop** | 给模型一次感知/改写机会,不立刻整 run 崩 |
|
||||
| 预算在 ToolBoundary 触顶 | **先 error observation** + progress 标记预算 | 本轮不强制撕开 loop;下一轮 model 的 `beforeModelCall` 会硬闸 |
|
||||
| 信息饱和后仍要工具 | **`DiagnosisCollectionStoppedException` 穿出** | 收集已无价值,禁止空转 |
|
||||
| 协议违规(未达阈值) | **repairable error observation** | 要求模型补 previous_observation 等 |
|
||||
|
||||
`ToolBoundary` 对预算的处理(**吞异常 → 错误码**,不抛给框架):
|
||||
|
||||
```text
|
||||
catch (RunAbortedException | BudgetExceededException)
|
||||
→ ToolBoundaryResult.error(BUDGET_EXHAUSTED | RUN_INACTIVE)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Loop 边界:`controlledExecution`
|
||||
|
||||
**位置**:`DiagnosisAgentUseCase`,包住 `agent.call(...)`。
|
||||
|
||||
**职责**:把「Harness 约定的受控停止」从异常栈(含 cause 链)捞出来,转成 **`DiagnosisAgentExecution.stopped`**;其余失败交给外层。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
E[catch Exception from agent.call] --> F1{cause 含 CollectionStopped?}
|
||||
F1 -->|是| S1[stopped progress + stopReason]
|
||||
F1 -->|否| F2{cause 含 RunAborted?}
|
||||
F2 -->|是且 BUDGET_EXHAUSTED| S2[markBudgetLimitReached<br/>stopped BUDGET_LIMIT_REACHED]
|
||||
F2 -->|是且其它终态| R1[再抛 RunAborted]
|
||||
F2 -->|否| F3{BudgetExceeded 或 lifecycle 已预算耗尽?}
|
||||
F3 -->|是| S2
|
||||
F3 -->|否| N[return null]
|
||||
N --> W[包装 DiagnosisAgentOutputException EXECUTION_FAILED]
|
||||
S1 --> RET[正常 return 给 Executor]
|
||||
S2 --> RET
|
||||
```
|
||||
|
||||
| 输入信号 | 输出 |
|
||||
|----------|------|
|
||||
| `DiagnosisCollectionStoppedException` | `stopped(stopReason)`,`draft=null` |
|
||||
| 预算耗尽类 | `stopped(BUDGET_LIMIT_REACHED)` |
|
||||
| 取消 / 超时类 `RunAborted` | **不转 stopped,再抛** |
|
||||
| 未知 | `null` → 包装执行失败异常 |
|
||||
|
||||
**结果形态**:
|
||||
|
||||
```text
|
||||
completed(draft, progress) // 有合法草稿
|
||||
stopped(progress, stopReason) // 无草稿,仅有进度快照
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop 外:坏 Draft 与 `recoverInvalidDraft`
|
||||
|
||||
**时机**:`agent.call` **已经返回**(或解析阶段),最终文本 **不是**合法 `DiagnosisDraft`。
|
||||
|
||||
**位置**:`DiagnosisChatExecutor` catch `DiagnosisAgentOutputException`。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[DiagnosisAgentOutputException] --> K{isDraftContractFailure?}
|
||||
K -->|否 EXECUTION_FAILED| P1[再抛 → Application FAILED]
|
||||
K -->|是 EMPTY/INVALID_JSON/SCHEMA| H{hasObservedFacts?}
|
||||
H --> AUD[审计 agentDraftInvalid]
|
||||
AUD --> H2{hasObservedFacts?}
|
||||
H2 -->|否| P1
|
||||
H2 -->|是| SSE[status SAFETY_VALIDATING]
|
||||
SSE --> RID[releaseInvalidDraft progress]
|
||||
RID --> FB[FALLBACK INSUFFICIENT_EVIDENCE]
|
||||
```
|
||||
|
||||
要点(与常见误解对照):
|
||||
|
||||
| 误解 | 实际 |
|
||||
|------|------|
|
||||
| recover 里再验 draft 完不完整 | draft 合法性已在 Agent 用例判定;此处只看 **异常 Kind** |
|
||||
| hasProgress = 有过 tool_call | = **`progress.observedFacts` 非空**(可发布观察事实) |
|
||||
| 降级会带上坏 JSON | **丢弃非法正文**,只根据 progress 生成 SafeFallback |
|
||||
|
||||
**专用发布**:`DiagnosisReleaseUseCase.releaseInvalidDraft`
|
||||
|
||||
- 不再跑 Evidence/Semantic Guard(没有可校验 draft)
|
||||
- 要求 `hasObservedFacts()`,否则 `IllegalStateException`
|
||||
- 产出 `FallbackType.INSUFFICIENT_EVIDENCE`
|
||||
|
||||
---
|
||||
|
||||
## 6. Release:按 execution 形态分叉
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
EX[DiagnosisAgentExecution] --> D{draft == null?}
|
||||
D -->|是 stopped| CS[releaseControlledStop<br/>需有 observedFacts]
|
||||
D -->|否| C{conclusion == null?}
|
||||
C -->|是| NC[releaseNoConclusion<br/>部分 Evidence + progress]
|
||||
C -->|否| RC[releaseConclusion<br/>Evidence → 可选 Repair → Semantic]
|
||||
CS --> FB[FALLBACK]
|
||||
NC --> FB
|
||||
RC -->|SUPPORTED| OK[SUCCESS]
|
||||
RC -->|其它| FB
|
||||
```
|
||||
|
||||
| 入口 | 典型来源 |
|
||||
|------|----------|
|
||||
| `releaseControlledStop` | `controlledExecution` → stopped |
|
||||
| `releaseInvalidDraft` | `recoverInvalidDraft`(loop 结束坏 draft) |
|
||||
| `releaseConclusion` | 正常 completed + 有结论 |
|
||||
| `releaseNoConclusion` | draft 合法但无 conclusion |
|
||||
|
||||
---
|
||||
|
||||
## 7. Application 统一失败出口
|
||||
|
||||
未被转成 FALLBACK / SUCCESS 的异常,进入 `ChatApplicationUseCase`:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant EX as DiagnosisChatExecutor
|
||||
participant APP as ChatApplicationUseCase
|
||||
participant Core as DiagnosisHarnessCore
|
||||
participant Store as ChatRunStore
|
||||
participant SSE as ChatSseSession
|
||||
|
||||
EX-->>APP: 抛 ChatApplicationException 或 RuntimeException
|
||||
APP->>Core: terminalOutcome / completeFailure
|
||||
APP->>Store: finish(terminal, ...)
|
||||
APP->>APP: safeFailure → ChatFailureCode
|
||||
APP-->>SSE: fail(code, 安全文案)
|
||||
```
|
||||
|
||||
常见映射直觉:
|
||||
|
||||
| 情况 | ChatFailureCode 倾向 |
|
||||
|------|----------------------|
|
||||
| 取消 | `RUN_CANCELLED` |
|
||||
| 路由失败 | `ROUTING_UNAVAILABLE` |
|
||||
| 落库失败 | `RUN_PERSISTENCE_FAILED` |
|
||||
| 其它 | `INTERNAL_FAILURE` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 端到端对照表(按场景)
|
||||
|
||||
| # | 场景 | 发生位置 | 关键信号 | 第一处理点 | 用户侧 |
|
||||
|---|------|----------|----------|------------|--------|
|
||||
| 1 | 第 N 次模型前预算没了 | Loop 内 Model | `BudgetExceeded` | controlledExecution → stopped | 有 facts→FALLBACK;无→FAILED |
|
||||
| 2 | 工具执行失败 | Loop 内 Tool | `TOOL_EXECUTION_ERROR` observation | 留在 loop | 模型可能改写或再试 |
|
||||
| 3 | 工具触顶预算 | Loop 内 ToolBoundary | error + markBudget | 常留在 loop,下轮 model 硬闸 | 同预算结局 |
|
||||
| 4 | 饱和后仍要工具 | Loop 内 Tool | `DiagnosisCollectionStopped` | controlledExecution → stopped | 有 facts→FALLBACK |
|
||||
| 5 | 取消/超时 | Core → 任意 checkActive | `RunAborted` | controlledExecution **再抛** | CANCELLED / FAILED |
|
||||
| 6 | 模型返回烂 JSON | Loop 外解析 | `INVALID_JSON` 等 | recoverInvalidDraft | 有 facts→FALLBACK;无→FAILED |
|
||||
| 7 | 执行崩溃未识别 | Loop 边界 | `EXECUTION_FAILED` | recover 不恢复,上抛 | FAILED |
|
||||
| 8 | Guard 引用失败 | Loop 外 Release | Evidence 违规 | repair 或 FALLBACK | FALLBACK EVIDENCE_* |
|
||||
| 9 | 语义不支持 | Loop 外 Release | UNSUPPORTED | FALLBACK | FALLBACK SEMANTIC_* |
|
||||
|
||||
---
|
||||
|
||||
## 9. 两条主恢复路径对比(必记)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph mid["Loop 中途打断"]
|
||||
A1[Interceptor / Core 异常] --> B1[controlledExecution]
|
||||
B1 --> C1[stopped draft=null]
|
||||
C1 --> D1[releaseControlledStop]
|
||||
end
|
||||
|
||||
subgraph end["Loop 正常结束但输出坏"]
|
||||
A2[解析 Draft 失败] --> B2[DiagnosisAgentOutputException]
|
||||
B2 --> C2[recoverInvalidDraft]
|
||||
C2 --> D2[releaseInvalidDraft]
|
||||
end
|
||||
|
||||
D1 --> E[INSUFFICIENT_EVIDENCE FALLBACK<br/>前提 hasObservedFacts]
|
||||
D2 --> E
|
||||
```
|
||||
|
||||
| | `controlledExecution` | `recoverInvalidDraft` |
|
||||
|--|----------------------|------------------------|
|
||||
| 时机 | loop **中途** | loop **结束后** |
|
||||
| 输入 | `Throwable` cause 链 | `DiagnosisAgentOutputException` |
|
||||
| 成功产物 | `Execution.stopped` | 直接 `DiagnosisExecutionResult(FALLBACK)` |
|
||||
| 发布入口 | `releaseControlledStop` | `releaseInvalidDraft` |
|
||||
| 共同点 | 都依赖 **可发布 observedFacts**;都 **fail closed** |
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码索引
|
||||
|
||||
| 组件 | 路径 |
|
||||
|------|------|
|
||||
| Model 拦截 | `harness/agent/HarnessModelInterceptor.java` |
|
||||
| Tool 拦截 | `harness/agent/HarnessToolInterceptor.java` |
|
||||
| 工具边界 | `harness/tool/boundary/ToolBoundary.java` |
|
||||
| 受控停止转换 | `harness/agent/DiagnosisAgentUseCase#controlledExecution` |
|
||||
| 坏 Draft 恢复 | `harness/application/executor/DiagnosisChatExecutor#recoverInvalidDraft` |
|
||||
| 非法 draft 发布 | `harness/release/DiagnosisReleaseUseCase#releaseInvalidDraft` |
|
||||
| 受控停止发布 | `DiagnosisReleaseUseCase#releaseControlledStop` |
|
||||
| 应用失败出口 | `harness/application/ChatApplicationUseCase` |
|
||||
| 错误码注释 | `ChatFailureCode` / `ReleaseOutcome` / `FallbackType` / `RunState` / `DiagnosisStopReason` / `ToolBoundaryErrorCode` 等 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 阅读检查清单
|
||||
|
||||
1. 这个失败是 **还在 loop 里**,还是 **已经穿出 agent.call**?
|
||||
2. 是 **资源/取消**(Core),还是 **收集收敛**(Progress),还是 **输出契约**(Draft Kind)?
|
||||
3. 最终有没有 **`observedFacts`**?有才能谈 FALLBACK。
|
||||
4. `RunState` 与 `ReleaseOutcome` 是否一致理解(预算耗尽 ≠ 一定 FAILED)?
|
||||
5. Tool 错误是 **observation** 还是 **异常穿出**?不要默认「有 error 就崩 run」。
|
||||
|
||||
---
|
||||
|
||||
## 12. 一句话总结
|
||||
|
||||
> **Loop 内:能安全回注的变成 observation,必须停收集或硬预算的穿出异常。**
|
||||
> **Loop 边界:controlledExecution 把受控停止收成 stopped。**
|
||||
> **Loop 外:坏 draft 用 recoverInvalidDraft,只认 observedFacts 做降级。**
|
||||
> **全程 fail closed:没有可验证事实,就不发「看起来友好」的假成功。**
|
||||
@@ -0,0 +1,165 @@
|
||||
# Harness 执行控制笔记:终态检查与取消广播
|
||||
|
||||
**用途**:面试复习用。回答"一次 Run 的执行如何被控制、取消如何生效、为什么是协作式"。
|
||||
**代码基线**:`com.superbiz.agent.harness.core` + `guard/semantic/GuardModelCall` + `tool/mysql/JdbcMysqlReadOnlyExecutor`
|
||||
**配套**:[RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md)
|
||||
|
||||
## 1. 一句话核心
|
||||
|
||||
> 执行控制由三个句柄组成:RunBudget 管"还能不能花"、RunCancellation 管"要不要停"、RunLifecycle 管"最终怎么定"。判断停止的机制有两套:**checkActive 轮询检查点**(读终态)和 **onCancel 订阅广播**(推送中断)——前者让"到了检查点的调用"被拒绝,后者让"正在阻塞的操作"被实时打断。
|
||||
|
||||
## 2. 执行控制三件套
|
||||
|
||||
| 句柄 | 回答的问题 | 关键机制 | 本质 |
|
||||
|---|---|---|---|
|
||||
| RunBudget | 还能不能继续消耗 | 调用前预扣,超限抛异常 | 门禁(止损) |
|
||||
| RunCancellation | 是否要求停止 | first-reason-wins + 回调广播 | 事件(信号) |
|
||||
| RunLifecycle | 最终哪个终态生效 | first-terminal-wins(CAS) | 事实(结果) |
|
||||
|
||||
**预扣 vs 记账 vs 落定**:预算在调用前拦,取消在运行中广播,终态在结束时定死。
|
||||
|
||||
## 3. checkActive:三层闸门(轮询)
|
||||
|
||||
`DiagnosisHarnessCore.checkActive` 在**每次模型/Tool 调用前**执行,顺序固定:
|
||||
|
||||
```java
|
||||
① termination 已存在 → 抛 RunAbortedException // 已终态,无论什么原因
|
||||
② deadline 已过 → finish(TIMED_OUT) + cancel(DEADLINE_EXCEEDED) + 抛异常
|
||||
// 超时是主动动作:自己写终态、自己广播,不是等别人来
|
||||
③ cancellation.isCancelled() → 抛 RunAbortedException
|
||||
```
|
||||
|
||||
- 调用点:`beforeModelCall`(每轮模型)、`beforeToolCall`(每次 Tool)、`reserveRunBytes`(canonical 体积)
|
||||
- 它是**轮询**:只在检查点生效。正在阻塞的操作(模型等待、SQL 查询)不会自己撞上它。
|
||||
|
||||
## 4. termination:终结事实快照
|
||||
|
||||
`RunLifecycle` 持有 `AtomicReference<RunTermination>`:
|
||||
|
||||
```java
|
||||
record RunTermination(RunState state, String reason, Instant completedAt)
|
||||
// 构造校验:state 必须 isTerminal(),reason 非空
|
||||
// null = 还在 RUNNING;非 null = 已终结,不可变
|
||||
```
|
||||
|
||||
- 终态只有 5 个:`SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED`(`RUNNING` 非终态)
|
||||
- 写入后永远定格,只能靠 CAS 换整个引用 → first-terminal-wins 的物理基础
|
||||
- checkActive 第一道闸就是读它
|
||||
|
||||
## 5. 两个正交的 CAS
|
||||
|
||||
| 门 | 保护什么 | 语义 |
|
||||
|---|---|---|
|
||||
| `RunCancellation.reason`(AtomicReference) | **原因**:谁要求停、为什么停 | first-reason-wins |
|
||||
| `RunLifecycle.termination`(AtomicReference) | **结果**:最终终态 | first-terminal-wins |
|
||||
|
||||
```java
|
||||
// cancel() 两件事:写原因(CAS)+ 遍历 callbacks 广播
|
||||
public boolean cancel(RunCancellationReason reason) {
|
||||
if (!this.reason.compareAndSet(null, reason)) return false; // first-reason-wins
|
||||
callbacks.forEach(...); // 广播给订阅者
|
||||
return true;
|
||||
}
|
||||
|
||||
// finish() 一件事:写终态(CAS)
|
||||
public boolean finish(RunState state, String reason) {
|
||||
return termination.compareAndSet(null, new RunTermination(state, reason, now));
|
||||
}
|
||||
```
|
||||
|
||||
**为什么不能合并**:
|
||||
- 取消是"意图/原因"(可被多线程同时请求、可被订阅),终态是"结果/事实"(只读、不可变)
|
||||
- 原因→终态是**多对一**映射:`DEADLINE_EXCEEDED→TIMED_OUT`、`INTERNAL_FAILURE→FAILED`、`CLIENT_DISCONNECTED/USER_REQUESTED→CANCELLED`
|
||||
- 完成路径(`completeSuccess`/`completeFailure`)根本不经过 cancel;run 已终态时取消请求被拒绝(不翻案)
|
||||
|
||||
## 6. 取消广播:为什么必须有它(推送 vs 轮询)
|
||||
|
||||
只设置终态,只能让**下一次 checkActive** 拒绝——正在阻塞的操作不会自己醒来。广播通过 **onCancel 回调**直接打断阻塞中的操作。
|
||||
|
||||
代码里真实的订阅者只有三处:
|
||||
|
||||
| 订阅者 | 回调动作 | 打断机制 |
|
||||
|---|---|---|
|
||||
| Core `startRun` | `lifecycle.finish(terminalState(reason))` | 终态联动 |
|
||||
| `GuardModelCall` | `future.cancel(true)` | 线程 interrupt |
|
||||
| `JdbcMysqlReadOnlyExecutor` | `statement.cancel()` | JDBC 协议取消 |
|
||||
|
||||
## 7. 打断机制:两种物理中断
|
||||
|
||||
**机制一:Java 线程中断(`Future.cancel(true)`)**
|
||||
```java
|
||||
Future<String> future = executor.submit(() -> invoke(...)); // Guard 任务在独立线程池
|
||||
context.cancellation().onCancel(ignored -> future.cancel(true)); // 订阅
|
||||
return future.get(timeout, TimeUnit.NANOSECONDS); // 业务线程阻塞等待
|
||||
```
|
||||
取消线程执行回调 → `future.cancel(true)` → 向执行任务的线程发 `Thread.interrupt()` → 目标线程若阻塞在可中断等待则立刻抛 `InterruptedException` / `CancellationException` 醒来 → catch 后 `checkActive` → `RunAbortedException`。
|
||||
|
||||
**机制二:JDBC 协议取消(`Statement.cancel()`)**
|
||||
```java
|
||||
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
|
||||
context.cancellation().onCancel(ignored -> cancel(statementRef.get())); // 订阅
|
||||
... executeQuery() ...
|
||||
finally { statementRef.set(null); }
|
||||
```
|
||||
取消线程 → `statement.cancel()` → 向 MySQL 服务器发取消请求 → 服务器终止查询 → 客户端 `executeQuery` 抛 `SQLException` 醒来。不走线程中断,走数据库协议,更"物理"。
|
||||
|
||||
**细节**:
|
||||
- `statementRef` 用 AtomicReference 包:回调可能在执行前/中/后触发,`finally` 里 `set(null)`,读到 null 说明已结束、跳过 cancel
|
||||
- `future.cancel()` 幂等,对已完成 Future 调用无害,Guard 侧无需此保护
|
||||
- 被打断不是"裸死":Guard catch `CancellationException` → `checkActive`;MySQL 抛 `SQLException` → ToolBoundary 记 ERROR——**醒来后仍走统一状态机**,取消不会产生绕过 Harness 的野异常
|
||||
|
||||
## 8. 协作式取消的精确边界
|
||||
|
||||
能否推送中断,取决于 **Harness 是否持有该调用的执行句柄**:
|
||||
|
||||
| 调用 | 谁发起 | Harness 有句柄吗 | 取消时 |
|
||||
|---|---|---|---|
|
||||
| Agent 模型调用 | 框架 ReAct 内部 | 无(拦截器只环绕) | 只能等下一次 checkActive 轮询 |
|
||||
| Guard 模型调用 | Harness `executor.submit` | 有 Future | `future.cancel(true)` 推送中断 |
|
||||
| MySQL 查询 | Harness 自己执行 | 有 Statement | `statement.cancel()` 推送中断 |
|
||||
|
||||
> "协作式" = 愿意被打断的(订阅了 onCancel 且持有句柄)实时打断;框架持有的 Agent loop 物理上无法打断,只能等检查点。但无论哪种,最终结果都受 first-terminal-wins 保护。
|
||||
|
||||
## 9. 为什么"先 finish 再 cancel"(二次 finish 无害)
|
||||
|
||||
`exhaustBudget` / 超时路径都执行"自己设终态 + 自己广播":
|
||||
|
||||
```java
|
||||
lifecycle.finish(BUDGET_EXHAUSTED, ...); // ① 先固化事实(主操作,不依赖回调)
|
||||
cancellation.cancel(BUDGET_EXHAUSTED); // ② 再广播信号(副作用)
|
||||
```
|
||||
|
||||
- cancel 触发回调 → 回调里再次 `finish` → **CAS 失败返回 false** → 无害、被静默吸收
|
||||
- 顺序意义:终态不依赖回调注册/执行;即使回调异常或重复触发,终态都已正确
|
||||
- 两个 CAS 各自 first-wins,最终状态永远一致(见下表,任何组合都无害):
|
||||
|
||||
| finish CAS | cancel CAS | 结果 |
|
||||
|---|---|---|
|
||||
| 成功 | 成功 | 正常 |
|
||||
| 成功 | 失败 | 终态正确,广播已由先前 cancel 触发过 |
|
||||
| 失败 | 成功 | 已有更早终态,回调里 finish 失败无害 |
|
||||
| 失败 | 失败 | 早已终结,本次调用本就不该发生 |
|
||||
|
||||
## 10. 面试话术(三段式)
|
||||
|
||||
**执行控制**:
|
||||
> 执行控制由预算、取消、终态三个句柄组成,统一走 Core 的检查链:每轮模型或 Tool 调用前先 checkActive——终态存在就拒绝,超时就主动写 TIMED_OUT 并广播取消,已取消就拒绝;然后预算预扣,超限时把 BUDGET_EXHAUSTED 固化为终态并广播取消。预算管"能不能花",取消管"要不要停",终态管"最终怎么定"。
|
||||
|
||||
**取消广播**:
|
||||
> 取消是协作式的。取消信号可能由容器线程注入(SseEmitter 断连回调调 core.cancel),CAS 写原因后同步遍历订阅者回调:Guard 模型调用中断自己的 Future、MySQL 中断自己的 Statement,让业务线程从阻塞中立刻醒来,再在下一个检查点被 RunAbortedException 拒绝。能否推送中断取决于 Harness 是否持有该调用的句柄——自己提交的调用(Guard/MySQL)能打断,框架持有的 Agent 模型调用只能等下一次 checkActive。
|
||||
|
||||
**为什么两个 CAS**:
|
||||
> cancel 和 finish 是两条正交的通道:cancel 传原因和广播信号,finish 落最终事实。取消必须走 cancel 是因为要保留原因维度、要广播给运行中的组件;但终态又必须由 finish 直接保证,不能依赖回调。所以预算耗尽时两个都调——事实先定,信号随后,重复写终态被 CAS 吸收。
|
||||
|
||||
## 11. 代码位置索引
|
||||
|
||||
| 内容 | 位置 |
|
||||
|---|---|
|
||||
| RunContext 九成员 | `harness/core/RunContext.java` |
|
||||
| checkActive / startRun / exhaustBudget | `harness/core/DiagnosisHarnessCore.java` |
|
||||
| 终态 CAS | `harness/core/RunLifecycle.java` + `RunTermination.java` |
|
||||
| 原因 CAS + 广播 | `harness/core/RunCancellation.java` |
|
||||
| 预算预扣/记账 | `harness/core/RunBudget.java` + `RunCapacityCounter.java` |
|
||||
| Guard 模型取消订阅 | `harness/guard/semantic/GuardModelCall.java:58` |
|
||||
| MySQL 查询取消订阅 | `harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java:52` |
|
||||
| 断连取消入口 | `controller/sse/ChatSseSession.java:44` → `harness/application/ChatApplicationUseCase.java:340` |
|
||||
@@ -0,0 +1,440 @@
|
||||
# Harness 生命周期与状态
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**状态**:当前实现口径
|
||||
**术语前置**:[CONTEXT.md](CONTEXT.md)
|
||||
|
||||
## 1. 先澄清:系统不存在一条包含所有状态的总状态机
|
||||
|
||||
当前 Harness 有多组正交状态:
|
||||
|
||||
- RunState 控制内存执行终态;
|
||||
- ChatApplicationStatus 表示用户可见处理阶段;
|
||||
- InvocationStatus 表示单次 Tool invocation 生命周期;
|
||||
- EvidenceStatus 表示 Tool 客观结果;
|
||||
- CollectionState 表示能否继续收集证据;
|
||||
- StopReason 表示停止收集的内部原因;
|
||||
- SemanticVerdict 表示结论支持度;
|
||||
- ReleaseOutcome 表示最终发布结果;
|
||||
- SSE Session State 表示连接能否继续发送。
|
||||
|
||||
它们在一次请求中并行演进,只在少数边界发生映射。生命周期文档的目标不是把它们合并,而是说明谁先发生、由谁拥有、在哪里汇合。
|
||||
|
||||
## 2. 一次 Run 的主时间线
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client / SSE
|
||||
participant A as ChatApplicationUseCase
|
||||
participant H as Harness Core
|
||||
participant R as Intent Router
|
||||
participant D as Diagnosis Runtime
|
||||
participant L as Release Pipeline
|
||||
participant P as Persistence / Trace
|
||||
|
||||
C->>A: query + optional sessionId
|
||||
A->>A: 读取 routing history / PreviousTurn
|
||||
A->>H: startRun(sessionId)
|
||||
H-->>A: RUNNING RunContext
|
||||
A->>P: diagnosis_run=RUNNING + RUN_STARTED
|
||||
A-->>C: metadata(sessionId, runId)
|
||||
|
||||
A->>R: route(query, bounded history)
|
||||
R-->>A: IntentType
|
||||
|
||||
alt SYSTEM_CHAT
|
||||
A->>A: 单轮受控模型回答
|
||||
else KNOWLEDGE_QUERY
|
||||
A->>A: 一次 RAG + 单轮答案
|
||||
else DIAGNOSIS
|
||||
A->>D: Agent ReAct / Tool / Progress
|
||||
D-->>A: Draft 或 Controlled Stop
|
||||
A->>L: release(Draft/Progress/StopReason)
|
||||
L-->>A: SUCCESS Draft 或 SafeFallback
|
||||
end
|
||||
|
||||
alt 正常完成
|
||||
A->>H: completeSuccess(预算 Fallback 特例除外)
|
||||
A->>P: 持久化 release outcome / safe content / usage
|
||||
A->>P: RUN_FINISHED
|
||||
A-->>C: content + done
|
||||
else 失败
|
||||
A->>H: completeFailure 或读取既有终态
|
||||
A->>P: 持久化 FAILED/CANCELLED
|
||||
A->>P: RUN_FINISHED
|
||||
A-->>C: failure + done(FAILED)
|
||||
end
|
||||
```
|
||||
|
||||
创建顺序很重要:Application 先读取会话上下文,再创建 RunContext、持久化 RUNNING、记录 RUN_STARTED,之后才进入路由和执行。SSE 的 metadata 在 `onStarted` 中发布 exact sessionId/runId,后续结果必须匹配这组身份。
|
||||
|
||||
## 3. Run 生命周期
|
||||
|
||||
### 3.1 状态
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> RUNNING
|
||||
RUNNING --> SUCCESS: 正常路径完成
|
||||
RUNNING --> FAILED: 不可恢复内部失败
|
||||
RUNNING --> CANCELLED: 客户端断开或用户取消
|
||||
RUNNING --> TIMED_OUT: deadline 到达
|
||||
RUNNING --> BUDGET_EXHAUSTED: 任一硬预算超限
|
||||
|
||||
SUCCESS --> [*]
|
||||
FAILED --> [*]
|
||||
CANCELLED --> [*]
|
||||
TIMED_OUT --> [*]
|
||||
BUDGET_EXHAUSTED --> [*]
|
||||
```
|
||||
|
||||
`RunLifecycle.finish` 使用原子 compare-and-set:只有从“尚无 termination”到某个终态的第一次转换成功。所有终态都不可再次转换。
|
||||
|
||||
### 3.2 状态含义
|
||||
|
||||
| RunState | 含义 | 常见来源 |
|
||||
|---|---|---|
|
||||
| `RUNNING` | 尚未产生 RunTermination | `startRun` 后默认状态 |
|
||||
| `SUCCESS` | Application 已形成正常可持久化结果 | 正常内容,或非预算类 SafeFallback |
|
||||
| `FAILED` | 不可恢复执行失败 | 路由不可用、模型失败、无安全进展的非法 Draft 等 |
|
||||
| `CANCELLED` | 协作取消已成为第一个终态 | 客户端断开、用户请求 |
|
||||
| `TIMED_OUT` | `checkActive` 发现 deadline 已到 | 模型/Tool/Guard 边界前后检查 |
|
||||
| `BUDGET_EXHAUSTED` | 模型、Tool、Token 或 bytes 预算超限 | `DiagnosisHarnessCore.exhaustBudget` |
|
||||
|
||||
### 3.3 RunState 与 ReleaseOutcome 不一一对应
|
||||
|
||||
最重要的反例是 Fallback:
|
||||
|
||||
- 证据不足、缺少上下文、语义不支持等正常降级,RunState 最终为 `SUCCESS`,ReleaseOutcome 为 `FALLBACK`;
|
||||
- 预算耗尽后已经有安全 ProgressSnapshot,RunState 保持 `BUDGET_EXHAUSTED`,但 ReleaseOutcome 可以为 `FALLBACK`;
|
||||
- 超时或预算耗尽且无法形成安全内容时,RunState 分别保持 `TIMED_OUT / BUDGET_EXHAUSTED`,ReleaseOutcome 映射为 `FAILED`。
|
||||
|
||||
所以不能从 ReleaseOutcome 反推出精确 RunState,也不能把 `FALLBACK` 当作 RunState。
|
||||
|
||||
## 4. 取消生命周期
|
||||
|
||||
`RunCancellation` 使用 first-reason-wins。取消原因包括:
|
||||
|
||||
| Reason | RunState 映射 |
|
||||
|---|---|
|
||||
| `CLIENT_DISCONNECTED` | `CANCELLED` |
|
||||
| `USER_REQUESTED` | `CANCELLED` |
|
||||
| `DEADLINE_EXCEEDED` | `TIMED_OUT` |
|
||||
| `BUDGET_EXHAUSTED` | `BUDGET_EXHAUSTED` |
|
||||
| `INTERNAL_FAILURE` | `FAILED` |
|
||||
|
||||
生命周期和取消句柄互相配合:取消回调尝试完成 RunLifecycle;deadline 和预算路径也会先确定终态,再触发取消阻止后续工作。
|
||||
|
||||
取消语义是协作式的:
|
||||
|
||||
1. 后续模型、Tool、Guard 边界调用 `checkActive` 时立即失败;
|
||||
2. SSE 断开后不再发送 content;
|
||||
3. first-terminal-wins 阻止迟到成功覆盖 CANCELLED/TIMED_OUT;
|
||||
4. 已经进入 Provider 的同步调用是否物理停止,取决于底层客户端,Harness 不作虚假保证。
|
||||
|
||||
## 5. Application 阶段不是生命周期状态
|
||||
|
||||
`ChatApplicationStatus` 用于 SSE status 事件:
|
||||
|
||||
```text
|
||||
ROUTING
|
||||
SYSTEM_RESPONDING
|
||||
KNOWLEDGE_SEARCHING
|
||||
KNOWLEDGE_ANSWERING
|
||||
DIAGNOSIS_RUNNING
|
||||
SAFETY_VALIDATING
|
||||
```
|
||||
|
||||
这些值只是用户可见的处理阶段:
|
||||
|
||||
- 不是严格完备的状态机;
|
||||
- 不表示终态;
|
||||
- 不持有取消或预算;
|
||||
- 不保证每个 Run 都经过全部阶段。
|
||||
|
||||
例如 Diagnosis Run 通常经过 `ROUTING -> DIAGNOSIS_RUNNING -> SAFETY_VALIDATING`,System Chat 则经过 `ROUTING -> SYSTEM_RESPONDING`。
|
||||
|
||||
## 6. Diagnosis Agent 执行生命周期
|
||||
|
||||
`DiagnosisAgentExecution` 只有两种合法形态,不额外定义一套枚举:
|
||||
|
||||
| 形态 | 字段 | 含义 |
|
||||
|---|---|---|
|
||||
| Completed | `draft != null, stopReason=null` | Agent 输出严格合法 DiagnosisDraft |
|
||||
| Controlled Stop | `draft=null, stopReason != null` | Tool loop 因信息饱和、预算或协议错误受控停止 |
|
||||
|
||||
其他情况通过异常表达:
|
||||
|
||||
- 空 Draft;
|
||||
- 非法 JSON;
|
||||
- Schema 不合法;
|
||||
- 模型调用失败;
|
||||
- RunAborted。
|
||||
|
||||
Draft 合同失败不是 DiagnosisStopReason。非法 Draft 会被丢弃;只有异常携带的 ProgressSnapshot 已包含可验真 facts,Application 才允许 Release 生成过程型 Fallback。
|
||||
|
||||
## 7. 单次 Tool Invocation 生命周期
|
||||
|
||||
### 7.1 状态转换
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PREFLIGHT
|
||||
PREFLIGHT --> REJECTED: invalid run/id/auth/readonly/request/budget/store
|
||||
PREFLIGHT --> PROJECTING: canonical begin 成功
|
||||
PROJECTING --> READY: backend + projection + store 成功
|
||||
PROJECTING --> ERROR: execution/projection/size/budget/store error
|
||||
READY --> [*]
|
||||
ERROR --> [*]
|
||||
REJECTED --> [*]
|
||||
```
|
||||
|
||||
`PREFLIGHT` 和 `REJECTED` 是本文用于解释流程的阶段,不是 `InvocationStatus` 枚举值。canonical record 只有:
|
||||
|
||||
- `PROJECTING`:已创建,尚未形成可引用结果;
|
||||
- `READY`:执行和投影完成,可以被当前 Run 引用;
|
||||
- `ERROR`:调用失败,不可引用。
|
||||
|
||||
部分 preflight 失败发生在 canonical begin 之前,因此可能只有 `ToolBoundaryResult.ERROR` 和审计事件,没有 canonical ERROR record。
|
||||
|
||||
### 7.2 EvidenceStatus 是另一条轴
|
||||
|
||||
| InvocationStatus | EvidenceStatus | 语义 |
|
||||
|---|---|---|
|
||||
| `PROJECTING` | `null` | 未完成 |
|
||||
| `READY` | `EVIDENCE_FOUND` | 技术完成,存在候选内容 |
|
||||
| `READY` | `NO_EVIDENCE` | 技术完成,当前 scope 为空 |
|
||||
| `ERROR` | `ERROR` | 技术失败 |
|
||||
|
||||
`READY + EVIDENCE_FOUND` 仍不表示证据足以支持结论;后续还要经过 InformationGain、EvidenceGuard 和 SemanticGuard。
|
||||
|
||||
## 8. 信息收集生命周期
|
||||
|
||||
### 8.1 Collection State
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED / 清零 no-gain
|
||||
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
|
||||
COLLECTING --> SATURATED: 连续 NO_GAIN 达阈值
|
||||
COLLECTING --> SATURATED: 连续 progress protocol violation 达阈值
|
||||
SATURATED --> SATURATED: 终态,不再允许新 evidence Tool
|
||||
```
|
||||
|
||||
`SATURATED` 只表示证据收集不再允许继续。它不是整个 Run 的终态,Agent 仍有一次机会输出 Draft,之后进入 Release。
|
||||
|
||||
预算超限不会把 CollectionState 改成 SATURATED。它将 RunState 置为 `BUDGET_EXHAUSTED`,并把内部 DiagnosisStopReason 标记为 `BUDGET_LIMIT_REACHED`。
|
||||
|
||||
### 8.2 InformationGain
|
||||
|
||||
- `NO_EVIDENCE` 和完全重复的 `tool_name + normalized_scope` 由 Harness 产生 `NO_GAIN`;
|
||||
- 其他成功非空结果由 Diagnosis Agent 评价 `GAINED / NO_GAIN`;
|
||||
- `GAINED` 清零连续 NO_GAIN;
|
||||
- 技术失败和 progress 协议错误不产生 InformationGain。
|
||||
|
||||
### 8.3 StopReason
|
||||
|
||||
| DiagnosisStopReason | 触发条件 | 对 CollectionState 的影响 |
|
||||
|---|---|---|
|
||||
| `INFORMATION_SATURATED` | 连续 NO_GAIN 达阈值 | 进入 SATURATED |
|
||||
| `PROGRESS_PROTOCOL_VIOLATED` | 连续协议错误达阈值 | 进入 SATURATED |
|
||||
| `BUDGET_LIMIT_REACHED` | RunBudget 超限 | 不要求进入 SATURATED;Run 已终止 |
|
||||
|
||||
STOP_REQUIRED 是否已经交付由独立 boolean 记录,它不是第四种 CollectionState。一次指令交付后仍请求 Tool,会抛出受控停止异常。
|
||||
|
||||
## 9. Guard 与 Release 生命周期
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
I["DiagnosisAgentExecution"] --> D{"有合法 Draft?"}
|
||||
D -->|"否,受控停止"| PS["验证 ProgressSnapshot"]
|
||||
D -->|"是,无 conclusion"| NC["验证已有 Tool references"]
|
||||
D -->|"是,有 conclusion"| EG["EvidenceGuard initial"]
|
||||
|
||||
EG --> EV{"evidence valid?"}
|
||||
EV -->|"否"| ER["EvidenceRepair once"]
|
||||
ER --> RE["EvidenceGuard recheck"]
|
||||
EV -->|"是"| SG["SemanticGuard"]
|
||||
RE -->|"valid"| SG
|
||||
RE -->|"invalid"| EF["EVIDENCE_VALIDATION_FAILED"]
|
||||
|
||||
SG -->|"SUPPORTED"| SU["ReleaseOutcome.SUCCESS"]
|
||||
SG -->|"UNSUPPORTED"| SF["SEMANTIC_UNSUPPORTED"]
|
||||
SG -->|"unavailable"| UF["SEMANTIC_UNAVAILABLE"]
|
||||
|
||||
NC -->|"有 progress"| IF["INSUFFICIENT_EVIDENCE"]
|
||||
NC -->|"无 progress,有 missing_info"| MF["MISSING_REQUIRED_CONTEXT"]
|
||||
NC -->|"两者都无"| FAIL["FAILED"]
|
||||
PS -->|"有 verified facts"| IF
|
||||
PS -->|"无 verified facts"| FAIL
|
||||
|
||||
EF --> FB["ReleaseOutcome.FALLBACK"]
|
||||
SF --> FB
|
||||
UF --> FB
|
||||
IF --> FB
|
||||
MF --> FB
|
||||
```
|
||||
|
||||
`DiagnosisReleaseResult` 只表达 `SUCCESS` 或 `FALLBACK`。真正不可恢复的 `FAILED / CANCELLED` 由 Chat Application 异常路径映射。
|
||||
|
||||
## 10. RunState、ReleaseOutcome 和 FallbackType 映射
|
||||
|
||||
| 场景 | RunState | ReleaseOutcome | FallbackType / 内容 |
|
||||
|---|---|---|---|
|
||||
| 有结论且 Guards 通过 | `SUCCESS` | `SUCCESS` | Diagnosis report |
|
||||
| 缺少必要上下文,零 Tool 合法结束 | `SUCCESS` | `FALLBACK` | `MISSING_REQUIRED_CONTEXT` |
|
||||
| 有限检查后证据不足 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| 信息饱和后有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| 协议停止后有安全进展 | `SUCCESS` | `FALLBACK` | `INSUFFICIENT_EVIDENCE` |
|
||||
| EvidenceGuard 最终失败 | `SUCCESS` | `FALLBACK` | `EVIDENCE_VALIDATION_FAILED` |
|
||||
| SemanticGuard 判定不支持 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNSUPPORTED` |
|
||||
| SemanticGuard 技术不可用 | `SUCCESS` | `FALLBACK` | `SEMANTIC_UNAVAILABLE` |
|
||||
| 预算耗尽但有安全进展 | `BUDGET_EXHAUSTED` | `FALLBACK` | 当前发布 `INSUFFICIENT_EVIDENCE` |
|
||||
| 超时,无法形成安全内容 | `TIMED_OUT` | `FAILED` | failure |
|
||||
| 预算耗尽且无安全进展 | `BUDGET_EXHAUSTED` | `FAILED` | failure |
|
||||
| 不可恢复内部失败 | `FAILED` | `FAILED` | failure |
|
||||
| 客户端断开 | `CANCELLED` | `CANCELLED` | 不公开 CANCELLED done |
|
||||
|
||||
这张表解释了为什么不能只问“最后 status 是什么”。必须先确定是在问内存执行、发布结果还是 Fallback 原因。
|
||||
|
||||
## 11. SSE 连接生命周期
|
||||
|
||||
`ChatSseSession` 有自己的连接状态机:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> NEW
|
||||
NEW --> OPEN: onStarted / metadata
|
||||
NEW --> DISCONNECTED: 客户端提前断开
|
||||
OPEN --> OPEN: status events
|
||||
OPEN --> TERMINAL: content + done
|
||||
OPEN --> TERMINAL: failure + done(FAILED)
|
||||
OPEN --> DISCONNECTED: send failure / disconnect
|
||||
TERMINAL --> [*]
|
||||
DISCONNECTED --> [*]
|
||||
```
|
||||
|
||||
公开事件顺序是:
|
||||
|
||||
```text
|
||||
metadata -> status* -> content | failure -> done
|
||||
```
|
||||
|
||||
边界规则:
|
||||
|
||||
- content 最多发送一次;
|
||||
- result 的 sessionId/runId 必须匹配 metadata;
|
||||
- TERMINAL 或 DISCONNECTED 后拒绝迟到内容;
|
||||
- `ReleaseOutcome.CANCELLED` 不作为公开 done outcome;客户端已经断开时没有可靠发送目标;
|
||||
- 当前 `SseOutcome` 枚举未参与运行时协议,`ChatSseEvent.Done` 使用 `ReleaseOutcome`。
|
||||
|
||||
## 12. 持久化生命周期
|
||||
|
||||
### 12.1 创建
|
||||
|
||||
RunContext 创建后,Application 写入:
|
||||
|
||||
```text
|
||||
diagnosis_run.status = RUNNING
|
||||
session_id / run_id / query
|
||||
```
|
||||
|
||||
之后记录 intent。
|
||||
|
||||
### 12.2 完成映射
|
||||
|
||||
`JpaChatRunStore` 根据 ReleaseOutcome 映射数据库通用 status:
|
||||
|
||||
| ReleaseOutcome | diagnosis_run.status |
|
||||
|---|---|
|
||||
| `SUCCESS` | `SUCCESS` |
|
||||
| `FALLBACK` | `SUCCESS` |
|
||||
| `FAILED` | `FAILED` |
|
||||
| `CANCELLED` | `CANCELLED` |
|
||||
|
||||
这里的 `status=SUCCESS` 表示请求已被正常处理并形成安全内容,不表示一定有诊断 conclusion。
|
||||
|
||||
同时保存:
|
||||
|
||||
- `release_outcome`;
|
||||
- safe answer JSON;
|
||||
- 从 safe content 提取的 conclusion;
|
||||
- duration、Token、Agent step 数和实际 Tool call 数;
|
||||
- 仅在 `DIAGNOSIS + SUCCESS` 时保存 `published_result`。
|
||||
|
||||
Fallback 不进入下一轮 PreviousTurn。
|
||||
|
||||
### 12.3 当前可观测缺口
|
||||
|
||||
内存 `RunTermination.state/reason` 当前没有独立字段直接持久化。`RUN_FINISHED` 主要记录 ReleaseOutcome 和预算对账,精确的 TIMED_OUT/BUDGET_EXHAUSTED 原因需要结合异常路径和其他 Trace 事件判断。
|
||||
|
||||
因此数据库 `status`、ReleaseOutcome 和 Trace 都是必要观察面,任何一个都不是完整替代品。
|
||||
|
||||
## 13. Trace Timeline 如何对应生命周期
|
||||
|
||||
典型 Diagnosis SUCCESS:
|
||||
|
||||
```text
|
||||
RUN_STARTED
|
||||
ROUTING_ATTEMPT
|
||||
ROUTING_DECISION
|
||||
AGENT_MODEL_STEP
|
||||
TOOL_INVOCATION / TOOL_PROGRESS ...
|
||||
EVIDENCE_GUARD_INITIAL
|
||||
SEMANTIC_GUARD_ATTEMPT
|
||||
SEMANTIC_GUARD_DECISION
|
||||
RELEASE_DECISION SUCCESS
|
||||
RUN_FINISHED SUCCESS
|
||||
```
|
||||
|
||||
典型信息不足 Fallback:
|
||||
|
||||
```text
|
||||
RUN_STARTED
|
||||
ROUTING_ATTEMPT
|
||||
ROUTING_DECISION
|
||||
AGENT_MODEL_STEP
|
||||
TOOL_INVOCATION / TOOL_PROGRESS ...
|
||||
COLLECTION_STOP(可选)
|
||||
EVIDENCE_GUARD_INITIAL(无结论引用检查)
|
||||
RELEASE_DECISION FALLBACK
|
||||
RUN_FINISHED FALLBACK
|
||||
```
|
||||
|
||||
`TracePhase` 和 `TraceEventStatus` 用来组织 Timeline。它们描述事件发生在哪一阶段、该事件结果如何,不构成新的 Run 生命周期。
|
||||
|
||||
## 14. 并发和迟到结果规则
|
||||
|
||||
一次 Run 的终止安全依赖三层协作:
|
||||
|
||||
1. `RunLifecycle`:first-terminal-wins,终态不可覆盖;
|
||||
2. `DiagnosisHarnessCore.checkActive`:每个关键边界阻止终止后的新工作;
|
||||
3. `ChatSseSession`:TERMINAL/DISCONNECTED 后拒绝迟到 content。
|
||||
|
||||
这能保证逻辑上的“取消后不再发布”。它不等于强制终止底层线程或 Provider 计算;底层调用返回后仍要经过 active 和 SSE state 检查,迟到结果才会被丢弃。
|
||||
|
||||
## 15. 排障时应该先看哪个状态
|
||||
|
||||
| 问题 | 首先看 | 然后看 |
|
||||
|---|---|---|
|
||||
| 用户为什么收到 Fallback | `release_outcome + FallbackType` | RELEASE/EVIDENCE/SEMANTIC Trace |
|
||||
| Agent 为什么停止调用 Tool | `DiagnosisStopReason` | TOOL_PROGRESS、TOOL_REQUEST_REJECTED、COLLECTION_STOP |
|
||||
| Tool 为什么没有证据 | `InvocationStatus + EvidenceStatus` | canonical record 和 Tool audit error code |
|
||||
| Run 是超时还是预算耗尽 | 内存 termination(运行中)或相关 Trace/异常 | budget usage、collection stop、失败码 |
|
||||
| 数据库为什么 status=SUCCESS 但没有结论 | `release_outcome` | answer 的 content type / FallbackType |
|
||||
| 为什么没有 SSE done | `ChatSseSession.State` | client disconnect/send failure 和 Run cancellation |
|
||||
| 为什么下一轮没有上一轮上下文 | 是否 `DIAGNOSIS + ReleaseOutcome.SUCCESS + published_result` | PublishedResultPolicy |
|
||||
|
||||
## 16. 生命周期不变量
|
||||
|
||||
1. 一个 Run 只能有一个 RunTermination。
|
||||
2. 一个公开 SSE 最多有一次 content/failure 和一次 done。
|
||||
3. Tool Invocation 只有 READY 才能被引用。
|
||||
4. READY 必须同时拥有 `EVIDENCE_FOUND` 或 `NO_EVIDENCE`。
|
||||
5. `NO_EVIDENCE` 不能被提升为全局否定。
|
||||
6. SATURATED 后不再执行新的 evidence Tool。
|
||||
7. StopReason 不直接决定公开内容,必须经过 Release。
|
||||
8. 有 conclusion 才执行完整 EvidenceGuard/Repair/SemanticGuard 链。
|
||||
9. FALLBACK 是正常发布结果,不等于 RunState.FAILED。
|
||||
10. 数据库 status、ReleaseOutcome 和 RunState 不能互相替代。
|
||||
@@ -0,0 +1,374 @@
|
||||
# Harness 组件全景:职责、设计原因与边界
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**适用代码**:`src/main/java/com/superbiz/agent/harness`
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**配套主文**:[Harness设计-非确定性Agent的确定性控制边界.md](Harness设计-非确定性Agent的确定性控制边界.md)
|
||||
|
||||
> 本文是完整组件参考手册,不建议第一次接触 Harness 时顺序阅读。入门请先读 [Harness 阅读入口](README.md),需要逐组理解组件时使用[组件渐进式导读](components/README.md)。
|
||||
|
||||
## 1. 这份文档怎样定义“全部组件”
|
||||
|
||||
当前 `harness` 目录包含 10 个一级职责域、189 个 Java 源文件。它们并不都是独立运行的“服务”:
|
||||
|
||||
- **执行组件**拥有行为,例如 Core、Interceptor、Boundary、Guard、Release、Executor;
|
||||
- **端口与适配器**隔离框架、Redis、JPA、JDBC 和业务 Tool;
|
||||
- **状态与契约类型**固定跨组件语言,防止字符串协议漂移;
|
||||
- **Limits、Prompt 和异常类型**把边界配置与失败语义显式化。
|
||||
|
||||
因此本篇先解释 10 个职责域为什么存在,再列出每个生产类型。判断某个类应该放在哪里时,只问三个问题:它拥有什么状态、它能作出什么决定、它绝不能决定什么。
|
||||
|
||||
## 2. 组件地图
|
||||
|
||||
| 职责域 | 文件数 | 解决的问题 | 核心组件 |
|
||||
|---|---:|---|---|
|
||||
| `application` | 34 | 谁创建 Run、路由请求、持久化和映射公开结果 | `ChatApplicationUseCase`、三个 Executor、`ChatRunStore` |
|
||||
| `core` | 14 | deadline、取消、预算和唯一终态由谁拥有 | `DiagnosisHarnessCore`、`RunContext`、`RunBudget`、`RunLifecycle` |
|
||||
| `agent` | 17 | 如何把框架 ReAct 接入 Harness,而不复制 ReAct | `DiagnosisAgentUseCase`、Factory、Model/Tool Interceptor |
|
||||
| `progress` | 14 | 如何识别无增益、重复和协议空转 | `DiagnosisProgressTracker`、Projector、scope normalizer |
|
||||
| `tool` | 49 | Tool 如何安全执行、保存真相并只暴露必要内容 | `ToolBoundary`、Adapters、Projectors、Canonical Store、MySQL sandbox |
|
||||
| `guard` | 15 | 如何分开验证引用真实性和结论支持度 | `EvidenceGuard`、`SemanticGuard`、`GuardModelCall` |
|
||||
| `release` | 6 | 谁拥有最终 SUCCESS / FALLBACK 决策 | `DiagnosisReleaseUseCase`、`EvidenceRepair`、`SafeFallbackFactory` |
|
||||
| `retry` | 8 | 哪些失败允许重试、attempt 如何可见 | `HarnessRetryExecutor`、Policies、Failure taxonomy |
|
||||
| `audit` | 17 | 如何重放决策而不复制敏感正文 | Trace、Tool audit、Model ledger、Agent hook |
|
||||
| `contract` | 15 | 跨层公开语言如何保持类型化 | Draft、PublishedResult、Fallback、状态枚举 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
APP["application<br/>Run 与公开用例"] --> CORE["core<br/>执行不变量"]
|
||||
APP --> AGENT["agent<br/>ReAct 接入"]
|
||||
AGENT --> PROGRESS["progress<br/>收敛控制"]
|
||||
AGENT --> TOOL["tool<br/>证据边界"]
|
||||
APP --> RELEASE["release<br/>唯一发布"]
|
||||
RELEASE --> GUARD["guard<br/>真实性与支持度"]
|
||||
CORE --> RETRY["retry<br/>显式 attempt"]
|
||||
CORE -.-> AUDIT["audit<br/>可观测账本"]
|
||||
AGENT -.-> AUDIT
|
||||
TOOL -.-> AUDIT
|
||||
RELEASE -.-> AUDIT
|
||||
CONTRACT["contract<br/>类型化语言"] -.-> APP
|
||||
CONTRACT -.-> AGENT
|
||||
CONTRACT -.-> TOOL
|
||||
CONTRACT -.-> GUARD
|
||||
CONTRACT -.-> RELEASE
|
||||
```
|
||||
|
||||
## 3. Application:Run 的应用所有者
|
||||
|
||||
### 为什么需要
|
||||
|
||||
Core 只知道一次 Run 是否活跃,并不知道 HTTP、SSE、意图路由、数据库持久化和上一轮上下文。若这些职责塞进 Core,Harness 会变成业务工作流引擎;若散落在 Controller,则每个入口都可能产生不同的终态和 Fallback。
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `ChatApplicationUseCase` | 为一次请求建立唯一应用事务边界 | 解析 session、读取历史、创建 Run、路由、执行分支、落终态和安全输出 | 不做诊断推理,不自行构造诊断 Fallback |
|
||||
| `IntentRouter` | 路由也会消耗模型、超时并返回非法 JSON | 在有界输入、timeout 和显式 retry 下输出唯一 `IntentType` | 不执行业务 Tool,不生成最终回答 |
|
||||
| `SystemChatExecutor` | 系统问答不需要 ReAct,但仍必须受模型预算控制 | 单轮回答产品能力和闲聊 | 不声称查询了实时数据 |
|
||||
| `KnowledgeQueryExecutor` | 知识问答需要一次 RAG 和一次受控生成,但不需要完整诊断链 | 调用知识 Tool、校验 Tool result、生成带来源答案 | 首版不进入 SemanticGuard,不执行多 Tool 诊断 |
|
||||
| `DiagnosisChatExecutor` | Agent 执行和安全发布需要一个明确接合点 | 执行 Agent、处理合法停止/非法 Draft、调用 Release、映射公开内容 | 不重复 Guard 或 Release 决策 |
|
||||
| `ChatRunStore` / `JpaChatRunStore` | 内存 Run 状态与长期数据库状态职责不同 | 保存 Run 开始、intent、结果、预算摘要;读取安全上一轮 | 不保存 canonical raw;只允许安全发布结果进入 PreviousTurn |
|
||||
| `PublishedResultPolicy` | 直接复用上一轮完整结果会让上下文无限增长并传播失败内容 | 生成可持久化 PublishedResult 和有界 PreviousTurn | Fallback、失败、raw evidence 不进入下一轮 |
|
||||
|
||||
### 类型清单
|
||||
|
||||
| 类型 | 分类与功能 |
|
||||
|---|---|
|
||||
| `ChatApplicationRequest`、`ChatApplicationResult` | 应用入口和出口 DTO;固定 session、run、intent、outcome 和内容类型 |
|
||||
| `ChatApplicationContent`、`ChatContentType` | 公开内容的 sealed/typed 边界,避免任意对象直接发给 SSE |
|
||||
| `DiagnosisContent`、`KnowledgeContent`、`SystemChatContent`、`FallbackContent` | 四种公开内容载体;分别包装安全诊断、知识答案、系统回答和 Fallback |
|
||||
| `ChatApplicationStatus`、`ChatApplicationObserver` | 向 SSE 报告有界阶段,不泄露模型内部步骤 |
|
||||
| `ChatRunControl` | 只向入口暴露 exact session/run 和客户端断开取消能力 |
|
||||
| `ChatApplicationException`、`ChatFailureCode` | 把内部异常映射为稳定、可公开的失败语义 |
|
||||
| `IntentRouting`、`SystemChatOperation`、`KnowledgeQueryOperation`、`DiagnosisOperation` | 四个应用端口;使主用例不依赖具体模型或执行器 |
|
||||
| `DiagnosisExecutionResult` | Diagnosis 分支的内部返回,携带 outcome、content、published result 和预算已处理标记 |
|
||||
| `IntentRouterInput`、`IntentRouterLimits`、`IntentRouterPrompt`、`IntentRoutingException` | 路由输入、边界、Prompt 和失败类型 |
|
||||
| `KnowledgeQueryLimits`、`SingleTurnExecutorLimits` | 知识与单轮模型路径的输入、输出、timeout 上限 |
|
||||
| `ChatRunStore`、`RoutingHistory` | 持久化端口及最小路由历史 |
|
||||
| `PreviousTurnLimits`、`PublishedResultPolicy` | 安全历史的字段/大小限制与投影策略 |
|
||||
|
||||
## 4. Core:每个 Run 的执行不变量
|
||||
|
||||
### 为什么需要
|
||||
|
||||
模型、Tool、Guard 和 Application 都要检查取消、deadline 和预算。如果每层各自维护计数或终态,会出现多个真相源;如果依赖 ThreadLocal,则异步线程无法可靠继承。
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `DiagnosisHarnessCore` | 所有执行边界需要同一套 active / budget / terminal 规则 | 创建 RunContext,执行模型/Tool/Token/bytes 门禁,处理取消和终态 | 不持久化,不调用 Agent/Tool,不维护全局 Run Map |
|
||||
| `RunContext` | Run 身份和状态句柄必须一起显式传播 | 固定 sessionId、runId、deadline 及 per-run handles | record 结构不可变,不代表内部计数不变化 |
|
||||
| `RunLifecycle` | 成功、失败、取消可能竞态到达 | 原子 `compareAndSet` 实现 first-terminal-wins | 不映射公开 ReleaseOutcome |
|
||||
| `RunCancellation` | 取消原因和回调只能被第一个请求确定 | first-reason-wins,并通知 lifecycle/资源回调 | 不承诺强杀同步 Provider 请求 |
|
||||
| `RunBudget` | 多维预算必须原子地先检查再计数 | 模型、Tool、单 Tool、输入/输出/总 Token 和 bytes 计量 | 不判断信息是否有价值 |
|
||||
| `RunCapacityCounter` | bytes 可能由并发边界累计 | CAS 方式维护 Run 总容量 | 不负责字段级截断策略 |
|
||||
|
||||
### 类型清单
|
||||
|
||||
| 类型 | 分类与功能 |
|
||||
|---|---|
|
||||
| `RunBudgetLimits`、`RunBudgetUsage` | 预算配置和值快照;区分限制与已使用量 |
|
||||
| `BudgetKind`、`BudgetExceededException` | 明确指出耗尽的是模型、Tool、Token 还是 bytes |
|
||||
| `RunState`、`RunTermination` | 内存执行状态与不可变终止快照 |
|
||||
| `RunCancellationReason` | 客户端断开、用户请求、deadline、预算和内部失败的取消分类 |
|
||||
| `RunAbortedException` | 将已经确定的 RunTermination 穿过深层调用栈,不丢失终态 |
|
||||
|
||||
## 5. Agent:框架 ReAct 与 Harness 的接合层
|
||||
|
||||
### 为什么需要
|
||||
|
||||
业务需要框架原生 Tool Calling 和 ReAct loop,但框架默认并不知道项目的 RunContext、预算、审计、Tool 双视图和停止协议。接合层的目标是“拦截边界”,不是重新实现 Agent 循环。
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `DiagnosisAgentFactory` | 每个 Run 的 interceptor 和 metadata 不同 | 为当前 Run 创建 ReactAgent,注册 Tool callback、Prompt、Hook 和 interceptor | 不缓存跨 Run Agent 状态 |
|
||||
| `DiagnosisAgentUseCase` | 框架输入输出是字符串,业务要求严格 Draft 和 bytes 边界 | 序列化输入、显式注入 Run metadata、调用 Agent、严格解析 Draft、映射受控停止 | 不执行证据和语义校验 |
|
||||
| `HarnessModelInterceptor` | 每一轮 ReAct 模型调用都必须进入预算和 Token 账本 | 调用前 reserve,调用后记录 Provider usage 并再次检查 active | 不重试模型 |
|
||||
| `HarnessToolInterceptor` | 模型 Tool Call 中混有 Harness 进展协议和业务参数 | 校验 Envelope、上一轮增益、重复/饱和、调用 Tool、投影 observation、交付 STOP_REQUIRED | 不执行 backend,不复制 ToolBoundary 预算 |
|
||||
| `HarnessEvidenceTools` | Tool schema 必须由服务端原生注册,且业务 Tool 可选启用 | 注册 RAG/log/MySQL callback,严格解析通用 Envelope,桥接 Adapter | Prompt 不写死 Tool schema;未配置 MySQL 时不暴露死 Tool |
|
||||
| `ToolResultViewProjector` | canonical agent_result 仍含 Harness 控制字段 | 生成 Control View 和白名单 Model Observation | 不读取 raw response,不判断根因 |
|
||||
|
||||
### 类型清单
|
||||
|
||||
| 类型 | 分类与功能 |
|
||||
|---|---|
|
||||
| `DiagnosisAgentInput`、`DiagnosisAgentExecution` | Agent 用例输入,以及 Draft/ProgressSnapshot/stop reason 的执行结果 |
|
||||
| `DiagnosisAgentLimits` | query、previous turn、总输入和 Draft 的 UTF-8 bytes 上限 |
|
||||
| `DiagnosisAgentPrompt`、`DiagnosisDraftOutputSchema` | 最小职责 Prompt 与严格结构化输出 Schema |
|
||||
| `EvidenceToolInvoker` | Agent 层到具体 Adapter 的函数端口 |
|
||||
| `ParsedAgentToolCall` | 解包后的 previous observation、typed business input 和 JSON 参数 |
|
||||
| `ToolControlView` | Harness 消费的 evidence status、count、scope 等控制视图 |
|
||||
| `DiagnosisAgentLimitException` | Agent 输入或输出越界 |
|
||||
| `DiagnosisAgentOutputException` | 空、非法 JSON、Schema 不合格 Draft,并可携带安全 ProgressSnapshot |
|
||||
| `DiagnosisCollectionStoppedException` | STOP_REQUIRED 后仍请求 Tool 时,把受控停止穿出框架 loop |
|
||||
|
||||
## 6. Progress:从资源上限到正常收敛
|
||||
|
||||
### 为什么需要
|
||||
|
||||
预算只能阻止无限消耗,不能识别“连续查询没有推进诊断”。Progress 子系统只保存 Run 内最小控制状态,不复制完整证据。
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `DiagnosisProgressTracker` | 连续无增益、待评价调用和协议错误需要线程安全单一所有者 | 记录 completed scope、pending evaluation、NO_GAIN、协议错误、饱和和一次停止指令 | 不保存 raw/agent_result,不判断非空内容的业务价值 |
|
||||
| `ToolScopeNormalizer` | 字段顺序或无关格式不应绕过重复检测 | 将各 Tool typed input 规范化为稳定 scope | 首版不做自然语言语义去重 |
|
||||
| `DiagnosisProgressProjector` | 受控停止或非法 Draft 后仍需安全说明已检查内容 | 按 Tracker identity 回读 canonical READY 记录,生成有界事实、来源和限制 | 无法验真的记录直接排除,不输出 raw |
|
||||
| `DiagnosisProgressProjection` | Agent 用例不应依赖具体 Redis projector | 定义 Run 到安全快照的端口,并提供 empty 实现 | 不决定 Fallback 类型 |
|
||||
|
||||
### 类型清单
|
||||
|
||||
| 类型 | 分类与功能 |
|
||||
|---|---|
|
||||
| `InformationGain` | 仅 `GAINED / NO_GAIN`,避免引入含混质量等级 |
|
||||
| `DiagnosisCollectionState` | `COLLECTING / SATURATED`,只描述信息收集状态 |
|
||||
| `DiagnosisStopReason` | 区分信息饱和、预算限制和进展协议错误 |
|
||||
| `PreviousObservation` | 模型在下一次 Tool Call 回传上一轮 `tool_call_id + information_gain` |
|
||||
| `CompletedToolCall`、`ToolScopeIdentity` | 保存已完成调用的 identity 和规范化 scope,不保存 payload |
|
||||
| `DiagnosisProgressSnapshotState` | Tracker 的内部控制快照,含计数、pending ID 和停止指令状态 |
|
||||
| `DiagnosisProgressSnapshot` | Release 可消费的安全快照,含 verified sources、observed facts 和 limitations |
|
||||
| `ProgressProtocolViolationType`、`ProgressProtocolViolationException` | 缺字段、乱序评价、意外评价和非法 Envelope 的稳定分类 |
|
||||
|
||||
## 7. Tool:执行、真相、投影与后端安全
|
||||
|
||||
Tool 是文件最多的职责域,但可以按四层理解。
|
||||
|
||||
### 7.1 Boundary:所有 Tool 共用的确定性入口
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `ToolBoundary` | 每个 Adapter 自己实现授权、预算、store 和 audit 会产生漂移 | 统一 preflight、active、read-only、Tool budget、bytes、canonical 状态迁移和 audit | 不理解 Tool 业务内容;不做信息增益判断 |
|
||||
| `ToolCallRequestEnvelope` | 调用必须同时证明 Run、ID、Tool、参数、授权和只读意图 | Boundary 的内部调用信封 | 不等同于模型侧 progress Envelope |
|
||||
| `ToolExecutor` | Boundary 不依赖具体 backend | raw 执行函数端口 | 不投影结果 |
|
||||
| `ToolResultProjector` | raw 到 canonical agent_result 的逻辑因 Tool 而异 | 标准化、限制、计算客观 evidence status | 不判断结论支持度 |
|
||||
| `ToolBoundaryResult` | 只允许 READY 或 ERROR 离开 Boundary | 向上返回 ID、状态、agent result、evidence status 或稳定错误码 | PROJECTING 不对外暴露 |
|
||||
| `ProjectedToolResult` | projector 同时返回有界 agent result 与客观状态 | Boundary/store 的中间值 | 不是最终 Model Observation |
|
||||
| `ToolBoundaryErrorCode` | 不能把内部异常正文交给 Agent | 固定非法 ID、Run mismatch、未授权、非只读、超限、执行/投影/store 等错误 | 不包含敏感原因 |
|
||||
|
||||
### 7.2 Canonical Store:TTL 内的完整 Tool 真相
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `CanonicalInvocationStore` | Guard 需要独立于 Agent 上下文读取原始调用真相 | 定义 begin/find/markReady/markError 状态端口 | 不负责长期审计 |
|
||||
| `RedisCanonicalInvocationStore` | 完整 request/raw 有敏感性和容量,适合短期 TTL 存储 | 原子创建,保持剩余 TTL 的状态更新,读取不续期 | 只有该 Adapter 访问 Redis |
|
||||
| `CanonicalToolInvocation` | request、raw、agent_result 和两个状态必须形成合法组合 | 封装 PROJECTING -> READY/ERROR 转换及 Run 可引用判断 | ERROR/PROJECTING 不可被 EvidenceGuard 引用 |
|
||||
| `CanonicalInvocationLimits` | Redis record、raw 候选和 agent result 需要独立硬限制 | 统一 UTF-8 bytes 校验 | 不执行截断 |
|
||||
| `ToolCallKeyFactory` | Key 要隔离 Run 且保留框架 ID | 校验安全 segment,生成 prefix/runId/toolCallId | 不生成或改写 tool_call_id |
|
||||
| `DuplicateInvocationException`、`InvocationStateException`、`CanonicalStoreException`、`ResultTooLargeException` | Store 失败需要可分类而不是字符串猜测 | 区分重复、非法迁移、基础设施错误和容量错误 | 最终对 Agent 仍映射为安全错误码 |
|
||||
|
||||
### 7.3 Adapter 与 Projector:隔离业务后端
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `RagToolAdapter` | 检索实现会演进,但 Agent schema 和 Boundary 不应随之变化 | 解析 RAG request,经 Boundary 调 backend 和 `RagResultProjector` | 不把检索轨迹直接给模型 |
|
||||
| `QueryLogsToolAdapter` | 现有日志 Tool 的返回和时间语义需要规范化 | 校验范围、桥接 backend、投影日志事件 | 0 命中是 `NO_EVIDENCE`,不是技术失败 |
|
||||
| `MysqlToolAdapter` | LLM 生成 SQL 必须经过授权和只读沙箱 | 解析 request、先校验 SQL,再经 Boundary 执行和投影 | 未配置数据源时 Tool 不注册 |
|
||||
| `RagResultProjector` | 上游 evidence、score、relevance 表达不稳定 | 输出有界证据,并保留粗粒度 `relevance_level` | 非空/REFERENCE 不等于支持根因 |
|
||||
| `QueryLogsResultProjector` | raw 日志不可直接进入上下文 | 生成有界 events、pattern、scope 和截断标记 | 不泄露无限日志正文 |
|
||||
| `MysqlResultProjector` | JDBC rows 和元数据需要稳定 Agent contract | 限制行列、单元格和 bytes,输出 rows/columns/scope | 不执行 SQL 安全判断 |
|
||||
| `ToolProjectionLimits`、`MysqlToolLimits` | 各投影边界必须集中、可测试 | 配置证据数、文本、行列和结果上限 | 不改变 Run 总 bytes 预算 |
|
||||
|
||||
### 7.4 MySQL 只读沙箱
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `MysqlSqlValidator` | `readOnly=true` 声明不能证明 SQL 安全 | 解析并限制单条 SELECT、数据源、schema/table/column、limit 等 | 不执行查询 |
|
||||
| `MysqlDataSourceDefinition` | 连接存在不代表 Agent 可访问所有表列 | 保存逻辑数据源和 allowlist | 不携带密码 |
|
||||
| `MysqlQueryPlan` | 校验后的内容不应在 Executor 再解析原始请求 | 固定已批准数据源、SQL 和限制 | 只能由 Validator 产生 |
|
||||
| `MysqlReadOnlyExecutor` / `JdbcMysqlReadOnlyExecutor` | JDBC 细节与 Harness Boundary 解耦 | 在只读连接、timeout 和 row limit 下执行 plan | 不接收未经验证的 request |
|
||||
| `MysqlRawResult` | JDBC 原始但结构化的执行结果 | 携带 columns、rows、truncated、duration | 仍需 Projector 后才能进入 canonical agent_result |
|
||||
| `MysqlSecurityException` | 安全拒绝必须与基础设施错误区分 | 表达非法 SQL、未授权对象等 | 不向 Agent泄露详细策略 |
|
||||
|
||||
### 7.5 Tool contract 完整清单
|
||||
|
||||
| 类型 | 设计原因与功能 |
|
||||
|---|---|
|
||||
| `AgentToolContracts` | Tool 名、描述和服务端注册契约的唯一常量源,防止 Prompt/代码漂移 |
|
||||
| `RagToolCall`、`QueryLogsToolCall`、`MysqlToolCall` | 模型侧统一 Envelope:`previous_observation + input` |
|
||||
| `RagToolRequest`、`QueryLogsRequest`、`MysqlToolRequest` | 业务 Tool 的 typed input;Interceptor 解包后仍保持原业务协议 |
|
||||
| `RagToolResult`、`QueryLogsToolResult`、`MysqlToolResult` | canonical agent-facing 标准结果,不等同于 raw backend response |
|
||||
| `RagEvidence`、`SourceDocument` | 有界知识证据及文档身份 |
|
||||
| `RagRelevanceLevel` | `PRECISE / HIGHLY_RELEVANT / REFERENCE` 等客观检索相关度,不是诊断置信度 |
|
||||
| `LogQueryScope`、`LogEvent`、`LogPattern` | 日志查询实际范围、事件和聚合模式 |
|
||||
| `LogSourceKind`、`LogTopic` | 限制日志来源和主题的可选集合 |
|
||||
| `ToolContractCollections` | 对 contract 集合做 defensive copy 和空值规范化 |
|
||||
|
||||
## 8. Guard:把两个验证命题分开
|
||||
|
||||
### 8.1 Evidence Guard
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `EvidenceGuard` | 模型不能被信任去验证自己引用的 ID 和 Run 归属 | 严格验证 Draft、analysis ID、canonical READY、当前 Run 所有权、evidence status 和引用闭包 | 不调用模型,不判断结论语义是否成立 |
|
||||
| `EvidenceGuardResult` | 校验只能是 verified snapshot 或 violations | 阻止半有效结果继续发布 | 不包含原始 Draft 修复逻辑 |
|
||||
| `VerifiedEvidenceSnapshot` | SemanticGuard 只能看到已验真的证据投影 | 汇总 verified analyses 和 sources | 不包含 raw Tool payload |
|
||||
| `VerifiedAnalysisEvidence`、`VerifiedEvidence` | 保持 analysis 到证据的归属关系 | 提供来源、scope、excerpt 等安全证据 | 不提升为业务结论 |
|
||||
| `EvidenceViolation`、`EvidenceViolationCode` | Repair 和 Fallback 需要稳定失败原因 | 标识缺 analysis、未知调用、Run mismatch、非 READY、引用不闭合等 | 不保存内部异常栈 |
|
||||
|
||||
### 8.2 Semantic Guard 与模型调用边界
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `SemanticGuard` | 引用真实仍可能无法支持结论 | 用隔离单轮调用输出 `SUPPORTED / UNSUPPORTED`,严格解析并有限 retry | 无 Tool、无记忆、不访问 Redis、不改写 Draft |
|
||||
| `GuardModelCall` | Router、单轮回答、Repair、Semantic 都需一致的模型预算、timeout 和 bytes 控制 | 在线程池中执行单轮 ChatModel,记录 usage,超时取消 future | 不决定业务 retry policy |
|
||||
| `SemanticDraftView` | Guard/Repair 只应比较用户可见语义 | 从 Draft 提取稳定语义视图,并判断修复前后是否一致 | 不包含引用实现细节 |
|
||||
| `SemanticGuardInput`、`SemanticGuardDecision` | 固定 Guard 输入和带 verdict 的输出 | 只传 query、Draft view 和 verified snapshot | 不传 Prompt 历史或 raw Tool response |
|
||||
| `SemanticGuardLimits`、`SemanticGuardPrompt` | 输入、输出、单次/总 timeout 和判定职责可测试 | 限定一次语义审查的成本与 Prompt | 不暴露给 Diagnosis Agent |
|
||||
| `GuardModelCallException` | 单轮模型失败要按 timeout、transport、parse、schema 分类 | 为 Harness retry 提供类型化信号 | 不直接映射用户内容 |
|
||||
|
||||
## 9. Release:唯一公开决策点
|
||||
|
||||
### 为什么需要
|
||||
|
||||
如果 Agent、Guard、Application 都可以各自构造结果,同一个失败会出现不同用户语义,迟到内容也可能绕过安全校验。Release 必须集中回答一个问题:当前 Run 有哪些内容可以公开?
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `DiagnosisReleaseUseCase` | Draft、停止、Guard 和 Repair 的组合分支必须只有一个所有者 | 处理有结论、无结论、受控停止、非法 Draft;协调 Guard/Repair/Semantic;返回成功或 Fallback | 不持久化、不发送 SSE、不写新结论 |
|
||||
| `EvidenceRepair` | 引用或结构小错不应总是丢掉语义正确的 Draft | 单轮修复引用,严格解析,并用 `SemanticDraftView` 保证用户语义不变 | 只 attempt 一次;不新增事实、不改结论 |
|
||||
| `SafeFallbackFactory` | 失败文案若交给模型生成会再次引入幻觉 | 确定性构造 evidence failed、semantic unsupported/unavailable、insufficient evidence、missing context | 只使用已验真事实和有界问题码 |
|
||||
| `DiagnosisReleaseResult` | 下游不能同时收到 Draft 和 Fallback | 类型化承载 outcome、Draft、verified evidence 或 SafeFallback | 不等同于 RunState |
|
||||
| `EvidenceRepairLimits`、`EvidenceRepairPrompt` | Repair 的成本与职责必须比 Diagnosis Agent 更窄 | 限制输入/输出/timeout,固定只修引用的指令 | 不允许 Tool Calling |
|
||||
|
||||
## 10. Retry:显式、类型化、可审计的 attempt
|
||||
|
||||
### 为什么需要
|
||||
|
||||
重试会改变成本、延迟和副作用,必须是调用者的有意识决策。统一 Executor 负责循环,但具体组件持有自己的 Policy。
|
||||
|
||||
| 类型 | 设计原因与功能 |
|
||||
|---|---|
|
||||
| `HarnessRetryExecutor` | 在每个 attempt 前检查 Run active,统一记录成功/失败,并绝不吞掉取消和预算耗尽 |
|
||||
| `HarnessRetryPolicies` | 集中定义 Router/SemanticGuard 最多两次,其余一次的严格策略 |
|
||||
| `RetryPolicy` | `maxAttempts + retryable failures` 的不可变值,避免布尔 `retry=true` |
|
||||
| `RetryFailure` | timeout、transport、parse、schema、invalid output、cancel、budget 等稳定分类 |
|
||||
| `RetryAttempt` | 记录 attempt 序号、是否成功和失败类型,供 Trace 使用 |
|
||||
| `RetryOperation`、`RetryFailureClassifier` | 将执行和异常分类作为端口注入,Executor 不依赖具体模型组件 |
|
||||
| `RetryExecutionException` | 重试终止时保留最终 attempt 和失败分类 |
|
||||
|
||||
## 11. Audit:记录控制事实,而不是复制业务正文
|
||||
|
||||
### 为什么需要
|
||||
|
||||
诊断系统需要回答“为什么停止、调用了什么、Token 是否对账、发布为何降级”,但普通观察面不应长期保存 Prompt、SQL、日志 query、raw response 或 reasoning。
|
||||
|
||||
| 组件 | 为什么设计 | 作用 | 明确边界 |
|
||||
|---|---|---|---|
|
||||
| `DiagnosisTraceRecorder` / `JpaDiagnosisTraceRecorder` | 所有阶段需要同一 exact-run timeline | 按 Run 分配 sequence,追加安全事件;失败 best-effort | 不改变业务结果,不保存敏感正文 |
|
||||
| `TraceAuditEvents` | 各组件手写 details 容易字段漂移或泄露 | 集中构造 run/routing/model/tool/progress/guard/release 事件 | 只接受有界、安全字段 |
|
||||
| `DiagnosisTraceAuditEvent` | Recorder 与业务组件解耦 | 统一事件 identity、phase、type、status、details | 不是领域事件总线 |
|
||||
| `ToolInvocationAuditSink` / `JpaToolInvocationAuditSink` | canonical raw 不能长期保存,但调用元数据要留存 | 保存 exact Run、Tool ID、状态、耗时、bytes 和有界 enrichments | 不保存完整 request/raw/agent result |
|
||||
| `RagLookupAuditEnricher` | RAG 需要有限的质量诊断字段 | 从受限输入提取 step/query/relevance 等安全摘要 | 不打印完整 rewritten/raw query |
|
||||
| `HarnessAgentAuditHook` | 框架每轮 Agent 模型步骤需关联数据库 step 和 Provider reasoning 可用性 | 写 AgentStep metadata,并把 reasoning/assistant text 放独立受限存储 | reasoning 不进普通 Timeline、Guard 或下一轮上下文 |
|
||||
| `AgentStepAuditTracker` | Tool audit 需要关联当前 Agent step,但不能使用 ThreadLocal | 按 runId 显式绑定/查询/清理 stepId | 不保存 Step 实体 |
|
||||
| `ModelCallAuditor` / `ModelCallLedger` | 所有模型入口都要按组件、轮次对账 Token | begin call、记录 Provider usage、写 MODEL_TOKEN_USAGE、生成 Run reconciliation | Usage 缺失时标 unavailable,不估算 |
|
||||
| `RunConclusionExtractor` | Trace/DB 常需直接读取安全发布结论 | 从 public JSON 提取 conclusion | 不读取 Provider reasoning |
|
||||
|
||||
### Audit 类型清单
|
||||
|
||||
| 类型 | 分类与功能 |
|
||||
|---|---|
|
||||
| `ModelCallComponent` | Router、System、Knowledge、Diagnosis、Repair、Semantic 的统一组件枚举,并映射 Trace phase |
|
||||
| `ToolInvocationAuditEvent` | durable Tool metadata 事件 |
|
||||
| `TracePhase`、`TraceEventType`、`TraceEventStatus` | Timeline 的阶段、事件和结果词汇表 |
|
||||
|
||||
## 12. Contract:跨组件唯一语言
|
||||
|
||||
### 为什么需要
|
||||
|
||||
Harness 横跨模型 JSON、Java 对象、Redis、JPA 和 SSE。若状态以自由字符串在各层重复定义,`SUCCESS`、`READY`、`EVIDENCE_FOUND` 很容易被混为一谈。Contract 包将不同维度保持正交。
|
||||
|
||||
| 类型 | 为什么存在与表达什么 |
|
||||
|---|---|
|
||||
| `DiagnosisDraft` | Diagnosis Agent 唯一结构化输出;analysis 绑定 Tool Call 引用,允许 `conclusion=null` |
|
||||
| `KnowledgeAnswerDraft` | Knowledge Query 单轮模型输出,答案项绑定文档来源 |
|
||||
| `PublishedResult` | 只有成功、安全的诊断结果可持久化为下一轮候选 |
|
||||
| `PreviousTurn` | PublishedResult 的有界历史投影,不是完整会话记录 |
|
||||
| `SafeFallback` | 确定性公开降级结构,包含 verified sources、observed facts、limitations、next steps 和 validation issues |
|
||||
| `SourceDocument` | 知识回答中的文档身份与来源 |
|
||||
| `IntentType` | `SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS` 路由结果 |
|
||||
| `InvocationStatus` | Tool invocation 生命周期:`PROJECTING / READY / ERROR` |
|
||||
| `EvidenceStatus` | Tool 客观结果:`EVIDENCE_FOUND / NO_EVIDENCE / ERROR` |
|
||||
| `SemanticVerdict` | 最终推论支持度:`SUPPORTED / UNSUPPORTED` |
|
||||
| `ReleaseOutcome` | Harness 发布结果:`SUCCESS / FALLBACK / FAILED / CANCELLED` |
|
||||
| `SseOutcome` | 当前未被运行时消费的遗留枚举;公开 `done` 实际使用 `ReleaseOutcome`,不要把它作为 SSE 协议真理源 |
|
||||
| `FallbackType` | evidence validation、semantic、insufficient evidence、missing context 等降级原因 |
|
||||
| `AnalysisKind` | 区分正向证据分析与限定范围的负向观察 |
|
||||
| `ContractCollections` | 对公共 contract 集合做 defensive copy、去空和不可变处理 |
|
||||
|
||||
## 13. 配置装配:组件如何真正连起来
|
||||
|
||||
`HarnessChatConfiguration` 不在 `harness` 包内,但它是运行时组件图的 Composition Root:
|
||||
|
||||
- 构造两个有界线程池:Chat worker 与 Harness model executor;
|
||||
- 从 `ChatHarnessProperties` 创建 Run、预算、停止阈值和各模型调用 limits;
|
||||
- 装配 Redis canonical store、ToolBoundary、三类 Adapter/Projector;
|
||||
- 仅在存在有效逻辑数据源时注册 `query_mysql`;
|
||||
- 为每个 Run 创建带 Model/Tool interceptor 和 Audit Hook 的 Agent;
|
||||
- 装配 Guard、Repair、Release、Router、三个执行分支和顶层 Application UseCase。
|
||||
|
||||
它存在的原因是让所有限制和替换点在一个地方可见。组件内部不应自行读取 Spring 配置或寻找全局 Bean,否则 focused test 很难证明其边界。
|
||||
|
||||
## 14. 用调用链快速定位组件
|
||||
|
||||
| 想回答的问题 | 首先阅读 | 接着阅读 |
|
||||
|---|---|---|
|
||||
| 一次请求如何创建并结束 Run | `ChatApplicationUseCase` | `DiagnosisHarnessCore`、`JpaChatRunStore` |
|
||||
| ReAct 每轮如何计预算 | `DiagnosisAgentFactory` | `HarnessModelInterceptor`、`ModelCallAuditor` |
|
||||
| Tool 为什么被拒绝 | `HarnessToolInterceptor` | `DiagnosisProgressTracker`、`ToolBoundary` |
|
||||
| Tool 结果为何没有原样给模型 | `ToolBoundary` | 具体 ResultProjector、`ToolResultViewProjector` |
|
||||
| 一条 evidence_ref 如何验真 | `EvidenceGuard` | `CanonicalToolInvocation`、具体 ToolResult contract |
|
||||
| 为什么最终是 FALLBACK | `DiagnosisReleaseUseCase` | `SafeFallbackFactory`、Trace 的 RELEASE 事件 |
|
||||
| 为什么诊断停止继续查 | `DiagnosisProgressTracker` | Tool progress / rejection / collection stop Trace |
|
||||
| Token 为什么对不上 | `ModelCallAuditor` | `ModelCallLedger`、`RUN_FINISHED` reconciliation |
|
||||
|
||||
## 15. 组件边界自检
|
||||
|
||||
未来新增能力时,可以用下面的问题判断放置位置:
|
||||
|
||||
1. 它是在判断业务根因吗?应留在 Diagnosis Agent,而不是 Core/Tool/Guard。
|
||||
2. 它能由代码机械证明吗?应放在 Boundary、Tracker 或 EvidenceGuard。
|
||||
3. 它需要完整 Tool 真相吗?读取 canonical store,不要从 Agent observation 反推。
|
||||
4. 它要决定公开内容吗?只能进入 Release,不要在 Application 或 Guard 私自构造。
|
||||
5. 它是短期验真数据还是长期运营元数据?前者 canonical,后者 metadata audit。
|
||||
6. 它会再次调用模型吗?必须进入 Core budget、ModelCallAuditor、timeout 和显式 retry policy。
|
||||
7. 它引入了新的状态吗?先确认是否只是 error code、stop reason 或 fallback reason,避免再造全局生命周期。
|
||||
@@ -0,0 +1,163 @@
|
||||
# Harness 组件学习路线(进度追踪)
|
||||
|
||||
**用途**:记录面试准备过程中已了解的 Harness 组件,标记进度,规划下一步。每次学完一个职责域后更新本表。
|
||||
**依据**:`mvp/engineering/harness/Harness组件全景-职责-设计原因与边界.md`(10 个职责域、189 个文件)
|
||||
|
||||
## 0. 学习交流方式与衔接说明(新会话请先读本节)
|
||||
|
||||
### 0.1 目标
|
||||
|
||||
为**面试准备**深入理解 Harness:不只是知道有哪些组件,要能讲清「为什么这样设计」——每个设计点都有动机(问题)→ 决策 → 代价 → 面试话术。
|
||||
|
||||
### 0.2 交流模式(用户与 AI 的协作方式)
|
||||
|
||||
1. **逐域学习**:按[学习主线](#3-一次请求的完整学习主线)顺序,一次一个职责域;进度见[第 1 节](#1-进度总览)。
|
||||
2. **讲解顺序固定**:设计动机(为什么重试权归 Harness)→ 实现细节(真实代码)→ 面试话术。
|
||||
3. **用户会用自己的话复述理解**(「我理解下...」)——AI 需逐条核对:基本正确就确认 + 精修表述;有偏差要明确指出并给出修正后的说法。
|
||||
4. **用户会追问**(「为什么...」「如果...那...」)——AI 必须基于源码事实回答(`src/main/java/com/superbiz/agent/harness`),先读代码再答,不凭印象。
|
||||
5. **概念分不清时用户会要求回到底层概念**(如「副作用幂等是什么」)——用类比 + 具体例子讲透再回到主线。
|
||||
6. **每学完一个主题沉淀成 mermaid 文档**,放本目录 `mvp/engineering/harness/`(与已有笔记同风格:用途/图/表/面试话术/代码位置),并更新本路线图。
|
||||
7. **终端对话中不输出 mermaid**(用户终端显示不了,用 ASCII 树/表格);落地文档中用 mermaid。
|
||||
8. 回复用中文、不用 emoji、重要内容(代码/表/推理)不截断。
|
||||
|
||||
### 0.3 新会话衔接步骤
|
||||
|
||||
```text
|
||||
1. 读本路线图:第 0 节(交流方式)+ 第 1 节(进度)+ 第 4 节(下一步)
|
||||
2. 读「已产出笔记」里的文档,了解已学内容的深度(尤其是 core / retry)
|
||||
3. 从第 4 节「下一步规划」继续,保持 0.2 的交流模式
|
||||
```
|
||||
|
||||
### 0.4 当前会话的起始上下文(供追溯)
|
||||
|
||||
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ RunBudget 深挖 → retry → progress(设计+代码双视角)→ tool 域(49 文件全注释 + 注册调用执行链路 + Tool 调用链旅程)→ RAG 检索体系(lookup_knowledge 后端:L0/多路召回+RRF/qualityScore/降级/契约/审计/离线评测,已闭环)。当前停在「tool 域只差 MySQL 沙箱线,下一步 tool 收尾」的位置。
|
||||
|
||||
### 0.5 面试准备策略(学习目标)
|
||||
|
||||
**学每个域的达标标准**(不只是「看懂了」):
|
||||
|
||||
```text
|
||||
1. 能 2 分钟讲清该域:为什么存在 → 核心机制 → 边界/代价
|
||||
2. 能接住 3 个追问:动机追问(为什么这样)→ 细节追问(怎么实现)→ 边界追问(什么不做)
|
||||
3. 有一句背得出的面试话术(每篇笔记都有「面试话术」章节)
|
||||
```
|
||||
|
||||
**每个域的面试讲法模板(固定叙事结构)**:
|
||||
|
||||
```text
|
||||
① 动机:不这么做会出什么问题(问题驱动,不要先报组件名)
|
||||
② 决策:选了什么方案、放弃了什么(对比)
|
||||
③ 实现:关键机制 + 代码事实(一句话带过实现细节)
|
||||
④ 边界:明确不做什么、代价是什么(诚实)
|
||||
⑤ 话术:一段 30 秒可背诵的回答
|
||||
```
|
||||
|
||||
**高频追问地图**(面试被问到时先答哪篇):
|
||||
|
||||
| 面试问题 | 答案指向 |
|
||||
|---|---|
|
||||
| 什么是 Harness?30 秒讲清 | [面试速查](Harness面试速查-一张图讲清设计.md) §1-2 |
|
||||
| 为什么不用多 Agent? | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
|
||||
| RunContext 为什么要显式传递? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §2 |
|
||||
| 取消是强杀吗? | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) §6-8 |
|
||||
| 预算和 Ledger 有什么区别? | [RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) §5 |
|
||||
| FALLBACK 算成功还是失败? | 状态流(未沉淀,学完后补) |
|
||||
| 重试为什么归 Harness 管? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §2 |
|
||||
| 为什么 Agent/Tool 不重试? | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) §9 |
|
||||
| 如何防止 Agent 编造证据? | 证据安全链(tool/guard 学完后补) |
|
||||
|
||||
**面试总复习路径**(面试前一天):
|
||||
|
||||
```text
|
||||
1. 30 秒电梯陈述 + 一张图(面试速查 §1-2)
|
||||
2. 默画三张白板图:主链路、职责迁移、数据三层(面试速查 §2)
|
||||
3. 过一遍六个易错点(面试速查 §8)
|
||||
4. 2 分钟真实案例(支付超时)
|
||||
5. 每篇笔记的「面试话术」章节快速背诵
|
||||
```
|
||||
|
||||
## 1. 进度总览
|
||||
|
||||
| 职责域 | 作用摘要 | 状态 | 已深入了解 | 对应文档 |
|
||||
|---|---|---|---|---|
|
||||
| `core` | **执行控制**:身份 / deadline / 预算 / 取消 / 唯一终态,checkActive 三道闸 | ✅ 深入 | RunContext、budget、cancel、lifecycle、checkActive、termination | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)、[RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
|
||||
| `retry` | **显式可计量重试**:分类裁决(技术/业务)、次数/时间/成本三重封顶、attempt 可审计 | ✅ 深入 | 设计动机、分类裁决、剩余超时、幂等性、SDK 关闭 | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
|
||||
| `contract` | **跨层类型化语言**:Draft / PublishedResult / SafeFallback / 状态枚举,防字符串漂移 | ✅ 深入 | 11 个状态枚举五层全景、四个正交轴(RunState⊥ReleaseOutcome、InvocationStatus⊥EvidenceStatus)、纵向映射链、SseOutcome 未接线发现 | [状态流笔记](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) |
|
||||
| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ✅ 深入 | 装配(Factory 粘合点)、双拦截器(Model:预算+Token 审计;Tool:五道门)、UseCase 循环外壳(字节/预算限制)、受控停止(异常栈捞回可控信号)、双视图投影(模型观察 vs 控制视图)、串行工具 | [agent 域学习笔记](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) |
|
||||
| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ✅ 深入 | trace 时序线(15 帧真实数据)、Ledger 分账、模型步审计 hook、RunConclusionExtractor、DiagnosisTraceService 三级回放 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
|
||||
| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ✅ 深入 | 六步编排、取消句柄(CoreRunControl)、统一失败出口、多轮记忆有界化、PublishedResultPolicy 落库 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
|
||||
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ✅ 深入 | 20 个违规码、三层校验(结构/验真/重读投影)、语义不变性、守卫模型受控调用、全栈衔接 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
|
||||
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ✅ 深入 | 三分支决策树、fail closed、终态透传、EvidenceRepair 语义不变性、SafeFallbackFactory 五种降级 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
|
||||
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ✅ 深入 | 49 文件全注释、Boundary/Contract/Projection/Store/Adapter/注册链、RAG 后端(L0/RRF/qualityScore/降级)、MySQL 沙箱(Validator/Executor/Projector 三层防线) | [tool 域注册调用执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)、[Tool 调用链旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md)、[RAG 检索体系](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md)、[MySQL 沙箱](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) |
|
||||
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ✅ 深入 | 设计动机、Tracker 双计数/pending/软硬停止、拦截器五道门、canonical 生命周期、Projector 投影、Release 消费 | [代码学习笔记](Harness progress 代码学习笔记-从拦截器五道门到唯一发布点.md)(与[设计视角](Harness信息增益停止-让无证据诊断正常收敛.md)配套) |
|
||||
|
||||
图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
|
||||
|
||||
## 2. 已产出笔记
|
||||
|
||||
| 文档 | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
|
||||
| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
|
||||
| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
|
||||
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | progress 设计视角:双停止机制 / 三方判断权 / 状态机 / 协议 / 真实问题 | ✅ 已沉淀(设计视角) |
|
||||
| [Harness progress 代码学习笔记-从拦截器五道门到唯一发布点](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md) | progress 代码视角:类地图 / 五道门 / Tracker 状态机 / canonical 生命周期 / 门禁 / 投影发布 / 易错点 / 面试话术 | ✅ 已沉淀(代码视角) |
|
||||
| [Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md) | tool 域:装配/注册(bridge/callbacks)/调用/执行(ToolBoundary)/返回全链路 + 面试话术 | ✅ 已沉淀 |
|
||||
| [Harness Tool 调用链-一次工具调用的完整旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md) | 动态时序:模型决定 → 拦截器 → invoke → Adapter → ToolBoundary → 返回 → 模型观察 | ✅ 已沉淀 |
|
||||
| [Harness RAG 检索体系学习笔记-从 query 到可验证证据](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md) | RAG 后端:L0/多路召回+RRF/qualityScore/去重判级/降级/契约/审计/离线评测/讨论沉淀 | ✅ 已沉淀 |
|
||||
| [Harness MySQL 沙箱学习笔记-从 SQL 校验到脱敏投影](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) | MySQL 工具:三层防线(语义/连接/输出)/白名单/参数化强制/取消联动/脱敏/与 RAG 对照 | ✅ 已沉淀 |
|
||||
| [Harness 证据安全链学习笔记-从收敛控制到唯一发布点](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) | progress→guard→release 联动:双通道验证架构/六层状态流转与映射/关键字段来源与使用/设计要点/面试话术 | ✅ 已沉淀 |
|
||||
| [Harness application+audit 学习笔记-从 Run 编排到可回放审计](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) | application 六步编排/取消句柄/持久化策略;audit 域层次(trace 子体系/Ledger/审计表);**audit vs trace 区别**(真实数据对照)/三级回放 | ✅ 已沉淀 |
|
||||
| [Harness contract 状态流学习笔记-11个状态枚举的正交全景](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) | 五层状态/四正交轴/纵向映射链/真实数据案例/面试叙事模板与追问应对 | ✅ 已沉淀 |
|
||||
| [Harness 面试复习笔记-五步复习与白板图沉淀](Harness%20面试复习笔记-五步复习与白板图沉淀.md) | **详细版**:30 秒陈述展开/三张白板图/九域五段式讲法(动机→决策→实现→边界→话术)/六易错点带原因/追问应对大全/支付超时案例/纠正认知清单/面试 Checklist | ✅ 已沉淀 |
|
||||
| [Harness agent 域学习笔记-从框架 ReAct 接入到受控停止](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) | agent 域:装配(Factory 粘合点)/双拦截器(Model 预算+Token 审计、Tool 五道门)/UseCase 循环外壳/受控停止/双视图投影/串行工具 | ✅ 已沉淀 |
|
||||
| [Harness LLM Judge 设计笔记-从不可信判定到可信裁决](Harness%20LLM%20Judge%20设计笔记-从不可信判定到可信裁决.md) | LLM-as-a-judge 模式:SemanticGuard(判支持度)+ EvidenceRepair(修引用)+ GuardModelCall(受控底座);面试五段式回答稿 + 六追问应对 + 通用要素 | ✅ 已沉淀 |
|
||||
| [Harness 整体架构学习笔记-从装配到入口到记忆到知识库写入](Harness%20整体架构学习笔记-从装配到入口到记忆到知识库写入.md) | 整体架构补充:配置装配中心(三层组织)/ HTTP 入口层(薄 Controller + SSE 状态机 + 断连取消)/ 会话与记忆体系(PreviousTurn 注入 + 术语校准 + skill 长期记忆)/ 知识库写入链路(分块 + hybrid 同源) | ✅ 已沉淀 |
|
||||
|
||||
## 3. 一次请求的完整学习主线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["core<br/>执行控制 ✅"] --> B["retry<br/>重试 ✅"]
|
||||
B --> C["progress<br/>信息增益 ✅"]
|
||||
C --> D["tool<br/>事实边界 ✅"]
|
||||
D --> E["guard<br/>验证 ✅"]
|
||||
E --> F["release<br/>发布 ✅"]
|
||||
F --> G["application + audit<br/>收尾 ✅"]
|
||||
G --> H["contract<br/>类型化语言 ✅"]
|
||||
```
|
||||
|
||||
## 4. 下一步规划
|
||||
|
||||
```text
|
||||
主线九域全部 ✅(含 agent 域收尾)+ 面试五步复习 ✅(共沉淀 14 篇笔记)
|
||||
|
||||
面试前一天建议:
|
||||
1. 重读「面试速查」§1-2 + §8(30 秒陈述 / 一张图 / 六易错点)
|
||||
2. 默画三张白板图(复习笔记 §3)
|
||||
3. 背诵每域 30 秒话术(复习笔记 §5)
|
||||
4. 过一遍纠正的认知清单(复习笔记 §6,最容易踩的坑)
|
||||
5. 2 分钟支付超时案例(复习笔记 §7)
|
||||
|
||||
可选深化(不阻塞面试):
|
||||
1. audit 域深化:RagLookupAuditEnricher 检索审计明细(已覆盖大半)
|
||||
2. LLM Judge 设计(已沉淀:面试问答 + 追问应对)
|
||||
3. 整体架构补充(已沉淀:装配/入口/记忆体系/知识库写入)
|
||||
```
|
||||
|
||||
## 5. 建议每次学完一个域后更新
|
||||
|
||||
```text
|
||||
1. 把本表"状态"从 ⬜ 改为 ✅/⬜
|
||||
2. 在"已深入了解"列补充该域的关键类
|
||||
3. 如产出笔记,加入"已产出笔记"表
|
||||
```
|
||||
|
||||
## 6. 参考资料索引
|
||||
|
||||
| 文档 | 用途 |
|
||||
|---|---|
|
||||
| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事(30 秒回答、三大决策、易错点) |
|
||||
| [Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) | 全部组件的参考手册(需要查类时用) |
|
||||
| [components/README.md](components/README.md) | 组件渐进式导读入口(02-04 对应 progress/tool/guard+release) |
|
||||
| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 设计主文档(决策总表、不变量) |
|
||||
@@ -0,0 +1,503 @@
|
||||
# Harness 设计:非确定性 Agent 的确定性控制边界
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**适用范围**:当前 MVP Chat / Diagnosis Harness
|
||||
**代码基线**:`com.superbiz.agent.harness`
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**组件索引**:[Harness组件全景-职责-设计原因与边界.md](Harness组件全景-职责-设计原因与边界.md)
|
||||
|
||||
## 1. 结论先行
|
||||
|
||||
这个系统真正需要解决的,不是“怎样让模型多调用几个 Tool”,而是:
|
||||
|
||||
> 当业务推理由非确定性模型完成时,怎样保证每一次执行仍然有身份、有边界、有停止条件、有证据闭包,并且只发布系统能够负责的内容。
|
||||
|
||||
当前 Harness 给出的答案是职责分治:
|
||||
|
||||
- Diagnosis Agent 负责提出假设、选择 Tool、理解结果和撰写 Draft。
|
||||
- Harness 负责所有必须确定的事情:Run 生命周期、预算、取消、Tool 授权、结果隔离、停止、验真、发布和审计。
|
||||
- Tool 只报告客观执行结果,不宣称业务根因。
|
||||
- Guard 不重新做诊断,只回答受限的验证问题。
|
||||
- Release 是唯一对外发布决策点,只能发布原始安全 Draft 或确定性 SafeFallback。
|
||||
|
||||
这不是一个新的工作流引擎。Harness 不复制 ReAct 循环,不维护 Planner / Executor / Composer 图,也不替模型判断根因。它包围 ReAct 的不确定部分,在所有外部副作用和最终发布点建立确定性门禁。
|
||||
|
||||
## 2. 根问题:模型能推理,但系统必须能够负责
|
||||
|
||||
### 2.1 旧架构把“推理角色”当成了“安全边界”
|
||||
|
||||
旧方案使用 Planner、Executor、Verifier、Composer 等多个 Agent 串联。表面上每个角色各司其职,实际上每个角色都拥有一部分 Prompt、模型调用、Schema 转换、重试和 Fallback 逻辑。结果是:
|
||||
|
||||
1. 一次诊断的控制权分散在多个模型角色和业务 Service 中。
|
||||
2. 同一种错误可能被不同层重试、改写或吞掉。
|
||||
3. Verifier 既检查引用,又判断语义,还可能重写报告,成为第二个诊断者。
|
||||
4. Tool 原始结果、Agent 上下文和长期审计没有明确的数据边界。
|
||||
5. Run 身份依赖 ThreadLocal 传播,跨线程后无法证明一次调用属于哪个 Run。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户请求"] --> P["Planner Agent"]
|
||||
P --> E["Executor Agent"]
|
||||
E --> T["Tool / raw result"]
|
||||
E --> V["Verifier Agent"]
|
||||
V --> C["Composer Agent"]
|
||||
C --> O["公开结果"]
|
||||
|
||||
P -.-> X1["独立 Prompt / retry / schema"]
|
||||
E -.-> X2["独立 Prompt / retry / fallback"]
|
||||
V -.-> X3["验真、语义判断、改写混合"]
|
||||
C -.-> X4["再次生成用户事实"]
|
||||
T -.-> X5["raw、trace、正文边界不清"]
|
||||
|
||||
H["ThreadLocal context"] -.-> P
|
||||
H -.-> E
|
||||
H -.-> V
|
||||
|
||||
classDef risk fill:#fff1f0,stroke:#cf1322,color:#5c0011;
|
||||
class X1,X2,X3,X4,X5,H risk;
|
||||
```
|
||||
|
||||
问题并不在于“Agent 数量多”本身,而在于控制职责没有单一所有者。只要预算、重试、证据和发布仍分散,多 Agent 换成 Graph 也不会自然变得可靠。
|
||||
|
||||
### 2.2 单 Agent 仍然不等于受控系统
|
||||
|
||||
把多 Agent 合并成一个 ReAct Agent,只消除了重复推理链,并没有自动解决以下问题:
|
||||
|
||||
- 模型可能无限改写相似查询;
|
||||
- SDK 可能在 Harness 之外自动重试;
|
||||
- Tool 可能返回过大、敏感或不可引用的原始数据;
|
||||
- 模型可以引用其他 Run 或不存在的 Tool Call;
|
||||
- 证据引用真实,不代表结论被证据支持;
|
||||
- 客户端断开后,迟到的模型结果仍可能覆盖终态;
|
||||
- “没有足够证据”可能被错误地发布为技术失败。
|
||||
|
||||
所以重构的核心不是“单 Agent”,而是“单 Agent + 确定性 Harness”。
|
||||
|
||||
## 3. 设计约束与不变量
|
||||
|
||||
以下不变量是组件拆分的依据,不是实现后的总结。
|
||||
|
||||
| 不变量 | 系统含义 | 由谁保证 |
|
||||
|---|---|---|
|
||||
| 一个 Run 只有一个终态 | 成功、失败、取消、超时、预算耗尽不能相互覆盖 | `RunLifecycle` 的 first-terminal-wins |
|
||||
| 所有边界显式携带 Run 身份 | 不依赖线程绑定的隐式上下文 | `RunContext` 和 framework `tool_call_id` |
|
||||
| 所有模型与 Tool 消耗可计量 | SDK 隐式 retry 不能绕过预算 | Core、Model/Tool interceptor、Harness retry |
|
||||
| Tool raw 不直接进入模型 | 内部数据、体积和控制字段不能污染上下文 | ToolBoundary、canonical store、双视图投影 |
|
||||
| 只有当前 Run 的 READY 调用可被引用 | 防止伪造、串 Run 和引用失败调用 | EvidenceGuard |
|
||||
| 引用真实性与结论支持度分开 | 确定性规则和语义判断不互相冒充 | EvidenceGuard、SemanticGuard |
|
||||
| Guard 不写报告 | 防止验证器成为第二个业务 Agent | Release Policy |
|
||||
| 停止权属于 Harness | Prompt 建议不能替代系统保证 | ProgressTracker、预算和 interceptor |
|
||||
| 没有根因是合法业务结果 | 证据不足不应伪装成内部故障 | `conclusion=null`、ProgressSnapshot、SafeFallback |
|
||||
| 普通审计不保存敏感正文 | 可观测性不能以泄露 Prompt、raw、reasoning 为代价 | metadata-only audit、reasoning 独立存储 |
|
||||
|
||||
## 4. 当前总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Entry["应用入口与 Run 所有权"]
|
||||
APP["ChatApplicationUseCase"]
|
||||
ROUTER["IntentRouter"]
|
||||
EXEC["DiagnosisChatExecutor"]
|
||||
STORE["ChatRunStore"]
|
||||
end
|
||||
|
||||
subgraph Harness["确定性 Harness 边界"]
|
||||
CORE["Core<br/>context / lifecycle / budget / cancel"]
|
||||
MI["Model Interceptor<br/>调用预算与 Token"]
|
||||
TI["Tool Interceptor<br/>协议、重复、停止"]
|
||||
TB["ToolBoundary<br/>授权、执行、canonical"]
|
||||
PROGRESS["ProgressTracker<br/>信息增益与饱和"]
|
||||
EG["EvidenceGuard<br/>引用真实性"]
|
||||
SG["SemanticGuard<br/>结论支持度"]
|
||||
RELEASE["Release Policy<br/>SUCCESS / FALLBACK"]
|
||||
AUDIT["Audit / Trace<br/>metadata-only"]
|
||||
end
|
||||
|
||||
subgraph Nondeterministic["非确定性区域"]
|
||||
AGENT["Diagnosis ReAct Agent"]
|
||||
MODEL["Chat Model"]
|
||||
end
|
||||
|
||||
subgraph Evidence["证据执行与存储"]
|
||||
ADAPTER["RAG / Logs / MySQL Adapter"]
|
||||
BACKEND["Evidence Backend"]
|
||||
CANON["Redis Canonical Invocation"]
|
||||
META["MySQL Durable Metadata"]
|
||||
end
|
||||
|
||||
APP --> CORE
|
||||
APP --> ROUTER
|
||||
ROUTER --> MODEL
|
||||
APP --> EXEC
|
||||
EXEC --> AGENT
|
||||
AGENT --> MI --> MODEL
|
||||
AGENT --> TI
|
||||
TI --> PROGRESS
|
||||
TI --> TB --> ADAPTER --> BACKEND
|
||||
TB --> CANON
|
||||
TB --> META
|
||||
TB --> TI --> AGENT
|
||||
EXEC --> EG --> SG --> RELEASE
|
||||
PROGRESS --> RELEASE
|
||||
RELEASE --> APP --> STORE
|
||||
CORE -.-> MI
|
||||
CORE -.-> TI
|
||||
CORE -.-> TB
|
||||
CORE -.-> EG
|
||||
CORE -.-> SG
|
||||
APP -.-> AUDIT
|
||||
MI -.-> AUDIT
|
||||
TI -.-> AUDIT
|
||||
TB -.-> AUDIT
|
||||
EG -.-> AUDIT
|
||||
SG -.-> AUDIT
|
||||
RELEASE -.-> AUDIT
|
||||
```
|
||||
|
||||
图中的关键边界有三条:
|
||||
|
||||
1. **Agent 之前**:Application 创建 Run,Core 固定身份、预算和终止语义。
|
||||
2. **Tool 前后**:Interceptor 管控制协议,ToolBoundary 管执行与真相,Projector 管模型可见内容。
|
||||
3. **发布之前**:EvidenceGuard 验引用,SemanticGuard 验推论,Release 决定唯一公开内容。
|
||||
|
||||
## 5. 关键决策及设计推导
|
||||
|
||||
### 5.1 决策一:只保留一个拥有 Tool loop 的 Diagnosis Agent
|
||||
|
||||
**问题**:多角色 Agent 把同一份业务上下文在多个模型之间传递,每一跳都可能增加信息损失、重试和幻觉面。
|
||||
|
||||
**候选方案**:
|
||||
|
||||
| 方案 | 优点 | 主要问题 |
|
||||
|---|---|---|
|
||||
| 保留 Planner / Executor / Verifier / Composer | 角色概念直观 | 控制权分散;多套 Prompt 和重试;Verifier/Composer 会产生新事实 |
|
||||
| 外层自建 ReAct / StateGraph | 流程显式 | 与框架 ReAct 重复;状态和异常路径翻倍 |
|
||||
| 单 ReAct Agent + Harness | 推理上下文连续;控制边界集中 | 对 Harness 的契约和门禁设计要求更高 |
|
||||
|
||||
**决策**:Diagnosis Agent 是唯一业务推理者和 Draft 作者,使用框架 `ReactAgent` 自己完成 Thought / Action / Observation。项目不在外层复制循环。
|
||||
|
||||
**代价**:单 Agent 并不能通过“角色互审”获得表面冗余,因此必须把可机械验证的安全要求下沉到 Guard,把语义审查收缩成隔离的单轮判断。
|
||||
|
||||
### 5.2 决策二:RunContext 显式传播,结构不可变、状态句柄线程安全
|
||||
|
||||
**问题**:旧 ThreadLocal 可以在同步调用中工作,但异步 Tool、线程池和取消回调跨线程后,调用方无法证明读到的是当前 Run 的上下文。
|
||||
|
||||
**容易走错的方案**:把所有字段都做成不可变值对象。这样 deadline 和 identity 很干净,但预算、取消和终态不得不在外部另建全局 Map,反而形成第二真相源。
|
||||
|
||||
**决策**:`RunContext` 本身是 record,固定 `sessionId / runId / deadline` 和各状态句柄引用;预算、取消、生命周期、模型账本和进展 Tracker 在各自线程安全对象内变化。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
RC["RunContext<br/>结构不可变"] --> ID["sessionId / runId / deadline"]
|
||||
RC --> C["RunCancellation<br/>first-reason-wins"]
|
||||
RC --> B["RunBudget<br/>同步复合计数"]
|
||||
RC --> L["RunLifecycle<br/>first-terminal-wins"]
|
||||
RC --> M["ModelCallLedger<br/>组件/轮次账本"]
|
||||
RC --> P["ProgressTracker<br/>Run 内进展"]
|
||||
```
|
||||
|
||||
**为什么不是全局 Run Registry**:Harness 只控制当前调用,不承担 Run 查询和持久化;持久化真相仍由 `diagnosis_run` 所有,避免 Core 演变成工作流引擎。
|
||||
|
||||
### 5.3 决策三:关闭 SDK 隐式 retry,由 Harness 按失败类型拥有 retry
|
||||
|
||||
**问题**:Spring AI 默认 `maxAttempts=10`。如果 SDK 在模型边界内部自动重试,Harness 看到的一次调用可能对应多个 Provider attempt,预算、延迟、Trace 和取消都失真。
|
||||
|
||||
**决策**:底层 SDK retry 设为 1;仅在 Harness 中使用类型化策略:
|
||||
|
||||
- IntentRouter:超时、传输、非法输出最多 2 次 attempt;
|
||||
- SemanticGuard:超时、传输、解析或 Schema 问题最多 2 次 attempt;
|
||||
- Diagnosis Agent、业务 Tool、EvidenceRepair:1 次,不自动重试;
|
||||
- 取消、预算耗尽、`NO_EVIDENCE`、业务拒绝:从不重试。
|
||||
|
||||
**设计理由**:重试不是通用容错开关。只有调用者知道一次失败是否幂等、是否还在 deadline 内、是否应该再次消耗预算。
|
||||
|
||||
**代价**:Provider 瞬时抖动更容易直接暴露,但真实 attempt 终于可计算、可审计,且不会把业务无证据误判为技术重试条件。
|
||||
|
||||
### 5.4 决策四:Tool 使用 canonical truth 与 Agent projection 双视图
|
||||
|
||||
**问题**:同一份 Tool 返回同时服务三个目标,而三个目标互相冲突:
|
||||
|
||||
- 证据验真需要完整、稳定、按 Run 归属的记录;
|
||||
- Agent 只需要完成下一步推理的最小内容;
|
||||
- 长期审计应保留身份和耗时,但不能长期保存敏感 raw。
|
||||
|
||||
**决策**:把 Tool 数据分成三层,而不是让一个 JSON 到处流转。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REQ["framework tool_call_id<br/>typed request"] --> B["ToolBoundary"]
|
||||
B --> RAW["backend raw response"]
|
||||
RAW --> CAN["Redis canonical invocation<br/>request + raw + agent_result<br/>短 TTL"]
|
||||
RAW --> PRJ["Tool-specific projector"]
|
||||
PRJ --> CTRL["Harness control view"]
|
||||
PRJ --> OBS["Agent observation<br/>白名单、有界"]
|
||||
B --> META["Durable audit<br/>identity / status / latency / bytes"]
|
||||
CTRL --> STOP["重复、NO_GAIN、停止控制"]
|
||||
OBS --> AGENT["Diagnosis Agent"]
|
||||
CAN --> GUARD["EvidenceGuard"]
|
||||
```
|
||||
|
||||
**关键取舍**:
|
||||
|
||||
- 使用框架 `tool_call_id`,Harness 不生成第二套 ID;
|
||||
- `PROJECTING / READY / ERROR` 表达调用生命周期;
|
||||
- `EVIDENCE_FOUND / NO_EVIDENCE / ERROR` 表达客观结果语义;
|
||||
- raw 超限直接失败,不静默截断;Agent projection 可以有界截断,但必须标记 `truncated`;
|
||||
- Redis 是 TTL 内完整 Tool 真相源,MySQL 只做长期 metadata audit;
|
||||
- Redis 读取不续期,避免一次历史读取无限延长敏感 raw 生命周期。
|
||||
|
||||
**代价**:同一次 Tool 调用需要 projector、store 和 audit 三套表达。但它们不是重复数据模型,而是分别回答“真实发生了什么”“模型允许看到什么”“长期允许保留什么”。
|
||||
|
||||
### 5.5 决策五:EvidenceGuard、SemanticGuard 和 Release 三段分工
|
||||
|
||||
**问题**:“引用是真实的”和“结论被引用支持”是两个不同命题。只做前者会放过牵强推论;都交给模型则无法确定性防止伪造 ID、跨 Run 引用或修改报告。
|
||||
|
||||
**决策**:
|
||||
|
||||
1. `EvidenceGuard` 使用纯代码检查 Draft 结构、analysis ID、Tool Call 当前 Run 所有权、READY 状态、evidence status 和引用闭包。
|
||||
2. 首次引用失败时,`EvidenceRepair` 只允许修复结构和引用;修复前后用户可见语义必须一致,然后重新执行 EvidenceGuard。
|
||||
3. `SemanticGuard` 只接收 query、完整 Draft 和 verified snapshot,进行无 Tool、无记忆的单轮 `SUPPORTED / UNSUPPORTED` 判断。
|
||||
4. `DiagnosisReleaseUseCase` 是唯一发布决策点。Guard 无权改写最终报告。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Diagnosis Agent
|
||||
participant R as Release UseCase
|
||||
participant E as EvidenceGuard
|
||||
participant C as Canonical Store
|
||||
participant X as EvidenceRepair
|
||||
participant S as SemanticGuard
|
||||
participant P as Public Result
|
||||
|
||||
A->>R: DiagnosisDraft
|
||||
R->>E: validate draft references
|
||||
E->>C: read exact run/tool_call_id
|
||||
C-->>E: READY agent_result
|
||||
alt 引用或结构错误
|
||||
E-->>R: violations
|
||||
R->>X: 原 Draft + violations
|
||||
X-->>R: 语义不变的修复 Draft
|
||||
R->>E: revalidate once
|
||||
end
|
||||
alt 证据闭包有效
|
||||
E-->>R: verified snapshot
|
||||
R->>S: query + draft + snapshot
|
||||
S-->>R: SUPPORTED / UNSUPPORTED
|
||||
end
|
||||
alt SUPPORTED
|
||||
R-->>P: 原始安全 Draft
|
||||
else 任一门禁失败
|
||||
R-->>P: deterministic SafeFallback
|
||||
end
|
||||
```
|
||||
|
||||
**为什么 Repair 不能修正文义**:一旦 Repair 可以修改结论,它就成为新的报告作者;此时最终内容不再是 Diagnosis Agent 的 Draft,也无法证明修复只解决了引用问题。
|
||||
|
||||
**为什么 SemanticGuard 不是第二个 Agent**:它没有 Tool、历史记忆或 ReAct loop,只回答一个受约束的二值审查问题,不能探索新事实或生成新结论。
|
||||
|
||||
### 5.6 决策六:资源预算和信息增益是两套停止机制
|
||||
|
||||
**问题**:预算只能回答“还能不能花资源”,不能回答“继续查询还有没有价值”。知识库只有通用资料、日志持续为空时,Agent 可以在预算范围内不断换关键词,最终以 `BUDGET_EXHAUSTED` 结束。用户得到的是技术失败,而系统实际已经知道“当前范围证据不足”。
|
||||
|
||||
**决策**:
|
||||
|
||||
- Tool 负责客观事实:是否执行成功、是否为空、实际 scope;
|
||||
- Harness 机械判定空结果和完全重复的 `tool_name + normalized_scope` 为 `NO_GAIN`;
|
||||
- 其他成功非空结果由模型在下一次 Tool Call Envelope 中声明 `GAINED / NO_GAIN`;
|
||||
- `DiagnosisProgressTracker` 维护连续 `NO_GAIN`,达到阈值后进入 `SATURATED`;
|
||||
- 连续 Envelope 协议错误使用独立计数和 `PROGRESS_PROTOCOL_VIOLATED`,不伪装成无信息增益;
|
||||
- STOP_REQUIRED 只交付一次,再次请求 Tool 直接受控终止;
|
||||
- `INFORMATION_SATURATED` 与 `BUDGET_LIMIT_REACHED` 始终分开记录。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED / 清零 NO_GAIN
|
||||
COLLECTING --> COLLECTING: NO_GAIN / 未达阈值
|
||||
COLLECTING --> SATURATED: 连续 NO_GAIN 达阈值
|
||||
COLLECTING --> SATURATED: 连续协议错误达阈值
|
||||
COLLECTING --> STOPPED: 硬预算到达
|
||||
SATURATED --> DRAFT_CHANCE: 单次 STOP_REQUIRED
|
||||
DRAFT_CHANCE --> STOPPED: 再次请求 Tool
|
||||
DRAFT_CHANCE --> RELEASE: 输出合法 Draft
|
||||
STOPPED --> RELEASE: ProgressSnapshot
|
||||
```
|
||||
|
||||
**为什么不用 `new_count`**:跨 RAG、日志和数据库建立统一内容指纹成本高,而且“新记录”不等于“对假设有价值”。
|
||||
|
||||
**为什么不用独立 Progress Judge**:它会增加模型成本和新的失败点,还会把简单的空结果、重复 scope 判断模型化。
|
||||
|
||||
**首版边界**:重复检测只比较规范化参数,不承诺识别自然语言语义等价查询。
|
||||
|
||||
### 5.7 决策七:`conclusion=null` 是合法完成,不是模型失败
|
||||
|
||||
**问题**:如果成功的唯一含义是“必须给出根因”,Agent 在缺少企业、时间范围或错误信息时只能继续盲查,或者编造结论。
|
||||
|
||||
**决策**:DiagnosisDraft 允许 `conclusion=null`:
|
||||
|
||||
- 缺少开始查询所需信息:`MISSING_REQUIRED_CONTEXT`;
|
||||
- 已完成有限检查但证据不足:`INSUFFICIENT_EVIDENCE`;
|
||||
- 有结论:才进入完整 EvidenceGuard、Repair、SemanticGuard 链。
|
||||
|
||||
最终公开生命周期仍只有 `SUCCESS / FALLBACK / FAILED / CANCELLED`。证据不足是 `FALLBACK` 的原因,不再引入一套与 Run 终态平行的诊断状态机。
|
||||
|
||||
如果模型输出非法 Draft,系统会丢弃非法正文;只有当前 Run 已存在可验真的 ProgressSnapshot,才允许降级成过程型 Fallback,否则保持 fail closed。
|
||||
|
||||
### 5.8 决策八:可观测性记录决策证据,不复制敏感上下文
|
||||
|
||||
**问题**:为了调试 Agent,最直接的做法是保存 Prompt、模型正文、Tool arguments 和 raw response。但这会让普通 Trace 变成敏感数据仓库,也会造成多份事实副本。
|
||||
|
||||
**决策**:
|
||||
|
||||
- `diagnosis_trace_event` 只保存追加式、按 exact Run 排序的事件和有界 metadata;
|
||||
- `ToolInvocation` 长期保存 Tool 身份、状态、耗时和字节数,不保存完整请求/响应;
|
||||
- `AgentStep` 保存步骤元数据和 Token,不保存 Prompt、Tool payload;
|
||||
- Provider reasoning 与 assistant text 放入独立受限审计表和接口;
|
||||
- Trace 写入失败不得改变业务结果;
|
||||
- Model usage 按组件和轮次入账,Run 结束做 Token 对账;
|
||||
- Tool 请求被 Harness 拒绝时记录 `TOOL_REQUEST_REJECTED`,不能伪装成一次真实 `TOOL_INVOCATION`。
|
||||
|
||||
**尚未完成的治理**:Reasoning 的访问控制、保留期限和加密仍由 ISS-015 跟踪。已有隔离不代表完整合规闭环。
|
||||
|
||||
## 6. 一次诊断的完整控制流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant App as Chat Application
|
||||
participant Core as Harness Core
|
||||
participant Agent as Diagnosis Agent
|
||||
participant TI as Tool Interceptor
|
||||
participant TB as ToolBoundary
|
||||
participant Store as Canonical Store
|
||||
participant Guard as Guards
|
||||
participant Release as Release
|
||||
|
||||
U->>App: query + optional sessionId
|
||||
App->>Core: startRun(sessionId)
|
||||
Core-->>App: RunContext(runId, deadline, handles)
|
||||
App->>Agent: query + bounded safe previousTurn
|
||||
|
||||
loop ReAct 由框架拥有
|
||||
Agent->>TI: Tool Call Envelope
|
||||
TI->>TI: 应用上一轮信息增益、检查重复/饱和/协议
|
||||
alt 门禁允许
|
||||
TI->>TB: framework id + business input + RunContext
|
||||
TB->>TB: active / auth / readonly / budget / size
|
||||
TB->>Store: PROJECTING -> READY or ERROR
|
||||
TB-->>TI: bounded canonical agent_result
|
||||
TI-->>Agent: whitelist observation
|
||||
else 信息饱和或协议停止
|
||||
TI-->>Agent: one-shot STOP_REQUIRED
|
||||
end
|
||||
end
|
||||
|
||||
Agent-->>App: DiagnosisDraft 或受控停止
|
||||
App->>Release: Draft + ProgressSnapshot + stop reason
|
||||
alt 有结论
|
||||
Release->>Guard: evidence truth + semantic support
|
||||
Guard-->>Release: verified / unsupported
|
||||
else 无结论或受控停止
|
||||
Release->>Release: 构造确定性 Fallback
|
||||
end
|
||||
Release-->>App: original safe Draft or SafeFallback
|
||||
App->>Core: first terminal wins
|
||||
App-->>U: content/failure + done
|
||||
```
|
||||
|
||||
## 7. 真实问题如何反向修正设计
|
||||
|
||||
这些不是零散 Bug 清单。每个问题都暴露了一个原设计假设不成立,并促成了边界调整。
|
||||
|
||||
| 现场问题 | 被证伪的假设 | 设计修正 | 固化位置 |
|
||||
|---|---|---|---|
|
||||
| Spring AI 默认 10 次 retry | 一次 Harness 模型调用等于一次 Provider attempt | SDK retry=1,retry 所有权上移 | Core / Retry / 配置测试 |
|
||||
| ThreadLocal 跨异步边界不稳定 | 同线程上下文足以表示 Run 所有权 | 显式 `RunContext` 贯穿调用链 | Core / framework metadata |
|
||||
| Tool raw 直接进入 Agent | Tool 返回可同时服务推理、验真和审计 | canonical / control / observation 三层拆分 | ToolBoundary / Projector / Store |
|
||||
| 0 条日志被表达为 `success=false` | 空结果等于技术失败 | 技术执行与 `NO_EVIDENCE` 分离 | Tool contract / Projector |
|
||||
| `REFERENCE` 被当作诊断证据 | 非空候选等于支持结论 | 保留相关度,语义增益交给模型 | RAG projector / Progress |
|
||||
| Redis canonical 全部 `STORE_ERROR` | 单测 ObjectMapper 与生产配置行为一致 | 使用生产 ObjectMapper 能力并增加 live E2E | Store wiring / E2E |
|
||||
| raw/rewritten query 出现在日志 | 可观测性可以直接打印检索输入 | 普通日志和 Trace 仅保存安全 metadata | Audit boundary |
|
||||
| 空查反复改写直到预算耗尽 | 硬预算可以承担正常收敛 | 引入信息增益和饱和停止 | ProgressTracker / Interceptor |
|
||||
| 9 次协议拒绝仍消耗 13 轮模型 | 协议错误会被模型自然修正 | 独立协议错误阈值和一次 STOP_REQUIRED | Progress protocol |
|
||||
| 非法 Draft 导致已完成检查全部丢失 | Draft 失败意味着整个 Run 没有安全价值 | 仅在已有验真进展时发布过程型 Fallback | ProgressSnapshot / Release |
|
||||
|
||||
## 8. 关键决策总表
|
||||
|
||||
| 决策 | 选择 | 放弃的方案 | 获得的能力 | 付出的代价 |
|
||||
|---|---|---|---|---|
|
||||
| 推理拓扑 | 单 Diagnosis ReAct Agent | 多 Agent Graph、外层 ReAct | 上下文连续、唯一 Draft 作者 | Harness 门禁必须完整 |
|
||||
| Run 上下文 | 显式 record + 状态句柄 | ThreadLocal、全局 Registry | 异步可证明、状态所有权清楚 | 参数需要显式传递 |
|
||||
| Tool ID | framework `tool_call_id` | Harness 二次生成 ID | 引用链唯一 | 依赖框架 ID 契约 |
|
||||
| Tool 真相 | Redis canonical,短 TTL | JPA 保存完整 raw | 可验真且限制敏感数据寿命 | Redis 可用性成为验证依赖 |
|
||||
| Agent 输入 | 白名单 projection | raw / 完整 canonical 直传 | 上下文有界、减少泄露 | 需为每类 Tool 维护 projector |
|
||||
| 长期审计 | metadata-only | 永久保存请求和响应 | 降低泄露与重复真相 | 深度回放受 TTL 限制 |
|
||||
| 证据安全 | 确定性 EvidenceGuard + 隔离 SemanticGuard | 单一 Verifier Agent | 分清真实性和支持度 | 两段门禁增加延迟 |
|
||||
| 修复 | 只修引用且语义必须不变 | Guard 重写报告 | 保持唯一作者 | 部分报告只能 Fallback |
|
||||
| 停止 | 预算 + 信息增益双机制 | 只靠 Prompt 或硬上限 | 正常无证据收敛 | 首版只能做参数级重复判断 |
|
||||
| 无结论 | 合法 Draft + SafeFallback | 强制根因 | 避免盲查和编造 | 调用方需理解 FALLBACK 是业务结果 |
|
||||
| 发布 | 唯一 Release Policy | 各层自行 fallback | 对外语义一致 | Release 成为关键集中组件 |
|
||||
|
||||
## 9. Harness 明确不做什么
|
||||
|
||||
边界是否清晰,既看它做什么,也看它拒绝做什么:
|
||||
|
||||
- 不判断业务根因;
|
||||
- 不实现 Planner / Executor / Verifier / Composer 角色图;
|
||||
- 不在框架外复制 ReAct while-loop;
|
||||
- 不让 Tool 声称结果是否支持诊断结论;
|
||||
- 不把检索分数直接提升为结论可信度;
|
||||
- 不依赖 Prompt 作为唯一预算或停止机制;
|
||||
- 不自动重试 Diagnosis Agent 和业务 Tool;
|
||||
- 不允许 Guard 生成新事实或改写结论;
|
||||
- 不把 Redis canonical 变成永久审计库;
|
||||
- 不承诺同步 Provider 调用一定能被立即物理中断;
|
||||
- 不在首版做自然语言语义去重或跨 Tool 内容指纹。
|
||||
|
||||
这些非目标是在防止 Harness 再次长成一套不可维护的业务编排系统。
|
||||
|
||||
## 10. 如何验证设计成立
|
||||
|
||||
验证重点不是某个类是否被调用,而是上述不变量能否在失败和竞态下保持:
|
||||
|
||||
| 验证层 | 需要证明的事实 | 代表性测试/证据 |
|
||||
|---|---|---|
|
||||
| Core 单测 | deadline、取消、预算、first-terminal-wins、异步显式 context | `RunContextTest`、`RunBudgetTest`、`DiagnosisHarnessCoreTest` |
|
||||
| Tool 单测 | cross-run、重复 ID、只读、大小、状态迁移、投影 | `ToolBoundaryTest`、`CanonicalInvocationStoreTest`、各 Projector test |
|
||||
| Agent loop | framework ID、Envelope、STOP_REQUIRED、非法 Draft | `DiagnosisAgentUseCaseTest`、`HarnessToolInterceptorTest` |
|
||||
| Guard / Release | 引用闭包、Repair 语义不变、Unsupported Fallback | `EvidenceGuardTest`、`SemanticGuardTest`、`DiagnosisReleaseUseCaseTest` |
|
||||
| Audit | metadata 边界、Token 对账、拒绝与执行分离 | audit 包 focused tests、exact-run Trace |
|
||||
| Live E2E | 生产序列化、Redis、数据库、模型和 SSE 的组合契约 | `devflow/projects/2026-07-22-single-react-cleanup-e2e/`、ISS-016 evidence |
|
||||
|
||||
单元测试能证明局部状态机,不能证明生产 `ObjectMapper`、Redis serializer、Provider Tool Calling 和 SSE 串联正确。因此 canonical store 的 Java Time 问题、零日志语义和协议空转都必须依靠 live E2E 反证,而不能只看 mock tests。
|
||||
|
||||
## 11. 当前边界与后续治理
|
||||
|
||||
当前设计已经建立可运行的确定性边界,但仍有明确限制:
|
||||
|
||||
1. 重复检测是 `tool_name + normalized_scope` 的参数等价,不识别语义近似查询。
|
||||
2. Redis canonical 受 TTL 约束;TTL 过后只能依赖 metadata audit,不能重建完整证据正文。
|
||||
3. 同步模型请求的取消主要阻止后续边界和迟到发布,不承诺 Provider 已执行计算立即停止。
|
||||
4. SemanticGuard 仍是模型判断,只是被限制在无 Tool、无记忆、二值输出的最小范围内。
|
||||
5. Reasoning 已与普通 Trace 隔离,但访问控制、保留期限和加密仍需完成。
|
||||
6. 信息增益阈值默认为 2,仍需通过固定评测集持续校准,不能通过线上直觉随意调大。
|
||||
|
||||
## 12. 可复用的设计原则
|
||||
|
||||
这套 Harness 最值得复用的不是某个 Java 类,而是以下判断顺序:
|
||||
|
||||
1. 先列出系统必须保证的确定性不变量,再决定组件。
|
||||
2. 将推理权留给模型,将授权、预算、归属、验真和发布权留给代码。
|
||||
3. 不让一个数据表示同时承担内部真相、模型上下文和长期审计。
|
||||
4. 不用资源耗尽代替业务收敛,也不用 Prompt 代替硬门禁。
|
||||
5. 不让验证器成为第二个作者;验证失败时降级,而不是偷偷改写。
|
||||
6. 把“没有足够证据”设计成一等业务结果,系统才不需要用幻觉换取成功率。
|
||||
|
||||
## 13. 代码与文档入口
|
||||
|
||||
- 完整组件说明:[Harness组件全景-职责-设计原因与边界.md](Harness组件全景-职责-设计原因与边界.md)
|
||||
- 当前质量门禁:[harness-quality-gates.md](../../architecture/harness-quality-gates.md)
|
||||
- Diagnosis Agent 架构:[agent-orchestration.md](../../architecture/agent-orchestration.md)
|
||||
- 信息增益停止:[diagnosis-information-gain-stop-architecture.md](../../architecture/diagnosis-information-gain-stop-architecture.md)
|
||||
- 核心重构 Issue:[ISS-014-single-react-agent-harness-aci-ptk-refactor.md](../../issues/archived/ISS-014-single-react-agent-harness-aci-ptk-refactor.md)
|
||||
- 信息增益 Issue:[ISS-016-diagnosis-information-gain-stop-contract.md](../../issues/active/ISS-016-diagnosis-information-gain-stop-contract.md)
|
||||
@@ -0,0 +1,371 @@
|
||||
# Harness 设计演进:从多 Agent 编排到确定性控制边界
|
||||
|
||||
Harness 不是一开始就被完整设计出来的。它来自几轮真实重构:系统先拆出多个 Agent 角色,又增加 Gatekeeper 保证证据真实性,随后用 StateGraph 显式管理状态和分支,最终才发现一个更根本的问题:**外层系统正在重复实现 Agent 本身已经具备的 ReAct 生命周期。**
|
||||
|
||||
这篇文章不按提交逐条记流水账,而是追踪每一次设计变化背后的问题:当时为什么这样做、它解决了什么、为什么后来仍然不够,以及哪些思想最终保留了下来。
|
||||
|
||||
第一次阅读只看第 1、2、6、8 和 10 节即可。先建立演进主线,再理解最终边界和可复用经验。
|
||||
|
||||
## 1. 先看完整演进路线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M["多 Agent 分工<br/>Planner / Executor / Verifier / Composer"]
|
||||
G["Gatekeeper<br/>在模型审查前机械验真"]
|
||||
S["StateGraph<br/>显式状态、条件边和终态"]
|
||||
H["Single ReAct + Harness<br/>推理与控制分离"]
|
||||
P["Progress Control<br/>从预算止损到正常收敛"]
|
||||
|
||||
M -->|"证据引用可能伪造"| G
|
||||
G -->|"状态藏在 Service、Hook 和 ThreadLocal"| S
|
||||
S -->|"显式了编排,但仍重复 ReAct"| H
|
||||
H -->|"预算能止损,不能判断继续是否有价值"| P
|
||||
```
|
||||
|
||||
这几次变化不是简单地“旧方案错、新方案对”。每一阶段都解决了当时最明显的问题,同时也让下一个更深层的问题暴露出来。
|
||||
|
||||
| 阶段 | 当时解决的核心问题 | 后来暴露的核心问题 |
|
||||
|---|---|---|
|
||||
| 多 Agent | 复杂诊断如何分工 | 一次 ReAct 被拆成多个模型角色和协议 |
|
||||
| Gatekeeper | 如何阻止伪造证据引用 | 验真依赖旧输出结构、Trace 和隐式上下文 |
|
||||
| StateGraph | 如何显式表达状态、重试和 Fallback | Graph 仍在编排多个重复的推理角色 |
|
||||
| Single ReAct + Harness | 如何分离业务推理与确定性控制 | Agent 仍可能在证据不足时空转 |
|
||||
| Progress Control | 如何让无证据诊断正常停止 | 阈值和信息增益仍需持续校准 |
|
||||
|
||||
## 2. 第一阶段:把复杂诊断拆成多个 Agent
|
||||
|
||||
项目早期采用过 Supervisor 和 Sequential 两类多 Agent 编排。Chat 诊断最终形成了一条固定链路:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["用户问题"] --> P["Planner<br/>制定排查计划"]
|
||||
P --> E["Executor<br/>调用 Tool 收集证据"]
|
||||
E --> G["Gatekeeper<br/>检查证据引用"]
|
||||
G --> V["Verifier<br/>判断 Claim 是否可信"]
|
||||
V --> C["Composer<br/>组织最终回答"]
|
||||
```
|
||||
|
||||
这个方案有合理动机:复杂诊断既要规划、执行,又要验证和表达,把职责拆开比让一个 Prompt 包办所有事情更容易理解。
|
||||
|
||||
它也确实建立了几项重要能力:
|
||||
|
||||
- Planner 不直接编造执行结果;
|
||||
- Executor 专注 Tool 调用和微观事实;
|
||||
- Verifier 不再负责重新检索;
|
||||
- Composer 只能表达经过允许的 Claim;
|
||||
- 每个角色都有自己的结构化输出和测试入口。
|
||||
|
||||
问题出在拆分粒度。Planner、Executor、Verifier 和 Composer 看似是不同业务岗位,实际上刚好覆盖了一次完整 ReAct:
|
||||
|
||||
```text
|
||||
思考 -> Planner
|
||||
行动观察 -> Executor + Tool
|
||||
自我检查 -> Verifier
|
||||
最终回答 -> Composer
|
||||
```
|
||||
|
||||
Agent 框架本来已经支持“思考、调用 Tool、读取 Observation、继续推理、生成答案”。外层再把它拆成四个 Agent 后,系统必须额外维护:
|
||||
|
||||
- 四套 Prompt 和输出 Schema;
|
||||
- Agent 间的 JSON 转换;
|
||||
- 证据和上下文的重复搬运;
|
||||
- PASS、LOW_CONFID、REJECT 与重试分支;
|
||||
- 每个角色各自的 Token、timeout 和错误语义;
|
||||
- Composer 是否严格遵守 Verifier 输出的新风险。
|
||||
|
||||
第一阶段真正留下的经验不是“多 Agent 一定不好”,而是:
|
||||
|
||||
> 只有当角色拥有不同数据权限、不同工具或真正独立的业务目标时,拆成多个 Agent 才可能值得。仅仅把一次 ReAct 的内部步骤外置成多个角色,会放大协议成本。
|
||||
|
||||
## 3. 第二阶段:Gatekeeper 把确定性验真从模型中拿出来
|
||||
|
||||
多 Agent 链路很快遇到另一个问题:Verifier 可以判断一段 evidence excerpt 看起来是否支持 Claim,却不能证明 `source_invocation_id`、`raw_path` 和 excerpt 真正对应某次 Tool 调用。
|
||||
|
||||
如果仍然让模型检查这些字段,就会出现“模型验证模型”的循环。于是系统在 Executor 和 Verifier 之间加入 `ExecutorGatekeeperService`:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["Executor 输出引用"] --> G["Gatekeeper<br/>查 Tool Invocation 和 raw path"]
|
||||
G -->|"真实"| V["Verifier<br/>判断语义支持关系"]
|
||||
G -->|"伪造或错配"| R["拒绝进入 Verifier"]
|
||||
```
|
||||
|
||||
这是演进中一个非常重要、并且最终被保留的决策:
|
||||
|
||||
```text
|
||||
代码能够机械证明的事实,不交给模型判断。
|
||||
```
|
||||
|
||||
Gatekeeper 能拒绝伪造 invocation、错误 raw path 和不匹配的 evidence excerpt,使“引用真实”和“语义成立”第一次成为两个独立问题。
|
||||
|
||||
但旧 Gatekeeper 仍然耦合在多 Agent 协议上:
|
||||
|
||||
- 它读取 Executor 特定的 `executor_evidence_v2`;
|
||||
- 引用协议包含 `source_invocation_id + raw_path + excerpt`;
|
||||
- Verifier 输入依赖 Hook 组装;
|
||||
- Tool Trace 和 Agent 上下文通过 ThreadLocal 等隐式状态关联;
|
||||
- 它只能保护旧 Executor 到 Verifier 的这一段链路。
|
||||
|
||||
后来的 `EvidenceGuard` 不是凭空出现的。它继承了 Gatekeeper 的核心思想,但把真理源改为当前 Run 的 canonical Tool Invocation,并把验证对象改为最终 `DiagnosisDraft`。
|
||||
|
||||
## 4. 第三阶段:StateGraph 让隐式编排变得可见
|
||||
|
||||
随着重试、低置信分支、Fallback、Run Trace 和多个 Agent 输出不断增加,旧 `ChatService + SequentialAgent + Hook + ThreadLocal` 很难回答一个简单问题:**当前诊断到底处于哪个状态,下一步为什么走这条分支?**
|
||||
|
||||
2026-07-17 到 2026-07-20,项目完成了一轮 StateGraph 改造。它将节点、条件边、共享状态和终态显式化,并切换公开 Chat 诊断入口。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["Planner Node"] --> E["Executor Node"]
|
||||
E --> G["Gatekeeper Node"]
|
||||
G --> V["Verifier Node"]
|
||||
V -->|"PASS"| C["Composer Node"]
|
||||
V -->|"补证据"| RP["Evidence Retry Prepare"]
|
||||
RP --> P
|
||||
V -->|"拒绝"| F["Fallback Node"]
|
||||
```
|
||||
|
||||
StateGraph 解决了几个真实问题:
|
||||
|
||||
- 分支不再隐藏在大段 Service `if/else` 中;
|
||||
- Graph State 显式携带 Run 级数据;
|
||||
- Node 和条件边可以独立测试;
|
||||
- Fallback 和 retry 路径可以画出来并验证;
|
||||
- 旧 `VerifierContextHolder`、部分 Hook 和 ThreadLocal 状态得以清理;
|
||||
- `ChatService` 从直接拥有全部诊断细节转为调用 Graph Runtime。
|
||||
|
||||
因此,StateGraph 不是一次无效重构。它提高了旧多 Agent 架构的可见性和可测试性。
|
||||
|
||||
但它解决的是“怎样更清楚地编排这些角色”,没有重新质疑“这些角色是否都应该存在”。结果是:
|
||||
|
||||
- Planner、Executor、Verifier、Composer 仍然各自调用模型;
|
||||
- Graph State 继续搬运多个角色的结构化上下文;
|
||||
- Node Adapter、Result Mapper、条件边和业务协议形成第二套控制结构;
|
||||
- 外层 Graph 决定何时计划、执行、补证据和回答,而框架内 Agent 也在做相似的 ReAct 控制;
|
||||
- 状态机显式了复杂度,却没有消除复杂度。
|
||||
|
||||
这一阶段带来的关键认识是:
|
||||
|
||||
> 显式状态机能够治理复杂流程,但不能证明流程本身有必要。如果核心流程只是一个 Agent 的自然 ReAct,Graph 可能只是把重复实现变得更整齐。
|
||||
|
||||
## 5. 转折点:问题不是编排写得不好,而是重复实现了 ReAct
|
||||
|
||||
ISS-014 对旧架构做了一个更根本的判断:Planner、Executor、Verifier、Composer 并不是四个真正独立的业务主体,而是把一个完整 ReAct 生命周期拆到了 Agent 外部。
|
||||
|
||||
这使设计问题从:
|
||||
|
||||
```text
|
||||
怎样把多 Agent 编排得更清楚?
|
||||
```
|
||||
|
||||
转变为:
|
||||
|
||||
```text
|
||||
哪些决定必须由模型做,哪些约束必须由代码拥有?
|
||||
```
|
||||
|
||||
这个问题带来了新的职责划分:
|
||||
|
||||
| 决定 | 所有者 |
|
||||
|---|---|
|
||||
| 提出故障假设、选择 Tool、解释证据、写 Draft | Diagnosis Agent |
|
||||
| Run 身份、deadline、预算、取消、retry、唯一终态 | Harness Core |
|
||||
| Tool 权限、只读、bytes、canonical truth | ToolBoundary |
|
||||
| 引用是否真实 | EvidenceGuard |
|
||||
| 真实证据是否支持报告 | 隔离的 SemanticGuard |
|
||||
| 发布原 Draft 还是 SafeFallback | Release |
|
||||
|
||||
这不是把所有能力重新塞回一个“大 Agent”。相反,它按**判断性质**而不是按“岗位名称”拆分:
|
||||
|
||||
- 非确定性的业务推理留给 Agent;
|
||||
- 可以机械证明的控制规则交给代码;
|
||||
- 必须使用模型的语义审查被隔离成单轮、无 Tool、无记忆的 Guard。
|
||||
|
||||
## 6. 第四阶段:一个 Diagnosis Agent,加一层 Harness
|
||||
|
||||
最终架构只保留一个拥有业务 Tool loop 的 Diagnosis Agent:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
U["用户问题"] --> APP["Chat Application"]
|
||||
APP --> H["Harness Core<br/>Run、预算、取消、终态"]
|
||||
H --> A["Diagnosis ReAct Agent<br/>假设、Tool、Observation、Draft"]
|
||||
A --> TB["ToolBoundary<br/>执行与 canonical truth"]
|
||||
TB --> A
|
||||
A --> EG["EvidenceGuard<br/>确定性引用验真"]
|
||||
EG --> SG["SemanticGuard<br/>隔离语义审查"]
|
||||
SG --> REL["Release<br/>SUCCESS 或 SafeFallback"]
|
||||
```
|
||||
|
||||
这里做了几项明确取舍。
|
||||
|
||||
### 不再保留业务 StateGraph
|
||||
|
||||
项目不再用外层 Graph 编排 Planner、Executor、Verifier 和 Composer。底层 ReactAgent 框架内部是否使用 Graph 属于框架实现细节,不再成为项目业务协议。
|
||||
|
||||
### 不自己重写 ReAct loop
|
||||
|
||||
Diagnosis Agent 使用框架原生 Tool Calling 和 ReAct。Harness 通过 Model/Tool Interceptor、Hook 和显式 RunContext 接入,不维护第二套 `while` 循环。
|
||||
|
||||
### 不让单 Agent 获得全部权力
|
||||
|
||||
Agent 合并的是业务推理职责,不是安全职责。它不能管理预算、读取 canonical raw、验证自己引用、调用 SemanticGuard 或决定最终发布。
|
||||
|
||||
### SemanticGuard 不是第二个业务 Agent
|
||||
|
||||
它只接收原始问题、Draft 的用户可见语义和 verified evidence,输出 `SUPPORTED / UNSUPPORTED`。它无 Tool、无记忆、不回调主 Agent,也不能改写报告。
|
||||
|
||||
### 迁移按边界而不是按页面完成
|
||||
|
||||
实施顺序先冻结 Contract,再建立 RunContext/Retry Core、Tool Boundary、各类投影、单 Diagnosis Agent、Evidence/Semantic Guard,最后切换 Application/SSE 并删除旧架构。这避免了“先切入口,再补安全边界”的过渡风险。
|
||||
|
||||
## 7. Harness 建成后,问题继续暴露
|
||||
|
||||
单 Agent + Harness 解决了外层重复编排,但真实运行又暴露了几类更细的问题。
|
||||
|
||||
### Tool 成功不等于有证据
|
||||
|
||||
旧代码常用一个 `success` 表达所有含义。后来拆为:
|
||||
|
||||
```text
|
||||
InvocationStatus:Tool 调用是否完成
|
||||
EvidenceStatus:当前 scope 是否返回候选证据
|
||||
SemanticVerdict:证据是否支持报告
|
||||
ReleaseOutcome:最终向用户发布什么
|
||||
```
|
||||
|
||||
状态变多不是为了复杂,而是为了避免 `SUCCESS` 在四层中表达四种不同意思。
|
||||
|
||||
### Agent Observation 不能充当真理源
|
||||
|
||||
Agent 需要的是有界、清洗后的 Tool 结果;EvidenceGuard 需要的是独立、可回读的调用真相;长期 Audit 又不能复制全部敏感 raw。于是形成 canonical truth、Model Observation 和 metadata audit 三层数据责任。
|
||||
|
||||
### 引用真实不等于结论成立
|
||||
|
||||
Gatekeeper 思想被升级为 EvidenceGuard,但仅验引用仍不足以拦截“真实日志被过度解释”。因此保留隔离的 SemanticGuard,并由 Release 掌握唯一出口。
|
||||
|
||||
### 隐藏 retry 会破坏预算和审计
|
||||
|
||||
SDK、HTTP Client 和各模型组件各自重试会让 Token、延迟和 attempt 无法解释。最终只允许 Harness 根据稳定失败分类执行显式 retry;正常 ReAct 下一轮和重新调用 Tool 都不叫 retry。
|
||||
|
||||
## 8. 第五阶段:预算能止损,但不能让诊断正常完成
|
||||
|
||||
单 Agent 运行后又出现一个问题:当知识库未知、日志为空或查询条件不足时,预算只能限制最大调用次数,不能判断继续搜索是否还有价值。
|
||||
|
||||
模型可能不断改写查询,直到:
|
||||
|
||||
```text
|
||||
BUDGET_EXHAUSTED
|
||||
或 INTERNAL_FAILURE
|
||||
```
|
||||
|
||||
用户最终只看到通用错误,却不知道系统已经检查了什么、为什么没有结论。
|
||||
|
||||
于是 Harness 增加 ProgressTracker 和信息增益停止协议:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Tool Result"] --> I{"是否推进当前诊断?"}
|
||||
I -->|"GAINED"| C["继续收集"]
|
||||
I -->|"NO_GAIN"| N["连续无增益计数"]
|
||||
N -->|"未达阈值"| C
|
||||
N -->|"达到阈值"| S["SATURATED / STOP_REQUIRED"]
|
||||
S --> P["ProgressSnapshot"]
|
||||
P --> F["INSUFFICIENT_EVIDENCE Fallback"]
|
||||
```
|
||||
|
||||
这次演进补上了资源控制与任务完成之间的差距:
|
||||
|
||||
- Budget 回答“还能不能继续消耗”;
|
||||
- Information Gain 回答“继续查询是否推进诊断”;
|
||||
- ProgressSnapshot 回答“没有结论时,哪些已检查事实仍可安全告诉用户”。
|
||||
|
||||
最重要的行为变化是:**证据不足成为合法完成,而不是只能撞到预算后失败。**
|
||||
|
||||
## 9. 哪些设计被放弃,哪些思想被保留
|
||||
|
||||
| 曾经的设计 | 最终处理 | 保留下来的思想 |
|
||||
|---|---|---|
|
||||
| Supervisor 调度多个诊断角色 | Chat 主链不再使用 | 复杂任务需要清晰职责边界 |
|
||||
| Planner / Executor / Verifier / Composer | 合并业务推理到一个 Diagnosis Agent | 规划、执行、审查、表达仍需明确责任,只是不必都是 Agent |
|
||||
| Executor Gatekeeper | 旧实现删除 | 确定性验真先于语义审查,演化为 EvidenceGuard |
|
||||
| SequentialAgent | 删除 | 固定业务步骤必须可测试、可观测 |
|
||||
| 业务 StateGraph | 删除 | 状态和终态必须显式,转化为 typed contracts、RunLifecycle 和 Release |
|
||||
| ThreadLocal 上下文 | 删除 | exact Run 归属仍必须传播,改为显式 RunContext |
|
||||
| PASS / LOW_CONFID / REJECT + 补证据循环 | 删除 | 不支持的结论不能发布,改为二元语义门禁和确定性 Fallback |
|
||||
| Tool raw 直接参与上下文与审计 | 分层 | Tool 结果必须可追溯,但不同消费者使用不同视图 |
|
||||
|
||||
好的重构通常不是把过去全部推翻,而是把有效思想从不合适的实现形式中提取出来。
|
||||
|
||||
## 10. 这段演进真正说明了什么
|
||||
|
||||
Harness 最终形成,不是因为团队一开始就知道所有组件,而是逐步回答了四个问题:
|
||||
|
||||
1. **业务推理应该由谁负责?** 一个完整的 Diagnosis ReAct Agent。
|
||||
2. **哪些约束不能依赖 Prompt?** 身份、预算、取消、权限、容量、验真和唯一发布。
|
||||
3. **哪些模型判断必须隔离?** 证据是否支持用户可见报告的 SemanticGuard。
|
||||
4. **证据不足怎样成为正常结果?** ProgressTracker、ProgressSnapshot 和 SafeFallback。
|
||||
|
||||
最终边界可以浓缩为:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["需要理解业务语义和提出假设"] --> A["交给 Diagnosis Agent"]
|
||||
M["能够由代码机械证明"] --> H["交给 Harness"]
|
||||
S["必须使用模型但不能拥有业务循环"] --> G["交给隔离 Guard"]
|
||||
O["决定什么可以公开"] --> R["只交给 Release"]
|
||||
```
|
||||
|
||||
这也是本项目对 Agent 系统最核心的工程判断:
|
||||
|
||||
> 不要围绕模型的“角色感”设计系统,而要围绕决策权、真理源和失败责任设计边界。
|
||||
|
||||
## 11. 这套演进的代价和未完成问题
|
||||
|
||||
当前方案不是没有代价:
|
||||
|
||||
- Harness 类型和状态较多,需要统一 Context 防止误读;
|
||||
- canonical store 引入 Redis TTL、容量和访问控制成本;
|
||||
- EvidenceGuard 与 SemanticGuard 增加发布延迟;
|
||||
- 信息增益依赖模型对非空结果的二元评价,仍可能误判;
|
||||
- `NO_GAIN` 阈值、Token 和 timeout 需要根据 Trace 持续校准;
|
||||
- 当前 Mock 日志和未配置的业务 MySQL 数据源限制了真实诊断覆盖面。
|
||||
|
||||
但这些复杂度与旧多 Agent/Graph 的复杂度性质不同:旧复杂度主要用于搬运推理过程,当前复杂度主要用于保护身份、资源、事实和发布边界。前者会随角色数增长,后者围绕稳定的不变量增长。
|
||||
|
||||
## 12. 面试时如何讲这段演进
|
||||
|
||||
可以用下面这段话概括:
|
||||
|
||||
> 项目最初把复杂诊断拆成 Planner、Executor、Verifier 和 Composer,并通过 Gatekeeper 验证证据引用;为了治理 Service、Hook 和 ThreadLocal 中的隐式分支,又引入 StateGraph 显式管理状态和终态。Graph 提高了可测试性,但没有解决根问题:外层仍在重复框架已有的 ReAct 生命周期,并产生多套 Prompt、Schema、重试和上下文搬运。后来我们按决策性质重新划分责任,只保留一个拥有 Tool loop 的 Diagnosis Agent,把 Run、预算、取消、Tool 真相、证据验真和发布权放进确定性 Harness,语义审查则隔离成无 Tool、无记忆的单轮 Guard。最后又通过信息增益协议,让证据不足从预算失败变成可解释的正常 Fallback。
|
||||
|
||||
这段回答的重点不是“我们用了哪些框架”,而是展示:系统怎样从症状修补逐步走到责任边界重构。
|
||||
|
||||
## 13. 事实来源与延伸阅读
|
||||
|
||||
关键演进节点可由 Git 提交确认:
|
||||
|
||||
| 日期 | 代表提交 | 含义 |
|
||||
|---|---|---|
|
||||
| 2026-07-03 | `f01866c` | Chat 切换 Sequential Agent |
|
||||
| 2026-07-08 | `c5e496e`、`1b31e78`、`6015bcb` | Gatekeeper、Verifier、Composer 完成 |
|
||||
| 2026-07-17 | `581daff`、`99e490f` | StateGraph 设计冻结并切换 Chat |
|
||||
| 2026-07-20 | `190013c` | StateGraph 清理与验收完成 |
|
||||
| 2026-07-21 | `58c3910` 至 `f8809cb` | Single ReAct、Harness、Tool、Guard 和 Application 分阶段落地 |
|
||||
| 2026-07-22 | `8ee7cc0` | 删除旧 Agent 架构 |
|
||||
| 2026-07-27 | `d045218`、`38f781b` | 信息增益停止与协议修复完成 |
|
||||
|
||||
主要历史资料:
|
||||
|
||||
- `mvp/architecture/archive/2026-07-22-legacy/agent-orchestration.md`;
|
||||
- `mvp/issues/archived/ISS-014-single-react-agent-harness-aci-ptk-refactor.md`;
|
||||
- `devflow/projects/2026-07-21-single-react-design-freeze/decisions.md`;
|
||||
- `openspec/changes/archive/2026-07-27-diagnosis-information-gain-stop-contract/`。
|
||||
|
||||
继续阅读:
|
||||
|
||||
- [Harness 入门](README.md)
|
||||
- [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)
|
||||
- [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md)
|
||||
- [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md)
|
||||
- [组件渐进式导读](components/README.md)
|
||||
@@ -0,0 +1,276 @@
|
||||
# Harness 证据安全链:从引用真实到结论可发布
|
||||
|
||||
**更新日期**:2026-07-29
|
||||
**主题**:EvidenceGuard、EvidenceRepair、SemanticGuard 与 Diagnosis Release
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**前置阅读**:[Harness-Tool双视图-从原始结果到可验证证据.md](Harness-Tool双视图-从原始结果到可验证证据.md)
|
||||
|
||||
## 1. 证据存在,不代表结论成立
|
||||
|
||||
诊断报告至少可能出现三类不同错误:
|
||||
|
||||
1. **伪造引用**:Draft 引用了不存在、失败或属于其他 Run 的 Tool Call。
|
||||
2. **引用断裂**:analysis 有证据,但 conclusion、action plan 或 recommendation 没有绑定对应 analysis。
|
||||
3. **牵强推论**:引用和结构都真实,但证据并不足以支持所写根因。
|
||||
|
||||
例如,系统确实查到了一条支付超时日志,但报告把根因写成“数据库连接池耗尽”。此时 Tool Call 真实、日志真实、引用格式也正确,结论仍然不成立。
|
||||
|
||||
因此“报告是否安全”不能由一个笼统的 Verifier 回答。它至少包含两个不同命题:
|
||||
|
||||
```text
|
||||
P1:引用的证据是否真实、属于当前 Run,并形成结构闭包?
|
||||
P2:这些真实证据是否足以支持报告中的结论?
|
||||
```
|
||||
|
||||
P1 可以由代码确定性证明;P2 是语义判断。把两者都交给模型,会让本可机械验证的身份和状态也变成概率结果;全部交给规则,则规则代码会逐渐变成另一套业务诊断引擎。
|
||||
|
||||
## 2. 设计目标:验证链不能产生新事实
|
||||
|
||||
证据安全链遵守四个约束:
|
||||
|
||||
- Diagnosis Agent 是唯一报告作者;
|
||||
- EvidenceGuard 只做确定性验真;
|
||||
- SemanticGuard 只做受限语义审查;
|
||||
- Release 是唯一公开决策点。
|
||||
|
||||
任何 Guard 都不能调用业务 Tool、探索新证据或重写最终报告。否则验证链会变成第二条隐式 Agent 链,重新引入多 Agent 架构的问题。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["DiagnosisDraft<br/>唯一业务 Draft"] --> R["DiagnosisReleaseUseCase<br/>发布编排所有者"]
|
||||
R --> E["EvidenceGuard<br/>确定性引用验真"]
|
||||
E -->|"invalid"| X["EvidenceRepair<br/>只修结构/引用"]
|
||||
X --> E2["EvidenceGuard Recheck"]
|
||||
E -->|"valid"| V["VerifiedEvidenceSnapshot"]
|
||||
E2 -->|"valid"| V
|
||||
V --> S["SemanticGuard<br/>SUPPORTED / UNSUPPORTED"]
|
||||
S -->|"SUPPORTED"| OK["发布原始或等义修复 Draft"]
|
||||
E -->|"repair 失败"| F["SafeFallback"]
|
||||
E2 -->|"invalid"| F
|
||||
S -->|"UNSUPPORTED / unavailable"| F
|
||||
```
|
||||
|
||||
## 3. 第一层:EvidenceGuard 证明引用真实性
|
||||
|
||||
`EvidenceGuard` 不调用模型。它从当前 `RunContext` 出发,对 Draft 进行两阶段检查。
|
||||
|
||||
### 3.1 Draft 结构与引用闭包
|
||||
|
||||
首先检查报告自身结构:
|
||||
|
||||
- Draft、analysis、analysis ID、kind 和正文是否存在;
|
||||
- analysis ID 是否重复;
|
||||
- 每条有结论分析是否绑定 Tool Call;
|
||||
- conclusion、action plan 和 recommendation 是否绑定已存在的 analysis ID;
|
||||
- limitations 是否存在并声明报告范围。
|
||||
|
||||
引用链的目标不是让 JSON 看起来完整,而是形成下面的闭包:
|
||||
|
||||
```text
|
||||
Conclusion / Action / Recommendation
|
||||
|
|
||||
v
|
||||
Analysis Item
|
||||
|
|
||||
v
|
||||
framework tool_call_id
|
||||
|
|
||||
v
|
||||
Canonical READY Invocation
|
||||
```
|
||||
|
||||
只要其中一跳缺失,公开报告就不能证明其来源。
|
||||
|
||||
### 3.2 Canonical Invocation 验真
|
||||
|
||||
对每个 `tool_call_id`,Guard 使用当前 `runId` 重新生成 canonical key,然后检查:
|
||||
|
||||
1. ID 格式是否合法;
|
||||
2. canonical record 是否存在;
|
||||
3. record 内 ID 是否与引用一致;
|
||||
4. record 是否属于当前 Run;
|
||||
5. invocation 是否 READY;
|
||||
6. analysis kind 是否接受该 evidence status;
|
||||
7. Tool 类型是否受支持;
|
||||
8. `agent_result` 是否能严格解析为对应 typed result;
|
||||
9. typed result 中的 ID、状态、count 和集合是否自洽。
|
||||
|
||||
因此,即使模型猜中了另一个 Run 的 Tool Call ID,也无法通过当前 Run key 和 ownership 检查。
|
||||
|
||||
### 3.3 正向证据和负向观察不能混用
|
||||
|
||||
`AnalysisKind` 与 `EvidenceStatus` 的匹配是关键约束。
|
||||
|
||||
一条 READY + NO_EVIDENCE 日志结果,只能支持如下陈述:
|
||||
|
||||
> 在企业 A、时间窗 T、服务 S、查询条件 Q 下没有匹配事件。
|
||||
|
||||
它不能支持:
|
||||
|
||||
> 系统没有发生故障。
|
||||
|
||||
EvidenceGuard 会把空结果投影为带 source 和 scope 的负向观察,而不是把它提升为支持任意根因的正向证据。
|
||||
|
||||
## 4. VerifiedEvidenceSnapshot 为什么是必要中间产物
|
||||
|
||||
EvidenceGuard 验证通过后不会把 canonical raw 直接交给 SemanticGuard,而是生成 `VerifiedEvidenceSnapshot`。它保存:
|
||||
|
||||
- analysis ID、正文和 kind;
|
||||
- 已验证证据的 source type、source、scope、timestamp、excerpt;
|
||||
- Tool-specific 的有限结构化 values;
|
||||
- 去重后的 verified sources。
|
||||
|
||||
Snapshot 的作用是形成一条新的最小信任边界:SemanticGuard 不需要访问 Redis,也不能看到 request、raw response 或内部错误。它只判断“这组已经验真的事实是否支持 Draft”。
|
||||
|
||||
这也避免了语义审查阶段重新解释 backend 私有格式。
|
||||
|
||||
## 5. 第二层:EvidenceRepair 只允许修引用
|
||||
|
||||
### 5.1 为什么需要 Repair
|
||||
|
||||
模型可能生成业务语义正确、但引用结构存在局部问题的 Draft,例如:
|
||||
|
||||
- conclusion 漏写 `based_on_analysis_ids`;
|
||||
- analysis 引用了错误的 Tool Call ID;
|
||||
- 输出字段结构不符合严格 Schema。
|
||||
|
||||
如果所有结构错误都直接 Fallback,会丢掉部分可修复结果。但让 Repair 自由重写又会产生更严重的问题:最终结论不再来自 Diagnosis Agent,Repair 事实上成为第二个报告作者。
|
||||
|
||||
### 5.2 Repair 的不变量
|
||||
|
||||
`EvidenceRepair` 只获得:
|
||||
|
||||
- 原始 query;
|
||||
- 原始 Draft;
|
||||
- EvidenceGuard 给出的 violations。
|
||||
|
||||
它没有 Tool,也不能获取新证据。修复输出必须再次严格解析,并满足:
|
||||
|
||||
```text
|
||||
SemanticDraftView(original)
|
||||
==
|
||||
SemanticDraftView(repaired)
|
||||
```
|
||||
|
||||
即用户可见的 analysis、conclusion、action plan、recommendations 和 limitations 语义不变,只允许修复引用和结构。之后必须重新执行 EvidenceGuard,不能因为“已经 Repair”就跳过验真。
|
||||
|
||||
当前策略只给 EvidenceRepair 一次 attempt。Repair 失败、改变语义或复检仍不通过,直接进入 `EVIDENCE_VALIDATION_FAILED` Fallback。
|
||||
|
||||
## 6. 第三层:SemanticGuard 判断结论支持度
|
||||
|
||||
EvidenceGuard 证明的是“证据是真的”,SemanticGuard 判断的是“推论是否成立”。为了不让它变成第二个 Agent,系统主动削减了它的能力:
|
||||
|
||||
| 约束 | 目的 |
|
||||
|---|---|
|
||||
| 无 Tool | 不能自行寻找新事实 |
|
||||
| 无会话记忆 | 不能引入当前输入之外的信息 |
|
||||
| 单轮调用 | 不形成新的 ReAct loop |
|
||||
| 输入固定 | 只接收 query、Draft 和 VerifiedEvidenceSnapshot |
|
||||
| 输出严格 | 只能返回 `verdict + reason` |
|
||||
| 二值 verdict | 只允许 `SUPPORTED / UNSUPPORTED` |
|
||||
| 无发布权限 | 不能改写或直接返回用户报告 |
|
||||
|
||||
技术超时、传输失败、解析失败和 Schema 非法可以按完全相同输入进行一次显式重试。仍失败时不假设“可能支持”,而是 fail closed,发布 `SEMANTIC_UNAVAILABLE` Fallback。
|
||||
|
||||
这里需要准确描述“确定性边界”:SemanticGuard 的语义判断仍然是概率性的;确定的是它的权限、输入、输出、成本、重试次数和失败后的发布行为。
|
||||
|
||||
## 7. Release 为什么必须是唯一发布入口
|
||||
|
||||
如果 Application、EvidenceGuard 和 SemanticGuard 都能构造公开结果,同一种失败会出现多个含义,甚至可能绕过前置校验。`DiagnosisReleaseUseCase` 集中处理以下分支:
|
||||
|
||||
| 输入情况 | 验证路径 | 发布结果 |
|
||||
|---|---|---|
|
||||
| Draft 有 conclusion | EvidenceGuard -> 可选 Repair/Recheck -> SemanticGuard | 原 Draft/等义修复 Draft,或安全 Fallback |
|
||||
| Draft 无 conclusion,有已验真进展 | 只验证其中已有 Tool 引用 | `INSUFFICIENT_EVIDENCE` |
|
||||
| Draft 无 conclusion,无进展但声明缺失信息 | 检查引用边界 | `MISSING_REQUIRED_CONTEXT` |
|
||||
| Harness 受控停止,有已验真进展 | ProgressSnapshot | `INSUFFICIENT_EVIDENCE` |
|
||||
| Draft 非法,但有已验真进展 | 丢弃非法 Draft,使用 ProgressSnapshot | `INSUFFICIENT_EVIDENCE` |
|
||||
| Draft 非法且没有安全进展 | 无可发布事实 | FAILED,保持 fail closed |
|
||||
|
||||
有结论时,只有 `SUPPORTED` 可以发布 Draft。发布的是 Diagnosis Agent 原始 Draft,或者经证明用户可见语义相同的 Repair Draft,不让 SemanticGuard生成“更好的答案”。
|
||||
|
||||
## 8. `conclusion=null` 为什么不进入完整语义审查
|
||||
|
||||
当模型明确表示无法确认根因时,不存在需要验证的根因结论。继续调用 EvidenceRepair 或 SemanticGuard,不仅浪费 Token,还可能让验证模型反向补出一个原本不存在的结论。
|
||||
|
||||
因此无结论路径只做必要的引用真实性检查,然后根据安全进展决定:
|
||||
|
||||
```text
|
||||
有 verified progress
|
||||
-> INSUFFICIENT_EVIDENCE
|
||||
|
||||
没有 progress,但有 limitations.missing_info
|
||||
-> MISSING_REQUIRED_CONTEXT
|
||||
|
||||
两者都没有
|
||||
-> 不是合法业务结果,fail closed
|
||||
```
|
||||
|
||||
这项设计把“没有找到根因”从模型失败中分离出来,但没有降低证据要求。
|
||||
|
||||
## 9. SafeFallback 不是通用错误文案
|
||||
|
||||
SafeFallback 是结构化、确定性的发布结果。它可以包含:
|
||||
|
||||
- `verified_sources`:通过当前 Run 验真的来源;
|
||||
- `observed_facts`:有界、去重后的事实或负向观察;
|
||||
- `limitations`:为什么不能确认根因;
|
||||
- `next_steps`:下一步需要补充的信息或检查;
|
||||
- `validation_issues`:稳定违规码和目标字段;
|
||||
- `failure_stage`:失败发生在收集、证据还是语义阶段。
|
||||
|
||||
它不能包含 Prompt、原始 Draft、raw Tool payload、内部异常或模型 reasoning。Fallback 不是“把错误吞掉”,而是只发布系统已经能够证明的部分。
|
||||
|
||||
## 10. 为什么不采用其他方案
|
||||
|
||||
### 10.1 单个 Verifier Agent
|
||||
|
||||
优点是实现表面简单,缺点是把 ID、Run ownership、Schema 和业务支持度混在同一次模型判断中。本来可以 100% 用代码拒绝的跨 Run 引用,也会变成概率审查。
|
||||
|
||||
### 10.2 Guard 自动改写报告
|
||||
|
||||
可以提高表面成功率,但破坏唯一作者原则。Guard 为了“修好”结论,往往会引入新解释;此时必须重新验证新内容,最终形成循环。
|
||||
|
||||
### 10.3 只做引用存在检查
|
||||
|
||||
能阻止伪造 ID,却无法阻止“真实证据 + 错误根因”。这正是 SemanticGuard 存在的原因。
|
||||
|
||||
### 10.4 语义失败直接技术报错
|
||||
|
||||
丢失了已经验真的事实,也把“结论不受支持”错误表达为基础设施故障。SafeFallback 可以保留过程价值,同时拒绝发布根因。
|
||||
|
||||
## 11. 真实问题如何改变设计
|
||||
|
||||
| 问题 | 暴露的设计缺陷 | 修正 |
|
||||
|---|---|---|
|
||||
| 旧 Verifier 同时验引用、判断语义和改写答案 | 验证职责没有边界 | EvidenceGuard、SemanticGuard、Release 三段拆分 |
|
||||
| Tool 结果面向开发者,含 raw 和重复正文 | Guard 与 Agent 没有独立事实面 | 先建立 canonical truth 和 verified snapshot |
|
||||
| `NO_EVIDENCE` 被当成一般失败或正向证据 | 负向观察没有范围约束 | AnalysisKind 与 EvidenceStatus 匹配校验 |
|
||||
| Repair 可能改变结论 | 修复者变成新的报告作者 | SemanticDraftView 前后等义检查 |
|
||||
| 非法 Draft 使已完成 Tool 检查全部丢失 | Draft 是唯一可发布价值来源 | 只在 ProgressSnapshot 已验真时允许过程型 Fallback |
|
||||
| SemanticGuard 技术失败时行为不一致 | 各层自行决定降级 | Release 统一生成 `SEMANTIC_UNAVAILABLE` |
|
||||
|
||||
## 12. 代价与剩余风险
|
||||
|
||||
1. SemanticGuard 增加一次模型调用和延迟;这是语义安全与成本之间的明确取舍。
|
||||
2. Semantic verdict 不是形式化证明,仍可能误判;当前设计限制的是权限和失败影响,而不是宣称模型绝对正确。
|
||||
3. EvidenceGuard 需要理解每种 Tool 的 typed result;新增 Tool 必须同步增加验证规则。
|
||||
4. Repair 的语义等价由结构化视图定义,无法证明两个自然语言文本在所有解释下完全等价,因此 Repair 能力被刻意限制。
|
||||
5. Canonical record TTL 到期后无法重新完成完整验真,所以公开决策必须在当前 Run 内完成。
|
||||
|
||||
## 13. 如何验证
|
||||
|
||||
| 需要证明 | 代表性验证 |
|
||||
|---|---|
|
||||
| 缺失、重复、未知 analysis 引用被拒绝 | `EvidenceGuardTest` |
|
||||
| cross-run、非 READY、ID/status 不一致被拒绝 | `EvidenceGuardTest` + canonical fixtures |
|
||||
| NO_EVIDENCE 只能形成限定负向观察 | Evidence kind focused cases |
|
||||
| Repair 不得改变用户可见语义 | `DiagnosisReleaseUseCaseTest`、Repair tests |
|
||||
| Semantic 非法输出只有限重试并安全降级 | `SemanticGuardTest` |
|
||||
| Guard 不改写报告,Release 只发布原 Draft 或 Fallback | `DiagnosisReleaseUseCaseTest` |
|
||||
| 无结论、非法 Draft、受控停止正确映射 | `DiagnosisChatExecutorTest`、Release focused cases |
|
||||
| exact-run Trace 能解释每一道门禁 | live E2E Timeline |
|
||||
|
||||
## 14. 与前后链路的关系
|
||||
|
||||
证据安全链依赖 [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)提供独立 canonical truth;它解决的是“哪些内容能够发布”,不负责判断 Agent 是否还应该继续取证。正常收敛由[Harness信息增益停止-让无证据诊断正常收敛.md](Harness信息增益停止-让无证据诊断正常收敛.md)负责。
|
||||
@@ -0,0 +1,304 @@
|
||||
# Harness 面试速查:用一张图讲清设计
|
||||
|
||||
这篇文章是 Harness 系列的收尾,不增加新的组件和状态。它把现有设计压缩成一套可在面试中逐层展开的叙事:先用一句话定义,再用一张图说明边界,最后根据追问进入事实、停止、发布和失败设计。
|
||||
|
||||
如果只剩 10 分钟,阅读第 1、2、4 和 8 节即可。
|
||||
|
||||
## 1. 30 秒回答:什么是 Harness
|
||||
|
||||
> Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。
|
||||
|
||||
这段回答包含三个重点:
|
||||
|
||||
```text
|
||||
Agent 负责业务推理
|
||||
Harness 负责确定性约束
|
||||
Release 决定什么可以公开
|
||||
```
|
||||
|
||||
不要一开始列 10 个职责域。面试官追问“具体怎么做”时,再沿下面的总图展开。
|
||||
|
||||
## 2. 一张图讲清完整设计
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["用户问题"] --> APP["Chat Application<br/>创建 Run、路由、持久化、SSE"]
|
||||
|
||||
subgraph CONTROL["一、运行控制"]
|
||||
CORE["Harness Core<br/>identity / deadline / budget<br/>cancel / lifecycle / retry"]
|
||||
PROGRESS["Progress Control<br/>重复、信息增益、停止"]
|
||||
end
|
||||
|
||||
subgraph REASONING["二、业务推理"]
|
||||
AGENT["Diagnosis ReAct Agent<br/>假设、选 Tool、解释 Observation、写 Draft"]
|
||||
end
|
||||
|
||||
subgraph TRUTH["三、事实边界"]
|
||||
TB["ToolBoundary<br/>授权、只读、容量、状态迁移"]
|
||||
CAN["Canonical Invocation<br/>当前 Run 的短期完整真相"]
|
||||
OBS["Model Observation<br/>模型可见的有界投影"]
|
||||
AUDIT["Metadata Audit<br/>长期可观测账本"]
|
||||
end
|
||||
|
||||
subgraph PUBLICATION["四、验证发布"]
|
||||
EG["EvidenceGuard<br/>引用是否真实"]
|
||||
SG["SemanticGuard<br/>证据是否支持结论"]
|
||||
REL["Release<br/>原报告或 SafeFallback"]
|
||||
end
|
||||
|
||||
APP --> CORE
|
||||
APP --> AGENT
|
||||
CORE -.->|"RunContext 控制句柄"| AGENT
|
||||
CORE -.->|"active / budget 门禁"| TB
|
||||
AGENT --> PROGRESS
|
||||
AGENT -->|"Tool Call"| TB
|
||||
TB --> CAN
|
||||
CAN --> OBS --> AGENT
|
||||
TB -.-> AUDIT
|
||||
AGENT -->|"DiagnosisDraft"| REL
|
||||
REL --> EG
|
||||
CAN --> EG --> SG --> REL
|
||||
PROGRESS -->|"无结论或受控停止"| REL
|
||||
REL --> APP --> U
|
||||
```
|
||||
|
||||
这张图不表示 Harness 替 Agent 安排固定步骤。Agent 自己决定查什么、何时形成 Draft;Harness 只在每个边界回答:
|
||||
|
||||
- 这一步是否属于当前 active Run;
|
||||
- 是否仍有时间、预算和调用权限;
|
||||
- Tool 事实应该保存在哪里,模型可以看到多少;
|
||||
- Agent 的引用能否从当前 Run 的真实调用中验出;
|
||||
- 现有证据是否足以支持对用户公开的结论;
|
||||
- 无法继续时,应该发布安全说明还是失败。
|
||||
|
||||
## 3. 一次请求怎样穿过 Harness
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Client
|
||||
participant APP as Chat Application
|
||||
participant CORE as Harness Core
|
||||
participant A as Diagnosis Agent
|
||||
participant I as Tool Interceptor
|
||||
participant T as ToolBoundary
|
||||
participant C as Canonical Store
|
||||
participant R as Release Pipeline
|
||||
|
||||
U->>APP: 支付服务为什么超时?
|
||||
APP->>CORE: startRun(sessionId)
|
||||
CORE-->>APP: RunContext(runId, deadline, handles)
|
||||
APP->>A: query + bounded PreviousTurn
|
||||
|
||||
loop 框架原生 ReAct
|
||||
A->>I: Tool Call Envelope
|
||||
I->>I: progress / duplicate / saturation
|
||||
I->>T: business input + RunContext
|
||||
T->>T: active / auth / readonly / budget / bytes
|
||||
T->>C: PROJECTING -> READY or ERROR
|
||||
T-->>I: canonical projected result
|
||||
I-->>A: 有界 Model Observation
|
||||
end
|
||||
|
||||
A-->>R: DiagnosisDraft
|
||||
R->>C: 回读当前 Run 的 Tool 真相
|
||||
R->>R: EvidenceGuard + SemanticGuard
|
||||
alt 验证通过
|
||||
R-->>APP: 原始安全报告 / SUCCESS
|
||||
else 证据不足或门禁失败
|
||||
R-->>APP: SafeFallback / FALLBACK
|
||||
end
|
||||
APP->>CORE: first terminal wins
|
||||
APP-->>U: content or failure + done
|
||||
```
|
||||
|
||||
讲这条链路时只需要抓住四个时间点:
|
||||
|
||||
1. Agent 运行前先建立 Run 边界。
|
||||
2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定。
|
||||
3. Agent 只看到有界观察,完整事实由系统独立保管。
|
||||
4. Draft 必须经过唯一发布出口,不能直接发送给用户。
|
||||
|
||||
## 4. 三个最核心的设计决策
|
||||
|
||||
### 决策一:按决策权拆分,而不是按角色拆分
|
||||
|
||||
项目早期使用过 Planner、Executor、Verifier、Composer 和 StateGraph。它提高了职责可见性,但也把一次自然 ReAct 拆成多个模型调用、Prompt、Schema 和状态搬运。
|
||||
|
||||
最终选择是:
|
||||
|
||||
| 决策 | 所有者 |
|
||||
|---|---|
|
||||
| 提出假设、选择 Tool、解释证据、撰写 Draft | Diagnosis Agent |
|
||||
| 身份、预算、取消、权限、容量、唯一终态 | Harness |
|
||||
| 引用能否被代码机械证明 | EvidenceGuard |
|
||||
| 真实证据是否支持用户可见结论 | 隔离的 SemanticGuard |
|
||||
| 发布正常报告还是 SafeFallback | Release |
|
||||
|
||||
这里不是把多个 Agent 粗暴合并成一个“大 Agent”。业务推理合并了,安全权力反而被拆得更清楚:Agent 没有 canonical raw 读取权、不能验证自己的引用,也没有最终发布权。
|
||||
|
||||
放弃的方案:在外层继续编排多 Agent 或重新实现一套 ReAct loop。
|
||||
|
||||
获得的能力:唯一业务上下文、唯一 Draft 作者、控制规则可测试。
|
||||
|
||||
付出的代价:Harness 契约和门禁必须完整,不能再依赖角色之间“互相提醒”。
|
||||
|
||||
### 决策二:系统事实、模型观察和长期审计不能共用一份数据
|
||||
|
||||
同一份 Tool 结果要服务三个互相冲突的目标:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RAW["Tool backend raw result"] --> TB["ToolBoundary + Projector"]
|
||||
TB --> CAN["Canonical truth<br/>当前 Run、短 TTL、可验真"]
|
||||
CAN --> OBS["Model Observation<br/>白名单、有界、服务推理"]
|
||||
CAN --> GUARD["EvidenceGuard<br/>独立回读、验证引用"]
|
||||
TB -.-> META["Metadata Audit<br/>身份、状态、耗时、bytes"]
|
||||
```
|
||||
|
||||
如果 raw 直接给模型,上下文、敏感数据和 prompt injection 风险不可控;如果只保存裁剪后的 Observation,EvidenceGuard 无法独立证明 Agent 引用了真实结果;如果把完整 raw 永久写入审计库,又会制造敏感数据副本。
|
||||
|
||||
因此当前设计将数据责任拆开:
|
||||
|
||||
- Redis canonical invocation 保存当前 Run 的短期完整 Tool 真相;
|
||||
- Model Observation 只包含 Agent 下一步推理所需字段;
|
||||
- 长期 Audit 只保存 identity、状态、耗时、Token 和 bytes 等元数据;
|
||||
- EvidenceGuard 从 canonical store 验证物理真实性;
|
||||
- SemanticGuard 只在 verified evidence 上判断语义支持关系。
|
||||
|
||||
放弃的方案:一份 Tool JSON 在 Agent、Guard 和数据库之间直接流转。
|
||||
|
||||
获得的能力:模型不能靠自己看到的内容完成自证,长期审计也不必复制全部敏感正文。
|
||||
|
||||
付出的代价:每类 Tool 都需要 projector、canonical contract 和容量策略,Redis TTL 也成为验证可用性的边界。
|
||||
|
||||
### 决策三:把“如何结束”设计成一等能力
|
||||
|
||||
Agent 系统最常见的问题不只是错误,而是无法正常结束:空日志、通用知识和相似查询都可能让模型持续尝试,最后撞上预算。
|
||||
|
||||
当前 Harness 使用两套不同机制:
|
||||
|
||||
```text
|
||||
Budget:还能不能继续消耗资源
|
||||
Information Gain:继续查询是否推进诊断
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
X["一次 Tool 结果或执行问题"] --> C{"Run 还能继续?"}
|
||||
C -->|"可以"| G{"结果是否推进诊断?"}
|
||||
G -->|"GAINED"| N["继续 ReAct"]
|
||||
G -->|"连续 NO_GAIN"| S["SATURATED<br/>停止新增 Tool"]
|
||||
C -->|"不可以"| P{"已有可验证进展?"}
|
||||
S --> P
|
||||
P -->|"有"| F["SafeFallback<br/>已检查内容、限制和下一步"]
|
||||
P -->|"无"| E["FAILED<br/>不发布未验证内容"]
|
||||
N --> D{"最终 Draft 可发布?"}
|
||||
D -->|"是"| OK["SUCCESS"]
|
||||
D -->|"否"| F
|
||||
```
|
||||
|
||||
`READY + NO_EVIDENCE` 表示查询成功但当前 scope 为空,不是技术异常;`conclusion=null` 是合法 Draft,不是模型失败;`SATURATED` 只停止收集,不是 Run 终态;`FALLBACK` 表示请求被安全处理但没有正常报告,也不等于 `RunState.FAILED`。
|
||||
|
||||
放弃的方案:强制 Agent 必须给出根因,或者只依靠硬预算和全局异常结束。
|
||||
|
||||
获得的能力:证据不足可以成为诚实、可解释的产品结果,局部 Tool 故障也不必立即摧毁整个 Run。
|
||||
|
||||
付出的代价:RunState、Tool 状态、CollectionState 和 ReleaseOutcome 必须保持正交,排障不能只看一个 `status`。
|
||||
|
||||
## 5. 这套设计最难的地方是什么
|
||||
|
||||
面试中不要把难点回答成“接入了 Spring AI”或“写了很多 Interceptor”。真正困难的是确定责任边界,并让这些边界在异常和竞态下仍成立。
|
||||
|
||||
| 难点 | 核心问题 | 当前答案 |
|
||||
|---|---|---|
|
||||
| Run 归属 | 异步调用和多轮 Session 中,事实到底属于哪次执行 | 显式 `RunContext` 和 exact `runId` |
|
||||
| 事实可信 | Agent 引用的 Tool 内容如何独立验真 | canonical invocation + EvidenceGuard |
|
||||
| 结论可信 | 引用真实但推论牵强怎么办 | 隔离的 SemanticGuard |
|
||||
| 正常收敛 | 没有证据时如何避免反复查询 | Information Gain + SATURATED + ProgressSnapshot |
|
||||
| 失败一致 | 超时、预算、Tool ERROR 和断开如何对应结果 | first-terminal-wins + 唯一 Release Policy |
|
||||
| 可观测性 | 如何回放决策又不永久保存敏感正文 | metadata audit + 短期 canonical truth |
|
||||
|
||||
如果面试官只允许选一个,回答“事实可信”最能代表这套设计:它要求系统同时解决 Tool 身份、Run 归属、数据视图、证据引用和最终发布,而不是只调一个模型接口。
|
||||
|
||||
## 6. 用真实案例讲 2 分钟
|
||||
|
||||
可以使用支付超时案例:
|
||||
|
||||
> 用户要求诊断支付服务超时。Application 先创建独立 Run,并为 Router、Agent、Tool 和 Guard 共享同一套 deadline、预算和取消能力。Diagnosis Agent 自主调用知识库和日志 Tool;ToolBoundary 执行调用并把完整事实保存为当前 Run 的 canonical invocation,只把有界 Observation 返回给 Agent。Agent 根据两个 Tool 结果生成 Draft,但 Draft 没有直接发给用户。EvidenceGuard 回读 canonical store 后发现引用无法完成真实性校验,因此 Release 没有继续让模型润色或猜测,而是发布 `EVIDENCE_VALIDATION_FAILED` SafeFallback。最终数据库记录请求处理成功、ReleaseOutcome 为 FALLBACK,SSE 也完整结束,但未经验证的根因没有离开系统。
|
||||
|
||||
这个案例的价值不在于“最终失败了”,而在于证明:
|
||||
|
||||
```text
|
||||
Tool READY != 引用已验真
|
||||
引用已验真 != 结论被支持
|
||||
Agent 生成 Draft != 报告允许发布
|
||||
```
|
||||
|
||||
完整数据和过程见[支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md)。
|
||||
|
||||
## 7. 常见追问怎样展开
|
||||
|
||||
| 面试官追问 | 回答主线 | 深入阅读 |
|
||||
|---|---|---|
|
||||
| 为什么不继续使用多 Agent? | 多角色重复实现 ReAct;改为按决策权拆分 | [设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
|
||||
| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
|
||||
| 全部组件有哪些? | 先讲四组,再按需展开 10 个职责域 | [组件全景](Harness组件全景-职责-设计原因与边界.md) |
|
||||
| Tool 结果为什么不直接给模型? | 真相、观察和长期审计有不同数据责任 | [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) |
|
||||
| 如何防止 Agent 编造证据? | framework Tool ID、canonical store、EvidenceGuard | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
|
||||
| EvidenceGuard 已经通过,为什么还要 SemanticGuard? | 引用真实不等于结论被支持 | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
|
||||
| Agent 为什么不会无限调用 Tool? | 预算止损,信息增益负责正常收敛 | [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) |
|
||||
| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 | [失败图谱](Harness失败图谱-异常-停止-降级与终态.md) |
|
||||
| FALLBACK 算成功还是失败? | RunState、ReleaseOutcome、数据库和 SSE 是不同视图 | [生命周期与状态](Harness生命周期与状态.md) |
|
||||
| 如何验证不是纸面设计? | focused tests 证明不变量,live E2E 验证组合契约 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) |
|
||||
| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、Reasoning 治理 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
|
||||
|
||||
## 8. 面试中最容易讲错的六件事
|
||||
|
||||
### 不要说:Harness 负责安排 Agent 的执行步骤
|
||||
|
||||
应说:ReAct Agent 自己选择 Tool 和下一步,Harness 负责运行边界、事实边界和发布边界。
|
||||
|
||||
### 不要说:SemanticGuard 是第二个诊断 Agent
|
||||
|
||||
应说:它是无 Tool、无记忆、单轮二值判断的隔离审查器,不能探索事实或改写报告。
|
||||
|
||||
### 不要说:Tool 返回 SUCCESS 就找到了证据
|
||||
|
||||
应说:`InvocationStatus=READY` 只表示调用完成,还要结合 `EvidenceStatus`;存在候选证据也不代表支持结论。
|
||||
|
||||
### 不要说:FALLBACK 就是 Run 失败
|
||||
|
||||
应说:Fallback 是安全发布结果。典型证据不足场景可以是 `RunState.SUCCESS + ReleaseOutcome.FALLBACK`。
|
||||
|
||||
### 不要说:Redis 是完整的长期审计库
|
||||
|
||||
应说:Redis canonical store 保存当前 Run 的短期完整 Tool 真相;长期审计只保存有界元数据。
|
||||
|
||||
### 不要说:取消能立刻杀死所有模型调用
|
||||
|
||||
应说:当前是协作式取消;first-terminal-wins、active check 和 SSE 状态保证迟到结果不能发布,但同步 Provider 计算未必立即停止。
|
||||
|
||||
## 9. 当前设计的代价和边界
|
||||
|
||||
一套可信的面试叙事不能只讲收益,还要主动说明代价:
|
||||
|
||||
1. 正交状态较多,必须用统一 Context 防止 `SUCCESS / READY / FALLBACK` 被混读。
|
||||
2. canonical store、Projector 和 Guard 增加了实现复杂度与发布延迟。
|
||||
3. Redis TTL 过期后只能保留元数据回放,不能恢复完整 Tool 正文。
|
||||
4. 自然语言近义查询目前不能被确定性去重,只能依赖 Agent 的信息增益义务。
|
||||
5. SemanticGuard 仍是模型判断,只是被收缩到最小、隔离、无 Tool 的范围。
|
||||
6. 协作式取消保护逻辑终态和发布,不等于强制终止 Provider 计算。
|
||||
7. 信息增益阈值和各类预算仍需依靠固定评测集持续校准。
|
||||
|
||||
主动说出这些边界,会让设计从“组件介绍”变成可讨论的工程决策。
|
||||
|
||||
## 10. 最后只记住四句话
|
||||
|
||||
```text
|
||||
Agent 决定如何诊断,Harness 决定诊断必须遵守什么边界。
|
||||
系统保管 Tool 真相,模型只读取完成下一步所需的有界观察。
|
||||
引用真实与结论成立是两个问题,必须由不同门禁处理。
|
||||
Harness 不保证每次找到答案,但保证任何结束方式都诚实、可审计、不会越权发布。
|
||||
```
|
||||
|
||||
到这里,Harness 文档的主线已经闭合。需要回忆某个细节时,通过第 7 节进入专题即可,不需要重新从组件清单开始阅读。
|
||||
@@ -0,0 +1,86 @@
|
||||
# Harness:先从一次诊断请求开始
|
||||
|
||||
这不是 Harness 的完整说明,而是一页入门导读。
|
||||
|
||||
第一次阅读时,不需要记组件名,不需要看状态枚举,也不需要理解所有边界。先回答一个问题:**为什么 Diagnosis Agent 外面还需要一层 Harness?**
|
||||
|
||||
## 1. 如果只有 Agent,会发生什么
|
||||
|
||||
假设用户问:
|
||||
|
||||
> 为什么支付服务在 10:00 到 10:10 大量超时?
|
||||
|
||||
Diagnosis Agent 会自己决定查什么、调用哪些 Tool、什么时候停止,并根据返回结果写出结论。这里有四个不能只靠 Prompt 解决的问题:
|
||||
|
||||
- 它可能查询过多,耗尽时间和 Token;
|
||||
- Tool 原始结果可能包含敏感或超大内容,不能直接交给模型;
|
||||
- 它引用了某条日志,不代表这条日志真的来自本次查询;
|
||||
- 它写出了一个看似合理的根因,不代表证据足以支持这个根因。
|
||||
|
||||
这些都不是“诊断能力”问题,而是**执行是否受控、结果是否可信**的问题。Harness 就是为此存在的。
|
||||
|
||||
## 2. Harness 在一次请求中做了什么
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["用户提出诊断问题"] --> R["给本次执行建立 Run<br/>限制时间和资源"]
|
||||
R --> A["Agent 分析问题<br/>选择 Tool"]
|
||||
A --> T["Harness 执行 Tool<br/>保存事实,只给 Agent 安全视图"]
|
||||
T --> A
|
||||
A --> D["Agent 写出诊断草稿"]
|
||||
D --> G["Harness 检查<br/>引用是否真实、证据是否支持结论"]
|
||||
G --> P["发布报告<br/>或安全地说明证据不足"]
|
||||
```
|
||||
|
||||
顺着这条线看,Harness 只做三类事情:
|
||||
|
||||
1. **执行前设边界**:为本次请求建立身份、deadline、预算和取消能力。
|
||||
2. **执行中管事实**:所有 Tool 经过统一入口,完整事实由系统保管,模型只看到安全、有限的内容。
|
||||
3. **发布前做验证**:先验证引用,再判断证据是否支持结论;不满足条件就发布 Fallback,而不是让未经验证的结论出去。
|
||||
|
||||
Agent 仍然负责“问题的根因是什么”。Harness 不替 Agent 推理,它负责的是:**让这次推理有边界、有证据、能停止。**
|
||||
|
||||
## 3. 先建立这个最小心智模型
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["Diagnosis Agent<br/>负责业务推理"]
|
||||
H["Harness<br/>负责确定性控制"]
|
||||
U["用户最终看到的结果"]
|
||||
|
||||
A -->|"提出 Tool 请求和诊断草稿"| H
|
||||
H -->|"返回受控的 Tool 观察"| A
|
||||
H -->|"验证通过后发布,失败则 Fallback"| U
|
||||
```
|
||||
|
||||
到这里,第一次阅读就可以停下。只要能说清下面这句话,就已经抓住了主干:
|
||||
|
||||
> Agent 决定如何诊断,Harness 决定这次诊断可以消耗什么、可以看到什么、何时必须停止,以及什么结果允许发布。
|
||||
|
||||
## 4. 需要时再往下读
|
||||
|
||||
不要按目录顺序阅读。遇到具体问题时,只进入对应文档:
|
||||
|
||||
| 当你想知道 | 再阅读 |
|
||||
|---|---|
|
||||
| 准备面试,想用一张图快速复习完整设计 | [Harness 面试速查](Harness面试速查-一张图讲清设计.md) |
|
||||
| 想看一次 Run 的资源预算如何被门禁控制 | [RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
|
||||
| 想复习终态检查与取消广播(checkActive / onCancel) | [Harness 执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md) |
|
||||
| 想复习重试机制(分类裁决 / 剩余超时 / 幂等性) | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
|
||||
| 想看当前组件学习进度与规划 | [Harness 组件学习路线](Harness组件学习路线-进度追踪.md) |
|
||||
| 想看 Harness 如何处理一次真实支付超时诊断 | [支付超时诊断案例](案例-从一次支付超时诊断看Harness如何控制Agent.md) |
|
||||
| 想知道这套设计如何从多 Agent 和 StateGraph 演进而来 | [Harness 设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md) |
|
||||
| 想知道异常、停止、降级和最终状态如何对应 | [Harness 失败图谱](Harness失败图谱-异常-停止-降级与终态.md) |
|
||||
| 想分清 ReactAgent loop 内外异常怎么走、和状态如何对应 | [Loop 内外异常处理](Harness异常处理-Loop内外与状态流.md) |
|
||||
| 想逐步认识 Harness 的各组组件 | [组件渐进式导读](components/README.md) |
|
||||
| 为什么选这种控制边界,而不是工作流编排 | [Harness 主设计](Harness设计-非确定性Agent的确定性控制边界.md) |
|
||||
| 一次请求从开始到结束经历什么 | [生命周期与状态](Harness生命周期与状态.md) |
|
||||
| Tool 为什么要保存一份事实、给模型另一份视图 | [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md) |
|
||||
| 如何阻止“引用是真的,但结论是错的” | [证据安全链](Harness证据安全链-从引用真实到结论可发布.md) |
|
||||
| Agent 为什么不会无限查询 | [信息增益停止](Harness信息增益停止-让无证据诊断正常收敛.md) |
|
||||
| 某个类属于哪里、负责什么 | [组件全景](Harness组件全景-职责-设计原因与边界.md) |
|
||||
| 某个名词或状态是什么意思 | [Context 词典](CONTEXT.md) |
|
||||
|
||||
如果准备系统学习,建议先读组件渐进式导读的四篇文章。每次只读一篇,读到“先记住这些”就可以停止。
|
||||
|
||||
“组件全景”和“Context”都是参考手册,不需要从头读,也不需要背。
|
||||
@@ -0,0 +1,242 @@
|
||||
# Harness Retry 重试机制:显式、可计量、可审计的 attempt 循环
|
||||
|
||||
**用途**:面试讲解与复习 Harness 重试设计的完整文档。回答"为什么重试权归 Harness、重试如何被分类裁决、如何防重试失控"。
|
||||
**代码基线**:`com.superbiz.agent.harness.retry` + `guard/semantic/GuardModelCall`
|
||||
**配套**:[Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md)
|
||||
|
||||
## 1. 一句话核心
|
||||
|
||||
> 重试不是通用的容错开关,而是**显式的、分类驱动的、可审计的 attempt 循环**:HarnessRetryExecutor 统一执行,RetryFailure 分类决定"哪种失败能重试",RetryPolicy 决定"最多试几次",每次 attempt 都受 Run 门禁控制、真实消耗预算、并记录到 Trace。SDK 的隐式重试被关闭(`spring.ai.retry.max-attempts: 1`),因为重试必须是调用者的有意识决策,且必须可计量。
|
||||
|
||||
## 2. 设计动机:为什么重试权归 Harness
|
||||
|
||||
### 2.1 问题:SDK 在内部悄悄重试
|
||||
|
||||
Spring AI 默认 `maxAttempts=10`,RetryTemplate 包在 `ChatModel.call()` 内部。Harness 拦截器在**外面**,只看到一次调用入口,实际却发生了多次 Provider attempt:
|
||||
|
||||
```text
|
||||
Harness 视角: "我调了一次模型,扣了一次预算"
|
||||
实际发生: Provider 内部悄悄试了 10 次(9 次失败 + 1 次成功)
|
||||
```
|
||||
|
||||
后果:预算失真、Trace 失真、取消失效、成本失控——**"重试"这个决定被藏在 SDK 内部,Harness 看不见、管不着、记不了账**。
|
||||
|
||||
### 2.2 钩子只能观察,不能控制
|
||||
|
||||
框架确实提供观察钩子(Spring Retry 的 `RetryListener`:open/onError/onSuccess),但钩子只能"看见"重试,不能"控制"重试:
|
||||
|
||||
| 需要的能力 | RetryListener 能吗 |
|
||||
|---|---|
|
||||
| attempt 之间检查 Run 是否还 active(取消/预算/超时后立刻停) | 不能(只是通知,不能中断循环) |
|
||||
| 每个 attempt 前扣预算 | 不能 |
|
||||
| 根据 Run 状态决定放弃重试 | 不能(不知道 RunContext) |
|
||||
|
||||
**SDK 一旦开始重试,即使 Run 已取消或预算耗尽也会继续**。所以取舍是"关闭 SDK 重试 + 重试外移到 Harness 自己控制",让每个 attempt 都成为完整可控点。
|
||||
|
||||
### 2.3 决策
|
||||
|
||||
```text
|
||||
① 关闭 SDK 隐式重试:spring.ai.retry.max-attempts: 1(测试锁定,防回归)
|
||||
② 失败分类:RetryFailure——只有技术类失败才可能重试
|
||||
③ 策略与执行分离:RetryPolicy(静态配置) + HarnessRetryExecutor(执行循环)
|
||||
④ 每次 attempt 都 checkActive、扣预算、记录——完全可控可计量
|
||||
```
|
||||
|
||||
## 3. 架构:Retry 在调用链中的位置
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CALLER["IntentRouter / SemanticGuard / EvidenceRepair"]
|
||||
CALLER -->|"execute(context, policy, operation, classifier, recorder)"| R["HarnessRetryExecutor<br/>attempt 循环 + 双条件裁决"]
|
||||
R -->|"每次 attempt: operation.execute()"| G["GuardModelCall<br/>单次调用边界"]
|
||||
G -->|"beforeModelCall"| C["HarnessCore<br/>checkActive + 预算预扣"]
|
||||
G --> M["ChatModel(SDK retry=1)"]
|
||||
R -.->|"每次 attempt 收据"| T["Trace / Audit<br/>RetryAttempt"]
|
||||
|
||||
style R fill:#e6f4ff,stroke:#0958d9
|
||||
style G fill:#f6ffed,stroke:#389e0d
|
||||
```
|
||||
|
||||
## 4. 核心组件
|
||||
|
||||
| 类型 | 角色 | 内容 |
|
||||
|---|---|---|
|
||||
| `RetryFailure` | 失败分类枚举(10 种) | 可重试组(技术类)vs 绝不重试组(业务/系统事实) |
|
||||
| `RetryPolicy` | 不可变策略 | `maxAttempts(1或2) + retryableFailures`,不是布尔 `retry=true` |
|
||||
| `HarnessRetryExecutor` | 统一重试循环 | 每个 attempt 前 checkActive,4 条异常路径分支 |
|
||||
| `RetryAttempt` | 单次 attempt 收据 | 序号 + 成败 + 失败类型;经 recorder 送 Trace |
|
||||
| `RetryExecutionException` | 重试终止异常 | `attempts + failure`,保留最终失败 |
|
||||
|
||||
## 5. 时序图:一次带重试的调用(失败→重试→成功)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as HarnessRetryExecutor
|
||||
participant G as GuardModelCall
|
||||
participant C as HarnessCore
|
||||
participant M as ChatModel(Provider)
|
||||
participant T as Trace/Audit
|
||||
|
||||
R->>R: attempt=1
|
||||
R->>C: checkActive(Run 仍可执行)
|
||||
R->>G: operation.execute()(模型调用 + 严格解析)
|
||||
G->>C: beforeModelCall(扣 1 次模型预算)
|
||||
G->>M: chatModel.call(prompt, timeout=remaining)
|
||||
M--xG: 超时 / 传输失败
|
||||
G-->>R: GuardModelCallException(TIMEOUT)
|
||||
R->>R: classify → TIMEOUT
|
||||
R->>T: recorder.failed(1, TIMEOUT)
|
||||
R->>R: policy.allowsRetry(1, TIMEOUT)?→ 是
|
||||
|
||||
R->>C: checkActive(第二次 attempt 前再查)
|
||||
R->>G: operation.execute()(attempt=2)
|
||||
G->>C: beforeModelCall(再扣 1 次预算)
|
||||
G->>M: chatModel.call(prompt, timeout=remaining 递减)
|
||||
M-->>G: 合法响应
|
||||
G-->>R: 解析成功
|
||||
R->>T: recorder.succeeded(2)
|
||||
R-->>调用方: 返回业务结果(T)
|
||||
```
|
||||
|
||||
## 6. 失败分类与双条件裁决
|
||||
|
||||
### 6.1 RetryFailure:什么失败能重试
|
||||
|
||||
```text
|
||||
可重试组(技术类,重试可能成功):
|
||||
TIMEOUT / TRANSPORT / INVALID_OUTPUT / PARSE_ERROR / SCHEMA_INVALID
|
||||
|
||||
绝不重试组(业务/系统事实,重试不会改变结果):
|
||||
NO_EVIDENCE / BUSINESS_REJECTION / CANCELLED / BUDGET_EXHAUSTED / UNKNOWN
|
||||
```
|
||||
|
||||
关键:**"业务无证据"(NO_EVIDENCE)不是技术失败**——重试不会让证据出现。取消、预算耗尽更不能被重试吞掉。
|
||||
|
||||
### 6.2 双条件裁决
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["attempt 开始"] --> B{"checkActive?"}
|
||||
B -->|"Run 已终止"| X1["抛 RunAbortedException<br/>不重试"]
|
||||
B -->|"可执行"| C["operation.execute()"]
|
||||
C -->|"成功"| S["return 业务结果"]
|
||||
C -->|"RunAborted"| X2["分类 BUDGET_EXHAUSTED / CANCELLED<br/>立即抛,不重试"]
|
||||
C -->|"BudgetExceeded"| X3["BUDGET_EXHAUSTED<br/>立即抛,不重试"]
|
||||
C -->|"其他异常"| CL["classifier.classify()<br/>null → UNKNOWN"]
|
||||
CL --> P{"allowsRetry?<br/>attempt < maxAttempts<br/>&& failure ∈ retryableFailures"}
|
||||
P -->|"是"| A
|
||||
P -->|"否"| X4["RetryExecutionException<br/>(attempts, failure)"]
|
||||
```
|
||||
|
||||
```java
|
||||
public boolean allowsRetry(int completedAttempts, RetryFailure failure) {
|
||||
return completedAttempts < maxAttempts // 条件1:还有剩余次数
|
||||
&& retryableFailures.contains(failure); // 条件2:失败类型可重试
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 剩余超时递减:两层超时防撑爆总时间
|
||||
|
||||
每个可重试组件有两套超时:`perAttemptTimeout`(单次)+ `totalTimeout`(整体)。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["totalTimeout(总封顶)<br/>Router:25s / Semantic:45s"] -->|"每次计算剩余"| R["remaining = total - elapsed<br/>elapsed = now - 固定起点"]
|
||||
R -->|"attempt 实际超时"| A["min(remaining, perAttemptTimeout)"]
|
||||
A -->|"剩余 <= 0"| E["直接抛 TIMEOUT<br/>不发起注定失败的调用"]
|
||||
```
|
||||
|
||||
```text
|
||||
Router(perAttempt=10s, total=25s):
|
||||
第一次 attempt:remaining = min(25, 10) = 10s → 用 9s 失败
|
||||
第二次 attempt:remaining = 25 - 9 = 16 → min(16, 10) = 10s → 用 10s 失败
|
||||
第三次 attempt:remaining = 25 - 19 = 6s → 6s 到点直接 TIMEOUT
|
||||
总耗时 = 25s,被 totalTimeout 精确封顶
|
||||
```
|
||||
|
||||
要点:
|
||||
- **起点固定**:`startedNanos` 在 execute 前取一次,operation lambda 捕获它——每次 attempt 用同一起点算 elapsed,之前 attempt 的耗时自然累计
|
||||
- **单次超时管"一次别太久",剩余递减管"总共别太久"**
|
||||
- 时间计算用 `System.nanoTime()`(单调时钟),不受系统时间调整影响
|
||||
|
||||
## 8. 三重止损:次数 / 时间 / 成本
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
B["次数封顶<br/>RetryPolicy.maxAttempts(1 或 2)"] --- S["重试不会失控"]
|
||||
T["时间封顶<br/>totalTimeout + 剩余递减"] --- S
|
||||
C["成本封顶<br/>每个 attempt 扣预算"] --- S
|
||||
S["三层独立、互相兜底"]
|
||||
```
|
||||
|
||||
- 即使未来把 maxAttempts 调大,总时间仍然被封死
|
||||
- 预算一耗尽(`BudgetExceededException`)重试立即停止——不重试是防止继续烧预算
|
||||
|
||||
## 9. 幂等性假设:为什么 Agent / Tool 不重试
|
||||
|
||||
重试的前提是**副作用幂等**(执行 N 次 = 执行 1 次的外部效果)。但幂等是必要条件,不是充分条件:
|
||||
|
||||
| 组件 | 副作用幂等 | 重试策略 | 原因 |
|
||||
|---|---|---|---|
|
||||
| IntentRouter | ✅(单轮无状态判定) | 2 次 | Harness 拥有调用控制权、成本低、重试结果都是合法判定 |
|
||||
| SemanticGuard | ✅(单轮无状态判定) | 2 次 | 同上 |
|
||||
| Diagnosis Agent | ❌(多轮有状态 loop) | 1 次 | 轮级重试需侵入框架;失败走受控停止 → Fallback |
|
||||
| 业务 Tool | ✅(只读)但**有成本** | 1 次 | 重试决策权在 Agent(入参可能不同);后端执行昂贵;Agent 自有重试语义 |
|
||||
| EvidenceRepair | 状态相关 | 1 次 | 失败走 Fallback 更安全 |
|
||||
|
||||
关键区分:
|
||||
|
||||
```text
|
||||
副作用幂等 vs 结果幂等:
|
||||
Router 重试结果可能不同(模型非确定性),但每次都只是"一次独立判定"——无副作用、不破坏状态
|
||||
→ "结果变了也没关系"的正确表述:重试只在第一次失败(无结果)时发生,重试结果是唯一判定,不存在覆盖
|
||||
|
||||
只读 ≠ 免费:
|
||||
业务 Tool 技术上幂等(只读),但每次执行消耗真实后端资源——重试是成本决策,不是安全决策
|
||||
```
|
||||
|
||||
## 10. 与预算 / 取消的关系
|
||||
|
||||
```text
|
||||
预算:每个 attempt 都走 GuardModelCall → beforeModelCall → 扣 1 次模型调用额度
|
||||
2 次 attempt = 2 次配额;配额耗尽 → BUDGET_EXHAUSTED → 不重试
|
||||
|
||||
取消:每次 attempt 前 checkActive——取消发生在 attempt 之间时,第二次调用被拦下
|
||||
GuardModelCall 挂 onCancel → future.cancel(true) → 正在等待的 attempt 可被打断
|
||||
```
|
||||
|
||||
## 11. 面试话术
|
||||
|
||||
**为什么重试权归 Harness**:
|
||||
|
||||
> SDK 默认在 ChatModel 外包装 RetryTemplate 悄悄重试 10 次,Harness 只看到一次入口、计量却失真。我们通过 spring.ai.retry.max-attempts: 1 关掉它,并用专门的配置测试锁死防回归——这样一次 ChatModel 调用对应一次真实 Provider attempt,预算和 Trace 才可计量。框架的 RetryListener 钩子只能观察不能控制,所以重试外移到 Harness 自己的 RetryExecutor。
|
||||
|
||||
**分类与裁决**:
|
||||
|
||||
> 重试先分类再决策:RetryFailure 区分技术失败(超时、传输、解析、schema)和业务失败(无证据、业务拒绝、取消、预算耗尽),只有技术类才允许重试;RetryPolicy 限定每个组件最多 2 次。每次 attempt 前 checkActive、每个 attempt 真实扣预算并记录,所以"试了几次、为什么停"完全可审计。
|
||||
|
||||
**为什么 Agent / Tool 不重试**:
|
||||
|
||||
> Router 和 SemanticGuard 是 Harness 自己发起的单轮无副作用判定,重试便宜且结果独立;Diagnosis Agent 是多轮有状态 loop,重试某一轮会破坏循环上下文,整个重试成本翻倍且破坏收敛——失败走受控停止到 Fallback 是设计好的结局;业务 Tool 虽只读但重试决策权在 Agent(下一轮入参可能不同),且后端执行昂贵。
|
||||
|
||||
## 12. 自测
|
||||
|
||||
1. 为什么关闭 SDK 隐式重试?不关会发生什么计量失真?(一次入口 vs 10 次 Provider attempt,预算/Trace/取消/成本)
|
||||
2. RetryPolicy 的双条件裁决是哪两个?NO_EVIDENCE 为什么永不重试?
|
||||
3. 剩余超时递减怎么防止重试撑爆总时间?为什么起点必须固定?
|
||||
4. 为什么 Agent 不重试?"保留前 2 轮重试第 3 轮"技术上可行为什么系统不做?
|
||||
5. 业务 Tool 是只读的(幂等),为什么不重试?
|
||||
|
||||
## 13. 代码位置
|
||||
|
||||
| 内容 | 位置 |
|
||||
|---|---|
|
||||
| 重试循环(4 条路径) | `harness/retry/HarnessRetryExecutor.java` |
|
||||
| 双条件裁决 | `harness/retry/RetryPolicy.java` |
|
||||
| 失败分类 | `harness/retry/RetryFailure.java` |
|
||||
| 组件策略 | `harness/retry/HarnessRetryPolicies.java` |
|
||||
| attempt 收据 | `harness/retry/RetryAttempt.java` |
|
||||
| 单次调用边界 | `harness/guard/semantic/GuardModelCall.java` |
|
||||
| 剩余超时计算 | `harness/application/routing/IntentRouter.java`(remaining) |
|
||||
| 关闭 SDK 重试 | `src/main/resources/application.yml` + `SpringAiRetryConfigurationTest` |
|
||||
| 行为契约测试 | `src/test/.../retry/HarnessRetryExecutorTest.java` |
|
||||
@@ -0,0 +1,251 @@
|
||||
# RunBudget 预算流程:一次 Run 的资源门禁时序图
|
||||
|
||||
**用途**:面试讲解 RunBudget 用的聚焦时序图,回答"一次 Run 的资源消耗是如何被门禁控制的"。
|
||||
**代码基线**:`RunContext` → `RunBudget` + `RunBudgetLimits` + `RunCapacityCounter`
|
||||
|
||||
## 1. 在完整 Harness 中的位置(极简上下文)
|
||||
|
||||
RunBudget 是 `RunContext` 里的一个执行控制句柄,两个门禁经过它:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
MI["ModelInterceptor<br/>每轮模型调用前"] -->|"beforeModelCall"| CORE["DiagnosisHarnessCore"]
|
||||
TI["ToolInterceptor / ToolBoundary<br/>每次 Tool 执行前"] -->|"beforeToolCall"| CORE
|
||||
CORE --> B["RunBudget<br/>预扣 + 超限升级"]
|
||||
T["Canonical 写入前"] -->|"reserveRunBytes"| CORE
|
||||
B --> E["BudgetExceededException →<br/>finish(BUDGET_EXHAUSTED) + cancel"]
|
||||
```
|
||||
|
||||
## 2. RunBudget 流程时序图(核心)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant APP as ChatApplication
|
||||
participant CORE as DiagnosisHarnessCore
|
||||
participant MI as ModelInterceptor
|
||||
participant TI as ToolInterceptor
|
||||
participant T as ToolBoundary
|
||||
participant B as RunBudget
|
||||
participant C as RunCapacityCounter(CAS)
|
||||
|
||||
Note over APP,C: 启动:startRun(sessionId) → new RunBudget(RunBudgetLimits)<br/>句柄挂到 RunContext,随请求显式传递
|
||||
|
||||
loop 每一轮模型调用
|
||||
MI->>CORE: beforeModelCall(context)
|
||||
CORE->>CORE: checkActive() 先确认 Run 还能跑
|
||||
CORE->>B: reserveModelCall() 预扣 1 轮
|
||||
alt 超限
|
||||
B-->>CORE: BudgetExceededException(MODEL_CALLS)
|
||||
CORE->>CORE: exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel()
|
||||
CORE-->>MI: 抛异常,之后所有 checkActive 拒绝
|
||||
end
|
||||
CORE->>B: recordTokens(input, output) 调用后记账
|
||||
B->>B: 三档检查 input / output / total
|
||||
end
|
||||
|
||||
loop 每一次 Tool 调用
|
||||
TI->>CORE: beforeToolCall(context, toolName)
|
||||
CORE->>CORE: checkActive()
|
||||
CORE->>B: reserveToolCall(toolName)
|
||||
alt 超限(总量 或 单 Tool 独立限额)
|
||||
B-->>CORE: BudgetExceededException(TOOL_CALLS / TOOL_CALLS_PER_TOOL)
|
||||
CORE->>CORE: exhaustBudget(...)
|
||||
end
|
||||
T->>CORE: reserveRunBytes(bytes) canonical 体积预扣
|
||||
CORE->>C: capacity.reserve(bytes) AtomicLong CAS 自旋
|
||||
alt 超限
|
||||
C-->>CORE: BudgetExceededException(RUN_BYTES)
|
||||
CORE->>CORE: exhaustBudget(...)
|
||||
end
|
||||
end
|
||||
|
||||
APP->>B: snapshot() → RunBudgetUsage
|
||||
Note over APP,B: Run 结束对账:各维度实际用量(模型轮数/工具次数/Token/字节)
|
||||
```
|
||||
|
||||
## 3. 五个流程节点
|
||||
|
||||
1. **创建**:`startRun` 里 `new RunBudget(RunBudgetLimits)`,限额不可变,消耗状态可变,句柄随 RunContext 显式传递。
|
||||
2. **模型调用前**:`reserveModelCall()` synchronized 预扣,超限抛异常。
|
||||
3. **Tool 调用前**:`reserveToolCall(toolName)` 双重限额——总次数 + 单 Tool 次数。
|
||||
4. **Canonical 写入前**:`reserveRunBytes(bytes)` 走 CAS 计数器。
|
||||
5. **调用后**:`recordTokens` 三档 Token 上限记账。
|
||||
|
||||
**统一超限出口**:任何维度超限都抛带 `BudgetKind` 的 `BudgetExceededException` → Core `exhaustBudget`(固化终态 + 广播取消)→ 后续所有调用被 checkActive 拒绝。预算失败是 Run 级事实,不是局部异常。
|
||||
|
||||
## 4. 四个维度的时机对照表
|
||||
|
||||
| 维度 | 时机 | 预扣/记账 | 超限 BudgetKind |
|
||||
|---|---|---|---|
|
||||
| 模型轮数 | 模型调用前 | 预扣 | `MODEL_CALLS` |
|
||||
| Tool 次数 | Tool 调用前 | 预扣 | `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` |
|
||||
| Token | 模型调用后 | 记账(实际用量) | `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` |
|
||||
| 字节 | canonical 写入前 | 预扣 | `RUN_BYTES` |
|
||||
|
||||
## 5. 自测:对着图能回答这四个问题吗
|
||||
|
||||
1. 第 7 轮模型调用时 `reserveModelCall` 超限——哪个组件抛异常、Run 变成什么终态、后续调用为什么全部被拒?
|
||||
(ModelInterceptor 调 beforeModelCall → Core reserveModelCall 抛 BudgetExceededException → exhaustBudget 写 BUDGET_EXHAUSTED + cancel → 之后 checkActive 见终态直接抛 RunAbortedException)
|
||||
|
||||
2. 为什么 Tool 需要"总次数 + 单 Tool 次数"双重限额?
|
||||
(总量防"调用太多",单 Tool 限额防"死磕同一个工具",比如反复查同一份日志)
|
||||
|
||||
3. 为什么字节预算用 CAS 自旋,计数预算用 synchronized?
|
||||
(字节是高频原子累加,AtomicLong + compareAndSet 无锁乐观并发;计数是"读-判-写"复合操作,synchronized 保证原子性)
|
||||
|
||||
4. RunBudget 和 ModelCallLedger 都是记消耗,区别在哪?
|
||||
(Budget 调用前预扣、管允不允许、超限会停止 Run;Ledger 调用后记账、管记了多少、幂等去重,只服务审计对账)
|
||||
|
||||
## 6. RunBudget 字段与组件速查
|
||||
|
||||
### 6.1 RunBudget 状态字段
|
||||
|
||||
| 字段 | 类型 | 含义 |
|
||||
|---|---|---|
|
||||
| `limits` | `RunBudgetLimits` | 限额定义(不可变) |
|
||||
| `capacity` | `RunCapacityCounter` | 字节 CAS 计数器 |
|
||||
| `modelCalls` | int | 累计模型调用轮数 |
|
||||
| `toolCalls` | int | 累计工具调用次数 |
|
||||
| `toolCallsByName` | `Map<String,Integer>` | 单工具名次数(防死磕) |
|
||||
| `inputTokens` / `outputTokens` / `totalTokens` | long | 三档累计 token |
|
||||
|
||||
### 6.2 RunBudgetLimits:限额定义(7 字段 + 默认值)
|
||||
|
||||
| 字段 | 默认值 | 来源 |
|
||||
|---|---|---|
|
||||
| `maxModelCalls` | 24 | 配置 `harness.chat.*` |
|
||||
| `maxToolCalls` | 24 | 配置 |
|
||||
| `maxCallsPerTool` | 8 | 配置 |
|
||||
| `maxInputTokens` | 100_000 | 配置 |
|
||||
| `maxOutputTokens` | 100_000 | 配置 |
|
||||
| `maxTotalTokens` | 200_000 | 配置 |
|
||||
| `maxRunBytes` | 1_000_000(1MB) | 配置 |
|
||||
|
||||
### 6.3 支撑类型
|
||||
|
||||
| 类型 | 字段/枚举 | 作用 |
|
||||
|---|---|---|
|
||||
| `RunCapacityCounter` | `maxBytes` + `usedBytes`(AtomicLong) | 字节 CAS 自旋计数 |
|
||||
| `RunBudgetUsage` | modelCalls / toolCalls / toolCallsByName / 三档 token / runBytes | `snapshot()` 只读快照,Run 结束对账 |
|
||||
| `BudgetExceededException` | `kind` / `limit` / `attempted` | 超限异常,携带具体维度 |
|
||||
| `BudgetKind` | 7 个枚举 | `MODEL_CALLS` / `TOOL_CALLS` / `TOOL_CALLS_PER_TOOL` / `INPUT_TOKENS` / `OUTPUT_TOKENS` / `TOTAL_TOKENS` / `RUN_BYTES` |
|
||||
|
||||
### 6.4 持有与消费组件
|
||||
|
||||
| 组件 | 与 budget 的关系 |
|
||||
|---|---|
|
||||
| `DiagnosisHarnessCore` | **门禁枢纽**:持有 `RunBudgetLimits`,创建 `RunBudget`;`beforeModelCall` / `beforeToolCall` / `recordTokens` / `reserveRunBytes` 统一入口;超限走 `exhaustBudget` 升级终态 |
|
||||
| `RunContext` | 持有 `RunBudget` 句柄,随请求显式传递 |
|
||||
| `HarnessModelInterceptor` | 每轮模型调用:`beforeModelCall`(预扣)+ 调用后 `ModelCallAuditor.recordUsage`(→ `core.recordTokens`) |
|
||||
| `HarnessToolInterceptor` | 工具请求:`beforeToolCall` 预扣 |
|
||||
| `ToolBoundary` | `beforeToolCall` + **3 处** `reserveRunBytes`:request 字节、raw response 字节、agent_result 字节 |
|
||||
| `GuardModelCall` / `DiagnosisAgentUseCase` / `EvidenceRepair` | 各自 `beforeModelCall` + `reserveRunBytes`(输入/输出/Draft/Repair 输入) |
|
||||
|
||||
Token 记账链路:`HarnessModelInterceptor.recordUsage` → `ModelCallAuditor.recordUsage` → `core.recordTokens` → `RunBudget.recordTokens`(三档检查)。
|
||||
|
||||
### 6.5 配置来源:两层字节控制
|
||||
|
||||
字节控制有两层,作用域不同:
|
||||
|
||||
```text
|
||||
单次 payload 上限(每类内容独立闸门,不在 RunBudgetLimits 内):
|
||||
diagnosis-max-query-bytes: 16384 查询输入
|
||||
diagnosis-max-previous-turn-bytes: 16384 历史轮次
|
||||
diagnosis-max-input-bytes: 49152 诊断输入合计
|
||||
diagnosis-max-draft-bytes: 49152 Draft
|
||||
semantic-max-input/output-bytes: 100000 / 10000
|
||||
repair-max-input/output-bytes: 100000 / 48000
|
||||
canonical-max-record-bytes: 1048576 单条 canonical 记录
|
||||
canonical-max-agent-result-bytes: 65536 agent_result
|
||||
|
||||
Run 累计上限:
|
||||
max-run-bytes: 1000000 整个 Run 累计预扣
|
||||
```
|
||||
|
||||
**注意**:`canonicalMaxRecordBytes(1MB)` 是"单条记录"上限,`maxRunBytes(1MB)` 是整个 Run 累计上限——两者都是 1MB 但作用域不同,一条记录就能占满 Run 预算的一半以上。
|
||||
|
||||
## 7. 预算异常与终态对应
|
||||
|
||||
### 7.1 异常 → 终态对应表
|
||||
|
||||
| 异常 | 抛出处 | 携带信息 | 写入/对应终态 |
|
||||
|---|---|---|---|
|
||||
| `BudgetExceededException` | `RunBudget`(reserve/record) | `kind` / `limit` / `attempted` | **BUDGET_EXHAUSTED**(写入) |
|
||||
| `RunAbortedException`(deadline 超时) | `checkActive` 第二道闸 | `RunTermination(TIMED_OUT, ...)` | **TIMED_OUT**(主动写入后抛出) |
|
||||
| `RunAbortedException`(已取消) | `checkActive` 第三道闸 | 已有终态(如 CANCELLED) | **读取**已有终态,不新写 |
|
||||
| `RunAbortedException`(终态已存在) | `checkActive` 第一道闸 | 已有终态(可能是任何终态) | **读取**已有终态,不新写 |
|
||||
| `IllegalArgumentException` | `RunBudgetLimits` 构造 / `RunBudget` 参数 | 校验信息 | **无终态**(Run 开始前 fail fast) |
|
||||
|
||||
### 7.2 两阶段异常:预算超限后的完整路径
|
||||
|
||||
```text
|
||||
第一次(reserve/record 超限):
|
||||
RunBudget 抛 BudgetExceededException(kind/limit/attempted)
|
||||
→ Core 捕获 → exhaustBudget:finish(BUDGET_EXHAUSTED) + cancel
|
||||
→ 异常继续向上抛(可审计"哪个维度爆了")
|
||||
|
||||
之后(任何 checkActive):
|
||||
termination 已存在 → 抛 RunAbortedException(携带 BUDGET_EXHAUSTED 终态)
|
||||
```
|
||||
|
||||
第一枪是预算异常(带维度),之后所有拦截是终止异常(带终态)——两者配合。
|
||||
|
||||
### 7.3 各消费组件的异常处理
|
||||
|
||||
| 组件 | 处理 |
|
||||
|---|---|
|
||||
| `DiagnosisHarnessCore.applyBudget` | catch `BudgetExceededException` → `exhaustBudget` → 再 throw |
|
||||
| `GuardModelCall` | `ExecutionException` 的 cause 判断:`instanceof BudgetExceededException` → 原样 rethrow;`CancellationException` → `checkActive` → 可能抛 `RunAbortedException` |
|
||||
| `HarnessRetryExecutor` | `BudgetExceededException` / `RunAbortedException` 属于**从不重试**类(取消、预算耗尽、协议错误不能被重试吞掉) |
|
||||
|
||||
## 8. 异常三要素:kind / limit / attempted
|
||||
|
||||
### 8.1 三个字段
|
||||
|
||||
| 字段 | 含义 | 例子 |
|
||||
|---|---|---|
|
||||
| `kind` | **哪个资源维度**超限(BudgetKind 枚举) | `MODEL_CALLS` |
|
||||
| `limit` | 该维度的**限额**(来自 RunBudgetLimits) | `maxModelCalls=24` |
|
||||
| `attempted` | 本次**试图达到的值**(尝试后的总量,不是超出差额) | `25` |
|
||||
|
||||
### 8.2 attempted 是"尝试后的总量",不是"超出的部分"
|
||||
|
||||
```java
|
||||
int attempted = modelCalls + 1; // 尝试让计数变成多少
|
||||
if (attempted > limits.maxModelCalls()) {
|
||||
throw new BudgetExceededException(BudgetKind.MODEL_CALLS,
|
||||
limits.maxModelCalls(), attempted);
|
||||
}
|
||||
modelCalls = attempted;
|
||||
```
|
||||
|
||||
```text
|
||||
已调用 24 次(正好达到上限)→ 第 25 次尝试:attempted=25 > 24
|
||||
→ 抛异常:kind=MODEL_CALLS, limit=24, attempted=25
|
||||
```
|
||||
|
||||
### 8.3 各维度实际值举例
|
||||
|
||||
| kind | limit(配置) | attempted | 含义 |
|
||||
|---|---|---|---|
|
||||
| `MODEL_CALLS` | 24 | 25 | 第 25 轮模型调用被拒 |
|
||||
| `TOOL_CALLS` | 24 | 25 | 第 25 次工具调用被拒 |
|
||||
| `TOOL_CALLS_PER_TOOL` | 8 | 9 | 某个工具第 9 次调用被拒(死磕拦截) |
|
||||
| `INPUT_TOKENS` | 100_000 | 100_003 | 累计输入 token 超出 3 个 |
|
||||
| `TOTAL_TOKENS` | 200_000 | 200_500 | 累计总 token 超出 |
|
||||
| `RUN_BYTES` | 1_000_000 | 1_000_001 | Run 累计字节超出 1 字节 |
|
||||
|
||||
### 8.4 审计价值
|
||||
|
||||
- `kind` → 定位哪一类资源(token / 次数 / 字节)
|
||||
- `limit` → 知道配置上限(是否配置太紧)
|
||||
- `attempted` → 知道差多少爆的(贴线超限说明要调配置,暴涨说明有失控路径)
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
| 文档 | 用途 |
|
||||
|---|---|
|
||||
| [Harness 面试速查-一张图讲清设计](Harness面试速查-一张图讲清设计.md) | 面试主叙事 + 常见追问 |
|
||||
| [Harness 设计-非确定性 Agent 的确定性控制边界](Harness设计-非确定性Agent的确定性控制边界.md) | 决策五:预算与信息增益双机制 |
|
||||
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | 预算之外的第二套停止机制 |
|
||||
| [Harness 异常处理-Loop 内外与状态流](Harness异常处理-Loop内外与状态流.md) | 预算耗尽如何落终态 |
|
||||
@@ -0,0 +1,121 @@
|
||||
# 01 运行控制:让一次请求有边界
|
||||
|
||||
这一篇只回答一个问题:**一次诊断请求由谁负责到底?**
|
||||
|
||||
## 1. 先看一个具体问题
|
||||
|
||||
用户发起支付超时诊断。请求开始后,Router 调了一次模型,Diagnosis Agent 又调用多轮模型和 Tool,SemanticGuard 最后还要调用一次模型。与此同时,客户端可能断开,某次调用可能超时,两个线程也可能同时报告成功和失败。
|
||||
|
||||
如果没有统一的运行控制,每个组件都会有自己的理解:
|
||||
|
||||
- Controller 认为连接断开了,后台 Agent 却还在继续查询;
|
||||
- Agent 认为还可以调用 Tool,Run 的总预算其实已经耗尽;
|
||||
- 超时线程先写入失败,迟到的模型结果又把它覆盖为成功;
|
||||
- SDK 在内部偷偷重试,系统无法解释多出来的延迟和 Token;
|
||||
- 线上只看到最终失败,却不知道请求在哪一步、因为什么停止。
|
||||
|
||||
所以运行控制不是一个计时器,而是由几个职责不同的组件共同完成。
|
||||
|
||||
## 2. 四组组件怎样协作
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant APP as Application
|
||||
participant CORE as Core / RunContext
|
||||
participant WORK as Agent、Tool、Guard
|
||||
participant RETRY as Retry
|
||||
participant AUDIT as Audit
|
||||
|
||||
APP->>CORE: 创建 RunContext
|
||||
CORE-->>APP: runId、deadline、budget、cancel、lifecycle
|
||||
APP->>WORK: 显式传入 RunContext
|
||||
WORK->>CORE: 每次消耗前检查 active 和预算
|
||||
WORK->>RETRY: 仅允许策略声明的技术重试
|
||||
RETRY->>AUDIT: 记录每个 attempt
|
||||
WORK->>AUDIT: 记录模型、Tool 和阶段元数据
|
||||
APP->>CORE: 尝试写入最终终态
|
||||
CORE-->>APP: first-terminal-wins
|
||||
APP->>AUDIT: 记录公开结果和预算对账
|
||||
```
|
||||
|
||||
第一次阅读可以把它们理解为:
|
||||
|
||||
- **Application 是负责人**:组织一次请求从创建到公开结果。
|
||||
- **Core 是执行规则**:统一管理身份、deadline、预算、取消和唯一终态。
|
||||
- **Retry 是重做规则**:明确什么失败允许再试一次。
|
||||
- **Audit 是运行账本**:记录发生过什么,但不保存敏感正文。
|
||||
|
||||
## 3. Application:负责人,而不是推理者
|
||||
|
||||
Application 负责建立一次请求的应用边界:读取安全历史、创建 Run、判断 intent、调用对应执行分支、持久化结果,并把安全内容交给 SSE。
|
||||
|
||||
设计它的原因,是这些步骤必须由一个地方协调。如果散落在 Controller、Agent 和 Guard 中,会出现多个结果出口和不同的失败语义。
|
||||
|
||||
它不负责判断支付超时的根因,也不负责自己拼一份诊断 Fallback。它只负责把正确的执行组件按顺序接起来,并确保结果经过统一出口。
|
||||
|
||||
主要代码入口:`ChatApplicationUseCase`、`DiagnosisChatExecutor`、`KnowledgeQueryExecutor`、`SystemChatExecutor`、`ChatRunStore`。
|
||||
|
||||
## 4. Core:一次 Run 的共同规则
|
||||
|
||||
Core 创建 `RunContext`。这个上下文显式携带:
|
||||
|
||||
```text
|
||||
这是谁的请求:sessionId + runId
|
||||
最晚执行到何时:deadline
|
||||
还能消耗多少:RunBudget
|
||||
是否要求停止:RunCancellation
|
||||
最终如何结束:RunLifecycle
|
||||
模型消耗如何对账:ModelCallLedger
|
||||
信息收集是否仍有价值:DiagnosisProgressTracker
|
||||
```
|
||||
|
||||
这里最重要的决策是**显式传递 RunContext**,而不是使用 ThreadLocal 或让每个组件自己查询全局状态。原因是模型和 Tool 可能跨线程运行;隐式上下文很容易丢失、串线,也很难在测试中证明归属关系。
|
||||
|
||||
`RunLifecycle` 使用 first-terminal-wins:第一个成功写入的终态不可被迟到结果覆盖。它解决的是并发一致性,不是公开结果的业务含义。
|
||||
|
||||
主要代码入口:`DiagnosisHarnessCore`、`RunContext`、`RunBudget`、`RunCancellation`、`RunLifecycle`。
|
||||
|
||||
## 5. Retry:不能让“再试一次”藏起来
|
||||
|
||||
重试会增加成本和延迟,也可能重复副作用,因此不能由 SDK、HTTP Client 和各组件各自决定。当前策略是:
|
||||
|
||||
- Router 和 SemanticGuard 的特定技术失败最多尝试两次;
|
||||
- Diagnosis Agent、业务 Tool 和 EvidenceRepair 不做隐藏自动重试;
|
||||
- 取消、预算耗尽和协议错误不能被重试吞掉。
|
||||
|
||||
这里没有设计通用重试 DSL。首版只需要不可变的 `RetryPolicy`、稳定的失败分类和统一的 `HarnessRetryExecutor`,复杂配置系统反而会掩盖真实执行路径。
|
||||
|
||||
## 6. Audit:留下解释,而不是留下全部内容
|
||||
|
||||
线上排障需要知道:调用了哪个组件、用了多少 Token、Tool 是否完成、为什么停止、为什么发布 Fallback。但这不等于长期保存 Prompt、SQL、日志原文、Tool raw response 和模型 reasoning。
|
||||
|
||||
因此 Audit 长期保留的是有界元数据,完整 Tool 真相只在 canonical store 中短期存在。这个决策同时满足可回放和最小暴露原则。
|
||||
|
||||
主要代码入口:`DiagnosisTraceRecorder`、`TraceAuditEvents`、`ToolInvocationAuditSink`、`HarnessAgentAuditHook`、`ModelCallAuditor`。
|
||||
|
||||
## 7. 为什么没有合并成一个 RunManager
|
||||
|
||||
把上述职责都放进一个 `RunManager` 看起来更简单,但它会同时知道 HTTP、路由、预算、模型、Tool、数据库和 SSE,最后变成新的业务工作流引擎。
|
||||
|
||||
当前拆分依据不是“代码越细越好”,而是决策权不同:
|
||||
|
||||
| 组件 | 它拥有的决定 | 它不能决定 |
|
||||
|---|---|---|
|
||||
| Application | 请求走哪条应用分支、何时持久化和返回 | 业务根因是否成立 |
|
||||
| Core | 是否仍可执行、资源是否允许、哪个终态生效 | 返回正常报告还是 Fallback |
|
||||
| Retry | 某类技术失败是否允许下一 attempt | 修改业务结果 |
|
||||
| Audit | 记录哪些安全元数据 | 影响执行结果 |
|
||||
|
||||
## 8. 先记住这些
|
||||
|
||||
第一次阅读只需记住:
|
||||
|
||||
1. Application 对一次请求负责,Core 对执行不变量负责。
|
||||
2. RunContext 必须显式传播,所有模型和 Tool 共用同一预算与取消信号。
|
||||
3. 第一个终态获胜,迟到结果不能翻案。
|
||||
4. Retry 必须显式、分类、可审计。
|
||||
5. Audit 记录控制事实,不长期复制敏感正文。
|
||||
|
||||
下一篇:[02 Agent 接入与收敛](02-Agent接入与收敛-让推理可以工作也可以停止.md)。
|
||||
|
||||
需要查完整状态转换时,阅读[生命周期与状态](../Harness生命周期与状态.md);需要查全部类型时,阅读[组件全景](../Harness组件全景-职责-设计原因与边界.md)。
|
||||
@@ -0,0 +1,148 @@
|
||||
# 02 Agent 接入与收敛:让推理可以工作,也可以停止
|
||||
|
||||
这一篇只回答一个问题:**怎样保留 Agent 的自主推理,同时阻止它重复查询或无限空转?**
|
||||
|
||||
## 1. 先看一个具体问题
|
||||
|
||||
Diagnosis Agent 第一次查询 10:00 到 10:10 的支付日志,没有发现异常;第二次换了关键词,仍然没有新信息;第三次又提交了与第一次等价的查询。
|
||||
|
||||
仅设置“最多调用 10 次 Tool”只能限制最坏损失,却回答不了:
|
||||
|
||||
- 上一次结果有没有推进诊断?
|
||||
- 这次查询是否已经做过?
|
||||
- 连续多少次没有新信息后应该停止?
|
||||
- Agent 已经收到停止要求,为什么还能继续调用 Tool?
|
||||
- 停止后,怎样安全地向用户说明已经检查过什么?
|
||||
|
||||
这就是 Agent 接入层和 Progress 组件共同解决的问题。
|
||||
|
||||
## 2. 一轮 ReAct 怎样经过 Harness
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Diagnosis Agent
|
||||
participant MI as Model Interceptor
|
||||
participant TI as Tool Interceptor
|
||||
participant P as Progress Tracker
|
||||
participant T as Tool Boundary
|
||||
|
||||
A->>MI: 发起一轮模型调用
|
||||
MI->>MI: 检查 Run、预留预算、记录 Token
|
||||
A->>TI: 请求调用 Tool
|
||||
TI->>P: 评价上一轮是否有信息增益
|
||||
P-->>TI: 可继续 / 重复 / 已饱和 / 协议错误
|
||||
alt 允许继续
|
||||
TI->>T: 执行业务 Tool
|
||||
T-->>TI: Control View + Model Observation
|
||||
TI->>P: 登记已完成调用和 scope
|
||||
TI-->>A: 只返回 Model Observation
|
||||
else 必须停止
|
||||
TI-->>A: STOP_REQUIRED
|
||||
end
|
||||
```
|
||||
|
||||
第一次阅读可以把它们理解为:
|
||||
|
||||
- **Agent 接入层是检查站**:把框架原生 ReAct loop 接入预算、Tool 和审计边界。
|
||||
- **Progress Tracker 是收敛记录器**:判断是否重复、是否连续无增益、是否必须停止。
|
||||
|
||||
## 3. 为什么复用框架 ReAct,而不是自己重写循环
|
||||
|
||||
Diagnosis Agent 需要模型原生 Tool Calling 和多轮 ReAct。项目没有再写一套 `while` 循环,而是通过 Factory、Model Interceptor、Tool Interceptor 和 Audit Hook 接入框架。
|
||||
|
||||
这样做的决策依据是:
|
||||
|
||||
- ReAct 的业务推理由框架和模型负责;
|
||||
- 预算、Tool 授权、事实保存和停止协议由 Harness 负责;
|
||||
- 两者通过明确边界连接,不互相复制实现。
|
||||
|
||||
如果 Harness 自己维护另一套 ReAct 状态机,就会同时出现“框架认为的下一步”和“Harness 认为的下一步”,调试时很难确定谁才是真理源。
|
||||
|
||||
主要代码入口:`DiagnosisAgentFactory`、`DiagnosisAgentUseCase`、`HarnessModelInterceptor`、`HarnessToolInterceptor`、`HarnessEvidenceTools`。
|
||||
|
||||
## 4. Model Interceptor:每一轮模型调用都必须记账
|
||||
|
||||
一个 Diagnosis Agent 执行不等于一次模型调用。ReAct 可能经历多轮思考和 Tool 返回,因此每一轮都要:
|
||||
|
||||
1. 检查 Run 是否仍然 active;
|
||||
2. 在调用前预留模型预算;
|
||||
3. 调用后记录 Provider 返回的 Token usage;
|
||||
4. 再次检查取消或迟到结果。
|
||||
|
||||
Interceptor 不负责重试模型,也不判断输出是否支持根因。它只确保框架内部的每轮调用无法绕过 Harness。
|
||||
|
||||
## 5. Tool Interceptor:先检查进展,再允许查询
|
||||
|
||||
模型提交的 Tool Call 不只是业务参数,还携带对上一轮结果的评价。Tool Interceptor 会依次检查:
|
||||
|
||||
- previous observation 是否完整、顺序是否正确;
|
||||
- 上一轮是 `GAINED` 还是 `NO_GAIN`;
|
||||
- 当前 Tool scope 是否已经完成过;
|
||||
- 收集状态是否已经 `SATURATED`;
|
||||
- 通过检查后,才把业务请求交给 ToolBoundary。
|
||||
|
||||
它不执行后端查询,也不再次扣 Tool 预算。实际执行属于 ToolBoundary;收敛状态属于 ProgressTracker。Interceptor 只是两者与 ReAct 框架之间的接合点。
|
||||
|
||||
## 6. 为什么“有结果”不等于“有信息增益”
|
||||
|
||||
Tool 可以客观判断是否返回候选内容,却不知道这些内容是否推进了当前假设。例如同一条超时日志再次出现:
|
||||
|
||||
```text
|
||||
InvocationStatus = READY
|
||||
EvidenceStatus = EVIDENCE_FOUND
|
||||
InformationGain = NO_GAIN
|
||||
```
|
||||
|
||||
前三个状态回答不同问题:查询是否完成、是否有候选内容、内容是否推进当前诊断。把它们合成一个 `SUCCESS` 会让系统无法正常收敛。
|
||||
|
||||
当前由 Agent 在**下一次 Tool Call** 中评价上一轮的信息增益。这样评价发生在它真正做出下一步行动时,Harness 也能机械检查调用顺序,而不需要再增加一个 Progress Judge 模型。
|
||||
|
||||
## 7. Progress Tracker 保存什么
|
||||
|
||||
Tracker 只保存控制所需的最小状态:
|
||||
|
||||
- 已完成的 `tool_call_id` 和规范化 scope;
|
||||
- 哪个结果仍等待 Agent 评价;
|
||||
- 连续 `NO_GAIN` 次数;
|
||||
- 收集状态和停止原因;
|
||||
- 停止指令是否已经交付。
|
||||
|
||||
它不保存 raw response,也不判断日志是否证明支付线程池耗尽。业务价值判断仍由 Diagnosis Agent 负责,事实内容仍由 canonical store 负责。
|
||||
|
||||
主要代码入口:`DiagnosisProgressTracker`、`ToolScopeNormalizer`、`DiagnosisProgressProjector`。
|
||||
|
||||
## 8. 为什么停止不直接等于失败
|
||||
|
||||
连续无增益后停止,是一次正常的受控收敛,不是技术异常。Harness 会从已经完成的 canonical Tool 记录中投影 `ProgressSnapshot`,只保留可验真的检查范围、观察事实和限制,再形成安全 Fallback。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
N["连续 NO_GAIN 或重复查询"] --> S["CollectionState = SATURATED"]
|
||||
S --> X["拒绝新的证据 Tool"]
|
||||
X --> P["生成安全 ProgressSnapshot"]
|
||||
P --> F["发布证据不足的 Fallback"]
|
||||
```
|
||||
|
||||
因此,“没有找到足够证据”可以正常结束;只有违反进展协议、预算耗尽且没有安全进展等情况,才可能升级为失败。
|
||||
|
||||
## 9. 为什么没有采用其他方案
|
||||
|
||||
| 方案 | 没有采用的原因 |
|
||||
|---|---|
|
||||
| 只设 Tool 次数上限 | 只能止损,不能识别查询已经没有价值 |
|
||||
| Harness 根据结果条数判断增益 | 条数是客观统计,不代表是否推进业务假设 |
|
||||
| 增加 Progress Judge Agent | 增加模型成本和新的非确定性判断点 |
|
||||
| 用自然语言相似度判断重复 | 首版难以稳定解释误判,当前只做 typed 参数级 scope 规范化 |
|
||||
| 把剩余预算告诉模型 | 容易让模型围绕阈值博弈,硬限制应由 Harness 保持 |
|
||||
|
||||
## 10. 先记住这些
|
||||
|
||||
1. 框架负责 ReAct loop,Harness 通过 Interceptor 接入控制能力。
|
||||
2. 预算回答“还能不能查”,信息增益回答“继续查有没有价值”。
|
||||
3. Tool 是否有结果与结果是否推进诊断是两件事。
|
||||
4. ProgressTracker 只保存控制状态,不保存完整证据。
|
||||
5. 信息饱和可以正常发布 Fallback,不等于 Run 执行失败。
|
||||
|
||||
下一篇:[03 Tool 事实边界](03-Tool事实边界-让模型看到必要信息而系统保留真相.md)。
|
||||
|
||||
需要深入停止协议时,阅读[信息增益停止专题](../Harness信息增益停止-让无证据诊断正常收敛.md)。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 03 Tool 事实边界:让模型看到必要信息,系统保留真相
|
||||
|
||||
这一篇只回答一个问题:**Tool 返回的数据应该由谁保管,模型究竟可以看到多少?**
|
||||
|
||||
## 1. 先看一个具体问题
|
||||
|
||||
Agent 查询支付日志,后端一次返回了几千行内容,其中包含重复堆栈、敏感字段和远超上下文窗口的正文。
|
||||
|
||||
直接把原始结果塞回模型会产生几个问题:
|
||||
|
||||
- 敏感数据进入模型上下文;
|
||||
- 大结果挤占 Token,真正关键的证据反而被淹没;
|
||||
- 模型后续引用经过裁剪的文本,系统无法证明它对应哪次真实调用;
|
||||
- 若只保存裁剪结果,EvidenceGuard 又失去了独立验真的事实来源;
|
||||
- 若把完整 raw 长期写入数据库,泄露面和存储成本都会扩大。
|
||||
|
||||
所以 Tool 设计的核心不是“怎样调用后端”,而是**谁拥有原始事实,以及不同消费者应该看到哪一层数据**。
|
||||
|
||||
## 2. 一次 Tool 调用的数据怎样变化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REQ["Typed Tool Request"] --> B["ToolBoundary<br/>权限、只读、预算、大小检查"]
|
||||
B --> RAW["Backend Raw Result"]
|
||||
RAW --> P["Tool-specific Projector"]
|
||||
P --> C["Canonical Invocation<br/>短期保存 request、raw、agent_result 和状态"]
|
||||
C --> CV["Control View<br/>供 Harness 判断状态和进展"]
|
||||
C --> MO["Model Observation<br/>供 Agent 推理的白名单内容"]
|
||||
C --> EG["EvidenceGuard<br/>按当前 Run 独立验真"]
|
||||
B -.-> AU["Durable Audit<br/>长期只留有界元数据"]
|
||||
```
|
||||
|
||||
第一次阅读只需区分三份内容:
|
||||
|
||||
- **Canonical Invocation**:系统在当前 Run 内短期保管的完整调用真相。
|
||||
- **Control View**:Harness 用来判断状态、scope 和证据数量的控制字段。
|
||||
- **Model Observation**:模型真正看到的安全、有限内容。
|
||||
|
||||
## 3. ToolBoundary:所有 Tool 的统一入口
|
||||
|
||||
RAG、日志和 MySQL 后端完全不同,但它们都必须遵守相同的不变量:
|
||||
|
||||
1. Run 仍然 active,调用 ID 与当前 Run 匹配;
|
||||
2. Tool 已授权,请求声明和实际行为保持只读;
|
||||
3. 调用前预算允许,结果大小没有越界;
|
||||
4. canonical 状态只能从 `PROJECTING` 进入 `READY` 或 `ERROR`;
|
||||
5. 长期 Audit 不保存完整请求和 raw response。
|
||||
|
||||
如果每个 Adapter 自己实现这些规则,三种 Tool 很快会出现不同的错误码、预算顺序和存储行为。ToolBoundary 集中处理共同规则,Adapter 只处理业务协议。
|
||||
|
||||
ToolBoundary 不判断信息增益,也不判断某条日志是否支持根因。它只保证调用合法、状态可信、结果有界。
|
||||
|
||||
主要代码入口:`ToolBoundary`、`ToolCallRequestEnvelope`、`ToolBoundaryResult`、`ToolBoundaryErrorCode`。
|
||||
|
||||
## 4. Projector:把后端结果变成稳定事实
|
||||
|
||||
不同后端的 raw 数据不能直接成为 Agent 契约:
|
||||
|
||||
- RAG 需要限制证据数量和摘录长度,并保留文档身份;
|
||||
- Logs 需要限制事件、聚合 pattern,明确实际时间范围和是否截断;
|
||||
- MySQL 需要限制行、列、单元格和总 bytes,并保留查询 scope。
|
||||
|
||||
每种 Tool 使用自己的 `ToolResultProjector`。Projector 负责标准化和计算客观 `EvidenceStatus`,但它不调用模型,也不判断这些事实是否足以支持最终结论。
|
||||
|
||||
这是刻意保留的业务差异。强行做一个“万能 JSON Cleaner”,往往会丢掉每类证据真正需要的身份和范围信息。
|
||||
|
||||
## 5. Canonical Store:为什么要保留独立真相
|
||||
|
||||
Agent Observation 是为推理优化的,它会被裁剪和白名单投影,不能反过来充当验真依据。Canonical Store 保存当前调用的 request、raw、标准化 `agent_result`、状态和时间,使 EvidenceGuard 能绕开 Agent 上下文独立读取事实。
|
||||
|
||||
当前选择 Redis TTL,是因为完整调用记录:
|
||||
|
||||
- 只在当前 Run 的验证阶段需要;
|
||||
- 可能包含敏感内容,不应永久保存;
|
||||
- 需要按 `runId + tool_call_id` 快速定位;
|
||||
- 容量必须有硬上限,读取不能自动续期。
|
||||
|
||||
raw 超限时选择失败,而不是悄悄截断。因为一旦截断,canonical record 就不再代表后端真实返回,后续验真会建立在不完整事实之上。
|
||||
|
||||
主要代码入口:`CanonicalInvocationStore`、`RedisCanonicalInvocationStore`、`CanonicalToolInvocation`、`CanonicalInvocationLimits`。
|
||||
|
||||
## 6. Model Observation:模型只拿完成任务所需的内容
|
||||
|
||||
即使 canonical `agent_result` 已经标准化,其中仍可能包含 Harness 控制字段。`ToolResultViewProjector` 会进一步拆成:
|
||||
|
||||
```text
|
||||
Control View:status、evidence count、scope、截断状态等
|
||||
Model Observation:有界证据正文、可读来源和下一步推理所需字段
|
||||
```
|
||||
|
||||
模型不会看到 raw response、Redis key、预算阈值、重复指纹和完整 Harness 状态。这样既减少上下文噪声,也避免模型利用或复述内部控制信息。
|
||||
|
||||
## 7. MySQL 为什么还需要单独的只读沙箱
|
||||
|
||||
`readOnly=true` 只是请求声明,不能证明模型生成的 SQL 安全。MySQL Tool 在进入真实执行前还要经过:
|
||||
|
||||
- JSqlParser AST 解析;
|
||||
- 单条 SELECT 和保守语法子集限制;
|
||||
- 数据源、schema、table、column 精确 allowlist;
|
||||
- 禁止投影通配符和元数据探测;
|
||||
- 只读账号、timeout、LIMIT 与最大行数。
|
||||
|
||||
Validator 产生已经批准的 `MysqlQueryPlan`,Executor 只接受这个 plan,不重新信任原始字符串。这是纵深防御:任何一层都不能单独被当作完整授权。
|
||||
|
||||
## 8. 为什么短期真相和长期审计要分开
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
C["Canonical Store<br/>完整、敏感、短期"] -->|"用于"| V["当前 Run 验真"]
|
||||
A["Durable Audit<br/>有界、元数据、长期"] -->|"用于"| O["排障、统计、成本对账"]
|
||||
```
|
||||
|
||||
把两者合并会走向两个极端:要么长期复制所有敏感正文,要么为了安全只存元数据,导致当前 Run 无法验真。分开后,每种存储只承担自己的责任。
|
||||
|
||||
## 9. 为什么没有采用其他方案
|
||||
|
||||
| 方案 | 没有采用的原因 |
|
||||
|---|---|
|
||||
| raw 直接返回 Agent | 泄露、超限、噪声和无法独立验真 |
|
||||
| 只保存 Agent Observation | 投影内容不是完整事实,Guard 会依赖模型看到的版本 |
|
||||
| 完整 raw 永久写 MySQL | 扩大敏感数据暴露面和长期存储成本 |
|
||||
| raw 超限后静默截断 | canonical truth 会变成不完整真相 |
|
||||
| 所有 Tool 共用万能 Projector | 无法保持日志、RAG、表格各自的身份和 scope 语义 |
|
||||
|
||||
## 10. 先记住这些
|
||||
|
||||
1. ToolBoundary 统一执行规则,Adapter 处理具体后端。
|
||||
2. Canonical Invocation 是系统真相,Model Observation 是模型视图。
|
||||
3. Agent 看到的内容不能反过来成为 EvidenceGuard 的事实来源。
|
||||
4. 完整真相短期保存,长期 Audit 只留有界元数据。
|
||||
5. Tool 找到候选内容,不代表它支持最终根因。
|
||||
|
||||
下一篇:[04 验证与发布](04-验证与发布-让未经证明的结论无法越过出口.md)。
|
||||
|
||||
需要深入字段和存储取舍时,阅读[Tool 双视图专题](../Harness-Tool双视图-从原始结果到可验证证据.md)。
|
||||
@@ -0,0 +1,162 @@
|
||||
# 04 验证与发布:让未经证明的结论无法越过出口
|
||||
|
||||
这一篇只回答一个问题:**Agent 写完诊断草稿以后,为什么还不能直接返回给用户?**
|
||||
|
||||
## 1. 先看一个具体问题
|
||||
|
||||
Agent 在报告中写道:
|
||||
|
||||
> 10:03 出现数据库连接池耗尽,因此支付接口大量超时。
|
||||
|
||||
它同时引用了一次日志 Tool Call。系统即使确认这个调用真实存在、属于当前 Run,而且日志中确实出现“connection timeout”,仍然不能直接发布上述结论。
|
||||
|
||||
因为这里至少有三个不同问题:
|
||||
|
||||
1. Agent 引用的 Tool Call 是不是真的?
|
||||
2. 真实日志是否足以证明“数据库连接池耗尽导致支付超时”?
|
||||
3. 验证失败后,系统最终应该向用户发布什么?
|
||||
|
||||
它们分别属于 EvidenceGuard、SemanticGuard 和 Release。
|
||||
|
||||
## 2. 草稿怎样通过发布链
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["DiagnosisDraft<br/>Agent 写出的草稿"] --> E["EvidenceGuard<br/>机械验证引用和归属"]
|
||||
E -->|"结构或引用可修复"| R["EvidenceRepair<br/>只修引用,不改语义"]
|
||||
R --> E
|
||||
E -->|"得到已验真证据"| S["SemanticGuard<br/>判断证据是否支持报告"]
|
||||
S -->|"SUPPORTED"| P["Release SUCCESS<br/>发布原始安全 Draft"]
|
||||
E -->|"无法验真"| F["SafeFallback"]
|
||||
S -->|"UNSUPPORTED 或不可用"| F
|
||||
F --> O["Release FALLBACK"]
|
||||
```
|
||||
|
||||
第一次阅读只需记住:
|
||||
|
||||
- **EvidenceGuard 验真**:证明引用确实来自当前 Run 的已完成 Tool 调用。
|
||||
- **SemanticGuard 验义**:判断这些真实证据是否支持报告中的结论。
|
||||
- **Release 决定出口**:只发布验证通过的原 Draft,或者发布确定性的 SafeFallback。
|
||||
|
||||
## 3. EvidenceGuard:代码能证明的事交给代码
|
||||
|
||||
EvidenceGuard 会检查:
|
||||
|
||||
- Draft 结构和 analysis ID 是否合法;
|
||||
- 所有结论分析是否形成完整引用闭包;
|
||||
- `tool_call_id` 是否属于当前 Run;
|
||||
- canonical invocation 是否为 `READY`;
|
||||
- `EvidenceStatus` 与引用类型是否一致;
|
||||
- 引用的 evidence 是否确实存在于标准化 Tool 结果中。
|
||||
|
||||
这些都是确定性问题,不需要模型判断。让模型验证自己的引用,会让同一个非确定性来源同时当作者和裁判。
|
||||
|
||||
验证通过后,EvidenceGuard 生成 `VerifiedEvidenceSnapshot`。它只包含 SemanticGuard 所需的已验真证据、来源和 scope,不包含 Tool raw response。
|
||||
|
||||
主要代码入口:`EvidenceGuard`、`EvidenceGuardResult`、`VerifiedEvidenceSnapshot`、`EvidenceViolation`。
|
||||
|
||||
## 4. EvidenceRepair:为什么允许修,又为什么只能修一次
|
||||
|
||||
有些 Draft 的业务语义可能没有问题,只是引用结构出错,例如漏写一个 analysis ID。直接丢弃会浪费一次昂贵诊断,因此系统允许一次 EvidenceRepair。
|
||||
|
||||
但 Repair 不是第二个报告作者。它必须满足:
|
||||
|
||||
```text
|
||||
修复前用户可见语义 == 修复后用户可见语义
|
||||
```
|
||||
|
||||
它只能修结构和引用,不能新增事实、改变结论或补写建议;修复后还必须重新经过 EvidenceGuard。若语义视图发生变化,Repair 立即失败。
|
||||
|
||||
只允许一次,是为了避免形成“修复失败再修复”的隐式 Agent loop,也让成本和执行路径保持可解释。
|
||||
|
||||
## 5. SemanticGuard:引用真实不等于推论成立
|
||||
|
||||
一条真实的 `connection timeout` 日志,可能来自下游网络问题,也可能只是故障结果,不能自动证明数据库连接池耗尽。
|
||||
|
||||
这类支持关系无法完全用规则判断,因此 SemanticGuard 使用一次隔离的模型调用,只接收:
|
||||
|
||||
- 用户原始问题;
|
||||
- Draft 的用户可见语义;
|
||||
- EvidenceGuard 产生的 verified snapshot。
|
||||
|
||||
它没有 Tool、没有记忆、没有 ReAct loop、不访问 Redis,也不能改写报告。输出只有 `SUPPORTED / UNSUPPORTED` 和审计原因。
|
||||
|
||||
这个设计没有追求一个看似精确的置信度分数。首版真正需要的是发布门禁,而未经校准的 0.73 并不能形成比二元判定更可靠的协议。
|
||||
|
||||
主要代码入口:`SemanticGuard`、`SemanticGuardInput`、`SemanticGuardDecision`、`GuardModelCall`。
|
||||
|
||||
## 6. Release:为什么必须只有一个出口
|
||||
|
||||
如果 Agent、Guard、Application 都能各自构造最终结果,会出现:
|
||||
|
||||
- 同一验证失败被不同层解释成不同文案;
|
||||
- 某个分支忘记经过 SemanticGuard;
|
||||
- 迟到的 Draft 绕过已经确定的取消或失败;
|
||||
- Fallback 混入未经验证的 Agent 内容。
|
||||
|
||||
`DiagnosisReleaseUseCase` 因此拥有唯一发布决策。它协调 EvidenceGuard、可选 Repair、SemanticGuard 和 SafeFallbackFactory,但自己不写新根因。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
V{"可以安全发布正常报告吗?"}
|
||||
V -->|"引用真实且语义受支持"| OK["ReleaseOutcome.SUCCESS<br/>发布原 Draft"]
|
||||
V -->|"证据不足或验证不通过"| FB["ReleaseOutcome.FALLBACK<br/>发布 SafeFallback"]
|
||||
V -->|"无法形成安全业务结果"| ER["ReleaseOutcome.FAILED"]
|
||||
```
|
||||
|
||||
这里必须区分:
|
||||
|
||||
```text
|
||||
RunState.SUCCESS + ReleaseOutcome.FALLBACK
|
||||
```
|
||||
|
||||
这表示系统完整、安全地处理了请求,但证据不足以发布正常诊断结论。Fallback 是安全产品结果,不等于执行失败。
|
||||
|
||||
## 7. SafeFallback:不是让另一个模型重新回答
|
||||
|
||||
验证失败后再次让模型“写得保守一点”,仍然可能产生新事实。SafeFallback 因此由代码确定性构造,只允许使用:
|
||||
|
||||
- 已验真的来源和检查范围;
|
||||
- 可以安全表达的观察事实;
|
||||
- 当前证据的限制;
|
||||
- 面向补充数据的下一步建议;
|
||||
- 稳定、可公开的问题类型。
|
||||
|
||||
它不会包含 Agent 原 Draft 的未验证结论、SemanticGuard 内部原因、Provider 错误或异常栈。
|
||||
|
||||
主要代码入口:`DiagnosisReleaseUseCase`、`EvidenceRepair`、`SafeFallbackFactory`、`DiagnosisReleaseResult`。
|
||||
|
||||
## 8. Contract:为什么状态和结果必须类型化
|
||||
|
||||
发布链横跨模型 JSON、Java、Redis、数据库和 SSE。如果各层都使用自由字符串 `SUCCESS`,很快就无法区分:
|
||||
|
||||
- Tool 调用完成;
|
||||
- Tool 找到候选证据;
|
||||
- 语义审查通过;
|
||||
- Run 执行成功;
|
||||
- 最终发布正常报告。
|
||||
|
||||
Contract 包用不同类型保持这些问题正交,例如 `InvocationStatus`、`EvidenceStatus`、`SemanticVerdict`、`RunState` 和 `ReleaseOutcome`。类型多不是为了复杂,而是为了阻止一个含糊的 `status` 穿过整个系统。
|
||||
|
||||
## 9. 为什么没有采用其他方案
|
||||
|
||||
| 方案 | 没有采用的原因 |
|
||||
|---|---|
|
||||
| 只检查 Tool Call ID 存在 | 只能证明引用存在,不能证明归属、状态和内容一致 |
|
||||
| 一个 Verifier Agent 同时验引用和语义 | 混合确定性与非确定性判断,失败后难以定位责任 |
|
||||
| Guard 自动改写报告 | Guard 会变成第二个作者,并可能引入未验证事实 |
|
||||
| SemanticGuard 使用 Tool 再查证 | 会形成第二条诊断链,预算和证据归属变复杂 |
|
||||
| 验证失败直接抛技术错误 | 证据不足是正常业务结果,用户仍需要安全说明 |
|
||||
| 用置信度阈值发布 | 未校准分数不能充当可靠安全协议 |
|
||||
|
||||
## 10. 先记住这些
|
||||
|
||||
1. Draft 是候选结果,不是已发布报告。
|
||||
2. EvidenceGuard 用代码证明引用真实,SemanticGuard 判断证据是否支持语义。
|
||||
3. Repair 只能修引用,不能改变用户可见语义,而且只尝试一次。
|
||||
4. Release 是唯一出口,只发布原 Draft 或确定性 SafeFallback。
|
||||
5. `FALLBACK` 可以对应一次正常完成的 Run。
|
||||
|
||||
四篇组件导读到这里结束。需要回看整体路径时返回[组件学习地图](README.md)。
|
||||
|
||||
需要深入验证规则时,阅读[证据安全链专题](../Harness证据安全链-从引用真实到结论可发布.md);需要查所有 contract 类型时,阅读[组件全景](../Harness组件全景-职责-设计原因与边界.md)。
|
||||
@@ -0,0 +1,22 @@
|
||||
# Harness 组件:从一次请求逐步认识
|
||||
|
||||
这里不按照 Java 包逐个介绍组件,而是沿着一次诊断请求,分四步回答四个问题。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["一个诊断请求来了"] --> C1["01 运行控制<br/>怎样保证这次执行受控"]
|
||||
C1 --> C2["02 Agent 接入与收敛<br/>怎样让 Agent 工作但不空转"]
|
||||
C2 --> C3["03 Tool 事实边界<br/>怎样安全地取得事实"]
|
||||
C3 --> C4["04 验证与发布<br/>怎样决定结果能否交给用户"]
|
||||
```
|
||||
|
||||
| 顺序 | 先回答的问题 | 涉及的职责域 |
|
||||
|---|---|---|
|
||||
| [01 运行控制](01-运行控制-让一次请求有边界.md) | 谁创建 Run,谁限制资源,谁记录它如何结束? | Application、Core、Retry、Audit |
|
||||
| [02 Agent 接入与收敛](02-Agent接入与收敛-让推理可以工作也可以停止.md) | 怎样使用框架 ReAct,同时阻止重复查询和无增益空转? | Agent、Progress |
|
||||
| [03 Tool 事实边界](03-Tool事实边界-让模型看到必要信息而系统保留真相.md) | Tool 原始结果由谁保存,模型究竟能看到什么? | Tool |
|
||||
| [04 验证与发布](04-验证与发布-让未经证明的结论无法越过出口.md) | 引用真实是否等于结论成立,最终由谁决定发布? | Guard、Release、Contract |
|
||||
|
||||
建议一次只读一篇。每篇读到“先记住这些”就可以停下;类名只在最后用于定位代码。
|
||||
|
||||
需要查全部生产类型时,再使用[组件全景参考手册](../Harness组件全景-职责-设计原因与边界.md)。
|
||||
@@ -0,0 +1,326 @@
|
||||
# 从一次支付超时诊断看 Harness 如何控制 Agent
|
||||
|
||||
这篇文章不从组件清单开始,而是跟随一次真实请求,看 Agent 如何完成诊断,以及 Harness 在每个关键节点控制了什么。
|
||||
|
||||
先说最终结果:这次请求正常执行了两轮 Agent、调用了两个 Tool,两个 Tool 都返回了候选证据,但最终没有发布诊断结论,而是安全地返回了 Fallback。
|
||||
|
||||
这不是一次“什么都没做成”的失败。恰恰相反,它展示了 Harness 最重要的价值:**即使 Agent 已经写出答案,只要证据无法完成验真,答案就不能越过发布出口。**
|
||||
|
||||
第一次阅读只看第 1、2、8、9 和 13 节即可:先知道问题和主流程,再看为什么被挡下、用户最终收到什么。其余章节用于展开每一步的组件设计。
|
||||
|
||||
## 1. 这次诊断从什么问题开始
|
||||
|
||||
用户请求是:
|
||||
|
||||
> 支付接口最近出现超时。在给出结论前必须实际调用 `lookup_knowledge` 和 `query_logs` 各一次,日志范围使用 APPLICATION 并查询 `payment-service error slow database`;随后结束诊断,证据不足时明确说明缺口。
|
||||
|
||||
这是一个很典型的 AIOps 问题。用户看到的是“支付超时”,但可能原因很多:
|
||||
|
||||
- 数据库连接池耗尽;
|
||||
- 下游服务响应过慢;
|
||||
- Redis 或网络超时;
|
||||
- JVM、线程池或 CPU 资源异常;
|
||||
- 只是历史知识中的相似案例,并非当前生产故障。
|
||||
|
||||
如果只有 Agent,它可以查询资料、阅读日志并写出一个听起来合理的解释。但系统还必须回答:查询是否属于本次请求、结果能否被引用、引用是否支持结论,以及证据不足时应该怎样结束。
|
||||
|
||||
## 2. 先看完整故事
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["用户报告支付超时"] --> R["创建 Run<br/>建立身份、预算和取消"]
|
||||
R --> I["Router 判定为 DIAGNOSIS"]
|
||||
I --> A1["Agent 第 1 轮<br/>选择知识库和日志 Tool"]
|
||||
A1 --> T["ToolBoundary<br/>执行、投影并保存调用真相"]
|
||||
T --> A2["Agent 第 2 轮<br/>根据观察结果生成 Draft"]
|
||||
A2 --> E["EvidenceGuard<br/>验证引用真实性"]
|
||||
E -->|"本次未通过"| F["SafeFallback<br/>不发布未经验证的根因"]
|
||||
F --> U["SSE content + done FALLBACK"]
|
||||
```
|
||||
|
||||
沿着这条线,可以把双方职责简单分开:
|
||||
|
||||
| 阶段 | Agent 在做什么 | Harness 在控制什么 |
|
||||
|---|---|---|
|
||||
| 请求开始 | 尚未参与 | 创建 Run,固定身份、deadline、预算和取消 |
|
||||
| 意图路由 | 尚未诊断 | 限制 Router 输入、调用次数和重试 |
|
||||
| 选择 Tool | 提出查询计划 | 检查 Run、进展协议、重复 scope 和 Tool 权限 |
|
||||
| Tool 返回 | 阅读有界观察 | 保存 canonical truth,限制 bytes,只投影必要字段 |
|
||||
| 生成 Draft | 写出候选报告 | 限制输出结构和大小,Draft 尚不可发布 |
|
||||
| 验证发布 | 不再拥有决定权 | 验引用、验语义,决定报告或 Fallback |
|
||||
| 请求结束 | 执行结束 | 固化终态、持久化结果、记录 Trace 和 Token |
|
||||
|
||||
下面逐步展开。
|
||||
|
||||
## 3. 第一步:Harness 先创建 Run
|
||||
|
||||
请求进入 `ChatApplicationUseCase` 后,系统不会立即调用 Agent,而是先创建本次执行的 `RunContext`。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["sessionId<br/>多轮对话容器"] --> R["runId<br/>本次独立执行"]
|
||||
R --> D["deadline"]
|
||||
R --> B["RunBudget"]
|
||||
R --> C["RunCancellation"]
|
||||
R --> L["RunLifecycle"]
|
||||
R --> P["ProgressTracker"]
|
||||
```
|
||||
|
||||
为什么不能只使用 sessionId?因为同一个会话可以连续提出多个问题。Tool Call、Agent Step、Evidence 和最终结果都必须属于某一个精确 Run,否则上一轮日志可能被下一轮报告误引用。
|
||||
|
||||
为什么要在 Agent 之前创建预算和取消能力?因为 Router、Agent、Tool 和 SemanticGuard 都会消耗时间或资源。只有共享同一个 RunContext,系统才能统一回答“还能不能继续执行”。
|
||||
|
||||
这一阶段最重要的不是创建了几个对象,而是确定了一条规则:
|
||||
|
||||
> 后续任何模型调用、Tool 调用和发布动作,都必须证明自己仍属于这个 active Run。
|
||||
|
||||
## 4. 第二步:Router 只决定走哪条路
|
||||
|
||||
用户问题先经过 Intent Router。它只判断请求属于:
|
||||
|
||||
```text
|
||||
SYSTEM_CHAT
|
||||
KNOWLEDGE_QUERY
|
||||
DIAGNOSIS
|
||||
```
|
||||
|
||||
本次结果是 `DIAGNOSIS`,于是 `ChatApplicationUseCase` 将同一个 RunContext 交给 `DiagnosisChatExecutor`。
|
||||
|
||||
Router 不读取完整历史,不调用业务 Tool,也不尝试回答支付超时的根因。这样设计是为了避免“路由”在不知不觉中变成一个简化版诊断 Agent。
|
||||
|
||||
即便只是路由,Harness 仍然控制它的输入大小、timeout、Token、显式 retry 和 Trace。因为一次隐藏的模型重试,同样会造成成本和延迟无法解释。
|
||||
|
||||
## 5. 第三步:Agent 决定查什么,Harness 决定能不能查
|
||||
|
||||
Diagnosis Agent 第 1 轮读取用户问题和服务端注册的 Tool schema,随后发出两个 Tool Call:
|
||||
|
||||
```text
|
||||
lookup_knowledge
|
||||
query_logs
|
||||
```
|
||||
|
||||
这里仍然由 Agent 负责业务判断:它认为需要先查排障知识,再查看应用日志。Harness 不会用固定工作流替它安排“先 RAG、再日志”。
|
||||
|
||||
但 Agent 发出 Tool Call 不等于后端会立即执行。调用先经过 `HarnessToolInterceptor`:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TC["Agent Tool Call"] --> A{"Run 仍 active?"}
|
||||
A --> P{"上一轮进展协议正确?"}
|
||||
P --> D{"scope 是否重复或已饱和?"}
|
||||
D --> B{"ToolBoundary 权限与预算允许?"}
|
||||
B -->|"全部通过"| X["执行 Tool backend"]
|
||||
A -->|"否"| STOP["拒绝调用"]
|
||||
P -->|"否"| STOP
|
||||
D -->|"否"| STOP
|
||||
B -->|"否"| STOP
|
||||
```
|
||||
|
||||
这就是 Harness 与工作流引擎的区别:
|
||||
|
||||
- 工作流引擎决定下一步必须调用什么;
|
||||
- Harness 不替 Agent 选下一步,只检查这一步是否满足执行条件。
|
||||
|
||||
## 6. 第四步:Tool 返回的不是一份数据,而是三种视图
|
||||
|
||||
两个 Tool 都通过 `ToolBoundary`。它统一完成 Run 归属、Tool 授权、只读约束、调用预算、结果 bytes、canonical 状态和长期审计检查。
|
||||
|
||||
后端原始结果不会直接返回给 Agent,而是形成三种用途不同的视图:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RAW["Tool Raw Result"] --> C["Canonical Invocation<br/>当前 Run 的短期完整真相"]
|
||||
C --> CV["Control View<br/>状态、证据数量、scope"]
|
||||
C --> MO["Model Observation<br/>Agent 可见的有界内容"]
|
||||
C --> EG["EvidenceGuard<br/>独立验真来源"]
|
||||
C -.-> AU["Durable Audit<br/>长期只保存元数据"]
|
||||
```
|
||||
|
||||
为什么要分开?
|
||||
|
||||
- Agent 需要的是能继续推理的少量事实,不需要 Redis key、预算阈值和全部 raw;
|
||||
- EvidenceGuard 需要独立于 Agent 上下文读取真实调用记录;
|
||||
- 线上审计需要状态、耗时和 bytes,但不应该永久复制日志正文。
|
||||
|
||||
本次真实 Run 中:
|
||||
|
||||
| Tool | InvocationStatus | EvidenceStatus | 说明 |
|
||||
|---|---|---|---|
|
||||
| `lookup_knowledge` | `READY` | `EVIDENCE_FOUND` | 找到了候选排障知识 |
|
||||
| `query_logs` | `READY` | `EVIDENCE_FOUND` | 返回了候选日志内容,但数据源明确为 Mock |
|
||||
|
||||
这两个结果只能证明“查询成功并返回候选内容”,不能证明“已经找到支付超时的生产根因”。尤其 Mock 日志不能被包装成生产环境事实。
|
||||
|
||||
## 7. 第五步:Agent 写出 Draft,但 Draft 还不是报告
|
||||
|
||||
Tool Observation 回到 ReAct 上下文后,Agent 进入第 2 轮模型调用并生成结构化 `DiagnosisDraft`。
|
||||
|
||||
真实 Trace 中可以看到:
|
||||
|
||||
```text
|
||||
Agent Step 0:has_text=false,发出 lookup_knowledge 和 query_logs
|
||||
Agent Step 1:has_text=true,不再调用 Tool
|
||||
```
|
||||
|
||||
到这一步,Agent 的职责已经完成:它收集了观察结果,并根据这些内容写出候选结论、分析和限制。
|
||||
|
||||
但 Harness 把它称为 Draft,而不是 Report。原因是模型输出还没有证明:
|
||||
|
||||
- 引用的 `tool_call_id` 属于当前 Run;
|
||||
- 引用对应的调用已经 `READY`;
|
||||
- 引用内容确实存在于 canonical result;
|
||||
- 真实证据足以支持用户可见结论。
|
||||
|
||||
如果此时直接通过 SSE 返回,前面所有 canonical store 和 Guard 设计都失去了意义。
|
||||
|
||||
## 8. 第六步:EvidenceGuard 挡住了这次发布
|
||||
|
||||
Release Pipeline 首先调用 EvidenceGuard。它不调用模型,而是通过代码检查 Draft 的结构、引用闭包、Run 归属、Tool 状态和 evidence 内容。
|
||||
|
||||
本次结果没有通过 EvidenceGuard。公开 SSE 没有泄露具体内部违规字段,只给出了稳定结果:
|
||||
|
||||
```text
|
||||
FallbackType = EVIDENCE_VALIDATION_FAILED
|
||||
message = 当前证据无法完成真实性校验,无法确认根因
|
||||
verified_sources = []
|
||||
```
|
||||
|
||||
这意味着系统不能把 Agent Draft 中的引用提升为已验真证据。既然第一层确定性验证都没有通过,后续 SemanticGuard 就没有可信输入,也不应继续讨论结论是否“语义上合理”。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D["DiagnosisDraft"] --> E{"EvidenceGuard"}
|
||||
E -->|"通过"| S["SemanticGuard<br/>检查证据是否支持结论"]
|
||||
E -->|"本次未通过"| F["EVIDENCE_VALIDATION_FAILED"]
|
||||
S -->|"SUPPORTED"| OK["发布原 Draft"]
|
||||
S -->|"UNSUPPORTED"| SF["语义不支持 Fallback"]
|
||||
```
|
||||
|
||||
这里最值得注意的是:两个 Tool 都成功了,Agent 也成功输出了 Draft,但 Release 仍然拒绝发布。
|
||||
|
||||
```text
|
||||
Tool READY
|
||||
不等于 EvidenceGuard 通过
|
||||
EvidenceGuard 通过
|
||||
不等于 SemanticGuard SUPPORTED
|
||||
SemanticGuard SUPPORTED
|
||||
才可能发布正常诊断报告
|
||||
```
|
||||
|
||||
## 9. 第七步:Fallback 是安全结果,不是技术失败
|
||||
|
||||
EvidenceGuard 未通过后,`SafeFallbackFactory` 由代码构造公开内容。它没有让另一个模型“重新写得保守一点”,也没有复用 Draft 中未经验证的根因。
|
||||
|
||||
用户最终收到:
|
||||
|
||||
```json
|
||||
{
|
||||
"content_type": "SAFE_FALLBACK",
|
||||
"fallback": {
|
||||
"type": "EVIDENCE_VALIDATION_FAILED",
|
||||
"conclusion": null,
|
||||
"message": "当前证据无法完成真实性校验,无法确认根因",
|
||||
"verified_sources": [],
|
||||
"limitations": ["证据引用校验未通过"],
|
||||
"next_steps": ["重新收集当前诊断范围内的证据后再发起诊断"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SSE 顺序完整结束:
|
||||
|
||||
```text
|
||||
metadata
|
||||
-> status ROUTING
|
||||
-> status DIAGNOSIS_RUNNING
|
||||
-> status SAFETY_VALIDATING
|
||||
-> content SAFE_FALLBACK
|
||||
-> done FALLBACK
|
||||
```
|
||||
|
||||
数据库记录则是:
|
||||
|
||||
```text
|
||||
diagnosis_run.status = SUCCESS
|
||||
diagnosis_run.release_outcome = FALLBACK
|
||||
```
|
||||
|
||||
两者并不矛盾:`status=SUCCESS` 表示请求被系统正常、安全地处理完毕;`release_outcome=FALLBACK` 表示没有正常诊断结论可以发布。
|
||||
|
||||
## 10. 这次 Run 最终留下了什么
|
||||
|
||||
| 项目 | 真实结果 |
|
||||
|---|---|
|
||||
| sessionId | `mvp-demo-payment-timeout-stage7-20260722-1741` |
|
||||
| runId | `363f481c-33b8-42e7-8699-428a6ec61806` |
|
||||
| 总耗时 | 35,996 ms |
|
||||
| 总 Token | 11,764 |
|
||||
| Agent Step | 2 |
|
||||
| Tool Invocation | 2 |
|
||||
| Tool 结果 | 两次均 `READY / EVIDENCE_FOUND` |
|
||||
| 最终内容 | `SAFE_FALLBACK` |
|
||||
| ReleaseOutcome | `FALLBACK` |
|
||||
| FallbackType | `EVIDENCE_VALIDATION_FAILED` |
|
||||
|
||||
长期审计保留的是 Run、Agent Step、Tool 状态、耗时和 Token 等有界信息。模型 Thought 保持为空,完整 Tool raw 也不会因为排障方便就永久进入普通 Trace。
|
||||
|
||||
因此这次 Run 虽然没有根因结论,却仍然可以回答:谁执行了什么、用了多少资源、在哪一层停止、为什么没有发布以及用户最终收到了什么。
|
||||
|
||||
## 11. 如果验证通过,后续会发生什么
|
||||
|
||||
本次真实路径在 EvidenceGuard 结束。为了理解完整设计,可以继续看通过分支:
|
||||
|
||||
1. EvidenceGuard 生成 `VerifiedEvidenceSnapshot`;
|
||||
2. SemanticGuard 只接收用户问题、Draft 语义和已验真证据;
|
||||
3. SemanticGuard 输出 `SUPPORTED / UNSUPPORTED`,不能改写报告;
|
||||
4. 只有 `SUPPORTED` 才发布 Agent 原 Draft;
|
||||
5. `UNSUPPORTED` 或 Guard 不可用时发布对应 SafeFallback。
|
||||
|
||||
这条分支不是本次支付超时 Run 的真实结果,因此这里只说明当前代码协议,不把它写成已经发生的事实。
|
||||
|
||||
## 12. 这次案例体现了哪些设计决策
|
||||
|
||||
### 决策一:Agent 拥有推理权,不拥有发布权
|
||||
|
||||
Agent 可以选择 Tool、解释 Observation 和撰写 Draft,但不能决定 Draft 是否直接交给用户。否则模型既是作者又是最终审批者。
|
||||
|
||||
### 决策二:Tool 成功与结论成立必须分层
|
||||
|
||||
`READY + EVIDENCE_FOUND` 只描述一次 Tool 调用。EvidenceGuard 和 SemanticGuard 分别处理引用真实性与结论支持度,避免一个含糊的 `SUCCESS` 贯穿全链路。
|
||||
|
||||
### 决策三:系统保留事实,模型只拿观察
|
||||
|
||||
Canonical Invocation 为当前 Run 保存完整真相;Agent Observation 只服务推理。Guard 不依赖模型看到的裁剪版本进行自证。
|
||||
|
||||
### 决策四:证据不足也应正常结束
|
||||
|
||||
不是所有诊断都能找到根因。Fallback 把“不能安全下结论”转换成稳定产品行为,而不是抛出技术异常或让模型猜一个答案。
|
||||
|
||||
### 决策五:审计记录控制事实,不复制思维过程
|
||||
|
||||
系统需要可回放,但不需要永久存储 Chain of Thought、完整 Prompt 和所有 raw payload。可观测性本身也必须有数据边界。
|
||||
|
||||
## 13. 用一句话复述这次案例
|
||||
|
||||
> 用户提出支付超时问题后,Harness 为请求建立独立 Run,允许 Diagnosis Agent 自主调用知识库和日志 Tool,并将完整调用事实保存在系统边界内;Agent 根据有界 Observation 写出 Draft 后,EvidenceGuard 发现引用无法完成真实性校验,Release 因此拒绝发布根因,转而返回确定性的 SafeFallback。整个请求正常结束、过程可回放,但未经验证的结论没有离开系统。
|
||||
|
||||
理解这句话,就理解了 Harness 的核心:**它不保证 Agent 每次都找到答案,但保证系统只对能够证明的答案负责。**
|
||||
|
||||
## 14. 事实来源与延伸阅读
|
||||
|
||||
本文案例数据来自:
|
||||
|
||||
- `mvp/demo/requests/payment-timeout-chat.json`;
|
||||
- `mvp/demo/output/stage7-20260722-1741/chat-sse.txt`;
|
||||
- `mvp/demo/output/stage7-20260722-1741/trace-response.json`;
|
||||
- `devflow/projects/2026-07-22-single-react-cleanup-e2e/evidence.md`。
|
||||
|
||||
继续理解具体机制:
|
||||
|
||||
- [Harness 入门](README.md)
|
||||
- [组件渐进式导读](components/README.md)
|
||||
- [Tool 双视图](Harness-Tool双视图-从原始结果到可验证证据.md)
|
||||
- [证据安全链](Harness证据安全链-从引用真实到结论可发布.md)
|
||||
- [生命周期与状态](Harness生命周期与状态.md)
|
||||
|
||||
需要查看另一条真实 `SUCCESS` 路径及详细 Token、RAG 和 Trace 数据,阅读[一次诊断全流程 E2E 导读](../diagnosis/一次诊断全流程-E2E导读.md)。
|
||||
@@ -0,0 +1,936 @@
|
||||
# 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
|
||||
**关联文档**:
|
||||
- `RAG排序-多路召回与RRF.md`(排序与多路召回判断框架)
|
||||
- 本文后续实现讨论以本节 **「交付拆分:分块去重 + Hybrid 同规划」** 为基线
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论先说
|
||||
|
||||
可以接,而且和前面讨论的多路召回 / RRF 高度一致。
|
||||
但(写作当时)项目 **还不具备 hybrid 运行条件**,缺的不是“再调一次 search”,而是:
|
||||
|
||||
```text
|
||||
1. schema 只有 dense,没有 sparse/BM25 字段
|
||||
2. 写入只产 dense embedding
|
||||
3. 检索抽象仍以单路 similaritySearch 为中心
|
||||
4. 后处理仍承担了过多“伪融合”职责
|
||||
5. 证据去重粒度偏文档/source 级,同文档多 chunk 会被吞掉
|
||||
```
|
||||
|
||||
**接入原则:**
|
||||
|
||||
```text
|
||||
- 不继续加厚旧 SDK search 分支
|
||||
- 以“检索端口 + 写入端口”抽象为准
|
||||
- hybrid 融合尽量下沉到向量库(RRFRanker)
|
||||
- 应用层保留:filter 策略、chunk 去重、return-n、阈值、投影
|
||||
- 分块去重与 hybrid 同规划、分里程碑交付(先共用地基,再开 hybrid)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph gaps["写作时的缺口"]
|
||||
G1[无 sparse/BM25 schema]
|
||||
G2[写入只有 dense]
|
||||
G3[单路 similaritySearch]
|
||||
G4[后处理伪融合]
|
||||
G5[source 级去重吞 chunk]
|
||||
end
|
||||
|
||||
subgraph principles["接入原则"]
|
||||
P1[端口抽象 · 不堆旧 SDK]
|
||||
P2[融合下沉向量库 RRF]
|
||||
P3[应用层:filter/dedup/return-n/投影]
|
||||
P4[先地基后 hybrid 分里程碑]
|
||||
end
|
||||
|
||||
gaps --> principles
|
||||
```
|
||||
|
||||
## 实现状态(2026-07-27,后续已完成)
|
||||
|
||||
| 里程碑 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 交付 1 chunk 身份/去重/SearchPort | **已完成并归档** | `2026-07-27-rag-chunk-evidence-identity-dedup` |
|
||||
| 交付 2a 应用层 multi-path+RRF | **已完成并归档** | `2026-07-27-rag-hybrid-search-rrf`(已被 2b 取代为生产路径) |
|
||||
| 交付 2b 真 BM25 hybrid + 废弃 SDK | **已完成并归档** | `2026-07-27-rag-bm25-hybrid-drop-sdk` |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D1[交付1<br/>chunk 身份/去重] --> D2a[交付2a<br/>app RRF]
|
||||
D2a --> D2b[交付2b<br/>真 BM25 hybrid]
|
||||
D2b --> NOW[生产:V2 store + hybrid mode]
|
||||
```
|
||||
|
||||
**当前生产知识路径:**
|
||||
|
||||
```text
|
||||
VectorIndexService / VectorSearchService
|
||||
-> MilvusHybridKnowledgeStore (MilvusClientV2 only)
|
||||
collection: milvus.collection (default biz)
|
||||
mode: retrieval.search.mode = dense | hybrid
|
||||
hybrid: dense ANN + BM25 sparse ANN + RRFRanker
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph write["写入"]
|
||||
UP[upload / init / rebuild] --> VIS[VectorIndexService]
|
||||
VIS --> STORE[MilvusHybridKnowledgeStore]
|
||||
end
|
||||
|
||||
subgraph read["检索"]
|
||||
LK[lookup_knowledge] --> VSS[VectorSearchService]
|
||||
VSS -->|dense| SD[searchDense]
|
||||
VSS -->|hybrid| SH[searchHybrid + RRF]
|
||||
SD --> STORE
|
||||
SH --> STORE
|
||||
end
|
||||
|
||||
STORE --> COL[(Milvus collection biz<br/>dense + BM25 schema)]
|
||||
```
|
||||
|
||||
**运维必做:** 全量重灌知识库到 hybrid schema collection(配置名以 `milvus.collection` 为准,常见 `biz`);旧纯 dense collection 不能直接当 hybrid 用。
|
||||
|
||||
---
|
||||
|
||||
## 1.1 交付拆分:分块去重 + Hybrid 同规划
|
||||
|
||||
> 实现讨论基线。后续排期、拆 PR、评审范围,默认按本节两个交付理解。
|
||||
|
||||
### 判断
|
||||
|
||||
**可以一起做,而且应该绑在同一条改造主线上**;
|
||||
但不要理解成「一个 PR 把 hybrid 全做完」。
|
||||
|
||||
更准确的表述:
|
||||
|
||||
```text
|
||||
同一条演进线,两层交付:
|
||||
交付 1:共用地基(证据身份 + chunk 去重 + 检索裁剪 + 端口雏形)
|
||||
交付 2:hybrid(schema/写入/查询/RRF + 阈值校准)
|
||||
```
|
||||
|
||||
### 为什么必须同规划
|
||||
|
||||
两边改的是同一条链上的相邻环节:
|
||||
|
||||
```text
|
||||
检索命中
|
||||
-> 候选身份(docId / chunkIndex / evidenceKey) ← hybrid 要,去重也要
|
||||
-> 去重 / 单文档 chunk 上限 ← 分块去重
|
||||
-> 排序融合(现在规则 / 以后库内 RRF) ← hybrid
|
||||
-> return-n / Agent 投影
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
HIT[检索命中] --> ID[候选身份<br/>docId / chunkIndex / evidenceKey]
|
||||
ID --> DEDUP[chunk 去重 · 每文档上限]
|
||||
DEDUP --> FUSE[排序融合 · 库内 RRF]
|
||||
FUSE --> RET[return-n]
|
||||
RET --> PROJ[Agent 投影]
|
||||
|
||||
ID -.->|交付1 地基| D1[chunk identity]
|
||||
DEDUP -.-> D1
|
||||
FUSE -.->|交付2| D2[hybrid]
|
||||
```
|
||||
|
||||
若拆开且顺序错误:
|
||||
|
||||
| 只做一项 | 后果 |
|
||||
|---|---|
|
||||
| 只做 hybrid,不做 chunk 去重 | 多路召回更多同文档片段,仍被 source 级去重吞掉,**hybrid 收益被吃掉** |
|
||||
| 只做去重,完全不管候选/端口结构 | 能立刻改善,但接 hybrid 时往往还要再改一遍 DTO 与映射 |
|
||||
|
||||
因此:
|
||||
|
||||
> **分块去重不是 hybrid 的可选项,而是 hybrid 生效的前提。**
|
||||
> 设计上当一件事;代码上分两个可独立验证的里程碑。
|
||||
|
||||
### 必须放进同一批(交付 1 公共地基)
|
||||
|
||||
这些强烈建议同一波完成,作为后续实现讨论的最小必选范围:
|
||||
|
||||
| 项 | 原因 |
|
||||
|---|---|
|
||||
| 候选补 `docId` / `chunkIndex` / `evidenceKey` | 去重 key 与 hybrid hit 身份统一 |
|
||||
| 后处理按 `docId#chunkIndex`(或 vector id fallback)去重 | 修复「同文档多 chunk 被吞」 |
|
||||
| `maxChunksPerDocument` | 放开多 chunk 后防止单文档刷屏 |
|
||||
| `retrieve-k` / `return-n` 分离 | hybrid 扩召回时必需;现在 K=3 也不该三者混用 |
|
||||
| Projector 去重语义对齐 | 后处理放出的多 chunk,不能在投影阶段再按 `source` 砍成 1 条 |
|
||||
| `SearchHit` / `RetrievedEvidenceCandidate` 字段对齐 | 避免 hybrid 再引入第三套结果结构 |
|
||||
| (建议)`KnowledgeSearchPort` 雏形 | 检索调用面先稳定,后续只换实现 |
|
||||
|
||||
可称为:
|
||||
|
||||
```text
|
||||
「检索结果身份与裁剪契约」
|
||||
```
|
||||
|
||||
**不上 hybrid 也有独立价值**,并且为交付 2 铺路。
|
||||
|
||||
### 不要硬塞进交付 1 的同一 PR
|
||||
|
||||
可同规划、建议第二波(交付 2):
|
||||
|
||||
| 项 | 原因 |
|
||||
|---|---|
|
||||
| 新 collection + sparse/BM25 schema | 数据迁移/重灌,风险独立 |
|
||||
| 全量重索引 | 耗时长,需单独验证 |
|
||||
| 打开 `search.mode=hybrid` | 依赖 sparse 数据已就绪 |
|
||||
| fused score 阈值重标定 | 要 hybrid 跑起来后有样本 |
|
||||
| 删除旧 SDK 读路径 | 最后做,降低回滚成本 |
|
||||
|
||||
否则单个交付会同时碰:业务排序逻辑 + 数据迁移 + 基础设施,评审、回滚、评测都困难。
|
||||
|
||||
### 交付 1:chunk 级证据身份 + 去重 + 检索裁剪
|
||||
|
||||
**主题:** 让同一次检索内,同文档多个相关 chunk 能作为独立证据存活,并为 hybrid 统一 hit 模型。
|
||||
|
||||
**范围(实现讨论默认包含):**
|
||||
|
||||
```text
|
||||
1. RetrievedEvidenceCandidate / EvidenceBlock
|
||||
- 补 docId、chunkIndex、evidenceKey
|
||||
2. KnowledgeDocumentRetriever
|
||||
- 从 metadata 抽取 docId/chunkIndex
|
||||
- evidenceKey 规则:
|
||||
docId + "#chunk-" + chunkIndex
|
||||
fallback: "vector:" + id
|
||||
fallback: "rank:" + originalRank
|
||||
3. KnowledgeEvidencePostProcessor
|
||||
- 去重 key = evidenceKey(不再 source/title 优先)
|
||||
- maxChunksPerDocument(建议默认 2)
|
||||
- 同 key 才 merge;merge 不覆盖更高分 content
|
||||
4. RagResultProjector
|
||||
- 按 evidence 身份去重(chunk 级 document_id 或显式 chunk 身份)
|
||||
- 禁止再仅用 source 当“每文档一条”的唯一键
|
||||
5. 配置
|
||||
- rag.retrieve-k
|
||||
- rag.return-n
|
||||
- rag.max-chunks-per-document
|
||||
- 逐步弱化/废弃单一 rag.top-k 身兼多职
|
||||
6. (建议同批)KnowledgeSearchPort / SearchRequest / SearchHit 雏形
|
||||
- 即使底层暂时仍是 dense-only,调用面先稳定
|
||||
7. 单测
|
||||
- 同 doc 两 chunk 都保留
|
||||
- 同 doc+chunk 真重复只留一条
|
||||
- 超 maxChunksPerDocument 裁掉低分
|
||||
- projector 不再误杀同 source 不同 chunk
|
||||
```
|
||||
|
||||
**明确不包含:**
|
||||
|
||||
```text
|
||||
- sparse/BM25 schema
|
||||
- 全量重灌
|
||||
- hybridSearch 开关
|
||||
- 旧 SDK 删除
|
||||
```
|
||||
|
||||
**完成定义(交付 1 Done):**
|
||||
|
||||
```text
|
||||
[ ] 同文档多相关 chunk 可同时出现在内部 evidenceBlocks
|
||||
[ ] Agent 投影后仍能看到多于 1 条同文档片段(未超预算时)
|
||||
[ ] retrieve-k / return-n 可配置且行为可测
|
||||
[ ] 候选身份字段稳定,足够支撑后续 hybrid hit 映射
|
||||
[ ] 不依赖旧 SDK 新增逻辑
|
||||
```
|
||||
|
||||
### 交付 2:Hybrid 写入 + 查询
|
||||
|
||||
**主题:** 在交付 1 的身份/裁剪契约稳定后,打开 dense + sparse/BM25 与库内 RRF。
|
||||
|
||||
**范围:**
|
||||
|
||||
```text
|
||||
1. 新 collection schema(dense + sparse/BM25 + 必要标量字段)
|
||||
2. 写入 dense + sparse,doc_id 级删除与重灌
|
||||
3. KnowledgeSearchPort 实现 hybrid 模式
|
||||
4. ranker = RRF(默认)/ Weighted(可配路权)
|
||||
5. category filter 策略:
|
||||
- 唯一 domain 时 filtered hybrid
|
||||
- 低质时 unfiltered 兜底(或双路径轻量合并)
|
||||
6. 分数语义区分 fused/dense/sparse,重标定 found/relevance
|
||||
7. 回归评测与延迟对比
|
||||
8. 冻结并最终删除旧 SDK 读路径
|
||||
```
|
||||
|
||||
**完成定义(交付 2 Done):**
|
||||
|
||||
```text
|
||||
[ ] 可配置 dense | hybrid 切换
|
||||
[ ] hybrid 默认 RRF,术语类与语义类回归不回退
|
||||
[ ] filter 误杀有兜底
|
||||
[ ] 应用层仍按 chunk 身份去重,hybrid 多命中不会被 source 级逻辑误伤
|
||||
[ ] 新逻辑不再依赖旧 SDK search
|
||||
```
|
||||
|
||||
### 节奏与评审方式
|
||||
|
||||
```text
|
||||
规划:一件事(检索质量主线)
|
||||
设计评审:按交付 1 + 交付 2 两章看
|
||||
开发:
|
||||
先合并交付 1(可独立上线/验证)
|
||||
再做交付 2(数据迁移 + hybrid 开关)
|
||||
验收:
|
||||
交付 1 用“同文档多 chunk”用例
|
||||
交付 2 用“术语/语义/filter 兜底/延迟”用例
|
||||
```
|
||||
|
||||
### 反模式(实现讨论时直接否决)
|
||||
|
||||
```text
|
||||
❌ 一个大 PR:去重 + schema 重灌 + hybrid 开关 + 删 SDK
|
||||
❌ 先上 hybrid、后补 chunk 去重
|
||||
❌ 交付 1 仍按 source 去重,只把 hybrid 分数接进来
|
||||
❌ 为 hybrid 新建第三套与 candidate/EvidenceBlock 并行的结果模型长期共存
|
||||
❌ 在旧 SDK search 实现里继续堆 hybrid 细节作为长期方案
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状对照
|
||||
|
||||
| 层级 | 当前实现 | Hybrid 需要 |
|
||||
|---|---|---|
|
||||
| Collection | `id / vector / content / metadata` | 至少再有 sparse/BM25 文本检索能力 |
|
||||
| 写入 | `VectorIndexService` 只写 dense | 同步维护 dense + sparse/BM25 |
|
||||
| 检索门面 | `VectorSearchService`:`sdk \| spring \| auto` | 单端口:`search(query, options)`,内部可 hybrid |
|
||||
| 旧 SDK 路径 | `MilvusServiceClient.search` | **废弃,不再作为主实现** |
|
||||
| Spring AI 路径 | `VectorStore.similaritySearch` | 可作过渡 dense 读路径,但 hybrid 能力要单独确认/扩展 |
|
||||
| 后处理 | 规则 boost + source 去重 | 融合交给库;后处理做裁剪/等级/打包 |
|
||||
| Agent 投影 | `RagResultProjector` | 基本不动 |
|
||||
|
||||
当前关键文件:
|
||||
|
||||
```text
|
||||
写入:
|
||||
VectorIndexService
|
||||
DocumentChunkService
|
||||
VectorEmbeddingService
|
||||
MilvusClientFactory # schema/index 创建(旧)
|
||||
|
||||
读取:
|
||||
VectorSearchService # 门面,含 sdk/spring 路由
|
||||
KnowledgeDocumentRetriever
|
||||
KnowledgeEvidencePostProcessor
|
||||
LookupKnowledgeTool
|
||||
|
||||
配置:
|
||||
retrieval.vector-store.mode
|
||||
retrieval.kb-scope
|
||||
rag.top-k
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构(不绑旧 SDK)
|
||||
|
||||
```text
|
||||
┌─────────────────────────┐
|
||||
upload/init │ KnowledgeWritePort │
|
||||
chunk + embed -> │ - upsertChunks() │
|
||||
│ - deleteByDocId() │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
Vector DB
|
||||
dense + sparse/BM25
|
||||
metadata filters
|
||||
▲
|
||||
┌───────────┴─────────────┐
|
||||
lookup_knowledge │ KnowledgeSearchPort │
|
||||
query + options-> │ - search() │
|
||||
│ - mode: DENSE/HYBRID │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
KnowledgeDocumentRetriever
|
||||
│
|
||||
▼
|
||||
PostProcess(dedup/chunk cap/threshold/pack)
|
||||
│
|
||||
▼
|
||||
LookupResult / Projector
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph write_port["写入边界"]
|
||||
W[KnowledgeWritePort<br/>upsert / deleteByDocId]
|
||||
end
|
||||
|
||||
subgraph search_port["检索边界"]
|
||||
S[KnowledgeSearchPort<br/>mode DENSE / HYBRID]
|
||||
end
|
||||
|
||||
W --> VDB[(Vector DB<br/>dense + BM25 sparse<br/>metadata filter)]
|
||||
S --> VDB
|
||||
|
||||
UP[upload/init] --> W
|
||||
LK[lookup_knowledge] --> S
|
||||
S --> RET[DocumentRetriever]
|
||||
RET --> POST[PostProcess<br/>dedup / cap / threshold / pack]
|
||||
POST --> PROJ[LookupResult / Projector]
|
||||
PROJ --> AG[Agent]
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- **Port** 是应用边界,实现可换成 Spring AI、Milvus 新客户端、或其他封装。
|
||||
- 旧 `MilvusServiceClient` 检索实现可以暂时留着,但 **新功能不要往里堆**。
|
||||
- Hybrid 是 `KnowledgeSearchPort` 的一种 mode,不是再开一套平行 tool。
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema 改造清单
|
||||
|
||||
### 4.1 建议逻辑模型
|
||||
|
||||
```text
|
||||
id string PK # chunk 级唯一 id
|
||||
doc_id string # 文档 id(从 metadata 提升为一等字段更稳)
|
||||
chunk_index int
|
||||
content text/varchar # 原始 chunk 正文(给 BM25 / 返回)
|
||||
title string nullable
|
||||
breadcrumb string nullable
|
||||
category string nullable
|
||||
kb_scope string nullable
|
||||
dense_vector float vector # embedding(title/path/content)
|
||||
sparse_vector sparse vector # BM25 或 sparse embedding
|
||||
metadata json # 兼容扩展字段
|
||||
```
|
||||
|
||||
### 4.2 和现状差异
|
||||
|
||||
| 字段 | 现状 | 建议 |
|
||||
|---|---|---|
|
||||
| `vector` | 有 | 可改名 `dense_vector`,或保留别名兼容 |
|
||||
| `content` | 有,仅存储/返回 | 同时作为 BM25 输入文本 |
|
||||
| `sparse_vector` | 无 | **新增,hybrid 必需** |
|
||||
| `docId/chunkIndex` | 塞在 JSON metadata | 建议提升为可过滤/可排序字段 |
|
||||
| `category/kb_scope` | metadata JSON | 建议提升,filter 更稳 |
|
||||
|
||||
### 4.3 索引
|
||||
|
||||
```text
|
||||
dense_vector -> 向量索引(COSINE/IP/L2,与 embedding 一致)
|
||||
sparse_vector -> 稀疏倒排 / BM25 索引
|
||||
category/kb_scope/doc_id -> 标量过滤索引(如需要)
|
||||
```
|
||||
|
||||
### 4.4 迁移策略
|
||||
|
||||
不要幻想“只改 search 方法”:
|
||||
|
||||
1. **新建 collection 或新版本 collection**(推荐)
|
||||
2. 全量重灌知识库(dense + sparse)
|
||||
3. 双写一段时间(可选)
|
||||
4. 切换读路径到 hybrid
|
||||
5. 下线旧 collection / 旧 SDK 读路径
|
||||
|
||||
就地改老 collection 风险高:已有数据无 sparse,历史 metadata 形态也不统一。
|
||||
|
||||
---
|
||||
|
||||
## 5. 写入路径改造清单
|
||||
|
||||
### 5.1 需要动的职责
|
||||
|
||||
| 类/模块 | 现在 | 改造 |
|
||||
|---|---|---|
|
||||
| `DocumentChunkService` | 产出 chunk 正文/title/breadcrumb | 基本可复用 |
|
||||
| `VectorEmbeddingService` | 只做 dense embed | 保留;sparse/BM25 另算或交给库 |
|
||||
| `VectorIndexService` | 组装 metadata + insert dense | 升级为 write port 实现:dense+sparse 一并 upsert |
|
||||
| 删除逻辑 | 按 `metadata.docId` / `_source` 删 | 统一按 `doc_id` 删,避免路径不一致 |
|
||||
|
||||
### 5.2 写入时每条 chunk 必须具备
|
||||
|
||||
```text
|
||||
- dense_vector: embed(buildEmbeddingText(chunk))
|
||||
- sparse 输入: 建议用“可检索文本”
|
||||
title + breadcrumb + content
|
||||
而不是只丢 raw content
|
||||
- doc_id / chunk_index / category / kb_scope
|
||||
- 稳定 chunk id(doc_id + chunk_index 派生)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CHUNK[DocumentChunk] --> EMB[dense embed]
|
||||
CHUNK --> ST[search_text<br/>title+path+content]
|
||||
CHUNK --> META[docId/chunkIndex<br/>category/kb_scope]
|
||||
EMB --> ROW[upsert row]
|
||||
ST --> ROW
|
||||
META --> ROW
|
||||
ROW --> FN[BM25 Function<br/>search_text → sparse]
|
||||
ROW --> COL[(collection)]
|
||||
FN --> COL
|
||||
```
|
||||
|
||||
### 5.3 注意
|
||||
|
||||
- embedding 文本可以继续拼 `Title/Path/Content`
|
||||
- **返回给 Agent 的 content 仍应是原文 chunk**,不要返回 embedding 拼接串
|
||||
- BM25 文本建议包含 title/breadcrumb,否则专有名词在标题里时字面路会弱
|
||||
|
||||
---
|
||||
|
||||
## 6. 检索路径改造清单
|
||||
|
||||
### 6.1 新检索端口(建议)
|
||||
|
||||
不要继续扩:
|
||||
|
||||
```text
|
||||
searchSimilarDocuments(query, topK, category)
|
||||
```
|
||||
|
||||
建议收敛成:
|
||||
|
||||
```text
|
||||
SearchRequest {
|
||||
query: string
|
||||
retrieveK: int # 例如 20
|
||||
returnN: int # 例如 5,可在后处理裁
|
||||
mode: DENSE | HYBRID
|
||||
categoryFilter?: string
|
||||
kbScope?: string
|
||||
ranker: RRF | WEIGHTED
|
||||
rrfK: int # 默认 60
|
||||
weights?: {dense, sparse}
|
||||
}
|
||||
|
||||
SearchHit {
|
||||
id, docId, chunkIndex
|
||||
content, title, breadcrumb
|
||||
source, category
|
||||
scores: {
|
||||
fused?, denseRank?, sparseRank?, raw?...
|
||||
}
|
||||
metadata
|
||||
}
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
REQ[SearchRequest<br/>query · retrieveK · mode<br/>filter · rrfK] --> PORT[KnowledgeSearchPort]
|
||||
PORT -->|DENSE| D[dense ANN only]
|
||||
PORT -->|HYBRID| H[dense + BM25 + RRF]
|
||||
D --> HIT[SearchHit 列表<br/>id/docId/chunk · content · ranks]
|
||||
H --> HIT
|
||||
HIT --> APP[后处理 / 投影]
|
||||
```
|
||||
|
||||
### 6.2 `VectorSearchService` 怎么演进
|
||||
|
||||
短期:
|
||||
|
||||
```text
|
||||
保留门面类名也可
|
||||
但内部:
|
||||
- 不再把 sdk 当长期分支
|
||||
- 增加 hybridSearch(...) 能力
|
||||
- mode 配置改为:
|
||||
dense | hybrid
|
||||
(spring 仅作 dense 兼容实现)
|
||||
```
|
||||
|
||||
中期:
|
||||
|
||||
```text
|
||||
VectorSearchService 实现 KnowledgeSearchPort
|
||||
旧 sdk 分支删除或仅 test/fallback 开关默认关
|
||||
```
|
||||
|
||||
### 6.3 Hybrid 查询语义
|
||||
|
||||
```text
|
||||
路 A: dense(query_embedding) limit=retrieveK
|
||||
路 B: bm25/sparse(query_text) limit=retrieveK
|
||||
可选过滤: category / kb_scope
|
||||
融合: RRFRanker(k=60) 或 WeightedRanker
|
||||
输出: top retrieveK/returnN
|
||||
```
|
||||
|
||||
对应我们之前的公式:
|
||||
|
||||
```text
|
||||
RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||
```
|
||||
|
||||
- 用 RRF:先不调权重
|
||||
- 用 Weighted:调的是 **dense/sparse 路权**,不是 keyword contains 加分
|
||||
|
||||
### 6.4 filtered + unfiltered 还要不要?
|
||||
|
||||
还要,但定位变了:
|
||||
|
||||
| 能力 | 放哪 |
|
||||
|---|---|
|
||||
| dense + bm25 融合 | **库内 hybrid** |
|
||||
| category filter 开/关 | 应用策略层,可变成两次 hybrid 或 filter 参数 |
|
||||
| chunk 去重 / 每文档上限 | 应用后处理 |
|
||||
| found / relevanceLevel | 应用后处理 |
|
||||
|
||||
推荐策略:
|
||||
|
||||
```text
|
||||
if 唯一 domain:
|
||||
hybrid(query, filter=category) # 主路
|
||||
若低质量:
|
||||
hybrid(query, filter=null) # 兜底
|
||||
或并行两条 hybrid 再做一次轻量合并
|
||||
else:
|
||||
hybrid(query, filter=null)
|
||||
```
|
||||
|
||||
注意:这里的“两条”是 **filter 策略双路径**,不是再手写一套 dense/bm25 融合。
|
||||
|
||||
---
|
||||
|
||||
## 7. 和现有 pipeline 的衔接(按类)
|
||||
|
||||
### 7.1 基本不动
|
||||
|
||||
| 类 | 原因 |
|
||||
|---|---|
|
||||
| `LookupKnowledgeTool` | 继续编排 transform → retrieve → post → pack |
|
||||
| `KnowledgeQueryTransformer` | 仍产 categoryFilter / hints |
|
||||
| `KnowledgeContextPacker` | 仍做字符预算 |
|
||||
| `LookupResultAssembler` | 仍组装内部结果 |
|
||||
| `RagToolAdapter` / `RagResultProjector` | Agent 契约保持稳定 |
|
||||
|
||||
### 7.2 要改
|
||||
|
||||
| 类 | 改什么 |
|
||||
|---|---|
|
||||
| `KnowledgeDocumentRetriever` | 调新 search port;透传 retrieveK/mode;把 docId/chunkIndex 提成候选一等字段 |
|
||||
| `KnowledgeEvidencePostProcessor` | 弱化“跨路融合”职责;保留 dedup、chunk cap、阈值、轻精排 |
|
||||
| `VectorSearchService` | 成为 hybrid 入口,去掉对旧 sdk 的依赖增长 |
|
||||
| `VectorIndexService` | 写入 dense+sparse,统一 doc_id 删除 |
|
||||
| schema 工厂/初始化 | 新 collection 定义与索引 |
|
||||
|
||||
### 7.3 后处理职责重新划分
|
||||
|
||||
**交给 Milvus hybrid:**
|
||||
|
||||
- dense/sparse 多路召回
|
||||
- RRF / weighted 融合
|
||||
- 基础 topK
|
||||
|
||||
**留给应用层:**
|
||||
|
||||
```text
|
||||
1. chunk 级去重(docId#chunkIndex)
|
||||
2. maxChunksPerDocument
|
||||
3. return-n 裁剪
|
||||
4. relevanceLevel / isLowQuality
|
||||
5. 可选轻规则精排(title/breadcrumb 命中)
|
||||
6. context pack 与 Agent 投影
|
||||
```
|
||||
|
||||
**应降级或删除的:**
|
||||
|
||||
```text
|
||||
把 keyword contains 大额加分当主排序器
|
||||
在应用层重复实现一套 dense+bm25 分数硬加
|
||||
```
|
||||
|
||||
L0 仍只做:
|
||||
|
||||
```text
|
||||
- 是否启用 category filter
|
||||
- 轻量精排特征
|
||||
- trace 解释
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置建议(示意)
|
||||
|
||||
```properties
|
||||
# 检索模式:dense | hybrid
|
||||
retrieval.search.mode=hybrid
|
||||
|
||||
# 旧 sdk 读路径默认关闭(后续删除)
|
||||
retrieval.legacy-sdk.enabled=false
|
||||
|
||||
# 召回/返回分离
|
||||
rag.retrieve-k=20
|
||||
rag.return-n=5
|
||||
rag.max-chunks-per-document=2
|
||||
|
||||
# 融合
|
||||
retrieval.hybrid.ranker=rrf
|
||||
retrieval.hybrid.rrf-k=60
|
||||
# 若用 weighted:
|
||||
# retrieval.hybrid.ranker=weighted
|
||||
# retrieval.hybrid.weight.dense=1.0
|
||||
# retrieval.hybrid.weight.sparse=0.8
|
||||
|
||||
# filter 策略
|
||||
retrieval.filter.retry-unfiltered-on-low-quality=true
|
||||
retrieval.normalization.reference-threshold=0.5
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `rag.top-k` 应逐步废弃,避免“召回/返回/展示”一个参数打天下
|
||||
- 阈值字段若 hybrid 后分数语义变化,需要重新校准,不能照搬旧 L2 经验值
|
||||
|
||||
---
|
||||
|
||||
## 9. 分数与阈值:hybrid 后要重标定
|
||||
|
||||
当前后处理默认假设:
|
||||
|
||||
```text
|
||||
score ≈ 兼容 L2 距离
|
||||
normalizeL2 后得到 0~1
|
||||
```
|
||||
|
||||
hybrid 后常见变化:
|
||||
|
||||
| 来源 | 语义 |
|
||||
|---|---|
|
||||
| dense raw | L2 / cosine |
|
||||
| sparse/BM25 raw | 另一套 |
|
||||
| fused RRF | 名次融合分,不是相似度概率 |
|
||||
|
||||
因此:
|
||||
|
||||
1. `SearchHit` 要区分 `fusedScore` / `denseScore` / `sparseScore`
|
||||
2. `isLowQuality` 不要直接拿 RRF 分当旧 L2 用
|
||||
3. 过渡期可:
|
||||
- 用“是否有命中 + 规则完整性”判断 found
|
||||
- 或只对 dense 分做阈值,RRF 只负责排序
|
||||
4. 重新用 15~30 条回归 query 标定
|
||||
|
||||
---
|
||||
|
||||
## 10. 分阶段落地(推荐)
|
||||
|
||||
> 与 **§1.1 交付 1 / 交付 2** 对齐。Phase 编号用于执行拆解;对外沟通优先用两个交付里程碑。
|
||||
|
||||
### 交付 1 对应 Phase
|
||||
|
||||
#### Phase 0:应用层前提(交付 1 核心)
|
||||
|
||||
```text
|
||||
[ ] chunk 级去重(不要 source 级吞 chunk)
|
||||
[ ] 候选暴露 docId / chunkIndex / evidenceKey
|
||||
[ ] retrieve-k / return-n 分离
|
||||
[ ] maxChunksPerDocument
|
||||
[ ] Projector 按证据身份去重(不再 source 唯一)
|
||||
[ ] 明确 legacy-sdk 读路径仅兼容、默认关或冻结
|
||||
[ ] 单测:同文档多 chunk / 真重复 / 单文档上限
|
||||
```
|
||||
|
||||
#### Phase 1:检索端口收敛(交付 1 建议同批或紧随)
|
||||
|
||||
```text
|
||||
[ ] 定义 KnowledgeSearchPort / SearchRequest / SearchHit
|
||||
[ ] VectorSearchService 适配该端口(先 dense-only 也可)
|
||||
[ ] KnowledgeDocumentRetriever 只依赖端口
|
||||
[ ] 单测用 fake search port,不再绑 SDK 细节
|
||||
```
|
||||
|
||||
**交付 1 出口:** 不上 hybrid 也可合并;hybrid 所需 hit 身份与裁剪契约已稳定。
|
||||
|
||||
### 交付 2 对应 Phase
|
||||
|
||||
#### Phase 2:写入与 schema 支持 sparse/BM25
|
||||
|
||||
```text
|
||||
[ ] 新 collection schema
|
||||
[ ] 写入 dense + sparse/BM25 文本
|
||||
[ ] doc_id 级删除与重灌
|
||||
[ ] 知识库全量重建脚本/任务
|
||||
```
|
||||
|
||||
#### Phase 3:打开 hybrid 读路径
|
||||
|
||||
```text
|
||||
[ ] search.mode=hybrid
|
||||
[ ] ranker=rrf
|
||||
[ ] category filter 策略接入
|
||||
[ ] low-quality 时 unfiltered 兜底
|
||||
[ ] trace 记录 dense/sparse/fused 信息(内部)
|
||||
[ ] 确认 hybrid 多命中仍走 chunk 级去重,不被 source 误伤
|
||||
```
|
||||
|
||||
#### Phase 4:瘦身后处理 + 下线旧路径
|
||||
|
||||
```text
|
||||
[ ] 规则 boost 降为轻精排或可关
|
||||
[ ] 删除/隔离旧 SDK search 实现
|
||||
[ ] 校准 found/relevance 阈值
|
||||
[ ] 回归评测与延迟对比
|
||||
```
|
||||
|
||||
**交付 2 出口:** dense|hybrid 可切换;RRF 默认可用;旧 SDK 检索不再被新逻辑依赖。
|
||||
|
||||
---
|
||||
|
||||
## 11. 类级改造对照表
|
||||
|
||||
| 类 | 优先级 | 动作 | 是否依赖旧 SDK |
|
||||
|---|---|---|---|
|
||||
| `KnowledgeDocumentRetriever` | P0 | 接新端口,透传 hybrid 选项,补 chunk 身份 | 否 |
|
||||
| `KnowledgeEvidencePostProcessor` | P0 | 去重改 chunk 级;融合职责外移 | 否 |
|
||||
| `LookupKnowledgeTool` | P1 | 使用 retrieve-k/return-n;保留 filter 降级策略 | 否 |
|
||||
| `VectorSearchService` | P0 | 增加 hybrid;冻结/移除 sdk 增长 | 实现可无 SDK |
|
||||
| `VectorIndexService` | P0 | dense+sparse 写入,doc_id 删除 | 实现可无 SDK |
|
||||
| `MilvusClientFactory` | P2 | 仅迁移期维护;新 schema 建议新模块 | 旧 |
|
||||
| `VectorEmbeddingService` | P1 | 继续 dense;不塞 hybrid 逻辑 | 否 |
|
||||
| `RagResultProjector` | P2 | 若 document_id 变 chunk 级,同步语义 | 否 |
|
||||
| Spring AI `VectorStore` | P2 | 可继续承载 dense;hybrid 需单独能力层 | 否 |
|
||||
|
||||
---
|
||||
|
||||
## 12. 测试清单
|
||||
|
||||
### 单元
|
||||
|
||||
```text
|
||||
[ ] RRF 融合结果顺序(可用 fixture,不连库)
|
||||
[ ] chunk 去重:同 doc 不同 chunk 都保留
|
||||
[ ] 同 doc 超过 maxChunksPerDocument 被裁
|
||||
[ ] category filter 低质时走 unfiltered
|
||||
[ ] SearchHit 字段映射:docId/chunkIndex/content
|
||||
```
|
||||
|
||||
### 集成 / 回归
|
||||
|
||||
```text
|
||||
[ ] 专有名词/错误码 query:hybrid 应优于 pure dense
|
||||
[ ] 换说法语义 query:hybrid 不低于 pure dense
|
||||
[ ] 错误 domain filter:unfiltered 兜底仍能找回
|
||||
[ ] 长文档多 chunk:返回不少于 2 个相关片段(若存在)
|
||||
[ ] 延迟:hybrid P95 可接受
|
||||
[ ] 重灌后旧 doc 删除干净,无幽灵 chunk
|
||||
```
|
||||
|
||||
### 兼容
|
||||
|
||||
```text
|
||||
[ ] mode=dense 仍可用(回滚开关)
|
||||
[ ] Agent 契约字段不破(evidence/document_id/excerpt)
|
||||
[ ] tool_invocation / trace 仍有 selectedAttempt 与基本检索信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 明确不做的事
|
||||
|
||||
1. **继续在旧 SDK `search` 上叠 hybrid 细节当长期方案**
|
||||
2. **应用层把 dense raw 分和 BM25 raw 分直接相加**
|
||||
3. **用 L0 contains 大额加分替代库内 RRF**
|
||||
4. **只改查询、不重灌 sparse 数据**
|
||||
5. **hybrid 后仍拿旧 L2 阈值硬套 fused score**
|
||||
6. **让 Agent 直接依赖内部 fused/raw score 字段**(除非契约明确升级)
|
||||
|
||||
---
|
||||
|
||||
## 14. 和前序讨论的对齐
|
||||
|
||||
| 讨论结论 | 在本清单中的落点 |
|
||||
|---|---|
|
||||
| K=3 不必先上复杂 rerank | `retrieve-k=20, return-n=5` |
|
||||
| filtered + unfiltered 有价值 | hybrid 之上的 filter 策略双路径 |
|
||||
| BM25 是跨维度召回 | schema sparse/BM25 + hybrid 路 |
|
||||
| 跨路优先 RRF | 库内 `RRFRanker` |
|
||||
| `w_i` 是路权 | `WeightedRanker` / 配置 weight.dense/sparse |
|
||||
| L0 只做导航 | 仅影响 filter 与轻精排,不负责主融合 |
|
||||
| 旧 SDK 后续废弃 | 新开发只走 search/write port,不绑 SDK |
|
||||
|
||||
---
|
||||
|
||||
## 15. 最小可交付定义(MVP)
|
||||
|
||||
MVP 拆成两个可独立验收的里程碑(与 §1.1 一致)。
|
||||
|
||||
### MVP-1:分块去重与身份契约(交付 1)
|
||||
|
||||
```text
|
||||
1. 候选/证据具备 docId、chunkIndex、evidenceKey
|
||||
2. 后处理与投影均按 chunk 身份去重
|
||||
3. maxChunksPerDocument 生效
|
||||
4. retrieve-k / return-n 分离且可测
|
||||
5. 同文档多相关 chunk 在未超预算时可同时到达 Agent
|
||||
6. 不新增对旧 SDK 的依赖
|
||||
```
|
||||
|
||||
### MVP-2:Hybrid 接入(交付 2)
|
||||
|
||||
```text
|
||||
1. 新 collection 可写入 dense + BM25/sparse
|
||||
2. 知识库可全量重建
|
||||
3. lookup_knowledge 可通过配置切换 dense/hybrid
|
||||
4. hybrid 默认 RRF 融合
|
||||
5. 应用层 chunk 去重与 return-n 在 hybrid 下仍正确
|
||||
6. 旧 SDK 检索不再被新逻辑依赖
|
||||
7. 至少一套回归 query 证明:
|
||||
- 术语类 query 不回退
|
||||
- 语义类 query 不回退
|
||||
- filter 误杀有兜底
|
||||
```
|
||||
|
||||
只有 MVP-1 完成,才建议开始 MVP-2 的数据迁移与开关切换。
|
||||
|
||||
---
|
||||
|
||||
## 16. 建议的下一步实现顺序(动手时)
|
||||
|
||||
实现讨论与排期默认按此顺序:
|
||||
|
||||
```text
|
||||
1. 交付 1 / MVP-1
|
||||
- chunk 身份
|
||||
- chunk 去重 + maxChunksPerDocument
|
||||
- retrieve-k / return-n
|
||||
- Projector 对齐
|
||||
- SearchPort 雏形(建议)
|
||||
|
||||
2. 交付 2 / MVP-2
|
||||
- schema + 重灌
|
||||
- hybrid RRF 读路径
|
||||
- filter 兜底
|
||||
- 阈值校准
|
||||
- 下线旧 SDK 读路径
|
||||
```
|
||||
|
||||
**不要跳过交付 1 直接做 hybrid。**
|
||||
交付 1 不依赖旧 SDK,不阻塞后续 hybrid,且单独合并就有质量收益。
|
||||
|
||||
---
|
||||
|
||||
## 17. 后续实现讨论检查清单
|
||||
|
||||
开会或开 PR 前,用下面问题对齐范围:
|
||||
|
||||
```text
|
||||
[ ] 本次是交付 1、交付 2,还是仅其中子项?
|
||||
[ ] 是否改动了证据身份字段(docId/chunkIndex/evidenceKey)?
|
||||
[ ] 去重 key 是否仍存在 source 级路径?
|
||||
[ ] retrieve-k 与 return-n 是否仍混用 top-k?
|
||||
[ ] 是否把 schema 重灌/hybrid 开关误塞进交付 1?
|
||||
[ ] 是否有新增旧 SDK 依赖?
|
||||
[ ] 单测是否覆盖“同文档多 chunk”?
|
||||
[ ] 若已 hybrid:fused score 是否被误当成旧 L2 阈值?
|
||||
```
|
||||
@@ -0,0 +1,396 @@
|
||||
# Agent 如何读 `relevance_level`:它是什么、不是什么
|
||||
|
||||
**日期**:2026-07-28
|
||||
**范围**:`lookup_knowledge` 投影给 Agent 的粗粒度相关度标签
|
||||
**读者**:要在 Diagnosis Agent / 工具契约里正确使用知识库结果的工程与提示词同学
|
||||
**关联**:
|
||||
|
||||
- ACI 契约:`RagToolResult.relevance_level` / `RagRelevanceLevel`
|
||||
- 计算:`KnowledgeEvidencePostProcessor` → `qualityScore` 阈值
|
||||
- 质量统一:`RAG-Hybrid质量分与后处理.md`
|
||||
- 多路与 RRF:`RAG排序-多路召回与RRF.md`
|
||||
- **运行时 Trace / 审计**:`mvp/architecture/RAG检索可观测性与审计.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话定义
|
||||
|
||||
**`relevance_level` 是对「这一次 `lookup_knowledge` 调用整体有多相关」的粗档标签,不是某一条 evidence 的分数,也不是 0~1 的相似度。**
|
||||
|
||||
Agent 真正写诊断、做引用时,仍应以 `evidence[]` 里的 excerpt 为准;`relevance_level` 只帮助判断:**这批评据大概有多硬、还要不要再查。**
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph tool["lookup_knowledge 结果"]
|
||||
ES[evidence_status]
|
||||
EV[evidence excerpt]
|
||||
RL[relevance_level]
|
||||
TR[truncated]
|
||||
end
|
||||
|
||||
ES --> DEC{Agent 决策}
|
||||
EV --> DEC
|
||||
RL --> DEC
|
||||
TR --> DEC
|
||||
|
||||
DEC --> A1[写结论 / 引用]
|
||||
DEC --> A2[再查 / 换工具]
|
||||
DEC --> A3[证据不足降级]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 它出现在哪里
|
||||
|
||||
成功(或有结果)的知识库工具投影里,典型形状:
|
||||
|
||||
```json
|
||||
{
|
||||
"evidence_status": "EVIDENCE_FOUND",
|
||||
"tool_call_id": "call-…",
|
||||
"query": "用户/Agent 的检索句",
|
||||
"evidence": [
|
||||
{
|
||||
"document_id": "doc#chunk-0",
|
||||
"source": "…",
|
||||
"title": "…",
|
||||
"breadcrumb": "…",
|
||||
"excerpt": "…"
|
||||
}
|
||||
],
|
||||
"returned_count": 3,
|
||||
"relevance_level": "PRECISE",
|
||||
"truncated": false
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- JSON 字段名是 **`relevance_level`**(snake_case)
|
||||
- Java 枚举:`RagRelevanceLevel`(`PRECISE` / `HIGHLY_RELEVANT` / `REFERENCE`)
|
||||
- 无可用证据或质量不够时,字段常为 **null / 省略**(`NON_NULL`)
|
||||
- Agent **看不到** raw L2、RRF 分、`retrievalTrace`、`rerankTrace`(ACI 有意裁掉)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph internal["内部 LookupResult(审计/调试)"]
|
||||
QS[qualityScore / topSimilarity]
|
||||
RT[retrievalTrace / rerankTrace]
|
||||
CP[contextPack 全文]
|
||||
SC[raw score / scoreLabel]
|
||||
end
|
||||
|
||||
subgraph project["RagResultProjector"]
|
||||
P[裁剪与规范化]
|
||||
end
|
||||
|
||||
subgraph agent["Agent 可见 RagToolResult"]
|
||||
A1[evidence_status]
|
||||
A2[tool_call_id / query]
|
||||
A3[evidence excerpt 列表]
|
||||
A4[relevance_level 可选]
|
||||
A5[returned_count / truncated]
|
||||
end
|
||||
|
||||
internal --> P --> agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 枚举值怎么理解
|
||||
|
||||
| 值 | 产品语义 | Agent 侧更合理的用法 |
|
||||
|----|----------|----------------------|
|
||||
| **PRECISE** | 整体很贴:当前 top 证据质量高,继续同题检索不太可能更准 | 优先依据 `evidence[]` 组织结论;避免无意义的重复 `lookup_knowledge` |
|
||||
| **HIGHLY_RELEVANT** | 高度相关(枚举保留) | 与 PRECISE 类似,略保守表述即可 |
|
||||
| **REFERENCE** | 可作参考,但不到「已精准命中」 | 可引用,但结论留余地;缺维度时换 query 再查或叠日志/指标 |
|
||||
| **null / 不出现** | 没有可报的粗相关档(无证据或 top 质量偏低) | **不要**当成知识库已证实;按证据不足处理 |
|
||||
|
||||
### 和 `evidence_status` 的分工
|
||||
|
||||
| 字段 | 回答的问题 |
|
||||
|------|------------|
|
||||
| `evidence_status` | 这次有没有合法、可引用的证据(如 `EVIDENCE_FOUND` / `NO_EVIDENCE`) |
|
||||
| `relevance_level` | **有证据时**,整体有多贴(粗档) |
|
||||
| `evidence[]` | 具体可以引用哪些片段 |
|
||||
|
||||
没有证据时,不应指望靠 `relevance_level`「升级」出结论;契约上也不会用 level 把空结果扮成有证据。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ES{evidence_status}
|
||||
ES -->|NO_EVIDENCE| N1[不要当知识库已证实]
|
||||
ES -->|EVIDENCE_FOUND| RL{relevance_level}
|
||||
|
||||
RL -->|PRECISE| U1[优先引用 excerpt · 少重复检索]
|
||||
RL -->|REFERENCE| U2[可引用 · 结论留余地]
|
||||
RL -->|null / 缺省| U3[有块但质量偏低 · 慎用强结论]
|
||||
RL -->|HIGHLY_RELEVANT| U4[与 PRECISE 类似 · 略保守]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 它是怎么算出来的(实现口径)
|
||||
|
||||
### 4.1 只看「本轮第一名」的 qualityScore
|
||||
|
||||
后处理在完成排序、去重、截断之后:
|
||||
|
||||
```text
|
||||
取 originalRank 最优(排序后第一条)的 qualityScore
|
||||
≥ highly-relevant-threshold (默认 0.75) → PRECISE
|
||||
≥ reference-threshold (默认 0.5) → REFERENCE
|
||||
否则 / 无可用证据 → null
|
||||
```
|
||||
|
||||
配置(`application.yml`):
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
normalization:
|
||||
max-l2-distance: 2.0
|
||||
highly-relevant-threshold: 0.75
|
||||
reference-threshold: 0.5
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- level 描述的是 **整次调用的 top 质量**,不是每条 evidence 各打一档
|
||||
- 列表里第 2、第 3 条即使偏弱,只要 top1 够高,整次仍可能是 `PRECISE`
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RET[检索有序候选] --> POST[后处理:保 originalRank · 去重 · 截断]
|
||||
POST --> TOP[取排序后第一条 qualityScore]
|
||||
TOP --> T1{≥ 0.75?}
|
||||
T1 -->|是| PRECISE[PRECISE]
|
||||
T1 -->|否| T2{≥ 0.5?}
|
||||
T2 -->|是| REF[REFERENCE]
|
||||
T2 -->|否| NULL[null / 不报档]
|
||||
```
|
||||
|
||||
### 4.2 qualityScore 从哪来(和检索 mode 绑定)
|
||||
|
||||
统一经 `RetrievalScoreNormalizer.toQualityScore`:
|
||||
|
||||
| `retrieval.search.mode` | top qualityScore 含义 |
|
||||
|-------------------------|------------------------|
|
||||
| **dense** | top1 的 L2 归一化:约 `1 - L2 / maxL2Distance` |
|
||||
| **hybrid** | **优先**同 id 的 `denseDistance`(绝对 L2 质量,供闸门/level);无 dense 邻域时 **rank 回退** |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph dense_mode["mode=dense"]
|
||||
L2[score = L2] --> QD[quality = 1 - L2/max]
|
||||
end
|
||||
|
||||
subgraph hybrid_mode["mode=hybrid"]
|
||||
RRF[RRF 序 = originalRank] --> SORT[列表顺序]
|
||||
DD[denseDistance 可选] --> QH{有 dense?}
|
||||
QH -->|是| QL[quality = L2 归一化]
|
||||
QH -->|否| QR[quality = rank 映射]
|
||||
end
|
||||
|
||||
QD --> LV[relevance_level]
|
||||
QL --> LV
|
||||
QR --> LV
|
||||
```
|
||||
|
||||
读 level 时注意:
|
||||
|
||||
> **dense 下的 PRECISE ≈「向量足够近」**
|
||||
> **hybrid 下的 PRECISE ≈「top1 的绝对/回退 quality 跨过了 0.75」**;排序仍跟 RRF,不是「又变回只信 L2 排序」。
|
||||
|
||||
不要把 hybrid 的 level 读成与 dense **完全同一把尺子**,但也不要再假设「rank1 永远 PRECISE」(在附带 denseDistance 后,远邻 decoy 可以很低分并触发 filter fallback)。
|
||||
### 4.3 关于 HIGHLY_RELEVANT
|
||||
|
||||
枚举和旧文档里仍有三档。历史上大致是:
|
||||
|
||||
```text
|
||||
高分 + L0 hint 支撑 → PRECISE
|
||||
高分但无 hint → HIGHLY_RELEVANT
|
||||
中等分 → REFERENCE
|
||||
```
|
||||
|
||||
质量分统一之后,当前实现是:**≥ 0.75 直接 PRECISE**,不再要求 L0 contains 才能精准。
|
||||
因此运行时 **很少再单独产出 HIGHLY_RELEVANT**;读旧 trace / 旧快照时仍可能见到。
|
||||
|
||||
---
|
||||
|
||||
## 5. Agent 应该怎么读(建议协议)
|
||||
|
||||
### 5.1 推荐读法
|
||||
|
||||
```text
|
||||
1. 先看 evidence_status
|
||||
2. 再读 evidence[] 的 excerpt(唯一可引用正文)
|
||||
3. 用 relevance_level 调节「敢多敢少」与「要不要再查」
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
START[收到 RagToolResult] --> S1{evidence_status}
|
||||
S1 -->|无证据| FAIL[不编造 · 换工具或安全降级]
|
||||
S1 -->|有证据| S2[精读 evidence excerpt]
|
||||
S2 --> S3{relevance_level}
|
||||
S3 -->|PRECISE| C1[结论可较硬 · 少重复同 query 检索]
|
||||
S3 -->|REFERENCE| C2[结论留余地 · 可换问法或叠日志指标]
|
||||
S3 -->|缺省| C3[慎用强结论 · 优先补查]
|
||||
C1 --> CITE[引用必须落在 excerpt]
|
||||
C2 --> CITE
|
||||
C3 --> CITE
|
||||
```
|
||||
|
||||
| 组合 | 建议行为 |
|
||||
|------|----------|
|
||||
| FOUND + PRECISE | 以 excerpt 为主写结论;少重复同 query 检索 |
|
||||
| FOUND + REFERENCE | 可引用,表述保守;缺关键事实则改写 query 或换工具 |
|
||||
| FOUND 但 level 空 | 有块但质量闸门偏低:慎用强结论,优先补查 |
|
||||
| NO_EVIDENCE | 不编造知识库依据;走其他证据工具或安全降级 |
|
||||
|
||||
### 5.2 明确不要这样读
|
||||
|
||||
1. **不要当逐条相关度**
|
||||
没有 `evidence[i].relevance_level`;不能说「第 2 条是 REFERENCE」。
|
||||
|
||||
2. **不要当连续分数**
|
||||
没有 0.83;只有粗档。不要在推理里假装有精确分。
|
||||
|
||||
3. **不要在 hybrid 下当成绝对语义相似度**
|
||||
序数 quality 下 PRECISE 很常见,表示「本轮第一够格」,不等于「全局语义必近」。
|
||||
|
||||
4. **不要代替 excerpt 引用**
|
||||
level 不能当证据正文;Gatekeeper / EvidenceGuard 认的是可核对片段与引用约束。
|
||||
|
||||
5. **不要和 Harness 验真混为一谈**
|
||||
level 是检索侧粗标;工具生命周期、`evidence_status`、守卫校验是另一层。
|
||||
|
||||
6. **不要用它驱动跨 mode 对比**
|
||||
同一 query 切 dense/hybrid 时,比命中集合与排名;别只比「是不是都 PRECISE」。
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent 看不见、但会影响 level 的内部量
|
||||
|
||||
便于排查「为什么突然全是 PRECISE / 总是 null」:
|
||||
|
||||
| 内部量 | 作用 | Agent 是否可见 |
|
||||
|--------|------|----------------|
|
||||
| `qualityScore` / `topSimilarity` | 定 level、低质 retry | 否 |
|
||||
| `originalRank` | 排序权威;hybrid quality 输入 | 否 |
|
||||
| `score` + `scoreLabel` | dense=L2 / hybrid=融合侧 | 否 |
|
||||
| L0 domain/keyword | 现仅 hitReasons 解释,不改序、不抬 level | 否(reasons 也可能被投影裁掉) |
|
||||
| `completenessHint` | 内部完整度文案 | 通常否 |
|
||||
| category filter + unfiltered retry | 低质时可能换一批 evidence 再定 level | 过程 trace 否 |
|
||||
|
||||
投影原则(ACI):模型只要能理解与引用结果;**不给 raw score、阈值、trace。**
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph pipe["检索管道内部"]
|
||||
STORE[Milvus hybrid/dense]
|
||||
NORM[toQualityScore]
|
||||
POST[PostProcessor]
|
||||
STORE --> NORM --> POST
|
||||
end
|
||||
|
||||
POST --> LV[relevance_level]
|
||||
POST --> EB[evidenceBlocks]
|
||||
POST --> TR[traces · 通常不投影]
|
||||
|
||||
EB --> PROJ[RagResultProjector]
|
||||
LV --> PROJ
|
||||
PROJ --> AGENT[Agent Observation]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 和相邻概念的边界
|
||||
|
||||
```text
|
||||
evidence_status 有没有证据
|
||||
relevance_level 有的话有多贴(粗)
|
||||
evidence[].excerpt 贴在哪一段文字上(细、可引用)
|
||||
truncated 列表是否被预算截断(可能还有更好的没展示)
|
||||
information_gain 等 诊断环路里「这轮工具对任务有没有增益」(另一契约)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph fields["同一次工具结果里的分工"]
|
||||
ES[evidence_status<br/>有没有]
|
||||
RL[relevance_level<br/>有多贴·粗]
|
||||
EX[excerpt<br/>说什么·细]
|
||||
TC[truncated<br/>是否被截断]
|
||||
end
|
||||
|
||||
ES --> USE[Agent 使用]
|
||||
RL --> USE
|
||||
EX --> USE
|
||||
TC --> USE
|
||||
|
||||
USE --> NOTE[结论锚在 excerpt<br/>level 只调力度]
|
||||
```
|
||||
|
||||
`truncated=true` 时:即使 `PRECISE`,也只说明 **已返回子集里的 top 很强**,不保证库内没有更相关却被截掉的块。
|
||||
|
||||
---
|
||||
|
||||
## 8. 提示词 / 产品文案可用的短说明
|
||||
|
||||
可直接给模型或文档的精简版:
|
||||
|
||||
```text
|
||||
relevance_level 是本次知识库检索的整体相关度粗标:
|
||||
- PRECISE:当前证据整体很贴,优先引用 evidence 写结论,避免无意义重复检索
|
||||
- REFERENCE:仅供参考,结论需留余地,必要时换问法或改用其他工具
|
||||
- 缺省:不要把本次结果当作高置信知识库证实
|
||||
|
||||
务必以 evidence 中的 excerpt 为唯一引用依据;不要编造未出现的文档内容。
|
||||
在 hybrid 检索下,PRECISE 更多表示「本轮排序第一档」,不是精确相似度分数。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见误读示例
|
||||
|
||||
| 误读 | 更正 |
|
||||
|------|------|
|
||||
| 「PRECISE 所以三条 evidence 都精准」 | 只保证 top 质量跨线;其余条只是同批返回 |
|
||||
| 「没有 relevance_level 就是工具失败」 | 更可能是无证据或质量偏低;看 `evidence_status` |
|
||||
| 「hybrid 全是 PRECISE 说明召回完美」 | 可能只是 rank1→quality=1.0 的档位特性 |
|
||||
| 「REFERENCE 的 excerpt 不能引用」 | 可以引用,但结论强度应下调 |
|
||||
| 「level 高就可以跳过 excerpt」 | 不可;引用与验真仍看正文 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 结语
|
||||
|
||||
`relevance_level` 是检索链路送给 Agent 的 **粗粒度驾驶辅助**:
|
||||
|
||||
- 告诉模型这批评据大概硬不硬
|
||||
- **不**替代 excerpt,**不**暴露打分细节,**不**等于逐条标注
|
||||
|
||||
在 dense 模式下,它更接近「向量有多近」;
|
||||
在 hybrid 主路径下,它更接近「本轮融合第一名是否跨过质量门槛」。
|
||||
|
||||
读的时候记住三句即可:
|
||||
|
||||
```text
|
||||
1. 先 status,再 excerpt,最后才看 level
|
||||
2. level 管「敢多敢少」,excerpt 管「说了什么」
|
||||
3. hybrid 的 PRECISE ≠ 绝对语义满分
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:代码与规格锚点
|
||||
|
||||
| 项 | 位置 |
|
||||
|----|------|
|
||||
| Agent 结果契约 | `RagToolResult` / `RagRelevanceLevel` |
|
||||
| 投影 | `RagResultProjector` |
|
||||
| 等级计算 | `KnowledgeEvidencePostProcessor.computeRelevance` |
|
||||
| 质量分 | `RetrievalScoreNormalizer` |
|
||||
| 规格 | `openspec/specs/aci-evidence-tool-contracts`、`rag-retrieval-quality-score` |
|
||||
| 架构 | `mvp/architecture/RAG知识检索架构.md` §9 |
|
||||
@@ -0,0 +1,599 @@
|
||||
# 混合检索上线之后:为什么还要统一 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,却发现「召回变了、质量判断还拧着」的工程同学
|
||||
**关联实现**:
|
||||
|
||||
- `lookup_knowledge` 模块化链路
|
||||
- `MilvusHybridKnowledgeStore`(dense / hybrid)
|
||||
- `RetrievalScoreNormalizer` / `KnowledgeEvidencePostProcessor`
|
||||
- OpenSpec / devflow:`rag-quality-score-unify`
|
||||
- 前置讨论:`RAG排序-多路召回与RRF.md`
|
||||
- 架构:`mvp/architecture/RAG知识检索架构.md` §6
|
||||
|
||||
---
|
||||
|
||||
## 1. 引言:融合排好了序,不等于质量链路闭环了
|
||||
|
||||
上一篇文章(《诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF》)回答的是:
|
||||
|
||||
> 候选太少时别急着上精排;跨路不要硬加原始分;优先 RRF;L0 只做导航。
|
||||
|
||||
那一轮讨论之后,工程上陆续落地了:
|
||||
|
||||
1. **chunk 级证据身份与去重**(`docId#chunkIndex`,同文档多片段可并存)
|
||||
2. **真 hybrid**:Milvus 服务端 dense ANN + BM25 sparse + `hybridSearch` + `RRFRanker`
|
||||
3. **单一知识后端**(`MilvusClientV2`),去掉 sdk/spring 多路由主路径
|
||||
4. **mode 开关**:`hybrid` 线上主路径,`dense` 同库对照评测
|
||||
|
||||
主缺口从「假 hybrid / 粗去重」变成了另一件事:
|
||||
|
||||
```text
|
||||
库内:RRF 已经决定谁先谁后
|
||||
应用:后处理仍假装每条 score 都是 L2
|
||||
再用 L0 关键词 contains 加分改序
|
||||
```
|
||||
|
||||
于是出现一种很拧的现象:
|
||||
|
||||
- 检索层已经是 **混合检索的世界**
|
||||
- 质量层还活在 **单路 dense + 规则 boost 的世界**
|
||||
|
||||
本文记录的,就是这次对「拧」的拆解、拍板与落地口径:
|
||||
**统一 qualityScore,废止 hybrid 的 L2 伪装,去掉关键词 boost 改序。**
|
||||
|
||||
目标不是再推一套更复杂的模型,而是回答:
|
||||
|
||||
> hybrid 上线之后,排序权威和质量闸门到底听谁的?后处理还该不该拿关键词打分?
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph retrieval["检索层 · 已 hybrid"]
|
||||
Q[query] --> D[dense ANN]
|
||||
Q --> B[BM25 sparse]
|
||||
D --> RRF[hybridSearch + RRF]
|
||||
B --> RRF
|
||||
RRF --> ORD[RRF 序]
|
||||
end
|
||||
|
||||
subgraph post_old["后处理 · 仍 L2 世界"]
|
||||
ORD --> FAKE[伪装 / 回填 L2]
|
||||
FAKE --> BOOST[关键词 boost 改序]
|
||||
BOOST --> GATE[阈值 / level / retry]
|
||||
end
|
||||
|
||||
post_old --> PAIN[排序与闸门拧巴]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 上一代(hybrid 刚落地时)到底长什么样
|
||||
|
||||
### 2.1 检索侧:已经是真混合
|
||||
|
||||
`mode=hybrid` 时大致是:
|
||||
|
||||
```text
|
||||
query
|
||||
├─ dense ANN(query embedding) → vector / L2
|
||||
└─ BM25 sparse(EmbeddedText) → sparse_vector / BM25
|
||||
│
|
||||
▼
|
||||
Milvus hybridSearch + RRFRanker(k)
|
||||
│
|
||||
▼
|
||||
融合后的 hit 列表(RRF 序)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[query] --> EMB[embedding]
|
||||
Q --> TXT[raw text]
|
||||
EMB --> DA[dense ANN<br/>vector / L2]
|
||||
TXT --> BA[BM25 ANN<br/>sparse]
|
||||
DA --> HS[Milvus hybridSearch]
|
||||
BA --> HS
|
||||
HS --> RR[RRFRanker]
|
||||
RR --> HITS[有序 hits]
|
||||
```
|
||||
|
||||
这比应用层 sparse-lite / 伪 hybrid 前进了一大步:词面与语义在**库内**融合,chunk 身份也不会在后处理被文档级折叠吞掉。
|
||||
|
||||
### 2.2 分数侧:仍在「骗」后处理
|
||||
|
||||
后处理历史契约默认:
|
||||
|
||||
```text
|
||||
score ≈ L2 距离(越小越好)
|
||||
baseScore = 1 - clamp(L2) / maxL2Distance # 越大越好
|
||||
再 + domain/entity/keyword boost
|
||||
按 finalScore 重排
|
||||
用 baseScore 定 relevance_level / 是否低质 retry
|
||||
```
|
||||
|
||||
为了迁就这套契约,hybrid 路径做了补丁:
|
||||
|
||||
```text
|
||||
hybrid 融合结果
|
||||
-> 再跑一路 dense
|
||||
-> 按 id 把 L2 回填到 score,label 改成 l2_distance
|
||||
-> 仅 BM25 命中、dense 没命中:
|
||||
score = maxL2Distance
|
||||
label = bm25_only_no_dense
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
H[hybrid RRF 结果] --> P[并行 dense 探测]
|
||||
P --> M{同 id 有 L2?}
|
||||
M -->|是| O1[score=L2 · label=l2_distance]
|
||||
M -->|否| O2[score=maxL2 · label=bm25_only]
|
||||
O1 --> N[normalizeL2 + boost 重排]
|
||||
O2 --> N
|
||||
N --> BAD[BM25-only 好证据被当成最差]
|
||||
```
|
||||
|
||||
意图是好的:让 `normalizeL2` 和 0.75/0.5 阈值「还能用」。
|
||||
副作用也很清楚:
|
||||
|
||||
| 现象 | 后果 |
|
||||
|------|------|
|
||||
| RRF 决定顺序,L2 决定「好不好」 | 两套真理,互相打架 |
|
||||
| BM25-only 好证据被标成最远 L2 | quality≈0,像低质,甚至触发 unfiltered retry |
|
||||
| `bm25_only_no_dense` 像第三种 label | 概念膨胀:mode 其实只有 dense/hybrid |
|
||||
| 多打一路 dense 只为回填 | 延迟与复杂度,换来的是语义自洽的假象 |
|
||||
|
||||
一句话:
|
||||
|
||||
> **不是拿 RRF 分错误地套了 L2 公式,而是排序信 RRF,打分/闸门仍假装大家都是 L2。**
|
||||
|
||||
### 2.3 后处理侧:关键词 boost 改主序
|
||||
|
||||
典型逻辑:
|
||||
|
||||
```text
|
||||
baseScore = normalizeL2(score)
|
||||
finalScore = baseScore
|
||||
+ domain_match (+0.15)
|
||||
+ entity_match (+0.20)
|
||||
+ keyword_match (+0.10)
|
||||
+ source_type (+0.05)
|
||||
按 finalScore 降序
|
||||
```
|
||||
|
||||
在 **还没有库内 BM25** 时,这套东西多少能补一点词面。
|
||||
在 **已经 hybrid** 之后,问题变成:
|
||||
|
||||
1. **双重计分**
|
||||
BM25 已经在 RRF 里投过票;后处理再用 contains 加分,等于词面再抬一次。
|
||||
|
||||
2. **contains 比 BM25 更糙**
|
||||
无 IDF、无文档长度、短词子串误命中——正好制造「词频/词面高、相关度低却排前面」。
|
||||
|
||||
3. **冲掉 RRF 序**
|
||||
花了 hybrid 买到的融合序,被 L0 词表二次改写。
|
||||
|
||||
4. **PRECISE 还绑 hint**
|
||||
高质量还要 `hasHintSupport`(同样是 contains),把导航层信号抬成等级门槛。
|
||||
|
||||
结合上一篇文章的判断——**L0 / 关键词适合做提示,不适合当最终裁判**——hybrid 上线后,后处理 boost 改序已经从「可接受的轻启发式」滑向「明确的设计债」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 问题清单:chunk 去重 + hybrid 之后,还剩什么
|
||||
|
||||
可以分成四层(本次主要收口前两层):
|
||||
|
||||
### 3.1 正确性 / 契约(本次主战场)
|
||||
|
||||
1. hybrid **没有**独立的融合分归一化,只有 L2 兼容补丁
|
||||
2. 后处理关键词打分不合理,会抬升词面热、语义冷的片段
|
||||
3. `scoreLabel` 语义混乱:`l2_distance` / `rrf_fused` / `bm25_only_*` 混用
|
||||
4. `mode=dense` 与 hybrid 内部 dense 子路概念易混(mode 是整次查询算法,不是「第三套库」)
|
||||
|
||||
### 3.2 质量上限(未在本 change 做完)
|
||||
|
||||
- 无固定 RAG 评测报表驱动阈值标定
|
||||
- 无邻块扩展、真 query rewrite、cross-encoder 精排
|
||||
- 中文 analyzer / 分词策略未产品化钉死
|
||||
|
||||
### 3.3 工程债(部分清理、部分保留)
|
||||
|
||||
- 应用层 `LexicalRanker` / 自研 `RrfFusion` 可能仍像「还有 app-layer hybrid」
|
||||
- Spring AI starter 仍可作 sidecar,但不是知识主路径(starter 至 2.0.0 仍无 BM25 hybrid)
|
||||
- 写入先删后插,非强 upsert;Milvus / MySQL / L0 三方一致性靠流程
|
||||
|
||||
### 3.4 运维边界
|
||||
|
||||
- hybrid 依赖 BM25 Function + sparse index
|
||||
- 全量 rebuild 受 embedding 与写入延迟约束
|
||||
- `totalVectors` 一类统计可能仍不可信
|
||||
|
||||
本次 change(`rag-quality-score-unify`)**有意只收口 3.1**:
|
||||
让 hybrid 的排序权威与质量闸门重新对齐,而不是同时上精排模型。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键澄清:L2 是什么,它是不是「后处理」本身
|
||||
|
||||
讨论中容易把「L2」和「后处理打分流程」混成一个词。需要拆开:
|
||||
|
||||
### 4.1 L2 是度量
|
||||
|
||||
**L2 = 欧氏距离**,dense ANN 常用 metric:
|
||||
|
||||
- 越小越相似
|
||||
- 单位向量场景下可用 `maxL2Distance≈2` 做上界
|
||||
- 归一化相似度:`1 - clamp(L2) / maxL2`
|
||||
|
||||
### 4.2 后处理是流水线
|
||||
|
||||
后处理消费的是「约定好的 score」,历史上**假定**它是 L2,于是:
|
||||
|
||||
```text
|
||||
score(L2) → normalizeL2 → baseScore → (+boost) → finalScore → 排序/等级
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph metric["度量层"]
|
||||
L2[L2 距离]
|
||||
end
|
||||
|
||||
subgraph pipe["后处理流水线"]
|
||||
N[normalize]
|
||||
B[可选 boost]
|
||||
S[排序 / 截断]
|
||||
G[等级 / 闸门]
|
||||
N --> B --> S --> G
|
||||
end
|
||||
|
||||
L2 -.->|历史上假定输入是 L2| N
|
||||
RRF[RRF 融合分] -.->|量纲不同 · 不能直接套| N
|
||||
```
|
||||
|
||||
所以:
|
||||
|
||||
- L2 ≠ 后处理
|
||||
- L2 = dense 路径的自然距离
|
||||
- 后处理 = 把某种 score 变成 quality / 等级 / 截断结果的流程
|
||||
|
||||
hybrid 的问题是:**流程还在,输入契约已经不再总是 L2。**
|
||||
|
||||
### 4.3 category 降级也不是 L2 存在的唯一理由
|
||||
|
||||
filtered → unfiltered retry 用的是:
|
||||
|
||||
```text
|
||||
isLowQuality = 无可用证据 或 topSimilarity < referenceThreshold
|
||||
```
|
||||
|
||||
`topSimilarity` 来自归一化后的质量分。
|
||||
unfiltered 只是**再检一次**,尺子本来就该是统一 quality,而不是「专为降级准备的 L2」。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
F[带 category 的检索] --> Q{isLowQuality?}
|
||||
Q -->|是| U[unfiltered retry]
|
||||
Q -->|否| K[采用本次结果]
|
||||
U --> M[合并/替换为 retry 结果]
|
||||
K --> OUT[后处理出口]
|
||||
M --> OUT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 设计拍板:统一成什么
|
||||
|
||||
### 5.1 两层概念,不要混
|
||||
|
||||
| 层 | 只有什么 | 不是什么 |
|
||||
|----|----------|----------|
|
||||
| **检索 mode** | `dense` \| `hybrid` | 不是三套库 |
|
||||
| **一级 scoreLabel** | `dense` \| `hybrid` | 不是 `bm25_only` 第三种模式 |
|
||||
|
||||
- `mode`:整次查询怎么跑(配置 `retrieval.search.mode`)
|
||||
- `scoreLabel`:这条 hit 的 `score` 怎么解释
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
CFG[retrieval.search.mode] --> M1[dense 整次只跑 ANN]
|
||||
CFG --> M2[hybrid 整次 dense+BM25+RRF]
|
||||
|
||||
M1 --> L1[scoreLabel=dense]
|
||||
M2 --> L2[scoreLabel=hybrid]
|
||||
|
||||
L2 -.->|不是| L3[bm25_only 第三种 mode]
|
||||
```
|
||||
|
||||
`bm25_only_no_dense` **不是第三种检索**,只是旧链路里「这条 hybrid 命中没有 dense L2 可回填」的补丁标签。统一后应降级为历史别名(canonicalize → `hybrid`),不再一级发射。
|
||||
### 5.2 检索回来带什么
|
||||
|
||||
建议最小契约:
|
||||
|
||||
```text
|
||||
rank (originalRank) // 1 最好;hybrid = RRF 序;dense = ANN 序
|
||||
score // 引擎主分;量纲由 label 解释
|
||||
scoreLabel // dense | hybrid
|
||||
rawScore? // 可选调试
|
||||
denseDistance? // hybrid 可选:同 id 的 L2,仅供闸门
|
||||
```
|
||||
|
||||
| label | score 含义 |
|
||||
|-------|------------|
|
||||
| `dense` | L2 距离(越小越好) |
|
||||
| `hybrid` | 引擎融合分可放 raw/score;**排序不看其量纲** |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph emit["Store 发射"]
|
||||
LAB[scoreLabel<br/>dense | hybrid]
|
||||
SCR[score / rawScore]
|
||||
RNK[列表序 → originalRank]
|
||||
DD[denseDistance? 仅 hybrid]
|
||||
end
|
||||
|
||||
LAB --> NORM
|
||||
SCR --> NORM
|
||||
RNK --> SORT
|
||||
DD --> NORM
|
||||
NORM[toQualityScore] --> QS[qualityScore]
|
||||
SORT[按 rank 排序] --> LIST[evidence 顺序]
|
||||
QS --> GATE[level / isLowQuality]
|
||||
```
|
||||
|
||||
### 5.3 唯一归一化点
|
||||
|
||||
```text
|
||||
qualityScore = toQualityScore(label, score, rank, batchSize, maxL2, denseDistance?)
|
||||
// 输出统一:[0,1],越大越好
|
||||
```
|
||||
|
||||
分支只允许出现在这里:
|
||||
|
||||
```text
|
||||
dense → 1 - clamp(L2)/maxL2
|
||||
hybrid → 优先 denseDistance 的 L2 归一化(绝对质量 / 闸门)
|
||||
无 dense 时 rank 线性回退
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
IN[label + score + rank + denseDistance?] --> C{canonicalize label}
|
||||
C -->|dense| L2[l2ToQuality score]
|
||||
C -->|hybrid| H{denseDistance?}
|
||||
H -->|有| L2H[l2ToQuality denseDistance]
|
||||
H -->|无| RK[rankToQuality]
|
||||
L2 --> OUT[qualityScore 0..1]
|
||||
L2H --> OUT
|
||||
RK --> OUT
|
||||
```
|
||||
|
||||
**演进说明:** 切片 1 曾用纯 rank 做 hybrid quality;eval 发现 rank1 恒高会杀死 L0 filter fallback。
|
||||
现行约定:**排序仍纯 RRF;闸门可用 denseDistance 绝对质量**,且 **不得** 再把主分/label 伪装成 L2。
|
||||
|
||||
### 5.4 后处理:统一流程,不要按 label 再分叉业务
|
||||
|
||||
```text
|
||||
candidates
|
||||
→ 每条 toQualityScore(...) ← 唯一认 label 的地方
|
||||
→ qualityScore + originalRank
|
||||
→ 统一:按 rank 排序 / 去重 / 每文档 chunk 上限 / return-n
|
||||
→ 统一:relevance_level、isLowQuality(只看 qualityScore)
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
CAND[candidates] --> QS[toQualityScore 每条]
|
||||
QS --> SORT[sort by originalRank ASC]
|
||||
SORT --> DEDUP[evidenceKey 去重]
|
||||
DEDUP --> CAP[max-chunks / return-n]
|
||||
CAP --> REL[relevance_level]
|
||||
CAP --> LOW[isLowQuality → filter retry]
|
||||
CAP --> OUT[EvidenceBlocks]
|
||||
```
|
||||
|
||||
可以记成:
|
||||
|
||||
> **Label 只活在进后处理之前的适配器里;后处理是 label-agnostic 的。**
|
||||
> **排序听 rank;闸门听 qualityScore(hybrid 可含 denseDistance)。**
|
||||
### 5.5 后处理还改不改?——要改,而且和归一化同一刀
|
||||
|
||||
后处理合理职责是 **裁剪与装配**,不是第二套检索:
|
||||
|
||||
| 保留 | 去掉或降级 |
|
||||
|------|------------|
|
||||
| evidenceKey 去重 | domain/entity/keyword **加分改序** |
|
||||
| max-chunks-per-document | contains 当相关度代理 |
|
||||
| return-n | PRECISE 强制 hint support |
|
||||
| excerpt 截断、EvidenceBlock | |
|
||||
| 统一 qualityScore 闸门 | |
|
||||
|
||||
L0 仍可:
|
||||
|
||||
- 导航:category filter(失败 unfiltered retry)
|
||||
- 解释:`hitReasons` 记 `l0_keyword_overlap` 等(**零分值**)
|
||||
|
||||
词面该不该高:交给 **BM25 子路 + RRF**。
|
||||
语义该不该近:交给 **dense 子路**(融合时已参与;dense-only mode 对照时单独看)。
|
||||
|
||||
### 5.6 mode=dense 还要不要
|
||||
|
||||
要,但定位清楚:
|
||||
|
||||
| 模式 | 定位 |
|
||||
|------|------|
|
||||
| **hybrid** | 线上主路径 / 默认 |
|
||||
| **dense** | 同库对照、评测、排障——看「去掉 BM25+RRF 后差在哪」 |
|
||||
|
||||
注意:
|
||||
|
||||
- hybrid **入库**数据完全适用于 dense 查询(每条都写了 `vector`)
|
||||
- hybrid **内部**仍有 dense 子路——那是融合的一部分,≠ `mode=dense`
|
||||
- 对照时固定 `retrieve-k` / `return-n` / filter / query 集,只切 mode
|
||||
- 优先比命中集合与排名;`relevance_level` 在 hybrid 下是序数 quality,慎作跨 mode 绝对值对比
|
||||
|
||||
---
|
||||
|
||||
## 6. 目标数据流(落地后)
|
||||
|
||||
```text
|
||||
VectorSearchService (mode=dense|hybrid)
|
||||
-> hits{ originalRank, score, scoreLabel=dense|hybrid, rawScore?, denseDistance? }
|
||||
-> KnowledgeDocumentRetriever / SearchPort
|
||||
-> KnowledgeEvidencePostProcessor
|
||||
qualityScore = RetrievalScoreNormalizer.toQualityScore(...)
|
||||
sort by originalRank ASC
|
||||
evidenceKey dedup / max-chunks / return-n
|
||||
relevance_level & topSimilarity from qualityScore
|
||||
L0 overlap → hitReasons only
|
||||
-> ContextPack / Assembler / Projector
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Q[query] --> VSS[VectorSearchService]
|
||||
VSS -->|mode=dense| SD[searchDense]
|
||||
VSS -->|mode=hybrid| SH[searchHybrid + 可选 denseDistance]
|
||||
SD --> PORT[KnowledgeSearchPort / Adapter]
|
||||
SH --> PORT
|
||||
PORT --> POST[KnowledgeEvidencePostProcessor]
|
||||
POST --> PACK[ContextPacker]
|
||||
POST --> ASM[LookupResultAssembler]
|
||||
ASM --> PROJ[RagResultProjector]
|
||||
PROJ --> AGENT[Agent 可见契约]
|
||||
```
|
||||
|
||||
与上一代对比:
|
||||
|
||||
| 环节 | 上一代 | 现在 |
|
||||
|------|--------|------|
|
||||
| hybrid score | 常被 L2 覆盖 | 保持融合侧;label=`hybrid` |
|
||||
| BM25-only | maxL2 + `bm25_only_*` | 普通 hybrid hit,quality 看 rank |
|
||||
| 归一化 | 一律当 L2 | 按 label 唯一转换 |
|
||||
| 排序 | finalScore(含 boost) | originalRank |
|
||||
| L0 关键词 | +分改序 | 仅解释 |
|
||||
| 质量闸门 | baseScore(L2 兼容) | qualityScore |
|
||||
|
||||
---
|
||||
|
||||
## 7. 行为变化:必须说清楚的协议调整
|
||||
|
||||
这是**有意的行为变化**(对内检索质量语义;Agent ACI 字段名可不变):
|
||||
|
||||
1. hybrid 下证据顺序更贴近 **RRF**,不再被 contains 抬到前面
|
||||
2. 「词面很准、dense 略远」的命中,不再被默认打成低质占位
|
||||
3. `relevance_level` / category unfiltered retry 的触发分布可能变化
|
||||
4. hybrid 的 quality 是**本轮序数分**,跨 query 绝对值不可比;阈值可能需后续标定
|
||||
5. dense 对照模式:质量仍走 L2 归一化,行为更接近旧 dense 主路径
|
||||
|
||||
未改:
|
||||
|
||||
- Agent 可见字段结构(evidence 列表、relevance 枚举名等)
|
||||
- Milvus hybrid schema / 不必为本次 rebuild
|
||||
- `mode=dense` 开关本身
|
||||
|
||||
---
|
||||
|
||||
## 8. 和上一篇文章的衔接:阶段进度
|
||||
|
||||
对照 `RAG排序-多路召回与RRF.md` 的推进顺序:
|
||||
|
||||
| 阶段 | 内容 | 状态(截至 2026-07-28) |
|
||||
|------|------|-------------------------|
|
||||
| Phase 0 | chunk 去重、retrieve-k/return-n、身份 | **已落地** |
|
||||
| Phase 1~2 | 多路 + RRF;真 BM25 hybrid | **已落地**(库内 hybrid,非 app-layer 伪融合) |
|
||||
| 分数职责分离 | 排序 vs 可用性/质量闸门 | **本次收口**(qualityScore 统一) |
|
||||
| 去掉 L0 当裁判 | 关键词不改主序 | **本次收口** |
|
||||
| Phase 3 | 可插拔模型 Rerank | **未做**(候选池与评测闭环仍优先) |
|
||||
| 邻块 / query rewrite | 上下文与问句改写 | **未做** |
|
||||
|
||||
因此,本次文章不是推翻上一篇,而是补上上一篇写到「融合之后」却还没写完的半截:
|
||||
|
||||
> 融合解决「谁进来、谁先排」;
|
||||
> 归一化与后处理决定「算不算够好、会不会被规则再次打乱」。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实现锚点(便于对照代码)
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `RetrievalScoreLabels` | `dense` / `hybrid` + 旧别名 canonicalize |
|
||||
| `RetrievalScoreNormalizer` | 唯一 `toQualityScore` |
|
||||
| `MilvusHybridKnowledgeStore` | 发射 label;hybrid 不 L2 覆盖 |
|
||||
| `VectorSearchService` | mode 路由;SearchResult 契约注释 |
|
||||
| `KnowledgeEvidencePostProcessor` | rank 保序、去 boost 改序、quality 闸门 |
|
||||
| 架构 §6.0 | mode 用途:hybrid 主路径 / dense 对照 |
|
||||
|
||||
验证(单测,非 live E2E):
|
||||
|
||||
- normalizer:L2 边界、rank 单调、别名
|
||||
- post-process:rank 不被 keyword 打乱;caps/return-n
|
||||
- lookup tool:保序;context pack 元数据仍在
|
||||
|
||||
已知未验证:真实 Milvus 联调对照、阈值标定。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实践清单:以后别再踩的坑
|
||||
|
||||
1. **不要**为了复用旧 `normalizeL2`,把 hybrid 结果伪装成 L2。
|
||||
2. **不要**在已经 BM25 hybrid 之后,再用 L0 contains 大额加分改主序。
|
||||
3. **不要**把 `bm25_only` 当成第三种检索模式。
|
||||
4. **不要**把 hybrid 内部的 dense 子路,和 `mode=dense` 整次查询混为一谈。
|
||||
5. **要**让 label 差异停在适配器;后处理只认 qualityScore + rank。
|
||||
6. **要**用 dense mode 做召回对照,而不是第二套长期线上策略。
|
||||
7. **要**接受:hybrid 序数 quality 与绝对阈值之间,需要观测后再调,而不是再发明一层伪装。
|
||||
8. **下一步再考虑**精排模型——在契约掰直、有固定 query 回归集之后。
|
||||
|
||||
---
|
||||
|
||||
## 11. 结语
|
||||
|
||||
混合检索落地,解决的是「漏」和「跨路硬加分」里很大一块。
|
||||
但若后处理仍活在 L2 + 关键词 boost 的旧世界,hybrid 买到的 RRF 序和质量信号会被悄悄改写,甚至惩罚「只在 BM25 路很强」的好证据。
|
||||
|
||||
这次收口的核心就三句:
|
||||
|
||||
```text
|
||||
1. 一级 label 只有 dense / hybrid
|
||||
2. 唯一 toQualityScore;后处理统一、保 rank
|
||||
3. L0 关键词可以解释,不可以再当排序裁判
|
||||
```
|
||||
|
||||
它不是 RAG 的终点,而是 hybrid 从「能跑」变成「分数语义自洽」的必要一步。
|
||||
在此之后,评测闭环、阈值标定、邻块与精排,才有干净的基线可谈。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:术语
|
||||
|
||||
| 术语 | 含义 |
|
||||
|------|------|
|
||||
| L2 | 欧氏距离;dense ANN 常用;越小越相似 |
|
||||
| RRF | Reciprocal Rank Fusion;用名次融合多路,不融合原始分 |
|
||||
| scoreLabel | 一级分数语义:`dense` \| `hybrid` |
|
||||
| qualityScore | 归一化后的 0~1 质量分(越大越好),供等级与低质闸门 |
|
||||
| originalRank | 检索返回名次;后处理排序权威 |
|
||||
| mode=dense | 整次只跑 dense ANN(对照) |
|
||||
| mode=hybrid | dense+BM25+RRF(主路径) |
|
||||
| L0 | query hint / 可选 category filter;不作事实证据、不改主序 |
|
||||
|
||||
## 附录 B:相关材料
|
||||
|
||||
| 材料 | 路径 |
|
||||
|------|------|
|
||||
| 多路与 RRF 讨论 | `RAG排序-多路召回与RRF.md` |
|
||||
| 当前架构 | `mvp/architecture/RAG知识检索架构.md` |
|
||||
| OpenSpec 归档 | `openspec/changes/archive/2026-07-28-rag-quality-score-unify/` |
|
||||
| 主规格 | `openspec/specs/rag-retrieval-quality-score/spec.md` |
|
||||
| devflow | `devflow/projects/2026-07-28-rag-quality-score-unify/` |
|
||||
@@ -0,0 +1,163 @@
|
||||
# RAG 审计补丁 E2E:`step_id` + query(含业务 FALLBACK 样本)
|
||||
|
||||
**日期**:2026-07-28
|
||||
**状态**:审计字段 live 验收记录
|
||||
**关联主文档**:[一次诊断到底发生了什么(SUCCESS 全流程)](../diagnosis/一次诊断全流程-E2E导读.md)
|
||||
|
||||
> 主文档只保留 **SUCCESS 完整诊断** 与 **现行审计能力说明**。
|
||||
> 本页单独记录:改造后的一次 live 验收——**审计字段 PASS**,业务因 Milvus 空结果走了 **FALLBACK**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 样本身份
|
||||
|
||||
| 项 | 值 |
|
||||
|----|----|
|
||||
| `session_id` | `e2e-audit-20260728171858` |
|
||||
| `run_id` | `0be605f6-e036-40d3-b364-11b73672241f` |
|
||||
| 入口 | `POST /api/chat`(与 SUCCESS 样例同构的 RAG-only 约束题) |
|
||||
| 冷启动 | 含 `AgentStepAuditTracker` 等补丁后的 `mvn spring-boot:run` |
|
||||
| 业务结局 | `release_outcome=FALLBACK`,`content_type=SAFE_FALLBACK` |
|
||||
| 工具 | 1× `lookup_knowledge` |
|
||||
|
||||
本地产物(若仍在):`target/e2e-audit-sse.txt`、`target/e2e-audit-session.txt`、`target/e2e-audit-run.txt`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 验收目标 vs 非目标
|
||||
|
||||
| 目标 | 是否本页重点 |
|
||||
|------|----------------|
|
||||
| `tool_invocation.step_id` = 发出 tool_call 的 `agent_step.id` | **是** |
|
||||
| `input_params` 含安全 `query` 预览 | **是** |
|
||||
| Trace / timeline 带回 `step_id` | **是** |
|
||||
| 业务必须 SUCCESS | **否**(本 run 为 FALLBACK,归因环境) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 审计结果:PASS
|
||||
|
||||
### 3.1 `agent_step`
|
||||
|
||||
| id | step_index | has_tool_call | 说明 |
|
||||
|----|------------|---------------|------|
|
||||
| **962** | 0 | 1 | 发出 `lookup_knowledge` |
|
||||
| 963 | 1 | 0 | 无证据后的收尾轮 |
|
||||
|
||||
### 3.2 `tool_invocation`(id=860)
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `step_id` | **962**(= step0) |
|
||||
| `tool_name` | `lookup_knowledge` |
|
||||
| `success` | 1(工具跑完;无证据也算执行成功) |
|
||||
| `search_mode` | hybrid |
|
||||
| `evidence_status` | `NO_EVIDENCE` |
|
||||
| `candidate_count` | 0 |
|
||||
| `relevance_level` | null |
|
||||
|
||||
**`input_params` 实值:**
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "MySQL connection pool exhausted HikariCP diagnosis",
|
||||
"step_id": 962,
|
||||
"tool_call_id": "call_00_LireJzbiFEsbZ9ZVvgYp8330",
|
||||
"request_bytes": 62
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Trace API
|
||||
|
||||
- `toolInvocations[0].stepId = 962`
|
||||
- `inputParams.query` 有值
|
||||
- timeline `TOOL_INVOCATION.details.step_id = 962`
|
||||
|
||||
### 3.4 对照表
|
||||
|
||||
| 检查项 | 预期 | 实际 | 判定 |
|
||||
|--------|------|------|------|
|
||||
| `tool_invocation.step_id` | = agent_step.id | 962 | **PASS** |
|
||||
| `input_params.query` | 有预览 | 有 | **PASS** |
|
||||
| `input_params.step_id` | 与列一致 | 962 | **PASS** |
|
||||
| Trace `stepId` | 非空 | 962 | **PASS** |
|
||||
| timeline `step_id` | 非空 | 962 | **PASS** |
|
||||
| `search_mode` | hybrid | hybrid | **PASS** |
|
||||
| 业务 outcome | (非本页 KPI) | FALLBACK | 见 §4 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S0[agent_step 962<br/>r1 tool_call] --> TI[tool_invocation 860<br/>step_id=962]
|
||||
TI --> IP[input_params.query]
|
||||
TI --> TR[Trace / timeline]
|
||||
TI --> RAG[hybrid candidate_count=0]
|
||||
RAG --> FB[FALLBACK<br/>NO_EVIDENCE]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 业务 FALLBACK 原因(与审计无关)
|
||||
|
||||
| 现象 | 说明 |
|
||||
|------|------|
|
||||
| 日志 | `found=false`,`evidenceBlocks=0` |
|
||||
| audit | `attempts[].usable=false`,`candidate_count=0` |
|
||||
| warm 检索 | 同期 `GET /api/search/similar` 亦失败 |
|
||||
| 日志噪音 | Milvus channel 未正确 shutdown 等提示(环境/客户端生命周期) |
|
||||
|
||||
**结论**:hybrid **路径进了**,但当次 **0 候选** → 无证据可写报告 → `SAFE_FALLBACK` / `INSUFFICIENT_EVIDENCE`。
|
||||
这不否定 `step_id` / `query` 落库;完整 SUCCESS 业务故事见主文档。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Q[同一 RAG-only 题] --> LK[lookup_knowledge]
|
||||
LK --> AUD[审计: step_id + query PASS]
|
||||
LK --> HIT{有候选?}
|
||||
HIT -->|SUCCESS 主文档 run| OK[PRECISE → DIAGNOSIS_REPORT]
|
||||
HIT -->|本页 run| NO[0 候选 → FALLBACK]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 实现索引(补丁代码)
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `AgentStepAuditTracker` | run 级 bind/current/clear `agent_step.id` |
|
||||
| `HarnessAgentAuditHook` | `beforeModel` 落 step 后 `bind` |
|
||||
| `ToolBoundary.auditSafely` | 读 tracker,带上 `stepId` + `requestJson` |
|
||||
| `JpaToolInvocationAuditSink` | 写 `step_id` 列;安全展开 `input_params` |
|
||||
| `TraceAuditEvents.toolInvocation` | timeline details 的 `step_id` |
|
||||
| `JpaChatRunStore.finish` | `clear(runId)` |
|
||||
|
||||
**`input_params` 安全规则摘要**:
|
||||
|
||||
- 始终:`tool_call_id`、`request_bytes`;有则:`step_id`
|
||||
- 顶层 string/number/boolean/纯字符串数组;文本 ≤160 字符
|
||||
- 键名含 password/token/secret/apikey 等 → 不落库
|
||||
|
||||
更完整的字段词典与 SUCCESS 阶段拆解见主文档 §3.4 / §3.4.1。
|
||||
|
||||
---
|
||||
|
||||
## 6. 复盘 SQL
|
||||
|
||||
```bash
|
||||
python scripts/query_mysql.py "SELECT id, step_id, tool_name, relevance_level, CAST(input_params AS CHAR) AS inputp, LEFT(CAST(retrieval_details AS CHAR), 500) AS details FROM tool_invocation WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f'"
|
||||
|
||||
python scripts/query_mysql.py "SELECT id, step_index, agent_name, has_tool_call, token_count FROM agent_step WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f' ORDER BY step_index"
|
||||
|
||||
python scripts/query_mysql.py "SELECT run_id, status, release_outcome, tool_call_count, total_token_count FROM diagnosis_run WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f'"
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/diagnosis/e2e-audit-20260728171858/trace?runId=0be605f6-e036-40d3-b364-11b73672241f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-07-28 | 从主 walkthrough 拆出:专门记录 audit 验收 run(FALLBACK 业务 + step_id/query PASS) |
|
||||
@@ -0,0 +1,893 @@
|
||||
# 诊断 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 问答的工程同学
|
||||
**关联实现**:`Lookup_knowledge` 模块化链路(L0 hint → L1 向量 → 规则后处理 → 投影)
|
||||
|
||||
---
|
||||
|
||||
## 1. 引言:RAG 排序常被高估,也常被低估
|
||||
|
||||
在诊断 Agent 里,知识库检索很少是“搜一下、答一句”那么简单。一次 `lookup_knowledge` 往往要在有限工具预算里,尽快给出可引用的证据片段。这时排序质量会直接影响:
|
||||
|
||||
- Agent 是否看得到正确 runbook / 排查步骤
|
||||
- 是否被错误 domain 的文档带偏
|
||||
- 是否在本就很少的 topK 里,把唯一有用的 chunk 挤掉
|
||||
|
||||
讨论排序时,团队里很容易出现两种极端:
|
||||
|
||||
1. **高估 Rerank**
|
||||
一提质量问题就上 Cross-Encoder、商业 Rerank、LLM listwise。
|
||||
但候选池只有 3 条时,精排只能在这 3 条里换座位,召不回的内容永远排不上来。
|
||||
|
||||
2. **低估融合**
|
||||
觉得“都是相似度,加一加就行”。
|
||||
但向量 L2、BM25、关键词加分根本不在同一尺度上,硬加权会把系统调成玄学。
|
||||
|
||||
本文基于一次针对真实诊断 Agent RAG 链路的讨论,整理一套可落地的判断框架:
|
||||
|
||||
```text
|
||||
1. 候选太少时,复杂 rerank 价值有限
|
||||
2. 多路召回解决“漏”,精排解决“噪”
|
||||
3. 跨路不要硬加原始分,优先 RRF 用名次投票
|
||||
4. L0 / 关键词适合做提示,不适合当最终裁判
|
||||
5. 权重 w_i 是“路权”,不是文档原始分
|
||||
```
|
||||
|
||||
目标不是证明某一种模型永远最优,而是回答工程上更常见的问题:
|
||||
|
||||
> 现在的 K、现有的 L0/L1、现有的规则加分,下一步到底该扩召回、该融合,还是该上精排?
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P[排序/质量问题] --> A{候选池 K 多大?}
|
||||
A -->|K 很小 3~5| B[先扩召回 / 修去重 / 轻规则]
|
||||
A -->|K 中等 15~30| C[多路 + RRF + 可选轻精排]
|
||||
A -->|K 很大 50+| D[强 rerank 才划算]
|
||||
|
||||
P --> E{跨路分数?}
|
||||
E -->|原始分硬加| F[尺度不同 · 易玄学]
|
||||
E -->|RRF 名次投票| G[推荐]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 现状解剖:有“重排”,不等于有“强 Rerank”
|
||||
|
||||
### 2.1 一条典型主链路
|
||||
|
||||
以模块化 `lookup_knowledge` 为例,主路径大致是:
|
||||
|
||||
```text
|
||||
Agent query
|
||||
-> KnowledgeQueryTransformer # L0:domain / keyword hint,可选 category filter
|
||||
-> KnowledgeDocumentRetriever # L1:向量 topK
|
||||
-> KnowledgeEvidencePostProcessor # 归一化 + 规则 boost + 排序 + 去重
|
||||
-> KnowledgeContextPacker # 字符预算打包
|
||||
-> LookupResultAssembler
|
||||
-> RagResultProjector # 投影成 Agent 可见契约
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AQ[Agent query] --> L0[KnowledgeQueryTransformer<br/>L0 hint / category filter]
|
||||
L0 --> L1[KnowledgeDocumentRetriever<br/>L1 向量 topK]
|
||||
L1 --> POST[KnowledgeEvidencePostProcessor<br/>归一化 + 规则 boost + 去重]
|
||||
POST --> PACK[KnowledgeContextPacker]
|
||||
PACK --> ASM[LookupResultAssembler]
|
||||
ASM --> PROJ[RagResultProjector]
|
||||
PROJ --> AGENT[Agent 可见结果]
|
||||
```
|
||||
|
||||
其中“重排”发生在后处理阶段,名字也常叫 rerank,但实现通常是:
|
||||
|
||||
```text
|
||||
baseScore = 向量距离归一化后的相似度
|
||||
finalScore = baseScore
|
||||
+ domain_match
|
||||
+ entity_match
|
||||
+ keyword_match
|
||||
+ source_type_prior
|
||||
再按 finalScore 降序
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
BASE[baseScore<br/>向量相似度] --> SUM[finalScore]
|
||||
D[+ domain]
|
||||
E[+ entity]
|
||||
K[+ keyword]
|
||||
S[+ source_type]
|
||||
D --> SUM
|
||||
E --> SUM
|
||||
K --> SUM
|
||||
S --> SUM
|
||||
SUM --> SORT[按 finalScore 降序]
|
||||
```
|
||||
|
||||
同时会留下 `rerankTrace`(base/final score、boost reasons),便于内部审计。
|
||||
|
||||
### 2.2 这套做法解决了什么
|
||||
|
||||
它不是毫无意义:
|
||||
|
||||
- 在小候选池里,能把更像目标域、更像 runbook 的结果往前推
|
||||
- 有可解释的 boost 原因,方便 trace
|
||||
- 实现成本低,不引入额外模型服务
|
||||
|
||||
如果只是 Demo,或语料很小、query 很规范,这种轻规则重排往往够用。
|
||||
|
||||
### 2.3 它没解决什么
|
||||
|
||||
真正的问题通常不在“3 条里谁排第一”,而在:
|
||||
|
||||
1. **K 太小**
|
||||
`topK=3` 时,重排空间极小。复杂精排模型也只能在这 3 条里微调。
|
||||
|
||||
2. **规则加分不稳定**
|
||||
依赖 L0 词表和字符串 `contains`。短词、泛词、别名缺失都会让 boost 误触发或漏触发。
|
||||
|
||||
3. **L0 filter 可能误杀**
|
||||
唯一 domain 时加 category filter,能降噪;一旦 L0 判错域,正确文档可能根本进不了候选。
|
||||
|
||||
4. **去重粒度若偏文档级**
|
||||
同文档多个相关 chunk 可能被压成 1 条,召回了也会在后处理/投影阶段丢掉。
|
||||
|
||||
5. **Agent 侧看不到排序细节**
|
||||
投影层常只保留 excerpt 与少量元数据,score / hitReasons / retrievalTrace 被裁掉。这对安全边界合理,但对模型“判断有多相关”不友好。
|
||||
|
||||
一句话概括现状:
|
||||
|
||||
> 不是没有排序,而是在太小的候选池里,用不够稳的启发式做微调。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关键判断:候选池大小决定策略上限
|
||||
|
||||
排序策略必须和 K 匹配。可以先用下面这张表做决策:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
K{retrieve-k / 候选规模}
|
||||
K -->|3~5| S1[修去重 · 轻规则<br/>双路径 dense 融合]
|
||||
K -->|15~30| S2[多路召回 + RRF<br/>可选轻精排]
|
||||
K -->|50+| S3[强 Cross-Encoder / 托管 Rerank]
|
||||
|
||||
S1 -.->|先别上| X1[商业 Rerank]
|
||||
S2 -.->|收益有限| X2[只继续调 keyword boost]
|
||||
S3 -.->|避免| X3[无评测堆模型]
|
||||
```
|
||||
|
||||
| 召回规模 K | 更适合做什么 | 不太值得先做什么 |
|
||||
|---|---|---|
|
||||
| 3 ~ 5 | 修去重、轻规则、双路径 dense 融合 | Cross-Encoder / 商业 Rerank |
|
||||
| 15 ~ 30 | 多路召回 + RRF + 轻精排 | 只继续调 keyword boost |
|
||||
| 50+ | 强 rerank 才划算 | 无评测地堆模型 |
|
||||
|
||||
背后的原因很简单:
|
||||
|
||||
```text
|
||||
Rerank 的价值来自:
|
||||
候选很多、噪声大 → 精排把好的顶到前面
|
||||
|
||||
如果好文档根本不在候选里:
|
||||
再贵的 rerank 也救不回来
|
||||
```
|
||||
|
||||
因此工程上应把两个参数拆开:
|
||||
|
||||
```text
|
||||
retrieve-k # 召回阶段多捞一些,例如 20
|
||||
return-n # 最终给 Agent 的条数,例如 3~5
|
||||
```
|
||||
|
||||
而不是始终:
|
||||
|
||||
```text
|
||||
topK = 3,召回、排序、返回都是 3
|
||||
```
|
||||
|
||||
**先让该进来的进来,再谈谁排前面。**
|
||||
|
||||
---
|
||||
|
||||
## 4. 多路召回:先分清“同模态双路径”和“跨维度多路”
|
||||
|
||||
“多路召回”不是只有 BM25 + 向量这一种。可以分层理解。
|
||||
|
||||
### 4.1 同模态双路径:filtered + unfiltered
|
||||
|
||||
很多系统已经有类似逻辑:
|
||||
|
||||
```text
|
||||
若 L0 给出唯一 category:
|
||||
先 filtered 向量检索
|
||||
若结果差,再 unfiltered 重试
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Q[query + L0 category?] --> F[filtered dense]
|
||||
F --> BAD{结果差/空?}
|
||||
BAD -->|是| U[unfiltered retry]
|
||||
BAD -->|否| USE[采用 filtered]
|
||||
U --> REP[常见:整锅替换 filtered]
|
||||
```
|
||||
|
||||
这能工作,但常见实现是 **串行整锅替换**:
|
||||
|
||||
- retry 成功后,直接丢掉第一次 filtered 的全部结果
|
||||
- 没有“两边好结果都保留,再统一排序”
|
||||
|
||||
更稳的做法是把它升级为并行/双路融合:
|
||||
|
||||
```text
|
||||
路 A:dense + category filter # 求准
|
||||
路 B:dense + 无 filter # 求全
|
||||
去重合并后一起排序
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q[query] --> A[路A filtered dense]
|
||||
Q --> B[路B unfiltered dense]
|
||||
A --> M[去重合并]
|
||||
B --> M
|
||||
M --> RRF[RRF / 统一排序]
|
||||
RRF --> OUT[topN]
|
||||
```
|
||||
|
||||
#### 为什么值得做?
|
||||
|
||||
因为两路解决的是不同失败模式:
|
||||
|
||||
| 路径 | 优点 | 风险 |
|
||||
|---|---|---|
|
||||
| filtered | 更贴当前域,噪声少 | filter 错了会漏召回 |
|
||||
| unfiltered | 召回面宽,容错强 | 容易掺进其他域文档 |
|
||||
|
||||
典型场景:
|
||||
|
||||
- L0 误判成 `mysql`,真正文档在 `redis`
|
||||
→ 只走 filtered 会空或很差
|
||||
→ unfiltered 能救回
|
||||
- L0 判对了 `mysql`
|
||||
→ filtered 更干净
|
||||
→ unfiltered 可能带噪声
|
||||
|
||||
所以:
|
||||
|
||||
> filtered 求准,unfiltered 求全;融合比二选一更稳。
|
||||
|
||||
#### 它算多路吗?
|
||||
|
||||
算,但要说清楚边界:
|
||||
|
||||
```text
|
||||
这是同一 dense 检索器、不同过滤条件的双路径
|
||||
属于多路召回的子集
|
||||
还不是完整的跨维度多路
|
||||
```
|
||||
|
||||
它的主要价值是:
|
||||
|
||||
1. 降低 L0 category 误杀
|
||||
2. 保留“有 filter 时更准”的收益
|
||||
3. 避免 retry 整锅替换导致好结果被误删
|
||||
|
||||
即便暂时不上 BM25,只做这一步,也常常比继续调 keyword boost 更有效。
|
||||
|
||||
### 4.2 跨维度多路:Dense + BM25
|
||||
|
||||
更完整的多路,通常来自不同相关性维度:
|
||||
|
||||
| 路 | 擅长 | 不擅长 |
|
||||
|---|---|---|
|
||||
| Dense(向量) | 语义相近、换说法、同义表达 | 罕见专有名词可能漂 |
|
||||
| BM25 / 关键词 | 错误码、类名、配置键、告警名、精确术语 | 换一种说法就容易漏 |
|
||||
|
||||
例子:
|
||||
|
||||
```text
|
||||
用户说:连接池打满了
|
||||
文档写:HikariCP pending threads high
|
||||
→ dense 更容易搭上
|
||||
|
||||
用户说:SQLSTATE 08001
|
||||
文档标题就含 08001
|
||||
→ BM25 / 字面匹配往往更稳
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
```text
|
||||
candidates = dense ∪ bm25
|
||||
```
|
||||
|
||||
不是“BM25 替代向量”,而是“补向量漏掉的字面命中”。
|
||||
|
||||
### 4.3 L0 能不能当一路召回?
|
||||
|
||||
可以,但要降权、控边界。
|
||||
|
||||
L0(文档 frontmatter 关键词 / domain hint)适合:
|
||||
|
||||
- 决定要不要启用 filtered 路
|
||||
- 提供精排时的弱特征
|
||||
- 写出可解释 trace
|
||||
|
||||
不适合:
|
||||
|
||||
- 关键词命中就直接当高置信事实证据
|
||||
- 用 L0 粗分主导最终排序
|
||||
|
||||
一句话:
|
||||
|
||||
> L0 是导航,不是裁判长。
|
||||
|
||||
---
|
||||
|
||||
## 5. 融合为什么难:不是不会加权,是分数不可比
|
||||
|
||||
多路召回之后,第一反应常常是:
|
||||
|
||||
```text
|
||||
final = 0.7 * dense_score + 0.3 * bm25_score
|
||||
```
|
||||
|
||||
这在课堂上好讲,在工程上很脆。
|
||||
|
||||
### 5.1 原始分为什么不能直接加
|
||||
|
||||
不同路的分数:
|
||||
|
||||
- Dense:L2 距离或 cosine similarity,分布随 embedding 模型和语料变化
|
||||
- BM25:另一套量纲,数值大小和 dense 完全不可比
|
||||
- 规则 boost:+0.15 / +0.20 这种启发式加分,更像人工偏好,不是校准概率
|
||||
|
||||
把它们直接线性相加,等于默认“0.1 的向量分提升”和“2.0 的 BM25 提升”可以交换。这个默认通常不成立。
|
||||
|
||||
### 5.2 规则 keyword boost 为什么不稳定
|
||||
|
||||
若最终分主要靠:
|
||||
|
||||
```text
|
||||
query.contains(keyword) || keyword.contains(query)
|
||||
```
|
||||
|
||||
再叠加固定加分,会出现:
|
||||
|
||||
- 短词误命中
|
||||
- 泛词普遍加分,区分度下降
|
||||
- 词表一改,线上排序整体漂移
|
||||
- 同义词没写进 frontmatter 就完全没帮助
|
||||
|
||||
所以:
|
||||
|
||||
> 现在的“权重”如果本质是关键词启发式加分,稳定性天然有限。
|
||||
|
||||
这不代表规则无用,而是应把它放对层:路内微调或精排特征,而不是跨路主融合器。
|
||||
|
||||
---
|
||||
|
||||
## 6. RRF:跨路融合时,优先用名次投票
|
||||
|
||||
### 6.1 核心思想
|
||||
|
||||
RRF(Reciprocal Rank Fusion)的关键思想是:
|
||||
|
||||
> 不管各路原始分是什么尺度,只看每条候选在各路里的名次,再投票。
|
||||
|
||||
基础公式:
|
||||
|
||||
```text
|
||||
RRF(d) = Σ 1 / (k + rank_i(d))
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
| 符号 | 含义 |
|
||||
|---|---|
|
||||
| `d` | 某个候选文档或 chunk |
|
||||
| `i` | 第 i 路召回 |
|
||||
| `rank_i(d)` | d 在第 i 路中的名次(从 1 开始);未出现则该路贡献为 0 |
|
||||
| `k` | 常数,常用 60,用来缓和头部名次过强 |
|
||||
|
||||
直觉:
|
||||
|
||||
- 某路第 1 名:贡献约 `1/61`
|
||||
- 某路第 2 名:贡献约 `1/62`
|
||||
- 多路都靠前的候选,融合分自然更高
|
||||
- 只在一路偶然靠前的候选,不会单靠绝对分尺度“爆掉”
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph paths["各路有序结果"]
|
||||
P1[dense ranks]
|
||||
P2[BM25 ranks]
|
||||
P3[filtered ranks · 可选]
|
||||
end
|
||||
|
||||
P1 --> RRF["RRF(d) = Σ 1/(k + rank_i)"]
|
||||
P2 --> RRF
|
||||
P3 --> RRF
|
||||
RRF --> OUT[融合序 · 不依赖原始分尺度]
|
||||
|
||||
L2[L2 原分] -.->|不直接相加| X[避免]
|
||||
BM[BM25 原分] -.-> X
|
||||
```
|
||||
|
||||
### 6.2 为什么适合 RAG 多路融合
|
||||
|
||||
RRF 特别适合下面这种现实约束:
|
||||
|
||||
```text
|
||||
dense 用 L2
|
||||
bm25 用 BM25
|
||||
filtered / unfiltered 虽同度量,但候选集合不同
|
||||
暂时没有可靠的分数校准器
|
||||
```
|
||||
|
||||
它把问题从:
|
||||
|
||||
```text
|
||||
如何把不可比的分数对齐?
|
||||
```
|
||||
|
||||
简化成:
|
||||
|
||||
```text
|
||||
各路是否都认为它靠前?
|
||||
```
|
||||
|
||||
### 6.3 加权 RRF:w_i 是路权,不是文档分
|
||||
|
||||
加权形式:
|
||||
|
||||
```text
|
||||
RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||
```
|
||||
|
||||
这里的 `w_i` 非常容易被误解。
|
||||
|
||||
#### 正确理解
|
||||
|
||||
```text
|
||||
w_i = 第 i 整路的话语权
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
w_dense = 1.0
|
||||
w_bm25 = 1.5 # 想让 BM25 更重要,就提高这一路的 w
|
||||
w_filtered_dense = 1.0
|
||||
```
|
||||
|
||||
含义是:
|
||||
|
||||
- BM25 这一路投出的“名次票”更值钱
|
||||
- 并不是把某个文档的 BM25 原始分 12.7 直接拿来和向量分相加
|
||||
|
||||
#### 错误理解
|
||||
|
||||
```text
|
||||
先算 keyword boost 得到一个大杂烩分
|
||||
再把这个分塞进 RRF
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```text
|
||||
RRF = f(L2原始分, BM25原始分, keyword加分)
|
||||
```
|
||||
|
||||
这都不是加权 RRF。
|
||||
|
||||
### 6.4 分层心智模型
|
||||
|
||||
建议始终按三层理解分数角色:
|
||||
|
||||
```text
|
||||
第 1 层:路内排序
|
||||
dense 路用向量分排序
|
||||
bm25 路用 BM25 分排序
|
||||
各用各的分,互不直接相加
|
||||
|
||||
第 2 层:跨路融合
|
||||
只吃各路 rank
|
||||
普通 RRF 或加权 RRF(w_i 为路权)
|
||||
|
||||
第 3 层:精排(可选)
|
||||
对融合后的 topM 再打分
|
||||
这里才适合规则特征或 Cross-Encoder
|
||||
```
|
||||
|
||||
一句话记住:
|
||||
|
||||
> 原始分只负责路内排名;RRF 只负责跨路投票;w_i 只调节哪一路更值钱;精排才做最终挑剔。
|
||||
|
||||
### 6.5 手算例子
|
||||
|
||||
设:
|
||||
|
||||
```text
|
||||
k = 60
|
||||
w_dense = 1.0
|
||||
w_bm25 = 1.5
|
||||
```
|
||||
|
||||
候选 A:
|
||||
|
||||
- dense 第 1 名
|
||||
- bm25 第 5 名
|
||||
|
||||
```text
|
||||
RRF(A)
|
||||
= 1.0/(60+1) + 1.5/(60+5)
|
||||
= 1/61 + 1.5/65
|
||||
≈ 0.01639 + 0.02308
|
||||
≈ 0.03947
|
||||
```
|
||||
|
||||
候选 B:
|
||||
|
||||
- dense 第 4 名
|
||||
- bm25 第 1 名
|
||||
|
||||
```text
|
||||
RRF(B)
|
||||
= 1.0/(60+4) + 1.5/(60+1)
|
||||
= 1/64 + 1.5/61
|
||||
≈ 0.01563 + 0.02459
|
||||
≈ 0.04022
|
||||
```
|
||||
|
||||
在这个设定下 B 更高,因为你主动提高了 BM25 路权,BM25 头名更吃香。
|
||||
|
||||
如果把 `w_bm25` 降到 `0.5`,同样名次下 dense 会重新占主导。
|
||||
这就是路权的意义:调的是整路话语权,不是某个文档的原始分公式。
|
||||
|
||||
### 6.6 w_i 怎么设
|
||||
|
||||
实操建议:
|
||||
|
||||
1. **起步全设 1.0**
|
||||
先看纯 RRF,不要一上来调花活。
|
||||
|
||||
2. **再按评测微调**
|
||||
- 专有名词/报错码总靠 BM25 才找得到,却总被 dense 压下去 → 提高 `w_bm25`(如 1.2~1.5)
|
||||
- BM25 噪声大、泛词乱入 → 降低 `w_bm25`(如 0.6~0.8)
|
||||
- filtered 很准但有时过窄 → 可与 unfiltered 同权,或略高一点
|
||||
|
||||
3. **经验范围**
|
||||
`w_i` 常见落在 `0.5 ~ 2.0`。
|
||||
若一路是 10、另一路是 0.1,基本等于放弃多路。
|
||||
|
||||
4. **没有回归集就不要谈“最优权重”**
|
||||
路权应来自离线评测,而不是长期拍脑袋。
|
||||
|
||||
---
|
||||
|
||||
## 7. 精排放在哪里:有了候选池,才配谈 Rerank
|
||||
|
||||
### 7.1 轻规则精排
|
||||
|
||||
在 RRF 融合出 top 10~15 后,可以用更稳的字段级规则做二次排序:
|
||||
|
||||
```text
|
||||
title 命中 > breadcrumb 命中 > body 覆盖
|
||||
精确术语 > 泛词
|
||||
runbook / case 来源轻微加分
|
||||
与已选证据过相似则降权(MMR 思路)
|
||||
```
|
||||
|
||||
注意:这里的规则特征,最好作用于 **融合后的候选精排**,而不是重新发明一套跨路原始分加法。
|
||||
|
||||
### 7.2 模型精排
|
||||
|
||||
当 `retrieve-k` 到 15~30,且评测证明融合后噪声仍高时,再考虑:
|
||||
|
||||
```text
|
||||
RRF topM
|
||||
-> Cross-Encoder / 托管 Rerank API
|
||||
-> 取 topN 给 Agent
|
||||
```
|
||||
|
||||
可选路线:
|
||||
|
||||
- 开源 reranker(如 bge-reranker 一类)
|
||||
- 云厂商 / Cohere 等托管 rerank
|
||||
- LLM listwise(贵且不稳,一般不当首选)
|
||||
|
||||
### 7.3 什么时候不要上模型 rerank
|
||||
|
||||
- 仍在 `K=3` 主路径上
|
||||
- 还没修 chunk 级去重
|
||||
- 还没有固定 query 回归集
|
||||
- 延迟和工具预算已经很紧
|
||||
|
||||
否则花的是精排成本,换不到召回质量。
|
||||
|
||||
---
|
||||
|
||||
## 8. 工程落地:比“换模型”更重要的顺序
|
||||
|
||||
### 8.1 建议的目标形态
|
||||
|
||||
```text
|
||||
Query
|
||||
├─ dense unfiltered top 20
|
||||
├─ dense filtered top 10 # 有唯一 domain 时
|
||||
└─ bm25 / keyword top 10 # 第二阶段
|
||||
│
|
||||
▼
|
||||
chunk 级去重(docId#chunkIndex)
|
||||
│
|
||||
▼
|
||||
RRF / 加权 RRF 融合
|
||||
│
|
||||
▼
|
||||
轻规则或模型精排,取 top 5
|
||||
│
|
||||
▼
|
||||
每文档 chunk 上限 + context pack
|
||||
│
|
||||
▼
|
||||
Agent projection
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Q[Query] --> U[dense unfiltered top20]
|
||||
Q --> F[dense filtered top10]
|
||||
Q --> B[bm25/keyword top10]
|
||||
U --> DEDUP[chunk 级去重<br/>docId#chunkIndex]
|
||||
F --> DEDUP
|
||||
B --> DEDUP
|
||||
DEDUP --> RRF[RRF / 加权 RRF]
|
||||
RRF --> RR[轻规则或模型精排 top5]
|
||||
RR --> CAP[每文档 chunk 上限 + pack]
|
||||
CAP --> AG[Agent projection]
|
||||
```
|
||||
|
||||
### 8.2 分阶段推进
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P0[Phase0<br/>chunk 去重<br/>retrieve-k/return-n] --> P1[Phase1<br/>filtered+unfiltered RRF]
|
||||
P1 --> P2[Phase2<br/>BM25 跨维度]
|
||||
P2 --> P3[Phase3<br/>可插拔精排]
|
||||
```
|
||||
|
||||
#### Phase 0:先修前提
|
||||
|
||||
否则后面多路都会被吞:
|
||||
|
||||
1. 去重 key 从“文档/source”改为 `docId + chunkIndex`(fallback 可用向量主键)
|
||||
2. 配置拆分:
|
||||
|
||||
```properties
|
||||
rag.retrieve-k=20
|
||||
rag.return-n=5
|
||||
rag.max-chunks-per-document=2
|
||||
```
|
||||
|
||||
3. 明确 baseScore(可用性阈值)与融合分/精排分(排序)职责分离
|
||||
|
||||
#### Phase 1:同模态双路径 + RRF
|
||||
|
||||
1. filtered dense 与 unfiltered dense 都产出候选
|
||||
2. 合并去重,不再整锅替换
|
||||
3. RRF 融合后取 topN
|
||||
4. trace 记录每路 rank 与 fused rank
|
||||
|
||||
这是最贴很多现有系统的一步,收益通常大于继续调 boost。
|
||||
|
||||
#### Phase 2:跨维度多路
|
||||
|
||||
1. 增加 BM25 / 关键词路
|
||||
2. 继续 RRF,必要时给 `w_bm25` 微调
|
||||
3. 增加字段级轻精排
|
||||
|
||||
#### Phase 3:可插拔模型 Rerank
|
||||
|
||||
```text
|
||||
interface Reranker {
|
||||
rerank(query, candidates, topN) -> rankedCandidates
|
||||
}
|
||||
```
|
||||
|
||||
实现可切换:
|
||||
|
||||
- `NoopReranker`
|
||||
- `RuleReranker`
|
||||
- `HttpCrossEncoderReranker`
|
||||
|
||||
让精排成为插件,而不是写死在业务里。
|
||||
|
||||
### 8.3 和 Agent 系统相关的额外约束
|
||||
|
||||
诊断 Agent 场景还有几个现实约束:
|
||||
|
||||
1. **工具预算有限**
|
||||
检索本身只是工具循环的一环,延迟不能无限涨。
|
||||
|
||||
2. **证据要可引用**
|
||||
最终给 Agent 的应是有界 excerpt,而不是内部全量 trace。
|
||||
|
||||
3. **投影会再裁一层**
|
||||
即便内部排序很细,Agent 可见字段仍可能只有 document/source/title/breadcrumb/excerpt。
|
||||
因此内部要保留完整 trace,外部保持契约稳定。
|
||||
|
||||
4. **安全发布与验真**
|
||||
排序再好,也不能绕过证据引用与 guard;RAG 优化的是“更可能拿到对的证据”,不是“让模型自由发挥”。
|
||||
|
||||
---
|
||||
|
||||
## 9. 评测:没有回归集,权重都是感觉
|
||||
|
||||
多路和 RRF 最怕“上线凭体感”。最少准备 15~30 条固定 query,覆盖:
|
||||
|
||||
- 标准故障词
|
||||
- 口语化换说法
|
||||
- 专有名词 / 错误码
|
||||
- 容易误判 domain 的问题
|
||||
- 同文档多 chunk 才完整的流程题
|
||||
- 负例:知识库本就没有答案
|
||||
|
||||
关注指标:
|
||||
|
||||
| 指标 | 看什么 |
|
||||
|---|---|
|
||||
| Recall@5 | 该出现的文档/chunk 是否进前 5 |
|
||||
| nDCG@5 或人工 0/1/2 | 排序是否把更相关的放前面 |
|
||||
| filter 误杀率 | 唯一 domain 是否经常害人 |
|
||||
| 同文档多 chunk 保留率 | 去重是否过粗 |
|
||||
| P95 延迟 | 多路是否打爆预算 |
|
||||
| 无证据正确率 | 不该有答案时是否老实说没有 |
|
||||
|
||||
路权 `w_i` 的调整,应建立在这些数上,而不是单次手工 query。
|
||||
|
||||
---
|
||||
|
||||
## 10. 反模式清单
|
||||
|
||||
下面这些做法看起来勤快,实际常把系统带偏:
|
||||
|
||||
1. **只在 K=3 上接昂贵 rerank**
|
||||
候选池不够,精排没有舞台。
|
||||
|
||||
2. **L2 和 BM25 直接加权相加**
|
||||
分数不可比,调参不可迁移。
|
||||
|
||||
3. **把 L0 contains 当最终裁判**
|
||||
词表质量绑死线上排序。
|
||||
|
||||
4. **文档级去重吞掉同文档多 chunk**
|
||||
多路召回也会在终点被自己吃掉。
|
||||
|
||||
5. **filtered 失败就整锅替换**
|
||||
丢掉本可保留的好结果。
|
||||
|
||||
6. **无评测调 w_i**
|
||||
今天的“最优权重”往往是过拟合某几条样例。
|
||||
|
||||
7. **L0 命中全文直接当高置信 evidence**
|
||||
导航信号被抬成事实,诊断场景尤其危险。
|
||||
|
||||
---
|
||||
|
||||
## 11. 可直接拿走的决策框架
|
||||
|
||||
遇到 RAG 排序问题时,按这个顺序问:
|
||||
|
||||
```text
|
||||
Q1. 正确答案是否经常连候选池都进不来?
|
||||
是 → 先扩召回 / 多路,不要先上复杂 rerank
|
||||
|
||||
Q2. 是否存在 filter 误杀?
|
||||
是 → filtered + unfiltered 双路径融合
|
||||
|
||||
Q3. 是否大量依赖专有名词、错误码、配置键?
|
||||
是 → 加 BM25 / 关键词路
|
||||
|
||||
Q4. 多路分数是否不可比?
|
||||
是 → RRF,而不是原始分硬加
|
||||
|
||||
Q5. 融合后 topM 仍噪声大,且 K 已经够大?
|
||||
是 → 再上规则精排或模型 rerank
|
||||
|
||||
Q6. 有没有固定回归集?
|
||||
没有 → 先建评测,再谈“最优权重”
|
||||
```
|
||||
|
||||
对应到一句话策略:
|
||||
|
||||
> **先扩召回,再用名次融合,最后才模型精排。**
|
||||
|
||||
---
|
||||
|
||||
## 12. 结语
|
||||
|
||||
RAG 排序讨论很容易变成模型名词竞赛。但在诊断 Agent 这种真实系统里,更常见的瓶颈是:
|
||||
|
||||
- 候选太少
|
||||
- 过滤过猛
|
||||
- 分数不可比
|
||||
- 去重过粗
|
||||
- 启发式加分承担了不该承担的最终裁决
|
||||
|
||||
RRF 的价值,不只是一个公式,而是一种工程态度:
|
||||
|
||||
```text
|
||||
承认各路分数不可比
|
||||
让每路先做好自己的排序
|
||||
再用名次投票决定谁更值得进入下游
|
||||
```
|
||||
|
||||
加权 RRF 也并不神秘:`w_i` 只是给整路调音量。
|
||||
想让 BM25 更有话语权,就提高 `w_bm25`;它不会、也不应该要求你先把 BM25 分和向量分校准到同一宇宙。
|
||||
|
||||
如果只记住三句:
|
||||
|
||||
1. **K=3 时,复杂 rerank 不是第一优先级。**
|
||||
2. **filtered + unfiltered 是值得做的同模态双路径;Dense + BM25 才是跨维度多路。**
|
||||
3. **跨路融合优先 RRF;L0 做导航,精排做挑剔,原始分不要跨路硬加。**
|
||||
|
||||
按这个脉络演进,通常比“继续把 keyword boost 调大一点”更接近稳定、可解释、可评测的 RAG 排序系统。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:术语对照
|
||||
|
||||
| 术语 | 含义 |
|
||||
|---|---|
|
||||
| L0 | 基于文档元数据/关键词的 query understanding 或弱召回 |
|
||||
| L1 | 向量语义召回 |
|
||||
| retrieve-k | 召回阶段候选数 |
|
||||
| return-n | 最终返回给 Agent 的证据数 |
|
||||
| baseScore | 向量相似度等主相关性分,常用于可用性阈值 |
|
||||
| finalScore / 精排分 | 用于排序的综合分 |
|
||||
| RRF | 基于名次的多路融合 |
|
||||
| w_i | 第 i 路在 RRF 中的路权 |
|
||||
| Rerank | 对已有候选做精排,不负责凭空召回新文档 |
|
||||
|
||||
## 附录 B:最小配置示例(示意)
|
||||
|
||||
```properties
|
||||
# 召回与返回分离
|
||||
rag.retrieve-k=20
|
||||
rag.return-n=5
|
||||
rag.max-chunks-per-document=2
|
||||
|
||||
# RRF
|
||||
rag.fusion.method=rrf
|
||||
rag.fusion.rrf-k=60
|
||||
rag.fusion.w-dense=1.0
|
||||
rag.fusion.w-dense-filtered=1.0
|
||||
rag.fusion.w-bm25=0.8
|
||||
|
||||
# 精排(先规则,后可插模型)
|
||||
rag.rerank.mode=rule # rule | model | off
|
||||
```
|
||||
|
||||
以上配置名是示意,重点在职责拆分,不在具体键名。
|
||||
|
||||
## 附录 C:和本文讨论直接对应的实现关注点
|
||||
|
||||
阅读或改造现有代码时,可重点核对:
|
||||
|
||||
1. 后处理是否把“规则 boost”命名成了 rerank,却未做候选扩展
|
||||
2. filtered 低质时是整锅替换,还是双路融合
|
||||
3. 去重 key 是 source/文档级,还是 chunk 级
|
||||
4. `topK` 是否同时承担召回、排序、返回三种职责
|
||||
5. 内部 `rerankTrace` 是否可观测,Agent 投影是否有意裁剪
|
||||
|
||||
这些点决定了:你写在黑板上的 RRF,能不能在系统里真正跑起来。
|
||||
@@ -0,0 +1,588 @@
|
||||
# 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 主路径对齐
|
||||
**读者**:要维护或扩展知识库回归评测的工程同学
|
||||
**关联实现 / 变更**:
|
||||
|
||||
- 目录:`eval/rag-retrieval/`
|
||||
- 脚本:`scripts/eval_rag_retrieval.py`、`generate_rag_lookup_snapshots.ps1`、`prepare_rag_eval_seed.ps1`
|
||||
- OpenSpec / devflow:`rag-eval-hybrid-baseline`(已归档)
|
||||
- 前置:`RAG-Hybrid质量分与后处理.md`、`RAG-Agent如何读relevance_level.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要单独谈评测
|
||||
|
||||
hybrid、chunk 去重、qualityScore 统一之后,工程上仍缺一块:
|
||||
|
||||
> **改检索之后,用什么可重复的信号判断「变好了还是变坏了」?**
|
||||
|
||||
完整诊断 E2E(多工具 + 最终回答)太重、太噪。需要一层**只盯 `lookup_knowledge` 召回与管道行为**的回归。
|
||||
|
||||
本文汇总讨论中形成的:
|
||||
|
||||
1. 离线评测是什么、不是什么
|
||||
2. Golden / Fixture 怎么设计、比什么
|
||||
3. 项目现状是否符合定义
|
||||
4. 改造复杂度与 sm-flow 落地(含 apply 中发现的闸门问题)
|
||||
5. 指标、报告、纪律
|
||||
|
||||
---
|
||||
|
||||
## 2. 离线评测:概念边界
|
||||
|
||||
### 2.1 离线 vs 在线
|
||||
|
||||
| 说法 | 含义 |
|
||||
|------|------|
|
||||
| **在线** | 真跑检索:embedding、Milvus、完整 `lookup_knowledge` |
|
||||
| **离线** | **不再访问检索栈**;用事先冻住的结果快照,和标准答案比对 |
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph online["在线(贵、真、偶发)"]
|
||||
S[Seed 语料] --> G[真 lookup / 检索管道]
|
||||
G --> F[写入 Fixtures]
|
||||
end
|
||||
|
||||
subgraph offline["离线(便宜、稳、可 CI)"]
|
||||
C[Golden cases] --> E[比对脚本]
|
||||
F2[已提交的 Fixtures] --> E
|
||||
E --> R[报告 / baseline / diff]
|
||||
end
|
||||
|
||||
F -.->|提交入库| F2
|
||||
```
|
||||
|
||||
可以记成:
|
||||
|
||||
```text
|
||||
在线:考试现场答题(环境会变)
|
||||
离线:用标准答卷复印件批改(环境冻结)
|
||||
```
|
||||
|
||||
### 2.2 离线适合 / 不适合回答的问题
|
||||
|
||||
**适合:**
|
||||
|
||||
- 契约有没有破(结构、关键字段、行为路径)
|
||||
- 在「同一份检索结果」假设下,期望文档/关键词/attempt 是否仍满足
|
||||
- 相对上一版 baseline 的回归 diff
|
||||
|
||||
**不适合单独承担:**
|
||||
|
||||
- hybrid 是否比 dense 更好 → 需要**同一时期**在线双跑
|
||||
- 阈值 0.75 是否合适 → 需要在线统计 level 分布
|
||||
- 换 embedding 后召回如何 → 必须重刷 fixture 或 live
|
||||
- Agent 最终诊断对不对 → 诊断 E2E
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q1[契约 / 期望回归] --> OFF[离线 baseline]
|
||||
Q2[当前召回是否正确] --> ON[在线生成 fixture 或 live]
|
||||
Q3[dense vs hybrid 增益] --> CMP[同期双 mode 对照]
|
||||
Q4[诊断是否正确] --> E2E[诊断 harness]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 三块积木
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph golden_box["① Golden(薄)"]
|
||||
GQ[query]
|
||||
GE[期望:doc / keyword / attempt / …]
|
||||
end
|
||||
|
||||
subgraph fixture_box["② Fixture(冻)"]
|
||||
FM[meta: caseId, searchMode, kbScope, time]
|
||||
FL[lookupResult 结构化输出]
|
||||
end
|
||||
|
||||
subgraph judge_box["③ 裁判(规则)"]
|
||||
A[按 golden 字段断言]
|
||||
M[衍生 Hit / rank / 通过率]
|
||||
REP[json + md + 可选 diff]
|
||||
end
|
||||
|
||||
golden_box -->|caseId 对齐| judge_box
|
||||
fixture_box --> judge_box
|
||||
```
|
||||
|
||||
| 积木 | 是什么 | 不是什么 |
|
||||
|------|--------|----------|
|
||||
| **Golden** | 问什么 + **应该**怎样 | 不是整包线上成功 JSON 原样当期望 |
|
||||
| **Fixture** | 某次跑完**实际**怎样 | 不是每次离线评测都要重跑检索 |
|
||||
| **裁判** | 关键字段比对 + 指标 + 报告 | 不是两个大 JSON deep equal |
|
||||
|
||||
时间线:
|
||||
|
||||
```text
|
||||
① 设计 Golden
|
||||
② (改检索 / 换库 / 换 mode 时)在线跑 → 写/更新 Fixtures
|
||||
③ 日常:Fixtures × Golden → 报告(多数时候只做这一步)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 为什么不全量字段对比
|
||||
|
||||
讨论中的直觉「分数不固定」是对的,但原因不止这一条:
|
||||
|
||||
| 原因 | 说明 |
|
||||
|------|------|
|
||||
| 分数不稳定 | L2 / RRF / embedding 会漂 |
|
||||
| 实现细节会变 | trace 结构、reason 文案、时间戳、tool_call_id |
|
||||
| 截断与预算会变 | excerpt 长度、packedText |
|
||||
| 目标是「对不对」 | 不是字节级一致 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
F[Fixture JSON] --> K[只抽取关键字段]
|
||||
G[Golden 期望] --> K
|
||||
K --> P{断言}
|
||||
P -->|通过| OK[Pass + 指标]
|
||||
P -->|失败| FAIL[Fail + 原因列表]
|
||||
|
||||
F -.->|不做| FULL[全量 deep equal]
|
||||
```
|
||||
|
||||
**全量对比**偶尔可用于「紧挨着两次生成器输出的工程 diff」,那不是 golden 质量标准。
|
||||
|
||||
---
|
||||
|
||||
## 5. Golden / Fixture 字段设计
|
||||
|
||||
### 5.1 Golden:一行长什么样(分层)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
ID[身份: caseId, scenario/tags, notes]
|
||||
IN[输入: query]
|
||||
P0[P0 命中: expectedDocIds/Sources, expectedKeywords]
|
||||
P1[P1 行为/展示: attempt, fallback, status, breadcrumb]
|
||||
P2[P2 细粒度: evidenceKey, minCount, firstRankMax]
|
||||
P3[P3 观察: relevanceLevel — 慎作硬门禁]
|
||||
NEG[负例: mustNotDocIds/Sources]
|
||||
|
||||
ID --> IN --> P0 --> P1
|
||||
P1 --> P2
|
||||
P1 --> NEG
|
||||
P1 --> P3
|
||||
```
|
||||
|
||||
| 优先级 | 比什么 | 作用 |
|
||||
|--------|--------|------|
|
||||
| P0 | docId / source、keywords | 召回对不对、段是否有用 |
|
||||
| P1 | selectedAttempt、fallback、evidenceStatus | 路径有没有坏 |
|
||||
| P1 | mustNot* | 硬负例 / decoy |
|
||||
| P2 | chunk / evidenceKey、条数、首条相关 rank | 身份与排序 |
|
||||
| P3 | relevance_level | 观察用;hybrid 下易松 |
|
||||
|
||||
**原则:** 期望对齐「用户/Agent 可感知的对错」,少锁实现细节。
|
||||
|
||||
### 5.2 Fixture:最少保留什么
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph fix["fixture"]
|
||||
META["meta<br/>caseId, query, retrievedAt<br/>searchMode, kbScope?"]
|
||||
RES["result / lookupResult<br/>有序 evidence[]<br/>attempt / fallback / status<br/>contextPack? / traces?"]
|
||||
DBG["debug 可选<br/>rawScore, denseDistance…"]
|
||||
end
|
||||
|
||||
META --> RES
|
||||
RES --> DBG
|
||||
```
|
||||
|
||||
每条 evidence 最少:
|
||||
|
||||
```text
|
||||
docId 或可对齐的 source
|
||||
excerpt / content(关键词断言需要)
|
||||
顺序 = rank(数组下标即可)
|
||||
evidenceKey / chunkIndex(多 chunk case 需要)
|
||||
title / breadcrumb(按需)
|
||||
```
|
||||
|
||||
**故意不锁:** score 全文、完整 trace、tool_call_id、packedText 全文(除非单独立项)。
|
||||
|
||||
### 5.3 Golden → Fixture 取值对照
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph g["Golden"]
|
||||
g1[expectedDocIds]
|
||||
g2[expectedKeywords]
|
||||
g3[expectedSelectedAttempt]
|
||||
g4[expectedFallbackReason]
|
||||
g5[mustNotDocIds]
|
||||
end
|
||||
|
||||
subgraph f["Fixture"]
|
||||
f1[evidence[].docId/source]
|
||||
f2[evidence[].excerpt 拼接]
|
||||
f3[retrievalTrace.selectedAttempt]
|
||||
f4[retrievalTrace.fallbackReason]
|
||||
f5[evidence 全表扫描]
|
||||
end
|
||||
|
||||
g1 --> f1
|
||||
g2 --> f2
|
||||
g3 --> f3
|
||||
g4 --> f4
|
||||
g5 --> f5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 指标与报告
|
||||
|
||||
### 6.1 两层指标
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph gate["门禁主信号"]
|
||||
PASS[逐 case Pass/Fail]
|
||||
RATE[通过率 / 按 tag 通过率]
|
||||
end
|
||||
|
||||
subgraph quality["质量刻度(报告展示)"]
|
||||
HIT[Hit@n / hitLevel strong·medium·weak·miss]
|
||||
RANK[first relevant rank / MRR]
|
||||
KW[keyword coverage]
|
||||
FB[fallback rate]
|
||||
LV[level 直方图 — 观察]
|
||||
end
|
||||
|
||||
PASS --> RATE
|
||||
HIT --> RANK
|
||||
```
|
||||
|
||||
| 指标 | 含义 |
|
||||
|------|------|
|
||||
| **Pass/Fail** | golden 声明的 expected* 是否全部满足 |
|
||||
| **hitLevel** | strong / medium / weak / miss(项目已有) |
|
||||
| **Recall@K** | strong+medium 算命中 |
|
||||
| **firstExpectedRank** | 第一条期望文档的排名 |
|
||||
| **fallback / attempt** | 行为路径 |
|
||||
| **level 分布** | 宜观察,hybrid 下慎作硬门禁 |
|
||||
|
||||
### 6.2 报告长什么样
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
EVAL[离线评测] --> J[baseline.json<br/>机器可读]
|
||||
EVAL --> M[baseline.md<br/>人读表格]
|
||||
EVAL --> D[baseline-diff.*<br/>相对上一版]
|
||||
|
||||
J --> CI[CI / 脚本解析]
|
||||
M --> HUM[人看失败原因]
|
||||
D --> REV[改代码还是改期望]
|
||||
```
|
||||
|
||||
工程上的「得出结果」=:
|
||||
|
||||
1. **门禁**:must-pass 是否全绿
|
||||
2. **诊断**:谁红、红在哪类断言
|
||||
3. **趋势**:相对旧 baseline 变好还是变差
|
||||
|
||||
---
|
||||
|
||||
## 7. 项目现状审计(改造前)
|
||||
|
||||
讨论结论:**模型符合定义,内容偏旧(约 70%)**。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ok["已符合"]
|
||||
A1[golden × fixture × key-field]
|
||||
A2[seed + kb_scope=rag-eval]
|
||||
A3[Hit 分层 + recall + baseline diff]
|
||||
A4[离线不连库]
|
||||
end
|
||||
|
||||
subgraph gap["缺口"]
|
||||
B1[生成器仍传 vector-store.mode=spring]
|
||||
B2[fixture 无 searchMode/kbScope]
|
||||
B3[无 dense/hybrid 双目录对照]
|
||||
B4[无 mustNot / chunk 硬期望]
|
||||
B5[快照停在 boost 改序时代]
|
||||
end
|
||||
|
||||
ok --> gap
|
||||
```
|
||||
|
||||
| 维度 | 符合度 |
|
||||
|------|--------|
|
||||
| 三件套架构 | 高 |
|
||||
| 关键字段比对 | 高 |
|
||||
| 报告 / diff | 高 |
|
||||
| 与 hybrid 主路径同步 | 低(改造前) |
|
||||
| chunk / 负例 / mode 矩阵 | 弱或无 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 改造策略与复杂度
|
||||
|
||||
### 8.1 两刀切分
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
K1["第一刀(已落地)<br/>search.mode 接线<br/>fixture meta<br/>README<br/>重刷 hybrid baseline"]
|
||||
K2["第二刀(未做)<br/>fixtures/hybrid vs dense<br/>对照表<br/>golden tags/mustNot"]
|
||||
|
||||
K1 --> DONE[可门禁当前主路径]
|
||||
K2 --> CMP[可回答 hybrid 增益]
|
||||
```
|
||||
|
||||
**复杂度判断:中低。** 不必重写框架;成本在联调环境与 baseline 纪律,不在算法。
|
||||
|
||||
| 工作 | 复杂度 |
|
||||
|------|--------|
|
||||
| 改生成参数 / meta / README | 低 |
|
||||
| seed + 重刷 fixture | 中低(看环境) |
|
||||
| 双目录对照 | 中低(第二刀) |
|
||||
| 换框架 / LLM judge | 高(不建议现在) |
|
||||
|
||||
### 8.2 sm-flow 落地范围(已确认)
|
||||
|
||||
- **仅第一刀**
|
||||
- **接线必交**;fixture 刷新尽力(本次环境可用,已刷绿)
|
||||
|
||||
Change:`rag-eval-hybrid-baseline`(已归档)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 落地后的主链路(当前)
|
||||
|
||||
### 9.1 日常离线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GC[golden-cases.json] --> PY[eval_rag_retrieval.py]
|
||||
FX[fixtures/*.json] --> PY
|
||||
PY --> BR[reports/baseline.*]
|
||||
```
|
||||
|
||||
```bash
|
||||
python scripts/eval_rag_retrieval.py
|
||||
```
|
||||
|
||||
### 9.2 改检索后的完整环
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Eng as 工程师
|
||||
participant Seed as prepare_rag_eval_seed
|
||||
participant Gen as generate_rag_lookup_snapshots
|
||||
participant Tool as LookupKnowledgeTool
|
||||
participant Off as eval_rag_retrieval.py
|
||||
participant Git as 仓库 baseline
|
||||
|
||||
Eng->>Seed: 导入 seed-docs (kb_scope=rag-eval)
|
||||
Seed-->>Eng: MySQL/L0/Milvus 就绪
|
||||
Eng->>Gen: -SearchMode hybrid
|
||||
Gen->>Tool: 每条 golden.query
|
||||
Tool-->>Gen: LookupResult
|
||||
Gen->>Gen: 写 fixture + searchMode/kbScope
|
||||
Eng->>Off: fixtures × golden
|
||||
Off-->>Eng: pass/fail + 指标
|
||||
Eng->>Git: 意图变更则更新 baseline(带 diff 原因)
|
||||
```
|
||||
|
||||
### 9.3 生成器配置(改造后)
|
||||
|
||||
| 参数 | 默认 | 含义 |
|
||||
|------|------|------|
|
||||
| `SearchMode` | `hybrid` | `retrieval.search.mode` |
|
||||
| `KbScope` | `rag-eval` | 评测语料隔离 |
|
||||
| (已删除) | — | `vector-store.mode=spring\|sdk` |
|
||||
|
||||
```powershell
|
||||
.\scripts\prepare_rag_eval_seed.ps1
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 # hybrid
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode dense -Fixtures ... -SkipEval # 对照用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Apply 中发现的关键问题:filter fallback 与 quality 闸门
|
||||
|
||||
### 10.1 现象
|
||||
|
||||
重刷 hybrid fixtures 后,`chat-l0-filter-fallback` 变红:
|
||||
|
||||
- 只命中 decoy(`overfilter-decoy`)
|
||||
- `selectedAttempt=FILTERED_VECTOR`,**没有** unfiltered retry
|
||||
- 根因:hybrid **纯 rank→quality** 时 rank1 恒为 ~1.0 → `isLowQuality` 永不成立
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph before["纯 rank quality(有问题)"]
|
||||
H1[hybrid RRF 序] --> R1[rank1 quality=1.0]
|
||||
R1 --> N1[isLowQuality=false]
|
||||
N1 --> X1[不 retry · decoy 留下]
|
||||
end
|
||||
|
||||
subgraph after["denseDistance 闸门(已修)"]
|
||||
H2[hybrid RRF 序 · 排序不变] --> D2[并行 dense 填 denseDistance]
|
||||
D2 --> Q2[quality = L2 归一化]
|
||||
Q2 --> L2{top quality < 0.5?}
|
||||
L2 -->|是| RET[UNFILTERED_VECTOR_RETRY]
|
||||
L2 -->|否| KEEP[保留 filtered 结果]
|
||||
end
|
||||
```
|
||||
|
||||
### 10.2 设计取舍(必须记清)
|
||||
|
||||
| 信号 | 用途 |
|
||||
|------|------|
|
||||
| **RRF / originalRank** | **排序权威**(谁在前) |
|
||||
| **denseDistance → quality** | **绝对质量闸门**(要不要 retry、level 档) |
|
||||
| **不**再:用 L2 覆盖 hybrid 主分 / label | 避免回到「伪装成 L2」 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph sort["排序"]
|
||||
RRF[RRF 返回序]
|
||||
end
|
||||
|
||||
subgraph gate["质量闸门"]
|
||||
L2[dense L2 若有]
|
||||
RK[rank 回退若无 dense]
|
||||
L2 --> QS[qualityScore]
|
||||
RK --> QS
|
||||
end
|
||||
|
||||
RRF --> LIST[evidence 列表顺序]
|
||||
QS --> LV[relevance_level]
|
||||
QS --> FB[isLowQuality → filter fallback]
|
||||
```
|
||||
|
||||
这与 quality 统一文的精神一致:**排序与闸门分信号**;只是 hybrid 闸门不能**只**靠序数分。
|
||||
|
||||
验证(归档时):
|
||||
|
||||
- offline **7/7 pass**
|
||||
- fallback case:`UNFILTERED_VECTOR_RETRY` + `filtered_vector_low_quality`
|
||||
|
||||
---
|
||||
|
||||
## 11. Baseline 纪律
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
RED[离线变红] --> Q{实现退步还是预期变了?}
|
||||
Q -->|退步| CODE[改代码]
|
||||
Q -->|预期变了| GOLD[改 golden / 重刷 fixture]
|
||||
GOLD --> NOTE[写清原因 · 更新 baseline]
|
||||
Q -->|禁止| BLIND[不看 diff 整锅覆盖]
|
||||
```
|
||||
|
||||
README 原话仍然成立:fixture 对不上,要么修链路,要么改期望——**二选一要显式**。
|
||||
|
||||
---
|
||||
|
||||
## 12. 与完整评测体系的位置
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph L1["L1 离线契约 — 已有且已对齐 hybrid"]
|
||||
OFF[fixtures × golden]
|
||||
end
|
||||
|
||||
subgraph L2["L2 在线召回 — 生成器已接线"]
|
||||
LIVE[seed → snapshot hybrid]
|
||||
end
|
||||
|
||||
subgraph L3["L3 对照与标定 — 部分未做"]
|
||||
DD[dense vs hybrid 双目录表]
|
||||
CAL[level/阈值直方图标定]
|
||||
end
|
||||
|
||||
subgraph L4["L4 诊断 E2E — 另一套"]
|
||||
DIAG[多工具 · 最终回答]
|
||||
end
|
||||
|
||||
L1 --> L2
|
||||
L2 --> L3
|
||||
L2 -.-> L4
|
||||
```
|
||||
|
||||
| 层 | 状态 |
|
||||
|----|------|
|
||||
| L1 离线 | **已落地**,hybrid fixtures + baseline 绿 |
|
||||
| L2 在线生成 | **已接线**,本机已成功重刷 |
|
||||
| L3 双 mode 对照目录 | **未做**(第二刀) |
|
||||
| L4 诊断 E2E | 独立 harness,非本文 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 实践清单
|
||||
|
||||
1. **日常**:只跑离线 `eval_rag_retrieval.py`。
|
||||
2. **改检索 / 索引 / mode / 闸门**:seed → hybrid 生成 → 离线 → 看 diff 再更新 baseline。
|
||||
3. **对照 dense**:`-SearchMode dense` 指到另一 fixtures 目录(第二刀可产品化报表)。
|
||||
4. **Golden** 锁业务真值;**score / 完整 trace** 默认不锁。
|
||||
5. **relevance_level** 先观察,慎作硬门禁。
|
||||
6. **排序听 RRF**;**retry/level 听绝对 quality(dense L2)**。
|
||||
7. 评测语料固定 `kb_scope=rag-eval`,勿绑生产杂库。
|
||||
|
||||
---
|
||||
|
||||
## 14. 结语
|
||||
|
||||
离线评测不是「再造一个复杂平台」,而是:
|
||||
|
||||
```text
|
||||
固定问题(Golden)
|
||||
× 冻结答卷(Fixture)
|
||||
× 关键字段裁判
|
||||
→ 可 diff 的报告
|
||||
```
|
||||
|
||||
项目原本骨架正确;本轮补上了 **hybrid 时代的生成接线、fixture meta、baseline 重刷**,并在真实跑通时修正了 **「序数 quality 杀死 filter fallback」** 的闸门设计。
|
||||
|
||||
下一有价值的增量是 **dense/hybrid 同期对照表(第二刀)** 与 **level 分布标定**,而不是换评测框架。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:目录与命令速查
|
||||
|
||||
| 路径 | 作用 |
|
||||
|------|------|
|
||||
| `eval/rag-retrieval/cases/golden-cases.json` | Golden |
|
||||
| `eval/rag-retrieval/fixtures/*.json` | Fixtures(含 searchMode) |
|
||||
| `eval/rag-retrieval/seed-docs/` | 评测语料 |
|
||||
| `eval/rag-retrieval/reports/baseline.*` | 离线基线报告 |
|
||||
| `scripts/eval_rag_retrieval.py` | 离线裁判 |
|
||||
| `scripts/generate_rag_lookup_snapshots.ps1` | 在线生成 fixture |
|
||||
| `scripts/prepare_rag_eval_seed.ps1` | 导入 seed |
|
||||
|
||||
```powershell
|
||||
# 离线
|
||||
python scripts\eval_rag_retrieval.py
|
||||
|
||||
# 完整刷新(需 embedding + Milvus 等)
|
||||
.\scripts\prepare_rag_eval_seed.ps1
|
||||
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode hybrid
|
||||
```
|
||||
|
||||
## 附录 B:相关文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| `eval/rag-retrieval/README.md` | 操作说明(以仓库为准) |
|
||||
| `RAG-Hybrid质量分与后处理.md` | quality / 后处理 |
|
||||
| `RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
|
||||
| `RAG排序-多路召回与RRF.md` | 多路与 RRF |
|
||||
| `devflow/projects/2026-07-28-rag-eval-hybrid-baseline/` | 本 change 档案 |
|
||||
| `openspec/specs/rag-eval-offline-baseline/spec.md` | 主规格 |
|
||||
@@ -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 校验 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user