Grok 流式前端逐字渲染
前端接流式,最容易犯两个错:一是每来一个 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 等)以其各自文档为准;本节不涉及框架选型。
七、动手试
- 把
DISCARD_BELOW从 20 改成 5 再跑一遍,观察「网络中断且过短」用例的结论变化。想一想:这个阈值该按字数定,还是按「是否已形成完整句子」定? - 给
Renderer加一个partial_marker字段:keep_partial时在末尾追加一个可识别的标记,并写一个函数在用户点击「继续」时把它去掉。 - 设计「取消后重新提问」的流程:列出界面要保留什么、要清空什么、要提示什么,并说明为什么不能直接复用上一轮的半截文本作为新请求的上下文。

TRAE-AI编程


更多建议: