大模型思考链的前端展示流式渲染与折叠交互的工程化实践一、Token 洪流下的渲染困境思考链展示为何频繁卡顿推理模型DeepSeek-R1、QwQ、o1 系列的普及改变了前端的渲染对象。过去流式输出的只是答案如今先输出一段数千 Token 的思考链再输出最终回答。思考链以逐 Token 方式经 SSE 推送频率可达每秒 40-80 个 Token。这对前端造成三重压力。第一每个 Token 触发一次状态更新与 DOM 重绘高频写入引发布局抖动layout thrashing。第二思考链内容较长完整渲染后 DOM 节点可达数千个滚动帧率骤降。第三用户往往只想看结论思考链需要默认折叠但折叠态下仍需持续接收 Token 以备展开查看。实测数据可以说明问题的严重程度。在 M2 MacBook Air 上对一段 3000 Token 的思考链做逐 Token 直接 setState加全量 markdown 重渲染滚动帧率从 60fps 跌至 12fps主线程长任务50ms占比 73%。这已不是优化问题而是架构问题。逐 Token 同步重绘的模型在推理模型场景下根本不可用。二、流式渲染管线从 SSE 分帧到增量 DOM 提交解决思路是将接收与渲染解耦。接收层只负责拼字符串渲染层按帧批量提交。整条管线如下┌──────────┐ chunk ┌──────────────┐ token ┌──────────────┐ │ SSE / │ ─────────► │ ChunkBuffer │ ───────► │ StreamParser │ │ fetch │ │ (按行拼接) │ │ (分流reason/ │ │ stream │ └──────────────┘ │ content) │ └──────────┘ └──────┬───────┘ │ append ▼ ┌──────────────────┐ schedule ┌────────────────────────────┐ │ IncrementalDOM │ ◄────────── │ RAFBatcher (合并到下一帧) │ │ (只重渲染末块) │ │ - 收集 token 至 dirty 标记 │ └──────────────────┘ │ - rAF 回调统一 flush │ │ └────────────────────────────┘ ▼ ┌──────────────────┐ │ VirtualScroller │ 只渲染可视区域 ± buffer 行 │ (折叠/展开视口) │ └──────────────────┘关键设计有三层。分帧层fetch 的 ReadableStream 读取到的 chunk不保证按 SSE event 边界对齐需要先按空行切分再解析 data 字段。EventSource 虽然封装了分帧但不支持 POST 请求与自定义 Header生产环境通常用 fetch 加 ReadableStream 手动解析。分流层推理模型的输出混含think.../think标记。StreamParser 需要维护一个状态机在 reason思考链与 content回答之间切换分别写入两个 buffer。状态切换是增量渲染的前提。只有知道当前 Token 属于哪段才能决定渲染到哪个 DOM 容器。批量层RAFBatcher 把一帧内收到的所有 Token 攒到一起在下一个 requestAnimationFrame 回调中统一 flush。这样无论 Token 到达多频繁渲染频率被锁死在 60fps。同时配合增量 markdown 渲染只对最后未闭合的 markdown 块重新解析已闭合的块缓存其 HTML 不再重算。下面是各层的关键参数对比层级输入频率输出频率缓冲策略丢弃策略分帧层不定网络 MTU按行字符串拼接不丢弃分流层按 Token按 Token状态机不丢弃批量层40-80 Token/s60fpsdirty 标记合并增量渲染60fps60fps块缓存不重算已闭合块三、生产级实现流式解析器与可折叠视图的协同下面给出 TypeScript 实现。核心是 StreamParser状态机分流、RAFBatcher帧合并、CollapsibleStream折叠视图三者的协同。// stream-parser.ts // 推理模型输出混含 think 标签需要状态机在 reason/content 间切换。 // 为什么不用正则全局匹配流式场景下 think 可能跨 chunk // 正则只能处理完整字符串状态机可处理任意切分点。 export type SegmentType reason | content; export interface StreamSegment { type: SegmentType; text: string; } type ParserState reason | content; export class StreamParser { private state: ParserState reason; // 默认先进入思考链 private buffer ; // 跨 chunk 的未决字符 private pendingTag ; // 可能是未完整的标签前缀 /** 写入一个 chunk返回本次解析出的分段可能为空 */ write(chunk: string): StreamSegment[] { this.buffer chunk; const segments: StreamSegment[] []; // 标签边界检测think 与 /think 可能被网络切分 // 所以用逐字符扫描加前缀缓存而非 split。 let i 0; while (i this.buffer.length) { if (this.buffer[i] ) { const matched this.tryMatchTag(i); if (matched open) { if (this.pendingTag) { segments.push({ type: this.state, text: this.pendingTag }); this.pendingTag ; } this.state reason; i think.length; continue; } if (matched close) { if (this.pendingTag) { segments.push({ type: this.state, text: this.pendingTag }); this.pendingTag ; } this.state content; i /think.length; continue; } if (matched partial) { // 标签被截断缓存到下一 chunk 再判 this.pendingTag this.buffer.slice(i); this.buffer ; break; } } this.pendingTag this.buffer[i]; i; } if (this.pendingTag !this.isPossiblyTagStart(this.pendingTag)) { segments.push({ type: this.state, text: this.pendingTag }); this.pendingTag ; } this.buffer ; return segments; } private tryMatchTag(pos: number): open | close | partial | null { const rest this.buffer.slice(pos); if (rest.startsWith(think)) return open; if (rest.startsWith(/think)) return close; const open think; const close /think; if (open.startsWith(rest) || close.startsWith(rest)) return partial; return null; } private isPossiblyTagStart(s: string): boolean { return think.startsWith(s) || /think.startsWith(s); } }// raf-batcher.ts // 为什么不用 throttlethrottle 基于时间窗 // 但 Token 到达频率不稳定rAF 能与浏览器渲染周期对齐 // 保证每次渲染都在帧内避免掉帧。 export class RAFBatcherT { private queue: T[] []; private scheduled false; private readonly onFlush: (items: T[]) void; constructor(onFlush: (items: T[]) void) { this.onFlush onFlush; } push(item: T): void { this.queue.push(item); if (this.scheduled) return; // 用 rAF 锁定到下一帧避免同帧多次触发渲染 this.scheduled true; requestAnimationFrame(() { this.scheduled false; if (this.queue.length 0) return; const items this.queue; this.queue []; try { this.onFlush(items); } catch (err) { // 渲染异常不能中断流吞掉并上报 console.error([RAFBatcher] flush failed, err); } }); } dispose(): void { this.queue []; this.scheduled false; } }// CollapsibleStream.tsx // 折叠态下仍需接收 Token以便展开时立即可见 // 但不触发 DOM 写入靠渲染门控控制。 import { useEffect, useRef, useState, useCallback } from react; interface Props { stream: ReadableStreamUint8Array; } export function CollapsibleStream({ stream }: Props) { const [reasonText, setReasonText] useState(); const [contentText, setContentText] useState(); const [collapsed, setCollapsed] useState(true); // 默认折叠思考链 const parserRef useRef(new StreamParser()); const reasonBatcherRef useRefRAFBatcherstring | null(null); const contentBatcherRef useRefRAFBatcherstring | null(null); const readerRef useRefReadableStreamDefaultReaderUint8Array | null(null); useEffect(() { // 初始化两个 batcher分别绑定到 reason/content 的 setState reasonBatcherRef.current new RAFBatcher((items) { setReasonText((prev) prev items.join()); }); contentBatcherRef.current new RAFBatcher((items) { setContentText((prev) prev items.join()); }); const reader stream.getReader(); readerRef.current reader; const decoder new TextDecoder(); const pump async (): Promisevoid { try { while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value, { stream: true }); const segments parserRef.current.write(text); for (const seg of segments) { if (seg.type reason) { reasonBatcherRef.current?.push(seg.text); } else { contentBatcherRef.current?.push(seg.text); } } } } catch (err) { // 网络中断保留已收到的内容标记为不完整 console.error([CollapsibleStream] stream error, err); } finally { decoder.decode(); // flush 解码器内部缓冲 } }; pump(); return () { // 组件卸载时取消读取防止内存泄漏与 setState 到已卸载组件 readerRef.current?.cancel().catch(() {}); reasonBatcherRef.current?.dispose(); contentBatcherRef.current?.dispose(); }; }, [stream]); const toggle useCallback(() setCollapsed((c) !c), []); return ( div classNamestream-container section classNamereason-section button onClick{toggle} aria-expanded{!collapsed} {collapsed ? 展开 : 折叠}思考链 /button {!collapsed pre classNamereason-text{reasonText}/pre} /section section classNamecontent-section pre classNamecontent-text{contentText}/pre /section /div ); }上述实现有几个生产级细节。StreamParser 用状态机而非正则是为了应对 think 标签跨 chunk 的情况。RAFBatcher 的 flush 异常被吞掉而非抛出因为流式场景下单次渲染失败不应中断整个回答。组件卸载时调用 reader 的 cancel否则浏览器会持续拉取数据直到流结束造成内存泄漏。四、性能代价与边界内存增长与回溯重绘的取舍上述方案并非没有代价。延迟代价。RAFBatcher 引入最多 16.7ms 的渲染延迟。对于普通对话场景无感但对于逐字打字机效果的强需求场景这个延迟会让动画看起来不够跟手。权衡点在于要帧率稳定还是要首字延迟二者不可兼得。实测将 batcher 改为混合策略队列超过 20 Token 即 flush可把首字延迟压到 8ms 以内但帧率波动会增大。内存代价。为了支持折叠展开后回看reasonText 与 contentText 的完整字符串必须常驻内存。一段 5000 Token 的思考链约占 30-50KB单会话尚可接受。但多轮对话场景下若不清理历史轮次的完整文本内存会持续增长。生产建议超过 20 轮时对历史轮次做摘要替换只保留最近 5 轮的完整流。增量 markdown 渲染的边界。增量策略依赖已闭合块不再重算的假设但 markdown 的某些语法打破了这个假设。例如引用块跨多行时只有遇到空行才闭合。代码块在流式中可能长时间不闭合。若在未闭合期间做了样式计算闭合后需要回溯重绘。更棘手的是数学公式单个美元符号可能是公式开始或普通文本需要后向 lookahead。对这些场景稳妥做法是未闭合块渲染为纯文本闭合后再做 markdown 解析。适用边界。本方案适用于推理模型对话场景即 Token 频率 40-80/s、总长度 1k-10k Token。若 Token 频率超过 200/s某些批量补全接口RAFBatcher 的合并会丢失视觉粒度此时应改用按段提交策略。若单次输出超过 50k Token虚拟滚动是必须的否则 DOM 节点数会触发浏览器内存上限。禁用场景。要求精确逐字同步显示如实时字幕、同声传译辅助的场景不应使用 RAF 合并因为合并会丢失 Token 到达的时间信息。此时应直接写入并用 CSS containment 限制重绘范围。五、总结推理模型的思考链展示本质上是一个高频写入与有限渲染预算的矛盾。核心解法是把接收、分流、批量、渲染四层解耦用状态机处理标签跨 chunk用 requestAnimationFrame 锁定渲染频率用增量渲染压缩重算范围。落地步骤如下。第一步用 fetch 加 ReadableStream替换 EventSource获得 POST 与自定义 Header 能力。第二步实现 StreamParser 状态机把 reason 与 content 分流到两个 buffer。第三步接入 RAFBatcher把 Token 写入合并到每帧一次。第四步实现折叠视图折叠态下只接收不渲染。第五步对超过 5k Token 的输出接入虚拟滚动。第六步在多轮对话中实现历史轮次的摘要替换控制内存增长。性能验证以滚动帧率与主线程长任务占比为准。目标3000 Token 思考链下滚动帧率稳定在 55fps 以上长任务占比低于 15%。