Skip to content

AI 聊天与流式输出

当你在做聊天界面、token 流式输出、SSE 响应预览,或者任何“用户正在看着内容持续变化”的 Markdown 场景时,就走这条路径。

如果你的页面其实是静态文章、文档站或低频更新页面,请回到 使用与流式渲染,优先使用更简单的 content 路径。

1. 先选最小安装组合

需求安装包适合场景
纯文本或轻量聊天界面markstream-vue基础 Markdown、列表、链接、引用
轻量代码高亮markstream-vue stream-markdownSSR 友好的聊天记录、较小 bundle
更强的代码交互markstream-vue stream-diffs复制、预览、语法高亮和 File/Diff surface
聊天内容里有图表或公式markstream-vue mermaid katexMermaid 图表和 KaTeX 公式

只安装你预期回复里真的会出现的能力,对聊天界面的收益通常很大。

2. 推荐的数据流

对于高频 token 流,使用 MarkdownRender 内置的 smooth pacing。

vue
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { ref } from 'vue'

const streamedText = ref('')
const final = ref(false)
</script>

<template>
  <MarkdownRender
    custom-id="chat"
    :content="streamedText"
    :final="final"
    :max-live-nodes="0"
    :batch-rendering="true"
    :render-batch-size="16"
    :render-batch-delay="8"
    :render-batch-budget-ms="4"
    :fade="false"
    :typewriter="true"
  />
</template>

这样做的好处:

  • Incoming chunk 可能是突发式的,但可见输出可以保持平稳。
  • Backlog-aware pacing 在积压文本增多时会自动加速。
  • 最终解析会等到可见内容追上后再触发,避免流结束时的不稳定状态。
  • custom-id="chat" 给了你一个安全的作用域,用来定制聊天界面样式或替换单个节点。
  • 默认 smooth-streaming="auto" 已经在 typewriter 开启或 max-live-nodes <= 0 时自动启用 smooth pacing。只有在需要首屏内容也从空白开始 pacing 时才用 :smooth-streaming="true"——这会跳过 mounted 门控,在 SSR 场景下可能导致 hydration 不匹配或空白闪烁。

如果某个渲染面需要原始 chunk 节奏,可以用 :smooth-streaming="false" 关闭。如果你已经在 worker/store 中自行解析并需要 AST 控制,可以继续用 nodes + final

3. 这几个渲染配置通常最稳

  • 长聊天记录优先保留默认虚拟化;只有在你有明确性能测量时再去调 maxLiveNodes
  • 如果代码块很多,但增强代码 surface 对当前聊天界面太重,可以先用 renderCodeBlocksAsPre 降级。
  • 重型 peers 先别全装。聊天类页面最容易从“不默认带 Mermaid、KaTeX、stream-diffs”里拿到体积收益。
  • 如果你关闭虚拟化(:max-live-nodes="0"),那 Props 与选项 里的 batching 相关配置就会更重要。

4. 常见升级路径

直播聊天自动滚到底,但不要每个 token 都写滚动

如果聊天窗口要跟随最新 assistant 输出,滚动写入要用 requestAnimationFrame 合并,而且只在用户本来就接近底部时才跟随。不要在每个 chunk 上调用 scrollIntoView({ behavior: 'smooth' });流式输出时这会制造一堆互相打断的滚动动画。

vue
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'

interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  content: string
  final: boolean
}

const messages = ref<ChatMessage[]>([])
const scrollRoot = ref<HTMLElement | null>(null)
const contentRoot = ref<HTMLElement | null>(null)
const bottomPinned = ref(true)

const BOTTOM_THRESHOLD_PX = 64
let scrollFrame = 0
let resizeObserver: ResizeObserver | undefined

const latestOutputSignal = computed(() => {
  const latest = messages.value[messages.value.length - 1]
  return latest
    ? `${messages.value.length}:${latest.id}:${latest.content.length}:${latest.final}`
    : '0'
})

function isNearBottom(element: HTMLElement) {
  return element.scrollHeight - element.clientHeight - element.scrollTop <= BOTTOM_THRESHOLD_PX
}

function updateBottomPinned() {
  const root = scrollRoot.value
  bottomPinned.value = !root || isNearBottom(root)
}

function scrollToBottomNow() {
  const root = scrollRoot.value
  if (!root)
    return

  root.scrollTo({
    top: root.scrollHeight,
    behavior: 'auto',
  })
}

function scheduleScrollToBottom() {
  if (!bottomPinned.value || scrollFrame)
    return

  scrollFrame = requestAnimationFrame(() => {
    scrollFrame = 0
    if (!bottomPinned.value)
      return

    scrollToBottomNow()
  })
}

watch(
  latestOutputSignal,
  async () => {
    await nextTick()
    scheduleScrollToBottom()
  },
  { flush: 'post' },
)

onMounted(() => {
  updateBottomPinned()
  resizeObserver = new ResizeObserver(scheduleScrollToBottom)

  if (contentRoot.value)
    resizeObserver.observe(contentRoot.value)
})

onBeforeUnmount(() => {
  if (scrollFrame)
    cancelAnimationFrame(scrollFrame)

  resizeObserver?.disconnect()
})
</script>

<template>
  <div ref="scrollRoot" class="chat-scroll" @scroll.passive="updateBottomPinned">
    <div ref="contentRoot" class="chat-list">
      <article
        v-for="message in messages"
        :key="message.id"
        class="chat-message"
        :class="`chat-message--${message.role}`"
      >
        <MarkdownRender
          v-if="message.role === 'assistant'"
          custom-id="chat"
          mode="chat"
          :content="message.content"
          :final="message.final"
          :smooth-streaming="message.final ? false : 'auto'"
          :fade="message.final"
          :typewriter="!message.final"
          v-bind="message.final ? {} : { maxLiveNodes: 0 }"
        />
        <p v-else class="user-text">
          {{ message.content }}
        </p>
      </article>
    </div>
  </div>
</template>

<style scoped>
.chat-scroll {
  height: min(70vh, 720px);
  overflow: auto;
  overscroll-behavior: contain;
  scrollbar-gutter: stable;
}

.chat-list {
  display: flex;
  min-height: 100%;
  flex-direction: column;
  gap: 12px;
  padding: 16px;
}

.chat-message {
  max-width: min(720px, 88%);
}

.chat-message--user {
  align-self: flex-end;
}

.chat-message--assistant {
  align-self: flex-start;
}
</style>

关键点是:

  • latestOutputSignal 只关注最后一条消息,避免长聊天记录在每个 token 上跑 deep watch。
  • nextTick() 等当前 Vue render pass 完成后再读取 scrollHeight;后续 smooth-streaming 帧的高度变化由 ResizeObserver 继续处理。
  • requestAnimationFrame 把多次 chunk 更新合并成每帧最多一次滚动写入。
  • ResizeObserver 处理图片、KaTeX、代码块、字体等异步高度变化;前提是用户仍然贴底。
  • bottomPinned 会在用户向上翻历史时关闭自动跟随,等用户回到底部附近再恢复。
  • 如果挂载时已经有完整历史消息,要单独决定首帧跳到最新消息,还是恢复已保存的滚动位置。

长的混合 timeline 优先用内置虚拟列表。默认 stick-to-bottom="auto" 是同一类产品行为:贴底时跟随,用户手动翻历史时不抢滚动。

vue
<MarkstreamVirtualTimeline
  :items="timelineItems"
  :thread-key="activeThreadId"
  stick-to-bottom="auto"
/>

更好的代码块

  • 想要更轻的文档风格:用 MarkdownCodeBlockNode,配 stream-markdown
  • 想要更强的预览 / diff / 交互:用 CodeBlockNode,配 stream-diffs

具体差异看 渲染器与节点组件

thinking 这类可信标签

使用 custom-html-tags + setCustomComponents('chat', mapping),让自定义标签只作用在聊天区域。

详见 自定义标签与高级组件

只在一个消息区域里做覆盖

通过 setCustomComponents('chat', { image: ChatImageNode }) 注册,再配合 custom-id="chat" 渲染。

详见 覆盖内置组件

5. CSS 与 SSR 检查清单

  • 先引入 reset,再使用 @import 'markstream-vue/index.css' layer(components);
  • 只有启用数学公式时,才额外导入 katex/dist/katex.min.css
  • SSR 场景下,把 Mermaid、D2、增强代码 runtime 这类浏览器专属依赖放到 client-only 边界之后
  • 如果样式串到别的区域,所有聊天界面的定制都收口到 [data-custom-id="chat"]

页面效果不对时,先从这里开始排: 故障排除

6. 手动使用 composable 搭配 nodes

如果你自己在 worker、store 或自定义 AST 管线中解析 nodesMarkdownRender 内置的 smooth streaming 不会启用——它只作用于 content 路径。你可以直接使用 useSmoothMarkdownStream,在解析前对原始文本做 pacing。

ts
import { getMarkdown, parseMarkdownToStructure, useSmoothMarkdownStream } from 'markstream-vue'
import { ref, watch } from 'vue'

const stream = useSmoothMarkdownStream()

// 从事件源喂入新 chunk
eventSource.onmessage = (event) => {
  stream.enqueue(event.data)
}

eventSource.addEventListener('done', () => {
  stream.finish()
})

// 只解析可见部分;最终解析等 caughtUp 后再触发
declare const messageId: string
const md = getMarkdown(`chat-${messageId}`)
const nodes = ref([])

watch([stream.visible, stream.final], () => {
  nodes.value = parseMarkdownToStructure(stream.visible.value, md, {
    final: stream.final.value,
  })
})

该 composable 返回响应式 ref:visiblesourcecaughtUpfinal。用 visible 渲染,等 caughtUptrue 后再认为流结束。

7. 流式输出 vs 恢复历史消息 —— 运行时切换 props

在聊天界面中,同一个 MarkdownRender 实例通常需要处理两种截然不同的模式:

  • 流式输出:模型正在实时生成 token — content 逐步增长,finalfalse
  • 恢复历史消息:从缓存或存储中加载已完成的消息 — 完整的 Markdown 字符串一次性可用。

这两种模式需要不同的 smooth-streamingfade 组合:

流式输出(token 实时到达)

vue
<MarkdownRender
  :content="streamedText"
  :final="false"
  smooth-streaming="auto"
  :fade="false"
  :typewriter="true"
  :max-live-nodes="0"
/>
  • smooth-streaming="auto" 对可见输出进行 pacing,使突发式 chunk 平稳呈现。它已经在内容层实现了"文本逐步出现"的效果。
  • fade=false,因为 280 ms 的 opacity 动画与高频 smooth-streaming 更新冲突——每个小批量内容都会打断上一帧的 fade,导致闪烁而非平滑淡入。
  • typewriter=true 在流末尾添加闪烁光标。
  • max-live-nodes=0 关闭虚拟化,启用流式场景下的增量/分批渲染。

恢复历史消息(完整 Markdown 一次性加载)

vue
<MarkdownRender
  :content="historyText"
  :final="true"
  :smooth-streaming="false"
  :fade="true"
  :typewriter="false"
/>
  • smooth-streaming=false,因为内容已经完整——pacing 会人为地拖慢一条用户希望立即看到的消息。
  • fade=true 为每个段落和节点提供优雅的 opacity 入场动画(280 ms),这在内容只到达一次(而非每帧到达)时效果很好。
  • typewriter=false——已完成的消息不需要光标。
  • final=true 告知解析器这是完整文档,不会将末尾分隔符留在 loading 状态。

在一个组件中动态切换

典型模式是:一个 MarkdownRender 先以流式模式运行,当响应完成时切换到历史消息模式:

vue
<script setup lang="ts">
import MarkdownRender from 'markstream-vue'
import { computed, ref } from 'vue'

const content = ref('')
const final = ref(false)
const isStreaming = computed(() => !final.value)
</script>

<template>
  <MarkdownRender
    custom-id="chat"
    :content="content"
    :final="final"
    :smooth-streaming="isStreaming ? 'auto' : false"
    :fade="!isStreaming"
    :typewriter="isStreaming"
    v-bind="isStreaming ? { maxLiveNodes: 0 } : {}"
  />
</template>

当流结束时,设置 final.value = true。渲染器会从 smooth pacing + 无 fade 切换到无 pacing + fade,但不会 remount 未变化的内容,从而避免完成瞬间闪烁;fade=true 会作用于之后新挂载的完整历史消息,或一次性到达的完整内容。

静态 / SSR 快照(无动画)

vue
<MarkdownRender
  :content="staticText"
  :final="true"
  :smooth-streaming="false"
  :fade="false"
/>

零动画——适合服务端渲染输出、打印或 PDF 管线。

8. 什么时候不该走这条路径

  • 更新频率不高、页面基本静态时,用 content 更简单
  • 如果服务端或别的层已经接管 Markdown 解析,就直接用预解析后的 nodes
  • 如果当前问题主要是 SSR / runtime 边界,而不是流式输出本身,优先看对应框架文档

下一步继续看