定时器伪流式逐字渲染 → markdown-body 表格样式 → fetch-event-source 真实 SSE 流式
我们在大模型聊天回复中,常借助 SSE 来实现流式询问回复,返回的格式以 md 格式为主。我们如何流式地输出 md 格式?借助 react-markdown 可轻松实现。
本文先用定时器模拟流式,如想真实后端请求数据流,文末也会给出相应代码。
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;
部分 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 表格渲染成一坨"的问题。这段代码定义了一个 React 组件 Test,它使用了一些现代 React 技术和库来展示一个 Markdown 字符串,并通过一种模拟的"流式"方式(即逐字符地)将其内容添加到页面上。
注:原文注释中 100ms 与代码实际 10ms 不一致,可能是笔误;用 Immer 在"每次只加一个字符"的场景下也偏重,但作为教学示例无妨。
我们一般使用 @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.signal | AbortController 信号,随时 ctrl.abort() 取消请求 |
| onopen | 连接建立:区分 text/event-stream(成功)与 application/json(错误返回) |
| onmessage | 每段数据到达:在此拼接 markdown 字符串(配合 ReactMarkdown 渲染) |
| onclose / onerror | 连接关闭 / 出错:统一清理状态 |
| 术语 | 定义 |
|---|---|
| useImmer | 基于 Immer 的 React Hook,以"可变写法"更新不可变状态 |
| remark-gfm | 支持 GitHub Flavored Markdown 的 remark 插件(表格、任务列表、删除线等) |
| 伪流式 | 用定时器逐字符追加内容模拟真实流式,适合本地演示 / 调试 |
| openWhenHidden | fetch-event-source 配置:页面隐藏时仍保持连接输出 |
| AbortController | 配合 signal 手动取消请求的浏览器 API |
// ========== 安装依赖 ==========
// 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