为什么大厂AI 聊天体验更丝滑?前端流式 Markdown 的底层真相

90 阅读4分钟

在大模型时代,前端开发的主要内容之一就是 AI Chat Bot 的开发,在聊天组件中,更为核心的内容是模型回复的流式输出,如何构建一个能力强、适配性广、性能高的流式输出组件,变成了我们可以深入研究的内容。

我们看到无论是国内还是国外知名 AI 聊天 App 的对话体验,都明显高于一些开源项目和 demo 示例,这里的原因是什么呢?请听我逐一分解。

其实国内的豆包、通义千问也都经历了很多迭代,刚开始的性能或者 bug 也都存在,但是由于 AI 发展十分迅猛,大家都变成了小步快跑,先上线再迭代


Markdown 与流式输出的天然冲突

因为大模型输出的格式前期以 Markdown 形式为主,所以基本都会选用 Markdown 解析组件。
以 ChatGPT 为例,ChatGPT 的技术栈前期是 React,其 Markdown 渲染组件选用的是 react-markdown

这是一个经典插件化架构的组件,它的插件分由两个部分组成:

  1. 解析与语法扩展阶段remarkPlugins
  2. 转换与后处理阶段rehypePlugins

通过这些插件可以增加组件对非标准 Markdown 语法的支持,比如识别表格、任务列表、删除线等,甚至是文本中的 LaTeX 数学公式标记(如 $E=mc^2$)。

这款 Markdown 组件的扩展性很强,可以实现很多自定义的节点,但是它其实并不能很好地处理 LLM 中流式输出的 Markdown,主要原因有以下 2 点:

  1. Markdown 语法通常是前后闭合的标签
    例如 **标题**,如果流式输出过程中只输出了 **标题,此时处于不完整状态,这个前置标签 react-markdown 则无法正确展示,等 **标题** 完整获取之后才能展示,此时就会出现临时闪烁 ** 符号的问题。

  2. 频繁 State 更新导致性能问题
    因为 react-markdown 组件被封装为完整的 React 组件,当流式数据源源不断地涌入时,React 里的 state 状态会导致其不断刷新,甚至超出 React 的刷新频率,导致页面性能急剧下降,特别是在渲染代码块 code 并进行高亮时尤为明显。(豆包网页端在某段时间也存在这个问题)


解决方案拆解

那么如何解决这两个问题呢?方案如下。

一、不完整 Markdown 语法的流式识别

针对不完整的 Markdown 语法结构,我们可以通过专门设计的流式语法处理机制来智能识别。
对多种 Markdown 语法的完整性进行检查,针对常见语法类型(如链接、图片、标题等)定义明确规则,示例如下:

语法类型完整格式示例未完成状态检测规则未完成示例
链接[text](url)检测 []() 是否成对闭合[示例网站](https://example
图片![alt](src)检测 ![]() 是否成对闭合![产品图](https://cdn.example.com
标题## 标题检测标题标记(#)后是否有内容或空格###
强调**粗体**检测 *_ 是否成对出现**未完成的粗体
XML 标签<div>检测标签是否有对应的闭合标签(如 </div><div class="

针对不完整的语法结构,设置对应的自定义渲染未完成语法,从而解决闪烁问题。


二、通过分块渲染解决性能问题

针对重复渲染导致的性能问题,我们可以将原始 Markdown 文本分割成块,识别离散的元素,把内容拆分成多个块,然后利用 React 的 memo 功能,仅更新实际发生变化的块,从而优化重新渲染性能。

下面以 marked 库为例进行示范:

import { marked } from 'marked';
import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';

function parseMarkdownIntoBlocks(markdown: string): string[] {
  const tokens = marked.lexer(markdown);
  return tokens.map(token => token.raw);
}

const MemoizedMarkdownBlock = memo(
  ({ content }: { content: string }) => {
    return <ReactMarkdown>{content}</ReactMarkdown>;
  }
);

export const MemoizedMarkdown = memo(
  ({ content, id }: { content: string; id: string }) => {
    const blocks = useMemo(() => parseMarkdownIntoBlocks(content), [content]);

    return blocks.map((block, index) => (
      <MemoizedMarkdownBlock content={block} key={`${id}-md-${index}`} />
    ));
  }
);

现成方案推荐

通过上述两个方案,可以解决绝大多数 AI Chat Bot 流式输出体验问题,让你的产品体验来到一线水平。

当然有人会有疑问:这些方案比较复杂,是否已经有成熟的组件库可以直接使用?

我来回答你:有的,兄弟。

这里推荐 2 个流式 Markdown 组件库:

  • x-markdown(Antd 团队出品)
  • streamdown(Vercel 团队出品)

有兴趣的同学可以阅读这两个组件库的源码,它们都使用了上述的优化方案。

当然,由于 AI 发展的速度非常快,上述两个库截止当前仍只发布了几个月,还需要不断解决 issue 和持续迭代。各个大厂也都在沉淀内部技术方案,而非完全使用自家的开源版本,比如通义千问没有使用 AntdX,豆包也没有使用 Semi Design,这些开源项目更多是各家商业化过程中的技术沉淀。


以上就是前端 AI 研发深层次的 AI Chat 技术方案,希望大家点赞、关注、加收藏,后续会持续输出更多前端 AI 深层次干货!

公众号:橙子的AI前端笔记