# 记忆分层与画像设计 > 目的:讲清短期/中期/长期/画像四层**各自存在哪里、谁写、什么条件下向上提升、是否进画像**。 > 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。 > **状态口径(2026-09-14 修订)**:本文原 §5「现状与缺口一览」把 5 个环节标为 ❌, > 但代码侧已完成,该表**已作废并重写**。判定依据是**源码实际装配**,不是文档承诺: > `app/service/profile_assembly_service.py`、`app/worker/runtime.py`、 > `app/service/relationship_service.py`、`app/service/memory_lifecycle_service.py`。 > 下文凡标 ✅ 者均注明承载文件,可逐条复核;标 ❌ 者为**确认仍缺**的部分。 ## 0. 一句话 ``` 短期(会话上下文)→ 中期(候选事实)→ 长期(稳定事实)→ 画像(决策依据) ``` **逐层提高门槛**,是为了让"随口一说"永远变不成画像结论; **三条路径(问卷/行为/对话)在画像层汇合**,但按字段划分所有权,不互相覆盖。 ## 1. 总览 | 层 | 存在哪里 | 装什么 | 谁写 | 提升门槛 | 进画像 | | --- | --- | --- | --- | --- | --- | | **短期** | `conversation_message` 表(**唯一来源**,不引入 Redis 双写) | 当前会话最近 10 轮对话 | run 受理时落库;读取只读不写 | — | ❌ 不进 | | **中期** | `memory_unit` + `memory_evidence` | 从对话抽出的候选事实,带证据链 | Worker 记忆抽取 | `evidence_count ≥ 2` 或 `confidence ≥ 0.90` | ✅ 过门槛后提升 | | **长期** | `user_facts`(`is_critical` 标关键事实) | 已确认的稳定事实 | 由中期提升(`promote_facts`) | 由 `user_facts` 判定 | ✅ 唯一用途就是喂画像 | | **画像** | `fin_customer_profile`(当前)+ `profile_snapshots`(版本化,带 `generation_basis`);**关系侧另有 Neo4j 派生投影** | 整合三路来源的客户视图 | 画像组装服务 | — | 它就是画像 | > **两库分工**(原文档未写):画像的**值**在 MySQL(唯一真相),画像的**关系**在 Neo4j > (单向、幂等、可重建的派生视图)。见 §3.5。 ## 2. 三条路径在画像汇合 这是整套设计的核心结构。**只有对话路径需要累积证据**,另外两条是权威/客观数据,直接写: ``` 对话消息 ──→ [短期] 会话上下文(conversation_message 最近 10 轮) │ └─ 以 history= 参数送入 AgentRequest │ └─(run 完成,发 memory.extraction_requested 事件) ↓ [中期] memory_unit + memory_evidence │ ↑ │ └─ 每轮 run 开始由 recall_memory() 召回 │ (Redis 热缓存 → MySQL 结构化 + Milvus 向量,双通道合并) │ 证据累积过门槛(evidence ≥ 2 或 confidence ≥ 0.90) ↓ [长期] user_facts │ │ ┌── 问卷测评(权威)────┐ └──组装──────┤ ├──→ [画像] fin_customer_profile └── 行为/流水(客观)──┘ + profile_snapshots(版本) │ ┌─────────┴─────────┐ ↓ ↓ [投影] Neo4j 关系图 投顾 / 风控 / 客服 (单向、幂等、可降级; 读侧走 RelationshipService) ``` **为什么问卷和行为不需要走前三层**:它们是**权威**(问卷由客户答题、系统评分)与**客观**(交易记录无法自称)数据,不需要"多次出现才可信"这层保护;对话自述才有"记错、修饰、被引导"的风险。 **图的定位**:它不存画像的值,只存**画像之上的关系**(客户偏好的标签、持有的产品)。 存在理由是投顾要靠"买过 A 的客户还买过什么"这类多跳关系做组合推荐、风控要靠关系网络 发现关联账户——这些查询用关系库写既别扭又慢。详见 §3.5。 ## 3. 各层细节 ### 3.1 短期:会话上下文 - **装什么**:当前会话最近 **10 轮**对话,旧 → 新排列,用于解析指代与保持连贯。 - **怎么工作**:run 执行时由 `WorkerRuntime._conversation_history()` 读出历史 → 组装成 `ConversationTurn` 元组 → 作为 `history=` 一并交给 `AgentRequest`。 - **存储选择**:以 `conversation_message` 表为**唯一来源**,**刻意不做 Redis 双写**。 | 方案 | 代价 | | --- | --- | | MySQL 单读(现状) | 一次索引查询 | | MySQL + Redis 双写 | 换来省下这一次查询,付出**不一致风险 + TTL 管理成本** | 消息在受理时已经落库,再同步一份到 Redis 的收益抵不上代价。方案 §2.2 设想的是 Redis 列表,实现取**等价语义**(同样"最近若干轮、超出即截断")而不复制存储。 - **两个边界细节**: - `before_message_id` **排除本轮请求消息本身**。它刚写入库,若也算进历史,模型会在 上下文里看到自己的问题被重复一遍。 - 截断按**条数**而非 token。这里没有与模型一致的分词器,按 token 截断只能靠估算、 边界会随实现漂移;按条数是确定性的——**宁可少给几轮,也不给一个不稳定的边界**。 - **为什么不进画像**:会话上下文是"他刚才说了什么",不是"他是什么样的人"。 把临时对话当画像会造成画像抖动。 - **实现现状**:✅ **已实现**,承载于 `app/worker/runtime.py::_conversation_history()`。 (原文档记为"❌ 未实现"——已过期。) - **⚠️ 与公共召回的区别**:`BaseAgent.recall_memory()` 里有明确注释—— > 公共召回是长期/画像记忆,**不是客服二期的会话短期上下文**;定义未授权时不得读取。 即两层走的是**两个不同入口**:短期走 `history=` 参数,中期走 `recall_memory()`。 且定义未开 `recalls_customer_memory`、或角色含 `visitor` 时,短期层直接不读。 ### 3.2 中期:候选事实 - **装什么**:从对话里抽出的事实(风险偏好自述、投资期限、家庭情况、资产类别偏好等),每条都带证据。 - **怎么工作**: - 写入:run 完成 → 发布 `memory.extraction_requested` → **Worker 消费事件** → 调模型抽取 → 写 `memory_unit`,同时写一条 `memory_evidence`(含原文摘录,可回溯"这句话从哪来")。 - 读取:run 开始时 `recall_memory` 自动召回,`recall_count` 累加。 - 演进:同一事实再次出现 → `evidence_count` 增加;出现相反证据 → `conflict_count` 增加(**不直接覆盖**,留冲突待判)。 - **生命周期**:`valid_from` / `valid_until`;过期或长期无新证据即降级/失效(`memory_lifecycle_service` 负责级联失效)。 - **进画像的条件**:`evidence_count ≥ 2` **或** `confidence ≥ 0.90` —— 这条门槛就是"一次性说法不该成为画像结论"的落地方式。 - **什么情况下才触发抽取**(原文档未写,但直接影响"为什么我说话没被记住"): `WorkerRuntime.should_request_memory_extraction()` 先做**前置过滤**,任一不满足即整条链不启动: | 条件 | 说明 | | --- | --- | | `agent_type != "customer_service"` | 客服 Agent **不写长期记忆**(它走画像候选那条独立路径) | | `"visitor" not in context.roles` | 访客不写 | | `should_extract_memory(...)` 为真 | 见下 | 而 `should_extract_memory()` 的三个触发条件是**任一命中**即可: - 命中受控信号词(`detect_memory_signals()`) - 本轮有成功的工具调用(`tool_result=True`) - 本轮产生了业务事件(`event_type ∈ BUSINESS_EVENT_TYPES`) **注意:句长不是门槛**。"只买货币基金"(3 字,明确)必须触发;正常的长问答不应触发。 - **实现现状**:✅ **已实现并可验证**。实测:客户说"我的风险偏好是稳健型,平时只买债券基金" → 抽出 `preference:risk_level = 稳健型`(置信 0.95)+ 1 条证据。 - **⚠️ 运维前提**:抽取依赖**常驻 Worker** 消费 Outbox 事件。只起 API 服务不起 Worker,事件会一直堆在 `pending`,记忆永远不产生。 - **⚠️ 幂等边界**:证据的幂等键是 `{event_type}:{event_id}`;重复消费返回 False 且**不产生第二条证据**。 ### 3.3 长期:稳定事实 - **装什么**:经过证据累积确认的稳定事实,`is_critical` 标记其中对决策关键的那些(如风险偏好、投资期限)。 - **怎么工作**:只由中期提升而来(不直接从对话写);`source_portal` 记录事实来自哪个入口,`source_episode_id` 记录来自哪个会话片段。 - **为什么不直接从对话写**:长期层是画像的输入,必须保证"这条结论经过验证",否则画像会被一次性说法污染。 - **进画像**:✅ 它的存在意义就是喂画像。 - **提升实现细节**(`ProfileAssemblyService.promote_facts()`): - 只取 `status='active'` 且未过 `valid_until` 的记忆; - 值优先取 `structured_value`(结构化值),回退到正文 `content`; - `is_critical` 由 `CRITICAL_FACTS` 决定,当前是 `{preference:risk_level, preference:horizon, preference:asset_class}` 三个。 - **两个必须知道的表约束**(否则容易写出线上故障): 1. **`user_facts.id` 没有 `auto_increment`**,主键由应用侧 `_fact_id()` 生成 (微秒时间戳:单调递增、无需额外序列)。 2. **该表没有 `(customer_id, fact_key)` 唯一键**(只有两个普通索引), 所以"每个键一条事实"必须靠服务层**先查后写**保证,**不能指望数据库约束**。 这也是 `promote_facts()` 里先 `select` 再决定 insert/update 的原因。 - **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::promote_facts()`。 (原文档记为"❌ 表在,无生成代码,当前 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」三方不一致时,**这种矛盾本身就是风控信号**。 实现上由 `SELF_REPORTED_PREFIXES = ("preference:risk_level", "preference:", "profile:")` 判定, 统一渲染成 `自述:{key}={value}`、以「;」连接写入 `risk_tags`。 - **本服务只拥有 4 个字段**:`PROFILE_OWNED_FIELDS = (investor_type, preferred_asset_class, investment_horizon, risk_tags)`。 其余字段归交易/注册侧所有,本服务**不写**——注释写明"避免两个模块抢写同一列"。 - **事实进画像有白名单**:`FACT_TO_PROFILE_FIELD` 只有 **2 条**: | 事实键 | 画像字段 | | --- | --- | | `preference:asset_class` | `preferred_asset_class` | | `preference:horizon` | `investment_horizon` | 其余事实(例如 `profile:family`)**只进 `user_facts`,不进画像字段**—— 画像要保持"能直接支撑决策"的信噪比。 - **两条不对称的更新规则**(`rebuild_profile()`,容易看漏):`investor_type` 本轮**没查到问卷就保持原值** (既不写也不清)——否则重测前的空档会把开户时的等级抹掉,而该列是 `NOT NULL`;其余 3 个自有字段 **本轮没有对应事实就清空**——否则记忆失效后画像会留着一个已作废的投资期限,投顾据此给建议, 而客户从未授权这条信息继续生效(**实测踩到过**)。 - **画像行不存在时不代建**:返回 `{"profile": None, "reason": "profile_row_not_opened"}`。 `trade_account` 等身份字段是 `NOT NULL` 且属注册/账户侧所有,代建会写出一条**假的开户记录**—— 注释的原话是"画像恰恰是风控要读的东西,**假数据比没有数据更危险**"。事实已提升进 `user_facts`, 开户后再重建即可。 - **快照写入顺序不能反**:唯一键 `uk_profile_snapshot_current` 建在 `current_customer_id` 上, 保证"每客户最多一条 current",所以必须**先 flush 旧版本 `is_current=False`,再写新版本**。 版本号取 `COALESCE(MAX(version),0)+1`,`snapshot_hash` 是 payload(`sort_keys=True`)的 sha256。 - **实现现状**:✅ **已实现**。承载于 `app/service/profile_assembly_service.py::rebuild_profile()`。 (原文档记为"❌ 两张表都在,无组装代码,当前 0 行"——已过期。) ### 3.5 画像的第二存储:Neo4j 关系投影 ⚠️ **原文档完全漏了这一层**。画像不是只存 MySQL:**值在 MySQL,关系在 Neo4j**。 两者的地位**不是并列的**: | | MySQL | Neo4j | | --- | --- | --- | | 存什么 | **字段的值**(`investor_type='C4'`) | **客户与外部实体的关系**(偏好标签、持仓产品) | | 地位 | **唯一真相** | **派生视图**(derived projection) | | 丢了会怎样 | 灾难 | **可直接重建**(拿同一份画像重投影) | | 与 MySQL 事务一致 | — | **不需要,也刻意不做** | **节点(5 种,`graph_model.py::NODE_SPECS`)**: | 类型名 | 标签 | 主属性 | | --- | --- | --- | | `customer` | `Customer` | `customer_id`(int) | | `product` | `Product` | `product_code` | | `tag` | `Tag` | `tag_key` | | `industry` | `Industry` | `category` | | `event` | `Event` | `event_id` | 未登记的类型调 `node_spec()` 直接抛 `ValueError`——注释:"宁可失败也不拼出任意标签"。 **关系(8 种,`RELATION_SEMANTICS` 记录"谁指向谁")**: ``` PREFERS 客户 → 标签 HAS_GOAL 客户 → 标签 INTERESTED_IN 客户 → 产品 | 行业 TRADED 客户 → 产品 HOLDS 客户 → 产品 TRIGGERED_RISK 客户 → 事件 BELONGS_TO_CATEGORY 产品 → 标签 EXPOSED_TO_INDUSTRY 产品|客户 → 行业 ``` 底座本只有关系名白名单,这张表补的是**语义约束**(拒绝把 `TRADED` 写成客户→标签这类无意义的边)。 **实际会被投影的内容**——`FACT_PROJECTION` 只有 4 条,13 个受控记忆键里**只有这 4 个进图**: ``` preference:risk_level → PREFERS preference:asset_class → PREFERS preference:horizon → HAS_GOAL profile:family → PREFERS ``` 外加持仓 `HOLDS`(来自 `fin_holding JOIN fin_product`,上限 50 条)。 `constraint:*` 与其余 `profile:*` **不进图**——它们是结构化约束与属性,该走结构化通道查, 放进图只增加召回噪音。整客上限 `MAX_EDGES_PER_CUSTOMER = 200`。 **四条设计约束**(`profile_graph_projection_service.py` docstring): 1. **单向投影** —— 图可随时重建,不要求与 MySQL 事务一致。 2. **幂等** —— 全部 `MERGE`,重复投影不产生重复节点/关系。 3. **降级** —— 图库不可用时返回 `degraded`,**绝不把异常抛给画像重建主链路**。 原文:"投顾推荐可以暂时没有图,但不能因为图挂了就让画像更新失败。" 4. **只投影"已确认"的事实** —— 数据源是 `user_facts`,**不直接读 `memory_unit`**。 原文:"保证图里的偏好标签与画像口径一致;否则同一件事在画像和图里会有两种说法。" 这条不是洁癖:仓库里**曾真的有两套图投影并存**(同事分支按客户各建私有 `Preference`/`Goal` 节点、数据源是 `memory_unit`;主干写共享 `Tag` 节点、数据源是 `user_facts`)。2026-09-12 合并时取舍为**方案 A:只保留主干服务**, `app/infrastructure/neo4j_profile_projection.py` 现已**未被生产装配**(文件头有显式 warning)。 **触发链**(与画像重建同源): ``` memory_unit 落库(同事务写 profile.rebuild_requested) → Worker 消费 ├─ ProfileAssemblyService.rebuild() → user_facts + 画像 + 快照 └─ ProfileGraphProjectionService → Neo4j 图 另有 memory_sync_outbox 的 neo4j 分支 ⤴ 幂等复投,仅用于收敛投递状态 ``` **为什么必须绕事件而不能直接调**(`runtime.py::dispatch_profile_rebuild` 注释): > 抽取时那条记忆还在**未提交**的事务里,另开 session 去重建画像看不到它 > (实测:快照加了、事实没进、图里也没多出关系)。事件只可能在本事务提交之后被消费, > 届时数据一定可见。 **一致性:两层对账,职责不重叠** | 层 | 服务 | 回答的问题 | | --- | --- | --- | | **事件层** | `ProjectionReconciliationService` | 哪些投影事件还没投递、需要重放 | | **数据层** | `ProfileGraphProjectionService.reconcile_customer()` | 投递完成后,**图里的内容对不对** | 数据层把差异分两类:`missing`(画像有/图里没有)与 `orphaned`(图里有/画像已无), 两者都为空才算 `consistent`。加 `repair=True` 自动修,**差异一律以画像为准** (`missing` 补写、`orphaned` 删除,**绝不反过来改画像**),修完还会**重新读一遍确认** ——注释:"不凭'操作没报错'就宣布修好了"。手工工具: ```powershell python tools/reconcile_graph.py 9001 # 只检查 python tools/reconcile_graph.py 9001 --repair # 检查并修复 python tools/reconcile_graph.py --all # 所有有记忆数据的客户 ``` **删除的两种粒度**:`delete_customer()` 用 `DETACH DELETE` 连节点带关系一起清 (这是此前**缺失的投影删除客户端**——缺了它,销户后图里留着旧关系,投顾会拿过期偏好做推荐); `_delete_edge()` **只删边、保留节点**(节点是共享的,同一个 `Tag` 可能被上百客户指向)。 后者有个安全细节:关系名取自图中既有边,属外部数据,拼进 Cypher 前**必须过 `ALLOWED_RELATIONSHIPS` 白名单**。 **读侧走受控边界 `RelationshipService`**(docstring:*"Controlled Neo4j boundary; callers cannot submit arbitrary labels or Cypher."*): ```python neighbors(customer_id, relationship, *, limit=50) # limit 夹到 1..100 paths(customer_id, *, max_hops=2, limit=50) # hops 硬上限 2 portfolio_industry_context(customer_id) # LIMIT 20 ``` 实际读图的两处都在投顾侧:`portfolio_analysis_service.py`(产出 `graph_context`)与 `product_recommendation_service.py`(产出 `graph_status`)。`portfolio_industry_context` 的 docstring 有一条红线:**"Return only relationship enrichment; no position value comes from Neo4j."** —— 图只提供关系上的补充信息,**任何持仓金额之类的数值一律不从图里取**。 调用侧还二次过滤 `product_count >= 2`、只取前 5 条,图挂了返回 `{"degraded": True}` 不影响主分析。 - **实现现状**:✅ **已实现**(写侧 `profile_graph_projection_service.py`、 读侧 `relationship_service.py`、驱动 `app/infrastructure/graph.py`)。 **⚠️ 环境前提**:`build_graph_driver()` 在 `neo4j_uri`/密码缺失或驱动未安装时**返回 None** (图能力整体关闭,不让应用起不来)。`docs/37` 记录本机验证时图库是 `Exited(1)`, `neo4j` 分支停在 `failed` —— 文档明确判断**那是环境不可用,不是代码缺陷**, 因为"图库不可用时如实失败、不伪造成功"正是设计口径。 ## 4. 红线(必须由代码保证,不能只靠约定) 1. **适当性判定只认问卷**。`investor_type` 只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。 *落地*:`rebuild_profile()` 里 `investor_type` 只从 `fin_risk_assessment` 查询取数,记忆路径碰不到它。 2. **风控与投顾只读画像**,不直接读 `memory_unit`。否则同一条记忆会被多处按各自口径解释。 *落地*:`risk_repository.py` 走 `FundCustomerProfile`;投顾三个服务走 `enforce_profile_governance=True`。 3. **每条画像字段都要能回答"凭什么"**。写入时通过 `generation_basis` 记录依据来源。 *落地*:`basis[field] = {source, fact_key, confidence}`,随 `profile_snapshots` 一并落库。 4. **画像变更留版本**,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。 *落地*:`_write_snapshot()` 先清旧 `is_current` 再写新版本;版本号 `MAX(version)+1`。 5. **内部资料不进入面向客户的知识库**(已按此处理:反洗钱手册 `visibility=internal`)。 6. **数值不从图里取**。Neo4j 只做关系补充,任何持仓金额/市值必须来自权威库。 *落地*:`portfolio_industry_context()` docstring——*"Return only relationship enrichment; no position value comes from Neo4j."* 7. **投影与权威源不一致时,一律以画像为准**。图是派生数据,不能反过来改画像。 *落地*:`reconcile_customer(repair=True)` 只补写 `missing`、删 `orphaned`,修完重新读一遍确认。 8. **投影失败不得拖垮画像更新**。图库不可用返回 `degraded`,主链路照常提交。 *落地*:`ProjectionOutcome(degraded=True, reason=...)`;`dispatch_profile_rebuild` 只记 warning。 ## 5. 现状一览(2026-09-14 重写) > 原表把 5 个环节标为 ❌,但代码侧已完成,故**整表作废重写**。 > 判定依据是源码实际装配;每条给出承载文件,便于逐条复核。 | 环节 | 状态 | 承载文件 | | --- | --- | --- | | 对话 → 中期记忆(抽取 + 证据 + 可回溯) | ✅ 已实现(依赖常驻 Worker) | `worker/memory_extraction_worker.py`、`service/memory_extraction_service.py` | | 中期 → 长期(证据累积与门槛) | ✅ 已实现 | `service/profile_assembly_service.py::promote_facts()` | | 长期 → 画像(组装 + 版本快照) | ✅ 已实现 | `service/profile_assembly_service.py::rebuild_profile()` | | 问卷 → 画像 | ✅ 已实现(`rebuild_profile` 内查 `fin_risk_assessment` 最新一条) | 同上 | | 短期会话上下文 | ✅ 已实现(最近 10 轮;不影响多轮指代) | `worker/runtime.py::_conversation_history()` | | 画像 → 图投影(Neo4j 关系) | ✅ 已实现(**依赖图库可用**) | `service/profile_graph_projection_service.py`、`service/graph_model.py` | | 画像 → 投顾 / 风控 | ✅ 已实现 | 读:`risk_repository.py`(8+ 处 join)、`portfolio_analysis_service.py`、`product_recommendation_service.py`;闸门:`profile_governance_service.py` | | 中期记忆召回(双通道合并 + 降级) | ✅ 已实现 | `service/memory_recall_service.py` | | 记忆更新与遗忘(冲突留痕、失效、客户级级联) | ✅ 已实现 | `service/memory_service.py`、`service/memory_lifecycle_service.py` | | **行为 → 画像** | ❌ **仍未实现** | 交易侧字段(`total_asset`/`trading_frequency`/`behavior_score`)本服务**刻意不写**,留给交易模块 | | **画像候选的人工确认闭环** | ⚠️ 接口与候选服务已在 | `customer_profile_candidate_service.py`;`GET/POST /users/me/memory-candidates*`、`POST /admin/customer-profile-candidates/{id}/reviews` | ### 5.1 三条需要注意的"看着像,其实另一回事" 1. **画像有第二个落点,别只找 MySQL**。关系在 Neo4j,见 §3.5。混起来会得出 "画像只在 MySQL"或"两个库地位相同"这两种错误结论。 2. **投顾侧还有一条独立的标签证据链**:`advisor_profile_tag`(每 `tag_key` 只有一条 `status='active'`,靠唯一列 `active_customer_tag` 保证)+ `advisor_profile_drift_review` (标签漂移时把候选画像**挂起待复核**)。它与 `risk_tags` 不是同一套——前者是投顾内部 带证据血统的标签,后者是画像里给风控看的三方不一致信号。 3. **`risk_tags` 是 JSON 列但写入的是字符串**(`";".join(tags)`)。读取侧不要假定它是数组。 ### 5.2 剩余待办(原 §5 的建议已基本生效) 原表末尾的"建议顺序:先做中期→长期→画像,再接问卷与行为两路,最后接下游消费者" —— 除**行为一路**外均已落地。剩余待办集中在两处: - **行为 → 画像**:等交易模块产出 `total_asset`/`trading_frequency`/`behavior_score`; 本服务的口径是"不抢写",交易侧就绪后无需改动即可衔接。 - **图库可用性**:`neo4j_uri`/密码缺失或驱动未装时 `build_graph_driver()` 返回 None, 投顾侧走 `degraded` 分支但不报错;排查时**先确认返回体里的 `graph_status`/`graph_context`, 不要误判成"投影代码坏了"**。