SSE 流式场景下的 Markdown 渲染
AI Agent 应用中 SSE 流式传输与 Markdown 渲染的衔接问题:为什么流式 Markdown 比普通 Markdown 麻烦、四层架构分层、三种渲染方案选型、前端节流策略、安全与样式处理。
[!info] related notes
- 知识地图: BodySense 项目 MOC
- SSE 消费: 前端 SSE 消费
- Chat UI: AI 聊天 UI 设计
- 会话设计: 会话与消息流设计
- SSE 管道: 多服务 SSE 管道
SSE 流式场景下的 Markdown 渲染
SSE 只负责把文本增量送到前端;前端拿到完整/半完整文本后,必须用 Markdown 渲染器解析,而不是当普通字符串直接渲染。
问题本质
AI Agent 应用的完整链路:
LLM 生成 token
↓
后端把 token/delta 包成 SSE event
↓
浏览器 fetch stream 接收
↓
前端把 delta 追加到 message.content
↓
React 重新渲染消息
↓
Markdown Renderer 把 message.content 渲染成 HTML/React 组件
SSE 做数据传输,不知道 Markdown 是什么。Markdown 渲染做展示层解析。 问题通常发生在最后一步:消息内容被普通文本组件渲染了,而不是被 Markdown 组件渲染。
为什么 AI 流式 Markdown 比普通 Markdown 麻烦
普通 Markdown 是一次性完整字符串,但 AI 流式输出时前端收到的是增量片段:
#
##
## 标
## 标题
## 标题
**
**头
**头前
**头前移
**头前移**
前端经常要渲染不完整的 Markdown。
典型问题一:加粗/代码块/列表半截
模型刚输出 **头前移 时 ** 还没闭合,普通解析器无法正确判断。代码块更明显——在模型输出最后的 ``` 之前,代码块是”未闭合”的,会裸露在页面上或导致布局跳动。
典型问题二:表格在流式场景下特别容易崩
Markdown 表格需要至少看到表头和分隔行才能识别。流式过程中只收到 | 原因 | 说明 | 时它只是普通文本,等 |-----|------| 来了之后渲染器重解析成表格,处理不好会造成明显闪烁。
典型问题三:字符级流式导致频繁重解析
每个 token 都触发 setState + Markdown parse → 性能差、滚动抖动、代码块闪烁、表格反复重排、长回答越来越卡。
四层架构分层
不要把传输、协议、状态、渲染混在一起:
Transport Layer:SSE 负责传输
Protocol Layer:定义 text_delta / tool_call / done
State Layer:把 delta 累积成 message
Render Layer:Markdown / Tool Card / Citation / Image
推荐的 SSE 数据格式:
event: message_start
data: {"messageId":"msg_1","role":"assistant"}
event: text_delta
data: {"messageId":"msg_1","delta":"**头前移** 是一种常见体态问题"}
event: text_delta
data: {"messageId":"msg_1","delta":"\n\n## 1. 形象解释\n"}
event: tool_call
data: {"id":"tool_1","name":"extract_posture_info","args":{...}}
event: message_done
data: {"messageId":"msg_1"}
前端状态模型:
type ChatMessage = {
id: string;
role: "user" | "assistant";
content: string; // 累计文本,不是单个 delta
status: "streaming" | "done" | "error";
};
接收 delta 时追加到同一条消息:
setMessages((prev) =>
prev.map((msg) =>
msg.id === event.messageId
? { ...msg, content: msg.content + event.delta }
: msg
)
);
重点:SSE delta 是增量,message.content 是累计文本,Markdown renderer 渲染累计文本。 不要把每个 delta 单独渲染成一个 Markdown 块。
三种渲染方案选型
方案 A:@assistant-ui/react-markdown(MVP 首选)
适合当前使用 Assistant UI 的场景。安装:
npm install @assistant-ui/react-markdown react-markdown remark-gfm
# 或
npx shadcn@latest add https://r.assistant-ui.com/markdown-text.json
在消息组件里把 text part 交给 MarkdownText:
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
}}
/>
Assistant UI 的基础 primitive 不会自动把字符串变成 Markdown,需要显式接到 Markdown 渲染组件。
方案 B:@assistant-ui/react-streamdown(流式体验最优)
Vercel 的 Streamdown 专门处理 AI 流式输出中的不完整/未闭合 Markdown 块,支持 Shiki 语法高亮、KaTeX 数学公式、Mermaid 图表。
npm install @assistant-ui/react-streamdown
用法类似,把 Text component 换成 Streamdown:
<MessagePrimitive.Parts
components={{
Text: Streamdown,
}}
/>
AI Agent + SSE + 长回答 + 表格/标题/列表多的场景,推荐这套。
方案 C:自定义 react-markdown 渲染管线(深度定制)
npm install react-markdown remark-gfm rehype-sanitize
自由度最高,但需要自己处理流式未完成 Markdown、代码高亮、复制按钮、Mermaid、安全白名单等。
选型总结
MVP 阶段: @assistant-ui/react-markdown + remark-gfm
体验优化: @assistant-ui/react-streamdown
深度定制: react-markdown + 自定义 code/table/link/tool card 组件
前端节流策略
不要每个字符都触发 Markdown parse,用 buffer + requestAnimationFrame 或 setTimeout 合并:
let buffer = "";
let scheduled = false;
function onTextDelta(delta: string) {
buffer += delta;
if (!scheduled) {
scheduled = true;
requestAnimationFrame(() => {
setContent((prev) => prev + buffer);
buffer = "";
scheduled = false;
});
}
}
或按 30-80ms flush 一次:
let buffer = "";
let timer: number | null = null;
function onTextDelta(delta: string) {
buffer += delta;
if (timer) return;
timer = window.setTimeout(() => {
setContent((prev) => prev + buffer);
buffer = "";
timer = null;
}, 50);
}
减少 React render 次数、Markdown parse 次数、滚动抖动、代码块闪烁。用户仍然感觉实时输出。
安全:不要后端直接传 HTML
AI 输出是不可信内容,后端直接转 HTML 传给前端容易引入 XSS。正确做法:
后端传 Markdown 文本
前端用安全 Markdown Renderer 渲染
链接、HTML、图片、iframe、script 全部白名单控制
绝不要 dangerouslySetInnerHTML。用 rehype-sanitize 做白名单。
样式:Tailwind prose 适配
消息容器加 prose 类,但聊天气泡需要调整间距:
<div className="
prose prose-sm max-w-none dark:prose-invert
prose-p:my-2
prose-headings:mt-4 prose-headings:mb-2
prose-ul:my-2 prose-ol:my-2
prose-pre:my-3
prose-table:text-sm
">
<MarkdownMessage content={content} />
</div>
默认 prose 的标题间距在聊天气泡里偏大,需要收紧。
推荐最终架构
后端:
LLM streaming → SSE event(message_start / text_delta / tool_call / message_done)
前端:
SSE parser → message accumulator → state buffer + throttle
→ Assistant UI Thread / MessagePrimitive
→ Markdown renderer(优先 Streamdown,简单场景 react-markdown)
→ 自定义组件(code block / table / link / blockquote)
一句话总结
SSE 已经把 Markdown 文本传到了前端,但消息渲染层没有把 text part 交给 Markdown renderer,所以 Markdown 语法被当成普通文本显示。修复方法:接入 @assistant-ui/react-markdown 或 react-streamdown。