AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染
前言
在做 AI 对话页面时,消息展示一开始很容易被低估。
很多时候我们会先把后端返回的内容直接放进气泡里:
vue
{{ message.content }}
如果 AI 只回复一句普通文本,这当然没有问题。
但真实项目里,AI 的输出通常不会这么简单。
它可能返回:
text
标题
段落
列表
代码块
表格
链接
引用
步骤说明
错误分析
实现方案

尤其是在 Zero Code Agent 这种 AI Coding 场景里,AI 经常要输出方案、代码、目录结构、配置说明。
如果仍然只按普通字符串展示,用户看到的会是一大坨文本:
text
# 页面结构
- Header
- Sidebar
- Content
```ts
const app = createApp(App)
```
内容本身是结构化的,但页面没有把结构表达出来。
这就是为什么 AI 对话不能只停留在"把字符串显示出来"。
这篇文章记录一下我在项目里为什么要引入 Markdown 解析器,以及我是如何基于成熟底层库封装自己的 MarkdownRenderer 业务组件,再接入到 Ant Design X Vue 的 Bubble 消息气泡里的。
为什么不是直接展示字符串
AI 返回的内容,本质上经常是"带结构的文本"。
Markdown 正好是这类内容最常见的表达格式。
比如模型返回一份实现方案:
md
## 实现思路
1. 新增 MarkdownRenderer 组件
2. 使用 markdown-it 解析 Markdown
3. 使用 highlight.js 处理代码高亮
4. 使用 DOMPurify 做安全过滤
```ts
messageRender: (content) => h(MarkdownRenderer, {
content: String(content ?? ''),
})
```
如果按纯文本展示,用户看到的是符号本身。
如果按 Markdown 渲染,用户看到的是:
text
清晰的标题
有层级的列表
带高亮的代码块
可点击的链接
可阅读的表格
这两种体验差很多。
纯文本展示的问题主要有三个。
第一个问题是可读性差。
AI 回复越长,纯文本越难读。尤其是生成方案、技术分析、代码说明时,用户需要快速扫结构,而不是从一堆字符里自己找重点。
第二个问题是代码不可用。
AI Coding 场景里,代码块是高频内容。没有代码块样式和语法高亮,用户复制、阅读、定位问题都会变得困难。
第三个问题是产品质感不够。
AI 产品的输出不是普通日志,消息区本身就是产品主界面。内容渲染质量直接影响用户对系统能力的判断。
所以 Markdown 渲染不是锦上添花。
在 AI 对话产品里,它更像是基础能力。
Markdown 解析器解决什么问题
Markdown 解析器要解决的核心问题是:
把 AI 返回的 Markdown 字符串,转换成浏览器可以渲染的 HTML 结构。
比如输入:
md
## 页面结构
- 顶部导航
- 左侧历史
- 中间对话区
```ts
const message = 'hello'
```
经过解析之后,会变成类似:
html
<h2>页面结构</h2>
<ul>
<li>顶部导航</li>
<li>左侧历史</li>
<li>中间对话区</li>
</ul>
<pre><code class="language-ts">const message = 'hello'</code></pre>
浏览器真正擅长渲染的是 HTML。
Markdown 解析器就是中间这一层:
text
AI 输出 Markdown
↓
Markdown 解析器
↓
HTML 结构
↓
组件样式渲染
但注意,Markdown 解析器本身只负责解析。
它不等于完整的消息展示方案。
真实项目里还需要处理:
text
代码高亮
HTML 安全过滤
链接打开方式
表格横向滚动
代码块样式
流式输出中的半截 Markdown
和消息气泡组件的配合

所以我没有直接把某个 Markdown 组件丢进页面,而是选择了更稳的方式:
成熟底层库 + 自己封装业务组件。
为什么选择成熟底层库
当前项目选择的是:
text
markdown-it Markdown 解析
highlight.js 代码高亮
DOMPurify HTML 安全过滤
这三个库各司其职。
| 库 | 作用 |
|---|---|
markdown-it |
把 Markdown 字符串解析成 HTML |
highlight.js |
给代码块做语法高亮 |
DOMPurify |
清理不安全 HTML,降低 XSS 风险 |
这个组合在工程上比较舒服。
它不是一个黑盒大组件,而是一组成熟的基础能力。
这样做的好处是:渲染逻辑掌握在自己手里,后面要加复制代码、代码块标题、图片预览、表格优化、暗色主题,都比较容易扩展。
如果一开始直接使用封装很重的 Vue Markdown 组件,短期会快一点,但后期要改细节时经常会被组件内部结构限制。
AI 对话这种页面,后面一定会长出很多业务细节。
所以我的判断是:
text
底层解析用成熟库
业务样式和行为自己封装
这也是更接近真实项目的做法。
安装依赖
项目里新增了这些依赖:
json
{
"dependencies": {
"dompurify": "^3.2.7",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2"
}
}
对应命令可以是:
bash
pnpm add markdown-it highlight.js dompurify
pnpm add -D @types/markdown-it
DOMPurify 和 highlight.js 当前版本本身已经带类型支持,不需要额外安装类型包。
封装 MarkdownRenderer
在项目里,我把 Markdown 渲染单独封装成了一个组件:
text
src/features/workbench/components/chat/MarkdownRenderer.vue
它的职责很明确:
text
接收 markdown 字符串
解析成 HTML
做代码高亮
做安全过滤
输出最终可渲染内容
组件对外只暴露一个 content:
ts
const props = defineProps<{
content: string;
}>();
这点很重要。
业务层不需要关心 Markdown 怎么解析,也不需要关心代码高亮怎么配置。
业务层只需要传入字符串:
text
MarkdownRenderer(content)
组件内部负责剩下的事情。
markdown-it 配置
核心解析器这样创建:
ts
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 表示自动识别链接。
比如模型输出:
text
https://example.com
可以自动变成链接。
breaks: true 更适合聊天场景。
用户在聊天里通常认为换行就是换行,而不是严格遵守标准 Markdown 的段落规则。打开这个配置后,模型输出中的普通换行会更接近对话阅读习惯。
langPrefix: 'language-' 是给代码块语言 class 加前缀。
比如:
md
```ts
const name = 'zero code'
```
会得到:
html
<code class="language-ts">
这对代码高亮和后续样式扩展都有帮助。
代码高亮如何处理
AI Coding 场景里,代码块是核心内容。
所以 MarkdownRenderer 里接入了 highlight.js:
ts
highlight(code: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
}
return escapeHtml(code);
}
这里没有盲目高亮所有内容。
先判断:
ts
lang && hljs.getLanguage(lang)
只有模型标注了语言,并且 highlight.js 支持这个语言时,才执行高亮。
如果语言不存在,就走普通转义:
ts
return escapeHtml(code);
这样可以避免因为模型输出了不存在的语言标识,导致高亮库解析异常。
这也是 AI 场景里常见的工程处理:
模型输出可以不完美,前端渲染必须稳定。
为什么要手动 escapeHtml
代码块即使不做高亮,也不能直接返回原始内容。
比如代码里有:
html
<script>alert(1)</script>
如果不转义,后续又通过 v-html 渲染,就会有安全风险。
所以项目里写了一个轻量转义函数:
ts
const escapeHtml = (value: string) => value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
它的作用是把危险字符变成普通文本。
这样即使代码块里出现 HTML 标签,也只会作为代码展示,而不会被浏览器执行。
DOMPurify:最后一道安全过滤
Markdown 解析最终会得到 HTML 字符串。
Vue 里要渲染 HTML,一般会用:
vue
<div v-html="html" />
但只要用了 v-html,就必须认真考虑安全问题。
因为 v-html 会把字符串当 HTML 插入页面。
所以最终渲染前,项目里又加了一层 DOMPurify:
ts
const html = computed(() => {
const rawHtml = markdown.render(props.content);
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
});
});
这里的链路是:
text
Markdown 字符串
↓
markdown.render
↓
rawHtml
↓
DOMPurify.sanitize
↓
safeHtml
↓
v-html 渲染
有人可能会问:
text
既然 markdown-it 已经 html: false 了,为什么还要 DOMPurify?
我的理解是,html: false 是第一层防线。
DOMPurify 是最后一道兜底。
真实项目里,后续可能会新增 Markdown 插件,可能会支持更多语法,也可能有人改配置打开 HTML。
安全过滤单独保留,可以让组件更稳。
这不是多余,而是防止未来扩展时把安全边界弄丢。
链接为什么要特殊处理
AI 回复里经常会带链接。
比如文档地址、接口地址、参考资料。
如果链接直接在当前页打开,用户可能会离开工作台。
所以项目里改写了 link_open 渲染规则:
ts
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);
};
这里做了两件事。
第一,给链接加:
html
target="_blank"
让链接在新窗口打开。
第二,给链接加:
html
rel="noopener noreferrer"
避免新页面通过 window.opener 影响当前页面。
这属于很小但很实用的安全细节。
为什么通过 Bubble 的 messageRender 接入
当前消息列表使用的是 Ant Design X Vue 的 BubbleList。
之前纯文本时,assistant 消息直接展示 content 就够了。
现在要渲染 Markdown,就不能再让 Bubble 按普通字符串展示。
项目里是在 assistant 的 role 配置里加了 messageRender:
ts
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 ?? ''),
}),
},
};
这段代码的意思是:
text
assistant 消息仍然由 Bubble 渲染气泡
但消息正文不直接显示字符串
而是交给 MarkdownRenderer 渲染
这里我没有用 BubbleList 的 message 插槽直接渲染 item.content。
原因是项目里还用到了 Bubble 的打字机能力。
如果在插槽里直接写:
vue
<template #message="{ item }">
<MarkdownRenderer :content="item.content" />
</template>
很容易绕过 Bubble 内部处理后的 typedContent。
这样打字机效果可能会退化成整段直接出现。
而 messageRender 更适合这个场景。
因为它接收的是 Bubble 内部已经处理后的内容。
也就是说:
text
SSE 追加完整 content
Bubble 根据 typing 生成 typedContent
messageRender 拿到 typedContent
MarkdownRenderer 渲染当前可见内容
这条链路比较干净。
和 SSE 流式输出的关系
后端通过 SSE 返回 chunk 时,前端持续把 chunk 追加到 assistant 消息:
ts
replaceMessage(target, assistantKey, item => ({
...item,
loading: false,
content: `${item.content}${chunk}`,
}));
这时 message.content 是完整的 Markdown 字符串。
但是页面显示的内容不是直接等于完整 content。
因为 assistant 消息创建时配置了 typing:
ts
typing: { step: 2, interval: 24, suffix: '|' }
于是显示链路变成:
text
后端 SSE chunk
↓
追加到 assistant.content
↓
Bubble typing 截取当前展示内容
↓
messageRender 接收当前 typedContent
↓
MarkdownRenderer 渲染 Markdown
这个设计的好处是:
text
数据层仍然是完整 Markdown
展示层仍然有打字机效果
最终内容仍然能按 Markdown 排版
三件事没有混在一起。
useWorkbenchChat 不需要知道 Markdown 怎么渲染。
MarkdownRenderer 也不需要知道 SSE 怎么连接。
Bubble 继续负责消息气泡、角色样式和 typing。
每一层只做自己的事情。
流式 Markdown 的一个现实问题
流式输出时,Markdown 内容经常是"半截"的。
比如模型正在输出代码块:
md
```ts
const message =
这时结尾的三个反引号可能还没回来。
MarkdownRenderer 必须能接受这种半成品内容。
它不能因为 Markdown 暂时不完整就报错,也不能让页面白屏。
这也是为什么我没有在渲染层做太多复杂假设。
当前策略是:
text
每次拿到当前 content
按当前内容尽量解析
解析结果交给 DOMPurify 清洗
页面正常展示当前阶段能展示的内容
等后续 chunk 到达,Markdown 结构逐渐完整,渲染结果也会自然更新。
对于 AI 流式产品来说,这比追求每一帧 Markdown 都完全正确更重要。
用户要的是持续反馈,而不是等所有格式闭合后才展示。
为什么用户消息不走 Markdown
当前项目只给 assistant 配置了 messageRender。
用户消息仍然按普通文本展示。
原因很简单:
text
用户消息通常是输入需求
AI 消息才是结构化输出
如果用户消息也走 Markdown,反而可能产生一些意外效果。
比如用户输入:
text
# 这个按钮为什么不生效
它可能被渲染成一级标题。
这不一定符合聊天输入的预期。
所以当前设计是:
text
user:普通文本气泡
assistant:Markdown 富文本气泡
这也是多数 AI 产品更自然的展示方式。
当前实现的完整链路
最后把整个链路串一下。
用户发送消息后:
text
用户输入需求
↓
前端插入 user 消息
↓
前端插入 assistant 空消息,并配置 typing
↓
建立 SSE 连接
↓
后端持续返回 Markdown chunk
↓
前端持续追加 assistant.content
↓
Bubble 根据 typing 生成当前 typedContent
↓
messageRender 把 typedContent 传给 MarkdownRenderer
↓
MarkdownRenderer 解析、高亮、净化并渲染
这条链路里,各层职责是这样的:
| 层 | 责任 |
|---|---|
| 后端 SSE | 返回流式 Markdown 文本 |
useWorkbenchChat |
维护会话和消息状态 |
BubbleList |
渲染消息列表和滚动 |
Bubble |
处理角色、气泡、typing |
messageRender |
接管 assistant 正文渲染 |
MarkdownRenderer |
Markdown 解析、高亮、安全过滤 |
我比较喜欢这种分层。
因为它没有把所有逻辑塞进消息列表组件里。
ChatMessageList 仍然只负责消息展示结构。
MarkdownRenderer 只负责内容渲染。
useWorkbenchChat 只负责数据流和消息状态。
后面要扩展任何一个能力,都不会牵一发动全身。
小结
这次接入 Markdown 渲染,本质上不是为了"让文本好看一点"。
它解决的是 AI 对话产品里的核心展示问题:
text
AI 输出是结构化内容
页面必须把结构正确表达出来
所以我选择了:
text
markdown-it 负责解析
highlight.js 负责代码高亮
DOMPurify 负责安全过滤
MarkdownRenderer 负责业务封装
Bubble messageRender 负责接入消息气泡
这样做之后,AI 消息可以同时具备:
text
流式输出
打字机效果
Markdown 排版
代码高亮
安全过滤
统一气泡样式