docs(24): 客服 Agent 阶段性总结与下阶段计划

覆盖区间:从"客服能答常见问题但换个说法就时灵时不灵"到"能对客户给出适当性结论"。

只写已经跑通并验证过的事实(附实测数据)与明确还没做的事,不含计划外推测。
内容:知识检索质量(字面兜底/行级切分/粒度选择,含两个我自己引入的回归)、
多轮上下文(换话题串味 / 只补主语不补内容)、适当性裁决接入、知识库内容补充、
调试前端;三个教训(_topic_of 反解文本咬了三次、"接口留好了线没接上"的模式、
阈值必须和数据规模与形状一起看);当前状态;下阶段计划与待决策事项;遗留缺口清单。

同时更正此前口述的两处错误:领先远程是 12 个提交(不是 16),
闲聊提示词配置是"从未落库"(不是"某次发布弄丢")。
This commit is contained in:
2026-09-11 09:33:48 +08:00
parent c369a919c6
commit d40d808dd0
@@ -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`(记忆分层与画像)。
**本阶段最值得抄走的一条经验**:新接一个能力之后,**别只看代码在不在,要跑一遍确认数据真的流过**。这个项目里"接口留好了、线没接上"出现过至少五次,而它们的表现都是**静默降级**——不报错,只是回答变得又慢又差,或者一直转人工。