模块 1 · 客户视角

客户能做什么?
从 SSE 对话到 Core 只读

登录客户(如 CUST-9527)打开「客户助手」后,可走同步或 SSE 流式 对话。未登录访客另有游客试聊(只 RAG、不查持仓)。 2026-09 merger 分支:客户线 SSE 已接通,测试基线 **786 passed**。

四条能力线(指挥 AI 改功能前先对齐)

对话 SSE / 同步

POST /api/chat/stream 或 POST /api/chat,请求头 X-Agent-Type: customer + Bearer JWT。

持仓 / 流水 / 风评 / 净值

意图命中后走 Core 模拟库只读查询 Tool(core_ro_tool.py);含 C-05 最新净值(非实时);C-04 查持仓时可 inline 阈值提醒。

产品规则 RAG

fin_* 知识库(Milvus)回答申购赎回、费率等产品咨询;画像语境注入但不泄露敏感字段。

游客试聊

POST /api/chat/visitor 免登录,游客试聊 9 节点编排(visitor_service.py),不写客户会话表。

和理财师线的区别: 客户线走登录客户 14 节点 LangGraph 编排(customer_service.py),不走 顾问通用 tool→llm→guard 编排(agent_service.py)。

数据流:浏览器问「我持仓多少」

客户发消息后,四 Agent 对话 HTTP 入口(chat.py)按 X-Agent-Type 分流到客户编排,再查 Core。

🖥浏览器
🚪对话 HTTP 入口(chat.py)
🤖customer_service
🗄core_ro_tool

点击「下一步」看请求怎么走

入口 · 四 Agent 对话 HTTP 入口(chat.py)
if agent_type == "customer":
    host_ctx = host_auth_for_customer_service(auth, trace_id=trace_id)
    customer_prep = prepare_customer_stream(
        host_ctx, message, sid, cust_id, req.end_session
    )
    # 图跑完后按 chunks 推 SSE
白话

看到 customer 就换轨道:不进 agent_service,改走客户专用编排。

先把 JWT 转成 customer_service 认识的 AuthContext(字段名对齐)。

流式路径:LangGraph 整图跑完,再把回复切成块推给前端——Tool/RAG 仍是同步完成。

未登录访客在首页试聊,会查到自己的持仓吗?

模块 2 · 编排内核

14 节点 LangGraph:
从回忆到归档

客户线不走顾问通用 tool→llm→guard 编排(agent_service.py),而是 登录客户 14 节点 LangGraph 编排(customer_service.py)里一张更大的 LangGraph。 入口永远是 recall_memory,出口经 save_memory 写记忆后收尾。

14 个节点一览(改分支前先认门牌号)

1recall_memory · 拉 Redis/MySQL 窗口
2intent_classify · LLM 分意图
3rag_search · fin_* Milvus
4param_extract · 抽查询参数
5tool_call · Core 只读 Tool(core_ro_tool.py)
6interpret · 解读 fact_text
7generate · RAG 拼回复
8chitchat · 闲聊
9reject · 合规拒绝
10transfer_human · 转人工
11fallback · 兜底话术
12save_note · 客户记一笔
13save_memory · 写回合记忆
14profile_maybe_extract + archive_check
分支规则: intent_classify 之后走 RAG(产品/政策/FAQ)、走 Tool(持仓/流水/风评)、或走静态话术(拒绝/转人工/闲聊)。所有生成分支最后都汇入 save_memory。 口吻护栏与转人工决策见模块 4。

群聊动画:客户问「我持仓多少」

下面按节点顺序播放(数据查询分支);若问「申购费率多少」则在节点 2 后改走 rag_search → generate。

图构建 · 登录客户 14 节点 LangGraph 编排(customer_service.py)
g.set_entry_point("recall_memory")
g.add_edge("recall_memory", "intent_classify")
g.add_conditional_edges("intent_classify", _route, {...})
g.add_edge("tool_call", "interpret")
g.add_edge("rag_search", "generate")
# 各生成分支 → save_memory → profile → archive → END
白话

先回忆上下文,再让 LLM 判意图,然后走 RAG 或 Tool 或静态话术。

数据类问题:param_extract → tool_call → interpret,数字 100% 来自 Core。

无论哪条分支,最后都要 save_memory,再异步做画像和归档。

客户问「基金申购费率多少」,LangGraph 走哪条分支?

模块 3 · 铁律与流式

两条铁律 +
prepare_customer_stream

客户线能跑通,靠文件头写死的两条 铁律。 流式 SSE 看起来是「边生成边推」,实现上是先跑完整张 LangGraph,再切块推送。

铁律(指挥 AI 改代码时别碰)

铁律 1 · 数值不经过 LLM 编造

持仓、流水、风评等查询结果 100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 在 interpret / generate 里只做解读与组织语言,不能自己算数。

铁律 2 · customer_id 只来自 JWT

四 Agent 对话 HTTP 入口(chat.py)在进图之前已从令牌解析客户号并做归属校验。图内 state["customer_id"] 不信用户消息里的「帮我查 CUST-xxx」。

和 advisor 线的差异: 理财师走顾问通用编排(agent_service.py)的 tool→llm→guard;客户走独立 14 节点图。别混用对话 Tool 编排(tool_service.py)关键词意图那套来改客户 RAG 分支。

流式真相:图跑完再切块

方案 C 下 prepare_customer_stream 先调用 run_customer_chat 跑完整图,再用 _chunk_reply_text 切成 16 份左右推 SSE——Tool/RAG 仍是同步完成,不是 token 级流式。

CODE · prepare_customer_stream
def prepare_customer_stream(
    ctx, message, session_id, customer_id, end_session=False
) -> dict[str, Any]:
    reply, has_disclaimer, intent, transfer = run_customer_chat(
        ctx, message, session_id, customer_id, end_session
    )
    return {
        "reply": reply,
        "has_disclaimer": has_disclaimer,
        "intent": intent,
        "transfer_to_human": transfer,
        "chunks": _chunk_reply_text(reply),
    }
白话

入参里的 customer_id 是四 Agent 对话 HTTP 入口(chat.py)验完 JWT 后塞进来的,函数内部不再解析用户文本。

先拿到完整 reply(含 intent、是否转人工、是否附免责),再切片给 SSE 层逐块写。

改「真流式」要先动 LangGraph 执行模型,不是只改前端 ChatPanel。

相关文件(改流式 / 铁律时打开这些)

app/service/
登录客户 14 节点 LangGraph 编排(customer_service.py)— prepare_customer_stream
游客试聊 9 节点 LangGraph 编排(visitor_service.py)— 无 Tool 查持仓
RAG 检索层(rag_service.py)— fin_faq / fin_product / fin_policy
app/api/
四 Agent 对话 HTTP 入口(chat.py)— customer 分支分流 + SSE 写帧
宿主 AuthContext → 模块 AuthContext 适配(auth_adapter.py)— host_auth_for_customer_service
app/tool/
客户 Agent Core 只读查询工具(core_ro_tool.py)— query_holdings / query_trades 等

客户 SSE 流式回复,LangGraph 什么时候跑完?

模块 4 · 口吻与转人工

专业口吻怎么「锁住」?
什么时候该转人工?

机制课讲了 LangGraph 怎么走,但客服线还有两条你指挥 AI 时最常碰的线: 回复别漂成投顾/推销员,以及什么时候打「建议转人工」。 代码里不是单靠「写个好 prompt」,而是三层滤网 + 一条主动转人工路径(sanitize 命中不再自动转人工,拍板 1B)。

三层滤网:防止口吻漂移

可以把客服回复想成「过三道闸」——前面两道尽量写对,最后一道不管 LLM 说什么都要拦违规词。

①

Prompt 层 · 客户线各分支 System Prompt 模板(customer_prompts.py)
每个生成分支有独立 system 模板(INTERPRET_SYSTEM / GENERATE_SYSTEM / CHITCHAT_SYSTEM),开头就定身份:「金融客服助手(已登录客户模式)」——禁止投顾话术、禁止预测、画像只调措辞不能念出。

②

记忆层 · recall_memory
咨询走 consult_memory、闲聊走 chitchat_memory,再注入 profile_context。让回复有连续语境,但 prompt 里写死:不得主动复述年龄/收入等画像字段。

③

事后扫描 · finalize_sanitized_reply()(sanitize_postprocess.py)
interpret / generate / chitchat 产出后过违禁词正则。命中后:数据查询类(含 nav_query)回退 Core fact_text;RAG/闲聊替换 COMPLIANCE_REJECT——均不自动 transfer_to_human。游客线已对齐。

入口还有第 0 道闸: 四 Agent 对话 HTTP 入口(chat.py)的 input_guard 在进图之前就拦注入/超长——这类请求根本到不了 LLM。
事后扫描 · 共用分治(sanitize_postprocess.py)
def finalize_sanitized_reply(reply, *, intent, fact_text):
    safe, hit = sanitize_reply(reply)
    if not hit: return safe, False
    if intent in DATA_QUERY and fact_text:
        return fact_text, False  # 1B:保真账,不转人工
    return COMPLIANCE_REJECT, False
白话

数据查询分支:LLM 嘴瓢时回退 Core fact_text,客户仍能看到「持有 3 只产品」等真数。

RAG/闲聊分支:替换为固定拒答,但不打转人工标志——除非用户原话已是投诉/转人工。

底层 sanitize_reply 仍负责扫违禁词;分治逻辑在 customer 编排层。

转人工 vs 直接拒绝:别混成一条线

模块 2 列出了 reject 和 transfer_human 两个节点,但触发条件完全不同。指挥 AI 改逻辑时最容易搞混这里。

💬intent_classify
👤transfer_human
🚫reject
🔍sanitize_reply

点击「下一步」看决策分叉

路径 A · 主动转人工

关键词:转人工、投诉、纠纷、账户异常、被盗 等 → TRANSFER_TEXT + transfer_to_human=True。

路径 B · LLM 越界被拦(1B)

数据查询 interpret 违禁 → 回退 fact_text,transfer=false。RAG/闲聊违禁 → COMPLIANCE_REJECT,transfer=false。

路径 C · 边界外但不必转人工

问「推荐稳赚基金」「明天会涨吗」→ reject 静态话术(如 REJECT_ADVICE_TEXT)。不自动打转人工标志;话术中可引导用户自行说「转人工」。

演示环境边界: 「建议转人工」只是 API 字段 + 前端橙色 Tag(ChatPanel),不会真的排队接入真人坐席——真转接要接呼叫中心/工单系统,那是产品层后续工作。

群聊动画:LLM 嘴瓢后被扫描层拦住

客户问持仓,Tool 返回正常 fact_text,但 interpret 的 LLM 多嘴加了「建议您加仓」——扫描层介入。

RAG 咨询还要加风险提示

产品/政策/FAQ 类意图在 generate 通过后,若 intent 属于咨询类,会追加 RISK_DISCLAIMER(should_add_disclaimer)。这是「口吻」的另一面:该免责时必须免责,不是只有拦违规词。

客户说「推荐一个稳赚不赔的基金」,系统会怎么标记?

指挥 AI 改客服时的检查清单: ① 改口吻 → 动客户线各分支 System Prompt 模板(customer_prompts.py)对应 SYSTEM,别只改前端; ② 加新禁语 → 同时更新 FORBIDDEN_TERMS 和意图关键词路由; ③ 加「必须转人工」场景 → 改 _TRANSFER_KEYWORDS 或 INTENT_SYSTEM 规则,别误放进 reject。

模块 5 · 边界与接缝

两套知识库、合规口径
和一期代码差在哪?

客服线「能答什么」不只看 LangGraph,还看知识库分家和合规文档 vs 一期实现。 指挥 AI 改 RAG 或「放开推荐」前,先对齐下面这张差分表。

两套 RAG:别灌错库

客服 · fin_* 三库

登录客户的对话编排(customer_service.py)和游客试聊编排(visitor_service.py)遇到产品/政策/FAQ 类问题时,都会交给 RAG 检索层(rag_service.py:把问题向量化后查 Milvus),从 fin_product · fin_policy · fin_faq 三个知识集合取片段。

顾问 · kb_product_rules

理财师/风控通用 tool→llm→guard 编排(agent_service.py)的 search_knowledge Tool → 知识库对话 Tool 注册与分发(kb_tools.py)· 集合名不同,不能把顾问文档灌进 fin_*。

风控 · 不开放 search_knowledge

知识库对话 Tool 注册与分发(kb_tools.py)注册表对 risk 关闭——风控对话走 RISK_TOOL_REGISTRY 四只读 Tool。

指挥 AI 误区: 「统一成一个知识库」听起来省事,但会破坏各 Agent 合规过滤与检索策略——要改先对契约和 scripts/kb/ 脚本。

合规允许 vs 一期拒答(你拍板要讲的)

合规文档里,客户 Agent 在过 R-02 + 附免责 + 标注需持证审核时,可以做「匹配说明」——但一期代码对主动推荐/预测类问题走 reject 静态拒答,更保守。

合规文档 · 允许方向
适当性匹配说明(C-11)
→ 系统判定 + 规则解读 + 免责声明
→ 禁止具体操作指令
一期代码 · 实际行为

suitability_check Tool:「我能买 R3 吗」「有哪些匹配产品」——允许。

「推荐稳赚基金」「买什么好」→ reject + REJECT_ADVICE_TEXT——直接拒。

要「放开推荐」= 产品决策 + 改 intent 路由 + 合规评审,不是只改 prompt 一句。

群聊:同一客户,两种问法

游客与画像:客户看不见 L2/L3

游客走游客试聊 9 节点编排(visitor_service.py):无 JWT、无 Core 持仓查询、不写登录客户会话表。L1 画像由客服线后台抽槽写入;L2/L3 对客户不可见——合规上客户只能 enrich L1,不能替代正式风评。

AI 建议「把 kb 和 fin 合成一个 Milvus 集合简化维护」,你怎么回?

模块 6 · L0 与 L1 画像

正式风评 vs 抽槽画像
Top-K 怎么注入 prompt?

客户线除了查 Core 真账,还有后台画像抽槽(L1)调措辞。 拍板铁律:L0 永远优先——正式 C1~C5、持仓数字、适当性判定听 Core,L1 不能覆盖。 偏好类槽位注入 prompt 时走 Top-K + 时间衰减(R1),不是全量平铺。

L0 vs L1:谁说了算?

L0 · Core 正式数据

风评 C1~C5、持仓/流水数字、适当性矩阵判定——100% 来自 Core 只读查询 Tool(core_ro_tool.py)的 fact_text。LLM 只解读,不能改写。

L1 · 对话 enrich 画像

登录客户每 5 轮节流,后台线程跑画像抽槽与合并(profile_service.py)→ 写入 MySQL style_tags + Redis 热缓存。只影响 generate/chitchat 的措辞语境,不能替代正式风评。

撞车规则

抽槽若与 L0 字段冲突(例如用户口述「我是 R5」但 Core 是 R3)→ 永远听 L0。合并规则 D7:用户显式 user_declared 不被 inferred 覆盖。

13 槽位 + confidence 门槛

槽位表唯一来源:画像槽位定义(profile_slots.py)——13 个 path(含 C-08 investment.allocation_target),含 merge_mode 与 sensitivity。

①

归一 · normalize_value
例如「我喜欢货币基金」→ 映射到 9 类 PRODUCT_TYPE_VOCAB(不是具体 SKU 代码)。

②

阈值 · confidence ≥0.7(默认)/ ≥0.9(高敏)才写入;高敏 inferred 一律丢弃。

③

合并 · merge_candidates
单值 latest 覆盖;偏好/排除类 set_union 并集,并写 items_meta(R1)。

谁写 L1: 仅登录客户线(customer_service.profile_maybe_extract);游客试聊不写画像。
C-04 阈值槽: threshold_pref_summary → customer_threshold_config;达线提醒仅在查持仓时 inline。

R1 · 偏好 Top-K 注入

注入前筛选 · 画像上下文渲染(profile_service.py)
# settings 默认:top_k=3 · ttl=90d · half_life=30d
score = confidence × decay × (1 + log(mention_count))
# 超 TTL 的偏好不进 prompt;取得分 Top-K 条
白话

客户说过很多偏好类型,但 prompt 里只带最近、反复提到的前几条,避免画像越积越长带偏 LLM。

很久没提过的偏好(超过 90 天)不再注入,但 MySQL 里仍保留,下次提到会刷新。

改 K 或 TTL → 动环境配置(settings.py 的 profile_preference_*),别在 prompt 里硬编码。

客户口述「我是激进型 R5」,但 Core 风评是 R3,持仓查询应信谁?

模块 7 · Wave3 数据查询扩展

净值、阈值、匹配说明
关键词怎么分流?

2026-09-10 批次在持仓/流水/风评/适当性之外,又接了净值查询(C-05)、亏损阈值(C-04)和「我能买什么」(C-11)。 指挥 AI 改意图时,先看关键词路由顺序——数据查询类必须优先于「帮我记住」类 save_note。

新增 intent 一览

C-05 · nav_query

「最新净值 / 单位净值 / 基金净值」→ Core 只读 get_latest_nav(快照)。「实时净值」仍走 reject(禁止实时盘口)。

C-04 · 阈值提醒

用户说「亏 10% 提醒我」→ L1 槽 threshold_pref_summary + 写 customer_threshold_config;下次查持仓时组合加权盈亏达线 → 追加提醒 + customer_notify_log。

C-11 · 匹配说明

「我能买什么 / 匹配产品 / 有哪些我能买」→ suitability_check + Core 可购列表。「推荐稳赚」仍 reject。

C-07 · 风评重测

「重新测评 / 重做风评」扩进风评关键词 → 引导 App/网点;Agent 内不做问卷。

关键词优先级(易踩坑)

客户线各分支 System Prompt 模板(customer_prompts.py)里 keyword_route 顺序:

1

转人工 / reject(实时净值、推荐稳赚等)

2

数据查询:流水 · 风评 · 适当性 · 持仓(含「最近的流水」)

3

save_note(「帮我记住…」——但不含上面已命中的数据词)

4

匹配说明 · 净值查询

回归用例: 「帮我记住最近的流水」必须走 transaction_query,不能误进 save_note——见 test_wave5_notes.py。
C-04 · 亏损阈值服务(threshold_service.py)
# 画像抽槽 → sync_threshold_from_summary
# query_holdings 末尾 → build_threshold_alert
if loss_pct >= threshold:
    append alert + insert_notify_log
白话

阈值不是 push 通知——只有客户再来问持仓时才 inline 提醒。

组合盈亏按持仓市值加权算,不是单只基金。

改阈值:口述新比例 → 画像摘要更新 → config upsert。

1B 仍覆盖 nav_query

违禁词扫描后的分治(sanitize_postprocess.py)把 nav_query 算进数据查询 intent——interpret 嘴瓢时仍回退 Core fact_text。

客户问「这只基金实时净值多少」,系统怎么走?