Files
go-sip/docs/references/a.md
T

212 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
FreeSWITCH AI 通话回复太慢?从推流配置到语音对话的完整调优
作者: 无双的博客
发布时间: Sep 17, 2026, 5:24 PM
发布地点: 贵州
我的视频教程还没更新,很多朋友就私信我,用了我的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 音频注入通话。
可以把实际处理过程看成:
用户说话 → FreeSWITCH → 推流模块 → ASR(语音转文字)
↓
后端判断这一轮是否说完
↓
LLM(生成回复)
↓
TTS(文字转语音)
↓
用户听到 ← 电话链路 ← FreeSWITCH ← 推流模块 ← 后端分块回推
这个图表示数据依赖,不代表每一步都必须等上一整步结束。用户还在说话时,ASR 就可以持续处理;模型已经给出一个可以播报的短句时,TTS 也不必等整段回答完成。
模块负责传输和执行媒体动作。什么时候认定用户说完、什么时候允许插话、打断后取消哪一轮生成,需要业务后端负责。调整 FCC 的呼叫控制参数,不能直接缩短模型的推理时间。
先把数据记录一下
我建议先保留当前配置,做一通固定话术的测试电话。不要一上来同时换模型、改缓冲、改静音时间,改完很难知道是哪一步起作用。
面向用户的指标是“最后一个实际语音片段结束,到电话端听到第一段有效回复”的时间。用来安抚的“请稍等”可以单独记录,但不能拿它代替拿到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 生成的音频也要转换成当前媒体流要求的采样率再回推。转换采样率可以适配接口,却不能恢复电话原本没有采集到的高频信息。
上行音频是有符号、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 就按双声道解析。
人声忽快忽慢、音调明显异常时,先核对采样率与声道解释。ASR 把机器人自己的话也识别进来时,检查是否使用了双方混音,以及话机是否把扬声器声音重新收进麦克风。声道分离不能消除这种声学回声。降噪、增益和回声处理要用处理前后的相同音频比较,别把辅音和轻声一起削掉。
一套能开始调试的推流配置
下面这些参数放在现有 mod_taering_stream.conf.xml 的 <settings> 内。只修改同名项,不要再添加第二份,也不要覆盖已有的 HTTP 鉴权和业务地址配置。
这里假设后端是同一 Compose 网络里名为 ai-backend 的服务,监听 9000 端口并实现 /stream 媒体协议。地址、端口和路径都需要换成你的实际值。只有两个进程共享网络命名空间时,才可以用 127.0.0.1 访问彼此;独立容器之间优先使用服务名和容器端口。
<!-- 后端必须实现模块的 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。
先用 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,确认上行识别和下行声音都正常。若出现退化,恢复备份文件并按同样步骤重载、重建测试通话。
重要: 模块连接的 ai-backend 是协议适配服务,不应直接替换成某家云 ASR 的 WebSocket 地址。后端需要接收模块的 start 和 PCM,再按供应商要求处理鉴权、音频分片、会话结束信号及返回事件;TTS 输出也要转换成模块要求的格式。模块的 20ms 分片不等于云 ASR 也要求 20ms,请按商业接口文档做必要聚合,并把聚合等待计入日志。
不要让几层静音等待串在一起
VAD 判断有没有人在说话,轮次判定决定是不是轮到 AI 回答。用户说“我想查一下……明天下午的预约”,中间的停顿可能只是思考。把所有静音等待都压得很短,容易在“查一下”后就开始抢答。
检查后端有没有同时存在 ASR 服务自身的结束判定、业务层静音计时和额外的固定等待。搞清楚它们的触发顺序:如果业务层在 ASR 已确认结束以后又完整等待一次,才有理由考虑去掉重复等待。不同服务有的计时并行、有的串行,不能看到两个阈值就直接相加。
我的建议是先指定一个明确的轮次提交入口。ASR 中间结果可以更新界面或参与内部判断,但在结果可能回改时,不要据此提交不可撤销的业务操作。然后保持其他条件不变,小步缩短真正决定提交的等待,重复测试短回答、句中停顿和长数字。抢话增加就回调,不要为了日志好看让用户重复说话。
后端如果支持语义结束判定或提前生成,可以再比较收益。提前计算的结果在用户继续说话时要能丢弃,额外算力也需要计入并发容量。这里介绍的是设计取舍,不要求安装新框架,也不能把别家框架的参数直接填进模块 XML。
识别准确率要单独检查。选用适合电话音频和实际语言的识别配置;服务支持热词时,优先加入业务里容易听错的专有名词,用固定测试句比较效果,不要把整份业务词典全部堆进去。音频质量、采样率声明和流式提交方式也会影响识别。
金额、日期、号码这类字段不能只靠模型“猜得像”。假设用户说“明天下午三点,不是上午”,验收时要看完整意思是否保留;识别不清时,针对不确定部分复述确认。语音识别错、大模型理解错、业务查询返回错,是三个不同问题,要分别记录。
让回复边生成边说,同时保证一句话说得完整
支持 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 满了会丢旧帧,结果可能是后半句或中间内容缺失。实际调度要避免一旦落后就突发补发所有块;应记录积压、取消过期轮次,并保持同一通电话的发送顺序。
打断时,先堵住旧音频继续发送
开启 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 与待播文件队列,不撤回已注入的音频,也不保证停止正在执行的兼容文件播放。
注意: 实时 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 和内存,宿主机有空闲资源不表示容器没有触及限制。把实际容器限制与部署文件对照,再检查后端是否在异步处理线程里执行阻塞推理、同步转码或密集日志写入。
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,也可以通过我的博客联系我。
参考链接:
https://github.com/Taering365/mod_fcc
https://github.com/Taering365/freeswitch_stream_mod