🎯《前线业务开发02:stream流式接口获取数据实现打字机效果》

773 阅读4分钟

🚩第一章:需求分析——AI聊天各家打字机效果统一解 - stream流式传输

效果拆解

ai回复时文字规模过大,需要一个方法来让已经生成的文字提前显示在页面上,制造出正在逐渐打字出来的效果

✈️第二章:流式输出介绍

纵观所有ai对话应用,使用打字机效果的,都使用了流式传输的方法,即contentType为text/event-stream的http请求

image.png

image.png

image.png

注意:这种方式与EventSource Api不同,虽然后端都是sse,但是EventSource Api无法传递参数,而使用text/event-stream的http请求,请求方式与普通请求无异,只在接受响应时需要特殊处理。

传统HTTP请求遵循请求-响应全量交付模式(图1),服务器必须生成完整响应后才能返回数据。这种模式在AI长文本生成场景中面临两大瓶颈:

  • 高延迟:用户需等待全部内容生成完成(如30秒生成1000字)
  • 资源浪费:大文本传输易受网络波动影响,重传成本极高

而流式传输通过分块传送实现技术突围:

[客户端] --提问--> [服务器]

├── chunk1 (200ms) → 即时渲染
├── chunk2 (400ms) → 增量更新
└── chunkN (...) → 持续拼接
具体实现方式如下

xhr:

const xhrApi = () => {
  const xhr = new XMLHttpRequest();
  // 修改:正确使用 open 方法
  xhr.open('post', 'http://dify.wujialin.top/v1/chat-messages', true);
  // 修改:使用 setRequestHeader 设置请求头
  xhr.setRequestHeader('Content-Type', 'application/json');
  xhr.setRequestHeader('Authorization', 'Bearer app-7vsaRmKs8yG87fNQ4KDB1ZBE');
  const requestBody = JSON.stringify({
    "inputs": {
      "agent_id": '100407',
      "language": "en"
    },
    "query": "What are the specs of the iPhone 13 Pro Max?",
    "response_mode": "streaming",
    "conversation_id": "",
    "user": "abc-123",
  });
  xhr.responseType = 'text';
  xhr.onreadystatechange = function () {
    if (xhr.readyState === XMLHttpRequest.OPENED) {
      // 当请求已打开时,可在此处进行一些初始化操作
    }
    if (xhr.readyState === XMLHttpRequest.LOADING) {
      // 当数据正在接收时,处理接收到的部分数据
      const chunk = xhr.responseText;
      console.log(chunk);
    }
    if (xhr.readyState === XMLHttpRequest.DONE) {
      if (xhr.status === 200) {
        // 请求成功完成
        console.log('Request completed successfully');
      } else {
        // 请求失败
        console.error('Request failed with status:', xhr.status);
      }
    }
  };
  // 修改:使用 send 方法发送请求体
  xhr.send(requestBody);
  return () => {
    xhr.abort();
  };
}

fetch:

const fetchApi = async () => {
  const controller = new AbortController();
  const signal = controller.signal;

  try {
    // 修改:添加请求参数
    const response = await fetch('http://dify.wujialin.top/v1/chat-messages', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer app-7vsaRmKs8yG87fNQ4KDB1ZBE'
      },
      body: JSON.stringify({
        "inputs": {
          "agent_id": '100407',
          "language": "en"
        },
        "query": "What are the specs of the iPhone 13 Pro Max?",
        "response_mode": "streaming",
        "conversation_id": "",
        "user": "abc-123",
      }),
      signal
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    while (true) {
      const { done, value } = await reader?.read() as ReadableStreamReadResult<Uint8Array>;
      if (done) break;
      const chunk = decoder.decode(value);
      console.log(chunk);
    }
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      console.log('Fetch aborted');
    } else {
      console.error('Fetch error:', error);
    }
  }

  return () => {
    controller.abort();
  };
}

这边主要介绍一下fetch的流式传输,fetch会响应一个类型为ReadableStream的可读流,该流通过分块(chunk)逐步传输数据,避免一次性加载全部内容到内存。这种机制通过getReader()方法获取流读取器,逐块读取数据并异步处理。但是需要注意的是,流的分块可能与业务逻辑的数据单元(如JSON对象)不一致,需设计缓冲区或使用分隔符解析。这边给出一个示例缓冲写法


  const decoder = new TextDecoder('utf-8')
  const reader = response.getReader()
  let buffer = ''
  let bufferObj: Record<string, any>
  const read = () => {
    let hasError = false
    reader
      ?.read()
      .then((result: any) => {
        if (result.done) {
          console.log('done')
          return
        }
        buffer += decoder.decode(result.value, { stream: true })
        const lines = buffer.split('\n')
        // console.log(lines, 'line')
        try {
          lines.forEach((message) => {
            if (message.startsWith('data: ')) {
              // check if it starts with data:
              try {
                bufferObj = JSON.parse(message.substring(6)) as Record<string, any> // remove data: and parse as json
              } catch (e) {
                // mute handle message cut off
                // console.warn(e)
                return
              }
              console.log(bufferObj)
            }
          })
          buffer = lines[lines.length - 1]
        } catch (e: any) {
          hasError = true
          console.warn(e)
          return
        }
        if (!hasError) read()
      })
      .catch((err) => {
        console.warn(err)
      })
  }
  read()

利用每一块之间的换行符进行分割,分割后若有多余块信息存进buffer

🏁第三章 总结

技术收获

对比一下三种长连接、类长连接的方式:

协议特点适用场景
SSE(Event Source Api)基于HTTP长连接,服务端单向推送,轻量级文本流(如聊天消息、AI回复)
WebSocket双向全双工通信,支持二进制数据,协议复杂实时游戏、协同编辑、音视频流
HTTP Chunked(text/event-stream)原生分块传输,无需额外协议,兼容性强简单文本/JSON流(如API逐段输出)

流式传输的核心是数据分块、按序推送、实时拼接,需权衡延迟、吞吐与可靠性。SSE适合轻量级文本流,WebSocket用于复杂交互,HTTP Chunked可作为兜底方案。关键难点在于异常处理与性能优化,需结合场景针对性设计。

📢📢📢预告

全新的文章系列——「前线业务开发」。这个系列将聚焦于笔者自己真实业务中的真实需求,分享从需求分析、技术选型到最终实现的完整过程。每一篇文章都将包含:

  • 业务场景还原:还原真实需求背景,明确技术挑战。
  • 技术方案探索:从多个角度分析可能的解决方案,记录踩坑与优化过程。
  • 代码实现与优化:提供可直接复用的代码,并分享性能优化技巧。
  • 总结与思考:提炼通用解法,为类似场景提供参考。

当然,笔者还是小牛马,更新时间不定请谅解🫡🫡🫡🫡🫡大家有想分享的解决的没解决的需求,也可以私信我一起探讨