慢模型下的流式体验:SSE、超时、配额与降级

#Agent实战

慢模型下的流式体验:SSE、超时、配额与降级

如果你的模型只有 2 tok/s,用户体验的瓶颈就不在"回答质量",而在等待。 这篇讲怎么在慢模型上把等待变得可以接受:事件设计、网关配置、进度反馈、配额与降级。

1. 先量出来:慢在哪一段

一次站内问答的耗时构成(实测,2 tok/s 的本地模型):

阶段耗时说明
决策轮20~40s输出一个 160 token 的 JSON
工具执行1~50ms知识库检索 / 哈希计算,可忽略
第二轮决策0~30s只有需要二次检索时才有
成文轮30~90s200 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":"..."}

关键点:

  1. 第一条事件要快:连接建立后立刻发 meta,否则有些网关会等首字节超时;
  2. 工具事件带参数和摘要:前端能显示"🔧 kb_search(q=goroutine 调度)",用户知道它没卡住;
  3. 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   // 用户断开,别再浪费算力
}

三个细节:

  1. 排队而不是拒绝:30 秒内能等到就等,比直接报错体验好;
  2. 监听 c.Request.Context():用户关掉页面就取消生成,否则白烧 CPU;
  3. 配额按 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 页面, 再快的模型也会让人怀疑"是不是卡了"。

笔记加载中…