补齐"记忆系统为画像服务"的断链,按 docs/23 的分层设计实现后三层。
1. 新增 app/model/profile.py:user_facts 与 profile_snapshots 的 ORM 映射。此前这两张表
只有结构、没有 Model,实际没有任何代码在用。两处表结构特例在 docstring 里显式标注,
避免后续有人按直觉写入踩坑:
· user_facts.id 无 auto_increment,主键必须由应用提供(本实现用微秒时间戳,单调递增);
· profile_snapshots.current_customer_id 是生成列(IF(is_current=1, customer_id, NULL)),
故意不映射——映射了反而会在写入时与之冲突。
2. 新增 app/service/profile_assembly_service.py,三段职责:
· 事实提升(中期→长期):evidence_count ≥ 2 或 confidence ≥ 0.90 才从 memory_unit
提炼进 user_facts —— 这条门槛就是"客户随口一说不能变成画像结论"的落地方式;
· 画像组装(长期→画像):按白名单映射进 fin_customer_profile,未列入白名单的事实
(如 profile:family)只进 user_facts,保证画像的信噪比;
· 版本留痕:每次重建写一条 profile_snapshots,generation_basis 逐字段记录来源,
用于回答"当时凭什么这么判断"。
3. 新增 tools/rebuild_profile.py:手工触发入口(单客户或 --all)。画像暂无自动触发,
这是目前唯一的重建方式,也便于排查"画像为什么没更新"。
红线由代码保证而非约定:investor_type 只从 fin_risk_assessment 最新一条读取,实现中
不存在任何记忆路径能写它。实测——客户 9001 问卷为 C2、对话自述"稳健型",重建后
investor_type 仍为 C2,自述信息进入 risk_tags 并标注"自述:"前缀。三方不一致保持可见,
但等级判定只认问卷,客户无法靠对话改变自己的可购范围。
另一处由实测修正的设计:fin_customer_profile 的 trade_account/real_name/total_asset/
behavior_score 均为 NOT NULL,说明画像行由开户流程创建(也印证了"注册时填问卷"是开户
前置条件)。原先"首次重建时创建画像行"的做法是错的——会写出一条假的开户记录,而画像
恰恰是风控要读的数据。已改为只更新已存在的画像,未开户时返回 reason=profile_row_not_opened
并如实报告,而不是静默成功。
同时新增 docs/23-记忆分层与画像设计.md:短期/中期/长期/画像四层各自存在哪里、谁写、
提升门槛、是否进画像,以及三条路径(问卷/行为/对话)在画像层汇合的设计。
验证:ruff 通过、mypy 109 文件无错;tools/rebuild_profile.py 对客户 9001 连续两次重建
产生 version=1/2 两条快照且 is_current 正确轮转(旧版本置 0)。
8.3 KiB
8.3 KiB
记忆分层与画像设计
目的:讲清短期/中期/长期/画像四层各自存在哪里、谁写、什么条件下向上提升、是否进画像。 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
0. 一句话
短期(会话上下文)→ 中期(候选事实)→ 长期(稳定事实)→ 画像(决策依据)
逐层提高门槛,是为了让"随口一说"永远变不成画像结论; 三条路径(问卷/行为/对话)在画像层汇合,但按字段划分所有权,不互相覆盖。
1. 总览
| 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 |
|---|---|---|---|---|---|
| 短期 | Redis 会话列表(设计);conversation_message 表(现状只有存档) |
当前会话最近的对话轮次 | 每次 run 追加 | — | ❌ 不进 |
| 中期 | memory_unit + memory_evidence |
从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | evidence_count ≥ 2 或 confidence ≥ 0.90 |
✅ 过门槛后提升 |
| 长期 | user_facts(is_critical 标关键事实) |
已确认的稳定事实 | 由中期提升 | 由 user_facts 判定 |
✅ 唯一用途就是喂画像 |
| 画像 | fin_customer_profile(当前)+ profile_snapshots(版本化,带 generation_basis) |
整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 |
2. 三条路径在画像汇合
这是整套设计的核心结构。只有对话路径需要累积证据,另外两条是权威/客观数据,直接写:
对话消息 ──→ [短期] 会话上下文 ──→ Agent 用它理解指代("它""那个")
│
└─(run 完成,发 memory.extraction_requested 事件)
↓
[中期] memory_unit + memory_evidence
│ 证据累积过门槛
↓
[长期] user_facts
│
│ ┌── 问卷测评(权威)────┐
└──组装──────┤ ├──→ [画像]
└── 行为/流水(客观)──┘
│
投顾 / 风控 / 客服
为什么问卷和行为不需要走前三层:它们是权威(问卷由客户答题、系统评分)与客观(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。
3. 各层细节
3.1 短期:会话上下文
- 装什么:当前会话最近若干轮对话,用于解析指代与保持连贯。
- 怎么工作:run 开始时按
session_id读出历史 → 与当前消息一起交给意图分类与回答生成;run 结束时把本轮追加进去。 - 生命周期:会话结束或长时间无活动即失效(方案 §2.2 给的是 TTL 30min、最长 24h、超 4096 token 截断旧消息)。
- 为什么不进画像:会话上下文是"他刚才说了什么",不是"他是什么样的人"。把临时对话当画像会造成画像抖动。
- 实现现状:❌ 未实现。
conversation_message表存了全部消息、svc_conversation_session.message_count只在计数,但没有任何"加载最近 N 条"的代码,run 时只拿到当前这一条消息。后果是多轮指代无法解析(问"那它风险高吗"无法知道"它"指谁)。
3.2 中期:候选事实
- 装什么:从对话里抽出的事实(风险偏好自述、投资期限、家庭情况、资产类别偏好等),每条都带证据。
- 怎么工作:
- 写入:run 完成 → 发布
memory.extraction_requested→ Worker 消费事件 → 调模型抽取 → 写memory_unit,同时写一条memory_evidence(含原文摘录,可回溯"这句话从哪来")。 - 读取:run 开始时
recall_memory自动召回,recall_count累加。 - 演进:同一事实再次出现 →
evidence_count增加;出现相反证据 →conflict_count增加(不直接覆盖,留冲突待判)。
- 写入:run 完成 → 发布
- 生命周期:
valid_from/valid_until;过期或长期无新证据即降级/失效(memory_lifecycle_service负责级联失效)。 - 进画像的条件:
evidence_count ≥ 2或confidence ≥ 0.90—— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。 - 实现现状:✅ 已实现并可验证。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出
preference:risk_level = 稳健型(置信 0.95)+ 1 条证据。 - ⚠️ 运维前提:抽取依赖常驻 Worker 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在
pending,记忆永远不产生。
3.3 长期:稳定事实
- 装什么:经过证据累积确认的稳定事实,
is_critical标记其中对决策关键的那些(如风险偏好、投资期限)。 - 怎么工作:只由中期提升而来(不直接从对话写);
source_portal记录事实来自哪个入口,source_episode_id记录来自哪个会话片段。 - 为什么不直接从对话写:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。
- 进画像:✅ 它的存在意义就是喂画像。
- 实现现状:❌ 表在(9 列),无生成代码,当前 0 行。
3.4 画像:决策依据
-
装什么:整合三路来源后的客户视图,是投顾、风控、客服共同读取的唯一客户数据入口。
-
怎么工作:画像组装服务按字段所有权写入 →
fin_customer_profile保存当前值 → 每次重建写一条profile_snapshots(带version、generation_basis、snapshot_hash、is_current),从而每次变化都可追溯。 -
字段所有权(关键:按字段切开,避免互相覆盖):
字段 唯一写入方 性质 investor_type(C1–C5)只有问卷测评 权威、合规硬约束 total_asset、trading_frequency、behavior_score交易/流水侧 客观行为 preferred_asset_class、investment_horizon、risk_tags记忆系统(经长期层) 软信息、需累积 real_name、birth_date、occupation、mobile_masked、opened_at注册/账户流程 身份 -
客户自述风险偏好放哪:放
risk_tags并标注"客户自述",不得写入investor_type。 保留它的价值在于:当出现「问卷 C4 / 自述稳健 / 行为买 R4」三方不一致时,这种矛盾本身就是风控信号。 -
实现现状:❌ 两张表都在,无组装代码,当前 0 行。
4. 红线(必须由代码保证,不能只靠约定)
- 适当性判定只认问卷。
investor_type只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。 - 风控与投顾只读画像,不直接读
memory_unit。否则同一条记忆会被多处按各自口径解释。 - 每条画像字段都要能回答"凭什么"。写入时通过
generation_basis记录依据来源。 - 画像变更留版本,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。
- 内部资料不进入面向客户的知识库(已按此处理:反洗钱手册
visibility=internal)。
5. 现状与缺口一览
| 环节 | 状态 |
|---|---|
| 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) |
| 中期 → 长期(证据累积与门槛) | ❌ 待实现 |
| 长期 → 画像(组装 + 版本快照) | ❌ 待实现 |
| 问卷 → 画像 | ❌ 待实现(注册问卷流程未做) |
| 行为 → 画像 | ❌ 待实现(交易模块未做) |
| 短期会话上下文 | ❌ 待实现(影响多轮指代) |
| 画像 → 投顾 / 风控 | ❌ 待实现 |
建议顺序:先做「中期 → 长期 → 画像」这一段(现在就有记忆数据可验证),再接问卷与行为两路,最后接下游消费者。