Skip to content

Feat/customer service example - #301

Draft
usionkong wants to merge 9 commits into
QwenAudio:mainfrom
usionkong:feat/customer-service-example
Draft

Feat/customer service example#301
usionkong wants to merge 9 commits into
QwenAudio:mainfrom
usionkong:feat/customer-service-example

Conversation

@usionkong

Copy link
Copy Markdown
Contributor

变更说明

验证

  • npm test
  • npm run lint
  • npm run build
  • 行为变化已补充测试或说明无法自动测试的原因

兼容性与安全

  • 未提交密钥、用户数据、日志或内部地址
  • 配置、用户可见行为和依赖变化已同步更新文档
  • 已说明网络、权限、隐私、持久化或发布流程影响;不适用时请注明

kongyuxiang added 6 commits September 2, 2026 10:17
在 examples/ 下新增客服场景示例,本次交付服务层与零售域的只读闭环。

工程形态照 smart-cockpit:单一状态源 + 两个 MCP 工具面。前台面是后台面的
子集(白名单),两者调用同一份 executor —— 已实测前台面写入后台面立刻可见,
不存在两份状态或两个理解者。

policy 拆成两处:常驻的六段式人设(gateway/assistant/retail.md,只放身份、
语音约束与硬边界)与走 knowledge 检索的细则(domains/retail/policy.md)。
工具何时调用写在 MCP description 里,不进 prompt。

前台白名单只放核验与只读查询。写库类留给后台,因为只有后台能用
auth_required 让任务挂起等客户批准;前台工具没有这个机制,确认就只能靠
prompt,而那是守不住的。

两处刻意的设计:
- 未核验就查订单硬拒并留红色审计记录,而不是只记警告 —— 客户数据说出去
  就收不回来。纯顺序类的偏差才只记录。
- 查不到订单与查别人的订单返回同一句话,否则来电者能靠试探得知某个订单号
  是否属于他人。

db.json 手写 20 单,配 15 条数据完整性与场景覆盖断言:引用自洽、金额自洽,
并保证四种订单状态、无邮箱用户、超退款上限订单、缺货变体都存在,
否则对应场景没法演示。
README 里明确标注这是 draft、当前只有 service 层能跑,并把「已知缺口」按优先级
列出来 —— 其中 auth_required 端到端探链排第二,因为它在座舱示例里用不到,
可能从没在真实语音会话里跑过,通不通会影响工具在两个面之间的划分。

零售数据那一节把每个埋点对应的演示分支写清楚了(无邮箱用户逼出第二条核验分支、
2899 元订单触发转人工、家电 22 天超窗口、家具类在 policy 里刻意缺失)。
这些不是注释而是断言,db-integrity 测试守着它们。

README 里的四条命令与四个端点都照抄实跑过一遍。

.env.example 的端口刻意避开 smart-cockpit(3010/3020/18888/5173),
两个示例可以同时起。
新增 returns 领域:cancel_order / return_items / modify_address /
transfer_to_human,全部只在后台面。

## 批准机制改用两段式,不再用 user_confirmed 参数

原方案是让写库工具带一个 user_confirmed,靠 prompt 要求模型「问过客户再填
true」。那守不住:模型可以不问就填,我们只能事后在审计里发现,而钱已经出去了。

改成第一次调用只返回预览与一枚令牌、不碰数据库,拿着令牌再调一次才执行。
模型没有「跳过批准」这个选项 —— 它拿不到令牌就执行不了。这把流程约束变成了
数据依赖。

三处细节:
- 令牌绑定「动作 + 对象」,不是通用通行证。否则能拿取消 A 单的批准去取消 B 单;
  部分退货还要把款式写进 subject,「退耳机」的批准不能拿去退鼠标。
- 令牌一次性,取出即删。否则一次批准可被重放成多次退款。
- 预览里的金额由 executor 算,不经模型的手。

## 资格判定:查不到就说查不到

退货时限表抄自 policy 第二条,而家具类在细则里确实没写 —— 表里也就没有它。
查不到时返回「细则未覆盖,需转人工」,不挑一个看起来合理的天数。这是「不许
编造」的机制保证:不靠 prompt 请模型别编,而是工具本身给不出数字。

退款超上限时【不发令牌】,直接要求转人工。若只在预览里写「金额较大建议转人工」,
模型照样能往下走。

## 修掉一条假测试

「令牌一次性」原本用 cancel_order 写,断言写成「令牌错误 或 状态错误 都算过」。
取消后订单变 cancelled,重放会被 not_pending 拦住 —— 于是把令牌一次性去掉,
测试照样绿。反证时才发现它什么都没测。

改用 modify_address:它不改变 status,重放唯一能被拦住的理由就是令牌已消耗。
另拆一条测试单独守「状态变化也能拦住重复退款」,两个机制各测各的。

56 条测试通过。三处反证均验证有效:去掉令牌一次性、放开退款上限、给家具编一个
时限,各自都能让对应测试变红。
写库工具已交付,缺口第 1 条移除;auth_required 探链升为第一位。
新增一节说明为什么用两段式令牌而不是 user_confirmed 参数,
并写明它与 auth_required 是两层:令牌保证「没批准就执行不了」,
auth_required 负责把问题送到客户耳边 —— 即使后者不通,前者仍拦得住。
新增 agent/:model / mcp-client / executor / server,照 smart-cockpit 的 A2A
形态。后台连 /mcp/backend 拿完整工具面。

## auth_required 这条链是通的,实测过了

它在 realtime 侧代码一直是接通的,但座舱示例用不到(开天窗不需要客户批准),
所以此前没有证据说明它真能跑。现在有了:

  工具返回 needsApproval
    → executor 发 TASK_STATE_AUTH_REQUIRED + 预览消息
    → adapter 转成 InputRequest{kind:'authorization', status:'pending'}
    → respondInput({action:'accept'}) → 任务恢复 → 带令牌执行 → 订单 cancelled

拒绝路径也测了:respondInput({action:'decline'}) 之后订单保持 pending。

## 过程中撞到三个真实问题

一、首个事件必须是 Task。直接发 statusUpdate 会被客户端拒:
   Received statusUpdate before initial 'Message'/'Task' event.

二、但恢复执行时不能重发 Task,否则报
   Stream ordering violation: received task in task lifecycle stream.
   所以改成只在 requestContext.task 不存在时发。

三、【设计缺陷,不是测试问题】恢复执行时丢了 approval_token。
   runServiceAgent 每次都是空对话开局,模型看不到上一轮的工具返回,
   于是又取了一次预览、又挂起一次 —— 客户会被问第二遍。
   修法:挂起时把预览原文一起存下来,恢复时拼进 objective 交回模型。
   预览里含令牌,这是唯一能把它送回去的载体。

## 一条测试写法上的教训

等 InputRequest 最初写成 setTimeout 轮询事件数组,结果整个测试挂死 45 秒:
submit 没被 await(挂起期间它不返回),轮询又持续占着事件循环,
后台任务推不下去。改成在 subscribe 回调里直接兑现 Promise。

另外事件字段是 event.input 而不是 event.payload.input —— 靠探针打印真实结构
才发现,集成测试超时只能说明「没等到」,说明不了卡在哪一环。

service 56 条 + agent 5 条通过,主干测试未受影响。
反证:去掉 executor 里的 needsApproval 抛出,对应测试立刻变红。
新增一节写清这条链的完整序列与拒绝路径,并把接它时撞到的三个问题列成表:
首个事件必须是 Task、恢复时不能重发 Task、恢复时丢令牌。
第三个标注为设计缺陷而非测试问题 —— 真实模型同样看不到上一轮的工具返回。

缺口第 1 条更新为「Gateway 装配 + 真实语音会话验证」:
目前是用 A2ABackendAdapter 直接对接验证的,还没经过 realtime 那一层。
@x-lixu

x-lixu commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

感谢 @usionkong 提交这么完整的客服语音场景示例 🙌 这是我们继 examples/car 之后第二个场景级示例,方向和框架化定位非常契合:Gateway(语音前台 + GCP)保持不变,业务逻辑收敛在 customer service 自己的 agent/MCP 服务与 retail 域配置里。

为了加快评审,麻烦补全 PR 模板:

  1. 变更说明:客服场景的架构简述(gateway / agent / service 三层各自职责),以及 domains/retail 的定制方式
  2. 验证:勾选并说明 npm test / npm run lint 的实际运行结果(example 目录是否纳入根 CI?若否请注明手工验证方式)
  3. 兼容性与安全:确认 db.json / .env.example 不含真实密钥或用户数据

另外建议 README 顶部加一句"本示例演示如何基于 Gateway Client Protocol 将语音运行时接入客服场景",方便读者建立心智。期待评审通过后合入。

kongyuxiang added 3 commits September 2, 2026 14:35
管理台的第一块:读 policy.md,抽出阈值、类别时限、转人工触发条件、顺序依赖,
以及 policy 自身的空白。每条都要求 quote 是原文句子,并落回行号。

## 实测到的稳定性差异,直接决定了分栏判据

同一份 retail policy 连抽三次(temperature: 0):

  数值类(阈值、类别时限)  三次完全一致,行号也一致
  语义类(order_rules)    三次都不同,其中两次对同一句原文抽出【相反的顺序】:
                           「复述新地址 → 修改地址」与「修改地址 → 复述新地址」

所以 order_rules 一律进待决定栏,不看模型给的 confidence。
一条方向错了的时序规则进了 flows.json,FlowPanel 会把正确流程标成「跳步」——
那比不显示进度更糟。数值类可以直接生成配置。

这不是 prompt 写得不够好,是「哪个动作在前」依赖对业务的理解,
而那份理解不在 policy 文本里。

## 三个抽取质量问题,都是实跑发现的

一、模型把文档章节顺序当业务流程顺序,8 条 order 规则里 7 条是错的
   (「退款 → 订单取消」业务上完全反了)。prompt 收紧 + 代码侧拦「两端都是章节名」。
   判据用「两端」而不是「任一端」:第一版按任一端拦,把正确的
   「身份核验 → 办理任何业务」也降级了 —— 「身份核验」既是章节名也是合法动作。

二、模型把我 prompt 里的示例句当成了原文素材,抽出一条 policy.md 里
   根本不存在的规则。行号校验机制正确地抓住了它(quote 落不回 → 降级)。
   示例改成占位符形式,并明说「不要拿本说明里的文字当 quote」。

三、四条抽对了的类别时限被推给人手填:partition 对 window 用了 confidence 判据,
   而 schema 里没要求模型给这个字段,它一律缺省成 ambiguous。
   改成「有可执行的数值 + quote 可核」即算确定,不看模型的自我评价。
   days 缺失时再从 quote 里解析(「30 天」是确定信息,不该麻烦人)。

## 一条单测抓到的真 bug

strip 只清了中文标点。模型输出中文时常把「,」写成半角「,」,
于是只差标点形态的 quote 落不回原文,被误判成幻觉。补上半角标点。

console 13 条测试通过(不调模型,喂固定的模型输出锁判据)。
管理台的第二块。给每个工具输出「建议去哪个面 + 为什么 + 推翻的后果」。

## 这一项刻意不调模型

判据全部来自 manifest 里已有的事实:readOnlyHint / destructiveHint /
monetaryHint、必填参数个数、schema 里有没有 approval_token。这些是确定的。

而上一个 commit 的实测已经说明:order_rules 那种需要语义判断的项,
同一份 policy 连抽三次会给出三个结果,其中两次方向相反。
能用规则的地方不该交给模型。

## 与手写白名单交叉验证一致

surfaces.mjs 不读 FRONTEND_TOOL_NAMES,它只看工具自身的标注与 schema。
两条独立路径得出完全相同的五个前台工具 ——
说明手写白名单与工具声明的性质没有矛盾。

这条写成了断言。以后如果变红,要么是新工具漏了标注,要么是白名单被手改跑偏。

## 判据的顺序是有意义的

RULES 按优先级排列,命中第一条就定。monetary 必须排在 single_step_write
之前 —— 否则一个「涉款但只有一个必填参数」的工具会被放到前台。
反证验证过:把 monetary 挪到后面,对应测试立刻变红。

推翻建议时分两级:建议后台却改到前台是 risk(会绕过 auth_required),
反方向只是 slowdown(多 1~3 秒静默,不影响正确性)。
这个不对称来自工具面的「全集 + 子集」结构 —— 后台永远有完整能力兜底。

console 24 条测试通过。
回答「管理员改了 pipeline 以什么形式生效」:executor 读配置,不是改 prompt。

## schema 借 DMN 的决策表形态

arXiv 2505.11701(DMN-Guided Prompting)指出的问题正是我们的问题:
"Since decision logic is typically embedded in prompts, it becomes challenging
 for end users to modify or refine it."

管理员改不了 prompt 里的逻辑,也改不了 executor 里的 if。表格他能改。

借了三个概念:
- Hit policy(first / unique / collect)—— 规则重叠时怎么办。unique 下重叠
  直接报错而不是随便挑一条,「退货窗口既是 30 天又是 7 天」这种冲突必须暴露。
- 兜底行(wildcard)—— 「未覆盖」从代码里的 undefined 分支变成表里显式一行。
  家具类在 policy 里没写时限,表里也没有它,兜底行把它导向转人工。
- 区间记法 [a..b] / ]a..b[ —— 「7 天内」和「超过 7 天」差一天就是两种结果,
  开闭必须能精确表达。

只实现 FEEL 的最小子集(比较、区间、字面量、通配)。多出来的表达能力换不到
东西,却会让「管理员能看懂这张表」这个前提失效。

## 搬走的东西

从 returns/execute.mjs 与 orders/execute.mjs 里搬出:退货时限表、退款上限、
哪些状态能取消/退货/改地址、取消原因的枚举、以及五个工具的身份前置条件。
executor 现在只负责取出决策输入(类别、天数、状态、金额)和翻译结果。

shared.mjs 里的 guardVerified 删掉了 —— 它把「必须已核验」写死在代码里。

## 这修正了之前「不做工具级状态机」的结论

当初反对的三条理由:前置条件随场景变、硬编码把场景差异写进工具定义、
枚举不全那个场景就不能用。配置化解决前两条;第三条靠一个缺省行为解决 ——
没在 preconditions 里声明的工具照常执行,只是少一道保护。
于是「枚举不全」从「功能缺失」退化成「保护缺失」,代价可接受。

## 实测五组配置改动,行为全部随之改变

  家电时限 15 → 30 天    超期被拒 → 放行到批准环节
  退款上限 2000 → 500    899 元自行处理 → 要求转人工
  删掉退货的身份前置      未核验被拒 → 不再拦
  枚举加「太贵了」        原因被拒 → 接受
  兜底行 policy_gap→allow 家具类转人工 → 直接放行

探针跑完自动还原,确认 guards.json 未被改动。

## 一个自己挖的坑

isCatchAll 第一版用「拿 Symbol 去试匹配」来反推是否通配,
结果 Number(Symbol) 直接抛 TypeError,把整张表的求值都带崩。
改成独立的 isWildcard 判断。

service 81 + agent 5 + console 24 = 110 条测试通过。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants