我的视频教程还没更新,很多朋友就私信我,用了我的docker镜像以后,说是电话接通了,语音识别也有结果,但人说完一句话,还是要等一会儿才能听到 AI 回答。等它开始说了,想插一句话,又发现它停不下来。
这篇文章面向已经使用我提供的 Docker 镜像、接通 AI 呼叫链路的朋友。本文使用的镜像标识是 local/freeswitch:1.10.12-fcc1.2.1,容器名称为 freeswitch-fcc,已经集成开源的 mod_fcc 和商业授权的 mod_taering_stream。接下来要做的是把这条链路调顺:找出等待发生在哪里,修改对应配置,再用同一组电话测试确认效果。 如果你不太明白这篇文章的内容,你也可以把这个文章丢给AI,给AI提供一些思路来进行调优。
local/freeswitch 是这里使用的镜像名称,不是公开镜像下载地址。
配置以最新的 mod_taering_stream 0.54 手册为依据。镜像 tag 不包含推流模块版本,请先核对容器中的实际版本;旧版本不能直接照搬全部接口。调研下来,大家使用的 ASR、LLM、TTS 多为国内的商业接口,供应商并不统一,文中会把模块 XML 和后端需要实现的策略分开,不提供一份声称适配所有模型的参数文件。
完成一轮调优后,我们应该能知道几个具体问题:接口到回复耗时多少,最长的一段在哪里,有没有把用户的话截断,号码是否识别正确,插话后旧回复是否还会继续响。
两个模块各管什么,先分清楚
mod_fcc 负责外呼、入呼接管、应答、挂机、转接等呼叫控制,并提供通话状态和事件。音频推流由独立模块承担。mod_taering_stream 把指定 FreeSWITCH 通道上的音频送给后端,再把后端返回的 PCM 音频注入通话。mod_fcc 职责说明
可以把实际处理过程看成:
用户说话 → FreeSWITCH → 推流模块 → ASR(语音转文字)
↓
后端判断这一轮是否说完
↓
LLM(生成回复)
↓
TTS(文字转语音)
↓
用户听到 ← 电话链路 ← FreeSWITCH ← 推流模块 ← 后端分块回推
这个图表示数据依赖,不代表每一步都必须等上一整步结束。用户还在说话时,ASR 就可以持续处理;模型已经给出一个可以播报的短句时,TTS 也不必等整段回答完成。
模块负责传输和执行媒体动作。什么时候认定用户说完、什么时候允许插话、打断后取消哪一轮生成,需要业务后端负责。调整 FCC 的呼叫控制参数,不能直接缩短模型的推理时间。0.54 集成手册
先把数据记录一下
我建议先保留当前配置,做一通固定话术的测试电话。不要一上来同时换模型、改缓冲、改静音时间,改完很难知道是哪一步起作用。
面向用户的指标是“最后一个实际语音片段结束,到电话端听到第一段有效回复”的时间。用来安抚的“请稍等”可以单独记录,但不能拿它代替拿到TTS并且已经开始注入的时间。测试时可在同一端录下双方音频并标注起止点;服务器收到第一块 TTS 数据,只能证明数据已经到达服务器。
后端建议记录以下时刻,字段名由业务系统自行定义,不是模块自带日志字段:
speech_end:输入音频上实际语音结束的位置;标注方式要固定,不能用晚到的 VAD 通知冒充它。asr_final:这一段识别结果确定。turn_commit:后端决定开始回答。llm_first_text:模型返回第一段文本。tts_first_pcm:得到第一块可回推的音频。first_pcm_sent:第一块音频提交给媒体连接。caller_first_audio:测试电话端实际听见回复,仅在能测得时填写。
同一进程的间隔使用单调时钟;跨进程、跨机器比较要校时,并说明采样点。每条日志关联 FCC call_id、FreeSWITCH channel_id、媒体 stream_id 和后端自己的轮次编号。SIP Call-ID 不是 FreeSWITCH channel UUID,不要混用。
如果音频早就送到 ASR,后端却迟迟没有提交轮次,优先看断句。模型首段文本很快,TTS 首包很慢,就看合成接口和分句策略。第一块 PCM 已经发出,电话端仍然长时间无声,再查音频格式、消费队列与电话链路。这些是排查方向,不能只凭一个日志时间就认定根因。
还要把首次调用、后续轮次和并发测试分开。记录样本数,再看中位数、P95 和失败次数;样本很少时,不要把 P95 当作稳定容量结论。流式处理会有重叠,整段识别耗时加整段生成耗时,并不等于用户停口后的等待。
先确认音频送对了,再讨论识别率
在 Docker 宿主机执行下面的命令,先保存版本和当前配置位置。命令以容器内 fs_cli 在 PATH 中且已配置访问凭据为前提;若不在 PATH,请替换为镜像中的实际可执行文件路径。
# 查询模块版本;本文的协议说明对应 0.54。
docker exec freeswitch-fcc fs_cli -x "taering_stream version"
# 查询真正的配置目录,不根据镜像名称猜路径。
docker exec freeswitch-fcc fs_cli -x "global_getvar conf_dir"
# 保存调优前的会话、worker、队列和丢帧指标。
docker exec freeswitch-fcc fs_cli -x "taering_stream stats"
一个容易忽略的细节是:0.54 启动媒体流时,优先使用 FreeSWITCH 通道的实际采样率。配置写 16000、启动命令写 16k,都不保证后端收到的就是 16kHz。模块本身没有独立的 PCM 重采样过程,后端必须读取 WebSocket start.sample_rate,并按这个值解释后续二进制数据。
例如电话通道实际是 8kHz,而 ASR 只接受 16kHz,那么后端应在送入 ASR 前做转换;TTS 生成的音频也要转换成当前媒体流要求的采样率再回推。转换采样率可以适配接口,却不能恢复电话原本没有采集到的高频信息。Google 的音频采样说明
上行音频是有符号、16 位、小端、没有 WAV 文件头的 PCM。对普通通道,mono 采集 read 方向,mixed 混合双方声音,stereo 按左 read、右 write 交织。后端只需要识别人声时,先用 mono,并在实际通道上试听确认采到的是用户。需要区分双方声音时再用 stereo,后端拆出用户所在声道送 ASR,不能直接把交织数据当单声道读。
使用 FCC 的 dialplan 路由时尤其要检查是否经过 loopback。0.54 对 loopback A 腿有方向特例:它改用 write 采集、read 注入,而且该路径不做普通双声道交织。此时使用 mono 并验证实际方向,不能只看 mix_type=stereo 就按双声道解析。0.54 手册第 8 节
人声忽快忽慢、音调明显异常时,先核对采样率与声道解释。ASR 把机器人自己的话也识别进来时,检查是否使用了双方混音,以及话机是否把扬声器声音重新收进麦克风。声道分离不能消除这种声学回声。降噪、增益和回声处理要用处理前后的相同音频比较,别把辅音和轻声一起削掉。
一套能开始调试的推流配置
下面这些参数放在现有 mod_taering_stream.conf.xml 的 <settings> 内。只修改同名项,不要再添加第二份,也不要覆盖已有的 HTTP 鉴权和业务地址配置。
这里假设后端是同一 Compose 网络里名为 ai-backend 的服务,监听 9000 端口并实现 /stream 媒体协议。地址、端口和路径都需要换成你的实际值。只有两个进程共享网络命名空间时,才可以用 127.0.0.1 访问彼此;独立容器之间优先使用服务名和容器端口。Docker Compose 网络说明
<!-- 后端必须实现模块的 start/stop 文本帧与双向二进制 PCM 协议。 -->
<param name="default_ws_url" value="ws://ai-backend:9000/stream"/>
<!-- 从单声道人声输入开始;先在实际通道上核对采集方向。 -->
<param name="default_mix_type" value="mono"/>
<!-- 这是配置值,后端仍必须以 start.sample_rate 为准。 -->
<param name="default_sample_rate" value="16000"/>
<!-- 允许下行注入;允许后端主动清空播放,均不包含自动插话判断。 -->
<param name="default_autoplay" value="true"/>
<param name="enable_barge_in" value="true"/>
<!-- 分片与目标缓冲采用手册示例值,后续根据丢帧和试听调整。 -->
<param name="packet_ms" value="20"/>
<param name="tx_buffer_ms" value="80"/>
<param name="rx_buffer_ms" value="80"/>
<!-- 本示例使用二进制 PCM;原业务依赖文件播放时先完成迁移再关闭。 -->
<param name="allow_text_audio" value="false"/>
<param name="enable_file_playback" value="false"/>
<!-- 平时关闭逐帧调试,定位具体问题时短时开启并保存必要日志。 -->
<param name="log_debug" value="false"/>
tx_buffer_ms 对应上行目标缓冲,rx_buffer_ms 对应下行目标缓冲。它们影响可以积压多少音频,不能理解成每次必定等待这么久,也不能把两个数相加当成模块固定延迟。
0.54 的 ring 槽位按 ceil(buffer_ms / packet_ms) 计算,至少四槽。在 packet_ms=20 时,把缓冲从 80 改成 40,并不会得到两槽缓冲。只增大 tx_queue_limit、rx_queue_limit,也不会自动扩大由缓冲时长决定的容量。play_queue_limit 管的是兼容文件任务,不是实时 PCM ring。这些约束见手册第 11 节。
先用 20ms 分片、80ms 目标缓冲作为基线。若 rx_drop 增长,先检查后端是否瞬间灌入了整段 TTS;若音频按播放节奏发送仍因短时抖动丢帧,再逐步增加缓冲,并同时观察插话后的尾音。缓冲变大可以吸收一部分抖动,也可能保留更多待播放的旧音频。
配置文件在实际 conf_dir 下的 autoload_configs 目录。Docker 部署要检查它是不是宿主机挂载文件:改了容器内临时文件,重建后可能丢失。备份当前文件,在无活动测试电话或允许中断媒体的维护窗口执行:
# 重新读取 XML;这一条单独执行不会刷新模块内存参数。
docker exec freeswitch-fcc fs_cli -x "reloadxml"
# 重载会清理已有媒体会话,不是无损热更新。
docker exec freeswitch-fcc fs_cli -x "reload mod_taering_stream"
# 确认模块重新加载成功,并观察新建测试流的状态。
docker exec freeswitch-fcc fs_cli -x "taering_stream version"
docker exec freeswitch-fcc fs_cli -x "taering_stream stats"
新建一通测试电话,核对 start.sample_rate、mix_type,确认上行识别和下行声音都正常。若出现退化,恢复备份文件并按同样步骤重载、重建测试通话。上述配置和生效方式均以 0.54 配置手册 为准。
模块连接的 ai-backend 是协议适配服务,不应直接替换成某家云 ASR 的 WebSocket 地址。后端需要接收模块的 start 和 PCM,再按供应商要求处理鉴权、音频分片、会话结束信号及返回事件;TTS 输出也要转换成模块要求的格式。模块的 20ms 分片不等于云 ASR 也要求 20ms,请按商业接口文档做必要聚合,并把聚合等待计入日志。
不要让几层静音等待串在一起
VAD 判断有没有人在说话,轮次判定决定是不是轮到 AI 回答。用户说“我想查一下……明天下午的预约”,中间的停顿可能只是思考。把所有静音等待都压得很短,容易在“查一下”后就开始抢答。
检查后端有没有同时存在 ASR 服务自身的结束判定、业务层静音计时和额外的固定等待。搞清楚它们的触发顺序:如果业务层在 ASR 已确认结束以后又完整等待一次,才有理由考虑去掉重复等待。不同服务有的计时并行、有的串行,不能看到两个阈值就直接相加。
我的建议是先指定一个明确的轮次提交入口。ASR 中间结果可以更新界面或参与内部判断,但在结果可能回改时,不要据此提交不可撤销的业务操作。然后保持其他条件不变,小步缩短真正决定提交的等待,重复测试短回答、句中停顿和长数字。抢话增加就回调,不要为了日志好看让用户重复说话。
后端如果支持语义结束判定或提前生成,可以再比较收益。提前计算的结果在用户继续说话时要能丢弃,额外算力也需要计入并发容量。这里介绍的是设计取舍,不要求安装新框架,也不能把别家框架的参数直接填进模块 XML。轮次与打断调优参考
识别准确率要单独检查。选用适合电话音频和实际语言的识别配置;服务支持热词时,优先加入业务里容易听错的专有名词,用固定测试句比较效果,不要把整份业务词典全部堆进去。音频质量、采样率声明和流式提交方式也会影响识别,Google 的官方建议同样强调这些输入条件。语音识别输入建议
金额、日期、号码这类字段不能只靠模型“猜得像”。假设用户说“明天下午三点,不是上午”,验收时要看完整意思是否保留;识别不清时,针对不确定部分复述确认。语音识别错、大模型理解错、业务查询返回错,是三个不同问题,要分别记录。
让回复边生成边说,同时保证一句话说得完整
支持 WebSocket 不等同于整条链路已经流式工作。检查 ASR 是否收到音频就处理,LLM 是否流式返回,TTS 是否真的提供增量音频。把整段生成好的 WAV 切成小块回传,只改善回传方式,无法追回生成整段 WAV 已花掉的等待。
后端可以在得到一个语义完整、适合播报的短句后启动 TTS,并让后续句子继续生成。不要每来一个字就发起一次合成,也不要只等很长一段文字末尾的句号。对缺少标点的输出设置等待上限和长度上限,数值应在实际 TTS 上比较首包速度、语调、漏字与并发请求量后确定。
假设是预约查询,可以用这样的电话回复约束作为提示词起点:
你通过电话帮助用户查询和确认预约。
先直接回应当前问题,每轮优先处理一件事,使用适合听的短句。
查询结果没有返回前,不编造可预约时间或声称操作已经成功。
号码、日期、金额不确定时,只确认不确定的部分。
用户纠正或打断时,以新信息为准,不继续复述被否定的内容。
不要输出 Markdown、表格或需要用户看屏幕才能理解的内容。
这只是业务表达示例。提示词不能代替真实查询、权限校验和操作结果检查。测试“回答是否准确”时,把接口真实结果作为依据,不以回答是否流畅来评分。
TTS 音频回推使用单声道裸 PCM16LE,采样率与当前 start.sample_rate 一致。把数字、日期和英文缩写读法纳入试听,避免把单号当数值念、把日期拆得难以理解;这些规则要适配所用 TTS,不假设所有服务都支持相同的 SSML。
后端发送还需要节奏控制。0.54 下行会按 packet_ms 切片,不足一片会补零;频繁发送很短的片段可能引入额外静音。后端应维护跨块余数缓冲,优先发送完整分片或整数倍,而不是每收到一小段字节就立即发送。句末残余需要明确收尾,不能一直等下一句。
按采样点计算,一块单声道 PCM16LE 的字节数为 采样率 × 时长秒数 × 2。在 16kHz、20ms 条件下是 640 字节,在 8kHz 下是 320 字节;16kHz 双声道上行同样时长是 1280 字节。上行尾片可以短于整片,接收端不要因此拒绝消息。
TTS 比实时播放生成得快时,后端要有有界队列和按音频时长推进的发送调度,不能把几秒音频瞬间塞进很小的模块 ring。0.54 ring 满了会丢旧帧,结果可能是后半句或中间内容缺失。实际调度要避免一旦落后就突发补发所有块;应记录积压、取消过期轮次,并保持同一通电话的发送顺序。PCM 分片、补零与丢帧规则
打断时,先堵住旧音频继续发送
开启 enable_barge_in 只表示允许执行打断,不会自动识别人声。后端需要区分用户真正插话、轻声附和、咳嗽和回声,不能简单粗暴的打断。
我建议把一通电话的发送动作串行管理。确认插话后,先使旧轮次失效、禁止它继续向连接写音频,再取消旧的 LLM/TTS 任务并丢弃后端待发送块。不能等待远端模型完全取消后才让电话停声;本地禁发应立即生效,取消请求可以随后完成。
通过同一媒体 WebSocket 的唯一发送队列,可以在旧轮次写入被阻止后发送以下控制消息;uuid 换成当前连接的 FreeSWITCH channel UUID:
{"type":"clear","uuid":"<channel UUID>"}
这样可以利用同一连接内的消息顺序,让模块先收到之前已经发出的块,再处理清空。接下来只允许新轮次的音频发送。不要让多个协程分别直接写同一个连接,否则清空与旧音频的先后顺序仍可能失控。
0.54 也支持结构化接口的 interrupt_playback,传统 CLI 对应 taering_stream <uuid> interrupt_play。用独立控制连接打断时,还要考虑媒体连接上在途旧块晚于控制动作到达的问题。业务轮次编号是后端自己维护的状态,不要擅自在模块二进制 PCM 前加一段自定义编号。
WebSocket clear 没有 JSON 成功回执,应结合错误事件、interrupted 事件和实际试听验证。它清理 PCM 与待播文件队列,不撤回已注入的音频,也不保证停止正在执行的兼容文件播放。0.54 打断与控制协议
这里还要纠正一种接法:实时 PCM 路径没有每句话的 queued/start/done 事件,也没有周期性播放进度。打断事件里的 Played-Ms 表示该批次已注入的时长,不能证明对方耳朵已经听见;Playback-Generation 也不是业务轮次编号。后端维护对话历史时,要区分生成了、发送了和估计播到了哪里,不能把整段未播完的回复都写成“已经告诉用户”。
一路正常,多路变慢,就看资源和积压
如果单路电话流畅,多路才变慢,先比较负载上升前后的队列、丢帧和各阶段耗时。FreeSWITCH、ASR、LLM、TTS 都可能是限制点,单看 GPU 利用率无法定位全部问题。
在宿主机执行以下检查,示例容器名换成实际名称:
# 分别看媒体进程与 AI 后端的资源占用,不只看宿主机总体负载。
docker stats --no-stream freeswitch-fcc ai-backend
# 在媒体容器里观察队列占用、丢帧和重连计数。
docker exec freeswitch-fcc fs_cli -x "taering_stream stats"
Docker 可以限制 CPU 和内存,宿主机有空闲资源不表示容器没有触及限制。把实际容器限制与部署文件对照,再检查后端是否在异步处理线程里执行阻塞推理、同步转码或密集日志写入。Docker 资源限制
tx_fill、rx_fill 持续高位,或 tx_drop、rx_drop 持续增加时,要结合后端日志检查生产与消费速度。单次累计值不足以说明当前仍在丢帧,应该比较同一测试窗口的增量。网络 worker 数量也应结合负载测试调整,不能把它当成增加模型推理能力的参数。
国内商业接口也要记录请求建立、首包、限流响应和重试等待。核对账号并发配额、所选服务地域和流式能力,不假设同一供应商的全部接口具有相同行为。设置有上限的超时与重试,过期轮次不再重试;不要通过关闭 TLS 校验换取所谓加速。重复建连、模型冷启动和远程请求耗时要分开记录。服务支持长连接与预热时可以利用,但要遵守具体 API 的会话约束。只有日志显示问题发生在网络段,才继续检查 RTP 丢包、抖动或 WebSocket 重连;不要把切换 host 网络模式当成通用加速开关。
0.54 的地址池给新会话轮询选址,断线后仍重连本会话原 URL,不是自动健康检查和故障切换。授权允许的路数也不是机器可承载的路数。根据包含 ASR、LLM、TTS 的端到端压测设置并发与限流,超载时执行清楚的超时、提示或转人工流程,别让所有电话无期限排队。
用同一组电话确认调优结果
回到调优前保存的话术与配置。每次只调整一类因素,保存镜像 tag 或 digest、模块版本、后端版本、配置差异、并发数和测试结果。下面是一组假设的测试输入,可以按实际业务替换:
- 短回答:“可以。”检查后端是否还在多等一轮静音。
- 句中停顿:“我想查一下……明天下午的预约。”检查有没有中途抢答。
- 纠正信息:“下午三点,不是上午。”检查最终回复是否采用纠正后的时间。
- 长数字:使用专门的虚构测试号码,检查漏字、顺序和复述读法。
- 插话:AI 说话时说“等一下,我换个时间”。检查停声后是否又冒出旧回复。
- 噪声与连续多轮:比较安静环境和常见背景声,观察误打断、断句和上下文变化。
- 目标并发:重复上述输入,观察尾部延迟、音频完整性和失败次数。
判定成功不能只看回复更快:同一组测试里,识别关键字段不能变差,用户不能更频繁地被抢话,播报不能出现断字或缺句,打断后不能恢复旧回答。若平均值下降却出现更多长时间无声,也不能算调好了。
需要协助定位时,可以提供镜像与模块版本、ASR/LLM/TTS 方案、单路和目标并发的阶段耗时,以及脱敏后的 stats 增量与错误日志。这样才能判断下一步该改推流、断句、后端调度,还是模型服务。模块接口和更新说明放在 mod_fcc 与 freeswitch_stream_mod,也可以通过 Taering 博客 联系我。