Chrome 扩展中 AI 流式输出为何中断
文章将流式回答中断归因于 Manifest V3 的跨域请求、单次消息响应和服务工作线程生命周期限制。通过调整请求位置、使用持续消息通道并考虑线程终止条件,定位扩展端的流式通信问题。
AI 深度解读
Chrome 扩展里的 AI 回答停在半句话,未必是模型出了问题。作者把这类故障归到 Manifest V3 的三组约束:请求从哪里发、消息通道能回复几次,以及 service worker 什么时候会被终止。
文章给出的方案是:服务端输出 SSE,service worker 转发流,content script 负责显示。对正在做网页内 AI 助手的开发者,最有用的是它把保活、取消和异常收尾串成了一条链路。但这是一套工程建议,原文没有提供修复前后的对照数据。
为什么网页里能跑的流式请求,放进扩展就会断
第一个问题是请求身份。content script 虽然由扩展注入,却代表所在网页发起请求。即使扩展配置了 host_permissions,也不能直接把 content script 的跨域请求变成扩展自己的请求。用户浏览的网站各不相同,你的 API 对这些页面通常都是跨域地址。
第二个问题是通信方式。sendMessage 只承载一次回复,不适合持续推送 token。作者提到的报错是 The message port closed before a response was received。流式响应需要使用 chrome.runtime.connect() 建立长连接端口。
第三个问题最容易被忽略:service worker 不保证一直运行。原文列出了三个终止条件:
- 连续 30 秒没有事件或扩展 API 调用。
- 单个事件或 API 调用运行超过 5 分钟。
fetch()的响应超过 30 秒才到达。
从 Chrome 114 起,长连接端口上的消息传输可以维持 worker 活跃,但只打开端口已经不会重置计时器。推理模型可能很久才吐出第一个 token,因此「端口已连接」和「端口有消息流动」是两回事。
保活靠持续传递消息,不能只靠建立连接。
不过,先确认 UI 在哪里。如果功能只存在于 side panel,作者认为普通的 fetch() 读取循环就够了。需要把结果显示在网页输入框或选区旁边时,才需要下面这套转发结构。
从服务端到页面,三层分别负责什么
服务端负责保存 API key、调用模型,并把结果转换成服务端发送事件(SSE)。密钥不能放进扩展包,因为安装包可以被解压查看。
原生 EventSource 也不能直接替代这里的请求逻辑:它使用 GET,不能设置自定义鉴权请求头,不适合通过 POST 请求体提交 prompt 的接口。作者选择 fetch() 配合 POST,再读取响应体流。
下面这段 Node.js 代码设置流式响应头,并立即写出首字节,避免请求一直等模型输出:
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'x-accel-buffering': 'no',
});
res.write(': open\n\n'); // 立即写出首字节
要看的是最后一行:不能把第一次写入拖到模型回答时。前面还有 nginx 时,x-accel-buffering: no 用于关闭代理缓冲,否则零散输出可能被攒成很晚才到达的一整块。
模型思考期间,服务端约每 15 秒发送一次心跳。服务端还应把 Anthropic 的 content_block_delta 和 OpenAI 的 response.output_text.delta 统一成简单事件,让扩展不用跟着不同供应商的格式变化。
service worker 负责发请求、转发文本和心跳,并把端口断开与取消请求绑定:
chrome.runtime.onConnect.addListener((port) => {
const ctrl = new AbortController();
port.onDisconnect.addListener(() => ctrl.abort());
// fetch(API_URL, { signal: ctrl.signal, ... })
// for each SSE event: port.postMessage({ type: 'delta' | 'ping' | 'done', ... })
});
这里最关键的是 port.onDisconnect:页面关闭后,请求应该跟着取消。注意,这只是结构示意,注释中的请求和事件处理没有实现;原文也没有在这段代码里展示完整 SSE 解析器。扩展还需要在 host_permissions 中列出 API 主机。
content script 则通过 chrome.runtime.connect() 发送 prompt、接收结果并追加显示。如果收到 onDisconnect 时还没收到 done,就保留已有文字并显示重试按钮,别让光标一直闪。
模型输出也不能直接当作可信 HTML。它可能复述网页中被植入的标记,因此应使用 textContent,或者对 Markdown 渲染结果做净化。更不能执行返回内容:Manifest V3 禁止远程托管代码,把服务端输出求值执行同样踩线。
真正需要验收的是取消、长回答和错误收尾
用户关闭界面后,理想链路是:端口断开,worker 取消 fetch(),服务端发现客户端断开,再取消上游模型调用。作者强调要专门测试这条路径,因为前端停止显示,不代表模型已经停止生成和计费。
worker 仍可能重启。需要恢复时,可以把部分输出存进 chrome.storage.session,但保存文字只解决了状态留存,原文没有说明如何接续上游生成。
超长回答也有边界。作者建议限制 max_tokens,并在最低支持的 Chrome 版本上测试最长的真实回答。至于 5 分钟限制怎样作用于长流,原文明确表示没有说清,不能据此承诺心跳可以无限续命。
中途出现 overloaded_error,或者使用了已退役的 model ID,都应该显示明确错误。把 model ID 留在服务端配置中,至少可以避免每次换模型都重新提交商店版本。
观测也放在服务端:记录首 token 延迟、到达 done 的完成率、断连与错误分类,以及每次请求的 token 数。流式输出改变的是等待体验,不会自动降低成本。
我的判断
我认可这篇文章把生命周期当作架构约束来处理。它最有价值的部分,是把请求、消息、代理缓冲和取消机制放在一起看;只修改某个前端读取循环,未必能解决整条链路的问题。
但作者没有给出浏览器版本矩阵、故障复现过程或完成率对照,约 15 秒的心跳间隔也属于工程建议。文章对 5 分钟边界保持了保留,这一点应该跟着保留。
方案还依赖团队能修改服务端和代理配置。仅靠扩展端转发消息,解决不了代理缓冲和上游取消。相比所有请求都经过 worker,只有 side panel 的功能直接在扩展页面请求,链路更短;相比一次性等待完整回答,流式方案则多出了事件解析、部分结果保存和中断状态管理的成本。
能拿来做什么
- 做网页内 AI 助手时,先确认请求是否误放在 content script,再检查是否用
sendMessage承担持续输出;需要流式转发时改用端口。 - 给 Node 服务增加立即写出首字节、约每 15 秒心跳和断连取消,并通过实际部署的代理验证数据是否及时到达。
- 验收时主动关闭标签页或面板,确认取消能够传到模型供应商,同时检查未收到
done时是否保留部分结果并允许重试。 - 上线前用
max_tokens约束最长回答,在最低支持的 Chrome 版本上测试,并记录首 token 延迟、完成率和每次请求的 token 数。