很多团队做 AI 对话产品时,前端渲染是最后才考虑的环节。后端调好了大模型 API,流式输出生效了,然后发现前端渲染跟不上------要么等整条消息才显示,要么逐字蹦但完全没有富 UI 结构。
问题的根源不在前端代码写得好不好,而在于从后端到前端的整条渲染链路缺少工程设计。TokUI 不只是一个前端组件库,它是一套从 Token 到 UI 的完整流式管道。本文拆解这条链路的每一个环节。
一、链路全景:从大模型到浏览器
一条完整的 TokUI 流式渲染链路分四层:
模型层。 大模型(GPT、GLM、Claude 等)逐 Token 输出。模型不理解 UI,它只吐文本。如果想让模型输出 TokUI DSL,需要在系统提示词中注入 DSL 语法说明和组件清单。
服务端层。 后端收到模型的流式输出后,有两个选择:原样透传(模型直接输出 DSL),或者用 TokUIBuilder 编排(后端逻辑组装 DSL)。两种方式都最终把 DSL 文本切成小块,通过 SSE 推送到前端。
传输层。 SSE 是单向长连接,服务端逐块推送。每个 data 行内是 JSON,取其中的 tokui 字段作为 DSL 分片。
渲染层。 前端 TokUI 引擎收到每个分片,喂给解析器的 feed 方法,状态机增量解析,渲染器逐节点挂载到 DOM。
css
[bubble role:ai model:GLM-5.2]
[think tt:思考过程] 用户需要数据分析... [/think]
[chart t:bar tt:月度趋势 d:"42,55,48,70,82"]
[p 分析结论:增长趋势明显]
[/bubble]
这段 DSL 在流式渲染时,用户看到的效果是:先出现对话气泡,然后思考过程折叠区弹出,然后柱状图逐根长出,最后分析文字逐字浮现。每一个组件不需要等后面的组件到达才开始渲染。
二、后端层:TokUIBuilder 的链式组装
后端不是手拼 DSL 字符串------那样太脆弱。TokUIBuilder 提供链式 API,在服务端用代码逻辑组装 DSL:
服务端收到模型输出后,可以动态组装 DSL 片段。比如模型输出了"查询销售数据"的意图,后端查完数据库后用 Builder 生成一个图表标签:
arduino
[chart t:line tt:近6月销售趋势 area l:"1月,2月,3月,4月,5月,6月" d:"42,55,48,70,82,95"]
Builder 的价值在于保证输出的 DSL 语法正确------属性引号不遗漏、容器标签正确闭合、数据格式符合组件要求。从 JBoltAI 踩过的坑来看,直接让模型裸写 DSL 的错误率约 8%,经过 Builder 组装后接近零。
三、传输层:SSE 协议约定
SSE 的每个 data 行内是 JSON,取 tokui 字段作为 DSL 分片。服务端把完整的 DSL 字符串切成 2 到 20 字节的任意片段推送:
kotlin
第一个 data: 推送 [bubble role:a
第二个 data: 推送 i model:GLM
第三个 data: 推送 -5.2]
前端收到每个分片后喂给 feed 方法。关键约定是:分块边界完全随机------可能切在标签名中间、属性值中间、甚至引号内部。解析器必须能从任意断点继续。
SSE 流结束时推送 [DONE] 标记,前端调用 endStream 刷新缓冲区残余,为所有未关闭的容器补发闭合信号。从 JBoltAI 的实践来看,endStream 的一个常见坑是:如果最后一个分片正好停在半截闭标签 [/car 上,endStream 必须正确判断这是闭合标签的前缀还是正文文本。
四、解析层:状态机的增量推进
解析器是整条链路的核心。它在 TEXT、TAG_OPEN、TAG_CLOSE 三状态间切换,每次 feed 到来时推进状态。
一个典型的卡片渲染过程。第一次 feed 到达 [card tt: 时,解析器进入 TAG_OPEN 状态,开始读取标签内容。第二次 feed 到达 流式卡片] 时,findCloseBracket 找到 ],parseTag 解析出标签类型 card 和属性 tt,解析器回到 TEXT 状态。此时渲染器收到一个 _stream:'open' 标记的容器节点,创建 DOM 并压入插槽栈。
第三次 feed 到达 [p 内容边 时,解析器又遇到 [,进入 TAG_OPEN。第四次 feed 到达 到边渲染],解析出 p 标签,渲染器将其挂到栈顶容器的 _slot。第五次 feed 到达 [/card],解析器进入 TAG_CLOSE,弹出栈顶容器,渲染器绑定事件。
css
[card tt:流式卡片]
[p 内容边到边渲染]
[btn tx:操作 v:primary]
[/card]
以 JBoltAI 的实践经验来看,解析器最容易被忽视的边界是原始内容容器的流式处理。代码块内部的内容不能按标签解析,但闭标签 [/code] 可能被 SSE 劈成两块------解析器必须回持半截闭标签,等下一块到齐再判断。
五、渲染层:插槽栈与增量挂载
渲染器收到解析器输出的节点后,根据 _stream 标记决定挂载策略。容器打开时创建 DOM 并压栈,容器关闭时弹栈并绑定事件,普通子节点挂到栈顶容器的插槽位置。
以一段嵌套布局为例:
css
[card tt:用户信息]
[row]
[col [avatar s:user.jpg]]
[col [h3 张三] [tag t:success 在线]]
[/row]
[/card]
渲染顺序是:card 打开→row 打开→col1 打开→avatar 渲染挂到 col1 的 slot→col1 关闭→col2 打开→h3 渲染→tag 渲染→col2 关闭→row 关闭→card 关闭。每一步都是增量操作,不等待后续节点。
两个关键工程细节。插槽委托:tab 组件需要在 tabs 容器上插入 radio 和 label,面板内容追加到 panel 自身。流式关闭钩子:picker 等组件的交互初始化推迟到容器关闭时执行。
六、图表的流式预览:最精巧的环节
图表是数据量最大的组件。等整个 [chart] 标签的 ] 闭合才渲染,图表区域会长时间空白。
csharp
[chart t:gantt c:"#1677ff" tasks:"设计评审,0,2|接口开发,2,6|联调测试,6,8|部署上线,8,10"]
TokUI 的方案是 TAG_OPEN 状态累积 chart 标签时,数据属性每增长就 emit 一个半成品预览。Renderer 用 pending wrapper 承接,每次预览增量重绘------柱状图逐根长出、甘特图任务条逐条增长。
从 JBoltAI 的实践来看,这个机制把图表首帧可见时间从秒级降到了毫秒级。用户在数据还在生成时就看到了图表骨架,体验提升非常显著。
全链路总结
| 环节 | 核心挑战 | TokUI 的解法 |
|---|---|---|
| 模型层 | 模型不输出 UI | DSL 语法注入提示词 |
| 服务端 | DSL 拼装易错 | TokUIBuilder 链式组装 |
| 传输层 | 分块边界随机 | SSE 协议 + 状态机任意断点恢复 |
| 解析层 | 原始内容/转义/容错 | 三态状态机 + 隐式闭合 |
| 渲染层 | 嵌套挂载 + 延迟初始化 | slotStack 插槽栈 + 关闭钩子 |
| 图表预览 | 数据量大闭合晚 | 半成品 wrapper + 增量重绘 |
从 JBoltAI 在实际 AI 对话产品中的验证来看,这条链路的每一个环节都有真实存在的工程难题。TokUI 不是在前端层面解决问题,而是从后端到传输到解析到渲染做了一体化设计。任何一个环节断裂,"从 Token 到 UI"就只是一句口号。
从 JBoltAI 在实际 AI 对话产品中的验证来看,这条链路的每一个环节都有真实存在的工程难题。TokUI 不是在前端层面解决问题,而是从后端到传输到解析到渲染做了一体化设计。任何一个环节断裂,"从 Token 到 UI"就只是一句口号。