iT邦幫忙

2026 iThome 鐵人賽

DAY 19
1
AI Engineering

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

Day 19:把對話圖接上 API - Checkpoint 在真正服務裡也要撐得住

  • 分享至 

  • xImage
  •  

Day 11 已驗證 Checkpoint 能在重啟後保留對話歷史:chat_once.py 執行兩次,即使中間關閉終端機,仍能接續對話。不過 chat_once.py 每次都是新的 Python 行程,與 Day 13 到 Day 18 的 main.py 服務分開,uvicorn main:app 也尚未實際呼叫 graph.py。

Day 24 的聊天頁面需要透過 HTTP API 與後端互動。今天會把已有 Router 與 Checkpoint 的 graph.py 接進 main.py,新增 POST /chat,並驗證完整服務重啟後仍能保留對話記憶。

為什麼今天才接,不是 Day 12 路由做完就接

Day 12 到 Day 17,plan_node、report_node、qa_node 內容一直在變動:Day 12 三個都是空殼,Day 13 只有 plan_node 換成真正呼叫 Planner,其他兩個到今天都還是 Day 12 的簡化版本。如果 Day 12 路由一做完就急著把 /chat 公開出去,等於每次 Node 內容調整都要重新對外公告一次介面,沒有意義。現在 Day 17 Daily Coach 也做完了,main.py 已經是一支穩定運作的 FastAPI 服務,這是把對話圖跟其他端點放在一起、一次到位的合理時機。

Thread ID 從哪裡來:不讓前端操心

Day 11 的範例是自己在腳本裡寫死 thread_id="demo-user"。今天要讓每個使用者有自己的對話線,最簡單的做法是拿 user_id 直接算出 Thread ID,例如 f"user-{user_id}"。前端呼叫 /chat 時只需要帶 user_id 和訊息內容,完全不用知道「Thread」這個概念存在,這件事對前端來說應該是後端內部的實作細節。

今天先讓每位使用者只有一條對話線。日後若要支援多個不同主題的對話,前端需額外帶入對話 ID,而非只由後端以 user_id 產生唯一的 Thread ID。

/chat 跟 /plans、/progress/log 是兩條不相干的路

今天的 /chat 呼叫 plan_node、report_node 時,只是讓模型用不同的系統提示詞回話,並不會真的呼叫 Day 13 的 generate_plan 去生成一份計畫、也不會呼叫 Day 18 的 log_progress 去寫進度記錄。使用者在聊天視窗說「我今天做完了」,教練只會回一句鼓勵,資料庫裡的任務狀態不會被改動,要標記完成還是得走 Day 15 的 PATCH /tasks/{task_id} 或 Day 18 的 POST /progress/log。

這是目前的簡化。若要讓聊天直接觸發資料庫操作,例如辨識「我做完了」後建立進度記錄,plan_node 與 report_node 必須加入資料庫查詢邏輯,並辨識使用者指涉的任務。今天先完成對話 API 與記憶持久化。


實作步驟

步驟1:定義 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(接續 Day 18 的內容,繼續往下加)
用途: 定義 /chat 端點的請求與回應格式
依賴: pydantic

class ChatRequest(BaseModel):
    """POST /chat 的請求格式"""
    user_id: int
    message: str = Field(min_length=1, description="使用者這輪說的話")


class ChatResponse(BaseModel):
    """POST /chat 的回應格式"""
    intent: str
    reply: str

步驟2:寫 POST /chat

檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 18 的內容,繼續往下加)
用途: 把使用者訊息送進對話圖,回傳教練的回應與判斷的意圖
依賴: schemas, graph

from graph import graph
from schemas import ChatRequest, ChatResponse


@app.post("/chat", response_model=ChatResponse)
def chat(payload: ChatRequest) -> ChatResponse:
    """把使用者訊息送進對話圖,回傳教練的回應與判斷的意圖"""
    config = {"configurable": {"thread_id": f"user-{payload.user_id}"}}

    result = graph.invoke(
        {"messages": [{"role": "user", "content": payload.message}], "intent": ""},
        config,
    )

    return ChatResponse(intent=result["intent"], reply=result["messages"][-1].content)

from graph import graph 這行是今天真正的重點,其他都只是包裝。graph 這個物件在 graph.py 被 import 的當下就已經建立好(模組層級的程式碼只會執行一次),main.py 啟動時只會連一次 checkpoints.sqlite,之後每次呼叫 /chat 都共用同一個已經編譯好的圖,不會每次請求都重新組圖、重新連線。

/chat 沒有用到 Depends(get_db),因為今天這支端點完全不碰 Day 5 的 app.db,它只碰 graph.py 自己管理的 checkpoints.sqlite,兩個資料庫從 Day 11 開始就是分開的,今天沒有讓它們產生任何交集。

步驟3:測試 - 先確認同一個服務行程裡,多輪對話正常

啟動後端伺服器:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,用 POST /chat 依序測試:

第一句:

{
  "user_id": 1,
  "message": "我叫Alice,正在準備AWS Solutions Architect認證"
}

應該收到類似:

{
  "intent": "qa",
  "reply": "你好Alice!準備AWS SA認證是個很棒的目標..."
}

第二句(同一個 user_id):

{
  "user_id": 1,
  "message": "我剛剛說我叫什麼名字?"
}

reply 應該正確答出「Alice」,證明 thread_id 是根據 user_id 固定算出來的,兩次呼叫接到了同一條對話線。

步驟4:測試 - 真正終止服務,確認記憶還在

這一步要驗證的不是「重新整理網頁」,而是整個 uvicorn 行程被終止之後,記憶還在不在。

  1. 回到跑 uvicorn 的終端機,按 Ctrl+C,等它完全停止(不是 --reload 偵測到檔案變更那種自動重啟,是你自己手動終止行程)
  2. 重新執行 uvicorn main:app --reload
  3. 再打一次 POST /chat:
{
  "user_id": 1,
  "message": "我剛剛提到我在準備什麼認證?"
}

reply 應該正確答出「AWS Solutions Architect」,這代表 Day 11 證明過的 Checkpoint 機制,在真正的 API 服務裡一樣成立:對話記憶存在 checkpoints.sqlite 檔案上,不是存在服務行程的記憶體裡,行程整個重啟也不影響。

步驟5:測試 - 不同 user_id 的對話互不干擾

{
  "user_id": 2,
  "message": "我剛剛說我叫什麼名字?"
}

因為 user_id=2 是第一次呼叫,thread_id 是 user-2,跟 user-1 完全是不同的 Thread,模型應該答不出名字(甚至會反問你是誰),這證明不同使用者的對話確實被 Checkpoint 分開存放,不會互相污染。


常見問題

第一次呼叫 /chat 反應很慢

跟 Day 8、Day 9 遇到的情況一樣,Ollama 第一次載入模型需要暖機時間,屬於正常現象,跟今天的改動無關。

sqlite3.OperationalError: database is locked

checkpoints.sqlite 目前是一個連線被整個 FastAPI 服務共用(Day 11 建立時就是這樣設計),如果同時有好幾個請求並發打進 /chat,理論上有機會遇到鎖定的狀況。今天先接受這個簡化:實際測試時一次只打一個請求,不會遇到問題;如果之後要應付真正的高併發流量,需要改用連線池或幫每個請求開獨立連線,這不在今天的範圍內。

換了 user_id 卻好像還記得上一個使用者說的話

檢查 thread_id 的組法是不是真的用了 payload.user_id,而不是不小心寫死成固定字串。今天的 Thread ID 是 f"user-{payload.user_id}",只要 user_id 不同,理論上一定是全新的 Thread,不會混到別人的對話。

為什麼 /chat 回傳的 intent 有時候跟預期不一樣

classify_intent(Day 12 寫的)只是關鍵字比對,不是完全精準的語意理解,這個限制今天沒有改變。如果測試時發現常見的說法一直被誤判,可以回頭調整 Day 12 router_node.py 裡的關鍵字清單,跟今天把圖接上 API 這件事是兩個獨立的問題。

/chat 說使用者「做完了」,為什麼資料庫裡的任務狀態沒有變

如前面設計小節提到的,今天 /chat 只負責對話回應,不會觸發 Day 15、Day 18 的資料庫操作。要標記任務完成,目前還是得直接呼叫 PATCH /tasks/{task_id} 或 POST /progress/log,這是今天刻意保留的邊界,不是遺漏。


進度回顧

今天把 Day 11 到 Day 17 逐步養出來的對話圖,第一次接上真正對外服務的 API。POST /chat 讓前端(Day 24 會用到)能透過簡單的 HTTP 請求跟教練對話,Thread ID 根據 user_id 自動決定,不用前端操心。也重新驗證了一次 Checkpoint 機制:這次不是靠獨立腳本前後執行兩次,而是把整個服務行程真正關掉重開,對話記憶依然完整。

系統現在是這樣的:

Day 1  ✓ 產品定義完成
Day 2  ✓ 開發環境準備
Day 3  ✓ 專案架構設計
Day 4  ✓ 資料庫設計
Day 5  ✓ SQLite 資料庫建置
Day 6  ✓ FastAPI 基礎
Day 7  ✓ 使用者檔案 API
Day 8  ✓ 理解 LLM Agent 的本質
Day 9  ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent
Day 14 ✓ 簡化的排程邏輯
Day 15 ✓ 計畫 CRUD API
Day 16 ✓ 計畫審核與用戶反饋
Day 17 ✓ Daily Coach
Day 18 ✓ 進度記錄系統
Day 19 ✓ 把對話圖接上 API(今天)
Day 20 ⬜ 簡化版測驗系統

現在後端已經有兩條完整可用的路:一條是 /chat 這種對話式的互動,一條是 /plans、/tasks、/progress 這些結構化的 CRUD。明天要幫系統加上第三種能力:自動生成測驗題目、讓使用者作答、自動評分,用比對話更明確的方式檢驗使用者到底學會了沒有。


上一篇
Day 18:進度記錄系統 - 讓資料撐起真正的個人化
下一篇
Day 20:簡化版測驗系統 - 用比對話更明確的方式驗收學習成效
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-10-04 00:45:36

我找到鑰匙了

我要留言

立即登入留言