Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83193bdf4a | ||
|
|
de56551fea | ||
|
|
da18fdf4e1 | ||
|
|
e1b8d1fb2c | ||
|
|
074d1aa5a9 | ||
|
|
f26d395650 | ||
|
|
ff0752a16c | ||
|
|
7844bcea40 |
@@ -0,0 +1,140 @@
|
||||
# Harness LLM Judge 设计笔记:从不可信判定到可信裁决
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:SemanticGuard + EvidenceRepair + GuardModelCall = LLM-as-a-judge 模式在证据安全链的完整落地(面试问答版)
|
||||
**代码位置**:`src/main/java/com/superbiz/agent/harness/guard/semantic/` + `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java`
|
||||
|
||||
## 1. 定位:三个角色
|
||||
|
||||
```text
|
||||
SemanticGuard → 典型 LLM judge:判「结论是否被已验证证据支持」,输出 verdict + reason
|
||||
EvidenceRepair → judge 的修复延伸(rewriter):验真失败后「只修引用、不修结论」
|
||||
GuardModelCall → 受控 LLM 调用底座:judge 类调用的基础设施(共用)
|
||||
```
|
||||
|
||||
## 2. 面试五段式回答稿(完整叙事)
|
||||
|
||||
### ① 动机(先讲问题,不报组件名)
|
||||
|
||||
> 我们的证据安全链里有一道「机械验真」——检查模型引用的每条证据是不是真实来自工具结果,这个用规则就能做。但光验真不够:模型可能引用真实的证据,结论却是「站不住」的——比如证据只支持 A 场景,它却拿去支撑 B 结论。这个「结论被没被证据支持」是**语义判断**,规则引擎做不了,必须靠模型。所以我们需要一个「裁判模型」来判——但裁判模型本身是不可信的,它可能乱判、可能输出奇怪的形状、可能跑很久。所以核心问题是:**怎么让一个不可信的模型做可信的判定**。
|
||||
|
||||
### ② 决策(方案 + 放弃了什么)
|
||||
|
||||
> 我的方案是:用**隔离的轻量判定模型**——单轮、无工具、输出被强约束,跟主 Agent 的循环完全分离。这里放弃了两条路:第一,让主 Agent 自己判——不行,它已经写了自己的结论,有偏向;第二,纯规则判——语义判断规则做不到。同时有个关键决策:**判读的输入是「视图」不是原始内容**——裁判只看到用户将看到的内容和已验证证据,看不到内部 id 这些实现细节,防止信息污染影响裁判的客观性。
|
||||
|
||||
### ③ 实现(关键机制)
|
||||
|
||||
> 三个关键机制:
|
||||
> **输入视图化**:把 draft 投影成「用户可见视图」再交给裁判,剥离内部引用 id;
|
||||
> **输出硬校验**:裁判的输出必须是恰好两个字段——verdict 和 reason,verdict 必须是合法枚举,reason 不能为空。多一个字段都不接受——我们不信任模型输出的形状,只信它在一个极小的空间里做选择;
|
||||
> **受控调用**:裁判跑在独立线程、有硬超时、Run 取消能强杀它、它的输入输出都计入预算和 Token 账本——裁判的花费不是无底洞,它也是 Run 的一部分。
|
||||
|
||||
### ④ 边界(诚实说不做什么)
|
||||
|
||||
> 裁判不判「内容对不对」——那是事实问题,由证据链负责;裁判不自己调工具,单轮无工具;裁判有硬截止线,超时就放弃判定;裁判失败走降级,**不阻塞主结论的发布路径**——我们宁可没有裁决,也不让裁决失败卡死整个流程。
|
||||
|
||||
### ⑤ 30 秒话术
|
||||
|
||||
> "LLM judge 的完整设计:**动机**是结论的支持度是语义判断、规则做不了,但裁判模型不可信,所以核心是让不可信的模型做可信的判定。**方案**是隔离的轻量判定模型——单轮、无工具、强约束输出。三个关键机制:输入视图化(裁判只看用户可见内容,防信息污染)、输出硬校验(恰好 {verdict, reason} 两字段,多一个都不接受)、受控调用(独立线程、硬超时、取消强杀、计预算记账)。**边界**:裁判不判事实、不调工具、超时即放弃、失败走降级不阻塞主路径。总结一句话——judge 不是追加一个模型调用,而是把『不可信判定』关进笼子里:限定输入、锁死输出、受控运行、失败降级。"
|
||||
|
||||
## 3. 追问应对大全
|
||||
|
||||
### Q1:为什么 judge 不判事实?(最容易混的边界)
|
||||
|
||||
```text
|
||||
分工:事实由证据链保证,judge 只判「支持关系」
|
||||
事实真伪 → 证据来自真实工具结果 + EvidenceGuard 验引用真实(根在数据源)
|
||||
支持关系 → SemanticGuard 判结论与证据的逻辑/相关性
|
||||
|
||||
judge 判不了事实的三个原因:
|
||||
① 没有事实源——它只看「视图 + 已验证证据」,不能查库,判事实只能猜
|
||||
② 事实真伪需要权威源复核(真实值在哪),judge 拿不到
|
||||
③ 如果 judge 判事实,它成了第二个事实来源——两个来源可能打架
|
||||
|
||||
例子 1(judge 能判的——判支持不是判真伪):
|
||||
证据:mysql 返回 count(*)=1000;结论:「user 表有 2000 条」
|
||||
EvidenceGuard 验引用真实 → 通过;SemanticGuard → UNSUPPORTED(数字不一致)
|
||||
注意:judge 不知道真实值是多少,它只发现「结论与证据不一致」
|
||||
|
||||
例子 2(支持关系成立,但事实未必对):
|
||||
证据:慢查询日志显示 DB 全表扫描;结论:「延迟由 DB 全表扫描导致」
|
||||
SemanticGuard → SUPPORTED(逻辑上站得住)
|
||||
但真实原因可能是网络抖动——judge 判不了(没有网络数据源)
|
||||
→ judge 只能保证「在现有证据下结论站得住」,不能保证「事实就是如此」
|
||||
|
||||
一句话:EvidenceGuard 保证「引用的证据是真的」,SemanticGuard 保证
|
||||
「基于这些证据结论说得通」——事实的真伪从来不是 judge 的职责。
|
||||
```
|
||||
|
||||
### Q2:为什么重试 2 次(semanticGuard 策略)?
|
||||
|
||||
```text
|
||||
可重试性分析:语义审查单轮、无副作用(幂等)——多试几次不会造成破坏
|
||||
但也不能无限重试:判定有硬截止线(总超时耗尽即放弃)
|
||||
→ 2 次 = 一次失败的成本 × 收益的平衡点;judge 失败走 Fallback,不影响主路径
|
||||
```
|
||||
|
||||
### Q3:语义不变性怎么保证(EvidenceRepair)?
|
||||
|
||||
```text
|
||||
双重锁死:prompt(只能改 analysis_id / tool_call_ids / based_on_analysis_ids 三字段)
|
||||
+ SemanticDraftView.hasSameUserVisibleSemantics(修复前后逐字段比对)
|
||||
|
||||
关键:比的是「用户可读的内容」不是内部引用 id——
|
||||
Conclusion 只比 text,不比 basedOnAnalysisIds
|
||||
Action 只比 action + requiresHumanConfirmation,不比 basedOnAnalysisIds
|
||||
→ 引用 id 允许变(这正是修复目标),用户看到的文字不许动(碰了判 SCHEMA_INVALID 重试)
|
||||
```
|
||||
|
||||
### Q4:judge 判错了怎么办?
|
||||
|
||||
```text
|
||||
judge 不是最终真相源,是「安全链的一道闸」:
|
||||
① judge 判 UNSUPPORTED → 不发布,走 Fallback(宁可保守)
|
||||
② judge 判 SUPPORTED 但事实错 → 那是事实问题,不在 judge 职责(见 Q1)
|
||||
③ judge 自身失败 → 降级(不阻塞主路径)
|
||||
→ 设计哲学:judge 的角色是「挡住明显不成立的结论」,不是「证明结论正确」
|
||||
```
|
||||
|
||||
### Q5:为什么独立线程 + 单独超时?
|
||||
|
||||
```text
|
||||
judge 调用不能阻塞主流程(主 Agent 循环)
|
||||
独立线程 + future.get(timeout) = 硬超时截断
|
||||
Run 取消 → future.cancel(true) 强杀在途判定(judge 也是 Run 的一部分)
|
||||
```
|
||||
|
||||
### Q6:输入为什么视图化?
|
||||
|
||||
```text
|
||||
judge 只看该看的:用户可见内容 + 已验证证据
|
||||
剥离内部 id(tool_call_id 等)——防止 judge 用内部信息做「看起来合理」的裁决
|
||||
(信息污染:judge 看到内部 id 可能产生不当关联,或泄露内部结构到裁决)
|
||||
```
|
||||
|
||||
## 4. 通用 LLM Judge 设计要素(可迁移)
|
||||
|
||||
| 通用要素 | 本项目实现 |
|
||||
|---|---|
|
||||
| 判什么(judgment task) | 结论是否被已验证证据支持 |
|
||||
| 输入视图(该看什么) | SemanticDraftView(剥离内部 id)+ 已验证证据 |
|
||||
| 输出约束(schema) | 恰好 {verdict, reason} + 枚举合法 + reason 非空 |
|
||||
| 硬校验 | 字段集 equals({verdict, reason})——不多不少 |
|
||||
| 隔离 | 单轮、无工具、独立线程——judge 不能自己调工具 |
|
||||
| 硬超时 | 每次 attempt 剩余超时递减,总超时耗尽即放弃 |
|
||||
| 重试策略 | 可重试性分析:单轮无副作用 → 2 次 |
|
||||
| 失败降级 | judge 失败 → Fallback(不卡死主路径) |
|
||||
| 可审计 | ModelCallLedger 记账 + semanticAttempt/evidenceRepairAttempt trace |
|
||||
| 成本控制 | 输入/输出字节限制 + reserveRunBytes 计入 Run 预算 |
|
||||
|
||||
## 5. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `GuardModelCall` | `src/main/java/com/superbiz/agent/harness/guard/semantic/GuardModelCall.java` |
|
||||
| `SemanticGuard` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticGuard.java` |
|
||||
| `SemanticDraftView` | `src/main/java/com/superbiz/agent/harness/guard/semantic/SemanticDraftView.java` |
|
||||
| `SemanticGuardInput` / `SemanticGuardLimits` | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
|
||||
| `EvidenceRepair` | `src/main/java/com/superbiz/agent/harness/release/EvidenceRepair.java` |
|
||||
| `EvidenceRepairLimits` / `EvidenceRepairPrompt` | `src/main/java/com/superbiz/agent/harness/release/` |
|
||||
| 重试策略(semanticGuard/evidenceRepair) | `src/main/java/com/superbiz/agent/harness/retry/HarnessRetryPolicies.java` |
|
||||
@@ -0,0 +1,158 @@
|
||||
# Harness MySQL 沙箱学习笔记:从 SQL 校验到脱敏投影
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:query_mysql 工具完整链路——三层防线(语义/连接/输出)
|
||||
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(注册/调用/执行全链路)
|
||||
|
||||
## 1. 定位:可查询、不可破坏、不可越界、不可拖库
|
||||
|
||||
query_mysql 让模型查询授权数据库,但封死三种攻击面:
|
||||
|
||||
```text
|
||||
破坏:写/删/改(非 SELECT)→ 语义层拒绝
|
||||
越界:未授权表/列 → 白名单拒绝
|
||||
拖库:全表通配(*)/无界读取 → 禁通配符 + 三重有界截断
|
||||
```
|
||||
|
||||
**为什么 MySQL 要安全层而 RAG 不要**:Milvus 天然只读检索无破坏面;MySQL 直接连数据库,SELECT 之外全是风险面——**安全设计随攻击面走**。
|
||||
|
||||
## 2. 架构总览(三层防线 + 接线员)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E["MysqlToolAdapter<br/>接线员"] -->|"parse 请求"| V["第 1 层 语义层<br/>MysqlSqlValidator<br/>AST fail-closed"]
|
||||
V -->|"MysqlQueryPlan"| X["第 2 层 连接层<br/>JdbcMysqlReadOnlyExecutor<br/>JDBC 只读+超时+取消"]
|
||||
X -->|"MysqlRawResult<br/>(Harness-only)"| P["第 3 层 输出层<br/>MysqlResultProjector<br/>脱敏+有界"]
|
||||
P -->|"MysqlToolResult<br/>(冻结契约)"| B["ToolBoundary<br/>统一门禁"]
|
||||
```
|
||||
|
||||
**接线员**:Adapter 把 validator/executor/projector 组装进 ToolBoundary;任何安全/参数异常统一映射 INVALID_REQUEST(不泄露内部细节)。
|
||||
|
||||
## 3. 定义层(6 个小文件)
|
||||
|
||||
| 类 | 作用 |
|
||||
|---|---|
|
||||
| `MysqlReadOnlyExecutor` | 函数式接口(执行端口):plan + RunContext → raw 结果 |
|
||||
| `MysqlQueryPlan` | 执行计划:request + dataSource + normalizedSql + params(params 深拷贝) |
|
||||
| `MysqlRawResult` | Harness-only raw 结果:columns + rows + truncated(不直接给 Agent) |
|
||||
| `MysqlSecurityException` | 安全异常:Adapter 映射稳定错误码 |
|
||||
| `MysqlToolLimits` | 限额:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时 |
|
||||
| `MysqlDataSourceDefinition` | 逻辑数据源 + **schema → table → 列三级白名单**(访问边界) |
|
||||
|
||||
**白名单深拷贝**(TreeSet 保证确定性)+ `defaultSchema 必须出现在白名单里`——配置即边界。
|
||||
|
||||
## 4. 第 1 层:Validator——语义层(fail-closed)
|
||||
|
||||
### 4.1 禁止清单(JSqlParser 解析 AST,逐节点拒绝)
|
||||
|
||||
```text
|
||||
非 SELECT / 多条语句 / WITH / 子查询(SubSelect) / 通配符投影(* 和 t.*)
|
||||
窗口函数(Analytic) / CASE / EXISTS / 分层查询(OracleHierarchical)
|
||||
锁读(FOR UPDATE / SKIP LOCKED) / OFFSET/FETCH/TOP/SKIP/FIRST/OPTIMIZE FOR
|
||||
字面量:StringValue/LongValue/DoubleValue/HexValue/DateValue/TimeValue/TimestampValue
|
||||
—— 全部必须参数化(防注入的最强形态)
|
||||
```
|
||||
|
||||
### 4.2 允许清单
|
||||
|
||||
```text
|
||||
白名单内表列的 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER)
|
||||
聚合函数:COUNT/SUM/AVG/MIN/MAX(COUNT(*) 只允许 COUNT)
|
||||
占位符参数 ?(PreparedStatement 绑定)
|
||||
```
|
||||
|
||||
### 4.3 附加校验
|
||||
|
||||
```text
|
||||
恰好一条语句 + 必须是 Select + 无 WITH + 普通 PlainSelect(禁集合操作/值语句)
|
||||
FROM 必须是白名单内实体表(禁子查询来源);重复别名拒绝(不区分大小写)
|
||||
列校验:限定表 → 查该表列白名单;未限定 → 已注册表恰好一个命中(防歧义/未授权)
|
||||
占位符数量 == params 数量(防参数错位/少传)
|
||||
```
|
||||
|
||||
### 4.4 fail-closed 原则
|
||||
|
||||
```text
|
||||
任何解析/校验异常 → 统一转 MysqlSecurityException(默认拒绝,不是默认放行)
|
||||
业务规则违规原样穿出;解析/未知异常包装统一信息(不泄露内部细节)
|
||||
→ 安全策略是「拒绝清单外的全允许」的反面:「允许清单外的全拒绝」
|
||||
```
|
||||
|
||||
## 5. 第 2 层:Executor——连接层(双保险)
|
||||
|
||||
```text
|
||||
connection.setReadOnly(true) ← JDBC 连接层强制只读(语义层之外的物理防线)
|
||||
PreparedStatement 参数化 ← 占位符绑定(防注入第二道)
|
||||
setQueryTimeout(5s) ← 慢查询截断
|
||||
setMaxRows(maxRows+1) ← 多取一行用于检测截断
|
||||
context.cancellation().onCancel → statement.cancel() ← Run 取消联动
|
||||
checkRun 每行检查 ← 取消/超时即刻中止(与 core 终态联动)
|
||||
jsonSafe:byte[] → Base64;字符串截断 maxCellChars
|
||||
estimatedBytes:字节预算超限移除末行并标记截断
|
||||
```
|
||||
|
||||
**关键**:查询不是独立资源——**受 Run 生命周期管**(取消 → 立即 cancel 语句),与 RAG 检索同理(checkRun 与 Harness core 终态联动)。
|
||||
|
||||
## 6. 第 3 层:Projector——输出层
|
||||
|
||||
### 6.1 脱敏(输出时,不是查询时)
|
||||
|
||||
```text
|
||||
列名含 password/passwd/token/secret/api_key/apikey/credential → [REDACTED]
|
||||
→ raw 保留真实值,只对 Agent 可见层脱敏(查询照常执行,输出才遮)
|
||||
```
|
||||
|
||||
### 6.2 有界(三重截断 + 兜底)
|
||||
|
||||
```text
|
||||
行数(maxRows) + 单元格字符(maxCellChars) + 总字节(maxResultBytes)
|
||||
列名必须非空且唯一(防歧义投影)
|
||||
fitBudget 兜底:逐行裁掉尾部 → 裁空诚实降级 NO_EVIDENCE → 仍超限 fail closed
|
||||
```
|
||||
|
||||
### 6.3 客观证据语义
|
||||
|
||||
```text
|
||||
rows 空 → NO_EVIDENCE;非空 → EVIDENCE_FOUND
|
||||
→ 与 RAG 的 evidence_status 同一套契约(证据状态由「有没有内容」客观决定)
|
||||
```
|
||||
|
||||
## 7. 与 RAG 对照(同类架构,不同复杂度)
|
||||
|
||||
| | query_mysql | lookup_knowledge |
|
||||
|---|---|---|
|
||||
| 安全层 | 有(Validator + 只读连接 + 脱敏) | 无(Milvus 天然只读检索) |
|
||||
| 后端复杂度 | 简单(Validator→Executor→Projector) | 复杂(三段 + 降级 + 双 trace) |
|
||||
| raw → 契约 | MysqlRawResult → MysqlToolResult | LookupResult → RagToolResult |
|
||||
| 数量限制 | maxRows=100 / 64KB | returnN=5 / 8 条 / 16KB |
|
||||
| 证据语义 | rows 空不空(NO_EVIDENCE/EVIDENCE_FOUND) | evidenceBlocks + relevance_level |
|
||||
| 与 Harness 衔接 | 同为 Boundary/Projector/Adapter 模式 | 同为 Boundary/Projector/Adapter 模式 |
|
||||
|
||||
## 8. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Validator 只查 SELECT | 还有白名单表列、禁字面量、禁通配符、占位符计数 |
|
||||
| 语义层够了 | 连接层 setReadOnly + 参数化是物理防线(纵深防御) |
|
||||
| 查询独立于 Run | 查询受 Run 取消/超时联动(onCancel → statement.cancel) |
|
||||
| 脱敏在查询层 | 脱敏在投影层(raw 保留真实值,只对 Agent 脱敏) |
|
||||
| 有界只限行数 | 行 + 单元格 + 字节三重截断 + fitBudget 兜底 |
|
||||
| 安全异常抛原样 | 统一映射 INVALID_REQUEST(不泄露内部细节) |
|
||||
| 字面量可以清洗放行 | 字面量全拒必须参数化(清洗是弱防线,参数化是强防线) |
|
||||
|
||||
## 9. 面试话术(30 秒)
|
||||
|
||||
> "query_mysql 是三层防线的只读沙箱:**语义层**(JSqlParser 解析 AST,fail-closed 拒绝一切不安全形态——非 SELECT、多语句、子查询、通配符、字面量、未授权表列、锁读全部拒绝,只允许白名单表列的 INNER/LEFT JOIN 和聚合 + 参数化占位符,且占位符数量必须与 params 匹配);**连接层**(JDBC setReadOnly + PreparedStatement 参数化 + 超时 + maxRows + 取消联动——Run 取消立即 cancel 语句);**输出层**(敏感列脱敏 [REDACTED] + 行/单元格/字节三重截断 + rows 空不空定证据状态)。安全异常统一映射稳定错误码,不泄露内部细节。"
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `MysqlToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/MysqlToolAdapter.java` |
|
||||
| `MysqlSqlValidator` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlSqlValidator.java` |
|
||||
| `JdbcMysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java` |
|
||||
| `MysqlResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlResultProjector.java` |
|
||||
| `MysqlDataSourceDefinition` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlDataSourceDefinition.java` |
|
||||
| `MysqlReadOnlyExecutor` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlReadOnlyExecutor.java` |
|
||||
| `MysqlQueryPlan` / `MysqlRawResult` | `src/main/java/com/superbiz/agent/harness/tool/mysql/MysqlQueryPlan.java` 等 |
|
||||
| 契约(MysqlToolRequest/Result) | `src/main/java/com/superbiz/agent/harness/tool/contract/MysqlTool*.java` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# Harness RAG 检索体系学习笔记:从 query 到可验证证据
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:lookup_knowledge 完整后端链路——检索前/检索/检索后/打包/组装/降级/契约/验证
|
||||
**设计文档**:`mvp/engineering/rag/`(RAG 排序、Hybrid 质量分、relevance_level 等)
|
||||
**代码视角**:[Harness tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链衔接)
|
||||
|
||||
## 1. 定位与骨架
|
||||
|
||||
一次 `lookup_knowledge` 从 query 到证据的旅程(模块化三段 + 收尾):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Q["query"] --> A["检索前<br/>KnowledgeQueryTransformer<br/>L0 导航(分类/域/关键词)"]
|
||||
A --> B["检索<br/>KnowledgeDocumentRetriever<br/>dense + BM25 → RRF 融合"]
|
||||
B --> C["检索后<br/>KnowledgeEvidencePostProcessor<br/>qualityScore/去重/判级/闸门"]
|
||||
C -->|"低质"| B2["降级重试<br/>UNFILTERED_VECTOR_RETRY<br/>(去过滤 + 原始 query)"]
|
||||
C --> D["打包<br/>KnowledgeContextPacker"]
|
||||
D --> E["组装<br/>LookupResultAssembler<br/>→ LookupResult"]
|
||||
E --> F["投影<br/>RagResultProjector<br/>→ RagToolResult(Agent 契约)"]
|
||||
```
|
||||
|
||||
**关键特征**:模块化三段各自独立 Service、降级是阶段间控制流、双轨可观测(RetrievalTrace + RerankTrace)、LookupResult 是内部契约(Agent 看到的是投影后的 RagToolResult)。
|
||||
|
||||
## 2. 检索前:L0 导航(缩短边界,不决定边界)
|
||||
|
||||
- **产出**:categoryFilter / domainHints / matchedKeywords / entities / l0Titles(从 query 语义推导)
|
||||
- **只缩短边界**:categoryFilter 限定「搜哪些分类」(FILTERED_VECTOR)
|
||||
- **不决定边界**:低质 → 降级去掉过滤重查(L0 边界可被推翻)
|
||||
- **只解释不打分**:L0 命中只写 hitReasons(l0_domain_overlap),不改 qualityScore 和排序——防关键词碰瓷
|
||||
- **语义差异应对**:降级用原始 query(非 rewritten)——抹掉 L0 推导误差
|
||||
|
||||
## 3. 检索:多路召回 + RRF
|
||||
|
||||
**为什么混合检索**:旧方案(dense 语义 + L0 关键词加权重排)有词频碰瓷误差——词频高但相关性不高的排前面。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 召回
|
||||
D["dense ANN(L2)<br/>抓语义相似"]
|
||||
S["sparse BM25<br/>抓精确匹配"]
|
||||
end
|
||||
D --> R["RRF 融合<br/>score = Σ w/(k + rank)"]
|
||||
S --> R
|
||||
R --> F["融合排序(originalRank)"]
|
||||
```
|
||||
|
||||
**关键决策**:
|
||||
- **RRF 用排名不用分数**——屏蔽跨路分数尺度不可比(dense 的 L2 vs BM25 的稀疏分)
|
||||
- **k=60**(`retrieval.hybrid.rrf-k`)——平滑参数,排名差异对分数的影响平缓
|
||||
- **加权是预留能力**:RrfFusion 支持 `w/(k+rank)`,但当前 Milvus 服务端走等权 RRFRanker(只传 k)——想让某路更可信时再调旋钮
|
||||
- **召回优先**(RAG 排序文档观点):候选池只有 3 条时,精排只能换座位,召不回的内容永远排不上来
|
||||
|
||||
## 4. 检索后:qualityScore 统一 + 质量闸门
|
||||
|
||||
### 4.1 为什么需要统一分数
|
||||
|
||||
三路返回三种分数(L2 距离 / BM25 稀疏分 / RRF 融合分)——不可比,必须统一成 qualityScore ∈ [0,1]。
|
||||
|
||||
### 4.2 打分(RetrievalScoreNormalizer)
|
||||
|
||||
```text
|
||||
DENSE: l2ToQuality(score) = 1 - clamp(L2)/maxL2 (maxL2 默认 2.0)
|
||||
HYBRID: denseDistance != null ? l2ToQuality(denseDistance) ← 恢复绝对质量
|
||||
: rankToQuality(rank, batchSize) ← BM25-only 保守回退
|
||||
```
|
||||
|
||||
**denseDistance 的来源**(隐藏机制):hybrid 融合后**再单独跑一次 searchDense**,按 id 把 L2 补到融合结果上——因为服务端 RRF 只输出融合分,原始 L2 信息丢了。`attachDenseDistances` 只填充不改 score/label/order(排序评估分离的又一体现)。
|
||||
|
||||
### 4.3 排序与评估分离(核心设计)
|
||||
|
||||
```text
|
||||
originalRank(RRF 融合序)→ 排序:谁在前面(相对序)
|
||||
qualityScore(L2/rank) → 评估:够不够格、要不要降级(绝对度)
|
||||
→ 排序不用 quality 重排(防 boost 操纵)
|
||||
→ quality 只被判级和闸门消费
|
||||
```
|
||||
|
||||
**为什么排名不能证明质量**:排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)——排第 1 只代表「这批里最好」,不代表「够好」(候选池全是低质时排第 1 的也低质);RRF 分本身不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别。
|
||||
|
||||
### 4.4 五步流程(process)
|
||||
|
||||
```
|
||||
① 打分(toQualityScore)→ ② 排序(originalRank,不用 quality 重排)
|
||||
③ 去重/截断:evidenceKey 合并 + maxChunksPerDocument=2 + returnN=5
|
||||
④ 判级:top qualityScore ≥0.75→PRECISE / ≥0.5→REFERENCE
|
||||
⑤ 闸门:topSimilarity <0.5 → 低质 → 降级重查
|
||||
```
|
||||
|
||||
**两级去重**(粒度不同):
|
||||
```
|
||||
evidenceKey 去重(chunk 级):同 chunk(docId#chunkIndex)被两路召回 → mergeEvidence 合并
|
||||
—— mergeEvidence 只合并 hitReasons + 补 breadcrumb,不处理 content(同 chunk 内容相同)
|
||||
maxChunksPerDocument(文档级):同文档不同 chunk 最多 2 个 → 防单文档垄断证据槽位
|
||||
```
|
||||
|
||||
**判级用 ranked(全量)不是 deduped**——判级评估「整体质量」(全量 top),去重决定「输出内容」(合并片段),两件事平行。
|
||||
|
||||
**BM25-only 的弱点**:无 dense 邻居 → rankToQuality 回退(排第 1 恒为 1.0)——整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)。改善方向:文本相似度兜底(绝对信号)+ 批次一致性检查(整体水平),而非返回 BM25 分(统计度量无绝对语义)。
|
||||
|
||||
## 5. 契约语义:relevance_level 是什么、不是什么
|
||||
|
||||
- **是什么**:一次 lookup_knowledge 调用整体有多相关的**粗档标签**(判级产出:PRECISE/REFERENCE/null)
|
||||
- **不是什么**:不是单条 evidence 的分数、不是相似度数值、不是「结论可发布」判据
|
||||
- **Agent 正确用法**:evidence_status 管有没有证据,relevance_level 管这批评据多硬/要不要再查,实际写诊断引用的是 evidence[].excerpt
|
||||
- **REFERENCE ≠ NO_GAIN**:一般相关可能仍排除一个假设——语义价值由模型判断(progress 原则)
|
||||
|
||||
## 6. 打包与组装
|
||||
|
||||
### ContextPacker(打包)
|
||||
|
||||
```
|
||||
输入顺序即优先级 → 逐条塞进 4000 字符预算
|
||||
单条放不下 → content 截断(+"...")
|
||||
连 header 都放不下 → 整条省略(记 omittedSources)
|
||||
输出:packedText + strategy + charBudget/usedChars + included/omittedSources
|
||||
```
|
||||
|
||||
**当前定位**:Agent 主要看结构化 evidence 列表,packedText 更多用于内部/调试/审计(Harness 用结构化列表因为可验真——evidence_ref 引用 document_id)。
|
||||
|
||||
### LookupResultAssembler(组装)
|
||||
|
||||
- **内部契约出口**:found / evidenceBlocks / 双数量 / 双 trace / relevanceLevel / completenessHint / message
|
||||
- **found 在这里综合判定**:evidence.hasUsableEvidence()
|
||||
- **message 语义**:found=false 时「知识库未检索到可用证据,请结合日志、指标、告警继续排查」——证据不足不是失败,是换方向引导
|
||||
- **与投影的关系**:LookupResult 是后端完整出口,RagResultProjector 再裁剪成 Agent 契约(两层契约)
|
||||
|
||||
## 7. 降级:突破 L0 边界的兜底
|
||||
|
||||
```
|
||||
触发:categoryFilter != null && isLowQuality(无证据 或 topSimilarity < 0.5)
|
||||
动作:原始 query(非 rewritten)+ 去掉分类过滤 + 覆盖选择(不合并两轮)
|
||||
原因分类:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(完全没查到)
|
||||
有限降级:只一次(防重查风暴);retry 低质也接受(降级失败直接返回)
|
||||
trace:attempts 记录全部尝试(FILTERED/UNFILTERED/UNFILTERED_RETRY)
|
||||
+ selectedAttempt + fallbackReason → 可对比「过滤 vs 全域」判断 L0 过滤是否过度
|
||||
```
|
||||
|
||||
## 8. 投影衔接(RagResultProjector)
|
||||
|
||||
- **契约转换**:LookupResult JSON → RagToolResult(evidence[] + relevance_level + evidence_status + truncated)
|
||||
- **四重有界**:query 500 字 / excerpt 1200 字 / 条数 8(maxEvidence)/ 总字节 16KB(fitBudget)
|
||||
- **优先级链去重**:evidenceKey → document_id → docId#chunk-idx → legacy 序号(chunk 级身份)
|
||||
- **诚实标记**:一切有损(截断/去重丢弃/query 截断)都置 truncated;fitBudget 裁空诚实降级 NO_EVIDENCE + relevance 置 null(没有证据就没有相关度,自洽)
|
||||
- **两级数量限制**:后端 returnN=5(业务目标值)vs maxEvidence=8(Harness 护栏)——5<8 时护栏休眠,后端配置失控时兜底
|
||||
|
||||
## 9. 验证:审计 + 离线评测
|
||||
|
||||
### 9.1 审计 vs Trace(两套记录系统)
|
||||
|
||||
| | DiagnosisTrace(事件流) | ToolInvocationAudit(调用档案) |
|
||||
|---|---|---|
|
||||
| 粒度 | 事件(一次调用多个事件) | 记录(一次调用一行) |
|
||||
| 覆盖 | Run 全生命周期 | 仅工具调用 |
|
||||
| 内容 | metadata-only(不存 raw) | 完整(raw + agentResult + enrichments) |
|
||||
| 用途 | 时序回放 / Token 对账 | 单次调用深查 |
|
||||
|
||||
```
|
||||
RAG 后端双 trace(Retrieval/Rerank)→ 进 LookupResult → Harness
|
||||
→ 安全字段提取 → tool_invocation 审计(enrichments)
|
||||
→ 轻量事件 → DiagnosisTrace
|
||||
审计在投影之后、给模型之前(ToolBoundary.execute 内先落库)
|
||||
```
|
||||
|
||||
### 9.2 离线评测设计(eval/rag-retrieval)
|
||||
|
||||
```
|
||||
三层:offline(fixtures × golden-cases,无真实栈)/ snapshot 生成(真实跑一次冻结)/ live smoke
|
||||
断言:行为契约(expectedDocIds/Breadcrumbs/Keywords/SelectedAttempt/FallbackReason/EvidenceStatus)
|
||||
+ 不断言 raw scores / chunkId / 全序(脆或内部实现)
|
||||
基线:baseline.json + diff(区分有意改进 vs 无意回归)
|
||||
隔离:seed-docs + kb_scope=rag-eval
|
||||
```
|
||||
|
||||
**断言设计原则**:
|
||||
- 断言「用户可感知的结果 + 管道行为」,不断言「实现细节」(chunk/分数/全序)
|
||||
- 双信号:fail 抓行为回归 + diff 抓「绿了但漂了」(通过但退化可见)
|
||||
- **期望来自设计意图,不从当前输出反推**(否则固化 bug)
|
||||
- golden set 是演进的:初期种子(设计意图)→ 中期真实数据(**必须人工验证**——成功案例只是观测,不是契约)→ 持续事故固化
|
||||
|
||||
### 9.3 指标(缺的下一步)
|
||||
|
||||
```
|
||||
检索质量:recall@k / Hit@k / MRR(复用 expectedDocIds + rank,低成本)
|
||||
管道行为:降级触发正确率 / 降级有效率 / 过滤误伤率(复用 trace 字段)
|
||||
质量闸门:低质识别准确率 / 降级误杀率(BM25-only 弱信号代价可测)
|
||||
需要新标注:precision@k(负例)/ nDCG(相关度分级)
|
||||
```
|
||||
|
||||
## 10. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| RRF 排序了就不用打分 | 排序(RRF)与评估(qualityScore)分离——RRF 管谁在前,L2 管够不够格 |
|
||||
| 排第 1 = 质量好 | 排第 1 只代表「这批里最好」——候选池全低质时排第 1 也低质 |
|
||||
| 返回 BM25 分能解决 BM25-only | BM25 是统计度量(无界/依赖集合),无绝对语义——用文本相似度/一致性检查 |
|
||||
| denseDistance 是融合分 | 是融合后再跑一次 dense 探测的 L2(RRF 丢了原始 L2) |
|
||||
| 去重和判级有先后 | 平行:去重管输出(deduped),判级管评估(ranked 全量 top) |
|
||||
| 后端 returnN=5,maxEvidence=8 多余 | returnN 是业务目标值,maxEvidence 是 Harness 护栏(防配置失控) |
|
||||
| 线上成功案例可直接当 golden | 成功只是「当前实现没出错」的观测——必须人工确认设计意图 |
|
||||
| 审计在给模型之后 | 审计在投影后、给模型前(ToolBoundary 内先落库) |
|
||||
|
||||
## 11. 讨论沉淀:值得记住的问题与洞见
|
||||
|
||||
本节收录学习过程中的关键问答——按价值分层,面试准备直接翻这里。
|
||||
|
||||
### 11.1 触及设计本质(第一梯队)
|
||||
|
||||
**① 「RRF 已经排序了,为什么还要打分」——排序与评估分离**
|
||||
|
||||
```text
|
||||
RRF 管「谁在前面」(融合排序,相对序)
|
||||
qualityScore 管「够不够格」(质量评估,绝对度)
|
||||
|
||||
为什么排名不能证明质量:
|
||||
排名是「序」(A 在 B 前),质量是「度」(0.75 就是 0.75)
|
||||
排第 1 只代表「这批里最好」,不代表「够好」——候选池全是低质时排第 1 也低质
|
||||
RRF 分不含距离信息;跨批次的两个「第 1 名」绝对质量天差地别
|
||||
```
|
||||
|
||||
**② 「最终落地到 dense 决定,RRF 白用了吗」——谁被评估 vs 评估够不够**
|
||||
|
||||
```text
|
||||
RRF/BM25 管:召回 + 排序(BM25 路召回 dense 召不回的候选,RRF 让两路共识靠前)
|
||||
dense L2 管:质量评估的绝对标尺(唯一有绝对语义的)
|
||||
→ 分工:RRF 决定「谁能被评估」,dense 决定「评估结果够不够」
|
||||
→ 「排第一但 dense 低质 → 降级」不是矛盾,是排序与评估分离的价值(发现域选错)
|
||||
```
|
||||
|
||||
**③ 「能不能返回 BM25 分当质量」——度量类型决定能否设阈值**
|
||||
|
||||
```text
|
||||
几何度量(L2):embedding 空间稳定 → 能设 0.75/0.5 绝对阈值
|
||||
统计度量(BM25):无界、依赖集合 IDF、随集合演进漂移 → 设不了稳定阈值
|
||||
→ 质量评估需要绝对标尺,只能来自几何度量或可解释相似度(字符重叠)
|
||||
→ 改善 BM25-only:文本相似度兜底 / 批次一致性检查 / fail-closed,而非返回 BM25 分
|
||||
```
|
||||
|
||||
**④ 「BM25-only 质量有问题」(自己发现的设计弱项)**
|
||||
|
||||
```text
|
||||
rank 回退:排第 1 恒为 1.0 → 整批 BM25-only 时闸门永不降级(topSimilarity=1.0 ≥ 0.5)
|
||||
根因:rank 是相对序(第 1 名不代表够 0.5),顶位给满分是「排序最好 = 质量满分」的错误等价
|
||||
改进:rank 顶位保守化 / 文本相似度兜底 / 批次一致性检查
|
||||
```
|
||||
|
||||
### 11.2 隐藏机制(第二梯队)
|
||||
|
||||
**⑤ 「denseDistance 是融合分还是 dense 分」**
|
||||
|
||||
```text
|
||||
是 dense 那一路的 L2——RRF 服务端融合只输出融合分,原始 L2 信息丢了
|
||||
→ attachDenseDistances 融合后再单独跑一次 searchDense,按 id 把 L2 补到融合结果
|
||||
→ 只填充不改 score/label/order(排序评估分离的又一体现)
|
||||
```
|
||||
|
||||
**⑥ 「为什么降级只一次」——有限降级**
|
||||
|
||||
```text
|
||||
降级 = 突破 L0 边界重查(原始 query + 去过滤 + 覆盖选择)
|
||||
只降级一次:预算约束(retrieveK × 2 检索成本)防重查风暴
|
||||
fallbackReason 区分:FALLBACK_LOW_QUALITY(查到了但低质)/ FALLBACK_NO_EVIDENCE(没查到)
|
||||
```
|
||||
|
||||
**⑦ 「returnN=5 为什么 maxEvidence=8」——目标值 vs 护栏**
|
||||
|
||||
```text
|
||||
returnN=5:RAG 业务目标值(rag.return-n)——打算给 5 条
|
||||
maxEvidence=8:Harness 安全上限(ToolProjectionLimits)——最多允许多少
|
||||
两级解耦:业务层和安全层各自配置;5<8 时护栏休眠,后端配置失控时兜底
|
||||
```
|
||||
|
||||
### 11.3 方法论沉淀(第三梯队,可迁移)
|
||||
|
||||
**⑧ 从 0 设计离线评测的八步**
|
||||
|
||||
```text
|
||||
目标(回归保护)→ 粒度(工具级)→ 输入(冻结快照)→ 断言(行为契约)
|
||||
→ 用例(行为维度覆盖)→ 隔离(种子数据)→ 基线(区分有意/无意变化)→ 成本(分层运行)
|
||||
```
|
||||
|
||||
**⑨ golden set 怎么设计**
|
||||
|
||||
```text
|
||||
行为清单 → 每个行为一个 case → query 拟真(能触发目标行为)
|
||||
→ 期望来自设计意图(不从当前输出反推——否则固化 bug)→ 补负例/边界
|
||||
→ 演进:初期种子打底 → 中期真实数据(人工验证后转契约)→ 持续事故固化
|
||||
```
|
||||
|
||||
**⑩ 「线上成功案例能不能直接用」——观测 ≠ 契约**
|
||||
|
||||
```text
|
||||
线上成功只是「当前实现没出错」的观测:可能恰好没触发 bug 路径、结果碰巧对
|
||||
→ 必须人工确认「结果确实符合设计意图」后才从观测升级为契约
|
||||
→ 失败案例则明确「期望应该怎样」作回归保护
|
||||
```
|
||||
|
||||
**⑪ 「不用 chunk 断言也是数据原因吗」——不是**
|
||||
|
||||
```text
|
||||
chunk 边界是切分实现细节:算法优化/文档微调都让 chunk 偏移 → 合法重构被误判回归
|
||||
契约语义的证据单位是文档级(document_id 常等于 source)——chunk 模型都看不到
|
||||
→ 即使数据充足也不该断言 chunk(和数据量无关)
|
||||
```
|
||||
|
||||
### 11.4 三个核心洞见(最值得记住)
|
||||
|
||||
```text
|
||||
① 排序与评估分离:RRF 管「序」(相对),L2 管「度」(绝对)——排名不能证明质量
|
||||
② 度量类型决定能不能设阈值:几何(L2)可以,统计(BM25)不行
|
||||
③ 期望来自设计意图,不从实现反推——这是评测和 golden set 的分水岭
|
||||
```
|
||||
|
||||
## 12. 面试话术(30 秒)
|
||||
|
||||
### 11.1 排序与评估为什么分离
|
||||
|
||||
> "RRF 管『谁在前面』(融合排序),qualityScore 管『这批结果够不够格』(质量评估)——打分不是重排,是排序后的质量校验。RRF 分是排名派生的相对值,没法设绝对阈值(排第 1 不代表够 0.5,候选池全是低质时排第 1 的也低质);qualityScore 把 dense L2 归一化成 [0,1] 的绝对质量,用于判级(0.75/0.5 阈值)和闸门(<0.5 触发降级)。排序决定看哪些,评估决定够不够好。"
|
||||
|
||||
### 11.2 为什么 BM25 分不能当质量
|
||||
|
||||
> "质量评估需要绝对标尺,绝对标尺只能来自几何度量(L2 距离——embedding 空间稳定)或可解释的相似度(字符重叠),不能来自统计度量(BM25——无界、依赖文档集合的 IDF、随集合演进漂移)。BM25-only 命中用排名回退估质量(保守),但顶位给满分是设计弱项——改进方向是文本相似度兜底或批次一致性检查,而不是返回 BM25 分。"
|
||||
|
||||
### 11.3 降级设计
|
||||
|
||||
> "降级是突破 L0 过滤边界的兜底:带分类过滤检索结果低质(无证据或 topSimilarity<0.5)时,用原始 query + 去掉分类过滤重查一次(UNFILTERED_VECTOR_RETRY),结果覆盖选择、记录进 trace。fallbackReason 区分『查到了但低质』vs『完全没查到』;只降级一次(预算约束防重查风暴),降级失败也直接以低质结果返回。attempts 列表让审计能对比过滤 vs 全域检索差异,判断 L0 过滤是否过度。"
|
||||
|
||||
### 11.4 golden set 怎么设计
|
||||
|
||||
> "golden set 是行为契约的清单:先列要保护的行为,每个行为一个 case(不耦合可定位);query 用能触发目标行为的真实形态;期望来自设计意图(我知道这个文档属于这个场景),绝不从当前输出反推(否则固化 bug);补负例与边界;golden set 是演进的——初期人为种子打底,中期真实数据必须人工验证后才能转契约,持续事故修复固化。核心:断言用户可感知的结果 + 管道行为,不断言实现细节。"
|
||||
|
||||
## 13. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `LookupKnowledgeTool` | `src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java` |
|
||||
| `KnowledgeQueryTransformer` | `src/main/java/com/superbiz/agent/service/KnowledgeQueryTransformer.java` |
|
||||
| `KnowledgeDocumentRetriever` | `src/main/java/com/superbiz/agent/service/KnowledgeDocumentRetriever.java` |
|
||||
| `KnowledgeEvidencePostProcessor` | `src/main/java/com/superbiz/agent/service/KnowledgeEvidencePostProcessor.java` |
|
||||
| `KnowledgeContextPacker` | `src/main/java/com/superbiz/agent/service/KnowledgeContextPacker.java` |
|
||||
| `LookupResultAssembler` | `src/main/java/com/superbiz/agent/service/LookupResultAssembler.java` |
|
||||
| `RetrievalScoreNormalizer` | `src/main/java/com/superbiz/agent/service/retrieval/RetrievalScoreNormalizer.java` |
|
||||
| `RrfFusion` | `src/main/java/com/superbiz/agent/service/retrieval/RrfFusion.java` |
|
||||
| `MilvusHybridKnowledgeStore` | `src/main/java/com/superbiz/agent/service/milvus/MilvusHybridKnowledgeStore.java` |
|
||||
| `RagResultProjector` | `src/main/java/com/superbiz/agent/harness/tool/projection/RagResultProjector.java` |
|
||||
| 审计链路 | `src/main/java/com/superbiz/agent/harness/audit/`(RagLookupAuditEnricher / JpaToolInvocationAuditSink) |
|
||||
| 离线评测 | `eval/rag-retrieval/` + `scripts/eval_rag_retrieval.py` |
|
||||
@@ -0,0 +1,203 @@
|
||||
# Harness Tool 调用链:一次工具调用的完整旅程
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:从「模型决定调用工具」到「模型收到观察」的运行时完整链路——拦截器 → invoke → Adapter → ToolBoundary → 返回 → 二次加工 → ToolCallResponse
|
||||
**结构篇**:[Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(讲装配/注册/静态结构)
|
||||
**本文**:动态时序(一次调用怎么跑完)
|
||||
|
||||
## 1. 旅程全景(一张图)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as 模型
|
||||
participant F as 框架 ReactAgent
|
||||
participant I as HarnessToolInterceptor(per-Run)
|
||||
participant ET as HarnessEvidenceTools(单例)
|
||||
participant AD as RagToolAdapter(单例)
|
||||
participant TB as ToolBoundary(单例)
|
||||
participant P as DiagnosisProgressTracker
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over M,I: 阶段 A:模型决定 → 拦截器(执行前)
|
||||
M->>F: 输出 tool_call(工具名 + 参数 JSON)
|
||||
F->>I: 回调 interceptToolCall(request, handler)
|
||||
I->>I: ① supports 注册检查
|
||||
I->>ET: ② parse(typed 严格契约)
|
||||
ET-->>I: ParsedAgentToolCall(previous_observation + input)
|
||||
I->>P: ③ 协议校验(pending 评价)+ 判重
|
||||
end
|
||||
|
||||
rect rgb(255, 250, 240)
|
||||
Note over I,TB: 阶段 B:invoke → 执行(backend + 投影)
|
||||
I->>ET: ④ invoke(context, toolName, toolCallId, args)
|
||||
ET->>AD: bridge 闭包 → adapter.execute(context, envelope)
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>TB: ⑤ 五阶段:preflight/预算/begin → executor 跑 backend → 校验 → projector 投影 → markReady
|
||||
TB-->>I: ToolBoundaryResult(READY/ERROR)
|
||||
end
|
||||
|
||||
rect rgb(245, 255, 245)
|
||||
Note over I,M: 阶段 C:返回 → 模型(执行后)
|
||||
I->>I: ⑥ 双源校验(controlView 重读 evidence_status)
|
||||
I->>P: ⑦ recordCompleted(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
|
||||
I->>I: ⑧ modelObservation 加工(有界观察 + stop_required/reason)
|
||||
I-->>F: ToolCallResponse.of(toolCallId, toolName, observation)
|
||||
F-->>M: observation 作为本轮 tool 结果
|
||||
end
|
||||
```
|
||||
|
||||
**三个阶段**:A 执行前(模型决定→门禁)→ B 执行中(invoke→backend→投影)→ C 执行后(校验→记账→成型)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段 A:模型决定 → 拦截器(执行前)
|
||||
|
||||
### 2.1 模型怎么知道有这个工具
|
||||
|
||||
```
|
||||
模型 → callbacks 里看到工具(名字+描述+Schema)→ 决定调用 lookup_knowledge
|
||||
→ 输出 tool_call JSON(工具名 + 参数)
|
||||
```
|
||||
|
||||
工具名是**模型决定的**——框架把模型输出包成 `ToolCallRequest`(含 toolName + arguments),回调拦截器。
|
||||
|
||||
### 2.2 拦截器的三道执行前门
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["① supports(toolName)?"] -->|"否(非证据工具)"| X["handler.call 透传"]
|
||||
A -->|"是"| B["② parse:typed 严格契约<br/>FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS"]
|
||||
B -->|"违规"| Y["协议处理(不执行)"]
|
||||
B --> C["③ 协议校验(pending 评价)+ 判重"]
|
||||
C -->|"重复"| Z["recordDuplicateScope(不执行)"]
|
||||
C -->|"通过"| D["进入阶段 B:invoke"]
|
||||
```
|
||||
|
||||
关键:**不是「拿到名字就执行」**——parse(模型输出必须精确匹配 `RagToolCall{previous_observation, input}`,多一个字段都炸 INVALID_ENVELOPE)、协议校验、判重,三道门不通过都不执行 backend。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段 B:invoke → 执行(backend + 投影)
|
||||
|
||||
### 3.1 invoke 的委托链
|
||||
|
||||
```
|
||||
I.invoke(context, "lookup_knowledge", "call-1", args)
|
||||
→ ET.invokers.get("lookup_knowledge") ← 注册表取 bridge 闭包
|
||||
→ bridge lambda:adapter.execute(context,
|
||||
new ToolCallRequestEnvelope(runId, "call-1", "lookup_knowledge", args, true, true))
|
||||
→ ragAdapter.execute(context, envelope)
|
||||
→ boundary.execute(context, envelope, executor, projector)
|
||||
```
|
||||
|
||||
**envelope 是 bridge 里现造的**:`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」。
|
||||
|
||||
### 3.2 Adapter 组装两个函数(接线员)
|
||||
|
||||
```java
|
||||
return boundary.execute(context, envelope,
|
||||
// executor:跑 backend 拿 raw(LookupResult 序列化成 JSON 文本)
|
||||
ignored -> objectMapper.writeValueAsString(legacyExecutor.execute(request.query())),
|
||||
// projector:raw → 有界契约 + evidenceStatus
|
||||
raw -> projector.project(request, envelope.toolCallId(), raw));
|
||||
```
|
||||
|
||||
| 端口 | 干什么 | 产物 |
|
||||
|---|---|---|
|
||||
| `executor` | 调具体后端 | rawResponse(JSON 文本,执行链「货币」) |
|
||||
| `projector` | 净化定型 | ProjectedToolResult(agentResult, evidenceStatus) |
|
||||
|
||||
**模型永远看不到 raw**——raw 只用于校验、落 canonical、投影。
|
||||
|
||||
### 3.3 ToolBoundary 五阶段
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
|
||||
B["② executor.execute(requestJson) → backend raw"]
|
||||
C["③ raw 大小校验 + Run bytes 预留"]
|
||||
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["⑤ agent_result 校验 + bytes → markReady(READY) 或 markError(ERROR)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
返回 `ToolBoundaryResult(READY/ERROR)`——PROJECTING 永不外泄。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段 C:返回 → 模型(执行后)
|
||||
|
||||
### 4.1 拦截器的二次加工(不是直接返回)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["ToolBoundaryResult"] --> B{"status == READY?"}
|
||||
B -->|"否"| E1["error observation<br/>BUDGET_EXHAUSTED 额外 markBudgetLimitReached"]
|
||||
B -->|"是"| C["⑥ 双源校验:controlView 重读 evidence_status"]
|
||||
C -->|"不一致"| E2["OBSERVATION_CONTRACT_MISMATCH 拒绝"]
|
||||
C -->|"一致"| D["⑦ recordCompleted<br/>NO_EVIDENCE → 立即 NO_GAIN<br/>FOUND → 挂 pending"]
|
||||
D --> F["⑧ modelObservation 加工<br/>(有界观察 + stop_required/reason)"]
|
||||
F --> G["ToolCallResponse.of(...) → 框架 → 模型"]
|
||||
```
|
||||
|
||||
### 4.2 双源校验(自洽性防线)
|
||||
|
||||
```text
|
||||
源1:result.evidenceStatus() ← Projector 投影时计算的声明值
|
||||
源2:controlView(agentResult).evidenceStatus() ← 从 agent_result 内容重读
|
||||
一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
|
||||
```
|
||||
|
||||
防止「声明有证据但内容空 / 声明无证据但内容有」的不一致状态进入 progress 记账。
|
||||
|
||||
### 4.3 给模型的对象形态
|
||||
|
||||
```
|
||||
ToolCallResponse.of(toolCallId, toolName, observation)
|
||||
observation = 有界观察:
|
||||
正常结果:脱敏后的契约内容(可能裁剪)
|
||||
饱和时: 附加 stop_required:true + reason
|
||||
协议错误:repair_required:true + violation_type/期望ID/指令
|
||||
```
|
||||
|
||||
框架把 observation 作为本轮 tool 结果给模型——**模型下一轮读取它,决定继续调用(带评价)还是输出 Draft 收尾**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 旅程的衔接点(模型视角的闭环)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["模型调工具"] --> B["观察(有界契约)"]
|
||||
B --> C{"模型决定"}
|
||||
C -->|"继续"| D["下次 Tool Call + previous_observation 评价"]
|
||||
C -->|"收尾"| E["输出 Draft → Release 发布"]
|
||||
D --> B
|
||||
```
|
||||
|
||||
**progress 协议的闭环**:模型每次继续调用,都要在 Envelope 里回带对上一轮的 GAINED/NO_GAIN 评价——这就是拦截器 ③ 校验的 pending 逻辑(可回看 progress 笔记)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键点总结
|
||||
|
||||
| 阶段 | 关键认知 |
|
||||
|---|---|
|
||||
| A 执行前 | 工具名是模型决定的;parse 是 typed 严格契约(输出必须匹配 Schema);三道门不通过不执行 |
|
||||
| B 执行中 | executor/projector 是 Adapter 组装进 boundary 的**参数**;执行链货币是 JSON 文本;模型永远看不到 raw |
|
||||
| C 执行后 | 拦截器不直接返回——双源校验 + progress 记账 + modelObservation 成型;ToolCallResponse 才是模型拿到的对象 |
|
||||
|
||||
## 7. 面试 30 秒说法
|
||||
|
||||
> "一次工具调用的完整旅程分三段:执行前,模型从 callbacks 看到工具并决定调用,拦截器做 supports 分流、typed 严格 parse、协议校验和判重——三道门不通过都不执行 backend;执行中,invoke 经 bridge 到 Adapter,Adapter 把 executor(跑 backend 拿 raw)和 projector(raw 投影成有界脱敏契约)组装进 ToolBoundary 的五阶段门禁,返回 ToolBoundaryResult;执行后,拦截器不直接返回——先双源校验 evidence_status,再 recordCompleted 记进度,再 modelObservation 加工成有界观察,最后包装成 ToolCallResponse 给模型。模型看到的永远是脱敏后有界的观察,raw 只进 canonical 供审计验真。"
|
||||
|
||||
## 8. 代码位置索引
|
||||
|
||||
| 环节 | 文件 |
|
||||
|---|---|
|
||||
| 拦截器(A/C 阶段) | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| 注册表 + parse + invoke | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| Adapter 组装(B 阶段) | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
|
||||
| 五阶段门禁 | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| 双源校验 + 观察成型 | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
|
||||
| 模型观察形态 | `src/main/java/com/superbiz/agent/harness/agent/ToolControlView.java` |
|
||||
@@ -0,0 +1,186 @@
|
||||
# Harness agent 域学习笔记:从框架 ReAct 接入到受控停止
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:agent 域完整链路——装配(Factory)/ 双拦截器(Model/Tool)/ 循环外壳(UseCase)/ 受控停止 / 双视图投影
|
||||
**配套**:[tool 域代码学习笔记](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)(工具链)、[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(Tool 拦截器五道门)
|
||||
|
||||
## 1. 定位:框架 ReAct 接入层(粘合点)
|
||||
|
||||
```text
|
||||
框架(spring-ai-alibaba ReactAgent):负责 ReAct 多轮(模型 ↔ tool_call)
|
||||
Harness:不复制 loop,只通过 Interceptor 卡住【每次消耗】
|
||||
→ Model Interceptor:每次模型调用(预算/审计/Token)
|
||||
→ Tool Interceptor:每次工具调用(五道门)
|
||||
|
||||
原则:拦截器是挂点,不是 loop 实现——Harness 不需要知道框架内部怎么循环
|
||||
```
|
||||
|
||||
## 2. 装配图(DiagnosisAgentFactory——粘合点)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 框架能力
|
||||
M["ChatModel"]
|
||||
T["tools<br/>evidenceTools.callbacks()"]
|
||||
L["ReactAgent 循环"]
|
||||
end
|
||||
subgraph Harness 控制面
|
||||
I1["HarnessModelInterceptor<br/>预算+Token 审计"]
|
||||
I2["HarnessToolInterceptor<br/>五道门+投影"]
|
||||
H["Hooks<br/>agent_step 落库"]
|
||||
O["outputSchema<br/>DiagnosisDraft(conclusion 可 null)"]
|
||||
end
|
||||
M --> L
|
||||
T --> L
|
||||
L --> I1
|
||||
L --> I2
|
||||
L --> H
|
||||
L --> O
|
||||
```
|
||||
|
||||
**关键装配决策**:
|
||||
|
||||
```text
|
||||
.parallelToolExecution(false) ← 串行工具:预算与 step 绑定可解释
|
||||
.returnReasoningContents(true) ← 推理内容返回
|
||||
.releaseThread(true)
|
||||
每次 run 新建 Agent(create(context))——拦截器持有 RunContext,不可跨 run 复用
|
||||
```
|
||||
|
||||
## 3. HarnessModelInterceptor(模型拦截器)
|
||||
|
||||
```text
|
||||
interceptModel(request, handler):
|
||||
core.beforeModelCall(context) ← 预算门(模型调用前扣预算)
|
||||
call = auditor.begin(...) ← 审计开始(Token 记账)
|
||||
response = handler.call(request) ← 框架实际调用
|
||||
recordUsage(call, response) ← 记 prompt/completion tokens
|
||||
core.checkActive(context) ← 终态检查(预算耗尽在此打断)
|
||||
return response
|
||||
异常:记 0 token + 上抛(不吞)
|
||||
```
|
||||
|
||||
**三个动作**:预算(beforeModelCall)→ 记账(auditor.begin/recordUsage)→ 终态(checkActive)——每次模型调用都被 Harness 卡住一次。Usage 字段 null/负数安全兜底(nonNegative)。
|
||||
|
||||
## 4. HarnessToolInterceptor(工具拦截器,已深学)
|
||||
|
||||
五道门(progress 会话已沉淀):证据工具必炸 handler → 拦截器唯一执行路径 → 边界投影 → 审计落库 → 返回。本会话只补装配视角:`interceptors` 列表里第二个,构造时注入 context + evidenceTools + objectMapper + traceRecorder。
|
||||
|
||||
## 5. DiagnosisAgentUseCase(循环外壳)
|
||||
|
||||
### 5.1 执行流程
|
||||
|
||||
```text
|
||||
execute(context, input):
|
||||
checkActive → 输入限制(query/previous_turn/input 字节)
|
||||
reserveRunBytes(input) ← 输入也占 Run 预算
|
||||
RunnableConfig.metadata 挂 RunContext ← 显式传递(避免隐式 ThreadLocal)
|
||||
agent = factory.create(context) ← 每次 run 新建
|
||||
response = agent.call(inputJson, config) ← ★ 框架跑完整个 ReAct 循环
|
||||
output → 字节限制 → reserveRunBytes(draft) → parse DiagnosisDraft
|
||||
→ completed(draft) 或 受控停止
|
||||
```
|
||||
|
||||
### 5.2 受控停止(controlledExecution)——从异常栈捞回可控信号转正常返回值
|
||||
|
||||
```text
|
||||
① DiagnosisCollectionStoppedException(信息饱和后仍强 tool)
|
||||
→ stopped(stopReason) ← 收集该停(draft=null)
|
||||
|
||||
② RunAbortedException + BUDGET_EXHAUSTED
|
||||
→ markBudgetLimitReached + stopped(BUDGET_LIMIT_REACHED)
|
||||
|
||||
③ 其他 RunAborted(取消/超时/内部失败终态)→ 原样再抛
|
||||
← 留给 Application 写 CANCELLED/FAILED(不降级为正常停止)
|
||||
|
||||
④ BudgetExceededException 或 lifecycle 已 BUDGET_EXHAUSTED → 预算 stopped
|
||||
|
||||
⑤ 都识别不了 → 包装 DiagnosisAgentOutputException(Agent 执行失败)
|
||||
```
|
||||
|
||||
**关键**:不是笼统「业务异常 → 正常」——**只识别 Harness 约定的可控信号**(沿 cause 链找,因框架可能再包一层);取消/超时必须上抛(诚实终态)。
|
||||
|
||||
### 5.3 与 recoverInvalidDraft 的分工
|
||||
|
||||
```text
|
||||
controlledExecution:loop 被预算/收敛打断(往往还没有合法 draft)→ stopped
|
||||
recoverInvalidDraft:loop 跑完了,但输出不是合法 DiagnosisDraft → 恢复/重试
|
||||
```
|
||||
|
||||
### 5.4 输出解析(严格)
|
||||
|
||||
```text
|
||||
draftReader = FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS(严格模式)
|
||||
JsonParseException → INVALID_JSON / SchemaInvalid → SCHEMA_INVALID(分类错误码)
|
||||
空输出 → EMPTY_DRAFT(分类错误码)
|
||||
```
|
||||
|
||||
## 6. 双视图投影(ToolResultViewProjector)
|
||||
|
||||
```text
|
||||
modelObservation() → 模型观察:只含该工具的内容字段
|
||||
lookup_knowledge → scope.query + evidence + relevance_level
|
||||
query_logs → source_kind + scope + patterns + events
|
||||
query_mysql → scope + columns + rows
|
||||
+ stop_required/reason(需要停止时附加)
|
||||
|
||||
controlView() → 控制视图:evidence_status / relevance_level / returned_count / truncated
|
||||
(Harness 控制面读,模型看不到)
|
||||
|
||||
分工:模型看到「内容」,Harness 看到「控制信息」(判级/截断/进度消费)
|
||||
```
|
||||
|
||||
## 7. 定义层小件
|
||||
|
||||
| 类 | 作用 |
|
||||
|---|---|
|
||||
| `DiagnosisAgentInput` | query + previous_turn(query 必填) |
|
||||
| `DiagnosisAgentLimits` | maxQuery/PreviousTurn/Input/DraftBytes 四类字节上限 |
|
||||
| `DiagnosisAgentPrompt` | classpath 加载系统提示词(prompts/diagnosis-agent-prompt.md) |
|
||||
| `DiagnosisAgentExecution` | completed(draft, progress) / stopped(progress, stopReason) 双形态 |
|
||||
| `DiagnosisDraftOutputSchema` | BeanOutputConverter postProcess:conclusion 允许 object/null(无结论合法) |
|
||||
| `EvidenceToolInvoker` | 函数式:RunContext + toolCallId + arguments → ToolBoundaryResult |
|
||||
| `ParsedAgentToolCall` | 解析出的工具调用(previousObservation + businessInput + arguments) |
|
||||
|
||||
## 8. 关键设计点(面试)
|
||||
|
||||
| 设计 | 为什么 |
|
||||
|---|---|
|
||||
| **不复制 loop** | 框架 ReAct 是标准能力;Harness 用拦截器挂在每次消耗点,不需要知道框架内部怎么循环 |
|
||||
| 每次 run 新建 Agent | 拦截器持有 RunContext——Agent 与 run 绑定,防跨 run 串状态 |
|
||||
| RunContext 显式传(metadata) | 避免隐式 ThreadLocal(框架线程池/异步下 ThreadLocal 不可靠) |
|
||||
| 串行工具(parallel=false) | 预算与 step 绑定可解释(并行会让「哪一步花多少钱」不可审计) |
|
||||
| 受控停止只认约定信号 | 取消/超时绝不降级为正常停止(诚实终态) |
|
||||
| conclusion 允许 null | 无结论也是合法 Draft(FALLBACK 路径) |
|
||||
| 双视图 | 模型观察 vs 控制视图分离——控制信息(判级/截断)不进模型上下文 |
|
||||
| 输出严格解析 | FAIL_ON_UNKNOWN/TRAILING——防止模型输出混入意外字段 |
|
||||
|
||||
## 9. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Harness 自己实现 Agent loop | 框架跑 loop,拦截器挂消耗点(不复制 loop) |
|
||||
| 任何异常都转 stopped | 只认约定信号(CollectionStopped/预算);取消/超时原样上抛 |
|
||||
| Agent 复用 | 每次 run 新建(拦截器绑定 RunContext) |
|
||||
| ThreadLocal 传 context | RunnableConfig metadata 显式传 |
|
||||
| 并行工具省时间 | 串行(预算与 step 绑定可解释) |
|
||||
| 模型看到控制信息 | 双视图:模型看内容,Harness 看控制 |
|
||||
| 输出宽容解析 | FAIL_ON_UNKNOWN + FAIL_ON_TRAILING(严格模式) |
|
||||
|
||||
## 10. 面试话术(30 秒)
|
||||
|
||||
> "agent 域是框架 ReAct 的接入层:不复制 loop——spring-ai-alibaba 的 ReactAgent 负责多轮循环,Harness 通过两个拦截器卡住每次消耗:Model Interceptor(每次模型调用前 checkActive + 预算,调用后记 Token 审计)、Tool Interceptor(工具调用五道门)。装配在 DiagnosisAgentFactory,每次 run 新建 Agent(拦截器绑定 RunContext,RunnableConfig metadata 显式传递避免 ThreadLocal)。循环外壳 DiagnosisAgentUseCase 做输入/输出字节限制 + Run 预算预留,并实现受控停止——只把 Harness 约定的可控信号(信息饱和、预算耗尽)从异常栈捞回转成 stopped,取消/超时原样上抛留给 Application 写 CANCELLED/FAILED。串行工具保证预算与 step 绑定可解释。"
|
||||
|
||||
## 11. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
|
||||
| `HarnessModelInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessModelInterceptor.java` |
|
||||
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| `DiagnosisAgentUseCase` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentUseCase.java` |
|
||||
| `ToolResultViewProjector` | `src/main/java/com/superbiz/agent/harness/agent/ToolResultViewProjector.java` |
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| `DiagnosisAgentExecution` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentExecution.java` |
|
||||
| `DiagnosisAgentOutputException` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentOutputException.java` |
|
||||
| 契约(DiagnosisDraft/PreviousTurn) | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
@@ -0,0 +1,166 @@
|
||||
# Harness application + audit 学习笔记:从 Run 编排到可回放审计
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:application 域(Run 全生命周期编排 + 安全落库)+ audit 域(可观测账本)——重点讲清 audit 与 trace 的设计与区别
|
||||
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
```text
|
||||
application = Run 应用所有者:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出
|
||||
audit = 可观测账本:Trace 时序回放 + Token 对账 + 各明细审计表(metadata-only)
|
||||
```
|
||||
|
||||
## 2. application 域:ChatApplicationUseCase 六步编排
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Controller → execute(request, observer)"] --> B["① 读会话上下文<br/>RoutingHistory + PreviousTurn"]
|
||||
B --> C["② core.startRun<br/>创建 Run 边界"]
|
||||
C --> D["③ persistStart + observer.onStarted<br/>(SSE metadata + 取消句柄 CoreRunControl)"]
|
||||
D --> E["④ router.route<br/>意图路由(单次模型调用)"]
|
||||
E --> F["⑤ executePath 按 intent 分叉<br/>SYSTEM_CHAT / KNOWLEDGE_QUERY / DIAGNOSIS"]
|
||||
F --> G["⑥ completePath + persistFinish + 返回"]
|
||||
G -.异常.-> H["统一失败出口<br/>terminalOutcome + safeFailure"]
|
||||
```
|
||||
|
||||
### 2.1 关键设计点
|
||||
|
||||
| 设计 | 代码事实 | 意义 |
|
||||
|---|---|---|
|
||||
| 取消句柄 | `observer.onStarted(new CoreRunControl(core, context))` → `core.cancel(context, CLIENT_DISCONNECTED)` | 客户端断连 → 取消广播 → 强杀 in-flight 调用 |
|
||||
| 取消有原因 | `RunCancellationReason.CLIENT_DISCONNECTED` | 区分断连/用户取消,可审计 |
|
||||
| sessionId 白名单 | `SAFE_ID = [A-Za-z0-9][A-Za-z0-9._-]{0,63}` | 信任边界校验 |
|
||||
| 多轮记忆有界 | RoutingHistory(intent+query) + PreviousTurn(PublishedResult 有界化) | 上一轮只传安全摘要,无 tool ids / raw evidence |
|
||||
| 预算终态特殊处理 | `handledBudgetTermination()` 时不二次 completeSuccess | 不破坏 first-terminal-wins |
|
||||
| 统一失败出口 | terminalOutcome → CANCELLED/FAILED + ChatFailureCode(文案安全) | 不暴露内部堆栈 |
|
||||
| 路由输出契约 | `OUTPUT_FIELDS = {intent}`,值必须枚举名 | 防模型夹带 |
|
||||
|
||||
### 2.2 持久化(JpaChatRunStore + PublishedResultPolicy)
|
||||
|
||||
- start / markIntent / finish(@Transactional),finish 写 outcome + safeContentJson + publishedResult;
|
||||
- PublishedResultPolicy:**只有 SUCCESS 且 draft 有结论才构造 PublishedResult**;sanitize 全套有界(query 2000/结论 2000/scope 1000/limitations 10×500/文档 10);
|
||||
- sourceDocuments 只收 RAG 类型证据的 document_id(去重)——MySQL/日志证据不进发布文档列表;
|
||||
- PreviousTurn = sanitize 后的有界摘要(多轮记忆来源)。
|
||||
|
||||
## 3. audit 域:可观测账本的层次
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 写入侧["写入侧(不阻断主流程)"]
|
||||
H1["HarnessAgentAuditHook<br/>每模型步 → agent_step + agent_reasoning_audit"]
|
||||
H2["ToolInvocationAuditSink<br/>工具 → tool_invocation"]
|
||||
H3["ModelCallAuditor<br/>Token → ledger + core + agent_step 回写"]
|
||||
H4["JpaDiagnosisTraceRecorder<br/>事件 → diagnosis_trace_event"]
|
||||
H5["JpaChatRunStore<br/>Run → diagnosis_run"]
|
||||
end
|
||||
|
||||
subgraph 读取侧["读取侧(回放)"]
|
||||
S["DiagnosisTraceService"]
|
||||
S --> R["DiagnosisTraceResponse<br/>timeline + steps + toolInvocations + run + summary"]
|
||||
end
|
||||
|
||||
H1 --> DB[(MySQL 各表)]
|
||||
H2 --> DB
|
||||
H3 --> DB
|
||||
H4 --> DB
|
||||
H5 --> DB
|
||||
DB --> S
|
||||
```
|
||||
|
||||
### 3.1 各落库点(谁写哪张表)
|
||||
|
||||
| 落库点 | 表 | 内容 |
|
||||
|---|---|---|
|
||||
| HarnessAgentAuditHook | agent_step | 每模型步摘要(stepIndex/耗时/token/工具计划) |
|
||||
| HarnessAgentAuditHook | agent_reasoning_audit | 推理 + assistant_text 正文(受限) |
|
||||
| ToolInvocationAuditSink | tool_invocation | 工具入参/输出预览/检索明细 |
|
||||
| JpaDiagnosisTraceRecorder | diagnosis_trace_event | 全链路事件时序线 |
|
||||
| ModelCallAuditor | agent_step.token_count | Token 回写(仅 DIAGNOSIS_AGENT) |
|
||||
| JpaChatRunStore | diagnosis_run / chat_session | Run 生命周期 + 发布契约 |
|
||||
|
||||
### 3.2 audit 的三个核心边界(fail-safe / metadata-only / 对账)
|
||||
|
||||
1. **审计不阻断主流程**:三个落库点全部 try-catch + log.warn——审计挂了不能让 Run 跟着挂;
|
||||
2. **metadata-only 分层**:正文只允许出现在 agent_reasoning_audit(受限)和 tool_invocation(入参/输出预览),其余全部摘要化;
|
||||
3. **Token 三写闭环**:ledger 分账 → core.recordTokens(Run 预算)→ agent_step.token_count 回写——审计与预算同源可对账。
|
||||
|
||||
## 4. audit vs trace:设计与区别(重点)
|
||||
|
||||
### 4.1 核心区别:包含关系,不是并列
|
||||
|
||||
```text
|
||||
audit = 域(可观测账本的总集合,17 个文件)
|
||||
├─ ★ trace = 域内的事件回放子体系(诊断时序线)
|
||||
├─ ModelCallLedger / Auditor(Token 记账)
|
||||
├─ HarnessAgentAuditHook(模型步审计)
|
||||
├─ ToolInvocationAuditSink(工具审计)
|
||||
├─ RagLookupAuditEnricher(RAG 检索审计)
|
||||
└─ RunConclusionExtractor(结论提取)
|
||||
```
|
||||
|
||||
**常见误解修正**:trace 不是「agent 执行记录」,而是**全链路七阶段时序线**(RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);audit 也不只是「tool 审计」,tool 审计只是其中一小块。
|
||||
|
||||
### 4.2 一张表讲清区别
|
||||
|
||||
| 维度 | trace(diagnosis_trace_event) | audit(各明细账本) |
|
||||
|---|---|---|
|
||||
| 本质 | 时序事件流(按 sequence_no 排序) | 实体化明细记录 |
|
||||
| 回答 | 发生了什么、按什么顺序 | 每个细节落在哪本账上 |
|
||||
| 粒度 | 每帧只带摘要 + 关联键(step_id 等) | 完整字段(入参/输出/token/耗时) |
|
||||
| 结构 | 一张表、一个序列 | 多张表(agent_step/tool_invocation/...) |
|
||||
| 排序保证 | sequence_no 单调(ConcurrentHashMap 分配) | step_index / id / createdAt 排序 |
|
||||
| 典型查询 | `findByRunIdOrderBySequenceNoAscIdAsc` | `findByRunIdOrderByStepIndex` 等 |
|
||||
| 与明细的关系 | 靠 step_id / run_id 互链,不重复存储 | 承载被关联的实体数据 |
|
||||
|
||||
### 4.3 真实数据对照(run 1b584a01)
|
||||
|
||||
```text
|
||||
trace(15 帧时序线):
|
||||
RUN_STARTED → ROUTING_DECISION → AGENT_MODEL_STEP×2 → TOOL_INVOCATION
|
||||
→ EVIDENCE_GUARD_INITIAL → SEMANTIC_GUARD_DECISION → RELEASE_DECISION → RUN_FINISHED
|
||||
|
||||
audit 各账本(同 Run):
|
||||
agent_step 2 行(token 2461/4415,耗时 1418/8542ms)
|
||||
agent_reasoning_audit 2 行(step1 = 完整 Draft JSON)
|
||||
tool_invocation 1 行(lookup_knowledge,step_id=979)
|
||||
diagnosis_run outcome=SUCCESS + published_result 完整 JSON
|
||||
```
|
||||
|
||||
**关联示例**:trace 第 7 帧 TOOL_INVOCATION 的 details 里 `step_id=979` = agent_step.id=979 = tool_invocation.step_id——时序帧与明细账本通过 id 互链。
|
||||
|
||||
## 5. 可回放机制(DiagnosisTraceService)
|
||||
|
||||
```text
|
||||
GET /api/diagnosis/{sessionId}/trace?runId=xxx
|
||||
→ DiagnosisTraceResponse 七块:runId / chatSession / session / run /
|
||||
steps[] / toolInvocations[] / timeline[] / summary
|
||||
|
||||
GET /api/diagnosis/{sessionId}/trace/reasoning?runId=xxx(受限:runId 必填)
|
||||
→ agent_reasoning_audit(reasoning + assistantText)
|
||||
```
|
||||
|
||||
三级回放深度:**时间线(timeline)→ 明细(steps/toolInvocations)→ 推理(reasoning,按需受限读取)**。
|
||||
|
||||
回放能成立的四个保证:
|
||||
1. sequence_no 单调(索引 idx_trace_event_run_sequence);
|
||||
2. 事件与明细靠 step_id 互链;
|
||||
3. summary 的 persisted vs returned 双计数对账(发现落库不完整);
|
||||
4. 双查询入口兼容新旧会话(buildLegacyTraceResponse)。
|
||||
|
||||
## 6. 面试话术(30 秒)
|
||||
|
||||
> "application 域是 Run 的应用所有者:一次请求六步编排——建 Run 边界、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。**audit 域是可观测账本,trace 是它内部的事件回放子体系**:trace 用一张 diagnosis_trace_event 表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),audit 的明细账本(agent_step / tool_invocation / agent_reasoning_audit / diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账 → Run 预算 → 明细回写)。回放由 DiagnosisTraceService 按 runId 聚合排序,三级深度:时间线 → 明细 → 推理。"
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| ChatApplicationUseCase / ChatFailureCode / ChatApplicationStatus | `src/main/java/com/superbiz/agent/harness/application/` |
|
||||
| DiagnosisChatExecutor / 其他 executor | `.../application/executor/` |
|
||||
| IntentRouter / IntentRouterPrompt | `.../application/routing/` |
|
||||
| JpaChatRunStore / PublishedResultPolicy / RoutingHistory | `.../application/persistence/` |
|
||||
| Trace 体系(Recorder/Event/Type/Status/AuditEvents) | `src/main/java/com/superbiz/agent/harness/audit/`(前半) |
|
||||
| ModelCallLedger / ModelCallAuditor / HarnessAgentAuditHook / RunConclusionExtractor | `.../audit/`(后半) |
|
||||
| DiagnosisTraceService / DiagnosisTraceController | `src/main/java/com/superbiz/agent/service/` + `controller/` |
|
||||
| V014 建表(diagnosis_trace_event) | `src/main/resources/db/migration/V014__create_diagnosis_trace_event.sql` |
|
||||
@@ -0,0 +1,105 @@
|
||||
# Harness contract 状态流学习笔记:11 个状态枚举的正交全景
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:contract 域状态枚举全景——五层状态 / 正交维度 / 纵向映射链 / 真实数据案例 / 面试讲法
|
||||
**配套**:[证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md)、[application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
**contract 状态流 = 11 个状态枚举按五层正交组织,每层回答一个独立问题;层间通过显式映射链串联——从技术终态到用户结局,从工具调用到发布裁决,全部类型化,杜绝字符串漂移。**
|
||||
|
||||
## 2. 五层状态全景
|
||||
|
||||
| 层 | 枚举 | 值 | 回答的问题 |
|
||||
|---|---|---|---|
|
||||
| ① 技术层(core) | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许执行 |
|
||||
| ② 证据层(tool/guard) | `InvocationStatus` | PROJECTING / READY / ERROR | 调用生命周期走到哪 |
|
||||
| | `EvidenceStatus` | EVIDENCE_FOUND / NO_EVIDENCE / ERROR | 这次调用有没有拿到可引用证据 |
|
||||
| | `AnalysisKind` | NORMAL / NEGATIVE_OBSERVATION | 分析条目是正向还是负向观察 |
|
||||
| ③ 收集层(progress) | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROTOCOL_VIOLATED | 证据收集为何受控停止 |
|
||||
| | `SemanticVerdict` | SUPPORTED / UNSUPPORTED | 结论是否被证据支持 |
|
||||
| ④ 发布层(release) | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局 |
|
||||
| | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | 为什么降级 |
|
||||
| ⑤ 协议层 + 失败层 | `ChatApplicationStatus` | ROUTING / SYSTEM_RESPONDING / KNOWLEDGE_SEARCHING / KNOWLEDGE_ANSWERING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING | 当前走到哪一阶段(进行中) |
|
||||
| | `SseOutcome` | SUCCESS / FALLBACK / FAILED | done 事件粗粒度结局(**未接线**) |
|
||||
| | `ChatFailureCode` | ROUTING_UNAVAILABLE / SYSTEM_CHAT_UNAVAILABLE / KNOWLEDGE_UNAVAILABLE / DIAGNOSIS_UNAVAILABLE / RUN_PERSISTENCE_FAILED / RUN_CANCELLED / INTERNAL_FAILURE | 失败时给客户端的粗粒度原因 |
|
||||
|
||||
## 3. 正交维度(四个独立轴)
|
||||
|
||||
```text
|
||||
轴 1:RunState(技术终态)⊥ ReleaseOutcome(用户结局)
|
||||
一个 Run 技术停了,用户看到的可能是降级(有安全进展)或失败(没进展)
|
||||
轴 2:InvocationStatus(调用生命周期)⊥ EvidenceStatus(证据语义)
|
||||
注释原话:一个是「投影中/就绪/错误」,一个是「有没有拿到可引用证据」
|
||||
NO_EVIDENCE 仍可能是 success 的工具执行(查了但空)
|
||||
轴 3:ChatApplicationStatus(进度,进行中)⊥ 结局(终态)
|
||||
进度回答「走到哪」,结局回答「最终给什么」
|
||||
轴 4:IntentType(路由)——每次请求一个,决定走哪条分支
|
||||
```
|
||||
|
||||
## 4. 纵向映射链(代码事实)
|
||||
|
||||
```text
|
||||
RunState.BUDGET_EXHAUSTED ──hasObservedFacts()==true──▶ FALLBACK(INSUFFICIENT_EVIDENCE)
|
||||
└──无 facts──▶ FAILED(fail closed)
|
||||
RunState.CANCELLED ──▶ ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
|
||||
内部失败 ──▶ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
|
||||
SemanticVerdict.SUPPORTED(guard 全过)──▶ ReleaseOutcome.SUCCESS(唯一出口)
|
||||
FallbackType 任意值 ──▶ ReleaseOutcome.FALLBACK
|
||||
|
||||
证据层内部约束:
|
||||
AnalysisKind.NORMAL.accepts(EVIDENCE_FOUND)
|
||||
AnalysisKind.NEGATIVE_OBSERVATION.accepts(NO_EVIDENCE)
|
||||
InvocationStatus.READY 是 EvidenceGuard 可引用前提(isReferencableBy)
|
||||
EvidenceStatus.ERROR 不是证据,不能支持分析/结论(prompt 原话)
|
||||
```
|
||||
|
||||
## 5. 真实数据案例(run 1b584a01 的状态流转)
|
||||
|
||||
| 阶段 | 状态值(真实 trace 佐证) |
|
||||
|---|---|
|
||||
| 路由 | IntentType=DIAGNOSIS(ROUTING_DECISION 事件 details) |
|
||||
| Agent 执行 | RunState=RUNNING,ChatApplicationStatus: ROUTING → DIAGNOSIS_RUNNING → SAFETY_VALIDATING |
|
||||
| 工具调用 | InvocationStatus: PROJECTING → READY;EvidenceStatus=EVIDENCE_FOUND |
|
||||
| 分析 | AnalysisKind=NORMAL × 3(accepts EVIDENCE_FOUND) |
|
||||
| 验真 | EVIDENCE_GUARD_INITIAL=PASSED(violations=0) |
|
||||
| 语义裁决 | SEMANTIC_GUARD_DECISION=SUPPORTED |
|
||||
| 发布 | RELEASE_DECISION=SUCCESS(= ReleaseOutcome.SUCCESS) |
|
||||
| 终态 | RunState=SUCCESS |
|
||||
|
||||
## 6. 面试怎么讲这个状态流设计
|
||||
|
||||
### 6.1 叙事模板(① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术)
|
||||
|
||||
**① 动机**:Agent 系统里最容易被搞混的就是「状态」。技术停了(预算耗尽)不等于用户看到失败;查了没查到不等于系统出错;进行中不等于终态。如果所有状态塞进一个枚举,语义就糊了。
|
||||
|
||||
**② 决策**:**分层 + 正交 + 显式映射**——每个枚举只回答一个问题(技术/证据/收集/发布/协议各管各的),层间不隐式耦合,用明确的映射链串联。
|
||||
|
||||
**③ 实现**:11 个枚举五层,两个最典型的正交轴 + 一条纵向映射链(如上);全部类型化(enum/record),杜绝字符串漂移。
|
||||
|
||||
**④ 边界**:SSE 取消场景连接可能已断、发不出 done,所以协议层只有三态;`SseOutcome` 目前未接线(实现直接复用 ReleaseOutcome);`FallbackType.BUDGET_EXHAUSTED` 是命名债务(实际发布 INSUFFICIENT_EVIDENCE)。
|
||||
|
||||
**⑤ 话术(30 秒)**:
|
||||
|
||||
> "Harness 的状态设计核心是**分层正交**:技术终态(RunState)和用户结局(ReleaseOutcome)是两个正交轴——预算耗尽且有安全进展时用户看到 FALLBACK 降级,没进展才是 FAILED,这样同一个技术终态可以诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空结果(NO_EVIDENCE)仍是成功执行。层间是显式映射链:BUDGET_EXHAUSTED+有事实→FALLBACK,CANCELLED→CANCELLED+RUN_CANCELLED,guard 全过→SUCCESS 唯一出口。协议层(SSE)复用 ReleaseOutcome 三态并拒绝 CANCELLED(取消时连接已断),SseOutcome 是预留未启用的抽象。"
|
||||
|
||||
### 6.2 高频追问应对
|
||||
|
||||
| 追问 | 答 |
|
||||
|---|---|
|
||||
| RunState 和 ReleaseOutcome 有什么区别? | 技术 vs 用户:RunState 回答 Run 是否还允许执行;ReleaseOutcome 回答用户侧内容形态。BUDGET_EXHAUSTED 可以有 FALLBACK 或 FAILED 两种用户结局 |
|
||||
| 为什么预算耗尽既可能 FALLBACK 又可能 FAILED? | `progress.hasObservedFacts()` 安全阀——有已验真事实才能发布降级,没有就 fail closed |
|
||||
| NO_EVIDENCE 算失败吗? | 不算。NO_EVIDENCE 是成功执行但范围内无证据(查了但空),常记为信息无增益;ERROR 才是工具侧失败 |
|
||||
| CANCELLED 为什么不在 SSE done 里? | 取消时客户端连接可能已断,发不出 done;所以协议层只有 SUCCESS/FALLBACK/FAILED 三态 |
|
||||
| 这些状态为什么不直接用一个枚举? | 塞一个枚举语义就糊了——技术、证据、发布、协议回答的是不同问题,正交分层后各层可独立演进,映射显式可审计 |
|
||||
| InvocationStatus 和 EvidenceStatus 会不会重复? | 分工明确:生命周期(投影中/就绪/错误)vs 证据语义(有没有证据);READY+NO_EVIDENCE 是完全合法的组合 |
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| 全部状态枚举与数据契约 | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
| RunState | `.../harness/core/RunState.java` |
|
||||
| DiagnosisStopReason | `.../harness/progress/DiagnosisStopReason.java` |
|
||||
| ChatApplicationStatus / ChatFailureCode | `.../harness/application/` |
|
||||
| SSE 会话(done 事件拒绝 CANCELLED) | `.../controller/sse/ChatSseEvent.java` |
|
||||
@@ -0,0 +1,415 @@
|
||||
# Harness progress 代码学习笔记:从拦截器五道门到唯一发布点
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:Progress 子系统的代码落地——拦截器五道门、Tracker 状态机、canonical 生命周期、执行门禁、投影与发布闭环
|
||||
**术语与状态**:[CONTEXT.md](CONTEXT.md) · [Harness生命周期与状态.md](Harness生命周期与状态.md)
|
||||
**设计视角**:[Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md)(讲「为什么这样设计」)
|
||||
**本文视角**:代码里怎么落地(类地图、调用链、生命周期、状态机、门禁、易错点、面试话术)
|
||||
|
||||
## 1. 定位:双停止机制与三方判断权
|
||||
|
||||
### 1.1 双停止机制(progress 存在的根本理由)
|
||||
|
||||
预算管「能不能花」,progress 管「值不值得继续查」。让预算充当正常停止策略,会把「当前证据不足」错误表达成「系统执行失败」——这是语义错误,不是资源问题。
|
||||
|
||||
| 机制 | 问的问题 | 归属 |
|
||||
|---|---|---|
|
||||
| RunBudget | 这次 Run 最多允许消耗多少? | core 域 |
|
||||
| Progress | 继续查询是否仍可能推进当前诊断? | progress 域 |
|
||||
|
||||
### 1.2 三方判断权(信任边界)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Tool / Projector<br/>客观结果"] -->|"evidence_status:空不空(代码判)"| H
|
||||
M["Diagnosis Agent<br/>语义价值"] -->|"information_gain:有没有用(模型判)"| H
|
||||
H["Harness<br/>最终停止权"] -->|"scope 重复 / 连续 NO_GAIN / 协议合规"| R["停止裁决"]
|
||||
```
|
||||
|
||||
- **谁判空**:`EVIDENCE_FOUND / NO_EVIDENCE` 是 Harness 代码判(证据数组空不空),Projector 投影时客观计算,不需要模型。
|
||||
- **谁判价值**:`GAINED / NO_GAIN` 的语义价值是模型判——非空结果「有没有用」只有结合诊断上下文才能判断。
|
||||
- **谁决定停止**:Harness。模型可以主动结束(输出 Draft),但不能用继续发 Tool Call 绕过 Harness 已定的饱和状态。
|
||||
|
||||
**两层信任边界分开**:模型输出乱来(瞎报增益)时,Harness 手里仍有 evidenceStatus 这个与模型无关的事实层(canonical 记录、审计、验真都建立在它之上)。
|
||||
|
||||
## 2. 类地图与调用链
|
||||
|
||||
### 2.1 14 个文件分类
|
||||
|
||||
| 类别 | 类 | 作用 |
|
||||
|---|---|---|
|
||||
| 状态机核心 | `DiagnosisProgressTracker` | 记账(双计数/pending/identity)+ 停止裁决 |
|
||||
| 判重 | `ToolScopeNormalizer` + `ToolScopeIdentity` | 业务输入 → 稳定 scope 指纹 |
|
||||
| 投影 | `DiagnosisProgressProjector` + `DiagnosisProgressProjection` | identity → 回读 canonical → 有界快照 |
|
||||
| 枚举 | `InformationGain` / `DiagnosisCollectionState` / `DiagnosisStopReason` / `ProgressProtocolViolationType` | GAINED·NO_GAIN / COLLECTING·SATURATED / 三种停止原因 / 五类违规 |
|
||||
| record | `PreviousObservation` / `CompletedToolCall` / `DiagnosisProgressSnapshot` / `DiagnosisProgressSnapshotState` | 协议字段 / 完成 identity / 对外快照 / 内部快照 |
|
||||
| 异常 | `ProgressProtocolViolationException` | 受控协议违规 |
|
||||
|
||||
### 2.2 一次 Tool Call 的完整链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as HarnessToolInterceptor(per-Run)
|
||||
participant ET as HarnessEvidenceTools(注册表)
|
||||
participant AD as Adapter(接线员)
|
||||
participant TB as ToolBoundary(门禁)
|
||||
participant S as CanonicalInvocationStore(真相)
|
||||
participant P as Projector(投影)
|
||||
|
||||
I->>ET: invoke(context, toolName, toolCallId, args)
|
||||
ET->>AD: bridge 包装的 invoker
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>S: begin(PROJECTING)
|
||||
TB->>TB: executor.execute(requestJson)(backend raw)
|
||||
TB->>P: projector.project(raw) → agent_result + evidenceStatus
|
||||
TB->>S: markReady(READY) 或 markError(ERROR)
|
||||
TB-->>AD: ToolBoundaryResult(READY/ERROR)
|
||||
AD-->>I: 一路 return
|
||||
I->>I: 双源交叉验证 → recordCompleted → 返回有界 observation
|
||||
```
|
||||
|
||||
### 2.3 三层关系
|
||||
|
||||
**持有关系**:
|
||||
|
||||
```
|
||||
HarnessToolInterceptor ──持有──▶ RunContext(含 Tracker)/ HarnessEvidenceTools
|
||||
HarnessEvidenceTools ──持有──▶ Map<toolName, EvidenceToolInvoker>(bridge 注册表)
|
||||
Adapter ──持有──▶ ToolBoundary + 具体 backend + 具体 Projector
|
||||
ToolBoundary ──持有──▶ DiagnosisHarnessCore / CanonicalInvocationStore / ToolCallKeyFactory
|
||||
```
|
||||
|
||||
**接口-实现关系**:
|
||||
|
||||
| 接口 | 实现 |
|
||||
|---|---|
|
||||
| `EvidenceToolInvoker`(@FunctionalInterface) | `HarnessEvidenceTools.fromAdapters` 的 bridge |
|
||||
| `AdapterCall`(内部 @FunctionalInterface) | 三个 Adapter 的 execute 方法 |
|
||||
| `ToolExecutor`(@FunctionalInterface) | Adapter 里 `ignored -> legacyExecutor.execute(query)` |
|
||||
| `ToolResultProjector` | `RagResultProjector` / `QueryLogsResultProjector` / `MysqlResultProjector` |
|
||||
| `CanonicalInvocationStore` | `JpaCanonicalInvocationStore` 等 |
|
||||
|
||||
## 3. 生命周期:单例 vs per-Run
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 应用启动(一次)
|
||||
A["Spring 容器装配单例 bean"]
|
||||
A --> B["DiagnosisHarnessCore / ToolBoundary / Adapter / HarnessEvidenceTools / DiagnosisAgentFactory"]
|
||||
end
|
||||
subgraph 每次请求(多次)
|
||||
C["core.startRun() → RunContext(含 new DiagnosisProgressTracker)"]
|
||||
C --> D["DiagnosisAgentFactory.create(context)"]
|
||||
D --> E["new HarnessModelInterceptor / HarnessToolInterceptor(绑定本 Run context)"]
|
||||
E --> F["ReactAgent.call() → ReAct loop"]
|
||||
F --> G["Run 结束:拦截器/ReactAgent 变成垃圾"]
|
||||
end
|
||||
```
|
||||
|
||||
| 对象 | 生命周期 | 原因 |
|
||||
|---|---|---|
|
||||
| core / ToolBoundary / Adapter / EvidenceTools / AgentFactory | 单例 | 无状态,只存依赖与规则 |
|
||||
| RunContext | per-Run | 状态容器(Tracker/Budget/Lifecycle) |
|
||||
| 两个 Interceptor | per-Run | 持有本 Run 的 RunContext |
|
||||
| ReactAgent | per-Run | 框架有状态对象(loop/memory) |
|
||||
|
||||
**无状态体现**:单例 bean 的字段全是构造注入的依赖/配置(创建后不变);可变状态全部外置到 RunContext 和持久化存储。方法一律以 `context` 参数显式传入——同一个 ToolBoundary 实例可并发服务多个 Run,各算各的账,**状态外置是并发正确性的硬要求**(若在 bean 里存「当前预算计数」,并发 Run 会互相覆盖)。
|
||||
|
||||
## 4. 拦截器五道门(代码核心)
|
||||
|
||||
### 4.1 门卫 + 执行者合一
|
||||
|
||||
证据工具的 `ToolCallback` 被**故意定义为直接抛异常**(`"Harness evidence Tools require the framework Tool interceptor"`)——如果框架绕过拦截器执行 `handler.call()`,直接爆炸。这从结构上保证:**执行不可绕过门禁,校验+执行是原子操作**。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REQ["Tool Call 到达"] --> CHK{"evidenceTools.supports?"}
|
||||
CHK -->|"非证据工具"| HANDLER["handler.call() 透传"]
|
||||
CHK -->|"证据工具"| GATES["五道门(校验+执行+记账)"]
|
||||
```
|
||||
|
||||
**为什么不在 definition 里写**(四个原因):
|
||||
1. 拦截器接管执行时**根本不经过 ToolCallback**——写在 definition 里永远不会执行;
|
||||
2. ToolCallback 是纯函数(input→output),**拿不到 RunContext**,调不了 `context.progress()`;
|
||||
3. ToolCallback 本身就是执行,**没有「执行前」时机**——判重必须在 backend 前,只有拦截器同时拥有执行前/后两个观察点;
|
||||
4. progress 逻辑横跨所有证据工具,写在每工具 callback 会**复制漂移**。
|
||||
|
||||
### 4.2 五道门流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① 已停止检查"] -->|"SATURATED && stopInstructionDelivered"| B["抛 DiagnosisCollectionStoppedException(硬停止)"]
|
||||
A --> C["② 解析 + 协议校验"]
|
||||
C -->|"违规"| D["recordProgressProtocolViolation → 可修复反馈 / 达阈值饱和"]
|
||||
C -->|"评价导致饱和"| E["stopRequired(软停止)"]
|
||||
C --> F["③ 判重:isDuplicate(toolName, normalizedScope)"]
|
||||
F -->|"重复"| G["recordDuplicateScope(直接 NO_GAIN)→ 饱和? stopRequired : 返回 DUPLICATE_SCOPE 观察"]
|
||||
F --> H["④ ToolBoundary 执行(预算/canonical/审计/checkActive)"]
|
||||
H -->|"READY"| I["双源交叉验证 → recordCompleted → ⑤ 收尾(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending / 饱和交付 stop_required)"]
|
||||
H -->|"非 READY"| J["error observation(BUDGET_EXHAUSTED 额外 markBudgetLimitReached)"]
|
||||
```
|
||||
|
||||
### 4.3 协议三类违规(第二道门)
|
||||
|
||||
校验的是 **Envelope 与 Tracker pending 状态的协议关系**(不是 JSON 结构——JSON 结构在 parse 用严格反序列化做了):
|
||||
|
||||
| 违规 | 含义 | 判定条件 |
|
||||
|---|---|---|
|
||||
| `UNEXPECTED_PREVIOUS_OBSERVATION` | 没欠账却带评价 | pending == null 且 observation != null |
|
||||
| `MISSING_PREVIOUS_OBSERVATION` | 欠账不还 | pending != null 且 observation == null |
|
||||
| `OUT_OF_ORDER_PREVIOUS_OBSERVATION` | 还错账 | observation.toolCallId() != pendingToolCallId |
|
||||
|
||||
另两类(parse 阶段):`MISSING_INPUT`(缺业务输入)、`INVALID_ENVELOPE`(JSON/字段非法)。
|
||||
|
||||
**可修复反馈**:首次违规(未达阈值)返回 `repair_required:true` 观察——带 `violation_type` / `missing_field` / `expected_previous_tool_call_id` / `allowed_information_gain` / `instruction`,让模型下一轮自愈;**连续**违规达独立阈值才饱和(PROGRESS_PROTOCOL_VIOLATED)。
|
||||
|
||||
### 4.4 双源交叉验证(第四道门,READY 后)
|
||||
|
||||
```text
|
||||
源1:result.evidenceStatus() ← Projector 投影时计算并携带的声明值
|
||||
源2:controlView.evidenceStatus() ← 从 agent_result 内容里解析 "evidence_status" 字段
|
||||
比较:一致 ? 通过 : OBSERVATION_CONTRACT_MISMATCH 拒绝
|
||||
```
|
||||
|
||||
本质是「自洽性防线」:同一状态被两处描述(结果对象字段 vs 内容 JSON 字段),必须一致——防止投影 bug / 数据损坏 / 构造不一致导致 progress 基于错误状态决策(如声明 FOUND 但内容空 → 挂 pending 等评价不存在的证据;声明 NO_EVIDENCE 但内容有证据 → 误记 NO_GAIN)。
|
||||
|
||||
### 4.5 三副 observation 面孔
|
||||
|
||||
| 面孔 | 触发 | 关键字段 |
|
||||
|---|---|---|
|
||||
| 正常执行结果 | READY 且未饱和 | 有界观察 + 可选 `stop_required` |
|
||||
| `STOP_REQUIRED` | 饱和后交付一次 | `stop_required:true` + `reason` |
|
||||
| 可修复协议错误 | 协议违规未达阈值 | `repair_required:true` + violation_type/missing_field/expected id/instruction |
|
||||
|
||||
## 5. Tracker 状态机
|
||||
|
||||
### 5.1 11 个字段
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `stopAfterConsecutiveNoGain` | 连续 NO_GAIN 阈值(默认 2,yml 可配) |
|
||||
| `stopAfterConsecutiveProgressProtocolViolations` | 连续协议违规阈值(默认 2) |
|
||||
| `completedScopes` | `toolName + normalizedScope` 去重集合 |
|
||||
| `completedToolCalls` | 已完成调用 identity 列表(无 payload) |
|
||||
| `consecutiveNoGain` | 连续无增益计数(GAINED 清零) |
|
||||
| `consecutiveProgressProtocolViolations` | 连续协议违规计数(合法评价清零) |
|
||||
| `collectionState` | `COLLECTING` / `SATURATED` |
|
||||
| `stopReason` | `INFORMATION_SATURATED` / `BUDGET_LIMIT_REACHED` / `PROGRESS_PROTOCOL_VIOLATED` |
|
||||
| `pendingToolCallId` | 待评价调用 ID(同一时刻最多一个) |
|
||||
| `stopInstructionDelivered` | STOP_REQUIRED 是否已交付(只一次) |
|
||||
|
||||
### 5.2 两条独立计数(重点:协议违规 ≠ NO_GAIN)
|
||||
|
||||
```text
|
||||
NO_GAIN 路径(applyGain)—— backend 执行了但没增益:
|
||||
入口:applyPreviousObservation(NO_GAIN) / recordDuplicateScope() / recordCompleted(NO_EVIDENCE)
|
||||
阈值:stopAfterConsecutiveNoGain(2) → SATURATED + INFORMATION_SATURATED
|
||||
GAINED 清零连续计数
|
||||
|
||||
协议违规路径(recordProgressProtocolViolation)—— backend 根本没执行:
|
||||
入口:拦截器 catch 分支
|
||||
阈值:stopAfterConsecutiveProgressProtocolViolations(2) → SATURATED + PROGRESS_PROTOCOL_VIOLATED
|
||||
一次合法评价(applyPreviousObservation 通过)清零
|
||||
```
|
||||
|
||||
**为什么分开**:`NO_GAIN` 表示「Tool 执行了但没推进诊断」;协议错误表示「模型没守契约,Tool 根本没执行」。混淆会让真实空转无法归因(真实 E2E:9 次协议拒绝 + 13 轮模型调用空转)。
|
||||
|
||||
### 5.3 pending 协议(锚点)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["recordCompleted(EVIDENCE_FOUND)"] --> B["pendingToolCallId = toolCallId(挂账)"]
|
||||
B --> C["模型下一轮回带 previous_observation"]
|
||||
C --> D{"ID == pending ?"}
|
||||
D -->|"是"| E["清 pending → applyGain"]
|
||||
D -->|"否"| F["OUT_OF_ORDER 违规"]
|
||||
```
|
||||
|
||||
- 只对**非空成功**结果设 pending;NO_EVIDENCE 不设(Harness 已自己判 NO_GAIN)。
|
||||
- pending 是「唯一欠账」——保证协议逐轮、不乱序、不重评。
|
||||
- 模型读完 observation 直接输出 Draft → pending 不消费也合法(不需要评价最后一轮)。
|
||||
|
||||
### 5.4 收集状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> COLLECTING
|
||||
COLLECTING --> COLLECTING: GAINED / consecutiveNoGain=0
|
||||
COLLECTING --> COLLECTING: NO_GAIN 未达阈值
|
||||
COLLECTING --> SATURATED: NO_GAIN 达阈值 / 协议违规达阈值
|
||||
SATURATED --> SATURATED: claimStopInstruction 交付一次(软停止)
|
||||
SATURATED --> [*]: 模型再请求 Tool → DiagnosisCollectionStoppedException(硬停止)
|
||||
```
|
||||
|
||||
**软/硬停止两段式**(易错点:stopReason 设置 ≠ 软停止):
|
||||
|
||||
```text
|
||||
饱和瞬间 → stopReason 已设置(状态事实)
|
||||
软停止 = 第一次交付 stop_required 观察(claimStopInstruction 返回 true,流程不中断)
|
||||
→ 给模型合法输出 Draft 的机会
|
||||
硬停止 = 模型无视指令再次请求 Tool → 入口检查 SATURATED && delivered
|
||||
→ 抛 DiagnosisCollectionStoppedException 穿出框架 loop
|
||||
```
|
||||
|
||||
### 5.5 为什么需要 Tracker(三层)
|
||||
|
||||
1. **progress 本身需要**:预算只止损,不能当正常停止策略;
|
||||
2. **必须单一所有者**:「连续无增益」是跨轮次、跨入口(模型评价/代码判空/重复检测)的全局判断,分散记账会状态漂移;
|
||||
3. **最小状态**:不存 payload(真相在 Canonical Store),避免第二份真相。
|
||||
|
||||
## 6. canonical 生命周期(tool 域衔接)
|
||||
|
||||
### 6.1 状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[不存在] --> PROJECTING: store.begin(preflight+预算通过后)
|
||||
PROJECTING --> READY: store.markReady(backend 成功+投影成功)
|
||||
PROJECTING --> ERROR: store.markError(任何异常)
|
||||
note right of PROJECTING: 仅身份+request+startedAt
|
||||
note right of READY: raw + agent_result + evidence + completedAt
|
||||
note right of ERROR: raw + error_code + completedAt(禁 agent_result)
|
||||
```
|
||||
|
||||
### 6.2 为什么分 begin/markReady/markError 三段
|
||||
|
||||
| 动机 | 说明 |
|
||||
|---|---|
|
||||
| 崩溃恢复 | backend 执行可能耗时数秒,一次写入会丢「已开始」的事实;begin 先留痕(PROJECTING = WAL) |
|
||||
| 审计耗时 | 需要 startedAt(begin 记)和 completedAt(迁移记)算调用耗时 |
|
||||
| 状态合法 | 每种状态只允许合法字段组合,由构造校验兜底 |
|
||||
| 防篡改 | record 不可变,迁移生成新实例——READY 一旦写入不可改(证据可验真的前提) |
|
||||
| 幂等 | begin 抛 `DuplicateInvocationException`(同 key 重复 begin 拒绝)→ `DUPLICATE_TOOL_CALL` |
|
||||
|
||||
**两类失败不落库**:requestBytes 超限、preflight 非法、重复 begin——都发生在 begin 之前,canonical 里**无记录**。
|
||||
|
||||
### 6.3 Store vs Tracker 分层
|
||||
|
||||
```text
|
||||
Store(事实层):request / raw_response / agent_result / 状态 / 时间戳 —— 完整真相
|
||||
Tracker(判断层):identity(引用)+ 双计数 + pending + 状态 —— 决策状态
|
||||
|
||||
NO_GAIN 计数不是「第二份真相」:它无法从 Store 重建
|
||||
(模型评价是瞬时的、不落 Store),是独立增量状态
|
||||
```
|
||||
|
||||
## 7. ToolBoundary 执行门禁
|
||||
|
||||
### 7.1 executeCanonical 五阶段
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["阶段一:preflight + Tool 预算 + request bytes 预留 → begin(PROJECTING)"]
|
||||
B["阶段二:executor.execute(requestJson)(backend raw)"]
|
||||
C["阶段三:raw 大小校验 + Run bytes 预留"]
|
||||
D["阶段四:projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["阶段五:agent_result 校验 + Run bytes 预留 → markReady(READY)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
**三笔 bytes 预留**:request(阶段一)/ raw(阶段三)/ agent_result(阶段五)——Run 的 bytes 预算分三个时间点消耗,任何一笔超限触发对应错误码。
|
||||
|
||||
### 7.2 失败路径错误码
|
||||
|
||||
| 失败点 | 是否已 begin | canonical | 错误码 |
|
||||
|---|---|---|---|
|
||||
| requestBytes 超限 / preflight 非法 / 重复 begin / Run 终态 | 否 | 无记录 | `RESULT_TOO_LARGE` / 分类错误码 / `DUPLICATE_TOOL_CALL` / `BUDGET_EXHAUSTED`·`RUN_INACTIVE` |
|
||||
| executor 抛错 | 是 | ERROR | `TOOL_EXECUTION_ERROR` |
|
||||
| raw 过大 / 预算 / 取消 | 是 | ERROR | `RESULT_TOO_LARGE` / `BUDGET_EXHAUSTED` / `RUN_INACTIVE` |
|
||||
| 投影失败 | 是 | ERROR | `PROJECTION_ERROR` |
|
||||
| agent_result 过大 / store 失败 | 是 | ERROR | `RESULT_TOO_LARGE` / `PROJECTION_ERROR` / `STORE_ERROR` |
|
||||
|
||||
### 7.3 checkActive 在哪
|
||||
|
||||
progress 拦截器流程里看不到显式 checkActive——它在 **ToolBoundary/core 层**隐式执行:`core.beforeToolCall` / `core.reserveRunBytes` 内部若 Run 已终态,抛 `RunAbortedException` → 映射为 `RUN_INACTIVE` 错误码。
|
||||
|
||||
## 8. 投影与发布闭环
|
||||
|
||||
### 8.1 停止后的数据流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
T["Tracker(identity 列表)"] --> P["DiagnosisProgressProjector"]
|
||||
P --> S["CanonicalInvocationStore(回读 READY)"]
|
||||
S --> P
|
||||
P --> SN["ProgressSnapshot(verifiedSources + observedFacts + limitations + stopReason)"]
|
||||
SN --> R["DiagnosisReleaseUseCase"]
|
||||
R --> O["SUCCESS / FALLBACK(INSUFFICIENT_EVIDENCE 等)"]
|
||||
```
|
||||
|
||||
### 8.2 Projector 有界投影
|
||||
|
||||
- 三重校验(`isReferencableBy` / toolCallId / toolName)通过才发布;无法验真/不可读/格式非法 → limitation,**绝不输出 raw**;
|
||||
- 空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是「已检查」的证明;
|
||||
- 硬截断:`MAX_FACTS=12`、summary/scope 320 字符、source 160 字符;
|
||||
- 去重:`sourceKey = type + source + scope`,`factKey = sourceKey + summary`(LinkedHashMap 保序)。
|
||||
|
||||
### 8.3 Release 决策树
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
EX["execute(execution)"] --> D1{"draft == null?"}
|
||||
D1 -->|"是"| CS["releaseControlledStop<br/>需 hasObservedFacts → INSUFFICIENT_EVIDENCE"]
|
||||
D1 -->|"否"| D2{"conclusion == null?"}
|
||||
D2 -->|"是"| NC["releaseNoConclusion<br/>有事实 → INSUFFICIENT_EVIDENCE<br/>missing_info → MISSING_REQUIRED_CONTEXT<br/>都没有 → fail closed"]
|
||||
D2 -->|"否"| C["releaseConclusion<br/>EvidenceGuard 验引用 → repair → SemanticGuard 裁决<br/>SUPPORTED ? SUCCESS : FALLBACK"]
|
||||
```
|
||||
|
||||
## 9. 易错点清单
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| EVIDENCE_FOUND 累计 NO_GAIN | **NO_EVIDENCE**(空结果)才立即累计;FOUND 挂 pending 等模型评价 |
|
||||
| 重复调用计入协议违规 | 重复走 **NO_GAIN** 路径(recordDuplicateScope),不是违规 |
|
||||
| 软停止 = 设置 stopReason | stopReason 饱和时就设了;软停止是**交付 stop_required 观察** |
|
||||
| checkActive 应该在拦截器 | 在 ToolBoundary/core 层,`RUN_INACTIVE` 错误码 |
|
||||
| 拦截器直接 invoke = 绕过设计 | 是**唯一执行通道**(证据工具 handler 必炸) |
|
||||
| 数据都在 Tracker | 判断层在 Tracker,**事实层在 Canonical Store** |
|
||||
| 判重对比裸入参 | 对比的是**规范化指纹**(字段顺序/格式/缺省值统一后) |
|
||||
| 首次协议违规直接终止 | 首次返回**可修复反馈**,连续违规才饱和 |
|
||||
|
||||
## 10. 面试话术合集(30 秒)
|
||||
|
||||
### 10.1 双停止机制
|
||||
|
||||
> "Harness 有两套停止机制。预算是资源门禁,管『能不能花』;Progress 是收敛门禁,管『继续查有没有价值』。核心设计是:Tool 只提供客观结果,模型负责判断语义增益,但停止权归 Harness。信息增益故意只保留 GAINED/NO_GAIN 两值,靠模型在下次 Tool Call 里用 previous_observation 回传评价;连续两次 NO_GAIN 就进入饱和,交付一次 STOP_REQUIRED 给模型合法收尾的机会,再纠缠就抛受控异常穿出框架。"
|
||||
|
||||
### 10.2 为什么需要 Tracker(单一所有者)
|
||||
|
||||
> "ProgressTracker 是 Run 内收敛控制的单一决策者:它不存证据内容,只保存最小账——已完成调用的 identity、连续 NO_GAIN 和协议违规两条计数、一个待评价的 pending ID、以及 COLLECTING/SATURATED 状态。三路输入(模型评价、代码判空、重复检测)汇入计数,GAINED 清零、NO_GAIN 累加,达阈值进入饱和,交付一次停止指令。所有方法 synchronized,是并发安全的单一所有者。"
|
||||
|
||||
### 10.3 拦截器为什么自己执行(必炸路径)
|
||||
|
||||
> "Harness 的证据工具不是『拦截后放行』——拦截器对它们既是门卫又是执行者。证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为必炸路径,从结构上保证执行不可绕过门禁;校验(判重/协议/预算)和执行(ToolBoundary/canonical)内联在同一个流程里,保证『执行前判重、执行后记录』的时机原子性,且模型拿到的永远是有界观察而不是 raw。"
|
||||
|
||||
### 10.4 为什么 begin/markReady/markError 三段式
|
||||
|
||||
> "Tool 执行是跨门禁、backend IO、校验、投影多个不可靠阶段的流程,所以 canonical 记录用 begin → markReady/markError 的追加式状态机:begin 先落 PROJECTING 保证已开始的调用必有痕迹,backend 成功且投影成功才 markReady 定案为 READY,任何异常 markError 收尾;三个状态各校验合法字段组合,record 不可变保证一旦写入不可篡改——这样崩溃可恢复、审计可对账(startedAt/completedAt 算耗时)、证据可验真(READY 不可造假),重复 begin 还能幂等拦截。"
|
||||
|
||||
### 10.5 谁判空谁判价值
|
||||
|
||||
> "空不空是客观事实,代码可判,所以 evidenceStatus 归 Harness 的 Projector;有没有用是语义判断,代码判不了(一段通用知识可能看似相关实则无用),所以 information_gain 归模型。Harness 绝不把客观状态交给模型声明,模型也绝不替 Harness 做停止裁决——两层的信任边界是分开的。"
|
||||
|
||||
### 10.6 无状态 bean
|
||||
|
||||
> "Harness 的单例 bean(core/ToolBoundary/Adapter)全部无状态:字段只有构造注入的依赖和配置,创建后不变;所有可变状态外置到每个 Run 的 RunContext(预算计数、进度 Tracker、生命周期)和持久化存储里。方法一律以 context 参数显式传入,所以同一个 bean 实例能并发服务多个 Run 而不串账——状态在参数里,不在实例里。"
|
||||
|
||||
## 11. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `DiagnosisProgressTracker` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressTracker.java` |
|
||||
| `ToolScopeNormalizer` | `src/main/java/com/superbiz/agent/harness/progress/ToolScopeNormalizer.java` |
|
||||
| `DiagnosisProgressProjector` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjector.java` |
|
||||
| `DiagnosisProgressProjection` | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisProgressProjection.java` |
|
||||
| progress 枚举/record/异常 | `src/main/java/com/superbiz/agent/harness/progress/`(其余 9 个文件) |
|
||||
| `HarnessToolInterceptor` | `src/main/java/com/superbiz/agent/harness/agent/HarnessToolInterceptor.java` |
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java` |
|
||||
| `DiagnosisAgentFactory` | `src/main/java/com/superbiz/agent/harness/agent/DiagnosisAgentFactory.java` |
|
||||
| `RagToolAdapter` | `src/main/java/com/superbiz/agent/harness/tool/adapter/RagToolAdapter.java` |
|
||||
| `ToolBoundary` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| `ToolBoundaryResult` | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundaryResult.java` |
|
||||
| `CanonicalToolInvocation` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalToolInvocation.java` |
|
||||
| `CanonicalInvocationStore` | `src/main/java/com/superbiz/agent/harness/tool/store/CanonicalInvocationStore.java` |
|
||||
| `DiagnosisReleaseUseCase` | `src/main/java/com/superbiz/agent/harness/release/DiagnosisReleaseUseCase.java` |
|
||||
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
|
||||
@@ -0,0 +1,382 @@
|
||||
# Harness tool 域代码学习笔记:工具的注册、调用与执行链路
|
||||
|
||||
**更新日期**:2026-08-03
|
||||
**主题**:tool 域 49 个文件的完整链路——装配 → 注册 → 调用 → 执行 → 返回,拆分阶段讲,最后合并
|
||||
**设计视角**:[Harness 组件全景-职责-设计原因与边界](Harness组件全景-职责-设计原因与边界.md) §7 Tool
|
||||
**代码视角**:[Harness progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)(progress 域衔接,本笔记是 tool 域)
|
||||
|
||||
## 1. 定位:tool 域管什么
|
||||
|
||||
**职责**:工具如何安全执行、保存真相并只暴露必要内容。
|
||||
|
||||
| 问题 | 不解决会怎样 | 催生的层 |
|
||||
|---|---|---|
|
||||
| 每个 Adapter 自己写授权/预算/审计 → 漂移 | 三个工具三种行为 | **Boundary**(统一门禁) |
|
||||
| raw 结果直接给模型 | 敏感数据泄露、超大响应、无结构 | **Projector**(有界投影) |
|
||||
| 模型可能编造证据 | 结论无法验真、审计黑洞 | **Store**(canonical 真相) |
|
||||
| MySQL 查询不可控 | 写库、删库、危险 SQL | **MySQL 沙箱**(只读红线) |
|
||||
|
||||
**49 文件分 6 组**:
|
||||
|
||||
| 组 | 数量 | 角色 |
|
||||
|---|---|---|
|
||||
| Contract | 18 | 跨层类型化语言(Call/Request/Result) |
|
||||
| Boundary | 7 | 统一门禁(ToolBoundary + 配套) |
|
||||
| Projection | 3 | raw → 有界 agent 契约 |
|
||||
| Store | 9 | canonical 真相持久化 |
|
||||
| Adapter | 3 | 接线(boundary + 后端 + 投影器) |
|
||||
| MySQL 沙箱 | 9 | 只读执行 + fail-closed 校验 |
|
||||
|
||||
**核心设计**:几乎不用继承——用「接口 + 组合 + 函数式接口」三件套解耦。
|
||||
|
||||
---
|
||||
|
||||
## 2. 阶段一:装配(config → bean 注入链)
|
||||
|
||||
### 2.1 注入链
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph 底层
|
||||
R["RedisCanonicalInvocationStore"]
|
||||
B["ToolBoundary"]
|
||||
RP["RagResultProjector"]
|
||||
LP["QueryLogsResultProjector"]
|
||||
end
|
||||
subgraph 中层
|
||||
RA["RagToolAdapter"]
|
||||
QA["QueryLogsToolAdapter"]
|
||||
MA["MysqlToolAdapter"]
|
||||
end
|
||||
subgraph 顶层
|
||||
ET["HarnessEvidenceTools"]
|
||||
end
|
||||
R --> B
|
||||
B --> RA
|
||||
B --> QA
|
||||
B --> MA
|
||||
RP --> RA
|
||||
LP --> QA
|
||||
ET --> RA
|
||||
ET --> QA
|
||||
ET --> MA
|
||||
```
|
||||
|
||||
注入规律:所有 `@Bean` 方法参数 = 依赖注入点;**没有任何类 extends 别人**。
|
||||
|
||||
### 2.2 为什么不用继承
|
||||
|
||||
```
|
||||
❌ 继承方案(没采用):
|
||||
abstract class BaseToolAdapter { ... }
|
||||
RagToolAdapter extends BaseToolAdapter { ... }
|
||||
→ 加一个工具就得改基类,横切逻辑散落
|
||||
|
||||
✅ 组合方案(实际):
|
||||
Adapter = ToolBoundary(门禁) + 后端(执行) + Projector(投影)
|
||||
↑ 构造注入持有引用,不是继承
|
||||
→ 每个 Adapter 独立组装,改一个不影响其他
|
||||
```
|
||||
|
||||
组合的优势:
|
||||
1. **ToolBoundary 对三种工具完全无感知**——只认 `ToolExecutor` / `ToolResultProjector` 两个端口,三个工具共用同一个实例;
|
||||
2. **后端各不相同**(LookupKnowledgeTool / QueryLogsTools / JDBC),无法抽象成共同基类,用函数式接口适配;
|
||||
3. **开闭原则**:加新工具 = 新写 Adapter + Projector + config 注册,**不动已有类**(门禁/真相/进度自动继承)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 阶段二:注册(HarnessEvidenceTools 门面)
|
||||
|
||||
### 3.1 两个平行的注册表
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph fromAdapters
|
||||
RAG["ragAdapter::execute"]
|
||||
LOGS["logsAdapter::execute"]
|
||||
MYSQL["mysqlAdapter::execute"]
|
||||
end
|
||||
subgraph HarnessEvidenceTools
|
||||
direction TB
|
||||
CALL["callbacks(List)<br/>模型可见 Schema + 必炸"]
|
||||
INV["invokers(Map)<br/>toolName → bridge 闭包"]
|
||||
end
|
||||
RAG -->|bridge| INV
|
||||
LOGS -->|bridge| INV
|
||||
MYSQL -->|bridge| INV
|
||||
INV -.同一个工具名串起.-> CALL
|
||||
CALL --> MODEL["模型(可见工具目录)"]
|
||||
INV --> INTERCEPTOR["拦截器(执行入口)"]
|
||||
```
|
||||
|
||||
**同一工具名字符串串起两个表**:模型从 callbacks 决定调 `lookup_knowledge` → 拦截器用同一个名字去 invokers 取执行器。
|
||||
|
||||
### 3.2 bridge:方法引用绑定实际调用者
|
||||
|
||||
```java
|
||||
private static EvidenceToolInvoker bridge(String toolName, AdapterCall adapter) {
|
||||
// lambda 闭包捕获 adapter 实例 + 固定的 toolName
|
||||
return (context, toolCallId, arguments) -> adapter.execute(
|
||||
context,
|
||||
new ToolCallRequestEnvelope(
|
||||
context.runId(), toolCallId, toolName, arguments, true, true));
|
||||
}
|
||||
```
|
||||
|
||||
关联链(三层绑定):
|
||||
|
||||
```
|
||||
① config:new RagToolAdapter(boundary, mapper, projector, backend)
|
||||
→ adapter 实例已组合好 boundary + projector + backend
|
||||
② fromAdapters:ragAdapter::execute 是「绑定实例的方法引用」
|
||||
→ bridge lambda 捕获它 —— invoker 与 Adapter 的关联在此固化
|
||||
③ 构造方法:按工具名常量 put 进 invokers —— "lookup_knowledge" → 捕获了 ragAdapter 的 lambda
|
||||
```
|
||||
|
||||
**invoker 与调用者的关联 = 方法引用绑定**:取出来直接 `adapter.execute(...)`,不需要再查表找调用者。
|
||||
|
||||
### 3.3 三个关键设计点
|
||||
|
||||
**① callbacks 的必炸保护**:
|
||||
|
||||
```java
|
||||
FunctionToolCallback.builder(name, ignored -> {
|
||||
throw new IllegalStateException(
|
||||
"Harness evidence Tools require the framework Tool interceptor");
|
||||
})
|
||||
```
|
||||
|
||||
| 场景 | callback 行为 |
|
||||
|---|---|
|
||||
| 正常(拦截器接管) | 不执行(拦截器直接 evidenceTools.invoke) |
|
||||
| 异常(某处 handler.call / 直接调) | **抛异常** → 暴露「绕过门禁」的 bug |
|
||||
|
||||
结构性保证:唯一能执行证据工具的路径 = 拦截器接管 → 门禁永远在线;绕过不可能静默成功(fail-fast)。
|
||||
|
||||
**② mysql 条件注册**:
|
||||
|
||||
```java
|
||||
// config
|
||||
boolean mysqlEnabled = 数据源配置了 jdbcUrl ? true : false;
|
||||
return fromAdapters(rag, logs, mysqlEnabled ? mysql : null);
|
||||
|
||||
// fromAdapters
|
||||
EvidenceToolInvoker mysql = mysqlAdapter == null ? null : bridge(QUERY_MYSQL, mysqlAdapter::execute);
|
||||
// 构造方法里 null 也不注册 invokers / callbacks
|
||||
```
|
||||
|
||||
没配数据源 → query_mysql 从模型视野和执行注册表**都消失**(不暴露「必死工具」)。RAG/日志后端内置,无条件注册。
|
||||
|
||||
**③ definition 的 typed 输入类**:
|
||||
|
||||
```java
|
||||
definition(AgentToolContracts.LOOKUP_KNOWLEDGE, ..., RagToolCall.class)
|
||||
```
|
||||
|
||||
输入类型 = 模型必须匹配的 Schema——`RagToolCall{previous_observation, input}`。parse 时用 `FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS` 强制匹配,**模型输出多一个字段都炸**(INVALID_ENVELOPE)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 阶段三:调用(拦截器 → 注册表)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as 模型
|
||||
participant F as 框架 ReactAgent
|
||||
participant I as HarnessToolInterceptor
|
||||
participant ET as HarnessEvidenceTools
|
||||
|
||||
M->>F: 决定调用 lookup_knowledge(输出 tool_call JSON)
|
||||
F->>I: 回调 interceptToolCall(request, handler)
|
||||
I->>I: supports(toolName) ? 注册检查
|
||||
I->>ET: parse(toolName, arguments, mapper) → typed Envelope
|
||||
ET-->>I: ParsedAgentToolCall(previous_observation + input)
|
||||
I->>I: 协议校验 / 判重(不通过不执行)
|
||||
I->>ET: invoke(context, toolName, toolCallId, args)
|
||||
ET->>I: bridge lambda → adapter.execute
|
||||
```
|
||||
|
||||
调用链要点:
|
||||
|
||||
| 点 | 说明 |
|
||||
|---|---|
|
||||
| **工具名是模型决定的** | `request.getToolName()` 来自模型输出,拦截器拿它查 invokers |
|
||||
| **invoke 前有三道门** | supports 分流 → parse 严格契约 → 协议/判重——判重不通过不执行 |
|
||||
| **envelope 现造** | bridge 里构造,`authorized=true, readOnly=true` 写死——每个进 ToolBoundary 的信封都声明「已授权 + 只读」 |
|
||||
| **工具名被闭包捕获** | 即使调用方传错名字,envelope 里也是正确的工具名(防混淆) |
|
||||
|
||||
---
|
||||
|
||||
## 5. 阶段四:执行(Adapter → ToolBoundary → 后端)
|
||||
|
||||
### 5.1 Adapter = 接线员
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AD["Adapter.execute"] -->|"boundary.execute(context, envelope,"| TB["ToolBoundary"]
|
||||
AD -->|"executor = ignored -> legacyExecutor.execute(query)"| TB
|
||||
AD -->|"projector = raw -> projector.project(...)"| TB
|
||||
TB -->|"executor 跑 backend"| BK["具体后端<br/>LookupKnowledgeTool / QueryLogsTools / JDBC"]
|
||||
TB -->|"projector 投影"| PR["RagResultProjector / QueryLogsResultProjector / MysqlResultProjector"]
|
||||
TB -->|"markReady"| ST["CanonicalInvocationStore"]
|
||||
```
|
||||
|
||||
**两个端口**:executor(跑 backend 拿 raw)+ projector(raw → 有界脱敏契约)。**模型永远看不到 raw**——这是执行链的核心目的。
|
||||
|
||||
### 5.2 LegacyExecutor vs 专用 Executor
|
||||
|
||||
| | RAG/日志 | MySQL |
|
||||
|---|---|---|
|
||||
| 后端来源 | 重构前旧类(LookupKnowledgeTool / QueryLogsTools) | 全新实现(JdbcMysqlReadOnlyExecutor) |
|
||||
| 端口位置 | Adapter **内部**定义 LegacyExecutor | mysql **包**里定义 MysqlReadOnlyExecutor |
|
||||
| 注入方式 | `backend::lookupKnowledge` 方法引用 / lambda | 直接注入专用实现 |
|
||||
| 为什么 | 复用成熟旧代码 | 新工具直接面向沙箱设计 |
|
||||
|
||||
```
|
||||
RAG: Adapter → LegacyExecutor(Adapter内部) → LookupKnowledgeTool(旧后端)
|
||||
MySQL: Adapter → MysqlReadOnlyExecutor(mysql包) → JdbcMysqlReadOnlyExecutor(新实现)
|
||||
```
|
||||
|
||||
### 5.3 ToolBoundary 五阶段(执行门禁)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["① preflight + Tool 预算 + request bytes → begin(PROJECTING)"]
|
||||
B["② executor.execute(requestJson) → backend raw"]
|
||||
C["③ raw 大小校验 + Run bytes 预留"]
|
||||
D["④ projector.project(raw) → 有界 agent_result + evidenceStatus"]
|
||||
E["⑤ agent_result 校验 + bytes → markReady(READY)"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
**三笔 bytes 预留**:request(①)/ raw(③)/ agent_result(⑤)。
|
||||
|
||||
### 5.4 脱敏与有界(投影器)
|
||||
|
||||
- **日志**:sanitize 抹掉密码/token/主机/Pod/IP/PID/SQL 字面量;均匀采样 + 模式聚合;
|
||||
- **MySQL**:敏感列(password/token/secret 等)单元格 → `[REDACTED]`;行数/字符/字节三重截断;
|
||||
- **RAG**:chunk 级去重 + 摘录截断 + fitBudget 总字节兜底。
|
||||
|
||||
---
|
||||
|
||||
## 6. 阶段五:返回(双源校验 → 记账 → 有界观察)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant TB as ToolBoundary
|
||||
participant I as HarnessToolInterceptor
|
||||
participant P as DiagnosisProgressTracker
|
||||
participant M as 模型
|
||||
|
||||
TB-->>I: ToolBoundaryResult(READY/ERROR)
|
||||
I->>I: 双源交叉验证(声明值 vs 内容重算 evidence_status)
|
||||
alt 不一致
|
||||
I->>M: OBSERVATION_CONTRACT_MISMATCH(拒绝)
|
||||
else 一致
|
||||
I->>P: recordCompleted(call, evidenceStatus)
|
||||
P-->>I: 快照(NO_EVIDENCE 立即 NO_GAIN / FOUND 挂 pending)
|
||||
I->>I: modelObservation 加工(含 stop_required / stopReason)
|
||||
I-->>M: 有界 observation(模型永远看不到 raw)
|
||||
end
|
||||
```
|
||||
|
||||
**错误处理三种形态**:
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
| Adapter 业务/参数异常 | catch → `INVALID_REQUEST`(不泄露内部细节) |
|
||||
| MySQL 安全异常 | `MysqlSecurityException` 单独 catch → `INVALID_REQUEST` |
|
||||
| 日志后端缺失 | `ObjectProvider.getIfAvailable()` → 返回空结果 JSON(不炸) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 全链路合起来
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as config(启动)
|
||||
participant ET as HarnessEvidenceTools(单例)
|
||||
participant I as 拦截器(per-Run)
|
||||
participant AD as Adapter(单例)
|
||||
participant TB as ToolBoundary(单例)
|
||||
participant ST as CanonicalStore(单例)
|
||||
participant M as 模型
|
||||
|
||||
rect rgb(240, 248, 255)
|
||||
Note over C,ET: ① 装配(应用启动一次)
|
||||
C->>C: 建 boundary / 3 个 Adapter(注入 boundary+后端+投影器)
|
||||
C->>ET: fromAdapters(rag, logs, mysql?)
|
||||
ET->>ET: bridge → invokers + definition → callbacks(平行,同名串起)
|
||||
end
|
||||
|
||||
rect rgb(255, 250, 240)
|
||||
Note over M,AD: ② 调用+执行(每次 Tool Call)
|
||||
M->>I: 模型决定工具名 → 框架回调拦截器
|
||||
I->>I: supports 分流 → parse(typed 严格契约)→ 协议/判重
|
||||
I->>ET: invoke → invokers.get(名字) → bridge 闭包
|
||||
ET->>AD: adapter.execute(context, envelope[现造,授权只读写死])
|
||||
AD->>TB: boundary.execute(context, envelope, executor, projector)
|
||||
TB->>ST: begin(PROJECTING) → executor 跑 raw → projector 投影 → markReady(READY)
|
||||
TB-->>I: ToolBoundaryResult
|
||||
I->>I: 双源校验 → recordCompleted → modelObservation
|
||||
I-->>M: 有界 observation(无 raw)
|
||||
end
|
||||
```
|
||||
|
||||
**四阶段汇总**:
|
||||
|
||||
| 阶段 | 做什么 | 关键类 |
|
||||
|---|---|---|
|
||||
| 装配 | Spring 组合依赖(无继承) | config / Adapter / ToolBoundary |
|
||||
| 注册 | bridge 成 invoker + definition 成 callback(平行同名串起) | HarnessEvidenceTools |
|
||||
| 调用 | supports → parse 严格契约 → 协议/判重 → invoke | Interceptor / HarnessEvidenceTools |
|
||||
| 执行+返回 | boundary 五阶段 → 投影脱敏 → canonical → 双源校验 → 记账 → 有界观察 | Adapter / ToolBoundary / Projector / Store |
|
||||
|
||||
---
|
||||
|
||||
## 8. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| 必炸 = 死工具 | 必炸是**保护**:模型通过拦截器正常执行,只有绕过路径才炸 |
|
||||
| LegacyExecutor 是通用 executor | 它只服务于「复用旧后端」;新工具直接注入专用 Executor(如 MysqlReadOnlyExecutor) |
|
||||
| callbacks 是执行器 | 它是「模型可见目录」+ 必炸占位;真执行走 invokers |
|
||||
| 执行链只有 executor | 还有 **projector**(raw → 有界契约)——模型永远看不到 raw |
|
||||
| MySQL 没有 tool 类 | `JdbcMysqlReadOnlyExecutor` 就是它的后端执行类,只是不叫 Tool |
|
||||
| 工具注册是静态列表 | **配置驱动**:没配数据源 → query_mysql 从两表消失 |
|
||||
| 返回就是 ToolBoundaryResult | 返回后还有双源校验 → recordCompleted → modelObservation |
|
||||
|
||||
## 9. 面试话术合集(30 秒)
|
||||
|
||||
### 9.1 为什么不用继承
|
||||
|
||||
> "tool 域刻意不用继承:ToolBoundary 通过 ToolExecutor/ToolResultProjector 两个函数式端口接收执行和投影逻辑,三个 Adapter 各自用构造注入组合 boundary + 后端 + projector,HarnessEvidenceTools 再用 bridge 把 Adapter 包成统一的 EvidenceToolInvoker 注册表。类图里没有 extends 箭头——全是 has-a(组合)和函数适配(函数式接口),扩展新工具不改任何已有类。"
|
||||
|
||||
### 9.2 为什么必炸保护
|
||||
|
||||
> "必炸保护是执行不可绕过的结构性保证:证据工具的 ToolCallback 被故意定义为直接抛异常,使 handler 路径成为死路。唯一能执行证据工具的路径就是拦截器接管——预算、canonical、进度协议、脱敏门禁永远在线;任何绕过尝试要么抛异常暴露 bug(fail-fast),要么根本不执行(fail-closed)。非证据工具不需要门禁,所以拦截器放行、callback 正常。"
|
||||
|
||||
### 9.3 LegacyExecutor 是什么
|
||||
|
||||
> "LegacyExecutor 是 Adapter 内部定义的旧后端端口:RAG 和日志是重构前就有的工具,后端实现(backend::lookupKnowledge)被方法引用注入复用,通过 Adapter 包进 Harness 门禁——『旧后端复用,新门禁外挂』。它和 ToolBoundary 的 ToolExecutor 是两层:LegacyExecutor 是具体后端怎么查,ToolExecutor 是边界统一端口,Adapter 把前者包成后者。MySQL 是全新工具,没有 legacy,直接用新写的 MysqlReadOnlyExecutor。"
|
||||
|
||||
### 9.4 模型为什么看不到 raw
|
||||
|
||||
> "执行链是两个端口:executor 跑 backend 拿 raw,projector 把 raw 投影成有界脱敏契约(截断 + 脱敏 + 冻结 Schema)。ToolBoundary 只让 READY/ERROR 离开,模型拿到的是 modelObservation 加工后的有界观察——raw 只进 canonical Store 供审计和验真,永远不进入模型上下文。"
|
||||
|
||||
### 9.5 新工具怎么加(开闭原则)
|
||||
|
||||
> "新工具按 MySQL 模板:写 Contract 三件套 + 专用执行器 + 投影器 + Adapter,config 注册。要改的只有 AgentToolContracts 常量、HarnessEvidenceTools 构造、config;不用改 ToolBoundary、canonical、拦截器——新工具自动获得预算门禁、真相记录、脱敏投影、进度收敛、证据验真全套管控。"
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 类 | 文件 |
|
||||
|---|---|
|
||||
| `HarnessEvidenceTools` | `src/main/java/com/superbiz/agent/harness/agent/HarnessEvidenceTools.java`(agent 包,tool 域门面) |
|
||||
| `RagToolAdapter` / `QueryLogsToolAdapter` / `MysqlToolAdapter` | `.../tool/adapter/` |
|
||||
| `ToolBoundary` / `ToolBoundaryResult` / `ToolCallRequestEnvelope` / `ToolExecutor` / `ToolResultProjector` / `ProjectedToolResult` | `.../tool/boundary/` |
|
||||
| `RagResultProjector` / `QueryLogsResultProjector` / `ToolProjectionLimits` | `.../tool/projection/` |
|
||||
| `CanonicalInvocationStore` / `RedisCanonicalInvocationStore` / `CanonicalToolInvocation` / `ToolCallKeyFactory` / `CanonicalInvocationLimits` | `.../tool/store/` |
|
||||
| Contract 18 个 | `.../tool/contract/` |
|
||||
| `MysqlSqlValidator` / `JdbcMysqlReadOnlyExecutor` / `MysqlResultProjector` / `MysqlDataSourceDefinition` 等 | `.../tool/mysql/` |
|
||||
| 装配 | `src/main/java/com/superbiz/agent/config/HarnessChatConfiguration.java` |
|
||||
@@ -0,0 +1,223 @@
|
||||
# Harness 整体架构学习笔记:从装配到入口到记忆到知识库写入
|
||||
|
||||
**更新日期**:2026-08-04
|
||||
**主题**:整体架构五块补充(了解层面)——配置装配中心 / HTTP 入口层 / 会话系统与记忆体系 / 知识库写入链路
|
||||
**配套**:九域主线笔记(core/retry/progress/tool/guard/release/agent/audit/contract 全 ✅)
|
||||
|
||||
## 1. 配置装配中心(整体怎么搭起来)
|
||||
|
||||
### 1.1 装配全景(Bean 拓扑)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["ChatHarnessProperties<br/>配置集中(yml)"] --> K["DiagnosisHarnessCore<br/>总闸门:超时/预算/重试/收敛"]
|
||||
K --> B["ToolBoundary<br/>工具底座:canonical/门禁/投影"]
|
||||
B --> A1["RagToolAdapter"]
|
||||
B --> A2["QueryLogsToolAdapter"]
|
||||
B --> A3["MysqlToolAdapter<br/>(可选装配)"]
|
||||
A1 --> E["HarnessEvidenceTools<br/>组装注册"]
|
||||
A2 --> E
|
||||
A3 --> E
|
||||
K --> G["GuardModelCall<br/>(守卫/修复/路由共用底座)"]
|
||||
G --> SG["SemanticGuard"]
|
||||
G --> ER["EvidenceRepair"]
|
||||
G --> R["IntentRouter"]
|
||||
E --> F["DiagnosisAgentFactory"]
|
||||
F --> UC["DiagnosisAgentUseCase"]
|
||||
UC --> D["DiagnosisChatExecutor"]
|
||||
R --> D
|
||||
R --> S["SystemChatExecutor"]
|
||||
R --> KQ["KnowledgeQueryExecutor"]
|
||||
D --> A["ChatApplicationUseCase<br/>应用入口"]
|
||||
S --> A
|
||||
KQ --> A
|
||||
```
|
||||
|
||||
### 1.2 三层组织(话术版)
|
||||
|
||||
```text
|
||||
① 总闸门(core):能花多少钱/跑多久/怎么重试/何时停——配置集中,改一处全局生效
|
||||
② 工具底座:所有工具统一留痕(canonical)/拦截(ToolBoundary)/裁剪(投影)——行为整齐划一
|
||||
③ 具体工具:RAG/Logs/MySQL 按需装配(没配数据源不装死工具)→ 组装注册给 Agent
|
||||
|
||||
串联:先判意图(路由)→ 走对应分支 → 全程在总闸门管辖下
|
||||
一句话:边界集中、执行统一、工具可插拔
|
||||
```
|
||||
|
||||
### 1.3 关键设计点
|
||||
|
||||
| 设计 | 为什么 |
|
||||
|---|---|
|
||||
| 单一装配入口(HarnessChatConfiguration) | 读 Bean 签名 = 读架构拓扑 |
|
||||
| 一切围绕 core | 所有链路共享同一套门禁(超时/预算/重试/收敛) |
|
||||
| 配置属性集中(@EnableConfigurationProperties) | 一处改全局生效,不会有的环节漏管 |
|
||||
| 工具可选装配(mysqlEnabled 判断) | 没配置不装死工具;ObjectProvider 可选后端 |
|
||||
| 守卫/修复/路由共用 GuardModelCall | LLM judge 模式:同一轻量模型底座 |
|
||||
| Redis 存 canonical | 跨实例共享 + TTL 过期 |
|
||||
| 线程池 AbortPolicy | 队列满直接拒绝(fail fast) |
|
||||
|
||||
## 2. HTTP 入口层(薄协议适配)
|
||||
|
||||
### 2.1 请求流时序
|
||||
|
||||
```text
|
||||
POST /api/chat {Id, Question}
|
||||
→ 校验 → new SseEmitter + ChatSseSession(= ChatApplicationObserver)
|
||||
→ chatWorkerExecutor.execute(...) ← 异步:HTTP 线程不跑模型
|
||||
→ 立即返回 200 + TEXT_EVENT_STREAM
|
||||
→ worker 线程执行编排,经 session 推事件
|
||||
→ 队列满 → 503(RejectedExecutionException)
|
||||
```
|
||||
|
||||
### 2.2 SSE 状态机 + 五类事件
|
||||
|
||||
```text
|
||||
状态机:NEW → OPEN → TERMINAL(收尾)/ DISCONNECTED(断连)
|
||||
每个方法 requireState 校验顺序——防乱序推送
|
||||
|
||||
事件协议:
|
||||
metadata → {session_id, run_id}(首推)
|
||||
status → 编排进度(ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING…)
|
||||
content → 最终内容(content_type + payload)
|
||||
failure → 失败码 + 消息
|
||||
done → 终态(SUCCESS/FALLBACK/FAILED)★ CANCELLED 对外不可见
|
||||
```
|
||||
|
||||
### 2.3 断连取消链路(贯穿到 Harness)
|
||||
|
||||
```text
|
||||
客户端断开 → emitter.onTimeout/onError/onCompletion → session.disconnect()
|
||||
→ 状态 DISCONNECTED → runControl.cancelClientDisconnect()
|
||||
→ Harness 取消机制接管(checkActive / 拦截器 / 线程池 cancel)
|
||||
onStarted 时若已断连:直接取消——不白跑
|
||||
```
|
||||
|
||||
### 2.4 失败两层出口
|
||||
|
||||
```text
|
||||
SSE 通道:ChatApplicationException → session.fail(failure + done(FAILED))
|
||||
其他 RuntimeException → INTERNAL_FAILURE 通用信息(不泄露细节)
|
||||
REST 通道:GlobalExceptionHandler → 404(SessionNotFound)/ 400(参数/文档/文件超限)/ 500(兜底)
|
||||
|
||||
→ 编排异常走 SSE failure,REST 异常走 HTTP 状态码——都不暴露内部细节
|
||||
```
|
||||
|
||||
## 3. 会话系统与记忆体系(术语精确校准)
|
||||
|
||||
### 3.1 会话存储:不存历史,存「可重放的发布结果」
|
||||
|
||||
```text
|
||||
ChatSession(chat_session 表):只存元数据(status/messagePairCount/时间戳)
|
||||
——「message history is not persisted here」
|
||||
DiagnosisSession:诊断快照(query/answer/selfEvaluation/feedback)
|
||||
真正的历史:DiagnosisRun(每次运行一行)+ PublishedResult(JSON 落库)
|
||||
```
|
||||
|
||||
### 3.2 PreviousTurn 注入链路(短期记忆)
|
||||
|
||||
```text
|
||||
ChatApplicationUseCase 开头读 findPreviousTurn(sessionId)
|
||||
→ 查最近 SUCCESS+DIAGNOSIS+publishedResult 非空的 Run
|
||||
→ 反序列化 PublishedResult → PublishedResultPolicy 生成【有界】摘要
|
||||
(limitations 10 条×500 字 / 源文档 10 个 / 字段限长——有界在生成时)
|
||||
→ 传 executePath → DiagnosisChatExecutor
|
||||
→ new DiagnosisAgentInput(query, previous_turn)
|
||||
→ 序列化成输入 JSON → agent.call(inputJson) → 模型从输入读到
|
||||
|
||||
设计三决策(话术版):
|
||||
诊断短流程 → 只取上一轮(更早记忆靠多轮逐层传递)
|
||||
非 SUCCESS 误导 → SUCCESS 才准入(且非 SUCCESS 轮次根本没写 PublishedResult)
|
||||
token 爆炸 → 有界摘要(PreviousTurnLimits)
|
||||
补充:可回放——模型看有界摘要,审计看全量 JSON(两层分离)
|
||||
```
|
||||
|
||||
### 3.3 记忆术语校准(重要认知)
|
||||
|
||||
```text
|
||||
判定标准:记忆 = 会被【注入 prompt/上下文】的东西(不是存了什么)
|
||||
|
||||
PreviousTurn → 注入输入 JSON(user message)→ ✅ 短期记忆(当前会话)
|
||||
lookup_knowledge → 工具调用动态获取(tool result)→ ❌ 记忆,是检索增强(RAG)
|
||||
知识库 → 检索源,规模太大无法全量注入 → 归检索侧(工具检索是正确形态)
|
||||
案例库 → 有结构有写入、缺检索注入 → 长期记忆的【候选原料】
|
||||
运行档案 → 元数据层永不注入 → 审计数据
|
||||
|
||||
项目真实情况:只有短期记忆(PreviousTurn)+ 检索增强(RAG),【没有】长期记忆层
|
||||
```
|
||||
|
||||
### 3.4 长期记忆设计路径(如果要做)
|
||||
|
||||
```text
|
||||
筛选标准:规模可控 + 跨会话价值 + 可注入形态
|
||||
|
||||
案例库最符合:root_cause+solution 结构化摘要、注入 top 2-3 条、相似故障复用解法
|
||||
PreviousTurn 扩展:最近 N 轮结论摘要(短期 → 中期记忆)
|
||||
Feedback 偏好:用户偏好摘要
|
||||
知识库不符合:全量太大 → 保持工具检索
|
||||
|
||||
案例 → skill 提炼(项目已实现):
|
||||
6 个 SKILL.md(diagnose-mysql-connection-pool 等)
|
||||
结构:Workflow / Required Evidence / Stop Conditions / Report Rules / Eval Anchor
|
||||
注入:ClasspathSkillRegistry → SystemPromptTemplate → system prompt(程序性长期记忆)
|
||||
情景记忆(案例)→ 程序记忆(skill)→ 常驻注入 ✅ 长期记忆的正确形态
|
||||
现有缺口:单技能激活(只放行 1 个)/ 静态加载(无按 query 自动匹配)/ skill 与案例库断开
|
||||
```
|
||||
|
||||
## 4. 知识库写入链路(RAG 写半边)
|
||||
|
||||
```text
|
||||
上传(/api/documents/upload)
|
||||
→ TextExtractorService(文本提取)
|
||||
→ DocumentChunkService.chunkDocument(分块)★
|
||||
→ VectorEmbeddingService(dense embedding)
|
||||
→ VectorIndexService.indexDocumentChunks(写 Milvus)★
|
||||
→ 检索侧(lookup_knowledge)读同一份索引
|
||||
```
|
||||
|
||||
### 关键设计
|
||||
|
||||
```text
|
||||
① 分块:按章节分块(非定长硬切)+ 相邻 chunk 保留 overlap(减轻边界断裂)
|
||||
双条件限制:maxSize(字符)+ maxTokens(token)
|
||||
② hybrid 写入:dense(应用侧 embedding → vector 字段)
|
||||
+ BM25(buildSearchText → search_text 字段)
|
||||
★ 关键:dense embedding 输入 与 BM25 search_text 【同源】——
|
||||
同一个「增强文本」既喂 embedding 又写 BM25 字段
|
||||
→ 两路召回看到完全一致的文档内容,混合检索才公平
|
||||
③ 弃用:legacy MilvusServiceClient(旧 SDK)/ Spring AI VectorStore#add(无 hybrid schema)
|
||||
唯一后端:MilvusHybridKnowledgeStore(Milvus SDK v2)
|
||||
```
|
||||
|
||||
## 5. 易错点
|
||||
|
||||
| 易错 | 正确 |
|
||||
|---|---|
|
||||
| Controller 做业务编排 | 薄适配层:校验+开 SSE+异步+写回,编排在 Application |
|
||||
| SSE 顺序不重要 | requireState 状态机校验——防乱序推送 |
|
||||
| 取消对外可见 | Done 拒绝 CANCELLED——客户端只看到 SUCCESS/FALLBACK/FAILED |
|
||||
| 知识库 = 长期记忆 | 是检索源(工具动态获取);记忆 = prompt 注入——项目无长期记忆层 |
|
||||
| 案例 = 长期记忆 | 是候选原料——缺检索注入;skill 才是程序性长期记忆(已实现) |
|
||||
| 工具预算在工具层 | maxToolCalls/收敛参数都在 core(总闸门) |
|
||||
| 分块定长硬切 | 按章节 + overlap + 双条件限制 |
|
||||
|
||||
## 6. 面试话术(30 秒)
|
||||
|
||||
### 6.1 整体架构怎么组织
|
||||
|
||||
> "整个系统从下往上三层:最底层一套全局规则(超时/预算/重试/收敛,配置集中改一处全局生效);中间一层工具共用的底座(调用留痕、统一拦截、结果裁剪);上层按需装配具体工具(知识库/日志/数据库,配了才装)。最后串成应用入口——先判意图再走分支,全程在总闸门管辖下。一句话:边界集中、执行统一、工具可插拔。"
|
||||
|
||||
### 6.2 记忆体系
|
||||
|
||||
> "记忆的判定标准是会不会被注入上下文:PreviousTurn 是短期记忆(注入输入 JSON,上一轮 SUCCESS 的有界摘要);知识库是检索增强不是记忆(工具动态获取);项目没有长期记忆层——skill(案例提炼的诊断方法)注入 system prompt 是程序性长期记忆的正确形态;案例库是候选原料,缺检索注入。"
|
||||
|
||||
## 7. 代码位置索引
|
||||
|
||||
| 块 | 文件 |
|
||||
|---|---|
|
||||
| 装配中心 | `config/HarnessChatConfiguration.java`(+ `config/ChatHarnessProperties.java`) |
|
||||
| HTTP 入口 | `controller/ChatController.java` + `controller/sse/ChatSseSession.java` / `ChatSseEvent.java` / `SseEmitterChatSink.java` |
|
||||
| 异常映射 | `exception/GlobalExceptionHandler.java` |
|
||||
| 会话存储 | `harness/application/persistence/JpaChatRunStore.java` / `PreviousTurnLimits.java` / `PublishedResultPolicy.java` |
|
||||
| 记忆注入 | `harness/application/ChatApplicationUseCase.java` + `harness/application/executor/DiagnosisChatExecutor.java` + `harness/agent/DiagnosisAgentUseCase.java` |
|
||||
| skill 机制 | `config/SkillConfig.java` + `src/main/resources/skills/*/SKILL.md` |
|
||||
| 知识库写入 | `service/DocumentChunkService.java` / `VectorIndexService.java` / `VectorEmbeddingService.java` / `KnowledgeBaseInitService.java` + `controller/DocumentController.java` / `KnowledgeBaseController.java` |
|
||||
@@ -0,0 +1,164 @@
|
||||
# Harness 证据安全链学习笔记:从收敛控制到唯一发布点
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:progress → guard → release 三域联动——双通道验证架构 + 状态流转全景 + 关键字段来源与使用
|
||||
**配套**:[progress 代码学习笔记](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md)、[Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md)、[执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)
|
||||
|
||||
## 1. 一句话定位
|
||||
|
||||
**证据安全链 = progress(执行期收敛控制)→ guard(验证)→ release(唯一发布点)**:
|
||||
任何对外发布的内容,必须能追溯到 canonical 账本的已验证事实;任何无法证明的内容,只能以有界、诚实的 SafeFallback 降级形态出现。
|
||||
|
||||
## 2. 主链路图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 执行期["Agent 执行期(core 控资源 + progress 控收敛)"]
|
||||
TC["HarnessToolInterceptor<br/>工具调用完成"]
|
||||
TC -->|"完整记录"| CS["Canonical Store<br/>(唯一真相源, TTL 2h)"]
|
||||
TC -->|"identity"| TR["Progress Tracker<br/>(toolCallId+toolName+scope)"]
|
||||
end
|
||||
|
||||
subgraph 停止["Agent 结束(所有出口触发投影)"]
|
||||
TR -->|"回读验真"| PP["DiagnosisProgressProjector"]
|
||||
PP --> PS["ProgressSnapshot<br/>(observedFacts+limitations+stopReason)"]
|
||||
end
|
||||
|
||||
subgraph 裁决["Release 裁决(唯一发布点)"]
|
||||
EX["DiagnosisAgentExecution<br/>(draft / stopped)"]
|
||||
EX --> RC["releaseConclusion<br/>(有结论)"]
|
||||
RC -->|"tool_call_ids"| EG["EvidenceGuard<br/>机械验引用"]
|
||||
EG -->|"失败"| ER["EvidenceRepair<br/>只修引用→复查"]
|
||||
EG -->|"通过"| VES["VerifiedEvidenceSnapshot"]
|
||||
VES --> SG["SemanticGuard<br/>判支持度"]
|
||||
SG -->|"SUPPORTED"| SUCCESS["SUCCESS<br/>(唯一出口)"]
|
||||
SG -->|"UNSUPPORTED/不可用"| FB["SafeFallback 降级"]
|
||||
EG -->|"仍失败"| FB
|
||||
EX -->|"stopped / 无结论"| FB
|
||||
PS -->|"降级原料"| FB
|
||||
end
|
||||
|
||||
CS -.->|"回读"| EG
|
||||
CS -.->|"回读"| PP
|
||||
```
|
||||
|
||||
## 3. 双通道验证架构(核心)
|
||||
|
||||
两条并行的「账本背书」通道,合起来覆盖所有结局:
|
||||
|
||||
| | progress 快照 | guard 快照 |
|
||||
|---|---|---|
|
||||
| 类 | `DiagnosisProgressSnapshot` | `VerifiedEvidenceSnapshot` |
|
||||
| 组装时机 | agent 结束那一刻(所有出口) | release 验引用通过后 |
|
||||
| 原料 | Tracker 的调用 identity | draft 里模型写的 tool_call_ids |
|
||||
| 回读者 | `DiagnosisProgressProjector` | `EvidenceGuard` |
|
||||
| 需要 draft | **不需要** | **必须** |
|
||||
| 服务谁 | 受控停止/无结论/非法 draft 降级 | 有结论 draft 证据链 + SemanticGuard |
|
||||
| 共同点 | 都从 canonical 回读、三重校验、去重有界、绝不输出 raw | 同左 |
|
||||
|
||||
**设计意义**:agent 没给出合法 draft(预算打断、输出烂)时,靠 progress 快照降级;给出合法 draft 时,靠 guard 快照支撑。**任何情况下对外发布都有据可依。**
|
||||
|
||||
## 4. 状态流转全景(技术终态 vs 用户终态)
|
||||
|
||||
### 4.1 六层状态(从内到外)
|
||||
|
||||
| 层 | 类型 | 值 | 回答的问题 |
|
||||
|---|---|---|---|
|
||||
| core 生命周期 | `RunState` | RUNNING / SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED | Run 技术上是否还允许继续执行 |
|
||||
| progress 收集停止 | `DiagnosisStopReason` | INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED | 证据收集为何受控停止 |
|
||||
| release 发布裁决 | `ReleaseOutcome` | SUCCESS / FALLBACK / FAILED / CANCELLED | 用户侧内容结局是什么 |
|
||||
| release 降级细分 | `FallbackType` | EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / SEMANTIC_UNAVAILABLE / BUDGET_EXHAUSTED / INSUFFICIENT_EVIDENCE / MISSING_REQUIRED_CONTEXT | FALLBACK 为什么降级 |
|
||||
| SSE 进度 | `ChatApplicationStatus` | ROUTING / DIAGNOSIS_RUNNING / SAFETY_VALIDATING ... | 当前走到哪一阶段(不是结局) |
|
||||
| 对外失败码 | `ChatFailureCode` | RUN_CANCELLED / INTERNAL_FAILURE / ... | 失败时给客户端的粗粒度原因 |
|
||||
|
||||
### 4.2 关键映射规则(代码事实)
|
||||
|
||||
```text
|
||||
RunState.BUDGET_EXHAUSTED + progress.hasObservedFacts()==true
|
||||
→ ReleaseOutcome.FALLBACK(FallbackType.INSUFFICIENT_EVIDENCE)
|
||||
RunState.BUDGET_EXHAUSTED + 无 facts
|
||||
→ FAILED(fail closed:没有安全内容可发布)
|
||||
RunState.CANCELLED → ReleaseOutcome.CANCELLED + ChatFailureCode.RUN_CANCELLED
|
||||
内部失败(completeFailure)→ RunState.FAILED + ReleaseOutcome.FAILED + INTERNAL_FAILURE
|
||||
guard 全过 → RunState.SUCCESS + ReleaseOutcome.SUCCESS(唯一出口)
|
||||
预算耗尽且子路径已产出 FALLBACK → 不二次 completeSuccess
|
||||
```
|
||||
|
||||
### 4.3 两个正交维度的区分(高频面试点)
|
||||
|
||||
- **RunState** 回答「Run 技术上是否还在跑、因何技术终态停下」;
|
||||
- **ReleaseOutcome** 回答「用户侧内容形态:正常报告 / 安全降级 / 失败 / 取消」;
|
||||
- 常见组合:`RunState=BUDGET_EXHAUSTED` 且有安全进展 → `ReleaseOutcome=FALLBACK`;无进展 → `FAILED`。
|
||||
|
||||
### 4.4 终态异常透传
|
||||
|
||||
`DiagnosisReleaseUseCase.propagateTerminal`:`RunAbortedException` / `BudgetExceededException` / `RetryFailure.CANCELLED|BUDGET_EXHAUSTED` **原样上抛**,不吞、不伪装成业务 FALLBACK——取消和预算耗尽是 Run 的技术终态事实,用户侧必须知道「被取消了」而不是「诊断结论是不足」。
|
||||
|
||||
## 5. 关键字段的来源与使用
|
||||
|
||||
### 5.1 CanonicalToolInvocation(唯一真相源,执行时落库)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| tool_call_id + run_id | ToolBoundary 执行时生成 | key = runId + toolCallId(两个投影器都按它回读) |
|
||||
| request / raw_response | 工具请求与原始返回 | 审计/验真,不外发 |
|
||||
| agent_result | Projector 投影后的有界结果 | Agent 可见的唯一形态;EvidenceGuard 重读它 |
|
||||
| status | PROJECTING → READY / ERROR(单向迁移) | isReferencableBy 要求 READY |
|
||||
| evidence_status | FOUND / NO_EVIDENCE / ERROR | kind.accepts() 匹配、投影一致性校验 |
|
||||
| error_code | 仅 ERROR 携带 | 稳定错误码 |
|
||||
| started_at / completed_at | 生命周期时间戳 | TTL / 审计 |
|
||||
|
||||
### 5.2 DiagnosisProgressSnapshot(progress 快照,agent 结束时组装)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| verifiedSources | Projector 回读 canonical,去重 | 降级时展示「查过哪些来源」 |
|
||||
| observedFacts | 同上(≤12 条,摘要 320 字,空查询也算) | 「查了查到什么」;`hasObservedFacts()` 安全阀 |
|
||||
| limitations | 无法验真/截断的诚实说明 | 降级时展示限制 |
|
||||
| stopReason | Tracker 状态 | release 检查白名单后决定降级 |
|
||||
|
||||
### 5.3 VerifiedEvidenceSnapshot(guard 快照,验真通过后组装)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| analyses[] | EvidenceGuard 重读 canonical agent_result 重建 | SemanticGuard.review 输入 |
|
||||
| verifiedSources() | 方法去重(sourceType+source+scope) | SUCCESS 的 published_result.source_documents、FALLBACK 的来源 |
|
||||
|
||||
### 5.4 SafeFallback(降级载荷,release 降级出口构造)
|
||||
|
||||
| 字段 | 来源 | 使用 |
|
||||
|---|---|---|
|
||||
| type | SafeFallbackFactory 按场景 | 用户/审计区分降级原因 |
|
||||
| conclusion | 恒 null | 降级绝不发布根因结论 |
|
||||
| verified_sources / observed_facts | progress 或 guard 快照投影 | 保留已验证事实供用户继续排查 |
|
||||
| limitations / next_steps | 工厂按场景拼装 | 诚实说明 + 下一步 |
|
||||
| failure_stage | DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION | 定位失败阶段 |
|
||||
| validation_issues | EvidenceViolation 映射 | EVIDENCE_VALIDATION_FAILED 时的违规明细 |
|
||||
|
||||
## 6. 设计要点(贯穿全链的规律)
|
||||
|
||||
1. **fail closed 贯穿每一层**:guard 验引用默认拒绝、读投影严格反序列化、release 无 facts 抛异常、SafeFallback 无事实拒绝构造——「不确定 → 默认拒绝,没查到永不伪装成结果」。
|
||||
2. **负向证据被完整建模**:progress 空查询投影成事实、NEGATIVE_OBSERVATION 配 NO_EVIDENCE、降级诚实声明「查到了但不够」。
|
||||
3. **canonical 唯一真相源**:链上不存在第二份工具真相;两个投影器共用同一套三重校验。
|
||||
4. **全链路可审计回放**:EVIDENCE_GUARD_INITIAL/RECHECK、SEMANTIC_ATTEMPT/DECISION、EVIDENCE_REPAIR_ATTEMPT、RELEASE_DECISION 全落 trace。
|
||||
5. **语义不变性**:EvidenceRepair 只修引用不修结论(prompt 锁死 + hasSameUserVisibleSemantics 校验)。
|
||||
6. **命名债务**:`FallbackType.BUDGET_EXHAUSTED` 枚举保留,但预算停实际发布 `INSUFFICIENT_EVIDENCE`(注释自认)。
|
||||
7. **重复验证**:同一 tool_call_id 被多条 analysis 引用会重复验 N 次(引用级独立校验的代价,账本查询便宜可接受)。
|
||||
|
||||
## 7. 面试话术(30 秒)
|
||||
|
||||
> "Harness 的证据安全链是 progress → guard → release 三段联动。**执行期**:progress 管信息增益收敛(预算归 core),工具调用实时落 canonical 账本、Tracker 只记 identity;**验证期**:agent 结束后 release 编排——有结论的 draft 先进 EvidenceGuard 机械验引用(每个 tool_call_id 从账本回读、READY 且 kind 匹配,失败则 EvidenceRepair 只修引用再验),通过后 SemanticGuard 判结论是否被证据支持;**发布期**:SUPPORTED 是唯一 SUCCESS 出口,其余全部经 SafeFallbackFactory 构造有界诚实的降级。关键设计是**双通道**:没有合法 draft 时靠 progress 快照(observedFacts)降级,有 draft 时靠 guard 快照支撑——任何情况对外发布都有据可依;以及**状态正交**:RunState 回答技术终态(预算耗尽/取消),ReleaseOutcome 回答用户结局(降级/失败),取消和预算耗尽经 propagateTerminal 原样上抛,绝不伪装成业务降级。"
|
||||
|
||||
## 8. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| EvidenceGuard / EvidenceViolationCode | `src/main/java/com/superbiz/agent/harness/guard/evidence/` |
|
||||
| SemanticGuard / GuardModelCall | `src/main/java/com/superbiz/agent/harness/guard/semantic/` |
|
||||
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `src/main/java/com/superbiz/agent/harness/release/` |
|
||||
| DiagnosisProgressProjector / Tracker / Snapshot | `src/main/java/com/superbiz/agent/harness/progress/` |
|
||||
| CanonicalToolInvocation / Store | `src/main/java/com/superbiz/agent/harness/tool/store/` |
|
||||
| RunState | `src/main/java/com/superbiz/agent/harness/core/RunState.java` |
|
||||
| DiagnosisStopReason | `src/main/java/com/superbiz/agent/harness/progress/DiagnosisStopReason.java` |
|
||||
| ReleaseOutcome / FallbackType / SafeFallback | `src/main/java/com/superbiz/agent/harness/contract/` |
|
||||
| ChatApplicationStatus / ChatFailureCode | `src/main/java/com/superbiz/agent/harness/application/` |
|
||||
@@ -0,0 +1,307 @@
|
||||
# Harness 面试复习笔记:五步复习与白板图沉淀(详细版)
|
||||
|
||||
**更新日期**:2026-08-06
|
||||
**主题**:面试总复习成果固化——30 秒电梯陈述 / 三张白板图 / 九域五段式面试讲法 / 六个易错点 / 追问应对大全 / 支付超时案例
|
||||
**配套**:[面试速查](Harness面试速查-一张图讲清设计.md)、[设计演进](Harness设计演进-从多Agent编排到确定性控制边界.md)、各域学习笔记
|
||||
|
||||
## 1. 五步复习路径
|
||||
|
||||
```text
|
||||
① 30 秒电梯陈述 + 一张图
|
||||
② 默画三张白板图(主链路 / 职责迁移 / 数据三层)
|
||||
③ 六个易错点
|
||||
④ 2 分钟真实案例(支付超时)
|
||||
⑤ 每域面试话术背诵(九域五段式)
|
||||
```
|
||||
|
||||
## 2. 30 秒电梯陈述(详细版)
|
||||
|
||||
### 2.1 一句话版本
|
||||
|
||||
> "Harness 是包围非确定性 Agent 的确定性控制边界。Diagnosis Agent 负责提出假设、选择 Tool、解释观察并生成 Draft;Harness 负责一次 Run 的身份、deadline、预算、取消、Tool 权限和事实保管,发布前再验证引用真实性与结论支持度。它不保证 Agent 每次都找到根因,但保证执行过程有边界、失败能够收敛,并且只有可验证的内容能够发布。"
|
||||
|
||||
### 2.2 逐句展开(面试官追问「具体怎么做」时用)
|
||||
|
||||
```text
|
||||
「确定性控制边界」展开为三层边界:
|
||||
运行边界:RunContext(身份/截止/预算/取消)+ 唯一终态(first-terminal-wins)
|
||||
事实边界:ToolBoundary(权限/只读/容量)+ canonical 真相 + 有界观察
|
||||
发布边界:EvidenceGuard 验引用 → SemanticGuard 判支持度 → Release 唯一出口
|
||||
```
|
||||
|
||||
### 2.3 三个重点(背的时候盯住)
|
||||
|
||||
```text
|
||||
Agent 负责业务推理(出草稿,不是出报告)
|
||||
Harness 负责确定性约束(真相与观察分离)
|
||||
Release 决定什么可以公开(引用真实与结论支持是两个独立门禁)
|
||||
```
|
||||
|
||||
### 2.4 不要一开始列 10 个职责域
|
||||
|
||||
先给一句定义 + 三句话,面试官追问「具体怎么做」再沿三张白板图展开。
|
||||
|
||||
## 3. 三张白板图(详细版)
|
||||
|
||||
### 3.1 图一:主链路(含分支,不是单轮)
|
||||
|
||||
```text
|
||||
chat 接口
|
||||
→ 创建 RunContext(core.startRun:runId/deadline/budget/cancel/lifecycle)
|
||||
→ 意图识别(IntentRouter,单次模型调用,输出契约恰好 {intent} 枚举)
|
||||
→ 诊断 Agent(ReAct 多轮循环)
|
||||
├─ agent 决策 → ToolBoundary
|
||||
│ ├─ preflight(run 匹配/授权/只读/JSON/key)→ 失败不落库
|
||||
│ ├─ 预算门禁(beforeToolCall + bytes 三笔预留)
|
||||
│ ├─ canonical 状态机(begin PROJECTING → READY/ERROR)
|
||||
│ ├─ 执行 + Projector 投影(严格校验 + 脱敏 + 截断)
|
||||
│ └─ 有界观察返回 Agent(模型永远看不到 raw)
|
||||
├─ progress 判 GAINED/NO_GAIN(连续 NO_GAIN → 饱和停止)
|
||||
└─ ↺ 循环直到:出草稿 或 受控停止(预算/饱和/协议违规)
|
||||
→ 分支 A(有结论草稿):
|
||||
EvidenceGuard 验引用(key=runId+toolCallId 查账本,READY + kind 匹配)
|
||||
├─ 失败 → EvidenceRepair 只修引用(prompt 锁死 + 语义不变性)→ 复查
|
||||
│ └─ 仍失败 → SafeFallback(EVIDENCE_VALIDATION_FAILED)
|
||||
└─ 通过 → SemanticGuard 判支持度(隔离守卫模型)
|
||||
├─ SUPPORTED → Release → SUCCESS(唯一出口)
|
||||
└─ UNSUPPORTED/不可用 → SafeFallback(SEMANTIC_UNSUPPORTED/UNAVAILABLE)
|
||||
→ 分支 B(无草稿/无结论):Release 凭 progress 快照 → SafeFallback(INSUFFICIENT_EVIDENCE 等)
|
||||
└─ 无安全进展 → fail closed 抛异常 → FAILED
|
||||
```
|
||||
|
||||
**讲解要点**(讲主链路时抓四个时间点):
|
||||
1. Agent 运行前先建立 Run 边界;
|
||||
2. Tool 调用经过 Harness,但 Tool 选择仍由 Agent 决定;
|
||||
3. Agent 只看到有界观察,完整事实由系统独立保管;
|
||||
4. Draft 必须经过唯一发布出口,不能直接发送给用户。
|
||||
|
||||
**三个词记忆**:循环(多轮 ReAct + 收敛)/ 分支(验真失败有修复、无草稿也能降级)/ 分离(真相在 canonical,Agent 只见有界观察)。
|
||||
|
||||
### 3.2 图二:职责迁移(推理合并,权力拆分)
|
||||
|
||||
**为什么迁移**:早期多 Agent(Planner/Executor/Verifier/Composer)加上 Gatekeeper、StateGraph,代价是四套 Prompt/JSON/上下文策略。后来发现四个角色**加起来正好是一次完整 ReAct**:
|
||||
|
||||
```text
|
||||
思考 → Planner
|
||||
行动观察 → Executor + Tool
|
||||
自我检查 → Verifier
|
||||
最终回答 → Composer
|
||||
```
|
||||
|
||||
外层在重复实现框架已有的循环。于是收敛成单个 React Agent + 控制面拆分:
|
||||
|
||||
| 早期角色 | 干什么 | 现在迁移到哪 |
|
||||
|---|---|---|
|
||||
| Planner | 制定排查计划 | Diagnosis Agent(ReAct 思考) |
|
||||
| Executor | 调用 Tool 收集证据 | Diagnosis Agent(ReAct tool 调用) |
|
||||
| Gatekeeper | 机械验真证据引用 | EvidenceGuard(真理源改为 canonical store,对象改为 DiagnosisDraft) |
|
||||
| Verifier | 判断 Claim 是否可信 | SemanticGuard(隔离,无 Tool 无记忆) |
|
||||
| Composer | 组织最终回答 | Release(唯一发布点) |
|
||||
| StateGraph | 显式状态/分支/终态 | Harness 状态分层(正交枚举 + first-terminal-wins) |
|
||||
| 预算止损 | 防止空转 | Progress Control(信息增益收敛,从止损升级为正常收敛) |
|
||||
|
||||
**两个洞察**:
|
||||
1. 代码能机械证明的事实,不交给模型判断(Gatekeeper → EvidenceGuard 的思想延续);
|
||||
2. 只有拥有不同数据权限、不同工具或真正独立业务目标的角色,拆成多 Agent 才值得——把一次 ReAct 的内部步骤外置成多角色,只会放大协议成本。
|
||||
|
||||
**结果**:业务推理合并回单个 Agent,但安全权力拆得更清楚——Agent 没有 canonical 读取权、不能自证引用、没有发布权。
|
||||
|
||||
### 3.3 图三:数据三层(一份工具结果,三个职责)
|
||||
|
||||
```text
|
||||
┌─ 第 1 层:canonical(Redis,TTL 2h,key=runId+toolCallId)────────┐
|
||||
│ request + raw_response + agent_result + status + evidenceStatus │
|
||||
│ 用途:EvidenceGuard 回读验真 / 短期完整真相 / 排查 │
|
||||
├─ 第 2 层:Model Observation(Projector 有界投影,冻结契约)──────┤
|
||||
│ 白名单 / 脱敏 / 截断,只含 Agent 下一步推理所需字段 │
|
||||
│ 用途:服务模型推理(不给 raw,防上下文膨胀/prompt injection) │
|
||||
├─ 第 3 层:Metadata Audit(MySQL 长期)────────────────────────────┤
|
||||
│ 只存 identity/状态/耗时/token/bytes,无正文无 raw 副本 │
|
||||
│ 用途:长期回放(配合 trace 时序线) │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**三分字段**(一次调用):
|
||||
|
||||
```text
|
||||
request = 我问了什么(模型入参)
|
||||
raw_response = 工具回了什么(完整返回,存 canonical 不外发)
|
||||
agent_result = 我能信什么 / 模型能看到什么(投影后有界,供推理 + 验真)
|
||||
```
|
||||
|
||||
**为什么不能共用一份数据**:raw 直接给模型 → 上下文膨胀 + 敏感泄漏 + prompt injection;只存裁剪观察 → EvidenceGuard 无法独立验真;raw 永久进审计 → 制造敏感副本。三个目标冲突,所以真相、观察、元数据各存各的。
|
||||
|
||||
**关键事实**:MySQL 不存完整工具返回和 agent_result——`JpaToolInvocationAuditSink` 落库时只提取 `output_preview`(默认仅 status/evidence_status)和 `output_length`(字节长度)。TTL 过期后只能元数据回放。
|
||||
|
||||
## 4. 九域五段式面试讲法
|
||||
|
||||
每域固定叙事结构:**① 动机 → ② 决策 → ③ 实现 → ④ 边界 → ⑤ 话术**,外加高频追问。
|
||||
|
||||
### 4.1 core:执行控制
|
||||
|
||||
- **① 动机**:非确定性 Agent 执行时,身份归属、截止时间、预算、取消、终态必须确定——异步调用和多轮会话中,事实到底属于哪次执行?迟到结果能不能发布?
|
||||
- **② 决策**:RunContext 显式传递(不用 ThreadLocal);唯一终态 first-terminal-wins。
|
||||
- **③ 实现**:`checkActive` 三道闸(模型调用前/工具调用前/工具执行中逐行);取消广播(onCancel → future.cancel);deadline;RunBudget;预算耗尽/取消走终态。
|
||||
- **④ 边界**:协作式取消——同步 Provider 计算未必立即停止;终态防迟到发布但不物理强杀。
|
||||
- **⑤ 话术**:
|
||||
> "core 管一次 Run 的确定性边界:显式 RunContext 跨线程传递(挂进 config metadata,防并发串线),first-terminal-wins 保证唯一终态——第一个写入的终态不可被迟到结果覆盖;checkActive 在模型前、工具前、工具执行中逐行检查,预算/取消/超时到点即 abort;取消是协作式的,逻辑终态和发布被保护,但同步 Provider 计算未必立即停。"
|
||||
- **追问**:取消是强杀吗?(协作式,检查点中止 + 终态防迟到)RunContext 为什么显式?(跨线程 + 并发隔离)预算和 Ledger 区别?(Run 资源门禁 vs 审计账本)
|
||||
|
||||
### 4.2 retry:显式可计量重试
|
||||
|
||||
- **① 动机**:框架/Agent 自带的重试是盲目重试——同一请求无限重试、不计量、不可审计,一个不可靠的工具能把整个 Run 预算耗光。
|
||||
- **② 决策**:重试权从框架收归 Harness,做成显式可计量的 attempt 循环。
|
||||
- **③ 实现**:分类裁决(技术性失败可重试 / 业务性失败不重试);次数/时间/成本三重封顶;剩余超时递减(总超时耗尽不再重试);attempt 可审计。
|
||||
- **④ 边界**:业务性失败不重试(重试也没用);SDK 关闭后由 Harness 全权控制。
|
||||
- **⑤ 话术**:
|
||||
> "重试归 Harness 因为它是成本行为:框架自带的盲目重试不可计量不可审计,Harness 做成显式 attempt 循环——技术性失败才重试、业务性失败不重试,次数/时间/成本三重封顶,每次尝试有分类有记录可审计。Agent 和 Tool 不重试,它们只负责执行,要不要再来一次由 Harness 裁决。"
|
||||
- **追问**:为什么 Agent/Tool 不重试?(重试是成本裁决权,执行层只管执行)
|
||||
|
||||
### 4.3 progress:信息增益收敛
|
||||
|
||||
- **① 动机**:预算只能止损(不能继续消耗资源),不能判断「继续查是否有价值」——Agent 可能拿着通用知识、相似查询、空日志反复空转,最后撞预算。
|
||||
- **② 决策**:预算之外的第二套停止机制——信息增益控制。
|
||||
- **③ 实现**:GAINED/NO_GAIN 判定(结果是否推进诊断);重复检测;Tracker 双计数/pending;连续 NO_GAIN → SATURATED 饱和停止(软/硬停止);拦截器五道门。
|
||||
- **④ 边界**:SATURATED 只停收集,不是 Run 终态;`READY + NO_EVIDENCE` 是成功执行但空结果,不是技术异常。
|
||||
- **⑤ 话术**:
|
||||
> "progress 是预算之外的第二套停止机制:预算管能不能继续消耗资源,progress 管继续查是否推进诊断。它用信息增益判定(GAINED/NO_GAIN)+ 重复检测 + 饱和停止——连续 NO_GAIN 就停,防止 Agent 拿通用知识或空日志空转;空查询(NO_EVIDENCE)也是被完整建模的负向观察,不是技术异常。"
|
||||
- **追问**:Agent 为什么不会无限调用 Tool?(预算止损 + 信息增益收敛双保险)
|
||||
|
||||
### 4.4 tool:事实边界
|
||||
|
||||
- **① 动机**:工具是证据边界——能查什么、查到多少、看到什么必须封死;工具直接连数据库/检索库有破坏面。
|
||||
- **② 决策**:ToolBoundary 统一执行规则 + canonical 存真相 + Projector 有界投影;数据三层分离。
|
||||
- **③ 实现**:preflight 五项(run 匹配/授权/只读/JSON/key)失败不落库;预算门禁(beforeToolCall + bytes 三笔);canonical 状态机(PROJECTING→READY/ERROR);审计 best-effort;每类工具一个 Projector(严格校验 + 脱敏 + 截断)。
|
||||
- **④ 边界**:不理解业务内容(投影交给 ToolResultProjector);不做信息增益判断(progress 的事);只允许 READY/ERROR 离开。
|
||||
- **⑤ 话术**:
|
||||
> "tool 域是证据边界:ToolBoundary 统一四项职责——preflight(run 匹配/授权/只读/JSON 合法性/key 生成,失败不落库)、预算门禁(Tool 预算 + request→raw→agent_result 三笔字节预留)、canonical 状态机(PROJECTING→READY/ERROR)、审计。执行结果分三层:完整真相存 Redis canonical(2h),有界投影给模型,长期审计只留元数据。它明确不做业务投影和信息增益判断——那是 Projector 和 progress 的事。"
|
||||
- **追问**:Tool 结果为什么不直接给模型?(三层数据责任:推理/验真/留存冲突);MySQL 沙箱怎么防?(语义/连接/输出三层防线)
|
||||
|
||||
### 4.5 guard:证据安全链双闸
|
||||
|
||||
- **① 动机**:Agent 会撒谎——编造工具调用、夸大结论。不信任模型自述。
|
||||
- **② 决策**:双闸分离——EvidenceGuard 机械验引用真实(规则、可审计、不调模型),SemanticGuard 隔离判结论支持度(无工具无记忆的单轮二值判断)。
|
||||
- **③ 实现**:EvidenceGuard 三层校验(结构校验 → 逐条引用验真 key=runId+toolCallId 查账本 + isReferencableBy + kind 匹配 → 重读投影重建证据,20 个违规码);SemanticGuard 严格 schema(恰好 {verdict, reason})+ 预算/重试/取消全栈衔接。
|
||||
- **④ 边界**:EvidenceGuard 只问引用真不真,不问语义;SemanticGuard 不能探索事实、不能改写报告。
|
||||
- **⑤ 话术**:
|
||||
> "防编造证据用双闸:EvidenceGuard 是机械验真——模型草稿里每个 tool_call_id 都要去 canonical 账本查到真实记录(key 绑定 runId 防跨 Run、记录必须 READY、kind 与证据语义匹配、投影内部自洽),纯规则可审计不调模型;SemanticGuard 是隔离语义审查——无工具、无记忆、单轮二值判断,只判结论是否被已验证证据支持,输出硬校验为 {verdict, reason} 两个字段。先机械后语义:引用假的直接拦,不浪费模型调用。"
|
||||
- **追问**:为什么 EvidenceGuard 通过还要 SemanticGuard?(引用真实 ≠ 结论被支持:一个防编造证据,一个防夸大结论)
|
||||
|
||||
### 4.6 release:唯一发布点
|
||||
|
||||
- **① 动机**:模型输出的是未经证明的断言,不能直接当答案返回。
|
||||
- **② 决策**:唯一发布点——SUCCESS 只有一条路径(验真过 + 语义支持),其余全降级 SafeFallback。
|
||||
- **③ 实现**:三分支决策树(受控停止凭 progress 快照 / 无结论验引用按进展降级 / 有结论走 Evidence→Repair→Semantic 链);fail closed(无安全进展抛异常);EvidenceRepair 只修引用(prompt 锁死 + 语义不变性检查);SafeFallbackFactory 五种降级(有界/去重/诚实)。
|
||||
- **④ 边界**:终态异常透传(取消/预算耗尽不伪装成业务 FALLBACK);SafeFallback conclusion 恒 null。
|
||||
- **⑤ 话术**:
|
||||
> "release 是唯一发布点:任何对外内容必须经过验证。它按 Draft 形态三分支——受控停止(无草稿)凭 progress 快照发布 INSUFFICIENT_EVIDENCE,且必须有已验真事实否则 fail closed;无结论只验引用按进展降级;有结论走完整链——EvidenceGuard 验引用,失败则 EvidenceRepair 只修引用(prompt 锁死只能改引用字段 + 语义不变性保证用户可见内容不变)再复查,仍失败降级 EVIDENCE_VALIDATION_FAILED;验真通过后 SemanticGuard 判支持度,SUPPORTED 是唯一 SUCCESS 出口,其余降级。所有降级走 SafeFallbackFactory:有界、去重、诚实,保留已验证事实但不发布未证明的根因。"
|
||||
- **追问**:FALLBACK 算成功还是失败?(正交:可 RunState.SUCCESS + FALLBACK,是安全发布结果不是失败)
|
||||
|
||||
### 4.7 application:Run 应用所有者
|
||||
|
||||
- **① 动机**:一次请求从创建到公开结果需要编排:建 Run、路由、执行分支、持久化、SSE 输出。
|
||||
- **② 决策**:ChatApplicationUseCase 六步编排,不做业务判断,不把 HTTP/SSE 细节塞 Core。
|
||||
- **③ 实现**:startRun → 读会话上下文(RoutingHistory + PreviousTurn)→ 路由 → executePath 分支 → completePath + persistFinish;统一失败出口(terminalOutcome + safeFailure);取消句柄(CoreRunControl → core.cancel)。
|
||||
- **④ 边界**:路由只给枚举不执行;预算耗尽的 FALLBACK 不二次 completeSuccess。
|
||||
- **⑤ 话术**:
|
||||
> "application 是 Run 的应用所有者:六步编排——建 Run 边界(core.startRun)、意图路由(单次模型调用、输出契约严格为 {intent} 枚举)、按意图分叉执行、路径完成后写终态并持久化发布契约,异常统一走失败出口映射成安全的 ChatFailureCode;取消能力通过 SSE 句柄暴露给客户端(断连即 core.cancel)。路由只回答走哪条分支,分支执行权在 executePath。"
|
||||
- **追问**:多轮记忆怎么实现?(RoutingHistory + PreviousTurn,只传发布后的安全摘要)
|
||||
|
||||
### 4.8 audit:可观测账本(含 trace)
|
||||
|
||||
- **① 动机**:要能回放决策过程,又不永久保存敏感正文——两个目标冲突。
|
||||
- **② 决策**:metadata-only + trace 时序线 + Token 对账账本;audit 是域,trace 是域内子体系。
|
||||
- **③ 实现**:trace 17 种事件按 sequence_no 单调落 diagnosis_trace_event(七阶段:RUN/ROUTING/AGENT/TOOL/EVIDENCE/SEMANTIC/RELEASE);明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段;Token 三写闭环(ledger 分账 → Run 预算 → agent_step 回写);DiagnosisTraceService 三级回放。
|
||||
- **④ 边界**:审计不阻断主流程(fail-safe);正文只在受限审计表;TTL 过期后只能元数据回放。
|
||||
- **⑤ 话术**:
|
||||
> "audit 是可观测账本,trace 是它内部的事件回放子体系。trace 用一张表按 sequence_no 记录全链路七阶段的事件时序线(每帧只带摘要和关联键),明细账本(agent_step/tool_invocation/agent_reasoning_audit/diagnosis_run)承载完整字段,两层通过 step_id/run_id 互链不重复存储。三个边界:审计不阻断主流程(fail-safe)、metadata-only(正文只在受限审计表)、Token 三写闭环(ledger 分账→Run 预算→明细回写)。回放三级:时间线→明细→推理。"
|
||||
- **追问**:audit 和 trace 什么关系?(包含关系:trace 是 audit 域内的时序事件流,audit 还含 ledger/工具审计/推理审计)
|
||||
|
||||
### 4.9 contract:状态流正交
|
||||
|
||||
- **① 动机**:技术停了不等于用户看到失败;查了没查到不等于系统出错——状态语义混在一个枚举里就糊了。
|
||||
- **② 决策**:分层 + 正交 + 显式映射。
|
||||
- **③ 实现**:11 个状态枚举五层(技术 RunState / 证据 InvocationStatus+EvidenceStatus / 收集 StopReason / 发布 ReleaseOutcome+FallbackType / 协议 ChatApplicationStatus+SseOutcome+ChatFailureCode);四个正交轴;纵向映射链。
|
||||
- **④ 边界**:SSE 只有三态(取消连接已断发不出 done);SseOutcome 未接线;FallbackType.BUDGET_EXHAUSTED 命名债务。
|
||||
- **⑤ 话术**:
|
||||
> "状态设计核心是分层正交:RunState(技术终态)和 ReleaseOutcome(用户结局)是正交轴——预算耗尽有安全进展→FALLBACK、没进展→FAILED,同一技术终态诚实映射到不同用户结局;工具调用也是两个正交轴(InvocationStatus 生命周期 vs EvidenceStatus 证据语义),查了但空仍是成功执行。映射链:CANCELLED→CANCELLED+RUN_CANCELLED,SUPPORTED→SUCCESS 唯一出口;协议层(SSE)复用 ReleaseOutcome 三态拒绝 CANCELLED。"
|
||||
- **追问**:这些状态为什么不合并成一个枚举?(不同层回答不同问题,正交后独立演进、映射显式可审计)
|
||||
|
||||
## 5. 六个易错点(带「为什么」)
|
||||
|
||||
| # | ❌ 不说 | ✅ 应说 | 为什么 |
|
||||
|---|---|---|---|
|
||||
| 1 | Harness 安排 Agent 执行步骤 | ReAct Agent 自己选 Tool 和下一步;Harness 只管边界 | 边界 ≠ 编排:Harness 不替 Agent 决定查什么 |
|
||||
| 2 | SemanticGuard 是第二个诊断 Agent | 无 Tool、无记忆、单轮二值判断的隔离审查器 | 职责 ≠ 角色:它没有探索能力,判完就走 |
|
||||
| 3 | Tool 返回 SUCCESS 就找到证据 | READY 只表示调用完成,还要看 EvidenceStatus | 生命周期 ≠ 证据:调完成功和有没有证据是两回事 |
|
||||
| 4 | FALLBACK 就是 Run 失败 | Fallback 是安全发布结果,可 RunState.SUCCESS + FALLBACK | 技术 ≠ 用户:两个正交维度 |
|
||||
| 5 | Redis 是长期审计库 | canonical 只存当前 Run 短期真相(TTL 2h);长期审计只留元数据 | 短期真相 ≠ 长期审计:可验真 vs 不留敏感副本 |
|
||||
| 6 | 取消能立刻杀死所有模型调用 | 协作式取消:终态防迟到发布,同步 Provider 未必立即停 | 逻辑保护 ≠ 物理强杀:终态定了但线程未必立刻停 |
|
||||
|
||||
## 6. 高频追问应对大全
|
||||
|
||||
| 追问 | 回答主线 |
|
||||
|---|---|
|
||||
| 什么是 Harness?30 秒讲清 | 确定性控制边界:运行/事实/发布三层边界 |
|
||||
| 为什么不用多 Agent? | 四角色加起来是一次 ReAct;推理合并、权力拆分 |
|
||||
| 为什么 Harness 不是工作流引擎? | Agent 选择下一步,Harness 只检查边界和发布资格 |
|
||||
| RunContext 为什么显式传递? | 跨线程异步链 + 并发隔离,ThreadLocal 会丢/串 |
|
||||
| 取消是强杀吗? | 协作式:检查点中止 + first-terminal-wins 防迟到 |
|
||||
| 预算和 Ledger 区别? | Run 资源门禁 vs 审计账本(不同域) |
|
||||
| FALLBACK 算成功还是失败? | 正交:技术终态(RunState)与用户结局(ReleaseOutcome) |
|
||||
| 重试为什么归 Harness? | 盲目重试不可计量;显式可计量 attempt 循环 |
|
||||
| 为什么 Agent/Tool 不重试? | 重试是成本裁决权,执行层只管执行 |
|
||||
| 如何防止 Agent 编造证据? | framework Tool ID + canonical store + EvidenceGuard |
|
||||
| 为什么双闸? | 引用真实(机械)≠ 结论被支持(语义) |
|
||||
| Tool 结果为什么不直接给模型? | 真相/观察/审计三个数据责任冲突 |
|
||||
| Agent 为什么不会无限调 Tool? | 预算止损 + 信息增益收敛双保险 |
|
||||
| Tool 报错是不是 Run 就失败? | 局部失败先看是否可继续及是否已有安全进展 |
|
||||
| 如何回放决策? | metadata audit + trace 时序线 + 三级回放 |
|
||||
| 当前还有什么限制? | 语义去重、TTL、同步取消、SemanticGuard 不确定性、阈值校准 |
|
||||
|
||||
## 7. 支付超时案例(2 分钟完整版)
|
||||
|
||||
> "用户要求诊断支付服务超时。Application 先创建独立 Run,为 Router、Agent、Tool、Guard 共享同一套 deadline、预算和取消能力。Diagnosis Agent 自主调用知识库和日志 Tool;ToolBoundary 执行调用并把完整事实保存为当前 Run 的 canonical invocation,只把有界 Observation 返回给 Agent。Agent 根据两个 Tool 结果生成 Draft,但 Draft 没有直接发给用户。EvidenceGuard 回读 canonical store 后发现引用无法完成真实性校验,因此 Release 没有继续让模型润色或猜测,而是发布 EVIDENCE_VALIDATION_FAILED SafeFallback。最终数据库记录请求处理成功、ReleaseOutcome 为 FALLBACK,SSE 也完整结束,但未经验证的根因没有离开系统。"
|
||||
|
||||
**三个不等于**:Tool READY ≠ 引用已验真 / 引用已验真 ≠ 结论被支持 / Agent 生成 Draft ≠ 报告允许发布。
|
||||
|
||||
## 8. 复习中纠正的认知清单(最容易踩的坑)
|
||||
|
||||
| 错误认知 | 纠正为 |
|
||||
|---|---|
|
||||
| Harness 顶层所以控制 retry/progress | 不只是位置——重试是成本行为必须可计量封顶;progress 解决「预算不能判断价值」 |
|
||||
| RunContext 因为「回调」显式传 | 跨线程异步链 + 并发隔离;显式挂 config metadata |
|
||||
| ToolBoundary 管 token/收敛/重试次数 | 那些归 core/retry/progress;ToolBoundary 只四项(preflight/预算/状态机/审计) |
|
||||
| 工具失败抛异常 | ToolBoundary 转 ERROR 状态 + 错误观察,Agent 可继续换工具;Run 是否失败看 hasObservedFacts |
|
||||
| FALLBACK 可能因超时 | 超时(TIMED_OUT)通常走 FAILED;FALLBACK 前提是有已验证事实 |
|
||||
| agent_result 也存 MySQL | 不存——MySQL 只留 output_preview + output_length;完整 agent_result 在 Redis canonical(2h) |
|
||||
| 注入 skill/知识域给 agent | 注入的是 query + PreviousTurn + 系统 prompt;知识靠工具主动查 |
|
||||
| 非法 draft 由 release 捕捉 | 反序列化在 agent 出口(recoverInvalidDraft);release 只做验证+裁决 |
|
||||
| preflight 失败也落库 | 失败不落库(errorAndNoRecord)——只有 preflight 全过才写 PROJECTING |
|
||||
| 多 Agent 一定不好 | 只有不同数据权限/独立业务目标的角色才值得拆;拆 ReAct 内部步骤只会放大协议成本 |
|
||||
|
||||
## 9. 面试前一天 Checklist
|
||||
|
||||
```text
|
||||
□ 30 秒电梯陈述背熟(§2.1),三个重点不丢(§2.3)
|
||||
□ 默画三张白板图(§3):主链路含分支 / 职责迁移对照 / 数据三层
|
||||
□ 六个易错点扫一遍(§5)——重点看「为什么」列
|
||||
□ 九域话术:挑 3 个最可能被追问的(guard/release/contract)背熟
|
||||
□ 2 分钟支付超时案例 + 三个不等于(§7)
|
||||
□ 过一遍纠正认知清单(§8)——这些是踩过的坑
|
||||
□ 读一遍面试速查 §7 追问表,心里有数
|
||||
```
|
||||
|
||||
## 10. 代码位置索引
|
||||
|
||||
| 组件 | 文件 |
|
||||
|---|---|
|
||||
| ToolBoundary(preflight/预算/状态机/审计) | `src/main/java/com/superbiz/agent/harness/tool/boundary/ToolBoundary.java` |
|
||||
| JpaToolInvocationAuditSink(output_preview 落库) | `.../audit/JpaToolInvocationAuditSink.java` |
|
||||
| DiagnosisAgentUseCase(RunContext 挂 config metadata) | `.../agent/DiagnosisAgentUseCase.java` |
|
||||
| EvidenceGuard / SemanticGuard | `.../guard/evidence/` + `.../guard/semantic/` |
|
||||
| DiagnosisReleaseUseCase / EvidenceRepair / SafeFallbackFactory | `.../release/` |
|
||||
| DiagnosisProgressProjector / Tracker / Snapshot | `.../progress/` |
|
||||
| ChatApplicationUseCase / IntentRouter | `.../application/` |
|
||||
| DiagnosisTraceService / DiagnosisTraceController | `.../service/` + `.../controller/` |
|
||||
| 状态枚举(RunState/ReleaseOutcome/FallbackType/...) | `.../contract/` + `.../core/RunState.java` |
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
### 0.4 当前会话的起始上下文(供追溯)
|
||||
|
||||
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ 取消广播与打断机制 → RunBudget 深挖 → retry(设计+实现+超时+幂等性)→ 状态流预备(枚举归属)。当前停在「执行控制面已闭环,下一步 progress」的位置。
|
||||
本次学习从 Harness 入口文档开始,已完整走过:入口导读 → 面试速查 → RunContext → 执行控制(budget/cancel/lifecycle/checkActive)→ RunBudget 深挖 → retry → progress(设计+代码双视角)→ tool 域(49 文件全注释 + 注册调用执行链路 + Tool 调用链旅程)→ RAG 检索体系(lookup_knowledge 后端:L0/多路召回+RRF/qualityScore/降级/契约/审计/离线评测,已闭环)。当前停在「tool 域只差 MySQL 沙箱线,下一步 tool 收尾」的位置。
|
||||
|
||||
### 0.5 面试准备策略(学习目标)
|
||||
|
||||
@@ -82,14 +82,14 @@
|
||||
|---|---|---|---|---|
|
||||
| `core` | **执行控制**:身份 / deadline / 预算 / 取消 / 唯一终态,checkActive 三道闸 | ✅ 深入 | RunContext、budget、cancel、lifecycle、checkActive、termination | [执行控制笔记](Harness执行控制笔记-终态检查与取消广播.md)、[RunBudget 时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) |
|
||||
| `retry` | **显式可计量重试**:分类裁决(技术/业务)、次数/时间/成本三重封顶、attempt 可审计 | ✅ 深入 | 设计动机、分类裁决、剩余超时、幂等性、SDK 关闭 | [Retry 重试机制](Retry重试机制-显式可计量的attempt循环.md) |
|
||||
| `contract` | **跨层类型化语言**:Draft / PublishedResult / SafeFallback / 状态枚举,防字符串漂移 | ⬜ 部分 | RunState / ReleaseOutcome / SseOutcome / PublishedResult(只摸过枚举) | 状态流(未系统学) |
|
||||
| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ⬜ 部分 | HarnessModelInterceptor(预算/Token 记账) | — |
|
||||
| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ⬜ 部分 | ModelCallLedger / ModelCallAuditor | — |
|
||||
| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ⬜ 部分 | ChatApplicationUseCase 入口(cancel 链路) | — |
|
||||
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ⬜ 部分 | GuardModelCall(预算/超时/取消订阅) | — |
|
||||
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ⬜ 部分 | DiagnosisReleaseResult(结果类型) | — |
|
||||
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ⬜ 空白 | JdbcMysqlReadOnlyExecutor(取消订阅) | — |
|
||||
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ⬜ 空白 | — | — |
|
||||
| `contract` | **跨层类型化语言**:Draft / PublishedResult / SafeFallback / 状态枚举,防字符串漂移 | ✅ 深入 | 11 个状态枚举五层全景、四个正交轴(RunState⊥ReleaseOutcome、InvocationStatus⊥EvidenceStatus)、纵向映射链、SseOutcome 未接线发现 | [状态流笔记](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) |
|
||||
| `agent` | **框架 ReAct 接入**:拦截器把预算/审计/停止协议挂到框架循环上,不复制 loop | ✅ 深入 | 装配(Factory 粘合点)、双拦截器(Model:预算+Token 审计;Tool:五道门)、UseCase 循环外壳(字节/预算限制)、受控停止(异常栈捞回可控信号)、双视图投影(模型观察 vs 控制视图)、串行工具 | [agent 域学习笔记](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) |
|
||||
| `audit` | **可观测账本**:Trace 事件回放、Token 对账、metadata-only(不存敏感正文) | ✅ 深入 | trace 时序线(15 帧真实数据)、Ledger 分账、模型步审计 hook、RunConclusionExtractor、DiagnosisTraceService 三级回放 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
|
||||
| `application` | **Run 应用所有者**:创建 Run / 路由意图 / 执行分支 / 持久化 / SSE 输出 | ✅ 深入 | 六步编排、取消句柄(CoreRunControl)、统一失败出口、多轮记忆有界化、PublishedResultPolicy 落库 | [application+audit 笔记](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) |
|
||||
| `guard` | **验证分离**:EvidenceGuard 机械验引用真实性 + SemanticGuard 隔离判结论支持度 | ✅ 深入 | 20 个违规码、三层校验(结构/验真/重读投影)、语义不变性、守卫模型受控调用、全栈衔接 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
|
||||
| `release` | **唯一发布点**:SUCCESS / FALLBACK 裁决,EvidenceRepair 只修引用,SafeFallback 确定性构造 | ✅ 深入 | 三分支决策树、fail closed、终态透传、EvidenceRepair 语义不变性、SafeFallbackFactory 五种降级 | [证据安全链笔记](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) |
|
||||
| `tool` | **证据边界**:ToolBoundary 统一执行规则、canonical 保存真相、projector 有界投影、MySQL 只读沙箱 | ✅ 深入 | 49 文件全注释、Boundary/Contract/Projection/Store/Adapter/注册链、RAG 后端(L0/RRF/qualityScore/降级)、MySQL 沙箱(Validator/Executor/Projector 三层防线) | [tool 域注册调用执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md)、[Tool 调用链旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md)、[RAG 检索体系](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md)、[MySQL 沙箱](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) |
|
||||
| `progress` | **收敛控制**:信息增益(GAINED/NO_GAIN)、重复检测、饱和停止(预算之外的第二套停止机制) | ✅ 深入 | 设计动机、Tracker 双计数/pending/软硬停止、拦截器五道门、canonical 生命周期、Projector 投影、Release 消费 | [代码学习笔记](Harness progress 代码学习笔记-从拦截器五道门到唯一发布点.md)(与[设计视角](Harness信息增益停止-让无证据诊断正常收敛.md)配套) |
|
||||
|
||||
图例:✅ 深入 = 已完整学透,能面试讲 2 分钟;⬜ 部分 = 接触过但没系统学;⬜ 空白 = 未开始
|
||||
|
||||
@@ -100,32 +100,49 @@
|
||||
| [Harness 执行控制笔记-终态检查与取消广播](Harness执行控制笔记-终态检查与取消广播.md) | RunContext / checkActive / 两个 CAS / 取消广播 / 打断机制 | ✅ 已沉淀 |
|
||||
| [RunBudget 预算流程-一次 Run 的资源门禁时序图](RunBudget预算流程-一次Run的资源门禁时序图.md) | RunBudget 时序图 / 字段组件 / 异常终态 / 三要素 | ✅ 已沉淀 |
|
||||
| [Retry 重试机制-显式可计量的 attempt 循环](Retry重试机制-显式可计量的attempt循环.md) | Retry 设计动机 / 分类裁决 / 剩余超时 / 幂等性 | ✅ 已沉淀 |
|
||||
| [Harness 信息增益停止-让无证据诊断正常收敛](Harness信息增益停止-让无证据诊断正常收敛.md) | progress 设计视角:双停止机制 / 三方判断权 / 状态机 / 协议 / 真实问题 | ✅ 已沉淀(设计视角) |
|
||||
| [Harness progress 代码学习笔记-从拦截器五道门到唯一发布点](Harness%20progress%20代码学习笔记-从拦截器五道门到唯一发布点.md) | progress 代码视角:类地图 / 五道门 / Tracker 状态机 / canonical 生命周期 / 门禁 / 投影发布 / 易错点 / 面试话术 | ✅ 已沉淀(代码视角) |
|
||||
| [Harness tool 域代码学习笔记-工具的注册调用与执行链路](Harness%20tool%20域代码学习笔记-工具的注册调用与执行链路.md) | tool 域:装配/注册(bridge/callbacks)/调用/执行(ToolBoundary)/返回全链路 + 面试话术 | ✅ 已沉淀 |
|
||||
| [Harness Tool 调用链-一次工具调用的完整旅程](Harness%20Tool%20调用链-一次工具调用的完整旅程.md) | 动态时序:模型决定 → 拦截器 → invoke → Adapter → ToolBoundary → 返回 → 模型观察 | ✅ 已沉淀 |
|
||||
| [Harness RAG 检索体系学习笔记-从 query 到可验证证据](Harness%20RAG%20检索体系学习笔记-从%20query%20到可验证证据.md) | RAG 后端:L0/多路召回+RRF/qualityScore/去重判级/降级/契约/审计/离线评测/讨论沉淀 | ✅ 已沉淀 |
|
||||
| [Harness MySQL 沙箱学习笔记-从 SQL 校验到脱敏投影](Harness%20MySQL%20沙箱学习笔记-从%20SQL%20校验到脱敏投影.md) | MySQL 工具:三层防线(语义/连接/输出)/白名单/参数化强制/取消联动/脱敏/与 RAG 对照 | ✅ 已沉淀 |
|
||||
| [Harness 证据安全链学习笔记-从收敛控制到唯一发布点](Harness%20证据安全链学习笔记-从收敛控制到唯一发布点.md) | progress→guard→release 联动:双通道验证架构/六层状态流转与映射/关键字段来源与使用/设计要点/面试话术 | ✅ 已沉淀 |
|
||||
| [Harness application+audit 学习笔记-从 Run 编排到可回放审计](Harness%20application+audit%20学习笔记-从%20Run%20编排到可回放审计.md) | application 六步编排/取消句柄/持久化策略;audit 域层次(trace 子体系/Ledger/审计表);**audit vs trace 区别**(真实数据对照)/三级回放 | ✅ 已沉淀 |
|
||||
| [Harness contract 状态流学习笔记-11个状态枚举的正交全景](Harness%20contract%20状态流学习笔记-11个状态枚举的正交全景.md) | 五层状态/四正交轴/纵向映射链/真实数据案例/面试叙事模板与追问应对 | ✅ 已沉淀 |
|
||||
| [Harness 面试复习笔记-五步复习与白板图沉淀](Harness%20面试复习笔记-五步复习与白板图沉淀.md) | **详细版**:30 秒陈述展开/三张白板图/九域五段式讲法(动机→决策→实现→边界→话术)/六易错点带原因/追问应对大全/支付超时案例/纠正认知清单/面试 Checklist | ✅ 已沉淀 |
|
||||
| [Harness agent 域学习笔记-从框架 ReAct 接入到受控停止](Harness%20agent%20域学习笔记-从框架%20ReAct%20接入到受控停止.md) | agent 域:装配(Factory 粘合点)/双拦截器(Model 预算+Token 审计、Tool 五道门)/UseCase 循环外壳/受控停止/双视图投影/串行工具 | ✅ 已沉淀 |
|
||||
| [Harness LLM Judge 设计笔记-从不可信判定到可信裁决](Harness%20LLM%20Judge%20设计笔记-从不可信判定到可信裁决.md) | LLM-as-a-judge 模式:SemanticGuard(判支持度)+ EvidenceRepair(修引用)+ GuardModelCall(受控底座);面试五段式回答稿 + 六追问应对 + 通用要素 | ✅ 已沉淀 |
|
||||
| [Harness 整体架构学习笔记-从装配到入口到记忆到知识库写入](Harness%20整体架构学习笔记-从装配到入口到记忆到知识库写入.md) | 整体架构补充:配置装配中心(三层组织)/ HTTP 入口层(薄 Controller + SSE 状态机 + 断连取消)/ 会话与记忆体系(PreviousTurn 注入 + 术语校准 + skill 长期记忆)/ 知识库写入链路(分块 + hybrid 同源) | ✅ 已沉淀 |
|
||||
|
||||
## 3. 一次请求的完整学习主线
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["core<br/>执行控制 ✅"] --> B["retry<br/>重试 ✅"]
|
||||
B --> C["progress<br/>信息增益 ⬜"]
|
||||
C --> D["tool<br/>事实边界 ⬜"]
|
||||
D --> E["guard<br/>验证 ⬜"]
|
||||
E --> F["release<br/>发布 ⬜"]
|
||||
F --> G["application + audit<br/>收尾 ⬜"]
|
||||
G --> H["contract<br/>类型化语言 ⬜"]
|
||||
B --> C["progress<br/>信息增益 ✅"]
|
||||
C --> D["tool<br/>事实边界 ✅"]
|
||||
D --> E["guard<br/>验证 ✅"]
|
||||
E --> F["release<br/>发布 ✅"]
|
||||
F --> G["application + audit<br/>收尾 ✅"]
|
||||
G --> H["contract<br/>类型化语言 ✅"]
|
||||
```
|
||||
|
||||
## 4. 下一步规划
|
||||
|
||||
```text
|
||||
下一个:progress(信息增益停止)——14 个文件,小而独立
|
||||
和刚学完的预算组成"双停止机制":预算管"能不能花",信息增益管"继续查有没有价值"
|
||||
主线九域全部 ✅(含 agent 域收尾)+ 面试五步复习 ✅(共沉淀 14 篇笔记)
|
||||
|
||||
之后顺序:
|
||||
tool(49 个文件,按四层理解:Boundary → Canonical → Projector → Adapter)
|
||||
guard(15 个文件:EvidenceGuard + SemanticGuard)
|
||||
release(6 个文件,小而关键:唯一发布点)
|
||||
补 application(路由/执行器/SSE 收尾)和 audit(Trace 回放)
|
||||
最后状态流(RunState ↔ ReleaseOutcome ↔ SseOutcome 正交全景)
|
||||
面试前一天建议:
|
||||
1. 重读「面试速查」§1-2 + §8(30 秒陈述 / 一张图 / 六易错点)
|
||||
2. 默画三张白板图(复习笔记 §3)
|
||||
3. 背诵每域 30 秒话术(复习笔记 §5)
|
||||
4. 过一遍纠正的认知清单(复习笔记 §6,最容易踩的坑)
|
||||
5. 2 分钟支付超时案例(复习笔记 §7)
|
||||
|
||||
可选深化(不阻塞面试):
|
||||
1. audit 域深化:RagLookupAuditEnricher 检索审计明细(已覆盖大半)
|
||||
2. LLM Judge 设计(已沉淀:面试问答 + 追问应对)
|
||||
3. 整体架构补充(已沉淀:装配/入口/记忆体系/知识库写入)
|
||||
```
|
||||
|
||||
## 5. 建议每次学完一个域后更新
|
||||
|
||||
@@ -52,19 +52,39 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截器名称(框架注册用)。
|
||||
*/
|
||||
@Override
|
||||
public String getName() {
|
||||
return "harness_evidence_tool_interceptor";
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截框架 ReAct loop 的每次 Tool Call——progress 协议的主战场。
|
||||
*
|
||||
* <p>只拦截证据类 Tool(RAG / 日志 / MySQL),其余 Tool 原样放行。对证据 Tool 依次执行:
|
||||
* <ol>
|
||||
* <li>已停止检查:停止指令交付后仍请求 → 受控停止;</li>
|
||||
* <li>解析 + 评价:严格解析 Envelope,应用模型对上一轮的 GAINED/NO_GAIN;</li>
|
||||
* <li>重复检测:参数级规范化 scope,backend 执行前拒绝重复查询;</li>
|
||||
* <li>执行:交给 ToolBoundary(预算 / canonical / 审计统一门禁),并对结果做双源交叉验证;</li>
|
||||
* <li>收尾:NO_EVIDENCE 自动计 NO_GAIN,饱和时交付一次 STOP_REQUIRED,返回有界 observation。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>模型侧观察(observation)共有三副面孔:正常执行结果、STOP_REQUIRED(含 reason)、
|
||||
* 可修复协议错误(repair_required)。
|
||||
*/
|
||||
@Override
|
||||
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
|
||||
Objects.requireNonNull(request, "request must not be null");
|
||||
Objects.requireNonNull(handler, "handler must not be null");
|
||||
// 只拦截证据类 Tool(RAG/日志/MySQL);其余 Tool 原样放行
|
||||
if (!evidenceTools.supports(request.getToolName())) {
|
||||
return handler.call(request);
|
||||
}
|
||||
|
||||
// ── 第一道门:停止指令已交付后,任何证据 Tool 请求直接受控停止 ──
|
||||
DiagnosisProgressSnapshotState before = context.progress().snapshot();
|
||||
if (before.collectionState() == DiagnosisCollectionState.SATURATED
|
||||
&& before.stopInstructionDelivered()) {
|
||||
@@ -75,27 +95,34 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
ParsedAgentToolCall call;
|
||||
String normalizedScope;
|
||||
try {
|
||||
// ── 第二道门:严格解析 Envelope + 应用模型对上一轮的评价 ──
|
||||
call = evidenceTools.parse(request.getToolName(), request.getArguments(), objectMapper);
|
||||
context.progress().applyPreviousObservation(call.previousObservation());
|
||||
DiagnosisProgressSnapshotState evaluated = context.progress().snapshot();
|
||||
// 审计:模型回传的评价进入 Trace(producer=MODEL)
|
||||
recordModelProgress(before, call, evaluated);
|
||||
// 评价导致饱和(连续 NO_GAIN 达阈值)→ 本工具不执行,交付停止指令
|
||||
if (evaluated.collectionState() == DiagnosisCollectionState.SATURATED) {
|
||||
recordRejection(request, "INFORMATION_SATURATED");
|
||||
return stopRequired(request, evaluated.stopReason());
|
||||
}
|
||||
// 规范化当前轮业务输入 → 稳定 scope(判重指纹)
|
||||
normalizedScope = scopeNormalizer.normalize(request.getToolName(), call.businessInput());
|
||||
} catch (ProgressProtocolViolationException exception) {
|
||||
// 协议违规:记录违规并返回可修复的 error observation(达阈值则饱和)
|
||||
return handleProgressProtocolViolation(request, exception);
|
||||
} catch (IllegalArgumentException | IllegalStateException exception) {
|
||||
// 解析/规范化失败(非法 JSON、缺 input 等)统一按 INVALID_ENVELOPE 违规处理
|
||||
return handleProgressProtocolViolation(request,
|
||||
new ProgressProtocolViolationException(
|
||||
ProgressProtocolViolationType.INVALID_ENVELOPE,
|
||||
"Tool Call Envelope is invalid", null, null, exception));
|
||||
}
|
||||
|
||||
// ── 第三道门:参数级重复检测(backend 执行前拒绝)──
|
||||
if (context.progress().isDuplicate(request.getToolName(), normalizedScope)) {
|
||||
recordRejection(request, "DUPLICATE_SCOPE");
|
||||
context.progress().recordDuplicateScope();
|
||||
context.progress().recordDuplicateScope(); // 重复直接累计 NO_GAIN
|
||||
DiagnosisProgressSnapshotState duplicate = context.progress().snapshot();
|
||||
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
||||
InformationGain.NO_GAIN, "HARNESS", duplicate);
|
||||
@@ -105,14 +132,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
return duplicateScope(request);
|
||||
}
|
||||
|
||||
// ── 第四道门:真正执行 Tool(ToolBoundary 统一门禁:预算/canonical/审计)──
|
||||
ToolBoundaryResult result = evidenceTools.invoke(
|
||||
context, request.getToolName(), request.getToolCallId(), call.businessArguments());
|
||||
if (result.status() == InvocationStatus.READY) {
|
||||
// 双源交叉验证:从 agent_result 重算的 evidence status 必须与声明的值一致
|
||||
ToolControlView control = viewProjector.controlView(result.agentResult());
|
||||
if (control.evidenceStatus() != result.evidenceStatus()) {
|
||||
recordRejection(request, "OBSERVATION_CONTRACT_MISMATCH");
|
||||
return safeError(request, "OBSERVATION_CONTRACT_MISMATCH");
|
||||
}
|
||||
// 记录完成:NO_EVIDENCE 立即累计 NO_GAIN;EVIDENCE_FOUND 挂 pending 等模型评价
|
||||
context.progress().recordCompleted(
|
||||
new CompletedToolCall(
|
||||
request.getToolCallId(), request.getToolName(), normalizedScope),
|
||||
@@ -123,17 +153,20 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
recordProgress(request.getToolCallId(), request.getToolName(), normalizedScope,
|
||||
InformationGain.NO_GAIN, "HARNESS", completed);
|
||||
}
|
||||
// 完成后若饱和:领取一次停止指令(STOP_REQUIRED 只交付一次),观察里带 stop_required
|
||||
boolean stopRequired = completed.collectionState() == DiagnosisCollectionState.SATURATED
|
||||
&& context.progress().claimStopInstruction();
|
||||
if (stopRequired) {
|
||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||
context, request.getToolCallId(), request.getToolName(), completed));
|
||||
}
|
||||
// 有界 observation 返回给模型(含停止指令/停止原因)
|
||||
String observation = viewProjector.modelObservation(
|
||||
request.getToolName(), result.agentResult(), normalizedScope,
|
||||
stopRequired, completed.stopReason());
|
||||
return ToolCallResponse.of(request.getToolCallId(), request.getToolName(), observation);
|
||||
}
|
||||
// Tool 预算耗尽:标记停止原因(BUDGET_LIMIT_REACHED),其余错误返回稳定 error observation
|
||||
if ("BUDGET_EXHAUSTED".equals(result.errorCode())) {
|
||||
context.progress().markBudgetLimitReached();
|
||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||
@@ -149,8 +182,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 交付「必须停止」观察(observation 三副面孔之一)。
|
||||
*
|
||||
* <p>调用前必须已 SATURATED。先领取一次停止指令(STOP_REQUIRED 只交付一次);
|
||||
* 若已被领过(说明上一轮已交付、模型却继续请求 Tool),直接抛
|
||||
* {@link DiagnosisCollectionStoppedException} 把受控停止穿出框架 ReAct loop。
|
||||
* 正常时返回带 stop_required=true + reason 的观察,并记录 collectionStop Trace。
|
||||
*/
|
||||
private ToolCallResponse stopRequired(ToolCallRequest request, DiagnosisStopReason reason) {
|
||||
if (!context.progress().claimStopInstruction()) {
|
||||
// 停止指令已被交付过 → 模型未听指令,受控停止穿出框架 loop
|
||||
throw new DiagnosisCollectionStoppedException(reason);
|
||||
}
|
||||
traceRecorder.record(TraceAuditEvents.collectionStop(
|
||||
@@ -164,6 +206,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复 scope 的观察:告诉模型本次调用被判定为参数级重复、记为 NO_GAIN,
|
||||
* 但 stop_required=false(单次重复不一定饱和,模型可换查询继续)。
|
||||
*/
|
||||
private ToolCallResponse duplicateScope(ToolCallRequest request) {
|
||||
Map<String, Object> observation = new LinkedHashMap<>();
|
||||
observation.put("tool_call_id", request.getToolCallId());
|
||||
@@ -174,6 +220,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
request.getToolCallId(), request.getToolName(), writeObservation(observation));
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议违规统一入口:记录一次违规(独立计数,达阈值 → SATURATED +
|
||||
* PROGRESS_PROTOCOL_VIOLATED)。未饱和时返回可修复错误观察,饱和时交付停止指令。
|
||||
*/
|
||||
private ToolCallResponse handleProgressProtocolViolation(
|
||||
ToolCallRequest request,
|
||||
ProgressProtocolViolationException exception) {
|
||||
@@ -188,6 +238,11 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
return repairableProtocolError(request, exception, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可修复协议错误观察(observation 三副面孔之一):
|
||||
* 返回 violation_type / 缺失字段 / 期望的上一轮 ID / 允许的增益值 / 指令,
|
||||
* 让模型有机会在下一轮修正,而不是直接失败(repair_required=true)。
|
||||
*/
|
||||
private ToolCallResponse repairableProtocolError(
|
||||
ToolCallRequest request,
|
||||
ProgressProtocolViolationException exception,
|
||||
@@ -221,6 +276,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全错误观察:只回稳定错误码(不泄露 raw/敏感信息),status=error。
|
||||
* 用于契约不一致等非协议类拒绝。
|
||||
*/
|
||||
private ToolCallResponse safeError(ToolCallRequest request, String errorCode) {
|
||||
Map<String, Object> observation = new LinkedHashMap<>();
|
||||
observation.put("evidence_status", "ERROR");
|
||||
@@ -235,10 +294,19 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版拒绝记录:仅错误码(非协议类拒绝)。
|
||||
*/
|
||||
private void recordRejection(ToolCallRequest request, String errorCode) {
|
||||
recordRejection(request, errorCode, null, false, context.progress().snapshot());
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整版拒绝记录:写入 Trace 的 TOOL_REQUEST_REJECTED 事件。
|
||||
*
|
||||
* <p>与 TOOL_INVOCATION 区分:被拒绝的 Tool 从未调用 backend,
|
||||
* 不消耗 Tool 预算、不计入实际执行数。
|
||||
*/
|
||||
private void recordRejection(
|
||||
ToolCallRequest request,
|
||||
String errorCode,
|
||||
@@ -250,6 +318,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
violationType, repairPromptDelivered, state));
|
||||
}
|
||||
|
||||
/**
|
||||
* 观察序列化:失败时返回稳定的 SERIALIZATION_ERROR 观察(fail closed)。
|
||||
*/
|
||||
private String writeObservation(Map<String, Object> observation) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(observation);
|
||||
@@ -258,6 +329,10 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模型回传的评价写入 Trace(producer=MODEL):在 before 的已完成调用里
|
||||
* 找到被评价的那次,记录其 tool_call_id + information_gain + 评价后的状态。
|
||||
*/
|
||||
private void recordModelProgress(
|
||||
DiagnosisProgressSnapshotState before,
|
||||
ParsedAgentToolCall call,
|
||||
@@ -274,6 +349,9 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
call.previousObservation().informationGain(), "MODEL", after));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次信息增益事件(producer 区分 HARNESS 判定 / MODEL 评价)。
|
||||
*/
|
||||
private void recordProgress(
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
@@ -286,10 +364,17 @@ public final class HarnessToolInterceptor extends ToolInterceptor {
|
||||
informationGain, producer, state));
|
||||
}
|
||||
|
||||
/**
|
||||
* scope 摘要:只记录 toolName + 规范化 scope 的哈希指纹,
|
||||
* 不把完整查询/参数写进 Trace(避免敏感正文落审计)。
|
||||
*/
|
||||
private String scopeSummary(String toolName, String normalizedScope) {
|
||||
return toolName + "#" + String.format("%08x", normalizedScope.hashCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool 非 READY 时的错误观察:稳定错误码,不包含 raw 或敏感正文。
|
||||
*/
|
||||
private String errorObservation(ToolBoundaryResult result) {
|
||||
Map<String, Object> observation = new LinkedHashMap<>();
|
||||
observation.put("evidence_status", result.evidenceStatus());
|
||||
|
||||
@@ -4,15 +4,41 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 安全回退契约(Release 层 FALLBACK 的对外载荷):不发布根因报告时,
|
||||
* 把「为什么降级 + 已验证事实 + 下一步建议」结构化地交给用户。
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ul>
|
||||
* <li>{@code conclusion} 恒为 null:降级绝不发布未证明的根因结论;</li>
|
||||
* <li>诚实降级:verified_sources / observed_facts 保留已验证事实
|
||||
* (供用户继续排查),validation_issues 给出失败原因;</li>
|
||||
* <li>有界冻结契约:全部列表不可变,内容经 SafeFallbackFactory 截断去重
|
||||
* (来源 12 条以内、摘要 320 字),绝不泄露 raw / 敏感正文。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>构造:仅 {@code SafeFallbackFactory}(release 域);
|
||||
* 包装对外:{@code FallbackContent}(application 域);
|
||||
* 审计提取:{@code RunConclusionExtractor}(audit 域)。
|
||||
*/
|
||||
public record SafeFallback(
|
||||
/** 降级细分类型:EVIDENCE_VALIDATION_FAILED / SEMANTIC_UNSUPPORTED / ... */
|
||||
@JsonProperty("type") FallbackType type,
|
||||
/** 恒为 null(降级不发布根因结论,保留字段仅为契约完整性)。 */
|
||||
@JsonProperty("conclusion") String conclusion,
|
||||
/** 一句话降级原因(用户可读)。 */
|
||||
@JsonProperty("message") String message,
|
||||
/** 已验证来源(去重):查过哪些来源。 */
|
||||
@JsonProperty("verified_sources") List<VerifiedSource> verifiedSources,
|
||||
/** 限制声明:检查范围 / 缺失项 / 降级原因。 */
|
||||
@JsonProperty("limitations") List<String> limitations,
|
||||
/** 下一步建议(用户可执行)。 */
|
||||
@JsonProperty("next_steps") List<String> nextSteps,
|
||||
/** 失败阶段标识:DIAGNOSIS_INPUT / DIAGNOSIS_COLLECTION / EVIDENCE_VALIDATION / SEMANTIC_VALIDATION。 */
|
||||
@JsonProperty("failure_stage") String failureStage,
|
||||
/** 已观察事实(去重、有界):每条 = 来源 + 范围 + 摘要,供继续排查。 */
|
||||
@JsonProperty("observed_facts") List<ObservedFact> observedFacts,
|
||||
/** 验证违规明细(EVIDENCE_VALIDATION_FAILED 时携带 code + target)。 */
|
||||
@JsonProperty("validation_issues") List<ValidationIssue> validationIssues) {
|
||||
|
||||
public SafeFallback {
|
||||
@@ -33,12 +59,14 @@ public record SafeFallback(
|
||||
null, List.of(), List.of());
|
||||
}
|
||||
|
||||
/** 已验证来源:来源类型 + 来源 + 范围(发布层可对外展示的最小来源单位)。 */
|
||||
public record VerifiedSource(
|
||||
@JsonProperty("source_type") String sourceType,
|
||||
@JsonProperty("source") String source,
|
||||
@JsonProperty("scope") String scope) {
|
||||
}
|
||||
|
||||
/** 已观察事实:来源类型 + 来源 + 范围 + 有界摘要(一条工具调用结果投影)。 */
|
||||
public record ObservedFact(
|
||||
@JsonProperty("source_type") String sourceType,
|
||||
@JsonProperty("source") String source,
|
||||
@@ -46,6 +74,7 @@ public record SafeFallback(
|
||||
@JsonProperty("summary") String summary) {
|
||||
}
|
||||
|
||||
/** 验证违规明细:违规码 + 目标字段(EVIDENCE_VALIDATION_FAILED 时携带)。 */
|
||||
public record ValidationIssue(
|
||||
@JsonProperty("code") String code,
|
||||
@JsonProperty("target") String target) {
|
||||
|
||||
@@ -26,6 +26,27 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 证据机械验真(证据安全链第 1 道闸):不信任模型自述,以 Canonical Store 账本为准。
|
||||
*
|
||||
* <p>职责:校验 DiagnosisDraft 结构合法 + 每个 tool_call_id 引用真实可查
|
||||
* + 投影内部自洽,并把账本投影「重读重建」为 VerifiedEvidence 快照。
|
||||
*
|
||||
* <p>三个阶段:
|
||||
* <ol>
|
||||
* <li>{@link #validateDraft}:草稿结构完整性(analysis 非空、id 唯一、kind/text/引用齐全、limitations 必填);</li>
|
||||
* <li>{@link #verifyInvocation}:引用验真(key=runId+toolCallId 查账本、记录必须 READY、
|
||||
* 同 Run、agent_result 非空、kind 与证据语义匹配、工具受支持);</li>
|
||||
* <li>readRag/readLogs/readMysql:严格反序列化投影并校验内部一致性,
|
||||
* 重读重建 VerifiedEvidence(证据内容来自账本,不是模型复述)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>纯规则门控、不调大模型:20 个违规码全部可枚举可审计;
|
||||
* 产出 {@link VerifiedEvidenceSnapshot} 供 SemanticGuard 语义裁决与 release 发布。
|
||||
*
|
||||
* <p>被 {@code DiagnosisReleaseUseCase} 调用(validate / validateNoConclusionReferences),
|
||||
* 是「唯一发布点」的第一道门。
|
||||
*/
|
||||
public final class EvidenceGuard {
|
||||
|
||||
private final CanonicalInvocationStore store;
|
||||
@@ -35,6 +56,12 @@ public final class EvidenceGuard {
|
||||
private final ObjectReader mysqlRequestReader;
|
||||
private final ObjectReader mysqlResultReader;
|
||||
|
||||
/**
|
||||
* 构造:注入账本(canonical store)+ key 工厂 + 严格模式 reader。
|
||||
*
|
||||
* <p>四种 reader 全部开启 FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS——
|
||||
* 多余字段、尾随内容一律解析失败(fail closed,不接受「看起来差不多」的投影)。
|
||||
*/
|
||||
public EvidenceGuard(CanonicalInvocationStore store,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
ObjectMapper objectMapper) {
|
||||
@@ -55,6 +82,12 @@ public final class EvidenceGuard {
|
||||
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:draft 结构校验 → 逐条 tool_call 引用验真 → 重读投影重建证据。
|
||||
*
|
||||
* <p>任何违规都收集到 violations(不中断,尽量报全);全部通过才产出快照。
|
||||
* 每个 analysis 只保留「证据非空」的条目——引用为空/无效的分析不进快照。
|
||||
*/
|
||||
public EvidenceGuardResult validate(RunContext context, DiagnosisDraft draft) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
List<EvidenceViolation> violations = validateDraft(draft);
|
||||
@@ -79,6 +112,11 @@ public final class EvidenceGuard {
|
||||
: EvidenceGuardResult.invalid(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 无结论场景的引用校验(conclusion == null 时由 release 调用):
|
||||
* 只验引用真实性,不产出快照(返回 empty)——没有结论就没有「是否被支持」可判。
|
||||
* 供 {@code DiagnosisReleaseUseCase.releaseNoConclusion} 发布前兜底验引用。
|
||||
*/
|
||||
public EvidenceGuardResult validateNoConclusionReferences(
|
||||
RunContext context, DiagnosisDraft draft) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
@@ -113,6 +151,11 @@ public final class EvidenceGuard {
|
||||
: EvidenceGuardResult.invalid(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 A:草稿结构完整性校验(纯规则,不碰 store)。
|
||||
* 先收集全部 analysis id 到集合,再校验报告级引用只能指向这些已登记 id
|
||||
* (封死「结论引用不存在的分析」路径)。
|
||||
*/
|
||||
private List<EvidenceViolation> validateDraft(DiagnosisDraft draft) {
|
||||
List<EvidenceViolation> violations = new ArrayList<>();
|
||||
if (draft == null) {
|
||||
@@ -149,6 +192,10 @@ public final class EvidenceGuard {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告级引用校验:conclusion / action_plan / recommendations 的
|
||||
* based_on_analysis_ids 必须存在且指向已登记 analysis id;limitations 必填。
|
||||
*/
|
||||
private void validateReportReferences(DiagnosisDraft draft, Set<String> ids,
|
||||
List<EvidenceViolation> violations) {
|
||||
if (draft.conclusion() != null) {
|
||||
@@ -178,6 +225,10 @@ public final class EvidenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条报告文本 + 其 based_on_analysis_ids 的合法性:
|
||||
* 文本非空、引用列表非空、每个引用都必须指向已登记 analysis id。
|
||||
*/
|
||||
private void validateTextAndReferences(String target, String text, List<String> references,
|
||||
Set<String> ids, List<EvidenceViolation> violations) {
|
||||
if (isBlank(text)) {
|
||||
@@ -200,6 +251,18 @@ public final class EvidenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 B(核心):验真单条 tool_call 引用。链条逐环检查,任何一环不过即记违规并跳过:
|
||||
*
|
||||
* <pre>
|
||||
* id 非空 → key 构造(runId 绑定,防跨 Run 引用)→ 账本可查 → id 一致
|
||||
* → isReferencableBy(READY + 同 Run + agent_result 非空 + 合法证据语义)
|
||||
* → kind 匹配证据语义(NORMAL↔FOUND / NEGATIVE_OBSERVATION↔NO_EVIDENCE)
|
||||
* → 工具受支持(RAG / LOGS / MYSQL)
|
||||
* </pre>
|
||||
*
|
||||
* 该引用不被采信不代表整体失败:继续检查其余引用,违规全部汇总。
|
||||
*/
|
||||
private void verifyInvocation(RunContext context, DiagnosisDraft.AnalysisItem analysis,
|
||||
int analysisIndex, String toolCallId,
|
||||
List<VerifiedEvidence> evidence,
|
||||
@@ -250,6 +313,11 @@ public final class EvidenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 C(RAG):严格反序列化 RagToolResult 投影,校验内部一致性
|
||||
* (toolCallId / evidenceStatus / returnedCount==evidence.size() / NO_EVIDENCE 时证据必须为空)
|
||||
* 后,把每条命中重建为 VerifiedEvidence。
|
||||
*/
|
||||
private void readRag(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
|
||||
String target, List<EvidenceViolation> violations) {
|
||||
RagToolResult result;
|
||||
@@ -301,6 +369,11 @@ public final class EvidenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 C(LOGS):校验 QueryLogsToolResult 结构(topic/query/时间窗/matchCount 齐全、
|
||||
* returnedCount==events.size()、NO_EVIDENCE 时 matchCount 必须为 0 且无 patterns/events),
|
||||
* 把日志模式与事件重建为 VerifiedEvidence。
|
||||
*/
|
||||
private void readLogs(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
|
||||
String target, List<EvidenceViolation> violations) {
|
||||
QueryLogsToolResult result;
|
||||
@@ -364,6 +437,7 @@ public final class EvidenceGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/** 投影通用一致性校验:toolCallId 与 evidenceStatus 必须与账本记录一致(防投影与账本脱节)。 */
|
||||
private boolean projectionMatches(CanonicalToolInvocation invocation, String toolCallId,
|
||||
com.superbiz.agent.harness.contract.EvidenceStatus evidenceStatus,
|
||||
String target, List<EvidenceViolation> violations) {
|
||||
@@ -378,6 +452,10 @@ public final class EvidenceGuard {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阶段 C(MYSQL):校验 MysqlToolResult(列唯一非空、returnedCount==rows.size()、
|
||||
* 每行 keySet 必须恰好等于 columns),把每一行重建为 VerifiedEvidence(带 _row_number)。
|
||||
*/
|
||||
private void readMysql(CanonicalToolInvocation invocation, List<VerifiedEvidence> evidence,
|
||||
String target, List<EvidenceViolation> violations) {
|
||||
MysqlToolRequest request;
|
||||
|
||||
@@ -7,6 +7,10 @@ public record EvidenceGuardResult(
|
||||
List<EvidenceViolation> violations,
|
||||
VerifiedEvidenceSnapshot snapshot) {
|
||||
|
||||
/**
|
||||
* 结构不变量:valid 必须有 snapshot、invalid 必须有 violations,二选一无中间态
|
||||
* (有效结果不可能带违规,无效结果不可能带快照)。
|
||||
*/
|
||||
public EvidenceGuardResult {
|
||||
violations = violations == null ? List.of() : List.copyOf(violations);
|
||||
if (violations.isEmpty() == (snapshot == null)) {
|
||||
|
||||
@@ -24,6 +24,19 @@ import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* 守卫模型调用器:通用「隔离判定模型」的受控调用(SemanticGuard 等守卫用)。
|
||||
*
|
||||
* <p>与主 Agent 调用不同:守卫模型单轮、无工具、强约束输出,但同样受 Run 生命周期管:
|
||||
* <ul>
|
||||
* <li>core.beforeModelCall / checkActive:与 core 门禁对齐;</li>
|
||||
* <li>auditor.begin / recordUsage:Token 记账(ModelCallLedger);</li>
|
||||
* <li>executor.submit + future.get(timeout):独立线程 + 超时截断;</li>
|
||||
* <li>context.cancellation().onCancel → future.cancel(true):Run 取消强杀在途调用;</li>
|
||||
* <li>输出限制:非文本 / 带 tool_calls / 超 maxOutputBytes → SCHEMA_INVALID 重试;</li>
|
||||
* <li>终态异常(RunAborted / BudgetExceeded)原样穿出,不吞。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class GuardModelCall {
|
||||
|
||||
private final DiagnosisHarnessCore core;
|
||||
@@ -44,6 +57,11 @@ public final class GuardModelCall {
|
||||
this.auditor = Objects.requireNonNull(auditor, "auditor must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 受控调用:beforeModelCall 门禁 → 记账开始 → 提交执行 → 注册取消回调
|
||||
* → future.get(timeout) 等待。超时/取消/中断/执行异常分别归类映射 RetryFailure;
|
||||
* RunAbortedException / BudgetExceededException 原样穿出(Run 终态事实,不可重试)。
|
||||
*/
|
||||
public String call(RunContext context, ModelCallComponent component,
|
||||
Prompt prompt, Duration timeout, long maxOutputBytes) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
@@ -91,6 +109,11 @@ public final class GuardModelCall {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行侧:真实 chatModel.call,成功则记账 usage 并 checkActive;
|
||||
* 输出形状违规(null/空白/带 tool_calls)或超 maxOutputBytes 归类 SCHEMA_INVALID;
|
||||
* 输出字节也 reserveRunBytes 计入预算(守卫模型的花费不是无底洞)。
|
||||
*/
|
||||
private String invoke(RunContext context, ModelCallLedger.Call call,
|
||||
Prompt prompt, long maxOutputBytes) {
|
||||
ChatResponse response;
|
||||
@@ -119,6 +142,7 @@ public final class GuardModelCall {
|
||||
return output.getText();
|
||||
}
|
||||
|
||||
/** 记账 usage;缺 metadata/usage 时按 0 记账(保持账本完整性,可对账)。 */
|
||||
private void recordUsage(RunContext context, ModelCallLedger.Call call, ChatResponse response) {
|
||||
if (response == null || response.getMetadata() == null) {
|
||||
auditor.recordUsage(context, call, 0, 0, false);
|
||||
|
||||
@@ -24,6 +24,24 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 语义裁决(证据安全链第 2 道闸):判「结论是否被已验证证据支持」。
|
||||
*
|
||||
* <p>在 EvidenceGuard 机械验真通过后调用——结构错、引用假根本到不了这里。
|
||||
* 职责:把用户可见视图(SemanticDraftView,不含内部 id)+ 已验证证据交给
|
||||
* 隔离的守卫模型,硬校验输出(恰好 {verdict, reason} 两个字段),
|
||||
* 裁决 SUPPORTED / UNSUPPORTED。
|
||||
*
|
||||
* <p>与 Harness 全栈衔接:
|
||||
* <ul>
|
||||
* <li>预算:输入输出字节都 {@code core.reserveRunBytes} 计入 Run 预算;</li>
|
||||
* <li>重试:走 {@code context.retryPolicies().semanticGuard()},每次 attempt 递减剩余超时;</li>
|
||||
* <li>取消:GuardModelCall 内 onCancel → future.cancel(true);</li>
|
||||
* <li>审计:ModelCallLedger 记账 + TraceAuditEvents.semanticAttempt 落 trace。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用(唯一入口)。
|
||||
*/
|
||||
public final class SemanticGuard {
|
||||
|
||||
private static final Set<String> OUTPUT_FIELDS = Set.of("verdict", "reason");
|
||||
@@ -65,6 +83,12 @@ public final class SemanticGuard {
|
||||
this.prompt = SemanticGuardPrompt.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:序列化输入(超限即拒绝 SCHEMA_INVALID)→ 计入预算
|
||||
* → 构造 System(prompt)+User(输入 JSON) 双消息
|
||||
* → 经 HarnessRetryExecutor 按 semanticGuard 策略执行模型调用
|
||||
* → 硬校验输出 schema → 返回裁决。每次 attempt 都记审计 trace。
|
||||
*/
|
||||
public SemanticGuardDecision review(RunContext context, SemanticGuardInput input) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
Objects.requireNonNull(input, "input must not be null");
|
||||
@@ -91,6 +115,11 @@ public final class SemanticGuard {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬校验模型输出:必须是 JSON、字段恰好 {verdict, reason}、verdict 合法枚举、
|
||||
* reason 非空;否则按失败类型抛 GuardModelCallException(可重试:
|
||||
* PARSE_ERROR / SCHEMA_INVALID)。防止模型夹带多余字段或输出不完整。
|
||||
*/
|
||||
private SemanticGuardDecision parse(String output) {
|
||||
JsonNode root;
|
||||
try {
|
||||
@@ -115,6 +144,10 @@ public final class SemanticGuard {
|
||||
return new SemanticGuardDecision(verdict, root.path("reason").asText());
|
||||
}
|
||||
|
||||
/**
|
||||
* 每次 attempt 的剩余超时 = min(总超时 - 已用, 单次上限);
|
||||
* 总超时耗尽即抛 TIMEOUT(不再重试)——守卫判定有硬截止线。
|
||||
*/
|
||||
private Duration remainingTimeout(long startedNanos) {
|
||||
long elapsed = Math.max(0L, System.nanoTime() - startedNanos);
|
||||
long remaining = limits.totalTimeout().toNanos() - elapsed;
|
||||
@@ -125,6 +158,10 @@ public final class SemanticGuard {
|
||||
return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败分类:GuardModelCallException 自带 RetryFailure;
|
||||
* 其余未知异常归 UNKNOWN(不重试,直接失败)。
|
||||
*/
|
||||
private RetryFailure classify(Exception exception) {
|
||||
if (exception instanceof GuardModelCallException guardFailure) {
|
||||
return guardFailure.failure();
|
||||
|
||||
@@ -16,16 +16,42 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 停止后的「安全投影」:把 Tracker 里保存的调用 identity 回读 Canonical Store 的
|
||||
* READY 记录,投影成可发布的有界快照 {@link DiagnosisProgressSnapshot}。
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ul>
|
||||
* <li>输入:Tracker 只存 identity(toolCallId + toolName + normalizedScope),无 payload;
|
||||
* 完整事实按 key(runId + toolCallId)从 Canonical Store 回读——避免出现第二份 Tool 真相;</li>
|
||||
* <li>三重校验(isReferencableBy / toolCallId / toolName)通过才发布;
|
||||
* 无法验真、不可读、格式非法的记录一律排除,只形成 limitation;</li>
|
||||
* <li>空结果也投影为有界事实(「该范围内未发现」)——限定范围的空查询是有价值信息;</li>
|
||||
* <li>硬截断:最多 12 条事实、摘要/范围各 320 字符、来源 160 字符,绝不输出 raw。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>产出被 {@code DiagnosisReleaseUseCase} 消费,是发布 INSUFFICIENT_EVIDENCE 类
|
||||
* FALLBACK 的全部原料(progress 与 release 的交汇点)。
|
||||
*/
|
||||
public final class DiagnosisProgressProjector implements DiagnosisProgressProjection {
|
||||
|
||||
/** 投影事实条数上限:超出记 limitation 并停止投影。 */
|
||||
private static final int MAX_FACTS = 12;
|
||||
/** 每条事实摘要的最大字符数。 */
|
||||
private static final int MAX_SUMMARY_CHARS = 320;
|
||||
/** 查询范围(scope)的最大字符数。 */
|
||||
private static final int MAX_SCOPE_CHARS = 320;
|
||||
|
||||
/** Canonical 事实存储:按 key 回读 READY 记录(唯一真相源)。 */
|
||||
private final CanonicalInvocationStore store;
|
||||
/** 生成 canonical key(runId + toolCallId)。 */
|
||||
private final ToolCallKeyFactory keyFactory;
|
||||
/** JSON 解析:agent_result 反序列化 + normalizedScope 解析。 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 全参构造:三个依赖全部必填(null 直接 NPE 暴露装配错误)。
|
||||
*/
|
||||
public DiagnosisProgressProjector(CanonicalInvocationStore store,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
ObjectMapper objectMapper) {
|
||||
@@ -34,6 +60,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:遍历 Tracker 的已完成调用列表,逐个回读 canonical 并投影;
|
||||
* 返回不可变的 ProgressSnapshot(verified sources + observed facts +
|
||||
* limitations + stopReason),供 Release 发布。
|
||||
*/
|
||||
@Override
|
||||
public DiagnosisProgressSnapshot project(RunContext context) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
@@ -44,11 +75,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
for (CompletedToolCall completed : state.completedToolCalls()) {
|
||||
CanonicalToolInvocation invocation = resolve(context, completed, limitations);
|
||||
if (invocation == null) {
|
||||
// 无法验真/不可读:已记 limitation,跳过
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
projectInvocation(completed, invocation, sources, facts);
|
||||
} catch (RuntimeException exception) {
|
||||
// 投影异常(agent_result 非合法对象等):排除并记 limitation,不发布 raw
|
||||
addLimitation(limitations, "部分已完成的工具结果格式无法验证,未纳入已检查事实");
|
||||
}
|
||||
if (facts.size() >= MAX_FACTS) {
|
||||
@@ -63,6 +96,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
state.stopReason());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 identity 回读 canonical 记录,做三重校验:
|
||||
* isReferencableBy(READY + 同 run + agentResult 非空 + evidence 合法)、
|
||||
* toolCallId 一致、toolName 一致——任何一项不过即排除并记 limitation。
|
||||
*/
|
||||
private CanonicalToolInvocation resolve(RunContext context,
|
||||
CompletedToolCall completed,
|
||||
List<String> limitations) {
|
||||
@@ -78,11 +116,16 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
return invocation;
|
||||
} catch (RuntimeException exception) {
|
||||
// Store 不可读(如 TTL 过期/后端异常):记 limitation,不中断整体投影
|
||||
addLimitation(limitations, "部分已完成的工具记录暂时不可读取,未纳入已检查事实");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 Tool 类型分派投影:先把 agent_result 解析为 JSON 对象并计算公开 scope,
|
||||
* 再交给对应 Tool 的投影逻辑(RAG / 日志 / MySQL)。
|
||||
*/
|
||||
private void projectInvocation(CompletedToolCall completed,
|
||||
CanonicalToolInvocation invocation,
|
||||
Map<String, SafeFallback.VerifiedSource> sources,
|
||||
@@ -97,12 +140,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 投影:evidence 数组空 → 有界事实「未发现可用文档证据」;
|
||||
* 非空 → 逐条投影 source(source/title/document_id 取其一)+ excerpt。
|
||||
*/
|
||||
private void projectRag(JsonNode root,
|
||||
String scope,
|
||||
Map<String, SafeFallback.VerifiedSource> sources,
|
||||
Map<String, SafeFallback.ObservedFact> facts) {
|
||||
JsonNode evidence = root.path("evidence");
|
||||
if (!evidence.isArray() || evidence.isEmpty()) {
|
||||
// 空结果是有价值信息:限定范围的空查询也是「已检查」的证明
|
||||
addFact(sources, facts, "RAG", "knowledge_base", scope,
|
||||
"该知识检索范围内未发现可用文档证据");
|
||||
return;
|
||||
@@ -115,6 +163,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志投影:events 空 → 「未发现匹配事件」;非空 → 逐条 message。
|
||||
* source 取自 source_kind(缺省 logs)。
|
||||
*/
|
||||
private void projectLogs(JsonNode root,
|
||||
String scope,
|
||||
Map<String, SafeFallback.VerifiedSource> sources,
|
||||
@@ -132,6 +184,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 投影:rows 空 → 「未发现匹配记录」;非空 → 逐行 toString。
|
||||
* source 从公开 scope 的 data_source 提取。
|
||||
*/
|
||||
private void projectMysql(JsonNode root,
|
||||
String scope,
|
||||
Map<String, SafeFallback.VerifiedSource> sources,
|
||||
@@ -148,6 +204,11 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开 scope:从 agent_result / normalizedScope 提取对外展示的查询范围,
|
||||
* 按 Tool 类型不同(RAG=query、LOG=scope 对象、MYSQL=data_source),
|
||||
* 统一截断到 MAX_SCOPE_CHARS——不泄露完整参数。
|
||||
*/
|
||||
private String publicScope(String toolName, String normalizedScope, JsonNode result) {
|
||||
if (AgentToolContracts.LOOKUP_KNOWLEDGE.equals(toolName)) {
|
||||
return bounded("query=" + text(result, "query"), MAX_SCOPE_CHARS);
|
||||
@@ -164,11 +225,17 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/** 从公开 scope("data_source=xxx")中提取 MySQL 数据源名。 */
|
||||
private String mysqlSource(String scope) {
|
||||
int separator = scope.indexOf('=');
|
||||
return separator < 0 ? "mysql" : scope.substring(separator + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加一条有界事实:三重截断(source 160 / scope 320 / summary 320)后
|
||||
* 写入去重 Map——同「来源类型 + 来源 + 范围」只发布一次 source,
|
||||
* 同「sourceKey + 摘要」只发布一次 fact(LinkedHashMap 保持顺序)。
|
||||
*/
|
||||
private void addFact(Map<String, SafeFallback.VerifiedSource> sources,
|
||||
Map<String, SafeFallback.ObservedFact> facts,
|
||||
String sourceType,
|
||||
@@ -189,6 +256,10 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
new SafeFallback.ObservedFact(sourceType, safeSource, safeScope, safeSummary));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 canonical agent_result:必须是合法 JSON 对象,否则抛异常
|
||||
* (由调用方捕获后记为 limitation,不发布)。
|
||||
*/
|
||||
private JsonNode readObject(String value) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(value);
|
||||
@@ -201,12 +272,14 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
}
|
||||
}
|
||||
|
||||
/** 追加 limitation(按文案去重,避免同一条限制重复出现)。 */
|
||||
private static void addLimitation(List<String> limitations, String value) {
|
||||
if (!limitations.contains(value)) {
|
||||
limitations.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取第一个非空值,全空返回 "unknown"。 */
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
@@ -216,11 +289,13 @@ public final class DiagnosisProgressProjector implements DiagnosisProgressProjec
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** 安全取 JSON 字段文本:缺失/null 返回空串。 */
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node == null ? null : node.get(field);
|
||||
return value == null || value.isNull() ? "" : value.asText("");
|
||||
}
|
||||
|
||||
/** 截断到 max 字符(null 视为空串)。 */
|
||||
private static String bounded(String value, int max) {
|
||||
String safe = value == null ? "" : value;
|
||||
return safe.length() <= max ? safe : safe.substring(0, max);
|
||||
|
||||
@@ -4,12 +4,29 @@ import com.superbiz.agent.harness.contract.SafeFallback;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 停止后的「安全进展快照」:Run 已完成的验证事实 + 限制声明 + 停止原因。
|
||||
*
|
||||
* <p>由 {@link DiagnosisProgressProjector} 在 Agent 停止后投影生成:
|
||||
* 把 Tracker 里的调用 identity 回读 Canonical Store 的 READY 记录,
|
||||
* 重读投影成有界、去重的事实——不是模型自述,是账本背书。
|
||||
*
|
||||
* <p>被 {@code DiagnosisReleaseUseCase} 消费:
|
||||
* 受控停止 / 无结论 / 非法 Draft 三条降级路径都靠它决定发布形态
|
||||
* (有 observed_facts 才允许发布 INSUFFICIENT_EVIDENCE,否则 fail closed)。
|
||||
*/
|
||||
public record DiagnosisProgressSnapshot(
|
||||
/** 已验证来源(去重后):只到「查过哪些来源」粒度,供 FALLBACK 展示。 */
|
||||
List<SafeFallback.VerifiedSource> verifiedSources,
|
||||
/** 已观察事实(去重后):每条 = 来源类型 + 来源 + 范围 + 有界摘要,
|
||||
* 是「Run 真的查过什么、结果如何」的证据性记录(空查询也算事实)。 */
|
||||
List<SafeFallback.ObservedFact> observedFacts,
|
||||
/** 限制声明:无法验真/不可读/截断等原因的诚实说明。 */
|
||||
List<String> limitations,
|
||||
/** 停止原因(受控停止时):信息饱和 / 预算耗尽 / 协议违规。 */
|
||||
DiagnosisStopReason stopReason) {
|
||||
|
||||
/** 防御:三列表全部转不可变,null 视为空列表。 */
|
||||
public DiagnosisProgressSnapshot {
|
||||
verifiedSources = verifiedSources == null ? List.of() : List.copyOf(verifiedSources);
|
||||
observedFacts = observedFacts == null ? List.of() : List.copyOf(observedFacts);
|
||||
@@ -20,6 +37,11 @@ public record DiagnosisProgressSnapshot(
|
||||
return new DiagnosisProgressSnapshot(List.of(), List.of(), List.of(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「是否有安全进展」的判断依据:observedFacts 非空即视为有已验真事实。
|
||||
* release 域的 fail-closed 分支全靠它——没有事实就不能把
|
||||
* 「没查到」伪装成业务结果发布。
|
||||
*/
|
||||
public boolean hasObservedFacts() {
|
||||
return !observedFacts.isEmpty();
|
||||
}
|
||||
|
||||
@@ -7,23 +7,60 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Progress 层的核心状态机:判定「继续收集证据是否还有价值」。
|
||||
*
|
||||
* <p>与 RunBudget 的分工(双停止机制):
|
||||
* <ul>
|
||||
* <li>RunBudget 管「能不能花」——模型次数 / Tool 次数 / Token / bytes 等硬资源上限;</li>
|
||||
* <li>本 Tracker 管「继续查有没有价值」——连续 NO_GAIN、重复 scope、协议违规都会推动
|
||||
* 收集状态走向 SATURATED,进而在硬预算之前让 Agent 受控停止。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ul>
|
||||
* <li>只保存做停止决策需要的最小状态(identity + 计数),不保存 request / raw /
|
||||
* agent result,避免出现第二份 Tool 真相(完整事实在 Canonical Store);</li>
|
||||
* <li>所有状态读写 synchronized,是 RunContext 中的线程安全单一所有者;</li>
|
||||
* <li>Tool 提供客观结果,模型判断语义增益(GAINED/NO_GAIN),但最终停止权归 Harness。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class DiagnosisProgressTracker {
|
||||
|
||||
/** 连续 NO_GAIN 达到该阈值 → SATURATED + INFORMATION_SATURATED(默认 2)。 */
|
||||
private final int stopAfterConsecutiveNoGain;
|
||||
/** 连续 progress 协议违规达到该阈值 → SATURATED + PROGRESS_PROTOCOL_VIOLATED(默认 2)。 */
|
||||
private final int stopAfterConsecutiveProgressProtocolViolations;
|
||||
/** 已完成调用的去重集合:toolName + normalizedScope,backend 执行前判重。 */
|
||||
private final Set<ToolScopeIdentity> completedScopes = new LinkedHashSet<>();
|
||||
/** 已完成调用的 identity 列表(无 payload),供结束时 Projector 回读 canonical。 */
|
||||
private final List<CompletedToolCall> completedToolCalls = new ArrayList<>();
|
||||
/** 连续无增益次数;GAINED 清零。 */
|
||||
private int consecutiveNoGain;
|
||||
/** 连续协议违规次数;一次合法评价(或无 pending 的合法调用)后清零。 */
|
||||
private int consecutiveProgressProtocolViolations;
|
||||
/** 收集状态机:COLLECTING(可继续收集)→ SATURATED(已饱和,只能停止)。 */
|
||||
private DiagnosisCollectionState collectionState = DiagnosisCollectionState.COLLECTING;
|
||||
/** 停止原因:INFORMATION_SATURATED / BUDGET_LIMIT_REACHED / PROGRESS_PROTOCOL_VIOLATED。 */
|
||||
private DiagnosisStopReason stopReason;
|
||||
/** 等待模型评价的 tool_call_id;同一时刻最多一个 pending。 */
|
||||
private String pendingToolCallId;
|
||||
/** 一次 STOP_REQUIRED 指令是否已交付(claimStopInstruction 只成功一次)。 */
|
||||
private boolean stopInstructionDelivered;
|
||||
|
||||
/**
|
||||
* 单参数构造:连续 NO_GAIN 阈值显式指定,协议违规阈值使用默认值 2。
|
||||
*/
|
||||
public DiagnosisProgressTracker(int stopAfterConsecutiveNoGain) {
|
||||
this(stopAfterConsecutiveNoGain, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全参构造:两个连续停止阈值都必须为正数(不允许 0 或负数)。
|
||||
*
|
||||
* @param stopAfterConsecutiveNoGain 连续 NO_GAIN 达到该次数即饱和
|
||||
* @param stopAfterConsecutiveProgressProtocolViolations 连续协议违规达到该次数即饱和
|
||||
*/
|
||||
public DiagnosisProgressTracker(
|
||||
int stopAfterConsecutiveNoGain,
|
||||
int stopAfterConsecutiveProgressProtocolViolations) {
|
||||
@@ -39,6 +76,22 @@ public final class DiagnosisProgressTracker {
|
||||
stopAfterConsecutiveProgressProtocolViolations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用模型在下次 Tool Call 中回传的对上一轮观察的评价。
|
||||
*
|
||||
* <p>三类协议违规会被拒绝并抛 {@link ProgressProtocolViolationException}:
|
||||
* <ul>
|
||||
* <li>{@link ProgressProtocolViolationType#UNEXPECTED_PREVIOUS_OBSERVATION}——没有 pending
|
||||
* 时却带了 previous_observation(如首次调用);</li>
|
||||
* <li>{@link ProgressProtocolViolationType#MISSING_PREVIOUS_OBSERVATION}——有 pending 却
|
||||
* 没带评价;</li>
|
||||
* <li>{@link ProgressProtocolViolationType#OUT_OF_ORDER_PREVIOUS_OBSERVATION}——带的
|
||||
* tool_call_id 与 pending 不符(乱序/指向未知调用)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>校验通过后才清协议违规计数并应用 GAINED/NO_GAIN。协议错误与无增益是两件事:
|
||||
* 前者 Tool 根本没执行,后者 Tool 执行了但没推进诊断,因此必须分开统计。
|
||||
*/
|
||||
public synchronized void applyPreviousObservation(PreviousObservation observation) {
|
||||
if (pendingToolCallId == null) {
|
||||
if (observation != null) {
|
||||
@@ -48,6 +101,7 @@ public final class DiagnosisProgressTracker {
|
||||
"previous_observation",
|
||||
null);
|
||||
}
|
||||
// 没有 pending 且没带评价:正常(如首次调用),顺带清协议违规计数
|
||||
clearProtocolViolations();
|
||||
return;
|
||||
}
|
||||
@@ -65,20 +119,40 @@ public final class DiagnosisProgressTracker {
|
||||
"previous_observation.tool_call_id",
|
||||
pendingToolCallId);
|
||||
}
|
||||
// 校验通过:清空 pending,评价生效
|
||||
pendingToolCallId = null;
|
||||
clearProtocolViolations();
|
||||
applyGain(observation.informationGain());
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复检测:toolName + normalizedScope 是否已被本 Run 完成过(backend 执行前调用)。
|
||||
*/
|
||||
public synchronized boolean isDuplicate(String toolName, String normalizedScope) {
|
||||
return completedScopes.contains(new ToolScopeIdentity(toolName, normalizedScope));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次被判重的调用:Harness 直接判定为 NO_GAIN(backend 未被调用)。
|
||||
*/
|
||||
public synchronized void recordDuplicateScope() {
|
||||
clearProtocolViolations();
|
||||
applyGain(InformationGain.NO_GAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次成功的 Tool 完成。
|
||||
*
|
||||
* <ul>
|
||||
* <li>SATURATED 后禁止再记录完成(饱和即停止收集);</li>
|
||||
* <li>只接受 {@link EvidenceStatus#EVIDENCE_FOUND} 或 {@link EvidenceStatus#NO_EVIDENCE};
|
||||
* 失败走技术失败流程,不进入进度统计;</li>
|
||||
* <li>重复 scope 抛 IllegalStateException(应在此之前被 isDuplicate 拦截);</li>
|
||||
* <li>{@link EvidenceStatus#NO_EVIDENCE}:空结果由 Harness 直接判 NO_GAIN,不需要模型评价;</li>
|
||||
* <li>{@link EvidenceStatus#EVIDENCE_FOUND}:设置 pendingToolCallId,等模型在下次
|
||||
* Tool Call 的 previous_observation 中评价语义增益。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public synchronized void recordCompleted(CompletedToolCall call, EvidenceStatus evidenceStatus) {
|
||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||
throw new IllegalStateException("Cannot record Tool completion after saturation");
|
||||
@@ -93,15 +167,24 @@ public final class DiagnosisProgressTracker {
|
||||
}
|
||||
completedToolCalls.add(call);
|
||||
if (evidenceStatus == EvidenceStatus.NO_EVIDENCE) {
|
||||
// 空结果无需模型评价:立即累计 NO_GAIN
|
||||
clearProtocolViolations();
|
||||
applyGain(InformationGain.NO_GAIN);
|
||||
} else {
|
||||
// 非空结果:挂起等待模型在下一轮评价语义增益
|
||||
pendingToolCallId = call.toolCallId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次 progress 协议违规,返回最新快照。
|
||||
*
|
||||
* <p>协议违规(缺评价/乱序/非法 Envelope)不计入 NO_GAIN——那是 Tool 执行了却没增益,
|
||||
* 而违规时 backend 从未执行。连续违规达到独立阈值后进入 SATURATED。
|
||||
*/
|
||||
public synchronized DiagnosisProgressSnapshotState recordProgressProtocolViolation() {
|
||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||
// 已饱和:不再累计,直接返回当前快照
|
||||
return snapshot();
|
||||
}
|
||||
consecutiveProgressProtocolViolations++;
|
||||
@@ -113,6 +196,13 @@ public final class DiagnosisProgressTracker {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取一次停止指令(STOP_REQUIRED)。
|
||||
*
|
||||
* <p>只有 SATURATED 且尚未交付过时返回 true——给模型一次合法完成机会(输出 Draft),
|
||||
* 而不是立即抛错;之后模型仍请求 Tool 时由上层抛
|
||||
* {@code DiagnosisCollectionStoppedException} 穿出框架 ReAct loop。
|
||||
*/
|
||||
public synchronized boolean claimStopInstruction() {
|
||||
if (collectionState != DiagnosisCollectionState.SATURATED) {
|
||||
return false;
|
||||
@@ -124,12 +214,22 @@ public final class DiagnosisProgressTracker {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记预算触顶(由 RunBudget 侧调用)。
|
||||
*
|
||||
* <p>只在尚无 stopReason 时设置 BUDGET_LIMIT_REACHED,不覆盖已有的
|
||||
* INFORMATION_SATURATED / PROGRESS_PROTOCOL_VIOLATED——三种停止原因必须分开,
|
||||
* 信息饱和不能伪装成预算耗尽。
|
||||
*/
|
||||
public synchronized void markBudgetLimitReached() {
|
||||
if (stopReason == null) {
|
||||
stopReason = DiagnosisStopReason.BUDGET_LIMIT_REACHED;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回内部控制快照(计数、pending、停止指令状态与已完成调用列表)。
|
||||
*/
|
||||
public synchronized DiagnosisProgressSnapshotState snapshot() {
|
||||
return new DiagnosisProgressSnapshotState(
|
||||
consecutiveNoGain,
|
||||
@@ -149,6 +249,14 @@ public final class DiagnosisProgressTracker {
|
||||
return stopAfterConsecutiveProgressProtocolViolations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单次增益判定(核心状态迁移):
|
||||
* <ul>
|
||||
* <li>GAINED:清零连续 NO_GAIN——一次早期空查不能使后续有效取证被过早停止;</li>
|
||||
* <li>NO_GAIN:累加,达到阈值 → SATURATED + INFORMATION_SATURATED。</li>
|
||||
* </ul>
|
||||
* 饱和后禁止再次应用(停止权只行使一次)。
|
||||
*/
|
||||
private void applyGain(InformationGain gain) {
|
||||
if (collectionState == DiagnosisCollectionState.SATURATED) {
|
||||
throw new IllegalStateException("Collection is already saturated");
|
||||
@@ -164,6 +272,10 @@ public final class DiagnosisProgressTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空协议违规计数:一次合法评价(或没有 pending 的合法调用)都会重置,
|
||||
* 避免历史违规累积导致误饱和(协议违规只按「连续」计数)。
|
||||
*/
|
||||
private void clearProtocolViolations() {
|
||||
consecutiveProgressProtocolViolations = 0;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,23 @@ import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 发布裁决结果(Release 域唯一出口的结果类型)。
|
||||
*
|
||||
* <p>结构不变量:SUCCESS 必须有 draft 且不允许带 fallback;
|
||||
* FALLBACK 必须有 fallback 且不允许带 draft——
|
||||
* 成功只能带验证过的草稿、降级只能带安全回退,绝无「半真半假」的中间产物。
|
||||
*/
|
||||
public record DiagnosisReleaseResult(
|
||||
ReleaseOutcome outcome,
|
||||
DiagnosisDraft draft,
|
||||
SafeFallback fallback,
|
||||
VerifiedEvidenceSnapshot verifiedEvidence) {
|
||||
|
||||
/**
|
||||
* 结构不变量:outcome 必填;SUCCESS ↔ draft、FALLBACK ↔ fallback 严格互斥;
|
||||
* 本域只支持 SUCCESS / FALLBACK 两个出口(FAILED/CANCELLED 由 Application 层写)。
|
||||
*/
|
||||
public DiagnosisReleaseResult {
|
||||
Objects.requireNonNull(outcome, "outcome must not be null");
|
||||
Objects.requireNonNull(verifiedEvidence, "verifiedEvidence must not be null");
|
||||
|
||||
@@ -24,14 +24,41 @@ import com.superbiz.agent.harness.retry.RetryFailure;
|
||||
import java.util.Objects;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 对外结果的「唯一发布点」:把 Agent 执行结果(Draft / 受控停止 / 非法 Draft)
|
||||
* 裁决为 {@code SUCCESS / FALLBACK},并保证任何对外发布内容都经过
|
||||
* 证据验证(EvidenceGuard)+ 语义裁决(SemanticGuard)。
|
||||
*
|
||||
* <p>决策树(与 progress 的衔接在这里):
|
||||
* <pre>
|
||||
* execute(execution)
|
||||
* ├─ draft == null → 受控停止(progress + stopReason)→ INSUFFICIENT_EVIDENCE
|
||||
* ├─ draft.conclusion == null → 无结论 Draft → 有已验真事实 ? INSUFFICIENT_EVIDENCE
|
||||
* │ : missing_info ? MISSING_REQUIRED_CONTEXT
|
||||
* │ : fail closed(抛异常)
|
||||
* └─ 有结论 Draft → evidenceGuard 验引用 → repair 重试 → semanticGuard 裁决
|
||||
* → SUPPORTED ? SUCCESS : FALLBACK(SEMANTIC_UNSUPPORTED)
|
||||
* </pre>
|
||||
*
|
||||
* <p>任何 FALLBACK 都通过 SafeFallbackFactory 构造有界安全回退(不泄露 raw/敏感正文),
|
||||
* 终态异常(取消/预算耗尽)向上传播不吞掉。
|
||||
*/
|
||||
public final class DiagnosisReleaseUseCase {
|
||||
|
||||
/** 验引用真实性:EvidenceGuard 机械校验 evidence_ref 是否真实可引用。 */
|
||||
private final EvidenceGuard evidenceGuard;
|
||||
/** 引用修复:只修引用不修结论(证据安全链的一环)。 */
|
||||
private final EvidenceRepair evidenceRepair;
|
||||
/** 结论支持度裁决:隔离判断结论是否被已验证证据支持。 */
|
||||
private final SemanticGuard semanticGuard;
|
||||
/** 有界安全回退工厂:构造 INSUFFICIENT_EVIDENCE 等 FALLBACK。 */
|
||||
private final SafeFallbackFactory fallbackFactory;
|
||||
/** Trace 记录器:release 阶段的决策事件(evidence/semantic/release)。 */
|
||||
private final DiagnosisTraceRecorder traceRecorder;
|
||||
|
||||
/**
|
||||
* 四参构造:Trace 记录器使用 noop(测试/无审计场景)。
|
||||
*/
|
||||
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
||||
EvidenceRepair evidenceRepair,
|
||||
SemanticGuard semanticGuard,
|
||||
@@ -40,6 +67,9 @@ public final class DiagnosisReleaseUseCase {
|
||||
DiagnosisTraceRecorder.noop());
|
||||
}
|
||||
|
||||
/**
|
||||
* 全参构造:五个依赖全部必填(null 直接 NPE 暴露配置错误)。
|
||||
*/
|
||||
public DiagnosisReleaseUseCase(EvidenceGuard evidenceGuard,
|
||||
EvidenceRepair evidenceRepair,
|
||||
SemanticGuard semanticGuard,
|
||||
@@ -53,12 +83,27 @@ public final class DiagnosisReleaseUseCase {
|
||||
this.traceRecorder = Objects.requireNonNull(traceRecorder, "traceRecorder must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化入口:正常收尾(模型输出了合法 Draft)时调用——
|
||||
* 包装成 completed execution(progress 为空),走完整裁决。
|
||||
*/
|
||||
public DiagnosisReleaseResult execute(RunContext context, String query, DiagnosisDraft draft) {
|
||||
return execute(context, query, DiagnosisAgentExecution.completed(
|
||||
Objects.requireNonNull(draft, "draft must not be null"),
|
||||
DiagnosisProgressSnapshot.empty()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:按执行结果三分支裁决(对外结果的唯一出口)。
|
||||
*
|
||||
* <ul>
|
||||
* <li>draft == null:受控停止(信息饱和 / 预算耗尽 / 协议违规),
|
||||
* 只凭 progress 快照发布 INSUFFICIENT_EVIDENCE;</li>
|
||||
* <li>conclusion == null:模型明确无结论,校验其引用后按
|
||||
* 有无已验真事实 / missing_info 决定发布类型;</li>
|
||||
* <li>有结论:走完整证据验证 + 语义裁决链。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public DiagnosisReleaseResult execute(
|
||||
RunContext context, String query, DiagnosisAgentExecution execution) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
@@ -69,19 +114,27 @@ public final class DiagnosisReleaseUseCase {
|
||||
|
||||
DiagnosisDraft draft = execution.draft();
|
||||
if (draft == null) {
|
||||
// 受控停止:没有 Draft,只能靠已完成检查的 progress 快照发布
|
||||
return releaseControlledStop(context, execution.progress(), execution.stopReason());
|
||||
}
|
||||
if (draft.conclusion() == null) {
|
||||
// 无结论 Draft(含 conclusion=null 合法收尾):查引用 + 按进展发布
|
||||
return releaseNoConclusion(context, draft, execution.progress());
|
||||
}
|
||||
// 有结论 Draft:证据验证 →(必要时)修复 → 语义裁决
|
||||
return releaseConclusion(context, query, draft);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非法 Draft 专用发布:模型输出不符合 Schema 时,若本 Run 已有可发布事实,
|
||||
* 降级为 INSUFFICIENT_EVIDENCE Fallback(非法 draft 正文永不出现在对外 content)。
|
||||
*/
|
||||
public DiagnosisReleaseResult releaseInvalidDraft(
|
||||
RunContext context, DiagnosisProgressSnapshot progress) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
Objects.requireNonNull(progress, "progress must not be null");
|
||||
if (!progress.hasObservedFacts()) {
|
||||
// 无安全事实 → fail closed:调用方保持原异常(DiagnosisChatExecutor 里再上抛)
|
||||
throw new IllegalStateException(
|
||||
"Invalid Diagnosis Draft has no verified publishable progress");
|
||||
}
|
||||
@@ -90,6 +143,12 @@ public final class DiagnosisReleaseUseCase {
|
||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 有结论 Draft 的完整发布链:
|
||||
* ① EvidenceGuard 验引用真实性 → ② 不过则 EvidenceRepair 只修引用再验
|
||||
* → ③ SemanticGuard 裁决结论支持度 → SUPPORTED ? SUCCESS : SEMANTIC_UNSUPPORTED FALLBACK。
|
||||
* 任何环节的终态异常(取消/预算)向上传播,不吞掉。
|
||||
*/
|
||||
private DiagnosisReleaseResult releaseConclusion(
|
||||
RunContext context, String query, DiagnosisDraft draft) {
|
||||
DiagnosisDraft candidate = draft;
|
||||
@@ -98,6 +157,7 @@ public final class DiagnosisReleaseUseCase {
|
||||
context, TraceEventType.EVIDENCE_GUARD_INITIAL, evidence, candidate));
|
||||
if (!evidence.valid()) {
|
||||
try {
|
||||
// 引用不真实:只修引用(EvidenceRepair),不替模型改结论
|
||||
candidate = evidenceRepair.repair(context, query, draft, evidence.violations());
|
||||
evidence = evidenceGuard.validate(context, candidate);
|
||||
traceRecorder.record(TraceAuditEvents.evidenceValidation(
|
||||
@@ -107,6 +167,7 @@ public final class DiagnosisReleaseUseCase {
|
||||
return evidenceFailure(context, evidence);
|
||||
}
|
||||
if (!evidence.valid()) {
|
||||
// 修复后仍不真实 → 证据验证失败 Fallback(不发布模型原文结论)
|
||||
return evidenceFailure(context, evidence);
|
||||
}
|
||||
}
|
||||
@@ -117,6 +178,7 @@ public final class DiagnosisReleaseUseCase {
|
||||
decision = semanticGuard.review(
|
||||
context, SemanticGuardInput.from(query, candidate, snapshot));
|
||||
} catch (RuntimeException exception) {
|
||||
// 语义裁决不可用(如模型超时)→ 降级为 SEMANTIC_UNAVAILABLE Fallback
|
||||
propagateTerminal(exception);
|
||||
traceRecorder.record(TraceAuditEvents.semanticUnavailable(context));
|
||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||
@@ -127,16 +189,25 @@ public final class DiagnosisReleaseUseCase {
|
||||
}
|
||||
traceRecorder.record(TraceAuditEvents.semanticDecision(context, decision.verdict()));
|
||||
if (decision.verdict() == SemanticVerdict.SUPPORTED) {
|
||||
// 引用真实 + 结论被支持 → 唯一的 SUCCESS 出口
|
||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||
context, com.superbiz.agent.harness.contract.ReleaseOutcome.SUCCESS, null));
|
||||
return DiagnosisReleaseResult.success(candidate, snapshot);
|
||||
}
|
||||
// 引用真实但结论不支持 → 语义不支持 Fallback(保留已验证快照)
|
||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||
context, com.superbiz.agent.harness.contract.ReleaseOutcome.FALLBACK,
|
||||
FallbackType.SEMANTIC_UNSUPPORTED));
|
||||
return DiagnosisReleaseResult.fallback(fallbackFactory.semanticUnsupported(snapshot));
|
||||
}
|
||||
|
||||
/**
|
||||
* 无结论 Draft 路径(conclusion=null 是合法收尾,不是失败):
|
||||
* 先验引用,再按「有无已验真事实 / 是否声明缺失上下文」发布:
|
||||
* 有事实 → INSUFFICIENT_EVIDENCE(展示已查内容 + 缺失项);
|
||||
* 无事实但声明 missing_info → MISSING_REQUIRED_CONTEXT;
|
||||
* 都没有 → 不变量被破坏,fail closed 抛异常。
|
||||
*/
|
||||
private DiagnosisReleaseResult releaseNoConclusion(
|
||||
RunContext context, DiagnosisDraft draft, DiagnosisProgressSnapshot progress) {
|
||||
EvidenceGuardResult evidence = evidenceGuard.validateNoConclusionReferences(context, draft);
|
||||
@@ -148,19 +219,27 @@ public final class DiagnosisReleaseUseCase {
|
||||
|
||||
List<String> missingInfo = missingInfo(draft);
|
||||
if (progress.hasObservedFacts()) {
|
||||
// 已查过一些内容:诚实展示「查了什么、都是空的」+ 下一步所需信息
|
||||
return progressFallback(context,
|
||||
fallbackFactory.insufficientEvidence(progress, missingInfo),
|
||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||
}
|
||||
if (!missingInfo.isEmpty()) {
|
||||
// 零 Tool 直接声明缺上下文:合法,不强制空查
|
||||
return progressFallback(context,
|
||||
fallbackFactory.missingRequiredContext(missingInfo),
|
||||
FallbackType.MISSING_REQUIRED_CONTEXT);
|
||||
}
|
||||
// 既无进展又无缺失声明 → 状态非法,fail closed
|
||||
throw new IllegalStateException(
|
||||
"No-conclusion Diagnosis has neither verified progress nor missing context");
|
||||
}
|
||||
|
||||
/**
|
||||
* 受控停止路径(draft == null 时进入):三种停止原因(信息饱和 / 预算 / 协议违规)
|
||||
* 都必须有已验真事实才能发布 INSUFFICIENT_EVIDENCE;
|
||||
* 无安全进展 → fail closed(不能把「没查到」伪装成业务结果)。
|
||||
*/
|
||||
private DiagnosisReleaseResult releaseControlledStop(
|
||||
RunContext context,
|
||||
DiagnosisProgressSnapshot progress,
|
||||
@@ -171,14 +250,19 @@ public final class DiagnosisReleaseUseCase {
|
||||
throw new IllegalStateException("Unsupported Diagnosis stop reason");
|
||||
}
|
||||
if (!progress.hasObservedFacts()) {
|
||||
// 没有已验证进展 → fail closed(对外不可发布任何结论)
|
||||
throw new IllegalStateException(
|
||||
"Controlled Diagnosis stop has no verified publishable progress");
|
||||
}
|
||||
// 有进展:把「已查过这些、都无增益」作为诚实的业务结果发布
|
||||
return progressFallback(context,
|
||||
fallbackFactory.insufficientEvidence(progress, List.of()),
|
||||
FallbackType.INSUFFICIENT_EVIDENCE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 FALLBACK 出口:记录 release 决策 Trace 并返回安全回退结果。
|
||||
*/
|
||||
private DiagnosisReleaseResult progressFallback(
|
||||
RunContext context,
|
||||
com.superbiz.agent.harness.contract.SafeFallback fallback,
|
||||
@@ -188,11 +272,18 @@ public final class DiagnosisReleaseUseCase {
|
||||
return DiagnosisReleaseResult.fallback(fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 Draft 声明的缺失上下文(limitations.missingInfo),供发布类型判定。
|
||||
*/
|
||||
private List<String> missingInfo(DiagnosisDraft draft) {
|
||||
return draft.limitations() == null
|
||||
? List.of() : draft.limitations().missingInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据验证失败出口:引用无法验真 → EVIDENCE_VALIDATION_FAILED Fallback
|
||||
* (违规明细进 fallback,不发布模型原文)。
|
||||
*/
|
||||
private DiagnosisReleaseResult evidenceFailure(
|
||||
RunContext context, EvidenceGuardResult evidence) {
|
||||
traceRecorder.record(TraceAuditEvents.releaseDecision(
|
||||
@@ -202,6 +293,12 @@ public final class DiagnosisReleaseUseCase {
|
||||
fallbackFactory.evidenceValidationFailed(evidence.violations()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 终态异常透传:取消(RunAbortedException / RetryFailure.CANCELLED)和
|
||||
* 预算耗尽(BudgetExceededException / RetryFailure.BUDGET_EXHAUSTED)不能被
|
||||
* release 吞掉——它们是 Run 的终态事实,必须向上传播到 Application 层。
|
||||
* 其余运行时异常(修复/裁决的内部失败)则不拦截,由调用方按降级处理。
|
||||
*/
|
||||
private void propagateTerminal(RuntimeException exception) {
|
||||
if (exception instanceof RunAbortedException
|
||||
|| exception instanceof BudgetExceededException) {
|
||||
|
||||
@@ -26,6 +26,25 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 引用修复器(证据安全链的一环):EvidenceGuard 验真失败后,「只修引用、不修结论」。
|
||||
*
|
||||
* <p>核心约束:
|
||||
* <ul>
|
||||
* <li>prompt 锁死:只能改 analysis_id / tool_call_ids / based_on_analysis_ids
|
||||
* 三个引用字段,结论、分析正文、kind、limitations 一律禁止动;</li>
|
||||
* <li>语义不变性检查:修复前后 {@link SemanticDraftView#hasSameUserVisibleSemantics}
|
||||
* 逐字段比对——用户可见内容一个字节不许变,变了判 SCHEMA_INVALID 重试;</li>
|
||||
* <li>受控调用:与 SemanticGuard 同款全栈衔接(输入计预算、独立重试策略
|
||||
* evidenceRepair、超时限制、GuardModelCall 受控调用、审计 trace)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>为什么调大模型而不是 Harness 机械替换:修引用需要理解语义
|
||||
* (哪条 analysis 该锚哪个调用),机械替换做不到;但修复器的自由度
|
||||
* 被 prompt + 语义不变性双重锁死。
|
||||
*
|
||||
* <p>被 {@code DiagnosisReleaseUseCase.releaseConclusion} 调用。
|
||||
*/
|
||||
public final class EvidenceRepair {
|
||||
|
||||
private final DiagnosisHarnessCore core;
|
||||
@@ -69,6 +88,14 @@ public final class EvidenceRepair {
|
||||
this.prompt = EvidenceRepairPrompt.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:输入(query + 原 draft + 违规清单)序列化并计预算
|
||||
* → 构造 System(prompt)+User(输入) 双消息
|
||||
* → 按 evidenceRepair 重试策略执行模型修复
|
||||
* → 解析修复结果并做语义不变性检查(变了即失败)。
|
||||
*
|
||||
* @return 修复后的 DiagnosisDraft(仅引用字段可能变化)
|
||||
*/
|
||||
public DiagnosisDraft repair(RunContext context, String query, DiagnosisDraft original,
|
||||
List<EvidenceViolation> violations) {
|
||||
Objects.requireNonNull(context, "context must not be null");
|
||||
@@ -110,6 +137,7 @@ public final class EvidenceRepair {
|
||||
});
|
||||
}
|
||||
|
||||
/** 严格反序列化修复输出(FAIL_ON_UNKNOWN_PROPERTIES + FAIL_ON_TRAILING_TOKENS),失败归类 PARSE_ERROR。 */
|
||||
private DiagnosisDraft parse(String output) {
|
||||
try {
|
||||
return draftReader.readValue(output);
|
||||
@@ -119,6 +147,7 @@ public final class EvidenceRepair {
|
||||
}
|
||||
}
|
||||
|
||||
/** 失败分类:GuardModelCallException 自带 RetryFailure;其余归 UNKNOWN。 */
|
||||
private RetryFailure classify(Exception exception) {
|
||||
return exception instanceof GuardModelCallException failure
|
||||
? failure.failure() : RetryFailure.UNKNOWN;
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.superbiz.agent.harness.release;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* EvidenceRepair 的限额:输入/输出字节 + 单次修复超时。
|
||||
* 防修复器本身成为无底洞(超大 draft 或无限重试)。
|
||||
*/
|
||||
public record EvidenceRepairLimits(
|
||||
long maxInputBytes,
|
||||
long maxOutputBytes,
|
||||
|
||||
@@ -13,11 +13,28 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 安全回退工厂:构造所有 FALLBACK 形态的有界、去重、诚实降级。
|
||||
*
|
||||
* <p>设计要点:
|
||||
* <ul>
|
||||
* <li>诚实降级:降级不抹掉进展——SEMANTIC_UNSUPPORTED / INSUFFICIENT_EVIDENCE
|
||||
* 保留已验证事实(observed_facts / verified_sources)供用户继续排查;</li>
|
||||
* <li>有界:observed_facts 最多 12 条、摘要 320 字符,missing_info 最多 8 条
|
||||
* (绝不泄露 raw / 敏感正文,空摘要降级为受限审计说明文案);</li>
|
||||
* <li>conclusion 恒为 null:降级不发布根因结论;</li>
|
||||
* <li>fail closed:insufficientEvidence 要求 progress 必须有已验真事实,
|
||||
* missingRequiredContext 要求 missing_info 非空,否则拒绝构造。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>被 {@code DiagnosisReleaseUseCase} 的各个降级出口调用。
|
||||
*/
|
||||
public final class SafeFallbackFactory {
|
||||
|
||||
private static final int MAX_OBSERVED_FACTS = 12;
|
||||
private static final int MAX_SUMMARY_CHARS = 320;
|
||||
|
||||
/** 引用验真失败(含 repair 后仍失败):只给违规明细,零事实。 */
|
||||
public SafeFallback evidenceValidationFailed(List<EvidenceViolation> violations) {
|
||||
return fallback(
|
||||
FallbackType.EVIDENCE_VALIDATION_FAILED,
|
||||
@@ -30,6 +47,7 @@ public final class SafeFallbackFactory {
|
||||
issues(violations));
|
||||
}
|
||||
|
||||
/** 结论不被证据支撑(verdict=UNSUPPORTED):保留已验证事实与来源。 */
|
||||
public SafeFallback semanticUnsupported(VerifiedEvidenceSnapshot snapshot) {
|
||||
return fallback(
|
||||
FallbackType.SEMANTIC_UNSUPPORTED,
|
||||
@@ -42,6 +60,7 @@ public final class SafeFallbackFactory {
|
||||
List.of());
|
||||
}
|
||||
|
||||
/** 语义评审技术不可用(超时等):不发布根因,保留已验证事实。 */
|
||||
public SafeFallback semanticUnavailable(VerifiedEvidenceSnapshot snapshot) {
|
||||
return fallback(
|
||||
FallbackType.SEMANTIC_UNAVAILABLE,
|
||||
@@ -54,6 +73,10 @@ public final class SafeFallbackFactory {
|
||||
List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 有限检查但证据不足(受控停止/无结论/非法 draft 降级的共同出口):
|
||||
* 必须已有已验真事实(否则 fail closed),展示检查过的范围 + 缺失项。
|
||||
*/
|
||||
public SafeFallback insufficientEvidence(
|
||||
DiagnosisProgressSnapshot progress, List<String> missingInfo) {
|
||||
Objects.requireNonNull(progress, "progress must not be null");
|
||||
@@ -78,6 +101,7 @@ public final class SafeFallbackFactory {
|
||||
List.of());
|
||||
}
|
||||
|
||||
/** 缺上下文未开始有效查询:只列缺失项,无事实。 */
|
||||
public SafeFallback missingRequiredContext(List<String> missingInfo) {
|
||||
List<String> safeMissingInfo = boundedMissingInfo(missingInfo);
|
||||
if (safeMissingInfo.isEmpty()) {
|
||||
@@ -112,6 +136,10 @@ public final class SafeFallbackFactory {
|
||||
return Objects.requireNonNull(snapshot, "snapshot must not be null").verifiedSources();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从验证快照提取去重后的可观察事实:key = 来源类型+来源+范围+摘要,
|
||||
* 最多 12 条、摘要 320 字符;空摘要降级为受限审计说明(不泄露 raw)。
|
||||
*/
|
||||
private List<SafeFallback.ObservedFact> facts(VerifiedEvidenceSnapshot snapshot) {
|
||||
Objects.requireNonNull(snapshot, "snapshot must not be null");
|
||||
Map<String, SafeFallback.ObservedFact> unique = new LinkedHashMap<>();
|
||||
@@ -133,6 +161,7 @@ public final class SafeFallbackFactory {
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
/** 把违规明细映射为对外可用的 ValidationIssue 列表(code + target)。 */
|
||||
private List<SafeFallback.ValidationIssue> issues(List<EvidenceViolation> violations) {
|
||||
List<SafeFallback.ValidationIssue> result = new ArrayList<>();
|
||||
for (EvidenceViolation violation : violations == null ? List.<EvidenceViolation>of() : violations) {
|
||||
|
||||
@@ -15,13 +15,20 @@ import com.superbiz.agent.harness.tool.mysql.MysqlSqlValidator;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Validates and runs the logical MySQL Tool through the canonical boundary. */
|
||||
/**
|
||||
* MySQL 逻辑工具的接线员:反序列化请求 → SQL 沙箱校验(fail-closed)→
|
||||
* 把只读执行器(executor)和投影器(projector)组装进 ToolBoundary 统一门禁。
|
||||
* 安全/参数异常映射为 INVALID_REQUEST(不泄露内部细节)。
|
||||
*/
|
||||
public final class MysqlToolAdapter {
|
||||
|
||||
private final ToolBoundary boundary;
|
||||
private final ObjectMapper objectMapper;
|
||||
/** SQL 沙箱:白名单表列 + fail-closed 策略。 */
|
||||
private final MysqlSqlValidator validator;
|
||||
/** 只读执行器(JDBC 只读连接 + 超时 + 行数 + 取消)。 */
|
||||
private final MysqlReadOnlyExecutor executor;
|
||||
/** 投影器:raw 行 → 有界脱敏契约。 */
|
||||
private final MysqlResultProjector projector;
|
||||
|
||||
public MysqlToolAdapter(ToolBoundary boundary, ObjectMapper objectMapper,
|
||||
@@ -34,12 +41,19 @@ public final class MysqlToolAdapter {
|
||||
this.projector = Objects.requireNonNull(projector, "projector must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行入口:解析请求 → SQL 沙箱校验(生成执行计划)→ 组装 executor/projector
|
||||
* 交给 ToolBoundary。任何安全/参数异常统一映射 INVALID_REQUEST。
|
||||
*/
|
||||
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
|
||||
try {
|
||||
MysqlToolRequest request = objectMapper.readValue(envelope.requestJson(), MysqlToolRequest.class);
|
||||
// 沙箱校验:表列白名单 + fail-closed 策略 → 规范化执行计划
|
||||
MysqlQueryPlan plan = validator.validate(request);
|
||||
return boundary.execute(context, envelope,
|
||||
// executor:只读执行器返回 raw 行 JSON
|
||||
ignored -> objectMapper.writeValueAsString(executor.execute(plan, context)),
|
||||
// projector:有界脱敏投影(用该数据源的限制)
|
||||
raw -> projector.project(request, envelope.toolCallId(), raw, plan.dataSource().limits()));
|
||||
} catch (MysqlSecurityException | IllegalArgumentException e) {
|
||||
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
|
||||
|
||||
@@ -17,9 +17,14 @@ import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Bridges logical query-log requests and the existing Mock tool through ToolBoundary. */
|
||||
/**
|
||||
* 逻辑日志请求与既有 Mock 工具的接线员:
|
||||
* 反序列化请求 → 校验 topic/query/lookback → 构造查询范围 scope →
|
||||
* 把 legacy executor 和投影器组装进 ToolBoundary 统一门禁执行。
|
||||
*/
|
||||
public final class QueryLogsToolAdapter {
|
||||
|
||||
/** legacy backend 执行端口(region + 旧主题 + 关键词 + 条数)。 */
|
||||
@FunctionalInterface
|
||||
public interface LegacyExecutor {
|
||||
String execute(String region, String legacyTopic, String query, Integer limit) throws Exception;
|
||||
@@ -58,25 +63,33 @@ public final class QueryLogsToolAdapter {
|
||||
this.legacyLimit = legacyLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行入口:解析请求 → 校验 → 构造 scope → 组装 executor/projector 交给 ToolBoundary。
|
||||
* 业务参数非法返回 INVALID_REQUEST(不抛异常打断 ReAct)。
|
||||
*/
|
||||
public ToolBoundaryResult execute(RunContext context, ToolCallRequestEnvelope envelope) {
|
||||
try {
|
||||
QueryLogsRequest request = objectMapper.readValue(envelope.requestJson(), QueryLogsRequest.class);
|
||||
if (request.topic() == null || request.query() == null || request.query().isBlank()) {
|
||||
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
// 回看窗口:缺省 30 分钟,上限 24 小时
|
||||
int lookback = request.lookbackMinutes() == null
|
||||
? DEFAULT_LOOKBACK_MINUTES : request.lookbackMinutes();
|
||||
if (lookback <= 0 || lookback > 24 * 60) {
|
||||
return ToolBoundaryResult.error(envelope.toolCallId(), ToolBoundaryErrorCode.INVALID_REQUEST);
|
||||
}
|
||||
Instant end = clock.instant();
|
||||
// 实际查询范围(审计/公开 scope 用)
|
||||
LogQueryScope scope = new LogQueryScope(
|
||||
request.topic(), request.query(),
|
||||
DateTimeFormatter.ISO_INSTANT.format(end.minus(Duration.ofMinutes(lookback))),
|
||||
DateTimeFormatter.ISO_INSTANT.format(end));
|
||||
String legacyTopic = legacyTopic(request.topic());
|
||||
return boundary.execute(context, envelope,
|
||||
// executor:调用 legacy 日志 backend
|
||||
ignored -> legacyExecutor.execute(region, legacyTopic, request.query(), legacyLimit),
|
||||
// projector:投影成冻结契约(含 scope)
|
||||
raw -> projector.project(request, envelope.toolCallId(), scope, raw));
|
||||
} catch (Exception e) {
|
||||
return ToolBoundaryResult.error(envelope == null ? null : envelope.toolCallId(),
|
||||
@@ -84,6 +97,7 @@ public final class QueryLogsToolAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 逻辑主题 → legacy 日志主题名映射。 */
|
||||
private static String legacyTopic(LogTopic topic) {
|
||||
return switch (topic) {
|
||||
case APPLICATION -> "application-logs";
|
||||
|
||||
@@ -4,6 +4,10 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Projector 的产出:有界 agent_result 文本 + 客观 evidence status。
|
||||
* ToolBoundary 只接受 FOUND/NO_EVIDENCE(ERROR 走错误路径,不产生投影结果)。
|
||||
*/
|
||||
public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceStatus) {
|
||||
|
||||
public ProjectedToolResult {
|
||||
@@ -11,6 +15,7 @@ public record ProjectedToolResult(String agentResult, EvidenceStatus evidenceSta
|
||||
throw new IllegalArgumentException("agentResult must not be blank");
|
||||
}
|
||||
Objects.requireNonNull(evidenceStatus, "evidenceStatus must not be null");
|
||||
// 投影结果只能是合法证据语义(空不空),错误状态不从这里出
|
||||
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
||||
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
|
||||
throw new IllegalArgumentException("projected result must be evidence or no-evidence");
|
||||
|
||||
@@ -25,18 +25,46 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 所有证据 Tool 的统一执行门卫(tool 域核心):每个 Adapter 不再自己实现
|
||||
* 授权、预算、store 和审计,而是统一走这里。
|
||||
*
|
||||
* <p>职责(对一次 Tool 执行):
|
||||
* <ol>
|
||||
* <li>preflight:run 匹配 / 授权 / 只读意图 / JSON 合法性 / key 生成;</li>
|
||||
* <li>预算门禁:Tool 预算(beforeToolCall)+ Run bytes 预留(request → raw → agent_result 三笔);</li>
|
||||
* <li>canonical 状态机:begin(PROJECTING) → 执行/投影 → markReady(READY) 或 markError(ERROR);</li>
|
||||
* <li>审计:best-effort 记录 ToolInvocationAuditEvent(失败不阻断业务)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>边界(明确不做):
|
||||
* <ul>
|
||||
* <li>不理解 Tool 业务内容——raw → agent_result 的投影由调用方传入的
|
||||
* {@code ToolResultProjector} 完成;</li>
|
||||
* <li>不做信息增益判断(那是 progress 层的事);</li>
|
||||
* <li>只允许 READY / ERROR 离开(PROJECTING 不对外暴露)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class ToolBoundary {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ToolBoundary.class);
|
||||
|
||||
/** Harness 核心:Run bytes 预留(reserveRunBytes)、Tool 预算检查(beforeToolCall)。 */
|
||||
private final DiagnosisHarnessCore core;
|
||||
/** canonical key 生成(runId + toolCallId)。 */
|
||||
private final ToolCallKeyFactory keyFactory;
|
||||
/** canonical 持久化(唯一真相源)。 */
|
||||
private final CanonicalInvocationStore store;
|
||||
/** request JSON 合法性校验。 */
|
||||
private final ObjectMapper objectMapper;
|
||||
/** 时间戳(begin/ready/error/audit 统一时钟)。 */
|
||||
private final Clock clock;
|
||||
/** 持久化审计 sink(可 noop)。 */
|
||||
private final ToolInvocationAuditSink auditSink;
|
||||
/** 当前 step id 追踪(可 null:无 step 场景不记录)。 */
|
||||
private final AgentStepAuditTracker stepTracker;
|
||||
|
||||
/** 最简构造:审计 noop + stepTracker null(测试/轻量场景)。 */
|
||||
public ToolBoundary(DiagnosisHarnessCore core,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
CanonicalInvocationStore store,
|
||||
@@ -45,6 +73,7 @@ public final class ToolBoundary {
|
||||
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop(), null);
|
||||
}
|
||||
|
||||
/** 带审计构造:持久化 Tool 审计,无 stepTracker。 */
|
||||
public ToolBoundary(DiagnosisHarnessCore core,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
CanonicalInvocationStore store,
|
||||
@@ -54,6 +83,7 @@ public final class ToolBoundary {
|
||||
this(core, keyFactory, store, objectMapper, clock, auditSink, null);
|
||||
}
|
||||
|
||||
/** 全参构造:七个依赖全部必填(null 直接 NPE 暴露装配错误)。 */
|
||||
public ToolBoundary(DiagnosisHarnessCore core,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
CanonicalInvocationStore store,
|
||||
@@ -70,6 +100,12 @@ public final class ToolBoundary {
|
||||
this.stepTracker = stepTracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一执行入口:计算耗时 → 执行 canonical 状态机 → best-effort 审计 → 返回结果。
|
||||
*
|
||||
* @param executor 具体 backend 的 raw 执行函数(如 MySQL/日志 adapter)
|
||||
* @param projector 该 Tool 的投影器(raw → 有界 agent_result + evidence status)
|
||||
*/
|
||||
public ToolBoundaryResult execute(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolExecutor executor,
|
||||
@@ -80,6 +116,11 @@ public final class ToolBoundary {
|
||||
return outcome.result();
|
||||
}
|
||||
|
||||
/**
|
||||
* canonical 状态机主流程:所有成功/失败路径都映射为
|
||||
* ToolBoundaryResult.ready / ToolBoundaryResult.error,中间状态不外泄;
|
||||
* 任何异常都先尝试 markError 落库(best-effort),再返回稳定错误码。
|
||||
*/
|
||||
private ExecutionOutcome executeCanonical(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolExecutor executor,
|
||||
@@ -87,10 +128,12 @@ public final class ToolBoundary {
|
||||
String toolCallId = request == null ? null : request.toolCallId();
|
||||
String key;
|
||||
try {
|
||||
// ── 阶段一:preflight + Tool 预算 + request bytes 预留,通过后写 PROJECTING ──
|
||||
key = preflight(context, request);
|
||||
core.beforeToolCall(context, request.toolName());
|
||||
long requestBytes = store.limits().utf8Bytes(request.requestJson());
|
||||
if (requestBytes > store.limits().maxRecordBytes()) {
|
||||
// 请求体超限:不落库直接拒绝
|
||||
return ExecutionOutcome.of(errorAndNoRecord(toolCallId, ToolBoundaryErrorCode.RESULT_TOO_LARGE));
|
||||
}
|
||||
core.reserveRunBytes(context, requestBytes);
|
||||
@@ -98,18 +141,22 @@ public final class ToolBoundary {
|
||||
request.toolCallId(), request.runId(), request.toolName(),
|
||||
request.requestJson(), clock.instant()));
|
||||
} catch (DuplicateInvocationException e) {
|
||||
// 同 key 重复 begin(同一 run+toolCallId 调两次)→ 拒绝
|
||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.DUPLICATE_TOOL_CALL));
|
||||
} catch (RunAbortedException | BudgetExceededException e) {
|
||||
// Run 已取消/预算耗尽:对应终态错误码
|
||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId,
|
||||
e instanceof BudgetExceededException
|
||||
? ToolBoundaryErrorCode.BUDGET_EXHAUSTED
|
||||
: ToolBoundaryErrorCode.RUN_INACTIVE));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// preflight 非法:按异常类型归类错误码
|
||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, classifyPreflightError(e)));
|
||||
} catch (CanonicalStoreException e) {
|
||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.STORE_ERROR));
|
||||
}
|
||||
|
||||
// ── 阶段二:真正执行 backend(raw 响应)──
|
||||
String rawResponse;
|
||||
try {
|
||||
rawResponse = Objects.requireNonNull(executor, "executor must not be null")
|
||||
@@ -118,10 +165,12 @@ public final class ToolBoundary {
|
||||
throw new IllegalArgumentException("executor returned null");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// backend 执行失败:落 ERROR(无 raw)
|
||||
markErrorSafely(key, null, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR);
|
||||
return ExecutionOutcome.of(ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.TOOL_EXECUTION_ERROR));
|
||||
}
|
||||
|
||||
// ── 阶段三:raw 大小校验 + Run bytes 预留 ──
|
||||
try {
|
||||
store.limits().validateRawCandidate(request.requestJson(), rawResponse);
|
||||
core.reserveRunBytes(context, store.limits().utf8Bytes(rawResponse));
|
||||
@@ -139,6 +188,7 @@ public final class ToolBoundary {
|
||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
||||
}
|
||||
|
||||
// ── 阶段四:投影(raw → 有界 agent_result,Projector 计算 evidence status)──
|
||||
ProjectedToolResult projected;
|
||||
try {
|
||||
projected = Objects.requireNonNull(projector, "projector must not be null")
|
||||
@@ -147,11 +197,13 @@ public final class ToolBoundary {
|
||||
throw new IllegalArgumentException("projector returned null");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 投影失败(raw 无法解析等):落 ERROR
|
||||
markErrorSafely(key, rawResponse, ToolBoundaryErrorCode.PROJECTION_ERROR);
|
||||
return new ExecutionOutcome(
|
||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.PROJECTION_ERROR), rawResponse);
|
||||
}
|
||||
|
||||
// ── 阶段五:agent_result 校验 + bytes 预留 → 迁移 READY(唯一成功出口)──
|
||||
try {
|
||||
store.limits().validateAgentResult(projected.agentResult());
|
||||
core.reserveRunBytes(context, store.limits().utf8Bytes(projected.agentResult()));
|
||||
@@ -172,6 +224,7 @@ public final class ToolBoundary {
|
||||
return new ExecutionOutcome(
|
||||
ToolBoundaryResult.error(toolCallId, ToolBoundaryErrorCode.RUN_INACTIVE), rawResponse);
|
||||
} catch (CanonicalStoreException e) {
|
||||
// store 持久化失败:错误码归类(结果过大 vs 投影问题)
|
||||
ToolBoundaryErrorCode code = e instanceof ResultTooLargeException
|
||||
? ToolBoundaryErrorCode.RESULT_TOO_LARGE
|
||||
: ToolBoundaryErrorCode.PROJECTION_ERROR;
|
||||
@@ -180,17 +233,24 @@ public final class ToolBoundary {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行前门禁:run 匹配 / 授权 / 只读意图 / 字段与 JSON 合法性 / key 生成。
|
||||
* 任何一项不过都会抛特定异常,由调用方归类为稳定错误码。
|
||||
*/
|
||||
private String preflight(RunContext context, ToolCallRequestEnvelope request) {
|
||||
if (context == null || request == null) {
|
||||
throw new IllegalArgumentException("request/context must not be null");
|
||||
}
|
||||
if (!context.runId().equals(request.runId())) {
|
||||
// 信封 run 与当前 context 不一致 → RUN_MISMATCH
|
||||
throw new RunMismatchException();
|
||||
}
|
||||
if (!request.authorized()) {
|
||||
// 未授权调用 → UNAUTHORIZED
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!request.readOnly()) {
|
||||
// 非只读意图(诊断 Tool 必须是只读)→ NOT_READ_ONLY
|
||||
throw new NotReadOnlyException();
|
||||
}
|
||||
if (request.toolName() == null || request.toolName().isBlank()
|
||||
@@ -198,6 +258,7 @@ public final class ToolBoundary {
|
||||
throw new IllegalArgumentException("tool name and request must not be blank");
|
||||
}
|
||||
try {
|
||||
// request 必须是合法 JSON 对象
|
||||
JsonNode root = objectMapper.readTree(request.requestJson());
|
||||
if (root == null || !root.isObject()) {
|
||||
throw new IllegalArgumentException("request must be a JSON object");
|
||||
@@ -208,10 +269,14 @@ public final class ToolBoundary {
|
||||
try {
|
||||
return keyFactory.create(request.runId(), request.toolCallId());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// toolCallId 非法 → INVALID_TOOL_CALL_ID
|
||||
throw new InvalidToolCallIdException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全落 ERROR(best-effort):持久化失败只记 warn 日志,不阻断返回错误结果。
|
||||
*/
|
||||
private void markErrorSafely(String key, String rawResponse, ToolBoundaryErrorCode code) {
|
||||
try {
|
||||
store.markError(key, rawResponse, code.name(), clock.instant());
|
||||
@@ -220,10 +285,12 @@ public final class ToolBoundary {
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求体超限等「无需落库」的错误出口(尚未 begin,无记录可标记)。 */
|
||||
private ToolBoundaryResult errorAndNoRecord(String toolCallId, ToolBoundaryErrorCode code) {
|
||||
return ToolBoundaryResult.error(toolCallId, code);
|
||||
}
|
||||
|
||||
/** 把 preflight 抛出的 IllegalArgumentException 归类为稳定错误码。 */
|
||||
private ToolBoundaryErrorCode classifyPreflightError(IllegalArgumentException exception) {
|
||||
if (exception instanceof InvalidToolCallIdException) {
|
||||
return ToolBoundaryErrorCode.INVALID_TOOL_CALL_ID;
|
||||
@@ -240,6 +307,10 @@ public final class ToolBoundary {
|
||||
return ToolBoundaryErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化 Tool 审计事件(best-effort):记录调用元数据(status/耗时/bytes/enrichments),
|
||||
* 不保存完整 request/raw/agent result(敏感正文不进审计);失败只记 warn 不阻断业务。
|
||||
*/
|
||||
private void auditSafely(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolBoundaryResult result,
|
||||
@@ -268,38 +339,45 @@ public final class ToolBoundary {
|
||||
}
|
||||
}
|
||||
|
||||
/** UTF-8 字节数(null 视为 0),用于 bytes 预算与审计。 */
|
||||
private static int utf8Bytes(String value) {
|
||||
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
|
||||
}
|
||||
|
||||
/** 防溢出的 int 封顶转换。 */
|
||||
private static int saturatingInt(long value) {
|
||||
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
|
||||
}
|
||||
|
||||
/** 执行结果 + 原始响应(审计用),rawResponse 仅成功/已产出时携带。 */
|
||||
private record ExecutionOutcome(ToolBoundaryResult result, String rawResponse) {
|
||||
static ExecutionOutcome of(ToolBoundaryResult result) {
|
||||
return new ExecutionOutcome(result, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** preflight 专用异常:toolCallId 非法。 */
|
||||
private static final class InvalidToolCallIdException extends IllegalArgumentException {
|
||||
private InvalidToolCallIdException(Throwable cause) {
|
||||
super("Invalid tool call ID", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/** preflight 专用异常:信封 runId 与 context 不一致。 */
|
||||
private static final class RunMismatchException extends IllegalArgumentException {
|
||||
private RunMismatchException() {
|
||||
super("Run ID does not match context");
|
||||
}
|
||||
}
|
||||
|
||||
/** preflight 专用异常:调用未授权。 */
|
||||
private static final class UnauthorizedException extends IllegalArgumentException {
|
||||
private UnauthorizedException() {
|
||||
super("Tool call is not authorized");
|
||||
}
|
||||
}
|
||||
|
||||
/** preflight 专用异常:意图非只读(诊断 Tool 只读红线)。 */
|
||||
private static final class NotReadOnlyException extends IllegalArgumentException {
|
||||
private NotReadOnlyException() {
|
||||
super("Tool call is not read-only");
|
||||
|
||||
@@ -4,6 +4,23 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.contract.InvocationStatus;
|
||||
|
||||
/**
|
||||
* ToolBoundary 向上(Harness)返回的结果:只允许 READY 或 ERROR 两种状态离开 Boundary。
|
||||
*
|
||||
* <p>状态-字段约束(构造时强校验):
|
||||
* <ul>
|
||||
* <li>READY:必须携带有界 agent_result + 合法证据语义(FOUND/NO_EVIDENCE),
|
||||
* 不得带 error_code;</li>
|
||||
* <li>ERROR:evidence_status 必须 ERROR + 稳定 error_code 必填,
|
||||
* 不得带 agent_result(错误时没有可发布的投影结果);</li>
|
||||
* <li>PROJECTING 等中间状态绝不对外暴露(Boundary 内部状态机专用)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>两个工厂方法对应两条出口:{@link #ready}(成功投影后)与 {@link #error}(失败)。
|
||||
*
|
||||
* <p>拦截器拿到它后还会做双源交叉验证(ToolResultViewProjector 重算 evidence status),
|
||||
* 因此 READY 的声明值与内容必须一致。
|
||||
*/
|
||||
public record ToolBoundaryResult(
|
||||
@JsonProperty("status") InvocationStatus status,
|
||||
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
||||
@@ -12,6 +29,7 @@ public record ToolBoundaryResult(
|
||||
@JsonProperty("error_code") String errorCode) {
|
||||
|
||||
public ToolBoundaryResult {
|
||||
// READY:必须有界证据结果(agent_result + FOUND/NO_EVIDENCE),禁止带错误码
|
||||
if (status == InvocationStatus.READY) {
|
||||
if (agentResult == null || evidenceStatus == null
|
||||
|| (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
||||
@@ -22,6 +40,7 @@ public record ToolBoundaryResult(
|
||||
throw new IllegalArgumentException("READY result must not contain errorCode");
|
||||
}
|
||||
} else if (status == InvocationStatus.ERROR) {
|
||||
// ERROR:稳定错误码必填 + evidence 必须 ERROR,禁止带投影结果
|
||||
if (evidenceStatus != EvidenceStatus.ERROR || errorCode == null || errorCode.isBlank()) {
|
||||
throw new IllegalArgumentException("ERROR result requires errorCode and ERROR evidence status");
|
||||
}
|
||||
@@ -29,10 +48,15 @@ public record ToolBoundaryResult(
|
||||
throw new IllegalArgumentException("ERROR result must not contain agent result");
|
||||
}
|
||||
} else {
|
||||
// PROJECTING 等中间状态不允许离开 Boundary
|
||||
throw new IllegalArgumentException("ToolBoundaryResult must be READY or ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* READY 出口:投影成功后调用,携带有界 agent_result 与客观证据语义
|
||||
* (evidence 由 Projector 按「证据数组空不空」计算)。
|
||||
*/
|
||||
public static ToolBoundaryResult ready(String toolCallId,
|
||||
String agentResult,
|
||||
EvidenceStatus evidenceStatus) {
|
||||
@@ -40,6 +64,9 @@ public record ToolBoundaryResult(
|
||||
InvocationStatus.READY, evidenceStatus, toolCallId, agentResult, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* ERROR 出口:失败时调用,携带稳定错误码(不携带 raw/agent_result)。
|
||||
*/
|
||||
public static ToolBoundaryResult error(String toolCallId, ToolBoundaryErrorCode errorCode) {
|
||||
return new ToolBoundaryResult(
|
||||
InvocationStatus.ERROR, EvidenceStatus.ERROR, toolCallId, null, errorCode.name());
|
||||
|
||||
@@ -2,11 +2,22 @@ package com.superbiz.agent.harness.tool.boundary;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 一次 Tool 执行的内部信封:同时证明 Run、调用 ID、工具、参数、授权意图和只读意图。
|
||||
* 由 Adapter 在调 ToolBoundary 前构造(HarnessEvidenceTools 的 bridge 固定 authorized/readOnly=true)。
|
||||
* 与模型侧 progress Envelope(previous_observation + input)不同:这是边界内部信封。
|
||||
*/
|
||||
public record ToolCallRequestEnvelope(
|
||||
/** 所属 Run。 */
|
||||
@JsonProperty("run_id") String runId,
|
||||
/** 本次调用 ID(canonical key 的一部分)。 */
|
||||
@JsonProperty("tool_call_id") String toolCallId,
|
||||
/** 工具名。 */
|
||||
@JsonProperty("tool_name") String toolName,
|
||||
/** 业务请求 JSON(纯业务参数,无协议字段)。 */
|
||||
@JsonProperty("request") String requestJson,
|
||||
/** 是否授权(bridge 恒为 true;策略拒绝走 UNAUTHORIZED)。 */
|
||||
@JsonProperty("authorized") boolean authorized,
|
||||
/** 是否只读意图(诊断 Tool 必须只读,否则 NOT_READ_ONLY)。 */
|
||||
@JsonProperty("read_only") boolean readOnly) {
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.superbiz.agent.harness.tool.boundary;
|
||||
|
||||
/**
|
||||
* 具体 backend 的 raw 执行函数端口(函数式):输入业务请求 JSON,输出原始响应文本。
|
||||
* ToolBoundary 不依赖具体 backend,只认这个端口——Adapter 把各自 backend 接进来。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ToolExecutor {
|
||||
/** 执行 backend,返回原始响应(非 null);失败抛异常由边界映射错误码。 */
|
||||
String execute(String requestJson) throws Exception;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.superbiz.agent.harness.tool.boundary;
|
||||
|
||||
/**
|
||||
* raw → 有界 agent 契约的投影端口(函数式):输入原始响应,输出投影结果
|
||||
* (有界 agent_result + 客观 evidence status)。每个 Tool 一个实现
|
||||
* (Rag/QueryLogs/Mysql ResultProjector),ToolBoundary 通过它解耦投影逻辑。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ToolResultProjector {
|
||||
/** 投影 raw:必须返回非 null 的有界结果;失败抛异常由边界映射 PROJECTION_ERROR。 */
|
||||
ProjectedToolResult project(String rawResponse) throws Exception;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
/**
|
||||
* 三个证据 Tool 的「名字 + 模型可见描述」的单一事实源(冻结契约)。
|
||||
*
|
||||
* <p>所有地方(拦截器判重、Normalizer 分派、Projector 分派、注册表)都引用这里的
|
||||
* 常量而不是字符串字面量——避免工具名拼错导致跨层漂移。
|
||||
*/
|
||||
public final class AgentToolContracts {
|
||||
|
||||
/** 知识库检索工具名。 */
|
||||
public static final String LOOKUP_KNOWLEDGE = "lookup_knowledge";
|
||||
/** 日志查询工具名。 */
|
||||
public static final String QUERY_LOGS = "query_logs";
|
||||
/** MySQL 只读查询工具名。 */
|
||||
public static final String QUERY_MYSQL = "query_mysql";
|
||||
|
||||
/** 模型可见的 RAG 工具描述:稳定背景知识,不用于实时日志/指标。 */
|
||||
public static final String LOOKUP_KNOWLEDGE_DESCRIPTION =
|
||||
"查询内部知识库中的文档、接口说明、错误码和排障手册。"
|
||||
+ "适用于稳定背景知识,不用于查询实时日志、指标或数据库状态。"
|
||||
+ "输入 query:需要查询的问题或关键词。";
|
||||
|
||||
/** 模型可见的日志工具描述:应用错误/慢查询/系统事件,不用于指标或表。 */
|
||||
public static final String QUERY_LOGS_DESCRIPTION =
|
||||
"查询指定逻辑日志主题在时间窗口内与目标相关的日志证据。"
|
||||
+ "适用于应用错误、慢查询和系统事件,不用于查询指标或数据库表。"
|
||||
+ "输入 topic、query、lookback_minutes。";
|
||||
|
||||
/** 模型可见的 MySQL 工具描述:授权数据源的参数化只读 SELECT,禁止发现表结构/写操作。 */
|
||||
public static final String QUERY_MYSQL_DESCRIPTION =
|
||||
"在授权的逻辑数据源上执行参数化只读查询,获取业务数据库事实。"
|
||||
+ "只用于已知库表字段的 SELECT,不用于发现表结构或执行写操作。"
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 单条日志事件(冻结契约):时间/级别/服务/消息四元组。
|
||||
*/
|
||||
public record LogEvent(
|
||||
@JsonProperty("timestamp") String timestamp,
|
||||
@JsonProperty("level") String level,
|
||||
|
||||
@@ -2,11 +2,17 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 日志模式聚合(冻结契约):把相似事件压缩成一条「模式」,
|
||||
* 供模型快速了解事件全貌而不用读每条原始事件。
|
||||
*/
|
||||
public record LogPattern(
|
||||
/** 该模式出现次数。 */
|
||||
@JsonProperty("count") long count,
|
||||
@JsonProperty("first_seen") String firstSeen,
|
||||
@JsonProperty("last_seen") String lastSeen,
|
||||
@JsonProperty("level") String level,
|
||||
@JsonProperty("service") String service,
|
||||
/** 示例事件(有界)。 */
|
||||
@JsonProperty("example") String example) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 实际日志查询范围(冻结契约):反映「这次到底查了什么」,
|
||||
* 供审计、ProgressProjector 的公开 scope、以及重复检测参考。
|
||||
*/
|
||||
public record LogQueryScope(
|
||||
@JsonProperty("topic") LogTopic topic,
|
||||
@JsonProperty("query") String query,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
/**
|
||||
* 日志来源类型(冻结契约)。当前只有 MOCK(演示/评估环境),
|
||||
* 后续可扩展 ES/ClickHouse 等真实来源。
|
||||
*/
|
||||
public enum LogSourceKind {
|
||||
MOCK
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
/**
|
||||
* 逻辑日志主题(冻结契约):模型只能在这三个主题内查询,不能自由指定任意来源。
|
||||
*/
|
||||
public enum LogTopic {
|
||||
/** 应用错误/业务日志。 */
|
||||
APPLICATION,
|
||||
/** 数据库慢查询。 */
|
||||
DATABASE_SLOW_QUERY,
|
||||
/** 系统事件。 */
|
||||
SYSTEM_EVENTS
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.superbiz.agent.harness.progress.PreviousObservation;
|
||||
|
||||
/**
|
||||
* 模型发起的 MySQL 只读查询 Tool 调用 Envelope(Agent-facing 冻结契约):
|
||||
* 协议字段 previous_observation + 业务输入 input。
|
||||
*/
|
||||
public record MysqlToolCall(
|
||||
/** 对上一轮观察的评价(首次调用可为 null)。 */
|
||||
@JsonProperty("previous_observation") PreviousObservation previousObservation,
|
||||
/** 业务输入:data_source + sql + params。 */
|
||||
@JsonProperty("input") MysqlToolRequest input) {
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MySQL 只读查询业务输入(冻结契约):授权逻辑数据源 + 参数化 SQL + 绑定参数。
|
||||
* 判重指纹 = {data_source, sql, params}。
|
||||
*/
|
||||
public record MysqlToolRequest(
|
||||
/** 授权数据源名(不是任意 JDBC URL)。 */
|
||||
@JsonProperty("data_source") String dataSource,
|
||||
/** 参数化 SQL(只允许 SELECT,沙箱校验)。 */
|
||||
@JsonProperty("sql") String sql,
|
||||
/** 绑定参数(防注入)。 */
|
||||
@JsonProperty("params") List<Object> params) {
|
||||
|
||||
public MysqlToolRequest {
|
||||
|
||||
@@ -6,12 +6,21 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MySQL 查询结果(冻结契约):Projector 投影后的有界结果。
|
||||
* 列名 + 行数据(嵌套不可变),供模型看事实、Harness 看 returned_count/truncated。
|
||||
*/
|
||||
public record MysqlToolResult(
|
||||
/** 证据语义:rows 空不空(客观判定)。 */
|
||||
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
||||
@JsonProperty("tool_call_id") String toolCallId,
|
||||
/** 列名列表(有界)。 */
|
||||
@JsonProperty("columns") List<String> columns,
|
||||
/** 行数据(有界、截断过,每行不可变 Map)。 */
|
||||
@JsonProperty("rows") List<Map<String, Object>> rows,
|
||||
/** 返回的行数。 */
|
||||
@JsonProperty("returned_count") int returnedCount,
|
||||
/** 是否因预算截断。 */
|
||||
@JsonProperty("truncated") boolean truncated) {
|
||||
|
||||
public MysqlToolResult {
|
||||
|
||||
@@ -2,8 +2,15 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 日志查询业务输入(冻结契约):逻辑主题 + 关键词 + 回看窗口。
|
||||
* 判重指纹 = {topic, query, lookback_minutes(缺省 30)}。
|
||||
*/
|
||||
public record QueryLogsRequest(
|
||||
/** 逻辑日志主题(APPLICATION / DATABASE_SLOW_QUERY / SYSTEM_EVENTS)。 */
|
||||
@JsonProperty("topic") LogTopic topic,
|
||||
/** 查询关键词。 */
|
||||
@JsonProperty("query") String query,
|
||||
/** 回看分钟数;null 时 Normalizer 用默认 30。 */
|
||||
@JsonProperty("lookback_minutes") Integer lookbackMinutes) {
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.superbiz.agent.harness.progress.PreviousObservation;
|
||||
|
||||
/**
|
||||
* 模型发起的日志查询 Tool 调用 Envelope(Agent-facing 冻结契约):
|
||||
* 协议字段 previous_observation + 业务输入 input。
|
||||
*/
|
||||
public record QueryLogsToolCall(
|
||||
/** 对上一轮观察的评价(首次调用可为 null)。 */
|
||||
@JsonProperty("previous_observation") PreviousObservation previousObservation,
|
||||
/** 业务输入:topic + query + lookback_minutes。 */
|
||||
@JsonProperty("input") QueryLogsRequest input) {
|
||||
}
|
||||
|
||||
@@ -5,15 +5,28 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 日志查询结果(冻结契约):Projector 投影后的有界结果。
|
||||
* 包含聚合 pattern(压缩)与原始 event(有界、截断过),
|
||||
* 供模型看事件、Harness 看 match_count/truncated。
|
||||
*/
|
||||
public record QueryLogsToolResult(
|
||||
/** 证据语义:events 空不空(客观判定)。 */
|
||||
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
||||
@JsonProperty("tool_call_id") String toolCallId,
|
||||
/** 日志来源类型(当前 MOCK)。 */
|
||||
@JsonProperty("source_kind") LogSourceKind sourceKind,
|
||||
/** 实际查询范围(topic/query/时间窗)。 */
|
||||
@JsonProperty("scope") LogQueryScope scope,
|
||||
/** 匹配总数(可能大于 returned_count)。 */
|
||||
@JsonProperty("match_count") long matchCount,
|
||||
/** 实际返回的事件条数。 */
|
||||
@JsonProperty("returned_count") int returnedCount,
|
||||
/** 压缩后的模式聚合(有界)。 */
|
||||
@JsonProperty("patterns") List<LogPattern> patterns,
|
||||
/** 事件明细(有界、截断过)。 */
|
||||
@JsonProperty("events") List<LogEvent> events,
|
||||
/** 是否因预算截断。 */
|
||||
@JsonProperty("truncated") boolean truncated) {
|
||||
|
||||
public QueryLogsToolResult {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
/**
|
||||
* RAG 检索相关度(冻结契约):Projector 客观计算,供 Harness/Release 参考。
|
||||
* 注意:REFERENCE(一般相关)不能自动映射为信息 NO_GAIN——可能仍排除一个假设,
|
||||
* 需要模型结合诊断上下文判断。
|
||||
*/
|
||||
public enum RagRelevanceLevel {
|
||||
/** 精确匹配。 */
|
||||
PRECISE,
|
||||
/** 高度相关。 */
|
||||
HIGHLY_RELEVANT,
|
||||
/** 一般相关(参考级)。 */
|
||||
REFERENCE
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.superbiz.agent.harness.progress.PreviousObservation;
|
||||
|
||||
/**
|
||||
* 模型发起的 RAG 工具调用 Envelope(Agent-facing 冻结契约):
|
||||
* 协议字段 previous_observation + 业务输入 input。
|
||||
* 拦截器解析后剥离 previous_observation,只把 input 传给业务执行。
|
||||
*/
|
||||
public record RagToolCall(
|
||||
/** 对上一轮观察的评价(首次调用可为 null)。 */
|
||||
@JsonProperty("previous_observation") PreviousObservation previousObservation,
|
||||
/** 业务输入:检索 query。 */
|
||||
@JsonProperty("input") RagToolRequest input) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.contract;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* RAG 业务输入(冻结契约):单个检索查询关键词。
|
||||
*/
|
||||
public record RagToolRequest(
|
||||
/** 检索 query(判重指纹的一部分)。 */
|
||||
@JsonProperty("query") String query) {
|
||||
}
|
||||
|
||||
@@ -6,20 +6,31 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* RAG 工具结果(冻结契约):Projector 投影后的有界结果。
|
||||
* 是模型看到的 observation 与 canonical 记录的 agent_result 的统一形态。
|
||||
*/
|
||||
public record RagToolResult(
|
||||
/** 证据语义:证据数组空不空(客观判定)。 */
|
||||
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
|
||||
@JsonProperty("tool_call_id") String toolCallId,
|
||||
/** 原始检索 query。 */
|
||||
@JsonProperty("query") String query,
|
||||
/** 投影后的证据列表(有界、截断过)。 */
|
||||
@JsonProperty("evidence") List<RagEvidence> evidence,
|
||||
/** 返回的证据条数。 */
|
||||
@JsonProperty("returned_count") int returnedCount,
|
||||
/** 检索相关度(仅 EVIDENCE_FOUND 时有值;NO_EVIDENCE 时为 null 不序列化)。 */
|
||||
@JsonProperty("relevance_level") @JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
RagRelevanceLevel relevanceLevel,
|
||||
/** 是否因预算截断。 */
|
||||
@JsonProperty("truncated") boolean truncated) {
|
||||
|
||||
public RagToolResult {
|
||||
evidence = ToolContractCollections.immutable(evidence);
|
||||
}
|
||||
|
||||
/** 便捷构造:无相关度(NO_EVIDENCE / 内部使用)。 */
|
||||
public RagToolResult(EvidenceStatus evidenceStatus,
|
||||
String toolCallId,
|
||||
String query,
|
||||
|
||||
@@ -5,15 +5,21 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 契约对象的不可变集合工具(包私有):所有 Result 的列表字段统一用这里保证不可变,
|
||||
* 防止投影层/消费方意外修改冻结契约。
|
||||
*/
|
||||
final class ToolContractCollections {
|
||||
|
||||
private ToolContractCollections() {
|
||||
}
|
||||
|
||||
/** 列表不可变拷贝(null → 空列表)。 */
|
||||
static <T> List<T> immutable(List<T> values) {
|
||||
return values == null ? List.of() : List.copyOf(values);
|
||||
}
|
||||
|
||||
/** 行集合不可变拷贝:每行 Map 也做深拷贝(嵌套不可变)。 */
|
||||
static List<Map<String, Object>> immutableRows(List<Map<String, Object>> rows) {
|
||||
if (rows == null) {
|
||||
return List.of();
|
||||
@@ -23,6 +29,7 @@ final class ToolContractCollections {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** 单行不可变拷贝(null → 空 Map)。 */
|
||||
private static Map<String, Object> immutableRow(Map<String, Object> row) {
|
||||
if (row == null) {
|
||||
return Map.of();
|
||||
|
||||
@@ -20,11 +20,15 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** JDBC implementation with read-only, timeout, row and cancellation controls. */
|
||||
/**
|
||||
* JDBC 只读执行器:连接强制只读 + 查询超时 + 行数上限 + Run 取消联动。
|
||||
* 是 MysqlReadOnlyExecutor 的唯一实现——沙箱的「执行侧」防线。
|
||||
*/
|
||||
public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(JdbcMysqlReadOnlyExecutor.class);
|
||||
|
||||
/** 逻辑数据源 id → 真实 DataSource 映射(配置时注入)。 */
|
||||
private final Map<String, DataSource> dataSources;
|
||||
private final Clock clock;
|
||||
|
||||
@@ -41,13 +45,16 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
}
|
||||
MysqlToolLimits limits = plan.dataSource().limits();
|
||||
try (Connection connection = dataSource.getConnection()) {
|
||||
// 强制只读连接(双保险:Validator 语义层 + JDBC 连接层)
|
||||
connection.setReadOnly(true);
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
plan.normalizedSql(), ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
|
||||
statement.setQueryTimeout(limits.queryTimeoutSeconds());
|
||||
// 多取一行用于检测截断
|
||||
statement.setMaxRows(limits.maxRows() + 1);
|
||||
bind(statement, plan.params());
|
||||
|
||||
// 注册取消回调:Run 取消时同步 cancel 正在执行的语句
|
||||
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
|
||||
context.cancellation().onCancel(ignored -> cancel(statementRef.get()));
|
||||
checkRun(context);
|
||||
@@ -61,6 +68,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
java.util.ArrayList<Map<String, Object>> rows = new java.util.ArrayList<>();
|
||||
boolean truncated = false;
|
||||
while (resultSet.next()) {
|
||||
// 每行前检查 Run 终态/取消/超时
|
||||
checkRun(context);
|
||||
if (rows.size() >= limits.maxRows()) {
|
||||
truncated = true;
|
||||
@@ -74,6 +82,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
truncated |= cell.truncated();
|
||||
}
|
||||
rows.add(row);
|
||||
// 结果字节预算:超限移除最后一行并标记截断
|
||||
if (estimatedBytes(rows) > limits.maxResultBytes()) {
|
||||
rows.remove(rows.size() - 1);
|
||||
truncated = true;
|
||||
@@ -86,6 +95,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
}
|
||||
}
|
||||
} catch (MysqlSecurityException e) {
|
||||
// 安全异常原样穿出(Adapter 映射稳定错误码)
|
||||
throw e;
|
||||
} catch (SQLException e) {
|
||||
log.debug("MySQL read-only execution failed: sqlState={}", e.getSQLState());
|
||||
@@ -93,18 +103,21 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定参数(PreparedStatement 参数化,防注入)。 */
|
||||
private static void bind(PreparedStatement statement, List<Object> params) throws SQLException {
|
||||
for (int i = 0; i < params.size(); i++) {
|
||||
statement.setObject(i + 1, params.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行前/每行检查:Run 已取消或过 deadline 则中止(与 core 终态联动)。 */
|
||||
private void checkRun(RunContext context) throws SQLException {
|
||||
if (context.cancellation().isCancelled() || !clock.instant().isBefore(context.deadline())) {
|
||||
throw new SQLException("run cancelled or deadline exceeded");
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消正在执行的语句(Run 取消回调)。 */
|
||||
private static void cancel(Statement statement) {
|
||||
if (statement == null) {
|
||||
return;
|
||||
@@ -116,6 +129,9 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格 JSON 安全化:数字/布尔原样;byte[] 转 Base64;字符串截断到 maxCellChars。
|
||||
*/
|
||||
private static CellValue jsonSafe(Object value, int maxCellChars) {
|
||||
if (value == null || value instanceof Number || value instanceof Boolean) {
|
||||
return new CellValue(value, false);
|
||||
@@ -131,6 +147,7 @@ public final class JdbcMysqlReadOnlyExecutor implements MysqlReadOnlyExecutor {
|
||||
: new CellValue(text.substring(0, maxCellChars), true);
|
||||
}
|
||||
|
||||
/** 行集预估字节数(结果预算用)。 */
|
||||
private static int estimatedBytes(List<Map<String, Object>> rows) {
|
||||
return rows.toString().getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,18 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/** Logical datasource metadata and exact schema/table/column authorization. */
|
||||
/**
|
||||
* 逻辑数据源元数据 + 精确的 schema/表/列授权白名单:
|
||||
* 模型只能查询白名单内的表与列——这是 MySQL 只读沙箱的「访问边界」。
|
||||
*/
|
||||
public record MysqlDataSourceDefinition(
|
||||
/** 逻辑数据源 id(模型用这个,不暴露真实 JDBC)。 */
|
||||
String id,
|
||||
/** 默认 schema(必须出现在白名单里)。 */
|
||||
String defaultSchema,
|
||||
/** 授权白名单:schema → table → 允许的列集合。 */
|
||||
Map<String, Map<String, Set<String>>> allowedSchemas,
|
||||
/** 该数据源的查询限制。 */
|
||||
MysqlToolLimits limits) {
|
||||
|
||||
public MysqlDataSourceDefinition {
|
||||
@@ -19,6 +26,7 @@ public record MysqlDataSourceDefinition(
|
||||
requireText(defaultSchema, "defaultSchema");
|
||||
Objects.requireNonNull(allowedSchemas, "allowedSchemas must not be null");
|
||||
Objects.requireNonNull(limits, "limits must not be null");
|
||||
// 深拷贝白名单(列集合 TreeSet 排序保证确定性),防止外部修改
|
||||
Map<String, Map<String, Set<String>>> schemas = new LinkedHashMap<>();
|
||||
allowedSchemas.forEach((schema, tables) -> {
|
||||
requireText(schema, "schema");
|
||||
@@ -35,10 +43,12 @@ public record MysqlDataSourceDefinition(
|
||||
}
|
||||
}
|
||||
|
||||
/** 表是否被授权。 */
|
||||
public boolean allowsTable(String schema, String table) {
|
||||
return allowedSchemas.containsKey(schema) && allowedSchemas.get(schema).containsKey(table);
|
||||
}
|
||||
|
||||
/** 列是否被授权。 */
|
||||
public boolean allowsColumn(String schema, String table, String column) {
|
||||
return allowsTable(schema, table) && allowedSchemas.get(schema).get(table).contains(column);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,18 @@ import com.superbiz.agent.harness.tool.contract.MysqlToolRequest;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次 MySQL 查询的执行计划:请求 + 解析出的逻辑数据源 + 规范化 SQL + 绑定参数。
|
||||
* 由 MysqlToolAdapter 在 SQL 校验后构造,交给只读执行器执行。
|
||||
*/
|
||||
public record MysqlQueryPlan(
|
||||
/** 模型原始请求(data_source/sql/params)。 */
|
||||
MysqlToolRequest request,
|
||||
/** 解析出的授权数据源定义(含 schema/表/列白名单与限制)。 */
|
||||
MysqlDataSourceDefinition dataSource,
|
||||
/** 校验并规范化后的 SQL(单条、无尾分号、禁注释等)。 */
|
||||
String normalizedSql,
|
||||
/** 绑定参数(防注入)。 */
|
||||
List<Object> params) {
|
||||
|
||||
public MysqlQueryPlan {
|
||||
|
||||
@@ -5,7 +5,10 @@ import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Harness-only raw query result; never returned directly to an Agent. */
|
||||
/**
|
||||
* Harness-only raw 查询结果(不直接给 Agent):列 + 行 + 是否截断。
|
||||
* 之后由 MysqlResultProjector 投影成冻结的 MysqlToolResult 才对外。
|
||||
*/
|
||||
public record MysqlRawResult(
|
||||
List<String> columns,
|
||||
List<Map<String, Object>> rows,
|
||||
|
||||
@@ -2,7 +2,12 @@ package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
import com.superbiz.agent.harness.core.RunContext;
|
||||
|
||||
/**
|
||||
* MySQL 只读执行端口(函数式):输入已校验的查询计划 + Run 上下文,输出 raw 结果。
|
||||
* JdbcMysqlReadOnlyExecutor 是唯一实现(只读连接 + 超时 + 行数 + 取消控制)。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface MysqlReadOnlyExecutor {
|
||||
/** 执行只读查询,返回 Harness-only raw 结果;失败抛异常(安全/超时/SQL)。 */
|
||||
MysqlRawResult execute(MysqlQueryPlan plan, RunContext context) throws Exception;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,16 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** Projects raw JDBC rows into the bounded Agent-facing MySQL contract. */
|
||||
/**
|
||||
* 把 raw JDBC 行投影成有界、脱敏的 Agent 可见 MySQL 契约。
|
||||
*
|
||||
* <p>关键职责:
|
||||
* <ul>
|
||||
* <li>脱敏:列名含 password/token/secret/api_key 等敏感 token 时单元格置为 [REDACTED];</li>
|
||||
* <li>有界:行数(maxRows)+ 单元格字符(maxCellChars)+ 总字节(maxResultBytes)三重截断;</li>
|
||||
* <li>客观证据语义:rows 空不空 → NO_EVIDENCE / EVIDENCE_FOUND。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class MysqlResultProjector {
|
||||
|
||||
private static final List<String> SENSITIVE_TOKENS = List.of(
|
||||
@@ -37,6 +46,12 @@ public final class MysqlResultProjector {
|
||||
return project(request, toolCallId, rawResponse, limits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:raw JDBC 行 JSON → 冻结的 MysqlToolResult。
|
||||
*
|
||||
* <p>流程:校验 columns/rows 结构 → 列名去重 → 逐行逐列投影(敏感列脱敏 + 字符截断)
|
||||
* → 行数/字节截断 → 判定 evidence status → fitBudget 总字节兜底。
|
||||
*/
|
||||
public ProjectedToolResult project(MysqlToolRequest request, String toolCallId,
|
||||
String rawResponse, MysqlToolLimits projectionLimits) throws Exception {
|
||||
if (request == null || toolCallId == null || toolCallId.isBlank()) {
|
||||
@@ -49,6 +64,7 @@ public final class MysqlResultProjector {
|
||||
}
|
||||
List<String> columns = new ArrayList<>();
|
||||
java.util.LinkedHashSet<String> uniqueColumns = new java.util.LinkedHashSet<>();
|
||||
// 列名必须非空且唯一(避免歧义投影)
|
||||
root.path("columns").forEach(node -> {
|
||||
String column = node.asText();
|
||||
if (column.isBlank() || !uniqueColumns.add(column)) {
|
||||
@@ -59,6 +75,7 @@ public final class MysqlResultProjector {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
boolean truncated = root.path("truncated").asBoolean(false);
|
||||
for (JsonNode rowNode : root.path("rows")) {
|
||||
// 行数上限:超出置 truncated 并停止
|
||||
if (rows.size() >= projectionLimits.maxRows()) {
|
||||
truncated = true;
|
||||
break;
|
||||
@@ -71,12 +88,14 @@ public final class MysqlResultProjector {
|
||||
truncated |= cell.truncated();
|
||||
}
|
||||
rows.add(row);
|
||||
// 结果字节预算:超限移除最后一行并标记截断
|
||||
if (utf8Bytes(rows.toString()) > projectionLimits.maxResultBytes()) {
|
||||
rows.remove(rows.size() - 1);
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 客观证据语义:行空不空
|
||||
MysqlToolResult result = new MysqlToolResult(
|
||||
rows.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
|
||||
toolCallId, columns, rows, rows.size(), truncated);
|
||||
@@ -84,6 +103,10 @@ public final class MysqlResultProjector {
|
||||
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 总字节兜底:超过 maxResultBytes 时逐行裁掉尾部;裁空则诚实降级为 NO_EVIDENCE;
|
||||
* 仍超限则抛异常(fail closed)。
|
||||
*/
|
||||
private MysqlToolResult fitBudget(MysqlToolResult result, int maxResultBytes) throws Exception {
|
||||
MysqlToolResult current = result;
|
||||
while (utf8Bytes(objectMapper.writeValueAsString(current)) > maxResultBytes
|
||||
@@ -100,11 +123,16 @@ public final class MysqlResultProjector {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单单元格投影:null 原样;敏感列 → [REDACTED](含脱敏标记);
|
||||
* 数字/布尔原样;字符串截断到 maxCellChars。
|
||||
*/
|
||||
private CellProjection projectCell(String column, JsonNode value, int maxCellChars) {
|
||||
if (value == null || value.isNull()) {
|
||||
return new CellProjection(null, false);
|
||||
}
|
||||
if (isSensitive(column)) {
|
||||
// 敏感列(password/token/secret 等):绝不把真实值给 Agent
|
||||
return new CellProjection("[REDACTED]", true);
|
||||
}
|
||||
if (value.isNumber()) {
|
||||
@@ -119,6 +147,7 @@ public final class MysqlResultProjector {
|
||||
: new CellProjection(text.substring(0, maxCellChars), true);
|
||||
}
|
||||
|
||||
/** 列名是否含敏感 token(password/passwd/token/secret/api_key/apikey/credential)。 */
|
||||
private static boolean isSensitive(String column) {
|
||||
String normalized = column == null ? "" : column.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_TOKENS.stream().anyMatch(normalized::contains);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
/**
|
||||
* MySQL 沙箱安全异常:触发只读红线/未授权访问/危险 SQL 时抛出,
|
||||
* 由 Adapter 映射为稳定的错误码(而非泄露内部细节)。
|
||||
*/
|
||||
public final class MysqlSecurityException extends RuntimeException {
|
||||
|
||||
public MysqlSecurityException(String message) {
|
||||
|
||||
@@ -41,7 +41,18 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Fail-closed SQL policy for the Agent-facing MySQL Tool. */
|
||||
/**
|
||||
* MySQL 工具的 fail-closed SQL 策略(沙箱的「语义层」防线):
|
||||
* 用 JSqlParser 解析 AST,逐一拒绝所有不安全形态。
|
||||
*
|
||||
* <p>禁止:非 SELECT / 多条语句 / WITH / 子查询 / 通配符投影(*)/
|
||||
* 窗口函数 / CASE / EXISTS / 分层查询 / 字面量(必须参数化)/ 未授权表/列 /
|
||||
* 锁读 / 复杂子句(OFFSET/FETCH/TOP 等)。
|
||||
*
|
||||
* <p>允许:白名单内表与列的 INNER/LEFT JOIN、聚合函数
|
||||
* (COUNT/SUM/AVG/MIN/MAX)、占位符参数——且占位符数量必须与 params 匹配。
|
||||
* 任何解析/校验异常统一转 MysqlSecurityException(fail closed,不泄露细节)。
|
||||
*/
|
||||
public final class MysqlSqlValidator {
|
||||
|
||||
private static final Set<String> ALLOWED_FUNCTIONS = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX");
|
||||
@@ -53,11 +64,15 @@ public final class MysqlSqlValidator {
|
||||
this.dataSources = Map.copyOf(dataSources);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并生成执行计划。全流程 fail-closed:任何一步不满足直接抛 MysqlSecurityException。
|
||||
*/
|
||||
public MysqlQueryPlan validate(MysqlToolRequest request) {
|
||||
if (request == null || request.dataSource() == null || request.dataSource().isBlank()
|
||||
|| request.sql() == null || request.sql().isBlank()) {
|
||||
throw new MysqlSecurityException("data_source and sql are required");
|
||||
}
|
||||
// 数据源必须存在于授权映射
|
||||
MysqlDataSourceDefinition dataSource = dataSources.get(request.dataSource());
|
||||
if (dataSource == null) {
|
||||
throw new MysqlSecurityException("unknown logical data source");
|
||||
@@ -66,6 +81,7 @@ public final class MysqlSqlValidator {
|
||||
throw new MysqlSecurityException("SQL exceeds policy length");
|
||||
}
|
||||
try {
|
||||
// 必须恰好一条语句,且是 SELECT
|
||||
Statements statements = CCJSqlParserUtil.parseStatements(request.sql());
|
||||
if (statements.getStatements() == null || statements.getStatements().size() != 1) {
|
||||
throw new MysqlSecurityException("exactly one SQL statement is required");
|
||||
@@ -78,6 +94,7 @@ public final class MysqlSqlValidator {
|
||||
throw new MysqlSecurityException("WITH is not allowed");
|
||||
}
|
||||
SelectBody body = select.getSelectBody();
|
||||
// 只允许普通 PlainSelect(无集合操作/值语句)
|
||||
if (!(body instanceof PlainSelect plainSelect)
|
||||
|| body instanceof SetOperationList
|
||||
|| body instanceof ValuesStatement) {
|
||||
@@ -97,10 +114,12 @@ public final class MysqlSqlValidator {
|
||||
throw new MysqlSecurityException("unsupported SELECT clause");
|
||||
}
|
||||
|
||||
// 表注册:FROM 必须是白名单内的实体表(禁子查询/非表来源),重复别名拒绝
|
||||
Map<String, TableRef> tables = new LinkedHashMap<>();
|
||||
registerTable(plainSelect.getFromItem(), dataSource, tables);
|
||||
List<Join> joins = plainSelect.getJoins() == null ? List.of() : plainSelect.getJoins();
|
||||
for (Join join : joins) {
|
||||
// 只允许 INNER/LEFT JOIN(禁 CROSS/RIGHT/FULL/OUTER)
|
||||
if (join.isCross() || join.isRight() || join.isFull() || join.isOuter()
|
||||
|| (!join.isInner() && !join.isLeft())) {
|
||||
throw new MysqlSecurityException("only INNER/LEFT JOIN is allowed");
|
||||
@@ -117,6 +136,7 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// 投影必须显式(禁 * / t.*),所有表达式逐节点校验
|
||||
if (plainSelect.getSelectItems() == null || plainSelect.getSelectItems().isEmpty()) {
|
||||
throw new MysqlSecurityException("projection must be explicit");
|
||||
}
|
||||
@@ -129,6 +149,7 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
validateExpression(expressionItem.getExpression(), tables, dataSource);
|
||||
}
|
||||
// WHERE/HAVING/GROUP BY/ORDER BY 全表达式校验
|
||||
validateExpression(plainSelect.getWhere(), tables, dataSource);
|
||||
validateExpression(plainSelect.getHaving(), tables, dataSource);
|
||||
if (plainSelect.getGroupBy() != null) {
|
||||
@@ -140,6 +161,7 @@ public final class MysqlSqlValidator {
|
||||
plainSelect.getOrderByElements().forEach(order ->
|
||||
validateExpression(order.getExpression(), tables, dataSource));
|
||||
}
|
||||
// 占位符数量必须与 params 匹配(防止参数错位/少传)
|
||||
int placeholders = countPlaceholders(plainSelect);
|
||||
int provided = request.params() == null ? 0 : request.params().size();
|
||||
if (placeholders != provided) {
|
||||
@@ -148,12 +170,15 @@ public final class MysqlSqlValidator {
|
||||
return new MysqlQueryPlan(request, dataSource, statement.toString(),
|
||||
request.params() == null ? List.of() : request.params());
|
||||
} catch (MysqlSecurityException e) {
|
||||
// 业务规则违规:原样穿出(已分类)
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
// 解析/其他异常:统一 fail closed(不泄露内部细节)
|
||||
throw new MysqlSecurityException("SQL cannot be safely validated", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册 FROM 表:必须是白名单内实体表,别名唯一。 */
|
||||
private void registerTable(FromItem item, MysqlDataSourceDefinition dataSource,
|
||||
Map<String, TableRef> tables) {
|
||||
if (!(item instanceof Table table)) {
|
||||
@@ -174,6 +199,7 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表达式逐节点校验:列白名单 / 函数白名单 / 禁字面量、子查询、通配符、窗口函数等。 */
|
||||
private void validateExpression(Expression expression, Map<String, TableRef> tables,
|
||||
MysqlDataSourceDefinition dataSource) {
|
||||
if (expression == null) {
|
||||
@@ -282,6 +308,10 @@ public final class MysqlSqlValidator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列校验:限定表时查该表列白名单;未限定时必须在已注册表中唯一匹配
|
||||
* (否则视为歧义或未授权)。
|
||||
*/
|
||||
private void validateColumn(Column column, Map<String, TableRef> tables,
|
||||
MysqlDataSourceDefinition dataSource) {
|
||||
String name = column.getColumnName();
|
||||
@@ -290,12 +320,14 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
Table table = column.getTable();
|
||||
if (table != null && table.getName() != null && !table.getName().isBlank()) {
|
||||
// 限定表:必须在白名单内
|
||||
TableRef ref = tables.get(table.getName().toLowerCase(Locale.ROOT));
|
||||
if (ref == null || !dataSource.allowsColumn(ref.schema(), ref.table(), name)) {
|
||||
throw new MysqlSecurityException("column is not allowlisted");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 未限定表:必须在已注册表中恰好一个白名单命中
|
||||
List<TableRef> matches = tables.values().stream()
|
||||
.filter(ref -> dataSource.allowsColumn(ref.schema(), ref.table(), name))
|
||||
.toList();
|
||||
@@ -304,6 +336,10 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 AST 中的占位符数(必须与 params 数量一致):
|
||||
* 只在 AST 接受后按节点计数,引号内的问号不会被误计。
|
||||
*/
|
||||
private static int countPlaceholders(PlainSelect plainSelect) {
|
||||
// Parser assigns JdbcParameter nodes; use the canonical SQL token count only after
|
||||
// the AST has been accepted, so quoted question marks are not counted.
|
||||
@@ -337,6 +373,7 @@ public final class MysqlSqlValidator {
|
||||
return counter.count;
|
||||
}
|
||||
|
||||
/** 占位符计数 visitor:统计 JdbcParameter;子查询继续拒绝。 */
|
||||
private static final class PlaceholderCounter extends ExpressionVisitorAdapter {
|
||||
private int count;
|
||||
|
||||
@@ -351,6 +388,7 @@ public final class MysqlSqlValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/** 已注册表引用(schema + 表名),用于列白名单校验。 */
|
||||
private record TableRef(String schema, String table) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package com.superbiz.agent.harness.tool.mysql;
|
||||
|
||||
/**
|
||||
* MySQL 工具限制:行数 / 单元格字符 / 结果字节 / 查询超时。
|
||||
* 防止超大结果进 Agent 上下文,防长时间占用连接。
|
||||
*/
|
||||
public record MysqlToolLimits(
|
||||
/** 最大返回行数。 */
|
||||
int maxRows,
|
||||
/** 单单元格最大字符数(超长截断)。 */
|
||||
int maxCellChars,
|
||||
/** 结果总字节上限。 */
|
||||
int maxResultBytes,
|
||||
/** 查询超时秒数。 */
|
||||
int queryTimeoutSeconds) {
|
||||
|
||||
public MysqlToolLimits {
|
||||
@@ -12,6 +20,7 @@ public record MysqlToolLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认:100 行 / 2000 字单元 / 64KB 总字节 / 5 秒超时。 */
|
||||
public static MysqlToolLimits defaults() {
|
||||
return new MysqlToolLimits(100, 2_000, 64 * 1024, 5);
|
||||
}
|
||||
|
||||
+38
-1
@@ -19,7 +19,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Projects legacy Mock log JSON into the frozen query_logs contract. */
|
||||
/**
|
||||
* 把 legacy Mock 日志 JSON 投影成冻结的 query_logs 契约。
|
||||
*
|
||||
* <p>关键职责:
|
||||
* <ul>
|
||||
* <li>脱敏:sanitize 抹掉消息里的密码/token/主机/Pod/IP/端口等敏感信息,不进入 Agent 上下文;</li>
|
||||
* <li>有界:条数截断(maxEvents)+ 均匀采样 + 总字节兜底(fitBudget);</li>
|
||||
* <li>聚合:按「级别+服务+规范化消息」压缩成 PatternAccumulator(模型看全貌不读每条);</li>
|
||||
* <li>客观证据语义:allEvents 空不空 → NO_EVIDENCE / EVIDENCE_FOUND。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class QueryLogsResultProjector {
|
||||
|
||||
private static final Pattern SECRET = Pattern.compile(
|
||||
@@ -41,6 +51,12 @@ public final class QueryLogsResultProjector {
|
||||
this.limits = limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:legacy 日志 raw JSON → 冻结的 query_logs 契约。
|
||||
*
|
||||
* <p>流程:校验 raw(success 标记)→ 逐条脱敏+聚合 → 均匀采样截断 →
|
||||
* 生成 patterns 聚合 → 判定 evidence status → fitBudget 总字节兜底。
|
||||
*/
|
||||
public ProjectedToolResult project(QueryLogsRequest request, String toolCallId,
|
||||
LogQueryScope scope, String rawResponse) throws Exception {
|
||||
if (request == null || toolCallId == null || toolCallId.isBlank() || scope == null) {
|
||||
@@ -60,6 +76,7 @@ public final class QueryLogsResultProjector {
|
||||
List<LogEvent> allEvents = new ArrayList<>();
|
||||
Map<String, PatternAccumulator> aggregates = new LinkedHashMap<>();
|
||||
for (JsonNode log : logs) {
|
||||
// 逐条:字段截断 + 消息脱敏(敏感信息不进 Agent 上下文)
|
||||
String timestamp = bounded(text(log, "timestamp"), limits.maxMessageChars());
|
||||
String level = bounded(text(log, "level"), 32);
|
||||
String service = bounded(text(log, "service"), 128);
|
||||
@@ -70,13 +87,16 @@ public final class QueryLogsResultProjector {
|
||||
}
|
||||
String example = message;
|
||||
allEvents.add(new LogEvent(nullable(timestamp), nullable(level), nullable(service), message));
|
||||
// 聚合键:级别+服务+规范化消息(数字→<n>)
|
||||
String patternKey = level + "\u0000" + service + "\u0000" + normalizePattern(message);
|
||||
aggregates.computeIfAbsent(patternKey, ignored -> new PatternAccumulator(level, service, example))
|
||||
.add(timestamp);
|
||||
}
|
||||
|
||||
// 均匀采样截断(避免只保留头部事件,牺牲时间分布)
|
||||
List<LogEvent> events = sample(allEvents, limits.maxEvents());
|
||||
truncated |= events.size() < allEvents.size();
|
||||
// 模式聚合按次数降序 + 示例升序,限制条数
|
||||
List<LogPattern> patterns = aggregates.values().stream()
|
||||
.sorted(Comparator.comparingLong(PatternAccumulator::count).reversed()
|
||||
.thenComparing(PatternAccumulator::example))
|
||||
@@ -86,6 +106,7 @@ public final class QueryLogsResultProjector {
|
||||
truncated |= aggregates.size() > patterns.size();
|
||||
|
||||
long matchCount = root.path("total").canConvertToLong() ? root.path("total").asLong() : allEvents.size();
|
||||
// 客观证据语义:事件空不空
|
||||
QueryLogsToolResult result = new QueryLogsToolResult(
|
||||
allEvents.isEmpty() ? EvidenceStatus.NO_EVIDENCE : EvidenceStatus.EVIDENCE_FOUND,
|
||||
toolCallId,
|
||||
@@ -100,6 +121,10 @@ public final class QueryLogsResultProjector {
|
||||
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 总字节兜底:超过 maxAgentUtf8Bytes 时先裁事件尾部、再裁模式尾部;
|
||||
* 仍超限则抛异常(fail closed)。
|
||||
*/
|
||||
private QueryLogsToolResult fitBudget(QueryLogsToolResult result) throws Exception {
|
||||
QueryLogsToolResult current = result;
|
||||
while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()
|
||||
@@ -121,6 +146,9 @@ public final class QueryLogsResultProjector {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 均匀采样:超过 max 时按时间等距取 max 条(保留时间分布,而非只留头部)。
|
||||
*/
|
||||
private static List<LogEvent> sample(List<LogEvent> events, int max) {
|
||||
if (events.size() <= max) {
|
||||
return List.copyOf(events);
|
||||
@@ -133,6 +161,11 @@ public final class QueryLogsResultProjector {
|
||||
return sampled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息脱敏(敏感信息不进 Agent 上下文/审计):
|
||||
* 密码/token/secret/api-key → REDACTED;Pod/主机/端口/PID/IP → REDACTED_*;
|
||||
* SQL 字符串字面量 → '[REDACTED_LITERAL]';去掉 Java 堆栈尾部。
|
||||
*/
|
||||
private static String sanitize(String message) {
|
||||
String value = SECRET.matcher(message).replaceAll("$1=[REDACTED]");
|
||||
value = POD.matcher(value).replaceAll("[REDACTED_POD]");
|
||||
@@ -143,6 +176,7 @@ public final class QueryLogsResultProjector {
|
||||
return STACK_SUFFIX.matcher(value).replaceAll("").trim();
|
||||
}
|
||||
|
||||
/** 模式归一化:数字(含小数)→ <n>,压缩空白——让相似消息聚合到同一模式。 */
|
||||
private static String normalizePattern(String message) {
|
||||
return message.replaceAll("\\b\\d+(?:\\.\\d+)?\\b", "<n>")
|
||||
.replaceAll("\\s+", " ").trim();
|
||||
@@ -168,6 +202,7 @@ public final class QueryLogsResultProjector {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
/** 单个模式聚合器:同键(级别+服务+归一化消息)事件累加 count,记录首末时间。 */
|
||||
private static final class PatternAccumulator {
|
||||
private final String level;
|
||||
private final String service;
|
||||
@@ -182,6 +217,7 @@ public final class QueryLogsResultProjector {
|
||||
this.example = example;
|
||||
}
|
||||
|
||||
/** 累加一条事件:count++ 并更新首末时间窗。 */
|
||||
private PatternAccumulator add(String timestamp) {
|
||||
count++;
|
||||
if (firstSeen == null || timestamp.compareTo(firstSeen) < 0) {
|
||||
@@ -201,6 +237,7 @@ public final class QueryLogsResultProjector {
|
||||
return example;
|
||||
}
|
||||
|
||||
/** 转成冻结契约 LogPattern。 */
|
||||
private LogPattern toPattern() {
|
||||
return new LogPattern(count, firstSeen, lastSeen, nullable(level), nullable(service), example);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ public final class RagResultProjector {
|
||||
this.limits = limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:legacy raw JSON → 冻结的 Agent 可见 RAG 契约。
|
||||
*
|
||||
* <p>流程:解析 evidenceBlocks → 按 chunk 级身份去重 → 截断摘录/条数 →
|
||||
* 判定 evidence status(空不空)→ 计算相关度 → fitBudget 总字节兜底。
|
||||
*/
|
||||
public ProjectedToolResult project(RagToolRequest request, String toolCallId,
|
||||
String rawResponse) throws Exception {
|
||||
if (request == null || toolCallId == null || toolCallId.isBlank()) {
|
||||
@@ -52,10 +58,12 @@ public final class RagResultProjector {
|
||||
int ordinal = 0;
|
||||
for (JsonNode block : blocks) {
|
||||
ordinal++;
|
||||
// 条数上限:超出置 truncated 并停止
|
||||
if (evidence.size() >= limits.maxEvidence()) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
// 摘录取 content(回退 excerpt),空则跳过该块
|
||||
String excerpt = text(block, "content");
|
||||
if (excerpt.isBlank()) {
|
||||
excerpt = text(block, "excerpt");
|
||||
@@ -66,6 +74,7 @@ public final class RagResultProjector {
|
||||
String source = text(block, "source");
|
||||
String title = text(block, "title");
|
||||
// Chunk-scoped identity first; do not collapse on source alone.
|
||||
// 证据身份优先级:evidenceKey → document_id → docId#chunk-idx → legacy 序号
|
||||
String documentId = firstPresent(
|
||||
text(block, "evidenceKey"),
|
||||
text(block, "evidence_key"),
|
||||
@@ -75,6 +84,7 @@ public final class RagResultProjector {
|
||||
text(block, "chunkIndex"), text(block, "chunk_index")),
|
||||
"legacy-evidence-" + ordinal);
|
||||
if (!evidenceIds.add(documentId)) {
|
||||
// 重复 chunk:跳过但标记 truncated(被去重)
|
||||
truncated = true;
|
||||
continue;
|
||||
}
|
||||
@@ -92,6 +102,7 @@ public final class RagResultProjector {
|
||||
}
|
||||
}
|
||||
|
||||
// 客观证据语义:证据数组空不空(不需要模型判断)
|
||||
EvidenceStatus status = evidence.isEmpty()
|
||||
? EvidenceStatus.NO_EVIDENCE
|
||||
: EvidenceStatus.EVIDENCE_FOUND;
|
||||
@@ -103,6 +114,7 @@ public final class RagResultProjector {
|
||||
return new ProjectedToolResult(objectMapper.writeValueAsString(result), result.evidenceStatus());
|
||||
}
|
||||
|
||||
/** 由 docId + chunkIndex 组合 chunk 级证据身份(两者都缺则返回 null)。 */
|
||||
private static String composeChunkId(String docId, String docIdAlt, String chunkIndex, String chunkIndexAlt) {
|
||||
String id = firstPresentOrNull(docId, docIdAlt);
|
||||
String idx = firstPresentOrNull(chunkIndex, chunkIndexAlt);
|
||||
@@ -112,11 +124,13 @@ public final class RagResultProjector {
|
||||
return id + "#chunk-" + idx;
|
||||
}
|
||||
|
||||
/** 取第一个非空值,全空回退 "unknown-document"。 */
|
||||
private static String firstPresent(String... values) {
|
||||
String found = firstPresentOrNull(values);
|
||||
return found == null ? "unknown-document" : found;
|
||||
}
|
||||
|
||||
/** 取第一个非空值,全空返回 null。 */
|
||||
private static String firstPresentOrNull(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
@@ -129,6 +143,10 @@ public final class RagResultProjector {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 总字节兜底:投影结果超过 maxAgentUtf8Bytes 时逐条裁掉尾部证据
|
||||
* (裁空则诚实降级为 NO_EVIDENCE);仍超限则抛异常(fail closed)。
|
||||
*/
|
||||
private RagToolResult fitBudget(RagToolResult result, boolean truncated) throws Exception {
|
||||
RagToolResult current = result;
|
||||
while (bytes(objectMapper.writeValueAsString(current)) > limits.maxAgentUtf8Bytes()
|
||||
@@ -155,6 +173,7 @@ public final class RagResultProjector {
|
||||
return value == null || value.isBlank() ? null : value;
|
||||
}
|
||||
|
||||
/** 从 raw 读取相关度(relevance_level / relevanceLevel),无法解析返回 null。 */
|
||||
private static RagRelevanceLevel relevanceLevel(JsonNode root) {
|
||||
String value = text(root, "relevance_level");
|
||||
if (value.isBlank()) {
|
||||
@@ -170,6 +189,7 @@ public final class RagResultProjector {
|
||||
}
|
||||
}
|
||||
|
||||
/** 截断到 max 字符(null 视为空串)。 */
|
||||
private static String bounded(String value, int max) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
@@ -177,6 +197,7 @@ public final class RagResultProjector {
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
|
||||
/** UTF-8 字节数(投影总预算用)。 */
|
||||
private static int bytes(String value) {
|
||||
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
package com.superbiz.agent.harness.tool.projection;
|
||||
|
||||
/** Bounds applied to Agent-facing projections. */
|
||||
/**
|
||||
* 投影层的有界限制:raw → agent 契约投影时的全部硬上限。
|
||||
* 保证模型看到的任何结果都有界(数量/长度/字节),防止超大响应进入上下文。
|
||||
*/
|
||||
public record ToolProjectionLimits(
|
||||
/** RAG 最大证据条数。 */
|
||||
int maxEvidence,
|
||||
/** RAG 单条摘录最大字符数。 */
|
||||
int maxExcerptChars,
|
||||
/** 日志最大模式聚合条数。 */
|
||||
int maxPatterns,
|
||||
/** 日志最大事件条数。 */
|
||||
int maxEvents,
|
||||
/** 日志单条消息最大字符数。 */
|
||||
int maxMessageChars,
|
||||
/** 查询关键词最大字符数。 */
|
||||
int maxQueryChars,
|
||||
/** 投影结果最大 UTF-8 字节数(总预算)。 */
|
||||
int maxAgentUtf8Bytes) {
|
||||
|
||||
public ToolProjectionLimits {
|
||||
@@ -18,6 +28,7 @@ public record ToolProjectionLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认限制:8 条证据 / 1200 字摘录 / 12 模式 / 30 事件 / 1000 字消息 / 500 字查询 / 16KB 总字节。 */
|
||||
public static ToolProjectionLimits defaults() {
|
||||
return new ToolProjectionLimits(8, 1200, 12, 30, 1000, 500, 16_384);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* canonical 记录的大小与 TTL 限制:执行门禁(ToolBoundary)和 Redis 实现共用。
|
||||
* 三条校验分别对应执行链的 request+raw、agent_result、序列化后的整条记录。
|
||||
*/
|
||||
public record CanonicalInvocationLimits(
|
||||
/** 记录 TTL(过期后不可引用,ProgressProjector/EvidenceGuard 会排除)。 */
|
||||
Duration ttl,
|
||||
/** request + raw_response 合计字节上限。 */
|
||||
long maxRecordBytes,
|
||||
/** agent_result 字节上限(须 ≤ maxRecordBytes)。 */
|
||||
long maxAgentResultBytes) {
|
||||
|
||||
public CanonicalInvocationLimits {
|
||||
@@ -23,6 +30,7 @@ public record CanonicalInvocationLimits(
|
||||
ttl.toMillis();
|
||||
}
|
||||
|
||||
/** 校验 request + raw_response 未超上限(阶段三)。 */
|
||||
public void validateRawCandidate(String request, String rawResponse) {
|
||||
long actual = utf8Bytes(request) + utf8Bytes(rawResponse);
|
||||
if (actual > maxRecordBytes) {
|
||||
@@ -30,6 +38,7 @@ public record CanonicalInvocationLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验 agent_result 未超上限(阶段五)。 */
|
||||
public void validateAgentResult(String agentResult) {
|
||||
long actual = utf8Bytes(agentResult);
|
||||
if (actual > maxAgentResultBytes) {
|
||||
@@ -37,6 +46,7 @@ public record CanonicalInvocationLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验序列化后的整条记录未超上限(Redis 写入前)。 */
|
||||
public void validateSerializedRecord(String json) {
|
||||
long actual = utf8Bytes(json);
|
||||
if (actual > maxRecordBytes) {
|
||||
@@ -44,6 +54,7 @@ public record CanonicalInvocationLimits(
|
||||
}
|
||||
}
|
||||
|
||||
/** UTF-8 字节数(null 视为 0)。 */
|
||||
public static long utf8Bytes(String value) {
|
||||
return value == null ? 0 : value.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,29 @@ import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* canonical 真相存储端口:一次 Tool 调用的完整生命周期(begin → markReady/markError → find)。
|
||||
* RedisCanonicalInvocationStore 是唯一实现;ToolBoundary 通过它落库,ProgressProjector/EvidenceGuard 通过它回读验真。
|
||||
*/
|
||||
public interface CanonicalInvocationStore {
|
||||
|
||||
/** 记录大小/TTL 限制(执行门禁与写入前校验共用)。 */
|
||||
CanonicalInvocationLimits limits();
|
||||
|
||||
/** 写入 PROJECTING 记录(同 key 重复 begin 抛 DuplicateInvocationException)。 */
|
||||
void begin(String key, CanonicalToolInvocation invocation);
|
||||
|
||||
/** 按 key 查询记录(不存在或已过期返回 empty)。 */
|
||||
Optional<CanonicalToolInvocation> find(String key);
|
||||
|
||||
/** PROJECTING → READY 迁移:携带 raw + agent_result + 客观 evidence status + 完成时间。 */
|
||||
CanonicalToolInvocation markReady(String key,
|
||||
String rawResponse,
|
||||
String agentResult,
|
||||
EvidenceStatus evidenceStatus,
|
||||
Instant completedAt);
|
||||
|
||||
/** PROJECTING → ERROR 迁移:携带 raw(尽力而为)+ 稳定错误码 + 完成时间。 */
|
||||
CanonicalToolInvocation markError(String key,
|
||||
String rawResponse,
|
||||
String errorCode,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.superbiz.agent.harness.tool.store;
|
||||
|
||||
/**
|
||||
* canonical 存储基础设施异常(非业务规则):序列化/反序列化/值类型错误等。
|
||||
* 由 ToolBoundary 映射为 STORE_ERROR 错误码。
|
||||
*/
|
||||
public class CanonicalStoreException extends RuntimeException {
|
||||
|
||||
public CanonicalStoreException(String message) {
|
||||
|
||||
@@ -7,6 +7,28 @@ import com.superbiz.agent.harness.contract.InvocationStatus;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Canonical Store 中的「唯一真相」Tool 调用记录(不可变 record)。
|
||||
*
|
||||
* <p>状态机(每次迁移返回新实例,原实例不变):
|
||||
* <pre>
|
||||
* PROJECTING ──markReady──▶ READY (成功:raw + agent_result + evidence FOUND/NO_EVIDENCE)
|
||||
* PROJECTING ──markError──▶ ERROR (失败:error_code,无 agent_result)
|
||||
* </pre>
|
||||
*
|
||||
* <p>字段分工:
|
||||
* <ul>
|
||||
* <li>tool_call_id / run_id / tool_name:调用身份(key = runId + toolCallId);</li>
|
||||
* <li>request / raw_response:请求与原始返回(完整真相,供审计/验真,不外发);</li>
|
||||
* <li>agent_result:Projector 投影后的有界结果(对外可见的唯一形态);</li>
|
||||
* <li>evidence_status:证据语义(FOUND/NO_EVIDENCE/ERROR);</li>
|
||||
* <li>error_code:仅 ERROR 状态携带;</li>
|
||||
* <li>started_at / completed_at:生命周期时间戳。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>构造时 {@link #validateState} 强制状态-字段一致性:每个状态只允许携带
|
||||
* 该状态合法的字段组合(如 PROJECTING 不得有终态字段、ERROR 不得有 agent_result)。
|
||||
*/
|
||||
public record CanonicalToolInvocation(
|
||||
@JsonProperty("tool_call_id") String toolCallId,
|
||||
@JsonProperty("run_id") String runId,
|
||||
@@ -21,15 +43,21 @@ public record CanonicalToolInvocation(
|
||||
@JsonProperty("completed_at") Instant completedAt) {
|
||||
|
||||
public CanonicalToolInvocation {
|
||||
// 身份/请求字段必填;状态与开始时间必填
|
||||
requireText(toolCallId, "toolCallId");
|
||||
requireText(runId, "runId");
|
||||
requireText(toolName, "toolName");
|
||||
requireText(request, "request");
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
Objects.requireNonNull(startedAt, "startedAt must not be null");
|
||||
// 状态-字段一致性:非法组合直接拒绝入库
|
||||
validateState(status, evidenceStatus, rawResponse, agentResult, errorCode, completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 PROJECTING 记录:执行开始时调用,仅携带身份 + request + 开始时间
|
||||
* (终态字段全部为 null)。
|
||||
*/
|
||||
public static CanonicalToolInvocation projecting(String toolCallId,
|
||||
String runId,
|
||||
String toolName,
|
||||
@@ -40,6 +68,10 @@ public record CanonicalToolInvocation(
|
||||
InvocationStatus.PROJECTING, null, null, startedAt, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* PROJECTING → READY 迁移:成功路径(Projector 投影完成后调用)。
|
||||
* 必须携带 raw_response + agent_result + evidence(FOUND/NO_EVIDENCE)+ 完成时间。
|
||||
*/
|
||||
public CanonicalToolInvocation markReady(String rawResponse,
|
||||
String agentResult,
|
||||
EvidenceStatus evidenceStatus,
|
||||
@@ -50,6 +82,10 @@ public record CanonicalToolInvocation(
|
||||
InvocationStatus.READY, evidenceStatus, null, startedAt, completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* PROJECTING → ERROR 迁移:失败路径(backend 执行/投影/预算等异常时调用)。
|
||||
* 携带 raw_response(尽力而为)+ 稳定 error_code + 完成时间;agent_result 禁止出现。
|
||||
*/
|
||||
public CanonicalToolInvocation markError(String rawResponse,
|
||||
String errorCode,
|
||||
Instant completedAt) {
|
||||
@@ -59,6 +95,11 @@ public record CanonicalToolInvocation(
|
||||
InvocationStatus.ERROR, EvidenceStatus.ERROR, errorCode, startedAt, completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验真条件(EvidenceGuard / ProgressProjector 都依赖它):
|
||||
* READY + 属于同一 Run + agent_result 非空 + evidence 是合法证据语义
|
||||
* (FOUND / NO_EVIDENCE)——即该记录可被引用为真实证据。
|
||||
*/
|
||||
public boolean isReferencableBy(String expectedRunId) {
|
||||
return status == InvocationStatus.READY
|
||||
&& Objects.equals(runId, expectedRunId)
|
||||
@@ -67,12 +108,17 @@ public record CanonicalToolInvocation(
|
||||
|| evidenceStatus == EvidenceStatus.NO_EVIDENCE);
|
||||
}
|
||||
|
||||
/** 状态机防御:只有 PROJECTING 允许迁移(READY/ERROR 是终态,不可再变)。 */
|
||||
private void requireProjecting() {
|
||||
if (status != InvocationStatus.PROJECTING) {
|
||||
throw new InvocationStateException("Only PROJECTING invocation can transition");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态-字段一致性校验:每种状态只允许合法的字段组合,
|
||||
* 非法组合(如 PROJECTING 带终态字段、ERROR 带 agent_result)直接拒绝构造。
|
||||
*/
|
||||
private static void validateState(InvocationStatus status,
|
||||
EvidenceStatus evidenceStatus,
|
||||
String rawResponse,
|
||||
@@ -81,14 +127,17 @@ public record CanonicalToolInvocation(
|
||||
Instant completedAt) {
|
||||
switch (status) {
|
||||
case PROJECTING -> {
|
||||
// 投影中:不得携带任何终态字段(evidence/agent_result/error/完成时间)
|
||||
if (evidenceStatus != null || agentResult != null || errorCode != null || completedAt != null) {
|
||||
throw new InvocationStateException("PROJECTING invocation contains terminal fields");
|
||||
}
|
||||
}
|
||||
case READY -> {
|
||||
// 就绪:必须三者齐全(raw + agent_result + 完成时间)
|
||||
if (rawResponse == null || agentResult == null || completedAt == null) {
|
||||
throw new InvocationStateException("READY invocation requires raw, agent result and completion");
|
||||
}
|
||||
// 就绪:evidence 只能是合法证据语义,且不得带错误码
|
||||
if (evidenceStatus != EvidenceStatus.EVIDENCE_FOUND
|
||||
&& evidenceStatus != EvidenceStatus.NO_EVIDENCE) {
|
||||
throw new InvocationStateException("READY invocation has invalid evidence status");
|
||||
@@ -98,6 +147,7 @@ public record CanonicalToolInvocation(
|
||||
}
|
||||
}
|
||||
case ERROR -> {
|
||||
// 错误:evidence 必须 ERROR + 完成时间 + 错误码必填,agent_result 禁止
|
||||
if (evidenceStatus != EvidenceStatus.ERROR || completedAt == null) {
|
||||
throw new InvocationStateException("ERROR invocation requires ERROR evidence status and completion");
|
||||
}
|
||||
@@ -109,6 +159,7 @@ public record CanonicalToolInvocation(
|
||||
}
|
||||
}
|
||||
|
||||
/** 非空校验(用于身份、请求、错误码等必填字段)。 */
|
||||
private static void requireText(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.superbiz.agent.harness.tool.store;
|
||||
|
||||
/**
|
||||
* 同 key 重复 begin(幂等拦截):同一 runId+toolCallId 只允许 begin 一次。
|
||||
* ToolBoundary 映射为 DUPLICATE_TOOL_CALL 错误码。
|
||||
*/
|
||||
public final class DuplicateInvocationException extends CanonicalStoreException {
|
||||
|
||||
public DuplicateInvocationException() {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.superbiz.agent.harness.tool.store;
|
||||
|
||||
/**
|
||||
* canonical 状态机违规:非法迁移(非 PROJECTING 时迁移)、记录缺失/已过期等。
|
||||
* 属于防御性异常(正常流程不应触发)。
|
||||
*/
|
||||
public final class InvocationStateException extends CanonicalStoreException {
|
||||
|
||||
public InvocationStateException(String message) {
|
||||
|
||||
+16
@@ -13,6 +13,16 @@ import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Redis 版 CanonicalInvocationStore:唯一真相源的持久化实现。
|
||||
*
|
||||
* <p>关键点:
|
||||
* <ul>
|
||||
* <li>begin 用 setIfAbsent(原子)实现「同 key 只 begin 一次」→ 幂等拦截;</li>
|
||||
* <li>迁移(markReady/markError)用 update 读-改-写,保留剩余 TTL 续期;</li>
|
||||
* <li>记录过期(TTL)后 find 返回 empty——ProgressProjector/EvidenceGuard 据此排除。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class RedisCanonicalInvocationStore implements CanonicalInvocationStore {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
@@ -43,6 +53,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
}
|
||||
String json = serialize(invocation);
|
||||
limits.validateSerializedRecord(json);
|
||||
// 原子 SETNX:同 key 已存在 → 幂等拒绝(DUPLICATE_TOOL_CALL)
|
||||
Boolean created = values.setIfAbsent(
|
||||
key, json, limits.ttl().toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!Boolean.TRUE.equals(created)) {
|
||||
@@ -55,6 +66,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
requireKey(key);
|
||||
Object stored = values.get(key);
|
||||
if (stored == null) {
|
||||
// 不存在或已过期(TTL 清除)
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!(stored instanceof String json)) {
|
||||
@@ -71,6 +83,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
Instant completedAt) {
|
||||
CanonicalToolInvocation existing = find(key)
|
||||
.orElseThrow(() -> new InvocationStateException("Canonical invocation is missing or expired"));
|
||||
// 迁移前校验尺寸(raw + agent_result),不合法不落库
|
||||
limits.validateRawCandidate(existing.request(), requireValue(rawResponse, "rawResponse"));
|
||||
limits.validateAgentResult(requireValue(agentResult, "agentResult"));
|
||||
return update(key, current -> current.markReady(
|
||||
@@ -85,6 +98,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
try {
|
||||
return update(key, current -> current.markError(rawResponse, errorCode, completedAt));
|
||||
} catch (ResultTooLargeException e) {
|
||||
// raw 过大:降级为不保存 raw 的 ERROR(仍保留错误事实)
|
||||
if (rawResponse == null) {
|
||||
throw e;
|
||||
}
|
||||
@@ -93,6 +107,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
}
|
||||
}
|
||||
|
||||
/** 读-迁移-写:保留剩余 TTL(从 begin 起的生命周期,不被迁移重置)。 */
|
||||
private CanonicalToolInvocation update(String key,
|
||||
UnaryOperator<CanonicalToolInvocation> transition) {
|
||||
CanonicalToolInvocation current = find(key)
|
||||
@@ -105,6 +120,7 @@ public final class RedisCanonicalInvocationStore implements CanonicalInvocationS
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** 剩余 TTL:不存在/已过期则抛错(防止迁移写入已失效记录)。 */
|
||||
private long remainingTtlMillis(String key) {
|
||||
Long remaining = redisTemplate.getExpire(key, TimeUnit.MILLISECONDS);
|
||||
if (remaining == null || remaining <= 0) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.superbiz.agent.harness.tool.store;
|
||||
|
||||
/**
|
||||
* 结果超过字节上限:raw_response / agent_result / 序列化后的整条记录。
|
||||
* ToolBoundary 映射为 RESULT_TOO_LARGE 错误码(或降级为不保存 raw 的 ERROR)。
|
||||
*/
|
||||
public final class ResultTooLargeException extends CanonicalStoreException {
|
||||
|
||||
/** 降级 ERROR 时使用的稳定错误码(raw 过大时替换为它)。 */
|
||||
public static final String ERROR_CODE = "RESULT_TOO_LARGE";
|
||||
|
||||
public ResultTooLargeException(String field, long limit, long actual) {
|
||||
|
||||
@@ -2,6 +2,10 @@ package com.superbiz.agent.harness.tool.store;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* canonical key 工厂:key = keyPrefix + ":" + runId + ":" + toolCallId。
|
||||
* runId/toolCallId 必须匹配安全字符集(防 key 注入/分隔符混淆/超长 key)。
|
||||
*/
|
||||
public final class ToolCallKeyFactory {
|
||||
|
||||
private static final int MAX_SEGMENT_LENGTH = 128;
|
||||
@@ -13,12 +17,14 @@ public final class ToolCallKeyFactory {
|
||||
if (keyPrefix == null || keyPrefix.isBlank()) {
|
||||
throw new IllegalArgumentException("keyPrefix must not be blank");
|
||||
}
|
||||
// 前缀不允许空段(防 "a::b" 式歧义)
|
||||
if (keyPrefix.startsWith(":") || keyPrefix.endsWith(":") || keyPrefix.contains("::")) {
|
||||
throw new IllegalArgumentException("keyPrefix contains an empty segment");
|
||||
}
|
||||
this.keyPrefix = keyPrefix;
|
||||
}
|
||||
|
||||
/** 生成 canonical key(runId + toolCallId 双段定位一次调用)。 */
|
||||
public String create(String runId, String toolCallId) {
|
||||
requireSafeSegment(runId, "runId");
|
||||
requireSafeSegment(toolCallId, "toolCallId");
|
||||
@@ -29,6 +35,7 @@ public final class ToolCallKeyFactory {
|
||||
return keyPrefix;
|
||||
}
|
||||
|
||||
/** 段安全校验:非空、长度 ≤128、字符集 [A-Za-z0-9._-]。 */
|
||||
private static void requireSafeSegment(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
|
||||
@@ -65,6 +65,7 @@ public class KnowledgeDocumentRetriever {
|
||||
}
|
||||
}
|
||||
|
||||
/** 检索命中 → 统一的候选模型:映射字段 + 补 hitReasons(semantic_rank + attempt)。 */
|
||||
private List<RetrievedEvidenceCandidate> toCandidates(String attemptName, List<KnowledgeSearchHit> hits) {
|
||||
if (hits == null || hits.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -94,6 +95,7 @@ public class KnowledgeDocumentRetriever {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** 组装单次 attempt 的 trace 元信息(候选数/耗时/顶分/错误/usable)。 */
|
||||
private RetrievalTrace.Attempt attempt(String name,
|
||||
String query,
|
||||
String categoryFilter,
|
||||
@@ -113,6 +115,7 @@ public class KnowledgeDocumentRetriever {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 取首条命中分数(用作该 attempt 的顶分)。 */
|
||||
private Double topScore(List<KnowledgeSearchHit> hits) {
|
||||
if (hits == null || hits.isEmpty() || hits.get(0).score() == null) {
|
||||
return null;
|
||||
@@ -120,6 +123,7 @@ public class KnowledgeDocumentRetriever {
|
||||
return hits.get(0).score();
|
||||
}
|
||||
|
||||
/** 小写去空格(searchMode 配置比对用)。 */
|
||||
private static String trim(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
@@ -57,10 +57,17 @@ public class KnowledgeEvidencePostProcessor {
|
||||
@Value("${rag.return-n:5}")
|
||||
private int returnN = 5;
|
||||
|
||||
/**
|
||||
* 检索后处理主入口:打分 → 排序 → 去重/截断 → 判级 → 装配。
|
||||
*
|
||||
* <p>关键决策:qualityScore 不参与排序(排序按检索权威序 originalRank),
|
||||
* 只用于判级(relevance)和闸门(isLowQuality)——防关键词 boost 操纵。
|
||||
*/
|
||||
public EvidencePostprocessResult process(KnowledgeQuery query, List<RetrievedEvidenceCandidate> candidates) {
|
||||
List<RetrievedEvidenceCandidate> safeCandidates = candidates == null ? List.of() : candidates;
|
||||
int batchSize = safeCandidates.size();
|
||||
|
||||
// ① 打分 + 排序:qualityScore 归一化,但排序仍按 originalRank(检索权威序)
|
||||
List<ScoredCandidate> ranked = safeCandidates.stream()
|
||||
.map(candidate -> score(query, candidate, batchSize))
|
||||
.sorted(Comparator
|
||||
@@ -68,6 +75,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.thenComparing(s -> resolveEvidenceKey(s.candidate()), Comparator.nullsLast(String::compareTo)))
|
||||
.toList();
|
||||
|
||||
// ② 去重/截断:evidenceKey 去重 + 每文档 chunk 上限 + returnN
|
||||
Map<String, EvidenceBlock> deduped = new LinkedHashMap<>();
|
||||
List<RerankTrace.Item> traceItems = new ArrayList<>();
|
||||
Map<String, Integer> chunksPerDocument = new HashMap<>();
|
||||
@@ -85,10 +93,12 @@ public class KnowledgeEvidencePostProcessor {
|
||||
String docBucket = resolveDocBucket(candidate, evidenceKey);
|
||||
|
||||
if (deduped.containsKey(evidenceKey)) {
|
||||
// 同 evidenceKey:合并 hitReasons/补 breadcrumb,不新增
|
||||
mergeEvidence(deduped.get(evidenceKey), toBlock(candidate, evidenceKey, scored));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 每文档 chunk 上限:超出则跳过该候选
|
||||
int used = chunksPerDocument.getOrDefault(docBucket, 0);
|
||||
if (used >= effectiveMaxChunks) {
|
||||
continue;
|
||||
@@ -121,6 +131,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 质量闸门:无可用证据、或顶分低于参考阈值 → 低质量(触发 LookupKnowledgeTool 降级重试)。
|
||||
*/
|
||||
public boolean isLowQuality(EvidencePostprocessResult result) {
|
||||
if (result == null || !result.hasUsableEvidence()) {
|
||||
return true;
|
||||
@@ -140,6 +153,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return referenceThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个候选装配成 EvidenceBlock:content 截断 800 字 + 合并 hitReasons。
|
||||
*/
|
||||
private EvidenceBlock toBlock(RetrievedEvidenceCandidate candidate,
|
||||
String evidenceKey,
|
||||
ScoredCandidate scored) {
|
||||
@@ -157,6 +173,10 @@ public class KnowledgeEvidencePostProcessor {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据身份:优先检索给的 evidenceKey;否则组合 docId + chunkIndex + id + rank
|
||||
* (chunk 级去重身份,同文档多 chunk 可并存)。
|
||||
*/
|
||||
private String resolveEvidenceKey(RetrievedEvidenceCandidate candidate) {
|
||||
if (candidate.getEvidenceKey() != null && !candidate.getEvidenceKey().isBlank()) {
|
||||
return candidate.getEvidenceKey();
|
||||
@@ -168,6 +188,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
candidate.getOriginalRank());
|
||||
}
|
||||
|
||||
/** 文档分桶键:有 docId 用 docId,否则退回 evidenceKey(用于每文档 chunk 上限)。 */
|
||||
private String resolveDocBucket(RetrievedEvidenceCandidate candidate, String evidenceKey) {
|
||||
String docId = EvidenceIdentity.trimToNull(candidate.getDocId());
|
||||
if (docId != null) {
|
||||
@@ -176,6 +197,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return evidenceKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打分:qualityScore 归一化(按 scoreLabel 分支);L0 重叠只写解释,不改分数。
|
||||
*/
|
||||
private ScoredCandidate score(KnowledgeQuery query, RetrievedEvidenceCandidate candidate, int batchSize) {
|
||||
double quality = RetrievalScoreNormalizer.toQualityScore(
|
||||
candidate.getScoreLabel(),
|
||||
@@ -185,7 +209,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
maxL2Distance,
|
||||
candidate.getDenseDistance());
|
||||
List<String> explain = new ArrayList<>();
|
||||
// L0 重叠仅解释,不改变 quality / 排序
|
||||
// L0 重叠仅解释,不改变 quality / 排序(防关键词碰瓷)
|
||||
if (matchesAny(candidate, query.getDomainHints())) {
|
||||
explain.add("l0_domain_overlap");
|
||||
}
|
||||
@@ -198,6 +222,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return new ScoredCandidate(candidate, quality, explain);
|
||||
}
|
||||
|
||||
/** 候选字段与 L0 提示是否重叠(仅解释用)。 */
|
||||
private boolean matchesAny(RetrievedEvidenceCandidate candidate, List<String> hints) {
|
||||
if (hints == null || hints.isEmpty()) {
|
||||
return false;
|
||||
@@ -217,6 +242,9 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相关等级判定:顶分 >= 0.75 → PRECISE(+提示);>= 0.5 → REFERENCE(+提示);否则无。
|
||||
*/
|
||||
private RelevanceAssessment computeRelevance(List<ScoredCandidate> ranked) {
|
||||
if (ranked.isEmpty()) {
|
||||
return new RelevanceAssessment(null, null);
|
||||
@@ -232,6 +260,7 @@ public class KnowledgeEvidencePostProcessor {
|
||||
return new RelevanceAssessment(null, null);
|
||||
}
|
||||
|
||||
/** 同 evidenceKey 命中:合并 hitReasons + 补 breadcrumb(去重不丢信息)。 */
|
||||
private void mergeEvidence(EvidenceBlock existing, EvidenceBlock incoming) {
|
||||
Set<String> reasons = new LinkedHashSet<>();
|
||||
if (existing.getHitReasons() != null) {
|
||||
|
||||
@@ -10,17 +10,28 @@ import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Reciprocal Rank Fusion helpers.
|
||||
* Reciprocal Rank Fusion 工具:把多路检索的排名列表融合成一个分数排序。
|
||||
*
|
||||
* <pre>
|
||||
* RRF_w(d) = Σ w_i / (k + rank_i(d))
|
||||
* </pre>
|
||||
*
|
||||
* <p>只依赖排名不依赖原始分数——屏蔽跨路分数尺度不可比的问题;
|
||||
* 每路可加权(w <= 0 时按 1.0 等权),k 是平滑参数(默认 60,可配)。
|
||||
*/
|
||||
public final class RrfFusion {
|
||||
|
||||
private RrfFusion() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 融合多路排名:对每路的每个 item 累加 w/(k+rank),按总分降序输出。
|
||||
*
|
||||
* @param paths 多路排名(每路带 name / items / weight)
|
||||
* @param rrfK 平滑参数 k(至少 1)
|
||||
* @param identityFn 跨路识别同一 item 的身份函数(如 evidenceKey)
|
||||
* @return 融合后排序(含每路排名明细)
|
||||
*/
|
||||
public static <T> List<Scored<T>> fuse(List<RankedPath<T>> paths,
|
||||
int rrfK,
|
||||
Function<T, String> identityFn) {
|
||||
@@ -45,7 +56,7 @@ public final class RrfFusion {
|
||||
continue;
|
||||
}
|
||||
int rank = i + 1;
|
||||
double contrib = weight / (k + rank);
|
||||
double contrib = weight / (k + rank); // 排名越前贡献越大
|
||||
Acc<T> bucket = acc.computeIfAbsent(id, ignored -> new Acc<>(item));
|
||||
bucket.score += contrib;
|
||||
bucket.ranks.put(path.name(), rank);
|
||||
@@ -57,12 +68,14 @@ public final class RrfFusion {
|
||||
Acc<T> value = entry.getValue();
|
||||
scored.add(new Scored<>(entry.getKey(), value.item, value.score, Map.copyOf(value.ranks)));
|
||||
}
|
||||
// 总分降序(两路共识的靠前),同分按身份稳定排序
|
||||
scored.sort(Comparator
|
||||
.comparingDouble((Scored<T> s) -> s.rrfScore()).reversed()
|
||||
.thenComparing(Scored::identity));
|
||||
return scored;
|
||||
}
|
||||
|
||||
/** 一路检索结果:name(路名)+ items(按排名顺序)+ weight(可选加权,≤0 视为等权)。 */
|
||||
public record RankedPath<T>(String name, List<T> items, double weight) {
|
||||
public RankedPath {
|
||||
Objects.requireNonNull(name, "name");
|
||||
@@ -70,9 +83,11 @@ public final class RrfFusion {
|
||||
}
|
||||
}
|
||||
|
||||
/** 融合后的单个 item:identity + 原始 item + rrfScore + 每路排名明细。 */
|
||||
public record Scored<T>(String identity, T item, double rrfScore, Map<String, Integer> ranks) {
|
||||
}
|
||||
|
||||
/** 跨路累加器:同一 identity 的 item 累加 RRF 分并记录各路排名。 */
|
||||
private static final class Acc<T> {
|
||||
private final T item;
|
||||
private double score;
|
||||
|
||||
@@ -77,6 +77,10 @@ public class LookupKnowledgeTool {
|
||||
@Autowired
|
||||
private LookupResultAssembler resultAssembler;
|
||||
|
||||
/**
|
||||
* 解析检索宽度配置:retrieveK 优先级 rag.retrieve-k > rag.top-k > 默认 3。
|
||||
* return-n 由后处理器持有,这里只做可观测性记录。
|
||||
*/
|
||||
@PostConstruct
|
||||
void resolveRetrievalWidths() {
|
||||
int fallback = legacyTopK > 0 ? legacyTopK : 3;
|
||||
@@ -89,12 +93,27 @@ public class LookupKnowledgeTool {
|
||||
retrieveK, legacyTopK, returnNConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* RAG 检索主入口(legacy 后端,不感知 Harness)。
|
||||
*
|
||||
* <p>流程(模块化三段):
|
||||
* <ol>
|
||||
* <li>检索前:QueryTransformer.transform → KnowledgeQuery(分类过滤/域/关键词);</li>
|
||||
* <li>检索:DocumentRetriever.retrieve(FILTERED 或 UNFILTERED,retrieveK 候选);</li>
|
||||
* <li>检索后:PostProcessor.process(qualityScore/去重/判级);</li>
|
||||
* <li>低质量降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查;</li>
|
||||
* <li>打包 + 组装:ContextPacker.pack → LookupResultAssembler.assemble → LookupResult。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>返回的 LookupResult 是内部契约,Agent 可见字段由 RagResultProjector 再裁剪。
|
||||
*/
|
||||
public LookupResult lookupKnowledge(String query) {
|
||||
log.info("========================================");
|
||||
log.info(">>> [工具调用] lookup_knowledge");
|
||||
log.info(">>> metadata: query_chars={}, retrieveK={}", query == null ? 0 : query.length(), retrieveK);
|
||||
log.info("----------------------------------------");
|
||||
|
||||
// ── 检索前:查询理解(L0)──
|
||||
KnowledgeQuery knowledgeQuery = queryTransformer.transform(query);
|
||||
log.info("[QueryTransformer] categoryFilter={}, domainHintCount={}, keywordCount={}",
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
@@ -104,6 +123,7 @@ public class LookupKnowledgeTool {
|
||||
List<RetrievalTrace.Attempt> attempts = new ArrayList<>();
|
||||
String fallbackReason = null;
|
||||
|
||||
// ── 检索:首轮(有分类过滤则 FILTERED,否则 UNFILTERED)──
|
||||
String firstAttemptName = knowledgeQuery.getCategoryFilter() == null
|
||||
? ATTEMPT_UNFILTERED_VECTOR
|
||||
: ATTEMPT_FILTERED_VECTOR;
|
||||
@@ -112,6 +132,7 @@ public class LookupKnowledgeTool {
|
||||
knowledgeQuery.getRewrittenQuery(),
|
||||
knowledgeQuery.getCategoryFilter(),
|
||||
retrieveK);
|
||||
// ── 检索后:质量统一 + 去重 + 判级 ──
|
||||
EvidencePostprocessResult selectedEvidence = evidencePostProcessor.process(
|
||||
knowledgeQuery,
|
||||
firstAttempt.candidates());
|
||||
@@ -119,6 +140,7 @@ public class LookupKnowledgeTool {
|
||||
attempts.add(firstAttempt.attempt());
|
||||
String selectedAttemptName = firstAttemptName;
|
||||
|
||||
// ── 降级:带分类过滤结果低质 → 去掉过滤、用原始 query 重查(L0 边界可被推翻)──
|
||||
if (knowledgeQuery.getCategoryFilter() != null && evidencePostProcessor.isLowQuality(selectedEvidence)) {
|
||||
fallbackReason = selectedEvidence.hasUsableEvidence()
|
||||
? FALLBACK_LOW_QUALITY
|
||||
@@ -139,6 +161,7 @@ public class LookupKnowledgeTool {
|
||||
selectedAttemptName = ATTEMPT_UNFILTERED_VECTOR_RETRY;
|
||||
}
|
||||
|
||||
// ── 打包 + 组装(内部契约出口)──
|
||||
ContextPack contextPack = contextPacker.pack(selectedEvidence.getEvidenceBlocks());
|
||||
RetrievalTrace retrievalTrace = buildRetrievalTrace(knowledgeQuery, attempts, selectedAttemptName,
|
||||
fallbackReason, selectedEvidence);
|
||||
@@ -148,6 +171,7 @@ public class LookupKnowledgeTool {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 用后处理结果补充 attempt 的观测字段:topSimilarity + usable(是否达参考阈值)。 */
|
||||
private void enrichAttempt(RetrievalTrace.Attempt attempt, EvidencePostprocessResult evidence) {
|
||||
attempt.setTopSimilarity(evidence.getTopSimilarity());
|
||||
attempt.setUsable(evidence.hasUsableEvidence()
|
||||
@@ -155,6 +179,7 @@ public class LookupKnowledgeTool {
|
||||
&& evidence.getTopSimilarity() >= evidencePostProcessor.getReferenceThreshold());
|
||||
}
|
||||
|
||||
/** 组装完整检索路径 Trace(原始/改写 query、分类过滤、选中 attempt、降级原因、query 提示)。 */
|
||||
private RetrievalTrace buildRetrievalTrace(KnowledgeQuery query,
|
||||
List<RetrievalTrace.Attempt> attempts,
|
||||
String selectedAttempt,
|
||||
@@ -181,6 +206,7 @@ public class LookupKnowledgeTool {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 返回日志:found / relevanceLevel / 证据数 / 检索路径(供排查)。 */
|
||||
private void logReturn(LookupResult result) {
|
||||
log.info("----------------------------------------");
|
||||
log.info("<<< [工具返回] lookup_knowledge");
|
||||
|
||||
Reference in New Issue
Block a user