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 | 按路径局部更新 props |
ui.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 递增,粒度过细会导致比较逻辑复杂,粒度过粗(全局)会在多会话并发时出问题。
会话 ID(sessionId)与执行 ID(runId):用户在一个会话里可能连续发好几轮,而上一轮的执行可能还没完全结束。前端所有状态都必须挂在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 注册表的结构与降级策略
注册表本质上是一张Map<string, ComponentDef>,每一项包含组件本体、参数校验器、默认 props、错误边界、以及一个可选的“骨架形态”。我习惯在应用启动时集中注册,配合懒加载:
type ComponentDef = { loader: () => Promise<{ default: React.ComponentType<any> }>; schema: ZodSchema; defaultProps?: Record<string, unknown>; skeleton?: React.ComponentType<any>; fallback?: React.ComponentType<{ reason: string }>; }; const registry = new Map<string, 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": f"evt_{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 = f"blk_{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 Map<string, 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 }} className="sk" />, });对于数据量大的组件(比如几百个点的折线图),还要考虑数据分片。第一条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这三个字段,一开始觉得啰嗦,出过一次线上问题之后就再也不敢省了。