修复长期记忆向量链路:投影入队 + 集合名三侧同源 + 召回按客户过滤
语义召回"恒空"的根因分四层,本提交修掉投递层与读取层(另两层——重试计数
门禁、episode 不投 rebuild 事件——已在前两个提交修复)。
R2 投递层:dispatch_profile_rebuild 只做图投影,没有任何 Milvus 投递,
长期记忆的向量从未被写入过(实测客户 9001 在 memory_sync_outbox 里 0 行)。
新增 _enqueue_memory_vector_projection(),在画像重建后投
MemorySyncOutbox(target_store="milvus")。走事件而不是同步写,是为了拿
Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
⚠️ payload 必须带 version(正整数):适配器 _coerce_profile_version 缺它
直接抛 ValueError(实测踩到,事件立刻 failed)。
R4 读取层:写的集合与读的集合不是同一个 ——
写(MilvusProfileProjection)用 "user_long_term_memory_v1",
读(bootstrap.get_vector_memory_adapter)与删(projection_cleanup_service)
却用 settings.milvus_collection = "jr_memory",而该集合从未被创建。
⇒ 召回:MilvusClient 构造不校验集合存在,适配器"构造成功"但每次 search
抛异常被 VectorMemoryAdapter 吞成 degraded → 召回恒
degraded_reasons=('milvus_unavailable',)、向量命中恒 0 条;
⇒ 清理:jr_memory 不在集合列表 → 走 vector_collection_absent 分支 →
报清理成功但一个向量都没删,陈旧向量永久留存。
修法:PROFILE_COLLECTION 成为唯一常量,读/删两侧直接引用它;
并删除 Settings.milvus_collection 配置项、清掉 .env.example 的
MILVUS_COLLECTION —— 写侧从来没读过它,一个只在契约一侧生效的配置项
比没有配置项更危险(Settings 的 extra="ignore" 会让其他环境残留的该
变量被安全忽略)。
顺带:
- 语义检索把客户过滤下推到 Milvus(filter="customer_id == N")。此前不带
过滤,别家客户的命中会白占 limit 名额,稀释本客户的召回条数。
- upsert 在 sources 为空时先返回,不再无条件 load_collection ——
"本来就没有可写内容"不该被记成投递失败(10001/10002 那两条事件即如此
重试 5 次进死信)。
回归守卫:tests/unit/infrastructure/test_memory_vector_collection_consistency.py
断言读侧与删侧用的都是 PROFILE_COLLECTION,且被删掉的配置项不得回归。
这个缺陷能活下来,正是因为两侧单测全绿而接缝无人守。
验证(走生产装配、进程内调用,未重启你正在跑的 API 窗口):
读侧集合打印 user_long_term_memory_v1(修前为 jr_memory);
召回 degraded=False / reasons=() / sources 含 milvus,且排序随 query 语义
变化(投资期限→horizon 0.288 > risk_level 0.201;风险偏好→risk_level 0.287);
query="进取型" 双通道合并且 vector_score=0.9987;query=None 走 mysql 全量。
pytest tests/unit tests/contract → 1432 passed, 2 skipped, 1 failed
(唯一失败是同事正在改的投顾页面,与记忆链路无关)。
文档:docs/演示用/记忆召回恒空-根因与修复-2026-09-14.md(新增,四层根因+证据)、
docs/37-记忆投影链路实现说明.md(补集合名三侧契约)。
This commit is contained in:
@@ -82,6 +82,51 @@
|
||||
建集合:`python tools/setup_milvus_profile_collection.py`
|
||||
——**幂等**,集合已存在时只做结构比对报告、不覆盖不删重建(共享 Milvus 实例里还有别的项目的集合)。
|
||||
|
||||
### ⚠️ 集合名是"写、读、删"**三侧共用**的代码级契约(2026-09-14 修)
|
||||
|
||||
集合名 `user_long_term_memory_v1` **不做成配置项**,唯一真相是
|
||||
`app/infrastructure/milvus_profile_projection.py` 的 `PROFILE_COLLECTION` 常量。
|
||||
|
||||
**踩过的坑(本次语义召回"恒空"的 4 个根因里的第 4 个,也是最隐蔽的一个)**:
|
||||
三侧各写各的集合名 ——
|
||||
|
||||
| 侧 | 位置 | 用的名字 |
|
||||
|---|---|---|
|
||||
| **写** | `MilvusProfileProjection`(`PROFILE_COLLECTION`) | `user_long_term_memory_v1` ✅ |
|
||||
| **读** | `bootstrap.get_vector_memory_adapter()` | `settings.milvus_collection` = **`jr_memory`** ❌ |
|
||||
| **删** | `ProjectionCleanupService._cleanup_vector()` | 同上 ❌ |
|
||||
|
||||
`.env` 里 `MILVUS_COLLECTION=jr_memory`,而 **`jr_memory` 这个集合在本仓从未被创建过**
|
||||
(实测 `list_collections()` 只有 `fin_faq_collection` / `fin_policy_collection` /
|
||||
`fin_product_collection` / `user_long_term_memory_v1`)。于是:
|
||||
|
||||
- **读**:适配器构造成功(`MilvusClient` 不校验集合存在),每次 `search` 抛异常并被
|
||||
`VectorMemoryAdapter.search` 吞掉 → `degraded=True` → 召回结果恒
|
||||
`degraded_reasons=('milvus_unavailable',)`、**向量通道条数恒为 0**。
|
||||
写侧的投影日志一切正常,所以"写入成功、召回没数据"看似矛盾,实际是**查错了集合**。
|
||||
- **删**:`jr_memory not in list_collections()` 为真 → 走 `vector_collection_absent`
|
||||
分支 → **报"清理成功",但一个向量都没删**,陈旧向量永久留存。
|
||||
|
||||
两侧各自的单测都是绿的 —— 因为**没人守"两侧必须是同一个名字"这条接缝**。
|
||||
|
||||
**修法(三条一起改)**:
|
||||
|
||||
1. `app/infrastructure/milvus_profile_projection.py`:`PROFILE_COLLECTION` 成为唯一常量,
|
||||
并注明"这里改就三侧一起改"。
|
||||
2. `app/service/agent/bootstrap.py` 与 `app/service/projection_cleanup_service.py`:
|
||||
改为直接引用该常量。
|
||||
3. **删掉 `Settings.milvus_collection` 配置项**(`app/core/config.py`)并清掉
|
||||
`.env` / `.env.example` 里的 `MILVUS_COLLECTION`:写侧从来没读过它,
|
||||
一个**只在契约一侧生效**的配置项比没有配置项更危险。`Settings` 的 `extra="ignore"`
|
||||
保证其他环境残留的 `MILVUS_COLLECTION` 被安全忽略(不会报错、也不会再生效)。
|
||||
|
||||
回归守卫:`tests/unit/infrastructure/test_memory_vector_collection_consistency.py`
|
||||
(断言读侧适配器与删侧用的都是 `PROFILE_COLLECTION`,且被删掉的配置项不得回归)。
|
||||
|
||||
> 另注:同批次还给召回加了**客户过滤下推** —— `VectorMemoryAdapter.search(..., customer_id=N)`
|
||||
> 现在会带 `filter="customer_id == N"` 去查。此前不带过滤,别家客户的命中会白占 `limit`
|
||||
> 名额,本客户能拿到的条数被稀释(回表时虽按 customer_id 再筛一次,但名额已经没了)。
|
||||
|
||||
### `memory_sources` 契约(适配器的输入)
|
||||
|
||||
```json
|
||||
@@ -306,10 +351,15 @@ handler 异常被捕获 ✓、`status`/`retry_count`/`last_error`/`next_retry_at
|
||||
- `tools/setup_milvus_profile_collection.py`
|
||||
- `tools/normalize_memory_sync_outbox.py`(历史取值订正,默认 dry-run、幂等)
|
||||
- `tests/unit/infrastructure/test_milvus_profile_projection.py`
|
||||
- `tests/unit/infrastructure/test_memory_vector_collection_consistency.py`(集合名三侧同源守卫)
|
||||
- `tests/unit/worker/test_memory_sync_outbox_worker.py`
|
||||
- `tests/unit/worker/test_runtime_profile_projection.py`(消费端兜底 5 用例)
|
||||
|
||||
**修改**
|
||||
- `app/core/config.py`(**删除 `milvus_collection` 配置项**,见 §3 的集合名契约)
|
||||
- `app/infrastructure/vector_memory.py`(`search()` 支持按 `customer_id` 下推过滤)
|
||||
- `app/service/memory_recall_service.py`(语义检索传 `customer_id`)
|
||||
- `app/service/projection_cleanup_service.py`(清理改引用 `PROFILE_COLLECTION`)
|
||||
- `app/service/profile_generation_service.py`(取值改小写、payload 加 `memory_sources` 与 `profile_version`)
|
||||
- `app/repository/profile_repository.py`(`active_memories()`)
|
||||
- `app/service/agent/bootstrap.py`(`get_milvus_profile_vector_client()`)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# 记忆召回"恒空"根因与修复(第二轮)
|
||||
|
||||
> 承接 `记忆系统排查报告-2026-09-14.md`(第一轮:断头路 / 身份语义 / 引用校验)与
|
||||
> `记忆系统修复文档-2026-09-14.md`(F1/F3/F2/F5)。
|
||||
> 本轮目标是回答"**为什么语义召回一条数据都没有**",结论是**四个根因叠在一起**,
|
||||
> 全部已修并留下可复现证据。技术细节见 `docs/37-记忆投影链路实现说明.md`。
|
||||
|
||||
---
|
||||
|
||||
## 〇、一句话结论
|
||||
|
||||
**"没数据"不是一件事,是四件事串在一起。** 四个根因分层:**投递层**(事件根本没产生)→
|
||||
**门禁层**(产生了却被重试计数挡住)→ **写入层**(集合不存在 + 空内容被误判为失败)→
|
||||
**读取层**(写进了 A 集合、从 B 集合查)。前三层修好后召回依然是空的,因为第四层
|
||||
把读和写指向了两个不同的集合 —— **这是最隐蔽的一层,两侧单测全绿**。
|
||||
|
||||
| 你提的现象 | 根因 | 状态 |
|
||||
|---|---|---|
|
||||
| ① 语义召回(Milvus)没数据 | R1+R2+R3+R4(四层叠加) | ✅ **已修,端到端验证通过** |
|
||||
| ② 员工身份召回恒空 | 授权范围语义未定义 | ❌ **未修 —— 需你拍板**(见 §4.1) |
|
||||
| ③ 记忆更新传不到画像 | R1(重试计数把事件挡住) | ✅ **已修,有版本证据** |
|
||||
|
||||
---
|
||||
|
||||
## 一、四个根因
|
||||
|
||||
### R1(门禁层)重复聚合把 `retry_count` 刷到 1405,事件被永久过滤
|
||||
|
||||
- **现象**:`memory_sync_outbox` 里 40 条 `profile.rebuild_requested` 全部 `retry_count=1405`。
|
||||
- **机制**:`episode_worker._persist()` 每次都调 `_touch_retry()`,**重复聚合也照样 +1**。
|
||||
消费端只领 `retry_count < worker_retry_limit`(=3) 的事件 ⇒ 一旦超过 3 就**永远领不到,
|
||||
且不会报错、不会进死信**(它连被领取的资格都没有)。
|
||||
- **修法**:删掉 `_touch_retry` 及其调用点;补回归用例
|
||||
`test_repeated_aggregation_never_bumps_retry_count`。存量数据已订正(40 行归零)。
|
||||
- **证据**:修前 pending 40 → 修后 **0**。
|
||||
|
||||
### R2(投递层)`dispatch_profile_rebuild` **只有图投影,没有任何向量投递**
|
||||
|
||||
- **现象**:客户 9001 的记忆在 09-13、09-14 都更新过,而 `memory_sync_outbox` 里
|
||||
**它一行都没有**(全表仅 4 行,是 10001/10002 开户测评的画像投影)。
|
||||
- **机制**:该处置函数只调 `ProfileGraphProjectionService`(写 Neo4j),
|
||||
**Milvus 侧完全没有投递代码** ⇒ 长期记忆的向量从未被写入过。
|
||||
- **修法**:新增 `WorkerRuntime._enqueue_memory_vector_projection()`,在画像重建后投一条
|
||||
`MemorySyncOutbox(target_store="milvus", aggregate_type="profile")`。走事件而非同步写,
|
||||
是为了拿 Outbox 的重试/退避/死信,且不把 embedding 的网络等待拖进事务。
|
||||
⚠️ payload 必须带 `version`(正整数):适配器的 `_coerce_profile_version` 缺它直接
|
||||
抛 `ValueError`(实测踩到,事件立刻 failed)。
|
||||
|
||||
### R3(投递层)`episode_worker` 从不投 `profile.rebuild_requested`
|
||||
|
||||
- **机制**:抽取出记忆后没有触发画像重建 ⇒ 记忆进了 `memory_unit`,画像永远不动。
|
||||
- **修法**:`record_evidence` 返回非空时投 `DomainEventOutbox(event_type="profile.rebuild_requested",
|
||||
trigger="episode_extraction")`。
|
||||
- **证据**:伪造一个片段跑消费,`extracted=[191]`,rebuild 事件 **3 → 4**。
|
||||
|
||||
### R4(读取层)**写的集合和读的集合不是同一个** ← 最隐蔽,也是最后一根稻草
|
||||
|
||||
| 侧 | 位置 | 集合名 |
|
||||
|---|---|---|
|
||||
| **写** | `MilvusProfileProjection.PROFILE_COLLECTION` | `user_long_term_memory_v1` ✅ |
|
||||
| **读** | `bootstrap.get_vector_memory_adapter()` | `settings.milvus_collection` = **`jr_memory`** ❌ |
|
||||
| **删** | `ProjectionCleanupService._cleanup_vector()` | 同上 ❌ |
|
||||
|
||||
`.env` 里 `MILVUS_COLLECTION=jr_memory`,而 **`jr_memory` 从未被创建**
|
||||
(实测 `list_collections()` 只有 `fin_faq_collection` / `fin_policy_collection` /
|
||||
`fin_product_collection` / `user_long_term_memory_v1`)。于是:
|
||||
|
||||
- **读**:`MilvusClient` 构造不校验集合存在 ⇒ 适配器"构造成功",每次 `search` 抛异常被
|
||||
`VectorMemoryAdapter.search` 吞掉 → `degraded=True` → 召回**恒**
|
||||
`degraded_reasons=('milvus_unavailable',)`、向量命中恒 0 条。
|
||||
写侧日志一切正常,所以"写入成功却说没数据"看似矛盾,**实际是查错了集合**。
|
||||
- **删**:`jr_memory` 不在集合列表 ⇒ 走 `vector_collection_absent` 分支 ⇒
|
||||
**报"清理成功"但一个向量都没删**,陈旧向量永久留存。
|
||||
|
||||
**修法(三侧锁死成一个常量,把配置项删掉)**:
|
||||
|
||||
1. `PROFILE_COLLECTION` 成为唯一常量,注释写明"这里改就三侧一起改";
|
||||
2. 读侧(`bootstrap.py`)、删侧(`projection_cleanup_service.py`)改为直接引用它;
|
||||
3. **删除 `Settings.milvus_collection` 配置项**,并清掉 `.env` / `.env.example` 里的
|
||||
`MILVUS_COLLECTION`:写侧从来没读过它,一个**只在契约一侧生效**的配置项比没有配置项
|
||||
更危险。`Settings` 的 `extra="ignore"` 保证其他环境残留的该变量被安全忽略。
|
||||
4. 回归守卫:`tests/unit/infrastructure/test_memory_vector_collection_consistency.py`。
|
||||
|
||||
**同批附带**:语义检索此前**不带客户过滤**,别家客户的命中会白占 `limit` 名额;
|
||||
现在 `VectorMemoryAdapter.search(..., customer_id=N)` 会带
|
||||
`filter="customer_id == N"` 下推到 Milvus。
|
||||
|
||||
### 附:两个"助推"因素(同批修掉)
|
||||
|
||||
- **向量集合本身不存在**:`user_long_term_memory_v1` 此前从未创建过,用
|
||||
`python tools/setup_milvus_profile_collection.py` 建好(幂等,已存在只做结构比对)。
|
||||
- **`upsert` 无条件先 `load_collection`**:集合不存在时抛错 ⇒ "**本来就没有可写内容**"
|
||||
的事件被记成**投递失败**、重试 5 次进死信(实测 10001/10002 那两条就是这样死的)。
|
||||
现改为 `sources` 为空时**先返回**,留给"零写入"与"没跑"可区分的日志。
|
||||
|
||||
---
|
||||
|
||||
## 二、端到端验证证据(2026-09-14)
|
||||
|
||||
### 2.1 记忆 → 画像(现象 ③)
|
||||
|
||||
`profile_snapshots`**版本号真的动了**:
|
||||
**v7 @ 2026-09-10 13:57 → v10 @ 2026-09-14 13:06:57**,
|
||||
`user_facts.preference:risk_level = "进取型"`(来自伪造的验证片段)。不是"跑了没报错",
|
||||
是**库里的值变了**。
|
||||
|
||||
### 2.2 记忆 → 向量库(现象 ① 的写入侧)
|
||||
|
||||
集合 `user_long_term_memory_v1` 现有 2 行,都是客户 9001:
|
||||
|
||||
| memory_uuid | memory_key | content | version |
|
||||
|---|---|---|---|
|
||||
| `187e3b7c…` | `preference:risk_level` | 进取型 | 5 |
|
||||
| `fdae7288…` | `preference:horizon` | 约三年 | 4 |
|
||||
|
||||
`memory_sync_outbox` 该客户的行**全部 `processed`**(清理掉 1 行验证残留后:
|
||||
`[('milvus','processed',2), ('neo4j','processed',2)]`)。
|
||||
|
||||
### 2.3 语义召回(现象 ① 的读取侧)—— 走**生产装配**,不是测试替身
|
||||
|
||||
```powershell
|
||||
& D:\conda\envs\jr_py313\python.exe -X utf8 - <脚本:build_memory_recall_service → recall()>
|
||||
```
|
||||
|
||||
读侧集合打印为 `user_long_term_memory_v1`(修前是 `jr_memory`),召回结果:
|
||||
|
||||
| query | degraded | sources | 命中与排序 |
|
||||
|---|---|---|---|
|
||||
| `投资期限` | **False** | `['milvus']` | `horizon`(0.288) > `risk_level`(0.201) |
|
||||
| `风险偏好` | **False** | `['milvus']` | `risk_level`(0.287) > `horizon`(0.154) |
|
||||
| `三年` | **False** | `['milvus','mysql']` | `horizon` 双通道,**vector_score 0.810** |
|
||||
| `进取型` | **False** | `['milvus','mysql']` | `risk_level` 双通道,**vector_score 0.9987** |
|
||||
| `货币基金` | **False** | `['milvus']` | 正文不含该词 ⇒ 只有弱相关向量命中(0.255) |
|
||||
| `None` | **False** | `['mysql']` | 结构化全量 2 条(conf 1.0 / 0.95) |
|
||||
|
||||
**判定**:`degraded=False`、`degraded_reasons=()`、**排序随 query 语义变化**、
|
||||
双通道按 `memory_uuid` 正确合并 —— 这是真实语义信号,不是"接口不报错"。
|
||||
|
||||
### 2.4 回归
|
||||
|
||||
- `pytest tests/unit tests/contract` → **1432 passed, 2 skipped, 1 failed**
|
||||
(唯一失败是同事正在改的投顾页面 `test_advisor_workspace_registers_documented_operation_endpoints`,
|
||||
与记忆链路无关)。
|
||||
- 新增/更新用例:集合名三侧同源守卫 4 条、召回客户过滤断言 1 条、
|
||||
episode worker 重试计数回归 1 条(共 +4 通过)。
|
||||
|
||||
---
|
||||
|
||||
## 三、演示前必做(否则前端看到的仍是旧行为)
|
||||
|
||||
1. **重启 API 进程**:`get_vector_memory_adapter()` 是 `lru_cache` 且旧进程仍持有
|
||||
`jr_memory` 那版代码,**不重启则接口依旧返回 `milvus_unavailable`**。
|
||||
(我在验证时刻意用生产装配在进程内调用,没有动你正在跑的窗口。)
|
||||
2. **确保常驻 Worker 在跑**:`python -X utf8 -m app.worker`。记忆抽取是
|
||||
`EPISODE_INTERVAL_ROUNDS = 30` 轮一次的节奏,`--once` **碰不到** episode 链路。
|
||||
3. 行情有效期只有 15 分钟,演示前先 `python tools/sync_market_prices.py`。
|
||||
|
||||
---
|
||||
|
||||
## 四、未修 / 待你决策
|
||||
|
||||
### 4.1 ⚠️ 员工身份召回恒空 —— **卡在你的一个决策上**
|
||||
|
||||
`governance.recall` 场景下员工查客户记忆恒空。**技术上已定位**:召回入口按"身份"取
|
||||
客户范围,而**员工的可见范围没有权威定义**,所以要么查不到、要么可能越权。
|
||||
修它需要你先定**授权口径**,二选一:
|
||||
|
||||
- **(A) 按 `sys_customer_assignment` 归属**:员工只能召回"分配给我"的客户。
|
||||
语义最严(谁负责谁可见),但依赖分配表数据完整 —— 数据没维护就会什么都查不到。
|
||||
- **(B) 按角色 `data_scope`**:如投顾可见其服务范围内全部客户。
|
||||
更贴近真实组织,但需要 `data_scope` 有明确定义,越权面更大。
|
||||
|
||||
**我倾向 (A)**:金融场景"最小可见"优先,且它天然可审计。**你拍板后我再动手改。**
|
||||
|
||||
### 4.2 其他(不阻塞演示)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| `EpisodeExtractionConsumer` 幂等哈希不覆盖记忆内容 | `snapshot_hash` 只算 `fin_customer_profile`+`fin_risk_assessment`,**"只有记忆变了"会被判定 `changed=False` 短路**,`_memory_sources()` 根本不执行。建议把 `memory_uuid+version` 纳入哈希,或拆成 `snapshot_changed`/`memory_changed` 两个判据。 |
|
||||
| `ProjectionReconciliationService.mark_replay` 无调用方 | 有方法没入口(无 CLI / 无 Worker 接线),对账重放实际跑不起来。 |
|
||||
| 投顾线两个生产者不发 `memory_sources` | `profile_governance_service` / `risk_questionnaire_service` 的 payload 仍缺该键(`docs/37` §8.2 已登记)。消费端有兜底所以不死信,但根治在投顾线。 |
|
||||
| `today_profit_loss` 硬编码 0 | 业务决策项,见 `软件需求文档-2026-09-14.md` **Q22**。 |
|
||||
|
||||
---
|
||||
|
||||
## 五、本轮改动文件
|
||||
|
||||
**新增**
|
||||
- `tests/unit/infrastructure/test_memory_vector_collection_consistency.py`(集合名三侧同源守卫)
|
||||
- 本文件
|
||||
|
||||
**修改**
|
||||
- `app/core/config.py`(**删除 `milvus_collection` 配置项**)
|
||||
- `app/infrastructure/milvus_profile_projection.py`(集合名唯一常量 + 空内容早返回)
|
||||
- `app/infrastructure/vector_memory.py`(`search()` 支持 `customer_id` 下推过滤)
|
||||
- `app/service/memory_recall_service.py`(语义检索传 `customer_id`)
|
||||
- `app/service/agent/bootstrap.py`(读侧改用 `PROFILE_COLLECTION`)
|
||||
- `app/service/projection_cleanup_service.py`(删侧改用 `PROFILE_COLLECTION`)
|
||||
- `app/worker/runtime.py`(`_enqueue_memory_vector_projection`)
|
||||
- `app/worker/episode_worker.py`(去掉 `_touch_retry`;投 `profile.rebuild_requested`)
|
||||
- `tests/unit/service/test_memory_recall_service.py`(断言客户过滤)
|
||||
- `tests/unit/worker/test_episode_worker.py`(重试计数回归用例)
|
||||
- `.env.example`(移除 `MILVUS_COLLECTION`)
|
||||
- `docs/37-记忆投影链路实现说明.md`(补集合名契约与根因)
|
||||
Reference in New Issue
Block a user