本地模型输出正常但界面卡顿?流式消费与前端渲染优化指南

发布时间:2026/10/31 8:53:22
本地模型输出正常但界面卡顿?流式消费与前端渲染优化指南

你是不是也经常遇到这种情况:本地跑着一个 7B 或 13B 的模型,Prompt 发过去之后,模型明明已经开始老老实实吐字了,终端日志里 token 一个接一个往外蹦,但页面聊天界面还是在转圈,要么一动不动,要么憋个好几秒再一次性全刷出来。这个现象在本地推理场景里特别常见,很多人第一反应是“模型太慢了”,其实大部分时候模型一点都不慢,真正卡死的是你的界面。这不是玄学,而是典型的异步流消费问题。

这篇文章就专门讲清楚这件事:本地推理的流式输出走了哪些链路,模型侧“吐字”和界面侧“卡顿”为什么能同时存在,以及你该怎么定位、怎么修。适合自己写前端、自己搭 Chat UI,或者接 Ollama、llama.cpp 这类本地模型的 API 但被界面卡顿折磨过的开发者。放心,模型不用换,代码也不用大改,搞清楚消费侧的逻辑就够了。

1. 为什么“开始吐字”和“界面卡死”能同时发生

1.1 生产者和消费者的节奏完全不一样——把输出想成水管

想理解这个问题,先记住一个结论:流式输出里,模型是生产者,界面是消费者,中间那条 HTTP/SSE 连接是水管,浏览器的主线程是你的水杯。水厂产水再快,杯子不换、不倒水,你看到的水位也永远不会上涨。

具体到本地推理,模型生成 token 的速度一般是多少?我用 llama.cpp 在 M 系列芯片或者中端 N 卡上跑 7B 量化模型,常见速度在 20~60 token/s 之间。一个 token 大概对应半个到两个汉字,也就是说模型一秒能“说”出几十个字,这个速度人眼看起来是“正在流畅生成”。但模型产出 token 之后,并不是直接画到你屏幕上,而是先写进服务端的输出流,再通过 SSE 分段推给浏览器。

浏览器这边拿到 chunk 之后,要做的事情远远不止“把字显示出来”:要解析流里的 JSON、要更新 React/Vue 的状态、要重新跑一遍 Markdown 渲染、要做代码高亮、要检查滚动位置,甚至还要处理一下 CSS 样式和布局。这些操作全都跑在同一个 JS 主线程上,任何一个环节慢了,后面的 token 就只能在队列里排队。

这就是最核心的错位:生产者按毫秒级节奏持续输出,消费者却每处理一个 chunk 要花几十甚至几百毫秒。模型已经在吐字,不代表界面有能力同步把字吐出来。

1.2 别忽略事件循环:JS 主线程是单车道

很多人会把“异步流”理解成一个很神奇的东西,觉得既然是流式传输,前端自然应该一边收一边显示。但浏览器不是这么工作的。JavaScript 主线程是单线程,所有网络回调、定时器、DOM 渲染、用户事件处理,全都在一条车道上排队执行。

你通过 fetch 的 ReadableStream 读取数据时,流的“读取事件”确实会随着网络数据到达而触发,但真正执行的回调函数还是要排到主线程的任务队列里。如果这个时候主线程正在做一件很重的事——比如渲染一段 5 万字符的 Markdown,或者对所有代码块跑一遍语法高亮——那么后面所有堆积下来的 token chunk 都只能等着。

所以你会看到一种非常典型的现象:终端里模型输出很平稳,页面却卡了两秒,然后“哗啦”一下把前面攒的文字全部刷出来。这说明界面不是没收到数据,而是被阻塞了,缓冲区里攒了一堆 chunk,等主线程一空出来,就一次性全部处理掉。卡顿的本质不是网络没传完,而是消费端处理不过来。

这里顺带说一个容易误导人的细节:你在终端里用 curl 看流式接口,通常会感觉“输出挺流畅”,因为 curl 的终端渲染极快,只是往 TTY 上打字符而已。但浏览器里的富文本渲染、高亮、布局,比终端那种纯文本输出重得多,同样的流,在不同消费端表现完全不一样。这也是很多人误判“本地推理很流畅,但界面很卡”的原因之一。

2. 模型侧:先搞清楚本地推理的“吐字”是怎么发生的

2.1 Prefill 阶段和 Decode 阶段,哪个决定你看到的速度

要做界面优化,最好对模型侧的状态机有个基本认知。Transformer 文本生成通常分为两个阶段:

  • Prefill(填充阶段):把 prompt 一次性喂给模型,计算整个输入序列的中间状态。这个阶段耗时比较长,输出端看不到任何文字。你可以理解为模型先“读题”。
  • Decode(解码阶段):基于已有的上下文,一个 token 一个 token 地预测下一个词。每生成一个 token,就把它追加到 KV Cache 里,然后继续预测下一个。

你看到“模型已经吐字”,说明 Prefill 已经完成,模型进入了 Decode 阶段。Decode 每个 step 生成一个 token,速度主要取决于硬件算力、内存带宽、上下文长度和量化方式。界面卡的时候,你第一个反应应该是去确认 Decode 的 token/s 是多少,而不是凭感觉说“模型卡”。

本地推理里几个常见参数也会影响这两个阶段,值得记住:

  • 线程数(-t/--threads):CPU 推理时,线程数增加能提升 prefill 和 decode 的并行度,但线程开得太多反而会因调度开销而掉速。
  • Batch Size(-b/--batch-size):主要影响 Prefill 阶段一次性处理多少 token。batch 开大,处理长 prompt 会更快;但过大的 batch 在单次请求里会提高首 token 延迟前的峰值计算量。
  • 上下文长度(ctx):决定 KV Cache 能占多大内存。上下文越长,decode 每个 step 需要参与计算的历史信息越多,越容易拖慢生成速度。

尤其注意:很多人在 UI 卡的时候跑去把 batch size 调小,结果首 token 变慢、整体生成速度可能还会下降。原因在于,decode 阶段每个 step 理论上只生成一个 token,batch size 对 decode 的直接影响有限,它主要在 prefill 阶段发挥作用。调这个参数之前,得先弄清你缺的是首 token 延迟还是吞吐。

2.2 模型吞吐量和界面渲染速度不是一个量纲

我们再拿数据说话。假设你的本地模型稳定跑出 40 token/s。这个速度体现在时间线上就是:每 25ms 生成一个新 token,持续不断地往外发。你自己的前端代码,处理一个 chunk 要多久?如果只是往 DOM 里加一段纯文本,通常很快;但如果你每次都把完整文本拿去跑 Markdown 解析,1 万字符以内的 Markdown 解析加高亮可能要几百毫秒,5 万字符就要接近一秒。

模型的吞吐是线性的:字数越多,生成这么多字需要的时间越长。前端的渲染开销通常是超线性的:字数越多,单次渲染全局文本的成本越高。两个曲线一叠加,就会出现“文本越长、界面越卡”的现象,短对话完全正常,长对话卡到触屏。

另一个被忽略的因素是主线程的持续占用。浏览器一个性能稍弱的设备上,如果你通过innerHTML插入几 MB 的 HTML 片段,或者反复触发整页样式重算,浏览器会表现为“页面卡住”“按钮没响应”,但不是真的崩溃,而是所有事件都被排到长任务后面去了。模型的流式输出其实一直在走,只是无论它怎么吐,你都看不到。

3. 界面侧:真正卡住你的不是模型,是渲染策略

3.1 最常见的“一次性渲染”误区

前端接入流式接口,最常见的错误是把流用完了再渲染。代码长这样:

let fullText = ''; for await (const chunk of stream) { fullText += chunk; } setText(fullText); // 所有 token 都拿完才更新界面

这种写法等于把异步流退化成了同步等待:模型吐字的过程全部发生在循环里,UI 上没有任何反馈,直到整个响应结束才一次性 setState。如果模型生成 50 秒,界面就白屏 50 秒。很多时候用户以为界面卡了,其实代码逻辑压根没把流“流”起来。

还有个更隐蔽的版本:确实每个 chunk 都触发更新,但每次更新都基于完整字符串重新渲染。从用户角度看,界面是动的,但越动越慢,因为渲染成本随字符串长度增长。比如第一秒 token 数少,渲染只需 5ms;第二秒文本变长,渲染要 50ms;到接近结束时每 token 需要 100ms。模型每 25ms 产一个 token,渲染一个 pulp 需要 100ms,那就必然出现明显的“模型在吐、界面跟不上”现象。

正确的流式消费思路是:增量接收、增量渲染。你只需要把新增的文字追加到界面上,而不是把整段内容重画一遍。

3.2 Markdown 渲染、代码高亮和 DOM 膨胀的三重夹击

为什么单纯的追加文字还不够?因为很多 Chat UI 不是直接渲染纯文本,而是先把模型输出的 Markdown 渲染成 HTML,再做代码高亮、处理表格和列表。问题就出在这里。

第一波消耗是 Markdown 解析。像marked、markdown-it这类库,你给它一个大字符串,它要从头到尾跑一遍语法解析,生成 token 树,再生成 HTML。解析本身是 CPU 密集型操作,和字符串长度基本成线性关系。但这只是开始。

第二波消耗是代码高亮。高亮库(比如 highlight.js、prism)往往需要对字符串做正则或者分 token 处理,再为每个 token 生成带 class 的 span。代码块越大,生成的 span 数量越多,耗时可轻松达到几百毫秒。而且很多实现里,高亮是对整个回答全文跑的,一旦回答里出现一大段代码,高亮成本会直接拉爆主线程。

第三波消耗是 DOM 膨胀。每次渲染,你都会一次性插入大量 HTML 节点。比如一个长长的代码块,可能生成几百上千个 span 节点。浏览器要维护这些 DOM 节点,React/Vue 做 diff 时也要遍历虚拟节点树。节点越多,每次状态更新的成本越高。到后面就是恶性循环:文本越长 → 节点越多 → diff 越慢 → 界面越卡 → 用户越崩溃。

所以,如果你发现自己接入本地模型后,生成过程特别卡,优先把这三个环节查一遍:Markdown 是否全量重复解析、高亮是否每 token 重跑、DOM 节点数量是否在爆发式增长。

3.3 自动滚动和动画效果雪上加霜

还有一个容易被忽略的元凶:自动滚动。聊天界面的常规需求是“新内容出来就自动滚到底部”,很多人的实现是这样的:

container.scrollTop = container.scrollHeight;

每次收到新 chunk 就执行这一行。如果页面本身很长,这行代码会强制浏览器进行同步布局计算——因为它要读取scrollHeight,并且设置scrollTop,这两件事都会让浏览器立刻做一次布局计算。高频调用时,主线程会被迫反复计算布局,直接造成卡顿。

更糟糕的是有人为了体验顺滑,加上了behavior: 'smooth'平滑滚动。平滑滚动实际上是一个持续的动画过程,如果新的 token 一直到来,滚动动画可能永远没机会结束,浏览器一边重新排版一边跑动画,卡顿感加倍。

我自己的建议是:不是任何时候都要自动滚动到底部。只有用户本身在底部附近时才跟随,用户上翻查看历史内容时停止滚动;设置scrollTop时用auto而不是smooth,并且在下一帧统一处理滚动位置,不要每个 chunk 都立刻滚一次。

4. 优化落地方案:从消费端到服务端的完整思路

4.1 流式消费的正确姿势:累积 Stream + 帧渲染

聊完了问题,直接给一套能落地的方案。前端消费流的基本策略可以总结成一句话:高频接收、低频提交、增量渲染。

读流这件事本身谈不上性能问题,真正影响性能的是你如何把流里的内容送进 UI。一个稳妥的模式是:先接收到的文本放进一个缓冲区,然后用requestAnimationFrame或者setTimeout做节流,每一帧只更新一次界面,并且只追加新增的部分。

下面这段是我常用的最小实现思路:

let output = ''; let pending = ''; let scheduled = false; async function readStream(stream: ReadableStream<Uint8Array>) { const reader = stream.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; pending += decoder.decode(value, { stream: true }); if (!scheduled) { scheduled = true; requestAnimationFrame(flush); } } } function flush() { scheduled = false; if (!pending) return; output += pending; pending = ''; // 这里只把新增的 output 追加到容器,不要重新渲染整个 output appendToContainer(output); }

用requestAnimationFrame的原因很简单:浏览器大概 16.7ms 刷新一帧,你在这一帧里统一提交一次 DOM 更新,比每个 chunk 连续更新好几次更高效,也能避免掉帧和撕裂感。

4.2 把 Markdown 和高亮拆出去交给 Web Worker

如果你确实需要富文本显示,最有效的优化是把 Markdown 解析和高亮从主线程挪到 Web Worker 里。Worker 可以正常跑marked、highlight.js这类纯 JS 库,解析完成之后再通过postMessage把 HTML 字符串传回主线程。

这里有个注意点:postMessage传递大字符串本身也有序列化成本。所以不要把整个输出全部传回来,而是按增量传。每次只把新增的 Markdown 片段丢给 Worker,Worker 解析并返回对应 HTML,主线程把它追加到容器里。这样主线程只负责插一个已经生成好的节点,不再做同步解析。

比如主线程收到一段新增文本,直接发给 Worker:

worker.postMessage({ type: 'appendChunk', text: newText }); worker.onmessage = (event) => { container.insertAdjacentHTML('beforeend', event.data.html); };

在 Worker 内部,你负责维护完整的 Markdown 源文本,并解析新增部分。当然,Markdown 的块级解析会有跨 chunk 的状态,比如一个代码块可能拆成好几片才到达,所以一个更稳妥的做法是:在 Worker 里缓存全量 Markdown 源,每次只渲染“自上次末尾之后新增的完整块”,解析完拼回已有 HTML 中。

如果项目里不方便引入 Worker,退而求其次的做法是:对纯文本直接追加显示,Markdown 高亮延迟到“用户停止输入后 500ms”或“流结束后”再做。这样可以保住大部分流畅度,只是代码高亮会晚一点出现。

4.3 模型服务端侧的几个配合技巧

前端做得再顺,服务端也可能给你添乱。可以先确认几个常见坑:

  • 不要关掉流式开关。有些 OpenAI 兼容接口默认stream=false,你前端再优化也迎不来增量数据。
  • 确认 SSE chunk 是否被打散发送。有些服务端为了友好,会把多个 token 攒成一个 chunk 再发出去,结果界面看起来是一跳一跳的。如果你希望更平滑,可以在前面加一层代理,把服务端输出的 SSE 再转发成更细的 chunk,或者保持默认就好。
  • 本地推理里,--n-predict不要设置成无限。生成无限长的内容会导致前端缓冲区无限增长,界面迟早卡死。建议给一个合理上限,比如 1024、2048,超出即停止,既保护资源也保护界面。
  • 并行请求数要控制。如果你给同一个 llama.cpp server 同时开了几个生成请求,模型设备会被多个请求抢占,每个请求的 token/s 都会下降,界面就会同时表现出“首 token 慢、生成慢、渲染更卡”。尤其是 Open WebUI 这类工具,后台可能有多个会话抢模型,排查时先把并发压到 1。

另外我想多说一句:很多人会尝试调小--batch-size来降低界面卡顿,这其实是头痛医脚。这个参数主要在 prefill 阶段影响计算峰值,对 decode 阶段的流式输出速度影响有限。真正想让界面更早出现内容,更应该关注的是降低 Prefill 的耗时,比如缩短上下文长度、用更快的推理引擎、或者把模型量化级别调低。别在参数和现象之间乱配对。

4.4 一个可落地的 Chat UI 最小改法

如果你不想一上来就引入 Worker,只想先把卡顿压下去,我建议按这个顺序改:

  1. 改成增量纯文本显示:游戏聊天内容先用textContent追加,绕过 Markdown 渲染。
  2. 高频缓冲,一帧一画:用requestAnimationFrame节流,不要 tokens 一到就 update。
  3. 只在输出结束后跑 Markdown 和高亮:流结束后,把完整文本交给 Worker 做一次富文本转换,再替换掉纯文本区域。
  4. 滚动位置做节流:上翻时锁定,用户停在底部才自动跟随。
  5. 限制单条回复长度:在请求参数里设置max_tokens,防止超长回复拖垮 DOM。

这套组合改完,大多数本地推理场景的卡顿至少缓解 80%。模型本身还是那个模型,token/s 也没变,但界面体验完全不一样。

5. 排查实录与常见问题速查表

5.1 用 Performance 面板定位到底谁在卡

“模型吐字正常,但界面卡”这类问题,不要靠猜,直接打开浏览器 DevTools 的 Performance 面板看现场。

操作步骤:

  1. 打开聊天页面,确保你的本地模型已经启动。
  2. F12 打开 DevTools,切到 Performance 面板,点 Record。
  3. 向界面发送一个长一点的 Prompt,等界面出现明显卡顿,等输出结束后停止录制。
  4. 看主线程时间线里的 Long Task(长任务),也就是超过 50ms 的同步任务。
  5. 检查长任务集中在哪个阶段:如果集中在模型生成期间,并且长任务里能看到 Markdown 解析、highlight 这类函数名,那就是渲染侧问题;如果长任务很少,反而是网络请求跨度很长、chunk 之间空隙很大,那就要回头查模型服务和接口。

还有一个非常高效的判断标准:切到 Network 面板,观察/v1/chat/completions这条 SSE 请求。如果网络层是持续有数据流入的(看响应时间线是一条持续增长的线),而界面不动,那 100% 是前端消费问题。如果网络请求本身长时间没有新数据,那才是模型或者服务端吞吐的问题。

5.2 我自己踩过的两个坑

第一个坑是把渲染逻辑直接写在 fetch 的循环里,每个 chunk 都调用 Markdown 解析。最初接 Ollama 的时候,我图省事,直接在for await里把每次的新字符串拼起来,然后setText(fullText)。短对话看不出问题,次一长,页面直接假死,鼠标都点不动。后来看 Performance 才发现每个长任务都是 highlight 相关函数。把高亮丢到 Worker 并按帧节流之后,同样的模型从卡两秒变成肉眼无感。

第二个坑是自动滚动用得太激进。当时我想让输出滚动更顺滑,给scrollTo加了behavior: 'smooth',结果输出快的时候,滚动动画一直被打断重启,整个页面滚动区域乱跳,CPU 占用反而上去了。改成“只在用户接近底部时用auto直接跳到底部”之后,反而稳定得多。

5.3 常见问题速查表

现象最可能的原因解决思路
模型生成完了界面才一次性显示前端把流攒完后才 setState改为增量接收,追加到界面
模型在吐字,界面转圈但完全不动主线程被长任务阻塞检查 Markdown 全量渲染、高亮,挪到 Worker
文本越长,界面越卡DOM 节点膨胀,diff 成本高限制回复长度,按块渲染,避免全量替换
输出过程中滚动条跳动自动滚动没节流,或用了 smooth只跟随用户到底部,用 auto 滚动
短提示词流畅,长 prompt 首 token 很久Prefill 阶段太长缩短上下文长度、调高 batch、升级推理引擎
网络有持续数据,界面卡消费端处理速度跟不上生产端加缓冲池、requestAnimationFrame 节流
多个用户并发请求后界面全卡模型侧被并发抢占把并发降到 1,或换支持 continuous batching 的推理服务

这张表基本覆盖了本地推理 + Chat UI 里最常见的卡顿组合。可以按表逐项排除,大多数问题不是“模型不行”,而是“消费端姿势不对”。

当初我花了一晚上排查这个问题的最后一根稻草,其实是把 Markdown 渲染从主线程卸掉。自那以后我再也没有被“模型开始吐字但界面在转圈”困扰过,因为模型侧和界面侧之间的那条流,本质上是在告诉你:生产端没问题,你的水杯得勤快一点倒水。这个思路放在任何流式接口里都通用——不管是聊天机器人、语音识别,还是自动生成文档的界面,流的消费方式是否平滑,几乎决定了你体感上到底“卡不卡”。