当模型卡住时:GPT‑5.2 完成了 Opus 4.5 无法实现的“简单”Spinner
I’m happy to translate the article for you, but I need the full text of the article itself. Could you please paste the content you’d like translated (excluding the source line you’ve already provided)? Once I have the text, I’ll translate it into Simplified Chinese while preserving all formatting, markdown, and code blocks.
Feature Request: “This should be simple”
“有时 LLM 的响应在句子中途卡住。出现这种情况时显示一个基本的加载指示器,等到有更多文本时移除它。”
在网页 UI 中卡顿令人烦恼;在终端中则看起来像崩溃。
我们需要的是一个微妙的⋯ waiting for more指示器——不花哨,只是一个明确的信号,表明系统仍在运行并等待。
场景:模拟流式与现实世界的卡顿
Aye Chat 使用 Rich(Live)将响应流式输出到终端 UI。
我们也会模拟流式:即使提供者一次返回大量块,我们也会逐词动画化输出,使其看起来像“打字”。
真实的提供者行为如下
- 你收到 一些 token。
- 出现间隙(LLM 正在思考、服务器端暂停、背压)。
- 流式继续。
如果间隙出现在句子中间,用户会卡住。
四个看似简洁的需求
- 在流式过程中检测 卡顿。
- 仅在卡顿期间显示
⋯ waiting for more。 - 新文本到达时立即移除它。
- 不破坏 Markdown 或最终的格式。
架构(以及它为何会欺骗你)
在高层次上,流式 UI 有三个活动部件:
| 组件 | 角色 |
|---|---|
update(content: str) | 当 新的流式内容到达 时调用(完整累计的内容,而非增量)。 |
_animate_words(new_text: str) | 逐字打印新接收的文本,每个词之间有小延迟。 |
| Background monitor thread | 定期决定我们是否“卡住”。 |
渲染通过类似以下的辅助函数完成:
def _create_response_panel(
content: str,
use_markdown: bool = True,
show_stall_indicator: bool = False,
) -> Panel:
# …build a Rich Panel…
if show_stall_indicator:
content += "\n⋯ waiting for more"
return Panel(content)
当 show_stall_indicator=True 时,会追加旋转指示行。
“Stalled” 实际含义
有 两种 卡顿类型:
- Network stall – 没有新的内容从 LLM 传入。
- User‑visible stall – 屏幕上没有任何变化(UI 已经追上最新数据)。
在故意延迟渲染的系统中,这两者并不相同。
Opus 4.5 卡住的地方:只修复症状而非根本机器
Claude Opus 4.5 快速完成了第一部分:
- 添加时间戳,
- 监控已用时间,
- 当超过阈值时显示指示器。
它工作了……直到它不工作了。旋转指示器在文字仍在打印时仍短暂闪烁。
为什么?
卡顿检测器观察的是自上一次网络更新以来的时间,而 UI 仍在忙于动画化缓冲的文字。Opus 试图添加一个 _is_animating 标志来抑制指示器,但问题仍然存在。
真正的问题是两个并发写入同一 UI:
- 动画路径在打印文字时调用
Live.update()。 - 监控线程在切换指示器时调用
Live.update()。
没有序列化,渲染出不一致的中间帧,表现为指示器的闪烁。
Opus 陷入了局部最优:
- 把它当作时间问题处理,
- 把它当作单一布尔值处理,
- 不断添加保护。
我们真正需要的是状态 + 同步。
Source: …
GPT‑5.2 与众不同之处:把它当作状态机并使用单一渲染器
GPT‑5.2 并不是靠聪明取胜,而是靠严格取胜。它引入了三项决定性改动。
1️⃣ 序列化共享状态和所有 UI 更新
创建一个锁:
self._lock = threading.RLock()
规则: 任何触及共享状态 或 调用 Live.update() 的代码都必须持有该锁。
将渲染集中到单个辅助函数:
def _refresh_display(
self,
use_markdown: bool = False,
show_stall: bool = False,
) -> None:
with self._lock:
if not self._live:
return
self._live.update(
_create_response_panel(
self._animated_content,
use_markdown=use_markdown,
show_stall_indicator=show_stall,
)
)
self._showing_stall_indicator = show_stall
现在一次只能有一个线程修改 UI,消除了“两个线程争抢帧缓冲导致闪烁”这类 bug。
2️⃣ 将“卡顿”重新定义为“已追上且没有新输入”
卡顿仅在以下情况下可能出现:
- 我们当前没有动画,并且
- 动画输出已经追上我们收到的内容。
caught_up = (not self._is_animating) and (
self._animated_content == self._current_content
)
如果 caught_up 为 True 且可配置的超时时间已到,我们就认为系统卡顿并显示指示器。
3️⃣ 将指示器完全放在 UI 端
监控线程现在只设置一个期望的卡顿状态;实际的渲染决定在 _refresh_display 中完成。这种分离保证了旋转指示器仅在 UI 空闲时恰好出现,并在新文本到达的瞬间消失。
def _monitor_stall(self):
while self._running:
time.sleep(self._check_interval)
with self._lock:
if self._should_show_stall():
self._refresh_display(show_stall=True)
else:
self._refresh_display(show_stall=False)
结果:一个无聊‑但‑正确的加载指示器
- 没有闪烁。
- 没有 Markdown 损坏。
- 准确的“等待更多”信号,仅在 UI 真正空闲时显示。
整个过程把一个“简单”的功能请求变成了关于 状态机、同步以及对整个系统建模的重要性——而不仅仅是其症状 的教训。
修复“指示灯在单词仍在打印时仍亮着”错误
下面的单一定义解决了最初的问题,即即使缓冲的单词仍在打印时,卡顿指示灯仍会亮起。
注意: 如果 UI 仍在消耗缓冲的单词,你并未卡顿——你只是在忙碌。
3) 使用 “last receive time” 而不是 “last render time”
在修复第一个问题后,我们遇到了第二个更微妙的错误:
当流实际暂停时,指示灯会闪烁而不是保持常亮。
这是实时 UI 代码中的经典错误:在重绘期间更新 进度时间戳 会导致指示灯自行取消。
解决方案 (GPT‑5.2)
分离这两个概念:
# Only updated when new stream content arrives
self._last_receive_time: float = 0.0
仅在内容真正变化时在 update() 中更新它:
with self._lock:
if content == self._current_content:
return
self._last_receive_time = time.time()
监视器随后检查:
time_since_receive = time.time() - self._last_receive_time
should_show_stall = time_since_receive >= self._stall_threshold
结果:指示灯以正确的方式变为“粘性”:
- 在阈值后点亮,
- 持续保持点亮,
- 当有新文本到达时立即熄灭。
最终监视循环(可工作的无聊版本)
def _monitor_stall(self) -> None:
while not self._stop_monitoring.is_set():
if self._stop_monitoring.wait(0.5):
break
with self._lock:
if not self._started or not self._animated_content:
continue
caught_up = (not self._is_animating) and (
self._animated_content == self._current_content
)
if not caught_up:
continue
time_since_receive = time.time() - self._last_receive_time
should_show_stall = time_since_receive >= self._stall_threshold
if should_show_stall != self._showing_stall_indicator:
self._live.update(
_create_response_panel(
self._animated_content,
use_markdown=False,
show_stall_indicator=should_show_stall,
)
)
self._showing_stall_indicator = should_show_stall
关键属性
- 在缓冲的单词仍在动画播放时不显示指示器。
- 指示器仅在
stall_threshold时间内没有新内容到达后出现。 - 指示器一旦出现 会持续显示。
- 当有新文本到达时,指示器 会立即消失。
此时 spinner 不再是“功能”,而是基础设施——正是终端用户体验应有的表现。
真正的主题:为何切换模型是调试工具
我并不关心“模型之争”,但我关注的是使用它们的实际情况:
| 模型 | 优势 |
|---|---|
| Opus 4.5 | 能快速生成合理的实现并在请求时整理结构,但往往在增量修复上循环。 |
| GPT‑5.2 | 能退后一步,看到“两个作者 + 模糊的停顿定义”问题,并将解决方案强制为带序列化渲染的有限状态机。 |
这并不意味着某个模型在抽象意义上“更好”;而是它在特定的调试情境下更有用。当模型出现循环时,改变对话的形态——或者更换模型。在 Aye Chat 中,切换模型成本低,而“低成本”在你卡在只能 1 / 10 次复现的 UI 竞争条件时尤为重要。
要点(以及它们如何契合 Aye Chat 的理念)
-
一个 spinner 的正确性表面积比它应有的要大。
动画 + 监控 + 并发渲染是一个真实的系统。 -
“Stall” 是一种状态,而不是超时。
它必须表示“已追上且没有新输入”,而不是“经过了一段时间”。 -
不要让渲染去更新决定是否渲染的时钟。
那样就会产生闪烁。 -
当多个线程可以渲染时,锁定不是可选的。
即使没有崩溃,用户体验也会受影响。 -
模型选择是工具链的一部分。
当一个模型卡在局部修复时,另一个模型可能会看到全局形状。
以一种奇怪的方式,这个小小的 ⋯ waiting for more 指示器传授了与乐观工作流相同的教训:
- 让系统快速运行,
- 构建能够瞬间恢复的机制,
- 对帮助你摆脱困境的工具(包括模型)保持务实。
关于 Aye Chat
Aye Chat 是一个开源、AI 驱动的终端工作空间,可将 AI 直接引入命令行工作流。编辑文件、运行命令,并与代码库聊天 无需离开终端。
支持我们
- ⭐ 在我们的 GitHub 仓库点星
- 传播这个消息。与使用终端的团队和朋友分享 Aye Chat。