资讯动态

ChatFly 语音输入踩坑记:ASR 流式识别中 finish_reason 的语义陷阱与修复

发布时间:2026/10/27 20:14:25 来源:尧图企业网站定制
1. 语音输入链路里最容易被忽略的一环做 ChatFly 这个项目的过程中语音输入一直是我最看重的功能之一。原因很简单聊天场景下打字效率太低尤其是移动端或者双手被占用的时候能直接说话转文字体验提升是质变级别的。但就是这个看起来调个 API 就完事的功能在第七次迭代里给我上了一课——ASR 把我的话吃了。具体表现是这样的用户对着麦克风说了一段话界面上文字正常滚动出来了看起来一切正常。但等到把识别结果提交给对话模型的时候模型收到的内容比用户实际说的少了一截有时候是最后几个字没了有时候干脆整段都是空的。更诡异的是前端显示的文本是完整的说明识别本身没问题问题出在识别完成这个信号的传递上。这个 bug 折磨了我差不多两个晚上。因为它的复现不稳定有时候说短句没事说长句就丢有时候网络好的时候没事网络一抖动就丢。我一开始怀疑是音频采集的问题换了采样率、换了编码格式甚至怀疑是 macOS 上麦克风权限的玄学问题折腾了一圈才发现根因在 ASR 服务返回的finish_reason字段上。这篇就围绕这个坑展开把 ChatFly 语音输入链路的完整设计、finish_reason的语义陷阱、WAV 音频在 macOS 上的处理细节以及我最后怎么定位和修复的过程完整地讲一遍。如果你也在做语音转文字相关的功能尤其是接第三方 ASR 服务的这篇应该能帮你少走不少弯路。2. ChatFly 语音输入的整体链路设计2.1 从按下麦克风到文字上屏中间经过了什么先把链路讲清楚不然聊 bug 就是空中楼阁。ChatFly 的语音输入流程大致分成四段第一段是音频采集。在 macOS 上我用的是系统原生的音频框架通过AVAudioEngine拿到麦克风的 PCM 数据。这里有个细节采集到的原始数据是浮点型的采样率通常是 44100Hz 或 48000Hz但大多数 ASR 服务要求的是 16000Hz、16bit、单声道的 WAV。所以采集完必须做一次重采样和格式转换。第二段是音频编码与分片。ASR 服务一般不是让你把一整段音频一次性丢过去而是走流式识别——你一边说它一边返回中间结果。所以要把 PCM 数据切成小片通常是每 100ms 到 200ms 一片编码成服务要求的格式后推过去。第三段是流式识别与结果拼接。服务端会不断返回识别片段每个片段带一个状态标记告诉你这是中间结果还是最终结果。前端需要把这些片段按顺序拼起来中间结果会被后续结果覆盖最终结果才落定。第四段是提交给对话模型。识别结束后把完整文本塞进对话请求里发给模型。问题就出在第三段和第四段之间的衔接上。前端显示的文本是拼接后的结果看起来没问题但提交给模型的那份文本是从另一个地方取的而那个地方依赖finish_reason来判断识别是否真的结束了。2.2 为什么流式识别不能简单等一个结束信号这里要解释一个很多人会踩的认知误区。流式 ASR 不像普通的 HTTP 请求发出去、等回来、结束它是一个持续的过程。服务端会持续推送数据什么时候算结束有两种情况一种是用户主动停止说话比如松开了麦克风按钮或者静音检测VAD判断用户说完了。这时候客户端要主动告诉服务端我说完了你把剩下的结果吐出来。另一种是服务端自己判断结束比如检测到一段静音或者达到了最大时长限制。这时候服务端会主动返回一个结束标记。这两种结束方式返回的finish_reason是不一样的。而我在代码里犯的错误就是把这两种情况当成了一种处理。2.3 一个被低估的字段finish_reasonfinish_reason这个字段字面意思是结束原因。在 ASR 服务的返回结构里它通常出现在最后一个识别片段上取值可能是stop、length、null之类的。不同服务商的命名不一样但语义大同小异stop或end正常结束识别结果完整。length或max_tokens因为达到长度限制被截断结果可能不完整。null或缺失还在识别中或者这个片段不是最终片段。我踩的坑是代码里只判断了finish_reason stop才认为识别结束然后把累积的文本提交给模型。但实际上当用户主动停止说话时服务端返回的可能是finish_reason: null加上一个is_final: true的标记或者干脆是另一个字段。结果就是代码一直在等stop等不到就不提交或者提交了一个不完整的中间结果。更坑的是有些服务在流式模式下最后一个片段的finish_reason是异步到达的可能比文本片段晚几十毫秒。如果你的代码在收到文本片段后立刻判断结束就会漏掉最后那部分内容。3. finish_reason 的语义陷阱与真实行为3.1 文档说的和实际返回的可能不是一回事我翻了好几遍 ASR 服务的官方文档文档里写的是流式识别结束时最后一个响应会包含finish_reason字段值为stop表示正常结束。看起来很清楚对吧但实际抓包看返回数据的时候我发现了几种文档里没写的情况第一种用户主动停止时服务端返回的最后一个片段finish_reason是null但带了一个is_final: true。这个is_final字段文档里提都没提。第二种网络抖动导致连接中断时服务端可能直接断开最后一个片段根本没有finish_reason字段前端拿到的是一个不完整的响应。第三种长音频被服务端主动截断时finish_reason是length但文本其实是完整的只是服务端不想再继续识别了。这三种情况如果代码只认stop就会分别导致主动停止时丢结果、网络抖动时丢结果、长音频误判为不完整。3.2 我实际抓到的几种返回结构对比为了把这个问题讲清楚我把当时抓到的几种典型返回结构整理成表格你可以对照看看自己有没有遇到类似的情况场景finish_reasonis_final文本是否完整正确处理方式用户主动停止nulltrue完整以 is_final 为准提交文本服务端正常结束stoptrue完整提交文本达到长度限制lengthtrue可能完整提交文本但提示用户可能被截断网络中断缺失缺失不完整不提交提示重试识别中nullfalse不完整继续累积不提交这张表是我踩坑之后总结出来的核心结论就一句话不要只依赖 finish_reason 判断结束要结合 is_final 和文本完整性一起判断。3.3 为什么服务商要设计得这么反直觉你可能会问为什么服务商不统一用一个字段表示结束非要搞出finish_reason、is_final、is_last这么多花样我后来想明白了这跟流式识别的底层实现有关。流式识别是增量返回的服务端每识别出一小段就推一次。is_final表示这个片段是最终版不会再改了而finish_reason表示整个识别会话结束了。这两个语义是正交的一个片段可以是is_final: true但finish_reason: null意思是这段文字定稿了但后面可能还有。也可以是is_final: false但finish_reason: stop意思是会话结束了但这段文字可能还会被修正。我之前的代码把这两个概念混为一谈只看了finish_reason自然就出问题了。4. macOS 上 WAV 音频处理的那些细节4.1 为什么 ASR 服务偏爱 WAV 格式聊完finish_reason再说说音频格式。ASR 服务几乎都要求 WAV 或者 PCM 裸流原因很简单这两种格式没有经过压缩服务端不需要解码就能直接送进声学模型延迟最低。MP3、AAC 这些压缩格式虽然体积小但解码需要时间而且是有损压缩可能影响识别准确率。WAV 的本质是一个文件头加上 PCM 数据。文件头里记录了采样率、位深、声道数这些信息。很多 ASR 服务对文件头的要求很严格采样率必须是 16000Hz位深必须是 16bit声道必须是单声道。如果你的 WAV 头写错了服务端可能直接拒绝或者识别出一堆乱码。4.2 macOS 上采集音频的采样率转换macOS 的AVAudioEngine默认采集的格式是 44100Hz 或 48000Hz 的浮点数据。要转成 16000Hz 的 16bit 整数需要做两步重采样和位深转换。重采样我用的是AVAudioConverter这是 macOS 原生提供的转换器质量比手写线性插值好很多。配置的时候要注意输入格式用采集到的格式输出格式指定为 16000Hz、16bit、单声道。转换器会自动处理抗混叠滤波避免重采样带来的频率混叠。位深转换就是把浮点数映射到 16bit 整数范围。浮点数的范围是 -1.0 到 1.016bit 整数的范围是 -32768 到 32767。转换公式是int16 float * 32767注意要 clamp 一下防止溢出。这里有个坑如果你直接用Int16(float * 32768)当 float 是 1.0 的时候会溢出成 -32768听起来就是一声爆音。所以要用 32767 而不是 32768并且做边界检查。4.3 WAV 文件头的正确写法WAV 文件头是 44 个字节结构是固定的。我见过不少人手写 WAV 头的时候把字段顺序搞错或者字节序搞反结果服务端读不出来。这里把关键字段列一下ChunkID4 字节固定是 RIFF。ChunkSize4 字节整个文件大小减去 8。Format4 字节固定是 WAVE。Subchunk1ID4 字节固定是 fmt 注意最后有个空格。Subchunk1Size4 字节PCM 格式固定是 16。AudioFormat2 字节PCM 是 1。NumChannels2 字节单声道是 1。SampleRate4 字节16000。ByteRate4 字节等于 SampleRate * NumChannels * BitsPerSample / 8。BlockAlign2 字节等于 NumChannels * BitsPerSample / 8。BitsPerSample2 字节16。Subchunk2ID4 字节固定是 data。Subchunk2Size4 字节PCM 数据的字节数。所有多字节字段都是小端序Little Endian。如果你用 Swift 或者 Objective-C 写注意Data的字节序处理。我当时的做法是写了一个WAVHeader结构体用withUnsafeBytes直接内存拷贝比手动拼字节数组靠谱得多。提示WAV 头的 ChunkSize 和 Subchunk2Size 必须和实际数据长度一致否则有些严格的服务端会直接报错。流式识别的时候如果你是把分片单独封装成 WAV每个分片都要有完整的头不能只给第一片加头。5. 定位吃话问题的完整排查链路5.1 第一步确认问题出在识别还是提交排查任何 bug第一步都是缩小范围。我当时的做法是在代码里加了两处日志一处打印 ASR 返回的完整文本一处打印提交给模型的文本。对比之后发现ASR 返回的文本是完整的提交给模型的文本少了最后一段。这就排除了识别本身的问题问题出在从识别结果到提交这个环节。范围一下子缩小了很多。5.2 第二步打印 finish_reason 和 is_final 的实际值接下来我在每次收到 ASR 响应的时候把finish_reason、is_final、文本长度都打出来。跑了几次之后规律就出来了用户说短句然后主动停止时最后一个片段的finish_reason是nullis_final是true。用户说长句自然结束时最后一个片段的finish_reason是stopis_final是true。网络抖动时最后一个片段的finish_reason缺失is_final也是缺失。而我原来的代码逻辑是if finish_reason stop { submit() }。所以第一种和第三种情况都不会提交或者提交的是上一次的中间结果。5.3 第三步构造可复现的测试用例找到规律之后我写了三个测试用例说一句短话说完立刻松开按钮。说一段长话中间不停顿让它自然结束。说话过程中切换网络模拟抖动。这三个用例分别对应上面三种情况。跑下来确认了修复方案的有效性。这里有个经验ASR 相关的 bug一定要构造可复现的用例不能靠多试几次。因为语音本身是不确定的你不构造用例永远不知道是代码问题还是音频问题。5.4 第四步修复方案与验证修复的核心思路是不再单一依赖 finish_reason而是综合判断。具体逻辑改成这样如果收到is_final: true的片段就把这个片段的文本追加到最终结果里。如果收到finish_reason: stop或length就认为会话结束提交最终结果。如果连接中断且没有收到任何结束标记就不提交提示用户重试。如果收到is_final: false的片段只更新中间显示不追加到最终结果。这样改完之后三种测试用例都通过了。短句不再丢长句正常网络抖动会提示重试而不是静默丢结果。6. 流式识别结果拼接的工程实践6.1 中间结果和最终结果要分开存这是我在修复过程中悟出来的一个设计原则中间结果和最终结果必须用两个变量存不能混在一起。中间结果是用来实时显示给用户看的它会不断被覆盖。最终结果是用来提交给模型的只增不改。如果你用一个变量同时承担这两个职责就会出现显示的是最新的提交的却是旧的这种问题。我的做法是维护两个字符串displayText和finalText。每次收到片段如果是is_final: false就更新displayText如果是is_final: true就把内容追加到finalText同时更新displayText。提交的时候只用finalText。6.2 拼接时的去重与顺序问题流式识别的片段有时候会有重叠。比如服务端可能先返回今天天气再返回今天天气不错这时候如果你直接拼接就会变成今天天气今天天气不错。正确的做法是每个片段带一个序号或者时间戳拼接的时候根据序号判断是覆盖还是追加。如果服务端没给序号那就只能靠文本前缀匹配来去重——判断新片段是否以已有文本开头如果是就替换不是就追加。这个逻辑听起来简单但实际写起来要考虑很多边界情况。比如用户说了两遍同样的话前缀匹配就会误判。所以最好的方案还是让服务端返回序号客户端按序号拼接。6.3 超时与重试的边界处理流式识别还有一个容易被忽略的问题超时。如果用户说了很久或者网络很慢连接可能超时断开。这时候要区分两种情况一种是正常超时比如服务端设置了最大识别时长到了就断开。这时候应该把已经识别到的文本提交并提示用户识别已结束。另一种是异常超时比如网络问题导致连接断了。这时候不应该提交因为文本可能不完整应该提示用户重试。区分这两种情况靠的就是finish_reason。如果断开前收到了stop或length就是正常超时如果什么都没收到就断了就是异常超时。7. 几个让我印象深刻的踩坑瞬间7.1 那个看起来正常的假象最坑的地方在于前端显示的文本一直是正常的。因为显示用的是displayText它会被中间结果不断更新所以用户看到的是完整的。但提交用的是另一个变量那个变量因为finish_reason判断错误没拿到最后一段。这种显示正常但实际有问题的 bug 最难查因为用户反馈的时候说的是有时候回复不对而不是文字少了。我一开始还以为是模型的问题查了半天模型侧最后才发现是 ASR 这边的锅。7.2 macOS 麦克风权限的玄学排查过程中我还遇到过一个插曲有时候第一次调用麦克风没声音第二次就好了。我一度以为是权限问题查了AVAudioSession的配置加了各种权限请求代码都没用。后来发现是AVAudioEngine的启动时机问题。如果在引擎还没完全启动的时候就往里写数据数据会丢。正确的做法是等引擎的isRunning变成 true 之后再开始采集。这个跟 ASR 没关系但排查的时候很容易被误导。7.3 采样率不匹配导致的识别乱码还有一次我改了重采样的代码结果识别出来的文字全是乱码。查了半天发现是重采样后的采样率标错了——实际数据是 16000Hz但 WAV 头里写的是 44100Hz。服务端按 44100Hz 去解读 16000Hz 的数据自然就乱了。这个坑的教训是WAV 头里的采样率必须和实际数据的采样率一致不能想当然。改完重采样逻辑之后一定要验证 WAV 头里的字段。8. 给同样在做语音输入的人几条实在建议第一不要相信文档说的一定对。ASR 服务的实际返回结构可能和文档有出入尤其是流式模式下。一定要自己抓包看真实数据把各种边界情况的返回都摸清楚。第二中间结果和最终结果分开存。这是血泪教训混在一起迟早出问题。第三构造可复现的测试用例。语音相关的 bug 靠多试几次是查不出来的必须构造确定的场景。第四WAV 头的字段要仔细核对。采样率、位深、声道数、数据长度任何一个错了都可能导致识别失败或乱码。第五超时和断连要区分处理。正常结束和异常中断的处理逻辑不一样不能一概而论。第六日志要打全。finish_reason、is_final、文本长度、时间戳这些都要打出来。出问题的时候日志是你唯一的线索。我在 ChatFly 这个项目上踩的 ASR 坑说到底就是对一个字段的理解不到位。但正是这种看起来很简单的地方最容易出问题。语音输入这条链路从采集到编码到识别到提交每一环都有细节任何一环疏忽都会导致用户体验受损。希望这篇记录能帮你在这条路上走得顺一点。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑