fetch实现类似ChatGPT的stream流式请求

2,832 阅读4分钟

一. 前言

    第一次看到ChatGPT的时候,我的关注点不是它那近乎无所不知的回复功能,而是它呈现内容时的“打字机”效果。顿时,setTimeout、requestAnimationFrame这些字眼不自觉的从脑海中浮现,而且它“打字”的速度不是匀速,时快时慢,有种“抑扬顿挫”的感觉,我心中惊奇,难道这种拟人化的交互效果也在ChatGPT的计算之中吗?我搜索了一番之后,才发现自己想岔了......

    其实是因为大语言模型一边计算一边返回词元,导致视觉上看起来像是“打字机”效果。这其中涉及到了SSE、流式请求等知识点,本文尝试使用fetch实现类似的请求效果。

参考文章和SSE的更多介绍可移步:# 模拟ChatGPT流式数据——SSE最佳实践(附可运行案例)

二. 代码实现

2.1 需求分析

  • 拦截器:发送请求前添加自定义请求头,如token
  • 参数自动序列化:get、post统一传参Object,内部自行转换成url参数
  • 响应内容自动处理:返回的是stream流,内部转换成文本
  • 请求支持终止
  • 超时:这种场景下我感觉不需要设置超时,也可以实现一下

2.2 后端模拟

使用node简单模拟stream响应,新建mockServer.js文件

const http = require("http");

const texts = [
  "轻轻的我走了,",
  "正如我轻轻的来;",
  "我轻轻的招手,",
  "作别西天的云彩。",
];

const server = http.createServer((req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Allow-Headers", "*");
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("Transfer-Encoding", "chunked");

  let index = 0;
  res.write(texts[index]);
  const timer = setInterval(() => {
    index++;
    res.write(texts[index]);
    if (index === texts.length - 1) {
      clearInterval(timer);
      res.end();
    }
  }, 1000);
});

const port = 666;
server.listen(port, () => {
  console.log(`启动: http://localhost:${port}`);
});

启动

node mockServer.js

打开对应地址可以看到页面上每秒返回一句文本。

2.3 前端实现

以下的实现,模拟了axios的request拦截器,传参支持Object类型的params、body

import _ from "lodash";

type TFetchOptions = Omit<RequestInit, "headers" | "body"> & {
  headers?: Record<string, any>;
  params?: Record<string, any>;
  body?: Record<string, any> | BodyInit | null;
  timeout?: number;
};

// 默认配置
const defaultOptions: Partial<TFetchOptions> = {
  headers: {
    "Content-Type": "application/json; charset=utf-8",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  },
  timeout: 60 * 1000,
};

// 拦截器
const requestInterceptors = (options: TFetchOptions) => {
  if (!options.headers) options.headers = {};
  options.headers["token"] = "111";
  return options;
};

// get请求body转化成query
const parseParams = (params?: Record<string, any>) => {
  const searchObj = new URLSearchParams();
  Object.entries(params || {}).forEach(([key, value]) => {
    searchObj.append(key, value);
  });
  const query = searchObj.toString();
  return query ? `?${query}` : "";
};

// fetch封装
const baseFetch = (
  url: string,
  fetchOptions?: TFetchOptions,
) => {
  // 处理request配置
  let options = _.merge(
    defaultOptions,
    fetchOptions
  ) as TFetchOptions;
  options = requestInterceptors(options);
  
  // 处理request参数
  let urlFix = url;
  if (_.toUpper(options.method) === "GET") {
    fetchOptions?.params && (urlFix = url + parseParams(fetchOptions.params));
  } else {
    fetchOptions?.body && (options.body = JSON.stringify(fetchOptions.body));
  }
 
  return globalThis.fetch(urlFix, options as RequestInit)
};

// 导出post请求
export const postStream = (url: string, body?: Record<string, any>) => {
  return baseFetch(url, { method: "post", body });
};

发送请求试试

const body = [{ role: "user", content: "写一句诗" }];
const response = await postStream("http://localhost:666", body);
const reader = response.body?.getReader();
while (reader) {
  const chunk = await reader.read();
  console.log(chunk);
  if (chunk?.done) {
    break;
  }
}

查看请求结果

image.png

类似迭代器的返回格式,返回值是字节流,需要使用TextDecoder转换成文本。

如果转换的过程在封装的函数内部进行,而接口又是持续性的,不能直接return数据,所以采用callback函数的方式return数据。

下面的代码把中断请求的逻辑也一起实现了,借助 AbortController API。

type TResponseCallback = {
  onData?: (data: any) => void; // chunk的回调
  onEnd?: () => void; // 传输完毕的回调
  onError?: (err: any) => void; // 错误的回调
  setAbortController?: (controller: AbortController) => void; // 获取中止控制器的回调
  onAborted?: () => void; // 中止的回调
};

// fetch封装
const baseFetch = (
  url: string,
  fetchOptions?: TFetchOptions,
  callback?: TResponseCallback
) => {
  // 取消请求的控制器
  const abortController = new AbortController();
  callback?.setAbortController?.(abortController);

  // 处理request配置
  let options = _.merge(
    { signal: abortController.signal },
    defaultOptions,
    fetchOptions
  ) as TFetchOptions;
  options = requestInterceptors(options);

  // 处理request参数(略...)

  // 响应处理
  return globalThis
    .fetch(urlFix, options as RequestInit)
    .then((res) => {
      const response = res.clone();
      return /^(2|3)\d{2}$/.test(String(res.status))
        ? handleStream(response, callback)
        : handleError(response, callback);
    })
    .catch((err) => {
      if (err.name === "AbortError") {
        callback?.onAborted?.();
      } else {
        callback?.onError?.(err);
      }
      callback?.onEnd?.();
    });
};

// 处理错误
const handleError = (response: Response, callback?: TResponseCallback) => {
  if (response.status === 401) {
    window.location.href = "/login";
  }
};

// 处理流式数据
const handleStream = async (
  response: Response,
  callback?: TResponseCallback
) => {
  if (!response.ok) {
    throw new Error("服务出错");
  }
  const reader = response.body?.getReader();
  const textDecoder = new TextDecoder("utf-8");
  while (reader) {
    const chunk = await reader.read();
    if (chunk?.done) {
      callback?.onEnd?.();
      break;
    }
    const chunkText = textDecoder.decode(chunk?.value);
    callback?.onData?.(chunkText);
  }
};

export const postStream = (
  url: string,
  body?: Record<string, any>,
  callback?: TResponseCallback
) => {
  return baseFetch(url, { method: "post", body }, callback);
};

上面的代码主要有两个坑:

  1. fetch对于400和500的响应不会reject,而是正常解析,在处理响应数据时需要注意。
  2. 处理响应数据时调用了clone方法,这样做可以避免一些意外情况的出现。在流式请求中,使用了response对象,该对象就会被消耗,无法再次使用。clone之后再使用,不会影响到原始响应数据。

最后一点,超时可以使用Promise.race实现。

以下是完整代码

三. 使用示例

以下是在react中的使用示例

import React, { useState } from "react";
import { postStream } from "./fetchStream";

export default function Test() {
  const [loading, setLoading] = useState(false);
  const [text, setText] = useState("");
  const [abortController, setAbortController] = useState<AbortController>();

  const onSend = async () => {
    if (loading) return;
    const body = [{ role: "user", content: "写一句诗" }];
    setLoading(true);
    postStream("http://localhost:666", body, {
      setAbortController,
      onData(data) {
        console.log("响应数据: ", data);
        setText(pre => pre + data);
      },
      onEnd() {
        console.log("结束");
        setLoading(false);
      },
      onError(err) {
        console.log("错误: ", err);
      },
      onAborted() {
        console.log("终止");
      },
    });
  };

  const onStop = () => {
    abortController?.abort();
  };

  return (
    <div style={{ padding: 100 }}>
      <div>
        <button onClick={onSend}>发送请求</button>
        &nbsp;&nbsp;
        <button onClick={onStop}>终止请求</button>
      </div>
      <div>{loading && "加载中..."}</div>
      <div>{text}</div>
    </div>
  );
}