iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

https://ithelp.ithome.com.tw/upload/images/20260825/20161290m6zaXTrDKJ.png

POST body、進度事件、chunk 串流與半截 JSON 容錯。

在前四天中,我們完成了 Generative UI 的四塊核心拼圖:

  • Day 21:了解 json-render 的 Catalog / Registry / Spec 三件套架構。
  • Day 22:將 UI 定義為扁平的 Flat Tree(root + elements Map)。
  • Day 23:建立前後端 Catalog 自動對帳與 Smart Fallback 機制。
  • Day 24:落實「數據與敘事分離」,讓 Java 負責真實資料陣列,LLM 負責文字解讀。

今天我們要將這些靜態零件串成一條動態的即時串流管線(Streaming Pipeline)

在現代 AI 產品體驗中,使用者無法忍受點擊送出後面對 8 秒鐘的空白等待。我們需要讓使用者親眼看見:

  1. Agent 正在分析意圖status: analyzing
  2. A 演算法規劃出的 3 個步驟*(plan: [...]
  3. 步驟逐步打勾完成step: running -> done
  4. 最後生成的儀表板隨著 Chunk 逐步在畫面上生長長出來chunk 漸進渲染)

要達到這個效果,我們必須攻克兩大工程難題:為什麼不能用瀏覽器原生的 EventSource? 以及 未閉合的「半截 JSON(Partial JSON)」該如何安全渲染而不崩潰?


1. 今天要解決的痛點與核心觀念

痛點背景: Generative UI 串流的兩大技術陷阱

  1. 瀏覽器原生 EventSource 的先天缺陷
    • 瀏覽器內建的 new EventSource(url) 僅支援 HTTP GET 請求,無法在 Request Body 中傳遞複雜的自然語言查詢、篩選條件或使用者 Context。
    • 解決方案:必須在前端改用 fetch() 搭配 ReadableStream 手動解析 SSE 協定。
  2. 半截 JSON 導致的白屏崩潰(SyntaxError & Crash)
    • 當 LLM 正在吐出 JSON 時,字串可能停在 {"title": "近一年,括號與引號尚未閉合。
    • 若直接傳給 JSON.parse() 會立即拋出 SyntaxError;若暴力略過,前端則會一直等待到最後一秒,失去串流生長的動態體驗。

觀念圖解:完整的前後端 SSE 串流生命週期

https://ithelp.ithome.com.tw/upload/images/20260825/20161290FTSZ7zGuu2.jpg

  1. 前端發送 POST 查詢:透過 fetch + ReadableStream 建立 SSE 連線。
  2. 後端 Spring Boot SSE 事件序列
    • status: 意圖分析(如 ANALYZING
    • plan: A* 規劃出的思維鏈步驟
    • step: 單一步驟完成狀態與耗時
    • chunk: Spec JSON 增量片段
    • complete: 收尾指標與 Token 成本
  3. 前端寬鬆解析(lenientParse)與淨化(sanitizeSpec):修補半截 JSON 並漸進式更新儀表板畫面。

2. 官方核心技術依據與架構深度

1. 標準 SSE 事件契約(Event Types)

為了同時滿足「可觀測性進度」與「UI 漸進渲染」,一條標準連線包含以下 7 類 Typed Events:

事件名稱 (Event) 傳遞資料 (Payload) 前端對應職責
status { phase: "analyzing" / "generating" } 切換主狀態文字與動畫
intent { isMultiIntent: boolean, tags: [] } 呈現意圖識別標籤
plan { totalSteps: 3, actions: [...] } 繪製 GOAP 思維鏈步驟清單
step { actionName: "fetch", status: "DONE" } 即時更新單一步驟勾選狀態與耗時
chunk string (JSON Spec 局部增量字串) 累積至緩衝區並進行漸進式解析
complete { executionCost: 0.002, durationMs: 1400 } 流程結束,呈現最終成本與指標
error { code: 500, message: "..." } 捕捉例外並呈現友善錯誤面板

2. 漸進式容錯解析(Lenient Parsing Pipeline)

前端收到 chunk 後,必須經過三層防護管道:

  1. 字串累積(Accumulator)jsonAccumulator += chunkData
  2. 寬鬆修補解析(lenientParseSpec:使用正規化與棧(Stack)演算法,自動為未閉合的引號補 ",為未閉合的大括號補 }
  3. 結構淨化防禦(sanitizeSpec
    • 為缺少 props 的元素補上 props: {}
    • 為缺少 children 的元素補上 children: []
    • 過濾無效參照(Dangling Child Ref):若 children 包含尚未抵達的 Key,自動過濾以防渲染中斷。

3. 完整程式碼實戰(Production-Ready Code)

以下提供:

  1. 前端 SSE 串流解析器與容錯修補器(TypeScript)
  2. 完整 React 串流狀態管理 Hook(useDashboardStream
  3. 後端 Spring Boot 串流推播 Controller(Java 21)

1. 前端 TypeScript:寬鬆解析與防禦清洗器

// src/utils/lenientJsonParser.ts
import { DashboardSpec } from '../components/renderer/FlatTreeRenderer';

/**
 * 寬鬆 JSON 解析器
 * 自動嘗試修補串流中尚未閉合的引號與大括號
 */
export function lenientParseSpec(rawJson: string): any {
  if (!rawJson || rawJson.trim() === '') return null;

  let cleaned = rawJson.trim();

  // 1. 嘗試直接解析
  try {
    return JSON.parse(cleaned);
  } catch (e) {
    // 進入修補模式
  }

  // 2. 修補未閉合引號
  const quoteCount = (cleaned.match(/(?<!\\)"/g) || []).length;
  if (quoteCount % 2 !== 0) {
    cleaned += '"';
  }

  // 3. 修補未閉合的大括號與中括號
  const openBraces = (cleaned.match(/\{/g) || []).length;
  const closeBraces = (cleaned.match(/\}/g) || []).length;
  for (let i = 0; i < openBraces - closeBraces; i++) {
    cleaned += '}';
  }

  try {
    return JSON.parse(cleaned);
  } catch (e) {
    return null; // 若修補後依然不合法,靜默等待下一批 Chunk
  }
}

/**
 * 規格安全淨化器
 * 補齊缺失屬性並過濾不存在的子節點參照
 */
export function sanitizeSpec(parsed: any): DashboardSpec | null {
  if (!parsed || typeof parsed !== 'object') return null;
  if (!parsed.root || !parsed.elements || typeof parsed.elements !== 'object') return null;

  const validElements: Record<string, any> = {};
  const allKeys = new Set(Object.keys(parsed.elements));

  for (const [key, element] of Object.entries<any>(parsed.elements)) {
    if (!element || typeof element !== 'object' || !element.type) continue;

    // 防禦 1: 補齊 props
    const props = element.props && typeof element.props === 'object' ? element.props : {};
    
    // 防禦 2: 過濾尚未抵達的子節點 key
    const rawChildren = Array.isArray(element.children) ? element.children : [];
    const safeChildren = rawChildren.filter((childKey: string) => allKeys.has(childKey));

    validElements[key] = {
      type: element.type,
      props: props,
      children: safeChildren
    };
  }

  // 確保 root 節點存在
  if (!validElements[parsed.root]) return null;

  return {
    root: parsed.root,
    elements: validElements
  };
}

2. 前端 React:useDashboardStream 自製 Hook

// src/hooks/useDashboardStream.ts
import { useState, useRef } from 'react';
import { DashboardSpec } from '../components/renderer/FlatTreeRenderer';
import { lenientParseSpec, sanitizeSpec } from '../utils/lenientJsonParser';

export interface StreamProgress {
  phase: string;
  steps: Array<{ name: string; status: 'PENDING' | 'RUNNING' | 'DONE' }>;
  costUsd: number;
}

export function useDashboardStream() {
  const [spec, setSpec] = useState<DashboardSpec | null>(null);
  const [progress, setProgress] = useState<StreamProgress>({ phase: 'IDLE', steps: [], costUsd: 0 });
  const [isLoading, setIsLoading] = useState(false);
  const jsonAccumulatorRef = useRef("");

  const startStream = async (query: string) => {
    setIsLoading(true);
    setSpec(null);
    jsonAccumulatorRef.current = "";
    setProgress({ phase: 'CONNECTING', steps: [], costUsd: 0 });

    try {
      const response = await fetch('/api/dashboard/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query })
      });

      if (!response.body) throw new Error("瀏覽器不支援 ReadableStream");

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
        const rawEvents = buffer.split("\n\n");
        buffer = rawEvents.pop() || ""; // 留下半截待下輪拼接

        for (const raw of rawEvents) {
          const lines = raw.split("\n");
          let eventType = "message";
          let dataStr = "";

          for (const line of lines) {
            if (line.startsWith("event:")) eventType = line.replace("event:", "").trim();
            if (line.startsWith("data:")) dataStr = line.replace("data:", "").trim();
          }

          if (!dataStr) continue;

          // 處理不同類型的 SSE 事件
          if (eventType === 'status') {
            const data = JSON.parse(dataStr);
            setProgress(prev => ({ ...prev, phase: data.phase }));
          } else if (eventType === 'chunk') {
            jsonAccumulatorRef.current += dataStr;
            const parsed = lenientParseSpec(jsonAccumulatorRef.current);
            const safe = sanitizeSpec(parsed);
            if (safe) {
              setSpec(safe); // 漸進式更新畫面!
            }
          } else if (eventType === 'complete') {
            const data = JSON.parse(dataStr);
            setProgress(prev => ({ ...prev, phase: 'COMPLETED', costUsd: data.executionCost }));
          }
        }
      }
    } catch (err: any) {
      setProgress(prev => ({ ...prev, phase: 'ERROR' }));
      console.error("串流中斷或發生錯誤:", err);
    } finally {
      setIsLoading(false);
    }
  };

  return { spec, progress, isLoading, startStream };
}

3. 後端 Java 21:Spring Boot SSE 串流 Controller

package com.antechinus.travel.web;

import com.antechinus.travel.spec.DashboardSpec;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

/**
 * 儀表板生成串流端點
 * 透過 POST 接收查詢並回傳 SSE 串流事件
 */
@RestController
@RequestMapping("/api/dashboard")
public class DashboardStreamController {

    private final ObjectMapper objectMapper = new ObjectMapper();

    public record GenerateRequest(String query) {}

    @PostMapping(value = "/generate", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter streamDashboard(@RequestBody GenerateRequest request) {
        // 設定 60 秒超時
        SseEmitter emitter = new SseEmitter(60_000L);

        CompletableFuture.runAsync(() -> {
            try {
                // 1. 發送分析狀態
                sendEvent(emitter, "status", Map.of("phase", "ANALYZING"));
                Thread.sleep(300);

                // 2. 發送規劃思維鏈
                sendEvent(emitter, "plan", Map.of(
                    "steps", List.of("讀取歷史旅程", "計算年度指標", "組裝儀表板規格")
                ));
                Thread.sleep(400);

                // 3. 模擬 Action 完成
                sendEvent(emitter, "step", Map.of("actionName", "讀取歷史旅程", "status", "DONE"));

                // 4. 模擬分批 Chunk 傳送 Spec
                DashboardSpec sampleSpec = DashboardSpec.sample();
                String fullJson = objectMapper.writeValueAsString(sampleSpec);

                // 模擬將 JSON 拆為 3 個 Chunk 依序送出
                int chunkSize = fullJson.length() / 3;
                for (int i = 0; i < fullJson.length(); i += chunkSize) {
                    int end = Math.min(i + chunkSize, fullJson.length());
                    String chunk = fullJson.substring(i, end);
                    sendEvent(emitter, "chunk", chunk);
                    Thread.sleep(200); // 模擬生成間隔
                }

                // 5. 發送收尾事件
                sendEvent(emitter, "complete", Map.of(
                    "executionCost", 0.0018,
                    "durationMs", 1250
                ));

                emitter.complete();
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        });

        return emitter;
    }

    private void sendEvent(SseEmitter emitter, String eventName, Object data) throws IOException {
        String payload = (data instanceof String str) ? str : objectMapper.writeValueAsString(data);
        emitter.send(SseEmitter.event().name(eventName).data(payload));
    }
}

4. 生產環境避坑指南與對比分析

常見踩雷與除錯秘訣

  1. 雷區一:Nginx 預設啟用 Proxy Buffering 導致串流延遲
    • 現象:後端明明一行行送出 Chunk,但前端卻完全沒動靜,最後 5 秒鐘瞬間一次吐出整坨資料。
    • 解法:在 Spring 回應標頭加上 X-Accel-Buffering: no,或在 Nginx 配置 proxy_buffering off;,強制 Nginx 不得快取 SSE 封包。
  2. 雷區二:未處理 CRLF(\r\n)導致事件解析破裂
    • 現象:在 Windows 伺服器或特定瀏覽器上,換行符為 \r\n,若用 split("\n\n") 會導致事件無法正確切割。
    • 解法:解析前一律先執行 .replace(/\r\n/g, "\n") 進行全域正規化。
  3. 雷區三:前端忘記在組件 Unmount 時調用 controller.abort()
    • 現象:使用者在生成過程中切換到其他分頁,背景連線依然在跑,耗費伺服器資源且在 React 引發 Memory Leak 警告。
    • 解法:在 useEffect 的 Cleanup 函式中呼叫 AbortController.abort() 主動中斷 fetch 連線。

傳輸方案對比表

評估維度 ❌ 瀏覽器原生 EventSource ❌ WebSocket ✅ Fetch + ReadableStream SSE
HTTP Method 支援 僅支援 GET,無法帶 Body 獨立二進位/文字雙向協定 支援標準 POST 與 JSON Body
協定複雜度 極低 較高(需處理心跳、握手、狀態維護) 輕量標準 HTTP/2 串流
防火牆穿透性 良好 部分企業代理伺服器(Proxy)會攔截 100% 相容既有 HTTP 防火牆與 CDN
半截 JSON 漸進渲染 支援(但難傳參數) 支援 完美結合 lenientParse 與狀態漸進展示

5. 實機畫面:SSE 串流進行中的瞬間

這張截圖攝於查詢送出後、生成尚未完成的瞬間:前四個 GOAP action 已完成(右側各自帶耗時),「生成儀表板」正在執行、狀態列顯示「串流渲染中…」,左側儀表板已經先渲染出第一個元素(標題)——這就是 status → plan → step → chunk 事件管線與半截 JSON 容錯的實際樣貌。

https://ithelp.ithome.com.tw/upload/images/20260825/20161290Dm6IflRzrg.png


6. 今日動手實作任務與發文備註

🛠️ 今日實作任務

  1. 實作前端 SSE 解析器:建立 useDashboardStream Hook,透過 fetch + ReadableStream 接收後端發送的 statuschunk 事件。
  2. 測試半截 JSON 容錯:故意傳入一段殘缺的 JSON(如 {"root":"r1","elements":{"r1":{"type":"Stack"),驗證 lenientParseSpec 是否能自動補全大括號並成功由 sanitizeSpec 淨化。
  3. 思考題:如果 Agent 規劃需要 5 秒,但最後產出 JSON 只花 0.5 秒,這時候「顯示思維鏈進度」與「漸進渲染 JSON」哪一個對使用者的心理等待體驗改善更大?

上一篇
Day 24:數字交給程式算
下一篇
Day 26:先決定要看什麼
系列文
讓 AI Agent 真的做事:用 Embabel 打造可控、可測試的智慧 Dashboard29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言