在大模型时代,前端开发的主要内容之一就是 AI Chat Bot 的开发,在聊天组件中,更为核心的内容是模型回复的流式输出,如何构建一个能力强、适配性广、性能高的流式输出组件,变成了我们可以深入研究的内容。
我们看到无论是国内还是国外知名 AI 聊天 App 的对话体验,都明显高于一些开源项目和 demo 示例,这里的原因是什么呢?请听我逐一分解。
其实国内的豆包、通义千问也都经历了很多迭代,刚开始的性能或者 bug 也都存在,但是由于 AI 发展十分迅猛,大家都变成了小步快跑,先上线再迭代。
Markdown 与流式输出的天然冲突
因为大模型输出的格式前期以 Markdown 形式为主,所以基本都会选用 Markdown 解析组件。
以 ChatGPT 为例,ChatGPT 的技术栈前期是 React,其 Markdown 渲染组件选用的是 react-markdown。
这是一个经典插件化架构的组件,它的插件分由两个部分组成:
- 解析与语法扩展阶段:
remarkPlugins - 转换与后处理阶段:
rehypePlugins
通过这些插件可以增加组件对非标准 Markdown 语法的支持,比如识别表格、任务列表、删除线等,甚至是文本中的 LaTeX 数学公式标记(如 $E=mc^2$)。
这款 Markdown 组件的扩展性很强,可以实现很多自定义的节点,但是它其实并不能很好地处理 LLM 中流式输出的 Markdown,主要原因有以下 2 点:
-
Markdown 语法通常是前后闭合的标签
例如**标题**,如果流式输出过程中只输出了**标题,此时处于不完整状态,这个前置标签 react-markdown 则无法正确展示,等**标题**完整获取之后才能展示,此时就会出现临时闪烁**符号的问题。 -
频繁 State 更新导致性能问题
因为 react-markdown 组件被封装为完整的 React 组件,当流式数据源源不断地涌入时,React 里的 state 状态会导致其不断刷新,甚至超出 React 的刷新频率,导致页面性能急剧下降,特别是在渲染代码块code并进行高亮时尤为明显。(豆包网页端在某段时间也存在这个问题)
解决方案拆解
那么如何解决这两个问题呢?方案如下。
一、不完整 Markdown 语法的流式识别
针对不完整的 Markdown 语法结构,我们可以通过专门设计的流式语法处理机制来智能识别。
对多种 Markdown 语法的完整性进行检查,针对常见语法类型(如链接、图片、标题等)定义明确规则,示例如下:
| 语法类型 | 完整格式示例 | 未完成状态检测规则 | 未完成示例 |
|---|---|---|---|
| 链接 | [text](url) | 检测 [ 与 ]、( 与 ) 是否成对闭合 | [示例网站](https://example |
| 图片 |  | 检测 ![ 与 ]、( 与 ) 是否成对闭合 | 后是否有内容或空格 | ### |
| 强调 | **粗体** | 检测 * 或 _ 是否成对出现 | **未完成的粗体 |
| 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前端笔记