
先说个真实场景。上个月有个朋友在群里发截图本地跑的7B模型已经一个字一个字往外蹦了但旁边那个聊天界面还在转圈鼠标拖一下都卡顿他问是不是这个模型太弱要不要直接换13B。我一看截图就知道换模型解决不了问题——模型在吐字说明推理本身没毛病卡的是数据从显存走到屏幕的这条链路。这条链路上但凡有一个同步点界面就会变成PPT。本地推理不是单机跑个脚本它本质上是生成速度不定、延迟比网络接口更不可控的流式数据源而异步流才是让界面跟手的唯一解。这篇文章不绕弯子直接把模型已经开始吐字但界面还卡这件事拆开讲先看症状再对账算时间然后给出后端、前端、渲染端三层改造方案最后会把我实测过的几个坑和低显存、长上下文场景下的调优经验一并摆出来。适合自己部署过Ollama、llama.cpp或者拿本地模型做Web服务、桌面工具的人参考想从能跑提升到好用的那种朋友也值得看一看。1. 先分清你的界面是哪种卡从症状定位阻塞点1.1 卡在等第一个字和卡在已经在吐字是两回事很多人一看到界面卡就笼统地归类为模型太慢这其实是把两个完全不同的阶段混在一起了。第一个阶段是从你点发送到模型吐出第一个字业界叫TTFTTime To First Token。这个阶段长通常是模型在做prefill也就是把整段输入Prompt一次性过一遍生成K/V cache。输入越长、显存越紧张prefill就越慢3B小模型也可能等上好几秒。你的界面如果在这一阶段转圈那基本正常的优化空间主要在上游缩短Prompt、换更小模型、用支持continuous batching的推理后端。第二个阶段才是本文核心模型已经开始吐出token了界面却依然不流畅。这说明数据已经在流动但流动的路径上有阻塞点。这个阶段的卡顿跟模型本身的能力关系很小反而跟你怎么接数据、怎么渲染有直接关系。1.2 已经吐字却卡顿的三种典型症状与对应根因同样是卡实际体感差异很大对应的根因也完全不同。我按频率整理了三类症状你对照一下就知道该查哪里。症状典型表现最大嫌疑元凶优先排查方向症状A整体假死窗口拖不动按钮点不了连光标都变成沙滩球主线程/事件循环被同步阻塞async函数里是否存在同步推理调用、同步parse、同步IO症状B文本在滚但页面飘字一直在出滚动拖拽发飘输入框敲字延迟渲染管线在每次token上做全量重绘是否每个token都触发markdown全量解析、innerHTML全量替换症状C一顿一顿出字速度时快时慢隔一会明显停顿一下推理吞吐本身有抖动KV cache换入换出、CPU/GPU混合推理、上下文过长后prefill膨胀我见过最常见的坑集中在症状B。很多本地推理前端为了支持markdown在收到每一个token后把全部历史消息重新走一遍markdown解析再整体setInnerHTML或者setState。五十个token的时候没事五百个token的时候主线程开始吃不消两千个token的时候每次渲染耗时甚至超过token生成间隔界面自然越来越卡。这里要记住一个原则模型吐字的节奏在驱动你但你绝不能让渲染节奏跟着token逐个蹦。token是生产者UI是消费者两者之间必须有一个缓冲层让慢的消费端不被快的生产端牵着跑。2. 本地推理的token时间账生成、传输、渲染谁才是真瓶颈2.1 一个token从显存走到屏幕的完整路径要理解卡顿得先把一条路径上的每一段都算清楚。一个token从生成到最终显示大致要经过这么几站推理decode模型根据当前K/V cache和输入预测下一个token。这一步是最慢的通常单个token要几十毫秒甚至更久。序列化推理引擎把token包成JSON。本地HTTP服务一般是几微秒到几十微秒可以忽略。网络传输本地都是loopback回环地址一个数据包往返不到1毫秒。前端解析SSE把流式chunk重新拼装成完整事件再JSON.parse。这步在长文本下可能变成隐藏杀手后面细说。渲染到屏幕可能是textContent追加也可能是markdown全量解析加innerHTML替换。这一步的耗时从零点几毫秒到几十毫秒都有可能。走到这里你能看出一个问题真正慢的是第1步但最容易失控的是第4和第5步。因为第1步再慢也就几十毫秒但如果第5步的耗时是随历史长度线性增长甚至O(n²)增长的那它迟早会超过第1步成为新的瓶颈。2.2 同步请求为什么会让界面沦为loading如果你的接口是同步的服务端会把全部token生成完等到完整结果产生后才返回HTTP响应。此时前面讲的五站路径全部压缩到最后一刻才发生前端唯一能做的就是转圈。有个细节值得注意同步接口并不是完全没有数据流动而是数据在服务端内部流动不经过HTTP响应体。客户端完全不知道模型已经生成了一半。所以很多本地推理新手会疑惑明明日志里模型已经在吐字了为什么网页还在加载答案就在这里——日志输出到服务端stdout不代表数据被响应给你。想要界面提前开始吐字流式接口是前提不是可选项。Ollama的/api/generate必须带stream: truellama.cpp server的/completion要带stream: trueOpenAI兼容接口则天然按SSE事件流输出。不流式后面所有优化都是空中楼阁。2.3 Streaming接口改变了什么SSE与chunked的机制流式接口改变了问题的性质从一次性交付全部结果变成持续交付一批增量。但增量到达前端之后处理方式稍有不当前面白干。目前本地推理主流的流式协议是SSEServer-Sent Events说人话就是HTTP响应头声明text/event-stream正文是一行行data:开头的JSON事件之间用空行分隔。下面是Ollama流式响应的一个典型片段data: {model:qwen2.5:7b,response:深度,done:false} data: {model:qwen2.5:7b,response:学习,done:false} data: {model:qwen2.5:7b,response:,done:true,stats:{...}}OpenAI兼容接口也类似只是结构变成choices[0].delta.contentdata: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]这里有个特别容易踩的坑SSE是面向行的协议但TCP/HTTP底层是按chunk传输的一个SSE事件可能被拆成两个chunk两个事件也可能合并进一个chunk。前端如果每次拿到chunk就按完整事件解析一定会出现解析失败。正确姿势是维护一个buffer先拼字符串再按\n\n切事件最后一段不完整的残片留到下一次chunk来了再处理。这个下一章会给出完整代码。3. 把管道改成异步流后端、前端、渲染端逐层落地3.1 后端不要把同步推理生成器直接扔进async函数这一节是给写后端的人看的。先说反例再说正解。很多用FastAPI接本地推理库的人会这么写from fastapi.responses import StreamingResponse from llama_cpp import Llama llm Llama(model_path./qwen2.5-7b-q4.gguf) app.post(/chat) async def chat(prompt: str): def generator(): for tok in llm(prompt, streamTrue): yield fdata: {tok[choices][0][text]}\n\n return StreamingResponse(generator(), media_typetext/event-stream)这个写法能跑但有个致命问题函数虽然叫async事件循环在generator执行期间完全被阻塞。FastAPI的StreamingResponse如果拿到的是一个普通的同步生成器会在线程池里跑单路请求影响不大但多路请求并发时线程池很快被占满CPU密集的推理调用还会抢GIL整个服务响应速度会越来越差。真正正确且稳妥的做法是把推理放到独立线程里持续产出token再通过asyncio.Queue把token搬回事件循环让协程侧可以await。下面是一个经过我实测的可运行版本import asyncio import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from llama_cpp import Llama app FastAPI() llm Llama(model_path./qwen2.5-7b-q4.gguf) app.post(/chat) async def chat(prompt: str): queue: asyncio.Queue asyncio.Queue() def inference_worker(): loop asyncio.get_event_loop() # 这里调用的是同步阻塞的Llama对象会一直循环生成token for tok in llm(prompt, streamTrue): text tok[choices][0][text] loop.call_soon_threadsafe(queue.put_nowait, text) loop.call_soon_threadsafe(queue.put_nowait, None) asyncio.get_running_loop().run_in_executor(None, inference_worker) async def generate(): while True: token await queue.get() if token is None: break yield fdata: {json.dumps({delta: token}, ensure_asciiFalse)}\n\n return StreamingResponse(generate(), media_typetext/event-stream)这里为什么要用loop.call_soon_threadsafe包裹queue.put_nowait因为asyncio.Queue的线程安全边界比很多人以为的更严格在线程里直接操作它属于灰色地带。用call_soon_threadsafe把操作调度回事件循环线程就完全避免了跨线程风险。如果你的推理不是进程内绑定而是调用Ollama或llama.cpp server这类独立HTTP服务那更简单直接用异步HTTP客户端转发就行import httpx import json from fastapi.responses import StreamingResponse OLLAMA_URL http://127.0.0.1:11434/api/generate app.post(/chat) async def chat(prompt: str): async def generate(): payload {model: qwen2.5:7b, prompt: prompt, stream: True} async with httpx.AsyncClient() as client: async with client.stream(POST, OLLAMA_URL, jsonpayload) as resp: async for line in resp.aiter_lines(): if not line.startswith(data:): continue data json.loads(line[5:]) if data.get(done): break text data.get(response, ) if text: yield fdata: {json.dumps({delta: text}, ensure_asciiFalse)}\n\n return StreamingResponse(generate(), media_typetext/event-stream)这种方式的优势在于Ollama和llama.cpp server本身是C实现有自己的线程模型和调度器Python这边只做转发事件循环几乎不会被阻塞多路并发时表现好得多。能用独立服务跑推理就别执着于进程内绑定。3.2 前端逐块吃SSE而不是攒到结束后端出流式响应只成功了一半前端如果还是await fetch()然后一次性拿response.json()那跟同步接口没区别。流式前端要直接读取response.body这个ReadableStream。下面是我在浏览器端用原生fetch实现的SSE逐token消费完整处理了chunk半包和事件合并的问题async function streamChat(prompt, onToken) { const resp await fetch(/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }) }); if (!resp.ok || !resp.body) { throw new Error(HTTP ${resp.status}); } const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按空行切分SSE事件最后一段可能是残片要留到下一轮 const parts buffer.split(\n\n); buffer parts.pop() ?? ; for (const rawEvent of parts) { const lines rawEvent.split(\n); for (const line of lines) { if (!line.startsWith(data:)) continue; const data line.slice(5).trim(); if (data [DONE]) continue; const parsed JSON.parse(data); onToken(parsed.delta ?? ); } } } }这里有两个习惯很多人做不到位。第一切分事件时不要用\n行切一定要用空行\n\n切这是SSE的协议边界第二decoder.decode(value, { stream: true })必须开否则一个中文字符被拆成两个chunk时会出现乱码。这两个细节我都在生产环境亲眼见过翻车。3.3 渲染端token先排队每个动画帧只画一次前端拿到了token如果立刻操作DOM性能依然会崩。因为浏览器渲染是依据显示器帧率来的通常是60帧每秒每帧间隔约16.7ms。你就算每秒收到40个token也要挤在16.7ms的帧周期里完成所有DOM操作。正确的思路是token只管往暂存区里存渲染逻辑统一合并到requestAnimationFrame里每个动画帧最多执行一次。let pendingText ; let lastText ; let rafPending false; function onToken(delta) { pendingText delta; if (rafPending) return; rafPending true; requestAnimationFrame(() { // 把这一帧内累积的所有增量一次性上屏 lastText pendingText; pendingText ; outputContainer.textContent lastText; rafPending false; }); } streamChat(介绍一下异步流, onToken);这段代码的核心价值在于它把渲染频率硬性限制为帧率而不是token率。哪怕模型1秒吐50个tokenDOM更新次数还是最多60次而且是在浏览器最适合处理的时机。如果你的对话会很长连textContent lastText这种整体替换都不划算。更优的做法是增量追加节点let lastNode null; function appendDelta(text) { if (!lastNode) { lastNode document.createElement(span); outputContainer.appendChild(lastNode); } lastNode.textContent text; }但注意你如果还需要markdown渲染就不能直接在DOM节点上拼字符串了。我的经验做法是把增量文本按段落粒度累积一个段落完整结束遇到换行或者停顿超过200ms之后才做一次markdown解析已经解析过的段落就固定下来不再重复处理。这样既保留了markdown效果又避免了全量历史反复解析的灾难。4. 我踩过的坑从表里不一的流畅到稳定60帧4.1 每次token全量JSON.stringify历史记录2k token后的账单前面反复提全量渲染的坑这里给一组我实测的数据来自一台8核心CPU、32GB内存、RTX 3060的机器本地跑7B Q4模型decode速度大约28 token/s。前端做了三件事维护一个messages数组每次收到token就push然后JSON.stringify(messages)塞进一个隐藏div做调试再用markdown库全量解析整个对话。听起来都是常规操作。在500 token时单次JSON.stringify大约0.8msmarkdown全量解析大约2.5ms看起来完全不算事。到了1500 tokenJSON.stringify涨到3msmarkdown解析涨到9ms。到了2500 tokenmarkdown解析一次已经要22ms而一个token的生成间隔才35ms。也就是说每次token到达后主线程光是处理渲染就要22ms直接占掉了帧预算里的一大半滚动和输入的响应自然就废了。历史长度单次JSON.stringify单次markdown全量解析是否影响交互500 token0.8ms2.5ms几乎不可见1500 token3.1ms9ms开始飘2500 token6ms22ms明显卡顿解决办法也很直接把调试用的JSON.stringify从生产环境里删掉markdown解析改成前面说的段落粒度的增量解析。记住一条红线任何随历史长度增长的操作都不能放在token热路径上。4.2 GIL、线程池和asyncio.Queue进程内本地推理的正确姿势进程内绑定的场景有个绕不开的话题GIL。不少人对它的理解是Python多线程等于废物这其实是误解。Python的解释器在C扩展执行CPU密集任务时是会定期释放GIL的。llama-cpp-python底层的推理是C实现的decode期间GIL的持有时间远比你想象的要少。所以用ThreadPoolExecutor把同步的Llama对象丢到后台线程事件循环主线程还是有机会继续跑的——前提是你像我3.1节那样用队列把token搬运回来。如果不用队列而是图省事直接写for token in await asyncio.to_thread(llm, prompt, {stream: True}): yield token这也是错的。asyncio.to_thread会等同步函数完全执行完再返回而llm()要跑到生成结束才返回中间那些token根本没机会流出来。你需要的是一个边跑边送的通道线程安全队列或者管道都可以。我实际用的方案是3.1节那段代码跑了几个星期没出过问题。有一个补充建议queue最好支持丢弃策略。如果前端已经把本次请求取消掉了推理线程其实还在跑,不打断它的话它会一直把token塞进来。可以在连接断开时设置一个generator_stopped标志worker每生成一个token前检查一下主动中断生成。4.3 背压和队列堆积当生成速度超过消费速度内存爆掉异步流解决了阻塞事件循环的问题但引入了另一个不为人知的风险如果生产速度长期大于消费速度队列会无限增长内存先爆。典型场景是prefill走完后的瞬间decode忽然加速前端网络抖动一下消费变慢或者用户切走了页面浏览器开始节流后台标签页的定时器和渲染reader.read()迟迟不返回。这个时候队列里积压几百个token还好积压几万个就危险了。背压处理的核心是限制队列长度。asyncio.Queue(maxsize64)就是一种简单控制。当queue满了worker侧的put_nowait会抛异常需要catch并等待消费者腾出空间。为了不阻塞推理线程太久我通常会再配合丢弃策略对话场景里用户只关心最新文本中间被丢弃的token在下一轮补上就行。async def generate(): current_text while True: token await queue.get() if token is None: break current_text token # 每次最多保留最近800个token的增量防止消费者积压太久 yield fdata: {json.dumps({delta: token, snapshot: current_text[-600:]})}\n\n当然不是所有场景都能丢中间帧比如代码补全就必须严格保序、保完整。但对话场景做这个取舍很值得。5. 低显存和长上下文下的异步流体验优化5.1 上下文变长后prefill变慢界面该给什么反馈本地推理最让界面尴尬的另一个场景是长上下文。随着对话轮数增加每次新发送的一句话都要和全部历史一起做prefill这个等待时间会越来越长。很多用户以为是卡死了实际上模型引擎在全力处理输入。异步流在这里的价值是你可以把正在处理前的历史上下文作为流式状态反馈给用户而不是让用户面对一个死寂的loading。比如在等待第一个token期间界面每200ms发送一个正在理解前面的上下文事件或者直接显示一个心跳动画。这个在同步架构里很难做因为同步请求一挂起前端什么都拿不到。另外要注意低显存机器上的KV cache问题。当你设置很大的上下文窗口时显存不够就会发生K/V cache置换decode速度会出现周期性断崖。这时候的卡其实是推理引擎自己的哽咽。异步流下你至少能做到把token计数器匀速跳动让用户知道系统活着同时考虑把模型上下文窗口调小或换用支持滑动窗口的模型结构减少显存压力。5.2 多路并发单卡跑两个会话时谁在抢占decode如何调度本地推理真正进入实用阶段一定会遇到多路并发两个对话窗口同时开一个在回答长问题另一个才刚发送。Ollama和llama.cpp server这类服务遇到并发请求时通常会排队实际上是一个decode线程被多个请求轮流占用。结果就是一个会话吐字飞快另一个会话TTFT长得像个死局。从异步流的角度我建议在后端做一个显式的请求调度队列给每个会话发一个序号和预计等待秒数前端拿到这个信息后可以展示前面还有1个任务约等待3秒。别小看这个反馈它能极大缓解用户对界面卡死的焦虑。具体实现可以在FastAPI里维护一个asyncio队列把生成请求按到达顺序入队再逐个转发给推理服务。注意多路并发时如果你在进程内跑的是单卡单模型并发不会加快总吞吐只会让每个会话都变慢。你要做的是公平调度而不是无限并发。显存允许的范围内模型的KV cache占用决定了你最多能同时挂多少会话。5.3 用异步流改善软卡顿的几个小技巧最后说几个我在实际项目里沉淀下来的细节优化成本很低收益却很直接。第一取消按钮必须工作正常。本地推理是不可预测的用户常常问完就后悔或者觉得答案跑偏了要立刻停止。如果你的前端只做了接收不做取消那就只能干等模型吐完。正确做法是维护一个AbortController点击停止时调用controller.abort()同时后端要监听连接断开并中断推理线程释放显存。我在3.2节代码的fetch上补一下signal即可。第二token计数器和生成速度显示。异步流天然适合做这个前端统计每秒收到的token数展示34 tokens/s或者预计还需5秒用户对卡顿的容忍度会大幅提高。第三保持UI事件循环的空闲度。前端如果有React/Vue这类框架对高频流式状态更新特别容易有渲染开销。建议把流式文本隔离到独立的、不经过框架响应式的DOM节点上用原生API去更新。让框架只管按钮、输入框这些低频状态token流走快捷通道这样即使模型狂吐不止框架也不会被拖垮。结尾再分享一个小习惯。我现在每次部署本地推理界面第一件事不是看效果而是打开浏览器Performance面板观察主线程里有没有超过150ms的长任务。如果模型明明在吐字主线程却经常出现长任务我就去token路径上摸一遍谁在JSON.stringify、谁在全量markdown、谁在async函数里偷偷调用同步推理。绝大多数情况不用换显卡把这三类阻塞点清理干净流畅度立刻回来。你要是也被吐字但界面卡折磨着不妨从Network面板里看SSE事件间隔开始顺藤摸瓜卡点会自己浮出水面。