关注

前端AI工程化(二) : LLM流式输出与前端渲染


👤 关于作者

JavaAgent架构师 — 十年Java分布式架构老兵,专注AI Agent企业级落地。

主导过数字员工、SOP智能引擎等项目,开发过RPC框架、消息中间件、ORM框架。

正在输出《前端AI工程化》《Java体系也能玩转AI》《从0构建Agent系统》专栏,让Java开发者不转Python也能构建企业级AI应用

📮 关注专栏|🔔 点赞收藏|💬 评论区见


核心定位:解决AI长文本流的渲染性能瓶颈,实现生产级打字机效果

关键产出:高性能Markdown流式渲染引擎

核心定位:解决AI长文本流的渲染性能瓶颈,实现生产级打字机效果
关键产出:高性能Markdown流式渲染引擎


2.1 AI长文本流式渲染的性能攻坚战

开篇:你忽略的那个性能炸弹

AI聊天的流式渲染,看起来只是"追加文本"这么简单的事——直到你遇到这些场景:

  • 一段3000字的回答,包含5个代码块、3个表格、2个嵌套列表
  • 代码块正在流式输出,高亮引擎还没检测到语言类型
  • 用户同时打开了3个AI对话窗口,每个都在流式输出

每个token到来时触发一次全量Markdown解析 + DOM重建 = 页面卡死的元凶。

这一期,我们系统拆解流式渲染的性能瓶颈,并建立优化策略的知识框架。


一、流式渲染的核心矛盾

流式渲染存在一个根本性的矛盾:

更新频率 vs 渲染性能

维度需求约束
更新频率每秒20-60个token,用户期望实时看到每次更新都可能触发重排重绘
内容复杂度Markdown包含代码块、表格、公式等复杂结构复杂结构的解析和渲染成本高
交互响应用户可能随时滚动、选中、复制渲染不能阻塞主线程

这个矛盾的本质是:你不能每来一个token就做一次完整的渲染。


二、三大性能瓶颈深度剖析

瓶颈1:高频DOM更新

每个token到来时,最直觉的做法是追加文本到DOM:

// ❌ 最直觉但最慢的方式
tokenStream.on('token', (token) => {
  contentDiv.innerHTML += token; // 每次追加都触发完整HTML解析
});

问题分析:

// innerHTML += 的实际执行过程:
// 1. 读取 contentDiv.innerHTML(序列化现有DOM树)
// 2. 拼接新token
// 3. 重新解析整个HTML字符串
// 4. 重建整个DOM子树
// 5. Diff & Patch
// 6. 重排 + 重绘

一个3000字、50个token/秒的流式输出,意味着每秒50次完整的DOM重建。

瓶颈2:Markdown解析开销

Markdown到HTML的转换是CPU密集型操作:

import { marked } from 'marked';

// ❌ 每个token都全量解析
tokenStream.on('token', () => {
  const html = marked.parse(accumulatedText); // 随着文本增长,耗时线性增加
  contentDiv.innerHTML = html;
});

marked.parse的耗时与文本长度正相关。1000字的解析可能只需要2ms,但5000字可能需要15ms——而这15ms在60fps的预算中占了整整一帧(16.67ms)。

瓶颈3:代码高亮计算

代码高亮(如highlight.js、Prism)是另一个性能黑洞:

// ❌ 流式输出代码块时的高亮
tokenStream.on('token', (token) => {
  accumulatedText += token;
  const html = marked.parse(accumulatedText);
  contentDiv.innerHTML = html;
  // 对每个代码块执行高亮
  contentDiv.querySelectorAll('pre code').forEach((block) => {
    hljs.highlightElement(block); // 每次都重新高亮所有代码块
  });
});

代码高亮的特殊性:代码块在流式输出时,语言类型可能还没出现(三个反引号后的语言标识可能还没到达),高亮引擎无法确定语法规则;即使确定了,对不完整的代码做高亮会产生大量中间状态的高亮DOM,每次追加token都需要重新计算。


三、三种渲染策略的性能对比

策略A:innerHTML全量替换
class InnerHTMLRenderer {
  private text = '';

  append(token: string): void {
    this.text += token;
    this.element.innerHTML = marked.parse(this.text);
  }
}
  • 优点:实现简单,Markdown渲染结果始终正确
  • 缺点:O(n)解析复杂度,DOM全量重建,光标跳动,滚动位置丢失
  • 适用:文本量小(<500字),简单Markdown
策略B:虚拟DOM增量更新
class VDOMRenderer {
  private text = '';
  private vdom: VNode;

  append(token: string): void {
    this.text += token;
    const newVdom = h('div', { innerHTML: marked.parse(this.text) });
    patch(this.vdom, newVdom); // 只更新差异部分
    this.vdom = newVdom;
  }
}
  • 优点:DOM更新粒度更细,减少不必要的重排
  • 缺点:Markdown全量解析的开销依然存在,VDOM diff本身也有成本
  • 适用:中等文本量(500-2000字),复杂Markdown结构
策略C:手动DOM操作 + 增量解析
class IncrementalRenderer {
  private currentBlock: HTMLDivElement;
  private blockType: 'text' | 'code' | 'table' = 'text';
  private blockContent = '';

  append(token: string): void {
    this.blockContent += token;

    // 只更新当前正在追加的block
    if (this.blockType === 'text') {
      this.currentBlock.innerHTML = marked.parseInline(this.blockContent);
    } else if (this.blockType === 'code') {
      this.updateCodeBlock();
    }
  }
}
  • 优点:只更新当前活跃块,O(1)渲染复杂度
  • 缺点:实现复杂,需要自行管理Markdown块边界
  • 适用:大文本量(>2000字),高性能要求场景
性能对比数据
指标innerHTML全量VDOM增量手动DOM增量
1000字渲染耗时~8ms~5ms~1ms
5000字渲染耗时~35ms~18ms~2ms
主线程阻塞严重中等极轻
实现复杂度
光标/滚动稳定性

四、requestAnimationFrame与渲染调度

无论选择哪种策略,都需要将渲染与浏览器刷新周期对齐:

class ScheduledRenderer {
  private pendingTokens: string[] = [];
  private rafId: number | null = null;

  append(token: string): void {
    this.pendingTokens.push(token);

    // 如果没有待执行的渲染帧,调度一帧
    if (this.rafId === null) {
      this.rafId = requestAnimationFrame(this.render);
    }
  }

  private render = (): void => {
    // 合并本帧内的所有token
    const tokens = this.pendingTokens;
    this.pendingTokens = [];
    this.rafId = null;

    // 执行渲染
    const combinedToken = tokens.join('');
    this.doRender(combinedToken);
  };
}

核心思想:token的到达频率(50次/秒)高于浏览器刷新频率(60fps),将同一帧内的token合并后只渲染一次,既保证了流畅度,又减少了不必要的渲染。

requestIdleCallback的场景

当渲染任务较重(如代码高亮)且不是用户当前关注的焦点时,可以用requestIdleCallback延迟到浏览器空闲时执行:

// 代码高亮延迟到空闲时执行
requestIdleCallback((deadline) => {
  if (deadline.timeRemaining() > 5) {
    // 有足够空闲时间,执行高亮
    hljs.highlightElement(codeBlock);
  }
  // 否则等下一次空闲
});

五、增量Markdown解析:避免全量重解析

这是性能优化的关键突破点。

传统方式是每次追加token后,对整个文本重新执行marked.parse。增量解析的思路是:

只解析新增部分,复用已有的解析结果。

class IncrementalMarkdownParser {
  private parsedBlocks: ParsedBlock[] = [];
  private currentBlockText = '';

  append(token: string): ParsedBlock[] {
    this.currentBlockText += token;

    // 检测块边界
    if (this.isBlockComplete(this.currentBlockText)) {
      const block = marked.parse(this.currentBlockText);
      this.parsedBlocks.push({
        html: block,
        text: this.currentBlockText,
        stable: true, // 已完成的块不会再变
      });
      this.currentBlockText = '';
      return this.parsedBlocks.slice(-1); // 只返回新增的块
    }

    // 当前块尚未完成,返回临时预览
    return [
      ...this.parsedBlocks,
      {
        html: marked.parseInline(this.currentBlockText),
        text: this.currentBlockText,
        stable: false, // 未完成的块,后续还会追加
      },
    ];
  }

  private isBlockComplete(text: string): boolean {
    // 检测是否以双换行符结尾(Markdown块分隔符)
    return text.endsWith('\n\n') || text.endsWith('\n\n\n');
  }
}

关键设计

  • 已完成的块标记为stable,后续渲染可以跳过
  • 只有当前活跃块需要重新解析
  • 块边界检测是增量解析的核心难点

六、代码块的特殊处理:先渲染后高亮

代码块在流式场景下的处理策略:

阶段1:检测到三个反引号 → 创建<pre><code>容器
阶段2:语言标识到达 → 记录语言类型,暂不高亮
阶段3:代码内容流式追加 → 只做纯文本追加,不执行高亮
阶段4:代码块结束(遇到结束反引号)→ 执行完整高亮
class StreamingCodeBlockHandler {
  private codeContent = '';
  private language = '';
  private element: HTMLElement;
  private isComplete = false;

  start(language: string, container: HTMLElement): void {
    this.language = language;
    this.element = document.createElement('code');
    this.element.className = `language-${language}`;
    container.appendChild(this.element);
  }

  append(token: string): void {
    this.codeContent += token;

    if (this.isComplete) {
      // 已完成,可以增量高亮
      this.element.innerHTML = hljs.highlight(this.codeContent, {
        language: this.language,
      }).value;
    } else {
      // 未完成,只做纯文本渲染(极快)
      this.element.textContent = this.codeContent;
    }
  }

  complete(): void {
    this.isComplete = true;
    // 最终完整高亮
    this.element.innerHTML = hljs.highlight(this.codeContent, {
      language: this.language,
    }).value;
  }
}

实践任务

任务:使用Chrome DevTools Performance面板对比三种流式渲染策略的帧率表现,输出性能分析报告。

步骤

  1. 准备一个SSE模拟服务端,以50 tokens/秒输出一段包含代码块、表格、列表的Markdown文本
  2. 分别用innerHTML全量、VDOM增量、手动DOM增量三种方式渲染
  3. 使用Performance面板录制10秒,关注以下指标:
    • FPS(帧率)
    • Main Thread占用率
    • Layout Shifts(布局偏移)
    • Long Tasks(>50ms的任务)
  4. 输出对比报告,含截图和数据表格

面试题解析

Q:AI流式输出时,前端如何保证渲染性能避免页面卡顿?

答题要点

  1. 渲染调度:requestAnimationFrame合并同帧token,避免高频DOM更新
  2. 增量解析:只重新解析当前活跃块,跳过已稳定的块
  3. 延迟高亮:代码块流式阶段只做纯文本,完成后一次性高亮
  4. 虚拟滚动:长对话场景下只渲染可视区域的消息
  5. 分块策略:Markdown按块管理,已完成块不再参与重渲染

Q:innerHTML、虚拟DOM、手动DOM操作在流式场景下哪个更优?

答题要点:没有绝对优劣,取决于场景规模。小文本innerHTML够用;中等文本VDOM平衡了性能与开发效率;大文本高性能场景需要手动DOM增量。生产环境推荐混合策略——当前活跃块用手动DOM操作保证性能,已完成块用VDOM保证可维护性。


2.2 实现生产级打字机效果

开篇:ChatGPT的打字机效果,比你想的复杂得多

看到ChatGPT的逐字输出效果,大多数人会想:这不就是逐个追加字符嘛?

但当你真正开始实现,问题接踵而来:

  • 光标要闪烁,但闪烁频率不能被token到达节奏干扰
  • 代码块中途换行时,行号要正确对齐
  • 表格正在流式输出,列宽不能每来一个token就重新计算
  • 网络突然抖动,3秒没来新token,再恢复时不能"突然冒出一大段"
  • 用户点了"停止生成",当前token渲染到一半,怎么优雅收尾

这一期,我们从零实现一个处理所有这些边界情况的生产级打字机效果。


一、打字机效果的核心机制

1.1 逐字符渲染 vs 逐Token渲染

LLM的token不等于字符——一个token可能是1个字符,也可能是一个完整单词,甚至是"```"这样的标记。

逐Token渲染:每个token立即显示
  → 速度快,但可能出现"突然跳出一大块"的不连贯感

逐字符渲染:token拆成字符,按固定节奏逐个显示
  → 节奏稳定,但引入了人为延迟,与实际生成速度脱节

生产级方案:逐Token渲染 + 缓冲平滑。

class TypewriterBuffer {
  private buffer: string[] = [];    // 待渲染的token队列
  private isFlushing = false;       // 是否正在消费缓冲区

  push(token: string): void {
    this.buffer.push(token);

    if (!this.isFlushing) {
      this.flush();
    }
  }

  private flush(): void {
    this.isFlushing = true;

    const renderFrame = () => {
      if (this.buffer.length === 0) {
        this.isFlushing = false;
        return;
      }

      // 每帧消费所有缓冲的token(合并渲染)
      const combined = this.buffer.join('');
      this.buffer = [];
      this.render(combined);

      requestAnimationFrame(renderFrame);
    };

    requestAnimationFrame(renderFrame);
  }

  private render(text: string): void {
    // 子类实现具体渲染逻辑
  }
}

为什么不用逐字符? 因为LLM的生成速度本身就是"逐token"的节奏,逐字符会引入不必要的人为延迟,让用户感觉"更慢了"。缓冲平滑解决的是网络抖动导致的"时快时慢",而不是人为减速。


二、光标管理

2.1 光标的CSS实现
.typing-cursor {
  display: inline-block;
  width: 2px;
  height: 1.1em;
  background-color: currentColor;
  margin-left: 1px;
  animation: blink 1s step-end infinite;
  vertical-align: text-bottom;
}

@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

/* 流式输出时光标常亮,停止后才闪烁 */
.typing-cursor.active {
  animation: none;
  opacity: 1;
}

关键细节

  • 流式输出时光标应该是常亮的(active状态),不是闪烁的——闪烁的光标+不断追加的文字=视觉干扰
  • 只有在生成完成(或暂停)后,光标才开始闪烁
  • 光标宽度2px、高度1.1em,与等宽字体的字符宽度接近,视觉上像真正的文本光标
2.2 光标位置管理

光标必须始终跟随在最后一个渲染字符之后:

class CursorManager {
  private cursorEl: HTMLElement;

  constructor(container: HTMLElement) {
    this.cursorEl = document.createElement('span');
    this.cursorEl.className = 'typing-cursor active';
    container.appendChild(this.cursorEl);
  }

  /** 在目标元素后重新插入光标 */
  moveAfter(element: Node): void {
    element.parentNode?.insertBefore(this.cursorEl, element.nextSibling);
  }

  /** 追加文本时,光标自动跟在末尾 */
  appendTo(container: HTMLElement): void {
    container.appendChild(this.cursorEl);
  }

  /** 生成完成,切换为闪烁模式 */
  finish(): void {
    this.cursorEl.classList.remove('active');
  }

  /** 移除光标 */
  remove(): void {
    this.cursorEl.remove();
  }
}

三、弱网场景:Backpressure与缓冲区管理

3.1 问题场景
正常情况:token均匀到达 → 渲染节奏稳定 ✅
弱网场景:3秒无token → 突然涌入20个token → 渲染节奏被打乱 ❌

用户感知到的是:“文字先是停了3秒,然后突然跳出一大段”——这严重破坏了打字机效果的流畅感。

3.2 缓冲平滑策略
class SmoothTypewriter {
  private tokenBuffer: string[] = [];
  private tokensPerFrame = 1;       // 动态调节:每帧渲染的token数
  private lastRenderTime = 0;
  private lastTokenTime = 0;

  push(token: string): void {
    this.tokenBuffer.push(token);
    this.lastTokenTime = performance.now();
  }

  private renderLoop = (): void => {
    const now = performance.now();

    if (this.tokenBuffer.length === 0) {
      // 无待渲染token,暂停渲染循环
      return;
    }

    // 动态调节渲染速度
    // 如果缓冲区积压了较多token,加速消费
    // 如果缓冲区快空了,减速以保持节奏
    if (this.tokenBuffer.length > 10) {
      this.tokensPerFrame = Math.min(5, this.tokenBuffer.length / 3);
    } else if (this.tokenBuffer.length <= 2) {
      this.tokensPerFrame = 1;
    }

    // 消费token
    const tokensToRender = this.tokenBuffer.splice(0, Math.ceil(this.tokensPerFrame));
    const combined = tokensToRender.join('');
    this.doRender(combined);

    this.lastRenderTime = now;
    requestAnimationFrame(this.renderLoop);
  };
}
3.3 Backpressure:通知服务端降速

在极端情况下,如果客户端渲染速度跟不上token到达速度,可以通过SSE的上行通道通知服务端降速:

// 客户端检测到缓冲区过满
if (this.tokenBuffer.length > 50) {
  // 通过HTTP POST通知服务端降低生成速度
  // 或者在SSE的上行通道发送backpressure信号
  fetch('/api/chat/backpressure', {
    method: 'POST',
    body: JSON.stringify({ slowDown: true }),
  });
}

注意:大多数LLM API不支持服务端降速,Backpressure更多是一个客户端的缓冲管理策略。实际效果是:当token堆积时,加速消费;当token稀少时,减速以保持节奏感。


四、Markdown流式渲染的边界处理

4.1 代码块流式渲染
收到: "这是代码:\n```"
  → 检测到代码块开始,切换到代码块模式

收到: "java"
  → 记录语言类型,暂不高亮

收到: "\npublic clas"
  → 纯文本追加到代码容器

收到: "s Hello {\n"
  → 继续纯文本追加

收到: "```"
  → 代码块结束,执行完整高亮
class MarkdownStreamingRenderer {
  private mode: 'normal' | 'code' | 'table' = 'normal';
  private codeBlockHandler: StreamingCodeBlockHandler | null = null;
  private tableHandler: StreamingTableHandler | null = null;

  append(token: string): void {
    this.accumulatedText += token;

    switch (this.mode) {
      case 'normal':
        this.renderNormalMode(token);
        break;
      case 'code':
        this.renderCodeMode(token);
        break;
      case 'table':
        this.renderTableMode(token);
        break;
    }
  }

  private renderNormalMode(token: string): void {
    // 检测代码块开始
    if (this.accumulatedText.match(/```\w*$/)) {
      this.mode = 'code';
      const langMatch = this.accumulatedText.match(/```(\w+)$/);
      const lang = langMatch?.[1] ?? '';
      this.codeBlockHandler = new StreamingCodeBlockHandler();
      this.codeBlockHandler.start(lang, this.container);
      return;
    }

    // 检测表格开始
    if (this.accumulatedText.match(/\|.+\|$/m) && this.accumulatedText.includes('---')) {
      this.mode = 'table';
      this.tableHandler = new StreamingTableHandler();
      this.tableHandler.start(this.container);
      return;
    }

    // 普通文本:增量渲染
    this.renderInlineMarkdown();
  }

  private renderCodeMode(token: string): void {
    // 检测代码块结束
    if (this.accumulatedText.endsWith('```')) {
      this.codeBlockHandler!.complete();
      this.mode = 'normal';
      this.codeBlockHandler = null;
      return;
    }

    // 追加代码内容
    this.codeBlockHandler!.append(token);
  }
}
4.2 表格流式渲染

表格的流式渲染更加棘手——列宽需要等到所有行已知才能确定,但流式输出时行是逐行到来的。

class StreamingTableHandler {
  private rows: string[][] = [];
  private currentRow: string[] = [];
  private tableEl: HTMLTableElement;
  private headerRow: HTMLTableRowElement | null = null;

  start(container: HTMLElement): void {
    this.tableEl = document.createElement('table');
    this.tableEl.className = 'streaming-table';
    container.appendChild(this.tableEl);
  }

  append(token: string): void {
    // 解析表格行的逻辑
    // 每检测到一行完整数据,渲染为新行
    const lines = token.split('\n');
    for (const line of lines) {
      const cells = line.split('|').filter(c => c.trim());
      if (cells.length > 0) {
        if (cells[0].trim().match(/^-+$/)) {
          // 分隔行,标记上方为表头
          this.headerRow = this.tableEl.rows[0] ?? null;
          if (this.headerRow) {
            this.headerRow.parentElement?.replaceChild(
              this.createHeaderRow(this.headerRow),
              this.headerRow
            );
          }
        } else {
          this.addRow(cells);
        }
      }
    }
  }

  private addRow(cells: string[]): void {
    const tr = document.createElement('tr');
    for (const cell of cells) {
      const td = document.createElement('td');
      td.textContent = cell.trim();
      tr.appendChild(td);
    }
    this.tableEl.appendChild(tr);
  }

  private createHeaderRow(oldRow: HTMLTableRowElement): HTMLTableRowElement {
    const tr = document.createElement('tr');
    for (const cell of Array.from(oldRow.cells)) {
      const th = document.createElement('th');
      th.textContent = cell.textContent;
      tr.appendChild(th);
    }
    return tr;
  }
}

五、中断与回放:用户停止生成时的状态处理

当用户点击"停止生成"按钮:

class TypewriterEngine {
  private isGenerating = false;

  stop(): void {
    this.isGenerating = false;

    // 1. 清空缓冲区中未渲染的token(丢弃,不渲染)
    this.tokenBuffer = [];

    // 2. 完成当前正在流式输出的块
    if (this.codeBlockHandler) {
      this.codeBlockHandler.complete();
      this.codeBlockHandler = null;
    }
    this.mode = 'normal';

    // 3. 光标切换为闪烁模式
    this.cursorManager.finish();

    // 4. 触发完成回调
    this.onComplete?.();
  }
}

关键决策:停止生成时,缓冲区中未渲染的token应该丢弃还是立即显示?

  • 丢弃:用户看到的是"截止到当前已渲染的内容",体验更自然
  • 立即显示:用户看到的是"模型实际生成的全部内容",信息更完整

生产级方案:提供一个"继续显示"按钮,默认丢弃,但用户可以选择继续查看。


实践任务

任务:实现一个完整的流式Markdown渲染器,支持代码块流式高亮、表格流式渲染、光标闪烁动画。

验收标准

  1. 普通文本逐token流式渲染,光标常亮跟随
  2. 代码块检测→纯文本追加→完成后高亮,整个过程不闪烁
  3. 表格逐行渲染,表头/数据行正确区分
  4. 嵌套列表流式渲染,缩进层级正确
  5. 生成完成后光标切换为闪烁模式
  6. 点击"停止"后优雅收尾

面试题解析

Q:如何实现ChatGPT的打字机效果?弱网环境下如何保证平滑?

答题要点

  1. 核心机制:token级追加渲染 + requestAnimationFrame调度
  2. 光标管理:流式输出时常亮,完成后闪烁
  3. 缓冲平滑:token先入缓冲区,渲染循环按帧消费,避免网络抖动导致的"时快时慢"
  4. Backpressure:缓冲区积压时加速消费,极端情况通知服务端降速
  5. 块级渲染:代码块/表格等复杂结构需要状态机管理,不同阶段用不同渲染策略

2.3 流式渲染引擎封装与性能度量

开篇:从"能用"到"工程化"

前两期我们解决了流式渲染的核心技术问题。现在要把这些零散的解决方案封装为一个可复用、可扩展、可度量的流式渲染引擎。

这一期的关键词是:架构、度量、监控


一、渲染引擎架构:Parser → Renderer → Highlighter三层解耦

┌───────────────────────────────────────────────────┐
│                  StreamRenderer                    │
│                   (门面/协调层)                     │
├───────────┬─────────────────┬─────────────────────┤
│  Parser   │    Renderer     │    Highlighter       │
│  解析层   │    渲染层        │    高亮层            │
├───────────┼─────────────────┼─────────────────────┤
│ - 增量MD  │ - DOM操作       │ - 代码高亮           │
│   解析    │ - 光标管理      │ - 延迟高亮策略       │
│ - 块边界  │ - 缓冲平滑      │ - LaTeX渲染          │
│   检测    │ - 虚拟滚动      │ - 自定义高亮器       │
│ - 状态机  │ - 事件分发      │                     │
└───────────┴─────────────────┴─────────────────────┘
         ↕               ↕                ↕
┌───────────────────────────────────────────────────┐
│                  Plugin System                     │
│              (插件扩展层)                           │
│  - 自定义Parser   - 自定义Renderer                  │
│  - 自定义Highlighter   - 性能监控插件               │
└───────────────────────────────────────────────────┘

设计原则

  • 单一职责:Parser只负责解析,Renderer只负责渲染,Highlighter只负责高亮
  • 可替换:每一层都可以独立替换实现(如用markdown-it替换marked)
  • 插件化:通过插件系统扩展功能,不修改核心代码

二、核心接口定义

// === 解析层 ===
interface IStreamParser {
  /** 追加token,返回需要更新的块 */
  append(token: string): ParseResult;
  /** 标记当前内容结束 */
  finalize(): ParseResult;
  /** 重置解析器状态 */
  reset(): void;
}

interface ParseResult {
  /** 新增的稳定块(已完成,不需要重新解析) */
  stableBlocks: ParsedBlock[];
  /** 当前活跃块(未完成,后续会继续追加) */
  activeBlock: ParsedBlock | null;
}

interface ParsedBlock {
  id: string;
  type: 'paragraph' | 'code' | 'table' | 'list' | 'heading' | 'blockquote';
  html: string;
  rawText: string;
  stable: boolean;
  metadata?: Record<string, any>; // 如代码块的语言类型
}

// === 渲染层 ===
interface IStreamRenderer {
  /** 渲染解析结果 */
  render(result: ParseResult): void;
  /** 获取容器DOM */
  getContainer(): HTMLElement;
  /** 滚动到底部 */
  scrollToBottom(smooth?: boolean): void;
  /** 销毁渲染器 */
  destroy(): void;
}

// === 高亮层 ===
interface IHighlighter {
  /** 对代码块执行高亮 */
  highlight(code: string, language: string): string;
  /** 检测是否支持指定语言 */
  supportsLanguage(language: string): boolean;
  /** 获取所有支持的语言列表 */
  getSupportedLanguages(): string[];
}

// === 插件系统 ===
interface IStreamRendererPlugin {
  name: string;
  install(engine: StreamRendererEngine): void;
  uninstall?(): void;
}

三、引擎核心实现

class StreamRendererEngine {
  private parser: IStreamParser;
  private renderer: IStreamRenderer;
  private highlighter: IHighlighter;
  private plugins: IStreamRendererPlugin[] = [];

  // 性能度量
  private metrics: RendererMetrics;

  // 渲染调度
  private pendingTokens: string[] = [];
  private rafId: number | null = null;
  private isStreaming = false;

  constructor(options: StreamRendererOptions) {
    this.parser = options.parser ?? new IncrementalMarkdownParser();
    this.renderer = options.renderer ?? new DOMRenderer(options.container);
    this.highlighter = options.highlighter ?? new HighlightJsAdapter();
    this.metrics = new RendererMetrics();
  }

  // === 公共接口 ===

  /** 追加一个token */
  append(token: string): void {
    this.pendingTokens.push(token);
    this.isStreaming = true;
    this.scheduleRender();
  }

  /** 标记流结束 */
  finalize(): void {
    this.isStreaming = false;

    // 执行最终渲染
    const result = this.parser.finalize();
    this.renderer.render(result);

    // 执行延迟高亮
    this.highlightPendingBlocks();

    // 记录度量
    this.metrics.recordFinalize();
  }

  /** 重置引擎 */
  reset(): void {
    this.pendingTokens = [];
    this.isStreaming = false;
    this.parser.reset();
    // 清空渲染容器
    this.renderer.getContainer().innerHTML = '';
    this.metrics.reset();
  }

  /** 注册插件 */
  use(plugin: IStreamRendererPlugin): this {
    this.plugins.push(plugin);
    plugin.install(this);
    return this;
  }

  /** 获取性能度量数据 */
  getMetrics(): Readonly<RendererMetricsData> {
    return this.metrics.getData();
  }

  // === 渲染调度 ===

  private scheduleRender(): void {
    if (this.rafId !== null) return;

    this.rafId = requestAnimationFrame(() => {
      this.rafId = null;
      this.renderPending();
    });
  }

  private renderPending(): void {
    const startTime = performance.now();

    // 合并本帧所有token
    const combined = this.pendingTokens.join('');
    this.pendingTokens = [];

    // 解析
    const parseResult = this.parser.append(combined);

    // 渲染
    this.renderer.render(parseResult);

    // 延迟高亮(只对稳定块执行)
    for (const block of parseResult.stableBlocks) {
      if (block.type === 'code') {
        this.scheduleHighlight(block);
      }
    }

    // 记录度量
    const renderTime = performance.now() - startTime;
    this.metrics.recordFrame(renderTime);

    // 如果还有pending token,继续调度
    if (this.pendingTokens.length > 0) {
      this.scheduleRender();
    }
  }

  private pendingHighlights: ParsedBlock[] = [];

  private scheduleHighlight(block: ParsedBlock): void {
    this.pendingHighlights.push(block);

    // 使用requestIdleCallback延迟高亮
    requestIdleCallback((deadline) => {
      while (this.pendingHighlights.length > 0 && deadline.timeRemaining() > 2) {
        const block = this.pendingHighlights.shift()!;
        this.doHighlight(block);
      }

      // 如果还有未高亮的块,继续调度
      if (this.pendingHighlights.length > 0) {
        this.scheduleHighlight(this.pendingHighlights.shift()!);
      }
    });
  }

  private doHighlight(block: ParsedBlock): void {
    const codeElement = this.renderer
      .getContainer()
      .querySelector(`[data-block-id="${block.id}"] code`);

    if (codeElement) {
      const lang = block.metadata?.language ?? '';
      codeElement.innerHTML = this.highlighter.highlight(
        codeElement.textContent ?? '',
        lang
      );
    }
  }
}

四、性能度量指标体系

interface RendererMetricsData {
  /** 首Token渲染时间 (Time to First Token Render) */
  ttft: number;
  /** 每秒Token渲染数 (Tokens Per Second) */
  tps: number;
  /** 平均帧渲染耗时 */
  avgFrameTime: number;
  /** 最大帧渲染耗时 */
  maxFrameTime: number;
  /** 帧率(基于渲染频率计算) */
  fps: number;
  /** 总渲染帧数 */
  totalFrames: number;
  /** 总渲染token数 */
  totalTokens: number;
  /** 长任务次数(>50ms的帧) */
  longTaskCount: number;
  /** DOM节点数 */
  domNodeCount: number;
}

class RendererMetrics {
  private startTime = 0;
  private firstTokenTime = 0;
  private frameTimes: number[] = [];
  private tokenCount = 0;
  private longTaskThreshold = 50; // ms

  recordStart(): void {
    this.startTime = performance.now();
  }

  recordFirstToken(): void {
    if (this.firstTokenTime === 0) {
      this.firstTokenTime = performance.now();
    }
  }

  recordFrame(renderTime: number): void {
    this.frameTimes.push(renderTime);
    if (renderTime > this.longTaskThreshold) {
      this.longTaskCount++;
    }
  }

  recordToken(count: number = 1): void {
    this.tokenCount += count;
  }

  recordFinalize(): void {
    // 最终度量快照
  }

  getData(): RendererMetricsData {
    const elapsed = (performance.now() - this.startTime) / 1000;
    const avgFrameTime = this.frameTimes.length > 0
      ? this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length
      : 0;

    return {
      ttft: this.firstTokenTime - this.startTime,
      tps: elapsed > 0 ? this.tokenCount / elapsed : 0,
      avgFrameTime,
      maxFrameTime: Math.max(...this.frameTimes, 0),
      fps: this.frameTimes.length > 0 && elapsed > 0
        ? this.frameTimes.length / elapsed
        : 0,
      totalFrames: this.frameTimes.length,
      totalTokens: this.tokenCount,
      longTaskCount: this.longTaskCount,
      domNodeCount: document.querySelectorAll('*').length,
    };
  }

  reset(): void {
    this.startTime = 0;
    this.firstTokenTime = 0;
    this.frameTimes = [];
    this.tokenCount = 0;
    this.longTaskCount = 0;
  }

  private longTaskCount = 0;
}

五、性能监控面板插件

class PerformanceMonitorPlugin implements IStreamRendererPlugin {
  name = 'performance-monitor';
  private panel: HTMLElement | null = null;
  private engine: StreamRendererEngine | null = null;
  private updateInterval: ReturnType<typeof setInterval> | null = null;

  install(engine: StreamRendererEngine): void {
    this.engine = engine;
    this.createPanel();
    this.updateInterval = setInterval(() => this.update(), 500);
  }

  uninstall(): void {
    this.panel?.remove();
    if (this.updateInterval) clearInterval(this.updateInterval);
  }

  private createPanel(): void {
    this.panel = document.createElement('div');
    this.panel.className = 'perf-monitor-panel';
    this.panel.innerHTML = `
      <div class="perf-title">StreamRenderer Metrics</div>
      <div class="perf-row"><span>TTFT</span><span id="perf-ttft">-</span></div>
      <div class="perf-row"><span>TPS</span><span id="perf-tps">-</span></div>
      <div class="perf-row"><span>FPS</span><span id="perf-fps">-</span></div>
      <div class="perf-row"><span>Avg Frame</span><span id="perf-avg">-</span></div>
      <div class="perf-row"><span>Max Frame</span><span id="perf-max">-</span></div>
      <div class="perf-row"><span>Long Tasks</span><span id="perf-lt">-</span></div>
      <div class="perf-row"><span>DOM Nodes</span><span id="perf-dom">-</span></div>
    `;
    document.body.appendChild(this.panel);
  }

  private update(): void {
    if (!this.engine || !this.panel) return;

    const data = this.engine.getMetrics();
    const $ = (id: string) => this.panel!.querySelector(`#perf-${id}`);

    ($('ttft')!.textContent = `${data.ttft.toFixed(0)}ms`);
    ($('tps')!.textContent = `${data.tps.toFixed(1)}`);
    ($('fps')!.textContent = `${data.fps.toFixed(0)}`);
    ($('avg')!.textContent = `${data.avgFrameTime.toFixed(1)}ms`);
    ($('max')!.textContent = `${data.maxFrameTime.toFixed(1)}ms`);
    ($('lt')!.textContent = `${data.longTaskCount}`);
    ($('dom')!.textContent = `${data.domNodeCount}`);
  }
}

六、引擎插件化扩展点

// 自定义渲染器示例:Canvas渲染器(适合超长文本)
class CanvasRenderer implements IStreamRenderer {
  private ctx: CanvasRenderingContext2D;
  private lineHeight = 24;
  private scrollOffset = 0;

  constructor(container: HTMLElement) {
    const canvas = document.createElement('canvas');
    container.appendChild(canvas);
    this.ctx = canvas.getContext('2d')!;
  }

  render(result: ParseResult): void {
    // Canvas渲染逻辑——不受DOM性能约束
  }

  getContainer(): HTMLElement {
    throw new Error('Canvas renderer does not use DOM container');
  }

  scrollToBottom(smooth?: boolean): void {
    // Canvas滚动逻辑
  }

  destroy(): void {
    this.ctx.canvas.remove();
  }
}

// 自定义高亮器示例:Shiki渲染器
class ShikiHighlighter implements IHighlighter {
  private shiki: any;

  async init(): Promise<void> {
    this.shiki = await import('shiki');
  }

  highlight(code: string, language: string): string {
    return this.shiki.codeToHtml(code, { lang: language });
  }

  supportsLanguage(language: string): boolean {
    return this.shiki.getLoadedLanguages().includes(language);
  }

  getSupportedLanguages(): string[] {
    return this.shiki.getLoadedLanguages();
  }
}

实践任务

任务:封装StreamRendererEngine类,支持插件扩展,附带性能监控Dashboard。

验收标准

  1. Parser/Renderer/Highlighter三层可独立替换
  2. 插件系统可注册/卸载插件
  3. PerformanceMonitorPlugin实时显示TTFT/TPS/FPS等指标
  4. 增量解析:已稳定块不参与重渲染
  5. 延迟高亮:代码块完成后在空闲时执行高亮
  6. 完整的TypeScript类型导出

面试题解析

Q:如何衡量AI前端页面的性能指标?

答题要点

  1. AI场景特有指标
    • TTFT(首Token渲染时间):用户发送消息到看到第一个token的时间
    • TPS(每秒Token渲染数):反映流式渲染的吞吐量
    • 帧率稳定性:流式渲染期间FPS是否保持>55
    • Long Task占比:>50ms任务占渲染帧的比例
  2. 通用Web指标
    • LCP、FID、CLS等Core Web Vitals
    • DOM节点数、内存占用
  3. 度量方法
    • Performance API记录关键时间点
    • requestAnimationFrame回调计算实时FPS
    • MutationObserver监控DOM变化频率
    • 开发自定义性能面板实时展示

下期预告:前端AI工程化(三) - 异步编程与并发控制,我们将从渲染层进入调度层,拆解Promise高级模式在多AI模型调用场景的实战应用。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/sijingqian/article/details/161035481

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--