考研复习本来就很费脑子,如果能直接语音输入而非打字,用户会有更好的体验感。因此我决定给研途 Buddy 装上接入语音输入功能。这篇博客将完整复盘我在这项工作中的架构思考、模型选型纠结,以及在前后端联调时踩过的坑。

一、 架构选择

我首先学习了在工业界语音 Agent 的两条路线:

  1. 端到端(End-to-End):按住说话 -> 语音直接发给大模型 -> 大模型直接语音回复。
  2. 解耦架构(ASR + LLM):前端录音 -> 后端转成文字 -> 自动填入输入框 -> 用户确认修改后发给 Agent。
    考虑到 408 考研包含了大量诸如“多路复用”、“CSMA/CD”等中英夹杂的专业术语,任何语音模型都不敢保证 100% 准确。于是我选择了解耦架构,让用户能在发送前看一眼、改一下错别字,不仅容错率极高,而且完全不需要改动之前辛辛苦苦写好的 Agent 核心逻辑。

二、模型选型

语音转文字模型的选择是我比较纠结的地方。

  1. Faster-Whisper(本地部署):最开始想使用本地模型,但发现即使是 Base 版本也相当吃内存,对于轻量级服务器来说负担太重,果断放弃。
  2. Paraformer(WebSocket 实时流式):这是阿里开源的模型,支持实时识别。我没选择他的原因是工程太复杂:后端需要处理复杂的跨线程通信,前端要做实时重采样,因为浏览器麦克风默认采样率通常是 44.1kHz 或 48kHz(Float32格式),而 Paraformer 模型需要 16kHz、16-bit 单声道 PCM 数据,我需要使用 AudioContext 和 ScriptProcessorNode (或更现代的 AudioWorklet) 对音频在浏览器端实时重采样,然后将 Int16Array 转成 ArrayBuffer 发给 ws.send()。投入产出比太低。
  3. Qwen3-Omni(全模态模型):我尝试把音频转成 Base64 喂给 30B 参数的全模态大模型但发现这有些大材小用,这种模型具有推理能力,但推理速度慢且成本高昂,甚至会把背景里的咳嗽声给翻译出来,对于我们的ASR场景来说,不太需要推理能力,只要做最简单的识别语音内容就行,所以也没选择。
  4. 最终选择:Qwen3-ASR-Flash。它的调用极简并且识别速度快,前端只需录完上传一个常规的 .webm 文件,后端几乎瞬间就能返回结果。保证了极佳的用户体验。

三、具体实现

首先是后端API的实现。
在对接阿里云的 Qwen3-ASR-Flash 时出现了很多bug。
起初我试图用兼容 OpenAI 的 SDK 去调它,但底层的强类型校验没通过,报了很多错:

  1. 遇到的问题:
    报错 Input should be a valid string… type=‘audio_url’:阿里多模态需要 URL 格式,而我传了纯 Base64。
    报错 Input should be ‘audio’…:改成URL后OpenAI 的本地 Pydantic 又拦截了,规定类型必须写 audio。
    报错 The item of content should be a message of a certain modal:最后发现该模型不支持 chat.completions 接口。
  2. 解决方案:
    使用阿里的dashscope原生SDK,这里我是先用本地录音文件了测试后端接口
# 组装原生 SDK 要求的本地文件 URI 格式 (绝对路径)
        local_file_uri = f"file://{file_path}"
        print(f"正在测试识别本地文件: {local_file_uri}")
        messages = [
            {
                "role": "user",
                "content": [
                    {"audio": local_file_uri}
                ]
            }
        ]
        def call_dashscope():
            return dashscope.MultiModalConversation.call(
                model="qwen3-asr-flash",
                messages=messages,
                result_format="message",
                asr_options={
                    "enable_lid": True,
                    "enable_itn": True
                }
            )

四、测试接口

这里我用本地录音文件studyPlan.mp3进行测试,fastapi测试结果如下,对比本地文件的说话内容是完全一致的,测试通过,后端接口可以正常识别语音。
在这里插入图片描述

五、接入ASR

  1. 后端ASR接口
    在上面的测试接口通过后,就可以写真正的ASR后端接口了。
    首先要创建临时的录音文件,然后异步读取前端的二进制音频并写入这个临时文件:
content = await file.read()
        with open(temp_audio_file.name, "wb") as f:
            f.write(content)

之后的URI构造、参数构造以及模型调用逻辑和测试接口相同。
但别忘了要清除这个临时文件,避免造成磁盘压力:

if os.path.exists(temp_audio_file.name):
            os.remove(temp_audio_file.name)
  1. 前端重构
    给前端的输入增加语音输入的处理逻辑,具体处理过程是:
    点击录音–>启动麦克风录音–>组装成一整个音频 Blob,上传给后端–>后端进行语音识别ASR服务,返回响应–>前端get到识别结果”。
    直接展示前端view结果:
    多模态输入:
    在这里插入图片描述
    输入时的用户交互:
    在这里插入图片描述
  2. 遇到的问题:虽然之前的测试通过了(用的本地.mp3文件),但是当真正接入ASR,从浏览器录音并传回服务器进行识别时却总是提取不到内容,通过比对测试代码和真正的接入代码发现,问题出在浏览器录音的格式与阿里原生 SDK 的兼容性上。
    debug:经过学习发现,浏览器的 MediaRecorder 原生录制的音频通常是 .webm 格式(Chrome/Edge)或 .mp4(Safari)。阿里提供给 qwen3-asr-flash 的底层接口是 MultiModalConversation(多模态大模型接口)。这个原生接口不像之前的 OpenAI 兼容网关那样带有自动转码功能,把 .webm 文件传给它时,它不知道该怎么解码里面的音频轨,但它又不会报错,而是直接提取了“0秒”的声音,所以返回文本永远是空的。如下图所示,从前端接收到的音频文件并不为空,但模型返回的response总是识别不到内容:
    在这里插入图片描述
    解决方案:在后端接到 .webm 后,利用 FFmpeg 将其转换为 .wav格式,然后再喂给模型。在这个方案中首先要安装FFmpeg,然后调用FFmpeg执行转换:
subprocess.run([
                "ffmpeg", "-y", 
                "-i", temp_webm.name,    # 输入文件
                "-ar", "16000",          # 采样率 16000Hz
                "-ac", "1",              # 单声道
                temp_wav.name            # 输出文件
            ], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

测试结果:从前端用浏览器麦克风执行语音输入,控制台打印模型的response结果如下:
在这里插入图片描述
正确完成语音识别全过程!
4. 效果展示
识别成功后自动输入进input组件,允许修改:
在这里插入图片描述

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐