跳到主要内容

当流式 Token 遇到屏幕阅读器:生成式 UI 的无障碍债

· 阅读需 12 分钟
Tian Pan
Software Engineer

过去两年中最受赞誉的交互模式——文本逐字显现,仿佛机器正在放声思考——对于屏幕阅读器用户来说,这更像是噪音而非语言。你的模型输出的每一个 Token 都是一次 DOM 变更。如果在这个流中插入一个简单的 aria-live 区域,屏幕阅读器会尝试播报每一次到来的变更,从而产生断断续续、相互重叠的洪流,每秒钟会在句中重置几十次。使你的产品显得充满灵性的功能,恰恰是让那些依赖辅助技术的人无法使用它的元凶。

这就是无障碍债,而生成式 UI 积累这种债务的速度比以往任何界面模式都要快。其原因是结构性的:传统的 Web 内容是静态且可预测的,因此你可以推理一次后即发布。生成式界面在每次交互时都会发生变化——一个提示词返回列表,下一个返回表格,再下一个流式输出 600 字的文章,随后是一个工具调用组件。这里没有可供审计的固定 DOM,没有可验证的稳定 Tab 键顺序,也没有代表“页面”的单一快照。无障碍契约必须在无限的生成输出空间中保持有效,而几乎没有人为此进行测试。

这种代价并非假设。2024 年的一项 ACM 研究调查了六个完全由 AI 模型构建的网站,并记录了 308 个不同的无障碍错误——其中一半以上是认知失效(界面不可预测、令人困惑或内部不一致),其余则是对 WCAG 2.2 的严重技术违规。另一项分析发现,73% 的 AI 生成替代文本(alt text)是错误或毫无意义的,这比没有替代文本更糟糕,因为它会主动误导用户。生成界面的模型完全没有“谁在阅读它”的概念。

流式播报问题

从核心失效模式开始,因为这是大多数团队在没有察觉的情况下发布的问题。你希望向屏幕阅读器用户播报响应内容,因此你将响应容器包裹在 aria-live="polite" 中并以此告终。现在,每一个追加的 Token 都会触发一次播报尝试。

实际发生的情况取决于屏幕阅读器,而这种差异性本身就是问题所在。某些实现会对播报进行排队,结果导致播报进度完全落后于数据流。某些实现会在每次更新时中断当前的播报,导致用户听到每一个部分状态的片段,而听不到任何一个完整版本。如果在容器上设置了 aria-atomic="true",屏幕阅读器会在每个 Token 出现时从头开始重读整个响应——想象一下数百次地听到“法国的”、然后是“法国的首”、接着是“法国的首都”、最后是“法国的首都是”。用户无法跳过,无法进行有意义的暂停,也无法信任他们所听到的是最终内容。

解决方法是停止将“流”作为你播报的对象。可见的流是为视力正常用户提供的功能特征;它标志着进度并减少了感知的延迟。屏幕阅读器用户并不受益于这种“进度表演”——他们受益于一次性交付的清晰、完整、结构良好的答案。因此,请将两者解耦:

  • 在流式传输时,在响应区域设置 aria-busy="true",告知辅助技术在内容稳定之前保持静默。由于浏览器和屏幕阅读器组合的支持并不完美,因此不要仅依赖于此。
  • 当流式传输完成时,在 polite 动态区域播报一次响应内容——或者对于非常长的响应,每两到三秒进行一次防抖后的批量播报,这样用户就能在不被 Token 洪流淹没的情况下获取进度。
  • 渲染一个简短、静态的状态消息(“正在生成响应……”然后是“响应已就绪”),而不是将原始 Token 注入动态区域。状态是屏幕阅读器用户所需要的;Token 则不是。

Microsoft 自家的 Bot Framework WebChat 曾存在一个长期存在的问题:aria-live 区域位于滚动对话记录内部,导致屏幕阅读器误报并丢失新消息。解决方法是将动态区域完全移出对话记录。这个教训具有普适性:动态区域是一个专用的播报通道,而不是你眼睛正在阅读的同一个 DOM 节点。

动态区域比看起来更脆弱

即使你停止向其流式注入内容,ARIA 动态区域依然充满了快速演示中发现不了的陷阱。最常见的一个 Bug 是:动态区域必须已经存在于 DOM 中且为空,然后才能向其中注入内容。 如果你在同一次渲染中创建并填充该区域,许多屏幕阅读器会识别到该节点但从不触发播报——内容被直接忽略了。该区域必须先存在并处于闲置状态,然后再进行变更。

接下来是 politeassertive 的选择,团队经常选错。assertive 会中断用户当前正在听的任何内容;它仅适用于错误、安全提示和时间敏感的警报。如果在聊天响应中使用它,你会强行打断用户正在阅读的内容。polite 会等待停顿——这几乎总是生成式内容所需要的。将 role="alert"(等同于 assertive 加上 aria-atomic="true")留给真正的警报,并在对话更新中依靠 role="status"role="log",同时要接受 role="log" 的支持目前仍不完善的事实。

一个更微妙的陷阱:动态区域是瞬态的。它们完成播报后,对用户而言内容就消失了——无法导航回该内容。因此,对于用户可能想要重读的任何内容,动态区域都不是理想的归宿,也就是说,它不适合存放响应正文本身。持久的答案属于正常的文档结构,可以通过标题和地标进行导航。动态区域的唯一工作是通知答案已经到达。将“通知”与“包含”混为一谈,是导致很大一部分聊天 UI 无法访问的根本原因。

焦点在用户毫无察觉的情况下不断移动

文本流式传输只是第一个动态层面。现代生成式 UI 也会在对话过程中注入组件:工具调用结果渲染为交互式卡片、表单在聊天中显现、引用面板滑入、或者出现确认对话框来拦截智能体(Agent)的操作。其中的每一个都是 DOM 插入,都可能移动或夺取焦点 —— 而焦点是屏幕阅读器用户的光标,是他们对位置的全部感知。

失败模式是具体且常见的。用户正在阅读回复时,一个工具结果卡片插入到了他们当前位置的上方;他们的阅读点悄无声息地发生了偏移,导致他们失去了上下文。一个表单出现了,随着模型在后续 Token 中修改布局,表单字段也在自行重新排序 —— BRICS 经济分析记录了用户描述表单在“他们输入时随机重新排序字段”的情况,这直接违反了 WCAG 2.2 的“有意义序列(Meaningful Sequence)”要求。一个用于不可逆智能体操作的确认对话框弹出了,但从未获得焦点,因此屏幕阅读器用户甚至不知道自己被要求批准某些操作。

做对这件事意味着将焦点处理视为生成流水线的一等公民,而不是渲染器的后期补丁:

  • 绝不要因为被动内容的到达而移动焦点。 流式传输的回复不应夺取焦点;由用户决定何时导航到该内容。提供一种可预测的方式来跳转到最新消息(如跳转链接或键盘快捷键),而不是强迫跳转。
  • 针对需要响应的中断,有目的地移动焦点。 拦截智能体操作的确认对话框 应当 获取焦点,在开启时将其捕获(Trap),并在关闭时将焦点返回给触发元素。规则是基于意图的:被动更新绝不夺取焦点,阻塞性交互始终索取焦点。
  • 渲染后冻结布局。 生成的表单或卡片在首次绘制后不得重新排序其交互元素。如果模型产生了更好的结构,那应该是新的一轮对话,而不是对用户当前操作内容的实时突变。

为什么生成式技术让这变得极度困难

以上所有问题都可以通过已知技术解决。生成式 UI 仍然是一个独特且困难的无障碍挑战,原因在于其输出不是预先编写的 —— 它是由一个在目标函数中完全没有“辅助技术用户”概念的模型在运行时产出的。

当人类编写组件时,标题级别、替代文本(Alt Text)、aria-label、Tab 键顺序都会被编写一次并经过审查。当模型按需输出界面时,其中的每一项都是在每次请求时根据概率重新生成的 —— 模型会毫不犹豫地生成一个带有听起来合理但事实错误的替代文本的图表,或者将“列表”渲染为没有列表语义的样式化 div,或者连续出现四个 h1。你的无障碍性现在取决于模型的输出分布,而不是一个你可以签发的固定产物。这就是为什么 ACM 研究中的错误偏向于认知层面:界面不仅仅是缺少属性,而是 不一致 —— 同一个产品在不同轮次表现不同,这对于依赖可预测性的用户来说,本身就是 WCAG 级别的障碍。

实际的结果是,你不能像审计页面那样审计生成式 UI。你必须约束并验证生成过程本身。以下三个举措最为关键:

  • 约束输出表面。 不要让模型输出任意 HTML。让它从一个经过预先审查、无障碍审计的组件库中进行选择并渲染。模型决定选择 哪张 卡片;而你的代码负责语义、焦点行为和播报。这一个决定就能消除大部分长尾问题。
  • 在流水线中验证生成的语义。 在内容到达用户之前,通过程序检查那些低成本、高频率的失败:没有替代文本的图像、标题级别跳跃、没有无障碍名称的控件、不是列表的列表。进行拦截或修复,就像你验证任何不可信输出一样。
  • 针对实际数据流进行测试,而非最终状态。 已完成回复的快照可以通过审计,但实时流式体验可能会惨遭失败。在 内容流式传输和组件注入时,运行 NVDA、JAWS 和 VoiceOver 进行测试 —— 真正的 Bug 就潜伏在这里,而这正是自动化检查器永远无法看到的状态。

无障碍现在是一个生成问题

人们的本能是将这一切归类为“无障碍”问题,交给专家处理,并在发布前强行补上去。这种本能正是产生债务的原因。在生成式界面中,无障碍性不是最终渲染页面的属性 —— 它是关于你如何在不断变化的 DOM 中进行约束、流式传输、播报和管理焦点的属性。这些是架构决策,由构建流式传输和渲染层的工程师做出,一旦实时区域(Live Region)直接接入 Token 流,且组件随模型心意随处注入,事后补救几乎是不可能的。

有一个值得明确说明的好处。使生成式 UI 对屏幕阅读器可用的同一种准则 —— 拥有自有语义的受限组件库、可见流与播报结果之间的清晰分离、有目的的焦点管理、以及在到达用户前验证模型输出 —— 也会使界面对 所有人 来说都更可预测、更易测试且更健壮。将播报与 Token 流解耦,也能让你在不破坏体验的情况下更换渲染策略。掌控组件语义,也能防止模型向视力正常的用户交付损坏的布局。

那些将辅助技术支持视为构建优秀生成式 UI 架构的“强制函数”的团队,最终将获得全面更优的产品。而那些等待观望的人会发现,将 Token 流式传输到实时区域是最容易写的代码,也是最难解决的无障碍 Bug —— 并且这种漏洞,就像大多数无障碍技术债一样,从第一次提交时就存在了。

References:Let's stay in touch and Follow me for more thoughts and updates