1. 为什么要在Unity里接入Qwen2.5-Omni
1.1 从“只能打字”到“能听会说”的交互升级
做过Unity项目的人都有一个体会:玩家和游戏角色之间的交互,绝大多数时候还是靠按钮、摇杆和键盘。你按A,角色跳;你点对话框,NPC说下一句。这种交互方式稳定、可控,但放在2024年之后的项目里,尤其是涉及虚拟数字人、AI陪伴、智能导览、互动教育这类场景,就显得有点“不够看”了。
Qwen2.5-Omni是阿里通义千问系列里的全模态模型,它的核心能力是同时处理文本、图像、音频和视频输入,并且能直接输出文本和语音。注意这里的“直接输出语音”很关键——它不是先出文字再调一个TTS(文本转语音)服务,而是模型端到端地把语音合成也包了。这意味着你在Unity里拿到的就是一个可以“听你说、看你拍、开口回你”的完整交互闭环。
我最初动这个念头,是因为手上有一个虚拟展厅的项目。原本的方案是:语音识别用某云厂商的ASR,对话用另一个大模型API,语音合成再调第三个TTS接口。三个服务串起来,延迟高、成本高、维护烦,而且经常出现“识别对了但对话跑偏,对话对了但语气不对”的割裂感。Qwen2.5-Omni把这三步合成一步之后,链路短了,一致性好了,开发量也降下来了。
这篇文章适合谁看?如果你是有一定Unity基础(会写C#脚本、知道协程和UnityWebRequest怎么用),想给自己的项目加上语音交互或者多模态AI能力的开发者,那接下来的内容基本可以照着抄。如果你是完全没碰过Unity的纯AI方向同学,也能从里面了解到端侧集成大模型的工程思路。
1.2 全模态到底“全”在哪:能力边界先摸清楚
在动手之前,得先把Qwen2.5-Omni的能力边界搞清楚,不然容易做出错误的架构决策。
它的输入支持:
- 文本:常规的prompt,和普通大模型一样。
- 图像:单帧图片,可以理解画面内容。
- 音频:直接输入音频流或音频文件,模型自己完成语音识别和理解。
- 视频:多帧序列,能理解时序信息。
输出方面:
- 文本:对话回复、描述、分析结果。
- 语音:直接生成对应文本的语音波形,带自然语气。
但有几个点必须提前知道,否则会踩坑:
第一,它不是实时流式语音对话的“完美方案”。虽然支持流式,但在Unity这种对帧率敏感的环境里,网络往返加上模型推理时间,实际端到端延迟通常在1到3秒之间,取决于你的网络和音频长度。想要做到像真人打电话那样无缝,目前还做不到,但用于“你说一句、它想一下、回你一句”的轮次对话,体验是完全可以接受的。
第二,图像和视频输入是“附加”在对话轮次里的,不是持续的视频流理解。你不能指望它像监控分析那样一直盯着摄像头看。正确的用法是:在某个对话轮次里,把当前帧截图作为图像输入传进去,让它“看一眼”再回答。
第三,语音输出的音色是模型固定的,不能像专业TTS那样自由选音色、调语速。如果你对音色有强需求,可能需要考虑混合方案:Qwen2.5-Omni负责理解和生成文本,语音合成交给专门的TTS。但如果你追求的是链路简洁和一致性,直接用它的语音输出是最省事的。
提示:在项目早期就确定你是“全模态一把梭”还是“混合方案”,这直接决定了后面的接口设计和成本结构。我建议先用全模态跑通原型,再根据实际体验决定要不要拆。
2. Unity侧的整体架构设计
2.1 通信层:为什么选WebSocket而不是纯HTTP
Unity和大模型服务通信,最直接的方式是UnityWebRequest发HTTP请求。但Qwen2.5-Omni的语音交互场景里,纯HTTP有几个硬伤。
首先是音频数据的传输。如果你用HTTP POST把音频文件整个传上去,等模型处理完再返回,那每一次对话都要经历“录音→编码→上传→等待→下载→播放”的完整周期。音频文件哪怕只有几秒,Base64编码后体积也会膨胀三分之一左右,加上网络往返,延迟很难压下来。
其次是流式输出的需求。Qwen2.5-Omni支持流式返回文本和语音,如果走HTTP,你只能等整个响应结束才能拿到数据,流式的优势就没了。
所以我的方案是:WebSocket为主,HTTP为辅。WebSocket负责对话主链路——发送音频、接收流式文本和语音;HTTP负责一些非实时的辅助请求,比如获取token、上传较大的图像文件等。
Unity里用WebSocket,原生ClientWebSocket在部分平台上有兼容性问题,尤其是WebGL平台基本不能用。我实测下来比较稳的是NativeWebSocket这个开源库,轻量、跨平台、API简单。如果你要发布到WebGL,那就得走HTTP轮询或者SSE(Server-Sent Events)方案,这个后面会单独说。
架构分层大概是这样:
- 表现层:Unity场景里的角色、UI、动画。
- 交互控制层:管理对话状态机、录音触发、播放控制。
- 通信层:WebSocket客户端,负责消息的序列化和反序列化。
- 音频处理层:麦克风采集、PCM编码、音频播放。
- 模型服务层:Qwen2.5-Omni的API网关。
每一层之间通过事件和回调解耦,这样你换通信方式或者换模型服务时,不用动上面的业务逻辑。
2.2 音频链路:从麦克风到模型再到扬声器
音频这条链路是整个项目里最容易出问题的部分,我单独拎出来说。
采集端:Unity的Microphone类可以拿到麦克风数据,但它是AudioClip格式,你需要把它转成模型能接受的格式。Qwen2.5-Omni的音频输入通常要求16kHz采样率、16bit位深、单声道的PCM数据。Unity默认的麦克风采样率可能是44.1kHz或48kHz,所以要做重采样。
重采样这件事,自己写容易出bug,我建议用NAudio或者OnsetDetection这类库来处理。如果不想引入额外依赖,也可以用Unity的AudioClip.GetData拿到float数组,然后手动做线性插值降采样。线性插值的质量对于语音识别来说够用了,不用上什么高级算法。
编码端:PCM数据体积大,传输前最好做一次编码。模型服务一般接受PCM或者WAV,如果你走WebSocket,可以直接传PCM的byte数组,省去WAV头部的开销。但要注意字节序,Unity是小端,网络传输通常也是小端,这块一般不用额外处理。
播放端:模型返回的语音也是PCM流,你需要把它转成AudioClip来播放。这里有个技巧:不要等整个语音都收完再创建AudioClip,而是边收边往一个环形缓冲区里写,用AudioSource的流式播放能力。Unity的AudioClip.Create支持stream参数,配合PCMReaderCallback可以实现边收边播,延迟能降低不少。
注意:麦克风采集和扬声器播放同时进行时,一定要做回声消除(AEC),否则模型会听到自己刚说的话,陷入“自问自答”的死循环。Unity本身没有内置AEC,可以用
WebRTC Audio Processing的Unity封装,或者简单粗暴地在播放时暂停采集。
2.3 状态机设计:让对话流程可控
语音交互最怕的就是状态混乱:用户还没说完就开始识别、模型还在回复又触发新一轮录音、播放到一半被打断等等。所以一个清晰的状态机是必须的。
我设计的状态大概有这几个:
- Idle:空闲,等待用户触发。
- Listening:正在采集麦克风音频。
- Processing:音频已发送,等待模型响应。
- Speaking:正在播放模型返回的语音。
- Interrupted:被打断,需要清理当前状态。
状态之间的转换条件要明确。比如从Listening到Processing,触发条件是“检测到静音超过800毫秒”或者“用户手动停止”。从Speaking到Listening,可以是“播放自然结束”或者“用户按下打断键”。
这个状态机用C#的enum加switch就能实现,不需要上什么复杂的状态机框架。关键是把每个状态的进入和退出逻辑写清楚,尤其是资源清理——比如退出Listening时要停止麦克风,退出Speaking时要停止AudioSource。
3. 核心实现步骤拆解
3.1 准备工作:账号、SDK与Unity环境
第一步是拿到Qwen2.5-Omni的API访问权限。你需要去阿里云百炼平台开通服务,拿到API Key。注意这个Key是敏感信息,绝对不要硬编码在Unity的C#脚本里,因为Unity打包后的程序集是可以被反编译的。正确的做法是放在服务端,Unity通过自己的后端中转请求。如果只是本地测试,可以用环境变量或者一个不纳入版本管理的配置文件。
Unity版本方面,我建议用2021 LTS或2022 LTS。2020之前的版本在UnityWebRequest和ClientWebSocket上有一些已知问题,处理起来麻烦。我实测2022.3 LTS比较稳,IL2CPP和Mono都能跑。
需要引入的包:
NativeWebSocket:从GitHub下载或者用UPM安装。Newtonsoft.Json:处理JSON序列化,比Unity自带的JsonUtility灵活太多。- 音频处理库(可选):
NAudio或者自己写重采样。
项目设置里要注意:
- Api Compatibility Level设为
.NET Standard 2.1或.NET Framework,否则ClientWebSocket可能不可用。 - Scripting Backend如果用IL2CPP,要确保WebSocket库支持AOT编译,
NativeWebSocket是支持的。 - 麦克风权限:在Android和iOS上要申请权限,PC上一般不用。
3.2 音频采集与格式转换的完整代码
先看采集部分。Unity的Microphone.Start会返回一个AudioClip,但这个Clip是循环写入的,你需要自己维护读取位置。
using UnityEngine; public class MicrophoneCapture : MonoBehaviour { private AudioClip micClip; private int lastSamplePos; private const int SampleRate = 16000; private const int MaxDurationSec = 30; public void StartCapture(string deviceName) { micClip = Microphone.Start(deviceName, true, MaxDurationSec, SampleRate); lastSamplePos = 0; } public byte[] GetNewAudioData() { int currentPos = Microphone.GetPosition(null); if (currentPos == lastSamplePos) return null; int sampleCount; if (currentPos > lastSamplePos) sampleCount = currentPos - lastSamplePos; else sampleCount = micClip.samples - lastSamplePos + currentPos; float[] samples = new float[sampleCount]; micClip.GetData(samples, lastSamplePos); // float转16bit PCM byte[] pcmBytes = new byte[sampleCount * 2]; for (int i = 0; i < sampleCount; i++) { short value = (short)(Mathf.Clamp(samples[i], -1f, 1f) * 32767); pcmBytes[i * 2] = (byte)(value & 0xFF); pcmBytes[i * 2 + 1] = (byte)((value >> 8) & 0xFF); } lastSamplePos = currentPos; return pcmBytes; } public void StopCapture() { Microphone.End(null); } }这段代码的关键点在于lastSamplePos的维护。因为AudioClip是环形缓冲区,写满之后会从头覆盖,所以读取位置要处理“绕回”的情况。另外,Microphone.GetPosition(null)传null表示使用默认设备,如果你有多个麦克风,要传对应的设备名。
采样率我直接设成了16000,这样就不用做重采样了。但有些设备不支持16kHz,Microphone.Start会失败。稳妥的做法是先尝试16kHz,失败就退到44100Hz,然后自己降采样。
降采样的代码:
public static byte[] DownsampleTo16k(float[] input, int originalRate) { if (originalRate == 16000) { // 直接转PCM } float ratio = (float)originalRate / 16000; int outputLength = Mathf.FloorToInt(input.Length / ratio); byte[] output = new byte[outputLength * 2]; for (int i = 0; i < outputLength; i++) { float srcIndex = i * ratio; int indexLow = Mathf.FloorToInt(srcIndex); int indexHigh = Mathf.Min(indexLow + 1, input.Length - 1); float t = srcIndex - indexLow; float sample = Mathf.Lerp(input[indexLow], input[indexHigh], t); short value = (short)(Mathf.Clamp(sample, -1f, 1f) * 32767); output[i * 2] = (byte)(value & 0xFF); output[i * 2 + 1] = (byte)((value >> 8) & 0xFF); } return output; }线性插值虽然简单,但对语音识别来说足够了。我对比过用专业重采样库和线性插值的效果,识别准确率差异在1%以内,但代码复杂度差了好几倍。
3.3 WebSocket通信与消息协议设计
WebSocket连接建立之后,你需要定义一套消息协议。我用的格式是JSON,每条消息有一个type字段标识类型,data字段放具体内容。
发送音频的消息:
{ "type": "audio_input", "data": { "format": "pcm16", "sample_rate": 16000, "audio": "base64编码的PCM数据", "session_id": "xxx" } }接收消息有几种类型:
text_delta:流式文本片段。audio_delta:流式语音片段。turn_end:本轮对话结束。error:错误信息。
C#侧的WebSocket管理类:
using NativeWebSocket; using Newtonsoft.Json.Linq; public class QwenWebSocketClient { private WebSocket websocket; private string sessionId; public async void Connect(string url, string apiKey) { websocket = new WebSocket(url, new[] { "Authorization: Bearer " + apiKey }); websocket.OnOpen += () => { Debug.Log("WebSocket connected"); sessionId = System.Guid.NewGuid().ToString(); }; websocket.OnMessage += (bytes) => { string message = System.Text.Encoding.UTF8.GetString(bytes); HandleMessage(message); }; websocket.OnError += (err) => { Debug.LogError("WebSocket error: " + err); }; await websocket.Connect(); } private void HandleMessage(string json) { var obj = JObject.Parse(json); string type = obj["type"]?.ToString(); switch (type) { case "text_delta": string text = obj["data"]["text"].ToString(); EventBus.Publish(new TextDeltaEvent(text)); break; case "audio_delta": byte[] audio = System.Convert.FromBase64String( obj["data"]["audio"].ToString()); EventBus.Publish(new AudioDeltaEvent(audio)); break; case "turn_end": EventBus.Publish(new TurnEndEvent()); break; } } public async void SendAudio(byte[] pcmData) { var msg = new JObject { ["type"] = "audio_input", ["data"] = new JObject { ["format"] = "pcm16", ["sample_rate"] = 16000, ["audio"] = System.Convert.ToBase64String(pcmData), ["session_id"] = sessionId } }; await websocket.SendText(msg.ToString()); } }这里用了一个简单的EventBus来做事件分发,避免WebSocket类直接依赖UI或角色控制。实际项目里你可以用UnityEvent或者Action回调,看个人习惯。
提示:Base64编码会让数据体积增大约33%,如果音频较长,可以考虑用二进制帧直接发送PCM数据,省去编码开销。但二进制帧的协议设计要复杂一些,需要自己定义头部来区分消息类型。原型阶段用Base64就够了。
3.4 流式语音播放的实现细节
模型返回的语音是分片的,每片可能只有几百毫秒。如果每收到一片就创建一个AudioClip播放,会有明显的断音。正确的做法是用一个环形缓冲区,把所有收到的PCM数据写进去,AudioSource从缓冲区里连续读取。
public class StreamingAudioPlayer : MonoBehaviour { private AudioSource audioSource; private AudioClip streamClip; private float[] ringBuffer; private int writePos; private int readPos; private readonly object lockObj = new object(); private const int BufferSize = 16000 * 10; // 10秒缓冲 void Start() { audioSource = gameObject.AddComponent<AudioSource>(); ringBuffer = new float[BufferSize]; streamClip = AudioClip.Create("stream", BufferSize, 1, 16000, true, OnPCMRead); audioSource.clip = streamClip; audioSource.loop = true; audioSource.Play(); } private void OnPCMRead(float[] data) { lock (lockObj) { for (int i = 0; i < data.Length; i++) { if (readPos != writePos) { data[i] = ringBuffer[readPos]; readPos = (readPos + 1) % BufferSize; } else { data[i] = 0f; // 缓冲区空,输出静音 } } } } public void AppendAudio(byte[] pcmBytes) { lock (lockObj) { for (int i = 0; i < pcmBytes.Length; i += 2) { short value = (short)(pcmBytes[i] | (pcmBytes[i + 1] << 8)); ringBuffer[writePos] = value / 32768f; writePos = (writePos + 1) % BufferSize; } } } }这个方案的关键是AudioClip.Create的stream参数设为true,然后提供PCMReaderCallback。Unity的音频线程会定期调用这个回调来填充音频数据。注意回调是在音频线程上执行的,不是主线程,所以对共享缓冲区的访问必须加锁。
audioSource.loop = true是必须的,因为streamClip的长度是固定的,不循环的话播完就停了。实际上我们是通过环形缓冲区来实现“无限长度”的。
这个方案我实测下来延迟可以控制在100毫秒以内,而且不会有断音。唯一需要注意的是缓冲区大小,太小了容易欠载(underrun),太大了会增加延迟。10秒的缓冲对于语音对话来说比较合适。
4. 多模态输入的工程处理
4.1 图像输入:截图、编码与传输
Qwen2.5-Omni支持图像输入,这在Unity里意味着你可以把游戏画面截图发给模型,让它“看到”当前场景。应用场景很多:虚拟导游看到用户指的建筑、AI助手看到玩家当前的装备界面、教育应用看到学生画的图等等。
截图用ScreenCapture.CaptureScreenshotAsTexture或者Camera.Render到RenderTexture。前者简单但会包含UI,后者可以指定相机,更灵活。
public byte[] CaptureCameraImage(Camera cam, int width = 448, int height = 448) { RenderTexture rt = new RenderTexture(width, height, 24); cam.targetTexture = rt; cam.Render(); RenderTexture.active = rt; Texture2D tex = new Texture2D(width, height, TextureFormat.RGB24, false); tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); tex.Apply(); cam.targetTexture = null; RenderTexture.active = null; Destroy(rt); byte[] jpgBytes = tex.EncodeToJPG(80); Destroy(tex); return jpgBytes; }分辨率我建议控制在448x448到1024x1024之间。太小了模型看不清细节,太大了传输慢而且模型内部也会做缩放。448x448对于大多数场景理解任务够用了,JPEG质量80也能在体积和清晰度之间取得平衡。
传输时同样用Base64编码,放在消息的image字段里。注意图像和音频可以在同一个对话轮次里一起发送,模型会同时理解两者。比如用户说“这个是什么”,同时传一张截图,模型就能结合语音和画面来回答。
4.2 视频理解的替代方案
Qwen2.5-Omni虽然支持视频输入,但在Unity里传视频流不太现实——数据量太大,而且模型对视频的处理时间也长。我的做法是用“关键帧序列”来模拟视频理解。
具体来说,在用户提问的前后几秒内,每隔一定时间截一帧,比如每500毫秒截一帧,取3到5帧,作为多张图像一起发送。模型会把这些帧当作一个序列来理解,效果上接近短视频。
这个方案的好处是数据量可控,而且实现简单。缺点是对于快速运动的场景,可能漏掉关键信息。但对于大多数对话场景——用户指着某个东西问问题——静态帧足够了。
4.3 多模态消息的组装与发送
把文本、音频、图像组装成一条消息:
public JObject BuildMultimodalMessage(string text, byte[] audio, byte[] image) { var content = new JArray(); if (!string.IsNullOrEmpty(text)) { content.Add(new JObject { ["type"] = "text", ["text"] = text }); } if (audio != null) { content.Add(new JObject { ["type"] = "audio", ["format"] = "pcm16", ["sample_rate"] = 16000, ["data"] = System.Convert.ToBase64String(audio) }); } if (image != null) { content.Add(new JObject { ["type"] = "image", ["format"] = "jpeg", ["data"] = System.Convert.ToBase64String(image) }); } return new JObject { ["type"] = "multimodal_input", ["data"] = new JObject { ["content"] = content, ["session_id"] = sessionId } }; }消息组装好之后通过WebSocket发出去,剩下的就是等模型返回。注意多模态输入的处理时间会比纯文本长,尤其是带图像的时候,延迟可能到2到4秒。UI上要有明确的“思考中”提示,不然用户会以为卡死了。
5. 常见问题与排查实录
5.1 音频相关问题的速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型识别不出语音 | 采样率不对 | 打印实际采集的采样率 | 统一转成16kHz |
| 语音断断续续 | 缓冲区欠载 | 检查环形缓冲区读写位置 | 增大缓冲区,检查锁竞争 |
| 模型听到自己的声音 | 回声未消除 | 播放时观察麦克风电平 | 播放时暂停采集或加AEC |
| 录音有电流声 | 位深转换错误 | 检查short转byte的字节序 | 确认小端序,检查符号位 |
| 播放速度不对 | 采样率不匹配 | 对比采集和播放的采样率 | AudioClip创建时用正确采样率 |
5.2 网络与延迟优化
延迟是语音交互体验的杀手。我实测下来,端到端延迟的构成大概是:录音停止检测(300-800ms)+ 音频上传(100-500ms)+ 模型推理(500-2000ms)+ 语音下载和播放缓冲(200-500ms)。
能优化的点:
- 静音检测:不要用固定的静音时长,用自适应阈值。环境噪音大的时候阈值高一些,安静的时候低一些。我用的是基于RMS能量的简单VAD,效果够用。
- 音频压缩:PCM虽然简单但体积大。如果网络是瓶颈,可以考虑用Opus编码,体积能压到PCM的十分之一。但模型端要支持Opus解码,这个要提前确认。
- 就近接入:如果服务端有多个区域,选离你用户最近的。Unity里可以通过ping或者测速来决定连接哪个端点。
- 预连接:WebSocket连接建立本身也要时间,可以在进入对话场景时就提前连好,而不是等用户按下说话才连。
5.3 平台兼容性踩坑记录
WebGL平台:NativeWebSocket在WebGL上不能用,因为浏览器不允许直接访问TCP Socket。替代方案是用JavaScript的WebSocket,通过jslib和Unity通信。或者干脆走HTTP+SSE。但WebGL的麦克风权限也有坑,需要用户手势触发才能申请。
Android平台:麦克风权限要在AndroidManifest.xml里声明,而且Android 6.0以上要运行时申请。另外Android的音频采集延迟比PC高,缓冲区要设大一些。
iOS平台:麦克风权限同样要声明,而且iOS对后台音频有严格限制。如果应用切到后台,音频采集会被暂停。
IL2CPP编译:Newtonsoft.Json在IL2CPP下需要AOT配置,否则可能报错。可以用link.xml保留必要的类型,或者换用System.Text.Json。
注意:如果你要发布到微信小游戏平台,WebSocket和麦克风都有额外的限制。微信小游戏的WebSocket需要走
wx.connectSocket,麦克风要用wx.getRecorderManager。这块需要单独做适配层,不能直接用Unity的API。
5.4 成本控制与请求频率管理
Qwen2.5-Omni的API是按token计费的,音频和图像都会折算成token。如果不管控,成本很容易失控。
几个实用的控制手段:
- 音频时长限制:单次录音不超过15秒,超时自动截断。
- 图像分辨率限制:不要超过1024x1024,448x448通常够用。
- 对话轮次限制:维护一个上下文窗口,超过一定轮次就丢弃最早的对话。
- 本地缓存:对于常见问题的回答,可以在本地做缓存,避免重复请求。
- 降级策略:网络不好或者成本超预算时,降级到纯文本对话。
我在项目里做了一个简单的token计数器,每次请求前估算一下消耗,超过阈值就提示用户或者自动切换到轻量模式。这个在Demo阶段可能用不上,但上线前一定要加。
6. 从原型到产品的进阶思路
6.1 角色人格与对话风格的一致性
模型默认的输出风格是“助手”式的,但你的项目可能需要一个傲娇的精灵、一个沉稳的导师、或者一个活泼的吉祥物。怎么让模型保持角色一致性?
最直接的方法是在system prompt里定义角色。但光靠prompt还不够,因为多模态输入会引入很多变量。我的做法是维护一个“角色状态”,包括当前情绪、与用户的好感度、最近的话题等,每次请求时把这些状态注入到prompt里。
另外,语音输出的语气虽然不能直接控制,但可以通过文本内容来间接影响。比如在文本里加入“(开心地)”、“(小声地)”这样的标注,模型生成的语音会带有相应的语气变化。这个技巧我实测有效,但效果不是100%稳定,需要多试几次找到合适的表达方式。
6.2 打断机制与自然对话流
真人对话是可以打断的,但AI语音交互默认是“你说完我说,我说完你说”。要支持打断,需要在Speaking状态下持续监听麦克风,一旦检测到用户说话,立即停止播放并切换到Listening。
技术上的难点是回声消除——扬声器在播放,麦克风在采集,怎么区分是用户在说话还是扬声器的声音?简单的方案是用一个较高的能量阈值,只有用户声音明显大于扬声器时才触发打断。更好的方案是用AEC,把扬声器的参考信号从麦克风信号里减掉。
打断之后,已经收到的但还没播放的语音数据要丢弃,同时要通知模型“用户打断了”,让模型知道上一轮回复没有完整传达。这个通过发送一个interrupt消息来实现。
6.3 离线降级与混合架构
完全依赖云端模型有一个风险:网络断了或者服务不可用,整个交互就废了。对于产品级应用,需要设计降级方案。
降级的第一层是本地关键词识别。用Unity的KeywordRecognizer或者一个轻量级的本地语音识别模型,处理一些固定的指令,比如“打开菜单”、“下一个”、“返回”。这些不需要大模型,本地就能搞定。
降级的第二层是本地小模型。如果设备性能允许,可以跑一个量化后的小参数模型,处理简单的对话。虽然效果不如云端大模型,但至少能保证基本可用。
降级的第三层是纯文本模式。语音链路不通时,退回到键盘输入,至少保证功能不中断。
这个混合架构的复杂度不低,但对于要上线的产品来说,是值得投入的。我的建议是原型阶段先用纯云端方案跑通,验证了核心价值之后,再逐步加入降级层。
6.4 性能监控与迭代优化
上线之后,你需要知道实际运行效果。几个关键指标要持续监控:
- 端到端延迟:从用户停止说话到听到回复的时间。
- 识别准确率:模型是否正确理解了用户意图。
- 打断成功率:用户打断时系统是否正确响应。
- 错误率:网络错误、模型错误、音频错误的频率。
这些数据可以上报到自己的分析后台,用来指导优化。比如发现某个场景下识别准确率特别低,可能是背景噪音太大,需要加强VAD;发现延迟在某个时段特别高,可能是服务端负载问题,需要调整请求策略。
我在项目里做了一个简单的埋点系统,每次对话记录时间戳、音频时长、文本长度、是否打断等信息,存成CSV。积累几百条数据之后,就能看出明显的模式了。
这套方案从最初的原型到相对稳定的版本,我大概花了三周时间,其中一半时间在调音频链路。如果你只是想做Demo,一周之内能跑通;如果要上产品,音频处理、错误处理、降级策略这些“脏活”才是真正花时间的地方。但一旦跑通,你会发现语音交互带来的体验提升是值得的——用户不再需要学习UI,直接说话就行,这个门槛的降低对很多场景来说是质变。