welclaiAI·TREND·DIGEST
教程

在 UI 中流式传输并渲染模型输出

为什么流式传输让 AI 功能感觉飞快,以及如何在 UI 中逐 token 渲染输出,而不出现闪烁、断裂的标记或布局混乱。

tutorials2026-04-26 10:23 KST·主编·7 分钟

一个感觉飞快的 AI 功能和一个感觉坏掉的 AI 功能,区别往往不在模型——而在于你是否流式传输输出。一个要花好几秒才能产出完整答案的模型,如果用户全程盯着转圈,就会感觉慢得难受。同一个模型,如果第一批字几乎立刻出现、其余内容随后涌入,就会感觉很灵敏。流式传输正是把漫长的等待变成实时响应的技术,而把它渲染好,是一门值得学的小手艺。

为什么流式传输改变了体验

当你发起一个普通请求时,你要等到整个响应完成才能看到任何内容。对一个长答案而言,那就是对着空白长久地干瞪眼。流式传输改变了等待的形态:模型不再是在末尾来一次大的延迟,而是边生成边把输出一点点发过来,你则在每一片到达时就把它显示出来。

完成的总时间大致相同。变的是感知到的速度。首 token 时间——从发起到出现内容的时长——降到了几分之一秒,而人会把一个流式的响应读作很快,哪怕整体生成其实很慢。这和进度条对比死机画面是同一种心理。工作量完全一样;体验却不一样。对任何需要人去等待的场景,流式传输几乎是必选项。

流式传输大致如何运作

在底层,一个流式响应是一条长连接,它传递的是一串小事件,而不是一个最终的载荷。每个事件携带一块输出——往往是几个字符或一个 token。你的代码在这些事件到来时读取它们,把每一块追加到你目前已积累的内容上,并更新显示。当流发出完成信号时,你就拥有了那个逐片拼装起来的完整响应。

用伪代码来写,这个循环很简单:

accumulated = ""
for chunk in stream(request):
    accumulated += chunk.text
    render(accumulated)
on_complete():
    finalize(accumulated)

提供方的 SDK 会处理好传输的细节。你的工作就是这个循环:读取块、积累、渲染、处理结尾。有意思的问题几乎全在渲染这一侧。

渲染累积的文本,而不是增量

渲染流的第一条规则是:显示累积的字符串,而不只是最新的那一块。人们很容易把每个增量直接当作纯文本追加进 DOM,但只要你需要任何格式,这种做法立刻就崩。Markdown、代码块和结构化输出,只有作为整体才说得通。一块内容可能把一个词、一个 markdown 标记或一个标签从中间劈成两半。如果你各自独立地渲染增量,就会得到一堆错乱的残缺标记。

正确的做法是把完整的累积文本保存在状态里,并在每次更新时重新渲染它。现代 UI 框架让这件事成本很低——你只更新状态里的一个字符串,框架就会高效地协调显示。每次都渲染整段已累积的答案,还意味着你的 markdown 或语法高亮始终看到的是“到目前为止完整”的文本,它解析这种文本,要比解析孤立的碎片优雅得多。

处理残缺与畸形的中间状态

当一个响应在流式传输时,每一个中间状态按定义都是不完整的。一个代码块可能已经有了开头的围栏,却还没有结尾的。一个 markdown 链接可能只打了一半。一个列表可能在某一项中途就停了。如果你的渲染器很严格,这些残缺状态就会闪烁或报错。

解决办法是宽容地渲染。用一个能优雅处理未闭合结构、而不是直接报错的 markdown 解析器,并接受这样一个事实:显示中会短暂地出现“正在进行中”的格式,它会随着更多文本到来而归位。特别是对代码块,检测到一个未闭合的围栏、并把其后的内容当作代码处理直到它闭合,会很有帮助。原则就是把“这段文本还没写完”设计成流式传输期间的常态,因为它确实如此。

驯服布局抖动与滚动

一个流式响应在增长,而增长会推动页面。若不小心,响应下方的内容会随着新行的出现而跳来跳去,而一个想读顶部的用户会被猛地拽下去。两个习惯能让这一切保持平静。

第一,预留空间,避免重排无关内容。把流式文本渲染在一个向下生长的容器里,让它不至于不可预测地把其余布局推得乱动。第二,刻意地处理滚动。一种常见而舒服的行为是:当用户已经在底部时,把视图钉在底部,让他们跟着实时输出走——但一旦他们向上滚动去读某段内容,就立刻停止自动滚动,这样你才不会和他们较劲。检测用户是否靠近底部,只在他们靠近时才自动滚动。

还要对更新做节流。对很快的流来说,每来一个 token 就重新渲染会把浏览器压垮。把更新批量化到一个合理的间隔——比如每帧几次——能让 UI 保持流畅,又不会明显损害灵敏度。

展示状态,并处理中断

流式传输给了你天然的机会去传达状态。在第一个 token 之前就显示生成已经开始,在进行过程中清楚地标示,并在完成时做出标记。流式期间一个微妙的光标或一个“停止”按钮,会告诉用户系统是活着的、正在干活。

那个停止按钮很要紧。因为流是一条活着的连接,你可以在中途取消它。给用户一种打断一个又长又错的响应的办法,而不是逼他们干等到底——取消请求,保留目前已到达的文本,并把控制权交还。在错误这一侧,一个流可能在中途失败;把断开的连接当作一种可恢复的状态,保留已有的部分输出,并提供重试,而不是把一切丢弃。从一开始就为取消和部分失败做好设计,要远比事后补救容易。

总结

流式传输是你能给一个 AI 功能的“手感”做的最划算的大升级:工作量一样,但立刻出现内容就会被读作飞快。在循环里读取块,并始终渲染累积的文本、绝不渲染原始增量,这样格式才能保持完整。宽容地解析,因为每一个中间状态都没写完;驯服布局抖动与自动滚动,好让阅读保持舒适;并对更新做节流以求流畅。最后,把取消和部分失败当作一等的状态来对待。把这些做对,一个慢模型也会感觉灵敏——而这恰恰是用户真正评判的大部分。

#streaming#ui#latency#frontend