LLM 天然会生成 Markdown 格式文本,包括标题、列表、代码块、表格和行内格式。将这些内容渲染为纯文本会浪费模型提供的结构。这个模式展示如何在代理流式传输 Markdown 时,跨主流前端框架实时解析和渲染它。

Markdown 渲染的工作原理

渲染管线分为三步:
  1. 接收: useStream 将流式文本累积到每条 AI 消息的 msg.text 中,并在新 token 到达时响应式更新。
  2. 解析: Markdown 解析器将原始文本转换为 HTML(或 React 元素树)。这会在每次更新时运行,但对于聊天长度的内容足够快(5 KB 消息低于 5 ms)。
  3. 渲染: 将解析后的输出渲染到 DOM。React 使用虚拟 DOM diff;Vue 和 Svelte 使用经过清理的 HTML 配合 v-html / {@html}

设置 useStream

Markdown 模式使用一个简单的聊天代理,不需要特殊配置。用你的代理 URL 和 assistant ID 连接 useStream
The code examples use useStream<typeof myAgent> for type-safe stream state. See Type inference for Python or JavaScript backends.
import { useStream } from "@langchain/react";
import { AIMessage, HumanMessage } from "langchain";

const AGENT_URL = "http://localhost:2024";

export function Chat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "simple_agent",
  });

  return (
    <div>
      {stream.messages.map((msg) => {
        if (AIMessage.isInstance(msg)) {
          return <Markdown key={msg.id}>{msg.text}</Markdown>;
        }
        if (HumanMessage.isInstance(msg)) {
          return <p key={msg.id}>{msg.text}</p>;
        }
      })}
    </div>
  );
}

选择 Markdown 库

每个框架都有适合 Markdown 渲染的自然选择:
框架输出原因
Reactreact-markdown + remark-gfmReact 元素基于组件、虚拟 DOM diff,不需要 dangerouslySetInnerHTML
Vuemarked + dompurify通过 v-html 输出经过清理的 HTML轻量、快速,内置 GFM
Sveltemarked + dompurify通过 {@html} 输出经过清理的 HTML与 Vue 相同,API 一致
Angularmarked + dompurify通过 [innerHTML] 输出经过清理的 HTML与 Vue/Svelte 相同
React 的 react-markdown 会直接把 Markdown 转换为 React 元素,因此不需要 HTML 清理。这里不涉及 dangerouslySetInnerHTML。对于 Vue、Svelte 和 Angular,请始终在渲染前用 dompurify 清理解析后的 HTML。

构建 Markdown 组件

import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

export function Markdown({ children }: { children: string }) {
  return (
    <div className="markdown-content">
      <ReactMarkdown remarkPlugins={[remarkGfm]}>
        {children}
      </ReactMarkdown>
    </div>
  );
}

清理 HTML 输出

将解析后的 Markdown 渲染为原始 HTML(v-html{@html}[innerHTML])时,必须清理输出以防止跨站脚本攻击(XSS)。LLM 响应可能包含任意文本,包括 Markdown 解析器可能转换为可执行 HTML 的标记。 使用 dompurify 移除危险元素:
import DOMPurify from "dompurify";

const safeHtml = DOMPurify.sanitize(rawHtml);
DOMPurify 会移除 <script> 标签、onclick 属性、javascript: URL 和其他 XSS 向量,同时保留标题、列表、代码块、表格和链接等安全的 Markdown 输出。
React 的 react-markdown 不需要 dompurify,因为它直接生成 React 元素,不涉及原始 HTML 注入。

流式传输注意事项

useStream 会在每个 token 到达时响应式更新 msg.text。Markdown 组件会在每次更新时重新解析。对于典型聊天消息,这种方式性能足够好:
  • marked 的解析速度约为 1 MB/s。5 KB 消息耗时低于 5 ms
  • react-markdown + remark 管线处理聊天长度内容时同样很快
  • 浏览器布局引擎可以高效处理 DOM 更新
对于很长的响应(大于 50 KB),可以考虑这些优化:
  • 节流渲染: 使用 requestAnimationFrame 以 60 fps 批量更新,而不是在每个 token 上重新渲染
  • 增量解析: 仅解析新内容并追加到已渲染缓冲区(高级用法,聊天 UI 通常不需要)
对于大多数聊天应用,在每个 token 上重新解析完整消息的简单方式已经足够。只有在很长消息中观察到滚动卡顿或掉帧时才需要优化。

最佳实践

  • 始终清理: 使用 v-html{@html}[innerHTML] 时,始终通过 dompurify 处理解析后的输出。不要信任由 Markdown 解析器处理 LLM 输出后得到的原始 HTML。
  • 启用 GFM: GitHub Flavored Markdown 增加了表格、删除线、任务列表和自动链接。LLM 经常使用这些特性。
  • 处理空内容: 解析前检查空字符串,避免渲染空容器。
  • 使用 breaks: true 启用换行转换,让 LLM 输出中的单个换行渲染为 <br>,而不是被忽略。LLM 经常使用单个换行做视觉分隔。
  • 为聊天上下文设置样式: 使用适合聊天气泡的紧凑边距和尺寸,而不是整宽文章布局。
  • 用丰富内容测试: 使用标题、嵌套列表、长行代码块、宽表格和块引用验证渲染,以发现溢出或布局问题。