news 2026/10/1 17:11:00

形近字归一化把两个客户合并成一个人:一次“串聊”事故的技术复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
形近字归一化把两个客户合并成一个人:一次“串聊”事故的技术复盘

客服机器人把 A 客户的订单信息、聊天历史,回给了另一位毫不相干的 B 客户——不是提示词写错,也不是模型抽风,而是上游一个看似人畜无害的"形近字归一化"模块,把"李文宇"和"李文字"两个真人合并成了同一个人。本文按时间线复盘这起串聊事故:它是怎么发生的,为什么会在测试里漏掉,以及修复时沉淀下来的三条通用原则。

背景:为容错而生的形近字映射表

这套客服系统的输入里,有相当一部分来自截图和 OCR:客户转发的聊天截图、12px 小字的消息标题、低分辨率的缩略图。在 12px 的字号下,"宇"和"字"只差顶部一小截笔画,OCR 把"宇"认成"字"的概率不低。人名"李文宇"被读成"李文字",后续按人名做会话归属匹配时就对不上号,机器人便认不出这位客户。

为了容错,我们建了一张形近字映射表,收的都是真实出现过的误读对:宇↔字、己↔已、末↔未 这一类。整体流程是:OCR 得到文本 → 查表把形近字映射成"标准形" → 再拿归一化后的名字去匹配会话归属。

思路本身很常见,问题出在"归一化"被当成了无条件执行的动作。

事故现场:四个锚点都指向另一个人

那天坐席侧反馈:机器人回复张冠李戴,把别的客户的事安在了当前客户头上。

排查结论是:真机上 4 个锚点——历史消息、群昵称、备注、近期消息发送者——全部命中"李文宇";而当前窗口里的对话者,是另一位真实存在、名字就叫"李文字"的客户。归一化之后,"李文宇"和"李文字"都变成同一个标准形,两者相等,系统判定"当前会话就是李文宇的会话",于是用李文宇的上下文——他聊过的订单、提过的诉求——去回复李文字的消息。

出问题的匹配逻辑,简化后大致是这段:

def normalize(text: str) -> str: # 形近字映射:宇->字,已->己 … return "".join(SIMILAR_MAP.get(ch, ch) for ch in text) def find_session(msg) -> Session | None: name_ocr = ocr(msg.screenshot) # "李文字" 或 "李文宇" key = normalize(name_ocr) # 两者都变成同一个标准形 for s in sessions: if normalize(s.owner_name) == key: # 两个真人撞在一起 return s return None

这段代码单看没有语法错误,问题在于它把"猜测"当成了"事实":归一化是一种猜测——这个名字也许是 OCR 误读的结果;而"李文字"是真人、是既成事实。当猜测作用于一个真名字上,两个不同的人就被无声地合并了。更隐蔽的是,测试用的样例里恰好只有"李文宇 + OCR 误读"这一种组合,没有构造过"李文字真人在线"的反例,所以全链路测试是绿的。

连锁反应:污染不止一层

事故本身只是开始,后续两件事让影响放大。

其一,被误拦的那几条回复,已经带着李文宇的上下文写进了李文字的会话历史。之后即便匹配逻辑修好,李文字的上下文窗口里也躺着一段"他从没说过的话",机器人后续回复继续被这段脏上下文带着跑。串聊不止是"回错一次",而是把错误写进了记忆,需要人工清理会话历史才能止损。

其二,运营同学为了兜住"系统认不出李文宇"的工单,把 OCR 误读出来的"李文字"当作别名,补进了李文宇的白名单——方向反了。这下李文宇一个人裂成两个身份,原本按白名单精确匹配的路径开始分裂:同一个客户在不同场景下被当成两个人,统计、召回、跟进全都对不齐。

修复:三条可以复用的工程原则

原则一:精确命中白名单时原样返回,绝不再做归一化

白名单是人工确认过的事实,归一化是模型给的猜测,事实的优先级高于猜测。只要输入精确命中白名单里的名字,就直接原样返回、直接精确匹配,不再经过任何形近字映射。修复后的守卫逻辑大致如下:

def resolve_key(raw: str) -> str: # 1) 精确命中白名单:这是事实,原样返回 if raw in WHITELIST: return raw # 2) 多锚点一致且无 OCR 参与:高置信,原样返回 if is_multi_anchor_agreed(raw): return raw # 3) 仅当确认来自 OCR 且在疑似误读集合里,才允许归一化 if comes_from_ocr(raw) and raw in OCR_SUSPECT_SET: return normalize(raw) return raw

守卫的含义很直白:归一化从"默认路径"降级为"持证上岗"——必须先证明当前输入确实可能是误读,才有资格动手改写。白名单命中、多锚点一致这类高置信输入,一律绕开模糊层。

原则二:形近字表只收真实误读对,并加硬约束

映射表不允许出现"看起来像就可能混"的字对,只收线上真实发生、且经过人工确认的误读对;每个条目要能追溯到具体 badcase。同时加一道结构性约束:两个字若要定义为"可归并",在字表里的差异度必须足够大——规则是"字表差异≥2 字才可并"。像"李女士/李先生"这种只差 1 个字的称呼,无论 OCR 给出多高的置信度,永远不会被并成同一个人。这类称呼恰好是客服场景里密度很高的词,1 字之差就是两个人,宁可让少数误读漏过去,也不能让真人被并掉。

原则三:容错逻辑下沉到归一化层,不散在判断分支

修复前,散落在各判断分支里的容错补丁有七八处:有的先精确比对再做模糊匹配,有的做前缀截断,有的看编辑距离。同一类容错在多处各写一遍,行为不一致,排查时没人说得清哪一层在起作用。修复后,所有模糊匹配收敛到归一化这一个入口,上游各分支只做精确比较。容错变成一个可单测、可审计、可灰度的独立模块,而不是散在代码里的暗桩。

通用教训:模糊化之前先问一句

这起事故不该算在 OCR 头上,也不是映射表本身的错,而是"为了容错而模糊化"这个动作没有边界。复盘之后,我们给团队立了一条评审准则:凡是打算引入"为了容错而模糊化"的逻辑——归一化、模糊匹配、别名合并、相似度阈值——都必须先回答一个问题:两个真东西会不会被模糊成一样?

  • 会:必须先加守卫,比如白名单原样返回、来源校验、差异度下限,把模糊化的作用域压到已证实会出错的输入上。
  • 不会或不确定:才允许默认开启,同时给出可以一键关掉它的开关。

容错的价值在于兜住少见的错误输入,代价是给所有正常输入加了一层不确定性。好的容错,应该让这层不确定性只作用于真正需要它的那一小部分输入,而不是让所有"李文字"陪着"李文宇"一起为 OCR 的误读买单。

参考文章

串聊的本质是"会话归属判错",它与"机器人什么时候该交还给人"属于同一类边界设计问题,下面两篇做了更系统的梳理:

  • 人机配合:自动回复转人工的触发设计
  • 客服回复模板库:高频话术整理
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 17:09:53

PyTorch+OCR实现火车车厢号识别:从检测到部署

简介:面向铁路货运管理、物流追踪与智能交通等对识别效率与精度要求较高的应用场景,这套基于PyTorch框架的OCR深度学习方案,专门解决火车车厢编号的自动识别与提取问题。从图像批量预处理、文字区域检测到序列识别,代码覆盖了完整…

作者头像 李华
网站建设 2026/10/1 17:08:25

AgentScope Java 实战 04:互通层——A2A 协作与 Nacos 接线

03 篇结尾留了一句话:主干还剩最后一篇。这篇补上互通层——Agent 对外暴露成 A2A 服务、按需调用远端 Agent,以及 Nacos 的 Prompt / Card / Skill 三条 AI 通道和它们的现实限制。这也是本系列主干(01-04)的收尾篇。前三篇把单个…

作者头像 李华
网站建设 2026/10/1 17:07:09

Go CI/CD:GitHub Actions 与 GitLab CI 全流程实战

Go CI/CD:GitHub Actions 与 GitLab CI 全流程实战高效 CI/CD 是研发心脏。本文以两个主流平台为案例,从语言环境到多阶段流水线讲透。一、GitHub Actions 基础 .github/workflows/go.yml: name: Go on: [push, pull_request]jobs:test:runs-…

作者头像 李华