iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 24 篇

Day 24:簡化的聊天功能 - 串流回應

  • 分享至 

  • xImage
  •  

Day 23 的對話頁已能使用,但送出後會等模型完成整句回覆才顯示內容,畫面會暫時空白。今天改為讓教練回覆逐字顯示。

做法分兩邊:後端新增一支 POST /chat/stream,模型每產生一小段字就立刻送出去;前端改成邊收邊顯示,順便加上聊天泡泡和「停止」按鈕。

概念:SSE 就是「做好一道上一道」

SSE 是伺服器單向、持續把資料送到瀏覽器的方式。原本的 /chat 會等完整回覆生成後才回傳;SSE 則會在每一小段內容產生時立即送出。

前端送出問題 → 後端開始請模型回答
後端:「Amazon」 → 前端立刻顯示
後端:「 Web」   → 前端接在後面顯示
後端:「 Services」 → 前端再接上去
後端:「done」   → 前端知道結束了

SSE 的格式很簡單:每一則訊息以 data: 開頭,後面接內容,最後空一行代表這則結束。

data: {"type": "token", "content": "Amazon"}

data: {"type": "token", "content": " Web"}

data: {"type": "done", "intent": "qa"}

模型生成總時間大致不變,但第一個字會更早顯示,使用者能立即知道系統正在回應。

為什麼前端不用 EventSource

瀏覽器內建一個專門收 SSE 的 EventSource,但它只能發 GET 請求,沒辦法帶 JSON 的請求內容。我們的 /chat 需要送 user_id 和 message,所以前端改用 fetch,自己一段一段讀回應。多寫一點程式碼,換來可以用 POST,也可以用 AbortController 隨時切斷連線。


實作步驟

步驟1:後端新增 POST /chat/stream

檔案位置: backend/main.py
狀態: 修改檔案(接在 Day 19 的 /chat 後面,新增,原本的 /chat 保留不動)
用途: 把對話圖的輸出改成一個字一個字串流回去
依賴: fastapi, graph (已安裝)

import json
from fastapi.responses import StreamingResponse


def _sse(event: dict) -> str:
    """把一則事件包成SSE格式:以「data: 」開頭,空一行代表這則結束"""
    return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"


@app.post("/chat/stream")
def chat_stream(payload: ChatRequest) -> StreamingResponse:
    """跟 /chat 做同一件事,但回應改成一個字一個字串流出來"""
    config = {"configurable": {"thread_id": f"user-{payload.user_id}"}}
    inputs = {"messages": [{"role": "user", "content": payload.message}], "intent": ""}

    def event_generator():
        intent = ""
        try:
            # stream_mode="messages" 會把模型每產生的一小段字都送出來
            for chunk, metadata in graph.stream(inputs, config, stream_mode="messages"):
                node = metadata["langgraph_node"]
                # 只轉送回答節點的字,並用節點名稱當作這輪的意圖
                if node in ("plan", "report", "qa") and chunk.content:
                    intent = node
                    yield _sse({"type": "token", "content": chunk.content})
            yield _sse({"type": "done", "intent": intent})
        except Exception:
            yield _sse({"type": "error", "detail": "教練暫時無法回應,請稍後再試"})

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

import json 和 StreamingResponse 請放在 main.py 最上面的 import 區,ChatRequest 和 graph 在 Day 19 已經 import 過了。

這段有三個重點:

  • graph.stream(..., stream_mode="messages") 取代了 graph.invoke,每次拿到的是模型吐出的一小段字(chunk)和這段字來自哪個節點(metadata)。
  • 判斷意圖的 router 節點不呼叫模型,所以只有 plan、report、qa 三個節點會產生字。節點名稱剛好就是意圖,不用另外查。
  • 函式沒有用 async,FastAPI 會自動把它放到執行緒裡跑,不會卡住其他請求。Day 11 用的 SqliteSaver 是同步版本,所以這裡維持同步最單純。

跟 /chat 一樣用 user-{user_id} 當 Thread ID,所以對話記憶是共用的:先用 /chat 聊的內容,/chat/stream 也記得。

步驟2:測試 - 用 curl 看串流

Swagger 的 /docs 會等整個回應結束才顯示,看不出一個字一個字的效果。我們改用 curl,加上 -N 讓它不要緩衝。

啟動後端:

uvicorn main:app --reload

先在 backend/ 資料夾建一個 req.json,把要送的內容存進去:

{"user_id": 1, "message": "Introduce AWS in one sentence"}

另開一個終端機,在 backend/ 資料夾裡執行:

curl.exe -N -X POST http://127.0.0.1:8000/chat/stream -H "Content-Type: application/json" --data-binary "@req.json"

Windows PowerShell 裡的 curl 其實是另一個指令的別名,要打 curl.exe。macOS 或 Linux 直接用 curl。把內容存成檔案再送,可以避開各種終端機對引號的處理差異。

你應該會看到字一行一行跳出來,最後出現 done:

data: {"type": "token", "content": "Amazon"}

data: {"type": "token", "content": " Web"}

...

data: {"type": "done", "intent": "qa"}

看到一行一行出現,而不是等很久才一次全部出來,後端就完成了。

步驟3:前端匯出後端網址

檔案位置: frontend/src/lib/api.ts
狀態: 修改檔案(只改第一行,加上 export)
用途: 讓新的串流函式也能用同一個後端網址
依賴: 無

export const API_BASE_URL =
  process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000";

檔案其他部分(apiFetch)不用動。

步驟4:寫串流函式 streamChat

檔案位置: frontend/src/lib/chatStream.ts
狀態: 新增檔案
用途: 送出訊息,一邊讀後端串流回來的資料,一邊把每個字交給呼叫者
依賴: lib/api

import { API_BASE_URL } from "@/lib/api";

// 後端 /chat/stream 會送出的三種事件
type ChatStreamEvent =
  | { type: "token"; content: string }
  | { type: "done"; intent: string }
  | { type: "error"; detail: string };

interface StreamChatOptions {
  userId: number;
  message: string;
  onToken: (token: string) => void;
  signal?: AbortSignal;
}

// 送出訊息並邊收邊處理,每收到一個字就呼叫onToken,結束時回傳教練判斷的意圖
export async function streamChat({
  userId,
  message,
  onToken,
  signal,
}: StreamChatOptions): Promise<string> {
  const response = await fetch(`${API_BASE_URL}/chat/stream`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ user_id: userId, message }),
    signal,
  });

  if (!response.ok || !response.body) {
    throw new Error(`API錯誤:${response.status}`);
  }

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

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

    // stream: true 讓被切在中間的中文字先留著,等下一包資料來了再接起來
    buffer += decoder.decode(value, { stream: true });

    // 每則事件以空白行結尾,最後一段可能還沒收完,先留在buffer
    const parts = buffer.split("\n\n");
    buffer = parts.pop() ?? "";

    for (const part of parts) {
      if (!part.startsWith("data: ")) {
        continue;
      }
      const event = JSON.parse(part.slice("data: ".length)) as ChatStreamEvent;

      if (event.type === "token") {
        onToken(event.content);
      } else if (event.type === "done") {
        intent = event.intent;
      } else {
        throw new Error(event.detail);
      }
    }
  }

  return intent;
}

網路資料不會剛好依事件邊界送達,一包資料可能只有半則事件,也可能包含多則。因此先把內容放進 buffer,以 \n\n 切出完整事件,未完成的部分留到下一輪接續處理。

步驟5:聊天泡泡的樣式

檔案位置: frontend/src/app/chat/chat.module.css
狀態: 新增檔案
用途: 讓使用者的話靠右、教練的話靠左,各有一個泡泡
依賴: 無

.page {
  max-width: 640px;
  margin: 0 auto;
  padding: 16px;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 320px;
  max-height: 60vh;
  overflow-y: auto;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 12px;
}

.bubble {
  max-width: 80%;
  padding: 10px 14px;
  border-radius: 16px;
  white-space: pre-wrap;
  line-height: 1.6;
}

.user {
  align-self: flex-end;
  background: #2563eb;
  color: #fff;
}

.coach {
  align-self: flex-start;
  background: #f1f1f1;
  color: #111;
}

.inputRow {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

.input {
  flex: 1;
  padding: 10px 12px;
  border: 1px solid #ccc;
  border-radius: 8px;
  font-size: 16px;
}

.css 結尾加上 module 是 Next.js 內建支援的寫法,每個樣式名稱只對這一頁有效,不會影響別的頁面,所以不用再裝任何套件。

步驟6:改寫對話頁

檔案位置: frontend/src/app/chat/page.tsx
狀態: 修改檔案(整個覆蓋 Day 23 的版本)
用途: 呼叫 streamChat,讓教練的回覆逐字長出來,並支援停止
依賴: context, lib/chatStream, chat.module.css

"use client";

import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { streamChat } from "@/lib/chatStream";
import styles from "./chat.module.css";

interface ChatMessage {
  role: "user" | "coach";
  content: string;
}

export default function ChatPage() {
  const { userId } = useAuth();
  const router = useRouter();
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");
  const [sending, setSending] = useState(false);
  const abortRef = useRef<AbortController | null>(null);
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (userId === null) {
      router.push("/login");
    }
  }, [userId, router]);

  // 每次訊息變長,就捲到最底下
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  // 離開這一頁時,順便把還在進行中的串流切斷
  useEffect(() => {
    return () => abortRef.current?.abort();
  }, []);

  // 把新收到的字接到最後一則訊息(也就是教練正在回的那一則)後面
  const appendToLastMessage = (text: string) => {
    setMessages((prev) =>
      prev.map((msg, index) =>
        index === prev.length - 1 ? { ...msg, content: msg.content + text } : msg
      )
    );
  };

  const handleSend = async () => {
    const text = input.trim();
    if (!text || userId === null || sending) {
      return;
    }

    const controller = new AbortController();
    abortRef.current = controller;

    // 先放使用者的訊息,再放一個空的教練訊息,等字一個一個長出來
    setMessages((prev) => [
      ...prev,
      { role: "user", content: text },
      { role: "coach", content: "" },
    ]);
    setInput("");
    setSending(true);

    try {
      await streamChat({
        userId,
        message: text,
        onToken: appendToLastMessage,
        signal: controller.signal,
      });
    } catch (error) {
      // 使用者自己按停止不算錯誤,其他情況才顯示提示
      const stoppedByUser =
        error instanceof DOMException && error.name === "AbortError";
      if (!stoppedByUser) {
        appendToLastMessage("(教練暫時無法回應,請稍後再試)");
      }
    } finally {
      setSending(false);
      abortRef.current = null;
    }
  };

  return (
    <main className={styles.page}>
      <h1>跟教練聊聊</h1>
      <div className={styles.messages}>
        {messages.map((msg, index) => (
          <div
            key={index}
            className={`${styles.bubble} ${
              msg.role === "user" ? styles.user : styles.coach
            }`}
          >
            {msg.content || "..."}
          </div>
        ))}
        <div ref={bottomRef} />
      </div>
      <div className={styles.inputRow}>
        <input
          className={styles.input}
          value={input}
          onChange={(event) => setInput(event.target.value)}
          onKeyDown={(event) => event.key === "Enter" && handleSend()}
          placeholder="輸入想跟教練說的話"
        />
        {sending ? (
          <button onClick={() => abortRef.current?.abort()}>停止</button>
        ) : (
          <button onClick={handleSend}>送出</button>
        )}
      </div>
    </main>
  );
}

幾個設計的小地方:

  • 送出時先放一個內容是空字串的教練訊息,之後每收到一個字就接在這則訊息後面。還沒有字的時候顯示 ...,使用者知道教練正在想。
  • 「簡單的連接管理」靠 AbortController:按「停止」或離開這一頁,前端就切斷連線,不會再收到新的字,也不會對已經不存在的畫面更新狀態。
  • 送出中時,送出按鈕會換成停止按鈕,也不能重複送出,避免兩條串流同時往同一則訊息接字。

步驟7:測試

確認後端和 Ollama 都開著,然後:

cd frontend
npm run dev

進入 http://localhost:3000,登入後點導覽列的「對話」,輸入一句話按送出。你應該會看到:

  1. 先出現你的藍色泡泡,旁邊有一個顯示 ... 的灰色泡泡
  2. 不到一兩秒,灰色泡泡開始一個字一個字長出來
  3. 長完後,按鈕從「停止」變回「送出」

再試一次停止:問一個會回答很久的問題(例如「請介紹 AWS 的十個服務」),字開始出現後按「停止」,文字應該立刻停住,按鈕變回「送出」,可以馬上問下一題。

我自己實測,一句 30 幾個字的回答,第一個字大約 0.7 秒出現,整句在 4 秒左右完成。你的數字會因為電腦和模型而不同,重點是第一個字很快就出現。


常見問題

按送出後一直顯示 ...,很久才有字

第一次呼叫時 Ollama 要先把模型載入記憶體,可能要多等好幾秒,之後就會快很多。如果等很久還是沒反應,先確認 Ollama 有在跑,並用步驟 2 的 curl.exe -N 試後端有沒有輸出。

字不是一個一個出現,而是等很久後整段一次跳出

先用步驟 2 的 curl.exe -N 判斷問題在哪邊。如果 curl 也是一次全部出現,是後端的問題,檢查 /chat/stream 有沒有用 graph.stream 而不是 graph.invoke。如果 curl 是逐字出現,但網頁不是,檢查前端是不是用了 Day 23 的 apiFetch(它會等整個回應結束),而不是 streamChat。

Windows 用 curl 傳中文,後端回 There was an error parsing the body

Windows 命令列直接傳給 curl 的中文,編碼不是 UTF-8,後端讀不懂。測試時請把請求內容存成 UTF-8 編碼的 req.json 再用 --data-binary "@req.json" 送出,或乾脆改用英文。從網頁送出的中文不受影響。

Console 出現 CORS 錯誤

/chat/stream 跟其他 API 共用同一個 CORS 設定,檢查 Day 6 的 allow_origins 是不是包含 http://localhost:3000,網址要完全一致,包含 port。

重新整理後,之前的泡泡都不見了

跟 Day 23 一樣,畫面上的 messages 只存在這個分頁的記憶體裡,重新整理就會清空。後端的對話記憶還在,教練仍然記得你們聊過什麼,只是畫面不會自動補回舊訊息。


明天會用 Recharts 製作核心指標儀表板,呈現完成率、學習時數、連續天數、能力評分與進度條。


上一篇
Day 23:簡化版前端架構 - 三個核心頁面
下一篇
Day 25:核心指標儀表板 - 學習進度可視化
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言