diff --git a/docs/24-客服Agent阶段性总结与下阶段计划.md b/docs/24-客服Agent阶段性总结与下阶段计划.md new file mode 100644 index 0000000..7b89d5e --- /dev/null +++ b/docs/24-客服Agent阶段性总结与下阶段计划.md @@ -0,0 +1,171 @@ +# 24 · 客服 Agent 阶段性总结与下阶段计划 + +> 本文覆盖一段**具体的工作区间**:从"客服能答公募基金常见问题"到"客服能对客户给出适当性结论"。 +> +> 写法说明:只写**已经跑通并验证过**的事实(附实测数据),以及**明确还没做**的事。 +> 不含计划外的推测,也不含"应该差不多了"这类没验证的判断。 + +--- + +## 一、这一阶段的起点和终点 + +**起点**:客服 Agent 能回答常见问题,但有两个客户一用就会撞上的毛病—— + +1. **同一个问题换个说法就时灵时不灵**:「季季盈90天最低投多少钱」答得出来,「季季盈90天的起投金额是多少」却转人工; +2. **问「我这个等级能不能买它」答不了**:只会把 C1 的通用规则复述一遍,给不出结论。 + +**终点**:四个出口(知识直返 / 适当性裁决 / 引导人工 / 闲聊)都能稳定工作,`c1客户能买它吗` 这类问题会给出**明确结论**,且结论依据档案等级而非客户自述。 + +--- + +## 二、做了什么 + +### 1. 知识检索质量 + +三条都是**客户实测先撞上**、我再定位的: + +| 现象 | 根因 | 做法 | 实测结果 | +|---|---|---|---| +| 「季季盈90天的起投金额是多少」转人工 | 专有名词在 embedding 空间不占优势,向量 top1 仅 **0.6291**,够不到 0.75 硬门槛 | 产品名走**字面兜底召回**(`title like` + 最长公共子串) | 该问句唯一命中 PROD-007 | +| 问「起投多少」和问「风险高吗」拿到**一模一样的整节表格** | 切分粒度太粗:一个叶子标题 = 一整节(表格 + 说明) | Markdown 表格拆成**行级自解释子块**,父块保留;知识块 **160 → 636** | 该问句 top1 **0.6291 → 0.869**,命中的正是「起投金额」那一行 | +| 问「介绍一下」只返回一行「产品期限 90天封闭期」 | 拆细之后某一行抢答了概括性问题 | 命中子块时一并带回整节父块,由 Agent 按问句**选粒度** | 「起投多少」给一行、「介绍一下」给整节 | + +顺带修掉两个由我引入的回归,都已固化成测试: + +- 字面匹配**无条件优先**时,把「基金赎回几天到账」答成了操作步骤(手册章节标题「5.2 基金赎回流程」与问句有 4 字重合)。→ 字面匹配改为**只在向量结果不够确定时介入**,重叠门槛从 4 字提到 6 字。 +- 把「doc_id 不含两位数字后缀」当成整节块,导致 **FAQ/政策/公司信息的块全被误判**、把正确答案挤出 top1。 + +### 2. 多轮上下文 + +| 现象 | 根因 | 做法 | +|---|---|---| +| 客户**换话题**时答非所问:先问季季盈、再问赎回,第二问答的是季季盈 | 检索问句**无条件**拼接上一轮客户原话 | 只在客户这一句**自己说不清楚**时才带上文(有明确指代词,或短到不构成完整意图) | +| 追问「那它风险高吗」拿到整节表格 | 拼接的是上一轮**整句**,检索词被摊宽,只能命中粗粒度块 | 带上文时**只带"在说哪个产品"**,不带上一轮的原话——上文的作用是补主语,不是补内容 | + +### 3. 适当性裁决(这一阶段的主要新增能力) + +把底座**早就存在**的 `check_suitability` 工具接进客服(接口留好了、线没接上,本项目第三次遇到同一类情况)。 + +- 新增 `suitability_check` 意图,意图码**三处对齐**(`AgentDefinition.supported_intents` + `agent_intent_config` 的 active 行 + 发布版 `agent_tools` 白名单)。第三处缺失是**静默**失败关闭,所以这一步必须做。 +- 新出口按三条硬约束走: + 1. 产品风险等级**从知识库查**(不猜、也不采信问句里的 "R2" 字样); + 2. 产品名**只取上一轮回答里的主语**(来自知识块字段,可信); + 3. 客户等级**交给 `check_suitability` 按档案解析,不采信客户自称**。 +- 任何一步拿不到确定值就转人工 —— 这个出口会给出"能不能买"的结论,**宁可答不了也不能答错**。 +- 配置已发布(版本 **181** active,5 条配置项),白名单 `customer_service:suitability_check = [search_knowledge, check_suitability]`。 + +**实测**:`C3 客户能买它吗` 会先说明判断依据("以您在公司留存的测评结果为准,不以本次自述为准"),再给结论。9001 因为在 `fin_risk_assessment` 里**没有测评记录**,得到"暂时无法购买 + 请先完成风险测评"——这是**合规上的正确行为**,不是故障。 + +### 4. 知识库内容 + +- 为 **C1–C5 各补一条「能买什么产品」问答**,答案全部取自《个人投资者适当性管理指南》原文(第十二条匹配矩阵 + 第十四条跨级禁止 + 第十五条豁免规则),未自行编写规则。起因是「C1 客户能买什么」因缺"保守型"这个锚点而转人工(top1 0.5633、gap 0.0236)。 +- 顺带修正 `load_knowledge_milvus.py` 里过时的自检期望(该问句现在命中 FAQ 而非 `POL-AST`,两者内容一致)。 + +### 5. 调试前端 + +`tools/chat_console.py`:一个能直接聊天的本地控制台,页面展示识别到的**意图**与**是否引导人工**,方便观察路由。 + +**为什么做成独立进程,而不是给底座加接口**:底座目前**没有登录接口**(按计划推迟)。任何让浏览器直接拿令牌的做法——无论是 dev token 端点还是把私钥下发前端——都等于把"任意身份"开放给任何能访问服务的人。控制台把令牌签发与调用全部留在**服务端进程内**(私钥不出进程),**底座代码零改动、零新增路由**。 + +跑法:`D:\conda\envs\jr_py313\python.exe tools\chat_console.py`,然后打开 http://127.0.0.1:8098。 +**跑之前请停掉常驻 Worker** —— 控制台自己驱动这一条 run,Worker 会抢走队列。 + +--- + +## 三、三个值得记下的教训 + +### 1. `_topic_of` 靠反解文本猜主语,咬了三次 + +它从回答文本里猜"这一轮在说哪个产品",于是每加一种回答格式就得补一条规则。三次咬人**全部是客户实测先发现的**: + +| 次序 | 咬人的格式 | 后果 | +|---|---|---| +| 1 | FAQ 回答首行 `问:C1 客户能买什么产品?` | 把孤零零的"问"字当主语,污染检索词 | +| 2 | 适当性回答首行没有冒号 | 追问丢失指代对象,转人工 | +| 3 | 自述等级说明行占了首行 | 产品名被挤到第二行,追问又一次转人工 | + +**已做的补救(方案 A)**:`tests/unit/service/test_customer_service_topic_matrix.py` 把**四个出口的真实产出**都过一遍 `_topic_of`,谁再改回答格式或加出口,测试立刻红。写这个矩阵**当场又抓出两个真 bug**: + +- 整节块的主语带着章节号(`2.1 南方季季盈90天`),而下游要求产品名是块的**子串**——带编号**永远匹配不上**,适当性查询会**静默失败**、退化成转人工。这个故障从 `_topic_of` 最初写出来就存在,只是之前的追问恰好都走"行级子块"那条路径,一直没被触发。 +- 政策条款(`第十二条 …`)和表格行(`| C1保守型 | ✅ 可购买 |`)也会被当成主语。 + +**还没做的根治(方案 B)**:让出口**显式声明主题**,不再反解文本。会碰 `app/core/contracts.py`,等需要动契约时再做。 + +### 2. "接口留好了,线没接上"是这个项目的常见模式 + +已发现的同类情况:`user_facts`/`profile_snapshots` 有表无 ORM、Neo4j 有驱动无装配、短期记忆有契约无装载器、图投影有 Worker 无生产者、`check_suitability` 有工具无白名单。 + +它们的共同表现是**静默降级或静默失败**,不报错。**结论:每接一个能力,都要验证数据真的流过一遍,不能只看代码在。** + +### 3. 阈值必须和数据规模、数据形状一起看 + +- `IVF_FLAT` + `nlist=128` 用在 26~73 行的集合上,我一度怀疑近似检索在丢召回 —— 实测 `nprobe` 从 1 拉到 128,分数**一个数字都没变**。猜测作废,索引不用动。 +- `HIGH_SCORE=0.75` 对"整节 Markdown 表格"这种长块**永远达不到**(产品类问句 top1 普遍 0.62~0.65),于是全靠 `gap≥0.07` 这条通道过 —— 而 `gap` 在"具体命中 + 泛化问答对"混排时又太紧。**块切细之后分数自然上到 0.869,问题自己消失了**:调阈值之前先看数据形状。 + +--- + +## 四、当前状态 + +| 项 | 值 | +|---|---| +| 分支 | `qyqy_develop_1`,领先 `origin` **13 个提交**(含本文,尚未推送) | +| 门禁 | `ruff` 干净 / `mypy` 113 文件无问题 / **497** unit+contract / 29 integration | +| 知识库 | 636 块(faq / product / policy 三个集合) | +| 发布配置 | 版本 **181** active,5 条配置项 | +| 新增测试 | 5 个文件:`test_knowledge_keyword_recall`、`test_knowledge_granularity`、`test_customer_service_search_query`、`test_customer_service_suitability`、`test_customer_service_topic_matrix` | +| 新增工具 | `tools/chat_console.py` | + +本阶段的提交(自 `07a922f` 起): + +``` +c369a91 用格式矩阵把 _topic_of 的第四、五口咬痕也堵上 +8b1ed70 自述等级的说明行不能挡住产品名 +c564494 追问适当性结论时不能丢掉产品名 +958fd62 锁住适当性出口的每条分支 +33fbb0e 接入适当性裁决,回答"我这个等级能不能买它" +ee9de1b FAQ 型回答不能把"问"字当成追问的主语 +7b3a728 为 C1-C5 各补一条「能买什么产品」的问答 +a7135d2 补交检索问句的断言更新 +4c2b147 产品知识拆到表格行级,并按问句选粒度 +a6c09fa 检索问句只在客户这一句说不清楚时才带上文 +570493e 知识检索增加产品名的字面兜底召回 +07a922f 新增本地客服控制台(可聊天的调试前端) +``` + +--- + +## 五、下阶段计划 + +### 要你先决策的两件事(挡着后续验证) + +1. **给 9001 补一条风险测评记录**(`fin_risk_assessment`,测试数据、会明确标注)。 + 不补的话,适当性出口**永远只能演示"不能购买"这一半**——"可以购买 + 需签风险揭示书"的完整路径看不到,也没法验证。 +2. **闲聊提示词配置从未落库**。查证结果:整个库里涉及提示词的配置项是 **0 行**(我之前说"某次发布弄丢的"是错的,已更正)。版本 174 的编号虽然叫 `cs-prompt-…`,但没留下任何配置项,闲聊出口一直在用代码里的默认提示词(有兜底,功能正常)。 + 要按当初"走配置"的要求补上的话:**必须先确认 `tools/publish_chitchat_prompt.py` 会继承版本 181 的配置项**,否则会把刚配好的工具白名单清空。`config_release` 是整版本替换语义。 + +### 紧接着要做的(不依赖上面的决策) + +3. **收掉 `_topic_of` 的文本反解(方案 B)**:让出口显式声明主题。现在有方案 A 兜着,不急,但它是唯一一处"格式一改就可能悄悄答错"的地方。 +4. **`transfer_required` 落库**:目前它只在返回值里,API 不返回、消息表不存,前端只能靠"回答里是否含兜底话术开头"来判断。这是权宜之计。 +5. **`reconcile_graph.py --all` 还是手工的**:图投影的对账没有自动化触发。 + +### 后续功能(按依赖顺序) + +6. **投顾 Agent**:画像(`fin_customer_profile` + `profile_snapshots`)、图投影(Neo4j)、适当性裁决**都已就绪**,可以开始。 +7. **风控 Agent**:需要先有规则引擎,尚未启动。 +8. **登录接口**:`docs/19` 里列为未解决项、当时有意推迟。前端要变成"能用"的产品级界面,这一步绕不过去。 + +### 已知但暂不处理的缺口 + +- `projection_reconciliation_service` 的事件层 outbox(`MemorySyncOutbox`)**没有生产者**。 +- `RUN_NOT_CANCELLABLE` 这个错误码被复用在了状态冲突场景(语义不贴切)。 +- `docs/05` §9.5 缺 `agent-intent-configs` 的 GET 说明;列表类接口普遍没有 `next_cursor`/`has_more`。 +- 客服热线仍是占位符 `400-XXX-XXXX`(`customer_service.py` 的 `HOTLINE`),真号码确定后应改为读配置项。 + +--- + +## 六、给组员的一句话 + +如果你要在这套底座上加新 Agent,先读 `docs/19`(接入实操 + 11 个真实坑)和 `docs/23`(记忆分层与画像)。 + +**本阶段最值得抄走的一条经验**:新接一个能力之后,**别只看代码在不在,要跑一遍确认数据真的流过**。这个项目里"接口留好了、线没接上"出现过至少五次,而它们的表现都是**静默降级**——不报错,只是回答变得又慢又差,或者一直转人工。