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

0 阅读4分钟

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

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

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 }

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 后端返回的数据格式:

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

import {EventSourceParserStream} from 'eventsource-parser/stream'

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

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

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

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

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

之前:

whlie(true) {
    const {value,done} =await reader.read()
    if(done) break;
}

现在:

      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个月

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

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后面调试总算能用。最后吐槽下,前端的坑是真多,但好在有各种轮子可以抱。(~ ̄▽ ̄)~