一通电话最容易被写坏的地方,不是 SIP 协议本身,而是数据库里那个看似无害的 status 字段。很多项目只有 ringing → answered → completed 三个状态,等到需要把用户桥接给 Agent、转到人工、处理重复回调或清理晚到事件时,所有逻辑都挤在几个 if 里。
这会制造一个危险的假象:电话一旦 answered,系统就以为可以查询订单、写 CRM、开始 TTS,甚至释放原呼叫。实际上,接听、媒体可用、Agent 已桥接、人工已接管,是不同的业务时刻。尤其在中文客服里,用户刚接通就改地址、报金额或要求人工时,状态错一次,后续的数据回流就会错。
我的做法是把呼入、接听、桥接、失败和结束写成一个有终态保护的状态机:只有进入 bridged 后才放行业务动作;回调必须按 provider event id 和序号幂等;completed 与 failed 之后不允许任何迟到事件把通话"复活"。
下面的示例不是 SIP SDK,而是一个可运行的生命周期模型。它的价值在于先把状态边界和测试固定下来,再去对接不同运营商、SBC 或实时音频平台。
目录
- 状态字段为什么会把电话系统写乱
- 把通话拆成业务真正需要的阶段
- 桥接不是一个装饰性状态
- 重复与乱序回调,应该在哪里被拦住
- 从最小状态机演进到可审计对象
- 运行示例与七项测试
- [电话状态与 CRM、人工交接如何对齐](#电话状态与 CRM、人工交接如何对齐)
- 应记录的事件与指标
- 边界:哪些状态不能由模型决定
- 参考资料
状态字段为什么会把电话系统写乱
先看一段很常见的伪代码:
python
if callback.status == "answered":
call.status = "connected"
start_agent(call)
run_business_tools(call)
它至少省略了四个问题:回调是不是重复的;它是不是晚到的;Agent 是否真的进入同一媒体会话;业务工具是否应该在桥接完成前执行。一个 answered 事件只说明呼叫腿已经被接听,不能证明对话系统已经可用。
例如,Twilio 的进度事件能提供 initiated、ringing、answered、completed,而在回调层面,事件会以独立 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、查询与写入工具
这并不意味着业务状态必须等到用户说完第一句话才开始。系统可以在 ringing 和 answered 阶段准备权限、知识库、坐席可用性,但不能把不可逆动作,例如创建投诉单、修改订单地址或宣布"已处理",放在桥接前。
在 LiveKit 一类基于 room 的电话链路中,dispatch rule 负责让 SIP participant 进入房间并可指定 Agent dispatch;这正好提供了一个把"电话入站"和"Agent 实际到位"分开的参照。LiveKit dispatch rule 与 Agent dispatch 都强调了显式调度的控制边界。
重复与乱序回调,应该在哪里被拦住
我更倾向在电话适配层就做三层保护,而不是让下游每个服务自己猜:
- 事件 ID 去重:同一个 provider event id 只应用一次;
- 序号或版本保护 :比本地
last_sequence更旧的事件不回退状态; - 终态保护 :
completed、failed后只记录迟到事件,不再改变状态。
这三层分别对付重放、乱序和资源泄漏。它们不是为了让日志好看,而是防止一条晚到的 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。它刻意只允许 bridged 时 business_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,也仍要遵循业务规则和人工边界。状态机解决的是系统一致性,不是替代业务责任。