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 后所有内容攒到最后一次性输出。
原因:三层缓冲叠加——
- Nginx
proxy_buffering默认开启,把响应攒到结束才吐 - gzip 压缩需要凑够缓冲区才输出
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 状态不入上下文 |
