iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Build on Google AI

用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務系列 第 16 篇

Day 16 -【流式傳輸】實戰 Server-Sent Events 與 Gemini Stream:打造秒級回應、逐字噴發的絲滑 AI 互動體驗

  • 分享至 

  • xImage
  •  

在昨天的 [Day 15] 中,我們成功打造了穩健的 Google AI File API 檔案管線,讓 OmniVibe AI 能輕鬆吞下數百 MB 的巨型影片與 PDF 檔案。

然而,當使用者在 Dashboard 上按下「開始 AI 內容提煉」後,如果後端採用傳統的「阻塞式同步請求(Blocking API)」,系統必須等待 Gemini 1.5 將數千字的摘要、Threads 貼文與短影音腳本全部生成完畢,才能一次性回傳 JSON。這意味著使用者將面對長達 8 至 15 秒的白屏與等待空窗期。

在現代 AI SaaS 的產品設計中,「首字回應時間(Time-To-First-Token, TTFT)」 是決定用戶留存率的關鍵指標。

今天,我們將結合 Gemini SDK 的 generateContentStream 與 Web API 的 ReadableStream / Server-Sent Events (SSE),在 Next.js 15 中打造出一條極速噴發的流式傳輸管線,將使用者的感知延遲從「10 秒乾等」直接降至「0.5 秒秒級吐字」!


⚡ 傳統 API vs. 流式傳輸 (Streaming)

sequenceDiagram
    autonumber
    actor User as 使用者 UI
    participant Server as Next.js API Route
    participant Gemini as Gemini 1.5 Engine

    box RGB(255,235,235) 傳統阻塞式 (無 Stream)
    User->>Server: 1. 發送提煉請求
    Server->>Gemini: 2. generateContent()
    Note over Gemini: 模型運算中... (乾等 10 秒)
    Gemini-->>Server: 3. 一次性回傳完整 2000 字
    Server-->>User: 4. 回傳全量 JSON (使用者等待感極強)
    end

    box RGB(235,255,235) 現代流式傳輸 (Gemini Stream + SSE)
    User->>Server: 5. 發送串流請求
    Server->>Gemini: 6. generateContentStream()
    Gemini-->>Server: 7. 吐出 Chunk 1 ("# OmniVibe...") [TTFT < 500ms]
    Server-->>User: 8. SSE 串流推送 Chunk 1
    Gemini-->>Server: 9. 吐出 Chunk 2 ("核心洞見如下...")
    Server-->>User: 10. SSE 串流推送 Chunk 2 (畫面呈現打字機效果)
    end

為什麼選擇 Server-Sent Events (SSE) 而非 WebSocket?

在 AI 文本生成的場景中,數據流向是單向的(伺服器不斷推送文字給客戶端)。與雙向的 WebSocket 相比,SSE 具備以下優勢:

  1. 基於標準 HTTP 協定:無需建立額外的 TCP 長連接握手,更容易通過防火牆與 API Gateway。
  2. 自動斷線重連:瀏覽器原生 EventSource / fetch 串流能自動處理網路波動。
  3. 無縫整合 Serverless:完美相容 Vercel / Next.js Edge Runtime 與 Serverless 環境。

💻 實戰演練:搭建 Gemini 串流 API 端點

我們將在 Next.js 15 App Router 中實作支援串流輸出的 Route Handler (src/app/api/ai/stream-distill/route.ts)。

1. 建立串流 API 端點 (src/app/api/ai/stream-distill/route.ts)

我們使用 @google/generative-ai 提供的 generateContentStream 方法,並利用 Web Standard ReadableStream 將資料塊(Chunk)即時編碼推送:

// src/app/api/ai/stream-distill/route.ts
import { NextRequest } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { OMNIVIBE_SYSTEM_INSTRUCTION } from '@/lib/gemini/prompts';

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || '');

export const runtime = 'edge'; // 使用 Edge Runtime 取得更低回應延遲

export async function POST(req: NextRequest) {
  try {
    const { fileUri, mimeType, prompt } = await req.json();

    if (!fileUri || !mimeType) {
      return new Response(JSON.stringify({ error: '缺少必要的 fileUri 或 mimeType' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    const model = genAI.getGenerativeModel({
      model: 'gemini-1.5-flash',
      systemInstruction: OMNIVIBE_SYSTEM_INSTRUCTION,
    });

    // 1. 發起 Gemini 串流請求
    const streamingResult = await model.generateContentStream([
      {
        fileData: {
          fileUri: fileUri,
          mimeType: mimeType,
        },
      },
      prompt || '請將此資產提煉為核心洞見、爆款 Threads 貼文與 60 秒短影音腳本。',
    ]);

    // 2. 建立 Web ReadableStream 封裝 SSE 數據流
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      async start(controller) {
        try {
          for await (const chunk of streamingResult.stream) {
            const chunkText = chunk.text();
            if (chunkText) {
              // 以 SSE 格式發送 data: {"text": "..."}\n\n
              const eventData = `data: ${JSON.stringify({ text: chunkText })}\n\n`;
              controller.enqueue(encoder.encode(eventData));
            }
          }
          // 推送完成標記
          controller.enqueue(encoder.encode('data: [DONE]\n\n'));
          controller.close();
        } catch (error) {
          console.error('[Stream Controller Error]:', error);
          controller.error(error);
        }
      },
    });

    // 3. 回傳 SSE 標準標頭 Responses
    return new Response(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache, no-transform',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no', // 禁用 Nginx 等反向代理快取
      },
    });
  } catch (error: any) {
    console.error('[Stream Route Error]:', error);
    return new Response(JSON.stringify({ error: '串流生成失敗', message: error.message }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    });
  }
}


🎨 2. 實作前端 React 串流 Hook (src/hooks/useGeminiStream.ts)

在前端,我們使用 fetch 結合 response.body.getReader() 來逐字解碼並更新 React State:

// src/hooks/useGeminiStream.ts
'use client';

import { useState, useCallback } from 'react';

export function useGeminiStream() {
  const [streamedText, setStreamedText] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const startStream = useCallback(async (params: { fileUri: string; mimeType: string; prompt?: string }) => {
    setIsStreaming(true);
    setStreamedText('');
    setError(null);

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

      if (!response.ok || !response.body) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = '';

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

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n\n');
        
        // 留存未完整的片段
        buffer = lines.pop() || '';

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const dataStr = line.replace('data: ', '').trim();
            
            if (dataStr === '[DONE]') {
              setIsStreaming(false);
              return;
            }

            try {
              const parsed = JSON.parse(dataStr);
              if (parsed.text) {
                // 逐字累加文字狀態,驅動打字機效果
                setStreamedText((prev) => prev + parsed.text);
              }
            } catch (e) {
              console.error('SSE JSON 解析失敗:', e);
            }
          }
        }
      }
    } catch (err: any) {
      console.error('[Stream Hook Error]:', err);
      setError(err.message || '串流傳送發生錯誤');
    } finally {
      setIsStreaming(false);
    }
  }, []);

  return {
    streamedText,
    isStreaming,
    error,
    startStream,
  };
}


🖥️ 3. 整合打字機渲染元件 (src/components/dashboard/ResultViewer.tsx)

現在,將 useGeminiStream 產生的文字即時綁定至 Day 14 的 ResultViewer 中,文字將順暢地噴發在畫面上:

// src/components/dashboard/ResultViewer.tsx (部分切片)
import { Sparkles } from 'lucide-react';

interface StreamingViewerProps {
  streamedContent: string;
  isStreaming: boolean;
}

export function StreamingResultViewer({ streamedContent, isStreaming }: StreamingViewerProps) {
  return (
    <div className="border border-slate-800 rounded-xl bg-slate-950/60 p-5 min-h-[450px] relative font-mono text-sm leading-relaxed text-slate-200">
      {/* 頂部串流狀態指示燈 */}
      <div className="flex items-center justify-between border-b border-slate-800 pb-3 mb-4">
        <div className="flex items-center space-x-2">
          <span className={`w-2.5 h-2.5 rounded-full ${isStreaming ? 'bg-emerald-500 animate-pulse' : 'bg-slate-600'}`} />
          <span className="text-xs text-slate-400 font-sans">
            {isStreaming ? 'Gemini 1.5 串流生成中...' : '生成完畢'}
          </span>
        </div>
        {isStreaming && <Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />}
      </div>

      {/* 打字機內容即時展示 */}
      <div className="whitespace-pre-wrap">
        {streamedContent}
        {/* 光標打字機閃爍效果 */}
        {isStreaming && (
          <span className="inline-block w-2 h-4 bg-indigo-500 ml-1 animate-ping" />
        )}
      </div>
    </div>
  );
}


🧪 實測驗證:首字延遲 (TTFT) 體驗大躍進

我們在上傳一段 10 分鐘的 Podcasting 音訊後發起流式提煉:

[HTTP Monitor] POST /api/ai/stream-distill
- 傳統 Blocking 方式: 等待 9.2 秒才一次顯示全篇文字。
- 本串流 SSE 方式: 
  * 首個 Chunk 送達 (TTFT): 380 ms!
  * 文字噴發速度: 每秒約 45 門 Token 逐字順暢展開。

關鍵價值體現:

  1. 心理感知延遲極低:使用者在點擊按鈕後 不到半秒 就看到 AI 開始思考並回答,注意力被立即吸引。
  2. 記憶體占用低:伺服器無需在 RAM 中累積完整的長篇文案,Chunk 邊生成邊流出,有效防止 Serverless 記憶體爆滿。
  3. 體驗極致流暢:搭配 CSS 閃爍光標(Blinking Cursor),賦予產品強烈的「智慧代理即時思考」科技感。

🎯 總結與明日預告

今天我們打通了 SaaS 互動體驗中最關鍵的一環:

  1. 深入理解了 Server-Sent Events (SSE) 與傳統阻塞式 API 的效能差距。
  2. 實作了 Next.js 15 App Router (Edge Runtime) 結合 **Gemini generateContentStream** 的串流管線。
  3. 透過自訂 useGeminiStream React Hook,成功在前端打造出零延遲、逐字噴發的打字機互動體驗。

在完成前端介面、檔案管線與串流互動後,我們的 OmniVibe AI 已經具備了驚艷的產品雛形!但目前的資產與提煉成果只儲存在前端記憶體中,重新整理網頁資料就會消失。

👉 明天(Day 17),我們將進入【資料持久化篇】:實戰 Supabase / Firestore + Firebase Auth 多租戶資料庫架構!看我們如何用代碼保存使用者的專案歷史紀錄、提煉結果與 Token 額度管理!

我們明天見!🔥


上一篇
Day 15 -【檔案管線】Server Actions + Google AI File API 多模態檔案處理管線:巨型影音與 PDF 異步上傳與狀態監控實戰
下一篇
Day 17 -【資料持久化】實戰 Supabase PostgreSQL + Firebase Auth 多租戶架構:歷史紀錄保存與 Token 額度控管
系列文
用 Google AI 生態系 30 天從零打造一個全棧 AI SaaS 服務 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言