第 7 章 流式渲染与状态机
难度 ★4 | 约 4 学时 | 6 节
这一章把「边收边渲染」写成一台可预测的状态机。判据不是「代码优雅」,而是「用户在每一刻看到什么」——所有界面事故都能落到这一句上。
学习目标:把「边收边渲染」写成可预测的状态机,而不是一堆互相打架的布尔变量。
本章交付物:一张状态转移图 + 一份「每个状态的进入与退出条件」清单
7.1 AI 界面本质是状态机:四个值,而不是几个布尔
官方把会话状态对外暴露成一个四值的枚举,四个值各有明确语义:
| 值 | 一句话语义 |
|---|---|
| 等待开始 | 消息已经发给接口,正在等响应流的开始 |
| 正在流式 | 正在接收分片 |
| 就绪 | 完整响应已收到并处理完,可以发下一条 |
| 出错 | 请求失败 |
请注意这套枚举的用法——用法本身就证明了四个值互斥:输入框与提交按钮的禁用条件是「状态不等于就绪」;停止按钮的显示条件是「状态是等待开始或正在流式」。一条判断就够了,不用三个布尔与一下。
那为什么「用几个布尔描述界面」一定会坏?这不是风格问题,是组合数问题。
四个布尔——正在加载 / 正在流式 / 出错 / 已完成——一共 16 种组合,而合法的只有四五种。也就是说,剩下十几种全是非法状态,而编译器不会拦你。 你只需要一次没被重置的赋值,就会掉进其中一种,而且代码评审时看不出来:每一行赋值单看都对。
把它们与线上症状对起来,这张表就是排查工具:
| 同时成立的布尔 | 用户在屏幕上看到什么 |
|---|---|
| 正在流式 + 已完成 | 停止按钮与提交按钮同时可用,用户不知道该点哪个 |
| 出错 + 就绪 | 输入框能用了,但错误提示还挂着,用户不敢再发 |
| 已中止 + 正在加载 | 转圈永远不消失(7.4 会讲它为什么不归请求管) |
| 等待开始 + 已完成 | 进度条卡在中间,而内容其实已经到齐 |
这张表的价值不在「有几种坏法」,而在它给了排查方向:你在线上看到的症状,基本都能反推到某一对同时成立的布尔。
所以「枚举更好」这句话要说得再实一点:在布尔方案里,非法状态是可以写出来、可以编译、可以跑到线上的;在枚举方案里,你没法表示「既正在流式又已完成」——它不是被禁止,是不存在。 编译器替你关掉了那十几种组合。
这一节的产出不是「用状态机」这句口号,而是一张四态表:状态 / 一句话语义 / 进入条件 / 退出条件。第 7.6 节会把它扩成六态并画成图。
最后交代一句口径:官方给的是四个值,而一个完整会话还需要两个——已中止与已完成。它们为什么必须补、补在哪,7.6 讲。
7.2 片段与消息是三层,不是两层:渲染要用 parts
先把那句可以照抄的建议放在前面:UI 消息有一个 parts 属性,它包含消息的各个部分;建议用 parts 属性渲染消息,而不是 content 属性。 官方给的理由也直白:parts 支持不同类型——文本、工具调用、工具结果——从而支持更灵活的界面。
但大纲写的「片段是传输单位、消息是显示单位」是两层,而实际链条是三层:
分片(传输单位)→ 消息的 parts(累积后的数据模型)→ 渲染。
中间这层「累积」被抹掉之后,读者很容易把分片直接当成渲染单位——那正好是 7.3 要拦的那个坑。 所以这一节不改清,下一节没法读。
三层各自坏掉,症状完全不同:
| 层 | 它负责什么 | 坏掉时用户看到什么 |
|---|---|---|
| 分片层(传输) | 顺序、编号、不丢块、不乱序 | 历史碎:一条回答被拆成两条,或工具调用与文本错位 |
| 累积层(parts) | 按块把增量拼成一条完整消息 | 重发与回放错:重发时带上了旧的部分,编辑后回放出两个版本 |
| 渲染层 | 决定多久把变化画到屏幕上 | 界面被拖垮:打字卡顿、滚动条乱跳 |
把三层与三种症状配起来,这一节才有用:你看到的症状,直接告诉你哪一层错了。 这比「要注意分层」这种话有用得多——分层的价值就是让症状可归因。
一条旁证,先给 7.3 铺路:官方的节流说明写的是「降低界面更新频率,而不会延迟流的处理或回调」。这句话把两件事明确分开了——分片被处理的节奏,与消息被渲染的节奏,是两件独立的事。 既然能分开,就说明它们不在同一层。
两个实操细节也要落在这里,因为它们都依赖「消息是一层独立的数据模型」:消息带唯一标识与角色;发送函数可以带消息标识来「替换某条消息」(用于编辑),不带标识则是重发当前消息(用于补上工具输出之后继续)。如果消息只是「拼起来的字符串」,替换与重发都无从落地。
收口:别用 content 渲染,用 parts;别把分片当显示单位,它是传输单位。 三层界线画对了,后面两节才有地基。
7.3 到达频率不等于渲染频率:节流只能落在渲染层
先给一个不好听的事实:默认行为就是最坏的那种。
官方文档承认:「默认情况下,这个 hook 会在每收到一个新分片时触发一次渲染。」也就是说,「界面被拖垮」在默认配置下是必然,不是偶发——只要输出够长、分片够密,它一定会发生。
官方给的开关是一个带实验性前缀的选项,说明是「降低界面更新频率,而不会延迟流的处理或回调」,并注明「这个特性目前只对 React 可用」。
现在说这一节唯一有工程含量的地方:位置。
大纲说「节流与批量刷新要有明确的位置」,但没说位置在哪——而官方特意加的那半句「不会延迟流的处理或回调」,反过来读就是一条禁令:
节流只能落在渲染层,绝不能落在消费流层。
为什么?因为在读取流的循环里按节奏等待(每读一块等一小段),会同时拖慢分片处理与回调——而回调里有错误处理与结束判定。结果是错误被发现得更晚。 这个写法看起来很正常(「我只是在控制速度」),但它把「渲染节流」写成了「流节流」——这是本节要拦住的第一个事故。
第二条路,也是更好的一条:不要引入固定延迟,交给框架的并发渲染。
React 官方对「延迟某个值」的立场是:它与用 debounce 或 throttle 延迟更新的用户侧 hook 类似,但好处是「React 会在其他工作结束后立刻处理这个更新,而不是等一个任意时长」;而且由它触发的后台重渲染是可被中断的。
两句话合起来是一条完整判据:能交给调度器,就不要自己定时。 自己定时的问题不是「不准」,而是它不感知这一帧有多忙——固定的 100 毫秒,空闲时太长、忙碌时又太短。
一条侧证,说明节流确实是渲染层的事:官方文档写明,共享同一个会话实例时,使用不同节流间隔的消费者会看到不同的消息快照。同一份数据、不同的渲染节奏、不同的快照——这只可能发生在渲染层,不可能发生在传输层。
最后一条边界,别漏:那个开关是实验性的、且只对 React 可用。所以「换个框架就照搬」行不通——机制可以搬(渲染层节流),开关不能搬。
收口:这一节的判据是两句,位置对(渲染层)+手段对(调度器优先,不定固定延迟);说得出这两句,「性能优化」才不是一句许愿。
7.4 中止要真的中止:中止、断开与失败是三件事
这一节要把「中止」从一个动作,写成一个带三条分支的状态转换。
先说它到底能作用于什么。权威文档写得很具体:中止能作用于 fetch 请求本身、任何响应体的消费、以及流。三样都算——这句话的意思是,中止不是「告诉浏览器别管了」,它是一条一路贯穿到「读流」那个位置的信号。
由此得到一条最容易被忽略的行为:如果请求在被中止之前已经 resolve(响应已经到了),但响应体还没被读完,那么之后尝试读响应体会抛出一个名为 AbortError 的异常。
也就是说,你必须在读的位置接住它。不接的后果只有两种:要么读不完,要么抛错没人接。 这句话解释了一类很难复现的报错——用户点了停止,控制台里却冒出一个 AbortError,看起来像 bug,其实是「信号到了,但代码没在那里等它」。
框架侧的封装也印证了它落在请求层:官方对中止函数的描述是「中止当前正在流式返回的响应」,并补了效果「fetch 请求会被中止。这避免了消耗不必要的资源。」
现在说本节的核心:中止、断开、失败是三件事。
依据就在官方的结束回调签名里——它收到的是三个独立的布尔标志:是否被中止、是否连接断开、是否出错。三件事在官方口径里本来就是分开的,所以把它们揉成一个 error,是你自己丢掉了信息。
| 三件事 | 谁造成的 | 界面该怎么做 |
|---|---|---|
| 中止 | 用户自己按了停止 | 安静收尾:保留已有内容、标「已停止」、不报错 |
| 断开 | 网络断了 | 提示可重连、保留已有内容、标「连接中断」 |
| 失败 | 上游给的 | 按错误类型决定重试与否,给通用文案(9.2) |
把三者揉成一个 error 的后果很具体:用户按了停止,界面却弹出「出错了,请重试」——一个把用户自己的动作报成故障的界面。 这是本篇纪律「用户在这一刻看到什么」的第一个正面用例:他按的是停止,他要看到的是「停了」,不是「坏了」。
还有一条动作纪律,比「取消请求」更容易漏:中止后要清理的不只是请求。
占位符、骨架屏、计时器、进度条的定时刷新、以及那个「正在输入」的光标——它们都不归请求管,请求结束不会让它们停。 「转圈不消失」的经典成因就是这个:你取消了请求,但没有取消那个一直在转的东西。
收口:中止只是三条分支里的一条,而它的判据是「用户按了停止之后,屏幕上还剩下什么」。 这句话能直接检出一半的假中止。
7.5 重试要有幂等边界:键必须由你的服务端生成
「有副作用的工具必须带幂等键」这句话方向对,但键从哪来才是这一节真正的分界线。
先把参考实现写清楚——支付接口的官方文档把这套机制写得最完整,六条:
① 客户端生成一个唯一键随请求发出,服务端用它识别重复。 ② 首次请求开始执行之后,服务端会保存它的状态码与响应体;用同一个键重试会返回保存的响应——包括 500 错误。 ③ 键最长 255 字符。 ④ 幂等层会比较入参与原请求,不一致就报错。 ⑤ 键满 24 小时后自动清理。 ⑥ 所有 POST 都接受幂等键,而 GET 与 DELETE 不要带(没有作用,它们本来就幂等)。
另有两条安全与边界纪律:不要用敏感数据(例如邮箱或个人标识)做键;并且只在该端点开始执行之后才保存结果——入参校验失败或与并发请求冲突时不会保存,这类请求可以直接重试。
现在说 AI 场景的定界,这是本篇最重要的一处加料:这个键不能由模型生成。
理由很硬:模型的输出本身是不确定的,重试时它「再想一次」很可能给出不同的串——那等于每次都换一个新键,幂等层完全失效,副作用照样发生两次。 而这一切不会报错:你只会看到「有时候会重复下一单」,然后去怀疑网络。
正确的形状是:键由你的服务端在「用户动作发生」的那一刻生成并持久化,再作为工具调用参数随调用传递。
换句话说:幂等边界画在「你的服务端与工具之间」,不是画在「模型与你的服务端之间」。 这条边界画错,幂等键就只是个装饰。
配套的限流侧还有四条纪律,缺一条都会让重试变成放大器:
① Retry-After 是下限。 至少等这么久,再加一点随机延迟——否则大量客户端会在同一时刻重试,形成惊群效应;而且各客户端的重试时刻会重新对齐,所以光是退避也不够。
② 失败的请求照样计入每分钟配额。 无退避地猛重发,只会让限流更糟——这是一个自我加重的循环。
③ 不要重试配额、计费这类需要你先去处理问题的错误。 它们不是「等一等就好」,是「先去把余额或上限改了」(9.5 会把这条讲透)。
④ 如果 SDK 自带重试,要在自己的重试上限里把它算进去——否则嵌套重试会把请求数乘起来。
另有一条容易被忽略的边界:单次尝试的超时,不必然是整个操作的最后期限;如果服务器给出的延迟超过你配置或支持的上限,应停止重试并把请求推迟,而不是更早重试。
收口一句,作为重试前的自问:这次重试是「多花一次钱」,还是「多下一单」? 前者可以不加键;后者必须加——而且键不能来自模型。
7.6 一个可观测的验证:六态转移图与「无出口」检查
这一节的东西是本书构造的,先说清身份:本轮检索没有找到任何机构发布过「状态转移图」的标准模板或验收口径。官方给的是一个四值枚举,不是一张图。
那为什么还值得画?因为它解决的是「看不出来」这件事。
六个状态里,四个来自官方(等待开始 / 正在流式 / 就绪 / 出错),两个由本书补上:已中止与已完成。
为什么必须补这两个?因为官方把「完成」隐含在「就绪」里(就绪的语义是「完整响应已收到并处理完」),把「中止」隐含在结束回调的一个布尔标志里。隐含的意思是:它们在你的界面上没有独立的名字——而没有名字的状态,就没有人去检查它的出口条件。
产出格式四列,每一列都要能填满:
| 状态 | 进入条件 | 退出条件 | 无出口时的症状 |
|---|---|---|---|
| 等待开始 | 用户提交、请求已发 | 收到第一个分片 / 出错 / 被中止 | 点了发送没反应,输入框已清空 |
| 正在流式 | 收到第一个分片 | 收尾 / 出错 / 断开 / 被中止 | 一直转圈,停止按钮可按但没用 |
| 就绪 | 收尾完成 | 用户提交下一条 | 能发但发不出去 |
| 出错 | 任一失败分支 | 重试 / 用户清空 | 错误提示挂着不动 |
| 已中止 | 用户按停止 | 用户重新提交 | 骨架屏不消失 |
| 已完成 | 收尾且内容完整 | 归档 / 滚出视口 | 内容在但状态仍显示「生成中」 |
为什么值得画?理由是:「用布尔变量拼状态」这个坑,单看代码看不出来——每一行赋值单看都对。只有把六态并列、逐条走边的时候,「从正在流式到已中止有没有 exit」「从出错回就绪谁负责清空错误」这类问题才会暴露。
这就是「可观测的验证」这个说法的本意:它把不可见的东西,变成一张能被评审的图。 评审时你可以指着一条边问「这条谁触发」,而代码里没有这个抓手。
这张图要能回答一个具体问题:有哪些状态没有出口?
收口:没有出口的状态,就是会卡住的状态。 它与第 4 篇那条「这个门禁有没有让流水线红过」是同一个形状——都是拿「它有没有真的发生过一次」来证明机制存在。