把 Qwen-Audio-Agent 接进已有的工单与客服系统时,需要先分清哪些能力由框架提供、哪些必须自己写。通常,框架负责一轮会话中的模型推理和音频流转;而“这次对话属于哪张工单、哪个坐席、哪次来电”的编号,“两端音频编码能不能对上”,“连接中途断掉之后这通对话怎么收尾”这三件事,业务系统和框架之间没有现成协议承载,需要自建一层薄适配。判断标准也简单:凡是需要在工单页面看到、需要写进库、需要事后翻日志复盘的,都归适配层;框架不负责这些。
接入前先用三个问题自检:一次会话的编号由谁生成、在哪些环节透传、日志里能否一眼对上;业务侧录音与框架输入是否同为单声道固定采样率的 PCM;网络抖动或上游静默超时时,会话是卡住还是能按约定收尾。三项都能通过配置、日志或回放命令验证,再谈联调;否则联调阶段排查成本会明显放大。适配层建议写薄,只做标识、格式和回退,不把业务规则塞进去。
先定业务系统用什么标识一次会话
标识来源建议由业务系统产生,不要等框架返回 ID 后再回填。常见做法是“工单号 + 会话序号”,例如 ticket-8842-s03,或者一个自增 session_id 加一个 trace_id 用于跨服务串联。这样,一次转接、一次重连,都能在同一张工单下区分成不同会话,不会互相覆盖。
透传路径通常有三个位置可选:网关或 BFF 层统一注入 HTTP 头,例如 X-Session-Id;放进建连消息的元数据字段;随每一帧音频的分包消息携带。一般建议前两个位置必带、第三个位置可选冗余,因为音频分包频率高,每帧都塞长字符串会占带宽。需要结合环境确认网关是否会把自定义头剥掉,很多反向代理默认只透传已知头。
核对方式很直接:在适配层收到请求的第一行打一条日志,在和框架完成绑定后再打一条,在会话结束时打第三条。三条日志里的 session_id 必须逐字相同,否则说明中间有环节丢失。
# 收到上游请求时
log.info("session_in", session_id=sid, trace_id=trace, ticket=tk)
# 完成与框架绑定后
log.info("session_bound", session_id=sid, upstream=sid, backend_ref=ref)
# 会话收尾时
log.info("session_close", session_id=sid, reason=reason, dur_ms=elapsed)
在边界处把音频编码格式统一
两端格式不一致,表现出来往往不是报错,而是杂音、变速、只有半边声道或者解码直接失败,排查起来容易误判成模型问题。接入前先把业务侧录音和框架输入格式都查出来。
- 业务侧文件:
ffprobe -v error -show_streams -select_streams a:0 rec.wav,看codec_name、sample_rate、channels、sample_fmt。 - 流式来源:
sox `--i` rec.wav或file rec.wav,快速看容器、采样率、位深。 - 框架侧:先查配置项或接入说明里声明的输入格式;看不到就以单声道、16kHz、16bit PCM 这类通用格式做保守假设,再用一段短音频试通。
转换步骤建议固化成一条命令或一个函数,不要临时手敲:
ffmpeg -i input.wav -ac 1 -ar 16000 -c:a pcm_s16le -f s16le output.pcm
ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le -f wav output.wav
转换后必须回放验证,不能只看文件生成成功:ffplay -f s16le -ar 16000 -ac 1 output.pcm,或 aplay -f S16_LE -r 16000 -c 1 output.pcm。听人声是否自然、有没有咔哒声、总时长与原文件是否接近。时长偏差明显,通常是采样率参数写错。
写一层薄适配把会话标识传给框架
适配层的作用是解耦:业务系统只认自己的工单模型,框架只认自己的入参,中间由适配层做翻译和兜底。下面是一个不依赖任何未公开接口的通用骨架,参数名按实际 SDK 替换即可。
class SessionAdapter:
def __init__(self, backend, cfg):
self.backend = backend # 框架客户端,任意实现
self.cfg = cfg # 采样率、声道、超时等配置
def open(self, req):
sid = req.session_id or make_sid(req.ticket_id)
ctx = {
"session_id": sid, # 业务侧可读编号
"trace_id": req.trace_id, # 跨服务排查用
"format": self.cfg["audio"],# 输入格式声明
"meta": {"ticket": req.ticket_id, "agent": req.agent_id},
}
self.h = self.backend.connect(ctx)
log.info("session_bound", session_id=sid, backend_ref=ctx["session_id"])
return sid
def on_audio(self, sid, chunk):
chunk = self.normalize(chunk) # 采样率/声道对齐
self.stats[sid]["bytes_in"] += len(chunk)
self.h.send(chunk)
def close(self, sid, reason):
self.h.close(reason=reason)
log.info("session_close", session_id=sid, reason=reason)
参数上有几点要注意:session_id 一旦生成就不要在会话中途变更,重连时沿用同一编号,另起序号表示新会话;format 建议显式声明而不是依赖框架推断,避免两端默认值不同;normalize 里做重采样和声道合并,不要在这层做降噪或增益,那是业务侧音频处理的事。
断流和超时时给出可验证的回退行为
让一次网络抖动不至于卡死整通对话,关键是提前定义“什么算断”和“断了做什么”,并且这两件事都要能在日志里看到。
触发条件通常有几类:上游连续一段时间没有音频数据、收到的音频持续为静音且超过阈值、连接层心跳丢失、单轮响应等待超时。阈值按业务场景定,可以先取保守值再压测调整,不要照搬别人的数字。
# 伪代码:每个会话一个监控循环
while session.alive:
if now() - session.last_audio_ts > T_SILENCE:
fire(session, reason="upstream_silent")
elif now() - session.last_frame_ack > T_ACK:
fire(session, reason="backend_stall")
elif session.turn_start and now() - session.turn_start > T_TURN:
fire(session, reason="turn_timeout")
fire(session, reason):
session.state = "fallback"
notify_business(session.sid, reason) # 坐席端提示或占位
session.half_close() # 先停写入,等尾部数据
session.flush_remaining(T_DRAIN)
session.close(reason=reason)
log.warn("session_fallback", session_id=session.sid,
reason=reason, bytes_in=session.bytes_in,
elapsed_ms=session.elapsed, state=session.state)
回退动作建议按顺序:先把半截结果落库并标记为不完整,再通知业务侧(坐席端弹提示、工单上写一条事件),最后释放连接。不要静默重连并复用同一个会话编号,那样事后无法区分正常结束和异常收尾。日志里至少固定输出 session_id、state、reason、bytes_in、elapsed_ms 五个字段,用 grep session_fallback 就能拉出全部异常会话。
用一段录音回放验证整条链路
联调最后一步不要只靠实时说话验证,用一段固定录音按实时节奏推流,结果可复现,也方便对比两次改动。
# 转成适配层的输入格式后,按 20ms 一包推给本地适配服务
ffmpeg -re -i sample.wav -ac 1 -ar 16000 -f s16le - | \
python tools/push_chunks.py `--endpoint` localhost:8080 `--session` ticket-8842-s03 `--chunk-ms` 20
预期日志序列应当是:session_in → session_bound → fmt_normalized → 若干 chunk_in → session_close。其中 fmt_normalized 打印的实际采样率和声道要与配置一致;chunk_in 累计字节数应接近录音时长乘以每秒字节数,差太多说明适配层吞了音频或者分包参数写错。
业务侧的结果也要看:工单页面是否出现这次会话记录、时长是否与录音接近、坐席端是否收到完整回复而不是半句。三处都对上,说明标识透传、格式统一、回退分支都没有把数据吃掉。如果日志齐但业务侧看不到记录,问题通常在落库那一步,不在适配层。