记忆系统演示文档 + 修掉两处会让演示断链的真问题
## 新增:docs/演示用/记忆系统演示文档-2026-09-14.md 按"操作 → 看到什么 → 这体现什么"写,五个场景(探针看存储 / 记住一件事 / 谁能读谁读不到 / 画像版本与投影 / 停 Worker),每个场景配可复现命令与**实测输出**,另附建议顺序与时长、 六条问答话术、演示前自检清单、受控信号词表摘录。 配套两个新工具(都已实跑): - `tools/memory_demo_chain.py`:一键走完"客户说 → 候选 → 客户确认 → 管理员批准 → 记忆与画像自动收敛",打印演示前后对比(实测 2 秒收敛)。 - `tools/memory_recall_demo.py`:同一客户换六个身份召回,当场看出"客户只读自己 / 员工要权限码+归属 / 运营读不到 / 管理员有权限码但没归属也读不到"。 ## 修掉两处会让演示当场断链的问题(都是真机复现的) 1. **候选批准后画像字段不收敛** `CustomerProfileCandidateService._promote` 只写画像快照、**不投** `profile.rebuild_requested`,而 `user_facts` 与画像字段是 `ProfileAssemblyService` 的重建链路写的。后果:批准后记忆变 active、快照版本 +1,但**画像字段停在旧值** (客户说"三年以内",画像里还是"十年以上"),要等一次无关的重建才收敛 —— 而"客户说完 → 批准 → 画像变了"正是演示主线。 现补一条重建事件(走事件而不是就地重建:本方法所在事务还没提交, 另开 session 看不到刚写入的记忆)。 2. **画像快照的唯一键从来没起作用,而且埋雷** `uk_profile_snapshot_current` 建在列 `current_customer_id` 上(不是 `is_current`), 但 `ProfileAssemblyService._write_snapshot` 旧行只置 `is_current=False`(不清该列)、 新行**不写**该列(实测 13 行该列全 NULL)。 后果:只要客户**先被重建过一次**,旧 current 行仍占着 `current_customer_id=9001`, 下一次"批准画像候选"就会撞 `Duplicate entry '9001' for key uk_profile_snapshot_current` → **整次批准 500**(本次实测踩到)。 现按唯一键的真实语义写:清旧行的该列、新行显式写客户号。 (`ProfileGenerationService` / 候选路径本来就是这么写的,只有这一处没对齐。) ## 守卫 新增 `tests/integration/test_profile_snapshot_current_invariant_mysql.py`: 按真实顺序"先重建再批准候选",断言 ① 只有一条 current 且它占着唯一键、 ② 历史版本已归还该列、③ 批准不再 500、④ 批准投出了重建事件。 修复前这条用例会在第 ② 步失败。 ## 验证 - `pytest tests/unit tests/contract` → 1490 passed, 2 skipped, 0 failed - 新增集成用例通过;真机实测演示主线:候选 → verified → active → **2 秒内** `user_facts` 与 `fin_customer_profile.investment_horizon` 都变成新值 - `mypy tools/memory_demo_chain.py tools/memory_recall_demo.py` → 0 错;ruff 全绿 ## 文档 `docs/44-演示流程.md`:配套文档清单与"记忆链路"备选场景都指向新演示文档。
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
# 记忆系统演示文档(照着演)
|
||||
|
||||
> **读者**:负责演示的人。本文按"**操作 → 看到什么 → 这体现什么**"三段式写,可以直接照着念。
|
||||
> **核心思路**:记忆系统最怕"讲得很玄、看不到东西"。所以这里每个场景都配一条**可复现的命令**,
|
||||
> 把"库里真有什么"摆出来 —— **能自证,才叫演示**。
|
||||
> **配套**:原理看 `记忆架构与Worker工作流程-大白话版.md`;整体流程看 `全功能流程-大白话版.md`。
|
||||
> **实测日期**:2026-09-14(数字会随数据变化,**以现场命令输出为准**)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 演示前 5 分钟准备
|
||||
|
||||
### 0.1 必须满足的两件事
|
||||
|
||||
| 前提 | 为什么 | 怎么确认 |
|
||||
|---|---|---|
|
||||
| **API 在跑** | 所有操作走接口 | 浏览器能打开 <http://127.0.0.1:8000/portal/> |
|
||||
| **Worker 在跑** | 记忆是**异步**提炼的:受理 202 → Worker 领单 → 抽候选 → 重建画像 | `python -m app.worker` 的窗口还在;**它停了记忆链路会静默不动**,而页面只显示"客服繁忙" |
|
||||
|
||||
> ⚠️ 只跑 `uvicorn` 不跑 Worker 是**最常见的翻车点**:你说完话,什么都不发生,也没有任何报错。
|
||||
> 一句话排查:`agent_run` 最新那行是不是 `status='queued'` 且 `worker_id` 为空。
|
||||
|
||||
### 0.2 三条命令(演示时要用到的全部工具)
|
||||
|
||||
```powershell
|
||||
# ① 一眼看清"记忆链路现在是什么样"(只读,不写数据)
|
||||
python tools/probe_memory_state.py 9001
|
||||
|
||||
# ② 一键走完整条链路:客户说 → 候选 → 客户确认 → 管理员批准 → 记忆与画像收敛
|
||||
# (会真的写入数据,这正是它的意义)
|
||||
python tools/memory_demo_chain.py
|
||||
|
||||
# ③ 演示"谁能读、谁读不到"(只读)
|
||||
python tools/memory_recall_demo.py
|
||||
```
|
||||
|
||||
### 0.3 演示账号
|
||||
|
||||
| 角色 | 账号 | 在记忆这条链上干什么 |
|
||||
|---|---|---|
|
||||
| 客户 | `cust_t` / `123456` | 说那句话;**确认**候选(第一把钥匙) |
|
||||
| 管理员 | `admin_t` / `88888888` | **批准**候选(第二把钥匙);在「画像候选」页操作 |
|
||||
| 风控专员 | `risk_t` / `666666` | 演示"有权限 + 有归属 → 能读到客户记忆" |
|
||||
| 运营 | `offsite_t` / `offsite123` | 演示"没有权限 → 读不到(失败关闭)" |
|
||||
|
||||
---
|
||||
|
||||
## 1. 场景一:先看"记忆到底存在哪"(1.5 min,只读)
|
||||
|
||||
**操作**:跑探针。
|
||||
|
||||
```powershell
|
||||
python tools/probe_memory_state.py 9001
|
||||
```
|
||||
|
||||
**看到什么**(2026-09-14 实测):
|
||||
|
||||
```
|
||||
memory_unit 3 ← 长期记忆本体(客户 9001 有 3 条)
|
||||
memory_evidence ... ← 每条记忆的证据(能追到"客户哪句话说的")
|
||||
memory_conflict ... ← 同一键内容变化时的留痕
|
||||
memory_sync_outbox 已 processed ← 投影到 Milvus / Neo4j 的投递台账
|
||||
user_facts 3 ← 事实层(记忆 → 画像字段的中间层)
|
||||
profile_snapshots 30 版 ← 画像的历史版本(每次变更只新增,不覆盖)
|
||||
```
|
||||
|
||||
**这体现什么**(照着念):
|
||||
|
||||
> 记忆不是一个黑盒,而是**一个账本加两份副本**:
|
||||
> **账本在 MySQL**(记忆本体、证据、冲突留痕、画像版本),
|
||||
> **关系副本在 Neo4j**、**向量副本在 Milvus**(都是为了检索快)。
|
||||
> 副本丢了能从账本重建;**账本与副本不一致时,一律以账本为准。**
|
||||
|
||||
**被问到"证据在哪"**:`memory_evidence` 里每条记录都带 `source_table` / `source_record_id` /
|
||||
`evidence_excerpt` —— 可以当场指出"这条记忆来自 `conversation_message` 第 4668 行,原话是……"。
|
||||
这是"**可追溯**",不是"模型说它记住了"。
|
||||
|
||||
---
|
||||
|
||||
## 2. 场景二 ★:让平台真的记住一件事(3 min,核心场景)
|
||||
|
||||
这是整套演示里最有说服力的一段:**客户说一句 → 平台抽成候选 → 客户自己确认 → 管理员批准 →
|
||||
记忆与画像都变**。全程走真实接口与真实权限。
|
||||
|
||||
### 2.1 快速版(30 秒,一条命令)
|
||||
|
||||
```powershell
|
||||
python tools/memory_demo_chain.py
|
||||
```
|
||||
|
||||
**实测输出**(节选,2026-09-14):
|
||||
|
||||
```
|
||||
--- 演示前(客户 9001) ---
|
||||
active 长期记忆: [('preference:horizon', '投资期限十年以上,长期持有'), ...]
|
||||
画像字段 : [('C5', '投资期限十年以上,长期持有', '"自述:preference:risk_level=保守型"')]
|
||||
【1】客户在客服会话里说:我的投资期限是三年以内。
|
||||
受理 202,run_id=…(等 Worker 处理…) 运行终态:succeeded
|
||||
【2】等它被抽成画像候选 候选 #56x:preference:horizon = 三年以内(置信度 0.9)
|
||||
【3】第一把钥匙:客户本人确认 状态:verified(candidate → verified)
|
||||
【4】第二把钥匙:管理员批准 状态:active(verified → active)
|
||||
【5】等 Worker 自动收敛 第 1 次轮询(2s):user_facts 已更新
|
||||
--- 演示后(客户 9001) ---
|
||||
active 长期记忆: [('preference:horizon', '三年以内'), ...]
|
||||
画像字段 : [('C5', '三年以内', ...)] ← 画像字段真的变了
|
||||
画像快照 : 共 30 版,最高 v30 ← 每变一次多一版,历史不丢
|
||||
```
|
||||
|
||||
### 2.2 界面版(推荐现场用,能点的地方就点)
|
||||
|
||||
| 步 | 在哪点 | 说什么 |
|
||||
|---|---|---|
|
||||
| 1 | 客户门户 → 右下角客服浮窗 → 输入"**我的投资期限是三年以内**"(任何命中记忆信号的话都行) | "客户随口说了一句自己的偏好" |
|
||||
| 2 | 管理员工作台 →「**画像候选**」标签页 | "它没有直接进档案,而是先变成一条**候选**" |
|
||||
| 3 | (客户确认这一步目前**只有接口**,见下) | "客户本人得点头" |
|
||||
| 4 | 回到「画像候选」→ 点「**批准**」 | "管理员再核一次,才进正式记忆" |
|
||||
| 5 | 跑 `python tools/probe_memory_state.py 9001` | "现在看库里:记忆 active、画像多了一版" |
|
||||
|
||||
**第 3 步的接口兜底**(客户确认目前没有页面,如实说明即可):
|
||||
|
||||
```powershell
|
||||
$c = Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/auth/tokens -Method Post `
|
||||
-Body '{"username":"cust_t","password":"123456"}' -ContentType application/json
|
||||
$h = @{ Authorization = "Bearer $($c.data.access_token)" }
|
||||
# 看自己名下待确认的候选
|
||||
Invoke-RestMethod -Uri http://127.0.0.1:8000/api/v1/users/me/memory-candidates -Headers $h |
|
||||
ConvertTo-Json -Depth 4
|
||||
# 确认(把 <候选ID> 换成上一步拿到的 candidate_id)
|
||||
Invoke-RestMethod -Uri "http://127.0.0.1:8000/api/v1/users/me/memory-candidates/<候选ID>/decisions" `
|
||||
-Method Post -Headers ($h + @{ "Idempotency-Key" = [guid]::NewGuid().ToString("N") }) `
|
||||
-Body '{"decision":"confirmed"}' -ContentType application/json
|
||||
```
|
||||
|
||||
### 2.3 这体现什么(这段一定要讲)
|
||||
|
||||
> 平台**不会**因为客户说了一句话就改档案。它分三步:
|
||||
> ① 从对话里**抽**一条候选(带置信度、带证据);
|
||||
> ② **客户本人确认**(这是他的数据);
|
||||
> ③ **管理员批准**(这是合规要求)。
|
||||
> 两把钥匙都插上,才写进正式记忆,并顺着 **记忆 → 事实 → 画像字段** 一层层更新。
|
||||
>
|
||||
> 为什么这么做:客服场景里客户是**随口说**的。让它直接进风控与投顾要读的画像,
|
||||
> 等于用一个没核过的数字去支撑决策。
|
||||
|
||||
**被追问时的三个加分点**:
|
||||
|
||||
1. **同一键只留一条生效**:客户后来说"三年以内",旧值会被置为 `invalidated`,
|
||||
并在 `memory_conflict` 留一条"谁替换了谁",不是悄悄覆盖。
|
||||
2. **风险等级只认问卷**:`investor_type`(C1–C5)**只**来自风险测评,
|
||||
记忆里哪怕有"风险偏好"也只进 `risk_tags` 并标注"**自述**"。
|
||||
所以会出现"问卷 C5 / 自述保守 / 行为买 R4"这种**三方不一致**——那本身就是风控信号。
|
||||
3. **证据门槛**:记忆要升成"事实"需要**证据 ≥2 条**或**置信度 ≥0.90**,一句话不够格就先躺着。
|
||||
|
||||
---
|
||||
|
||||
## 3. 场景三:谁能读到、谁读不到(2 min,只读,最容易被问)
|
||||
|
||||
**操作**:
|
||||
|
||||
```powershell
|
||||
python tools/memory_recall_demo.py
|
||||
```
|
||||
|
||||
**实测输出**(节选):
|
||||
|
||||
| 身份 | 库里的角色/权限/归属 | 可读客户范围 | 召回 |
|
||||
|---|---|---|---|
|
||||
| 客户本人 9001 | `customer`,`self` | `[9001]` | **3 条**(稳健型 / 低亏损容忍 / 三年以内) |
|
||||
| 另一个客户 12001 | `customer`,`self` | `[12001]` | 0 条(读的是**他自己**名下) |
|
||||
| 风控 9002 | `risk_operator`,有 `memory:read:customer`,归属 `[9001]` | `[9001]` | **3 条** |
|
||||
| 投顾 9020 | `advisor`,有权限码,归属 `[9001, 9101-9104]` | 同上 | **3 条** |
|
||||
| 运营 9006 | `operator`,**没有** `memory:read:customer` | **(空)** | 0 条 → 日志点名"缺能力码,失败关闭" |
|
||||
| 管理员 9003 | `admin`,**有**权限码,但**没有归属行** | **(空)** | 0 条 → 日志点名"没有生效的归属客户" |
|
||||
|
||||
**这体现什么**:
|
||||
|
||||
> 读谁的记忆由**一个文件**说了算(`app/core/memory_scope.py`):
|
||||
> **客户只读自己;员工必须同时满足"有能力码"和"客户分配给了我"**,缺一条就是**空**,并且日志点名原因。
|
||||
>
|
||||
> 为什么这么严:**员工号和客户号是同一号段**(演示数据里客户 9001-9020、员工 9002/9020 并存)。
|
||||
> 早年三处各自判断口径,结果一边是"员工永远读不到"(把员工号当客户号查),
|
||||
> 另一边更危险——**按号查会读到陌生客户的记忆并塞进提示词,还不报错**。
|
||||
> 金融场景最不能接受的就是这种"看起来正常"的越权。
|
||||
|
||||
> 💡 现场最漂亮的一句:**"管理员权限最大,但他也读不到这位客户的记忆 ——
|
||||
> 因为权限码解决的是'能不能读他人客户数据',归属关系解决的是'谁负责谁',两者是**与**关系。"**
|
||||
|
||||
**已知边界(被问到就照实说)**:
|
||||
|
||||
- **客服 Agent 不召回客户记忆**(`recalls_customer_memory=False`)。客服那条线走的是场景二的
|
||||
"候选"链路。所以"客服怎么不记得我上次说的话"——那是**特意关掉的**。
|
||||
- 目前**真正消费召回结果的只有风控 Agent**(把记忆拼进系统提示词)。
|
||||
- **风控的定时扫描上下文(`system` 身份)读不到记忆**:它没有归属客户,
|
||||
根因是"召回发生在拿到目标客户之前",属已登记的遗留(两条出路待定)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 场景四(备选):画像的历史版本与投影(1 min,只读)
|
||||
|
||||
```powershell
|
||||
python tools/probe_memory_state.py 9001
|
||||
```
|
||||
|
||||
**看到什么**:`profile_snapshots` 里同一个客户有**很多版**(实测 30 版),只有一版
|
||||
`is_current=1`,其余是历史。
|
||||
|
||||
**这体现什么**:
|
||||
|
||||
> 画像**只新增版本、不原地覆盖** —— 因为风控与投顾要能回答"当时是凭什么给的结论"。
|
||||
> 每次变更多一版,`generation_basis` 还逐字段记了来源(这个字段是问卷来的、那个是记忆来的)。
|
||||
|
||||
另外讲一句可观测性:记忆写入后平台会**异步**把它投影到两个派生存储
|
||||
(关系进 Neo4j、向量进 Milvus),投递台账在 `memory_sync_outbox`。
|
||||
**投影失败不会假装成功**:`status` 会停在 `failed`/`dead` 并带 `last_error`,
|
||||
Milvus 没配时事件**保持 pending**(而不是标成"已同步")。
|
||||
|
||||
---
|
||||
|
||||
## 5. 场景五(备选):把 Worker 停掉会怎样(1.5 min,风险低但很能说明问题)
|
||||
|
||||
| 步 | 操作 | 看到什么 |
|
||||
|---|---|---|
|
||||
| 1 | 关掉 Worker 窗口 | 页面一切正常(**这就是坑**) |
|
||||
| 2 | 客户再问一句客服 | 一直"客服繁忙/超时";**服务端没有任何报错** |
|
||||
| 3 | 查 `agent_run` 最新一行 | `status='queued'`、`worker_id` 为空 |
|
||||
| 4 | 重新起 Worker(`python -m app.worker`) | 几秒内自己追上,队列清空 |
|
||||
|
||||
**这体现什么**:Agent 与记忆都是"**受理 → 排队 → Worker 执行**"的异步三段式。
|
||||
**停了不会有报错,只会静默不动** —— 所以"看板正常但什么都没发生"时,第一个要查的就是 Worker。
|
||||
|
||||
---
|
||||
|
||||
## 6. 建议的演示顺序与时长
|
||||
|
||||
| 序 | 场景 | 时长 | 目的 |
|
||||
|---|---|---|---|
|
||||
| 1 | 场景一:探针看"存在哪" | 1.5 min | 先把"不玄"立住 |
|
||||
| 2 | **场景二:记住一件事**(界面版 + 命令兜底) | 3 min | **主线**,最能打 |
|
||||
| 3 | 场景三:谁能读谁读不到 | 2 min | 讲清权限与安全 |
|
||||
| 4 | 场景四:画像版本与投影 | 1 min | 讲可追溯与可观测 |
|
||||
| 5 | 场景五(时间够再演):停 Worker | 1.5 min | 讲异步与排障 |
|
||||
|
||||
合计约 **9 分钟**(含答疑 15 分钟)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可能被问到的问题(建议话术)
|
||||
|
||||
**Q:记忆存在哪里?会不会丢?**
|
||||
A:**值在 MySQL**(记忆本体、证据、画像版本,唯一权威);**关系在 Neo4j、向量在 Milvus**,
|
||||
这两份是**可以重建的副本**。副本不一致时以 MySQL 为准。
|
||||
|
||||
**Q:模型会不会自己乱记?**
|
||||
A:不会。① 只抽**受控键**(风险偏好、投资期限、流动性约束……词表在
|
||||
`app/service/memory_taxonomy.py`);② 每条记忆**带证据**(能追回原话);③ 要升成"事实"
|
||||
需**证据 ≥2 条或置信度 ≥0.90**;④ 客服这类"随口说"的必须**客户确认 + 管理员批准**才进正式档案。
|
||||
|
||||
**Q:为什么客服聊天不写长期记忆?**
|
||||
A:客服链路走的是**候选画像**(两把钥匙),不直接写。要让客服也参与召回,
|
||||
得先解决"召回发生在目标客户确定之前"这个已登记的遗留。
|
||||
|
||||
**Q:员工能看到客户的记忆吗?**
|
||||
A:能看到的必须**同时**满足两条:有 `memory:read:customer` 权限码 **且** 这位客户分配给他。
|
||||
缺一条读到的就是空,而且日志会点名原因("缺能力码"还是"归属未维护")。
|
||||
**管理员权限最大也不例外** —— 归属表里没有就不是他的客户。
|
||||
|
||||
**Q:记忆会过期吗?**
|
||||
A:会。记忆有 `valid_until`,召回时只取未过期的,并按 `置信度 × exp(-距今天数/365)` 衰减排序。
|
||||
|
||||
**Q:客户要求删掉记忆怎么办?**
|
||||
A:业务侧只写一条 `memory.deletion_requested` 事件,由 Worker 级联:记忆失效/删除 → 删证据 →
|
||||
**按记忆 id 通知两个派生存储清理** → 写审计 → 清召回热缓存。
|
||||
**清不掉就如实留痕**(审计里记 `skipped` + 原因),不假装删了。
|
||||
⚠️ 当前**单条记忆失效**(非客户级联)不清理投影,属已知不对称。
|
||||
|
||||
**Q:改了记忆,画像会立刻变吗?**
|
||||
A:会,但路径是**异步**的:记忆 → `user_facts`(事实层)→ 画像字段 → 新画像版本,
|
||||
由 Worker 消费 `profile.rebuild_requested` 事件完成,实测**约 2 秒**。
|
||||
(这条是 2026-09-14 补的:此前**候选批准这条链**不发重建事件,画像字段会停在旧值。)
|
||||
|
||||
**Q:演示会不会改坏数据?**
|
||||
A:会**真的写入**(记忆、事实、画像版本各加一条)。想隔离就用另一个客户号重跑;
|
||||
演示完不用清理——历史版本本来就是设计的一部分(画像只新增、不覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 演示前自检(30 秒)
|
||||
|
||||
```powershell
|
||||
python tools/probe_memory_state.py 9001 # 表行数、事件是否堆在 pending
|
||||
python tools/memory_recall_demo.py # 六个身份的召回结果是否如表格所示
|
||||
```
|
||||
|
||||
两条都正常再看一遍这张清单:
|
||||
|
||||
- [ ] API **和 Worker** 都在跑(两个窗口都在)
|
||||
- [ ] `domain_event_outbox` 里没有大堆 `pending`(有 = Worker 没在消费)
|
||||
- [ ] 客户 `cust_t` 能登录,客户门户能打开浮窗
|
||||
- [ ] 管理员能登录,能打开「画像候选」标签页
|
||||
- [ ] 演示用的那句话**命中记忆信号**(含"投资期限 / 风险偏好 / 流动性 / 养老"等词,见 §9)
|
||||
|
||||
---
|
||||
|
||||
## 9. 附:哪些话会被记住(受控信号词表摘录)
|
||||
|
||||
来自 `app/service/memory_taxonomy.py`(**不是随便一句都会被记**):
|
||||
|
||||
| 记忆键 | 命中示例词 |
|
||||
|---|---|
|
||||
| `preference:risk_level` | 风险偏好、风险承受、稳健型、保守型、激进型 |
|
||||
| `preference:horizon` | 投资期限、投资周期、长期持有、短期投资 |
|
||||
| `preference:product_type` | 偏好股票/债券/货币/指数、只买、只投 |
|
||||
| `preference:communication` | 沟通方式、联系我、推送、短信通知 |
|
||||
| `constraint:liquidity` | 流动性、随时赎回、急用钱、不能锁定 |
|
||||
| `constraint:loss_tolerance` | 不能亏、怕亏、最大回撤、不能接受亏损 |
|
||||
| `constraint:exclusion` | 不买、不投、别推荐、禁止 |
|
||||
| `profile:occupation` / `profile:family` | 我的职业、已婚、有孩子、赡养 |
|
||||
| `goal:target` / `goal:retirement` | 我的目标是、攒够、买房、养老、退休 |
|
||||
|
||||
> 演示时挑一句**客户口吻**的话(如"我的投资期限是三年以内,不着急用钱"),
|
||||
> 比念术语自然,也更容易命中。
|
||||
Reference in New Issue
Block a user