外观
LLM 天然会产生 markdown 格式的文本,包括标题、列表、代码块、表格与行内格式。将这些内容渲染为纯文本,会浪费模型所提供的结构。这个模式展示了如何在所有主流前端框架中,实时解析并渲染从智能体流式输出的 markdown。
import { PatternEmbed } from "/snippets/pattern-embed.jsx"
markdown 渲染的工作原理
渲染流水线有三个步骤:
- 接收:
useStream将流式文本累积到每条 AI 消息的msg.text中,并在新 token 到达时以响应式方式更新。 - 解析: markdown 解析器将原始文本转换为 HTML(或 React 元素树)。每次更新时它都会运行,但对于聊天长度的内容来说速度足够快(5 KB 的消息不到 5 毫秒)。
- 渲染: 解析后的输出被渲染到 DOM 中。React 使用虚拟 DOM 差异比对;Vue 和 Svelte 使用
v-html/{@html}配合经净化(sanitize)的 HTML。
设置 useStream
markdown 模式使用一个无需特殊配置的简单聊天智能体。将 useStream 与你的智能体 URL 和 assistant ID 连接起来。
INFO
The code examples use useStream<typeof myAgent> for type-safe stream state. See Type inference for Python or JavaScript backends.
tsx
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 (
{stream.messages.map((msg) => {
if (AIMessage.isInstance(msg)) {
return <Markdown key={msg.id}>{msg.text}</Markdown>;
}
if (HumanMessage.isInstance(msg)) {
return {msg.text};
}
})}
);
}vue
<script setup lang="ts">
import { useStream } from "@langchain/vue";
import { AIMessage, HumanMessage } from "langchain";
const AGENT_URL = "http://localhost:2024";
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "simple_agent",
});
</script>
<template>
<template v-for="msg in stream.messages.value" :key="msg.id">
<Markdown v-if="AIMessage.isInstance(msg)">{{ msg.text }}</Markdown>
{{ msg.text }}
</template>
</template>svelte
<script lang="ts">
import { useStream } from "@langchain/svelte";
import { AIMessage, HumanMessage } from "langchain";
const AGENT_URL = "http://localhost:2024";
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "simple_agent",
});
</script>
{#each stream.messages as msg (msg.id)}
{#if AIMessage.isInstance(msg)}
<Markdown content={msg.text} />
{:else if HumanMessage.isInstance(msg)}
{msg.text}
{/if}
{/each}ts
import { Component } from "@angular/core";
import { injectStream } from "@langchain/angular";
const AGENT_URL = "http://localhost:2024";
@Component({
selector: "app-chat",
template: `
@for (msg of stream.messages(); track msg.id) {
<app-markdown [content]="msg.text" />
}
`,
})
export class ChatComponent {
stream = injectStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "simple_agent",
});
}选择 markdown 库
每个框架都有一个用于 markdown 渲染的自然选择:
| 框架 | 库 | 输出 | 原因 |
|---|---|---|---|
| React | react-markdown + remark-gfm | React 元素 | 基于组件,虚拟 DOM 差异比对,无需 dangerouslySetInnerHTML |
| Vue | marked + dompurify | 通过 v-html 净化后的 HTML | 轻量、快速、内置 GFM |
| Svelte | marked + dompurify | 通过 {@html} 净化后的 HTML | 与 Vue 相同,API 一致 |
| Angular | marked + dompurify | 通过 [innerHTML] 净化后的 HTML | 与 Vue/Svelte 相同 |
TIP
React 的 react-markdown 将 markdown 直接转换为 React 元素,因此它不需要 HTML 净化。不涉及 dangerouslySetInnerHTML。对于 Vue、Svelte 和 Angular,在渲染之前始终使用 dompurify 净化解析后的 HTML。
构建 Markdown 组件
tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function Markdown({ children }: { children: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{children}
</ReactMarkdown>
);
}vue
<script setup lang="ts">
import { computed, useSlots } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
marked.setOptions({ gfm: true, breaks: true });
const slots = useSlots();
const html = computed(() => {
const slot = slots.default?.();
const text = slot
?.map((vnode) =>
typeof vnode.children === "string" ? vnode.children : ""
)
.join("") ?? "";
if (!text) return "";
return DOMPurify.sanitize(marked.parse(text) as string);
});
</script>
<template>
</template>svelte
<script lang="ts">
import { marked } from "marked";
import DOMPurify from "dompurify";
let { content }: { content: string } = $props();
marked.setOptions({ gfm: true, breaks: true });
let html = $derived.by(() => {
if (!content) return "";
return DOMPurify.sanitize(marked.parse(content) as string);
});
</script>
{@html html}ts
import { Component, Input, computed, signal } from "@angular/core";
import { marked } from "marked";
import DOMPurify from "dompurify";
marked.setOptions({ gfm: true, breaks: true });
@Component({
selector: "app-markdown",
template: ``,
})
export class MarkdownComponent {
@Input() set content(value: string) {
this._content.set(value);
}
private _content = signal("");
html = computed(() => {
const text = this._content();
if (!text) return "";
return DOMPurify.sanitize(marked.parse(text) as string);
});
}净化 HTML 输出
当将解析后的 markdown 作为原始 HTML 渲染时(v-html、{@html}、[innerHTML]),你必须净化输出以防止跨站脚本(XSS)攻击。LLM 响应可能包含任意文本,包括 markdown 解析器可能将其转化为可执行 HTML 的标记。
使用 dompurify 去除危险元素:
ts
import DOMPurify from "dompurify";
const safeHtml = DOMPurify.sanitize(rawHtml);DOMPurify 会移除 <script> 标签、onclick 属性、javascript: URL 以及其他 XSS 载体,同时保留安全的 markdown 输出,如标题、列表、代码块、表格与链接。
INFO
React 的 react-markdown 不需要 dompurify,因为它直接生成 React 元素,不涉及原始 HTML 注入。
流式传输注意事项
useStream 会在每个 token 到达时以响应式方式更新 msg.text。markdown 组件会在每次更新时重新解析。对于典型的聊天消息,这性能很好:
marked的解析速度约为 1 MB/s。一条 5 KB 的消息不到 5 毫秒- 对于聊天长度的内容,
react-markdown+ remark 流水线同样快速 - 浏览器的布局引擎能高效地处理 DOM 更新
对于非常长的响应(超过 50 KB),请考虑这些优化:
- 节流渲染: 使用
requestAnimationFrame以 60fps 批量更新,而不是在每次出现 token 时重新渲染 - 增量解析: 只解析新增的内容,并追加到已渲染的缓冲区中(进阶方案,聊天界面通常不需要)
INFO
对于大多数聊天应用而言,在每次出现 token 时重新解析完整消息这种简单方法就足够了。只有当你在非常长的消息上观察到滚动卡顿或掉帧时,才进行优化。
最佳实践
- 始终净化: 使用
v-html、{@html}或[innerHTML]时,始终将解析后的输出经dompurify处理。永远不要信任由 LLM 输出作为输入、经过 markdown 解析器得到的原始 HTML。 - 启用 GFM: GitHub 风格 Markdown 增加了表格、删除线、任务列表与自动链接。这些功能是 LLM 常用的。
- 处理空内容: 在解析之前检查空字符串,以避免渲染空容器。
- 使用
breaks: true: 启用换行转换,使 LLM 输出中的单个换行渲染为而不是被忽略。LLM 经常使用单个换行进行视觉分隔。 - 针对聊天场景定制样式: 使用适合聊天气泡的紧凑边距与尺寸,而不是全宽的文章布局。
- 用丰富内容进行测试: 使用标题、嵌套列表、带长行的代码块、宽表格与引用块来验证渲染,以发现溢出或布局问题。