☰
AGUI与DSP:Agent动态界面的流式增量渲染实践
2026/10/28 5:01:13 网站建设 项目流程

某天下午排查一个线上问题,用户反馈很直接:Agent 的文字都吐出三段了,界面还是一片空白,然后“啪”一下整块内容全蹦出来,滚动位置被打断,输入框里刚打的字也被清空。这不是某个组件的 bug,而是整条渲染链路的设计问题——后端按 token 流式产出,前端却还在等一个完整的页面描述。我后来把这类“由 Agent 动态决定界面长什么样、并且必须边生成边显示”的界面体系统称为AGUI(Agent-driven GUI,智能体驱动的图形界面),而把承载它增量描述的那套流式协议内部叫作DSP(Declarative Streaming Protocol,声明式流式协议,纯粹是我们团队自己的命名习惯,不是行业标准)。这篇内容讲的就是:怎么把 Agent 的输出切成一帧一帧可累积的界面描述,前端又怎么把这些碎片安全、稳定、无闪烁地拼成真实界面。适合正在做对话式应用、智能助手、动态表单、低代码生成界面的开发者参考,前端和后端都涉及,不需要你事先懂流式编程,但需要你对组件化渲染有基本概念。

1. 先把问题拆开:Agent 界面为什么不能沿用传统渲染链路

1.1 传统请求-响应渲染在 Agent 场景里的三个硬伤

常规页面渲染的逻辑是:前端发请求,后端算完,返回一个完整的 JSON,前端拿去做一次 diff,渲染完成。这个模型的前提是“结果是一个有限、已知、可一次描述清楚的实体”。Agent 场景恰好把这个前提打破了。

第一个硬伤是时间维度上的不可知。模型生成一个 30 行的表格,可能要 8 秒;生成一个两句话的回答,只要 1 秒。你不知道这次会产出多大的界面,也无法预估总时长。如果坚持等完整结果,用户面对的就是一个长达数秒的空白——而人对超过 1 秒的无反馈等待非常敏感,会本能地怀疑“是不是卡了”。

第二个硬伤是结构上的不确定。同一个提问,模型今天给你返回一个列表,明天可能返回一段带小标题的分块内容。如果后端必须先拼出一个完整的、符合固定 schema 的页面树再下发,那这个“拼”的过程本身就是一次额外的等待,而且一旦模型中途输出格式崩了,整棵树都作废。

第三个硬伤是体积。我做过一次统计,一个中等复杂度的 Agent 结果页,完整描述树大概在 10~14KB(未压缩),其中真正变化的部分在生成过程中可能只占 20%。每次都全量下发,移动端弱网下的表现会非常难看。

所以真正的诉求不是“把 JSON 发快一点”,而是把“界面描述”本身当成一条可以边生成边消费的数据流来处理。这就是 DSP 协议要解决的事,也是整个 AGUI 链路的地基。

1.2 AGUI 到底解决的是“谁来拼界面”的问题

很多人第一次听到 AGUI,会理解成“给 Agent 配一个聊天窗口”。这是把它想小了。聊天窗口是固定界面,消息气泡的位置、头像、时间戳全是写死的,模型只负责填文字。AGUI 的核心区别在于:界面的结构由模型在运行时决定,而不是由前端开发者预先写死。

举个具体例子。同样是“帮我看看这个月的支出”,固定界面只能把答案塞进一个 Markdown 气泡里;AGUI 则可以由模型决定:这里应该出一个环形图节点、下面挂一个按类目展开的列表节点、再下面放三个快捷追问按钮节点。模型输出的不是文字,而是“我要一个图表组件,数据是这些”这样的意图描述。

这个转变带来的直接后果是:前端不能再假设“我知道页面上有哪些元素”。它必须变成一个通用解释器——拿到描述,查表,找到对应的真实组件,挂载,填数据。这跟前端框架的虚拟 DOM 渲染思路是一致的,区别只在于虚拟 DOM 的描述是本地代码生成的、可信的、同步的;而这里的描述来自远程模型,是不可信、异步、可能残缺的。

所以 AGUI 的工程重点从来不是“怎么把模型接进前端”,而是三件事:描述怎么表达(协议设计)、描述怎么安全落地(白名单与校验)、落地过程怎么不抖(渲染调度)。这三件事分别对应后面三章,缺一个都会在上线后以各种奇怪的形式爆出来。

1.3 DSP 协议要承担的四件事

我把 DSP 定位成“渲染指令的传输层”,它必须同时兜住四件事,少一件链路就断。

第一,增量性。帧与帧之间是叠加关系,不是替换关系。第 5 帧到达时,前端应该知道“这次只是给 id 为card-2的节点补了一个标题”,而不是“重新渲染整个页面”。这决定了帧里必须带寻址信息。

第二,顺序性。流式传输在网络层不保证顺序(尤其是多路复用或重连场景),必须有一个单调递增的序号,前端据此做排序和丢弃。序号还得能支持“续传”——连接断了以后,从断点继续,而不是从头再来。

第三,可校验性。每一帧到达前端后,在真正触碰 DOM 之前,必须过一遍结构校验和属性白名单。模型幻觉出一个不存在的组件名、一个带javascript:的链接、一个长度两万字的标题,都不能让它们进到渲染层。

第四,可终止性。流必须有明确的成功结束信号和失败信号。我见过不少实现只靠“连接关闭”来判断结束,结果代理层超时关闭连接、模型侧报错关闭连接、正常完成关闭连接,前端根本分不清,只能一律当成功处理,于是半截界面被当成完整结果展示出去。

提示:这四条不是设计出来的“最佳实践”,是被线上事故一条条逼出来的。先想清楚这四件事,再动手写代码,能省掉后面大量返工。

2. DSP 协议的帧设计:把一次渲染拆成可累积的片段

2.1 帧类型收敛到六种就够用

协议设计最容易犯的错是“一开始就想设计得很完备”,定义十几种帧类型,结果实现到一半发现没人用,维护成本却上来了。我现在的做法是:先按“前端拿到这一帧要做什么动作”来分类,而不是按“业务语义”来分类。按动作分,六种就够。

帧类型作用前端动作是否必需
hello会话建立,下发协议版本、能力位、组件白名单初始化解释器是
node新增一个节点(含组件名、属性、父子关系)创建占位并挂载是
patch修改已有节点的某个属性或子节点集合局部更新是
text文本增量追加(专用于流式文字)追加文本节点内容是
done正常结束,附带统计信息收尾、解除 loading是
error异常结束,附错误码与可读信息展示降级界面是

hello和error这两类经常被省掉,我建议别省。hello里下发组件白名单,是后面所有安全校验的依据——前端只认白名单里的组件名,白名单外的直接丢弃并计数上报。这个设计的好处是:白名单可以随版本灰度下发,不用发版就能收紧或放开某些组件。error帧则让前端有机会展示“生成中断,点击重试”这种有明确出路的界面,而不是卡在一个不动的半成品上。

有人会问,那“删除节点”“移动节点”怎么办?我的经验是收敛到patch里,用op字段区分set/append/remove/move四种操作。这样帧类型不膨胀,解析器只需要维护一个操作分发表。至于text为什么单独拎出来——因为文本是唯一高频、必须逐字追加的内容类型,如果每来一个 token 都走patch,帧头开销会非常夸张。

2.2 组件树的增量表达:节点寻址与 patch 语义

寻址是整个协议里最容易埋雷的地方。早期我用的是路径寻址,比如/root/children/2/children/0。写起来直观,跑起来全是 bug——因为流式生成过程中,父节点的 children 数组长度在不断变化,第 2 个位置昨天是这个节点,今天可能已经变成另一个了。顺序一错,属性就打到了错误的节点上,表现出来就是“标题跑到按钮里去了”这种诡异现象。

后来改成了稳定 ID 寻址:每个节点在创建时由后端分配一个会话内唯一的短 ID(我用的是n+ 自增序号,比如n17),之后所有 patch 都指这个 ID。这样即使乱序到达,只要按序号排好再应用,寻址就是确定的。ID 长度也要控制,短 ID 相比 UUID 能省下大量帧体积——按一屏 40 个节点算,UUID 方案光 ID 就要多出 1.2KB 左右。

patch的语义我定义得很克制,就四种:

  • set:给某个属性赋值,覆盖原值。适合标题、状态、数值这类单值属性。
  • append:往数组属性末尾追加元素。适合列表项、按钮组。
  • remove:按 ID 移除子节点或某个属性。
  • move:调整兄弟节点顺序,用在“先占位后回填”的场景。

这里有个细节值得单独说:append的幂等性。网络重试可能导致同一帧到达两次,如果不做去重,列表就会出现重复项。我的处理方式是每一帧带seq,前端维护一个已应用序号集合,seq小于等于当前水位线的帧直接丢弃。这个机制顺带把“乱序”问题也解决了。

2.3 字段级的类型约束与长度上限

协议里必须写死上限,否则一定会被模型喂出极端数据。我按“最坏情况下的可用性”来定这些值,而不是按平均值。

字段类型上限定这个上限的理由
节点 IDstring24 字符短 ID 方案下实际不会超过 8 字符,留余量
组件名string32 字符白名单匹配用,超长的一律视为非法
单个属性值长度string2000 字符超过这个长度的文本应走text帧分段追加
单节点子元素数array200一屏超过 200 个子项,说明该改用虚拟列表了
单帧字节数bytes32KB超过就要拆帧,避免代理层截断
帧总数int5000防止死循环生成的兜底熔断

这些数字不是拍脑袋来的。以 32KB 单帧上限为例:主流反代和网关对单条 SSE 消息的缓冲上限普遍在 64KB 上下,我取一半留出安全余量,实测在这个值以下从没出现过截断。2000 字符的文本属性上限则是这么算的:一个中文字符 UTF-8 编码占 3 字节,2000 字符约 6KB,加上其余属性后整帧仍在 32KB 以内,同时又能覆盖绝大多数标题、摘要、标签类内容,避免频繁拆帧。

注意:这些上限一定要在前端也要校验一遍,不能只信后端。后端校验是防模型,前端校验是防中间层篡改和旧版本后端。两端都校验,才叫真的校验。

2.4 心跳、序号与断流续传

流式链路最怕的不是报错,是静默卡死。连接还在,数据不来,前端一直转圈。我在协议里加了心跳帧,间隔 15 秒,纯空内容带一个当前序号。前端侧做超时判定:连续 30 秒没收到任何帧(心跳也算帧),主动断开并进入重连流程。

重连不是从头开始。前端在断开时记录最后成功应用的seq,重连请求里带上这个值,后端从该序号之后重新推。这套机制能跑通的前提是后端必须能重放——也就是说,生成过程中的帧要在一个短期缓冲区里留存,不能边生成边丢。我用的是一个会话级的内存环形缓冲,容量按“最近 500 帧或最近 30 秒”两个条件取先到者,实测足够覆盖 99% 的重连场景,内存占用也可控(500 帧 × 平均 2KB ≈ 1MB 上限)。

还有个坑:重连时hello帧要不要重发?要发,但只发能力位和白名单,不再重置节点树。前端收到重连后的hello时要判断是不是同一个会话 ID,是的话就跳过初始化,直接继续应用后续帧。

3. 前端渲染引擎:从帧流到 DOM 的调度实现

3.1 组件注册表:白名单是安全底线

前端拿到组件名之后,第一件事是查表。这张表我叫它组件注册表,本质是一个Map<组件名, 渲染函数>。查不到就丢弃该节点并上报——不降级、不猜测、不“尽力渲染”。这条规则我执行得比较硬,因为一旦允许“找不到就渲染成纯文本”,模型幻觉出来的组件名就会静默地变成一个丑陋的文本块,用户看到的是奇怪的残留内容,排查时也很难定位。

注册表除了组件名,还要登记属性契约:这个组件接受哪些属性、每个属性的类型、是否必填、枚举取值范围。比如一个chart组件,type属性只允许bar/line/pie三个值,传别的值就回退到默认值bar并记一条警告。这套契约用配置的方式写,不要散在组件内部,否则校验逻辑会碎掉。

安全上还有几条硬规则,都属于“不写会出事”的级别:

  • 禁止任何形式的表达式求值。属性值只能是字面量,不能是{{...}}、不能是函数体字符串。这一条堵死了绝大多数注入路径。
  • URL 类属性强制协议白名单,只允许https,并且域名要过前缀匹配。javascript:、data:、vbscript:全部拒绝。
  • 富文本属性一律降级为纯文本,需要富文本能力就单独封装一个受控组件,内部走固定的标记收敛,绝不让模型直接给 HTML 片段。
  • 样式属性走受限集合,只允许文档里定义过的那几十个(尺寸、间距、颜色变量名),禁止任意 CSS 字符串。原因很实际:一条position: fixed就能把界面布局搞乱。

3.2 渲染调度:双缓冲与帧合并

帧到达频率和渲染频率不匹配,是卡顿的主要来源。模型 token 输出峰值能到每秒 40~60 个片段,如果每来一帧就触发一次渲染,主线程会被打满,表现出来就是界面滚动卡、输入延迟。

我的做法是双缓冲 + 帧合并:

维护两份数据,pending是接收中的缓冲,committed是当前已渲染的。帧到达时只往pending里写,不碰渲染。然后用浏览器的一帧回调(requestAnimationFrame)作为提交时机,在一个渲染周期里把pending中所有未应用的帧一次性merge 进committed,再统一做一次 diff。这样无论这一帧内来了 1 个片段还是 20 个片段,都只渲染一次。

实测下来,这个改动把主线程的渲染次数从峰值每秒 60 次压到每秒最多 60 次但实际平均 25~30 次(受限于屏幕刷新率与 rAF 调度),配合把长文本的追加合并,输入框的输入延迟从肉眼可感的 200ms 级降到了基本无感。

帧合并有个必须注意的地方:合并要保持操作顺序。不能把append和remove拍平成一个集合,那样顺序语义就丢了。我用的方式是按seq排序后顺序应用,而不是“先去重再批量”,虽然会稍微多一点开销,但语义正确性不能妥协。

3.3 局部更新与状态保持(避免输入框被打断)

这是最容易翻车的地方。流式追加内容时,如果每次变更都导致整棵子树重建,就会出现:用户正在输入框里打字,内容一更新,输入框被卸载重建,光标丢了、已输入内容清空——这就是开头那个线上问题的直接原因。

根因是节点复用判定失败。组件化框架判断“这个节点是不是同一个”,靠的是 key 或者位置。如果你用数组索引作为 key,那么当列表头部插入新元素时,后面所有元素的 key 都会错位,框架只能全部销毁重建。

我的解法有三条:

  1. 所有节点使用协议下发的稳定 ID 作为 key,绝不用索引。这是最根本的一条。
  2. 输入类组件的值走“非受控 + 显式同步”。也就是组件内部维护自己的状态,只有收到专门的set指令时才把外部值同步进去,普通的内容追加不碰它。
  3. 滚动位置锚定。流式追加时如果用户已经手动上滚,就不要再自动滚到底部。判断方式是记录当前滚动距离底部的差值,超过阈值就认为用户“接管了控制权”,后续追加不自动滚动。

提示:第 3 条看着像个小细节,但它是“AI 回答到一半我上翻看看前面的内容,结果被强行拽回底部”这类抱怨的唯一有效解法。

3.4 一次完整的链路时序

拿一个具体例子把整条链路串一遍:用户问“帮我对比一下三个方案的优缺点”。后端决定输出一个三段式对比卡片。

第 1 帧是hello,带协议版本v1、白名单(含card、section、list、badge)、心跳间隔。前端初始化解释器,seq水位归零。

第 2 帧是node,创建一个card容器,IDn1,属性只有一个title: "方案对比"。前端立即挂载这个卡片,此时卡片是空的,但边框、标题、占位骨架已经可见——这一步很关键,它给了用户“确实在动”的反馈。

第 3~5 帧是三个node,都是section子节点,IDn2/n3/n4,父节点指向n1。每个 section 创建时只带标题,内容留空。到这里用户已经看到三个小标题整齐排列,心理预期建立了。

第 6~20 帧是一批text帧,分别指向n2/n3/n4的内容槽位。这时候是逐字追加的,用户在读文字。因为文本追加用的是专门的text帧,前端只做文本节点内容更新,页面结构完全不动,滚动也稳。

第 21~23 帧是patch,给三个 section 各挂一个badge,标注“推荐”“稳妥”“激进”。前端局部更新,其他区域重排影响极小。

第 24 帧是done,带总耗时、帧数、token 用量。前端解除 loading 状态,把“正在生成”指示器撤掉。

整个过程中,用户从第 2 帧(约 260ms)就看到界面骨架,到第 6 帧(约 700ms)开始有文字可读,全程没有一次大规模重排。这就是流式渲染的价值——不是“更快生成完”,而是让等待过程始终有可见的进展。

4. 后端与模型侧:让模型稳定吐出合规的流

4.1 结构化输出的约束方式

模型侧的目标很单纯:让它尽可能输出符合协议的帧,而不是自由发挥。这里我试过三种约束方式,效果差异明显。

纯提示词约束(把协议 schema 写进系统提示,要求输出 NDJSON):上手最快,但稳定性最差。实测在输出长度超过 800 token 后,格式崩坏率明显上升,会出现漏字段、括号不配对、在中文字符中间截断等问题。

工具调用式约束(把每次帧输出定义为一个工具调用):稳定性好很多,因为大多数模型对工具参数的格式遵守度更高,而且参数是结构化的,天然带类型。代价是每次生成要多次调用,延迟叠加,实测比纯文本输出慢 30%~50%。

混合方案:我最后用的是这个——首帧与结构帧走工具调用,文本追加走纯文本输出。因为结构帧数量少(一屏通常不超过 15 个),延迟影响可控;而文本是大头,走纯文本流最快。这样既保证了骨架稳定,又保住了文字吐出速度。

采样参数上,这类任务我固定用低温度(0.1~0.2)。不是因为要“更准确”,而是因为结构化输出对多样性的需求为零,温度高只会让格式选择随机化,纯属有害。

4.2 流式解析器的容错设计

即便约束到位,解析器也不能假设输入是完美的。我现在的解析器按“三道闸门”设计,逐层收紧。

第一道是传输层切分。按换行切 NDJSON,但必须处理“半行”情况——一个 JSON 对象可能被切在两个网络包里。做法是维护一个未完成缓冲,每次收到数据先拼上缓冲,再按换行切,最后一个不完整的片段留回缓冲。这里有个细节:缓冲要有上限(我设的 256KB),超过就判定为异常并主动断开,防止畸形输入把内存吃穿。

第二道是语法层解析。逐个尝试JSON.parse,失败不立即报错,先做一次轻量修复尝试:去掉尾部多余逗号、补齐未闭合的括号(按栈计数补对应数量的}或])。修复成功就继续,失败才记为坏帧。实测这套修复能救回大约一半的截断帧,尤其对“最后一个对象被网络切断”这种高频情况很有效。

第三道是语义层校验。校验组件名在白名单内、必填属性齐全、属性类型匹配、长度不超限、父子引用有效(父节点必须已经出现过)。这一层失败就直接丢弃该帧并计数,不尝试修复——语义错的帧修了也是错的。

三层都过不了的帧,累计到阈值(我设的是连续 5 帧)就触发熔断,下发error帧让前端展示降级界面。为什么不立即熔断?因为流式场景下偶发的单帧坏掉很常见,立即熔断会让可用性变得很差。

4.3 校验与修复:三道闸门

上面说的三道闸门,落到工程上有几个具体动作值得拎出来。

父子引用的处理。流式生成时,模型有可能先输出子节点、后输出父节点(尤其当它一边想一边写的时候)。如果严格要求“父必须先到”,就会丢节点。我的处理是:允许子节点先到,先挂在暂存区(一个按父 ID 分组的待挂载列表),等父节点到达时再一次性挂上去。暂存区要设过期时间(我设的 10 秒),超时未找到父节点的直接丢弃并记账。

占位与回填的顺序。为了让首屏尽快出现骨架,我会在hello之后立即主动发一个骨架帧,不等模型。这个骨架帧里包含预期的容器结构和 loading 占位。模型后续输出时,通过patch往占位槽里填内容。这样首屏可交互时间能从依赖模型首个结构帧的 700ms 级压到 200ms 级,体感差异巨大。

幂等与去重。重试是必然存在的,所以每个生成的帧都要带确定性的seq,同一请求下seq不能重复。我用的是请求级自增计数器,重试时从断点续,不重新编号。

5. 踩坑记录与排查速查表

5.1 典型故障现象与定位路径

流式渲染的问题有很强的“现象迷惑性”——看到的症状和真正的原因往往隔着好几层。这张表是我从真实排查记录里整理的,按现象反查原因,能省掉很多瞎猜的时间。

现象最可能的原因快速定位方式处理
界面长时间空白后一次性全出代理层开启了响应缓冲看服务端日志首帧发送时间与客户端首帧接收时间差关闭中间层缓冲,强制no-buffer
文字吐到一半停住,无报错心跳缺失,被空闲超时切断检查超时阈值与心跳间隔心跳间隔设在超时阈值的 1/3 以下
列表出现重复项重试导致帧重复应用打印已应用seq集合加序号去重水位线
输入框打字被打断节点 key 用了数组索引检查渲染层的 key 来源改用协议下发的稳定 ID
内容更新后滚动跳回顶部整树重建导致滚动容器重置观察渲染层是否发生容器替换容器抽离到流式区域外层,不参与重建
偶发显示“未知组件”白名单版本不一致比对hello帧下发的白名单与前端版本白名单随协议版本一起灰度
长文本截断在中间单帧超限被网关截断统计帧字节数分布拆帧,单帧控制在 32KB 以内

5.2 性能调优的几个实测点

调优这块,我踩过最深的坑是“优化了不该优化的地方”。一开始我以为瓶颈在解析,花了不少时间优化 JSON 解析器,结果 profile 一看,解析只占 6% 的时间,大头全在渲染层的重复布局上。

几个真正有效的点,按收益排序:

第一位是减少渲染次数。前面说的双缓冲合并,把渲染次数从“每帧一次”降到“每个动画帧一次”,是收益最大的一步。这一项带来的主线程占用下降,比我后面做的所有微优化加起来都多。

第二位是稳定 key。这个不光是解决输入框问题,它对整体渲染性能的影响同样巨大。key 稳定之后,列表追加时只有新增节点需要挂载,已有节点全部复用,实测一个 50 项列表追加 10 项时,渲染耗时从 40ms 级降到 8ms 级。

第三位是文本节点的合并策略。文本追加如果每个 token 都创建一个新文本节点,DOM 节点数会爆炸。做法是:同一个文本槽位在一帧内收到的所有片段,合并成一次文本内容赋值。注意这里合并的是同一帧内的片段,跨帧的不合并,否则就没有流式效果了。

第四位是虚拟列表。当节点数超过 100 时,必须上虚拟列表。但虚拟列表和流式追加有个冲突:流式追加会让总高度不断变化,导致滚动条抖动。解法是给列表项预估高度,并在追加时只增加总高度估计值而不实测,等流结束再做一次精确测量回填。

注意:不要过早引入虚拟列表。节点数在 30 以下时,虚拟列表带来的复杂度(滚动锚定、高度预估、测量回填)远超收益,老老实实全量渲染反而更稳。

5.3 我个人的几条硬经验

第一条:先把协议定死,再写渲染层。我最初是边写前端边改协议,结果是前端代码里到处是“兼容旧字段”的分支,三个月后没人敢动。后来重构成了“协议版本号 + 能力位”的机制,前端只按版本处理,旧版本字段直接丢弃,代码量砍掉近四成。

第二条:任何“看起来不会发生”的边界都要写日志。比如“父节点找不到”“组件名不在白名单”“帧超限”。这些情况在测试环境几乎不出现,但线上一定会出现,而且出现时你可能连复现都做不到。有日志,能定位;没日志,只能猜。

第三条:降级要有出路,不能只是报错。流中断时,我的做法是把已经渲染出来的内容保留,在底部加一条“生成中断,点击继续”的条。用户至少还能看到大半内容,而不是面对一个空白页。这个设计把流中断的用户抱怨量压下来了非常多——同一个故障,处理方式不同,体感天差地别。

第四条:首屏骨架千万不要等模型。这是我在这个链路上做过性价比最高的一次改动。骨架是确定的、可预知的,完全没必要等模型吐第一个结构帧。先给骨架,用户立刻有反馈,后面的流式填充只是让它“长肉”。

这套东西跑到现在,最长的一次流式会话生成了 200 多帧、持续了近 40 秒,全程界面平稳、可滚动、可输入,没有一次重建。回头看,真正难的不是流式传输本身,而是把所有“不确定”都收敛成前端能安全消化的确定帧——协议定得越克制,前端就越简单,整条链路也就越稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询