AI Chat实现:如何在流式输出中实时输出带样式的文本

0 阅读12分钟

AI Chat 流式 Markdown 渲染实践:双阶段方案让“边输出边可读”

前言

做 AI Chat 时,最常见的体验问题之一是:

  • 流式输出阶段只是“纯文本打字机”;
  • Markdown(标题、列表、代码块)要等回答结束才生效;
  • 用户在长回答里很难快速定位重点。

这篇文章分享一套在业务里可落地的核心方案:双阶段渲染

  • Phase A(流式阶段):轻量 Markdown 渲染,优先可读性和稳定性;
  • Phase B(完成阶段):完整 Markdown + 代码高亮,一次性精修。

目标很明确:既要“实时可读”,又要“最终精致”。


0. 需求:像动图中一样实现js代码块的解析(包含代码围栏和高亮两步)

会话样式.gif

1. 问题复盘:为什么会“要等全部返回才像 Markdown”

很多项目里,流式链路其实没问题,问题在渲染策略:

  1. onChunk 已经持续拿到了 fullText
  2. 但渲染组件在 isStreaming=true 时走了纯文本分支;
  3. 于是 Markdown 解析被延后到 done

表现出来就是:回答结束前只有换行,没有结构化样式。


2. 核心思路:双阶段渲染

Phase A:流式轻量渲染(不做高亮)

流式阶段每个 chunk 都可能触发渲染,高亮计算很重。
因此 Phase A 的策略是:

  • 保留 markdown-it 的基础解析能力;
  • 代码块仅做转义和 <pre><code> 包裹,不做 highlight.js
  • 对不完整语法做“最小补全”,避免 UI 抖动。

Phase B:完成后完整渲染(一次高亮)

当收到 done

  • 切换到完整渲染器;
  • 执行一次 highlight.js
  • 得到最终可发布级展示效果。

这能避免流式期间反复全量高亮导致的卡顿。

为了更直观看清双阶段在运行时怎么切换,可以看这张流程图:

flowchart TD
  sseChunk["SSE返回Chunk"] --> appendText["onChunk累加fullText"]
  appendText --> streamingCheck{"isStreaming?"}
  streamingCheck -->|是| safePreprocess["toSafeStreamingMarkdown最小补全"]
  safePreprocess --> phaseARender["mdStreaming渲染(无高亮)"]
  phaseARender --> uiUpdateA["气泡实时更新(可读优先)"]
  appendText --> doneEvent["收到done事件"]
  doneEvent --> phaseBRender["mdFinal渲染(完整高亮)"]
  phaseBRender --> uiUpdateB["最终展示态(精修完成)"]

图里最关键的一点是:高亮只在 done 后触发一次,流式阶段只做轻量渲染。


3. 实现细节(可直接复用)

以下为核心片段,完整实现在项目 MarkdownRenderer.vue

3.1 两套 markdown-it 实例

const createMarkdownIt = (highlight) => new MarkdownIt({
  html: false,
  breaks: true,
  linkify: true,
  typographer: true,
  highlight
})

// Phase A: 流式阶段(无高亮)
const mdStreaming = createMarkdownIt((str, lang) => {
  const className = lang ? ` class="language-${lang}"` : ''
  return `<pre class="hljs"><code${className}>${escapeHtml(str)}</code></pre>`
})

// Phase B: 完成阶段(完整高亮)
const mdFinal = createMarkdownIt((str, lang) => {
  if (lang && hljs.getLanguage(lang)) {
    const result = hljs.highlight(str, { language: lang, ignoreIllegals: true })
    return `<pre class="hljs"><code class="language-${lang}">${result.value}</code></pre>`
  }
  return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
})

这段代码的核心不是“写了两个变量”,而是把渲染成本按阶段拆开。可以这样理解:

  • createMarkdownIt(highlight):这是一个工厂函数。
    作用是把 html / breaks / linkify / typographer 这些公共配置统一收口,避免维护两套实例时出现配置漂移。

  • highlight 参数:这里是可插拔策略位。
    同样的 Markdown 解析器,不同的 highlight 实现,就能得到“轻量渲染”与“完整高亮渲染”两种行为。

  • mdStreaming:流式阶段实例。
    它保留代码块容器结构(pre + code + language-*),但只做 escapeHtml,不做语法着色。
    这意味着用户仍能看到“这是代码块、是什么语言”,同时把 CPU 成本压低。

  • mdFinal:完成阶段实例。
    这里才调用 hljs.highlight(...) 做真正的语法高亮。
    if (lang && hljs.getLanguage(lang)) 是保护条件,避免未知语言直接抛错。

  • ignoreIllegals: true:高亮容错开关。
    代码片段不完整或语法不标准时,不会因为高亮异常导致整段渲染失败。

  • escapeHtml(str) 的 fallback:兜底策略。
    如果语言不存在或高亮不可用,仍然保证代码以安全文本展示,而不是把原始 HTML 注入页面。

一句话总结这段设计:
“同一套 Markdown 基线配置,按阶段切换不同 highlight 策略,换来体验和性能的平衡。”

3.2 流式“最小补全”防抖动

流式过程中最容易出问题的是未闭合语法:代码围栏和行内反引号。

为什么会抖动?因为流式文本天然是“半成品”:

  • 代码块经常先收到 `````` 开头,结束围栏要过几秒才到;
  • 行内代码可能先收到一个反引号,后一段才闭合;
  • Markdown 解析器面对不完整结构时,会在“普通文本 / 块级元素”之间来回切换,视觉上就像跳动。

这时我们不应该强行“猜语义”,而是做一个非常克制的策略:只补语法闭合,不改原始内容意图
也就是:

  1. 如果 ````` 数量是奇数,临时在末尾补一个结束围栏;
  2. 如果行内反引号是奇数,临时补一个反引号;
  3. 下一次 chunk 到来后重新计算,始终基于最新全文判断。

这样做的好处是:

  • 渲染结构更稳定,代码块不会一会儿是段落、一会儿是代码;
  • 补全是“临时态”,不会污染最终内容;
  • 实现足够轻量,适合每个 chunk 都执行。
function toSafeStreamingMarkdown(input) {
  let text = input || ''

  // 代码围栏 ``` 奇数则补齐
  const fenceCount = (text.match(/```/g) || []).length
  if (fenceCount % 2 === 1) text += '\n```'

  // 行内反引号(忽略 ```)奇数则补齐
  let inlineTickCount = 0
  for (let i = 0; i < text.length; i++) {
    if (text.startsWith('```', i)) {
      i += 2
      continue
    }
    if (text[i] === '`') inlineTickCount++
  }
  if (inlineTickCount % 2 === 1) text += '`'

  return text
}

简单理解:它不是在“修正文案”,而是在给流式渲染加一个“防抖层”。

3.3 渲染切换逻辑

双阶段渲染的关键不在于“写两套解析器”,而在于切换时机必须清晰且可预测

这里的规则非常明确:

  • isStreaming=true:走 mdStreaming,目标是低成本、可读优先;
  • isStreaming=false:走 mdFinal,目标是完整高亮、最终形态。

这意味着一次回答只会经历一次“精修”:

  • 流式期间多次轻量渲染;
  • 结束时一次完整渲染。

所以我们既能保证实时可读,又不会在流式阶段反复触发高亮的全量计算。

const renderedHtml = computed(() => {
  if (!props.content) return ''
  if (props.isStreaming) {
    return mdStreaming.render(toSafeStreamingMarkdown(props.content))
  }
  return mdFinal.render(props.content)
})

这几行可以按“输入 -> 分流 -> 输出”来理解:

  • renderedHtml:这是一个 Vue computed,本质是“根据当前状态实时计算出来的 HTML 字符串”。模板里通常会通过 v-html 把它渲染出来。
  • if (!props.content) return '':兜底分支。AI 还没返回内容时直接返回空字符串,避免渲染器做无意义计算。
  • props.isStreaming:外部传入的流式状态开关。true 代表回答还在持续到达,false 代表已经结束。
  • mdStreaming.render(...):流式阶段使用轻量渲染器(即只包含如代码标识框不包含代码的高亮功能)。它保留 Markdown 结构解析,但不做代码高亮,目的是降低每个 chunk 的计算成本。
  • toSafeStreamingMarkdown(props.content):在真正渲染前先做“最小补全”,把未闭合代码围栏/反引号临时闭合,减少流式过程中的布局跳动。
  • mdFinal.render(props.content):流结束后的最终渲染器。这里才启用完整高亮与最终样式,保证收尾质量。

如果把它翻译成一句话,就是:
“内容为空不渲染;流式中走轻量稳定路径;结束后走完整精修路径。”

从工程收益看,这段切换逻辑直接解决了两个核心矛盾:

  • 体验上,用户不再等“最后一刻”才看到结构化内容;
  • 性能上,主线程不会被高频高亮反复压垮。

4. 待优化点与踩坑复盘

这次双阶段渲染已经解决了核心体验问题,但如果要继续往“大厂生产级”演进,下面这些点建议优先处理。

4.1 待优化点(下一阶段可以做)

  1. 流式渲染节流调度
    当前每个 chunk 都可能触发一次渲染。若后端切 chunk 很碎,主线程仍会有压力。
    建议增加 50~100ms 的调度窗口(requestAnimationFrame 或定时合批),把多次更新合并成一次 render。

  2. 超长内容分档策略
    回答很长(尤其带大代码块)时,即使无高亮也可能变慢。
    建议按长度分档,比如超过阈值后提升节流间隔,或者对历史片段做惰性处理。

  3. 更稳健的“未闭合语法”识别
    目前是“最小补全”策略,已覆盖高频场景。
    后续可补充更多边界:比如复杂嵌套列表、多语言围栏混排、反引号出现在字符串中的误判。

4.2 这次实际踩过的坑

  1. 看似是 Markdown 问题,实际是 CSS 干扰
    我们遇到过“段落间隔异常大”的现象,根因是消息气泡通用样式里 white-space: pre-wrap 影响了 Markdown 块级排版。
    修复思路:用户纯文本保留 pre-wrap,AI Markdown 区域改为 normal

  2. 流式阶段直接高亮会导致性能抖动
    一开始若对每次 chunk 都跑高亮,长回复场景下会出现明显卡顿。
    这也是双阶段渲染必要性的关键依据:流式轻量,结束精修。

  3. 未闭合代码围栏导致结构跳变
    不做最小补全时,代码块会在“普通段落/代码块”之间来回切换,阅读体验很差。
    增加补全后可显著降低视觉跳动。


结语

AI Chat 的体验,不只是“能返回”,而是“返回过程是否可读”。
双阶段渲染的价值就在于:把最终质量前置到生成过程里

一句话总结:
流式阶段先保证读得懂,完成阶段再做到看起来专业。

完整代码

<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'

// 只导入常用语言,减小体积
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import python from 'highlight.js/lib/languages/python'
import java from 'highlight.js/lib/languages/java'
import html from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import sql from 'highlight.js/lib/languages/sql'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import markdown from 'highlight.js/lib/languages/markdown'

// 注册语言
hljs.registerLanguage('javascript', javascript)
hljs.registerLanguage('typescript', typescript)
hljs.registerLanguage('python', python)
hljs.registerLanguage('java', java)
hljs.registerLanguage('html', html)
hljs.registerLanguage('css', css)
hljs.registerLanguage('sql', sql)
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('shell', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('yaml', yaml)
hljs.registerLanguage('yml', yaml)
hljs.registerLanguage('markdown', markdown)
hljs.registerLanguage('md', markdown)

// Props 定义
const props = defineProps({
  content: {
    type: String,
    default: ''
  },
  isStreaming: {
    type: Boolean,
    default: false
  }
})

// 复用基础配置,避免两套实例配置漂移
const createMarkdownIt = (highlight) => new MarkdownIt({
  html: false,        // 禁用 HTML,防止 XSS
  breaks: true,       // 将换行符转换为 <br>
  linkify: true,      // 自动转换 URL 为链接
  typographer: true,  // 启用排版优化
  highlight
})

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

// 流式阶段:只做基础 Markdown 渲染,不做语法高亮(降低每个 chunk 的计算成本)
const mdStreaming = createMarkdownIt((str, lang) => {
  const className = lang ? ` class="language-${lang}"` : ''
  return `<pre class="hljs"><code${className}>${escapeHtml(str)}</code></pre>`
})

// 完成阶段:做完整渲染和代码高亮
const mdFinal = createMarkdownIt((str, lang) => {
  if (lang && hljs.getLanguage(lang)) {
    try {
      const result = hljs.highlight(str, { language: lang, ignoreIllegals: true })
      return `<pre class="hljs"><code class="language-${lang}">${result.value}</code></pre>`
    } catch (__) {
      // 高亮失败,回退为转义文本
    }
  }
  return `<pre class="hljs"><code>${escapeHtml(str)}</code></pre>`
})

function applySafeLinkRule(mdInstance) {
  const defaultRender = mdInstance.renderer.rules.link_open || function(tokens, idx, options, env, self) {
    return self.renderToken(tokens, idx, options)
  }

  mdInstance.renderer.rules.link_open = function(tokens, idx, options, env, self) {
    const token = tokens[idx]
    const hrefIndex = token.attrIndex('href')
    if (hrefIndex >= 0) {
      const href = token.attrs[hrefIndex][1]
      token.attrPush(['target', '_blank'])
      token.attrPush(['rel', 'noopener noreferrer'])
      if (href.startsWith('http://') || href.startsWith('https://')) {
        token.attrPush(['class', 'external-link'])
      }
    }
    return defaultRender(tokens, idx, options, env, self)
  }
}

applySafeLinkRule(mdStreaming)
applySafeLinkRule(mdFinal)

// 流式文本不完整时做最小补全,避免 markdown 结构大幅抖动
function toSafeStreamingMarkdown(input) {
  let text = input || ''

  // 1) 围栏代码块:``` 数量为奇数时临时补齐
  const fenceCount = (text.match(/```/g) || []).length
  if (fenceCount % 2 === 1) {
    text += '\n```'
  }

  // 2) 行内代码:忽略 ``` 后统计单反引号,奇数则临时补齐
  let inlineTickCount = 0
  for (let i = 0; i < text.length; i++) {
    if (text.startsWith('```', i)) {
      i += 2
      continue
    }
    if (text[i] === '`') {
      inlineTickCount += 1
    }
  }
  if (inlineTickCount % 2 === 1) {
    text += '`'
  }

  return text
}

// 内容容器引用
const contentRef = ref(null)

// 渲染后的 HTML
const renderedHtml = computed(() => {
  if (!props.content) return ''

  // Phase A: 流式阶段走轻量 Markdown 渲染(无高亮)
  if (props.isStreaming) {
    return mdStreaming.render(toSafeStreamingMarkdown(props.content))
  }

  // Phase B: 结束后仅做一次完整 Markdown + 高亮渲染
  return mdFinal.render(props.content)
})

// 当流式状态变化时,滚动到最新消息
watch(() => props.isStreaming, async (newVal, oldVal) => {
  if (oldVal === true && newVal === false) {
    // 流式结束,等待渲染完成后通知父组件
    await nextTick()
    emit('rendered')
  }
})

const emit = defineEmits(['rendered'])
</script>

<template>
  <div ref="contentRef" class="markdown-content" v-html="renderedHtml"></div>
</template>

<style scoped>
.markdown-content {
  line-height: 1.6;
  word-break: break-word;
}

/* 段落 */
.markdown-content :deep(p) {
  margin: 0 0 6px 0;
}

.markdown-content :deep(p:last-child) {
  margin-bottom: 0;
}

/* 标题 */
.markdown-content :deep(h1),
.markdown-content :deep(h2),
.markdown-content :deep(h3),
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6) {
  margin: 10px 0 6px 0;
  font-weight: 600;
  line-height: 1.4;
  color: var(--el-text-color-primary);
}

.markdown-content :deep(h1) { font-size: 20px; }
.markdown-content :deep(h2) { font-size: 18px; }
.markdown-content :deep(h3) { font-size: 16px; }
.markdown-content :deep(h4) { font-size: 15px; }
.markdown-content :deep(h5) { font-size: 14px; }
.markdown-content :deep(h6) { font-size: 14px; }

/* 列表 */
.markdown-content :deep(ul),
.markdown-content :deep(ol) {
  margin: 6px 0;
  padding-left: 24px;
}

.markdown-content :deep(li) {
  margin: 4px 0;
}

.markdown-content :deep(li > ul),
.markdown-content :deep(li > ol) {
  margin: 2px 0;
}

/* 引用块 */
.markdown-content :deep(blockquote) {
  margin: 6px 0;
  padding: 8px 16px;
  border-left: 4px solid var(--el-border-color);
  background: var(--el-fill-color-light);
  color: var(--el-text-color-secondary);
  border-radius: 0 4px 4px 0;
}

.markdown-content :deep(blockquote p:last-child) {
  margin-bottom: 0;
}

/* 代码块 */
.markdown-content :deep(pre.hljs) {
  margin: 6px 0;
  padding: 12px 16px;
  background: #f6f8fa;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
  overflow-x: auto;
}

.markdown-content :deep(pre.hljs code) {
  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
  font-size: 13px;
  line-height: 1.5;
  background: transparent;
  padding: 0;
  border: none;
}

/* 行内代码 */
.markdown-content :deep(code:not(pre code)) {
  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
  font-size: 0.9em;
  padding: 2px 6px;
  background: var(--el-fill-color-light);
  border-radius: 4px;
  color: var(--el-color-danger);
}

/* 表格 */
.markdown-content :deep(table) {
  width: 100%;
  margin: 8px 0;
  border-collapse: collapse;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 4px;
  overflow: hidden;
}

.markdown-content :deep(th),
.markdown-content :deep(td) {
  padding: 8px 12px;
  border: 1px solid var(--el-border-color-lighter);
  text-align: left;
}

.markdown-content :deep(th) {
  background: var(--el-fill-color-light);
  font-weight: 600;
  color: var(--el-text-color-primary);
}

.markdown-content :deep(tr:nth-child(even)) {
  background: var(--el-fill-color-lighter);
}

/* 链接 */
.markdown-content :deep(a) {
  color: var(--el-color-primary);
  text-decoration: none;
}

.markdown-content :deep(a:hover) {
  text-decoration: underline;
}

.markdown-content :deep(a.external-link::after) {
  content: ' ↗';
  font-size: 0.8em;
}

/* 分割线 */
.markdown-content :deep(hr) {
  margin: 10px 0;
  border: none;
  border-top: 1px solid var(--el-border-color-lighter);
}

/* 图片 */
.markdown-content :deep(img) {
  max-width: 100%;
  height: auto;
  border-radius: 4px;
}

/* 高亮样式 - 使用 github 主题 */
.markdown-content :deep(.hljs) {
  display: block;
  color: #24292e;
}

.markdown-content :deep(.hljs-comment,
.hljs-quote) {
  color: #6a737d;
  font-style: italic;
}

.markdown-content :deep(.hljs-keyword,
.hljs-selector-tag,
.hljs-subst) {
  color: #d73a49;
  font-weight: bold;
}

.markdown-content :deep(.hljs-number,
.hljs-literal,
.hljs-variable,
.hljs-template-variable,
.hljs-tag .hljs-attr) {
  color: #005cc5;
}

.markdown-content :deep(.hljs-string,
.hljs-doctag) {
  color: #032f62;
}

.markdown-content :deep(.hljs-title,
.hljs-section,
.hljs-selector-id) {
  color: #6f42c1;
  font-weight: bold;
}

.markdown-content :deep(.hljs-subst) {
  font-weight: normal;
}

.markdown-content :deep(.hljs-type,
.hljs-class .hljs-title) {
  color: #458;
  font-weight: bold;
}

.markdown-content :deep(.hljs-tag,
.hljs-name,
.hljs-attribute) {
  color: #000080;
  font-weight: normal;
}

.markdown-content :deep(.hljs-regexp,
.hljs-link) {
  color: #009926;
}

.markdown-content :deep(.hljs-symbol,
.hljs-bullet) {
  color: #990073;
}

.markdown-content :deep(.hljs-built_in,
.hljs-builtin-name) {
  color: #0086b3;
}

.markdown-content :deep(.hljs-meta) {
  color: #999;
  font-weight: bold;
}

.markdown-content :deep(.hljs-deletion) {
  background: #fdd;
}

.markdown-content :deep(.hljs-addition) {
  background: #dfd;
}

.markdown-content :deep(.hljs-emphasis) {
  font-style: italic;
}

.markdown-content :deep(.hljs-strong) {
  font-weight: bold;
}
</style>