从第一次调大模型流式接口开始我就在 Chrome 的 Network 面板里反复受折磨。每次流式响应刷出来一屏一屏的data:夹杂着 JSON 片段、[DONE]、莫名其妙的event:我至少要花一半时间在“从垃圾堆里捞有效信息”。后来我直接把这种场景总结成一句话用 Network 面板调 AI 流式接口本质是在考古一堆碎片。所以我自己写了一个开源 Chrome 插件sse-devtools-panel专门解决 AI 流式调试的痛点。这篇博文就把整个插件的设计思路、实现细节、踩坑过程全部摊开讲给正在被 SSE 和流式响应折磨的开发者参考。这个插件能做的事很直接在 DevTools 里新增一个独立 Panel把页面里的 EventSource 连接、fetch 流式响应全部截获把协议层的data:碎片解析成结构清晰的事件列表渲染出每个事件的时间戳、类型、内容、耗时你甚至能直接看到某个 token 是什么时候到达的、请求什么时候重连的、哪一帧数据触发了错误。适合前端、后端、AI 应用开发者特别是要跟大模型流式输出、智能体事件流打交道的人。1. AI 流式响应为什么在 Network 面板里“看吐了”1.1 SSE 协议本身长什么样SSEServer-Sent Events在协议层其实不复杂它的 MIME 是text/event-stream服务端可以持续向客户端推送数据。一个典型的 SSE 帧长这样event: message data: {choices:[{delta:{content:你好}}]} id: 1720070000001 event: done data: [DONE]这里的关键是帧与帧之间用空行分割每行格式是字段: 值data:可以出现多行并拼成一条数据event:指定事件类型id:用于断线重连时传 Last-Event-ID还有以冒号开头的注释行一般用作心跳。你可以把 SSE 想成餐厅后厨的出餐小票打印机普通 HTTP 响应是一整本菜单一次性给你SSE 是后厨每做好一道菜就单独打一张小票递出来而打印纸一旦开始走就不会停。这个机制本身没什么问题问题出在浏览器开发者工具对它的呈现上。1.2 原生 Network 面板缺了哪三块第一块是流式过程不可视。Chrome 原生 Network 面板默认等整个请求完成之后才把响应体展示给你Preview标签页会把所有data:行混成一整坨文本。流式界面在你面前一个字一个字蹦但调试工具里看到的永远是一个终态尸体。第二块是事件结构被压平。SSE 协议本身是有结构的event 类型、data 内容、id、retry 时间、注释行。但 Network 面板把这些全部拍扁成一个普通响应体你想筛选出error类型的事件只能靠眼力在几 MB 文本里人肉搜索。第三块是连接状态无法观测。EventSource 的 readyState 从 CONNECTING 到 OPEN 再到 CLOSED以及断线重连次数、Last-Event-ID、可能吞掉错误帧的静默失败这些关键信息在原版面板里完全没有。AI 应用最容易出的问题恰恰在这里服务端推流中断了前端 EventSource 在自动重连你在 Network 面板里只能看到一个 pending 了半天的请求。1.3 AI 流式场景独有的恶心点AI 流式输出在 SSE 之上还叠加了一层“方言”。不同厂商接口返回的格式五花八门有的是标准的data: {choices...}有的是每行一个裸 JSON没有data:前缀结束标记可能是data: [DONE]也可能直接断连。再加上现在很多 AI 网关会用 fetch ReadableStream 自己实现流式不走 EventSource这个时候你在 Network 里看到的就不是text/event-stream而是application/json或者text/plain原版面板连“这是一个流”都识别不出来。我自己实际调过的一个智能体服务错误信息是通过event: error帧下发的而不是data: {error:...}。原生 EventSource 的默认onmessage根本收不到error事件前端 UI 毫无反应。我在 Network 面板里盯了十分钟才从一堆data:里翻到那条event: error。那一刻我就决定必须写一个面板插件专门干这件事。2. 技术选型从 DevTools API 到 MAIN world 注入的迭代2.1 第一版chrome.devtools.network 只能看尸体最开始我用的是 Chrome 扩展的chrome.devtools.networkAPI这是最顺手的入口。只要在devtools.js里注册监听请求结束就能拿到响应体chrome.devtools.network.onRequestFinished.addListener((request) { if (request.response.content request.response.content.mimeType text/event-stream) { request.getContent((content) { // 只能在请求结束后拿到完整 content console.log(content); }); } });这套 API 的优点是接入成本低但很快我就发现它做不了实时调试。onRequestFinished这个名字已经说明了问题请求完成之后才会触发。对于 SSE 这种持续连接它要等到服务端断流或者客户端关闭连接之后才给你完整响应体完美绕过了“流式”两个字的意义。第一版做出来之后我只用了一天就放弃了。它的定位不是调试工具而是“事后取证工具”。如果你只是想批量导出几个请求的响应体做离线分析它够用如果你想实时看到每个 token 的到达过程它完全不合格。2.2 第二版chrome.debugger 的 Fetch 域差点把我劝退想要在流式过程中拿到数据我第二个想到的是chrome.debuggerAPI也就是 CDPChrome DevTools Protocol。里面有Fetch域可以拦截请求并接管响应理论上能把响应 body 以流的形式逐步读出来。我尝试了用Fetch.enable监听所有请求命中目标后进入 paused 状态然后通过Fetch.takeResponseBodyAsStream拿 IOStream 再分块读const target { tabId: tabId }; chrome.debugger.attach(target, 1.3, () { chrome.debugger.sendCommand(target, Fetch.enable, { patterns: [{ urlPattern: *, requestStage: Response }], handleAuthRequests: false, }); });这条路能通但代价非常大。首先它会拦截页面所有请求需要自己维护请求白名单和放行逻辑其次它把请求挂起再恢复的性能开销会让流式体验卡顿本来 30ms 一帧的 token 推送被拖到几百毫秒再有就是 CDP 在Fetch域下处理重定向、认证请求时会有一堆边界情况写着写着就变成了“我在调试我的调试工具”。我折腾了大概两天最后把这个方案放弃了。原因是它太“侵入式”而且本身依赖 DevTools 附加调试会话用户如果同时在调试页面两边会打架。2.3 最终方案main world 注入 消息桥最后我换了一个思路既然我要看的是页面里的 EventSource 和 fetch 流那不如直接在页面主世界MAIN world里包装原生 API把流式数据实时截获再通过消息通道送到 DevTools Panel。整体数据链路是这样的扩展通过chrome.scripting.executeScript向目标页面注入hook.js指定world: MAIN这样脚本可以访问并改写页面的window.EventSource和window.fetch。hook.js包装原生 API在每次收到 SSE 帧、fetch chunk、连接状态变化时通过window.postMessage发给 content script。content script 收到消息后用chrome.runtime.sendMessage转发给 service worker。service worker 再把消息广播给 devtools panel。这个方案最大的优势是完全不影响原请求的生命周期。EventSource 的监听是只读的fetch 的流式读取用的是clone()出来的副本原始响应流照常给页面用。实时性也够消息链路全程在扩展内部延迟基本可以忽略。架构看起来简单但实际踩坑的点也不少下面几章我把关键代码和解析细节都贴出来。3. 数据采集与解析从字节流到结构化事件3.1 如何在 main world 里把数据“搬”进面板先看 Manifest 配置。这是 MV3 的写法和权限{ manifest_version: 3, name: sse-devtools-panel, version: 0.1.0, devtools_page: devtools.html, background: { service_worker: background.js }, permissions: [scripting, storage, tabs], host_permissions: [all_urls] }devtools.html里拉起来一个devtools.js它负责创建面板chrome.devtools.panels.create( SSE, , panel.html, (panel) { console.log(sse-devtools-panel created); } );注入主脚本用chrome.scripting.executeScript关键参数是world: MAIN。如果不指定这个参数默认注入 isolated world拿不到页面自己的window.EventSource包装就无从谈起chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) { if (changeInfo.status complete tab.url /^https?:/.test(tab.url)) { chrome.scripting.executeScript({ target: { tabId: tabId }, files: [inject.js], world: MAIN, }); } });inject.js里要做的核心事情是重写 EventSourceconst NativeEventSource window.EventSource; function notify(type, payload) { window.postMessage({ source: sse-devtools-panel, type: type, payload: payload, }, *); } function patchEventSource() { window.EventSource function (url, config) { const es new NativeEventSource(url, config); const state { url: String(url), eventCount: 0, byteCount: 0 }; es.addEventListener(open, () { notify(es-state, { url: state.url, readyState: es.readyState }); }); es.addEventListener(message, (event) { state.eventCount 1; state.byteCount (event.data || ).length; notify(es-event, { url: state.url, readyState: es.readyState, eventType: message, data: event.data, lastEventId: event.lastEventId, eventCount: state.eventCount, byteCount: state.byteCount, timestamp: Date.now(), }); }); es.addEventListener(error, () { notify(es-state, { url: state.url, readyState: es.readyState, error: true, }); }); return es; }; window.EventSource.prototype NativeEventSource.prototype; Object.setPrototypeOf(window.EventSource, NativeEventSource); } patchEventSource();这段代码里有一个关键细节window.EventSource.prototype NativeEventSource.prototype。如果只在实例里加监听而不改原型某些框架会用new EventSource(...)之后自行通过原型添加方法不改原型会出现兼容问题。fetch 的包装稍微麻烦一点要看res.body是不是 ReadableStream并且 contentType 是否包含text/event-stream。为了不消费原始流我用res.clone()拿副本const nativeFetch window.fetch; async function interceptFetch(...args) { const res await nativeFetch(...args); const contentType res.headers.get(content-type) || ; if (!res.body || contentType.indexOf(text/event-stream) -1) { return res; } const cloned res.clone(); const reader cloned.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; (async () { try { while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); notify(fetch-chunk, { url: res.url, status: res.status, chunk: buffer, timestamp: Date.now(), }); } } catch (err) { notify(fetch-error, { url: res.url, message: err.message, timestamp: Date.now(), }); } })(); return res; } window.fetch interceptFetch;content script 的转发层就比较简单了它收到window.postMessage之后做一下来源校验再通过chrome.runtime.sendMessage往后台发window.addEventListener(message, (event) { if (event.source ! window) return; if (!event.data || event.data.source ! sse-devtools-panel) return; chrome.runtime.sendMessage(event.data); });3.2 SSE 解析引擎空行分帧、多行 data、注释行原始字节流是连续的必须自己按 SSE 协议拆帧。拆帧逻辑的核心是用空行作为帧结束标志缓存没有遇到空行的内容直到出现\n\n或\r\n\r\n。我的解析函数如下function parseSSE(rawText) { const events []; const lines rawText.split(/\r?\n/); let currentEvent null; const flush () { if (!currentEvent) return; events.push(currentEvent); currentEvent null; }; for (const line of lines) { if (line ) { flush(); continue; } if (line.startsWith(:)) { // 注释行一般用作心跳 if (!currentEvent) currentEvent { comment: [] }; currentEvent.comment.push(line.slice(1).trim()); continue; } const colonIndex line.indexOf(:); const field colonIndex -1 ? line : line.slice(0, colonIndex); const value colonIndex -1 ? : line.slice(colonIndex 1).replace(/^ /, ); if (!currentEvent) currentEvent { data: [], event: null, id: null, retry: null }; switch (field) { case data: currentEvent.data.push(value); break; case event: currentEvent.event value; break; case id: currentEvent.id value; break; case retry: currentEvent.retry parseInt(value, 10); break; default: break; } } flush(); return events; }注意点有两个。第一data:字段可以出现多行按规范用换行符拼接我在 parse 阶段把多行 data 保留成一个数组UI 渲染时再join(\n)这样既保留原始多行 JSON 的语义又方便查看。第二注释行的作用不能被忽略很多服务端每隔 15 秒发一条: ping保持连接原版 Network 面板看不到这个信息面板里把它单独归到 comment 字段方便你判断“没有新 token 但连接还在”是哪一方的问题。3.3 兼容 fetch 流式与 JSON Lines不是所有 AI 服务都严格遵循 SSE 规范。我遇到过一个服务端把响应体写成每行一个 JSON 对象没有data:前缀也没有空行。这种格式 JSON Lines 在外面看像那么回事但一旦流式传输你很难从纯文本里定位每个事件边界。我的处理方式是在解析层加一个模式探测如果累计的 buffer 里完全没有data:前缀、也没有event:字段但每一行都能被JSON.parse就按 JSON Lines 模式解析。实现思路是把parseSSE的入参先做一次规范化把 JSON Lines 转成标准 SSE 再喂给解析器function normalizeChunk(chunk) { if (chunk.includes(\n\n) || chunk.includes(\r\n\r\n)) { return chunk; } // 如果已经有 data: 前缀说明是标准 SSE不处理 if (chunk.includes(data:)) return chunk; const lines chunk.split(/\r?\n/).filter((line) line.trim() ! ); if (lines.every((line) { try { JSON.parse(line); return true; } catch (e) { return false; } })) { return lines.map((line) data: ${line}).join(\n\n); } return chunk; }这个函数不追求完美但能让面板兼容绝大多数“伪 SSE”服务实测下来够用。另外要说明一点WebSocket 不在这个插件的范围内。有人问过我为啥不顺便把 WebSocket 也接了。原因是 SSE 和 WebSocket 的协议模型、调试维度完全不一样SSE 是一个带首尾的 HTTP 响应里分帧WebSocket 是独立的双向消息通道。硬塞进同一个面板会让 UI 复杂一倍不如拆开做。4. 面板 UI 与调试效率让每个 token 都看得见4.1 请求列表 事件流 Preview 三栏布局面板 UI 我设计成了三栏结构左边是请求列表中间是事件流右边是详情预览。左边列表按时间倒序每条显示 URL 的 host 和路径前缀、event 总数、总字节数、连接持续时长、状态图标。列表要支持切换连接类型过滤EventSource 的显示[SSE]标识fetch 流式显示[FETCH]标识。中间事件流是整个面板的核心。每一条 SSE 帧以横向卡片展示左边是 event 类型标签中间是 data 内容预览右边是相对时间戳和字节数。卡片按照到达顺序从上往下排列保留原始顺序。每条卡片可以展开展开后显示完整的解析字段包括 id、retry、comment 数组。右边详情预览区做的事情比较直接如果 data 内容能被JSON.parse就格式化成缩进 JSON 展示方便拷贝到本地去校验结构如果是纯文本就直接显示原文。这个区域还提供了复制按钮一键复制原始帧内容。为什么要分成三栏而不是用原生 Network 的平铺结构因为流式调试的核心诉求是同一条请求里按时间顺序观察每个事件这天然是“一屏三块”的交互模型。左栏管范围中栏管顺序右栏管细节任何两块合并都会让其中一块的信息密度降级。4.2 关键指标TTFT、首包字节、事件间隔面板内置了几个统计指标在选中一条请求后显示在顶部TTFTTime To First Token从连接建立到收到第一个 data 帧的时间间隔。这个指标衡量的是“服务端第一次返回可用内容的耗时”比 HTTP 响应头里的 TTFB 更贴近大模型场景。首包字节数第一帧 data 的字节数判断服务端是立即推了一个完整 JSON 再等下一帧还是每个 token 一到就立刻推出来。事件间隔相邻两帧之间的时间差。AI 流式接口最常见的性能问题就是“打字机效果断断续续”你看到 token 一卡一卡的但说不清是网络抖动还是服务端生成卡顿。用事件间隔可以量化如果间隔普遍大于 1 秒大概率是服务端生成慢如果间隔均匀但帧内容很大可能是服务端在攒 buffer。总事件数、总字节数、总耗时这三个基本指标支撑横向对比比如同样一个 prompt 换不同服务商谁的 TTFT 低谁的总耗时长。4.3 过滤、搜索、导出与错误聚类事件流多了之后必须有筛选能力。我在面板顶部放了一个过滤栏支持三组条件按 event 类型筛选比如只看message或error。实测中这个最常用AI 网关出现错误时通常会推一个event: error或event: done但夹杂在几十个message帧里肉眼根本翻不到。按关键词搜索直接搜正文内容比如搜error、429、stop_reason命中时高亮。按时间范围过滤适合调试长时间运行的智能体任务只关注某一秒发生的事件。导出功能我也做了选中的请求可以导出为 JSONL 文件每行一个事件对象包含eventType、data、timestamp、id、retry等完整字段。这个功能最初是为了方便我提交 bug 给服务端开发后来发现它也是很好的基准测试材料把同一请求在两个模型服务上各跑一遍导出的 JSONL 一对比差异一目了然。错误聚类是我后来加的一个小功能。面板按 event 类型为error的帧做聚合统计列出出现次数最多的错误文本片段。调试重连时它尤其有用你会看到一个服务端在连接断开前连续推了同一个错误码这说明问题很可能是服务端主动断流而不是网络原因。4.4 连接状态灯与重连计数除了事件流我还做了连接状态的可视化。EventSource 的 readyState 用三种颜色状态灯展示灰色 CONNECTING、绿色 OPEN、红色 CLOSED。面板还会记录当前连接的重连次数每次 readyState 从 CONNECTING 切到 OPEN 就计数一次。这个功能解决的是原生 EventSource 最让人头疼的“静默重连”问题。浏览器原生 EventSource 在连接断开后会自动重连而且这个过程对开发者基本不可见。前端业务代码里经常只会监听到一次error然后它自己又接上了你以为是一次偶发抖动实际上可能已经重连了十几次。面板把这些重连过程全部记录成轨迹你能清楚地看到19:00:01.000 readyState: OPEN 19:05:13.422 readyState: CLOSED (error) 19:05:13.500 readyState: CONNECTING 19:05:15.031 readyState: OPEN (reconnect #1)有了这个判断服务端稳定性、前端 EventSource 配置是否合理就简单多了。5. 实测接入一个真实对话接口把流中断查到根因5.1 测试环境与用例设计我在本地搭了一个兼容 OpenAI 格式的网关后端用 Python 的异步流式接口返回一段多轮对话。为了测试插件我故意在配置里加了几个坑第一个坑服务端每 15 秒发一条: ping注释行保活第二个坑正常对话在返回完最后一个 token 后直接关闭连接不发送data: [DONE]第三个坑当输入文本触发某个关键词时服务端推一个event: error帧但 HTTP 状态码保持 200。前端用原生 EventSource 连接不做额外处理。我用插件面板观察整个调用过程。5.2 从流开始到连接关闭面板上发生了什么开始对话后面板左栏立刻出现一条[SSE]请求状态灯从灰色切到绿色。TTFT 显示 860ms这个数字和服务端日志里第一次调用大模型的时间基本吻合。随后中栏出现第一条message帧data 内容是一个完整的 choices JSON里面只有一个 token我把它展开后能直接看到delta.content字段。接着每隔 100ms 到 400ms 出现一条帧事件间隔图是均匀的锯齿状没有明显卡顿。但过了一段时间中间突然插入一段 4.7 秒的无事件期面板里最后一条帧之后跟着两条 comment 注释帧显示: ping。这说明连接没有断服务端还活着只是模型生成下一个 token 耗时异常。我点击最后几条帧的间隔统计看到max gap: 4.7s然后去服务端日志里查果然那段时间模型在等待一个上游工具的返回。这个定位过程在原版 Network 面板里几乎不可能完成因为: ping注释帧根本不会显示你只会看到一个很长的 pending 请求。最后结束阶段面板显示 readyState 直接切到 CLOSED然后立即进入 CONNECTING触发了一次重连重连计数变为 1。事件流里没有出现任何done类型帧这说明服务端确实是直接断连而不是发送结束标记。我再用导出的 JSONL 和网关配置一对照发现是后端的 stream 结束逻辑里少了一步发送[DONE]的操作。那第三个坑更直观。触发关键词后面板中栏出现一条红色error类型帧data 是一段 JSON 错误信息。因为 EventSource 原生 API 不会把event: error暴露给onmessage页面前端完全没有反应但如果我的面板没有把它可视化这个错误可能永远不会被发现。看到这条帧的同时右侧详情区直接格式化出了完整的错误结构我就知道网关是在应用层“软报错”而不是网络层断连。5.3 这个插件在真实调试中的价值边界用了一周之后我的结论是sse-devtools-panel解决的是“SSE 协议层和 AI 流式交互层”的可观测性问题不要把它的能力夸大。它不替代后端链路追踪也不替代日志系统它是把浏览器里发生的那一段协议交互完整地摊在你面前。对你来说最实用的几个点前端对接流式接口时用面板确认事件类型和字段结构而不是反复console.log。后端联调时前端把导出的 JSONL 发给后端双方直接看同一份事件数据省去截图传话的沟通成本。做模型服务对比时用 TTFT 和事件间隔量化两个服务商的真实输出差异。这个插件目前的版本还比较简单代码量不大但核心的解析、采集、展示链路已经完整跑通了。我最近在考虑加一个功能把面板里的事件流和 Performance 面板的时间轴做关联这样就能看到某一次流式输出在浏览器渲染层面到底卡没卡。如果你也在搞 AI 流式调试不妨直接拉下来改一版配合自己的业务场景去扩展它本身就是一个很好的协议调试框架。