端侧多模态会话与流式语音的状态管理
iOS 多模态助手的链路并不是“模型输出文字,再交给播放器”。一轮回答同时涉及会话历史、图片与文本的对应关系、生成任务、语音分句、自回归音频码、codec 缓存和播放缓冲。用户发送下一条消息、编辑历史或停止朗读时,这些状态需要分别处理。
下面结合实际项目中的 Swift 会话层、llama.cpp 原生桥接和 MOSS-TTS-Nano ONNX 语音路径,梳理这些状态的作用范围。当前代码已经实现的队列、代次校验与缓存管理,和仍需完善的底层取消、背压能力分开说明。
一、先分清状态由谁持有
| 状态 | 当前持有位置与生命周期 | 需要避免的混用 |
|---|---|---|
| 消息与附件 | 应用层持久化,同一会话内保留 | 把聊天记录当成已经驻留的模型 KV |
| 语言模型与 context | 当前本地桥接每次请求创建并释放 | 根据界面“已加载”状态推断模型常驻 |
| TTS 图与权重 | 活跃朗读期间复用,队列空闲或停止时释放 | 每个音频帧重新创建 Session |
| 参考音频码 | 按声纹配置缓存,可跨次合成复用 | 更换参考音频后继续命中旧条件 |
| global 音频 KV | 当前文本段的帧生成序列 | 把上一句的缓存直接续到新 prompt |
| local 音频 KV | 当前音频帧内部的码本生成 | 与跨帧的 global KV 共用生命周期 |
| codec 状态 | 同一连续音频序列的解码过程 | 重排音频帧,或无条件跨句续用 |
| 播放缓冲与回调 | 当前朗读代次 | 旧任务结束回调修改新任务计数 |
同样叫缓存,复用条件也不同。模型参数可以在输入变化后继续使用,KV 却依赖前面实际处理过的序列;参考音频码描述说话人条件,codec 缓存则描述连续音频帧的解码历史。
二、聊天历史不等于模型 KV
当前会话层根据配置保留最近的消息,加入 system prompt,并过滤空的 assistant 占位消息,再交给本地或远程模型。本地 llama.cpp 桥接随后加载 GGUF、创建 context、处理 prompt 并生成,结束后释放资源。因此目前的多轮对话依靠重放消息历史实现,没有跨轮复用本地聊天 KV。
这种方式比较容易保证请求隔离,代价是反复加载与 prefill。后续若改为模型常驻,应继续区分权重常驻和 context 复用:前者主要减少初始化,后者还要求模板、token、图片表示与位置完全对应。
编辑历史后重新生成,会改变被编辑位置之后的上下文。当前应用会移除该消息之后的回答并重新构造请求;若以后复用 KV,也必须同时截断或重建对应的模型状态。只修改界面的消息列表,不能保证模型已经忘记旧内容。
消息轮数也不是 token 预算。长文本、system prompt 和图片输入都会占用上下文。按最近若干轮截取历史只是应用层策略,还需要检查模型实际输入长度,为生成留出空间。
图片输入还需要保留顺序。本地服务按消息顺序收集图片路径,在相应消息里插入媒体标记,再由 mtmd 处理文本与图像。媒体标记、实际图片和消息位置应一一对应,不能只维护一个脱离历史的“本轮图片数组”。
尤其要核对历史图片被删除、附件路径失效以及用户编辑消息的情况。当前图片路径收集会过滤不存在的文件,而模板中的标记按附件数量建立;两者数量不一致时,应该在请求前发现,而不是交给模型猜测。
当前本地路径接入的是图片 mtmd,其他附件类型有明确的能力检查。界面能选择某种媒体,不等于本地模型链路已经支持它;远程接口的能力也不能自动映射到本地桥接。
三、文本流、分句与音频流是三层流程
项目中的“流式朗读”包含两种独立行为:一是语言模型边输出文字,应用边切出可朗读片段;二是某个片段已经提交 TTS 后,音频码边生成、codec 边解码、播放器边消费 PCM。
关闭边生成边朗读,只会改为等完整回答后再提交文本,并不意味着 codec 必须等整段音频生成完才能播放。
模型文字增量 → 文本缓冲 → 自然语言片段 → 顺序合成任务
↓
音频码帧 → 有状态 codec → PCM → 播放会话层会累计文字增量,并按短时间间隔批量刷新 UI,避免每个 token 都引发界面更新;朗读缓冲则按语言停顿与长度预算释放文本。二者不必使用相同的切分粒度。
分句不能简单遇到句号就提交。例如某次增量以 3. 结束,下一次可能补成 3.5。当前实现会对数字之间的小数点、末尾尚未确定的英文句号保留等待,并结合中英文长度估计、自然停顿和音频帧预算选择切分位置。生成结束时,再处理剩余文本。
过短的片段会反复建立语音生成上下文,停顿也更明显;过长则增加首段等待,还可能触及生成帧数限制。分句需要同时考虑听感、首音延迟与模型预算,单纯把字符上限调小并不一定更流畅。
展示文本和朗读文本也不应共用一个可变字符串。Markdown、符号与格式清理只作用于提交 TTS 的副本;最终回答的清理则发生在会话展示层。若不希望朗读 thinking 或其他控制片段,需要在流式阶段就识别,等回答结束后再删除,已经无法撤回播放过的声音。
流式文本还涉及字节与字符的对应。本地原生桥接按 token 片段尝试构造 UTF-8 字符串,而一个 token 的字节片段不一定能单独解码。流式输出需要保留尚未完整的字节,待后续补齐后再发给 Swift。完整输出按原始字节拼接后正确,也不能单独证明逐片段回调没有漏字;这应纳入本地流式文本的验证。
四、语音生成内部还有三套不同状态
MOSS ONNX 路径不是用一个模型反复输出 PCM。当前实现先构造参考音频码与文本组成的输入,执行 global prefill,随后进入跨音频帧的 global 循环。每一帧内部再通过 local prefill/step,依次生成各码本通道的 token。
因此 global KV 随音频帧推进,而 local KV 在每一帧开始时重新建立,并在该帧的码本生成中推进。把两者都理解成“整个回答共用的 KV”,会在复用时接错状态。
新文本段会重新构造 prompt 与 global prefill。虽然底层 ONNX Session 可以复用,当前段的生成 KV 仍然是新的。只有生成协议明确支持续写、输入序列又正确衔接时,才有条件跨段保留 KV。
跨图传递 KV 时,顺序也需要显式对应。导出的图将 KV 暴露为具名张量,需要按层号和 key/value 身份接回下一次调用。字典遍历顺序或普通字符串排序都不适合作为隐含约定:pkv_10_key 可能排在 pkv_2_key 前面,而它们的 shape 又完全相同。
当前实现解析数字索引并区分 key/value 后排序,再建立 step 输入。移植时还应将输入名、输出名、层号、类型、shape 和有效长度一并对照。shape 匹配只能说明张量能传入,不能说明接到了正确位置。
codec 则使用另一套状态。流式 decoder 根据元数据保存 attention 的 key/value、位置及其他状态,并将本次输出接到下一批音频帧的输入。音频码不能乱序或跳过后继续沿用旧状态;独立的新段落则需要按解码协议重新初始化。
五、播放状态与生成完成分开管理
当前正常语音路径将连续码帧送入 streaming decoder,输出 PCM 后调度到同一个 AVAudioEngine 播放节点。项目使用的流式 codec 输出为 48 kHz 双声道,并按通道平面数据复制到非交错 PCM buffer。采样率、声道数和内存排列都必须来自输出契约,不能只把数组长度当作单声道采样点数。
“音频已生成”“buffer 已调度”和“声音已播放完”是三个不同事件。项目使用 .dataPlayedBack 回调更新待播放 buffer 数,播放开始前也保留了少量预缓冲;短段不足预缓冲长度时,在生成结束后强制启动,避免短句一直等待。
播放器回调通常晚于提交发生。停止后,旧 buffer 的完成回调仍可能进入应用层,因此回调也要核对当前播放代次,再更新计数。只清空队列但让旧回调继续减计数,可能破坏新一次朗读的完成判断。
会话层通过一个 worker 依次处理朗读片段,TTS 层也有忙碌状态保护,避免多段生成同时争用运行时。但当前主路径中的文本待合成数组和异步音频流,没有形成完整的按播放余量等待的背压协议。不能仅因存在队列,就把它描述成容量已经受控。
对 Float32 PCM,仅样本数据的占用约为:
48 kHz 双声道缓存 10 秒,样本本身约为 3.84 MB,尚未计入复制、音频引擎与模型内存。实际积压可能同时存在于文本队列、异步流和播放器中,只限制其中一层不一定能控制峰值。
更完整的做法是按未播放时长或字节设置高低水位,让生产者在容量不足时等待。音频帧具有连续性,不能直接套用“只保留最新结果”的丢弃策略;如果必须中断,应结束当前生成序列并清理对应 codec 与播放状态。
六、停止朗读需要处理到哪一层
项目给朗读任务和播放器分别维护代次标识。停止时先更换标识,再清空待处理片段、取消相关 Swift Task 并停止播放器。异步完成的结果和回调要检查自己的代次,旧结果即使晚到,也不应进入新的朗读。
| 层次 | 停止时需要完成的事 |
|---|---|
| 会话与 UI | 保留已经生成的文字,结束旧请求的更新资格 |
| 文本分段 | 不再提交旧回答的剩余片段 |
| 合成 worker | 停止取新任务,使迟到结果失效 |
| 原生推理 | 在安全检查点退出,或等待正在执行的调用返回 |
| codec 与播放 | 丢弃旧序列状态、清理缓冲并停止发声 |
| 完成回调 | 校验代次,避免修改新任务状态 |
Swift 取消是协作式的,Task.cancel() 不会强行中断同步的 C/C++ 或 ONNX 调用。当前远程文本流和 ONNX 音频流有终止处理,但本地 llama.cpp 使用独立的 detached task 调用同步桥接,还没有接通原生 decode 循环的中止信号。因此界面停止接收旧 token,不代表底层生成已经立即结束。
ONNX 语音路径会在音频块回调处检查取消;如果一轮推理仍在同步执行,取消也需要等待相应检查点。要缩短资源占用,应进一步将停止信号传到安全的逐步生成位置,而不是在使用中的 context 上直接释放内存。
开始新任务时,还需要考虑旧推理尚未退出的情况。代次校验可以防止串音,却不能消除两个底层任务重叠带来的算力和内存争用;资源复用前应确认旧任务已经结束。
七、复用运行时与回退的取舍
ONNX 语音 Session 在活跃朗读期间复用,worker 结束或停止朗读时释放运行时内存。相比永久常驻,这减少了语言模型、视觉编码器与 TTS 长期同时占用内存的压力;代价是下一轮可能重新加载。项目也在片段前后检查进程物理内存占用,必要时暂停语音。
这里不应将界面的“模型已加载”、磁盘中的模型文件、持有的 Session 和当前 KV 混为一个状态。释放 TTS 运行时不必删除声纹文件,停止播放也不必删除聊天记录;卸载与清理应针对明确的资源。
项目文档记录过 CoreML 路径在动态自回归图上出现无界维度、图分区及模型计划问题,当前 ONNX 语音主路径固定使用 CPU EP。这个结果适用于该模型导出与运行环境,需要逐图比较后端,不能概括为 iOS 语音模型都不适合 CoreML。
流式 codec 还有完整解码的回退,但回退时机很重要:当前逻辑只在还没有发出音频时允许重新走完整解码;一旦已经发出部分 PCM,再从头回退会重复播放前面内容,此时应结束并报告失败。
八、先对齐序列,再评估体验
移植验证应从确定性较强的中间结果开始,而不是只凭最终音频“听起来差不多”:
| 验证层次 | 对照内容 |
|---|---|
| 会话输入 | system prompt、历史截取、媒体标记与图片顺序 |
| 文本增量 | 不同切块方式下能否还原同一字符串,有无漏字或重复 |
| 音频生成 | 固定输入下的 global/local 输出、KV 身份与码帧顺序 |
| codec | 同一组码帧分批输入与参考连续解码的 PCM 对齐 |
| 播放 | 通道排列、时长、短句启动、尾段播放与停止 |
| 生命周期 | 发新消息、切换会话、编辑重发后是否仍有旧结果进入 |
随机采样需要单独控制。当前 ONNX 实现存在随机抽样,不能把另一个后端固定了 seed,理解为整条应用链路已经可复现。做数值对照时应注入相同码帧或使用可复现随机源,再将采样差异与缓存接线错误分开。
端侧体验也要分段计时:首个文字到达、首段文本提交、首个 PCM 产生、真正开始播放、最后一段播完,以及停止后底层任务退出。只记录每秒生成多少音频帧,会遗漏初始化、分句等待和播放积压。
这类应用最终需要的不是一个统一的“忙碌”标志,而是能够回答:谁仍在计算、哪段输出还有效、哪些缓存可复用,以及哪一层已经真正结束。把这些关系明确之后,常驻、预取和异步执行才有可靠的优化基础。