基于Fetch流复制的Chrome插件:让AI流式接口调试一目了然
2026/9/15 4:39:47 网站建设 项目流程

说个我自己真实的开发日常:每次在 Chat 类应用里调试 AI 接口的流式输出,打开 Network 面板,看到那一坨挤在一起的data: {"choices":[{"delta":{"content":"你"}}]},再往下翻还有第二条、第三条,最后整个响应体被拉成一根超长的“数据香肠”,我内心基本是崩溃的。你说它没信息吧,信息全在;你说它有信息吧,根本没法看。

这个场景在接大模型流式接口时太常见了,前端要处理text/event-stream,后端返回来的是按事件流切分的数据,可浏览器的开发者工具根本没把这当成一等公民来展示。我忍了很久,最后花了一个周末自己写了个开源 Chrome 插件,专门解决“AI 流式调试看不清、理不顺、查不快”的问题。这篇博文就把它完整拆开聊聊:为什么这么设计、核心代码怎么写的、实际用起来会遇到哪些坑。

1. 为什么 Network 面板里的 SSE 让人想打人

1.1 一坨 data: 到底长什么样

先还原一下现场。你在页面上问了大模型一句“讲个冷笑话”,背后实际发生的是这样的请求流:

data: {"choices":[{"delta":{"role":"assistant"},"index":0}]} data: {"choices":[{"delta":{"content":"好的"},"index":0}]} data: {"choices":[{"delta":{"content":","},"index":0}]} data: {"choices":[{"delta":{"content":"说一"},"index":0}]} data: {"choices":[{"delta":{"content":"个"},"index":0}]} ...(中间省略几百条) data: [DONE]

在 Chrome 的 Network 面板里,这些内容会作为一个超大响应体的原始文本堆在一起。你没法按事件粒度去看,也看不出哪条对应哪个时间点,更别说想统计“第一 token 多久出现”“平均每秒返回多少条事件”这种基础指标了。唯一能做的,是手动复制出来,再用一个 JSON 工具把每行慢慢拆开。

如果只是看一眼还好,但联调 AI 接口时,这种原始视图的短板会被放大得非常明显。

1.2 原生工具的四个短板

我自己总结了一下,Chrome 原生 Network 面板在 SSE 流式调试场景里主要有四个问题:

一是语义缺失。SSE 里面的核心是“事件”:它有 event 字段、有 data 字段、有 id 字段,多个 data 行会拼接成一个完整事件,事件之间用空行隔开。但 Network 面板不关心这套协议,它只把响应体当成普通文本渲染,用户直接面对的是满屏的data:前缀。

二是响应体持续追加导致 UI 卡顿。AI 接口的流式响应往往几十秒甚至几分钟不停输出,Network 面板在渲染这个超长字符串时,会不断触发滚动和重绘。响应体越大越卡,最后你连复制文本都费劲。

三是无法定位中断点。流式请求最常见的故障是“流到一半断了”,前端报个stream disconnected before completion,后端说我没断,网络层说我不知道。你在 Network 面板里看到的只是一段戛然而止的文本,根本不知道断在第几条消息上,更看不出前后时间戳。

四是没有统计维度。调试 AI 流式接口时,我们想知道的东西很多:首 token 延迟是多少、平均每秒多少事件、累计消耗了多少字符、哪段时间输出最快。原生面板什么都没有,想量化只能自己写脚本。

1.3 插件方案选型:为什么是“hook fetch + 流复制”

既然原生不行,那就自己做。但在动手之前,我认真对比过几条技术路线。

第一个想到的是chrome.webRequest。它在 MV3 里只能看到请求头和部分元信息,拿不到响应体,更拿不到流式的增量数据,直接排除。

第二个想到的是chrome.debugger(也就是 CDP,Chrome DevTools Protocol)。这个方案理论上能监听到网络事件,但有两个问题:一个是它会在页面上方弹出“扩展正在调试此浏览器”的提示条,用户体验很重;另一个是它拿流式响应体仍然很别扭,Network.getResponseBody主要面向请求结束后的完整 body,实时流式场景并不是它的强项。所以这个方案也被我否了。

第三个是chrome.devtools.networkAPI。它的问题是只能在 DevTools 面板的扩展里用,而且getHAR()拿到的也是完整响应,对实时流没有感知。

最后我选了一条看起来更“绕”但实际非常稳的路线:在页面主世界注入脚本,给window.fetch加上一层包装,检测到text/event-stream类型的响应后,用ReadableStream.tee()把响应体拆成两条管道,一条还给页面原来的调用方继续消费,另一条由插件自己解析、渲染、统计

这个方案复杂度可控,不依赖调试协议,也不会影响页面正常功能。而且它对 SSP 流式接口的兼容性最好,因为它是直接站在页面自身的 HTTP 消费链路上“偷看”数据,页面能拿到什么,插件就能拿到什么。

2. 插件拆解:Manifest、注入脚本和执行流

2.1 整体架构

插件的整体结构分四层:

  • 注入层inject.jsdocument_start阶段注入一个<script>标签,加载真正的核心脚本hook.js到页面主世界。
  • 拦截层hook.js负责包装window.fetch,通过tee()复制流式响应,并做 SSE 逐行解析。
  • 通信层:页面主世界不能直接调扩展 API,所以hook.js通过window.dispatchEvent(new CustomEvent(...))把解析好的事件抛出去;content script 在 isolated world 里监听这个事件,再转发到面板 iframe。
  • 展示层:在页面右上角挂一个 iframe 面板,负责列表渲染、状态统计、JSON 高亮,不污染页面本身的样式和布局。

为什么不在 content script 里直接改window.fetch?因为 content script 运行在 isolated world,虽然和页面共享 DOM,但 JavaScript 上下文是隔离的,它改动window.fetch对页面主世界里的代码不生效。所以必须通过动态插入<script src="chrome-extension://.../hook.js">的方式,把代码真正塞进页面上下文。

2.2 manifest 配置与权限设计

manifest v3 的配置有几个关键点。content_scripts必须要设置"run_at": "document_start",因为如果注入太晚,页面里早期的 fetch 请求已经被发出去了,就抓不到了。host_permissions<all_urls>,这样任意站点都能用;不过要接受一点,安装后浏览器会提示插件可以读取所有网站的数据。

{ "manifest_version": 3, "name": "SSE Stream Inspector", "version": "0.1.0", "description": "AI 流式接口调试插件:把 Network 面板里那一坨 data: 变成可读的事件列表", "permissions": ["storage"], "host_permissions": ["<all_urls>"], "content_scripts": [ { "matches": ["<all_urls>"], "js": ["inject.js"], "run_at": "document_start", "all_frames": true } ], "web_accessible_resources": [ { "resources": ["hook.js", "panel.html", "panel.js"], "matches": ["<all_urls>"] } ], "action": { "default_popup": "popup.html", "default_title": "SSE Stream Inspector" } }

web_accessible_resources这里很容易漏,没有它的话chrome.runtime.getURL('hook.js')加载的脚本会被页面的 CSP 直接拦住,导致插件完全不工作。

2.3 主世界注入:绕过 CSP 的关键一步

inject.js的写法其实很固定:

// inject.js const script = document.createElement('script'); script.src = chrome.runtime.getURL('hook.js'); script.dataset.sseStreamInspector = 'true'; script.onload = function () { this.remove(); }; (document.head || document.documentElement).appendChild(script);

很多人在这一步踩坑:页面如果设置了严格的script-srcCSP,直接内联<script>会被拦截。用src指向扩展资源是一个相对稳妥的办法,因为 MV3 的web_accessible_resources机制就是为这种场景设计的。

理论上也可以用chrome.scripting.executeScript配合world: 'MAIN'来做主世界注入,代码更干净,但需要动态声明权限,还要处理被注入页面的时序问题。我最后保留了<script>标签方案,兼容性最好,逻辑也直观。

3. 核心实现:从 fetch 到事件流的完整链路

3.1 给 fetch 加一层“分流器”

hook.js里的核心逻辑是包装window.fetch。每次请求发出后,在 Promise 的 then 里检查响应头,发现是text/event-stream就走分流逻辑。

这里我“费过一番脑筋”:不能直接在原Response上做文章,因为 Response 的 body 只能被消费一次。必须用tee()把流复制成两份,一份用来构造新的 Response 返回给调用方,一份插件自己拿着解析。

const originalFetch = window.fetch; window.fetch = function (...args) { return originalFetch.apply(this, args).then((response) => { try { const contentType = response.headers.get('content-type') || ''; if (contentType.includes('text/event-stream') && response.body) { const [pageStream, inspectorStream] = response.body.tee(); consumeEventStream(inspectorStream, response.url); return new Response(pageStream, { status: response.status, statusText: response.statusText, headers: response.headers }); } } catch (e) { // tee 失败(比如流已被锁定),回退到原始响应,不阻塞业务 } return response; }); };

一个非常容易踩的坑:tee()出来的一条流如果不去读它,数据会堆积在内部队列里产生背压,最终导致页面原本的 fetchReadableStream也不往下走了。表现就是 AI 界面一直转圈、不出字。所以“插件自己消费这条流”不是可选项,而是必须项。

3.2 SSE 解析器:处理多行 data、注释行和 chunk 跨界

拿到了流,下一步是把它切成一个个事件。SSE 的协议规则是:

  • 事件之间用空行\n\n分隔
  • 每一行格式是field: value
  • 常见字段有dataeventidretry
  • 以冒号开头但不带字段名的行是注释行,应该忽略
  • 同一个事件里可以有多个data行,它们用换行符拼成一个完整数据

这里最大的坑是chunk 跨界。网络传输时一个 SSE 事件可能被切成好几个 TCP 包,reader.read()拿到的 chunk 边界和事件边界完全不对齐。所以必须维护一个字符串缓冲区,把每次拿到的数据先追加进去,再尝试从缓冲区里提取完整事件。

function consumeEventStream(stream, url) { const reader = stream.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; function send(message) { window.dispatchEvent(new CustomEvent('sse-inspector-event', { detail: { url, ...message } })); } function pump() { reader.read().then(({ done, value }) => { if (done) { send({ type: 'end', time: Date.now() }); return; } buffer += decoder.decode(value, { stream: true }); let boundary; while ((boundary = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const parsed = parseSSE(rawEvent); if (parsed && parsed.data) { send({ type: 'event', id: parsed.id, eventName: parsed.event, data: parsed.data, retry: parsed.retry, time: Date.now() }); } } pump(); }).catch((error) => { send({ type: 'error', error: String(error), time: Date.now() }); }); } pump(); }

解析器parseSSE严格按协议逐行拆分,遇到data:后面的空格要主动去掉,遇到以:开头的注释行直接跳过,否则会把注释行误当成空数据往上抛。

function parseSSE(raw) { const event = { id: '', event: 'message', data: [], retry: undefined }; const lines = raw.split(/\r?\n/); for (const line of lines) { if (line.startsWith(':')) continue; // 注释行直接忽略 const colonIndex = line.indexOf(':'); if (colonIndex === -1) continue; const field = line.slice(0, colonIndex); let value = line.slice(colonIndex + 1); if (value.startsWith(' ')) value = value.slice(1); if (field === 'data') { event.data.push(value); } else if (field === 'event') { event.event = value; } else if (field === 'id') { event.id = value; } else if (field === 'retry') { event.retry = parseInt(value, 10); } } return event; }

还要处理一种情况:有些后端的 event-stream 里会混入 BOM 字符,尤其是 Spring Boot 的响应偶尔会带上\ufeff。所以在解析第一行 data 前,最好判断一下开头是否是\ufeff并剔除。

3.3 页面与扩展的通信管道

hook.js跑在页面主世界,消息发不出去,必须借由 content script 中转。做法是用CustomEvent把解析结果挂在 window 上,content script 监听同名事件,再通过postMessage发到面板 iframe。

// content script 内部 window.addEventListener('sse-inspector-event', (e) => { const panel = document.getElementById('sse-inspector-panel'); if (panel && panel.contentWindow) { panel.contentWindow.postMessage({ source: 'sse-inspector', payload: e.detail }, '*'); } });

这里有一个实践经验:不要在事件回调里做复杂逻辑,更不要直接chrome.runtime.sendMessage每来一条消息就发一次。SSE 密集的时候一秒可能有几十条事件,每条都走扩展消息通道会产生明显延迟。用postMessage打到 iframe 里,由面板自己批量渲染,是性能上最稳的组合。

3.4 面板渲染与性能优化

面板是一个 iframe,悬浮在页面右上角,里面维护一个事件列表。列表项包含:时间、序号、event 类型、data 摘要、耗时。如果 data 是 JSON,就尝试JSON.parse,能解析的话再做字段提取。

AI 接口的事件大多是{"choices":[{"delta":{"content":"..."}}]}这种结构,我特意做了个逻辑:解析成功后优先找delta.content,有就渲染成“文本增量”,没有就尝试message.content,再不行就看tool_calls这类函数调用参数,反正都是给调试场景服务的,怎么方便怎么来。

性能方面要坚持两条原则。第一,内存里只留最近 1000 条事件,用环形缓冲区实现,防止长时间联调把内存撑爆。第二,渲染用requestAnimationFrame合并,同一帧内到达的几十条事件只做一次 DOM 更新,避免对列表造成“轰炸式”重绘。

let pendingItems = []; let rafId = null; function pushItem(item) { pendingItems.push(item); if (rafId) return; rafId = requestAnimationFrame(() => { appendToPanel(pendingItems); pendingItems = []; rafId = null; }); }

实测下来效果很好,事件密集到每秒 50 条时面板也能保持流畅。

4. 实操中会踩的坑和排查清单

4.1 流挂起、没输出的检查顺序

我把这个坑放在第一位,因为它最容易让插件“看起来坏了”。现象是:页面 AI 回复正常,但插件面板什么都不显示,或者更糟,页面本身转圈不输出了。

先检查hook.js是否真的注入了。打开页面控制台,输入window.__sseInspectorLoaded,我一般在注入脚本最后会打一个标记变量,没有的话就说明资源没注入成功,重点看web_accessible_resources配置。

再检查tee()出来的 inspector 流是否被消费。如果只是tee()却没有reader.read()的循环,流的内部队列会被填满,最终背压到页面的原始流上。这个点必须靠代码保证:只要走了分流分支,就一定要有人去读那条流。

还有一种情况是响应头里没有content-type: text/event-stream,或者被代理改成了application/json。有些后端框架会先返回一个 200 加上content-type: text/event-stream; charset=utf-8,有些会漏掉charset,判断条件里最好用includes而不是相等判断。

4.2 流断开的错误解读与标记

AI 接口联调最讨厌的错误就是流“静默死亡”。页面层报stream disconnected before completion或者network error: error decoding response body,其实对应到插件侧是两类情况:

  • 一类是reader.read()的 Promise reject 了,说明底层网络连接中断或者解码失败。
  • 另一类是流没有报错,但迟迟拿不到done信号,说明服务端开了连接但一直不推数据。

我在面板里会区分这两种:断流事件标红,挂起事件标灰,并且记录最后一条事件的时间戳。这样排查时能很直观地看出是服务端不发数据、还是传输中间断了、还是客户端处理报错。

4.3 其他常见问题速查表

现象可能原因解决办法
面板不出现插件注入失败检查web_accessible_resources,刷新页面重试
页面 AI 回复正常但面板无数据fetch 包装时机太晚确认run_atdocument_start
流数据重复页面同时用了 XHR 和 fetch 两套只能按需拦截,不建议同时 hook 两套
事件乱序SSE 本身无序,服务端多线程推送面板按到达时间排序,显示相对时间差
打开插件后内存涨很快没有环形缓冲限制最大事件数,超出后丢弃旧事件
data 里的 JSON 被截断缓冲区切分逻辑有误检查\n\n边界处理,确认多 data 行拼接
事件源来自 XHR插件只 hook 了 fetch在代码里额外包装XMLHttpRequest

另外补充一个细节:部分站点会用EventSource对象发起 SSE 请求,它不走window.fetch,所以 fetch 的包装对它无效。想覆盖这个场景,需要把window.EventSource也包一层,监听它的onmessageonerror以及内部请求。我在插件的较新版本里加了这个能力,代码结构和 fetch 包装类似,但要注意别影响原始EventSource的实例行为,建议包装完返回原实例的方法和属性。

对我个人来说,这个插件解决的不只是“看清楚 data:”的问题。它让我从“盯着原始文本猜测”变成了“看着事件列表直接判断”:服务端有没有推、推了多少、首 token 延迟多久、断在哪一条。做 AI 应用联调的时候,这种粒度才是我真正需要的。代码我已经整理到 GitHub 开源仓库,搜sse-stream-inspector就能找到。如果你也在被 AI 流式调试折磨,直接拿去用,顺手提个 issue 或者 PR 都行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询