一句话定位

平台的「转人工」用的是会议室桥接模型:把客户腿放进一个 FreeSWITCH 会议室,再把坐席分机
originate 进同一个会议室,两边在会议里通话。后续所有「加人 / 减人 / 换人」(盲转、协商转、
主管监听 / 强插)都在同一个会议室上动态做,互不影响。

为什么不直接 bridge 两条腿?因为一旦 bridge 死,再想加第三方(主管旁听、协商转)就很难。
会议室天然支持成员动态进出,是坐席协作场景的更自然原语。

这条链路横跨两个服务:callflow-esl 负责 AI 业务侧(识别转人工意图、生成会议室、把客户腿入会、
回退);callout-server 负责坐席侧(预占空闲坐席、originate 坐席入会、登记会议会话、坐席间流转)。
完整源码文档见仓库 apps/callout-server/docs/transfer-to-human.mddocs/integration.md

两套「转人工」机制,别混淆

机制 触发 实现 用途
AI 会议转人工 通话进行中,AI 识别到转人工意图 POST /api/transfers/route + ctx.conference.join 把人工坐席接进正在进行的 AI 通话
坐席工作台桥接 事后 / 坐席主动发起 submitAgentBridgeCall:先 originate 坐席分机、接通后 &bridge('<客户号码>') 坐席重新接触某客户(人工外呼 / 回拨 / 转接登记)

下文第 2–6 节讲 AI 会议转人工(核心、新增);第 7 节讲坐席工作台桥接

端到端流程(AI 会议转人工)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
AI业务: 识别"转人工"
│ 1. 生成 conferenceId = transfer-<uuid>,播报"正在转接",停掉自己的 ASR

AI业务 ──POST /api/transfers/route──▶ callout-server
│ │ 2. reserveIdleAgent(): Redis 锁 + 行锁 原子预占在线坐席,置 busy
│ │ 3. bgapi originate user/<分机> &socket(callflow-esl)
│ │ {business_code=agent-conference-guest, conference_id, callout_*}
│ │ 4. openConferenceSession(): 登记 conference_sessions(主坐席=该坐席)
│ ◀──── { routed, agent, conferenceId, softphoneCallId } ────
│ 5. routed=true → 客户腿 ctx.conference.join({ conferenceId })
│ routed=false(no_agent) → 播报"稍后联系您" + 挂机 / 落跟进

FreeSWITCH 接通坐席分机 → 回连 callflow-esl → agent-conference-guest 业务
│ 6. answer → 读 conference_id → ctx.conference.join({ conferenceId, flags })

客户与坐席在同一会议室通话 ✅
│ 7. 看门狗:振铃超时后若会议成员 < 2 → 改判未接通、播报、挂机
│ 8. 末位坐席离开 / 客户挂断 → 挂机事件 或 conferenceReconcile 兜底收尾、releaseAgent

AI 业务侧:请求路由 + 入会 + 回退

业务通过 business/shared/human-transfer.tsrequestHumanAgentTransfer(ctx, request) 发起路由,
任何异常 / 未配置 / 非 2xx 都被归一化为 { routed: false, reason },让业务安全回退:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { requestHumanAgentTransfer } from "./shared/human-transfer.ts";

const conferenceId = `transfer-${crypto.randomUUID()}`; // 业务自己生成会议室 ID
await ctx.speak({ kind: "tts", text: "好的,正在为您转接人工坐席,请稍候。" });
await asrSession.stop("transfer-to-agent"); // 转人工后不再驱动 AI 流程

const route = await requestHumanAgentTransfer(ctx, {
conferenceId,
requestId: calloutRequestId ?? undefined,
campaignId: campaignId ?? undefined,
contactId: contactId ?? undefined,
customerNumber: ctx.channel.destinationNumber ?? null,
customerCallUuid: ctx.channel.uuid, // 客户 A 腿 UUID,供末位坐席离开时收尾
ringTimeoutSeconds: 30, // 坐席分机振铃超时
});

if (!route.routed) {
// 无空闲坐席 / 提交失败 → 回退(播报稍后联系 + 挂机或落跟进)
await ctx.speak({ kind: "tts", text: "暂时没有空闲坐席,我们会尽快与您联系。" });
await ctx.hangup("NORMAL_CLEARING");
return;
}

// 看门狗:振铃超时(+5s)后若会议成员 < 2,说明坐席没进来,改判未接通并挂机以中断 join 阻塞
const watchdog = setTimeout(() => void (async () => {
const reply = await ctx.api(`conference ${conferenceId} count`);
if (Number.parseInt(reply.trim(), 10) >= 2) return;
await ctx.hangup("NORMAL_CLEARING");
})(), (30 + 5) * 1000);

try {
await ctx.conference.join({ conferenceId }); // 客户腿入会,阻塞至退出会议
} finally {
clearTimeout(watchdog);
}

注意

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
2
3
4
5
6
7
8
interface HumanTransferRouteResult {
routed: boolean; // 坐席后台 originate job 是否已被接受
reason?: string; // no_agent / originate_failed / not_configured / http_xxx / request_error
conferenceId?: string;
agent?: { id: number; name: string; extension: string | null } | null;
softphoneCallId?: number;
ringTimeoutSeconds?: number;
}

callout-server 侧:POST /api/transfers/route

内部接口(token 鉴权,与 /api/call-results 同款,非会话鉴权;callflow-esl 公开调用免登录)。
handleTransferRoute 处理逻辑:

  1. reserveIdleAgent() 原子预占一个空闲在线坐席(Redis 锁跨实例 + FOR UPDATE SKIP LOCKED 库内):
    候选条件为「启用、未软删、status ∈ (online, idle)、配了非空分机」,按 lastStatusAt 最久空闲优先,
    命中后立刻置 busy。无坐席 → 记 agent_transfer_no_agent 运行事件,返回 routed:false, reason:no_agent
  2. 插一条 softphone_callsdirection=transferstatus=requested);
  3. submitAgentConferenceCall 后台 originate 坐席分机入会(business_code=agent-conference-guest
    conference_idcall_timeout=ringTimeoutSeconds);
  4. originate 被接受 → openConferenceSession 登记 conference_sessions(主坐席 = 该坐席、客户腿 UUID);
    失败 → releaseAgent 复位坐席;
  5. 把转人工信息并入 call_attempts.businessResult.transferToAgent,记 agent_transfer_routed 运行事件。

坐席入会业务 agent-conference-guest

坐席分机被 originate 接通后,FreeSWITCH 按 business_code=agent-conference-guest 回连 callflow-esl,
进入这个业务。它只做一件事——读会议室 ID 并 join(首次转人工、后续坐席间流转、主管监听都复用它):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const handler: CallBusiness = async (ctx) => {
await ctx.execute("answer");
await ctx.execute("sleep", String(ctx.config["esl-server"].settleDelayMs));

const conferenceId = await ctx.getVariable("conference_id");
if (!conferenceId) { // 缺参数兜底
await ctx.speak({ kind: "tts", text: "转接参数缺失,无法加入会议。" });
await ctx.hangup("NORMAL_TEMPORARY_FAILURE");
return;
}

// callout-server 透传角色与会议 flags(主管静默监听 = mute)
const flagsRaw = await ctx.getVariable("callout_conference_flags");
const flags = flagsRaw ? flagsRaw.split("|").map((f) => f.trim()).filter(Boolean) : [];

await ctx.conference.join({ conferenceId, ...(flags.length > 0 ? { flags } : {}) });
};

export const agentConferenceGuestBusiness = defineBusiness({
code: "agent-conference-guest",
desc: "坐席入会(AI 转人工)业务",
handler,
});

坐席间流转(监控台 / 坐席操作)

围绕同一个会议室做成员动态进出,全部走会议成员 API:

操作 端点 说明
加成员 POST /api/conference-sessions/:id/participants role=agent(默认):带 agentId 指定坐席、否则空闲池预占;replacePrimary=true盲转(加新坐席成功后踢旧主坐席并切 primary),否则为协商转 / 三方(primary 不变)。role=supervisor主管介入,必须带 agentId、不占坐席池,mode=monitormute 静默监听、mode=barge 可发言
减成员 DELETE /api/conference-sessions/:id/participants/:softphoneCallId 对成员腿 uuid_kill:协商转完成(移除旧主坐席)/ 取消(移除新坐席)/ 主管退出;移除主坐席时自动把 primary 提升为剩余最新一条 answered 坐席腿
监控 GET /api/conference-sessions[/:id] 列表 / 详情,附 FreeSWITCH 实时成员数 liveMemberCountliveStatuslive / 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-server internalApi.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」一节