“请说一下订单号。”在产品演示里很自然,放到真实中文电话里却常常变成一串难以确认的数字:用户说得快,ASR 把“幺”和“一”混在一起,系统复述一次又一次,最后用户还要从头再报。遇到地址、手机号、订单尾号这类字段,纯语音并不总是更顺畅。
我的判断是:Voice Agent 不应该把 DTMF 当作语音能力不够时的羞耻兜底,也不该把所有数字字段一股脑推给按键。更可靠的设计是按字段风险和精确性选择通道:自然语言仍负责意图和上下文;需要逐位确认的非敏感数字可以走 DTMF 并回显确认;身份凭证、支付秘密或高风险操作不能因为“按键输入”就被当作安全,仍应交给合规验证流程或人工。
本文讨论 SIP/RTP 电话链路中的交互设计与状态处理。示例仅模拟 DTMF 事件去重、结束标识和路由选择;不提供支付、身份核验或任何敏感数据处理能力,也不代表真实运营商、电话网关或安全合规结论。
目录
- 纯语音在哪些字段上反而更脆弱
- DTMF 是一种事件,不是“把声音录下来”
- 按字段决定语音、按键还是人工
- 输入状态机要处理结束、重传和超时
- 用标准库夹具验证混合交互边界
- 中文客服里的确认话术与数据最小化
- 参考资料
纯语音在哪些字段上反而更脆弱
语音擅长表达意图和模糊偏好:“我要查上周的订单”“送到公司前台”“下午再联系”。但当系统需要逐位、不可猜测、可重复校验的字段时,语音链路里会叠加多个误差源:电话窄带、口音、背景噪声、ASR 数字归一化、用户停顿以及 TTS 复述。
这不等于“数字一律不能说”。订单后四位、楼栋门牌、验证码提示等字段的风险和可替代性不同。真正需要避免的是把一个字段的技术可识别性,误当作它可以被安全自动处理。
| 业务意图、配送偏好、问题描述 | 语音 + 必要确认 | 需要自然语言上下文,不宜强迫用户按菜单 |
| 非敏感、必须逐位一致的订单尾号 | DTMF + 读回/确认 | 降低 ASR 数字歧义;仍需绑定当前任务 |
| 地址、金额等可由用户纠正的业务字段 | 语音收集 + 结构化确认 | 不能只凭一串按键判断业务含义 |
| 身份凭证、支付秘密、一次性口令 | 合规验证流程或人工 | DTMF 不是通用安全通道,不能据此降低风险等级 |
所以问题不是“还需不需要按键”,而是系统有没有能力在正确时机切换通道,并把切换后的状态接回同一个用户任务。
DTMF 是一种事件,不是“把声音录下来”
在 RTP 电话场景中,DTMF 数字、电话音和电话信号可按 RFC 4733 的 named telephone events 形式承载。RFC 中定义了事件字段、结束位(E bit)、持续时间等概念;实现不应假设“收到一次数字就已经完成输入”。RFC 4733:RTP Payload for DTMF Digits
工程上至少要把下面三件事分开:
事件到达:收到某个 digit 的 RTP/网关事件。
输入确认:事件带结束标识,且在当前收集状态中有效。
业务提交:位数、任务关联、用户确认和权限都满足后,才交给工具或 CRM。
如果把第一步直接写成“订单号已输入”,重传、部分事件、超时或用户中断都可能制造重复字段。DTMF 在传输层表达的是按键事件,不自动解决业务幂等、会话隔离或用户确认。
按字段决定语音、按键还是人工
建议将路由规则做成可审计的字段策略,而不是散落在 Prompt 里。例如:
FieldRequest("order_suffix", numeric=True, requires_exactness=True)
# -> "dtmf_with_confirmation"
FieldRequest("identity_secret", sensitive=True, numeric=True)
# -> "handoff_or_compliant_verification"
这条规则有一个重要的优先级:sensitive=True 比“数字”和“可精确输入”优先。否则团队很容易把“ASR 不再听到它”误说成“已经安全”。是否能处理认证、支付或其他高风险信息,取决于业务授权、合规流程、端到端安全设计和人工责任边界,不能由一条 DTMF 路由规则替代。
混合交互的对话也不应机械化。系统可以先用语音解释为什么要按键:“为了确认订单尾号,请按键输入后四位;输入后我会只确认是否匹配,不重复播报完整号码。” 这样既保留了语音的引导能力,也减少了把完整数字暴露在 TTS、日志和人工摘要中的机会。
输入状态机要处理结束、重传和超时
一个最小状态机可写成:
#mermaid-svg-M94ZXeVDaXhYvRBW{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M94ZXeVDaXhYvRBW .error-icon{fill:#552222;}#mermaid-svg-M94ZXeVDaXhYvRBW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M94ZXeVDaXhYvRBW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M94ZXeVDaXhYvRBW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW .marker.cross{stroke:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M94ZXeVDaXhYvRBW p{margin:0;}#mermaid-svg-M94ZXeVDaXhYvRBW defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-M94ZXeVDaXhYvRBW g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-M94ZXeVDaXhYvRBW g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-M94ZXeVDaXhYvRBW g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-M94ZXeVDaXhYvRBW g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-M94ZXeVDaXhYvRBW .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-M94ZXeVDaXhYvRBW .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-M94ZXeVDaXhYvRBW .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-M94ZXeVDaXhYvRBW .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-M94ZXeVDaXhYvRBW .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-M94ZXeVDaXhYvRBW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M94ZXeVDaXhYvRBW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M94ZXeVDaXhYvRBW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M94ZXeVDaXhYvRBW .edgeLabel .label text{fill:#333;}#mermaid-svg-M94ZXeVDaXhYvRBW .label div .edgeLabel{color:#333;}#mermaid-svg-M94ZXeVDaXhYvRBW .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-M94ZXeVDaXhYvRBW .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-M94ZXeVDaXhYvRBW .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-M94ZXeVDaXhYvRBW .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW #statediagram-barbEnd{fill:#333333;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M94ZXeVDaXhYvRBW .cluster-label,#mermaid-svg-M94ZXeVDaXhYvRBW .nodeLabel{color:#131300;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-M94ZXeVDaXhYvRBW .note-edge{stroke-dasharray:5;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-note text{fill:black;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram-note .nodeLabel{color:black;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagram .edgeLabel{color:red;}#mermaid-svg-M94ZXeVDaXhYvRBW #dependencyStart,#mermaid-svg-M94ZXeVDaXhYvRBW #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-M94ZXeVDaXhYvRBW .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M94ZXeVDaXhYvRBW :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
field requires exact digits
partial / duplicate event
expected digits complete
timeout or user correction
user confirms
mismatch or sensitive action
voice_collect
dtmf_collect
confirm
submit
handoff
其中 dtmf_collect 不能只存一个字符串。它至少需要当前任务 ID、期望位数、已见事件 ID、完成状态、超时与用户取消原因。事件 ID 去重是为了避免同一结束事件被重复写入;结束标识是为了避免把尚未结束的信号当成一位完整数字;达到位数后仍应进入确认,而不是直接写 CRM。
业务提交前还有一个现实边界:用户可能在输入途中说“不是这个订单”,或按错后要求重来。此时旧输入必须失效,不能只在界面上清空而保留在下一轮工具参数里。这与地址、金额等状态更新问题一样,关键不在于把新值写进去,而在于明确旧值何时失效。
用标准库夹具验证混合交互边界
项目中的 examples/dtmf_router.py 有两部分。choose_channel() 只根据字段风险、数值性和精确性决定通道;DigitCollector 只在事件未重复、带结束标识、字符有效且未溢出时追加数字。
collector = DigitCollector(max_digits=4)
collector.consume(DtmfEvent("1", "1", end=True))
collector.consume(DtmfEvent("1", "1", end=True)) # duplicate_ignored
assert collector.value() == "1"
这个设计故意没有“把数字直接发到 CRM”的方法。缺少任务绑定、用户确认与权限校验时,收集器只应证明输入状态正确,不能替业务系统做提交决定。
cd "CSDN-VoiceAgent-语音DTMF混合交互(0828)"
PYTHONPATH=. python3 -m examples.run_demo
PYTHONPATH=. python3 -m unittest discover -s tests -v
8 项测试覆盖普通语音字段、精确订单尾号、敏感字段拒绝走普通 DTMF、部分事件、结束事件、去重、非法字符和位数溢出。它们不验证电话媒体质量、支付安全或身份核验合规。
中文客服里的确认话术与数据最小化
中文语音里“零/幺”“二/两”“十/拾”以及地址里的号、栋、单元,很容易让“复述确认”变成长而脆弱的 TTS 段落。混合交互可以降低逐位听辨负担,但仍应遵守最小化原则:
- 日志只保留输入长度、掩码、事件状态和任务关联,不默认落完整按键序列;
- TTS 只确认必要的掩码或匹配结果,不回播完整敏感号码;
- 用户中断或超时时,删除未确认的临时字段并记录原因;
- 转人工时传递“已收集/未确认/已失效”的状态,不传递超出坐席处理所需的明文。
我不建议把 DTMF 菜单扩展成整套客服体验。用户讲一个问题时仍应该能自然说话;只有当字段需要精确、风险允许、并且系统能把状态接回当前任务时,按键才是更短的路径。
参考资料
- RFC 4733:RTP Payload for DTMF Digits, Telephony Tones, and Telephony Signals
- RFC 3550:RTP

