Files
group_fqcd_jr/开发文档/D4.8-客服Agent智能度体检与整改报告-W21-2026-09-21.md
T

320 lines
21 KiB
Markdown
Raw Normal View History

# D4.8 · 客服 Agent 智能度体检与整改报告(`W21`)
> **体系编号**:`D4.8` · 域:四、重构与清除留痕
> **编号**:CS-RPT-2026-024 | **版本**:v1.0 | **日期**:2026-09-21 | **状态**:现行
> **性质**:**留痕报告**。回答甲方一句质疑 —— 「客服 Agent 不智能,很多问题强制转人工」。
> 本文只做三件事:**取实测证据**、**定位根因**、**登记整改与未整改项**。不改需求、不立新任务。
---
## 0. 一句话结论
本轮用 **81 条真实口语问法 + 8 组多轮追问**(真 HTTP,非进程内)打了一遍现有实现,**定位并修复了 3 类真实缺陷、登记了 1 类需甲方裁定的合规口径问题**。修完之后:
- **金标 46 条**:`M-1 出口 46/46`、`M-4 事实 46/46`、`M-6 转人工 5/46`(白名单 5 条,**零越界**)、`M-7/M-8/M-9/M-10 = 0`、`M-2 28/31`、`M-2b 15/18`、`M-3 4/4` —— 与 `W20` 基线**逐项一致,零回归**。
- **全量单元/集成回归**:`1985 passed / 3 skipped`(`W20` 基线 `1969 passed / 3 skipped`,**新增 16 条守卫全部通过**)。
- **智能度体检**:退化形态由 `W21` 首轮的 **25/78** 降到 **16/81**(口径说明见 §2.3,两次分布**不可直接相减**)。
- **`_chunks_report.txt` 判定为可删**(依据见 §1)。
---
## 1. 待判物:`_chunks_report.txt` 能否删除 —— **判定:可删,已删并已加 `.gitignore`**
| 判据 | 结论 |
|---|---|
| 是不是源码依赖? | 不是。全仓库 `rg` 无任何模块 import / 读取该文件 |
| 内容能否复算? | 能。它是 `tools/build_knowledge_chunks.py` 的**控制台输出落盘稿**,每一个数字都能从已入库的 `knowledge/_chunks.jsonl` 重新算出 |
| 是否入库(git 追踪)? | 否。`git ls-files` 无记录,`git ls-remote` 侧同样无 |
| 删掉会不会丢信息? | 不会。同一份统计口径已写进 `D2.4` §6 / `D2.8` §2,语料条数守卫由 `tools/build_knowledge_chunks.py` 的 `FAQ_EXPECTED_COUNT` 承担 |
**动作**:删除文件 + 在 `.gitignore` 末尾追加排除项,避免它再次被生成后误入库。
```
# tools/build_knowledge_chunks.py 的输出副产物(可由 knowledge/_chunks.jsonl 复算,不入库)
_chunks_report.txt
```
> 留痕口径:这是**工具产物**,不是项目内容。同类处理见 `.gitignore` 里已有的 `.workbuddy/`(AI 助手会话记忆)。
---
## 2. 体检方法(可复现)
### 2.1 为什么要用"真 HTTP"而不是进程内 harness
进程内金标 harness(`_eval_harness/probe.py`)测的是**判定链**(出口 / 检索 / 事实),它跳过了 **API → 队列 → Worker → 治理层** 这一段。而"不智能"的体感恰恰主要来自那一段:队列里重算 `chitchat_streak`、治理层追加免责声明、输出侧红线二次校验。
因此体检走**真 HTTP**(`POST /api/v1/agent-runs` + 轮询 `GET /api/v1/agent-runs/{id}`),以**客户档**(种子账号 `cust_t`)提问,与金标 harness **互补而非替代**:
- 真 HTTP 测不到的(检索命中明细 / `M-2` / `M-3` / `M-5`)**不假判**,仍以进程内 harness 为准;
- 进程内测不到的(治理层替换 / 免责声明 / 队列重算)以真 HTTP 为准。
### 2.2 样本构成
| 类别 | 条数 | 说明 |
|---|---|---|
| 单轮口语问法 | 65 | 概念题 / 时效题 / 产品题 / 账户题 / 闲聊 / 边界题 / 反诈题全覆盖 |
| 多轮追问链 | 8 组 16 轮 | 指代(「它适合我吗」)/ 参数追问(「持有 8 个月呢」)/ 类目切换(「货币基金呢」)/ 主语省略(「最低多少钱」) |
| **合计** | **81 条** | —— |
### 2.3 打标口径(**必须先读,否则会误读成"退步"**)
打标器**只识别"退化的形态"**,认不出来的一律计为「作答」:
| 标 | 含义 | 是否退化 |
|---|---|---|
| `作答` | 有实质回答(直答 / 生成 / 计算 / 适当性 / 闲聊) | 否 |
| `E5b部分答` | 「我先帮您把找到的公开资料放上来…」——贴了资料,未必贴对 | 是 |
| `E5b空答` | 「我暂时没找到对应的公开资料」 | 是(但诚实) |
| `澄清` | 「您想了解的是下面哪一项呢?」 | 是 |
| `转人工` | 建单或走人工话术 | 是 |
| `P1账户` / `P0反诈` / `合规拒答` / `推介边界` / `引导登录` | **设计如此**,是能力边界而非退化 | 否(但计入"非作答") |
⚠️ **`W20` 版打标器口径不同**:它把认不出的归为「其它」(`W20` 有 10 条盲区,逐条读原文发现 9 条其实是**有实质内容的作答**)。因此「`W20` 的 25 条退化」与「`W22` 的 16 条退化」**不能直接相减**,两者分母与标类都不同。
### 2.4 `W22` 结果分布(81 条)
| 标 | 条数 |
|---|---|
| 作答 | **65** |
| E5b部分答 | 6 |
| 澄清 | 3 |
| P1账户 | 2 |
| 转人工 | 2 |
| 合规拒答 | 2 |
| 推介边界 | 1 |
| E5b空答 | 0 |
---
## 3. 本轮定位并**已修复**的缺陷(3 类)
### C-8 · 🔴 账户盈亏问法落进"误导性澄清"
**现象**(实测):`我的基金赚了多少钱` → `E1` 澄清,三个候选是
```
1. 1.1 南方现金添利货币市场基金〔示例〕 · 万份收益
2. 各类基金的起投金额分别是多少?
3. 为什么不同产品的收益率差别这么大?
```
**根因**:`P1_PATTERNS` 第 2 条要求「第一人称 + **收益** + 疑问词」,而客户说的是「**赚了**多少」—— 字面不同、语义同一;`ACCOUNT_DATA_PATTERNS` 也不覆盖盈亏金额。
**为什么这比"答不上来"更糟**:三个候选**全是公开知识**,客户问的是**自己账户的盈亏金额**(Agent 无权读取)。让客户在三件不相干的事里挑一件,客户挑完拿到的仍是答非所问 —— 一个"看似有回应"的问题掩盖了能力边界。
**修法**(`app/core/customer_service_rules.py` · `P1_PATTERNS` 第 9 条):补「第一人称 + 盈亏动词 + **金额疑问词**」骨架,**金额疑问词是必要条件**。
**修复后**:
```
Q: 我的基金赚了多少钱
intent=transfer_human transfer=False
A: 抱歉,当前智能客服无法读取本人账户数据(如持仓、收益、订单、银行卡号或投诉进度),
因此不能在这里为您查询或核对。您可以登录后在「我的账户」页面自助查看,
或拨打官方客服电话 400-889-8899(每日 7:00—22:00)由人工协助处理。
```
**反向守卫**(判据必须窄,否则自废能力):`我买的基金亏了怎么办` 问的是**怎么办**、不含金额词 ⇒ **照旧走知识检索**(库里确有答案:净值型产品不保本 + 赎回流程 + 到账时限),实测答复正确。
---
### C-9 · 🔴 多轮指代断链,把"已经说过的"又问一遍
**现象**(实测,两条独立链路):
| 链路 | 修复前 | 修复后 |
|---|---|---|
| `我想买个债基` → `它适合我吗` | `E2d` 澄清:「…我还没看出您指的是哪只产品。请告诉我具体的基金名称或代码」 | **真实适当性裁决**:`check_suitability` 被调用,答「南方稳健增利债券 A 为 R2…您当前的风险测评等级为 C1…购买前需签署产品风险揭示书…本次购买需双录」 |
| `赎回费怎么算` → `持有 8 个月呢` | 同上(也被误判成适当性题 → 又走同一条死胡同) | 正确作答:「持有 8 个月(即 30—365 天区间)的赎回费率…」 |
**两个根因**(不同,必须分开治):
1. **主语反解取不到**。`_topic_of` 只在答复**前 4 行的固定形状**里取主语(`X:…` / `### 2.1 X` / `…为 R2`)。上一轮是 **FAQ 型答复**(首行是「问:…」)时反解为**空**,本出口于是回一句"请告诉我具体的基金名称或代码"—— 而客户**刚刚**才被告知是那只产品。
2. **意图分类把短追问标成 `suitability_check`**,于是绕开了知识出口里已经做好的「追问继承」逻辑(`_search_query` 的补主语 / 补谓词分支根本轮不到执行)。
**修法**(`app/service/agent/implementations/customer_service.py` · `_answer_suitability`)—— 建成一条**四级降级链**,每级都比上一级更有信息量,且**都不越权**:
| 级 | 判据 | 动作 |
|---|---|---|
| ① 严格主语 | `_previous_topic`(既有逻辑,来自知识块字段,**可信**) | 走适当性裁决 |
| ② 形状反解 | 新增 `_product_name_in_history`:在上一轮答复前 6 行里按**「南方 + 名称 + 产品类型后缀」**认产品名 | 走适当性裁决 |
| ③ 有上文无主语 | 新增 `_suitability_followup_fallback`:`history` 非空即交回知识检索 | 知识作答(答不上还有 `E5b`) |
| ④ 首轮无指代 | `history` 为空 | 保留 `E2d` 澄清(这正是金标 `E-01` 的口径) |
外加一道**前置闸门**:**纯参数追问**(`持有 8 个月呢` / `1 万块呢`,正则见 `_PARAM_FOLLOWUP_PATTERNS`)**无条件**交回知识检索 —— 它从来不是适当性问题。
**安全上不放宽的三条**:
- 本出口**从不给**"能不能买"的结论给访客;访客在更早的分支就被引导登录,降级链够不着。
- ②级抽出的名字若**查不到风险等级**,**不**回"我给不出结论",而是回落到知识检索 —— **绝不因一次推测**降级成 `E5b`。
- ③级的 `_previous_topic` 为空时的澄清**保留**:首轮「它费率多少?」没有指代对象,问清才是对的。
**为什么不能只做「删掉澄清、一律检索」**:那会把首轮指代也放行,客户问「它费率多少?」会拿到一段与"它"无关的任意资料 —— 从"答不上来"变成"答错"。
---
### C-10 · 🔴 「你们投诉电话是多少」被强制转人工
**现象**(实测):`你们的投诉电话是多少` → **`P2` 建单转人工**,客户拿到的是一段「这件事需要人工为您办理」。
**根因**:`P2_WRITE_DISPUTE_KEYWORDS` 里有一个**裸词**「投诉」。该词表是**关键词子串匹配**,于是"问投诉渠道"和"提交投诉"撞在同一个判据上。
**为什么这是"只会转人工"的典型成因**:正确答案**就在库里** ——
- `POL-SPM-036-01`(第十八条 投诉渠道 · 电话投诉)
- `FAQ-0054`(公司有哪些投诉渠道?处理时限是多久?)
**知道投诉电话是公开信息,受理投诉才是人工的事。** 两者混为一谈,就把一条**能答**的问句推给了人工。
**修法**(`customer_service_rules.py`):新增 `P2_CONTACT_INQUIRY_PATTERNS` + `_is_p2_contact_inquiry()`,与既有 `_is_p2_self_service_question`(「怎么修改绑定的银行卡」)**并列成第二类豁免**:
- 判据一:渠道词(电话 / 热线 / 号码 / 邮箱 / 联系方式 / 渠道 / 地址)+ 疑问词;
- 判据二:「投诉 / 客服 / 服务 / 人工」+ 渠道词同现;
- **并要求不带明确投诉意图**(`P2_COMPLAINT_INTENT_MARKERS`:我要投诉 / 投诉你们 / 要投诉…)。
**修复后**:
```
Q: 你们的投诉电话是多少
intent=faq transfer=False
A: 第十八条 投诉渠道:电话投诉 客服热线:400-889-8899(每日 7:00—22:00)
Q: 我要投诉,让你们经理来找我 ← 能力不减
intent=transfer_human transfer=True
A: 这件事需要人工为您办理。…
```
**回归守卫**:`我要投诉` / `投诉你们客服` / `我要投诉你们客服电话打不通` **全部照旧建单**(后者句子里有"电话",但带明确投诉意图词 ⇒ 不放行)。
---
## 4. 定位但**未修复**的缺陷(1 类)—— 需甲方裁定
### C-11 · 🟡 `E4` 证据约束生成的答复被"收益数值"闸门整条拦回 `E5b`
**现象**(实测,三条同因):`南方现金添利怎么样`(top1 分数 **1.0000**)/ `买基金要手续费吗`(**0.7328**)/ `债基和货基哪个收益高`(**0.5457**)—— 检索命中**完全正确**,客户拿到的却是「我先帮您把找到的公开资料放上来…」这种兜底话术。
**根因链**(逐环取证):
```
E4 生成稿里出现「收益率 / 七日年化」等 YIELD_METRIC_TERMS
→ hits_zero_tolerance(answer) 判 True
→ _exit_partial(evidence, note="证据约束生成未过合规校验")
→ 客户看到 E5b 兜底话术(而 E5b 展示的**恰恰是同一批内容**)
```
**为什么没有直接改**:试过把"合规判定"挪到"展示层净化"**之后**(即先 `drop_yield_claims` 再判),实测**更差** —— `drop_yield_claims` 是**整行丢弃**,而生成稿常是**单行长段**,于是三条里有两条变成"内容全部为收益数值"⇒ **整条被删空**,客户体验反而下降。改动已回退,并把原因写进代码注释留痕。
**这为什么是"决策"而不是"缺陷"**:`D2.2` / `D2.4` 的既定口径是「收益数值属不得输出内容」。E4 生成稿里引用**产品卡上的收益数字**算不算"输出收益数值",是**合规口径问题**,不该由代码单方面放宽。三个可选方案与我的建议见 §6 第 3 项。
---
## 5. 验收结果
### 5.1 金标 46 条(进程内 harness,`_eval_harness/score_w22c.json`)
| 指标 | `W20` 基线 | `W22` 本轮 | 判定 |
|---|---|---|---|
| `M-1` 出口准确率 | 46/46 = 100.0% | **46/46 = 100.0%** | ✅ 持平 |
| `M-2` Top1 命中率 | 28/31 = 90.3% | **28/31 = 90.3%** | ✅ 持平 |
| `M-2b` 难例命中率 | 15/18 = 83.3% | **15/18 = 83.3%** | ✅ 持平 |
| `M-3` 证据召回率 | 4/4 | **4/4** | ✅ 持平 |
| `M-4` 事实正确率 | 46/46 = 100.0% | **46/46 = 100.0%** | ✅ 持平 |
| `M-5` 引用不可解析数 | 0 | **0** | ✅ 持平 |
| `M-6` 转人工率 | 5/46 = 10.9% | **5/46 = 10.9%** | ✅ 白名单 5 条,**未新增** |
| `M-7/M-8/M-9/M-10` | 0/0/0/0 | **0/0/0/0** | ✅ 持平 |
转人工白名单(逐条未变):`F-05` / `G-01` / `G-03` / `G-04` / `G-05`。
### 5.2 全量回归
`1985 passed / 3 skipped`(`W20` 基线 `1969 passed / 3 skipped`)。新增守卫 **16 条**,分布:
| 文件 | 新增 | 守什么 |
|---|---|---|
| `tests/unit/core/test_customer_service_rules.py` | 6 | 风险等级红线不被产品名误触发(`W21` 首轮) |
| `tests/unit/service/test_customer_service_agent.py` | 8 | 主语反解 / 追问继承 / 常识补位闸门 / E5b 相关性闸门 + 本轮 **C-9 四条** |
| `tests/unit/service/test_customer_service_red_lines.py` | 4 | 风险等级反例 + 本轮 **C-8 两条 / C-10 两条** |
### 5.3 真机 HTTP 复验(`_w20_evidence/_w22_final.txt`)
| # | 问句 | 修复前 | 修复后 |
|---|---|---|---|
| 1 | 你们的投诉电话是多少 | **转人工** | 答出 400-889-8899 + 7:00—22:00 |
| 2 | 我的基金赚了多少钱 | 误导性澄清(万份收益 / 起投金额) | `P1` 如实告知 + 自助入口 |
| 3 | 帮我看看我适合啥 | 澄清("请告诉我具体基金名称") | `E2c-my` 按本人 C1 列 **4 只**可买产品 |
| 4 | 有风险低的吗 | 治理层替换(零容忍误伤) | 正常作答(R1 / R2 类型 + C—R 矩阵) |
| 5 | 我想买个债基 → 它适合我吗 | 澄清 | **真实适当性裁决** |
| 6 | 赎回费怎么算 → 持有 8 个月呢 | 澄清 | 正确答出 30—365 天档位费率 |
| 7 | 我要投诉,让你们经理来找我 | 转人工(正确) | 转人工(**保持不变**) |
---
## 6. 需要甲方决策的事项(附我的建议)
### 第 1 项 · 零容忍词的**输出侧**口径(最高优先)
**背景**:甲方明确提过"零容忍词会让转人工/兜底频率大增"。本轮实测**定位到具体的一处**:不是输入侧(客户问句),而是 **E4 生成稿的输出侧**(`YIELD_METRIC_TERMS`:收益率 / 年化)。
**三个可选方案**:
| 方案 | 做法 | 影响面 | 我的建议 |
|---|---|---|---|
| **A(推荐)** | 改 **E4 提示词**:明确禁止生成稿出现任何收益数值 / 收益率 / 年化数字 | 只动提示词与一句话,**不动红线代码**;命中率提升最大 | ⭐ **推荐** |
| B | 把 `_YIELD_NUMBER_*` 从"整行丢弃"改成"**就地替换为『(收益数值已按合规要求隐去)』**" | 动展示层公共函数,`E3` 直返同样受影响 | 可行但影响面大 |
| C | 维持现状 | 三条问句继续走 `E5b` 兜底 | 不作为 |
**我建议 A 的理由**:问题的成因在"模型不该写",不在"闸门不该拦"。改提示词是**在源头消除**,红线代码与单测一条都不用动;而且是**可回归**的(复跑那三条问句即可验证)。
**需要你给的一句话**:是否批准修改 `E4` 证据约束生成的提示词(禁止输出收益数值)?提示词走发布配置,需要重新发布一次。
### 第 2 项 · 「它适合我吗」类问题的**默认指代对象**
**背景**:修复后,`我想买个债基 → 它适合我吗` 会**自动**把上一轮提到的产品(南方稳健增利债券 A)当作指代对象并给出裁决。若上一轮提过**多个**产品(如产品清单),目前取**第一个**。
**需要你给的**:产品清单场景下,取"第一个"是否可以接受?还是要改成"反问客户选哪一只"?
**我的建议**:**取第一个可以接受,但加一句"我按上一轮提到的第一只(X)帮您核对"** —— 让客户看得见指代依据,错了也能立刻纠正。这比反问更省客户一步。
### 第 3 项 · 知识库里**没有**的产品被问到时的回答
**背景**:`科创债ETF南方怎么样` 的 top1 是 **南方稳健增利债券 A 的产品卡**(0.6696)—— 客户问 A,拿到的却是 B 的资料。这是"答非所问",比"没找到"更伤。
**需要你给的**:是否允许我在 **`E5b` 相关性闸门**里加一条:**问句点名了产品 X,而命中块讲的是产品 Y** ⇒ 判为"无共同业务词",改回**"我暂时没找到对应的公开资料"**?
**我的建议**:**加**。理由:金融场景下"诚实说没找到"优于"贴一段别的产品的资料";且这条闸门已有同源实现(`_hits_share_terms`),改动面很小。
### 第 4 项 · `E5b`「我先帮您把找到的公开资料放上来」的措辞
**背景**:`E5b` 是本轮退化条里最大的一类(6/16)。它的**内容往往是对的**(如「债基和货基哪个收益高」贴出的 FAQ 正文就是标准答案),但**开场白的语气**让它显得像兜底。
**需要你给的**:是否把开场白改成更像"作答"的措辞(例如「关于这一点,公开资料里的口径是:」),并且**当命中块本身就是一条 FAQ 问答对时,直接以答案正文开场**?
**我的建议**:**改**。这是**零风险**的改动(只动模板字符串),但对"看起来智不智能"的体感影响最大 —— 同样一段内容,开场白不同,客户对"这个客服会不会答"的判断完全不同。
---
## 7. 本轮改动清单(文件级)
| 文件 | 改动 |
|---|---|
| `app/core/customer_service_rules.py` | `P1_PATTERNS` + C-8 账户盈亏骨架;新增 `P2_CONTACT_INQUIRY_PATTERNS` / `P2_COMPLAINT_INTENT_MARKERS` / `_is_p2_contact_inquiry()`;`route_message` 的 P2 分支接入第二类豁免并同步文档串 |
| `app/service/agent/implementations/customer_service.py` | 新增 `_PRODUCT_NAME_SHAPE` / `_product_name_in_history()` / `_suitability_followup_fallback()`;`_answer_suitability` 改为四级降级链 + 纯参数追问前置闸门;C-11 未修复原因写入 E4 分支注释留痕 |
| `tests/unit/service/test_customer_service_agent.py` | +4(C-9 守卫) |
| `tests/unit/service/test_customer_service_red_lines.py` | +4(C-8 / C-10 守卫) |
| `.gitignore` | +`_chunks_report.txt` |
| `客服agent/D2.1-…Todolist.md` | 升 `v6.37`,登记本轮修订 |
| `开发文档/D1.1-文档索引与权威声明.md` | 升 `v1.13`,登记 `D4.8` 与本轮版本位移 |
> `W21` 首轮已完成的部分(7 类缺陷 C-1~C-7、语料补强 4 组 FAQ / 3 个 basic 章节 / 1 个 product 章节、`FAQ_EXPECTED_COUNT 65→69`、新集合条数 `basic 105 / product 398 / faq 300 / policy 576`)见 `客服agent\D2.1` 的 `v6.37` 条目,不在此重复。
---
## 8. 诚实留痕(别漏)
1. **打标口径变更**:`W20` 版把认不出的归「其它」(10 条盲区,逐条读原文发现 9 条其实是**正确作答**);`W22` 版翻转为"只识别退化形态"。两次分布**不可相减**,§2.3 已写明。
2. **C-11 是"未修复"而不是"不需要修"**。它已被精确定位到代码行与触发条件,只差一个**合规口径的批准**(§6 第 1 项)。
3. **本轮体检样本是本报告自己构造的**(81 条 + 8 组多轮),不是甲方给定的验收集。金标 46 条仍是**唯一**验收依据,体检集只用于**发现缺陷**。
4. **代码里 4 条 ruff 告警是既有的**(`customer_service_rules.py:318/416`、`customer_service.py:34/1188` 的 E501 与 I001),不是本轮引入,也**未顺手改** —— 避免把无关改动混进本轮 diff。
5. **真机复验依赖本地四依赖**(MySQL / Redis / Milvus / API+Worker)。复跑前须确认 `/internal/health/ready` 三依赖全绿,否则 `E5b` 会被误读成"缺陷"。
6. **`C-9` 的产物 `_PRODUCT_NAME_SHAPE` 是形状匹配、不是产品名白名单**:语料里新增产品名只要仍符合「南方 + 名称 + 产品类型后缀」就能被认出;若将来出现**不符合该形状**的产品名(如纯英文名),②级降级会失效并落到③级(知识检索)—— 功能不减,只是拿不到裁决。