AI 对话里遇到速率限制,别把重试提示写进正文:瞬时横幅与对话正文的分流做法
学习目标
读完这篇,你能给自己的 AI 对话应用做一道「瞬时状态与对话正文的分流」:遇到速率限制要自动重试时,把「几秒后重试、第几次」这类提示放进一个独立的瞬时横幅里倒计时显示,而不是写进对话正文。做完之后,你的对话记录里不会再混进重试提示,历史回放也不会把一句临时状态当成模型说过的话,读者(和下一轮的模型)看到的始终是干净的对话。
先给大家科普一个概念「瞬时状态」。翻译成人话:有些信息只在当下有用,事情过去就该消失------比如「正在重试」「网络抖动了一下」「正在连接」。它和对话正文是两类东西:正文是要存下来、能回放、能再喂给模型当上下文的;瞬时状态只负责让此刻屏幕前的人安心,任务一结束就该自己消失。把这两类东西混在一个通道里,是很多对话应用都会踩的坑。
daw 就踩过这个坑。它的查数 Agent 遇到模型服务商的速率限制时会自动重试,本来是个体贴的功能,但提示是这样实现的:后端把「(遇到速率限制,X 秒后自动重试...第几次)」当成一段 text 直接发给前端,前端就把它当成正文显示出来。结果呢?第一,这句话混进了最终答复,读者读到的答案里夹着一句系统唠叨;第二,更糟的是,它可能被当成对话历史的一部分回放甚至喂回模型------一句临时状态,摇身一变成了模型「说过的话」。后来 daw 把这条提示从正文通道里抽了出来,单独走一条瞬时事件通道。下面就是这套做法的完整步骤。
1. 前置知识:对话里其实有两条通道
动手之前,先把通道分清楚。很多对话应用表面上只有一条流------后端推什么,前端就显示什么------但实际上至少有两类内容在流动:
第一类是持久内容:模型的回答、工具调用、工具结果、用量统计。这些要落盘、要回放、要进下一轮的上下文,丢了不行,串了也不行。
第二类是瞬时内容:重试提示、连接状态、倒计时。这些只在流式进行中有意义,一旦后续内容到了,它们就完成了使命。它们不需要落盘,更不应该进历史。
判断标准很简单,问自己一句话:这句话在对话结束一小时后还有意义吗?「遇到速率限制,5 秒后重试」一小时后毫无意义------重试早就结束了。这就是瞬时内容。而「本月放款总额是 X」一小时后仍有意义------这是持久内容。分流的第一步,就是把这个判断变成代码里的结构,而不是靠开发者每次凭感觉。
2. 第一步:后端给重试提示一个独立的事件类型
旧做法的问题根源在后端:重试提示复用了 text 事件类型。前端收到 text,第一反应就是往正文里追加------它分不清这句 text 是答案还是系统唠叨。所以第一步是让后端把重试提示发成一种独立的事件。
在 daw 的做法里,后端新增了一个专门的发送函数 emit_retry_notice,发出 kind 为 "retry" 的事件,带的不是一段文本,而是三个结构化数字:attempt(当前第几次尝试)、maxAttempts(最多重试几次)、delaySecs(本轮等待几秒)。原先拼在 text 里的那句话------「遇到速率限制,X 秒后自动重试...第几次」------不再由后端拼字符串,而是由前端拿这三个数字现算、现显示。
这里有个细节值得抄:重试的等待间隔本身是指数退避的,即 delay = 基础秒数 × 2^(attempt-1)。这说明了一件事:重试提示里的数字是运行时状态,就该用结构化字段传,而不是拼成一句话混进正文------字符串一旦进了正文,就再也拆不回结构了。
3. 第二步:前端用独立状态存它,绝不写进消息段
后端发了独立事件,前端就要有独立的状态接住它,而不是顺手塞进消息列表。daw 的前端做法是:
- 定义一个独立的 RetryNotice 类型,字段就是上面那三个数字,再加一个 at(前端收到事件时的时间戳,单位毫秒,驱动倒计时)。
- 在应用顶层用一个按任务 id 键控的记录表存它:retryNoticestaskId,而不是把它追加到任何一条消息的 segment 列表里。
- 对话视图组件通过一个独立的 prop(retryNotice)接收它,渲染在消息流末尾的一个横幅位置------视觉上它贴在对话里,但数据上它不属于任何一条消息。
有人可能会问:视觉上也在对话流里,数据上分不分有那么重要吗?重要。视觉位置是暂时的,数据归属是永久的。只要它进了消息段,落盘、回放、组装 LLM 历史都会把它当成正文处理;而独立状态的好处是:处理正文的逻辑根本看不见它,想混也混不进去。分流要分在数据层,不是分在 CSS 层。
4. 第三步:横幅倒计时,归零后显示「重试中」
瞬时横幅光独立还不够,它还得让用户在等待时安心。daw 的横幅是这样做的:
- 横幅样式是琥珀色提示加一个沙漏符号,一眼就能看出这是「系统状态」,不是模型回答。
- 倒计时由父级的一个 100 毫秒时钟驱动,不需要横幅自己再开定时器。剩余毫秒数 = at + delaySecs × 1000 − 当前时间,向下取整到秒显示:「遇到速率限制,X 秒后自动重试(第 a/b 次)」。
- 倒计时归零后,文案自动切换成「正在重试...(第 a/b 次)」,直到重试真正有结果。这个切换很重要:如果倒计时到零就把横幅直接撤掉,用户会以为重试失败了;显示「正在重试...」是在告诉他:等待结束了,正在干活,别走开。
这里有个容易忽略的点:倒计时的起点是前端收到事件的时间(at = Date.now()),而不是后端发出事件的时间。后端发出、网络传输、前端接收之间有延迟,用接收时间起算,显示的秒数才是用户实际要等的秒数。反过来,如果用后端时间起算,横幅可能一出现就只剩 1 秒甚至直接归零,用户会觉得倒计时在「跳秒」,信任感就没了。
5. 第四步:任何实质内容一到,横幅自动清除
瞬时状态最关键的一条纪律:它必须有明确的清除条件,不能靠用户手动关,也不能靠定时器猜。daw 的清除规则写在事件分发处,一行逻辑:
- 收到 kind = "retry" 的事件 → 写入(或更新)该任务的横幅状态。
- 收到任何其他实质内容事件(text、reasoning、工具调用、done 等)→ 删除该任务的横幅状态。
- 唯一的例外是 usage(用量)事件:用量统计不是对话内容,它的到达不代表重试结束,所以不清除横幅。
为什么清除条件要定成「任何实质内容到达」而不是「重试成功」?因为只要有实质内容来了,就说明对话已经恢复流动,横幅的使命已经完成。绑在「内容到达」这个可观测的事实上,比绑在需要后端额外通知的「重试成功」上更可靠:少一次通知,就少一次横幅赖着不走的可能。重试次数耗尽、最终返回错误时,错误事件本身也是实质内容,横幅同样会被清除,不会和错误提示叠在一起。
6. 为什么这样设计:三条判断标准
回过头看,这套做法背后是三条可以复用的判断标准,换到别的瞬时状态(比如「正在连接」「模型切换中」)上同样适用:
第一,持久还是瞬时,先分数据通道。能进历史、进上下文的东西走消息段;只在当下有意义的东西走独立事件 + 独立前端状态。判断句就是前面那句:一小时后还有意义吗?
第二,状态用结构化字段传,不用拼好的句子传。attempt、maxAttempts、delaySecs 三个数字,前端可以算倒计时、可以换文案、可以做无障碍播报;拼成一句话传过来,前端除了原样显示什么也干不了。结构化是可组合的前提。
第三,清除条件要绑在可观测的事实上,不要绑在需要额外通知的状态上。「有内容来了就清除」比「收到成功通知才清除」少一个失效点。瞬时 UI 最怕的不是出现得晚,而是消失得晚------赖着不走的状态提示,比没有提示更损害信任。
7. 避坑清单
- 别把系统提示复用 text 事件类型。前端分不清,落盘逻辑也分不清,混进去之后再想摘出来,要改的地方比一开始分流多得多。
- 别把横幅塞进消息段再用样式「假装」它不是正文。数据归属决定了它会不会进历史、进上下文,CSS 决定不了。
- 别用后端时间起算倒计时。用前端接收时间起算,显示的秒数才是用户实际要等的秒数。
- 别让用量事件清除横幅。用量是统计,不是内容;它一到就清横幅,会导致横幅在重试还没结束时提前消失。
- 别给横幅加手动关闭按钮。瞬时状态的生命周期应该由内容到达驱动,用户手动关掉之后,重试状态就没地方显示了------用户反而更慌。
- 别在横幅文案里写「失败」两个字。速率限制重试是正常流程,不是故障;文案要说「遇到速率限制,X 秒后自动重试」,不要说「请求失败,正在重试」,前者让用户安心等待,后者让用户以为出事了。
8. 总结
给 AI 对话应用做重试提示,记住四句话:后端发独立的 retry 事件、带三个结构化数字;前端用独立状态存它、绝不写进消息段;横幅倒计时显示、归零后显示「重试中」;任何实质内容一到就自动清除。daw 之前把提示写进正文,付出的代价是历史回放里多了一句模型没说过的话;分流之后,重试提示只在它该在的时间出现,也只在它该在的时间消失------而这正是瞬时状态应有的样子:来的时候让人安心,走的时候不留痕迹。
项目开源在 GitHub:tsingliuwin/daw,觉得有用欢迎点个 star。