一句话定位
平台的「转人工」用的是会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机
也 originate 进同一个会议室,两边在会议里通话。后续所有「加人 / 减人 / 换人」(盲转、协商转、
主管监听 / 强插)都在同一个会议室上动态做,互不影响。
为什么不直接
bridge两条腿?因为一旦 bridge 死,再想加第三方(主管旁听、协商转)就很难。
会议室天然支持成员动态进出,是坐席协作场景的更自然原语。
这条链路横跨两个服务:callflow-esl 负责 AI 业务侧(识别转人工意图、生成会议室、把客户腿入会、
回退);callout-server 负责坐席侧(预占空闲坐席、originate 坐席入会、登记会议会话、坐席间流转)。
完整源码文档见仓库 apps/callout-server/docs/transfer-to-human.md 与 docs/integration.md。
两套「转人工」机制,别混淆
| 机制 | 触发 | 实现 | 用途 |
|---|---|---|---|
| AI 会议转人工 | 通话进行中,AI 识别到转人工意图 | POST /api/transfers/route + ctx.conference.join |
把人工坐席接进正在进行的 AI 通话 |
| 坐席工作台桥接 | 事后 / 坐席主动发起 | submitAgentBridgeCall:先 originate 坐席分机、接通后 &bridge('<客户号码>') |
坐席重新接触某客户(人工外呼 / 回拨 / 转接登记) |
下文第 2–6 节讲 AI 会议转人工(核心、新增);第 7 节讲坐席工作台桥接。
端到端流程(AI 会议转人工)
1 | AI业务: 识别"转人工" |
AI 业务侧:请求路由 + 入会 + 回退
业务通过 business/shared/human-transfer.ts 的 requestHumanAgentTransfer(ctx, request) 发起路由,
任何异常 / 未配置 / 非 2xx 都被归一化为 { routed: false, reason },让业务安全回退:
1 | import { requestHumanAgentTransfer } from "./shared/human-transfer.ts"; |
注意
routed=true 只代表坐席的后台 originate job 已被 FreeSWITCH 接受,不代表坐席已接听。
坐席是否真进会议室必须用看门狗 + conference <id> count 兜底(成员 < 2 视为没进来),否则
客户会一直卡在 ctx.conference.join 的阻塞里。
requestHumanAgentTransfer 内部读 ctx.config.callout.agentRouteEndpoint、带X-Callout-Internal-Token(= callout.resultCallbackToken)POST 路由请求,返回结构:
1 | interface HumanTransferRouteResult { |
callout-server 侧:POST /api/transfers/route
内部接口(token 鉴权,与 /api/call-results 同款,非会话鉴权;callflow-esl 公开调用免登录)。handleTransferRoute 处理逻辑:
reserveIdleAgent()原子预占一个空闲在线坐席(Redis 锁跨实例 +FOR UPDATE SKIP LOCKED库内):
候选条件为「启用、未软删、status ∈ (online, idle)、配了非空分机」,按lastStatusAt最久空闲优先,
命中后立刻置busy。无坐席 → 记agent_transfer_no_agent运行事件,返回routed:false, reason:no_agent;- 插一条
softphone_calls(direction=transfer、status=requested); submitAgentConferenceCall后台originate坐席分机入会(business_code=agent-conference-guest、conference_id、call_timeout=ringTimeoutSeconds);- originate 被接受 →
openConferenceSession登记conference_sessions(主坐席 = 该坐席、客户腿 UUID);
失败 →releaseAgent复位坐席; - 把转人工信息并入
call_attempts.businessResult.transferToAgent,记agent_transfer_routed运行事件。
坐席入会业务 agent-conference-guest
坐席分机被 originate 接通后,FreeSWITCH 按 business_code=agent-conference-guest 回连 callflow-esl,
进入这个业务。它只做一件事——读会议室 ID 并 join(首次转人工、后续坐席间流转、主管监听都复用它):
1 | const handler: CallBusiness = async (ctx) => { |
坐席间流转(监控台 / 坐席操作)
围绕同一个会议室做成员动态进出,全部走会议成员 API:
| 操作 | 端点 | 说明 |
|---|---|---|
| 加成员 | POST /api/conference-sessions/:id/participants |
role=agent(默认):带 agentId 指定坐席、否则空闲池预占;replacePrimary=true 为盲转(加新坐席成功后踢旧主坐席并切 primary),否则为协商转 / 三方(primary 不变)。role=supervisor:主管介入,必须带 agentId、不占坐席池,mode=monitor 以 mute 静默监听、mode=barge 可发言 |
| 减成员 | DELETE /api/conference-sessions/:id/participants/:softphoneCallId |
对成员腿 uuid_kill:协商转完成(移除旧主坐席)/ 取消(移除新坐席)/ 主管退出;移除主坐席时自动把 primary 提升为剩余最新一条 answered 坐席腿 |
| 监控 | GET /api/conference-sessions[/:id] |
列表 / 详情,附 FreeSWITCH 实时成员数 liveMemberCount 与 liveStatus(live / ended / unknown,只读校准、不写库) |
典型操作组合:
- 盲转:加成员
{role:"agent", agentId, replacePrimary:true}→ 旧主坐席被自动踢、primary 切新坐席; - 协商转:先
{role:"agent", agentId}(三方,原坐席向新坐席交接)→ 交接完DELETE旧主坐席的softphoneCallId; - 主管监听:
{role:"supervisor", agentId, mode:"monitor"};需要插话再{...mode:"barge"}。
僵尸会话兜底
conference_sessions.status 只在坐席腿挂机事件命中收尾路径时才翻 ended。客户先挂断、事件丢失或进程
重启会留下「FS 里会议已没、库里仍 active」的僵尸行。后台 conferenceReconcile worker(默认每 30s、graceMs 60s 宽限期)用 conference <id> list count 校准:成员数为 0 且过宽限期,就回收卡住的坐席腿、
挂断残留客户腿、关闭会话并记 conference_session_reconciled 运行事件。FreeSWITCH 不可达时整轮跳过,
绝不误关。
callflow-esl 侧会议基础设施
上面讲的都是 callout-server 视角的 ACD 链路(conference_sessions 表 + 坐席间流转)。
callflow-esl 自己还有一整套会议基础设施,是「会议」这件事的另一半真相源,转人工链路
里两者靠 conferenceId 隐式对应、没有外键关联。
会议三表(callflow schema)
apps/callflow-esl/src/db/schema.ts 定义三张会议表,由 db/conference-store.ts 持久化:
| 表 | 作用 |
|---|---|
callflow.conferences |
会议实体:conferenceId / status(created/active/ended) / locked / recordingStatus / recordingFilePath / recordingFileUrl / 时间戳。业务直接 ctx.conference.join 时会自动 upsert 一条 active 记录 |
callflow.conference_members |
成员快照:conferenceId / callUuid / conferenceMemberId / role / muted / deaf / talking / joinedAt / leftAt。离会只写 leftAt 不物理删除,唯一索引 (conferenceId, callUuid) |
callflow.conference_events |
审计日志:conference.created / member.updated / member.left / conference.locked / recording.* / conference.ended 等事件追加 |
注意区分:callout 的
callout.conference_sessions是「转人工会话 + 主坐席 + 客户腿」的
ACD 视角;callflow 的三表是「会议实体 + 成员快照 + 事件审计」的会议视角。同一通转人工通话
会在两侧各留一份记录。
HTTP 会议控制面 /conferences/*
callflow-esl 在 HTTP 端口(默认 9912)暴露一套 RESTful 会控 API(http/conference-server.ts,
由 freeswitch/conference-http-service.ts 实现,写库走 conference-store.ts):
| Method | Path | 用途 |
|---|---|---|
| POST | /conferences |
建会(可选 initialInviteNumbers 立即邀请) |
| GET | /conferences |
列表(分页 / 按 status 过滤) |
| GET | /conferences/:id |
会议详情 |
| GET | /conferences/:id/members |
成员列表 |
| POST | /conferences/:id/invite |
批量 originate 邀请成员入会 |
| POST | /conferences/:id/end |
结束会议(mode=kick 踢出 / hup 挂断) |
| POST | /conferences/:id/lock / unlock |
锁会 / 解锁 |
| POST | /conferences/:id/members/:callUuid/{mute,unmute,deaf,undeaf,kick} |
单成员会控 |
| GET / POST | /conferences/:id/recording/{status,start,stop,pause,resume} |
会议录音控制 |
这套 API 让外部系统(监控台、CRM)不经过 DTMF、不经过 callout 也能直接操控会议。会议级 DTMF
自助会控的演示形态见 conference-control-demo-business。
Redis 跨实例会议协调层
多 callflow-esl 实例部署时,会议成员可能分散在不同进程。conference-coordinator.ts
基于 Redis Pub/Sub 做跨实例协调(conference-session-registry.ts 则是单进程内的 Map 索引,
两者一层跨实例、一层进程内):
- 订阅 4 个 channel:
control/transcription/message/presence - 把可序列化运行态写 Redis Hash + TTL(
config.ttlMs):会议成员、监听者、playback 状态 syncConferenceAudioFork:跨实例广播会议级 audio_fork 启停(会议级 ASR 用)requestConferencePlaybackStop:用 Lua 脚本SET NX做抢占式 stop,保证多实例下只有一个 stop 生效- 跨实例广播 transcription / error / message 事件与 instance 心跳
Redis 未启用时(redis.enabled=false)所有协调降级为 no-op,单实例部署可用;多实例部署
必须启用,否则跨实例的 audio_fork 同步、playback 抢占停止、成员状态广播会失效。配置见
配置参考 的 redis 段。
坐席工作台桥接(与 AI 会议转人工是两套机制)
除 AI 自动转人工外,坐席也可在工作台主动发起通话。这一组经 submitAgentBridgeCall——先 originate
坐席分机、接通后 &bridge('<客户拨号串>') 直接桥接客户号码,是对客户发起的新一通桥接呼叫,
不接管正在进行的 AI 会议:
| 接口 | 方向 | 行为 |
|---|---|---|
POST /api/agents/:id/transfers |
transfer |
对指定 attemptId 对应的客户号码发起坐席↔客户桥接呼叫,在 businessResult.transferToAgent 登记转接标记、把联系人归属改到该坐席 |
POST /api/agents/:id/callbacks |
callback |
对联系人发起人工回拨:originate 坐席分机、接通后 &bridge 客户号码 |
POST /api/agents/:id/manual-calls |
manual_call |
坐席对任意客户号码人工呼出(同样 originate 坐席→&bridge 客户) |
POST /api/agents/:id/claim-contact |
— | 领取联系人并生成跟进记录、改联系人归属 |
选型:通话正在进行中要把人工接进来 → 用 AI 会议转人工;事后 / 工作台要由坐席重新接触
该客户 → 用这一组桥接接口。
落地检查清单
- callflow-esl
config.json配了callout.agentRouteEndpoint(指向/api/transfers/route)与callout.resultCallbackToken,且 token 与 callout-serverinternalApi.callResultToken一致; - AI 业务自己生成
conferenceId,请求路由时带customerCallUuid(末位坐席离开收尾要用); -
routed=false有回退分支(播报稍后联系 + 挂机 / 落跟进),不要硬等; - 用看门狗 +
conference <id> count处理坐席振铃超时未接; -
agent-conference-guest业务已注册(business-registry.ts+bun run db:push); - 坐席在 callout-server 配了正确的分机,且 FreeSWITCH 能
user/<分机>拨到; - 转人工成功后业务仍照常
POST /api/call-results回写(businessStatus=transferred); - 多实例部署:callflow-esl
redis.enabled=true并配好host/port/keyPrefix/ttlMs,否则跨实例会议协调(audio_fork 同步、playback 抢占停止)失效;单实例可关。
下一步
- 想看外呼整体能力与会议监控前端 → 智能外呼
- 想看 AI 业务怎么写(CallContext / conference API) → 业务开发指南、callflow-esl
- 想看坐席工作台与转人工会议在前端长什么样 → 智能外呼 的「前端 callout-webpage」一节