LLM 推理延迟优化:流式输出与 Token 级缓存的前端渲染加速方案
一、3秒白屏的死亡地带:LLM 应用的用户体验困局
调用 GPT-4 生成一篇 800 字的回复,完整响应耗时约 5 秒。如果等待全部 token 生成完毕再渲染,用户盯着白屏或转圈动画整整 5 秒——这是交互设计的死亡地带。用户期望的感知响应时间在 200ms 以内,超过 1 秒就开始产生焦虑。
一个常见的产品数据:在 AI 聊天应用中,第一字节到达时间(TTFB, Time To First Byte)每增加 1 秒,用户留存率下降 7%。解决方案有两个层面:后端实现真正的流式输出(Server-Sent Events),前端实现流式渲染——逐 token 渐进式显示。更进一步,Token 级缓存能大幅减少重复请求的延迟。
二、流式输出的全链路架构
sequenceDiagram
participant U as 用户浏览器
participant FE as 前端渲染引擎
participant GW as API Gateway
participant LLM as LLM 服务
participant C as Token 缓存
U->>FE: 发送消息
FE->>GW: POST /chat (stream=true)
GW->>C: 查询缓存(消息哈希)
alt 缓存命中
C–>>GW: 返回缓存的 token 流
else 缓存未命中
GW->>LLM: 发起流式请求
LLM–>>GW: SSE 事件流: token1
LLM–>>GW: SSE 事件流: token2
GW->>C: 写入缓存(异步)
end
GW–>>FE: SSE 事件流
FE->>FE: 逐个 token 追加到 DOM
FE–>>U: 逐字渲染文本
真正的延迟改善体现在三个环节:网络首包(SSE 推送第一个 token)、渲染首帧(前端展示第一个字)、缓存命中(跳过 LLM 调用)。
2.1 后端:SSE 流式推送
// SSE 流式处理 handler,带断线重连和超时保护
func (h *ChatHandler) HandleSSE(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "不支持流式传输", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// 先发送连接确认事件,让前端知道流已建立
fmt.Fprintf(w, "event: connected\\ndata: {}\\n\\n")
flusher.Flush()
tokenChan := make(chan string, 64)
errChan := make(chan error, 1)
go h.llmClient.StreamChat(ctx, r.Body, tokenChan, errChan)
for {
select {
case token, ok := <-tokenChan:
if !ok {
fmt.Fprintf(w, "event: done\\ndata: {}\\n\\n")
flusher.Flush()
return
}
payload, _ := json.Marshal(map[string]string{"t": token})
fmt.Fprintf(w, "data: %s\\n\\n", payload)
flusher.Flush()
case err := <-errChan:
payload, _ := json.Marshal(map[string]string{
"error": err.Error(),
})
fmt.Fprintf(w, "event: error\\ndata: %s\\n\\n", payload)
flusher.Flush()
return
case <-ctx.Done():
fmt.Fprintf(w, "event: timeout\\ndata: {}\\n\\n")
flusher.Flush()
return
}
}
}
关键点是 X-Accel-Buffering: no:Nginx 默认缓冲 SSE 响应,会导致前端收不到逐 token 的事件,所有内容一次性到达——这就失去了流式的意义。
2.2 前端:Token 级渲染
class StreamingRenderer {
private buffer = '';
private container: HTMLElement;
private renderTimer: number | null = null;
constructor(containerId: string) {
const el = document.getElementById(containerId);
if (!el) throw new Error(`容器元素 #${containerId} 不存在`);
this.container = el;
}
// 使用 requestAnimationFrame 批量渲染,避免高频 DOM 操作
appendToken(token: string): void {
this.buffer += token;
if (this.renderTimer !== null) return;
this.renderTimer = requestAnimationFrame(() => {
// 转为 textContent 操作而非 innerHTML,避免 XSS 和解析开销
const textNode = document.createTextNode(this.buffer);
this.buffer = '';
this.container.appendChild(textNode);
this.renderTimer = null;
// 自动滚动到底部
this.container.scrollTop = this.container.scrollHeight;
});
}
destroy(): void {
if (this.renderTimer !== null) {
cancelAnimationFrame(this.renderTimer);
}
}
}
requestAnimationFrame 限制渲染频率在 60 FPS,避免每个 token 都触发一次 DOM 操作。实测 1000 个 token 的输出场景,批量渲染比逐个 textContent += token 的 CPU 占用降低了 40%。
三、Token 级缓存的工程实现
LLM 推理的核心成本在于重复计算。用户提问"Python 的 GIL 是什么",100 个不同用户可能收到相同的回复。语义级缓存——对 prompt 做哈希——可以跳过 LLM 调用。
// 基于语义哈希的 Token 级缓存
type TokenCache struct {
store *redis.Client
ttl time.Duration
minScore float64 // 最小相似度阈值
}
func (c *TokenCache) Get(
ctx context.Context,
prompt string,
) ([]string, bool, error) {
hash := semanticHash(prompt)
key := fmt.Sprintf("token_cache:%s", hash)
tokens, err := c.store.LRange(ctx, key, 0, -1).Result()
if err != nil {
return nil, false, fmt.Errorf("缓存查询失败: %w", err)
}
if len(tokens) > 0 {
return tokens, true, nil
}
// 模糊匹配:用语义向量搜索相似 prompt
similar, err := c.findSimilar(ctx, prompt)
if err != nil || similar.score < c.minScore {
return nil, false, err
}
return similar.tokens, true, nil
}
func (c *TokenCache) Set(
ctx context.Context,
prompt string,
tokens []string,
) error {
hash := semanticHash(prompt)
key := fmt.Sprintf("token_cache:%s", hash)
pipe := c.store.Pipeline()
pipe.Del(ctx, key) // 先清除旧值
for _, t := range tokens {
pipe.RPush(ctx, key, t)
}
pipe.Expire(ctx, key, c.ttl)
_, err := pipe.Exec(ctx)
return err
}
缓存的命中率取决于场景:FAQ 类问答可达 60-80%,开放式创作只有 5-15%。TTL 需要与内容时效性匹配——技术文档缓存 24 小时,新闻摘要缓存 1 小时。
四、边界与权衡
流式输出的网络代价:SSE 是长连接,占用服务器连接资源。1000 并发 SSE 连接与 1000 并发短连接对服务器的影响不同。Nginx 的 worker_connections 需要同步调整。
缓存一致性问题:LLM 模型更新后,旧缓存可能产出过时的回复。建议在缓存 key 中加入模型版本号:token_cache:v2:sha256-hash。
前端的复杂状态:流式渲染中断(网络波动、用户取消)时,需要清理已渲染的部分并恢复 UI 状态。这个恢复逻辑往往比正常渲染逻辑更复杂。
禁用缓存的场景:
- 回复内容具有实时性(如查询当前天气)
- 用户明确要求"创意"或"新鲜"的回复
- 涉及用户个性化数据(历史、偏好)
五、总结
LLM 推理延迟优化是一个全栈命题:后端 SSE 流式推送、前端 requestAnimationFrame 批量渲染、缓存层语义哈希去重。三者在一条链路上协同工作——任何一个环节的阻塞都会体现在用户感知的延迟上。
落地的先后顺序:先启用 SSE 流式输出(后端改造最少,收益最明显),再实现前端 Token 级渲染(用户体验提升最大),最后加缓存层(成本节约最可观)。缓存策略从精确匹配开始(prompt 哈希),数据积累后再引入语义相似度匹配。每个阶段用 RUM(Real User Monitoring)数据验证效果,不要凭直觉判断。




