💡 从"傻等"到"流淌":我在AI项目中实现流式输出的血泪史(附真实代码+深度解析)

一、那个让我想删代码的午后

"去年实习时,我正在开发一个AI消息回答模块,用户等了5~6秒才看到回答,产品经理拍说:'能不能像deepseek那样字一个一个蹦出来?,我心想这不是打字机效果吗,说实话,我一直以为打字机效果是前端模拟,使用setInterval轮询就行了。然后自己开发时,先试着写了个打字机:

ini 复制代码
let index =0
// 模拟事件流
const events = [
  { data: '{"data": {"answer": "你"}}' },
  { data: '{"data": {"answer": "好"}}' },
  { data: '{"data": true}' }
];

let index = 0;
const timer = setInterval(() => {
  if (index >= events.length) {
    clearInterval(timer);
    return;
  }
  const event = events[index++];
  const val = JSON.parse(event.data);
  if (typeof val.data === 'boolean' && val.data) {
    console.log('结束');
    clearInterval(timer);
    return;
  }
  if (val.data?.answer) {
    document.getElementById('output').innerText += val.data.answer;
  }
}, 200);

但是这种方法在生产环境上是错的。因为定时器是间隔触发,网络延迟为0,真实流式输出网络不确定,块到达时间可能不均与,大小也不固定,还有太多问题了,不能展示流式输出优势,用户体验也不好。

二、真相:后端在"流",前端在"接"

****deepseek的"流淌"不是前端模拟的,而是后端一边生成一边发送(像水龙头流水,不是水桶装满再倒)。

学习{ stream: true }

ini 复制代码
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value,{ stream: true }) ;

这行代码的作用是从网络流中读取的二进制数据块(value)解码为字符串。{ stream: true } 参数是最为重要的,它告诉解码器当前的数据块只是整个流式数据的一部分,不完整,要保留后拼接。 我出于好奇,试了下不加{ stream: true },结果出现了乱码,原因是UTF-8里一个汉字占3字节,假如第一个chunk 只返回了两个字节,第二个chunk返回了一个字节。解码器认为每个块都是独立完整的,不完整就报错。所以需要设置 { stream: true }来缓存后拼接

三、SSE格式的真相:为什么需要EventSourceParserStream?

介绍一个库,EventSourceParserStream 后端返回的数据格式:

kotlin 复制代码
data: {"data": {"answer": "你"}}
data: {"data": {"answer": "好"}}
data: {"data": true} // 结束标志

问题来了:为什么不能直接用JSON.parse

  • SSE格式不是纯JSON !它是data: 前缀+JSON的格式
  • 网络传输是流式,可能把一条消息切成多段
  • 需要解析data: 前缀,提取JSON内容 既然不是JSON 格式的字符串,自然不能直接使用JSON.parse转为对象

为什么我选择EventSourceParserStream?

当然是这个库好用嘛。SSE的边界也多,省的自己写解析器了。

  1. 自动去除data: 前缀
  2. 处理\n\n分隔符
  3. 处理不完整的JSON片段

代码演示

npm install --save eventsource-parser

javascript 复制代码
import {EventSourceParserStream} from 'eventsource-parser/stream'

const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .pipeThrough(new EventSourceParserStream())

四、为什么需要手动解析JSON?(SSE的真相)

SSE协议规定:data: 是前缀,后面才是数据。EventSourceParserStream会自动把data: 去掉,只留下JSON字符串

kotlin 复制代码
//通过EventSourceParserStream处理后,event.data是个纯json字符串
cosnt val = JSON.parse(event.data)

五、终极优化:用 for await 让代码更优雅

之前:

javascript 复制代码
whlie(true) {
    const {value,done} =await reader.read()
    if(done) break;
}

现在:

kotlin 复制代码
      for await(const event of reader){
        try{
            const val =JSON.parse(event.data);
            const d=val.data;
  
            //结束标志 :看到true就结束(与后端定好)
            if(typeof d ==="boolean" && d){
                setDone(true);
                break;
            if(d.answer){
               setAnsewr(d);
                }
            }
        
        }
        catch{
        //错误处理
    }
}

为什么 for await 更好?

  1. 自动处理流结束 (不用手动检查 done
  2. 代码更简洁(少写20%的样板代码)
  3. 符合ES2020规范(现代浏览器完全支持)

七、最终代码:已经上线6个月

这里贴一段,真实项目代码:

typescript 复制代码
import { EventSourceParserStream } from 'eventsource-parser/stream';

const send = useCallback(
  async (
    body: any,
    controller?: AbortController,
  ): Promise<{ response: Response; data: ResponseType } | undefined> => {
    // 关键1:初始化请求取消控制器
    initializeSseRef();
    
    //  关键2:重置完成状态
    setDone(false);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          [Authorization]: getAuthorization(),
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: controller?.signal || sseRef.current?.signal,
      });

      // 500错误直接处理
      if (response.status === 500) {
        message.error(i18n.t('message.500'));
        setDone(true);
    
        return;
      }

      // 核心:管道式处理 + for await
      const stream = response.body
        .pipeThrough(new TextDecoderStream()) //二进制-> 字符串
        .pipeThrough(new EventSourceParserStream());// 字符串 -> SSE 对象
      // 关键:用for await循环处理流(现代写法!)
      for await (const event of stream) {
        try {
          const val = JSON.parse(event.data);
          const d = val.data;
          
          // 结束标志:看到true就结束
          if (typeof d === 'boolean' && d) {
            setDone(true);
         
            break;
          }
          
          // 处理普通内容
          if (d?.answer && d.answer.trim() !== '') {
            setAnswer({
              ...d,
              conversationId: body?.conversation_id,
            });
          }
        } catch (e) {
          console.error('解析失败', e);
        }
      }

      setDone(true);

      return { data: await response.clone().json(), response };
    } catch (e) {
      setDone(true);
   
      console.error('请求失败', e);
    }
  },
  [initializeSseRef, url], //  依赖项这么少的原因
);

//创建控制器
const sseRef =useRef(null)
// 初始化:每次新请求取消旧的请求
const initializeSseRef = (){
    if (sseRef.current) { sseRef.current.abort(); // ✅ 关键!取消旧请求 }
    sseRef.current =new AbortController();
}

我的成长

写这个Hook的时候,我一开始以为加个setTImeout,后来老老实实啃了SSE协议,解决了中文乱码,又发现了EventSourceParserStream这个库,不用自己写解析器,代码从while改成了for await of,清爽了不少。 最后把sendanswerdone暴漏出去了。Hook后面调试总算能用。最后吐槽下,前端的坑是真多,但好在有各种轮子可以抱。(~ ̄▽ ̄)~

相关推荐
bluceli1 小时前
前端性能优化实战指南:让你的网页飞起来
前端·性能优化
SuperEugene1 小时前
Vue状态管理扫盲篇:如何设计一个合理的全局状态树 | 用户、权限、字典、布局配置
前端·vue.js·面试
没想好d1 小时前
通用管理后台组件库-9-高级表格组件
前端
阿虎儿2 小时前
React Hook 入门指南
前端·react.js
核以解忧2 小时前
借助VTable Skill实现10W+数据渲染
前端
WangHappy2 小时前
不写 Canvas 也能搞定!小程序图片导出的 WebView 通信方案
前端·微信小程序
李剑一2 小时前
要闹哪样?又出现了一款新的格式化插件,尤雨溪力荐,速度提升了惊人的45倍!
前端·vue.js
闲云一鹤2 小时前
Git LFS 扫盲教程 - 你不会还在用 Git 管理大文件吧?
前端·git·前端工程化
阿虎儿3 小时前
React Context 详解:从入门到性能优化
前端·vue.js·react.js