## 现象(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 会不会自己中途退出"。
18 KiB
记忆系统演示文档(照着演)
读者:负责演示的人。本文按"操作 → 看到什么 → 这体现什么"三段式写,可以直接照着念。 核心思路:记忆系统最怕"讲得很玄、看不到东西"。所以这里每个场景都配一条可复现的命令, 把"库里真有什么"摆出来 —— 能自证,才叫演示。 配套:原理看
记忆架构与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 这体现什么(这段一定要讲)
平台不会因为客户说了一句话就改档案。它分三步: ① 从对话里抽一条候选(带置信度、带证据); ② 客户本人确认(这是他的数据); ③ 管理员批准(这是合规要求)。 两把钥匙都插上,才写进正式记忆,并顺着 记忆 → 事实 → 画像字段 一层层更新。
为什么这么做:客服场景里客户是随口说的。让它直接进风控与投顾要读的画像, 等于用一个没核过的数字去支撑决策。
被追问时的三个加分点:
- 同一键只留一条生效:客户后来说"三年以内",旧值会被置为
invalidated, 并在memory_conflict留一条"谁替换了谁",不是悄悄覆盖。 - 风险等级只认问卷:
investor_type(C1–C5)只来自风险测评, 记忆里哪怕有"风险偏好"也只进risk_tags并标注"自述"。 所以会出现"问卷 C5 / 自述保守 / 行为买 R4"这种三方不一致——那本身就是风控信号。 - 证据门槛:记忆要升成"事实"需要证据 ≥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里 IMAPlogout()又把真正的退出原因盖掉了。现在:续租失败只放弃当前这一批,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 |
我的目标是、攒够、买房、养老、退休 |
演示时挑一句客户口吻的话(如"我的投资期限是三年以内,不着急用钱"), 比念术语自然,也更容易命中。