掘金文章归档 · 第 9 篇 AI 流式系列 4/4 React / Markdown

实现 markdown 格式流式输出:react-markdown + remark-gfm

定时器伪流式逐字渲染 → markdown-body 表格样式 → fetch-event-source 真实 SSE 流式

原文作者:随意_(掘金) | juejin.cn/post/7416525113174540339 | 发布于 2024-09-20 | 阅读 5,330 · 约 9 分钟

0背景:md 格式的流式输出怎么做

我们在大模型聊天回复中,常借助 SSE 来实现流式询问回复,返回的格式以 md 格式为主。我们如何流式地输出 md 格式?借助 react-markdown 可轻松实现。

本文先用定时器模拟流式,如想真实后端请求数据流,文末也会给出相应代码。

截图位置请对照原文:markdown 内容(含表格)逐字"打字机"式渲染出来的最终效果。

1伪流式代码:定时器模拟逐字输出

import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useImmer } from "use-immer";

// 演示用的 markdown 内容(含标题、加粗、表格)
const markdownstr = `
大模型的参数量随着技术的不断进步和研究的深入而不断增加。目前,参数量最多的大模型之一是**Grok-1**,它是一款由xAI团队开发的混合专家模型,参数量达到了3140亿,被公认为"迄今为止全球参数量最大的开源大语言模型"。这一数字相较于其他知名大模型,如OpenAI的GPT-3(参数量为1750亿)有显著的提升。

接下来,我们可以对几个著名大模型的参数量进行对比:

| 模型名称 | 参数量(亿) | 备注/特点                           |
|----------|-------------|-------------------------------------|
| GPT-3    | 1750        | OpenAI推出,自然语言处理领域表现优异 |
| T5       | 11          | Google Brain推出,语言生成能力优秀    |
| BERT     | (未具体提及,但远小于GPT-3) | 基于Transformer的双向语言模型,NLP领域代表作 |
| Grok-1   | 3140        | 迄今为止全球参数量最大的开源大语言模型 |

需要注意的是,虽然大模型的参数量是衡量其规模和复杂度的一个重要指标,但并不意味着参数量越多的模型在所有任务上的表现都一定更好。模型的性能还受到训练数据、算法设计、计算资源等多种因素的影响。

此外,随着技术的不断发展和创新,未来大模型的参数量有可能会继续增加,同时也会出现更多在特定任务上表现更优的模型。因此,在选择和使用大模型时,需要根据具体任务的需求和场景进行综合考虑。
`;

function Test() {
  const [targetString, setTargetString] = useImmer<string>("");

  const streamString = (source: string, index = 0) => {
    if (index < source.length) {
      // 将当前字符添加到目标字符串
      setTargetString((darf: string) => {
        return darf + source[index];
      });

      // 递归调用,延迟一定时间(例如100毫秒)
      setTimeout(() => {
        streamString(source, index + 1);
      }, 10); // 这里的10毫秒是模拟的"流速"
    }
  };

  useEffect(() => {
    streamString(markdownstr, 0);
  }, []);

  return (
    <div
      style={{ whiteSpace: "pre-wrap", width: "60%" }}
      className="markdown-body"
    >
      <ReactMarkdown remarkPlugins={[remarkGfm]}>{targetString}</ReactMarkdown>
    </div>
  );
}
export default Test;
📌 核心思路:useImmer 管理累计字符串,递归 + setTimeout 每 10ms 追加一个字符,ReactMarkdown 每次都把"半成品"md 渲染一遍——所以标题、加粗、表格是边拼边渲染出来的。

2样式:markdown-body 表格优化

部分 md 格式的样式(如表格)没有展示出对应的格式,我们可以写一套样式,并在首页引入:

import "@/styles/index.css";
.markdown-body {
  padding: 16px 4%;
  word-break: break-word;
  line-height: 1.75;
  font-weight: 400;
  font-size: 16px;
  overflow-x: hidden;
  color: #252933;
  table {
    display: inline-block !important;
    font-size: 12px;
    width: auto;
    max-width: 100%;
    overflow: auto;
    border: 1px solid #f6f6f6;
    text-indent: initial;
    unicode-bidi: isolate;
    border-spacing: 2px;
    //   border-collapse: collapse;
    thead {
      background: #f6f6f6;
      color: #000;
      text-align: left;
      display: table-header-group;
      vertical-align: middle;
      unicode-bidi: isolate;
      border-color: inherit;
    }
    tr {
      display: table-row;
      vertical-align: inherit;
      unicode-bidi: isolate;
      border-color: inherit;
    }
    td {
      min-width: 120px;
      border: 1px solid #f6f6f6;
    }
    td,
    th {
      padding: 12px 7px;
      line-height: 24px;
    }
    tbody {
      display: table-row-group;
      vertical-align: middle;
      unicode-bidi: isolate;
      border-color: inherit;
    }
  }
}
✅ 表格关键样式:table 横滚(overflow: auto + max-width: 100%)、td 最小宽度 120px、thead 灰底黑字左对齐。这套样式专门治"md 表格渲染成一坨"的问题。

3代码解释:组件逻辑与注意事项

这段代码定义了一个 React 组件 Test,它使用了一些现代 React 技术和库来展示一个 Markdown 字符串,并通过一种模拟的"流式"方式(即逐字符地)将其内容添加到页面上。

3.1 组件结构和依赖

3.2 组件逻辑

3.3 注意事项

⚠️ 性能:这种流式渲染的方式在视觉上可能很有趣,但它并不是处理大量文本或实时更新的高效方式。每次更新状态都会触发组件的重新渲染,这可能会导致性能问题,尤其是在处理长文本时。
⚠️ 代码优化:setTimeout 的递归调用可能会导致调用栈过深,尽管在 JavaScript 中这通常不是问题,因为 setTimeout 会将回调放入事件循环的宏任务队列中,而不是直接递归调用。

注:原文注释中 100ms 与代码实际 10ms 不一致,可能是笔误;用 Immer 在"每次只加一个字符"的场景下也偏重,但作为教学示例无妨。

4真实流式输出:fetch-event-source 实战

我们一般使用 @microsoft/fetch-event-source,支持 post 请求、自定义 header 等,有失败重试机制:

import { fetchEventSource } from "@microsoft/fetch-event-source";

const ctrl = new AbortController();

fetchEventSource(url, {
      method: "POST",
      // 自定义请求头
      headers: {
        "Content-Type": "application/json",
        Accept: ["text/event-stream", "application/json"] as unknown as string,
        Authorization: userInfo.token,
      },
      // 自定义传参
      body: JSON.stringify({
        user_id: userInfo.id,
        kb_ids: [paramsQuery.kbid],
        history: [], // 历史记录传递最后三项
        question: q,
        streaming: true,
      }),
      openWhenHidden: true, // 页面失活仍然输出
      signal: ctrl.signal, // 取消请求
      onopen(e: any) {
        if (e.ok && e.headers.get("content-type") === "text/event-stream") {
         // 流式链接成功
        } else if (e.headers.get("content-type") === "application/json") {
          return e
            .json()
            .then((data: any) => {
             // 失败的处理逻辑
            })
            .catch(() => {
               // 失败的处理逻辑
            });
        }
      },
      onmessage(msg: { data: string }) {
        // 流式输出的,可再次处理业务逻辑 如拼接字符串
      },
      onclose() {
        // 链接关闭
      },
      onerror(err: any) {
        // 报错处理
      },
    });
配置项作用
openWhenHidden: true页面切到后台(标签页失活)时仍然继续接收流式输出
signal: ctrl.signalAbortController 信号,随时 ctrl.abort() 取消请求
onopen连接建立:区分 text/event-stream(成功)与 application/json(错误返回)
onmessage每段数据到达:在此拼接 markdown 字符串(配合 ReactMarkdown 渲染)
onclose / onerror连接关闭 / 出错:统一清理状态
📌 与伪流式对接:onmessage 里拿到增量文本后,用上文的 useImmer 累计拼接,再交给 ReactMarkdown 渲染——就是"真实版打字机 + md 表格/代码高亮"的完整闭环。

5术语表 & 附录:完整代码

术语表

术语定义
useImmer基于 Immer 的 React Hook,以"可变写法"更新不可变状态
remark-gfm支持 GitHub Flavored Markdown 的 remark 插件(表格、任务列表、删除线等)
伪流式用定时器逐字符追加内容模拟真实流式,适合本地演示 / 调试
openWhenHiddenfetch-event-source 配置:页面隐藏时仍保持连接输出
AbortController配合 signal 手动取消请求的浏览器 API

附录 A:最小可运行骨架(伪流式 + 表格样式)

// ========== 安装依赖 ==========
// npm i react-markdown remark-gfm use-immer -S

import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useImmer } from "use-immer";

function Test() {
  const [targetString, setTargetString] = useImmer("");

  const streamString = (source: string, index = 0) => {
    if (index < source.length) {
      setTargetString((darf) => darf + source[index]);
      setTimeout(() => streamString(source, index + 1), 10);
    }
  };

  useEffect(() => {
    streamString(markdownstr, 0);
  }, []);

  return (
    <div style={{ whiteSpace: "pre-wrap", width: "60%" }} className="markdown-body">
      <ReactMarkdown remarkPlugins={[remarkGfm]}>{targetString}</ReactMarkdown>
    </div>
  );
}
export default Test;

// 注意:markdownstr 为带表格的 md 文本(见第 1 节);表格样式见第 2 节 index.css