砍价被当下单:客服智能体状态机实测
作者用雨刷店客服原型测试意图误判风险,通过四状态流程控制对话,将模型职责限制为意图和车辆信息解析。项目仍是 v0.2 脚手架,默认使用规则解析器及模拟目录、价格,尚不能接真实订单。
AI 深度解读
客户问「可以便宜点吗」,雨刷店的 WhatsApp Agent 却直接记下一笔原价订单。问题没有出在模型编造价格,而是规则解析器把「可以」识别成了确认购买。
作者对 Wiper-Service-Agent 的测试说明:状态机能稳住报价和流程,却挡不住被错误识别的意图。对正在做客服、销售或业务 Agent 的程序员,这篇最有用的不是选型建议,而是怎样发现「程序跑通了,业务却记错了」的缺口。
56 个测试全通过,为什么还会记错订单
Wiper-Service-Agent 还是 scaffold v0.2,测试版本为 commit 9675291。它只有覆盖 5 个品牌的 8 条模拟适配记录,以及一张演示价格表,作者明确表示不能接真实订单。
它把职责分成三层:状态机决定流程,工具查询尺寸和计算价格,解析器提取意图及车辆信息。默认解析器是标记为 SIMULATED 的 MockLLM,使用确定性规则;RealLLMProvider 则负责接入真实 LLM,但同样只输出意图和槽位(slots),也就是品牌、车型、年份这些结构化字段。
流程只有四个状态:
GREETING → COLLECT_VEHICLE → QUOTE → DONE
车辆资料完整就查适配表,找到后报价;报价阶段可以回答问题、议价、确认或拒绝。进入 DONE 后再发消息,会清空旧会话并重新开始。
作者重跑仓库测试,结果是 56 passed、0 failed,README 仍写着过时的 44。随后又用未修改的代码执行了 27 段脚本对话、62 条消息、13 个意图冲突探针,独立复算全部 8 条适配记录,并检查持久化和双客户隔离。
正常路径确实稳定。分次输入 Toyota Camry 和 2018,询问安装后确认,最终会记录 26/20 英寸雨刷与 $26.98 报价。完整西班牙语对话也能走通,最终记录 detected_language: es。
计价工具 pricing.build_quote 使用三档单价:不超过 20 英寸为 $12.99,21–24 英寸为 $14.99,25 英寸及以上为 $16.99。Camry 小计 $29.98,优惠 10% 后为 $26.98,议价后为 $25.48。
这里的额外 5% 按原始小计计算,最终支付小计的 85%,不是对九折价再打九五折。8 条记录在议价前后共 16 种组合均核对通过。
不过,准确计算不等于规则描述准确。注释把 10% 称为主副驾驶组合优惠,代码却对包含后雨刷的整个小计打折。唯一带后雨刷的 RAV4,小计 $42.97,报价 $38.67,议价后 $36.52。金额没算错,业务口径仍需统一。
真正危险的,是把一句话过早当成成交
MockLLM.parse 的判断顺序是:
decline > confirm > haggle > FAQ > price > greeting > provide-vehicle > unknown
多数关键词用 w in low 做子串匹配,只有 sí、да、نه 三个短词使用词边界。确认词包含「买」「好的」「可以」「ok」「yes」,拒绝词里还有裸写的 no。
于是,13 个探针里有 7 个分类明显偏离说话者意图。
「可以便宜点吗」先命中「可以」,被识别为 CONFIRM;「好的,多少钱」也先确认,根本走不到价格问题;「我想买,先问问保修」虽然检测到了 warranty 主题,仍被「买」提前截走。「yes but too expensive」同样如此。
拒绝判断更机械。「I don't know」和「another question」都含有连续的 no,因此被归为 DECLINE。代码已有 CONFIRM_TOKENS / DECLINE_TOKENS 词边界机制,只是没有把 no 放进去。
作者把最严重的三个案例放回真实会话重放:报价时问「可以便宜点吗」,系统进入 DONE,记录原价 $26.98 的确认意向;先问保修,也直接确认;表示「I don't know」,则被记录为拒绝。
这里记录的是订单意向,原文没有证明它会实际扣款或履约。但店主若据此备货、联系客户,错误已经会进入人工业务流程。
结束交易所需的证据,不该比回答一个问题还弱。
作者建议先修规则:给短词加边界,收紧确认条件,让 FAQ、价格和议价等可继续对话的请求先得到处理,再考虑确认或拒绝。之后增加 CONFIRMING 状态,回读车辆和总价,拿到明确答复才进入 DONE。
这些是修复建议,原文没有实现后的对照结果,也没有测出多一次确认会损失多少成交率。更稳妥的方向有依据,具体收益仍待验证。
换上真实 LLM,也修不好旧报价残留
另一组问题发生在车辆字段和报价的生命周期上。
客户先为 Altima 2019 议价,价格从 $26.98 降到 $25.48,haggled 标记设为真。随后换成 Civic 2020,系统重新报价 $26.98,却保留已议价标记。再次议价时,它会说「已经是最低价」,尽管当前报价没有享受过议价优惠。
原因很具体:优惠标记属于整个会话,报价却可以随车辆更换。新报价既没继承优惠,也没获得重新议价的资格。
车辆提取失败还会与旧字段拼出错误结果。已有 Camry 报价时,客户输入「BMW X5 2020」。由于提取器只认识模拟目录中的品牌和车型,BMW、X5 都没被提取,只留下年份 2020。字段合并保留旧 Toyota Camry,最终自信地给出 Camry 2020 报价。
这与查询失败不同。「Toyota Camry 2005」能完整提取,但 wiper_db.lookup 找不到,会明确告知未找到。BMW 案例却在查询前就被旧字段补成了另一辆车。
反过来,只输入「2025」会超出 Camry 的 2018–2024 年范围,系统正确退回 COLLECT_VEHICLE,却没清除旧的 $26.98 报价。客户再说「算了」,拒绝记录里就出现了没有适配结果的 Camry 2025,以及旧车型的报价。
FAQ 也受状态限制。安装、保修、寿命、支付、自提、配送六类问题,在 QUOTE 都能回答并保持报价不变;在 GREETING 或收集车辆阶段询问,却只会收到问候或补资料提示,没有待办问题机制。客户必须再问一次。首次消息说「不要了」,也因为 GREETING 没有拒绝分支而得到问候。
解析器负责理解输入,业务代码仍须验证这些输入能否改变当前交易。
换 RealLLMProvider 或许能改善复合句理解,却不会自动清理旧报价、调整优惠标记,或补上被丢弃的问题。
我的判断
我认可作者把尺寸、价格和流程放在确定性代码里的选择。全部报价复算通过;持久化恢复后,QUOTE、$25.48 优惠价、标记和 4 条历史记录完整保留;两个交错客户也没有串数据。这些是具体测试结果。
但「问题不在状态机,只在入口契约」说得偏满。入口过度信任解析结果是一部分,状态覆盖不足、派生数据失效规则缺失,也是业务流程设计问题。四个状态画得清楚,不代表业务约束已经完整。
与直接让 LLM 主导对话相比,这种架构便于核对金额和限制动作;与堆关键词相比,真实 LLM 可能更擅长理解复合请求。不过作者没有对 RealLLMProvider 重跑 13 个探针,不能据此宣称替换后错误率会下降多少,更不能把 MockLLM 的错误当作真实模型的实测表现。
覆盖范围也有限:没有进行 WhatsApp webhook 签名、Cloud API 投递到订单记录的端到端测试;俄语和波斯语只验证了问候与语言识别,模板未经母语者审校。真实语音转写、混合语言和一句话两辆车,也没有系统测试。
这是一份有价值的小规模故障报告,足以暴露机制缺陷,还不足以证明真实营业环境中的可靠性。
能拿来做什么
- 做客服或报价 Agent 时,把「可以便宜点吗」「我想买,先问保修」「I don't know」加入回归集,同时断言最终状态、报价和意向记录,别只检查回复文案。
- 在 Node 服务的确认入口增加车辆与金额回读,并要求明确确认。把「有购买兴趣」和「确认当前报价」分开处理,避免一句含有「买」的话就生成确认记录。
- 把适配结果、报价和优惠资格作为关联数据管理。车辆变更或查询失败时,明确规定哪些数据重算、哪些清空,防止新车辆携带旧报价。
- 前端展示客服会话和订单意向时,让车辆、报价及确认依据可被人工核对;更换解析器后,继续运行同一组业务断言,检查理解改善是否真正传递到了记录结果。