前两天的线上事故,把我折腾得够呛:一个本来跑得好好的 AI 对话应用,功能都正常,接口通、前端流式打字机效果也上来了,结果刚放量,服务器 CPU 直接冲到 100%,接口大面积超时,整个服务像被抽干了血一样瘫掉。这就是标题里说的“正常部署 AI + 流式输出(Stream)”,为什么 CPU 会炸。
我复盘了很久,也和不少做 AI 应用部署的朋友对了下情况,发现大家遇到的现象高度一致:本地单用户调试一切正常,一上生产就崩。原因不在于大模型推理引擎本身,而在于整条流式链路里多个隐性开销叠在一起,最终把 CPU 打穿。这篇文章把我这次的完整排查过程、定位方法和修复方案写出来,给正在做 AI 应用、尤其是用 SSE 流式输出的小伙伴一个参考。
1. 事故现场:一个“正常部署”的 AI 应用如何把 CPU 干到 100%
1.1 部署架构与“我以为的正常”
先交代一下我这次的部署环境,这直接决定了问题的走向。服务器是一台 8 核 16G 的机型,没有 GPU,跑的是 Ollama 部署的 Qwen2.5-7B-Instruct Q4_K_M 量化模型。后端是 Spring Boot 3.2 项目,通过 Spring AI 的 Ollama 客户端接入模型,Controller 返回 SseEmitter,也就是用 SSE(Server-Sent Events)方式把模型生成的 token 逐个推给前端。前端是 Vue3 页面,用 fetch 的 ReadableStream 读取流式数据,然后把每一条文本丢给 markdown 渲染库实时渲染。
为什么我说“我以为的正常”?因为在开发环境里,我自己开一个页面、发一条消息,整个链路跑下来非常流畅,首 token 大概 1 秒内就能看到,打字机效果也很顺,CPU 占用大概 30% 左右。当时我想:这不就是正常部署嘛,模型能跑,接口能通,流式也出了,直接上生产就行。
问题恰恰出在这个“正常”上。本地单用户永远测不出并发场景下的资源争抢,尤其测不出长连接导致的线程膨胀和 CPU 抢占。我用 k6 模拟了 20 个并发用户,每个人连续发几条消息,观测了 5 分钟,CPU 从 30% 一路狂飙到 100%,随后 Tomcat 工作线程被打满,新的请求全部进入队列等待,客户端纷纷超时。第一次测的时候我甚至以为是压测工具把服务器打崩了,后来才发现根本不是那么回事。
1.2 雪崩是从哪一秒开始的
这个事故值得拆开来看,因为“CPU 炸了”并不是某一秒突然发生的,它是循序渐进的。按照时间线来还原,大致是这样的:
第 1 分钟,20 个并发请求进来,每个请求都是 SSE 长连接。Ollama 开始同时处理多个推理任务,CPU 占用快速上升。此时 token 生成速度还在可接受范围,前端反馈也还正常。
第 2 分钟,几个上下文比较长的对话进入 prefill 阶段,需要把用户的整段历史记录(几千个 token)重新计算一遍 K/V cache。prefill 是矩阵密集型计算,CPU 占用瞬间拉满,首 token 延迟从 1 秒暴涨到 10 秒以上。
第 3 分钟,用户端因为等待时间太久,开始出现超时。客户端设置的自动重试机制触发,同一个请求被反复重新发送,请求量从 20 突然翻到 40、60。服务器既要处理新请求,又要处理重试请求,Ollama 那边的并发请求数翻倍,CPU 彻底饱和。
第 4 分钟,Tomcat 的 200 个工作线程被全部占满。注意,这里的“占满”指的不是处理速度快慢的问题,而是每个流式请求都占用一个线程长达几十秒,等待模型生成完所有 token 才释放。线程被耗尽后,新的 HTTP 请求排队,连 Spring Boot 健康检查接口都开始超时,服务进入雪崩状态。
1.3 为什么 CPU 是第一个崩的环节
很多人以为 AI 应用最紧张的是 GPU 显存,没 GPU 时最紧张的是内存,CPU 反而容易被忽略。但流式输出场景下,CPU 恰恰是最先撑不住的。原因是它承担的角色太杂了:要跑 HTTP 解析、线程调度、网络收发,还要跑模型推理的矩阵运算、token 的编码解码、JSON 序列化,以及垃圾回收。这些开销在普通接口里占比不高,但在 AI 流式接口里,每生成一个 token 就要把整条链路从头到尾走一遍,CPU 自然变成了木桶最短的那块板。
搞清楚了“为什么炸”,下一步就是弄清楚“炸在哪”。下面进入正题,我把流式输出链路里每一个消耗 CPU 的环节拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆开流式输出:SSE 链路里的每一个 CPU 消耗点
2.1 一条 SSE 消息的真实旅程
为了定位问题,我先得搞清楚一个 token 从模型生成到展示在用户屏幕上,到底经历了哪些步骤。用 Ollama + Spring AI + 前端 fetch 流式读取这个典型链路来说,大概是这样的:
前端把用户输入发到后端。后端调用 Ollama 的 /api/chat 接口并开启 stream: true。Ollama 收到请求后,先做 tokenize,也就是把文本切成 token 序列;然后进入 prefill 阶段,把整个 prompt 的所有 token 送入模型网络,计算出一组 K/V cache。之后才进入自回归生成阶段,每生成一个 token,就把上一次的输出作为输入,再做一次前向推理,然后采样。采样完成后,把 token detokenize 成文本,再按 SSE 格式,也就是 data: {"response":"..."}\n\n 这样的内容,通过 HTTP 响应体推送出来。后端收到这个数据后,再拼装成自己的 SSE 事件,推给前端。前端收到事件后,解析出文本片段,触发 markdown 渲染,最终显示在屏幕上。
这条链路里,每一个环节都吃 CPU。推理前向传播是绝对大头,但其他环节叠加在一起,也不容小觑。尤其是流式输出把“一次性处理”变成了“高频持续处理”,原本一个普通接口只有一轮计算,现在变成几十轮甚至几百轮,每轮都要重新走一遍协议解析、序列化、渲染,这些开销被乘以 token 数量之后,非常可观。
2.2 五大隐藏开销:推理、tokenize、序列化、线程调度、GC
我总结了一下,流式输出场景下 CPU 里藏着五个主要开销点:
第一,模型前向推理。这是模型本身的计算,7B 模型即便是 Q4 量化,在 CPU 上每生成一个 token 也需要做数十亿次乘加运算。这部分是刚性开销,只能通过量化、裁剪上下文、限制并发来缓解,无法消除。
第二,tokenize 和 detokenize。大模型词表很大,像 Qwen2 的词表有 15 万左右,把 token id 还原成字符串或者把字符串切成 token,本身需要查表、拼接,逐 token 处理时开销不可忽略。尤其是服务端如果习惯性把每个 token 都 log.info 出来,日志本身也会成为 CPU 和磁盘 IO 的黑洞。
第三,频繁的 JSON 序列化。Ollama 每返回一个 token,都会包装成一段 JSON,Spring AI 的客户端又要解析这段 JSON,后端再序列化成自己的 SSE 事件。一次两次还好,一个 500 token 的回答就是 500 次序列化加反序列化。并发一高,这部分在 GC 和 CPU 上都会被明显放大。
第四,线程调度和上下文切换。Spring MVC 的 SseEmitter 本质上是一个请求占一个工作线程,等模型慢慢吐 token。线程本身在阻塞等待时并不消耗大量 CPU,但一旦多个线程频繁在等待、唤醒、读写之间切换,CPU 的上下文切换开销就会显著上升,尤其是当线程数超过 CPU 核心数时,切换成本更明显。
第五,内存分配与垃圾回收。流式场景下,短生命周期对象非常多,每来一个 token 就新建几个对象。JVM 频繁触发 Young GC,甚至压力大到进入 Old GC,GC 阶段 CPU 会明显飙高。这个问题在 Java 后端最容易忽略,因为 GC 线程也吃 CPU。
2.3 并发模型的放大器:长连接 × 线程 × 模型上下文
如果只是单用户,上面这些开销都不算什么。但并发一上来,问题就被放大得很厉害。SSE 长连接的特点是“请求时间特别长”,普通接口几百毫秒就释放线程了,SSE 接口要占住线程几十秒甚至几分钟。Tomcat 默认核心线程数 200,理论上 200 个并发长连接就能把线程池打满,后续请求全部排队。线程本身也是内存大户,200 个线程的栈空间就是几百 MB,加上 GC 压力,CPU 高是必然的。
模型的并发上下文也要注意。Ollama 虽然支持并发请求,但每个并发请求都会独立维护一份 K/V cache,也就是要额外占用内存和计算资源。多个请求同时在 CPU 上做 prefill 和生成,相当于多个大型矩阵运算任务互相抢 CPU 核心。CPU 推理的关键性能指标是“每秒生成 token 数”,并发翻倍后,单个请求的生成速度会显著下降,用户感知就是越来越慢。
这一节讲的是原理,下面是我实际的排查过程。如果你也遇到了类似现象,可以直接照着下面的命令和思路来。
3. 排查实录:用 20 分钟锁定 CPU 真凶
3.1 第一板斧:top / pidstat / jstack 定位热点
排查的第一步不是看代码,而是先看进程和线程,确定 CPU 到底被谁吃掉了。我当时的操作顺序是这样的:
bash复制# 看整体负载,确认是不是 CPU 饱和
top -b -n 1 | head -20
# 按 CPU 排序,找到 java 和 ollama 两个关键进程的 PID
top -b -n 1 -o %CPU | head -30
执行之后我发现,java 进程和 ollama 进程的 CPU 占用都在 400% 以上,也就是各自占掉了四五个核。一台 8 核的机器,光这两个进程就吃掉了差不多九成算力。确认是这两个进程的问题之后,再往深挖:
bash复制# 查看进程内各线程的 CPU 占用(-H 开启线程模式)
top -b -H -p <java_pid> -n 1 | sort -k9 -r | head -20
# 想抓 Java 线程栈,先把线程 ID 转成十六进制
printf "%x\n" <线程ID>
# 然后在 jstack 输出里搜索这个线程
jstack <java_pid> | grep -A 30 "nid=0x..."
这一步其实出现了很有价值的现象:Java 进程里 CPU 最高的几个线程,thread dump 显示它们在处理 JSON 序列化和 HTTP request 的写操作,而不是在等 I/O。说明 Java 侧并不只是“堵住”了,而是真的在持续干活,在反复做 token 的包装和推送。Ollama 那边用 top -H 看,多个推理工作线程处于 R 状态,说明模型推理线程一直在满负荷计算。
如果你用的是 Python FastAPI,抓线程栈也有现成工具:
bash复制py-spy dump --pid <python_pid>
通过线程栈就能很直观地看到代码卡在哪一行,是阻塞在模型推理,还是阻塞在事件循环里。
3.2 第二板斧:后端 SSE 代码里找问题
线程栈确认 Java 侧不是纯等待之后,我开始审查后端的 SSE 实现代码。当时这个接口的写法很典型,也存在很多常见问题:
java复制@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter();
// 每次请求都丢给线程池,线程池没有设置明确的有界策略
executor.execute(() -> {
try {
ollamaChatClient.stream(prompt)
.doOnNext(token -> emitter.send(token))
.blockLast();
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
这段代码在功能上没问题,但在并发场景下全是坑。第一,没有限流。用户量一大,executor 线程池如果无界,线程数会无限制膨胀;如果是有界的,任务会排队,超时后客户端重试,压力翻倍。第二,没有超时设置。SseEmitter 如果长时间不发送数据,也没有超时断开,连接会一直挂着。第三,没有心跳。如果前置一层 Nginx,默认 proxy_read_timeout 是 60 秒,服务端超过 60 秒没有写数据,Nginx 会主动断开连接,前端收不到正常关闭事件,大概率会重试。第四,没有背压控制,服务端生成速度不受客户端消费速度限制。
3.3 第三板斧:推理引擎侧排查与客户端渲染
解决了 Java 侧的怀疑点,我又去看了 Ollama 侧。执行 ollama ps 检查模型驻留状态,发现模型确实常驻内存,但并发请求没有做任何限制。查了相关环境变量,OLLAMA_NUM_PARALLEL 是默认状态,文档显示当前版本的默认并发策略,对 CPU 推理来说过于乐观,我建议在 CPU 环境显式控制并发数。
另外检查了 CPU 拓扑:
bash复制lscpu
这里可以看到物理核数和超线程情况。在 CPU 推理时,超线程带来的逻辑核心,跑矩阵运算类负载时不仅不能提升性能,反而会因为共享物理核心的执行单元导致争抢。llama.cpp 家族如果线程数设置成逻辑核数,性能反而比只用物理核更差。所以我后来把 Ollama 的线程数调整成了物理核心数。
排查完服务端,我又顺手看了一下浏览器端。打开开发者工具 Performance 面板,录制了一段流式接收过程,发现前端每次收到 token 就触发了一次 markdown 全量渲染。渲染的文本积累到几百字之后,单次渲染耗时已经达到几十毫秒。虽然这部分 CPU 不在服务器上,但用户量一大,浏览器端的卡顿也会通过重试机制反向拖垮服务器。
3.4 定位结论:多因素叠加,不是单点故障
两轮排查之后,结论很清晰:不是模型崩了,也不是代码跑错了,而是整条流式链路在并发场景下把资源吃穿。其中大头有三个,一是 Ollama 在 CPU 上并发做推理,二是 Java 后端对每个 token 的重复序列化和线程占用,三是客户端重试机制放大了请求量。找到了原因,修复方案就清晰了,下面是完整的工程化改造思路。
4. 工程化修复:让 AI 流式服务的 CPU 稳下来
4.1 推理端优化:量化、线程数与并发度
先说代价最小但收益最明显的几个调整。
第一个,确认模型量化级别。CPU 推理,7B 模型无脑选 Q4_K_M,别追求 Q8 或 BF16,性能差距非常明显,而质量差距在一般对话场景里可以接受。如果你用的是更大参数模型,比如 14B,CPU 上基本只能跑 Q4,别硬上更高精度。
第二个,限制 Ollama 的并发请求数。在 systemd 服务或者启动脚本里设置环境变量,让模型同一时间只处理一个请求,多余请求排队等待。我实测下来,宁可让用户多等几秒排队,也不能让三四个推理任务同时抢 CPU,后者会让所有人一起卡死,CPU 直接爆表。
第三个,调整线程数。通过 lscpu 查看物理核心数,比如这台 8 核 16 线程的机器,Core(s) per socket 是 8,Thread(s) per core 是 2,那么就把 Ollama 的线程数设成 8 而不是 16。对于矩阵运算密集的推理任务,关闭超线程反而更稳定。
第四个,控制上下文长度。之前为了“让模型记住更多内容”,把上下文调到了很大的值。但 CPU 环境下,prefill 阶段的计算量随 prompt token 数线性上升,上下文越长,首 token 延迟越高,CPU 越容易被瞬间打满。建议根据业务实际需要设定,够用就行,不要盲目追求长上下文。
第五个,上线前预热。模型冷加载后第一次推理要分配内存、建立 K/V cache,CPU 会短暂飙高。可以在服务启动后主动发一条短消息让模型“热起来”,避免第一个真实用户踩中冷启动。
4.2 服务端加固:限流、超时、心跳与背压
服务端这一层,最重要的是停止“无限并发”的乐观假设。AI 推理型接口必须按稀缺资源来治理。
我改造后的核心逻辑是这样的:用信号量限制并发流式任务数,超出直接返回 429 提示用户稍后再试,而不是让请求无限排队。这个做法在用户体验上更诚实,也比把服务拖垮强得多。如果你担心直接拒绝感受不好,可以在提示里加上“排队中”的话术,让前端引导用户重试。
SseEmitter 要设置超时和时间,并且必须配套心跳机制。心跳其实很简单,SSE 规范里以 : 开头的行是注释行,客户端会忽略,但能起到维持连接的作用:
java复制@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String prompt) throws InterruptedException {
SseEmitter emitter = new SseEmitter(120_000L);
if (!sseSemaphore.tryAcquire()) {
emitter.completeWithError(new RuntimeException("too many requests"));
return emitter;
}
executor.submit(() -> {
try {
// 每 15 秒发送一次心跳,防止网关断开空闲连接
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
emitter.send(SseEmitter.event().comment("keepalive"));
} catch (IOException e) {
emitter.completeWithError(e);
scheduler.shutdown();
}
}, 0, 15, TimeUnit.SECONDS);
ollamaChatClient.stream(prompt)
.doOnNext(token -> emitter.send(token))
.doOnComplete(emitter::complete)
.doOnError(emitter::completeWithError)
.blockLast();
scheduler.shutdown();
} catch (Exception e) {
emitter.completeWithError(e);
} finally {
sseSemaphore.release();
}
});
return emitter;
}
注意超时也不宜设置过长,CPU 推理场景下,一个请求几十秒还是可以接受的,但如果超过 2 分钟还没完成,大概率是模型卡死或上下文太长。此时主动断开,让客户端重试,反而比吊着连接更好。
再就是生产环境日志。建议把 token 级别的日志直接关掉,要么改成 DEBUG,要么用采样方式记录,只打印首 token 延迟和总耗时这些关键指标。我见过太多服务被“一行日志打一年”拖垮的案例,流式输出时尤其明显。
如果在 Spring MVC 下有更高的并发诉求,可以考虑把接口迁移到 WebFlux,用 Netty 事件循环处理 SSE,而不是一个连接占一个 Tomcat 线程。这块改造工作量稍大,但收益长远。如果暂时没精力改,靠限流 + 有界线程池也能撑住大部分场景。
4.3 前端降载:节流渲染与连接管理
服务端稳住了,前端也别拖后腿。浏览器端消耗 CPU 最狠的不是 JS 逻辑本身,而是每帧都做完整的 markdown 解析和渲染。我的做法是加一个节流器,把 100ms 内的多次数据更新合并成一次渲染。
具体的思路是:收到流式数据后,先把文本拼到一个变量里,然后立刻开启一个定时器,100ms 后再统一触发 markdown 渲染。这样不管一秒钟后端推过来 20 个 token 还是 50 个 token,UI 最多只更新 10 次。经过实测,用户基本感知不到打字机效果的差异,但 CPU 占用能下降 50% 以上。
另外,前端断线重试必须加指数退避。不要一看到 stream disconnected 就立刻重连,这样会在服务端还处于恢复期时制造请求风暴。比如第一次重试等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 5 次,超了就直接提示用户手动重试。
4.4 从单机到监控:建一套可观测体系
优化的同时,我把监控也补上了。没有指标,你永远不知道下一次事故从什么时候开始。我用 Spring Boot Actuator 暴露了线程数、内存和 HTTP 指标,接入了 Prometheus 和 Grafana。重点监控四个指标:CPU 使用率、活跃线程数、SSE 连接数、Ollama 进程 CPU 占用。
告警规则建议这样:CPU 持续 5 分钟超过 80%,或者活跃线程数超过 Tomcat 线程池的 80%,或者 SSE 连接数超过预设阈值,就触发告警。别等 CPU 100% 了才报警,那时服务已经在雪崩边缘了。用 Grafana 的 Alerting 规则或云监控都能实现,关键是规则要事先配好。
5. 常见问题速查:这些报错和现象都是同一个坑
5.1 stream disconnected 系列错误
部署过程中,后端日志里经常会出现类似这样的报错:
text复制stream disconnected before completion: transport error: network error: error
stream disconnected before completion: websocket closed by server before response
stream disconnected before completion: upstream request failed
这些错误本质上是“上游流在结束前被中断了”。常见原因有三个:第一,上游服务生成时间太长,网关或代理主动切断了空闲连接;第二,服务端在流未结束时进程崩溃,比如 OOM、CPU 百分百导致健康检查失败被负载均衡摘除;第三,网络抖动导致 TCP 连接重置。
排查时先区分是服务端主动断开还是网络设备断开。看上游日志有没有异常退出,看网关超时配置,如果服务端确实长时间没有推送数据,那就按前面说的加心跳。另外,检查 HTTP 客户端的读超时设置,Spring AI 底层用的是 WebClient,注意确认 responseTimeout 已调大。
5.2 WebSocket 提前关闭与连接泄漏
有部分实现用 WebSocket 做流式,报错文本接近:
text复制websocket closed by server before response
这种往往是服务端在异常场景下直接关闭了连接,却没有发 close frame。排查方向就是服务端代码,把所有异常路径都 ensures 住,确保在关闭前 flush 掉缓冲区的数据。同时,连接关闭监听器里要释放对应的资源,包括 SseEmitter 和 WebSocketSession 的引用,不然积少成多,连接泄漏会持续占用内存和描述符,最后也会拖累 CPU。
5.3 并发一高 CPU 就炸
如果你的现象是 MySQL 都没压力,Redis 也没瓶颈,就是单纯并发一高 CPU 拉满,那大概率就是推理引擎和服务端线程模型的双重问题。CPU 推理的并发上限是硬天花板,8 核机器跑 7B Q4 模型,单请求生成速度大概在 5 到 15 token 每秒,已经要消耗大半 CPU。并发 2 到 3 个推理任务时,机器基本就没有余量处理其他请求了。所以必须做两件事:限制推理并发,以及给非推理请求(比如静态资源、健康检查)留出 CPU 余量。这两件事不解决,加机器也白搭。
5.4 复现与预防清单
我整理了一份上线检查清单,现在团队里所有 AI 项目上生产前都会过一遍:
- 用 k6 或 wrk 模拟 10 到 20 个并发流式请求,压测持续 5 分钟以上
- 压测时重点观察 CPU、内存、线程数、首 token 延迟
- SSE 接口设置合理的超时时间和心跳
- 服务端限流,超出并发直接返回 429
- 客户端断线重试使用指数退避,限制最大重试次数
- 前端 markdown 渲染做节流,流结束前不要全量重渲染
- 生产环境日志级别调到 WARN 或 ERROR,token 级日志关闭
- 上线前做模型预热
- Prometheus + Grafana 监控 CPU、线程、连接数,并配置告警
我在实际排查中还发现,很多人会把注意力放在模型选型上,盯着 CPU 天梯图选服务器,却忽略了应用层的并发控制。其实在 AI 流式输出这个场景里,硬件再强也顶不住无节制的并发和重复渲染,把资源管理做对,比单纯堆配置重要得多。你拿到一台新机器时,我建议先跑一轮压测,把 CPU 推理的并发上限摸清楚,然后按这个上限去设计限流和队列策略,后面基本不会再出大乱子。
