Skip to content

Markdown 消息 ​

将 LLM 响应渲染为具备完善流式支持的富文本格式化 markdown

LLM 自然会生成 Markdown 格式的文本,包括标题、列表、代码块、表格以及行内格式化。将这些内容渲染为纯文本会浪费模型所提供的结构信息。本模式向你展示如何在所有主流前端框架中,实时解析并渲染从智能体流式传输的 Markdown。

Markdown 渲染的工作原理 ​

渲染流水线包含三个步骤:

  1. 接收(Receive):useStream 会在每条 AI 消息中将流式文本累积到 msg.text 中,并在新 token 到达时响应式更新。
  2. 解析(Parse):Markdown 解析器将原始文本转换为 HTML(或 React 元素树)。每次更新时都会运行此步骤,但对于聊天长度的内容而言,它的速度足够快(对于 5 KB 的消息耗时 < 5ms)。
  3. 渲染(Render):将解析后的输出渲染到 DOM 中。React 使用虚拟 DOM diff;Vue 和 Svelte 使用带净化处理的 HTML 的 v-html / {@html}。

配置 useStream ​

Markdown 模式使用无需特殊配置的简单聊天智能体。使用你的智能体 URL 和 assistant ID 接入 useStream。

NOTE

代码示例使用 useStream<typeof myAgent> 来实现类型安全的流式状态。请参阅适用于 Python 或 JavaScript 后端的类型推断。

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 (
    <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>
  );
}
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",
});
`&lt;/script&gt;`

<template>
  <div>
    <template v-for="msg in stream.messages.value" :key="msg.id">
      <Markdown v-if="AIMessage.isInstance(msg)">{{ msg.text }}</Markdown>
      <p v-else-if="HumanMessage.isInstance(msg)">{{ msg.text }}</p>
    </template>
  </div>
</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",
  });
`&lt;/script&gt;`

<div>
  {#each stream.messages as msg (msg.id)}
    {#if AIMessage.isInstance(msg)}
      <Markdown content={msg.text} />
    {:else if HumanMessage.isInstance(msg)}
      <p>{msg.text}</p>
    {/if}
  {/each}
</div>
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 渲染的天然之选:

框架库输出原因
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 相同

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 (
    <div className="markdown-content">
      <ReactMarkdown remarkPlugins={[remarkGfm]}>
        {children}
      </ReactMarkdown>
    </div>
  );
}
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);
});
`&lt;/script&gt;`

<template>
  <div class="markdown-content" v-html="html" />
</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);
  });
`&lt;/script&gt;`

<div class="markdown-content">
  {@html html}
</div>
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: `<div class="markdown-content" [innerHTML]="html()"></div>`,
})
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 会移除 &lt;script&gt; 标签、onclick 属性、javascript: URL 以及其他 XSS 攻击向量,同时保留标题、列表、代码块、表格和链接等安全的 Markdown 输出。

NOTE

React 的 react-markdown 不需要 dompurify,因为它直接生成 React 元素,不涉及原始 HTML 注入。

流式传输注意事项 ​

useStream 会在每个 token 到达时响应式地更新 msg.text。Markdown 组件在每次更新时都会重新解析。对于典型的聊天消息,这种方式性能良好:

  • marked 的解析速度约为 1 MB/s。一条 5 KB 的消息耗时 < 5ms
  • 对于聊天长度的内容,react-markdown + remark 流水线同样快速
  • 浏览器的布局引擎能够高效处理 DOM 更新

对于超长响应(> 50 KB),请考虑以下优化方案:

  • 节流渲染(Throttle renders):使用 requestAnimationFrame 以 60fps 批量处理更新,而不是在每个 token 到达时都重新渲染
  • 增量解析(Incremental parsing):仅解析新内容并追加到已渲染的缓冲区(属于高级用法,聊天 UI 通常不需要)

NOTE

对于大多数聊天应用程序,在每个 token 到达时重新解析完整消息这种简单方法就足够了。仅当你在极长消息中观察到滚动卡顿或掉帧时才进行优化。

最佳实践 ​

  • 始终进行净化(Always sanitize):使用 v-html、{@html} 或 [innerHTML] 时,务必将解析后的输出经由 dompurify 处理。切勿信任来自输入了 LLM 输出的 Markdown 解析器所生成的原始 HTML。
  • 启用 GFM(Enable GFM):GitHub Flavored Markdown 增加了表格、删除线、任务列表和自动链接功能。这些特性常被 LLM 使用。
  • 处理空内容(Handle empty content):在解析前检查空字符串,以避免渲染空容器。
  • 使用 breaks: true(Use breaks: true):启用换行符转换,以便 LLM 输出中的单个换行符能渲染为 <br> 而不是被忽略。LLM 通常使用单个换行符进行视觉分隔。
  • 针对聊天场景进行样式适配(Style for chat context):使用适合聊天气泡的紧凑边距与尺寸,而不是全宽文章布局。
  • 使用富文本内容进行测试(Test with rich content):使用标题、嵌套列表、包含长代码行的代码块、宽表格和块级引用(blockquote)来验证渲染效果,以及时发现溢出或布局问题。