## 新入库(`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 个文件**,只抽样确认了改动性质与规模。若其中有需要复核的段落,
请指明文件,我逐处核对。
26 KiB
记忆分层与画像设计
目的:讲清短期/中期/长期/画像四层各自存在哪里、谁写、什么条件下向上提升、是否进画像。 全部基于底座已有的真实表,不是另起一套;每节末尾标注实现现状。
状态口径(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增加(不直接覆盖,留冲突待判)。
- 写入:run 完成 → 发布
-
生命周期:
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}三个。
- 只取
- 两个必须知道的表约束(否则容易写出线上故障):
user_facts.id没有auto_increment,主键由应用侧_fact_id()生成 (微秒时间戳:单调递增、无需额外序列)。- 该表没有
(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_classpreferred_asset_classpreference:horizoninvestment_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):
- 单向投影 —— 图可随时重建,不要求与 MySQL 事务一致。
- 幂等 —— 全部
MERGE,重复投影不产生重复节点/关系。 - 降级 —— 图库不可用时返回
degraded,绝不把异常抛给画像重建主链路。 原文:"投顾推荐可以暂时没有图,但不能因为图挂了就让画像更新失败。" - 只投影"已确认"的事实 —— 数据源是
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. 红线(必须由代码保证,不能只靠约定)
- 适当性判定只认问卷。
investor_type只能由问卷写入,记忆与对话在任何情况下都不得修改它。客户在对话里说"我是激进型"不能让他买到 R5。 落地:rebuild_profile()里investor_type只从fin_risk_assessment查询取数,记忆路径碰不到它。 - 风控与投顾只读画像,不直接读
memory_unit。否则同一条记忆会被多处按各自口径解释。 落地:risk_repository.py走FundCustomerProfile;投顾三个服务走enforce_profile_governance=True。 - 每条画像字段都要能回答"凭什么"。写入时通过
generation_basis记录依据来源。 落地:basis[field] = {source, fact_key, confidence},随profile_snapshots一并落库。 - 画像变更留版本,不原地覆盖 —— 风控复盘时需要"当时看到的是什么"。
落地:
_write_snapshot()先清旧is_current再写新版本;版本号MAX(version)+1。 - 内部资料不进入面向客户的知识库(已按此处理:反洗钱手册
visibility=internal)。 - 数值不从图里取。Neo4j 只做关系补充,任何持仓金额/市值必须来自权威库。
落地:
portfolio_industry_context()docstring——"Return only relationship enrichment; no position value comes from Neo4j." - 投影与权威源不一致时,一律以画像为准。图是派生数据,不能反过来改画像。
落地:
reconcile_customer(repair=True)只补写missing、删orphaned,修完重新读一遍确认。 - 投影失败不得拖垮画像更新。图库不可用返回
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 三条需要注意的"看着像,其实另一回事"
- 画像有第二个落点,别只找 MySQL。关系在 Neo4j,见 §3.5。混起来会得出 "画像只在 MySQL"或"两个库地位相同"这两种错误结论。
- 投顾侧还有一条独立的标签证据链:
advisor_profile_tag(每tag_key只有一条status='active',靠唯一列active_customer_tag保证)+advisor_profile_drift_review(标签漂移时把候选画像挂起待复核)。它与risk_tags不是同一套——前者是投顾内部 带证据血统的标签,后者是画像里给风控看的三方不一致信号。 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, 不要误判成"投影代码坏了"。