Agent流式UI渲染链路:AGUI与DSP协议设计实践 1. 为什么 Agent 的输出一旦“流”起来UI 就不能只是往外吐字第一次接触 AGUI 与 DSP 这两个词的人十有八九会以为这又是一套“给聊天框加个打字机效果”的方案。我最初也是这么想的直到把一条真实的 Agent 链路拆开看才发现问题根本不在“字怎么一个个冒出来”而在于模型那端吐出的是 token界面这端要的是组件、是状态、是可交互的东西中间隔着一道很宽的断层。这篇内容想聊的就是这道断层怎么填。AGUI 可以理解成一套“Agent 与界面的契约”它规定了一次 Agent 执行过程中前端应该收到哪些语义事件、该把它们映射到哪些组件上DSP 则是这条链路上的载荷协议负责把这些事件打成一个个可以增量传输的信封。两者配合起来才构成了我们说的Agent 流式 UI 渲染链路。适合谁看如果你正在给自家产品接 Agent 能力或者已经接了但发现“输出一长界面就卡”“组件出来的顺序乱七八糟”“断网重连之后界面直接花掉”这篇基本能对上你的症状。需要先说明一点行业里 AGUI 和 DSP 的具体实现各家用得不太一样下面涉及的字段命名、事件分类、握手流程是我基于主流流式传输实践做的合理归纳不指向任何特定实现。你在落地时照着思路改字段名即可协议的骨架是通用的。1.1 token 流与界面语义之间的那道断层先看最朴素的场景。用户问“帮我查一下这三台服务器的负载顺便画个趋势图”模型开始输出。如果前端只是把文本往一个div里追加用户看到的是这样一串东西好的、我先查一下、服务器A的负载是、43%、服务器B……然后突然蹦出一大段 JSON再然后是一堆看不懂的坐标数字。这就是断层的第一层语义不可见。文本流里其实包含了好几种不同性质的内容——自然语言叙述、结构化查询结果、要渲染成图表的数值序列——但在传输层它们被拉平成同一个字符串流前端无法区分。你当然可以在前端做正则匹配把看起来像 JSON 的片段抠出来解析但这条路走三步就会撞墙JSON 被切成了两半怎么办、JSON 里的字符串恰好长得像 JSON 怎么办、模型输出格式飘了怎么办。第二层断层是时序与依赖。一个 Agent 的执行通常是串行有依赖的先调工具拿到数据再基于数据生成图表最后写一段总结。这三步在文本流里是顺序出现的但界面上它们可能是三个独立区域甚至顺序需要重排——总结区在上面图表在下面。纯文本流没法表达“我刚才那段是给图表用的别显示在对话框里”这种意图。第三层是不确定性。Agent 不是每次都会走同样的路径。这次它调了两个工具下次它直接回答了这次它生成了一张表下次它说“查不到”。前端如果硬编码“第一次输出一定是文本、第二次一定是表格”遇到路径变化就会整段崩掉。所以链路上必须有一个机制让后端能够在运行时告诉前端“接下来这一块是什么类型、由哪个组件承接”。把这三层摊开看结论就很清楚了流式 UI 的核心不是流是语义事件的解耦。传输层只管把事件按顺序送到渲染层只管按事件类型找组件两层之间用一份稳定的协议隔开。AGUI 管的是后者DSP 管的是前者。1.2 AGUI 想解决的三件事我把 AGUI 的职责收敛成三句话这三句话也基本决定了你后续所有设计的取舍。第一件定义“界面上有哪些位置可以放东西”。一个 Agent 界面通常不是只有一个消息气泡它可能有正文叙述区、有工具调用卡片、有数据表格区、有图表区、有推荐追问区。这些区域在 AGUI 里应该被抽象成具名的“槽位”或者“组件类型”而不是散落在各处的 if-else。这一步做扎实了后面加新组件只是往注册表里加一条不用动渲染主流程。第二件定义“事件如何驱动这些位置”。这里的关键词是增量。一个表格不该等所有行都到齐才渲染它应该先出现表头然后一行一行往里填一段长文本不该等整段生成完才显示它应该边生成边追加。AGUI 的事件模型必须能表达“开始”“追加”“结束”这三种状态缺一不可。只有“设置”没有“追加”就只能整块替换流式的意义就没了。第三件定义“异常时怎么收场”。这条最容易被忽略但在实际项目里最容易炸。Agent 跑到一半工具超时了事件流断了模型输出了半个组件的参数就停了用户中途点了取消。这些情况下界面上那个已经渲染了一半的表格该怎么办是留个“加载失败”的空壳还是回滚到上一次完整状态AGUI 必须给出明确约定否则前端只能各自发挥最终就是一堆不一致的体验。我见过不少项目前两件事做得挺漂亮第三件事完全没做上线之后用户反馈最多的问题就是“有时候界面会卡在一个奇怪的半成品状态”。这不是渲染性能问题是协议缺了一块。1.3 DSP 在这条链路里到底负责哪一段如果 AGUI 是“说什么”DSP 就是“怎么说过去”。它要处理的问题非常具体一条事件怎么切分、怎么标记边界。文本流里没有天然的边界你必须靠分隔符或者长度前缀来定界。多条事件怎么保序。网络层不保证顺序尤其在多路复用或者重连场景下你必须在载荷里带序号。半条事件怎么处理。一个 JSON 事件被 TCP 分片切成两半是常态缓冲区的拼接逻辑必须稳。迟到的事件怎么办。用户已经切到下一个话题了上一个话题的图表事件才姗姗来迟这时候要能识别出来并丢弃。我这里把 DSP 理解成一种声明式的流式载荷协议——它不承载业务语义只承载“这是一个什么类型的事件、属于哪次会话、序号是多少、负载是什么”。这个定位很重要因为一旦你把业务逻辑塞进传输层后面想换传输方式比如从长连接换成别的方案就得重写一遍。提示判断你的分层是否健康的土办法——把传输层整体换掉如果业务渲染代码一行都不用改说明分层是对的如果要改说明业务语义漏到传输层去了。2. 拆开协议信封DSP 载荷里该放什么、不该放什么这一节聊具体的载荷设计是整个链路里最值得花时间的地方。我踩过的坑里至少一半能追溯到“当初信封设计得太随意”。2.1 事件化是唯一能扛住增量的写法先对比两种思路。第一种叫状态快照式每次后端有变化就把当前完整状态序列化一份推给前端前端整块替换。这种写法实现最简单demo 跑起来也最漂亮但它有三个硬伤——带宽随状态线性增长一个长表格到第五十行的时候每次推送都要带上前面四十九行渲染开销大整块替换意味着组件全量重渲染输入框里的光标、滚动位置、展开的折叠面板状态全部丢失以及无法表达“删除”,因为快照里没出现的东西你分不清是“没变化”还是“被删掉了”。第二种叫事件式后端只推“发生了什么变化”前端在本地维护状态并根据事件做增量更新。这就是 DSP 应该走的路。一个表格的新增一行推的是{type: table.row.append, payload: {...}}前端往数组里 push 一条只重渲染新增的那一行。带宽恒定渲染增量状态可推导。事件式的代价是复杂度转移到了前端你得维护一份本地状态树得处理事件乱序、重复、丢失。但这个复杂度是一次性的写好了之后所有组件都受益。而快照式的复杂度是随组件数量线性增长的做到第五个组件你就会想重写。一个经验判断如果你的 Agent 界面里出现了“超过 20 条数据的列表”“会持续 10 秒以上的生成过程”“用户可以中途交互的区域”中的任意一个就必须走事件式。三者都没有的场景快照式凑合能用。2.2 一份能直接抄的事件类型表下面这张表是我实际项目里收敛出来的事件分类按“生命周期阶段”划分你可以直接拿去改字段名。核心原则是每个组件类型都要有 start / delta / end 三件套加上一个 error。事件类型触发时机关键字段前端行为run.start一次 Agent 执行开始runId,sessionId,ts建立本次执行的状态容器message.start文本块开始blockId,role创建空的文本节点message.delta文本增量blockId,text追加文本message.end文本块结束blockId标记完成解除加载态tool.start工具调用开始toolId,name,args渲染工具卡片骨架tool.delta工具中间输出toolId,chunk追加日志行tool.end工具调用结束toolId,result,status渲染结果区或错误态ui.mount挂载一个结构化组件componentId,componentType,props按注册表找组件并挂载ui.patch更新组件属性componentId,path,value按路径局部更新 propsui.unmount卸载组件componentId清理并移除run.end执行结束runId,finishReason收尾允许新输入run.error执行异常runId,code,message全局错误提示 局部降级几个设计细节值得展开说。ui.patch用路径而不是整块 props。很多人图省事更新组件就整块推props。问题是图表组件的数据序列可能有几百上千个点整块推一次就是几十 KB。用path比如data.series[0].points加局部值能精确命中变更点。当然代价是前端要有路径写入的能力用不可变更新的话要自己实现一个setIn。tool.end里带status而不是靠有没有result来推断。工具调用失败是很常见的如果约定“有 result 就成功、没 result 就失败”那遇到“成功但没返回值”的工具就会误判。显式带状态码最稳。run.error和组件级 error 要分开。前者是整条链路挂了后者是某个组件的数据拿不到。混在一起的话一个图表数据超时就会让整个界面进入错误页体验极差。2.3 序号、会话 ID 与幂等键的约定这三样东西不带线上一定会出问题。序号seq每个事件带一个单调递增的整数前端维护一个lastAppliedSeq。收到序号小于等于它的直接丢弃。这一条规则能同时解决乱序和重复两个问题实现成本极低性价比极高。要注意序号的作用域——是全局的、按 run 的、还是按 block 的我建议按 run 递增粒度过细会导致比较逻辑复杂粒度过粗全局会在多会话并发时出问题。会话 IDsessionId与执行 IDrunId用户在一个会话里可能连续发好几轮而上一轮的执行可能还没完全结束。前端所有状态都必须挂在runId下收到不属于当前runId的事件直接丢弃。我曾经在一个项目里省了runId只靠sessionId区分结果用户快速连发两条消息时前一条的图表会莫名其妙跑到后一条的回复下面去排查了两个下午。幂等键eventId重连场景下服务端可能会重放一部分事件。如果纯靠seq去重重放窗口和你的去重窗口不重叠就会漏。加一个全局唯一的eventId前端维护一个最近 N 条的 ID 集合重放的事件就能稳稳挡住。N 取多少我一般用 512能覆盖绝大多数重连窗口内存占用也可以忽略。{ v: 1, eventId: evt_7f3a91, sessionId: sess_2c8b, runId: run_4410, seq: 128, type: message.delta, ts: 1730000000123, payload: { blockId: blk_1, text: 服务器的负载 } }上面这个信封结构没什么花哨的但每一条都有用v用于协议版本协商eventId用于幂等sessionId/runId用于归属判定seq用于排序去重ts用于延迟观测type用于分派。多带这几个字段的成本几乎为零省下来的排查时间是以天计的。3. 组件注册表AGUI 如何把事件名翻译成屏幕上的一块区域事件到了前端接下来要回答“这个componentType: bar-chart到底是什么”。这一步如果做成一堆switch-case项目稍微长一点就会变成地狱。3.1 注册表的结构与降级策略注册表本质上是一张Mapstring, ComponentDef每一项包含组件本体、参数校验器、默认 props、错误边界、以及一个可选的“骨架形态”。我习惯在应用启动时集中注册配合懒加载type ComponentDef { loader: () Promise{ default: React.ComponentTypeany }; schema: ZodSchema; defaultProps?: Recordstring, unknown; skeleton?: React.ComponentTypeany; fallback?: React.ComponentType{ reason: string }; }; const registry new Mapstring, ComponentDef(); registry.set(bar-chart, { loader: () import(./components/BarChart), schema: barChartSchema, skeleton: ChartSkeleton, fallback: ChartUnavailable, });关键在fallback。前端和后端是两个发版节奏后端某天上线了一个新组件类型老版本前端不认识。这时候如果直接报错或者渲染空白用户看到的就是界面缺一块。正确做法是渲染一个“该模块暂不可用”的占位同时把componentType上报到埋点里你就能知道有多少人在用旧版本。loader用动态导入也很重要。一个 Agent 界面可能会挂十几种组件类型图表、地图、代码编辑器、表单、文件预览全量打包进去首屏体积会非常难看。按需加载、加载期间显示skeleton用户感知上反而更流畅。3.2 参数校验与“半成品”组件后端推过来的 props 不一定可信。可能是模型生成的、可能是工具返回的、可能是版本不一致导致的字段缺失。所以每次挂载或 patch 之前都过一遍 schema 校验。这里有个权衡要注意校验失败不该直接不渲染。流式场景下ui.mount到达时 props 可能本来就是不完整的比如图表的数据字段要等后续ui.patch才填这时候严校验会误杀。我的做法是分两级ui.mount时只做“结构校验”——检查必填的标识字段在不在其余字段能缺就缺组件内部自己处理空数据ui.patch之后再做一次“完整校验”这时候如果还缺关键字段才降级到 fallback。function handleMount(evt: UiMountEvent) { const def registry.get(evt.payload.componentType); if (!def) return renderFallback(evt, unknown-component); const loose def.schema.partial().safeParse(evt.payload.props); if (!loose.success) return renderFallback(evt, bad-structure); mountComponent(evt.payload.componentId, def, loose.data); }组件内部处理空数据的写法也得提前定好。比如图表组件收到data: []应该显示一个带坐标轴的空图而不是抛异常表格收到零行显示表头和“暂无数据”而不是整块空白。这些细节攒起来就是“看起来稳”和“看起来脆”的区别。3.3 版本协商与前后端契约漂移最麻烦的不是新组件是老组件改字段。原来props.title改成props.header.text后端一上线前端所有用到老字段的图表全部标题空白。这类问题在流式场景下更难发现因为不会报错只是静静地少显示一块。两个措施。第一信封里带v协议版本服务端在连接建立时的握手里返回它支持的版本区间前端选一个双方都支持的。版本跨度大时服务端做字段兼容转换前端不感知。第二组件 schema 做向后兼容的宽松解析新字段缺失时回落读老字段两者都缺才用默认值。const title props.header?.text ?? props.title ?? 未命名图表;这行代码看着丑但它救过一次线上事故。等所有客户端都升级完再删掉兼容分支通常要等两三个版本周期。一个血泪教训不要在协议第一版就把字段设计得太“干净”。留一点冗余字段、留一点宽松解析比事后加兼容层便宜得多。我见过一个项目第一版协议极其优雅字段命名整齐划一结果第二个月业务要加“分步展示”发现事件模型里根本没有中间态只能整个协议推倒重来。4. 端到端跑通一条最小链路理论说完了落到能跑起来的代码。这一节给一条最小可用的链路从服务端产生事件到前端渲染出东西大概两三百行能搞定。4.1 服务端从模型输出到事件流的转换服务端的核心工作是把“模型的原始输出”翻译成 DSP 事件。这里有个关键决策在哪里做翻译。有两种位置一是在模型输出的解析层做边解析边发事件二是在业务编排层做等一个完整步骤结束再发事件。前者更实时后者更简单。我倾向选前者但加一层缓冲。原因是模型输出的结构化内容比如 JSON 片段在生成过程中是不完整的直接解析会失败必须靠增量 JSON 解析器边吃边吐。这类解析器能处理“对象还没闭合”的中间状态吐出已确认的键值对。async def stream_run(prompt: str, run_id: str): seq 0 def emit(event_type: str, payload: dict): nonlocal seq seq 1 return { v: 1, eventId: fevt_{run_id}_{seq}, runId: run_id, seq: seq, type: event_type, ts: int(time.time() * 1000), payload: payload, } yield emit(run.start, {input: prompt}) block_id fblk_{run_id}_1 yield emit(message.start, {blockId: block_id, role: assistant}) parser IncrementalJsonParser() async for chunk in model.stream(prompt): if chunk.kind text: yield emit(message.delta, {blockId: block_id, text: chunk.text}) elif chunk.kind structured: for node in parser.feed(chunk.raw): if node.op mount: yield emit(ui.mount, { componentId: node.id, componentType: node.type, props: node.props, }) elif node.op patch: yield emit(ui.patch, { componentId: node.id, path: node.path, value: node.value, }) yield emit(message.end, {blockId: block_id}) yield emit(run.end, {finishReason: stop})这段代码里最值得抄的是emit函数——把信封构造收敛到一个地方将来加字段、改字段名、加签名校验都只动这里。我见过太多项目每个yield手写一遍字典最后字段名拼错了两三个线上排查了好久。4.2 传输层SSE 与 WebSocket 的取舍标准选哪个先看你的需求落在哪一档。维度SSE服务端推送WebSocket双向方向单向服务端到客户端双向协议基于 HTTP代理友好需要协议升级部分中间层需额外配置断线重连浏览器内置可配Last-Event-ID需自行实现重连与状态恢复消息格式纯文本需自行定界帧边界天然存在适用场景纯渲染流、无中途交互用户可打断、可追加指令大部分 Agent 渲染场景其实是纯下行的——用户发一句然后看着 Agent 一步步执行。这种场景 SSE 是更省心的选择尤其Last-Event-ID这个机制天然适配我们前面设计的seq重连时浏览器自动带上最后收到的事件 ID服务端从那个位置续传即可。但如果你的产品支持“用户在 Agent 执行中途插话”“中途取消某个子任务”“拖拽调整参数后重新生成”那就必须上 WebSocket。判断标准很直接用户是否需要在流式过程中向服务端发消息。需要就是双向不需要就别为了“看起来高级”上 WebSocket重连逻辑够你写两天。有个折中方案值得提一下主链路走 SSE用户的中途干预走独立的 HTTP 请求。这样既保住了 SSE 的重连便利又满足了交互需求。缺点是两条通道之间要做状态同步服务端得知道“用户在第 30 个事件之后取消了任务”然后在下一条 SSE 事件里体现出来。做得好体验很顺做不好会出现“用户点了取消但界面还在继续输出”的尴尬。4.3 前端解析器、缓冲区与渲染队列前端这一段要做三件事拼接、分派、调度。拼接指的是处理 TCP 分片。SSE 的格式是data: xxx\n\n一个完整的 JSON 事件可能被切成两次到达你必须在缓冲区里攒到\n\n才切。写成状态机最稳class EventBuffer { private buf ; push(chunk: string): string[] { this.buf chunk; const out: string[] []; let idx: number; while ((idx this.buf.indexOf(\n\n)) ! -1) { const raw this.buf.slice(0, idx); this.buf this.buf.slice(idx 2); const line raw.split(\n).find((l) l.startsWith(data:)); if (line) out.push(line.slice(5).trim()); } return out; } }分派就是查表 去重 顺序校验按前面说的规则走一遍通过了才进入下一步。调度是最容易被忽略的部分。事件到达频率可能很高——模型每秒吐几十个 token意味着每秒几十个message.delta。如果每个 delta 都触发一次 React 重渲染页面会明显卡顿。解决办法是攒批用一个缓冲区收事件requestAnimationFrame的节奏统一 flush 一次一帧之内不管来多少 delta 都只渲染一次。let pending: Event[] []; let raf 0; function enqueue(evt: Event) { pending.push(evt); if (!raf) { raf requestAnimationFrame(() { raf 0; const batch pending; pending []; applyBatch(batch); }); } }applyBatch里把所有 delta 按blockId归并每个块只做一次字符串拼接texts[blockId] chunk然后统一更新状态。这一个改动通常能把每秒几十次的渲染压到每秒十几帧的稳定水平肉眼观感从“一顿一顿”变成“顺滑”。4.4 联调阶段最容易卡住的三处实际接的时候90% 的时间耗在这三个地方。第一处跨域和代理缓冲。不少反向代理默认会对响应做缓冲导致服务端明明在实时推前端要等到连接结束才一次性收到。表现为“看起来完全不流”。排查方法很简单在服务端每次推送后立刻打一条日志前端用浏览器的网络面板看响应是不是分块到达。如果服务端日志实时刷、前端网络面板一片空白直到最后基本就是缓冲问题。对应的头需要按你所用的中间层去配Content-Type: text/event-stream加上禁用缓冲的相关设置。第二处事件顺序在开发环境正常、生产乱序。开发环境单实例直连生产环境多实例加负载均衡会话可能在不同实例间漂移。如果你的 Agent 执行状态存在实例内存里换实例就丢了。要么做会话粘滞要么把状态外置到共享存储用runId做键。我建议后者虽然多写几十行但省心。第三处编码问题。中文文本在网络传输时如果分片恰好切在多字节字符中间直接按字符串处理会出现乱码。正确做法是在字节层做缓冲和切分用TextDecoder的流式模式解码const decoder new TextDecoder(utf-8); const text decoder.decode(chunkBytes, { stream: true });{ stream: true }这个参数是关键它会让解码器保留尾部不完整的字节等下一片到了再拼。不加的话偶发的乱码能让你怀疑人生——因为它不是必现的取决于分片位置。5. 增量渲染的稳定性乱序、断流与回滚链路跑通之后真正决定用户体验的是异常路径。正常路径大家都能做对异常路径才见功底。5.1 乱序与重复事件的收敛前面提了seq去重这里补充两个更细的场景。跨 block 的乱序。假设有两个文本块并行生成多 Agent 协同时很常见它们的seq是全局递增的但事件可能在网络层被重排。如果你的状态树是“按 block 分组”的其实不用担心单个 block 内部乱序因为同一个 block 的事件天然连序。真正要防的是ui.mount和ui.patch的乱序——patch 先到对应的组件还没挂载这时候 patch 应该被暂存等 mount 到了再一起应用。const pendingPatch new Mapstring, PatchEvent[](); function applyPatch(evt: PatchEvent) { const target state.components.get(evt.payload.componentId); if (!target) { const list pendingPatch.get(evt.payload.componentId) ?? []; list.push(evt); pendingPatch.set(evt.payload.componentId, list); return; } setIn(target.props, evt.payload.path, evt.payload.value); }这个暂存机制要考虑过期。如果 mount 一直不来后端异常暂存的 patch 会一直堆着。加个上限比如每个组件最多缓存 20 条超了就丢弃并打日志。重复事件的识别成本。别指望后端保证不重复网络层重传、消息队列至少一次投递、断线重放任何一个环节都可能产生重复。eventId去重是必须的同时最好给事件加个短时效的签名或者时间戳超过一定时间比如 5 分钟的重复直接丢弃防止旧事件在新会话里诈尸。5.2 断流重连时的状态对账重连之后最怕的是“状态对不上”。前端以为自己在第 128 个事件服务端以为前端在第 100 个中间 28 个事件是补还是不补SSE 的Last-Event-ID机制能解决大部分问题——服务端拿这个 ID 查自己的事件日志从下一条开始补发。但这要求服务端保留一份可回放的事件日志至少在内存里保留最近 N 条。N 的大小取决于你的最大断开时长乘上事件频率保守点按 5 分钟、每秒 50 条算一千多条内存占用可以接受。如果断的时间太长日志已经滚掉了怎么办这时候补发是不可能的只能做全量对账前端发一个“我当前的 runId 和最后应用的状态摘要”给服务端服务端对比自己的最终状态如果已经结束就直接返回结果快照如果还在进行中就返回一个精简的中间快照前端整体替换。这条路会让界面闪一下但比卡死强。async function reconcile(runId: string, lastSeq: number) { const res await fetch(/api/runs/${runId}/reconcile, { method: POST, body: JSON.stringify({ lastSeq }), }); const { mode, snapshot, events } await res.json(); if (mode replay) { events.forEach(enqueue); } else if (mode snapshot) { replaceState(snapshot); } else { markAsStale(); } }markAsStale这个分支不能省。有些情况下服务端已经清理了这次执行的上下文既补不了事件也给不出快照。这时候与其让用户干等不如明确告诉他“这次回复已中断”并保留已渲染的部分内容供他参考。5.3 回滚允许 UI “退回去”的设计流式渲染里“回滚”这个词听起来别扭毕竟已经显示出来的东西为什么要撤掉但实际场景不少模型先输出了“这台服务器没问题”然后工具返回超时模型改口说“查询失败”。前半句如果不撤掉用户看到的就是自相矛盾的内容。处理方式有两种。第一种是软回滚不删内容而是给这块内容打上“已被后续修正”的标记视觉上淡化鼠标悬停能看到修正说明。这适合叙述类文本保留了推理过程的痕迹。第二种是硬回滚直接移除组件或清空内容适合结构化数据——比如一个图表先挂载了错误的字段后来发现数据源选错了那必须整个换掉兼容不了。协议层面要支持硬回滚需要一条明确的指令。我在事件表里有ui.unmount但它是“正常卸载”语义上不够。更清晰的做法是加一个rollback事件明确指向某个componentId或者某个seq之前的范围前端按范围清理{ type: rollback, payload: { scope: component, targetId: cmp_chart_1, reason: data-source-changed } }reason字段一定要带。它有三个用处前端做精细化处理不同原因走不同降级策略、埋点分析哪类回滚最常见、排查问题线上出问题时能看到具体原因而不是一堆神秘消失的组件。实操心得回滚一定要在前端留“痕迹”。完全不解释地让内容消失用户会怀疑是不是出了 bug。加一行细小的灰字说明“该结果已被更新”体验会好很多也顺带解决了用户困惑。6. 性能账本渲染节流、分片与可观测性最后一节聊性能和观测。这部分不解决“能不能用”但决定“用起来爽不爽”“出了问题能不能查”。6.1 高频事件的合并与帧对齐前面提了 rAF 攒批这里补充几个更细的优化点。文本追加的最后一种优化方式为字符串拼接。不要每次 delta 都生成一个新字符串数组元素。用string[]存片段、渲染时join()比每次prev chunk快得多尤其在文本达到几千字之后差距会非常明显。合并同类 patch。一个图表组件可能在短时间内收到几十条ui.patch改的都是同一个序列的不同点。在一帧内把它们合并成一次不可变更新能省掉大量中间对象的创建。合并策略是按path的前缀分组同组内做深合并。分派阶段就做过滤。那些已经被判定为“不可见”的组件比如用户滚动出去了、或者所在标签页没激活可以延后处理它们的事件只维护状态不触发渲染。等重新可见时一次性按最新状态重渲染。这一招在长对话、多组件并存的场景里效果显著。优化手段解决的瓶颈预期收益rAF 攒批高频小事件触发多次渲染渲染次数降 60%~90%字符串片段数组长文本拼接的 O(n²) 拷贝万级字符时差异明显patch 合并同一组件的密集属性更新对象分配减少一半以上不可见组件延后大量隐藏区域仍在重渲染首屏和滚动流畅度提升6.2 大组件的分片与骨架占位有些组件天生就大——一张地图、一个代码编辑器、一个视频预览。这类组件挂载时如果阻塞主线程整个流式输出会一起卡住。处理思路是先把位置占住再异步填内容。ui.mount到达时立刻渲染一个尺寸固定的骨架块同时开始动态导入组件代码、准备数据。等两边都就绪再替换。这个过程中流式文本还能继续输出用户感知上是“内容边出边加载”。尺寸固定这一点很关键。如果骨架高度和最终组件高度不一致替换时会发生布局跳动用户正在读的文本突然被顶走体验很差。给每个组件类型约定一个默认尺寸在注册表里配置骨架按这个尺寸渲染基本都是准的。registry.set(code-editor, { loader: () import(./components/CodeEditor), schema: codeSchema, defaultProps: { height: 320, language: text }, skeleton: () div style{{ height: 320, borderRadius: 8 }} classNamesk /, });对于数据量大的组件比如几百个点的折线图还要考虑数据分片。第一条ui.mount只带前 50 个点让图表先渲染出形状后续用ui.patch追加剩余点。用户看到的是“图表逐渐长出来”而不是“白屏三秒然后突然出现”。这一点在弱网环境下效果尤其明显。6.3 埋点该记哪些字段流式链路的可观测性比普通接口更难——一次请求持续几十秒中间有几十上百个事件出了问题光看一条日志根本定位不到。所以埋点要从协议层就设计好。必须记的几类首字节时间从发起请求到第一个事件到达反映服务端启动延迟、首屏时间第一个有实际内容的组件挂载反映用户感知、事件间隔分布相邻事件的间隔出现长间隔说明中间卡了、批处理耗时一帧内 applyBatch 的耗时反映前端渲染压力、降级次数fallback 触发次数反映协议版本和兼容性问题、回滚次数按 reason 分组反映 Agent 行为稳定性。const metrics { runId, ttfb: firstEventTs - requestTs, ttfc: firstContentTs - requestTs, eventCount: 0, maxGap: 0, maxBatchMs: 0, fallbackCount: 0, rollbackCount: 0, };这几个数攒够一周你就能画出很清晰的分布图。我上次就是靠maxGap的 P99 指标发现某个工具调用在特定输入下会阻塞十几秒补了个中间态心跳事件之后P99 从 14 秒降到 2 秒出头。没有这个指标用户的反馈只会是“有时候特别慢”根本无从下手。最后一个容易被忽略的埋点协议版本分布。统计每个客户端用的v值能让你清楚知道旧版本还要兼容多久。当某个旧版本占比降到 0.1% 以下就可以删兼容代码了。这个数据比拍脑袋决定“再等三个月”靠谱得多。写了这么多回到最开始那句话流式 UI 的难点从来不是“怎么把字一个个吐出来”而是语义能不能在传输过程中不丢失。AGUI 负责把语义定义清楚DSP 负责把语义完整送到前端负责把语义还原成界面。三层各自守住边界剩下的都是工程细节。我自己在这个链路上来回改过三四版最大的体会是协议阶段多花一天想清楚字段后面能省掉两周的排查。特别是seq、runId、eventId这三个字段一开始觉得啰嗦出过一次线上问题之后就再也不敢省了。