最近刚处理完一个挺常见的问题:
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。而是应该在请求前就知道这是什么类型的