Grok 流式前端逐字渲染

2026-11-04 17:00 更新

前端接流式,最容易犯两个错:一是每来一个 chunk 就用新内容覆盖整个区域,用户在长回答里会看到光标来回跳;二是用户点了「停止」,代码把已生成的内容一起清空,用户想复制刚才那段也没了。前者是渲染方式错,后者是中断策略错。

这一节把渲染拆成三个动作:追加、停止、处置半截答案。

一、问题不是「怎么逐字」,而是「增量怎么落」

逐字渲染的本质不是特效,而是把增量安全地累加到已有内容上。它要同时满足三件事:

  • 只追加,不重排。每来一段 delta,接在已有文本末尾。
  • 可停止。用户取消后,循环不再写入,但已生成的内容保留。
  • 可判定。中断时要知道这段答案是完整的、部分的,还是应当丢弃的。

第三件最容易被忽略。用户看到半句话,如果界面不做任何标记,会以为模型答错了;如果直接清空,又浪费了已经生成的内容。所以「保留还是丢弃」要有判据,而不是凭感觉。

二、最小可运行示例:渲染状态机

下面的脚本不联网、不需要真实密钥。它用 Python 模拟前端渲染器,验证「追加、取消、中断处置」三条路径。浏览器端把 push() 换成往 DOM 追加文本即可,判定逻辑不变。

"""流式渲染状态机(mock):追加、取消、半截答案处置。"""

from dataclasses import dataclass

DISCARD_BELOW = 20   # 未完成且少于这个字数,视为无保留价值

@dataclass
class Renderer:
    text: str = ""
    done: bool = False
    cancelled: bool = False

    def push(self, delta: str) -> None:
        """只有未完成、未取消时才追加。"""
        if self.cancelled or self.done:
            return
        self.text += delta

    def finish(self) -> None:
        self.done = True

    def on_interrupt(self, reason: str) -> str:
        """中断处置:返回 keep / keep_partial / discard。"""
        self.cancelled = True
        if reason == "user_cancel":
            return "keep"                 # 用户主动停止,保留已生成
        if reason == "network" and len(self.text) < DISCARD_BELOW:
            return "discard"              # 网络中断且太短,丢弃避免误导
        return "keep_partial"             # 其余情况保留并标记不完整

def run_case(name: str, deltas: list[str], interrupt: str | None = None) -> None:
    r = Renderer()
    for d in deltas:
        r.push(d)
    if interrupt:
        action = r.on_interrupt(interrupt)
        shown = r.text if action != "discard" else "(已丢弃)"
        print(f"{name}:{shown}({action})")
    else:
        r.finish()
        print(f"{name}:{r.text}(完整)")

def offline_check() -> None:
    ok = ["数据库", "索引", "能加速查询。"]
    run_case("正常", ok)
    run_case("用户取消", ok, "user_cancel")
    run_case("网络中断且过短", ["数据库"], "network")
    run_case("网络中断但有内容", ["数据库索引能加速查询,", "因为它减少了全表扫描。"], "network")

if __name__ == "__main__":
    offline_check()

真正落地时,push() 对应「向容器追加一段文本」,finish() 对应「移除光标、标记完成」,on_interrupt() 对应「显示重试按钮或保留提示」。三者分开,前端就不会把渲染和策略搅在一起。

三、三种中断的处置规则

中断原因 已生成内容 处置 理由
用户主动取消 任意长度 保留 用户要的就是已经看到的部分
网络中断 少于 20 字 丢弃 太短,保留反而误导
网络中断 20 字以上 保留并标记不完整 有信息价值,标注后可继续
上游截断 任意长度 保留并标记不完整 与重连策略配合(见 6.4)

三件要强调的事:

  • 取消要真的停下。cancelled 一旦置位,后续到达的 delta 全部丢弃;否则会出现「点了停止还在增长」,比不停止更让人困惑。
  • 丢弃不等于删除用户数据。如果这段内容已经进入会话历史,清空界面不等于清空记录;处置策略要和存储策略一致,避免界面与历史不一致。
  • 前端只消费 delta 文本。SSE 的切分、[DONE] 判定、reasoning_content 与 content 的分流属于传输层(见 6.1);把这些逻辑塞进渲染组件,会让它同时承担解析与展示两件事,测试也变难。

四、预期输出与异常分支

运行状态机,预期输出:

正常:数据库索引能加速查询。(完整)
用户取消:数据库索引能加速查询。(keep)
网络中断且过短:(已丢弃)(discard)
网络中断但有内容:数据库索引能加速查询,因为它减少了全表扫描。(keep_partial)

换成真实界面时,若出现异常,按下面顺序定位:

现象 优先怀疑 第一步动作
文字闪烁或重复 整段覆盖而非追加 改追加,检查是否用了替换
取消后仍在增长 cancelled 未生效 在 push() 入口先判状态
半句直接消失 一律丢弃策略 按中断原因分档处置
内容与历史不一致 界面清空但记录未清 对齐处置与存储

最后一行尤其重要:界面上的「丢弃」是展示层决定,历史记录是数据层决定。两者不一致时,用户刷新页面会看到「消失的答案又回来了」,这是很难解释的 bug。

五、判据卡

判断项 该这么做 不该这么做
增量写入 追加到末尾 每个 chunk 重设整个区域
取消行为 停止写入并保留已生成 取消后清空全部
中断处置 按原因分档(保留/部分/丢弃) 一律保留或一律丢弃
半截答案 标注不完整并给继续入口 假装完整
组件职责 只做渲染 在渲染层解析 SSE

这张卡的重点是把策略从渲染里拆出来。四类现象里「取消后仍增长」和「半句直接消失」常常同源:状态机没有单一出口,表现一个是停不住一个是丢太狠,但修法都在 push 与 on_interrupt 的边界上。

六、来源与核验

  • 官方 Streaming 页:https://docs.x.ai/developers/model-capabilities/text/streaming(核验日期 2026-11-04)。增量事件、stream: true 与推理模型超时提示以该页为准;本节只取其「增量到达」这一事实,不引用其示例代码。
  • 渲染状态机与三档处置规则为本书原创的工程框架,不依赖任何前端框架的特定实现。
  • 具体框架的 DOM 更新方式、取消信号(AbortController 等)以其各自文档为准;本节不涉及框架选型。

七、动手试

  1. 把 DISCARD_BELOW 从 20 改成 5 再跑一遍,观察「网络中断且过短」用例的结论变化。想一想:这个阈值该按字数定,还是按「是否已形成完整句子」定?
  2. 给 Renderer 加一个 partial_marker 字段:keep_partial 时在末尾追加一个可识别的标记,并写一个函数在用户点击「继续」时把它去掉。
  3. 设计「取消后重新提问」的流程:列出界面要保留什么、要清空什么、要提示什么,并说明为什么不能直接复用上一轮的半截文本作为新请求的上下文。
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号