流式响应中途网络中断时,屏幕阅读器用户会听到残缺片段;作者用状态机为不同失败节点设计差异化播报与焦点行为,显著提升无障碍体验。
流式响应出错时,需要的是一套播报策略,而不是一个重试按钮
上周我用最无聊的方式把自己的聊天 UI 搞坏了:把网络限速到"慢速 3G",发了一条消息,看着三个 token 流进来,然后把笔记本搬出了 Wi-Fi 范围。
视觉效果上,结果还行——流式停止了,错误卡片出现了,重试按钮也显示了。但当我打开 VoiceOver 重复这个测试时,体验就是一塌糊涂:屏幕阅读器正在朗读部分回复的中间一句话,此时错误区域触发了,随后播报新 token 的 live region 永远沉默了,当我点击重试时,焦点跳转回了输入框,而新的回复开始流入一个我已经不再聚焦的消息气泡里。一个视力正常的用户看到的是一个完整的故事:失败了,重试了,恢复了。但屏幕阅读器用户听到的是三个互不相关的碎片。
问题不在于更多的 ARIA 属性,而在于提前决定每个失败节点该播报什么——也就是一套播报策略——然后把它接入一个带类型的状态机。
失败不是"流式失败了"这么简单,而是"在哪里"失败了。
流式响应可以在非常不同的时刻终止,而每一种都需要一段不同的语音播报:
| 失败发生的位置 | 首次失败播报 | 重试成功恢复播报 |
|---|---|---|
| 首 token 之前 | "请求失败,响应尚未开始。没有内容丢失。" | "已恢复,响应继续中。" |
| 流进行中 | "响应在中途停止,已收到 N 个字符。可以重试。" | "响应从停止处继续。" |
| 完成之前(接近尾声) | "响应可能不完整。可以重试。" | "响应已恢复,这是完整的回复。" |
中间那几行是最让人不舒服的。如果你播报了"正在重试"然后立刻恢复 token 播报,屏幕阅读器用户分不清新的 token 是延续了之前的思路还是替换了它。这种区别必须说出来,而不是靠暗示。
状态机本身很小。策略是一个纯函数,输入是(failurePoint, attempt),输出是一段播报字符串,所以不需要浏览器就能测试:
type FailurePoint =
| "before-first-token"
| "mid-stream"
| "pre-completion";
type StreamState =
| { phase: "idle" }
| { phase: "streaming"; chars: number }
| { phase: "failed"; at: FailurePoint; chars: number; attempt: number }
| { phase: "retrying"; at: FailurePoint; chars: number; attempt: number }
| { phase: "exhausted"; at: FailurePoint; attempt: number }
| { phase: "done"; chars: number };
function announcement(
event: "fail" | "retry-start" | "retry-resume" | "retry-replace" | "exhausted",
s: Extract<StreamState, { phase: "failed" | "retrying" | "exhausted" }>
): string {
switch (event) {
case "fail":
if (s.at === "before-first-token")
return "The request failed before the response started. Nothing was lost.";
if (s.at === "pre-completion")
return "The response may be incomplete. Retry is available.";
return `The response stopped partway after ${s.chars} characters. Retry is available.`;
case "retry-start":
return "Retrying.";
case "retry-resume":
return "The response continued where it stopped.";
case "retry-replace":
return "The previous partial response was replaced by a new one.";
case "exhausted":
return `Retry failed again after ${s.attempt} attempts. You can start a new message or try again later.`;
}
}
两个细节很重要:
"resume"和"replace"是不同的播报。如果你的重试是再次发送 prompt 并流式输出一段全新的答案,那就这么说。如果它是延续了同一个流(比如通过一个可恢复的端点),那就播报那个。别忘了屏幕阅读器用户是根据播报内容来构建心理模型的;对模型说谎会产生你之后无法调试的困惑。
每次状态转换播报一次,不要每个 token 都播。正常流式传输时的 token 更新应该已经被节流了(常见模式:在视觉隐藏的摘要节点上使用aria-live="polite",每约 500ms 更新一次,而不是直接放在原始 token 容器上)。错误播报复用同一个摘要节点,这样它们会礼貌地排队,而不会在朗读到一半时被打断。
Live region 的接线方式:
<!-- 一个摘要节点,供所有流式播报共用 -->
<div id="stream-status" class="sr-only" role="status" aria-live="polite"></div>
<!-- 错误卡片不是 live region。它是可聚焦的内容。 -->
<div class="error-card" role="alertdialog" aria-labelledby="err-title" hidden>
<p id="err-title">Response interrupted</p>
<button type="button" data-action="retry">Retry</button>
<button type="button" data-action="dismiss">Dismiss</button>
</div>
注意这个分离:role="status"负责播报;错误卡片提供操作。把两者合并到一个role="alert"元素里,就是你最终得到一个按钮——它能被播报但键盘永远够不到,直到用户一个个摸索过去。
这里是大多数测试装置作弊的地方。一个恰好在 200ms 后失败的 mock,永远产生不了那些丑陋的边界情况:第三个 token 之后失败、在上一个失败的播报过程中失败、在用户的屏幕阅读器仍在朗读部分文本时失败。
在写这篇文章时,我把 UI 指向了一个真实的流式模型,所以 token 节奏是真实可信的。我用了 MonkeyCode 上的免费模型访问来做上游——重点不是模型本身,而是真实的 token 到达间隔(突发、不规律、偶尔会在句子中间暂停)才会暴露播报堆积的问题。披露:本文是作为 MonkeyCode 产品推广的一部分准备的。
然后,为了让失败变得可复现而不是靠运气,我在 UI 和模型端点之间运行了一个微小的失败注入代理。MonkeyCode 的免费服务器选项足以托管它——这个代理约 40 行代码,不需要 GPU,只是转发流并按计划"谋杀"它:
// proxy.ts — 运行方式: FAILURE_MODE=mid-stream deno run --allow-net proxy.ts
// 在确定性位置注入真实的网络级中止。
const mode = Deno.env.get("FAILURE_MODE") ?? "mid-stream";
const UPSTREAM = Deno.env.get("UPSTREAM_URL")!;
Deno.serve({ port: 8787 }, async (req) => {
const upstream = await fetch(UPSTREAM, {
method: "POST",
headers: req.headers,
body: req.body,
});
if (!upstream.body) return new Response("no body", { status: 502 });
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const reader = upstream.body.getReader();
let chunks = 0;
const killAfter = mode === "before-first-token" ? 0 : mode === "pre-completion" ? 12 : 3;
(async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (chunks++ === killAfter) {
// 突然终止:没有关闭帧,没有错误 JSON。就像真实的 Wi-Fi 断连。
await writer.abort(new Error("injected network failure"));
return;
}
await writer.write(value);
}
await writer.close();
})();
return new Response(readable, {
headers: { "content-type": "text/event-stream" },
});
});
现在FAILURE_MODE=mid-stream让你在每次运行时都得到相同的失败,无论是在 CI 还是在你桌上,且上游有真实模型节奏驱动。你的 Playwright 测试可以确定性地产出播报断言:
test("mid-stream failure announces char count and keeps focus", async ({ page }) => {
await page.goto("/chat");
await page.getByRole("textbox", { name: /message/i }).fill("hello");
await page.keyboard.press("Enter");
const status = page.getByRole("status");
await expect(status).toContainText(/stopped partway after \d+ characters/);
// 焦点绝对不能从输入框被夺走
await expect(page.getByRole("textbox", { name: /message/i })).toBeFocused();
// 重试在错误卡片里,可以通过键盘操作
await page.getByRole("alertdialog").getByRole("button", { name: "Retry" }).click();
await expect(status).toContainText("Retrying.");
});
把测试套件跑三遍——每种 FAILURE_MODE 各一遍——你就覆盖了策略表的每一行。
播报行为在不同的浏览器/辅助技术组合之间差异巨大,所以"在 Chrome 里用 VoiceOver 测试通过了"不是一个测试结果。在宣布完成之前最少要跑以下矩阵:
| Chrome + VoiceOver | Safari + VoiceOver | Firefox + NVDA | Edge + JAWS | |
|---|---|---|---|---|
| 首 token 之前失败 | ||||
| 流中途失败 | ||||
| 完成前失败 | ||||
| 重试后恢复 | ||||
| 重试后替换 | ||||
| 重试耗尽 |
这建立在你控制流式协议的基础上。如果你的提供商 SDK 隐藏了 chunk 边界,你就无法可靠地区分"流中途"和"完成前"——你将不得不把这两行合并成一个更模糊的播报。
可恢复的流在实践中很罕见。大多数 API 会让你重新发送 prompt,所以你现实中的播报通常是"被替换了",而且你的 UI 应该在视觉上对气泡做 diff 或版本化,这样播报才能和实际发生的变化对上。
不要为了"节省一个节点"把错误卡片放进 live region 里。你会在每次重试 tick 时重新播报它。
如果你的产品是一个非交互式的日志查看器(CI 输出、遥测数据),这些都不适用——日志里的一行错误就是全部的策略。而且如果你的重试需要页面刷新,先把那个修了;没有任何播报策略能在刷新后存活。
如果你想试试这个方案,MonkeyCode 上的免费模型层级和免费服务器足以支持上面的代理加真实上游这套装置——但任何流式端点加上这个代理都可以;策略表才是真正的产物。
如果你复现了这个问题,而且遇到我表格里漏掉的转换,我真的很想知道:把你的浏览器、操作系统和屏幕阅读器版本以及确切的错误转换(播报错了或者没播报)在评论里告诉我。