☰
AI应用后端优化:流式输出与异步调度避免线程阻塞
2026/10/10 7:55:06 网站建设 项目流程

做AI应用的朋友,十有八九遇到过这个场景:前端页面上用户点完按钮,光标转啊转,等了十几秒没反应,产品经理在旁边盯着你,后端日志里那个请求却一直挂在"调用大模型接口"这一步。我去年接手一个内部AI问答系统,上线第一天就被这种"暂停键"按住了——不是模型生成得慢,是后端把线程资源全堵在等待响应上,后续请求全部排队,越积越多。后来花了大半个周末把AI流式输出和异步调度彻底重做了一遍,才算把线程从"被堵死"的状态里救回来。这篇就把我踩过的坑、想明白的原理,以及一套可以直接抄走的改造方案整理出来,给正在做AI应用接入、或者后端接口被慢任务拖垮的朋友作参考。

1. 从一次"转圈圈"事故说起:流式输出为什么成了AI应用的硬需求

1.1 一次线上事故:接口慢到连健康检查都跟着遭殃

当时我们的AI问答服务走的是最朴素的打法:前端发一个POST请求过来,后端拿着用户的提问,同步去调大模型API,等到整段回答生成完了,再把完整文本一次性返回给前端。

这套逻辑单独测没问题,但一上线就露馅了。某个下午业务方反馈"页面一直转圈",我看监控面板,接口P95延迟飙到了30秒以上,而正常情况下应该在2秒内返回。最讽刺的是我们当时QPS并不高,平均每秒不到20个请求。按说这个负载对一台4核8G的机器来说绰绰有余,为什么服务会卡到近乎瘫痪?

深入排查后原因非常清晰:大模型生成一段300字的中文回答,通常需要10到30秒。在同步接口的设计下,这几十秒时间内,后端处理该请求的线程是彻底阻塞的——它不干别的,就干等着模型那边吐字。一个请求堵一个线程,线程池一满,后面的请求全部排队。前端等不了就超时重试,重试的请求又继续挤进线程池,整个服务就在这种"慢请求+重试风暴"里被拖垮了。

1.2 流式输出的本质:SSE与增量token

要解决"用户体验差"和"线程被堵死"这两个问题,最直接的手段就是改为流式输出。

流式输出的原理并不玄乎。大模型的生成方式本来就是逐token(token可以粗略理解为一个字或一个词片段)地往外吐,只是API层面默认帮你攒齐了再返回。开启stream参数后,服务端会通过HTTP长连接,每生成一部分内容就立刻推给客户端。主流大模型API都支持这种模式,返回格式通常是Server-Sent Events(SSE),也就是Content-Type: text/event-stream,每一行以data:开头携带一段JSON。客户端收到后不断解析,UI上就能看到文字逐个蹦出来,首字延迟可以压到1秒以内。

这里有个关键认知:流式输出让"单个连接的生命周期"变长了,因为它不再是一来一回的短事务,而是持续几十秒的长连接。这个特性本身就是对后端并发模型的一次考验——你越需要保持大量长连接不断,就越不能用一个请求独占一个线程的老思路,否则前面说的线程池打满问题不但没解决,反而会因为连接时间变长而更加严重。这就是标题里"别堵死线程"的真正含义。

2. 线程为什么会堵死:大模型推理的耗时模型与阻塞传播路径

2.1 先搞懂大模型生成为什么这么慢

在优化之前,最好先理解你的慢请求到底慢在哪。大模型的生成是自回归式的:模型每生成一个token,都要把当前已有的全部token重新过一遍神经网络,才能预测下一个token。这就意味着回答越长,耗时增长得越明显。哪怕GPU推理速度已经很快,生成长文本的总体时间依然是"秒级"起步的。

举个例子,假设模型输出速度是每秒20个token,生成一段200字的回答大约要10秒。这10秒对程序来说非常尴尬——说短不短,说不长也不长,但足够把"请求线程池有界、线程不能无限创建"这类后端经典问题全部引爆。

更要命的是,大模型API调用本质上是一个外部IO操作。你的服务器把HTTP请求发出去,然后就是漫长的等待。这时候线程本身不消耗CPU,但它被占用了,不能去干别的活。对Web服务这种"人员密集型"场景来说,空闲但被占用的线程是最浪费的——明明不干活,却占着编制。

2.2 阻塞传播链路:一个慢请求如何拖垮整个服务

我把当时的阻塞传播链路梳理了一遍,这可能是很多后端同学容易忽视的地方:

首先是HTTP服务线程池。以常见的Tomcat为例,默认最大线程数一般是200。当200个请求都在等待大模型API返回时,第201个请求就只能在队列里排队。客户端等不起,网关超时,前端报错。

接着是连接池耗尽。如果你用的是数据库连接池或HTTP连接池,慢请求长时间占用连接不释放,连接池里的连接也会被耗光。其他正常的快请求需要连接时拿不到,跟着一起失败。

然后是线程切换成本。如果某个同学为了扛住压力,把线程池最大线程数调到了1000甚至更多,又会引入新的问题:大量线程处于阻塞状态,一旦被唤醒,CPU在上下文切换上的开销会明显上升,线程多了反而把CPU时间都花在切换上,有效率反而下降。

这一整套链路串起来,就是一个典型的"慢IO导致的线程饥饿"事故。

2.3 "线程嵌套线程"和"线程切换泄漏"这两个误区

排查过程中我还注意到网上有不少相关讨论,这里顺手澄清两个容易搞错的点。

一个是"python线程嵌套线程"。有些同学在处理慢任务时,喜欢在线程A里面再new一个线程B去等结果,然后线程A也等着线程B完成。表面看是并行,实际上外层线程并没有释放,内层线程又在做同样的事,线程数量被白白放大了一倍。如果你在日志里发现线程数莫名其妙地持续上涨,先查查是不是有这种嵌套等待的写法。

另一个是"线程切换时会泄漏吗"。切换本身不会泄漏什么东西,但频繁地创建线程确实会泄漏资源——每个线程都要占用栈内存(默认栈大小通常1MB左右)和内核句柄。线程若只创建不回收,或者线程池没做复用,内存和句柄就会稳步涨上去,最后触发OOM。我见过不少"内存越用越高"的案例,根子不在堆上,而在失控的线程数量上。

3. 异步调度方案选型:线程池、事件循环与任务队列,到底该用哪个

既然问题出在"同步等待外部慢IO",那答案自然就是"异步化"。我试过三种主流路线,各有适用场景,这里按我的实战感受逐一拆开讲。

3.1 方案A:线程池加Future,最朴素的异步化

在现有同步代码基础上,最省事的是引入线程池,把"调用大模型"这个动作提交给线程池执行,主线程等Future返回或者注册回调。

Java里可以用ExecutorService加CompletableFuture,把阻塞调用包进异步任务里。Python里可以用ThreadPoolExecutor配合concurrent.futures.Future,或者用asyncio.run_in_executor把同步函数丢到线程池里跑。

# Python ThreadPoolExecutor 示例 from concurrent.futures import ThreadPoolExecutor executor = ThreadPoolExecutor(max_workers=32) def call_llm_sync(prompt: str) -> str: # 这里是原本的同步大模型调用 return requests.post(LLM_URL, json={"prompt": prompt}).text future = executor.submit(call_llm_sync, prompt) result = future.result(timeout=60)

这个方案最大的优点是改动量小,适合快速止血。但要注意几个坑:线程池的线程数不能拍脑袋定,太大会引入切换开销,太小又会排队;future.result(timeout=...)一定要设置超时,否则线程可能永远挂在那里。

3.2 方案B:事件循环与IO多路复用,真正的"不占线程"

如果要彻底解决"一个连接占一个线程"的问题,就得用事件循环模型。

Python的asyncio就是典型的例子。它的核心思路是:单线程内通过事件循环调度多个协程,遇到IO等待时主动让出控制权,等数据到了再继续执行。这样一万个并发连接可以共享一个线程,每个连接只是事件循环里的一个任务而已。

import asyncio import httpx async def call_llm(prompt: str): async with httpx.AsyncClient(timeout=None) as client: resp = await client.post(LLM_URL, json={"prompt": prompt}, stream=True) return resp

Node.js天然也是这个模型,Java里对应的则是虚拟线程或CompletableFuture+ 非阻塞IO。我个人的体会是:如果项目是Java,用虚拟线程(JDK 21之后)改造体验最好,代码写起来和同步一样,底层却能在线程阻塞时自动让出资源。如果项目是Python,用asyncio+httpx.AsyncClient是标准答案。顺带说一句,Python 3.13发布的free-threaded构建取消了GIL,能让多线程真正并行利用多核,但异步IO的"不占线程"优势依然存在,两者解决的问题并不重合。

使用事件循环最大的禁忌,是在协程里调用同步阻塞库。requests这类库会直接卡住事件循环,导致整个进程的协程全部停止响应。这个问题我在后面"踩坑实录"里会单独讲。

3.3 方案C:消息队列解耦,把压力挡在业务入口之前

如果请求量继续往上走,而且对实时性的要求没那么高,可以考虑把请求先丢进消息队列,由独立的worker异步消费。这样业务入口可以秒回,慢模型调用全部在worker侧消化,天然具备削峰填谷的能力。

这个方案适合"任务型"场景,比如批量生成摘要、离线分析报告。但如果是纯在线交互场景,多引入一个中间件会增大链路复杂度,不太划算。

3.4 选型对比与我的决策逻辑

方案改动量并发上限适用场景主要风险
线程池+Future小受线程数限制同步代码快速改造线程池参数配置不当
事件循环/协程中极高长连接、高并发IO密集误用阻塞库
消息队列解耦大极高任务型、削峰填谷链路复杂、延迟增加

我当时的选择是:在线接口全部改成asyncio+ 流式输出,一次性把线程资源从"一个一个被占死"的窘境里解放出来。因为我们的核心痛点恰恰是"大量长连接等待模型输出",这是事件循环模型最擅长处理的情形。

4. 实战改造:一个流式AI网关的异步调度落地过程

4.1 改造前的同步链路长什么样

先看一段典型的改造前代码。我们当时的接口用FastAPI写的,但犯的错误是纯同步写法:

from fastapi import FastAPI from fastapi.responses import JSONResponse import requests app = FastAPI() @app.post("/chat") def chat(prompt: str): # 同步等待,线程一直阻塞在这里 response = requests.post(LLM_URL, json={"prompt": prompt}, stream=False) return JSONResponse({"answer": response.json()["content"]})

问题一目了然:requests.post是同步阻塞的,而且stream=False意味着要等整个回答生成完才返回。这就是"一个请求占一个线程几十秒"的源头。

4.2 改造后的流式异步链路

改造分两层:外层把FastAPI接口改成异步流式返回,内层把HTTP调用换成异步客户端并开启流式读取。

from fastapi import FastAPI from fastapi.responses import StreamingResponse import httpx app = FastAPI() LLM_URL = "https://your-llm-api/v1/chat/completions" async def stream_llm(prompt: str): payload = { "model": "your-model", "messages": [{"role": "user", "content": prompt}], "stream": True, } # timeout=None:流式场景下连接要保持较长空闲 async with httpx.AsyncClient(timeout=None) as client: async with client.stream("POST", LLM_URL, json=payload) as resp: # 按行读取SSE数据 async for line in resp.aiter_lines(): if line.startswith("data:"): yield line[5:].strip() + "\n" @app.post("/chat") async def chat(prompt: str): return StreamingResponse(stream_llm(prompt), media_type="text/event-stream")

这里每一步都有讲究。换成async def后,FastAPI会把请求放进事件循环而不是线程池,原先"一个请求占一个线程"的模型被打破。httpx.AsyncClient负责异步IO,在等待模型API返回时,事件循环可以继续处理其他请求。client.stream保证以流式方式读取响应,而不是攒完整份再返回。StreamingResponse把后端的产出一边生成、一边推给前端。

做完这一步,我们接口的并发表现有了质的变化:之前10个并发请求就能把线程池吃得差不多了,改造后同样的机器可以轻松扛住几百路并发,且CPU占用没有明显上升。

4.3 线程池参数的重新推演

如果你因为历史原因必须保留线程池,线程数怎么定?别拍脑袋,先看一个经典公式:

核心线程数 ≈ CPU核数 × (1 + 平均等待时间 / 平均计算时间)

这是《Java并发编程实战》里的经验公式,核心思想是:如果任务是IO密集型的,等待时间远大于计算时间,那线程数可以远大于CPU核数,因为线程大部分时间都在等IO,不抢CPU。

放到"调大模型API"这个场景来算:假设CPU核数是4,一次请求里计算时间(拼装参数、解析响应等)大约50毫秒,等待时间(模型生成)大约10秒,也就是10000毫秒。代入公式:

4 × (1 + 10000 / 50) = 4 × 201 = 804

理论上804个线程才够把CPU用满。但实际我不建议真的开这么多线程——每开一个线程就多一份栈内存和切换开销。这个数字要打折扣,通常取一半左右,再配合有界队列削峰,让多余的任务排队而不是疯狂开线程。

4.4 线程池的阻塞队列选择

线程池配哪一个阻塞队列,是很考基本功的细节,这里展开说一下:

  • SynchronousQueue:不存任务,来一个任务必须立刻有一个线程接走,否则就阻塞。适合"任务必须马上处理"的场景,比如把任务转交给另一个线程池,不积压。
  • ArrayBlockingQueue:有界队列,队列满了就触发拒绝策略。适合需要严格限制积压量的场景,避免内存被堆积的任务撑爆。
  • LinkedBlockingQueue:可以配置有界,也可以无界。无界队列最大的隐患是任务无限堆积,内存最终被打满,而且线程数永远达不到最大线程数,等于白设了上限。

我的建议是:凡是暴露给外部流量的线程池,一律用有界队列。队列长度可以根据业务预估的峰值积压量来定,宁可拒绝一部分请求让客户端重试,也别把内存耗光导致整个进程崩掉。

4.5 超时、取消与断连处理

流式接口比普通接口多了一个大坑:客户端可能中途断开。用户关掉页面了,前端的网络连接断了,但如果后端还在傻傻等模型生成完,线程或协程就白白浪费在给一个"已经不存在的连接"喂数据上。

因此一定要做两层处理。第一层是响应本身的超时,给模型调用设置一个整体超时上限,比如120秒,超过就直接掐断。第二层是监听客户端断开,FastAPI里可以在生成器里捕获asyncio.CancelledError,或者用request.is_disconnected()检测连接状态,一旦发现客户端断开,立刻退出生成循环,同时把底层的模型调用取消掉。

async def stream_llm(prompt: str, request: Request): async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client: try: async with client.stream("POST", LLM_URL, json=payload) as resp: async for line in resp.aiter_lines(): if await request.is_disconnected(): break yield ... # 推送数据 except asyncio.CancelledError: # 客户端断开,及时退出,释放底层连接 pass

这个处理看起来不起眼,但在高并发场景下,漏掉它意味着大量连接和协程不会按时释放,累积一段时间后仍然会把资源耗尽——只是比"线程堵死"来得慢一些而已。

5. 踩坑实录:线程异步化改造中的三个经典陷阱与完整排查链路

5.1 陷阱一:async函数里调用了同步requests,异步直接报废

改造初期,我踩的最大的坑就是在async def chat这个协程里,还是习惯性地用了requests.post去调模型接口。结果线上验证时发现:并发上来之后,整个服务还是一样卡,甚至比改造前更卡了。

原因不复杂:requests的post方法是同步阻塞的,它会卡住当前正在执行的事件循环。表面上你这个接口写成了异步,但事件循环里只要有一个协程卡在同步IO上,整个循环里的所有其他协程都得等它。也就是说,一个同步阻塞调用就能把异步化的收益全部吃掉,这是"一颗老鼠屎坏了一锅粥"的典型。

排查方法也很简单,打印异步函数里每步的执行时间,你会发现requests.post那一步的耗时和其他步骤完全不是一个量级,且期间其他请求的延迟整体飙升。解决办法就是把所有外部调用统一换成httpx.AsyncClient或aiohttp,并做一次全局检查,确保协程调用链上没有同步阻塞库。

5.2 陷阱二:两个任务互相等待,线程死锁现场还原

做异步调度时,任务之间互相等待是最隐蔽的死锁来源。我当时为了做"问答+知识库检索"并行,写了这样一个逻辑:主任务A负责汇总结果,它先派发子任务B去检索知识库,同时派发子任务C去调大模型。等B和C都完成后再汇总。听起来没问题,但有一次知识库检索组件内部又反向等待了主任务A的某个中间结果,于是变成了A等B、B等A,用asyncio.wait等了一个超时周期才报错。

Java里类似场景更常见,两个线程各自持有一把锁,又在等对方释放另一把锁,就是教科书级的死锁。解决思路是两条:一是尽量用"单向依赖",避免任务之间互相回调;二是所有等待都必须带超时,用asyncio.wait_for或Future.get(timeout)兜底,宁可超时失败重试,也不要无限等下去。

还有个小细节,多个协程共享计数器时,别随手用普通int。在Python的asyncio里单线程协程之间用普通变量问题不大,但如果你混用了ThreadPoolExecutor,多个线程同时改一个计数器就必须用threading.Lock或AtomicInteger这类并发安全的对象,否则计数会丢。这个“atomicInteger线程安全吗”的问题,答案是它专门为此设计的,该用就用。

5.3 陷阱三:流式连接没读完,底层连接泄漏

第三个坑是在排查"连接数持续上涨"时发现的。有些调用方代码在拿到HTTP响应头之后就提前退出了解析,没有把流完整读完。在非流式模式下这问题不大,响应体小,连接用完就还回去了。但流式模式下,响应体会持续几十秒甚至几分钟,如果你半路退出而没关闭响应对象,底层TCP连接和连接池里的占用就一直不释放,积累多了就把连接池耗尽。

这个问题的特征是:接口并发不算高,但服务端的活跃连接数和进程句柄数稳定上涨,重启后又回落。解决方法是,在所有读流的地方用上下文管理器(async with)保证退出时正确关闭,同时给连接池设置合理的最大连接数和空闲回收时间。不要贪图省事写resp = await client.post(...)这样不带async with的代码,一旦中途return,连接很容易泄漏。

5.4 排查链路:从线程数暴涨到定位真凶的完整过程

最后把一次典型的"线程堵死"排查链路完整走一遍,供你复现思路。

第一步,观察基础指标:线程数、活跃连接数、JVM/进程内存。如果线程数持续走高且不回落,十有八九是线程在等某个IO。

第二步,抓线程转储。Java服务用jstack,Python服务可以用py-spy dump --pid <pid>,Windows上还可以用Process Explorer这类工具观察进程内线程状态与调用栈。重点看线程都停在哪个方法上——如果大量线程都stack在HTTP请求发送或连接等待的位置,基本就锁定是外部慢IO阻塞。

第三步,顺着调用链找到阻塞的源头。我排查的一个案例中,几十个线程全部停在requests.post内部的一个socket读操作上,于是很快就定位到了那个同步访问模型API的代码位置。加上日志里的请求耗时分布,可以确认就是模型接口慢,而不是程序死循环。

第四步,做对照实验。把慢调用的mock掉,看线程数是否回落;或者强制走异步客户端,看同样并发下线程数是否保持平稳。这一步能快速验证"同步阻塞"是不是根因。

经过这次改造,我养成了一个习惯:凡是后端代码里出现外部HTTP调用、数据库查询这种IO操作,第一反应就是看它会在什么情况下阻塞多久,以及这个阻塞会不会传染给其他请求。很多看起来神秘的"服务卡死",本质上都是慢IO加上同步等待的组合。把这条链路想清楚,用流式输出和异步调度把线程解放出来,系统的稳定性和用户体验会有质的提升。希望这篇实战记录能帮你在自己的项目里少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询