Files
group_fqcd_jr/docs/23-记忆分层与画像设计.md
lzf_0626 36c7a9d8d2 文档:审查报告入库 + 全量校对补注
## 新入库(`docs/演示用/`)

- `代码库全面审查报告-2026-09-14.md`
- `代码修改方案-2026-09-14.md`
- `记忆系统排查报告-2026-09-14.md`
- `记忆系统修复文档-2026-09-14.md`
- `文档一致性审计报告-2026-09-14.md`
- `多Worker接入方案-2026-09-14.md`

## 全量校对(32 个既有文档 + `AGENTS.md`)

跨 39 个文件、**1125 insertions / 148 deletions**。

⚠️ **这批改动同样不是本次会话写的**。我抽样核对过性质:是**实质内容补充**而不是
格式/换行转换。例如 `docs/44-演示流程.md` 新增两条"2026-09-14 补注":

- `启动金融Agent平台.bat` 只在**桌面**上,仓库里只有 `启动平台.bat` 这一份
  (两份由同一个 `tools/make_launcher_bat.py` 产出,改完 `start.ps1` 重跑它一起更新);
- `advisor_t`(9020) 与 `offsite_t`(9006) **不在 `tools/seed_test_rbac.py` 的演示用户里**
  (那里只有 `cust_t`/`risk_t`/`admin_t`/`review_t` 四个),由 `grant_*.py` 系列创建,
  **重跑种子不会重建它们** —— 换机器时这两个账号登录失败,要先查 `sys_user` 有没有这两行,
  而不是查密码。

这两条都是对的地方,与我这一路踩到的现象一致(我确实用到了 `advisor_t`/`offsite_t`)。

**我没有逐字审阅全部 39 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
2026-09-14 20:36:00 +08:00

26 KiB
Raw Permalink Blame History

记忆分层与画像设计

目的:讲清短期/中期/长期/画像四层各自存在哪里、谁写、什么条件下向上提升、是否进画像。 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。

状态口径(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 删除,绝不反过来改画像),修完还会重新读一遍确认 ——注释:"不凭'操作没报错'就宣布修好了"。手工工具:

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."):

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, 不要误判成"投影代码坏了"。