ingFang SC";font-weight: bold;color: rgb(255, 66, 0);line-height: 20px;visibility: visible;"> ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1.75em;">本文介绍了在 iOS 平台上使用 MNN 框架部署大语言模型(LLM)时,针对聊天应用中文字流式输出卡顿问题的优化实践。通过分析模型输出与 UI 更新不匹配、频繁刷新导致性能瓶颈以及缺乏视觉动画等问题,作者提出了一套包含智能流缓冲、UI 更新节流与批处理、以及打字机动画渲染的三层协同优化方案。最终实现了从技术底层到用户体验的全面提升,让本地 LLM 应用的文字输出更加丝滑流畅,接近主流在线服务的交互体验。 ingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.544px;visibility: visible !important;width: 113px !important;"/>![]() ![]() ![]() ![]() ![]() ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1em;">
在iOS端部署大语言模型(LLM) 聊天应用时,用户体验的流畅性是一个关键要素。MNN LLM iOS应用基于MNN推理框架,为用户提供本地化的AI对话体验。如果直接将模型的输出更新到回答的页面UI中,会有一个严重影响用户体验的问题:模型输出文字时存在明显的卡顿现象,文字显示生硬,缺乏自然的流动感。
因为用户已经习惯了ChatGPT、Qwen等在线服务提供的流畅回复和丝滑打字机效果。本地模型推理输出没有网络延迟,如果直接将模型结果输出,在用户体验上会大打折扣。所以我针对这个问题,进行了优化。本文将分析具体的问题,针对这些问题提出解决方法,并且详细的讲解具体的原理和实现。 ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1.75em;"> ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1.75em;">我们先看看优化前的直接输出:完整的项目地址如下:https://github.com/alibaba/MNN/blob/master/apps/iOS/MNNLLMChat/README.md ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1.75em;">
ingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", "Microsoft YaHei", Arial, sans-serif;letter-spacing: 0.544px;visibility: visible !important;width: 113px !important;"/>![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ![]() ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 2em;">
通过输出现象分析,可以识别出导致卡顿和生硬输出的三个核心问题: 1. 模型输出速度与UI更新频率不匹配 2. UI刷新频率过高造成性能瓶颈 3. 缺乏流式输出的视觉动画效果 现象:文字瞬间出现,缺乏渐进式的视觉反馈。 原因:没有展示类似打字机的逐字符显示动画。 ingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;font-size: 16px;font-style: normal;font-variant-ligatures: normal;font-variant-caps: normal;font-weight: 400;letter-spacing: normal;orphans: 2;text-align: justify;text-indent: 0px;text-transform: none;widows: 2;word-spacing: 0px;-webkit-text-stroke-width: 0px;white-space: normal;background-color: rgb(255, 255, 255);text-decoration-thickness: initial;text-decoration-style: initial;text-decoration-color: initial;line-height: 1.75em;">
在Chat应用回答的过程中, 数据流向如下: 原始输出流 → 智能缓冲 → 批量更新 → 动画渲染 → 用户界面。 基于上面的数据流和优化需求,我们在可以进行后面三层协同优化策略:
▐1.底层流缓冲优化 (OptimizedLlmStreamBuffer)
职责:解决模型输出与UI更新的频率不匹配问题。
▐2. 中间层更新优化 (UIUpdateOptimizer)
职责:统一管理UI更新请求,实现批处理和节流。
▐3. UI层动画增强 (LLMMessageTextView)
职责:提供自然流畅的用户视觉体验。 条件化动画:判断是否需要启用打字机效果; 流式适配:完美适配流式输出的文本变化; 资源管理:自动清理动画资源,防止内存泄漏。
最终,我们通过底层增加缓冲输出,中层合并更新请求,UI层提供视觉缓冲——这三层配合实现了从技术优化到体验优化的完整覆盖,提升整体性能和体验效果。
▐1. OptimizedLlmStreamBuffer:智能流缓冲优化
OptimizedLlmStreamBuffer是对标准std::streambuf的增强实现,通过智能缓冲策略解决模型输出与UI更新的频率不匹配问题。它的工作原理是在模型输出和UI更新之间建立一个缓冲层,根据内容特征和缓冲大小决定何时将累积的内容推送给UI。
classOptimizedLlmStreamBuffer:publicstd::streambuf {private: staticconstsize_tBUFFER_THRESHOLD =64; // 缓冲区阈值(字节) std::string buffer_; // 内容缓冲区public: usingCallBack = std::function<void(constchar* str,size_tlen)>;// 更新回调 OptimizedLlmStreamBuffer(CallBack callback);protected: virtualstd::streamsizexsputn(constchar* s, std::streamsize n)override;
private: voidflushBuffer(); // 刷新缓冲区 boolcheckForFlushTriggers(constchar* s, std::streamsize n);// 检查触发条件 boolcheckUnicodePunctuation(); // Unicode标点检测};
1.xsputn方法 - 数据流入口下面是整体方法流程,每当模型生成新内容时都会调用此方法: virtualstd::streamsizexsputn(constchar* s, std::streamsize n)override{ if(!callback_ || n <=0) { returnn;// 参数校验,确保安全性 }
try{ // 步骤1: 将新数据追加到缓冲区 buffer_.append(s, n);
// 步骤2: 判断是否需要立即刷新 constsize_tBUFFER_THRESHOLD =64; boolshouldFlush = buffer_.size() >= BUFFER_THRESHOLD;
// 步骤3: 如果大小未达标,检查内容特征 if(!shouldFlush && n >0) { shouldFlush =checkForFlushTriggers(s, n); }
// 步骤4: 符合条件则刷新缓冲区 if(shouldFlush) { flushBuffer(); }
returnn; }catch(conststd::exception& e) { NSLog(@"Error in stream buffer: %s", e.what()); return-1;// 异常处理,确保程序稳定性 }}
工作流程说明: 数据接收:模型每次输出的文本片段进入缓冲区; 阈值判断:当累积 内容达到64字节时立即输出; 自动触发:即使未达到阈值,遇到标点符号 也会触发输出; 异常处理:完善的错误处理机制保证系统稳定性。
2. 触发机制 constsize_tBUFFER_THRESHOLD=64;//积累64byte内容才输出 boolcheckForFlushTriggers(constchar* s, std::streamsize n){ charlastChar = s[n-1];// 获取最后一个字符
// 检查常见的英文标点符号 if(lastChar =='\n'|| // 换行符 - 句子结束 lastChar =='\r'|| // 回车符 - 兼容不同系统 lastChar ==' '|| // 空格 - 词语分隔 lastChar =='\t'|| // 制表符 - 格式化字符 lastChar =='.'|| // 句号 - 句子结束 lastChar ==','|| // 逗号 - 语句停顿 lastChar ==';'|| // 分号 - 语句分隔 lastChar ==':'|| // 冒号 - 说明引导 lastChar =='!'|| // 感叹号 - 情感表达 lastChar =='?') { // 问号 - 疑问句结束 returntrue; }
returncheckUnicodePunctuation();// 继续检查Unicode标点}
触发逻辑说明: 语义完整性:在语义完整的点进行输出,提升阅读体验 视觉节奏:模拟人类阅读时的自然停顿 跨语言支持:同时支持英文和中文的标点符号 Unicode标点符号检测
中文标点符号采用UTF-8编码,需要特殊处理: boolcheckUnicodePunctuation(){ if(buffer_.size() >=3) {// UTF-8中文标点通常占3字节 constchar* bufferEnd = buffer_.c_str() + buffer_.size() -3;
// 定义中文标点符号的UTF-8编码 staticconststd::vector<std::string> chinesePunctuation = { "\xE3\x80\x82", // 。(句号) - 句子结束 "\xEF\xBC\x8C", // ,(逗号) - 语句停顿 "\xEF\xBC\x9B", // ;(分号) - 语句分隔 "\xEF\xBC\x9A", // :(冒号) - 说明引导 "\xEF\xBC\x81", // !(感叹号) - 情感表达 "\xEF\xBC\x9F", // ?(问号) - 疑问句结束 "\xE2\x80\xA6", // …(省略号) - 语意延续 };
// 逐一比较字节序列 for(constauto& punct : chinesePunctuation) { if(memcmp(bufferEnd, punct.c_str(),3) ==0) { returntrue;// 找到匹配的中文标点 } } }
// 检查2字节的Unicode标点(如破折号) if(buffer_.size() >=2) { constchar* bufferEnd = buffer_.c_str() + buffer_.size() -2; if(memcmp(bufferEnd,"\xE2\x80\x93",2) ==0|| // – (短破折号) memcmp(bufferEnd,"\xE2\x80\x94",2) ==0) { // — (长破折号) returntrue; } }
returnfalse;}
UTF-8编码处理细节:
3. 内存预分配OptimizedLlmStreamBuffer(CallBackcallback):callback_(callback){buffer_.reserve(1024);//预分配1KB内存}
1)std::string 在动态增长时,每次容量不足都会: //没有预分配的情况下,字符串增长模式://容量:0->1->2->4->8->16->32->64->128->256->512->1024//重分配次数:约10次//预分配1024字节后://容量:1024(一次分配)//重分配次数:0次(在1024字节内) C++constsize_tBUFFER_THRESHOLD=64;boolshouldFlush=buffer_.size()>=BUFFER_THRESHOLD; 缓冲阈值:64字节触发刷新 预分配容量:1024字节 协同效果:支持16次缓冲操作而无需重分配
因此我们预分配1024字节避免了前期的多次重分配操作。
4. 异常安全设计 ~OptimizedLlmStreamBuffer(){flushBuffer();//析构时确保缓冲区内容全部输出}voidflushBuffer(){if(callback_&&!buffer_.empty()){callback_(buffer_.c_str(),buffer_.size());buffer_.clear();//清空缓冲区,释放内存}}
▐2. UIUpdateOptimizer:基于Actor的更新优化器
UIUpdateOptimizer采用Swift 5.5引入的Actor并发模型,解决UI更新的线程安全和性能问题。它的核心思想是将频繁的UI更新请求按缓存大小或间隔时间进行批处理和节流,减少主线程压力。 Actor队列(批处理+节流)->主线程UI(低频率UI更新)
actorUIUpdateOptimizer{ staticletshared =UIUpdateOptimizer()// 全局单例
// 状态管理 privatevarpendingUpdates: [String] = [] // 待处理更新队列 privatevarlastFlushTime ate=Date() // 上次刷新时间 privatevarflushTask:Task<Void,Never>? // 延迟刷新任务
// 配置参数 privateletbatchSize:Int=5 // 批处理大小 privateletflushInterval:TimeInterval=0.03// 节流间隔(30ms)}
简单介绍一下 Actor。在多线程或异步程序中,多个任务访问共享变量时容易造成数据竞争(data race)。Actor 是一种引用类型,用来保护其内部状态免受数据竞争影响。它是并发安全的,当你调用时,会自动对外部访问进行同步(串行队列),所以不需要手动加锁。
1. 双重触发策略 funcaddUpdate(_ content:String, completion escaping(String) -> Void) { // 步骤1: 添加到待处理队列 pendingUpdates.append(content)
// 步骤2: 判断触发条件 letshouldFlushImmediately = pendingUpdates.count>= batchSize || Date().timeIntervalSince(lastFlushTime) >= flushInterval
// 步骤3: 选择处理策略 ifshouldFlushImmediately { flushUpdates(completion: completion)// 立即处理 }else{ scheduleFlush(completion: completion)// 延迟处理 }}
privatefuncscheduleFlush(completion escaping(String) -> Void) { // 取消之前的调度,避免重复执行 flushTask?.cancel()
// 创建新的延迟任务 flushTask =Task{ // 等待指定时间间隔 try?awaitTask.sleep(nanoseconds:UInt64(flushInterval *1_000_000_000))
// 检查任务是否被取消,以及是否有待处理内容 if!Task.isCancelled&& !pendingUpdates.isEmpty{ flushUpdates(completion: completion) } }}
上面的方式,可以: 节流控制:为UI更新提供30毫秒的缓冲时间; 批处理优化:在这30毫秒内如果有新的更新到来,会取消当前延迟任务并重新开始计时; 性能平衡:既避免过于频繁的UI更新,又保证内容能及时显示; 响应性保证:即使在低频更新场景下,也确保内容在30毫秒内显示给用户。
privatefuncflushUpdates(completion escaping(String) -> Void) { guard !pendingUpdates.isEmptyelse{return}
// 合并所有待处理的更新 letbatchedContent = pendingUpdates.joined()
// 清空队列,准备下一轮 pendingUpdates.removeAll() lastFlushTime =Date()
// 切换到主线程执行UI更新 Task{@MainActorin completion(batchedContent) }}
批处理优势分析:
▐3. LLMMessageTextView:沉浸式打字机动画
LLMMessageTextView的设计目标是创造接近人类打字速度的自然动画效果。通过设置的时间参数和智能的动画控制,让AI的文字输出更加自然和富有节奏感。
structLLMMessageTextView:View{ // 数据模型 lettext:String? // 完整文本内容 letmessageUseMarkdown:Bool // 是否使用Markdown渲染 letmessageId:String // 消息唯一标识 letisAssistantMessage:Bool // 是否为AI消息 letisStreamingMessage:Bool // 是否正在流式传输
// 动画状态 @StateprivatevardisplayedText:String=""// 当前显示的文本 @StateprivatevaranimationTimer:Timer? // 动画定时器
// 动画配置参数 privatelettypingSpeed:TimeInterval=0.015// 15ms每字符 privateletchunkSize:Int=1 // 每次显示1个字符}
1. 条件化动画触发 privatevarshouldUseTypewriter:Bool{//只有同时满足以下条件才启用动画://1.是AI助手的消息(用户消息不需要动画)//2.文本长度超过5个字符(避免短消息的不必要动画)returnisAssistantMessage&&(text?.count??0)>5}触发逻辑分析: 用户体验导向:只对AI消息使用动画,用户消息直接显示; 性能考虑:短消息(≤5字符)直接显示,避免动画开销; 场景适配:流式传输时启用动画,静态显示时关闭动画。
privatefunchandleTextChange(_newText:String?) { guardletnewText=newTextelse{ displayedText="" stopAnimation() return }
ifisAssistantMessage&&isStreamingMessage&&shouldUseTypewriter { // 智能判断文本变化类型 ifnewText.hasPrefix(displayedText)&&newText!=displayedText { // 场景1: 文本内容追加(流式输出的常见情况) continueTypewriterAnimation(with: newText) }elseifnewText!=displayedText { // 场景2: 文本内容完全变化(消息重新生成) restartTypewriterAnimation(with: newText) } // 场景3: 文本内容无变化,不做处理 }else{ // 非动画场景:直接显示完整文本 displayedText=newText stopAnimation() }}
1. 动画启动流程 privatefuncstartTypewriterAnimation(fortext:String) { // 步骤1: 重置显示状态 displayedText=""
// 步骤2: 开始动画循环 continueTypewriterAnimation(with: text)}privatefunccontinueTypewriterAnimation(withtext:String) { // 前置检查:避免无效动画 guarddisplayedText.count<text.countelse{return}
// 清理旧定时器,避免冲突 stopAnimation()
// 创建新的动画定时器 animationTimer=Timer.scheduledTimer(withTimeInterval: typingSpeed, repeats:true) { timerin DispatchQueue.main.async { self.appendNextCharacters(from: text) } }}
定时器机制特点:
privatefuncappendNextCharacters(fromtext:String) { letcurrentLength=displayedText.count
// 边界检查:防止越界访问 guardcurrentLength<text.countelse{ stopAnimation()// 动画完成,清理资源 return }
// 计算下一次显示的字符范围 letendIndex=min(currentLength+chunkSize, text.count) letstartIndex=text.index(text.startIndex, offsetBy: currentLength) lettargetIndex=text.index(text.startIndex, offsetBy: endIndex)
// 提取新字符并追加到显示文本 letnewChars=text[startIndex..<targetIndex] displayedText.append(String(newChars))
// 检查动画是否完成 ifdisplayedText.count>=text.count { stopAnimation() }}
字符处理细节:
varbody:someView{Group{iflettext=text,!text.isEmpty{ifisAssistantMessage&&isStreamingMessage&&shouldUseTypewriter{typewriterView(text)//动画视图}else{staticView(text)//静态视图}}}//生命周期绑定.onAppear{/*启动动画*/}.onDisappear{/*清理资源*/}.onChange(of:text){/*处理文本变化*/}.onChange(of:isStreamingMessage){/*处理流式状态变化*/}}@ViewBuilderprivatefunctypewriterView(_text:String)->someView{ifmessageUseMarkdown{Markdown(displayedText).markdownBlockStyle(\.blockquote){configurationinconfiguration.label.padding().markdownTextStyle{FontSize(13)FontWeight(.light)BackgroundColor(nil)}.overlay(alignment:.leading){Rectangle().fill(Color.gray).frame(width:4)}.background(Color.gray.opacity(0.2))}}else{Text(displayedText)}}
.onAppear{iflettext=text,isAssistantMessage&&isStreamingMessage&&shouldUseTypewriter{startTypewriterAnimation(for:text)}elseiflettext=text{displayedText=text}}.onDisappear{stopAnimation()//防止内存泄漏}.onChange(of:isStreamingMessage){oldIsStreaming,newIsStreaminginif!newIsStreaming{//流式传输结束,立即显示完整内容iflettext=text{displayedText=text}stopAnimation()}}privatefuncstopAnimation(){animationTimer?.invalidate()//停止定时器animationTimer=nil//释放引用}
综上,结合三层的优化,通过以上多层协同优化方案,我们成功地将一个卡顿、生硬的文字输出体验转变为流畅、自然的现代化AI交互界面。 |