Files
group_fqcd_jr/docs/演示用/记忆系统演示文档-2026-09-14.md
T
lzf_0626 01e4e6a687 修掉"Worker 会自己退出"的真缺陷(场外游标续租失败误取消主任务)
## 现象(2026-09-14 实测,非人为停止)
Worker 进程自己退出,退出码 1,日志末尾是
`ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接`
(发生在 `offsite_worker.close()` 的 IMAP `logout()`),
而真正的起点是更上面那句 `asyncio.exceptions.CancelledError`。

## 根因(两处,都是真缺陷)
1. **取消错了对象**:`OffsiteMailWorker._process_batch` 把
   `asyncio.current_task()`(= `__main__.serve()` 的**主循环任务**)交给游标心跳,
   心跳在续租失败(`rowcount != 1`)或续租抛异常时执行 `task.cancel()` ——
   于是"放弃这一批"变成了"**杀掉整个 Worker**"。`CancelledError` 从 `run_once()`
   一路冒到 `serve()`,主循环直接结束。
2. **收尾异常盖掉退出原因**:`serve()` 的 `finally` 里 `await offsite_worker.close()`
   在网络已断时抛 `ConnectionResetError`,把 `CancelledError` 顶掉,
   表现为"关闭流程崩了",看不出真实原因。

## 改法
- `_process_batch` 把"这一批"跑在**独立任务** `batch` 里,心跳只取消 `batch`;
  调用方捕获 `CancelledError` 后区分两种情况:
  **本批被放弃**(`batch.cancelled()` 且当前任务自己没有在取消)→ 记 warning、返回 `False`、
  Worker 继续下一轮;**外层在取消当前任务**(Ctrl+C / 进程关闭)→ 原样上抛,绝不吞掉。
- `serve()` 的 `finally` 里关闭场外 Worker 包 try/except:收尾失败只记日志,
  **不改变退出码与退出原因**。
- 新增 `_current_task_is_cancelling()` 用 `Task.cancelling()` 做这个区分(3.11+)。

## 守卫
`tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_worker`
—— 续租失败(rowcount=0)时断言:批次确实跑起来过、返回 `False`、
**调用方任务没有被取消**。修复前这条用例会挂在"调用方被取消"上。

## 验证
- `pytest tests/unit/worker` → 112 passed
- `pytest tests/unit tests/contract` → 见下方(0 failed)
- `mypy app/worker/offsite_mail_worker.py app/worker/__main__.py` → 0 错(除组员文件里既有的 1 个)
- 重启 Worker 后跑记忆演示链路:候选 → verified → active → **2 秒**收敛,进程稳定

## 文档
`docs/演示用/记忆系统演示文档-2026-09-14.md`:
场景五补"如果发现 Worker 自己退了是怎么回事",问答补"Worker 会不会自己中途退出"。
2026-09-15 08:01:00 +08:00

18 KiB
Raw Blame History

记忆系统演示文档(照着演)

读者:负责演示的人。本文按"操作 → 看到什么 → 这体现什么"三段式写,可以直接照着念。 核心思路:记忆系统最怕"讲得很玄、看不到东西"。所以这里每个场景都配一条可复现的命令, 把"库里真有什么"摆出来 —— 能自证,才叫演示。 配套:原理看 记忆架构与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 三条命令(演示时要用到的全部工具)

# ① 一眼看清"记忆链路现在是什么样"(只读,不写数据)
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,只读)

操作:跑探针。

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 秒,一条命令)

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 步的接口兜底(客户确认目前没有页面,如实说明即可):

$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,只读,最容易被问)

操作:

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,只读)

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。

⚠️ 如果发现 Worker 自己退了(窗口关了 / 退出码 1 / 日志最后一行是 ConnectionResetError [WinError 10054] 这种 IMAP 连接被重置),那是踩到了 2026-09-14 修掉的一个真缺陷:场外收件的游标续租失败时,心跳取消的是主循环任务 (而不是"这一批"),于是整个 Worker 被取消、退出时 finally 里 IMAP logout() 又把真正的退出原因盖掉了。现在:续租失败只放弃当前这一批,Worker 继续跑; 关闭阶段的异常只记日志、不改变退出码。 回归守卫:tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_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:会真的写入(记忆、事实、画像版本各加一条)。想隔离就用另一个客户号重跑; 演示完不用清理——历史版本本来就是设计的一部分(画像只新增、不覆盖)。

Q:Worker 会不会自己中途退出? A:正常不会;曾经会 —— 场外收件的游标续租失败时,心跳误把主循环任务取消了, 整个 Worker 就退了(退出码 1,日志末尾是 IMAP 连接被重置)。2026-09-14 已修: 续租失败只放弃当前这一批。演示前跑一次 §8 自检,看到两个进程都在就没问题。


8. 演示前自检(30 秒)

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 我的目标是、攒够、买房、养老、退休

演示时挑一句客户口吻的话(如"我的投资期限是三年以内,不着急用钱"), 比念术语自然,也更容易命中。