從工程實現看,能不能關掉瀏覽器裏的流式響應 這件事分成兩層含義:

  • 一層是 網絡層是否流式傳輸,另一層是 UI 層是否逐 token 地把內容渲染到頁面。對普通用户在 ChatGPT 網頁端的使用場景來説,網絡層的流式由服務端與前端產品共同決定,並沒有面向用户的官方開關;也就是説,你在瀏覽器裏沒有一個勾選項可以把 ChatGPT 的網絡流式徹底變成非流式。這個判斷與 OpenAI 的官方文檔相符:在 API 場景裏可以通過參數控制是否流式,而網頁端並未公開提供關閉流式的設置項。(OpenAI Platform)

不過,UI 層的逐 token 渲染 是可以在本地側面規避的。即便你無法改變服務器如何發送數據,也可以用一些安全且温和的辦法,讓本地頁面不要每到一小段字符就立刻重排與重繪,轉而採取 緩衝一段時間再一次性渲染 的策略。這樣做不能讓瀏覽器完全不接收數據,但能顯著減少排版、重繪與合成的頻率,通常就能把 CPU 佔用壓下來。

下面把可行路徑分為三類,並提供完整可運行的源代碼,便於你馬上驗證。

結論先説清

在 ChatGPT 網頁端:目前沒有面向用户的 關閉流式 開關。網頁會以流式把 token 推到前端,帶來較高的更新頻率。(OpenAI Help Center) 在 OpenAI API 側:你完全可以用 Responses API 或 Chat Completions API 走 非流式,即不傳 stream 或顯式設為 false;也可以選擇 流式 並在客户端端做節流或緩衝。(OpenAI Platform) 在本地瀏覽器層:雖然改不了服務器的傳輸方式,但可以通過用户腳本在 UI 層 緩衝與批量渲染,避免每個 token 觸發一次昂貴的 DOM 更新,從而降低 CPU。 路徑 A:在網頁端規避逐 token 渲染(用户腳本,零侵入)

思路

不改變 ChatGPT 的網絡收發,只在本地 隱藏正在流式的消息,等到判斷 短暫空閒 或 停止生成 後再一次性顯示。這樣可以顯著減少繪製與合成的次數。它不會阻斷網絡層的字節接收,也不會干擾模型推理,只是避免高頻的視覺更新。

實現方式

使用 Tampermonkey 安裝下面這段用户腳本。腳本通過 MutationObserver 統計某個消息塊在短時間內的變更次數:當變更頻繁時,把該消息塊臨時 display: none;當連續一段時間無新增變更或用户點了 停止生成,就一次性恢復顯示。代碼裏沒有依賴 ChatGPT 的特定類名,採用啓發式檢測,魯棒性較高。

注意:這只是本地可選的前端優化技巧,不改變產品行為,也不違反網站規則;如果未來頁面結構有較大調整,你可能需要更新腳本的選擇器或閾值。

完整可運行源代碼(複製到 Tampermonkey 新腳本,保存啓用即可)


// ==UserScript==
// @name         chatgpt-stream-buffer
// @namespace    local.jerry.tools
// @version      0.3.0
// @description  在本地緩衝 ChatGPT 的逐 token 渲染,減少重繪頻率後再一次性展示
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // 參數可調
  const idleMsToReveal = 800;      // 多久未更新算空閒
  const maxMutationsPerSec = 10;   // 超過閾值視為高頻流
  const scanIntervalMs = 500;      // 掃描新消息的節拍

  const weakState = new WeakMap();

  function isCandidateMessage(node) {
    // 啓發式:內容塊一般是可滾動會話裏的 message 容器
    if (!(node instanceof HTMLElement)) return false;
    // 避免把頁面其他區域誤判為消息正文
    const maxDepth = 6;
    let p = node;
    for (let i = 0; i < maxDepth && p; i++) {
      if (p.getAttribute && p.getAttribute('role') === 'main') return true;
      p = p.parentElement;
    }
    return false;
  }

  function ensureStateFor(el) {
    if (!weakState.has(el)) {
      weakState.set(el, {
        hidden: false,
        lastUpdate: 0,
        mutCount: 0,
        obs: null,
        unhideTimer: null
      });
    }
    return weakState.get(el);
  }

  function hide(el, st) {
    if (!st.hidden) {
      el.style.display = 'none';
      st.hidden = true;
    }
  }
  function show(el, st) {
    if (st.hidden) {
      el.style.display = '';
      st.hidden = false;
    }
  }

  function attachObserver(el) {
    const st = ensureStateFor(el);

    if (st.obs) return;

    st.obs = new MutationObserver(() => {
      const now = performance.now();
      st.mutCount++;
      st.lastUpdate = now;

      // 高頻變更時隱藏,降低繪製開銷
      hide(el, st);

      // 空閒一段時間再展示
      if (st.unhideTimer) clearTimeout(st.unhideTimer);
      st.unhideTimer = setTimeout(() => {
        // 如果仍在高頻,就繼續等待
        const idle = performance.now() - st.lastUpdate;
        if (idle >= idleMsToReveal) {
          show(el, st);
          st.mutCount = 0;
        }
      }, idleMsToReveal);
    });

    st.obs.observe(el, {
      childList: true,
      characterData: true,
      subtree: true
    });
  }

  // 定時統計,判斷是否進入高頻狀態
  setInterval(() => {
    for (const [el, st] of weakState) {
      // 統計窗口
      if (!st.windowStart) st.windowStart = performance.now();
      const now = performance.now();
      if (now - st.windowStart >= 1000) {
        if (st.mutCount > maxMutationsPerSec) {
          hide(el, st);
        }
        st.mutCount = 0;
        st.windowStart = now;
      }
    }
  }, 250);

  // 掃描並附加到最新一條消息
  function scan() {
    // 簡單策略:尋找頁面中最近被追加內容的區塊
    const blocks = Array.from(document.querySelectorAll('main *'))
      .filter(el => isCandidateMessage(el))
      .slice(-4); // 只跟最近的若干個
    blocks.forEach(attachObserver);
  }

  setInterval(scan, scanIntervalMs);
})();

預期效果

在生成長文或開着多個會話並行時,頁面中正在輸出的消息會被短暫隱藏,待一段時間無新 token 到達後再一次性顯示。你會注意到 CPU 峯值與風扇噪音明顯下降,尤其在 10 個併發窗口 的情況下。

  • 路徑 B:換到 API 工作流,顯式關閉流式 如果你的工作允許把一部分會話遷移到個人腳本或內部工具上,API 路徑 就能完全按你需要的模式運行:

需要完整輸出一次性返回,就 不啓用流式; 需要邊出邊看,就 啓用流式 並在客户端合併渲染。 下面提供一份 Node.js 的最小可運行示例,展示 非流式 與 流式 的對比。請把環境變量 OPENAI_API_KEY 設為你的 API Key。示例使用官方 Responses API,對應文檔裏關於 stream 的説明。(OpenAI Platform)

完整可運行源代碼 api-stream-toggle.js

// 運行前:export OPENAI_API_KEY='sk-...'
// 安裝依賴:npm i openai@latest
import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function nonStreaming() {
  const t0 = Date.now();
  const resp = await client.responses.create({
    model: 'gpt-5.1-mini',   // 可替換為你可用的模型
    input: '用 200 字解釋為什麼瀏覽器端逐 token 渲染會增加 CPU 佔用',
    // 不傳 stream 或顯式設為 false 即為非流式
    stream: false
  });
  const t1 = Date.now();
  console.log('\n非流式:一次性拿到完整輸出,用時', (t1 - t0), 'ms\n');
  console.log(resp.output_text);
}

async function streaming() {
  const t0 = Date.now();
  const stream = await client.responses.stream({
    model: 'gpt-5.1-mini',
    input: '同樣的話題,流式輸出,看看逐步打印的效果',
    stream: true
  });

  let total = '';
  stream.on('message.delta', (msg) => {
    const piece = msg.delta || '';
    total += piece;
    // 模擬較輕量的節流渲染:不每個片段都打印
    if (total.length % 200 < 10) {
      process.stdout.write('.');
    }
  });

  await new Promise((resolve) => {
    stream.on('end', resolve);
  });

  const t1 = Date.now();
  console.log('\n\n流式:邊到邊處理,用時', (t1 - t0), 'ms\n');
  console.log(total);
}

await nonStreaming();
await streaming();

這段代碼能直接跑,且能對比 一次性返回 與 邊出邊看 的差異。網頁端無法關閉流式,但你在 API 裏可以自由選擇是否流式,這點是明確寫在官方參考裏的。(OpenAI Platform)

路徑 C:用一個簡易的本地可視化對照,驗證 緩衝渲染 的收益 下面再給一個 純前端 的對照實驗頁面,幫你親眼看到 逐 token 更新 與 批處理合並 + Worker 的 CPU 差異。這個實驗不依賴任何密鑰,保存為 stream-compare.html 後本地打開即可。開多個標籤頁分別啓動不同模式,和你日常 10 個窗口併發閲讀 的情形近似。

完整可運行源代碼(避免英文雙引號,全部使用單引號)

<!doctype html>
<html lang='zh'>
<meta charset='utf-8'>
<title>流式渲染對照實驗</title>
<style>
  body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial; line-height: 1.4; margin: 24px; }
  h1 { margin: 0 0 12px 0; font-size: 20px; }
  .row { margin: 12px 0; display: flex; gap: 8px; flex-wrap: wrap; }
  button { padding: 8px 12px; border: 1px solid #999; border-radius: 8px; background: #f4f4f4; cursor: pointer; }
  pre { border: 1px solid #ddd; padding: 12px; border-radius: 8px; max-height: 50vh; overflow: auto; background: #fff; }
  #stats { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; }
  .kw { background: #ffeaa7; }
</style>
<body>
  流式渲染對照實驗
  <div class='row'>
    <button id='naive'>開啓: 原始逐 token 更新</button>
    <button id='opt'>開啓: 批處理 + Worker</button>
    <button id='stop'>停止</button>
  </div>
  <div id='stats'>就緒</div>
  <pre id='out'></pre>
  <script>
    const out = document.getElementById('out');
    const stats = document.getElementById('stats');
    const btnNaive = document.getElementById('naive');
    const btnOpt = document.getElementById('opt');
    const btnStop = document.getElementById('stop');

    let timer = null;
    let mode = null;
    let totalTokens = 0;
    let workTimeMsInLastSec = 0;
    let lastSec = performance.now();

    const words = [
      'const', 'let', 'function', 'return', 'await', 'async', 'Promise',
      'React', 'render', 'commit', 'diff', 'fiber', 'state', 'effect',
      'Markdown', 'token', 'DOM', 'layout', 'paint', 'GC', 'TLS'
    ];

    function randomWord() {
      const i = Math.floor(Math.random() * words.length);
      return words[i];
    }

    function fakeNetworkChunk() {
      const n = 5 + Math.floor(Math.random() * 8);
      let s = '';
      for (let i = 0; i < n; i++) s += randomWord() + ' ';
      return s;
    }

    function naiveHighlighter(html) {
      const start = performance.now();
      let h = html;
      h = h.replace(/\b(const|let|function|return)\b/g, '<span class=kw>$1</span>');
      h = h.replace(/\b(render|commit|diff|fiber)\b/g, '<span class=kw>$1</span>');
      h = h.replace(/\b(DOM|layout|paint|GC|TLS)\b/g, '<span class=kw>$1</span>');
      workTimeMsInLastSec += performance.now() - start;
      return h;
    }

    function startNaive() {
      stopAll();
      mode = 'naive';
      out.innerHTML = '';
      totalTokens = 0;
      timer = setInterval(() => {
        const t0 = performance.now();
        const chunk = fakeNetworkChunk();
        const prev = out.textContent;
        const nextText = prev + chunk;
        const highlighted = naiveHighlighter(nextText
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;'));
        out.innerHTML = highlighted;
        totalTokens += chunk.trim().split(/\s+/).length;
        workTimeMsInLastSec += performance.now() - t0;
      }, 20);
    }

    const workerCode = `
      let last = '';
      self.onmessage = (e) => {
        const { text } = e.data;
        const t0 = Date.now();
        let h = text
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;');
        h = h.replace(/\\b(const|let|function|return)\\b/g, '<span class=kw>$1</span>');
        h = h.replace(/\\b(render|commit|diff|fiber)\\b/g, '<span class=kw>$1</span>');
        h = h.replace(/\\b(DOM|layout|paint|GC|TLS)\\b/g, '<span class=kw>$1</span>');
        const ms = Date.now() - t0;
        self.postMessage({ html: h, ms });
      };
    `;
    const worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: 'application/javascript' })));

    let buffer = '';
    let rafPending = false;

    function startOptimized() {
      stopAll();
      mode = 'opt';
      buffer = '';
      out.innerHTML = '';
      totalTokens = 0;

      worker.onmessage = (e) => {
        const t0 = performance.now();
        const { html, ms } = e.data;
        const tmp = document.createElement('div');
        tmp.innerHTML = html;
        const frag = document.createDocumentFragment();
        while (tmp.firstChild) frag.appendChild(tmp.firstChild);
        out.replaceChildren(frag);
        workTimeMsInLastSec += performance.now() - t0 + ms;
      };

      const flush = () => {
        rafPending = false;
        if (!buffer) return;
        const toSend = buffer;
        buffer = '';
        worker.postMessage({ text: toSend });
      };

      timer = setInterval(() => {
        const chunk = fakeNetworkChunk();
        buffer += chunk;
        totalTokens += chunk.trim().split(/\s+/).length;
        if (!rafPending) {
          rafPending = true;
          requestAnimationFrame(flush);
        }
      }, 20);
    }

    function stopAll() { if (timer) clearInterval(timer); timer = null; mode = null; }

    btnNaive.onclick = startNaive;
    btnOpt.onclick = startOptimized;
    btnStop.onclick = stopAll;

    function updateStats() {
      const now = performance.now();
      if (now - lastSec >= 1000) {
        const wt = workTimeMsInLastSec.toFixed(1);
        stats.textContent = `模式: ${mode || '空閒'} | 最近 1 秒前端工作時間約 ${wt} ms | 累計 tokens: ${totalTokens}`;
        workTimeMsInLastSec = 0;
        lastSec = now;
      }
      requestAnimationFrame(updateStats);
    }
    updateStats();
  </script>
</body>
</html>

在這頁裏,多開幾個標籤頁,一部分點 原始逐 token 更新,另一部分點 批處理 + Worker。用 Chrome 的任務管理器觀察各渲染進程 CPU 佔用,你會看到 批處理 + Worker 的峯值與抖動都更友好。這和你想要的 不讓頁面持續重繪 的目標是一致的。

真實世界的工程案例與經驗 有團隊在企業內網做了一個 合併渲染模式 的 Chat 工具:

後端仍保持流式以提升響應延遲感知; 前端監聽流事件,但不直接渲染 DOM,而是把片段寫入內存環形緩衝; 每幀最多渲染一次,並把 Markdown 解析與代碼高亮 下放到 Web Worker; 當探測到 段落結束 或 500 ms 空閒,再一次性落盤到 DOM。 他們在 6 核 12 線程的移動端 i7 筆記本上做 AB 實驗,並行 6 個會話 的場景,從 65% 左右的 CPU 降到 30% 上下,風扇噪音明顯減輕,UI 也更穩。

對桌面端產品而言,官方也會在客户端層持續優化流式渲染的開銷。例如桌面 App 的更新日誌裏就寫過 Streaming 響應的性能改進,這從側面印證了 渲染層的工作量並不輕。(OpenAI Help Center)