闪电智能VoiceAgent 如何管理呼入、接听、桥接和挂断状态?

一通电话最容易被写坏的地方,不是 SIP 协议本身,而是数据库里那个看似无害的 status 字段。很多项目只有 ringing → answered → completed 三个状态,等到需要把用户桥接给 Agent、转到人工、处理重复回调或清理晚到事件时,所有逻辑都挤在几个 if 里。

这会制造一个危险的假象:电话一旦 answered,系统就以为可以查询订单、写 CRM、开始 TTS,甚至释放原呼叫。实际上,接听、媒体可用、Agent 已桥接、人工已接管,是不同的业务时刻。尤其在中文客服里,用户刚接通就改地址、报金额或要求人工时,状态错一次,后续的数据回流就会错。

我的做法是把呼入、接听、桥接、失败和结束写成一个有终态保护的状态机:只有进入 bridged 后才放行业务动作;回调必须按 provider event id 和序号幂等;completedfailed 之后不允许任何迟到事件把通话"复活"。

下面的示例不是 SIP SDK,而是一个可运行的生命周期模型。它的价值在于先把状态边界和测试固定下来,再去对接不同运营商、SBC 或实时音频平台。

目录

状态字段为什么会把电话系统写乱

先看一段很常见的伪代码:

python 复制代码
if callback.status == "answered":
    call.status = "connected"
    start_agent(call)
    run_business_tools(call)

它至少省略了四个问题:回调是不是重复的;它是不是晚到的;Agent 是否真的进入同一媒体会话;业务工具是否应该在桥接完成前执行。一个 answered 事件只说明呼叫腿已经被接听,不能证明对话系统已经可用。

例如,Twilio 的进度事件能提供 initiatedringingansweredcompleted,而在回调层面,事件会以独立 HTTP 请求送达,不能假定到达顺序与触发顺序相同。Twilio call progress callbacks 已明确说明这一点。换句话说,数据库不能用"最后到的回调覆盖前一个状态"作为一致性策略。

把通话拆成业务真正需要的阶段

对入呼 Voice Agent,下面这组状态比单一的 connected 更可操作:
#mermaid-svg-dr9TbhPoZLMtZXZ6{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-dr9TbhPoZLMtZXZ6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .error-icon{fill:#552222;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .marker.cross{stroke:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 p{margin:0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-dr9TbhPoZLMtZXZ6 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-dr9TbhPoZLMtZXZ6 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dr9TbhPoZLMtZXZ6 .edgeLabel .label text{fill:#333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .label div .edgeLabel{color:#333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .cluster-label,#mermaid-svg-dr9TbhPoZLMtZXZ6 .nodeLabel{color:#131300;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .note-edge{stroke-dasharray:5;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-note text{fill:black;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagram .edgeLabel{color:red;}#mermaid-svg-dr9TbhPoZLMtZXZ6 #dependencyStart,#mermaid-svg-dr9TbhPoZLMtZXZ6 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-dr9TbhPoZLMtZXZ6 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dr9TbhPoZLMtZXZ6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ingress accepted
provider answered
media + target participant ready
reject or routing error
bridge error
hangup or normal finish
caller cancels
caller hangs up
new
ringing
answered
bridged
failed
completed

状态 代表什么 允许什么 不允许什么
new 系统已创建本地记录 关联 provider id、校验入口 启动 Agent
ringing 对方尚未完成接听 记录路由、准备 dispatch 写入业务结果
answered 呼叫腿已被接听 继续建立媒体和调度 把任务当完成
bridged 目标 Agent 或人工腿可用 开始受约束会话、允许工具服务 忽略挂断与失败
failed 建连或桥接失败 进入兜底、保留原因 再次自动开场
completed 这次通话已经结束 清理资源、结算审计 被晚到事件重新激活

这里的 bridged 是业务状态,不是要求所有底层平台都叫这个名字。有的平台以 room participant、有的平台以 conference、有的平台以 SIP dialog 表示。关键在于:系统必须有一个可验证的"双方已处于可工作的同一会话"门。

桥接不是一个装饰性状态

为什么不在 answered 后直接调用工具?因为后面很可能还有这些事情:媒体仍在协商、Agent worker 未接到 job、TTS output 未绑定、目标人工坐席没有确认,或者用户已经挂断。

我建议把业务工具和会话首句都挂在 bridged 的 admission gate 后面:

text 复制代码
provider answered
  → 媒体/room/target participant ready
  → state = bridged
  → 才允许 ASR、受控 TTS、查询与写入工具

这并不意味着业务状态必须等到用户说完第一句话才开始。系统可以在 ringinganswered 阶段准备权限、知识库、坐席可用性,但不能把不可逆动作,例如创建投诉单、修改订单地址或宣布"已处理",放在桥接前。

在 LiveKit 一类基于 room 的电话链路中,dispatch rule 负责让 SIP participant 进入房间并可指定 Agent dispatch;这正好提供了一个把"电话入站"和"Agent 实际到位"分开的参照。LiveKit dispatch ruleAgent dispatch 都强调了显式调度的控制边界。

重复与乱序回调,应该在哪里被拦住

我更倾向在电话适配层就做三层保护,而不是让下游每个服务自己猜:

  1. 事件 ID 去重:同一个 provider event id 只应用一次;
  2. 序号或版本保护 :比本地 last_sequence 更旧的事件不回退状态;
  3. 终态保护completedfailed 后只记录迟到事件,不再改变状态。

这三层分别对付重放、乱序和资源泄漏。它们不是为了让日志好看,而是防止一条晚到的 bridge_ready 在用户已挂断后重新启动 Agent,或者一条重复的 answered 造成第二次工具调用。

如果 provider 没有可靠 sequence,不能凭空制造全局顺序。可以用 provider event id 去重,加上事件时间、接收时间、状态版本和允许的转移矩阵,必要时将冲突状态送入人工或异步对账。不要为了"自动恢复"让系统猜哪一条回调更真。

从最小状态机演进到可审计对象

示例中的 CallLifecycle 把这些约束写进对象,而不是散在 webhook handler:

python 复制代码
if callback.provider_event_id in self.seen_event_ids or callback.sequence <= self.last_sequence:
    self.ignored.append(callback.provider_event_id)
    return

if self.state in TERMINAL:
    self.ignored.append(callback.provider_event_id)
    return

接着才执行合法转移:

python 复制代码
if target is CallState.BRIDGED and self.state is not CallState.ANSWERED:
    raise ValueError("cannot bridge before the call is answered")

if target is CallState.ANSWERED and self.state is CallState.NEW:
    raise ValueError("answered callback requires a prior ringing state")

完整实现见 examples/call_lifecycle.py。它刻意只允许 bridgedbusiness_actions_allowed=True。这不是说所有查询都要拖到桥接之后,而是把会影响用户、CRM 或人工交接的动作放在一个稳定边界后。

运行示例与七项测试

text 复制代码
CSDN-通话状态机/
├── article.md
├── README.md
├── examples/
│   ├── call_lifecycle.py
│   └── run_demo.py
└── tests/
    └── test_call_lifecycle.py
bash 复制代码
python3 -m unittest discover -v
python3 -m examples.run_demo

7 项测试检查:正常通话必须桥接后才放行业务动作;重复 event id 与旧序号被忽略;未接听不能桥接;未振铃不能写入 answered;结束后的迟到事件不能复活;失败是终态。演示里的 callback 是合成输入,不等同于任一运营商的回调协议。

电话状态与 CRM、人工交接如何对齐

电话状态不应该直接覆盖 CRM 任务状态。它们解决的问题不同:

记录 该回答的问题
call state 电话与媒体会话是否还活着
task state 用户当前业务任务是否已确认、执行或失败
CRM state 外部系统是否真的接受并处理了请求
handoff state 人工是否已接到足够上下文并实际接管

例如用户在 bridged 后说"把地址改成 12 号楼",电话状态只说明可以听到这句话;任务状态还要记录字段确认;CRM 状态要等外部 API 真正成功;若转人工,handoff state 必须记录坐席是否连接。用一个 connected 同时表示这些结果,迟早会在投诉场景里出错。

应记录的事件与指标

每个回调记录至少带 call_id、provider call id、provider event id、sequence、received_at、state_before、state_after、ignore_reason、trace_id。号码、SIP Authorization、完整 SDP 和录音链接需要脱敏或在受控系统中单独保存。

我会持续看四类计数:

  • duplicate_callback_count:重放或重试是否异常;
  • out_of_order_callback_count:外部事件到达顺序是否恶化;
  • answered_to_bridged_ms:接听后真正可服务的等待;
  • terminal_late_event_count:资源清理是否有遗漏。

这些是指标定义,不是本文提供的线上数字。没有真实流量与线路分组,不应该用它们声称系统可靠性提升。

边界:哪些状态不能由模型决定

模型可以辅助生成对话摘要、判断用户是否要求人工,不能自行宣布电话已桥接、CRM 已写入或人工已经接通。这些状态必须来自可信的电话、工具或坐席系统事件。

身份核验、金额争议、投诉升级、医疗和法律类事项即使已经 bridged,也仍要遵循业务规则和人工边界。状态机解决的是系统一致性,不是替代业务责任。

参考资料

相关推荐
云浪1 小时前
Milvus + RAG 实战:《红楼梦》问答助手
前端·人工智能·后端
空堂与归1 小时前
机器学习如何入门?AI/ML/DL概念与建模流程全景
人工智能·机器学习
花花鱼1 小时前
TinyML Agent:MCU 上的微型智能体,AI 智能体下沉的底层实现
人工智能·单片机·嵌入式硬件
tech讯息1 小时前
商业化内容生产适用企业级视频生成模型云平台推荐|从模型生成至审核分发全链路 AWS 选型方案
人工智能·音视频·aws
独码侠1 小时前
FunASR 语音识别本地部署实录(下):34 分钟录音实测,踩过的坑、上线前要补的事
人工智能·语音识别
没落之王1 小时前
易学使者郑氏正脉郑冰推演马航事件
大数据·人工智能·算法·机器学习·可用性测试
wy_hhxx1 小时前
AI Agent & Harness Engineering 笔记
人工智能·笔记
yuhulkjv3351 小时前
Grok鸿蒙版导出word格式的终极解法:AI导出鸭如何重构AI内容到文档的最后一公里
人工智能·ai·word·harmonyos·ai导出鸭
ONEYAC唯样1 小时前
2026年中国电子元器件进出口全景分析:AI时代下的供应链重构与产业机会
人工智能