ATRULE 技术博客ATRULE 技术博客
首页
博客
文档
关于
首页
博客
文档
关于
  • 技术文章

    • VuePress 2.x 完全指南
    • Gemini3 & GPT5 & DeepSeek3:AI时代程序员的身份转变
    • Agent 架构选型指南:单智能体 vs 多智能体
    • Agent 工程师转型学习路线图 (Full-Stack to Agent Engineer)
    • Agent 工程师学习笔记
    • 📖 Agent 系统架构与工程实践面试速记卡片
    • Agent 流式对话前端踩坑记录
    • RAG 检索工程问题记录
    • Agent 后端问题记录(Koa / MongoDB)
    • LLM 调用与 Agent 编排问题记录
    • Electron 桌面端问题排查记录
    • AI Agent 全栈开发问题排查记录
    • ES2026 与 TypeScript 进阶问题记录
    • 浏览器渲染与 V8 内存问题记录
    • React 19 与 Vue 4 新内核问题记录
    • 前端工程化与微前端问题记录
    • 性能、安全与可观测性问题记录
    • 前端架构实战问题记录
  • 项目实战

    • Node.js + Koa 抖音直播弹幕Agent 五大核心模块(面试项目完整版,TS技术栈)★★★★★
    • Poiclaw 项目蓝图:自主编程实体
    • AI旅行助手

Agent 流式对话前端踩坑记录

记录 AI Agent 流式对话(SSE)在前端落地时踩过的坑,每个问题都是实际遇到过并解决掉的。

问题一:汉字被网络分片劈成两半,JSON.parse 直接报错

现象:流式输出中文时偶发 JSON.parse 异常或页面出现乱码,本地复现不了。

原因:一个汉字的 UTF-8 是 3 个字节,TCP 分片完全可能把一个字劈在两个 chunk 里。直接对每个 chunk 做 decode + parse 就会炸。

解决:按 SSE 事件边界(\n\n)累积 buffer,而不是按 TCP chunk 处理;解码用支持流式的 decoder:

const decoder = new TextDecoder('utf-8')
let buffer = ''

function handleChunk(bytes: Uint8Array) {
  // stream: true 让 decoder 自己缓存跨 chunk 的残尾字节
  buffer += decoder.decode(bytes, { stream: true })

  // 按完整事件边界切,半截事件留在 buffer
  const events = buffer.split('\n\n')
  buffer = events.pop() ?? '' // 最后一段可能不完整

  for (const event of events) {
    const data = event.replace(/^data:\s*/, '')
    if (data) render(JSON.parse(data))
  }
}

延伸:emoji 是 4 字节代理对,同样会被劈开——stream: true 能救。但如果服务端自己按 token 数切流,emoji(2 个 token)会被服务端劈坏,这是服务端的锅,前端救不了。

问题二:3000 token 输出完,页面越来越卡

现象:长回复生成过程中页面滚动卡顿,输入框响应变慢。

原因:每收到一个 token 就更新响应式状态(Pinia/useState),触发组件重渲染 + DOM diff。3000 次 setState/commit,主线程被打满。

解决:用 ref 存中间态 + requestAnimationFrame 攒批,每帧只 commit 一次;流式消息拆成独立组件,不牵连历史列表重渲染。

// React 版:token 进 buffer,rAF 攒批
const bufferRef = useRef('')
let scheduled = false

function onToken(token: string) {
  bufferRef.current += token
  if (scheduled) return
  scheduled = true
  requestAnimationFrame(() => {
    scheduled = false
    streamingMsgRef.current.content += bufferRef.current
    bufferRef.current = ''
  })
}

Vue3 同理:流式中的消息用 shallowRef 存数组引用,高频 token 只改最后一条消息对象的属性,避免深层 Proxy 监听大数组。

问题三:本地丝滑,上线后"憋 5 秒一次性刷屏"

现象:开发环境流式正常,部署到 Nginx 后所有内容攒到最后一次性输出。

原因:三层缓冲叠加——

  1. Nginx proxy_buffering 默认开启,把响应攒到结束才吐
  2. gzip 压缩需要凑够缓冲区才输出
  3. proxy_read_timeout 默认 60s,长回复可能中途被掐

解决(Nginx 配置):

location /api/agent/ {
  proxy_buffering off;
  proxy_cache off;
  proxy_read_timeout 300s;
  # SSE 响应跳过 gzip(在应用层compress中间件里也要跳过 text/event-stream)
  gzip off;
}

Koa 侧同时要处理:compress() 中间件对 text/event-stream 跳过压缩,否则应用层也会缓冲。

问题四:页面开了 3 条 SSE 后,其他请求全部变慢

现象:Agent 页面有主对话 + 检索状态 + 通知推送 3 条 SSE 长连接,之后点击任何按钮请求都卡住排队。

原因:HTTP/1.1 下浏览器对同域并发连接数上限是 6 条(Chrome)。SSE 是常驻连接,3 条 SSE 占了半壁江山,普通请求被饿死排队。

解决:

  • 流式连接走独立子域名(stream.example.com),让浏览器认为是不同域
  • 根本方案:上 HTTP/2,单 TCP 连接多路复用,没有 6 条限制

问题五:用户点"停止生成",token 还在烧钱

现象:前端点了停止,UI 停了,但后端日志显示 LLM 还在继续生成。

原因:AbortController.abort() 只停了前端接收。后端收到连接断开,如果没有把取消信号传给上游 LLM Provider,模型照常生成、照常计费。

解决:取消必须端到端传递:

前端 abort → 后端 ctx.req 'close' 事件 → 调 LLM SDK 的 stream.cancel()
→ 中断消息落库标记 status: 'stopped' + 记录输出 offset
→ 下一轮对话构建 prompt 时过滤 stopped 消息(防止半截内容污染上下文)

小结

问题一句话方案
中文乱码按事件边界累积 buffer + TextDecoder stream 模式
页面卡顿ref 缓冲 + rAF 攒批,流式组件独立渲染
上线刷屏proxy_buffering off + gzip 跳过 SSE
请求饿死子域名隔离或 HTTP/2
停止不彻底abort 端到端传递 + stopped 状态不入上下文
最后更新: 2026/9/5 17:19
Prev
📖 Agent 系统架构与工程实践面试速记卡片
Next
RAG 检索工程问题记录