做AI应用这一年多,被团队问得最多的一个点,不是模型选型,也不是提示词怎么写,而是:AI输出的内容到底怎么“流”到页面上,还能不崩。某公司一个AI助手项目,从第一版用Markdown做流式文本输出,到后来演进成一套让模型直接生成界面描述结构、再由前端动态渲染成组件的生成式UI方案,这条路我走了小一个季度。这篇文章就把整套实践的选型逻辑、增量协议设计、前端渲染管线和生产环境踩过的坑完整拆开讲。正在做AI应用的前端、全栈甚至后端同学,可以直接拿到一套可落地的流式渲染参考方案。
先说结论:Markdown不是终点,它只是AI内容落地的及格线。当你要给用户的不只是“读一段话”,而是“看一张表”“填一个表单”“点一个按钮”的时候,文本流就撑不住了。本文会重点覆盖从SSE流式传输、增量协议设计,到LLM结构化输出约束、React端动态渲染,再到降级与排查的完整链路。
1. 为什么Markdown不够了:AI流式渲染的演进起点
1.1 第一版为什么选了Markdown流式输出
任何方案的选择都要放在当时的约束条件下看。第一版AI助手的目标很简单:让用户能像聊天一样看到AI逐字回答问题。这时候Markdown就是最优解,原因有三个。
第一,LLM天生擅长输出Markdown。训练语料里大量存在这类格式,模型不需要额外的格式约束就能稳定生成标题、列表、加粗、代码块。第二,渲染成本极低。前端接到一段文本流,累加字符串,丢给Markdown解析器转成HTML,几十行代码就能跑通。第三,用户的认知成本为零。聊天窗口里的Markdown和我们在代码仓库里看到的README观感一致,不突兀。
第一版的实现方式是这样的:后端把模型输出的增量文本通过SSE逐块推给前端,前端维护一个完整的markdown字符串,每次收到新块就重新解析、重新渲染。这个版本上线后,纯文本问答场景体验其实挺好的,用户能看到逐字蹦出来的效果,也不觉得卡。但问题恰恰藏在“重新渲染”这个动作里。
1.2 真正逼我们换方案的三个场景
项目跑到第二个月,产品侧的需求开始变化。不再是“回答一个问题”,而是“完成一个任务”。三个场景直接暴露了Markdown流式渲染的天花板。
第一个场景是数据表格。用户问“对比这三个月的销售数据”,模型要输出一张可排序、可滚动、可导出的表格。Markdown的表格语法只能表达静态的文本网格,无法承载排序、筛选、固定表头这些交互。就算前端把Markdown表格识别出来再手工转换成组件,流式输出过程中表格结构不完整会导致解析错乱,体验非常糟糕。
第二个场景是图表。用户要求“画一个过去30天访问量的折线图”,模型如果输出ASCII风格的折线图,观感就是玩具。理想情况是模型直接生成图表配置,前端灌进图表组件里渲染。而Markdown对这种结构化描述毫无表达能力。
第三个场景是可交互表单。比如AI诊断完问题,给用户一个“重启服务”的按钮,或者让用户补充工单信息的表单。这类需求已经不是文本内容,而是组件与事件。Markdown渲染器再强也做不出按钮的回调逻辑。
这三个场景出现后,我们意识到:AI应用的前端渲染,核心矛盾已经从“文本格式转换”变成了“结构化界面的动态生成与更新”。于是开始做生成式UI。
2. 流式传输与增量协议:先把数据管道打通
2.1 传输层选型:为什么SSE比WebSocket更省事
生成式UI的第一步不是渲染,是先把“流的通道”建好。流式方案里,可选项无非SSE、WebSocket和HTTP轮询。我们的选择很直接:服务端推送用SSE。
单听概念可能有点绕,其实可以这样理解:SSE是一条服务端单向向浏览器推送文本消息的通道,浏览器通过EventSource接口监听;WebSocket则像一条双向隧道,两边随时可以互发消息。AI对话有个天然特点:消息方向以服务端往客户端为主,用户只在发起阶段发送一次请求。这种单向数据流和SSE的模型完全匹配。
SSE的好处还在于它建立在普通HTTP之上。内网网关、鉴权中间件、日志系统全都按HTTP那一套走,不用额外引入WebSocket的协议升级和心跳保活机制。尤其在生产环境里,WebSocket需要处理连接超时、重连、消息心跳,任何一个环节出问题排查成本都不低。SSE自带的自动重连机制更是省心,连接断了浏览器会主动重试。
这里有一个实践细节:虽然EventSource用起来简单,但它只能发GET请求,没法方便地携带POST body。我们的方案是改用fetch + ReadableStream来消费SSE。后端照常返回text/event-stream,前端用fetch发起POST,再从响应体里读流。这样既能传参数,又能拿到底层的流式数据,灵活性高很多。
const resp = await fetch('/api/ai/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '对比这三个月的销售数据', sessionId: 's-1024' }), }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE事件以空行分割,这里只处理完整的事件 const events = buffer.split('\n\n'); buffer = events.pop() ?? ''; for (const event of events) { if (!event.startsWith('data:')) continue; const payload = JSON.parse(event.slice(5).trim()); handlePayload(payload); } }2.2 增量协议v1:delta文本流的实现与痛点
数据通道建好后,前后端约定一个极简协议。服务端每个SSE事件发一个JSON对象,里面带一个delta字段,存的是模型新生成的文本片段。前端维护一个累积的fullText,每收到一块就拼接,然后重新渲染Markdown。
最初的渲染策略是“全量重渲染”,也就是把完整的Markdown字符串反复交给解析器。实测下来,文本短的时候没问题,一旦回答超过1000个汉字,每来一个块就全量解析一次,页面明显掉帧。代码块场景更糟:模型输出代码时,```还没闭合,解析器会把后续的所有内容都当成代码文本处理,用户会看到大段灰色背景反复跳变。
这里要解释一个核心矛盾:Markdown语法是上下文相关的,一个未闭合的代码块会污染整个后续解析。而流式输出天然存在“中间态”,很多语法在中间态是不完整、不正确的。如果每次对全量文本做解析,就必须反复处理这种“脏中间态”。
我们的折中方案是三项组合拳。
第一,渲染合并。不每收一个delta就渲染,而是用一个队列把多个delta累积起来,通过requestAnimationFrame合并成一次渲染操作,把高频更新压到浏览器帧率以内。
第二,代码块延迟渲染。检测到未闭合的代码块标记时,只显示一个“代码生成中”的占位,等闭合标记到达后再一次性渲染整个代码块。
第三,滚动位置补偿。由于重新渲染会导致页面高度变化,手动记录滚动容器的锚点元素位置,渲染完成后把滚动偏移加回去。
这套组合拳让Markdown流式体验达到了可用级别,但根本矛盾还在:Markdown渲染器输出的是一整段HTML,前端无法对其中某个表格、某张图做局部更新。结构化的交互需求,文本流永远满足不了。
2.3 从“文本增量”到“结构增量”的关键转变
流式渲染的进化节点,是想通一个问题:文本流式的单位是“字符增量”,界面流式的单位应该是“操作增量”。
什么意思呢?文本流告诉前端“这段文字变多了”,前端要做的是字符串拼接;界面流应该告诉前端“在这个位置新增了一个组件”“给那个组件更新了属性”“在这个容器里插入了子节点”。前端拿到的是一个个明确的结构操作,按顺序执行就能得到完整界面。
这个思维的转变,直接推动了增量协议v2的设计。协议不再是“模型说什么前端显示什么”,而是“模型想让界面上发生什么变化,前端就执行什么操作”。前端从被动的文本消费者,变成了结构操作的解释器和执行器。
3. 生成式UI:让模型直接输出界面描述
3.1 生成式UI的本质是UI Schema,不是让模型画页面
一听到“生成式UI”,很多人的第一反应是让AI生成一份前端代码然后执行。这个方向既危险又笨重。危险在于,让模型输出JavaScript并在页面执行等于引狼入室;笨重在于,代码生成的中间态太多,流式渲染时非常容易产生半截语法。
我们走的是另一条路:让模型输出一棵描述型UI树,也就是UI Schema。节点类型是预先定义好的白名单组件,节点属性是组件的配置参数,节点之间通过嵌套表达布局关系。渲染器拿到这棵树的描述,动态映射成真实React组件。数据、样式、行为三者分离:UI描述负责“长什么样”,数据字段负责“显示什么”,动作字段负责“点了之后干什么”。
举一个最小示例,模型想展示一条提示信息加一个操作按钮,可以输出这样一个Schema:
{ "type": "Stack", "props": { "direction": "vertical", "gap": 12 }, "children": [ { "type": "Alert", "props": { "kind": "warning", "text": "服务存在异常,建议重启节点" } }, { "type": "Button", "props": { "text": "查看诊断报告", "action": { "name": "openReport", "payload": { "reportId": "r-1024" } } } } ] }这里action字段是事件出口。渲染器遇到带name的action,会把它绑定到前端的事件分发器上。模型自己不需要知道点击按钮后具体执行什么——前端预先注册好动作处理器,只管接收回调即可。这套隔离方案让生成式UI既灵活,又不至于越权。
3.2 增量指令流协议:每条指令独立可应用
如果让模型一次性输出完整的UI Schema JSON,会产生两个问题:第一,长JSON生成时间长,首屏等得急;第二,JSON在流式中途不完整,前端没法解析,等于没流。所以我们把UI Schema拆成了指令流。
每条指令都是一个独立操作,字段可以类比成数据库写入操作。核心指令集如下:
createNode:声明一个组件实例,分配唯一id,指定type和初始props。appendChild:把一个已有节点挂到某个父节点的children里。setProps:更新某节点的属性。setData:给某节点填充数据,表格行、图表点位都走这里。removeNode:删除节点。finish:标记整棵UI树生成完毕,前端可以收起“生成中”占位状态。
流式表现大致长这样:
{"op":"createNode","id":"card-1","type":"Card","props":{"title":"本月数据"}} {"op":"createNode","id":"table-1","type":"Table","props":{"columns":["渠道","访问量"]}} {"op":"appendChild","parentId":"card-1","childId":"table-1"} {"op":"setData","id":"table-1","data":[{"channel":"搜索","visits":12000}]} {"op":"finish","id":"root"}注意这里的字段名我在生产环境里会压缩成短键,比如op直接保留,id保留,type缩成t,props缩成p,parentId缩成pi。别小看这点优化,LLM输出是按token计费的,也更难让模型把长字段拼错。压缩后协议体积缩小约三成,模型输出速度明显提升。可读性差一点没关系,协议文档写清楚就行。
这条协议设计里最关键的一点是:每条指令都自包含、可独立应用。前端收到一条就执行一条,不需要等待完整JSON。这样即使输出中途断掉,已经渲染出来的部分依然有效,系统可以随时降级。
3.3 让LLM稳定输出合法指令的约束方法
模型不可能天生知道你的UI协议,必须约束。我们的做法分三层。
第一层是系统提示词示例。在system prompt里给两到三个完整的指令流示例,明确告诉模型“先创建节点,再挂载子节点,最后填充数据”的顺序。实测示例比抽象描述有效得多,模型能模仿样例的结构。
第二层是JSON Schema约束。如果你用的是支持结构化输出的模型服务,可以直接在请求里附加JSON Schema,声明每条指令的字段类型、必填项和枚举值。例如op字段只允许那六个值,type字段只允许我们注册表里存在的组件名。模型在生成时会优先满足Schema约束,出格式问题的概率大幅下降。
第三层是前端兜底容错。不管前面做多好,模型总有抽风的时候。后端要做一层格式校验,发现解析失败就重试一次;如果重试还是失败,就把这部分内容降级为普通文本返回。前端也要做防御性解析,每条指令单独try/catch,单条失败不影响其他指令执行。
约束的目标不是让模型百分百完美输出,而是把错误率压到可接受范围,并且让错误表现为“可降级”而不是“白屏”。这条心得,后面做稳定性的时候还会再展开。
4. 前端渲染实战:从指令流到组件树
4.1 用状态仓库管理UI树
生成式UI的前端核心是一个可动态增删的UI树状态。我们用Zustand做状态管理,结构很直观:nodes是一个以节点id为key的Map,存所有节点实例;rootIds是根节点id列表。apply方法接收一批指令,按顺序执行,返回新的状态。
interface UIState { nodes: Record<string, UINode>; rootIds: string[]; apply: (ops: UIOperation[]) => void; } const useUIStore = create<UIState>((set) => ({ nodes: {}, rootIds: [], apply: (ops) => set((state) => { const nodes = { ...state.nodes }; const rootIds = [...state.rootIds]; for (const op of ops) { if (op.op === 'createNode') { nodes[op.id] = { ...op.node, children: [] }; if (op.node.isRoot) rootIds.push(op.id); } else if (op.op === 'appendChild') { nodes[op.parentId]?.children.push(op.childId); } else if (op.op === 'setProps') { nodes[op.id] = { ...nodes[op.id], props: { ...nodes[op.id].props, ...op.props } }; } else if (op.op === 'setData') { nodes[op.id] = { ...nodes[op.id], data: op.data }; } } return { nodes, rootIds }; }), }));特别注意,apply每次执行的都是纯状态更新,不涉及任何组件实例操作。这意味着整个UI树的一举一动都可以记录成日志,回放重演,调Bug时犹如看录像。课件阶段我觉得这个设计可有可无,上线后全靠这个日志定位了几次诡异问题。
4.2 从UI树到React组件:注册表与递归渲染
状态里存的只是描述,真正渲染成React元素需要两步:注册表映射 + 递归渲染。
注册表维护一个从组件type到React组件的映射。这是白名单机制的关键位置,只允许注册过的组件类型被渲染,其他一律渲染为一个透明Fallback,绝不执行未知代码。
const componentRegistry: Record<string, React.ComponentType<any>> = { Card: CardComponent, Stack: StackComponent, Text: TextComponent, Table: DataTableComponent, Alert: AlertComponent, Button: ActionButtonComponent, };递归渲染器从根节点开始,读取节点的type找到组件,把props透传进去,再递归渲染它的children。
function RenderNode({ id }: { id: string }) { const node = useUIStore((s) => s.nodes[id]); if (!node) return <NodeSkeleton />; const Component = componentRegistry[node.type]; if (!Component) return <UnknownNode node={node} />; const children = (node.children ?? []).map((childId) => ( <RenderNode key={childId} id={childId} /> )); return ( <Component {...node.props} nodeData={node.data}> {children} </Component> ); }渲染器里有两个容易被忽略的细节。
第一,key必须用节点id。因为指令流可能动态调整节点顺序和父子关系,用数组下标做key会让React复用错乱,出现状态残留。第二,子节点还没到达时,父节点已经渲染的情况很常见。上面代码里node.children中某个childId在nodes里不存在时,RenderNode会渲染一个骨架占位。等对应createNode指令到达,骨架自动变成真实组件,用户感知上是无缝的。
4.3 React流式更新的性能优化
流式渲染的性能关键不在React本身,而在控制状态更新的频率和优先级。
React 18的自动批处理能合并同一事件循环里的多次setState,但SSE场景下数据是异步逐条到达的,每个apply都可能触发独立渲染。我们的做法是积攒一批指令再一次性apply,而不是来一条执行一条。接收线程把指令推进一个pending队列,用微任务或定时器定期清空,合并成一次更新。
另一个更关键的优化是startTransition。UI树更新属于低优先级任务,用户点击按钮、输入框打字属于高优先级交互。如果模型正在疯狂输出UI节点,而这期间用户拖动滚动条或者点击了某个Tab,不能让渲染卡住用户操作。用startTransition包裹UI树的setState,React会在合适的时机中断低优先级渲染,优先响应用户事件。
长列表场景还要叠加虚拟滚动。一个表格可能有几百行数据,一个树形组件可能挂几十个子节点。把这些全部真实渲染成DOM,再快的React也扛不住。表格组件内部使用react-window做按需渲染,只有可视区域的行才挂载DOM节点。流式生成期间,表格数据从一行逐渐增至几百行,每次update都由React的diff机制去处理新增行,实测在数据量500行以内没有出现可感知卡顿。
5. 生产环境的稳定性与降级策略
5.1 乱序、超时与中断处理
流式协议进了生产环境,第一个遇到的隐性坑是乱序。理论上一根SSE连接顺序到达,不会乱;但模型服务端如果做了多路并发生成,或者代理层做了缓冲重排,指令就可能乱。比如appendChild先到而createNode还没到,前端执行时找不到父节点或子节点。
我们的对策是做一个挂起队列。所有操作先经过一层校验,如果指令引用的节点id还不存在,就先放进pending列表暂不执行。每成功创建一个节点,就扫描pending列表里有哪些指令可以执行了。这个机制同样能解决“子节点未创建就挂载”的问题,把乱序容错沉到协议执行前。
超时也要分两类处理。首包超时指请求发出去很久第一条指令还没回来,多半是模型服务异常或排队过久,前端要给出“正在排队”的提示而不是一直转圈。完成超时指生成总时长超过阈值,模型可能陷入死循环,后端应该主动切断生成并发送一个finish指令兜底。前端的AbortController不仅能停掉HTTP请求,还能触发清理UI树生成状态的逻辑。
5.2 输入侧安全:白名单与事件隔离
生成式UI本质上是让模型操作你的页面结构,安全边界必须划清楚。我们做了四个层面的隔离,缺一不可。
组件白名单:只有注册表里存在的type才会被渲染,非白名单一律渲染为Fallback组件。这从根上杜绝了“让模型创建任意组件”的越权风险。
属性白名单:组件接收的props逐一走Schema校验。比如某个组件的href字段只允许http和https协议,事件字段只允许注册过的actionName。这里要特别小心,有些组件库会透传onClick、dangerouslySetInnerHTML这类危险属性,必须显式过滤掉。
事件动作白名单:模型只能声明action.name,前端在实际执行时查询一个动作注册表,里面是预先编写好的业务回调。回调之外的任何行为都不会被执行,模型没有“执行任意JavaScript”的能力。
文本转义:所有模型生成的文本内容,在进入组件前统一转义。宁可让特殊字符显示为转义码,也不能让用户输入内容被当成HTML注入。这是最基本的底线。
5.3 降级方案:生成式UI失败时退回Markdown
再完善的约束协议,面对真实世界的模型重复度,也要做好“万一失败了怎么办”的准备。
我们的降级设计是双通道并存。后端在输出生成式UI指令流的同时,把一份等价的Markdown文本也拼出来。正常时候走UI指令流渲染;一旦前端检测到异常——比如指令解析连续报错、Schema校验失败率超过阈值、SSE连接多次中断后重试仍失败——就自动切换到Markdown通道,把早已累积好的Markdown文本用传统方式渲染。
这个设计的巧妙之处在于:降级不是“白屏后重新请求”,而是“已有内容换一种表现方式”。因为文本流始终在累积,用户看到的结果不会断档,只是从精美组件变成了朴素的Markdown排版。
前端实现上,用一个renderMode全局状态控制渲染分支。UI指令流把指令日志传给监控看板,Markdown通道则渲染完整文本。两边的数据都从同一个SSE连接解析出来,落地成本很低。这套降级机制多次在线上模型抽风时救了场,用户几乎感知不到渲染模式切换,只偶发觉得“版面变朴素了”——总比页面白屏好得多。
6. 常见问题排查与避坑记录
6.1 高频问题速查表
把这段实践里碰到的典型问题整理成一张表,方便直接对照排查。
| 问题表现 | 典型原因 | 处理方式 |
|---|---|---|
| 渲染内容一直不出现 | SSE首包超时,模型排队过久 | 前端增加首包超时提示,后端提供排队状态事件 |
| 指令JSON解析频繁报错 | 模型输出非法格式 | 降低temperature,附加JSON Schema,解析失败重试 |
| 某个组件区域空白 | 节点id在nodes里不存在 | 检查appendChild是否早于createNode,启用挂起队列 |
| 表格数据越攒越卡 | 全量行都挂载到DOM | 表格内部接入虚拟滚动 |
| 未闭合代码块反复跳变 | Markdown中间态解析问题 | 使用占位组件延迟渲染,闭合后再完整渲染 |
| 点击按钮无反应 | action.name未注册到动作表 | 前端动作注册表按业务维度登记,缺失时输出告警日志 |
| 模型输出了未知组件type | 新组件未加入白名单 | 注册表补齐组件,Fallback组件给埋点信息 |
| 降级后内容与UI模式不一致 | 双通道文本未同步 | 后端改成同一份输出源分别生成两个格式 |
6.2 几条排查心法
排查这类流式渲染问题,最有效的不是盯页面,而是盯「指令日志+状态快照」。生产环境把每一条收到的指令都记录下来,配合时间戳和状态哈希,出问题时可以精确回放当时的UI树。我在定位一个表格列错乱问题时,就是靠回放指令日志,发现某次setData包含了一个错误的列定义字段,模型把列头放在了数据行里。
另一个容易被坑的点是网络字节流解析。SSE数据在传输层会被拆成任意大小的chunk,绝对不能假定“一次read就是一条完整事件”。必须维护字符串buffer,按空行切分事件,再把剩余半截留在buffer里等下一次数据到达。我见过好几次线上偶发解析报错,最后定位都是半包问题,而不是模型乱输出。
最后一条心得:把模型当“不稳定的上游服务”对待。你做外部API调用时,会设计超时重试、限流熔断、降级方案;对LLM输出UI指令,也应该用同样的工程心态。不要幻想模型每次输出都完美符合Schema,而是要设计一套让“不完美”也能平稳落地的系统。
写在最后的个人体会
这个项目做下来,最深的感受是:Markdown和生成式UI不是二选一的对立关系,它们是同一条演进路径上的不同站点。纯文本问答场景,Markdown流式渲染永远是够用且简洁的选择;但产品一旦往前走,开始要求可点击、可填写、可筛选的界面时,结构化的增量指令流就会成为必经之路。
如果让我重新选择,我会在一开始就把流式管道按“增量指令”来设计,哪怕第一版只渲染Markdown文本,也可以先把指令协议的空壳搭好,后续切换成本会低很多。协议层是一切生成式UI的地基,先想清楚这块,再谈组件层和效果层。先做降级,再追求花活,这个顺序不要颠倒。
最后再分享一个小技巧:把UI指令流的日志输出到控制台,用颜色区分不同操作类型,开发调试时会非常愉悦。我在调试一个复杂卡片嵌套渲染时,靠着彩色指令日志一眼看出了appendChild挂错父节点的问题,省下至少半天排查时间。这个工具已经在团队里传开了。