ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Markdown到生成式UI:AI应用流式渲染实战与避坑指南

从Markdown到生成式UI:AI应用流式渲染实战与避坑指南

1. 从纯文本到动态界面:为什么流式渲染是AI应用的必答题

做过AI应用开发的人都有一个共同体会:大模型的输出不是一次性给你的,它是一个token一个token往外蹦的。如果你等模型全部生成完再渲染,用户盯着空白屏幕转圈十几秒,体验直接崩盘。流式渲染要解决的核心问题就一个——让用户看到内容在“生长”,而不是等一个完整的答案砸下来。

这个项目标题里提到的“从Markdown到生成式UI”,本质上描述的是AI应用前端渲染的一条演进路径。早期大家用Markdown做AI输出的载体,因为模型天然擅长输出结构化文本,标题、列表、代码块、表格都能用Markdown语法表达,前端拿到文本后用marked或markdown-it一类的解析器转成HTML,简单直接。但用着用着就发现不够了——用户想要的是可交互的卡片、可点击的按钮、可拖拽的图表,而不是一堆静态的文本块。于是“生成式UI”的概念被提出来:模型输出的不再只是文本,而是描述界面的结构化数据,前端根据这些数据动态渲染出组件。

我最早接触这块是在做一个AI助手类产品的时候。当时后端返回的是SSE流,前端用fetch的ReadableStream逐块读取,每读到一个chunk就追加到缓冲区,然后重新解析Markdown并渲染。听起来简单,但实际踩的坑一个接一个:代码块被截断导致解析报错、表格渲染到一半列数对不上、数学公式的定界符跨chunk断裂……这些问题不解决,流式渲染就是一场灾难。

这篇文章适合谁看?如果你正在做AI应用的前端开发,或者负责AI产品的技术选型,又或者你只是好奇“为什么ChatGPT的回答能一个字一个字往外蹦”,那接下来的内容应该能给你一些可以直接抄作业的方案。我会从Markdown流式渲染的底层机制讲起,逐步过渡到生成式UI的实现思路,中间穿插大量实操细节和踩坑记录。

2. Markdown流式渲染的核心机制与解析策略

2.1 流式数据的接收与缓冲管理

流式渲染的第一步是拿到数据流。目前主流方案有两种:SSE(Server-Sent Events)和基于fetch的ReadableStream。SSE的好处是浏览器原生支持EventSource,自动重连,但缺点是不能自定义请求头,只能发GET请求。实际项目中我更推荐用fetch加ReadableStream的方式,因为可以发POST请求,能带认证信息,灵活性高得多。

const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: userInput }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按SSE格式解析出完整的消息单元 const lines = buffer.split('\n'); buffer = lines.pop() || ''; // 最后一行可能不完整,留到下次 for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') continue; const parsed = JSON.parse(data); appendContent(parsed.content); } } }

这段代码里有几个关键点值得展开说。decoder.decode(value, { stream: true })这个stream: true参数非常重要,它告诉TextDecoder当前解码的不是最后一个chunk,遇到多字节字符(比如中文)被截断的情况时,它会保留不完整的字节序列,等下一个chunk到了再一起解码。如果不加这个参数,中文就会出现乱码。

另一个关键是缓冲区管理。你不能假设每个chunk都是一个完整的SSE消息,网络传输的切分是随机的。所以要用一个buffer变量累积数据,按换行符切分后,把最后一个可能不完整的行留在buffer里。这个模式在处理任何流式文本协议时都适用。

2.2 Markdown增量解析的难点与方案选型

拿到流式文本后,下一步是把它渲染成HTML。这里有个核心矛盾:Markdown解析器通常要求输入是完整的文档,但流式场景下你拿到的是不完整的片段。比如模型正在输出一个代码块:

```python def hello(

这个时候如果直接丢给marked去解析,它会认为代码块没有闭合,可能把后面的内容全部当成代码。更糟糕的是表格,Markdown表格的列数依赖分隔行来确定,如果分隔行还没输出完,解析结果完全是错的。

我试过几种方案,各有优劣:

方案原理优点缺点
全量重解析每次收到新内容,对整个缓冲区重新解析实现简单,结果准确内容长时性能差,O(n²)复杂度
增量解析只解析新增部分,维护解析状态机性能好实现复杂,边界情况多
分块解析按块级元素切分,只重解析最后一个未完成的块平衡性能和复杂度需要识别块级边界

实测下来,对于大多数AI应用场景,分块解析是最实用的方案。具体做法是:维护一个已完成的Markdown块列表和一个当前正在生成的块。每次新内容到达时,尝试判断当前块是否已经完成(比如代码块遇到了闭合的```,段落遇到了空行),如果完成就固化下来,不再重新解析,只解析新的当前块。

function parseIncremental(fullText) { const blocks = splitIntoBlocks(fullText); const completed = blocks.slice(0, -1); const pending = blocks[blocks.length - 1]; const html = completed.map(b => markdownToHtml(b)).join('') + markdownToHtml(pending); return html; } function splitIntoBlocks(text) { // 按空行切分,但代码块内的空行不算 const blocks = []; let current = ''; let inCodeBlock = false; for (const line of text.split('\n')) { if (line.trim().startsWith('```')) { inCodeBlock = !inCodeBlock; } if (!inCodeBlock && line.trim() === '' && current.trim()) { blocks.push(current); current = ''; } else { current += line + '\n'; } } if (current.trim()) blocks.push(current); return blocks; }

这个splitIntoBlocks函数是核心,它要正确处理代码块内的空行——代码块里的空行不能作为块的分隔符。同样的逻辑还要考虑数学公式块($$...$$)、HTML块等。

2.3 代码块高亮的流式处理技巧

代码高亮是AI应用里很常见的需求,模型输出的代码需要语法着色。但流式场景下,代码块可能只输出了一半,这时候做高亮会有问题。比如Python代码里有个三引号字符串还没闭合,高亮器可能会把后面所有内容都当成字符串。

我的做法是:对于正在生成中的代码块,先不做高亮,用统一的样式显示纯文本。等代码块闭合后,再用highlight.js或Prism.js做高亮。这样用户看到的是代码在逐渐出现,闭合后突然有了颜色,视觉上反而有一种“完成”的反馈感。

function renderCodeBlock(code, language, isComplete) { if (!isComplete) { return `<pre class="code-pending"><code>${escapeHtml(code)}</code></pre>`; } const highlighted = hljs.highlight(code, { language }).value; return `<pre class="code-complete"><code>${highlighted}</code></pre>`; }

还有一个细节:代码块的语言标识可能在流式过程中才出现。比如先收到```,然后才收到python。这时候要能正确处理,先按无语言处理,等语言标识到了再更新。

2.4 数学公式的流式渲染陷阱

数学公式是流式渲染里最容易出问题的部分。KaTeX和MathJax都要求完整的公式才能渲染,但流式场景下公式可能被切成好几段。比如:

行内公式 $E = mc^2$ 是这样

如果chunk切在$E = mc^2$中间,比如先收到$E = mc,这时候解析器会认为$没有闭合,可能把后面的内容都当成公式。

我的解决方案是延迟渲染:检测到$符号后,不立即渲染,而是等待下一个$出现。如果在一定时间内(比如500ms)没有等到配对的$,就先把$当普通字符显示。这个超时机制很重要,因为有些场景下用户就是输入了一个单独的$符号,不是公式。

function renderMath(text) { // 匹配完整的行内公式 const inlineMathRegex = /\$([^\$]+)\$/g; // 匹配完整的块级公式 const blockMathRegex = /\$\$([^\$]+)\$\$/g; let result = text; result = result.replace(blockMathRegex, (match, formula) => { try { return katex.renderToString(formula, { displayMode: true }); } catch (e) { return match; // 渲染失败就保留原文 } }); result = result.replace(inlineMathRegex, (match, formula) => { try { return katex.renderToString(formula, { displayMode: false }); } catch (e) { return match; } }); return result; }

注意这里用了try-catch,因为流式过程中可能拿到不完整的公式,KaTeX会抛异常。捕获异常后保留原文,等下次重解析时再尝试。

3. 从Markdown到生成式UI的架构演进

3.1 生成式UI的核心思路与数据协议设计

Markdown解决的是“文本怎么展示”的问题,生成式UI解决的是“界面怎么生成”的问题。两者的本质区别在于:Markdown的输出是给人看的文本,生成式UI的输出是给前端渲染器看的结构化数据。

我目前采用的方案是让模型输出一种轻量级的DSL(领域特定语言),前端解析这个DSL后映射到具体的组件。比如模型输出:

{ "type": "card", "props": { "title": "天气查询结果" }, "children": [ { "type": "text", "props": { "content": "北京今天晴,气温25°C" } }, { "type": "button", "props": { "label": "查看详情", "action": "view_detail" } } ] }

前端拿到这个JSON后,递归渲染出对应的组件树。这种方案的好处是模型只需要输出数据,不需要关心具体的UI实现,前端可以自由决定用什么组件库、什么样式。

但这里有个关键问题:流式场景下JSON也是不完整的。你不能等整个JSON对象接收完再解析,那样就失去了流式的意义。所以需要一种支持流式解析的格式。

3.2 流式JSON解析与增量组件渲染

我试过几种流式JSON解析方案。最直接的是用JSON.parse的流式版本,比如clarinet或oboe.js,它们能在JSON还没完整时就开始触发事件。但这类库通常比较重,而且对于生成式UI这种场景,我们其实不需要完整的JSON解析能力。

更实用的方案是自定义一种行分隔的协议。每一行是一个完整的JSON对象,代表一个UI指令:

{"op": "create", "id": "card1", "type": "card", "props": {"title": "天气"}} {"op": "append", "parent": "card1", "id": "text1", "type": "text", "props": {"content": "北京今天晴"}} {"op": "append", "parent": "card1", "id": "btn1", "type": "button", "props": {"label": "详情"}} {"op": "update", "id": "text1", "props": {"content": "北京今天晴,气温25°C"}}

每一行都是完整的JSON,可以独立解析。前端维护一个组件树的状态,收到指令后就地更新。这种方案的好处是:解析简单(每行JSON.parse就行),流式友好(逐行处理),而且支持增量更新(update指令可以修改已有组件的属性)。

function processUIStream(line) { const cmd = JSON.parse(line); switch (cmd.op) { case 'create': componentTree[cmd.id] = createComponent(cmd.type, cmd.props); break; case 'append': const parent = componentTree[cmd.parent]; if (parent) { parent.children.push(createComponent(cmd.type, cmd.props)); } break; case 'update': const target = componentTree[cmd.id]; if (target) { Object.assign(target.props, cmd.props); } break; } scheduleRender(); }

scheduleRender用requestAnimationFrame做批量更新,避免每个指令都触发一次重渲染。

3.3 组件映射与动态渲染的实现细节

组件映射层是生成式UI的“翻译官”,它把模型输出的类型字符串映射到具体的组件。我通常用一个注册表来管理:

const componentRegistry = { text: (props) => <Text {...props} />, button: (props) => <Button {...props} />, card: (props) => <Card {...props} />, chart: (props) => <Chart {...props} />, table: (props) => <Table {...props} />, form: (props) => <Form {...props} /> }; function renderComponent(node) { const Component = componentRegistry[node.type]; if (!Component) { console.warn(`Unknown component type: ${node.type}`); return <FallbackComponent type={node.type} />; } return ( <Component {...node.props}> {node.children?.map(child => renderComponent(child))} </Component> ); }

这里有个安全考虑:模型输出的props不能直接透传给组件,需要做白名单过滤。比如button的onClick不能由模型指定,而应该由前端根据action字段映射到预定义的处理函数。否则模型可能输出恶意代码。

const actionHandlers = { view_detail: () => navigateToDetail(), submit_form: (formData) => submitForm(formData), refresh: () => refreshData() }; function Button({ label, action }) { const handler = actionHandlers[action] || (() => {}); return <button onClick={handler}>{label}</button>; }

3.4 流式渲染的性能优化与内存管理

流式渲染做久了会发现,性能问题主要来自两个方面:频繁的DOM操作和不断增长的内存占用。

DOM操作方面,每收到一个chunk就更新一次DOM是不可取的。我的做法是用requestAnimationFrame做节流,把多次更新合并到一帧里:

let pendingUpdate = false; function scheduleRender() { if (pendingUpdate) return; pendingUpdate = true; requestAnimationFrame(() => { pendingUpdate = false; doActualRender(); }); }

内存方面,随着对话进行,消息列表会越来越长。如果不做处理,几千条消息后页面会变得很卡。我的方案是虚拟滚动加消息分页:只渲染可视区域内的消息,历史消息折叠起来,用户滚动到顶部时再加载。

function MessageList({ messages }) { const [visibleRange, setVisibleRange] = useState([0, 50]); return ( <VirtualScroller itemCount={messages.length} itemSize={estimateHeight} onRangeChange={setVisibleRange} > {({ index }) => <Message data={messages[index]} />} </VirtualScroller> ); }

还有一个容易被忽略的点:流式过程中产生的中间状态要及时清理。比如一个消息正在流式生成时,它的解析缓存、高亮缓存都要在完成后释放,否则内存会持续增长。

4. 实操全流程:从零搭建一个流式渲染的AI对话界面

4.1 项目初始化与技术栈选型

我以React为例,搭建一个完整的流式渲染AI对话界面。技术栈选择如下:

  • 框架:React 18 + TypeScript
  • 构建工具:Vite
  • Markdown解析:markdown-it(比marked更灵活,插件生态好)
  • 代码高亮:highlight.js
  • 数学公式:KaTeX
  • 流式请求:fetch + ReadableStream
npm create vite@latest ai-streaming-ui -- --template react-ts cd ai-streaming-ui npm install markdown-it highlight.js katex npm install -D @types/markdown-it

选markdown-it而不是marked的原因是它的插件系统更完善,而且支持自定义renderer规则,方便我在流式场景下做特殊处理。highlight.js比Prism.js的体积大一些,但它的自动语言检测更准确,对于模型输出的代码块(可能没有语言标识)更友好。

4.2 流式请求层的封装与错误处理

请求层要处理的不只是数据接收,还有错误重试、超时、中断等场景。我封装了一个StreamClient类:

class StreamClient { private controller: AbortController | null = null; async stream( url: string, body: object, onChunk: (text: string) => void, onDone: () => void, onError: (err: Error) => void ) { this.controller = new AbortController(); try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: this.controller.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const data = line.slice(6).trim(); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); onChunk(parsed.content); } catch (e) { console.warn('Failed to parse SSE data:', data); } } } onDone(); } catch (err) { if ((err as Error).name === 'AbortError') { console.log('Stream aborted by user'); } else { onError(err as Error); } } finally { this.controller = null; } } abort() { this.controller?.abort(); } }

这里有几个实操细节:buffer.split('\n')之后用pop()保留最后一行,因为最后一行可能不完整。JSON.parse要包在try-catch里,因为流式过程中可能收到不完整的数据。AbortController用于用户主动中断生成,这在AI应用里是必备功能。

4.3 Markdown渲染组件的完整实现

渲染组件是整个前端的核心,它要协调流式数据、Markdown解析、代码高亮、公式渲染等多个环节:

import MarkdownIt from 'markdown-it'; import hljs from 'highlight.js'; import katex from 'katex'; const md = new MarkdownIt({ html: false, linkify: true, breaks: true, highlight: (code, lang) => { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (e) {} } return ''; // 让markdown-it自己转义 } }); function StreamingMarkdown({ content, isStreaming }: { content: string; isStreaming: boolean; }) { const html = useMemo(() => { const blocks = splitIntoBlocks(content); const completed = blocks.slice(0, -1); const pending = blocks[blocks.length - 1] || ''; const completedHtml = completed .map(b => md.render(b)) .join(''); // 正在生成的块,代码块不做高亮 const pendingHtml = isStreaming ? renderPendingBlock(pending) : md.render(pending); return completedHtml + pendingHtml; }, [content, isStreaming]); return ( <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} /> ); } function renderPendingBlock(block: string): string { // 检测是否是未闭合的代码块 const codeBlockMatch = block.match(/^```(\w*)\n([\s\S]*)$/); if (codeBlockMatch) { const lang = codeBlockMatch[1]; const code = codeBlockMatch[2]; return `<pre class="code-pending"><code>${escapeHtml(code)}</code></pre>`; } // 其他情况正常渲染 return md.render(block); }

这个实现里,splitIntoBlocks负责把内容按块切分,已完成的块正常渲染,正在生成的块特殊处理。对于未闭合的代码块,用纯文本显示,避免高亮器出错。

4.4 生成式UI的组件注册与动态渲染

生成式UI部分,我实现一个简单的组件注册表和渲染器:

type ComponentNode = { id: string; type: string; props: Record<string, any>; children?: ComponentNode[]; }; const registry: Record<string, React.FC<any>> = { text: ({ content, style }) => ( <p style={style}>{content}</p> ), button: ({ label, action }) => ( <button onClick={() => handleAction(action)}>{label}</button> ), card: ({ title, children }) => ( <div className="card"> {title && <h3>{title}</h3>} {children} </div> ), chart: ({ type, data }) => ( <ChartRenderer type={type} data={data} /> ) }; function DynamicRenderer({ node }: { node: ComponentNode }) { const Component = registry[node.type]; if (!Component) { return <div className="unknown-component">未知组件: {node.type}</div>; } const safeProps = sanitizeProps(node.type, node.props); return ( <Component {...safeProps}> {node.children?.map(child => ( <DynamicRenderer key={child.id} node={child} /> ))} </Component> ); } function sanitizeProps(type: string, props: Record<string, any>) { const allowedProps: Record<string, string[]> = { text: ['content', 'style'], button: ['label', 'action'], card: ['title'], chart: ['type', 'data'] }; const allowed = allowedProps[type] || []; const result: Record<string, any> = {}; for (const key of allowed) { if (key in props) result[key] = props[key]; } return result; }

sanitizeProps是安全防线,只允许白名单内的属性透传,防止模型输出危险属性(比如dangerouslySetInnerHTML)。

4.5 状态管理与流式更新的协调

整个应用的状态管理用useReducer比较合适,因为流式更新涉及多种操作(追加文本、更新组件、切换状态):

type State = { messages: Message[]; isStreaming: boolean; error: string | null; }; type Action = | { type: 'START_STREAM' } | { type: 'APPEND_TEXT'; messageId: string; text: string } | { type: 'UPDATE_COMPONENT'; messageId: string; node: ComponentNode } | { type: 'END_STREAM' } | { type: 'SET_ERROR'; error: string }; function reducer(state: State, action: Action): State { switch (action.type) { case 'START_STREAM': return { ...state, isStreaming: true, error: null, messages: [ ...state.messages, { id: generateId(), content: '', components: [] } ] }; case 'APPEND_TEXT': return { ...state, messages: state.messages.map(m => m.id === action.messageId ? { ...m, content: m.content + action.text } : m ) }; case 'END_STREAM': return { ...state, isStreaming: false }; case 'SET_ERROR': return { ...state, isStreaming: false, error: action.error }; default: return state; } }

这里的关键是APPEND_TEXT操作要尽量轻量,只更新对应消息的content字段,避免整个消息列表重新渲染。配合React.memo和useMemo,可以做到只有正在流式生成的那条消息在更新。

5. 常见问题排查与避坑指南

5.1 流式渲染中的典型问题速查表

问题现象可能原因排查方法解决方案
中文显示乱码TextDecoder未设置stream模式检查decode调用加{ stream: true }参数
代码块高亮错乱未闭合代码块被高亮检查isStreaming状态未闭合时不高亮
表格渲染错位分隔行未完整输出观察表格块延迟渲染表格
公式显示为原文KaTeX解析失败查看控制台异常try-catch包裹,失败保留原文
页面卡顿频繁DOM更新Performance面板requestAnimationFrame节流
内存持续增长中间状态未清理Memory面板完成后释放缓存
SSE连接断开网络波动或超时Network面板实现重连机制
消息顺序错乱并发请求未排序检查请求时序用队列串行化

5.2 流式解析的边界情况处理

边界情况是流式渲染里最耗时间的部分。我整理了几个最常见的:

情况一:chunk切在多字节字符中间。比如“你好”两个字的UTF-8编码是6个字节,如果chunk切在第3个字节,直接解码会得到乱码。解决方案就是前面提到的stream: true。

情况二:chunk切在Markdown语法标记中间。比如**bold**被切成**bo和ld**。这种情况下,第一次解析时**bo会被当成普通文本,第二次解析时整个**bold**才能正确加粗。用户会看到文本先出现,然后突然加粗。这个体验可以接受,但如果想优化,可以在解析前做一个“语法完整性检查”,不完整的标记先不渲染。

情况三:chunk切在HTML实体中间。比如&amp;被切成&am和p;。这个和情况二类似,等完整了再解析。

情况四:模型输出包含Markdown语法但用户想显示原文。比如用户问“怎么在Markdown里写加粗”,模型回答“用**text**”。这时候**text**应该显示为原文而不是加粗。解决方案是用代码块包裹,或者前端做转义。

5.3 性能优化的实战经验

流式渲染的性能优化,我总结下来就三条:减少重渲染、减少DOM操作、减少内存占用。

减少重渲染方面,React.memo是基本操作,但要注意memo的比较函数。对于消息组件,只需要比较content和isStreaming两个prop:

const Message = React.memo( ({ message, isStreaming }) => { ... }, (prev, next) => prev.message.content === next.message.content && prev.isStreaming === next.isStreaming );

减少DOM操作方面,除了requestAnimationFrame节流,还可以用DocumentFragment做批量插入。不过在React里,这个由React自己处理,我们只需要保证不频繁setState就行。

减少内存占用方面,最重要的是及时清理不再需要的缓存。比如消息完成后,它的Markdown解析缓存、高亮缓存都可以释放。如果用了虚拟滚动,还要注意回收不可见区域的DOM节点。

5.4 我踩过的几个印象深刻的坑

第一个坑是KaTeX的渲染时机。我一开始在每次chunk到达时都重新渲染所有公式,结果发现公式会闪烁——因为KaTeX渲染时会先清空容器再插入新内容。后来改成只渲染新出现的公式,已渲染的公式不再动,闪烁问题就解决了。

第二个坑是代码块的自动滚动。流式生成代码时,用户希望看到最新的一行,所以需要自动滚动到底部。但如果用户手动往上滚了,就不能再自动滚动,否则会打断用户的阅读。解决方案是监听滚动事件,判断用户是否在底部,只有在底部时才自动滚动。

function useAutoScroll(ref, isStreaming) { const [autoScroll, setAutoScroll] = useState(true); useEffect(() => { const el = ref.current; if (!el) return; const handleScroll = () => { const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50; setAutoScroll(isAtBottom); }; el.addEventListener('scroll', handleScroll); return () => el.removeEventListener('scroll', handleScroll); }, []); useEffect(() => { if (autoScroll && isStreaming && ref.current) { ref.current.scrollTop = ref.current.scrollHeight; } }, [autoScroll, isStreaming]); }

第三个坑是SSE的代理缓冲。部署到生产环境后,发现流式效果消失了,所有内容一次性出现。排查后发现是Nginx默认开启了proxy_buffering,它会把SSE数据缓冲起来。解决方案是在Nginx配置里加上proxy_buffering off;和proxy_cache off;。

第四个坑是移动端的键盘遮挡。在手机上,输入框获得焦点后键盘弹出,会把消息列表顶上去。流式生成时如果自动滚动,会跟键盘的动画冲突。解决方案是用visualViewport API监听键盘高度变化,动态调整滚动位置。

6. 生成式UI的进阶方向与个人实践体会

生成式UI目前还在快速演进中,我观察到几个有意思的方向。一个是模型直接输出React/Vue组件代码,前端用沙箱环境执行。这个方案灵活度最高,但安全风险也最大,需要严格的沙箱隔离。另一个是模型输出设计稿格式(比如类似Figma的JSON结构),前端用渲染引擎解析。这个方案适合低代码平台,但实现复杂度高。

我目前在实际项目中采用的是混合方案:简单场景用Markdown,复杂交互用DSL指令流。判断标准是看内容是否需要用户交互——纯展示用Markdown,需要点击、输入、拖拽的用生成式UI。这个分界线在实践中比较好把握。

流式渲染这块,我最大的体会是:不要追求一步到位。先把基本的文本流式跑通,再逐步加代码高亮、公式渲染、生成式UI。每加一个功能,都要充分测试边界情况。我见过太多项目因为一开始就追求大而全,结果每个环节都有bug,最后不得不推倒重来。

另外,服务端的配合也很重要。前端做流式渲染,服务端要保证chunk的切分是合理的。比如不要在Markdown语法标记中间切分,尽量按语义单元切分。这个需要前后端一起约定协议,不能只靠前端硬扛。

最后分享一个实用技巧:在开发阶段,用一个模拟的流式数据源来测试。把一段完整的Markdown文本按随机长度切分,模拟网络传输的不确定性。这样可以快速复现各种边界情况,比等真实模型输出效率高得多。

function mockStream(text: string, onChunk: (chunk: string) => void) { let index = 0; const interval = setInterval(() => { if (index >= text.length) { clearInterval(interval); return; } const chunkSize = Math.floor(Math.random() * 10) + 1; const chunk = text.slice(index, index + chunkSize); index += chunkSize; onChunk(chunk); }, 50); }

这个mock函数我到现在还在用,每次改渲染逻辑都会跑一遍,确保没有回归问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进