慢模型下的流式体验:SSE、超时、配额与降级
如果你的模型只有 2 tok/s,用户体验的瓶颈就不在"回答质量",而在等待。 这篇讲怎么在慢模型上把等待变得可以接受:事件设计、网关配置、进度反馈、配额与降级。
1. 先量出来:慢在哪一段
一次站内问答的耗时构成(实测,2 tok/s 的本地模型):
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 决策轮 | 20~40s | 输出一个 160 token 的 JSON |
| 工具执行 | 1~50ms | 知识库检索 / 哈希计算,可忽略 |
| 第二轮决策 | 0~30s | 只有需要二次检索时才有 |
| 成文轮 | 30~90s | 200 token 的自然语言回答,流式 |
| 合计 | 50~150s | — |
结论:时间几乎全花在模型生成上,工具不是瓶颈。所以优化方向只有两个: 减少模型调用次数(见《工具路由器》),以及让用户在等待中看到进展。
2. SSE 事件设计:让"等待"变成"过程可见"
不要只推最终文本。把过程也推出去:
event: meta {"type":"meta","model":"qwen"} ← 立刻发,建立连接
event: tool {"type":"tool","tool":"kb_search","text":"..."} ← 每调一个工具发一条
event: delta {"type":"delta","text":"Go 的"} ← 成文时逐块
event: done {"type":"done","text":"...","refs":[...]}
event: error {"type":"error","err":"..."}
关键点:
- 第一条事件要快:连接建立后立刻发
meta,否则有些网关会等首字节超时; - 工具事件带参数和摘要:前端能显示"🔧 kb_search(q=goroutine 调度)",用户知道它没卡住;
- done 带来源:用户拿到答案后可以点进去核对原文,这对小模型尤其重要。
前端的伪代码(fetch + ReadableStream,不用 EventSource,因为要 POST):
const res = await fetch("/api/v1/agent/chat", { method: "POST", body });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop() ?? "";
for (const f of frames) {
const line = f.split("\n").find((l) => l.startsWith("data:"));
if (line) handle(JSON.parse(line.slice(5)));
}
}
3. 网关:三个必须显式配置的地方
慢请求 + SSE,nginx 默认配置几乎每一条都会出问题:
location ^~ /api/v1/agent/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_read_timeout 300s; # 默认 60s,慢模型中间空档会超时
proxy_send_timeout 300s;
proxy_buffering off; # 默认 on,SSE 会被攒着一次性吐出
gzip off; # 默认可能对 text/event-stream 压缩,破坏分块
}
再加一个响应头兜底(有些部署里 proxy_buffering 被全局打开):
c.Header("X-Accel-Buffering", "no")
c.Header("Cache-Control", "no-cache")
踩坑记录:上线前我忘了配
proxy_read_timeout,结果 100 秒的回答在 60 秒时被掐断, 前端只显示"网络错误"——而服务端日志里一切正常。
4. 配额与并发闸门:保护那唯一一个核
慢模型的机器通常也很小(我这台是 1 物理核)。如果没有闸门,两个用户同时提问就会把站点拖慢。
// 每 IP 每天 N 次(内存 map,单实例够用)
if _, ok := quota.take(ip, dailyLimit); !ok {
response.Fail(c, 429, 42901, "今日提问次数已用完")
return
}
// 全局并发 1:排队而不是直接失败
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-time.After(30 * time.Second):
response.Fail(c, 503, 50302, "助手正忙,请稍后再试")
return
case <-c.Request.Context().Done():
return // 用户断开,别再浪费算力
}
三个细节:
- 排队而不是拒绝:30 秒内能等到就等,比直接报错体验好;
- 监听
c.Request.Context():用户关掉页面就取消生成,否则白烧 CPU; - 配额按 IP + 按天:重启清零可以接受,别为这个引入 Redis(除非多实例)。
5. 降级:让失败也有价值
慢模型 + 小机器,失败是常态。设计两条降级路径:
| 场景 | 降级 |
|---|---|
| 模型调用超时/报错 | 如果已有工具结果 → 直接用结果成文;否则返回"服务繁忙" |
| 决策轮解析失败 | 拿问题直接检索知识库,用检索结果回答(退化成 RAG) |
| 配额用完 | 返回 429 + 明确的"明天再来",并提示可以用站内搜索 |
第二条最重要:它把"助手挂了"变成"助手变笨了"。用户仍然能拿到知识库里的内容, 只是不再有自然语言组织——这比一个空白错误页好得多。
6. 前端也要为慢做设计
- 输入框禁用 + 显式状态:"思考中…(本地模型较慢,请稍等)",每 10 秒更新一次文案;
- 可中断:按钮从"发送"变成"停下",点了就
AbortController.abort(); - 不要假装很快:页面底部写清"当前模型约 2 token/s,长回答需要等一会",用户的预期管理比优化更有效;
- 历史限制:只带最近 4 条对话进上下文,否则慢模型会被历史撑爆(也拖慢首 token)。
7. 一个可复用的决策表
| 你的情况 | 建议 |
|---|---|
| 模型 ≥ 20 tok/s | 可以做多轮规划,首 token 延迟 < 1s,SSE 可选 |
| 模型 5~20 tok/s | 单轮决策 + 流式成文 + 30s 排队,够用 |
| 模型 < 5 tok/s | 工具路由器 + 确定性短路 + 严格配额,把期望写清楚 |
| 完全不能忍受等待 | 别用本地模型,接云端 API(配置里换个 base_url 即可) |
小结
慢模型的体验问题,本质是信息节奏问题:用户不怕等,怕的是"不知道在等什么"。 把过程事件化(meta → tool → delta)、把超时显式配置、把并发放进闸门、把失败降级成"变笨但可用", 2 tok/s 的模型也能做出不难受的对话体验。反过来,如果只是把接口套壳成一个流式的 ChatGPT 页面, 再快的模型也会让人怀疑"是不是卡了"。