標籤: Vibe Coding

  • Vibe Coding 協助物聯 AI 開發:從 RTSP 撞牆到 Telegram Bot 上線的完整實戰

    這是一篇關於 Vibe Coding(憑感覺邊聊邊寫)在物聯 AI 開發上的實戰紀錄。主題聽起來很酷,但實情是:我只是想解決一個很平凡的問題 —— 怎麼把家裡長照白板上的磁鐵打點,自動變成醫生回診時能看的資料。

    這篇會誠實記錄這一晚跟 AI 協作的過程、做對的決策、撞到的牆、以及學到的方法論。不是成功故事,中間我還沒跑出一份真正可用的資料。但我覺得過程本身比結果更值得寫下來。

    重點摘要

    • Vibe Coding 不是憑空想像,而是即時驗證 —— 每個假設都在對話中被一條 curl / 一行 ffmpeg 打臉或確認
    • Gemma 4 跟 Gemini 3 Flash Preview 在同一張模糊中文手寫白板上差距巨大:前者一直幻覺亂編項目,後者誠實回 null
    • Schema-constrained Prompt + 信心度分級是物聯 AI 應用的關鍵 —— 寧缺勿錯,比準度更重要
    • 最後撞到的不是模型牆,是光學物理牆 —— RTSP 1080p 遠距離下磁鐵只有 3~5 像素,任何 VLM 都無解
    • 與 AI 協作最大的價值是迭代速度:從想法 → curl 驗證 → 決策 → 下一步,一晚走完通常要一週的探索

    什麼是 Vibe Coding?為什麼它適合物聯 AI 開發

    Vibe Coding 是一種開發風格,核心精神是:不先寫規格、不先畫架構,先跟 AI 對話,在對話中讓問題結構自己浮現。聽起來很鬆散,但在物聯 AI 開發(IoT + AI 結合)的情境下,這種作法反而比傳統瀑布式規劃更有效。

    原因很簡單:物聯 AI 專案有太多無法事先確定的變數 —— 攝影機實際畫質、現場光線、模型對中文手寫的容忍度、API 認證流程的細節、現場使用者的真實習慣。這些東西你寫再多規格也寫不清,只能邊試邊改。

    傳統作法是先買硬體、接好線、寫一堆程式、跑起來才發現 OCR 讀不到。Vibe Coding 作法是:在對話中模擬整條管線,每一步都先驗證再決定下一步。成本極低,迭代極快。

    實戰情境:家母的長照交班白板

    先講背景。家裡照顧阿嬤的居服員會在客廳牆上用一塊大白板做交班記錄:夜間活動、睡前狀況、昨夜睡眠、早/午/晚餐、活動、外出、陪伴狀況、排泄狀況、洗澡,共 12 個項目。每一項下面有幾個選項方格,居服員把一顆彩色磁鐵貼在正確的選項上。

    用意是交班:早班居服員進門就知道夜間情況,中班看早上發生什麼事,晚班接手時看到整天脈絡。白板是「人看的 UI」,極度務實。

    我這邊的需求是:這些資料要每月匯整,讓神經內科醫師看到阿嬤這個月的生活趨勢 —— 是不是睡眠惡化、食慾變差、活動量下降。這關係到藥物調整。原本我每天晚上八點親自去拍照記錄到 iDempiere,但我很清楚一件事:

    靠人不穩定。我不能保證自己每天都拍照,請妹妹拍她都不拍。任何依賴人的環節最終都會斷掉。

    所以目標很明確:零人工依賴的自動化管線。RTSP 攝影機 → 抽幀 → VLM 識別白板狀態 → 寫進 iDempiere。今晚就是要從零對話出這套設計。

    第一步:用 ffmpeg 抓一張給 AI 看

    對話一開始,AI 想直接讀 RTSP 流,但我的 Ubuntu 上沒裝 ffmpeg,只有 VLC,而且 VLC 一直被 satip 模組攔截。試了幾個旗標都失敗。最後決定直接 apt-get install ffmpeg,然後:

    ffmpeg -y -rtsp_transport tcp \
      -i "rtsp://:@192.168.0.53:554/stream1" \
      -frames:v 1 -update 1 -q:v 2 /tmp/tapo/whiteboard_hd.jpg

    一張 1920×1080 的 frame 落地。第一個教訓:TAPO 有 stream1(主碼流,1080p)跟 stream2(子碼流,較低畫質),白板 OCR 一定要用 stream1,子碼流字跡會糊到任何模型都讀不出來。

    把第一張 frame 丟給 AI 看,AI 發現:

    • 畫面是廣角客廳全景,白板只佔左後方約 25%
    • 白板有角度(側視),字有透視變形
    • 阿嬤正躺在沙發上睡 —— 這畫面本身就能當「夜間睡眠」的訊號來源
    • 時間戳在左上角,直接可當記錄時間

    這是 Vibe Coding 的第一個價值:「看到了才知道要問什麼」。事先想像不到的細節(阿嬤就在沙發上、一顆鏡頭可同時驅動多個欄位),第一張實拍 frame 全部揭露。

    Gemma 4 vs Gemini 3 Flash Preview:一張圖的殘酷對比

    我手上的 Gemini API Key 支援 Google AI Studio,我問 AI 能不能用我已經在跑的 gemma-4-26b-a4b-it(我本來拿它做 HN 文章摘要)。AI 坦白說:不確定 Gemma 4 這個 MoE 版本有沒有 vision 能力,最好的方式是直接寫 script 試。

    寫完測下去,兩個結論:

    1. Gemma 4 確實吃圖 —— 這是好消息
    2. 但它嚴重幻覺 —— 它把日期讀成「4月10日」(實際 4/14)、項目名自創「睡眠品質/活動量/食事/盥洗」(白板上根本沒這些)、備註整段編造「各位家屬提醒…」。所有項目都回「綠」,明顯是在套公版答案

    切換到 gemini-3-flash-preview(Gemini 3 系列的 flash preview 版本),同一張圖,結果判若兩人:

    項目Gemma 4Gemini 3 Flash Preview
    項目名自創 5 個通用項目讀出 13 個具體項目
    備註段落純幻覺讀出「為了照護過程順暢請大家配合…飲食攝入小於 500ml 請 Line 通知…」
    品質差距低解析度下直接編造雖有 OCR 誤差但是「在讀真的東西」

    Gemini 3 Flash Preview 讀出的備註段落跟我白板上實際寫的紅字幾乎一致 —— 它不是在猜,是真的看到了。雖然還有誤差(把「外出狀況」讀成「外服藥記」、把「排泄」讀成「排便」),但那些是視覺相似字誤差,不是幻覺。

    這是第二個教訓:「同一張圖丟不同模型」是物聯 AI 應用最該做的前測。同價位的模型在模糊中文手寫這種邊緣任務上差距可以是幾倍級的。不要以為「我手上這把就夠用」。

    iDempiere REST API:從文件陷阱到 10 分鐘跑通

    OCR 能讀不代表能寫。寫入端是我自己建的 iDempiere,一張叫 Z_momSystem 的表。AI 不知道 schema,我也懶得翻 UI,直接讓它打 REST API 取。

    這裡踩第一個坑:iDempiere REST API 的認證流程文件跟實作不一致。我的 brain 筆記寫兩步驟(POST 拿暫時 token → PUT 帶 clientId 換 session token),但 AI 試了之後發現:直接在 POST 時一次帶齊所有 ID 也可以成功:

    POST /api/v1/auth/tokens
    {
      "userName": "",
      "password": "",
      "parameters": {
        "clientId": 1000000,
        "roleId": 1000000,
        "organizationId": 0,
        "warehouseId": 0,
        "language": "zh_TW"
      }
    }
    → 直接回 session token

    四個參數缺一不可,少 roleId 就回 Missing roleId parameter。這種小細節沒人會寫在文件裡,只能撞過才知道。AI 在對話中撞完就直接更新我的 brain file:

    [source: 長照 OCR 專案 2026/4/14] 驗證過 POST 一次帶齊 clientId/roleId/organizationId/warehouseId 可直接拿到可用 token,不用走 PUT 兩步。

    這是 Vibe Coding 第三個價值:踩到的坑立刻回饋到未來。不寫記錄的話,下個專案又會踩一次同樣的坑。

    Schema-Constrained Prompt:讓模型不能亂編

    認證跑通後,AI 直接查 AD_Column + AD_Ref_List,把 Z_momSystem 的 40 個欄位跟 12 個清單型欄位的所有合法選項抓齊:

    欄位合法選項(id → 中文)
    NightActivity 夜間活動C=完成 / N=未完成 / S=抗拒
    BeforeSleepStatus 睡前狀況X=亢奮 / N=正常 / S=疲倦
    LastNightSleep 昨夜睡眠G=良好 / S=斷續 / N=差
    Breakfast/Lunch/DinnerN=正常 / 10000001=少 / 10000009=多 / 10000002=拒食
    ExcretionStatus 排泄狀況10000006=正常 / 10000007=便秘 / 10000008=稀

    拿到完整選項清單後,我跟 AI 共同寫出 Prompt v2,核心設計有四塊:

    1. 嚴格列出 12 個欄位名稱 + 每個欄位的合法 enum,告訴模型「只能從清單裡選,不要自由發揮」
    2. 明確定義磁鐵規則:磁鐵貼在選項上 = 選中;磁鐵在左側待命區 / 沒看到磁鐵 = 未填(null)
    3. 強制輸出信心度:high / medium / low,並規定「寧可標 low 也不要亂猜」
    4. 禁止事項明確化:不要輸出清單外的項目、不要用清單外的值、不要把空白當選中

    這個 Prompt 模式我叫它 Schema-Constrained Prompt:輸出 schema 先於輸入內容確定。模型的自由度被壓縮到只剩「讀」,不能「想」。對資料入庫型應用來說,這是我目前看過最能壓住幻覺的作法。

    信心度機制:為什麼寧缺勿錯比高準度更重要

    我跟 AI 討論一個核心問題:如果 OCR 讀錯一個欄位,後果是什麼?答案是 —— 醫生根據錯誤資料調藥

    這條紅線決定了整個系統的預設行為:

    • High confidence → 直接寫入 iDempiere
    • Medium confidence → 寫入但標記為待確認,月底回診前我掃一次
    • Low confidence → 不寫,保留原圖,等我手動補

    這樣最差情況是「資料不全」而不是「資料錯誤」。對臨床情境來說,null 永遠比錯值安全

    最後的物理牆:磁鐵只有 3 像素

    所有準備工作做完,Prompt v2 在手、schema 對齊、認證跑通。我說「跑」,ffmpeg 抓一張新的 frame、裁切、送 Gemini 3 Flash Preview。結果回來:

    {
      "date": "4月10日",
      "patient_name": "",
      "items": {
        "NightActivity": {"value": null, "confidence": "high"},
        "BeforeSleepStatus": {"value": null, "confidence": "high"},
        ... 12 個項目全部 null ...
      },
      "overall_notes": "白板所有項目的磁鐵均位於最左側待命區,表示尚未填寫狀態。"
    }

    問題是 —— 我明明告訴 AI,最上面兩項的磁鐵是貼在選項上的。模型卻全讀成 null。這代表什麼?

    我們一起算了一下畫素:在當前 TAPO 攝影機位置,1080p 畫面中白板只佔約 25%,每個選項方格大概 40×20 像素,磁鐵大約 3~5 像素寬。

    任何 VLM 都讀不出 3~5 像素的物體位置。不是模型問題,是物理極限。

    這是整晚最重要的一個教訓。我原本以為換到 Gemini 3 就能解決一切,但真正的瓶頸不在模型,在光學。你沒辦法用演算法放大不存在的資訊。

    有意思的是,這個結論不是我預設的,是對話中算出來的。如果我沒跟 AI 一起 debug、一起看 crop、一起數像素,我可能還在抱怨「模型不夠強」,然後浪費錢去升級 model tier。實際上答案是「把鏡頭移近到 1.2 公尺」或「把磁鐵換大到 3 公分以上」—— 兩個都是硬體側的改動。

    Vibe Coding 在物聯 AI 開發上的六個心得

    整晚下來,我想整理出一些可以帶到下個專案的方法論:

    1. 第一張真實 frame 永遠比規格重要

    物聯 AI 專案第一件事不是畫架構圖,是用 ffmpeg / curl 抓一張真實資料給 AI 看。看到之後你才會發現「原來畫面裡還有沙發」、「原來字有角度」、「原來磁鐵只有 3 像素」—— 這些事事先想不到。

    2. 同一任務丟多個模型前測

    不要假設「手上這把就夠」。Gemma 4 跟 Gemini 3 Flash Preview 在同一張圖上差距巨大,如果我沒前測直接開始寫 production code,下游全部要重做。前測成本只有一個 Python script。

    3. Schema 先於 Prompt

    Prompt 不應該從「我想知道什麼」開始寫,應該從「我資料庫裡有什麼欄位、合法值是什麼」開始寫。把 enum 硬塞進 prompt,模型就只能從那個清單選。幻覺空間瞬間縮到 0。

    4. 信心度是一等公民

    每個欄位都要輸出信心度,而且模型要被明確告知「寧缺勿錯」。信心度不是錦上添花,是讓系統可以在「自動化」跟「人工審核」之間無縫切換的基礎設施。

    5. 踩到的坑要立刻寫回知識庫

    iDempiere REST API 的認證細節、TAPO stream1/stream2 的差異、VLM 對 3 像素物體的物理極限 —— 這些東西踩過一次就要寫下來。否則半年後你或你的下一個 AI 助手會原地再踩一次。

    6. 永遠先問「這個瓶頸是模型還是物理」

    當 AI 應用做不出效果時,工程師的直覺是「換更大的模型」。但物聯 AI 應用有一半以上的瓶頸是光學 / 聲學 / 感測器物理,不是模型。在升級 model tier 之前,先算一下原始訊號的解析度夠不夠。

    一晚的進度清單:完成、撞牆、下一步

    分類項目狀態
    環境ffmpeg + venv + google-genai SDK
    模型選型gemini-3-flash-preview 確認為白板 OCR 唯一可用
    API 認證iDempiere REST auth flow + brain 更新
    SchemaZ_momSystem 40 欄位 + 12 清單欄位合法值
    Promptv2 schema-constrained + 信心度
    實拍 OCR12 項全 null(物理極限)
    下一步鏡頭移近至白板前 ~1.2m 或把磁鐵換大🔜

    尾聲:AI 協作不是減少思考,是加速思考

    這一晚最大的收穫不是程式,是幾個原本需要好幾天才能驗證的判斷,在對話中幾個小時就走完了:

    • 「Gemma 4 能不能做 vision OCR」—— 10 分鐘驗證完
    • 「iDempiere REST API 認證怎麼打」—— 15 分鐘跑通
    • 「Z_momSystem schema 長怎樣」—— 5 分鐘 curl 查完
    • 「Prompt 用什麼結構壓住幻覺」—— 30 分鐘寫完 + 測完
    • 「OCR 失敗的根本原因是什麼」—— 從「模型不夠強」修正到「物理像素不夠」

    如果自己慢慢摸,這些決策可能得花一個禮拜,還未必會發現最後那個「像素物理牆」的結論。

    但 Vibe Coding 也不是萬靈丹。它的前提是你自己要會即時判斷 AI 給的答案對不對。如果我不知道「1080p 遠距離下一顆磁鐵只有幾像素」該怎麼算,我不會發現光學才是真兇,可能還會繼續跟 AI 討論要換什麼模型。AI 負責快速生成假設跟驗證工具,但關掉討論的那個人還是我

    下一步很清楚:明天白天我會去把攝影機搬到白板正對面 1.2 公尺處,如果還不夠,就把磁鐵換成 3 公分的大顆粒。然後再跑一次 Prompt v2,看看準度能衝到 8/12 還是 12/12。

    等驗證完整條管線跑通,我會再寫第二篇:零人工依賴的長照自動化 —— 從 OCR 到 iDempiere 到每月回診的完整管線。這一篇先停在「撞到物理牆」這裡,因為這個結論本身就夠值得記下來了。

    後續實戰:從撞到物理牆到 Telegram Bot 上線

    前一篇的結尾停在「撞到物理牆」,隔天白天我接著實戰。這一段記錄從 PTZ 鏡頭控制、模型賽馬、到最後關鍵 pivot 的完整過程。

    後續重點摘要

    • ONVIF PTZ 跑通:TAPO C200 用 RTSP 同帳密就能從 ONVIF 控制 pan/tilt,存 preset 在「看媽媽」跟「看白板」間自動切換
    • 但物理牆還在:C200 沒有 ONVIF zoom,白板在畫面裡的絕對大小被鏡頭距離鎖死
    • 模型賽馬揭露真相:Gemma 4 / Gemini 2.5 Flash 會套預設答案假裝在看;只有 Gemini 3 Flash Preview 跟 2.5 Pro 真的「在看」
    • 關鍵 pivot:放棄 RTSP 自動化,改走「手機拍照 + Telegram Bot」,把 human 設計進 loop 當 verifier
    • 最後上線:Telegram Bot + Gemini 3 Flash Preview + iDempiere REST API + systemd service,免費版每日 20 次額度剛好夠 production 用

    第一回合:ONVIF PTZ 成功,但沒有 zoom

    既然 TAPO C200 是 PTZ 型號(有 pan/tilt 馬達),自然想法就是「平常看媽媽、每天 20:00 轉過去拍白板、拍完轉回來」。代價幾乎為零:不改位置、不買硬體、不影響監護。

    TAPO 的本地 API 很頭痛,新版韌體要另外設定「Camera Account」。試了 pytapo 套件一直認證失敗。最後發現 ONVIF 標準協定可以直接用 RTSP 同帳密,port 2020:

    from onvif import ONVIFCamera
    cam = ONVIFCamera("192.168.0.53", 2020, "", "")
    ptz = cam.create_ptz_service()
    media = cam.create_media_service()
    token = media.GetProfiles()[0].token
    
    # Save current position as preset
    req = ptz.create_type("SetPreset")
    req.ProfileToken = token
    req.PresetName = "mom"
    ptz.SetPreset(req)
    
    # Move to whiteboard preset
    req2 = ptz.create_type("GotoPreset")
    req2.ProfileToken = token
    req2.PresetToken = "2"   # whiteboard preset token
    ptz.GotoPreset(req2)

    30 分鐘內建好兩個 preset:mom (pan=0.165, tilt=-0.714) 看沙發、whiteboard (pan=0.2, tilt=-0.3) 看白板。自動切換、抽幀、切回,全程 10 秒內。

    但查 PTZ 能力的時候發現一個殘酷事實:

    AbsoluteZoomPositionSpace: []

    C200 的數位 zoom 不對 ONVIF 開放。pan/tilt 能改變「看哪裡」,但不能改變「看多近」。白板在畫面裡的絕對大小還是被物理距離鎖死,磁鐵還是只有幾個像素。

    這是今天的第一個重要教訓:即使有 PTZ,沒有 zoom 的 PTZ 解決不了光學採樣率不足的問題。pan/tilt 的價值在於切換主題,不是放大細節。

    第二回合:四個模型的殘酷賽馬

    既然 PTZ 轉過去也只是切換主題、不會變清楚,我決定做一件更有意義的事:在同一張困難的 RTSP 照片上,把所有可能的模型跑一遍,看哪個真的「在看」。

    測試方法:請媽媽的居服員確認白板上 12 個項目的真實狀態(4 個 box 1、2 個 box 2、6 個 null),這是 ground truth。同一張 RTSP 照片丟給四個模型,計算準度。

    模型命中行為模式
    Gemini 2.5 Pro8/12 (67%)6/6 null 正確、off-by-one 錯誤,真的有在讀像素
    Gemini 3 Flash Preview6/12非常保守,看不清就全回 null(這在醫療情境反而安全)
    Gemini 2.5 Flash4/12預設 box 1,剛好對 4 個 box 1 項目,是幻覺而不是識別
    Gemma 4 (26B-A4B-IT)3~4/12兩次跑結果不一樣:第一次全 box 2、第二次全 box 1

    最有意義的發現:跑同一張圖兩次讓偽答案現形。Gemma 4 第一次答「10 項 box 2 + 1 項 box 1 + 1 項 null」,第二次答「12 項 box 1」。兩次完全不同,同一張靜態圖,同一個 prompt。這證明 Gemma 4 根本沒在看磁鐵,只是在套一個預設樣板。

    便宜模型「預設 box 1」這個偏誤特別危險,因為它剛好能騙過不注意的測試者:真實世界裡大部分項目本來就選 box 1(例如「完成/正常/穩定」通常是第一格),所以「全答 box 1」天然就有 30~50% 命中率。

    Gemini 2.5 Pro 的 8/12 表現是最高的,錯誤都是「off-by-one」(看到磁鐵、算錯格子),至少是在做真正的視覺識別。但 2.5 Pro 免費版不開放 vision,要付費才能用。

    Gemini 3 Flash Preview 的 6/12 看起來輸給 2.5 Pro,但它的錯誤類型完全不一樣:它的「錯」是把有磁鐵的項目讀成 null,不會產生錯誤的 value。這在醫療場景反而是最安全的行為 —— 寧缺勿錯永遠優於硬填。

    第三回合:投票沒用、upscale 沒用

    既然 Gemini 2.5 Pro 有 67% 準度,同一張圖跑 3 次取多數票應該能把 67% 拉高吧?

    結果投票後掉到 6/12。為什麼?因為 2.5 Pro 的錯誤不是隨機噪音,是系統性偏誤:某個項目 3 次都答同一個錯的答案。投票對系統性錯誤完全沒用,它只會把多數的錯誤鎖定成最終答案。

    接著試 pre-upscale:先用 ffmpeg 把白板區域裁出來、用 Lanczos 4 倍放大、加 unsharp,再丟 Gemini。直覺上放大應該有用,對吧?

    ffmpeg -i wb.jpg -vf "crop=720:820:480:30,scale=iw*4:ih*4:flags=lanczos,unsharp=7:7:1.5" wb_up.jpg

    結果:2.5 Pro 從 8/12 掉到 6/12,3 Flash Preview 從 6/12 掉到 2/12。放大反而讓準度下降。

    事後想通:Lanczos 是重採樣,不是超解析。它只是讓相鄰像素平滑插值,沒有增加資訊量。原本 3 像素的磁鐵放大成 12 像素,但那 12 像素是模糊的平均值,模型反而更難從中辨認形狀。

    要真的增加資訊量必須用 AI 超解析(Real-ESRGAN 這類),而不是幾何插值。但那會在機器上增加 PyTorch + 模型的負擔。與其走這條,不如直接面對問題:RTSP 這條路本身就有上限

    關鍵 Pivot:把 human 設計進 loop

    這是今天最重要的決策點。

    我原本堅持 RTSP 全自動,理由是「靠人不穩定,請妹妹拍她都不拍」。這個理由本身沒錯 —— 強制依賴他人的流程一定會斷。但我把「靠他人」跟「靠自己」混為一談了。我自己每天可以拍照,不穩定的只是「依賴別人」這一環

    更深層的領悟:為什麼執著於「100% 自動」?因為我下意識想把人完全排除。但對一個給醫生回診參考的月報系統來說,人的角色不該是全被排除,而應該是最後一道驗證。理由:

    • OCR 永遠有錯,醫療資料的錯誤比缺失代價更高
    • 家屬(我)本來就是最終看月報的人,有修正權限跟動機
    • 拍照的動作本身就是「我已經確認白板內容正確」的確認點
    • 沒有必要為了系統 purity 去承擔「誤導醫生」的風險

    這是整個專案最有價值的 design pattern 轉變 —— 從「zero-human automation」到「human-as-verifier」。具體分工:

    角色職責
    我(家屬)看白板、判斷、拍清楚的照片、事後在 iDempiere 修正 OCR 誤判
    Bot + Gemini 3把照片轉成結構化資料寫進 iDempiere、附上原檔
    iDempiere永久保存、提供 UI 讓月底回診時查閱

    最可怕的情境 —— OCR 幻覺亂寫進 DB 誤導醫生 —— 被「我本來就要檢查 iDempiere」這個 policy 擋住了。系統可以不完美,因為人在迴圈裡。

    Telegram Bot 架構

    決定走 human-in-the-loop 後,剩下的工程問題是「怎麼把手機拍的照片快速送到 server」。選項比較:

    方案優點缺點
    Telegram Bot零 app 安裝、即時回饋、從任何地方都能傳要建 bot token
    Syncthing 資料夾同步純本機、無雲端要裝 app + 手動移動檔案
    自架 HTTP 上傳頁不用 app要開 browser、每次登入

    Telegram 明顯最低摩擦,而且我本來就用 Telegram 傳照片 debug。一個 @BotFather 建 bot、5 分鐘拿到 token、寫一個 Python 長輪詢腳本就搞定。

    Bot 的最終能力:

    使用者動作Bot 行為
    📸 傳白板照片Gemini 3 Flash Preview OCR → 找今天紀錄 → update 或 create → 附加照片 → drain 文字佇列
    📝 傳 LINE 居服員回報文字[SHIFT|HH:MM] 前綴 → prepend 到今天紀錄的 Description 欄位
    🕑 今天還沒紀錄時傳文字存 pending 佇列,等照片來時自動塞入
    /pending, /clear, /id管理指令

    Shift 標記:用時段而不是時間點

    iDempiere 既有資料的 Description 欄位有一個我沒注意到的約定格式:

    [NIGHT|20:13]今天把東西往外丟
    [DAY|17:56]攻擊居服員

    格式是 [SHIFT|HH:MM]事件,新的在最上面,\n 分隔。照顧者三班制:

    • DAY(早班):07:00-17:59
    • NIGHT(晚班):18:00-23:59
    • GRAVEYARD(大夜):00:00-06:59

    Bot 根據當下時間自動產生正確的 shift 標記。這個設計好處是醫生看月報時能直接對應到「哪一班回報的事件」,這對失智照護尤其重要 —— 同一個病人,不同班次的表現可能完全不同(例如日落症候群)。

    Find-or-Create:同一天一筆紀錄

    一個設計決策:同一天傳多次照片要建新紀錄還是更新舊的?

    選項:

    • (a) 每次都 create 新的 → iDempiere 一天可能有多筆,報表聚合需要 GROUP BY 日期
    • (b) 同天 find-or-update → 資料乾淨,但要實作 OData filter 查當天紀錄
    • (c) 帶時序保留所有版本 → 最完整但最複雜

    選 (b)。理由是月底回診時醫生看的是「今天的狀態」,不需要看一天之內的修改歷史。實作核心:

    def idempiere_find_today(token):
        date_str = datetime.now().strftime("%Y-%m-%d")
        r = requests.get(
            f"{IDEMPIERE_URL}/models/z_momsystem",
            headers={"Authorization": f"Bearer {token}"},
            params={"$filter": f"DateDoc eq '{date_str}' and Name eq '{PATIENT_NAME}'", "$top": 1},
        )
        recs = r.json().get("records", [])
        return recs[0]["id"] if recs else None

    還有一個巧妙設計:OCR 回 null 的欄位不進 PUT payload。這樣早上拍一次只填前 6 項、晚上拍一次只改後 6 項,早上的資料不會被晚上的 null 洗掉。整個合併邏輯在 Python 端就搞定,iDempiere 端只接受「明確填入」的欄位。

    iDempiere 附件上傳的小坑

    iDempiere REST API 的附件端點是 POST /models/{table}/{id}/attachments,payload 是 {"name": "...", "data": "base64..."}。踩了一個坑:重複檔名會回 409

    解法:上傳時把當下的 HHMMSS 塞進檔名 suffix。同一張照片重複上傳也不會衝突:

    ts = datetime.now().strftime("%H%M%S")
    fn = f"{orig.stem}_up{ts}{orig.suffix}"

    Systemd Service 自動重啟

    最後收尾:bot 用 systemd service 跑,開機自動啟動 + 崩潰自動重啟。關鍵是設定 Restart=on-failureRestartSec=10

    [Unit]
    Description=Tapo Caregiver Telegram Bot
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=tom
    WorkingDirectory=/home/tom/tapo-caregiver
    ExecStart=/home/tom/tapo-caregiver/venv/bin/python telegram_bot.py
    Restart=on-failure
    RestartSec=10
    Environment=PYTHONUNBUFFERED=1
    
    [Install]
    WantedBy=multi-user.target

    成本:免費版剛好夠用

    這個專案我原本因為 debug 爆量升級了 Google AI Studio 付費版。但實際跑 production 的時候算了一下用量:

    • 晚上 8 點拍一次照片 = 1 次 Gemini 3 Flash Preview 呼叫
    • LINE 回報文字 = 0 次(純文字不進 OCR,只是 prepend 到 Description)
    • 一天總用量:1~2 次

    Gemini 3 Flash Preview 的免費 tier 是 每天 20 次GenerateRequestsPerDayPerProjectPerModel-FreeTier, limit: 20, model: gemini-3-flash),實際從先前的 429 錯誤訊息確認過。

    也就是說 完整上線後可以馬上取消付費,免費額度就夠日常跑。debug 階段的付費只是為了不被 quota 卡住開發速度。

    兩天下來的反思

    1. 物理限制不是模型問題,不要用演算法去試

    RTSP 畫面中磁鐵 3~5 像素這件事,不管換什麼模型、不管怎麼放大、不管怎麼 prompt,都解決不了。我浪費了兩輪嘗試才認清:先驗證訊號源的解析度是否足夠,再決定上面要疊什麼演算法

    2. 便宜模型會偽裝在看

    Gemma 4 和 Gemini 2.5 Flash 在困難任務上會套預設答案(全 box 1 或全 box 2),這種偽答剛好能騙過不注意的準度統計。同一張圖跑兩次是揭穿偽答最便宜的方法 —— 真的在看的模型答案應該高度一致,套公版的會變。

    3. 投票救不了系統性偏誤

    Gemini 2.5 Pro 跑 3 次投票準度從 8/12 掉到 6/12,因為它的錯誤是系統性的(3 次都答同一個錯的答案)。投票只對隨機噪音有用,對系統性偏誤沒用。想救系統性偏誤,要用不同模型交叉比對,而不是同一個模型多次跑。

    4. Human-in-the-loop 不是妥協,是正確設計

    我原本想做 100% 自動化是出於「系統純粹」的直覺。但對醫療情境來說,把人排除才是風險來源。正確問題不是「怎麼不靠人」而是「人在哪個環節最有價值」。答案是:人當最終 verifier,機器做勞力密集的 OCR + 結構化 + 儲存。

    5. 工具思維 vs 平台思維

    我原本想把 RTSP 攝影機變成一個「全知白板記錄系統」,這是平台思維,野心大但難。pivot 之後變成「Telegram bot 幫我打字少打一些」,這是工具思維,目標小但能跑。工具思維的上線速度 ≫ 平台思維的完美程度

    6. 讓 AI 自己跑模型賽馬

    過程中我讓 AI 自動跑一圈 Gemma 4 / 2.5 Flash / 2.5 Pro / 3 Flash Preview,把命中率、錯誤類型、各自的 observation 都列出來。我只要看那張表就能判斷用哪個。這種「讓 AI 幫你比較 AI」的 meta-loop 是 vibe coding 很爽的一點 —— 選型決策從「讀文件猜性能」變成「直接實測算分」。

    最終上線狀態

    組件狀態
    Telegram bot(@your_bot_name,白名單限制)✅ LIVE
    Gemini 3 Flash Preview OCR (schema-constrained + box index + layout map)
    iDempiere REST find-or-create + attachment upload
    Description shift tag (DAY/NIGHT/GRAVEYARD) 自動 prepend
    pending notes queue(文字先到、照片後到的情境)
    systemd service 開機自動啟動
    PTZ ONVIF 控制(暫時不用,因為 pivot 到手機路線)✅ 備用

    每日 SOP:

    1. 晚上 8 點我拍一張白板照片
    2. Telegram 分享給 @your_bot_name
    3. Bot 跑 OCR + 寫 iDempiere + 回覆結果(10~20 秒)
    4. 看到有讀錯的,打開 iDempiere 手動改
    5. 居服員白天在 LINE 的回報,我看到時直接貼到 bot,自動 prepend 到今天的 Description
    6. 月底回診時點開 iDempiere 給醫生看趨勢

    總結:這篇文章的「二階 vibe coding」

    這篇文章本身也是 vibe coding 的產物 —— 我叫 AI 把兩天的工作回溯成一篇技術文,自己只負責審核跟指正敏感資料。這種「AI 幫你跟 AI 協作的過程寫回顧」也算是一種 meta-loop:決策的當下有 AI 幫忙驗證,事後有 AI 幫忙紀錄。我負責方向跟價值判斷,剩下的雜活都給 AI。

    兩天下來最重要的一句話:「vibe coding 不是減少思考,是加速思考。」 AI 幫你快速驗證假設、生成原型、寫測試、跑實驗、甚至幫你寫反思文章。但你要知道該往哪個方向走、什麼時候該 pivot、什麼時候該停手。AI 是踏板車,你是駕駛。

  • AI 逆向工程協作:Source Inventory 協議讓一比一重建不再落東掉西

    重點摘要

    • AI 協作做逆向工程,最大的坑不是技術,而是 AI 「選擇性閱讀」原始碼
    • 根治方法:強制 Source Inventory 協議 — 動手前先列清單、附行號、用戶確認邏輯後才寫程式
    • 用戶不需要知道原版長什麼樣,清單上的每一項都要有原始碼行號出處
    • 這套協議已寫入 CLAUDE.md,適用於任何 legacy code 逆向重建專案

    最近在做一個系統重建專案:把一套已上線多年的 Ionic + PHP 系統,逐頁逐模組一比一重建為 Flutter 應用。聽起來直白,實際做起來卻踩了一個讓人很沮喪的坑——不是技術難度的問題,而是 AI 協作流程設計的問題

    這篇文章記錄我如何診斷這個問題,並建立一套可複用的 AI 逆向工程協作協議。

    問題:一比一複製,卻一直落東掉西

    我明確告訴 AI:「這是舊版原始碼,你幫我一比一複製到 Flutter。」但每次交付,都會發現缺少功能。最典型的例子:登入後的登出功能不見了

    登出不是什麼複雜功能,但它不在主流程頁面——它藏在 header component 的某個角落。AI 在讀原始碼的時候,只讀了主頁面,沒讀共用元件,自然就沒看到登出按鈕的存在。

    更麻煩的是:我自己也不知道原版長什麼樣(這是別人開發的系統),所以我也沒辦法直接指出「你少了這個」。每次都要在用起來的時候才發現缺少什麼,然後重來。

    根本原因:AI 的「選擇性閱讀」

    深入想了一下,問題的根源在於 AI 讀原始碼的方式:

    錯誤的讀法 正確的讀法
    找「我預期會有的東西」 逐行掃描,列出「所有看到的東西」
    只讀主要頁面檔案 讀所有相關檔案(service、routing、共用元件)
    憑印象補齊功能 每一項都有原始碼出處(檔案 + 行號)
    「讀完」就開始寫 產出清單、確認後才開始寫

    用一句話說:AI 是在「確認自己的預期」,不是在「完整盤點原始碼」。這種差距在簡單頁面感覺不出來,在有大量共用邏輯的真實系統裡,每個模組都會漏東西。

    解法:Source Inventory 協議

    我和 AI 一起設計了一套強制執行的協議,並寫入專案的 CLAUDE.md,讓每個接手這個專案的 AI session 都必須遵守。

    Step 0 — 列出所有要讀的檔案(動手前)

    在讀任何內容之前,先列出這個模組涉及的所有檔案,包含:

    • 頁面本體(template + logic)
    • 所有被引用的 service
    • 共用元件(header、footer、tab-bar、nav)
    • 路由設定
    • 後端 handler

    每個檔案讀完打勾,沒打勾的不算讀過。這一步強迫 AI 在開始之前就意識到「這個模組牽涉哪些檔案」,避免只讀主檔就動手。

    Step 1 — 產出 Source Inventory(每項附行號)

    讀完所有檔案後,產出功能清單。格式固定:

    - [ ] 登出按鈕
          來源:home.page.html:142 <ion-button (click)="logout()">
    
    - [ ] 下拉刷新
          來源:ticket.page.html:38 <ion-refresher (ionRefresh)="doRefresh($event)">
    
    - [ ] 空狀態提示
          來源:ticket.page.html:91 <div *ngIf="tickets.length === 0">目前沒有票券</div>

    關鍵點:每一項都必須有原始碼的檔案和行號

    這解決了「用戶不知道原版長什麼樣,所以沒辦法驗證清單正確性」的問題。用戶不需要看過原版,只需要問:「這一項你是從哪行讀到的?」答不出來,就代表是 AI 憑印象補的,不是真的在原始碼裡。

    Step 2 — 逐項實作,逐項打勾

    對照清單一項一項做,每完成一項就打勾。不允許「大概實作了」或「之後再補」。

    Step 3 — Self-Verify 後才交付

    交付前,AI 自己對照清單跑一遍,確認每項都存在於程式碼中。如果 Step 3 發現漏項,必須補完再說「完成」。漏項不能留給用戶發現。

    一條關鍵規則:禁止在原始碼存在的情況下向用戶索取截圖

    另一個常見的壞模式是:AI 讀不懂某個行為,就跟用戶要截圖或圖片。

    這在逆向工程的情境下尤其荒謬。用戶拿到舊系統原始碼,通常自己也沒有截圖,甚至從來沒用過那個系統。要求用戶提供截圖,是把 AI 本來應該做的工作(讀原始碼)轉嫁給用戶。

    正確的做法是:原始碼讀不懂的地方,標記 [?],說明不確定的點,讓用戶確認業務邏輯——而不是要求視覺確認。

    這套協議適用於任何逆向升級工程

    雖然這次的情境是 Ionic → Flutter,但這套協議的核心邏輯對任何 legacy 重建都適用:

    • jQuery / AngularJS → React / Vue
    • PHP monolith → 現代後端(Go、Node、Python)
    • 原生 Android/iOS → Flutter / React Native
    • 桌面應用 → Web 應用

    只要是「拿到舊系統 code,重寫到新技術棧」,用戶通常都不知道原版的全貌,也無法驗證 AI 是否讀完了所有東西。Source Inventory + 行號出處 是讓這個過程可稽核的最低門檻。

    如何在你的專案裡執行這套協議

    把以下內容加入你的專案 CLAUDE.md(或任何 AI 工作指引文件):

    ## 一比一複製協議(MANDATORY)
    
    > 原始碼是唯一的 ground truth。用戶不應該被要求提供截圖。
    
    ### Step 0 — 列出所有要讀的檔案
    (所有頁面、service、共用元件、routing、後端 handler)
    每個檔案讀完打勾,沒打勾不算讀過。
    
    ### Step 1 — Source Inventory(每項附檔案:行號)
    格式:
    - [ ] 功能描述
          來源:filename.html:LINE_NUMBER <原始碼片段>
    
    ### Step 2 — 逐項實作,逐項打勾
    
    ### Step 3 — Self-Verify 後才交付
    
    ### 禁止行為
    - 在原始碼存在的情況下向用戶索取截圖
    - 沒有產出 Source Inventory 就開始寫程式
    - 只讀主頁面,不讀 service / routing / 共用元件

    結語

    逆向升級工程的難點不在技術轉換,而在於如何保證「沒有遺漏」。AI 的天性是預測下一個 token,這讓它容易「補全期望的樣子」,而不是「如實記錄看到的東西」。

    Source Inventory 協議的作用,就是把 AI 的工作模式從「自由發揮」強制切換成「逐行比對」。這不是在限制 AI,而是在讓 AI 的輸出變得可以被信任。

    如果你也在做類似的逆向重建專案,歡迎把這套協議複製進你的 CLAUDE.md,看看效果如何。

  • LangGraph 多模型實戰:從零到 Production 的完整教學

    重點摘要

    • LangGraph 讓你把不同 AI 模型串成自動化流水線:Claude 負責「想」,Groq Llama 負責「寫」,各司其職
    • 本文從零開始,帶你走完四個階段:基本流水線 → 智慧路由 → Streaming + 容錯 → FastAPI 部署
    • 每個階段都有完整可執行的程式碼,跟著做就能跑
    • 核心區別:Claude Code / Cursor 是「你的工具」,LangGraph 是「你造工具的材料」——當你要造產品給別人用時才需要它
    • 實測結果:比全用 Claude Sonnet 省 75% 成本,同時保留深度分析的品質

    這篇文章是給誰看的?

    如果你符合以下任一情況,這篇文章就是為你寫的:

    • 你想做一個 AI chatbot(客服、內部助手、產品功能),但不知道怎麼開始
    • 你已經在用 Claude / GPT,但覺得全部用最貴的模型太浪費錢
    • 你聽過「多模型協作」但不確定具體怎麼實作
    • 你想知道 LangGraph 跟你平常用的 Claude Code / Cursor / Copilot 到底差在哪

    本文從零開始,帶你走完四個階段,每個階段都有完整可執行的程式碼。你可以在任何一個階段停下來——不是每個人都需要走到 production。

    先搞清楚:這跟 Claude Code / Cursor / Copilot 完全不同

    在往下讀之前,先釐清最容易搞混的一件事:

    面向 Claude Code / Cursor / Copilot LangGraph
    本質 開發者工具 — 你用它寫 code 開發框架 — 你用它造產品
    使用者是誰 你自己(開發者) 你的客戶 / 你的系統 / 你的團隊
    使用場景 日常寫程式、debug、重構 建 chatbot、自動化流程、API 服務
    互動方式 人 ↔ AI 即時對話 程式碼自動跑,可以無人值守
    模型選擇 工具幫你選好(通常固定一個) 你自己決定哪個步驟用哪個模型
    計費方式 月費訂閱(工具包了) 純 API 按量計費,你自己控制成本

    用一個比喻:Claude Code 是你請了一個很強的工程師坐在旁邊幫你寫 code;LangGraph 是你在蓋一條自動化產線,產線上有不同的機器人各司其職

    如果你只是日常寫程式,用 Claude Code 就好,不需要 LangGraph。往下讀之前,確認你的需求是「造一個東西給別人用」,而不是「讓自己寫 code 更快」。

    為什麼不同的 AI 模型要分工合作?

    沒有一個模型什麼都最好。每個模型有不同的強項和定價:

    能力 Claude Sonnet Groq Llama 70B Groq Llama 8B
    架構設計 / Code Review ⭐ 最強 普通
    程式碼生成 ⭐ 強且快 普通
    簡單問答 大材小用 大材小用 ⭐ 夠用且極便宜
    生成速度 ~50 tok/s ~300 tok/s ~800 tok/s
    費用 (Output/1M tokens) $15.00 $0.79 $0.08

    核心邏輯:讓擅長「想」的模型去想,讓擅長「做」的模型去做,讓便宜的模型處理瑣事。就像軟體團隊裡,架構師出規格、工程師寫程式、實習生回答簡單問題一樣。

    LangGraph 是什麼?一分鐘看懂

    LangGraph 是 LangChain 團隊開發的有狀態流程編排框架。三個核心概念:

    1. Node(節點)— 一個步驟,比如「用 Sonnet 設計」或「用 Llama 實作」
    2. Edge(邊)— 步驟之間的連線,決定「做完 A 接著做 B」
    3. State(狀態)— 所有節點共享的資料,比如設計規格、程式碼、審查結果

    把這三個組合起來,就是一個可以自動跑的流水線。你定義「什麼步驟做什麼、什麼條件走什麼路」,框架負責執行。

    環境準備(所有階段共用)

    開始之前,你需要準備兩個 API key 和安裝套件。這一步做完,後面四個階段都不用再設定。

    1. 取得 API Key(兩個都有免費額度)

    ⚠️ 注意:這是 API key,跟 Claude.ai 的月費訂閱、GitHub Copilot 的訂閱完全無關。API 是按用量計費的。

    2. 建立專案

    mkdir langgraph-duo && cd langgraph-duo
    
    # 安裝套件
    pip install langgraph langchain-anthropic langchain-groq python-dotenv
    
    # 建立 .env 檔案(填入你的 key)
    cat > .env << 'EOF'
    ANTHROPIC_API_KEY=sk-ant-xxxx
    GROQ_API_KEY=gsk_xxxx
    EOF

    3. 驗證連線

    python3 -c "
    from dotenv import load_dotenv; load_dotenv()
    from langchain_anthropic import ChatAnthropic
    from langchain_groq import ChatGroq
    
    sonnet = ChatAnthropic(model='claude-sonnet-4-6', max_tokens=50)
    llama = ChatGroq(model='llama-3.3-70b-versatile', max_tokens=50)
    
    print('Sonnet:', sonnet.invoke('Say OK').content)
    print('Llama:', llama.invoke('Say OK').content)
    "

    兩行都印出 OK,就可以開始了。

    第一階段:Duo 流水線(設計 → 實作 → 審查)

    目標:讓 Claude Sonnet 設計規格,Groq Llama 寫程式碼,Sonnet 再審查品質。審查不通過自動重做,最多 3 次。

    適合場景:批量生成程式碼、自動化 code review、需要品質把關的程式碼生成。

    Task → [Sonnet 設計] → [Llama 實作] → [Sonnet 審查]
                                ↑               |
                                └── 未通過 ──────┘  (最多 3 次)
                                      ↓ 通過
                                     END

    完整程式碼:duo.py

    from typing import TypedDict
    from dotenv import load_dotenv
    from langgraph.graph import StateGraph, END
    from langchain_anthropic import ChatAnthropic
    from langchain_groq import ChatGroq
    from langchain_core.messages import HumanMessage, SystemMessage
    
    load_dotenv()
    
    # 模型分工:Sonnet 想,Llama 做
    sonnet = ChatAnthropic(model="claude-sonnet-4-6", max_tokens=4096)
    llama = ChatGroq(model="llama-3.3-70b-versatile", max_tokens=4096)
    
    # 所有節點共享的狀態
    class AgentState(TypedDict):
        task: str             # 原始需求
        design: str           # Sonnet 的設計規格
        code: str             # Llama 的實作
        review: str           # Sonnet 的審查結果
        revision_notes: str   # 修改指示(給重試用)
        approved: bool        # 是否通過
        attempt: int          # 重試次數
    
    MAX_ATTEMPTS = 3
    
    # Node 1: Sonnet 設計
    def design_node(state):
        response = sonnet.invoke([
            SystemMessage(content="You are a senior architect. Produce a precise technical spec with function signatures, edge cases, and pseudocode."),
            HumanMessage(content=f"Request: {state['task']}")
        ])
        return {"design": response.content}
    
    # Node 2: Llama 實作
    def implement_node(state):
        prompt = f"Spec:\n{state['design']}"
        if state.get("revision_notes"):
            prompt += f"\n\nFix these issues:\n{state['revision_notes']}"
        response = llama.invoke([
            SystemMessage(content="Implement per spec. Output only Python code."),
            HumanMessage(content=prompt)
        ])
        return {"code": response.content, "attempt": state.get("attempt", 0) + 1}
    
    # Node 3: Sonnet 審查
    def review_node(state):
        response = sonnet.invoke([
            SystemMessage(content="Review code vs spec. Reply VERDICT: APPROVED or REJECTED with details."),
            HumanMessage(content=f"Spec:\n{state['design']}\n\nCode:\n{state['code']}")
        ])
        review = response.content
        approved = "APPROVED" in review.upper()
        return {"review": review, "approved": approved,
                "revision_notes": "" if approved else review}
    
    # 條件路由:通過 → 結束,未通過 → 回去重做
    def should_continue(state):
        if state["approved"] or state["attempt"] >= MAX_ATTEMPTS:
            return "end"
        return "revise"
    
    # 組裝 Graph
    workflow = StateGraph(AgentState)
    workflow.add_node("design", design_node)
    workflow.add_node("implement", implement_node)
    workflow.add_node("review", review_node)
    workflow.set_entry_point("design")
    workflow.add_edge("design", "implement")
    workflow.add_edge("implement", "review")
    workflow.add_conditional_edges("review", should_continue,
                                   {"end": END, "revise": "implement"})
    app = workflow.compile()
    
    # 跑!
    result = app.invoke({
        "task": "Write a Python function that reads a CSV and returns column averages as a dict",
        "design": "", "code": "", "review": "",
        "revision_notes": "", "approved": False, "attempt": 0,
    })
    
    print("=== CODE ===")
    print(result["code"])
    print(f"\n{'✅ APPROVED' if result['approved'] else '⚠️ BEST EFFORT'} after {result['attempt']} attempt(s)")

    實測結果

    階段 模型 結果
    🧠 Design Claude Sonnet 4.6 產出 4 個參數、10 個 edge case、9 步 pseudocode 的完整規格
    ⚡ Implement Groq Llama 70B 完整 Python 函數,含 type hints、docstring、error handling
    🔍 Review Claude Sonnet 4.6 VERDICT: APPROVED — 第一次就通過,沒有重試

    到這裡你已經有一個能跑的多模型流水線了。如果你的需求是「批量生成程式碼 + 自動品質把關」,可以停在這個階段。

    第二階段:Smart Router(自動選模型的聊天機器人)

    目標:做一個像 ChatGPT 一樣的對話介面,但底下不是固定一個模型,而是自動根據問題類型選最適合的模型。

    適合場景:客服 chatbot、團隊內部 AI 助手、需要控制 API 成本的聊天服務。

    你的問題 → [Llama 8B 分類器] → 判斷類型
                                      |
                      ┌────────────────┼────────────────┐
                      ↓                ↓                ↓
                [Claude Sonnet]  [Llama 70B]      [Llama 8B]
                 深度分析          寫程式            簡單問答
                      ↓                ↓                ↓
                      └────────────────┼────────────────┘
                                       ↓
                                    回答你

    分類器怎麼運作?

    分類器用最便宜的 Llama 8B(每次呼叫不到 $0.0001)讀取問題,輸出一個 JSON 判斷結果。分類規則:

    分類 路由到 觸發條件 費用 (Output/1M)
    🧠 深度思考 Claude Sonnet 架構分析、比較權衡、code review、規劃 $15.00
    ⚡ 寫程式 Llama 70B 實作函數、生成腳本、重構、修 bug $0.79
    💨 快速回答 Llama 8B 打招呼、簡單問答、定義、基礎算數 $0.08

    核心程式碼

    import json
    from langgraph.graph import StateGraph, END
    
    # 分類器:Llama 8B 讀問題,判斷該走哪條路
    def router_node(state):
        response = llama_8b.invoke([
            SystemMessage(content="""Classify into one category:
    - "sonnet": complex reasoning, analysis, architecture
    - "llama_70b": write code, implement, fix bugs
    - "llama_8b": greetings, simple facts, casual chat
    Reply ONLY JSON: {"route": "...", "reason": "..."}"""),
            HumanMessage(content=state["question"])
        ])
        parsed = json.loads(response.content)
        return {"route": parsed["route"]}
    
    # 條件路由:根據分類結果,走不同的回答節點
    workflow = StateGraph(RouterState)
    workflow.add_node("router", router_node)
    workflow.add_node("sonnet", answer_sonnet)
    workflow.add_node("llama_70b", answer_llama_70b)
    workflow.add_node("llama_8b", answer_llama_8b)
    
    workflow.set_entry_point("router")
    workflow.add_conditional_edges("router", lambda s: s["route"], {
        "sonnet": "sonnet",
        "llama_70b": "llama_70b",
        "llama_8b": "llama_8b",
    })
    app = workflow.compile()

    實測分類準確度:7/7

    問題 路由結果 正確?
    Hi! 💨 Llama 8B
    1+1=? 💨 Llama 8B
    Write a Python quicksort ⚡ Llama 70B
    Compare microservices vs monolith 🧠 Sonnet
    What is Docker? 💨 Llama 8B
    幫我寫一個 REST API ⚡ Llama 70B
    分析 Redis cache vs CDN 優缺點 🧠 Sonnet

    到這裡你有一個能自動選模型的聊天機器人了。但它還缺兩個東西:回答會一次全部吐出(不是逐字顯示),而且 Groq 掛了就整個壞掉。第三階段解決這兩個問題。

    第三階段:Streaming + Fallback(讓它不會掛)

    目標:回答逐字出現(像 ChatGPT 一樣流暢),而且模型掛了自動切換備用模型。

    為什麼這一步很重要:沒有 Streaming 的 chatbot,使用者體驗像 2010 年的網頁——按下送出,等 5 秒,突然一大段文字出現。沒有 Fallback 的服務,Groq 一限速(免費版每分鐘 30 次),你的整個服務就掛了。

    Streaming:體感延遲降 25 倍

    方式 使用者體驗 程式碼差別
    invoke()(非串流) 等 5 秒 → 突然出現整段文字 response = model.invoke(messages)
    stream()(串流) 0.2 秒開始出字 → 像打字一樣流暢 for chunk in model.stream(messages)
    # 只需要把 .invoke() 改成 .stream()
    # 然後迭代每個 chunk 即時輸出
    
    for chunk in model.stream(messages):
        print(chunk.content, end="", flush=True)  # flush=True 強制即時顯示

    Fallback:模型掛了自動切換

    主要模型 Fallback 模型 切換代價
    🧠 Sonnet ⚡ Llama 70B 分析品質略降,速度更快
    ⚡ Llama 70B 🧠 Sonnet 速度略慢,品質更高
    💨 Llama 8B ⚡ Llama 70B 稍慢稍貴,但一定能回答
    # Fallback 模式:try 主要模型,失敗自動切備用
    try:
        for chunk in primary_model.stream(messages):
            print(chunk.content, end="", flush=True)
    except Exception:
        print("⚠️ Primary failed, switching to fallback...")
        for chunk in fallback_model.stream(messages):
            print(chunk.content, end="", flush=True)

    完整的 router_stream.py 把 Streaming + Fallback + 對話歷史 + 使用統計整合在一起,不到 200 行。跑起來就是一個帶有自動模型切換的 terminal 聊天機器人。

    到這裡你有一個穩定的、體驗流暢的聊天機器人了。但它還是跑在你的 terminal 裡,只有你能用。第四階段讓它變成任何人都能呼叫的 API 服務。

    第四階段:FastAPI 部署(讓別人能用)

    目標:把 chatbot 包成 HTTP API,讓網頁、App、Line bot、Slack bot 都能呼叫。

    為什麼是 FastAPI:Python 生態最主流的 API 框架,原生支援 async、自動生成 API 文件、社群龐大。

    API 端點設計

    端點 方式 適合場景
    POST /chat 非串流,回傳完整 JSON Slack/Line bot、後端呼叫、批次處理
    POST /chat/stream SSE 串流,逐 token 推送 網頁聊天窗、需要即時體感的 UI
    GET /health 健康檢查 負載平衡器、監控系統
    GET /sessions/{id} 取得對話歷史 Debug、對話紀錄查詢
    DELETE /sessions/{id} 清除對話 使用者開始新對話

    啟動與測試

    # 安裝額外套件
    pip install fastapi uvicorn sse-starlette
    
    # 啟動 server
    python server.py
    # → 🤖 Smart Router API running on http://localhost:8900
    
    # 測試非串流
    curl -X POST http://localhost:8900/chat \
      -H "Content-Type: application/json" \
      -d '{"message": "什麼是 Docker?", "session_id": "test-user"}'
    
    # 回應範例:
    # {
    #   "answer": "Docker 是一個容器化平台...",
    #   "model_used": "llama_8b",
    #   "route_reason": "simple definition question",
    #   "session_id": "test-user",
    #   "fallback_used": false,
    #   "elapsed_seconds": 0.85
    # }

    前端怎麼接 SSE 串流?

    如果你要做網頁聊天窗,前端只需要幾行 JavaScript:

    // 瀏覽器原生 EventSource API
    const response = await fetch('/chat/stream', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({message: '寫一個排序函數', session_id: 'user-1'})
    });
    
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    
    while (true) {
      const {done, value} = await reader.read();
      if (done) break;
      const text = decoder.decode(value);
      // 每個 chunk 到了就即時顯示在畫面上
      document.getElementById('chat').innerHTML += text;
    }

    為什麼用 SSE 而不是 WebSocket?

    聊天場景是「使用者送一次訊息、AI 回一次」的單向推送。SSE 比 WebSocket 更適合:

    • 更簡單 — 單向傳輸,不需要管雙向連線
    • 瀏覽器原生 — EventSource API 內建自動重連
    • 穿透力好 — 走標準 HTTP,能通過代理和 CDN
    • 夠用 — 使用者的訊息用 POST 送,AI 的回應用 SSE 推

    四個階段的完整對照

    階段 檔案 新增能力 適合誰 可以停在這嗎?
    1. Duo 流水線 duo.py 設計→實作→審查 批量生成程式碼
    2. Smart Router router.py 自動選模型 個人實驗、學習
    3. Streaming + Fallback router_stream.py 逐字輸出 + 自動容錯 團隊內部使用
    4. FastAPI 部署 server.py HTTP API + SSE + Session 對外服務、產品整合

    費用比較:到底能省多少?

    假設一天 100 個問題,其中 20% 深度分析、30% 寫程式、50% 簡單問答:

    方案 月成本估算 品質
    全部用 Claude Sonnet ~$54 最高,但簡單題大材小用
    Smart Router(自動切換) ~$13.50 深度題用 Sonnet,其餘用 Llama
    全部用 Groq Llama 70B ~$4.20 最便宜,但分析品質弱

    Smart Router 方案比全用 Sonnet 省 75%,同時保留了深度分析任務的 Sonnet 品質。規模越大差距越明顯——1000 個使用者的 SaaS 產品,月省 $3000+。

    什麼場景適合?什麼場景不適合?

    ✅ 適合的場景

    1. 客服 Chatbot — 70% 簡單題用 Llama 8B 秒回,複雜題自動升級到 Sonnet
    2. 團隊 AI 助手 — 接 Slack,PM 問策略用 Sonnet,工程師要 code 用 Llama 70B
    3. 自動化 Pipeline — CI/CD 中的 AI code review,PR 提交自動跑
    4. SaaS 產品 — 加 AI 功能但要控成本,簡單摘要用 Llama,深度分析用 Sonnet
    5. 批量內容生成 — 50 篇產品描述:Sonnet 定規範 → Llama 批量寫 → Sonnet 抽檢

    ❌ 不適合的場景

    • 日常寫程式 — 用 Claude Code 或 Cursor 就好,不需要 LangGraph
    • 一次性分析 — 直接貼給 Claude 問就好,不需要搭 pipeline
    • 不需要控成本 — 個人使用月花不到 $10,Smart Router 省下的錢不值得建置成本

    三個問題判斷法

    在決定要不要用之前,問自己:

    1. 使用者是誰? — 你自己 → 不需要。別人(客戶/團隊/系統)→ 繼續看
    2. 會跑多少次? — 幾次 → 直接呼叫 API。幾百幾千次 → LangGraph 有意義
    3. 需要品質分級嗎? — 所有問題都要最高品質 → 用最強模型。不同問題可以不同品質 → Smart Router

    三個都答「後者」才值得用 LangGraph。

    走完四個階段之後,還有什麼?

    如果你的服務要上正式商業環境,還有幾個面向需要處理:

    面向 做什麼 不做的後果
    RAG(檢索增強) 接向量資料庫,讓 AI 查你的文件回答 AI 只能回答通用知識,不懂你的業務
    評估(LangSmith) 追蹤每次呼叫的路由、延遲、成本 不知道 Router 分類準不準,成本失控
    Session 持久化 用 Redis 存對話歷史(目前在記憶體) Server 重啟,所有對話消失
    認證 API key 或 JWT 驗證 任何人都能呼叫你的 API,幫你花錢
    Prompt Injection 防護 驗證使用者輸入,防止惡意 prompt 使用者讓 AI 做不該做的事
    Docker 容器化 打包成 Docker image 部署 環境不一致,部署困難
    Subgraph 嵌套 Router 判斷「寫程式」→ 啟動整個 Duo 流水線 只能單步回答,沒有品質把關

    完整專案結構

    langgraph-duo/
    ├── .env                  # API keys(不要 commit)
    ├── .gitignore            # 排除 .env
    ├── requirements.txt      # 所有套件
    ├── duo.py                # 階段 1:設計→實作→審查 流水線
    ├── duo_notebook.ipynb    # 階段 1 的 Jupyter 版
    ├── router.py             # 階段 2:Smart Router 基本版
    ├── router_stream.py      # 階段 3:+ Streaming + Fallback
    └── server.py             # 階段 4:FastAPI HTTP API

    所有程式碼都有詳細的中英文註解,說明每個模型的選用原因和適用場景。從哪個階段開始都可以,每個檔案都是獨立可執行的。

    總結

    LangGraph 多模型流水線的核心價值不是「用 AI 寫程式更快」——如果只是速度,直接用一個模型最快。它的價值在於把 AI 當成團隊來管理:讓擅長設計的去設計、擅長實作的去實作、擅長審查的去審查。

    從 Duo 流水線到 Smart Router,再到帶有 Streaming、Fallback、API 部署的生產版本,每一步都是從「能跑」走向「能上線」的必經之路。你不需要一次走完四個階段——先跑通第一階段,確認這個架構對你有用,再往下走。

    相關閱讀:LangChain/LangGraph 深度分析:架構師、顧問、個人公司的實戰指南 | Claude Code Agent Teams:從穩定執行到自動化代碼審查的完整指南 | 舊系統整合場景下,會用 vs 不會用 Claude Code 的差距