AI 探索技术手记

AI 对话 Markdown 即时渲染:不截断标签的流式解析方案

Michael Meng· 2026年7月22日· ◷ 3 分钟阅读· ◉ 1 次浏览
AI 对话 Markdown 即时渲染:不截断标签的流式解析方案

一、问题:为什么 AI 流式输出 Markdown 会出 bug?

假设 AI 正在逐字返回一段 Markdown:

这个效果非常**粗体文字**棒

如果你在收到 **粗体 时就 dangerouslySetInnerHTML,渲染结果是:
- 粗体 的后一半丢失
- 后续文字被错误的 HTML 包裹
- 页面布局可能错乱

这会导致:
- 代码块的高亮闪烁
- 链接只显示一半
- 表格结构破碎
- 用户体验极差


二、方案一:全量重渲染(最简单)

import { marked } from 'marked';
import { useState, useRef } from 'react';

function ChatMessage({ streamText }: { streamText: string }) {
  // 每次都重新渲染全部 Markdown
  const html = marked.parse(streamText);
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

优点:实现简单,因为 marked 库可以容错处理不完整的 Markdown
缺点:长文本高频刷新有性能开销

适用场景:消息总长度 < 5000 字,大多数应用的首选方案。


三、方案二:块级增量渲染(推荐)

核心思路:只在完整块(段落/代码块/标题 等)结束时才渲染,避免截断。

import { useState, useRef, useEffect } from 'react';
import { marked } from 'marked';

function useStreamingMarkdown(streamText: string) {
  const [blocks, setBlocks] = useState<string[]>([]);
  const lastBuffer = useRef('');

  useEffect(() => {
    // 按双换行分割块
    const allText = streamText;
    const segments = allText.split('\n\n');

    // 最后一个可能不完整,先不渲染
    const completeBlocks = segments.slice(0, -1);
    const pendingBlock = segments[segments.length - 1];

    const newHtmlBlocks = completeBlocks.map((block, i) => {
      if (i >= blocks.length) {
        return marked.parse(block);
      }
      return blocks[i];
    });

    setBlocks(newHtmlBlocks);
    lastBuffer.current = pendingBlock;
  }, [streamText]);

  // 流结束后渲染缓冲块
  const finalize = () => {
    if (lastBuffer.current) {
      setBlocks(prev => [...prev, marked.parse(lastBuffer.current)]);
      lastBuffer.current = '';
    }
  };

  return { blocks, finalize };
}

优点:兼顾性能与体验,不会出现标签截断
缺点:需要额外处理代码块(三引号跨多行)


四、方案三:状态机增量解析(终极方案)

使用状态机跟踪当前解析位置,只在内容完整时输出 HTML。

type StreamState = 'normal' | 'code_fence' | 'inline_code' | 'bold';

class StreamingMarkdownParser {
  private state: StreamState = 'normal';
  private buffer = '';
  private output = '';

  feed(chunk: string): string {
    this.buffer += chunk;
    let newOutput = '';

    for (const char of this.buffer) {
      switch (this.state) {
        case 'normal':
          if (this.buffer.endsWith('```')) {
            this.state = 'code_fence';
          } else if (char === '\n' && this.buffer.includes('\n\n')) {
            newOutput += this.processParagraph();
          }
          break;
        case 'code_fence':
          if (this.buffer.endsWith('\n```\n')) {
            newOutput += this.processCodeBlock();
            this.state = 'normal';
          }
          break;
      }
    }

    this.output += newOutput;
    return newOutput;
  }

  private processParagraph(): string {
    // 段落完整,渲染
    const lines = this.buffer.split('\n\n');
    const paragraph = lines[0];
    this.buffer = lines.slice(1).join('\n\n');
    return marked.parse(paragraph);
  }

  private processCodeBlock(): string {
    const result = marked.parse(this.buffer);
    this.buffer = '';
    return result;
  }
}

优点:性能最优,体验最好
缺点:实现复杂,需要处理各种 Markdown 语法状态


五、生产环境推荐组合

import { marked } from 'marked';
import DOMPurify from 'dompurify';

// 配置 marked
marked.setOptions({
  breaks: true,        // 单换行 = <br>
  gfm: true,           // 表格、任务列表
  highlight: (code, lang) => {
    return hljs.highlightAuto(code, [lang]).value;
  }
});

// 安全防护
const renderSafe = (md: string) => {
  const raw = marked.parse(md);
  return DOMPurify.sanitize(raw, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
    ALLOWED_ATTR: ['href', 'target', 'class']
  });
};

六、总结

方案 复杂度 性能 体验 推荐场景
全量重渲染 ⭐⭐ ⭐⭐⭐ 短文本、快速原型
块级增量 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 生产推荐
状态机 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 极致体验要求

实际项目中,块级增量渲染 是最佳性价比方案:实现简单、体验流畅、大模型回复也不会卡顿。

Comments 留言讨论

还没有评论,来抢个沙发,聊聊你的看法~

Michael.Meng

michaelnews@126.com
用 AI 记录,用文字沉淀

© 2026 Michael Meng · 保留所有权利 · Powered by FastAPI + Nuxt