fix(knowledge): 修复循环依赖 + 归档 session-dedup-knowledge-map + 记录 ISS-002

- KnowledgeIndexService: 域级生成从 @PostConstruct 移到 @EventListener(ApplicationReadyEvent),解决 KnowledgeIndexService ↔ KnowledgeDomainService 循环依赖
- devflow 归档: evidence.md + acceptance.md(含运行验证结果)
- devflow/index.md: session-dedup-knowledge-map 状态改为 archived
- openspec .archive-ready 标记
- mvp/issues/ISS-002: Executor 无约束重复调用 lookup_knowledge
This commit is contained in:
zhuyongxin
2026-07-01 14:26:59 +08:00
parent 354ffc1947
commit e4f37cb9e6
7 changed files with 253 additions and 3 deletions
+1
View File
@@ -10,3 +10,4 @@
| 2026-06-25 | doc-management-ui | 前端开发/文档管理 | 文档管理页面, CRUD, 状态监控, 纯静态页面, API集成 | archived | | 2026-06-25 | doc-management-ui | 前端开发/文档管理 | 文档管理页面, CRUD, 状态监控, 纯静态页面, API集成 | archived |
| 2026-06-26 | session-storage | 会话存储/可观测 | diagnosis_session, agent_step, tool_invocation, token追踪, 多Agent路由 | openspec/changes/session-storage | archived | | 2026-06-26 | session-storage | 会话存储/可观测 | diagnosis_session, agent_step, tool_invocation, token追踪, 多Agent路由 | openspec/changes/session-storage | archived |
| 2026-06-29 | confidence-feedback | 质量评估/反馈机制 | evidence_score, selfEvaluation, feedback, useful, not_useful, case_library, BAD_CASE, tool_invocation规则引擎, 反馈按钮, sessionId回传 | openspec/changes/confidence-feedback | archived | | 2026-06-29 | confidence-feedback | 质量评估/反馈机制 | evidence_score, selfEvaluation, feedback, useful, not_useful, case_library, BAD_CASE, tool_invocation规则引擎, 反馈按钮, sessionId回传 | openspec/changes/confidence-feedback | archived |
| 2026-06-30 | session-dedup-knowledge-map | 去重/知识图谱 | RetrievedDocTracker, KnowledgeDomainService, knowledge_domain, covers, whenToRetrieve, Planner注入, ISS-001 | openspec/changes/session-dedup-knowledge-map | archived |
@@ -0,0 +1,58 @@
# Acceptance: session-dedup-knowledge-map
## 静态验证
| 项目 | 结果 | 说明 |
|------|------|------|
| 编译检查 | PASS | `mvn compile -q` exit code 0,所有 17 个变更文件无编译错误 |
| 代码结构检查 | PASS | 6 个新文件(RetrievedDocTracker, DocumentFieldEnricher, KnowledgeDomainService, KnowledgeDomain, KnowledgeDomainRepository, V009 迁移)均存在且路径正确 |
| Prompt 外部化 | PASS | `doc-field-enricher-prompt.md` 和 `domain-summary-prompt.md` 位于 `src/main/resources/prompts/`,Java 代码通过 `@PostConstruct` + `ClassPathResource` 加载 |
| Flyway 迁移脚本 | PASS | `V009__add_knowledge_domain.sql` 存在,表结构完整 |
| DTO 字段 | PASS | Frontmatter / KnowledgeEntry / LookupResult 新增字段均已添加 |
| 解析器扩展 | PASS | FrontmatterParser 解析 `covers` 和 `when_to_retrieve` |
| Jackson 替换 | PASS | KnowledgeIndexService 不再包含 extractJsonValue/extractJsonArray,改用 objectMapper.readValue |
| Prompt 检索规则 | PASS | chat-planner-prompt.md 新增"知识库检索规则"区块(4 条规则) |
## 脚本验证
| 项目 | 结果 | 说明 |
|------|------|------|
| 单元测试 | 未运行 | 项目当前无针对本 change 的单元测试 |
| 集成测试 | 未运行 | 需启动应用 + Milvus + MySQL 验证完整链路 |
## 浏览器/人工验证
| 项目 | 结果 | 说明 |
|------|------|------|
| V009 迁移 | PASS | Flyway 日志:`Successfully applied 1 migration to schema superbiz_agent, now at version v009` |
| knowledge_domain 表数据 | PASS | 4 个域全部 LLM 生成 when_to_retrieve 成功(api/domain/infrastructure/troubleshooting),内容包含跨域边界引用 |
| knowledge map 注入 Planner | PASS | 多 Agent 路径正常触发 `Supervisor → chat_planner → chat_executor`,Planner 能按域做检索规划 |
| session 级去重 | PASS | 两个 session 均验证去重生效:session `7c517329` 去 4 次重拦截,session `9693b9fb` 6 次去重拦截 |
| LLM 字段生成 | 未验证 | 需上传新文档后检查 metadata JSON 中是否包含 covers 和 whenToRetrieve |
## 未验证项
| 项目 | 风险 | 建议补验步骤 |
|------|------|-------------|
| LLM 字段生成 | 中 — 依赖外部 LLM 服务 | 上传新文档,检查 metadata JSON 中是否包含 covers 和 whenToRetrieve |
## 启动问题修复
| 问题 | 修复 | 状态 |
|------|------|------|
| `@PostConstruct` 中调用 `knowledgeDomainService.onDocumentChange()` 导致循环依赖 | 将域级生成从 `@PostConstruct` 移到 `@EventListener(ApplicationReadyEvent.class)` | 已修复,编译通过 |
## 任务完成状态
14/14 任务全部完成 (T1-1 ~ T6-2)。
## 遗留问题
ISS-002:Executor 无约束重复调用 `lookup_knowledge`(单会话 20+ 次),knowledge map 和检索约束只注入了 Planner 未注入 Executor。详见 `mvp/issues/ISS-002-executor-unconstrained-lookup.md`。
## 已知限制
1. **RetrievedDocTracker 为 JVM 内存存储**:应用重启后去重状态丢失,同一会话内重启无法继续去重(可接受,会话通常短于重启间隔)
2. **Planner 只看域级 when_to_retrieve**:文档级细粒度筛选留 Phase 2
3. **文档级 prompt 依赖同域其他文档**:首个上传到某域的文档无法获得同域参照(此时 prompt 输出"无同域其他文档")
4. **域级 prompt 依赖其他域已入库**:首次启动且 DB 为空时,其他域信息从 L0 索引 category 列表兜底
@@ -0,0 +1,87 @@
# Evidence: session-dedup-knowledge-map
## E1: ThreadLocal 在多 Agent 路径是否安全
**问题**:`SessionContextHolder` 基于 ThreadLocal,多 Agent 异步路径可能导致 sessionId 丢失。
**证据**:
- `AsyncConfig` 只启用 `@EnableAsync`,无 `TaskDecorator`
- `SupervisorAgent.invoke()` 是同步阻塞调用,工具调用与主线程同线程
- 当前路径下 ThreadLocal 安全
**结论**:当前同步路径安全。未来引入异步扩展时需补 `TaskDecorator` 传递 ThreadLocal。
---
## E2: 6 个文档是否全部有 category 字段
**问题**:域聚合依赖 `category` 字段分组,需确认现有文档是否都有值。
**证据**:
- 全部 6 个文档均有 `category` 字段:api(1)、domain(1)、infrastructure(3)、troubleshooting(1)
**结论**:现有文档无需修补,category 覆盖率 100%。
---
## E3: 去重 key 设计
**问题**:用什么字段唯一标识一个文档用于去重。
**证据**:
- `KnowledgeEntry.filePath` 在 L0 索引内唯一
- L1 向量索引的 `_source` 字段也是 filePath
- 上传时 `saveToLocal()` 生成 `knowledge_base/{category}/{fileName}` 路径
**结论**:统一用 `filePath` 作去重 key,L0 和 L1 一致。
---
## E4: Planner prompt token 增量是否可接受
**问题**:knowledge map YAML 注入 Planner prompt 会增加固定 token 开销。
**证据**:
- 当前 planner prompt 21 行
- 注入 knowledge map 约增加 200-400 字符(6 个文档场景)
- 相比 Planner 整体 prompt + 历史消息,增量占比 < 5%
**结论**:可接受,不构成性能瓶颈。
---
## E5: EvaluationService.tool_call_count 影响
**问题**:去重后 `tool_call_count` 降低,是否影响 `EvaluationService` 评分逻辑。
**证据**:
- `EvaluationService` 使用 `tool_call_count` 作为评分因子
- 去重导致重复调用被过滤,`tool_call_count` 下降
- 这是修复效果(消除了无意义的重复调用),不是回归
**结论**:`EvaluationService` 评分规则无需改动。下降的 `tool_call_count` 反映了真实效率提升。
---
## P1: 手写 JSON 解析器脆弱性
**问题**:`KnowledgeIndexService.extractJsonValue` / `extractJsonArray` 在遇到含逗号、引号的自然语言字段时会截断。
**证据**:
- `whenToRetrieve` 字段由 LLM 生成,内容为自然语言(含逗号、分号等标点)
- 手写解析器以 `"` 和 `,` 作分隔符,自然语言中的标点会导致提前截断
- Jackson `ObjectMapper.readValue(metadata, Frontmatter.class)` 是项目已有依赖
**结论**:全量替换为 Jackson,影响范围仅 `KnowledgeIndexService.parseDocumentToEntry()`,行为更健壮。
---
## P2: LookupResult 去重提示字段
**问题**:去重命中时如何向 LLM 返回"不要重试"的信号。
**证据**:
- 复用 `primary.content` 语义不清,LLM 可能理解为正常检索结果
- 独立 `message` 字段 + `found=false` 语义明确,LLM 能理解"已检索过"不再重试
**结论**:`LookupResult` 新增 `String message` 字段,去重时填入提示文本。
@@ -0,0 +1,86 @@
# ISS-002 Executor 无约束重复调用 lookup_knowledge
**状态**:待修复
**严重程度**:中(工具层去重已拦截重复文档,但调用本身仍浪费 token 和耗时)
**发现时间**:2026-07-01
**关联**:ISS-001(Part A 已修,Part B 注入范围不足)
---
## 现象
ISS-001 修复后,session 级去重(RetrievedDocTracker)生效,同一文档不再重复召回内容。但 Executor 在单次会话中仍调用 `lookup_knowledge` 20+ 次,大部分被去重拦截返回"已检索过"。
实测日志(session `7c517329`,2026-07-01 13:53):
```
Executor 调用 lookup_knowledge ~20 次
去重拦截 11 次:
- infrastructure/mysql-connection-pool.md × 6
- api/payment-errors.md × 5
有效检索仅 2-3 次(首次命中各域时)
```
Executor 用不同的 query 变体反复查同一个域,因为 LLM 觉得"需要更多细节"。
---
## 根本原因
**knowledge map 和检索约束只注入了 Planner prompt,未注入 Executor prompt。**
当前注入范围:
| 组件 | knowledge map | 每域最多一次约束 |
|------|:---:|:---:|
| Planner prompt | 已注入 | 已注入 |
| Executor prompt | **未注入** | **未注入** |
调用链路:
```
Supervisor → Planner:规划一次,输出"查 infrastructure 域 + api 域"
Supervisor → Executor:执行步骤(ReactAgent,自主决定调用工具)
Executor step 1:lookup("MySQL 连接池配置") → 命中 infrastructure 域 ✓
Executor step 2:lookup("HikariCP 参数调优") → 去重拦截 ✗
Executor step 3:lookup("连接池耗尽排查步骤") → 去重拦截 ✗
Executor step 4:lookup("支付超时排查") → 命中 api 域 ✓
Executor step 5:lookup("ERR_TIMEOUT 错误码") → 去重拦截 ✗
...(反复用不同变体查同域)
```
Executor 看不到"每个域只查一次"的约束,也不知道已有哪些域被检索过。
---
## 影响
- **Token 浪费**:每次去重拦截仍需走完 L0+L1 检索流程,再返回"已检索过";LLM 也要处理这个返回信息
- **耗时增加**:每次冗余调用约 400-500ms(L0+L1 检索 + 向量查询),20 次冗余调用浪费约 10s
- **LLM 行为低效**:Executor 花大量 step 在重复检索上,而不是基于已有信息推理
---
## 修法方向
### 方案 A:Executor prompt 注入 knowledge map + 检索约束
在 `chat-executor-prompt.md` 或 `buildChatExecutorAgent()` 中:
1. 注入 knowledge map(与 Planner 相同的 YAML)
2. 添加规则:"每个域最多调用一次 lookup_knowledge;已检索过的域不要再用不同关键词重复检索"
优点:与 Planner 对齐,LLM 能理解域级边界
缺点:仍依赖 LLM 遵守指令(但比纯 Prompt 约束强,因为有 knowledge map 做锚点)
### 方案 B:工具层硬限制(session + 域级计数)
在 `RetrievedDocTracker` 中增加域级计数:`ConcurrentHashMap<sessionId, Map<domain, count>>`。
当某域检索次数 > 1 时,直接在 `LookupKnowledgeTool` 入口返回"该域已检索过,不允许再次调用"。
优点:100% 可靠,不依赖 LLM
缺点:需改动 RetrievedDocTracker + LookupKnowledgeTool,需要从 filePath 反查 domain
### 建议
**先做方案 A**(改动小,与已有 knowledge map 注入逻辑一致),观察效果。
如果 LLM 仍不遵守,再升级到方案 B。
+6
View File
@@ -0,0 +1,6 @@
# 已知问题记录
| # | 标题 | 严重程度 | 状态 | 文件 |
|---|---|---|---|---|
| ISS-001 | Executor 重复召回同一文档 | 中 | 已修复 | [ISS-001-duplicate-retrieval.md](ISS-001-duplicate-retrieval.md) |
| ISS-002 | Executor 无约束重复调用 lookup_knowledge | 中 | 待修复 | [ISS-002-executor-unconstrained-lookup.md](ISS-002-executor-unconstrained-lookup.md) |
@@ -9,7 +9,9 @@ import com.superbiz.agent.repository.KnowledgeDomainRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -69,7 +71,18 @@ public class KnowledgeIndexService {
log.info("知识库索引加载完成,共 {} 个文档", loaded); log.info("知识库索引加载完成,共 {} 个文档", loaded);
// 检查各域是否有 knowledge_domain 记录,无则触发生成 } catch (Exception e) {
log.error("知识库索引加载失败", e);
}
}
/**
* 应用就绪后,检查各域是否有 knowledge_domain 记录,无则触发生成
* 使用 ApplicationReadyEvent 而非 PostConstruct,避免循环依赖
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
try {
knowledgeIndex.stream() knowledgeIndex.stream()
.map(KnowledgeEntry::getCategory) .map(KnowledgeEntry::getCategory)
.filter(c -> c != null && !c.isBlank()) .filter(c -> c != null && !c.isBlank())
@@ -80,9 +93,8 @@ public class KnowledgeIndexService {
knowledgeDomainService.onDocumentChange(category); knowledgeDomainService.onDocumentChange(category);
} }
}); });
} catch (Exception e) { } catch (Exception e) {
log.error("知识库索引加载失败", e); log.error("域级记录生成失败", e);
} }
} }