AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染

130 阅读12分钟

AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染

前言

在做 AI 对话页面时,消息展示一开始很容易被低估。

很多时候我们会先把后端返回的内容直接放进气泡里:

{{ message.content }}

如果 AI 只回复一句普通文本,这当然没有问题。

但真实项目里,AI 的输出通常不会这么简单。

它可能返回:

标题
段落
列表
代码块
表格
链接
引用
步骤说明
错误分析
实现方案
image.png

尤其是在 Zero Code Agent 这种 AI Coding 场景里,AI 经常要输出方案、代码、目录结构、配置说明。

如果仍然只按普通字符串展示,用户看到的会是一大坨文本:

# 页面结构
- Header
- Sidebar
- Content
```ts
const app = createApp(App)
```

内容本身是结构化的,但页面没有把结构表达出来。

这就是为什么 AI 对话不能只停留在“把字符串显示出来”。

这篇文章记录一下我在项目里为什么要引入 Markdown 解析器,以及我是如何基于成熟底层库封装自己的 MarkdownRenderer 业务组件,再接入到 Ant Design X Vue 的 Bubble 消息气泡里的。

为什么不是直接展示字符串

AI 返回的内容,本质上经常是“带结构的文本”。

Markdown 正好是这类内容最常见的表达格式。

比如模型返回一份实现方案:

## 实现思路

1. 新增 MarkdownRenderer 组件
2. 使用 markdown-it 解析 Markdown
3. 使用 highlight.js 处理代码高亮
4. 使用 DOMPurify 做安全过滤

```ts
messageRender: (content) => h(MarkdownRenderer, {
  content: String(content ?? ''),
})
```

如果按纯文本展示,用户看到的是符号本身。

如果按 Markdown 渲染,用户看到的是:

清晰的标题
有层级的列表
带高亮的代码块
可点击的链接
可阅读的表格

这两种体验差很多。

纯文本展示的问题主要有三个。

第一个问题是可读性差。

AI 回复越长,纯文本越难读。尤其是生成方案、技术分析、代码说明时,用户需要快速扫结构,而不是从一堆字符里自己找重点。

第二个问题是代码不可用。

AI Coding 场景里,代码块是高频内容。没有代码块样式和语法高亮,用户复制、阅读、定位问题都会变得困难。

第三个问题是产品质感不够。

AI 产品的输出不是普通日志,消息区本身就是产品主界面。内容渲染质量直接影响用户对系统能力的判断。

所以 Markdown 渲染不是锦上添花。

在 AI 对话产品里,它更像是基础能力。

Markdown 解析器解决什么问题

Markdown 解析器要解决的核心问题是:

把 AI 返回的 Markdown 字符串,转换成浏览器可以渲染的 HTML 结构。

比如输入:

## 页面结构

- 顶部导航
- 左侧历史
- 中间对话区

```ts
const message = 'hello'
```

经过解析之后,会变成类似:

<h2>页面结构</h2>
<ul>
  <li>顶部导航</li>
  <li>左侧历史</li>
  <li>中间对话区</li>
</ul>
<pre><code class="language-ts">const message = 'hello'</code></pre>

浏览器真正擅长渲染的是 HTML。

Markdown 解析器就是中间这一层:

AI 输出 Markdown
  ↓
Markdown 解析器
  ↓
HTML 结构
  ↓
组件样式渲染

但注意,Markdown 解析器本身只负责解析。

它不等于完整的消息展示方案。

真实项目里还需要处理:

代码高亮
HTML 安全过滤
链接打开方式
表格横向滚动
代码块样式
流式输出中的半截 Markdown
和消息气泡组件的配合
image.png

所以我没有直接把某个 Markdown 组件丢进页面,而是选择了更稳的方式:

成熟底层库 + 自己封装业务组件。

为什么选择成熟底层库

当前项目选择的是:

markdown-it   Markdown 解析
highlight.js  代码高亮
DOMPurify     HTML 安全过滤

这三个库各司其职。

库作用
markdown-it把 Markdown 字符串解析成 HTML
highlight.js给代码块做语法高亮
DOMPurify清理不安全 HTML,降低 XSS 风险

这个组合在工程上比较舒服。

它不是一个黑盒大组件,而是一组成熟的基础能力。

这样做的好处是:渲染逻辑掌握在自己手里,后面要加复制代码、代码块标题、图片预览、表格优化、暗色主题,都比较容易扩展。

如果一开始直接使用封装很重的 Vue Markdown 组件,短期会快一点,但后期要改细节时经常会被组件内部结构限制。

AI 对话这种页面,后面一定会长出很多业务细节。

所以我的判断是:

底层解析用成熟库
业务样式和行为自己封装

这也是更接近真实项目的做法。

安装依赖

项目里新增了这些依赖:

{
  "dependencies": {
    "dompurify": "^3.2.7",
    "highlight.js": "^11.11.1",
    "markdown-it": "^14.1.0"
  },
  "devDependencies": {
    "@types/markdown-it": "^14.1.2"
  }
}

对应命令可以是:

pnpm add markdown-it highlight.js dompurify
pnpm add -D @types/markdown-it

DOMPurify 和 highlight.js 当前版本本身已经带类型支持,不需要额外安装类型包。

封装 MarkdownRenderer

在项目里,我把 Markdown 渲染单独封装成了一个组件:

src/features/workbench/components/chat/MarkdownRenderer.vue

它的职责很明确:

接收 markdown 字符串
解析成 HTML
做代码高亮
做安全过滤
输出最终可渲染内容

组件对外只暴露一个 content:

const props = defineProps<{
  content: string;
}>();

这点很重要。

业务层不需要关心 Markdown 怎么解析,也不需要关心代码高亮怎么配置。

业务层只需要传入字符串:

MarkdownRenderer(content)

组件内部负责剩下的事情。

markdown-it 配置

核心解析器这样创建:

const markdown: MarkdownIt = new MarkdownIt({
  html: false,
  linkify: true,
  breaks: true,
  langPrefix: 'language-',
  highlight(code: string, lang: string): string {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
    }

    return escapeHtml(code);
  },
});

这几个配置分别解决不同问题。

html: false 表示不允许 Markdown 里的原始 HTML 直接生效。

这是我在 AI 对话场景里比较坚持的一点。

因为模型输出不完全可控,用户输入也不完全可信。如果放开原始 HTML,后面就必须面对更复杂的 XSS 风险。

当前项目只需要 Markdown 语义,不需要模型直接插入任意 HTML,所以关掉是更稳的选择。

linkify: true 表示自动识别链接。

比如模型输出:

https://example.com

可以自动变成链接。

breaks: true 更适合聊天场景。

用户在聊天里通常认为换行就是换行,而不是严格遵守标准 Markdown 的段落规则。打开这个配置后,模型输出中的普通换行会更接近对话阅读习惯。

langPrefix: 'language-' 是给代码块语言 class 加前缀。

比如:

```ts
const name = 'zero code'
```

会得到:

<code class="language-ts">

这对代码高亮和后续样式扩展都有帮助。

代码高亮如何处理

AI Coding 场景里,代码块是核心内容。

所以 MarkdownRenderer 里接入了 highlight.js:

highlight(code: string, lang: string): string {
  if (lang && hljs.getLanguage(lang)) {
    return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
  }

  return escapeHtml(code);
}

这里没有盲目高亮所有内容。

先判断:

lang && hljs.getLanguage(lang)

只有模型标注了语言,并且 highlight.js 支持这个语言时,才执行高亮。

如果语言不存在,就走普通转义:

return escapeHtml(code);

这样可以避免因为模型输出了不存在的语言标识,导致高亮库解析异常。

这也是 AI 场景里常见的工程处理:

模型输出可以不完美,前端渲染必须稳定。

为什么要手动 escapeHtml

代码块即使不做高亮,也不能直接返回原始内容。

比如代码里有:

<script>alert(1)</script>

如果不转义,后续又通过 v-html 渲染,就会有安全风险。

所以项目里写了一个轻量转义函数:

const escapeHtml = (value: string) => value
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

它的作用是把危险字符变成普通文本。

这样即使代码块里出现 HTML 标签,也只会作为代码展示,而不会被浏览器执行。

DOMPurify:最后一道安全过滤

Markdown 解析最终会得到 HTML 字符串。

Vue 里要渲染 HTML,一般会用:

<div v-html="html" />

但只要用了 v-html,就必须认真考虑安全问题。

因为 v-html 会把字符串当 HTML 插入页面。

所以最终渲染前,项目里又加了一层 DOMPurify:

const html = computed(() => {
  const rawHtml = markdown.render(props.content);

  return DOMPurify.sanitize(rawHtml, {
    USE_PROFILES: { html: true },
  });
});

这里的链路是:

Markdown 字符串
  ↓
markdown.render
  ↓
rawHtml
  ↓
DOMPurify.sanitize
  ↓
safeHtml
  ↓
v-html 渲染

有人可能会问:

既然 markdown-it 已经 html: false 了,为什么还要 DOMPurify?

我的理解是,html: false 是第一层防线。

DOMPurify 是最后一道兜底。

真实项目里,后续可能会新增 Markdown 插件,可能会支持更多语法,也可能有人改配置打开 HTML。

安全过滤单独保留,可以让组件更稳。

这不是多余,而是防止未来扩展时把安全边界弄丢。

链接为什么要特殊处理

AI 回复里经常会带链接。

比如文档地址、接口地址、参考资料。

如果链接直接在当前页打开,用户可能会离开工作台。

所以项目里改写了 link_open 渲染规则:

const defaultLinkOpenRenderer = markdown.renderer.rules.link_open;

markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
  const token = tokens[index];
  const targetIndex = token.attrIndex('target');
  const relIndex = token.attrIndex('rel');

  if (targetIndex < 0) {
    token.attrPush(['target', '_blank']);
  } else {
    token.attrs![targetIndex][1] = '_blank';
  }

  if (relIndex < 0) {
    token.attrPush(['rel', 'noopener noreferrer']);
  } else {
    token.attrs![relIndex][1] = 'noopener noreferrer';
  }

  return defaultLinkOpenRenderer
    ? defaultLinkOpenRenderer(tokens, index, options, env, self)
    : self.renderToken(tokens, index, options);
};

这里做了两件事。

第一,给链接加:

target="_blank"

让链接在新窗口打开。

第二,给链接加:

rel="noopener noreferrer"

避免新页面通过 window.opener 影响当前页面。

这属于很小但很实用的安全细节。

为什么通过 Bubble 的 messageRender 接入

当前消息列表使用的是 Ant Design X Vue 的 BubbleList。

之前纯文本时,assistant 消息直接展示 content 就够了。

现在要渲染 Markdown,就不能再让 Bubble 按普通字符串展示。

项目里是在 assistant 的 role 配置里加了 messageRender:

const roles = {
  assistant: {
    placement: 'start',
    variant: 'borderless',
    avatar: {
      src: aiAvatar,
      size: 32,
      shape: 'square',
    },
    classNames: { content: 'agent-message-ai' },
    messageRender: (content: unknown) => h(MarkdownRenderer, {
      content: String(content ?? ''),
    }),
  },
};

这段代码的意思是:

assistant 消息仍然由 Bubble 渲染气泡
但消息正文不直接显示字符串
而是交给 MarkdownRenderer 渲染

这里我没有用 BubbleList 的 message 插槽直接渲染 item.content。

原因是项目里还用到了 Bubble 的打字机能力。

如果在插槽里直接写:

<template #message="{ item }">
  <MarkdownRenderer :content="item.content" />
</template>

很容易绕过 Bubble 内部处理后的 typedContent。

这样打字机效果可能会退化成整段直接出现。

而 messageRender 更适合这个场景。

因为它接收的是 Bubble 内部已经处理后的内容。

也就是说:

SSE 追加完整 content
Bubble 根据 typing 生成 typedContent
messageRender 拿到 typedContent
MarkdownRenderer 渲染当前可见内容

这条链路比较干净。

和 SSE 流式输出的关系

后端通过 SSE 返回 chunk 时,前端持续把 chunk 追加到 assistant 消息:

replaceMessage(target, assistantKey, item => ({
  ...item,
  loading: false,
  content: `${item.content}${chunk}`,
}));

这时 message.content 是完整的 Markdown 字符串。

但是页面显示的内容不是直接等于完整 content。

因为 assistant 消息创建时配置了 typing:

typing: { step: 2, interval: 24, suffix: '|' }

于是显示链路变成:

后端 SSE chunk
  ↓
追加到 assistant.content
  ↓
Bubble typing 截取当前展示内容
  ↓
messageRender 接收当前 typedContent
  ↓
MarkdownRenderer 渲染 Markdown

这个设计的好处是:

数据层仍然是完整 Markdown
展示层仍然有打字机效果
最终内容仍然能按 Markdown 排版

三件事没有混在一起。

useWorkbenchChat 不需要知道 Markdown 怎么渲染。

MarkdownRenderer 也不需要知道 SSE 怎么连接。

Bubble 继续负责消息气泡、角色样式和 typing。

每一层只做自己的事情。

流式 Markdown 的一个现实问题

流式输出时,Markdown 内容经常是“半截”的。

比如模型正在输出代码块:

```ts
const message =

这时结尾的三个反引号可能还没回来。

MarkdownRenderer 必须能接受这种半成品内容。

它不能因为 Markdown 暂时不完整就报错,也不能让页面白屏。

这也是为什么我没有在渲染层做太多复杂假设。

当前策略是:

每次拿到当前 content
按当前内容尽量解析
解析结果交给 DOMPurify 清洗
页面正常展示当前阶段能展示的内容

等后续 chunk 到达,Markdown 结构逐渐完整,渲染结果也会自然更新。

对于 AI 流式产品来说,这比追求每一帧 Markdown 都完全正确更重要。

用户要的是持续反馈,而不是等所有格式闭合后才展示。

为什么用户消息不走 Markdown

当前项目只给 assistant 配置了 messageRender。

用户消息仍然按普通文本展示。

原因很简单:

用户消息通常是输入需求
AI 消息才是结构化输出

如果用户消息也走 Markdown,反而可能产生一些意外效果。

比如用户输入:

# 这个按钮为什么不生效

它可能被渲染成一级标题。

这不一定符合聊天输入的预期。

所以当前设计是:

user:普通文本气泡
assistant:Markdown 富文本气泡

这也是多数 AI 产品更自然的展示方式。

当前实现的完整链路

最后把整个链路串一下。

用户发送消息后:

用户输入需求
  ↓
前端插入 user 消息
  ↓
前端插入 assistant 空消息,并配置 typing
  ↓
建立 SSE 连接
  ↓
后端持续返回 Markdown chunk
  ↓
前端持续追加 assistant.content
  ↓
Bubble 根据 typing 生成当前 typedContent
  ↓
messageRender 把 typedContent 传给 MarkdownRenderer
  ↓
MarkdownRenderer 解析、高亮、净化并渲染

这条链路里,各层职责是这样的:

层责任
后端 SSE返回流式 Markdown 文本
useWorkbenchChat维护会话和消息状态
BubbleList渲染消息列表和滚动
Bubble处理角色、气泡、typing
messageRender接管 assistant 正文渲染
MarkdownRendererMarkdown 解析、高亮、安全过滤

我比较喜欢这种分层。

因为它没有把所有逻辑塞进消息列表组件里。

ChatMessageList 仍然只负责消息展示结构。

MarkdownRenderer 只负责内容渲染。

useWorkbenchChat 只负责数据流和消息状态。

后面要扩展任何一个能力,都不会牵一发动全身。

小结

这次接入 Markdown 渲染,本质上不是为了“让文本好看一点”。

它解决的是 AI 对话产品里的核心展示问题:

AI 输出是结构化内容
页面必须把结构正确表达出来

所以我选择了:

markdown-it 负责解析
highlight.js 负责代码高亮
DOMPurify 负责安全过滤
MarkdownRenderer 负责业务封装
Bubble messageRender 负责接入消息气泡

这样做之后,AI 消息可以同时具备:

流式输出
打字机效果
Markdown 排版
代码高亮
安全过滤
统一气泡样式