LLM 流式响应:网慢和断线怎么处理?

最近刚处理完一个挺常见的问题:

LLM 生成的很快,但用户网络慢或者断线问题。

链路就是:

text 复制代码
LLM → Node → 浏览器
快      夹中间     慢

一看到"上游快、下游慢",很容易条件反射想到两个字:背压。

然后直接上 pause()resume()drain

但这次折腾完,我发现其实没必要一上来就把问题搞复杂。

用户端无非两种情况:

text 复制代码
1. 网络慢,但连接还在
2. 网络直接断了

分开处理就行。


一、用户只是网络慢

先说最常见的。

LLM 在哐哐吐 Token,用户因为移动网络、VPN、海外线路之类的原因,接得比较慢。

这时候第一件事不是上背压。

而是:

先看数据到底有多大。

数据就几十 KB、几百 KB?Node 直接扛

假设一次 LLM 最终输出 100KB。

500 个慢用户:

text 复制代码
100KB × 500 = 50MB

50MB。

真没到需要大动干戈的程度。

这种场景我更倾向:

text 复制代码
LLM 尽快生成
    ↓
Node 尽快收
    ↓
前端慢慢拿

说白了就是:

用一点内存,换代码简单。

几十 KB 的东西都舍不得放内存,结果为了"标准背压"搞出一堆状态控制,多少有点本末倒置。

当然,这里有个前提:

Buffer 必须有上限。

别今天模型只输出 50KB,你觉得天下太平;明天产品突然来一句:

"支持一下生成 20 万字报告。"

然后服务器原地升天。

所以小数据可以豪爽一点,大数据还是得讲规矩。


真堵了怎么办?看 res.write()

Node 往前端写数据:

js 复制代码
const ok = res.write(chunk)

如果:

js 复制代码
ok === true

继续写。

如果:

js 复制代码
ok === false

说明下面真塞不动了。

这时候别硬怼,等 drain

js 复制代码
import { once } from 'node:events'

for await (const chunk of llmStream) {
  if (!res.write(chunk)) {
    await once(res, 'drain')
  }
}

其实没什么玄学。

write() 返回 false,大概就一句人话:

下面塞满了,你先停一下。

drain 再继续。

这才是正常的背压。


那要不要顺手把 LLM 也 pause()

理论上很漂亮:

text 复制代码
浏览器慢
   ↓
Node 停止读取
   ↓
上游也跟着慢

标准背压链路。

但放到 LLM 场景里,不能想得太理想。

上游不是你家硬盘上的一个文件,中间可能还有:

text 复制代码
模型
 ↓
推理服务
 ↓
厂商 Buffer
 ↓
API Gateway
 ↓
Node

你 Node 不读了,不代表模型一定同步停止生成。

Token 可能已经生成,只是堵在上游某一层。

所以这里我现在的理解是:

pause() 是解决自己链路的背压,不是拿来遥控厂商 GPU 的。

我们真正需要盯的是自己这一段:

text 复制代码
Node 内存会不会一直涨?
前端是不是真的写不动?
连接会不会拖太久?

这些才是能控制的。


二、用户网络直接断了

网络慢还能慢慢等,网络一断,问题就变了。

比如模型还在生成,突然:

text 复制代码
client disconnected

这时候应该看的,是这个请求一开始就属于哪一类。

无非两种:

text 复制代码
实时请求
后台任务

实时请求:连接断了,就尝试 Abort

比如普通的实时对话:

text 复制代码
"Promise 是什么?"
"这段报错怎么解决?"
"帮我翻译一下这句话。"

它本来就是:

text 复制代码
用户发消息
   ↓
模型实时生成
   ↓
当前页面实时接收

这种请求的生命周期,本来就和当前这次连接绑得比较紧。

用户直接把页面关了,或者客户端明确取消了生成,那继续往下跑通常没什么意义。

这时候可以把取消继续往上游传:

js 复制代码
req.on('close', () => {
  controller.abort()
})

当然,abort() 不代表 GPU 一定在这一毫秒立刻停下来。

中间还有 SDK、网关、模型服务,取消最终能传到哪一层,要看厂商实现。

但站在 Node 这一层,我们该做的已经很明确:

客户端已经不要这次实时响应了,那就别再主动维持这个上游请求。


后台任务:Socket 断了,跟任务没关系

另一类就完全不同。

比如:

text 复制代码
分析 300 页 PDF
生成完整报告
扫描整个代码仓库
跑一套 Agent 工作流
生成文件
批量处理数据

这种东西从设计上就不应该依赖一根 Socket 活着。

它应该在一开始就被当成一个独立任务:

text 复制代码
创建任务
   ↓
返回 task_id
   ↓
后台执行
   ↓
保存状态和结果

比如:

json 复制代码
{
  "task_id": "abc123"
}

后端自己维护:

text 复制代码
pending
↓
running
↓
completed / failed

这时候用户网络断了,只代表:

text 复制代码
"看任务进度的连接断了"

不代表:

text 复制代码
"任务本身应该死了"

所以:

Socket 可以断,Task 继续跑。


用户回来以后怎么办?

前端重新连上以后,带着 task_id 回来查状态:

text 复制代码
abc123

如果:

text 复制代码
running

继续订阅进度。

如果:

text 复制代码
completed

直接拿结果。

如果:

text 复制代码
failed

再决定是否重试。

整个流程类似:

text 复制代码
用户创建任务
    ↓
task_id = abc123
    ↓
后台执行
    ↓
用户断网
    ↓
任务继续
    ↓
用户重新连接
    ↓
通过 task_id 恢复

这样网络抖一下、刷新一下页面,都不会让一个已经跑了几十秒的任务从头再来。


Redis / DB 在这里才真正派上用场

任务既然已经和 Socket 解耦,就不能再把状态只放在某个 Node 进程的变量里。

否则:

text 复制代码
Node 重启

任务信息直接失忆。

最简单可以把状态放 Redis:

json 复制代码
{
  "status": "running",
  "content": "...",
  "updatedAt": 1786000010
}

任务完成以后:

json 复制代码
{
  "status": "completed",
  "content": "完整结果"
}

短期任务可以设 TTL。

需要长期保留的报告、文件、业务结果,则应该进数据库或者对象存储。

所以 Redis 在这里解决的不是单纯"断线缓存"。

而是:

让任务状态不依赖某一个 Socket,也不依赖某一个 Node 进程。


所以网络断了,其实就看这一件事

这个问题在业务设计的时候就应该定下来。

text 复制代码
实时请求
→ 跟当前连接走
→ 客户端断开 / 取消
→ 尝试 Abort 上游
text 复制代码
后台任务
→ 有独立 task_id
→ Socket 断了也继续
→ Redis / DB 保存状态
→ 重连以后恢复

别等用户断线了,才临时决定这个请求到底算 Chat 还是 Task。而是应该在请求前就知道这是什么类型的

相关推荐
IT_陈寒1 小时前
Java并行流把我坑惨了:原来不是线程安全的!
前端·人工智能·后端
石逸凡1 小时前
AI驱动的金融IT架构转型升级
人工智能·金融·架构
染指11101 小时前
76.高级RAG-后检索器(时间排序)
人工智能·python·llama_index·llamaindex
小雷信息医学1 小时前
5 篇老年队列套路拆解第 3 篇:单库 + U 型新指标 + Cox + RCS 拐点
人工智能·机器学习
少冰1 小时前
从数据清洗到 Docker 部署:我在本地训练了一个中医领域 Qwen3 模型
人工智能
计科土狗1 小时前
GESP六级专题之类与对象
java·前端·数据库
opensnn1 小时前
当闭源AI遇上开源反击:未来竞争的核心不是模型,而是生态
人工智能·开源
anOnion1 小时前
构建无障碍组件之Listbox Pattern
前端·html·交互设计
一碗白开水一1 小时前
入门实践工程九:基于 BERT 的中文情感分类微调~附:安装依赖库及工程源码
人工智能·深度学习·机器学习·自然语言处理·分类·bert
凌涘2 小时前
前端路由(三):鉴权、拦截与重定向
前端