当模型卡住时:GPT‑5.2 完成了 Opus 4.5 无法实现的“简单”Spinner

发布: (2026年1月7日 GMT+8 00:54)
12 min read
原文: Dev.to

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 使用 RichLive)将响应流式输出到终端 UI。
我们也会模拟流式:即使提供者一次返回大量块,我们也会逐词动画化输出,使其看起来像“打字”。

真实的提供者行为如下

  1. 你收到 一些 token。
  2. 出现间隙(LLM 正在思考、服务器端暂停、背压)。
  3. 流式继续。

如果间隙出现在句子中间,用户会卡住。

四个看似简洁的需求

  • 在流式过程中检测 卡顿
  • 仅在卡顿期间显示 ⋯ 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” 实际含义

两种 卡顿类型:

  1. Network stall – 没有新的内容从 LLM 传入。
  2. 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️⃣ 将“卡顿”重新定义为“已追上且没有新输入”

卡顿仅在以下情况下可能出现:

  1. 我们当前没有动画并且
  2. 动画输出已经追上我们收到的内容。
caught_up = (not self._is_animating) and (
    self._animated_content == self._current_content
)

如果 caught_upTrue 且可配置的超时时间已到,我们就认为系统卡顿并显示指示器。

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 指示器传授了与乐观工作流相同的教训:

  1. 让系统快速运行,
  2. 构建能够瞬间恢复的机制,
  3. 对帮助你摆脱困境的工具(包括模型)保持务实。

关于 Aye Chat

Aye Chat 是一个开源、AI 驱动的终端工作空间,可将 AI 直接引入命令行工作流。编辑文件、运行命令,并与代码库聊天 无需离开终端

支持我们

  • ⭐ 在我们的 GitHub 仓库点星
  • 传播这个消息。与使用终端的团队和朋友分享 Aye Chat。
Back to Blog

相关文章

阅读更多 »