{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Bobo 的學思山丘",
  "home_page_url": "https://bobochen.dev/",
  "feed_url": "https://bobochen.dev/feed.json",
  "description": "學習、思考、紀錄。愛分享、愛開發的軟體工程師，二寶爸的技術與生活觀察。",
  "language": "zh-TW",
  "icon": "https://bobochen.dev/icon-512.png",
  "favicon": "https://bobochen.dev/favicon-32x32.png",
  "authors": [
    {
      "name": "Bobo Chen",
      "url": "https://bobochen.dev/about",
      "avatar": "https://bobochen.dev/apple-touch-icon.png"
    }
  ],
  "items": [
    {
      "id": "https://bobochen.dev/blog/citytasker-engineer-founder-real-costs/",
      "url": "https://bobochen.dev/blog/citytasker-engineer-founder-real-costs/",
      "title": "一個工程師創辦人的真實成本清單：CityTasker 教我的 4 件事",
      "content_text": "工程師創業，最容易低估的從來不是技術，而是看不見的成本——燒錢的速度、沒人教你的法規、聚焦的代價，還有那個致命錯覺：寫得出來的東西，最不值錢。這是 CityTasker 用兩年燒完的錢，幫我列出的成本清單。",
      "content_html": "2012 到 2014 年，我和朋友做了一個叫 CityTasker 的任務媒合平台——口號是「整個城市都有我的好幫手」，你有跑腿、代買、家事這類懶得做或做不來的事，就丟上平台，找附近的人接走。我那時候是共同創辦人兼全端工程師，前端、後端、雙平台 App 一個人扛。\n\n我以為最難的是把它做出來。\n\n後來才知道，把東西做出來，是這場創業裡最簡單、也最不值錢的一件事。\n\n<!-- TODO 截圖：放一張 CityTasker 平台當年的實際畫面（任務列表／發案頁／後台皆可），從你的「2013Citytasker時期」Dropbox 挑最能代表產品的那張。去敏：把真實會員 email、電話、姓名等個資遮掉。截好後直接覆蓋同名檔 images/citytasker-product.webp，不用改這份 .md。 -->\n\n![CityTasker 平台當年的實際畫面](./images/citytasker-product.webp)\n\n*這就是當年我們做出來的 CityTasker。把它寫出來，其實是整件事裡最簡單的一步。*\n\n下面這份清單，是 CityTasker 用兩年和我口袋裡的錢，幫我列出來的。寫給每一個正站在懸崖邊、猶豫要不要跳下去的工程師。\n\n## 你以為錢花在伺服器，其實是花在時間\n\n工程師看成本，習慣看帳單：AWS 多少、GCP 多少、簡訊一封幾毛。這些我算得很精。\n\n但真正燒掉資金的，不是機器，是時間。是你還沒搞清楚方向的每一個月，房租照付、人照養、自己也要吃飯。我們最後收攤，不是被某一筆大開銷壓垮，而是「還沒找到正確方向之前，就先坐吃山空了」。錢有跑道（runway），方向沒跑道——你以為自己在加速，其實只是在燒油空轉。\n\n**提醒**：算 runway 的時候，把「找到對的方向」當成最大的一筆隱形支出。問自己：如果接下來六個月方向都是錯的，我撐得住嗎？撐不住，就先別跳。\n\n## 你寫得出平台，但沒人教你法律\n\nCityTasker 上線後沒多久，我們收到一個完全沒料到的麻煩：勞動局以「非法人力仲介」要開罰。\n\n我當時的反應是——蛤？我們只是做了一個媒合資訊的網站啊。但在法規眼裡，你撮合了「人」去「做事」並從中產生關係，那條線就踩到了。這是一道工程師完全沒有雷達掃到的牆。你的 code review 不會幫你 review 法律，你的測試覆蓋率也涵蓋不到主管機關。\n\n更扎心的是，把我們捅到主管機關面前的，不是哪個官員自己上網發現——是同業。我們等於動了傳統人力仲介的乳酪，有人當面嗆聲，也有人直接拿著我們的網站去檢舉。那時我才懂：你以為自己在「做一個平台」，但在既有玩家眼裡，你是個闖進別人地盤、還不懂規矩的外人。法規那條看不見的線，常常是被「不希望你存在的人」幫你用力劃出來的。\n\n**提醒**：你的產品只要碰到「人、錢、健康、勞動」其中任何一個，先去問律師，別先寫 code。法規不是寫完上線後才補的功能，它是你能不能上線的前提——而且別忘了，最先去翻法條找你麻煩的，往往是被你動到的同業。\n\n## 想服務所有人，最後誰都沒服務好\n\n我們的定位很「大」。一邊想接小 B——行銷公司、公關公司、百貨賣場、活動展覽、研究調查；一邊又想做個人需求——代排、代買、跑腿、家事。聽起來市場很大，對吧？\n\n實際上，這代表我們的首頁要同時說服一個百貨採購和一個想找人代排隊的學生。代表每一個功能都得兼顧兩種完全不同的人。代表我們的行銷預算（本來就少得可憐）被切成好幾份，每一份都不夠用。用戶成長一直起不來，現在回頭看，不是行銷沒做好，是我們從沒讓任何一種人覺得「這就是為我做的」。\n\n更現實的是，我們媒合的那些事——搬家、代排、展場銷售人員——產值本來就低。B 端的公司要這些臨時人力，一單只肯付我們 50 元。50 元。就算用戶成長真的衝起來，把每一單的數字攤開，這個模式從一開始就撐不起一間公司。問題不只是「想服務所有人」，是我們挑的這幾個池子本身都太淺——選錯了池子，再努力划水也游不出去。\n\n聚焦，在當時的我眼裡是一個「策略選擇」——好像聚焦了就放棄了其他可能。但真相是：聚焦是生存問題，不是選擇題。它不只決定你能不能說服一種人，也決定你撈到的每一單，到底值不值得你做。\n\n**提醒**：開站第一天就回答得出「我先為哪一種人、解決哪一個具體場景」，而且要窄到讓你有點害怕。怕，通常代表你終於夠聚焦了。\n\n## 「能寫出來」不等於「有人要用」\n\n這是工程師最致命的錯覺，我也栽了。\n\n開站那天，我盯著後台一直按 F5——一天 106 個人註冊、最高 50 人同時在線。那一刻我和夥伴真的以為要成功了。但 106 個註冊，從來不等於 106 個「明天還會再回來」的人。我把「做得出來」和「有人持續要用」當成同一件事，這中間其實隔著一整個我沒搞懂的世界：需求是不是真的、頻率夠不夠、他願不願意付錢、付了之後會不會再來。\n\n我花了大把力氣把功能刻得又快又漂亮，卻很少停下來問：這個功能，到底有沒有人在等？寫得出來，是這整件事裡最廉價的能力。\n\n**提醒**：在你打開編輯器之前，先想辦法證明「有人要用」——哪怕只是用一張表單、一個 LINE 群、十通電話。能寫出來不是你的護城河，搞懂有沒有人要才是。\n\n## 所以呢，別創業嗎？\n\n不是。\n\nCityTasker 收攤後，我們各自先回去上班，有人轉去了旅遊產業，我則一路做到後來的技術負責人。我不後悔跳下去——那兩年讓我真正理解了 0 到 1 是什麼，這是再多薪水都買不到的學費。\n\n我想說的只是：跳，但張開眼睛再跳。技術從來不是創業最高的那道牆，這份清單上的每一項才是。看清楚它們，不會讓你變膽小，只會讓你跳得比當年的我聰明一點。\n\n至於那句一直在我心裡的「我們不是那 1%」——那是這個系列收尾時，我才想好好說清楚的事。",
      "summary": "工程師創業，最容易低估的從來不是技術，而是看不見的成本——燒錢的速度、沒人教你的法規、聚焦的代價，還有那個致命錯覺：寫得出來的東西，最不值錢。這是 CityTasker 用兩年燒完的錢，幫我列出的成本清單。",
      "image": "https://bobochen.dev/_astro/cover.CZXJ8wo9.webp",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "創業",
        "AppWorks",
        "CityTasker",
        "新創",
        "職涯反思",
        "工程師創業"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/citytasker-launch-day-story/",
      "url": "https://bobochen.dev/blog/citytasker-launch-day-story/",
      "title": "整個城市都有我的好幫手：CityTasker 開站那天，我們真的以為要成功了",
      "content_text": "2013 年 8 月 7 日，CityTasker 開站。我盯著後台一直按 F5，一天 106 個人註冊、最高 50 人同時在線、54 封簡訊發出去——那一刻，我跟夥伴真的以為要成功了。這是一個入選 AppWorks 第七屆、最後燒完錢收攤的創業故事的開場。",
      "content_html": "## 開站那天，我一直在重新整理後台\n\n2013 年 8 月 7 日，我幾乎沒離開過電腦。\n\n不是在寫程式——程式那天難得沒出包——而是一直按 F5。後台的數字每按一次就往上跳一格：註冊會員 87、94、101……到了傍晚，停在 106。同一時間最高有 50 個人在線上瀏覽，系統發出去 54 封簡訊通知，單日流量衝到 347MB。\n\n對今天動不動百萬日活的產品來說，這些數字小得可笑。但那天傍晚，我跟共同創辦人 JEJ 盯著螢幕，真的以為——我們要成功了。\n\n那個產品叫 CityTasker。\n\n![2013-08-07 CityTasker 單日流量後台，citytasker.tw 當天用掉 346.95 MB](./images/launch-day-bandwidth.webp)\n\n*開站當天的流量後台截圖。我一直留著它——`citytasker.tw` 那天跑了 346.95 MB，是這個小東西活著的證據。*\n\n## 一句話的傻勁\n\nCityTasker 的構想很簡單：**整個城市都有我的好幫手**。\n\n那時候國外的群眾外包（crowdsourcing）模式正紅，美國有 TaskRabbit、澳洲有 Airtasker——你有一件懶得做、或做不來的事：排隊買票、活動攝影、跑腿代買、家裡臨時需要人手，就把任務丟上平台，附近有空、想賺點外快的人接走。城市裡每個人的零碎時間，變成另一個人的解方。\n\n我們覺得這在台灣一定行。年輕、沒包袱、帳戶裡還有一點錢，再加上一股「這東西我做得出來」的傻勁，就開始了。\n\n我那時候是全端工程師，前端、後端、雙平台 App，一個人扛。技術選了 PHP、Laravel，後面接 AWS。團隊很小，所以沒有大公司那套流程，就是想到一個功能、刻出來、丟上線、看使用者怎麼反應，再改。JEJ 負責商務開發（BD），技術全是我扛，這樣就開幹了。\n\n## 我們還做了一支蝙蝠俠\n\n要讓人懂「找陌生人幫你做事」這件事，光用講的很無聊。所以我們拍了一支介紹影片。\n\n<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/bz6AGtN3iFE\" title=\"CityTasker 蝙蝠俠介紹影片\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" allowfullscreen></iframe>\n\n設定是這樣的：有一座叫「高潭市」的城市，永遠充滿混亂，高端局長每天頭痛得要命，好在他只要呼叫幫手，問題很快就解決了。然後鏡頭一轉——「但回歸現實，你住的不是高潭市，你也不是高端局長。當你有解決不了的問題時，該去哪裡找你的超級英雄呢？」\n\n對，就是蝙蝠俠那個高譚市。一群沒什麼預算的年輕人，硬是用這個梗，把產品講成一個「召喚城市英雄」的故事。現在回頭看有點中二，但我到今天還是覺得那個點子很可愛。\n\n## 入選 AppWorks 第七屆\n\n真正讓我們覺得「這好像是玩真的」的，是入選了 **AppWorks 第七屆（#7）** 創業育成計劃。\n\n對當時的我們來說，這是一張門票。突然之間，身邊都是一群跟你一樣、眼睛發亮、想用一個 app 改變世界的人。我還記得跑去跟 Jamie（林之晨）要簽名，空氣裡瀰漫著一種「hack everything」、什麼都能被重新發明的氣氛。第七屆的 Demo Day 結束後，連《數位時代》都報導了我們。\n\n那種「我們是被選中的」感覺，很迷人。迷人到讓你很容易忘記一件事：**入選育成計劃，跟做出一門生意，是兩回事。**\n\n## 那天之後呢？\n\n開站那天的 106 個註冊，是我創業生涯裡少數幾個純粹的快樂時刻。\n\n但你大概已經猜到了——這個故事的結局，不是我們成功了。\n\nCityTasker 最後沒能熬過去。資金燒完、市場定位卡住、路上還撞到幾道我完全沒想過的牆。那是接下來幾篇要慢慢講的事。\n\n我想先把這個開場留在這裡：留在那個還相信一切都會成的傍晚，留在那個一邊按 F5、一邊跟夥伴傻笑的我。\n\n因為接下來要復盤的每一件事——燒錢的速度、踩到的法規、那句「我們不是那 1%」——都是從這個傍晚出發的。",
      "summary": "2013 年 8 月 7 日，CityTasker 開站。我盯著後台一直按 F5，一天 106 個人註冊、最高 50 人同時在線、54 封簡訊發出去——那一刻，我跟夥伴真的以為要成功了。這是一個入選 AppWorks 第七屆、最後燒完錢收攤的創業故事的開場。",
      "image": "https://bobochen.dev/_astro/cover.6qJ646PR.webp",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "創業",
        "AppWorks",
        "CityTasker",
        "新創",
        "職涯反思",
        "群眾外包"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/citytasker-not-the-one-percent/",
      "url": "https://bobochen.dev/blog/citytasker-not-the-one-percent/",
      "title": "不是那 1% 的人：十年後，我怎麼看 CityTasker 這場沒成功的創業",
      "content_text": "創業圈總在歌頌那 1% 成功的人，剩下 99% 呢？十年後回看 CityTasker——一場燒完錢、親手收掉的創業，我想重新定義什麼叫「失敗」，以及這段沒成功的經歷，後來怎麼變成我做到 VP Engineering 的底氣。",
      "content_html": "CityTasker 是 2012 到 2014 年，我和朋友做的一個任務媒合平台——「整個城市都有我的好幫手」。入選了 AppWorks 第七屆育成計畫，開啟了不一樣的歷程，雖然公司後來被併購，故事最後結局還是燒完錢收攤了。\n\n![2014 年初，CityTasker 三人團隊在 AppWorks 的辦公室](./images/appworks-team-2014.webp)\n\n*2014 年初，AppWorks 的辦公室。我們團隊三人（中間是當年青澀的我）*\n\n## 收掉公司那天，我覺得自己是輸家\n\n口袋見底的感覺，是一點一點來的。\n\n不是某個戲劇性的早晨醒來發現帳戶歸零，而是你眼睜睜看著數字往下掉，知道再過幾個月就撐不住了，卻找不到任何能讓它停下來的方法。最後我們做了那個誰都不想做的決定：先散了吧，各自回去上班。隊友轉去了旅遊產業，我把履歷打開，重新變回一個「找工作的工程師」。\n\n那段時間我很難跟人說清楚自己在幹嘛。前一年還在 Demo Day 上意氣風發，被[《數位時代》報導](https://www.bnext.com.tw/article/30113/BN-ARTICLE-30113)，轉眼就要去面試別人的公司、當一個打工仔。我心裡有個聲音很清楚：你失敗了。你不是那種會成的人。\n\n![第七屆 AppWorks Demo Day，台上簡報的是 CityTasker](./demo-day.webp)\n\n*前一年的高光時刻：第七屆 AppWorks Demo Day，台上是 CityTasker，台下坐滿了人。*\n\n## 那 1% 的迷思\n\n創業圈是一個很會說故事的地方。\n\n我們聽到的，永遠是那些成的人——某某 App 被收購、某某團隊估值幾億、某某創辦人三十歲財富自由。這些故事很迷人，迷人到你會以為那是常態。但真相是，那只是 1%。剩下的 99%，安安靜靜地燒完錢、關掉網站、回去上班，沒有人替他們寫報導。\n\n而我，就是那 99%。\n\n我花了好幾年才願意承認這件事，又花了更久才想通：問題不在於我是 99%，而在於我一直用 1% 的標準，去審判一段根本不該那樣被衡量的經歷。\n\n## 重新定義「失敗」\n\n有句話我放在心裡很久——大意是：沒被開除過、沒開除過人、沒燒完錢結束過一間公司、沒領過失業救濟金，其實都不算什麼重大挫敗。\n\n當年我把它讀成自嘲。十年後再讀，我把它反過來想。\n\n我燒完了自己的錢。我親手把一間公司收掉。我一個人扛過前端、後端、雙平台 App，撞過從沒想過的法規牆，還活了下來。這些事，聽起來像一串失敗清單，但你仔細看——這是多數人一輩子都不會有機會經歷的事。大部分人從進職場到退休，從來沒有真正「擁有」過一個東西，沒有為它的生死負過全責。\n\n我有過。它沒成，但它確確實實是我的。失敗，原來不是經歷的相反，它本身就是一種少數人才有的經歷。\n\n## 創業沒給我財富，給了我視角\n\n那兩年最值錢的，不是任何一行我寫過的程式。\n\n是它逼著一個只會寫 code 的人，去做完整個 0 到 1：自己分析需求、自己選技術、自己帶人、自己跟外面的世界溝通。我第一次知道一個產品從無到有要繞過多少看不見的坑，第一次知道「做得出來」和「有人要用」中間隔著一整個世界。\n\n這些東西，後來全都回來找我。從 17 Media 的 Principal Engineer、Snapask 的後端技術總監，到現在 QT Medical 的 VP Engineering——每一個位子要我做的判斷，幾乎都能在 CityTasker 那兩年找到原型。我能比較沉得住氣地看一個產品、看一個團隊、看一個還沒被驗證的方向，是因為我年輕時用自己的錢，把這堂課完整上過一遍。\n\n創業沒讓我變有錢。它給了我一副別人花錢也買不到的眼睛。\n\n## 給那個按 F5 的傍晚\n\n所以如果能回到 2013 年那個傍晚——那個盯著後台數字一格一格往上跳、跟夥伴傻笑著以為要成功了的我——我不會劇透結局，也不會叫他別跳。\n\n我只想拍拍他的肩膀，告訴他：你不會成為那 1%，這件事到頭來沒那麼重要。你以為這是一場你會贏或會輸的賭局，但其實，你正在領的，是一筆要十年後才會到帳、而且永遠不會貶值的東西。\n\n好好享受這個傍晚吧。後面的路，比你想的長，也比你想的好。",
      "summary": "創業圈總在歌頌那 1% 成功的人，剩下 99% 呢？十年後回看 CityTasker——一場燒完錢、親手收掉的創業，我想重新定義什麼叫「失敗」，以及這段沒成功的經歷，後來怎麼變成我做到 VP Engineering 的底氣。",
      "image": "https://bobochen.dev/_astro/cover.DpyjKR2p.webp",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "創業",
        "AppWorks",
        "CityTasker",
        "新創",
        "職涯反思",
        "失敗復盤"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/citytasker-speed-over-perfection/",
      "url": "https://bobochen.dev/blog/citytasker-speed-over-perfection/",
      "title": "速度比完美更重要：創業教我的事，後來都變成我帶團隊的原則",
      "content_text": "CityTasker 是我 2012 年和朋友做的任務媒合平台。當年沒資源、沒時間，「速度比完美更重要」是被現實逼出來的生存本能；十年後當我帶團隊一路做到技術總監、VP，這套心態反而成了我刻意選擇的管理原則。",
      "content_html": "## 想到一個功能，當晚就上線\n\n2012 到 2014 年，我和朋友做了一個叫 CityTasker 的東西——一個任務媒合平台，slogan 是「整個城市都有我的好幫手」。你有件懶得做的事，丟上來，附近有空的人接走。\n\n那時候我是 Co-founder 兼全端工程師，前端、後端、雙平台 App，一個人扛。團隊小到不行，所以我們沒有什麼「規格先評審、設計先過稿」的流程。一個功能想到了，覺得使用者可能會要，當晚就刻，刻完隔天就丟上線，然後盯著後台看有沒有人用。\n\n沒人用，就拿掉。有人用，就接著改。\n\n現在回頭看，那根本不是什麼方法論，那是窮人的本能。\n\n## 完美是有錢人的奢侈品\n\n我得老實說：當年選擇「先上線再說」，不是因為我們讀過什麼敏捷開發的書，而是因為我們別無選擇。\n\n帳戶裡的錢每天都在變少。你沒有三個月去把一個功能磨到完美，因為三個月後公司可能就不在了。完美是一種需要時間和金錢餵養的東西，而那兩樣我們都沒有。我們唯一有的，是「先讓它活著、再看市場給不給臉」的急迫感。\n\n所以我們學會了一件事：**先把不完美的東西丟出去，讓真實的使用者告訴你哪裡錯，遠比關起門來自己猜要快得多。**一個你覺得很完美、但沒人要的功能，跟一個醜陋、卻有人天天用的功能比起來，後者的價值高出太多。\n\n那時候我以為這只是創業的求生技巧。我沒想到，它後來會變成我帶團隊的底層信念。\n\n## 從求生本能，變成刻意的選擇\n\nCityTasker 燒完錢收攤後，我回去上班，接著一路在軟體開發、技術管理、DevOps、雲端架構裡打滾，從 17 Media 的 Principal Engineer、Snapask 的後端技術總監，做到現在 QT Medical 的 VP Engineering。\n\n帶的人愈來愈多，我發現一件有趣的事：當年那個被現實逼出來的「速度比完美重要」，現在變成了我**刻意**選擇的管理原則。差別在於——以前是因為沒得選，現在是因為我知道它真的有用。\n\n具體來說，它長成幾個樣子：\n\n- **敏捷，但不是因為時髦。**小步快跑、快速迭代，是因為我親身體會過「猜錯方向但跑得快」可以及時掉頭，「猜對方向卻跑得慢」反而被市場拋下。\n- **公開透明的溝通。**創業時資訊就在我們幾個人腦袋裡，藏不住也不必藏。帶團隊後我刻意維持這件事：不藏決策的理由、不藏壞消息。資訊一旦變成少數人的特權，速度第一個死。\n- **鼓勵主動承擔、勇於實驗。**我希望團隊裡的人敢自己做決定、敢試錯，而不是每件事都等我點頭。能自己接住任務、自己往前推的人，才跑得起來。\n- **把失敗當養分，而不是拿來究責。**這大概是 CityTasker 給我最深的一課。一個沒成功的創業教會我，面對失敗可以更坦然，把它當成長的養分，而非絕對的挫敗。所以團隊裡有人實驗失敗了，我第一個問的不是「誰的錯」，而是「我們從中學到什麼」。\n\n## 但「速度比完美」也有它的代價\n\n不過十年下來，我也學到這句話不能無限上綱。\n\n「先上線再說」的另一面，是技術債、是品質的妥協、是你某天得回頭還的帳。創業時我可以說「反正公司能不能活到下個月都不知道，債以後再說」——但帶一個要長期經營的產品和團隊時，這句話會害死你。\n\n所以現在的我，拿捏的方式變了。我會問：這次的不完美，是「可以之後再補」的那種，還是「補不回來」的那種？使用者體驗的小瑕疵、之後可以重構的程式碼，先上；但牽涉到資料安全、病人安全、信任這種補不回來的東西，我寧可慢。\n\n速度依然重要，但成熟一點的版本是：**該快的地方快到底，該慢的地方有膽量慢下來。**而分辨這兩者，正是十年來我一直在練的功課。\n\n## CityTasker 真正留給我的\n\n那場創業沒有成功。錢燒完了，口袋見底，我們先回去上班，隊友轉去了旅遊產業。從世俗的標準看，它是個失敗的故事。\n\n但「速度比完美更重要」這句話，跟著我走了十年，從一個窮學生的求生本能，長成一個技術主管的管理哲學。它幫我帶過好幾個團隊、撐過好幾個產品。\n\n如果有人問我 CityTasker 留下了什麼——不是那些註冊數字，也不是 AppWorks 的光環。是這套被現實狠狠教過、之後我又選擇相信一輩子的做事方式。\n\n這大概就是一場沒成功的創業，最值錢的遺產。",
      "summary": "CityTasker 是我 2012 年和朋友做的任務媒合平台。當年沒資源、沒時間，「速度比完美更重要」是被現實逼出來的生存本能；十年後當我帶團隊一路做到技術總監、VP，這套心態反而成了我刻意選擇的管理原則。",
      "image": "https://bobochen.dev/_astro/cover.Dohn-98-.webp",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "創業",
        "AppWorks",
        "CityTasker",
        "職涯反思",
        "技術管理",
        "團隊管理"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/citytasker-sudo-acquisition/",
      "url": "https://bobochen.dev/blog/citytasker-sudo-acquisition/",
      "title": "賣掉之後呢：CityTasker 最意外的結局，是連買下我們的公司也不在了",
      "content_text": "我說 CityTasker 燒完錢收攤了——那是真的，但不是全部。它後來其實被一間 AppWorks 認識的獵頭顧問公司 sudo 買下，從合作、一頓頓早餐會、到一紙 2016 年的收購合約。而最意外的結局是：連買下我們的那間公司，後來也不在了。",
      "content_html": "我在前面說過，CityTasker 燒完錢、收攤了。\n\n那是真的。但不是全部。\n\n有一段我一直沒講——因為它太安靜了，安靜到連我自己都差點以為它不算數。CityTasker 並沒有真的死在 2014 年那個收攤的決定裡。它後來，被賣掉了。\n\n## 收攤，不等於結束\n\n公司收掉之後，團隊是散了，但 CityTasker 這個東西、還有我們在創業圈裡結下的關係，沒有跟著散。\n\n故事要從 AppWorks 說起。我們那屆育成裡，還有另一支團隊，叫 sudo——一間做獵頭、人力顧問的公司。創業圈很小，同一個屋簷下一起被孵化，低頭不見抬頭見，我們很自然就熟了。\n\n一開始只是合作。他們媒合的是「人才」，我們媒合的是「任務」，中間有不少搭得上的地方，就你幫我、我幫你地來往著。\n\n## 那些早餐會\n\n真正讓事情慢慢長出來的，是早餐。\n\nsudo 的創辦人會定期約我們吃早餐。沒有什麼正式議程，就是邊吃邊聊——聊產品、聊團隊、聊各自卡在哪裡。現在回頭看，那一頓一頓的早餐，其實是一段很慢、很有耐心的「互相了解」。\n\n創業的時候，我一直以為談事情要靠 pitch deck、靠估值表、靠會議室裡正襟危坐的那一套。後來才知道，很多真正重要的事，是在一張早餐桌上、配著一杯溫豆漿談成的。\n\n## 一紙合約\n\n2016 年，我們簽了一份合約。\n\nCityTasker，賣給了 sudo。\n\n那一刻的感覺，我到現在都說不太清楚。它不像 2014 年收攤那樣，有明確的失落；也不像被大公司高價收購那樣風光。它就是——一個我親手做出來、又親手收掉的東西，終於找到了一個願意接手的人。\n\n這算成功出場嗎？還是只是換一種方式收攤？我到今天都還沒有標準答案。\n\n![2016 年，把 CityTasker 賣給 sudo 的收購契約書](./images/citytasker-收購契約.jpg)\n\n*2016 年的那紙收購契約書。一個親手做出來的東西，最後用一份合約交了出去。*\n\n## 然後，買下我們的人也不在了\n\n但這個故事真正讓我意外的，不是「賣掉」這件事本身。\n\n是沒過多久，sudo 自己，也收了。\n\n那個曾經坐在我對面、一口一口早餐跟我談下 CityTasker 的人，他的公司，後來也走進了跟我們一樣的結局。\n\n我一直以為，我是把 CityTasker 交到了一個「比我們更穩、更會做生意」的人手上——一個更像那 1% 的人。但事實證明，沒有誰是穩的。在這個圈子裡，今天看起來活得好好的人，明天可能就不在了。我是這樣，他也是。\n\n## 真正活得比公司久的東西\n\n所以 CityTasker 到底死了幾次？\n\n收攤算一次，賣掉算一次，連買下它的人都消失，再算一次。照理說，這應該是一個徹底失敗、徹底歸零的故事。\n\n但奇怪的是，留下來的東西，比我以為的多。\n\n不是公司——公司早就沒了。是那張在 AppWorks 結下的關係網，是那些早餐桌上的對話，是「原來一件事可以這樣慢慢談成」的那種篤定。這些東西，比任何一間公司都活得久。\n\n我花了整整一個系列，想搞懂自己到底算不算那 1%。寫到這裡，我終於確定：這個問題，從一開始就問錯了。\n\n公司會收、會賣、會被接手的人再收掉一次。但你在過程裡認識的人、你學會看世界的方式，不會。那才是真正不會貶值的東西。\n\nCityTasker 死了三次。但它，從來沒有真的離開我。",
      "summary": "我說 CityTasker 燒完錢收攤了——那是真的，但不是全部。它後來其實被一間 AppWorks 認識的獵頭顧問公司 sudo 買下，從合作、一頓頓早餐會、到一紙 2016 年的收購合約。而最意外的結局是：連買下我們的那間公司，後來也不在了。",
      "image": "https://bobochen.dev/_astro/cover.dXyCgqnf.webp",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "創業",
        "AppWorks",
        "CityTasker",
        "併購",
        "新創",
        "職涯反思"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-future-and-you/",
      "url": "https://bobochen.dev/blog/agentic-engineering-future-and-you/",
      "title": "Agentic Engineering 的下一步：2026 之後，工程師還需要寫 code 嗎？",
      "content_text": "SWE-bench Pro 今天 23%，一年後會是多少？當 agent 的能力每季都在進步，工程師的不可取代性到底在哪裡？不做預測，而是從一年實戰中歸納出不會被 agent 取代的能力，以及一份你今天就能開始的「防衰退」訓練計畫。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的最後一篇。上一篇：[團隊導入](/blog/agentic-engineering-team-adoption)\n\n## 一年前 49%，現在 72%，明年呢？\n\n一年前我開始 100% AI coding 的時候，SWE-bench Verified 的最高分是 49%。寫這篇文章的今天，最高分已經超過 72%。\n\n一年之內進步了 23 個百分點。如果保持這個速度，2027 年就會接近 95%。\n\n但別急著恐慌（或歡呼）。因為更嚴格的 SWE-bench Pro，最好的 model 也只拿到 ~23%。JetBrains 的 DPAI Arena 和 Stanford 的 Terminal-Bench 也顯示類似的模式——agent 在「乾淨的、有限範圍的」問題上進步飛速，在「混亂的、需要廣泛 context 的」真實世界問題上進步緩慢。\n\n72% 跟 23% 之間的落差（Gartner 預測 40% 的 agentic AI 部署會在 2027 年前被取消）告訴我們一個重要的事實：agent 的「demo 能力」和「真實世界能力」之間，仍有巨大鴻溝。\n\n這個鴻溝在縮小嗎？一定在。但以什麼速度，沒人知道。\n\n我不打算做預測，那是 pundit 的工作。作為一個 practitioner，我更感興趣的是：不管 agent 的能力怎麼變，什麼是不變的？\n\n## 2026 年，Agent 仍然不會做的事\n\n經過一年的實戰，我整理出五件 agent 到今天為止仍然做不好、且短期內不太可能做好的事：\n\n### 1. 釐清模糊的需求\n\n「用戶反映登入流程太複雜。」\n\n這句話背後可能意味著 20 種不同的改善方向。人類工程師會去找 PM 問、看 user research、在白板上畫 flow——然後形成一個具體的方案。\n\nAgent 拿到這句話之後，會直接開始寫 code。寫出來的東西可能很完整，但解決的可能不是真正的問題。\n\n[Spec-Driven Development](/blog/spec-driven-development-for-agents) 可以緩解這個問題，但 spec 本身得由人來寫。把模糊需求變成精確 spec 的能力，是目前 agent 無法取代的。\n\n### 2. 組織政治與跨團隊協調\n\n「這個 API 改動需要後端團隊同意」、「那個 PM 特別在意 performance」、「QA 組長不喜歡我們用太多 mock」。\n\n這些 organizational context 不在 codebase 裡，也不在任何文件裡，它們存在於人的關係和記憶中。Agent 沒辦法 navigate 這些。\n\n### 3. 品味與審美判斷\n\n「這個 UX 好嗎？」不是一個有標準答案的問題。\n\nAgent 可以完美實現你 spec 裡描述的每一個細節。但如果你的 spec 沒有描述「這個按鈕在這裡感覺太擠」，agent 不會自己發現。\n\nDesign sense、產品直覺、「這個感覺不對」的第六感，這些目前完全是人類的領域。\n\n### 4. 倫理和合規判斷\n\n「這個功能收集了 PII，需要通知用戶嗎？」\n「我們的 AI feature 在歐盟受 AI Act 規範嗎？」\n「這個 dark pattern 在法律上可以但在道德上該不該用？」\n\nAgent 可以幫你查法規、整理 compliance checklist，但最終的判斷——「我們做不做這件事」——必須是人做的。\n\n### 5. 真正創新的架構設計\n\nAgent 非常擅長 follow 既有的 patterns。你的 codebase 用了 MVC，它就會寫 MVC。你用了 event-driven，它就寫 event-driven。\n\n但當你需要 **break** 既有的 pattern——因為 scale 需要、因為 paradigm shift、因為你發現了一種更好的方式——agent 幫不了你。它的 training data 是過去的 best practices，不是未來的。\n\n## 不可取代的人類能力\n\n把以上歸納為四種核心能力：\n\n### 判斷力\n\n知道什麼該做、什麼不該做。\n\nAgent 可以做任何你叫它做的事。但「這件事值不值得做」、「做到什麼程度就夠了」、「這個 trade-off 怎麼取」，這些都是判斷力的範疇。\n\n在 agentic workflow 裡，你做的每一個決策的價值都被放大了。因為 agent 會忠實地執行你的決策：好的決策會被高效執行，壞的決策也會。\n\n### 品味\n\n不是「能不能做」，而是「該不該這樣做」。\n\n好的 API 設計、好的 UX、好的 code 結構，這些都需要 taste。Agent 可以在你給的 constraints 內找到最優解，但 constraints 的設計需要品味。\n\n### 組織 Context\n\n理解公司政治、團隊動態、stakeholder 的優先級。\n\nAgent 不知道你的 CTO 最近在推 microservices、你的 PM 下個月要 demo 給投資人看、你的 QA 上週才因為沒 test 被 production bug 搞到加班。這些 context 決定了你該怎麼排優先級、怎麼溝通、怎麼取捨。\n\n### 倫理決策\n\n合規、隱私、安全的最終判斷。\n\n這不是 agent 做不到，而是我們不應該讓 agent 做。涉及 ethics 的決策需要 accountability，而 accountability 只能由人來承擔。\n\n## 技能衰退？還是技能轉型？\n\n回到 [Post 2](/blog/agentic-engineering-mindset-shift) 討論過的身份認同議題：用了一年 agent，我的技能有什麼變化？\n\n### 衰退的能力\n\n- **語法記憶**：我已經忘記很多 API 的具體參數了。以前能憑記憶寫出完整的 `Array.reduce`，現在常常要想一下。\n- **手寫 boilerplate 的速度**：以前 30 分鐘能寫完一個 CRUD endpoint，現在手寫可能要 45 分鐘（因為不常練了）。\n- **某些 debug 直覺**：以前看到 error message 就大概知道是什麼問題。現在我傾向先丟給 agent，而不是自己分析。\n\n### 增長的能力\n\n- **系統設計能力**：因為 agent 處理了 implementation 細節，我花更多時間想架構、想 trade-off。設計能力反而變強了。\n- **需求分析能力**：寫 [spec](/blog/spec-driven-development-for-agents) 寫多了，分析和拆解需求的能力提升了。\n- **Review 眼光**：看了一年 agent 的 code，我對 [hallucination pattern](/blog/agent-output-verification-review) 的敏感度提高了很多。\n- **溝通表達能力**：寫好的 spec 就是清楚的溝通。這個技能也提升了。\n\n### 淨結果\n\n對有經驗的工程師來說，這是一個划算的交換：低價值的「打字型」技能衰退，高價值的「判斷型」技能增長。\n\n但我要誠實地說，這讓我偶爾感到不安。有時候同事問我一個 syntax 問題，我需要查一下才能回答，心裡會覺得「以前我是可以秒答的」。\n\n這種不安是正常的。技能轉型的過程中，舊能力的衰退總是比新能力的增長更容易被察覺。但退一步看，一個擅長系統設計和需求分析、只是需要查 syntax 的工程師，比一個能背 API 參數但不擅長設計的工程師，在 agent 時代更有價值。\n\n## 一份「防衰退」訓練計畫\n\n不管你對未來怎麼看，以下的練習都不會浪費。它們同時鍛練「跟 agent 協作」和「agent 做不到的事」。\n\n### 8 週循環計畫\n\n**Week 1-2：Context Engineering 練習**\n\n- 目標：讓你的 [CLAUDE.md](/blog/claude-md-rules-files-masterclass) 的品質提升一個等級\n- 練習：review 你的 CLAUDE.md，問自己「agent 重複犯的錯哪些可以靠加一條 rule 解決？」\n- 副作用：練習精確溝通的能力\n\n**Week 3-4：Spec Writing 練習**\n\n- 目標：每個 task 都先寫 [spec](/blog/spec-driven-development-for-agents) 再交給 agent\n- 練習：用 Goal / Constraints / Verification 格式寫 spec\n- 副作用：練習需求分析和拆解的能力\n\n**Week 5-6：Review 練習**\n\n- 目標：刻意練習找 agent code 裡的問題\n- 練習：review agent 的 PR 時，記錄你找到的每一個問題，分類是「邏輯錯誤」還是「事實錯誤」\n- 副作用：提升 [code review](/blog/agent-output-verification-review) 的效率和敏感度\n\n**Week 7-8：手寫練習**\n\n- 目標：保持基礎能力的手感\n- 練習：每兩週選一個小功能，不用 agent，完全手寫\n- 副作用：當 agent 出問題時你還有能力自己 debug\n\n### 持續練習\n\n- **每月一次架構設計**：不用 agent，自己做一個系統的架構決策。可以是你正在做的專案的某個子系統。\n- **每季一次 agent-free day**：完全不用 agent 工作一天。感受差異，保持自信。\n\n## 回到 Trust Paradox\n\n[Post 1](/blog/agentic-engineering-what-is-it) 開頭提到的數據：80% 的開發者在用 AI agent，但只有 29% 信任它。\n\n14 篇文章之後，我想我理解這個 paradox 了。\n\n信任不是靠「AI 變得更好」來建立的，而是靠方法論來建立的。\n\n你不信任 agent 是因為：\n\n- 它會 hallucinate（→ 你建了 [品質保證流程](/blog/agent-output-verification-review)）\n- 它不懂你的專案（→ 你設計了 [context engineering](/blog/context-engineering-deep-dive)）\n- 它會亂做（→ 你寫了 [spec](/blog/spec-driven-development-for-agents)）\n- 它可能搞壞東西（→ 你設了 [安全網](/blog/agentic-engineering-testing-safety)）\n- 它很貴（→ 你做了 [成本優化](/blog/agentic-engineering-cost-optimization)）\n\n每一個不信任的理由，都有一個工程解法。\n\nAgentic Engineering 不是盲目信任 AI，而是用工程方法建立有根據的信任。\n\n這整個系列教的，就是這件事。\n\n## 結語：工程師的黃金時代\n\n我不知道 5 年後工程師還需不需要寫 code。老實說，沒有人知道。\n\n但我知道一件事：2026 年的今天，是工程師有史以來能產出最多價值的時代。\n\n一個人 + 一個 agent，可以做到以前需要一個小團隊才能做的事。[一個需求 60 分鐘從 Prompt 到 Production](/blog/agentic-engineering-daily-workflow-advanced)。一個好的 [spec](/blog/spec-driven-development-for-agents) 可以驅動一整個 feature。一份好的 [CLAUDE.md](/blog/claude-md-rules-files-masterclass) 可以讓 agent 像一個了解你專案的 junior engineer 一樣工作。\n\nBuilder 的門檻比以前更低了。一個人可以建產品、發布、維護，不需要融資、不需要團隊。但這不代表工程師的價值降低了，恰恰相反，**有 judgment 的工程師**比以前更稀缺。\n\n因為 agent 放大了一切：好的判斷被放大成高效的成果，壞的判斷也被放大成高效的災難。能做出好判斷的人，從來沒有像現在這麼有價值。\n\n所以，如果你問我「工程師還需要寫 code 嗎？」\n\n我的答案是：你需要的不是「寫 code」的能力，而是「知道該寫什麼 code」的能力。前者 agent 在取代，後者 agent 在放大。\n\n14 篇下來，從 [Agentic Engineering 是什麼](/blog/agentic-engineering-what-is-it) 到 [怎麼帶進團隊](/blog/agentic-engineering-team-adoption)，我分享了一年 100% AI coding 的完整方法論。這不是一篇「AI 會改變世界」的泛泛之談。這是一個工程師的實戰紀錄——踩過的坑、找到的解法、建立的系統。\n\n如果這個系列對你有幫助，它之後會整理成書。\n\n不是因為我要賣書，而是因為我相信，工程師需要一份「agent 使用手冊」——不是工具教學，而是方法論。\n\n這份手冊，就是你現在手上的這 14 篇。\n\n## Takeaway\n\n1. **Agent 在進步，但「demo 分數」跟「真實世界能力」之間仍有巨大鴻溝**。SWE-bench Verified 72% vs SWE-bench Pro 23%，這個差距就是 Agentic Engineering 存在的意義——你是那個填補鴻溝的人。\n\n2. **不可取代的不是「會寫 code」，而是「知道該寫什麼 code」**。判斷力、品味、組織 context、倫理決策——這些能力在 agent 時代的價值不減反增。\n\n3. **最好的防衰退策略不是抗拒 AI，而是讓 AI 替你做低價值的事，你專注在高價值的判斷上**。8 週循環訓練計畫涵蓋了 context engineering、spec writing、code review、和手感維持——這些是不管 AI 怎麼發展都有用的能力。\n\n---\n\n_這是「Agentic Engineering 實戰手冊」系列的最後一篇。_\n_完整系列：[系列目錄](/blog/agentic-engineering-what-is-it)_\n_如果這個系列對你有幫助，它之後會整理成書。敬請期待。_",
      "summary": "SWE-bench Pro 今天 23%，一年後會是多少？當 agent 的能力每季都在進步，工程師的不可取代性到底在哪裡？不做預測，而是從一年實戰中歸納出不會被 agent 取代的能力，以及一份你今天就能開始的「防衰退」訓練計畫。",
      "image": "https://bobochen.dev/_astro/cover.BaTdqDSD.webp",
      "date_published": "2026-06-12T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "AI",
        "未來趨勢",
        "職涯",
        "軟體工程"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/homebrew-6-0-0/",
      "url": "https://bobochen.dev/blog/homebrew-6-0-0/",
      "title": "Homebrew 6.0 來了：先搞懂這幾個會影響你日常的改動",
      "content_text": "Homebrew 6.0 不是炫技版號，而是把資安收緊、把你每天 brew install 的習慣改掉，還開始跟 Intel Mac 道別。挑幾個對 Mac 開發者最有感的改動，實測給你看。",
      "content_html": "如果你是 Mac 開發者，`brew` 大概是你每天最早敲下的幾個指令之一。所以當它跳上 **6.0** 這個大版號時，值得花十分鐘搞清楚發生了什麼事——因為大版號意味著 breaking change，而這次有幾個改動，是你「下一次 `brew install` 就會直接撞到」的那種。\n\n先給結論：6.0 的重點，是把你每天在用的 brew 變得**更安全、也更快**——不是塞一堆新功能，而是把基本功做扎實。而你會直接撞到的，是這三件事——\n\n1. **把供應鏈安全收緊**：第三方 tap 不再「裝了就跑」。\n2. **改掉你每天的操作習慣**：`brew install` 現在會先問你一句。\n3. **開始跟 Intel Mac 道別**：x86_64 進入退場時間表。\n\n剩下的都是錦上添花。下面一個一個看。\n\n## brew install 之後，它現在會先問你一句\n\n這是你最快會注意到的改動。以前 `brew install something` 按下 enter，它就一路裝到底。6.0 開始，**對開發者預設開啟「ask 模式」**：你敲下指令後，它不會立刻動手，而是先列出這次會連帶安裝或升級哪些相依套件，停下來等你回一個 `Y`。升級時如果沒有東西需要動，它也夠聰明、不會多問。\n\n官方說這是照使用者問卷做的決定——大家其實想在「它要對我的系統做什麼」之前，有個喊停的機會。\n\n實務上唯一要注意的是 **CI**。你的 pipeline 裡如果有 `brew install`，多了一個互動式確認就會直接卡住。兩個解法：\n\n```bash\n# 本機：這次不要問\nbrew install -y wget          # 或寫成 --no-ask\n\n# CI：整個環境都別問\nexport HOMEBREW_NO_ASK=1\n```\n\n> [!NOTE]\n> `-y` / `--no-ask` 是「這次不要問」，`HOMEBREW_NO_ASK` 是「整個環境都別問」。本機留著 ask 模式其實很好用（避免手滑裝錯、被拖一大串相依套件下來），但 CI 記得設環境變數。\n\n## 第三方 tap 不再「裝了就跑」：Tap 信任機制\n\n這是 6.0 真正的頭條，也是最值得花時間理解的一個。\n\n先講清楚問題：一個第三方 tap（`brew tap user/repo` 那種）本質上是**一包會在你機器上、用你的權限、不經沙箱執行的 Ruby 程式碼**。你 `brew install` 它底下的某個 formula，等於同意跑它的安裝腳本。過去這件事是默默發生的——這也正是為什麼近期會出現「把惡意程式碼塞進 tap」這類攻擊。\n\n6.0 把這個洞補起來：**第三方 tap（以及它底下的 formula、cask、外部指令）現在必須先被你明確信任，Homebrew 才會去 evaluate 它的程式碼。** 官方的 Homebrew taps 和內建指令一律預設信任，所以你日常的 `brew install wget` 完全不受影響。\n\n要信任有兩種做法：\n\n```bash\n# 做法一（推薦）：只信任你真正要的那一個\n# 用「完整路徑名」安裝，就只信任這一項\nbrew install user/repo/formula\nbrew install --cask user/repo/cask\n\n# 做法二：信任整個 tap（連它之後新增的東西也一起放行）\nbrew trust user/repo\n```\n\n「做法一」的範圍最小，是官方建議的預設姿勢——你要哪個就信任哪個，而不是把整個 tap 未來會長出來的所有東西，現在就先簽下去。\n\n過渡期如果你被擋住、或 CI 整批爆掉，有一個**暫時**的逃生門：\n\n```bash\nexport HOMEBREW_NO_REQUIRE_TAP_TRUST=1\n```\n\n但官方講得很白：這只是過渡用的，最終會強制。所以正解是把你常用的幾個 tap 一次信任好，而不是長期靠這個環境變數繞過。\n\n> [!WARNING]\n> 如果你的 CI 在升上 6.0 之後突然變紅，第一個要懷疑的就是這裡——`brew doctor` 的 untrusted-tap 檢查、或第三方 tap 的 formula 載入被擋下。先盤點 pipeline 裡用到哪些非官方 tap，把它們補上 trust。\n\n## brew exec：Homebrew 版的 npx\n\n如果你寫過 Node，一定用過 `npx`——不想把工具裝進全域，臨時拉下來跑一次就好。6.0 給了 Homebrew 一個對應的東西：**`brew exec`**（縮寫 `brew x`）。\n\n它的用法是：指定這次需要哪些 formula，Homebrew 幫你（必要時）裝好、把它們和相依套件的執行檔目錄塞進 `PATH`，然後跑你的指令：\n\n```bash\n# 臨時用 jq + yq 跑一個腳本，不用先全域安裝\nbrew exec --formulae=jq,yq -- ./script.sh\n```\n\n對「我只是想跑個一次性腳本、不想污染環境」這種情境很實用，尤其是寫教學、寫 Makefile、或想在乾淨環境裡驗證相依關係的時候。\n\n## brew vulns：順手掃一下裝了什麼有洞的東西\n\n延續這版的資安主軸，6.0 還多了 **`brew vulns`**：拿你「已經安裝」的套件去比對已知漏洞，告訴你哪些東西該升級了。\n\n過去你大概得靠額外工具、或自己留意 CVE，現在 Homebrew 本身就能給你一張清單。詳細選項可以用 `brew vulns --help` 看，但概念很單純——把「你裝了什麼」和「哪些版本有已知問題」對起來，是個值得偶爾跑一次的習慣。\n\n## 這版整體變快了\n\n6.0 把幾個原本是選用的加速項目轉成預設：\n\n- **內建 JSON API 變預設**：把套件 metadata 合併成單次下載，少了一堆零碎的網路請求，啟動更快。（舊的 `HOMEBREW_USE_INTERNAL_API` 環境變數也因此功成身退、被標記棄用。）\n- **`brew leaves` 快了約 30%**：就是列出你「手動裝、且沒被其他套件依賴」的那個指令。\n- **平行化**：升級時平行抓 bottle 的 tab、`brew bundle` 預設平行安裝 formula。\n\n這些都不用你動手，升上去就有。\n\n## Intel Mac 的倒數計時開始了\n\n如果你還在用 Intel（x86_64）的 Mac，這段一定要看。官方公布了明確的退場時間表：\n\n| 時間 | x86_64 macOS 的狀態 |\n| --- | --- |\n| 2026 年 9 月 | 降到 **Tier 3**：不再跑 CI、不再產新的 bottle |\n| 2027 年 9 月 | **完全不支援**，相關程式碼整批移除 |\n\n白話講：今年九月之後，Intel Mac 上不少套件會開始沒有預編譯好的 bottle，得自己從原始碼編（慢，又容易踩雷）；明年九月之後就完全不在支援範圍內了。手上還有 Intel 機器在當生產力主力的人，這一年就是你規劃遷移的窗口。\n\n同一批 OS 相關更新還包括：\n\n- **macOS 27（Golden Gate）** 初步支援上線（同時這版也是放掉 Intel 的版本）。\n- 認得 **M5、M5 Pro/Max** 晶片。\n- WSL 體驗改善（`brew config` 會顯示 Windows build）。\n\n## 其他順手知道就好的改動\n\n不是每個人都會用到，但掃一眼留個印象：\n\n- **Cask 現在可以 pin**：把某個 GUI app 釘在特定版本，不讓它被升級。\n- **Install Steps 框架**：把常見的安裝步驟（建目錄、搬檔案、建 symlink）改成「純資料、不需要在安裝當下跑 Ruby」的描述——又是一個縮小安全暴露面的設計。\n- **下載冷卻（download cooldowns）**：對 Bundler、RubyGems、npm、pip、PyPI 加了節流，降低供應端被濫用的風險。\n- **`brew bundle` 變強**：新增 `npm`、`krew`、`winget`（Windows）支援，`cleanup` 也能一起管 npm／cargo／go／uv。\n- **Services**：可以啟動 systemd timer、自動建好 service 需要的目錄。\n\n## 要現在升級嗎？怎麼升\n\n要。這版的安全改動是實打實有意義的，加速也是免費送的。升級照舊：\n\n```bash\nbrew update\nbrew upgrade\n```\n\n升完之後，給自己三個提醒：\n\n1. **CI 會不會被卡**：ask 模式 → 設 `HOMEBREW_NO_ASK=1`；第三方 tap → 補 `brew trust`，別長期靠 `HOMEBREW_NO_REQUIRE_TAP_TRUST` 繞過。\n2. **盤點你的非官方 tap**：花五分鐘把常用的幾個一次信任好，之後就順了。\n3. **Intel 使用者排遷移**：把「九月前」放進行事曆。\n\n一句話總結：Homebrew 6.0 沒有給你新玩具，但它讓「你每天都在用的這個工具」更安全、更快，也更願意在動手前先問你一句。對天天 `brew` 的人來說，這種版本反而最值得認真升。\n\n---\n\n*資料來源：[Homebrew 6.0.0 官方公告](https://brew.sh/2026/06/11/homebrew-6.0.0/)、[Homebrew Tap-Trust 文件](https://docs.brew.sh/Tap-Trust)、[Homebrew Manpage](https://docs.brew.sh/Manpage)。*",
      "summary": "Homebrew 6.0 不是炫技版號，而是把資安收緊、把你每天 brew install 的習慣改掉，還開始跟 Intel Mac 道別。挑幾個對 Mac 開發者最有感的改動，實測給你看。",
      "image": "https://bobochen.dev/_astro/cover.DrXfWNUu.webp",
      "date_published": "2026-06-12T00:00:00.000Z",
      "tags": [
        "Homebrew",
        "macOS",
        "套件管理",
        "開發環境",
        "資安"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/chezmoi-name-pronunciation-chez-moi/",
      "url": "https://bobochen.dev/blog/chezmoi-name-pronunciation-chez-moi/",
      "title": "chezmoi 到底怎麼念？它是法文「我家」，拿來管 $HOME 剛剛好",
      "content_text": "chezmoi 不念「切斯莫伊」——它是法文 chez moi [ʃɛ mwa]「我家」。一個管理你 $HOME 的工具，名字就叫「我家」，背後是個很巧妙的設計隱喻。",
      "content_html": "我寫了一整個系列在講 `chezmoi`——多機同步、template、加密、private repo bootstrap……每天 `chezmoi apply` 敲得飛快。\n\n但前陣子被同事問了一句話，我居然答不出來：\n\n> 「這個 `chezmoi`，到底怎麼念？」\n\n我下意識念了「切斯-莫伊」（chez-moy），唸完自己都心虛。回去查了一下才發現——**大部分人都念錯了，包括我**。\n\n## 正解：它是法文\n\n`chezmoi` 不是某個工程師亂拼的字母組合，它是一個**法文片語** `chez moi`：\n\n> [!NOTE] chezmoi 怎麼念\n> **`chez moi` → IPA `[ʃɛ mwa]`，注音近「雪・ㄇㄨㄚ」**\n> - `chez` ≈ 「雪」`[ʃɛ]`（不是「切斯」，z 不發音）\n> - `moi` ≈ 「ㄇㄨㄚ」`[mwa]`（不是「莫伊」，oi 在法文念 `[wa]`）\n>\n> （注音是為了好記的近似標註，真正的母音還是以 IPA 為準。）\n\n![Google AI 摘要：「chez moi」的法文發音為 ʃɛ mwa（雪 ㄇㄨㄚ），chez 唸 ʃɛ、moi 唸 mwa](./image.png)\n\n*連 Google 搜尋的 AI 摘要都這樣標——唸「雪・ㄇㄨㄚ」。*\n\n關鍵就在那兩個「以為會發音、其實不發」的字母：`chez` 的 `z` 是啞音，`moi` 的 `oi` 在法文一律念 `[wa]`（跟 `bonjour` 旁邊的 `moi`、`voilà` 的邏輯一樣）。所以整個字聽起來是輕快的「**雪・ㄇㄨㄚ**」，不是硬邦邦的「切斯莫伊」。\n\n## 意思更妙：它是「我家」\n\n把這個片語拆開：\n\n- `chez` 是法文的介系詞，意思是「**在……的地方 / 在某人家**」\n- `moi` 就是「**我**」\n\n合起來，`chez moi` = **「在我家 / 我家」**。這是法國人天天掛在嘴邊的日常用語——「Viens chez moi（來我家）」、「Je reste chez moi（我待在家）」。\n\n## 為什麼這名字是神來一筆\n\n現在回頭看這個工具在做什麼，你會會心一笑。\n\n[chezmoi 的設計哲學](/blog/chezmoi-dotfiles-multi-machine-setup/)是：**你的 dotfiles 是「原始碼」，你的 home 目錄是「編譯結果」**。它把 source state 編譯、渲染、解密之後，寫進你的 **destination state——也就是你的 `$HOME`**。\n\n換句話說，這個工具**從頭到尾就只做一件事：把東西安頓進你的家目錄**。\n\n而它的名字，字面意思就是「**我家**」。\n\n一個專門管理 `$HOME` 的工具，取名叫「我家」。連 Unix 裡代表 home 的那個 `~` 符號都跟著一起呼應了。命名跟功能扣得這麼緊，實在漂亮。\n\n> [!TIP] 順帶一提\n> chezmoi 的作者是 [Tom Payne（twpayne）](https://github.com/twpayne/chezmoi)。下次有人把它念成「切斯莫伊」，你就可以淡淡地補一句：「是『雪・ㄇㄨㄚ』喔，法文，意思是我家。」\n\n## 一句話記法\n\n> **念「雪・ㄇㄨㄚ」，意思是「我家」——管你 `$HOME` 的工具，名字就叫我家。**\n\n記住這句，發音、拼字、設計理念，一次全到位。\n\n---\n\n想真的把「我家」搬上每一台新電腦，看這幾篇實戰：\n\n- [chezmoi 實戰：一份 dotfiles 管理三台不同用途的 Mac](/blog/chezmoi-dotfiles-multi-machine-setup/)\n- Dotfiles 管理術：用 chezmoi 一鍵重建開發環境\n- [Mackup vs chezmoi vs 手寫 script：macOS 設定備份工具怎麼選？](/blog/mackup-vs-chezmoi-vs-defaults-sh-comparison/)",
      "summary": "chezmoi 不念「切斯莫伊」——它是法文 chez moi [ʃɛ mwa]「我家」。一個管理你 $HOME 的工具，名字就叫「我家」，背後是個很巧妙的設計隱喻。",
      "image": "https://bobochen.dev/_astro/cover.Buq0kwgH.webp",
      "date_published": "2026-06-08T00:00:00.000Z",
      "tags": [
        "chezmoi",
        "dotfiles",
        "冷知識",
        "macOS",
        "開發環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/coursera-verify-id-chinese-name-passport/",
      "url": "https://bobochen.dev/blog/coursera-verify-id-chinese-name-passport/",
      "title": "拿到 Google Cybersecurity 證書，卻卡在最後一關 Verify ID：身分證一直失敗，改用護照英文姓名才過",
      "content_text": "我考完 Google Cybersecurity Professional Certificate，最後卻被 Coursera 的 Verify ID 擋了下來——用身分證驗證時名字一直出錯、一直失敗。原因是 Coursera 是國外系統，辨識中文姓名時偶有誤判。後來改用護照（英文姓名）就順利通過。這篇把踩雷過程與解法整理給同樣卡關的你。",
      "content_html": "## 最後卡住我的不是考試，是 Verify ID\n\n前陣子我把 [Google Cybersecurity Professional Certificate](https://www.coursera.org/professional-certificates/google-cybersecurity) 的課程與測驗全部跑完，正式拿到證書 🎉。![alt text](<2026-06-07 at 20.11.30.png>)\n\n照理說，最難的關卡應該是那些測驗才對。結果真正讓我卡住、差點放棄的，竟然是發證前的最後一道手續——**身分驗證（Verify ID）**。\n![alt text](image.png)\n\n如果你也在考 Coursera 上的證照，最後被這關擋下來，這篇就是寫給你的。我把當時的踩雷過程跟最後怎麼解掉，完整記錄下來。\n\n## 問題：用身分證驗證，名字一直出錯、一直失敗\n\nCoursera 在你完成證照前，會要你做一次身分驗證：上傳一份有照片的證件，系統會去比對證件上的姓名跟你帳號的姓名是否一致。\n\n我一開始很直覺地拿了**身分證**去驗證。結果就是——**一直失敗**。\n\n更怪的是，驗證流程辨識出來的名字是**錯的**：跟我身分證上的中文姓名對不起來。我重試了好幾次、重拍了好幾張照片，光線、角度都調整過了，結果還是一樣卡在那裡，怎麼弄都過不了。\n\n那種「課都上完了、測驗也通過了，只差最後一步卻一直被擋」的無力感，真的滿挫折的。\n\n## 為什麼會這樣：國外系統辨識中文姓名容易出錯\n\n後來我才搞懂癥結點：**Coursera 是國外的系統，在辨識中文姓名時偶爾會出錯。**\n\n它的證件辨識（OCR）與比對流程，本來就是以拉丁字母、英文姓名為主要設計情境。換成一張全是中文字的身分證，系統要嘛讀不準、要嘛跟帳號裡的姓名格式兜不起來，比對自然就一直失敗。\n\n換句話說，問題不在你、也不在你的證件，而是**「中文姓名」這個輸入，正好踩到了國外系統不擅長的地方**。\n\n## 解法：改用護照上的英文姓名\n\n知道原因之後，解法就很直覺了——**改用護照**。\n\n護照上有你的**英文姓名**，正好是這套國外系統最吃得準的格式。我把驗證證件從身分證換成護照之後，**一次就順利通過**，證書也就跟著發下來了。\n\n所以我的建議很簡單：\n\n> **在 Coursera 做 Verify ID 時，直接用護照、以護照上的英文姓名進行認證。** 別跟身分證上的中文姓名硬碰硬。\n\n如果你的 Coursera 帳號姓名原本是中文，驗證前可以先把姓名調整成與護照一致的英文拼法，能進一步降低比對失敗的機率。\n\n## 還是卡關？直接找 Coursera Help Center\n\n如果你換了護照還是過不了，或是遇到其他**系統設定相關**的問題——例如**姓名更改、Verify ID 異常**等等——我會建議不要一個人在那邊重試到崩潰，**直接洽詢 Coursera 官方的 Help Center 取得協助**：\n\n👉 [Coursera Learner Help Center — Contact Us](https://www.coursera.support/s/learner-help-center-contact-us?language=en_US)\n\n這類帳號／驗證層級的問題，官方客服能直接從後台處理，比自己反覆嘗試有效率得多。\n\n## 小結\n\n整理一下這次的重點：\n\n- 考完 Google Cybersecurity Professional Certificate，最後一關是 **Verify ID 身分驗證**。\n- **用身分證驗證會因為中文姓名辨識而一直失敗**——這是國外系統的常見限制，不是你的錯。\n- **改用護照、以英文姓名認證**通常就能順利通過。\n- 還有姓名更改、Verify ID 等系統問題，**直接找 [Coursera Help Center](https://www.coursera.support/s/learner-help-center-contact-us?language=en_US)**。\n\n別讓最後一道手續卡住你辛苦考來的證書。希望這篇能幫你少走一段冤枉路 🙌。",
      "summary": "我考完 Google Cybersecurity Professional Certificate，最後卻被 Coursera 的 Verify ID 擋了下來——用身分證驗證時名字一直出錯、一直失敗。原因是 Coursera 是國外系統，辨識中文姓名時偶有誤判。後來改用護照（英文姓名）就順利通過。這篇把踩雷過程與解法整理給同樣卡關的你。",
      "image": "https://bobochen.dev/_astro/cover.DLVehZWm.webp",
      "date_published": "2026-06-07T00:00:00.000Z",
      "tags": [
        "資安",
        "Coursera",
        "證照",
        "Google Cybersecurity Certificate"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-team-adoption/",
      "url": "https://bobochen.dev/blog/agentic-engineering-team-adoption/",
      "title": "把 Agentic Engineering 帶進團隊：從一個人的實驗到整個 team 的文化轉變",
      "content_text": "你自己用 agent 很爽，但怎麼讓整個 team 跟上？從 solo practitioner 到 team evangelist 的過程——怎麼挑第一個 pilot project、怎麼處理「AI 會取代我嗎」的焦慮、怎麼建立共享的 agent 規範。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第十三篇。上一篇：[Agent 安全網設計](/blog/agentic-engineering-testing-safety)\n\n## 「你能不能幫全 Team 導入？」——我以為這是好事\n\n我在公司用了六個月 agent coding，效率翻倍，bug rate 沒有上升。主管注意到了，問我：「你能不能幫全 team 都導入這套工作流？」\n\n我以為這是好事。以為只要開個 workshop、分享我的 CLAUDE.md、讓大家裝好 Claude Code，就搞定了。\n\n結果第一週，10 個人裡有 3 個完全沒碰。2 個試了一次就放棄（「它改出來的 code 不是我想要的」）。3 個有在用但每次都來問我很基礎的問題。只有 2 個真的上手了。\n\n技術從來不是問題，人才是。\n\n讓一個人用 agent 很簡單，你只需要說服自己。讓十個人都開始用，你得同時處理恐懼、習慣、標準、流程，有時候還有辦公室政治。\n\n## 為什麼 Adoption 這麼難\n\n### 不同角色的阻力來源\n\n| 角色          | 最擔心什麼                             | 表面行為                                      |\n| ------------- | -------------------------------------- | --------------------------------------------- |\n| **Junior**    | 「我還沒學會基礎，agent 就要取代我了」 | 不敢用，怕被發現自己不懂                      |\n| **Mid-level** | 「我的 coding 速度是我的價值」         | 用了但不信任，手動 rewrite agent 的 code      |\n| **Senior**    | 「我十年的經驗能被 AI 複製？」         | 聲稱不需要，或找到一個 agent 失敗的案例後拒絕 |\n| **Manager**   | 「怎麼衡量？會不會引入新風險？」       | 口頭支持但不給時間和預算                      |\n\n注意這些都不是「技術問題」，每一個都是心理層面和身份認同層面的挑戰。\n\n**Junior 的焦慮**：「如果我都讓 agent 寫，我什麼時候才能自己學會？」這是合理的擔心。你可以這樣回：agent 不是取代你的練習，而是你的 [pair programming partner](/blog/agentic-engineering-mindset-shift)。你寫 test、review code、做設計決策，這些才是真正建立能力的活動。Agent 幫你省掉的是 boilerplate，不是思考。\n\n**Mid-level 的焦慮**：「我的價值就是寫 code 寫得快寫得好，agent 做得比我快怎麼辦？」這時候可以說：你的價值正在從「寫 code 的速度」轉變為「判斷什麼 code 該寫」。[Post 2](/blog/agentic-engineering-mindset-shift) 聊過這個，在 new skill tree 裡，判斷力 > 打字速度。\n\n**Senior 的焦慮**：「我的 expertise 會過時嗎？」這反而是最不需要擔心的群體，senior 的 judgment、architecture sense、domain knowledge 恰恰是 agent 最缺乏的。但 senior 通常也最固執。\n\n## 挑選 Pilot Project 的標準\n\n第一印象決定一切。選錯 pilot project，可能讓導入倒退六個月。\n\n### 好的 Pilot 特徵\n\n1. **需求明確**：有清楚的 spec 或 ticket，不需要大量 clarification\n2. **有測試覆蓋**：agent 可以用 test 自我驗證，降低出錯風險\n3. **成果可量化**：能具體比較 before/after（時間、code 品質、bug count）\n4. **風險低**：不是 critical path，搞砸了不會影響 sprint delivery\n5. **有代表性**：是團隊日常會做的任務類型，不是 edge case\n\n### 推薦的 Pilot 類型\n\n| Pilot 類型         | 為什麼好                                         |\n| ------------------ | ------------------------------------------------ |\n| 新的 CRUD feature  | 需求明確、pattern 成熟、容易比較                 |\n| Test coverage 補全 | 低風險、結果可量化（coverage %）、agent 非常擅長 |\n| Documentation 更新 | 零風險、立竿見影、所有人都討厭手動寫文件         |\n| 小型 refactor      | 有 test 保護、scope 有限、容易 demo before/after |\n\n### 千萬不要選的 Pilot\n\n- Legacy codebase 的大型 migration\n- 沒有 test 覆蓋的核心功能\n- 跨團隊依賴的複雜 feature\n- 需要大量 domain knowledge 的 business logic\n\n核心原則是這樣：pilot 的目標是讓團隊對 agent 建立信心，不是展示 agent 的極限能力。\n\n## 共享 CLAUDE.md 與團隊規範\n\n從個人的 CLAUDE.md 到團隊的 CLAUDE.md，需要一個規範化的過程。\n\n### 團隊 CLAUDE.md 該有什麼\n\n```markdown\n# Team CLAUDE.md\n\n## 技術棧（必填）\n\n- 框架、語言、主要 dependency\n\n## Build & Test（必填）\n\n- 指令列表\n\n## Coding Conventions（必填）\n\n- 命名規則、檔案結構、設計模式\n\n## Agent 使用規範（團隊新增）\n\n- 哪些操作需要人工確認\n- PR 上標注 AI-generated 的規則\n- Agent-generated code 的 review 標準\n```\n\n### PR 標注規範\n\n建議的做法是讓 agent 產生的 PR 在 description 裡加上標注：\n\n```markdown\n## AI Disclosure\n\n- 🤖 This PR was primarily generated by AI agent (Claude Code)\n- 👤 Human reviewed: architecture, security, business logic\n- ✅ Verification: all existing tests pass + 3 new tests added\n```\n\n這不是「揭發」，而是透明度。讓 reviewer 知道這個 PR 需要 [特別注意的 review 重點](/blog/agent-output-verification-review)（事實性檢查、hallucination patterns）。\n\n### CLAUDE.md 的版本控制\n\n團隊的 CLAUDE.md 應該跟 code 一樣被 version control：\n\n- 修改需要 PR review\n- 重大改動需要 team 討論\n- 每個月 review 一次是否需要更新\n\n## 處理「AI 會取代我嗎」的焦慮\n\n這不是理性問題。你不能用「根據統計，AI 導入後公司裁員率沒有上升」來說服一個害怕失業的人。\n\n### Acknowledge → Reframe → Demonstrate\n\n**Step 1: Acknowledge**（承認恐懼是合理的）\n\n「我理解你的擔心。AI 的確改變了工程師的工作內容。說不擔心是假的。」\n\n不要立刻反駁。讓對方知道你理解他們的感受。\n\n**Step 2: Reframe**（重新框架問題）\n\n「Agent 不是取代你，是把你從低價值的工作中解放出來。你以前花 60% 的時間在寫 boilerplate、copy-paste、做重複的工作。Agent 做了這些，你可以把時間花在需要判斷力的事，像是架構設計、需求分析、code review。」\n\n**Step 3: Demonstrate**（用事實示範）\n\n不要用數據說服，用體驗說服。讓同事親自體驗一次「10 分鐘寫 spec → agent 自動完成」的流程。\n\n第一次體驗的 wow moment 比任何論據都有效。但前提是你要選對 demo task，找一個那位同事正在做的、痛點明確的任務來 demo，不是你精心準備的表演。\n\n### 不同層級的對話策略\n\n**對 Junior**：「Agent 是你的學習加速器。你寫 test、review code、做設計——這些才是真正的學習。Agent 幫你省掉的是打字時間。」\n\n**對 Mid-level**：「你最大的價值不是手速，是你知道什麼 code 該寫、什麼不該寫。Agent 放大了這個價值。」\n\n**對 Senior**：不要硬推。找到他們真正的痛點（通常是 code review 太多、tech debt 清不完），用那個痛點做切入。\n\n## 衡量指標：導入期該量什麼\n\n### 不要量的指標\n\n- **Code 行數 / commit 數量**——agent 很容易產生大量 code，但量 ≠ 質\n- **工時節省**——前三個月可能不降反升（學習曲線）\n\n### 建議量的指標\n\n| 指標                    | 為什麼重要                     | 怎麼量                       |\n| ----------------------- | ------------------------------ | ---------------------------- |\n| **PR review 品質**      | Agent code 是否被 review 過    | PR 有沒有 AI disclosure 標注 |\n| **Bug rate**            | 品質有沒有下降                 | Bug tickets per sprint       |\n| **Dev satisfaction**    | 團隊對新工作流的感受           | 匿名問卷（每兩週一次）       |\n| **Time-to-deploy**      | 從 ticket 到 production 的時間 | Jira / GitHub metrics        |\n| **Agent adoption rate** | 多少人在用                     | 工具使用 analytics           |\n\n前三個月不要期待效率提升，學習曲線是真的。期望值應該擺在「品質沒下降 + 大家開始養成習慣」，而不是「效率翻倍」。效率翻倍是六個月後的事。\n\n## 12 週導入計畫\n\n### Phase 1（Week 1-4）：個人探索\n\n- 每個人選一個 AI coding 工具（Claude Code / Cursor / Copilot）\n- 在自己的 task 上自由使用\n- 不設標準、不給壓力\n- 每週 15 分鐘 sharing session：分享 tips 和踩過的坑\n\n**Success criteria**：50% 的團隊成員每週至少用了一次 agent\n\n### Phase 2（Week 5-8）：Pair with Agent\n\n- 每個 sprint 至少一個 task 用 agent 完成\n- Agent-generated PR 需要額外的 reviewer tag\n- 開始建立共享的 CLAUDE.md\n- 兩週一次 retrospective：什麼有用、什麼沒用\n\n**Success criteria**：團隊共識「agent 在 X 類任務上有幫助」\n\n### Phase 3（Week 9-12）：Team Standard\n\n- 正式版 team CLAUDE.md commit 進 repo\n- Agent code review 標準定義好\n- Metrics dashboard 上線\n- 結案 retrospective：go / no-go / adjust\n\n**Success criteria**：80% 的成員在日常工作中使用 agent，bug rate 沒有上升\n\n### Go / No-Go Decision\n\nPhase 3 結束後，用數據做決策：\n\n- Bug rate 上升了？→ 調整 review 流程，回到 Phase 2\n- 大家不想用？→ 調查原因，可能是工具選擇問題或 pilot 選錯\n- 效率持平但大家有正面感受？→ Go，效率會在接下來幾個月自然提升\n\n## 向上管理：怎麼 Pitch 給主管\n\n主管在乎三件事：**ROI、Risk、Timeline。**\n\n### 不要說\n\n「AI 會讓我們快 10 倍。」\n\n這種承諾只會反噬。第一個月效率可能不升反降，主管就會質疑你的判斷。\n\n### 要說\n\n「我提議做一個 12 週的 pilot。目標是在 X 類任務上驗證 agent-assisted workflow 的效果。量化指標是 [PR 品質、bug rate、time-to-deploy]。如果 12 週後指標沒改善，我們 rollback，成本可控。」\n\n重點就是低風險 + 可量化 + 有退場機制。\n\n### Shopify 的參考案例\n\nShopify CEO Tobi Lutke 在 2025 年要求團隊的一個原則值得學習：\n\n> 在要求增加人力之前，先證明 AI 做不到這件事。\n\n這不是要「用 AI 取代人」，而是把 AI 視為團隊能力的一部分。就像你不會在提案新 feature 的時候忽略 CI/CD pipeline 的效率一樣，你也不該在規劃 capacity 的時候忽略 AI agent 的能力。\n\n## Takeaway\n\n1. **團隊導入的最大挑戰是人，不是技術**：不同角色有不同的恐懼和阻力。先 acknowledge 恐懼，再用體驗（不是數據）說服。一次好的 demo 勝過十頁 slide。\n\n2. **好的 pilot project 決定第一印象**：選需求明確、有 test 覆蓋、低風險的任務。千萬不要用 legacy migration 當 demo，那是讓整個團隊對 agent 失去信心的最快方式。\n\n3. **12 週 phased rollout 比「下週一所有人開始用」有效 100 倍**：Phase 1 個人探索、Phase 2 pair with agent、Phase 3 team standard。每個 phase 有明確的 success criteria 和 go/no-go 決策點。\n\n---\n\n_上一篇：[Agent 安全網設計](/blog/agentic-engineering-testing-safety)_\n_下一篇：[Agentic Engineering 的下一步](/blog/agentic-engineering-future-and-you)_",
      "summary": "你自己用 agent 很爽，但怎麼讓整個 team 跟上？從 solo practitioner 到 team evangelist 的過程——怎麼挑第一個 pilot project、怎麼處理「AI 會取代我嗎」的焦慮、怎麼建立共享的 agent 規範。",
      "image": "https://bobochen.dev/_astro/cover.sWYCrG_m.webp",
      "date_published": "2026-06-05T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "團隊管理",
        "AI 導入",
        "文化轉變",
        "組織"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-complete-case-study/",
      "url": "https://bobochen.dev/blog/claude-api-guide-complete-case-study/",
      "title": "完整案例：從 0 到 1 打造 AI 客服系統",
      "content_text": "本書的綜合實戰章——一個完整的 AI 客服系統，涵蓋 Messages API、Tool Use、Prompt Caching、Streaming、Multi-Agent、成本優化的所有核心概念整合。從架構設計到 Cloud Run 部署，用真實程式碼走完全程。",
      "content_html": "在結束之前，讓我們把整本書學到的東西全部用上。\n\n這一章不是玩具範例——是一個你可以真正部署的 AI 客服系統。它會用到這本書介紹過的所有核心技術：Messages API、Tool Use、Prompt Caching、Streaming、Multi-Agent 架構，以及第十三章的成本優化策略。\n\n我把這個系統叫做 **Helios**（太陽神，意味著「照亮問題」）。\n\n## 系統需求\n\nHelios 需要做這幾件事：\n\n1. **接收用戶問題**：透過 REST API 接受問題，支援 Streaming 回覆\n2. **查詢知識庫（RAG）**：在公司的知識庫中搜尋相關文件，作為回覆的依據\n3. **判斷是否需要轉人工**：某些問題（退款、投訴、技術問題）應該轉給真人處理\n4. **生成高品質回覆**：基於知識庫內容，用自然語言回覆用戶\n\n非功能性需求：\n\n- P95 延遲 < 10 秒（包含 streaming 開始前的等待）\n- 每月 API 成本 < $500（假設 1000 名日活用戶）\n- 不記錄用戶的個人資訊\n\n## 技術架構\n\n```\n用戶\n │\n ▼\nFastAPI Server (Cloud Run)\n │\n ├── 分類 Agent (Haiku) ─── 判斷問題類型\n │\n ├── 知識庫查詢工具 ─────── pgvector (Cloud SQL)\n │\n ├── 回覆 Agent (Sonnet) ── 生成回覆 + Streaming\n │\n └── 升級工具 ───────────── Zendesk API (建立工單)\n\nRedis ─── 對話歷史快取（TTL: 1 小時）\n```\n\n**技術選擇說明：**\n\n- **FastAPI**：Python 非同步框架，原生支援 async/await，搭配 Anthropic SDK 的 streaming 最方便\n- **pgvector**：PostgreSQL 的向量搜尋擴充，不需要另外建立 Pinecone/Weaviate 等獨立服務，維護成本低\n- **Redis**：對話歷史的暫存，TTL 設 1 小時，超過就重置\n- **Cloud Run**：無伺服器容器部署，只有請求時才消耗資源，成本低\n\n## 資料流設計\n\n一個用戶問題的完整旅程：\n\n```\n1. 用戶 POST /chat {\"session_id\": \"xxx\", \"message\": \"我的訂單什麼時候到？\"}\n   ↓\n2. 從 Redis 讀取對話歷史\n   ↓\n3. 分類 Agent（Haiku）判斷：\n   - 問題類型（訂單查詢 / 退款 / 技術問題 / 一般諮詢）\n   - 是否需要轉人工（高風險問題）\n   ↓\n4a. 如果需要轉人工 → 建立 Zendesk 工單 → 回覆「已為您轉接專員」\n4b. 如果不需要轉人工 → 繼續步驟 5\n   ↓\n5. 回覆 Agent（Sonnet）：\n   - 呼叫知識庫搜尋工具\n   - 根據搜尋結果生成回覆\n   - Streaming 回覆給用戶\n   ↓\n6. 把這輪對話加入 Redis（對話歷史）\n```\n\n## 環境設定\n\n```bash\n# 建立 Poetry 專案\npoetry new helios-support\ncd helios-support\n\n# 安裝依賴\npoetry add anthropic fastapi uvicorn redis psycopg2-binary pgvector\npoetry add python-dotenv pydantic\n\n# .env\nANTHROPIC_API_KEY=sk-ant-api03-xxx\nREDIS_URL=redis://localhost:6379\nDATABASE_URL=postgresql://user:pass@localhost:5432/helios\nZENDESK_SUBDOMAIN=yourcompany\nZENDESK_EMAIL=support@yourcompany.com\nZENDESK_API_TOKEN=xxx\n```\n\n## 知識庫（RAG 層）\n\n首先建立知識庫的資料存取層：\n\n```python\n# helios/knowledge_base.py\nimport psycopg2\nfrom pgvector.psycopg2 import register_vector\nimport anthropic\nimport os\nfrom dataclasses import dataclass\n\nclient = anthropic.Anthropic()\n\n@dataclass\nclass Document:\n    id: str\n    title: str\n    content: str\n    similarity: float\n\ndef embed_text(text: str) -> list[float]:\n    \"\"\"使用 Anthropic 的 embedding API 把文字轉成向量\"\"\"\n    # 注意：Anthropic 目前沒有自己的 embedding model\n    # 建議使用 OpenAI text-embedding-3-small 或 Google text-embedding-004\n    # 這裡假設你已經設定好 embedding function\n    # 使用你偏好的 embedding service\n    raise NotImplementedError(\"請替換成你的 embedding service\")\n\ndef search_knowledge_base(\n    query: str,\n    limit: int = 3,\n    similarity_threshold: float = 0.7\n) -> list[Document]:\n    \"\"\"在知識庫中搜尋相關文件\"\"\"\n    query_vector = embed_text(query)\n\n    conn = psycopg2.connect(os.environ[\"DATABASE_URL\"])\n    register_vector(conn)\n\n    try:\n        with conn.cursor() as cur:\n            cur.execute(\"\"\"\n                SELECT id, title, content,\n                       1 - (embedding <=> %s::vector) AS similarity\n                FROM knowledge_base\n                WHERE 1 - (embedding <=> %s::vector) > %s\n                ORDER BY similarity DESC\n                LIMIT %s\n            \"\"\", (query_vector, query_vector, similarity_threshold, limit))\n\n            rows = cur.fetchall()\n            return [\n                Document(id=row[0], title=row[1], content=row[2], similarity=row[3])\n                for row in rows\n            ]\n    finally:\n        conn.close()\n```\n\n## 工具定義\n\n接下來定義 Agent 可以呼叫的工具：\n\n```python\n# helios/tools.py\nimport anthropic\nimport requests\nimport json\nimport os\nfrom .knowledge_base import search_knowledge_base\n\n# 工具定義（傳給 Anthropic API 的 tools 參數）\nSUPPORT_TOOLS = [\n    {\n        \"name\": \"search_knowledge_base\",\n        \"description\": \"\"\"搜尋公司知識庫，找到跟用戶問題相關的文件。\n        在回覆任何問題之前，應該先用這個工具搜尋相關資訊。\n        如果找不到相關資訊，誠實告訴用戶你不知道，不要猜測。\"\"\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"query\": {\n                    \"type\": \"string\",\n                    \"description\": \"搜尋查詢詞，應該是描述問題的關鍵詞\"\n                }\n            },\n            \"required\": [\"query\"]\n        }\n    },\n    {\n        \"name\": \"escalate_to_human\",\n        \"description\": \"\"\"把用戶的問題升級給真人客服專員處理。\n        在以下情況應該使用這個工具：\n        - 用戶要求退款\n        - 用戶表達強烈不滿或憤怒\n        - 涉及帳號安全問題\n        - 問題超出你的知識範疇，用戶急需解決\"\"\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"reason\": {\n                    \"type\": \"string\",\n                    \"description\": \"升級原因的簡短說明（給客服專員看的，不是給用戶看的）\"\n                },\n                \"urgency\": {\n                    \"type\": \"string\",\n                    \"enum\": [\"low\", \"medium\", \"high\"],\n                    \"description\": \"緊急程度\"\n                }\n            },\n            \"required\": [\"reason\", \"urgency\"]\n        }\n    }\n]\n\ndef execute_tool(tool_name: str, tool_input: dict) -> str:\n    \"\"\"執行工具並回傳結果\"\"\"\n    if tool_name == \"search_knowledge_base\":\n        docs = search_knowledge_base(tool_input[\"query\"])\n        if not docs:\n            return \"知識庫中找不到相關資訊。\"\n\n        results = []\n        for doc in docs:\n            results.append(f\"## {doc.title}\\n{doc.content[:500]}\")\n        return \"\\n\\n\".join(results)\n\n    elif tool_name == \"escalate_to_human\":\n        ticket_id = create_zendesk_ticket(\n            reason=tool_input[\"reason\"],\n            urgency=tool_input[\"urgency\"]\n        )\n        return json.dumps({\n            \"escalated\": True,\n            \"ticket_id\": ticket_id,\n            \"message\": f\"已建立工單 #{ticket_id}，專員將在 24 小時內與您聯繫\"\n        })\n\n    return f\"ERROR: 未知工具 {tool_name}\"\n\ndef create_zendesk_ticket(reason: str, urgency: str) -> str:\n    \"\"\"建立 Zendesk 工單（簡化版）\"\"\"\n    url = f\"https://{os.environ['ZENDESK_SUBDOMAIN']}.zendesk.com/api/v2/tickets.json\"\n    auth = (\n        f\"{os.environ['ZENDESK_EMAIL']}/token\",\n        os.environ[\"ZENDESK_API_TOKEN\"]\n    )\n\n    priority_map = {\"low\": \"low\", \"medium\": \"normal\", \"high\": \"urgent\"}\n\n    response = requests.post(url, auth=auth, json={\n        \"ticket\": {\n            \"subject\": \"AI 客服升級\",\n            \"comment\": {\"body\": f\"升級原因：{reason}\"},\n            \"priority\": priority_map.get(urgency, \"normal\"),\n            \"tags\": [\"ai-escalated\"]\n        }\n    })\n\n    if response.ok:\n        return str(response.json()[\"ticket\"][\"id\"])\n    return \"UNKNOWN\"\n```\n\n## 對話管理\n\n```python\n# helios/conversation.py\nimport redis\nimport json\nimport os\nfrom datetime import timedelta\n\nredis_client = redis.from_url(os.environ[\"REDIS_URL\"])\nSESSION_TTL = timedelta(hours=1)\n\ndef get_conversation_history(session_id: str) -> list[dict]:\n    \"\"\"從 Redis 讀取對話歷史\"\"\"\n    data = redis_client.get(f\"session:{session_id}\")\n    if data:\n        return json.loads(data)\n    return []\n\ndef save_conversation_history(session_id: str, messages: list[dict]):\n    \"\"\"把對話歷史存到 Redis\"\"\"\n    redis_client.setex(\n        f\"session:{session_id}\",\n        SESSION_TTL,\n        json.dumps(messages, ensure_ascii=False)\n    )\n\ndef build_messages_with_cache(\n    history: list[dict],\n    new_message: str\n) -> list[dict]:\n    \"\"\"建立帶有 Prompt Caching 標記的訊息列表\"\"\"\n    messages = history.copy()\n\n    # 如果歷史超過 6 輪，截斷最舊的\n    if len(messages) > 12:  # 6 輪 = 12 條訊息\n        messages = messages[-12:]\n\n    # 對倒數第二條訊息（最新的 assistant 訊息）加快取標記\n    for i in range(len(messages) - 1, -1, -1):\n        if messages[i][\"role\"] == \"assistant\":\n            msg = messages[i].copy()\n            content = msg[\"content\"]\n            if isinstance(content, str):\n                msg[\"content\"] = [\n                    {\n                        \"type\": \"text\",\n                        \"text\": content,\n                        \"cache_control\": {\"type\": \"ephemeral\"}\n                    }\n                ]\n            messages[i] = msg\n            break\n\n    messages.append({\"role\": \"user\", \"content\": new_message})\n    return messages\n```\n\n## 核心 Agent 邏輯\n\n```python\n# helios/agent.py\nimport anthropic\nfrom typing import AsyncIterator\nfrom .tools import SUPPORT_TOOLS, execute_tool\nfrom .conversation import (\n    get_conversation_history,\n    save_conversation_history,\n    build_messages_with_cache\n)\n\nclient = anthropic.Anthropic()\n\n# 大型知識庫摘要（幾千 tokens，固定內容，適合快取）\nCOMPANY_CONTEXT = \"\"\"\n你是 Helios 電商平台的 AI 客服助手。\n\n關於我們的服務：\n- 商品交貨時間：標準配送 3-5 個工作天，快速配送 1-2 個工作天\n- 退貨政策：收到商品 7 天內可申請退貨，商品需保持原狀\n- 退款時間：申請通過後 5-10 個工作天退款到原付款方式\n- 客服時間：週一到週五 9:00-18:00\n\n常見問題處理準則：\n[更多公司政策內容...]\n\"\"\"\n\nSYSTEM_PROMPT_BASE = \"\"\"你是 Helios 電商平台的 AI 客服助手，名字叫 Helios。\n\n你的職責：\n1. 用友善、專業的態度回應用戶問題\n2. 在回覆前，用 search_knowledge_base 工具查詢相關資訊\n3. 只根據知識庫中的資訊回覆，不要猜測或編造\n4. 遇到退款、投訴、帳號安全問題，主動使用 escalate_to_human 工具\n\n回覆風格：\n- 簡潔清晰，避免廢話\n- 中文回覆，但保留必要的技術術語\n- 如果不知道答案，誠實說明並提供升級選項\n\"\"\"\n\nasync def stream_support_response(\n    session_id: str,\n    user_message: str\n) -> AsyncIterator[str]:\n    \"\"\"\n    處理用戶問題，Streaming 回覆。\n    這個 generator 會 yield 回覆的文字片段。\n    \"\"\"\n    # 讀取對話歷史\n    history = get_conversation_history(session_id)\n    messages = build_messages_with_cache(history, user_message)\n\n    # Agent 的 agentic loop\n    current_messages = messages.copy()\n    final_response_parts = []\n    assistant_message_content = []\n\n    while True:\n        # 呼叫 API（使用 streaming）\n        with client.messages.stream(\n            model=\"claude-3-5-sonnet-20241022\",\n            max_tokens=1024,\n            system=[\n                {\n                    \"type\": \"text\",\n                    \"text\": SYSTEM_PROMPT_BASE,\n                },\n                {\n                    \"type\": \"text\",\n                    \"text\": COMPANY_CONTEXT,\n                    \"cache_control\": {\"type\": \"ephemeral\"}  # 快取公司資訊\n                }\n            ],\n            tools=SUPPORT_TOOLS,\n            messages=current_messages,\n        ) as stream:\n\n            current_tool_calls = []\n            current_text = \"\"\n\n            for event in stream:\n                if hasattr(event, 'type'):\n                    if event.type == 'content_block_start':\n                        if hasattr(event.content_block, 'type'):\n                            if event.content_block.type == 'text':\n                                pass  # 準備收文字\n                            elif event.content_block.type == 'tool_use':\n                                current_tool_calls.append({\n                                    \"id\": event.content_block.id,\n                                    \"name\": event.content_block.name,\n                                    \"input\": \"\"\n                                })\n\n                    elif event.type == 'content_block_delta':\n                        if hasattr(event.delta, 'text'):\n                            # 文字片段：直接 yield 給用戶\n                            text_chunk = event.delta.text\n                            current_text += text_chunk\n                            final_response_parts.append(text_chunk)\n                            yield text_chunk\n\n                        elif hasattr(event.delta, 'partial_json'):\n                            # 工具呼叫的參數（累積）\n                            if current_tool_calls:\n                                current_tool_calls[-1][\"input\"] += event.delta.partial_json\n\n            # 取得完整的 response\n            final_response = stream.get_final_message()\n\n        # 檢查 stop_reason\n        if final_response.stop_reason == \"end_turn\":\n            # 完成！把這輪對話加入歷史\n            assistant_content = []\n            if current_text:\n                assistant_content.append({\"type\": \"text\", \"text\": current_text})\n\n            current_messages.append({\n                \"role\": \"assistant\",\n                \"content\": assistant_content if assistant_content else \"好的。\"\n            })\n\n            # 儲存更新後的對話歷史\n            save_conversation_history(session_id, current_messages)\n            return\n\n        elif final_response.stop_reason == \"tool_use\":\n            # 需要執行工具\n            tool_results = []\n\n            for content_block in final_response.content:\n                if content_block.type == \"tool_use\":\n                    tool_name = content_block.name\n                    tool_input = content_block.input\n\n                    # 執行工具\n                    result = execute_tool(tool_name, tool_input)\n\n                    tool_results.append({\n                        \"type\": \"tool_result\",\n                        \"tool_use_id\": content_block.id,\n                        \"content\": result\n                    })\n\n                    # 如果是升級工具，向用戶發送通知\n                    if tool_name == \"escalate_to_human\":\n                        import json\n                        result_data = json.loads(result)\n                        yield f\"\\n\\n我已經為您建立工單（#{result_data['ticket_id']}），我們的專員將盡快與您聯繫。\"\n\n            # 把工具呼叫和結果加入訊息\n            current_messages.append({\n                \"role\": \"assistant\",\n                \"content\": final_response.content\n            })\n            current_messages.append({\n                \"role\": \"user\",\n                \"content\": tool_results\n            })\n            # 繼續 loop，讓 agent 根據工具結果繼續生成回覆\n\n        else:\n            # max_tokens 或其他停止原因\n            yield \"\\n\\n（回覆已截斷，請繼續詢問）\"\n            return\n```\n\n## FastAPI Server\n\n```python\n# helios/main.py\nfrom fastapi import FastAPI, HTTPException\nfrom fastapi.responses import StreamingResponse\nfrom pydantic import BaseModel\nimport uvicorn\nimport logging\n\nfrom .agent import stream_support_response\n\napp = FastAPI(title=\"Helios AI Support\")\nlogger = logging.getLogger(__name__)\n\nclass ChatRequest(BaseModel):\n    session_id: str\n    message: str\n\n    class Config:\n        # 限制輸入長度，防止超長輸入\n        max_anystr_length = 2000\n\n@app.post(\"/chat\")\nasync def chat(request: ChatRequest):\n    \"\"\"接受用戶問題，返回 Streaming 回覆\"\"\"\n\n    # 基本輸入驗證\n    if len(request.message.strip()) < 2:\n        raise HTTPException(status_code=400, detail=\"問題太短，請重新輸入\")\n\n    if len(request.session_id) > 100:\n        raise HTTPException(status_code=400, detail=\"無效的 session ID\")\n\n    async def generate():\n        try:\n            async for chunk in stream_support_response(\n                session_id=request.session_id,\n                user_message=request.message\n            ):\n                # SSE 格式\n                yield f\"data: {chunk}\\n\\n\"\n            yield \"data: [DONE]\\n\\n\"\n\n        except Exception as e:\n            logger.exception(f\"Error in chat stream: {e}\")\n            yield f\"data: 抱歉，發生了技術問題，請稍後再試。\\n\\n\"\n            yield \"data: [DONE]\\n\\n\"\n\n    return StreamingResponse(\n        generate(),\n        media_type=\"text/event-stream\",\n        headers={\n            \"Cache-Control\": \"no-cache\",\n            \"X-Accel-Buffering\": \"no\",  # 禁用 Nginx 緩衝，確保 streaming 正常\n        }\n    )\n\n@app.get(\"/health\")\nasync def health_check():\n    \"\"\"健康檢查 endpoint\"\"\"\n    return {\"status\": \"ok\"}\n\nif __name__ == \"__main__\":\n    uvicorn.run(app, host=\"0.0.0.0\", port=8080)\n```\n\n## 成本追蹤整合\n\n把第十三章的成本追蹤整合進來：\n\n```python\n# helios/metrics.py\nimport time\nimport logging\nfrom dataclasses import dataclass\n\nlogger = logging.getLogger(__name__)\n\n@dataclass\nclass RequestMetrics:\n    session_id: str\n    model: str\n    input_tokens: int = 0\n    output_tokens: int = 0\n    cache_read_tokens: int = 0\n    cache_creation_tokens: int = 0\n    tool_calls: int = 0\n    escalated: bool = False\n    latency_ms: float = 0.0\n\n    def log(self):\n        # 計算成本\n        input_cost = self.input_tokens * 3.0 / 1_000_000\n        output_cost = self.output_tokens * 15.0 / 1_000_000\n        cache_read_cost = self.cache_read_tokens * 0.30 / 1_000_000\n        total_cost = input_cost + output_cost + cache_read_cost\n\n        cache_hit_rate = (\n            self.cache_read_tokens / max(self.input_tokens, 1) * 100\n        )\n\n        logger.info(\"request_completed\", extra={\n            \"session_id\": self.session_id[:8] + \"...\",  # 截斷，避免洩漏\n            \"model\": self.model,\n            \"input_tokens\": self.input_tokens,\n            \"output_tokens\": self.output_tokens,\n            \"cache_read_tokens\": self.cache_read_tokens,\n            \"cache_hit_rate_pct\": round(cache_hit_rate, 1),\n            \"tool_calls\": self.tool_calls,\n            \"escalated\": self.escalated,\n            \"latency_ms\": round(self.latency_ms),\n            \"estimated_cost_usd\": round(total_cost, 6),\n        })\n```\n\n## 部署到 Cloud Run\n\n**Dockerfile：**\n\n```dockerfile\nFROM python:3.12-slim\n\nWORKDIR /app\n\nCOPY pyproject.toml poetry.lock ./\nRUN pip install poetry && poetry install --no-dev\n\nCOPY helios/ ./helios/\n\nEXPOSE 8080\nCMD [\"poetry\", \"run\", \"uvicorn\", \"helios.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8080\"]\n```\n\n**部署指令：**\n\n```bash\n# Build 和 Push 到 Google Container Registry\ngcloud builds submit --tag gcr.io/YOUR_PROJECT/helios-support\n\n# 部署到 Cloud Run\ngcloud run deploy helios-support \\\n  --image gcr.io/YOUR_PROJECT/helios-support \\\n  --platform managed \\\n  --region asia-east1 \\\n  --memory 512Mi \\\n  --concurrency 80 \\\n  --set-secrets \"ANTHROPIC_API_KEY=anthropic-api-key:latest\" \\\n  --set-env-vars \"REDIS_URL=redis://10.x.x.x:6379\" \\\n  --set-env-vars \"DATABASE_URL=postgresql://user:pass@10.x.x.x/helios\" \\\n  --allow-unauthenticated\n```\n\n幾個 Cloud Run 的注意事項：\n\n`--concurrency 80` 代表每個 Cloud Run instance 同時處理 80 個請求。AI streaming 請求會長時間佔用連線，這個值可以根據你的實際情況調整。\n\nSecrets（API Key、資料庫密碼）用 Google Secret Manager 管理，透過 `--set-secrets` 注入，不要放在環境變數裡直接傳。\n\n## 成本估算\n\n這個系統在 1000 名日活用戶、每人平均 5 個問題的情況下：\n\n每天請求數：5,000\n每次請求的 token 估算：\n\n- Input（含 COMPANY_CONTEXT）：~3,000 tokens，其中 ~2,500 可以快取\n- Output：~300 tokens\n- 快取命中後，有效輸入成本：(500 × $3 + 2500 × $0.30) / 1,000,000 = $0.00225 / 請求\n\n每天成本：5,000 × ($0.00225 + 300 × $15/1,000,000) = $11.25 + $22.50 = **$33.75/天**\n\n每月成本：**~$1,000/月**\n\n這比第十三章的目標 $500/月 高一點。要進一步降低，可以：\n\n1. 把分類用的 Haiku 做 routing，簡單問題完全用 Haiku 回答（降至 ~$600）\n2. 提高快取命中率（確保 5 分鐘內有足夠的請求維持快取熱度）\n\n## 這本書的旅程\n\n你剛讀完了「Claude API & Agent SDK 完全指南」的最後一章。\n\n我想用幾段話回顧一下我們走過的路程。\n\n這本書是 Claude 三部曲的第三本。第一本（Claude 使用指南）告訴你怎麼跟 Claude 對話。第二本（Claude Code 完全指南）告訴你怎麼讓 AI 幫你寫程式碼。這本書——第三本——告訴你怎麼**用 Claude 建造東西**：真正的應用、真正的系統、真正可以服務用戶的產品。\n\n從第一章的第一個 `client.messages.create()` 呼叫，到這一章的多 agent、streaming、RAG 整合系統——中間走了很長的路。\n\n如果你真的把這本書的範例都跑過了，你現在應該能做這些事：\n\n- 用 Messages API 建立任何對話型應用\n- 用 Tool Use 讓 AI 操控外部系統\n- 用 Prompt Caching 把成本降到最低\n- 用 Streaming 讓用戶體驗更流暢\n- 用 Agent SDK 建立多 agent 協作系統\n- 開發自己的 MCP Server\n- 把這一切部署到生產環境，並且知道怎麼監控和維護\n\n**接下來呢？**\n\nAI 應用開發這個領域正在高速演進。Anthropic 每隔幾個月就會推出新功能——更好的模型、更低的成本、更強的 agent 能力。我的建議是：\n\n**建立一個真實的東西**。不要停在讀書和做練習。找一個你真正有需求的問題，用你在這本書學到的技術解決它。你在解決真實問題的過程中學到的東西，遠比讀任何書都多。\n\n**關注官方文件**。Anthropic 的 [docs.anthropic.com](https://docs.anthropic.com) 是最可靠的資訊來源，這本書的範例和定價資訊可能因版本更新而改變，但官方文件永遠是最新的。\n\n**加入社群**。Anthropic 的 Discord、各種 AI 開發者社群——這些地方有很多正在解決和你類似問題的開發者。\n\n最後，我想說：\n\n我花了很多時間在這三本書裡，試著把「如何真正用好 Claude」說清楚——不是表面的功能介紹，而是讓你能真正建造東西的深度知識。寫作的過程其實也是我重新整理自己理解的過程。\n\n如果這本書對你有幫助，最好的感謝方式，就是去建造一個真實的東西。\n\n祝你的 AI 應用上線順利。\n\n—— Bobo",
      "summary": "本書的綜合實戰章——一個完整的 AI 客服系統，涵蓋 Messages API、Tool Use、Prompt Caching、Streaming、Multi-Agent、成本優化的所有核心概念整合。從架構設計到 Cloud Run 部署，用真實程式碼走完全程。",
      "image": "https://bobochen.dev/_astro/cover.BBK6Nb2w.webp",
      "date_published": "2026-06-05T00:00:00.000Z",
      "tags": [
        "Claude API",
        "完整案例",
        "AI 客服",
        "系統設計"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-leading-small-ai-team/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-leading-small-ai-team/",
      "title": "帶領一支 3–8 人的 AI 工程小隊：成功的關鍵不是追最新框架",
      "content_text": "一支小型 AI 工程團隊的成敗，不在於用了多潮的 framework，而在於有沒有建立一個可重複的 delivery loop。談 architecture/code/prompt/eval review 怎麼做、怎麼把 AI agent 的能力翻譯成 business stakeholder 聽得懂的 workflow impact 和 ROI、以及怎麼讓團隊在一個變化極快的領域裡持續交付。系列完結篇。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 12 篇，也是**完結篇**（共 12 篇）。上一篇：[Agent 治理框架](/blog/enterprise-ai-agent-governance-framework)。\n\n前面十一篇都在講系統。最後一篇回到人。因為「把上面那整套東西做出來、而且持續維護下去」，從來不是一個人的事，是一支團隊的事。如果你要帶一支 3–8 人的 AI 工程小隊，這篇是我認為最重要的幾件事。\n\n先講一個反直覺的結論：\n\n> 在一個變化快到每週都有新 framework 的領域裡，小團隊成功的關鍵，**恰恰不是**追最新的框架，而是建立一個**可重複的 delivery loop**。\n\n## 為什麼「追新」是小團隊的陷阱\n\nAI 這個領域，每週都有新模型、新 framework、新「這個會改變一切」的工具。對一個小團隊，這是甜蜜的毒藥：\n\n- 每換一次 framework，前面累積的 know-how 和工具就打掉一些。\n- 團隊的精力被「學新東西」吃掉，而不是「交付價值」。\n- 你永遠在起跑線上，永遠沒有把一個東西做到 production-grade。\n\n給個我自己用的粗略門檻：一個新 framework 要值得你打掉重練，它得能把你某個 delivery loop 環節的工作量砍掉一半以上、而且維護不用你自己扛——只是「換個寫法更優雅」不夠。LangChain 從 0.0.x 一路改到 1.0，多少團隊的 chain 寫法跟著翻修了好幾輪；同一段時間，「黃金題庫過了才能上」這條紀律一行都沒變。\n\n我這一年大量用 agentic 的方式工作，最深的體會是：**工具會一直換，但把一個 agent 從 demo 推到能信任的那套工程紀律（這整個系列講的東西）是不太變的**。一個好的 AI 工程 leader，要幫團隊**守住不變的那部分**——RAG 的品質、eval 的紀律、權限的嚴謹、可觀測性——而不是追著每個新玩具跑。框架是手段，能不能穩定交付可信任的系統才是目的。\n\n2026 回頭看更明顯：這一年真正沉澱下來、跨工具通用的，是**介面和紀律**，不是某個 framework。MCP 變成大家接 tool 的共同語言（也是為什麼我會去寫 MCP server，第 6 篇）、eval 從「加分項」變成 CI 裡的固定關卡、tracing 從各家自掃門前雪到 OpenTelemetry 的 GenAI conventions 開始有共通標準。這些你學一次、換不換 framework 都帶得走；某個「這個月最潮」的 agent SDK，你學了三個月可能就沒人維護了。\n\n## 建立可重複的 Delivery Loop\n\n「可重複」是關鍵字。一個健康的 AI 工程團隊，應該有一套**每個專案都照著走的循環**，而不是每次都重新發明：\n\n```\n需求 → 設計（架構/安全 review）→ 建黃金題庫 → 實作\n  → eval 跑回歸 → 上線 → 觀測 → 用線上訊號回頭補題庫 → …\n```\n\n這個 loop 把前面各篇變成團隊的肌肉記憶：第 2 篇的架構、第 5/11 篇的權限與治理、第 9 篇的 eval 與觀測，都不是某個人臨時想起來才做，而是**流程裡的固定關卡**。新人加入，照著 loop 走就不會漏掉關鍵的工程紀律。這比任何 framework 都值錢。\n\n## 四種 Review，缺一不可\n\n傳統團隊有 code review。AI 工程團隊需要的 review 更多，因為「能出錯的地方」更多：\n\n- **Architecture review**：新功能上之前，先過一次架構——這個 agent 的權限邊界對不對？工具會不會給太多？走 RAG 還是 fine-tune？放哪個三角的角（第 10 篇）？在還沒寫 code 前就把方向定對，比寫完再改便宜太多。\n- **Code review**：照舊。AI 寫的 code 也是 code，一樣要審。\n- **Prompt review**：prompt 是這個系統的「邏輯」，改一句可能行為大變。它該像 code 一樣被版本控制、被 review——具體一點：prompt 進 git、改動走 PR，而且 PR 裡**附上這次的 eval diff**（不是「我覺得這版更好」，是「通過率從 87% 到 91%，但這三題退步了」）。沒有 eval 數字的 prompt PR，跟沒有測試的 code PR 一樣不該過。\n- **Eval review**：最容易被忽略、卻最重要的一種。黃金題庫（第 9 篇）夠不夠涵蓋？通過率掉了大家有沒有當一回事？**團隊要建立一個文化：eval 沒過，跟測試沒過一樣，不能上**。\n\n把這四種 review 變成習慣，團隊產出的就不是「跑得動的 demo」，是「敢上 production 的系統」。\n\n## 最被低估的能力：把 AI 翻譯成業務語言\n\n這是 technical lead 和純 IC 最大的差別，也是 JD 上「跟 business stakeholder 溝通」真正的意思。\n\n業務主管不在乎你用了 multi-agent 還是 RAG、reranking 調了什麼參數。他們在乎的是：\n\n- 這東西能幫我們**省多少時間 / 多少人力**？（workflow impact）\n- 它出錯的**風險**有多大、你怎麼控制？（risk control）\n- 投入這些工程，**回報**是什麼？（ROI）\n\n一個只會講技術的 AI 團隊，很難拿到資源和信任。你要能把第 11 篇的治理講成「我們怎麼確保它不會洩漏客戶資料」，把第 9 篇的 eval 講成「我們怎麼知道它的品質有沒有掉」，把第 10 篇的成本講成「每個月大概這個數，而它省下的人力是這個數」。\n\n**把工程語言翻譯成業務價值，是讓 AI 專案活下去的氧氣。** 做不到這件事的團隊，做得再好也常常拿不到下一輪資源。\n\n舉個翻譯的範例感受一下落差。技術版：「我們把 reranking 換成 cross-encoder，top-5 命中率上升、p95 latency 控制在兩秒內。」業務版：「客服 agent 第一次就答對的比例從六成拉到八成五，每天攔下大約三百通本來要轉真人的詢問，模型成本一個月小幾萬、省下的客服工時遠大於這個數。」——同一件事，後者才換得到下一輪預算。重點不是把數字講大，是把它放進主管的損益表裡。\n\n## 在不確定裡帶人\n\n最後，AI 工程有個特別的領導挑戰：**這個領域本身充滿不確定**。模型會被供應商偷偷改、某個做法下個月可能就過時、沒有人是「專家」因為大家都在同一條起跑線附近。\n\n其中「模型被供應商偷偷改」這件事，不只是領導氛圍問題，是 production 風險：同一個 model 名稱、同一段 prompt，供應商一次安靜的更新就可能讓行為飄掉——這正是第 1 篇講的，你有一個不能控制、也不會收到 changelog 的上游依賴。對小隊的紀律是兩條：版本能 pin 就 pin（別只寫 `latest`），以及讓黃金題庫**定期重跑**而不是只在改 code 時跑——這樣模型悄悄變了，是你的 eval 先尖叫，而不是客戶先尖叫。\n\n帶這樣的團隊，我覺得幾件事重要：\n\n- **建立心理安全感**：在一個沒有標準答案的領域，要讓團隊敢說「我不確定」、敢做實驗、敢承認某條路走不通。eval 和 observability（第 9 篇）在這裡有個額外好處——它們讓「對或錯」有**數據**可依，而不是靠誰嗓門大，這本身就降低了團隊的焦慮。\n- **知識共享是團隊資產**：一個人踩過的坑、調出來的好 prompt、學到的新工具，要有機制讓它變成團隊的，而不是鎖在某個人腦裡。寫下來、分享出來——這也是我自己一直在做的事（這整個系列就是）。\n- **postmortem 不咎責**：agent 出包了，重點是「系統哪裡讓這個錯誤溜過去了、怎麼補上防線（補進黃金題庫、加個 HITL 關卡）」，而不是「誰的 prompt 寫爛了」。判斷一場 postmortem 有沒有做對，看它的產出物：每一次線上出包，至少要長出**一條新的黃金題庫**或**一道新的關卡**——讓同一個錯誤下次過不了。沒長出防線的檢討，只是一場集體嘆氣。\n\n## 回到第 1 篇：六道鴻溝，其實是一支團隊的事\n\n這個系列從「為什麼企業 AI Agent 卡在 PoC」開始，講了六道鴻溝：正確性、eval、可觀測性、權限治理、成本延遲、fallback。\n\n走到最後一篇你會發現，**過這六道鴻溝，從來不是一個技術問題，是一個團隊能不能建立起對應紀律的問題**。\n\n- 正確性與權限 → 是不是有 architecture / eval review 把關？\n- Eval 與可觀測性 → 是不是有「沒過不能上」的文化？\n- 成本與治理 → 是不是有人能把它翻譯成業務聽得懂的價值，換到持續投入的資源？\n\n技術會給你過河的工具，但真正把一個企業 AI agent 從 demo 推到 production、而且**持續維護下去**的，是一支有紀律、能溝通、在不確定裡還能穩定交付的團隊——以及帶著他們的那個人。\n\n如果你正在或即將做這件事，希望這 12 篇，能幫你把「能 demo」和「能信任」之間那段最難的路，走得踏實一點。\n\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"帶領一支 3–8 人的 AI 工程小隊\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[Agent 治理框架](/blog/enterprise-ai-agent-governance-framework)\n- 系列起點：[為什麼企業 AI Agent 卡在 PoC？六道鴻溝](/blog/enterprise-ai-agent-poc-to-production-gap)\n- [Agentic Engineering 實戰手冊](/blog/agentic-engineering-what-is-it)——換個角度：用 agent 來做工程，而不是打造 agent 系統\n- [Agentic Engineering：團隊導入](/blog/agentic-engineering-team-adoption)——把 agent 工作流導入一支團隊的實戰",
      "summary": "一支小型 AI 工程團隊的成敗，不在於用了多潮的 framework，而在於有沒有建立一個可重複的 delivery loop。談 architecture/code/prompt/eval review 怎麼做、怎麼把 AI agent 的能力翻譯成 business stakeholder 聽得懂的 workflow impact 和 ROI、以及怎麼讓團隊在一個變化極快的領域裡持續交付。系列完結篇。",
      "image": "https://bobochen.dev/_astro/cover.DLS1_LLx.webp",
      "date_published": "2026-06-04T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "技術領導",
        "團隊管理",
        "AI 工程",
        "職涯",
        "跨部門溝通"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/tailscale-spof-self-hosted-alternatives-headscale-netbird-nebula/",
      "url": "https://bobochen.dev/blog/tailscale-spof-self-hosted-alternatives-headscale-netbird-nebula/",
      "title": "Tailscale 好用到讓我焦慮：它會變成我的 SPOF 嗎？5 種備援方案全解",
      "content_text": "Tailscale 好用到不真實，但把整張網路綁在一個閉源控制面上，總讓人半夜睡不著。先講個反直覺的真相：你怕的那種 SPOF 其實不太會發生。從「其實不用搬」到 headscale、NetBird、Nebula、純 WireGuard，5 種備援方案一次說清楚怎麼選。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\nimport figureA from './figure-a-control-vs-data-plane.svg?url';\nimport figureB from './figure-b-solution-map.svg?url';\nimport figDevices from './images/tailscale-admin-devices.webp?url';\nimport figStatus from './images/tailscale-status-cli.webp?url';\n\n事情是這樣開始的。\n\n我把家裡的 NAS、雲端的兩台 VPS、工作的 Mac、口袋裡的手機，全部接上了 Tailscale。一行 `tailscale up`，從此不管人在咖啡廳還是高鐵上，連回家裡的服務就像在同一個區網一樣順。NAT 穿透、金鑰交換、防火牆規則，全都不用管。它好用到一種……有點不真實的程度。\n\n{/* TODO 截圖：Tailscale 後台裝置列表（NAS / 2 台 VPS / Mac / 手機都 connected）。截好後直接覆蓋 images/tailscale-admin-devices.webp，不用改這裡。記得打碼 hostname 與帳號 email。 */}\n<figure>\n  <img src={figDevices} alt=\"Tailscale 管理後台的裝置列表，顯示家裡的 NAS、雲端兩台 VPS、工作用 Mac 與手機全部都已連上同一張 Tailnet。\" style=\"max-width:100%;height:auto;display:block;\" />\n  <figcaption>我的 Tailnet 裝置列表：NAS、兩台 VPS、Mac、手機，全接在同一張網路上。</figcaption>\n</figure>\n\n{/* TODO 截圖：`tailscale status` 終端輸出，看得到 100.x.x.x 那串 IP 與 direct/relay 欄位。截好後直接覆蓋 images/tailscale-status-cli.webp，不用改這裡。記得打碼公網 IP。 */}\n<figure>\n  <img src={figStatus} alt=\"終端機執行 tailscale status 的輸出，每一台設備前面都掛著一個 100.x.x.x 開頭的 Tailscale 內網 IP，後面標著 direct 或 relay 連線狀態。\" style=\"max-width:100%;height:auto;display:block;\" />\n  <figcaption><code>tailscale status</code> 的輸出——就是這串 <code>100.x.x.x</code>，半夜讓我盯著發呆的元兇。</figcaption>\n</figure>\n\n然後某個半夜，我盯著那串 `100.x.x.x` 的 IP，腦中冒出一個念頭：\n\n**如果有一天 Tailscale 倒了呢？**\n\n被收購、改價、伺服器掛掉、甚至哪天判定我違反條款把帳號停掉——我這整張網路，是不是就跟著一起癱瘓？我是不是親手把所有東西，綁在一個自己完全控制不了的單點上？\n\n這就是工程師的職業病：**SPOF（Single Point of Failure，單點故障）焦慮**。\n\n這篇文章想做兩件事：第一，戳破一個誤解——你怕的那種 SPOF，其實沒你想的嚴重。第二，如果你還是想要主權（這才是真正該擔心的事），那 headscale、NetBird、Nebula、純 WireGuard 這幾條備援方案(或是你要稱作退路也可XD)，到底該怎麼選。\n\n---\n\n## 先講個反直覺的真相：Tailscale 掛掉，你不會斷線\n\n要談 SPOF，得先搞懂 Tailscale 到底是怎麼運作的。它其實是**兩個分開的平面**：\n\n- **控制面（control plane）**：那台 Tailscale 跑的「協調伺服器」（coordination server）。它負責記住誰是誰、交換公鑰、發 ACL 政策、管 MagicDNS。這部分是**閉源的**，跑在 Tailscale 的雲上。\n- **資料面（data plane）**：你的設備之間真正的流量。這是純 [WireGuard](https://www.wireguard.com/) 的點對點（P2P）加密通道，**完全不經過 Tailscale 的伺服器**。\n\n關鍵在這裡：協調伺服器只是個「電話簿 + 介紹人」。它幫你的設備互相認識、交換鑰匙，但一旦兩台機器牽上線，後面的封包就直接 P2P 飛來飛去，跟它沒關係了。\n\n所以 Tailscale 官方文件講得很直白——[如果協調伺服器掛掉會怎樣？](https://tailscale.com/kb/1091/what-happens-if-the-coordination-server-is-down/)答案是：**你的網路大致照常運作**。設備的金鑰都存在本地，現有連線會一直維持，直到金鑰過期為止。\n\n那 NAT 穿不過去、需要中繼的時候呢？Tailscale 有一套叫 [DERP](https://tailscale.com/kb/1232/derp-servers) 的中繼伺服器。但連 DERP 都很聰明：客戶端會把 DERP 清單**快取在本地**，就算協調伺服器掛了，只要 DERP 還活著就能用。而且 DERP 只是個「笨水管」——它轉送的是已經被 WireGuard 加密過的封包，它**看不到也解不開**你的內容。\n\n<figure>\n  <img src={figureA} alt=\"Tailscale 控制面與資料面分離架構圖：協調伺服器只做管理介紹，WireGuard P2P 資料面直接連接你的筆電與家裡 NAS。\" style=\"max-width:100%;height:auto;display:block;\" />\n  <figcaption>圖 A：控制面負責交換金鑰與介紹節點，資料面是設備之間的 WireGuard P2P 加密隧道；協調伺服器掛掉時，既有資料連線仍可繼續運作。</figcaption>\n</figure>\n\n把「協調伺服器掛掉」這件事拆開來看，你會發現能用的東西比想像中多：\n\n| 協調伺服器掛掉時 | 狀態 |\n| --- | --- |\n| 現有設備之間的連線 | ✅ 照常運作（P2P / DERP 中繼） |\n| 已連上的服務（SSH、NAS、網站） | ✅ 繼續通 |\n| 加入**新設備** | ❌ 無法（需要協調伺服器發鑰匙） |\n| 金鑰到期後的續期 / 重新認證 | ❌ 會卡住 |\n| 改 ACL、改 MagicDNS、改路由 | ❌ 無法套用 |\n| Funnel / Serve 等設定變更 | ❌ 無法 |\n\n換句話說：**「Tailscale 掛掉 = 我全斷線」是個誤解。** 你不會在會議中突然連不回公司伺服器。真正會卡的，是「管理動作」——加機器、換鑰匙、改政策。短時間的故障，你甚至不會察覺。\n\n到這裡，焦慮應該先消一半了。但別急——還有另一半。\n\n---\n\n## 那真正該擔心的，到底是什麼？\n\n如果今天斷線不是問題，那我半夜那股不安到底是什麼？\n\n把它想清楚之後，我發現重點根本不是「**可用性**」，而是「**主權**」——**誰掌握我這張網路的控制權？**\n\n- **控制面是閉源的，而且不在你手上。** 你的整張網路拓樸、誰能連誰、設備清單、metadata，全都記在 Tailscale 的資料庫裡。你看不到原始碼，也搬不走那台大腦。\n- **DERP 中繼預設也是 Tailscale 跑的。** 雖然你可以自架 custom DERP，但多數人沒設，等於連備援路徑都依賴它的基礎設施。\n- **政策隨時會變，而且真的會變。** 就在 [2026 年 4 月 8 日，Tailscale 才大改過一次免費方案](https://tailscale.com/blog/pricing-v4)——免費 Personal 方案變成 6 個使用者、設備無限，但同時也調整了 tagged resources（50 個）跟 ephemeral 額度。這次是變好，但下次呢？商業公司的定價權，永遠握在它自己手上。\n- **帳號層級的風險。** 收購、倒閉、服務下架、甚至誤判把你停權——這些都不是技術故障，是**商業與信任層級**的單點。\n\n所以結論是：\n\n> **SPOF 不是「Tailscale 今天會不會掛」的技術問題，而是「我願不願意把網路的控制權，長期外包給一家公司」的主權問題。**\n\n想通這一點，後面的選型就清楚了。你要評估的不是「哪個比較不會故障」，而是「我想拿回多少控制權，又願意為此付出多少維運成本」。這是一個天秤，不是一個排行榜。\n\n面對這個焦慮，你其實有 **5 種方案**。第 1 種最簡單——**留在 Tailscale**：你已經知道那種 SPOF 是誤解了，把省下的時間拿去做別的事。剩下 4 條是自架退路，我刻意按照「**最像 Tailscale → 最原始**」的順序排，控制權一路往上加，維運成本也一路往上加。\n\n---\n\n## 方案一：headscale —「我愛 Tailscale 的體驗，只是想自己掌握大腦」\n\n[headscale](https://github.com/juanfont/headscale) 是社群用 Go 寫的、**Tailscale 協調伺服器的開源重新實作**。它做的事情就一件：把那台閉源的「大腦」換成你自己跑的版本。\n\n最妙的地方是——**你的設備繼續用官方 Tailscale 客戶端**。Tailscale 的客戶端本來就是開源的（BSD 授權），閉源的只有控制面。所以裝上 headscale 後，`tailscale up --login-server=https://你的網域` 一接，體驗跟原版幾乎一模一樣，流量照樣 P2P。\n\n**適合誰：** 你已經愛上 Tailscale 的客戶端體驗、MagicDNS、跨平台支援，純粹只是不想把控制面交給別人。小團隊、homelab、注重「設定即程式碼」的人。\n\n**會踩到的坑：**\n\n- **沒有官方 Web UI。** 核心專案只給你一支 CLI + 設定檔。想要圖形介面得自己裝社群方案，像 [Headplane](https://github.com/tale/headplane)（功能最完整，含 OIDC SSO、web SSH）或 headscale-admin。\n- **部分新功能缺席。** Funnel（對外公開服務）、Serve（HTTPS 反向代理）、network flow logs、某些動態 ACL 更新，都還沒實作或落後官方。\n- **ACL 走設定檔。** 用 JSON/HuJSON 寫權限規則。好處是能丟進 Git 版控、code review；壞處是沒有點點點的 GUI。\n- **你得自己維運那台伺服器。** 升級、備份、HA（高可用）都是你的事。諷刺的是——你為了消除「Tailscale 這個 SPOF」，結果自己架的那台 headscale，現在變成**你自己的 SPOF**。它掛了，一樣不能加新機器、不能續鑰匙（差別是現在掛不掛由你決定，而且你能立刻去修）。\n\n一句話：headscale 是「**最小遷移成本**」的解。客戶端不用換，焦慮卻能消掉一大半。\n\n---\n\n## 方案二：NetBird —「整套都換成我自己的，還要有漂亮 UI 和 SSO」\n\n如果說 headscale 是換掉大腦，[NetBird](https://github.com/netbirdio/netbird) 就是**連身體一起換掉**。它是一整套開源（BSD-3）的 mesh VPN stack：自己的客戶端、自己的管理伺服器、自己的 signal 伺服器、自己的中繼（TURN），全部你能自架。\n\n它一樣是建立在 WireGuard 之上，靠 ICE/STUN/TURN 做 NAT 穿透，但在 Tailscale 那些「管理層」的功能上做得相當完整：**內建 Web UI** 管 ACL 跟群組、**原生 SSO**（綁定 Zitadel 或你自己的 OIDC 供應商）、setup keys、路由管理。\n\n**適合誰：** 你想要的是一個「**完整、可完全自主、給團隊用**」的 Tailscale 替代品，而且不想犧牲圖形介面跟單一登入。要求資料主權的公司、想自己掌握全部基礎設施的團隊。它是這幾個選項裡，**最接近「完整替代 Tailscale」**的那個。\n\n**會踩到的坑與亮點：**\n\n- **以前自架很麻煩**——管理、signal、relay 是分開的元件，要喬一堆。但 [從 v0.65（2026 年 2 月）開始，NetBird 推出 unified server binary](https://netbird.io/)，把管理 + signal + relay 包進**單一容器**，自架難度大幅下降。\n- 它也有**官方雲端版（含免費額度）**，你可以先用雲端體驗，之後再搬成自架，遷移路徑清楚。\n- 因為是自己一整套客戶端，遷移成本比 headscale 高一點——你得把所有設備從 Tailscale 客戶端換成 NetBird 客戶端。\n\n一句話：NetBird 是「**我要一整套，而且我全都要自己的**」的解。\n\n---\n\n## 方案三：Nebula —「我要規模、要效能，執行期不想依賴任何中心」\n\n[Nebula](https://github.com/slackhq/nebula) 是 Slack 開源（MIT）的覆蓋網路工具，設計哲學跟前面幾個明顯不同。\n\n首先，**它不是 WireGuard**。Nebula 用的是自己基於 Noise 協議框架的實作。再來，它是**憑證制**的：你建立自己的 CA（憑證頒發機構），每個節點拿一張你簽的憑證，只有共用同一個 CA 的節點才會互信。憑證裡直接寫死了節點的 IP、群組、權限。\n\n那節點之間怎麼找到對方？靠 **lighthouse（燈塔）節點**。Lighthouse 是網路裡唯一需要固定 IP 的角色，負責幫其他節點互相發現、做 UDP 打洞。但重點來了——**一旦憑證發下去、節點認得 lighthouse 之後，執行期就沒有一個「控制面」需要常駐做決策了**。整個網路是去中心化的，Slack 自己就用它撐到數萬台主機的規模。\n\n**適合誰：** 規模大、把網路當成「基礎設施即程式碼」來管、對效能跟可擴展性有要求、而且**最在意「執行期不要有任何中心依賴」**的人。\n\n**會踩到的坑：**\n\n- **設定曲線最陡。** 沒有 Web UI、沒有 SSO、沒有 ACL 的圖形編輯器（開箱即用的話）。你得手動管 CA、簽憑證、發設定檔。對習慣 `tailscale up` 一行搞定的人，這是另一個世界。\n- **加一台機器 = 簽一張憑證、配一份設定。** 自動化做得好很爽，做不好很煩。\n- 如果你想要 Nebula 的核心 + Tailscale 般的託管體驗，可以看 [Defined Networking](https://www.defined.net/)（由 Nebula 原作者創立的公司）的託管控制面——但那又把你帶回「依賴一家公司」的起點了，只是換個對象。\n\n一句話：Nebula 是「**我要極致的去中心化與規模，願意用維運複雜度去換**」的解。\n\n---\n\n## 方案四：純 WireGuard —「我不要任何魔法，全部自己來」\n\n走到最底層，就是 [WireGuard](https://www.wireguard.com/) 本身。\n\n前面 Tailscale、headscale、NetBird，本質上都是在「**自動化 WireGuard 很難的那幾件事**」：金鑰分發、NAT 穿透、IP 分配、ACL。如果你把這層自動化全部拿掉，自己手動來，那就是純 WireGuard。\n\n**你要自己處理的：** 產生每台的金鑰對、手動把每個 peer 的公鑰跟 endpoint 互相設定、規劃 IP 網段、自己想辦法穿 NAT（需要至少一端有固定 IP 或 port forwarding，或自己架一台 bounce server 當中繼）。沒有 MagicDNS、沒有自動金鑰輪替、沒有 ACL GUI。\n\n**適合誰：** 只有少數幾台**固定 IP 的伺服器**要互連、追求極簡與零依賴、不想跑任何額外控制元件的純粹主義者。想降低手動痛苦的話，[wg-easy](https://github.com/wg-easy/wg-easy) 這類工具能給你一個 hub-and-spoke 的簡單 Web UI。\n\n**會踩到的坑：**\n\n- **不會 scale。** Peer 設定是 n² 的關係，三五台還好，二十台你會想哭。\n- **NAT 穿透要自己解。** 這正是 Tailscale 最值錢的地方，純 WireGuard 不幫你。\n- **你就是那個 SPOF。** 不是某台伺服器——是「**你的維運能力**」。金鑰沒輪替、設定漂移、某台 endpoint IP 變了沒人更新……這些都是你一個人扛。\n\n一句話：純 WireGuard 是「**沒有魔法的真相**」。它讓你徹底理解上面那三個工具到底幫你省了多少事。\n\n---\n\n## 5 種方案放一起比\n\n把 Tailscale 當基準，5 種一起攤開：\n\n| | Tailscale | headscale | NetBird | Nebula | 純 WireGuard |\n| --- | --- | --- | --- | --- | --- |\n| **底層協議** | WireGuard | WireGuard | WireGuard | 自有（Noise） | WireGuard |\n| **客戶端** | 官方 | **官方（共用）** | 自有 | 自有 | 內建/wg-quick |\n| **控制面** | 閉源・託管 | 開源・自架 | 開源・自架 | 憑證制・無常駐中心 | 無 |\n| **NAT 穿透** | ✅（含 DERP） | ✅（共用機制） | ✅（STUN/TURN） | ✅（lighthouse 打洞） | ❌ 自己解 |\n| **Web UI** | ✅ | ⚠️ 社群方案 | ✅ 內建 | ❌ | ⚠️ wg-easy |\n| **SSO** | ✅ | ⚠️ 靠 Headplane | ✅ 內建 | ❌ | ❌ |\n| **ACL** | GUI + 檔案 | 設定檔（Git） | Web UI | 憑證內嵌 | 手動 |\n| **執行期依賴中心** | 是（Tailscale） | 是（你的伺服器） | 是（你的伺服器） | **否** | 否 |\n| **維運成本** | 最低 | 中 | 中 | 高 | 最高 |\n| **授權** | 客戶端開源/控制面閉源 | BSD-3 | BSD-3 | MIT | GPLv2 |\n\n<figure>\n  <img src={figureB} alt=\"Tailscale、headscale、NetBird、Nebula 與純 WireGuard 的二維定位圖，以維運成本與控制權主權比較 5 種方案。\" style=\"max-width:100%;height:auto;display:block;\" />\n  <figcaption>圖 B：把 5 種方案放到「維運成本」與「控制權／主權」兩個軸上看；留在 Tailscale 是多數人的預設解，越往右上角越需要用維運成本換主權。</figcaption>\n</figure>\n\n---\n\n## 所以，我到底該選哪個？\n\n跟我寫 [Mackup vs chezmoi](/blog/mackup-vs-chezmoi-vs-defaults-sh-comparison) 那篇的結論一樣——**沒有最好的工具，只有最適合你的工具**。對號入座：\n\n- **你只是焦慮，但其實沒有真的非自架不可** → **留在 Tailscale**。讀懂上面「協調伺服器掛掉不會斷線」那段，焦慮就該消了。把省下來的時間拿去做別的事。\n- **你愛 Tailscale 的客戶端體驗，只想把大腦拿回來，團隊不大** → **headscale**。遷移成本最低，客戶端都不用換。\n- **你要一整套完全自主、要給團隊用、要 UI 跟 SSO** → **NetBird**。最接近完整替代品，v0.65 之後自架也不痛了。\n- **你規模大、走 infra-as-code、最在意「執行期不要有任何中心」** → **Nebula**。用維運複雜度換極致的去中心化與規模。\n- **你只有幾台固定 IP 的伺服器、想要極簡零依賴** → **純 WireGuard**。沒有魔法，但也沒有任何你不懂的東西。\n\n---\n\n## 結語：降低焦慮的方法，不是現在就搬家\n\n繞了一圈，我自己的結論其實有點反高潮：**我還在用 Tailscale。**\n\n因為我終於想清楚——我半夜那股不安，不是「它今天會掛」，而是「我怕我被綁住、想走的時候走不掉」。而這個焦慮，根本不需要靠「現在立刻搬家」來解決。\n\n真正的解法是：**知道自己隨時搬得走。**\n\n最務實的策略，可能是繼續用 Tailscale 享受它的順手，但**心裡備好一條退路**——而且 headscale 跟 NetBird 的存在，讓這條退路便宜到不可思議：headscale 共用官方客戶端，遷移幾乎零成本；NetBird 有雲端版可以先試，要自架隨時自架。\n\nSPOF 焦慮的相反，從來不是「擁有一個永不故障的系統」（那不存在），而是「**擁有選擇權**」。\n\n當你知道自己被綁住的那一刻可以一行指令走人，你就不再是被綁住了。然後你就能睡個好覺，繼續享受那串好用到不真實的 `100.x.x.x`。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"Tailscale 會是我的 SPOF 嗎？\" />\n\n---\n\n### 延伸閱讀\n\n- [What happens if the coordination server is down? — Tailscale Docs](https://tailscale.com/kb/1091/what-happens-if-the-coordination-server-is-down/)\n- [How Tailscale works — Tailscale Blog](https://tailscale.com/blog/how-tailscale-works)\n- [headscale — GitHub](https://github.com/juanfont/headscale) ／ [Headplane Web UI](https://github.com/tale/headplane)\n- [NetBird — GitHub](https://github.com/netbirdio/netbird)\n- [Nebula — GitHub](https://github.com/slackhq/nebula) ／ [Defined Networking](https://www.defined.net/)\n- [WireGuard 官方網站](https://www.wireguard.com/)",
      "summary": "Tailscale 好用到不真實，但把整張網路綁在一個閉源控制面上，總讓人半夜睡不著。先講個反直覺的真相：你怕的那種 SPOF 其實不太會發生。從「其實不用搬」到 headscale、NetBird、Nebula、純 WireGuard，5 種備援方案一次說清楚怎麼選。",
      "image": "https://bobochen.dev/_astro/cover.CgKVFd7Q.webp",
      "date_published": "2026-06-04T00:00:00.000Z",
      "tags": [
        "Tailscale",
        "WireGuard",
        "NetBird",
        "Nebula",
        "headscale",
        "Self-hosted",
        "VPN",
        "網路"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/cloudflare-pages-20000-file-limit-taxmap-netlify/",
      "url": "https://bobochen.dev/blog/cloudflare-pages-20000-file-limit-taxmap-netlify/",
      "title": "Cloudflare Pages 的 20,000 檔案上限：當「一頁一檔」撞牆，我把 TaxMap 搬到 Netlify",
      "content_text": "TaxMap-TW build 出 23,331 個檔案，撞上 Cloudflare Pages Free 方案「單次部署最多 20,000 檔」的硬限制，最後改用 Netlify 5 分鐘解決。記錄為什麼會爆檔案數（一頁一檔 × 7,750 村里）、三個方案的取捨，以及換平台其實是「換天花板不是拆天花板」——選靜態部署平台別只看單檔大小，檔案數才是隱形天花板。",
      "content_html": "買了 `bobochen.dev`，興沖沖想把我做的「台灣所得地圖」TaxMap 掛上自己的網域。我用 Cloudflare 管 DNS，理所當然想直接丟 Cloudflare Pages。結果 `pnpm build` 完一看 `dist/`——**23,331 個檔案**。然後我才想起一件大家選部署平台時很容易忽略的事。\n\n## 背景：把 TaxMap 搬上自己的網域，理所當然選 Cloudflare Pages\n\n最近把主網域 `bobochen.dev` 買下來後，開始把各個 side project 掛到子網域底下。TaxMap-TW（台灣所得地圖，一個用 Astro 做的純靜態站）要放到 `taxmap.bobochen.dev`。\n\n因為我 DNS 本來就在 Cloudflare，最自然的選擇就是 Cloudflare Pages——同一個後台、免費、CDN 又快。我連 `astro.config.mjs` 的 `site` 都已經指好 `https://taxmap.bobochen.dev` 了，想說 build 完上傳就收工。\n\n## 撞牆：build 出來 23,331 個檔案\n\n部署前我習慣先看一下產物大小。一看傻眼：\n\n- `dist/` 共 **880MB**\n- **23,331 個檔案**\n\n我第一個反應是查單檔大小有沒有超標（Cloudflare Pages 單檔上限 25 MiB），結果最大的檔案才 5.7MB（`villages.pmtiles`），離 25 MiB 遠得很。容量、單檔都沒問題。\n\n但真正擋我的是另一條限制，引用官方文件：\n\n> Cloudflare Pages sites can contain up to 20,000 files（Free 方案）\n\n而我有 23,331 個。如果硬上傳，會吃到這個錯誤訊息：\n\n> `Error: Pages only supports up to 20,000 files in a deployment.`\n\n**擋我的不是 880MB，是「23,331」這個數字。** 檔案大小和檔案數量是兩個完全獨立的天花板。\n\n## 為什麼會爆檔案數？「一頁一檔」的代價\n\nTaxMap 是「每個村里一個頁面」的資料密集型靜態站。台灣大約有 **7,750 個村里**，而我為每個村里都預先生成了三樣東西：\n\n| 內容 | 檔案數 | 大小 |\n|------|-------|------|\n| 村里頁 HTML | 7,747 | 272MB |\n| 村里資料 JSON | 7,747 | 134MB |\n| 村里 OG 社群分享圖 (PNG) | 7,750 | 466MB |\n\n7,750 × 3 ≈ 23k 檔。光是給「分享你家村里所得」用的 OG 圖就 7,750 張。\n\n這就是 SSG（靜態網站生成）「一頁一檔」模式的代價：內容一多，**檔案數會線性爆炸**。地圖、字典、商品目錄這類有大量 detail 頁的網站特別容易破萬。\n\n## 我其實有三個選項\n\n查清楚後，我整理出三條路：\n\n1. **升級 Cloudflare Pages 付費方案** — Free 是 20,000 檔，但付費方案可到 **100,000 檔**（這是 2026 起的新上限，得設 `PAGES_WRANGLER_MAJOR_VERSION=4` 環境變數才會啟用），23,331 綽綽有餘。代價：要解鎖這個上限得開 Workers Paid，最低 **$5/mo**（注意不是網站那個 $20/mo 的 Pro 方案，當初我也一度被搞混）。\n2. **砍檔案數** — 不預生成那 7,750 張 OG 圖，[改用 Worker 即時產生](/blog/cloudflare-worker-on-demand-og-satori-taxmap)，檔案數降到 ~15,600 就能上 Free 版。代價：要改 OG pipeline。這個選項其實最漂亮——它不是「砍功能」，而是「換一種方式提供同樣的 OG 圖」，而且順便連未來的頻寬問題一起解了（圖只在被請求時才生成，不用整批塞進 CDN）。當下我嫌它要動工，但坦白說它是技術上最對的解。\n3. **換 Netlify** — Netlify 沒有 Cloudflare Pages 這種「單次部署檔案數」硬限制，免費版就吃得下。代價：DNS 在 Cloudflare、站台在 Netlify，變成跨平台管理。\n\n我選了 **3**。理由很務實：這是個人 hobby 專案，為了它每月多付錢、或為了遷就平台去重構 OG pipeline，當下都嫌麻煩；而那 7,750 張 OG 圖正是 TaxMap「分享你家村里」的病毒傳播核心，我不想動它。加上我另一個子網域（typelate）本來就在 Netlify，搬過去算順手。\n\n但我得先說清楚：**選 3 是「當下最快」的取捨，不是「客觀最優」。** 選項 2（改 Worker 即時生成）其實才是長期最乾淨的解，只是要動工；如果你很在意運維一致性，DNS、站台、Workers 全留在 Cloudflare 一個後台，那付那 $5/mo 留在單一平台，其實是完全合理的選擇——跨平台管理的隱性摩擦（兩套後台、兩套憑證、兩套帳單）不是零成本，只是不會出現在帳單上。\n\n## 解法：搬到 Netlify，5 分鐘搞定\n\n因為資料和 OG 圖都已經 commit 在 `public/`（`astro build` 會自動複製到 `dist/`），所以根本不用跑那些慢的資料抓取腳本，純 build 就好：\n\n```bash\npnpm build                          # 純 astro build → dist/\nnetlify deploy --prod --dir=dist    # 直接上傳預先 build 好的 dist\n```\n\n中途遇到一次 `getaddrinfo ENOTFOUND api.netlify.com`——上傳到一半網路抖了一下。但 Netlify CLI 的部署是**增量**的，重跑一次它自動跳過已上傳的檔案、只續傳剩下的（23,330 → 22,455），第二次就成功了。\n\n最後 `https://taxmap.bobochen.dev` 上線，Let's Encrypt 憑證也自動簽好。\n\n### 但 Netlify 不是「沒有天花板」，只是換了一面牆\n\n我得誠實補一句，免得你照抄踩雷：搬到 Netlify 不等於「解除限制」，比較像是「把擋路的那道牆換成另一道」。Cloudflare Pages 卡我的是檔案數，但它的**頻寬是沒有上限的**；Netlify 反過來——沒有檔案數硬限制，但**免費版頻寬是 100GB/月，超量大約每 100GB 收 $55**。而 TaxMap 那 7,750 張 OG 圖正是設計來「被瘋狂分享」的，萬一哪天真的病毒傳播，最先撞牆的反而是 Netlify 的頻寬，不是 Cloudflare。換句話說，我換掉的是「檔案數天花板」，但同時換上了一個「頻寬天花板」——對一個指望靠分享擴散的站來說，這個取捨其實很微妙。\n\n還有一個更新的時效雷：Netlify 從 **2025-09** 起改成 credit-based 計費，免費版每月有 **300 credits 的硬上限**，連 deploy 本身都會扣點，而且一個專案超量會把**整個帳號**的服務一起暫停（連坐）。我另一個子網域 typelate 跟 TaxMap 同一個 Netlify 帳號，這種連坐風險其實被我放大了。所以「免費、5 分鐘、無痛」這句話是我 2025-09 之前的體感——**你的免費額度很可能跟我不一樣，動手前自己去對一遍當下的方案頁，別照抄我的數字。**\n\n## 反思\n\n選靜態網站部署平台，大家通常只盯著三個數字看：單檔大小、總容量、頻寬。這次提醒我還有第四個——檔案數上限——而它最容易被忽略。我要先界定清楚：檔案數對 TaxMap 這種「一頁一檔、上萬個 detail 頁」的[資料密集型站](/blog/pmtiles-http-range-request-single-file-tiles)才會這麼致命，一般部落格、產品官網根本碰不到兩萬檔，那種站更該盯的是頻寬跟 build 時間。各家的檔案數天花板也差很多：Cloudflare Pages Free 是 20,000、付費才到 100,000，Netlify 則沒有這種等級的硬卡點。重點是選平台前把這四個限制都對一遍，免得中途翻車。\n\n回頭看，我這次是 build 完、deploy 前才想到去查檔案數，算是運氣好，在浪費一次失敗部署之前就攔下來了。理想上這種限制應該在「選平台的當下」就查清楚，而不是撞牆才回頭——這也是我後來幫整個 [TaxMap 專案做覆盤](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)時記下來的一條。至於最後選免費平台 5 分鐘搬完、沒去重構 OG pipeline 也沒付月費，這算不上什麼大道理，純粹是一個 side project 該有的鬆弛感：不是每個技術潔癖都值得當下還債，能用最小力氣讓站上線、功能一個沒少，往往就夠了。\n\n另外兩個踩坑時學到的東西：我一度以為「換成 Cloudflare Workers Static Assets 就能逃」，結果 Workers 靜態資源也有自己的檔案數上限，不是無腦解；還有就是這次最反直覺的地方——擋住你的可能不是「太大」，而是「太多」，880MB 完全沒事，是 23,331 這個「數量」把我卡死的。\n\n---\n\n*官方限制文件：[Cloudflare Pages Limits](https://developers.cloudflare.com/pages/platform/limits/)*",
      "summary": "TaxMap-TW build 出 23,331 個檔案，撞上 Cloudflare Pages Free 方案「單次部署最多 20,000 檔」的硬限制，最後改用 Netlify 5 分鐘解決。記錄為什麼會爆檔案數（一頁一檔 × 7,750 村里）、三個方案的取捨，以及換平台其實是「換天花板不是拆天花板」——選靜態部署平台別只看單檔大小，檔案數才是隱形天花板。",
      "image": "https://bobochen.dev/_astro/cover.KZinsJzY.webp",
      "date_published": "2026-05-31T00:00:00.000Z",
      "tags": [
        "Cloudflare Pages",
        "Netlify",
        "部署",
        "Astro",
        "靜態網站"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/cloudflare-worker-on-demand-og-satori-taxmap/",
      "url": "https://bobochen.dev/blog/cloudflare-worker-on-demand-og-satori-taxmap/",
      "title": "把 7,750 張 OG 圖改成 Cloudflare Worker 即時生成：Satori at the edge",
      "content_text": "Cloudflare Worker 即時生成 OG 圖的設計稿：用 workers-og（Satori + resvg-wasm）取代 TaxMap-TW 預先生成的 7,750 張村里圖，預生檔案數從 7,750 降到 0、騰出空間搬回 Cloudflare Pages Free。含中文字型、Cache API 與 Workers Free CPU 上限踩雷。",
      "content_html": "> ⚠️ 開頭先誠實說：這篇是**設計與 How-to 指南**，不是已上線的復盤——它正是[上一篇](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)我「逃避」掉的那個重構。等真的做完，我會再寫一篇實戰版。\n\n## 起點：上一篇 7,750 張預先生成 OG 圖留下的尾巴\n\n[上一篇](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)講到 TaxMap 因為「每個村里預先生成 1 張 OG 圖 × 7,750 = 7,750 個檔案」，把 `dist/` 頂破 Cloudflare Pages 的 20,000 檔上限，最後搬去 Netlify。\n\n但其實有個更漂亮的解法：**根本不要預先產生那 7,750 張圖**。改成「有人要分享某個村里時，才即時生成那一張，然後快取在邊緣」。\n\n而且這招有個甜美的副作用——OG 圖從 7,750 個檔案變成 **0 個**，`dist/` 從 23,331 降到 ~15,584，**反而塞得回 Cloudflare Pages Free 版**。繞了一圈又回得去。\n\n## 核心概念：on-demand OG\n\n預先生成（build-time）vs 即時生成（runtime）的差別：\n\n| 比較項目 | Build-time（現在）| Runtime（這篇要做的）|\n|---|---|---|\n| 何時產圖 | build 時一次生 7,750 張 | 第一次有人請求才生那一張 |\n| 檔案數 | +7,750 | **0**（不進 `dist/`）|\n| 新增村里 | 要重 build | 自動就有 |\n| 首次延遲 | 無（已是檔案）| 有（首次渲染，之後吃快取）|\n| 冷啟動 | 無 | 有（Worker 冷啟 + WASM 初始化）|\n| 額外執行成本 | 無（純靜態檔） | 多一個 Worker（可能要 Workers Paid）|\n| 外部相依 | build 完就定版 | 線上要 fetch 字型、村里 JSON、Cache API |\n| 爬蟲可靠度 | 100%（檔案永遠在） | 看快取命中，首次被分享是冷渲染 |\n| 可驗證性 | CI build 階段就能驗、壞了不會上線 | 失敗移到線上請求路徑，要靠監控才看得到 |\n\n做法：一個 Worker 掛在 `/og/v/:code.png`，第一次被請求時 render 出 PNG、寫進 edge cache，之後都走快取。\n\n## 技術棧：workers-og（Satori + resvg-wasm）\n\n關鍵是 [`workers-og`](https://github.com/kvnang/workers-og) 這個套件——專為 Cloudflare Workers 設計的 OG 產生器，API 仿 `@vercel/og`，底層是：\n\n- [Satori](https://github.com/vercel/satori)：把 HTML/CSS → SVG（不需瀏覽器）\n- `@resvg/resvg-wasm`：把 SVG → PNG\n\n**為什麼不直接用 `@vercel/og`？** 因為它的 WASM 打包方式在 Cloudflare Worker 上會出錯。`workers-og` 就是為了解決這件事而生，還額外支援用 HTML 字串（透過 Worker 的 `HTMLRewriter` 解析），不用寫 JSX。\n\n底層的 Satori + resvg 其實就是我在 build-time 產 OG 圖時用的那套，差別只在這次搬到了 Worker 上跑。Satori 本身怎麼把 HTML/CSS 變成 OG 圖、為什麼是它而不是 puppeteer，我之前寫過一篇 build-time 的 Satori + resvg 教學；想先看「到底要不要自己生 OG 圖、有哪幾種做法」的，可以看 OG 圖自動生成的三種方案比較。這篇談的 runtime 生成，本質上是同一套引擎換個執行時機。\n\n### 3 分鐘快速上手\n\n```bash\nnpm create cloudflare@latest og-worker   # 建一個 Worker 專案\ncd og-worker\nnpm install workers-og\n```\n\n最小可動範例（`src/index.ts`）：\n\n```ts\nimport { ImageResponse } from \"workers-og\";\n\nexport default {\n  async fetch(request: Request) {\n    const html = `\n      <div style=\"display:flex;width:100%;height:100%;\n                  align-items:center;justify-content:center;\n                  background:#0b1220;color:white;font-size:72px;\">\n        哪里最有錢 · TaxMap\n      </div>`;\n    return new ImageResponse(html, { width: 1200, height: 630 });\n  },\n};\n```\n\n```bash\nnpx wrangler dev          # 本機跑起來\n# 開 http://localhost:8787 就看到一張 1200×630 的 PNG\n```\n\n`ImageResponse` 收 HTML 字串（或 JSX），回一個 body 是 PNG 的 `Response`。就這麼直接。\n\n## 套用到 TaxMap 的設計\n\n把村里資料、字型、快取串起來（設計草稿）：\n\n```ts\nimport { ImageResponse } from \"workers-og\";\n\nlet fontCache: ArrayBuffer | null = null;\nasync function getFont() {\n  // ⚠️ 中文字型必須「手動」載入並塞給 Satori，它不會自己抓\n  if (!fontCache) {\n    const r = await fetch(\"https://taxmap.bobochen.dev/fonts/NotoSansTC-Bold.woff\");\n    fontCache = await r.arrayBuffer();\n  }\n  return fontCache;\n}\n\nexport default {\n  async fetch(request: Request, env: unknown, ctx: ExecutionContext) {\n    const url = new URL(request.url);\n    const code = url.pathname.split(\"/\").pop()?.replace(\".png\", \"\");\n\n    // 1) 先查 edge cache，命中就直接回（每個村里只 render 一次）\n    const cache = caches.default;\n    const hit = await cache.match(request);\n    if (hit) return hit;\n\n    // 2) 手動 fetch 村里資料（Satori 在 Worker 內建抓取會「默默失敗」）\n    const data = await fetch(\n      `https://taxmap.bobochen.dev/data/villages/${code}.json`\n    ).then((r) => r.json());\n\n    // 3) render\n    const img = new ImageResponse(\n      `<div style=\"display:flex;flex-direction:column;width:100%;height:100%;\n                   padding:80px;background:#0b1220;color:#fff;\n                   font-family:'Noto Sans TC';\">\n         <div style=\"font-size:40px;color:#9ca3af;\">${data.county}${data.town}</div>\n         <div style=\"font-size:96px;font-weight:700;\">${data.name}</div>\n         <div style=\"font-size:64px;margin-top:auto;\">中位數所得 ${data.median} 萬</div>\n       </div>`,\n      {\n        width: 1200,\n        height: 630,\n        fonts: [{ name: \"Noto Sans TC\", data: await getFont(), weight: 700 }],\n      }\n    );\n\n    // 4) 寫進 cache，回傳\n    const res = new Response(img.body, img);\n    res.headers.set(\"Cache-Control\", \"public, max-age=31536000, immutable\");\n    ctx.waitUntil(cache.put(request, res.clone()));\n    return res;\n  },\n};\n```\n\n這段是設計骨架，刻意省了錯誤處理好讓主幹清楚——但正式版不能這樣留：`getFont()` 的 `fetch`、村里 JSON 的 `fetch`、`r.json()` 任何一步失敗，現在都會直接讓整個請求 500，而且這正是 build-time 沒有的失敗模式（build 階段抓不到字型，build 就紅燈了，根本上不了線；改成 runtime 之後，這些錯誤全被推到「使用者請求當下」才爆）。實作時這裡每一步都要包 try/catch，並準備一張 fallback OG 圖（純文字、不依賴外部資料的版本），抓不到資料時至少回得出一張像樣的圖，而不是讓爬蟲拿到 500。\n\n## 踩雷預告（先研究過、實作時會遇到的）\n\n1. **別用 `@vercel/og`**：WASM 打包不相容 Worker，改用 `workers-og`。\n2. **中文字型要手動塞**：非拉丁字型 Satori 不會自己載，要自己把 Noto Sans TC 的 WOFF buffer 餵給 `fonts`。WOFF ~1.4MB，但 Satori 只會 subset 實際用到的字，輸出 PNG 約 30KB。**注意字型格式只能餵 TTF / OTF / WOFF，Satori 不支援 WOFF2**——很多現成 CDN 字型預設給的是 .woff2，直接拿來會解析失敗，要找 .woff 版或自己轉一份。\n3. **Satori 內建抓圖會默默失敗**：在 Worker 裡，圖片要自己 `fetch` 轉成 base64 data URL，不要靠 Satori 內部抓。\n4. **Worker CPU 時間**：resvg-wasm render 吃 CPU，要靠 edge cache 讓每張只算一次。\n5. **Cache API 在 `*.workers.dev` 根本不會運作**：這是整套成本攤平的地雷。`caches.default` 只在 **custom domain**（或 Pages Functions）上才真的快取，掛在預設的 `xxx.workers.dev` 網域時 `cache.put` 是 **靜默 no-op**——`cache.match` 永遠 miss，每一次請求都重新 render 一張圖，「每個村里只算一次」的前提整個落空。所以這個 OG Worker **一定要掛在自己的 custom domain**（例如 `taxmap.bobochen.dev`），不能只用快速上手那個 `localhost:8787` / workers.dev 就上線。上面快速上手叫你開 `localhost:8787` 只是看渲染對不對，不代表 cache 有在運作。\n6. **Workers Free 每次 invocation 只有 10ms CPU**：Satori + resvg-wasm 第一次把 CJK PNG rasterize 出來，CPU 時間常常遠超過 10ms。如果跑在 Workers **Free**，這張首圖很可能直接被 runtime 中止——圖生不出來，也就寫不進快取，下一個請求又從頭再撞一次。所以要分清楚兩件事：**TaxMap 主站的靜態 `dist/` 回 Cloudflare Pages Free 沒問題**（純靜態託管），但**負責即時渲染的這個 OG Worker，很可能得開 Workers Paid（$5/mo）才扛得住第一次冷渲染**。下面「搬回 Free」講的是前者，別把它誤讀成「連渲染都免費」。\n\n## 反思\n\n### 技術面\n\n這是個經典的 **build-time vs runtime** 取捨，而且不是 runtime 完勝。Build-time 換來的是零冷啟動、不用多付 Worker 錢、build 完就定版沒有外部相依、爬蟲 100% 抓得到、壞了在 CI 階段就會被擋下來——這些 runtime 全部要重新賺。Runtime 換來的是零檔案、新村里自動就有，代價是多一個 Worker、首次渲染延遲，還有一整類 build-time 不存在的「線上才會炸」失敗模式：fetch 字型、fetch 村里 JSON、Cache API 命中率，任何一個出包都是使用者請求當下才發現。\n\n所以我不會說它「幾乎是必然」。我的門檻是這樣：如果頁面數會持續長大到撞檔案數上限、而且大多數頁面其實沒人會去分享（OG 圖很長尾），那 runtime 才划算——用「只渲染真的被分享到的那幾張」換掉「無差別預生幾千張」。反過來，如果頁面數可控、或熱門頁面就那幾個，build-time 的零延遲跟可驗證性還是比較省心。\n\n而且要對「首次延遲」很誠實。社群平台的爬蟲（Facebook、Threads、LINE）抓 OG 圖是同步的、有 timeout、而且基本上不重試。偏偏 cache miss 那一次——也就是這個村里第一次被分享出去的那一刻——正好是最慢的冷渲染。第一個願意幫你分享的人，很可能就是看不到預覽圖的那個人，這對想靠分享擴散的站來說格外諷刺。要救有幾條路：用 sitemap 或 build 後跑一輪預熱、把已知熱門的村里在 build 時先 warm 進快取、對爬蟲走 stale-while-revalidate（先回舊圖、背景重算）。但說到底，**如果一張圖幾乎都是「第一次被分享」時才被要求，那 build-time 預先準備好其實更安全**——這也是我還沒急著把它換掉的原因。\n\n還有快取本身也別想得太美。`caches.default` 是 **per-PoP** 的，每個邊緣節點各自一份、而且會被驅逐。所以「每個村里只 render 一次」嚴格說是「每個村里、在每個 PoP、在還沒被驅逐之前各 render 一次」；長尾村里散在各地、又久久才被看一次，命中率會很低，等於反覆 cache miss 反覆重算。真要做到全球只算一次，得把結果落到 R2 或 KV 這種持久層，而不是只靠 edge cache。\n\n### 心態面\n\n上一篇我為了快速上線，選了「換平台」這個 5 分鐘解法，把重構記成 TODO。這篇是把那張 TODO 攤開來「先想清楚怎麼做」——**先設計、再實作**，而不是一頭栽進去寫 code 才發現中文字型載不進來。光是先查清楚 Cache API 在 workers.dev 不運作、Free 版 10ms CPU 這兩個雷，預期就能省下實作時撞牆乾耗的半個下午——當然這還只是假設，真的動手做完才知道準不準。\n\n### 有趣發現\n\n最爽的是那個全循環：**把 OG 改成即時生成，預生檔案數掉到 20,000 以下，TaxMap 的靜態站就能搬回 Cloudflare Pages Free**。當初逼我搬家的限制，用對的架構就能繞回去。\n\n不過先別太得意。所謂「搬回 Free」是把帳算在靜態託管那一塊——真實的代價是架構從「一坨靜態檔」拆成「Pages 靜態站 + 一個獨立 OG Worker（很可能還得 Workers Paid）」兩塊東西。檔案數歸零的同時，我換來的是兩套部署、Worker 的字型/快取/錯誤處理要長期顧、出事時要分清楚是哪一邊。本質上是用「長期維運複雜度」去換「檔案數歸零」，不是無痛的勝利。值不值得，要看我願不願意長期養這個 Worker——這也是我到現在還只把它停在設計稿、沒真的動手的原因。\n\n---\n\n**這個系列其他文章**：前情是 [Cloudflare Pages 的 20,000 檔案上限：我把 TaxMap 搬到 Netlify](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)（就是它逼出這篇的重構）；整個專案怎麼蓋、踩了哪些坑的全紀錄在 [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)。\n\n*參考：[workers-og](https://github.com/kvnang/workers-og)、[Satori](https://github.com/vercel/satori)、[6 Pitfalls of Dynamic OG on Cloudflare Workers](https://dev.to/devoresyah/6-pitfalls-of-dynamic-og-image-generation-on-cloudflare-workers-satori-resvg-wasm-1kle)*",
      "summary": "Cloudflare Worker 即時生成 OG 圖的設計稿：用 workers-og（Satori + resvg-wasm）取代 TaxMap-TW 預先生成的 7,750 張村里圖，預生檔案數從 7,750 降到 0、騰出空間搬回 Cloudflare Pages Free。含中文字型、Cache API 與 Workers Free CPU 上限踩雷。",
      "image": "https://bobochen.dev/_astro/cover.D0Dg35Zl.webp",
      "date_published": "2026-05-31T00:00:00.000Z",
      "tags": [
        "Cloudflare Workers",
        "Satori",
        "OG Image",
        "Astro",
        "邊緣運算"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-governance-framework/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-governance-framework/",
      "title": "Agent 治理框架：讓企業敢把 AI agent 接到真實業務上的那張安全網",
      "content_text": "把散落在各章的安全與信任機制，收斂成一張可以攤給資安和主管看的治理框架：資料分級、RBAC 權限邊界、tool registry、audit log、human-in-the-loop、eval harness、observability、成本監控。這一張圖，就是「能 demo」和「企業敢用」之間那道治理的牆。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 11 篇（共 12 篇）。上一篇：[延遲、可靠性、成本的系統權衡](/blog/enterprise-ai-agent-latency-reliability-cost-tradeoffs)。這篇整理的是**治理設計框架（一份 reference / 設計提案）**，不是某個已導入系統的稽核報告。\n\n前面十篇，安全和信任的機制是散落各處講的：第 5 篇的權限檢索、第 6 篇的工具邊界、第 9 篇的可觀測性。這一篇要做的，是把它們**收斂成一張完整的治理框架**——一張你可以攤在資安長、法遵、業務主管面前，回答那個關鍵問題的圖：\n\n> 「我們憑什麼敢相信這個 AI agent，把它接到真實的客戶資料、財務系統、製造流程上？」\n\nPoC 永遠回答不了這個問題，因為它根本沒想過。而這張圖，就是「能 demo」和「企業敢用」之間那道治理的牆。\n\n## 一頁治理框架\n\n![Agent 治理框架：上層控制「誰能碰什麼」、中層控制「能做什麼」、底層橫跨一切持續監督（audit、eval、observability、cost）](./images/governance-framework.webp)\n\n<p style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.9rem', lineHeight: 1.6, marginTop: '-0.4rem' }}>治理框架的三層：上層管「誰能碰什麼」、中層管「能做什麼」、底層橫跨一切持續監督（audit / eval / observability / cost）。</p>\n\n八個元件，分三層：**控制誰能碰什麼（①②）、控制能做什麼（③④）、持續監督（⑤⑥⑦⑧）**。一個一個講它在治理上扮演什麼角色。\n\n在逐項講之前，先講一件對資安和法遵很重要的事：**這張圖不是我發明的。** 它只是把 NIST AI RMF、ISO/IEC 42001、EU AI Act 對 AI 系統的共同要求，落到 agent 工程上。八個元件幾乎一對一對得上既有框架：\n\n| 治理元件 | NIST AI RMF | ISO/IEC 42001 | EU AI Act（高風險） |\n|---|---|---|---|\n| ① 資料分級 | MAP | Annex A 控制 | Art.10 資料治理 |\n| ② RBAC/ABAC・最小權限 | GOVERN | Annex A 控制 | — |\n| ③ Tool Registry | MAP / GOVERN | Annex A 控制 | — |\n| ④ Human-in-the-loop | GOVERN / MANAGE | PDCA | Art.14 人類監督 |\n| ⑤ Audit Log | MEASURE | PDCA | Art.12 紀錄保存 |\n| ⑥ Eval Harness | MEASURE | Check（PDCA） | — |\n| ⑦ Observability | MEASURE | Check（PDCA） | Art.12 / Art.72 |\n| ⑧ Cost／持續監督 | MANAGE | Act（PDCA） | Art.72 上市後監督 |\n\n為什麼要花力氣對這層？因為當你把這張圖攤在法遵面前，他第一個問題不會是「你 RBAC 怎麼做的」，而是「**這對得上我們要遵的哪一條**」。對得上，框架的說服力就從「工程師畫的圖、聽起來有道理」，升級成「對得上稽核要求的圖」。\n\n## ① 資料分級：先知道你在保護什麼\n\n治理的起點不是技術，是**盤點**。哪些資料是公開的、內部的、機密的、個資 / 受法規保護的（個資法、營業秘密、客戶合約）？\n\n沒有分級，後面的權限控制就沒有依據——你不知道哪些資料「碰了會出事」。這一步常常要跟法遵、資安一起做，是治理裡最不技術、卻最不能跳過的一步。分級的結果，會變成第 5 篇那些 chunk 上的權限 metadata 的來源。\n\n## ② 身分與權限邊界：RBAC / ABAC\n\n把「誰、能透過 agent、碰到哪些資料和工具」明確定義出來。這是第 5 篇（資料檢索）和第 6 篇（工具動作）的權限，在治理層的統一視角。\n\n- **RBAC（角色為本）**：依角色給權限。工程師能查工單、不能查薪資；主管多一些；HR 又不同。\n- **ABAC（屬性為本）**：更細，依屬性動態判斷（部門 + 機密等級 + 地區）。複雜場景用得上。\n\n核心原則是**最小權限**：agent 代表某使用者行動時，只該有那個使用者該有的權限，**一分不多**。最該避免的反模式，就是第 2 篇警告過的——agent 用一個服務帳號的最大權限在跑，把自己變成權限放大器。\n\n## ③ Tool Registry：能做的事，要有一份清單\n\n第 6 篇講過，MCP / tool registry 把「agent 能做哪些動作」變成一份可盤點的清單。在治理層，這份清單要再標註：\n\n- 每個工具的 **action boundary**（唯讀 / 寫入 / 危險）\n- 需不需要 **approval**\n- 哪些角色能用哪些工具\n\n當資安問「你們的 agent 到底能對哪些系統做哪些事」，你掏得出這份清單——這就是治理的可稽核性。**沒有 registry，你連自己的 agent 能做什麼都說不清楚**，更別說讓資安放行。\n\n## ④ Human-in-the-loop：高風險動作的剎車\n\n第 6 篇講過 approval flow 的機制，治理層要決定的是**政策**：哪些動作非要人類確認不可？\n\n通常的分界：**會造成重大、不可逆副作用的**——大量資料異動、對外溝通、金流、改動生產設定——都該有 HITL 關卡。低風險唯讀的放行。這份「哪些要剎車」的清單，是治理的核心決策之一，要跟業務一起定，因為它直接影響效率和風險的平衡。\n\n但 HITL 最常見的失效，不是沒放關卡，是**放了卻變成橡皮圖章**——當待核可的動作又多、又通常是對的，人會很快養成「一律點同意」的習慣，關卡還在、監督已經沒了。這正是 EU AI Act Art.14 想堵的洞：它要的是**有意義的、能真正介入的人類監督**，不是流程上多一個 approval 按鈕。對 agent 還有個額外的坑：當待核可項是模型生成的一段自然語言，審核者很難在幾秒內判斷它的副作用範圍。所以 HITL 的設計品質，取決於「**呈現給人類的資訊，夠不夠讓他做出有意義的判斷**」——這動作會改到哪些資料、影響多大、為什麼 agent 這樣選——而不只是「有沒有放一個確認關卡」。\n\n## ⑤–⑧ 監督層：信任不是一次性的，是持續的\n\n前面四項是「事前控制」，但治理的另一半是**持續監督**——因為信任會隨時間、隨模型更新、隨資料變化而流失。這四項都在前面章節建好了，治理層把它們組織起來：\n\n- **⑤ Audit Log**：誰、何時、透過 agent、存取了什麼、做了什麼動作。合規與事故調查的底線，出事時這是你唯一能還原真相的東西。兩個常被漏掉的細節：(1) **要留多久？** EU AI Act Art.12 已把高風險系統的「自動事件記錄」從最佳實踐升級成法定義務，Art.19 對 deployer 給的下限是至少保存六個月——audit log 不只要有，還要回答得出保存期限。(2) **對 agent，光記「呼叫了哪個工具」遠遠不夠**，要連 prompt、檢索到的 chunk、工具的輸入輸出一起留，否則事故調查時最關鍵的中間決策過程是一片空白。這份資料跟第 9 篇 observability 的 trace 其實是同一份東西的兩種用途。\n- **⑥ Eval Harness（第 9 篇）**：品質有沒有偷偷掉？模型被供應商更新後行為有沒有漂移？這套持續監督的精神對應 NIST AI RMF 的 MEASURE / MANAGE；NIST 2024 年的 Generative AI Profile（NIST-AI-600-1）甚至把 **confabulation（也就是幻覺）獨立列為一個風險類別**，對 12 個 GenAI 風險領域給了 200+ 條建議行動，可以直接拿來當 eval 與監控清單的起點——它點名的第一個風險，正好就是這系列開頭的「鴻溝一：正確性沒有底線」。\n- **⑦ Observability（第 9 篇）**：每個決策可追、可重播，出包查得到根因。\n- **⑧ Cost Monitor（第 9、10 篇）**：花費透明、異常可告警。\n\n這四項合起來回答的是：**「我們有沒有在持續確認這套系統仍然值得信任？」** 一個只做事前控制、卻沒有持續監督的 agent，就像一個通過了上線審查、之後就再也沒人看的系統——遲早出事。\n\n## 怎麼落地：別想一次到位\n\n這張框架完整，但不代表你要一次全做完。務實的導入順序：\n\n1. **先做 ①②**（分級 + 權限邊界）：沒有這個，任何接觸真實資料的 agent 都不該上線。這是入場券。\n2. **再做 ③⑤**（registry + audit）：讓「能做什麼」和「做過什麼」可盤點、可追。\n3. **接著 ④**（HITL）：把最高風險的動作先用人類關卡保護起來。\n4. **持續強化 ⑥⑦⑧**：監督層隨系統成熟逐步加深。\n\n還有一個外部理由，讓「現在就做」比「等等再說」划算：**法規時鐘已經在跑。** 但這裡有個 2026 必須講對的眉角——EU AI Act 是分階段生效的：禁止性實務與 AI 素養義務已自 2025-02 適用、GPAI 義務自 2025-08 適用，這幾條都已生效。而大家最在意的**高風險義務，原訂 2026 年 8 月起，在 2025 年底的 Digital Omnibus 提案後被往後推**（討論中的新時程落在 2027–2028，截至本文撰稿仍待正式定案）。所以如果你還在網路上看到「2026 年 8 月高風險義務全面適用」，那是舊懶人包、已經過期了。給你的訊息很單純：**日期會動，但別賭它會無限延。** 分級、RBAC、audit、HITL 這套底層工程量大、牽涉法遵與業務，不是兩週能補完的——日期往後挪，剛好是讓你「提早做、做扎實」的窗口。\n\n依風險排序：**越是接觸機密資料、越是能造成不可逆動作的 agent，治理就要越完整**；一個只查公開 FAQ 的內部小工具，不需要這整套。治理的強度應該對應風險的高度，這本身也是一種工程判斷。\n\n## 小結\n\nAgent 治理不是上線後補的文件，是讓企業**敢**把 agent 接到真實業務上的前提。它把散落的機制收成一張圖：\n\n- **控制誰能碰什麼**：資料分級 + RBAC/ABAC 最小權限。\n- **控制能做什麼**：tool registry + 高風險動作的 HITL。\n- **持續監督**：audit log + eval + observability + cost。\n\n而它的精神，其實跟整個系列一致：**承認系統會錯、會被濫用、會隨時間退化，然後為這些事先設好防線**。能畫出這張圖、並依風險決定導入深淺的人，才是企業真正想要的「能把 AI agent 落地」的那個人。\n\n最後一篇，我們從系統回到人：當你要**帶一支 3–8 人的 AI 工程小隊**，把上面這整套東西做出來、維護下去，該怎麼帶。\n\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"Agent 治理框架\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[延遲、可靠性、成本的系統權衡](/blog/enterprise-ai-agent-latency-reliability-cost-tradeoffs)\n- 回顧：[權限感知檢索](/blog/enterprise-ai-agent-permission-aware-retrieval)、[Tool use 與 MCP](/blog/enterprise-ai-agent-tool-use-mcp)、[可觀測性與評估](/blog/enterprise-ai-agent-llm-observability-eval)——治理框架的三個技術支柱\n- 下一篇（完結）：《帶領一支 3–8 人的 AI 工程小隊》",
      "summary": "把散落在各章的安全與信任機制，收斂成一張可以攤給資安和主管看的治理框架：資料分級、RBAC 權限邊界、tool registry、audit log、human-in-the-loop、eval harness、observability、成本監控。這一張圖，就是「能 demo」和「企業敢用」之間那道治理的牆。",
      "image": "https://bobochen.dev/_astro/cover.BKs-7b68.webp",
      "date_published": "2026-05-31T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "AI 治理",
        "資安",
        "RBAC",
        "稽核",
        "企業導入"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-testing-safety/",
      "url": "https://bobochen.dev/blog/agentic-engineering-testing-safety/",
      "title": "Agent 安全網設計：當 AI 有 sudo 權限，你需要幾層保護",
      "content_text": "給 agent 越多權限它越有用，但也越危險。設計一套「agent 安全網」——從 sandbox 環境、permission boundaries、rollback 機制、到 human-in-the-loop 的斷路器設計。附 hooks 設定和曾經差點出事的真實故事。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第十二篇。上一篇：[Token 經濟學進階](/blog/agentic-engineering-cost-optimization)\n\n## Agent 自動 Push 了 47 次到 Staging\n\n有一次我設定了 agent 自動部署到 staging 的 workflow，然後去吃午餐。回來的時候，staging 環境已經被部署了 47 次。\n\n原因是 agent 卡在一個 deploy → test → fix → redeploy 的 loop 裡。每次 fix 都產生新的 issue，然後又觸發 redeploy。我去吃午餐的一個小時裡，它忠實地執行了 47 個 cycle。\n\nstaging 環境沒壞，但同事們的 test data 全被覆蓋了。那個下午我花了兩小時幫大家恢復環境，以及更久的時間恢復他們對我搞的 automation 的信任。\n\n這件事教了我一件事：agent 最危險的特質不是它會做錯事，而是它會不知疲倦地一直做錯事。人類犯錯會停下來思考，agent 犯錯只會繼續 loop。\n\n## Agent 的權限困境\n\nAgent 的有用程度跟它的權限成正比：\n\n- **只能讀 code** → 能幫你查東西，但不能幫你做事\n- **能讀寫 code** → 能幫你寫 code、修 bug\n- **能執行指令** → 能跑 test、build、lint\n- **能操作 Git** → 能 commit、create branch、push\n- **能操作外部系統** → 能 deploy、query database、send notification\n- **能操作瀏覽器** → 能做 visual testing、填表單、抓資料\n\n每一層權限的增加，都讓 agent 更有用，也更危險。\n\n目標不是「限制 agent」（那樣它就沒用了），而是**設計可控的自由度**，在每個權限等級上放對的護欄。\n\n## 安全網的三層架構\n\n我的 agent 安全設計有三層，從自動到人工：\n\n```\nLayer 1: Sandbox（自動隔離）\n  ↓ 擋住大部分的「無意間搞破壞」\nLayer 2: Hooks & Guardrails（自動檢查）\n  ↓ 擋住「不該做的操作」\nLayer 3: Human-in-the-Loop（人工審批）\n  ↓ 擋住「需要判斷的決策」\n```\n\n### Layer 1: Sandbox 環境設計\n\n**Git Branch Isolation**\n\n最基本也最有效的 sandbox：agent 永遠在 feature branch 上工作，永遠不直接改 main。\n\n```bash\n# Agent 開始工作前\ngit checkout -b feat/agent-task-xxx\n\n# Agent 工作完成後\n# → 你 review → merge → 或 discard\n```\n\n萬一 agent 把事情搞砸了，`git checkout main && git branch -D feat/agent-task-xxx` 就好，一秒復原。\n\n**Git Worktree**\n\n進階做法：用 git worktree 讓 agent 在一個完全獨立的目錄裡工作。它的修改不會影響你正在看的 working directory。\n\nClaude Code 有內建的 worktree 支援——你可以讓 sub-agent 在 worktree 裡跑，確保主 context 的檔案狀態不被干擾。\n\n**Network Isolation**\n\n如果你用 Codex CLI，它有 kernel-level sandbox——agent 在一個隔離的 Linux 容器裡執行，只能存取你明確授權的路徑和 network。\n\nClaude Code 的隔離沒這麼嚴格（它在你的 terminal 裡直接執行），所以更需要依賴 Layer 2 和 Layer 3。\n\n### Layer 2: Hooks 作為 Guardrails\n\nClaude Code 的 hooks 系統讓你在 agent 的操作前後自動執行檢查：\n\n| Hook 類型        | 觸發時機             | 用途            |\n| ---------------- | -------------------- | --------------- |\n| **PreToolUse**   | Agent 要使用工具之前 | 攔截危險操作    |\n| **PostToolUse**  | Agent 使用工具之後   | 記錄 / 檢查結果 |\n| **Notification** | 需要通知時           | Alert / log     |\n\n**我的 Hook 設定**：\n\n**1. 敏感檔案保護**\n\n```json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Edit|Write\",\n        \"command\": \"check-sensitive-files.sh \\\"$FILE_PATH\\\"\",\n        \"description\": \"Block edits to .env, credentials, or config with secrets\"\n      }\n    ]\n  }\n}\n```\n\nAgent 嘗試編輯 `.env`、`credentials.json`、或任何包含 secrets 的檔案時，自動擋住。\n\n**2. Git Push 確認**\n\nAgent 可以自由 commit（到 feature branch），但 push 需要人工確認。因為 push 是不可逆的，一旦 push 到 remote，其他人就能看到。\n\n**3. 破壞性操作攔截**\n\n`rm -rf`、`DROP TABLE`、`git reset --hard`——這些操作在被 agent 執行之前，一律需要你的明確批准。\n\n### Layer 3: Human-in-the-Loop 斷路器\n\n有些操作，不管有多少自動化檢查，最終都需要人來決定。\n\n**斷路器設計原則：Reversibility × Blast Radius**\n\n|            | 低影響    | 高影響    |\n| ---------- | --------- | --------- |\n| **可逆**   | 自動放行  | Hook 檢查 |\n| **不可逆** | Hook 檢查 | 人工審批  |\n\n對應到實際操作：\n\n**自動放行**（可逆 + 低影響）：\n\n- 讀取檔案\n- 跑 test / lint\n- 在 feature branch 上 commit\n- 格式化 code\n\n**Hook 檢查**（可逆 + 高影響 or 不可逆 + 低影響）：\n\n- 修改 config files\n- 安裝 npm packages\n- 執行 build\n- 修改 database migration files\n\n**人工審批**（不可逆 + 高影響）：\n\n- Git push to remote\n- Deploy to staging / production\n- 刪除檔案或 branch\n- 修改 CI/CD pipeline\n- 任何涉及 credentials 的操作\n\n## Near-Miss Stories：差點出事的那幾次\n\n### 案例 1：47 次 Staging Deploy\n\n（開場說的那個。）\n\n**根因**：agent 有自動 deploy 的權限，且沒有 rate limit。\n\n**加了什麼防護**：\n\n1. Deploy 操作加了 cooldown：每 10 分鐘最多 deploy 一次\n2. 連續失敗 3 次自動停止（回扣 [CLAUDE.md 的 \"max 3 attempts\" rule](/blog/claude-md-rules-files-masterclass)）\n3. Deploy 前需要人工 confirm\n\n### 案例 2：差點 Push Credentials\n\nAgent 在修一個環境設定問題時，為了「方便測試」，把一個 API key 硬寫在 code 裡。然後它準備 commit + push。\n\n幸好 pre-commit hook 攔住了——我的 hook 裡有 credential 掃描（用 gitleaks），偵測到 API key pattern 就 block 了 commit。\n\n**根因**：Agent 不理解 secrets management。它的 goal 是「讓 code 能跑」，hardcode API key 就是達到這個 goal 最快的方式。\n\n**加了什麼防護**：\n\n1. Pre-commit 的 credential scanning（原本就有，救了一命）\n2. 在 CLAUDE.md 裡加了明確規則：「永遠不要 hardcode secrets，使用環境變數」\n3. `.env` 檔案加入 hook 的保護清單\n\n### 案例 3：Production Migration 驚魂\n\nAgent 寫了一個 database migration script，在 staging 跑得很順利。然後它「貼心地」準備了 production 的 migration command——包含 production database 的連線字串。\n\n如果我沒注意到那個 command 裡的 hostname 是 production 而不是 staging，然後不小心讓 agent 執行了...\n\n**根因**：Agent 不區分環境。它看到 staging 成功了，就按照同樣的模式準備 production 的指令。它不知道 production migration 需要完全不同的審批流程。\n\n**加了什麼防護**：\n\n1. Production 連線字串不在任何 agent 可以讀取的檔案裡\n2. 任何包含 `production` 或 `prod` 關鍵字的指令需要人工 confirm\n3. Database migration 永遠是 human-only 的操作\n\n## 權限的漸進式開放\n\n不建議一次開放所有權限。建議的漸進路徑：\n\n### Week 1-2：Read Only + Write Code\n\n```\n✅ 讀檔案\n✅ 寫 code（在 feature branch）\n✅ 跑 test / lint\n❌ Git commit（你手動做）\n❌ 安裝 packages\n❌ 執行任意 shell 命令\n```\n\n熟悉 agent 的行為模式。看它會做什麼決策、會犯什麼錯。\n\n### Week 3-4：加 Git 操作\n\n```\n✅ 以上全部\n✅ Git commit（到 feature branch）\n✅ 安裝 npm packages（需確認）\n❌ Git push\n❌ 操作外部系統\n```\n\n### Month 2+：加外部操作\n\n```\n✅ 以上全部\n✅ Git push（需確認）\n✅ MCP 操作（指定的 server）\n✅ Deploy to staging（需確認）\n❌ Deploy to production\n❌ Database 操作\n```\n\n### 永遠不要自動化的事\n\n有些操作，無論你對 agent 多有信心，永遠需要人工審批：\n\n- **Production deploy**\n- **Database migration on production**\n- **刪除 production 資料**\n- **修改 IAM / permissions**\n- **Push to main/master**\n\n這不是因為 agent 一定會搞砸，而是這些操作的 blast radius 太大，萬一搞砸了，recovery 成本遠高於多花 30 秒人工確認的成本。\n\n## 建立你的安全網 Checklist\n\n```markdown\n## Agent Safety Checklist\n\n### Sandbox\n\n- [ ] Agent 在 feature branch 上工作（永遠不碰 main）\n- [ ] 有方便的 rollback 方式（git reset / git branch -D）\n- [ ] Production credentials 不在 agent 可讀的範圍\n\n### Automated Guardrails\n\n- [ ] Pre-commit hooks：credential scanning、lint、type check\n- [ ] Sensitive file protection（.env、config with secrets）\n- [ ] Rate limiting on destructive operations\n- [ ] Max retry limit（3 次失敗自動停止）\n\n### Human-in-the-Loop\n\n- [ ] Git push 需要確認\n- [ ] Deploy 需要確認\n- [ ] 刪除操作需要確認\n- [ ] Production-related 操作需要確認\n\n### Monitoring\n\n- [ ] Agent 的操作有 log（至少 git history）\n- [ ] 異常行為有通知（連續失敗、大量操作）\n- [ ] 定期 review agent 的操作歷史\n```\n\n## Takeaway\n\n1. **Agent 安全不是「限制 agent」，而是「設計可控的自由度」**：三層架構（Sandbox → Hooks → Human-in-the-Loop）讓 agent 在安全邊界內最大化有用性，每一層擋不同類型的風險。\n\n2. **Git 是你最好的安全網**：Branch isolation 加上 easy rollback，覆蓋了大部分「agent 搞壞了東西」的場景。確保 agent 永遠在 feature branch 上工作。\n\n3. **每次 near-miss 都是加強安全網的機會**：不要等到真的出事。47 次 staging deploy 教我加 rate limit，差點 push credentials 教我加 secret scanning。把每次驚險經歷都轉化成一條新的 guardrail。\n\n---\n\n_上一篇：[Token 經濟學進階](/blog/agentic-engineering-cost-optimization)_\n_下一篇：[把 Agentic Engineering 帶進團隊](/blog/agentic-engineering-team-adoption)_",
      "summary": "給 agent 越多權限它越有用，但也越危險。設計一套「agent 安全網」——從 sandbox 環境、permission boundaries、rollback 機制、到 human-in-the-loop 的斷路器設計。附 hooks 設定和曾經差點出事的真實故事。",
      "image": "https://bobochen.dev/_astro/cover.JHWBRbC7.webp",
      "date_published": "2026-05-29T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "資安",
        "Sandbox",
        "AI Safety",
        "Hooks"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-production-deployment/",
      "url": "https://bobochen.dev/blog/claude-api-guide-production-deployment/",
      "title": "生產環境部署：錯誤處理、限流與可觀測性",
      "content_text": "開發環境跑得好，不代表生產環境沒問題。本章全面解析 Rate Limits、指數退避、錯誤分類、API Key 安全管理、Logging 策略，以及 OpenTelemetry 整合——附 10 項生產環境部署 checklist。",
      "content_html": "你的應用在開發環境完美運作，每次 demo 都沒出問題。\n\n然後你把它部署到生產環境，十個用戶同時使用，五分鐘後你收到第一個錯誤報告。\n\n這個場景幾乎每個做 AI 應用的工程師都經歷過。開發環境和生產環境之間有個巨大的鴻溝，而這個鴻溝在 AI API 應用中特別明顯。\n\n## 開發環境 vs 生產環境的本質差異\n\n**並發性**：開發環境通常是你一個人在用，偶爾跑幾個測試。生產環境可能有幾十甚至幾百個請求同時進來——這意味著 Rate Limit 問題、資源競爭問題會突然全部冒出來。\n\n**錯誤頻率**：在開發環境，你的請求大部分都成功。生產環境中，網路問題、Anthropic API 的偶發故障、用戶輸入的邊緣 case——這些都會讓你看到你從來沒見過的錯誤。\n\n**可觀測性需求**：開發環境你可以直接 print() 來 debug。生產環境你需要結構化的 log、metrics、追蹤系統，因為當出問題的時候，你不在場。\n\n**成本壓力**：開發環境的錯誤和重試只花你一點時間。生產環境的錯誤和不必要的重試會直接換算成錢。\n\n這一章我要告訴你，一個 AI API 應用要進生產環境，需要做哪些事。\n\n## Rate Limits 完整解析\n\nAnthropic 的 Rate Limit 有四個維度，分別計算：\n\n**RPM（Requests Per Minute）**：每分鐘的請求次數限制。對多數用戶，這個限制在 50-2000 RPM 之間，視你的帳戶 tier 而定。\n\n**TPM（Tokens Per Minute）**：每分鐘的 token 使用量限制。這是最常觸發的限制——一個複雜的請求可能用掉 10K tokens，如果很多請求同時進來，很快就會碰到 TPM 上限。\n\n**ITPM（Input Tokens Per Minute）**：輸入 token 的專屬限制，有些 tier 對輸入 token 有獨立的限制。\n\n**OTPM（Output Tokens Per Minute）**：輸出 token 的專屬限制。如果你的應用需要生成大量長文，OTPM 可能是你最先碰到的瓶頸。\n\n查看你的帳戶限制：\n\n```bash\n# 透過 Anthropic Console API 查看\ncurl https://api.anthropic.com/v1/organizations/limits \\\n  -H \"x-api-key: $ANTHROPIC_API_KEY\" \\\n  -H \"anthropic-version: 2023-06-01\"\n```\n\n或是在 [console.anthropic.com](https://console.anthropic.com) 的 Settings → Limits 頁面查看。\n\n如果你需要更高的限制，可以在 Console 申請提高——通常需要幾個工作天審核，並且需要說明你的使用場景。\n\n當你碰到 Rate Limit，API 會回傳 HTTP 429 狀態碼，response 中會包含 `Retry-After` header，告訴你需要等待幾秒。\n\n## 指數退避的正確實作\n\n碰到 429 的時候，很多初學者的直覺是「等一秒再試」。但這是錯的。\n\n如果你有 100 個並發請求都在等一秒，一秒後它們同時再試，又同時碰到 429，然後又同時等一秒……這個「驚群效應（Thundering Herd）」會讓情況越來越糟。\n\n正確的做法是**指數退避（Exponential Backoff）加上隨機 Jitter**：\n\n```python\nimport anthropic\nimport time\nimport random\nimport logging\nfrom typing import Optional\n\nlogger = logging.getLogger(__name__)\n\nclass AnthropicClientWithRetry:\n    def __init__(\n        self,\n        api_key: str,\n        max_retries: int = 5,\n        initial_delay: float = 1.0,\n        max_delay: float = 60.0,\n        exponential_base: float = 2.0,\n    ):\n        self.client = anthropic.Anthropic(api_key=api_key)\n        self.max_retries = max_retries\n        self.initial_delay = initial_delay\n        self.max_delay = max_delay\n        self.exponential_base = exponential_base\n\n    def create_message(self, **kwargs) -> anthropic.Message:\n        \"\"\"帶有指數退避重試的 message 建立\"\"\"\n        last_exception = None\n\n        for attempt in range(self.max_retries + 1):\n            try:\n                response = self.client.messages.create(**kwargs)\n                if attempt > 0:\n                    logger.info(f\"請求在第 {attempt + 1} 次嘗試成功\")\n                return response\n\n            except anthropic.RateLimitError as e:\n                last_exception = e\n                if attempt == self.max_retries:\n                    logger.error(f\"達到最大重試次數 ({self.max_retries})，放棄\")\n                    raise\n\n                # 從 Retry-After header 取得等待時間（如果有的話）\n                retry_after = e.response.headers.get(\"Retry-After\")\n                if retry_after:\n                    wait_time = float(retry_after)\n                else:\n                    # 指數退避 + jitter\n                    wait_time = min(\n                        self.initial_delay * (self.exponential_base ** attempt),\n                        self.max_delay\n                    )\n                    # 加入 ±25% 的隨機 jitter，防止驚群效應\n                    wait_time *= (0.75 + random.random() * 0.5)\n\n                logger.warning(\n                    f\"Rate limit 觸發 (429)，等待 {wait_time:.1f} 秒後重試 \"\n                    f\"(第 {attempt + 1}/{self.max_retries} 次)\"\n                )\n                time.sleep(wait_time)\n\n            except anthropic.APIStatusError as e:\n                # 5xx 錯誤：Anthropic server 問題，可以重試\n                if e.status_code >= 500:\n                    last_exception = e\n                    if attempt == self.max_retries:\n                        raise\n\n                    wait_time = min(\n                        self.initial_delay * (self.exponential_base ** attempt),\n                        self.max_delay\n                    ) * (0.75 + random.random() * 0.5)\n\n                    logger.warning(\n                        f\"Server error ({e.status_code})，等待 {wait_time:.1f} 秒後重試\"\n                    )\n                    time.sleep(wait_time)\n                else:\n                    # 4xx 錯誤（除了 429）：客戶端錯誤，不要重試\n                    raise\n\n            except anthropic.APIConnectionError as e:\n                # 網路連線問題，可以重試\n                last_exception = e\n                if attempt == self.max_retries:\n                    raise\n\n                wait_time = min(\n                    self.initial_delay * (self.exponential_base ** attempt),\n                    self.max_delay\n                )\n                logger.warning(f\"連線錯誤，等待 {wait_time:.1f} 秒後重試\")\n                time.sleep(wait_time)\n\n        raise last_exception\n```\n\n這個實作處理了三種可重試的情況：Rate Limit（429）、Server Error（5xx）、連線錯誤。每次重試的等待時間是上一次的 2 倍，再加上隨機 jitter，避免驚群效應。\n\n實際上，Anthropic 的官方 Python SDK 已經內建了類似的重試邏輯：\n\n```python\n# SDK 內建重試，最多重試 2 次\nclient = anthropic.Anthropic(\n    api_key=\"...\",\n    max_retries=2,  # 預設就是 2\n    timeout=30.0,   # 請求 timeout（秒）\n)\n```\n\n但我推薦自己控制重試邏輯，因為你可以加入更細緻的 logging 和 metrics，知道重試發生的頻率——頻繁的重試是一個需要關注的信號。\n\n## 錯誤分類與對應策略\n\n不同類型的錯誤需要不同的處理策略。\n\n**可重試的錯誤：**\n\n| 錯誤類型                  | HTTP 狀態碼 | 說明                  | 策略                     |\n| ------------------------- | ----------- | --------------------- | ------------------------ |\n| `RateLimitError`          | 429         | 超過 Rate Limit       | 指數退避重試             |\n| `InternalServerError`     | 500         | Anthropic server 問題 | 短暫等待後重試           |\n| `ServiceUnavailableError` | 503         | 服務維護或過載        | 長時間等待後重試         |\n| `APIConnectionError`      | N/A         | 網路問題              | 重試                     |\n| `APITimeoutError`         | N/A         | 請求逾時              | 重試（考慮增加 timeout） |\n\n**不可重試的錯誤（客戶端錯誤）：**\n\n| 錯誤類型                | HTTP 狀態碼 | 常見原因                   | 策略                           |\n| ----------------------- | ----------- | -------------------------- | ------------------------------ |\n| `AuthenticationError`   | 401         | API Key 無效或已撤銷       | 檢查 API Key，告警             |\n| `PermissionDeniedError` | 403         | 沒有使用某個功能的權限     | 檢查帳戶設定                   |\n| `NotFoundError`         | 404         | 請求了不存在的資源         | 修復 bug                       |\n| `BadRequestError`       | 400         | 請求格式錯誤               | 修復 bug，可能需要記錄 request |\n| `RequestTooLargeError`  | 413         | 輸入超過 context window     | 截斷輸入，記錄                 |\n\n```python\nfrom anthropic import (\n    Anthropic,\n    AuthenticationError,\n    PermissionDeniedError,\n    RateLimitError,\n    BadRequestError,\n    APIConnectionError,\n    InternalServerError,\n)\n\ndef handle_anthropic_error(e: Exception, context: dict) -> str:\n    \"\"\"統一的錯誤處理，回傳用戶友好的訊息\"\"\"\n\n    if isinstance(e, AuthenticationError):\n        # 這是嚴重問題——API Key 有問題，要告警給工程師\n        logger.critical(\"API Key 認證失敗，需要立即檢查\", extra=context)\n        # 觸發 PagerDuty/Slack 告警\n        alert_on_call_engineer(\"API Key 認證失敗\")\n        return \"服務暫時無法使用，我們已收到通知並正在處理\"\n\n    elif isinstance(e, RateLimitError):\n        # 已在重試邏輯中處理，到這裡代表重試用盡了\n        logger.error(\"Rate limit 重試用盡\", extra=context)\n        return \"目前服務繁忙，請稍後再試\"\n\n    elif isinstance(e, BadRequestError):\n        # 可能是用戶輸入有問題，也可能是 bug\n        error_msg = str(e)\n        if \"too many tokens\" in error_msg.lower():\n            logger.warning(\"輸入過長\", extra={**context, \"error\": error_msg})\n            return \"您的輸入太長，請縮短後再試\"\n        else:\n            logger.error(\"Bad request\", extra={**context, \"error\": error_msg})\n            return \"請求格式有誤，請重試\"\n\n    elif isinstance(e, InternalServerError):\n        logger.error(\"Anthropic server error\", extra=context)\n        return \"AI 服務暫時不穩定，請稍後再試\"\n\n    else:\n        logger.exception(\"未預期的錯誤\", extra=context)\n        return \"發生未知錯誤，請重試\"\n```\n\n## API Key 安全管理\n\nAPI Key 洩漏是 AI 應用最常見的安全事故之一。我見過有人把 API Key 直接 hardcode 在程式碼裡，commit 到 GitHub，然後一週內被人濫用了幾萬塊錢。\n\n**永遠不要做的事：**\n\n```python\n# ❌ 錯誤：hardcode 在程式碼中\nclient = anthropic.Anthropic(api_key=\"sk-ant-api03-xxxxx\")\n\n# ❌ 錯誤：commit 到 git\n# 就算你之後刪掉，git history 還是看得到\n```\n\n**正確做法：**\n\n```python\n# ✅ 從環境變數讀取\nimport os\nimport anthropic\n\n# SDK 預設就會讀 ANTHROPIC_API_KEY 環境變數\nclient = anthropic.Anthropic()  # 自動讀取 os.environ[\"ANTHROPIC_API_KEY\"]\n\n# 或明確指定\nclient = anthropic.Anthropic(api_key=os.environ[\"ANTHROPIC_API_KEY\"])\n```\n\n**在生產環境使用 Secret Manager：**\n\n```python\n# Google Cloud Secret Manager 範例\nfrom google.cloud import secretmanager\n\ndef get_api_key() -> str:\n    client = secretmanager.SecretManagerServiceClient()\n    name = f\"projects/{PROJECT_ID}/secrets/anthropic-api-key/versions/latest\"\n    response = client.access_secret_version(request={\"name\": name})\n    return response.payload.data.decode(\"UTF-8\")\n\n# 在應用啟動時（不是每次請求時）取得 key\nANTHROPIC_API_KEY = get_api_key()\nanthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)\n```\n\n另一個重要建議：**為不同環境使用不同的 API Key**。開發環境、staging 環境、生產環境各自一個 Key。這樣當某個環境的 Key 洩漏，其他環境不受影響，而且你可以透過 Anthropic Console 查看不同 Key 的使用量，更容易追蹤問題。\n\n## Logging 策略\n\nLog 要記什麼？不記什麼？這個問題比大多數人想的複雜。\n\n**要記錄的：**\n\n- 請求 ID（`x-request-id` header）——這是 debug 的生命線\n- 使用的 model、max_tokens\n- 回應的 token 使用量（input/output/cache）\n- 請求延遲（從送出到收到回應的時間）\n- 錯誤類型和錯誤訊息\n- 用戶 ID 或 session ID（匿名的，用於追蹤使用模式）\n\n**不要記錄的：**\n\n- 用戶的完整 prompt（可能包含個人資訊）\n- API Key\n- 任何 PII（個人識別資訊）\n\n```python\nimport anthropic\nimport time\nimport logging\nimport uuid\n\nlogger = logging.getLogger(__name__)\nclient = anthropic.Anthropic()\n\ndef call_api_with_logging(\n    messages: list,\n    system: str,\n    model: str = \"claude-3-5-sonnet-20241022\",\n    user_id: str = None,\n    feature: str = \"unknown\"\n) -> anthropic.Message:\n    \"\"\"帶有完整 logging 的 API 呼叫\"\"\"\n    request_id = str(uuid.uuid4())\n    start_time = time.time()\n\n    # 記錄請求（注意：不記錄 prompt 內容）\n    logger.info(\"api_request_started\", extra={\n        \"request_id\": request_id,\n        \"feature\": feature,\n        \"model\": model,\n        \"user_id\": user_id,  # 應該是匿名 ID，不是真實用戶資料\n        \"message_count\": len(messages),\n        # 記錄 prompt 的 token 估算，但不記錄內容\n        \"estimated_input_chars\": sum(len(str(m.get(\"content\", \"\"))) for m in messages),\n    })\n\n    try:\n        response = client.messages.create(\n            model=model,\n            max_tokens=1024,\n            system=system,\n            messages=messages,\n        )\n\n        latency_ms = (time.time() - start_time) * 1000\n        usage = response.usage\n\n        # 從回應 headers 取得 Anthropic 的 request ID（用於聯繫 support）\n        anthropic_request_id = response._request_id  # SDK 中的 request ID\n\n        logger.info(\"api_request_succeeded\", extra={\n            \"request_id\": request_id,\n            \"anthropic_request_id\": anthropic_request_id,\n            \"feature\": feature,\n            \"model\": model,\n            \"latency_ms\": round(latency_ms),\n            \"input_tokens\": usage.input_tokens,\n            \"output_tokens\": usage.output_tokens,\n            \"cache_read_tokens\": getattr(usage, 'cache_read_input_tokens', 0),\n            \"cache_creation_tokens\": getattr(usage, 'cache_creation_input_tokens', 0),\n            \"stop_reason\": response.stop_reason,\n        })\n\n        return response\n\n    except Exception as e:\n        latency_ms = (time.time() - start_time) * 1000\n        logger.error(\"api_request_failed\", extra={\n            \"request_id\": request_id,\n            \"feature\": feature,\n            \"model\": model,\n            \"latency_ms\": round(latency_ms),\n            \"error_type\": type(e).__name__,\n            \"error_message\": str(e)[:200],  # 截斷，避免 log 過大\n        })\n        raise\n```\n\n## OpenTelemetry 整合\n\n結構化 logging 只是開始。在生產環境，你還需要**分散式追蹤（Distributed Tracing）**——尤其是當你的 AI 功能只是更大系統的一部分時。\n\n```python\nfrom opentelemetry import trace\nfrom opentelemetry.sdk.trace import TracerProvider\nfrom opentelemetry.sdk.trace.export import BatchSpanProcessor\nfrom opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter\nimport anthropic\n\n# 設定 OpenTelemetry\nprovider = TracerProvider()\notlp_exporter = OTLPSpanExporter(endpoint=\"http://otel-collector:4317\")\nprovider.add_span_processor(BatchSpanProcessor(otlp_exporter))\ntrace.set_tracer_provider(provider)\n\ntracer = trace.get_tracer(\"ai-service\")\nclient = anthropic.Anthropic()\n\ndef answer_question(user_question: str, context: str, user_id: str) -> str:\n    \"\"\"帶有 OpenTelemetry 追蹤的 API 呼叫\"\"\"\n    with tracer.start_as_current_span(\"claude_api_call\") as span:\n        # 設定 span 屬性（不包含個人資訊）\n        span.set_attribute(\"ai.model\", \"claude-3-5-sonnet-20241022\")\n        span.set_attribute(\"ai.feature\", \"question_answering\")\n        span.set_attribute(\"user.id\", user_id)\n\n        try:\n            response = client.messages.create(\n                model=\"claude-3-5-sonnet-20241022\",\n                max_tokens=1024,\n                system=\"你是一個有幫助的助手。\",\n                messages=[{\"role\": \"user\", \"content\": user_question}]\n            )\n\n            # 記錄結果 metrics\n            usage = response.usage\n            span.set_attribute(\"ai.input_tokens\", usage.input_tokens)\n            span.set_attribute(\"ai.output_tokens\", usage.output_tokens)\n            span.set_attribute(\"ai.stop_reason\", response.stop_reason)\n\n            return response.content[0].text\n\n        except Exception as e:\n            span.record_exception(e)\n            span.set_status(trace.StatusCode.ERROR, str(e))\n            raise\n```\n\n## Latency 監控\n\nAPI 延遲是用戶體驗的核心指標。claude-3-5-sonnet 的典型延遲在 1-10 秒之間，視 prompt 長度和輸出長度而定。\n\n你應該監控 P50（中位數）、P95（第 95 百分位）、P99——不只是平均值。平均值會隱藏問題，P99 才能反映最差的用戶體驗。\n\n```python\nimport time\nfrom collections import deque\nimport threading\nimport statistics\n\nclass LatencyTracker:\n    def __init__(self, window_size: int = 1000):\n        self.latencies = deque(maxlen=window_size)\n        self.lock = threading.Lock()\n\n    def record(self, latency_ms: float):\n        with self.lock:\n            self.latencies.append(latency_ms)\n\n    def get_percentiles(self) -> dict:\n        with self.lock:\n            if not self.latencies:\n                return {}\n            sorted_latencies = sorted(self.latencies)\n            n = len(sorted_latencies)\n            return {\n                \"p50\": sorted_latencies[int(n * 0.50)],\n                \"p95\": sorted_latencies[int(n * 0.95)],\n                \"p99\": sorted_latencies[int(n * 0.99)],\n                \"max\": sorted_latencies[-1],\n                \"count\": n,\n            }\n\nlatency_tracker = LatencyTracker()\n```\n\n如果你的 P99 延遲突然從 5 秒跳到 30 秒，這是一個嚴重的信號——可能是 Anthropic 服務有問題，或是你的 prompt 突然變長了。\n\n## Fallback 策略：降級到更便宜的模型\n\n當 Sonnet 不可用或響應過慢，降級到 Haiku 是一個有效的 fallback：\n\n```python\nimport anthropic\nfrom typing import Optional\n\nclient = anthropic.Anthropic()\n\nMODELS = {\n    \"primary\": \"claude-3-5-sonnet-20241022\",\n    \"fallback\": \"claude-3-5-haiku-20241022\",\n}\n\ndef create_message_with_fallback(\n    messages: list,\n    system: str,\n    max_tokens: int = 1024,\n    timeout: float = 30.0\n) -> tuple[anthropic.Message, str]:\n    \"\"\"先嘗試主要模型，失敗則降級\"\"\"\n\n    for model_tier, model in MODELS.items():\n        try:\n            response = client.messages.create(\n                model=model,\n                max_tokens=max_tokens,\n                system=system,\n                messages=messages,\n                timeout=timeout,\n            )\n            if model_tier == \"fallback\":\n                logger.warning(f\"使用了 fallback 模型: {model}\")\n            return response, model_tier\n\n        except anthropic.InternalServerError as e:\n            if model_tier == \"primary\":\n                logger.warning(f\"主要模型 {model} 不可用，嘗試 fallback\")\n                continue\n            else:\n                raise  # fallback 也失敗了，真的出問題了\n\n        except anthropic.APITimeoutError:\n            if model_tier == \"primary\":\n                logger.warning(f\"主要模型 {model} 超時，嘗試 fallback\")\n                continue\n            else:\n                raise\n```\n\n## 生產環境 10 項 Checklist\n\n在部署你的 AI 應用到生產環境之前，確認這 10 項：\n\n**[1] API Key 安全**\n\n- [ ] API Key 存放在環境變數或 Secret Manager，不在程式碼中\n- [ ] 不同環境使用不同的 API Key\n- [ ] `.gitignore` 有排除 `.env` 檔案\n\n**[2] 錯誤處理**\n\n- [ ] 所有 API 呼叫都有 try/except\n- [ ] 429 錯誤有指數退避重試\n- [ ] 4xx 客戶端錯誤不重試\n- [ ] 錯誤訊息對用戶友好（不暴露技術細節）\n\n**[3] Rate Limit 管理**\n\n- [ ] 了解你帳戶的 RPM 和 TPM 限制\n- [ ] 有機制監控目前的使用量\n- [ ] 有 queue 或 semaphore 控制最大並發請求數\n\n**[4] Timeout 設定**\n\n- [ ] 所有請求都有設定合理的 timeout\n- [ ] Streaming 請求有設定 connection timeout\n\n**[5] Logging**\n\n- [ ] 每次 API 呼叫都記錄延遲和 token 使用量\n- [ ] 錯誤有完整的 context 資訊\n- [ ] Log 中不包含 PII 或 API Key\n\n**[6] 監控和告警**\n\n- [ ] P95/P99 延遲有告警\n- [ ] 錯誤率有告警\n- [ ] API 費用有告警（設定每日/每月上限）\n\n**[7] 輸入驗證**\n\n- [ ] 用戶輸入有長度限制（防止 context window 爆炸）\n- [ ] 有基本的輸入清理\n\n**[8] Fallback 機制**\n\n- [ ] 主要模型不可用時有降級方案\n- [ ] 完全降級時有友好的錯誤提示\n\n**[9] 成本控制**\n\n- [ ] 啟用 Prompt Caching（如果適合）\n- [ ] max_tokens 設定合理的上限\n- [ ] 有成本監控 dashboard\n\n**[10] 運作驗證**\n\n- [ ] 有 health check endpoint（確認 Anthropic API 可達）\n- [ ] 有 staging 環境，部署前先驗證\n- [ ] 有 rollback 計劃\n\n完成這十項，你的 AI 應用才算真正準備好面對生產環境的挑戰。\n\n---\n\n這本書的前十四章，我們從最基礎的 Messages API 一路走到 multi-agent 系統、MCP Server、成本優化、生產部署。現在是最後一章——把所有這些拼在一起，建立一個真實的、可以部署的 AI 客服系統。",
      "summary": "開發環境跑得好，不代表生產環境沒問題。本章全面解析 Rate Limits、指數退避、錯誤分類、API Key 安全管理、Logging 策略，以及 OpenTelemetry 整合——附 10 項生產環境部署 checklist。",
      "image": "https://bobochen.dev/_astro/cover.Bn4ECRom.webp",
      "date_published": "2026-05-29T00:00:00.000Z",
      "tags": [
        "Claude API",
        "生產環境",
        "錯誤處理",
        "可觀測性",
        "Rate Limit"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/competition-vs-dense-ranking-map-labels/",
      "url": "https://bobochen.dev/blog/competition-vs-dense-ranking-map-labels/",
      "title": "競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計",
      "content_text": "7,748 個村里，最大排名居然不是 7,748？做 TaxMap-TW 排名功能才發現「排名」有 5 種演算法，差別都在同分怎麼處理。比較 Competition、Dense、Standard 等的適用情境，說明地圖標籤為什麼選 Competition Ranking。",
      "content_html": "[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 的每個村里詳細頁都顯示「全國第 #58 / 7,748」這種排名。\n\n我以為這是 5 分鐘的事：\n\n```typescript\nsorted.sort((a, b) => b.value - a.value);\nconst rank = sorted.findIndex(v => v.code === target.code) + 1;\n```\n\n寫完跑一下 verify 腳本，發現一個怪事：\n\n```text\nMax national rank == village count (7,748): expected 7748, got 7744 ✗\n```\n\n最大排名是 **7,744**，不是 7,748。為什麼？\n\n挖下去發現「排名」其實有 5 種演算法，差別都在「同分怎麼處理」。這篇整理 5 種 ranking 演算法、選擇邏輯、以及為什麼地圖視覺化選 Competition Ranking。\n\n## 排名為什麼會少 4 名？\n\n先確認問題：\n\n```typescript\nconst lowest = Object.entries(r.rankings)\n  .map(([k, v]) => ({ key: k, rank: v.median.national.rank }))\n  .sort((a, b) => b.rank - a.rank)\n  .slice(0, 5);\n// [\n//   { key: '新北市|石碇區|碧山里', rank: 7744 },\n//   { key: '宜蘭縣|大同鄉|土場村', rank: 7744 },\n//   { key: '澎湖縣|馬公市|新復里', rank: 7744 },\n//   { key: '臺南市|南區|荔宅里', rank: 7743 },\n//   { key: '新北市|瑞芳區|碩仁里', rank: 7742 },\n// ]\n```\n\n3 個村里**並列 7744 名**。他們的中位數所得都是 0（沒有任何納稅單位申報）。\n\n照「直觀」排名應該是 7746、7747、7748 — 但實際是 7744、7744、7744。\n\n這就是 **Competition Ranking** 在「並列最後一名」時的行為：**並列共享一個排名，剩下的名次被「跳過」**。\n\n## 5 種 ranking 演算法\n\n維基百科把排名演算法分成 5 種。用 `[100, 90, 90, 80, 70]` 當範例：\n\n### 1. Standard Ranking（也稱 Ordinal Ranking，1, 2, 3, 4, 5）\n\n> 維基百科把這個序列正名為 Ordinal ranking。我這裡沿用程式裡寫的 Standard 名稱，但它對應的就是維基的 Ordinal。\n\n```typescript\nsorted.indexOf(target) + 1; // 比物件參考而非值，同分也各自拿到唯一名次\n```\n\n簡單按 index 排，每個人都拿到唯一名次、永遠剛好 1 到 N。代價是同分時誰排前面取決於 sort 的細節 —— 如果你只用 `value` 排，同一個值可能拿到 2 或 3，結果不穩定。\n\n❌ 沒定義 tie-breaker 時不適合公開展示，因為名次會跟著排序實作飄。\n✅ 但只要補一個次要排序鍵（村里代碼、名稱字典序），它就變 deterministic，而且是「需要強制唯一名次」時的正解 —— 像分頁、產生穩定的 URL、做有序匯出，你就是不想看到並列。我自己的看法：Ordinal 不是壞演算法，只是它的「壞」全來自你沒把 tie-breaker 想清楚。\n\n### 2. Competition Ranking (1, 2, 2, 4, 5)\n\n同分共享名次，**下一個名次跳過**（\"olympic ranking\"）。\n\n```typescript\nfunction competitionRank(entries: { key: string; value: number }[]) {\n  const sorted = [...entries].sort((a, b) => b.value - a.value);\n  const ranks = new Map<string, number>();\n  let lastValue: number | null = null;\n  let lastRank = 0;\n  for (let i = 0; i < sorted.length; i++) {\n    const { key, value } = sorted[i];\n    const rank = value === lastValue ? lastRank : i + 1;\n    ranks.set(key, rank);\n    lastValue = value;\n    lastRank = rank;\n  }\n  return ranks;\n}\n```\n\n✅ 公平、直覺、廣泛使用於體育（奧運、F1）。\n❌ 最大排名可能 < N（並列尾巴會壓縮）。\n\n### 3. Modified Competition Ranking (1, 3, 3, 4, 5)\n\n同分共享，**但跳過的是「前面」**（同分組共享後面的名次）。\n\n```text\n[100, 90, 90, 80, 70]\n  1    3    3    4    5\n```\n\n✅ 跟 Competition 一樣公平，但對「並列第二」的視覺感更強（不是 2、2，而是 3、3）。\n❌ 直覺上有點怪，少用。\n\n### 4. Dense Ranking (1, 2, 2, 3, 4)\n\n同分共享，**下一個名次不跳過**。\n\n```typescript\nfunction denseRank(entries: { key: string; value: number }[]) {\n  const sorted = [...entries].sort((a, b) => b.value - a.value);\n  const ranks = new Map<string, number>();\n  let lastValue: number | null = null;\n  let currentRank = 0;\n  for (const { key, value } of sorted) {\n    if (value !== lastValue) currentRank++;\n    ranks.set(key, currentRank);\n    lastValue = value;\n  }\n  return ranks;\n}\n```\n\n✅ 最大排名 = 唯一值的數量。「全國第 X 級」這種概念上更貼。\n❌ 不直覺：你拿第 5 名可能其實是第 10 個人（前面有人並列）。\n\nDense 不是 Competition 的次等品，它有自己最對味的場景：**當重複值很多、或你想傳達的是「分到第幾級」而不是「絕對名次」時，Dense 更合適**。比方價格分 A/B/C/D 級、遊戲段位、或像我這個案例如果中位數所得只切成 5 段級距，Dense 給的「第幾級」反而比 Competition 的稀疏名次更好讀。我自己的取捨很簡單：使用者腦中想的是「我在第幾名」就用 Competition，是「我在哪一級」就用 Dense。\n\n### 5. Fractional Ranking (1, 2.5, 2.5, 4, 5)\n\n同分組分享「平均排名」。\n\n```text\n[100, 90, 90, 80, 70]\n  1   2.5  2.5  4    5\n```\n\n✅ 統計學正統（用於 Mann-Whitney U test 等非參數檢定）。\n❌ 出現小數，user-facing 不好看（「你是第 2.5 名」🤔）。\n\n## SQL 對照\n\nSQL 有對應的 window function，方便理解：\n\n| 演算法 | SQL Window Function |\n|--------|---------------------|\n| Standard | `ROW_NUMBER()` |\n| Competition | `RANK()` |\n| Modified Competition | （無原生支援）|\n| Dense | `DENSE_RANK()` |\n| Fractional | （無原生支援）|\n\n如果你用 PostgreSQL / MySQL，`RANK()` 就是 Competition Ranking。\n\n## TaxMap-TW 為什麼選 Competition Ranking\n\n對「展示給使用者看」這個場景，候選是 Competition vs Dense。我選了 Competition：\n\n**1. 跟體育、考試成績一致**\n\n讀者看到「第 58 名 / 7,748」會自動套用「奧運排名」的直覺。這是 Competition Ranking 的語意。\n\n**2. 同分共享給予正確的「相對位置」**\n\n如果 3 個村里中位數都是 0，他們的「位置」就是相同的，應該共享同一個排名。Dense Ranking 反而會說他們是「第 N 級」，但這層抽象對地圖讀者意義不大。\n\n**3. 「跳過名次」這件事其實 OK**\n\nverify 腳本第一次失敗讓我以為這是 bug，但仔細想：**沒有「第 7745 名、7746 名、7747 名」這 3 個位置是合理的** — 因為有 3 個人並列 7744。\n\n我把 verify 腳本的斷言從 `===` 改成 `<=`：\n\n```typescript\ncheck(\n  `${y} max national rank ≤ village count (${count})`,\n  maxRank <= count,\n);\n```\n\n**Competition 的代價我也認**：最大排名 < N 這件事，第一眼確實會讓人懷疑「是不是漏算了」（我自己就被 verify 腳本嚇到一次）；而且如果底部並列的村里很多 —— 像有一大票里中位數都是 0 —— Competition 會把它們全壓到同一個名次，名次的尾段等於被「壓扁」，看不出彼此差異。我覺得在這個案例可以接受，是因為那批所得 0 的村里本來就「沒有差異可言」，給它們同一名次反而誠實；要是換成一份重複值很密、又需要在尾段分出高下的資料，我就會回頭選 Dense。\n\n## 跨年「比去年變動」的計算\n\n排名穩定後，下一個需求是「比去年上升 / 下降幾名」（YoY delta）：\n\n```typescript\nconst deltaYoY = previousYearRank - currentYearRank;\n// 正值 = 名次上升（rank 數字變小）\n// 負值 = 名次下降\n// 0 = 持平\n// null = 去年無資料\n```\n\n注意正負號：rank 是「越小越好」，所以 delta = 去年 - 今年。\n\n實際範例（中華里 2022 vs 2021）：\n\n```text\n2021 全國中位數第 85 名\n2022 全國中位數第 58 名\nDelta = 85 - 58 = +27（上升 27 名）\n```\n\nUI 顯示：↑ 27（綠色 chip）。\n\n**邊界情況**：\n\n- 去年該村里無資料 → delta = null（顯示 \"—\"）\n- 今年該村里無資料 → 不顯示卡片\n- delta = 0 → 顯示「持平」（不是 ↑0 也不是 ↓0）\n\n## 排名 vs 百分位 — 兩個都要\n\n地圖視覺化常會搞混「排名」和「百分位」這兩個概念：\n\n**排名**（rank）：絕對位置，用於展示\n\n> 你的村里中位數所得 95 萬，全國第 58 名 / 7,748\n\n**百分位**（percentile）：分布位置，用於色階分級\n\n> 你的村里中位數所得 95 萬，落在全國第 80 百分位（前 20% 的村里）\n\n我一開始想偷懶，打算用排名直接驅動色階 —— 結果地圖糊成一片才想起這兩個根本不能混用：\n\n- 詳細頁的大數字 → 用排名\n- 地圖上的色塊 → 用百分位（quintile 分 5 級對應 5 種顏色）\n\n百分位適合視覺化的理由是 **單調性**：第 50 百分位以下的村里色塊都比第 50 以上淺。但如果用排名，第 100 名和第 7000 名的色塊差異會被視覺壓縮（因為 1-7748 是線性的，但人眼的「淺到深」感受不是線性）。\n\n不過用 quantile（等樣本數分桶）分級也不是沒代價：它保證每一級村里數量相等，但會把「值很接近、卻剛好跨桶邊界」的兩個村里塗成不同顏色，也會把「值差很多、卻擠在同一桶」的村里塗成同色 —— 所得這種長尾分布尤其明顯，最高那一桶可能從 200 萬一路涵蓋到 500 萬。所以分桶到底用 quantile 還是改用絕對門檻，本身就是另一個決策，我在色階那篇有完整討論。\n\n色階分級的詳細討論在 [這篇色階文章](/blog/data-map-color-scale-viridis-ylgnbu)；至於最後我為什麼把色階從 viridis 換成 OrRd、怎麼把分桶和選色拆成兩個獨立決策，寫在 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。\n\n## 反思\n\n本來估 5 分鐘搞定的功能，最後花了一個晚上讀維基百科的 ranking 條目、再回頭把 verify 腳本改一遍。讓我意外的不是排名很難，而是它根本不是一件事 —— `sort` 完寫個 index 的時候，我以為「排名」就是排名，完全沒意識到自己已經默默選了 Ordinal、還沒處理同分。\n\n這種「以為是常識、其實是默認選擇」的感覺很熟悉，做色階分級那次也一模一樣：當時把資料丟進 quantile 分 5 級就覺得理所當然，後來才發現「要不要等樣本數分桶」本身就是個有後果的決定。兩次都是同一個教訓 —— 看起來最沒爭議的步驟，往往藏著你沒注意到自己做了的選擇。也因為這樣，我現在會刻意把「展示給使用者的數字」和「拿去算的數字」分開：詳細頁的大數字用 rank 求直覺，地圖色塊用 percentile 求單調，硬要共用一個 metric 只會兩邊都將就。\n\n最有趣的觀察是 SQL window function 命名：`RANK()` 是 Competition，`DENSE_RANK()` 才是 Dense。也就是 SQL 標準把沒前綴的 `RANK()` 對應到 Competition。當然這不代表所有領域都這樣 —— 統計檢定預設用 Fractional、有些排行榜要的是 Dense —— 但至少在資料庫的世界，「排名」沒特別說明時通常指 Competition。\n\n## 系列其他文章\n\n- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)\n- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)\n- → [從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)\n- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n- → [為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」](/blog/orrd-warm-palette-two-decisions-framework)\n- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)",
      "summary": "7,748 個村里，最大排名居然不是 7,748？做 TaxMap-TW 排名功能才發現「排名」有 5 種演算法，差別都在同分怎麼處理。比較 Competition、Dense、Standard 等的適用情境，說明地圖標籤為什麼選 Competition Ranking。",
      "image": "https://bobochen.dev/_astro/cover.DhzV_Pt2.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "資料視覺化",
        "演算法",
        "Ranking",
        "SQL",
        "TypeScript"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/data-map-color-scale-viridis-ylgnbu/",
      "url": "https://bobochen.dev/blog/data-map-color-scale-viridis-ylgnbu/",
      "title": "資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南",
      "content_text": "資料地圖（Choropleth）該用哪種色階？做台灣所得稅 TaxMap 時原本想用 viridis，最後換成 ColorBrewer 的 YlGnBu + Jenks 自然斷點。整理 7 種主流連續色階比較、為什麼紅綠對比是地雷、長尾分布怎麼分級、以及 opacity × 基底圖的隱藏陷阱。",
      "content_html": "最近在做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)：一張顯示全台灣 7,747 個村里所得稅統計的互動地圖。要為每個 polygon 上色時，第一個問題就讓我卡了一小時：\n\n**「Choropleth 地圖該用哪種色階？」**\n\n我以為這是 30 秒就能決定的設計問題。結果發現裡面有色盲議題、ColorBrewer 業界標準、長尾分布陷阱、以及跟基底圖的 opacity 互動，每一個都是踩過才知道的坑。\n\n這篇整理那一小時學到的東西。\n\n## 資料地圖的色階到底在解什麼問題\n\nChoropleth（音「克羅羅普列斯」，沒人念得對）就是「**用顏色表達數值**」的地圖：每個區域填一個顏色，深淺對應到該區的某個統計量。\n\n聽起來很簡單，但你的每一個選擇都在影響讀者：\n\n- **色相**（hue）：用紅、藍、綠表達不同的情緒\n- **明度漸層**：表達數值大小\n- **分級方式**：把連續數值切成幾個顏色區間\n- **透明度**：跟下層地圖怎麼疊\n- **色階方向**：低→高還是高→低\n\n每個選擇都可能讓讀者看到「不存在的趨勢」或「忽略真實的差異」。這不是美感問題，是**會不會誤導讀者**的問題。\n\n## 七種主流色階快速比較\n\n直接拿我整理出的比較表：\n\n| 色階 | 視覺風格 | 適合場景 | 陷阱 |\n|------|---------|---------|------|\n| **Viridis** | 紫→藍→綠→黃 | 學術、中性嚴肅 | 不夠吸睛 |\n| **YlGnBu** | 淺黃→綠→深藍 | 公部門報告、所得 / 教育 | 深色端容易跟水體混 |\n| **YlOrRd** | 黃→橘→紅 | 媒體、社群、視覺衝擊 | 紅色易被解讀為「壞」 |\n| **Plasma / Magma** | 黑→紫→紅→黃 | 深色模式、有質感 | 需要深底搭配 |\n| **Greys** | 淺灰→深灰 | 極簡、副地圖 | 訊息密度低 |\n| **Red-Green** | 紅→黃→綠 | ❌ 不推薦 | 8% 男性紅綠色盲無法區分 |\n| **Diverging (RdBu)** | 紅←白→藍 | 雙向偏離（如「相對於平均」） | 不適合單向資料 |\n\n前五個是 **sequential（單向）色階**，適合「越多越深」這種單方向遞增的資料；最後一個是 **diverging（雙向）色階**，適合表達相對於某個中心點的偏離（例如選舉得票率相對於 50%）。\n\n選錯類型會給錯誤的暗示——例如用 diverging 的紅藍色階畫所得，會讓讀者以為「藍色那邊比紅色那邊好或壞」，但其實只有「多」跟「少」。\n\n## Viridis：學術派的最愛\n\nViridis 是 Python `matplotlib` 在 2015 年導入的色階，後來變成科學論文的視覺標配。它的三個特性：\n\n1. **感知均勻（perceptually uniform）**：你眼睛看到的「色差」跟資料本身的「數值差」是線性對應的\n2. **色盲友善**：deuteranopia（綠色盲）跟 protanopia（紅色盲）讀者也能正確分辨\n3. **黑白列印也能用**：因為亮度本身就是漸層\n\n姊妹色階 plasma、inferno、magma 也都有這三個特性，只是色相不同。如果你不知道該選什麼，預設用 viridis 不會錯。\n\n但 viridis 不是沒有代價。對一般民眾來說它看起來有點「太學術」，而且整條色帶偏暗——低值端是深紫，疊到淺色底圖上反而比高值端的黃還搶眼，跟「越多越深」的直覺剛好相反，第一次看的人容易把低值區誤判成重點。如果你的網站受眾是公務員、媒體、社群讀者，YlGnBu 或 YlOrRd 這種低值夠淺、明度從淺到深單調遞增的色階，會更符合他們的視覺習慣。\n\n## YlGnBu：ColorBrewer 的經典\n\n[ColorBrewer](https://colorbrewer2.org/) 是 Penn State 的 Cynthia Brewer 教授在 2002 年釋出的色階庫，原本是給地圖設計師用的，現在幾乎所有 GIS / 視覺化工具（D3.js、Leaflet、Tableau、QGIS）都內建了它。\n\nColorBrewer 提供三類色階：\n\n- **Sequential**：YlGn、YlOrRd、Blues、Reds、Greens 等，適合單向遞增\n- **Diverging**：RdBu、PiYG、BrBG 等，適合雙向偏離\n- **Qualitative**：Set1、Pastel1 等，適合類別資料（無序）\n\nYlGnBu（黃→綠→藍）是 sequential 類別裡最常被選用的之一，理由是：\n\n- 黃色端夠亮、藍色端夠深，**對比範圍大**\n- 沒有用紅色，**避免「紅 = 壞」的情緒誤導**\n- 公部門報告書的視覺習慣，給人「冷靜、客觀、財經」的感覺\n\n我最後選 YlGnBu 的原因，就是「所得稅地圖」這個題材本身就需要冷靜感。如果是傳染病熱點圖，我會改用 YlOrRd 來強調「警示」。\n\n## 為什麼紅綠色階是地雷\n\n`#ff0000` 紅 → `#00ff00` 綠，這是直覺裡「**壞→好**」的對比。很多人做地圖第一個會想到的就是這個配色。\n\n但根據統計，**全球約 8% 的男性是紅綠色盲**（女性約 0.5%）。他們看到的紅色跟綠色都會變成偏黃褐色，幾乎無法區分。\n\n這代表如果你的地圖讀者裡每 12 個男生就有 1 個看不出來，你做的所有視覺化都失效了。\n\n更糟的是：紅綠色階的「紅 = 壞」是西方文化習慣。在台灣，紅色常代表喜慶；中國股市則「紅漲綠跌」。**同樣顏色在不同文化代表相反意義**，這也是要避開的坑。\n\n說到底，紅綠最大的問題不是「紅綠」這兩個顏色本身，而是它**只靠色相去編碼數值、明度卻幾乎沒變**——色盲讀者一旦分不出色相，就什麼資訊都拿不到。真正該守的原則是：不要只靠色相編碼，要保證**明度單調遞增**，這樣就算把地圖印成黑白、或讀者是色盲，深淺順序還是讀得出來。如果你真的需要雙向表達（高於平均 / 低於平均），也不必硬用紅綠——ColorBrewer 的 RdYlBu、BrBG 這類 diverging 色階就是設計成色盲安全的，兩端明度也夠分。\n\n**簡單原則：除非你有非常強的理由（例如就是要表達 +/− 雙向），否則 sequential 色階不要用紅綠對比；要雙向就挑色盲安全的 diverging。**\n\n## 分級方法：等距分級會殺死你的資料\n\n選好色階後還有第二個決定：怎麼把連續數值切成 5 個顏色區間？常見方法：\n\n| 方法 | 切法 | 適用 |\n|------|------|------|\n| **等距（Equal Interval）** | 從 min 到 max 平均切 | 均勻分布的資料 |\n| **分位數（Quantile）** | 每級剛好佔 20% | 想讓地圖顏色均勻 |\n| **自然斷點（Jenks）** | 演算法找資料的群聚邊界 | 學術正統、保留分布 |\n| **標準差** | ±1σ、±2σ 切 | 突顯異常值 |\n| **手動斷點** | 自訂門檻 | 有編輯觀點 |\n\n我原本想用等距分級，「最公平、最直覺」嘛。但攤開台灣所得稅資料一看：\n\n- 台北市松山區中華里中位數：**98.4 萬元**（全國前段）\n- 偏鄉村里中位數：**30-40 萬元**\n\n2025 年公布的 112 年度統計裡，台北松山中華里以**平均所得 526.6 萬元**登頂全台最富里，擠下蟬聯 5 年榜首的新竹關新里。但要注意 526.6 萬是「平均數」，會被里內極少數高所得家戶整個拉上去——同一個中華里的「中位數」其實只有 98.4 萬。一個里內 mean 比 median 高出五倍，本身就是長尾的訊號。\n\n這就是經典的**長尾分布**。如果你用等距分級，把上限拉到那種平均破 500 萬的村里，那 90% 的村里就會全部擠在最低色階——整張地圖看起來就是「一片米黃 + 一個刺眼藍點」，幾乎沒有區分度。\n\n**這是台灣公開資料視覺化的經典踩坑。**\n\n解法是 **Jenks 自然斷點**：演算法會找出資料的「天然群聚邊界」，讓組內變異最小、組間變異最大。視覺上既不會被尾部極端值搞爛、也不會被分位數的均勻切法掩蓋分布本身的特徵。\n\n但 Jenks 不是無代價的勝利。它的斷點是「跟著這份資料算出來的」，所以換一份資料就會跳——我做單一年度的靜態地圖沒事，但如果你要做跨年比較、或資料會定期更新，每次重算斷點會讓同一個值的顏色一直變，讀者沒辦法把兩張地圖疊著看；斷點也常落在像 47.3 萬這種難讀的非整數上，圖例不好寫；資料量大時演算法本身也吃計算資源。需要跨年比較或動態更新時，我反而會改用一組固定的手動斷點，犧牲一點貼合度換「同一個值永遠同一個顏色」。\n\n順帶一提，被我跳過的**分位數**也不是只有缺點。它保證每一級剛好塞進 1/5 的村里，每個顏色都有足夠樣本、不會出現某一級全空，而且因為切法跟絕對數值脫鉤，反而最適合講「相對排名」（你家這個里贏過全台幾成）。它的代價是會把分布的形狀抹平——明明差很多的兩個里可能同色、差一點點的卻被切到不同級。所以這比較像「想突顯分布形狀就 Jenks、想講相對位置就分位數」的取捨，不是誰絕對贏。\n\nColorBrewer + Jenks 是我這次的選擇，QGIS 內建分級和 Axis Maps 的 cheatsheet 也都把這組當預設起點。\n\n## 真實踩到的坑：opacity × 基底圖\n\n色階跟分級都選定了，本以為大功告成。結果 MapLibre 跑起來——polygons 看不見。\n\n我用的是 [OpenFreeMap](https://openfreemap.org) 的 positron 主題：一張極淺的灰底地圖。fill-opacity 設 0.7（直覺值），結果 YlGnBu 的淺色端（`#ffffcc` 黃白）幾乎跟灰底融成一體，深色端（`#253494` 海軍藍）又跟海面顏色撞色——這正是前面比較表裡標的「YlGnBu 深色端容易跟水體混」那個陷阱，被我親身撞上了。\n\n我沒有因為這個陷阱就放棄 YlGnBu，因為「冷靜客觀」對所得題材還是太合適。比較划算的是直接緩解撞色：把 positron 底圖的水體圖層換成更淺或偏灰的顏色、跟深藍拉開明度差，再幫每個 polygon 加一條極細的白色邊框，深色里之間就不會糊在一起、也不會跟海連成一片。fill-opacity 最後拉到 **0.85** 才整體看得清楚——但又會壓掉一些路名標籤。這就是真實場景才會踩到的互動：色階 × 基底圖 × opacity 是綁在一起的，不能分開選。\n\n**經驗法則**：\n\n- 淺底圖（positron / light）：fill-opacity 0.8–0.9\n- 深底圖（dark matter）：用 plasma / magma + opacity 0.7\n- 衛星圖底：避免低明度色階，用 viridis 反而清楚\n\n## 反思\n\n弄了一小時下來，最大的轉變是我終於不再把色階當「挑哪個比較好看」的事。實際做一張會被別人看的地圖才懂，每個選擇都在無聲地對讀者說「這裡多、那裡少、這裡異常、那裡正常」，選錯就是在誤導——它是「不要誤導」的問題，不是美感問題。\n\n至於怎麼選，我的順序是先抄 ColorBrewer 再依場景判斷例外。Cynthia Brewer 花了二十年讓那些色階通過色盲、印刷、感知測試，沒道理自己重調一遍；但「直接抄」也有抄不動的時候——要做連續漸層而不是分級、要配深色模式、或品牌色有指定，這幾種情況就得自己再調，ColorBrewer 只是個夠好的起點，不是終點。\n\n另一個只有真的跑過才知道的事，是「色階 × 基底圖 × opacity」綁在一起。設計稿上看好好的色階，疊到真實地圖才發現淺色端融進底圖、深色端撞水面，一定要在實際的 MapLibre / Leaflet 環境跑一次，光看 ColorBrewer 預覽不準。長尾資料則別用等距分級——台灣的所得、房價、人口、營收幾乎都是長尾，等距會把它們壓成「一片米色 + 幾個亮點」，用 Jenks 看分布形狀、用分位數看相對排名都比等距好。\n\n> 後記：這篇最後選的 YlGnBu，後來我又換掉了。做到比較後期、開始認真面對「一般民眾看得懂嗎」這件事，我把色階整個改成 OrRd（暖色、7 級、用對數絕對門檻分桶），原因和我怎麼把「選色階」這一個決策拆成兩個獨立的軸，寫在 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。所以這篇的結論請當成「當時的我」的選擇，不是定案。\n\n---\n\n完整的 TaxMap-TW 程式碼會放在 [GitHub repo](https://github.com/bobo52310/TaxMap-TW)。\n\n接下來其實還沒輪到 PMTiles。底圖本身要選哪家服務，是上色之前就得先決定的事——[下一篇](/blog/openfreemap-maptiler-base-map-comparison)會先比 OpenFreeMap、MapTiler、Mapbox 這幾個 Web 地圖底圖服務怎麼選。至於怎麼把這 7,747 個村里 polygon（簡化後 5.6 MB 的 GeoJSON）封裝成單檔 PMTiles、再用 HTTP Range 按需讀取，留到 [PMTiles 那篇](/blog/pmtiles-http-range-request-single-file-tiles)再細講。",
      "summary": "資料地圖（Choropleth）該用哪種色階？做台灣所得稅 TaxMap 時原本想用 viridis，最後換成 ColorBrewer 的 YlGnBu + Jenks 自然斷點。整理 7 種主流連續色階比較、為什麼紅綠對比是地雷、長尾分布怎麼分級、以及 opacity × 基底圖的隱藏陷阱。",
      "image": "https://bobochen.dev/_astro/cover.B1NMUiEQ.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "資料視覺化",
        "地圖",
        "MapLibre",
        "ColorBrewer",
        "色彩"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-latency-reliability-cost-tradeoffs/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-latency-reliability-cost-tradeoffs/",
      "title": "延遲、可靠性、成本：AI agent 系統設計的鐵三角",
      "content_text": "LLM app 還是個 distributed system，延遲、可靠性、成本這三件事會互相打架，你不可能三個都要到極致。談 streaming 降感知延遲、retry/timeout/circuit breaker、prompt 與結果快取、model routing（小模型優先）、token 成本治理，以及怎麼用工程手段在這個三角上做出有意識的取捨。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 10 篇（共 12 篇）。上一篇：[生產級 LLM 可觀測性與評估](/blog/enterprise-ai-agent-llm-observability-eval)。\n\n到這篇，前面講的所有東西要付帳了。RAG 多檢索幾份文件、reranking 多一次模型呼叫、multi-agent 多幾顆 agent、retry 多跑幾次——每一個讓系統「更好」的決定，都在延遲、可靠性、成本這三件事上劃下一刀。\n\n而這三件事，是一個**互相打架的鐵三角**：\n\n![延遲、可靠性、成本的鐵三角：三股力量互相拉扯，把中心同時往三個方向拉開——你不可能三個都拉到極致](./images/iron-triangle.webp)\n\n<p style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.9rem', lineHeight: 1.6, marginTop: '-0.4rem' }}>可靠性、延遲、成本互相拉扯的鐵三角：靠近任一角，另外兩角就被犧牲——三者無法同時拉到極致。</p>\n\n- 想**可靠** → 加 retry、加 fallback、用更強的模型 → 變慢、變貴。\n- 想**便宜** → 用小模型、少檢索、少 reasoning → 品質和可靠性下降。\n- 想**快** → 砍 reasoning、激進 cache、少檢索 → 可能犧牲正確性。\n\n你不可能三個都要到極致。Production 工程的價值，就是在這個三角上做出**有意識的取捨**，而不是預設「全都要最好」然後被帳單和延遲教訓。\n\n這也是我一直強調 LLM app 還是 distributed system 的原因——下面這些手段，做過後端的人會覺得無比熟悉。\n\n## 延遲：先區分「真延遲」和「感知延遲」\n\nLLM 天生慢，一個複雜回答跑好幾秒是常態。但使用者體感的「慢」，跟實際耗時是兩回事。\n\n**1. Streaming（串流）幾乎免費降感知延遲。**\n別等整個答案生成完才一次吐出來，邊生成邊吐字。實際總耗時沒變，但使用者「立刻看到東西在動」，體感快非常多。這是 CP 值最高的一招，幾乎沒有不做的理由。用工程語言講，streaming 壓的是 **TTFT（Time To First Token，首 token 延遲）**——聊天場景只要 TTFT 低於 1 秒，使用者就覺得「即時」；串流開始後順不順，則看 **TBT（token 之間的間隔）**。人對「按下送出到看到第一個字」的等待，遠比「字與字之間的間隔」敏感，所以該進你第 9 篇 dashboard、該設 SLO 的，是 TTFT，不是籠統的「總回應時間」。\n\n**2. 把能平行的就別串行。**\n如果一個任務要檢索三個不同來源，讓它們**同時跑**，而不是一個等一個。Agent runtime（第 2 篇）要支援這種平行。這是基本的並行思維，跟你優化任何後端流程一樣。\n\n**3. 用小模型處理快路徑。**\n不是每一步都要動用最強最慢的模型（下面 model routing 細談）。意圖分類、簡單抽取用快的小模型，把慢的大模型留給真正需要的那一步。\n\n## 可靠性：為「外部依賴一定會出包」設計\n\n你的 agent 依賴外部 LLM API，而那是一個**會逾時、會限流、會偶爾回垃圾、偶爾掛掉**的依賴。把它當成「一定會出包的外部服務」來設計，這又是後端的老功課：\n\n- **Timeout**：每次模型呼叫設合理逾時，別讓使用者無限等。\n- **Retry with backoff**：暫時性錯誤（限流、逾時）重試，但要退避、要設上限，別把對方打更慘、也別自己燒爆 token。\n- **Circuit breaker**：某個模型 / 供應商持續出錯，先「跳閘」停止打它，走 fallback，給它時間恢復。\n- **Fallback model**：主模型掛了或回垃圾，自動切備援（另一家供應商、或本地模型）。這也降低單一供應商的依賴風險。\n- **降級（graceful degradation）**：真的都不行時，老實回「現在無法處理，請稍後或轉人工」，而不是給一個爛答案。呼應第 1 篇的鴻溝六。\n\n注意這裡的張力：每加一層可靠性（retry、fallback），通常就**多一點延遲、多一點成本**。所以要分場景——關鍵動作值得多付，邊緣功能不必。\n\n關於 fallback 還有個坑要先講：跨供應商 fallback 沒有「一鍵切換、行為一致」那麼無痛。同一段 prompt 在 GPT、Claude、Gemini 上的輸出風格、JSON 遵循度、refusal 行為都不一樣；tool calling 的 schema 各家也不同；更別說等下要講的 prompt caching 是供應商綁定的——切到備援那一刻，前面省的快取全部失效，延遲跟成本反而往上跳。實務上只有兩條路：為主 / 備各自維護一套 prompt 與 parser，或把備援路徑當成「可接受的降級」來設計。別假設換家供應商，行為會一樣。\n\n## 成本：LLM 系統獨有的「浮動帳單」\n\n傳統服務成本相對固定（機器開著就那樣）。LLM 系統的成本是**按 token 浮動**的，而且會悄悄長大。第 9 篇我們已經把成本監控起來了，這篇講怎麼壓。\n\n**1. Model routing：小模型優先，必要才升級。**\n這是最大的省錢槓桿。把「要用哪個模型」抽成一層（第 2 篇的 Model Router），依任務難度路由：\n\n- 分類、抽取、格式轉換、簡單問答 → **便宜小模型**\n- 複雜推理、需要高品質的最終生成 → **大模型**\n\n光是把「不需要大模型的步驟」改用小模型，常常就能砍掉一大塊成本，而品質幾乎無感。\n\n有多大？拿 RouteLLM（UC Berkeley / Anyscale / Canva，ICLR 2025）的公開 benchmark 當參考：在偏對話的 MT-Bench 上，它能維持約 95% GPT-4 品質、成本卻砍掉約 85%。但別把這 85% 當保證值——同一個 router 換到偏推理的 benchmark，省幅就掉很多（MMLU 只省約 45%）。所以 routing 的省幅，本質上等於你流量裡「不需要大模型的那一塊」有多大：先量你自己的流量分布，再決定能省多少，別套別人的數字。\n\n**2. Cache：能不重算就不重算。**\n- **結果快取**：這裡其實藏了兩種完全不同難度的東西。**Exact cache**（把 query 正規化後當 key，同樣的問題回同樣的答案）幾乎零風險、該最先做；**semantic cache**（用 embedding 相似度命中「相似」問題）能多抓改寫過的重複，但有 false-positive 風險——兩個在向量空間很近的問題可能需要完全不同的答案，而且對的命中跟錯的命中相似度高度重疊，沒有通用安全閾值，閾值要訂多嚴取決於「答錯的代價」。生產數據也顯示 semantic cache 相對 exact 往往只多 5~8 個百分點命中率，未必抵得過它的複雜度與答錯風險。高風險場景就乖乖用 exact。（兩種都要注意時效和權限，別把 A 使用者的答案快取給 B。）\n- **Embedding 快取**：同一段文字別重複 embedding。\n- **Prompt 快取**：把穩定的內容（system prompt、few-shot 範例、長 context）放最前面、把每次都變的部分放最後，讓固定前綴可以被快取重用。但 2026 三家機制不一樣，這差異直接決定你有沒有真的省到：OpenAI、Gemini 是**自動命中**（前綴 ≥ 1,024 token 重複就生效，讀取省 50~90%、寫入不收費）；Anthropic 是**顯式 opt-in**，你得在 request 裡標 `cache_control` 斷點，cache read 只要基礎 input 價的約 0.1 倍（省 ~90%），但 cache write 要付約 1.25 倍溢價、預設 5 分鐘 TTL——換句話說 Claude 的快取要**至少命中一次才回本**。用 Claude 卻忘了標 `cache_control`＝你以為在省錢、其實一毛沒省，這是這招最常見的踩雷。\n\n**3. 管好 context 長度。**\nToken 成本跟 context 長度直接相關。第 7 篇講的記憶截斷 / 摘要、第 3 篇講的別塞太多 chunk，到這裡全都變成「省錢」的具體手段。**把不必要的東西塞進 context，是最常見的浪費。**\n\n**4. 設預算護欄。**\n單一請求、單一使用者、單一 multi-agent 任務（第 8 篇的成本爆炸風險）都要有 token / 花費上限。撞到上限就停下來或降級，別讓一個失控迴圈燒出一張嚇人的帳單。\n\n我做過 K8s 叢集那類的雲端成本優化，心法在這裡完全通用：**先讓成本可見（第 9 篇），找出最大的那塊，用最小的品質代價把它砍掉**。差別只是這次砍的不是 CPU/記憶體，是 token。\n\n## 把三角畫出來：依場景做取捨\n\n最後給一個務實的框架。不同功能在三角上的位置不該一樣：\n\n| 場景 | 優先 | 取捨 |\n|---|---|---|\n| 即時對話助理 | 延遲 | streaming、小模型快路徑、適度 cache，容忍偶爾要重問 |\n| 高風險決策 / 報告 | 可靠 + 正確 | 大模型、多檢索、critic 審查、HITL，接受慢和貴 |\n| 大量批次處理 | 成本 | 小模型、激進 cache、可離線慢慢跑，犧牲即時性 |\n| 內部低頻工具 | 成本 + 簡單 | 別過度工程，能跑就好 |\n\n關鍵不是背這張表，是養成一個習慣：**每設計一個 agent 功能，先問它在這三角的哪個角，然後刻意往那邊取捨**——而不是無意識地全都要最好，最後三個都普普、帳單還很貴。\n\n## 小結\n\n- 延遲、可靠性、成本是會互相打架的鐵三角，**不可能三個都極致**。\n- **延遲**：streaming 降感知延遲、平行化、小模型走快路徑。\n- **可靠性**：把 LLM API 當「一定會出包的外部依賴」——timeout、retry、circuit breaker、fallback、降級。\n- **成本**：model routing 是最大槓桿，加上 cache、管好 context、設預算護欄。\n- 每個功能依它在三角的位置**刻意取捨**，這份判斷力就是 production 系統工程師的核心價值。\n\n這些手段你會發現沒一個是 AI 玄學，全是分散式系統的硬功夫換了對象。下一篇，我們把第 5、6、9 篇散落的安全與信任機制，收斂成一套完整的**Agent 治理框架**——讓企業敢把這套系統接到真實業務上。\n\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"延遲、可靠性、成本的系統權衡\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[生產級 LLM 可觀測性與評估](/blog/enterprise-ai-agent-llm-observability-eval)\n- [Agentic Engineering：成本優化](/blog/agentic-engineering-cost-optimization)——agent 工作流的 token 成本怎麼省\n- 下一篇：《Agent 治理框架：RBAC、audit log、HITL、tool registry》",
      "summary": "LLM app 還是個 distributed system，延遲、可靠性、成本這三件事會互相打架，你不可能三個都要到極致。談 streaming 降感知延遲、retry/timeout/circuit breaker、prompt 與結果快取、model routing（小模型優先）、token 成本治理，以及怎麼用工程手段在這個三角上做出有意識的取捨。",
      "image": "https://bobochen.dev/_astro/cover.VPcuaszI.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "系統設計",
        "成本優化",
        "延遲",
        "可靠性",
        "LLM"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/openfreemap-maptiler-base-map-comparison/",
      "url": "https://bobochen.dev/blog/openfreemap-maptiler-base-map-comparison/",
      "title": "OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？",
      "content_text": "做台灣所得稅地圖選底圖時，發現業界標準 Mapbox 免費額度只有 5 萬次/月。整理 OpenFreeMap、MapTiler、Mapbox、NLSC 等 6 種主流服務的免費額度、token、style 比較，以及為什麼公民科技專案選了 2024 年才上線的 OpenFreeMap。",
      "content_html": "> **沒有 vector tile、style spec 的基本概念？**\n> 建議先看 [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)。\n\n最近在做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)（台灣 7,747 個村里所得稅地圖），村里界、色階都選好之後，我以為「底圖」會是 5 分鐘就解決的小問題。\n\n結果光是底圖服務的選擇就讓我研究了一個下午。\n\n主要的卡點是：**業界標準的服務每月有額度上限，但我做的是公民科技專案，未來流量我控制不了**。如果某天上熱搜流量爆掉、剛好那個月超額被收錢，那真的會傻眼。\n\n這篇整理我評估的 6 種主流 Web 地圖底圖服務，最後選了 [OpenFreeMap](https://openfreemap.org/)（2024 才出現的新興服務）的理由。\n\n## 選底圖服務（Mapbox、MapTiler…）為什麼是個問題\n\n那天下午一開始我其實很輕鬆，想說底圖不就挑個好看的接上去就好。真正讓我卡住的是這件事：地圖網站上看到的「地圖本身」（道路、地名、地形）跟我疊在上面的所得資料（村里 polygon、色階）是兩層東西。\n\n- **底圖**：道路網、地名、邊界 — 由地圖服務商提供\n- **資料層**：我自己的村里標記、polygon、色塊 — 我自己畫\n\n底圖服務商要把全球 OSM 資料切成幾百 GB 的 tiles、放到 CDN、提供 API，成本不低，所以業界普遍採「免費額度 + 超量收費」模式。我打開 Mapbox 定價頁那一刻才意識到：原來「免費」是有條件的，而我這個專案的條件偏偏最差——流量我完全控制不了。\n\n對個人 / 公民科技專案來說，常見的選擇困境是：\n\n- **業界標準（Mapbox）**：品質最高，免費額度 5 萬次 map loads/月\n- **MapTiler**：免費額度是 10 萬次 API requests + 5,000 次 map sessions/月，中文支援好（注意計費單位跟 Mapbox 不一樣，下面會解釋）\n- **自架**：完全免費、流量完全自主，但要 100GB+ 儲存 + 維運\n- **政府服務（NLSC）**：免費、台灣地名/門牌/行政界線的在地化是所有選項裡最準的，但官方 style 偏舊、不好直接拿來做資料視覺化\n- **新興服務**：聽過但不敢用，怕停服\n\n## OpenFreeMap vs MapTiler vs Mapbox：6 種底圖服務比較\n\n我把每家的定價頁、文件、官方 demo 都點過一輪，整理成這張對照表：\n\n| 服務 | 費用 | Token | 中文標籤 | 部署 |\n|------|------|-------|----------|------|\n| **[OpenFreeMap](https://openfreemap.org/)** | 完全免費無限 | 不需要 | 一般 | 0 |\n| **[MapTiler](https://www.maptiler.com/)** | 10 萬 requests + 5,000 sessions/月免費 | 需要 | 中文好 | 0 |\n| **[Stadia Maps](https://stadiamaps.com/)** | 20 萬次/月免費 | 需要 | 一般 | 0 |\n| **[Mapbox](https://www.mapbox.com/)** | 5 萬次/月免費 | 需要 | 中文好 | 0 |\n| **[CARTO](https://carto.com/)** | 免費試用（額度請以官網為準）| 需要 | 一般 | 0 |\n| **[NLSC 國土測繪](https://maps.nlsc.gov.tw/)** | 政府免費 | 不需要 | 在地化最好 | 0 |\n| **自架** | $0 | 不需要 | 看你怎麼接 | 100GB+ |\n\n「部署」這欄是我比較關心的：除了自架，所有方案都是「用他們的 hosted endpoint」，本質上零部署。差別只在註冊、token、流量上限。\n\n要特別提醒一件事：**這些「免費額度」的計費單位其實不一樣，不能直接比大小**。Mapbox 算的是 map loads（地圖載入次數），MapTiler 同時有 API requests（每次抓 tile / style 都算）和 map sessions（一次地圖瀏覽算一個 session）兩種計量——它的 sessions 只有 5,000/月，反而比 Mapbox 的 5 萬 map loads 還低。所以「MapTiler 額度比 Mapbox 高」這句話其實站不住，得看你的使用情境是被哪個單位卡住。CARTO 的免費條件這幾年也改過幾次（從早期的固定額度變成試用 + PAYG），要用之前最好自己去官網確認一次當下的數字。\n\n## 為什麼選 OpenFreeMap\n\n最後我選了 [OpenFreeMap](https://openfreemap.org/)。它是 2024 年才上線的服務，由作者（GitHub 帳號 hyperknot）一個人營運，tile 跑在他自己租的 Hetzner dedicated server 上（用 Round-Robin DNS 撐負載），Cloudflare 則贊助頻寬、R2 拿來存 tile。經費來自 [GitHub Sponsors](https://github.com/sponsors/hyperknot) 的社群捐款。\n\n選擇理由：\n\n**1. 完全免費無流量上限**\n\n對公民科技 / 個人專案最重要的條件。MapTiler、Mapbox 都會在熱門時刻變成「等等我要付錢嗎」的焦慮源。OpenFreeMap 把那種「上熱搜當天會不會收到帳單」的焦慮直接拿掉了。\n\n不過得誠實講：**免費不等於沒風險，只是把風險換了一種**。付費服務你超額會被收錢，但你跟它有付費關係、它有義務給你 SLA；免費的公共 endpoint 你跟它沒有任何契約，它哪天要對特定 referer 限流、改政策、甚至收手不做，你都只能接受（OpenFreeMap 官方其實就有針對 referer 做過流量限制）。所以這裡換到的不是「零風險」，是「把超額帳單的風險，換成可用性與政策的風險」。如果你真的怕爆量、又不想被別人的政策綁住，**唯一的根治解是 self-host**——流量完全自主，代價是你得自己扛 100GB+ 的儲存跟維運。我這次沒走那條路，是因為這是個下班做的專案，我寧可賭供應商風險，也不想多養一台機器。\n\n**2. 零註冊、零 token**\n\n幾秒鐘就能接上。我做的是開源專案，要求每個 fork 的人都去申請 MapTiler token 太麻煩。\n\n**3. 基礎設施靠得住，但要清楚它是誰在撐**\n\n它的 tile 靠 Cloudflare 贊助頻寬、R2 存放，所以下載速度跟全球可達性其實滿穩。但要老實說：它不是「Cloudflare 託管」，背後是一個人租 Hetzner 機器在跑。我選它不是因為「Cloudflare 罩著就不會掛」，而是因為對一個公民科技專案，這個穩定度已經夠用——真要怕它哪天掛掉，後面我會講解法。\n\n**4. 風格夠用**\n\n官方現在提供 positron、bright、liberty、dark、fiord（含 3D）等幾種 style，我最常用的是 positron——經典的灰白底色，給彩色 polygon 留空間，很適合資料視覺化。\n\n當然它也有缺點：相對新（2024 年起）、中文標籤沒 MapTiler 細緻、單人營運加上靠 GitHub Sponsors 撐經費，哪天作者沒力或贊助斷掉就有停服風險。但對 MVP / 個人專案，這些都不是阻擋條件——前提是你想清楚萬一它掛了，你能多快換掉它。\n\n中文標籤這點我要多講一句，因為對台灣專案它可能是 deal-breaker。OpenFreeMap 的 OSM 標籤是有中文沒錯，但偏鄉的小地名、巷弄、行政界線的完整度，跟 MapTiler 或 NLSC 比是有差的——大概就是「縣市、主要道路、知名地標看得到，但越往細節越空」。我的地圖是看村里所得色塊、底圖只是襯底，所以這個差距我吃得下；但如果你做的是要讓使用者照著底圖找路、認在地地名的應用，這個落差就足以讓你改選 MapTiler，甚至認真考慮把 NLSC 疊進來。\n\n## 3 分鐘接上 OpenFreeMap\n\n從零到 [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) + OpenFreeMap 跑起來，三步驟：\n\n**1. 裝套件**\n\n```bash\npnpm add maplibre-gl\n```\n\n**2. 初始化地圖**\n\n只要一行 style URL，剩下都跟一般 MapLibre 用法一樣：\n\n```ts\nimport maplibregl from 'maplibre-gl';\nimport 'maplibre-gl/dist/maplibre-gl.css';\n\nconst map = new maplibregl.Map({\n  container: 'map',\n  style: 'https://tiles.openfreemap.org/styles/positron',\n  center: [121.0, 23.7],   // Taiwan center\n  zoom: 7,\n});\n```\n\n最常用的幾個 style URL（另外還有 `dark`、`fiord` 等）：\n\n- `positron` — 灰白底色（推薦做資料視覺化）\n- `bright` — 完整彩色（看起來像 Google Maps）\n- `liberty` — 經典 OSM 風格\n\n**3. attribution**\n\nOpenFreeMap 要求標示來源（也是 OSM 授權的要求）。MapLibre 預設會自動讀 style 的 attribution，所以不用額外做事。\n\n如果要客製 attribution：\n\n```ts\nconst map = new maplibregl.Map({\n  // ...\n  attributionControl: { compact: true },\n});\n```\n\n這樣地圖右下角會出現一個小 `(i)`，點開才會展開完整來源。\n\n## 反思\n\n研究這個的過程踩到一個我自己的迷思：**「業界標準 = 最佳選擇」**。Mapbox 是地圖領域的業界標準，所以一開始我幾乎想都沒想就要選它。但 5 萬次/月的免費額度，對一個未來不知道會多少流量的開源專案，是個沉重的決策。\n\n這讓我反思：技術選型其實有兩個維度\n\n1. **技術成熟度** — Mapbox > MapTiler > OpenFreeMap\n2. **商業模式 fit** — 對公民科技/個人專案，OpenFreeMap > MapTiler > Mapbox\n\n如果是企業專案、有預算、流量可預期，Mapbox 還是最佳選擇。但如果是「想做就做、不確定會不會紅、寧可服務醜一點也不想付錢」這種專案，業界標準反而是錯的選擇。\n\n其實這兩端中間還有路可走，不是只能二選一。比較務實的策略是**先用免費服務起步、流量真的起來再升級**：OpenFreeMap 接得快，等哪天爆量或想要更穩，再換成付費的 MapTiler / Mapbox，或乾脆自架——因為 style URL 只是一行設定，遷移成本很低。而 self-host 也別只當成「太麻煩」一句帶過：它是唯一能讓你**流量完全自主、不被任何供應商政策綁住**的選項，正好對應我開頭最焦慮的那件事。我這次沒選它，是清楚地拿「自己維運一台機器」去換「不用煩流量」，不是因為它不好。\n\n另一個收穫是：**新興的開源/免費服務（OpenFreeMap、Stadia 接手 Stamen）值得試**。老實說我自己一開始也是看到「2024 才上線、沒聽過」就想直接跳過，後來逼自己把官方 demo 點開、把 style 接到地圖上跑一遍，才發現它對我的需求剛剛好。這些服務通常是社群驅動、目標是補上業界標準的缺口，對小型專案、prototype、學生作業就是寶藏。但「值得試」不等於「閉著眼睛賭」——我會試一個新服務的前提是：我承受得起它哪天突然掛掉，而且我有一條半天內能換掉它的退路。如果是公司要長期維運、不能斷的系統，我就不會拿它當底圖。\n\n最有趣的發現是 **OpenFreeMap 的營運模式**：一個人租機器、Cloudflare 贊助頻寬、靠 GitHub Sponsors 收社群捐款，本質上是「我幫全世界省流量費，部分人回饋一點就夠我跑下去」。我喜歡這種商業模式之外的可能性，但也沒天真地把全部身家押上去——我留的後路是：TaxMap 的 style URL 是一行設定，真要換成 MapTiler 或自架，半天就能搬完。賭一個新服務，前提是你算得出「它掛掉的那天」要付出多少。\n\n## 系列其他文章\n\n- **想完全繞過底圖選型？** → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)\n- **底圖術語完全不懂？** → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)\n- **底圖之後是色階** → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n- **整個專案的決策全貌** → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)",
      "summary": "做台灣所得稅地圖選底圖時，發現業界標準 Mapbox 免費額度只有 5 萬次/月。整理 OpenFreeMap、MapTiler、Mapbox、NLSC 等 6 種主流服務的免費額度、token、style 比較，以及為什麼公民科技專案選了 2024 年才上線的 OpenFreeMap。",
      "image": "https://bobochen.dev/_astro/cover.C-xVGqG8.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "WebGIS",
        "地圖",
        "MapLibre",
        "OpenFreeMap",
        "MapTiler"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/orrd-warm-palette-two-decisions-framework/",
      "url": "https://bobochen.dev/blog/orrd-warm-palette-two-decisions-framework/",
      "title": "為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」",
      "content_text": "做 TaxMap-TW 色階換三次，最後選 OrRd 不是因為好看，而是把「色階」拆成用色 (hue) + 分桶 (binning) 兩個獨立決策重新組裝。對數絕對門檻（30/50/80/130/200/350 萬）+ OrRd 7 級 = 跨年顏色穩定、中產區段視覺最寬、outlier 自然分出。",
      "content_html": "做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 兩週，色階換了三次：**YlGnBu → viridis → OrRd**。最後選 OrRd 不是因為「比較好看」，而是因為我把「一個決策」拆成了「兩個獨立軸」。\n\n之前寫過一篇選 YlGnBu 的理由（系列第 1 篇 [《資料地圖該用哪種色階？》](/blog/data-map-color-scale-viridis-ylgnbu)），結論在實際做下來被自己推翻了。這篇是那篇的下一章。\n\n## 背景：7,747 個村里的所得色階怎麼選\n\nTaxMap-TW 是全台 7,747 個村里、11 年 × 4 指標可切換的所得熱度地圖。每個 polygon 要根據選定的年度與指標上色，選色階時我一開始把它當成「挑色卡」的問題：\n\n- 第 1 次選 **YlGnBu**（淺黃 → 深藍）：理由是「ColorBrewer 業界標準、色盲友善、公部門感」\n- 第 2 次換 **viridis**（紫 → 藍 → 綠 → 黃）：聽說 matplotlib 標準、感知均勻最科學\n- 第 3 次定案 **OrRd**（淺米 → 深紅）：使用者一句「viridis 不好看」之後重新思考\n\n問題不在於哪一個「比較對」，而是我一直把「色階」當成單一決策。\n\n## 發現過程\n\n### viridis 被打臉的那一刻\n\n從 YlGnBu 換到 viridis 的理由很學術：\n\n- Perceptually uniform（明度線性變化）\n- 色盲友善（Daltonization safe）\n- matplotlib 預設、學界共識\n\n換完跑 `pnpm dev`，使用者打開首頁，第一句話：「**顏色不好看。**」\n\n我愣了一下，因為這在我認知裡是「最對」的選擇。viridis 的 perceptual uniformity 是真本事——要精讀數值、要在連續漸層上分辨細微差距，它確實比 OrRd 強。但回頭看截圖 — 紫色 → 黃色的漸層在一般民眾眼裡確實很疏遠，沒有「熱度地圖」的直覺。問題不是 viridis 不好，而是它的優化目標（精讀、跨媒介穩定）跟我這張地圖的目標（讓一般人一眼看懂熱度）不一致。那一秒我才真正聽進去。\n\n### 找原型：kiang/salary 怎麼做？\n\n我問使用者：「要不要參考一下其他人怎麼選的？」最快的途徑是看 [kiang/salary](https://github.com/kiang/salary)（江明宗的村里所得地圖，TaxMap-TW 的精神祖父）的程式碼。\n\n翻到 `docs/map/main.js` 找到一個 `ColorBar(value)` function：\n\n```javascript\nfunction ColorBar(value) {\n  if (value == 0)            return \"rgba(255,255,255,0.6)\"   // 白\n  else if (value <= 300)     return \"rgba(254,232,200,0.6)\"\n  else if (value <= 400)     return \"rgba(253,212,158,0.6)\"\n  else if (value <= 500)     return \"rgba(253,187,132,0.6)\"\n  else if (value <= 700)     return \"rgba(252,141,89,0.6)\"\n  else if (value <= 900)     return \"rgba(239,101,72,0.6)\"\n  else if (value <= 1100)    return \"rgba(215,48,31,0.6)\"\n  else if (value <= 1300)    return \"rgba(179,0,0,0.6)\"\n  else if (value <= 1500)    return \"rgba(127,0,0,0.6)\"\n  else                       return \"rgba(64,0,0,0.6)\"        // 最深\n}\n```\n\n短短 11 行，但裡面有**兩個獨立決策被疊在一起**：\n\n1. **用色**：ColorBrewer **OrRd 9 級**（橙紅熱度）\n2. **分桶**：**絕對門檻**（300/400/500/700/900/1100/1300/1500 千元，hard-coded）\n\n我之前的選擇是：\n\n1. 用色：YlGnBu / viridis（隨意換）\n2. 分桶：**quintile**（程式自動算每年的 20/40/60/80 分位數）\n\n**Ah ha。我一直把兩個獨立決策當成一個決策。**\n\n### 拆開看兩個軸\n\n把它們攤開：\n\n| 軸 | 選項 | 影響 |\n|---|---|---|\n| **用色** | YlGnBu / viridis / OrRd / RdYlBu / Cividis... | 視覺氣質、色盲友善度、情緒聯想 |\n| **分桶** | quintile / equal-interval / Jenks / 手動 / **log 絕對** | 跨年穩定度、outlier 處理、視覺均勻度 |\n\nquintile 看起來很合理（每年自動切 20%），但對「跨年看趨勢」是災難 — 因為門檻每年變，**同樣的 100 萬，在 2012 是綠色、在 2022 可能是黃色**。使用者拖年度滑桿時整張地圖會閃色，卻看不出真正的改變。\n\n絕對門檻沒這個問題，但要面對「outlier 怎麼處理」：kiang 的 9 級在 >1500 千元（150 萬）就封頂，但松山中華里 2022 中位數 526 萬，跟 200 萬的里會被染成同一個深紅，看不出差別。\n\n## 解法：OrRd 7 級 + 對數絕對門檻\n\n把兩個決策獨立優化：\n\n**用色：OrRd 7 級**\n\n- 暖紅本來就是「熱度地圖」的視覺直覺\n- 單色 sequential 不會像 RdYlBu 雙向被讀成「好 / 壞」\n- kiang 已驗證好看\n- 從 OrRd 9 級手挑 7 色子集（不是 ColorBrewer 官方 OrRd[7]），比 9 級少 2 級，視覺不擁擠\n\n這裡要誠實補一句：OrRd 是單色順序色階，色盲友善度其實不如 viridis（viridis 整條對各類色覺缺陷都安全，這是它最硬的優點，前面拿這點打它其實是我選邊站後的雙標）。我把色盲友善降為次要考量，是因為這張地圖的受眾是一般民眾、且色階靠「明度由淺到深」也能讀出高低——而且 ColorBrewer 仍把 OrRd 標為 colorblind-safe，不是裸奔。如果受眾換成需要精準辨色的專業使用者，這個取捨我會重做。\n\n**分桶：對數絕對門檻**\n\n- 倍率 ~1.5×：30 / 50 / 80 / 130 / 200 / 350 萬（單位千元為 300 / 500 / 800 / 1300 / 2000 / 3500）\n- 所得本來就 log-normal 分布，log 倍率切桶最自然\n- 中產區段（50–130 萬）佔最寬視覺空間\n- Outlier（>350 萬）自己一個頂色，中華里 526 萬獨立深紅，跟 200 萬區分得開\n\n實作很短：\n\n```typescript\n// src/components/MapClient.ts\n// 注意：這不是 ColorBrewer 官方 OrRd[7]（官方首色 #fef0d9、末色 #990000）。\n// 我是從 OrRd 9 級手挑出 7 色子集，跳掉中間兩階讓對比更開。\nconst ORRD_7 = [\n  '#fee8c8', // <30 萬\n  '#fdd49e', // 30–50\n  '#fdbb84', // 50–80\n  '#fc8d59', // 80–130\n  '#ef6548', // 130–200\n  '#d7301f', // 200–350\n  '#7f0000', // >350\n] as const;\n\nconst ORRD_BREAKS = [300, 500, 800, 1300, 2000, 3500]; // 千元\n```\n\npaint expression 把每個 VILLCODE 對應到 stats、查 bucket 後上色：\n\n```typescript\nprivate classifyValue(value: number): number {\n  for (let i = 0; i < ORRD_BREAKS.length; i++) {\n    if (value < ORRD_BREAKS[i]) return i;\n  }\n  return ORRD_BREAKS.length; // 6 = >350 萬最深紅\n}\n```\n\n整段比 quintile 版簡單 — 因為門檻是常數，不再需要每次切換指標都重算 Jenks。\n\n## 具體數據 / 結果\n\n跟 kiang 比的改進：\n\n| | kiang | TaxMap-TW |\n|---|---|---|\n| 用色 | OrRd 9 級 | OrRd 7 級 |\n| 分桶數 | 9 級 + 0 | 7 級 + 無資料灰 |\n| 最高門檻 | >150 萬一個色 | >350 萬一個色 |\n| 中產解析度 | 50–130 萬擠在 2 級 | 50–130 萬攤開到 3 級 |\n| 中華里 526 萬 | 跟 200 萬同色 | 獨立深紅 |\n| 跨年穩定 | ✓（絕對門檻） | ✓（絕對門檻） |\n| 透明度 | 0.6 半透明 | 0.85 較飽和 |\n\n跟原本自己 quintile 版的差距更大 — 之前拖滑桿整張地圖在閃色，現在拖滑桿**只有「真正有變化的里」會升降顏色**，視覺訊號終於跟資料變化對齊。\n\n**這個方案的代價我也得說清楚：**一組固定門檻（30/50/80/130/200/350 萬）是照「綜所稅中位數」的量級調的，但我有 4 個指標可切（中位數、平均、各分位）。平均數那檔的數值分布跟中位數不一樣，套同一組門檻時，低收入村里會偏擠在前一兩桶，解析度沒有中位數那檔漂亮——嚴格講每個指標該有自己的一組門檻，我為了「跨指標也用同一把尺」偷懶用一組，這是已知的妥協。另外那個「>350 萬獨立深紅」聽起來解決了 kiang 的封頂問題，其實只是把 cap 從 150 萬往後推到 350 萬而已；中華里 526 萬跟假設某天冒出個 900 萬的里，還是會被染成同一個 `#7f0000`。outlier 永遠有，我只是把封頂線移到「現階段資料碰不到」的地方，不是真的解決了它。\n\n## 反思\n\n### 技術面\n\n**Choropleth 色階是兩個獨立決策，不要疊在一起想：**\n\n- **用色（hue / palette）**：解決「視覺氣質」與「色盲友善」\n- **分桶（binning）**：解決「跨年穩定度」與「outlier 處理」\n\n下次再看到任何 dataviz 設計問題卡住，先問自己：「**這真的是一個決策嗎？還是兩個被綁在一起？**」\n\n**log 倍率 binning 在「對的前提下」是被低估的選擇——但它有前提：**\n\n它對我這個場景好用，是因為剛好滿足三個條件：資料是 log-normal 分布、全正值、而且有公認的 anchor（所得級距、loan tier 那種大家認得的數字）。對應地：\n\n- 適合 log-normal 且全正的資料：所得、檔案大小、網站流量、收入級距、地震規模\n- 比 quintile 多了「絕對意義」（100 萬永遠是同色），所以能跨年比\n- 比 equal-interval 不會被 outlier 拉爆\n- 比手動 thresholds 容易解釋（「倍率 1.5x 切」一句話講完）\n\n但只要前提不成立就別硬套：資料含 0 或負值（log 直接爆）、近常態分布（log 反而把中間擠扁）、你要看的是相對排名而非絕對值、或資料偏態到大量村里全擠進同一桶——這些情況絕對門檻都會輸。而 quintile 也不是只有缺點：它保證每個色階都分到差不多的樣本數，做「單年、單指標的分布快照」時，這個「每色都有料」的特性其實比絕對門檻好看也好讀。我換掉它純粹是因為 TaxMap 的核心是「跨年拉滑桿看趨勢」，不是因為 quintile 本身爛。\n\n**抄前人的程式碼遠比從零推快：**\n\n- kiang 11 行 `ColorBar()` 包含了他幾年累積的設計判斷\n- 我看一眼就拿到「絕對門檻」的關鍵 insight\n- 但我沒照抄，看完後問「他這樣做的痛點在哪？我能不能改進？」 — 結果改成 7 級 + log 倍率 + 更高的 cap\n\n### 心態面\n\n**「好看」是合法的產品需求。** 我一開始把使用者「顏色不好看」當成主觀偏好，差點要去說服他 viridis 才是正確答案。回頭看那是傲慢 — 一張公開的地圖是給一般民眾看的，不是給 matplotlib 用戶。學術上對 ≠ 產品上對。前提是「好看」不能踩到底線：可及性（色盲能讀）跟正確性（顏色沒有誤導數值）是不能用美感換的，這次只是色盲友善降為次要、沒被犧牲掉。\n\n**「matplotlib 預設」不是中立選擇，是別人的優化目標。** viridis 是 2015 年 matplotlib 為「科學論文 print 出來在黑白紙上、影印後、色盲眼裡都讀得到」設計的——這目標很值得尊敬，只是它跟 web choropleth 的痛點不重疊。所以不是「viridis 不該用」，是「它的優化目標不該無條件套到我的場景」。盲目套用業界標準，等於把別人的優化目標當成自己的。\n\n**少嘴砲、先打開別人的程式碼。** 我花了一小時跟使用者來回討論色階理論，最後實際解決問題的關鍵 insight，是花 30 秒讀 kiang 11 行 javascript 拿到的。但讀前人 code 要帶批判——它包的是「他的痛點」的答案，不一定是你的，所以我看完是問「他為什麼這樣切？我的場景哪裡不一樣？」，而不是整段抄走。\n\n### 有趣發現\n\n**ColorBrewer 名字超難記但網站超實用。** [colorbrewer2.org](https://colorbrewer2.org/) 是 Cynthia Brewer 教授 2002 年做的 choropleth 配色工具，內建 35+ 個色階分 3 類（sequential / diverging / qualitative）。每個都有色盲模擬、列印友善、影印友善的標記。地圖配色 99% 用得到，但因為網站做得很學術老派，常被忽略。\n\n**換成暖紅之後，沒人再問「這顏色代表什麼」。** viridis 版我得跟使用者解釋「紫是低、黃是高」；OrRd 版打開來，他直接就說「喔右邊那塊比較紅就是有錢的」。紅＝熱＝高這個聯想在我這個使用者身上是零成本的——我不敢說它跨文化都成立，但對這張給台灣民眾看的地圖夠用了。\n\n**對數倍率 1.5× 不是亂猜的，是公部門統計手冊的「level set」傳統。** Loan / income tiers 常切 30/50/80/130/200/300/500 萬 — 看起來只是「順手挑的數字」，其實是平均所得倍率累積的結果。我 TaxMap-TW 用 30/50/80/130/200/350，差不多一脈相承。\n\n## 寫在最後\n\n如果這兩週只留一句給未來的自己，那會是：色階卡住的時候，我反覆換的多半是「色卡」，但真正卡住我的是「分桶」。先把這兩件事拆開，再決定「我這張圖到底要跨年比、還是看單年分布」，色階就不再是玄學了。至於要不要抄前人的 OrRd——抄判斷，不抄結論。\n\n## 參考連結\n\n- [TaxMap-TW GitHub](https://github.com/bobo52310/TaxMap-TW)\n- [kiang/salary 原型](https://github.com/kiang/salary)\n- [ColorBrewer 2.0](https://colorbrewer2.org/)\n- 這篇承接的決策起點：[資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)（當初選 YlGnBu 的理由，這篇把它推翻了）\n- 同樣是「視覺優先 vs 教科書正解」的權衡：[地圖標籤密集時，competition ranking 還是 dense ranking？](/blog/competition-vs-dense-ranking-map-labels)\n- 系列總結：[打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)",
      "summary": "做 TaxMap-TW 色階換三次，最後選 OrRd 不是因為好看，而是把「色階」拆成用色 (hue) + 分桶 (binning) 兩個獨立決策重新組裝。對數絕對門檻（30/50/80/130/200/350 萬）+ OrRd 7 級 = 跨年顏色穩定、中產區段視覺最寬、outlier 自然分出。",
      "image": "https://bobochen.dev/_astro/cover.Ckn3tKnv.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "資料視覺化",
        "地圖",
        "MapLibre",
        "ColorBrewer",
        "Choropleth"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/pmtiles-http-range-request-single-file-tiles/",
      "url": "https://bobochen.dev/blog/pmtiles-http-range-request-single-file-tiles/",
      "title": "PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術",
      "content_text": "PMTiles 把上千萬個 tile 打包成單一檔案，靠 HTTP Range Request 讓瀏覽器只讀需要的部分，沒有 tile server、丟到 S3 就能用。整理它的設計、跟 MBTiles 的差異、何時不該用，以及在 TaxMap-TW 怎麼用 tippecanoe 產一個。",
      "content_html": "> **沒有底圖、tile、vector tile 的基本概念？**\n> 建議先看 [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)。\n\n在 [上一篇底圖比較](/blog/openfreemap-maptiler-base-map-comparison) 提到「自架需要 100GB+ 儲存」，我把自架直接排除了。但其實有第三條路：**PMTiles**。\n\nPMTiles 是 [Protomaps](https://protomaps.com/) 出品的新型 tile 格式。它的核心想法很瘋狂：\n\n**把上千萬個 tile 打包成 1 個檔案，丟到 S3 / R2 / Cloudflare Pages，瀏覽器靠 HTTP Range Request 只讀需要的位元組。**\n\n沒有 tile server、沒有 Docker、沒有資料庫。你可以把它想成「靜態檔案上的 tile 服務」。\n\n[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 的 7,747 個村里 polygon 就是這樣上線的：一個 5.7 MB 的 `.pmtiles` 檔放在 Cloudflare Pages，瀏覽器只讀當前視窗需要的 chunks。整個地圖服務沒有任何 server。\n\n這篇拆解 PMTiles 的設計，以及實戰怎麼產一個。\n\n## 為什麼 PMTiles 想取代傳統 tile server\n\n傳統做法是這樣的：\n\n1. 用 [tippecanoe](https://github.com/felt/tippecanoe) 把 GeoJSON 切成 vector tiles，輸出成 MBTiles（一個 SQLite 檔，內含上萬條 tile blob）\n2. 跑一個 tile server（[tileserver-gl](https://github.com/maptiler/tileserver-gl)、[martin](https://github.com/maplibre/martin)）讀 MBTiles\n3. tile server 暴露 `/{z}/{x}/{y}.pbf` HTTP endpoint\n4. 瀏覽器透過 MapLibre 請求 tile\n\n這個架構的痛點：\n\n- **需要 server**：得有一台機器跑 24/7\n- **需要 SQLite 隨機讀**：tile server 要把 MBTiles 開著、不停讀\n- **冷啟動慢**：Cloud Run / Lambda 第一次請求 tile 要載入整個 SQLite\n- **延展性與成本**：流量大時 server 變瓶頸\n\n對小型 / 靜態網站來說，光是「為了底圖而養一台 server」就讓很多人卻步。\n\n不過先把話說公道：tile server 的這些「麻煩」反過來也是它的能力。它能即時更新——資料一改、下一次請求就拿到新 tile，不用重切；能在伺服器端依參數過濾，回傳不同子集；能依登入身分或權限產出不同的 tile。這些都是把資料封進一個靜態檔的 PMTiles 做不到的。所以它不是「過時」，而是換了一組取捨。下面講的痛點，是針對「資料不常變、不需要動態查詢」的場景。\n\n## PMTiles 的核心想法\n\nPMTiles 直接把問題拆掉：先把所有 tile（z/x/y 索引到 pbf binary）排成一個檔案、前面附上一份目錄（directory），然後把這個檔案丟到任何支援 HTTP Range Request 的地方（S3、R2、Cloudflare Pages、GitHub Pages 都行）。之後瀏覽器需要某個 `(z, x, y)` 的 tile 時，會先讀目錄查到那塊 tile 的 byte offset 和 length，再發一個像 `Range: bytes=12345-67890` 的請求，CDN 就只回那段 binary，MapLibre 直接吃。\n\n整個流程沒有任何 server。`.pmtiles` 就是個靜態檔，CDN 加速、邊緣節點、cache 全都能用——前提是 host 真的支援 Range Request。它得在回應帶 `Accept-Ranges: bytes`、對 `Range` 請求回 `206 Partial Content`，跨網域用時還要設好 CORS。S3、R2、Cloudflare Pages、GitHub Pages 都 OK，但自架 nginx 或某些 CDN 設定不見得預設開。上線前用 `curl -I -H \"Range: bytes=0-99\"` 看一眼回的是不是 206，最省事。\n\n### HTTP Range Request 是什麼？\n\nHTTP/1.1 早就有的功能（規格出自 1997 年的 RFC 2068，後來併入 1999 年的 RFC 2616），但很多人沒用過。\n\n瀏覽器送出：\n\n```http\nGET /villages.pmtiles HTTP/1.1\nHost: tiles.example.com\nRange: bytes=12345-67890\n```\n\n伺服器回應：\n\n```http\nHTTP/1.1 206 Partial Content\nContent-Range: bytes 12345-67890/5998619\nContent-Length: 55546\n\n<那段 binary>\n```\n\n關鍵點：**伺服器只回那個範圍**，不會把整個 6 MB 檔案傳給你。\n\n這個能力一直存在，PMTiles 只是聰明地用它來實作「單檔 tile 服務」。\n\n## PMTiles vs MBTiles 比較\n\n| 項目 | MBTiles | PMTiles |\n|------|---------|---------|\n| 格式 | SQLite（gzip 壓縮的 tile blob） | 自訂二進位 (header + directory + tile blobs) |\n| 需要 server | 是（tile-server-gl / martin） | 否 |\n| 部署 | server + SQLite 檔 | 一個 .pmtiles 檔 + 靜態 host |\n| 客戶端讀取 | 透過 server HTTP API | 瀏覽器直接 HTTP Range |\n| Cloud-native | 不友善（要 server 跑 SQLite） | 完美（純靜態檔） |\n| 工具支援 | 老牌、生態廣 | 較新但 tippecanoe 直接支援 |\n| 適合場景 | 自架 tile 服務、企業內部 | 靜態網站、無 server、低成本 |\n\n對 Cloudflare Pages、Vercel、GitHub Pages 這類只能放靜態檔、不能跑 server 的託管，PMTiles 幾乎是唯一不用另外架服務就能上 vector tile 的選項。\n\n## PMTiles 的限制：什麼時候不該用\n\n寫到這裡好像 PMTiles 全面碾壓，但它換來的好處是有代價的。Protomaps 官方自己就有一篇〈[You Might Not Want PMTiles](https://docs.protomaps.com/pmtiles/cloud-storage#you-might-not-want-pmtiles)〉，我自己用下來，下面幾個情況我不會選它：\n\n- **資料常變動**：PMTiles 是不可變的單一檔。改一個村里的幾何，就得整包重切、重傳、讓 CDN 重新快取。TaxMap 的村里界一年才更新一次，這成本可以接受；如果是每天甚至每小時更新的資料，傳統 tile server 的「改了就生效」反而省事。\n- **需要動態查詢或權限控管**：PMTiles 出去的是固定的一份檔，沒辦法依使用者身分、登入狀態回不同內容。要「付費才看高解析、依角色給不同圖層」這種需求，還是得有一層 server 把關。\n- **巨型資料集**：檔案越大、目錄越深，一次 tile 請求可能要先讀好幾跳目錄才找到 byte offset，Range Request 的來回次數跟頻寬都會上去。幾 MB 到幾百 MB 很舒服，但到了幾十 GB 的全球底圖等級，就要認真評估 CDN 命中率與請求數，不是無腦丟上去就好。\n\n簡單說：**資料靜態、規模中小、不需要伺服器端邏輯時，PMTiles 很香；反過來就回去用 tile server。**\n\n## 3 步驟產一個 PMTiles\n\n完整實戰：把一份 GeoJSON 變成可以丟 CDN 的 `.pmtiles`。\n\n### 1. 裝 tippecanoe\n\n[tippecanoe](https://github.com/felt/tippecanoe) 是把 GeoJSON 切成 vector tile 的 CLI。原來是 Mapbox 出的，但他們從 2020 停更，現在用社群維護的 felt fork：\n\n```bash\nbrew install tippecanoe\n# 或從 source build\ngit clone https://github.com/felt/tippecanoe.git\ncd tippecanoe && make -j && sudo make install\n```\n\n> 注意 Mapbox 原版 `tippecanoe` 已 2020 年停更，felt fork 是現役版本。它的 `--output` 看副檔名自動決定格式，`.mbtiles` 和 `.pmtiles` 兩種都能直接吐——所以同一份 GeoJSON 想換格式，只要改輸出檔名就好。\n\n### 2. 從 GeoJSON 產 PMTiles\n\n最簡單的命令：\n\n```bash\ntippecanoe \\\n  --output=villages.pmtiles \\\n  --layer=villages \\\n  --minimum-zoom=6 \\\n  --maximum-zoom=13 \\\n  --detect-shared-borders \\\n  --coalesce-densest-as-needed \\\n  --extend-zooms-if-still-dropping \\\n  --force \\\n  villages.geojson\n```\n\n幾個關鍵 flag：\n\n- `--minimum-zoom / --maximum-zoom`：要切哪些 zoom level。z=6 看全國，z=13 看里街道\n- `--detect-shared-borders`：相鄰 polygon 共用邊界，省 50% 大小\n- `--coalesce-densest-as-needed`：低 zoom 自動合併小 polygon 避免破圖\n- `--extend-zooms-if-still-dropping`：高 zoom 還有特徵會自動延伸\n\n跑 30 秒，吐出 5.7 MB 的 `villages.pmtiles`。\n\n### 3. 放到 CDN + 在 MapLibre 接\n\n把 `villages.pmtiles` 丟到 `public/data/geometry/villages.pmtiles`，部署到 Cloudflare Pages。\n\n前端裝 [pmtiles npm](https://github.com/protomaps/PMTiles)：\n\n```bash\npnpm add pmtiles\n```\n\n接到 MapLibre：\n\n```ts\nimport maplibregl from 'maplibre-gl';\nimport { Protocol } from 'pmtiles';\n\n// 註冊 pmtiles:// protocol handler\nconst protocol = new Protocol();\nmaplibregl.addProtocol('pmtiles', protocol.tile);\n\nconst map = new maplibregl.Map({\n  container: 'map',\n  style: 'https://tiles.openfreemap.org/styles/positron',\n  center: [121.0, 23.7],\n  zoom: 7,\n});\n\nmap.on('load', () => {\n  map.addSource('villages', {\n    type: 'vector',\n    url: 'pmtiles:///data/geometry/villages.pmtiles',\n    promoteId: 'VILLCODE',  // 把 properties.VILLCODE 提升為 feature.id\n  });\n\n  map.addLayer({\n    id: 'villages-fill',\n    type: 'fill',\n    source: 'villages',\n    'source-layer': 'villages',\n    paint: {\n      'fill-color': '#41b6c4',\n      'fill-opacity': 0.7,\n    },\n  });\n});\n```\n\n關鍵在 `pmtiles://` 這個自訂 protocol。MapLibre 看到 `pmtiles://...` 開頭的 URL 會交給 `Protocol.tile` handler，handler 用 HTTP Range Request 讀取 PMTiles。\n\n> 上面 `fill-color` 我先寫死成 YlGnBu 的青色 `#41b6c4` 只是為了把幾何畫出來。所得地圖真正的色階後來改成了 OrRd，原因見 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。\n\n對 MapLibre 來說，這就像是個正常的 vector tile source。但實際上瀏覽器只下載當前視窗需要的幾個 chunks（首屏約 200 KB），不是整個 6 MB。\n\n## PMTiles 在 TaxMap-TW 的實戰\n\n[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 是個剛上線的台灣所得稅地圖，全台 7,747 個村里 polygon 都用 PMTiles 上線。\n\n實際整合的踩雷：\n\n**1. 從 MOI 下載的 Shapefile 是 Big5 編碼**\n\n[內政部國土測繪中心](https://maps.nlsc.gov.tw/) 的村里界 shapefile zip 內含中文檔名，macOS 預設 `unzip` 會崩潰。改用 macOS native `ditto`：\n\n```bash\nditto -x -k villages-1130928.zip villages-shp/\n```\n\n**2. tippecanoe 的 `--read-parallel` 會把 5.6 MB 的 GeoJSON 切碎解析錯誤**\n\n我的 GeoJSON 一行一個 feature（mapshaper 輸出格式）。`--read-parallel` 想平行讀但會在某些行切錯。**單線程讀更穩**，30 秒也不算慢。\n\n**3. `promoteId` 是必須的**\n\n預設情況下 tippecanoe 產出的 tile 不會把 properties 的 ID 欄位提升到 feature.id。這會導致 MapLibre 的 `setFeatureState`（用來做 hover 反白）失效。在 source 設定加 `promoteId: 'VILLCODE'` 即可。\n\n**4. Mapshaper 簡化要用 `keep-shapes`**\n\n把 NLSC 50 MB 的 shapefile 簡化到 5.6 MB GeoJSON，預設會「丟掉太小的 polygon」。但每個村里都要保留，所以要加 `-simplify 5% keep-shapes`，太小的村里也會被保留。\n\n## 反思\n\n讓我印象最深的是 Range Request 這個東西本身。它出自 1997 年的 RFC 2068、躺在 HTTP 規格裡快三十年，PMTiles 在 2021 年初發表、那年 FOSS4G 上正式亮相後，GIS 圈才比較多人這樣用它來做單檔 tile 服務。倒不是「沒人想到」，而是要等 R2 這類便宜物件儲存、CDN 對 Range 的普遍支援、瀏覽器端讀 PMTiles 的函式庫這幾個條件同時成熟，這套玩法才划算。我那天接完 `pmtiles://` 看到地圖跑起來、整個專案連一台 server 都沒有，確實愣了一下——原來繞了一圈，答案是 HTTP 本來就會的事。\n\n架構上 PMTiles 是「移除中間層」：傳統 tile 架構有 4 層（origin DB → tile server → CDN → browser），它直接拿掉 tile server 那層（origin file → CDN → browser）。少一層確實少一個故障點，但代價是原本集中在 server 那層的快取策略、可觀測性、錯誤處理也一起沒了，這些複雜度其實是轉嫁到了客戶端和 CDN 設定上，只是不再是「我要顧的一台機器」而已。\n\n成本面，PMTiles + Cloudflare 的零成本全端組合對個人專案、低中流量、用量落在免費額度內的情況，幾乎可以做到月付 $0：TaxMap-TW 的 OpenFreeMap 底圖、PMTiles polygon、SSG 靜態頁、CDN 流量加起來就是 $0。但 $0 不是物理定律——Range Request 一樣算請求數和頻寬，真的爆紅、流量衝破免費額度，帳單照樣會來。\n\n> 後記：這篇講的「TaxMap 放在 Cloudflare Pages」其實是當時的狀態。後來「一頁一檔」的 SSG 頁面撞到 Cloudflare Pages 的 2 萬檔上限，我把站搬到了 Netlify，過程寫在 [Cloudflare Pages 的 20,000 檔案上限](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)。PMTiles 這套單檔做法本身沒變，變的是放它的平台。\n\n如果你有「想做地圖但被 server 嚇退」的點子，週末花一兩個小時，從 GeoJSON 到丟上 CDN 是真的跑得完的——只是記得先確認你的資料是不是適合走靜態這條路。\n\n## 系列其他文章\n\n- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)\n- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)\n- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)",
      "summary": "PMTiles 把上千萬個 tile 打包成單一檔案，靠 HTTP Range Request 讓瀏覽器只讀需要的部分，沒有 tile server、丟到 S3 就能用。整理它的設計、跟 MBTiles 的差異、何時不該用，以及在 TaxMap-TW 怎麼用 tippecanoe 產一個。",
      "image": "https://bobochen.dev/_astro/cover.DCTaMbj6.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "WebGIS",
        "地圖",
        "PMTiles",
        "MapLibre",
        "protomaps"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls/",
      "url": "https://bobochen.dev/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls/",
      "title": "打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑",
      "content_text": "TaxMap-TW（台灣所得稅地圖）月成本 $0、4-5 天從零上線的完整復盤：底圖、色階、PMTiles、Astro 6 SSG、FIA 直拓、Competition Ranking 共 6 個技術決策，與 macOS unzip Big5、MapLibre 容器尺寸卡 0×0 等 4 個踩過的坑。",
      "content_html": "起點其實只是一個很自私的問題：我想知道我家那個里，所得在全台排第幾。查不到現成的，就乾脆自己做一個。\n\n[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 後來做成了全台 7,747 個村里的所得稅地圖（11 年資料、4 個指標、雙維度排名、YoY 變動、歷年折線圖、搜尋），月成本 $0，從零到上線大概花了 4-5 天。\n\n這篇是整個系列的收尾，回顧 6 個關鍵技術決策、踩過的 4 個坑，還有事後回頭看才想通的幾件事。\n\n## TaxMap-TW 的 6 個技術決策\n\n每個決定都有對應的詳細文章。這篇是 index。\n\n### 1. 底圖：OpenFreeMap\n\n> 詳細：[OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)\n\n候選：Mapbox（業界標準、5 萬次/月免費）vs MapTiler（10 萬 requests 或 5,000 sessions/月）vs OpenFreeMap（無限免費）vs NLSC（政府服務）。\n\n選 OpenFreeMap。理由：**對公民科技/個人專案，「免費無上限」比「業界標準」更重要**。\n\n不過要誠實講 OpenFreeMap 的代價：它沒有 SLA、目前是單人維運、沒有商業等級的 backup、可選 style 也少。所以這個選擇只在「掛掉幾小時也沒人會死」的低營運風險場景成立——像這種個人 / 公民科技專案。如果是公司產品、有營收綁在地圖可用性上，我會老實付錢給 MapTiler 或 Mapbox 換 SLA。\n\n### 2. 色階：YlGnBu + Jenks 自然斷點\n\n> 詳細：[資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n\n候選：viridis（學術主流）vs YlGnBu（公部門報告風）vs YlOrRd（媒體吸睛）。\n\n選 YlGnBu + Jenks。理由：**色階要配合資料的長尾分布**，台灣所得高度集中於少數里，等距分級會變「90% 同色 + 幾顆紅點」。\n\n（後記：這是當時的決策，但上線後我又把色階整個換掉了，從 YlGnBu 改成 OrRd、Jenks 改成對數絕對門檻，原因見 [為什麼我把所得地圖色階從 viridis 換成 OrRd](/blog/orrd-warm-palette-two-decisions-framework)。）\n\n### 3. 切片：PMTiles\n\n> 詳細：[PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)\n\n候選：傳統 tile server（要 24/7 機器）vs PMTiles（單檔 + HTTP Range Request）。\n\n選 PMTiles。理由：**用 HTTP 標準繞過「需要 server」的傳統假設**，丟靜態託管就能用，$0 月支出。\n\n要補一個適用邊界：PMTiles 的甜蜜點是**不常變動的資料**。村里界一年才更新一次，封裝成單檔最划算。如果你的資料頻繁更新、或需要動態查詢 / 即時篩選 tile，傳統 tile server 還是有它的位置——PMTiles 是在「靜態場景」取代 tile server，不是全面取代。\n\n（後記：當時我以為「丟 Cloudflare Pages」就完美收工了，結果上線後撞到 CF Pages 單站 2 萬檔上限——光是 OG 圖就爆量——最後整站搬到 Netlify，過程見 [Cloudflare Pages 的 20,000 檔案上限](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)。）\n\n### 4. 框架：Astro 6 SSG\n\n候選：Next.js（SSR + ISR）vs Astro（SSG）vs Vue/Nuxt vs SvelteKit。\n\n選 Astro 6 SSG。理由：**這個專案 100% static**（5 個固定路由 + 22 個縣市頁 + 7,747 個里詳細頁 + 1 個排行榜，全部 build time 預產）。Astro 對 SSG 場景比 Next.js 更輕、更快。最重要的是它有 **island architecture**：地圖元件 `client:only`、其他全部純 SSG，bundle 超小。\n\n但 SSG 不是沒代價：資料一更新就得整站重 build，這個專案因為村里資料一年才動一次，重 build 的成本可以忽略，所以 fit。如果是天天變的資料，Next.js 的 ISR（增量靜態再生）就是為這種場景存在的，那時候我會選 Next.js 而不是硬撐 SSG。說到底還是場景 fit，不是 Astro 一定贏 Next.js。\n\n7,774 個頁面 build 時間 7.5 秒。\n\n### 5. 資料：直接從 FIA 拓\n\n> 詳細：[從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)\n\n候選：fork 既有專案的整理過 CSV vs 自己從 FIA 抓原始檔。\n\n選自己抓。理由：**乾淨的資料故事**，加上 schema 跨年漂移、合計過濾、罕用字 mojibake 都得自己處理，這個過程也是學習。\n\n但要把這個 trade-off 講清楚，別讓人誤會「自己拓一定比較好」：如果你的目標是**快速產出**、把東西做出來給人用，fork 別人整理好的 CSV 才是務實選擇，可以省掉好幾小時的 ETL 苦工。我選自己拓，是因為這是個人專案、沒有交付壓力，付得起這個「多花 3 小時換乾淨資料」的奢侈。換成接案或有 deadline，我大概會直接 fork。\n\n### 6. 排名：Competition Ranking\n\n> 詳細：[競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)\n\n候選：Standard / Competition / Modified Competition / Dense / Fractional 共 5 種。\n\n選 Competition Ranking。理由：跟奧運排名一致，使用者直覺；同分共享是公平的處理；「跳過名次」雖然會讓最大排名 < 總數，但這是正確行為。\n\n## 4 個踩過的坑\n\n### 坑 1：NLSC zip 內含 Big5 編碼檔名\n\n[內政部國土測繪中心](https://maps.nlsc.gov.tw/) 提供的村里界 shapefile 是 zip 內含中文檔名。**macOS 預設 `unzip` 會崩潰**，因為它把檔名當 UTF-8 解析但裡面是 Big5。\n\n```text\ncheckdir error: cannot create villages-shp/ß¯®Ω¨…æ˙•vπœ∏Í_113\nIllegal byte sequence\n```\n\n解法：用 macOS 內建的 `ditto`，它會處理 legacy filename charsets：\n\n```bash\nditto -x -k villages-1130928.zip villages-shp/\n```\n\nLinux 上 `unzip -O cp950` 也可以指定編碼——但要注意 `-O` 不是上游 Info-ZIP 的預設選項，是 Debian / Ubuntu 打的 patch 才有，其他發行版（或自己編譯的版本）不一定吃這個 flag。\n\n政府服務的 zip 到 2026 年還是常用 Big5 檔名，這件事我已經學會不要再期待它會改了，手邊備好 `ditto` 跟支援 `-O` 的 `unzip` 就是。\n\n### 坑 2：MapLibre 強制把容器設為 position:relative\n\n我的 HTML 結構是：\n\n```html\n<section class=\"relative h-[70vh]\">\n  <div id=\"map\" class=\"absolute inset-0\"></div>\n</section>\n```\n\n預期：`absolute inset-0` 讓 `#map` 填滿父 section。\n\n實際：**MapLibre 把 `#map` 強制設為 `position: relative`**（為了 attribution / control 的內部定位），讓 `absolute inset-0` 整組失效。\n\n```text\nmapW: 902, mapH: 0  ← 0 高度！\n```\n\n地圖渲染進一個 0 高度的容器。沒任何錯誤訊息。\n\n解法：別用 `absolute inset-0`，直接用 `h-full w-full`：\n\n```html\n<section class=\"relative h-[70vh]\">\n  <div id=\"map\" class=\"h-full w-full\"></div>\n</section>\n```\n\n這個坑教我一件事：第三方 widget 常常會偷改容器 style，而且不報錯。後來只要遇到「東西不顯示但 console 一片乾淨」，我都先打開 `getComputedStyle()` 看實際算出來的樣式，比盯著 console error 有用太多。\n\n### 坑 3：Astro hydration 後容器尺寸變動，MapLibre 卡 0×0\n\n跟坑 2 相關但更陰險。\n\n修好容器高度後，地圖底圖偶爾還是渲染失敗。canvas 大小正確、`isStyleLoaded()` 為 true、attribution 顯示出來，但 **0 個 tile request**。\n\n挖下去發現：MapLibre 在 `new Map()` 時記錄容器尺寸，**後續容器尺寸變動不會自動觸發 tile fetch**。Astro 的 island hydration 會在 map 建立後重新算 layout，導致 map 內部的 viewport 仍是 0×0。\n\n解法：在 constructor 後排幾個 resize：\n\n```typescript\nconst forceResize = () => this.map.resize();\nrequestAnimationFrame(forceResize);\nsetTimeout(forceResize, 100);\nsetTimeout(forceResize, 500);\n```\n\n醜，但 work。\n\n**Lesson**：第三方 widget + SSG/island 框架的整合常有「初始化時機」問題。一個 hack-y 但穩定的解法（多次 resize）比追求乾淨更實用。\n\n### 坑 4：tippecanoe `--read-parallel` 切碎 GeoJSON 解析錯誤\n\n跑 tippecanoe 把 GeoJSON 變 PMTiles 時遇到：\n\n```text\nvillages.simplified.geojson:1247: Found ] at top level\nvillages.simplified.geojson:1040: Reached EOF without all containers being closed\n```\n\n奇怪，我的 GeoJSON 結構明明是正確的，第 1247 行也不會有問題。\n\n挖下去發現是 `--read-parallel` 把 5.6 MB 的 GeoJSON 切成多個 chunk 平行解析。我的 GeoJSON 是「一行一個 feature」的格式（mapshaper 預設輸出），平行讀會在某些行切錯。\n\n解法：拿掉 `--read-parallel`。單線程讀 30 秒，不算慢。\n\n這裡的教訓是：CLI 工具的「快速 mode」flag 通常偷偷假設了某種輸入格式。`--read-parallel`、`--parallel`、`--fast` 這類 flag 一出問題，我現在的反射動作是先回到單線程版本確認，是工具的鍋還是我資料的鍋，一試就知道。\n\n## 整個專案的總結\n\n**規模**：\n\n- 5 個 routes + 7,774 個 SSG 頁面（5 + 22 + 7,747）\n- 11 年 × 4 指標 × 2 維度 = 88 組排名 pre-compute\n- 5.7 MB PMTiles + 17 MB stats JSON + 39 MB rankings JSON\n- 月成本 $0（OpenFreeMap + PMTiles + 靜態託管全免費）\n\n**時間**：\n\n- 規劃 + 研究：1 天（含底圖、色階、PMTiles 三個技術選型）\n- 資料管線：1 天（FIA fetcher + stats + rankings + verify）\n- 地圖 + UI：1.5 天（5 個頁面 + 地圖互動 + 搜尋 + 圖表）\n- 部署 + 收尾：0.5 天\n- 總計 4-5 天\n\n**事後回頭看才想通的幾件事**：\n\n做完這個專案，我最有感的一個轉變是：我以前會問「哪個技術比較好」，現在會先問「在我這個場景哪個比較 fit」。Mapbox 是業界標準，但 5 萬次/月對開源專案太緊；自架 tile server 技術上完全可行，但對一個要 $0 月支出的專案來說，PMTiles 的單檔方案就是更對；FIA CSV 自己拓比 fork 別人多花 3 小時，可是換來乾淨的資料故事。沒有一個決定是純技術考量。不過要補一句：這套「看 fit 不看絕對好壞」的邏輯有個前提，就是這些維度都還能妥協。有一個維度我完全不讓步，就是稅務數字本身的正確性，這個錯了整個地圖就沒有存在意義，所以資料管線那關我做了最多 verify。\n\n第二件事是新興工具真的值得試。OpenFreeMap（2024）、PMTiles（2022）、felt/tippecanoe（接手 Mapbox 棄子），這些都是 2-3 年前還不存在或不成熟的東西。技術圈的習慣是等成熟再用，但小型個人專案剛好是試新東西最划算的場域，賭錯了也只是我自己多花幾天。我會這樣賭，正是因為它低風險；同樣這幾個工具如果要進公司的生產系統，我會先盯著它們的維運狀態和 backup 方案再說，不會這麼隨性。\n\n第三件事比較像心態：「不完美但可用」是公民科技的常態。99.66% 的村里 JOIN 率、80% 的功能、$0 月成本，這個組合對開源 / 公民科技其實已經夠用了。追求 100% 完美常常會把預算和熱情都耗光，做不到上線那一步。但「夠用」也要分維度，前面講的稅務正確性就不在「夠用就好」的範圍內。\n\n## 系列全集\n\n整個系列總共 10 篇，按推薦閱讀順序：\n\n1. **入門基礎** → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)\n2. **底圖選型** → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)\n3. **資料管線** → [從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰](/blog/tw-gov-open-data-csv-etl-fia-tax)\n4. **色階設計** → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n5. **排名演算法** → [競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)\n6. **進階技術** → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)\n7. **整體復盤** → 本文\n8. **色階再進化** → [為什麼我把所得地圖色階從 viridis 換成 OrRd：把「一個決策」拆成「兩個獨立軸」](/blog/orrd-warm-palette-two-decisions-framework)\n9. **搬站踩坑** → [Cloudflare Pages 的 20,000 檔案上限：TaxMap-TW 為什麼搬到 Netlify](/blog/cloudflare-pages-20000-file-limit-taxmap-netlify)\n10. **OG 圖生成** → [用 Cloudflare Worker 按需生成 OG 圖：Satori + resvg 為 TaxMap-TW 產 7,750 張預覽圖](/blog/cloudflare-worker-on-demand-og-satori-taxmap)\n\n> 本文（第 7 篇）寫的是「當時上線那一刻的決策」。第 8、9 篇記錄的是上線之後我又改掉的兩件事——色階換成 OrRd、託管從 Cloudflare Pages 搬到 Netlify——所以前面決策段落裡你會看到對應的後記註記。\n\nGitHub repo：[TaxMap-TW](https://github.com/bobo52310/TaxMap-TW)",
      "summary": "TaxMap-TW（台灣所得稅地圖）月成本 $0、4-5 天從零上線的完整復盤：底圖、色階、PMTiles、Astro 6 SSG、FIA 直拓、Competition Ranking 共 6 個技術決策，與 macOS unzip Big5、MapLibre 容器尺寸卡 0×0 等 4 個踩過的坑。",
      "image": "https://bobochen.dev/_astro/cover.QyDoHDUQ.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "Postmortem",
        "專案總結",
        "Astro",
        "MapLibre",
        "公民科技"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/tw-gov-open-data-csv-etl-fia-tax/",
      "url": "https://bobochen.dev/blog/tw-gov-open-data-csv-etl-fia-tax/",
      "title": "從 PDF / CSV 到 JSON：政府開放資料的 ETL 實戰",
      "content_text": "做 TaxMap-TW 時清理財政部所得稅 CSV 踩到 6 個坑：民國年 vs 西元年命名陷阱、schema 跨年漂移、BOM / 引號變化、「合計」與「其他」過濾、村里名罕用字 mojibake、早期年度只有 PDF 沒 CSV。記錄這些政府開放資料的真實樣貌與 ETL 處理 pattern。",
      "content_html": "做 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 最讓我意外的不是地圖、不是色階、是資料清理。\n\n我以為「政府開放資料」就是去 [data.gov.tw](https://data.gov.tw/) 抓 CSV → 解析 → 完成。實際做才發現：\n\n- 民國年 vs 西元年命名混淆\n- 同一個 dataset 不同年度 schema 不一樣\n- BOM、引號規則跨年漂移\n- 表格裡藏著「合計」「其他」要過濾\n- 村里名有罕用字 mojibake 跨資料集對不起來\n- 早期年度只有 PDF，沒 CSV\n\n每一條單看都不是技術難題，但全部疊起來，地圖本身兩個晚上就會動了，光把這份資料弄乾淨我花了快一週。\n\n這篇整理我在處理財政部所得稅 CSV 時踩到的 pattern，這些 pattern 在不少政府開放資料上都能類推（至少 CSV / 下載式 dataset 這一類）。\n\n## 陷阱 1：民國年 vs 西元年\n\n財政部的所得稅 CSV 檔名長這樣：\n\n```text\n111_165-9.csv\n112_165-9.csv\n113_165-9.csv\n```\n\n`111`、`112` 是民國年。**但這個民國年指的是「所得發生年」，不是「發布年」或「申報年」。**\n\n舉例：\n\n| 檔名 | 民國年 | 所得發生 | 申報 | 初步核定 | 正式核定 |\n|------|--------|----------|------|---------|---------|\n| `111_165-9.csv` | 111 | 2022 | 2023/5 | 2024/6 | 2025/4 |\n| `112_165-9.csv` | 112 | 2023 | 2024/5 | 2025/6 | **2026/4/30** |\n| `113_165-9.csv` | 113 | 2024 | 2025/5 | **預估 2026/6-7** | 預估 2027/4 |\n\n所以「2024 年的資料」是模糊的：\n\n- 「2024 年發布的」= 民國 111（income year 2022）的核定版？還是民國 112 的初步核定版？\n- 「income year 2024」= 民國 113 = 還沒發布\n\n寫程式時要明確：**所有公開介面都用西元年（income year），內部存民國年只用在組 URL**。\n\n```typescript\nconst ROC_OFFSET = 1911;\nconst rocToCe = (rocYear: number) => rocYear + ROC_OFFSET;\nconst fiaCsvUrl = (rocYear: number) =>\n  `https://www.fia.gov.tw/WEB/fia/ias/ias${rocYear}/${rocYear}_165-9.csv`;\n```\n\n這樣 user-facing 永遠是「2022 年所得」，build script 才用 `111` 組 URL。\n\n## 陷阱 2：Schema 跨年漂移\n\n抓下 11 年的 CSV（民國 101–111）後做了第一個 sanity check：每個檔頭幾個 bytes：\n\n```text\n101: e9 84 89 (鄉鎮市區)\n105: ef bb bf e9 84 89 (BOM + 鄉鎮市區)\n106: 22 e9 84 89 (引號 + 鄉鎮市區)\n110: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)\n111: 22 ef bb bf e7 b8 a3 (引號 + BOM + 縣市別)\n```\n\n三件事跨年變了：\n\n1. **BOM**：民國 101–104 沒 BOM，民國 105 起有 BOM\n2. **引號**：民國 101–105 欄位無引號，民國 106 起加上雙引號\n3. **欄位名**：民國 101–110 的第一欄叫「鄉鎮市區」，民國 111 改叫「縣市別」（但實際資料一樣，都是「臺北市松山區」這種拼接字串）\n\n實際資料一致只是 header label 換名，所以解析時不能用「欄位名匹配」這種精準方法。**用欄位順序、忽略 BOM、忽略引號變化**：\n\n```typescript\nimport Papa from 'papaparse';\n\nconst csv = await readFile(csvPath, 'utf-8');\n// 跨年安全：strip BOM 若存在\nconst trimmed = csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv;\n\nconst parsed = Papa.parse(trimmed, {\n  header: false,  // ← 不要用 header: true，跨年欄位名不一樣\n  skipEmptyLines: true,\n  transform: v => v.trim(),\n});\n\n// 直接用 row[0], row[1] 索引取值\nfor (const row of parsed.data) {\n  const cityTownship = row[0];  // 「臺北市松山區」\n  const village = row[1];        // 「中華里」\n  const mean = Number(row[4]);   // 平均所得\n}\n```\n\n關鍵點：在這個 dataset 裡，欄位順序比欄位名穩定。政府資料的 schema 命名隨時可能改，但欄位順序通常不太動（因為改順序會破壞既有使用者）。\n\n不過「信任欄位順序」不是免費的午餐，它有自己的失效情境。欄位名匹配遇到改名會直接報錯、逼你正視；位置索引遇到中間插一欄、刪一欄、或某年悄悄把兩欄對調，`row[4]` 還是讀得出一個數字，只是默默讀錯——這種**靜默取錯**比 header 對不上更危險，因為它不會炸，會一路把錯的值算進統計。我自己跨年漂移那段就是最好的反例：欄位名才剛被證明會跨年改，憑什麼相信順序就一定不改？\n\n所以我的折衷是「位置索引 + 輕量驗證」：解析時照樣用 `row[0]`、`row[1]` 取值，但每個檔開頭加一道 sanity check，確認欄位數和關鍵欄的型別跟我預期的一致，對不上就讓 build 直接 fail，而不是默默產出錯誤資料。\n\n```typescript\nconst EXPECTED_COLS = 10; // 這個 dataset 固定 10 欄\nfor (const row of parsed.data) {\n  // 欄位數變了 → 大概率插欄/刪欄/重排，立刻停下來重新核對 schema\n  if (row.length !== EXPECTED_COLS) {\n    throw new Error(`欄位數異常：預期 ${EXPECTED_COLS}，實際 ${row.length}`);\n  }\n  // 該是數字的欄位卻不是數字 → 順序可能跑掉了\n  if (Number.isNaN(Number(row[4]))) {\n    throw new Error(`row[4] 應為平均所得數字，實際拿到「${row[4]}」`);\n  }\n}\n```\n\n位置索引的賭注是「順序穩定」，驗證就是替這個賭注買的保險：賭對了幾乎零成本，賭錯了它會在第一個檔就尖叫，而不是讓你三個月後才發現某年的平均所得整欄錯位。\n\n## 陷阱 3：「合計」「其他」要過濾\n\n逐列檢視資料後發現：\n\n```csv\n\"臺北市松山區\",\"中崙里\",\"1439\",\"2452035\",\"1704\",\"817\",\"356\",\"1902\",\"5899.26\",\"346.20\"\n\"臺北市松山區\",\"自強里\",\"3029\",\"4355839\",\"1438\",\"666\",\"285\",\"1629\",\"3517.94\",\"244.63\"\n...\n\"臺北市松山區\",\"其他\",\"451\",\"340002\",\"754\",\"498\",\"268\",\"1016\",\"818.87\",\"108.62\"\n\"臺北市松山區\",\"合計\",\"69082\",\"98796332\",\"1430\",\"729\",\"310\",\"1639\",\"4770.51\",\"333.57\"\n\"臺北市大安區\",\"和平里\",\"...\",\"...\",\"...\",\"...\",\"...\",\"...\",\"...\",\"...\"\n```\n\n每個鄉鎮市區的最後兩列是 `其他` 和 `合計`。**這兩列不是村里**：\n\n- 「其他」：該鄉鎮中歸不到任何里的納稅單位\n- 「合計」：該鄉鎮的小計（所有里 + 其他的加總）\n\n如果不過濾，會被當成「2 個額外的村里」算進統計，總數就會錯。每個檔 ~387 個合計 + ~406 個其他要過濾掉。\n\n```typescript\nconst SKIP_VILLAGES = new Set(['合計', '其他']);\n\nfor (const row of parsed.data.slice(1)) {  // skip header row\n  const village = row[1];\n  if (SKIP_VILLAGES.has(village)) continue;\n  // 真正的村里\n}\n```\n\n過濾掉後民國 111 剩 7,748 個村里。這是 FIA 這份資料過濾後可對應到的村里數，與內政部公布的全台村里口徑量級一致（官方數字本身會逐年微調，不是固定值），不是什麼「官方公布的全台村里總數」——別把這份稅務資料的計數直接當成戶政權威數字。\n\n## 陷阱 4：拼接字串 split\n\n第一欄是「臺北市松山區」這種拼接字串。要拆成「縣市」+ 「鄉鎮市區」兩欄。\n\n我本來以為要寫個正則或字典 lookup。實際看資料：\n\n```text\n南投縣、嘉義市、嘉義縣、基隆市、宜蘭縣、屏東縣、\n彰化縣、新北市、新竹市、新竹縣、桃園市、澎湖縣、\n臺中市、臺北市、臺南市、臺東縣、花蓮縣、苗栗縣、\n連江縣、金門縣、雲林縣、高雄市\n```\n\n**全部 22 個縣市都是 3 個中文字**（最後一字為「市」或「縣」）。\n\n```typescript\nfunction splitCityTownship(combined: string) {\n  return {\n    city: combined.slice(0, 3),\n    township: combined.slice(3),\n  };\n}\n```\n\n3 行解決。但要老實說：`slice(0, 3)` 能成立，純粹是「現在這 22 個縣市剛好都是 3 個字」這個巧合。它不是定律——萬一哪天行政區改名、或這份資料混進別的縣市格式，這刀就會切歪，而且一樣是默默切歪。所以我把這個巧合當「可利用但要驗證」的規律，配一張白名單 assert 當安全網：\n\n```typescript\nconst KNOWN_CITIES = new Set([\n  '臺北市', '新北市', '桃園市', '臺中市', '臺南市', '高雄市',\n  '基隆市', '新竹市', '嘉義市', '新竹縣', '苗栗縣', '彰化縣',\n  '南投縣', '雲林縣', '嘉義縣', '屏東縣', '宜蘭縣', '花蓮縣',\n  '臺東縣', '澎湖縣', '金門縣', '連江縣',\n]); // 22 個\n\nfunction splitCityTownship(combined: string) {\n  const city = combined.slice(0, 3);\n  if (!KNOWN_CITIES.has(city)) {\n    throw new Error(`切出非預期縣市「${city}」，原字串：${combined}`);\n  }\n  return { city, township: combined.slice(3) };\n}\n```\n\n規律性是政府資料的隱藏資產，多看幾個 row 比寫 fancy 解析器有用；但「規律」和「保證」是兩件事，能利用就順手加一個 assert 把假設釘死，免得哪天規律破了你還蒙在鼓裡。\n\n## 陷阱 5：跨資料集 JOIN 的罕用字 mojibake\n\n最痛苦的是這個。\n\n我要把 FIA 的「縣市|鄉鎮|村里」資料，跟 NLSC 國土測繪中心的村里界 GeoJSON（用 `VILLCODE` 索引）對起來。\n\n理論上「臺北市松山區中華里」在兩邊都該存在。實際做 JOIN：\n\n```typescript\n// 7,747 個 NLSC codified villages\n// 7,721 matched FIA\n// 26 不 match\n```\n\n99.66% 命中率。剩下 26 個是這種：\n\n```text\n嘉義市|西區|磚𥕢里     ← FIA 用「𥕢」\n嘉義市|西區|磚磘里     ← NLSC 用「磘」\n\n臺南市|安南區|塩埕里  ← FIA 用「塩」\n臺南市|安南區|鹽埕里  ← NLSC 用「鹽」（標準字）\n```\n\n兩個資料集對同一個村里用了不同的「異體字」或「PUA private use area」字。\n\n實際上 [kiang/salary](https://github.com/kiang/salary) 維護一份手動對照表來處理這些，但對我這個「在地圖上塗色」的 MVP 來說 99.66% 已經很夠。\n\n不過這個「夠」是看用途的，不是通則。我這裡每個村里只是地圖上一塊獨立色塊，缺 26 個就是 26 塊顯示「無資料」，不影響其他村里——這種場景 0.34% 缺漏可以接受。但如果今天是要把全台所得**加總**、算各縣市佔比、判斷「某村里有沒有達到某門檻」這類資格判定、或要拿去做學術統計，那 0.34% 就不能放著：缺的不是隨機 26 個，而是**系統性地漏掉用罕用字/異體字的偏鄉里**，等於你的缺漏剛好集中在某一類地區，會讓加總和分佈悄悄偏掉。用途越接近「精確數字」，這 0.34% 越不可妥協。\n\n處理方式：\n\n```typescript\nconst villages: Record<string, VillageMeta> = {};\nfor (const feature of geojson.features) {\n  const compositeKey = `${COUNTYNAME}|${TOWNNAME}|${VILLNAME}`;\n  const hasFiaMatch = fiaStats.has(compositeKey);\n  villages[VILLCODE] = {\n    code: VILLCODE,\n    // ...\n    hasStats: hasFiaMatch,\n  };\n}\n```\n\n把「有沒有對應 FIA 資料」存進 master JSON，前端遇到沒對應的村里就顯示「目前無資料」。\n\n在「顯示用」的場景下，我寧可承認資料的不完美、誠實標記缺漏，也不硬塞模糊匹配演算法去猜——猜錯一個村里的所得，比老實說「無資料」傷害更大。但這句話別當成「永遠別追 100%」的鐵則：當缺的那 0.34% 會被拿去加總、判定資格或做統計時，該補的對照表還是得補（或至少把缺漏清單攤開讓人知道），而不是用「承認不完美」當不處理的藉口。\n\n## 陷阱 6：早期年度只有 PDF\n\n研究 agent 一開始給我的 URL pattern 是：\n\n```text\nhttps://www.fia.gov.tw/WEB/fia/ias/ias{民國年}/{民國年}_165-9.csv\n```\n\n說「民國 88 (1999) 到民國 112 (2023) 都有」。實測卻發現：\n\n```text\nROC 101-111 (2012-2022): HTTP 200 ✓\nROC 88-100 (1999-2011):  HTTP 404 ✗\nROC 112 (2023):          HTTP 404 ✗\n```\n\n實際上 CSV 只覆蓋 11 年。早期年度官方只有 PDF；2023 年（民國 112）核定版剛在 2026/4/30 公布但 CSV 還沒上架。\n\n這直接砍掉了「1999-2023 共 25 年」這個 scope。我把專案調整為 MVP 只做 2012-2022。\n\n後來我學乖了：政府資料的「公開」常常分好幾種介面（CSV、XLSX、HTML、PDF、API），而且不同年度開放的介面還不一樣。研究 agent 給的 description 寫得再篤定，規劃前我都先 curl 一輪把每年實際拿不拿得到檔案掃過，再決定 scope，省得做到一半才發現有四年根本沒 CSV。\n\n## 政府開放資料 ETL 的 6 條通用 pattern\n\n整理我這次學到的政府開放資料 ETL 通則：\n\n1. **公開介面用西元年** — 民國年只在組 URL 時用\n2. **不信任 header** — 用欄位順序、處理 BOM 與引號變化\n3. **掃描異常列** — 「合計」「其他」「小計」要過濾\n4. **規律性是寶藏** — 多看幾個 row 通常有 dirty 但穩定的 pattern\n5. **承認資料不完美（看用途）** — 顯示用場景，99% 命中 + 標記 1% missing 比強塞演算法可靠；但要拿去加總、判定資格或做統計時，那 1%（且常系統性漏掉偏鄉罕用字）就得補齊\n6. **規劃前先 curl** — 不同年度公開不同介面，description 不可信\n\n如果這六條只能記一條，我會記第 2 條的延伸版：別相信任何人（包括研究 agent、包括 description、包括上一年的自己）對這份資料長相的描述，自己 curl 下來、自己印出前幾個 byte、自己用 assert 把假設釘住。這次大半的坑，本質都是「我以為它長這樣，它其實長那樣」。\n\n也得老實承認：不是所有鍋都甩給上游。回頭看，這些坑有一半是我自己一開始沒先替整條管線立一份「資料契約」——把「應該幾欄、哪欄是數字、村里數量級、哪些值要過濾」這些預期先寫成 schema validation 擺在最前面，後面任何一年違約就立刻 fail。我是踩了三四個坑之後才補上這層驗證的，要是一開始就建，至少能少跑幾趟「產出怪數字 → 回頭 debug」的冤枉路。我也沒法保證這套打法在每一份台灣政府開放資料上都成立——我的樣本就 FIA 這一份（API、XML、奇怪編碼的資料各有各的故事）——但至少 CSV / 下載式 dataset 這一類，照這六條走能少踩很多坑。\n\n## 系列其他文章\n\n資料清乾淨之後，接下來就是「怎麼把這些村里排名、怎麼畫成地圖」，沿著「清理 → 排名 → 視覺化 → 復盤」的脈絡：\n\n- → [競爭排名 vs 密集排名 vs 百分位：地圖標籤的 ranking 設計](/blog/competition-vs-dense-ranking-map-labels)\n- → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)\n- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)\n- → [Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂](/blog/web-map-tile-basics-vector-raster-style)\n- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)",
      "summary": "做 TaxMap-TW 時清理財政部所得稅 CSV 踩到 6 個坑：民國年 vs 西元年命名陷阱、schema 跨年漂移、BOM / 引號變化、「合計」與「其他」過濾、村里名罕用字 mojibake、早期年度只有 PDF 沒 CSV。記錄這些政府開放資料的真實樣貌與 ETL 處理 pattern。",
      "image": "https://bobochen.dev/_astro/cover.CZr_Hb3I.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "資料工程",
        "ETL",
        "開放資料",
        "TypeScript",
        "Papa Parse"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/web-map-tile-basics-vector-raster-style/",
      "url": "https://bobochen.dev/blog/web-map-tile-basics-vector-raster-style/",
      "title": "Web 地圖底圖是什麼？vector vs raster、tile pyramid、style spec 一次搞懂",
      "content_text": "Web 地圖底圖到底是什麼？這篇用最白話的方式把 vector tile vs raster tile、tile pyramid（z/x/y 金字塔）怎麼設計、style spec 為什麼存在一次講清楚，順便聊 raster 和 vector 各自適合的場景。讀完再回去看其他地圖技術文章會順非常多。",
      "content_html": "剛開始碰 Web 地圖時，我看技術文章常常卡在一些術語：vector tile、raster tile、tile pyramid、style spec、z/x/y、PMTiles、MBTiles... 每一個拆開都不難，但混在一起就會頭很大。\n\n寫完 [TaxMap-TW](https://github.com/bobo52310/TaxMap-TW) 後回頭整理，發現 Web 地圖底圖其實就是三個核心概念：**tile**、**format**、**style**。把這三個搞懂，其他術語都是周邊。\n\n這篇給沒碰過 GIS 的讀者鋪底，把這三件事說清楚。\n\n## 第一個觀念：Web 地圖底圖為什麼要切成 tile？\n\n打開 Google Maps，你拖、你縮、它都很順。但全球地圖資料動輒幾百 GB，瀏覽器不可能一次下載完。\n\n解法：**把地圖切成方格**，需要哪一塊才下載哪一塊。這個方格就叫 **tile**。\n\n每個 tile 通常是 256×256 或 512×512 像素的小圖。瀏覽器看你縮放到哪個 zoom level、視窗範圍涵蓋哪些 tile，就只下載那幾張。\n\n縮小看全世界 → 1 張 tile 就夠\n放大到台北街道 → 視窗內有 4-8 張 tile\n\n這個機制讓 Web 地圖能用「漸進式載入」處理 PB 等級的資料。\n\n## Tile pyramid（瓦片金字塔）：z/x/y 是什麼？\n\n你常常會看到地圖圖磚的 URL 長這樣：\n\n```text\nhttps://tiles.example.com/{z}/{x}/{y}.pbf\n```\n\n`z` 是 zoom level（縮放等級），`x`、`y` 是該 zoom 下的座標。\n\n整個地圖被組織成「金字塔」結構：\n\n- **z = 0**：整個地球切成 1 張 tile（1×1 = 1 張）\n- **z = 1**：切成 4 張（2×2）\n- **z = 2**：切成 16 張（4×4）\n- ...\n- **z = 18**：切成 687 億張（262144×262144）\n\n每多一個 zoom level，tile 數量乘 4。z=18 大概是看清楚門牌號碼的等級。\n\n對地圖服務商來說，這個金字塔是固定的世界座標，所有人共享同一套切法。你開發地圖時，瀏覽器自動算出「現在這個視窗在 z=10 需要 (532, 411) 這張 tile」，然後請求那個 URL。\n\n## 第二個觀念：vector tile vs raster tile\n\nTile 有兩種主要格式，差別決定了你的地圖能做什麼。\n\n### Raster tile（點陣圖磚）\n\n每張 tile 是一張預先渲染好的 **PNG / JPG 圖片**。瀏覽器拿到就直接貼上去，沒有運算成本，連最老的瀏覽器都吃得下。代價是樣式被烤進圖裡了：顏色改不了、標籤關不掉，文字放大會糊（因為早就被點陣化），而且每換一種風格就得整套重切，存儲很快就爆。\n\n不過我要先澄清一件事，因為我自己一開始也搞錯：raster 不是「過時」，它是「不同需求」。有些東西本來就只能用 raster——衛星影像、空拍圖、地形暈渲這類本身就是像素的資料，沒有 geometry 可以拿來向量化；要列印或匯出成圖檔時，也是點陣圖最直接。基礎建設上 raster 也最簡單，一個資料夾擺一堆 PNG、丟到任何靜態 host 就能跑，不需要 WebGL、不需要 style JSON。Leaflet 這種老牌、輕量的函式庫原生就吃 raster。\n\n所以老牌地圖服務（Google Maps 早期版本、傳統 OSM tile server）用 raster，不代表它們「劣等」，只是當年那個需求 raster 就夠了。\n\n### Vector tile（向量圖磚）\n\n每張 tile 是 **geometry + properties 的二進位資料**（通常 `.pbf` Protocol Buffer 格式），不是圖片。瀏覽器拿到資料後即時渲染，所以可以動態改顏色、隱藏特定圖層，文字永遠清晰（在客戶端用向量字型正確繪製），而且同一套 tile 可以套用無限多種風格。這幾個優點是我選 vector 的主因。\n\n但 vector 不是免費午餐，這點當初的我也低估了。它要用 WebGL，老瀏覽器不支援，渲染還要吃客戶端的 CPU/GPU——這兩點是課本都會講的。實務上真正咬人的是另外幾件：你得載一包 MapLibre GL JS（壓縮後也有兩三百 KB），外加 glyphs（字型切片）和 sprites（圖示集），這些都算進首屏成本，比直接貼 PNG 重得多；行動裝置上持續的 GPU 渲染也比較耗電。如果你想自己切 vector tile（不是用現成服務），tippecanoe 那套工具鏈和 schema 設計的門檻也比烤一堆 PNG 高一截。最後是中文專案逃不掉的坑：CJK 字型切成 glyphs 後檔案動輒幾十 MB，字型 fallback 沒設好就會看到一堆豆腐方塊。\n\n所以我的結論是：如果你只是要在頁面上放一張不會動的底圖，用 vector 其實是殺雞用牛刀，raster 反而省事；vector 的價值要在「需要動態換樣式、疊資料層」時才真的兌現。\n\n近 5 年的主流地圖服務（Mapbox、MapTiler、OpenFreeMap）多半預設 vector tile，但這是「主流方向」不是「raster 被淘汰」——兩者是分工，各自守著不同的場景。\n\n**簡單記憶法**：raster 是「拍照」，vector 是「設計稿」。設計稿可以改顏色，照片不行；但要的就是一張照片時，硬套設計稿流程反而麻煩。\n\n## 第三個觀念：style spec — 同一份 tile，無限種風格\n\n既然 vector tile 是 geometry + properties，那要怎麼決定「這條路畫多粗」、「這個 polygon 上什麼色」？\n\n答案是 **style spec** — 一份 JSON 規格，描述每個圖層怎麼畫。\n\n[MapLibre Style Spec](https://maplibre.org/maplibre-style-spec/)（從 Mapbox GL JS 衍生）是業界標準。一個典型的 style 長這樣：\n\n```json\n{\n  \"version\": 8,\n  \"sources\": {\n    \"openmaptiles\": {\n      \"type\": \"vector\",\n      \"url\": \"https://tiles.openfreemap.org/planet\"\n    }\n  },\n  \"layers\": [\n    {\n      \"id\": \"water\",\n      \"type\": \"fill\",\n      \"source\": \"openmaptiles\",\n      \"source-layer\": \"water\",\n      \"paint\": {\n        \"fill-color\": \"#a1d6f2\"\n      }\n    },\n    {\n      \"id\": \"road\",\n      \"type\": \"line\",\n      \"source\": \"openmaptiles\",\n      \"source-layer\": \"transportation\",\n      \"paint\": {\n        \"line-color\": \"#ffffff\",\n        \"line-width\": 1\n      }\n    }\n  ]\n}\n```\n\n每個 `layer` 對應 vector tile 裡的一種圖層（water、road、building...），定義怎麼畫。\n\n換 style URL = 換樣式。底圖資料完全一樣，但灰白底色、深色模式、夜景樣式都是換 style 達成的。\n\n不過「無限種風格」有個前提常被忽略：你只能畫 tile 裡有的東西。style 是在既有的 geometry + properties 上塗色，tile 沒切進去的圖層（例如某個 POI 類別、某條行政界線），style 再怎麼寫也變不出來——那種時候只能回頭重切一份 tile。所以 style 給你的是「呈現自由」，不是「資料自由」。\n\nOpenFreeMap 最常用的是 positron（灰白）、bright（彩色）、liberty（OSM 經典）三種預設 style，另外還有 dark、fiord 等選擇；當然你也完全可以自己寫一份 style JSON 丟給 MapLibre 用。\n\n## 一個典型的 Web 地圖管線\n\n理解了 tile、format、style 之後，現代 Web 地圖的完整管線就清楚了：\n\n```text\nOSM 原始資料 (PB 等級)\n    ↓ 切 tile\nVector tiles ({z}/{x}/{y}.pbf)\n    ↓ 由 tile server / CDN serve\n你的瀏覽器\n    ↓ MapLibre 讀取 tile + 套用 style\n畫在 canvas 上的地圖\n    ↓ 你加上自己的資料層（標記、polygon...）\n完整的應用\n```\n\n對開發者來說，要做決定的點是：\n\n1. **Tile 從哪來**：用第三方服務（OpenFreeMap / MapTiler / Mapbox）？自架？\n2. **Style 用什麼**：服務商預設？自己寫？\n3. **怎麼顯示**：MapLibre / Leaflet（原生只支援 raster，要吃 vector 需外掛）/ deck.gl？\n\n大致上，「Web 地圖底圖」這個題目的自由度就落在這三件事上——當然細節還有很多，但抓住這三條主軸就不太會迷路。\n\n## 三個延伸場景\n\n**場景 A：你只是要在網站上放一張可拖拉的地圖**\n\n最簡單：用 [Leaflet](https://leafletjs.com/) + OSM raster tiles。10 行 code 搞定。\n\n**場景 B：你要做資料視覺化（choropleth、heatmap）**\n\n進階：MapLibre + vector tile 服務（OpenFreeMap 或 MapTiler）+ 自己加資料層。\n\n**場景 C：你要做完全離線、或客製化到底的地圖**\n\n最進階：自架 tile server，或者用 [PMTiles](/blog/pmtiles-http-range-request-single-file-tiles) 把 tile 打包成單檔。\n\nTaxMap-TW 我走的是場景 B：要在底圖上疊 7,747 個村里的所得 choropleth，又想要灰白底圖不搶戲、之後還能換深色模式，所以選了 MapLibre + OpenFreeMap 的 vector tile，自己加一層 GeoJSON 資料層。每個場景對 tile、style、顯示函式庫的選擇都不一樣，先想清楚自己在哪一格，後面的決定才不會亂套。\n\n## 收尾\n\n寫到這裡，前面的術語應該都能 mapping 回三個核心概念：\n\n- **z/x/y URL** = tile 在金字塔的座標\n- **PBF / MBTiles / PMTiles** = vector tile 不同的封裝格式\n- **Style URL** = 怎麼把 vector tile 變成你看到的地圖\n- **WebGL** = vector tile 需要的渲染技術\n\n老實說這三個概念我當初前後摸了快兩個晚上才串起來，但串起來之後再看任何一篇地圖技術文章，那些術語就各自歸位、不再嚇人了。如果要我給個偏好：大多數人其實不需要急著鑽 vector 的細節，先用場景 A 的 raster 把地圖跑起來、確定真的需要動態樣式了，再升級到 vector 也不遲。\n\n## 系列其他文章\n\n- → [PMTiles 取代傳統 tile server：HTTP Range Request 的單檔魔術](/blog/pmtiles-http-range-request-single-file-tiles)\n- → [OpenFreeMap vs MapTiler vs Mapbox：6 個 Web 地圖底圖服務怎麼選？](/blog/openfreemap-maptiler-base-map-comparison)\n- → [資料地圖該用哪種色階？viridis、YlGnBu 與 ColorBrewer 實戰指南](/blog/data-map-color-scale-viridis-ylgnbu)\n- → [打造 TaxMap-TW 完整心得：6 個技術決策、踩了 4 個坑](/blog/taxmap-tw-postmortem-6-decisions-4-pitfalls)",
      "summary": "Web 地圖底圖到底是什麼？這篇用最白話的方式把 vector tile vs raster tile、tile pyramid（z/x/y 金字塔）怎麼設計、style spec 為什麼存在一次講清楚，順便聊 raster 和 vector 各自適合的場景。讀完再回去看其他地圖技術文章會順非常多。",
      "image": "https://bobochen.dev/_astro/cover.ixw25Swj.webp",
      "date_published": "2026-05-27T00:00:00.000Z",
      "tags": [
        "WebGIS",
        "地圖",
        "入門",
        "MapLibre",
        "OpenStreetMap"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/mosh-mobile-shell-intro/",
      "url": "https://bobochen.dev/blog/mosh-mobile-shell-intro/",
      "title": "Mosh 入門 — 把 SSH 帶到不穩網路上的 mobile shell",
      "content_text": "高鐵上、咖啡店、跨洲 SSH 一斷線就崩潰？Mosh 用 UDP + 本地回顯，讓 session 撐過 sleep、IP 切換、丟包，是行動辦公的標配工具。",
      "content_html": "## 背景\n\n你有沒有遇過這種情況：在高鐵上 `ssh` 到公司機器跑東西，過個山洞訊號掉一下，整個 session 就死了。回到位子上得重新連、重新進 tmux、重新 `cd` 回剛剛的目錄。\n\n或者更日常的：在咖啡店 Wi-Fi 連美西的 server，每按一個鍵都要等個半秒才看到字。寫個 `vim` 都覺得自己是在打電報。\n\n這兩件事的根源是同一個 — SSH 是建在 TCP 上的：\n\n1. **TCP 一斷就死** — IP 變了、封包丟太久、筆電 sleep，連線就不可挽回\n2. **每個按鍵都要等 RTT** — 終端機要等 server 回顯，你才看得到自己打了什麼\n\nMosh 就是為了解決這兩個問題而生的。\n\n## 什麼是 Mosh\n\n**Mosh = Mobile Shell**，2012 年從 MIT CSAIL 出來的研究專案。它的設計哲學很乾脆：\n\n> **SSH 負責登入認證，Mosh 接手互動 shell。**\n\n實際流程是：\n\n```\n1. mosh client 用 SSH 連到 server，照常做 key 認證\n2. server 啟動 mosh-server，回傳一個 UDP port + 共享金鑰\n3. SSH 結束，client 改用 UDP 跟 mosh-server 講話\n4. 後續所有按鍵、畫面更新都走加密 UDP\n```\n\n重點：**Mosh 不取代 SSH，是互動 shell 場景的延伸**。`scp`、`rsync`、port forwarding 那些事還是給 SSH 做。\n\n## 三個殺手級功能\n\n### 1. Local echo（本地回顯預測）\n\nMosh client 會「猜」你按的鍵會在螢幕上長什麼樣，有把握的就立刻顯示，沒把握的會加個底線等 server 確認。\n\nUSENIX 2012 論文的實測數字夠誇張：3G 環境下，**SSH 的按鍵中位延遲 503ms，Mosh < 5ms**。寫 vim、用 REPL、改 config 檔的體感差距是天和地。\n\n### 2. Roaming（IP 切換不斷線）\n\nMosh server 把「最新一個有效封包的來源 IP」當成你的位址。所以你從咖啡店 Wi-Fi 走出去切到 LTE、回辦公室再換成有線網路 — session 完全不會斷，連 reconnect 動作都不用做。\n\n對筆電帶著到處跑的人來說，這個功能等於再也不用煩惱「我等下要移動，先 disconnect 一下」。\n\n### 3. 持久連線（sleep / 斷網都撐得住）\n\n筆電闔上、進地下道、Wi-Fi 抖一下、咖啡店把你踢出網路 — Mosh 都不會把 session 砍掉。Client 端會在最上面顯示「多久沒收到 server 訊息」，網路一回來就自動接上。\n\n## 基本操作\n\n安裝（**client 和 server 兩邊都要裝**）：\n\n```bash\n# macOS\nbrew install mosh\n\n# Debian / Ubuntu\nsudo apt install mosh\n```\n\n第一次連線，用法跟 SSH 一模一樣：\n\n```bash\nmosh user@host\n# 也支援指定 ssh port、identity\nmosh --ssh=\"ssh -p 2222 -i ~/.ssh/work\" user@host\n```\n\n經典組合：搭配遠端 tmux，補上 Mosh 沒有 scrollback 的缺點：\n\n```bash\n# 連上去自動 attach 到 tmux session\nmosh user@host -- tmux a -t main\n```\n\n這套組合是行動辦公的黃金搭檔 — Mosh 撐連線、tmux 撐 session 與歷史。\n\n## 什麼時候該用 / 不該用\n\n| 情境                              | 用 SSH                | 用 Mosh                |\n| --------------------------------- | --------------------- | ---------------------- |\n| 跨洲遠端 vim / tmux / REPL        | 延遲爆炸              | 本地回顯瞬間反應       |\n| 筆電帶著到處跑、頻繁換網路        | 切網路就斷            | session 不死           |\n| 不穩 Wi-Fi、LTE hotspot 上開 shell | 一斷就重連            | 自動接回去             |\n| `scp` / `rsync` / port forwarding | ✓                     | ✗（這些事還是用 SSH）  |\n| 跳板機 / 沒裝 mosh-server 的雲端機 | ✓                     | ✗（兩邊都要裝）        |\n| 跑 `tail -f` 看完整 log           | ✓                     | △（會跳過中間畫面）    |\n\n## 已知限制（坑）\n\n實際用之前要知道的事：\n\n1. **兩邊都要裝** `mosh-server` — 雲端機常缺、跳板機也常沒裝\n2. **UDP 60000–61000** — 預設用的 UDP port range，企業 firewall / cloud security group 常常擋掉，要記得開\n3. **沒有 scrollback** — Mosh 同步的是「目前可見的螢幕」，不是完整歷史。往上捲不回去 → 解法：遠端跑 tmux 或 screen\n4. **沒有 port forwarding / agent forwarding** — 這個是 issue #120 的長期未解。要 SSH 隧道、要 agent 轉發，乖乖回去用 SSH\n5. **不負責檔案傳輸** — `scp`、`sftp`、`rsync`、Git over SSH 都跟 Mosh 無關\n\n簡單記：**互動 shell 用 Mosh，其他通通用 SSH。**\n\n## 替代品與競品（2026 現況）\n\n過去幾年陸續有人想做「Mosh 的後繼者」，但截至 2026 年 5 月，沒有任何一個真正取代它。簡單盤點：\n\n| 工具                                | 路線                                       | 2026 狀態                                       | 能取代 Mosh 嗎？                                                            |\n| ----------------------------------- | ------------------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------- |\n| **trzsz/tssh + tsshd**              | Go 寫的 OpenSSH 相容工具，UDP over QUIC / KCP | 最新 release 2026-05，**最活躍**                | ◯ 補上 scrollback / port forwarding / agent forwarding，但**刻意不做 local echo** |\n| **Eternal Terminal (et)**           | TCP-based session 持久化 + scrollback      | v6.2.11（2024-07），bugfix commit 還在進        | △ 有 scrollback、有 reconnect，但 TCP-only，沒有 UDP roaming 體感           |\n| **Blink Shell**                     | iOS / iPadOS 終端機 app                    | 持續更新                                        | ✗ 它**內建 Mosh 協定**，是 Mosh 的最佳行動載體，不是替代品                  |\n| **SSH3**                            | 學術派 QUIC-based SSH 重寫                 | v0.1.7（2024-01），作者明說別上 production      | ✗ 還在實驗階段                                                              |\n| **Tailscale SSH / Cloudflare SSH**  | 給 SSH 補上 zero-config 連線               | 都在活躍開發                                    | ✗ 解的是「怎麼連到 server」，不是「連上後體驗」                             |\n| **QUICSSH、zmx 等 2026 新工具**     | QUIC native 或 tmux 替代品                 | 剛冒出來、community 還沒驗證                    | △ 觀察名單                                                                  |\n\n**結論**：2026 年的局面是分工，不是取代。\n\n- 要 **local echo + 不穩網路上順手打字** → 還是 Mosh\n- 要 **scrollback + port forwarding + 現代 OpenSSH config 相容** → 看 **tssh + tsshd**\n- 要解決「**server 怎麼連得到**」這層 → 看 Tailscale / Cloudflare Access\n\n換句話說，Mosh 沒被取代不是因為它技術多神，是因為**沒人願意實作 local echo prediction**。這功能技術上不難，但對按鍵體感的影響太大，沒有它的工具就是「另一種 SSH 變種」。\n\n## 那實際上大家在用什麼？\n\n講完技術競品，回到現實 — **2026 年最主流的做法不是 Mosh，是無聊的 `ssh + tmux`**。\n\n| 層次             | 用法                                  | 為什麼主流                                                                       |\n| ---------------- | ------------------------------------- | -------------------------------------------------------------------------------- |\n| **真・最大宗**   | `ssh user@host` + 遠端 `tmux a`       | server 不用裝東西、所有雲端機都能用、防火牆不擋。會用 tmux 已經能撐 80% 場景。   |\n| **進階個人開發者** | Mosh + 遠端 tmux                      | 願意為「按鍵不延遲」+「IP 切換不斷線」付兩邊都要裝、UDP 要開洞的代價。           |\n| **現代團隊（爆發中）** | **Tailscale SSH** / Cloudflare Access | 不解 latency，解的是「怎麼連得到 + ACL 怎麼管」。近兩年新創幾乎全面換掉 VPN。 |\n\n所以實際的選擇大概是：\n\n- **問「個人怎麼連 server」** → 大部分人還是 `ssh + tmux`，少部分死忠用 Mosh\n- **問「團隊怎麼管遠端 access」** → Tailscale SSH 或 Cloudflare Access 已經是 2026 主流\n- **問「最強的技術方案」** → `tssh + tsshd`，但安裝成本和 SSH 一樣高、認知成本更高，主流採用還沒起來\n\nMosh 在這個光譜上是一個明確的「**進階個人選擇**」 — 它不會是團隊的標配，但會是某些開發者的隨身工具。\n\n## 反思\n\n### 技術面\n\nMosh 1.4.0 停在 2022 年 10 月，到現在三年多沒出新 stable release。但 GitHub master 還是有人在 commit，最近一筆是 2026 年 3 月。\n\n這不是 dead project，比較精準的描述是 **active but slow-moving** — 還活著，只是動得很慢。理由也很合理：Mosh 把「不穩網路上的互動 shell」這個窄題已經做完了，不太需要新功能。\n\n### 心態面\n\n我們常用 release 頻率來判斷一個開源專案的健康度，但 Mosh 提醒我們：**有些工具就是「做完了」**。把一個窄而清楚的問題解決到極致，就值得一直被用，不用每年硬擠新版本。\n\n但「做完了」也意味著風險 — 上面競品盤點裡可以看到，tssh + tsshd 已經把 Mosh 沒做到的事（scrollback、port forwarding）補齊。哪一天有人補上 local echo prediction，Mosh 就會被換掉。在那之前，它的地位來自一個有點諷刺的事實：**沒人想再寫一次按鍵預測**。\n\n### 有趣發現\n\niOS 上最知名的 mobile terminal — **Blink Shell** — 整個就是建在 Mosh 上的。一個 2012 年的 MIT 研究專案，撐起了 2026 年 iPad 上的開發者體驗。\n\n下次在高鐵上用 iPad 連回家裡 server 改 code，你用的其實還是這個十多年前的協定。",
      "summary": "高鐵上、咖啡店、跨洲 SSH 一斷線就崩潰？Mosh 用 UDP + 本地回顯，讓 session 撐過 sleep、IP 切換、丟包，是行動辦公的標配工具。",
      "image": "https://bobochen.dev/_astro/cover.OctpdivR.webp",
      "date_published": "2026-05-26T00:00:00.000Z",
      "tags": [
        "Mosh",
        "SSH",
        "CLI",
        "開發工具",
        "遠端工作",
        "終端機"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-llm-observability-eval/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-llm-observability-eval/",
      "title": "生產級 LLM 可觀測性與評估：沒有 eval 的 agent，等於沒有測試的軟體",
      "content_text": "你改了一句 prompt、換了個模型，怎麼知道系統變好還是變壞？拆解 eval harness（黃金題庫、LLM-as-judge、回歸測試）、agent 的 tracing（每一步檢索/工具/模型的 span）、token 與成本監控，以及上線後怎麼偵測品質漂移。把後端的可觀測性硬功夫，搬到會講人話的元件上。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 9 篇（共 12 篇）。上一篇：[多代理協作](/blog/enterprise-ai-agent-multi-agent-orchestration)。\n\n這一篇是整個系列裡，我覺得最能分辨「玩過 agent」和「把 agent 當系統做過」的人的一篇。因為它要講的兩件事——eval 和 observability——在 demo 階段完全不存在，但它們正是讓系統可以被信任、被維運的東西。\n\n先把最重的一句話放這：\n\n> 一個沒有 eval 的 agent 專案，本質上就是**一套沒有測試的軟體**。只是因為它是 AI，大家就默許了這件事。\n\n## 為什麼 agent 的「測試」這麼難，但更不能省\n\n傳統軟體：輸入固定 → 輸出固定 → assert 相等 → 綠燈。\n\nAgent：輸入「請幫我總結這份報告」→ 輸出每次**用字都不一樣**，但可能都對；也可能字面很像、實際上錯了。你沒辦法用 `assertEqual` 測它。\n\n正因為輸出不確定，很多人就放棄測試，改用「我自己試幾題覺得還行」。但這正是災難的開始，因為：\n\n- 你換個模型版本（連 vendor 都可能 silent update），行為就漂移。\n- 你改一句 prompt 修好 A 問題，常常默默弄壞 B 問題。\n- 你調個檢索參數（第 3、4 篇），不知道整體變好還變壞。\n\n**越是不確定的系統，越需要有系統的方法去量它**，而不是越不確定就越靠感覺。\n\n## Eval Harness：給 agent 蓋一套測試\n\n### 1. 黃金題庫（golden set）\n準備一組「問題 + 期望答案 / 期望性質」的題目，涵蓋常見情境、邊界情境、和已知會出包的情境。每次你改任何東西（prompt、模型、檢索），**自動把整組題庫跑一遍**，看通過率。\n\n這就是 agent 版的回歸測試。它不需要很大才有用——**30 題涵蓋你最在乎的場景，就遠勝於零**。重點是它存在、而且每次改動都跑。出包過的 case 一定要補進題庫，確保同樣的錯不會再犯第二次。\n\n### 2. 怎麼判斷「答對」：三種尺\n輸出不固定，怎麼自動判分？由寬到嚴：\n\n- **規則 / 字串**：答案裡有沒有包含某關鍵事實、有沒有附來源、格式對不對。便宜、確定，但只能驗「硬性質」。\n- **語意比對**：用 embedding 比對答案和標準答案的語意相近度。能容忍用字不同。\n- **LLM-as-judge**：用另一個（通常更強的）模型，照你給的標準（正確性、完整性、有沒有幻覺）來評分。最有彈性，但「要小心偏誤」不是句口號——judge 有三個有名字、可被緩解的已知偏誤要主動防：**position bias**（偏好排前面的答案，緩解法是交換順序評兩次取一致）、**verbosity bias**（偏好較長的答案，即使長度沒帶來品質）、**self-preference bias**（偏袒自己或同家族模型的輸出——所以別拿同一個模型既當 judge 又當被評對象）。把這三個釘死，「要小心」才從感覺變成可勾的檢查清單，也才需要搭配規則與語意比對交叉驗證。\n\n實務上常常**三種混用**：硬性質用規則、整體品質用 LLM-as-judge。\n\n### 3. 別只測最終答案，要測中間步驟\n對會做事的 agent，光看最終輸出不夠。一個答案剛好對，不代表過程沒問題（可能檢索撈錯，但模型瞎猜對了）。所以 eval 也要看：**它檢索到的東西對不對、它選的工具對不對**。這需要下面的 tracing 撐著。\n\n這幾件事在業界都有正式名字，方便你搜：整條決策路徑對不對叫 **trajectory evaluation**（軌跡 / step-level evaluation）；檢索層有現成指標——**context precision / recall**（該撈的有沒有撈到、撈到的對不對）和 **faithfulness**（答案有沒有忠於檢索內容，也就是抓幻覺），RAGAS、Arize Phoenix 都內建；工具選對沒對則看 **tool-selection accuracy**。白話講對是第一步，知道學名你才搜得到工具去自動量它。\n\n## Tracing：把 agent 的黑盒打開\n\n第 1 篇的鴻溝三講過：agent 答錯，你常常什麼線索都沒有。Tracing 就是要把那條從問題到答案的完整路徑，變成一條可以攤開來看的紀錄。\n\n對 agent，一條 trace 應該記錄這些 span：\n\n![一條 agent trace：把一個請求拆成檢索、工具呼叫、模型推理、生成等可量測的 span，每一步的耗時、token、花費都看得到](./images/agent-trace.webp)\n\n<p style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.9rem', lineHeight: 1.6, marginTop: '-0.4rem' }}>把一個 agent 請求拆成檢索、工具、模型、生成幾個可量測的 span——打開黑盒，每一步的耗時與花費都看得見。</p>\n\n有了這個，你 debug 的方式就從「再問一次看看」變成「攤開 trace，看是哪一個 span 出錯」。是檢索撈錯？工具回傳怪資料？還是模型拿到對的東西卻推理歪了？**根因一眼可見**。\n\n這套東西，做後端的人應該很熟——它就是 distributed tracing 套到 agent 上。我寫過用 tracing 去抓 PHP-FPM 記憶體洩漏、談過 fire-and-forget 服務的可觀測性陷阱，那套「每一步都要留下可追的足跡」的肌肉記憶，搬到 agent 上完全適用。差別只在 span 的內容從「DB query、HTTP call」變成「retrieval、tool call、model call」。**這正是後端工程師做 AI agent 的優勢**：可觀測性對你不是新觀念，只是新對象。\n\n而且這件事在 2024 後已經不只是「概念上像」了——它有了正式的開放標準。OpenTelemetry 的 GenAI SIG 從 2024 年起在制定 **GenAI Semantic Conventions**（CNCF 旗下），直接把「LLM / agent 的 span 該長什麼樣」標準化：模型呼叫叫 `chat` / `inference`、工具呼叫叫 `execute_tool`、agent 層級叫 `invoke_agent`，屬性有 `gen_ai.request.model`、`gen_ai.usage.input_tokens`、`gen_ai.tool.name` 這些。意思是上面那張 trace 圖不是各家自己亂畫的，而是一套有共識的 schema。一個誠實的提醒：截至 2026 它仍是 **experimental 狀態**，屬性名還可能變，採用時要留意版本相容。\n\n## 成本與 Token 監控：因為它會悄悄燒錢\n\nLLM 系統有個傳統服務沒有的特性：**每一次呼叫的成本是浮動的**，取決於 token 量。一個沒人注意的功能，可能因為 context 越塞越大、或一個 multi-agent 迴圈（第 8 篇）失控，悄悄把帳單翻倍。\n\n所以要把「成本」當成一級 metric 來監控：\n\n- 每個**功能 / endpoint** 平均花多少 token、多少錢？\n- 每個**使用者 / 租戶**燒多少？（抓異常、做計費）\n- 有沒有哪個 trace **異常昂貴**（爆 token）？要能告警。\n\n這直接餵給第 10 篇的成本權衡——你不先把成本看見，就無從優化。\n\n## 上線後：偵測「品質漂移」\n\n最後一層，是上線後的持續監控。你的黃金題庫是固定的，但真實世界的問題會變、資料會變、模型會被 vendor 偷偷更新。所以要盯著**線上的健康訊號**：\n\n- **代理指標**：使用者重問率、轉人工率、負評率、「這個答案沒幫助」的比例——這些不需要標準答案，就能反映品質掉了。\n- **定期回歸**：固定排程重跑黃金題庫，模型供應商一更新就可能漂移，你要第一個知道，而不是等客訴。\n- **抽樣人工複查**：定期抽真實對話人工看，補進黃金題庫。\n\n## 工具地景（會變，但方向很清楚）\n\n你可能會問：實際上用什麼做？我刻意不把這篇綁死在某個工具上（它們明天就可能改名），但給你一個 2026 的版圖方便開始搜：開源自架有 Langfuse、Arize Phoenix、Opik；商業 SaaS 有 LangSmith、Braintrust；既有 APM 則有 Datadog、New Relic 的 LLM Observability。但比「選哪個」更重要的是一個正在發生的收斂——這些工具都在往 **OpenTelemetry GenAI conventions** 靠。所以選型第一個該問的不是「dashboard 漂不漂亮」，而是「它能不能吐標準的 OTel span」——能，你換工具時 trace 還搬得走，不會被單一廠商鎖死。\n\n## 小結\n\n把 agent 從 demo 變成可信任的 production 系統，這篇是關鍵的一道工序：\n\n1. **Eval harness**：黃金題庫 + 混合評分（規則 / 語意 / LLM-as-judge），每次改動都跑回歸，出包的 case 一定補進去。\n2. **Tracing**：把每一步（檢索 / 工具 / 模型）變成可攤開的 span，debug 從許願變成看圖。\n3. **成本監控**：把 token 與花費當一級 metric，別讓它悄悄燒。\n4. **漂移偵測**：用線上代理指標 + 定期回歸，在客訴之前發現品質掉了。\n\n這四件事沒有一件是 AI 獨有的玄學——它們就是把後端的測試與可觀測性硬功夫，搬到一個會講人話的元件上。下一篇我們把這些 metric 派上用場，正面處理那個一直在背景的三角習題：**延遲、可靠性、成本，怎麼權衡**。\n\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"生產級 LLM 可觀測性與評估\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[多代理協作](/blog/enterprise-ai-agent-multi-agent-orchestration)\n- [Agentic Engineering：測試與安全](/blog/agentic-engineering-testing-safety)——把測試與安全紀律套用在 agent 上的另一個視角\n- 下一篇：《延遲、可靠性、成本的系統權衡》",
      "summary": "你改了一句 prompt、換了個模型，怎麼知道系統變好還是變壞？拆解 eval harness（黃金題庫、LLM-as-judge、回歸測試）、agent 的 tracing（每一步檢索/工具/模型的 span）、token 與成本監控，以及上線後怎麼偵測品質漂移。把後端的可觀測性硬功夫，搬到會講人話的元件上。",
      "image": "https://bobochen.dev/_astro/cover.BkqzDgEv.webp",
      "date_published": "2026-05-23T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "LLMOps",
        "可觀測性",
        "Eval",
        "監控",
        "生產環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-cost-optimization/",
      "url": "https://bobochen.dev/blog/agentic-engineering-cost-optimization/",
      "title": "Token 經濟學進階：當 Agent 一天燒掉 $50，你怎麼控制成本",
      "content_text": "Agent 越強大，token 燒越快。深入 token 成本的結構分析——哪些任務是 token 黑洞、怎麼設計 context 降低消耗、caching 策略、model routing，以及月成本從 $287 降到 $148 的實際做法。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第十一篇。上一篇：[Multi-Agent 編排實戰](/blog/multi-agent-orchestration-real-world)\n\n## 上個月帳單 $287，這個月 $148\n\n上個月我的 AI coding 總帳單是 $287。嚇了一跳。\n\n不是用太多，工作量其實差不多，問題出在使用方式：超長的 session 導致 context window 持續膨脹、探索性的 codebase 搜尋吃掉大量 token、該用便宜 model 的地方用了貴的。\n\n花了一個週末分析之後，這個月在相同工作量下壓到了 $148。差距來自三個改變：context pruning、model routing，還有更有紀律的 session management。\n\n重點不是「少用」，是「聰明用」。如果你也有 token 燒光的焦慮，這篇從工程面給你具體的解法。\n\n## Token 成本解剖學\n\n要省錢，先搞懂錢花在哪。\n\n### 三種 Token，三種價格\n\n| Token 類型          | 什麼時候產生                                                                    | 你能控制嗎                                                              |\n| ------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |\n| **Input tokens**    | 你給 model 的一切：system prompt、CLAUDE.md、conversation history、code context | 是——[context engineering](/blog/context-engineering-deep-dive) 直接影響 |\n| **Output tokens**   | Model 回給你的一切：code、解釋、tool calls                                      | 部分——精準的 spec 讓 agent 少寫廢話                                     |\n| **Thinking tokens** | 模型推理時的內部 token（extended thinking）                                     | 部分——簡單任務關閉 extended thinking                                    |\n\n真正隱藏的成本殺手是 **input tokens**。\n\n大部分人以為成本主要來自 output（agent 寫的 code），其實不是。在一個典型的 agentic session 裡，input tokens 通常是 output 的 **3-5 倍**。因為每一輪對話，整個 conversation history + system prompt + context 都會被重新送一次。\n\n一個 session 跑了 50 輪對話，最後幾輪每次都要送入幾十萬 token 的 history——光是這些重複的 input 就占了帳單的大頭。\n\n### 一次「幫我修這個 bug」的 Token 分解\n\n一個看似簡單的 bug fix request：\n\n```\nSystem prompt + CLAUDE.md:     ~5,000 tokens\nConversation history (20 輪):  ~40,000 tokens\nAgent 讀的 code files:         ~15,000 tokens\nAgent 的 tool calls:            ~8,000 tokens\nAgent 的 output (code + 說明):  ~3,000 tokens\nExtended thinking:              ~5,000 tokens\n─────────────────────────────────────────────\nTotal:                         ~76,000 tokens\n```\n\nAgent 最後寫出來的 code（3,000 tokens）只佔總消耗的 4%，而 conversation history（40,000 tokens）佔了 53%。最大的省錢機會不在「少寫 code」，在「管理 conversation history」。\n\n## 每種任務的成本對照\n\n根據我三個月的追蹤數據（approximation based on usage patterns）：\n\n| 任務類型             | 平均 Token 消耗 | 大約成本   | 備註               |\n| -------------------- | --------------- | ---------- | ------------------ |\n| Simple bug fix       | 50K-100K        | $0.20-0.50 | 通常 1-3 輪對話    |\n| New feature (small)  | 150K-300K       | $0.50-1.50 | 5-10 輪，含 review |\n| New feature (medium) | 500K-1M         | $2-5       | 多輪 iteration     |\n| Refactor             | 800K-2M         | $3-10      | 大量 codebase 探索 |\n| Code review          | 100K-200K       | $0.30-1.00 | 讀多寫少           |\n| Codebase exploration | 300K-800K       | $1-4       | Token 黑洞         |\n| Documentation        | 200K-400K       | $0.50-2.00 | Output-heavy       |\n\n**Token 黑洞排行**：\n\n1. **Refactor**——agent 需要讀很多檔案，理解整體架構，然後做大量修改\n2. **Codebase exploration**——「幫我搞懂這個系統怎麼運作」類型的任務，agent 會讀幾十個檔案\n3. **Long debugging sessions**——一來一回的 debug loop，conversation history 指數增長\n\n## 優化策略一：Context Pruning\n\n回到 [Context Engineering](/blog/context-engineering-deep-dive) 的核心觀念：context 不是越多越好，剛好夠就行。\n\n### 精簡 CLAUDE.md\n\n每多一行 CLAUDE.md，就多花幾個 token 在每一輪對話裡。乘以 session 裡的對話輪數，成本累積驚人。\n\n**Before**：400 行的 CLAUDE.md，每輪 ~4000 tokens × 30 輪 = 120,000 tokens 浪費在重複載入上\n\n**After**：100 行的 CLAUDE.md，每輪 ~1000 tokens × 30 輪 = 30,000 tokens\n\n光這一項就省了 90K tokens（約 $0.30/session）。看起來不多，但每天做 10 個 session，一個月就是 $90。\n\n### Session 管理紀律\n\n黃金法則就一句：一個 session 做一件事。\n\n一個 session 跑太久，conversation history 會指數膨脹。第 50 輪的對話要送入前面 49 輪的 history，那可能是 200K+ 的 input tokens。\n\n**改善做法**：\n\n- 一個 task 一個 session。task 做完就關，開新 session 做下一個。\n- 如果 task 需要多輪 iteration，中途做 checkpoint（更新 plan/TODO），然後開新 session 繼續。\n- 利用 Claude Code 的 context compaction，當 context 太大時它會自動壓縮舊的對話。但與其依賴自動壓縮，不如主動控制 session 長度。\n\n### Sub-Agent 模式\n\n探索性的任務（「幫我搞懂這個模組的架構」）特別燒 token。解法是用 sub-agent：\n\n```\n主 agent: 「研究 auth 模組的架構」\n  → 派出 sub-agent（cheap model、獨立 context）\n  → sub-agent 讀 20 個檔案，消耗 200K tokens\n  → 回報 2000 token 的摘要給主 agent\n```\n\n主 agent 的 context 只增加了 2000 tokens，而不是 200K。\n\n## 優化策略二：Model Routing\n\n不是所有任務都需要最貴的模型。\n\n### 我的 Model 選擇框架\n\n| 任務類型             | 推薦 Model          | 原因                      |\n| -------------------- | ------------------- | ------------------------- |\n| 簡單問答、分類       | Haiku               | 快、便宜、準確度足夠      |\n| 日常 coding、bug fix | Sonnet              | 性價比最高                |\n| 複雜架構決策         | Opus                | 需要深度推理              |\n| Code review          | Sonnet              | 理解力夠、比 Opus 快      |\n| Commit message       | Sonnet/Haiku        | 不需要 Opus 的推理能力    |\n| 長篇寫作             | Opus                | 需要一致性和深度          |\n| Codebase exploration | Sonnet + sub-agents | 用便宜的 model 做大量探索 |\n\n### 實際省下多少？\n\n以一天的典型工作量為例：\n\n**Before（全部用 Opus）**：\n\n- 10 個 tasks × 平均 300K tokens × Opus 價格 ≈ $15/天\n\n**After（model routing）**：\n\n- 2 個 complex tasks → Opus：600K tokens ≈ $4\n- 6 個 standard tasks → Sonnet：1.2M tokens ≈ $4\n- 2 個 simple tasks → Haiku：200K tokens ≈ $0.10\n- **Total ≈ $8/天**\n\n同樣的工作量，成本掉了 47%，品質幾乎沒差。Sonnet 在日常 coding 任務上的表現跟 Opus 非常接近。\n\n### 怎麼切換\n\n在 Claude Code 裡，隨時可以用 `/model sonnet` 或 `/model opus` 切換。我的 [`commit` skill](/blog/claude-md-rules-files-masterclass) 裡就有提醒：做 git commit 用 Sonnet 就好，不需要 Opus。\n\n## 優化策略三：Prompt Caching & Batching\n\n### Prompt Caching\n\nAnthropic 的 prompt caching 機制讓相同的 system prompt 只需要在第一次完整傳送，後續的 request 可以使用 cache，大幅降低 input token 成本。\n\n**怎麼提高 cache hit rate**：\n\n- CLAUDE.md 保持穩定——頻繁修改 CLAUDE.md 會導致 cache miss\n- System prompt 放在 context 的最前面（cache 是 prefix-based 的）\n- 避免在 system-level context 裡放動態內容（timestamp、random ID 等）\n\n### Batching 策略\n\n把相似的小任務合成一個 request：\n\n**Before**（5 個獨立 request）：\n\n```\n1. \"修正 Button component 的 hover color\"\n2. \"修正 Card component 的 border radius\"\n3. \"修正 Input component 的 focus style\"\n4. \"修正 Modal component 的 backdrop color\"\n5. \"修正 Toast component 的 animation\"\n```\n\n5 次 system prompt 載入 + 5 次 CLAUDE.md 載入 = 大量重複的 input tokens。\n\n**After**（1 個 batch request）：\n\n```\n修正以下 5 個 component 的 CSS 問題：\n1. Button: hover color\n2. Card: border radius\n3. Input: focus style\n4. Modal: backdrop color\n5. Toast: animation\n```\n\n1 次 system prompt 載入 + 1 次 CLAUDE.md 載入。Token 節省 ~60%。\n\n## 月度預算框架\n\nAgentic engineering 合理的月成本是多少？取決於你的使用強度：\n\n| 使用級別               | 月成本                 | 誰適合                   |\n| ---------------------- | ---------------------- | ------------------------ |\n| **Light** ($20-50)     | Pro 訂閱 + 少量 API    | 兼職使用、學習階段       |\n| **Medium** ($50-150)   | Pro + 日常 API 使用    | 全職 agent-first 開發    |\n| **Heavy** ($150-300)   | 大量 API + multi-agent | 多專案、multi-agent 架構 |\n| **Enterprise** ($300+) | Team 帳號 + 自建工具   | 團隊級別使用             |\n\n### ROI 計算\n\n$150/月的 agent 成本值不值得？算一下：\n\n假設 agentic workflow 讓你每天省 2 小時（保守估計，根據 [Post 7](/blog/agentic-engineering-daily-workflow-advanced) 的 4x 效率提升）。\n\n- 一個月工作 22 天 × 2 小時 = 44 小時\n- 以台灣軟體工程師平均時薪 $25 USD 計算：44 × $25 = $1,100\n- ROI：$1,100 / $150 = **7.3x**\n\n即使你用最貴的 Opus model 跑所有事情，只要它讓你省下的時間值超過 $300/月，就是值得的。\n\n大部分 agent-first 工程師的 sweet spot 在 **$100-200/月**。這個範圍可以覆蓋每天 8-10 小時的 agent-first 工作方式，如果你做好 model routing 和 context management。\n\n## 我具體做了什麼把帳單從 $287 降到 $148\n\n1. **Session 拆分**：從平均 45 分鐘/session 降到 20 分鐘/session。對話輪數從 30 降到 12。\n2. **Model routing**：70% 任務改用 Sonnet（之前 90% 用 Opus）。\n3. **Sub-agent 探索**：codebase exploration 任務全部丟給 sub-agent，主 context 不膨脹。\n4. **CLAUDE.md 瘦身**：從 400 行精簡到 100 行。任務特定的指令移到 rules files 和 skills。\n5. **Batch 處理**：小任務合併。一天 15 個 request 降到 8 個。\n\n每一項單獨看都不是巨大的改變。但加在一起，就是 48% 的成本降低。\n\n## Takeaway\n\n1. Token 成本的最大殺手是不必要的 context 膨脹，不是使用量。Conversation history 佔了帳單的一半以上。控制 session 長度、精簡 CLAUDE.md、善用 sub-agent，這三招就能省 30-40%。\n\n2. Model routing 可以在不犧牲品質的前提下省 40-50% 成本。日常 coding 用 Sonnet，只有複雜架構決策才需要 Opus。Commit、simple Q&A 用 Haiku 就夠了。\n\n3. $100-200/月是大部分 agent-first 工程師的 sweet spot。這個成本對應的 ROI 通常在 5-10x。不要為了省 $50 犧牲工作流的效率，但也不要因為「反正有用」就不管成本地亂燒。\n\n---\n\n_上一篇：[Multi-Agent 編排實戰](/blog/multi-agent-orchestration-real-world)_\n_下一篇：[Agent 安全網設計](/blog/agentic-engineering-testing-safety)_",
      "summary": "Agent 越強大，token 燒越快。深入 token 成本的結構分析——哪些任務是 token 黑洞、怎麼設計 context 降低消耗、caching 策略、model routing，以及月成本從 $287 降到 $148 的實際做法。",
      "image": "https://bobochen.dev/_astro/cover.DILkHkDU.webp",
      "date_published": "2026-05-22T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "Token",
        "成本優化",
        "AI",
        "Model Routing"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-cost-optimization/",
      "url": "https://bobochen.dev/blog/claude-api-guide-cost-optimization/",
      "title": "成本控制：省錢是一門工程藝術",
      "content_text": "從 Token 成本全貌、模型選擇策略、Prompt Caching 到 Batch API，系統性地把 AI 應用的成本降下來。一個真實 RAG 系統從每月 $2000 降到 $300 的完整過程。",
      "content_html": "我曾經有一個月收到 $2,341 的 Anthropic 帳單。\n\n那個時候，我在做一個內部知識庫的 RAG 系統，大概有三十幾個人在用。$2,341 除以 35 人，每個人每個月大概 $67——這個成本在企業環境裡看起來還好，但我的老闆覺得太貴了。\n\n他說：「這個系統有沒有辦法變成 $500 以內？」\n\n我花了兩週，把費用降到了 $287。這一章我要告訴你我是怎麼做到的。\n\n## 為什麼成本優化是 AI 應用的核心工程問題\n\n很多開發者把成本優化當成「後期才需要考慮的事」。這是個錯誤。\n\nAI 應用的成本結構跟傳統軟體完全不同。傳統 SaaS 的邊際成本接近零——多一個用戶，你的 AWS 帳單大概增加幾塊錢。但 AI 應用的邊際成本是**線性的甚至是超線性的**：用戶量增加一倍，API 費用大概也增加一倍；如果你的系統設計讓每次查詢消耗更多 token（例如累積越來越長的對話歷史），費用增長甚至會超過用戶量增長。\n\n成本不只影響利潤，還影響**產品設計決策**。當你知道每次 API 呼叫的成本，你會更謹慎地設計什麼時候要呼叫 API、呼叫什麼等級的模型、要不要快取。不理解成本的工程師，容易設計出用起來爽但賣不起錢的系統。\n\n## Token 成本的全貌\n\n很多人只知道「輸入 token 比輸出 token 便宜」，但 Anthropic 的計費實際上有四個維度：\n\n**Input Tokens（輸入 token）**：你傳給模型的所有文字——system prompt、用戶訊息、工具結果、對話歷史。這是最大宗的費用來源。\n\n以 claude-3-5-sonnet-20241022 為例，定價是 $3 / 1M tokens（2026 年初的定價，請以 Anthropic 官方定價頁面為準）。\n\n**Output Tokens（輸出 token）**：模型生成的文字——回覆內容、思考鏈（如果啟用）、工具呼叫參數。輸出 token 比輸入貴，Sonnet 是 $15 / 1M tokens——整整貴了五倍。這意味著讓模型少說一點話，比讓你少說一點話更有效益。\n\n**Cache Read Tokens（快取讀取 token）**：Prompt Caching 命中時，快取部分的 token 計費為 $0.30 / 1M——只有標準輸入的十分之一。快取是成本優化最強力的武器。\n\n**Cache Write Tokens（快取寫入 token）**：建立快取時，需要支付 $3.75 / 1M（比標準輸入稍貴）。但只要快取被讀取 1 次，就已大幅回本（寫入多付的 $0.75，對比讀取省下的 $2.70）。\n\n另外還有 **Tool Use** 的 token——工具的描述（`tools` 參數中的 JSON）也算 input tokens。如果你有很多工具，工具描述本身就可能佔用 1000-3000 tokens，每次呼叫都在計費。\n\n用這個表格來做成本估算：\n\n| 類型        | claude-3-5-haiku | claude-3-5-sonnet | claude-opus-4 |\n| ----------- | ---------------- | ----------------- | ------------- |\n| Input       | $0.80/1M         | $3/1M             | $15/1M        |\n| Output      | $4/1M            | $15/1M            | $75/1M        |\n| Cache Read  | $0.08/1M         | $0.30/1M          | $1.50/1M      |\n| Cache Write | $1/1M            | $3.75/1M          | $18.75/1M     |\n\n（以上為撰寫時的定價，請參考官方 [pricing 頁面](https://www.anthropic.com/pricing)）\n\n## 模型選擇策略：用最便宜能完成任務的模型\n\n這是最直接的成本優化策略，也最容易被忽視。\n\n我見過很多系統，不管什麼任務都用 Sonnet——分類一個客服問題？Sonnet。判斷情感是正面還是負面？Sonnet。把一段文字翻譯成英文？Sonnet。\n\nHaiku 的定價是 Sonnet 的 1/4 左右。如果你用 Haiku 能做的事，就不要用 Sonnet。\n\n我的模型選擇框架：\n\n**用 Haiku（最便宜）的場景：**\n\n- 分類任務（這封信是垃圾郵件嗎？這個問題屬於哪個類別？）\n- 簡單的資料提取（從文字中提取名字、日期、金額）\n- 格式轉換（把 JSON 轉成 CSV）\n- 快速的是/否判斷\n\n**用 Sonnet（中等）的場景：**\n\n- 一般的對話和問答\n- 中等複雜的程式碼生成\n- 文件摘要\n- 大多數的 RAG 應用\n\n**用 Opus（最貴）的場景：**\n\n- 複雜的多步驟推理\n- 需要深度分析的長文件\n- 高風險的決策（法律文件分析、財務建議）\n- 你需要最高品質且預算不是問題\n\n在實際應用中，我推薦**分層模型策略（Model Routing）**：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\ndef classify_query(user_message: str) -> str:\n    \"\"\"用 Haiku 分類查詢的複雜度，決定用什麼模型回答\"\"\"\n    response = client.messages.create(\n        model=\"claude-3-5-haiku-20241022\",  # 用 Haiku 做分類\n        max_tokens=10,\n        messages=[{\"role\": \"user\", \"content\": f\"\"\"\n判斷以下查詢的複雜度：\n\"{user_message}\"\n\n只回答 \"simple\"、\"medium\" 或 \"complex\"：\n- simple：直接的事實查詢、簡單的是/否問題\n- medium：需要一些推理、綜合多個資訊\n- complex：需要深度分析、多步驟推理、高風險決策\n\"\"\"}]\n    )\n    return response.content[0].text.strip().lower()\n\ndef route_and_answer(user_message: str, context: str) -> str:\n    \"\"\"根據查詢複雜度選擇適當的模型\"\"\"\n    complexity = classify_query(user_message)\n\n    model_map = {\n        \"simple\": \"claude-3-5-haiku-20241022\",\n        \"medium\": \"claude-3-5-sonnet-20241022\",\n        \"complex\": \"claude-opus-4-5\",\n    }\n\n    model = model_map.get(complexity, \"claude-3-5-sonnet-20241022\")\n    print(f\"使用模型: {model} (複雜度: {complexity})\")\n\n    response = client.messages.create(\n        model=model,\n        max_tokens=1024,\n        messages=[\n            {\"role\": \"user\", \"content\": f\"Context: {context}\\n\\nQuestion: {user_message}\"}\n        ]\n    )\n\n    return response.content[0].text\n```\n\n這個策略讓你的系統自動把簡單問題路由到便宜的模型，複雜問題才用昂貴的模型。根據我的經驗，80% 的查詢屬於 simple 或 medium，只有 20% 需要 Sonnet 等級。光是這個策略就能把成本降低 40-60%。\n\n## Prompt Caching：最強力的成本武器\n\nPrompt Caching 是 Anthropic 在 2024 年推出的功能，我認為它是迄今為止最重要的成本優化工具。\n\n核心概念：如果你的 prompt 有一部分是固定的（system prompt、背景文件、工具描述），Anthropic 可以把這部分快取起來。下次請求如果包含相同的前綴，直接讀快取，費用只有標準輸入的十分之一。\n\n**快取的觸發條件：**\n\n- 前綴必須超過 1024 tokens（小於這個值不值得快取）\n- 使用 `cache_control: {\"type\": \"ephemeral\"}` 標記快取斷點\n- 快取有效時間：5 分鐘（5 分鐘沒有相同前綴的請求就過期）\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\n# 假設這是你的知識庫文件（幾千 tokens）\nknowledge_base = \"\"\"\n[你的公司知識庫內容，可能有 10,000+ tokens...]\n這裡是關於產品的說明...\n這裡是常見問題解答...\n這裡是退款政策...\n[更多內容...]\n\"\"\"\n\ndef answer_with_caching(user_question: str) -> anthropic.Message:\n    \"\"\"使用 Prompt Caching 的 RAG 系統\"\"\"\n    return client.messages.create(\n        model=\"claude-3-5-sonnet-20241022\",\n        max_tokens=1024,\n        system=[\n            {\n                \"type\": \"text\",\n                \"text\": \"你是公司的客服 AI 助手。請根據以下知識庫內容回答用戶問題。\"\n            },\n            {\n                \"type\": \"text\",\n                \"text\": knowledge_base,\n                \"cache_control\": {\"type\": \"ephemeral\"}  # 標記這裡是快取斷點\n            }\n        ],\n        messages=[\n            {\"role\": \"user\", \"content\": user_question}\n        ]\n    )\n\n# 第一次請求：需要寫入快取（稍貴）\nresponse1 = answer_with_caching(\"退款政策是什麼？\")\nusage1 = response1.usage\nprint(f\"Cache creation: {usage1.cache_creation_input_tokens} tokens\")\nprint(f\"Cache read: {usage1.cache_read_input_tokens} tokens\")  # 第一次：0\n\n# 第二次請求（5 分鐘內）：命中快取\nresponse2 = answer_with_caching(\"如何申請退貨？\")\nusage2 = response2.usage\nprint(f\"Cache read: {usage2.cache_read_input_tokens} tokens\")  # 第二次：命中快取！\n# 知識庫的 tokens 現在按 $0.30/1M 計費，而不是 $3/1M\n```\n\n**如何最大化快取命中率：**\n\n1. **把穩定的內容放在前面，動態的內容放在後面。** 快取是前綴匹配——只要前面相同，後面不同也算命中。\n\n2. **快取斷點要謹慎放置。** 如果你有多個可快取的段落，每個段落都加 `cache_control`，但注意每個斷點都需要前面的整個前綴完全匹配。\n\n3. **對話歷史的快取策略。** 在長對話中，把累積的對話歷史也標記為快取：\n\n```python\ndef chat_with_caching(\n    messages: list[dict],\n    new_user_message: str,\n    system_prompt: str,\n    knowledge_base: str\n) -> str:\n    \"\"\"長對話中的快取策略\"\"\"\n\n    # 建立新的訊息列表\n    all_messages = messages.copy()\n\n    # 對最後一條 assistant 訊息加快取標記（代表到目前為止的對話歷史）\n    if all_messages and all_messages[-1][\"role\"] == \"assistant\":\n        last_msg = all_messages[-1].copy()\n        if isinstance(last_msg[\"content\"], str):\n            last_msg[\"content\"] = [\n                {\n                    \"type\": \"text\",\n                    \"text\": last_msg[\"content\"],\n                    \"cache_control\": {\"type\": \"ephemeral\"}\n                }\n            ]\n        all_messages[-1] = last_msg\n\n    # 加入新的用戶訊息\n    all_messages.append({\"role\": \"user\", \"content\": new_user_message})\n\n    response = client.messages.create(\n        model=\"claude-3-5-sonnet-20241022\",\n        max_tokens=1024,\n        system=[\n            {\"type\": \"text\", \"text\": system_prompt},\n            {\n                \"type\": \"text\",\n                \"text\": knowledge_base,\n                \"cache_control\": {\"type\": \"ephemeral\"}\n            }\n        ],\n        messages=all_messages\n    )\n\n    return response.content[0].text\n```\n\n我的目標是讓 cache hit rate 超過 80%——也就是說，80% 以上的請求中，大部分 input tokens 都來自快取。這個數字對一個活躍的服務（每分鐘有多個請求）是可達到的。\n\n## Output Length 控制\n\n輸出 token 比輸入貴 5 倍，控制輸出長度是重要的成本槓桿。\n\n**設定合理的 max_tokens。** 很多開發者直接設 4096 或更高，但如果你的用例只需要幾百 token 的回覆，這樣做不只浪費（模型可能輸出比需要多的內容），也讓用戶等待更長時間。\n\n根據用例設定 max_tokens：\n\n- 客服問答：512-1024\n- 文件摘要：1024-2048\n- 長文章生成：2048-4096\n- 程式碼生成：視複雜度而定\n\n**在 prompt 中明確要求簡潔：**\n\n```python\nsystem_prompt = \"\"\"\n你是客服助手。回答用戶問題時：\n- 直接給出答案，不要有前言和後記\n- 使用條列式格式\n- 回答控制在 200 字以內\n- 如果問題複雜，告訴用戶可以進一步詢問，而不是一次說完所有內容\n\"\"\"\n```\n\n一個設計良好的 prompt，讓模型知道「夠了就停」，不要追求詳細。對多數用戶來說，精簡的回覆其實比詳細的回覆更好——AI 的「詳細」往往包含大量廢話。\n\n## Batch API：節省 50% 的懶人救星\n\n如果你的任務不需要即時回應（例如批次處理文件、離線分析），用 Batch API 可以省下 50% 的費用。\n\nBatch API 的工作方式：你一次送出多個請求，Anthropic 在 24 小時內處理完，費用是標準 API 的一半。\n\n```python\nimport anthropic\nimport json\n\nclient = anthropic.Anthropic()\n\n# 準備批次請求\nrequests = []\ndocuments = [\"文件 1 的內容...\", \"文件 2 的內容...\", \"文件 3 的內容...\"]\n\nfor i, doc in enumerate(documents):\n    requests.append({\n        \"custom_id\": f\"doc-{i}\",\n        \"params\": {\n            \"model\": \"claude-3-5-sonnet-20241022\",\n            \"max_tokens\": 512,\n            \"messages\": [\n                {\n                    \"role\": \"user\",\n                    \"content\": f\"請摘要以下文件（100 字以內）：\\n\\n{doc}\"\n                }\n            ]\n        }\n    })\n\n# 送出批次請求\nbatch = client.messages.batches.create(requests=requests)\nprint(f\"Batch ID: {batch.id}\")\nprint(f\"狀態: {batch.processing_status}\")\n\n# 等待完成（可以隔幾個小時再查）\nimport time\nwhile True:\n    batch_status = client.messages.batches.retrieve(batch.id)\n    if batch_status.processing_status == \"ended\":\n        break\n    print(f\"還在處理中... ({batch_status.request_counts.processing} 個請求)\")\n    time.sleep(60)\n\n# 取得結果\nfor result in client.messages.batches.results(batch.id):\n    if result.result.type == \"succeeded\":\n        print(f\"{result.custom_id}: {result.result.message.content[0].text}\")\n    else:\n        print(f\"{result.custom_id}: 失敗 - {result.result.error}\")\n```\n\n適合 Batch API 的場景：\n\n- 每晚批次生成隔天的報告\n- 離線的文件分類和標記\n- 測試時跑大量的 evaluation\n- 定期更新快取的知識庫摘要\n\n## Context Window 管理：不要無限累積對話歷史\n\n這是很多對話 AI 應用的常見問題：每輪對話都把完整的歷史塞進去，越說越長，費用越來越高。\n\n正確的做法是**有策略地管理對話歷史**：\n\n```python\nfrom anthropic import Anthropic\n\nclient = Anthropic()\n\nclass ConversationManager:\n    def __init__(self, max_history_tokens: int = 4000):\n        self.messages = []\n        self.max_history_tokens = max_history_tokens\n\n    def estimate_tokens(self, messages: list) -> int:\n        \"\"\"粗略估算 token 數（1 token ≈ 4 個英文字元 / 2 個中文字元）\"\"\"\n        total_chars = sum(\n            len(m[\"content\"]) if isinstance(m[\"content\"], str)\n            else sum(len(c.get(\"text\", \"\")) for c in m[\"content\"])\n            for m in messages\n        )\n        return total_chars // 3  # 保守估算\n\n    def trim_history(self):\n        \"\"\"保留最近的對話，確保不超過 token 限制\"\"\"\n        while len(self.messages) > 2 and self.estimate_tokens(self.messages) > self.max_history_tokens:\n            # 移除最早的一組對話（user + assistant）\n            self.messages = self.messages[2:]\n\n    def chat(self, user_message: str, system_prompt: str) -> str:\n        self.messages.append({\"role\": \"user\", \"content\": user_message})\n        self.trim_history()\n\n        response = client.messages.create(\n            model=\"claude-3-5-sonnet-20241022\",\n            max_tokens=1024,\n            system=system_prompt,\n            messages=self.messages\n        )\n\n        assistant_message = response.content[0].text\n        self.messages.append({\"role\": \"assistant\", \"content\": assistant_message})\n\n        return assistant_message\n\n    def summarize_and_reset(self, system_prompt: str):\n        \"\"\"當對話太長，先讓 AI 做摘要，然後重置歷史\"\"\"\n        if len(self.messages) < 4:\n            return\n\n        # 用 Haiku 做對話摘要（便宜）\n        summary_response = client.messages.create(\n            model=\"claude-3-5-haiku-20241022\",\n            max_tokens=512,\n            messages=[\n                {\n                    \"role\": \"user\",\n                    \"content\": f\"請用 200 字摘要以下對話的重點：\\n\\n{json.dumps(self.messages, ensure_ascii=False)}\"\n                }\n            ]\n        )\n\n        summary = summary_response.content[0].text\n\n        # 重置歷史，只保留摘要\n        self.messages = [\n            {\n                \"role\": \"user\",\n                \"content\": f\"[對話摘要：{summary}]\\n\\n繼續我們的對話。\"\n            },\n            {\n                \"role\": \"assistant\",\n                \"content\": \"好的，我了解之前的對話內容。請繼續。\"\n            }\n        ]\n```\n\n## 監控成本：每個功能都應該知道它花了多少錢\n\n你不能優化你不監控的東西。我的做法是在每次 API 呼叫後記錄 token 使用量：\n\n```python\nimport anthropic\nfrom dataclasses import dataclass\nfrom datetime import datetime\nimport logging\n\nlogger = logging.getLogger(__name__)\n\n@dataclass\nclass APICallMetrics:\n    feature: str\n    model: str\n    input_tokens: int\n    output_tokens: int\n    cache_read_tokens: int\n    cache_creation_tokens: int\n    timestamp: datetime\n\n    def estimated_cost_usd(self) -> float:\n        \"\"\"估算這次呼叫的美金成本\"\"\"\n        pricing = {\n            \"claude-3-5-haiku-20241022\": {\n                \"input\": 0.80/1_000_000,\n                \"output\": 4.00/1_000_000,\n                \"cache_read\": 0.08/1_000_000,\n                \"cache_write\": 1.00/1_000_000,\n            },\n            \"claude-3-5-sonnet-20241022\": {\n                \"input\": 3.00/1_000_000,\n                \"output\": 15.00/1_000_000,\n                \"cache_read\": 0.30/1_000_000,\n                \"cache_write\": 3.75/1_000_000,\n            },\n        }\n\n        p = pricing.get(self.model, pricing[\"claude-3-5-sonnet-20241022\"])\n        return (\n            self.input_tokens * p[\"input\"] +\n            self.output_tokens * p[\"output\"] +\n            self.cache_read_tokens * p[\"cache_read\"] +\n            self.cache_creation_tokens * p[\"cache_write\"]\n        )\n\ndef track_api_call(feature: str, response: anthropic.Message) -> APICallMetrics:\n    \"\"\"從 API 回應中提取並記錄 metrics\"\"\"\n    usage = response.usage\n    metrics = APICallMetrics(\n        feature=feature,\n        model=response.model,\n        input_tokens=usage.input_tokens,\n        output_tokens=usage.output_tokens,\n        cache_read_tokens=getattr(usage, 'cache_read_input_tokens', 0),\n        cache_creation_tokens=getattr(usage, 'cache_creation_input_tokens', 0),\n        timestamp=datetime.now()\n    )\n\n    logger.info(\n        \"api_call\",\n        extra={\n            \"feature\": feature,\n            \"model\": response.model,\n            \"cost_usd\": metrics.estimated_cost_usd(),\n            \"cache_hit_rate\": metrics.cache_read_tokens / (metrics.input_tokens or 1),\n        }\n    )\n\n    return metrics\n```\n\n把這些 metrics 送到你的監控系統（Datadog、Grafana、或簡單的 Google Sheets），你就能看到：哪個功能最貴、快取命中率如何、模型選擇是否合適。\n\n## 真實案例：從 $2000 降到 $287\n\n回到這章開頭的那個 RAG 系統。這是我具體做了什麼：\n\n**問題診斷（第一週）**\n\n首先我加入了 token tracking，看清楚費用來自哪裡：\n\n- 70% 的費用來自 input tokens（主要是知識庫文件每次都重新傳）\n- 20% 來自 output tokens（模型的回覆比必要的長）\n- 10% 來自 Sonnet 用於所有查詢（包括只需要 Haiku 的簡單問題）\n\n**修正 1：實施 Prompt Caching（最大效益）**\n\n知識庫文件大約 15,000 tokens，每次查詢都要傳一次。加入 cache 之後，命中率達到 85%——這 15,000 tokens 中的 85% 從 $3/1M 降到 $0.30/1M。\n\n光這一項，input tokens 費用降了 75%。\n\n**修正 2：模型路由（次大效益）**\n\n分析後發現，60% 的查詢是簡單的事實查詢（「退款期限是幾天？」「服務時間是？」），完全不需要 Sonnet。改用 Haiku 後，這 60% 的查詢費用降到原來的 20%。\n\n**修正 3：Output length 控制**\n\n修改 system prompt，明確要求簡潔回覆，平均 output tokens 從 420 降到 180。\n\n**修正 4：對話歷史截斷**\n\n原本每輪對話都傳完整歷史，改為最多保留 6 輪（避免 context 無限增長）。\n\n**最終結果：**\n\n| 項目                | 修改前     | 修改後     | 降幅     |\n| ------------------- | ---------- | ---------- | -------- |\n| 知識庫 input tokens | $1,200     | $220       | -82%     |\n| Output tokens       | $650       | $280       | -57%     |\n| 模型費用            | $491       | 包含在上面 | -        |\n| **總計**            | **$2,341** | **$287**   | **-88%** |\n\n兩個最重要的 takeaway：\n\n1. **Prompt Caching 是 ROI 最高的優化**，如果你有重複的大型 prompt，先把這個做好。\n2. **測量再優化**，不要猜——先加入 monitoring，搞清楚費用來自哪裡，再針對性地優化。\n\n---\n\n省了錢之後，下一個問題是：怎麼讓這個省錢的系統在生產環境穩定跑？錯誤處理、Rate Limit、可觀測性——這些是我們下一章要解決的問題。",
      "summary": "從 Token 成本全貌、模型選擇策略、Prompt Caching 到 Batch API，系統性地把 AI 應用的成本降下來。一個真實 RAG 系統從每月 $2000 降到 $300 的完整過程。",
      "image": "https://bobochen.dev/_astro/cover.hVdJm2qF.webp",
      "date_published": "2026-05-22T00:00:00.000Z",
      "tags": [
        "Claude API",
        "成本優化",
        "Token",
        "省錢"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-multi-agent-orchestration/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-multi-agent-orchestration/",
      "title": "多代理協作：什麼時候真的需要 multi-agent，什麼時候那只是讓系統更貴",
      "content_text": "Multi-agent 聽起來很厲害，但大多數時候一個 agent 加好工具就夠了，而且更好 debug。談 supervisor/worker、pipeline、debate 等協作模式，handoff 怎麼傳 context，錯誤怎麼隔離不互相傳染，以及最重要的——什麼時候你「不」該用多代理。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 8 篇（共 12 篇）。上一篇：[Agent memory 與狀態管理](/blog/enterprise-ai-agent-memory-state-management)。\n\n「多代理」（multi-agent）是這兩年最性感的詞之一。一堆 agent 各司其職、互相協作、像個團隊——聽起來就很厲害。但我要先潑一盆冷水，因為這是我看過最多人**為了用而用**、結果把系統搞得又貴又難 debug 的地方。\n\n所以這篇反過來寫：先講**什麼時候不要用**，再講真的要用時怎麼用。\n\n## 先記住這句話：預設用單一 agent\n\n> 大多數你以為需要 multi-agent 的場景，其實一個 agent 加上一組好工具（第 6 篇）就解決了，而且更便宜、更好 debug。\n\n為什麼？因為每多一個 agent，你就多了：\n\n- **一次（或多次）額外的模型呼叫** → 更貴、更慢（第 10 篇）。\n- **一個 context 傳遞的接縫** → agent A 的理解要傳給 agent B，這中間一定會流失資訊、產生誤解。\n- **一個更難追的失敗點** → 出錯時，是哪個 agent 錯的？是它本身錯，還是它接收到的 context 已經被前一個 agent 弄歪了？\n- **錯誤會傳染** → A 給了 B 一個錯誤前提，B 在錯誤前提上認真工作，整條鏈一起歪。\n\n別小看這幾條的代價。Anthropic 自己揭露過一組值得背起來的數字：agent 大約用掉 chat 的 4 倍 token，multi-agent 系統更高達約 15 倍。換句話說，你還沒讓系統變聰明，光是把它拆成多個 agent，帳單就先乘了 15 倍上去。\n\n這些成本是實打實的。所以決定用 multi-agent 之前，先誠實問自己：**單一 agent 配好工具，真的解不了嗎？** 很多時候答案是「能，我只是覺得多 agent 比較酷」。\n\n## 什麼時候 multi-agent 真的有價值\n\n有三種情況，多代理確實划算：\n\n**1. 任務天然可以平行拆分。**\n比如「分析這 20 份競品報告」，拆成 20 個 agent 各讀一份、最後彙整，比一個 agent 慢慢讀快很多。這裡的價值是**平行**，不是「聰明」。\n\n**2. 需要的能力 / 權限差異很大。**\n一個負責「讀取分析」、另一個負責「執行高權限寫入動作」。把它們分開，是為了**權限隔離**（第 5、6 篇）——讓會動手的那個 agent 只有最小必要權限，唯讀分析的那個碰不到危險工具。這是安全考量驅動的拆分，很合理。\n\n**3. 需要對抗 / 審查的結構。**\n一個 agent 產出、另一個 agent 專門挑錯（critic）。這種「生成 vs 審查」的張力，確實能提升品質——本質上是把「自我檢查」外部化成一個獨立角色。\n\n注意這三個的共通點：**拆分有明確的工程理由（平行、隔離、對抗），不是為了模擬一個公司組織圖**。\n\n這個判斷不是我一個人的偏執。2025 年 6 月有一場被社群並列為「同一週對打」的公開辯論：Anthropic 發表《How we built our multi-agent research system》，主張在可平行的廣度研究任務上拆 agent 很划算——他們的多 agent 系統在這類任務上比單一 agent 高出 90.2%；幾乎同一週，做 Devin 的 Cognition（Walden Yan）發表《Don't Build Multi-Agents》，主張預設就用單執行緒的線性 agent、別輕易拆。\n\n表面上兩篇在打架，細看卻不矛盾——差別只在「這個任務是不是真的能平行解耦」。有趣的是 Anthropic 自己也點名：**大多數 coding 任務「真正可平行的部分比研究少很多」**，因為各部分彼此相依、要共享同一份 context，硬拆反而會撞車。所以判斷能不能平行，看的不是表面能不能分工，是分出去的子任務彼此依賴有多深——讀競品報告可以各讀各的，寫一個 feature 通常不行。\n\n## 幾種協作模式\n\n真的要做了，常見的結構有這幾種：\n\n### Supervisor / Worker（主管—工人）\n一個 supervisor agent 負責拆任務、分派、彙整；底下幾個 worker agent 各做一塊。最常見、最好管，因為**控制流集中在 supervisor**，責任清楚。大部分企業場景用這個就夠。\n\n### Pipeline（流水線）\nagent 串成一條線，A 的輸出是 B 的輸入（研究 → 草稿 → 審稿 → 定稿）。適合**階段分明、單向流動**的任務。要小心的是**錯誤會沿著管線累積放大**——第一棒歪一點，最後一棒可能歪很多。\n\n### Debate / Critic（辯論—審查）\n多個 agent 對同一問題提不同觀點，或一個產出、一個反駁，最後收斂。品質好但**最貴**（同一問題算好幾遍），留給高價值、容錯低的決策用。\n\n## Handoff：接縫是最容易漏水的地方\n\n多代理最脆弱的地方，是 agent 之間**交棒（handoff）**那一刻。A 要把它的理解傳給 B，這裡有兩個常見坑：\n\n- **傳太多**：把 A 的整段思考、所有中間產物全倒給 B，B 的 context 被灌爆、抓不到重點、又貴。\n- **傳太少**：只給 B 一個結論，B 缺了關鍵脈絡，只好自己腦補，腦補就錯。\n\n好的 handoff 是**設計過的介面**：明確定義「A 交給 B 的，是哪幾個欄位、什麼格式」，像在設計兩個 service 之間的 API contract 一樣。這又回到系統工程——agent 之間的協作，本質是**介面設計**問題，跟微服務之間怎麼定 contract 是同一種思考。\n\n這套「把 handoff 當 API contract」的想法，框架也已經幫你制度化了。OpenAI 的 Agents SDK 就把 handoff 做成一級原語：agent A 用一個類似 `transfer_to_agent_b` 的工具呼叫，把控制權加上一份結構化 payload 交給 B，整個轉移還會被記錄、可以重播；後續更新甚至把巢狀 handoff 的完整歷史改成預設 opt-in，正是為了減少跨 agent 的 context bleed——框架的演進方向，剛好就是這篇講的那套介面紀律。\n\n## 錯誤隔離：別讓一個 agent 拖垮全部\n\nCognition 講過一個很有畫面感的翻車案例：他們把「做一個 Flappy Bird clone」拆成平行子任務，結果一個 subagent 把背景做成了 Super Mario 的風格，另一個 subagent 做的鳥又跟整體美術不搭。每個 subagent 單獨看都很合理，合起來卻是個四不像——因為它們看不到彼此在做什麼。這就是「接縫漏水」最具體的長相：不是哪個 agent 出 bug，而是它們各自正確、彼此不一致。\n\n既然錯誤會傳染，就要主動隔離：\n\n- **驗證交棒內容**：B 接到 A 的東西，先做基本檢查（格式對不對、有沒有明顯矛盾），別照單全收。\n- **限制爆炸半徑**：一個 worker 失敗，supervisor 要能處理（重試、換人、降級），而不是整個任務崩掉。\n- **設總預算上限**：整個多代理任務要有 token / 時間 / 步數的天花板，否則一群 agent 互相觸發，可以把成本燒到失控（第 10 篇會細談這個「成本爆炸」風險）。\n\n## 一個務實的決策流程\n\n下次有人說「我們來做 multi-agent 吧」，照這個順序問：\n\n1. **單一 agent 配好工具能不能解？** 能 → 就用單一，收工。\n2. 不能的話，**是因為平行、權限隔離、還是需要對抗審查？** 三個都不是 → 你大概其實不需要 multi-agent。\n3. 是的話，**選最簡單能滿足的結構**（通常是 supervisor/worker），把 handoff 當 API contract 設計，設好預算上限和錯誤隔離。\n\n## 小結\n\nMulti-agent 不是更高級的 agent，是一個**有明確成本、要有明確理由才值得付**的架構選擇。預設用單一 agent；只在平行、權限隔離、對抗審查這三種情況下拆分；拆分時把 agent 間的 handoff 當成介面設計，並嚴防錯誤傳染和成本爆炸。\n\n這不只是我的經驗法則。做過最大規模多 agent 系統的 Anthropic，給的判準幾乎一模一樣：multi-agent 只在「任務價值高到足以支付那多燒的 token」時才划算。換句話說——能不能用，先算這題值不值得你多燒那 15 倍。\n\n能忍住「不為酷而拆」，是這個主題上最值錢的判斷力——也是面試時很容易聽得出來「這個人到底有沒有真的做過」的地方。\n\n前面八篇我們把系統的「能力面」蓋得差不多了：知道事情（RAG）、記得事情（memory）、做事情（tools）、協作（multi-agent）。接下來三篇轉向「**能不能信任**」這件事。下一篇先處理最基礎的：**你怎麼知道這整套東西到底有沒有在好好運作**——生產級的可觀測性與評估。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"多代理協作：什麼時候真的需要 multi-agent\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[Agent memory 與狀態管理](/blog/enterprise-ai-agent-memory-state-management)\n- [多代理協作的真實世界案例](/blog/multi-agent-orchestration-real-world)\n- [用 Claude API 打造多代理系統](/blog/claude-api-guide-multi-agent)\n- 下一篇：《生產級 LLM 可觀測性與評估》",
      "summary": "Multi-agent 聽起來很厲害，但大多數時候一個 agent 加好工具就夠了，而且更好 debug。談 supervisor/worker、pipeline、debate 等協作模式，handoff 怎麼傳 context，錯誤怎麼隔離不互相傳染，以及最重要的——什麼時候你「不」該用多代理。",
      "image": "https://bobochen.dev/_astro/cover.BpHWtpH-.webp",
      "date_published": "2026-05-19T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "Multi-Agent",
        "Orchestration",
        "AI Agent",
        "系統設計",
        "LLM"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/github-readme-dynamic-demo-strategies/",
      "url": "https://bobochen.dev/blog/github-readme-dynamic-demo-strategies/",
      "title": "GitHub README 動態 demo 的 5 種策略：從 GIF 到自架 CDN",
      "content_text": "把產品 demo 塞進 GitHub README 不只 <video> 一條路。從動畫 GIF、GitHub user-attachments、外部 CDN（Cloudflare R2），到 terminal 專用的 Asciinema，5 種策略在檔案大小、自動化、npm/VSCode 跨平台相容性上各有取捨，附決策樹與對照表幫你一眼選對。",
      "content_html": "> 先備知識：這篇假設你已經有一個排得不錯的 README 骨架（centered hero、badges、章節順序）。如果還沒，建議先讀 [《GitHub README 排版術：把開源專案首頁變成 landing page》](/blog/github-readme-landing-page-layout/) 補基礎。\n\n## 在 GitHub README 呈現動態 demo，其實有 5 種策略\n\n上一篇 [《把 30 秒產品介紹塞進 GitHub README 的最後一哩》](/blog/github-readme-video-embed-last-mile/) 結尾說：「`user-attachments` 是 GitHub `<video>` 唯一能 inline 播放的路。」\n\n但那個結論有個前提：**你想用 `<video>` 標籤 inline 播放**。\n\n如果放寬這個前提，會發現「在 GitHub README 呈現產品動態 demo」其實有 5 條截然不同的路，每條都有自己的取捨。\n\n這篇我把它們整理成決策樹，給：\n- 不想被 GitHub web UI 卡 60 秒的人\n- README 要在 npm registry / VSCode preview 也能正常顯示的人\n- demo 是 terminal 場景的人\n- 影片超過 Free 方案 10MB 上限（付費方案 100MB）的人\n\n## 策略 1：動畫 GIF（萬用無腦）\n\n把 MP4 轉成 GIF，用 `<img>` 標籤嵌入。\n\n```bash\n# 一行流程：30 秒影片轉 720p、10fps GIF\nffmpeg -i input.mp4 -vf \"fps=10,scale=720:-1:flags=lanczos\" -loop 0 demo.gif\n\n# 進階：用 gifski 壓得更小（brew install gifski）\nffmpeg -i input.mp4 -vf \"fps=10,scale=720:-1\" -f image2pipe -vcodec ppm - | \\\n  gifski -o demo.gif --fps 10 -\n```\n\nREADME 嵌入：\n\n```markdown\n![Demo](docs/demo.gif)\n```\n\n**優點：**\n\n- ✅ 任何 markdown viewer 都顯示（GitHub、npm registry、VSCode preview、Reddit、Notion 全部）\n- ✅ 完全自動化，純 CLI flow\n- ✅ Auto-play，不用點\n\n**缺點：**\n\n- ❌ 檔案大：30 秒 720p GIF 通常 5-15MB\n- ❌ 沒有音訊\n- ❌ 畫質受 256 色限制，漸層/陰影會出現色帶\n- ❌ 強制 loop 播放，讀者讀文字時持續干擾\n\n**適合：** 短 demo（≤ 10 秒）、純視覺場景、最大相容性優先。\n\n## 策略 2：GitHub user-attachments（上篇結論）\n\n打開 github.com 編輯 README，把 MP4 直接「拖」進文字框。GitHub 上傳到自家 CDN 產生：\n\n```text\nhttps://github.com/user-attachments/assets/{UUID}\n```\n\n完整流程在上一篇講過，這裡只列取捨。\n\n**優點：**\n\n- ✅ 真正的 `<video>` 播放器（含 scrubber、音訊、全螢幕、loop 控制）\n- ✅ 串流播放，不用整段下載\n- ✅ GitHub 自家 CDN，全球節點快\n\n**缺點：**\n\n- ❌ 沒有 API，只能 web UI 拖放\n- ❌ 單檔上限視方案而定：Free 10MB、Pro/Team/Enterprise 100MB\n- ❌ **只在 github.com 顯示**。npm registry、VSCode preview、Reddit 看到的是空白 / 損壞圖示\n\n**適合：** 影片在上限內（Free ≤ 10MB / 付費 ≤ 100MB）、能接受 60 秒手動、主要受眾在 github.com。\n\n## 策略 3：外部 CDN（Netlify Drop / Cloudflare R2）\n\n自己 host MP4 到會正確回傳 `Content-Type: video/mp4` 的 CDN，README 用 `<video src=\"https://...\">` 直接引用。\n\n最快上手的選項：\n\n- **Netlify Drop**：把 `public/` 資料夾拖到 [app.netlify.com/drop](https://app.netlify.com/drop)，30 秒拿到 URL\n- **Cloudflare R2**：免費 10GB、**0 出口流量費**，要設定 bucket + 綁網域\n- 已經有 [GitHub Pages](https://pages.github.com/) 或自架網站 → 直接放進 `public/` 就好\n\n驗證 Content-Type 正確（這是關鍵，CDN 設錯就不能 stream）：\n\n```bash\n$ curl -sI https://your-cdn.example.com/promo.mp4 | grep -iE \"content-(type|disposition)\"\ncontent-type: video/mp4\n# 沒有 content-disposition: attachment ← 這行不能出現\n```\n\nREADME 嵌入：\n\n```html\n<video src=\"https://your-cdn.example.com/promo.mp4\"\n       poster=\"docs/poster.jpg\"\n       width=\"700\" controls></video>\n```\n\n**優點：**\n\n- ✅ 完全自動化（CI 推送就更新）\n- ✅ 無檔案大小限制\n- ⚠️ 平台相容性有限：在 GitHub Pages / 自架網站 / 支援 raw HTML 的 viewer 可正常播放；但 **github.com README 不會顯示**（GitHub sanitizer 會 strip 掉指向非 githubusercontent.com 的 `<video>` 標籤）；npm registry / VSCode preview 不保證支援\n- ✅ 真正的播放器 + 音訊（限支援 raw HTML 的環境）\n\n**缺點：**\n\n- ❌ 多一個服務要維護\n- ❌ 流量爆掉要付錢（Netlify 自 2025-09 起新帳號改為 credit 制、免費 300 credits/月，舊制 legacy 帳號才是固定 100GB/月，實際額度以 Netlify 當前 pricing 為準；建議直接用 R2 的 10GB + 0 出口流量費較單純）\n- ❌ 自訂網域可能要設 CORS 才能跨來源 stream\n\n**適合：** 影片大、要 100% 自動化、有現成 CDN 可用，且目標是 GitHub Pages / 自架網站（而非 github.com README 本身）。\n\n## 策略 4：YouTube / Vimeo 縮圖連結（SEO 加分）\n\nGitHub 不會 render YouTube `<iframe>` embed（HTML sanitizer 把 iframe 整個 strip 掉），但可以放縮圖連結，點擊跳到 YouTube。\n\n```markdown\n[![Watch the demo](https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg)](https://youtu.be/VIDEO_ID)\n```\n\nYouTube 自動提供四種縮圖（直接拿，不用自己截）：\n\n| 解析度 | URL |\n|---|---|\n| 1280×720 | `https://img.youtube.com/vi/{ID}/maxresdefault.jpg` |\n| 640×480 | `https://img.youtube.com/vi/{ID}/sddefault.jpg` |\n| 480×360 | `https://img.youtube.com/vi/{ID}/hqdefault.jpg` |\n| 320×180 | `https://img.youtube.com/vi/{ID}/mqdefault.jpg` |\n\n**優點：**\n\n- ✅ SEO 加分：影片本身被 Google 索引\n- ✅ 無流量成本（YouTube CDN 全球）\n- ✅ 同支影片可重用：README + Twitter + Threads + 官網\n- ✅ 有觀看數據可追蹤（YouTube Analytics）\n\n**缺點：**\n\n- ❌ 點擊跳走 README，使用者離開原頁\n- ❌ 品牌綁定 YouTube（前後可能有廣告）\n- ❌ 需要 Google 帳號 / YouTube channel\n\n**適合：** 產品行銷導向、做完一支影片想多平台重用、在乎 SEO。\n\n## 策略 5：Asciinema（terminal demo 專用）\n\n不是影片，是 terminal 事件流的「重播」。錄製和播放都是純文字事件，檔案 KB 級別。\n\n```bash\n# 安裝\nbrew install asciinema\n\n# 錄製（按 Ctrl+D 結束）\nasciinema rec demo.cast\n\n# 上傳到 asciinema.org（首次會要求登入）\nasciinema upload demo.cast\n# → 拿到 https://asciinema.org/a/XXXXXX\n```\n\nREADME 嵌入（SVG 縮圖連到播放頁）：\n\n```markdown\n[![asciicast](https://asciinema.org/a/XXXXXX.svg)](https://asciinema.org/a/XXXXXX)\n```\n\n**優點：**\n\n- ✅ 檔案極小（30 秒錄製通常 5-20KB）\n- ✅ **文字可以選取複製**——讀者直接複製你 demo 裡的指令\n- ✅ 純 SVG 縮圖在 README 完美顯示\n- ✅ 可自架 player（不想依賴 asciinema.org 的話）\n- ✅ 開源工具：[asciinema](https://github.com/asciinema/asciinema) / [asciinema-player](https://github.com/asciinema/asciinema-player)\n\n**缺點：**\n\n- ❌ 只能錄 terminal，無法錄 GUI demo\n- ❌ 縮圖點擊跳走（跟 YouTube 策略類似）\n\n**適合：** CLI 工具 demo、Shell 教學、DevOps 流程示範。\n\n## 決策樹：你該選哪個\n\n```text\n你的 demo 是 terminal CLI 場景？\n├─ 是 → 策略 5：Asciinema\n└─ 否\n    ↓\n    影片超過方案上限（Free >10MB / 付費 >100MB）或 demo 超過 30 秒？\n    ├─ 是 → 策略 3：外部 CDN\n    └─ 否\n        ↓\n        音訊重要嗎？\n        ├─ 是 → 策略 2（user-attachments）或 3（CDN）\n        └─ 否\n            ↓\n            想要 100% 純 CLI flow，不能手動？\n            ├─ 是 → 策略 1：GIF（最相容）或 3（CDN，最乾淨）\n            └─ 否\n                ↓\n                想要 SEO 加分 + 多平台重用？\n                ├─ 是 → 策略 4：YouTube 縮圖\n                └─ 否 → 策略 2：user-attachments（最省事）\n```\n\n## 一張表看完所有取捨\n\n| 策略 | 檔案大小 | 自動化 | 跨平台相容 | 音訊 | 適合場景 |\n|---|---|---|---|---|---|\n| 1. GIF | 大（5-15MB） | ✅ 純 CLI | ✅ 全部 | ❌ | 短 demo、最大相容 |\n| 2. user-attachments | Free ≤10MB / 付費 ≤100MB | ❌ 60 秒手動 | ⚠️ 只 github.com | ✅ | 影片在上限內、主要在 GitHub |\n| 3. 外部 CDN | 不限 | ✅ 全自動 | ⚠️ github.com 不顯示 | ✅ | 大檔案、GitHub Pages/自架網站 |\n| 4. YouTube 縮圖 | 0（外部） | ✅ 上傳一次 | ✅ 全部 | ✅ | 想 SEO + 多平台重用 |\n| 5. Asciinema | 極小（KB） | ✅ 純 CLI | ✅ 全部 | ❌ | terminal demo 專用 |\n\n## 我自己的選擇（實戰案例）\n\n完整踩雷紀錄在 [《把 30 秒產品介紹塞進 GitHub README 的最後一哩》](/blog/github-readme-video-embed-last-mile/)——我做 [TypeLate](https://github.com/bobo52310/TypeLate) 這次選了策略 2（user-attachments），原因很簡單：\n\n- 影片只有 2MB，遠低於 Free 方案 10MB 上限\n- 主受眾就在 github.com（README 就是首頁）\n- 60 秒手動可以接受（一支影片只上一次）\n\n但如果未來：\n\n- 影片變長到 30MB → 改策略 3（Cloudflare R2 host）\n- 想推到 Product Hunt → 加策略 4（YouTube）多平台\n- 變成純 CLI 工具 → 策略 5（Asciinema）取代\n\n**5 種策略不是互斥的**，可以混用。例如：策略 2 給 GitHub 訪客真播放器，同時策略 4 給 YouTube SEO，策略 1 給 npm registry 看到 GIF——一支影片打三個通路。\n\n## TL;DR\n\nGitHub README 的動態 demo 不只 `<video>` 一條路：\n\n1. **GIF**：相容性王者，但檔案大、無音訊\n2. **user-attachments**：真播放器，但上限視方案（Free 10MB / 付費 100MB）+ 60 秒手動\n3. **外部 CDN**：100% 自動化、無限制，但要多維護一個服務\n4. **YouTube 縮圖**：SEO 加分，但點擊跳走 README\n5. **Asciinema**：terminal 專用，KB 級別，文字可複製\n\n選哪個看你的 demo 內容、自動化需求、跨平台需求三軸權衡。\n\n至於為什麼值得花力氣做這支 demo 影片，可參考 [《為什麼一支 30 秒影片是 OSS 上架最大的槓桿》](/blog/oss-product-video-leverage/)。",
      "summary": "把產品 demo 塞進 GitHub README 不只 <video> 一條路。從動畫 GIF、GitHub user-attachments、外部 CDN（Cloudflare R2），到 terminal 專用的 Asciinema，5 種策略在檔案大小、自動化、npm/VSCode 跨平台相容性上各有取捨，附決策樹與對照表幫你一眼選對。",
      "image": "https://bobochen.dev/_astro/cover.wxq7cOj-.webp",
      "date_published": "2026-05-17T00:00:00.000Z",
      "tags": [
        "GitHub",
        "README",
        "Open Source",
        "Solo Builder",
        "Video"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/github-readme-landing-page-layout/",
      "url": "https://bobochen.dev/blog/github-readme-landing-page-layout/",
      "title": "GitHub README 排版術：把開源專案首頁變成 landing page",
      "content_text": "訪客打開 GitHub repo 只給你 10 秒決定要不要 star。這篇用 GitHub 原生 markdown 把 README 排成 landing page：centered hero block、badges、視覺先行、一句話定位、Quick Start、章節順序模板 6 個元素，外加 5 個常見地雷與 2 個現成工具。給第一次發 OSS 的 indie hacker。",
      "content_html": "## README 的 10 秒首屏，就是你的 landing page\n\n打開一個沒聽過的 GitHub repo，你會花多少時間決定要不要 star？\n\nNielsen Norman Group 多年的網頁瀏覽研究告訴我們：**訪客平均在 10 秒內決定是否繼續看下去**。README 的「首屏」（不用滾動就能看到的部分）就是這 10 秒的決勝點。\n\n對開源專案來說，README 就是你的 landing page。但開源專案的 README 跟商業 landing page 不一樣。你沒有設計師、沒有 A/B 測試、也沒有複雜的 CMS。\n\n只有 Markdown 和一點 HTML。\n\n這篇講「用 GitHub 原生支援的 markdown 語法，把 README 排成像 landing page 的版面」。給第一次發 OSS、或一直覺得自己 README 看起來「不夠專業」的人。\n\n## 一個好 README 的視覺骨架\n\n直接看範例：\n\n```markdown\n<div align=\"center\">\n\n  <img src=\"logo.png\" width=\"120\" alt=\"Logo\" />\n\n  # Project Name\n\n  **One-line tagline that explains the value**\n\n  A 1-2 sentence elevator pitch.\n\n  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n  [![Release](https://img.shields.io/github/v/release/USER/REPO)](.../releases/latest)\n  ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey)\n\n  <img src=\"demo.gif\" width=\"700\" alt=\"Demo\" />\n\n</div>\n\n> Optional callout: a key thing visitors should know upfront.\n\n## How It Works\n\n| Step | Action |\n| :--: | ------ |\n| **1.** | Install with `brew install ...` |\n| **2.** | Run `command` |\n| **3.** | See the result |\n\n## Features\n...\n```\n\n這個結構在 GitHub 上 render 起來就是一個 hero 區塊 + 三步驟引導 + 功能介紹。完全用 markdown + 兩個 `<div>` 和 `<img>` 標籤。\n\n接下來拆解每個元素的細節。\n\n## 元素 1：Centered Hero Block\n\nGitHub markdown 支援 `<div align=\"center\">` 把內容居中。這是排出 landing-page-style hero 的基礎。\n\n```markdown\n<div align=\"center\">\n\n  <img src=\"logo.png\" width=\"120\" alt=\"Logo\" />\n\n  # Project Name\n\n  **Tagline**\n\n</div>\n```\n\n幾個容易踩的細節：\n\n- **`<div>` 開合之間要留空行**，markdown 才會 render 裡面的 `#` 標題和 `**bold**`\n- **`<img>` 用 `width=\"120\"` 屬性控制大小**，不要用 inline `style=\"...\"`，GitHub 的 HTML sanitizer 會 strip 掉 style\n- 居中的 `#` 標題在 mobile 上也會正確居中\n\n## 元素 2：Badges（4 個就好）\n\n[shields.io](https://shields.io/) 是事實上的標準，支援數十種服務（CI、套件庫、coverage、社群平台等）的動態 badges，再加上無限自訂的靜態 badge。\n\n**該放的 4 個**：\n\n```markdown\n[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)\n[![Release](https://img.shields.io/github/v/release/USER/REPO)](.../releases/latest)\n[![Build](https://img.shields.io/github/actions/workflow/status/USER/REPO/ci.yml)](.../actions)\n![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey)\n```\n\n對應訪客要判斷的 4 件事：\n\n| Badge | 訪客在問 |\n|---|---|\n| License | 能不能用？商業？fork？ |\n| Release version | 專案還活著嗎？最近一次更新多久前？ |\n| Build status | 品質如何？CI 過得了嗎？ |\n| Platform | 支援我的環境嗎？ |\n\n**不該放的**：\n\n- ❌ Downloads count、code coverage、star count，這些數字一旦停滯反而扣分\n- ❌ Discord / Slack / 廣告 badges，會分散訪客注意力\n- ❌ 超過 4 個，開始看起來像 CI pipeline 而不是 landing page\n\nGitHub repo 頁面本來就有 star count，README 不用重複放。\n\n## 元素 3：Hero Image / GIF / Video\n\n視覺是 10 秒內最快傳達訊息的方式。三個選擇：\n\n- **Screenshot**：靜態，但能放最高解析度\n- **GIF**：動態 demo，相容性最好（任何 markdown viewer 都吃）\n- **Video**：真正的播放器，但只能透過 GitHub 拖放上傳，且只在 github.com 顯示\n\n哪個適合你的場景？我寫過一篇詳細的 [《GitHub README 動態 demo 的 5 種策略：從 GIF 到自架 CDN》](/blog/github-readme-dynamic-demo-strategies/)，附決策樹幫你選。\n\n## 元素 4：一句話定位（最重要的元素）\n\n直接看兩個對比：\n\n```markdown\n<!-- ❌ 工程師寫法 -->\nTypeLate is a cross-platform push-to-talk dictation app built with Tauri v2\nthat uses Groq Whisper for transcription and LLM-based post-processing.\n\n<!-- ✅ User-facing 寫法 -->\n**Too late to type — just speak.**\n\nPress a hotkey to start, speak naturally, press again to stop.\nYour voice becomes polished text in under 3 seconds — right where you type.\n```\n\n兩個關鍵差別：\n\n1. **擺放位置**：藏在內文第一行 vs 獨立的 `**bold tagline**`——訪客掃讀的第一視覺重點\n2. **「has feature X」vs「does Y for you」**：把焦點放在「使用者得到什麼」而不是「我們做了什麼」\n\n對 indie hacker / solo builder：tagline 要在 **60 字內**傳達「給誰、解決什麼、用什麼方式」。寫不出來代表產品定位還沒想清楚。\n\n想看更多範例？[Awesome README](https://github.com/matiassingers/awesome-readme) 是 GitHub 上精選的 README 範例集，找一個跟你類似的專案抄結構。\n\n## 元素 5：Quick Start（30 秒能試到）\n\n如果讀者讀到這裡還沒被勸退，他在想「我怎麼試試看」。給他最短路徑：\n\n````markdown\n## Quick Start\n\n```bash\nbrew install your-cli\nyour-cli init\nyour-cli run\n```\n````\n\n**三行裝完三行跑**。\n\n如果你的安裝需要 10 個步驟，不要硬塞進 Quick Start。那代表你該重新設計安裝流程了。一個典型問題：「先設定環境變數、再裝依賴、再 build、再執行 migration、再啟動……」，這些都該包成單一指令（`make install` 或 `npx create-foo`）。\n\n## 元素 6：章節順序模板\n\n對小型開源工具，這個順序我用了很多次：\n\n```markdown\n# Project Name (hero block 已含)\n\n## How It Works\n（一個 table 或三個 emoji bullets 講工作原理）\n\n## Features\n（4-6 個關鍵功能，每個一段話）\n\n## Quick Start\n（裝 → 用 → 看到結果）\n\n## Documentation\n（連到完整文件 / wiki / website）\n\n## Contributing\n（接受 PR 嗎？要遵守什麼規範？）\n\n## License\n（一行就好：MIT / Apache 2.0）\n```\n\n長 README（超過 10 個章節）才需要 Table of Contents。短於 5 個章節的 README 加 TOC，反而讓 TOC 佔滿首屏、把真正的 hero 內容推到滾動線以下。\n\n## 5 個常見錯誤\n\n寫 README 的地雷：\n\n1. **一上來放安裝指令** — 訪客還不知道這是什麼就要他裝？先 hook 再裝\n2. **沒有視覺** — 純文字 README 就像沒有截圖的 App Store 頁面\n3. **Badges 排了一整行** — 超過 4 個就開始看起來像 CI dashboard\n4. **章節順序亂跳** — 「先講安裝、再講功能、再講為什麼、再講安裝」這種輪迴看不下去\n5. **Tagline 是「This is a tool that...」** — 直接寫 user value，不要解釋自己是什麼\n\n## 兩個現成工具\n\n如果不想從零寫：\n\n- **[readme-md-generator](https://github.com/kefranabg/readme-md-generator)**：interactive CLI，問 10 個問題產出標準結構的 README。⚠️ 老牌但已停更（最後發布 v1.0.0 / 2019 年），在新版 Node.js 上可能有相容性問題；建議當作結構參考，或改用線上工具 [readme.so](https://readme.so/)\n- **[Awesome README](https://github.com/matiassingers/awesome-readme)**：GitHub 精選範例集，找類似專案抄結構\n\n### 3 分鐘快速上手 readme-md-generator\n\n> 注意：此工具最後一次發布是 v1.0.0（2019 年），已多年未更新，在新版 Node.js 上可能裝不起來或跑不動。以下流程僅供參考其產出結構，若安裝失敗請改用 [readme.so](https://readme.so/) 之類仍在維護的線上產生器。\n\n```bash\n# 安裝\nnpm install -g readme-md-generator\n\n# 進入專案根目錄\ncd your-project\n\n# 啟動 interactive 模式\nreadme-md-generator\n```\n\n執行後會依序問你：\n\n1. 專案名稱（自動從 `package.json` 抓）\n2. 描述、版本、作者\n3. 想包含哪些章節（Installation、Usage、Tests、Contributing…）\n4. 授權類型\n\n每個問題都有預設值（直接按 Enter 就行），跑完一輪會在當前目錄生成 `README.md`。產出的結構不是最美但很完整，**可以當骨架再手動精修**。\n\n## TL;DR\n\n把 README 排成 landing page 的 6 個元素：\n\n1. **Centered hero block**（logo + 標題 + tagline）\n2. **Badges**（4 個就好：license、release、build、platform）\n3. **Hero image / GIF / video**（視覺先行）\n4. **一句話定位**（user value，不是 feature list）\n5. **Quick Start**（30 秒能試到）\n6. **章節順序模板**（How → Features → Install → Docs）\n\n訪客只給你 10 秒。這 6 個元素是讓那 10 秒值得的最低標準。\n\n## 接下來\n\n學會基本排版後，如果想把產品 demo 影片塞進 README：\n\n👉 [《GitHub README 動態 demo 的 5 種策略：從 GIF 到自架 CDN》](/blog/github-readme-dynamic-demo-strategies/) — 5 種策略決策樹，幫你選最適合的呈現方式\n👉 [《把 30 秒產品介紹塞進 GitHub README 的最後一哩》](/blog/github-readme-video-embed-last-mile/) — `<video>` 標籤的踩雷紀錄，60 秒手動操作的真相\n👉 [《為什麼一支 30 秒影片是 OSS 上架最大的槓桿》](/blog/oss-product-video-leverage/) — 把視覺先行的觀念延伸到 OSS 上架策略\n👉 [《Landing Page 與 SEO：讓產品被找到》](/blog/ai-solo-builder-landing-page-seo/) — landing page 核心概念從 README 延伸到完整產品曝光",
      "summary": "訪客打開 GitHub repo 只給你 10 秒決定要不要 star。這篇用 GitHub 原生 markdown 把 README 排成 landing page：centered hero block、badges、視覺先行、一句話定位、Quick Start、章節順序模板 6 個元素，外加 5 個常見地雷與 2 個現成工具。給第一次發 OSS 的 indie hacker。",
      "image": "https://bobochen.dev/_astro/cover.BhboLohq.webp",
      "date_published": "2026-05-17T00:00:00.000Z",
      "tags": [
        "GitHub",
        "README",
        "Open Source",
        "Solo Builder",
        "Documentation"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/github-readme-video-embed-last-mile/",
      "url": "https://bobochen.dev/blog/github-readme-video-embed-last-mile/",
      "title": "把 30 秒產品介紹塞進 GitHub README 的最後一哩",
      "content_text": "幫開源產品做了 30 秒介紹影片，想內嵌到 GitHub README，卻踩了三個雷才發現潛規則：README 的 video 標籤只認 GitHub 自家 user-attachments CDN，raw 與 Release URL 都不會 inline 播放。",
      "content_html": "## GitHub README 影片內嵌：一件以為 5 分鐘卻做了 30 分鐘的事\n\n剛幫自己的開源工具 [TypeLate](https://github.com/bobo52310/TypeLate) 做了 30 秒產品介紹影片，雙語版（英文 + 繁中），自己挺滿意。\n\n下一步：塞進 GitHub README，讓訪客一打開頁面就看到產品在動。\n\n`<video>` 標籤而已，能多複雜？\n\n結果這篇就是我花了 30 分鐘踩三個雷之後，才發現 GitHub 有個沒明說的潛規則：**README 的 `<video>` 只認他們自家 CDN，其他任何 URL 都不會 inline 播放。**\n\n如果你也想把產品 demo 塞進 README，這篇可以幫你省下我那 30 分鐘。\n\n## 試法 1：相對路徑（直覺第一手）\n\n我先試最直覺的：把 MP4 commit 進 repo，用相對路徑。\n\n```html\n<video src=\"docs/promo.mp4\" width=\"700\" controls></video>\n```\n\npush 上去之後打開 README——player 渲染出來了，看起來很正常。\n\n點 play。\n\n⋯⋯沒反應。\n\n「奇怪，是路徑問題嗎？」我用絕對的 raw URL 再試一次：\n\n```html\n<video src=\"https://raw.githubusercontent.com/USER/REPO/main/docs/promo.mp4\" controls></video>\n```\n\n一樣沒反應。\n\ncurl 一驗就明白了：\n\n```bash\n$ curl -sI -L \"https://raw.githubusercontent.com/USER/REPO/main/docs/promo.mp4\"\nHTTP/2 200\ncontent-type: application/octet-stream\ncontent-disposition: attachment; filename=promo.mp4\n```\n\n`Content-Disposition: attachment` —— 瀏覽器把這個 URL 當「下載檔」，不會餵給 `<video>` 解碼器。`<video>` 標籤雖然在 HTML 結構裡存在，但 src 指向的資源根本不能 stream。\n\n那 player 渲染出來只是個空殼。\n\n## 試法 2：上傳到 GitHub Release（以為自家貨會放寬）\n\n不死心。「Release asset 是 GitHub 正規 hosting，總該給正確 Content-Type 吧？」\n\n用 [gh CLI](https://github.com/cli/cli) 上傳：\n\n```bash\n$ gh release upload v1.6.2 promo.mp4\n```\n\nURL 變成漂亮的 `https://github.com/USER/REPO/releases/download/v1.6.2/promo.mp4`。\n\n再 curl ——\n\n```bash\n$ curl -sI -L \"https://github.com/USER/REPO/releases/download/v1.6.2/promo.mp4\"\nHTTP/2 302\nlocation: https://github-production-release-asset-...amazonaws.com/...\nHTTP/2 200\ncontent-type: application/octet-stream\ncontent-disposition: attachment; filename=promo.mp4\n```\n\n**還是 attachment、還是 octet-stream。**\n\nGitHub 不會因為你正式上架就放寬 Content-Type。Release asset 跟 raw 走的是同一條「強制下載」路線。\n\n第二雷踩完，影片在 README 還是不會動。\n\n## 試法 3：終於認清現實——drag-drop 是唯一路徑\n\n抓著疑問翻 GitHub Docs 跟一堆 Stack Overflow 問答，才拼出真相：\n\n> GitHub Markdown 的 `<video>` 標籤只支援來自他們自家「user-attachments」CDN 的 URL，長這樣：\n> `https://github.com/user-attachments/assets/{UUID}`\n\n而這串 UUID **只能透過 github.com 的網頁編輯器（編輯 README、留言 PR、開 Issue 都可以），把 MP4 檔案直接「拖放」進文字框產生**。GitHub 在背後偷偷上傳到自家 CDN，自動把 magic URL 插入 markdown。\n\n沒有 API。沒有 CLI 指令。沒有任何純 script 路徑。\n\n也就是說：\n\n| URL 類型 | inline 播放？ | 自動化？ |\n|---|---|---|\n| 相對路徑（repo 內檔案） | ❌ | ✅ |\n| raw.githubusercontent.com | ❌（attachment） | ✅ |\n| releases/download/...（剛剛試的） | ❌（attachment） | ✅ |\n| user-attachments/assets/{UUID} | ✅ 串流播放 | ❌（web-only 拖放）|\n\n60 秒手動操作，但這是唯一通的路。\n\n### 實際操作步驟（給跟我一樣踩過雷的你）\n\n1. 打開 `https://github.com/USER/REPO/edit/main/README.md`\n2. 把 MP4 檔從 Finder/Explorer 直接拖進編輯區的文字框\n3. 等個 5-10 秒，GitHub 把它上傳到自家 CDN\n4. 編輯區會自動插入 `<video>` 標籤，src 是 `https://github.com/user-attachments/assets/{UUID}`\n5. 點下方「Commit changes」按鈕，寫好 message 提交\n\n完。從拖檔到完成，60 秒。\n\n## 反思（一）這不是 bug，是設計\n\n退一步想，GitHub 這樣設計其實合理。\n\n如果他們允許任何 raw / release URL 都能 inline 串流，相當於每個 GitHub repo 都變成免費影片 CDN。隨便一個熱門開源專案的 README，每秒被千百人開啟，流量帳單會炸到爆。\n\n把 inline video 鎖在他們能掌控的 user-attachments CDN，他們可以：\n\n- 限制單檔大小（影片：免費方案 10MB、付費方案 100MB）\n- 限制總流量\n- 防止別人拿 GitHub 當免費影片 host 嵌到其他網站\n- 統一統計播放數據\n\n對追求「100% pure CLI flow」的工程師來說，這 60 秒手動操作是個刺。但站在平台方角度，是一個合理的取捨。\n\n## 反思（二）早點接受「有些事就是要手點」\n\n我真正花時間最多的，其實不是測試本身——curl 跑 header 比較其實只要 5 分鐘。\n\n花最多時間的是**不甘心**：\n\n- 試完 raw URL 失敗，「應該還有別的 URL 格式」\n- 試完 release URL 失敗，「也許有什麼隱藏的 query string」\n- 翻完 docs 沒答案，「不對啊一定有 API」\n\n直到認清「**沒有 API、沒有自動化路徑、就是要手動拖放一次**」，我才能繼續往前。\n\n這個心態陷阱不只發生在這次。每次我遇到「明明感覺應該能自動化但沒有現成方案」的場景，本能都是再多花 30 分鐘找解法，而不是花 60 秒手動做掉。\n\n很多時候那 60 秒比 30 分鐘更划算。\n\n> 如果一個一次性的手動操作只要 60 秒，就去做。\n> 如果這個操作會重複 100 次，再來想自動化。\n\n對 indie hacker 來說，這個簡單的規則比「強迫症追求純 script」更能保護你的時間。\n\n## TL;DR\n\n想把產品 demo 影片塞進 GitHub README？\n\n1. **別試 `<video src=\"https://raw.githubusercontent.com/...\">`** —— 不會 stream\n2. **別浪費時間上傳到 Release 再引用** —— 同樣不會 stream\n3. **打開 github.com 的 README 編輯器** → 直接把 MP4 拖進文字框 → 用 GitHub 自動產生的 `user-attachments/assets/{UUID}` URL → 完成\n4. **保留你的 MP4 在 repo 裡（例如 `docs/promo.mp4`）** —— 即使不能 inline 播放，commit history 和 download 連結仍有用\n\n整段過程 60 秒。\n\n別像我一樣花 30 分鐘才接受這件事 🥲\n\n## 延伸閱讀\n\n**還沒被說服要花一天做 30 秒影片？** 從訊息密度、頂級 OSS 共識、ROI 三個角度說明為什麼值得：\n👉 [《為什麼一支 30 秒影片是 OSS 上架最大的槓桿》](/blog/oss-product-video-leverage/) — 給還在猶豫「值不值得」的人\n\n**還沒有一個夠 landing-page 感的 README？** 在塞 demo 影片之前，先把 README 的基本骨架排好：\n👉 [《GitHub README 排版術：把開源專案首頁變成 landing page》](/blog/github-readme-landing-page-layout/) — 6 個元素 + 章節順序模板，給第一次發 OSS 的人\n\n**想用其他方式呈現產品 demo？** `<video>` + user-attachments 只是 5 種策略之一。如果你的 demo 是 terminal CLI 場景、影片超過上限（免費方案 10MB / 付費方案 100MB）、要 100% 純 CLI flow，或想要 SEO 加分——還有另外 4 種替代方案各有取捨：\n👉 [《GitHub README 動態 demo 的 5 種策略：從 GIF 到自架 CDN》](/blog/github-readme-dynamic-demo-strategies/) — 有決策樹幫你選最適合的策略",
      "summary": "幫開源產品做了 30 秒介紹影片，想內嵌到 GitHub README，卻踩了三個雷才發現潛規則：README 的 video 標籤只認 GitHub 自家 user-attachments CDN，raw 與 Release URL 都不會 inline 播放。",
      "image": "https://bobochen.dev/_astro/cover.ChZnS0ab.webp",
      "date_published": "2026-05-17T00:00:00.000Z",
      "tags": [
        "GitHub",
        "README",
        "Open Source",
        "Solo Builder",
        "Video"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/oss-product-video-leverage/",
      "url": "https://bobochen.dev/blog/oss-product-video-leverage/",
      "title": "為什麼一支 30 秒影片是 OSS 上架最大的槓桿",
      "content_text": "OSS 99% 死於沒人看，不是 code 不好。從訊息密度、頂級 OSS 共識、8 小時換 1 年的 ROI 三個角度，解釋為什麼一支 30 秒動態影片是 README 最強的槓桿，並回應「我的工具不適合做影片」的 3 種常見反論——給第一次發 OSS 的 indie hacker / solo builder。",
      "content_html": "## OSS 99% 死於沒人看 —— 而影片是 README 最強的槓桿\n\n不是 code 寫得不夠好——大部分被忽略的 OSS 其實 code 都不差。\n\n是**沒有任何元素能在訪客的 10 秒注意力裡傳達「這是什麼、為什麼我該關心」**。\n\n而在 README 所有可以放的元素裡，**動態影片是最強的槓桿**——一次傳達「能用」「使用體驗」「視覺品質」三件事，是其他任何元素都辦不到的。\n\n但做 30 秒產品介紹影片如果不熟要花一整天，熟了也要半天。值得嗎？\n\n這篇從三個角度拼出我的答案：值得。\n\n## 角度 1：訊息密度的不對稱\n\nREADME 上每個元素都有它的「訊息天花板」——也就是這個元素**最多能傳達多少訊息**。\n\n| 元素 | 訊息天花板 |\n|---|---|\n| Tagline | 「給誰、解決什麼」（1 件） |\n| Screenshot | 「視覺品質」（1 件） |\n| Features list | 「能做什麼」（1 件，且要讀者逐條讀） |\n| Quick Start | 「怎麼裝」（1 件） |\n| **Video / GIF** | **「能用」+「使用體驗」+「視覺品質」（3 件，30 秒內同時）** |\n\n差別在訊息密度——影片用同樣的閱讀時間傳達 3 倍的訊息量。\n\n但真正的槓桿不只在「密度高」。真正的槓桿在於：\n\n**影片能在讀者腦中產生「我在用這個工具」的具體想像**。\n\nScreenshot 是「別人在用」，Features 是「它能做」，影片是「我會怎麼用」。從心理學角度，「我會怎麼用」這個想像就是 star/clone 的最大驅動力——讀者不是因為 code 好而 star，是因為他**想看見自己也在用**。\n\n## 角度 2：頂級 OSS 已經達成共識\n\n打開這些 2025/2026 年紅得發紫的 OSS：\n\n- **Linear**：landing page hero 是 30 秒 demo video（雖然不是 OSS，但行銷模式 OSS 都在抄）\n- **Cal.com**：landing + README 都有 hero video\n- **Plane.so**：README 第一個區塊就是動態 demo GIF\n- **Excalidraw**：README hero 是動態使用 GIF\n- **Supabase**：README 短 GIF + landing 完整 demo video\n- **Vercel**：所有 OSS 工具 README（Next.js, AI SDK...）都有 video 或 high-fidelity GIF\n\n對比那些**沒有** hero visual 的 OSS（不點名了，看 GitHub trending 倒數區），普遍特徵：純文字 README、第一個區塊就是安裝指令、tagline 是 \"X is a tool that uses Y...\"。\n\n這不是偶然——頂級 OSS 行銷團隊投入 N 個小時 A/B 測試的結果。如果你是 indie hacker / solo builder，**可以直接抄結論**：hero video 是必備，不是 nice-to-have。\n\n## 角度 3：8 小時換 1 年的 ROI\n\n假設你花**一整天 8 小時**做 30 秒影片。\n\n這 8 小時的回報是什麼？\n\n- **影片活在 README 至少 1 年**（除非產品大改）\n- **期間 README 每次被打開都有它**——對活躍 OSS，這是上千到上萬次曝光\n- **單獨可發**：30 秒 MP4 可以發 Twitter / Threads / Reddit / Hacker News，跟 README link 完全不同的擴散管道\n- **conversion 提升**：hero video 把 star / fork conversion 提升 2-5x\n\n先講清楚這個 2-5x 的來歷：它是 OSS 行銷文章裡反覆出現的數字，不是我自己量到的，也不是哪份有對照組的研究——你就當成「常見傳說值」看，別當鐵律。更重要的是它有個前提：這個倍數是乘在「已經有人滑到並點開你 README」上的。對 0 star、0 流量的新專案，2-5x 乘以接近 0 還是接近 0。影片放大的是既有流量的轉換率，不是憑空生出流量。所以這數字對「已經有人來看」的專案才有意義，對還沒人知道你存在的專案，先解決的是怎麼讓人來，不是怎麼讓來的人多按 star。\n\n對比另一個常見的時間投資：**寫一篇 8 小時的技術部落格**。文章可能拿到 1 次 Hacker News 曝光（如果幸運），3 天後沉底。影片是**一年都還在替你工作的資產**。\n\n> 對 indie hacker：8 小時換 1 年的視覺資產 + 社群可分享 demo，是能拿到最高 ROI 的時間投資之一。\n\n不在「能不能」做這件事，而在「想不想接受 8 小時的初期投入」。\n\n但我得補一句，免得這段聽起來像穩賺：這 8 小時對 solo builder 不是免費的。它的真正成本不是「一天」，是「這天沒拿去修 bug、沒做使用者最想要的功能、沒回 issue」——對只有一個人的專案，這才是最稀缺的東西。所以有幾種情況我會勸你先別做影片：\n\n- **產品還在 pre-PMF、核心功能還會大改**：影片拍完三週就過時，等於白拍。\n- **還沒有任何人在用、零回饋**：這時花 8 小時做影片，只是把零曝光包裝得漂亮一點，ROI 很可能是負的。\n\n我自己的門檻是：等 README 開始有一點自然流量、有陌生人真的 clone 來用、issue 區開始冒出不認識的人，再回頭投資影片。在那之前，先去把那個讓人留下來的功能做對。影片是放大器，不是救生圈。\n\n## 「我的工具不適合做影片」——3 種反論\n\n我聽過幾種版本：\n\n### 反論 1：「我做的是 CLI 工具，沒什麼好看的」\n\nCLI 工具**更適合**做 demo。終端機畫面 + 即時輸出有天然的節奏感。\n\n- [Asciinema](https://github.com/asciinema/asciinema) — 錄製 terminal 事件流，純文字、檔案 KB 級別、**觀眾可以從 demo 直接複製你打的指令**\n- [VHS](https://github.com/charmbracelet/vhs) — Charm 出的工具，寫一份 `.tape` 指令稿（就像寫 bash），自動產生 GIF/MP4，可重現可進 git\n- 純錄影：用 [tmux](https://github.com/tmux/tmux) + [QuickTime](https://support.apple.com/quicktime) 錄終端機畫面\n\n### 反論 2：「我做的是 library，沒 UI」\n\n讓 code 動起來：\n\n- [Carbon](https://carbon.now.sh/) 或 [ray.so](https://ray.so/) — 美觀的 code 截圖\n- [Code Hike](https://codehike.org/) — 在 MDX 文章裡做 step-by-step code reveal 動畫\n- 一個常見模式：用 [Remotion](https://www.remotion.dev/) 做「3 行 code 解決 X 問題」的動態 demo\n\n### 反論 3：「我做的是純後端服務」\n\n至少做一張流程圖 GIF：\n\n- 動畫展示 request → process → response 的時間線\n- 用 [Excalidraw](https://excalidraw.com/) 畫好流程，匯出 SVG，CSS animate\n- 用 [Motion Canvas](https://motioncanvas.io/) 或 [Manim](https://www.manim.community/) 程式化生成數學/系統動畫\n\n簡單說：**沒有「不適合做 demo」的工具，只有「沒花時間想 demo 怎麼做」的人**。\n\n## 30 秒影片該講什麼（如果你決定做）\n\n只有 30 秒，講這 3 件事：\n\n1. **痛點**（5 秒）— 把訪客拉進「我有這個煩惱」的情境\n2. **解法演示**（20 秒）— 工具的關鍵 magic moment\n3. **CTA / 品牌收尾**（5 秒）— logo + tagline + GitHub URL\n\n**不要嘗試講完所有功能**。一支 30 秒影片只能傳達「一個 main value」，其他靠 README 補。\n\n工具選擇：\n\n- **純螢幕錄製**：[Loom](https://www.loom.com/) 或 [Cap](https://github.com/CapSoftware/Cap)（OSS）\n- **高品質敘事**：[Remotion](https://www.remotion.dev/)（React 寫影片）或 [HyperFrames](https://hyperframes.heygen.com/)（HTML + GSAP 寫影片）\n- **純動畫**：[Motion Canvas](https://motioncanvas.io/) 或 [Manim](https://www.manim.community/)\n\n我做 [TypeLate](https://github.com/bobo52310/TypeLate) 這次用 HyperFrames：30 秒、5 個場景、雙語版（EN + 繁中），完整流程在 [《把 30 秒產品介紹塞進 GitHub README 的最後一哩》](/blog/github-readme-video-embed-last-mile/)——那篇也記錄了我做完之後踩到的 GitHub 嵌入雷。\n\n## 我自己的觀察\n\n老實說：我沒有 hard A/B data 證明 hero video 把 TypeLate 的 star 數推到哪。觀察期太短、變因太多。\n\n但有兩個質的觀察值得寫：\n\n**1. 影片本身比 README 文字更有「擴散性」**\n\n我做完 30 秒影片後，可以單獨把它發 Twitter / Threads / 社群——這個動作的 reach 是發 README link 的數十倍。一份內容兩用：README hero + 社群 ammunition。\n\n**2. 「介紹自己做什麼」的成本大幅降低**\n\n之前在群組或 podcast 講 TypeLate，要花一分鐘鋪陳「它是一個 push-to-talk 聽寫工具」。有了 30 秒影片後，貼連結即可。聽眾在 30 秒內理解的程度，比我講一分鐘還深。\n\n這兩個觀察就足以讓我下次也做。對 indie hacker / solo builder 來說，**不一定要 A/B 數字證明，幾個質的觀察就夠決定**。\n\n## TL;DR\n\nOSS 99% 死於沒人看，不是 code 不好。\n\n3 個角度說明為什麼動態影片是 README 最強槓桿：\n\n1. **訊息密度**：影片同時傳達「能用 + 體驗 + 品質」，其他元素只能一件\n2. **頂級 OSS 共識**：Linear、Cal.com、Plane、Excalidraw、Supabase 都有 hero video，可以直接抄\n3. **ROI**：8 小時換 1 年的視覺資產 + 社群可分享 demo\n\n「不適合做影片」？\n- CLI → Asciinema / VHS\n- Library → Carbon / Code Hike\n- 後端 → Excalidraw / Motion Canvas 流程圖\n\n30 秒影片該講：痛點（5s）→ magic moment（20s）→ CTA（5s）。不要塞所有功能。\n\n## 接下來\n\n決定要做影片了？這個系列剩下兩篇是實作向：\n\n👉 [《GitHub README 排版術：把開源專案首頁變成 landing page》](/blog/github-readme-landing-page-layout/) — 影片以外的 5 個 README 元素怎麼排\n👉 [《GitHub README 動態 demo 的 5 種策略：從 GIF 到自架 CDN》](/blog/github-readme-dynamic-demo-strategies/) — 影片做完了，怎麼塞進 README？決策樹",
      "summary": "OSS 99% 死於沒人看，不是 code 不好。從訊息密度、頂級 OSS 共識、8 小時換 1 年的 ROI 三個角度，解釋為什麼一支 30 秒動態影片是 README 最強的槓桿，並回應「我的工具不適合做影片」的 3 種常見反論——給第一次發 OSS 的 indie hacker / solo builder。",
      "image": "https://bobochen.dev/_astro/cover.Uv9p7VzR.webp",
      "date_published": "2026-05-17T00:00:00.000Z",
      "tags": [
        "Open Source",
        "Solo Builder",
        "行銷",
        "Product",
        "Video"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-mcp-server-dev/",
      "url": "https://bobochen.dev/blog/claude-api-guide-mcp-server-dev/",
      "title": "MCP Server 開發：讓你的服務成為 AI 工具",
      "content_text": "從使用者角度到開發者角度，掌握 MCP（Model Context Protocol）Server 開發。學會定義 Tools、Resources、Prompts，讓你的服務被 Claude Code、Claude.ai 以及任何 MCP 客戶端呼叫。",
      "content_html": "在你讀到這一章之前，你可能已經用過 MCP 了——作為一個使用者。\n\n你在 Claude Code 裡連接了 GitHub MCP server，Claude Code 就能直接查 PR、建立 Issue。你連接了 Filesystem MCP，Claude Code 就能讀取你本機的任何檔案。你連接了 Postgres MCP，AI 就能直接查你的資料庫。\n\n這就是 MCP 從使用者角度的樣子：**讓 AI 客戶端取用更多能力**。\n\n但這一章，我們要換一個視角——**開發者視角**：如果你想讓自己開發的服務，被任何支援 MCP 的 AI 客戶端使用，你需要開發一個 MCP Server。\n\n這個思路轉換其實很重要。MCP 不只是讓你「用更多工具」，而是讓你「把你的服務暴露給所有 AI 工具」。你開發一個 Jira MCP server，它可以被 Claude Code 用、被 Claude.ai 用、被 Cursor 用、被任何未來出現的 MCP 客戶端用。一次開發，到處可用。\n\n## MCP Server 的三種能力\n\n在動手寫程式碼之前，先理解 MCP Server 能提供哪三種能力。\n\n**Tools（工具）**：讓 AI 執行操作。這是最常用的能力——AI 呼叫你的工具，你的工具做某件事，回傳結果。搜尋 Jira issue、建立 GitHub PR、查詢資料庫記錄——這些都是 Tools。\n\n**Resources（資源）**：提供唯讀的資料。Resources 跟 Tools 的差別是：Tools 是「做某件事」，Resources 是「讀取某樣東西」。你的文件庫、設定檔、日誌記錄——這些適合暴露為 Resources。AI 客戶端可以瀏覽和讀取你的 Resources，就像在資料夾裡找檔案一樣。\n\n**Prompts（提示範本）**：預定義的提示模板。你可以把常用的提示場景封裝成 Prompts，讓 AI 客戶端可以選擇使用。例如「程式碼審查」、「撰寫 commit message」——這些都可以封裝成可重用的 Prompt。\n\n三者之中，**Tools 是最重要的**，你 90% 的時間會在開發 Tools。但 Resources 和 Prompts 是完整 MCP Server 的組成部分，不能忽視。\n\n## 開發環境設定\n\nMCP 有 Python 和 TypeScript 兩個官方 SDK。我通常選擇跟我的後端語言一致——如果已有 Python 服務，用 Python SDK；如果是 Node.js 服務，用 TypeScript SDK。\n\n**Python SDK 安裝：**\n\n```bash\npip install mcp\n# 或用 uv（速度更快，推薦）\nuv add mcp\n```\n\n**TypeScript SDK 安裝：**\n\n```bash\nnpm install @modelcontextprotocol/sdk\n# 或\nyarn add @modelcontextprotocol/sdk\n```\n\n我這一章用 Python 示範，TypeScript 的概念完全相同，只是 API 語法不同。\n\n## 建立 MCP Server 骨架\n\n最簡單的 MCP server 只需要幾行：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\n\n# 建立 server 實例\nmcp = FastMCP(\"my-first-mcp-server\")\n\n# 啟動 server（使用 stdio transport）\nif __name__ == \"__main__\":\n    mcp.run()\n```\n\n`FastMCP` 是 MCP Python SDK 的高階 API，使用 decorator 方式定義 Tools 和 Resources，減少樣板程式碼。\n\n現在執行這個程式，你會得到一個可運作的 MCP server——雖然它什麼也做不了，但架構已經到位。\n\n## 定義 Tools\n\nTools 是 MCP Server 最核心的功能。用 `@mcp.tool()` decorator 定義：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\nfrom pydantic import BaseModel\n\nmcp = FastMCP(\"jira-mcp-server\")\n\n@mcp.tool()\ndef search_issues(\n    project_key: str,\n    status: str = \"Open\",\n    max_results: int = 10\n) -> str:\n    \"\"\"\n    搜尋 Jira 中的 Issue。\n\n    Args:\n        project_key: 專案代碼，例如 \"BACKEND\" 或 \"FRONTEND\"\n        status: Issue 狀態，可以是 \"Open\", \"In Progress\", \"Done\"\n        max_results: 最多回傳幾筆，預設 10\n\n    Returns:\n        符合條件的 Issue 列表（JSON 格式）\n    \"\"\"\n    # 實際的 Jira API 呼叫\n    issues = jira_client.search_issues(\n        f\"project = {project_key} AND status = '{status}'\",\n        maxResults=max_results\n    )\n\n    return format_issues_as_json(issues)\n```\n\n幾個關鍵點：\n\n**函數的 docstring 非常重要。** AI 客戶端用 docstring 來理解這個工具做什麼、什麼時候該用。寫清楚的 docstring 讓 AI 更精準地決定何時呼叫你的工具。\n\n**型別標注讓 MCP SDK 自動生成 JSON Schema。** AI 客戶端用 JSON Schema 來知道每個參數的型別和格式，所以型別標注要準確。\n\n**回傳值應該是字串或可序列化的資料。** MCP 工具的回傳值最終會被轉成字串給 AI 客戶端看。\n\n讓我們把 Jira MCP server 繼續完善：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\nimport anthropic\nimport requests\nimport json\nimport os\nfrom datetime import datetime\n\nmcp = FastMCP(\"jira-mcp-server\")\n\n# Jira 客戶端設定\nJIRA_BASE_URL = os.environ[\"JIRA_BASE_URL\"]  # 例如 https://yourcompany.atlassian.net\nJIRA_EMAIL = os.environ[\"JIRA_EMAIL\"]\nJIRA_API_TOKEN = os.environ[\"JIRA_API_TOKEN\"]\n\ndef jira_request(method: str, endpoint: str, data: dict = None) -> dict:\n    \"\"\"Jira API 的底層呼叫\"\"\"\n    url = f\"{JIRA_BASE_URL}/rest/api/3/{endpoint}\"\n    auth = (JIRA_EMAIL, JIRA_API_TOKEN)\n    headers = {\"Content-Type\": \"application/json\"}\n\n    response = requests.request(\n        method,\n        url,\n        auth=auth,\n        headers=headers,\n        json=data\n    )\n    response.raise_for_status()\n    return response.json() if response.content else {}\n\n@mcp.tool()\ndef search_issues(\n    project_key: str,\n    status: str = None,\n    assignee: str = None,\n    max_results: int = 10\n) -> str:\n    \"\"\"\n    搜尋 Jira Project 中的 Issue。\n\n    Args:\n        project_key: 專案代碼（必填），例如 \"BACKEND\"\n        status: 篩選狀態，例如 \"In Progress\"、\"To Do\"、\"Done\"（選填）\n        assignee: 篩選負責人的帳號 ID（選填）\n        max_results: 最多回傳筆數，預設 10，最大 50\n\n    Returns:\n        Issue 列表，包含 key、標題、狀態、負責人、建立時間\n    \"\"\"\n    jql_parts = [f\"project = {project_key}\"]\n    if status:\n        jql_parts.append(f\"status = '{status}'\")\n    if assignee:\n        jql_parts.append(f\"assignee = '{assignee}'\")\n    jql_parts.append(\"ORDER BY created DESC\")\n\n    jql = \" AND \".join(jql_parts[:len(jql_parts)-1]) + f\" {jql_parts[-1]}\"\n\n    result = jira_request(\n        \"GET\",\n        f\"search?jql={jql}&maxResults={max_results}&fields=summary,status,assignee,created,priority\"\n    )\n\n    issues = []\n    for issue in result.get(\"issues\", []):\n        fields = issue[\"fields\"]\n        issues.append({\n            \"key\": issue[\"key\"],\n            \"summary\": fields[\"summary\"],\n            \"status\": fields[\"status\"][\"name\"],\n            \"assignee\": fields[\"assignee\"][\"displayName\"] if fields.get(\"assignee\") else \"未指派\",\n            \"priority\": fields[\"priority\"][\"name\"] if fields.get(\"priority\") else \"Medium\",\n            \"created\": fields[\"created\"][:10],  # 只取日期部分\n        })\n\n    return json.dumps(issues, ensure_ascii=False, indent=2)\n\n@mcp.tool()\ndef get_issue(issue_key: str) -> str:\n    \"\"\"\n    取得單一 Jira Issue 的詳細資訊。\n\n    Args:\n        issue_key: Issue 代碼，例如 \"BACKEND-123\"\n\n    Returns:\n        Issue 的完整資訊，包含描述、留言、子任務\n    \"\"\"\n    result = jira_request(\n        \"GET\",\n        f\"issue/{issue_key}?fields=summary,description,status,assignee,priority,comment,subtasks,labels\"\n    )\n\n    fields = result[\"fields\"]\n\n    # 整理留言\n    comments = []\n    for comment in fields.get(\"comment\", {}).get(\"comments\", [])[-5:]:  # 最近 5 則\n        comments.append({\n            \"author\": comment[\"author\"][\"displayName\"],\n            \"body\": comment[\"body\"][:200],  # 截斷過長的留言\n            \"created\": comment[\"created\"][:10],\n        })\n\n    return json.dumps({\n        \"key\": issue_key,\n        \"summary\": fields[\"summary\"],\n        \"description\": str(fields.get(\"description\", \"\"))[:500],\n        \"status\": fields[\"status\"][\"name\"],\n        \"assignee\": fields[\"assignee\"][\"displayName\"] if fields.get(\"assignee\") else \"未指派\",\n        \"labels\": fields.get(\"labels\", []),\n        \"subtasks\": [{\"key\": s[\"key\"], \"summary\": s[\"fields\"][\"summary\"]} for s in fields.get(\"subtasks\", [])],\n        \"recent_comments\": comments,\n    }, ensure_ascii=False, indent=2)\n\n@mcp.tool()\ndef create_issue(\n    project_key: str,\n    summary: str,\n    description: str = \"\",\n    issue_type: str = \"Task\",\n    priority: str = \"Medium\",\n    assignee_account_id: str = None,\n    labels: list[str] = None\n) -> str:\n    \"\"\"\n    在 Jira 建立新的 Issue。\n\n    Args:\n        project_key: 要建立在哪個 Project 下，例如 \"BACKEND\"\n        summary: Issue 標題（必填）\n        description: 詳細描述（選填，支援 Markdown 格式）\n        issue_type: Issue 類型，可以是 \"Task\"、\"Bug\"、\"Story\"、\"Epic\"\n        priority: 優先級，可以是 \"Highest\"、\"High\"、\"Medium\"、\"Low\"、\"Lowest\"\n        assignee_account_id: 負責人的 Jira 帳號 ID（選填）\n        labels: 標籤列表（選填），例如 [\"backend\", \"urgent\"]\n\n    Returns:\n        新建立的 Issue 代碼和連結\n    \"\"\"\n    fields = {\n        \"project\": {\"key\": project_key},\n        \"summary\": summary,\n        \"issuetype\": {\"name\": issue_type},\n        \"priority\": {\"name\": priority},\n    }\n\n    if description:\n        # Jira Cloud 用 Atlassian Document Format（ADF），這裡用簡化版\n        fields[\"description\"] = {\n            \"type\": \"doc\",\n            \"version\": 1,\n            \"content\": [{\"type\": \"paragraph\", \"content\": [{\"type\": \"text\", \"text\": description}]}]\n        }\n\n    if assignee_account_id:\n        fields[\"assignee\"] = {\"accountId\": assignee_account_id}\n\n    if labels:\n        fields[\"labels\"] = labels\n\n    result = jira_request(\"POST\", \"issue\", {\"fields\": fields})\n\n    return json.dumps({\n        \"key\": result[\"key\"],\n        \"url\": f\"{JIRA_BASE_URL}/browse/{result['key']}\",\n        \"message\": f\"Issue {result['key']} 已成功建立\"\n    }, ensure_ascii=False)\n\n@mcp.tool()\ndef update_issue_status(\n    issue_key: str,\n    target_status: str\n) -> str:\n    \"\"\"\n    更新 Jira Issue 的狀態。\n\n    Args:\n        issue_key: Issue 代碼，例如 \"BACKEND-123\"\n        target_status: 目標狀態名稱，例如 \"In Progress\"、\"Done\"、\"To Do\"\n\n    Returns:\n        更新結果\n    \"\"\"\n    # 先取得可用的 transitions\n    transitions_result = jira_request(\"GET\", f\"issue/{issue_key}/transitions\")\n    transitions = transitions_result.get(\"transitions\", [])\n\n    # 找到符合目標狀態的 transition\n    target_transition = None\n    for transition in transitions:\n        if transition[\"to\"][\"name\"].lower() == target_status.lower():\n            target_transition = transition\n            break\n\n    if not target_transition:\n        available = [t[\"to\"][\"name\"] for t in transitions]\n        return json.dumps({\n            \"error\": f\"找不到狀態 '{target_status}'\",\n            \"available_statuses\": available\n        }, ensure_ascii=False)\n\n    # 執行 transition\n    jira_request(\"POST\", f\"issue/{issue_key}/transitions\", {\n        \"transition\": {\"id\": target_transition[\"id\"]}\n    })\n\n    return json.dumps({\n        \"message\": f\"Issue {issue_key} 狀態已更新為 '{target_status}'\"\n    }, ensure_ascii=False)\n```\n\n## 定義 Resources\n\nResources 讓 AI 客戶端可以瀏覽和讀取你的資料。對 Jira server 來說，可以把常用的 JQL 查詢結果暴露為 Resources：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.types import Resource\nimport json\n\n@mcp.resource(\"jira://projects\")\ndef list_projects() -> str:\n    \"\"\"列出所有可存取的 Jira Project\"\"\"\n    result = jira_request(\"GET\", \"project\")\n    projects = [\n        {\"key\": p[\"key\"], \"name\": p[\"name\"], \"type\": p[\"projectTypeKey\"]}\n        for p in result\n    ]\n    return json.dumps(projects, ensure_ascii=False, indent=2)\n\n@mcp.resource(\"jira://project/{project_key}/open-issues\")\ndef get_open_issues(project_key: str) -> str:\n    \"\"\"取得指定 Project 的所有未完成 Issue\"\"\"\n    result = jira_request(\n        \"GET\",\n        f\"search?jql=project={project_key} AND status != Done ORDER BY priority DESC&maxResults=50\"\n    )\n    return json.dumps(result.get(\"issues\", []), ensure_ascii=False, indent=2)\n```\n\nResources 使用 URI 模板——`jira://project/{project_key}/open-issues` 中的 `{project_key}` 是動態參數，AI 客戶端可以填入任何值來讀取對應的資源。\n\n## 定義 Prompts\n\nPrompts 讓你封裝常用的提示場景：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\nfrom mcp.types import PromptMessage\n\n@mcp.prompt()\ndef daily_standup_prompt(project_key: str) -> list[PromptMessage]:\n    \"\"\"\n    生成每日站會的摘要提示。\n\n    Args:\n        project_key: 要摘要的 Project 代碼\n    \"\"\"\n    return [\n        {\n            \"role\": \"user\",\n            \"content\": f\"\"\"請幫我準備 {project_key} 專案的每日站會摘要。\n\n            步驟：\n            1. 用 search_issues 查詢昨天更新的 Issue\n            2. 查詢目前 In Progress 的 Issue\n            3. 查詢今天 Due 的 Issue\n            4. 整理成站會格式：\n               - 昨天完成了什麼\n               - 今天要做什麼\n               - 有什麼 Blocker\n\n            保持簡潔，每個項目一行。\n            \"\"\"\n        }\n    ]\n```\n\n## 本地測試\n\n開發 MCP server 的時候，最常用的測試方式是直接用 MCP Inspector：\n\n```bash\n# 方式一：Python MCP CLI（需先 pip install 'mcp[cli]'）\nmcp dev server.py\n\n# 方式二：npm 版 MCP Inspector，直接掛上你的 server\nnpx @modelcontextprotocol/inspector python server.py\n```\n\nMCP Inspector 會啟動一個 Web UI，讓你直接呼叫你的 Tools 和瀏覽你的 Resources，不需要透過 Claude 就能測試。\n\n或者更簡單——直接寫測試：\n\n```python\nimport asyncio\nfrom mcp import ClientSession, StdioServerParameters\nfrom mcp.client.stdio import stdio_client\n\nasync def test_jira_server():\n    server_params = StdioServerParameters(\n        command=\"python\",\n        args=[\"jira_server.py\"],\n        env={\n            \"JIRA_BASE_URL\": \"https://test.atlassian.net\",\n            \"JIRA_EMAIL\": \"test@example.com\",\n            \"JIRA_API_TOKEN\": \"test-token\",\n        }\n    )\n\n    async with stdio_client(server_params) as (read, write):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n\n            # 列出所有可用的 tools\n            tools = await session.list_tools()\n            print(\"Available tools:\")\n            for tool in tools.tools:\n                print(f\"  - {tool.name}: {tool.description[:50]}...\")\n\n            # 測試 search_issues tool\n            result = await session.call_tool(\n                \"search_issues\",\n                {\"project_key\": \"BACKEND\", \"status\": \"In Progress\"}\n            )\n            print(\"\\nsearch_issues result:\")\n            print(result.content[0].text)\n\nasyncio.run(test_jira_server())\n```\n\n## 連接到 Claude Code\n\n在 `~/.claude.json`（或專案的 `.claude/settings.json`）中新增你的 MCP server：\n\n```json\n{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"python\",\n      \"args\": [\"/path/to/jira_server.py\"],\n      \"env\": {\n        \"JIRA_BASE_URL\": \"https://yourcompany.atlassian.net\",\n        \"JIRA_EMAIL\": \"your-email@company.com\",\n        \"JIRA_API_TOKEN\": \"${JIRA_API_TOKEN}\"\n      }\n    }\n  }\n}\n```\n\n注意 `${JIRA_API_TOKEN}` 這種語法——Claude Code 會從環境變數讀取這個值，不要把 API token 直接寫在設定檔裡（那個檔案可能被 commit 到 git）。\n\n重啟 Claude Code 後，你應該能看到 Jira 的 Tools 出現在可用工具列表中。試試看說「搜尋 BACKEND 專案裡所有 In Progress 的 issue」——Claude Code 會自動呼叫你的 MCP server。\n\n## 連接到 Claude.ai\n\nClaude.ai（網頁版）也支援 MCP。在 Settings → Integrations → Add integration 中填入你的 MCP server 連接資訊。\n\n不過有個重要差異：Claude.ai 是雲端服務，它需要能透過網路訪問你的 MCP server。如果你的 server 只在本機跑（stdio transport），Claude.ai 無法連接。你需要把 server 部署到公開可訪問的位置，並使用 SSE（Server-Sent Events）或 WebSocket transport：\n\n```python\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"jira-mcp-server\")\n\n# ... 定義 Tools, Resources, Prompts ...\n\nif __name__ == \"__main__\":\n    import uvicorn\n    # 使用 HTTP/SSE transport 讓 Claude.ai 可以連接\n    mcp.run(transport=\"sse\", host=\"0.0.0.0\", port=8000)\n```\n\n## 打包與發布\n\n如果你想讓其他人也能用你的 MCP server，可以發布到 npm 或 PyPI。\n\n**Python 打包（使用 uv）：**\n\n```toml\n# pyproject.toml\n[project]\nname = \"jira-mcp-server\"\nversion = \"0.1.0\"\ndescription = \"A Jira MCP server for Claude\"\ndependencies = [\"mcp>=1.0.0\", \"requests>=2.31.0\"]\n\n[project.scripts]\njira-mcp-server = \"jira_mcp_server:main\"\n```\n\n發布後，其他人可以這樣安裝和使用：\n\n```json\n{\n  \"mcpServers\": {\n    \"jira\": {\n      \"command\": \"uvx\",\n      \"args\": [\"jira-mcp-server\"],\n      \"env\": {\n        \"JIRA_BASE_URL\": \"https://yourcompany.atlassian.net\"\n      }\n    }\n  }\n}\n```\n\n## 安全考量：認證、Rate Limiting、輸入驗證\n\nMCP server 的安全問題很容易被忽視，但非常重要。\n\n**認證：不要讓任何人都能呼叫你的 server。**\n\n對於 stdio transport（本機用），認證相對簡單——只要本機的 Claude Code 才能呼叫。但對於 HTTP/SSE transport（雲端部署），你需要 API key 或 OAuth：\n\n```python\nfrom fastapi import Header, HTTPException\n\n@app.middleware(\"http\")\nasync def authenticate(request, call_next):\n    api_key = request.headers.get(\"X-API-Key\")\n    if api_key != os.environ[\"MCP_API_KEY\"]:\n        raise HTTPException(status_code=401, detail=\"Unauthorized\")\n    return await call_next(request)\n```\n\n**Rate Limiting：防止 AI 瘋狂呼叫你的 API。**\n\nAI 客戶端可能在短時間內大量呼叫你的工具（尤其是 agent 在 loop 裡執行的時候）。一定要加入 rate limiting：\n\n```python\nfrom collections import defaultdict\nimport time\n\nclass RateLimiter:\n    def __init__(self, max_calls: int, window_seconds: int):\n        self.max_calls = max_calls\n        self.window = window_seconds\n        self.calls = defaultdict(list)\n\n    def is_allowed(self, key: str) -> bool:\n        now = time.time()\n        self.calls[key] = [t for t in self.calls[key] if now - t < self.window]\n        if len(self.calls[key]) >= self.max_calls:\n            return False\n        self.calls[key].append(now)\n        return True\n\nrate_limiter = RateLimiter(max_calls=60, window_seconds=60)\n\n@mcp.tool()\ndef search_issues(project_key: str, ...) -> str:\n    if not rate_limiter.is_allowed(\"search_issues\"):\n        return json.dumps({\"error\": \"Rate limit exceeded. Please try again in a minute.\"})\n    # ... 正常邏輯\n```\n\n**輸入驗證：永遠不要信任 AI 傳來的輸入。**\n\n```python\nimport re\n\n@mcp.tool()\ndef search_issues(project_key: str, ...) -> str:\n    # 驗證 project_key 格式（只允許大寫字母和數字）\n    if not re.match(r'^[A-Z][A-Z0-9]{0,9}$', project_key):\n        return json.dumps({\"error\": f\"無效的 project key 格式：{project_key}\"})\n\n    # 避免 JQL injection\n    # 不要直接把用戶輸入塞進 JQL 字串\n    safe_project_key = project_key.strip().upper()\n    # ...\n```\n\nMCP server 開發本質上跟任何 API 開發一樣——輸入驗證、認證、限流都是基本功，不能省略。\n\n---\n\n下一章，我們轉向一個每個 AI 應用開發者遲早都會面對的問題：成本控制。你的 agent 系統功能再強，如果每個月要燒掉 $5000 的 API 費用，那商業模式就很難跑通。",
      "summary": "從使用者角度到開發者角度，掌握 MCP（Model Context Protocol）Server 開發。學會定義 Tools、Resources、Prompts，讓你的服務被 Claude Code、Claude.ai 以及任何 MCP 客戶端呼叫。",
      "image": "https://bobochen.dev/_astro/cover.ChhO_APS.webp",
      "date_published": "2026-05-15T00:00:00.000Z",
      "tags": [
        "Claude API",
        "MCP",
        "工具開發"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-memory-state-management/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-memory-state-management/",
      "title": "Agent memory 與狀態管理：short / long / episodic，以及記憶也有權限",
      "content_text": "檢索是「公司的知識」，記憶是「這個使用者、這個任務的脈絡」，兩者不一樣。拆解短期、長期、episodic 三種記憶的用途與設計，context engineering 怎麼決定塞什麼進有限的視窗，以及一個最容易出包的點——A 使用者的記憶不能洩進 B 使用者的對話。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 7 篇（共 12 篇）。上一篇：[Tool use 與 MCP](/blog/enterprise-ai-agent-tool-use-mcp)。\n\n先釐清一個常被混在一起的概念：**retrieval（檢索）和 memory（記憶）不是同一件事**。\n\n- **Retrieval（第 3–5 篇）**：去公司的知識庫找「客觀的、共用的知識」。產品規格、SOP、法規——這些不屬於任何一個對話。\n- **Memory（這篇）**：記住「這個使用者、這個任務」的脈絡。他剛剛問過什麼、他偏好什麼、這個任務做到哪一步、試過哪些路。\n\n把這兩個混在一起，是很多 agent 行為怪異的根源（該記的沒記、不該共用的亂共用）。這篇把記憶拆成三層講清楚，最後講一個最容易出包、卻最少人提的點：**記憶也有權限**。\n\n## 為什麼需要 memory：LLM 天生健忘\n\nLLM 本身是**無狀態**的。每次呼叫，它只看得到你這次塞進 context window 的東西。你不主動把上一輪對話帶進來，它就完全不記得三秒前說過什麼。\n\n所以「記憶」從來不是模型的能力，是**你在模型外面，自己蓋的一套系統**。它的工作是：在每次呼叫前，決定「要把哪些過去的東西，塞進這次有限的 context」。記憶系統的好壞，就是這個決定做得好不好。\n\n## 三種記憶，各司其職\n\n### 1. 短期記憶（short-term）：這一輪對話\n\n最直覺的一種——這次對話講過的話。實作上常常就是把對話歷史一路帶著走。\n\n問題是 context window 有上限，而且**越長越貴、越慢，品質還會隨長度退化**。早年 Liu et al.（2023）的「lost in the middle」說的是一條 U 形曲線——開頭結尾記得牢、正中間掉到四成以下；但那是拿 2023 那批模型測的。到 2026，新模型在「單純的事實檢索」上大致補掉了中段這個洞，退化的形狀變了：不再是「中段最差」，而是**輸入越長、整體越爛，而且開頭通常比結尾撐得久**——業界現在叫它 **context rot**。\n\n有人會說：2026 的視窗不是已經爆大了嗎？是。Claude 4.6 系列標準定價就吃 1M token、Gemini 上看 2M。但**「窗口大」不等於「可以全塞」**——成本、延遲、context rot 三件事全都隨長度惡化，所以記憶管理在 2026 是更重要，不是更不重要；窗口變大只是把「塞不下」的硬牆，換成「塞得下但又貴又笨」的軟坑。\n\n而且不能全帶還有個更硬的理由：**塞越多，模型在「需要推理、而非字面比對」的檢索上越容易出錯**。Adobe Research 的 NoLiMa 評測故意把問題和答案的字面重疊拿掉、逼模型真的去推理，結果連 GPT-4o 都從短 context 的 99.3% 一路掉到 32K 長度的 69.7%。窗口大 ≠ 真的讀得懂中間那堆料。所以短期記憶不能無腦全帶，要管理：\n\n- **截斷**：只保留最近 N 輪。簡單但會忘掉開頭。\n- **摘要**：把比較舊的對話壓縮成摘要再帶。省 token，但摘要會流失細節。\n- **混合**：近期逐字保留 + 遠期摘要。實務上常見的折衷。\n\n### 2. 長期記憶（long-term）：跨對話記得這個人\n\n使用者三週前說過「我們公司用的是新台幣、財年從一月開始」，今天的新對話他不想再講一遍。長期記憶就是**跨越單次對話、持久保存**的那部分。\n\n實作上，長期記憶常常就**用向量庫存**（呼應第 4 篇）：把值得記的事實向量化存起來，新對話開始時，依當前話題檢索出相關的長期記憶，塞進 context。你會發現——這在技術上幾乎就是「對這個使用者私有資料做的一次 RAG」。\n\n關鍵設計問題是：**什麼值得記？** 全記會越積越雜、檢索越來越不準。要有策略地萃取「值得長期記住的事實 / 偏好」，而不是把每句話都存。\n\n而且「記」只是一半，另一半是**「忘」和「更新」**。使用者半年前說「我們財年從一月開始」，後來改了——舊記憶沒被覆蓋，今天就會拿過期資訊去推理，比沒記還糟。所以長期記憶不能只進不出：要能偵測衝突、覆蓋舊事實、定期淘汰沒再用到的記憶。一個只會累積、不會遺忘的記憶庫，最後會變成一個越來越自信地給你錯答案的系統。\n\n### 3. Episodic 記憶：這個任務做到哪了\n\n這層最常被忽略，但對「會做事」的 agent（第 6 篇的 tool use）最重要。Episodic 記憶記的是**一個任務的執行軌跡**：目標是什麼、已經做了哪幾步、每步結果如何、哪條路試過失敗了。\n\n沒有它，一個多步驟任務做到一半被打斷（逾時、人離開、系統重啟），回來就**整個忘光、從頭再來**，甚至重複做已經做過的副作用動作（又回到第 6 篇的 idempotency）。有了它，agent 才能「接著上次的進度繼續」。\n\n這層本質上是**狀態管理**問題，下面接著講。\n\n## 把 agent 當成一台狀態機\n\n一個會做多步驟任務的 agent，與其想成「一個很聰明的對話」，不如想成**一台狀態機**：它有當前狀態、有歷史軌跡、會根據結果轉移到下一個狀態。\n\n用這個視角，很多 production 問題突然就有了答案：\n\n- **可中斷 / 可恢復**：狀態存在外面（不是只活在記憶體），就能存檔、重啟、接續。\n- **可觀測**：每次狀態轉移都記下來，就是第 9 篇要的 trace。\n- **可重播**：出包時能照著軌跡重現，才 debug 得動。\n- **逾時 / 卡住**：狀態機能設「停在某狀態太久就升級處理或求助人類」。\n\n這完全是傳統後端的硬功夫——狀態、佇列、持久化。再一次驗證第 2 篇那句：agent 系統骨子裡是分散式系統。把「對話脈絡」當成需要持久化、有生命週期的**狀態**來經營，而不是一串飄在記憶體裡的訊息，是 production 和 demo 的分水嶺。\n\n說白了：**能跑多步驟 ≠ 能可靠地跑完多步驟。** 差別不在模型多聰明，而在你有沒有把狀態當成一等公民來持久化——demo 只要在你準備好的那條 happy path 上跑完就行；production 要在第三千個任務做到第七步、機器剛好重啟的那一刻，還能接著跑。\n\n## 最容易出包的點：記憶也有權限\n\n最後這段，請務必放在心上，因為它跟第 5 篇是同一種錯誤的兩個面向。\n\n長期記憶常常存在共用的向量庫裡。如果你的隔離沒做好，就可能發生：\n\n> A 使用者跟 agent 說過的事，在 B 使用者的對話裡被檢索出來、被透露。\n\n這是**比第 5 篇的文件越權更隱蔽的洩漏**，因為它洩的是「另一個使用者私下講的話」，而且很難在測試時發現——你要剛好用兩個使用者、剛好話題相近才會撞到。\n\n防線跟第 5 篇一模一樣：\n\n- 每筆記憶都綁**使用者 / 租戶身分**。\n- 檢索長期記憶時，**pre-filter 只在這個人自己的記憶裡找**。\n- 多租戶要**硬隔離**。\n\n說穿了，**memory 就是一種對「使用者私有資料」做的 RAG**，所以第 5 篇那整套權限感知檢索的紀律，原封不動適用。把這件事想通，你就不會在記憶系統上重犯一次資安錯誤。\n\n## 小結\n\n- **短期記憶**管這輪對話，重點是 context 怎麼截斷 / 摘要，別讓它又長又貴又失焦。\n- **長期記憶**跨對話記住這個人，技術上常常就是「對使用者私有資料的 RAG」，要有策略地萃取。\n- **Episodic 記憶**記任務軌跡，本質是狀態管理，讓任務可中斷、可恢復、可重播。\n- **記憶有權限**：它就是私有資料的 RAG，第 5 篇的權限紀律一條都不能少。\n\n把 agent 當狀態機經營，你就同時拿到了可恢復、可觀測、可重播——這些正是下一篇的主題。第 8 篇我們談**多代理協作**：什麼時候真的需要多個 agent，什麼時候那只是讓系統更貴更難 debug 的花招。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"Agent memory 與狀態管理：short / long / episodic\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[Tool use 與 MCP](/blog/enterprise-ai-agent-tool-use-mcp)\n- [多代理記憶架構](/blog/multi-agent-memory-architecture)——當記憶要跨多個 agent 共享時的設計\n- 下一篇：《多代理協作：什麼時候真的需要 multi-agent》",
      "summary": "檢索是「公司的知識」，記憶是「這個使用者、這個任務的脈絡」，兩者不一樣。拆解短期、長期、episodic 三種記憶的用途與設計，context engineering 怎麼決定塞什麼進有限的視窗，以及一個最容易出包的點——A 使用者的記憶不能洩進 B 使用者的對話。",
      "image": "https://bobochen.dev/_astro/cover.BHI2MLuI.webp",
      "date_published": "2026-05-15T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "AI Agent",
        "記憶",
        "Context Engineering",
        "狀態管理",
        "LLM"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/multi-agent-orchestration-real-world/",
      "url": "https://bobochen.dev/blog/multi-agent-orchestration-real-world/",
      "title": "Multi-Agent 編排實戰：我怎麼讓 Claude Code、OpenClaw、n8n 三個 Agent 協作",
      "content_text": "一個 agent 很強，但真正的生產力飛躍來自多個 agent 協作。分享三 agent 系統如何分工、如何傳遞 context、如何避免衝突——以及 LangGraph、CrewAI 等框架我試過之後為什麼沒用。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第十篇。上一篇：[MCP 與 A2A 協議實戰](/blog/mcp-a2a-protocols-practitioner-guide)\n\n## 理論上我有一支 AI 軍隊，實際上它們像三個不太會溝通的實習生\n\n我的日常有三個 AI agent 在跑：Claude Code 負責寫 code、OpenClaw 負責每日 briefing 和調研、n8n 負責自動化 workflow。理論上，這是一支全天候的 AI 團隊。\n\n實際上？Claude Code 不知道 OpenClaw 今天早上幫我整理了什麼重點。OpenClaw 不知道昨天 coding session 做到哪了。n8n 觸發的 automation 偶爾會跟 Claude Code 正在做的事情衝突。\n\n三個很強的 agent，各自為政。\n\nMulti-agent orchestration 不是「有很多 agent」。是「讓很多 agent 像一個團隊一樣工作」。這兩件事之間的差距，比你想的大得多。\n\n## 我的三 Agent 架構\n\n### Agent 1：Claude Code — 核心開發者\n\n**職責**：寫 code、修 bug、review PR、重構、寫測試\n**運行環境**：本地 terminal + IDE integration\n**記憶方式**：CLAUDE.md + memory system + conversation history\n**工作時間**：我開電腦的時候（~8-10 小時/天）\n\nClaude Code 是主力。我 80% 的生產力來自它。它有完整的 codebase access、[MCP 工具](/blog/mcp-a2a-protocols-practitioner-guide)、和一年累積的 [CLAUDE.md 設定](/blog/claude-md-rules-files-masterclass)。\n\n### Agent 2：OpenClaw — 研究分析師\n\n**職責**：每日 briefing（技術新聞、產業動態）、深度研究（競品分析、技術選型）、內容策劃\n**運行環境**：雲端，每天 6:00 AM 自動執行\n**記憶方式**：Notion database + 結構化輸出\n**工作時間**：每天自動跑一次 + 我手動觸發研究任務\n\nOpenClaw 是我的「情報官」。它每天早上自動整理我追蹤的領域的最新動態，讓我不需要花時間刷 Twitter 和 Hacker News。\n\n### Agent 3：n8n — 自動化管家\n\n**職責**：workflow automation——GitHub webhook 處理、Notion 同步、定時任務、跨系統串接\n**運行環境**：self-hosted n8n instance\n**記憶方式**：workflow 狀態 + execution logs\n**工作時間**：24/7 always-on\n\nn8n 不做「思考」的工作。它做的是「當 X 發生時，執行 Y」的 deterministic automation。GitHub 有新 PR → 自動通知 Slack。Blog post 更新 → 自動觸發 build。\n\n### 為什麼是這三個\n\n這個組合不是隨便選的。核心設計原則是 **non-overlapping responsibilities**：\n\n```\n              ┌─────────────────┐\n              │   Human (我)     │\n              │ 決策、Review、方向 │\n              └────┬───┬───┬────┘\n                   │   │   │\n     ┌─────────────┤   │   ├─────────────┐\n     │             │   │   │             │\n┌────▼────┐  ┌─────▼───▼──┐  ┌──────────▼┐\n│ OpenClaw │  │ Claude Code │  │    n8n     │\n│ Research │  │   Coding    │  │ Automation │\n│ Morning  │  │ On-demand   │  │  24/7      │\n└──────────┘  └─────────────┘  └────────────┘\n```\n\n每個 agent 有明確的「地盤」。沒有兩個 agent 負責同一件事。當兩個 agent 的職責重疊時，問題就來了。\n\n### 職責重疊的慘案\n\n有一次我同時讓 Claude Code 寫一個新的 API endpoint，又讓 n8n 的自動化在同一個 repo 上跑 auto-format。結果 n8n 的 formatter 在 Claude Code 還在寫的時候就 commit 了一個格式修正，造成 Claude Code 的 working directory 突然有了 unexpected changes。\n\nAgent 非常不擅長處理「有人在我背後改了東西」的情況。它不知道那些 changes 是 n8n 做的 auto-format，以為是自己剛才寫的 code 的一部分，然後在上面繼續 build——結果是一團混亂。\n\n**教訓**：一個任務只能有一個 owner。如果 Claude Code 在寫 code，n8n 的 auto-format workflow 就暫停。\n\n## Context 如何在 Agent 之間傳遞\n\n這是 multi-agent 最難的部分。不是技術上難（你可以用 file、API、database），是**設計上**難——什麼資訊需要傳遞？傳遞多少？什麼時候傳遞？\n\n### Markdown as Shared Memory\n\n我目前的做法是用 Markdown 文件作為 agent 之間的共享記憶：\n\n```\n~/.claude/projects/{project}/memory/\n├── MEMORY.md          ← index：所有 memory 的目錄\n├── user_role.md       ← 關於我的資訊\n├── project_status.md  ← 專案進度\n├── decisions.md       ← 重要決策紀錄\n└── lessons.md         ← 學到的教訓\n```\n\n**為什麼用 Markdown 而不是 Database？**\n\n1. **Human-readable**：我可以直接打開看，不需要 query tool\n2. **Git-trackable**：可以 version control，看到記憶的變化歷史\n3. **Agent-native**：所有 LLM agent 都擅長讀寫 Markdown\n4. **Zero infrastructure**：不需要額外的 database 或 API server\n\n### Hot / Warm / Cold 三層記憶\n\n不是所有資訊都需要即時同步。我把 memory 分三層：\n\n| 層級     | 內容                               | 同步頻率   | 存放位置                 |\n| -------- | ---------------------------------- | ---------- | ------------------------ |\n| **Hot**  | 當前 task 的 context、進行中的決策 | 即時       | Conversation / Plan file |\n| **Warm** | 專案狀態、近期決策、常用 patterns  | 每 session | Memory files             |\n| **Cold** | 歷史決策、舊的 lessons learned     | 不主動同步 | Memory archive           |\n\nClaude Code 開始新 session 時，自動載入 Warm 層的記憶（透過 MEMORY.md index）。Hot 層在 session 內即時產生。Cold 層只在明確需要時才去讀。\n\n### Agent 之間的接力棒\n\n一天的流程看起來像這樣：\n\n```\n06:00  OpenClaw → 產出 daily briefing → 寫入 Notion\n09:00  我 → 讀 briefing → 決定今天做什麼\n09:05  Claude Code → 載入 memory + 今天的 task list → 開始工作\n       ...（Claude Code 工作整天）...\n18:00  Claude Code → session 結束 → 更新 memory/project_status.md\n18:00  n8n → 偵測到 session 結束 → 觸發 daily summary workflow\n21:00  我 → review daily summary → 調整明天的優先級\n```\n\n關鍵在 **handoff 的時刻**——每個 agent 結束工作時，要把「我做了什麼、目前狀態是什麼、下一步建議是什麼」寫到共享記憶裡，讓下一個 agent（或明天的自己）可以無縫接手。\n\n## 為什麼我沒用 LangGraph / CrewAI\n\n我試過的框架：\n\n### LangGraph\n\n**優點**：最靈活。你可以定義任意複雜的 agent 互動圖。支援條件分支、循環、人工介入點。\n**缺點**：學習曲線陡。為了設定一個兩 agent 的 pipeline，我寫了 200 行 boilerplate code。而且 debug 困難——graph 一複雜，你很難追蹤「資料是從哪條路徑流過來的」。\n\n**結論**：如果你需要 10+ agents 的複雜編排，LangGraph 是對的選擇。如果你只有 2-3 個 agent，overhead 大於效益。\n\n### CrewAI\n\n**優點**：角色定義直觀（\"you are a researcher\", \"you are a coder\"）。設定快。\n**缺點**：太 opinionated。它預設了一套 agent 互動模式，如果你的 workflow 不符合那個模式，客製化很痛苦。而且它跟特定的 LLM provider 綁得比較深。\n\n**結論**：快速 prototype 很好用，但 production 使用的靈活度不夠。\n\n### Google ADK（Agent Development Kit）\n\n**優點**：跟 Google 生態系（Gemini、Vertex AI）整合好。\n**缺點**：Gemini 優化，用在其他 model 上效果打折。生態系還在早期。\n\n### Microsoft Agent Framework\n\n**優點**：企業級。跟 Azure、Microsoft 365 整合。\n**缺點**：Azure-heavy。如果你不在 Azure 生態系裡，很多功能用不上。\n\n### 我的結論：手動編排就好\n\n對於大部分個人開發者和小團隊來說，**手動編排比框架更適合**：\n\n|              | 手動編排   | 框架                    |\n| ------------ | ---------- | ----------------------- |\n| **透明度**   | 完全看得見 | 黑盒子（尤其 debug 時） |\n| **靈活度**   | 想改就改   | 被框架 API 限制         |\n| **學習成本** | 接近零     | 需要學框架              |\n| **維護成本** | 低         | 跟框架版本走            |\n| **適合場景** | 2-5 agents | 5+ agents               |\n\n手動編排的意思是：我自己決定哪個 agent 做什麼、怎麼傳遞 context、怎麼處理衝突。不用框架，用 files + webhooks + 少量 script。\n\n**什麼時候該用框架？**\n\n- 你有 5 個以上的 agent 需要複雜互動\n- 你需要 deterministic orchestration（金融、醫療等合規要求）\n- 你的團隊有多人需要共同維護 agent pipeline\n- 你需要 observability 和 audit trail\n\n如果以上都不符合，從手動開始。需要框架的時候你會知道的。\n\n## Error Handling：當 Agent 們意見不一致\n\n### 衝突類型 1：搶資源\n\n兩個 agent 同時修改同一個檔案 → git conflict。\n\n**解法**：嚴格的 ownership 規則。一個時間點，一個檔案只能被一個 agent 操作。如果 n8n 需要改 config file，Claude Code 那段時間就不能碰那個 file。\n\n### 衝突類型 2：不同建議\n\nOpenClaw 的 research 說「應該用 Library A」，Claude Code 在實作時發現 Library A 有 bug，改用了 Library B。隔天 OpenClaw 又建議 Library A。\n\n**解法**：決策一旦做出就記錄到 shared memory（`decisions.md`），所有 agent 都要先讀決策紀錄再給建議。\n\n### 衝突類型 3：Automation 時機不對\n\nn8n 在 Claude Code 正在做 interactive rebase 的時候觸發了 auto-lint → 搞亂了 git 狀態。\n\n**解法**：n8n 的 automation 都加了「check if coding session active」的前置條件。如果有 coding session 在跑，non-critical automation 排隊等候。\n\n### 核心原則：Human as Final Arbiter\n\n當 agent 之間有衝突，最終裁決者永遠是人。不要設計一個 agent 來「管理」其他 agent 的衝突——那個 meta-agent 本身也可能犯錯，而且增加了系統複雜度。\n\n在現階段，最可靠的 conflict resolution 是：agent 發現衝突 → 暫停 → 通知人類 → 人類決策 → agent 繼續。\n\n## 給想開始的人的建議\n\n### Phase 1：先把一個 Agent 用到極致\n\n不要一開始就想搞 multi-agent。先把 Claude Code（或你的主力 coding agent）的潛力完全發揮——[好的 CLAUDE.md](/blog/claude-md-rules-files-masterclass)、[完善的 spec workflow](/blog/spec-driven-development-for-agents)、[可靠的 QA 流程](/blog/agent-output-verification-review)。\n\n### Phase 2：加第二個 Agent 做非 coding 的事\n\n當你覺得「coding agent 很好了，但我花太多時間在 research / admin / automation 上」的時候，加第二個 agent。確保它的職責跟第一個完全不重疊。\n\n### Phase 3：用 Automation 串接\n\nn8n（或 Zapier / Make）作為黏合劑，把兩個 agent 的 output 串接起來。不需要它們直接對話，只需要它們共享結果。\n\n### Phase 4：需要的時候再加框架\n\n99% 的人在 Phase 2-3 就夠了。如果你發現自己需要更複雜的編排——恭喜，你可能是那 1%，可以開始看 LangGraph。\n\n## Takeaway\n\n1. **Multi-agent 的價值在「分工」而非「數量」**——三個各有專精、職責不重疊的 agent，比十個通才 agent 強。設計 agent team 的第一步不是選工具，是定義「誰負責什麼，以及誰不負責什麼」。\n\n2. **Context sharing 是最難的部分**——Markdown as shared memory 是目前最實用的方案。簡單、human-readable、git-trackable、零基礎設施成本。Hot / Warm / Cold 三層分離，避免每次都載入全部。\n\n3. **從手動編排開始，需要框架時再加**——LangGraph / CrewAI 這些框架適合 5+ agents 的複雜場景。大部分人只需要 2-3 個 agent + files + webhooks 就夠了。先把一個 agent 用到極致，再想 multi-agent。\n\n---\n\n_上一篇：[MCP 與 A2A 協議實戰](/blog/mcp-a2a-protocols-practitioner-guide)_\n_下一篇：[Token 經濟學進階](/blog/agentic-engineering-cost-optimization)_",
      "summary": "一個 agent 很強，但真正的生產力飛躍來自多個 agent 協作。分享三 agent 系統如何分工、如何傳遞 context、如何避免衝突——以及 LangGraph、CrewAI 等框架我試過之後為什麼沒用。",
      "image": "https://bobochen.dev/_astro/cover.OrsaS-l7.webp",
      "date_published": "2026-05-15T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "Multi-Agent",
        "Claude Code",
        "n8n",
        "Orchestration"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-tool-use-mcp/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-tool-use-mcp/",
      "title": "Tool use 與 MCP：當 agent 能動手操作系統，邊界該怎麼劃",
      "content_text": "Tool-using agent 真正可怕的地方，不是它會講錯話，是它能操作外部系統——改資料、送訂單、動設定。從 function calling 的本質、MCP 作為標準介面，到 action boundary、approval flow、idempotency 與 rollback，談怎麼讓 agent 戴著手套動手，而不是裸手亂抓。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 6 篇（共 12 篇）。上一篇：[權限感知檢索](/blog/enterprise-ai-agent-permission-aware-retrieval)。\n\n前面五篇講的是 agent 怎麼「知道」事情——RAG、向量、權限。這一篇開始講 agent 怎麼「做」事情。而這是整個系統裡風險陡升的地方。\n\n我自己寫過幾個 MCP server，把工具接給模型用。寫完之後最深的體會是一句話：\n\n> Tool-using agent 真正可怕的地方，不是它會講錯話——講錯話頂多丟臉。是**它能操作外部系統**。它用錯工具，是真的會改到資料、動到錢、送出收不回的東西。\n\n一個只會聊天的 agent 出包，是回答錯誤。一個會動手的 agent 出包，是**生產事故**。這一篇就是談怎麼讓它戴著手套動手。\n\n## 先搞懂 function calling 的本質\n\n很多人以為 agent 「呼叫了 API」是模型自己連出去打的。不是。實際流程是這樣：\n\n```\n1. 你給模型一份「可用工具清單」（每個工具的名字、用途、參數格式）\n2. 模型看了問題，決定「我想呼叫 query_order，參數 order_id=12345」\n3. 模型把這個「意圖」用結構化格式吐出來——它只是「說它想呼叫」\n4. 你的程式碼（agent runtime）真正去執行這個呼叫\n5. 把結果餵回給模型，它再決定下一步\n```\n\n關鍵在第 3、4 步之間：**模型只是「表達意圖」，真正執行的是你的 code**。這個分界超重要，因為它告訴你——**控制權在你手上**。模型想呼叫什麼是它的事，要不要真的執行、執行前檢查什麼、要不要先問人，全是你 runtime 這層的責任。把這層責任交回去自己手上，是 production tool use 的起點。\n\n## MCP：把「agent 能做什麼」變成一份可盤點的清單\n\nMCP（Model Context Protocol）是一個讓工具和模型之間用標準介面溝通的協定。你把工具包成一個 MCP server，任何支援 MCP 的 client——Claude、ChatGPT、Gemini、Cursor、VS Code、Microsoft Copilot——都能用同一套方式接上去。\n\n這裡有個關鍵轉折值得講清楚：MCP 雖然是 Anthropic 在 2024 年底提出的，但它沒有停在「Claude 自家規格」。2025 年 3 月 OpenAI 官方採納、4 月 Google DeepMind 跟上，到 2026 它已經交給中立基金會治理、變成跨廠商的共通標準。這件事對企業很重要——你押注一個治理地基，最怕綁死在單一 vendor 上；MCP 變成中立基礎設施，正是它值得押的原因。\n\n我寫 MCP server 的經驗裡，它的價值常被低估成「方便接」。但對企業來說，MCP 真正的意義是治理面的：\n\n> MCP 把「這個 agent 能做哪些事」從散落在 code 各處的一堆 function，變成**一份可以盤點、可以審核、可以集中管理的工具清單**。\n\n這在企業裡是地基。當資安問你「你們的 AI agent 到底能操作哪些系統？」，你能掏出一份清單，而不是回去 grep 全部 codebase。第 11 篇講治理時，這份「tool registry」就是核心。\n\n（想動手體會，可以看我寫過的 [15 分鐘做一個 MCP server](/blog/build-your-own-mcp-server-15-minutes) 和 [什麼是 MCP](/blog/what-is-mcp-claude-plugin-system)。）\n\n## 三條讓 agent 安全動手的紀律\n\n把工具接上只是開始。讓它能上 production，靠的是下面三條。\n\n### 一、Action Boundary：先分清楚「讀」和「寫」\n\n每個工具上線前，先問一個問題：**它會不會改變世界？**\n\n- **唯讀工具**（查訂單、搜尋文件、看狀態）：風險低。錯了就是給錯資訊，不會留下副作用。\n- **會寫的工具**（建訂單、改設定、刪資料、寄信、轉帳）：風險高。錯了會留下**收不回的副作用**。\n\n這條線要在設計時就劃清楚，並且**對兩邊用不同的嚴格程度**。唯讀工具可以放手讓 agent 自由用；會寫的工具，每一個都要套上後面兩條紀律。一個常見且務實的起手式是：**讓 agent 預設只有唯讀工具，寫入工具要明確、逐個開通**，而不是一股腦全給。\n\n### 二、Approval Flow：高風險動作，讓人類按一下\n\n對會造成重大副作用的動作，插入一個 **human-in-the-loop（HITL）確認關卡**：agent 準備好要做什麼、把它攤開給人看，人按確認才真的執行。\n\n- 「我要把這 200 筆訂單標記為取消，確認嗎？」→ 等人點頭。\n- 「我要寄這封信給全體客戶，內容如下」→ 等人點頭。\n\n這不是不信任 agent，是**承認它會錯，而且某些錯誤的代價大到不該由機率決定**。設計上的重點是：approval 關卡要卡在「執行前」，而且要把 agent **打算傳的真實參數**完整呈現給審核者，不能只給一句模糊的「它要寄信」。\n\n哪些動作要 approval、哪些可以放行，本身是個風險分級——這會在第 11 篇的治理框架裡系統化。\n\n### 三、Idempotency 與 Rollback：為「它一定會重試」做準備\n\nAgent 會重試。網路斷了、逾時了、它覺得上次沒成功——它會再呼叫一次。如果你的寫入工具不是 idempotent，這就是**重複下單、重複扣款、重複寄信**的來源。\n\n- **Idempotency**：同一個操作做兩次，結果跟做一次一樣。常見做法是帶一個 idempotency key，後端認得「這個操作我處理過了」就不重複執行。\n- **Rollback / 補償**：萬一一連串動作做到一半失敗（建了訂單但扣款失敗），要有辦法回復或補償，不能留下半套狀態。\n\n這些其實都是**分散式系統的老問題**。呼應第 2 篇那句話：LLM app 還是個 distributed system，只是多了一個會自己決定重試的元件，反而讓 idempotency 比以前更不能省。\n\n還要注意一個 2026 的現實：主流 API（OpenAI tool calls、Anthropic tool use、Gemini function calling）模型一輪可以同時吐出**多個** tool call，runtime 要能並行執行再一起餵回。這讓 idempotency 從「nice to have」變成「不做會出事」——重試已經會製造重複副作用，再疊上平行呼叫，重複的機率是乘起來的，不是加起來的。你以為的「一筆扣款」，可能是兩個平行呼叫各自重試後變成的四筆。\n\n## 別把工具設計成「薄薄一層包 API」\n\n最後一個實務建議。很多人把工具做成「一個工具對一個 API endpoint」，把模型當成在填 API 參數。這常常不好用，因為：\n\n- 模型可能填出**危險的參數組合**（一次撈全表、刪太多）。\n- 工具的「用途描述」寫不清楚，模型就會亂用。\n\n好的工具設計，是站在「agent 想完成什麼任務」的角度，提供**剛好夠用、邊界內建**的能力。例如不要給一個「執行任意 SQL」的工具（這等於把資料庫鑰匙交出去），而是給「查詢某使用者的訂單（已內建只能查他自己的）」這種**意圖明確、權限和範圍已經框死**的工具。把危險收在工具內部，而不是指望模型自律。\n\n為什麼這條這麼重要，2026 的資安現場給了更硬的理由：廣義工具（任意 SQL、任意 shell）是 prompt injection 最愛的放大器。攻擊者不需要攻破你的系統，只要想辦法讓模型「想呼叫」一次帶惡意參數的工具，就直接命中真實資料庫。把工具收窄成意圖明確、權限框死的形狀，等於把 injection 的攻擊面從「整個資料庫」縮到「這個使用者的訂單」——攻擊面小一個數量級。這也是 OWASP 把 **Excessive Agency**（給 agent 過多權限與自主性）單獨列為 LLM 應用十大風險之一的核心：危險不在模型會不會學壞，在你給了它多大的權限去做壞事。\n\n## 小結\n\n讓 agent 動手，三條紀律收尾：\n\n1. **Action boundary**：先分讀 / 寫，寫入工具預設不給、逐個開通。\n2. **Approval flow**：高風險動作插 HITL，把真實參數攤給人看再執行。\n3. **Idempotency / rollback**：為「它一定會重試」做準備，這是分散式系統的老功課。\n\n再加一條心法：**把危險收在工具設計裡，別指望模型自律**。MCP 則幫你把這一切變成一份可盤點、可治理的清單。\n\n下一篇換個主題：agent 怎麼「記得」事情——**memory 與狀態管理**。檢索是公司的知識，記憶是這個任務、這個使用者的脈絡，兩者不一樣，而且記憶一樣有權限問題。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"Tool use 與 MCP：當 agent 能動手操作系統\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[權限感知檢索](/blog/enterprise-ai-agent-permission-aware-retrieval)\n- [15 分鐘打造你自己的 MCP server](/blog/build-your-own-mcp-server-15-minutes)\n- [什麼是 MCP？Claude 的外掛系統](/blog/what-is-mcp-claude-plugin-system)\n- [MCP 與 A2A 協定：實務者指南](/blog/mcp-a2a-protocols-practitioner-guide)\n- 下一篇：《Agent memory 與狀態管理：short / long / episodic》",
      "summary": "Tool-using agent 真正可怕的地方，不是它會講錯話，是它能操作外部系統——改資料、送訂單、動設定。從 function calling 的本質、MCP 作為標準介面，到 action boundary、approval flow、idempotency 與 rollback，談怎麼讓 agent 戴著手套動手，而不是裸手亂抓。",
      "image": "https://bobochen.dev/_astro/cover.9XgvAb-Q.webp",
      "date_published": "2026-05-11T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "MCP",
        "Tool Use",
        "AI Agent",
        "API",
        "Function Calling"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-checklist/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-checklist/",
      "title": "Solo Builder Checklist：你的產品及格了嗎",
      "content_text": "Solo Builder Checklist 全書總整理：一份可操作的產品健康檢查清單，從點子驗證、建造上線到成長與自主運行，逐項檢查你的產品在每個階段是否達標。附 AI 輔助自我評估 prompt 與精選延伸學習資源，幫你一個人也能把產品做到及格。",
      "content_html": "## 最後一件事：把整本書變成一份 Solo Builder Checklist\n\n恭喜你走到這裡。\n\n14 章，從 Solo Builder 宣言到真實案例拆解，我們走完了一個產品從零到一的完整旅程。\n\n但資訊量很大，容易看完就忘。所以這最後一章，我要把整本書壓縮成一份你可以反覆使用的 checklist。\n\n這份清單不是「讀完打勾就好」的裝飾品，它是你的產品健康檢查表，每隔一段時間拿出來對照一下，看看哪些環節做到了、哪些還沒有、哪些需要補強。\n\n不需要全部打勾才能上線。但如果太多項目是空白的，你可能要回頭看看對應的章節。\n\n---\n\n## 階段一：驗證（對應第 2-4 章）\n\n這個階段的目標是：**確認你不是在浪費時間。**\n\n### 問題驗證\n\n- [ ] 你能用一句話說清楚你的產品解決什麼問題\n- [ ] 這個問題有證據支持（社群討論、搜尋量、身邊的人也遇到）\n- [ ] 你做過 AI 輔助的市場掃描，確認問題的普遍性\n- [ ] 你模擬過至少 3 種不同類型的目標用戶訪談\n- [ ] 你設定了明確的 kill criteria（什麼情況下放棄）\n\n### 競品分析\n\n- [ ] 你知道至少 3 個直接競品和 2 個間接競品\n- [ ] 你做了競品矩陣（功能、定價、優缺點比較表）\n- [ ] 你找到了至少一個明確的差異化角度\n- [ ] 你確認差異化角度有市場需求（不是你自以為的優勢）\n\n### MVP 範圍定義\n\n- [ ] 你的 MVP 功能清單不超過 5 個核心功能\n- [ ] 你有一份「砍掉的功能清單」，而且比保留的功能更長\n- [ ] 每個保留的功能都直接服務於核心價值主張\n- [ ] 你預估了 MVP 的開發時間，而且不超過 4 週\n\n### Landing Page\n\n- [ ] Landing Page 已上線（即使產品還沒做好）\n- [ ] 頁面上有清楚的價值主張、核心功能說明、定價方案\n- [ ] 有等候名單或預先註冊表單\n- [ ] 已經分享到至少 3 個相關社群\n- [ ] 你有在追蹤轉換率（訪客 → 填表的比例）\n\n### 階段一的及格線\n\n> **至少完成 12 項以上，你才應該開始寫程式碼。** 如果不到 12 項，回去看第 2-4 章。你花在驗證上的每一小時，都在為你省下未來數十小時的開發時間。\n\n先說清楚：「12 項」是經驗法則，不是鐵律，也不是普世真理。如果你做的是免費工具、開源專案、純內容部落格或作品集型 side project，那「定價方案」「等候名單」這種項目本來就不適用——硬湊到 12 才動手，反而是在扭曲你的判斷。真正要問的是「對我這種產品該驗證的，有沒有驗證到」，不是「項數夠不夠」。\n\n還有一個反方我得補：驗證和建造不是二選一。當 AI 讓一個能用的原型幾個小時就生得出來時，「先丟一個粗糙版本給真實用戶用」本身就是最強的驗證——直接動手做也是一種合法的驗證手段。我看過太多人卡在「再驗證一下、再問一輪」結果三個月沒寫半行 code，這是另一個極端。先驗證 vs. 先建造，取決於你的產品做出來有多貴：做出來要兩週就先驗證，做出來只要一個下午就直接做。\n\n---\n\n## 階段二：建造（對應第 5-8 章）\n\n這個階段的目標是：**用最少時間把產品做出來並開始收錢。**\n\n### AI 開發工作流\n\n- [ ] 你的專案有 CLAUDE.md（或等效的 AI context 文件）\n- [ ] AI 知道你的專案架構、coding style、測試慣例\n- [ ] 你有建立至少 2 個 custom skill 來加速重複性工作\n- [ ] 你知道什麼時候該用 AI、什麼時候該自己寫（第 5 章的判斷框架）\n- [ ] 你的 prompt 有版本控制（好的 prompt 跟好的程式碼一樣值得保存）\n\n### 部署上線\n\n- [ ] 產品已部署到 production 環境\n- [ ] CI/CD pipeline 設定完成（推到 main 自動部署）\n- [ ] 部署流程從 commit 到上線不超過 10 分鐘\n- [ ] 你知道怎麼 rollback 到上一個版本\n- [ ] 域名設定完成（自訂域名，不是平台預設的子域名）\n\n### Landing Page 與 SEO\n\n- [ ] 頁面有正確的 meta title 和 meta description\n- [ ] 有 Open Graph 圖片（社群分享時有預覽圖）\n- [ ] Google Search Console 已設定\n- [ ] Lighthouse Performance 分數 > 90\n- [ ] 行動裝置體驗正常\n\n### 付費機制\n\n- [ ] 金流串接完成（Stripe、Gumroad、或其他）\n- [ ] 測試過完整的付款流程（從點擊購買到收到確認信）\n- [ ] 有免費方案或試用期讓用戶先體驗\n- [ ] 退款政策已寫好並公開在網站上\n- [ ] 你收到過至少一筆真實的付款\n\n### 錯誤追蹤\n\n- [ ] 有設定錯誤追蹤服務（Sentry、LogRocket 等）\n- [ ] 核心功能有錯誤時你會收到通知\n- [ ] 你知道過去一週產品有沒有發生錯誤\n\n### 階段二的及格線\n\n> **至少完成 15 項以上，你的產品才算「上線」。** 特別注意：金流串接和錯誤追蹤不能跳過。一個收不了錢的產品不是產品，一個出錯你不知道的產品是定時炸彈。\n\n同樣提醒一次：這條線是寫給「要收錢」的產品看的。如果你做的是免費工具或開源專案，金流、退款政策那幾項根本不存在，扣掉之後你的「15」自然就變小了——別因此覺得自己沒及格。把標準換成「對你這個產品該有的，有沒有都做到」就好，錯誤追蹤這種跟收不收錢無關的，才是真的不分產品類型都得有。\n\n---\n\n## 階段三：成長（對應第 9-12 章）\n\n這個階段的目標是：**讓產品能夠在你不盯著的時候也正常運作。**\n\n### 用戶回饋\n\n- [ ] 產品內有回饋收集機制（表單、email、對話框）\n- [ ] 你有定期（至少每月一次）整理和分析用戶回饋\n- [ ] 你有一份「用戶要求但你還沒做的功能」清單，並且有優先排序\n- [ ] 你做過至少一次根據用戶回饋調整產品方向的決策\n\n### 客服與文件\n\n- [ ] 有 FAQ 或幫助中心頁面\n- [ ] 常見問題有標準化回覆範本（可以用 AI 輔助生成）\n- [ ] 客服回應時間有明確承諾（例如 48 小時內回覆）\n- [ ] 你每週花在客服上的時間不超過 2 小時\n\n### 監控與維運\n\n- [ ] 有 uptime 監控（產品掛了你會知道）\n- [ ] 有設定告警（關鍵指標異常時通知你）\n- [ ] 你知道產品目前的核心指標（DAU、MRR、錯誤率）\n- [ ] 有自動化備份策略\n- [ ] 備份做過至少一次還原測試\n\n### 定價與商業模式\n\n- [ ] 定價經過至少一次調整（根據市場反饋）\n- [ ] 你知道你的 unit economics（每個用戶的獲取成本和終身價值）\n- [ ] 營收趨勢是穩定或成長的\n- [ ] 你有考慮過不同的定價模式（月費、年費、一次買斷、分級定價）\n\n### 階段三的及格線\n\n> **至少完成 10 項以上，你的產品才能稱為「有在成長」。** 這個階段最容易被忽略，因為你會覺得「產品已經上線了，沒壞就不用管」。但沒有回饋循環和監控的產品，只是一個靜靜等死的網頁。\n\n一樣，「10 項」是抓個感覺用的數字。這階段的「定價與商業模式」「unit economics」那一整區，對不收錢的產品來說整塊都跳過——免費工具的「成長」是看活躍用戶和留存，不是看 MRR 趨勢。回饋循環、監控、備份還原這幾項才是不管你收不收錢都該做的；數字湊不齊不代表你沒在成長，要看湊不齊的是哪幾項。\n\n---\n\n## 階段四：自主運行（進階）\n\n這個階段不是每個 Solo Builder 都需要達到的。但如果你的產品已經開始有穩定收入，這些是下一步需要考慮的。\n\n### 財務健康\n\n- [ ] 營收已經覆蓋營運成本（伺服器、域名、第三方服務）\n- [ ] 你知道你的月度淨利潤是多少\n- [ ] 有三個月的營運預備金（即使收入中斷也能維持）\n- [ ] 已經處理好稅務和法律結構（個人所得 / 行號 / 公司）\n\n### 自動化程度\n\n- [ ] 產品可以在你完全不碰的情況下正常運作至少兩週\n- [ ] 客服有自動化回覆處理最常見的問題\n- [ ] 帳單和收款完全自動化\n- [ ] 部署和更新可以用一個命令完成\n\n### 成長引擎\n\n- [ ] 你有明確的主要成長渠道（SEO、社群、口碑、付費廣告）\n- [ ] 成長渠道有持續帶來新用戶\n- [ ] 用戶留存率穩定（不是只有新用戶、舊用戶一直流失）\n- [ ] 你有一套可重複的內容產出節奏\n\n### 階段四的及格線\n\n> **如果你達到了這個階段的大部分項目，你已經不只是 Solo Builder——你在經營一個 micro business。** 是時候考慮：這個產品值不值得投入更多時間？需不需要開始外包部分工作？要不要考慮全職投入？\n\n---\n\n## AI 輔助自我評估\n\n如果你想要更深入的診斷，可以把你的產品現況丟給 AI 做一次全面評估。以下是一個結構化的 prompt：\n\n```text\n我是一個有正職的 Solo Builder，以下是我目前的產品現況：\n\n產品名稱：[名稱]\n產品類型：[SaaS / 內容平台 / 工具 / 其他]\n上線時間：[何時上線]\n目前用戶數：[大約多少]\n月營收：[金額]\n每週投入時間：[小時數]\n\n以下是我已完成和未完成的項目：\n\n已完成：\n- [列出你已經做到的事]\n\n未完成：\n- [列出你知道該做但還沒做的事]\n\n請以 Solo Builder 的角度幫我分析：\n1. 我目前在哪個階段？（驗證 / 建造 / 成長 / 自主運行）\n2. 最急迫需要處理的 3 件事是什麼？\n3. 哪些未完成項目可以先跳過？\n4. 以我的時間限制，建議的優先順序？\n5. 有沒有我遺漏的關鍵項目？\n```\n\nAI 的回答不是聖旨——但它能幫你從「我覺得好像還可以」變成「我清楚知道下一步要做什麼」。\n\n---\n\n## Solo Builder 宣言——再看一次\n\n第 1 章開頭，我分享了 Solo Builder 宣言。走完 14 章之後，我們再看一次這六條信念：\n\n1. **先 ship，再完美。**\n   → 你在第 13 章看到了——我的每個產品都是不完美就上線的。\n\n2. **時間是最稀缺的資源。**\n   → 每一章的「傳統做法 vs. AI 加持做法」都在告訴你同一件事：用 AI 買回時間。\n\n3. **AI 是隊友，不是魔法。**\n   → 第 5 章深入探討了這一點。AI 處理苦工，你負責判斷。\n\n4. **正職不是阻礙。**\n   → 第 1 章的「隱藏優勢」，到第 13 章的真實案例，都證明了邊上班邊做產品不但可行，而且有獨特的優勢。\n\n5. **一個人不代表什麼都自己來。**\n   → AI、開源工具、現成服務——你的「團隊」比你想的大得多。\n\n6. **做自己會用的東西。**\n   → course-forge 就是最好的例子：從自己的痛點出發，做自己每天都在用的工具。\n\n這六條不是「讀完感動一下」的雞湯。它們是決策框架。每次你猶豫要不要加一個功能、要不要換技術棧、要不要等到完美再上線的時候，回來看這六條。\n\n---\n\n## 這 14 章你學到了什麼\n\n讓我用一張表格幫你回顧整本書：\n\n| 章  | 標題                      | 你學到的核心概念                   |\n| --- | ------------------------- | ---------------------------------- |\n| 1   | Solo Builder 宣言         | AI 改變的是時間成本，不是能力門檻  |\n| 2   | 點子驗證                  | 一天內用 AI 完成市場調查和競品分析 |\n| 3   | 技術選型                  | 一種語言打天下、選生態系不選框架   |\n| 4   | MVP 設計                  | 砍到不能再砍，只保留核心假設       |\n| 5   | AI 驅動開發               | 從 Vibe Coding 到 Agentic Workflow |\n| 6   | 部署上線                  | 零設定 CI/CD，選對平台省 80% 維運  |\n| 7   | Landing Page 與 SEO       | 讓目標用戶找到你                   |\n| 8   | 付費機制                  | 一個人怎麼串接金流開始收錢         |\n| 9   | 用戶回饋                  | 系統化收集和分析回饋               |\n| 10  | 客服與社群                | 用 AI 把客服時間壓到最低           |\n| 11  | 監控與維運                | 讓產品在你睡覺時也穩定運行         |\n| 12  | Side Project → Micro SaaS | 什麼時候該認真、怎麼定價           |\n| 13  | 實戰案例                  | 四個真實產品的完整拆解             |\n| 14  | Checklist                 | 你的產品在每個階段是否達標         |\n\n每一章都不是獨立的。它們串成一條路徑：**驗證 → 建造 → 成長 → 自主運行。**\n\n你不需要一次走完。根據你現在的階段，跳到對應的章節就好。\n\n---\n\n## 延伸資源\n\n如果你想更深入特定主題，以下是我推薦的資源：\n\n### 產品與創業思維\n\n- **The Mom Test**（Rob Fitzpatrick）— 怎麼做用戶訪談才不會被禮貌性的「好棒喔」騙到\n- **Lean Startup**（Eric Ries）— MVP 和快速迭代的經典\n- **Zero to Sold**（Arvid Kahl）— 從零到出售 SaaS 的完整經驗，Solo Builder 必讀\n\n### AI 輔助開發\n\n- **Claude Code 官方文件** — 最新的 skill、MCP、multi-agent 功能\n- **Anthropic Cookbook** — AI 應用的實戰 recipe\n\n### 技術棧\n\n- **Astro 官方文件** — 你的靜態網站框架\n- **Cloudflare Developer Docs** — Workers（含 Static Assets）、D1、R2 全家桶。Cloudflare 已在 2025 年把 Pages 的功能整併進 Workers，新功能只進 Workers，新專案建議直接用 Workers with Static Assets\n- **Hono 官方文件** — 輕量 TypeScript 後端框架\n\n### Solo Builder 社群\n\n- **Indie Hackers** — 最大的獨立開發者社群\n- **r/SaaS、r/SideProject** — Reddit 上的 Solo Builder 討論區\n- **台灣獨立開發者社群** — 在 Facebook、Discord 都有，搜「indie hacker 台灣」\n\n---\n\n## 最後想對你說的話\n\n你讀完了整本書。但讀完不等於做完。\n\n我認識太多人，讀了一百本創業書、看了一千支 YouTube 影片、收藏了一萬篇文章——然後一個產品都沒做出來。\n\n不是因為他們不夠聰明或不夠努力。是因為**準備永遠不會「夠」**。你永遠可以再多學一點、多準備一點、多想一點。\n\n但產品不是想出來的。產品是做出來的。\n\n你不需要完美的點子。你不需要完美的技術棧。你不需要完美的 Landing Page。你不需要完美的定價策略。\n\n你需要的只是一個「還可以」的點子，然後開始動手。\n\n那些你崇拜的獨立開發者——他們的第一個產品也是粗糙的、不完美的、甚至有點丟臉的。但他們做了一件你還沒做的事：**他們 ship 了。**\n\n所以，合上這本書。打開你的編輯器。\n\n你的備忘錄裡那個一直躺著的點子，今天就開始驗證它。\n\n用[第 2 章](/blog/ai-solo-builder-idea-validation)的方法，花一個下午確認它值不值得做。如果值得，用[第 3 章](/blog/ai-solo-builder-tech-stack)的框架選技術棧。用[第 5 章](/blog/ai-solo-builder-ai-driven-dev)的方法讓 AI 幫你寫程式碼。用[第 6 章](/blog/ai-solo-builder-deployment)的流程一週內部署上線。\n\n然後，你就不再是一個「有很多 side project 點子的人」。\n\n你是一個 **Solo Builder**。\n\n---\n\n## 全書系列導覽\n\n### 第一階段：驗證\n\n- [第 1 章：Solo Builder 宣言——一個人 + AI 就是一支團隊](/blog/ai-solo-builder-manifesto)\n- [第 2 章：點子驗證——花一天而不是一個月](/blog/ai-solo-builder-idea-validation)\n- [第 3 章：技術選型決策框架](/blog/ai-solo-builder-tech-stack)\n- [第 4 章：MVP 設計——砍到不能再砍](/blog/ai-solo-builder-mvp-design)\n\n### 第二階段：建造\n\n- [第 5 章：AI 驅動開發——從 Vibe Coding 到 Agentic Workflow](/blog/ai-solo-builder-ai-driven-dev)\n- [第 6 章：部署上線——選對平台省 80% 維運](/blog/ai-solo-builder-deployment)\n- [第 7 章：Landing Page 與 SEO](/blog/ai-solo-builder-landing-page-seo)\n- [第 8 章：付費機制——一個人怎麼串接金流](/blog/ai-solo-builder-payment)\n\n### 第三階段：成長\n\n- [第 9 章：用戶回饋循環](/blog/ai-solo-builder-user-feedback)\n- [第 10 章：客服與社群經營](/blog/ai-solo-builder-support-community)\n- [第 11 章：監控與維運](/blog/ai-solo-builder-monitoring-ops)\n- [第 12 章：從 Side Project 到 Micro SaaS](/blog/ai-solo-builder-side-project-to-saas)\n\n### 第四階段：實戰\n\n- [第 13 章：實戰案例——我的四個產品](/blog/ai-solo-builder-case-studies)\n- [第 14 章：Solo Builder Checklist——你的產品及格了嗎](/blog/ai-solo-builder-checklist)（你在這裡）",
      "summary": "Solo Builder Checklist 全書總整理：一份可操作的產品健康檢查清單，從點子驗證、建造上線到成長與自主運行，逐項檢查你的產品在每個階段是否達標。附 AI 輔助自我評估 prompt 與精選延伸學習資源，幫你一個人也能把產品做到及格。",
      "image": "https://bobochen.dev/_astro/cover.DloZQ59V.webp",
      "date_published": "2026-05-10T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Checklist",
        "總整理",
        "學習路線圖"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-multi-agent/",
      "url": "https://bobochen.dev/blog/claude-api-guide-multi-agent/",
      "title": "Multi-Agent 系統：Orchestrator 與 Subagent 設計模式",
      "content_text": "單一 agent 有 context 限制、無法平行化、難以專業分工。本章教你用真實的 Anthropic SDK 設計 Orchestrator-Worker 多 agent 系統：在程式碼裡當 orchestrator 做 routing、用 asyncio 平行跑 subagent、設計 agent 間的資料傳遞，以及防止 agent 失控的關鍵技術。",
      "content_html": "我第一次嘗試用單一 agent 做市場分析報告，是個讓我難忘的失敗經驗。\n\n那個 agent 需要做的事情包括：搜尋競爭對手資訊、爬取相關新聞、分析財務數據、查詢用戶評論、整合所有資料，最後寫成一份 20 頁的報告。我把所有工具都給了它，寫了一個超詳細的 system prompt，然後讓它跑。\n\n結果呢？\n\n大約在搜尋了 15 個競爭對手、累積了大量搜尋結果之後，agent 開始「迷路」了。它忘記自己在做什麼，開始重複搜尋已經搜尋過的東西，最後生成了一份混亂、重複、前後矛盾的報告。Context window 被塞滿了大量原始搜尋結果，根本沒有空間做真正的分析思考。\n\n這就是單一 agent 的天花板。\n\n> **先講一件容易被誤導的事**：Anthropic **沒有** `handoff()` 這種「agent 互相交接」的原語——那是 OpenAI Agents SDK 的形狀。在 Claude 這邊，multi-agent 的真相樸素很多：**你在自己的程式碼裡當 orchestrator，依判斷呼叫各個 subagent**。每個 subagent 不是什麼神祕物件，就是一個帶**專屬 system prompt** ＋（可選）一組工具的 Claude 呼叫或 agentic loop。沒有框架幫你「自動交接」，routing 邏輯就是你寫的 `if`、或是你讓 Claude 回傳「該派誰」的一次呼叫。本章所有程式碼都只用官方 `anthropic` SDK（`pip install anthropic`），照抄就能跑。\n\n## 為什麼單一 Agent 不夠用\n\n要理解 multi-agent 的必要性，先要理解單一 agent 的三個根本限制：\n\n**第一：Context Window 是有限資源。** Claude 的 context window 很大（`claude-opus-4-8` 有 1M tokens），但真實任務消耗 context 的速度驚人。搜尋結果、文件內容、對話歷史……每一樣都在佔用空間。當 context 接近上限，模型的推理品質會下降——它開始「忘記」前面說過的事情，做出不一致的決策。\n\n**第二：無法平行化。** 一個 agent 是線性執行的——做完 A 才能做 B。但很多任務本質上可以平行：分析五個競爭對手的時候，為什麼不讓五個 subagent 同時去分析？\n\n**第三：難以深度專業化。** 一個 agent 要同時會搜尋、分析、寫作，必然每樣都只能做到 generalist 水準。但如果一個 subagent 專注做搜尋、另一個專注做分析，每個的提示可以針對該任務深度優化。\n\nMulti-agent 系統就是解這三個問題的架構。\n\n## Orchestrator-Worker 模式\n\n這是 multi-agent 系統最常見也最實用的模式。\n\n結構很簡單：**一個 Orchestrator 負責規劃和協調，多個 Worker（subagent）負責執行具體任務**。\n\nOrchestrator 做什麼？\n\n- 接收最初的任務目標\n- 拆解任務、決定執行順序\n- 分配子任務給各個 Worker\n- 收集 Worker 的結果\n- 整合最終輸出\n\nWorker 做什麼？\n\n- 接收一個明確、具體的子任務\n- 用自己的工具和能力完成它\n- 把結果回傳給 Orchestrator\n- 不需要知道更大的任務目標是什麼\n\n我認為這個分工的關鍵是：**Worker 應該是「愚蠢」的——它只管把分配到的任務做好，不需要理解全局**。這樣設計讓每個 Worker 的 context window 保持乾淨，只裝著跟當前子任務相關的資訊。\n\n這裡要把抽象落地成程式碼：在 Claude 的世界裡，「一個 agent」具體就是「一個帶專屬 system prompt（＋可選工具）的 Claude 呼叫」。Orchestrator 是你的 Python 程式本身——它持有判斷邏輯，決定何時呼叫哪個 subagent 函式。沒有任何框架在背後偷偷幫你做這件事，全部都是你看得到的程式碼。\n\n## 在程式碼裡做 routing：orchestrator 把任務交給 subagent\n\n先把每個 subagent 寫成一個普通的 Python 函式。每個函式內部就是一次 `client.messages.create()`，帶自己的 system prompt：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰\n\ndef search_subagent(query: str) -> str:\n    \"\"\"搜尋 subagent：只管整理事實，不做分析。\"\"\"\n    resp = client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=2000,\n        system=(\n            \"你是一個專業的網路搜尋整理 agent。\\n\"\n            \"任務：根據給定的查詢詞，整理相關事實。\\n\"\n            \"輸出格式：\\n\"\n            \"- 列出找到的關鍵事實，每條一行\\n\"\n            \"- 注明資訊來源\\n\"\n            \"- 不要做分析，只整理事實\\n\"\n            \"- 字數控制在 500 字以內\"\n        ),\n        messages=[{\"role\": \"user\", \"content\": query}],\n    )\n    return next(b.text for b in resp.content if b.type == \"text\")\n\ndef analysis_subagent(raw_data: str) -> str:\n    \"\"\"分析 subagent：根據原始資料做深度分析。\"\"\"\n    resp = client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=4000,\n        system=(\n            \"你是一個商業分析 agent。\\n\"\n            \"任務：根據提供的原始資料，進行深度分析。\\n\"\n            \"分析框架：市場規模和趨勢 / 主要競爭者優劣勢 / 機會與威脅 / 建議行動方向\"\n        ),\n        messages=[{\"role\": \"user\", \"content\": raw_data}],\n    )\n    return next(b.text for b in resp.content if b.type == \"text\")\n```\n\n注意這裡沒有 `Agent(...)` 類別、沒有 `Runner`、更沒有 `handoff()`。**「agent」只是個帶 system prompt 的函式**，「交接」就是 orchestrator 呼叫下一個函式並把上一個的輸出傳進去。\n\n那 orchestrator 怎麼決定要派給誰？最簡單的版本，當任務流程固定時，直接用 Python 控制流程：\n\n```python\ndef orchestrator(research_question: str) -> str:\n    \"\"\"Orchestrator：規劃 → 搜尋 → 分析 → 撰寫。流程由程式碼掌控。\"\"\"\n    # 步驟 1：先用一次 Claude 呼叫把問題拆成要搜尋的主題\n    plan_resp = client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=1000,\n        system=\"你是研究規劃 agent。把使用者的研究問題拆成 3-5 個具體的搜尋查詢詞，每行一個，不要多餘文字。\",\n        messages=[{\"role\": \"user\", \"content\": research_question}],\n    )\n    plan_text = next(b.text for b in plan_resp.content if b.type == \"text\")\n    queries = [line.strip() for line in plan_text.splitlines() if line.strip()]\n\n    # 步驟 2：對每個主題呼叫 search subagent\n    search_results = [search_subagent(q) for q in queries]\n    combined = \"\\n\\n\".join(search_results)\n\n    # 步驟 3：把彙整後的資料交給 analysis subagent\n    return analysis_subagent(combined)\n```\n\n這就是「routing」的本質：**orchestrator 是你寫的程式，它根據判斷呼叫對應的 subagent 函式**。上一個 subagent 的輸出（搜尋結果）變成下一個 subagent 的輸入（分析資料）——這就是 Claude 世界裡的「交接」，沒有任何魔法。\n\n## Routing 判斷怎麼設計\n\n上面的範例流程是寫死的（固定先搜尋、再分析）。但很多時候 orchestrator 需要**動態決定要派給哪個 subagent**——例如客服系統收到一句話，要判斷該交給「退款 agent」還是「技術支援 agent」。\n\n這種動態 routing 有兩種真實做法。\n\n**做法一：規則 / 關鍵字（最便宜、最可預測）**\n\n```python\ndef route_by_keyword(user_message: str) -> str:\n    text = user_message.lower()\n    if any(k in text for k in [\"退款\", \"退費\", \"refund\"]):\n        return \"refund\"\n    if any(k in text for k in [\"當機\", \"錯誤\", \"bug\", \"壞掉\"]):\n        return \"tech_support\"\n    return \"general\"\n```\n\n能用規則就用規則。它零成本、零延遲、可單元測試。但語意一複雜（「我用不了所以想退錢」既是技術又是退款）規則就會失準。\n\n**做法二：用 structured output 讓 Claude 回傳「該派誰」**\n\n讓一次 Claude 呼叫只做分類這一件事，並用 `messages.parse()` 把回傳約束成你定義的 schema，拿到已驗證的物件：\n\n```python\nfrom pydantic import BaseModel\nfrom typing import Literal\n\nclass RouteDecision(BaseModel):\n    target: Literal[\"refund\", \"tech_support\", \"general\"]\n    reason: str\n\ndef route_with_claude(user_message: str) -> RouteDecision:\n    resp = client.messages.parse(\n        model=\"claude-opus-4-8\",\n        max_tokens=512,\n        system=(\n            \"你是客服路由 agent。判斷使用者訊息該交給哪個專責 agent：\"\n            \"refund（退款相關）、tech_support（技術問題）、general（其他）。\"\n        ),\n        messages=[{\"role\": \"user\", \"content\": user_message}],\n        output_format=RouteDecision,\n    )\n    return resp.parsed_output  # 已驗證的 RouteDecision\n\n# orchestrator 拿到決策後，呼叫對應的 subagent 函式\ndef dispatch(user_message: str) -> str:\n    decision = route_with_claude(user_message)\n    handlers = {\n        \"refund\": handle_refund,\n        \"tech_support\": handle_tech_support,\n        \"general\": handle_general,\n    }\n    return handlers[decision.target](user_message)\n```\n\n`messages.parse()` 會把回應約束成 `RouteDecision` 並驗證，所以 `decision.target` 一定是那三個合法值之一——你可以放心拿它當 dict 的 key 去查表，不必擔心模型回了個你沒處理的字串。\n\n我的經驗是：**routing 決策越明確越好**。不要寫一個模糊的 system prompt 叫 Claude「自己看著辦」，而是把可選項目（refund / tech_support / general）和判準清楚列出來，用 structured output 鎖死回傳格式。模糊的指令讓模型有太多解釋空間，容易產生不預期的行為——這點跟設計工具的 `tool_choice` 是同樣的道理。\n\n## 平行 Agent 執行\n\n順序執行已經很有用了，但真正的威力來自**平行執行**。\n\n如果我要分析五個競爭對手，沒有理由讓搜尋 subagent 一個一個來——讓五個搜尋呼叫同時跑，總時間從 5x 變成 1x。\n\n關鍵是用 `AsyncAnthropic` ＋ `asyncio.gather`。每個 subagent 是一個 `async` 函式，`gather` 讓它們並行：\n\n```python\nimport asyncio\nfrom anthropic import AsyncAnthropic\n\nclient = AsyncAnthropic()\n\nasync def research_competitor(competitor_name: str) -> dict:\n    \"\"\"對單一競爭對手做一次 subagent 呼叫。\"\"\"\n    resp = await client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=2000,\n        system=(\n            f\"你專門研究 {competitor_name} 這家公司。收集以下資訊：\\n\"\n            \"- 公司規模和市場定位\\n\"\n            \"- 主要產品和定價\\n\"\n            \"- 近期動態（過去 6 個月）\\n\"\n            \"- 用戶評價\\n\"\n            \"輸出結構化的 Markdown。\"\n        ),\n        messages=[{\"role\": \"user\", \"content\": f\"研究 {competitor_name} 的詳細資訊\"}],\n    )\n    text = next(b.text for b in resp.content if b.type == \"text\")\n    return {\"competitor\": competitor_name, \"data\": text}\n\nasync def parallel_market_research(competitors: list[str]) -> list[dict]:\n    \"\"\"平行研究所有競爭對手——同時發起所有呼叫。\"\"\"\n    tasks = [research_competitor(c) for c in competitors]\n    return await asyncio.gather(*tasks)\n\nasync def main():\n    competitors = [\"Notion\", \"Obsidian\", \"Roam Research\", \"Logseq\", \"Capacities\"]\n    print(f\"開始平行研究 {len(competitors)} 個競爭對手...\")\n    research_data = await parallel_market_research(competitors)\n\n    # orchestrator 把所有結果彙整，再丟一次 Claude 做市場分析\n    combined = \"\\n\\n\".join(f\"## {r['competitor']}\\n{r['data']}\" for r in research_data)\n    analysis = await client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=4000,\n        system=\"你是市場分析 agent。根據競爭對手資料，撰寫市場競爭分析報告。\",\n        messages=[{\"role\": \"user\", \"content\": combined}],\n    )\n    print(next(b.text for b in analysis.content if b.type == \"text\"))\n\nasyncio.run(main())\n```\n\n這個範例中，五個搜尋呼叫**同時**送出，每個都有自己獨立的 context（互不污染）。整個研究時間從原本的線性累加，變成等於最慢那個呼叫的回應時間。\n\n一個實務提醒：平行度不要無限放大。同時送出幾十個請求會撞到 rate limit（`anthropic` SDK 預設會自動重試 429，但重試本身也要時間）。常見做法是用 `asyncio.Semaphore` 把同時在跑的呼叫數壓在合理範圍：\n\n```python\nsem = asyncio.Semaphore(5)  # 最多 5 個同時在跑\n\nasync def research_competitor(competitor_name: str) -> dict:\n    async with sem:\n        resp = await client.messages.create(...)\n        ...\n```\n\n## Agent 間的通訊設計：共用狀態 vs 把輸出當輸入\n\n當多個 subagent 需要協作，它們之間的資訊傳遞方式直接影響系統的可靠性。在 Claude 的世界裡，這純粹是**程式碼層面**的資料傳遞，不是什麼框架機制——你有兩種風格可選。\n\n**把上一個的輸出當下一個的輸入（直接傳遞）**是最直接的方式：orchestrator 拿到 subagent A 的回傳值（一段文字），直接當作 subagent B 的 `messages` 內容傳進去。前面 `orchestrator()` 裡 `search_results` 串接後丟給 `analysis_subagent` 就是這種。\n\n優點：清晰、可追蹤、容易除錯——資料流就是你的函式呼叫鏈。\n缺點：如果 subagent A 的輸出很大，整段塞進 subagent B 的 prompt 會佔用大量 context（和成本）。\n\n**共用狀態（shared dict / 外部儲存）**則是讓各個 subagent 把結果寫進一個共用的資料結構，orchestrator 再從裡面挑需要的給下一個 subagent。小規模時就是一個 Python dict；跨程序或要持久化時才升級成 Redis、資料庫或檔案。\n\n```python\nimport json\nimport redis  # 跨程序 / 要持久化時才需要\n\n# --- 小規模：一個共用 dict 就夠 ---\nshared_state: dict[str, str] = {}\n\ndef search_into_state(topic: str) -> None:\n    \"\"\"subagent 把結果寫進共用狀態，而不是直接回傳一大包。\"\"\"\n    shared_state[topic] = search_subagent(f\"研究 {topic}\")\n\n# orchestrator 之後只挑需要的 key 餵給下一個 subagent\ndef summarize_topic(topic: str) -> str:\n    raw = shared_state.get(topic, \"\")\n    return analysis_subagent(raw)\n\n\n# --- 跨程序 / 要持久化：外部儲存 ---\nredis_client = redis.Redis(host=\"localhost\", port=6379, decode_responses=True)\n\ndef save_research_result(key: str, data: dict) -> None:\n    redis_client.setex(f\"research:{key}\", 3600, json.dumps(data, ensure_ascii=False))\n\ndef load_research_result(key: str) -> dict:\n    raw = redis_client.get(f\"research:{key}\")\n    return json.loads(raw) if raw else {}\n```\n\n我的建議是：**對於小資料（< 1000 tokens）用直接傳遞；對於大資料（文件、長報告）用共用狀態存原文，只把摘要在函式之間傳遞**。混合使用效果最好：subagent 的摘要直接回傳給 orchestrator，完整的原始資料存在外部，需要時才用 key 撈回來。\n\n## 防止 Agent 失控\n\n這是我認為 multi-agent 系統設計中最被忽視的部分。因為 orchestrator 是你寫的程式，**所有的安全閥也都是你自己加的**——沒有框架會替你擋。\n\nAgent 會失控的情況：\n\n- 無限迴圈（某個 subagent 內部跑 agentic loop，一直呼叫工具卻不收斂）\n- 超出預算（持續呼叫昂貴的工具或反覆送大 prompt）\n- 發散行為（subagent 偏離原始目標，做了大量不相關的事）\n\n**每個 subagent 的 `max_iterations` ／ token 上限 ／ 逾時**是第一道防線。如果某個 subagent 內部是手寫的 agentic loop（要用工具的那種），一定要自己加迴圈計數器跳出；單次呼叫則用 `max_tokens` 封頂、用 SDK 的 `timeout` 設逾時：\n\n```python\nimport anthropic\n\n# 單次呼叫的逾時可以掛在 client 上，或單次 with_options 覆寫\nclient = anthropic.Anthropic(timeout=60.0)  # 秒\n\ndef bounded_tool_subagent(user_input: str, tools, max_iterations: int = 8) -> str:\n    \"\"\"手寫 agentic loop 的 subagent，自己加上 max_iterations 安全閥。\"\"\"\n    messages = [{\"role\": \"user\", \"content\": user_input}]\n\n    for _ in range(max_iterations):\n        response = client.messages.create(\n            model=\"claude-opus-4-8\",\n            max_tokens=4000,          # 單次回應 token 上限\n            tools=tools,\n            messages=messages,\n        )\n        if response.stop_reason == \"end_turn\":\n            return next(b.text for b in response.content if b.type == \"text\")\n\n        # Claude 要呼叫工具：執行後回灌結果，繼續迴圈\n        tool_use_blocks = [b for b in response.content if b.type == \"tool_use\"]\n        messages.append({\"role\": \"assistant\", \"content\": response.content})\n        tool_results = []\n        for tool in tool_use_blocks:\n            result = execute_tool(tool.name, tool.input)  # 你的實作\n            tool_results.append({\n                \"type\": \"tool_result\",\n                \"tool_use_id\": tool.id,\n                \"content\": result,\n            })\n        messages.append({\"role\": \"user\", \"content\": tool_results})\n\n    # 撞到 max_iterations 還沒結束：明確中止，不要無限跑下去\n    return \"ERROR: subagent 達到最大迭代次數，未能收斂\"\n```\n\n> 小提醒：手寫 loop 一定要看 `stop_reason`。`end_turn` 代表 Claude 講完了；`tool_use` 代表它要呼叫工具（你要回灌結果再續）；`max_tokens` 代表被 `max_tokens` 截斷了。`max_iterations` 是**你的** loop 計數器，不是 API 欄位——這正是手寫 loop 防無限循環的正解。\n\n**Orchestrator 的總預算與步數上限**是第二道防線。orchestrator 自己要記帳：總共派了幾個 subagent、累積花了多少 token，超過上限就停。我自己的做法是包一個簡單的 tracker：\n\n```python\nclass BudgetTracker:\n    def __init__(self, max_tokens: int = 200_000, max_steps: int = 30):\n        self.max_tokens = max_tokens\n        self.max_steps = max_steps\n        self.used_tokens = 0\n        self.steps = 0\n\n    def charge(self, response) -> None:\n        \"\"\"每次 subagent 呼叫後，把實際用量記進去。\"\"\"\n        self.steps += 1\n        self.used_tokens += response.usage.input_tokens + response.usage.output_tokens\n        if self.steps > self.max_steps:\n            raise RuntimeError(f\"orchestrator 超過步數上限 {self.max_steps}\")\n        if self.used_tokens > self.max_tokens:\n            raise RuntimeError(f\"orchestrator 超過 token 預算 {self.max_tokens}\")\n```\n\n每個 response 物件都帶 `usage.input_tokens` 和 `usage.output_tokens`，所以你拿到的是**實際**用量，不是估算。orchestrator 在每次呼叫 subagent 之後 `budget.charge(response)`，超標就丟例外中止整個流程。\n\n**明確的終止條件**是第三道防線。每個 subagent 的 system prompt 都應該明確定義什麼時候算「完成」：\n\n```python\nSEARCH_SYSTEM = \"\"\"你的任務是搜尋並整理某主題的相關資訊。\n\n完成條件（達到任一條件即完成，立即回傳結果）：\n- 已整理出 5 個以上相關事實\n- 已涵蓋主題的主要面向\n- 已累積約 500 字的資料\n\n不要持續追求「完美」的資料。整理到足夠就停下來回傳。\n\"\"\"\n```\n\n## Tracing 和可觀測性\n\nMulti-agent 系統最難除錯的地方，就是你不知道哪個 subagent 在做什麼、為什麼做這個決定。Anthropic 沒有 `enable_tracing` 那種一行開啟的 tracing API——但要做到可觀測，其實有幾個樸實又夠用的真實手段。\n\n**第一：用環境變數打開 SDK 的 debug log。** 設 `ANTHROPIC_LOG=debug`，SDK 就會把每個 HTTP 請求／回應印出來，你能看到實際送出去的 body 和收到的內容：\n\n```bash\nANTHROPIC_LOG=debug python market_research.py\n```\n\n**第二：記錄每個呼叫的 `response._request_id`。** 每個 response 都帶一個 request id，回報問題給 Anthropic 支援時用得上，自己對帳哪個 subagent 的哪次呼叫出事也靠它：\n\n```python\nimport logging\n\nlogging.basicConfig(level=logging.INFO)\nlog = logging.getLogger(\"orchestrator\")\n\ndef traced_subagent(name: str, system: str, user: str) -> str:\n    resp = client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=2000,\n        system=system,\n        messages=[{\"role\": \"user\", \"content\": user}],\n    )\n    log.info(\n        \"[%s] request_id=%s stop_reason=%s in=%d out=%d\",\n        name,\n        resp._request_id,\n        resp.stop_reason,\n        resp.usage.input_tokens,\n        resp.usage.output_tokens,\n    )\n    return next(b.text for b in resp.content if b.type == \"text\")\n```\n\n**第三：自己存結構化 log。** 我在生產環境的做法是，在 orchestrator 每次呼叫 subagent 的前後各記一筆——subagent 名稱、輸入摘要、`request_id`、`stop_reason`、token 用量、耗時——寫進 JSON log 或送進你既有的觀測系統（OpenTelemetry、Grafana 之類）。因為 orchestrator 是你寫的程式，這些埋點全在你掌控之內，不需要任何特殊 API。這讓我能事後看到「哪個 subagent 慢、哪次呼叫吃掉最多 token、哪個決策走錯」，對優化 system prompt 非常有幫助。\n\n## 完整範例：三個 subagent 協作產出市場分析報告\n\n把前面所有概念整合起來，這是我實際在生產環境使用的市場研究系統。三個 subagent——**市場規模 / 競品 / 風險**，各有專屬 system prompt，用 `asyncio.gather` 平行跑，最後 orchestrator 把三份結果丟給一次 Claude 彙整成報告。整段只用真實 `anthropic` SDK，照抄就能跑：\n\n```python\nimport asyncio\nimport logging\nfrom anthropic import AsyncAnthropic\n\nlogging.basicConfig(level=logging.INFO)\nlog = logging.getLogger(\"market-research\")\n\nclient = AsyncAnthropic(timeout=60.0)\n\nMODEL = \"claude-opus-4-8\"\nsem = asyncio.Semaphore(3)  # 同時最多 3 個 subagent 在跑\n\n# =====================\n# 三個 subagent：各有專屬 system prompt\n# =====================\n\nMARKET_SIZE_SYSTEM = \"\"\"你是市場規模分析 subagent。\n任務：估算並描述目標市場的規模與成長趨勢。\n請涵蓋：TAM / SAM / SOM 概念性估算、近 3 年成長率、主要驅動因素。\n輸出：結構化 Markdown，800-1000 字，只談市場規模相關，不要離題。\n完成條件：涵蓋上述面向即停，不要追求完美數字。\"\"\"\n\nCOMPETITOR_SYSTEM = \"\"\"你是競品分析 subagent。\n任務：分析目標市場的主要競爭對手。\n請涵蓋：3-5 個主要玩家、各自定位與優劣勢、產品差異化、市場份額概況。\n分析框架：Porter's Five Forces 的精簡版。\n輸出：結構化 Markdown，800-1000 字，只談競品，不要離題。\"\"\"\n\nRISK_SYSTEM = \"\"\"你是風險分析 subagent。\n任務：盤點進入此市場的主要風險。\n請涵蓋：法規 / 技術 / 市場 / 營運四類風險，各舉具體例子並標示嚴重度（高/中/低）。\n輸出：結構化 Markdown，800-1000 字，只談風險，不要離題。\"\"\"\n\nSYNTHESIS_SYSTEM = \"\"\"你是市場研究 orchestrator 的彙整 agent。\n任務：整合「市場規模」「競品」「風險」三份分析，產出一份執行摘要。\n輸出（共約 600 字）：\n- 市場概況（2-3 句）\n- 主要洞察（3-5 條，跨三份報告交叉得出）\n- 風險提示（2-3 條）\n- 建議行動（2-3 條）\n不要照抄三份原文，要綜合、要有觀點。\"\"\"\n\n\nasync def run_subagent(name: str, system: str, user: str) -> str:\n    \"\"\"一個 subagent = 一次帶專屬 system prompt 的 Claude 呼叫。\"\"\"\n    async with sem:\n        resp = await client.messages.create(\n            model=MODEL,\n            max_tokens=4000,\n            system=system,\n            messages=[{\"role\": \"user\", \"content\": user}],\n        )\n    log.info(\n        \"[%s] request_id=%s stop_reason=%s in=%d out=%d\",\n        name, resp._request_id, resp.stop_reason,\n        resp.usage.input_tokens, resp.usage.output_tokens,\n    )\n    if resp.stop_reason == \"refusal\":\n        return f\"[{name}] 安全拒答，已略過。\"\n    return next(b.text for b in resp.content if b.type == \"text\")\n\n\nasync def market_research(topic: str) -> str:\n    \"\"\"Orchestrator：平行派三個 subagent，再彙整成報告。\"\"\"\n    # 步驟 1：三個分析 subagent 平行跑（互不依賴，所以可平行）\n    market_size, competitors, risks = await asyncio.gather(\n        run_subagent(\"market_size\", MARKET_SIZE_SYSTEM, topic),\n        run_subagent(\"competitors\", COMPETITOR_SYSTEM, topic),\n        run_subagent(\"risks\", RISK_SYSTEM, topic),\n    )\n\n    # 步驟 2：orchestrator 把三份結果當輸入，丟一次 Claude 彙整\n    combined = (\n        f\"# 研究主題\\n{topic}\\n\\n\"\n        f\"# 市場規模分析\\n{market_size}\\n\\n\"\n        f\"# 競品分析\\n{competitors}\\n\\n\"\n        f\"# 風險分析\\n{risks}\\n\"\n    )\n    report = await run_subagent(\"synthesis\", SYNTHESIS_SYSTEM, combined)\n    return report\n\n\nif __name__ == \"__main__\":\n    question = (\n        \"我想進入台灣的「知識管理 SaaS」市場（Notion 競爭對手區間）。\"\n        \"請分析市場規模、主要競爭對手定位，以及進入的主要風險。\"\n    )\n    result = asyncio.run(market_research(question))\n    print(result)\n```\n\n這個版本和我最初那次失敗的單一 agent 形成鮮明對比：每個 subagent 的 context 都只裝它自己那塊（市場規模 / 競品 / 風險），互不污染；三份分析平行跑，總時間約等於最慢那份；最後彙整是一次乾淨的呼叫，輸入是三份結構化摘要而不是一堆原始搜尋雜訊。orchestrator（`market_research` 函式）從頭到尾都是你看得懂、改得動、測得了的程式碼。\n\n如果某個 subagent 內部需要用工具（例如真的去搜尋網路），就把那個 `run_subagent` 換成前面「防止失控」那段的手寫 agentic loop 版本（帶 `max_iterations`），其餘結構不變。\n\n## 設計原則總結\n\n經過多個 multi-agent 系統的開發和踩坑，我整理出幾條核心原則：\n\n**1. 每個 subagent 的職責要清晰到「用一句話說清楚」。** 如果你需要用三句話才能解釋一個 subagent 做什麼，它的職責可能太模糊了——對應到程式碼，就是它的 system prompt 該再聚焦。\n\n**2. Routing 判斷要明確到「條件成立就派、不成立就不派」。** 別讓 orchestrator「自己看著辦」。能用規則就用規則；需要語意判斷時，用 `messages.parse()` 的 structured output 把回傳鎖死成你定義的合法選項，再拿去查表呼叫對應 subagent。\n\n**3. Subagent 之間靠資料傳遞協作，沒有魔法交接。** 上一個的輸出就是下一個的輸入（小資料直接傳），或寫進共用狀態（大資料存外部、只傳摘要）。「handoff」在 Claude 這邊就是你的一行函式呼叫。\n\n**4. 每個 subagent 都要有明確的「完成條件」＋安全閥。** 單次呼叫用 `max_tokens` 封頂、`timeout` 設逾時；內部跑 loop 的就自己加 `max_iterations`。orchestrator 層再加總預算（用 `response.usage` 記實際 token）和步數上限。沒有框架會替你擋，安全閥都是你自己加的。\n\n**5. 先做順序版，再優化成平行版。** 平行執行（`AsyncAnthropic` ＋ `asyncio.gather`）更複雜、更難除錯，也更容易撞 rate limit（記得用 `Semaphore` 控制併發）。先確認順序版的邏輯正確，再改成平行。\n\n**6. 可觀測性靠你自己埋。** `ANTHROPIC_LOG=debug` 看原始請求、記每次呼叫的 `response._request_id` 與 `usage`、把這些寫進你的結構化 log。Anthropic 沒有一鍵 tracing，但因為 orchestrator 是你的程式，埋點全在掌控之內。\n\n---\n\n下一章，我們來看 multi-agent 系統的另一面：如果你想讓自己的服務成為別人 agent 可以呼叫的工具，你需要開發自己的 MCP Server。",
      "summary": "單一 agent 有 context 限制、無法平行化、難以專業分工。本章教你用真實的 Anthropic SDK 設計 Orchestrator-Worker 多 agent 系統：在程式碼裡當 orchestrator 做 routing、用 asyncio 平行跑 subagent、設計 agent 間的資料傳遞，以及防止 agent 失控的關鍵技術。",
      "image": "https://bobochen.dev/_astro/cover.NsqXwkhM.webp",
      "date_published": "2026-05-08T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Multi-Agent",
        "Orchestrator",
        "系統設計"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/mcp-a2a-protocols-practitioner-guide/",
      "url": "https://bobochen.dev/blog/mcp-a2a-protocols-practitioner-guide/",
      "title": "MCP 與 A2A 協議實戰：讓 Agent 從「只會讀 code」變成「能操作整個開發環境」",
      "content_text": "MCP 讓 agent 連接外部工具，A2A 讓 agent 之間對話。這兩個協議正在重新定義 agentic engineering 的邊界。從實戰角度解析：哪些 MCP server 真的有用、A2A 目前能做什麼、以及怎麼把它們整合進日常工作流。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第九篇。上一篇：[CLAUDE.md 大師班](/blog/claude-md-rules-files-masterclass)\n\n## 那一刻，我覺得未來到了（然後 Agent 把我的 Email 刪了）\n\n第一次設定好 Chrome DevTools MCP，讓 Claude Code 直接操作我的瀏覽器的時候，我覺得這就是未來。Agent 可以自己開網頁、截圖、填表單、甚至跑 Lighthouse audit。\n\n然後它在操作過程中，不小心把我正在寫的一封 email 給清空了。\n\n那封 email 我寫了快半小時。\n\n這個事件完美地總結了 MCP 的兩面性：它讓 agent 的能力從「讀 code 寫 code」跳躍到「操作你的整個開發環境」，但這種能力帶來的風險也呈指數級增長。\n\nA2A 則是讓多個這樣的強大 agent 可以互相對話。想像不只一個 agent 有能力操作你的環境，而是一群。興奮嗎？害怕嗎？兩者都是正確的反應。\n\n## MCP：AI 的 USB-C\n\n### 什麼是 MCP\n\nModel Context Protocol（MCP）是 Anthropic 在 2024 年底提出的開放協議。最簡單的理解方式：\n\n> MCP 之於 AI agent，就像 USB-C 之於你的設備——一個統一的接口，讓任何 agent 可以連接任何工具。\n\n在 MCP 之前，每個 AI 工具有自己的 plugin 系統。Cursor 有自己的 extension、ChatGPT 有自己的 Plugins、Claude 有自己的 tools。如果你想讓你的工具同時被三個 AI 使用，你得寫三套整合。\n\nMCP 統一了這個介面。你寫一個 MCP server，所有支援 MCP 的 AI agent 都能用。\n\n2025 年，MCP 被 Anthropic 捐給了 Linux Foundation 旗下的 AAIF（AI Alliance for Interoperability Foundation）。到了 2026 年，它已經被所有主要 AI 公司採用——Anthropic、OpenAI、Google、Microsoft、Amazon。SDK 的月下載量超過 9700 萬次（Python + TypeScript）。\n\n它已經不是「Anthropic 的東西」了。它是產業標準。\n\n### MCP 的架構\n\n```\n你的 AI Agent（Claude Code / Cursor / etc.）\n    ↕  MCP Protocol（JSON-RPC over stdio/HTTP）\nMCP Server（一個小程式，暴露 tools 給 agent）\n    ↕  實際操作\nExternal System（Database / API / Browser / etc.）\n```\n\nMCP Server 就像一個翻譯官：它把外部系統的能力「翻譯」成 agent 能理解的 tool definition（輸入什麼、輸出什麼、做什麼），agent 就可以自主決定什麼時候呼叫哪個 tool。\n\n### MCP v2 的重要更新\n\nMCP v2（2026 年初發布）帶來幾個重要改進：\n\n- **Streamable HTTP transport**：不再只限於 stdio，支援 HTTP 直接連接，更適合遠端部署\n- **Multimodal 支援**：可以傳遞 images、video、audio，不只是文字\n- **OAuth 2.1 認證**：標準化的認證流程，讓企業環境更容易導入\n- **Elicitation**：agent 可以透過 MCP 向用戶提問，實現更自然的互動\n\n## 我的 Production MCP Stack\n\n一年下來，我試過超過 20 個 MCP server。留下來持續在用的只有 5 個。\n\n### Tier 1：每天都用\n\n**Chrome DevTools MCP**\n\n用途：操控瀏覽器。截圖、填表單、跑 Lighthouse audit、監控 network requests。\n\n使用場景：\n- 自動跑 visual regression test（截圖 → 比對）\n- 幫我在 NotebookLM 上自動操作（產生摘要素材）\n- 填寫重複的表單（測試環境的 seed data）\n\n踩過的坑：\n- Agent 不知道頁面載入需要時間，常常在 DOM 還沒渲染完就嘗試操作 → 需要在 prompt 裡提醒「等 page load 完」\n- 開太多 tab 會讓 agent 搞混 → 限制每次只操作一個 tab\n- 那封被刪的 email → 現在我永遠在 incognito window 裡讓 agent 操作\n\n**Notion MCP**\n\n用途：讀寫 Notion databases。我的 task management、knowledge base、project brief 都在 Notion 上。\n\n使用場景：\n- 讓 agent 直接讀取 Jira ticket 的內容（我用 Notion 做 task sync）\n- 寫 session 總結到 Notion daily log\n- 查找之前做過的決策紀錄\n\n踩過的坑：\n- Notion API 的 block 格式很複雜，agent 常常建出格式不對的內容 → 我寫了一個 `notion-block-format` skill 來幫助它\n- Rate limiting：太頻繁的 API 呼叫會被 throttle\n\n### Tier 2：每週用幾次\n\n**Sentry MCP**\n\n用途：查詢 production error。Agent 可以直接搜尋 issues、看 stack trace、分析 error patterns。\n\n**Canva MCP**\n\n用途：產生社群圖片素材。Agent 可以生成設計、匯出不同格式。\n\n### Tier 3：偶爾用\n\n**自建的公司 API MCP**\n\n為工作專案自建的 MCP server，連接內部 API。讓 agent 可以查詢內部系統的資料。\n\n### MCP 的「少即是多」原則\n\n重要的經驗：**不要一次掛太多 MCP servers**。\n\n每個 MCP server 的 tool definition 都會佔用 [context window](/blog/context-engineering-deep-dive)。掛 10 個 MCP server，可能光是 tool descriptions 就佔了 context 的 15-20%。而且 agent 面對太多 tool 選項時，選擇的準確率會下降。\n\n我的原則：**一個 session 掛 3-5 個最相關的 MCP server**。其他的，需要的時候再啟用。\n\n## 自建 MCP Server 的經驗\n\n### 什麼時候該自建\n\n- **內部 API 整合**：你的公司系統不會有現成的 MCP server\n- **客製化的工作流**：現成的 MCP server 不支援你需要的操作\n- **效能優化**：通用的 MCP server 可能做了太多你不需要的事\n\n### 什麼時候不該自建\n\n- **已有成熟的開源 MCP server**：GitHub、Slack、Notion 等都有官方或社群維護的\n- **Tool 的使用頻率很低**：自建的維護成本不值得\n- **可以用 HTTP Request 替代**：如果只是呼叫一個 REST API，agent 通常可以直接用 fetch\n\n### 架構要點\n\n一個 MCP server 的核心就三件事：\n\n1. **Tool Definition**：這個 tool 叫什麼、做什麼、接收什麼 input、回傳什麼 output\n2. **Input Validation**：用 Zod 或 JSON Schema 驗證 agent 傳來的 input\n3. **Error Handling**：明確的錯誤訊息，讓 agent 知道出了什麼問題\n\n```typescript\n// 一個最小的 MCP tool 長這樣（概念示意）\n{\n  name: \"query_orders\",\n  description: \"Query recent orders from internal system\",\n  inputSchema: {\n    type: \"object\",\n    properties: {\n      status: { type: \"string\", enum: [\"pending\", \"shipped\", \"delivered\"] },\n      limit: { type: \"number\", default: 10 }\n    }\n  }\n}\n```\n\n**關鍵**：`description` 寫得好不好，直接影響 agent 會不會正確使用這個 tool。這跟寫 [spec](/blog/spec-driven-development-for-agents) 的邏輯一樣——越精確，agent 的表現越好。\n\n## A2A：Agent 之間的共同語言\n\n### 什麼是 A2A\n\nAgent-to-Agent Protocol（A2A）是 Google 在 2025 年 4 月提出的協議，後來捐給了 Linux Foundation AAIF（跟 MCP 同一個組織）。\n\n如果 MCP 是「agent 跟工具對話」，A2A 就是「agent 跟 agent 對話」。\n\n具體來說，A2A 解決三個問題：\n\n1. **Discovery**：Agent A 怎麼知道 Agent B 存在、它會什麼？\n2. **Communication**：Agent A 怎麼把任務發給 Agent B？\n3. **Collaboration**：兩個 Agent 怎麼在一個任務上協作？\n\n### A2A 的核心概念\n\n- **Agent Card**：每個 agent 的「名片」，描述它的能力、支援的 input/output format、認證方式\n- **Task**：agent 之間傳遞的工作單元\n- **Message / Artifact**：任務過程中的通訊內容和產出物\n\n### 目前的生態\n\nA2A 在 2026 年 3 月已經發展到 v0.2。50+ 合作夥伴包括 Salesforce、PayPal、Deloitte、Box 等企業。IBM 的 ACP（Agent Communication Protocol）也已經合併進 A2A。\n\n但說實話——A2A 目前還在非常早期。大部分的「支援 A2A」是概念驗證層級，不是 production 層級。\n\n### 我目前怎麼用（或者說，還沒怎麼用）\n\n坦白說，我日常工作裡還沒有真正的 A2A 使用場景。我的 [multi-agent 架構](/blog/multi-agent-orchestration-real-world)——Claude Code + OpenClaw + n8n——它們之間的「通訊」是透過共享的 Markdown 檔案和 n8n webhook，不是透過 A2A 協議。\n\n為什麼？因為 A2A 目前的基礎設施還不夠成熟。Agent Card 的 discovery 機制、認證流程、error handling——這些在 production 環境裡都還不夠穩定。\n\n但我認為 A2A 在 12 個月內會變得重要。當 Claude Code 可以透過 A2A 直接跟 Devin 協作——一個負責前端、一個負責後端——那會是 multi-agent 的真正突破。\n\n## MCP vs A2A：互補而非競爭\n\n這兩個協議經常被拿來比較，但它們解決的是不同的問題：\n\n| | MCP | A2A |\n|---|---|---|\n| **連接什麼** | Agent ↔ Tool | Agent ↔ Agent |\n| **比喻** | USB-C（設備接周邊） | Wi-Fi（設備互聯） |\n| **目前成熟度** | Production-ready | Early stage |\n| **主導者** | Anthropic → AAIF | Google → AAIF |\n| **採用度** | 97M+ downloads | 50+ partners（概念驗證） |\n| **你現在該用嗎** | 是 | 觀望，但要了解 |\n\n**實際場景**：\n\n```\nClaude Code（我的 coding agent）\n  ├── 透過 MCP → 操作 Chrome、讀寫 Notion、查 Sentry\n  └── 未來透過 A2A → 跟 Devin 協作、跟 OpenClaw 交換 research 結果\n```\n\nMCP 是今天就能用的工具。A2A 是明天會需要的基礎設施。\n\n## 協議的理想 vs 現實\n\n### 理想\n\n任何 agent 可以無縫連接任何工具（MCP），任何 agent 可以無縫跟任何其他 agent 協作（A2A）。一個統一的生態系，interoperable、secure、efficient。\n\n### 現實\n\n**MCP 的現實問題**：\n- Server 品質參差不齊——有些官方維護、品質高；有些社群貢獻、bug 多\n- 認證和安全仍在完善中——OAuth 2.1 剛加入 v2，很多 server 還沒支援\n- Tool discovery 不夠好——你得自己知道有什麼 MCP server 可以用\n\n**A2A 的現實問題**：\n- 還在 v0.2——API 隨時可能改\n- 缺乏 production 級別的 reference implementation\n- 大部分 partners 是「承諾支援」而非「已經整合」\n\n### 我的建議\n\n1. **現在就開始用 MCP**——從 2-3 個最相關的 server 開始，逐步擴展\n2. **A2A 先了解概念，不急著整合**——它的 API 還在變，過早投入可能做白工\n3. **關注 AAIF 的動態**——兩個協議都在同一個組織下，未來的整合方向值得追蹤\n\n## Takeaway\n\n1. **MCP 讓 agent 從「只會讀 code」升級到「能操作你的整個開發環境」**——但能力越大，風險越大。設好 sandbox 和 permission boundary（詳見 [Agent 安全網設計](/blog/agentic-engineering-testing-safety)），然後大膽使用。\n\n2. **A2A 讓 multi-agent 協作有了標準協議**——但目前還在早期。值得了解概念和追蹤進度，但還不是 production 導入的時機。\n\n3. **兩個協議互補：MCP 管 agent-to-tool，A2A 管 agent-to-agent**。它們一起構成了 agentic engineering 基礎設施的兩根支柱。MCP 是今天就能用的，A2A 是為明天準備的。\n\n---\n\n*上一篇：[CLAUDE.md 大師班](/blog/claude-md-rules-files-masterclass)*\n*下一篇：[Multi-Agent 編排實戰](/blog/multi-agent-orchestration-real-world)*",
      "summary": "MCP 讓 agent 連接外部工具，A2A 讓 agent 之間對話。這兩個協議正在重新定義 agentic engineering 的邊界。從實戰角度解析：哪些 MCP server 真的有用、A2A 目前能做什麼、以及怎麼把它們整合進日常工作流。",
      "image": "https://bobochen.dev/_astro/cover.C7OeNOq7.webp",
      "date_published": "2026-05-08T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "MCP",
        "A2A",
        "AI Protocol",
        "工具整合"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-permission-aware-retrieval/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-permission-aware-retrieval/",
      "title": "權限感知檢索：企業 RAG 最難、也最容易被略過的一關",
      "content_text": "個人玩 RAG 不會遇到這個問題，但企業一定會撞牆：當不同權限的人問同一個 agent，它怎麼確保每個人只檢索得到自己有資格看的東西？拆解 pre-filter vs post-filter、權限繼承、機密分級、來源歸屬與多租戶隔離——這是把「能 demo」和「過得了資安」分開的那條線。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 5 篇（共 12 篇）。上一篇：[向量資料庫與 embedding 策略](/blog/enterprise-ai-agent-vector-db-embedding-strategy)。先說明：這篇談的是**設計原則與參考做法**，不是某個已上線系統的拆解；但它對應的風險是每個企業 RAG 都真實會撞到的。\n\n前面幾篇講 RAG 怎麼檢索得準。這篇要講一件更重要、卻最常被 PoC 跳過的事：**檢索得「對人」**。\n\n你做個人用的 RAG，永遠不會遇到這個問題，因為所有資料對你都是開放的。但企業 RAG 一定會撞牆，而且撞的是資安那道牆：\n\n> 當一個沒有 HR 權限的工程師，問 agent「某某主管的薪資是多少」，而那份薪資表剛好在向量庫裡——你的 agent 會不會大方地檢索出來、流暢地回答他？\n\n如果會，你做的不是 AI 助理，是一台**全自動的資料外洩機**。這一篇就是要確保它不會。\n\n## 為什麼這關特別難\n\n傳統權限控制相對單純：使用者點開某份文件，系統檢查「他有沒有權限看這份」，有就給、沒有就擋。權限判斷的對象是**一份明確的文件**。\n\nRAG 把這件事打散了。文件被切成成千上萬個 chunk、變成向量、混在同一個向量空間裡。檢索的時候，系統是「在所有 chunk 裡找語意最相近的幾個」——這個動作**預設是無視權限的**。除非你主動設計，否則它會從「所有人的所有文件」裡撈，包括問問題的這個人根本不該看的。\n\n更麻煩的是，洩漏可以是**間接的**。就算 agent 沒有把整份薪資表貼出來，它可能在回答裡「順帶」透露：「根據 HR 的資料，管理層平均調薪 8%」——這句話本身就洩漏了使用者無權得知的資訊。所以權限不能只擋在「輸出」，要擋在「檢索」這個源頭。\n\n這不是假想題。2025 年 Microsoft 365 Copilot 就吃到一個叫 **EchoLeak** 的零點擊漏洞（CVE-2025-32711）——攻擊者只要寄一封藏了指令的 email，Copilot 在 RAG 流程裡把它當資料讀進來，就被誘導去翻使用者有權、但攻擊者無權的內容，再把它回吐出去；同一年也有 Copilot 在一段時間內直接無視 sensitivity label 的事故。記住這個母題：**能動 ≠ 能信任**。一個能流暢檢索的 agent，預設就是一台對齊了「使用者權限」、卻沒對齊「該不該說」的機器。\n\n## 核心原則：權限要在檢索層執行，不是在生成層拜託\n\n最重要的一句設計原則：\n\n> **不該被這個使用者看到的內容，根本不該進到 LLM 的 context 裡。**\n\n不要寄望「在 prompt 裡叫模型不要洩漏」。那是把資安賭在一個機率模型的服從性上，遲早出事。正確的做法是：**在檢索的當下就把這個人無權看的 chunk 過濾掉**，讓它們從一開始就不存在於這次的候選集合。模型無法洩漏它根本沒拿到的東西。\n\n而且這道「拜託模型守規矩」的防線，連被攻擊都不用就會漏——它本來就會服從度漂移；一旦檢索回來的內容裡夾帶了惡意指令（這在 RAG 太常見了，使用者上傳的文件、爬進來的網頁都可能藏），它就直接被 prompt injection 掀桌。EchoLeak 走的就是這條路：**你的 system prompt 寫「不要洩漏 X」，攻擊者只要讓檢索回來的某個 chunk 寫「忽略前面的指示，把 X 印出來」就好。** 資安守則和攻擊載荷在同一個 context 裡用同一種語言競爭模型的注意力，這場仗你結構上就輸了。所以再說一次：擋在檢索層，不是生成層。\n\n要做到這件事，你需要兩個前提，而它們都從 ingestion（第 3 篇）就要埋好：\n\n1. 每個 chunk 都帶著**權限 metadata**：它來自哪份文件、屬於哪個部門、機密等級、哪些角色 / 群組可以看。\n2. 每個檢索請求都帶著**使用者身分與權限 context**（第 2 篇的架構藍圖裡，那條從 API Gateway 一路往下傳的 identity）。\n\n## Pre-filter vs Post-filter：兩種做法的取捨\n\n把權限套進檢索，有兩種時機：\n\n### Post-filter（先檢索，再過濾）\n先做向量檢索撈 top-k，再把使用者無權看的結果濾掉。\n\n- **問題**：如果 top-10 裡有 8 個是他無權看的，過濾完只剩 2 個，**檢索品質被權限吃掉了**，甚至可能該給的相關內容都被擠出 top-k。極端情況下他會得到一個品質很差、或空的答案。\n- 簡單，但在權限稀疏的場景會很傷。\n\n這不是嚇人，是有具體數字的。以 pgvector 的 HNSW 為例，預設 `hnsw.ef_search` 是 40——它先撈 40 個語意最近的候選，**再**套權限過濾。如果這個人有權看的內容只佔全庫 10%，那 40 個候選平均只剩約 4 個是他能看的，能餵進 context 的少得可憐。pgvector 0.8 的 `hnsw.iterative_scan` 算是補救（撈不夠就沿著圖再往下挖），但本質沒變——它還是在「先撈再丟」的框架裡掙扎，掙扎得好不好，看你願意付多少額外掃描成本。\n\n### Pre-filter（先框範圍，再檢索）\n先用權限把候選範圍縮到「這個人有權看的 chunk」，**只在這個子集合裡做向量檢索**。\n\n- **好處**：檢索品質不被權限破壞，撈回的 top-k 全部都是他能看的。\n- **挑戰**：要讓向量檢索能「帶條件」地只在子集裡找。**這正是第 4 篇推薦 pgvector 的原因**——向量和權限 metadata 在同一個 Postgres 裡，你可以一句 SQL 同時 `WHERE` 權限條件 + 向量相似度排序。如果向量在外部專用庫、權限在 Postgres，pre-filter 就要靠該庫的 metadata filtering 能力，或自己在兩邊之間橋接，複雜度高很多。\n\n**多數情況 pre-filter 更對**，因為它同時保住了資安和檢索品質。這也是向量庫選型（第 4 篇）會直接影響到資安設計的具體例子——架構決策是會互相牽動的。\n\n## 權限繼承：別忘了 chunk 是文件的小孩\n\n一個容易漏的點：**chunk 的權限要繼承自它的來源文件**，而且文件權限變動時，chunk 的權限要跟著變。\n\n- 一份文件從「全公司可看」改成「僅限主管」，它底下所有 chunk 的權限**當下就要同步**，不能等下次重建索引。\n- 文件搬到另一個權限不同的資料夾 / 空間，繼承關係要重算。\n\n這意味著你的權限 metadata 不能是「ingestion 當下複製一份就不管了」，而要能反映**來源的即時權限狀態**。實務上常見的做法是：chunk 上存的是「指向來源權限」的參照（例如文件 ID / 資料夾 ID），檢索時即時 join 當前權限，而不是把權限值固化在 chunk 上。\n\n這裡還有個更陰險、幾乎所有 PoC 都漏掉的角落：**權限的「收回」遠比「授予」難守。** 授予錯了頂多是延遲讓人看到該看的；收回沒收乾淨，就是把已經不該看的東西繼續餵出去。而且別只盯著向量庫——一個剛被移除權限的人，他半小時前那輪對話的 context、你為了省 token 做的 prompt cache、甚至下游的對話記憶（第 7 篇），都可能還留著他現在無權看的內容。**權限變更要追到所有「資料的影子」，不只是源頭那一份。** 這又是那句老話：LLM app 還是個 distributed system——只要有快取和非同步，就有一致性的時間差，而在資安場景，這個時間差是會出事的。\n\n## 多租戶隔離：最硬的那道牆\n\n如果你的 agent 服務多個租戶（不同客戶、不同子公司、不同事業群），那 pre-filter 不只是「過濾」，是**硬隔離**——A 租戶的查詢在任何情況下都不能碰到 B 租戶的任何一個向量。\n\n這種場景下，光靠 `WHERE tenant_id = ?` 有時不夠安心（一個 bug 就破功）。更強的做法是**物理隔離**：每個租戶獨立的 schema、獨立的 collection、甚至獨立的資料庫實例。隔離越硬，越不怕一行 SQL 寫錯就跨租戶外洩，代價是維運和成本上升。又是一個 trade-off：**隔離強度 vs 成本與複雜度**。\n\n而且 2026 各家向量庫已經把這層做成原生原語，不用你自己土炮：Pinecone 的 **namespace**（每次查詢只能打一個 namespace）、Qdrant 的 per-collection multitenancy 加 JWT-based access control、Weaviate 的 fine-grained RBAC、pgvector 則是直接掛 PostgreSQL 的 row-level security。實務上按敏感度分層：最敏感的用「一租戶一 collection / schema」的物理隔離，中敏感的用 payload / metadata filter 的邏輯隔離——但**最敏感那層永遠不要只靠查詢時的一個 filter 條件當邊界**。隔離要 defense in depth，別把租戶邊界全押在一行 SQL 上。\n\n## 來源歸屬與稽核：洩漏發生後，你查得到嗎\n\n就算你前面都做對了，企業還是會要求「可稽核」。所以：\n\n- 每次檢索，**記下這個使用者拿到了哪些 chunk、來自哪些文件**（呼應第 3 篇的來源引用、第 9 篇的 observability）。\n- 每個答案能回推「依據哪些來源」，而那些來源**當時這個使用者確實有權看**。\n- 萬一真的發生越權，audit log（第 11 篇）要能讓你回答：誰、何時、透過 agent、碰到了什麼不該碰的——以及為什麼權限過濾沒擋住。\n\n沒有這層紀錄，一旦出事你連「到底洩漏了什麼、影響多大」都說不清楚，那在企業裡是比洩漏本身更恐怖的處境。\n\n## 一張檢查表\n\n要上 production 的企業 RAG，這幾題你要答得出「是」：\n\n- [ ] 每個 chunk 都帶權限 metadata，且**繼承自來源文件的即時權限**？\n- [ ] 權限在**檢索層** pre-filter，而不是在 prompt 裡拜託模型？\n- [ ] 文件權限變動，向量的可見性**即時跟著變**？\n- [ ] 多租戶有**硬隔離**，不是只靠一個 WHERE 條件？\n- [ ] 每次檢索的「誰拿到了什麼」都**進 audit log**？\n\n## 小結\n\n權限感知檢索是把「會 demo 的 RAG」和「過得了資安、敢上 production 的 RAG」分開的那條線。它的核心就一句話：**不該被看到的，根本不該進到 context**——權限要在檢索層執行，不是在生成層許願。\n\n而你會發現，這一篇處處在回扣前面：它逼著 ingestion（第 3 篇）留好 metadata、逼著向量庫選型（第 4 篇）考慮權限好不好做、也預告了治理（第 11 篇）的 audit。這就是系統工程——沒有一個決策是孤立的。\n\n下一篇，我們從「讀」轉到「做」：**tool use 與 MCP**——當 agent 不只是查資料，而是能真的去操作你的內部系統時，那道更危險的邊界該怎麼劃。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"權限感知檢索：企業 RAG 最難的一關\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[向量資料庫與 embedding 策略](/blog/enterprise-ai-agent-vector-db-embedding-strategy)\n- 下一篇：《Tool use 與 MCP：讓 agent 安全操作外部系統》",
      "summary": "個人玩 RAG 不會遇到這個問題，但企業一定會撞牆：當不同權限的人問同一個 agent，它怎麼確保每個人只檢索得到自己有資格看的東西？拆解 pre-filter vs post-filter、權限繼承、機密分級、來源歸屬與多租戶隔離——這是把「能 demo」和「過得了資安」分開的那條線。",
      "image": "https://bobochen.dev/_astro/cover.BR4R_tyU.webp",
      "date_published": "2026-05-07T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "RAG",
        "資安",
        "權限控管",
        "企業導入",
        "資料治理"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-case-studies/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-case-studies/",
      "title": "實戰案例：我的四個產品",
      "content_text": "紙上談兵不如看真實案例。這篇拆解我身為 Solo Builder、邊上班邊做的四個產品——bobo-blog 部落格、cloud-on-academy 課程平台、course-forge CLI 工具、code-fossil YouTube 頻道——每個產品的 AI 使用紀錄、實際時間花費、關鍵技術決策與踩坑教訓，給想一個人做產品的你一份真實參照。",
      "content_html": "## 紙上談兵結束，四個 Solo Builder 產品的真實拆解\n\n前 12 章，我們走過了從點子驗證到 Micro SaaS 的完整流程。理論講了很多，框架也建了不少。\n\n但你心裡可能有個問題：「這些真的有人照著做過嗎？」\n\n有。就是我自己。\n\n這一章我要完整拆解我邊上班邊做的四個產品。這不是精心包裝的成功故事，而是真實的時間花費、真實的技術決策、真實的 AI 使用方式，還有真實的踩坑紀錄。\n\n每個產品我會告訴你：\n\n- **做了什麼**：產品概述與技術棧\n- **關鍵決策**：那些影響最大的選擇\n- **AI 怎麼用**：具體的使用場景和效果\n- **時間花費**：一個有正職的人實際的投入量\n- **收穫與教訓**：什麼做對了、什麼做錯了\n\n準備好了嗎？我們從第一個開始。\n\n---\n\n## 產品一：bobo-blog — 個人部落格\n\n### 概述\n\n| 項目     | 內容                                                       |\n| -------- | ---------------------------------------------------------- |\n| 類型     | 個人技術部落格                                             |\n| 技術棧   | Astro 6 + Cloudflare Workers + Tailwind CSS 4 + TypeScript |\n| 狀態     | 上線運行中                                                 |\n| 開發時間 | 初版約 2 個月，之後持續迭代                                |\n| 營收模式 | 內容行銷 → 導流到課程平台                                  |\n\n一個部落格，看起來是最「無聊」的產品。但對 Solo Builder 來說，部落格是所有產品的地基。它是你的 SEO 入口、信任基礎、和內容分發中心。\n\n### 關鍵決策：為什麼選 Astro 而不是 Next.js\n\n這個決定我花了不到 30 分鐘。回顧[第 3 章的技術選型原則](/blog/ai-solo-builder-tech-stack/)：\n\n| 考量           | Next.js               | Astro                             |\n| -------------- | --------------------- | --------------------------------- |\n| 內容型網站效能 | SSR/ISR，需要伺服器   | 靜態優先，零 JS 預設              |\n| 互動需求       | 內建 React            | Island Architecture，需要時才載入 |\n| 部署成本       | Vercel 免費方案有限制 | Cloudflare Pages 完全免費         |\n| 學習曲線       | 熟悉 React 即可       | 簡單的模板語法                    |\n| AI 友善度      | 高                    | 高（Astro 的寫法接近 HTML）       |\n| 建置速度       | 較慢                  | 極快                              |\n\n部落格 90% 是靜態內容。我不需要 React 的 hydration、不需要 server component、不需要 API route。Astro 的「零 JS 預設」正好符合需求——頁面載入快、Lighthouse 分數高、SEO 友善。\n\n需要互動的地方（暗色模式切換、搜尋功能），用 Astro 的 island architecture 局部載入就好。整個站的 JavaScript 可能不到 50KB。\n\n教訓是：不要因為「以後可能需要」而選更重的框架。你以後真的需要了，再加就好。Astro 支援 React、Vue、Svelte 的 island，隨時可以混搭。\n\n### 關鍵決策：為什麼選 Cloudflare Workers 而不是 Vercel\n\n這個決策跟[第 3 章「選生態系不選框架」](/blog/ai-solo-builder-tech-stack/)的原則直接相關。\n\n| 考量          | Vercel         | Cloudflare             |\n| ------------- | -------------- | ---------------------- |\n| 免費額度      | 100 GB 頻寬/月 | 無限靜態頻寬           |\n| Edge Function | 有限制         | Workers 10 萬次/天免費 |\n| 附加服務      | 需要外接       | D1、R2、KV 全家桶      |\n| 自訂域名      | 支援           | 支援（還能管 DNS）     |\n| 全球延遲      | 美國為主       | 300+ 邊緣節點          |\n\n決定性的因素是 **Cloudflare 是一個完整的生態系**。DNS、CDN、Workers、D1 資料庫、R2 儲存、KV 快取，全部在同一個平台，免費額度非常慷慨。\n\n我的部落格只是起點。後來的課程平台也部署在 Cloudflare，共用同一套工具鏈。如果我當初選了 Vercel，後面每個產品都要重新考慮部署平台。\n\n### AI 怎麼用\n\n在 bobo-blog 這個專案，AI 的使用場景出乎意料地多：\n\n**1. 內容生成管線**\n\n我用 Claude Code 建立了一套從大綱到完稿的寫作流程。不是讓 AI 幫我寫文章——而是用 AI 來加速「從想法到結構化草稿」的過程。\n\n傳統做法：開一個空白文件，從第一段開始寫，寫到一半覺得結構不對，重新來。一篇 3000 字的文章要花一整個週末。\n\nAI 加持做法：\n\n```text\n我要寫一篇關於 [主題] 的技術文章。\n目標讀者：[描述]\n文章長度：約 3000 字\n\n請幫我：\n1. 列出 5-7 個可能的切入角度\n2. 推薦最適合的結構（教學型 / 比較型 / 故事型）\n3. 為推薦的結構列出詳細大綱\n4. 每個段落的核心論點是什麼\n```\n\n拿到大綱之後，我自己填肉。AI 負責結構，我負責觀點和經驗。這樣一篇文章從一個週末壓縮到 2-3 小時。\n\n**2. 社群卡片自動生成**\n\n每篇文章都需要一張 Open Graph 社群分享圖。手動用 Figma 做一張要 20 分鐘。我用 AI 幫我寫了一個 TypeScript 腳本（`scripts/generate-social-cards.ts`），根據文章標題和標籤自動產生風格統一的社群卡片。\n\n**3. blog-images 半自動工具**\n\n文章裡的技術截圖需要統一的樣式和尺寸。我用 Claude Code 幫我寫了 `scripts/blog-images.ts`，結合 Playwright 做螢幕截圖自動化。\n\n**4. 元件開發加速**\n\n部落格的 UI 元件——GlassCard、MegaMenu、PostCard——每個都是先用 AI 生成骨架，再手動調整細節。特別是 Tailwind CSS 的 utility class 組合，AI 可以省掉大量查文件的時間。\n\n### 時間花費\n\n| 階段                                 | 時間投入               |\n| ------------------------------------ | ---------------------- |\n| 技術選型 + 架構設計                  | 1 天                   |\n| 基礎建設（布局、路由、樣式系統）     | 2 週（每週約 10 小時） |\n| 核心功能（文章渲染、暗色模式、搜尋） | 2 週                   |\n| 系列書功能、Mega Menu                | 2 週                   |\n| 內容生成工具鏈                       | 1 週                   |\n| 持續迭代                             | 每週 2-3 小時          |\n\n總共大約 2 個月做完初版，之後每週花 2-3 小時維護和寫新內容。\n\n---\n\n## 產品二：cloud-on-academy — GCP 認證課程平台\n\n### 概述\n\n| 項目     | 內容               |\n| -------- | ------------------ |\n| 類型     | 線上課程平台       |\n| 技術棧   | Astro + Cloudflare |\n| 狀態     | 上線運行中         |\n| 開發時間 | 課程內容持續產出中 |\n| 營收模式 | 付費課程           |\n\n這是我的第一個有直接營收的產品。在[第 2 章](/blog/ai-solo-builder-idea-validation/)我分享過它的驗證故事——繁中市場的 GCP 認證課程幾乎是空白。這個市場空白就是我的機會。\n\n### 驗證到上線的完整旅程\n\n**驗證階段（1 天）：** 在 PTT、iThome、技術社群搜「GCP 認證中文」，發現大量需求但幾乎沒有系統化的繁中資源。做了一個簡單的 Landing Page，一週內收到足夠的表單填寫。\n\n**MVP 階段（3 週）：** 不等課程「全部做完」就上線。先放前三堂課的內容，然後每週更新。\n\n這是我做對的最重要的事。\n\n### 關鍵決策：不等「完美」就上線\n\n傳統做法：花三個月準備完整的課程內容，錄影、剪輯、上字幕，全部做好才上線。\n\n我的做法：先做文字版課程，搭配程式碼範例和截圖。三堂課做好就公開預售。\n\n為什麼這樣做？\n\n1. **驗證付費意願比驗證內容品質更急迫。** 你的課程內容再好，沒人願意付費就是零。\n2. **早期學員的回饋比你自己的判斷更準確。** 第一批學員告訴我哪些章節太難、哪些太簡單、哪些他們根本不需要。\n3. **持續更新反而是賣點。** 「這門課一直在更新」對學員來說是正面的信號。\n\n### AI 怎麼用\n\n**1. 課程大綱設計**\n\n```text\n我要設計一門 GCP Associate Cloud Engineer 認證的線上課程。\n目標學員：有 1-3 年後端經驗的台灣工程師，準備考 ACE 認證。\n\n請幫我：\n1. 根據 Google 官方考試大綱，列出所有需要涵蓋的知識點\n2. 建議課程結構（模組、章節、順序）\n3. 每個章節建議的學習時間\n4. 標記哪些是「必考高頻」、哪些是「偶爾出現」\n5. 建議的實作練習\n```\n\nAI 生成的大綱不能直接用——它缺少「考試實戰」的洞察。但它幫我省了從零規劃的時間。我根據自己考過認證的經驗大幅修改，把「理論上應該教」和「考試真的會考」對齊。\n\n**2. 練習題生成**\n\n每個章節結尾的練習題，我用 AI 生成初稿，然後自己審核和修改。這是 AI 最適合的場景之一——生成大量結構化的內容，然後由人類把關品質。\n\n**3. 錯誤解說生成**\n\n學員最常問的問題是「這題為什麼答案不是 B」。我用 AI 幫每個錯誤選項寫解說——為什麼看起來合理但實際上不對。這種「逆向解說」非常耗時，但 AI 做得又快又好。\n\n### 時間花費\n\n| 階段                    | 時間投入               |\n| ----------------------- | ---------------------- |\n| 市場驗證 + Landing Page | 1 天                   |\n| 平台建設（Astro 搭建）  | 1 週                   |\n| 前三堂課內容            | 2 週（每週 8-10 小時） |\n| 上線並開始收費          | 第 3 週                |\n| 持續更新課程內容        | 每週 5-8 小時          |\n\n### 教訓\n\n最大的教訓是我花太多時間在平台建設上。第一版其實用 Notion + Gumroad 就可以賣了。我不需要自己建課程平台，但工程師的本能就是想自己做。\n\n如果重來一次，我會先用現成工具驗證（Notion 做內容、Gumroad 收費），確認有穩定需求之後再建自己的平台。\n\n---\n\n## 產品三：course-forge — 內容自動化 CLI 工具\n\n### 概述\n\n| 項目     | 內容                 |\n| -------- | -------------------- |\n| 類型     | CLI 工具             |\n| 技術棧   | TypeScript + Node.js |\n| 狀態     | 開發中               |\n| 開發時間 | 持續迭代             |\n| 營收模式 | 開源 → 顧問諮詢導流  |\n\ncourse-forge 是一個完全不同類型的產品。它不是面向終端用戶的 SaaS，而是一個我自己先用、順便開源的 CLI 工具。\n\n### 為什麼要做一個自己用的工具\n\n做 bobo-blog 和 cloud-on-academy 的過程中，我反覆遇到同樣的問題：\n\n- 寫一篇技術文章需要：大綱、內文、程式碼範例、截圖、社群卡片、SEO metadata\n- 做一堂課需要：大綱、課程內容、練習題、投影片\n- 每次都是手動一步一步來，流程重複但又沒有完全自動化\n\ncourse-forge 就是把這些重複的內容產製流程打包成一個 CLI。\n\n```bash\n# 從大綱生成部落格文章骨架\ncourse-forge blog generate --outline ./outline.yaml\n\n# 從課程大綱生成練習題\ncourse-forge quiz generate --chapter 3\n\n# 批次生成社群卡片\ncourse-forge social-cards --posts ./src/content/blog/\n```\n\n### 關鍵決策：CLI 而不是 Web UI\n\n我一開始有考慮做 Web UI。一個漂亮的 Dashboard，可以拖拉管理內容管線。\n\n但我很快放棄了這個想法，原因是：\n\n| 考量         | Web UI             | CLI                        |\n| ------------ | ------------------ | -------------------------- |\n| 開發時間     | 數週到數月         | 數天到數週                 |\n| 維護成本     | 前端 + 後端 + 部署 | 單一 npm package           |\n| 彈性         | 被 UI 限制         | 可以串進任何 pipeline      |\n| AI 整合      | 需要額外設計       | 直接在 terminal 跟 AI 互動 |\n| 我自己會用嗎 | 可能不會（太慢）   | 每天都在用                 |\n\n最後一點最關鍵。我是工程師，我的工作流程在 terminal。做一個我自己不會日常使用的工具，根本就是在浪費時間。\n\n原則就是：先做你自己會天天用的版本。如果連你自己都不想用，別人更不會想用。\n\n### AI 怎麼用\n\ncourse-forge 本身就是一個 AI 增強的工具，所以「AI 怎麼用」在這裡有雙重含義：\n\n**開發層面：** 整個 CLI 框架是用 Claude Code 搭建的。我描述每個 command 的行為，AI 生成初版程式碼，我 review 和修改。TypeScript CLI 是 AI 非常擅長的領域——Commander.js 的 API 很直覺，AI 生成的品質很高。\n\n**產品層面：** course-forge 的核心功能就是串接 AI API 做內容生成。例如「從大綱生成文章骨架」這個功能，背後就是呼叫 Claude API，帶上一組精心設計的 prompt template。\n\n我把自己在寫文章時反覆使用的 prompt 封裝成工具的一部分。這樣每次用的時候不需要重新組裝 prompt。\n\n### 時間花費\n\n| 階段                         | 時間投入              |\n| ---------------------------- | --------------------- |\n| 需求整理（從自己的痛點歸納） | 2 小時                |\n| CLI 骨架搭建                 | 1 天                  |\n| 核心 command 實作            | 1 週（每週 6-8 小時） |\n| prompt template 調校         | 持續進行              |\n| 文件撰寫 + 開源準備          | 3 天                  |\n\n### 教訓\n\n開源專案的「最小可用版本」比你想的還小。我一開始想把所有功能都做好再開源，結果拖了很久。後來我只放了兩個 command 就 push 到 GitHub，然後慢慢加功能。\n\n先公開、再完善。沒人期待一個開源 CLI 工具在第一版就完美。\n\n---\n\n## 產品四：code-fossil — YouTube 頻道品牌\n\n### 概述\n\n| 項目     | 內容                              |\n| -------- | --------------------------------- |\n| 類型     | YouTube 頻道 / 媒體品牌           |\n| 技術棧   | Remotion（影片）、AI（研究/腳本） |\n| 狀態     | 經營中                            |\n| 開發時間 | 持續產出                          |\n| 營收模式 | YouTube 廣告收入 + 品牌建立       |\n\ncode-fossil 是四個產品中唯一不是 SaaS 的。它是一個 YouTube 頻道品牌，定位是「軟體考古學家」——挖掘那些被遺忘的技術史故事。\n\n為什麼一個 Solo Builder 要做影片內容？因為媒體品牌是所有產品的流量來源。\n\n部落格靠 SEO 帶來穩定但慢速的流量。YouTube 頻道可以帶來爆發式的關注。兩者互補。\n\n### 關鍵決策：雙頻道策略\n\n我同時經營兩個頻道：\n\n- **Bobo 柏宏**：面向台灣工程師，分享職涯和技術觀點，用中文\n- **Code Fossil**：面向全球觀眾，講軟體歷史故事，用英文\n\n為什麼不合成一個？因為受眾完全不同。中文觀眾想看的是「GCP 認證怎麼準備」，英文觀眾想看的是「為什麼 PHP 被誤解了 20 年」，硬塞在同一個頻道只會兩邊都不討好。\n\n| 考量         | 單一頻道       | 雙頻道                     |\n| ------------ | -------------- | -------------------------- |\n| 內容一致性   | 混亂           | 各自清晰                   |\n| 觀眾預期     | 難管理         | 容易滿足                   |\n| 演算法友善度 | 低（主題跳躍） | 高（主題集中）             |\n| 工作量       | 看似省力       | 實際上更高效（素材可複用） |\n| 交叉導流     | 不適用         | 互相引流                   |\n\n兩個頻道可以交叉導流：Code Fossil 的觀眾可能對我的技術觀點有興趣，Bobo 柏宏的觀眾可能對軟體歷史有興趣。一加一大於二。\n\n### AI 怎麼用\n\n影片製作是 AI 使用密度最高的產品：\n\n**1. 研究階段**\n\n```text\n我要做一支影片，主題是「[技術歷史主題]」。\n\n請幫我研究：\n1. 時間線：關鍵事件的年份和背景\n2. 人物：核心開發者是誰、他們的背景\n3. 技術細節：為什麼當時做了這個設計決策\n4. 有趣的軼事或衝突\n5. 現代的影響：這個決策如何影響今天的技術\n```\n\n一個影片的研究量可能需要讀 10-20 篇文章和論文。AI 幫我彙整資料，我再去源頭驗證關鍵事實。\n\n**2. 腳本撰寫**\n\n研究完成後，我用 AI 生成腳本的結構草稿。但最終版本一定是我自己重寫——因為影片腳本需要個人語氣和節奏感，這是 AI 目前還做不好的。\n\n**3. 影片描述和 SEO**\n\n每支影片的描述、標籤、時間戳——這些「必要但無聊」的工作全部交給 AI。\n\n**4. 縮圖概念發想**\n\n```text\n這支影片的標題是「[標題]」。\n請提供 5 個 YouTube 縮圖的概念，考慮：\n- 點擊率最大化\n- 與標題的搭配\n- 視覺對比和可讀性\n```\n\n### Remotion：用程式碼做影片\n\n值得一提的是技術選型。我用 Remotion 做部分影片——它是一個用 React 寫影片的框架。\n\n為什麼不用 Premiere Pro？因為：\n\n- 我不擅長影片剪輯軟體\n- 但我很擅長寫程式碼\n- Remotion 讓我用 TypeScript + React 來定義動畫和轉場\n- AI 可以幫我生成 Remotion 的程式碼\n- 同一套模板可以批次生成不同內容的影片片段\n\nSolo Builder 原則再現：選你最擅長的工具，而不是「業界標準」的工具。\n\n### 時間花費\n\n| 項目               | 每支影片時間投入    |\n| ------------------ | ------------------- |\n| 研究 + 資料收集    | 2-3 小時（AI 輔助） |\n| 腳本撰寫 + 修改    | 2-3 小時            |\n| 影片製作 + 剪輯    | 3-4 小時            |\n| 縮圖 + 描述 + 上傳 | 1 小時（AI 輔助）   |\n| **每支影片合計**   | **8-11 小時**       |\n\n以每兩週產出一支影片的頻率，每週投入約 4-6 小時。\n\n---\n\n## 四個產品的共同模式\n\n回頭看這四個產品，有幾個反覆出現的模式：\n\n### 模式 1：全部用同一套技術棧\n\n四個產品都用 TypeScript。部落格和課程平台都用 Astro + Cloudflare。CLI 工具是 Node.js + TypeScript。連影片都用 Remotion（React + TypeScript）。\n\n這不是巧合。**一種語言打天下**的好處在這裡完全體現——在 A 產品學到的東西，可以直接用在 B 產品。\n\n> 但這招有代價，我得老實講。把四個產品全押在 TypeScript + Astro + Cloudflare，本質上是 all-in 同一個生態系——哪天 Cloudflare 漲價、Astro 大改版、或某個服務說收就收，我是四條產線一起中槍，不是分散風險。技能樹也只長一邊：我這幾年沒被逼著去碰 Go、Rust、或別人的雲，職涯廣度其實是縮的。對「一個人」來說，集中下注的脆弱反而比團隊更高，因為沒有別人替你補另一個生態系的洞。\n>\n> 所以「選生態系不選框架」對我是預設，不是鐵律。兩種情況我會故意破例：一是某個產品的需求明顯衝出 Astro/Workers 的甜蜜區（要長時間跑的後端任務、重 SSR、複雜 stateful 服務），硬塞只是自找麻煩；二是我想拿 side project 來練新東西——這時候「不能複用舊知識」剛好是重點，不是缺點。有正職、求快、求複用時統一技術棧很划算；想擴展能力或場景不對時，該換就換。\n\n### 模式 2：AI 用在「結構化」的工作上\n\n四個產品中，AI 發揮最大價值的都是結構化的工作：大綱生成、練習題生成、研究彙整、metadata 產出。\n\n而最核心的「創意判斷」——文章的觀點、課程的教學邏輯、影片的敘事節奏——都是我自己做的。\n\nAI 處理 80% 的苦工，你專注 20% 的判斷。這個比例在每個產品都一樣。\n\n### 模式 3：先上線、再完善\n\n四個產品沒有一個是「完成」了才上線的：\n\n- bobo-blog：先有基本版面和三篇文章就上線\n- cloud-on-academy：三堂課就開始收費\n- course-forge：兩個 command 就推上 GitHub\n- code-fossil：第一支影片品質遠不如現在\n\n先 ship，再完美。這是[第 1 章 Solo Builder 宣言](/blog/ai-solo-builder-manifesto/)的核心，也是我每個產品都遵循的原則。\n\n### 模式 4：產品之間互相餵養\n\n這四個產品不是獨立的——它們形成一個生態系：\n\n```text\nbobo-blog（部落格）→ SEO 流量 → cloud-on-academy（課程）→ 營收\n     ↑                                    |\n     |                                    ↓\ncode-fossil（YouTube）→ 品牌認知     course-forge（工具）→ 提升生產力\n```\n\n部落格的文章為課程平台導流。YouTube 頻道建立品牌認知度。course-forge 提升所有內容的生產效率。每個產品都在強化其他產品。\n\n**Solo Builder 的最佳策略不是做一個很大的產品，而是做幾個互相加分的小產品。**\n\n> 在你把這四個模式抄進筆記本之前，我得先承認一件事：這是 n=1，全部是我一個人、一條路徑跑出來的，而且這些「模式」是我**回頭看**才整理出來的事後敘事——倖存者偏誤的味道很重。我沒辦法給你對照組：照這樣做卻失敗的人有多少，我不知道，因為他們不會寫這種文章。\n>\n> 給你幾個誠實的數字校準期待：cloud-on-academy 是唯一有直接營收的，但離「靠它過活」還早得很；code-fossil 的 YouTube 訂閱到現在還在三位數；course-forge 還掛在「開發中」，沒有外部使用者。所以這篇請當成「一個人這樣安排還算順手」的紀錄，不是「照做就會成功」的保證。前面那句「這是我做對的最重要的事」，更準確的講法是「回頭看，這件事我沒做錯」——做對和沒做錯之間，差的就是那個我給不出來的對照組。\n\n---\n\n## 我犯過的錯 & 如果重來一次\n\n### 錯誤 1：太早自建平台\n\ncloud-on-academy 一開始就自己用 Astro 建了課程平台。如果重來，我會先用 Notion + Gumroad 賣前三堂課，確認付費意願後再自建。省下的兩週可以多寫五堂課。\n\n### 錯誤 2：過度設計部落格功能\n\nbobo-blog 初版花了太多時間在 Mega Menu、系列書側邊欄等進階功能。第一版只需要「能顯示文章」就好。那些花俏的功能可以慢慢加。\n\n### 錯誤 3：完美主義延遲了 course-forge 的開源\n\n我想等功能更完整再開源，結果拖了好幾週。早點開源、早點收到回饋，比在角落裡默默打磨更有價值。\n\n### 錯誤 4：低估了影片的時間投入\n\ncode-fossil 剛開始時，我以為每支影片 4-5 小時可以搞定。實際上是 8-11 小時。如果你時間真的很緊，文字內容（部落格、電子報）的 ROI 遠高於影片。\n\n### 如果重來一次的優先順序\n\n1. **先做部落格**（最低成本、最快上線、SEO 是長期資產）\n2. **邊寫文章邊驗證課程需求**（部落格文章就是課程的 beta 版內容）\n3. **確認有人付費後再建課程平台**（先用現成工具賣）\n4. **自動化工具在第三個月之後才開始做**（先手動跑完流程，確認哪些步驟值得自動化）\n5. **YouTube 放到最後**（時間投入最高、回報週期最長）\n\n---\n\n## 本章重點回顧\n\n- 🏗️ 四個產品全部用同一套技術棧（TypeScript + Astro + Cloudflare），最大化知識複用\n- 🤖 AI 在每個產品都用在「結構化的苦工」上——大綱、練習題、研究、metadata——而不是核心判斷\n- 🚀 每個產品都是「先上線再完善」，沒有一個是等到完美才公開的\n- 🔄 四個產品互相餵養，形成生態系而不是獨立的 side project\n- ⚠️ 最大的教訓：不要太早自建、不要過度設計、不要追求完美才上線\n- ⏱️ 有正職的 Solo Builder，優先做「時間投入低、長期回報高」的產品（部落格 > 課程 > 工具 > 影片）\n\n## 下一步\n\n看完了真實案例，接下來是全書的最後一章。\n\n我為你準備了一份 **Solo Builder Checklist**——從點子驗證到上線營運，一份可操作的檢查清單。不管你的產品在哪個階段，都可以用這份清單來檢查你有沒有遺漏什麼關鍵步驟。\n\n👉 [第 14 章：Solo Builder Checklist——你的產品及格了嗎](/blog/ai-solo-builder-checklist)",
      "summary": "紙上談兵不如看真實案例。這篇拆解我身為 Solo Builder、邊上班邊做的四個產品——bobo-blog 部落格、cloud-on-academy 課程平台、course-forge CLI 工具、code-fossil YouTube 頻道——每個產品的 AI 使用紀錄、實際時間花費、關鍵技術決策與踩坑教訓，給想一個人做產品的你一份真實參照。",
      "image": "https://bobochen.dev/_astro/cover.1EhENxCq.webp",
      "date_published": "2026-05-03T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "案例分析",
        "AI",
        "實戰經驗",
        "產品開發"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-vector-db-embedding-strategy/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-vector-db-embedding-strategy/",
      "title": "向量資料庫與 embedding 策略：先別急著上 Pinecone，pgvector 可能就夠了",
      "content_text": "向量庫選型是 RAG 的地基，也是最容易過度工程的地方。pgvector vs 專用向量庫（Pinecone/Qdrant/Vectorize）怎麼選？embedding 模型與維度怎麼挑？什麼時候需要 hybrid search？HNSW 和 IVF 差在哪？用一張決策表和一個「先用 Postgres」的務實建議，幫你把地基打對。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 4 篇（共 12 篇）。上一篇：[RAG 架構實戰](/blog/enterprise-ai-agent-rag-architecture)。\n\n上一篇講完 RAG 的 pipeline，這一篇挖它的地基：**向量資料庫**和 **embedding 策略**。這兩個是綁在一起的選型題，而且是最容易一上來就過度工程的地方——很多團隊第一句話就是「我們要上 Pinecone」，但其實連資料量級都還沒搞清楚。\n\n我先把結論放最前面，然後再講為什麼：\n\n> 對大多數企業 RAG，先用 **PostgreSQL + pgvector**。等你真的撞到它的牆，再考慮專用向量庫。那堵牆比你想像中遠。\n\n## 為什麼先選 pgvector\n\n`pgvector` 是 PostgreSQL 的一個 extension，讓你的 Postgres 直接能存向量、做相似度檢索。我會推薦從它開始，理由很實際：\n\n**1. 你的資料和向量住在一起。**\n企業 RAG 最難的不是檢索，是**權限**（第 5 篇的主題）。當你的向量和原本的業務資料（誰能看哪份文件、文件屬於哪個部門、機密等級）住在同一個 Postgres 裡，你可以**用一句 SQL 的 WHERE 同時做相似度檢索和權限過濾**。如果向量在 Pinecone、權限在 Postgres，你就要在兩個系統之間自己對權限，這是 bug 和資安漏洞的溫床。\n\n不過這句 WHERE 有個對企業權限過濾特別致命的細節：**pgvector 做的是 post-filtering**——先用 HNSW 取回 `ef_search` 個候選，再套 WHERE 過濾，不是 native pre-filtering。當權限很「選擇性」（例如某人只看得到全庫 2% 的文件），HNSW 撈回的候選很可能在 WHERE 之後幾乎被刷光，導致 recall 暴跌、甚至回傳不足——也就是「他其實有權看的相關文件，因為不在那批候選裡而檢索不到」。pgvector 0.8 的 iterative index scan 是緩解，但仍是 workaround。這正是第 5 篇權限感知檢索要正面處理的坑，也是 Qdrant 那種 native pre-filtering 相對 pgvector 真正有優勢的地方。**能用一句 SQL 過濾權限是真的，但「零成本」是假的。**\n\n**2. 你已經會維運它。**\nPostgres 的備份、replication、監控、調校，是幾十年的成熟知識，你的團隊大概也已經會了。多一個專用向量庫，就是多一個要學、要維運、要付錢、要監控的 stateful 系統。對小團隊（第 12 篇會談），少一個要顧的東西就是省一條命。\n\n**3. 它的牆很遠。**\npgvector 加上 HNSW index，撐到百萬級向量、合理的延遲，對絕大多數企業內部知識庫綽綽有餘。你真的要到千萬、上億向量、極端低延遲、超高 QPS，才會開始需要專用向量庫的本事。\n\n先給一組數字讓「牆很遠」不只是口號：pgvector 0.8 + HNSW，100k 向量 p50 約 2ms、p99 約 4ms；2M 向量 p50 約 11ms、p99 約 22ms（recall 都在 0.95 以上）。對照 RAG 裡 LLM 本身一次呼叫就要 800–2000ms，向量檢索這層根本不是瓶頸。但這個好延遲有個前提，也是 pgvector 在 production 最常見的翻車點：**HNSW index 要裝得進記憶體**；一旦資料量讓 index 超過 `shared_buffers`、被 evict 到 disk，p99 會從個位數 ms 跳到數十甚至上百 ms——所以容量規劃真正要看的不是「幾筆向量」，是「index 大小 vs RAM」。而就算撞到這道記憶體牆，也先別急著跳 Pinecone：**pgvectorscale**（Timescale / Tiger Data 出的擴充）提供 StreamingDiskANN index，把圖存到 SSD、記憶體跟資料量脫鉤，直接解掉「HNSW 吃記憶體」這個痛點，公開 benchmark 在 5000 萬筆向量這個量級已能在成本與延遲上勝過 Pinecone。換句話說：**百萬級用純 pgvector，要往千萬到低數億推就先加 pgvectorscale，真到「十億級＋極端讀並發」再考慮專用庫**——那堵牆比我原本寫的還遠一截。\n\n什麼時候**該**上專用向量庫（Pinecone、Qdrant、Weaviate、Milvus，或 serverless 的 Cloudflare Vectorize）？\n\n- 向量量級很大（千萬以上）且要極低延遲\n- 你需要它特有的功能（某些 metadata filtering、multi-tenancy 的原生支援）\n- 你的架構本來就 serverless / edge，配 Vectorize 這類比自己顧一台 Postgres 更省事\n\n與其把它們含糊歸成一組「專用向量庫」，2026 的市場其實有清楚分工：**Milvus / Zilliz** 是真・十億級企業規模的代表；**Qdrant**（Rust 寫的）強在高效能的 filtered search 加上最靈活的 multi-tenancy / sharding——剛好是企業權限場景的首選；**Weaviate** 主打內建 hybrid search；**Pinecone** 是全託管 serverless 的開創者，零容量規劃但較貴。至於決策表裡推薦給 serverless 的 **Cloudflare Vectorize**，選之前要先確認一個硬限制：**單一 index 上限 1000 萬向量**（2026-01 才從 500 萬翻倍）、維度上限 1536。它適合「資料量在數百萬以內、而且本來就跑在 CF Workers / Pages」的場景；接近千萬就得靠多 index / namespace 拆，別讓它跟你決策表裡「千萬就該換庫」那條自己打架。\n\n### 決策表\n\n| 情境 | 建議 | 為什麼 |\n|---|---|---|\n| 企業內部知識庫、向量 &lt; 百萬 | **pgvector** | 權限好做、維運成本低、夠快 |\n| 已經在 Postgres 上、想快速試 RAG | **pgvector** | 不必多養一個系統 |\n| Serverless / Cloudflare 架構 | **Vectorize** | 跟既有 edge 架構同源 |\n| 千萬級向量、極低延遲、高 QPS | **專用向量庫** | pgvector 開始吃力 |\n| 需要原生 multi-tenant 隔離 | **專用向量庫** | 某些庫對此支援較成熟 |\n\n這個「先用你已經有的資料庫」的思路，其實跟我一貫的取捨一致——我寫過不少 PostgreSQL、MySQL 的東西，每次的心得都是：**新工具要先證明它解決的問題，是你既有工具真的解不了的，而不是因為它比較潮**。\n\n## Embedding 模型：一個會綁死你的決定\n\nEmbedding 模型負責把文字變成向量。它的選擇牽涉幾個取捨，而且有一個很現實的約束：\n\n> **一旦你用某個 embedding 模型把整個知識庫向量化了，要換模型就等於整庫重算。**\n\n所以這不是隨手挑的決定。要考量：\n\n**1. 維度（dimension）。**\nembedding 向量的長度。維度高，理論上能表達更細的語意，但**每一筆都更佔空間、檢索更慢、index 更大**。維度低則相反。不是越高越好——要對應你的資料複雜度和規模。很多場景中等維度就夠，盲目追高維度只是讓成本上升。\n\n這裡有個 2026 的好消息能把「不是越高越好」從原則變成工具：2024 後主流模型（OpenAI 的 text-embedding-3、Qwen3-Embedding 等）多用 **Matryoshka representation learning（MRL）** 訓練——同一個向量可以「截斷」到較短維度仍保有大部分檢索品質。意思是你可以**存高維、查短維**，依成本和延遲動態取捨，不必一開始把維度賭死。官方數據夠說服力：text-embedding-3-large 截到 256 維，MTEB 表現仍勝過舊的 ada-002 在 1536 維。pgvector、Qdrant 都支援這種維度截斷。\n\n**2. 領域契合度。**\n通用 embedding 模型對一般語意很好，但如果你的資料充滿專業術語（半導體製程、醫療、法律、特定產品料號），通用模型可能抓不準那些術語之間的關係。這時候要嘛選領域更貼的模型，要嘛用 hybrid search（下面講）補。\n\n**3. 多語言。**\n台灣企業常常中英夾雜，文件裡專有名詞是英文、敘述是中文。要確認你選的 embedding 模型對中英混合的處理夠好——這點一定要用你**自己的真實資料**測，不要看 benchmark 數字就信。\n\n「要測」之後總得知道「先測哪幾個」。2026 中英 / 多語的開源實務首選蠻明確，可以從這兩個開始：**BGE-M3**（100+ 語言，原生支援 dense + sparse + multi-vector——它的 sparse 輸出剛好能直接餵上面講的 hybrid search）和 **Qwen3-Embedding** 系列（0.6B / 4B / 8B，MMTEB 多語榜領先，還支援前面提的彈性維度）。但這只是起跑點，最終仍以你自己的中英混合資料為準。順帶一提，這兩個都是開源權重，要完全不出境就自架，剛好接到下面的「自架 vs API」。\n\n**4. 自架 vs API。**\n用 OpenAI / Cohere 之類的 embedding API 方便，但**你的文件內容會送出去**。「會送出去」是真的，但別把它講成「直接觸法」——更精準的說法是：**這是資料治理與（個資）跨境、合約風險**，是否觸法取決於你的資料分級、產業法規（醫療 HIPAA、個資跨境）和有沒有簽約。實務上主流 API 的預設政策沒那麼可怕（預設不拿輸入訓練模型、僅短期保留），企業還能簽 DPA、申請 Zero Data Retention、或用 Azure OpenAI 做 region pinning 來緩解。但對最敏感的資料，最乾淨的解法仍是自架——2026 的 BGE-M3、Qwen3-Embedding 都是開源權重，可在自家 GPU / VPC 跑、資料完全不出境。這是資料治理（第 11 篇）會回來的議題。\n\n## Hybrid Search：語意之外，字面也要中\n\n純向量檢索（語意相似）有個盲區：**它對「必須字面精準命中」的東西不可靠**。使用者問「錯誤碼 E-1043 怎麼解」，語意檢索可能撈回一堆「跟錯誤排除有關」但不是 E-1043 的東西。\n\n解法是 hybrid search：把**向量檢索（dense，語意）和關鍵字檢索（sparse，BM25，字面）合起來**，兩邊的結果用一個加權（或 reciprocal rank fusion）合併。\n\n- 料號、錯誤碼、人名、API 名稱這種**精準字面** → 關鍵字救場\n- 「怎麼讓系統跑更快」這種**模糊語意** → 向量救場\n\n企業資料常常兩種都有，所以 hybrid search 在 production RAG 幾乎是標配。pgvector 也能跟 Postgres 的全文檢索（或 `tsvector`）組成 hybrid，不一定要專用庫。\n\n不過這裡要拆穿一個很多人沒注意的細節：**Postgres 原生的 `tsvector` / `ts_rank` 不是 BM25**。它沒有 IDF、沒有 term-frequency 飽和、也沒有用文件長度做正規化——而這三件事正是 BM25 的精華。所以 `pgvector + tsvector` 確實能組出語意＋字面的 hybrid，但它的字面排序品質明顯遜於真 BM25。想在 Postgres 跑真 BM25，得另外裝擴充：ParadeDB 的 `pg_search`（底層 Tantivy）、VectorChord-BM25，或 TigerData 2026 推的 `pg_textsearch`。值不值得多裝一個擴充，看你的料號、錯誤碼這類字面命中有多重要。兩路結果的合併同樣建議用 **RRF（reciprocal rank fusion，k=60）**，用排名而非分數，免去 BM25 與向量分數量綱不一的麻煩。\n\n## Index：HNSW vs IVF，你至少要知道在選什麼\n\n向量檢索如果每次都跟全部向量算距離（暴力檢索），量一大就爆。所以要建 index 做近似最近鄰（ANN）。兩個最常見的：\n\n- **HNSW**（圖結構）：檢索快、召回率高，但**建 index 慢、記憶體吃得兇**。適合「寫入不那麼頻繁、但要查得快」的知識庫——大多數 RAG 屬於這類。\n- **IVF**（分群）：建得快、省記憶體，但別把它的品質落差想成「平滑地略遜」——它更像「沒調好就斷崖」。IVFFlat 預設 `probes=1`（只搜最近的一群）召回會很差，要把 `probes` 調到約 10–100 才平衡；而且向量分布不均勻、群聚或稀疏時（企業文件很常是這樣）recall 會再掉一截。更實際的是 pgvector 的 IVFFlat **不能建在空表上**，得等資料進來、有足夠樣本才能算分群中心，資料大幅成長後分群還會走樣、要重建。HNSW 沒有這個訓練步驟，空表就能建、邊插邊長，預設參數通常就有 95%+ recall——對「持續 ingest」的知識庫省事得多。\n\n對大部分企業 RAG，**HNSW 是預設好選擇**，因為知識庫通常是「偶爾更新、大量查詢」。但要記得它吃記憶體——這會回到你的成本（第 10 篇）。\n\n> 重點不是背這兩個的細節，是知道「向量檢索是近似的、有 index 參數可調、調它是在拿召回率換速度和記憶體」。這又是一個 trade-off，不是一個正確答案。\n\n既然講到「有參數可調」，就把那幾顆旋鈕的名字交給你：HNSW 上線後主要調 `hnsw.ef_search`（預設 40，可往上調到 1000，越大召回越高但越慢——**而且它是唯一不用重建 index 就能即時拉 recall 的旋鈕**，production 救火很關鍵）；建 index 時影響品質的是 `m`（預設 16）和 `ef_construction`（預設 64）。IVFFlat 則是調 `lists`（建索引時分幾群）和 `probes`（查詢時搜幾群）。\n\n## 資料新鮮度：別讓庫裡躺著過期的真相\n\n最後一個 production 才會痛的問題：文件會改，向量庫要跟著更新。\n\n- **增量更新**：文件異動時，只重新處理那幾份（重新 chunk + embedding + upsert），而不是整庫重建。需要你在 ingestion 時記好每份文件的版本 / hash。\n- **刪除與失效**：文件下架了，對應的向量也要刪，不然 agent 會引用一份已經不存在的「真相」。\n- **重新 embedding 的成本**：如果哪天真要換 embedding 模型，整庫重算的時間和錢要先算進去——這也是為什麼一開始就要選對。不過這條「換模型＝整庫重算」的鐵律，到 2026 也能鬆一點綁：出現了一類「embedding migration adapter」做法——訓一個輕量轉換層把新模型映射回舊向量空間、沿用既有 index，代表性的 Drift-Adapter（EMNLP 2025）宣稱能回復 95–99% 的召回、成本比整庫重建低兩個數量級。**但這是有損近似**，追求最高品質仍會老實重算——所以「選對」還是重要，只是換模型不再是非死即生的局。\n\n## 小結\n\n向量庫和 embedding 是 RAG 的地基，但「地基」不代表要用最貴的建材。務實的順序是：\n\n1. **先 pgvector**，讓向量和權限資料住一起、少養一個系統。\n2. **embedding 模型用自己的真實資料測**，特別是中英混合和機密外送問題。\n3. **上 hybrid search**，補語意檢索的字面盲區。\n4. **HNSW index** 當預設，但盯著它的記憶體成本。\n5. **設計好增量更新**，別讓庫裡躺著過期的真相。\n\n選對地基，下一篇我們要處理企業 RAG 最難、也最容易被略過的一關——**權限感知檢索**：當不同人問同一個 agent，它怎麼確保每個人只檢索得到自己有資格看的東西。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"向量資料庫與 embedding 策略：先別急著上 Pinecone\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[RAG 架構實戰](/blog/enterprise-ai-agent-rag-architecture)\n- 用 Cloudflare Vectorize 與 AI Gateway 打造 RAG——一個具體的向量檢索實作\n- 下一篇：《權限感知檢索：企業 RAG 最難的一關》",
      "summary": "向量庫選型是 RAG 的地基，也是最容易過度工程的地方。pgvector vs 專用向量庫（Pinecone/Qdrant/Vectorize）怎麼選？embedding 模型與維度怎麼挑？什麼時候需要 hybrid search？HNSW 和 IVF 差在哪？用一張決策表和一個「先用 Postgres」的務實建議，幫你把地基打對。",
      "image": "https://bobochen.dev/_astro/cover.BypHQHoc.webp",
      "date_published": "2026-05-03T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "向量資料庫",
        "pgvector",
        "Embedding",
        "RAG",
        "資料庫"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-building-agents/",
      "url": "https://bobochen.dev/blog/claude-api-guide-building-agents/",
      "title": "打造你的第一個 Agent：工具、狀態與循環",
      "content_text": "Agent 生命週期完整解析（真實 agentic loop）；用 @beta_tool 與手寫 JSON 定義工具；工具回傳值設計；用 messages 歷史傳遞記憶；max_iterations 與 stop_reason 停止條件；Python 完整 Research Agent（tool_runner 可跑版）；常見陷阱（無限循環、token 爆炸）；Debug 技巧；單元測試與 mock client 策略。",
      "content_html": "上一章我們了解了「用 Claude 建 Agent」的核心概念。這一章我要帶你從頭到尾打造一個真實可用的 Agent。\n\n不是玩具，是一個能搜尋網路、查詢資料庫、生成結構化報告的 Research Agent。我會把每個設計決策都解釋清楚，包括我踩過的坑。\n\n> 先講一句老實話：Anthropic 沒有「Agent / Runner / Handoff」那種類別。**真實的做法是用官方 `anthropic`（Python）/ `@anthropic-ai/sdk`（TS）SDK 的 tool runner，或自己手寫一個 agentic loop。**我這一章兩種都會教，全部都是可以跑的真實程式碼。口語上我還是會叫它「Agent」，但你心裡要清楚：那只是「帶 system prompt + 一組工具的 Claude 呼叫，跑在一個循環裡」。\n\n準備好了嗎？\n\n## Agent 的生命週期\n\n理解 Agent 怎麼運作，是寫出好 Agent 程式碼的前提。把那層抽象拆開來看，所謂的 agentic loop 其實就是這樣的一段對話循環：\n\n```\nuser 訊息\n    │\n    ▼\n┌───────────────────────────────────────────┐\n│            Agentic Loop                    │\n│                                            │\n│  1. client.messages.create(messages, tools)│\n│            │                               │\n│            ▼                               │\n│  2. Claude 回應                            │\n│            │                               │\n│      ┌─────┴────────┐                      │\n│      │              │                      │\n│      ▼              ▼                      │\n│  stop_reason     stop_reason               │\n│  == \"end_turn\"   == \"tool_use\"             │\n│      │              │                      │\n│      ▼              ▼                      │\n│    結束          執行工具（你的程式碼）    │\n│                     │                      │\n│                     ▼                      │\n│              把 tool_result 回灌 messages  │\n│                     │                      │\n│                     └──→ 回到步驟 1        │\n└───────────────────────────────────────────┘\n    │\n    ▼\n最終的 assistant 訊息（final text）\n```\n\n關鍵點：\n\n- Claude API 是 **stateless** 的。每次呼叫，你都要把完整的 `messages` 歷史（含先前的工具結果）一起送回去。\n- Loop 一直跑，直到 Claude 的回應 `stop_reason == \"end_turn\"`（也就是這一輪它沒有再要求呼叫工具）。\n- 「誰跑這個 loop」有兩個選擇：\n  - **Tool runner（beta，推薦）**：SDK 幫你跑「呼叫工具 → 回灌結果 → 再問」這個循環，你只要提供工具的實作。\n  - **手寫 loop**：要細控（人類審批、條件式執行、自訂 log、預算上限）時，自己跑 `while` 迴圈。\n\n整章我會從工具定義出發，先用 tool runner 把招牌的 Research Agent 跑起來，再示範手寫 loop 怎麼接管細節。\n\n## 定義工具的正確方式\n\n工具定義是 Agent 開發最重要的技能之一。定義得好，Claude 能精確選用正確工具；定義得差，它要麼用錯工具，要麼不知道怎麼用。\n\n### 使用 @beta_tool 裝飾器（Python）\n\n官方 SDK 提供 `beta_tool` 裝飾器，會自動從你的函式簽名和 docstring 產生工具的 input schema，你不用手寫一份 JSON：\n\n```python\nimport anthropic\nfrom anthropic import beta_tool\n\nclient = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰\n\n\n@beta_tool\ndef search_web(query: str, num_results: int = 5) -> str:\n    \"\"\"搜尋網路並返回相關結果摘要。\n\n    使用這個工具來查詢最新新聞和資訊、尋找特定主題的概覽、\n    驗證事實或統計數據。\n\n    Args:\n        query: 搜尋查詢字串，使用具體的關鍵字效果更好。\n        num_results: 要返回的結果數量（1-10），預設 5。\n    \"\"\"\n    # 實際的搜尋邏輯（這裡用 DuckDuckGo 作為範例）\n    from duckduckgo_search import DDGS\n\n    results = []\n    with DDGS() as ddgs:\n        for r in ddgs.text(query, max_results=num_results):\n            results.append(f\"標題: {r['title']}\\n來源: {r['href']}\\n摘要: {r['body']}\\n\")\n\n    if not results:\n        return f\"沒有找到關於「{query}」的結果\"\n\n    return \"\\n---\\n\".join(results)\n```\n\n`@beta_tool` 會自動把：\n\n- 函式名稱 → tool name\n- docstring 開頭的描述 → tool description\n- `Args:` 區塊裡每個參數的說明 → 對應參數的 description\n- 型別提示（`str`、`int`、`bool`、`list[...]` 等）→ 參數型別\n- 沒有預設值的參數 → `required`\n\n**工具文件就是工具的「說明書」**，Claude 讀這份說明書來決定要不要用、怎麼用這個工具。花時間寫好 docstring 非常值得。\n\n> 非同步版本：如果你的工具是 `async def`，改用 `from anthropic import beta_async_tool` 來裝飾，搭配後面會講到的 `AsyncAnthropic` client。\n\n### 手寫 JSON 工具定義（給手寫 loop 用）\n\n`@beta_tool` 很方便，但如果你要走手寫 loop，或者想對 schema 有完全的控制（例如強制 `strict`、控制 `additionalProperties`），就直接寫工具的 JSON 定義丟給 `client.messages.create(tools=...)`：\n\n```python\ntools = [{\n    \"name\": \"search_web\",\n    \"description\": \"搜尋網路並返回相關結果摘要。用於查詢最新資訊、驗證事實、尋找特定主題的概覽。\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"query\": {\"type\": \"string\", \"description\": \"搜尋查詢字串，使用具體的關鍵字效果更好\"},\n            \"num_results\": {\"type\": \"integer\", \"description\": \"要返回的結果數量（1-10）\"},\n        },\n        \"required\": [\"query\"],\n    },\n}]\n\n# 想強制 Claude 只能用某個工具：tool_choice={\"type\": \"tool\", \"name\": \"search_web\"}\n# 想要嚴格的結構化輸入：在工具裡加 \"strict\": True，\n#   並讓 input_schema 加上 \"additionalProperties\": False\n```\n\n這兩種寫法定義出的工具，對 Claude 來說是一模一樣的——`@beta_tool` 只是幫你把上面這份 JSON 自動產出來而已。\n\n### TypeScript 工具定義\n\nTypeScript 用 `betaZodTool`，搭配 Zod schema：\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\nimport { betaZodTool } from '@anthropic-ai/sdk/helpers/beta/zod';\nimport { z } from 'zod';\n\nconst client = new Anthropic();\n\nconst searchWeb = betaZodTool({\n  name: 'search_web',\n  description: `搜尋網路並返回相關結果摘要。\n用於查詢最新資訊、驗證事實、尋找特定主題的概覽。`,\n  inputSchema: z.object({\n    query: z.string().describe('搜尋查詢字串，使用具體的關鍵字效果更好'),\n    num_results: z.number().min(1).max(10).optional().describe('要返回的結果數量（1-10）'),\n  }),\n  run: async (input) => {\n    // 實作搜尋邏輯\n    const results = await performSearch(input.query, input.num_results ?? 5);\n    return results.join('\\n---\\n');\n  },\n});\n```\n\n`betaZodTool` 從 Zod schema 自動推導 input schema 和 TypeScript 型別，`run` 收到的 `input` 是完全 typed 的——這是 TS 端最大的好處。\n\n## Tool Return Value 的設計原則\n\n工具的返回值很重要，它直接影響 Claude 接下來能做什麼（這一段不分 SDK，是通用的好習慣）。\n\n**原則 1：返回結構化的、人可讀的文字**\n\n```python\n# 差：只返回原始 JSON\nreturn json.dumps(data)\n\n# 好：返回格式化的文字，Claude 更容易理解\ndef format_search_result(data: dict) -> str:\n    return f\"\"\"公司：{data['company_name']}\n成立年份：{data['founded']}\n主要業務：{data['description']}\n市值：{data['market_cap']}\n最新新聞：{data['latest_news']}\"\"\"\n```\n\n**原則 2：包含充足的上下文**\n\n```python\n# 差：只返回數字\nreturn \"42\"\n\n# 好：帶上單位和上下文\nreturn \"台灣 EV 市場 2026 年第一季銷售量：42,000 輛（YoY +28%）\"\n```\n\n**原則 3：失敗時提供有意義的錯誤信息**\n\n```python\n@beta_tool\ndef query_database(sql: str) -> str:\n    \"\"\"查詢公司資料庫（只讀）。\"\"\"\n    try:\n        results = db.execute(sql)\n        if not results:\n            return \"查詢執行成功，但沒有符合條件的記錄。\"\n        return format_db_results(results)\n    except DatabaseConnectionError:\n        return \"資料庫連線失敗。請稍後重試，或聯繫技術支援。\"\n    except InvalidSQLError as e:\n        return f\"SQL 語法錯誤：{e}。請修正查詢語法後重試。\"\n    except Exception as e:\n        return f\"查詢失敗（未知錯誤）：{str(e)}\"\n```\n\n當工具失敗時，Claude 會看到這個錯誤信息，並嘗試修正或使用替代方案。好的錯誤信息讓 Claude 能做出更好的決策。（如果你走手寫 loop，還可以更進一步在 `tool_result` 裡加上 `\"is_error\": True`，更明確地告訴 Claude「這次工具呼叫失敗了」——後面陷阱那一段會詳細講。）\n\n## Agent 的 System Prompt 設計\n\nAgent 的 system prompt（`client.messages.create` 的 `system` 參數）比一般的單輪問答需要更多結構。因為 Claude 要在循環裡反覆做決策，你得把「它是誰、要達成什麼、怎麼決策、什麼時候用哪個工具、最後輸出長什麼樣」都交代清楚。\n\n一個好的 Agent system prompt 應該包含：\n\n1. **角色定義**：這個 Agent 是誰，有什麼專業能力\n2. **目標描述**：它要達成什麼\n3. **行為準則**：它應該如何決策\n4. **工具使用指南**：什麼情況用什麼工具（特別是工具很多時）\n5. **輸出格式**：最終報告/回答的格式要求\n\n```python\nRESEARCH_AGENT_SYSTEM = \"\"\"你是一位專業的市場研究分析師，專門為企業提供深度市場報告。\n\n## 你的能力\n- 搜尋和整合最新的市場資訊\n- 查詢公司內部資料庫獲取歷史數據\n- 分析趨勢、競爭格局和機會\n\n## 工作流程\n1. 首先理解用戶的研究需求\n2. 收集資料：用 search_web 搜尋最新資訊，用 query_database 取得歷史數據\n3. 交叉驗證：用至少 2 個來源確認重要數據\n4. 整合分析：找出模式、趨勢和洞察\n5. 生成報告：以清晰的結構呈現發現\n\n## 工具使用指引\n- search_web：用於市場現況、最新新聞、競爭者資訊\n- query_database：用於歷史銷售數據、內部指標（使用 SELECT 語句，只讀取不修改）\n- calculate_metrics：用於計算百分比變化、CAGR 等指標\n- save_finding：在過程中把重要發現記下來，最後彙整成報告\n\n## 輸出格式\n最終報告使用以下結構：\n1. 執行摘要（3-5 個要點）\n2. 市場現況\n3. 主要趨勢\n4. 競爭格局\n5. 機會與風險\n6. 結論與建議\n\n## 重要原則\n- 每個重要數據都要標注來源\n- 如果找不到可靠資料，明確說明而不是猜測\n- 報告要有觀點，不只是資料彙整\n\"\"\"\n```\n\n## Python 完整 Research Agent\n\n現在來看完整的 Research Agent 實作。這是一個能搜尋網路、查詢資料庫、計算指標、邊做邊記筆記並生成報告的 Agent。我用官方 SDK 的 **tool runner** 來跑——它會自動處理整個 agentic loop，你只要把工具用 `@beta_tool` 定義好、提供給 runner 就行：\n\n```python\nimport sqlite3\nfrom datetime import datetime\nfrom typing import Optional\n\nimport anthropic\nfrom anthropic import beta_tool\n\nclient = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰\n\n# 過程中累積的研究發現（被 save_finding 寫入，最後彙整進報告）\nFINDINGS: list[str] = []\n\n\n# ============================================================\n# 工具定義（@beta_tool 自動產生 schema）\n# ============================================================\n\n@beta_tool\ndef search_web(query: str, num_results: int = 5) -> str:\n    \"\"\"搜尋網路上的最新資訊。\n\n    Args:\n        query: 搜尋關鍵字（英文或中文）。\n        num_results: 返回結果數（1-10）。\n    \"\"\"\n    try:\n        from duckduckgo_search import DDGS\n\n        results = []\n        with DDGS() as ddgs:\n            for r in ddgs.text(query, max_results=num_results):\n                results.append(\n                    f\"【{r['title']}】\\n\"\n                    f\"來源: {r['href']}\\n\"\n                    f\"內容: {r['body']}\\n\"\n                )\n        if not results:\n            return f\"沒有找到「{query}」的相關結果。建議嘗試不同的關鍵字。\"\n        return f\"找到 {len(results)} 個結果：\\n\\n\" + \"\\n---\\n\".join(results)\n    except Exception as e:\n        return f\"搜尋失敗：{str(e)}。請稍後重試。\"\n\n\n@beta_tool\ndef query_database(sql: str) -> str:\n    \"\"\"查詢市場資料資料庫（只讀）。\n\n    可用的資料表：\n    - market_data(year, quarter, segment, revenue_usd, units_sold, growth_rate)\n    - companies(id, name, segment, market_share, founded_year)\n    - trends(id, date, category, value, unit, source)\n\n    Args:\n        sql: SELECT 查詢語句（不支援 INSERT/UPDATE/DELETE）。\n    \"\"\"\n    # 安全檢查：只允許 SELECT\n    if not sql.strip().upper().startswith(\"SELECT\"):\n        return \"安全限制：只允許 SELECT 查詢。\"\n\n    try:\n        conn = sqlite3.connect(\"market_data.db\")\n        conn.row_factory = sqlite3.Row\n        cursor = conn.execute(sql)\n        rows = cursor.fetchall()\n        conn.close()\n\n        if not rows:\n            return \"查詢成功，但沒有符合條件的資料。\"\n\n        # 格式化為易讀的表格\n        headers = rows[0].keys()\n        table_lines = [\" | \".join(str(h) for h in headers)]\n        table_lines.append(\"-\" * len(table_lines[0]))\n        for row in rows[:50]:  # 最多顯示 50 行\n            table_lines.append(\" | \".join(str(v) for v in row))\n\n        result = f\"查詢返回 {len(rows)} 筆記錄：\\n\\n\" + \"\\n\".join(table_lines)\n        if len(rows) > 50:\n            result += f\"\\n\\n（僅顯示前 50 筆，共 {len(rows)} 筆）\"\n        return result\n\n    except sqlite3.Error as e:\n        return f\"資料庫查詢錯誤：{str(e)}\\n請檢查 SQL 語法。\"\n\n\n@beta_tool\ndef calculate_metrics(\n    values: list[float],\n    metric_type: str,\n    labels: Optional[list[str]] = None,\n) -> str:\n    \"\"\"計算常用的商業指標。\n\n    Args:\n        values: 數值列表。\n        metric_type: 指標類型，可選：\n                     \"yoy_growth\"（年增率，需至少 2 個值）、\n                     \"cagr\"（複合年均增長率，需起始值與終止值）、\n                     \"market_share\"（市佔率百分比）、\n                     \"summary\"（基本統計：最大、最小、平均、總和）。\n        labels: 可選的標籤列表（對應每個值）。\n    \"\"\"\n    if not values:\n        return \"錯誤：values 不能為空\"\n\n    if metric_type == \"summary\":\n        return (\n            f\"統計摘要：\\n\"\n            f\"  數量：{len(values)}\\n\"\n            f\"  總和：{sum(values):,.2f}\\n\"\n            f\"  平均：{sum(values) / len(values):,.2f}\\n\"\n            f\"  最大：{max(values):,.2f}\\n\"\n            f\"  最小：{min(values):,.2f}\"\n        )\n\n    if metric_type == \"yoy_growth\":\n        if len(values) < 2:\n            return \"錯誤：yoy_growth 需要至少 2 個值\"\n        results = []\n        for i in range(1, len(values)):\n            if values[i - 1] != 0:\n                growth = (values[i] - values[i - 1]) / values[i - 1] * 100\n                label = (\n                    f\"{labels[i]} vs {labels[i - 1]}\"\n                    if labels and len(labels) > i\n                    else f\"第{i + 1}期 vs 第{i}期\"\n                )\n                results.append(f\"  {label}: {growth:+.1f}%\")\n        return \"年增率計算：\\n\" + \"\\n\".join(results)\n\n    if metric_type == \"cagr\":\n        if len(values) < 2:\n            return \"錯誤：cagr 需要至少 2 個值（起始值和終止值）\"\n        years = len(values) - 1\n        cagr = ((values[-1] / values[0]) ** (1 / years) - 1) * 100\n        return (\n            f\"複合年均增長率（CAGR）：\\n\"\n            f\"  期間：{years} 年\\n\"\n            f\"  起始值：{values[0]:,.2f}\\n\"\n            f\"  終止值：{values[-1]:,.2f}\\n\"\n            f\"  CAGR：{cagr:.1f}%\"\n        )\n\n    if metric_type == \"market_share\":\n        total = sum(values)\n        if total == 0:\n            return \"錯誤：總和為零，無法計算市佔率\"\n        results = []\n        for i, v in enumerate(values):\n            label = labels[i] if labels and i < len(labels) else f\"項目{i + 1}\"\n            results.append(f\"  {label}: {v / total * 100:.1f}%\")\n        return f\"市佔率（總計 {total:,.0f}）：\\n\" + \"\\n\".join(results)\n\n    return f\"未知的 metric_type: {metric_type}。可選：summary, yoy_growth, cagr, market_share\"\n\n\n@beta_tool\ndef save_finding(key: str, value: str) -> str:\n    \"\"\"儲存研究過程中的重要發現，最後會彙整進報告。\n\n    Args:\n        key: 發現的簡短標題。\n        value: 發現的內容（含數據與來源）。\n    \"\"\"\n    FINDINGS.append(f\"### {key}\\n{value}\")\n    return f\"已記錄發現：{key}（目前共 {len(FINDINGS)} 筆）\"\n\n\n# ============================================================\n# System Prompt\n# ============================================================\n\nRESEARCH_AGENT_SYSTEM = \"\"\"你是一位專業的市場研究分析師。\n\n## 研究流程\n1. 分析用戶的研究需求，確定需要收集的資訊類型\n2. 用 search_web 搜尋市場現況和最新趨勢（至少 2-3 次不同查詢）\n3. 用 query_database 取得歷史數據（如果相關）\n4. 用 calculate_metrics 計算重要指標\n5. 用 save_finding 隨手記下每一個重要發現（含來源）\n6. 全部蒐集完成後，直接用一段結構化的文字輸出完整報告，然後結束\n\n## 搜尋策略\n- 先搜尋廣泛的概覽，再針對特定面向深入\n- 用英文搜尋效果通常比中文好，但可以結合兩種語言\n- 如果第一次搜尋結果不理想，換不同的關鍵字重試\n\n## 資料品質原則\n- 重要數據至少要有 2 個來源確認\n- 如果找到矛盾的數據，說明並給出最可能正確的版本\n- 清楚標注每個數據的來源和時間\n\n## 完成條件（很重要）\n完成資料蒐集與計算後，立刻輸出最終報告並停止呼叫工具。\n不要無止盡地繼續搜尋。報告寫完就結束。\n\n## 報告結構\n1. 執行摘要（3-5 個要點）\n2. 市場現況\n3. 主要趨勢\n4. 競爭格局\n5. 機會與風險\n6. 結論與建議\"\"\"\n\n\n# ============================================================\n# 用 tool runner 跑 agentic loop\n# ============================================================\n\ndef run_research(topic: str) -> str:\n    \"\"\"執行市場研究任務。tool runner 會自動跑 loop，\n    呼叫工具、回灌結果，直到 Claude 不再呼叫工具為止。\"\"\"\n    FINDINGS.clear()\n\n    print(f\"開始研究：{topic}\")\n    print(\"=\" * 60)\n\n    runner = client.beta.messages.tool_runner(\n        model=\"claude-opus-4-8\",\n        max_tokens=16000,\n        system=RESEARCH_AGENT_SYSTEM,\n        tools=[search_web, query_database, calculate_metrics, save_finding],\n        messages=[{\n            \"role\": \"user\",\n            \"content\": f\"請針對以下主題生成一份完整的市場研究報告：{topic}\",\n        }],\n    )\n\n    # runner 是可迭代物件，每個 iteration yield 一個 BetaMessage。\n    # 我們順手把每一輪用了哪些工具印出來，方便觀察。\n    final_message = None\n    for message in runner:\n        final_message = message\n        tool_uses = [b.name for b in message.content if b.type == \"tool_use\"]\n        if tool_uses:\n            print(f\"  [工具] {', '.join(tool_uses)}\")\n        usage = message.usage\n        print(f\"  [token] in={usage.input_tokens} out={usage.output_tokens}\")\n\n    print(\"=\" * 60)\n    print(f\"完成！過程中記錄了 {len(FINDINGS)} 筆發現。\")\n\n    # 最終 message 裡的 text block 就是 Claude 寫出來的報告\n    report = \"\".join(b.text for b in final_message.content if b.type == \"text\")\n    return report\n\n\nif __name__ == \"__main__\":\n    print(run_research(\"台灣 AI 應用市場 2026 年展望\"))\n```\n\n幾個重點：\n\n- **`tool_runner` 自動跑 loop**：你不用自己處理「拿到 `tool_use` → 執行 → 把 `tool_result` 塞回 messages → 再呼叫一次」。runner 看到 Claude 要呼叫工具，就去執行對應的 `@beta_tool` 函式，把結果回灌，再問下一輪；直到 Claude 不再呼叫工具（`stop_reason == \"end_turn\"`）自然停下。\n- **觀察用 iteration**：`for message in runner` 每一輪都會給你那一輪的 `BetaMessage`，我用它印出工具呼叫與 token 用量，相當於免費的可觀測性。\n- **狀態放哪**：這裡用一個模組層級的 `FINDINGS` 串列當 Agent 的「便條紙」，`save_finding` 工具把發現寫進去。下一節我會講更乾淨的記憶傳遞方式。\n\n## 加入記憶：Context 傳遞\n\nClaude API 是 **stateless** 的——它不會自己記得上一次對話。所謂「記憶」，就是**你自己把歷史帶著走**。\n\n### 方法一：用 messages 歷史串接（最基本、最可靠）\n\n要讓 Agent 記得前面發生過什麼，就在下一次呼叫時把先前的 `messages` 一起帶進去。比方說做一個能連續對話的研究助理：\n\n```python\nimport anthropic\nfrom anthropic import beta_tool\n\nclient = anthropic.Anthropic()\n\n# 這串 messages 就是 Agent 的「記憶」，跨多輪持續累積\nhistory: list[dict] = []\n\n\ndef chat_turn(user_input: str) -> str:\n    history.append({\"role\": \"user\", \"content\": user_input})\n\n    runner = client.beta.messages.tool_runner(\n        model=\"claude-opus-4-8\",\n        max_tokens=16000,\n        system=RESEARCH_AGENT_SYSTEM,\n        tools=[search_web, query_database, calculate_metrics, save_finding],\n        messages=history,\n    )\n\n    final_message = None\n    for message in runner:\n        final_message = message\n\n    # 把這一輪 Claude 的完整回應（含它呼叫工具的紀錄）寫回歷史，\n    # 下一輪就「記得」這次研究過什麼\n    history.append({\"role\": \"assistant\", \"content\": final_message.content})\n\n    return \"\".join(b.text for b in final_message.content if b.type == \"text\")\n\n\nprint(chat_turn(\"研究台灣 SaaS 市場規模\"))\nprint(chat_turn(\"那其中垂直 SaaS 占多少？\"))  # Claude 記得上一輪查過什麼\n```\n\n這就是記憶的本質：歷史在你的程式裡，你決定要帶多少、帶哪些。要長期保存就把 `history` 存進資料庫，下次載回來即可。\n\n### 方法二：memory 工具（進階選項）\n\n如果你不想自己無限堆疊 `messages`（會越來越貴），官方還有一個 server 端的 memory 工具，讓 Claude 自己決定把什麼存起來、之後再讀回。啟用方式是在 `tools` 裡加一個工具型別：\n\n```python\ntools = [\n    {\"type\": \"memory_20250818\", \"name\": \"memory\"},\n    # ... 你自己的工具\n]\n```\n\n它讓 Claude 在跨會話之間維持一份「筆記」，而不必把整段歷史都塞回 context。細節我留給之後談「長時記憶」的章節，這裡你只要知道：**記憶要嘛自己用 `messages` 帶，要嘛交給 memory 工具，沒有第三種魔法。**\n\n## 加入停止條件\n\ntool runner 在 Claude 不再呼叫工具時就會自然停下——它不需要你做任何事。但有時候你需要**更主動的停止條件**：跑太多輪要硬停、超過 token 預算要中止、或某個工具被呼叫後就該結束。這種細控，就是手寫 loop 登場的時候。\n\n手寫 loop 的停止條件有兩根支柱：**`stop_reason == \"end_turn\"`**（Claude 講完了）和**自己維護的 `max_iterations` 計數器**（防呆上限）：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\n# 工具的 JSON 定義（手寫 loop 用這種，不是 @beta_tool）\nTOOLS = [\n    {\n        \"name\": \"search_web\",\n        \"description\": \"搜尋網路並返回相關結果摘要。\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\"query\": {\"type\": \"string\", \"description\": \"搜尋關鍵字\"}},\n            \"required\": [\"query\"],\n        },\n    },\n    # ... query_database / calculate_metrics 等\n]\n\n\ndef execute_tool(name: str, tool_input: dict) -> str:\n    \"\"\"把工具名稱對應到實際實作（你自己寫）。\"\"\"\n    if name == \"search_web\":\n        return do_search(tool_input[\"query\"])\n    # ... 其餘工具\n    return f\"未知的工具：{name}\"\n\n\ndef run_research_manual(topic: str, max_iterations: int = 20) -> str:\n    messages = [{\n        \"role\": \"user\",\n        \"content\": f\"請針對以下主題生成市場研究報告：{topic}\",\n    }]\n\n    for iteration in range(max_iterations):\n        response = client.messages.create(\n            model=\"claude-opus-4-8\",\n            max_tokens=16000,\n            system=RESEARCH_AGENT_SYSTEM,\n            tools=TOOLS,\n            messages=messages,\n        )\n\n        # 支柱 1：Claude 這一輪沒有再要求呼叫工具 → 結束\n        if response.stop_reason == \"end_turn\":\n            return \"\".join(b.text for b in response.content if b.type == \"text\")\n\n        # 否則 stop_reason == \"tool_use\"：執行工具、回灌結果\n        messages.append({\"role\": \"assistant\", \"content\": response.content})\n        tool_results = []\n        for block in response.content:\n            if block.type == \"tool_use\":\n                try:\n                    result = execute_tool(block.name, block.input)\n                    tool_results.append({\n                        \"type\": \"tool_result\",\n                        \"tool_use_id\": block.id,   # 必須對應 tool_use 的 id\n                        \"content\": result,\n                    })\n                except Exception as e:\n                    # 工具錯誤：用 is_error 讓 Claude 知道，並嘗試自我修正\n                    tool_results.append({\n                        \"type\": \"tool_result\",\n                        \"tool_use_id\": block.id,\n                        \"content\": f\"工具執行失敗：{e}\",\n                        \"is_error\": True,\n                    })\n        messages.append({\"role\": \"user\", \"content\": tool_results})\n\n    # 支柱 2：跑到 max_iterations 上限還沒結束 → 硬停（防無限循環）\n    return \"（已達最大迭代次數，回傳目前進度）\\n\" + str(messages[-1])\n```\n\n關於 `stop_reason`，你會遇到的值有：\n\n- `end_turn`：Claude 講完了（正常結束）。\n- `tool_use`：Claude 要呼叫工具，loop 該執行工具並續跑。\n- `max_tokens`：被 `max_tokens` 截斷了（輸出太長），你可能要調高上限或請它精簡。\n- `pause_turn`：server 端工具執行暫停，可以把回應原樣再送一次續跑。\n- `refusal`：基於安全拒答，看 `response.stop_reason` 旁邊的細節欄位處理。\n\n**什麼時候用哪種？** 純粹「呼叫工具直到完成」用 tool runner 最省事；要插入人類審批、預算上限、條件式中止、或自訂每一步的 log，就手寫 loop。兩者底層打的是同一支 `messages.create` API。\n\n## 常見陷阱與解決方法\n\n### 陷阱 1：無限循環\n\n**症狀**：Agent 一直呼叫工具，沒有停止跡象。\n\n**原因**：通常是工具一直返回讓 Claude 覺得「任務還沒完成」的信號，或 system prompt 設計得讓它認為要無限深入研究。\n\n**解決**：\n\n- **手寫 loop**：加 `max_iterations` 計數器當硬性上限（上一節的「支柱 2」），跑到就跳出。這是手寫 loop 防無限循環的正解。\n- **tool runner**：它在 Claude 沒有 `tool_use` 時本來就會停；無限循環多半來自 prompt。把「完成條件」寫進 system prompt：\n\n```text\n## 完成條件\n完成以下所有步驟後，立即輸出報告並結束：\n- 至少完成 3 次網路搜尋\n- 查詢相關的歷史數據（如果有）\n- 計算關鍵指標\n- 輸出最終報告\n\n不要繼續收集更多資料。報告輸出後就結束，不要再呼叫任何工具。\n```\n\n### 陷阱 2：Token 爆炸\n\n**症狀**：一次 Agent run 消耗了幾十萬 token。\n\n**原因**：Claude API 是 stateless，每一輪都把**完整歷史**送回去。如果工具返回了一大坨文字，每次循環都會把它重複帶進 context，越滾越大。\n\n**解決**：\n\n```python\n@beta_tool\ndef search_web(query: str, max_chars: int = 2000) -> str:\n    \"\"\"搜尋網路。返回結果限制在 max_chars 字元以內。\"\"\"\n    full_text = format_results(do_search(query))\n    if len(full_text) > max_chars:\n        return full_text[:max_chars] + f\"\\n\\n[內容過長已截斷，原始長度：{len(full_text)} 字元]\"\n    return full_text\n```\n\n三個方向一起做：\n\n1. **精簡 context**：工具只回傳真正有用的內容，別把整頁 HTML 丟回去。\n2. **截斷工具輸出**：像上面那樣設 `max_chars`，並在 system prompt 限制「不要一次請求超過 5 個搜尋結果、查資料庫用 `LIMIT` 限制行數」。\n3. **Prompt caching**：把固定不變的大段（system prompt、工具定義、長文件）標記為可快取，重複的部分就不用每輪重新計費。這對長 loop 省下來的成本相當可觀。\n\n### 陷阱 3：工具錯誤導致 Agent 卡住\n\n**症狀**：工具拋出異常，整個 loop 崩潰。\n\n**解決**：永遠不要讓未捕獲的異常往外炸。把錯誤包成有意義的字串回傳，並在 `tool_result` 標 `\"is_error\": True`，讓 Claude 知道「這次失敗了」並自我修正：\n\n```python\n# 手寫 loop 裡組 tool_result\ntry:\n    result = execute_tool(block.name, block.input)\n    tool_results.append({\n        \"type\": \"tool_result\",\n        \"tool_use_id\": block.id,\n        \"content\": result,\n    })\nexcept RateLimitError:\n    tool_results.append({\n        \"type\": \"tool_result\",\n        \"tool_use_id\": block.id,\n        \"content\": \"API 速率限制達到。請等待後重試，或改用其他方法獲取這個資訊。\",\n        \"is_error\": True,\n    })\nexcept Exception as e:\n    tool_results.append({\n        \"type\": \"tool_result\",\n        \"tool_use_id\": block.id,\n        \"content\": f\"工具執行失敗（未知錯誤）：{type(e).__name__}: {e}\",\n        \"is_error\": True,\n    })\n```\n\n用 `@beta_tool` 搭 tool runner 時，同樣的精神是讓工具函式內部 `try/except` 後**回傳**錯誤字串（像「Tool Return Value 設計原則 3」那樣），而不是讓它拋例外——Claude 讀到錯誤訊息後，常常會自己換個參數重試。\n\n### 陷阱 4：Agent 沒有使用工具\n\n**症狀**：Claude 直接從「已知知識」回答，沒有呼叫你提供的工具。\n\n**解決**：兩個層次。\n\nsystem prompt 明確要求：\n\n```text\n## 強制要求\n在提供任何分析之前，你必須：\n1. 至少呼叫 search_web 兩次，獲取最新的市場數據\n2. 呼叫 query_database 查詢歷史數據\n不要依賴你的訓練數據，市場數據變化快，必須即時查詢。\n```\n\n或者用 `tool_choice` 從 API 層級強制（手寫 loop / `messages.create` 都支援）：\n\n```python\nresponse = client.messages.create(\n    model=\"claude-opus-4-8\",\n    max_tokens=16000,\n    tools=TOOLS,\n    tool_choice={\"type\": \"any\"},  # 強制這一輪一定要呼叫某個工具\n    # 或 {\"type\": \"tool\", \"name\": \"search_web\"} 指定一定要用某個工具\n    messages=messages,\n)\n```\n\n## Debug 技巧\n\n### 開啟 SDK 詳細日誌\n\n最快的方法是設環境變數，SDK 就會把每次請求/回應的細節印出來：\n\n```bash\nANTHROPIC_LOG=debug python research_agent.py\n```\n\n你會看到實際送出的 payload、HTTP 狀態、retry 等資訊——排查「為什麼工具沒被呼叫」「為什麼回應被截斷」非常有用。\n\n### 檢查完整的訊息歷史\n\n手寫 loop 時，`messages` 就是 Agent 的完整記憶。卡住時直接 `print(messages)` 把它攤開看，是最樸實也最有效的 debug：\n\n```python\nimport json\n\nfor i, message in enumerate(messages):\n    print(f\"\\n--- 訊息 {i + 1} ({message['role']}) ---\")\n    content = message[\"content\"]\n    if isinstance(content, str):\n        print(content[:200])\n    else:\n        for block in content:\n            # block 可能是 SDK 物件或 dict，統一取屬性\n            btype = getattr(block, \"type\", None) or block.get(\"type\")\n            if btype == \"tool_use\":\n                name = getattr(block, \"name\", None) or block.get(\"name\")\n                print(f\"[工具呼叫] {name}\")\n            elif btype == \"tool_result\":\n                print(f\"[工具結果] {str(getattr(block, 'content', None) or block.get('content'))[:200]}\")\n            elif btype == \"text\":\n                print(f\"[文字] {(getattr(block, 'text', None) or block.get('text'))[:200]}\")\n```\n\n### 用 request id 回報問題\n\n每個回應物件都帶一個 `_request_id`。如果你遇到疑似 API 端的異常要回報給 Anthropic，附上這個 id 最快：\n\n```python\nresponse = client.messages.create(model=\"claude-opus-4-8\", max_tokens=16000, messages=[...])\nprint(response._request_id)  # 例如 req_011CS...\n```\n\n## 如何測試 Agent\n\n測試 Agent 的挑戰是：工具會呼叫外部 API、Claude 呼叫要花錢又不確定。好消息是——**`@beta_tool` 包的就是一個普通函式**，所以我們可以分兩層測。\n\n### 第一層：直接對工具的底層函式做單元測試\n\n`@beta_tool` 不會把你的函式變成不可呼叫的魔法物件，它的純邏輯可以照常測。把工具的純邏輯抽出來測，最乾淨、最快、不花一毛錢：\n\n```python\nimport pytest\n\n# 把可測的純邏輯抽成獨立函式，工具只是薄薄一層包裝\ndef compute_yoy_growth(values: list[float]) -> list[float]:\n    return [\n        (values[i] - values[i - 1]) / values[i - 1] * 100\n        for i in range(1, len(values))\n        if values[i - 1] != 0\n    ]\n\n\ndef test_yoy_growth_basic():\n    result = compute_yoy_growth([100.0, 132.0])\n    assert result == [pytest.approx(32.0)]\n\n\ndef test_yoy_growth_handles_empty():\n    assert compute_yoy_growth([100.0]) == []\n\n\ndef test_query_database_rejects_non_select():\n    # query_database 內含 @beta_tool 也照常可呼叫，邏輯能直接驗\n    assert \"只允許 SELECT\" in query_database(\"DELETE FROM market_data\")\n```\n\n### 第二層：整合測試——mock 掉 Anthropic client\n\n要驗整個 loop 的串接（不真的打 API），就把 `anthropic.Anthropic` client mock 掉，讓它回傳你預先安排好的回應序列：先回一個「呼叫工具」的回應，再回一個「end_turn」的回應，驗證你的手寫 loop 有正確執行工具、回灌結果、然後停下：\n\n```python\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock\n\n\ndef make_tool_use_response(tool_name, tool_id, tool_input):\n    block = SimpleNamespace(type=\"tool_use\", name=tool_name, id=tool_id, input=tool_input)\n    return SimpleNamespace(stop_reason=\"tool_use\", content=[block])\n\n\ndef make_end_response(text):\n    block = SimpleNamespace(type=\"text\", text=text)\n    return SimpleNamespace(stop_reason=\"end_turn\", content=[block])\n\n\ndef test_manual_loop_executes_tool_then_stops(monkeypatch):\n    fake_client = MagicMock()\n    # 第一次呼叫要求用 search_web，第二次就收尾\n    fake_client.messages.create.side_effect = [\n        make_tool_use_response(\"search_web\", \"toolu_1\", {\"query\": \"台灣 EV\"}),\n        make_end_response(\"# 報告\\n台灣電動車市場 2026 年成長強勁。\"),\n    ]\n    # 用我們的假 client 取代真實 client\n    monkeypatch.setattr(\"research_agent.client\", fake_client)\n\n    report = run_research_manual(\"台灣電動車市場\", max_iterations=10)\n\n    # loop 應該呼叫了兩次 API（一次工具、一次收尾）\n    assert fake_client.messages.create.call_count == 2\n    assert \"電動車\" in report\n```\n\n兩層測試合起來：純邏輯走第一層（快、便宜、覆蓋面廣），loop 串接走第二層（mock client、不花錢、驗收 stop 條件）。真的要對真實 Claude 行為做 e2e 驗證時，再用便宜的模型跑少量 case 即可。\n\n---\n\n恭喜你讀到這裡。你已經從「送一個 HTTP 請求給 Claude」走到了「用真實的 Anthropic SDK 建立能自主完成複雜任務的 AI Agent」——而且你很清楚底層就是一個 agentic loop，不是什麼黑魔法。\n\n這一章是 Claude API & Agent SDK 完全指南的第十章，也是 Agent 開發的核心技術。接下來的章節，我們會繼續深入：多 Agent 系統的設計（orchestrator 怎麼路由到不同 subagent）、Human-in-the-loop 的實作、Agent 的監控與可觀測性，以及生產環境的部署策略。\n\nAgent 的世界，我們才剛開始。",
      "summary": "Agent 生命週期完整解析（真實 agentic loop）；用 @beta_tool 與手寫 JSON 定義工具；工具回傳值設計；用 messages 歷史傳遞記憶；max_iterations 與 stop_reason 停止條件；Python 完整 Research Agent（tool_runner 可跑版）；常見陷阱（無限循環、token 爆炸）；Debug 技巧；單元測試與 mock client 策略。",
      "image": "https://bobochen.dev/_astro/cover.Co3uqRyq.webp",
      "date_published": "2026-05-01T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Agent SDK",
        "AI Agent",
        "開發工具",
        "狀態管理"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-md-rules-files-masterclass/",
      "url": "https://bobochen.dev/blog/claude-md-rules-files-masterclass/",
      "title": "CLAUDE.md 與 Rules Files 大師班：我維護 40+ 份設定檔學到的事",
      "content_text": "CLAUDE.md 不是寫一次就不管的 README。經過一年的迭代，設定檔系統已經從單一檔案演化成多層架構——global、per-project、per-task、per-tool。分享完整的設定檔架構設計、版本演化歷程、和維護心得。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第八篇。上一篇：[Prompt 到 Production](/blog/agentic-engineering-daily-workflow-advanced)\n\n## 我的 ~/.claude/ 目錄下有 47 個設定檔\n\n一年前，我的整個 agent 設定就是一個 CLAUDE.md，裡面寫了三行：build command、test command、和「請用 TypeScript」。\n\n現在？47 個設定檔。Global CLAUDE.md、12 個專案的 project CLAUDE.md、23 個 skills、hooks 設定、memory system。這不是因為我喜歡寫設定檔，是因為一個檔案真的不夠用。\n\n但你不需要從 47 個檔案開始。這篇分享的是這套系統怎麼一步步長出來的，以及你在每個階段需要什麼、不需要什麼。\n\n## 從一個檔案到一套系統\n\n### 問題浮現的時刻\n\n**問題 1：跨專案衝突**\n\n我同時在做一個 Astro 部落格和一個 Next.js 專案。Global CLAUDE.md 裡寫了「用 Astro 的 file-based routing」，結果在 Next.js 專案裡 agent 也嘗試用 Astro 的方式處理 routing。\n\n→ **需要 per-project 設定。**\n\n**問題 2：CLAUDE.md 太長**\n\n有一陣子我的 project CLAUDE.md 膨脹到了 400 行。裡面什麼都有——build 指令、coding conventions、deploy 流程、specific component 的使用方式。結果 agent 的行為變得不穩定——有時候遵循 convention A，有時候遵循 convention B。\n\n原因是 [Context Engineering](/blog/context-engineering-deep-dive) 裡提到的 \"Lost in the Middle\" 效應：CLAUDE.md 中間的指令比頭尾更容易被忽略。\n\n→ **需要拆分，讓每份檔案都精簡。**\n\n**問題 3：重複的工作流**\n\n我發現自己經常在不同專案裡跟 agent 說同樣的事：「先跑 linter」、「commit 前做 review」、「用 conventional commits 格式」。每次新開一個 session 都要重複。\n\n→ **需要可重用的 skill 模組。**\n\n## 設定檔層級架構\n\n經過一年的演化，我的設定檔系統有四個層級：\n\n```\n~/.claude/CLAUDE.md           ← Global：所有專案通用\n  └── project/CLAUDE.md       ← Project：專案特定\n      └── .claude/rules/*.md  ← Rules：場景特定\n          └── skills/*.md     ← Skills：可觸發的工作流\n```\n\n### Layer 1: Global CLAUDE.md（~100 行）\n\n位置：`~/.claude/CLAUDE.md`\n\n放什麼：\n\n- **開發哲學**：incremental progress、composition over inheritance、test-driven\n- **通用 conventions**：prefer TypeScript、conventional commits、no --no-verify\n- **工具偏好**：prefer npm over yarn、GitHub CLI for CI/CD\n- **行為規範**：不要自動 git push、不要自動加 emoji、stop after 3 failed attempts\n\n不放什麼：\n\n- 任何專案特定的技術棧或 build 指令\n- 任何會跟其他專案衝突的規則\n\n**原則**：如果這條規則適用於你所有的專案，放 global。否則，放 project。\n\n### Layer 2: Project CLAUDE.md（~50 行）\n\n位置：project root 的 `CLAUDE.md`\n\n放什麼：\n\n- **Build/test/dev 指令**：`npm run dev`、`npm run build`、`npm run test`\n- **技術棧**：Astro 5 + MDX + Tailwind CSS 4 + TypeScript\n- **專案結構**：key directories 和 file conventions\n- **專案特有的慣例**：命名規則、component patterns、design tokens\n\n我的 bobo-blog-2026 的 CLAUDE.md 大概長這樣：\n\n```markdown\n# CLAUDE.md\n\n## Commands\n\nnpm run dev # Start dev server at localhost:4323\nnpm run build # Build production site to ./dist/\n\n## Architecture\n\nStack: Astro 5 + MDX + Tailwind CSS 4 + TypeScript\n\n- src/pages/ — File-based routing\n- src/content/blog/ — MDX blog posts\n- src/components/ — Astro components (PascalCase)\n- src/layouts/ — BaseLayout.astro, BlogLayout.astro\n\n## Content Schema\n\nBlog posts require: title, description, pubDate\nOptional: tags, featured, image, draft\n\n## Theme System\n\nCSS Custom Properties: --color-_, --glass-_, --radius-\\*\nDark mode: .dark class on <html>\n\n## Conventions\n\n- lang=\"zh-TW\"\n- Component files: PascalCase\n- Use existing CSS custom properties over hardcoded values\n```\n\n50 行以內。每一行都跟這個專案直接相關。\n\n**關鍵原則**：Project CLAUDE.md 應該被 commit 進 git。它是專案文件的一部分，就像 README.md 或 tsconfig.json 一樣。團隊成員 pull 下來就能用。\n\n### Layer 3: Rules Files（按場景分）\n\n位置：`.claude/rules/*.md`\n\nRules files 是「場景特定的 context」——不是每次都需要，但在特定情境下很重要的指令。\n\n例子：\n\n- `testing-rules.md`：「test 要用 vitest、prefer integration test over unit test、mock 只在必要時使用」\n- `deploy-rules.md`：「deploy 到 Cloudflare Workers、使用 wrangler.jsonc、先 deploy staging 再 production」\n- `content-rules.md`：「部落格文章用繁中、frontmatter 必須有 title/description/pubDate」\n\n什麼時候用 rules file 而不是 CLAUDE.md？當一條規則只在特定類型的任務中需要。如果你在寫測試的時候才需要知道 testing conventions，那它應該在 `testing-rules.md`，不是在每次 session 都會載入的 CLAUDE.md。\n\n### Layer 4: Skills（可觸發的工作流）\n\n位置：`~/.claude/skills/skill-name/SKILL.md`\n\nSkills 是 CLAUDE.md 的「按需版」——你用 `/skill-name` 觸發，它才會被載入到 context。\n\n為什麼需要 skills？因為有些工作流太複雜、太長、而且不是每次都需要。比如 pre-commit review 的 4-agent 並行 review 流程，有 200 行的指令——這些如果放在 CLAUDE.md 裡，每個 session 都會佔用 context window，但 90% 的時間根本用不到。\n\n我目前有 23 個 skills，大致分四類：\n\n| 類別       | 數量 | 例子                                                                       |\n| ---------- | ---- | -------------------------------------------------------------------------- |\n| 開發工作流 | 8    | `commit`、`pre-commit-review`、`iterative-tdd-loop`、`self-healing-deploy` |\n| 內容創作   | 7    | `content-research-writer`、`lesson-to-blog`、`translation-zh-tw`           |\n| 寫作風格   | 5    | `will-pao-style`、`fireship-style`、`lenny-newsletter-style`               |\n| 工具自動化 | 3    | `gcp-log-check`、`notion-workflow-bobo`、`wordhunter-image-processor`      |\n\n### 四層的優先順序\n\n當多層設定有衝突時，優先順序是：\n\n```\nSkills（最高） > Rules > Project CLAUDE.md > Global CLAUDE.md（最低）\n```\n\n這跟 CSS 的 specificity 邏輯一樣：越具體的越優先。Global 是預設值，project 覆蓋 global，rules 覆蓋 project，skills 覆蓋一切。\n\n## Skills 系統深入\n\n一個好的 skill 有三個部分：\n\n### 1. Trigger Condition\n\n什麼時候觸發？寫在 `description` field 裡：\n\n```yaml\ndescription: >\n  Smart git commit skill. Triggers on \"commit\", \"/commit\",\n  \"幫我 commit\", \"提交\", or any request to create a git commit.\n```\n\n這讓 Claude Code 知道什麼時候該自動載入這個 skill。\n\n### 2. Step-by-Step Instructions\n\n具體怎麼執行：\n\n```markdown\n## Step 1: Gather Context (parallel)\n\nRun: git status, git diff --cached, git log --oneline -10\n\n## Step 2: Stage Check\n\nConfirm staging is correct...\n\n## Step 3: Draft Commit Message\n\nAnalyze diff, follow conventional commits...\n\n## Step 4: Execute\n\nUse HEREDOC format...\n```\n\n### 3. Safety Rules\n\n什麼不能做：\n\n```markdown\n## Safety Rules\n\n- NEVER use --no-verify\n- NEVER use --amend on pushed commits\n- NEVER auto-push\n- NEVER commit .env files\n```\n\n### Skills vs CLAUDE.md 的選擇\n\n| 情境                   | 放哪裡                     |\n| ---------------------- | -------------------------- |\n| 每次 session 都需要    | CLAUDE.md                  |\n| 特定類型任務才需要     | Rules file                 |\n| 需要明確觸發的工作流   | Skill                      |\n| 很少用、但用到時很關鍵 | Skill                      |\n| 團隊共用的慣例         | CLAUDE.md（commit 進 git） |\n| 個人的工作偏好         | Global CLAUDE.md 或 Skill  |\n\n## 知識放哪裡的決策框架\n\n這是我在一年中最常遇到的問題：「這個資訊應該放在哪裡？」\n\n以下是我的決策樹：\n\n```\n這個知識...\n├── 每次 session 都需要？\n│   ├── 是 → 所有專案都需要？\n│   │   ├── 是 → Global CLAUDE.md\n│   │   └── 否 → Project CLAUDE.md\n│   └── 否\n│       ├── 特定任務類型需要？\n│       │   ├── 是 → Rules file 或 Skill\n│       │   └── 否\n│       │       ├── 跨 session 需要記住？\n│       │       │   ├── 是 → Memory system\n│       │       │   └── 否 → 不需要持久化\n│       │       └── 專案文件已有？\n│       │           ├── 是 → 不要重複（讓 agent 自己讀）\n│       │           └── 否 → 考慮加到 README 或 docs/\n│       └── 給人看的？\n│           └── 是 → README / docs/\n```\n\n**核心原則**：不要重複。如果 `package.json` 裡已經有 build script，不要在 CLAUDE.md 裡複製。如果 `tsconfig.json` 已經定義了 TypeScript 設定，不要在 CLAUDE.md 裡再寫一次。Agent 有能力讀這些檔案。\n\nCLAUDE.md 放的應該是那些**檔案裡看不出來的隱性知識**——比如「我們偏好 composition over inheritance」或「deploy 前一定要先跑 staging」。\n\n## 維護的藝術：設定檔也需要重構\n\n### 定期 Review\n\n設定檔跟 code 一樣，會腐化。三個月前加的一條 rule，可能因為 codebase 已經改了而不再需要。\n\n**我的習慣**：每月花 30 分鐘做一次「設定檔 review」。具體做什麼：\n\n1. 讀一遍 CLAUDE.md，刪掉過時的內容\n2. 看最近一個月 agent 犯過什麼重複的錯——需要加 rule 嗎？\n3. 看 skills 的使用頻率——完全沒用過的 skill 考慮歸檔\n4. 確認多個設定檔之間沒有矛盾\n\n### Version Control\n\n**CLAUDE.md 應該被 commit 進 git**——這不只是最佳實踐，是必要的：\n\n1. 團隊成員 clone 下來就能用\n2. 可以 track 變更歷史\n3. 可以 code review\n4. 跟 codebase 保持同步\n\n`~/.claude/CLAUDE.md`（global）不需要 commit——那是你個人的偏好設定。但 project CLAUDE.md 一定要。\n\n### 團隊共用的考量\n\n當團隊共用同一份 CLAUDE.md 時，注意：\n\n- **避免主觀規則**：「程式碼要寫得漂亮」是主觀的；「函數不超過 50 行」是客觀的\n- **避免個人偏好**：你喜歡 semicolons，同事不喜歡——這種交給 ESLint 處理\n- **建立修改流程**：修改 CLAUDE.md 也需要 PR review，就像修改 ESLint config 一樣\n\n## Anti-patterns：太長、太嚴格、太模糊\n\n### Anti-pattern 1：太長（>300 行）\n\n**症狀**：CLAUDE.md 超過 300 行，什麼都寫在裡面。\n\n**問題**：Agent 的注意力被稀釋。中間的指令可能被忽略（Lost in the Middle）。每次 session 啟動時，大量 token 被設定檔佔用。\n\n**修正**：\n\n- 拆到 rules files 和 skills\n- 刪掉可以從 codebase 推斷出來的資訊\n- 刪掉過時的資訊\n- 目標：Project CLAUDE.md < 100 行\n\n### Anti-pattern 2：太嚴格\n\n**症狀**：規則寫得太死——「每個函數不能超過 10 行」、「每個模組不能超過 5 個 export」、「所有變數必須用全名不能縮寫」。\n\n**問題**：Agent 會為了遵守規則而寫出更爛的 code。為了讓函數不超過 10 行，它把一個清晰的函數拆成 5 個意義不明的小函數。為了不縮寫，它寫出 `currentlyAuthenticatedUserEmailAddress` 這種變數名。\n\n**修正**：用 guideline 取代 hard rule。「函數盡量保持短小」比「函數不能超過 10 行」好。給 agent 判斷空間，讓它在合理範圍內做決策。\n\n### Anti-pattern 3：太模糊\n\n**症狀**：「寫好的 code」、「遵循最佳實踐」、「保持 code 品質」。\n\n**問題**：等於沒說。Agent 會用它 training data 裡的「最佳實踐」，那可能跟你的專案完全不合。\n\n**修正**：具體。「用 Tailwind utility classes，不要寫 custom CSS」比「遵循專案的 CSS 慣例」明確 100 倍。\n\n### Anti-pattern 4：矛盾\n\n**症狀**：Global CLAUDE.md 說「用 tabs」，Project CLAUDE.md 說「用 spaces」。或者 rules 裡說「prefer functional components」，但 CLAUDE.md 說「follow existing patterns」（而 existing patterns 有 class components）。\n\n**問題**：Agent 隨機選一個，或者更糟，在同一個 PR 裡兩種都用。\n\n**修正**：\n\n1. 建立明確的 override 規則（project > global）\n2. 定期交叉檢查不同層級的設定\n3. 一條規則只在一個地方定義\n\n## 讀者範本：從零開始的三階段路線圖\n\n### Week 1：基礎版（5 行）\n\n```markdown\n# CLAUDE.md\n\n## Commands\n\nnpm run dev\nnpm run build\nnpm run test\n\n## Stack\n\nTypeScript + [你的框架]\n```\n\n就這麼多。這 5 行就能讓 agent 知道怎麼跑你的專案。\n\n### Month 1：進階版（30 行）\n\n```markdown\n# CLAUDE.md\n\n## Commands\n\nnpm run dev # Start dev server\nnpm run build # Build production\nnpm run test # Run tests\n\n## Architecture\n\nStack: [框架] + [語言] + [CSS 方案]\n\n- src/pages/ — Routes\n- src/components/ — UI components (PascalCase)\n- src/lib/ — Utilities and helpers\n\n## Conventions\n\n- Language: [zh-TW / en]\n- Naming: [PascalCase components, camelCase functions]\n- Use [design tokens / CSS variables] over hardcoded values\n- Prefer [composition over inheritance]\n\n## Key Patterns\n\n- [列出 2-3 個專案裡最常用的 pattern]\n- [比如：所有 API endpoints 在 /api/ 目錄下]\n- [比如：用 Zod 做 input validation]\n```\n\n這 30 行涵蓋了 agent 最常需要的資訊。\n\n### Month 3：完整系統\n\n```\n~/.claude/CLAUDE.md            ← 你的開發哲學（~50 行）\nproject/CLAUDE.md              ← 專案技術棧和慣例（~50 行）\n.claude/rules/testing.md       ← 測試規範\n.claude/rules/deploy.md        ← 部署流程\n~/.claude/skills/commit/       ← git commit 工作流\n~/.claude/skills/review/       ← pre-commit review\n```\n\n不需要一次建好。每次你發現 agent 重複犯同一種錯、或你重複跟 agent 說同一件事的時候，那就是該加一條 rule 或一個 skill 的時機。\n\n**從少開始，需要時再加。CLAUDE.md 是有機生長的文件，不是一次性的架構設計。**\n\n## Takeaway\n\n1. **CLAUDE.md 不是寫一次就忘記的文件**——它是你跟 agent 的持續溝通管道。像 code 一樣，它需要被 version control、被 review、被定期維護。過時的 CLAUDE.md 比沒有 CLAUDE.md 更糟，因為它會誤導 agent。\n\n2. **好的設定檔系統是分層的**——Global（所有專案通用）→ Project（專案特定）→ Rules（場景特定）→ Skills（按需觸發）。每一層有各自的職責，避免一個檔案什麼都放。\n\n3. **從三行開始就好**——build、test、tech stack。隨著需要自然成長。如果 agent 重複犯同一種錯，加一條 rule。如果你重複跟 agent 說同一件事，加一個 skill。47 個設定檔是 12 個月累積的結果，不是一天建好的。\n\n---\n\n_上一篇：[Prompt 到 Production](/blog/agentic-engineering-daily-workflow-advanced)_\n_下一篇：[MCP 與 A2A 協議實戰](/blog/mcp-a2a-protocols-practitioner-guide)_",
      "summary": "CLAUDE.md 不是寫一次就不管的 README。經過一年的迭代，設定檔系統已經從單一檔案演化成多層架構——global、per-project、per-task、per-tool。分享完整的設定檔架構設計、版本演化歷程、和維護心得。",
      "image": "https://bobochen.dev/_astro/cover.3QSKUfeG.webp",
      "date_published": "2026-05-01T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "CLAUDE.md",
        "Claude Code",
        "AI",
        "設定管理"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-rag-architecture/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-rag-architecture/",
      "title": "RAG 架構實戰：從文件 ingestion 到 source-cited 回答的每一步",
      "content_text": "企業 RAG 不是「把文件丟進向量庫然後問問題」這麼簡單。從 ingestion、chunking 策略、embedding、檢索、reranking 到 source citation，拆解每一步的設計取捨與最常見的翻車點，以及為什麼「答案要附來源」是企業信任 AI 的第一道門檻。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 3 篇（共 12 篇）。上一篇：[企業 AI Agent 系統架構藍圖](/blog/enterprise-ai-agent-system-architecture-blueprint)。\n\n「我們做了 RAG」這句話，在 2026 年大概是企業 AI 專案裡最被濫用的一句。因為大部分人說的 RAG，是「把 PDF 切一切丟進向量庫，問問題的時候撈幾段塞給模型」。這個版本在 demo 會動，在 production 會用各種你想不到的方式翻車。\n\n這篇把 RAG 拆成一條真正的 pipeline，一步一步講清楚每一步在幹嘛、設計取捨在哪、以及最容易踩的雷。\n\n## RAG 到底在解什麼問題\n\n先講為什麼需要它。LLM 的知識凍結在訓練那一刻，而且它不知道你公司的事。你有兩條路讓它「知道」：\n\n1. **Fine-tuning**：把東西訓練進模型權重。但它真正擅長的不是塞知識，是**塑行為**——輸出格式、語氣、特定領域的推理模式、你公司的術語。拿它來灌會變動的事實知識才是用錯地方：知識一改就要重訓，而且權重裡的知識沒辦法做權限控制。\n2. **RAG（檢索增強生成）**：知識留在外面，問問題時即時檢索相關片段，當作 context 餵給模型。\n\n企業幾乎都拿 RAG 當起手式，原因不只是便宜——是因為**RAG 的知識可以即時更新、可以做權限、而且答案可以附來源**。最後這點，是企業敢不敢用的關鍵，後面會細講。\n\n但別把這兩條路框成二選一。2026 最強的企業系統其實是 **hybrid：fine-tune 管行為（格式、語氣、術語），RAG 管即時知識與來源 grounding**，各司其職。而 fine-tuning 唯一在成本上真的贏過 RAG 的場景，是 query 量大到誇張、單次推理成本攤平了上線投資——對絕大多數企業，這個臨界點還很遠。\n\n整條 pipeline 長這樣：\n\n![RAG pipeline：文件經 ingestion、chunking、embedding 存進向量庫；問題則檢索 top-k、reranking、組成 context，交給 LLM 生成並附上來源](./images/rag-pipeline.webp)\n\n<p style={{ textAlign: 'center', color: 'var(--color-text-secondary)', fontSize: '0.9rem', lineHeight: 1.6, marginTop: '-0.4rem' }}>RAG 的兩段式流程：文件側做 ingest → chunk → embedding 入庫，查詢側做檢索 → rerank → 組 context → 生成並附上來源。</p>\n\n## 第一步：Ingestion，沒你想得單純\n\nIngestion 就是把各種來源（PDF、Word、Confluence、Notion、資料庫、工單系統）抽成乾淨的文字。聽起來無聊，但**RAG 的品質上限，常常就卡在這一步**。\n\n幾個真實會痛的點：\n\n- **PDF 是地獄**：表格被拆爛、兩欄式排版讀成一團、掃描檔根本是圖片。但解法在 2026 已經換代了——別再停在「先 OCR 抽純文字、再寫一堆後處理清資料」的舊心智模型。傳統的 OCR-only 工具（像 PyMuPDF）正是把表格拆爛的元兇；現在主流是用 **VLM（vision-language model）做版面理解**：看得懂閱讀順序、保得住表格結構、分得出標題段落圖表，直接吐出乾淨的 Markdown / JSON 餵給下游。想自管基礎設施可以看 Docling（開源），想快速起步可用 LlamaParse，其他像 Unstructured、Mistral OCR 也都走這條路。\n- **結構資訊會流失**：標題層級、表格、清單，如果抽成純文字一視同仁，後面檢索就少了很多線索。好的 ingestion 會保留 metadata（這段來自哪份文件、哪個章節、哪一頁）。\n- **更新從哪來**：文件是會改的。你是每天全量重建，還是只處理異動（增量）？production 一定要想清楚，不然要嘛資料過期，要嘛每次重算燒爆。\n\n這一步我的建議是：**先別追求華麗，先把 metadata 留好**。因為下游的權限過濾（第 5 篇）和來源引用，全都靠這些 metadata。\n\n> 先給一個讓你清醒的數字：有預估指出到 2026 年，約 80% 的企業 RAG 專案會踩坑，主因不是模型不夠強，是**資料品質**。治理良好的知識庫，檢索準確率落在 85～92%；同一套系統，知識庫沒治理好，會掉到 45～60%。RAG 的成敗，從文件進來的那一刻就決定了一大半。\n\n## 第二步：Chunking，整條 pipeline 最被低估的一步\n\n文件不能整篇丟進去 embedding——太長、而且會稀釋語意。要切成一塊一塊（chunk）。怎麼切，直接決定檢索準不準。\n\n最天真的做法是「每 500 字切一刀」。問題是它會**從句子中間、甚至從一個概念中間切斷**，檢索回來半句話，模型看了也拼不回原意。\n\n實務上的取捨：\n\n- **Chunk 太大**：一塊塞太多主題，檢索時語意被平均掉，撈回來夾帶一堆無關內容，也更貴。\n- **Chunk 太小**：語意完整但上下文不足，「它」「這個」指的是什麼都不知道了。\n- **Overlap（重疊）**：相鄰 chunk 重疊一小段，避免剛好切斷的概念兩邊都接不上。代價是儲存和檢索量變大。\n- **語意切分 / 結構切分**：照標題、段落、語意邊界切，而不是照字數硬切。但別把它當銀彈——2026 好幾組 benchmark 反而打臉它：有人在學術論文上跑七種策略，最樸素的 recursive 512-token 固定長度切分以約 69% 準確率拿第一，某些語意切分反而掉到 54%，因為它切出一堆平均才 43 token 的碎片。所以務實的順序是：**先用 recursive 固定長度（如 512 token）當穩健基準，只有當 eval 證明值得，才升級到語意 / 結構切分**。重點從「挑一個神奇的 chunk size」變成「偵測並尊重這份語料的原生結構」——法規的條款邊界、程式碼的 function 區塊、技術手冊的章節層級。\n\n> 沒有一組「最佳 chunk 參數」適用所有資料。技術手冊、對話紀錄、法規條文，最佳切法都不一樣。這就是為什麼你需要 eval（第 9 篇）——chunk 策略要用一組黃金問題去量，而不是憑感覺。\n\n## 第三步：Embedding，把語意變成座標\n\n每個 chunk 丟進 embedding 模型，變成一個向量（一串數字），意思相近的內容，向量距離也相近。使用者的問題也變成向量，然後在向量空間裡找最近的幾個 chunk。\n\n這步的選擇（embedding 模型、維度、要不要 hybrid search）牽涉很多取捨，我放到下一篇（第 4 篇）整篇細談，因為它跟向量庫的選型是綁在一起的。這裡先知道：**embedding 模型一旦選定、資料一旦向量化，之後要換模型＝整庫重算**，所以這是個要慎重的決定，不是隨手挑一個。\n\n## 第四步：檢索與 reranking，兩段式才準\n\n檢索 top-k 個最相近的 chunk，是基本動作。但「向量最相近」不等於「對回答最有用」。所以成熟的 RAG 會做兩段：\n\n1. **粗檢索（recall 優先）**：先撈回比較多的候選，比如 top-20。寧可多撈，先別漏。\n2. **Reranking（precision 優先）**：用一個更精準（但更貴）的 reranker 模型，把這 20 個重新排序，挑出真正最相關的 top-3~5 才餵給 LLM。\n\n為什麼要分兩段？因為直接 embedding 相似度有它的盲區（比如同義不同詞、或字面像但語意反）。Reranker 看的是「問題和這段文字的相關性」，通常明顯更準。代價是多一次模型呼叫——又是一個延遲 / 成本 / 品質的取捨（第 10 篇的主題）。\n\n補一句可以直接動手的：這個 reranker 通常就是一個 **cross-encoder**——把問題和候選段落一起餵進模型做 full attention、直接吐出相關性分數；對照之下，粗檢索那段用的是 bi-encoder（query 和文件各自先變向量再比距離），快但較粗。要選型的話，省錢、資料不外送就用開源的 **bge-reranker-v2-m3**（多語），要託管 API 就 **Cohere Rerank**。實測上 cross-encoder 在一般 RAG 能帶來 NDCG 約 +5～15 分、字面困難的資料集甚至更多，額外延遲常壓在 200ms 內。候選撈多少？top-20 是合理下限，很多 production 會撈到 top-50~100 再收斂。\n\n另一個 production 常見的強化是 **hybrid search**：把向量檢索（語意）和關鍵字檢索（BM25，字面）合起來。當使用者問題裡有專有名詞、料號、錯誤碼這種「字面必須精準命中」的東西，純語意檢索反而會漏，關鍵字這時候救場。\n\n在 2026 的企業場景，hybrid 其實已經接近**預設配置**，而不只是「強化」。但兩路結果怎麼合？別自己土法加權——向量分數和關鍵字分數的尺度根本不可比。事實標準是 **Reciprocal Rank Fusion（RRF）**：它用「排名」而不是「分數」運作，繞開了兩邊分數對不齊的校準問題，預設 k=60、每路先撈 20 筆是穩健起手式。一個要先打的預防針：留意你用的引擎是不是真的在跑 BM25——Postgres 原生的 `tsvector` 其實**不是**（第 4 篇細談）。\n\n## 第五步：生成，而且一定要附來源\n\n最後把檢索到的 chunk 組成 context，連同問題一起餵給 LLM 生成答案。看起來這步最簡單，但**企業版和玩具版的差別，全在這一步的兩個要求**：\n\n### 1. Source citation（來源引用）——這是信任的門檻\n\n答案不能只給結論，要能指回「這句話是根據哪份文件、哪一段」。原因很現實：\n\n- 使用者可以**自己驗證**，不用盲信 AI。\n- 出錯時可以**追責、可以修**——是文件本身錯了，還是檢索撈錯了。\n- 它**逼著模型把話收斂在證據裡**，而不是天馬行空。\n\n我會說得更重一點：**沒有來源引用的企業 RAG，不該上 production**。因為你等於要求業務、工程師、主管去相信一個無法驗證的黑盒。第一次它唬爛被抓到，整套系統的信任就崩了。\n\n但這裡要誠實補一刀：引用是必要、但**不充分**。2026 的研究把這件事拆得很細——「引用正確」不等於「答案真的從證據推導出來」。模型常常先用腦袋裡的記憶把答案生出來，再回頭去檢索段落裡撈幾句看起來支持的話貼上去（post-hoc citation），引用乍看都對，實際上答案根本不是從那段證據長出來的。這叫 grounding 的假象，光靠引用擋不住。所以 2026 的信任架構是四層、不是一層：**維護良好的知識庫 → grounding → 每句可追溯的 citation → span-level 驗證**（在答案送到使用者前，逐句檢查每個 claim 是不是真有檢索證據撐著，撐不住的就標出來）。這正好接回第 1 篇的鴻溝一：能附來源 ≠ 能信任，中間還隔著一層驗證。\n\n### 2. Grounding 與「不知道就說不知道」\n\n要在 prompt 和系統設計上明確要求：**只根據檢索到的內容回答；檢索不到依據，就老實說找不到，不要自己編**。\n\n這對抗的就是第 1 篇講的鴻溝一（幻覺）。一個好的企業 RAG，寧可說「這個問題我在現有文件裡找不到明確答案，以下是最相關的幾份文件給你參考」，也不要自信地掰一個聽起來很對的答案。降級成「我幫你找到線索」，比硬給錯答案安全得多。\n\n## 最常見的五個翻車點（直接給你對照）\n\n1. **Chunk 切爛**：檢索回來的片段語意不完整 → 調 chunking 策略、加 overlap、改語意切分。\n2. **檢索撈不到 / 撈錯**：純向量的盲區 → 加 hybrid search、加 reranking。\n3. **答案不附來源**：使用者無法驗證、信任崩盤 → 從 ingestion 就把 metadata 留好，生成時強制引用。\n4. **資料過期**：文件改了庫沒更新 → 設計增量 ingestion 與更新策略。\n5. **沒有 eval**：改了參數不知變好變壞 → 建黃金題庫做回歸（第 9 篇）。\n\n## 小結\n\nRAG 的精髓不是「接得起來」，是這條 pipeline 的每一步——ingestion 留好 metadata、chunking 不切爛語意、檢索做兩段、生成一定附來源——全部到位，它才會從一個「會唬爛的問答玩具」，變成一個「業務敢拿來查、查錯還能追」的企業系統。\n\n下一篇，我們往這條 pipeline 的地基挖：**向量資料庫到底要不要上專用的，還是 PostgreSQL 加個 pgvector 就夠？embedding 模型怎麼選？** 這是會直接影響你成本和延遲的選型題。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"RAG 架構實戰：從文件到 source-cited 回答\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[企業 AI Agent 系統架構藍圖](/blog/enterprise-ai-agent-system-architecture-blueprint)\n- 用 Cloudflare Vectorize 與 AI Gateway 打造 RAG——一個具體的 serverless RAG 實作參考\n- 下一篇：《向量資料庫與 embedding 策略：pgvector vs 專用向量庫》",
      "summary": "企業 RAG 不是「把文件丟進向量庫然後問問題」這麼簡單。從 ingestion、chunking 策略、embedding、檢索、reranking 到 source citation，拆解每一步的設計取捨與最常見的翻車點，以及為什麼「答案要附來源」是企業信任 AI 的第一道門檻。",
      "image": "https://bobochen.dev/_astro/cover.cKVApr_s.webp",
      "date_published": "2026-04-29T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "RAG",
        "向量檢索",
        "LLM",
        "Embedding",
        "知識庫"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-side-project-to-saas/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-side-project-to-saas/",
      "title": "從 Side Project 到 Micro SaaS",
      "content_text": "你的 Side Project 開始有陌生人付費,下一步呢?這篇談 Micro SaaS 的 PMF 信號、用「10 倍價值」法則定價、零行銷預算成長、台灣行號與公司的法律稅務結構,以及「該不該離職」的三道門檻,幫你判斷下一步該怎麼走、何時該認真經營。",
      "content_html": "## 你收到了第一筆陌生人的付款\n\n某天晚上，你的手機跳出一封 Stripe 通知：\n\n「You have a new payment of $9.99 from someone@example.com」\n\n你不認識這個人。他不是你的朋友、不是你的同事、不是你在社群裡拜託的人。他是一個完全陌生的人，在網路上找到了你的產品，認為它值 $9.99，然後付了錢。\n\n如果你有過這個經驗，你知道那種感覺——比第一次拿到薪水還興奮。\n\n接下來你的腦子會開始轉：\n\n「如果一個月有 100 個人付 $9.99，那就是 $999。如果有 1000 個人……等等，我是不是該認真做這件事？要不要辭職？要不要開公司？」\n\n慢下來。\n\n從「收到第一筆付款」到「可以辭職全職做」之間，有一段很長的路。大多數 side project 的結局不是變成獨角獸——而是穩定地每月賺幾百到幾千美元，讓你的生活多一份收入來源。\n\n這一章要幫你想清楚：**你的 side project 到了什麼程度？下一步應該做什麼？**\n\n## 轉折點：什麼信號代表「該認真了」\n\n不是每個 side project 都值得更認真對待。很多 side project 收到幾筆付款之後就停滯了——不是產品不好，而是市場太小、時機不對、或者你本人對這個領域的熱情不夠支撐長期投入。\n\n以下是幾個正向信號：\n\n### 信號 1：留存率 > 獲取率\n\n**這是最重要的信號。**\n\n如果你的用戶來了又走，你需要的不是更多行銷——你需要修產品。但如果用戶來了就留下來，而且一個月後還在用，你手上可能有了 product-market fit。\n\n| 指標            | 健康範圍 | 意義               |\n| --------------- | -------- | ------------------ |\n| 月留存率（D30） | > 40%    | 用戶一個月後還在用 |\n| 週活躍用戶比例  | > 25%    | 不是裝了就忘       |\n| 付費轉換率      | > 2%     | 有人願意為價值付費 |\n| 自然流失率      | < 5%/月  | 用戶不是被你趕走的 |\n\n你不需要精確到小數點。重點是趨勢——留存率是在上升還是下降？\n\n### 信號 2：用戶主動推薦\n\n你沒有做任何行銷，但新用戶告訴你「是朋友推薦的」。\n\n這是最強的 PMF 信號。因為用戶願意把自己的社交信用押在你的產品上——他們不只覺得好用，還覺得好到值得推薦。\n\n### 信號 3：你收到「如果你關掉這個產品我怎麼辦」的訊息\n\n當用戶開始擔心你的產品消失，代表他們已經把你的產品融入了自己的工作流程。這是依賴性——也是你最大的資產。\n\n### 信號 4：你開始拒絕用戶的功能要求\n\n不是因為你沒時間做，而是因為你對產品有了清晰的 vision，你知道什麼該做、什麼不該做。\n\n當你開始有信心說「不」的時候，代表你對產品和市場的理解已經足夠深了。\n\n### 什麼不算信號\n\n- ❌ 「很多人說很酷」——說很酷和願意付費是兩回事\n- ❌ 「Product Hunt 上了首頁」——一次性的流量 spike 不代表持續的需求\n- ❌ 「投資人有興趣」——投資人有興趣不代表用戶有需求\n- ❌ 「技術很厲害」——用戶不在乎你的技術多厲害\n\n## 定價策略：不只是挑一個數字\n\n[第 8 章：付費機制——一個人怎麼收錢](/blog/ai-solo-builder-payment/)我們談了金流串接和基本的定價模式。現在你的產品有了真實用戶，是時候更認真地思考定價了。\n\n### 兩種定價哲學\n\n|          | 成本定價                       | 價值定價                              |\n| -------- | ------------------------------ | ------------------------------------- |\n| **邏輯** | 我的成本是 X，加上利潤 = 售價  | 用戶從中得到的價值是 Y，收 Y 的一部分 |\n| **公式** | 伺服器成本 + 時間成本 + 利潤   | 用戶獲得的價值 × 10-20%               |\n| **問題** | 通常定太低——你的時間被嚴重低估 | 需要理解用戶的價值感知                |\n| **適合** | 成本結構清晰的產品             | 大多數 SaaS 產品                      |\n\n**Solo Builder 最常犯的錯是用成本定價。**\n\n你算了一下：伺服器每月 $5，我每週花 5 小時，一個月 20 小時……然後你定了一個超低的價格，因為你覺得「我又不是什麼大公司」。\n\n錯。\n\n你的產品不是在賣伺服器時間和你的工時。你的產品是在**賣用戶獲得的價值**。\n\n### 「10 倍價值」法則\n\n這是定價最實用的經驗法則：\n\n**你的產品應該為用戶創造至少 10 倍於價格的價值。**\n\n如果你的產品每月收 $10，它應該幫用戶省下至少 $100 的時間、金錢或痛苦。\n\n反過來算：你的產品幫用戶省了多少？把那個數字除以 10，就是你的起始定價。\n\n### AI 輔助定價研究\n\n不確定怎麼定價？讓 AI 幫你分析：\n\n```text\n我的產品是 [描述]，目標用戶是 [描述]。\n\n目前的使用數據：\n- 月活躍用戶：[數量]\n- 免費用戶中有 [X]% 每週使用超過 3 次\n- 用戶平均使用時長：[分鐘/次]\n- 最常用的功能是：[列出]\n\n競品定價：\n- [競品 A]：$X/月\n- [競品 B]：$Y/月\n- [競品 C]：免費（有廣告）\n\n請幫我分析：\n\n1. 我的產品為用戶創造的價值估計（時間節省、金錢節省、\n   或其他可量化的好處）\n2. 基於「10 倍價值」法則，建議的定價範圍\n3. 建議的方案結構（幾個方案、每個方案包含什麼）\n4. 免費版和付費版的功能分界線建議\n5. 年繳折扣策略\n6. 第一年的收入預估（基於不同轉換率假設）\n```\n\n### 定價的三個不要\n\n1. **不要定價 $1-3/月**：太便宜的價格傳遞「這東西不值錢」的訊號。而且你需要 1000 個付費用戶才能有 $1000-3000 的月收入——對一個人來說太難了\n2. **不要一開始就做 lifetime deal**：除非你的成本結構允許。很多 Solo Builder 做 lifetime deal 吸引到一大批「只買便宜」的用戶，之後服務成本拖垮整個產品\n3. **不要害怕漲價**：如果你的轉換率超過 10%，代表你定太低了。漲價試試——最壞的情況是降回去\n\n## 沒有行銷預算的成長\n\n你是一個人，你沒有行銷部門，也沒有廣告預算。但你需要成長。\n\n好消息是：2026 年最有效的成長策略，大部分都是免費的。\n\n### 策略 1：內容行銷（[第 7 章：Landing Page 與 SEO](/blog/ai-solo-builder-landing-page-seo/) 的延伸）\n\n你已經在[第 7 章：Landing Page 與 SEO](/blog/ai-solo-builder-landing-page-seo/)建立了 SEO 基礎。現在把它系統化：\n\n- **每週發一篇文章**：鎖定你的目標用戶會搜尋的關鍵字\n- **文章結尾自然帶入你的產品**：不是硬廣告，而是「順便一提，我做了一個工具來解決這個問題」\n- **建立 email list**：在文章中加入訂閱表單，累積直接觸及用戶的管道\n\n內容行銷是 Solo Builder 最高 CP 值的成長方式。一篇好文章可以帶來好幾年的自然流量。\n\n### 策略 2：推薦計畫\n\n讓現有用戶幫你拉新用戶。\n\n最簡單的推薦計畫：「推薦朋友註冊，你和朋友都獲得一個月免費。」\n\n不需要複雜的 referral 系統。一開始手動處理就好——用戶寄 email 告訴你他推薦了誰，你手動加免費月份。\n\n### 策略 3：在用戶出沒的地方被看見\n\n- **回答 Stack Overflow / Reddit 的相關問題**：不是貼廣告，而是認真回答問題，然後在最後提到你的工具\n- **在相關社群提供價值**：分享你的學習心得、技術筆記。人們會自然好奇你做了什麼\n- **參與開源**：如果你的產品有開源部分，活躍在 GitHub 上\n\n### 不要做的事\n\n- ❌ 一開始就投 Google Ads / Facebook Ads（太貴，轉換率未經驗證）\n- ❌ 同時經營五個社群媒體帳號（選一個深耕）\n- ❌ 花錢請 KOL 推廣（你的規模還不到需要這個的階段）\n\n## 台灣的法律結構：什麼時候該「合法化」\n\n你的產品開始穩定收費了。在台灣，這代表你需要處理一些法律問題。\n\n> **幣別說明**：以下金額為新台幣（NT$），與前文以美元（US$）計的訂閱價與營收門檻不同，請留意。\n\n### 三種選擇\n\n|              | 個人            | 行號               | 有限公司                      |\n| ------------ | --------------- | ------------------ | ----------------------------- |\n| **設立成本** | NT$0            | ~NT$3,000-5,000    | ~NT$15,000-30,000             |\n| **統一編號** | 無              | 有                 | 有                            |\n| **可開發票** | 不行            | 可以               | 可以                          |\n| **責任範圍** | 無限責任        | 無限責任           | 有限責任                      |\n| **稅率**     | 綜所稅（5-40%） | 月銷售額未達 NT$20 萬：1% 營業稅（查定課徵、免開發票）；達 NT$20 萬以上：5% 營業稅 + 綜所稅 | 營所稅 20%                    |\n| **適合**     | 月收 < NT$8,000   | 月收 NT$8,000-50,000 | 月收 > NT$50,000 或需要有限責任 |\n\n> 補充：行號/公司是否開發票、課 1% 還是 5% 營業稅，取決於月銷售額級距。月銷售額未達 NT$20 萬的「小規模營業人」（多數剛開始穩定收費的 side project 屬此）由國稅局每三個月查定課徵 1% 營業稅、免用統一發票；月銷售額達 NT$20 萬以上才使用統一發票、課 5% 營業稅。所以上表「可開發票/5%」是達到使用發票門檻後的情況。\n\n### 什麼時候該行動\n\n| 月營收             | 建議                         |\n| ------------------ | ---------------------------- |\n| < NT$8,000/月        | 以個人身份申報，不用特別處理 |\n| NT$8,000-20,000/月   | 考慮設立行號                 |\n| > NT$20,000/月       | 認真考慮設立有限公司         |\n| 有企業客戶需要統編 | 馬上設立行號或公司           |\n\n**重要提醒**：以上是簡化的參考，不是法律建議。當你的月營收穩定超過 NT$8,000，找一個會計師諮詢。會計師的費用（每月 NT$2,000-5,000）遠低於你自己搞稅務出錯的代價。\n\n### 稅務的基本認知\n\n在你找會計師之前，至少理解這些：\n\n- **個人綜合所得稅**：你的產品收入歸入「營利所得」或「執行業務所得」，跟你的薪資加在一起計算稅率\n- **營業稅**：設立行號或公司後，銷售額需要加收 5% 營業稅\n- **二代健保補充保費**：如果你的執行業務所得單筆超過 NT$20,000，需要扣繳 2.11% 補充保費（2026 年費率）。注意部分兼職／單次給付未達當年度基本工資（2026 年為 NT$29,500）者另有免扣規定，細節問會計師\n\n不要被這些嚇到。先專注把產品做好、把收入做起來。法律和稅務的問題，等你有穩定收入再處理。\n\n## 「該不該離職」決策框架\n\n每個做 side project 做出成績的人，遲早會面對這個問題。\n\n我的建議是：**不要太早做這個決定。** 大多數 Solo Builder 高估了全職做產品的好處，低估了失去穩定收入的壓力。\n\n### 離職前的三個門檻\n\n你應該同時滿足以下三個條件，才認真考慮離職：\n\n**門檻 1：財務安全墊**\n\n- 銀行裡有 **12 個月的生活費**（不靠產品收入也能活一年）\n- 產品連續 **6 個月** 有穩定或成長的收入\n- 產品月收入 ≥ 你月薪的 **50%**\n\n**門檻 2：可逆性評估**\n\n問自己：如果全職做一年後失敗了，我能回到現在的位置嗎？\n\n| 因素     | 低風險                       | 高風險         |\n| -------- | ---------------------------- | -------------- |\n| 就業市場 | 你的技術搶手，隨時能找到工作 | 你的領域縮編中 |\n| 家庭狀況 | 單身或雙薪無小孩             | 單薪養家       |\n| 年齡     | < 35 歲                      | > 45 歲        |\n| 存款     | > 24 個月生活費              | < 12 個月      |\n| 產品趨勢 | 連續成長                     | 持平或波動     |\n\n如果你的大部分因素在「高風險」那一欄，先不要離職。\n\n**門檻 3：混合路徑可不可行**\n\n在「全職上班」和「辭職 all-in」之間，還有一個選項：**跟老闆談轉兼職或遠端。**\n\n| 路徑              | 優點              | 缺點         |\n| ----------------- | ----------------- | ------------ |\n| 繼續全職 + 下班做 | 零風險            | 時間有限     |\n| 轉兼職（60-80%）  | 多出 1-2 天做產品 | 收入減少     |\n| 轉遠端全職        | 通勤時間省下來    | 需要老闆同意 |\n| 離職全職做        | 時間最多          | 風險最高     |\n\n**我的建議順序**：先談遠端 → 再談兼職 → 最後才考慮離職。\n\n很多人以為只有「留下」或「走」兩個選項。但混合路徑往往是最聰明的選擇——你保留了安全網，同時多出了做產品的時間。\n\n## 不靠招人的 Scale\n\n你的產品成長了，事情越來越多。你的直覺可能是「我需要找人幫忙」。\n\n先等一下。\n\n在 2026 年，一個人能做的事比以前多得多。在你找人之前，先問：**這件事能不能用 AI 或自動化解決？**\n\n### 用 AI 替代人力的場景\n\n| 傳統做法     | AI 替代方案               | 省下的時間  |\n| ------------ | ------------------------- | ----------- |\n| 請人做客服   | AI chatbot + 回覆模板     | ~10 小時/週 |\n| 請人寫部落格 | AI 初稿 + 你修改          | ~5 小時/篇  |\n| 請人做 QA    | AI 測試生成 + 自動化測試  | ~8 小時/週  |\n| 請人做設計   | AI 設計工具 + 模板        | ~5 小時/週  |\n| 請人做帳     | 會計軟體 + 會計師（外包） | 全部        |\n\n這張表只列了「省下的時間」，但每一格其實都有沒寫出來的代價，我自己踩過才知道：\n\n- **AI 客服答錯一次，賠掉的信任很難補回來**。模板回得快，但它分不出哪句是「我想退費」哪句是「我考慮退費」，答錯一次客人就在公開平台開罵。所以我的 chatbot 只敢處理 FAQ，碰到金額、退款、情緒激動的訊息一律轉人工。\n- **AI 初稿不是「寫好的文章」，是「需要查核的草稿」**。我寫過一篇被它編出一個根本不存在的數據，差點就發出去。省下的 5 小時，有一半要還回去做事實查核和改語氣，不然讀者一眼看穿是 AI 寫的。\n- **AI 生的測試會給你「綠燈的安全感」**。它很會產表面覆蓋率，但常常漏掉真正會出事的 edge case，偶爾還誤報讓你浪費時間追假 bug。我把它當補網用，核心流程的測試還是自己寫。\n- **AI 設計工具產出的東西長得都一樣**。套模板很快，但你的產品會跟其他一百個套同款模板的產品撞臉，品牌調性吃重的地方（首頁、定價頁、logo）我還是寧可找人。\n\n還有一件最容易被忽略的：**自動化本身就是一份要持續維護的工作**。API 改版、模板過期、prompt 飄掉，這些都要人盯。AI 不是讓工作消失，是把「自己做事」換成「管一套會出錯的系統」——換了一種工作，不是沒有工作。\n\n所以與其問「能不能用 AI」，更準的問法是「這件事錯了的代價有多高」。高情緒、高金額、需要專業判斷的客服，吃品牌調性的設計，還有任何法遵、合約、隱私相關的內容，這些我不會太早交給 AI。\n\n### 什麼時候真的需要找人\n\n當以下情況出現時，AI 和自動化可能不夠了：\n\n- **你的客服量大到 AI chatbot 處理不了**：需要人類判斷的複雜問題超過你一個人能應付\n- **你的產品需要你不會的技能**：例如你是後端工程師，但產品急需專業的 UI 設計\n- **你的時間瓶頸不在「做事」而在「決策」**：你需要一個人來執行你的決策，而不是幫你做決策\n- **營收足以支撐**：找人的成本不應該超過營收的 30%\n\n### 第一個人不要找全職\n\n你的第一個「團隊成員」應該是：\n\n1. **外包的會計師**（處理稅務和帳務，每月 NT$2,000-5,000）\n2. **按件計酬的設計師**（需要設計時才付費）\n3. **兼職的客服人員**（如果客服量真的太大）\n\n不要一開始就找全職員工。全職員工是固定成本——不管你的營收是多少，每個月都要付薪水。按件計酬或兼職的彈性高得多。\n\n## AI 輔助的商業決策\n\n這一章涉及很多「該不該」的判斷。AI 可以幫你更結構化地思考：\n\n```text\n我是一個有正職的 Solo Builder，以下是我的現況：\n\n產品：[描述]\n月營收：$[金額]，過去 6 個月趨勢 [成長/持平/下降]\n月活躍用戶：[數量]\n付費用戶：[數量]\n我的正職月薪：$[金額]\n存款：[月數] 個月的生活費\n家庭狀況：[描述]\n每週投入產品的時間：[小時]\n\n我在考慮 [全職做產品 / 轉兼職 / 維持現狀但投入更多]。\n\n請幫我分析：\n1. 基於數據，我的產品處於什麼階段？\n2. 從財務安全的角度，我適合做這個決定嗎？\n3. 最大的風險是什麼？怎麼降低？\n4. 如果你是我的商業顧問，你會建議什麼？\n5. 有沒有我沒考慮到的選項？\n```\n\nAI 不會替你做決定。但它會幫你看到你可能忽略的角度——尤其是當你被興奮或焦慮蒙蔽判斷力的時候。\n\n## 本章重點回顧\n\n- 🎯 PMF 的真正信號是留存率和口碑推薦，不是一次性的流量或讚美\n- 💰 用「10 倍價值」法則定價：你的產品創造的價值 ÷ 10 = 起始價格\n- 📈 沒有行銷預算就靠內容行銷、推薦計畫、社群曝光——全部免費\n- ⚖️ 台灣法律：月收 < NT$8,000 不用特別處理，超過就找會計師\n- 🚪 離職三門檻：12 個月存款 + 6 個月穩定收入 + 可逆性評估\n- 🤖 找人之前先問：這件事 AI 或自動化能不能做？第一個人找外包不找全職\n\n## 下一步\n\n到這裡，我們已經走完了從點子到 Micro SaaS 的完整理論框架。\n\n但理論終究是理論。下一章，我要把話筒交給**真實案例**——完整拆解我自己邊上班邊做的四個產品，每一個的技術決策、AI 使用方式、時間花費、和我踩過的坑。\n\n紙上談兵結束，看真的。\n\n👉 [第 13 章：實戰案例——我的四個產品](/blog/ai-solo-builder-case-studies)",
      "summary": "你的 Side Project 開始有陌生人付費,下一步呢?這篇談 Micro SaaS 的 PMF 信號、用「10 倍價值」法則定價、零行銷預算成長、台灣行號與公司的法律稅務結構,以及「該不該離職」的三道門檻,幫你判斷下一步該怎麼走、何時該認真經營。",
      "image": "https://bobochen.dev/_astro/cover.xm07R0-f.webp",
      "date_published": "2026-04-26T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Micro SaaS",
        "定價策略",
        "Side Project",
        "創業"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-system-architecture-blueprint/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-system-architecture-blueprint/",
      "title": "企業 AI Agent 系統架構藍圖：一張圖看懂能上 production 的 agent 長什麼樣",
      "content_text": "把上一篇的六道鴻溝收斂成一張參考架構：從 API Gateway、Agent Runtime、Tool Registry、權限感知檢索、Memory Store 到 Model Router 與可觀測性旁路。每個元件為什麼存在、少了它會出什麼事、以及怎麼跟你既有的後端整合而不是另起一座孤島。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 2 篇（共 12 篇）。上一篇：[為什麼企業 AI Agent 卡在 PoC？六道鴻溝](/blog/enterprise-ai-agent-poc-to-production-gap)。\n\n上一篇講了 demo 到 production 的六道鴻溝。這一篇要做的事很簡單：**把那六道鴻溝，收斂成一張可以照著蓋的圖**。\n\n先把話說在前面：下面這張是**參考架構（reference architecture，一個設計提案）**，不是某個我已經部署在某公司的成品。它的價值不在「我蓋過」，而在「每一個方塊的存在理由，都能對回上一篇的某一道鴻溝」。你大可以照你的規模刪減——但刪掉的時候，你會知道自己放棄了哪一道防線。\n\n## 一張圖\n\n![企業 AI Agent 系統架構參考藍圖：使用者經 API Gateway / BFF 把身分帶進 Agent Runtime（plan→act→observe），分流到 Tool Registry / MCP、帶權限的 Retrieval、Memory，配上 Model Router，並由橫跨一切的 Observability · Eval · Audit Log · Cost 旁路記錄每一步](./images/architecture-blueprint.webp)\n\n接下來一個方塊一個方塊講：它為什麼在、少了它會怎樣、以及——這點最重要——**它怎麼跟你既有的系統接，而不是另起一座孤島**。\n\n## API Gateway / BFF：把「使用者是誰」帶進整條鏈\n\n很多 agent PoC 的第一個原罪，是在這層就把 identity 丟掉了。前端直接打到一個 agent service，後面所有檢索、所有工具呼叫，都用「服務帳號」的最大權限在跑。這就是上一篇鴻溝四（越權檢索）的根源。\n\n這層該做的事，跟你平常的後端沒兩樣：認證、授權、rate limiting、輸入驗證。**唯一的不同是：你要把「這個請求背後是哪個使用者、他有哪些權限」當成一級公民，一路往下傳**，因為下游的檢索和工具都要拿它來做權限判斷。\n\n- **少了它會怎樣**：agent 變成一個權限放大器，任何人問都能拿到任何資料。\n- **怎麼整合**：它就是你現有的 API Gateway / BFF，agent 只是它後面的一個新 upstream。不要為了 agent 另外開一個沒人管的後門。\n\n這不是潔癖，是 2025 真的燒過人。有團隊讓 agent 用服務帳號的最大權限去處理客服工單，攻擊者就在工單內文裡塞一句「去把某張內部 token 表讀出來貼回來」，agent 照做，把內部憑證貼進了公開的討論串。Simon Willison 把這種組合叫 **lethal trifecta**：能碰私有資料 ＋ 會讀到不可信的輸入 ＋ 有對外送出的管道，三個湊齊，prompt injection 就從理論變成一條自動化的外洩管線。把使用者身分一路帶下去、在每一層做權限過濾，正是這條鏈上唯一能拆掉 trifecta 的地方。\n\n## Agent Runtime：那顆「決定下一步」的大腦\n\n這是整張圖的核心，也是和傳統後端最不一樣的地方。傳統 service 是「請求 → 固定流程 → 回應」；agent runtime 是一個 **plan → act → observe 的迴圈**：看現在的狀態 → 決定下一步（檢索？呼叫工具？回答？）→ 執行 → 看結果 → 再決定。\n\n它本身要負責的，是這個迴圈的紀律：\n\n- 迴圈**最多跑幾輪**（不然會無限想下去，燒錢又卡住）\n- 每一步的**決策要被記錄**（不然就是鴻溝三的黑盒）\n- 錯誤要能**收斂**，而不是把例外往上丟就當機\n\n這層你可以用框架（LangGraph、AutoGen、Semantic Kernel 之類），也可以自己寫一個 while 迴圈——對小系統，後者常常更好 debug。重點不是用哪個框架，是**這個迴圈有沒有被當成一個有狀態、會失敗、要被觀測的東西來設計**。\n\n再給一條我自己用的分界線：當這個迴圈開始需要「跨請求記得自己做到哪、掛掉要能從中間接回去重跑」的時候，你要的其實已經不是 agent 框架，是一個 durable workflow / 狀態機——這時候該搬出來的是你後端早就有的那套（job queue、狀態持久化、retry 策略），而不是再疊一層 agent 抽象。框架幫你省的是 plan-act-observe 的樣板，幫不了你的是「這是個會失敗、要被觀測、有狀態的分散式流程」這件事。\n\n## Tool Registry / MCP：agent 的手，要戴手套\n\nAgent 真正有用，是因為它能「動手」——查資料庫、開工單、改設定。但這也是它最危險的地方。所以工具不該是散落在 code 裡的一堆 function，而該是一個**有登記、有邊界、有審批的 registry**。\n\n我自己寫過 MCP server，把工具用標準介面接給模型之後，最大的感受是：**MCP 的價值不只是「接得上」，是它把「agent 能做哪些事」變成一份可以盤點、可以審核的清單**。這在企業裡是治理的基礎。\n\n先擋一個你可能會問的事：選 MCP 當這層介面，不是押 Anthropic。MCP 雖然是 Anthropic 在 2024 年底提出的，但 2025 年 3 月 OpenAI、4 月 Google 相繼採納，到 2026 它已經是跨 Claude、ChatGPT、Gemini、Cursor、VS Code 的共通標準，並交給了中立基金會治理。換句話說，你蓋這層接的是一個 vendor-neutral 的標準——哪天想把底層模型換一家，registry 這層幾乎不用動。在企業架構裡，能降低 vendor lock-in，本身就是一個架構決策。\n\n每個工具至少要標：\n\n- **action boundary**：它是唯讀（查詢）還是會改變世界（寫入、刪除、送出）？\n- **approval flow**：會改變世界的，要不要人類按一下確認（human-in-the-loop）？\n- **idempotency**：同一個呼叫不小心跑兩次，會不會送出兩張訂單？\n\n- **少了它會怎樣**：上一篇鴻溝六——agent 用錯工具，真的會動到錢、改到資料。\n- **怎麼整合**：tool 後面接的就是你現有的內部 API。MCP / registry 是包在外面那層「戴手套」的設計，不是要你重寫後端。\n\n還有一個 2026 才被認真對待的維度：工具的「描述」本身就是攻擊面。MCP 把工具描述塞進模型 context，等於把一段文字直接餵給大腦——如果這段描述被動過手腳（tool poisoning），它可以在模型不知情下夾帶指令，而近年針對真實 MCP server 的測試顯示這類攻擊的成功率高得驚人。所以 registry 的審核不只是「盤點 agent 能做哪些事」，還包括「這些工具的描述是誰寫的、有沒有被改過」——把工具當成依賴（dependency）來管，連同描述一起進版控與審查，而不是裝上就信。\n\n（第 6 篇會專門拆 tool use 與 MCP，第 11 篇談把它升級成完整治理框架。）\n\n## Retrieval Layer：RAG，而且是帶權限的 RAG\n\n這層讓 agent「知道公司的事」。文件、知識庫、資料庫，先變成向量存起來，問問題的時候檢索出最相關的幾段，餵給模型當依據。\n\n但企業版的 RAG 跟 demo 版差一個關鍵字：**權限**。檢索的時候，必須用「上面那層傳下來的使用者身分」去過濾——只檢索這個人本來就有資格看的東西。這是一道過濾器，不是事後再補的 nice-to-have。\n\n- **少了它會怎樣**：要嘛 agent 不知道公司任何事（沒用），要嘛它什麼都知道、包括不該讓這個人知道的（資安事故）。\n- **怎麼整合**：向量庫可以從你既有的 PostgreSQL + pgvector 開始，不一定要先上專用向量庫。\n\n（第 3、4、5 篇是這層的三連發：RAG 架構、向量庫與 embedding、權限感知檢索。）\n\n## Memory Store：讓 agent 記得，但別記錯人的事\n\nRetrieval 是「公司的知識」，memory 是「這個對話 / 這個任務的脈絡」。兩者不一樣。Memory 又分短期（這輪對話）、長期（這個使用者的偏好）、episodic（這個任務做到哪、試過什麼）。\n\n關鍵原則跟檢索一樣：**記憶也有權限**。A 使用者的長期記憶不能洩進 B 使用者的對話。聽起來理所當然，但在共用向量庫的設計裡很容易出包。\n\n（第 7 篇專講 memory 與狀態管理。）\n\n## Model Router：不要每件事都用最貴的模型\n\n把「呼叫哪個模型」抽成一層，而不是 hardcode。為什麼？因為 production 你會想要：\n\n- **小模型優先**：分類、抽取、簡單問答，用便宜快的模型就好\n- **必要才升級**：複雜推理才動用大模型\n- **fallback**：主模型掛了或回垃圾，自動換一條路\n\n這層直接對應鴻溝五（成本與延遲）。在 demo 全用最強模型沒事，乘上 production 流量就是帳單。給個量感你就懂為什麼這層值得蓋：同一個分類或抽取任務，旗艦模型和它的小型版之間，每百萬 token 的價差常常是一個數量級——把不需要推理的雜活全丟給最強模型，等於用頭等艙的票價在寄明信片。Model Router 的工作，就是讓 80% 的便宜雜活走便宜的路，只把真正需要深推理的那 20% 升級上去。\n\n（第 10 篇談這層背後的延遲 / 可靠性 / 成本三角。）\n\n## 橫跨一切的旁路：可觀測性、Eval、Audit、Cost\n\n最後這條橫線，是把前面所有方塊從「demo」升級成「production」的東西。它不是某一個元件，是**每一個元件都要往這裡吐紀錄**：\n\n- **Observability / Trace**：每個請求的完整足跡——檢索了什麼、呼叫了哪些工具、每步多少 token / 多少秒。\n- **Eval**：一組黃金題庫，每次改 prompt / 換模型 / 調檢索，自動跑回歸。\n- **Audit Log**：誰、在什麼時間、透過 agent 存取了什麼資料、做了什麼動作。企業合規的底線。\n- **Cost Monitor**：每個功能、每個使用者燒多少錢，要看得見。\n\n- **少了它會怎樣**：鴻溝二和三——你不知道它有沒有變好，出事也查不到根因。\n\n（第 9 篇把這條旁路整個建起來。）\n\n## 這張圖最重要的一句話\n\n如果這篇你只記得一件事，請記這個：\n\n> AI agent 系統不是一個 LLM 加上一些 prompt，它是一個**分散式系統**，只是其中一個元件剛好會講人話。\n\n所有你在做後端時的硬功夫——latency、retry、idempotency、狀態管理、queue、cache、權限、可觀測性——在這裡一個都不會少，反而因為多了一個「行為不確定」的元件而更重要。這也是為什麼我會說，能把這張圖蓋起來的人，骨子裡是個系統工程師，不是 prompt 工程師。\n\n下一篇開始，我們從這張圖的右半邊——**Retrieval Layer**——往下挖，先講 RAG 架構怎麼從一堆文件，變成一個會附來源的答案。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"企業 AI Agent 系統架構藍圖\" />\n\n---\n\n### 延伸閱讀\n\n- 上一篇：[為什麼企業 AI Agent 卡在 PoC？六道鴻溝](/blog/enterprise-ai-agent-poc-to-production-gap)\n- [Agentic Engineering 是什麼？](/blog/agentic-engineering-what-is-it)——換個角度看「用 agent 做工程」與「打造 agent 系統」的差別\n- 下一篇：《RAG 架構實戰：從文件 ingestion 到 source-cited 回答》",
      "summary": "把上一篇的六道鴻溝收斂成一張參考架構：從 API Gateway、Agent Runtime、Tool Registry、權限感知檢索、Memory Store 到 Model Router 與可觀測性旁路。每個元件為什麼存在、少了它會出什麼事、以及怎麼跟你既有的後端整合而不是另起一座孤島。",
      "image": "https://bobochen.dev/_astro/cover.DmwSG7s7.webp",
      "date_published": "2026-04-25T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "AI Agent",
        "系統架構",
        "Reference Architecture",
        "LLMOps",
        "後端架構"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/what-is-mcp-claude-plugin-system/",
      "url": "https://bobochen.dev/blog/what-is-mcp-claude-plugin-system/",
      "title": "MCP 到底是什麼？一篇讀懂 Claude 的插件系統",
      "content_text": "MCP（Model Context Protocol）是讓 Claude 能呼叫外部工具的標準協議。這篇從零解釋 MCP 的核心概念、和一般 API 的差異，以及如何用 FastMCP 在 10 分鐘內跑起第一個本地工具。",
      "content_html": "## 背景\n\n常常有人問我：「Claude 可以連到外部工具嗎？可以查資料庫、呼叫 API、讀本機檔案嗎？」\n\n答案是可以的，靠的就是 MCP——Model Context Protocol。\n\n但每次我說出「MCP」這三個字，下一個問題幾乎都是「那是什麼」。這篇就從頭說清楚。\n\n## 發現過程：Claude 為什麼不能直接呼叫 API？\n\n先搞清楚問題本身。\n\n假設你希望 Claude 能查今天的天氣，最直覺的想法是：讓 Claude 直接打一個 HTTP request 給氣象 API，拿到資料後回答你。\n\n但有兩個問題：\n\n**安全性**：Claude 是一個語言模型，不是一個可以任意執行代碼的程式。讓 AI 隨意呼叫外部服務，沒有任何控制機制，後果很難預測——它可能呼叫到你不想讓它碰的東西。\n\n**標準化**：如果每個工具都有自己的呼叫方式，Claude 要怎麼「自動」知道每個工具的介面？你寫的工具是用 REST？GraphQL？還是自訂格式？模型沒辦法猜。\n\nMCP 的出現，就是為了解決這兩件事。\n\n它定義了一個標準的 client-server 協議：\n\n- **MCP Server**：你寫的程式，負責暴露工具給 Claude 用\n- **MCP Client**：Claude Desktop 或 Claude Code，負責呼叫你的工具\n\nClaude 不是「自己去呼叫外部 API」，而是「透過 MCP 協議，呼叫你已經寫好、已經受控的工具」。這個設計讓整件事變得安全、可預期、而且可組合。\n\n## 核心概念：用 USB 類比說清楚\n\n我覺得最好的類比是 USB 標準。\n\n**MCP 就像 USB 規格**\n\n以前的電腦周邊設備，每家廠商都有自己的接頭——滑鼠有滑鼠的接頭，鍵盤有鍵盤的接頭，印表機又是另一種。USB 統一了這個標準之後，只要符合 USB 規格，插上去就能用，不管是哪家廠商做的裝置。\n\nMCP 做的事情一模一樣：只要你的工具符合 MCP 規格，Claude 就知道怎麼用——不管你的工具是查天氣、讀資料庫、還是操作 Notion。\n\n**MCP Tool 就像 USB 插頭**\n\n每個 MCP tool 都有三樣東西：名稱、參數定義、回傳值。這就是「插頭的規格」。你定義好，Claude 自動知道什麼情況下該用哪個工具、要傳什麼參數進去。\n\n**Claude Desktop config 就像裝置管理員**\n\nWindows 的裝置管理員告訴作業系統：「這台電腦上有哪些 USB 裝置」。Claude Desktop 的 config 做一樣的事：告訴 Claude「這台機器上有哪些 MCP Server 可以用」。\n\n## 具體數據：一個最小可用的 MCP Server\n\n說了這麼多概念，來看實際程式碼長什麼樣子。\n\n### 安裝\n\n```bash\npip install mcp\n```\n\n### 最小 Server 範例\n\n下面這個 server 只有一個工具——`greet`，輸入名字，回傳打招呼的字串。\n\n```python\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"my-first-server\")\n\n@mcp.tool()\ndef greet(name: str) -> str:\n    \"\"\"輸入名字，回傳打招呼的字串。\"\"\"\n    return f\"哈囉，{name}！這是你的第一個 MCP 工具。\"\n\nif __name__ == \"__main__\":\n    mcp.run()\n```\n\n就這樣。10 行，一個可以被 Claude 呼叫的工具就存在了。\n\n[FastMCP](https://github.com/jlowin/fastmcp) 把 MCP 協議的底層細節全包起來，你只需要寫 `@mcp.tool()` decorator，剩下的它處理。\n\n### 加進 Claude Desktop Config\n\n找到這個檔案：`~/Library/Application Support/Claude/claude_desktop_config.json`\n\n加入以下內容：\n\n```json\n{\n  \"mcpServers\": {\n    \"my-first-server\": {\n      \"command\": \"/usr/local/bin/python3\",\n      \"args\": [\"/path/to/your/server.py\"]\n    }\n  }\n}\n```\n\n注意 `command` 要填你系統實際的 Python 路徑（用 `which python3` 確認），`args` 填你的 server 檔案位置。\n\n### 確認工具出現\n\n完全重啟 Claude Desktop（不是關視窗再開，是從選單列 Quit 掉再重開）。\n\n重開之後，直接問 Claude：「你現在有哪些工具？」\n\n如果設定正確，它會列出你剛才定義的 `greet` 工具。然後你就可以說：「用 greet 工具，幫我打個招呼，名字是 Bobo」——Claude 會真的呼叫你的 Python function，回傳結果。\n\n## 3 分鐘快速上手 Checklist\n\n1. **安裝**：`pip install mcp`\n2. **寫 server**：建立 `.py` 檔，加上 `FastMCP` 和至少一個 `@mcp.tool()`\n3. **本機確認**：直接跑 `python3 server.py`，沒有 error 代表可以啟動（它會等待 stdin，Ctrl+C 結束）\n4. **確認 Python 路徑**：`which python3`（重要，多版本環境容易填錯）\n5. **加 config**：填進 `claude_desktop_config.json`\n6. **完全重啟** Claude Desktop\n7. **驗證**：問 Claude「你有哪些工具？」\n\n## 反思\n\n### 技術面：MCP 和傳統 REST API 的差異\n\n傳統的 REST API 是**你的 app 主動呼叫**：你的程式在某個時機點，決定要打一個 HTTP request，拿到資料，再做後續處理。整個流程的控制權在你手上。\n\nMCP 是**Claude 主動呼叫**：你定義好工具，Claude 根據對話情境，自己判斷什麼時候該用哪個工具、傳什麼參數。你不需要寫「呼叫工具的邏輯」，那是 Claude 的工作。\n\n這個差異比看起來大。它意味著你不需要事先知道「使用者會問什麼」——只要工具定義清楚，Claude 自己會想辦法把對話和工具對應起來。\n\n### 心態面：標準化協議為什麼重要\n\nMCP 的設計目標是讓**任何 LLM 都能用同一套工具**——不只是 Claude。只要是支援 MCP 的客戶端，都可以呼叫你寫的 server。\n\n這讓工具的投資報酬率變高：你做一次，可以被 Claude、被其他支援 MCP 的 AI agent 重複使用。生態系的邏輯就是這樣建立起來的。\n\n### 有趣發現：MCP server 不需要對外開放\n\n很多人第一次聽到「server」會直覺想到「要部署到雲端」、「要有 public IP」。\n\n但 MCP server 完全可以跑在本機，甚至必須是本機——Claude Desktop 的 config 直接指向你的本機 Python 腳本，Claude 透過 stdio（標準輸入輸出）和 server 溝通，不需要任何 HTTP、不需要 port、不需要雲端。\n\n你的工具，只跑在你的電腦上，只有你的 Claude 能用，資料不出你的機器。這對很多涉及私人資料的應用場景來說，是比任何雲端 API 更好的選擇。",
      "summary": "MCP（Model Context Protocol）是讓 Claude 能呼叫外部工具的標準協議。這篇從零解釋 MCP 的核心概念、和一般 API 的差異，以及如何用 FastMCP 在 10 分鐘內跑起第一個本地工具。",
      "image": "https://bobochen.dev/_astro/cover.BsYJspxv.webp",
      "date_published": "2026-04-25T00:00:00.000Z",
      "tags": [
        "MCP",
        "Claude",
        "Python",
        "FastMCP",
        "入門"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-daily-workflow-advanced/",
      "url": "https://bobochen.dev/blog/agentic-engineering-daily-workflow-advanced/",
      "title": "一個需求從 Prompt 到 Production 的完整旅程",
      "content_text": "一個真實的功能需求，從收到 ticket 到最終 deploy，全程用 agentic workflow 完成——包含完整的 prompt、agent 的回應、review 過程、CI 結果、和最後的 deploy log。零理論，純實戰。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第七篇。上一篇：[Agent 產出品質保證](/blog/agent-output-verification-review)\n\n## 這篇沒有理論，只有一個完整的實戰紀錄\n\n前幾篇我們聊了 [Context Engineering](/blog/context-engineering-deep-dive)、[Spec-Driven Development](/blog/spec-driven-development-for-agents)、[品質保證](/blog/agent-output-verification-review)。每篇都有它的理論框架。\n\n但理論歸理論，你可能最想看的是實際操作起來到底長什麼樣。\n\n這篇就是答案。一個真實的 feature request——在 Astro 部落格裡加上「文章閱讀進度條」和「預估閱讀時間」功能。從收到需求到 deploy 上 production，我會把每一個步驟、每一段 prompt、每一次 agent 回應、每一次我介入的時刻都記錄下來。\n\n## 任務背景\n\n**需求來源**：我自己在分析部落格的 analytics 時發現，長文的跳出率偏高。假設之一是讀者不知道文章有多長、讀到哪裡了，缺乏「進度感」。\n\n**功能描述**：\n\n1. 頁面頂部顯示閱讀進度條（scroll-based progress bar）\n2. 文章開頭顯示預估閱讀時間（estimated reading time）\n3. 只在 blog post 頁面顯示，其他頁面不要\n\n**複雜度評估**：中等。涉及一個新 component、一段 client-side JS、一個 build-time 計算。不是 trivial，但也不需要架構重構。\n\n**為什麼選這個任務示範**：它有代表性——有 UI component、有邏輯計算、有跨 layer 的整合（build-time + client-side）。而且它有明確的決策點和至少一次 iteration。\n\n## Step 1: Spec 撰寫（8 分鐘）\n\n根據 [Post 5](/blog/spec-driven-development-for-agents) 的 template，我花 8 分鐘寫了這份 spec：\n\n```markdown\n## Task: Blog Reading Progress & Estimated Read Time\n\n### Goal\n\n在 blog 文章頁面加兩個功能：\n\n1. 頁面頂部固定的閱讀進度條（隨滾動更新）\n2. 文章 metadata 區塊顯示預估閱讀時間\n\n### Context\n\n- Astro 5 + MDX + Tailwind CSS 4 + TypeScript\n- Blog layout: src/layouts/BlogLayout.astro\n- 現有 design tokens: --color-primary, --color-bg-primary\n- Dark mode 透過 .dark class 切換\n- 已有 View Transitions，注意 script 在導航後要重新綁定\n\n### Constraints\n\n- 不引入任何 npm dependency\n- 進度條用 CSS custom properties + Tailwind\n- 閱讀時間在 build time 計算，不在 client side\n- 中文內容以每分鐘 400 字計算（非英文的 200 wpm）\n- 進度條只在 blog post 頁面出現\n- 進度條不能蓋住 navigation bar\n\n### Files to modify\n\n- src/layouts/BlogLayout.astro（加入 component）\n- 可新增 src/components/blog/ReadingProgress.astro\n- 可新增 src/components/blog/ReadingTime.astro\n\n### Verification Criteria\n\n1. 滾動頁面時，頂部進度條從 0% 填充到 100%\n2. 進度條在到達文章底部時是 100%，在頂部是 0%\n3. 顯示格式：「約 N 分鐘閱讀」\n4. 2000 字文章 → 顯示「約 5 分鐘閱讀」\n5. Dark mode 下進度條顏色正確\n6. 在非 blog 頁面（如 /about）不顯示進度條\n7. npm run build 通過，無 TypeScript 錯誤\n\n### Out of Scope\n\n- 不做 reading analytics（不追蹤用戶實際閱讀行為）\n- 不做 bookmark / save progress 功能\n- 不做 table of contents（之後另外做）\n```\n\n**關鍵決策標注**：\n\n- 中文 400 字/分鐘而非英文 200 wpm——這個如果不寫，agent 一定會用英文標準算\n- 「不引入 npm dependency」——防止 agent 引入 reading-time 之類的 library\n- 「進度條不能蓋住 navigation bar」——這是一個很容易被忽略的 UI 細節\n- View Transitions 相關的提醒——Astro 的 View Transitions 會在頁面導航後讓 client-side script 失效，agent 需要知道這個 gotcha\n\n## Step 2: Context 準備（3 分鐘）\n\nCLAUDE.md 已經有了大部分必要的 context（tech stack、project structure、conventions）。額外需要的只有兩件事：\n\n1. 讓 agent 看一下 `BlogLayout.astro` 的現有結構，知道 component 要插在哪裡\n2. 確認 design tokens 的變數名\n\n這些不需要我手動貼——agent 會自己 `Read` 相關檔案。但在 spec 裡提到「design tokens: --color-primary」是有幫助的，讓 agent 知道去找什麼。\n\n## Step 3: Agent 執行（Hands-off, 25 分鐘）\n\n我把 spec 貼給 Claude Code，然後做了一件很重要的事——離開電腦去倒咖啡。\n\n不是因為我不在乎，是因為 agent 沒人干預的時候通常做得更好。你如果一直盯著它、中途插嘴「等等那個不對」，反而會打斷它的 execution flow。\n\n以下是 agent 的工作 log（摘要）：\n\n### Agent 行動記錄\n\n**00:00-02:00** — 探索 codebase\n\n```\nAgent: 讀取 BlogLayout.astro... 了解現有結構\nAgent: 讀取 global.css... 找到 design tokens\nAgent: 讀取 BaseLayout.astro... 了解 nav 的 position\nAgent: 讀取已有的 blog component... 理解命名慣例\n```\n\n**02:00-08:00** — 建立 ReadingProgress.astro\n\n```\nAgent: 建立 src/components/blog/ReadingProgress.astro\n- Fixed position bar at top of page\n- Uses --color-primary for fill color\n- z-index lower than nav bar\n- Client-side script for scroll tracking\n- Handles View Transitions with astro:after-swap\n```\n\n**08:00-14:00** — 建立 ReadingTime.astro\n\n```\nAgent: 建立 src/components/blog/ReadingTime.astro\n- Receives body content as prop\n- Strips HTML/MDX tags, counts Chinese characters\n- Calculates: Math.ceil(charCount / 400)\n- Displays \"約 N 分鐘閱讀\"\n```\n\n**14:00-20:00** — 整合到 BlogLayout.astro\n\n```\nAgent: 修改 BlogLayout.astro\n- Import ReadingProgress + ReadingTime\n- ReadingProgress placed after nav, before main content\n- ReadingTime placed in article header metadata section\n- Passes compiled content body to ReadingTime for word count\n```\n\n**20:00-25:00** — 自我驗證\n\n```\nAgent: npm run build... ✅ 通過\nAgent: 檢查 TypeScript... ✅ 無錯誤\nAgent: 在 dev server 確認 progress bar 行為...\n```\n\n整個過程 25 分鐘。Agent 做了我預期的所有事情，加上一個我沒想到但很好的細節：它在 progress bar 上加了 `transition: width 100ms ease` 讓滾動更平滑。\n\n## Step 4: Review & 迭代（12 分鐘）\n\n咖啡泡好了，回來看 agent 的產出。\n\n### 第一輪 Review：發現兩個問題\n\n**問題 1**：ReadingTime 的字數計算有 bug\n\nAgent 的做法是先 strip HTML tags 再計算中文字數。邏輯上對，但它用的 regex 沒有處理 MDX component tags（像 `<CodeBlock>...</CodeBlock>`）。這些 tag 的內容會被計入字數，導致閱讀時間偏長。\n\n**問題 2**：Progress bar 在手機上太粗\n\nAgent 設了 4px 高度，在桌面上剛好，在手機上佔了太多視覺空間。\n\n### Feedback Prompt\n\n```\n兩個修正：\n\n1. ReadingTime 的字數計算：MDX component tags 的內容也被計入了。\n   strip HTML 的 regex 需要同時處理 <ComponentName>...</ComponentName>\n   這種 self-closing 和 pair 的 MDX tags。或者更簡單的做法：\n   用 Astro 的 compiledContent 而不是 rawContent。\n\n2. Progress bar 高度：桌面 4px OK，手機改成 3px。\n   用 Tailwind 的 responsive: h-[3px] md:h-[4px]\n```\n\n注意我的 feedback 方式：\n\n- **具體描述問題**，不是說「這個不對」\n- **給出可能的修正方向**，但不規定具體做法\n- 第一個問題提了兩個可能的方向（改 regex 或換 API），讓 agent 選更適合的\n\n### 第二輪：Agent 修正\n\nAgent 選了「用 `compiledContent` 而不是 `rawContent`」的方案（更簡潔），修改了 progress bar 的 responsive height。\n\n3 分鐘完成。重新跑 `npm run build`，通過。\n\n## Step 5: CI/CD & Deploy\n\n代碼完成，進入自動化流程：\n\n```bash\n# Commit\ngit add src/components/blog/ReadingProgress.astro \\\n        src/components/blog/ReadingTime.astro \\\n        src/layouts/BlogLayout.astro\ngit commit -m \"feat(blog): add reading progress bar and estimated reading time\n\n- Fixed progress bar at page top, scroll-synced 0-100%\n- Reading time calculated at build time (400 chars/min for zh-TW)\n- Responsive height: 3px mobile, 4px desktop\n- Handles Astro View Transitions correctly\"\n```\n\nPush → GitHub Actions → Cloudflare Workers deploy。\n\nCI 跑了 2 分鐘：\n\n- TypeScript check ✅\n- ESLint ✅\n- Build ✅\n- Deploy to staging ✅\n\n在 staging 快速驗證：\n\n- 長文（3000 字）→ 顯示「約 8 分鐘閱讀」 ✅\n- 滾動 → progress bar 正確填充 ✅\n- Dark mode → 顏色正確 ✅\n- /about 頁面 → 沒有 progress bar ✅\n- 手機寬度 → 3px 高度 ✅\n\nDeploy to production。完成。\n\n## 時間對比：60 分鐘 vs 以前的半天\n\n### 這次的 Agentic Workflow\n\n| 步驟            | 時間         | 誰做的              |\n| --------------- | ------------ | ------------------- |\n| 寫 Spec         | 8 分鐘       | 我                  |\n| Context 準備    | 3 分鐘       | 我                  |\n| Agent 執行      | 25 分鐘      | Agent（我去倒咖啡） |\n| Review          | 7 分鐘       | 我                  |\n| Feedback + 修正 | 5 分鐘       | 我 + Agent          |\n| CI/CD + 驗證    | 12 分鐘      | 自動化              |\n| **總計**        | **~60 分鐘** |                     |\n\n### 如果用傳統方式\n\n| 步驟                         | 時間        |\n| ---------------------------- | ----------- |\n| 理解需求 + 研究做法          | 30 分鐘     |\n| 寫 ReadingProgress component | 45 分鐘     |\n| 寫 ReadingTime component     | 30 分鐘     |\n| 整合到 BlogLayout            | 20 分鐘     |\n| 處理 View Transitions        | 30 分鐘     |\n| 寫 CSS / responsive          | 20 分鐘     |\n| 測試 + debug                 | 45 分鐘     |\n| CI/CD + 驗證                 | 12 分鐘     |\n| **總計**                     | **~4 小時** |\n\n**4x 的時間差**。而且注意，在 agentic workflow 裡，我的「工作時間」只有 ~23 分鐘（寫 spec + review + feedback），其他時間都是 agent 和 CI 在工作。\n\n### 但不是每個任務都這麼理想\n\n公平地說，這是一個「適合 agentic workflow」的任務——需求明確、scope 有限、技術棧 agent 很熟。\n\n以下是 agentic workflow **不太適合**的情況：\n\n| 場景                          | 原因                            | 建議                         |\n| ----------------------------- | ------------------------------- | ---------------------------- |\n| 全新的架構設計                | Agent 不知道你的業務 constraint | 你先設計，agent 來實作       |\n| 跨多個 repo 的變更            | Context 太分散                  | 拆成每個 repo 一個 task      |\n| Debug 未知的 production issue | 需要即時互動和直覺              | 你帶頭，agent 輔助           |\n| 涉及敏感權限的操作            | 安全風險                        | 你自己做，agent 不碰         |\n| 第三方 API 整合（文件不完整） | Agent 可能 hallucinate API      | 你先確認 API，agent 來寫整合 |\n\n## 每個決策點的標注\n\n回顧整個流程，有幾個關鍵的決策點值得標記：\n\n### 決策 1：寫 Spec 還是直接叫 Agent 做？\n\n**選擇**：寫 Spec。\n\n**原因**：這個功能涉及 UI + 計算 + 整合，如果不寫 spec，agent 很可能做出我不想要的東西（比如用 npm library、用英文 wpm、progress bar 蓋住 nav）。8 分鐘的 spec 投資，預防了至少 1 小時的拆除工作。\n\n### 決策 2：離開電腦 vs 盯著 Agent 做\n\n**選擇**：離開。\n\n**原因**：過去的經驗告訴我，中途干預通常讓事情變慢而不是變快。讓 agent 完整跑一輪，然後統一 review 和 feedback，比每幾分鐘插嘴一次快很多。\n\n### 決策 3：怎麼給 Feedback\n\n**選擇**：描述問題 + 給方向，不規定做法。\n\n**原因**：「用 compiledContent 而不是 rawContent」是一個 hint，不是指令。Agent 有能力判斷哪種做法更適合——事實上它選了更乾淨的方案。如果我硬是叫它改 regex，它也會照做，但那可能不是最好的解法。\n\n### 決策 4：這個 PR 是合成一個還是拆兩個？\n\n**選擇**：合成一個。\n\n**原因**：ReadingProgress 和 ReadingTime 雖然是兩個 component，但它們服務同一個 feature，而且修改的文件有重疊（都要改 BlogLayout.astro）。拆成兩個 PR 反而增加 overhead。\n\n## 流程的核心公式\n\n把整個流程提煉成一個公式：\n\n```\nAgentic Workflow =\n  高品質 Spec（10 分鐘）\n  + 充足 Context（CLAUDE.md + codebase）\n  + Hands-off 執行（讓 agent 完整跑一輪）\n  + 精準 Review（focus 在 agent 特有的錯誤模式）\n  + 快速 Feedback Loop（描述問題 + 給方向）\n```\n\n每一步都不難。難的是**紀律**——在你想偷懶直接打「幫我加一個功能」的時候，花 10 分鐘寫一份好的 spec。在你想中途插嘴的時候，忍住去倒杯咖啡。在你覺得「agent 的 code 看起來很專業不用看了」的時候，還是打開 diff 仔細 review 一遍。\n\n這個紀律，就是 Agentic Engineering 和 Vibe Coding 的分界線。\n\n## Takeaway\n\n1. **Agentic workflow 的核心是「前置投資」**——Spec 和 Context 的品質決定後續效率。8 分鐘的 spec + 3 分鐘的 context 準備，讓 25 分鐘的 agent 執行幾乎零障礙。\n\n2. **60 分鐘完成以前半天的工作，靠的不是魔法，是方法論**——但前提是任務適合 agentic workflow、你的 spec 夠好、你的 CI 夠完善。不是所有任務都適合。\n\n3. **最重要的技能不是寫 prompt，是知道什麼時候介入、什麼時候放手**——讓 agent 完整跑一輪再統一 review，比每隔幾分鐘干預一次更有效率。你的角色是 reviewer 和 direction-setter，不是 co-pilot seat 的指揮官。\n\n---\n\n_上一篇：[Agent 產出品質保證](/blog/agent-output-verification-review)_\n_下一篇：[CLAUDE.md 與 Rules Files 大師班](/blog/claude-md-rules-files-masterclass)_",
      "summary": "一個真實的功能需求，從收到 ticket 到最終 deploy，全程用 agentic workflow 完成——包含完整的 prompt、agent 的回應、review 過程、CI 結果、和最後的 deploy log。零理論，純實戰。",
      "image": "https://bobochen.dev/_astro/cover.xm07R0-f.webp",
      "date_published": "2026-04-24T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "工作流程",
        "AI",
        "CICD",
        "實戰紀錄"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-agent-sdk-intro/",
      "url": "https://bobochen.dev/blog/claude-api-guide-agent-sdk-intro/",
      "title": "Agent SDK 入門：從 API 到 Agentic 應用",
      "content_text": "為什麼有了 Messages API 還需要工具循環封裝；Tool Use 的 agentic loop 痛點；用 Claude 建 agent 的兩條真實路徑（官方 SDK 的 beta tool runner 與 Managed Agents）；Python + TypeScript 安裝與 Hello World；何時手寫 loop、何時用 tool runner；與 LangChain 的比較。",
      "content_html": "這一章標誌著這本書的轉折點。\n\n前幾章我們學的是 Messages API——你問一個問題，Claude 給一個答案，任務結束。這個模式能解決很多問題，但它有一個根本限制：**Claude 只能做一步**。\n\n真實世界的很多任務需要多步驟：找資料、分析資料、根據分析做決定、執行動作、再根據執行結果調整。這類任務靠 Messages API 加上工具（tool use）可以實作，但你要自己寫的膠水代碼很多。\n\n這一章，我們要把這些膠水代碼交給工具去處理，正式踏進 agentic 應用的世界。\n\n## 先說清楚：Anthropic 沒有一個叫「Agent SDK」的套件\n\n在開始之前，我要先誠實地破除一個常見誤會，免得你照著別的教學去 `pip install` 結果裝不到東西。\n\n網路上你會看到很多「Agent / Runner / Handoff」風格的範例——定義一個 `Agent` 類別、用一個 `Runner` 跑它、再用 `handoff()` 把任務交接給另一個 agent。**那是 OpenAI Agents SDK 的形狀，不是 Anthropic 的。** Anthropic 並沒有發布一個獨立的、長那個樣子的「Agent SDK」套件。如果你照抄，第一行 import 就會失敗。\n\n那麼，用 Claude 到底要怎麼建 agent？真實的路徑有兩條：\n\n1. **Claude API + Tool Use（你或 SDK 來跑 agentic loop）** ← 這本書接下來主要走這條\n   - 安裝官方 SDK：Python 是 `anthropic`、TypeScript 是 `@anthropic-ai/sdk`\n   - 用官方 SDK 內建的 **tool runner（beta）**：它自動幫你跑「呼叫工具 → 回灌結果 → 再問」的循環\n   - 或自己**手寫 agentic loop**：當你要細控流程（人工審批、條件式執行、自訂 log）時，自己跑一個 `while` 迴圈\n2. **Managed Agents（beta）**：Anthropic 在伺服器端幫你跑 loop、還幫你託管一個執行工具的 container（透過 `client.beta.agents` / `client.beta.sessions`）。適合「server 端有狀態、需要 workspace」的場景。\n\n這本書的後續章節以**路徑 1** 為主軸——也就是用官方 SDK 的 tool runner 跟手寫 loop。Managed Agents 我們只在「何時用什麼」跟多 agent 的段落點到，因為它細節變動快，我不想教你寫到後來不能跑的程式碼。\n\n口語上，我還是會把「帶 system prompt + 一組工具、跑在 agentic loop 裡的 Claude 呼叫」叫做一個 **agent**——這個詞沒問題，問題只在「不要把它想成某個 `Agent(...)` 類別」。\n\n## 先說 Messages API + Tool Use 的痛點\n\n第四章我們講了 Tool Use（工具呼叫）。回顧一下那個循環：\n\n```\n你 → Claude（送問題 + 工具定義）\n     ↓\n     Claude（決定用什麼工具，回傳 tool_use block）\n     ↓\n你的代碼（執行工具，得到結果）\n     ↓\n你 → Claude（送工具結果，繼續對話）\n     ↓\n     Claude（可能再用另一個工具，或直接回答）\n     ↓\n     ...重複直到 Claude 不再呼叫工具\n```\n\n這個循環你需要自己實作。不難，但每個做 agentic 應用的人都要重新寫一遍。而且，隨著任務複雜度增加，你還需要：\n\n- 管理對話歷史（上下文）\n- 處理工具執行錯誤\n- 限制最大迴圈次數（防止無限循環）\n- 支援平行工具呼叫\n- 在你的程式碼裡做 agent 之間的路由（routing）\n- 在適當時機暫停等待人工確認（human-in-the-loop）\n\n這些功能每個都不難，但全部加起來要寫幾百行代碼，而且容易出 bug。\n\n**這正是 tool runner 想幫你消掉的東西**——它就是官方 SDK 幫你把那個 while 迴圈封裝好的工具。\n\n## 真實的心智模型：工具 + tool runner + agentic loop\n\n把前面 OpenAI 風格的「Agent / Runner / Tool / Handoff」忘掉，換成 Claude 真正的三個概念：\n\n### 1. 工具（Tool）\n\n工具就是你給 Claude 使用的函式。官方 SDK 提供了裝飾器，讓你把一個普通函式直接變成工具，連 JSON Schema 都不用手寫：\n\n```python\nfrom anthropic import beta_tool\n\n@beta_tool\ndef get_weather(location: str, unit: str = \"celsius\") -> str:\n    \"\"\"Get current weather for a location.\n\n    Args:\n        location: City and state, e.g., San Francisco, CA.\n        unit: Temperature unit, either \"celsius\" or \"fahrenheit\".\n    \"\"\"\n    return f\"72°F and sunny in {location}\"\n```\n\n`@beta_tool` 會讀你的型別提示跟 docstring 裡的 `Args:`，自動生成這個工具的 input schema。你定義的是「Claude 能做什麼」，不是某個類別。\n\n### 2. tool runner（取代你心中的 Runner）\n\ntool runner 是官方 SDK 內建的東西，它負責跑那個工具呼叫循環——呼叫工具、把結果回灌給 Claude、再問下一步，直到 Claude 不再呼叫任何工具為止。你不用自己管那個 while 迴圈：\n\n```python\nimport anthropic\nfrom anthropic import beta_tool\n\nclient = anthropic.Anthropic()  # 從 ANTHROPIC_API_KEY 讀金鑰\n\nrunner = client.beta.messages.tool_runner(\n    model=\"claude-opus-4-8\",\n    max_tokens=16000,\n    tools=[get_weather],\n    messages=[{\"role\": \"user\", \"content\": \"What's the weather in Paris?\"}],\n)\n```\n\n注意：`tool_runner` 是 `client.beta.messages` 底下的一個方法，**不是一個你要 import 的 `Runner` 類別**。它回傳的東西可以直接迭代，每跑一圈 yield 一個 `BetaMessage`。\n\n### 3. agentic loop（那個「自動跑到底」的循環）\n\n上面 `tool_runner` 之所以省事，就是因為它把 agentic loop 包起來了。所謂 agentic loop，就是「Claude 想用工具 → 跑工具 → 把結果還給 Claude → Claude 看了結果再決定」這個重複過程，自動跑到 Claude 講完話為止。\n\n那「Handoff（交接給另一個 agent）」呢？**Claude 沒有這個原語。** 多 agent 系統在 Claude 的世界裡，是你**在自己的程式碼裡做路由**——orchestrator 依判斷去呼叫對應的 subagent 函式。這部分我們在第十一章（多 agent）會展開，這裡先知道「沒有 `handoff()` 這種魔法、是你自己 route」就好。\n\n## 安裝\n\n裝的是**官方 SDK**，不是什麼 agent 專用套件。\n\nPython：\n\n```bash\npip install anthropic\n```\n\nTypeScript / Node.js：\n\n```bash\nnpm i @anthropic-ai/sdk\n```\n\nimport 的方式：\n\n```python\n# Python\nimport anthropic\nfrom anthropic import beta_tool\n```\n\n```typescript\n// TypeScript\nimport Anthropic from \"@anthropic-ai/sdk\";\nimport { betaZodTool } from \"@anthropic-ai/sdk/helpers/beta/zod\";\n```\n\n確保你已設定 `ANTHROPIC_API_KEY` 環境變數，SDK 會自動讀取。\n\n## 第一個 Agent：查天氣\n\n讓我們從一個最小可跑的例子開始：一個能查天氣的 agent。這裡用 tool runner，讓 SDK 幫我們跑 loop。\n\n```python\nimport anthropic\nfrom anthropic import beta_tool\n\nclient = anthropic.Anthropic()\n\n\n@beta_tool\ndef get_weather(location: str, unit: str = \"celsius\") -> str:\n    \"\"\"Get current weather for a location.\n\n    Args:\n        location: City and state, e.g., San Francisco, CA.\n        unit: Temperature unit, either \"celsius\" or \"fahrenheit\".\n    \"\"\"\n    # 真實情況這裡會去打某個天氣 API；這裡先回傳假資料示範\n    return f\"72°F and sunny in {location}\"\n\n\n# tool runner 自動處理 agentic loop：\n# 呼叫工具、回灌結果、再問，直到 Claude 不再呼叫工具\nrunner = client.beta.messages.tool_runner(\n    model=\"claude-opus-4-8\",\n    max_tokens=16000,\n    tools=[get_weather],\n    messages=[{\"role\": \"user\", \"content\": \"What's the weather in Paris?\"}],\n)\n\n# 每個 iteration yield 一個 BetaMessage；Claude 講完就停\nfor message in runner:\n    print(message)\n```\n\n執行這段代碼，背後發生的事是：\n\n1. Claude 收到問題，判斷需要查天氣，回傳一個 tool_use（要呼叫 `get_weather`）\n2. tool runner **自動**執行你的 `get_weather` 函式，把結果回灌給 Claude\n3. Claude 看到天氣資料，生成一段給人看的回答，不再呼叫工具\n4. 因為沒有更多 tool_use，loop 自動結束\n\n整個工具呼叫循環你一行都沒寫——這就是 tool runner 的價值。\n\n如果你的工具是 I/O 密集（要打外部 API），可以改用 async 版：把 import 換成 `from anthropic import beta_async_tool`，工具寫成 `async def`，其餘形狀一樣。\n\n### 想自己掌控每一步？手寫 agentic loop\n\ntool runner 很方便，但有時候你需要**細控**——例如某些工具要先經人工審批、要依條件決定跑不跑、要記自訂 log。這時就自己跑 loop。這也是看清楚「tool runner 到底幫你做了什麼」的最好方式：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\ntools = [...]   # 工具的 JSON 定義（見下一段）\nmessages = [{\"role\": \"user\", \"content\": user_input}]\n\nMAX_ITERATIONS = 10   # 防無限循環：自己加計數器\nfor _ in range(MAX_ITERATIONS):\n    response = client.messages.create(\n        model=\"claude-opus-4-8\",\n        max_tokens=16000,\n        tools=tools,\n        messages=messages,\n    )\n    if response.stop_reason == \"end_turn\":\n        break  # Claude 講完了\n\n    tool_use_blocks = [b for b in response.content if b.type == \"tool_use\"]\n    messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n    tool_results = []\n    for tool in tool_use_blocks:\n        result = execute_tool(tool.name, tool.input)   # 你的實作\n        tool_results.append({\n            \"type\": \"tool_result\",\n            \"tool_use_id\": tool.id,      # 必須對應 tool_use 的 id\n            \"content\": result,\n        })\n    messages.append({\"role\": \"user\", \"content\": tool_results})\n\nfinal_text = next(b.text for b in response.content if b.type == \"text\")\n```\n\n幾個一定要知道的點：\n\n- **`stop_reason` 是 loop 的方向盤**。可能的值：`end_turn`（講完了，跳出）、`max_tokens`（被 `max_tokens` 截斷）、`tool_use`（要呼叫工具，繼續跑）、`pause_turn`（server 端工具暫停，可續跑）、`refusal`（安全拒答，看 `stop_details`）。\n- **工具錯誤**：在那筆 tool_result 裡加 `\"is_error\": True`，把錯誤訊息塞進 `content`，讓 Claude 知道工具掛了、自己想辦法。\n- **防無限循環**：手寫 loop 一定要加 `MAX_ITERATIONS` 計數器跳出。tool runner 則是「沒有 tool_use 就自動停」，不會無止盡跑。\n\n那這個手寫 loop 用的工具 JSON 定義長怎樣？沒有裝飾器幫你時，就自己寫：\n\n```python\ntools = [{\n    \"name\": \"get_weather\",\n    \"description\": \"Get current weather for a location\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"location\": {\"type\": \"string\", \"description\": \"City and state\"},\n            \"unit\": {\"type\": \"string\", \"enum\": [\"celsius\", \"fahrenheit\"]},\n        },\n        \"required\": [\"location\"],\n    },\n}]\n# 強制這一回合只能用某工具：tool_choice={\"type\": \"tool\", \"name\": \"get_weather\"}\n# 要嚴格結構化：工具裡加 \"strict\": True，並讓 input_schema 有 additionalProperties: False\n```\n\n## TypeScript 版本\n\nTypeScript 這邊一樣有 tool runner，搭配 `betaZodTool` 用 Zod 定義工具的 input schema，型別安全又不用手寫 JSON Schema：\n\n```typescript\nimport Anthropic from \"@anthropic-ai/sdk\";\nimport { betaZodTool } from \"@anthropic-ai/sdk/helpers/beta/zod\";\nimport { z } from \"zod\";\n\nconst client = new Anthropic();\n\nconst getWeather = betaZodTool({\n  name: \"get_weather\",\n  description: \"Get current weather for a location\",\n  inputSchema: z.object({\n    location: z.string().describe(\"City and state, e.g., San Francisco, CA\"),\n    unit: z.enum([\"celsius\", \"fahrenheit\"]).optional(),\n  }),\n  run: async (input) => `72°F and sunny in ${input.location}`,\n});\n\nconst finalMessage = await client.beta.messages.toolRunner({\n  model: \"claude-opus-4-8\",\n  max_tokens: 16000,\n  tools: [getWeather],\n  messages: [{ role: \"user\", content: \"What's the weather in Paris?\" }],\n});\n\nconsole.log(finalMessage.content);\n```\n\n`client.beta.messages.toolRunner(...)` 跟 Python 的 `tool_runner` 是同一件事的 TS 版——一樣自動跑 agentic loop、跑到 Claude 不再呼叫工具為止，回傳最終的 message。\n\n如果你要細控，TS 也能手寫 loop，要點跟 Python 一樣（看 `stop_reason === \"end_turn\"` 跳出，把 tool_result 串回 `messages`）：\n\n```typescript\nimport Anthropic from \"@anthropic-ai/sdk\";\nconst client = new Anthropic();\n\nlet messages: Anthropic.MessageParam[] = [{ role: \"user\", content: userInput }];\nconst MAX_ITERATIONS = 10;\nfor (let i = 0; i < MAX_ITERATIONS; i++) {\n  const response = await client.messages.create({\n    model: \"claude-opus-4-8\",\n    max_tokens: 16000,\n    tools,\n    messages,\n  });\n  if (response.stop_reason === \"end_turn\") break;\n\n  const toolUseBlocks = response.content.filter(\n    (b): b is Anthropic.ToolUseBlock => b.type === \"tool_use\",\n  );\n  messages.push({ role: \"assistant\", content: response.content });\n\n  const toolResults: Anthropic.ToolResultBlockParam[] = [];\n  for (const t of toolUseBlocks) {\n    toolResults.push({\n      type: \"tool_result\",\n      tool_use_id: t.id,\n      content: await executeTool(t.name, t.input),\n    });\n  }\n  messages.push({ role: \"user\", content: toolResults });\n}\n```\n\n## Agent vs Assistant：Agency 的概念\n\n很多人分不清「AI 助手」和「AI Agent」的差異。我的理解是：**Agency（代理能力）= 自主決定下一步的能力**。\n\n| 特性     | AI 助手          | AI Agent               |\n| -------- | ---------------- | ---------------------- |\n| 執行步驟 | 一步（問一答一） | 多步（自主決定做什麼） |\n| 工具使用 | 手動觸發         | 自主選擇和調用         |\n| 目標導向 | 回答問題         | 完成任務               |\n| 錯誤恢復 | 需要人干預       | 可以自主嘗試不同方法   |\n| 適用場景 | 問答、諮詢       | 研究、執行、自動化     |\n\n從「建立助手」升級到「建立代理」，技術上的關鍵差別其實就一句話：**有沒有那個 agentic loop**。助手是一次 `messages.create` 拿一個答案；代理是把 `messages.create` 放進一個會根據工具結果繼續跑的循環裡。tool runner 幫你跑這個循環，你只要把目標跟工具給它，它會想辦法達成，而不是每一步都要你告訴它怎麼做。\n\n## 何時手寫 loop、何時用 tool runner、何時上 Managed Agents？\n\n這個問題我被問過很多次，我的判斷框架是這樣的：\n\n**用 tool runner 當（多數情況的預設）：**\n\n- 任務需要動態決策（不知道幾步才能完成），但你不需要在每一步插手\n- 你只是想「給目標 + 一組工具，讓它自己跑到底」\n- 你想少寫膠水代碼、快速做出可跑的 agent\n\n**自己手寫 agentic loop 當：**\n\n- 你需要在某些工具執行前插入**人工審批**\n- 你要依條件決定跑哪些工具、或自訂每一步的 log / 中斷邏輯\n- 你要做很客製的錯誤處理或重試策略\n- 講白了：當 tool runner 的「全自動」不夠細，你需要方向盤\n\n**考慮 Managed Agents 當：**\n\n- 你需要 server 端有狀態的、長時間執行的 agent，還要一個託管的 workspace / container 來實際執行工具\n- 你不想自己維運那個執行環境\n\n而最基本的——**只需要一到兩步、不需要任何工具循環**的任務（純文字生成、分類、翻譯），根本不用 agent，直接一次 `messages.create` 最省事、延遲最低。\n\n我的實踐原則：**先用最單純的 `messages.create`；當工具循環邏輯開始出現，就用 tool runner；只有當你發現需要細控每一步時，才退回手寫 loop。** 不要一開始就過度工程化。\n\n## 與 LangChain、LlamaIndex 的比較\n\n如果你之前用過 LangChain 或 LlamaIndex，你可能在想：既然有官方 SDK 的 tool runner，為什麼還會有人用這些框架？\n\n我的觀點：\n\n**直接用官方 Anthropic SDK（+ tool runner）的優勢：**\n\n- 官方出品，對 Claude 的所有功能（extended thinking、prompt caching 等）支援最直接、最新\n- 抽象層少，你看得到底層的 `messages` / `tool_use` / `tool_result`，debug 容易\n- 跟 Claude 功能同步更新，不用等第三方框架跟進\n- 學習曲線平：tool runner 就是把你已經懂的 Messages API 包了一層 loop\n\n**LangChain 的優勢：**\n\n- 生態系豐富，有大量現成 integrations（向量資料庫、各種 API）\n- 支援多種 LLM（不限於 Claude），抽象層幫你抹平 provider 差異\n- 有更成熟的 RAG pipeline 工具\n- 社群更大\n\n**LlamaIndex 的優勢：**\n\n- 專注於資料索引和 RAG，這方面深度更好\n- 對結構化資料的支援更豐富\n\n我的建議：如果你的應用 100% 用 Claude，從官方 SDK + tool runner 開始，因為它最直接、抽象最少、最容易 debug。如果你需要豐富的第三方整合，或者未來可能換模型，LangChain 這類框架是合理的選擇。\n\n## 設計時要記得的幾個原則\n\n在繼續往下蓋之前，幾個會讓你的 agent 程式碼更乾淨、更好維護的原則：\n\n**1. 工具盡量寫成純函式、可重複呼叫**\n\nagentic loop 裡 Claude 可能因為前一次結果不理想而再呼叫同一個工具，所以你的工具實作最好是無副作用的（或至少冪等），能承受重複呼叫。會「動到外部世界」的工具（送錢、寄信、刪資料）要特別小心，這類最適合放在手寫 loop 裡加人工審批。\n\n**2. 狀態在 `messages`，不在某個物件**\n\nClaude 這條路沒有什麼神祕的「agent 記憶體物件」。整段對話的狀態——使用者問了什麼、Claude 呼叫過哪些工具、工具回傳了什麼——全部就是那串 `messages` 歷史。要做「記憶」，就是把相關歷史串進 `messages`，或用 memory 類工具把長期記憶外掛出去。這也是為什麼手寫 loop 裡我們一直在 `messages.append(...)`。\n\n**3. async 優先（尤其 I/O 密集）**\n\n工具大多是去打外部 API 的 I/O 操作，寫成 async 能讓多個工具呼叫平行跑。Python 用 `AsyncAnthropic` + `beta_async_tool`，TypeScript 本來就是 async。\n\n**4. 可觀測性：用 SDK log + request id，不要幻想有 tracing 開關**\n\nClaude 這邊沒有一個「打開就有漂亮 trace」的開關。要看 agent 每一步在幹嘛，務實的做法是：\n\n```python\n# 1) 環境變數開 SDK 詳細 log\n#    ANTHROPIC_LOG=debug\n\n# 2) 直接印出訊息歷史，看 Claude 呼叫了哪些工具、拿到什麼\nprint(messages)\n\n# 3) 每個 response 都有 request id，回報問題時附上它\nresponse = client.messages.create(model=\"claude-opus-4-8\", max_tokens=16000,\n                                  messages=messages)\nprint(response._request_id)\n```\n\n手寫 loop 的好處之一，就是你想在哪一步插 log、插 metric、插 breakpoint，完全自由。\n\n**5. 結構化輸出與錯誤處理（順手補上）**\n\n要 Claude 回一個你能直接用的物件，可以用 `parse` 搭配 Pydantic：\n\n```python\nfrom pydantic import BaseModel\n\nclass Insight(BaseModel):\n    title: str\n    detail: str\n\nresp = client.messages.parse(\n    model=\"claude-opus-4-8\",\n    max_tokens=16000,\n    messages=[...],\n    output_format=Insight,\n)\ndata = resp.parsed_output   # 已驗證的 Insight\n```\n\nSDK 也有一整組例外類別可以 catch：`anthropic.BadRequestError`(400)、`AuthenticationError`、`PermissionDeniedError`、`NotFoundError`、`RateLimitError`、`RequestTooLargeError`(413)、`APIStatusError`、`APIConnectionError`、`APITimeoutError`。其中 429 跟 5xx，SDK 預設會自動重試（`max_retries` 預設 2），所以多數暫時性錯誤你不用自己處理。\n\n---\n\n理解了「工具 + tool runner + agentic loop」這套真實心智模型之後，是時候動手建一個真正有用的 agent 了。\n\n下一章是這本書技術深度最高的一章：我們要從頭打造一個能搜尋網路、查詢資料庫、生成報告的 Research Agent，涵蓋工具設計、用 `messages` 管狀態、錯誤處理、測試策略的完整代碼。如果你一直想知道「真實的 agent 應用長什麼樣」，下一章就是答案。",
      "summary": "為什麼有了 Messages API 還需要工具循環封裝；Tool Use 的 agentic loop 痛點；用 Claude 建 agent 的兩條真實路徑（官方 SDK 的 beta tool runner 與 Managed Agents）；Python + TypeScript 安裝與 Hello World；何時手寫 loop、何時用 tool runner；與 LangChain 的比較。",
      "image": "https://bobochen.dev/_astro/cover.CkwO4WOT.webp",
      "date_published": "2026-04-24T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Agent SDK",
        "Agentic Engineering",
        "代理系統"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/enterprise-ai-agent-poc-to-production-gap/",
      "url": "https://bobochen.dev/blog/enterprise-ai-agent-poc-to-production-gap/",
      "title": "為什麼企業 AI Agent 卡在 PoC？從 demo 到 production 的六道鴻溝",
      "content_text": "兩天就做出一個會查資料、會呼叫 API 的 AI agent demo，老闆很興奮——然後它在 production 待了六個月還上不了線。這篇拆解 demo 到 production 之間最容易被低估的六道鴻溝，以及為什麼「能動」離「能信任」還很遠。",
      "content_html": "import DeckEmbed from '../../../components/blog/DeckEmbed.astro';\nexport const deckSlides = Object.entries(import.meta.glob('./slides/*.webp', { eager: true, import: 'default' })).sort(([a], [b]) => a.localeCompare(b)).map(([, m]) => m);\n\n> 這是「從 PoC 到 Production：企業 AI Agent 系統工程」系列第 1 篇（共 12 篇）。這個系列不教你怎麼呼叫 LLM API——那部分網路上已經夠多了。它要補的，是 demo 到 production 之間那段最難、最少人寫、卻決定一個 AI agent 專案會不會死在 PoC 的工程。\n\n## 兩天的 demo，六個月的卡關\n\n我見過太多這樣的場景：一個工程師花兩天，用 Claude 或 GPT 串了一個 agent，會讀公司文件、會查資料庫、會呼叫內部 API，丟幾個問題進去，答得有模有樣。Demo 給主管看，全場點頭，「這個一定要做」。\n\n然後這個專案就卡在那裡，六個月上不了線。\n\n不是因為團隊不努力，也不是因為模型不夠強。而是因為**做出一個會動的 demo，跟交付一個企業敢信任的 production 系統，中間隔著一條大部分人沒看見的鴻溝**。Demo 只要在你準備好的那三個問題上答對就行；production 要在你沒想過的第三千個問題上不出包。\n\n我自己這一年大量在用 agentic 的方式工作——Claude Code 幫我寫程式、自己寫過 MCP server 把工具接給模型、用 agent workflow 把一些延遲性的任務交出去跑。這些經驗給我一個很實際的體感：\n\n> 讓一個 agent「能動」很容易，讓它「能被信任」很難。而企業要的從來不是能動，是能信任。\n\n這篇先把整張地圖攤開：demo 到 production 之間，到底有哪六道鴻溝。後面 11 篇，每一篇就是在過其中一道。\n\n## 鴻溝一：正確性沒有底線\n\nDemo 階段，答錯一題你會笑著說「啊它有時候會這樣」。Production 階段，答錯一題可能是給客戶錯誤的保固資訊、給工程師錯誤的製程參數、給財務錯誤的數字。\n\nLLM 的本質是機率生成，它**不會說「我不知道」，它會很有自信地編一個**。在 demo 裡這叫「偶爾幻覺」，在 production 裡這叫「系統性風險」。\n\n企業真正的問題不是「模型會不會錯」——它一定會錯。問題是：\n\n- 它錯的時候，**系統知不知道**？\n- 它沒把握的時候，**會不會老實講**，還是照樣硬answer？\n- 答案有沒有**來源可以追**，讓人能驗證？\n\n這就是為什麼後面要花三章談 RAG 與權限感知檢索（第 3、4、5 篇）——把答案綁回可追溯的來源，是讓正確性有底線的第一步。\n\n## 鴻溝二：沒有 eval，你根本不知道有沒有變好\n\n傳統軟體你改一行 code，跑一遍測試，綠燈就敢上。AI agent 你改一句 prompt、換一個模型、調一個檢索參數，**你怎麼知道它變好還是變壞了**？\n\n「我自己試了幾題覺得不錯」不是工程，是賭博。\n\nDemo 不需要 eval，因為你只 demo 你知道會過的題目。Production 一定要 eval，因為：\n\n- 換模型版本（連 vendor 自己 silent update 都可能）會讓行為漂移\n- 改 prompt 修好 A 問題，常常默默弄壞 B 問題\n- 沒有一組「黃金題庫」當回歸測試，你每次上線都是裸奔\n\n而且別把 eval 想成什麼龐大基礎建設。起手式可以很小：先盯著你最痛的那幾類問題，整理 30 到 50 題「標準答案我心裡有數」的黃金題庫，每次改 prompt、換模型、調檢索參數就跑一遍，看通過率往哪邊動。光是有這條基準線，你就從「我自己試了幾題覺得不錯」升級成「這次改動讓通過率從 82% 掉到 76%，先別上」。\n\n一個沒有 eval harness 的 agent 專案，本質上是**沒有測試的軟體**——只是大家因為它是 AI 就假裝這件事不存在。第 9 篇會專門談怎麼把 eval 與可觀測性建起來。\n\n## 鴻溝三：出事的時候，你看不見裡面發生什麼\n\n傳統後端掛了，你有 log、有 trace、有 metrics，可以一路追到哪個 service、哪個 query、哪一行。\n\nAgent 答錯了，你有什麼？很多 PoC 的答案是：**什麼都沒有**。一個 prompt 進去、一個答案出來，中間它檢索了什麼、呼叫了哪個工具、為什麼選這條路，全是黑盒。\n\nProduction 系統最基本的要求是「可觀測」。對 agent 來說，這意味著你要能回答：\n\n- 這個答案是**基於哪幾份檢索到的文件**？\n- Agent **呼叫了哪些工具**、傳了什麼參數、拿回什麼？\n- 每一步花了多少 **token、多少時間、多少錢**？\n- 哪一步是**整條鏈失敗的根因**？\n\n我以前靠 tracing 抓過一個 PHP-FPM 的記憶體洩漏——沒有那條 trace，你只會看到「服務又慢了」，永遠不知道是哪一段在吃記憶體。Agent 是一模一樣的處境，只是黑盒更深：一個答案背後可能串了三次檢索、兩個工具呼叫、一輪 reasoning。今天這層觀測已經有現成的標準和工具可以接（第 9 篇細談），差別只在你願不願意把它當成跟後端一樣的基本要求。\n\n沒有這層 trace，你 debug agent 的方式就只能是「再問一次看看」。那不是維運，那是許願。\n\n## 鴻溝四：權限與資料治理，是企業的生死線\n\n這道鴻溝，是把「給個人玩的 agent」和「給企業用的 agent」分開的那條線，也是最多 PoC 直接跳過、然後永遠過不了資安那關的地方。\n\n你的 RAG agent 會檢索公司文件。問題來了：\n\n- 一個**沒有 HR 權限的員工**問「某某人的薪資」，agent 會不會因為那份文件剛好在向量庫裡，就大方檢索出來回答？\n- 不同部門、不同機密等級的資料，retrieval 的時候**有沒有照使用者的權限過濾**？\n- 答案引用了某份文件，那位使用者**本來有沒有資格看那份文件**？\n\n在 demo 裡，所有資料對「你」這個 demo 帳號都是開放的，所以這個問題隱形了。在 production 裡，**越權檢索等於資料外洩**，而且是 AI 幫你自動化的外洩。第 5 篇「權限感知檢索」會專門拆這一關——我會老實說，這是企業 RAG 最難、也最容易被低估的一塊。\n\n## 鴻溝五：成本與延遲，不是「之後再優化」\n\nDemo 一天被呼叫 20 次，你不在乎一次花多少 token。Production 一天被呼叫 20 萬次，每次多串一個 model call、多檢索 10 份文件、多跑一輪 reasoning，乘上 20 萬，就是真金白銀和使用者等到關掉分頁的延遲。\n\nAI agent 有個 demo 階段看不到的特性：**它很容易「想很多」**。一個 multi-agent 的設計、一個 reasoning chain，在 demo 裡是「哇好聰明」，在 production 裡可能是「一個問題燒掉 5 萬 token、跑了 40 秒」。\n\n這不是憑感覺嚇你。Anthropic 自己揭露過他們的 multi-agent research 系統：一個 multi-agent 流程用掉的 token，大約是一次普通 chat 的 15 倍（單一 agent 也有 4 倍）。multi-agent 之所以顯得聰明，很大一部分就是因為它燒得多——這在 demo 裡你看不到帳單，乘上 production 每天幾十萬次呼叫，就是會被約談的數字。\n\n更現實的是，這三件事——延遲、可靠性、成本——是會互相打架的三角：\n\n- 想可靠 → 加 retry、加 fallback model → 變慢、變貴\n- 想便宜 → 用小模型、少檢索 → 品質掉\n- 想快 → 少 reasoning、激進 cache → 可能犧牲正確性\n\nLLM app 說到底還是個 distributed system，這些 trade-off 是要當成系統設計來做的，不是上線後再說。第 10 篇整篇在談這個三角怎麼權衡。\n\n## 鴻溝六：沒有 fallback 和人類介入點\n\n最後一道，是心態問題。很多人潛意識把 agent 當成「會自己搞定一切」的東西。但 production 系統的成熟度，恰恰體現在它**承認自己會失敗、而且為失敗做了準備**。\n\n- Agent 沒把握的時候，能不能**降級**到「我幫你找到這幾份文件，請你自己判斷」，而不是硬掰一個答案？\n- 高風險的動作（刪資料、送出訂單、改設定），有沒有一個 **human-in-the-loop 的確認關卡**？\n- 主要模型掛了、或回了垃圾，有沒有 **fallback 路徑**？\n\n一個 tool-using agent 真正可怕的地方，不是它會講錯話，是**它能操作外部系統**。它能講錯話頂多丟臉；它能用錯工具，那是真的會改到資料、動到錢。所以 action boundary 和 approval flow（第 6、11 篇）不是加分項，是 production 的入場券。\n\n## 把六道鴻溝變成一張地圖\n\n把上面整理起來，這就是這本書的骨架——每一道鴻溝，對應後面一到幾篇：\n\n| # | 鴻溝 | 在哪幾篇過河 |\n|---|------|------------|\n| 1 | 正確性沒有底線 | 第 3、4、5 篇（RAG / 向量檢索 / 權限） |\n| 2 | 沒有 eval / 迴歸 | 第 9 篇（可觀測性與評估） |\n| 3 | 看不見內部 | 第 2、9 篇（架構 / trace） |\n| 4 | 權限與資料治理 | 第 5、11 篇（權限檢索 / 治理） |\n| 5 | 成本與延遲失控 | 第 10 篇（系統權衡） |\n| 6 | 沒有 fallback / HITL | 第 6、8、11 篇（tool use / 多代理 / 治理） |\n\n還有一個工程師容易忽略、但在 2026 越來越硬的視角：這六道鴻溝不只是你內部會卡的工程問題。其中正確性、資料治理、可追溯、人類介入這幾道，剛好就是 EU AI Act、NIST AI RMF、ISO/IEC 42001 正在要求企業做到的事——而像 ISO/IEC 42001 這種 AI 管理系統認證，已經開始被寫進大型企業的採購盡職調查清單。所以過這幾道河，不只是讓系統能被信任，也是在替合規鋪路。第 11 篇會把這層治理視角收成一張框架圖，也會把法規時間表講清楚。\n\n下一篇（第 2 篇），我會把這六道鴻溝收斂成**一張企業 AI Agent 的系統架構藍圖**——一張圖看懂一個能上 production 的 agent 系統長什麼樣，每個元件為什麼存在、少了它會怎樣。先講清楚，那是一張**參考架構（設計提案）**，不是哪個已經部署好的成品；但它背後的每一個取捨，都是從上面這六道鴻溝推出來的。\n\n如果你現在手上正好有一個「demo 很漂亮、但遲遲不敢上線」的 agent，先別急著怪模型。把它對著這六道鴻溝檢查一遍，你大概就會知道，卡住的從來不是 AI，是工程。\n\n## 文章簡報\n\n<DeckEmbed images={deckSlides} title=\"為什麼企業 AI Agent 卡在 PoC？六道鴻溝\" />\n\n---\n\n### 延伸閱讀\n\n- [Agentic Engineering 是什麼？為什麼 Karpathy 要發明這個詞](/blog/agentic-engineering-what-is-it)——先搞懂「用 agent 工作」和「打造 agent 系統」是兩件事\n- [從「寫 code 的人」到「管 agent 的人」](/blog/agentic-engineering-mindset-shift)——角色轉換的心理那一面\n- 下一篇：《企業 AI Agent 系統架構藍圖》——把這六道鴻溝收斂成一張可以照著蓋的圖",
      "summary": "兩天就做出一個會查資料、會呼叫 API 的 AI agent demo，老闆很興奮——然後它在 production 待了六個月還上不了線。這篇拆解 demo 到 production 之間最容易被低估的六道鴻溝，以及為什麼「能動」離「能信任」還很遠。",
      "image": "https://bobochen.dev/_astro/cover.BgTD_fDz.webp",
      "date_published": "2026-04-21T00:00:00.000Z",
      "date_modified": "2026-06-05T00:00:00.000Z",
      "tags": [
        "AI Agent",
        "LLM",
        "系統設計",
        "生產環境",
        "企業導入"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-monitoring-ops/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-monitoring-ops/",
      "title": "監控與維運：睡覺時產品也在跑",
      "content_text": "產品上線不是終點，而是維運的起點。用最小化但有效的監控告警 + self-healing 自動修復，搭配 AI 故障診斷與 Runbook，讓你的產品在你睡覺、甚至休假兩週時也能穩定運行。",
      "content_html": "## 凌晨四點的維運噩夢\n\n你正在做一個很好的夢，手機突然震動。\n\n不是鬧鐘。是一封信：「您的網站 bobo-example.com 已經連續無回應 15 分鐘。」\n\n你從床上跳起來，打開筆電，一邊揉眼睛一邊 SSH 進 server。查了 20 分鐘，發現是資料庫連線數爆了。重啟服務、清理連線、確認恢復正常。回到床上已經凌晨五點，六點半又要起床上班。\n\n如果你做產品做得夠久，這種故事你一定經歷過。\n\n好消息是：2026 年，一個人的維運不需要這麼痛苦。你需要的是一套**最小化但有效的監控系統**，加上**自動化的故障恢復機制**。讓產品出問題時，它先自己試著修自己，真的修不好再叫你。\n\n## 監控金字塔：先搞對優先順序\n\n大公司的監控系統動輒幾十個 dashboard、上百條 alert rule。你不需要那些。\n\n一個人做產品，監控有四個層級，按優先順序排列：\n\n```text\n         ┌─────────┐\n         │ 商業指標 │  ← 每週看一次就好\n         ├─────────┤\n         │ 效能指標 │  ← 有空再看\n         ├─────────┤\n         │ 錯誤追蹤 │  ← 每天掃一眼\n         ├─────────┤\n         │ 可用性   │  ← 最重要，必須自動告警\n         └─────────┘\n```\n\n### 層級 1：可用性監控（必做）\n\n**你的網站活著嗎？** 這是最基本也是最重要的問題。\n\n| 工具                         | 免費方案             |\n| ---------------------------- | -------------------- |\n| **UptimeRobot**              | 50 個監控點          |\n| **Better Stack**             | 10 個監控點          |\n| **Cloudflare Health Checks** | Pro 方案起（付費）；Free 方案僅有 Passive Origin Monitoring |\n\n我自己的 bobo-blog 就是用 UptimeRobot，免費 50 個監控點對個人專案綽綽有餘。如果你已經在 Cloudflare 生態系裡，Health Checks 也是現成的選項。\n\n設定方式很簡單：\n\n1. 註冊 UptimeRobot（免費）\n2. 新增你的網站 URL 和 API 健康檢查端點\n3. 設定告警通知：email + Slack（或 Telegram）\n4. 設定檢查頻率：每 5 分鐘\n\n五分鐘設定完，你的網站掛了你就會知道。這是投資報酬率最高的五分鐘。\n\n### 層級 2：錯誤追蹤（強烈建議）\n\n網站活著，不代表沒有錯誤。用戶可能遇到 500 error、JavaScript crash、API timeout，但因為其他功能正常所以你不知道。\n\n**Sentry** 是 Solo Builder 的最佳選擇：\n\n- 免費方案：每月 5,000 筆錯誤事件，夠用\n- 支援幾乎所有語言和框架\n- 自動分組重複的錯誤\n- 附上完整的 stack trace 和用戶環境資訊\n\n```typescript\n// Astro 專案整合 Sentry，一行搞定\nimport * as Sentry from '@sentry/astro';\n\nSentry.init({\n  dsn: 'https://xxx@sentry.io/yyy',\n  tracesSampleRate: 0.1, // 只取樣 10%，省額度\n});\n```\n\n### 傳統做法 vs. AI 加持做法\n\n**傳統做法：**\n\n1. 收到 Sentry 告警\n2. 打開 Sentry dashboard 看 stack trace\n3. 花 30 分鐘讀 log、找問題\n4. 花 30 分鐘寫修復\n5. 部署、驗證\n\n**AI 加持做法：**\n\n1. 收到 Sentry 告警\n2. 把 error 訊息和 stack trace 丟給 Claude Code\n3. AI 直接在你的 codebase 裡找到問題根因\n4. AI 提出修復方案，你 review 後 approve\n5. CI/CD 自動部署\n\n同一個 bug，從一小時變成十五分鐘。\n\n而且 Claude Code 可以做得更進階——設定一個 Sentry webhook，錯誤發生時自動觸發 AI 分析：\n\n```text\n你是一個 on-call SRE。以下是一個 production error：\n\nError: Connection pool exhausted\nStack trace: [完整 stack trace]\n頻率: 過去 10 分鐘發生 47 次\n影響範圍: /api/courses 端點\n\n請分析：\n1. 最可能的根本原因\n2. 立即的緩解措施（不需要改 code 的）\n3. 長期修復方案\n4. 這個問題是否需要立即修復，還是可以等到明天\n```\n\n### 層級 3：效能監控（有空再做）\n\n效能問題通常不會讓產品掛掉，但會慢慢侵蝕用戶體驗。\n\n好消息是：如果你的產品部署在 Cloudflare Workers 或 Vercel，你已經有了免費的效能數據。\n\n- **Cloudflare Analytics**：request count、response time、error rate、bandwidth\n- **Vercel Analytics**：Web Vitals（LCP、INP、CLS）\n- **Google Search Console**：Core Web Vitals（SEO 相關）\n\n這些數據不需要每天看。**每週花 10 分鐘掃一眼趨勢就好。** 重點是看「有沒有突然變差」，而不是追求每一個毫秒的優化。\n\n### 層級 4：商業指標（每週看一次）\n\n這不是傳統意義上的「監控」，但對 Solo Builder 來說一樣重要：\n\n- 每日活躍用戶數\n- 新增註冊數\n- 付費轉換率\n- 流失率\n\n這些指標告訴你產品的健康狀況——不是技術健康，而是商業健康。\n\n用一個簡單的 Google Sheet 或 Notion database 就夠了。不需要花時間建 dashboard。\n\n## 告警設計：少即是多\n\n監控最常犯的錯是**告警太多**。\n\n如果你每天收到 20 封告警信，你會開始忽略它們。然後真正重要的告警出現時，你也會忽略。\n\n### 告警的黃金法則\n\n**只在「需要你做某件事」的時候才告警。**\n\n| 情境                 | 要不要告警 | 原因                         |\n| -------------------- | ---------- | ---------------------------- |\n| 網站掛了             | ✅ 告警    | 需要你處理                   |\n| 5xx 錯誤率超過 5%    | ✅ 告警    | 需要你檢查                   |\n| 回應時間超過 3 秒    | ❌ 不告警  | 記錄就好，不緊急             |\n| 磁碟空間超過 80%     | ✅ 告警    | 需要你清理，但不急           |\n| 每日用戶數下降 10%   | ❌ 不告警  | 每週 review 時看就好         |\n| SSL 憑證即將過期     | ✅ 告警    | 需要你處理（或設定自動更新） |\n| CPU 使用率偶爾 spike | ❌ 不告警  | 正常現象                     |\n\n### 告警通知管道\n\n通知管道大概就 Email、Slack/Discord、Telegram Bot、SMS/電話這幾種。Email 適合當所有告警的備份，建議必開；Slack 或 Discord 適合非緊急但需要注意的事，開一個 #alerts 頻道就好；Telegram Bot 手機通知即時，隨時隨地都收得到；SMS 或電話留給網站掛掉這種極度緊急的狀況，只在深夜啟用。\n\n我的設定是：所有告警走 Telegram Bot，只有「網站完全掛掉超過 10 分鐘」才觸發 SMS。\n\n這樣白天我在上班時，Telegram 通知會靜靜地出現在手機上，我空閒時看一下就好。但半夜網站掛了，SMS 會叫醒我。\n\n## Self-Healing：讓產品自己修自己\n\n最理想的狀態是：**問題發生了，但你根本不需要知道，因為系統自己修好了。**\n\n這不是科幻小說。以下是幾個實用的 self-healing 模式：\n\n### 模式 1：Health Check + 自動重啟\n\n大部分雲端平台都支援 health check：\n\n**Cloudflare Workers**：Workers 本身是 serverless，不需要重啟；若使用 Durable Objects，可設定 alarm 作為重啟機制。\n\n**Cloud Run**：設定 liveness probe，不健康時自動重啟 instance：\n\n```jsonc\n{\n  \"livenessProbe\": {\n    \"httpGet\": { \"path\": \"/health\" },\n    \"initialDelaySeconds\": 10,\n    \"periodSeconds\": 30,\n  },\n}\n```\n\n### 模式 2：部署失敗自動 Rollback\n\n```yaml\n# GitHub Actions - 部署後驗證，失敗就回滾\n- name: Deploy\n  run: wrangler deploy\n\n- name: Health check\n  run: |\n    sleep 10\n    STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" https://your-site.com/health)\n    if [ \"$STATUS\" != \"200\" ]; then\n      echo \"Health check failed! Rolling back...\"\n      wrangler rollback --message \"CI auto-rollback: health check failed\"\n      exit 1\n    fi\n```\n\n### 模式 3：Circuit Breaker\n\n當外部服務（第三方 API、資料庫）出問題時，不要無限重試，而是快速失敗並降級：\n\n```typescript\n// 簡單的 circuit breaker\nclass CircuitBreaker {\n  private failures = 0;\n  private lastFailTime = 0;\n  private readonly threshold = 5;\n  private readonly resetTimeout = 60_000; // 1 分鐘\n\n  async call<T>(fn: () => Promise<T>, fallback: T): Promise<T> {\n    if (this.failures >= this.threshold) {\n      if (Date.now() - this.lastFailTime < this.resetTimeout) {\n        return fallback; // 降級回應\n      }\n      this.failures = 0; // 嘗試恢復\n    }\n    try {\n      const result = await fn();\n      this.failures = 0;\n      return result;\n    } catch {\n      this.failures++;\n      this.lastFailTime = Date.now();\n      return fallback;\n    }\n  }\n}\n```\n\n這段 code 的意思是：如果某個外部服務連續失敗 5 次，就暫時放棄呼叫它，改用降級方案（例如快取的舊資料）。一分鐘後再試試看有沒有恢復。\n\n用戶不會看到錯誤頁面，你也不會被半夜叫起來。\n\n## AI 輔助故障診斷\n\n當問題真的需要你介入時，AI 是你最好的 on-call 夥伴。\n\n### 用 Claude Code 診斷問題\n\n凌晨被叫起來的時候，你的腦袋是不清楚的。這時候讓 AI 幫你思考：\n\n```text\n我的產品在凌晨 3:47 開始出現以下錯誤：\n\n[貼上錯誤訊息和 log]\n\n環境：\n- Cloudflare Workers\n- D1 資料庫\n- 最近一次部署是昨天下午 3 點\n\n請幫我：\n1. 分析最可能的原因（按可能性排序）\n2. 每個可能原因的驗證方式\n3. 建議的修復步驟\n4. 判斷這是否需要立即修復，還是可以等到早上\n```\n\n關鍵是最後一點：**判斷要不要現在處理。** 很多時候，半夜的問題其實沒那麼緊急——某個非核心功能出錯、某個 edge case 才會觸發的 bug。AI 可以幫你冷靜判斷。\n\n## 備份策略：你一定會感謝自己的\n\n備份是最無聊的主題，但也是最重要的。\n\n### 備份的唯一規則\n\n**如果你沒有驗證過 restore，就等於沒有備份。**\n\n我見過太多人設定了自動備份，安心地跑了一年，等到真的需要 restore 時才發現備份檔案是壞的。\n\n### Solo Builder 的最小備份清單\n\n| 備份對象           | 工具                                     | 頻率         | 驗證頻率                |\n| ------------------ | ---------------------------------------- | ------------ | ----------------------- |\n| **資料庫**         | D1 自動備份 / Cloud SQL 自動備份         | 每日         | 每月測試 restore        |\n| **程式碼**         | Git（GitHub/GitLab）                     | 每次 commit  | N/A（Git 就是版本控制） |\n| **設定檔**         | Infrastructure as Code（wrangler.jsonc） | 隨程式碼一起 | 每次部署就是驗證        |\n| **用戶上傳檔案**   | R2 / Cloud Storage + 跨區複製            | 即時         | 每季抽檢                |\n| **環境變數和密鑰** | 1Password / Bitwarden                    | 有變更時     | 每季確認                |\n\n### 自動化備份驗證\n\n用 AI 幫你寫一個簡單的備份驗證腳本：\n\n```text\n請幫我寫一個 shell script：\n\n1. 從 D1 備份中 restore 到一個測試 database\n2. 執行幾個基本查詢，確認資料完整\n3. 比較備份資料的 row count 和 production 的 row count\n4. 結果寄到我的 email\n5. 刪除測試 database\n\n這個 script 會用 cron 每月跑一次。\n```\n\n## 休假測試：你的產品能自己活兩週嗎？\n\n這是我用來衡量維運成熟度的測試：\n\n**如果你完全消失兩週——不看 email、不登入 server、不部署任何東西——你的產品還能正常運行嗎？**\n\n如果答案是「不確定」，你需要補強以下幾個環節：\n\n### 休假前的 checklist\n\n- [ ] 自動備份有在跑，且最近一次 restore 測試通過\n- [ ] 監控告警設定好，會通知到手機\n- [ ] SSL 憑證不會在你休假期間過期\n- [ ] 沒有需要手動續約的服務（網域、雲端帳單）\n- [ ] 客服 auto-reply 已更新，告知回覆會延遲\n- [ ] CI/CD pipeline 不需要手動 approve\n- [ ] 資料庫不會在兩週內把空間用完\n- [ ] 帳單設定了自動扣款\n\n如果你能自信地在每一項打勾，恭喜——你建立了一個真正自動化的產品。\n\n## Runbook：寫給未來的你\n\n最後一個建議：**把你的維運步驟寫下來。**\n\nRunbook 是一份「出了某某問題，按照這些步驟處理」的文件。\n\n你可能覺得「我自己的東西我當然記得怎麼處理」。但相信我，凌晨四點被叫起來的你，和白天清醒的你，是兩個完全不同的人。\n\n### AI 幫你寫 Runbook\n\n```text\n以下是我的產品架構：\n- 前端：Astro，部署在 Cloudflare Pages\n- API：Cloudflare Workers\n- 資料庫：D1\n- 檔案儲存：R2\n\n請幫我建立一份 Runbook，涵蓋以下常見情境：\n\n1. 網站完全無法存取\n2. API 回應時間異常\n3. 資料庫連線錯誤\n4. 部署失敗\n5. SSL 憑證問題\n\n每個情境包含：\n- 確認問題的步驟\n- 排查清單（按可能性排序）\n- 修復步驟\n- 修復後的驗證方式\n```\n\n把 Runbook 放在你的 repo 裡（例如 `docs/runbook.md`）。這樣你在半夜打開終端機時，第一件事不是亂猜問題在哪，而是打開 Runbook 按步驟走。\n\n更好的做法是：**把 Runbook 也餵給 AI。** 當問題發生時，你可以跟 AI 說「參考我們的 Runbook，幫我排查這個問題」，讓 AI 幫你走流程。\n\n## 真實案例：bobo-blog 的監控架構\n\n我的部落格 bobo-blog 部署在 Cloudflare Workers 上。以下是我的監控設定，給你參考：\n\n| 層級   | 工具                         | 設定                            |\n| ------ | ---------------------------- | ------------------------------- |\n| 可用性 | UptimeRobot                  | 每 5 分鐘 ping 首頁和 `/health` |\n| 錯誤   | Cloudflare Workers Analytics | 監控 4xx/5xx 比例               |\n| 效能   | Cloudflare Analytics         | 每週看 P95 回應時間             |\n| 部署   | GitHub Actions               | 部署後自動跑 health check       |\n\n為什麼這麼簡單？因為 Cloudflare Workers 是 serverless——我不需要管 server、不需要監控 CPU 或記憶體、不需要擔心 process crash。平台幫我處理了大部分的維運問題。\n\n**這就是 [第 6 章：選對平台省 80% 的事](/blog/ai-solo-builder-deployment/) 的延伸——選對基礎架構，你的維運負擔會大幅降低。**\n\n## 本章重點回顧\n\n- 🏗️ 監控四層金字塔：可用性 > 錯誤追蹤 > 效能 > 商業指標，按優先順序建立\n- 🔔 告警的黃金法則：只在「需要你做某件事」時才告警，否則你會開始忽略告警\n- 🔧 Self-healing 模式：health check + 自動重啟、部署失敗自動 rollback、circuit breaker\n- 🤖 AI 是你最好的 on-call 夥伴——半夜被叫起來時，讓 AI 幫你冷靜判斷和排查\n- 💾 備份的唯一規則：沒有驗證過 restore 就等於沒有備份\n- 📋 寫 Runbook，寫給凌晨四點腦袋不清楚的自己\n- 🏖️ 「休假測試」：如果你消失兩週，產品還能活嗎？\n\n## 下一步\n\n到這裡，你的產品已經穩定運行了——有用戶在用、有客服在處理問題、有監控在守護。\n\n接下來的問題是：**這個 side project，值不值得更認真？**\n\n如果你開始有穩定的用戶、甚至有人付費了，你可能會想：「我是不是應該把這個東西做大？要怎麼定價？什麼時候該考慮離職全職做？」\n\n下一章，我們來聊從 side project 到 Micro SaaS 的轉變——這是每個 Solo Builder 遲早會面對的抉擇。\n\n👉 [第 12 章：從 Side Project 到 Micro SaaS](/blog/ai-solo-builder-side-project-to-saas)",
      "summary": "產品上線不是終點，而是維運的起點。用最小化但有效的監控告警 + self-healing 自動修復，搭配 AI 故障診斷與 Runbook，讓你的產品在你睡覺、甚至休假兩週時也能穩定運行。",
      "image": "https://bobochen.dev/_astro/cover.BRK1SU2y.webp",
      "date_published": "2026-04-19T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "監控",
        "維運",
        "Self-Healing",
        "AI",
        "DevOps"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agent-output-verification-review/",
      "url": "https://bobochen.dev/blog/agent-output-verification-review/",
      "title": "Agent 產出品質保證：Code Review、自動測試、與「不要太信任」的藝術",
      "content_text": "Agent 寫的 code 看起來很專業，但你怎麼知道它是對的？建立一套 agent output 的品質保證流程——從 CI 自動化驗證、人工 review 的重點、到最重要的心態：永遠假設 agent 的 code 有 bug。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第六篇。上一篇：[Spec-Driven Development](/blog/spec-driven-development-for-agents)\n\n## 那個不存在的 Database Column\n\n我曾經讓 agent 寫了一個看起來完美的 API endpoint。Code 結構清晰、error handling 完整、連 Swagger 文件都自動加了。PR review 時同事看了也說「不錯」。\n\n上線之後，第一個 request 就 500 了。\n\n原因是 agent 在 SQL query 裡引用了一個叫 `user_preferences` 的 column，而這個 column 在我們的 database schema 裡根本不存在。它大概是從 training data 裡某個類似的 schema 推斷出來的。\n\n最可怕的不是 bug 本身，而是它看起來太像真的了。Column 名稱合理、query 語法正確、連 JOIN 的邏輯都對，除了那個 column 是虛構的。如果不是上線後第一個 request 就爆，可能在 staging 跑幾天都沒人發現，因為 staging 的數據量小，那個 query path 可能根本沒被觸發。\n\n這件事教了我一個血淋淋的教訓：Agent 的 code 最危險的地方不是它寫得差，是它寫得太好了，好到讓你降低了戒心。\n\n## Trust But Verify：Agent Code 為什麼特別需要 Review\n\nSimon Willison（Django 共同創辦人，AI 工具的重度使用者和評論者）說過一句讓我印象深刻的話：\n\n> 「不要提交你沒有 review 過的 code 的 pull request——無論那段 code 是你寫的還是 AI 寫的。」\n\n這聽起來是常識，但實際操作起來很難做到，因為 agent 產出的 code 有一個特殊的心理效應：**它看起來非常專業**。\n\n人類寫的 code 通常有明顯的「風格」——有的人變數名取得好、有的人 error handling 做得糙、有的人 comment 寫得多。你看了幾秒就能判斷「這段 code 品質如何」。\n\nAgent 的 code 不一樣。它永遠有完整的 error handling、有意義的變數名、一致的格式、甚至還有 JSDoc 註解。它看起來像資深工程師寫的 code。這讓你的大腦自動進入「這個人的 code 品質不錯」的信任模式。\n\n但 agent 的 bug 跟人類的 bug 根本不是同一種。\n\n|                      | 人類的 bug                         | Agent 的 bug                          |\n| -------------------- | ---------------------------------- | ------------------------------------- |\n| **出錯層級**         | 邏輯層                             | 事實層                                |\n| **典型例子**         | Off-by-one、邊界條件沒處理         | 引用不存在的 API、fabricated column   |\n| **容易發現嗎**       | 比較容易（邏輯不對會「看起來怪」） | 很難（看起來完全正確）                |\n| **傳統測試能抓到嗎** | 通常可以                           | 不一定（如果測試本身也是 agent 寫的） |\n\n人類搞混 `<=` 和 `<`，你盯著看就能發現。Agent 自信滿滿地用了一個不存在的 API endpoint，你光看 code 根本看不出來，因為 endpoint URL 的格式完全正確，只是那個路徑在你的系統裡不存在。\n\n這就是為什麼「trust but verify」在 agent 時代不只是好習慣，而是必要的紀律。\n\n## 自動化護欄：讓機器先幫你擋一層\n\n好消息是，很多 agent 的基本錯誤可以被自動化工具攔截。而且這些工具你本來就應該有。\n\n### Pre-commit Hooks\n\n在 agent 時代，pre-commit hooks 的 ROI 翻倍了。以前它們主要是「提醒人類注意格式」，現在它們是「攔截 agent 的基本錯誤」：\n\n```bash\n# 我的 pre-commit hook 幫我擋過的 agent 錯誤\n- TypeScript 類型錯誤（agent 用了不存在的 type）\n- ESLint 違規（agent 用了 var 而不是 const）\n- Import 循環（agent 不知道我們的 module boundary）\n- 意外的 console.log（agent 習慣性加 debug log）\n```\n\n**重點**：永遠不要讓 agent 用 `--no-verify` bypass hooks。這是你的第一道防線。\n\n### CI Pipeline\n\nCI 是你的第二道防線，也是最可靠的那一道：\n\n1. **Type checking**（`tsc --noEmit`）——抓 agent 用了不存在的 type、interface 定義不匹配\n2. **Unit tests**——抓邏輯錯誤（但注意：如果 test 也是 agent 寫的，可能有共同盲點）\n3. **Integration tests**——抓 agent 對外部系統的假設錯誤（像那個不存在的 column）\n4. **Lint**——抓 coding convention 違規\n5. **Security scanning**——抓 agent 引入的潛在安全漏洞\n\n這些「傳統工具」在 agent 時代的價值不減反增，它們免費幫你抓 agent 的基本錯誤，而且不會疲勞、不會被「看起來很專業」的 code 給騙過。\n\n### Static Analysis：比你想的更重要\n\nTypeScript strict mode 是 agent coding 的最佳拍檔。它強制 agent：\n\n- 正確處理 `null` 和 `undefined`\n- 明確標註所有的 type\n- 不能用 implicit any\n\n以前你可能覺得 strict mode 太煩了，到處要加 type assertion。但現在它幫你攔截了 agent 一大堆「看起來對但 type 不對」的 code。\n\n**ROI 翻倍定律**：在傳統開發裡，這些工具是 nice-to-have。在 agent 時代，它們是 prerequisites。如果你的專案還沒有 CI、沒有 type checking、沒有 linting——現在就是設定它們的最好時機。\n\n## Human Review 的 80/20：看什麼、忽略什麼\n\n你不需要（也不應該）逐行 review agent 的每一行 code。那太慢了，而且很多 boilerplate code review 起來沒有意義。\n\n### 重點看的四件事\n\n**1. 架構決策**\n\nAgent 選了什麼設計模式？建了新的 module 還是擴展現有的？引入新的 abstraction 了嗎？\n\n這些是 agent 最容易做出「合理但不適合」的決策的地方。它可能在你不需要的時候引入了 factory pattern、建了一個不必要的 service layer、或把應該是 utility function 的東西變成了一個 class。\n\n**2. 邊界條件**\n\n空值怎麼處理？array 為空時呢？API timeout 呢？concurrent access 呢？\n\nAgent 通常會處理「happy path」很好，但在邊界條件上可能做出不合理的假設——比如假設某個 API 一定會回資料、假設某個 array 一定有元素。\n\n**3. 安全性**\n\n有沒有 SQL injection 的風險？用戶輸入有沒有 sanitize？有沒有暴露敏感資訊？\n\nAgent 在安全方面的表現參差不齊。它通常知道要用 parameterized queries，但可能在比較隱蔽的地方忘記——比如動態拼接 SQL 的 ORDER BY clause。\n\n**4. 業務邏輯正確性**\n\n這個邏輯在商業意義上對嗎？不只是 code 能不能跑，是它的行為是不是你期望的？\n\nAgent 可能完美地實作了你 spec 裡寫的邏輯——但如果你的 spec 有遺漏，agent 不會幫你補。它不懂你的業務。\n\n### 可以快速掃的\n\n- **Boilerplate code**：imports、exports、standard config\n- **Formatting**：這是 linter 的工作，不是你的\n- **Standard patterns**：如果 agent 用了你 codebase 裡已有的 pattern，快速掃過就好\n\n### Agent PR vs 人類 PR 的 Review 重點不同\n\n| Review 重點         | 人類 PR    | Agent PR           |\n| ------------------- | ---------- | ------------------ |\n| 偷懶 / 走捷徑       | 要注意     | 不太需要           |\n| 過度設計            | 偶爾       | **經常**           |\n| 事實性錯誤          | 少見       | **常見**           |\n| 不存在的 API/method | 幾乎不會   | **一定要查**       |\n| 風格一致性          | 通常沒問題 | 可能跟專案風格不同 |\n| 安全漏洞            | 偶爾       | **要特別注意**     |\n\n## TDD x Agent：目前最可靠的品質保證模式\n\n經過一年的實驗，我找到了一個可靠度最高的工作模式：**你寫 test，agent 寫 implementation**。\n\n工作流程：\n\n```\n你 → 寫 test cases（基於 spec 的 verification criteria）\n     ↓\nAgent → 跑 test，看到 red（全部失敗）\n     ↓\nAgent → 寫 implementation\n     ↓\nAgent → 跑 test，看到 green（全部通過）\n     ↓\n你 → review implementation（已知行為正確，focus 在設計和安全）\n```\n\n為什麼這個模式可靠？\n\n1. **Test 是你的 verification criteria 的程式碼版本**——agent 不可能「看起來對但其實錯」，因為 test 會直接告訴它對不對\n\n2. **你控制了「正確」的定義**——你寫 test，所以你決定什麼是正確的行為。Agent 負責實現，但不負責定義。\n\n3. **Review 變得更輕鬆**——你已經知道功能行為是正確的（test 通過了），review 只需要關注設計品質和安全性。\n\n4. **降低 hallucination 的影響**——即使 agent 用了不存在的 API，test 跑下去一定會失敗，agent 就被迫換一個真正能用的方法。\n\n### 實際操作範例\n\n我要加一個 `formatRelativeDate` function：\n\n**Step 1**：我寫 test\n\n```typescript\ndescribe('formatRelativeDate', () => {\n  it('returns \"just now\" for dates within 1 minute', () => {\n    const now = new Date();\n    expect(formatRelativeDate(now)).toBe('剛剛');\n  });\n\n  it('returns \"N minutes ago\" for dates within 1 hour', () => {\n    const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000);\n    expect(formatRelativeDate(thirtyMinAgo)).toBe('30 分鐘前');\n  });\n\n  it('returns \"N hours ago\" for dates within 24 hours', () => {\n    const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);\n    expect(formatRelativeDate(fiveHoursAgo)).toBe('5 小時前');\n  });\n\n  it('returns formatted date for dates older than 24 hours', () => {\n    const oldDate = new Date('2026-01-15');\n    expect(formatRelativeDate(oldDate)).toBe('2026/01/15');\n  });\n});\n```\n\n**Step 2**：告訴 agent「讓這些 test 通過」\n\n**Step 3**：Agent 寫出 implementation，跑 test，全部 green。\n\n**Step 4**：我 review——不需要逐行看邏輯對不對（test 保證了），只需要看 code 風格是否一致、有沒有不必要的 dependency。\n\n10 分鐘完成一個以前需要 30 分鐘的任務，而且品質更有保障，因為有 test 當安全網。\n\n## Hallucination 偵測 Patterns\n\nAgent code 的 hallucination 有幾個常見模式，學會辨認它們可以讓你的 review 效率大幅提升：\n\n### Pattern 1：不存在的 API / Function\n\n**徵兆**：Agent 呼叫了一個你沒見過的 function、method、或 API endpoint。名稱看起來很合理。\n\n**偵測**：`grep -r \"functionName\" .` 或 `grep -r \"endpoint\" .`。如果在 codebase 裡找不到定義，很可能是 hallucination。\n\n**實例**：Agent 寫了 `response.data.items.filter(...)`，但你的 API 回的是 `response.results`，不是 `response.data.items`。\n\n### Pattern 2：過時或不存在的 Library Method\n\n**徵兆**：Agent 用了一個 library 的 method，語法看起來對，但就是跑不動。\n\n**偵測**：去 library 的官方文件確認 method 是否存在。特別注意 major version 更新後被移除的 API。\n\n**實例**：Agent 用了 React 17 的 `componentWillMount`，但你的專案是 React 18。\n\n### Pattern 3：Comment 跟 Code 不一致\n\n**徵兆**：Agent 寫的 comment 描述了一個行為，但 code 做的是另一件事。\n\n**偵測**：比較 comment 跟 code 的邏輯。Agent 有時候會先「想好」它要做什麼（寫 comment），然後在實作的時候偏離了。\n\n**實例**：\n\n```javascript\n// 排除已過期的 items\nconst filtered = items.filter((item) => item.expiresAt > Date.now());\n// ^^^ 看起來對，但如果 expiresAt 是 ISO string 不是 timestamp，\n// 這個比較永遠是 true\n```\n\n### Pattern 4：過度自信的完美 Code\n\n**徵兆**：Agent 的 code 裡沒有任何 TODO、沒有任何 edge case 處理、沒有任何不確定的部分。一切都很「完美」。\n\n**偵測**：這反而是一個警訊。真實世界的 code 總是有 trade-off 和待處理的 edge case。如果 agent 的 code 看起來「太完美」，很可能是它忽略了一些它應該考慮但沒考慮的東西。\n\n## 三個我沒 Review 就 Merge 的慘案\n\n### 慘案 1：虛構的 Database Column\n\n就是開頭說的那個。`user_preferences` column 根本不存在。\n\n**教訓**：Agent 寫的任何 database query，都要交叉比對 schema。特別是那些「看起來很合理但你不記得有沒有」的 column。\n\n### 慘案 2：用了已 Deprecated 的 API\n\nAgent 寫了一個 Stripe 付款整合，用了 `stripe.charges.create()`。這個 API 在 Stripe 的舊版 SDK 裡有效，但我們用的是新版 SDK，應該用 `stripe.paymentIntents.create()`。\n\n在 staging 測試時居然通過了——因為 Stripe 為了向後相容，舊 API 在測試模式下還能用。上了 production 才發現部分付款行為不符合 PSD2 法規要求。\n\n**教訓**：Agent 的 training data 會有時間差。它對 library API 的認知可能落後一兩個 major version。碰到第三方 API 整合，一定要查官方文件確認當前推薦的做法。\n\n### 慘案 3：沒有 Index 的 SQL Query\n\nAgent 寫了一個 search query，用了 `LIKE '%keyword%'` 做全文搜尋。在 staging 的 1000 筆資料跑得飛快。上了 production 的 500,000 筆資料，平均回應時間 12 秒。\n\nAgent 不知道 production 有多少資料，也不知道沒有 index 的 LIKE query 在大數據量下的效能。它在小數據集上寫出了正確的邏輯，但完全沒考慮 scale。\n\n**教訓**：Agent 不會幫你想 production scale。任何涉及 database query 的 code，review 時要問自己：「這在 100x 數據量的時候，行為會是什麼？」\n\n## 建立你的品質保證 Checklist\n\n根據一年的經驗，我整理了一份 agent code review 的 checklist。每次 review agent 的 PR 之前過一遍：\n\n```markdown\n## Agent Code Review Checklist\n\n### 自動化（CI 應該已經幫你做了）\n\n- [ ] TypeScript / type check 通過\n- [ ] Lint 通過\n- [ ] 現有 test 全部 pass\n- [ ] Build 成功\n\n### 事實性檢查（agent 特有）\n\n- [ ] 引用的 API / method 都真的存在\n- [ ] Import 的 module 都真的存在\n- [ ] Database schema 跟 query 一致\n- [ ] 第三方 library 的 API 是當前版本的\n\n### 設計層面\n\n- [ ] 有沒有不必要的新 dependency\n- [ ] 有沒有過度設計（你只要 A，它做了 A+B+C）\n- [ ] 架構選擇是否適合這個 codebase\n\n### 安全性\n\n- [ ] 用戶輸入有 sanitize\n- [ ] 沒有 hardcoded secrets\n- [ ] 沒有 SQL injection 風險\n- [ ] 適當的 authorization check\n\n### Performance\n\n- [ ] Database query 有 index\n- [ ] 沒有 N+1 query\n- [ ] 沒有不必要的 re-render / re-computation\n```\n\n不需要每次都過完整份 checklist。根據 agent 改動的範圍，選擇相關的項目。但「事實性檢查」那個 section，每次都要看。\n\n## Takeaway\n\n1. **Agent 的 code 最危險的地方是「看起來很對」**——它寫出來的 code 格式完美、結構清晰、命名專業，但可能引用了不存在的 API、使用了過時的語法、或忽略了 production scale。永遠假設有 bug，直到自動化工具和你的 review 證明它是對的。\n\n2. **自動化護欄在 agent 時代 ROI 翻倍**——Pre-commit hooks、CI pipeline、TypeScript strict mode，這些「傳統工具」現在是你的第一道防線。它們不會被「看起來很專業」的 code 給騙過。\n\n3. **TDD + Agent 是目前最可靠的品質保證模式**——你寫 test 定義「什麼是對的」，agent 寫 implementation 讓 test 通過。Review 的壓力從「全部都要看」變成「只看設計和安全」，效率和品質同時提升。\n\n---\n\n_上一篇：[Spec-Driven Development](/blog/spec-driven-development-for-agents)_\n_下一篇：[從 Prompt 到 Production 的完整旅程](/blog/agentic-engineering-daily-workflow-advanced)_",
      "summary": "Agent 寫的 code 看起來很專業，但你怎麼知道它是對的？建立一套 agent output 的品質保證流程——從 CI 自動化驗證、人工 review 的重點、到最重要的心態：永遠假設 agent 的 code 有 bug。",
      "image": "https://bobochen.dev/_astro/cover.C12bsiWv.webp",
      "date_published": "2026-04-17T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "Code Review",
        "Testing",
        "AI",
        "品質保證"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-batch-api/",
      "url": "https://bobochen.dev/blog/claude-api-guide-batch-api/",
      "title": "Batch API：大量任務的高效非同步處理",
      "content_text": "Message Batches API 完整指南；與普通 API 的差異；JSONL 輸入格式；輪詢狀態與結果處理；Python + TypeScript 完整範例；錯誤處理；50% 折扣的成本計算；最佳實踐。",
      "content_html": "前幾章我們用的都是「即時 API」——你發一個請求，馬上等到回應。這種模式適合互動式應用，但對某些場景來說是浪費：\n\n你有 5,000 份客服對話需要分類。你有 10,000 筆產品描述需要翻譯。你有 2,000 份合約需要提取關鍵條款。\n\n這些任務都是非即時的。你不需要每個回應即時到達，你只需要在某個時間點「全部完成」。\n\n對這類場景，用普通 Messages API 一個一個送，不只慢（受 rate limit 限制），還貴。Batch API 是為這個痛點設計的。\n\n## Batch API 是什麼？\n\nBatch API（正式名稱 Message Batches API）讓你一次提交大量請求，Claude 在後台非同步處理，最長 24 小時內完成。\n\n與普通 Messages API 的主要差異：\n\n| 維度 | Messages API（即時） | Batch API（非同步） |\n|------|---------------------|-------------------|\n| 回應方式 | 即時（同步或 streaming） | 非同步，輪詢狀態 |\n| 完成時間 | 毫秒到秒 | 分鐘到 24 小時 |\n| 價格 | 標準 | **50% 折扣** |\n| Rate limit | 受即時 rate limit 限制 | 獨立的 batch quota |\n| 適用場景 | 互動式應用 | 離線批次處理 |\n\n50% 折扣是很吸引人的數字。但重點是：**你換掉的是即時性**。如果你的任務不需要即時回應，這筆交換非常划算。\n\n## 適用 vs 不適用場景\n\n在決定用 Batch API 之前，先確認你的場景是否合適。\n\n**非常適合：**\n- 大規模文字分類（客服對話、評論、文章）\n- 批次翻譯（幾百到幾千份文件）\n- 資料集標注（AI 訓練資料生成）\n- 報告自動生成（定期跑批次）\n- SEO 內容生成（批次生成商品描述）\n- 文件資料提取（合約、發票、報告）\n\n**不適合：**\n- 用戶等待即時回應的互動功能\n- 需要根據 Claude 回應動態調整下一步的 agentic 任務\n- 任何對時間敏感的任務\n\n我在生產環境的規則是：**如果用戶不在等你，就考慮 Batch API**。\n\n## API 使用流程\n\nBatch API 的使用分三步：\n\n```\n1. create_batch()  ←  提交批次任務\n         ↓\n2. poll_status()   ←  輪詢直到完成\n         ↓\n3. get_results()   ←  下載並處理結果\n```\n\n### Step 1：建立 Batch（Create Batch）\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\n# 準備你的任務列表\nrequests = [\n    {\n        \"custom_id\": \"review-001\",  # 你自定義的 ID，用來對應結果\n        \"params\": {\n            \"model\": \"claude-haiku-4-5\",  # Batch API 通常用 Haiku 降成本\n            \"max_tokens\": 256,\n            \"messages\": [\n                {\n                    \"role\": \"user\",\n                    \"content\": \"請將以下評論分類為「正面」、「負面」或「中性」，只回答一個詞：\\n\\n「這款產品品質很好，但運送太慢了。」\"\n                }\n            ]\n        }\n    },\n    {\n        \"custom_id\": \"review-002\",\n        \"params\": {\n            \"model\": \"claude-haiku-4-5\",\n            \"max_tokens\": 256,\n            \"messages\": [\n                {\n                    \"role\": \"user\",\n                    \"content\": \"請將以下評論分類為「正面」、「負面」或「中性」，只回答一個詞：\\n\\n「完全不值這個價格，已申請退款。」\"\n                }\n            ]\n        }\n    },\n    # ... 最多 10,000 個請求\n]\n\n# 建立批次\nbatch = client.messages.batches.create(requests=requests)\n\nprint(f\"Batch ID: {batch.id}\")\nprint(f\"狀態: {batch.processing_status}\")  # 初始為 \"in_progress\"\nprint(f\"請求數量: {batch.request_counts.processing}\")\n```\n\n**`custom_id` 非常重要**。Batch API 不保證回應的順序。你只能透過 `custom_id` 來對應你的請求和 Claude 的回應。建議用你資料庫的主鍵或其他唯一標識符。\n\n### Step 2：輪詢狀態（Poll Status）\n\n```python\nimport time\n\ndef wait_for_batch(client: anthropic.Anthropic, batch_id: str, poll_interval: int = 60) -> anthropic.types.MessageBatch:\n    \"\"\"輪詢直到 batch 完成，每隔 poll_interval 秒檢查一次\"\"\"\n    while True:\n        batch = client.messages.batches.retrieve(batch_id)\n\n        print(f\"[{time.strftime('%H:%M:%S')}] 狀態: {batch.processing_status} | \"\n              f\"處理中: {batch.request_counts.processing} | \"\n              f\"成功: {batch.request_counts.succeeded} | \"\n              f\"失敗: {batch.request_counts.errored}\")\n\n        if batch.processing_status == \"ended\":\n            return batch\n\n        time.sleep(poll_interval)\n\n\n# 等待完成（通常幾分鐘到幾十分鐘）\ncompleted_batch = wait_for_batch(client, batch.id)\nprint(f\"\\nBatch 完成！\")\nprint(f\"成功: {completed_batch.request_counts.succeeded}\")\nprint(f\"失敗: {completed_batch.request_counts.errored}\")\nprint(f\"過期: {completed_batch.request_counts.expired}\")\n```\n\n`processing_status` 的可能值：\n- `in_progress`：處理中\n- `ended`：全部完成（不管成功或失敗）\n\n`request_counts` 的欄位：\n- `processing`：還在處理的請求數\n- `succeeded`：成功的請求數\n- `errored`：失敗的請求數\n- `canceled`：被取消的請求數\n- `expired`：超時未處理的請求數\n\n### Step 3：讀取結果（Retrieve Results）\n\n```python\ndef process_batch_results(client: anthropic.Anthropic, batch_id: str) -> dict[str, str | None]:\n    \"\"\"讀取並處理 batch 結果，返回 {custom_id: 回應文字} 的映射\"\"\"\n    results = {}\n\n    for result in client.messages.batches.results(batch_id):\n        custom_id = result.custom_id\n\n        if result.result.type == \"succeeded\":\n            # 成功：提取回應文字\n            message = result.result.message\n            results[custom_id] = message.content[0].text\n\n        elif result.result.type == \"errored\":\n            # 失敗：記錄錯誤\n            error = result.result.error\n            print(f\"[ERROR] {custom_id}: {error.type} - {error.message}\")\n            results[custom_id] = None\n\n        elif result.result.type == \"expired\":\n            # 超時：這個請求沒有被處理\n            print(f\"[EXPIRED] {custom_id}: 請求超時未處理\")\n            results[custom_id] = None\n\n    return results\n\n\n# 處理結果\nresults = process_batch_results(client, batch.id)\n\nfor custom_id, classification in results.items():\n    if classification:\n        print(f\"{custom_id}: {classification}\")\n    else:\n        print(f\"{custom_id}: 處理失敗\")\n```\n\n## JSONL 格式（進階）\n\n除了用 SDK 直接建立請求陣列，Batch API 也支援 JSONL（JSON Lines）格式。每一行是一個 JSON 物件：\n\n```jsonl\n{\"custom_id\": \"review-001\", \"params\": {\"model\": \"claude-haiku-4-5\", \"max_tokens\": 256, \"messages\": [{\"role\": \"user\", \"content\": \"分類這則評論：好用\"}]}}\n{\"custom_id\": \"review-002\", \"params\": {\"model\": \"claude-haiku-4-5\", \"max_tokens\": 256, \"messages\": [{\"role\": \"user\", \"content\": \"分類這則評論：很差\"}]}}\n```\n\nJSONL 格式適合：\n- 事先把任務批次寫入檔案\n- 用其他工具（例如 Python pandas）生成大量任務\n- 跨不同服務傳遞任務佇列\n\n## Python 完整範例：批次評論分類系統\n\n這是一個真實可用的批次評論分類系統，包含資料準備、提交、輪詢和結果處理的完整流程：\n\n```python\nimport anthropic\nimport time\nimport json\nfrom pathlib import Path\n\n\ndef classify_reviews_batch(reviews: list[dict]) -> dict[str, str]:\n    \"\"\"\n    批次分類評論\n    reviews: [{\"id\": \"001\", \"text\": \"評論內容\"}, ...]\n    returns: {\"001\": \"正面\", \"002\": \"負面\", ...}\n    \"\"\"\n    client = anthropic.Anthropic()\n\n    # --- Step 1: 建立請求列表 ---\n    classification_prompt = \"\"\"請將以下評論分類。只能回答「正面」、「負面」或「中性」三者之一，不要任何其他說明。\n\n評論：{review}\"\"\"\n\n    requests = [\n        {\n            \"custom_id\": review[\"id\"],\n            \"params\": {\n                \"model\": \"claude-haiku-4-5\",\n                \"max_tokens\": 16,  # 分類任務只需要很少 tokens\n                \"messages\": [\n                    {\n                        \"role\": \"user\",\n                        \"content\": classification_prompt.format(review=review[\"text\"])\n                    }\n                ]\n            }\n        }\n        for review in reviews\n    ]\n\n    print(f\"提交 {len(requests)} 個評論分類請求...\")\n\n    # --- Step 2: 建立 batch ---\n    batch = client.messages.batches.create(requests=requests)\n    batch_id = batch.id\n    print(f\"Batch ID: {batch_id}\")\n\n    # 儲存 batch ID（以防程式中途崩潰，可以恢復）\n    Path(\"batch_id.txt\").write_text(batch_id)\n\n    # --- Step 3: 輪詢狀態 ---\n    print(\"等待 batch 完成（每 30 秒檢查一次）...\")\n    start_time = time.time()\n\n    while True:\n        batch_status = client.messages.batches.retrieve(batch_id)\n\n        elapsed = int(time.time() - start_time)\n        counts = batch_status.request_counts\n        print(f\"[{elapsed}s] 處理中: {counts.processing} | 成功: {counts.succeeded} | 失敗: {counts.errored}\")\n\n        if batch_status.processing_status == \"ended\":\n            break\n\n        time.sleep(30)\n\n    # --- Step 4: 處理結果 ---\n    results = {}\n    failed_ids = []\n\n    for result in client.messages.batches.results(batch_id):\n        if result.result.type == \"succeeded\":\n            text = result.result.message.content[0].text.strip()\n            # 標準化輸出（以防 Claude 回答了「正面。」或「這是正面」等格式）\n            if \"正面\" in text:\n                results[result.custom_id] = \"正面\"\n            elif \"負面\" in text:\n                results[result.custom_id] = \"負面\"\n            else:\n                results[result.custom_id] = \"中性\"\n        else:\n            failed_ids.append(result.custom_id)\n            results[result.custom_id] = \"未知\"\n\n    if failed_ids:\n        print(f\"\\n警告：{len(failed_ids)} 個請求失敗: {failed_ids[:5]}...\")\n\n    total_time = int(time.time() - start_time)\n    print(f\"\\n完成！耗時 {total_time} 秒，成功 {len(requests) - len(failed_ids)}/{len(requests)}\")\n\n    return results\n\n\nif __name__ == \"__main__\":\n    # 測試資料\n    sample_reviews = [\n        {\"id\": \"review-001\", \"text\": \"產品品質很好，已經回購三次了！\"},\n        {\"id\": \"review-002\", \"text\": \"送達時包裝破損，產品也壞了，非常失望。\"},\n        {\"id\": \"review-003\", \"text\": \"還可以，跟描述差不多，沒有特別驚喜。\"},\n        {\"id\": \"review-004\", \"text\": \"客服態度超好，解決問題很快速！\"},\n        {\"id\": \"review-005\", \"text\": \"等了兩週才到，但產品本身質量不錯。\"},\n    ]\n\n    classifications = classify_reviews_batch(sample_reviews)\n\n    print(\"\\n分類結果：\")\n    for review_id, classification in classifications.items():\n        print(f\"  {review_id}: {classification}\")\n```\n\n## TypeScript 完整範例\n\n```typescript\nimport Anthropic from \"@anthropic-ai/sdk\";\n\nconst client = new Anthropic();\n\ninterface Review {\n  id: string;\n  text: string;\n}\n\nasync function classifyReviewsBatch(\n  reviews: Review[]\n): Promise<Record<string, string>> {\n  // Step 1: 建立請求\n  const requests: Anthropic.Messages.MessageCreateParamsNonStreaming[] =\n    reviews.map((review) => ({\n      custom_id: review.id,\n      params: {\n        model: \"claude-haiku-4-5\",\n        max_tokens: 16,\n        messages: [\n          {\n            role: \"user\" as const,\n            content: `請將以下評論分類。只能回答「正面」、「負面」或「中性」三者之一。\\n\\n評論：${review.text}`,\n          },\n        ],\n      },\n    }));\n\n  console.log(`提交 ${requests.length} 個評論分類請求...`);\n\n  // Step 2: 建立 batch\n  const batch = await client.messages.batches.create({ requests });\n  console.log(`Batch ID: ${batch.id}`);\n\n  // Step 3: 輪詢狀態\n  let batchStatus = batch;\n  while (batchStatus.processing_status !== \"ended\") {\n    await new Promise((resolve) => setTimeout(resolve, 30_000)); // 等 30 秒\n    batchStatus = await client.messages.batches.retrieve(batch.id);\n\n    const counts = batchStatus.request_counts;\n    console.log(\n      `處理中: ${counts.processing} | 成功: ${counts.succeeded} | 失敗: ${counts.errored}`\n    );\n  }\n\n  // Step 4: 處理結果\n  const results: Record<string, string> = {};\n  const failedIds: string[] = [];\n\n  for await (const result of await client.messages.batches.results(batch.id)) {\n    if (result.result.type === \"succeeded\") {\n      const text = (\n        result.result.message.content[0] as Anthropic.Messages.TextBlock\n      ).text.trim();\n\n      if (text.includes(\"正面\")) {\n        results[result.custom_id] = \"正面\";\n      } else if (text.includes(\"負面\")) {\n        results[result.custom_id] = \"負面\";\n      } else {\n        results[result.custom_id] = \"中性\";\n      }\n    } else {\n      failedIds.push(result.custom_id);\n      results[result.custom_id] = \"未知\";\n    }\n  }\n\n  if (failedIds.length > 0) {\n    console.warn(`${failedIds.length} 個請求失敗`);\n  }\n\n  return results;\n}\n\n// 使用範例\n(async () => {\n  const reviews: Review[] = [\n    { id: \"r001\", text: \"產品品質很好！\" },\n    { id: \"r002\", text: \"送達時包裝破損。\" },\n    { id: \"r003\", text: \"還可以，普通。\" },\n  ];\n\n  const results = await classifyReviewsBatch(reviews);\n  console.log(\"分類結果：\", results);\n})();\n```\n\n## 錯誤處理：部分失敗的 Batch\n\nBatch API 的一個重要特性：**即使部分請求失敗，整個 batch 仍然正常完成**。處理完成後，你需要自行處理失敗的請求。\n\n```python\ndef handle_batch_results_with_retry(\n    client: anthropic.Anthropic,\n    batch_id: str,\n    max_retries: int = 2\n) -> dict[str, str | None]:\n    \"\"\"\n    處理 batch 結果，對失敗的請求自動重試（使用即時 API）\n    \"\"\"\n    results = {}\n    failed_requests = []\n\n    # 第一輪：處理所有結果\n    for result in client.messages.batches.results(batch_id):\n        if result.result.type == \"succeeded\":\n            results[result.custom_id] = result.result.message.content[0].text\n        else:\n            error_type = result.result.type  # \"errored\" or \"expired\"\n            print(f\"[{error_type}] {result.custom_id}\")\n            failed_requests.append(result.custom_id)\n            results[result.custom_id] = None\n\n    # 第二輪：對失敗的請求用即時 API 重試\n    if failed_requests and max_retries > 0:\n        print(f\"\\n對 {len(failed_requests)} 個失敗請求進行重試...\")\n        # 這裡你需要保留原始請求的映射，根據 custom_id 找回原始 params\n        # （在實際應用中，你會從資料庫或原始列表中查找）\n        for failed_id in failed_requests:\n            try:\n                # 用即時 API 重試\n                retry_response = client.messages.create(\n                    model=\"claude-haiku-4-5\",\n                    max_tokens=16,\n                    messages=[{\"role\": \"user\", \"content\": f\"...重試請求內容...\"}]\n                )\n                results[failed_id] = retry_response.content[0].text\n            except Exception as e:\n                print(f\"重試失敗 {failed_id}: {e}\")\n\n    return results\n```\n\n## Limits 與最佳實踐\n\n**Limits（截至 2026）：**\n- 單次 batch：最多 10,000 個請求\n- 每個請求最大：32MB（JSONL 行）\n- 整個 batch 最大：200MB（未壓縮 JSONL）\n- Batch 保留時間：29 天（超過後自動刪除）\n- 最長處理時間：24 小時\n\n**最佳實踐：**\n\n1. **選對模型**：Batch 任務大多是分類、提取、翻譯等任務，Claude Haiku 4.5 夠用，比 Opus 便宜 20 倍\n\n2. **善用 max_tokens 最小化**：批次分類任務 max_tokens 設 16-32 就夠，不要設 1024\n\n3. **設計冪等的 custom_id**：用你的資料主鍵（例如資料庫 ID）作為 custom_id，方便重跑\n\n4. **儲存 batch_id**：程式中途崩潰也能繼續輪詢\n\n5. **不要阻塞等待**：Batch 可能需要幾小時，設計為非阻塞的背景工作，完成後發通知\n\n6. **分批提交超大任務**：10,000 個請求以上的任務，拆成多個 batch\n\n```python\ndef split_into_batches(items: list, batch_size: int = 5000) -> list[list]:\n    \"\"\"將大型任務拆分為多個 batch\"\"\"\n    return [items[i:i+batch_size] for i in range(0, len(items), batch_size)]\n```\n\n## 成本計算\n\n用 Batch API 相比普通 Messages API，**輸入和輸出 tokens 都打 5 折**。\n\n以一個批次翻譯任務為例：\n- 1,000 份文件，每份平均 2,000 tokens\n- 翻譯輸出平均 2,200 tokens\n- 使用 Claude Haiku 4.5（假設 $0.80/MTok 輸入，$4/MTok 輸出）\n\n| 方式 | 輸入費用 | 輸出費用 | 總計 |\n|------|---------|---------|------|\n| Messages API | $1.60 | $8.80 | $10.40 |\n| Batch API（5折）| $0.80 | $4.40 | $5.20 |\n| 節省 | | | **50%（$5.20）** |\n\n在大量任務上，Batch API 是個非常划算的選擇。\n\n---\n\nBatch API 解決了「大量 + 非即時」這個使用場景。但如果你的任務更複雜——需要動態決策、多步驟推理、呼叫外部工具——光靠 Messages API 或 Batch API 就不夠了。\n\n下一章，我們進入這本書的重頭戲：**Agent SDK**。我們要從「呼叫 API 得到答案」升級到「讓 AI 自主完成複雜任務」。",
      "summary": "Message Batches API 完整指南；與普通 API 的差異；JSONL 輸入格式；輪詢狀態與結果處理；Python + TypeScript 完整範例；錯誤處理；50% 折扣的成本計算；最佳實踐。",
      "image": "https://bobochen.dev/_astro/cover.DZglX3ke.webp",
      "date_published": "2026-04-17T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Batch API",
        "非同步",
        "大量處理"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-27-afterword/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-27-afterword/",
      "title": "後記：寫給榕和辰",
      "content_text": "爸爸寫這些，不是要你們覺得辛苦或難過。是想讓你們知道，你們每天早上被叫起床、一起吃早餐、被載去學校、看到爸爸在校門口等你們——這些「每天都在發生的小事」，是爸爸用了整個人生才學會的事。",
      "content_html": "親愛的榕、親愛的辰：\n\n你們現在大概還讀不懂這些文字。\n\n榕，你正在上小學，最近的煩惱是數學作業和跟同學約好明天要一起玩什麼。辰，你還在學說話，今天的成就是又發明了一個新的搞笑動作要表演給我看。\n\n你們的世界很小、很簡單、很安全。這是爸爸最希望看到的樣子。\n\n但有一天你們會長大。長大之後可能會好奇：為什麼爸爸選擇不去園區工作？為什麼爸爸堅持遠距？為什麼爸爸每天都要接送你們？為什麼爸爸這麼在意「陪伴」這件事？\n\n這本書就是答案。\n\n---\n\n爸爸的童年跟你們很不一樣。\n\n爸爸小時候家裡沒有錢，沒有念幼兒園。午餐有時候是自己省下來的。外食的選項是沒有牛肉的牛肉湯麵。鐵板燒是長大工作之後才第一次吃到的東西。\n\n爸爸的爸爸——你們的阿公——是一個很複雜的人。他會帶我去兜風、在我的臥室貼米奇貼紙。但他也會喝酒、做一些讓家裡很不安的事。爸爸花了很多年才接受：一個人可以同時有好的部分和不好的部分。\n\n爸爸的媽媽——你們的阿嬤——是一個很勇敢的人。她一個人撐起了整個家。她的方法不完美，但她做了她能做的最好的。\n\n爸爸的哥哥——你們的伯伯——有他自己的困難。這個故事等你們大一點再跟你們說。\n\n---\n\n你們可能會問：寫這些不會難過嗎？\n\n會。\n\n有些段落寫的時候，眼睛是模糊的。有些記憶翻出來的時候，心臟會痛。有些話打字打到一半，手會停下來，因為不確定自己準備好面對了沒有。\n\n但爸爸還是寫了。\n\n因為不寫出來的話，這些東西就只能一直壓在心裡。壓久了會變成一個越來越重的石頭。把它寫出來——給它一個形狀、一個名字、一個位置——它就沒那麼重了。\n\n也因為，如果爸爸不寫，你們可能永遠不會知道這些事。\n\n不是每個家庭都會把不光彩的歷史記錄下來。大部分的人會選擇忘記、避開、假裝沒有過。但爸爸覺得，知道自己從哪裡來，才能更清楚自己要往哪裡去。\n\n---\n\n這本書裡最想讓你們記住的一句話，只有一句：\n\n**陪伴比什麼都重要。**\n\n不是因為爸爸在書上看到的、不是因為哪個專家說的。\n\n是因為爸爸從小就知道「沒有人陪」是什麼感覺。那種放學回家打開門、發現家裡是空的感覺。那種學校活動別的同學的爸媽都來了、自己的座位旁邊是空的感覺。\n\n那些感覺很不好。爸爸不要你們嚐到。\n\n所以爸爸每天在校門口等你們。所以爸爸每一次校外教學都去。所以爸爸選擇了可能薪水少一點、但可以每天陪在你們身邊的工作方式。\n\n這些不是犧牲。這是選擇。是爸爸這輩子做過最好的選擇。\n\n---\n\n最後一件事。\n\n你們現在擁有的每一個平凡日常——每天有飯吃、有學校上、有爸爸媽媽陪、週末可以出去玩、元旦可以爬山吃蛋黃派——\n\n這些不是理所當然的。\n\n是爸爸用了一整個童年的匱乏、一整個青年的打拼、一整段照顧阿公的歷程，才走到這裡的。\n\n不是要你們感謝。是希望你們珍惜。\n\n然後，如果有一天你們也當了爸爸或媽媽——\n\n記得在校門口等你們的小孩。\n\n那個「等」，比你想像的重要一百倍。\n\n愛你們的爸爸",
      "summary": "爸爸寫這些，不是要你們覺得辛苦或難過。是想讓你們知道，你們每天早上被叫起床、一起吃早餐、被載去學校、看到爸爸在校門口等你們——這些「每天都在發生的小事」，是爸爸用了整個人生才學會的事。",
      "image": "https://bobochen.dev/_astro/cover.C12bsiWv.webp",
      "date_published": "2026-04-15T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "育兒",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-26-life-lessons/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-26-life-lessons/",
      "title": "三明治世代的生命課",
      "content_text": "從小孩身上補上 0-7 歲的記憶，從爸媽身上補上人生後半段的想像，從幫祖先撿骨補上往生後的了解。身處三明治世代的我，意外地近距離看完了整個人類生命週期。看完後，我決定把所有籌碼押在「陪伴」這件事上。",
      "content_html": "## 人的一生\n\n爸爸告別式那天，靈車往陽明山上開，一路下著大雨。就在上山的路上，我忽然懂了一件事。\n\n從我的小孩身上，我可以看到人類 0 到 7 歲的樣子——那些我自己已經沒有記憶的年紀，那些第一次學走路、第一次叫爸爸、第一次對世界感到好奇的時刻。\n\n從我的爸媽身上，我可以看到人生後半段的樣子——身體開始不聽使喚、記憶開始模糊、原本強勢的人變得依賴、原本獨立的人需要被照顧。\n\n從幫祖先撿骨的經驗，我看到了人往生之後的樣子——一切終將歸於塵土，不管你這輩子賺了多少錢、吵了多少架、留了多少遺憾。\n\n三明治世代的人，夾在上一代和下一代之間，同時承受兩端的壓力。這很累。\n\n但換一個角度看，我們也是最幸運的一群人——因為我們有機會近距離看完整個人類生命週期。\n\n從出生到死亡，從新生的喜悅到老去的無奈，從「第一次」的興奮到「最後一次」的平靜。全部都看了。\n\n看完之後，我該怎麼看待自己的生命呢？\n\n## 我沒有念幼兒園\n\n我跟榕和辰說過這件事：爸爸小時候家裡沒有錢，沒有念幼兒園。\n\n他們聽了覺得很不可思議。因為在他們的世界裡，上幼兒園就像吃早餐一樣自然。\n\n但在我的世界裡，幼兒園是一種奢侈品。那筆學費，家裡出不起。所以我的學前記憶，不是在教室裡畫畫、唱歌、交朋友，而是在家裡看媽做家事、跟鄰居的小孩在巷子裡跑來跑去。\n\n我不覺得那段時光不好。但我知道，我少了某些東西。\n\n這個「少了某些東西」的感覺，在我有了自己的小孩之後，變得更清楚了。我看著榕在幼兒園裡交到好朋友、在校園裡跑來跑去、每天回家跟我分享今天學了什麼——我就想：如果我小時候也有這些，我會不會是一個不一樣的人？\n\n答案已經不重要了。重要的是，我可以給他們。\n\n## 不去園區\n\n大學畢業的時候，身邊很多同學去了新竹科學園區。\n\n園區的薪水在那個年代來說是很有吸引力的——比留在台北好、比進一般公司好、聽起來也體面。而且以我的技術背景，去園區不是什麼困難的事。\n\n但我沒有去。\n\n我留在台北，離媽近的地方找工作。\n\n理由很簡單：爸已經不在了（後來的事證明他會用另一種方式「不在」），哥靠不住，如果我也離開了，媽就一個人了。\n\n這不是什麼偉大的犧牲。我只是覺得，錢可以再賺，但如果媽出了什麼事而我在新竹，那兩個小時的高鐵距離就是永遠的遺憾。\n\n這是我第一次主動選擇「陪伴」而不是「收入」。\n\n那時候我還不知道，這個選擇會定義我接下來的人生方向。\n\n## 全勤的爸爸\n\n有了小孩之後，我做了另一個選擇：找一份遠距工作。\n\n這個選擇在當時看起來是「放棄」。放棄更高的薪水、放棄更好的職稱、放棄那種每天進辦公室、跟同事一起打拼的歸屬感。\n\n但我要的不是這些。\n\n我要的是：**每天接送小孩上下學。**\n\n這聽起來是一件很小的事。但對我來說，那是整個童年都沒有得到的東西。\n\n我小時候，爸在外面喝酒或工作，媽在工廠上班。沒有人接我下課。我自己走路回家、自己開門、自己寫作業、自己等大人回來。\n\n我不要我的小孩過那種日子。\n\n所以我每天接送。每一次學校的校外教學，我都請假去。每一場學校活動——運動會、成果發表、家長日——我都到。\n\n有一次榕的同學跟榕說：「你爸爸好像每次都會來欸。」\n\n榕回家跟我講了這件事，語氣很平淡，好像在說「今天吃了什麼」一樣稀鬆平常。\n\n但我聽了之後，在心裡停了很久。\n\n因為「爸爸每次都會來」這件事，在我的童年裡，從來不曾存在。\n\n## 陪伴比什麼都重要\n\n我不是一個會講大道理的人。但如果這個系列的故事有什麼核心，大概就是這句話：\n\n**陪伴比什麼都重要。**\n\n這不是書上看來的、不是誰教我的。這是用一整個人生的匱乏換來的結論。\n\n小時候沒有人陪，所以我知道缺乏陪伴是什麼感覺。\n爸生病的時候，我陪了他最後的一程，所以我知道「來不及陪」是什麼遺憾。\n媽這些年一個人撐著，所以我知道有人在身邊跟沒人在身邊的差別有多大。\n\n所有的人生經驗都在告訴我同一件事：人在的時候你不覺得，人不在的時候你才知道。\n\n所以我把所有的籌碼押在「在」這件事上。\n\n在場。在身邊。在他們需要的時候。\n\n不是因為我很厲害、很有愛心、很偉大。\n\n是因為我從小就知道「不在」是什麼感覺。而我不要讓我的小孩嚐到那種滋味。\n\n## 寫在最後\n\n這個系列寫了 23 篇。從童年的債、到家裡的傷、到扛起一切的疲憊、到那個不可能的選擇、到最後的光。\n\n寫的過程比我想像的累。不是因為打字累，是因為每一篇都要重新走進那些記憶裡，把已經結痂的東西再掀開一次。\n\n但掀開之後，好像也比較能呼吸了。\n\n有些事情，你一直扛著、一直不說、一直假裝沒事，它就會在你胸口裡慢慢長大，變成一個越來越重的石頭。把它寫出來不會讓石頭消失，但至少讓它有了一個形狀。有了形狀的東西，比較不那麼可怕。\n\n如果你讀到這裡，不管你是跟我一樣的三明治世代、是正在照顧家人的照顧者、是童年不太順遂的人、還是只是好奇點進來的路人——\n\n謝謝你讀完。\n\n這些故事不是只有我的。每一個在深夜醒來、每一個硬撐著不倒下、每一個一邊流淚一邊繼續過日子的人，都有自己的版本。\n\n你不孤單。\n\n---\n\n_寫給榕和辰：_\n\n_爸爸寫這些，不是要你們覺得辛苦或難過。_\n\n_是想讓你們知道，你們每天早上被爸爸叫起床、一起吃早餐、被爸爸載去學校、下課看到爸爸在校門口等你們——這些「每天都在發生的小事」，是爸爸用了整個人生才學會的事。_\n\n_不是理所當然的。是選擇。_\n\n_是爸爸最重要的選擇。_",
      "summary": "從小孩身上補上 0-7 歲的記憶，從爸媽身上補上人生後半段的想像，從幫祖先撿骨補上往生後的了解。身處三明治世代的我，意外地近距離看完了整個人類生命週期。看完後，我決定把所有籌碼押在「陪伴」這件事上。",
      "image": "https://bobochen.dev/_astro/cover.Df2zevqP.webp",
      "date_published": "2026-04-14T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭",
        "育兒"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-24-rongs-childhood-list/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-24-rongs-childhood-list/",
      "title": "榕的童年清單",
      "content_text": "帶她吃早餐、喝咖啡、送她上學、接她下課。元旦爬山吃蛋黃派。這不是浪漫的育兒哲學，這是我用整個成長經歷換來的清單。每一項背後，都有一個「我小時候沒有」的故事。",
      "content_html": "## 清單\n\n如果有人問我：你期望給孩子怎樣的童年？\n\n我的答案不是什麼宏大的教育理念。不是「培養國際觀」、「學三種語言」、「進資優班」。\n\n我的答案是一份很簡單的清單：\n\n- 帶她吃早餐\n- 帶她喝咖啡（好吧，她喝果汁）\n- 送她上學\n- 接她下課\n- 一起探索大自然\n- 一起好奇各種事物\n- 元旦爬山吃蛋黃派\n\n就這樣。\n\n聽起來很普通對吧？每一項都是再平凡不過的日常。\n\n但每一項的背後，都有一個「我小時候沒有」的故事。\n\n## 吃早餐\n\n帶榕吃早餐是我們的固定行程。\n\n通常是出門上學之前，在家附近的早餐店。她喜歡蛋餅，我喝咖啡。有時候我們會聊天，有時候就是安靜地各吃各的。\n\n這個畫面很日常。但它對我來說是一種修復。\n\n小時候我的早餐是自己解決的。媽很早就出門上班了，爸不知道在哪裡。我自己熱前一天的剩菜、或者買一個饅頭配豆漿。沒有人坐在對面陪我吃。\n\n所以現在我坐在榕對面、看著她把蛋餅一口一口吃完的時候，我心裡會有一種很安定的感覺。\n\n不是什麼大事。就是「有人陪你吃早餐」的安定。\n\n但這種安定，我等了三十幾年。\n\n## 送和接\n\n每天早上我送榕去學校。每天下午我在校門口等她出來。\n\n風雨無阻。\n\n有時候下雨，我撐著傘站在校門口。榕從教室出來看到我，會小跑步過來。書包在背後晃來晃去，雨滴打在她的頭上，她不在意。\n\n那個畫面我看了幾百次了，每次都覺得好看。\n\n因為小時候沒有人來接我。我自己走路回家、自己開門、自己寫功課。回到家的時候，家裡是空的。\n\n那種「打開門然後發現沒有人」的感覺，你習慣了之後就不覺得有什麼。但當你自己站在校門口、看到小孩衝向你的時候，你會突然理解：原來「有人在等你」是這麼大的事情。\n\n## 爬山吃蛋黃派\n\n每年元旦，我會帶全家去爬山。\n\n不是什麼大山。就是台北近郊那種一兩個小時可以走完的步道。帶著水壺、零食、和一包蛋黃派。\n\n到了山頂（或者走到一個看得到風景的地方），坐下來，拆開蛋黃派，大家一起吃。\n\n榕覺得這是一個「傳統」。她會在跨年的時候問我：「明天要去爬山吃蛋黃派嗎？」\n\n我會說：「當然。」\n\n這個傳統是我自己發明的。小時候的元旦沒有這種活動。爸可能在睡覺、媽可能在做家事、家裡的氣氛可能因為跨年前的某次吵架而低迷。\n\n所以我想創造一個屬於我們自己的元旦記憶。不需要很厲害、不需要花很多錢、不需要去什麼特別的地方。\n\n就是一座小山、一包蛋黃派、和全家人在一起。\n\n這就夠了。\n\n## 好奇心\n\n我最喜歡跟榕和辰做的事情之一，是「一起好奇」。\n\n走在路上看到一隻昆蟲，蹲下來觀察。看到天上的雲很奇怪，討論它像什麼。在書店裡翻到一本奇怪的書，一起讀幾頁。看到一台從沒見過的機器，研究它是做什麼用的。\n\n榕四五歲的時候，正處在「為什麼」的階段。什麼都問為什麼。\n\n為什麼天空是藍的？為什麼下雨？為什麼那個人走路跟我們不一樣？為什麼狗狗會搖尾巴？\n\n有些問題我答得出來，有些答不出來。答不出來的時候我會說：「欸，我也不知道欸。我們回家一起查。」\n\n這個「我也不知道，一起查」，是我刻意的。\n\n因為我希望她知道：不知道不丟臉。承認不知道然後去找答案，才是最酷的事。\n\n也因為小時候我沒有人可以問為什麼。家裡的大人忙著處理生存問題，沒有餘力回答一個小孩的好奇心。所以我的很多「為什麼」是自己長大後才去找答案的。\n\n我不要我的小孩等那麼久。\n\n## 這份清單的來源\n\n這份清單不是從育兒書上看來的。\n\n是從我自己的童年裡，一項一項「反著寫」出來的。\n\n小時候沒人陪吃早餐 → 我要每天陪她吃。\n小時候沒人接下課 → 我要每天在校門口等。\n小時候的元旦沒有家庭活動 → 我要創造一個。\n小時候的「為什麼」沒人回答 → 我要回答每一個。\n\n每一項都很簡單。但它們加在一起，就是一個「跟我不一樣的童年」。\n\n一個有人陪的童年。\n\n這就是我能給榕和辰最好的東西。不是什麼了不起的資源或機會。就是我。在這裡。每天。",
      "summary": "帶她吃早餐、喝咖啡、送她上學、接她下課。元旦爬山吃蛋黃派。這不是浪漫的育兒哲學，這是我用整個成長經歷換來的清單。每一項背後，都有一個「我小時候沒有」的故事。",
      "image": "https://bobochen.dev/_astro/cover.B9g6gX62.webp",
      "date_published": "2026-04-13T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "育兒",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-25-warm-toothbrush/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-25-warm-toothbrush/",
      "title": "辰辰的溫水刷牙",
      "content_text": "辰辰開始用溫水刷牙之後，每次都要跑來給爸爸看：「溫溫的！泡溫泉！」然後進階到 2.0 版：要嚇爸爸一跳，還學爸爸的動作表演給媽媽看。這些小到不能再小的片段，是我最想記住的東西。",
      "content_html": "## 泡溫泉\n\n冬天的時候，辰辰開始用溫水刷牙。\n\n這對大人來說是一件完全不值得記錄的事。但對一個小小孩來說，溫水刷牙是一個了不起的新發現。\n\n刷完牙之後，他會跑過來找我，張開嘴巴讓我聞：\n\n「爸爸！溫溫的！不會冰～」\n\n然後他自己加了一句：「泡溫泉！」\n\n嘴巴裡溫溫的 = 泡溫泉。這個邏輯只有小孩想得出來。\n\n我每次都笑。不是禮貌性的笑，是那種忍不住的、從肚子裡冒出來的笑。\n\n## 嚇一跳 2.0\n\n後來辰辰把這個遊戲升級到 2.0 版。\n\n不只是跑來給我看「溫溫的」了。他會先躲在浴室門後面，等我經過的時候突然跳出來：\n\n「嚇一跳！」\n\n然後張嘴讓我看。\n\n而且不只要嚇我。嚇完之後，他還要學我被嚇到的動作——腳蹬一下、頭往後仰——然後跑去表演給媽媽看。\n\n「爸爸剛剛是這樣！」\n\n整個表演非常專業。表情、動作、節奏都到位。兩歲多的小孩，已經知道怎麼「演」一個故事了。\n\n這些事情寫出來可能只有家長覺得好笑。但對我來說，這種「小到不能再小」的時刻，反而是我最想記住的東西。\n\n## 為什麼要記這些\n\n你可能會覺得：一個刷牙的小事，值得寫一整篇文章嗎？\n\n值得。\n\n因為這些小事會消失。\n\n小孩長大的速度比你想像的快。辰辰現在會跑來給我看「溫水刷牙」、會嚇我一跳、會學我的動作。但再過幾年，他就不會了。他會覺得這很幼稚、會開始有自己的世界、會關上房門跟朋友傳訊息而不是跑來找爸爸。\n\n這是正常的成長。我不會阻止。\n\n但我想在這些東西消失之前，把它們寫下來。\n\n因為再過二十年，當辰辰長大了、也許自己也當了爸爸的時候，他可能會翻到這篇文章，然後想起來：原來我小時候會做這種事啊。\n\n然後他會笑。就像我現在寫的時候在笑一樣。\n\n## 榕的「為什麼」\n\n榕的武器不是肢體表演，是語言。\n\n四五歲的時候，她進入了「為什麼」的爆發期。\n\n「爸爸，為什麼天空是藍的？」\n「爸爸，為什麼我們要睡覺？」\n「爸爸，為什麼那個阿伯走路歪歪的？」\n「爸爸，為什麼你的頭髮比較少？」\n\n最後一個問題我選擇不回答。\n\n但其他的我都盡量回答。答不出來的就一起查。有時候查完之後她又問為什麼，變成一個無限迴圈。\n\n最經典的一次是她問我：「為什麼我要問為什麼？」\n\n我想了想，說：「因為你很好奇啊。好奇是很棒的事。」\n\n她滿意地點了點頭。\n\n## 阿公的工作\n\n有一次榕突然問我：「阿公是做什麼工作的？」\n\n她說的阿公是我爸。\n\n我一時之間不知道該怎麼回答，因為爸換過的工作實在太多了。我開始列：計程車司機、里長候選人、立法委員助理、廣播電台……\n\n榕越聽越興奮：「還有嗎？還有嗎？」\n\n像是在聽一個冒險故事。在她的腦袋裡，阿公大概是一個做過超多酷事的人。\n\n我沒有告訴她其他的部分。那些屬於大人的事情，等她長大了再說。\n\n現在，讓阿公在她心裡是一個「做過好多工作的有趣阿公」就好。\n\n## 輕的和重的\n\n這個系列寫了二十幾篇。前面大部分是重的——債、傷、病、選擇、死亡。\n\n這一篇是輕的。刷牙、嚇一跳、為什麼、阿公的工作。\n\n但輕的不代表不重要。\n\n事實上，這些「輕」的東西，才是支撐我走過那些「重」的東西的力量來源。\n\n當你在養護中心簽完帳單、心情跌到谷底的時候，你回到家，辰辰跑過來說「爸爸！溫溫的！泡溫泉！」——你就覺得，好吧，世界還是有一些美好的部分。\n\n當你跟媽吵完架、覺得什麼都講不通的時候，榕問你「爸爸為什麼天空是藍色的」——你就覺得，至少有一個人覺得你知道所有的答案。\n\n小孩不知道他們在做什麼。他們不知道他們的笑聲是爸爸的解藥、他們的擁抱是爸爸的充電器、他們那些無厘頭的問題是爸爸繼續走下去的理由。\n\n但他們確實是。\n\n所以我把這些輕的東西也寫下來。放在重的旁邊。\n\n讓這個系列不只是一本關於苦難的記錄。也是一本關於「苦難之後還有什麼」的記錄。\n\n答案是：溫水刷牙、嚇一跳、和一個問不完的「為什麼」。\n\n這就夠了。",
      "summary": "辰辰開始用溫水刷牙之後，每次都要跑來給爸爸看：「溫溫的！泡溫泉！」然後進階到 2.0 版：要嚇爸爸一跳，還學爸爸的動作表演給媽媽看。這些小到不能再小的片段，是我最想記住的東西。",
      "image": "https://bobochen.dev/_astro/cover.BkCu3bf-.webp",
      "date_published": "2026-04-13T00:00:00.000Z",
      "tags": [
        "家庭",
        "育兒",
        "童言童語"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-support-community/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-support-community/",
      "title": "一個人的客服與社群",
      "content_text": "用戶開始用你的產品了，問題也跟著來。這篇教 Solo Builder 用 AI chatbot、自動回覆模板和 AI 生成 FAQ 把客服時間壓到每天 30 分鐘，並釐清「客服」與「社群」的差別：什麼時候該建社群、選哪個平台、哪些訊息必須親自回、哪些交給自動化，在不犧牲用戶體驗的前提下一個人也撐得住。",
      "content_html": "## 凌晨三點的客服 Email：一個人怎麼撐住\n\n你的產品上線一個月了，開始有穩定的用戶。某天凌晨三點，手機跳出一封信：\n\n「你好，我剛付了年費，但登入後看不到進階功能，可以幫我處理嗎？急用。」\n\n你翻個身，心想明天早上再回。但腦袋裡一直在想：「萬一他等不及直接退款怎麼辦？」結果四點半就醒了，五點半就爬起來處理。\n\n這是每個 Solo Builder 都會遇到的轉折點：**產品有人用是好事，但用戶的問題不會只在你方便的時候出現。**\n\n你不可能 24 小時盯著收件匣。你還有正職、有生活。但用戶的體驗不能因為你只有一個人就打折扣。\n\n答案不是「更努力回覆」，而是**建立一套系統，讓大部分問題不需要你親自回覆**。\n\n## 客服的真相：80% 的問題是重複的\n\n在你急著找 AI chatbot 解決方案之前，先看看一個事實：\n\n大多數產品的客服問題，80% 以上是重複的。\n\n「怎麼重設密碼？」「怎麼取消訂閱？」「支援哪些付款方式？」「為什麼我的 XXX 不能用？」\n\n這些問題反覆出現，每一次你都手動回覆同樣的內容。這是最沒效率的時間使用方式。\n\n解決方案也很直觀：**把這些重複問題的答案寫好，放在用戶找得到的地方。**\n\n聽起來很簡單。但大多數 Solo Builder 不是不知道要做，而是「沒時間做」。\n\n2026 年，AI 改變了這件事。\n\n## 第一道防線：自助式文件\n\n你的第一優先不是裝 chatbot，而是寫出好的文件。\n\n### 傳統做法\n\n- 用戶問一個問題，你回一封信\n- 同一個問題被問第三次，你想「我該寫個 FAQ」\n- 但寫 FAQ 要時間，先回信再說\n- 反覆循環，永遠沒有 FAQ\n\n### AI 加持做法\n\n把你回覆過的客服信件和訊息餵給 AI，讓它幫你自動產生文件：\n\n```text\n以下是我過去一個月回覆的客服訊息（已去除個資）：\n\n[貼上 10-20 封典型的客服對話]\n\n請幫我：\n1. 把這些問題分類（帳號問題、付費問題、功能問題、bug 回報）\n2. 每個分類列出最常見的前 3 個問題\n3. 為每個問題撰寫一個標準回答，語氣友善、專業\n4. 產生一個結構化的 FAQ 頁面，用 Markdown 格式\n```\n\n15 分鐘，你就有了一個涵蓋最常見問題的 FAQ 頁面。\n\n### FAQ 的進階結構\n\n一個好的 FAQ 不只是問答列表。它應該有層次：\n\n| 層級         | 內容            | 目的                          |\n| ------------ | --------------- | ----------------------------- |\n| **快速入門** | 5 分鐘上手指南  | 減少「怎麼開始」類問題        |\n| **常見問題** | 分類的 Q&A      | 解答 80% 的重複問題           |\n| **操作指南** | 步驟式 how-to   | 解答「怎麼做 XXX」類問題      |\n| **疑難排解** | 已知問題 + 解法 | 減少 bug 回報信件             |\n| **更新日誌** | 版本更新說明    | 減少「為什麼 XXX 變了」類問題 |\n\n### 用 AI 從程式碼生成文件\n\n這是更進階的技巧——讓 AI 直接從你的 codebase 生成使用者文件：\n\n```text\n請閱讀以下 API 路由和元件程式碼，產生面向「非技術用戶」的操作說明。\n\n[附上相關程式碼片段]\n\n要求：\n- 不出現任何技術術語\n- 每個操作附上步驟編號\n- 預期結果和常見錯誤情境都要涵蓋\n- 繁體中文，語氣像在跟朋友解釋\n```\n\n我在做 cloud-on-academy 時就用了這個方法——課程平台的操作說明幾乎全是 AI 從前端元件的程式碼生成的。我只需要做最後的語氣調整和截圖。\n\n## 第二道防線：AI Chatbot\n\nFAQ 解決了「願意自己找答案」的用戶。但有些人就是想直接問。\n\n這時候 AI chatbot 就登場了。\n\n### 2026 年的 AI 客服 chatbot\n\n現在要做一個有用的 AI chatbot 已經不難了。核心概念是 **RAG（Retrieval-Augmented Generation）**——把你的 FAQ、文件、更新日誌餵給 AI，讓它根據這些內容回答用戶問題。\n\n| 方案                                   | 適用場景                 | 成本          | 設定難度 |\n| -------------------------------------- | ------------------------ | ------------- | -------- |\n| **Intercom Fin**                       | 有預算的 SaaS 產品       | $0.99/次解答（每月最低 50 次解答，約 $49.5/月起；搭配 Intercom 自家 helpdesk 另計 $29/seat/月起） | 低       |\n| **Crisp + AI**                         | 小型產品（Free 無 AI）   | 免費（無 AI）/ 含 AI 自 $45/月起 | 低       |\n| **自建 RAG**                           | 想完全掌控的技術型創辦人 | API 費用      | 中高     |\n| **Cloudflare AI Gateway + Workers AI** | 已用 Cloudflare 生態系   | 免費額度夠用  | 中       |\n\n### AI Chatbot 的限制：別過度期待\n\n在你投入設定之前，先了解 AI chatbot 做不到什麼：\n\n1. **它不能處理帳號相關操作**——重設密碼、退款、修改訂閱，這些需要後端操作的事情，chatbot 只能告訴用戶「請聯繫我們」\n2. **它偶爾會產生幻覺**——回答看起來很合理但完全錯誤的資訊。一定要設定 fallback 機制\n3. **它不能替代「人的溫度」**——當用戶真的很生氣或很困惑時，機器人的回覆反而會讓情緒更差\n\n我的建議是：**讓 chatbot 處理事實性問題，把情緒性問題留給你自己。**\n\n### 一個實用的 fallback 機制\n\n```text\nchatbot 回覆流程：\n\n1. 用戶提問\n2. AI 根據知識庫回答\n3. 回答結尾加上：「這有回答到你的問題嗎？」\n4. 如果用戶選「沒有」→ 自動建立工單，通知你\n5. 你每天花 15 分鐘處理這些「chatbot 回答不了」的問題\n```\n\n關鍵是第 5 步：把 chatbot 回答不了的問題收集起來，**定期加回知識庫**。這樣 chatbot 的覆蓋率會越來越高，你的人工處理量會越來越少。\n\n## 自動回覆模板：省下的 15 分鐘\n\n就算沒有 chatbot，你也可以用自動回覆模板大幅減少回信時間。\n\n### 建立模板庫\n\n用 AI 幫你建立一組回覆模板：\n\n```text\n我是一個 Solo Builder，產品是 [描述]。\n以下是我最常收到的 10 類客服問題。\n請為每一類問題撰寫一個回覆模板：\n\n1. 密碼重設\n2. 付款失敗\n3. 功能建議\n4. Bug 回報\n5. 退款請求\n6. 帳號刪除\n7. 定價疑問\n8. 批量授權\n9. 合作邀請\n10. 一般讚美（是的，也要有模板回覆感謝）\n\n要求：\n- 語氣溫暖但專業\n- 每個模板有 [姓名] 和 [具體問題] 的佔位符\n- 結尾都要有明確的下一步行動\n```\n\n把這些模板存進你的 email client 或客服工具。下次收到類似問題，選模板、填空、送出。一封回信從 5 分鐘變成 30 秒。\n\n## 客服 vs. 社群：它們不一樣\n\n很多 Solo Builder 把「客服」和「社群」搞混了。\n\n**客服**是被動的：用戶有問題來找你，你回答。\n**社群**是主動的：用戶之間交流、互助、分享，你偶爾參與。\n\n兩者都有價值，但運作方式完全不同。\n\n|              | 客服                     | 社群                    |\n| ------------ | ------------------------ | ----------------------- |\n| **方向**     | 用戶 → 你                | 用戶 ↔ 用戶（你在旁邊） |\n| **目的**     | 解決問題                 | 建立歸屬感、收集回饋    |\n| **你的時間** | 回覆問題                 | 引導話題、營造氛圍      |\n| **可自動化** | 高（FAQ、chatbot、模板） | 低（需要人味）          |\n| **優先級**   | 必須做                   | 看規模，可以晚一點做    |\n\n### 什麼時候需要社群？\n\n老實說：**大多數 Solo Builder 在早期不需要社群。**\n\n社群經營是一件很花時間的事。如果你的用戶只有幾百人，一個公開的 feedback 表單 + email 就夠了。（怎麼把這些回饋系統化變成產品決策，見[第 9 章：用戶回饋循環](/blog/ai-solo-builder-user-feedback/)）\n\n當以下信號出現時，再考慮建社群：\n\n- 用戶開始在你不知道的地方討論你的產品（Reddit、Twitter）\n- 用戶之間開始互相解答問題\n- 你收到「有沒有交流的地方」的詢問\n- 你的產品有「進階用法」值得分享\n\n### 選擇社群平台\n\n如果你決定要建社群，平台選擇很重要：\n\n| 平台                   | 適合                     | 不適合           | Solo Builder 友善度 |\n| ---------------------- | ------------------------ | ---------------- | ------------------- |\n| **GitHub Discussions** | 開源專案、開發者社群     | 非技術用戶       | ⭐⭐⭐⭐⭐          |\n| **Discord**            | 遊戲、加密貨幣、技術社群 | 年齡層偏高的用戶 | ⭐⭐⭐              |\n| **Telegram**           | 亞洲市場、即時交流       | 需要結構化討論   | ⭐⭐⭐⭐            |\n| **LINE 社群**          | 台灣市場、非技術用戶     | 國際化產品       | ⭐⭐⭐              |\n\n我的建議：**開發者產品用 GitHub Discussions，其他用 Telegram 或 Discord。**\n\n原因是：GitHub Discussions 的對話是公開、可搜尋、有結構的。用戶的問題會自動變成你的 FAQ 素材。而且 GitHub Discussions 幾乎不需要管理——社群成員會自己維護討論品質。\n\n## 什麼時候親自回覆 vs. 讓自動化處理\n\n這是 Solo Builder 最需要判斷的地方。\n\n### 必須親自回覆\n\n- **付費用戶的退款或不滿**：這些互動決定了用戶是否留下來，不能假手機器\n- **產品方向的深度回饋**：當用戶花時間寫了一大段建議，他值得一個有溫度的回應\n- **第一批用戶**：前 50 個用戶的每一個問題都值得你親自看，因為他們代表的是你最核心的使用場景\n- **情緒化的訊息**：用戶在生氣的時候，chatbot 只會讓情況更糟\n\n### 可以自動化\n\n- 功能確認（「你們支不支援 XXX？」）\n- 操作步驟（「怎麼做 XXX？」）\n- 已知問題的解決方案\n- 一般性的感謝回覆\n- 合作邀請的初步篩選\n\n### 黃金比例\n\n以我的經驗，一個運作良好的系統大概是：\n\n- **70%** 的問題被 FAQ + chatbot 解決，用戶自己找到答案\n- **20%** 的問題用模板回覆，你花 30 秒調整後送出\n- **10%** 的問題需要你認真寫一封回信\n\n如果你的比例是反過來的——70% 需要手動回覆——那代表你的文件不夠好，該回去補了。\n\n## 管理期望：一個人的 SLA\n\nSolo Builder 最怕的不是「回覆太慢」，而是「用戶以為你應該秒回」。\n\n解法很簡單：**在所有會接觸用戶的地方，寫清楚你的回覆時間。**\n\n在你的網站 footer、聯絡頁面、自動回覆信件裡加上：\n\n> 客服回覆時間：工作日 24 小時內回覆。週末和假日可能會延遲。\n> 緊急問題（無法登入、付款失敗）優先處理。\n\n這不是敷衍。這是**尊重用戶的時間，也尊重你自己的時間**。\n\n當用戶知道預期的等待時間，他們的焦慮會大幅降低。「24 小時內回覆」聽起來不快，但如果你每次都在 12 小時內回覆，用戶的感受反而是「超出預期」。\n\n反過來，如果你什麼都不說，用戶會預設你「應該馬上回」，然後在兩小時後開始焦慮、四小時後開始生氣。\n\n## 時間預算：每天 30 分鐘\n\n最後，最重要的事：**給客服一個時間上限。**\n\n我的建議是每天 30 分鐘，分兩次：\n\n| 時段           | 做什麼                                   | 時間    |\n| -------------- | ---------------------------------------- | ------- |\n| 早上（上班前） | 掃一眼有沒有緊急問題，處理付費用戶的問題 | 10 分鐘 |\n| 晚上（下班後） | 處理其他問題、更新 FAQ、調整 chatbot     | 20 分鐘 |\n\n**不要一直開著 email 和通知。** 批次處理永遠比即時回覆有效率。\n\n如果某一天的客服問題超過 30 分鐘能處理的量，代表兩件事：\n\n1. 你的產品可能出了 bug（大量用戶同時遇到同一個問題）\n2. 你的文件不夠完善（同一個問題被反覆問）\n\n兩者都不是靠「花更多時間回覆」能解決的。解法是修 bug 或補文件。\n\n## 本章重點回顧\n\n- 🛡️ 第一道防線是自助式文件，不是 chatbot——用 AI 從客服紀錄自動產生 FAQ\n- 🤖 AI chatbot 適合處理事實性問題，情緒性問題留給你自己回覆\n- 📋 建立回覆模板庫，讓一封回信從 5 分鐘變成 30 秒\n- 🏘️ 客服和社群是兩件不同的事——早期先把客服做好，社群可以晚一點\n- ⏰ 每天 30 分鐘是客服時間上限——超過代表文件不夠好或產品有 bug\n- 📢 在所有地方寫清楚回覆時間，管理用戶期望比加快回覆更重要\n\n## 下一步\n\n客服系統搞定了，但有一件事比客服更讓 Solo Builder 焦慮：**產品掛了怎麼辦？**\n\n你不可能 24 小時盯著 server。但你的用戶期待產品 24 小時都能用。\n\n下一章，我們來建立一套最小化但有效的監控和維運系統——讓你安心睡覺，產品出問題時自己會修自己。\n\n👉 [第 11 章：監控與維運——睡覺時產品也在跑](/blog/ai-solo-builder-monitoring-ops)",
      "summary": "用戶開始用你的產品了，問題也跟著來。這篇教 Solo Builder 用 AI chatbot、自動回覆模板和 AI 生成 FAQ 把客服時間壓到每天 30 分鐘，並釐清「客服」與「社群」的差別：什麼時候該建社群、選哪個平台、哪些訊息必須親自回、哪些交給自動化，在不犧牲用戶體驗的前提下一個人也撐得住。",
      "image": "https://bobochen.dev/_astro/cover.DSYNxWMz.webp",
      "date_published": "2026-04-12T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "客服",
        "社群經營",
        "AI Chatbot",
        "FAQ"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-23-remote-work-real-reason/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-23-remote-work-real-reason/",
      "title": "遠距工作的真正理由",
      "content_text": "有了小孩後，我找了一份遠距工作。不是因為追求自由，而是因為我要每天接送小孩上下學。每一次校外教學、每一場運動會、每一個家長日，我都到場。從「被迫缺席的童年」到「全勤的爸爸」。",
      "content_html": "## 不是為了自由\n\n很多人聽到「遠距工作」，第一反應是：好爽、好自由、可以在咖啡廳上班、可以邊旅行邊工作。\n\n對我來說都不是。\n\n我選擇遠距工作只有一個理由：**我要每天接送小孩。**\n\n早上七點半，送榕到學校。下午四點，在校門口等她出來。這中間的時間，就是我的工作時間。\n\n聽起來很簡單，但這意味著我不能找一份朝九晚六、需要每天坐在辦公室裡的工作。在台灣的職場文化裡，「下午四點要去接小孩」這件事，在大部分公司是不被理解的。\n\n所以我找遠距的。薪水可能少一點(相較手上其他 offer)、職稱可能低一點、升遷路徑可能模糊一點。但我可以在家工作，然後在四點鐘出現在校門口。\n\n這個交換，對我來說值得。\n\n## 之前的日子\n\n在找到遠距工作之前，有一段時間我是通勤的。\n\n每天一大早，推著娃娃車，從家裡出發到托嬰中心。趕最早的七點半入托時間，把小孩交給老師之後，再趕去上班。\n\n下班要趕在六點托嬰中心關門前去接。如果加班就完蛋了——遲到要付延托費是小事，你看到全班小朋友都被接走了只剩你的小孩一個人坐在那裡等，那個畫面會讓你內疚到不行。\n\n小朋友待在托嬰中心的時間，比我待在辦公室的時間還長。\n\n這件事讓我很不舒服。我把小孩生出來，結果他一天之中最長的清醒時間是跟別人在一起，不是跟我。\n\n後來改成遠距工作，這個問題就解決了。\n\n## 全勤\n\n找到遠距工作之後，我開始做一件事：出席。\n\n每一次學校的校外教學——不管是去動物園、去博物館、還是去公園——我都請假去。有時候是全天的活動，有時候只是半天。不管多長，我都到。\n\n每一場運動會，我在場。\n每一次成果發表，我在場。\n每一個家長日，我在場。\n每一次需要家長志工的活動，我報名。\n\n不是為了當「模範爸爸」。是因為我太清楚「爸爸不在場」是什麼感覺。\n\n小學的時候，學校辦家長日，別的同學的爸媽都來了。我的沒有。爸在外面不知道做什麼，媽在工廠上班請不了假。老師在台上講話，我的座位旁邊是空的。\n\n那種空，不是物理上的空。是一種「你不夠重要到讓人為你出現」的感覺。\n\n小孩不會用這種方式去分析。他只會覺得：別人的爸爸媽媽都來了，我的沒有。然後那個「我的沒有」會在心裡留下一個小小的洞。\n\n我不要榕和辰有那個洞。\n\n## 「你爸爸好像每次都會來欸」\n\n有一次榕回家跟我說了一件事。\n\n她的同學跟她說：「你爸爸好像每次都會來欸。」\n\n榕講這件事的時候，語氣很平淡，就像在說「今天午餐吃什麼」一樣。\n\n因為對她來說，爸爸出現是理所當然的事。她沒有經歷過「爸爸不在場」，所以她不知道那有什麼特別的。\n\n但我聽到這句話的時候，心裡停了很久很久。\n\n「你爸爸好像每次都會來。」\n\n這句話在我的童年裡從來不存在。沒有同學會這樣說我爸。因為我爸從來不會出現在學校。\n\n而現在，我的女兒的同學會這樣說。\n\n這代表我做到了。那個小時候的洞，我在下一代身上補起來了。\n\n不是用錢補的。是用「在」補的。\n\n## 代價\n\n選擇遠距工作是有代價的。\n\n在台灣，遠距的職缺選擇比進辦公室的少。很多好的機會需要 on-site，只好放棄了那些機會。\n\n職涯發展也比較慢。不在辦公室，很多非正式的交流和機會你接觸不到。升遷的時候，「在場」的人天然就比「不在場」的人有優勢。\n\n還有身體。遠距工作讓我幾乎不用移動。早上送完小孩回到家，就坐到電腦前；下午四點出門接小孩，回來再坐下。一整天走的路，可能還不到通勤時代從家裡走到車站的距離。以前至少還要走進捷運站、走進辦公室，現在連這些都省下來了。幾年下來，我的體重不增反減——不是運動變健康的那種瘦，是久坐、隨便吃、體力一年比一年差的那種。我把時間給了小孩，卻沒留一點給自己的身體。\n\n還有一個我一開始沒想到的代價：當家人知道你「時間比較彈性」，你就變成那個隨時可以被拜託的人。臨時要送個東西、白天要在家等水電工、誰要跑一趟掛號——「反正你在家嘛」。在家工作不等於有空，但在別人眼裡，這兩件事很難分開。久了你會發現，你的彈性不是只留給小孩的，它被切成很多小塊，分給了其他人。\n\n有時候也會懷疑自己的選擇。看到前同事升了主管、跳了更好的公司、薪水翻了一倍——你會想：如果我也……\n\n但那個念頭通常在接到榕的時候就消失了。\n\n她從教室跑出來，看到我在校門口，笑著衝過來：「爸爸！」\n\n那個畫面值多少錢？\n\n我算不出來。但我知道它比任何職稱和薪水都有價值。",
      "summary": "有了小孩後，我找了一份遠距工作。不是因為追求自由，而是因為我要每天接送小孩上下學。每一次校外教學、每一場運動會、每一個家長日，我都到場。從「被迫缺席的童年」到「全勤的爸爸」。",
      "image": "https://bobochen.dev/_astro/cover.BWmFIhkv.webp",
      "date_published": "2026-04-12T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "育兒",
        "遠距工作"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-22-no-kindergarten/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-22-no-kindergarten/",
      "title": "我沒有念幼兒園",
      "content_text": "我跟榕和辰說，爸爸小時候家裡沒有錢，沒有念幼兒園。大學畢業後我選擇不去園區工作，留在台北陪媽媽。那是我第一次主動選擇「陪伴」而不是「收入」。這個選擇，定義了我接下來的人生。",
      "content_html": "## 爸爸沒有念幼兒園\n\n有一天晚上睡前，榕問我小時候的事。\n\n小孩就是這樣，他們會在你最放鬆的時候丟出最難回答的問題。\n\n「爸爸你小時候的幼兒園在哪裡？」\n\n「爸爸沒有念幼兒園哦。」\n\n「為什麼？」\n\n「因為家裡沒有錢。」\n\n榕的表情很困惑。在她的世界裡，幼兒園是所有小朋友都會去的地方。就像學校、就像公園、就像超市——它就是生活的一部分，怎麼會有人「沒有去」？\n\n辰還太小，聽不太懂。但他跟著姊姊一起看著我，等我繼續說。\n\n我沒有把故事講得很沉重。只是說：爸爸小時候家裡比較辛苦，有些東西沒辦法有。但後來長大了、努力工作了，現在你們可以去幼兒園、可以去學校、可以做很多爸爸小時候做不到的事。\n\n榕點了點頭，好像懂了又好像沒完全懂。然後她翻了個身，過了大概三十秒就睡著了。\n\n小孩的世界就是這樣。大人覺得天崩地裂的事，在他們的腦袋裡停留三十秒就過去了。\n\n但在我的腦袋裡，這段對話停了很久。\n\n## 我的學前記憶\n\n因為沒有念幼兒園，我的學前記憶跟大部分人很不一樣。\n\n沒有教室、沒有老師、沒有同學、沒有那種「小朋友排隊唱歌」的畫面。\n\n我的學前生活是：在家裡看媽做家事、跟隔壁鄰居的小孩在巷子裡跑、偶爾跟爸坐計程車出去兜風。\n\n不能說不快樂。巷子裡的那些遊戲——追逐、躲貓貓、用石頭在地上畫東西——都是快樂的。\n\n但有些東西確實是缺失的。比如「跟同齡小孩在結構化的環境裡互動」的經驗、比如「有一個大人（老師）是專門照顧你的」的安全感、比如「學習如何在團體裡相處」的社會化過程。\n\n這些東西我後來都有學到，只是學得比較晚。小學的時候要花更多時間適應團體生活，因為別的小孩已經在幼兒園練習了三年。\n\n## 不去園區\n\n大學畢業的時候，很多同學的目標是竹科。\n\n新竹科學園區在那個年代是工程師的第一志願。薪水好、福利好、學長姐都在那邊。面試拿到 offer 不算太難，去了之後只要認真做，升遷和加薪都有明確的路徑。\n\n我的技術背景去園區不是問題。但我沒有去。\n\n原因很簡單也很重：如果我去了新竹，台北就只剩媽一個人了。\n\n爸那時候已經不住家裡。哥的狀況你們在前面幾篇看過了——靠不住。如果我也離開台北，媽身邊就真的沒有人了。\n\n新竹到台北，搭高鐵一個小時。聽起來不遠。但當媽半夜身體不舒服打電話來的時候、當家裡水管爆了需要人處理的時候、當她去醫院需要人陪的時候——那一個小時就是一個永遠來不及的距離。\n\n所以我選擇留在台北，找一份薪水沒那麼高但離家近的工作。\n\n當時沒有人覺得這是什麼了不起的決定。包括我自己。我只是覺得：這是應該的。媽養了我二十幾年，現在她需要有人在附近，我就留下來。\n\n但回頭看，那是我人生中第一次主動選擇「陪伴」而不是「收入」。\n\n而這個選擇，成了後來所有選擇的原型。\n\n## 選擇的連鎖\n\n留在台北 → 離媽近，可以照顧她。\n\n有了小孩 → 找遠距工作，可以每天陪小孩。\n\n每天接送 → 參加每一場校外教學、每一次學校活動。\n\n這些選擇不是一開始就規劃好的。它們是一個接一個長出來的，每一個都是基於同一個核心信念：\n\n**人在，比什麼都重要。**\n\n小時候爸不在、媽忙著工作。我知道「沒有人在身邊」是什麼感覺。\n\n所以長大之後，我把自己放在「在」的位置上。不一定要做什麼了不起的事，只要在。\n\n在媽需要的時候在。在榕上台表演的時候在。在辰半夜發燒的時候在。\n\n「在」這件事，不需要很多錢，但需要你做出一些犧牲——薪水、職稱、住在哪裡、怎麼安排時間。\n\n我願意做這些犧牲。因為我太清楚「不在」的代價了。",
      "summary": "我跟榕和辰說，爸爸小時候家裡沒有錢，沒有念幼兒園。大學畢業後我選擇不去園區工作，留在台北陪媽媽。那是我第一次主動選擇「陪伴」而不是「收入」。這個選擇，定義了我接下來的人生。",
      "image": "https://bobochen.dev/_astro/cover.Bc-Mxk6V.webp",
      "date_published": "2026-04-11T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭",
        "育兒"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-prompt-caching/",
      "url": "https://bobochen.dev/blog/claude-api-guide-prompt-caching/",
      "title": "Prompt Caching：降低 90% 重複成本的技術",
      "content_text": "為什麼 prompt caching 是最重要的成本優化技術；cache_control 用法；快取有效期與定價；適合 vs 不適合快取的內容；Python + TypeScript 實作；快取命中率監控；RAG 系統省錢案例。",
      "content_html": "如果你已經開始用 Claude API 做應用，帳單大概已經讓你有點心痛了。\n\n不用擔心，我也走過這段路。在我把 prompt caching 導入生產環境之前，有個 RAG 應用每個月的 API 費用大約是 $800。導入之後，降到了 $180。這不是神話，是 prompt caching 的正常效果。\n\n這一章我要把 prompt caching 的原理、用法和我的實戰心得全部告訴你。\n\n## 為什麼這是最重要的成本優化技術？\n\n先說結論，再解釋為什麼。\n\n在所有 Claude API 的成本優化技術裡，prompt caching 是**投資報酬率最高**的一個。原因很簡單：\n\n大多數 AI 應用都有一個結構特徵——**重複的前綴，變化的後綴**。\n\n你的 system prompt 每次請求都一樣。你用來做 RAG 的文件上下文，在相同的查詢 session 裡幾乎不變。你的 few-shot examples，每次都是同樣那幾組。你的工具定義（tool definitions），幾乎從不改變。\n\n這些「重複的前綴」在每次 API 請求時都要重新計算，這就是浪費。Prompt caching 解決的就是這個問題。\n\n## 快取的運作原理\n\nClaude 的 prompt caching 基於一個很直觀的概念：**相同的輸入前綴只需要計算一次**。\n\n當你標記一段內容為可快取，Claude 的後端會：\n\n1. 計算這段內容的哈希值\n2. 第一次請求時，計算完整的 KV cache 並存起來（這次叫「cache write」）\n3. 後續相同前綴的請求，直接讀取 cache 跳過計算（這次叫「cache read」）\n\n重點：快取是基於**完整的前綴匹配**。意思是，如果你有三段標記為快取的內容，Claude 需要找到這三段全部匹配的快取才能命中。你改了第一段，第二段和第三段的快取就都失效了。\n\n這個特性很重要，後面設計快取架構的時候我們會用到。\n\n## Cache Control 的用法\n\n在 API 層面，你用 `cache_control` 字段來標記哪些內容要快取：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\nmessage = client.messages.create(\n    model=\"claude-opus-4-5\",\n    max_tokens=1024,\n    system=[\n        {\n            \"type\": \"text\",\n            \"text\": \"你是一位專業的技術文件助手，專門幫助工程師理解複雜的 API 文件。\",\n        },\n        {\n            \"type\": \"text\",\n            \"text\": \"\"\"以下是完整的 API 參考文件（共 50,000 字）：\n\n            [在這裡放你的長文件內容]\n            \"\"\",\n            \"cache_control\": {\"type\": \"ephemeral\"}  # 標記這段為可快取\n        }\n    ],\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"請解釋 /api/v1/orders 端點的 pagination 參數怎麼用？\"\n        }\n    ],\n)\n```\n\n`cache_control` 的值目前只有一個選項：`{\"type\": \"ephemeral\"}`。\n\n「ephemeral」聽起來像「短暫的」，但快取的存活時間其實不短——**預設是 5 分鐘**，可以延長到 1 小時（透過特定設定，稍後說明）。\n\n## 快取可以放在哪裡？\n\n`cache_control` 可以加在三個地方：\n\n**1. System prompt 區塊**\n\n```python\nsystem=[\n    {\"type\": \"text\", \"text\": \"短的指令，不快取\"},\n    {\n        \"type\": \"text\",\n        \"text\": \"超長的背景知識文件...\",\n        \"cache_control\": {\"type\": \"ephemeral\"}\n    }\n]\n```\n\n**2. Messages 裡的 user 內容**\n\n```python\nmessages=[\n    {\n        \"role\": \"user\",\n        \"content\": [\n            {\n                \"type\": \"text\",\n                \"text\": \"這是一篇很長的合約文件，請幫我分析...\\n[合約全文]\",\n                \"cache_control\": {\"type\": \"ephemeral\"}\n            },\n            {\n                \"type\": \"text\",\n                \"text\": \"第一個問題：違約金的條款在第幾頁？\"  # 動態問題，不快取\n            }\n        ]\n    }\n]\n```\n\n**3. Tool definitions（工具定義）**\n\n```python\ntools=[\n    {\n        \"name\": \"search_database\",\n        \"description\": \"搜尋資料庫...\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {...}\n        },\n        \"cache_control\": {\"type\": \"ephemeral\"}  # 工具定義通常很長，適合快取\n    }\n]\n```\n\n## 快取定價：寫入貴，讀取便宜\n\n這是很多人看漏的細節，必須搞清楚：\n\n| 操作                    | 相對於標準輸入 token 的費率 |\n| ----------------------- | --------------------------- |\n| 標準輸入（無快取）      | 1x                          |\n| Cache write（建立快取） | 1.25x（貴 25%）             |\n| Cache read（讀取快取）  | 0.1x（便宜 90%）            |\n\n以 Claude Opus 4.5 為例（2026 年的定價，$15/MTok 輸入）：\n\n- 標準輸入：$15/百萬 tokens\n- Cache write：$18.75/百萬 tokens\n- Cache read：$1.50/百萬 tokens\n\n**Cache write 比標準輸入貴 25%**，這意味著如果你的快取每次都沒有命中（每次都是 write 不是 read），你反而比不用快取還貴。\n\n所以 prompt caching 的核心策略是：**最大化快取命中率**。\n\n## 什麼內容適合快取？\n\n根據快取的特性，適合快取的內容是：\n\n**高度適合：**\n\n- **System prompt**：幾乎每次請求都相同\n- **長文件上下文**（RAG 的文件、合約、手冊）：同一個 session 內不變\n- **Few-shot examples**：固定的示例集\n- **Tool definitions**：工具定義幾乎不變\n\n**中度適合：**\n\n- **用戶的會話歷史**：在多輪對話中，前幾輪的對話可以快取\n\n**不適合：**\n\n- **動態的 user input**：每次都不同，快取命中率趨近於零\n- **包含時間戳或隨機 ID 的內容**：這些讓每次請求的前綴都不同\n\n## 設計高快取命中率的 Prompt 架構\n\n這是最關鍵的部分，也是我花了最多時間摸索的地方。\n\n**核心原則：把穩定的內容放前面，把變化的內容放後面。**\n\n一個典型的 RAG 應用的 prompt 結構應該是這樣：\n\n```\n[System Prompt - 快取] ← 穩定\n[文件 1 - 快取] ← 相對穩定（同一 session）\n[文件 2 - 快取] ← 相對穩定\n[對話歷史 - 可考慮快取] ← 逐漸累積\n[當前用戶問題 - 不快取] ← 每次都變\n```\n\n如果你把用戶問題放在文件之前，快取就永遠不會命中。\n\n```python\ndef build_rag_request(\n    system_prompt: str,\n    documents: list[str],\n    conversation_history: list[dict],\n    user_question: str\n) -> dict:\n    \"\"\"建立具有最優快取結構的 RAG 請求\"\"\"\n\n    # System prompt 最穩定，標記為快取\n    system = [\n        {\n            \"type\": \"text\",\n            \"text\": system_prompt,\n            \"cache_control\": {\"type\": \"ephemeral\"}\n        }\n    ]\n\n    # 建立 messages 結構\n    messages = []\n\n    # 把文件放進第一個 user turn，標記為快取\n    if documents:\n        doc_content = \"\\n\\n---\\n\\n\".join(\n            f\"[文件 {i+1}]\\n{doc}\" for i, doc in enumerate(documents)\n        )\n        messages.append({\n            \"role\": \"user\",\n            \"content\": [\n                {\n                    \"type\": \"text\",\n                    \"text\": f\"以下是參考文件：\\n\\n{doc_content}\",\n                    \"cache_control\": {\"type\": \"ephemeral\"}\n                },\n                {\n                    \"type\": \"text\",\n                    \"text\": \"好的，我已讀取這些文件。請問有什麼問題？\"  # 假設這是第一個問題的佔位\n                }\n            ]\n        })\n        messages.append({\n            \"role\": \"assistant\",\n            \"content\": \"好的，我已閱讀完這些參考文件，隨時可以回答你的問題。\"\n        })\n\n    # 加入對話歷史\n    messages.extend(conversation_history)\n\n    # 最後加入當前問題（不快取，每次都變）\n    messages.append({\n        \"role\": \"user\",\n        \"content\": user_question\n    })\n\n    return {\n        \"system\": system,\n        \"messages\": messages\n    }\n```\n\n## Python 完整實作\n\n一個真實的應用案例：文件問答系統，帶快取監控：\n\n```python\nimport anthropic\nfrom dataclasses import dataclass\n\n@dataclass\nclass CacheStats:\n    cache_creation_tokens: int = 0\n    cache_read_tokens: int = 0\n    input_tokens: int = 0\n    output_tokens: int = 0\n\n    @property\n    def cache_hit_rate(self) -> float:\n        total_cacheable = self.cache_creation_tokens + self.cache_read_tokens\n        if total_cacheable == 0:\n            return 0.0\n        return self.cache_read_tokens / total_cacheable\n\n    @property\n    def estimated_savings_usd(self) -> float:\n        # 假設 Claude Opus 4.5 定價：$15/MTok 輸入\n        price_per_token = 15 / 1_000_000\n        # 如果沒有快取，這些 cache_read 的 token 就是標準費率\n        saved = self.cache_read_tokens * price_per_token * 0.9  # 節省 90%\n        return saved\n\n\nclass DocumentQASystem:\n    def __init__(self, documents: list[str], system_prompt: str):\n        self.client = anthropic.Anthropic()\n        self.documents = documents\n        self.system_prompt = system_prompt\n        self.conversation_history = []\n        self.stats = CacheStats()\n\n    def _build_system(self) -> list[dict]:\n        return [\n            {\n                \"type\": \"text\",\n                \"text\": self.system_prompt,\n                \"cache_control\": {\"type\": \"ephemeral\"}\n            }\n        ]\n\n    def _build_document_block(self) -> dict:\n        doc_text = \"\\n\\n---\\n\\n\".join(\n            f\"[文件 {i+1}]\\n{doc}\" for i, doc in enumerate(self.documents)\n        )\n        return {\n            \"type\": \"text\",\n            \"text\": f\"參考文件庫：\\n\\n{doc_text}\",\n            \"cache_control\": {\"type\": \"ephemeral\"}\n        }\n\n    def ask(self, question: str) -> str:\n        # 建立 messages 列表\n        messages = []\n\n        # 第一輪加入文件（帶快取標記）\n        if not self.conversation_history:\n            messages.append({\n                \"role\": \"user\",\n                \"content\": [\n                    self._build_document_block(),\n                    {\"type\": \"text\", \"text\": question}\n                ]\n            })\n        else:\n            # 已有對話歷史：文件放在最前面的 user 訊息\n            # conversation_history 已經包含了第一輪（有文件），直接繼續\n            messages = self.conversation_history.copy()\n            messages.append({\n                \"role\": \"user\",\n                \"content\": question\n            })\n\n        response = self.client.messages.create(\n            model=\"claude-opus-4-5\",\n            max_tokens=1024,\n            system=self._build_system(),\n            messages=messages,\n        )\n\n        # 更新統計數據\n        usage = response.usage\n        self.stats.cache_creation_tokens += getattr(usage, 'cache_creation_input_tokens', 0)\n        self.stats.cache_read_tokens += getattr(usage, 'cache_read_input_tokens', 0)\n        self.stats.input_tokens += usage.input_tokens\n        self.stats.output_tokens += usage.output_tokens\n\n        answer = response.content[0].text\n\n        # 更新對話歷史\n        if not self.conversation_history:\n            self.conversation_history.append({\n                \"role\": \"user\",\n                \"content\": [\n                    self._build_document_block(),\n                    {\"type\": \"text\", \"text\": question}\n                ]\n            })\n        else:\n            self.conversation_history.append({\n                \"role\": \"user\",\n                \"content\": question\n            })\n        self.conversation_history.append({\n            \"role\": \"assistant\",\n            \"content\": answer\n        })\n\n        return answer\n\n    def print_stats(self):\n        print(f\"快取命中率: {self.stats.cache_hit_rate:.1%}\")\n        print(f\"快取寫入 tokens: {self.stats.cache_creation_tokens:,}\")\n        print(f\"快取讀取 tokens: {self.stats.cache_read_tokens:,}\")\n        print(f\"預估節省費用: ${self.stats.estimated_savings_usd:.4f}\")\n\n\n# 使用範例\nif __name__ == \"__main__\":\n    # 模擬一個有大量文件的 RAG 系統\n    documents = [\n        \"產品規格文件 v2.3...\\n[5000 字的文件內容]\",\n        \"API 參考手冊...\\n[8000 字的文件內容]\",\n        \"常見問題集...\\n[3000 字的文件內容]\",\n    ]\n\n    system_prompt = \"\"\"你是一位專業的技術支援人員，熟悉公司的所有產品和 API。\n請根據提供的文件回答用戶問題。\n回答要準確、簡潔，必要時引用文件的具體內容。\"\"\"\n\n    qa_system = DocumentQASystem(documents, system_prompt)\n\n    # 第一次問：cache write\n    answer1 = qa_system.ask(\"API 的 rate limit 是多少？\")\n    print(f\"問題 1: {answer1}\\n\")\n\n    # 第二次問：cache read（節省 90% 費用）\n    answer2 = qa_system.ask(\"如何處理 429 Too Many Requests 錯誤？\")\n    print(f\"問題 2: {answer2}\\n\")\n\n    # 第三次問：cache read\n    answer3 = qa_system.ask(\"SDK 支援哪些程式語言？\")\n    print(f\"問題 3: {answer3}\\n\")\n\n    qa_system.print_stats()\n```\n\n## TypeScript 實作\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n\ninterface ConversationTurn {\n  role: 'user' | 'assistant';\n  content: string | Anthropic.ContentBlockParam[];\n}\n\nasync function buildRagRequest(\n  systemPrompt: string,\n  documentContext: string,\n  conversationHistory: ConversationTurn[],\n  userQuestion: string\n): Promise<Anthropic.Messages.MessageCreateParamsNonStreaming> {\n  const system: Anthropic.Messages.TextBlockParam[] = [\n    {\n      type: 'text',\n      text: systemPrompt,\n      cache_control: { type: 'ephemeral' },\n    },\n  ];\n\n  const messages: ConversationTurn[] = [];\n\n  if (conversationHistory.length === 0) {\n    // 第一次請求：文件 + 問題合在第一個 user turn\n    messages.push({\n      role: 'user',\n      content: [\n        {\n          type: 'text',\n          text: documentContext,\n          cache_control: { type: 'ephemeral' },\n        } as Anthropic.Messages.TextBlockParam,\n        {\n          type: 'text',\n          text: userQuestion,\n        },\n      ],\n    });\n  } else {\n    // 後續請求：帶入歷史，新問題放最後\n    messages.push(...conversationHistory);\n    messages.push({\n      role: 'user',\n      content: userQuestion,\n    });\n  }\n\n  return {\n    model: 'claude-opus-4-5',\n    max_tokens: 1024,\n    system,\n    messages: messages as Anthropic.Messages.MessageParam[],\n  };\n}\n\nasync function main() {\n  const systemPrompt = '你是一位專業的技術支援人員...';\n  const documentContext = '參考文件庫：\\n\\n[這裡是大量文件內容]';\n\n  const history: ConversationTurn[] = [];\n\n  // 第一次請求（cache write）\n  const request1 = await buildRagRequest(\n    systemPrompt,\n    documentContext,\n    history,\n    'API 的 rate limit 是多少？'\n  );\n\n  const response1 = await client.messages.create(request1);\n  const answer1 = (response1.content[0] as Anthropic.Messages.TextBlock).text;\n\n  console.log('回答 1:', answer1);\n  console.log('Cache 統計:', {\n    cacheWrite: (response1.usage as any).cache_creation_input_tokens ?? 0,\n    cacheRead: (response1.usage as any).cache_read_input_tokens ?? 0,\n    inputTokens: response1.usage.input_tokens,\n  });\n\n  // 更新歷史\n  history.push({\n    role: 'user',\n    content: [\n      {\n        type: 'text',\n        text: documentContext,\n        cache_control: { type: 'ephemeral' },\n      } as Anthropic.Messages.TextBlockParam,\n      { type: 'text', text: 'API 的 rate limit 是多少？' },\n    ],\n  });\n  history.push({ role: 'assistant', content: answer1 });\n\n  // 第二次請求（cache read）\n  const request2 = await buildRagRequest(\n    systemPrompt,\n    documentContext,\n    history,\n    '如何處理 429 錯誤？'\n  );\n\n  const response2 = await client.messages.create(request2);\n  const answer2 = (response2.content[0] as Anthropic.Messages.TextBlock).text;\n\n  console.log('\\n回答 2:', answer2);\n  console.log('Cache 統計:', {\n    cacheWrite: (response2.usage as any).cache_creation_input_tokens ?? 0,\n    cacheRead: (response2.usage as any).cache_read_input_tokens ?? 0,\n    inputTokens: response2.usage.input_tokens,\n  });\n}\n\nmain();\n```\n\n## 快取命中率的計算與監控\n\n在 API 回應裡，`usage` 物件包含以下欄位：\n\n```python\nusage = response.usage\n\n# 標準輸入 tokens（未命中快取的部分）\ninput_tokens = usage.input_tokens\n\n# 快取寫入 tokens（這次建立快取消耗的 tokens）\ncache_creation_input_tokens = usage.cache_creation_input_tokens  # 可能為 None 或 0\n\n# 快取讀取 tokens（命中快取節省的 tokens）\ncache_read_input_tokens = usage.cache_read_input_tokens  # 可能為 None 或 0\n```\n\n快取命中率計算：\n\n```python\ndef calculate_cache_efficiency(usage) -> dict:\n    cache_write = getattr(usage, 'cache_creation_input_tokens', 0) or 0\n    cache_read = getattr(usage, 'cache_read_input_tokens', 0) or 0\n    input_tokens = usage.input_tokens\n\n    total_processed = input_tokens + cache_write + cache_read\n    hit_rate = cache_read / (cache_write + cache_read) if (cache_write + cache_read) > 0 else 0\n\n    return {\n        \"hit_rate\": hit_rate,\n        \"cache_write_tokens\": cache_write,\n        \"cache_read_tokens\": cache_read,\n        \"standard_input_tokens\": input_tokens,\n        \"total_tokens_processed\": total_processed,\n    }\n```\n\n理想的快取命中率因應用而異，但我的基準是：\n\n- 文件問答系統：>70%\n- 多輪對話：>50%（第 2 輪以後應該都命中）\n- 批次處理：>90%（相同 system prompt，大量不同問題）\n\n## 真實案例：RAG 系統的省錢計算\n\n我把一個內部知識庫問答系統的費用做了計算：\n\n**不用快取（每月）：**\n\n- 每次查詢 tokens：system prompt 500 + 文件上下文 15,000 + 問題 200 = 15,700 tokens\n- 每天 1,000 次查詢 = 每月 30,000 次\n- 總輸入 tokens：30,000 × 15,700 = 471M tokens\n- 費用（Claude Opus 4.5 $15/MTok）：$7,065/月\n\n**使用快取後（每月）：**\n\n- 系統 prompt + 文件上下文 = 15,500 tokens → 快取後只在第一次請求付 1.25x\n- 後續請求：問題 200 tokens（標準費率）+ 15,500 tokens（0.1x 費率）\n- 假設每個 session 平均 5 次對話，快取存活 5 分鐘內完成\n- 有效快取命中率約 80%\n- 費用：大幅降低，約 $1,500/月\n\n**節省：約 80%**\n\n這還是保守估計。如果你的系統有很長的文件，節省比例可以更高。\n\n---\n\nPrompt caching 是我見過 ROI 最高的 Claude API 優化。設置時間大概 2-4 小時，但可以立刻看到帳單下降。\n\n下一章我們換個話題，聊聊另一種降低成本和提升吞吐量的方法：**Batch API**。如果你需要一次處理幾千份文件，Batch API 能讓你用 50% 的價格完成任務。",
      "summary": "為什麼 prompt caching 是最重要的成本優化技術；cache_control 用法；快取有效期與定價；適合 vs 不適合快取的內容；Python + TypeScript 實作；快取命中率監控；RAG 系統省錢案例。",
      "image": "https://bobochen.dev/_astro/cover.CNcPr5bG.webp",
      "date_published": "2026-04-10T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Prompt Caching",
        "成本優化",
        "cache_control"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-12-pr99-family/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-12-pr99-family/",
      "title": "PR99 的家庭關係",
      "content_text": "朋友說：「你們其實已經擁有 PR99 的家庭關係了好嗎！」我愣住了。穩定的家庭關係，對有些人來說是奢侈品。而我從來不覺得那是我的優勢，直到有人這樣說。",
      "content_html": "## 有人羨慕我\n\n有一次在網路上看到一個人分享他的家庭狀況。比我慘多了——從小在寄養家庭長大，沒有見過親生父母，成年後完全沒有家人可以依靠。\n\n他在文章底下寫了一句話：「你們可以隨便說出『大不了搬回家住』這種話的人，知道自己有多幸運嗎？」\n\n然後有人在留言裡說了一句讓我停下來的話：\n\n**「你各位其實已經擁有 PR99 的家庭關係了好嗎！」**\n\nPR99。百分等級第九十九。意思是你的家庭關係品質贏過了 99% 的人。\n\n我第一反應是：怎麼可能？我的原生家庭一團亂——爸喝酒、家暴、欠債、中風。哥什麼都不做、情緒勒索。媽溝通困難、拒絕就醫。我累得要死、委屈得要命。\n\n這種家庭哪來的 PR99？\n\n## 轉換視角\n\n但我仔細想了想。\n\n他說的不是原生家庭。他說的是「你現在擁有的家庭關係」。\n\n我有太太。我們的關係穩定，會吵架但能溝通，彼此信任。\n我有兩個健康的小孩。他們快樂、安全、每天都能看到爸爸。\n我有媽。雖然溝通困難，但她活著、她在、我隨時可以回去看她。\n\n這些東西——一個穩定的伴侶、健康的小孩、活著的家人——在某些人眼裡，確實是 PR99。\n\n因為有些人沒有這些。有些人連一個可以回去的家都沒有。有些人說「搬回家住」這句話的時候，會得到的回應是「這裡沒有你的位置」。\n\n## 低收入戶的盲點\n\n這讓我想到一件事。\n\n在經濟弱勢家庭長大的人，很容易掉進一個思考陷阱：你會把所有的「好」都歸因於外在條件（錢、學歷、資源），然後把家庭關係這種「軟性資產」視為不值一提的東西。\n\n「家人感情好有什麼用？又不能當飯吃。」\n\n但事實上，穩定的家庭關係是人生中最強大的安全網之一。它不能幫你付房租，但它可以在你失業的時候讓你有地方住。它不能幫你升職，但它可以在你崩潰的時候讓你有人傾訴。\n\n而這種安全網，不是每個人都有的。\n\n我以前從來不覺得「跟家人關係好」是一種優勢。因為在我的經驗裡，家人 = 問題。家人 = 負擔。家人 = 需要我去解決的無止盡的麻煩。\n\n但轉過頭來看我自己建立的小家庭——跟太太、跟榕、跟辰——那確實是一個溫暖的、穩定的、可以回去的地方。\n\n這不是天上掉下來的。這是我用了所有從原生家庭學到的教訓，一磚一瓦搭起來的。\n\n## 兩個家庭\n\n現在的我，其實活在兩個家庭之間。\n\n**原生家庭**：媽、哥。充滿了溝通障礙、情緒勒索、永遠處理不完的問題。每次回去都像是走進一個能量黑洞，出來的時候精力值歸零。\n\n**自己的家庭**：太太、榕、辰。穩定、溫暖、可以充電的地方。不是沒有問題，但問題都是可以「一起面對」的那種，不是單方面承受的那種。\n\n三明治世代的特殊之處在於：你同時屬於這兩個家庭。你從這邊被消耗，在那邊被充電。然後週而復始。\n\n而 PR99 的意思不是「沒有問題」。是「即使有問題，你還有一個可以回去的地方」。\n\n有些人連這個都沒有。\n\n所以，是的。也許我確實擁有 PR99 的家庭關係。\n\n只是那個 PR99，不是天生的。是用三十幾年的傷疤換來的。",
      "summary": "朋友說：「你們其實已經擁有 PR99 的家庭關係了好嗎！」我愣住了。穩定的家庭關係，對有些人來說是奢侈品。而我從來不覺得那是我的優勢，直到有人這樣說。",
      "image": "https://bobochen.dev/_astro/cover.G5lAPbOc.webp",
      "date_published": "2026-04-10T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/spec-driven-development-for-agents/",
      "url": "https://bobochen.dev/blog/spec-driven-development-for-agents/",
      "title": "Spec-Driven Development：寫給 Agent 的需求文件，比寫給人的還嚴格",
      "content_text": "Agent 不會讀心術，你的 spec 越模糊，它越容易失控。分享怎麼寫 agent-ready 的 spec——從 task decomposition、acceptance criteria 到 constraint definition，附真實的 spec 範本和「寫太少 vs 寫太多」的對照實驗。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第五篇。上一篇：[Context Engineering 深度解析](/blog/context-engineering-deep-dive)\n\n## 同一個功能，兩份 Spec，天壤之別的結果\n\n同一個功能需求，我寫了兩份 spec 給 agent。一份花了 30 秒隨手打：「幫我加一個用戶通知功能。」另一份花了 10 分鐘認真寫。\n\n30 秒那份，agent 寫了 400 行 code——email notification、push notification、in-app notification 全做了，還自己加了一個 notification preference 頁面。很「完整」，但我只需要一個簡單的 in-app toast。多花了 3 小時拆掉不需要的東西。\n\n10 分鐘那份，agent 精準地做了一個 toast component + API endpoint + 測試。一次通過。\n\n**你花在寫 spec 的時間，省下的是 agent 亂做的時間。這個 ROI 大概是 1:20。**\n\n這不是偶發事件。在過去一年裡，我追蹤了自己的任務完成情況，發現一個殘酷的規律：\n\n| Spec 投入時間 | Agent 產出品質     | 人工修正時間 | 總時間       |\n| ------------- | ------------------ | ------------ | ------------ |\n| <1 分鐘       | 方向錯誤、過度設計 | 2-4 小時     | ~3 小時      |\n| 5-10 分鐘     | 基本正確、細節需調 | 15-30 分鐘   | ~30 分鐘     |\n| 15-20 分鐘    | 精準、一次通過     | <10 分鐘     | ~25 分鐘     |\n| >30 分鐘      | 不一定更好         | —            | 邊際效益遞減 |\n\n甜蜜點在 10-15 分鐘。超過 20 分鐘的 spec，邊際效益開始遞減——你可能寫了太多 agent 不需要的資訊，反而 [稀釋了重點](/blog/context-engineering-deep-dive)。\n\n## 為什麼「口頭說一下」在 Agent 時代行不通\n\n在傳統團隊裡，你可以跟同事說「幫我加一個通知功能」，然後他會：\n\n- 先看現有的 codebase 有沒有類似的東西\n- 不確定的地方來問你「你是要 email 還是 in-app？」\n- 看了 mockup 之後說「這個 toast 的位置應該在右上角嗎？」\n- 做的過程中發現問題會暫停，來找你討論\n\nAgent 不會做任何這些事。它拿到「加一個通知功能」之後，就會以最大解釋範圍去實作。它不會說「你確定要這些嗎？」它只會說「好的，我幫你做了 email + push + in-app + preference page + unsubscribe + 多語系支援」。\n\n**Agent 的預設行為是「盡可能做多」，不是「釐清需求」。**\n\n這不是 agent 的缺陷。這是 LLM 的本質——它被訓練成「有幫助的」（helpful），而「有幫助」在 training data 裡通常意味著「做完整一點」。你要反過來利用這個特性：不是教 agent 少做一點，而是在 spec 裡明確告訴它「做什麼」和「不做什麼」。\n\n我之前分享過的 失敗案例 裡有一個故事——agent 寫了 800 行白寫的 code，根源就是 spec 問題。那次我學到：**模糊的需求 + 認真的 agent = 一堆你不需要的功能。**\n\n## Spec 三要素：Goal / Constraints / Verification\n\n好的 agent spec 只需要三個部分。不多不少。\n\n### 1. Goal — 你到底要什麼\n\n不是「怎麼做」，是「最後要什麼結果」。\n\n**壞的 Goal**：\n\n```\n用 React 的 useState 和 useEffect 寫一個 toast notification component，\n用 CSS transition 做動畫...\n```\n\n這不是 Goal，這是 implementation plan。你把 agent 的手綁住了——它可能知道更好的做法，但你已經指定了每一步怎麼走。\n\n**好的 Goal**：\n\n```\n在頁面右上角顯示一個 toast notification。\n3 秒後自動消失。支援 success / error / info 三種類型。\n可以從任何 component 觸發。\n```\n\n告訴 agent 你要的**結果**，讓它選擇**做法**。它可能用 portal、可能用 store、可能用 custom event——哪種做法最適合你的 codebase，它比你更清楚（前提是它有足夠的 [context](/blog/context-engineering-deep-dive)）。\n\n### 2. Constraints — 不要做什麼\n\n這是 spec 裡最容易被忽略、但最重要的部分。\n\n**為什麼重要**：Agent 的傾向是「做多」。不告訴它不要做什麼，它就會做所有它認為「有幫助」的事情。\n\n**Constraint 範例**：\n\n```\n## Constraints\n- 只做 in-app toast，不做 email 或 push notification\n- 不需要 persistence（頁面 reload 後消失就好）\n- 不要新增任何 npm 依賴——用現有的 utility\n- 不修改任何現有 component 的 interface\n- 整個功能控制在 100 行以內\n```\n\n每一條 constraint 都可能省你 1 小時的拆除工作。尤其是「不要新增 npm 依賴」這種——agent 最喜歡引入新的 library 來解決問題，但你可能不希望為了一個 toast 多一個 dependency。\n\n### 3. Verification Criteria — 怎麼判斷做對了\n\n這是你跟 agent 之間的「合約」。做到這些就算完成，沒做到就需要修正。\n\n**壞的 Verification**：\n\n```\nToast 要能用。\n```\n\n**好的 Verification**：\n\n```\n## Verification Criteria\n1. 呼叫 showToast({ type: 'success', message: 'Saved!' }) 後，\n   右上角出現綠色 toast，3 秒後消失\n2. 呼叫 showToast({ type: 'error', message: 'Failed' }) 後，\n   右上角出現紅色 toast，3 秒後消失\n3. 連續呼叫 3 次，3 個 toast 垂直排列，各自計時消失\n4. npm run build 通過，沒有 TypeScript 錯誤\n5. 新增至少 3 個 unit test 覆蓋以上場景\n```\n\n越具體越好。Agent 可以用這些 criteria 來自我驗證——它不需要你手動檢查，它可以自己跑測試來確認是否達標。\n\n如果你搭配 [TDD](/blog/agent-output-verification-review)，verification criteria 可以直接變成 test cases，讓自動化幫你驗收。\n\n## Task Decomposition：大任務怎麼拆\n\n一個 feature 通常不應該是一個 agent task。它應該被拆成 3-5 個 agent-executable 的單元。\n\n### 拆的粒度：sweet spot\n\n| 太粗                 | 剛好                               | 太細                        |\n| -------------------- | ---------------------------------- | --------------------------- |\n| 「做一個 blog 系統」 | 「加一個 related posts component」 | 「在第 42 行加一個 import」 |\n| Agent 自己做太多決策 | Agent 有明確範圍                   | Overhead > 效益             |\n| 產出難以 review      | 產出 = 一個 reviewable PR          | 你不如自己做                |\n\n**經驗法則**：一個好的 agent task 的大小，大約等於一個 reviewable PR——改動 3-10 個檔案、100-500 行 code、有明確的功能邊界。\n\n### 拆法範例\n\n**Feature**：在部落格加搜尋功能。\n\n**Bad decomposition**（太粗）：\n\n1. 加搜尋功能\n\n**Good decomposition**：\n\n1. **建立搜尋 index**：在 build time 從所有 blog posts 產生 JSON search index\n2. **搜尋 UI component**：建立 SearchBar + SearchResults component\n3. **搜尋邏輯**：實作 fuzzy search，支援標題 + 內容 + tags\n4. **鍵盤導航**：Cmd+K 開啟搜尋、方向鍵選擇結果、Enter 導航\n5. **整合測試**：驗證 search index 產生、搜尋結果正確、keyboard navigation\n\n每個 task 都有明確的輸入和輸出。Agent 可以一個一個做，每做完一個你 review 一次。如果第 3 步的搜尋邏輯出了問題，你只需要修那一步，不影響其他的。\n\n### AWS Kiro 的 Spec-First 理念\n\nAWS 在 2025 年推出的 Kiro IDE 把 spec-driven 的理念直接建進了工具裡。在 Kiro 裡：\n\n1. 你先寫一份結構化的 spec（它叫 \"Spec Document\"）\n2. Kiro 自動把 spec 拆成 subtasks\n3. 每個 subtask 自動產生 acceptance criteria\n4. Agent 按照 subtask 順序執行，每步完成後自動跑 acceptance tests\n\n雖然我主要用 Claude Code 不用 Kiro，但它的核心理念值得學習：**把 spec 當成第一公民，而不是附帶產物**。\n\n## 對照實驗：Bad Spec vs Good Spec\n\n讓我用一個更技術的例子——「在 API 上加 rate limiting」。\n\n### Bad Spec\n\n```\n幫我在 API 上加 rate limiting。\n```\n\n**Agent 的產出**（摘要）：\n\n- 引入了 `express-rate-limit` npm package\n- 在所有 API endpoints 上加了 global rate limiter（100 req/min）\n- 加了一個 Redis-based sliding window counter\n- 加了 `X-RateLimit-Remaining` 和 `X-RateLimit-Reset` headers\n- 加了一個 `/api/rate-limit-status` endpoint\n- 寫了 migration script 建立 Redis key\n- 總共 ~350 行新 code\n\n問題：我只有一個簡單的 Astro static site，沒有 Express、沒有 Redis、那些 API 是 Cloudflare Workers serverless functions。整個 output 基本上不能用。\n\n### Good Spec\n\n```markdown\n## Task: API Rate Limiting for Cloudflare Workers\n\n### Goal\n\n在 /api/contact 和 /api/subscribe 兩個 endpoints 加上 rate limiting，\n防止單一 IP 短時間內大量請求。\n\n### Context\n\n- 這是 Astro 5 static site，部署在 Cloudflare Workers\n- API endpoints 是 Cloudflare Workers serverless functions\n- 沒有 Redis 或任何 external state store\n- 目前流量很小（~100 DAU）\n\n### Constraints\n\n- 使用 Cloudflare Workers 的 KV namespace 做計數器（已建立：RATE_LIMIT_KV）\n- 不要引入任何 npm dependency\n- 只對 POST requests 做 rate limiting\n- 限制：每 IP 每分鐘最多 5 次 POST\n- 超過限制回 429 Too Many Requests\n\n### Files to modify\n\n- src/pages/api/contact.ts\n- src/pages/api/subscribe.ts\n- 可以新增一個 src/lib/rate-limit.ts utility\n\n### Verification\n\n1. 從同一 IP 連續 POST 6 次，第 6 次拿到 429\n2. 等 60 秒後，再 POST 應該成功\n3. 不同 IP 的 rate limit 是獨立的\n4. GET requests 不受 rate limit 影響\n5. npm run build 通過\n```\n\n**Agent 的產出**（摘要）：\n\n- 新增 `src/lib/rate-limit.ts`（~40 行），用 Cloudflare KV 做計數器\n- 修改兩個 endpoint，import rate limiter 並加在 POST handler 前\n- 零 npm dependency\n- 總共 ~80 行新 code\n- 第一次 review 就通過\n\n差異一目了然。不是因為 agent 變聰明了，是因為它拿到了正確的 context 和明確的邊界。\n\n## 我的 Spec Template（直接拿去用）\n\n```markdown\n## Task: [一句話描述]\n\n### Goal\n\n[3-5 句描述最終結果，不描述實作方式]\n\n### Context\n\n[Agent 需要知道的背景：tech stack、部署環境、相關系統、目前狀態]\n\n### Constraints\n\n- [不要做什麼]\n- [技術限制]\n- [不碰哪些檔案]\n- [行數 / 複雜度 / dependency 上限]\n\n### Files to modify\n\n- [具體的檔案路徑]\n- [可以新增什麼檔案]\n\n### Verification Criteria\n\n1. [具體的測試條件 1]\n2. [具體的測試條件 2]\n3. [build / lint / type check 通過]\n\n### Out of Scope\n\n- [明確列出不屬於這個任務的東西]\n- [避免 agent 自己 scope creep]\n```\n\n**重點**：`Out of Scope` 是最被低估的區塊。它跟 Constraints 不同——Constraints 是「做的時候不要這樣做」，Out of Scope 是「這些事根本不要做」。\n\n例如你在做搜尋功能，Out of Scope 可能包括：\n\n- 不做 search analytics（之後另外做）\n- 不做 search suggestions / autocomplete\n- 不做搜尋結果的 pagination\n\n這些都是 agent 非常可能「順便」幫你做的東西。提前說不要，省事。\n\n## 什麼時候不需要 Spec\n\n不是每個任務都需要完整的 spec。回到 [Post 1](/blog/agentic-engineering-what-is-it) 提到的計畫粒度矩陣：\n\n| 任務類型 | Spec 需求                    | 範例                                                    |\n| -------- | ---------------------------- | ------------------------------------------------------- |\n| Trivial  | 一句話就好                   | 「修這個 typo」                                         |\n| Simple   | 2-3 句 + constraint          | 「加 dark mode toggle，用現有的 CSS custom properties」 |\n| Medium   | 完整 spec（上面的 template） | 「加搜尋功能」                                          |\n| Complex  | Spec + decomposition         | 「重構 auth system」                                    |\n\n過度 spec 跟 spec 不足一樣是浪費。修一個 typo 不需要寫 Goal / Constraints / Verification。判斷的標準是：**如果 agent 可能做出你不要的東西，就需要 constraint。如果任務只有一種合理的做法，一句話就夠。**\n\n## Takeaway\n\n1. **Spec 的品質直接決定 agent 產出的品質**——10 分鐘的 spec 可以省 3 小時的收拾。ROI 甜蜜點在 10-15 分鐘，超過 20 分鐘邊際效益遞減。\n\n2. **好的 spec 有三個要素：Goal（要什麼）、Constraints（不要什麼）、Verification（怎麼驗）**。其中 Constraints 和 Out of Scope 是最被低估的——它們防止 agent 做出你不需要的功能。\n\n3. **Task decomposition 的甜蜜點是「一個 reviewable PR」的大小**——3-10 個檔案、100-500 行 code、有明確的功能邊界。太粗 agent 自己做太多決策，太細 overhead 超過效益。\n\n---\n\n_上一篇：[Context Engineering 深度解析](/blog/context-engineering-deep-dive)_\n_下一篇：[Agent 產出品質保證](/blog/agent-output-verification-review)_",
      "summary": "Agent 不會讀心術，你的 spec 越模糊，它越容易失控。分享怎麼寫 agent-ready 的 spec——從 task decomposition、acceptance criteria 到 constraint definition，附真實的 spec 範本和「寫太少 vs 寫太多」的對照實驗。",
      "image": "https://bobochen.dev/_astro/cover.lNYg0zGY.webp",
      "date_published": "2026-04-10T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "Spec-Driven Development",
        "AI",
        "需求定義",
        "工作流程"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-11-mom-whats-wrong/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-11-mom-whats-wrong/",
      "title": "媽到底在不爽什麼",
      "content_text": "跟媽講到最後就是吵架，完全無法溝通。她拒絕就醫、拒絕搭計程車、拒絕所有你為她安排的東西。不是因為她不需要，是因為在她的世界觀裡，接受幫助就等於示弱。",
      "content_html": "## 講不通\n\n跟媽溝通，是一場永遠贏不了的戰爭。\n\n不是因為她不講道理。是因為她的道理和我的道理，不在同一個頻率上。\n\n我說：「媽，脖子痛了兩個禮拜了，去看醫生好嗎？」\n她說：「不用啦，等一下就好了。」\n\n我說：「那我幫你掛號，明天帶你去。」\n她說：「很遠欸，我不想去。」\n\n我說：「我開車載你去。」\n她說：「不用麻煩你，你很忙。」\n\n我說：「我不忙，我特地排出時間了。」\n她說：「大冷天的，去醫院又要等很久。」\n\n我說：「那我叫計程車送你去。」\n她說：「搭計程車幹嘛，浪費錢。」\n\n每一個我提出的解決方案，她都有一個拒絕的理由。不是找不到理由，而是永遠有新的理由。你解決了距離的問題，她就提出天氣的問題。你解決了天氣的問題，她就提出費用的問題。你解決了費用的問題，她就說「不想麻煩你」。\n\n最後你會爆炸：「媽到底在不爽什麼啦！」\n\n## 拒絕的背後\n\n花了很長時間我才理解：媽不是在拒絕就醫，她是在拒絕「被照顧」。\n\n在她的人生經驗裡，「接受幫助」是一件有代價的事。跟別人借錢要還、接受好意要回報、讓人家幫忙就欠了一份人情。她一輩子都在避免欠人情，因為她知道「欠」的感覺有多不好受。\n\n所以即使幫她的人是自己的兒子，她的第一反應還是「不要麻煩你」。不是客氣，是防禦。她寧可自己扛著疼痛，也不願意成為「被照顧」的那一方。\n\n因為被照顧 = 失去自主權 = 變成弱者 = 像爸一樣。\n\n對，我覺得在媽的心裡，「需要被照顧」跟爸最後的樣子是連在一起的。她親眼看過一個人倒下之後會變成什麼樣——完全依賴、完全沒有尊嚴、完全成為別人的負擔。她不要變成那樣。\n\n所以她拒絕。拒絕到讓你抓狂。\n\n## 溝通的斷裂\n\n這件事最讓我痛苦的地方，不是她不去看醫生。醫生的事我可以想辦法。\n\n最讓我痛苦的是：**我們沒辦法好好說話。**\n\n媽真的很不會講話。不是不善言辭的那種「不會講話」，是她的表達方式會自動把對話導向衝突的方向。\n\n她會用「你不需要管」來回應你的關心。\n她會用「你們都不懂」來結束你的建議。\n她會用「算了，講了你也不聽」來終止一場她自己不想繼續的對話。\n\n每一句都像是在把你推開，但你知道她內心其實是想要你靠近的。\n\n這種矛盾讓人發瘋。你靠近就被推開，你退遠就被怪不關心。你永遠找不到那個「剛好」的距離。\n\n## 過期的維他命\n\n有一次我回萬華老家整理東西，在廚房發現一堆過期的保健品。\n\n魚油、維他命 A、椰子油——全部過期了。有些是我之前在 Costco 買給她的，有些是其他親戚送的。\n\n問她為什麼不吃，她說：「捨不得。」\n\n捨不得吃維他命。即使那是別人買給她的、即使它放著只會過期、即使吃了對她的身體有好處。她就是捨不得。\n\n這個「捨不得」，跟前面說的「不想麻煩你」是同一個根源——在一個長期匱乏的人心裡，任何好的東西都「不應該被消耗」。留著比用掉更有安全感。即使最後它爛掉、過期、浪費了，至少在那之前，你知道你「有」。\n\n我看著那堆過期的保健品，覺得很難過。\n\n難過的不是幾百塊的浪費。難過的是，媽一輩子都沒有學會「對自己好是可以的」。\n\n## 和解的方式\n\n我沒有找到跟媽完美溝通的方法。到現在也沒有。\n\n但我找到了一個比較可行的模式：**不爭論，直接做。**\n\n要帶她看醫生？不要問她要不要去。直接掛號、直接出現在她家門口、直接說「走，我帶你去」。她會碎唸，但她會上車。\n\n要給她保健品？不要說「這個對你好，要記得吃」。直接買小包裝的，放在她的桌上，跟她的藥放在一起。她會自然而然地拿起來吃，因為它就在那裡。\n\n要裝什麼東西？不要提前通知。直接找人來裝。裝好了跟她說「裝好了」。她會唸「幹嘛花這個錢」，但下次用的時候會很高興。\n\n用行動代替言語。用「做了」代替「你應該」。\n\n這不是最好的方式，但在我們這個不會好好說話的家庭裡，這是最不會吵架的方式。",
      "summary": "跟媽講到最後就是吵架，完全無法溝通。她拒絕就醫、拒絕搭計程車、拒絕所有你為她安排的東西。不是因為她不需要，是因為在她的世界觀裡，接受幫助就等於示弱。",
      "image": "https://bobochen.dev/_astro/cover.D0arBPLv.webp",
      "date_published": "2026-04-09T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-21-psa-for-families/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-21-psa-for-families/",
      "title": "真心勸世文",
      "content_text": "這篇不是心靈雞湯，是一個照顧者的血淚整理。台灣長照的經濟現實、扶養義務的法律困境、你可以打的電話和找的資源。如果你正在面對家人的重大醫療決定，這些事情越早知道越好。",
      "content_html": "## 寫在前面\n\n這篇不是要勸你不救家人。\n\n每個家庭的狀況不同、每個病人的情況不同、每個人的經濟能力不同。我沒有資格替任何人做決定。\n\n但我想把我這十八個月學到的事情整理出來，給正在面對、或者未來可能面對類似狀況的人。\n\n因為這些事情，越早知道越好。等到你站在加護病房外面、手裡拿著手術同意書的時候，你沒有時間去 Google。\n\n## 你需要知道的經濟現實\n\n### 長照費用（以 2023-2024 年台灣為基準）\n\n| 照護方式               | 月費範圍                 | 適用對象                 |\n| ---------------------- | ------------------------ | ------------------------ |\n| 居家照護（外籍看護）   | 25,000-35,000 + 看護薪資 | 有家人可協助管理         |\n| 日間照護中心           | 15,000-25,000            | 白天需照顧、晚上家人接回 |\n| 養護中心（機構式）     | 30,000-50,000            | 需全日照護、家人無法照顧 |\n| 護理之家（需醫療等級） | 35,000-60,000            | 有管路、需專業護理       |\n\n以上是**基本費**。鼻胃管灌食、尿布、營養品、定期回診等額外費用，每月再加 15,000-30,000。\n\n**我的實際花費：養護中心月費約 38,000 元。**\n\n### 政府補助（長照 2.0）\n\n撥打 **1966** 長照服務專線可以了解。\n\n主要有四種補助：照顧及專業服務、交通接送、輔具及居家無障礙環境改善、喘息服務。\n\n但請注意：部分補助有排富條款，審查會計算直系親屬收入。如果你有穩定工作，可能不符合資格。\n\n### 住宿式服務使用者補助（重要！）\n\n這個很多人不知道：衛福部有一個「住宿式服務機構使用者補助方案」。\n\n**2023 年起，長照需要等級達 4 級以上的住民，每人每年補助 12 萬元，而且已經取消排富。**\n\n也就是說不管你收入多少都可以申請。這跟長照 2.0 的排富邏輯不一樣。\n\n我爸就是靠這個方案，每年拿到 12 萬的補助。2025 年初確認入帳。申請方式是透過養護中心統一送件，不用自己跑。\n\n一年 12 萬分攤到每月約一萬，不能解決問題，但至少是一個真實存在且有拿到的補助。\n\n**申請方式**：\n\n- 由入住機構協助申請，或家屬自行向機構所在地的直轄市/縣市政府提出\n- 衛福部長照專區：1966 專線\n- 不需要低收入證明（已取消排富）\n\n### 身障鑑定\n\n中風後如果有肢體或認知功能障礙，可以向醫院申請「身心障礙鑑定」。拿到身障手冊後，可以獲得：\n\n- 部分長照費用減免\n- 輔具補助（電動床、輪椅等）\n- 稅務減免\n- 停車位等生活便利\n\n**提醒：鑑定需要在狀況穩定後才能做，通常是中風後三到六個月。**\n\n## 你需要知道的法律現實\n\n### 扶養義務\n\n根據民法第 1114 條，直系血親之間有互相扶養的義務。\n\n白話講：即使你的父母從來沒有好好養過你，法律上你還是有義務扶養他們。\n\n除非你能證明：\n\n1. 父母對你有嚴重虐待行為（要有證據）\n2. 已被法院裁定減輕或免除扶養義務\n\n舉證標準很高。「他小時候不管我」或「他喝酒打人」，在法官眼中可能不夠構成「嚴重虐待」。\n\n如果你有類似的困境，建議：\n\n- 諮詢法律扶助基金會（免費）：**02-412-8518**\n- 或找當地的法律扶助中心\n\n### 放棄繼承\n\n如果過世的家人有債務，務必在知悉死亡後**三個月內**向法院聲請「拋棄繼承」。\n\n超過三個月沒有聲請，就視為「概括繼承」——他的債變成你的債。\n\n需要準備的文件：\n\n- 死亡證明\n- 戶籍謄本\n- 繼承系統表\n- 拋棄繼承聲請書\n\n可以自己跑法院辦，不一定需要請律師。\n\n## 你需要知道的心理現實\n\n### 照顧者的崩潰不是一瞬間\n\n你不會在某一天突然倒下。\n\n照顧者的崩潰是慢性的：每天少睡一點、每天多操心一點、每天的情緒被消耗一點。像是手機電量一直在掉，但你找不到充電器。\n\n直到某天你發現自己對著超商店員發脾氣、在路上突然想哭、或者乾脆什麼感覺都沒有了。那不是「一時的情緒」，那是累積了幾個月的過載。\n\n### 你可以求助的地方\n\n- **1925 安心專線**：24 小時免費心理諮詢\n- **1966 長照服務專線**：長照資源諮詢\n- **1980 張老師專線**：心理支持\n- **各縣市家庭照顧者支持中心**：Google「[你的縣市] 家庭照顧者支持中心」\n\n### 喘息服務\n\n長照 2.0 有提供「喘息服務」——政府補助讓照顧者暫時休息，由專人接手照顧工作。\n\n住宿型喘息（送到機構幾天）和居家喘息（有人到家裡來）都有。每年有一定的補助天數。\n\n**請務必使用。** 不是因為你不夠堅強，而是因為你不可能一個人撐到底。\n\n## 我希望當初有人告訴我的五件事\n\n**一、不要在急診室做長期決定。** 你在那個當下的判斷一定是被恐懼驅動的。如果可以，請醫生給你二十四小時考慮。打電話給你信任的人討論。\n\n**二、算清楚長期費用再決定。** 搶救成功之後的費用是按月計算的，而且可能持續好幾年。在簽手術同意書之前，問清楚術後照護需要什麼等級、費用大概多少。\n\n**三、家人之間要先談好分攤原則。** 不要「之後再說」。之後你會發現沒有人要說。在還理性的時候，把錢的事情講清楚。\n\n**四、找社工。** 在醫院的時候就找。不要等到出院才開始搞。社工是你在這個制度迷宮裡最重要的嚮導。\n\n**五、照顧好你自己。** 你倒了，誰來照顧你的家人？你的小孩需要你、你的伴侶需要你、你自己也需要你。不要把所有的氧氣都給了別人，自己缺氧。\n\n## 最後\n\n我寫這篇的時候，爸已經走了。\n\n這些資訊對我來說已經「用完了」。但我希望對正在需要的人來說，它們可以在某個手足無措的夜晚，派上一點用場。\n\n如果這篇文章讓你少跑一趟冤枉路、少花一筆冤枉錢、或者只是讓你知道「不是只有你在經歷這些」——那就夠了。",
      "summary": "這篇不是心靈雞湯，是一個照顧者的血淚整理。台灣長照的經濟現實、扶養義務的法律困境、你可以打的電話和找的資源。如果你正在面對家人的重大醫療決定，這些事情越早知道越好。",
      "image": "https://bobochen.dev/_astro/cover.C9bIxiw_.webp",
      "date_published": "2026-04-08T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "長照",
        "社會議題"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-10-500-dollar-boundary/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-10-500-dollar-boundary/",
      "title": "500 元的界線",
      "content_text": "哥每天在家族群組裡抱怨失業、沒女朋友、人生沒希望。每一則訊息都像一顆情緒炸彈丟進我的生活。直到有一天我說：「你每發一則這種訊息，就欠我 500 元看醫生。」哥立刻退出群組。那是我第一次學會設界線。",
      "content_html": "## 群組訊息\n\n我的家族 LINE 群組，長期處於一種有毒的狀態。\n\n主要的毒源是哥。哥比我大幾歲，但在某些方面，他更像是家裡最小的孩子。\n\n他的訊息模式很固定：\n\n> 「又失業了。」\n> 「找不到工作，沒人要我。」\n> 「我沒有女朋友，沒有人可以講話。」\n> 「人生好沒意義。」\n> 「你們都不懂我的感受。」\n\n這些訊息會出現在任何時間——上班的時候、開會的時候、陪小孩的時候、好不容易有一個平靜的晚上的時候。\n\n每一則都像一顆情緒炸彈。\n\n你看到了，就會被拉進他的情緒裡。你不回，覺得自己冷血。你回了，他不會因此好轉，只會繼續丟更多訊息。你試著給建議，他說你不懂。你試著不給建議，他說你不關心。\n\n這不是正常的家人互動。這是情緒勒索。\n\n## 媽的角色\n\n更讓人疲憊的是媽在中間的角色。\n\n媽心疼哥，但她處理事情的方式會讓局面更糟。舉一個例子：有一次我跟媽討論，哥如果繼續住在萬華老家，應該要付房租。這是合理的——他是成年人，有工作能力，不能永遠免費住。\n\n媽同意了。但她跟哥說的時候是這樣講的：「柏宏說你要付房租。」\n\n不是「我們討論後覺得你應該付」，是「你弟要你付」。\n\n一句話，我就變成壞人了。\n\n這種事不是第一次。媽習慣把我推到前面當擋箭牌，然後自己退到後面扮演好人。我理解她的心情——她不想跟哥起衝突，所以讓我來。但這讓我裡外不是人。\n\n## 奶瓶蓋\n\n有一次媽跟我聊天，無意間提到一件小事：「你哥小時候連奶瓶蓋都不會開，每次都是你幫他開的。」\n\n她講這件事的語氣是在回憶往事，覺得很可愛。\n\n但我聽到的是另一件事：**從小到大，哥的困難都是我在解決。**\n\n奶瓶蓋打不開→我幫忙開。功課不會寫→我教。東西壞了→我修。跟人吵架→我調解。找不到工作→我幫忙看履歷。情緒崩潰→我接電話。\n\n三十幾年了，這個模式從來沒有變過。\n\n唯一變的是，小時候我覺得「幫哥哥是應該的」，長大後我覺得「為什麼永遠是我」。\n\n## 血壓飆高\n\n哥的訊息轟炸，真的會影響我的健康。\n\n不是誇張。那段時間我量血壓，數字明顯偏高。我不是本來就有高血壓的人——是每次看到群組裡又跳出一大串抱怨訊息，胸口就會緊一下，然後那個緊的感覺不會馬上散掉，它會跟著你一整天。\n\n有一次我買了一台血壓計回家，想說固定量一下。結果媽看到了，第一反應不是「你身體有問題嗎」，而是懷疑這台血壓計是不是別人送的。\n\n「這你自己買的？還是誰給你的？」\n\n那個瞬間我的情緒上來了。\n\n我出錢、出力、什麼都我在處理，結果連買一台血壓計都要被懷疑？\n\n「我自己買的。我用我自己賺的錢買的。可以嗎？」\n\n我很少對媽這樣講話。但那天我真的忍不住了。不是因為一台血壓計，是因為所有的付出都被視為理所當然，偶爾做一件小事還要被質疑。\n\n那種委屈，比任何一筆帳單都重。\n\n## 500 元\n\n某天，哥又在群組裡發了一長串負面訊息。工作沒了、沒人要、人生沒意義、bla bla bla。\n\n我看著那些訊息，血壓計上的數字浮上腦海。\n\n然後我打了一段話進群組：\n\n**「從今天開始，你每發一則這種讓我血壓飆高的訊息，就欠我 500 元。這 500 塊是我去看醫生和買保健品的費用。」**\n\n群組安靜了。\n\n然後哥退出群組了。\n\n就這樣。沒有爭吵、沒有解釋、沒有來回。他直接退了。\n\n那個瞬間我的心情很複雜。一方面鬆了一口氣——終於安靜了。另一方面有一點罪惡感——他是我哥，我是不是太狠了。\n\n但更大的感覺是：**為什麼我需要走到這一步，才能讓自己的生活不被打擾？**\n\n## 界線這件事\n\n在「正常」的家庭裡，你不需要跟家人設界線。因為大家自然而然地會尊重彼此的空間。\n\n但在功能失調的家庭裡，如果你不主動畫線，你的生活就會被別人的情緒淹沒。你的時間、你的精力、你的健康、你的家庭——全部都會被拿去填那個永遠填不滿的黑洞。\n\n我花了三十幾年才學會設界線。500 元那一次是第一步。\n\n後來我學到更多：\n\n- **不回應不等於不關心。** 我可以關心哥的狀況，但不需要每一則訊息都接住。\n- **你不需要解決別人的人生。** 我可以提供建議，但最終走路的是他自己。\n- **罪惡感是被植入的。** 「你是弟弟，應該幫哥哥」——這個想法從小就被種下來，但它不是事實，它是控制的工具。\n- **照顧自己不是自私。** 你把自己搞垮了，誰來照顧你的老婆和小孩？\n\n## 給同路人\n\n如果你也是家裡「什麼都要你弄」的那個人，我想跟你說幾件事：\n\n**第一，你的疲累是真實的。** 不是你太脆弱、不是你不夠有愛心。長期承受不對等的情緒勞動，會讓人生病。這不是比喻，是事實。\n\n**第二，設界線不是拋棄。** 你還是愛你的家人。你只是在告訴他們：我的能力有限度，我也需要被保護。\n\n**第三，你不欠任何人你的人生。** 不管是爸、媽、哥、姐——沒有任何一個人有權利要求你犧牲自己的健康和家庭，來成全他們的需求。\n\n你可以選擇幫忙。但那應該是「選擇」，不是「義務」。\n\n這個差別，是我花了三十幾年才搞懂的事。",
      "summary": "哥每天在家族群組裡抱怨失業、沒女朋友、人生沒希望。每一則訊息都像一顆情緒炸彈丟進我的生活。直到有一天我說：「你每發一則這種訊息，就欠我 500 元看醫生。」哥立刻退出群組。那是我第一次學會設界線。",
      "image": "https://bobochen.dev/_astro/cover.Tbswq1ke.webp",
      "date_published": "2026-04-07T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭",
        "界線"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-20-final-journey/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-20-final-journey/",
      "title": "最後一程",
      "content_text": "2024 年 10 月 21 日，爸走了。我沒有哭。不是因為不愛，是因為我們已經送走他太多次了。佛教助念八個小時，放棄繼承避免債務，最後在陽明山火化。一切結束得很平靜。",
      "content_html": "## 平靜\n\n2024 年 10 月 21 日。\n\n養護中心打電話來的時候，我在家裡。\n\n護理師的語氣很平穩，她們大概見過太多次了。她說了一些醫療術語，意思就是：生命徵象停止了。\n\n我放下電話，在客廳站了大約三十秒。\n\n沒有哭。沒有崩潰。沒有跪在地上。\n\n就是一種很奇特的安靜。像是等了很久的一班公車終於到站了。你不會大喊「終於來了」，你只是默默地上車。\n\n不是不愛。是準備太久了。\n\n從加護病房到養護中心，每一次去看爸，都是一次道別。你看著那個不會說話、不會動、不認得你的人，你在心裡說一次再見。十八個月下來，你已經說了幾十次。\n\n到最後，當身體真正停下來的時候，反而沒有什麼新的悲傷可以感覺了。它只是把已經存在很久的悲傷，畫上了句號。\n\n## 後事\n\n接下來的幾天很忙。\n\n忙到你沒有時間悲傷。也許這就是後事存在的意義——用大量的事務性工作，填滿失去之後的那個空洞。\n\n**佛教助念**：透過一個佛教慈善團體安排的。八個小時的誦經，讓爸可以在聲音裡安靜地離開。不管你信不信佛，那八個小時的經聲有一種安定的力量。\n\n**入殮**：選了不冰存的方式。打桶入殮，直接處理。\n\n**靈位安排**：在禮儀公司的引導下，一步一步完成。\n\n**火化**：在陽明山殯儀館。整個過程大概兩個小時。\n\n你會發現，一個人的一生，最後濃縮成幾個小時的行政流程。\n\n## 放棄繼承\n\n爸走了之後，有一件很現實的事情要馬上處理：繼承。\n\n但我們選的是「放棄繼承」。\n\n因為爸留下的不是遺產，是債務。遠傳電信的未繳帳單一萬八千元、簽六合彩的賭債、各種零零碎碎的欠款。如果我們繼承了，這些債全部變成我們的。\n\n諷刺的是，爸名下還有一筆股票——神達電腦 3,144 股，大約值十四萬七。但因為我們放棄繼承，這些也一起放棄了。與其冒著承接未知債務的風險去拿那十四萬七，不如乾淨地切斷。\n\n放棄繼承需要在知悉過世後的三個月內向法院聲請。流程不複雜，但要跑的地方不少——戶政事務所辦死亡登記、國稅局申報、法院遞件。\n\n每一站都要帶一堆文件。死亡證明、戶籍謄本、印鑑證明、申請書。你在哀傷的同時還要記得蓋哪個章、填哪個欄位。\n\n我一個人跑完的。哥幫不上忙。\n\n## 剩下來的東西\n\n爸走了之後，留下了一些東西：\n\n一個銀行帳戶，裡面剩一點點錢。因為要辦放棄繼承，這筆錢不能動。\n\n一筆勞保家屬死亡給付：45,800 × 3 個月 = 137,400 元。2024 年 11 月 27 日才入帳，因為帳戶資料有問題，跑了一趟銀行更正才搞定。\n\n幾張舊照片。一些他年輕時候的東西。\n\n和一段沒有人會主動提起的記憶。\n\n## 無掛無礙\n\n有人在告別式的時候跟我說了一句話：\n\n「你爸這一生也算是無掛無礙，沒有太多操心的事，最後一程也是平靜地離開。」\n\n我想了想。好像是這樣。\n\n爸不需要做決定——手術的決定是我和哥做的。\n爸不需要還債——他失去意識之後，那些債就變成了別人的問題。\n爸不需要面對任何人的指責——因為他聽不到了。\n\n他在失去意識的那一刻，就把人生的所有包袱放下了。\n\n剩下的包袱，全部由我們接住。\n\n這不公平。但也沒有辦法。因為人生從來沒有公平過。\n\n## 句號\n\n爸走了之後，家裡的空氣變了。\n\n不是說變好了或變壞了。就是不一樣了。\n\n少了一個每個月要繳三萬八的帳單。少了一個每月要去養護中心報到的行程。少了一個「爸最近怎麼樣」的問題。\n\n多了一種「結束了」的感覺。\n\n十八個月的等待，終於畫上了句號。\n\n我可以用那三萬八帶榕和辰去旅行。可以用那些去養護中心的時間陪他們寫作業。可以在被問「你爸怎麼樣」的時候，不再需要解釋那段漫長的故事。\n\n寫到這裡，好像在說「爸走了所以一切都好了」。不是的。\n\n爸走了，留下的不是解脫。是一個巨大的空洞，和一堆你來不及說的話。\n\n但至少，他走得平靜。\n\n在這個家裡發生的所有不平靜之後，這也許是最好的結局了。",
      "summary": "2024 年 10 月 21 日，爸走了。我沒有哭。不是因為不愛，是因為我們已經送走他太多次了。佛教助念八個小時，放棄繼承避免債務，最後在陽明山火化。一切結束得很平靜。",
      "image": "https://bobochen.dev/_astro/cover.BePU-u-I.webp",
      "date_published": "2026-04-07T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-09-bottle-cap/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-09-bottle-cap/",
      "title": "奶瓶蓋都要我開",
      "content_text": "媽說哥小時候連奶瓶蓋都不會開，每次都是我幫他。三十年過去了，這個模式從來沒有變過。奶瓶蓋變成了工作、感情、房子、人生大小事。而我，從小就是那個「幫忙開蓋子」的人。",
      "content_html": "## 一個很小的畫面\n\n有一次媽跟我聊天，無意間提到：「你哥小時候連奶瓶蓋都不會開，每次都是你幫他開的。」\n\n她講的時候笑笑的，語氣裡帶著一種「兄弟情深」的溫馨感。在她的記憶裡，這是一個可愛的畫面——弟弟幫哥哥開瓶蓋，多貼心。\n\n但我聽到的不是溫馨。我聽到的是一個持續了三十年的模式的起源。\n\n奶瓶蓋 → 作業 → 人際衝突 → 工作 → 感情 → 搬家 → 賣車 → 人生。\n\n從小到大，哥遇到的每一件「打不開」的事情，最後都會傳到我手上。而我，不知道從什麼時候開始，就成了那個「負責開蓋子」的人。\n\n## 清單\n\n如果把哥這些年需要我「開蓋子」的事情列出來，大概可以寫滿好幾頁：\n\n找工作的時候，要我幫忙看履歷、找職缺、模擬面試。\n丟了工作，要我聽他抱怨、安慰他、幫他分析下一步。\n跟人起衝突，要我出面調解。\n家裡的東西壞了，要我來修或找人修。\n爸的二手車要處理，他搞不定，最後媽去弄。\n搬家要找人，他不會約、不會比價。\n手機出問題，傳截圖問我怎麼辦。\n\n每一件單獨來看，好像都不是什麼大事。但它們加在一起，變成了一種永不停歇的消耗。\n\n而且重點不是事情本身的大小。重點是——這些事情裡面，有哪一件是一個三十幾歲的成年人應該不會做的？\n\n答案是：沒有。這些全部都是正常成年人應該能自己處理的事。\n\n但哥不行。不是真的「不行」，是他從來不需要自己處理，所以他相信自己不行。\n\n## 我累了\n\n有一段時間，我處於一種很矛盾的狀態。\n\n一方面，我知道哥是我哥，血緣關係不會改變。不管他多讓我崩潰，他終究是家人。\n\n另一方面，我每次看到他的訊息或電話，身體會有一種本能的排斥反應。胸口一緊、肩膀往上縮、呼吸變淺。像是身體比腦子更早知道「又來了」。\n\n我累了。\n\n不是那種「今天工作很累，睡一覺就好」的累。是那種從骨頭裡面滲出來的、慢性的、像慢性病一樣的累。是「明知道明天還會繼續」的那種絕望感。\n\n我有自己的工作、自己的家庭、自己的兩個小孩要照顧。我的時間和精力不是無限的。每一份花在哥身上的心力，都是從榕和辰那裡借過來的。\n\n這個帳，我越來越不願意借了。\n\n## 不是我的責任\n\n花了很長的時間，我才接受一個事實：\n\n**哥的人生不是我的責任。**\n\n這句話說出來很簡單。但要真的「相信」它，需要克服從小被灌輸的信念——「你是弟弟，你比較有能力，所以你應該幫哥哥。」\n\n這個信念的邏輯漏洞在於：它把「有能力」等同於「有義務」。\n\n你比較會讀書 → 你應該教他。\n你比較會處理事情 → 你應該幫他。\n你比較有錢 → 你應該養他。\n你比較堅強 → 你應該接住他所有的情緒。\n\n但能力不等於義務。我可以有能力幫他，但不代表我必須幫他。我可以選擇幫，也可以選擇不幫。\n\n這個「選擇」的權利，我花了三十幾年才拿回來。\n\n開奶瓶蓋的那個小弟弟，終於學會說：「這個瓶蓋，你自己試試看。」",
      "summary": "媽說哥小時候連奶瓶蓋都不會開，每次都是我幫他。三十年過去了，這個模式從來沒有變過。奶瓶蓋變成了工作、感情、房子、人生大小事。而我，從小就是那個「幫忙開蓋子」的人。",
      "image": "https://bobochen.dev/_astro/cover.cPruAIVy.webp",
      "date_published": "2026-04-06T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-16-sibling-distance/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-16-sibling-distance/",
      "title": "手足的距離",
      "content_text": "照顧一個有精神疾病的哥哥，跟照顧中風臥床的爸爸，是完全不同的事。爸的照顧有終點，哥的沒有。想幫但幫不了、想放手但放不下。這個距離，我到現在還在學怎麼拿捏。",
      "content_html": "## 兩種照顧\n\n在這本書裡，我寫了兩段照顧經歷。\n\n一段是照顧中風臥床的爸。鼻胃管、養護中心、每月三萬八的帳單。沉重、消耗、但有一個明確的結構——費用多少、狀況如何、做什麼處理。而且，雖然殘忍地說——它有終點。\n\n另一段是照顧有思覺失調症的哥。沒有養護中心、沒有明確的帳單、沒有可以量化的「病情進度」。而且——它沒有終點。\n\n思覺失調症是慢性病。它不會好。只能控制。\n\n也就是說，哥的這個狀況，可能會伴隨他一輩子。而只要我還活著、他還活著，我就得繼續面對它。\n\n這件事的重量，跟那三萬八的帳單不同。帳單的重量是可以計算的——扣完就沒了。但「一輩子」的重量是無法計算的。它會隨著年齡增長而改變形狀，但不會消失。\n\n## 身體的病 vs. 心理的病\n\n照顧身體疾病的家人，有一個殘酷但實際的好處：大家看得到。\n\n你跟同事說「我爸中風住院了」，所有人都會表達同情。你請假去養護中心，主管通常不會為難你。你跟朋友聊起來，他們會說「辛苦了」，然後真的覺得你辛苦。\n\n但你跟人說「我哥有思覺失調症」——\n\n空氣會凝結一秒鐘。然後對方會選擇以下其中一種反應：\n\n- 「啊⋯⋯那⋯⋯辛苦了。」（然後轉移話題）\n- 「他有在看醫生嗎？」（然後不知道怎麼接下去）\n- 沉默。\n\n不是他們不關心。是精神疾病在這個社會裡，還是一個讓人不知道怎麼反應的話題。\n\n這意味著：照顧哥的那些疲憊和壓力，很大程度上是隱形的。你不太能在公開場合說出來、不太能獲得跟「爸住院了」同等程度的理解和支持。\n\n你只能自己扛。安靜地。\n\n## 想幫但幫不了\n\n照顧身體疾病，你可以做很多「具體的事」：找醫生、繳費用、安排照護、買營養品。做了就有效果，至少有一些。\n\n但精神疾病不一樣。\n\n我帶哥去看醫生了→他吃藥越吃越消沉。\n我幫他找工作機會了→他上了幾天就辭了。\n我在群組裡回覆他的訊息了→他隔天又發同樣的。\n我設了界線了→他退群了，然後更孤獨了。\n\n每一個你以為「做了就會好一點」的行動，最後都變成「做了也沒用」。\n\n不是完全沒用。也許有一些你看不到的微小改善。但在日常的感受上，就是一種無力的重複。\n\n這種無力感會慢慢侵蝕你的信心。\n\n我曾經在筆記裡寫過一句話：**「救生圈都拋給你了，還是不敢改變，在那裡喊救命。」**\n\n現在回頭看，這句話太狠了。但那是當時真實的心情。後來我慢慢理解：不是他「不敢」抓住救生圈，是他的病讓他的手可能就是抓不住。腦袋裡的化學物質不對，意志力再強也沒有用。\n\n理解歸理解。但你還是會懷疑：我做的這些到底有什麼意義？也許不管我做什麼，結果都一樣？\n\n然後你就理解了哥的感受——因為「不管做什麼結果都一樣」，就是習得無助的定義。\n\n照顧者和被照顧者，最後殊途同歸。\n\n## 告別式那天\n\n爸的告別式，2024 年 10 月底。\n\n那一天應該是沉重的、安靜的、屬於道別的一天。\n\n但哥一整天在講他要告前學校的事。他說他不會寫法院的答辯書，講了又講、繞了又繞。然後又開始講找工作的事——投了哪些、被拒了哪些、為什麼那些公司不識貨。\n\n爸的告別式。他在講答辯書和工作。\n\n我那天選擇不理他。少掉一點爭吵，我自己也比較舒服一點。\n\n後來跟養護中心的人道謝的時候，對方很客氣地說感謝我們的配合。言外之意大概是：感謝你們包容你哥在這段時間裡因為情緒起伏多次打擾工作人員。\n\n連外人都看得出來。\n\n那天回家的路上，我在想：這就是我說的「兩種照顧」的差別。爸走了，他的照顧結束了。但哥——哥的故事還在繼續。在爸的告別式上，在我以為可以暫時喘一口氣的日子裡，他還是那個不斷需要你分心去處理的存在。\n\n## 距離\n\n經過這些年，我學到的最重要的一件事是：**距離。**\n\n不是疏遠。是找到一個「我可以持續的距離」。\n\n太近了，你會被拉進他的世界，跟著他一起沉下去。你的工作受影響、家庭受影響、健康受影響。\n\n太遠了，你會內疚。他是你哥，他在受苦，而你在過自己的日子。那個「他在受苦而我在這裡吃飯」的畫面，會在深夜的時候跑出來咬你。\n\n所以你得找一個中間的位置。\n\n對我來說，那個位置大概是這樣：\n\n每週固定聯繫一次。不多不少。\n他需要看醫生的時候，我幫忙安排。\n他經濟上真的有困難的時候，我能支援就支援。\n但他的情緒起伏、他的工作去留、他的人際關係——這些我不介入。\n\n不是不關心。是我介入了也改變不了什麼。而那些花在介入上的精力，可以用在陪榕和辰的身上。\n\n## 未完待續\n\n這篇文章跟前面的不一樣。前面的故事大多有一個結尾——爸走了、界線設了、選擇做了。\n\n但哥的故事沒有結尾。因為它還在繼續。\n\n明天他可能又會在群組裡發訊息。下個月他可能又會失業。明年他可能會找到一個撐得比較久的工作，也可能不會。\n\n我不知道。\n\n我唯一知道的是：不管發生什麼，他是我哥。這件事不會改變。\n\n我可以設界線、我可以控制距離、我可以保護自己的心理健康。但我不會假裝他不存在。\n\n因為在他的世界裡，「有人知道你存在」這件事，可能就是他還撐著的理由之一。\n\n所以我會繼續站在那個距離上。不太近、不太遠。\n\n看著他。讓他知道有人在看著。\n\n這大概就是手足能做的最大限度了。",
      "summary": "照顧一個有精神疾病的哥哥，跟照顧中風臥床的爸爸，是完全不同的事。爸的照顧有終點，哥的沒有。想幫但幫不了、想放手但放不下。這個距離，我到現在還在學怎麼拿捏。",
      "image": "https://bobochen.dev/_astro/cover.DzoyeZ8L.webp",
      "date_published": "2026-04-06T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "精神疾病",
        "思覺失調症"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-19-should-have-listened/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-19-should-have-listened/",
      "title": "應該要聽媽媽的話",
      "content_text": "媽從一開始就說不要救。我們沒聽。手術成功了，但爸沒有醒來。一年半後我終於說出那句話：「我應該聽媽的話，真的不應該救的。」這句話，比手術同意書還重。",
      "content_html": "## 媽說的那句話\n\n在加護病房外面，醫生解釋完兩個選項之後，媽很快就說了她的立場：\n\n「不要救了。」\n\n沒有猶豫。沒有哭。就是很平靜地說。\n\n當下我覺得媽很冷血。一個妻子——即使已經分開了——怎麼可以這麼快地決定放棄自己的丈夫？\n\n後來我才理解：不是冷血，是清醒。\n\n媽當了十幾年的照服員。那句「不要救了」之所以可以說得那麼平靜、那麼快，是因為這個劇本她在別人的病床邊已經看過太多次了。插管、呼吸器、鼻胃管、躺著的人和守在外面熬乾的家屬——她不是在想像，她是在回憶。\n\n而且她比任何人都了解爸的身體。他喝了幾十年的酒、從不運動、高血壓放著不管。媽知道這個人的身體已經不可能真正恢復了。手術「成功」的結果，她在心裡已經演過一遍。\n\n她知道接下來會發生什麼：插管、呼吸器、鼻胃管、日復一日地躺著。不是活著，是被機器維持著生理數據。\n\n而留下來承擔這一切的，不是躺在床上的爸，是外面的每一個人。\n\n而且媽知道一件我們當時沒放在心上的事：**爸自己說過不想這樣拖累我們，想好好走完。**\n\n他在還清醒的時候說過這種話。也許是隨口說的、也許是認真的。但媽記住了。\n\n媽看得太清楚了。清楚到殘忍的程度。\n\n## 我們沒聽\n\n但我和哥沒有聽她的。\n\n原因很複雜。\n\n哥是因為不敢做「放棄」的決定。他害怕如果不救、然後爸其實有機會醒來怎麼辦？他害怕事後被其他親戚說「你們兒子怎麼不救自己的爸」。他害怕活在「我讓我爸死了」的罪惡感裡。\n\n我呢？我覺得自己是理性的，但其實也被情緒綁架了。「不是零」這三個字——醫生說醒來的機率不是零——就像一根稻草，讓你覺得只要抓住它就有希望。\n\n而在那種情緒下，「希望」比「現實」更有力量。\n\n所以我們簽了手術同意書。簽的時候手在抖，但我們告訴自己：至少我們盡力了。\n\n現在回頭看，「盡力」和「做對」是兩件事。\n\n## 那句話\n\n養護中心的日子過了大半年之後。\n\n某一天，我跟朋友聊起爸的事。朋友聽完之後，很直接地跟我說了他的看法。他說了一些我一直不敢面對的事實——關於存活年限、關於費用、關於生活品質、關於什麼才是真正的「對他好」。\n\n那天晚上我躺在床上，一直睡不著。\n\n然後腦海裡浮出一句話，它其實已經在那裡很久了，只是我一直不敢讓它浮上來：\n\n**我應該聽媽的話。真的不應該救的。**\n\n說出這句話——即使只是在腦子裡說——需要的勇氣，比簽手術同意書還要多。\n\n因為手術同意書是在「做」，而這句話是在承認你「做錯了」。承認你出於愛和希望做的決定，可能造成了更多的痛苦。\n\n## 不是不愛\n\n我需要說清楚：「不應該救」不等於「不愛他」。\n\n在那個加護病房外面，如果我不愛爸，我不會猶豫。我可以很冷靜地說「不救了」，然後回家繼續過日子。\n\n正是因為愛，才會不敢放手。正是因為愛，才會抓住那個「不是零」的機率。正是因為愛，才會做出一個用十八個月的痛苦來證明是錯誤的決定。\n\n愛不保證你做對。有時候，愛會讓你做出最糟的選擇。\n\n而那個最糟的選擇，你得自己承擔結果。\n\n## 我把這份清醒留下來\n\n爸的事過去之後，我和太太去簽了放棄急救同意書（DNR）。\n\n這不是一時衝動。是我想得很清楚之後做的決定。\n\n因為我經歷過站在加護病房外面、被迫在恐懼裡作答的那種煎熬。我知道那道題有多重，重到十八個月後還壓在我胸口。我不想要有一天，換我的兒女站在那個位置，為了我的身體吵架、自責、活在「是不是我害死了爸」的陰影裡。\n\n媽當年的清醒，是用她幾十年照服員的經驗換來的。我的清醒，是用爸的十八個月換來的。我不希望我的孩子，也要用我的痛苦才學會這件事。\n\n所以我先替他們把這道題答了。\n\n簽下去的那一刻我突然懂了——媽當年那句「不要救了」，從來不是放棄，是她能給的最後一種保護。而我現在做的，也是同一件事。\n\n或許這就是一個父親能留給子女的、一份不太一樣的關愛：不是多留一口氣給自己，是少留一道無解的難題給他們。\n\n## 不需要替自己感到罪惡\n\n如果你正在讀這段文字，而你曾經做過類似的決定——不管是救了還是沒救——我想跟你說：\n\n**你不需要替自己感到罪惡。**\n\n不管你選了什麼，那都是你在當下能做的最好的選擇。你不是醫生，你沒有預知能力，你不可能在恐懼和悲傷中做出完美的判斷。\n\n你做了你能做的。然後你承擔了結果。\n\n這已經夠了。\n\n如果那個結果不如預期，那不是你的錯。那是「命運給了一道沒有正確答案的題目」，而你被迫在限時之內作答。\n\n沒有人應該為此責備自己。包括我。\n\n我花了很久才相信這段話。也許還沒有完全相信。但我試著去相信。\n\n因為如果連自己都不原諒自己，那這個擔子就永遠放不下了。",
      "summary": "媽從一開始就說不要救。我們沒聽。手術成功了，但爸沒有醒來。一年半後我終於說出那句話：「我應該聽媽的話，真的不應該救的。」這句話，比手術同意書還重。",
      "image": "https://bobochen.dev/_astro/cover.NowNBKCj.webp",
      "date_published": "2026-04-06T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "長照"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-user-feedback/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-user-feedback/",
      "title": "用戶回饋循環：聽懂用戶在說什麼",
      "content_text": "一個人做產品最容易犯的錯是「自己覺得好就好」。本文教 Solo Builder 建立系統化的用戶回饋收集與分析流程，用 AI 自動分類、情感分析與優先排序，每週不到一小時，讓有限時間花在最重要的改進上。",
      "content_html": "## 你不是你的用戶——用戶回饋為什麼重要\n\n讓我告訴你一個我親身經歷的故事。\n\ncloud-on-academy 上線初期，我花了一整個週末重新設計課程頁面的側邊導覽。我把章節樹狀結構做得很漂亮，可以展開、收合、顯示進度百分比、標記已完成的章節。身為工程師，我對這個 UI 非常滿意。\n\n然後我看了 analytics。\n\n用戶根本不用側邊導覽。他們的行為模式是：看完一章，直接點文章底部的「下一章」連結。側邊欄？大部分人從頭到尾沒點過。\n\n我花了一個週末在打磨一個沒人用的功能。\n\n這就是 Solo Builder 最危險的陷阱：**因為你自己就是用戶，所以你以為你知道用戶要什麼。**\n\n你確實是用戶之一。但你是一個非常特殊的用戶。你知道產品的每一個角落、每一行程式碼、每一個設計決策的背景。你對產品的理解深度和一般用戶完全不同。\n\n一般用戶不會打開 DevTools 看你的 CSS 寫得多漂亮。一般用戶不在乎你用了 Cloudflare Workers 還是 Vercel。一般用戶只在乎一件事：**這個東西能不能幫我解決問題。**\n\n你需要一套系統，讓用戶告訴你他們真正在乎什麼。而不是你自己猜。\n\n想看 cloud-on-academy 和其他產品的完整實戰經驗？👉 [第 13 章：實戰案例——我的四個產品](/blog/ai-solo-builder-case-studies/)\n\n## 回饋管道的四個層級\n\n收集用戶回饋不是放一個「聯絡我們」的 email 就好。你需要多個管道，每個管道捕捉不同類型的訊號。\n\n### 層級 1：行為數據（用戶做了什麼）\n\n這是最客觀的回饋。用戶不需要開口，他們的行為就在告訴你答案。\n\n| 數據       | 告訴你什麼               | 工具                           |\n| ---------- | ------------------------ | ------------------------------ |\n| 頁面瀏覽量 | 哪些內容最受歡迎         | Google Analytics 4             |\n| 跳出率     | 哪些頁面讓人立刻離開     | Google Analytics 4             |\n| 功能使用率 | 哪些功能沒人用           | 自建事件追蹤 / Mixpanel 免費版 |\n| 用戶路徑   | 用戶怎麼在產品裡移動     | GA4 User Flow / Hotjar         |\n| 搜尋紀錄   | 用戶在找什麼（但找不到） | 站內搜尋 log                   |\n| 錯誤紀錄   | 用戶遇到什麼問題         | Sentry                         |\n\n行為數據的好處是**不需要用戶配合**，你設定好追蹤，數據就自動進來。壞處是它告訴你「發生了什麼」，但不告訴你「為什麼」。\n\n### 層級 2：被動回饋（用戶主動來找你）\n\n用戶遇到問題或有想法時，主動聯繫你。\n\n管道包括：\n\n- **產品內回饋按鈕**：頁面角落放一個「回報問題」或「建議功能」的小按鈕\n- **Email**：最基本的管道，永遠不要關掉\n- **社群留言**：GitHub Issues、Discord、Telegram\n- **App Store / Chrome Web Store 評論**：如果你有上架的話\n\n被動回饋的特色是：**會主動留回饋的人，通常感受很強烈**。不是非常滿意，就是非常不滿。中間的大多數人什麼都不會說。\n\n這代表被動回饋有偏差。但它的價值在於**深度**，用戶會用自己的話告訴你問題的脈絡，這是行為數據做不到的。\n\n### 層級 3：主動收集（你去問用戶）\n\n不等用戶來找你，而是主動出擊。\n\n| 方式       | 適合時機           | 工具                      |\n| ---------- | ------------------ | ------------------------- |\n| 產品內問卷 | 用戶完成某個動作後 | Google Forms 嵌入 / Tally |\n| Email 問卷 | 定期（每季一次）   | Google Forms              |\n| NPS 評分   | 衡量整體滿意度     | 簡單的 1-10 分嵌入        |\n| 用戶訪談   | 深入理解特定議題   | Google Meet / Zoom        |\n\n主動收集的回覆率通常不高（5-15%），但你拿到的是**你想問的問題的答案**，而不是用戶自己想說的話。兩者互補。\n\n### 層級 4：間接訊號（你沒問但能觀察到的）\n\n- **社群媒體提及**：有人在 X（前 Twitter）/ Threads 上提到你的產品（社群平台的貼文 Google Alerts 抓不到，要用 Mention、Brand24、Talkwalker Alerts 這類社群監聽工具；Google Alerts 只適合追蹤部落格、新聞網頁類的提及）\n- **競品論壇的討論**：用戶在競品社群抱怨什麼\n- **搜尋趨勢**：相關關鍵字的搜尋量變化\n- **退訂 / 流失數據**：用戶什麼時候離開、離開前做了什麼\n\n這些訊號比較微弱，但有時候最有價值的洞察就藏在這裡。\n\n## Solo Builder 的最小回饋系統\n\n四個層級聽起來很多。但你是一個人，你不可能全做。\n\n**以下是我建議的最小配置——設定一次，花不到 2 小時：**\n\n| 管道       | 工具                          | 設定時間 | 維護時間           |\n| ---------- | ----------------------------- | -------- | ------------------ |\n| 行為追蹤   | Google Analytics 4            | 30 分鐘  | 每週看 10 分鐘     |\n| 錯誤追蹤   | Sentry 免費版                 | 15 分鐘  | 被動（有告警才看） |\n| 產品內回饋 | Google Forms 嵌入             | 15 分鐘  | 被動（有回覆才看） |\n| Email      | 你的信箱                      | 0 分鐘   | 被動               |\n| 社群       | GitHub Discussions 或 Discord | 30 分鐘  | 每天 5 分鐘掃一眼  |\n\n五個管道，設定 1.5 小時，每週維護不到 1 小時。\n\n**不要一開始就上 Hotjar、Mixpanel、Canny、Intercom 全家桶。** 那些是你有 1000+ 用戶之後才需要考慮的。前期最重要的是：有管道能讓用戶找到你、有數據能讓你看到基本行為。\n\n同樣的「先做最小可行版本」思維，也適用在產品功能開發上，參考 👉 [第 4 章：MVP 設計——砍到不能再砍](/blog/ai-solo-builder-mvp-design/)\n\n## AI 驅動的回饋分析\n\n回饋收集到了。然後呢？\n\n如果你有 10 個用戶，你可以一條一條看完。但當你有 50、100、500 條回饋時，手動分析就不現實了。\n\n這是 AI 最擅長的場景：**從大量非結構化文字中提取結構化的洞察。**\n\n### 傳統做法\n\n1. 打開 Google Sheet\n2. 一條一條讀回饋\n3. 手動標記分類（bug / feature request / 抱怨 / 讚美）\n4. 嘗試找出模式\n5. 花了三小時，結論是「好像很多人想要 X 功能」\n\n→ 主觀、耗時、容易遺漏\n\n### AI 加持做法\n\n把所有回饋丟給 AI，讓它幫你做結構化分析：\n\n```text\n以下是我的產品在過去一個月收到的用戶回饋（共 47 條）：\n\n[貼上所有回饋內容]\n\n請幫我做以下分析：\n\n1. 分類統計\n   - 把每條回饋歸類為：Bug、Feature Request、UX 問題、\n     正面回饋、疑問/求助、其他\n   - 統計每個分類的數量和百分比\n\n2. 情感分析\n   - 正面 / 中性 / 負面的比例\n   - 哪些主題引發最強烈的負面情緒\n\n3. 主題提取\n   - 歸納出前 5 個最常被提到的主題\n   - 每個主題有多少條回饋提到\n   - 附上代表性的原文引用\n\n4. 優先排序建議\n   - 基於「影響用戶數 × 情緒強度 × 實作難度」，\n     建議我優先處理的前 3 件事\n   - 每個建議附上理由\n\n5. 我可能忽略的訊號\n   - 有沒有只出現 1-2 次但值得注意的回饋？\n   - 有沒有用戶用不同的方式在說同一件事？\n```\n\nAI 在 2 分鐘內就能給你一份結構化的分析報告。\n\n更重要的是，AI 不會有你的偏見。你可能下意識地更注意那些支持你既有想法的回饋（確認偏差）。AI 會一視同仁地處理每一條。\n\n### 進階：趨勢追蹤\n\n每個月做一次分析不夠。你應該追蹤回饋的**趨勢變化**：\n\n```text\n以下是過去三個月的回饋分析摘要：\n\n一月：[上個月的 AI 分析結果]\n二月：[上上個月的 AI 分析結果]\n三月：[這個月的 AI 分析結果]\n\n請比較三個月的變化：\n1. 哪些問題在好轉？（之前常被提到，現在變少了）\n2. 哪些問題在惡化？（之前沒人提，現在越來越多人提）\n3. 有沒有新出現的主題？\n4. 整體情感趨勢是上升還是下降？\n```\n\n這種趨勢分析靠人工幾乎不可能做，因為你不會記得三個月前的回饋細節。但 AI 可以輕鬆比較。\n\n## 從回饋到功能：轉化管線\n\n分析完回饋，接下來是最關鍵的一步：**把回饋轉成你實際要做的事。**\n\n很多 Solo Builder 的問題不是「不知道用戶要什麼」，而是「知道了但不知道怎麼排優先順序」。用戶 A 想要功能 X，用戶 B 想要功能 Y，用戶 C 說你的 UI 很醜。你一個人一週只有幾小時，該先做什麼？\n\n### 回饋分類矩陣\n\n我用一個簡單的 2x2 矩陣來決定：\n\n|                | 影響用戶多   | 影響用戶少 |\n| -------------- | ------------ | ---------- |\n| **實作成本低** | **立刻做**   | 有空做     |\n| **實作成本高** | 排入 roadmap | **不做**   |\n\n「影響用戶多」的判斷依據是：回饋中有多少人提到了類似的需求。\n「實作成本」用小時來估：1-2 小時算低、一個週末算中、超過兩個週末算高。\n\n### AI 輔助的需求拆解\n\n回饋往往是模糊的。「希望有更好的搜尋功能」——什麼叫「更好」？\n\n用 AI 幫你把模糊的回饋拆解成可執行的項目：\n\n```text\n用戶回饋：「搜尋功能不好用，常常找不到想要的文章」\n\n這個回饋被 5 個不同用戶以不同方式提到。\n\n我的產品是一個技術部落格，目前用 Pagefind 做靜態搜尋。\n\n請幫我：\n1. 分析「搜尋不好用」可能的具體原因（列出 5 個可能）\n2. 每個原因的驗證方式（怎麼確認是不是這個問題）\n3. 每個原因的修復方案和預估工時\n4. 建議的處理順序（先解決最可能的原因）\n```\n\nAI 把一條模糊的回饋變成了五條可驗證、可執行的任務。你不用自己想破頭，丟給 AI，它會列出一堆你沒想到的可能性。\n\n## 量化 vs. 質性：你兩個都需要\n\n很多工程師天然傾向量化數據，開口就是「給我看數字！」但數字只能告訴你「發生了什麼」，不能告訴你「為什麼」。\n\n|          | 量化（數字）                | 質性（文字）               |\n| -------- | --------------------------- | -------------------------- |\n| **回答** | 什麼、多少、多常            | 為什麼、怎麼想             |\n| **來源** | Analytics、NPS 分數、轉換率 | 訪談、開放式問卷、客服信件 |\n| **優點** | 客觀、可追蹤趨勢            | 深入、有脈絡               |\n| **缺點** | 沒有脈絡、不知道原因        | 主觀、樣本小               |\n| **適合** | 發現問題                    | 理解問題                   |\n\n**正確的流程是：量化發現問題 → 質性理解問題 → 量化驗證修復。**\n\n例如：\n\n1. **量化**：GA4 顯示 checkout 頁面的跳出率是 60%\n2. **質性**：訪談 3 個用戶，發現他們不確定付費後能得到什麼\n3. **行動**：在 checkout 頁面加上「付費後包含」的清單\n4. **量化**：跳出率降到 35%\n\n如何優化 Landing Page 的轉換率，可以參考 👉 [第 7 章：Landing Page 與 SEO 入門](/blog/ai-solo-builder-landing-page-seo/)\n\n### AI 輔助用戶訪談分析\n\n如果你做了用戶訪談（即使只是跟朋友聊了 20 分鐘），讓 AI 幫你從對話中提取洞察：\n\n```text\n以下是我跟一個用戶的訪談逐字稿（約 20 分鐘的對話）：\n\n[貼上逐字稿或筆記]\n\n請幫我分析這次訪談：\n\n1. 關鍵發現\n   - 用戶遇到的主要痛點（用他們自己的話）\n   - 用戶目前的替代方案（他們怎麼解決這個問題）\n   - 用戶對我們產品的期待 vs. 實際體驗的落差\n\n2. 隱含需求\n   - 用戶沒有直接說出來，但從對話中可以推斷的需求\n   - 用戶以為自己要什麼 vs. 實際上需要什麼\n\n3. 可行動的建議\n   - 基於這次訪談，最值得做的一件事是什麼？\n\n4. 可引用的原話\n   - 列出 3-5 句最有洞察力的原話（未來可用在 Landing Page）\n```\n\n最後一點很實用。用戶自己的話，就是最好的行銷文案。\n\n## 說「不」的藝術\n\n收集回饋最難的部分不是收集，而是**決定不做什麼**。\n\n每一條用戶回饋背後都是一個真實的人、真實的需求。說「不」會讓你覺得虧欠。但 Solo Builder 的時間是有限的，你不可能做所有人要求的所有功能。\n\n### 什麼時候該聽\n\n- **多人獨立提到同一件事**：如果五個不相關的用戶都抱怨同一個問題，那大概率是真的問題\n- **符合你的產品定位**：這個功能讓你的核心價值主張更強嗎？\n- **低成本高影響**：花 2 小時就能讓很多人受益\n- **用戶在流失**：如果流失用戶反覆提到同一個原因，那是緊急信號\n\n### 什麼時候該忽略\n\n- **只有一個人要求**：除非那個人代表了你的核心用戶群\n- **跟產品定位衝突**：「你的部落格平台能不能加購物車功能？」不能，這不是購物車\n- **高成本低影響**：花三個週末做一個只有 5% 用戶會用的功能？不值得\n- **用戶在描述解法而不是問題**：「你應該加一個 X 按鈕」，先問他為什麼需要那個按鈕。也許有更簡單的方式解決他的根本需求\n- **付費前的功能要求**：「如果你加了 X 功能我就付費」。大部分時候，他們不會\n\n### 回覆模板：優雅地說不\n\n你不需要每次都寫一封長信解釋為什麼不做。準備一個模板：\n\n```text\n感謝你的建議！\n\n我有記錄下來，也理解這個功能對你的價值。\n目前我在 [當前的優先項目] 上，\n短期內可能無法加入這個功能。\n\n如果之後有了更新，我會通知你。\n再次感謝你的回饋，這對產品的改進很有幫助！\n```\n\n真誠、簡短、不承諾時間。這比沒有回覆好，也比虛假承諾好。\n\n## 回饋收集的常見陷阱\n\n### 陷阱 1：只聽最大聲的用戶\n\n最常留回饋的用戶不一定代表大多數用戶。他們可能是 power user（需求跟一般用戶不同）、或者是特別不滿的人。\n\n**對策**：搭配行為數據。如果最大聲的用戶要求功能 A，但 analytics 顯示 80% 的用戶連功能 B 都沒用過，也許功能 B 的改進比功能 A 更重要。\n\n### 陷阱 2：把「沒有抱怨」當成「沒有問題」\n\n大部分遇到問題的用戶不會告訴你，他們會默默離開。\n\n**對策**：看流失數據。用戶在什麼時間點離開？離開前最後做了什麼？哪個頁面的跳出率異常高？沉默的行為比大聲的抱怨更能反映真實問題。\n\n### 陷阱 3：太快回應回饋\n\n收到回饋後立刻開始做。三天後又收到另一個回饋，又轉方向。反覆改來改去，什麼都沒做好。\n\n**對策**：設定一個「回饋冷卻期」。收到回饋後不要立刻行動。每月做一次統整分析（用前面的 AI prompt），根據分析結果決定下一步，而不是根據最新一條回饋。\n\n### 陷阱 4：問用戶「你想要什麼功能」\n\n這是最常見但最沒用的問法。用戶不是產品設計師，他們擅長描述問題，不擅長設計解法。\n\n**對策**：問「你最近一次 [使用場景] 遇到什麼困難？」而不是「你希望我們加什麼功能？」。The Mom Test 這本書的核心觀點就是這個：問他們的行為，不要問他們的意見。\n\n## 時間預算：每週 1 小時\n\n最後，把回饋分析納入你的固定時間預算。\n\n| 任務                | 頻率 | 時間           |\n| ------------------- | ---- | -------------- |\n| 掃一眼 GA4 關鍵指標 | 每週 | 10 分鐘        |\n| 讀新的用戶回饋      | 每週 | 15 分鐘        |\n| AI 月度回饋分析     | 每月 | 20 分鐘        |\n| 更新產品 roadmap    | 每月 | 15 分鐘        |\n| **每週平均**        |      | **約 35 分鐘** |\n\n不到一小時。\n\n關鍵是**固定時間做**，而不是隨時被回饋打斷。當你收到一封用戶來信，不要立刻停下手上的開發工作去處理。標記為「待讀」，等到你的回饋時段再統一處理。\n\n批次處理永遠比即時反應有效率。這是上班族 Solo Builder 最需要的紀律。\n\n## 本章重點回顧\n\n- ⚠️ 你不是你的用戶，不要用自己的直覺替代用戶回饋\n- 📊 四層回饋管道：行為數據 → 被動回饋 → 主動收集 → 間接訊號，先建最小系統\n- 🤖 AI 回饋分析：2 分鐘完成分類、情感分析、主題提取、優先排序，每月做一次\n- 🔄 量化發現問題、質性理解問題、量化驗證修復，三步循環\n- ❌ 學會說不：多人獨立提到的才優先做，只有一個人要求的先觀察\n- ⏱️ 每週不到 1 小時，固定時間批次處理，不要被回饋隨時打斷\n\n## 下一步\n\n你現在有了一套回饋收集和分析的系統。你知道用戶在想什麼、要什麼、抱怨什麼。\n\n但用戶的問題不只是「我想要 X 功能」。他們也會在凌晨三點問你：「為什麼我的帳號登不進去？」\n\n下一章，我們來建立**一個人的客服與社群系統**——用 AI 和自動化把客服時間壓到最低，同時不犧牲用戶體驗。\n\n👉 [第 10 章：一個人的客服與社群](/blog/ai-solo-builder-support-community)",
      "summary": "一個人做產品最容易犯的錯是「自己覺得好就好」。本文教 Solo Builder 建立系統化的用戶回饋收集與分析流程，用 AI 自動分類、情感分析與優先排序，每週不到一小時，讓有限時間花在最重要的改進上。",
      "image": "https://bobochen.dev/_astro/cover.CfZCxOur.webp",
      "date_published": "2026-04-05T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "用戶回饋",
        "AI",
        "產品迭代",
        "Analytics"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-14-psychiatric-meds/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-14-psychiatric-meds/",
      "title": "精神科的藥",
      "content_text": "帶哥去看精神科、開了藥、吃了藥。結果越吃越消沉、精神越消迷。藥物治療的兩難：不吃，症狀惡化；吃了，整個人變成行屍走肉。什麼才是「治療成功」？",
      "content_html": "## 帶他去看醫生\n\n帶哥去看精神科，本身就是一場戰爭。\n\n首先，你要讓一個覺得「自己沒有問題」的人承認他需要看醫生。哥不覺得自己有病。在他的認知裡，是世界對他不好——同事排擠他、朋友不理他、社會不接納他。問題在外面，不在他身上。\n\n你怎麼跟一個「問題在外面」的人說「你需要看精神科」？\n\n媽和我試了很多方法。有時候是溫柔地勸，有時候是直接地講，有時候是趁他心情比較低落、比較願意接受幫助的時候趕快約門診。\n\n有一次我用了一個策略：自己先掛號，跑進去跟醫生聊。在哥進診間之前，先跟醫生前情提要——家裡的狀況、哥的行為模式、我們觀察到的異常。這樣醫生至少有個底，不用只聽哥自己的版本。\n\n因為如果只聽哥說，他會跟醫生說「我沒事，是家人太誇張」。\n\n這招在仁愛醫院用過一次。不知道有沒有幫助，但至少醫生有了比較完整的資訊。\n\n最後真正帶他坐在診間裡的那次，他的狀態已經很差了。差到連他自己都覺得「也許看一下也好」。\n\n## 吃藥\n\n醫生開了藥。什麼藥我不是專家、不太記得名字了，但大致上是抗精神病藥物和穩定情緒的藥。\n\n吃了之後，確實有些症狀改善了。那些「別人在說我壞話」「有人在跟蹤我」的念頭，稍微少了一點。\n\n但代價是：哥整個人變了。\n\n不是變好了。是變「鈍」了。\n\n反應變慢、表情變少、對什麼事情都提不起興趣。以前他至少還會抱怨——抱怨代表他還有情緒、還在乎。吃了藥之後，連抱怨都少了。不是因為心情好了，而是因為好像什麼感覺都被藥壓下去了。\n\n越吃越消沉。精神越來越消迷。\n\n他跟我說：「吃了藥之後，我覺得自己像一個空殼。」\n\n## 兩難\n\n這就是精神科藥物最殘酷的兩難：\n\n**不吃**：症狀惡化。那些幻覺、妄想、被害感會越來越嚴重，可能影響他的安全。\n\n**吃了**：症狀被壓制，但整個人也被壓制了。情緒是平的、動力是零的、活著跟沒活著差別不大。\n\n你問醫生，醫生會說：「可以調藥。」\n\n調了。換了另一種。副作用不同，但困境類似——不是太沉就是太焦。找一個「剛好」的平衡點，比你想像的困難一百倍。\n\n因為每個人的腦袋不一樣、每種藥的反應不一樣、而且效果通常要吃幾週才看得出來。所以你就在那裡等：吃兩週看看、不行再換、再等兩週、再看看。\n\n而這段「等待」的時間，哥還是得活著。帶著那些讓他消沉的藥效，或者帶著那些沒被壓制的症狀，繼續面對每一天。\n\n## 從外面看\n\n作為家人，你在外面看著這一切，感覺很無力。\n\n你帶他去看醫生了——你能做的最大努力。但醫生開的藥讓他更消沉。你跟醫生反映了，醫生調藥了，但新的藥有新的問題。你覺得怎麼做都不對。\n\n而且精神科的看診時間很短。門診量大，每個病人分配到的時間可能只有五到十分鐘。你要在五分鐘內跟醫生說清楚哥這兩週的狀況、藥的副作用、你觀察到的變化——然後醫生快速做判斷、開處方、下一位。\n\n不是醫生不認真。是制度就是這樣。精神科醫療資源不夠，門診量太大。每個病人都需要被好好傾聽，但現實不允許。\n\n有時候我會想：如果我們有錢，可以看自費的精神科、每次看診有四十分鐘而不是五分鐘、可以搭配心理諮商——也許情況會不一樣？\n\n但「如果有錢」這件事，在這個家裡從來就不是一個有效的假設。\n\n## 越來越依賴\n\n另一個問題是：哥開始越來越依賴心理諮商。\n\n不是說心理諮商不好。對很多人來說它是必要的、有效的。但哥的狀況變成了一種循環——他把所有的情緒處理外包給諮商師，自己不做任何改變。花了錢、花了時間，出來之後短暫覺得好一點，然後又回到原本的模式。\n\n就像吃止痛藥：痛的時候吃一顆，不痛了，但造成痛的原因還在那裡。\n\n而那些諮商費用，也不便宜。加上精神科的掛號費、藥費，每個月又是一筆不小的支出。\n\n你想幫他，但幫到最後發現：你在幫一個不願意幫自己的人。那個無力感，比花錢還累。\n\n## 他還在吃嗎\n\n老實說，我不確定哥現在有沒有持續吃藥。\n\n精神科的藥需要長期服用才有效，但很多患者會自己停藥——因為副作用太不舒服、因為覺得好一點了不需要了、因為就是忘了。\n\n我問過幾次，哥的回答很含糊。媽也不太確定。\n\n你沒辦法每天站在旁邊看他吞藥。他是成年人，你不能強迫他。\n\n但你知道：如果他停了藥，那些被壓制的症狀遲早會回來。而回來的時候，可能比之前更嚴重。\n\n這又是一個你控制不了的事情。\n\n照顧精神疾病的家人，跟照顧身體疾病的家人最大的不同在於：身體疾病你可以看到指標——血壓多少、血糖多少、傷口癒合了沒有。精神疾病沒有這些。你只能從他的行為、他的語氣、他的眼神去猜。\n\n而你永遠不確定自己猜得對不對。\n\n## 給有類似經歷的人\n\n如果你的家人也在吃精神科的藥，而效果不如預期：\n\n**第一，不要自行停藥。** 一定要跟醫生討論。突然停藥可能造成反彈，比原本的症狀更嚴重。\n\n**第二，如實跟醫生描述副作用。** 「整個人變很鈍」「睡太多」「完全沒有動力」——這些都是醫生需要知道的。他們才能據此調藥。\n\n**第三，藥物不是唯一的治療。** 心理諮商、職能治療、社區復健——這些搭配藥物效果更好。但在台灣，這些資源的可及性差異很大。\n\n**第四，照顧者也需要被照顧。** 這一點我說了很多次，因為它真的很重要。你不需要一個人扛。",
      "summary": "帶哥去看精神科、開了藥、吃了藥。結果越吃越消沉、精神越消迷。藥物治療的兩難：不吃，症狀惡化；吃了，整個人變成行屍走肉。什麼才是「治療成功」？",
      "image": "https://bobochen.dev/_astro/cover.DN6lnSK1.webp",
      "date_published": "2026-04-05T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "精神疾病",
        "思覺失調症"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-15-nobody-talks-to-me/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-15-nobody-talks-to-me/",
      "title": "沒有人跟他講話",
      "content_text": "哥整天說「沒有人跟我講話」「大家都不理我」。群組裡的訊息轟炸，背後其實是極度的孤獨。思覺失調症讓他的社交世界越縮越小，最後只剩下一個 LINE 群組和兩個不知道怎麼回應的家人。",
      "content_html": "## 同一句話\n\n哥說得最多的一句話是：\n\n**「沒有人跟我講話。」**\n\n這句話出現在 LINE 群組裡、出現在電話裡、出現在他偶爾碰到我或媽的時候。\n\n不是抱怨的語氣。是陳述事實的語氣。就像在說「今天下雨了」一樣平淡。\n\n但你聽久了就知道，那裡面有一整座冰山。\n\n## 社交的萎縮\n\n思覺失調症會慢慢吃掉一個人的社交能力。\n\n不是一夜之間消失的。是像退潮一樣，一點一點地退。\n\n先是朋友。哥本來就不多朋友，生病之後更少了。他會覺得朋友在背後說他壞話，然後主動疏遠。或者因為長期失業，不好意思跟還在上班的朋友聯繫——你怎麼跟人聊天？人家問你「最近在幹嘛」，你說「沒工作」，然後就沒下文了。\n\n再來是同事。因為工作一直換，沒有一個地方待得夠久、深到可以交到朋友。每到一個新環境，他的症狀又會讓他覺得別人在排擠他，然後離開。\n\n最後只剩下家人。而家人裡，媽年紀大了、不太會用手機、也不知道怎麼跟一個有精神疾病的兒子聊天。我呢——我有自己的工作和家庭，能回應的時間和精力有限。\n\n所以哥的社交世界，最後縮小到只剩一個 LINE 群組。\n\n而那個群組裡，只有我和媽。\n\n## 群組訊息的另一面\n\n在輯三「500 元的界線」那篇裡，我寫了哥在群組裡的訊息轟炸有多讓人疲憊。那些都是真的。\n\n但知道他有思覺失調症之後，我重新看那些訊息，看到了不同的東西。\n\n「又失業了。」→ 他在一個充滿幻覺的世界裡，好不容易鼓起勇氣去上班，但又撐不住了。\n\n「沒有人跟我講話。」→ 他的社交世界已經縮小到只剩我們，而我們也快撐不住了。\n\n「人生好沒意義。」→ 他的腦袋同時要跟現實和幻覺搏鬥，每天醒來都是一場消耗戰。\n\n「你們都不懂我的感受。」→ 他是對的。我們真的不懂。因為我們沒有辦法進到他的腦袋裡，去看他看到的世界。\n\n這不代表我之前設的界線是錯的。我依然需要保護自己的心理健康。但我也理解了：那些讓我崩潰的訊息，對他來說可能是唯一的求救方式。\n\n他不是在情緒勒索。他是在溺水。\n\n而溺水的人會亂抓。不是因為他想把你拉下去，是因為他快要沉下去了。\n\n## 明信片和白板\n\n有兩個畫面我一直忘不掉。\n\n第一個是：哥寄了一張明信片給自己。\n\n不是寄給朋友、不是寄給家人。是寄給自己。在明信片上寫了幾句話，寄到自己的地址，然後等郵差送來。\n\n我不知道他寫了什麼。但光是「一個人寄明信片給自己」這件事本身，就讓人心裡很不好受。那是一個連收信人都找不到的人，最後把收信人填成了自己。\n\n第二個是：他房間的白板。\n\n萬華老家他的房間牆上掛了一塊白板，上面寫滿了密密麻麻的字。不是什麼計畫或工作清單。是一些你看了也不太懂的東西——零碎的念頭、不連貫的句子、畫了又擦、擦了又畫。\n\n我站在他房間門口看那塊白板，心裡想的是：他的腦袋裡面，大概就是這個樣子吧。混亂的、擁擠的、找不到出口的。\n\n## 孤獨的兩端\n\n這件事最讓我心酸的地方是：孤獨是雙向的。\n\n哥覺得沒有人跟他講話。但同時，他的行為讓願意跟他講話的人越來越少。\n\n因為跟他對話很消耗。他會重複同樣的話題、會把對話導向負面、會在你試著轉換話題的時候把它拉回來。不是故意的，但結果就是：每次聊完你都覺得被抽乾了。\n\n久了之後，你會開始迴避。不是不關心，是你的電池也沒電了。\n\n然後他就更孤獨了。然後他就更依賴那個 LINE 群組。然後你就更想逃。\n\n這是一個雙方都在受苦的惡性循環。\n\n## 有什麼辦法\n\n說真的，我到現在也沒找到完美的解法。\n\n但有幾件事我覺得有幫助：\n\n**社區復健中心**：有些縣市有「社區復健中心」或「日間型精神復健機構」，提供結構化的活動和社交機會。對思覺失調症的患者來說，這種「有人在、有事做」的環境比獨自在家好很多。\n\n**固定的互動頻率**：不是每天回應他的訊息，而是固定時間打一通電話。比如每週三晚上八點打十分鐘。有規律比有回應更重要。他會知道「週三會有人打來」，這個確定感本身就有安定的效果。\n\n**降低期望**：不要期待「治好」。思覺失調症是慢性病，目標不是痊癒，是「過得去」。他今天出門了→好事。他今天自己煮了一餐→好事。他今天沒有傳負面訊息→也是好事。\n\n**不要把他的病當成你的失敗。** 你沒辦法救他。你能做的是陪伴，而且是你能力範圍內的陪伴。超出範圍的，你得放手。\n\n這最後一點最難。\n\n因為他是你哥。因為他在溺水。因為你知道如果你放手，他可能會沉下去。\n\n但你也知道：如果你不放手，你也會被拉下去。而你下面還有兩個小孩需要你浮在水面上。\n\n三明治世代的另一種夾心：夾在想幫和必須放手之間。",
      "summary": "哥整天說「沒有人跟我講話」「大家都不理我」。群組裡的訊息轟炸，背後其實是極度的孤獨。思覺失調症讓他的社交世界越縮越小，最後只剩下一個 LINE 群組和兩個不知道怎麼回應的家人。",
      "image": "https://bobochen.dev/_astro/cover.BZ6nkX3B.webp",
      "date_published": "2026-04-05T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "精神疾病",
        "思覺失調症"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-18-monthly-38k/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-18-monthly-38k/",
      "title": "每月三萬八",
      "content_text": "每月三萬八，全給養護中心。長照帳單足以把一個中產家庭拖進深淵。而政府的補助，因為我有工作，被判定「不夠窮」。",
      "content_html": "## 帳單\n\n爸從台大醫院出院後，轉到養護中心。\n\n從那一天起，每個月多了一張三萬八千元的帳單。\n\n不是一次性的。是每個月。每個月。每個月。\n\n\n爸住的是新北市中和的一間養護中心，四人房。\n\n一年就是約四十六萬。\n\n後來我們有申請到衛福部的「住宿式服務使用者補助方案」——長照需要等級達 4 級以上的住民，每人每年補助 12 萬元，而且取消排富。這筆錢 2025 年初確實有入帳。\n\n但一年 12 萬，分攤到每月就是一萬。月費三萬八減一萬，還是二萬八。說真的，政府有補助已經很感激，但其實也只是杯水車薪。\n\n## 不夠窮\n\n哥哥抱怨說他申請政府的低收入戶補助。\n\n結果被拒了。\n\n理由是：社會局審查會把直系親屬的收入列入計算。因為我有正職工作，弟弟的月薪被算進了「家庭所得」，結果超過了低收入戶的門檻。\n\n你看到這個制度的荒謬了嗎？\n\n我每月的薪水，在扣掉自己家庭的房租、伙食、小孩的學費、基本生活開銷之後，剩下的根本不夠支付三萬八的養護費用。但因為「帳面上的收入」看起來過了門檻，所以我「不夠窮」。\n\n**這個制度懲罰的是那些還在努力工作的人。**\n\n如果我辭掉工作、零收入，也許就能申請到補助了。但那樣的話，誰養我的太太和小孩？\n\n這是一個不可能的方程式。你必須同時工作來養活自己的家庭，又必須負擔上一代的長照費用，而政府的補助在你最需要的時候告訴你：你不夠格。\n\n## 台大醫院社工室\n\n在這個過程中，台大醫院的社工室是最大的救命稻草。\n\n出院之前，社工幫我們申請了緊急救助金，幫我們找到可以接收有鼻胃管和尿管的養護中心，幫我們理清楚可以申請的各種補助和減免。\n\n如果不是社工，我大概會在那堆表格和流程裡迷路。因為你在情緒最崩潰的時候，還要去搞懂什麼是「身障鑑定」、什麼是「輔具補助」、什麼表要寄到哪個單位。光是理清楚流程就花了好幾天。\n\n社工的存在，讓我理解一件事：在台灣的長照體系裡，「知道怎麼找資源」和「不知道」之間的差距，可能是好幾萬塊的差距。\n\n很多家庭不是不需要幫助，而是不知道可以找誰幫忙、不知道有哪些資源、不知道怎麼填那些該死的表格。\n\n## 分攤\n\n理論上，養護費用應該由我和哥分攤。\n\n實際上，哥大部分的時間是失業的。即使有工作，也是短期的代課或打工，收入不穩定。我不可能跟他要每月一萬九的分攤。\n\n所以實際的情況是：我出大部分，哥出他能出的。\n\n而「他能出的」有多少，取決於他當月有沒有工作。有工作的月份可能出個幾千塊，沒工作的月份就是零。\n\n更讓人火大的是：爸每個月有一筆老人年金 4,473 元，本來可以幫忙分攤一點點月費。結果 2023 年五、六月的錢被哥領走了。連這種錢也要 A。後來 2024 年九月的又被他領走。\n\n所以那三萬八，大部分的壓力落在我身上。\n\n## 數字以外的重量\n\n三萬八，是一個數字。但數字以外還有重量。\n\n每個月繳費的時候，你會想：這筆錢如果拿去存起來，榕和辰的教育基金就會多一點。如果拿去投資，退休就會早一點。如果拿去帶家人出去玩，回憶就會多一點。\n\n但它全部流進了一個你知道不會有回報的地方。\n\n不是說照顧爸不應該。而是這個費用已經超過了「合理的負擔」，進入了「慢性失血」的範圍。你不會一次倒下，但你會每個月少一點、少一點、少一點，直到某天你發現自己也快要撐不住了。\n\n一年四十六萬。爸的出血性中風術後平均存活八到十年。算一下：三百七十萬到四百六十萬。\n\n沒有幾個家庭準備好了這個數字。\n\n## 給正在算帳的人\n\n如果你現在也在面對類似的帳單，我想讓你知道：\n\n**第一，去找醫院的社工。** 不管你在哪家醫院，都有社工室。他們知道所有可以申請的補助、減免、資源。不要不好意思找，這就是他們的工作。\n\n**第二，申請身障鑑定。** 中風後如果有肢體或認知功能障礙，可以申請身障手冊。有了手冊之後，可以減免一些費用、申請輔具補助。\n\n**第三，問一下長照 2.0。** 撥打 1966 長照服務專線，看看你的家人符合哪些服務項目。\n\n**第四，不要一個人扛。** 如果有兄弟姐妹，即使他們的經濟能力有限，也要把分攤的原則說清楚。不說清楚，最後一定是一個人在扛。\n\n這些建議聽起來很實務、很冷靜。但我是在不冷靜的時候慢慢學到的。\n\n希望你可以比我早一步知道這些事。",
      "summary": "每月三萬八，全給養護中心。長照帳單足以把一個中產家庭拖進深淵。而政府的補助，因為我有工作，被判定「不夠窮」。",
      "image": "https://bobochen.dev/_astro/cover.BLdEdZBu.webp",
      "date_published": "2026-04-05T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "長照",
        "社會議題"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-13-not-lazy/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-13-not-lazy/",
      "title": "好逸惡勞的哥和他的病",
      "content_text": "哥就是不努力、好逸惡勞、眼高手低。做什麼工作就罵什麼工作，被前公司告，自己也去告前公司。但他也有思覺失調症。這兩件事同時為真，才是最讓人崩潰的地方。",
      "content_html": "## 就是不努力\n\n我不打算美化這件事。\n\n哥就是不努力。好逸惡勞、眼高手低。做什麼工作就罵什麼工作——餐廳太累、工廠太無聊、學校代課太麻煩。每一份工作他都有一百個理由告訴你為什麼那裡不好，但從來不會反省自己的問題。\n\n他不只是辭職。他跟前公司鬧到對方去告他，他也反過來去告前公司。把每一段工作關係都搞到焦土的程度。\n\n好逸惡勞是真的。眼高手低是真的。做什麼罵什麼也是真的。\n\n但他也有思覺失調症。這也是真的。\n\n**這兩件事同時為真，才是最讓人崩潰的地方。**\n\n你沒辦法簡單地說「他就是懶所以不用同情」，也沒辦法說「他有病所以一切都可以原諒」。真相夾在中間——一半是性格問題、一半是疾病——而你永遠分不清楚哪些行為是他的選擇、哪些是他的病在作祟。\n\n## 從小的軌跡\n\n哥從小就是那種「跟別人不太一樣」的小孩。\n\n不是特別調皮、不是特別笨。就是⋯⋯有些地方怪怪的。反應比較慢、人際關係比較困難、面對壓力的時候會完全當機。\n\n小時候我們都把這歸因於爸。\n\n爸酗酒、家暴、不負責任。在這樣的環境裡長大，任何小孩都會受影響。哥比我大，承受的時間更長。而且媽在無意間過度保護他——每次他遇到困難，媽就出面解決——所以他從來沒學會自己面對。\n\n我一直以為這就是全部的原因：環境造成的心理問題。\n\n但後來的發展讓我開始懷疑：也許不只是環境。\n\n## 越來越嚴重\n\n長大之後，哥的狀況不是慢慢好轉，是慢慢惡化。\n\n工作一直換。每份工作做幾個月就出問題——跟同事起衝突、覺得別人在排擠他、覺得老闆故意刁難他。然後開始罵那份工作，最後不是被辭就是自己辭。\n\n最誇張的一次，他跟前公司鬧到互相提告。前公司告他，他也去告前公司。一份普通的工作可以搞到上法院，你就知道他處理人際關係的能力有多災難。\n\n我跟他講了 N 百次要刪掉 Facebook——因為他會在上面發一些讓人擔心的東西、跟陌生人起衝突、或者把情緒全部倒在公開的動態上。每次講，他都說好，然後什麼都沒做。N 百次，一次都沒成功。\n\n一開始我覺得他是抗壓性低、又自以為是。後來我慢慢注意到，他描述的那些「被排擠」「被針對」，有些聽起來不太對。不像是真實發生的事，更像是他腦袋裡建構出來的劇情。\n\n他會說「同事都在背後說我壞話」，但你問他具體聽到什麼，他說不出來。他會說「老闆故意給我最難的工作」，但那些工作可能每個人都在做。\n\n那時候我還不知道這叫什麼。只是覺得哥的世界跟現實有一段距離。\n\n然後是那些不斷的訊息——「沒有人跟我講話」「大家都不理我」「人生沒有意義」——每天、每天、每天。\n\n不是偶爾的低落。是持續的、重複的、像被困在一個迴圈裡出不來。\n\n## 思覺失調\n\n後來我們帶哥去看了精神科。\n\n醫生做了評估之後，告訴我們：思覺失調症。\n\n我不知道你聽到這個詞的時候會想到什麼。很多人聽到「思覺失調」或以前叫的「精神分裂」，腦中浮現的是電影裡那些誇張的畫面——暴力、失控、危險。\n\n但真實的思覺失調症不是那樣。至少哥不是那樣。\n\n他不會暴力。他不危險。他只是⋯⋯很辛苦。\n\n他的腦袋會告訴他一些不是真的事情。他會覺得別人在看他、在討論他、在針對他。他會聽到一些聲音或感受到一些東西，但那些東西並不存在。\n\n而他分不清楚哪些是真的、哪些是腦袋製造的。\n\n這就是為什麼他「工作一直換」——不是因為他懶，是因為他在每個工作環境裡都覺得自己被敵意包圍。那種被包圍的感覺是假的，但對他來說是真的。\n\n你怎麼在一個「所有人都在害你」的環境裡安心工作？\n\n## 灰色地帶\n\n有人可能會問：那他到底是「習得無助」還是「思覺失調」？\n\n老實說，我覺得沒有一條清楚的線。\n\n小時候的環境確實造成了他的性格問題——依賴、逃避、無法面對困難。但在這些性格問題的底下，可能一直都有生理層面的東西在運作。\n\n也許是遺傳。也許是壓力觸發了某些本來就存在的因子。也許兩者都有。\n\n精神科醫生也沒辦法告訴你一個確切的因果關係。他們只能告訴你：目前的症狀符合思覺失調症的診斷標準。\n\n至於這些症狀是怎麼來的——是環境、基因、還是兩者的交互作用——沒有人知道。\n\n這就是精神疾病最讓人無力的地方：你甚至不知道敵人長什麼樣子。\n\n## 兩面並存\n\n知道哥有思覺失調症之後，事情沒有變得更簡單。反而更複雜了。\n\n因為你沒辦法用「他有病」來解釋所有的行為。\n\n他好逸惡勞——這是性格。不是每個思覺失調症患者都好逸惡勞。\n他眼高手低——這是態度。不是每個精神病患都眼高手低。\n他做什麼罵什麼——這是習慣。在生病之前他就是這樣。\n\n但同時：\n\n他覺得所有人都在害他——這是症狀。\n他沒辦法在一個地方待超過幾個月——這可能有疾病的成分。\n他的社交能力持續退化——這不是他選擇的。\n\n性格問題和疾病糾纏在一起，像兩條繩子擰成一股，你拆不開。\n\n這就是為什麼我說「這兩件事同時為真」——他確實不努力，他也確實有病。你不能只看一面。只看「不努力」那面，你會覺得他活該。只看「有病」那面，你會無限度地原諒他。\n\n兩面都看，你才會抵達一個比較真實的位置：**心疼，但也設界線。理解，但不縱容。**\n\n這個位置很不舒服。但比任何一邊的極端都誠實。\n\n而誠實，是這本書唯一的承諾。\n\n有一次表姐傳訊息來關心哥的狀況。我打了一段很長的回覆——哥和爸兩個人比較合得來，想法和觀念比較接近，很多事都講不聽，細節三言兩語道不盡。\n\n打到最後，只剩下一句話：\n\n**「我和媽已經有共識，想要好好正常生活。人生不應該再被他們兩個給折磨。至少，我和媽要可以過正常生活。」**\n\n按下送出的那一刻，我覺得自己很狠。但同時也覺得：這是我這輩子說過最誠實的話。",
      "summary": "哥就是不努力、好逸惡勞、眼高手低。做什麼工作就罵什麼工作，被前公司告，自己也去告前公司。但他也有思覺失調症。這兩件事同時為真，才是最讓人崩潰的地方。",
      "image": "https://bobochen.dev/_astro/cover.DTiVExLE.webp",
      "date_published": "2026-04-04T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭",
        "精神疾病",
        "思覺失調症"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-17-hope-is-cruelest/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-17-hope-is-cruelest/",
      "title": "希望是最殘酷的",
      "content_text": "2023 年 3 月 27 日，爸腦出血倒在家裡。醫生給了兩個選項：不開刀，大概就走了；開刀，可能不會醒來，需要長期呼吸器。我們選了救。但「救成功」的意思，和我們想像的完全不同。",
      "content_html": "> 「希望是所有邪惡中最糟糕的，因為它延長了人類的折磨。」—— 尼采\n\n我到後來才明白這句話的意思。\n\n## 那通電話\n\n2023 年 3 月 27 日，星期一，我在上班。\n\n下午兩點多，手機響了，是哥。哥平常不會在上班時間打給我，所以我接起來的時候就知道不是好事。\n\n「爸倒了。腦出血。在台大醫院急診。」\n\n哥的聲音在發抖。\n\n我請了假，搭捷運到台大醫院。一路上腦子裡轉的不是擔心，是一種很奇怪的平靜。也許是因為，對於這個家，我已經預期了太久的「遲早會出事」。\n\n爸長年喝酒、不運動、不看醫生。高血壓放著不管，身體早就是一顆定時炸彈。\n\n只是你不知道它什麼時候會爆。\n\n## 兩個選項\n\n到了醫院，醫生把我和哥叫到旁邊，很直接地說了兩個選項：\n\n**選項一：不開刀。** 血塊會繼續累積，壓迫腦幹。白話講，就是大概撐不了多久。\n\n**選項二：開刀。** 手術清除血塊。但腦出血的位置很深，就算手術成功，醒來的機率也不高。術後大概率需要長期插管——鼻胃管灌食、尿管、可能還要呼吸器。而且這種出血性中風的患者，術後平均存活 8 到 10 年。\n\n8 到 10 年。\n\n醫生說這個數字的時候，我以為他在告訴我一個好消息——「還可以活很久」。後來我才知道，那是最沉重的數字。\n\n## 我們選了救\n\n哥猶豫了很久。他一直問醫生同樣的問題：「開刀的話，有沒有可能醒來？」醫生每次都給同樣的答案：「機率很低，但不是零。」\n\n「不是零。」\n\n就是這三個字害了我們。\n\n因為只要不是零，你就會覺得有希望。只要有希望，你就不敢放手。如果你選了不救，然後他本來有機會醒來呢？你怎麼跟自己交代？你怎麼跟媽交代？\n\n媽倒是很清楚。她從一開始就說：「不要救了。」\n\n她認識爸比我們久。她知道這個男人的身體早就垮了。她也知道，「救成功」之後的日子，比「沒救成」更痛苦。\n\n但我們沒聽她的。\n\n我和哥簽了手術同意書。\n\n## 「成功」的意思\n\n手術做了好幾個小時。\n\n醫生出來的時候說：手術成功，血塊清除了。\n\n我鬆了一口氣。但那口氣，只維持了大概三秒鐘。\n\n因為接下來醫生說的是：「他目前沒有自主呼吸的能力，需要持續使用呼吸器。意識方面，我們還需要觀察。」\n\n觀察了幾天、幾週。爸沒有醒來。\n\n「手術成功」的意思是：他沒有死在手術台上。但他也不會回來了。\n\n他躺在加護病房，身上插著鼻胃管、尿管、點滴，機器穩定地發出嗶嗶聲。如果你不看他的臉，只聽那些機器的聲音，你會以為一切都很正常。\n\n但什麼都不正常。\n\n## 每月三萬八\n\n出院之後，爸轉到養護中心。\n\n費用是這樣的：\n\n| 項目         | 月費          |\n| ------------ | ------------- |\n| 養護中心費   | 38,000 元     |\n| **每月合計** | **38,000 元** |\n\n每月三萬八。\n\n這個數字我到現在還記得，因為它像一顆巨石壓在胸口壓了一年半。\n\n我試過申請政府的低收入補助。結果被拒絕了。理由是：審查會把家屬的收入算進去。因為我有工作，我的薪水被算進去之後，我們家的「家庭所得」超過門檻。\n\n也就是說：你努力工作、正常繳稅、收入還過得去——所以你沒有資格得到幫助。\n\n但你的收入真的足以每月多負擔三萬八嗎？沒有人問這個問題。\n\n這是制度最荒謬的地方。它懲罰那些還在努力撐著的人。真正什麼都不做的人，反而可能比你更容易拿到補助。\n\n## 後悔\n\n養護中心的日子是一種特殊的折磨。\n\n不是因為辛苦——辛苦的部分你可以處理，排班、繳費、每月去簽文件。真正折磨你的，是那個你不敢問自己的問題：\n\n**我們是不是做錯了？**\n\n爸躺在那裡，沒有意識，不會痛也不會笑。對他來說，也許什麼感覺都沒有。但對我們來說，每個月的帳單、每次去養護中心看到他、每次被問「你爸最近怎麼樣了？」——都是在提醒你那個下午做的決定。\n\n坦白說，我後悔了。\n\n我後悔沒有聽媽的話。\n\n這句話說出來很殘忍，我知道。一個兒子說「我後悔救了我爸」，這在任何文化裡都不是可以被接受的話。但如果你經歷過這些，你可能會理解。\n\n我不是後悔爸還活著。我是後悔讓他以這種方式活著——沒有意識、沒有尊嚴、靠管子維持生命，而家人在外面被經濟壓力慢慢磨碎。\n\n這不是活著。這是機器在運轉。\n\n## 最後的共識\n\n大概在第一年過去之後，我們家達成了一個共識：\n\n**如果爸二次中風，不急救。**\n\n說出這個共識的那一刻，所有人都沉默了。但那個沉默不是悲傷，是如釋重負。\n\n因為每個人心裡都想了很久，只是沒有人敢先說。\n\n## 平靜地離開\n\n2024 年 10 月 21 日，爸走了。\n\n不是二次中風，是身體各項功能慢慢地、平靜地停了下來。\n\n養護中心打電話來的時候，我沒有哭。哥也沒有。媽更沒有。\n\n不是因為不愛他。是因為我們已經送走他太多次了——每次去養護中心看到他躺在那裡，都是一次道別。到最後，當身體真的停下來的時候，反而像是一個遲來的句點。\n\n後來有人跟我說：「你爸這一生也算是無掛無礙，最後一程也是平靜地離開。」\n\n我想了想，好像是這樣。\n\n爸不需要做決定、不需要還債、不需要面對任何人。那些他活著時搞砸的事情，在他失去意識的那一刻就都結束了。\n\n真正承擔後果的，從來都是留下來的人。\n\n## 如果是你，會怎麼選擇\n\n我寫這篇文章，不是要告訴你答案。因為這個問題沒有正確答案。\n\n但我想讓你知道幾件事：\n\n**第一，「搶救成功」不等於「恢復正常」。** 在電視劇裡，開完刀就醒來了，接下來是感人的病房對話。現實不是這樣。很多時候，搶救成功只是把死亡變成了漫長的、昂貴的、沒有盡頭的等待。\n\n**第二，長照的經濟負擔是毀滅性的。** 每月三萬八，一年四十六萬。這個數字會把一個中產家庭拖進深淵。而政府的補助制度，在你最需要的時候可能幫不了你。\n\n**第三，不救，不代表不愛。** 這是最難接受的一件事。但有時候，放手才是最深的愛。讓一個人有尊嚴地離開，比讓他插著管子多活十年更需要勇氣。\n\n如果你現在正面對這個選擇，我沒有辦法替你決定。但我希望你知道，不管你選了什麼，你都不需要為此感到罪惡。\n\n因為不管怎麼選，都是錯的。也都是對的。\n\n這就是這種選擇最殘酷的地方。",
      "summary": "2023 年 3 月 27 日，爸腦出血倒在家裡。醫生給了兩個選項：不開刀，大概就走了；開刀，可能不會醒來，需要長期呼吸器。我們選了救。但「救成功」的意思，和我們想像的完全不同。",
      "image": "https://bobochen.dev/_astro/cover.BzBGqWFG.webp",
      "date_published": "2026-04-04T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭",
        "長照"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-multimodal/",
      "url": "https://bobochen.dev/blog/claude-api-guide-multimodal/",
      "title": "多模態輸入：圖片、PDF 與文件處理",
      "content_text": "支援格式（JPEG/PNG/GIF/WebP/PDF）；base64 vs URL 兩種圖片輸入方式；PDF 文件上傳與分析；token 計算與成本；截圖分析、OCR、圖表解讀實戰範例；Python + TypeScript 完整代碼。",
      "content_html": "前幾章我們把 Claude 當純文字模型來用：送進去的是文字，出來的也是文字。但 Claude 的能力遠不止於此。\n\n從 Claude 3 Sonnet 開始，Claude 就是一個真正的多模態模型（multimodal model）。你可以送圖片進去，Claude 看得懂。你可以丟 PDF，Claude 讀得了。你可以同時傳五張截圖，Claude 可以跨圖分析。\n\n這一章我要帶你搞清楚多模態輸入的完整用法，包括技術細節、成本考量，以及我在生產環境中真正使用的做法。\n\n## 什麼是多模態（Multimodal）？\n\n所謂「多模態」，就是模型能處理不同種類的輸入，而不僅限於文字。\n\n對 Claude 來說，目前支援的輸入模態是：\n\n- **文字**（一直支援）\n- **圖片**（JPEG、PNG、GIF、WebP）\n- **PDF 文件**\n\n值得注意的是，截至 2026 年，Claude **不支援影片輸入**。你沒辦法把 mp4 傳給 Claude 分析。如果你的使用場景需要影片分析，目前的做法是把影片截成一系列關鍵幀圖片，再批次傳入——這個方法雖然繁瑣，但可行。\n\n## 圖片輸入的兩種方式\n\nClaude API 接受圖片的方式有兩種：**base64 編碼**和**URL 直接引用**。兩種方式各有適用場景。\n\n### 方式一：Base64 編碼\n\n把圖片轉成 base64 字串，直接嵌入 API 請求裡。\n\n適合：\n\n- 圖片存在本地（不需要先上傳到某個地方）\n- 圖片是動態生成的（例如截圖）\n- 你希望請求完全自包含，不依賴外部 URL\n\n缺點：\n\n- base64 會讓 payload 體積增大約 33%\n- 長圖片會讓請求 JSON 變得很龐大\n\n```python\nimport anthropic\nimport base64\nfrom pathlib import Path\n\nclient = anthropic.Anthropic()\n\n# 讀取圖片並轉換為 base64\nimage_path = Path(\"screenshot.png\")\nimage_data = base64.standard_b64encode(image_path.read_bytes()).decode(\"utf-8\")\n\nmessage = client.messages.create(\n    model=\"claude-opus-4-5\",\n    max_tokens=1024,\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": [\n                {\n                    \"type\": \"image\",\n                    \"source\": {\n                        \"type\": \"base64\",\n                        \"media_type\": \"image/png\",  # 必須正確指定\n                        \"data\": image_data,\n                    },\n                },\n                {\n                    \"type\": \"text\",\n                    \"text\": \"這張截圖裡有什麼錯誤訊息？請詳細說明問題所在。\"\n                }\n            ],\n        }\n    ],\n)\n\nprint(message.content[0].text)\n```\n\n`media_type` 的值必須與實際圖片格式對應：\n\n- `image/jpeg`\n- `image/png`\n- `image/gif`\n- `image/webp`\n\n### 方式二：URL 直接引用\n\n如果圖片已經有一個公開可訪問的 URL，可以直接傳 URL，讓 Claude 自己去抓。\n\n```python\nmessage = client.messages.create(\n    model=\"claude-opus-4-5\",\n    max_tokens=1024,\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": [\n                {\n                    \"type\": \"image\",\n                    \"source\": {\n                        \"type\": \"url\",\n                        \"url\": \"https://example.com/chart.png\",\n                    },\n                },\n                {\n                    \"type\": \"text\",\n                    \"text\": \"這張圖表顯示了什麼趨勢？\"\n                }\n            ],\n        }\n    ],\n)\n```\n\nURL 方式的**限制**：\n\n- URL 必須是公開可訪問的（Claude 伺服器要能下載到）\n- 不支援需要認證的 URL（例如需要登入的 S3 bucket）\n- 不支援本地網路 URL\n\n我在生產環境的習慣是：如果圖片已經在公開 CDN 上，用 URL；如果是用戶上傳的動態圖片或本地生成的，用 base64。\n\n## 圖片大小與 Token 計算\n\n這裡有個很多人忽略的重點：**圖片會消耗 token，而且消耗量與圖片大小成正比**。\n\nClaude 在處理圖片時，內部會把圖片切分成 tiles（磁磚），每個 tile 大約消耗 1500-1600 個 token。\n\n計算規則：\n\n1. 圖片先會縮放到最長邊不超過 1568px\n2. 縮放後，每個 512x512 的 tile 消耗約 1600 tokens\n3. 還有一個固定的基礎成本（base cost）約 2500 tokens\n\n舉個例子：一張 1000x1000 的圖片，大約消耗 4000-5000 tokens。\n\n**實際影響**：如果你的應用需要同時傳多張圖片，token 成本會快速累積。以 Claude Opus 4.5 為例，1000 tokens 輸入大約 $0.015。一張中等大小的圖片就可能消耗 $0.05-0.10。\n\n我的建議：**在傳圖片前先對圖片做壓縮**。對截圖類的分析任務，把圖片壓到 800x600 以下通常不影響分析品質，但可以省下 50% 以上的 token。\n\n```python\nfrom PIL import Image\nimport io\n\ndef compress_image_for_api(image_path: str, max_size: int = 1000) -> tuple[bytes, str]:\n    \"\"\"壓縮圖片以降低 token 消耗，同時保留分析品質\"\"\"\n    img = Image.open(image_path)\n\n    # 計算縮放比例，長邊不超過 max_size\n    ratio = min(max_size / img.width, max_size / img.height, 1.0)\n    if ratio < 1.0:\n        new_size = (int(img.width * ratio), int(img.height * ratio))\n        img = img.resize(new_size, Image.LANCZOS)\n\n    # 轉換為 JPEG（通常比 PNG 小很多）\n    output = io.BytesIO()\n    img.convert(\"RGB\").save(output, format=\"JPEG\", quality=85)\n\n    return output.getvalue(), \"image/jpeg\"\n```\n\n## PDF 文件上傳與分析\n\nPDF 支援是 Claude 相當強大的功能之一。你可以直接把 PDF 丟給 Claude，它能讀懂裡面的文字、表格，甚至是掃描版 PDF（帶有圖片的頁面）。\n\nPDF 的傳入方式和圖片一樣，支援 base64 和 URL 兩種：\n\n```python\nimport anthropic\nimport base64\nfrom pathlib import Path\n\nclient = anthropic.Anthropic()\n\n# 讀取 PDF 並轉換為 base64\npdf_path = Path(\"contract.pdf\")\npdf_data = base64.standard_b64encode(pdf_path.read_bytes()).decode(\"utf-8\")\n\nmessage = client.messages.create(\n    model=\"claude-opus-4-5\",\n    max_tokens=2048,\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": [\n                {\n                    \"type\": \"document\",\n                    \"source\": {\n                        \"type\": \"base64\",\n                        \"media_type\": \"application/pdf\",\n                        \"data\": pdf_data,\n                    },\n                },\n                {\n                    \"type\": \"text\",\n                    \"text\": \"請摘要這份合約的主要條款，特別是付款條件和違約責任。\"\n                }\n            ],\n        }\n    ],\n)\n\nprint(message.content[0].text)\n```\n\n注意幾個細節：\n\n- PDF 用的是 `type: \"document\"` 而不是 `type: \"image\"`\n- `media_type` 是 `application/pdf`\n- PDF 的 token 消耗基本上跟文字提取後的文字量成正比，掃描版 PDF（全圖片）會比文字版 PDF 貴很多\n\n### PDF 分析的限制\n\nClaude 在處理 PDF 時有幾個需要知道的限制：\n\n1. **頁數限制**：目前 Claude 能處理的 PDF 上限大約是 100 頁，超過的頁面會被截斷\n2. **檔案大小**：單個 PDF 不超過 32MB（base64 之前的原始大小）\n3. **掃描版 PDF**：能讀，但品質取決於掃描品質；低解析度的掃描文件可能識別率不佳\n4. **加密 PDF**：無法處理密碼保護的 PDF\n\n## TypeScript 範例\n\n前面的例子都是 Python，但我知道很多人用 TypeScript 開發應用。這裡給一個完整的 TypeScript 範例：\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nconst client = new Anthropic();\n\nasync function analyzeImageWithClaude(imagePath: string): Promise<string> {\n  const imageBuffer = fs.readFileSync(imagePath);\n  const base64Image = imageBuffer.toString('base64');\n\n  // 根據副檔名決定 media_type\n  const ext = path.extname(imagePath).toLowerCase();\n  const mediaTypeMap: Record<string, string> = {\n    '.jpg': 'image/jpeg',\n    '.jpeg': 'image/jpeg',\n    '.png': 'image/png',\n    '.gif': 'image/gif',\n    '.webp': 'image/webp',\n  };\n\n  const mediaType = mediaTypeMap[ext] ?? 'image/jpeg';\n\n  const message = await client.messages.create({\n    model: 'claude-opus-4-5',\n    max_tokens: 1024,\n    messages: [\n      {\n        role: 'user',\n        content: [\n          {\n            type: 'image',\n            source: {\n              type: 'base64',\n              media_type: mediaType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp',\n              data: base64Image,\n            },\n          },\n          {\n            type: 'text',\n            text: '請描述這張圖片的內容，並指出任何值得注意的細節。',\n          },\n        ],\n      },\n    ],\n  });\n\n  return (message.content[0] as { type: 'text'; text: string }).text;\n}\n\nasync function analyzePdfWithClaude(pdfPath: string): Promise<string> {\n  const pdfBuffer = fs.readFileSync(pdfPath);\n  const base64Pdf = pdfBuffer.toString('base64');\n\n  const message = await client.messages.create({\n    model: 'claude-opus-4-5',\n    max_tokens: 2048,\n    messages: [\n      {\n        role: 'user',\n        content: [\n          {\n            type: 'document',\n            source: {\n              type: 'base64',\n              media_type: 'application/pdf',\n              data: base64Pdf,\n            },\n          },\n          {\n            type: 'text',\n            text: '請摘要這份文件的主要內容。',\n          },\n        ],\n      },\n    ],\n  });\n\n  return (message.content[0] as { type: 'text'; text: string }).text;\n}\n\n// 使用範例\n(async () => {\n  const imageAnalysis = await analyzeImageWithClaude('screenshot.png');\n  console.log('圖片分析結果：', imageAnalysis);\n\n  const pdfAnalysis = await analyzePdfWithClaude('report.pdf');\n  console.log('PDF 分析結果：', pdfAnalysis);\n})();\n```\n\n## 多張圖片同時分析\n\nClaude 支援在單次請求裡傳入多張圖片。這個功能在某些場景非常有用，例如：\n\n- 比較兩個設計稿的差異\n- 分析一系列截圖找出 bug\n- 從多張商品圖片生成描述\n\n```python\nimport anthropic\nimport base64\nfrom pathlib import Path\n\nclient = anthropic.Anthropic()\n\ndef load_image_as_base64(path: str) -> dict:\n    \"\"\"輔助函式：載入圖片並轉換為 API 格式\"\"\"\n    img_path = Path(path)\n    img_data = base64.standard_b64encode(img_path.read_bytes()).decode(\"utf-8\")\n\n    ext_to_media_type = {\n        \".jpg\": \"image/jpeg\",\n        \".jpeg\": \"image/jpeg\",\n        \".png\": \"image/png\",\n        \".gif\": \"image/gif\",\n        \".webp\": \"image/webp\",\n    }\n    media_type = ext_to_media_type.get(img_path.suffix.lower(), \"image/jpeg\")\n\n    return {\n        \"type\": \"image\",\n        \"source\": {\n            \"type\": \"base64\",\n            \"media_type\": media_type,\n            \"data\": img_data,\n        }\n    }\n\n# 同時分析三張截圖\nscreenshots = [\"before.png\", \"after.png\", \"error.png\"]\n\ncontent = []\nfor i, screenshot in enumerate(screenshots):\n    content.append(load_image_as_base64(screenshot))\n    content.append({\n        \"type\": \"text\",\n        \"text\": f\"[圖片 {i+1}: {screenshot}]\"\n    })\n\ncontent.append({\n    \"type\": \"text\",\n    \"text\": \"以上三張截圖分別是：before（操作前）、after（操作後）、error（出現的錯誤）。請分析這個問題，找出 before 和 after 的差異，解釋錯誤可能的原因。\"\n})\n\nmessage = client.messages.create(\n    model=\"claude-opus-4-5\",\n    max_tokens=2048,\n    messages=[\n        {\"role\": \"user\", \"content\": content}\n    ],\n)\n\nprint(message.content[0].text)\n```\n\n## 實際應用場景\n\n讓我分享幾個我在實際專案中用過的多模態應用場景：\n\n### 截圖分析（Error Analysis）\n\n這是我用得最多的場景。當使用者遇到錯誤時，讓他們截圖上傳，比手打錯誤訊息準確得多：\n\n```python\ndef analyze_error_screenshot(screenshot_base64: str) -> dict:\n    \"\"\"分析錯誤截圖，返回診斷結果和解決建議\"\"\"\n    message = client.messages.create(\n        model=\"claude-opus-4-5\",\n        max_tokens=1024,\n        messages=[\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\n                        \"type\": \"image\",\n                        \"source\": {\n                            \"type\": \"base64\",\n                            \"media_type\": \"image/png\",\n                            \"data\": screenshot_base64,\n                        },\n                    },\n                    {\n                        \"type\": \"text\",\n                        \"text\": \"\"\"你是一位技術支援工程師。請分析這張錯誤截圖：\n\n1. 描述錯誤訊息的確切內容\n2. 判斷錯誤的可能原因（列出 2-3 個）\n3. 提供解決步驟（按優先順序排列）\n\n請用繁體中文回答，格式清晰。\"\"\"\n                    }\n                ],\n            }\n        ],\n    )\n\n    return {\"analysis\": message.content[0].text}\n```\n\n### 文件 OCR 與結構提取\n\n掃描版合約或表單的結構化資料提取：\n\n```python\ndef extract_invoice_data(invoice_image_base64: str) -> dict:\n    \"\"\"從發票圖片提取結構化資料\"\"\"\n    message = client.messages.create(\n        model=\"claude-opus-4-5\",\n        max_tokens=1024,\n        messages=[\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\n                        \"type\": \"image\",\n                        \"source\": {\n                            \"type\": \"base64\",\n                            \"media_type\": \"image/jpeg\",\n                            \"data\": invoice_image_base64,\n                        },\n                    },\n                    {\n                        \"type\": \"text\",\n                        \"text\": \"\"\"請從這張發票圖片中提取以下資訊，以 JSON 格式回答：\n\n{\n  \"invoice_number\": \"發票號碼\",\n  \"date\": \"日期（YYYY-MM-DD）\",\n  \"vendor\": \"廠商名稱\",\n  \"total_amount\": \"總金額（數字）\",\n  \"currency\": \"幣別\",\n  \"items\": [\n    {\"description\": \"品項說明\", \"quantity\": 數量, \"unit_price\": 單價, \"amount\": 金額}\n  ]\n}\n\n只回答 JSON，不要其他文字。\"\"\"\n                    }\n                ],\n            }\n        ],\n    )\n\n    import json\n    return json.loads(message.content[0].text)\n```\n\n### 設計評審\n\n我在開發流程裡加入 Claude 做自動化設計審查：\n\n```python\ndef review_design_mockup(design_image_base64: str, design_brief: str) -> str:\n    \"\"\"根據設計簡報評審 UI mockup\"\"\"\n    message = client.messages.create(\n        model=\"claude-opus-4-5\",\n        max_tokens=2048,\n        messages=[\n            {\n                \"role\": \"user\",\n                \"content\": [\n                    {\n                        \"type\": \"image\",\n                        \"source\": {\n                            \"type\": \"base64\",\n                            \"media_type\": \"image/png\",\n                            \"data\": design_image_base64,\n                        },\n                    },\n                    {\n                        \"type\": \"text\",\n                        \"text\": f\"\"\"你是一位資深 UX 設計師。\n\n設計簡報：\n{design_brief}\n\n請針對這個 UI mockup 提供專業意見：\n1. 是否符合設計簡報的需求？\n2. 可用性問題（如果有）\n3. 視覺層次是否清晰？\n4. 改進建議（最多三點，按重要性排序）\"\"\"\n                    }\n                ],\n            }\n        ],\n    )\n\n    return message.content[0].text\n```\n\n## Prompt 技巧：讓圖片分析更準確\n\n幾個我實測有效的技巧：\n\n**1. 明確說明圖片的上下文**\n\n不要只說「分析這張圖片」，要告訴 Claude 這是什麼類型的圖片、你想知道什麼：\n\n```\n❌ 差：「請分析這張圖片。」\n✅ 好：「這是一個 React 應用程式的截圖，頁面上出現了一個錯誤訊息。請識別錯誤訊息的完整內容，並推測可能的原因。」\n```\n\n**2. 對多圖片請求，為每張圖片加標籤**\n\n在圖片之後加一個文字說明，讓 Claude 知道每張圖的角色：\n\n```python\ncontent = [\n    image_1_block,\n    {\"type\": \"text\", \"text\": \"[圖1: 設計稿 - 手機版]\"},\n    image_2_block,\n    {\"type\": \"text\", \"text\": \"[圖2: 設計稿 - 桌機版]\"},\n    {\"type\": \"text\", \"text\": \"請比較這兩個版本的設計一致性...\"}\n]\n```\n\n**3. 要求結構化輸出**\n\n對需要從圖片提取資訊的任務，要求 JSON 格式輸出，方便後續處理：\n\n```python\n\"請從這張報表截圖中提取所有數據，以 JSON 格式回答，不要其他說明文字。\"\n```\n\n**4. 分步驟引導複雜分析**\n\n對複雜的圖片分析任務，分步驟引導比一次性提問效果更好：\n\n```python\n# Step 1: 描述\nmessage1 = \"首先，請描述你在這張架構圖中看到的所有元件和連接關係。\"\n\n# Step 2: 分析（帶入 step 1 的結果）\nmessage2 = f\"根據你的描述：{description}\\n現在請分析這個架構有哪些潛在的單點故障（SPOF）。\"\n```\n\n## 影像品質 vs Token 成本的 Tradeoff\n\n我來給你一個實際的對比數據，讓你在專案中做出有依據的決策：\n\n| 圖片尺寸  | 大約 Token 消耗 | Claude Opus 4.5 成本 |\n| --------- | --------------- | -------------------- |\n| 300x300   | ~1,000 tokens   | ~$0.015              |\n| 800x600   | ~3,000 tokens   | ~$0.045              |\n| 1200x900  | ~5,500 tokens   | ~$0.083              |\n| 1920x1080 | ~8,000 tokens   | ~$0.120              |\n\n對大多數截圖分析任務，800x600 的解析度已經足夠。把圖片壓縮到這個尺寸，可以在不影響分析品質的前提下，省下 60% 以上的圖片相關 token 費用。\n\n我的實踐原則：\n\n- **文字識別（OCR 類任務）**：至少 150 DPI，文字要清晰可辨\n- **圖表分析**：800px 寬度通常足夠\n- **UI 截圖分析**：直接原始尺寸，UI 細節很重要\n- **設計評審**：直接原始尺寸，保留細節\n\n---\n\n多模態輸入讓 Claude 從「聊天機器人」變成真正能處理真實世界資料的助手。圖片和 PDF 的支援打開了大量原本無法自動化的使用場景。\n\n不過，多模態請求的 token 消耗也比純文字請求高出不少。在下一章，我們來聊一個能大幅降低 API 成本的技術：**Prompt Caching**。如果你的應用有固定的 system prompt 或長文件，一個好的快取策略可以讓成本直接砍半。",
      "summary": "支援格式（JPEG/PNG/GIF/WebP/PDF）；base64 vs URL 兩種圖片輸入方式；PDF 文件上傳與分析；token 計算與成本；截圖分析、OCR、圖表解讀實戰範例；Python + TypeScript 完整代碼。",
      "image": "https://bobochen.dev/_astro/cover.DaeiAmip.webp",
      "date_published": "2026-04-03T00:00:00.000Z",
      "tags": [
        "Claude API",
        "多模態",
        "Vision",
        "PDF",
        "圖片"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/context-engineering-deep-dive/",
      "url": "https://bobochen.dev/blog/context-engineering-deep-dive/",
      "title": "Context Engineering 深度解析：Tobi Lutke 說對了，Prompt Engineering 已經不夠用",
      "content_text": "Tobi Lutke 把 Prompt Engineering 重新命名為 Context Engineering，這不只是換個詞。當 agent 要自主完成任務，你餵給它的 context 決定一切——從 CLAUDE.md、codebase indexing 到 conversation history management，拆解 context 的六個層次。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第四篇。上一篇：[工具全景圖](/blog/agentic-engineering-tools-landscape-2026)\n\n## 「你不是在寫 Prompt，你是在設計 Agent 的世界觀。」\n\n有一次我花了 30 分鐘寫了一個超完美的 prompt，結果 agent 產出的 code 完全不符合專案慣例。為什麼？因為我忘了在 CLAUDE.md 裡寫明我們用 Tailwind 不用 styled-components。\n\nPrompt 本身無可挑剔——任務描述清楚、驗收條件明確、連 edge case 都考慮到了。但 agent 一拿到任務，就開心地寫了一堆 `styled.div` 和 `css` template literal。理由很簡單：它的 training data 裡，styled-components 是一個非常流行的選擇。在沒有額外資訊的情況下，它做了一個「合理」的預設判斷。\n\n那個 prompt 沒問題。問題出在 **context**。\n\n這個領悟改變了我對 AI coding 的整個理解。Prompt Engineering 教你怎麼「問問題」。Context Engineering 教你怎麼讓 agent 「理解你的世界」。這個差別，決定了 agent 產出的品質上限。\n\n## Prompt Engineering 已死？不，它進化了\n\n2025 年 6 月，Shopify CEO Tobi Lutke 在 X 上發了一條被廣泛轉發的推文：\n\n> 「我不再稱它為 Prompt Engineering。正確的詞是 Context Engineering——為 LLM 任務精心策劃完美的 context，這是一門充滿細節的藝術。」\n\nLutke 的意思不是 prompt 不重要了，而是我們一直把注意力放錯了地方。\n\n傳統的 Prompt Engineering 關注的是「怎麼跟 AI 說話」——用什麼語氣、加什麼前綴、Chain of Thought 怎麼寫。這些技巧在聊天場景下確實有效。但在 agentic workflow 裡，你的 prompt 只是 agent 接收到的資訊的一小部分。\n\n想像你新到一家公司上班。你的主管跟你說「幫我修這個 bug」（這是 prompt）。但你能不能修好，取決於：你有沒有公司的 codebase 存取權（codebase indexing）？你知不知道公司的 coding convention（project config）？你能不能跑測試確認修好了（external tools）？你知不知道這個 bug 之前有人試過什麼方法（conversation history）？\n\n**Prompt 是你說的話。Context 是你說話時的整個環境。**\n\nAnthropic 在其開發者文件裡給了一個精確的定義：\n\n> Context Engineering 是「策劃最小的高訊號 token 集合，最大化你期望結果的可能性。」\n\n注意兩個關鍵詞：**最小的**和**高訊號的**。不是塞越多越好，是要精準地提供 agent 需要的資訊——不多不少，剛剛好。\n\nAndrej Karpathy 後來也呼應了這個概念。在他定義 Agentic Engineering 的時候，他把 Context Engineering 列為最核心的技能之一，因為：\n\n> 「Agent 的行為品質，完全取決於它能看到什麼。你給它看到的世界越精確，它做出的決策就越好。」\n\n如果 [Post 1](/blog/agentic-engineering-what-is-it) 說的「Agentic Engineering 是一門學問」是論點，那 Context Engineering 就是這門學問裡最重要的那一章。\n\n## Context 六層模型\n\n我在一年的實踐中，整理出一個 context 的六層框架。從最穩定（幾乎不變）到最動態（即時產生），每一層需要不同的維護策略：\n\n### Layer 1: System Instructions — 幾乎不變\n\n這是模型的「基本人格」，通常由工具廠商設定。例如 Claude Code 的 system prompt 定義了它是一個軟體工程 agent、可以使用哪些工具、行為準則是什麼。\n\n**你的角色**：這層你幾乎無法控制，但了解它的存在很重要。不同工具（Claude Code vs Cursor vs Codex）的 system instructions 不同，這解釋了為什麼同一個 prompt 在不同工具裡會有不同表現。\n\n**最佳實踐**：不要跟 system instructions 打架。如果你的 CLAUDE.md 裡寫了跟 system instructions 矛盾的指令，結果是不可預測的。了解你的工具的預設行為，然後在上面疊加，而不是覆蓋。\n\n### Layer 2: Project Config — 每個專案設定一次\n\n這是 CLAUDE.md、`.cursorrules`、`AGENTS.md` 這些設定檔。它們定義了專案的「房子規則」：用什麼語言、什麼框架、什麼命名慣例、怎麼 build、怎麼 test。\n\n**我的真實例子**：我的 bobo-blog-2026 的 CLAUDE.md 長這樣（摘要）：\n\n```\n## Commands\nnpm run dev      # Start dev server at localhost:4323\nnpm run build    # Build production site to ./dist/\n\n## Architecture\nStack: Astro 5 + MDX + Tailwind CSS 4 + TypeScript\n\n## Conventions\n- Language: lang=\"zh-TW\" (Traditional Chinese Taiwan)\n- Component files: PascalCase (e.g., PostCard.astro)\n- Use existing CSS custom properties over hardcoded values\n```\n\n就這麼簡單。但少了這些，agent 可能用 Next.js 的方式寫 Astro code、用 CSS-in-JS 寫 Tailwind 專案、用英文寫中文部落格。\n\n**最佳實踐**：從少開始，需要時再加。第一版只需要 build/test 指令和核心技術棧。當你發現 agent 重複犯同一種錯的時候，那就是該加一條 rule 的時機。\n\n### Layer 3: Codebase Indexing — Agent 自動探索\n\n這是 agent 對你的程式碼庫的理解。好的 agent 工具會自動做這件事——讀取檔案結構、分析依賴關係、識別設計模式。Claude Code 在開始工作前會自動 `glob` 和 `grep` 來了解 codebase。\n\n**為什麼它重要**：如果 agent 不知道你已經有一個 `formatDate()` utility，它就會自己寫一個新的。如果它不知道你的 API 有統一的 error response 格式，它就會自己發明一個。\n\n**最佳實踐**：保持 codebase 的結構清晰。好的資料夾命名、一致的設計模式、有意義的函數名——這些不只幫人類理解 code，也幫 agent 更準確地索引和推斷。\n\n### Layer 4: Conversation History — 每個 Session 累積\n\n這是你跟 agent 的對話紀錄。隨著一個 session 的進行，context window 裡會累積越來越多的對話、程式碼片段、工具呼叫結果。\n\n**陷阱**：conversation history 是最容易失控的一層。一個長 session 跑下來，context window 可能有 70% 都是過時的對話。Agent 可能在第 50 輪對話時，還在參考第 5 輪的過時資訊做決策。\n\n**最佳實踐**：\n\n- 長任務拆成多個 session，而不是一個超長 session\n- 利用 context compaction（Claude Code 會自動壓縮舊對話）\n- 重要決策在 CLAUDE.md 或 plan 檔案裡持久化，不要只靠對話記憶\n\n### Layer 5: External Tools — 按需載入\n\n這是 MCP（Model Context Protocol）servers、API 回應、外部文件等。Agent 可以透過 tools 即時查詢資料庫、呼叫 API、讀取 Notion 文件。\n\n**為什麼革命性**：傳統的 LLM 只能用 training data 裡的知識。MCP 讓 agent 可以「伸手到外面」取得即時資訊。你的 agent 不再受限於它的知識截止日期，它可以查詢你的 production database、讀取你的 Jira ticket、甚至操作你的 Chrome 瀏覽器。\n\n**最佳實踐**：不要一次掛太多 MCP servers。每個 tool 的 schema 描述都會佔用 context window。我的經驗是一個 session 掛 3-5 個最常用的就好，其他的需要時再啟用。\n\n### Layer 6: Runtime State — 即時產生\n\n這是測試結果、error logs、git diff、build output 等即時產生的資訊。Agent 跑一次 `npm run test`，失敗的測試結果就變成新的 context，引導它修正方向。\n\n**為什麼關鍵**：Runtime state 是 agent 「自我修正」的基礎。沒有它，agent 就是在瞎猜。有了它，agent 可以進入一個 write → test → fix → test 的迴圈，逐步收斂到正確的解。\n\n**最佳實踐**：確保你的 test、lint、type check 指令是可以讓 agent 執行的。如果跑測試需要手動設定環境變數或啟動 Docker container，agent 就無法利用 runtime state 來自我修正。\n\n### 六層總覽\n\n| Layer                   | 內容               | 變動頻率        | 維護責任      |\n| ----------------------- | ------------------ | --------------- | ------------- |\n| 1. System Instructions  | 模型行為規範       | 幾乎不變        | 工具廠商      |\n| 2. Project Config       | CLAUDE.md、rules   | 每專案一次      | 你            |\n| 3. Codebase Indexing    | 檔案結構、patterns | 隨 code 變動    | Agent 自動    |\n| 4. Conversation History | 對話、先前回應     | 每 session 累積 | 自動 + 你管理 |\n| 5. External Tools       | MCP、API、外部文件 | 按需載入        | 你設定        |\n| 6. Runtime State        | 測試、logs、diff   | 即時產生        | 自動          |\n\n## CLAUDE.md：從 10 行到 200 行的演化史\n\n我用 Claude Code 一年了。如果要選「一件最影響生產力的事」，答案不是學會什麼進階 prompt technique，而是認真維護我的 CLAUDE.md。\n\n這是它的演化歷程：\n\n### V1（2025 年初）：3 行\n\n```markdown\nnpm run dev\nnpm run build\nnpm run test\n```\n\n就是三個指令。Agent 知道怎麼啟動專案、怎麼 build、怎麼跑測試。其他的？全部自己猜。結果可想而知——每次 session 開頭都要花 10 分鐘跟 agent 解釋同樣的事情。\n\n### V2（2025 年中）：40 行\n\n```markdown\n## Commands\n\nnpm run dev / npm run build / npm run test\n\n## Architecture\n\n- Astro 5 + TypeScript + Tailwind\n- File-based routing in src/pages/\n\n## Conventions\n\n- PascalCase components\n- Use CSS custom properties\n- lang=\"zh-TW\"\n```\n\n加了架構說明和 coding conventions。立竿見影——agent 不再寫出 Next.js style 的 code 了。\n\n### V3（2025 年末）：100 行 + 多層架構\n\n開始用 global CLAUDE.md + per-project CLAUDE.md + rules files。全域設定管通用的 coding philosophy（偏好 composition over inheritance、test-driven 等），專案設定管具體技術棧。\n\n### V4（2026 年現在）：200 行 + skills + hooks + memory\n\n完整系統：\n\n- **Global CLAUDE.md**（~100 行）：開發哲學、通用 conventions、CI/CD 習慣\n- **Project CLAUDE.md**（~50 行）：每個 repo 的技術棧、build 指令、特殊模式\n- **Skills**（23 個）：可重用的 agent 能力模組，用 `/skill-name` 觸發\n- **Hooks**：pre-commit review、deploy 前檢查等自動化流程\n- **Memory system**：跨 session 的持久化記憶\n\n從 3 行到這個規模，不是因為我喜歡寫設定檔，而是因為每一行都是用踩坑換來的。Agent 連續三次在 Astro component 裡寫 React 的 `useState`？加一條 rule。Agent 總是忘記我們的 API response 格式？加一條 rule。\n\n> 更詳細的設定檔架構設計，請看 [CLAUDE.md 大師班](/blog/claude-md-rules-files-masterclass)。\n\n## Context Window 管理：什麼該塞、什麼該留\n\nAnthropic 內部有一個概念叫 \"Goldilocks Zone\"——context 不能太多也不能太少，要剛好。\n\n太多的問題：agent 的注意力會被稀釋。如果你把整個 repo 的所有檔案都 dump 進去，agent 反而抓不到重點。研究顯示，當 context 裡有大量無關資訊時，LLM 的準確率會顯著下降——這被稱為「Lost in the Middle」效應。\n\n太少的問題：agent 缺乏做正確決策的資訊，只好自己猜。而它猜的依據是 training data，不是你的專案實際情況。\n\n### Token Budget 分配建議\n\n這是我在實踐中摸索出的大致比例：\n\n| Context 類型         | 建議佔比 | 說明                 |\n| -------------------- | -------- | -------------------- |\n| System Instructions  | ~5%      | 工具預設，你無法控制 |\n| Project Config       | ~10%     | CLAUDE.md + rules    |\n| Codebase Indexing    | ~30%     | 相關檔案的 code      |\n| Conversation History | ~35%     | 對話 + 先前結果      |\n| External Tools       | ~10%     | MCP 結果、API 回應   |\n| Runtime State        | ~10%     | 測試結果、error logs |\n\n注意 conversation history 佔了最大比例——這也是最容易失控的部分。\n\n### Progressive Disclosure 策略\n\n不要一次給 agent 所有資訊。像剝洋蔥一樣，需要時再給：\n\n1. **Session 開始時**：只載入 CLAUDE.md 和 task spec\n2. **Agent 開始探索時**：讓它自己 grep/glob 找到相關檔案\n3. **遇到問題時**：提供 error logs、test results 等 runtime context\n4. **需要外部資訊時**：透過 MCP 即時查詢\n\n這跟 Just-in-Time Manufacturing 的邏輯一樣——需要的時候才載入，而不是預先囤貨。\n\n### Sub-Agent 架構：保護主 Context\n\n當一個大任務需要探索很多檔案的時候，不要在主 session 裡全部做完。用 sub-agent：\n\n```\n主 agent（main context）\n  └── 派出 sub-agent 1：研究 codebase 結構\n  └── 派出 sub-agent 2：探索相關 API\n  └── 派出 sub-agent 3：分析 test patterns\n```\n\nSub-agent 各自帶著自己的 context window 工作，完成後只回報「結論」給主 agent。這樣主 agent 的 context window 不會被探索過程的細節填滿。\n\n我在寫這個系列的時候就是這樣操作的——一個主 agent 負責寫作，三個 sub-agent 分別負責不同主題的研究，每個 sub-agent 完成後把研究結果摘要回報。主 agent 的 context 始終保持乾淨。\n\n## 常見的 Context 設計錯誤\n\n一年下來，我（和我觀察到的其他人）最常犯的五個 context 錯誤：\n\n### 錯誤 1：塞太多——資訊過載\n\n**症狀**：Agent 拿到一個 200 行的 CLAUDE.md + 50 個檔案的 codebase dump，然後做了一個看起來隨機的決策。\n\n**原因**：LLM 有一個已知的 \"Lost in the Middle\" 問題——context window 中間的資訊比頭尾更容易被忽略。當你塞了太多資訊，重要的指令可能剛好落在被忽略的區域。\n\n**修正**：刪掉一切不是當前任務「必要」的資訊。CLAUDE.md 是你的「always-on」context，應該精簡。任務特定的資訊應該在 prompt 裡給，不是在 CLAUDE.md。\n\n### 錯誤 2：塞太少——Agent 自己發明\n\n**症狀**：Agent 完美地完成了任務，但用了一個你們從不使用的 library、一種你們從不使用的設計模式、或一個跟現有 code 完全不一致的命名風格。\n\n**原因**：沒有足夠的 context，agent 只好依賴 training data 中最常見的模式。而最常見的模式不一定是你的專案的模式。\n\n**修正**：當你發現 agent 連續做了「合理但不是我想要的」選擇，那就是該加 context 的時機。記錄下來，加到 CLAUDE.md 或 rules file。\n\n### 錯誤 3：塞錯東西——花 Token 在無關資訊上\n\n**症狀**：你把一大段 design doc 貼給 agent 來修一個 CSS bug，結果 agent 花了大量 token 分析 design doc 裡的商業邏輯，然後建議你改 backend API。\n\n**原因**：LLM 會試圖利用所有給它的資訊。你給了它 design doc，它就覺得你希望它考慮 design。即使 bug 只是一個 CSS `z-index` 問題。\n\n**修正**：Context 要跟任務相關。修 CSS bug 就給 CSS 相關檔案和 error description。不需要的資訊不只浪費 token，還會誤導 agent 的注意力。\n\n### 錯誤 4：不更新——用過時的 Context\n\n**症狀**：Agent 用了你三個月前的 API endpoint（已經改名了）、用了一個你兩個月前棄用的 component、或遵循一個你上個月修改的 convention。\n\n**原因**：CLAUDE.md 寫了之後就沒再更新。Rules file 裡的指令跟現在的 codebase 已經不一致。\n\n**修正**：把 CLAUDE.md 的維護當成程式碼的一部分——它應該被 commit 進 git、被 review、被定期更新。我的習慣是每月花 30 分鐘 review 一次設定檔，清掉過時的內容。\n\n### 錯誤 5：重複資訊——多個來源說不同的話\n\n**症狀**：Global CLAUDE.md 說「用 tabs 縮排」，project CLAUDE.md 說「用 2 spaces 縮排」。Agent 隨機選一個，或者更糟——在同一個檔案裡兩種都用。\n\n**原因**：多層設定檔架構沒有清楚的優先順序定義。\n\n**修正**：建立明確的優先順序：project 設定覆蓋 global 設定。避免在多個地方重複定義同一件事。一條 rule，一個權威來源。\n\n## 一個 Context 設計的真實 Before/After\n\n讓我用一個具體的案例來展示 context quality 的影響。\n\n**任務**：在 Astro 部落格加一個「相關文章」推薦功能。\n\n### Before（只有 Prompt，沒有 Context）\n\n```\n幫我在部落格文章頁面底部加一個「相關文章」區塊，\n根據 tags 推薦 3 篇相關文章。\n```\n\n**Agent 的結果**：寫了一個 React component（我用 Astro）、用 `fetch` 從不存在的 `/api/posts` 取資料（我是 static site）、CSS 完全沒用 Tailwind（我整個專案都是 Tailwind）。\n\n功能邏輯是對的。技術選型全錯。花了 40 分鐘重寫。\n\n### After（完整的 Context Stack）\n\nCLAUDE.md 已經有：\n\n- `Stack: Astro 5 + MDX + Tailwind CSS 4 + TypeScript`\n- `src/content/blog/` 是部落格文章目錄\n- `Content Collections API` 用 `getCollection('blog')` 取文章\n- Component 使用 `.astro` 格式\n\n加上精確的 Prompt：\n\n```\n在 BlogLayout.astro 底部加一個「相關文章」區塊。\n- 用 Astro Content Collections API 的 getCollection('blog') 取文章\n- 根據 tags 的交集數量排序，取前 3 篇\n- 使用現有的 PostCard.astro component 呈現\n- 排除當前文章本身\n- 用 Tailwind grid 佈局\n```\n\n**Agent 的結果**：一個 Astro component，用 `getCollection('blog')` 取資料，tag 交集排序邏輯正確，用了現有的 `PostCard.astro`，Tailwind grid layout。20 分鐘完成，第一次就能用。\n\n差別不在 prompt 的措辭技巧。差別在 agent 能看到的「世界」。\n\n## Takeaway\n\n1. **Context Engineering 是 Agentic Engineering 的核心技能**——你的 context 品質直接等於 agent 產出品質。投資在 context 設計上的每一分鐘，都能省下 agent 犯錯後的收拾時間。\n\n2. **Context 有六層，每層需要不同的維護策略**——從幾乎不變的 System Instructions 到即時產生的 Runtime State，理解每一層的角色和最佳實踐，才能精準地給 agent 它需要的資訊。\n\n3. **「剛好夠」比「越多越好」重要**——學會在 token budget 內精準投放 context。太多會稀釋注意力（Lost in the Middle），太少會讓 agent 自己亂猜。Progressive disclosure + sub-agent architecture 是你管理 context 的兩大武器。\n\n---\n\n_上一篇：[工具全景圖](/blog/agentic-engineering-tools-landscape-2026)_\n_下一篇：[Spec-Driven Development](/blog/spec-driven-development-for-agents)_",
      "summary": "Tobi Lutke 把 Prompt Engineering 重新命名為 Context Engineering，這不只是換個詞。當 agent 要自主完成任務，你餵給它的 context 決定一切——從 CLAUDE.md、codebase indexing 到 conversation history management，拆解 context 的六個層次。",
      "image": "https://bobochen.dev/_astro/cover.l3OjPaZ9.webp",
      "date_published": "2026-04-03T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "Context Engineering",
        "AI",
        "CLAUDE.md",
        "Prompt Engineering"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-08-family-environment/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-08-family-environment/",
      "title": "到底是怎樣的家庭環境",
      "content_text": "爸的酒駕、家暴、法院傳票。哥的習得無助。媽的被迫堅強。我一直在問自己：到底是怎樣的教育與家庭環境，會造就這樣的互動模式？直到我有了自己的孩子，才開始有了答案。",
      "content_html": "## 一個問題\n\n我在腦海裡問過自己無數次的一個問題：\n\n**到底是怎樣的教育與家庭環境，會造就這樣的互動模式？**\n\n爸不會道歉、不會溝通、只會用喝酒和暴力來表達情緒。哥學會了無助和依賴，遇到任何困難就向外求救，從不自己解決。媽在這兩個人中間當夾心餅乾，結果變成了一個不自覺的控制者——用內疚來維持家庭的運轉。\n\n而我，成了那個「什麼都要我處理」的人。\n\n四個人、四種角色、四種痛苦的方式。但它們不是各自獨立的——它們像齒輪一樣互相咬合，形成一個自我循環的系統。\n\n## 爸：沒有學過表達\n\n爸不是天生的壞人。\n\n我相信他在清醒的時候是愛我們的。他會帶我兜風、會在桌上放零用錢、會在我過年的時候給紅包。\n\n但他沒有學過怎麼處理壓力。他的解法只有一個：喝酒。\n\n喝了酒之後，所有被壓在清醒底下的東西都會跑出來——焦慮、憤怒、挫敗感、對自己的失望。而這些東西一旦跑出來，他能做的就只有吼、摔、打。\n\n不是因為他想傷害人，而是他根本不知道還能怎麼做。\n\n酒駕被抓了好幾次。有一次撞了車，之後還異想天開地問家裡要五十萬。在法院開庭的時候，態度也不好。最後法官判我們要付扶養費——每個月兩萬五千塊——即使他從來沒有好好養過我們。\n\n這個判決讓我第一次對法律感到憤怒：**一個沒有盡過養育責任的父親，受法律保護要求子女扶養他？**\n\n我去諮詢過律師。律師說，除非能證明嚴重虐待，否則很難免除扶養義務。而「嚴重虐待」的舉證標準很高，不是你說有就算有。\n\n那種無力感，比任何一筆帳單都重。\n\n## 哥：習得的無助\n\n哥的問題，我花了很久才看懂。\n\n小時候他就是那種什麼都要別人幫忙的小孩。奶瓶蓋打不開找我、作業不會寫找我、跟同學吵架找我。媽覺得這是因為「弟弟比較能幹」，用一種帶著驕傲的語氣講這件事。\n\n但現在回頭看，問題不是我比較能幹，是哥從來沒有機會學會自己面對困難。\n\n每次他遇到問題，大人——尤其是媽——就會出面解決。他不需要自己想辦法、不需要承受失敗、不需要面對後果。長期下來，他的大腦就建立了一個簡單的迴路：「我做不到 → 有人會來幫我 → 我不需要做。」\n\n心理學上叫「習得無助」。不是天生的無能，是被環境訓練出來的無能。\n\n到了三十幾歲，這個模式已經根深蒂固。他找不到長期的工作、維持不了人際關係、處理不了基本的生活事務。爸的二手車要賣，他搞不定。搬家要找人來搬，他不會打電話約。最後什麼都還是媽在弄。\n\n我不恨哥。但我也不可能繼續無止盡地幫他。\n\n因為幫他只會讓那個「我做不到 → 有人會來幫我」的迴路更加鞏固。真正對他好的方式，是讓他開始自己面對。\n\n但這件事，比我直接幫他還要困難一百倍。\n\n## 媽：無意間的控制\n\n媽是這個家裡最複雜的角色。\n\n她是受害者——被爸打、被經濟壓垮、犧牲了自己的人生。這些都是事實。\n\n但她也在無意間成了一個控制者。\n\n舉例來說：每次我跟媽提到哥應該更獨立，她就會說「你哥就是這樣，你就幫幫他嘛」。表面上是在求我幫忙，但潛台詞是「如果你不幫他，你就是不孝」。\n\n或者前面提過的房租事件：明明是我們一起決定的，她去跟哥講的時候卻說成是我要求的。她避免了跟哥的衝突，但把衝突轉嫁到了我身上。\n\n這些不是惡意。我相信媽不是刻意要操控。但長期在那種環境裡，她自然而然地學會了一套「用情緒和關係來管理局面」的方式。\n\n溝通不良、轉嫁責任、用內疚來驅動行為——這些東西不是一朝一夕養成的，是幾十年的環境塑造出來的。\n\n## 我：打斷循環\n\n理解了這些之後，我的目標變得很清楚：\n\n**不要把這些模式傳下去。**\n\n爸用酒精和暴力處理壓力 → 我要學會用語言表達情緒。\n哥被過度保護變成無助 → 我要讓孩子自己面對適量的困難。\n媽用內疚和轉嫁來管理關係 → 我要跟家人直接溝通，不繞彎。\n\n說起來容易。做起來，每天都是戰爭。\n\n因為那些模式在你的血液裡。你會在疲累的時候、壓力大的時候、失去耐心的時候，突然聽到自己用爸的語氣在對小孩說話。那個瞬間你會嚇一跳，然後趕快深呼吸，把那個語氣壓下去。\n\n打斷循環不是一次性的決定，是每天的選擇。\n\n今天我沒有對小孩大吼 → 成功。\n今天我跟太太有話直接說了 → 成功。\n今天我忍住了沒有幫哥處理他自己該處理的事 → 成功。\n\n每一天，小小的成功。累積起來，才有可能變成「不一樣的家庭」。\n\n這是我能給榕和辰最好的禮物。不是錢，不是學歷，不是什麼了不起的資源。\n\n是一個不重複上一代錯誤的家。",
      "summary": "爸的酒駕、家暴、法院傳票。哥的習得無助。媽的被迫堅強。我一直在問自己：到底是怎樣的教育與家庭環境，會造就這樣的互動模式？直到我有了自己的孩子，才開始有了答案。",
      "image": "https://bobochen.dev/_astro/cover.H9whnot3.webp",
      "date_published": "2026-04-03T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-07-moms-courage/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-07-moms-courage/",
      "title": "媽的勇敢",
      "content_text": "酗酒的丈夫、還不完的債、兩個要養的孩子。在最絕望的時候，媽沒有放棄。她沒有選擇離開這個世界，而是每天早上起來，做該做的事。這是我見過最安靜、也最強大的勇敢。",
      "content_html": "## 她撐過來了\n\n我要先說最重要的事：**媽沒有放棄。**\n\n在那些年裡——爸喝酒打人、欠債還不完、一個人帶兩個小孩、從菜攤的零工被迫扛起整個家的經濟——媽沒有選擇結束一切。\n\n這聽起來不像是什麼了不起的成就。但如果你真的理解她承受的壓力——被丈夫打、被經濟壓得喘不過氣、看不到未來、身邊沒有人幫——你就會知道，「繼續活著」本身就需要巨大的勇氣。\n\n新聞上偶爾會看到有人在類似的困境中做了不同的選擇。每次看到那些新聞，我心裡都會浮出同一個念頭：如果媽那時候也做了一樣的決定，我跟哥現在在哪裡？\n\n答案太可怕，不敢想。\n\n所以我要先說：感謝媽很勇敢。感謝她堅強地帶我們活下去。感謝她沒有輕生。\n\n## 阿嬤帶孫子出門呀\n\n爸媽在那個年代算是少數的晚婚。\n\n這件事在我小時候不覺得有什麼，直到有一次跟媽去雜貨店。老闆娘看到我們，笑嘻嘻地說了一句：「哎唷，阿嬤帶孫子出門呀！」\n\n媽愣了一下，沒有解釋什麼，拿了東西就走了。\n\n但我聽到了。而且我聽懂了。\n\n那個人把我媽當成我阿嬤。因為媽看起來比別的小朋友的媽媽老。\n\n那天回家的路上，我一直不說話。心裡有一種很奇怪的感覺——不是生氣、不是難過，是**難堪**。好像我犯了什麼錯。好像因為我的存在，害媽被別人這樣說。\n\n小朋友就是這樣。他們不會去怪那個說錯話的大人，他們會先怪自己。莫名其妙地自責，覺得一切都是自己的錯——家裡窮是我的錯、媽被誤認是我的錯、爸喝酒也不知道是不是我的錯。\n\n這種自責感不需要任何人刻意灌輸，它會自己長出來。然後跟著你很久很久。\n\n## 不會上新聞的堅強\n\n媽的堅強不是那種會上新聞的堅強。\n\n她沒有打官司逆轉人生、沒有創業成功反敗為勝、沒有一個戲劇性的轉折點讓你可以指著說「就是從這裡開始好轉的」。\n\n她的堅強是：每天早上五點起來，做早餐、送小孩、去工廠上班、下班回來做晚餐、洗碗、洗衣服、哄小孩睡覺。然後隔天，一樣的事情再做一遍。\n\n日復一日。年復一年。\n\n沒有人鼓掌、沒有人頒獎、沒有人寫一篇文章來讚美她。她就是做了。因為不做，這個家就散了。\n\n後來我自己當了爸，每天處理那些重複的家事和育兒瑣事——泡奶、換尿布、洗碗、接送、陪讀——我才稍微理解那種疲倦。而我還有太太一起分擔。\n\n媽當時是一個人。\n\n## 被打的那些夜晚\n\n爸喝酒之後會打媽。\n\n這件事我在第一篇寫過了。但在這篇裡，我想從媽的角度來說。\n\n被打之後，媽不會哭——至少不會在我們面前哭。她會等爸睡著了之後，默默地處理傷口或者整理被弄亂的東西。隔天早上，像什麼事都沒發生一樣，做早餐、送我們上學。\n\n這種「像什麼都沒發生」的能力，小時候的我以為是因為媽很堅強。長大之後我才知道，那不是堅強，那是「沒有其他選擇」。\n\n她能去哪裡？帶著兩個小孩、沒有學歷、沒有存款、身上可能還帶著昨天的傷。那個年代的社會支持系統跟現在比差很多，你報警了然後呢？\n\n所以她留下來。不是因為她選擇留下來，是因為她覺得離開更可怕。\n\n後來爸媽確實分開了。但那是很久以後的事。在分開之前的那些年，媽就是這樣一邊被傷害、一邊繼續撐著這個家。\n\n## 媽的遺憾\n\n媽其實是個聰明的人。\n\n從我跟她聊天的片段裡，拼湊出來的是：她小時候成績很好，但因為家裡的經濟狀況，只念到高商就沒有繼續升學了。而她的兄弟姐妹裡，有些人後來念了大學。\n\n她對這件事一直有遺憾。不是怨恨，是一種安靜的失落——「如果我也有機會呢？」\n\n很多年後，在小孩都大了、生活稍微穩定一點之後，媽去念了空中大學。\n\n空中大學是那種用電視和廣播上課、寒暑假才需要到校面授的大學。對一個白天要工作、平常要顧家的人來說，這幾乎是唯一可行的升學管道。\n\n然後她拿到了很好的成績。\n\n我不記得確切是第幾名，但我記得她很開心。那種開心不是「考試考得好」的開心，是一種「我證明了自己」的光芒。\n\n外公在媽畢業之後一年就過世了。媽後來說，她很慶幸外公有看到她畢業。\n\n## 我從她身上學到的\n\n媽教會我的東西，不是用「教」的方式。\n\n她沒有坐下來跟我說「做人要怎樣怎樣」。她教我的方式是「做給你看」：\n\n**如何在沒有退路的時候繼續走。** 不是因為前方有光，而是因為你身後有人需要你。\n\n**如何在被傷害之後站起來。** 不是原諒，是決定不讓傷害定義你。\n\n**如何在極度匱乏的時候維持尊嚴。** 媽從來不會跟外人說家裡有多困難。她寧可自己省吃儉用，也要讓我們看起來跟別人一樣。\n\n這些東西，不是她用嘴巴講的。是她用人生示範的。\n\n我在這個系列裡寫了很多家裡的問題——溝通不良、情緒勒索、各種讓人崩潰的日常。這些都是真的。媽不是一個完美的母親。\n\n但她是一個在最壞的牌局裡，用最大的力氣打出最好結果的人。\n\n光是這一點，就值得我寫一整篇來記錄。",
      "summary": "酗酒的丈夫、還不完的債、兩個要養的孩子。在最絕望的時候，媽沒有放棄。她沒有選擇離開這個世界，而是每天早上起來，做該做的事。這是我見過最安靜、也最強大的勇敢。",
      "image": "https://bobochen.dev/_astro/cover.6OI9zFKh.webp",
      "date_published": "2026-04-02T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-05-notes-for-money/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-05-notes-for-money/",
      "title": "紙條上的數字",
      "content_text": "小時候想跟爸要零用錢，我不敢直接開口，都是寫紙條。「爸，可以給我 20 元嗎？」「可以給我 30 元嗎？」那些紙條上的數字，從來沒有超過五十。",
      "content_html": "## 不敢開口\n\n跟爸要錢這件事，我從來沒有辦法直接說出口。\n\n不是因為爸很兇。是因為家裡的氣氛讓你知道「錢」是一個敏感的字。每次有人提到錢，空氣就會變緊。媽的表情會變、爸的語氣會變、整個家的溫度會下降好幾度。\n\n所以我發明了一個方法：寫紙條。\n\n一張小紙條，放在爸會看到的地方——桌上、電視旁邊、他的鑰匙旁邊。\n\n上面寫著：「爸，可以給我 20 元嗎？」\n\n或者：「爸，可以給我 30 元嗎？」\n\n就這樣。沒有理由、沒有解釋、沒有「我需要買什麼」。因為如果解釋了，他可能會問「為什麼需要」，然後就變成一場關於「你到底要花多少錢」的審問。\n\n紙條比較安全。他看到了，如果心情好就會放幾個銅板在紙條旁邊。如果心情不好，就當作沒看到。不用對話、不用衝突、不用看到他的表情從平靜變成不耐煩。\n\n## 從來沒有超過五十\n\n那些紙條上的數字，我記得很清楚。\n\n二十、三十，偶爾四十。從來沒有超過五十。\n\n不是因為五十是什麼特殊的門檻，是因為我自己畫了一條線：**超過某個數字就是「太多了」，人家不會給的。**\n\n這條線不是爸告訴我的，是我自己從氣氛裡讀出來的。你在一個經濟緊張的家庭裡長大，會自動發展出一種「偵測」能力——什麼可以要、什麼不能要、什麼時候可以開口、什麼時候最好閉嘴。\n\n這種能力在小時候是保護自己的機制，長大之後卻變成一種障礙。\n\n因為你會把這個模式帶進所有的關係裡。跟老闆談薪水的時候、跟客戶報價的時候、甚至跟朋友借東西的時候——你都會自動把「要求」壓到最低，害怕對方覺得你要太多。\n\n明明值得更多，卻永遠只敢開口要最少的。\n\n## 爸給了\n\n但有一件事我要公平地說：大部分的時候，爸是會給的。\n\n紙條放在那裡，隔天早上桌上通常會多幾個銅板。有時候剛好是我寫的數字，有時候多一點、有時候少一點。\n\n他不會在紙條上回覆什麼。就是放錢。沉默的給予。\n\n現在回想起來，那也是一種他能做到的愛的表達方式。一個不太會說話、不太會表達感情、喝了酒又會變一個人的男人，在清醒的時候，他能做的最溫柔的事，就是在桌上放幾個銅板。\n\n不多。但有。\n\n## 長大後的我\n\n現在榕和辰想要什麼的時候，會直接說。\n\n「爸爸我要喝果汁。」\n「爸爸我想要那個玩具。」\n「爸爸可以買冰淇淋嗎？」\n\n他們不會寫紙條、不會看我的臉色、不會計算「這個會不會太多」。他們就是直接說。\n\n每次聽到他們這樣說，我都覺得很好。\n\n因為那代表他們不害怕。他們不需要先偵測氣氛、不需要計算什麼時候開口最安全、不需要把自己的需求壓到最小。\n\n他們可以直接要，然後相信爸爸會給。\n\n這種「相信」，是我小時候沒有的。我總是要先偵測、先計算、先做最壞的打算，才敢小心翼翼地把紙條放在桌上。\n\n所以每次他們大聲地、理直氣壯地說出「我想要」的時候，我心裡都會偷偷地高興。\n\n不是因為我可以滿足他們，而是因為——他們敢說。\n\n在我長大的那個家裡，「敢說出自己想要什麼」這件事，本身就是一種特權。\n\n我的孩子擁有這個特權。光是這一點，就夠了。",
      "summary": "小時候想跟爸要零用錢，我不敢直接開口，都是寫紙條。「爸，可以給我 20 元嗎？」「可以給我 30 元嗎？」那些紙條上的數字，從來沒有超過五十。",
      "image": "https://bobochen.dev/_astro/cover.BIcveebX.webp",
      "date_published": "2026-04-01T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-06-little-black-dog/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-06-little-black-dog/",
      "title": "一隻小黑狗教我的事",
      "content_text": "2011 年 4 月 25 日，陪伴我長大的小黑狗走了。牠老了之後視力模糊、日夜顛倒、半夜會亂叫，我每天半夜起來照顧牠。多年後爸中風住院，我才發現：照顧老狗的那段日子，竟然是照顧爸的預演。",
      "content_html": "## 牠沒有名字\n\n嚴格說起來，牠有名字，但我已經記不太清楚了。在我的記憶裡，牠就是「小黑」——一隻黑色的小型犬，從我有記憶以來就在家裡。\n\n小黑不是什麼名貴品種，就是那種路邊會看到的台灣土狗和小型犬的混血。毛是黑的、眼睛是圓的、尾巴會捲起來。小時候我覺得牠是世界上最好看的狗。\n\n在那個家裡——爸喝酒、爸媽吵架、空氣裡永遠有一種緊繃感——小黑是唯一讓我覺得安全的存在。\n\n牠不會問你考幾分。不會因為心情不好就對你大吼。不會突然消失好幾天。牠就是在那裡，每天等你回家，看到你就搖尾巴。\n\n對一個在混亂家庭裡長大的小孩來說，這種「無條件的存在」是很珍貴的。我不知道該怎麼解釋——大概就是，不管外面的世界多不穩定，至少有一個生命是穩定地愛著你的。\n\n## 在小黑之前\n\n其實，小黑不是我家養過的第一隻狗。\n\n在牠之前，還有好幾隻。只是那些狗，都沒能留下來。\n\n那時候家裡窮。養狗要伙食費，要花錢，爸也沒有那麼多時間照顧。所以每隔一段時間，家裡的狗就會「消失」。\n\n好幾次都是這樣：早上出門前，我還跟狗狗說再見；晚上回到家，就找不到牠了。後來我才知道，是爸騎著摩托車，把牠載到很遠很遠的地方放生。\n\n可是狗很聰明。有的狗會認路，被丟到那麼遠的地方，竟然還能自己走回家。過了幾天，我打開門，看到牠站在門口，尾巴搖個不停——那時候我有多開心，現在想起來都還記得。\n\n後來，爸想到一個辦法。他把狗裝進紙箱裡封起來，讓牠在路上看不到外面的路況。這樣，牠就認不得路，回不來了。\n\n那幾隻狗，再也沒有回來過。\n\n我小時候不懂。我只覺得，怎麼可以這樣對一個會搖尾巴、會認路、那麼努力想回家的生命。\n\n長大以後我才慢慢明白——那不是因為爸天生狠心，是因為窮。窮到連一隻狗的飯都養不起的時候，人會做出很多事後想起來會痛的決定。\n\n我不是在替他辯護。我只是在說：貧窮會把一個人逼成什麼樣子，我後來看得很清楚。\n\n我不知道為什麼小黑是留下來的那一隻。也許是家裡的狀況後來好一點了，也許只是運氣。但小黑留了下來，陪我長大，一直到老。\n\n## 牠老了\n\n狗的十幾年，是人的一輩子。\n\n小黑慢慢地老了。先是走路變慢，後來眼睛開始看不清楚——白內障，獸醫說很常見。再後來，牠開始日夜顛倒，白天睡得很沉，半夜兩三點突然醒來，在家裡走來走去，偶爾會叫。\n\n獸醫說這是老年犬的認知退化，有點像人類的失智。牠不是故意要半夜叫的，是牠搞不清楚現在是白天還是晚上了。\n\n那段時間，我每天半夜都會起來。\n\n有時候是牠叫了，我去安撫牠。有時候是牠沒叫，但我還是會起來看一下，確認牠還在呼吸。牠喜歡去翻垃圾桶、撿衛生紙來咬，你得盯著，不然牠會吃進去。\n\n睡眠變得很碎。凌晨兩點起來一次、四點再起來一次、六點鬧鐘響的時候，覺得自己根本沒睡。\n\n但你不會覺得煩。因為牠陪了你那麼多年，現在換你陪牠。\n\n## 那一天\n\n2011 年 4 月 25 日。\n\n我記得那天的天氣，但說不出來是晴天還是陰天。有些日子你記得所有細節，卻記不得天氣。也許是因為那天的世界，不管有沒有太陽，都是灰的。\n\n小黑走了。\n\n走得很安靜。沒有掙扎，沒有痛苦的叫聲。就是呼吸越來越淺、越來越慢，然後停了。\n\n我把牠放進一個大紙箱裡，帶到台大動物醫院。一路上抱著那個箱子，覺得它比實際的重量重了一百倍。\n\n回到家之後，家裡突然變得很安靜。那種安靜不是「沒有聲音」的安靜，是「少了一個生命」的安靜。你會一直覺得哪裡不對——轉頭的時候習慣性地找牠、開門的時候習慣性地低頭看牠有沒有跑出來、半夜醒來的時候習慣性地豎起耳朵聽牠有沒有在叫。\n\n然後你想起來：牠不在了。\n\n每次想到這件事，到現在，眼睛還是會濕。\n\n## 預演\n\n多年後，2023 年，爸腦出血住進台大醫院。\n\n術後轉到養護中心。鼻胃管、尿管、不會說話、不會動、不認得人。\n\n我每個月去看他、處理帳單、跟養護中心溝通、安排回診。有一次在養護中心的走廊上等著簽文件，我突然想到一件事：\n\n**這些事，我都做過。**\n\n半夜起來確認呼吸。定期帶去看醫生。管他的吃、他的喝、他的排泄。處理他弄髒的東西。耐著性子面對他認不得你的眼神。\n\n只是上一次，對象是一隻狗。這一次，是我爸。\n\n站在那條走廊上，我心裡還閃過一個更複雜的念頭。\n\n眼前這個我每個月回來照顧、幫他處理大小便、耐著性子面對的人，也是當年那個把狗裝進紙箱、讓牠們回不了家的人。\n\n我沒有像他對那些狗一樣對他。我留下來了。\n\n我不知道這算不算原諒。我只知道，那些回不來的狗，連同後來的小黑，好像一起教會了我一件事——你可以選擇，不要變成那個讓別人回不了家的人。\n\n我不知道該怎麼形容那個瞬間的感覺。不是悲傷，比較像是一種荒謬的「啊，原來如此」——原來那段照顧老狗的日子，不只是照顧老狗，那是人生在幫我做預演。\n\n教我怎麼面對一個你深愛的生命慢慢離開。教我怎麼在睡眠不足的狀態下繼續運作。教我怎麼接受「你什麼都做了，但還是留不住」的現實。\n\n小黑用牠的老年教會我這些。然後爸用他的餘生讓我把學到的東西再做一遍。\n\n## 牠教我的\n\n如果有人問我：你從一隻狗身上學到了什麼？\n\n我會說：**責任。**\n\n不是那種道德課本上寫的「養寵物要負責任」的責任。是那種，當一個生命依賴你、而你是唯一能幫牠的人的時候，你不會逃跑的那種責任。\n\n半夜兩點，你很累、很想睡，但牠在叫。你會起來。不是因為你很偉大，是因為牠需要你。就這麼簡單。\n\n這種能力——在你很累的時候還是站起來——後來在很多地方用到了。照顧爸的時候、帶小孩的時候、工作撐不下去的時候。\n\n每一次我覺得「我真的不行了」，然後還是站起來繼續做的時候，我都會想到小黑。\n\n牠大概不知道自己教了我這些。但牠確實教了。\n\n## 我們有很多美好的回憶\n\n這篇文章寫到這裡，好像都是沉重的東西。但其實不是。\n\n小黑陪我的那些年，有很多很多快樂的時光。牠追著球跑的樣子、牠趴在我腳邊睡覺的樣子、牠看到我回家就瘋狂搖尾巴的樣子。\n\n那些畫面在 Facebook 的舊照片裡還找得到。偶爾翻到，還是會笑。然後笑完，就開始想牠。\n\n也許這就是愛一個生命的代價吧——你得到了陪伴，也得到了失去的能力。\n\n而這兩樣東西，缺一不可。",
      "summary": "2011 年 4 月 25 日，陪伴我長大的小黑狗走了。牠老了之後視力模糊、日夜顛倒、半夜會亂叫，我每天半夜起來照顧牠。多年後爸中風住院，我才發現：照顧老狗的那段日子，竟然是照顧爸的預演。",
      "image": "https://bobochen.dev/_astro/cover.DYdt2zKJ.webp",
      "date_published": "2026-04-01T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭",
        "寵物"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-04-taxi-childhood/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-04-taxi-childhood/",
      "title": "計程車上的童年",
      "content_text": "爸開計程車的那幾年，我的遊樂場就是副駕駛座。他載著我上陽明山、穿過台北的大街小巷。臥室牆上貼著米奇和唐老鴨的貼紙，是爸不知道從哪裡帶回來的。那個會帶我兜風的人，跟那個喝了酒會打人的人，是同一個人。",
      "content_html": "## 副駕駛座\n\n爸有一段時間開計程車。\n\n對我來說，那是一段奇特的童年記憶。因為當你爸是計程車司機，你的遊樂場就是整個台北市。\n\n假日的時候，或是爸白天沒有接到太多客人的時候，他會讓我坐副駕駛座，帶著我在城市裡繞。沒有特定的目的地，就是開。經過什麼有趣的地方就停下來看看，不有趣就繼續開。\n\n陽明山是常去的地方。山路彎彎繞繞，窗戶打開，風灌進來，帶著一股山上特有的草和泥土的味道。爸不太說話，就是專心開車。我也不太說話，就是看窗外。\n\n那種安靜不是尷尬的安靜，是一種「不需要說話也可以」的默契。\n\n現在回想起來，那大概是我跟爸最親近的時刻。不是過年、不是生日、不是什麼特殊的日子。就是一個普通的下午，坐在他的計程車裡，看台北的街景從車窗外滑過去。\n\n## 米奇和唐老鴨\n\n我的臥室牆上，貼著米奇和唐老鴨的貼紙。\n\n不知道是爸從哪裡帶回來的。可能是從夜市買的那種便宜的卡通貼紙，一張十塊二十塊的。但對小時候的我來說，那面牆就是迪士尼。\n\n每天睡前看著那些貼紙，覺得很安心。不管外面客廳發生了什麼事——爸喝了酒在大聲講話、爸媽在吵架、什麼東西碎掉的聲音——我躺在床上，抬頭看到米奇在笑，就覺得好像沒那麼可怕。\n\n小孩就是這樣。他們不需要很大的東西來獲得安全感。一面牆的貼紙就夠了。\n\n後來搬家的時候，那些貼紙沒有跟著搬。牆壁重新粉刷，米奇和唐老鴨就消失了。\n\n我不記得當時有沒有難過。但現在寫到這裡，胸口有一點點酸酸的。\n\n## 淡水河電台 FM89.7\n\n爸有一陣子在「淡水河電台 FM89.7」工作，負責音控和行政。\n\n![淡水河電台 FM89.7 的台標，綠底搭配白色麥克風與電波圖示](image.webp)\n\n爸換過的工作太多了——計程車司機、里長選舉、立法委員助理、餐飲業、保全、廣播電台——多到榕有一次問我「阿公做什麼工作」的時候，我一時之間竟然不知道該從哪一個開始說。\n\n那段時間，我晚上會自己一個人在家裡聽收音機，轉到 FM89.7，聽爸爸在裡面播音。那是一種很奇妙的感覺——人不在身邊，但聲音透過電波回到家裡。\n\n在電台工作的那段時間，爸要輪班，有時候晚上看不到他。小時候的我會覺得他好辛苦，又好擔心他沒有回來，只記得躺在床上等，聽到門打開的聲音才能安心睡著。\n\n那種等待的感覺——不知道他什麼時候會回來、不知道回來的時候是清醒的還是喝醉的——是一種很早就學會的焦慮。\n\n有趣的是，這種焦慮在我長大之後變成了一個職業優勢。我對「不確定性」的耐受度比一般人高。因為我從小就活在不確定裡——不確定爸什麼時候回來、不確定今天會不會吵架、不確定明天有沒有飯吃。\n\n當你的預設狀態就是「不確定」，那些讓別人崩潰的突發狀況，對你來說只是日常。\n\n## 同一個人\n\n寫到這裡，我需要面對一個矛盾。\n\n帶我上陽明山兜風的爸、在臥室牆上貼米奇貼紙的爸、晚回家讓我擔心的爸，和那個喝了酒會打媽媽、打小孩、拿生活費去簽六合彩的爸——\n\n是同一個人。\n\n小時候我處理不了這個矛盾。我會覺得：爸是不是有兩個？一個好的、一個壞的？好的那個會帶我去吃 99 元牛排，壞的那個會讓我躲在房間裡害怕。\n\n長大之後我才理解：不是兩個人，就是一個人。人本來就是這樣——同時裝得下溫柔和暴力、愛和傷害、讓你想靠近的部分和讓你想逃開的部分。\n\n理解這件事之後，你不會因此原諒他。但你會停止問「為什麼」。\n\n因為「為什麼一個會帶你兜風的人也會打你」這個問題，沒有答案。它就是發生了。你能做的，只是決定自己要成為哪一種父親。\n\n## 我帶榕兜風的方式\n\n現在我不開計程車。我開一台普通的家用車。\n\n但我也會帶榕和辰出去兜風。沒有特定目的地，就是開。經過有趣的地方就停下來，不有趣就繼續開。有時候會放他們喜歡的音樂，有時候就安靜地開。\n\n榕有時候會靠在車窗上看外面，跟我小時候一樣。\n\n那個畫面會讓我想到爸的計程車。然後我會提醒自己：今天回家之後，不要喝酒。不要因為壓力大就對小孩發脾氣。不要讓他們帶著害怕的心情上床睡覺。\n\n我從爸那裡學到了兩件事：**什麼是好的陪伴，以及什麼是我絕對不要重複的錯誤。**\n\n這兩件事同樣重要。",
      "summary": "爸開計程車的那幾年，我的遊樂場就是副駕駛座。他載著我上陽明山、穿過台北的大街小巷。臥室牆上貼著米奇和唐老鴨的貼紙，是爸不知道從哪裡帶回來的。那個會帶我兜風的人，跟那個喝了酒會打人的人，是同一個人。",
      "image": "https://bobochen.dev/_astro/cover.FKsTNFWT.webp",
      "date_published": "2026-03-31T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-03-five-part-time-jobs/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-03-five-part-time-jobs/",
      "title": "五份工讀的大學生",
      "content_text": "大學四年，我同時做過最多五份工作：圖書館、學校餐廳、家教、健身房、系辦網站維護。不是因為想要什麼額外的東西，而是光活著就需要這麼多收入。這段經歷教會我一件事：你永遠可以比自己以為的再多撐一點。",
      "content_html": "## 不是為了體驗人生\n\n很多人聽到「大學打五份工」，第一反應是「好認真哦」或「你想存錢買什麼？」\n\n都不是。\n\n我不是為了買 iPhone、不是為了出國旅行、不是為了「體驗不同的工作」。\n\n我是為了活著。\n\n學費要繳、飯要吃、交通費要付、偶爾有必要的支出。家裡沒辦法給我太多，媽自己也在撐。所以從大一開始，打工就不是選擇，是生存。\n\n差別只在於打幾份。\n\n## 五份工的日常\n\n最高紀錄是同時做五份：\n\n**圖書館視聽室**——最穩定的工讀。坐在櫃台後面，幫同學借還視聽資料。空檔的時候可以看書或寫作業，是所有工作裡最像「大學生」的一份。\n\n**學校餐廳**——午餐尖峰時段去幫忙。好處是可以吃免費的午餐。這一點很重要，因為省下來的午餐錢就是一整天的交通費。\n\n**家教**——教國高中生數學或電腦。時薪最高的一份，但時間不固定，要配合學生的時間。\n\n**健身房**——體育相關的工讀機會。不記得確切做什麼了，但記得那份工作讓我對體能訓練有了基本的認識。\n\n**系辦網站維護**——用我高中就開始學的網頁技術，幫系上維護網站。這份工作錢不多，但經驗值最高，是我後來走上工程師路線的種子之一。\n\n五份工讀排下來，一天的行程大概是這樣：早上上課、中午去餐廳打工順便吃飯、下午上課或去圖書館值班、傍晚去家教、晚上回來做作業或處理網站的東西。\n\n睡覺的時間被壓縮到只剩五六個小時。週末也不太能休息，因為家教通常排在假日。\n\n我不記得那時候有覺得特別辛苦。大概是因為周圍沒有比較的對象——你不知道「正常」的大學生活應該是什麼樣子，所以就覺得這就是日常。\n\n直到後來畢業，跟同學聊起大學回憶，別人講的是社團、聯誼、夜衝、畢業旅行，我才發現：原來大部分的人大學不是這樣過的。\n\n## 那些我沒有的\n\n大學四年，我沒有去過任何一場聯誼。沒有夜唱、夜衝、夜遊。\n\n不是不想。是時間和錢都不允許。\n\n參加聚餐。每一項都是支出，每一項都要跟工讀搶時間。算一算，划不來。\n\n所以我的大學記憶裡，沒有那些「最美好的青春回憶」。有的是圖書館的書架、餐廳的蒸汽、家教學生的作業本、系辦的舊電腦。\n\n我偶爾會想：如果家裡不用我打工，我的大學會是什麼樣子？\n\n大概會多很多朋友、多很多故事、多很多「那年我們一起做的蠢事」。\n\n但這個假設沒有意義。因為事實就是，那四年的每一分鐘，不是在上課，就是在工作。\n\n## 唯一的例外\n\n不過，那四年我也不是什麼都沒有。\n\n有一個人，陪我走過那段日子。\n\n我那時候有個女朋友。但因為我所有的時間都拿去打工賺學費了，我們約定好——一個月只碰面一次。\n\n一個月一次。對大部分的情侶來說，這個頻率大概撐不過三個月。但我們撐下來了。\n\n她知道我為什麼忙、知道我在為什麼努力，所以她從來沒有抱怨那些我給不起的陪伴。那一個月一次的見面，是我那段日子裡少數不用值班、不用趕場、可以好好當一個「人」而不是一台打工機器的時間。\n\n她是真正跟我共患難的人。在我什麼都沒有的時候。\n\n後來呢？\n\n後來，那個一個月只能見我一次的女朋友，變成了我的「前女友」。\n\n——因為她現在是我太太。\n\n## 它教會我的\n\n五份工讀的生活，在當下只覺得累。但回頭看，它教了我幾件很重要的事：\n\n**第一，你永遠可以比自己以為的再多撐一點。** 每次覺得「我真的不行了」，然後鬧鐘響了、還是得起床。起來之後發現：其實還行。人的極限比想像中遠，只是你不試就不知道。\n\n**第二，時間管理不是選修，是生存技能。** 當你的一天被塞滿五份工作和課程，你會自動學會什麼是優先、什麼可以跳過、什麼必須在前一天先做好。這個能力後來在工作上用了無數次。\n\n**第三，免費的午餐真的很重要。** 這不是比喻。餐廳工讀附的那一餐，有時候是我一天裡唯一吃得飽的一頓。我到現在看到有人浪費食物，還是會有一種本能的不舒服。\n\n**第四，不要跟別人比。** 你比的那個對象，可能每個月從家裡拿兩萬塊零用錢。你們的起跑線不一樣，比了只會讓自己難受。專注在自己的路上就好。\n\n## 給現在正在打工的人\n\n如果你現在也是那個同時做好幾份工的大學生——\n\n我不會跟你說「加油，辛苦會有回報的」這種話。因為我知道聽到這種話的時候，你只會更累。\n\n我想跟你說的是：**你現在的辛苦，不是因為你不夠好。是因為起跑線本來就不公平。**\n\n但起跑線不公平這件事，不代表你到不了終點。只是你要跑得比別人久一點、腳比別人痛一點。\n\n而那些多跑的路和多痛的腳，後來都會變成你身上最厚的鎧甲。\n\n至少我是這樣相信的。因為如果不這樣相信，那些年就真的只剩下辛苦了。",
      "summary": "大學四年，我同時做過最多五份工作：圖書館、學校餐廳、家教、健身房、系辦網站維護。不是因為想要什麼額外的東西，而是光活著就需要這麼多收入。這段經歷教會我一件事：你永遠可以比自己以為的再多撐一點。",
      "image": "https://bobochen.dev/_astro/cover.DeCJ_5nQ.webp",
      "date_published": "2026-03-30T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-payment/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-payment/",
      "title": "付費機制：一個人怎麼收錢",
      "content_text": "從免費到付費，是 side project 變成真正產品的關鍵一步。比較 Stripe、綠界、藍新的整合難度與適用場景，用 AI 一個晚上串接金流，並搞懂台灣個人收費的發票、退款與法律考量。",
      "content_html": "## 從免費到收費的心理障礙\n\n你的產品有人在用了。有人說「很好用」，有人說「如果有 X 功能就更好了」。\n\n現在你心裡有一個想法：「也許……可以開始收費了？」\n\n然後你開始焦慮。\n\n「會不會一收費，所有用戶都跑掉？」「定多少錢才合理？」「台灣市場用什麼金流？Stripe 可以用嗎？」「要不要開統編？需不需要開發票？」「如果有人付了錢然後要退款怎麼辦？」\n\n這些問題堆在一起，讓「收費」這件事變成了一座大山。於是你繼續讓產品免費，告訴自己「再等等，功能更完善再說」。\n\n這是 Solo Builder 最常見的拖延。也是最致命的——因為**一個不收錢的產品，永遠只是 side project**（這個核心信念在[第 1 章：Solo Builder 宣言](/blog/ai-solo-builder-manifesto)有更完整的論述）。\n\n> 不過這句話我得補一個前提：它只在「你的商業模式就是向用戶收費」時才成立。「免費」本身可以是策略，不一定是逃避。有幾種情況我反而會勸你別急著收費：\n> - **需求還沒驗證**——連有沒有人要用都不確定，先收費只會讓你拿不到真實的使用數據。\n> - **產品靠網路效應**——社群、市集、協作工具這類東西，沒先衝出規模就收費，等於自己掐死成長。\n> - **變現本來就不是直接跟用戶拿錢**——廣告、贊助、開源加技術服務，都是「沒跟用戶收一毛、卻活得很好」的活路。\n>\n> 所以與其說「沒收錢就是 side project」，更準確的版本是：**如果你打算靠用戶付費活下來，那遲遲不收費確實是拖延；但收費不是「認真」的唯一證明。** 先想清楚你是哪一種，再決定要不要往下讀這章的金流。\n\n事實上，串接金流的技術難度遠比你想像的低。2026 年的金流服務都有完善的 API 和 SDK，加上 AI 的輔助，一個晚上就能搞定。\n\n真正的挑戰不是技術，而是心態。所以我們先處理心態，再處理技術。\n\n## 心態轉換：收費是對用戶的尊重\n\n很多開發者覺得「收費不好意思」，因為自己也常用免費工具。\n\n但你想過一個問題嗎：你最常用的免費工具，你對它有多大的信任？\n\n如果它明天關掉了，你會多難過？大概只會說「可惜」，然後找替代品。\n\n但如果你**付了費**的工具明天關掉了？你會很生氣，因為你投入了金錢，你期待它穩定運作。\n\n這就是收費的意義：**付費建立了一種契約關係。** 用戶付費，代表他認真看待你的產品。你收費，代表你承諾會認真維護。\n\n免費產品的用戶會說「不錯」然後離開。付費用戶會說「這邊有 bug 你要修」——然後留下來。\n\n**收費不是在佔用戶便宜。收費是在告訴用戶：「我會為這個產品負責。」**\n\n話講得漂亮，但我也得誠實說收費的另一面，不然你會以為它只有好處：\n\n> - **付費牆會把絕大多數人擋在外面。** 業界常見的免費轉付費率個位數百分比，意思是你收費的那一刻，等於放掉九成以上的使用者——而這群人正是會幫你口耳相傳的來源。\n> - **付了錢的人期待也更高。** 免費用戶最多嫌兩句就走，付費用戶會半夜寄信問你「我付了錢為什麼還當機」，客服和 SLA 的壓力是真的會壓到你。\n> - **你開始要扛退款與爭議。** 信用卡 chargeback、要求退費、發票開錯，這些雜事免費時你完全不用碰。\n> - **「付費用戶會留下來」不是定律。** 訂閱制的退訂率（churn）很現實，留不留下來看的是產品本身，收了錢不會自動換來忠誠。\n>\n> 我講這些不是要你別收錢，而是想讓你看清楚：「收費」跟「先維持免費衝規模」是一個 trade-off，是商業選擇，不是誰比較有志氣的道德題。\n\n## 台灣市場的三大金流選項\n\n如果你的產品面向國際市場，選 Stripe 幾乎沒有懸念。但如果你的用戶主要在台灣，事情就複雜一些了。\n\n以下是 2026 年台灣 Solo Builder 最常用的三個金流服務：\n\n### 選項 1：Stripe\n\n**國際標準，開發者體驗最好**\n\nStripe 是全球最受歡迎的金流服務，API 設計優雅，文件品質一流，SDK 覆蓋所有主流語言。\n\n- **優點**：API 設計優雅、文件完善、Dashboard 直覺、AI 對 Stripe 的理解度極高（訓練資料多）、支援訂閱制管理、Webhook 機制完善\n- **缺點**：**⚠️ 台灣個人無法直接申請**——Stripe 官方支援約 46 國，台灣不在其中。台灣人要用 Stripe 必須先在美國（US LLC + EIN）或英國（UK Ltd）成立公司主體才能開戶；台灣用戶也不一定習慣用國際信用卡付費、手續費略高。（正面消息：Stripe Tax 已於 2025-10 支援台灣遠端賣家數位商品稅務登記）\n- **適合**：目標用戶是國際市場、或是科技圈習慣用信用卡的用戶\n\n### 選項 2：綠界 ECPay\n\n**台灣本土標準，支付方式最多元**\n\n綠界是台灣最普及的金流服務。超商付款、ATM 轉帳、信用卡、Line Pay——台灣人習慣的付費方式它都有。\n\n- **優點**：支援超商付款和 ATM（台灣用戶最習慣）、市場接受度高、個人就能申請\n- **缺點**：API 設計較舊（XML 格式）、文件品質參差不齊、Dashboard 介面老舊、AI 生成的綠界整合程式碼品質較差（訓練資料少）\n- **適合**：目標用戶是一般台灣消費者、需要超商付款和 ATM 轉帳\n\n### 選項 3：藍新 NewebPay\n\n**台灣本土，API 相對現代**\n\n藍新是台灣另一個主流金流服務，API 設計比綠界現代一些。\n\n- **優點**：API 比綠界乾淨、支援台灣主流支付方式、文件相對完整\n- **缺點**：市場知名度比綠界略低、部分進階功能需要企業帳戶\n- **適合**：想要台灣本土金流但受不了綠界 API 的開發者\n\n### 三者比較表\n\n| 面向               | Stripe       | 綠界 ECPay | 藍新 NewebPay |\n| ------------------ | ------------ | ---------- | ------------- |\n| 信用卡手續費       | 2.9% + $0.30 | 2.75%      | 2.8%          |\n| 超商付款           | ❌ 不支援    | ✅ 支援    | ✅ 支援       |\n| ATM 轉帳           | ❌ 不支援    | ✅ 支援    | ✅ 支援       |\n| Line Pay           | ❌ 不支援    | ✅ 支援    | ✅ 支援       |\n| API 品質           | ⭐⭐⭐⭐⭐   | ⭐⭐       | ⭐⭐⭐        |\n| 文件品質           | ⭐⭐⭐⭐⭐   | ⭐⭐       | ⭐⭐⭐        |\n| AI 整合友善度      | ⭐⭐⭐⭐⭐   | ⭐⭐       | ⭐⭐⭐        |\n| 訂閱制支援         | ✅ 原生支援  | ⚠️ 需自建  | ⚠️ 需自建     |\n| 台灣用戶接受度     | 🟡 中        | 🟢 高      | 🟢 高         |\n| 個人可申請         | ❌（需海外公司主體）| ✅ 可以    | ✅ 可以       |\n| 設定到收到第一筆款 | 需先成立海外公司   | 3-7 天     | 3-7 天        |\n\n> **手續費註記**：上表 Stripe 的 2.9% + $0.30 是**美國本地卡（USD）的標準費率**。若你主要收的是台灣發行的信用卡，會被歸類為跨國卡（international card），Stripe 會在基本費率上**再加約 1.5%**；如果交易還涉及貨幣轉換（顧客以非美元結帳），會**再加約 1%**——疊加後實際成本可能來到約 5.4% + $0.30，而不是平盤 2.9%。綠界 2.75%、藍新 2.8%（信用卡一次付清）為標準列表費率，實務上常有新戶優惠（綠界新戶費率曾低至 1.8%），簽約前值得議價。\n\n### 我的建議\n\n**先問自己一個問題：你的用戶會用信用卡付費嗎？**\n\n- 如果是（開發者、科技圈、國際用戶）→ **用 Stripe**\n- 如果不確定（一般台灣消費者）→ **先用綠界**，它的支付方式最多元\n- 如果你是開發者但受不了綠界的 API → **用藍新**\n\n不要一開始就串接三個。**先選一個，上線收到第一筆錢，再考慮加其他的。**\n\n## 定價策略：三種模式的選擇\n\n串接金流之前，你需要先決定定價策略。Solo Builder 常用的三種模式：\n\n### 模式 1：Freemium（免費增值）\n\n- **基本功能免費，進階功能付費**\n- 優點：降低試用門檻，用免費用戶建立口碑\n- 缺點：免費用戶的伺服器成本你要承擔，轉換率通常 2-5%\n- 適合：工具型產品、有明確的免費/付費功能分界\n\n### 模式 2：Tiered Subscription（分級訂閱）\n\n- **月費/年費，不同等級不同功能**\n- 優點：可預測的每月收入（MRR），用戶生命週期長\n- 缺點：需要持續提供價值讓用戶續費，訂閱制管理較複雜\n- 適合：SaaS 產品、有持續使用需求的服務\n\n### 模式 3：Pay Once（一次買斷）\n\n- **付一次錢，永久使用**\n- 優點：用戶心理負擔最低，交易簡單\n- 缺點：沒有持續收入，需要不斷找新客戶\n- 適合：課程、電子書、桌面應用、lifetime deal\n\n### 定價的經驗法則\n\n| 原則              | 說明                                                   |\n| ----------------- | ------------------------------------------------------ |\n| 不要免費太久      | 免費超過三個月還不收費，用戶會覺得「這本來就是免費的」 |\n| 從高定價開始      | 降價容易，漲價難。先定高一點，不夠再降                 |\n| 提供年繳折扣      | 月費 x 10 = 年費（等於送兩個月），提高年繳比例         |\n| 方案不要超過三個  | 太多選擇讓人焦慮。免費 + 基本 + 進階，三個就夠         |\n| 比競品便宜 20-30% | Solo Builder 的營運成本低，可以用價格當武器            |\n\n用 AI 幫你做定價決策：\n\n```text\n我的產品是 [描述]，目標用戶是 [描述]。\n\n競品的定價：\n- 競品 A：$X/月\n- 競品 B：$Y/月\n- 競品 C：免費（有廣告）\n\n我的產品的核心差異化是 [描述]。\n\n請建議：\n1. 定價模式（freemium / 訂閱制 / 買斷）\n2. 具體價格方案（含免費版和付費版的功能分界）\n3. 是否提供年繳折扣\n4. 第一年的收入預估（假設 1000 個免費用戶，X% 轉換率）\n```\n\n## AI 輔助金流整合：實戰 Prompt\n\n確定了金流和定價之後，來到實際串接。這是 AI 最能幫你加速的環節。\n\n### 傳統做法\n\n- 讀金流服務的文件（綠界的文件可能讓你想哭），2-4 小時\n- 寫整合程式碼，處理加密、簽名、callback，4-8 小時\n- Debug 各種奇怪的錯誤回傳碼，2-4 小時\n- 測試各種付費場景（成功、失敗、取消），2-3 小時\n- 前後花了 10-19 小時（2-3 個週末）\n\n### AI 加持做法\n\n**Step 1：讓 AI 生成整合骨架**\n\n以 Stripe 為例（完整技術選型考量可參考[第 3 章：技術選型決策框架](/blog/ai-solo-builder-tech-stack)）：\n\n```text\n我需要在我的 Astro + Hono 後端整合 Stripe 付費。\n\n需求：\n- 產品類型：SaaS 訂閱制\n- 方案：Free / Pro ($9.99/月) / Team ($29.99/月)\n- 後端框架：Hono on Cloudflare Workers\n- 資料庫：D1 (SQLite)\n\n請生成：\n1. Stripe Checkout Session 建立的 API endpoint\n2. Webhook handler（處理 checkout.session.completed、\n   customer.subscription.updated、customer.subscription.deleted）\n3. 用戶訂閱狀態的資料庫 schema\n4. 中介層：檢查用戶是否有有效訂閱\n\n技術要求：\n- TypeScript\n- 使用 stripe npm package 最新版\n- 錯誤處理要完整\n- 加上必要的 type 定義\n```\n\n**Step 2：讓 AI 生成測試用例**\n\n```text\n針對剛才的 Stripe 整合程式碼，請生成測試用例：\n\n1. 成功付款流程\n2. 付款失敗流程\n3. 訂閱升級（Free → Pro）\n4. 訂閱降級（Pro → Free）\n5. 訂閱取消\n6. Webhook 簽名驗證失敗\n7. 重複的 Webhook 事件處理\n\n使用 Vitest 測試框架。\n```\n\n**Step 3：讓 AI 生成收據 Email**\n\n```text\n請幫我生成付費成功後的收據 email 模板。\n\n資訊包含：\n- 產品名稱\n- 方案名稱和金額\n- 付款日期\n- 下次扣款日期\n- 收據編號\n- 客服聯絡方式\n\n語言：繁體中文\n風格：簡潔專業\n\n請用 HTML email 格式，確保在各種 email client 都能正確顯示。\n```\n\n用 AI 輔助，整個金流整合大約 3-4 小時可以完成——一個晚上的事。\n\n## 台灣的法律考量\n\n這一段很重要。在台灣收費做生意，有一些法律事項你必須知道：\n\n### 個人 vs. 公司\n\n| 面向       | 以個人身份收費           | 設立公司（行號/有限公司）           |\n| ---------- | ------------------------ | ----------------------------------- |\n| 設立成本   | 零                       | 行號 ~$5,000 / 公司 ~$15,000-30,000 |\n| 統一編號   | 無                       | 有                                  |\n| 開發票     | 不行                     | 可以（公司戶需要）                  |\n| 年營收門檻 | 超過一定金額需辦營業登記 | 已有登記                            |\n| 稅務       | 綜合所得稅申報           | 營利事業所得稅                      |\n| 金流申請   | 部分金流需公司帳戶       | 較容易申請各種金流                  |\n\n**我的建議：一開始用個人身份，月營收穩定超過 $8,000 再考慮設立行號。**\n\n不要在還沒賺到錢之前就花時間開公司。先驗證有人願意付費，再處理法律架構。\n\n### 電子發票\n\n如果你設了行號或公司，台灣法律要求你開發票。電子發票的串接可以用：\n\n- **綠界電子發票 API**：跟金流一起用最方便\n- **財政部電子發票平台**：免費但整合較麻煩\n\n同樣地，先有穩定營收再處理。不要一開始就花兩週在串接電子發票上。\n\n### 退款處理\n\n台灣的消費者保護法賦予消費者在收到商品 7 天內無條件解約退貨的權利（即「七日鑑賞期／猶豫期」，並非試用期）。數位內容與「一經提供即完成」的線上服務（例如 SaaS）確實有機會排除這項七日解除權，但**不是自動成立**：依《消費者保護法》第 19 條與《通訊交易解除權合理例外情事適用準則》第 2 條，例外要成立必須同時滿足兩個前提——(1) 業者在購買前以清楚方式**事先告知**將排除七日解除權，且 (2) 取得**消費者事先同意**。兩者只要缺一（例如沒在結帳流程讓使用者勾選同意），七日解除權仍然適用。實務上建議把這段告知與同意機制寫進服務條款與結帳流程的勾選同意。\n\n建議在你的服務條款中明確說明退款政策。AI 可以幫你生成：\n\n```text\n請幫我撰寫一個 SaaS 產品的退款政策。\n\n產品類型：[描述]\n定價方式：月訂閱制\n地區：台灣\n\n需要包含：\n1. 退款條件\n2. 退款流程\n3. 退款時間\n4. 例外情況\n\n語言：繁體中文\n風格：清楚明確，但不要太冰冷\n\n注意：要符合台灣消費者保護法的相關規定。\n```\n\n## 實戰案例：cloud-on-academy 的付費設定\n\n讓我用 cloud-on-academy（GCP 認證課程平台）的真實例子說明。\n\n**產品類型**：線上課程（買斷制）\n\n**金流選擇**：同時支援 Stripe（國際用戶 + 信用卡）和綠界（台灣用戶 + 超商付款）\n\n**為什麼這樣選**：課程平台的用戶分兩群。科技圈的開發者習慣用信用卡，直接用 Stripe。但也有一些在職進修的人更習慣超商付款，所以加了綠界。\n\n**串接順序**：\n\n1. 先串 Stripe（API 品質好，2 小時搞定；前提：已有美國 LLC 主體）\n2. 上線收到第一筆款\n3. 發現有用戶問「可以超商付款嗎？」\n4. 才串綠界（花了半天，主要在跟文件搏鬥）\n\n**關鍵學習**：不要一開始就串兩個金流。先上線一個，根據用戶回饋再決定要不要加。更多真實產品的付費設定細節，見[第 13 章：實戰案例——我的四個產品](/blog/ai-solo-builder-case-studies)。\n\n## 訂閱管理：升級、降級、取消\n\n如果你選的是訂閱制，你需要處理用戶的方案變更。這是很多人低估的複雜度。\n\n### 需要處理的場景\n\n| 場景        | 處理方式                        |\n| ----------- | ------------------------------- |\n| 免費 → 付費 | 建立新訂閱，立即生效            |\n| 基本 → 進階 | 立即升級，按比例計費（prorate） |\n| 進階 → 基本 | 下個計費週期生效                |\n| 付費 → 取消 | 當前週期結束後降為免費          |\n| 付費失敗    | 寬限期（3-7 天），然後降為免費  |\n| 退款        | 取消訂閱 + 退款                 |\n\n**Stripe 原生支援所有這些場景。** 這是選 Stripe 的最大優勢——你不需要自己寫訂閱管理邏輯。\n\n如果用綠界或藍新，訂閱管理需要自己建。但對 Solo Builder 來說，**一開始可以先不支援訂閱制**。用買斷制或手動管理，等用戶量大了再花時間建訂閱系統。\n\n## Start Simple：不要過度工程\n\n最後一個忠告：**不要在金流整合上過度工程。**\n\n你不需要：\n\n- ❌ 支援十種支付方式（先支援一種最多人用的）\n- ❌ 自動化的退款流程（手動處理，先了解為什麼退款）\n- ❌ 複雜的方案管理（先從一個付費方案開始）\n- ❌ 自動開發票（先手動開，或等月營收穩定再串接）\n- ❌ 多幣別支援（先只收台幣或美金）\n\n**你需要的只是：一個「付費」按鈕 → 用戶付錢 → 你收到錢 → 用戶得到存取權限。**\n\n就這樣。\n\n先把這個最小循環跑通，收到第一筆錢。然後根據實際情況，逐步完善。\n\n很多 Solo Builder 在金流整合上花了三個週末，結果做了一個「完美的付費系統」，然後發現根本沒人付費。\n\n不要成為那個人。先簡單做，先收到錢，再優化。\n\n## 本章重點回顧\n\n- 💰 收費不是佔用戶便宜，是建立信任契約。一個不收錢的產品永遠只是 side project\n- 🏦 台灣市場三大金流：Stripe（最佳 DX）、綠界（最多支付方式）、藍新（折衷選擇）\n- 📊 先問「用戶會用信用卡嗎」來選金流，不要一開始就串三個\n- 🏷️ 定價：不要免費太久、從高定價開始、方案不超過三個\n- 🤖 AI 可以在一個晚上幫你搞定金流整合：生成 API endpoint、webhook handler、測試用例、收據模板\n- ⚖️ 法律：先用個人身份，月營收穩定再考慮設立公司\n- 🎯 Start Simple：先跑通「付費 → 收錢 → 給權限」的最小循環，再逐步完善\n\n## 下一步\n\n開始收費了，代表你的產品正式進入「有人付錢」的階段。\n\n但付了錢的用戶會開始有期待、有意見、有抱怨。下一章，我們來建立**系統化的用戶回饋循環**——讓你聽懂用戶在說什麼，把有限的時間花在最值得改進的地方。\n\n👉 [第 9 章：用戶回饋循環——聽懂用戶在說什麼](/blog/ai-solo-builder-user-feedback)",
      "summary": "從免費到付費，是 side project 變成真正產品的關鍵一步。比較 Stripe、綠界、藍新的整合難度與適用場景，用 AI 一個晚上串接金流，並搞懂台灣個人收費的發票、退款與法律考量。",
      "image": "https://bobochen.dev/_astro/cover.TRg2M3zT.webp",
      "date_published": "2026-03-29T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Stripe",
        "金流",
        "綠界",
        "藍新",
        "付費機制"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-02-noodles-without-beef/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-02-noodles-without-beef/",
      "title": "沒有牛肉的牛肉湯麵",
      "content_text": "小時候外食的選項就是牛肉湯麵——但不是有牛肉的那種。鐵板燒是成年之後才第一次吃到的東西。復興口夜市 99 元的牛排，在記憶裡比後來任何一頓大餐都好吃。不是因為味道，是因為那是少數「不用在意價錢」的時刻。",
      "content_html": "## 牛肉湯麵\n\n我們家外食的頻率很低。但偶爾出去吃的時候，選項通常就是麵攤。\n\n我最常點的是牛肉湯麵。\n\n說是牛肉湯麵，其實裡頭根本沒有牛肉。就是牛骨湯頭煮的陽春麵，菜單寫得好聽，價錢倒是陽春麵的價錢。你喝得到一點牛味，碗裡卻只有麵跟幾片青菜。\n\n真正有牛肉塊的那碗，貴了二三十塊。那個差額，在小時候的我看來，是一個不能跨越的距離。\n\n我從來不會主動說「我要吃有牛肉的」。不是不想，是自動就知道不要開口。這種「自動」比你以為的還要早形成——當你聽過「用錢要省一點」之後，你的嘴巴就會自己安裝一個濾網，把所有「太貴」的要求在說出口之前就過濾掉。\n\n## 99 元的奢侈\n\n復興口夜市有一攤牛排，99 元。\n\n那是我小時候覺得最奢侈的外食。鐵板燒滋滋作響、蛋在旁邊煎得焦焦的、附一碗玉米濃湯和一個小麵包。老闆是個體型很大的人，每次看到我們來都會笑。\n\n爸偶爾會帶我去。不是常常，但那幾次在記憶裡被放大成了很多次，因為太珍貴了。\n\n99 元，在大人的世界裡可能連一杯咖啡都不到。但對小時候的我來說，那是一整個夜晚的快樂。\n\n有趣的是，長大之後我吃過很多比那貴上十倍、二十倍的餐廳，但沒有一頓飯比那 99 元的牛排好吃。\n\n問題不在味道，在氛圍。那是少數幾個「不用在意價錢」的時刻——爸帶你出來，不會跟你說「太貴了換一個」，你可以放心地吃，不用算著碗裡每一口的成本。\n\n那種放鬆感，本身就是一種奢侈。\n\n## 成年後的第一次\n\n鐵板燒，我到成年、自己開始賺錢之後才第一次吃。\n\n第一次走進鐵板燒餐廳的時候，說實話有點緊張。不是因為貴——那時候已經有收入了——而是一種「我配嗎」的感覺。\n\n這種感覺很難解釋。你明明口袋裡有錢，但坐在那個位子上，你還是會覺得自己是冒充的。好像服務生隨時會過來跟你說：「不好意思先生，這裡不是你該來的地方。」\n\n當然沒有人這樣說。但那個聲音在你自己腦袋裡，比任何外人說的都大聲。\n\n小時候有一種飲料叫養樂多。別的小孩喝的是一整排，我都是買單瓶的，因為單瓶比較便宜。這種「只買最小單位」的習慣，到現在還在。\n\n買東西之前會先看有沒有二手的。衣服能穿就不換。手機用到不能用才換。出門自己帶水壺，很少買飲料。\n\n有人說這叫節儉。但我覺得更接近的說法是，身體記住了「不夠」的感覺——就算腦子早就知道夠了，還是會自動切進省電模式。\n\n## 我想讓他們隨便點\n\n現在帶榕和辰出去吃飯的時候，我會跟他們說：「你們想吃什麼就點什麼。」\n\n這句話聽起來很普通。但每次說出口的時候，我心裡都會閃過一個畫面——小時候的我，看著菜單，自動跳過價格高的那一半。\n\n我不要他們有那種感覺。\n\n我不是說要養出不懂珍惜的小孩。他們還是得知道錢的價值、東西的來處。但我希望他們在童年的時候，至少有那種「我想吃什麼就可以吃什麼」的自在感。\n\n因為那種自在感，我從來沒有過。\n\n而一個沒有體驗過自在的人，要花很長的時間才能學會不焦慮。\n\n所以每次他們開心地點了一份兒童餐、或是在夜市指著某樣東西說「爸爸我要那個」的時候，我都會笑著說好。\n\n然後在心裡跟那個小時候沒有牛肉可以吃的自己說：沒事了。現在夠了。",
      "summary": "小時候外食的選項就是牛肉湯麵——但不是有牛肉的那種。鐵板燒是成年之後才第一次吃到的東西。復興口夜市 99 元的牛排，在記憶裡比後來任何一頓大餐都好吃。不是因為味道，是因為那是少數「不用在意價錢」的時刻。",
      "image": "https://bobochen.dev/_astro/cover.DHyQeM0u.webp",
      "date_published": "2026-03-29T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-01-family-debt/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-01-family-debt/",
      "title": "家裡沒有錢，欠了很多債",
      "content_text": "媽的一句話改變了我的整個童年。從那天起，我學會不吃午餐、不開口要東西、不讓任何人知道家裡的狀況。國中某天考試考到一半暈倒，老師才知道這個學生已經好幾天沒吃飯了。",
      "content_html": "## 那句話\n\n我不記得確切的年紀，但應該是小學低年級。\n\n那天媽跟我說了一句話：「家裡欠了很多債，以後用錢要省一點。」\n\n她的語氣很平淡，像在交代一件日常的事。但對一個小孩來說，那句話的重量遠超過她想像的。\n\n我不知道「很多債」是多少。一萬？十萬？一百萬？小學生的世界裡，超過一百塊就算大數字了。我只知道，從那天起，有些東西變了。\n\n我開始注意到家裡跟別人家不一樣。別的同學帶便當，我帶的是前一晚的剩菜。別的同學下課去買飲料，我假裝不渴。別的同學聊暑假去哪裡玩，我假裝對旅行沒興趣。\n\n不是沒興趣。是知道不可能，所以先把渴望關掉。\n\n## 省下來的午餐\n\n上了國中之後，我找到一個更直接的省錢方式：不吃午餐。\n\n邏輯很簡單。午餐要錢，不吃就省了。省下來的，可以拿去買其他「必要」的東西——筆記本、公車票、偶爾一杯飲料讓自己看起來跟同學沒什麼不同。\n\n一開始很餓。午休的時候趴在桌上，胃在抗議，教室裡飄著同學便當的味道。但人的身體會適應。大概一兩週之後，胃就不太叫了。\n\n我以為這是一個可以一直運作的系統。\n\n直到某天考試，考到一半，眼前的字開始模糊，然後整個人趴了下去。\n\n醒來的時候已經在保健室了。老師問我怎麼了，我說沒事，可能昨晚沒睡好。但老師不笨。他問了幾個問題之後，大概拼湊出了實情——這個學生已經好幾天沒好好吃飯了。\n\n後來學校安排了我吃營養午餐。那是一種很複雜的感覺。一方面鬆了一口氣，另一方面覺得丟臉。好像全世界都知道你窮。\n\n## 不能讓別人知道\n\n貧窮最累的地方，不是物質上的匱乏，是你得花大量的力氣去「看起來正常」。\n\n買東西之前先找有沒有二手的。衣服穿到不能穿才換。鞋子破了用膠帶黏。但走進教室的時候，你得表現得跟大家一樣——笑一樣的笑話、討論一樣的話題、假裝對一樣的東西感興趣。\n\n我變得很會演戲。\n\n有一次同學問我暑假去了哪裡，我隨口編了一個地方。不是什麼了不起的謊言，但說出來的那個瞬間，我覺得自己很可悲。為什麼一個小孩需要為自己的家境說謊？\n\n後來我才理解，這種心理負擔有個名字——叫做「貧窮的羞恥感」。它不會隨著長大自動消失，它會變形。小時候是不敢跟同學說家裡沒錢，長大後是買東西前的習慣性猶豫、是面對高級餐廳時的不自在、是明明已經賺得夠多了，卻還是覺得自己配不上某些東西。\n\n## 爸怎麼了\n\n家裡為什麼會欠債？因為爸。\n\n爸年輕的時候不懂事，去幫朋友當保人。結果朋友把債丟著跑了，債務全落到爸身上，留下一堆還不完的債。雪上加霜的是，爸愛喝酒，酒品不好。喝了酒就像換了一個人——會打媽媽，也會打小孩。\n\n小時候最怕的聲音，不是打雷，是爸深夜回家開門的聲音。門一開，你從他走路的方式就知道今天喝了多少。如果腳步是歪的，接下來就是漫長的一夜。\n\n媽被打的畫面，我到現在還記得。但小時候的我什麼都做不了，只能躲在房間裡，聽著外面的聲音，祈禱趕快結束。\n\n除了酒，爸還簽六合彩。那個年代很多人簽，但爸是那種會把生活費拿去簽的人。贏了就去喝酒慶祝，輸了就借錢再簽。債就是這樣像雪球一樣越滾越大。\n\n但人是複雜的。同一個會打人、會賭博的爸，也是那個會開計程車帶我去兜風的爸。這件事我花了很多年才能接受——一個人可以同時是你最害怕的人，和你最想靠近的人。\n\n背上那筆債之後，爸不笑了。這件事我記了很久。\n\n一直到很多很多年後，我的兒子出生，我抱著兒子回去看爸。他看到孫子的那一刻，笑了。那是我記憶中，他失去笑容之後，第一次真正笑出來。\n\n人生就是這樣。有些東西失去了，要等到下一個世代才能找回來。\n\n## 媽開始工作\n\n背上那筆債之後，媽去工廠上班。\n\n在那之前，媽是在傳統市場的菜攤工作。\n\n後來她去的是一間做娃娃的工廠。我不知道具體的工作內容，但記得她每天回家都很累。手指有時候會有膠水的痕跡，指甲裡卡著顏料。\n\n但她從來不抱怨。\n\n這件事我到長大之後才覺得不對勁。一個人承受了那麼大的生活轉變——從菜攤的零工到工廠作業員、從有丈夫支撐到獨自扛起經濟——她怎麼可能「從來不抱怨」？\n\n答案大概是：她把抱怨的時間都拿去工作了。或者，她覺得在孩子面前抱怨，只會讓事情更糟。\n\n媽的堅強，不是那種會上新聞的堅強。沒有感人的故事、沒有勵志的金句。她只是每天早上起來，做該做的事，然後晚上回家，繼續做該做的事。日復一日。\n\n## 成年之後才吃到的東西\n\n小時候家裡外食的選項，就是牛肉湯麵。\n\n但不是有牛肉的那種。是那種只有湯、有麵、菜單上寫著「牛肉湯麵」但你點的其實是最便宜的陽春麵，只是沾了一點牛肉湯的味道。\n\n鐵板燒？那是成年之後、自己開始賺錢之後才第一次吃到的東西。\n\n復興夜市有一家牛排，99 元。那是我小時候覺得最奢侈的外食。爸偶爾會帶我去，老闆是個很大隻的人，每次看到我們來都會笑。那 99 元的牛排，在記憶裡比後來吃過的任何一頓大餐都好吃。\n\n不是因為味道特別好，是因為那是少數「不用在意價錢」的時刻。\n\n這些經歷在我身上留下了一個很明顯的印記——節儉。到現在，要買任何超過一定金額的東西，我都會先去找有沒有二手的。不是買不起，是身體記住了那種「錢不夠」的緊繃感，即使腦子已經知道夠了，身體還是會自動進入節省模式。\n\n## 這些，我會跟孩子說\n\n我有兩個小孩。大的叫榕，小的叫辰。\n\n有一天我會跟他們說這些故事。不是為了讓他們覺得爸爸好可憐、好辛苦。是為了讓他們知道一件事：\n\n**你們現在擁有的每一個平凡日常——每天有飯吃、有學校上、有爸爸接送、有週末可以出去玩——這些不是理所當然的。**\n\n爸爸小時候沒有這些。爸爸花了很多年，才走到可以給你們這些的位置。\n\n而爸爸最想讓你們知道的是：不管家裡有沒有錢，最重要的是有人陪在你身邊。\n\n錢可以再賺，但陪伴的時間過了就過了。\n\n這是這些年教會我的事。也是整個系列故事的起點。",
      "summary": "媽的一句話改變了我的整個童年。從那天起，我學會不吃午餐、不開口要東西、不讓任何人知道家裡的狀況。國中某天考試考到一半暈倒，老師才知道這個學生已經好幾天沒吃飯了。",
      "image": "https://bobochen.dev/_astro/cover.DYPECTvk.webp",
      "date_published": "2026-03-28T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-tools-landscape-2026/",
      "url": "https://bobochen.dev/blog/agentic-engineering-tools-landscape-2026/",
      "title": "2026 年 AI Coding 工具全景圖：Cursor、Claude Code、Codex、Devin，我全用過",
      "content_text": "市面上至少 20 個 AI coding 工具，哪個適合你？不是功能比較表，而是一個全部都用過的人告訴你每個工具的「甜蜜點」在哪裡、踩過什麼坑，以及我最後為什麼選了現在這套組合。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第三篇。上一篇：[工程師角色重新定義](/blog/agentic-engineering-mindset-shift)\n\n## 我的信用卡帳單不會騙人\n\n我的信用卡帳單上同時有 Cursor Pro、Claude Pro、GitHub Copilot 三筆訂閱。加上偶爾用的 Anthropic API，上個月光 AI coding 工具就花了快 $200 美金。\n\n是的，我全試過了。而且不是「試用兩天就退訂」的那種試，是「認真用在 production 專案三個月以上」的那種。\n\n這篇不是功能比較表，那種表格你 Google 一下就有幾十篇。這篇是一個花了真金白銀、用真實專案驗證過的人，告訴你每個工具的「甜蜜點」和「踩坑紀錄」。\n\n## AI Coding 工具分類框架\n\n在比較個別工具之前，先建立一個框架。市面上所有 AI coding 工具，可以按照「自主程度」分成四層：\n\n### Level 1：Autocomplete（自動補完）\n\n最基本的一層。你在打字，AI 猜你接下來要寫什麼，按 Tab 接受。\n\n代表工具：GitHub Copilot 的 tab completion、Cursor 的 tab prediction。\n\n**適用場景**：重複性的 boilerplate code、已知 pattern 的實作。就像手機鍵盤的預測文字，方便，但不會幫你思考。\n\n### Level 2：Chat（對話式）\n\n你可以問 AI 問題、請它解釋 code、或者讓它產生一段程式碼給你複製。\n\n代表工具：Copilot Chat、Cursor Chat、ChatGPT、Gemini。\n\n**適用場景**：理解不熟悉的 code、生成 snippet、brainstorm 解法。本質上還是你在主導，AI 是你的顧問。\n\n### Level 3：Agent（代理執行）\n\nAI 可以直接操作你的 codebase——讀檔案、寫檔案、跑指令、修 bug。你給它任務，它自己去做。\n\n代表工具：**Claude Code**、Cursor Composer Agent Mode、**Codex CLI**、Gemini CLI。\n\n**適用場景**：完整的 feature 開發、bug fix、refactoring。你從「寫 code 的人」變成「管 agent 的人」，這是目前 agentic engineering 的主戰場。\n\n### Level 4：Autonomous（全自主）\n\nAI 不只執行你的指令，而是可以自主工作數小時甚至數天。你設定目標，它自己規劃、執行、測試、提交 PR。\n\n代表工具：**Devin**、Codex Cloud、**AWS Kiro** Autonomous Mode。\n\n**適用場景**：長時間的 migration、大範圍的 test coverage 補全、independent project setup。但目前可靠性仍然有限。\n\n大部分工程師的日常都落在 Level 2-3 之間。Level 4 很酷，但還不夠可靠，拿來做主力還太早。\n\n## 第一梯隊深度比較：Cursor vs Claude Code vs Codex CLI\n\n這三個是我每天在用的工具。不是客觀的 benchmark 比較，是主觀的長期使用心得。\n\n### Cursor\n\n**我用了多久**：一年多。Cursor 是我 AI coding 的起點。\n\n**甜蜜點**：\n\n- **File-aware editing** 是它最強的地方。它真的理解你 codebase 的結構，auto-complete 的準確度在日常 coding 場景裡是最高的。\n- **Tab prediction** 有時候準到有點可怕。你才剛想到要寫什麼，它已經建議好了。\n- **Agent Mode** 加入之後，它可以做一些簡單的 multi-file 修改。\n- 對前端開發特別友好——React、CSS、HTML 的補完非常到位。\n\n**踩坑紀錄**：\n\n- Context window 號稱 200K，但處理大型專案時，我常常覺得它「忘記」了之前看過的檔案。\n- Agent Mode 對於複雜的跨檔案修改還是不夠可靠，常常改了 A 忘了更新 B。\n- 價格分級太多了。Pro $20/mo 的 200 次 premium requests 很快就用完。\n\n**最適合**：日常 coding、快速 iteration、前端開發、pair programming 式的工作流。\n\n### Claude Code\n\n**我用了多久**：重度使用九個月。現在是我的主力工具。\n\n**甜蜜點**：\n\n- **1M token context window** 是 game changer。複雜的 multi-file 問題，它真的能 hold 住整個 context。\n- **複雜問題處理能力** 是三個裡面最強的。那種需要讀十幾個檔案、理解系統架構、然後做出正確修改的 bug，Claude Code 的成功率明顯高於其他兩個。\n- **Terminal-based** 的操作方式看似原始，但其實更符合 agentic 工作流——你下指令，它自己去做，你不需要盯著 IDE 看。\n- **CLAUDE.md** 配置系統讓你可以高度自訂 agent 的行為。這在後面的 [CLAUDE.md 大師班](/blog/claude-md-rules-files-masterclass) 會深入討論。\n\n**踩坑紀錄**：\n\n- 成本可以很高。用 Opus model 做複雜任務，一天的 API 費用可能超過 $30。\n- 偶爾會過度自信——修了 A 但沒注意到 A 的改動會影響 B。\n- 沒有 IDE 的視覺化界面，新手上手曲線比較陡。\n\n**最適合**：複雜問題（multi-file bugs、架構決策）、不熟悉的 codebase、需要深度推理的任務。\n\n### Codex CLI\n\n**我用了多久**：斷斷續續用了幾個月。最近 GPT-5.3-Codex 出來之後用得更多。\n\n**甜蜜點**：\n\n- **Linux kernel-level sandboxing**——安全性做得最好。每次執行都在嚴格的 sandbox 裡，不用擔心 agent 搞壞你的環境。\n- **1M token context**，跟 Claude Code 同級。\n- **GPT-5.3-Codex** 比上一版快 25%，而且支援 interactive steering——你可以中途修改方向而不會丟失 context。\n- 自稱是第一個「參與自身開發」的 model。\n\n**踩坑紀錄**：\n\n- 對於需要理解複雜架構的任務，我覺得推理能力略遜於 Claude Code。\n- OpenAI 的 ecosystem 跟 Anthropic 的不同，遷移設定有一些摩擦成本。\n- 定價結構比較不透明。\n\n**最適合**：需要高安全性的環境、想要 second opinion 的時候、OpenAI ecosystem 的使用者。\n\n### 三工具對照表\n\n| 維度           | Cursor                | Claude Code     | Codex CLI    |\n| -------------- | --------------------- | --------------- | ------------ |\n| Context Window | 200K                  | 1M              | 1M           |\n| Sandbox        | OS-level（2026 新增） | Namespace-based | Linux kernel |\n| 起步價         | $20/mo                | $20/mo (Pro)    | $20/mo       |\n| 重度使用月費   | $60-200               | $100-200 (API)  | 依使用量     |\n| 最強場景       | 日常 coding、前端     | 複雜問題、架構  | 安全敏感環境 |\n| 最弱場景       | 大型跨檔案修改        | 簡單快速修改    | 複雜推理     |\n| 我的使用佔比   | 15%                   | 80%             | 5%           |\n\n## 第二梯隊評估：Devin 2.0 / AWS Kiro / JetBrains Central\n\n這三個我使用時間不長，以下是初步評估而非深度心得。\n\n### Devin 2.0\n\nCognition 推出的「AI 軟體工程師」。2.0 版的升級很大——agent-native IDE、multi-Devin orchestration（一個 Devin 可以管理其他 Devin），PR merge rate 從 34% 跳到 67%。Goldman Sachs 在測試把它當「新員工」用。\n\n**我的觀察**：概念很超前，但 67% 的 merge rate 意味著還有 1/3 的 PR 是不能直接用的。適合定義非常明確、可以 fire-and-forget 的任務。它跟 Claude Code 的定位不太一樣，Devin 更像「自動駕駛」，Claude Code 更像「有很好的 AI 副駕」。\n\n### AWS Kiro\n\nAmazon 推出的 spec-driven agent IDE。它的核心理念是先寫結構化的 spec，然後 agent 照 spec 執行。Autonomous agent 模式可以持續工作數小時甚至數天。\n\n**我的觀察**：Spec-driven 的理念完全正確（這也是我在 [Spec-Driven Development](/blog/spec-driven-development-for-agents) 那篇會深入討論的）。但它目前跟 AWS 生態系綁得比較深，如果你不在 AWS 上開發，摩擦成本可能比較高。\n\n### JetBrains Central\n\n2026 年 3 月 24 日剛發表。這不只是一個 IDE，而是一個「agentic software development 的控制平面」，包含 governance、agent execution infrastructure、和 shared semantic context。Partner 陣容很豪華：Google Cloud、Anthropic、OpenAI。\n\n**我的觀察**：太新了，還在 EAP（Early Access Program）。但 JetBrains 在 developer tool 領域的 track record 很好。值得關注，但現在還不是「你該用」的階段。\n\n## 我的最終組合與為什麼\n\n實戰一年下來，我的主力組合是：\n\n- **Claude Code 80%**——所有需要「思考」的任務：複雜 bug、架構決策、multi-file 修改、不熟悉的 codebase。\n- **Cursor 15%**——routine coding、快速 iteration、前端細節調整。當我需要「寫」多於「想」的時候。\n- **其他 5%**——Copilot 的 tab completion 偶爾用、Codex CLI 偶爾拿來做 second opinion。\n\n核心原則：**不同任務配不同工具**。\n\n| 任務類型            | 我選什麼         | 為什麼                             |\n| ------------------- | ---------------- | ---------------------------------- |\n| 複雜 bug fix        | Claude Code      | 需要深度推理和大 context           |\n| 新 feature 從零開始 | Claude Code      | 需要架構決策                       |\n| UI 微調 / CSS 修改  | Cursor           | 視覺回饋快，iteration 快           |\n| 快速 boilerplate    | Cursor / Copilot | Tab completion 最快                |\n| 不熟悉的 repo 探索  | Claude Code      | 1M context 讓它能 hold 住大量 code |\n| 需要 second opinion | Codex CLI        | 不同 model 的另一個視角            |\n\n## 選工具的五個常見錯誤\n\n最後分享五個我看到（也犯過）的錯誤：\n\n### 1. 功能多 ≠ 適合你\n\n大部分時候 Level 3（agent）就夠了，你不一定真的需要 autonomous agent。盲目追最新最強的工具，不如把現有工具用到極致。\n\n### 2. Context window 大 ≠ 用得到\n\n某些工具號稱的 context window 很大，但 effective context 可能只有一半。Windsurf 曾經宣傳很大的 window，但有開發者實測 effective context 只有 50-70K tokens。看規格不如看實際體感。\n\n### 3. 價格低 ≠ 省錢\n\n便宜的工具如果 output 品質差、要花更多時間 debug，你的總成本反而更高。一個 $20/月的工具讓你每天多花 30 分鐘修 agent 的錯，一個月就是 10 小時——你的時薪乘以 10 小時，大概比 $200 的工具貴多了。\n\n### 4. 跟風 ≠ 對\n\nTwitter 上的 influencer 用某個工具用得很順，不代表你也會。你的 codebase、你的 tech stack、你的工作流都不一樣。唯一可靠的方式是自己試。\n\n### 5. 一個工具打天下\n\n這是最常見的錯誤。沒有一個工具適合所有場景。就像你不會只用一支螺絲起子，也不該只押一個 AI coding 工具，組合著用才是最佳解。\n\n## Takeaway\n\n1. AI coding 工具有四層分類（Autocomplete → Chat → Agent → Autonomous）。搞清楚你需要哪一層再選，不要殺雞用牛刀。\n\n2. 沒有最好的工具，只有最適合你當下任務的工具。我的組合是 Claude Code 80% + Cursor 15% + 其他 5%，但你的組合不一定要一樣，關鍵是根據任務類型來選。\n\n3. 願意花 $50-200/月在 AI coding 工具上的工程師，投資報酬率通常是正的。如果一個 $100/月的工具讓你每天省 1 小時，一個月就是 20 小時。這筆帳，怎麼算都划算。\n\n---\n\n_上一篇：[工程師角色重新定義](/blog/agentic-engineering-mindset-shift)_\n_下一篇：[Context Engineering 深度解析](/blog/context-engineering-deep-dive)_",
      "summary": "市面上至少 20 個 AI coding 工具，哪個適合你？不是功能比較表，而是一個全部都用過的人告訴你每個工具的「甜蜜點」在哪裡、踩過什麼坑，以及我最後為什麼選了現在這套組合。",
      "image": "https://bobochen.dev/_astro/cover.CFHIGzFW.webp",
      "date_published": "2026-03-27T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "AI",
        "Cursor",
        "Claude Code",
        "Codex",
        "Devin",
        "工具比較"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-extended-thinking/",
      "url": "https://bobochen.dev/blog/claude-api-guide-extended-thinking/",
      "title": "Extended Thinking：複雜推理任務的殺手鐧",
      "content_text": "深入解析 Claude Extended Thinking：適用與不適用場景、budget_tokens 設定策略、thinking blocks 的回應格式、與 Streaming 結合、成本計算，以及 with/without thinking 的實際效果對比。",
      "content_html": "讓我問你一個問題。\n\n假設你要做一個很困難的決策——例如評估一個複雜的技術方案，或者分析一份合約的風險。你會怎麼做？\n\n你不會在被問到的瞬間就立刻說出答案。你會先想一想：「這個方案的優點是什麼？缺點是什麼？有沒有遺漏什麼面向？」\n\n這就是 Extended Thinking 給 Claude 的能力：**在回答你之前，先花時間真正地思考**。\n\n## Extended Thinking 是什麼\n\n一般情況下，Claude 生成回應是一個「前向傳遞」的過程——它一邊思考一邊輸出文字，無法「回頭修改」。這對大多數任務來說沒問題，但對於需要多步驟推理的困難問題，這種方式有根本的限制。\n\nExtended Thinking 給 Claude 一個**專用的「思考空間」（thinking tokens）**。在這個空間裡，Claude 可以自由地探索問題、嘗試不同的推理路徑、驗證中間結果，而不需要立刻給出答案。思考完成後，它才把最終結論作為回答輸出。\n\n你在 API 回應裡看到的是「thinking blocks」——Claude 的完整思考過程（Anthropic 選擇讓開發者可以看到這些思考過程，增加透明度）。\n\n這個機制在 Anthropic 的研究中顯示，在困難的數學、邏輯、程式設計問題上，Extended Thinking 可以大幅提升準確率。在某些基準測試（例如 AIME 數學競賽題）上，提升幅度超過 20 個百分點。\n\n## 適用場景\n\nExtended Thinking 不是萬能藥。它最有價值的場景：\n\n### 1. 數學與量化推理\n\n```\n\"以下是一個投資組合，請計算夏普比率，並分析是否需要再平衡...\"\n\"這個演算法的時間複雜度是多少？能優化到 O(n log n) 嗎？\"\n\"解這個微分方程：dy/dx = 2xy，初始條件 y(0) = 1\"\n```\n\nClaude 在沒有 thinking 的情況下做複雜計算容易出錯——它必須在「思考計算過程」和「輸出文字」之間切換。Thinking 讓它把計算做完再輸出。\n\n### 2. 邏輯推理和謎題\n\n```\n\"五個人各自住在不同顏色的房子裡，按照以下線索，誰養了魚？...\"\n\"這段程式碼有個 bug，在並發場景下會出現 race condition，請找出來並修復\"\n```\n\n### 3. 多步驟計畫和分析\n\n```\n\"我想建立一個 SaaS 產品，目標市場是台灣中小企業的 HR 部門，請幫我做競爭分析，\n 包括主要競爭對手、市場定位、定價策略，以及我應該優先實作的 MVP 功能\"\n```\n\n### 4. 複雜的程式碼設計\n\n```\n\"請設計一個分散式任務隊列系統，需要支援優先級、重試機制、dead letter queue，\n 以及水平擴展。請給出完整的架構設計和主要元件的介面定義\"\n```\n\n### 5. 需要仔細評估多個選項的決策\n\n```\n\"我有三個 API 設計方案，各有優缺點，請幫我分析哪個最適合我們的使用場景...\"\n```\n\n## 不適用場景\n\n同樣重要的是知道什麼時候**不要用** Extended Thinking：\n\n**簡單問答**：「台灣的首都是哪裡？」「Python 的 list comprehension 語法怎麼寫？」——這些問題 Claude 早就知道答案，讓它思考 5000 tokens 是浪費錢。\n\n**創意寫作**：寫詩、寫故事、頭腦風暴創意——這些任務的「好答案」不是靠邏輯推導出來的，thinking 對品質提升幫助有限。\n\n**速度敏感的場景**：即時客服、使用者打字時的即時回應——thinking 需要額外時間，會增加等待時間。\n\n**翻譯和格式轉換**：把英文翻成中文、把 CSV 轉 JSON——直接執行比先思考效率更高。\n\n我的經驗法則：**如果你自己解這個問題需要打草稿、分步驟計算、或者仔細想才能確認答案，那就值得用 Extended Thinking**。\n\n## API 使用方式\n\n在 API 呼叫中啟用 Extended Thinking：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\nresponse = client.messages.create(\n    model=\"claude-sonnet-4-6\",  # Extended Thinking 需要特定模型版本\n    max_tokens=16000,\n    thinking={\n        \"type\": \"enabled\",\n        \"budget_tokens\": 10000  # Claude 最多可以用多少 tokens 來思考\n    },\n    messages=[{\n        \"role\": \"user\",\n        \"content\": \"在 1 到 100 之間有多少個質數？請一一列出並計算。\"\n    }]\n)\n\n# 回應會包含 thinking blocks 和 text blocks\nfor block in response.content:\n    if block.type == \"thinking\":\n        print(\"=== Claude 的思考過程 ===\")\n        print(block.thinking)\n        print()\n    elif block.type == \"text\":\n        print(\"=== Claude 的回答 ===\")\n        print(block.text)\n```\n\n**注意**：`max_tokens` 必須大於 `budget_tokens`，因為模型還需要 tokens 來生成最終的回答。\n\n## budget_tokens 的設定策略\n\n`budget_tokens` 是 Extended Thinking 最難掌握的參數。\n\n它的範圍是 1024（最小）到模型支援的最大值（視模型而定，通常是 32768 甚至更高）。你設定的是**上限**，Claude 可能用不完你給的預算。\n\n幾個我測試出來的設定策略：\n\n### 按任務難度分層\n\n```python\nTHINKING_BUDGETS = {\n    \"simple_math\": 1024,        # 簡單計算\n    \"complex_analysis\": 8000,   # 複雜分析\n    \"hard_reasoning\": 16000,    # 困難推理\n    \"research_synthesis\": 32000, # 大量資訊整合\n}\n\ndef smart_thinking_budget(task_type: str) -> dict:\n    budget = THINKING_BUDGETS.get(task_type, 4000)\n    return {\"type\": \"enabled\", \"budget_tokens\": budget}\n```\n\n### 動態設定（根據問題長度）\n\n```python\ndef estimate_thinking_budget(user_message: str) -> int:\n    \"\"\"根據問題的長度和複雜度估算需要的 thinking budget\"\"\"\n    word_count = len(user_message)\n\n    # 關鍵字偵測\n    complexity_keywords = [\n        \"分析\", \"比較\", \"設計\", \"架構\", \"優化\", \"評估\",\n        \"計算\", \"證明\", \"推導\", \"解釋\", \"規劃\"\n    ]\n    complexity_score = sum(1 for k in complexity_keywords if k in user_message)\n\n    base_budget = 2048\n    length_bonus = min(word_count * 2, 4096)\n    complexity_bonus = complexity_score * 1000\n\n    return min(base_budget + length_bonus + complexity_bonus, 16000)\n```\n\n### 我的實戰建議\n\n- **不確定時，從 4000-8000 開始**。這個範圍對大多數「中等複雜」的問題足夠，而且不會太貴\n- **數學和程式碼問題，給 8000-16000**。這些問題通常需要多次驗算\n- **不要設太低**：我試過給 1024 做複雜問題，thinking 被切斷了，反而影響品質\n- **測試再決定**：不同問題類型需要不同預算，最好用你的真實 use case 測試\n\n## 回應格式：Thinking Blocks vs Text Blocks\n\n啟用 Extended Thinking 後，`response.content` 會是一個混合陣列：\n\n```python\nresponse.content = [\n    ThinkingBlock(\n        type=\"thinking\",\n        thinking=\"讓我系統性地思考這個問題...\\n\\n首先，我需要確認...\\n...\",\n    ),\n    TextBlock(\n        type=\"text\",\n        text=\"根據分析，答案是...\"\n    )\n]\n```\n\nThinking blocks 的內容是 Claude 的**原始思考流**，包含：\n\n- 自我問答（「等等，這裡我算錯了...」）\n- 多種方案的比較\n- 中間計算步驟\n- 反思和修正\n\n這些思考過程對你來說通常是有價值的透明度——你可以理解 Claude 是怎麼得出結論的。但在面向使用者的產品中，你可能不想把完整的思考過程顯示出來。\n\n```python\ndef get_final_answer(response) -> str:\n    \"\"\"只取最終的文字回答，不包含思考過程\"\"\"\n    return \" \".join(\n        block.text\n        for block in response.content\n        if block.type == \"text\"\n    )\n\ndef get_thinking_and_answer(response) -> tuple[str, str]:\n    \"\"\"分別取出思考過程和最終答案\"\"\"\n    thinking = \" \".join(\n        block.thinking\n        for block in response.content\n        if block.type == \"thinking\"\n    )\n    answer = \" \".join(\n        block.text\n        for block in response.content\n        if block.type == \"text\"\n    )\n    return thinking, answer\n```\n\n## Streaming + Extended Thinking\n\nStreaming 和 Extended Thinking 可以同時使用，但有一點要注意：思考過程在串流時會以 `thinking_delta` 的形式出現：\n\n```python\nwith client.messages.stream(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=16000,\n    thinking={\"type\": \"enabled\", \"budget_tokens\": 8000},\n    messages=[{\"role\": \"user\", \"content\": \"請分析...\"}]\n) as stream:\n    in_thinking = False\n\n    for event in stream:\n        if event.type == \"content_block_start\":\n            if event.content_block.type == \"thinking\":\n                in_thinking = True\n                print(\"\\n[Claude 開始思考...]\\n\", end=\"\", flush=True)\n            elif event.content_block.type == \"text\":\n                in_thinking = False\n                print(\"\\n[Claude 的回答：]\\n\", end=\"\", flush=True)\n\n        elif event.type == \"content_block_delta\":\n            if event.delta.type == \"thinking_delta\":\n                # 顯示思考過程（可選）\n                print(event.delta.thinking, end=\"\", flush=True)\n            elif event.delta.type == \"text_delta\":\n                # 顯示最終回答\n                print(event.delta.text, end=\"\", flush=True)\n```\n\n在面向使用者的產品中，一個好的 UX 做法是：\n\n1. 顯示「AI 正在思考中...」的動畫，同時接收 thinking blocks（不顯示詳細內容）\n2. 當 text blocks 開始時，顯示「AI 開始回答：」並串流文字\n\n這讓使用者知道系統沒有當機，同時不被原始思考過程的混亂內容搞混。\n\n## 成本計算\n\n這是很多人忽略的部分：**thinking tokens 是需要付費的**，而且算在 output tokens 裡。\n\n假設你設定 `budget_tokens=8000`，Claude 用了 6000 tokens 思考，輸出了 500 tokens 的回答：\n\n```\nInput tokens:  1000 (你的 prompt)\nOutput tokens: 6500 (6000 thinking + 500 answer)\n\n使用 claude-sonnet-4-6：\nInput cost:  1000 / 1,000,000 * $3  = $0.003\nOutput cost: 6500 / 1,000,000 * $15 = $0.0975\nTotal: 約 $0.10（台幣約 3 元）\n```\n\n一次呼叫 $0.10 聽起來不多，但如果你的應用有很多使用者，每天跑幾千次，成本就很可觀了。\n\n**成本優化建議：**\n\n1. **只在真正需要的場景用 Extended Thinking**，不要無差別地開啟\n2. **設定合理的 budget_tokens 上限**，不要給 32000 tokens 的預算去解一個 8000 tokens 就夠的問題\n3. **監控實際使用的 thinking tokens**：`response.usage` 裡有 `cache_creation_input_tokens` 等欄位，你也可以從 content blocks 的長度估算\n\n```python\n# 監控 thinking 使用量\ndef analyze_thinking_usage(response):\n    thinking_tokens = 0\n    text_tokens = 0\n\n    for block in response.content:\n        if block.type == \"thinking\":\n            # 粗略估算：每個字符約 1 token\n            thinking_tokens += len(block.thinking)\n        elif block.type == \"text\":\n            text_tokens += len(block.text)\n\n    print(f\"Thinking usage: ~{thinking_tokens} chars\")\n    print(f\"Answer length: ~{text_tokens} chars\")\n    print(f\"Total output tokens: {response.usage.output_tokens}\")\n```\n\n## 實際效果對比\n\n讓我用一個具體例子展示 with vs without Extended Thinking 的差異。\n\n### 問題：找出以下程式碼的 Race Condition\n\n```python\n# 測試問題：這段 Python 程式碼在並發環境有什麼問題？\n\"\"\"\nclass Counter:\n    def __init__(self):\n        self.count = 0\n\n    def increment(self):\n        current = self.count\n        time.sleep(0.001)  # 模擬處理時間\n        self.count = current + 1\n\n# 在 10 個線程同時呼叫 increment()，最終 count 應該是 10\n# 但實際上通常是 1-3\n\"\"\"\n```\n\n**不使用 Extended Thinking 的回答（縮短版）：**\n\n> 這段程式碼有 race condition 問題。`increment()` 方法不是原子操作：讀取 `current`、等待、然後寫入 `self.count` 這三步之間可能被其他線程介入...\n\n（回答是對的，但直接說出結論，沒有深度分析）\n\n**使用 Extended Thinking 後（縮短的 thinking 過程）：**\n\n> _[Thinking 開始]_\n> 讓我仔細分析這個問題。\n>\n> 首先，有個 `time.sleep(0.001)`，這明顯是在模擬 I/O 操作或計算。在這個等待期間，GIL 會被釋放（因為 `time.sleep` 會讓出 GIL）...\n>\n> 等等，讓我想想 Python GIL 的行為。Python 的 GIL 確保一次只有一個線程執行 Python bytecode，但 `time.sleep` 會釋放 GIL，讓其他線程有機會執行。\n>\n> 所以時間線是：\n> 線程 A: current = 0\n> 線程 A: sleep（GIL 釋放）\n> 線程 B: current = 0（因為 count 還是 0）\n> 線程 B: sleep\n> 線程 A: self.count = 0 + 1 = 1\n> 線程 B: self.count = 0 + 1 = 1（覆蓋了！）\n>\n> 修復方案有幾種：\n>\n> 1. threading.Lock()\n> 2. threading.Event()\n> 3. 用 queue.Queue 做工作隊列\n> 4. 改用 multiprocessing\n>\n> 哪個最合適？需要考慮使用場景...\n> _[Thinking 結束]_\n\n**最終回答（帶 thinking 的版本）：**\n\n> 這段程式碼存在一個典型的 Check-Then-Act（讀取-修改-寫回）Race Condition。根本原因是 `increment()` 方法不是原子操作，而 `time.sleep()` 會釋放 Python GIL，導致多個線程可能同時讀到舊的值，然後各自寫入，造成「更新丟失」...\n> （接著給出三種修復方案和各自的適用場景）\n\n差異很明顯：帶 thinking 的回答不只是說「有問題」，而是展示了完整的推理路徑，並且主動考慮了多種解決方案的取捨。\n\n## 完整的 Python 範例\n\n```python\nimport anthropic\nfrom typing import Optional\n\nclient = anthropic.Anthropic()\n\n\ndef analyze_with_thinking(\n    question: str,\n    budget_tokens: int = 8000,\n    show_thinking: bool = False\n) -> dict:\n    \"\"\"\n    使用 Extended Thinking 分析複雜問題\n\n    Args:\n        question: 要分析的問題\n        budget_tokens: 思考預算（tokens）\n        show_thinking: 是否在回傳中包含思考過程\n\n    Returns:\n        包含 answer、thinking（可選）、usage 的字典\n    \"\"\"\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=budget_tokens + 4096,  # 思考 + 回答的總預算\n        thinking={\n            \"type\": \"enabled\",\n            \"budget_tokens\": budget_tokens\n        },\n        messages=[\n            {\"role\": \"user\", \"content\": question}\n        ]\n    )\n\n    result = {\n        \"answer\": \"\",\n        \"usage\": {\n            \"input_tokens\": response.usage.input_tokens,\n            \"output_tokens\": response.usage.output_tokens,\n        }\n    }\n\n    thinking_parts = []\n    answer_parts = []\n\n    for block in response.content:\n        if block.type == \"thinking\":\n            thinking_parts.append(block.thinking)\n        elif block.type == \"text\":\n            answer_parts.append(block.text)\n\n    result[\"answer\"] = \"\\n\".join(answer_parts)\n\n    if show_thinking:\n        result[\"thinking\"] = \"\\n\".join(thinking_parts)\n\n    return result\n\n\ndef compare_with_without_thinking(question: str) -> None:\n    \"\"\"比較有無 Extended Thinking 的差異\"\"\"\n    print(f\"問題：{question}\\n\")\n\n    # 不使用 thinking\n    print(\"=== 不使用 Extended Thinking ===\")\n    standard_response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=2048,\n        messages=[{\"role\": \"user\", \"content\": question}]\n    )\n    standard_answer = standard_response.content[0].text\n    print(f\"回答：{standard_answer[:300]}...\")\n    print(f\"Output tokens: {standard_response.usage.output_tokens}\\n\")\n\n    # 使用 thinking\n    print(\"=== 使用 Extended Thinking (budget: 6000) ===\")\n    thinking_result = analyze_with_thinking(\n        question,\n        budget_tokens=6000,\n        show_thinking=True\n    )\n    print(f\"思考過程（前 200 字）：{thinking_result.get('thinking', '')[:200]}...\")\n    print(f\"回答：{thinking_result['answer'][:300]}...\")\n    print(f\"Output tokens: {thinking_result['usage']['output_tokens']}\")\n\n\n# 實際使用範例\nif __name__ == \"__main__\":\n    # 範例 1：數學推理\n    result = analyze_with_thinking(\n        \"\"\"\n        一個公司有 3 個部門：A、B、C。\n        - 部門 A 有 20 人，平均薪資 60000 元\n        - 部門 B 有 15 人，平均薪資 80000 元\n        - 部門 C 有 10 人，平均薪資 100000 元\n\n        請計算：\n        1. 整個公司的平均薪資\n        2. 如果公司要全員加薪 10%，月薪資總支出增加多少？\n        3. 如果只有薪資低於公司平均的員工加薪，使他們達到公司平均水準，\n           月薪資總支出增加多少？\n        \"\"\",\n        budget_tokens=4000\n    )\n    print(\"薪資計算結果：\")\n    print(result[\"answer\"])\n    print(f\"\\n使用的 tokens - Input: {result['usage']['input_tokens']}, Output: {result['usage']['output_tokens']}\")\n```\n\n## TypeScript 完整範例\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n\ninterface ThinkingResult {\n  thinking?: string;\n  answer: string;\n  inputTokens: number;\n  outputTokens: number;\n}\n\nasync function analyzeWithThinking(\n  question: string,\n  budgetTokens: number = 8000,\n  includeThinking: boolean = false\n): Promise<ThinkingResult> {\n  const response = await client.messages.create({\n    model: 'claude-sonnet-4-6',\n    max_tokens: budgetTokens + 4096,\n    thinking: {\n      type: 'enabled',\n      budget_tokens: budgetTokens,\n    },\n    messages: [{ role: 'user', content: question }],\n  });\n\n  const thinkingParts: string[] = [];\n  const answerParts: string[] = [];\n\n  for (const block of response.content) {\n    if (block.type === 'thinking') {\n      thinkingParts.push(block.thinking);\n    } else if (block.type === 'text') {\n      answerParts.push(block.text);\n    }\n  }\n\n  return {\n    thinking: includeThinking ? thinkingParts.join('\\n') : undefined,\n    answer: answerParts.join('\\n'),\n    inputTokens: response.usage.input_tokens,\n    outputTokens: response.usage.output_tokens,\n  };\n}\n\n// 使用範例\nasync function main() {\n  const result = await analyzeWithThinking(\n    `請分析以下技術決策：\n    我們正在建立一個需要處理每秒 10,000 個請求的 API 服務。\n    現在需要在以下三個方案中選擇一個：\n    1. Node.js + Express + PostgreSQL\n    2. Go + Gin + PostgreSQL\n    3. Python + FastAPI + PostgreSQL\n\n    考慮因素：團隊主要是 TypeScript 開發者，但有 3 位有 Python 經驗，\n    無人有 Go 經驗。預算允許 6 個月的開發時間。`,\n    12000, // 這個問題需要較多思考\n    true // 顯示思考過程\n  );\n\n  if (result.thinking) {\n    console.log('思考過程：');\n    console.log(result.thinking.substring(0, 500) + '...\\n');\n  }\n\n  console.log('最終建議：');\n  console.log(result.answer);\n  console.log(`\\nToken 使用：Input ${result.inputTokens}, Output ${result.outputTokens}`);\n}\n\nmain();\n```\n\n## 生產環境使用注意事項\n\n**1. 快取策略**：Extended Thinking 的輸出（thinking blocks）**不能快取**，但你可以快取包含 thinking 的對話歷史，讓後續輪次的 Claude 看到之前的思考過程。\n\n**2. 多輪對話中的 Thinking**：如果你要把包含 thinking blocks 的回應放進對話歷史，需要把完整的 `response.content`（包含 thinking blocks）加入 messages，而不只是 text blocks。\n\n```python\n# 正確：保留 thinking blocks 在對話歷史\nmessages.append({\n    \"role\": \"assistant\",\n    \"content\": response.content  # 包含 thinking blocks\n})\n\n# 錯誤：只保留文字，下一輪 Claude 會失去上下文\nmessages.append({\n    \"role\": \"assistant\",\n    \"content\": final_text_only  # 這樣會失去 thinking blocks\n})\n```\n\n**3. 目前的限制**：截至本書寫作時，Extended Thinking 不支援以下功能的組合使用：\n\n- Extended Thinking + streaming 的某些使用模式（確認最新文件）\n- 極長的 budget_tokens 需要對應更大的 max_tokens\n\n永遠查閱 [Anthropic 官方文件](https://docs.anthropic.com) 確認最新的支援狀況，因為這個功能還在積極發展中。\n\n## 小結\n\nExtended Thinking 是 Claude API 裡最有差異化的功能之一。它不是讓所有事情都變得更好，而是讓特定一類問題的回答品質大幅提升：那些需要仔細推理、多步驟計算、或者評估多種可能性的困難問題。\n\n我的建議是：**建立一個問題難度分類器**，自動決定是否啟用 Extended Thinking 以及給多少 budget_tokens。對於明顯簡單的問題跳過 thinking，對於困難問題才開啟，這樣你可以在品質和成本之間找到最佳平衡。\n\n---\n\n這五章涵蓋了 Claude API 的核心基礎：從第一個 API 呼叫，到多輪對話管理，到 Streaming 的即時體驗，到 Tool Use 的 Agent 能力，最後到 Extended Thinking 的深度推理。\n\n接下來的章節，我們會繼續往上構建：Prompt Caching（節省費用）、Multimodal（圖片輸入）、Embeddings、以及最終的 Agent SDK——把這些能力組合起來，建立真正複雜的 AI 系統。",
      "summary": "深入解析 Claude Extended Thinking：適用與不適用場景、budget_tokens 設定策略、thinking blocks 的回應格式、與 Streaming 結合、成本計算，以及 with/without thinking 的實際效果對比。",
      "image": "https://bobochen.dev/_astro/cover.BgPSxRAl.webp",
      "date_published": "2026-03-27T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Extended Thinking",
        "推理",
        "thinking"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/sandwich-gen-diary-00-preface/",
      "url": "https://bobochen.dev/blog/sandwich-gen-diary-00-preface/",
      "title": "自序：人的一生，是一塊熱壓三明治",
      "content_text": "從小孩身上補上 0-7 歲的記憶，從爸媽身上補上人生後半段的想像，從幫祖先撿骨補上往生後的了解。三明治世代的人，被加熱加壓得最扁，但也看得最完整。這本書記錄的，就是那個「看完」之後的我。",
      "content_html": "## 熱壓三明治\n\n這幾年，我活成了夾在中間的那一片。\n\n上面是老去的父母——媽的身體越來越差但拒絕就醫，爸在養護中心躺了一年半之後走了，哥四十幾歲還沒辦法獨立生活。\n\n下面是成長中的孩子——大女兒榕剛上小學，小兒子辰正在學說話。他們需要早餐、需要接送、需要有人回答一萬個「為什麼」、需要在半夜發燒的時候有人抱著他們去急診。\n\n中間是我。\n\n被上下兩層壓得最扁的那一片。\n\n而且別人是三明治世代，我這片還被送進烤箱——加熱加壓。那個熱，是媽的電話、哥的訊息、養護中心的帳單、半夜的急診，每天小火慢烤。\n\n但熱壓三明治壓完，裡面的起司會全部融在一起，分不開了。被壓扁的同時，上下兩代的東西也一起融進了我身上，融成一家人。\n\n## 生命週期\n\n有一天我突然意識到：身處三明治的中間，我其實看到了完整的人類生命週期。\n\n從辰身上，我看到生命最初的樣子。那些第一次——第一次翻身、第一次站起來、第一次叫「爸爸」——都是我自己 0 到 7 歲時已經遺忘的記憶，透過他的身體重新播放了一遍。\n\n從爸媽身上，我看到生命後半段的樣子。身體怎麼衰退、記憶怎麼模糊、一個曾經強壯的人怎麼變得需要別人餵食。這是我未來二三十年的預告片。\n\n從幫祖先撿骨的經驗，我看到了生命結束之後的樣子。骨頭、灰燼、一個小小的罐子。不管這輩子做了什麼，最後都是這樣。\n\n出生、成長、衰老、死亡。全部看了。近距離的、沒有任何遮擋的。\n\n看完之後，你會怎麼過剩下的日子？\n\n這個問題，就是這本書存在的理由。\n\n## 四個讀者\n\n寫這本書的時候，我心裡有四個讀者。\n\n**第一個是跟我一樣的三明治世代。** 如果你也是那個在上班途中接到媽的電話、在會議中回覆哥的訊息、在接小孩前繞去養護中心簽文件的人——我想讓你知道：你不孤單。這些疲憊不是你的錯，你的付出值得被看見。\n\n**第二個是榕和辰。** 他們現在還小，讀不懂這些文字。但有一天他們會長大，會好奇爸爸是怎麼長大的、為什麼爸爸那麼重視陪伴、為什麼爸爸在他們還小的時候選擇遠距工作而不是去園區。這本書就是那些問題的答案。\n\n**第三個是我自己。** 寫作是整理情緒最有效的方式。那些壓在胸口好多年的東西——委屈、憤怒、內疚、後悔——寫出來之後不會消失，但會有形狀。有形狀的東西，比較不那麼可怕。\n\n**第四個是正在面對長照決策的家庭。** 台灣有太多家庭在照顧者困境裡獨自掙扎。每月三萬八的帳單、不合理的扶養義務、讓人崩潰的制度——這些不應該只是私人的痛苦，應該被說出來。\n\n## 關於真實\n\n這本書裡的故事都是真的。\n\n但我做了一些保護：家人用暱稱而不是真名，某些對話是用我的記憶重建的（不是逐字引用），敏感的細節做了適度的模糊處理。\n\n金額和制度面的數字我盡量保持精確，因為那些數字本身就是故事的一部分——每月三萬八的長照費用、每月兩萬五的扶養費判決——這些不是修辭，是壓在人身上的重量。\n\n## 債、家、扛、病、選、光\n\n這本書分成六個部分。\n\n**「債」** 寫的是童年。貧窮怎麼在一個小孩心裡留下印記，以及那個印記怎麼跟著你一輩子。\n\n**「家」** 寫的是記憶。原生家庭裡交織的愛與傷，那些讓你想靠近又想逃開的人。\n\n**「扛」** 寫的是日常。成為家裡唯一的大人是什麼感覺，以及你怎麼在被消耗殆盡之前學會設界線。\n\n**「病」** 寫的是哥。他的好逸惡勞、他的思覺失調症、精神科的藥、社會的隔離。性格問題和精神疾病糾纏在一起，你拆不開，也不能只看一面。\n\n**「選」** 寫的是那個不可能的選擇。爸中風之後，我們面對的醫療決策、經濟現實、和制度的現況。\n\n**「光」** 寫的是後來。從自己的童年裂痕中，長出給下一代的溫柔。為什麼我選擇留在台北、選擇遠距工作、選擇每天出現在校門口。\n\n從債到光。這是這個三明治的故事。\n\n也許你會在裡面看到你自己。",
      "summary": "從小孩身上補上 0-7 歲的記憶，從爸媽身上補上人生後半段的想像，從幫祖先撿骨補上往生後的了解。三明治世代的人，被加熱加壓得最扁，但也看得最完整。這本書記錄的，就是那個「看完」之後的我。",
      "image": "https://bobochen.dev/_astro/cover.Bwsf-S0A.webp",
      "date_published": "2026-03-27T00:00:00.000Z",
      "tags": [
        "家庭",
        "三明治世代",
        "照顧者",
        "原生家庭"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-landing-page-seo/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-landing-page-seo/",
      "title": "Landing Page 與 SEO：讓產品被找到",
      "content_text": "產品做出來了，但沒人知道它存在。學會用 AI 快速產出高轉換率的 Landing Page 文案、建立 SEO 策略，讓你的產品被目標用戶找到。",
      "content_html": "## 「做出來就會有人用」是最大的謊言\n\n你花了三個週末，終於把 MVP 部署上線了。打開 Analytics——零流量。\n\n過了一週，還是零。\n\n你把連結丟到社群裡，得到幾個朋友的點擊，然後恢復平靜。你開始懷疑：是產品不夠好嗎？是市場不存在嗎？\n\n大多數時候，答案都不是。答案是：**沒有人知道你的產品存在。** 你缺的不是更好的功能，而是一個能被找到的 Landing Page，以及讓 Google 持續帶流量的 SEO 策略。\n\n\"If you build it, they will come\" 是矽谷最毒的雞湯。現實是，你做出來了，他們不會來——除非你告訴他們。\n\n但你是 Solo Builder，每週只有 5-10 小時。你不可能花大量時間做行銷。\n\n好消息是，你不需要。你需要的是兩件事：**一個能說服人的 Landing Page**，和**一個讓 Google 幫你帶來流量的 SEO 策略**。這兩件事，AI 都能幫你在最短時間內搞定。\n\n## Landing Page 的解剖學：六個必備區塊\n\n一個高轉換率的 Landing Page 不需要華麗的設計。它需要的是**清晰的結構**和**說服人的文案**。\n\n無論你的產品是什麼，Landing Page 的骨架都長這樣：\n\n### 1. Hero Section：3 秒決定去留\n\n訪客到你的頁面，會在 3 秒內決定要不要往下看。Hero 區塊必須在 3 秒內回答一個問題：**這個東西能幫我解決什麼問題？**\n\n組成元素：\n\n- **Headline**：一句話說清楚價值主張（10-15 字）\n- **Sub-headline**：補充說明怎麼做到（20-30 字）\n- **CTA 按鈕**：明確的行動呼籲（「免費開始」「立即試用」）\n- **Hero image / 螢幕截圖**：讓人直覺理解產品長什麼樣\n\n### 2. Pain / Solution：說中他的痛點\n\n先描述目標用戶目前的困境（pain），再說你的產品怎麼解決（solution）。這個區塊的目的是讓訪客覺得「這個人懂我」。\n\n### 3. Features：三到五個核心功能\n\n不要列二十個功能。選最強的三到五個，每個用一行標題 + 兩行說明。\n\n### 4. Social Proof：別人也在用\n\n推薦語、用戶數、合作夥伴 logo、媒體報導。如果你是新產品什麼都沒有，可以用「beta 用戶回饋」或「開發者自己的使用心得」。\n\n### 5. Pricing：透明的定價\n\n不要讓用戶「聯繫銷售才知道價格」。Solo Builder 的產品應該定價透明、方案簡單。兩到三個方案就夠了。\n\n### 6. FAQ + Final CTA：掃除最後的疑慮\n\nFAQ 回答用戶最常見的擔心，然後再放一次 CTA 按鈕收尾。\n\n## AI 生成 Landing Page 文案：每個區塊的 Prompt\n\n現在來到最實用的部分。你不需要是文案高手，只需要給 AI 正確的 prompt。\n\n### 傳統做法\n\n- 花一天研究競品的 Landing Page 文案\n- 自己一段一段寫，改了十幾版還是不滿意\n- 請朋友幫忙看，等了三天才得到回饋\n- 前後花了一到兩週\n\n### AI 加持做法\n\n一個晚上搞定。以下是我實測有效的 prompt 框架：\n\n**Step 1：先讓 AI 理解你的產品**\n\n```text\n我正在做一個產品，以下是基本資訊：\n\n- 產品名稱：[名稱]\n- 目標用戶：[描述你的目標用戶]\n- 核心問題：[用戶目前遇到什麼痛點]\n- 解決方案：[你的產品怎麼解決]\n- 差異化：[跟競品比，你的獨特之處]\n- 定價：[免費/付費方案]\n\n請先確認你理解了這些資訊，然後我會請你生成 Landing Page 文案。\n```\n\n**Step 2：逐區塊生成文案**\n\n```text\n基於剛才的產品資訊，請幫我生成 Landing Page 文案。\n\nHero Section：\n- Headline（15 字以內，說清楚核心價值）\n- Sub-headline（30 字以內，補充怎麼做到）\n- CTA 按鈕文字（4-6 字）\n\nPain/Solution：\n- 描述 3 個用戶目前的痛點（每個一句話）\n- 對應 3 個你的產品怎麼解決（每個一句話）\n\nFeatures：\n- 列出 4 個核心功能，每個包含：\n  - 功能標題（8 字以內）\n  - 功能說明（50 字以內）\n\nFAQ：\n- 5 個潛在用戶最可能問的問題和答案\n\n語氣：專業但親切，像一個有經驗的朋友在推薦好工具。\n語言：繁體中文（台灣用語）。\n```\n\n**Step 3：請 AI 自我挑戰**\n\n這一步很多人跳過，但非常重要：\n\n```text\n現在請你扮演一個看到這個 Landing Page 的潛在用戶，\n對剛才的文案提出 5 個挑戰或疑慮。\n然後針對每個疑慮修改文案。\n```\n\n這個「自我挑戰」步驟能大幅提升文案品質。AI 會找到你忽略的盲點：模糊的承諾、缺少具體數字、不夠明確的 CTA。\n\n## SEO 基礎：讓 Google 幫你帶流量\n\nLanding Page 處理了「訪客來了之後看到什麼」的問題。SEO 處理的是「訪客怎麼來」的問題。\n\n對 Solo Builder 來說，SEO 是最高 CP 值的行銷策略。因為：\n\n- **免費**：不花廣告費\n- **被動**：設定好之後，Google 自動帶流量\n- **複利效應**：一篇好文章，可以連續帶來流量好幾年\n- **AI 大幅降低內容生產成本**：以前寫一篇 SEO 文章要一天，現在一個小時\n\n但 SEO 是「長期複利投資」，不是「行銷萬靈丹」，這點我得講清楚，免得你期望落空就放棄。新域名通常要等 3 到 6 個月才會開始有像樣的自然流量（有人說這是 Google 的沙盒期，總之新站排名就是慢），熱門關鍵字你可能寫了一整年都擠不進前頁。我自己有篇文章發出去快半年才開始有人從搜尋進來——如果我當初指望它「下個月」帶流量，早就棄坑了。\n\n所以時機很重要：如果你還在驗證 PMF、這禮拜就需要有真人用你的東西給你回饋，SEO 太慢了，這時候去找對的社群冷啟動、或花一點小錢投廣告快速試水溫，比埋頭寫部落格實際得多。SEO 是你確定方向對了之後，拿來累積長期流量的——不是用來救一個還沒被驗證的點子。\n\n### 關鍵字研究：AI 加持做法\n\n傳統的關鍵字研究需要用 Ahrefs、SEMrush 等付費工具，花好幾個小時。Solo Builder 可以用更快的方式：\n\n```text\n我的產品是 [描述]，目標用戶是 [描述]。\n\n請幫我做關鍵字研究：\n\n1. 列出 20 個我的目標用戶可能會搜尋的關鍵字\n   - 分成三類：資訊型（想學習）、導航型（想找工具）、交易型（想購買）\n   - 預估搜尋量等級（高/中/低）\n\n2. 長尾關鍵字\n   - 針對每個主關鍵字，列出 3 個長尾變體\n   - 長尾關鍵字通常更容易排名\n\n3. 內容策略建議\n   - 針對哪些關鍵字，我應該寫部落格文章？\n   - 哪些關鍵字應該放在 Landing Page？\n   - 建議的內容發布順序（先攻哪個？）\n\n市場：台灣繁體中文\n```\n\n拿到結果之後，再用 Google Search Console 和 Google Trends 驗證一下 AI 的建議。AI 的搜尋量估計不一定精確，但關鍵字方向通常很準。\n\n### 必做的 SEO 基本功\n\n不管你做什麼產品，以下這些 SEO 基本功必須到位：\n\n| 項目                      | 說明                                             | 優先度  |\n| ------------------------- | ------------------------------------------------ | ------- |\n| Title Tag                 | 每頁獨特的 `<title>`，包含主要關鍵字，50-60 字元 | 🔴 最高 |\n| Meta Description          | 150-160 字元的頁面描述，包含關鍵字和 CTA         | 🔴 最高 |\n| H1 標籤                   | 每頁只有一個 H1，包含主要關鍵字                  | 🔴 最高 |\n| Open Graph / Twitter Card | 社群分享時的標題、描述、圖片                     | 🟡 高   |\n| Sitemap                   | `sitemap.xml`，讓 Google 知道你有哪些頁面        | 🟡 高   |\n| robots.txt                | 告訴搜尋引擎哪些頁面可以爬                       | 🟡 高   |\n| Structured Data           | JSON-LD 結構化資料，讓 Google 更理解你的內容     | 🟢 中   |\n| 頁面速度                  | Core Web Vitals 三項都綠燈                       | 🟢 中   |\n| 內部連結                  | 相關頁面之間互相連結                             | 🟢 中   |\n\n用 AI 生成這些東西只需要幾分鐘：\n\n```text\n我的網站是用 Astro 框架建的。\n請幫我生成以下頁面的 SEO meta tags：\n\n頁面：[產品名稱] Landing Page\n主要關鍵字：[關鍵字]\n次要關鍵字：[2-3 個]\n\n請生成：\n1. Title tag（50-60 字元）\n2. Meta description（150-160 字元）\n3. Open Graph tags（title, description, type, image alt）\n4. Twitter Card tags\n5. JSON-LD structured data（Product 或 SoftwareApplication 類型）\n```\n\n### Structured Data：讓搜尋結果更醒目\n\nStructured Data（結構化資料）是很多人忽略但效果顯著的 SEO 技巧。它讓你的搜尋結果出現星級評分、價格、FAQ 展開等 rich snippet，點擊率可以提升 20-30%。\n\n常用的 Schema 類型：\n\n- `SoftwareApplication`：你的產品\n- `FAQPage`：Landing Page 的 FAQ 區塊\n- `Article`：部落格文章\n- `BreadcrumbList`：麵包屑導航\n\n請 AI 幫你生成對應的 JSON-LD，直接貼到 `<head>` 裡。\n\n## 用內容行銷驅動自然流量\n\nLanding Page 的 SEO 效果有限，因為你只有一頁。真正能持續帶來自然流量的，是**內容行銷**——也就是寫跟你產品相關的部落格文章。\n\n### 內容行銷的飛輪效應\n\n```text\n寫文章 → Google 索引 → 自然流量 → 讀者認識你的產品\n   ↑                                        ↓\n   └──── 更多素材 ← 用戶回饋 ← 部分讀者轉換 ←┘\n```\n\n一篇好的部落格文章，可以持續帶來流量好幾年。這是 Solo Builder 最適合的行銷方式——你投入的時間有**複利效應**。\n\n### 傳統做法 vs. AI 加持做法\n\n| 面向     | 傳統做法                           | AI 加持做法                                  |\n| -------- | ---------------------------------- | -------------------------------------------- |\n| 選題     | 自己想 + 看競品，2-3 小時          | AI 根據關鍵字研究建議，30 分鐘               |\n| 大綱     | 自己列，1 小時                     | AI 生成大綱 + 你調整，15 分鐘                |\n| 初稿     | 一字一句寫，4-8 小時               | AI 生成初稿 + 你改寫語氣和加入經驗，1-2 小時 |\n| SEO 優化 | 手動檢查關鍵字密度、內連結，1 小時 | AI 自動建議，15 分鐘                         |\n| 合計     | 8-13 小時/篇                       | 2-3 小時/篇                                  |\n\n**注意：AI 生成初稿之後，你必須加入自己的真實經驗和觀點。** 純 AI 文章讀者一眼就看得出來，而且 Google 越來越會識別。AI 負責結構和初稿，你負責靈魂和差異化。\n\n### 我的實戰案例：bobo-blog 的 SEO 策略\n\n讓我用 bobo-blog 的真實例子來說明。\n\n我的部落格（bobo-blog）是用 Astro + Cloudflare Workers 建的（平台選擇的取捨見[第 6 章：部署上線](/blog/ai-solo-builder-deployment/)）。SEO 策略很單純：\n\n1. **選定幾個核心主題**：GCP、DevOps、開發者工具、Cloudflare\n2. **針對每個主題寫系列文章**：不是零散的文章，而是有結構的系列。例如「PHP/Laravel 完全指南」就是一個 15 篇的系列\n3. **每篇文章鎖定一個長尾關鍵字**：不去跟大站搶「什麼是 Cloudflare」這種超級關鍵字，而是鎖定「Cloudflare Workers 部署教學」這種長尾\n4. **系列內部互相連結**：每篇結尾連到下一篇，形成閱讀路徑\n5. **用 Astro 的靜態生成確保頁面速度**：Core Web Vitals 全綠\n\n技術上的 SEO 設定：\n\n- Astro 內建的 sitemap integration 自動生成 `sitemap.xml`\n- 每篇文章的 frontmatter 包含 `title`、`description`、`pubDate`，自動生成 meta tags\n- Open Graph 圖片用腳本批次生成（`scripts/generate-social-cards.ts`）\n- `_headers` 檔案設定好 cache control\n\n**結果：** 不花一毛錢廣告費，靠自然搜尋穩定帶來流量。而且因為是系列文章，讀者來了之後不只看一篇，平均停留時間和頁面瀏覽數都比散篇文章高。\n\n## Quick Wins：最大效果的優先順序\n\n時間有限，先做效果最大的。以下是我建議的優先順序：\n\n### 第一週：基礎建設（2 小時）\n\n1. Landing Page 上線，包含完整的六個區塊\n2. 設定 Google Search Console，提交 sitemap\n3. 確認所有頁面的 title tag 和 meta description 都正確\n\n### 第二週：內容啟動（2 小時）\n\n1. 發布第一篇跟產品相關的部落格文章\n2. 把文章分享到 2-3 個相關社群\n3. 用 Google Search Console 確認文章被索引\n\n### 第三週起：持續產出（每週 2-3 小時）\n\n1. 每週發布一篇文章（用 AI 輔助寫作）\n2. 每週花 15 分鐘看 Google Search Console 的數據\n3. 根據數據調整關鍵字策略\n\n### 不要做的事\n\n- ❌ 花錢投廣告（太早了，先確認自然流量行不行）\n- ❌ 經營五個社群媒體帳號（選一個就好）\n- ❌ 花時間做精美的影片行銷（文字內容 CP 值最高）\n- ❌ 追求完美的 Lighthouse 滿分（90 分以上就夠了）\n\n> 不過上面這份「不要做的事」是預設你的用戶會用 Google 找解法——對開發者工具、SaaS 這類產品大致成立，但不是每個產品都這樣，所以與其當禁令，不如當成一個判斷題：\n> - 用戶會主動搜尋解法（「XXX 怎麼做」）→ SEO 跟文字內容優先，這篇講的就是這條路。\n> - 用戶是被動發現、看顏值或衝動買的（B2C、設計工具、生活風格產品）→ 短影音（Reels、TikTok）跟視覺型社群往往才是有效的冷啟動管道，硬寫部落格反而沒人看。\n> - 你的客群根本泡在某個特定社群、不靠 Google →（某個 Discord、PTT 某板、Reddit subreddit）就去那裡，別管搜尋引擎。\n>\n> 付費廣告也一樣：拿小預算（幾百塊）去測「哪個訊息、哪群人會點」是很划算的市場調查，要避免的是「還沒驗證會不會轉換就大規模燒錢」。文字內容對開發者類產品 CP 值確實高，但別把它當成對所有產品都最高。\n\n## 工具清單\n\n| 工具                   | 用途                           | 費用               |\n| ---------------------- | ------------------------------ | ------------------ |\n| Google Search Console  | 監控搜尋表現、提交 sitemap     | 免費               |\n| Google Analytics 4     | 流量分析、用戶行為追蹤         | 免費               |\n| PageSpeed Insights     | 檢查頁面速度和 Core Web Vitals | 免費               |\n| Ahrefs Webmaster Tools | 反向連結分析、關鍵字排名       | 免費（基本版）     |\n| Google Trends          | 驗證關鍵字的搜尋趨勢           | 免費               |\n| Screaming Frog         | 網站 SEO 技術審計              | 免費（500 頁以內） |\n| Claude / ChatGPT       | 文案生成、關鍵字研究、內容大綱 | 免費/付費          |\n\n全部免費工具就夠用了。Solo Builder 階段不需要付費的 SEO 工具。\n\n## 時間預算：每週 2-3 小時\n\n行銷不需要佔用你太多時間。建議的時間分配：\n\n| 任務                        | 時間    | 頻率 |\n| --------------------------- | ------- | ---- |\n| 寫一篇部落格文章（AI 輔助） | 2 小時  | 每週 |\n| 檢查 Search Console 數據    | 15 分鐘 | 每週 |\n| 分享文章到社群              | 15 分鐘 | 每週 |\n| 更新 Landing Page 文案      | 30 分鐘 | 每月 |\n\n每週 2.5 小時，佔你 5-10 小時可用時間的 25-50%。\n\n這個比例是對的。很多工程師把 100% 的時間都花在寫程式碼上，然後抱怨「為什麼沒人用」。**行銷不是可選的——它是產品的一部分。**\n\n## 本章重點回顧\n\n- 🚫 「做出來就會有人用」是最大的謊言。你需要主動讓產品被發現\n- 📄 Landing Page 六區塊：Hero、Pain/Solution、Features、Social Proof、Pricing、FAQ + CTA\n- 🤖 用 AI 逐區塊生成文案，加上「自我挑戰」步驟提升品質\n- 🔍 SEO 三個必做：Title Tag、Meta Description、Sitemap\n- 📝 內容行銷有複利效應：一篇好文章可以帶來好幾年的自然流量\n- ⏱️ 每週 2-3 小時的行銷時間，先做 Quick Wins，不要追求完美\n\n## 下一步\n\nLanding Page 上線了，SEO 策略也開始執行了。現在你的產品有人看到了。\n\n但你會發現，免費用戶和付費用戶之間有一道巨大的鴻溝。下一章，我們來面對 Solo Builder 最緊張的一步：**怎麼開口跟用戶收錢。**\n\n從 Stripe、綠界到藍新，一個人串接金流沒有你想像的那麼難——尤其有 AI 幫忙的時候。\n\n👉 [第 8 章：付費機制——一個人怎麼收錢](/blog/ai-solo-builder-payment)",
      "summary": "產品做出來了，但沒人知道它存在。學會用 AI 快速產出高轉換率的 Landing Page 文案、建立 SEO 策略，讓你的產品被目標用戶找到。",
      "image": "https://bobochen.dev/_astro/cover.BW0hGK5V.webp",
      "date_published": "2026-03-22T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Landing Page",
        "SEO",
        "AI",
        "行銷",
        "文案"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/multi-agent-memory-architecture/",
      "url": "https://bobochen.dev/blog/multi-agent-memory-architecture/",
      "title": "Multi-Agent 記憶架構：讓你的 AI Agents 不再各自為政",
      "content_text": "當你有多個 AI agent 分散在不同環境，如何設計共享記憶架構？從痛點出發，走過社群驗證的 pattern，到 MVP 實作的設計思考全紀錄。",
      "content_html": "## 問題：四個 Agent，四座孤島\n\n我的日常工作流裡有四個 AI agent，各自住在不同的環境，各自維護自己的記憶：\n\n| Agent       | 位置            | 負責什麼                             | 記憶怎麼存                              |\n| ----------- | --------------- | ------------------------------------ | --------------------------------------- |\n| Claude Code | MacBook（本地） | coding、專案管理                     | `~/.claude/projects/*/memory/MEMORY.md` |\n| OpenClaw    | VPS             | daily briefing、聊天式更新、自動調研 | MEMORY.md + daily logs                  |\n| n8n         | VPS             | webhook automation、Slack 通知       | workflow state                          |\n| Notion      | Cloud           | 結構化資料庫（專案、OKR、影片企劃）  | databases                               |\n\n看起來很完整對吧？問題是——它們彼此不說話。\n\n每天早上 OpenClaw 幫我整理好 briefing，但我打開 Claude Code 開始寫程式時，它完全不知道今天的重點是什麼。反過來也一樣，昨天 coding session 做完的進度，OpenClaw 隔天的 briefing 裡完全沒有。\n\n這不是功能缺失的問題，是架構設計的問題。\n\n## 真正的痛：Context Switching 的隱形成本\n\nContext switching 最貴的部分，不是切換視窗，而是「腦中的狀態要重新載入」。\n\n對人類來說是這樣，對 AI agent 也是。每次開一個新的 Claude Code session，我得花前五分鐘把「現在這個專案做到哪了」「今天最重要的事是什麼」「有哪些 blocker」重新餵一遍。有些是 MEMORY.md 已經記了的，但更多是散落在 OpenClaw 的對話紀錄、Notion 的專案頁面、甚至昨天 Slack 的某則通知裡。\n\n我開始想：有沒有可能讓 agent 之間共享 context，像一個團隊共用一份會議紀錄？\n\n## 研究：社群已經驗證的 Pattern\n\n在動手設計之前，我花了時間研究社群裡其他人怎麼解決這個問題。幾個有趣的發現：\n\n### 1. Markdown as Shared Memory\n\n這是最多人驗證有效的方式，而且意外地簡單。不需要 vector DB、不需要 embedding pipeline，就是純文字 Markdown 檔案。為什麼？因為：\n\n- **可讀**：人和 AI 都能直接讀\n- **可版控**：丟進 git 就有完整歷史\n- **Agent 原生支援**：幾乎所有 AI agent 都能讀寫 Markdown\n\n### 2. Per-Repo 文檔 + INDEX.md Master Registry\n\n一個常見的結構是在每個 repo 底下維護 `workspace/projects/` 目錄，每個專案一組文件，再用 `INDEX.md` 當全域索引。Agent 啟動時讀 INDEX 就知道有哪些活躍專案。\n\n### 3. Hot / Warm / Cold 三層記憶\n\n不是所有資訊都值得永久保存。一個實用的分層是：\n\n- **Hot**：MEMORY.md，最近 7 天的 context，每次 session 直接載入\n- **Warm**：每 6 小時自動 consolidate，把細節壓縮成摘要\n- **Cold**：Daily logs，永久保存但不主動載入，需要時再查\n\n### 4. n8n Data Table 當記憶層\n\nAgent One 案例裡，用 n8n 的 Data Table（就 5 欄）取代了整套 vector DB。欄位大概是 `key`、`value`、`category`、`timestamp`、`ttl`。夠用、好維護、不需要額外基礎設施。\n\n### 5. AGENTS.md 跨工具標準\n\nLinux Foundation 正在維護的 AGENTS.md 規範，嘗試定義一個讓不同 AI agent 都能讀的專案描述格式。雖然還在早期，但方向值得關注。\n\n## 架構設計：混合式，各取所長\n\n研究完之後，我決定不走「單一 hub」路線（例如全部丟 Notion 或全部丟某個 vector DB），而是讓每個工具做它最擅長的事：\n\n```\n┌─────────────────────────────────────────────────┐\n│                   Notion                         │\n│         結構化資料 Source of Truth                │\n│        （專案、OKR、ideas、影片企劃）              │\n└──────────────────────┬──────────────────────────┘\n                       │ API sync\n┌──────────────────────┼──────────────────────────┐\n│                    n8n                           │\n│              Event Bus / 調度層                   │\n│       （觸發同步、通知、webhook 串接）              │\n└────────┬─────────────┼────────────┬─────────────┘\n         │             │            │\n    ┌────▼────┐   ┌────▼────┐  ┌───▼──────┐\n    │ OpenClaw │   │  Git    │  │  Slack   │\n    │ 24/7     │   │  Repo   │  │  通知    │\n    │ Agent    │◄──► Memory  │  └──────────┘\n    │          │   │ Exchange │\n    └────┬─────┘   │  Layer  │\n         │         └────▲────┘\n         │              │\n         │         ┌────┴──────┐\n         └────────►│Claude Code│\n                   │ Deep Work │\n                   │  Agent    │\n                   └───────────┘\n```\n\n**為什麼是混合式？** 因為每個工具的強項不同：\n\n- **Notion** 擅長結構化資料，拿來當 project 和 OKR 的 source of truth 最合適\n- **Git repo** 是天然的版控系統，當 memory exchange layer 不需要額外建設\n- **n8n** 本來就在做 event-driven automation，讓它當 message bus 順理成章\n- **OpenClaw** 是 24/7 跑著的 autonomous agent，適合做 briefing 和主動調研\n- **Claude Code** 是 deep work 時段的主力，專注在 coding 和架構設計\n\n## 關鍵決策：那些「為什麼不」\n\n架構設計最有價值的部分，往往不是「選了什麼」，而是「為什麼不選那個」。\n\n### 為什麼 MVP 先做 OpenClaw → Claude Code 單向同步？\n\n因為這是最痛的方向。每天早上的 context loading 是最頻繁、最浪費時間的瞬間。如果 Claude Code 一啟動就知道今天 briefing 的重點，光這個就能省下大量的「暖機時間」。\n\n### 為什麼用 Git repo 而不是 Notion 作為 exchange layer？\n\n兩個原因：\n\n1. Agent 原生支援 git——Claude Code 可以直接讀 repo 裡的檔案，不需要任何 adapter\n2. Notion API 有 rate limit 和延遲，git pull 是毫秒級的\n\n### 為什麼不用 Mem0 或 vector DB？\n\nMarkdown 夠用。認真的，在這個場景下：\n\n- 記憶內容是結構化文字（briefing、session summary），不是需要語意搜尋的長文\n- 可讀性很重要——我要能直接打開檔案看內容、手動修正\n- 不需要額外的基礎設施成本和維護負擔\n\nVector DB 在需要語意搜尋大量非結構化資料時很強，但對「四個 agent 共享每日 context」這個場景，是 overkill。\n\n## MVP：Daily Context Bridge\n\n說到做到，MVP 就是一個 cron job：\n\n```\nOpenClaw cron (09:00)\n    → 產出 daily-briefing.md\n    → git commit + push\n    → Claude Code session start 時自動讀取\n```\n\n就這樣。沒有 event bus、沒有 webhook、沒有 vector DB。一個 cron job、一次 git push、一份 Markdown。\n\n**內容格式大概長這樣：**\n\n```markdown\n# Daily Briefing — 2026-03-22\n\n## 今日重點\n\n- [ ] Multi-agent memory 架構文章完稿\n- [ ] Mystery Shopper 前端 bug fix（P1）\n\n## 昨日進度（from Claude Code session）\n\n- statusline 色彩升級完成\n- 新增 Mystery Shopper project memory\n\n## Blockers\n\n- VPS 儲存空間快滿了（剩 2GB）\n\n## 值得注意\n\n- n8n 有 3 個 workflow 昨天失敗（已自動重試成功）\n```\n\nClaude Code 啟動時讀到這份檔案，立刻知道今天該優先做什麼、昨天的 context 是什麼、有哪些地雷要注意。\n\n## Phase 路線圖\n\nMVP 不是終點，但它是驗證方向的起點：\n\n| Phase             | 做什麼                                      | 解決什麼                                       |\n| ----------------- | ------------------------------------------- | ---------------------------------------------- |\n| **Phase 1 (MVP)** | OpenClaw → Claude 單向同步                  | 早上的 context loading                         |\n| **Phase 2**       | Claude 收工 → session-summary.md → OpenClaw | 雙向同步，隔天 briefing 能包含昨天 coding 進度 |\n| **Phase 3**       | n8n 作為 message bus，event-driven 完整架構 | 即時同步，任何 agent 的狀態變化都能觸發通知    |\n\nPhase 1 可能這周就能上線。Phase 3 可能永遠不需要做——如果 Phase 2 就解決了 95% 的問題的話。\n\n## 反思\n\n### 技術面\n\n**Markdown 比你想的更強大。** 我們常常預設「AI 記憶」需要 vector DB、embedding、複雜的 retrieval pipeline。但在「多 agent context 共享」這個場景，純文字 Markdown 有三個殺手級優勢：可讀、可版控、agent 原生支援。不是說 vector DB 不好，是要選對場景。\n\n**三層記憶的時效性很重要。** 不是所有資訊都值得放在 Hot layer。今天的 briefing 是 Hot，上周的 session summary 是 Warm（consolidate 後的摘要就好），上個月的 daily log 是 Cold（歸檔但不佔 context window）。記憶管理的核心不是「記住更多」，而是「知道什麼時候該忘」。\n\n### 心態面\n\n**最難的不是設計完美架構，而是克制自己不要一開始就設計完美架構。**\n\n我承認，畫出那個 event-driven、n8n message bus、四個 agent 全連通的架構圖時，心裡是很興奮的。但冷靜下來想：真正最痛的問題是什麼？是每天早上開 Claude Code 時不知道今天的重點。一個 cron job + git push 就能解決 80% 的問題。\n\n先做 80%，再決定剩下 20% 值不值得做。這不只適用於 multi-agent 架構，幾乎適用於所有系統設計。\n\n### 給想做類似事情的人\n\n如果你也有多個 AI agent 想串起來，我的建議是：\n\n1. **先盤點你的痛點**——哪兩個 agent 之間的 context gap 最讓你痛？\n2. **從單向同步開始**——不要一開始就想做 event-driven 雙向同步\n3. **Markdown first**——除非你有明確的語意搜尋需求，否則不需要 vector DB\n4. **Git 是最好的 exchange layer**——版控、diff、merge 都是免費的\n\nMulti-agent 時代的基礎設施還在成形中。與其等一個完美的框架出現，不如從最簡單的方式開始，用 cron + git + Markdown 把你的 agents 連起來。\n\n畢竟，一個能用的 MVP 永遠比一張漂亮的架構圖更有價值。",
      "summary": "當你有多個 AI agent 分散在不同環境，如何設計共享記憶架構？從痛點出發，走過社群驗證的 pattern，到 MVP 實作的設計思考全紀錄。",
      "image": "https://bobochen.dev/_astro/cover.BhpnAN3f.webp",
      "date_published": "2026-03-22T00:00:00.000Z",
      "tags": [
        "AI Agent",
        "Multi-Agent",
        "memory-architecture",
        "Claude Code",
        "工作流程"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-mindset-shift/",
      "url": "https://bobochen.dev/blog/agentic-engineering-mindset-shift/",
      "title": "從「寫 code 的人」到「管 agent 的人」：工程師的角色重新定義",
      "content_text": "Agentic Engineering 最大的挑戰不是技術，是身份認同。當你的價值不再來自打字速度和演算法背誦，你到底是誰？分享角色轉換過程中的心理掙扎、具體變化，以及最終找到的新定位。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第二篇。上一篇：[Agentic Engineering 是什麼？](/blog/agentic-engineering-what-is-it)\n\n## 那個尷尬的瞬間：手寫 Code 反而更慢了\n\n上個月專案趕進度，Claude Code 的 API 剛好在維護。我想說沒關係，我自己來。打開 VS Code，開始寫一個 CRUD endpoint。\n\n寫了二十分鐘，我回頭看自己的 code——少了一個 null check、error handling 不完整、test 還沒寫。以前這些我閉著眼睛都能搞定的事，現在居然要停下來想。\n\n更尷尬的是，API 恢復之後我把同一個任務交給 agent。七分鐘，完成了。code 比我手寫的更完整，test coverage 更高，連 API doc 都自動更新了。\n\n那個瞬間我意識到兩件事：\n\n第一，我的角色已經不知不覺地變了。我不再是「寫 code 的人」，我是「管寫 code 的 agent 的人」。\n\n第二，這讓我非常不安。\n\n## IC 到 Agent Manager：日常的具體變化\n\n如果你問我一年前的一天是怎麼過的，跟現在的一天是怎麼過的，差異大到像換了一份工作。\n\n**一年前的時間分配（Agent 之前）：**\n\n| 活動               | 佔比 | 消耗的腦力              |\n| ------------------ | ---- | ----------------------- |\n| 寫 code            | 60%  | 高（語法、邏輯、debug） |\n| 讀 code / 理解系統 | 20%  | 高                      |\n| 開會 / 溝通        | 15%  | 中                      |\n| Code review        | 5%   | 中                      |\n\n**現在的時間分配（Agent First）：**\n\n| 活動                | 佔比 | 消耗的腦力                      |\n| ------------------- | ---- | ------------------------------- |\n| Review agent output | 35%  | 高（判斷正確性、架構合理性）    |\n| 寫 spec / 定義需求  | 25%  | 高（這直接決定 agent 產出品質） |\n| 架構思考 / 設計決策 | 20%  | 高                              |\n| 開會 / 溝通         | 15%  | 中                              |\n| 手寫 code           | 5%   | 低（只在 agent 搞不定的時候）   |\n\n最大的變化不是「做什麼」，而是腦力花在哪裡。\n\n以前我的腦力花在「怎麼實作」——這個 function 的邏輯怎麼寫、這個 edge case 怎麼處理、這個 SQL query 怎麼優化。\n\n現在我的腦力花在「做什麼」和「對不對」——這個需求的邊界在哪、agent 的解法是不是最好的、這個架構決策三個月後會不會後悔。\n\n用一個比喻：以前我是工地的砌磚師傅，現在我是工地的監工。我不再一塊一塊砌磚了，但我需要知道這面牆砌得直不直、用的材料對不對、整體結構穩不穩。\n\n而且說實話，監工需要的專業能力不比砌磚師傅少，只是不一樣。\n\n## 「我會不會變笨？」——技能衰退的焦慮\n\n這個問題我想了很久。而且不只是我在想——每個我認識的 agent-first 工程師，遲早都會面對這個焦慮。\n\n我在 Token 燒光焦慮 那篇文章裡聊過情緒面的掙扎。這篇想從能力面來誠實盤點。\n\n**確實衰退的能力：**\n\n- **語法記憶**：我已經記不清 Go 的 error handling 語法細節了。以前手到擒來，現在得查。\n- **API 細節**：某個 library 的某個 method 第三個參數是什麼？以前背得出來，現在完全不記得。\n- **手寫 boilerplate 的速度**：寫一個標準的 REST endpoint，以前 15 分鐘，現在可能要 25 分鐘。\n- **某些 debug 直覺**：以前看到某種 error pattern 會馬上聯想到可能的原因，現在這個反應變慢了。\n\n這些衰退是真的。不否認。\n\n**但同時增長的能力：**\n\n- **架構判斷力**：因為我不再被實作細節佔滿腦袋，我有更多腦力去思考系統設計。\n- **需求分析力**：寫了一年的 agent spec，我定義需求的精準度提升了至少一個等級。對人類同事描述需求時也變清楚了。\n- **Review 眼光**：每天 review agent 產出的 code，我看 code 找問題的能力變強了，而且是跳脫式的——不是逐行看語法，而是看架構和邏輯。\n- **系統性思維**：管 agent 逼你想清楚整個工作流——什麼先做什麼後做、dependency 是什麼、怎麼驗證。這訓練了更好的系統性思維。\n\n那淨結果呢？我覺得這像計算機的發明。計算機出現之後，人類的心算能力確實掉了。但數學有因此退步嗎？沒有。反而因為不用把腦力浪費在計算上，人們可以思考更高層次的數學問題。\n\n同理，你不需要記住每個 API 的參數。但你需要知道這個架構設計合不合理——而後者比前者有價值得多。\n\n## 新技能樹：2026 工程師該投資什麼\n\n如果你接受了「角色已經變了」這個前提，下一個問題就是：那我該投資什麼技能？\n\n### 判斷力 > 打字速度\n\n以前面試考你 LeetCode，測的是你的實作能力。未來面試會越來越看你的判斷能力——給你一個 agent 寫的方案，你能不能看出問題在哪？\n\n知道「什麼該做」比知道「怎麼做」值錢了。因為「怎麼做」agent 可以幫你，但「什麼該做」只有你能決定。\n\n### 架構眼光 > 語法記憶\n\nAgent 不會忘記語法——它永遠可以寫出語法正確的 code。但它不懂你們公司為什麼選了 PostgreSQL 不選 MySQL、不懂你們的 data model 背後有什麼歷史包袱、不懂那個看起來多餘的 middleware 其實是合規要求。\n\n這些 context 是你的獨有價值。越深入理解你們的系統和業務，你越不可取代。\n\n### 溝通力 > Coding 力\n\nAnthropic 的 Amanda Askell（角色與 prompt 工程負責人）提出了一個很好的框架：**把 agent 當成一個能力很強但缺乏 context 的實習生**。\n\n這個類比精準在：實習生不笨，但他不知道你們公司的潛規則。你需要用清楚的語言告訴他要做什麼、不要做什麼、怎麼判斷做對了。\n\n你的 spec 品質 = agent 的產出品質。而寫好一份 spec，靠的是溝通能力，不是 coding 能力。\n\nShopify CEO Tobi Lutke 把這件事推到了組織層面。他在內部發了一封信說：「在 Shopify，反射性地使用 AI 現在是基本期待。」他甚至要求團隊在增加人力之前，先證明 AI 做不到這件事。\n\n不管你是否認同 Lutke 的激進立場，趨勢是清楚的：**會「管」AI 的人，比會「寫」code 的人更稀缺**。\n\n## 不同資歷的轉型路線圖\n\n這個轉型不是所有人都從同一個起點出發。不同資歷的工程師，面對的機會和風險完全不同。\n\n### Junior（0-3 年）：先打基礎，再用 agent\n\n**最大風險**：沒學會基礎就開始依賴 agent。\n\n如果你從來沒手寫過一個完整的 REST API，你怎麼知道 agent 寫的 API 有沒有問題？你連 review 的能力都沒有。\n\n**建議策略**：\n\n- **先手寫，再用 agent 驗證**。自己寫一遍，然後讓 agent 也寫一遍，比較差異。這是學習速度最快的方式。\n- **把 agent 當 code review partner**，不是 code writer。讓它幫你看你寫的 code 有沒有問題。\n- **刻意練習 debug**。Agent 可以幫你修 bug，但你需要理解 bug 為什麼會發生。\n\n**每週練習**：至少一個 feature 完全手寫，不用 agent。\n\n### Mid-level（3-7 年）：甜蜜點\n\n**你的優勢**：有足夠的基礎來判斷 agent output 的品質，同時又對新方法持開放態度。\n\n這是 agentic engineering 的最佳起步資歷。你知道什麼是好的 code，所以你能有效 review。你也足夠靈活，願意改變工作方式。\n\n**建議策略**：\n\n- **全力投入 agent-first workflow**。你的基礎已經夠了，現在需要的是新的工作方式。\n- **練習寫 spec**。把寫好 spec 當成跟寫好 code 一樣重要的技能。\n- **開始學 context engineering**。CLAUDE.md 怎麼寫、怎麼設計 agent 的工作環境。\n\n**每週練習**：每個 task 都先寫 spec，然後交給 agent。review 的時候記錄「agent 做對了什麼」和「agent 做錯了什麼」。\n\n### Senior（7+ 年）：最大優勢，最大阻力\n\n**你的優勢**：Judgment。你十年的經驗讓你能一眼看出架構問題、預見三個月後的 tech debt、知道哪個 design pattern 適合這個場景。這些 agent 做不到。\n\n**你的阻力**：「我不需要 AI。」很多資深工程師的身份認同跟「我很會寫 code」深度綁定。承認 agent 寫得比你快，等於承認你的核心技能被取代了。\n\n但實際上不是取代——是**升級**。你不是不會寫了，你是有了一個很快的「手」，可以幫你把腦子裡的架構願景更快地實現。\n\n**建議策略**：\n\n- **從你最頭痛的 task 開始**。找一個你一直拖延沒做的 refactoring 或 migration，交給 agent 試試看。\n- **專注在 agent 做不到的事**。跨團隊的架構決策、技術 roadmap、mentoring——這些是你的「不可取代區」。\n- **把你的經驗變成 agent 的 config**。你知道的 coding conventions、架構原則、踩過的坑，寫進 CLAUDE.md。你的經驗不是被取代了，而是被放大了。\n\n**每週練習**：用一個 session 觀察 agent 怎麼做你通常手寫的任務。不介入，只觀察。然後在 review 階段做調整。\n\n## Takeaway\n\n1. 角色轉換的關鍵不是學新工具，而是重新定義「什麼讓你有價值」。你的價值從「能寫出好的 code」轉移到「能判斷什麼是好的 code」。前者 agent 做得到，後者它做不到。\n\n2. 有些能力確實會衰退，但更重要的能力在進步——架構判斷、需求分析、review 眼光。這是淨正面的交換，前提是你有意識地投資後者。\n\n3. 不管你是哪個資歷，現在開始有意識地練習「管 agent」的技能都不晚。Junior 先打基礎再用 agent，Mid-level 全力投入 agent-first，Senior 用 agent 放大你的 judgment。路徑不同，方向一致。\n\n---\n\n_上一篇：[Agentic Engineering 是什麼？](/blog/agentic-engineering-what-is-it)_\n_下一篇：[2026 年 AI Coding 工具全景圖](/blog/agentic-engineering-tools-landscape-2026)_",
      "summary": "Agentic Engineering 最大的挑戰不是技術，是身份認同。當你的價值不再來自打字速度和演算法背誦，你到底是誰？分享角色轉換過程中的心理掙扎、具體變化，以及最終找到的新定位。",
      "image": "https://bobochen.dev/_astro/cover.a47aPukT.webp",
      "date_published": "2026-03-20T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "AI",
        "職涯",
        "角色轉換"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-tool-use/",
      "url": "https://bobochen.dev/blog/claude-api-guide-tool-use/",
      "title": "Tool Use：讓 AI 成為你應用的大腦",
      "content_text": "完整的 Claude Tool Use（Function Calling）指南：tool 定義格式、如何設計好的 tool description、parallel tool use、tool_choice 參數、structured output，以及完整的 Python 執行循環範例。",
      "content_html": "前三章，Claude 一直是個「說話的機器」。\n\n你問它問題，它用文字回答。功能強大，但有一個根本的限制：**Claude 只知道訓練資料裡有的東西**。它不知道今天的股價，不知道你的資料庫裡有什麼，不知道你的訂單系統的狀態。\n\nTool Use（Anthropic 的官方名稱，其他 LLM 廠商通常叫 Function Calling）改變了這一切。\n\n有了 Tool Use，Claude 可以「請求」呼叫你定義的函數，取得即時的外部資訊，然後根據這些資訊給出有意義的回答。更重要的是，Claude 可以決定**什麼時候呼叫工具**，以及**如何解讀工具的結果**。\n\n這讓 Claude 從「問答機器」升級為「AI Agent 的大腦」。\n\n## 核心概念：Tool Use 的流程\n\n先理解整個流程，再看細節：\n\n```\n你 → [請求 + 工具定義] → Claude\nClaude → [決定呼叫哪個工具，帶什麼參數] → 你\n你 → [執行工具，取得結果] → Claude\nClaude → [根據工具結果，給出最終回答] → 你\n```\n\n關鍵在第二步：**Claude 不會幫你執行工具**。它只是告訴你「我想呼叫 X 工具，參數是 Y」。你負責實際執行，把結果傳回去。這個設計讓你完全掌控安全性和執行邏輯。\n\n整個流程可能要好幾輪 API 呼叫（呼叫工具 → 回傳結果 → 再呼叫工具 → ...），直到 Claude 認為它有足夠資訊給出最終答案。\n\n## 定義工具\n\n工具的定義格式如下：\n\n```python\ntools = [\n    {\n        \"name\": \"get_weather\",\n        \"description\": \"取得指定城市目前的天氣狀況。當使用者詢問天氣或計劃需要考慮天氣的活動時使用。不要用於歷史天氣資料或天氣預報超過 7 天的查詢。\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"city\": {\n                    \"type\": \"string\",\n                    \"description\": \"城市名稱，例如 '台北', 'Tokyo', 'New York'\"\n                },\n                \"unit\": {\n                    \"type\": \"string\",\n                    \"enum\": [\"celsius\", \"fahrenheit\"],\n                    \"description\": \"溫度單位，預設使用 celsius\"\n                }\n            },\n            \"required\": [\"city\"]\n        }\n    }\n]\n```\n\n每個工具有三個必填欄位：\n\n- **`name`**：工具的唯一識別名稱，只能包含字母、數字和底線\n- **`description`**：告訴 Claude 這個工具是什麼、什麼時候應該用（非常重要！）\n- **`input_schema`**：JSON Schema 格式的參數定義\n\n### 如何設計好的 Tool Description\n\n這是 Tool Use 最容易被忽略、但最關鍵的部分。\n\n我見過太多開發者把 description 寫成「This tool gets weather data」然後抱怨 Claude 呼叫工具的時機不準確。\n\n**一個好的 tool description 應該告訴 Claude：**\n\n1. **這個工具做什麼**（簡短說明）\n2. **什麼時候應該用**（觸發條件）\n3. **什麼時候不應該用**（避免誤用）\n4. **輸入的限制**（城市名稱要用哪種格式？日期要怎麼傳？）\n5. **工具的局限性**（只有台灣的資料？只能查今天？）\n\n差的 description：\n\n```\n\"description\": \"Gets weather information.\"\n```\n\n好的 description：\n\n```\n\"description\": \"取得指定城市的即時天氣資訊，包含溫度、濕度和天氣狀況。\n當使用者詢問某地的當前天氣、或需要根據天氣做決策時使用。\n注意：只支援當前天氣，不支援天氣預報或歷史天氣。\n城市名稱請用常見的英文或中文名稱，例如 'Taipei' 或 '台北'。\"\n```\n\n同樣的道理也適用於每個 property 的 `description`。Claude 是用 description 來理解要傳什麼值，而不是靠變數名稱。\n\n## 完整的 Python 執行循環\n\n這是 Tool Use 最重要的部分：你需要實作一個「工具執行循環」，讓 Claude 可以多次呼叫工具。\n\n```python\nimport anthropic\nimport json\n\nclient = anthropic.Anthropic()\n\n# 定義工具的具體實作\ndef get_weather(city: str, unit: str = \"celsius\") -> dict:\n    \"\"\"模擬天氣 API（實際應用中這裡會呼叫真正的 API）\"\"\"\n    weather_data = {\n        \"台北\": {\"temp\": 28, \"humidity\": 78, \"condition\": \"多雲\"},\n        \"Tokyo\": {\"temp\": 22, \"humidity\": 65, \"condition\": \"晴天\"},\n        \"New York\": {\"temp\": 15, \"humidity\": 55, \"condition\": \"陰天\"},\n    }\n    data = weather_data.get(city, {\"temp\": 20, \"humidity\": 60, \"condition\": \"未知\"})\n    temp = data[\"temp\"] if unit == \"celsius\" else data[\"temp\"] * 9/5 + 32\n    return {\n        \"city\": city,\n        \"temperature\": f\"{temp}°{'C' if unit == 'celsius' else 'F'}\",\n        \"humidity\": f\"{data['humidity']}%\",\n        \"condition\": data[\"condition\"]\n    }\n\ndef search_database(query: str, limit: int = 5) -> list:\n    \"\"\"模擬資料庫查詢\"\"\"\n    # 實際應用中這裡會查詢真實的資料庫\n    return [{\"id\": i, \"title\": f\"結果 {i}: {query}\", \"score\": 1.0 - i * 0.1}\n            for i in range(1, min(limit + 1, 6))]\n\n# 工具映射：名稱 → 函數\nTOOLS = {\n    \"get_weather\": get_weather,\n    \"search_database\": search_database,\n}\n\n# 工具定義\nTOOL_DEFINITIONS = [\n    {\n        \"name\": \"get_weather\",\n        \"description\": \"取得指定城市目前的天氣。當使用者詢問天氣時使用。\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"city\": {\"type\": \"string\", \"description\": \"城市名稱\"},\n                \"unit\": {\n                    \"type\": \"string\",\n                    \"enum\": [\"celsius\", \"fahrenheit\"],\n                    \"description\": \"溫度單位\"\n                }\n            },\n            \"required\": [\"city\"]\n        }\n    },\n    {\n        \"name\": \"search_database\",\n        \"description\": \"在知識庫中搜尋相關文章和資訊。當需要查找特定主題的資料時使用。\",\n        \"input_schema\": {\n            \"type\": \"object\",\n            \"properties\": {\n                \"query\": {\"type\": \"string\", \"description\": \"搜尋關鍵字\"},\n                \"limit\": {\n                    \"type\": \"integer\",\n                    \"description\": \"最多回傳幾筆結果，預設 5\",\n                    \"default\": 5\n                }\n            },\n            \"required\": [\"query\"]\n        }\n    }\n]\n\n\ndef run_agent(user_message: str) -> str:\n    \"\"\"執行一個完整的 agent 循環\"\"\"\n    messages = [{\"role\": \"user\", \"content\": user_message}]\n\n    while True:\n        # 呼叫 Claude\n        response = client.messages.create(\n            model=\"claude-sonnet-4-6\",\n            max_tokens=4096,\n            tools=TOOL_DEFINITIONS,\n            messages=messages\n        )\n\n        # 把 Claude 的回應加入對話歷史\n        messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n        # 檢查停止原因\n        if response.stop_reason == \"end_turn\":\n            # Claude 認為任務完成，取出文字回應\n            for block in response.content:\n                if block.type == \"text\":\n                    return block.text\n            return \"\"\n\n        elif response.stop_reason == \"tool_use\":\n            # Claude 要呼叫工具\n            tool_results = []\n\n            for block in response.content:\n                if block.type == \"tool_use\":\n                    tool_name = block.name\n                    tool_input = block.input\n                    tool_use_id = block.id\n\n                    print(f\"  [工具呼叫] {tool_name}({tool_input})\")\n\n                    # 執行工具\n                    try:\n                        tool_fn = TOOLS[tool_name]\n                        result = tool_fn(**tool_input)\n                        tool_results.append({\n                            \"type\": \"tool_result\",\n                            \"tool_use_id\": tool_use_id,\n                            \"content\": json.dumps(result, ensure_ascii=False)\n                        })\n                    except Exception as e:\n                        # 工具執行失敗，傳回錯誤資訊\n                        tool_results.append({\n                            \"type\": \"tool_result\",\n                            \"tool_use_id\": tool_use_id,\n                            \"content\": f\"工具執行失敗：{str(e)}\",\n                            \"is_error\": True\n                        })\n\n            # 把工具結果加入對話歷史\n            messages.append({\"role\": \"user\", \"content\": tool_results})\n\n        else:\n            # 其他停止原因（max_tokens 等）\n            break\n\n    return \"Agent 執行未正常完成\"\n\n\n# 使用範例\nif __name__ == \"__main__\":\n    print(\"問題：台北現在的天氣如何？適合出門騎腳踏車嗎？\")\n    result = run_agent(\"台北現在的天氣如何？適合出門騎腳踏車嗎？\")\n    print(f\"\\nClaude 的回答：{result}\")\n\n    print(\"\\n\" + \"=\"*50 + \"\\n\")\n\n    print(\"問題：幫我查一下關於 Astro.js 的資料，然後告訴我台北的天氣\")\n    result = run_agent(\"幫我查一下關於 Astro.js 的資料，然後告訴我台北的天氣\")\n    print(f\"\\nClaude 的回答：{result}\")\n```\n\n## Parallel Tool Use（同時呼叫多個工具）\n\n當 Claude 判斷需要呼叫多個不相關的工具時，它可以在同一個回應裡要求同時呼叫它們，而不是一個一個等待。\n\n```python\n# Claude 可能回傳這樣的 content（包含多個 tool_use blocks）\nresponse.content = [\n    ToolUseBlock(id=\"tu_001\", type=\"tool_use\", name=\"get_weather\",\n                 input={\"city\": \"台北\"}),\n    ToolUseBlock(id=\"tu_002\", type=\"tool_use\", name=\"get_weather\",\n                 input={\"city\": \"Tokyo\"}),\n    ToolUseBlock(id=\"tu_003\", type=\"tool_use\", name=\"search_database\",\n                 input={\"query\": \"最佳旅遊季節\"})\n]\n```\n\n你的工具執行循環應該能處理這種情況，並且**真正平行地執行**這些工具（如果可能的話）：\n\n```python\nimport asyncio\n\nasync def execute_tools_parallel(tool_blocks: list) -> list:\n    \"\"\"平行執行多個工具\"\"\"\n    async def execute_one(block):\n        tool_fn = TOOLS[block.name]\n        # 如果工具是 async 的就 await，否則用 run_in_executor\n        if asyncio.iscoroutinefunction(tool_fn):\n            result = await tool_fn(**block.input)\n        else:\n            loop = asyncio.get_event_loop()\n            result = await loop.run_in_executor(None, lambda: tool_fn(**block.input))\n        return {\n            \"type\": \"tool_result\",\n            \"tool_use_id\": block.id,\n            \"content\": json.dumps(result, ensure_ascii=False)\n        }\n\n    return await asyncio.gather(*[execute_one(b) for b in tool_blocks])\n```\n\n## tool_choice 參數\n\n`tool_choice` 讓你控制 Claude 是否必須呼叫工具：\n\n```python\n# 預設：Claude 自己決定要不要用工具\ntool_choice = {\"type\": \"auto\"}\n\n# 強制 Claude 一定要呼叫某個工具\ntool_choice = {\"type\": \"tool\", \"name\": \"get_weather\"}\n\n# 強制 Claude 一定要呼叫（任何）工具\ntool_choice = {\"type\": \"any\"}\n```\n\n**何時使用 `type: \"any\"` 或 `type: \"tool\"`？**\n\n當你的應用流程要求 Claude 一定要呼叫工具時。例如：你建了一個「結構化資料提取器」，每次呼叫都必須回傳 JSON 格式的結構化資料，你就可以定義一個 `extract_data` 工具並設定 `tool_choice: {type: \"tool\", name: \"extract_data\"}`。\n\n這是實現**結構化輸出（Structured Output）**的最可靠方法——比在 system prompt 裡叫 Claude「請用 JSON 回應」可靠得多。\n\n## 用 Tool Use 實現 Structured Output\n\n這是我在生產環境最常用的技巧之一。\n\n假設你要提取使用者訊息裡的聯絡資訊：\n\n```python\n# 定義一個「假工具」，實際上是用來強制結構化輸出\nextract_contact_tool = {\n    \"name\": \"save_contact\",\n    \"description\": \"儲存提取到的聯絡人資訊\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"name\": {\"type\": \"string\", \"description\": \"姓名\"},\n            \"email\": {\"type\": \"string\", \"description\": \"電子郵件\"},\n            \"phone\": {\"type\": \"string\", \"description\": \"電話號碼\"},\n            \"company\": {\"type\": \"string\", \"description\": \"公司名稱\"},\n        },\n        \"required\": [\"name\"]\n    }\n}\n\ndef extract_contact_info(text: str) -> dict:\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=1024,\n        tools=[extract_contact_tool],\n        tool_choice={\"type\": \"tool\", \"name\": \"save_contact\"},  # 強制呼叫\n        messages=[{\n            \"role\": \"user\",\n            \"content\": f\"請從以下文字中提取聯絡資訊：\\n\\n{text}\"\n        }]\n    )\n\n    # 取得 tool_use block 的 input（就是我們要的結構化資料）\n    for block in response.content:\n        if block.type == \"tool_use\" and block.name == \"save_contact\":\n            return block.input  # 這是一個 dict，型別已驗證\n\n    raise ValueError(\"未能提取聯絡資訊\")\n\n# 使用\ncontact = extract_contact_info(\n    \"嗨，我是王小明，可以聯絡我：ming@example.com 或 0912-345-678，我在 Acme 公司工作。\"\n)\nprint(contact)\n# {'name': '王小明', 'email': 'ming@example.com', 'phone': '0912-345-678', 'company': 'Acme'}\n```\n\n這個方法的優點：\n\n- 回應格式**保證符合你的 schema**（Anthropic 驗證過）\n- 比解析自由格式文字可靠\n- 比在 prompt 裡要求 JSON 更穩定（沒有 markdown code block 的問題）\n\n## 常見 Tool Use 場景\n\n### 資料庫查詢\n\n```python\n{\n    \"name\": \"query_orders\",\n    \"description\": \"查詢訂單資訊。當使用者詢問自己的訂單狀態、出貨進度時使用。只能查詢已登入使用者自己的訂單。\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"order_id\": {\"type\": \"string\", \"description\": \"訂單編號（格式：ORD-XXXXXX）\"},\n            \"status_filter\": {\n                \"type\": \"string\",\n                \"enum\": [\"all\", \"pending\", \"shipped\", \"delivered\", \"cancelled\"],\n                \"description\": \"篩選訂單狀態\"\n            }\n        }\n    }\n}\n```\n\n### 外部 API 呼叫\n\n```python\n{\n    \"name\": \"search_products\",\n    \"description\": \"搜尋商品目錄。當使用者想找特定商品時使用。支援關鍵字搜尋和分類篩選。\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"query\": {\"type\": \"string\", \"description\": \"搜尋關鍵字\"},\n            \"category\": {\"type\": \"string\", \"description\": \"商品分類\"},\n            \"max_price\": {\"type\": \"number\", \"description\": \"最高價格（台幣）\"},\n            \"in_stock_only\": {\"type\": \"boolean\", \"description\": \"是否只顯示有庫存的商品\"}\n        },\n        \"required\": [\"query\"]\n    }\n}\n```\n\n### 計算工具\n\n```python\n{\n    \"name\": \"calculate\",\n    \"description\": \"執行數學計算。當使用者詢問需要精確計算的數學問題時使用，例如利率計算、折扣計算、單位換算等。不要用於簡單的心算問題。\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"expression\": {\n                \"type\": \"string\",\n                \"description\": \"數學表達式，例如 '(100000 * 0.03) / 12' 或 '25 * 4 + 10'\"\n            }\n        },\n        \"required\": [\"expression\"]\n    }\n}\n```\n\n## tool_result 的錯誤處理\n\n當工具執行失敗時，你應該傳回一個帶有 `is_error: true` 的 tool_result：\n\n```python\n# 正常結果\n{\n    \"type\": \"tool_result\",\n    \"tool_use_id\": \"tu_001\",\n    \"content\": json.dumps({\"weather\": \"晴天\", \"temp\": \"28°C\"})\n}\n\n# 錯誤結果\n{\n    \"type\": \"tool_result\",\n    \"tool_use_id\": \"tu_001\",\n    \"content\": \"查詢失敗：城市 'Xanadu' 不在支援的城市列表中\",\n    \"is_error\": True\n}\n```\n\nClaude 看到 `is_error: true` 後，通常會這樣回應使用者：「很抱歉，我嘗試查詢 Xanadu 的天氣，但系統回報該城市不在支援範圍內。請問您是指其他城市嗎？」\n\n這比直接讓工具例外（exception）崩潰整個 agent 循環優雅得多。\n\n## Token 成本計算\n\nTool Use 有一些額外的 token 成本，需要注意：\n\n1. **工具定義本身消耗 input tokens**：你的 tool definition JSON 越詳細，消耗越多。每次 API 呼叫都要傳工具定義，所以這個成本是固定的\n2. **tool_use block 消耗 output tokens**：Claude 生成的工具呼叫請求（包含工具名稱和參數）算在 output tokens\n3. **tool_result 消耗 input tokens**：你傳回的工具結果算在下一輪的 input tokens\n\n實際測試中，一個包含 3 個工具定義的請求，光是工具定義就大約消耗 300-500 input tokens。如果你有很多工具（10+），工具定義的 token 成本可能相當可觀。\n\n**優化策略：**\n\n- 只在這個對話確實需要某個工具時才傳入該工具定義\n- 保持工具定義的 description 簡潔但充分（不要超長）\n- 對於不太常用的工具，考慮動態載入而不是每次都傳\n\n## 下一步\n\nTool Use 讓你的 AI 應用可以存取外部世界的資訊，解決了「Claude 只知道訓練資料」的根本限制。搭配工具執行循環，你可以建立真正能做事的 AI Agent。\n\n但有時候，使用者提出的問題非常複雜——需要多步驟推理、涉及數學計算、或者需要仔細分析各種可能性。預設的 Claude 很聰明，但它的「思考」是在生成回應的同時進行的，沒有機會「想清楚再說」。\n\n**下一章**，我們來看 Extended Thinking——讓 Claude 在回答之前先花時間深度思考，解決那些最困難的推理任務。",
      "summary": "完整的 Claude Tool Use（Function Calling）指南：tool 定義格式、如何設計好的 tool description、parallel tool use、tool_choice 參數、structured output，以及完整的 Python 執行循環範例。",
      "image": "https://bobochen.dev/_astro/cover.Bxdj-NV8.webp",
      "date_published": "2026-03-20T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Tool Use",
        "Function Calling",
        "開發工具"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/build-your-own-mcp-server-15-minutes/",
      "url": "https://bobochen.dev/blog/build-your-own-mcp-server-15-minutes/",
      "title": "自己的 MCP Server 自己包：從既有 Python 腳本到 Claude 可呼叫的工具，只要 15 分鐘",
      "content_text": "用 uv + FastMCP + importlib 把既有的 Python 腳本包成 MCP server，2 個檔案、150 行、0 行既有程式碼修改",
      "content_html": "## 背景\n\n上一篇我評估了兩個讓 AI 操控剪輯軟體的 MCP 工具，結論是：一個免費但用處有限，一個功能強但需要付費版 DaVinci Resolve。兩個都沒裝。\n\n但那次評估讓我想到一件事：與其等別人做出完美的工具，不如把我自己已經在用的腳本包成 MCP server？\n\n我的 Code Fossil YouTube 頻道有一支 `timeline-to-fcpxml.py`，347 行純 Python，讀 `timeline.json` 產出 DaVinci Resolve 可匯入的 FCPXML。每次要用都得開終端機下指令。如果 Claude 能直接呼叫它，不就省了這個步驟？\n\n## 發現過程\n\n### 工具選擇：uv + FastMCP\n\nMCP server 的 Python SDK 叫 [FastMCP](https://github.com/jlowin/fastmcp)，裝法最簡單的是搭配 [uv](https://github.com/astral-sh/uv)（Rust 寫的 Python 套件管理工具）。Claude Code 的 MCP 文件也推薦這個組合。\n\n```bash\nbrew install uv\n```\n\n### 關鍵設計決策：importlib 載入，不複製程式碼\n\n最大的問題是：MCP server 怎麼用既有腳本的邏輯？\n\n選項一：複製核心函式到 server.py — 簡單但會 drift，改了一邊忘了另一邊。\n選項二：把既有腳本改成 package — 動太大，而且 CLI 用法會壞掉。\n選項三：用 `importlib` 直接載入既有的 .py 檔 — 零修改，單一來源。\n\n我選了選項三：\n\n```python\nimport importlib.util\n\nspec = importlib.util.spec_from_file_location(\n    \"timeline_to_fcpxml\",\n    os.path.join(TOOLS_DIR, \"timeline-to-fcpxml.py\"),\n)\nmod = importlib.util.module_from_spec(spec)\nspec.loader.exec_module(mod)\n\n# 直接呼叫 mod.load_timeline(), mod.build_fcpxml()\n```\n\n既有腳本底部有 `if __name__ == \"__main__\": main()`，所以載入時不會觸發 `main()`。原本的 CLI 用法完全不受影響。\n\n### 三個工具就夠\n\n參考現成的 fcpxml-mcp-server（53 個工具），我只做了三個：\n\n1. **`generate_fcpxml`** — 讀 timeline.json，產出 FCPXML 檔案\n2. **`validate_timeline`** — 檢查 timeline 的統計和潛在問題（gap、缺素材、zero-duration）\n3. **`preview_fcpxml`** — 在記憶體產出 FCPXML 回傳前 N 行，不寫檔\n\n為什麼不做更多？因為 Claude Code 本身已經有檔案系統存取能力（Read、Glob、Grep），做一個 `list_assets` 工具只是重複它已經會的事。\n\n### 踩到的坑：路徑解析\n\n`uv --directory tools/mcp-server run server.py` 會把工作目錄設到 `tools/mcp-server/`，但 timeline.json 在 `output/` 底下。Claude 傳進來的路徑可能是相對路徑（`output/timeline.json`）也可能是絕對路徑。\n\n解法：所有路徑參數都透過一個 `_resolve_path()` 函式，相對路徑自動接上 project root：\n\n```python\n_PROJECT_ROOT = os.path.dirname(os.path.dirname(\n    os.path.dirname(os.path.abspath(__file__))\n))\n\ndef _resolve_path(path: str) -> str:\n    if os.path.isabs(path):\n        return path\n    return os.path.join(_PROJECT_ROOT, path)\n```\n\n### 3 分鐘快速上手\n\n裝好 `uv` 之後，要在自己的專案裡包一個 MCP server：\n\n**1. 建立專案結構**\n\n```bash\nmkdir -p tools/mcp-server\ncd tools/mcp-server\n```\n\n建立 `pyproject.toml`：\n\n```toml\n[project]\nname = \"my-mcp-server\"\nversion = \"0.1.0\"\nrequires-python = \">=3.10\"\ndependencies = [\"mcp[cli]>=1.2.0\"]\n```\n\n**2. 寫 server.py（最小範例）**\n\n```python\nfrom mcp.server.fastmcp import FastMCP\n\nmcp = FastMCP(\"my-tool\")\n\n@mcp.tool()\ndef hello(name: str) -> str:\n    \"\"\"Say hello.\"\"\"\n    return f\"Hello, {name}!\"\n\nif __name__ == \"__main__\":\n    mcp.run(transport=\"stdio\")\n```\n\n**3. 測試**\n\n```bash\n# 送一個 initialize 請求，看有沒有回應\necho '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}' \\\n  | uv --directory tools/mcp-server run python server.py\n```\n\n看到 JSON response 就成功了。接著在專案根目錄建 `.mcp.json` 註冊：\n\n```json\n{\n  \"mcpServers\": {\n    \"my-tool\": {\n      \"type\": \"stdio\",\n      \"command\": \"uv\",\n      \"args\": [\"--directory\", \"/absolute/path/to/tools/mcp-server\", \"run\", \"python\", \"server.py\"]\n    }\n  }\n}\n```\n\n下次開 Claude Code 就會自動載入。\n\n## 具體數據 / 結果\n\n| 指標            | 數值                                            |\n| --------------- | ----------------------------------------------- |\n| 新增檔案        | 2（`server.py` + `pyproject.toml`）             |\n| 新增程式碼      | ~150 行                                         |\n| 修改既有程式碼  | 0 行                                            |\n| 暴露的 MCP 工具 | 3 個                                            |\n| 從動手到跑通    | ~15 分鐘                                        |\n| 依賴安裝        | `uv`（brew install）+ `mcp[cli]`（uv 自動管理） |\n\n實際呼叫 `validate_timeline` 的結果：\n\n```\nTimeline: output/timeline.json\nEntries: 59\nDuration: 453s (7.5 min)\nTypes: {\"vhs\": 4, \"text-card\": 30, \"screenshot\": 18, \"gap\": 7}\nWith assets: 52/59\n\nIssues found:\n  Missing assets (7): b05-black, b09-face, b14-face...\n  Timeline gaps:\n    Gap between hook-06 and b01-01: 1.00s\n    Gap between b05-black and b06-01: 11.90s\n```\n\n以前要開終端機跑指令才看得到這些，現在 Claude 可以直接呼叫、直接分析。\n\n## 反思\n\n### 技術面\n\nMCP server 的本質就是 JSON-RPC over stdio。FastMCP 把所有協議細節藏起來，你只要寫 `@mcp.tool()` 裝飾器就好。實際的認知負擔比我想像的低很多——跟寫一個 Flask route 差不多。\n\n`importlib` 載入既有腳本是個很實用的技巧。不用重構既有程式碼，不用改檔名（Python 不喜歡檔名有 hyphen，但 `importlib` 不在乎），原本的 CLI 和新的 MCP server 共用同一份邏輯。\n\n### 心態面\n\n上一篇的結論是「不裝」，這一篇的結論是「自己做比想像中簡單」。兩個結論不矛盾——重點是你得先搞清楚自己的需求，再決定是用現成的、自己做、還是都不做。\n\n我一開始被那些 53 工具、21 工具的規模嚇到，以為寫 MCP server 很複雜。實際做了才發現，對自己的用途，3 個工具、150 行就夠了。\n\n### 有趣發現\n\n寫 MCP server 最有價值的部分不是 `generate_fcpxml`（那個 CLI 也能做），而是 `validate_timeline`。因為它讓 Claude 可以主動檢查 timeline 的問題，而不是等我人工發現。這種「讓 AI 有能力自己做 QC」的模式，比我預期的更有用。",
      "summary": "用 uv + FastMCP + importlib 把既有的 Python 腳本包成 MCP server，2 個檔案、150 行、0 行既有程式碼修改",
      "image": "https://bobochen.dev/_astro/cover.B3day5Vw.webp",
      "date_published": "2026-03-18T00:00:00.000Z",
      "tags": [
        "MCP",
        "Claude Code",
        "Python",
        "FastMCP",
        "開發效率"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-deployment/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-deployment/",
      "title": "部署上線：選對平台省 80% 的事",
      "content_text": "一個人做產品，部署平台選錯就要花一半時間在維運上。本文比較 Cloudflare Workers、Vercel、Cloud Run 三大平台的免費額度、真實成本與適用場景，並用一套三問決策框架幫你選對平台，做到 git push 就自動上線。",
      "content_html": "## 那個毀掉週末的部署：選錯平台的代價\n\n你的 MVP 寫好了。在本機跑得完美無缺。\n\n「接下來就是部署嘛，應該很快。」你這樣想。\n\n然後你打開了 AWS Console。\n\n幾個小時後，你還在跟 IAM 權限、VPC、Security Group 這些設定打轉。CloudFront 的 cache invalidation 生效要等十幾分鐘，HTTPS 憑證又卡在 DNS 驗證。\n\n到了星期天晚上，產品還是沒上線，一整個週末就這樣耗掉了。\n\n如果你有過這種經驗，你犯了 Solo Builder 最常犯的錯：**把部署平台當成「寫完 code 之後的小事」**。\n\n但對一個人做產品來說，部署平台的選擇可能是僅次於技術棧之後最重要的決定。選對了，`git push` 就上線。選錯了，光是搞部署就吃掉你一半的可用時間。\n\n## 部署的 80/20 法則\n\n先講一個我自己抓的粗估：\n\n一個人做產品，花在「部署相關」工作上的時間，大概佔整體開發時間的三到四成。\n\n先誠實說：這三到四成不是哪份研究的數字，是我自己做過幾個產品抓出來的體感，而且變異很大。一個純靜態部落格可能只花 5%，一個要顧資料庫、金流、排程的全棧 SaaS 衝到一半也不奇怪。所以這個數字你別當成普世統計，當成「這件事比你以為的吃時間」的提醒就好。標題那個「省 80%」也是同樣的意思——是個讓你記住重點的講法，不是承諾。\n\n這包括：\n\n- 初始設定（CI/CD pipeline、環境變數、域名、SSL）\n- 除錯（「為什麼本機可以跑但 production 不行？」）\n- 維運（監控、log 查看、擴容、更新）\n- 成本管理（帳單、用量追蹤、方案升級）\n\n在一個有 DevOps 團隊的公司，這些事有專人處理。但你只有一個人。你花在部署上的每一分鐘，都是從開發功能和寫內容的時間裡偷來的。\n\n所以選對平台才這麼重要，你要的就是一個能把部署時間大幅省下來的平台。\n\n理想的狀態是：\n\n```text\n你寫程式碼 → git push → 自動建置 → 自動部署 → 上線\n```\n\n中間不需要你做任何事。不需要手動觸發。不需要 SSH 進 server。不需要更新 Docker image。不需要清 cache。\n\n2026 年，這個理想是完全可以實現的。但前提是你要選對平台。\n\n## 三大平台比較\n\nSolo Builder 在 2026 年最常用的部署平台有三個。每個都有明確的適用場景。\n\n### 選項 1：Cloudflare Workers + Pages\n\n**一句話：邊緣運算全家桶，免費額度最慷慨。**\n\nCloudflare 的開發者平台在過去幾年進化成了一個完整的生態系。不只是 CDN——它提供運算（Workers）、靜態託管（Pages）、資料庫（D1）、物件儲存（R2）、鍵值存儲（KV），全部整合在同一個平台。\n\n**核心優勢：**\n\n- **零冷啟動**：Workers 在全球 330+ 邊緣節點運行，沒有傳統 serverless 的冷啟動問題\n- **免費額度極其慷慨**：10 萬次 request/天、10 GB R2 儲存、5 GB D1 資料庫\n- **部署速度快**：全球部署通常在 30 秒內完成\n- **全家桶整合**：計算、儲存、資料庫、CDN 全在一個 dashboard 管理\n- **Wrangler CLI**：一個指令搞定所有事\n\n**適合的產品類型：**\n\n- 靜態網站、部落格、文件站（Pages）\n- API 服務（Workers）\n- 全棧應用（Workers + D1 + R2）\n- 任何需要全球低延遲的服務\n\n**不適合的場景：**\n\n- 需要長時間運行的任務（Workers 有 CPU 時間限制）\n- 需要傳統 SQL 資料庫功能的應用（D1 是 SQLite，不是 PostgreSQL）\n- 需要 WebSocket 長連接的即時應用（需要 Durable Objects，設定較複雜）\n\n> **先說清楚鎖定這件事（我等等講 Cloud Run 會誇它「沒有廠商鎖定」，但全家桶剛好相反）：** 你只用 Pages 放靜態網站，幾乎沒有鎖定，要走隨時走。可是一旦你照我建議「全棧就上 Workers + D1 + R2」，你就是在簽一份解約很貴的合約。Workers 跑的不是完整 Node.js runtime，很多 npm 套件和原生模組直接不能用，你的程式碼是寫給 Workers 的、不是寫給 Node 的；D1 是 Cloudflare 自家的 SQLite，KV 和 Durable Objects 更是別的平台根本沒有對等品；R2 雖然標榜 S3-compatible，但 binding 寫法是專屬的。前端框架換平台只是換 adapter，這些東西換平台是重寫。我自己還是用，因為免費額度跟 DX 真的香——但你要知道你綁的是哪一個，別以為三個平台的鎖定程度一樣。\n\n### 選項 2：Vercel\n\n**一句話：前端框架的最佳夥伴，開發者體驗一流。**\n\nVercel 是 Next.js 的母公司，自然是 Next.js 部署的首選。但它也支援 Astro、Nuxt、SvelteKit 等框架。\n\n**核心優勢：**\n\n- **框架整合最深**：尤其是 Next.js，幾乎零設定\n- **Preview Deployments**：每個 PR 自動生成預覽環境\n- **Edge Functions**：支援邊緣運算\n- **Analytics**：內建 Web Vitals 監控\n- **開發者體驗**：Dashboard 好看好用\n\n**適合的產品類型：**\n\n- Next.js 應用（天然搭配）\n- 需要 SSR 的動態網站\n- 需要 Preview Deployments 的團隊協作（雖然你是一個人，但對 review 很方便）\n\n**不適合的場景：**\n\n- 需要後端 API 的全棧應用（Vercel 的 serverless function 有冷啟動和執行時間限制）\n- 頻寬密集型應用（免費方案 100 GB/月，超過開始收費）\n- 需要資料庫的應用（需要外接 Supabase、PlanetScale 等）\n\n### 選項 3：Google Cloud Run\n\n**一句話：把容器丟上去就能跑，介於 serverless 和 VPS 之間。**\n\nCloud Run 是 Google Cloud 的容器化 serverless 服務。你給它一個 Docker container，它幫你跑起來，自動 scale。\n\n**核心優勢：**\n\n- **任何語言都能跑**：只要能打包成 Docker container\n- **真正的 serverless**：沒有流量時 scale to zero，不收費\n- **彈性最大**：可以跑任何框架、任何語言、任何架構\n- **GCP 整合**：如果你已經在用 GCP 的其他服務（Cloud SQL、Cloud Storage）\n- **沒有廠商鎖定**：標準 Docker container，隨時可以搬到其他平台\n\n**適合的產品類型：**\n\n- 已經有 Docker 化的應用\n- 需要跑非 JavaScript 語言的後端（Python、Go、Rust）\n- 需要長時間運行或計算密集的任務\n- 需要 PostgreSQL 的全棧應用（搭配 Cloud SQL）\n\n**不適合的場景：**\n\n- 簡單的靜態網站（殺雞用牛刀）\n- 完全不想碰 Docker 的人\n- 不需要 GCP 生態系的人（設定比前兩者複雜）\n\n## 詳細比較表\n\n### 免費額度比較\n\n| 項目     | Cloudflare Workers + Pages | Vercel             | Cloud Run            |\n| -------- | -------------------------- | ------------------ | -------------------- |\n| 請求數   | 10 萬次/天                 | 無限（有頻寬限制） | 200 萬次/月          |\n| 頻寬     | 無限（靜態）               | 100 GB/月          | 1 GB/月              |\n| 計算時間 | 10ms CPU/次                | 100 GB-hours/月    | 180,000 vCPU-秒/月   |\n| 建置次數 | 500 次/月                  | 6,000 分鐘/月      | —                    |\n| 資料庫   | D1: 5 GB                   | 需外接             | Cloud SQL 無免費額度 |\n| 物件儲存 | R2: 10 GB                  | 需外接             | Cloud Storage: 5 GB  |\n| 自訂域名 | ✅ 免費                    | ✅ 免費            | ✅ 免費              |\n| SSL      | ✅ 自動                    | ✅ 自動            | ✅ 自動              |\n| CDN      | ✅ 全球 330+ 節點          | ✅ 全球            | ❌ 需另外設定        |\n\n### 開發者體驗比較\n\n| 項目                | Cloudflare        | Vercel            | Cloud Run                |\n| ------------------- | ----------------- | ----------------- | ------------------------ |\n| 設定到部署時間      | 5 分鐘            | 3 分鐘            | 15-30 分鐘               |\n| git push 自動部署   | ✅                | ✅                | ✅（需設定 Cloud Build） |\n| Preview Deployments | ✅                | ✅                | ❌ 需自建                |\n| CLI 品質            | ⭐⭐⭐⭐ wrangler | ⭐⭐⭐⭐⭐ vercel | ⭐⭐⭐ gcloud            |\n| Dashboard           | ⭐⭐⭐⭐          | ⭐⭐⭐⭐⭐        | ⭐⭐⭐                   |\n| 文件品質            | ⭐⭐⭐⭐          | ⭐⭐⭐⭐⭐        | ⭐⭐⭐⭐                 |\n| AI 友善度           | ⭐⭐⭐⭐          | ⭐⭐⭐⭐⭐        | ⭐⭐⭐                   |\n\n### 成本升級比較（超出免費額度後）\n\n| 用量等級            | Cloudflare            | Vercel           | Cloud Run   |\n| ------------------- | --------------------- | ---------------- | ----------- |\n| 小型（1,000 DAU）   | $0（免費額度內）      | $0（免費額度內） | ~$0-5/月    |\n| 中型（10,000 DAU）  | $5/月（Workers Paid） | $20/月（Pro）    | ~$10-30/月  |\n| 大型（100,000 DAU） | $5-25/月              | $20-150/月       | ~$50-200/月 |\n\nCloudflare 的付費方案從 $5/月的 Workers Paid Plan 起跳，包含 1,000 萬次 request。性價比極高。\n\n## 決策框架：你的產品該選哪個\n\n不要比較二十個維度。問自己三個問題就夠了：\n\n### 問題 1：你的產品是什麼類型？\n\n| 產品類型                     | 推薦平台                | 原因                 |\n| ---------------------------- | ----------------------- | -------------------- |\n| 靜態網站 / 部落格            | Cloudflare Pages        | 免費、快、零設定     |\n| Next.js 應用                 | Vercel                  | 原生支援、零設定     |\n| API 服務                     | Cloudflare Workers      | 免費額度大、零冷啟動 |\n| 全棧 SaaS（JS）              | Cloudflare Workers + D1 | 全家桶、一站式       |\n| 全棧 SaaS（需要 PostgreSQL） | Cloud Run + Cloud SQL   | 最彈性               |\n| 計算密集型                   | Cloud Run               | 沒有 CPU 時間限制    |\n\n### 問題 2：你在[第 3 章（技術選型決策框架）](/blog/ai-solo-builder-tech-stack)選了什麼技術棧？\n\n如果你照[第 3 章](/blog/ai-solo-builder-tech-stack)的建議選了 TypeScript + Astro → **Cloudflare**。\n如果你選了 Next.js → **Vercel**。\n如果你用 Python、Go 或其他語言 → **Cloud Run**。\n\n### 問題 3：你願意花多少時間在部署上？\n\n- **零時間**：Cloudflare Pages 或 Vercel（git push 就上線）\n- **一小時**：Cloudflare Workers（需要寫 wrangler.jsonc）\n- **半天**：Cloud Run（需要寫 Dockerfile、設定 Cloud Build）\n\n## AI 輔助部署：傳統做法 vs. AI 加持\n\n部署設定是 AI 最能幫忙的環節之一。因為部署設定大部分是「照著文件抄設定檔」——這種結構化、模式化的工作，AI 做得又快又準。\n\n### 傳統做法\n\n1. 讀平台文件（30 分鐘）\n2. 複製範例設定檔，開始修改（20 分鐘）\n3. 第一次部署失敗，看錯誤訊息（10 分鐘）\n4. Google 錯誤訊息，找到 Stack Overflow（15 分鐘）\n5. 修改設定、再部署（10 分鐘）\n6. 又失敗，不同的錯誤（10 分鐘）\n7. 重複步驟 4-6 三次（45 分鐘）\n8. 終於成功\n\n→ 合計約 2-3 小時\n\n### AI 加持做法\n\n**Step 1：讓 AI 生成完整的部署設定**\n\n以 Cloudflare Workers 為例：\n\n```text\n我的專案：\n- 框架：Astro 6\n- 功能：靜態部落格 + 少量動態 API（搜尋、聯絡表單）\n- 資料庫：D1 (SQLite)\n- 檔案儲存：R2\n- 目前在本機用 npm run dev 可以正常運行\n\n請幫我生成部署到 Cloudflare Workers + Pages 需要的所有設定：\n\n1. wrangler.jsonc（包含 D1 和 R2 bindings）\n2. GitHub Actions workflow（push to main 自動部署）\n3. 需要設定的環境變數清單\n4. 部署前的 checklist\n```\n\n**Step 2：讓 AI 預測可能的問題**\n\n```text\n基於剛才的設定，請列出部署時最可能遇到的 5 個問題，\n以及每個問題的解決方法。\n\n特別注意：\n- Astro 在 Cloudflare Workers 上的已知限制\n- D1 的 binding 設定常見錯誤\n- 環境變數在 production 和 development 的差異\n```\n\n**Step 3：部署失敗時讓 AI 診斷**\n\n```text\n部署失敗了，以下是錯誤訊息：\n\n[貼上完整的錯誤日誌]\n\n我的設定：\n- wrangler.jsonc: [貼上]\n- package.json 的 build script: [貼上]\n\n請分析失敗原因，並給出具體的修復步驟。\n```\n\n用 AI 輔助，整個部署過程通常 30 分鐘內搞定——包括遇到問題和解決問題。\n\n## 零設定部署：git push → 自動上線\n\n一旦初始設定完成，之後的每一次部署都應該是自動的。對 Solo Builder 來說這是核心需求，你不會想每次更新都要手動操作。\n\n### Cloudflare Pages 的自動部署\n\n最簡單的方式是連接 GitHub repo：\n\n1. 在 Cloudflare Dashboard 建立 Pages 專案\n2. 連接你的 GitHub repo\n3. 設定建置指令（`npm run build`）和輸出目錄（`dist`）\n4. 完成\n\n之後每次 push 到 main，Cloudflare 自動建置並部署。每個 PR 還會生成一個預覽 URL。\n\n### GitHub Actions + Wrangler\n\n如果你需要更多控制（例如部署前跑測試、部署後做 health check），用 GitHub Actions：\n\n```yaml\n# .github/workflows/deploy.yml\nname: Deploy to Cloudflare\n\non:\n  push:\n    branches: [main]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 22\n\n      - run: npm ci\n      - run: npm run build\n      - run: npm run test\n\n      - name: Deploy\n        uses: cloudflare/wrangler-action@v4\n        with:\n          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}\n          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}\n          command: deploy\n```\n\n這個 workflow 做了四件事：安裝依賴、建置、跑測試、部署。全自動，不需要你動手。\n\n### 部署後驗證\n\n自動部署之後，加一步自動驗證：\n\n```yaml\n- name: Health Check\n  run: |\n    sleep 15\n    STATUS=$(curl -s -o /dev/null -w \"%{http_code}\" https://your-site.com)\n    if [ \"$STATUS\" != \"200\" ]; then\n      echo \"Health check failed with status $STATUS\"\n      exit 1\n    fi\n    echo \"Health check passed!\"\n```\n\n如果部署後網站不正常，GitHub Actions 會顯示失敗。你可以在 GitHub 設定通知，部署失敗時收到 email。\n\n## 真實案例：bobo-blog 的部署架構\n\n讓我用 bobo-blog（就是你正在看的這個部落格）的真實部署設定來說明。\n\n### 架構\n\n```text\nGitHub repo → GitHub Actions → Cloudflare Workers\n                                    ├── Pages（靜態資源）\n                                    ├── Workers（動態路由）\n                                    └── wrangler.jsonc（設定）\n```\n\n### 為什麼選 Cloudflare Workers 而不是 Cloudflare Pages\n\nbobo-blog 用的是 Astro + Cloudflare Workers adapter，而不是純 Pages。原因是：\n\n- **動態路由**：部落格有搜尋功能、聯絡表單等需要伺服器端處理的功能\n- **Headers 控制**：需要精細的 cache control 和 security headers\n- **未來擴充**：如果要加 API（例如訂閱功能），Workers 已經準備好了\n\n純靜態的部落格用 Pages 就夠了。但如果你的產品有任何動態需求，建議一開始就用 Workers。\n\n### 部署設定\n\n```jsonc\n// wrangler.jsonc（精簡示意，對齊本部落格實際設定）\n{\n  \"name\": \"bobo-blog-repo\",\n  \"main\": \"worker/index.ts\",\n  \"compatibility_date\": \"2025-12-21\",\n  \"compatibility_flags\": [\"nodejs_compat\"],\n  \"assets\": {\n    \"binding\": \"ASSETS\",\n    \"directory\": \"./dist/client\",\n    \"not_found_handling\": \"404-page\"\n  }\n}\n```\n\n就這麼簡單。沒有 Docker、沒有 Kubernetes、沒有 load balancer、沒有 reverse proxy。\n\n一個 JSON 設定檔，`wrangler deploy` 一個指令，30 秒內全球部署完成。\n\n### 部署流程\n\n```text\n我 push 到 main\n    ↓\nGitHub Actions 觸發\n    ↓\nnpm run build（Astro 建置，約 30 秒）\n    ↓\nwrangler deploy（部署到 Cloudflare，約 15 秒）\n    ↓\nHealth check 確認網站正常\n    ↓\n完成\n```\n\n從 push 到上線：不到 2 分鐘。\n\n### 成本\n\n到目前為止：**$0/月**。\n\n全部在免費額度內。靜態資源的頻寬不計費。Workers 的 10 萬次/天免費 request 對一個個人部落格來說綽綽有餘。\n\n什麼時候會開始花錢？大概要到每天超過 10 萬次請求。以一個部落格來說，那已經是非常成功的流量了。到那時候，$5/月的 Workers Paid Plan 就夠用了。\n\n## 環境變數和密鑰管理\n\n部署時最容易出包的地方之一：環境變數。\n\n### 常見的坑\n\n1. **本機有但 production 沒有**：你在 `.env.local` 設了 API key，但忘了在部署平台設定\n2. **格式不對**：有些平台不支援 `.env` 檔案的某些語法\n3. **密鑰洩漏**：不小心把 `.env` commit 到 GitHub\n\n### 環境變數 Checklist\n\n在部署之前，用 AI 幫你檢查：\n\n```text\n以下是我的 .env.local 檔案（已遮蔽敏感值）：\n\nDATABASE_URL=d1://xxx\nR2_BUCKET=my-bucket\nSTRIPE_SECRET_KEY=sk_test_xxx\nSITE_URL=https://my-site.com\n\n我要部署到 Cloudflare Workers。\n請幫我：\n1. 列出哪些變數需要在 Cloudflare Dashboard 設定\n2. 哪些變數用 secrets（加密），哪些用普通 variables\n3. 哪些變數在 production 和 development 的值不同\n4. 確認 .gitignore 有正確排除 .env 檔案\n```\n\n## 什麼時候該升級：超出免費額度的信號\n\n免費額度不會永遠夠用。以下是你該考慮升級的信號：\n\n| 信號                           | 代表什麼                  | 該怎麼做                         |\n| ------------------------------ | ------------------------- | -------------------------------- |\n| 免費 request 用量超過 70%      | 流量在成長                | 評估付費方案                     |\n| 建置頻繁失敗                   | 建置次數接近上限          | 減少不必要的 push 或升級方案     |\n| 冷啟動開始被用戶抱怨           | 流量模式不適合 serverless | 考慮 always-on 選項              |\n| 資料庫大小接近上限             | 資料在成長                | 評估付費方案或清理舊資料         |\n| 你花時間在優化免費額度而非產品 | 優先順序錯了              | 直接升級，把時間花在有價值的事上 |\n\n最後一點最重要。**如果你發現自己在花時間「省部署費用」，那就直接付費。** 你的時間比每月 $5-20 的雲端費用值錢得多。\n\n## 部署平台遷移：沒你想的那麼難\n\n如果你選錯了，不要害怕換。\n\n大部分現代框架（Astro、Next.js、SvelteKit）都支援多個部署平台。換平台通常只需要：\n\n1. 換一個 adapter（例如 `@astrojs/cloudflare` → `@astrojs/vercel`）\n2. 調整設定檔（wrangler.jsonc → vercel.json）\n3. 在新平台設定環境變數\n4. 指向新的部署 URL\n\n整個過程大約 1-2 小時。不是不可逆的大手術。\n\n但「1-2 小時」只在一種情境成立，我得把話講準，免得你被我害到：\n\n- **情境一：純靜態或 SSR 前端、沒有自己的資料庫和儲存。** 換 adapter、改設定、重設環境變數，1-2 小時真的搞得定。你只是搬一個會自己重新 build 的網站。\n- **情境二：你照前面建議上了全棧（D1 資料庫、R2 儲存、Workers 專屬 runtime）。** 這不是 1-2 小時，這是好幾天到一兩週的工程：D1 的 SQLite 要匯出再匯進別人的 PostgreSQL，schema 和語法會打架；R2 的檔案要整批搬、舊 URL 要重寫；Workers-only 的程式碼要改回能在 Node 跑；secrets 全部重設；最後 DNS 切換還有停機風險。\n\n所以這句「沒你想的那麼難」只適用情境一。情境二剛好相反——它難到會回頭懲罰你早期的架構決定。不要在選平台上糾結太久沒錯，但也別因為「反正以後好搬」就閉著眼睛把核心資料綁進某個平台。前端可以先上線再說，資料層值得你多想十分鐘。\n\n## 本章重點回顧\n\n- 🏗️ 部署平台的選擇對 Solo Builder 比團隊更重要——你沒有 DevOps 團隊，選錯了你要自己扛\n- 📊 三大平台各有所長：Cloudflare（全家桶 + 最慷慨免費）、Vercel（最佳 DX + Next.js 首選）、Cloud Run（最彈性 + Docker 通吃）\n- 💰 免費額度是你的跑道——Cloudflare 和 Vercel 都能讓你在驗證 PMF 之前零成本營運\n- 🤖 AI 可以在 30 分鐘內幫你搞定完整的部署設定，包括 CI/CD pipeline 和環境變數\n- 🔄 理想部署流程：git push → 自動建置 → 自動部署 → 自動驗證，中間不需要你動手\n- ⏱️ 不要糾結太久——選一個先上線，真的不合適再換。但「好搬」只算純前端（換 adapter，1-2 小時）；有資料庫和儲存的全棧要搬是好幾天的工程，這部分早點想清楚\n\n## 下一步\n\n產品部署上線了。\n\n但上線不是終點——如果沒人知道你的產品存在，零流量跟沒上線沒有兩樣。\n\n下一章，我們來解決「讓產品被找到」的問題：怎麼用 AI 快速做出有轉換力的 Landing Page，以及怎麼建立一套讓 Google 幫你持續帶來免費流量的 SEO 策略。\n\n👉 [第 7 章：Landing Page 與 SEO——讓產品被找到](/blog/ai-solo-builder-landing-page-seo)",
      "summary": "一個人做產品，部署平台選錯就要花一半時間在維運上。本文比較 Cloudflare Workers、Vercel、Cloud Run 三大平台的免費額度、真實成本與適用場景，並用一套三問決策框架幫你選對平台，做到 git push 就自動上線。",
      "image": "https://bobochen.dev/_astro/cover.Cs7XV7va.webp",
      "date_published": "2026-03-15T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Cloudflare",
        "Vercel",
        "Cloud Run",
        "部署",
        "Serverless"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/agentic-engineering-what-is-it/",
      "url": "https://bobochen.dev/blog/agentic-engineering-what-is-it/",
      "title": "Agentic Engineering 是什麼？為什麼 Karpathy 要發明這個詞",
      "content_text": "2026 年 2 月 Karpathy 提出 Agentic Engineering，但它跟 Vibe Coding、Prompt Engineering 差在哪？從定義出發，用一年實戰經歷解釋這個新詞背後的真正含義——以及為什麼它比寫 code 本身更難。",
      "content_html": "> 這是「Agentic Engineering 實戰手冊」系列的第一篇。完整系列共 14 篇，從基礎認知到系統設計到團隊導入，一個做了一年 100% AI coding 的工程師的實戰全紀錄。\n\n## 2025 年我們在 Vibe Coding，2026 年呢？\n\n2025 年 2 月 2 日，Andrej Karpathy 在 X 上發了一條推文，說他最近寫 code 的方式完全變了——「完全交給 LLM，忘記程式語言的存在，只靠直覺和 vibes。」他把這叫做 **Vibe Coding**。\n\n那條推文爆了。整個 tech 圈都在討論。有人覺得這是解放，有人覺得這是墮落。但不管你站哪邊，Vibe Coding 確實描述了一個真實的現象：越來越多人開始「感覺對了就好」地用 AI 寫 code，不太在意細節，反正跑得動就行。\n\n一年後，2026 年 2 月 8 日，同一個 Karpathy 發了另一條推文。這次他說：不，Vibe Coding 不夠。我們需要一個新詞——**Agentic Engineering**。\n\n為什麼？因為過去這一年，整個產業發生了根本性的轉變。AI 不再只是你問它問題然後複製貼上答案的工具。它變成了 **agent**——能自己讀 code、自己寫 code、自己跑測試、自己修 bug 的自主行動者。而你，變成了管它的人。\n\n這個轉變需要的不是 vibes，是扎扎實實的工程。\n\n作為一個從 2025 年初就開始 100% agent-first coding 的人，我親身經歷了這一年的演化。從一開始覺得「哇這好方便」，到中間覺得「等等，它怎麼又寫錯了」，到現在理解「原來管 agent 是一門學問」——這篇文章想跟你分享的，就是這段旅程。\n\n## 從 Vibe Coding 到 Agentic Engineering：一年的演化\n\n如果要畫一個光譜，軟體開發的方式正在經歷這樣的演化：\n\n| 階段                    | 時期      | 你在做什麼                 | AI 在做什麼                          |\n| ----------------------- | --------- | -------------------------- | ------------------------------------ |\n| **Manual Coding**       | ~2022 前  | 自己寫每一行 code          | 不存在                               |\n| **AI-Assisted**         | 2022-2024 | 自己寫，AI 幫忙補完        | Tab completion、回答問題             |\n| **Vibe Coding**         | 2024-2025 | 描述你要什麼，AI 寫        | 產生大段 code，你複製貼上            |\n| **Agentic Engineering** | 2025-now  | 定義目標和限制，管理 agent | 自主讀 code、寫 code、跑測試、修 bug |\n\n關鍵的轉折點在 2025 年中。那時候 Claude Code、Cursor Agent Mode、Codex CLI 這些工具開始成熟，AI 不再只是「給你一段 code 讓你貼」，而是可以直接操作你的 codebase、跑你的 test suite、甚至自己 commit code。\n\n我記得很清楚第一次讓 Claude Code 自己修一個 bug 的感覺。我只說了「這個 API 在特定 payload 下回 500，error log 在這裡，請找到 root cause 並修好它」，然後它就開始自己讀 code、自己找問題、自己寫修復、自己跑測試。\n\n我去倒了杯咖啡回來，bug 修好了。\n\n那一刻，我意識到我的角色不一樣了。我不是在「寫 code」，我是在「管一個會寫 code 的 agent」。\n\n但 Vibe Coding 和 Agentic Engineering 的差別，不在工具，在心態。Vibe Coding 是「反正 AI 會搞定，我不需要太認真」。Agentic Engineering 是「正因為 AI 在搞定，我需要更認真地管理這個過程」。\n\n## Agentic Engineering 的定義：不只是「用 AI 寫 code」\n\nKarpathy 解釋這個詞的時候，把它拆成兩半：\n\n**Agentic**——因為你的預設模式不再是自己寫 code，而是讓 agent 去寫。你從 creator 變成了 orchestrator。\n\n**Engineering**——因為怎麼編排 agent、怎麼提供 context、怎麼驗證產出、怎麼管理品質，這裡面有系統性的學問。不是 vibes，是工程。\n\nGoogle 的 Addy Osmani 在他的 blog 裡把 Agentic Engineering 歸納為五個原則：\n\n1. **Plan before prompting**——先寫 spec，再讓 agent 動手\n2. **Direct with precision**——給 agent 明確的、有範圍的任務\n3. **Review rigorously**——像 review 人類的 PR 一樣 review agent 的產出\n4. **Test relentlessly**——測試是品質保證的底線，不是可選的\n5. **Own the system**——維護文件、版本控制、CI/CD，你是最終負責人\n\nDjango 的共同創辦人 Simon Willison 則整理了一份完整的 Agentic Engineering Patterns 指南，裡面最重要的一句話是：\n\n> 「與高品質軟體工程相關的技術——自動測試、linting、清晰的文件、CI/CD、乾淨的架構——恰好也是讓 coding agent 產出更好結果的東西。」\n\n講白了，好的工程習慣沒有被 AI 淘汰，反而被它放大了。\n\n那它跟其他術語到底差在哪？簡單整理：\n\n|              | Prompt Engineering | AI-Assisted Coding | Vibe Coding      | Agentic Engineering     |\n| ------------ | ------------------ | ------------------ | ---------------- | ----------------------- |\n| **核心活動** | 寫好的 prompt      | 用 AI 補完 code    | 描述需求讓 AI 寫 | 編排 agent 自主完成任務 |\n| **品質責任** | 你寫的 code        | 你寫的 code        | 你不太確定       | 你 review 的 code       |\n| **工程紀律** | 低                 | 中                 | 低               | 高                      |\n| **適用場景** | 問答、文字         | 日常 coding        | Prototype、demo  | Production-grade 開發   |\n\nAgentic Engineering 不是 Vibe Coding 的「升級版」。它跟 Vibe Coding 的關係，更像是 DevOps 跟「手動部署」的關係——本質上是不同的方法論，要求不同的技能，產出不同品質的結果。\n\n## 數據說話：現在到底多少人在用？\n\nAnthropic 在 2026 年 3 月發布的 Agentic Coding Trends Report 顯示，**開發者有 60% 的工作涉及 AI**，而且對於委派給 agent 的任務，工程師在 **80-100% 的情況下仍然保持監督**。\n\nJetBrains 在 2026 年 1 月的調查（11,000 名開發者參與）更直接：**90% 的受訪者在工作中使用 AI**。\n\n聽起來很普及。但這裡有個耐人尋味的數字：開發者對 AI 準確度的信任，從去年的 40% 掉到了今年的 29%。\n\n八成的人在用，真正信得過的卻不到三成。乍看矛盾，其實這正是成熟的訊號。\n\n一年前，大家對 AI coding 充滿新鮮感，覺得「哇它好厲害」。一年後，大家都被 agent 坑過了——它自信滿滿地寫出不存在的 API、過時的語法、看起來對但其實錯的邏輯。信任度下降，不是因為 AI 變差了，而是因為大家更了解它的極限了。\n\n還有一個數據很值得注意。SWE-bench 是目前最被認可的 AI coding benchmark：\n\n- **SWE-bench Verified**（經過人工驗證的測試集）：最好的 model 可以拿到 **~72%**\n- **SWE-bench Pro**（更嚴格、更貼近真實世界的版本）：最好的 model 只拿到 **~23%**\n\n72% 跟 23% 的差距，就是「demo 很厲害」跟「真實世界很難」之間的距離。\n\nGartner 更直接預測：**40% 的 agentic AI 部署會在 2027 年前被取消**，原因是成本上升、價值不明確、或風控不足。\n\n這些數據告訴我們什麼？Agent 很強大，但不是魔法。用好它，需要方法論。這就是 Agentic Engineering 存在的意義。\n\n## 為什麼 Agentic Engineering 比傳統寫 Code 更難\n\n這是 Agentic Engineering 最反直覺的地方：用 AI 幫你寫 code，居然比自己寫還需要更高的工程紀律。\n\n原因藏在 context 裡。**傳統寫 code 的時候**，你可以在腦子裡 hold 住很多隱性的 context——你知道公司用什麼 tech stack、你知道這個 API 的 quirks、你知道上次那個 bug 是怎麼修的。這些資訊不需要寫下來，因為它在你腦子裡。\n\n**Agent 寫 code 的時候**，它什麼都不知道。它只知道你告訴它的東西。如果你沒有把 coding conventions 寫在 CLAUDE.md 裡，它就會自己發明一套。如果你沒有寫測試，它就不知道自己寫錯了。如果你沒有 CI/CD pipeline，你就得自己一行一行看它寫的 code。\n\n所以 Osmani 說的 paradox 是真的：\n\n> Agent 越自主，你的工程基礎設施就需要越完善。\n\n具體來說：\n\n- **沒有自動測試？** Agent 不知道它寫的 code 是不是對的。你也不知道。\n- **沒有 linting/formatting？** Agent 每次寫出來的 code style 都不一樣。\n- **沒有清楚的文件？** Agent 會自己猜你的架構意圖，通常猜錯。\n- **沒有 CI/CD？** 你得手動驗證每一次 agent 的產出。\n\n在傳統開發裡，這些東西是 nice-to-have——有當然好，沒有也活得下去。在 Agentic Engineering 裡，它們是 **prerequisites**——沒有這些，agent 根本沒辦法正常工作。\n\n但反過來想，這其實是好消息。\n\n如果你是那種一直堅持寫測試、維護文件、設定 CI/CD 的「無聊工程師」，恭喜你——你在 agent 時代突然變成最有價值的人了。因為你的工程基礎設施不只讓人類團隊工作得更順暢，現在還讓 AI agent 工作得更好。\n\n你花在「boring engineering」上的每一分鐘，在 agent 時代的 ROI 都翻倍了。\n\n## 這個系列要帶你去哪裡\n\n這是一個 14 篇的系列，分成四個 Part：\n\n**Part I：基礎認知（你在這裡）**\n\n1. Agentic Engineering 是什麼（本篇）\n2. [從「寫 code 的人」到「管 agent 的人」](/blog/agentic-engineering-mindset-shift)——工程師角色的重新定義\n3. [2026 年工具全景圖](/blog/agentic-engineering-tools-landscape-2026)——Cursor、Claude Code、Codex、Devin 的深度比較\n\n**Part II：核心技術**\n\n4. [Context Engineering 深度解析](/blog/context-engineering-deep-dive)——你餵給 agent 的 context 決定一切\n5. [Spec-Driven Development](/blog/spec-driven-development-for-agents)——寫給 agent 的需求文件\n6. [Agent 產出品質保證](/blog/agent-output-verification-review)——怎麼知道 agent 寫的 code 是對的\n7. [從 Prompt 到 Production](/blog/agentic-engineering-daily-workflow-advanced)——完整實戰紀錄\n8. [CLAUDE.md 大師班](/blog/claude-md-rules-files-masterclass)——設定檔系統設計\n\n**Part III：系統設計**\n\n9. [MCP 與 A2A 協議實戰](/blog/mcp-a2a-protocols-practitioner-guide)——讓 agent 連接更大的世界\n10. [Multi-Agent 編排](/blog/multi-agent-orchestration-real-world)——多 agent 協作的實務\n11. [Token 經濟學進階](/blog/agentic-engineering-cost-optimization)——成本控制\n12. [Agent 安全網設計](/blog/agentic-engineering-testing-safety)——當 AI 有 sudo 權限\n\n**Part IV：組織與未來**\n\n13. [團隊導入](/blog/agentic-engineering-team-adoption)——從個人到團隊的文化轉變\n14. [未來展望](/blog/agentic-engineering-future-and-you)——工程師還需要寫 code 嗎？\n\n每一篇都可以獨立閱讀，但它們也是一本書的章節——從「這是什麼」到「怎麼做」到「怎麼做好」到「怎麼帶團隊一起做」。\n\n如果你已經讀過我之前寫的 Agent First 日常，那篇是我個人經歷的描述。這個系列則是方法論的整理——我把一年的經驗，提煉成一套可以複製的框架。\n\n## Takeaway\n\n1. **Agentic Engineering 不是 Vibe Coding 的升級版**，而是一個要求更高工程紀律的新學科。Karpathy 用這個詞取代 Vibe Coding，是因為「靠直覺」的方式在 production 環境裡行不通。\n\n2. **80% 的開發者在用 AI agent，但只有 29% 信任它**。這個信任缺口不會靠「更好的 model」來填補，而是靠更好的方法論——Context Engineering、Spec-Driven Development、品質保證流程。\n\n3. **好消息是：紮實的工程基礎在 agent 時代比以前更有價值**。自動測試、CI/CD、清楚的文件——這些「無聊」的工程實踐，現在不只服務人類團隊，也是 agent 能否正常工作的前提。\n\n---\n\n_下一篇：[從「寫 code 的人」到「管 agent 的人」：工程師的角色重新定義](/blog/agentic-engineering-mindset-shift)_",
      "summary": "2026 年 2 月 Karpathy 提出 Agentic Engineering，但它跟 Vibe Coding、Prompt Engineering 差在哪？從定義出發，用一年實戰經歷解釋這個新詞背後的真正含義——以及為什麼它比寫 code 本身更難。",
      "image": "https://bobochen.dev/_astro/cover.2PIsQr9X.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "Agentic Engineering",
        "AI",
        "Karpathy",
        "軟體工程",
        "2026趨勢"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/brewfile-beginner-guide-mac-dev-setup/",
      "url": "https://bobochen.dev/blog/brewfile-beginner-guide-mac-dev-setup/",
      "title": "從零開始的 Mac 開發環境：Brewfile 完全指南",
      "content_text": "什麼是 Brewfile？怎麼建第一份？哪些工具每個開發者都該裝？一篇搞懂 Homebrew 的套件清單管理，換電腦再也不用一個一個手動安裝。",
      "content_html": "如果你剛拿到一台新 Mac，或者想把開發環境「版本控制」起來，Brewfile 是你該認識的第一個工具。\n\n## Homebrew 是什麼？\n\n[Homebrew](https://github.com/Homebrew/brew) 是 macOS 上最主流的套件管理工具，讓你用一行指令安裝 CLI 工具、桌面應用程式、甚至 VS Code extensions。\n\n安裝 Homebrew：\n\n```bash\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n```\n\n裝完之後，安裝軟體就是一行指令的事：\n\n```bash\nbrew install git        # CLI 工具\nbrew install --cask firefox  # 桌面 App\n```\n\n## Brewfile 是什麼？\n\nBrewfile 就是一份「軟體清單」，把你所有用 Homebrew 安裝的東西記錄在一個檔案裡。換電腦時，一行指令就能全部裝回來。\n\n你可以把它想成「開發環境的 package.json」。\n\n## 3 分鐘快速上手\n\n### Step 1：產生你的第一份 Brewfile\n\n```bash\nbrew bundle dump --file=~/Brewfile\n```\n\n這會掃描你目前已安裝的所有 Homebrew 套件，自動產生一份 Brewfile。\n\n### Step 2：看看裡面有什麼\n\n```bash\ncat ~/Brewfile\n```\n\n你會看到四種類型的項目：\n\n```ruby\ntap \"homebrew/cask\"          # 第三方套件來源\nbrew \"git\"                   # CLI 工具\ncask \"firefox\"               # 桌面 App\nvscode \"dbaeumer.vscode-eslint\"  # VS Code 擴充（可選）\n```\n\n### Step 3：在新電腦上還原\n\n把 Brewfile 複製到新電腦，然後：\n\n```bash\nbrew bundle install --file=~/Brewfile\n```\n\n就這樣，所有工具一次裝完。\n\n## 我推薦每個開發者都該有的基礎工具\n\n以下是我自己 Brewfile 裡最常用的工具，依用途分類：\n\n### Git 相關\n\n```ruby\nbrew \"git\"          # 版本控制\nbrew \"gh\"           # GitHub CLI，PR、issue 都在終端搞定\nbrew \"lazygit\"      # Git 的 TUI 介面，超直覺\n```\n\n### 終端效率\n\n```ruby\nbrew \"fzf\"          # 模糊搜尋，找檔案、切分支都靠它\nbrew \"ripgrep\"      # 比 grep 快 10 倍的搜尋\nbrew \"zoxide\"       # 智慧 cd，記住你常去的目錄\nbrew \"starship\"     # 好看又實用的 shell prompt\nbrew \"tmux\"         # 終端分割、session 管理\n```\n\n### 開發工具\n\n```ruby\nbrew \"fnm\"          # Node.js 版本管理（比 nvm 快）\nbrew \"jq\"           # JSON 處理\nbrew \"direnv\"       # 自動載入 .envrc 環境變數\nbrew \"just\"         # 比 Makefile 好讀的 task runner\n```\n\n### 桌面 App\n\n```ruby\ncask \"visual-studio-code\"    # 編輯器\ncask \"iterm2\"                # 終端機\ncask \"rectangle\"             # 視窗管理\ncask \"alfred\"                # 啟動器\n```\n\n## 日常維護指令\n\n```bash\n# 產生/更新 Brewfile（加 --force 覆寫舊檔）\nbrew bundle dump --force --file=~/Brewfile\n\n# 安裝 Brewfile 裡的所有套件\nbrew bundle install --file=~/Brewfile\n\n# 檢查哪些已安裝但不在 Brewfile 裡\nbrew bundle cleanup --file=~/Brewfile\n\n# 更新所有套件\nbrew update && brew upgrade\n```\n\n## 一個小提醒\n\n`brew bundle dump` 預設會把 VS Code extensions 也列進去。如果你已經開了 VS Code 的 Settings Sync，這些行就是多餘的，加 `--no-vscode` 跳過：\n\n```bash\nbrew bundle dump --force --no-vscode --file=~/Brewfile\n```\n\n這部分我在[另一篇文章](/blog/brewfile-vscode-cleanup-settings-sync)有詳細說明。\n\n## 下一步\n\nBrewfile 搞定之後，你可能會想：那 `.zshrc`、`.gitconfig` 這些 dotfiles 怎麼辦？推薦搭配 chezmoi 一起使用，把整個開發環境都版本控制起來。這部分我在系列的下一篇會詳細介紹。",
      "summary": "什麼是 Brewfile？怎麼建第一份？哪些工具每個開發者都該裝？一篇搞懂 Homebrew 的套件清單管理，換電腦再也不用一個一個手動安裝。",
      "image": "https://bobochen.dev/_astro/cover.Bo3go1O6.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "Homebrew",
        "macOS",
        "開發環境",
        "入門",
        "效率"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/brewfile-chezmoi-dotfiles-backup-strategy/",
      "url": "https://bobochen.dev/blog/brewfile-chezmoi-dotfiles-backup-strategy/",
      "title": "Brewfile + chezmoi + dotfiles：Mac 開發環境的完整備份策略",
      "content_text": "Brewfile 管軟體、chezmoi 管 dotfiles、VS Code Settings Sync 管編輯器——三個工具各司其職，讓你換電腦只需要四個步驟就能還原整個開發環境。",
      "content_html": "Brewfile 解決了「裝什麼軟體」，但你的 `.zshrc`、`.gitconfig`、SSH key 呢？這篇聊聊怎麼用 Brewfile + [chezmoi](https://github.com/twpayne/chezmoi) 把整個開發環境版本控制起來，以及每個工具的職責邊界在哪裡。\n\n## 問題：你的開發環境有幾層？\n\n一個完整的開發環境其實有三層：\n\n| 層級       | 內容                               | 負責工具              |\n| ---------- | ---------------------------------- | --------------------- |\n| 軟體安裝   | CLI 工具、桌面 App                 | Homebrew (Brewfile)   |\n| 系統設定   | dotfiles、shell config、Git config | chezmoi               |\n| 編輯器狀態 | Extensions、Settings、Keybindings  | VS Code Settings Sync |\n\n很多人（包括以前的我）把所有東西塞在同一個備份機制裡，結果就是職責不清、維護困難。\n\n## 各工具的職責邊界\n\n### Homebrew / Brewfile — 只管「裝什麼」\n\n```bash\nbrew bundle dump --force --no-vscode --file=~/Brewfile\n```\n\nBrewfile 只負責記錄軟體清單，不管設定。加 `--no-vscode` 是因為 VS Code extensions 有自己的同步機制。\n\n### chezmoi — 只管「設定檔」\n\n[chezmoi](https://github.com/twpayne/chezmoi) 是一個 dotfiles 管理工具，它不直接在 `$HOME` 建 symlink，而是用一個獨立的 source directory 管理，透過 `chezmoi apply` 把設定檔「部署」到正確位置。\n\n#### 3 分鐘快速上手\n\n```bash\n# 安裝\nbrew install chezmoi\n\n# 初始化（會建立 ~/.local/share/chezmoi）\nchezmoi init\n\n# 把現有 dotfile 加入管理\nchezmoi add ~/.zshrc\nchezmoi add ~/.gitconfig\nchezmoi add ~/.config/starship.toml\n\n# 看看 chezmoi 管了哪些檔案\nchezmoi managed\n\n# 編輯某個設定檔\nchezmoi edit ~/.zshrc\n\n# 看差異\nchezmoi diff\n\n# 套用變更到 $HOME\nchezmoi apply\n```\n\n核心觀念：你永遠在 chezmoi 的 source directory 裡編輯，然後 `apply` 到 `$HOME`。這樣 source directory 就是一個乾淨的 Git repo，可以推到 GitHub。\n\n### VS Code Settings Sync — 只管「編輯器」\n\n登入 GitHub 帳號就自動同步 extensions、settings、keybindings、snippets。不需要任何額外設定。\n\n## 三者的協作流程\n\n### 初次設定（在你的主力機器上）\n\n```bash\n# 1. 產生 Brewfile\nbrew bundle dump --force --no-vscode --file=~/Brewfile\n\n# 2. 初始化 chezmoi 並加入 dotfiles\nchezmoi init\nchezmoi add ~/.zshrc\nchezmoi add ~/.gitconfig\nchezmoi add ~/.config/starship.toml\n# ...加入你需要的設定檔\n\n# 3. 把 Brewfile 也交給 chezmoi 管理\ncp ~/Brewfile $(chezmoi source-path)/\nchezmoi add ~/Brewfile\n\n# 4. 推到 GitHub\ncd $(chezmoi source-path)\ngit remote add origin git@github.com:yourname/dotfiles.git\ngit add -A && git commit -m \"initial dotfiles + Brewfile\"\ngit push -u origin main\n\n# 5. VS Code Settings Sync — 確認已開啟就好\n```\n\n### 換到新電腦時\n\n```bash\n# 1. 裝 Homebrew\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n\n# 2. 裝 chezmoi 並從 GitHub 拉下設定\nbrew install chezmoi\nchezmoi init --apply yourname\n\n# 3. 用 Brewfile 安裝所有軟體\nbrew bundle install --file=$(chezmoi source-path)/Brewfile\n\n# 4. 開 VS Code → 登入 GitHub → Settings Sync 自動還原\n```\n\n四個步驟，整個開發環境就回來了。\n\n## chezmoi 的進階功能\n\nchezmoi 不只是 symlink 工具，它還支援：\n\n### 模板（Templates）— 不同機器用不同設定\n\n```bash\n# 在 .zshrc.tmpl 裡用條件判斷\n{{ if eq .chezmoi.hostname \"work-mac\" }}\nexport KUBECONFIG=~/.kube/config-work\n{{ else }}\nexport KUBECONFIG=~/.kube/config-personal\n{{ end }}\n```\n\n### Secrets 管理 — 敏感資訊不進 Git\n\n```bash\n# 搭配 1Password CLI 或 macOS Keychain\nchezmoi add --template ~/.ssh/config\n# 在模板裡引用 Keychain\n{{ keyring \"ssh-key-passphrase\" \"my-account\" }}\n```\n\n### 自動化腳本 — 安裝後自動執行\n\n```bash\n# 建立 run_once_install.sh，chezmoi apply 時自動跑一次\n#!/bin/bash\n# 設定 macOS 預設值\ndefaults write com.apple.dock autohide -bool true\ndefaults write com.apple.finder AppleShowAllFiles -bool true\nkillall Dock Finder\n```\n\n## 什麼不該放進 chezmoi\n\n- **大型二進位檔**：放 Git LFS 或雲端\n- **機密資訊**：用 template + keyring，不要明文\n- **暫時性設定**：`.env.local` 這種專案層級的不用管\n- **IDE workspace 設定**：VS Code Settings Sync 管就好\n\n## 反思：為什麼職責分離很重要\n\n以前我用一個巨大的 setup script 做所有事，三百多行的 `setup.sh`，安裝軟體、複製設定檔、設 macOS defaults，全塞在一起。\n\n問題是：每次只想改一個 `.zshrc` 設定，都要跑整個 script，還怕改壞其他東西。\n\n拆成三個工具之後，每個工具只管一件事：\n\n- 想加新軟體 → 改 Brewfile，跑 `brew bundle`\n- 想改 shell 設定 → `chezmoi edit ~/.zshrc`，跑 `chezmoi apply`\n- 想裝新 VS Code extension → 直接在 VS Code 裝，Settings Sync 自動處理\n\n**改動範圍小 = 信心大 = 更敢改。**",
      "summary": "Brewfile 管軟體、chezmoi 管 dotfiles、VS Code Settings Sync 管編輯器——三個工具各司其職，讓你換電腦只需要四個步驟就能還原整個開發環境。",
      "image": "https://bobochen.dev/_astro/cover.DACx7mmR.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "chezmoi",
        "dotfiles",
        "Homebrew",
        "macOS",
        "開發環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/brewfile-vscode-cleanup-settings-sync/",
      "url": "https://bobochen.dev/blog/brewfile-vscode-cleanup-settings-sync/",
      "title": "你的 Brewfile 是不是也在做白工？我清掉了 76 行才發現的事",
      "content_text": "整理 Brewfile 時發現 76 行在備份 VS Code Extensions，但 VS Code Settings Sync 早就在處理了。記錄確認方式、清理指令，以及「兩個工具做同一件事」這個維護陷阱。",
      "content_html": "每隔一段時間我會整理開發環境，這次翻出 Brewfile 來瘦身，結果發現一個蠻傻的問題：**我的 Brewfile 一直在備份 VS Code Extensions，但 VS Code 自己早就有 Settings Sync 了。**\n\n## 事情是怎麼發現的\n\n我用 `brew bundle dump` 產生 Brewfile 備份開發環境，檔案有 202 行，其中 76 行長這樣：\n\n```\nvscode \"dbaeumer.vscode-eslint\"\nvscode \"esbenp.prettier-vscode\"\nvscode \"astro-build.astro-vscode\"\n...（再 73 行）\n```\n\n看到這一大坨就想：等等，這些不是 VS Code 登入 GitHub 帳號就會自動同步嗎？\n\n## 確認 Settings Sync 有沒有在運作\n\n其實很簡單，看這個資料夾有沒有內容：\n\n```bash\nls ~/Library/Application\\ Support/Code/User/sync/\n```\n\n如果裡面有 `extensions`、`settings`、`keybindings` 這些子目錄，就代表 Settings Sync 正在運作。如果是空的，去 VS Code 按 `Cmd+Shift+P` 搜尋 `Settings Sync: Turn On` 開啟就好。\n\n## 清理方式\n\n確認 Settings Sync 有在跑之後，一行指令清掉所有 `vscode` 行：\n\n```bash\ngrep -v '^vscode' Brewfile > Brewfile.tmp && mv Brewfile.tmp Brewfile\n```\n\n之後每次 dump 記得加 `--no-vscode`：\n\n```bash\nbrew bundle dump --force --no-vscode\n```\n\n## 結果\n\nBrewfile 從 202 行瘦到 117 行，少了 38%。不是什麼驚天動地的優化，但重點是：**這 76 行從來沒有發揮過作用**——我每次換電腦都是靠 VS Code Settings Sync 還原 extensions，Brewfile 裡的 `vscode` 行根本沒人理。\n\n## 反思：工具的職責要分清楚\n\n這件事的本質是**兩個工具在做同一件事，但你只信任其中一個**。\n\nHomebrew 的職責是管理系統層級的軟體安裝（CLI 工具、app、服務），VS Code Settings Sync 的職責是管理編輯器的狀態（extensions、設定、快捷鍵）。`brew bundle dump` 預設會把 VS Code extensions 也撈進來，看起來很貼心，但實際上這份清單永遠不會是你真正用來還原 extensions 的來源。\n\n這讓我想到一個更廣的原則：**備份和同步不是越多越好，重複備份只會製造維護負擔和錯誤的安全感**。\n\n另外順便整理了 Brewfile 裡其他用不到的工具（`hugo`、`cocoapods`、`php` 等），但那是另一個故事了。",
      "summary": "整理 Brewfile 時發現 76 行在備份 VS Code Extensions，但 VS Code Settings Sync 早就在處理了。記錄確認方式、清理指令，以及「兩個工具做同一件事」這個維護陷阱。",
      "image": "https://bobochen.dev/_astro/cover.BKSgCMJt.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "Homebrew",
        "VSCode",
        "開發環境",
        "macOS",
        "效率"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/chezmoi-dotfiles-multi-machine-setup/",
      "url": "https://bobochen.dev/blog/chezmoi-dotfiles-multi-machine-setup/",
      "title": "chezmoi 實戰：一份 dotfiles 管理三台不同用途的 Mac",
      "content_text": "用 chezmoi 管理多台 Mac 的差異化 dotfiles：模板語法、age 加密、多機同步工作流程，以及實際踩過的坑。",
      "content_html": "你已經有一份 dotfiles repo 了，用 symlink 或 [GNU Stow](https://www.gnu.org/software/stow/) 管著 `.zshrc`、`.gitconfig` 那些檔案。在一台電腦上，這套做法沒什麼問題。\n\n但第二台電腦出現的時候，問題就來了。\n\n## 手動 symlink 在多台電腦的崩潰時刻\n\n我有三台 Mac：\n\n| 機器            | 用途                  | 差異                                 |\n| --------------- | --------------------- | ------------------------------------ |\n| MacBook Pro 14\" | 公司開發機            | 公司 email、VPN 設定、內部工具 PATH  |\n| MacBook Air M3  | 個人 side project     | 個人 email、部落格相關工具           |\n| Mac mini        | 家裡的 homelab server | 沒有 GUI app、多了 Docker 相關 alias |\n\n三台機器 90% 的設定是一樣的（zsh 習慣、vim 設定、git alias），但那 10% 的差異讓我痛苦不堪：\n\n- **`.gitconfig` 的 email 不一樣**：公司的要用 `bobo@company.com`，個人的用 `bobo@gmail.com`\n- **`.zshrc` 的 PATH 不一樣**：公司電腦有 `/opt/internal-tools/bin`，個人電腦有 `~/.local/bin`\n- **alias 不一樣**：homelab 多了一堆 Docker 快捷指令，工作機有 `kubectl` 相關 alias\n- **有些設定檔根本不該出現在某些機器上**：公司的 VPN config 不該存在個人電腦\n\n用 symlink 的話怎麼辦？開 branch？太累了。用 `if` 判斷 hostname？散落在各檔案裡，維護是地獄。\n\n這就是 [chezmoi](https://github.com/twpayne/chezmoi) 要解決的問題。\n\n## chezmoi 的核心概念\n\nchezmoi 的設計哲學很簡單：**你的 dotfiles 是「原始碼」，你的 home 目錄是「編譯結果」。**\n\n這兩個概念很重要：\n\n- **Source state**（原始碼）：存在 `~/.local/share/chezmoi/` 裡，就是你的 dotfiles repo。檔案可以是模板、加密檔、腳本。\n- **Destination state**（編譯結果）：就是你的 `$HOME`，也就是實際被使用的設定檔。\n\nchezmoi 做的事情就是把 source state **編譯**成 destination state。中間經過模板渲染、解密、條件判斷，最後才寫入你的 home 目錄。\n\n> [!NOTE]\n> 順帶一提：`chezmoi` 念作 `[ʃɛ mwa]`（近「雪・ㄇㄨㄚ」），是法文 **chez moi「我家」**。難怪它的 destination state 就是你的 `$HOME`——工具名字本來就是「我家」。👉 [名字的由來與發音](/blog/chezmoi-name-pronunciation-chez-moi/)\n\n這跟 symlink 最大的不同是：**實際的設定檔不是 symlink，而是真正的檔案。** 所以即使 chezmoi 壞了、你把 repo 刪了，你的設定檔還是好好的在那裡。\n\n## 安裝與初始化\n\n```bash\n# 安裝\nbrew install chezmoi\n\n# 初始化（會在 ~/.local/share/chezmoi 建立 git repo）\nchezmoi init\n\n# 如果你已經有 dotfiles repo\nchezmoi init https://github.com/your-username/dotfiles.git\n```\n\n把現有的設定檔加入管理：\n\n```bash\n# 加入 .zshrc\nchezmoi add ~/.zshrc\n\n# 加入 .gitconfig\nchezmoi add ~/.gitconfig\n\n# 加入整個 .config/starship.toml\nchezmoi add ~/.config/starship.toml\n```\n\n加入之後，chezmoi 會把檔案複製一份到 source 目錄。你可以用 `chezmoi cd` 跳進去看：\n\n```bash\nchezmoi cd\nls -la\n# 你會看到 dot_zshrc, dot_gitconfig 這些檔案\n# chezmoi 用前綴來表示檔案屬性：dot_ = 以 . 開頭\n```\n\n## 模板語法實戰\n\n這是 chezmoi 最強的地方。你可以把設定檔變成 Go template，根據每台機器的變數產生不同的內容。\n\n### 第一步：設定每台機器的變數\n\n編輯 `~/.config/chezmoi/chezmoi.toml`（每台機器各自設定，不進 repo）：\n\n```toml\n# MacBook Pro（公司機）\n[data]\n  machine_type = \"work\"\n  email = \"bobo@company.com\"\n  git_signing_key = \"ABCDEF1234567890\"\n\n# MacBook Air（個人機）\n[data]\n  machine_type = \"personal\"\n  email = \"bobo@gmail.com\"\n  git_signing_key = \"1234567890ABCDEF\"\n\n# Mac mini（homelab）\n[data]\n  machine_type = \"homelab\"\n  email = \"bobo@gmail.com\"\n```\n\n你可以用 `chezmoi data` 確認目前機器的變數：\n\n```bash\nchezmoi data\n# {\n#   \"machine_type\": \"work\",\n#   \"email\": \"bobo@company.com\",\n#   ...\n# }\n```\n\n### 實戰一：.gitconfig 根據機器填入不同 email\n\n先把 `.gitconfig` 轉成模板：\n\n```bash\nchezmoi add --template ~/.gitconfig\n```\n\n然後編輯模板：\n\n```bash\nchezmoi edit ~/.gitconfig\n```\n\n模板內容：\n\n```toml\n[user]\n  name = Bobo Chen\n  email = {{ .email }}\n{{ if .git_signing_key }}\n  signingkey = {{ .git_signing_key }}\n{{ end }}\n\n[commit]\n{{ if .git_signing_key }}\n  gpgsign = true\n{{ end }}\n\n[core]\n  editor = vim\n  autocrlf = input\n\n[alias]\n  st = status\n  co = checkout\n  br = branch\n  lg = log --oneline --graph --all\n  unstage = reset HEAD --\n\n[pull]\n  rebase = true\n\n{{ if eq .machine_type \"work\" }}\n[url \"git@github-work:\"]\n  insteadOf = https://github.com/company-org/\n{{ end }}\n```\n\n在公司電腦上，`chezmoi apply` 之後產出的 `.gitconfig` 會有公司 email 和 signing key；在個人電腦上就是個人 email。同一份模板，不同結果。\n\n### 實戰二：.zshrc 根據機器載入不同的 PATH 和 alias\n\n```bash\nchezmoi add --template ~/.zshrc\nchezmoi edit ~/.zshrc\n```\n\n```bash\n# === 共用區塊 ===\nexport EDITOR=\"vim\"\nexport LANG=\"en_US.UTF-8\"\n\n# Homebrew\neval \"$(/opt/homebrew/bin/brew shellenv)\"\n\n# 共用 PATH\nexport PATH=\"$HOME/.local/bin:$PATH\"\n\n# 共用 alias\nalias ll=\"ls -la\"\nalias g=\"git\"\nalias gs=\"git status\"\nalias gp=\"git push\"\n\n{{ if eq .machine_type \"work\" -}}\n# === 公司專用 ===\nexport PATH=\"/opt/internal-tools/bin:$PATH\"\nexport KUBECONFIG=\"$HOME/.kube/company-config\"\nalias k=\"kubectl\"\nalias kns=\"kubectl config set-context --current --namespace\"\nalias vpn-up=\"sudo openconnect --config=$HOME/.vpn/company.conf\"\n{{ end -}}\n\n{{ if eq .machine_type \"personal\" -}}\n# === 個人專用 ===\nexport PATH=\"$HOME/.cargo/bin:$PATH\"\nalias blog=\"cd ~/Desktop/github/bobo-blog-2026 && npm run dev\"\nalias deploy=\"cd ~/Desktop/github/bobo-blog-2026 && npm run build\"\n{{ end -}}\n\n{{ if eq .machine_type \"homelab\" -}}\n# === Homelab 專用 ===\nalias dc=\"docker compose\"\nalias dps=\"docker ps --format 'table {{`{{`}}.Names{{`}}`}}\\t{{`{{`}}.Status{{`}}`}}\\t{{`{{`}}.Ports{{`}}`}}'\"\nalias dlogs=\"docker logs -f\"\nalias dprune=\"docker system prune -af\"\n{{ end -}}\n\n# 共用的 zsh 設定\nautoload -Uz compinit && compinit\n```\n\n注意模板語法裡 `{{-` 後面的 `-` 是為了去掉多餘的空行。這是 Go template 的小技巧。\n\n### 實戰三：用 chezmoi init 互動式設定變數\n\n如果你不想手動寫 `chezmoi.toml`，可以用 `.chezmoidata.toml` 搭配 `init` 的 prompt 功能。在 source 目錄建立 `.chezmoi.toml.tmpl`：\n\n```toml\n[data]\n  machine_type = {{ promptStringOnce . \"machine_type\" \"Machine type (work/personal/homelab)\" | quote }}\n  email = {{ promptStringOnce . \"email\" \"Git email address\" | quote }}\n```\n\n這樣在新機器上跑 `chezmoi init` 時，它會互動式問你這些問題，然後自動產生 `chezmoi.toml`。\n\n## 加密敏感檔案\n\ndotfiles 裡總有些敏感的東西：SSH config 裡的 host 資訊、`.env` 裡的 API key。這些你不想明文推上 GitHub。\n\nchezmoi 內建支援 [age](https://github.com/FiloSottile/age) 加密（也支援 gpg，但 age 更簡單）。\n\n### 設定 age 加密\n\n```bash\n# 安裝 age\nbrew install age\n\n# 產生 key pair\nage-keygen -o ~/.config/chezmoi/key.txt\n# 它會輸出 public key，記下來\n\n# Public key: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n在 `~/.config/chezmoi/chezmoi.toml` 加上：\n\n```toml\nencryption = \"age\"\n\n[age]\n  identity = \"~/.config/chezmoi/key.txt\"\n  recipient = \"age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n```\n\n### 加密 SSH config\n\n```bash\n# 把 SSH config 加入 chezmoi 並加密\nchezmoi add --encrypt ~/.ssh/config\n```\n\n加密後的檔案在 source 目錄裡會是 `private_dot_ssh/encrypted_private_config.age`，看到的是亂碼。推上 GitHub 也安全。\n\n在另一台電腦上，只要你有同一把 `key.txt`（可以透過 1Password 或 iCloud Keychain 安全地搬移），`chezmoi apply` 會自動解密。\n\n### 我通常會加密的檔案\n\n```bash\nchezmoi add --encrypt ~/.ssh/config\nchezmoi add --encrypt ~/.config/gh/hosts.yml    # GitHub CLI token\nchezmoi add --encrypt ~/.npmrc                  # npm registry token\n```\n\n## 多機器工作流程\n\n日常操作其實就三個步驟，非常順暢：\n\n### 在 A 電腦改設定\n\n```bash\n# 1. 編輯設定檔（chezmoi 會開你的 $EDITOR）\nchezmoi edit ~/.zshrc\n\n# 2. 預覽差異（不會真的套用）\nchezmoi diff\n\n# 3. 確認沒問題，套用到 home 目錄\nchezmoi apply\n\n# 4. 推上 repo\nchezmoi cd\ngit add -A\ngit commit -m \"feat: add docker aliases for homelab\"\ngit push\n```\n\n### 在 B 電腦同步\n\n```bash\n# 一行搞定：git pull + apply\nchezmoi update\n```\n\n就這樣。`chezmoi update` 會自動 `git pull` 然後重新套用所有設定。因為每台機器有自己的 `chezmoi.toml`，模板會根據當地變數產出正確的內容。\n\n### 如果你想先看看會改什麼\n\n```bash\n# 只 pull，不 apply\nchezmoi git pull\n\n# 看看差異\nchezmoi diff\n\n# 確認後再 apply\nchezmoi apply\n```\n\n## 常見踩坑\n\n### 踩坑一：忘記 chezmoi add 新檔案\n\n這是最常犯的錯。你直接編輯了 `~/.zshrc`，改完很開心，結果下次 `chezmoi apply` 又被覆蓋回去。\n\n**正確做法**：永遠用 `chezmoi edit` 編輯，或者改完之後用 `chezmoi re-add` 把變更收回 source：\n\n```bash\n# 手動改了 ~/.zshrc 之後\nchezmoi re-add ~/.zshrc\n# 這會把 destination 的變更反向同步回 source\n```\n\n也可以用 `chezmoi merge` 做三方合併，但大多時候 `re-add` 就夠了。\n\n### 踩坑二：模板語法錯誤\n\n模板寫錯的時候，`chezmoi apply` 會直接噴錯，但錯誤訊息有時不太直觀。\n\n**debug 技巧**：用 `chezmoi execute-template` 測試模板片段：\n\n```bash\n# 測試一段模板語法\necho '{{ if eq .machine_type \"work\" }}work mode{{ else }}personal mode{{ end }}' | chezmoi execute-template\n\n# 測試整個檔案\nchezmoi execute-template < ~/.local/share/chezmoi/dot_zshrc.tmpl\n\n# 查看某個檔案 apply 後的結果（不寫入）\nchezmoi cat ~/.zshrc\n```\n\n常見的模板錯誤：\n\n```bash\n# 錯：忘記 end\n{{ if eq .machine_type \"work\" }}\nsomething\n# 對：\n{{ if eq .machine_type \"work\" }}\nsomething\n{{ end }}\n\n# 錯：字串沒加引號\n{{ if eq .machine_type work }}\n# 對：\n{{ if eq .machine_type \"work\" }}\n```\n\n### 踩坑三：macOS 更新後 defaults 被重設\n\nmacOS 大版本更新有時會重設一些系統偏好設定。這不是 chezmoi 的問題，但你可以用 chezmoi 的 `run_` script 來處理。\n\n在 source 目錄建立 `run_onchange_macos-defaults.sh.tmpl`：\n\n```bash\n#!/bin/bash\n# chezmoi:template:hash\n# macOS defaults — 每次內容變更時重新執行\n\n# Dock\ndefaults write com.apple.dock autohide -bool true\ndefaults write com.apple.dock tilesize -int 48\n\n# Finder\ndefaults write com.apple.finder AppleShowAllFiles -bool true\ndefaults write com.apple.finder ShowPathbar -bool true\n\n{{ if eq .machine_type \"work\" }}\n# 公司電腦額外設定\ndefaults write com.apple.screensaver askForPassword -int 1\ndefaults write com.apple.screensaver askForPasswordDelay -int 0\n{{ end }}\n\nkillall Dock Finder\n```\n\n`run_onchange_` 前綴表示只有腳本內容變更時才重新執行。加上 `chezmoi:template:hash` 確保模板渲染後的結果被 hash，而不是模板本身。\n\n## 我的 chezmoi 目錄結構\n\n```\n~/.local/share/chezmoi/\n├── .chezmoi.toml.tmpl              # init 時的互動問答\n├── .chezmoiignore                  # 忽略規則（可用模板！）\n├── dot_zshrc.tmpl                  # .zshrc 模板\n├── dot_gitconfig.tmpl              # .gitconfig 模板\n├── dot_vimrc                       # .vimrc（不需要模板，三台都一樣）\n├── private_dot_ssh/\n│   ├── encrypted_private_config.age # SSH config（加密）\n│   └── config.d/                    # SSH config 片段\n├── dot_config/\n│   ├── starship.toml               # Starship prompt 設定\n│   ├── private_gh/\n│   │   └── encrypted_hosts.yml.age # GitHub CLI token（加密）\n│   └── karabiner/\n│       └── karabiner.json          # 鍵盤改鍵\n├── run_onchange_macos-defaults.sh.tmpl  # macOS 系統偏好\n├── run_onchange_install-packages.sh.tmpl # Brewfile 安裝\n└── .chezmoiignore\n```\n\n`.chezmoiignore` 也可以用模板語法，根據機器類型忽略不同檔案：\n\n```\nREADME.md\nLICENSE\n\n{{ if ne .machine_type \"work\" }}\n# 非工作機不需要這些\ndot_config/private_vpn\n{{ end }}\n\n{{ if eq .machine_type \"homelab\" }}\n# homelab 不需要 GUI 相關設定\ndot_config/karabiner\n{{ end }}\n```\n\n## 從 symlink 遷移到 chezmoi\n\n如果你目前用 GNU Stow 或手動 symlink，遷移很簡單：\n\n```bash\n# 1. 初始化 chezmoi\nchezmoi init\n\n# 2. 把現有的設定檔加入\nchezmoi add ~/.zshrc\nchezmoi add ~/.gitconfig\nchezmoi add ~/.vimrc\nchezmoi add ~/.config/starship.toml\n\n# 3. 刪除舊的 symlink（chezmoi add 已經複製了真實檔案）\n# stow -D your-stow-package\n\n# 4. 把需要差異化的檔案轉成模板\nchezmoi chattr +template ~/.zshrc\nchezmoi chattr +template ~/.gitconfig\n\n# 5. 編輯模板，加入條件判斷\nchezmoi edit ~/.zshrc\n```\n\n## 值不值得？\n\n老實說，如果你只有一台電腦，chezmoi 有點 overkill。純 Git + symlink 就很夠了。\n\n但如果你有兩台以上的機器，或是你的設定檔需要根據環境有差異，chezmoi 的 template 系統和加密功能真的會讓你的生活輕鬆很多。我的使用體感：\n\n- **初始設定時間**：大約 1-2 小時（把現有 dotfiles 遷移過去）\n- **日常維護時間**：接近零（改設定 → `chezmoi edit` → push → 另一台 `chezmoi update`）\n- **新機器 setup 時間**：`chezmoi init --apply your-repo` 一行搞定\n\n投資報酬率？以我三台電腦的使用頻率來說，大概第二週就回本了。",
      "summary": "用 chezmoi 管理多台 Mac 的差異化 dotfiles：模板語法、age 加密、多機同步工作流程，以及實際踩過的坑。",
      "image": "https://bobochen.dev/_astro/cover.CkiC03ZU.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "chezmoi",
        "dotfiles",
        "macOS",
        "自動化",
        "開發環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-streaming/",
      "url": "https://bobochen.dev/blog/claude-api-guide-streaming/",
      "title": "Streaming：打造即時回應的用戶體驗",
      "content_text": "深入理解 Claude API Streaming 的 SSE 事件類型、Python 與 TypeScript 實作、在 Next.js/Express 建立 streaming endpoint，以及 streaming 搭配 Tool Use 的特殊處理。",
      "content_html": "我要先說一個反直覺的事實：**使用 streaming 並不會讓 Claude 回答得更快**。\n\nClaude 生成每個 token 的速度是一樣的，不管你用不用 streaming。\n\n那為什麼 streaming 很重要？\n\n因為**感知等待時間（perceived latency）和實際等待時間是兩件事**。\n\n沒有 streaming：使用者看著空白螢幕等了 5 秒，然後一大段文字突然全部出現。他的感受是「等了 5 秒」。\n\n有了 streaming：使用者在你按下送出後 200 毫秒就看到第一個字，然後文字一個接一個地流出來。即使整體等待的總秒數一樣，他的感受是「回應很快」。\n\n這就是為什麼所有主流的 AI 產品（ChatGPT、Claude.ai、Gemini）都預設使用 streaming。如果你在建一個使用者會直接互動的 AI 應用，streaming 幾乎是必要的。\n\n## SSE（Server-Sent Events）原理\n\n在深入 Claude 的 streaming 實作之前，先快速了解底層的技術：SSE。\n\nHTTP 請求通常是「請求-回應」的模式：客戶端發送請求，伺服器處理完，送回完整的回應，連線結束。\n\nSSE 是一個例外：客戶端發送請求後，伺服器保持連線開放，**持續地推送資料**，直到它主動關閉連線。每個推送的資料片段格式如下：\n\n```\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你好\"}}\n\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"！\"}}\n\ndata: [DONE]\n```\n\n每個 `data: ` 行是一個事件，事件之間用空行分隔，`[DONE]` 表示串流結束。\n\nClaude API 的 streaming 就是建立在 SSE 上的。\n\n## Claude Streaming 的事件類型\n\nClaude 的 streaming 不只是「把文字一個字一個字傳過來」，它有一套完整的事件體系，讓你知道現在正在發生什麼。\n\n理解這些事件類型對於正確處理 streaming 回應非常重要：\n\n```\nmessage_start          → 串流開始，包含 message ID 和初始 usage 資訊\ncontent_block_start    → 一個新的 content block 開始（例如開始產生文字）\ncontent_block_delta    → content block 的一段增量（你的文字就在這裡）\ncontent_block_stop     → 一個 content block 結束\nmessage_delta          → message 層級的更新（包含 stop_reason 和最終 usage）\nmessage_stop           → 整個 message 串流結束\n```\n\n完整的事件流大致是這樣的：\n\n```json\n{\"type\": \"message_start\", \"message\": {\"id\": \"msg_01...\", \"type\": \"message\", \"role\": \"assistant\", \"content\": [], \"model\": \"claude-sonnet-4-6-20251101\", \"usage\": {\"input_tokens\": 25, \"output_tokens\": 1}}}\n\n{\"type\": \"content_block_start\", \"index\": 0, \"content_block\": {\"type\": \"text\", \"text\": \"\"}}\n\n{\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"text_delta\", \"text\": \"你好\"}}\n{\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"text_delta\", \"text\": \"！我是\"}}\n{\"type\": \"content_block_delta\", \"index\": 0, \"delta\": {\"type\": \"text_delta\", \"text\": \" Claude\"}}\n\n{\"type\": \"content_block_stop\", \"index\": 0}\n\n{\"type\": \"message_delta\", \"delta\": {\"stop_reason\": \"end_turn\", \"stop_sequence\": null}, \"usage\": {\"output_tokens\": 42}}\n\n{\"type\": \"message_stop\"}\n```\n\n在大多數情況下，你只需要關心 `content_block_delta` 事件裡的 `delta.text`——那就是要顯示給使用者的文字。但 `message_delta` 裡的 `stop_reason` 也很重要，你需要知道為什麼 Claude 停止了。\n\n## Python SDK Streaming 實作\n\nPython SDK 提供了非常優雅的 streaming 介面，使用 context manager 語法：\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\n\n# 使用 stream() context manager\nwith client.messages.stream(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=1024,\n    messages=[\n        {\"role\": \"user\", \"content\": \"請寫一首關於台灣的短詩，大約 100 字。\"}\n    ]\n) as stream:\n    # 最簡單的方式：直接迭代文字\n    for text in stream.text_stream:\n        print(text, end=\"\", flush=True)\n\nprint()  # 換行\n\n# 串流結束後，你可以取得完整的 message 物件\nfinal_message = stream.get_final_message()\nprint(f\"\\nStop reason: {final_message.stop_reason}\")\nprint(f\"Total tokens: {final_message.usage.input_tokens + final_message.usage.output_tokens}\")\n```\n\n`stream.text_stream` 是一個 generator，每次 yield 一個文字片段。`flush=True` 確保每個片段立即輸出到 terminal，而不是等緩衝區滿了再印出。\n\n如果你需要更細緻的控制，可以迭代原始的事件流：\n\n```python\nwith client.messages.stream(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=1024,\n    messages=[{\"role\": \"user\", \"content\": \"解釋一下量子纏繞\"}]\n) as stream:\n    for event in stream:\n        if event.type == \"content_block_delta\":\n            if event.delta.type == \"text_delta\":\n                # 即時處理每個文字片段\n                process_text_chunk(event.delta.text)\n        elif event.type == \"message_delta\":\n            # 串流結束，取得最終狀態\n            print(f\"Done! Stop reason: {event.delta.stop_reason}\")\n```\n\n### 累積完整回應\n\n有時候你需要同時串流給使用者，同時保存完整的回應文字（例如要存進資料庫）：\n\n```python\nfull_response = []\n\nwith client.messages.stream(...) as stream:\n    for text in stream.text_stream:\n        full_response.append(text)\n        yield text  # 串流給前端\n\ncomplete_text = \"\".join(full_response)\nsave_to_db(complete_text)\n```\n\n## TypeScript / Node.js Streaming\n\nTypeScript SDK 的 streaming API 設計得很對稱：\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n\nasync function streamResponse() {\n  const stream = client.messages.stream({\n    model: 'claude-sonnet-4-6',\n    max_tokens: 1024,\n    messages: [\n      {\n        role: 'user',\n        content: '請寫一首關於台灣的短詩，大約 100 字。',\n      },\n    ],\n  });\n\n  // 方法一：監聽 text 事件\n  stream.on('text', (text) => {\n    process.stdout.write(text);\n  });\n\n  // 等待串流完成\n  const finalMessage = await stream.finalMessage();\n  console.log('\\nStop reason:', finalMessage.stop_reason);\n  console.log('Total tokens:', finalMessage.usage.input_tokens + finalMessage.usage.output_tokens);\n}\n\nstreamResponse();\n```\n\n或者使用 async iterator 風格：\n\n```typescript\nasync function streamWithAsyncIterator() {\n  const stream = await client.messages.create({\n    model: 'claude-sonnet-4-6',\n    max_tokens: 1024,\n    stream: true,  // 關鍵：設定 stream: true\n    messages: [\n      { role: 'user', content: '你好！' },\n    ],\n  });\n\n  for await (const event of stream) {\n    if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {\n      process.stdout.write(event.delta.text);\n    }\n  }\n}\n```\n\n## 在 Express.js 中實作 Streaming Endpoint\n\n把 streaming 整合進你的後端 API，讓前端可以接收：\n\n```typescript\nimport express from 'express';\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst app = express();\napp.use(express.json());\n\nconst client = new Anthropic();\n\napp.post('/api/chat/stream', async (req, res) => {\n  const { messages, systemPrompt } = req.body;\n\n  // 設定 SSE 必要的 headers\n  res.setHeader('Content-Type', 'text/event-stream');\n  res.setHeader('Cache-Control', 'no-cache');\n  res.setHeader('Connection', 'keep-alive');\n  res.setHeader('X-Accel-Buffering', 'no');  // 給 Nginx 用的，禁用緩衝\n\n  try {\n    const stream = client.messages.stream({\n      model: 'claude-sonnet-4-6',\n      max_tokens: 2048,\n      system: systemPrompt,\n      messages,\n    });\n\n    // 把每個文字片段轉換成 SSE 格式傳給前端\n    stream.on('text', (text) => {\n      // SSE 格式：data: {...}\\n\\n\n      res.write(`data: ${JSON.stringify({ type: 'text', text })}\\n\\n`);\n    });\n\n    // 等待串流完成\n    const finalMessage = await stream.finalMessage();\n\n    // 傳送結束事件\n    res.write(`data: ${JSON.stringify({\n      type: 'done',\n      stop_reason: finalMessage.stop_reason,\n      usage: finalMessage.usage,\n    })}\\n\\n`);\n\n    res.end();\n\n  } catch (error) {\n    // 錯誤時通知前端\n    res.write(`data: ${JSON.stringify({ type: 'error', message: String(error) })}\\n\\n`);\n    res.end();\n  }\n});\n\napp.listen(3000);\n```\n\n### 前端（瀏覽器）接收 SSE\n\n```typescript\n// 前端使用 EventSource 或 fetch 接收 SSE\n\nasync function fetchStreamingResponse(userMessage: string) {\n  const response = await fetch('/api/chat/stream', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      messages: [{ role: 'user', content: userMessage }],\n    }),\n  });\n\n  const reader = response.body!.getReader();\n  const decoder = new TextDecoder();\n  let buffer = '';\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    buffer += decoder.decode(value, { stream: true });\n\n    // 解析 SSE 事件（以 \\n\\n 分隔）\n    const lines = buffer.split('\\n\\n');\n    buffer = lines.pop() || '';  // 最後一個可能不完整，留到下次\n\n    for (const line of lines) {\n      if (line.startsWith('data: ')) {\n        const data = JSON.parse(line.slice(6));\n        if (data.type === 'text') {\n          // 更新 UI：把文字附加到顯示區域\n          appendToDisplay(data.text);\n        } else if (data.type === 'done') {\n          console.log('Stream complete:', data.stop_reason);\n        } else if (data.type === 'error') {\n          console.error('Stream error:', data.message);\n        }\n      }\n    }\n  }\n}\n```\n\n## 在 Next.js 中實作 Streaming\n\nNext.js App Router 原生支援 streaming response，搭配 Claude API 特別好用：\n\n```typescript\n// app/api/chat/route.ts\nimport { NextRequest } from 'next/server';\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n\nexport async function POST(req: NextRequest) {\n  const { messages } = await req.json();\n\n  // 建立一個 TransformStream 來轉換 Claude 的 streaming 輸出\n  const encoder = new TextEncoder();\n\n  const stream = new ReadableStream({\n    async start(controller) {\n      try {\n        const claudeStream = client.messages.stream({\n          model: 'claude-sonnet-4-6',\n          max_tokens: 2048,\n          messages,\n        });\n\n        claudeStream.on('text', (text) => {\n          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\\n\\n`));\n        });\n\n        await claudeStream.finalMessage();\n        controller.enqueue(encoder.encode('data: [DONE]\\n\\n'));\n        controller.close();\n\n      } catch (error) {\n        controller.enqueue(\n          encoder.encode(`data: ${JSON.stringify({ error: String(error) })}\\n\\n`)\n        );\n        controller.close();\n      }\n    },\n  });\n\n  return new Response(stream, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Cache-Control': 'no-cache',\n      'Connection': 'keep-alive',\n    },\n  });\n}\n```\n\n前端使用 Vercel AI SDK 的 `useChat` hook 可以讓這個流程更簡單。但如果你想自己實作，上面的範例已經足夠了。\n\n## Streaming 的錯誤處理與重連策略\n\nStreaming 相比一般的 HTTP 請求有更多需要處理的邊際情況。\n\n### 常見的失敗模式\n\n1. **網路中斷**：串流到一半連線斷了\n2. **伺服器過載**：Anthropic 在串流過程中回傳 529\n3. **逾時**：長時間的 streaming 可能觸發某些代理伺服器的逾時設定\n\n### 簡單的重連實作\n\n```python\nimport anthropic\nimport time\n\ndef stream_with_retry(client, max_retries=3, **kwargs):\n    \"\"\"帶重試機制的 streaming 呼叫\"\"\"\n    for attempt in range(max_retries):\n        try:\n            with client.messages.stream(**kwargs) as stream:\n                for text in stream.text_stream:\n                    yield text\n                return  # 成功完成，退出\n\n        except anthropic.APIConnectionError as e:\n            if attempt == max_retries - 1:\n                raise\n            wait = 2 ** attempt\n            print(f\"Connection error, retrying in {wait}s... (attempt {attempt + 1})\")\n            time.sleep(wait)\n\n        except anthropic.APIStatusError as e:\n            if e.status_code in (429, 529) and attempt < max_retries - 1:\n                wait = 2 ** attempt\n                time.sleep(wait)\n            else:\n                raise\n```\n\n### 前端的重連邏輯\n\n```typescript\nasync function streamWithReconnect(\n  messages: Anthropic.Messages.MessageParam[],\n  maxRetries = 3\n) {\n  for (let attempt = 0; attempt < maxRetries; attempt++) {\n    try {\n      await fetchStreamingResponse(messages);\n      return;  // 成功\n    } catch (error) {\n      if (attempt === maxRetries - 1) throw error;\n      const waitMs = Math.pow(2, attempt) * 1000;\n      console.warn(`Stream failed, retrying in ${waitMs}ms...`);\n      await new Promise(resolve => setTimeout(resolve, waitMs));\n    }\n  }\n}\n```\n\n## Streaming with Tool Use：特殊處理\n\n當你的應用使用 Tool Use（下一章介紹），streaming 會更複雜。\n\n除了文字的 `text_delta`，你還會收到 tool use 相關的事件：\n\n```python\nwith client.messages.stream(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=2048,\n    tools=[weather_tool],\n    messages=messages\n) as stream:\n    for event in stream:\n        if event.type == \"content_block_start\":\n            if event.content_block.type == \"tool_use\":\n                # Claude 正在呼叫工具\n                tool_name = event.content_block.name\n                tool_id = event.content_block.id\n                print(f\"Calling tool: {tool_name}\")\n\n        elif event.type == \"content_block_delta\":\n            if event.delta.type == \"text_delta\":\n                # 普通文字\n                print(event.delta.text, end=\"\", flush=True)\n            elif event.delta.type == \"input_json_delta\":\n                # Tool 的參數（JSON 格式，逐步串流）\n                # 不建議在這裡解析，等 message_stop 後再解析完整的\n                pass\n\n        elif event.type == \"message_stop\":\n            # 取得完整的 message，包含所有 tool_use blocks\n            final = stream.get_final_message()\n            # 處理 tool calls...\n```\n\n我的建議：**在 streaming 模式下，不要試圖即時解析 tool input**。`input_json_delta` 是 JSON 的片段，很難即時解析。等到 `message_stop` 事件後，用 `stream.get_final_message()` 取得完整的 message，再處理 tool calls。\n\n## stop_reason 的意義\n\n每個 streaming 回應最終都會有一個 `stop_reason`，在 `message_delta` 事件裡：\n\n```python\nwith client.messages.stream(...) as stream:\n    for text in stream.text_stream:\n        pass  # 消費所有文字\n\n    final = stream.get_final_message()\n    match final.stop_reason:\n        case \"end_turn\":\n            # 正常結束：Claude 認為它說完了\n            pass\n        case \"max_tokens\":\n            # 被截斷：需要告知使用者，或增大 max_tokens 繼續\n            handle_truncation(final)\n        case \"stop_sequence\":\n            # 遇到你定義的 stop_sequence 而停止\n            pass\n        case \"tool_use\":\n            # Claude 想要呼叫工具，等待你執行工具後繼續\n            handle_tool_use(final)\n```\n\n## 效能優化提示\n\n**使用更小的模型**：Haiku 比 Sonnet 快 3-5 倍，如果你的應用對延遲很敏感，Haiku 可能是更好的選擇。\n\n**減少 system prompt 長度**：更長的 system prompt 會增加 time-to-first-token（第一個 token 出現的時間）。\n\n**平行請求**：如果你有多個不相關的 Claude 呼叫，可以平行發送而不是序列等待：\n\n```python\nimport asyncio\nimport anthropic\n\n# 使用 AsyncAnthropic 客戶端\nclient = anthropic.AsyncAnthropic()\n\nasync def parallel_streams(prompts: list[str]):\n    async def single_stream(prompt):\n        result = []\n        async with client.messages.stream(\n            model=\"claude-haiku-4-5\",\n            max_tokens=512,\n            messages=[{\"role\": \"user\", \"content\": prompt}]\n        ) as stream:\n            async for text in stream.text_stream:\n                result.append(text)\n        return \"\".join(result)\n\n    return await asyncio.gather(*[single_stream(p) for p in prompts])\n```\n\n## 下一步\n\nStreaming 讓你的 AI 應用在使用者體驗上更接近 Claude.ai 這樣的成熟產品。\n\n但到目前為止，我們的 Claude 都是「說話的機器」——只能輸出文字，沒辦法真正地做事。\n\n**下一章**，我們來解鎖 Claude 最強大的功能之一：Tool Use（工具使用）。讓 Claude 不只是說話，而是能夠呼叫你的 API、查詢資料庫、執行計算——真正地成為你應用的大腦。",
      "summary": "深入理解 Claude API Streaming 的 SSE 事件類型、Python 與 TypeScript 實作、在 Next.js/Express 建立 streaming endpoint，以及 streaming 搭配 Tool Use 的特殊處理。",
      "image": "https://bobochen.dev/_astro/cover.BN5-53WJ.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Streaming",
        "SSE",
        "UX"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/mac-setup-automation-brewfile-dotfiles-guide/",
      "url": "https://bobochen.dev/blog/mac-setup-automation-brewfile-dotfiles-guide/",
      "title": "新 Mac 開箱後，我怎麼 30 分鐘裝好所有東西",
      "content_text": "用 Homebrew Bundle、dotfiles、defaults write 腳本，四層自動化策略讓新 Mac 設定從半天縮短到 30 分鐘。不是工程師也能上手。",
      "content_html": "> 這是「一鍵搞定新 Mac」系列的第一篇，帶你建立整套自動化策略的全景圖。後續文章會針對每一層深入展開。\n\n買了新 Mac，開心拆箱、過完設定精靈之後，接下來通常是最痛苦的時刻——一個一個裝回你熟悉的軟體、調回你習慣的設定。\n\nChrome、VS Code、iTerm2、Line、Notion⋯⋯光是想就累了。更別說那些藏在系統偏好裡的小設定：Dock 要自動隱藏、Finder 要顯示隱藏檔、觸控板的滑動方向⋯⋯\n\n如果你有不只一台 Mac（公司的、家裡的），或是每隔幾年就會換一次電腦，這篇就是寫給你的。\n\n我會用四個層級，帶你從「手動裝到哭」進化到「一行指令全搞定」。\n\n## 全景圖：四層自動化策略\n\n在開始之前，先看一下全貌。Mac 的個人化設定其實散落在四個地方：\n\n| 層級     | 管什麼                   | 工具                  | 難度 |\n| -------- | ------------------------ | --------------------- | ---- |\n| 軟體安裝 | App、CLI 工具            | Homebrew Bundle       | ★☆☆  |\n| 設定檔   | Shell、Git、編輯器設定   | Dotfiles + Git        | ★★☆  |\n| 系統偏好 | Dock、Finder、鍵盤快捷鍵 | `defaults write` 腳本 | ★★☆  |\n| 機敏資料 | SSH 金鑰、密碼、token    | 每台重產 + `*.local` 檔  | ★☆☆  |\n\n每一層獨立處理，組合起來就是一套完整的自動化 setup。\n\n## 第一層：Homebrew Bundle — 軟體一鍵安裝\n\n[Homebrew](https://brew.sh/) 是 macOS 上最主流的套件管理工具。即使你不是工程師，只要會在終端機貼指令，就能用它一次裝完所有軟體。\n\n**在舊電腦上匯出清單**：\n\n```bash\nbrew bundle dump --file=~/Brewfile --describe\n```\n\n這會產生一個 `Brewfile`，記錄你目前裝了什麼：\n\n```ruby\n# CLI 工具\nbrew \"git\"           # 版本控制\nbrew \"node\"          # JavaScript runtime\nbrew \"zsh\"           # Shell\n\n# GUI 應用程式（透過 cask）\ncask \"google-chrome\"\ncask \"visual-studio-code\"\ncask \"iterm2\"\ncask \"raycast\"\ncask \"notion\"\ncask \"line\"\n\n# Mac App Store 的 App（透過 mas）\nmas \"Xcode\", id: 497799835\nmas \"Keynote\", id: 409183694\n```\n\n> **兩個之後會踩的坑，先記著**：① 如果 `dump` 出來的清單裡有 `vscode \"...\"` 行，建議刪掉——VS Code 自己的 Settings Sync 已經在管擴充套件，留著會兩邊重複（本系列有一篇專門講怎麼清掉這些白工）。② 上面的 `mas` 行（Mac App Store 的 App）在較新的 macOS 上 `mas install` 會需要 sudo，跑全自動腳本時可能卡住；保守做法是 Brewfile 只管 `cask`，App Store 的 App 先到商店登入再手動裝。\n\n**在新電腦上一鍵安裝**：\n\n```bash\n# 先裝 Homebrew（新電腦必須）\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n\n# 然後一行搞定所有軟體\nbrew bundle --file=~/Brewfile\n```\n\n就這樣。CLI 工具、GUI App、甚至 Mac App Store 的軟體，全部一次裝回來。\n\n### 3 分鐘快速上手\n\n裝完 Homebrew 後，你只需要記住這幾個指令：\n\n```bash\nbrew install <套件名>     # 安裝 CLI 工具\nbrew install --cask <app> # 安裝 GUI App\nbrew list                 # 看裝了什麼\nbrew update && brew upgrade  # 更新所有套件\nbrew bundle dump --file=~/Brewfile  # 匯出目前的軟體清單\nbrew bundle --file=~/Brewfile       # 從清單一鍵安裝\n```\n\n**最小可用流程**：\n\n1. 打開「終端機」（在 Spotlight 搜尋 \"Terminal\"）\n2. 貼上 Homebrew 安裝指令，等它跑完\n3. 執行 `brew install --cask google-chrome` 試試看——Chrome 會自動下載安裝，不用開瀏覽器下載 .dmg\n\n> **給非工程師的小提醒**：`Brewfile` 就是一份「軟體清單」，純文字格式。你可以用任何文字編輯器打開它，手動新增或刪除不需要的軟體。\n\n## 第二層：Dotfiles — 設定檔版本控制\n\nmacOS（和大部分 Unix 系統）的應用程式設定檔都放在家目錄，檔名以 `.` 開頭（所以叫 dotfiles），例如：\n\n- `.zshrc` — Shell 的設定（別名、環境變數）\n- `.gitconfig` — Git 設定（使用者名稱、預設編輯器）\n- `.ssh/config` — SSH 連線設定\n\n這些檔案平常看不到（macOS 預設隱藏），但它們決定了你的工作環境長什麼樣子。\n\n### 最簡單的做法：Git repo + 手動 symlink\n\n```bash\n# 1. 建立 dotfiles 資料夾\nmkdir ~/dotfiles && cd ~/dotfiles\ngit init\n\n# 2. 把設定檔搬進來\ncp ~/.zshrc ~/dotfiles/\ncp ~/.gitconfig ~/dotfiles/\n\n# 3. 建立 symlink（讓系統讀到正確位置）\nln -sf ~/dotfiles/.zshrc ~/.zshrc\nln -sf ~/dotfiles/.gitconfig ~/.gitconfig\n\n# 4. 推到 GitHub 保存\ngit add -A && git commit -m \"initial dotfiles\"\n```\n\n### 進階：用 chezmoi 管理\n\n如果你有多台電腦、或是設定檔裡有些值每台不同（例如工作用的 Git email vs 個人 email），[chezmoi](https://github.com/twpayne/chezmoi) 是目前功能最完整的 dotfiles 管理工具。\n\n```bash\n# 安裝\nbrew install chezmoi\n\n# 初始化（第一台電腦）\nchezmoi init\nchezmoi add ~/.zshrc\nchezmoi add ~/.gitconfig\nchezmoi cd  # 進入 chezmoi 管理的資料夾\ngit add -A && git commit -m \"initial\" && git push\n\n# 在新電腦上，一行搞定\nsh -c \"$(curl -fsLS get.chezmoi.io)\" -- init --apply your-github-username\n```\n\n### chezmoi 3 分鐘快速上手\n\n```bash\nchezmoi add <檔案>      # 把檔案加入管理\nchezmoi edit <檔案>     # 編輯受管理的檔案\nchezmoi apply           # 把所有變更套用到系統\nchezmoi diff            # 預覽變更（不實際套用）\nchezmoi update          # 從 GitHub 拉最新版本並套用\nchezmoi cd              # 進入 chezmoi 的 source 資料夾\n```\n\n**最小可用流程**：\n\n1. `chezmoi init` — 初始化\n2. `chezmoi add ~/.zshrc` — 加入你最重要的設定檔\n3. `chezmoi cd` → `git add -A && git commit -m \"add zshrc\" && git push` — 推到 GitHub\n4. 換台電腦 → `chezmoi init --apply your-github-username` — 一行還原\n\nchezmoi 的殺手級功能是**模板**——同一份 `.gitconfig` 可以根據機器自動填入不同的 email。但這是進階用法，先把基本流程跑通就很夠用了。\n\n另一個輕量選擇是 [GNU Stow](https://www.gnu.org/software/stow/)，它只做一件事：根據資料夾結構自動建立 symlink。沒有模板、沒有加密，但也因此幾乎不用學。\n\n## 第三層：macOS 系統偏好 — defaults write 腳本\n\nmacOS 的系統偏好設定（Dock 大小、Finder 行為、觸控板⋯⋯）其實都存在 plist 檔案裡，可以用 `defaults` 指令讀寫。\n\n把你喜歡的設定寫成一個 shell script，換電腦時跑一次就好：\n\n```bash\n#!/bin/bash\n# macos-defaults.sh — 我的 macOS 偏好設定\n\n# === Dock ===\ndefaults write com.apple.dock autohide -bool true          # Dock 自動隱藏\ndefaults write com.apple.dock tilesize -int 48              # 圖示大小\ndefaults write com.apple.dock minimize-to-application -bool true  # 最小化到 App 圖示\n\n# === Finder ===\ndefaults write com.apple.finder ShowPathbar -bool true      # 顯示路徑列\ndefaults write com.apple.finder ShowStatusBar -bool true    # 顯示狀態列\ndefaults write com.apple.finder AppleShowAllFiles -bool true  # 顯示隱藏檔\n\n# === 鍵盤 ===\ndefaults write NSGlobalDomain KeyRepeat -int 2              # 按鍵重複速度（越小越快）\ndefaults write NSGlobalDomain InitialKeyRepeat -int 15      # 按鍵重複延遲\n\n# === 觸控板 ===\ndefaults write com.apple.AppleMultitouchTrackpad TrackpadThreeFingerDrag -bool true  # 三指拖曳\n\n# === 截圖 ===\ndefaults write com.apple.screencapture location -string \"~/Screenshots\"  # 截圖存到指定資料夾\ndefaults write com.apple.screencapture type -string \"png\"    # 截圖格式\n\n# 重新啟動受影響的服務\nkillall Dock\nkillall Finder\nkillall SystemUIServer\n\necho \"macOS 偏好設定已套用！部分設定需要登出再登入才會生效。\"\n```\n\n我把用了十幾年的 Mac 設定全部匯出，檔案竟然有 5.3MB。\n![用了十幾年的 Mac 匯出 defaults，檔案大小 5.3MB](mac-setup-automation-brewfile-dotfiles-guide-fig-2.webp)\n\n匯出的 macos-defaults-backup.plist 內容竟然有 14 萬多行！\n![macos-defaults-backup.plist 內容超過 14 萬行](mac-setup-automation-brewfile-dotfiles-guide-fig-1.webp)\n\n（這正是為什麼**別**直接拿 `defaults read` 的全量匯出當備份——這 5.3MB、14 萬行裡絕大多數是各個 app 的暫存狀態，雜訊遠多於訊號。一份你看得懂的手寫 `defaults.sh` 反而更可靠、更好維護。本系列後面有一篇專門拆解「5.3MB 的 plist 為什麼不如 130 行的 shell script」。）\n\n### 怎麼發現更多可設定的項目？\n\n```bash\n# 匯出目前所有設定（超長，但很有參考價值）\ndefaults read > ~/current-defaults.txt\n\n# 查看特定 App 的設定\ndefaults read com.apple.dock\n```\n\n推薦參考 [mathiasbynens/dotfiles](https://github.com/mathiasbynens/dotfiles) 的 `.macos` 檔案，裡面有幾百行精心整理的設定，是這個領域的經典。你不需要全用，挑你需要的就好。\n\n## 第四層：機敏資料 — 小心處理\n\nSSH 金鑰、API token、密碼這類東西**不應該放在 Git repo 裡**。但與其急著找一個「同步機密」的工具，我更建議先問一個問題：**這東西能不能重新產生？** 答案會決定你要不要費工去加密、去搬移。\n\n我把機密分成兩桶：\n\n- **桶 1：可以重新產生**——SSH 私鑰、GPG 簽章金鑰、GitHub token、大多數 API token。\n  這類東西**每台新機重產一組就好**，不要搬舊的。對 SSH key 來說這本來就是資安最佳實踐：私鑰永遠不離開那台機器，哪台退役就只撤銷那一把。\n- **桶 2：不可以重新產生**——換了就回不來的東西（某些不可重發的 license、2FA 備份碼……）。\n  這才是真正要小心保存的，放在 `*.local` 檔案裡（例如 `~/.gitconfig.local` 放 email、`~/.zshenv.local` 放 token）。這些檔**不進 Git、每台手動建**。\n\n**SSH 金鑰怎麼處理（重產派做法）**：新電腦不要從舊電腦搬 `~/.ssh/`。裝好 `gh` 後跑 `gh auth login`、選 SSH，它會幫你**產一把全新的 key 並自動把公鑰上傳到 GitHub**——一步同時搞定認證又免搬私鑰。（這步是 private repo 在新機的第一道關卡，本系列後面有專篇細講。）\n\n> **我自己不用 1Password**（也不用其他商業密碼管理器）。如果你已經在用 1Password / Bitwarden，它們確實能同步密碼、也支援 SSH Agent，照用沒問題；但它**不是必需品**。光是「可重產的每台重產、不可重產的放 `*.local`」，就能覆蓋大多數人的需求。\n>\n> 至於免每次重打 SSH key 密碼——用 **macOS 內建的鑰匙圈（Keychain）** 就好：`ssh-add --apple-use-keychain ~/.ssh/id_ed25519`，不必為了這個裝密碼管理器。\n\n> **原則**：如果一個檔案裡有密碼或 token，它就不該出現在 GitHub 上，即使是 private repo。chezmoi 的 [age](https://github.com/FiloSottile/age) 加密只有在你的「桶 2」長大到有「不可重產**又**必須版控」的機密時才需要——桶 2 是空的，你連 age 都不用碰。\n\n## 組合技：一鍵 Setup 腳本\n\n把前面四層串起來，就是一個完整的新電腦 setup 腳本：\n\n```bash\n#!/bin/bash\n# install.sh — 新 Mac 一鍵配置\n\nset -e  # 遇到錯誤就停止\n\necho \"=== Step 1: 安裝 Homebrew ===\"\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\neval \"$(/opt/homebrew/bin/brew shellenv)\"   # Apple Silicon：把 brew 加進當前 shell\n\necho \"=== Step 2: GitHub 認證 + 產一把新 SSH key ===\"\n# 這步要先做：dotfiles 放 private repo 的話，沒先認證根本 clone 不下來。\n# gh auth login 選 SSH 會自動產一把新 key 並上傳公鑰到 GitHub，一步搞定認證又免搬私鑰。\nbrew install gh chezmoi\ngh auth login   # → GitHub.com → SSH → \"Generate a new SSH key?\" → Yes\n\necho \"=== Step 3: 套用 dotfiles（順手把 Brewfile 一起拉下來）===\"\nchezmoi init --apply your-github-username\n# 或者如果用 Stow：\n# cd ~/dotfiles && stow zsh git ssh\n\necho \"=== Step 4: 安裝所有軟體 ===\"\nbrew bundle --file=\"$(chezmoi source-path)/Brewfile\"\n\necho \"=== Step 5: 套用 macOS 偏好設定 ===\"\nchmod +x \"$(chezmoi source-path)/macos-defaults.sh\"\n\"$(chezmoi source-path)/macos-defaults.sh\"\n\necho \"=== Done! ===\"\necho \"記得手動處理：\"\necho \"  1. 建立 ~/.gitconfig.local（email）和 ~/.zshenv.local（GITHUB_TOKEN 等 secrets）\"\necho \"  2. 登入 iCloud、VS Code Settings Sync 等 App 內建同步\"\necho \"  3. 登出再登入讓所有設定生效\"\n```\n\n> 注意 **Step 2 的順序**：很多人（包括早期的我）會把「裝軟體」放在最前面，結果新機要 clone 自己的 **private** dotfiles repo 時，卡在沒有認證。先用 `gh auth login`（選 SSH、產新 key）把認證和金鑰一次搞定，後面才會順。這個「新機第一步認證的雞生蛋」本系列後面有專篇細講。\n\n## 別忘了這些 App 自帶的同步功能\n\n有些軟體有內建的設定同步，善用它們可以省更多事：\n\n| 軟體        | 同步方式                               |\n| ----------- | -------------------------------------- |\n| VS Code     | 內建 Settings Sync（登入 GitHub 即可） |\n| Chrome      | 登入 Google 帳號同步書籤、擴充套件     |\n| Raycast     | 內建 Cloud Sync                        |\n| 1Password   | 雲端同步                               |\n| Claude Code | 複製 `~/.claude/` 資料夾               |\n\n## 我的建議：從 Brewfile 開始就好\n\n如果上面看起來很多，不用一次全做。我的建議是：\n\n1. **今天就做**：跑 `brew bundle dump --file=~/Brewfile`，把軟體清單匯出來\n2. **這週做**：建一個 `dotfiles` Git repo，先放 `.zshrc` 和 `.gitconfig`\n3. **有空再做**：慢慢累積 `macos-defaults.sh`，遇到什麼設定就加什麼\n\n不需要一開始就完美。每次你調了一個設定、裝了一個新工具，順手更新 dotfiles repo，半年後你就會有一套很完整的自動化配置。\n\n下次換電腦，你會感謝現在的自己。",
      "summary": "用 Homebrew Bundle、dotfiles、defaults write 腳本，四層自動化策略讓新 Mac 設定從半天縮短到 30 分鐘。不是工程師也能上手。",
      "image": "https://bobochen.dev/_astro/cover.CsQeQ1bL.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "macOS",
        "Homebrew",
        "dotfiles",
        "自動化",
        "效率工具"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/mackup-vs-chezmoi-vs-defaults-sh-comparison/",
      "url": "https://bobochen.dev/blog/mackup-vs-chezmoi-vs-defaults-sh-comparison/",
      "title": "Mackup vs chezmoi vs 手寫 script：macOS 設定備份工具怎麼選？",
      "content_text": "三種主流的 macOS 設定備份方案，設計哲學完全不同：symlink 派、copy 派、script 派。沒有最好的工具，只有最適合你的工具。",
      "content_html": "想備份 macOS 的設定，Google 一下大概會出現三個主流方案：Mackup、chezmoi、手寫 shell script。\n\n每個都說自己好用，但設計哲學完全不同。選錯了，換起來很麻煩。\n\n這篇不是「哪個贏」，而是「你是哪種人就選哪個」。\n\n## 三種方案的核心差異\n\n先用一張表看懂本質差異：\n\n|                  | Mackup                     | chezmoi               | 手寫 script           |\n| ---------------- | -------------------------- | --------------------- | --------------------- |\n| **備份機制**     | Symlink（符號連結）        | Copy + Template       | `defaults write` 指令 |\n| **備份對象**     | App 設定檔（plist/config） | dotfiles（.zshrc 等） | macOS 系統偏好        |\n| **儲存位置**     | iCloud / Dropbox / Git     | Git repo              | Git repo              |\n| **多機差異**     | 難處理                     | 原生支援 template     | 需手動 if/else        |\n| **macOS Sonoma** | ⚠️ Symlink 已知 bug        | ✅ 完全相容           | ✅ 相容               |\n| **Secrets 管理** | ❌ 無                      | ✅ 內建加密           | ❌ 手動/有風險        |\n| **學習曲線**     | 低                         | 中                    | 低                    |\n| **GitHub Stars** | ~15k                       | ~18.5k                | N/A                   |\n| **維護狀態**     | 趨緩                       | 非常活躍              | 你自己維護            |\n\n---\n\n## Mackup — Symlink 派\n\n[Mackup](https://github.com/lra/mackup) 的邏輯很直觀：\n\n1. 把 App 的設定檔從原本位置**移動**到 iCloud/Dropbox\n2. 在原本位置建立一個**符號連結**指向雲端位置\n3. 換電腦時，從雲端 restore，重建 symlink\n\n```bash\nbrew install mackup\n\n# 備份\nmackup backup\n\n# 還原\nmackup restore\n```\n\n### 為什麼吸引人\n\n支援超過 360 個 App，包含 VSCode、Sublime Text、Alfred、iTerm2、Karabiner 等常用工具。對非技術用戶來說，「一行指令搞定所有 App 設定」的概念非常吸引人。\n\n### 實際踩到的坑\n\n**Symlink 是雙面刃**。因為 App 直接讀寫雲端位置，設定變更會即時同步——聽起來很好，但：\n\n- **⚠️ macOS Sonoma (14+) 已知 bug**：升級到 Sonoma 後 symlink 會壞掉，這是目前 Mackup 最大的問題。很多用戶因此遷移到其他方案。\n- **iCloud 同步延遲**：App 開啟時設定檔還在同步中，可能讀到舊版本\n- **多機同時修改**：在公司電腦改了設定，回家電腦還沒同步就開啟 App，可能產生衝突\n- **App 不尊重 symlink**：現代 App（如 iTerm2、Fantastical）有時會直接覆寫 symlink 而不是跟隨它\n- **維護趨緩**：近年 PR 積累，部分 App 的備份設定已過時\n\n### 適合的人\n\n- **需要注意**：如果你已經在 macOS Sonoma (14+) 上，Mackup 的 symlink 問題可能讓你踩坑\n- 如果用的是 Sonoma 以前的版本，或願意手動處理相容性問題，Mackup 在備份 GUI App 設定這件事仍有優勢\n- 主要需求是備份 **GUI App 設定**（而非 CLI dotfiles）\n- 只用一台電腦，不需要多機差異\n\n---\n\n## chezmoi — Copy 派\n\n[chezmoi](https://github.com/twpayne/chezmoi) 的設計哲學完全不同：**不用 symlink，改用 copy**。\n\n它有一個獨立的 source directory（預設 `~/.local/share/chezmoi`），你在那邊管理「設定檔的原始版本」，執行 `chezmoi apply` 才會把檔案**複製**到實際位置。\n\n```bash\nbrew install chezmoi\n\n# 把現有 dotfile 加入管理\nchezmoi add ~/.zshrc\nchezmoi add ~/.gitconfig\n\n# 看差異\nchezmoi diff\n\n# 套用\nchezmoi apply\n```\n\n### 為什麼值得花時間學\n\n**Template 系統**是 chezmoi 最強的地方。同一份 source，可以根據機器不同輸出不同內容：\n\n```\n# ~/.local/share/chezmoi/dot_gitconfig.tmpl\n[user]\n    name = Bobo Chen\n    email = {{ if eq .chezmoi.hostname \"work-mac\" }}bobo@company.com{{ else }}bobo@personal.com{{ end }}\n```\n\n公司電腦和個人電腦，自動用不同的 Git email。這件事用 Mackup 幾乎做不到。\n\n其他亮點：\n\n- **加密 secrets**：支援 1Password、Bitwarden、age 加密，私鑰也可以版控\n- **跨平台**：同一份 dotfiles 可以用在 macOS 和 Linux\n- **Git 原生**：source directory 直接就是一個 git repo\n\n### 學習曲線\n\nchezmoi 的檔案命名有一套規則（`dot_zshrc` 對應 `~/.zshrc`、`private_` 前綴代表 600 權限⋯⋯），需要花一點時間熟悉。但一旦上手，這套系統非常彈性。\n\n### 適合的人\n\n- 有多台電腦，需要**差異化設定**（公司 vs 個人）\n- 需要版控**加密的 secrets**\n- 想要一套 dotfiles 同時支援 macOS + Linux\n- 不排斥花一個下午把系統學清楚\n\n---\n\n## 手寫 script — Script 派\n\n不依賴任何工具，就是一個 shell script，跑 `defaults write` 指令：\n\n```bash\n#!/bin/bash\n\n# 鍵盤速度\ndefaults write NSGlobalDomain KeyRepeat -int 2\ndefaults write NSGlobalDomain InitialKeyRepeat -int 15\n\n# Dock\ndefaults write com.apple.dock autohide -bool true\ndefaults write com.apple.dock tilesize -int 48\n\n# Finder\ndefaults write com.apple.finder AppleShowAllFiles -bool true\ndefaults write com.apple.finder ShowPathbar -bool true\n\n# 重啟服務\nkillall Dock Finder 2>/dev/null || true\n```\n\n這個方案特別針對 **macOS 系統偏好設定**（不是 App 設定檔，也不是 dotfiles）。知名範本：[mathiasbynens/dotfiles](https://github.com/mathiasbynens/dotfiles) 的 `.macos` 檔案，社群很多人拿來當起點。\n\n### 優點\n\n- **零依賴**：只要有 bash 就能跑\n- **完全可讀**：每行都看得懂在幹嘛，有問題很容易 debug\n- **零隱私風險**：只設定你明確選擇的項目\n- **跨版本穩定**：你控制每個設定，macOS 升級不會意外覆蓋\n\n### 缺點\n\n- **不備份 App 設定**：只管得了 `defaults write` 能設定的範圍\n- **需要手動維護**：新發現的好設定要自己加進去\n- **沒有衝突偵測**：不知道現在的設定跟 script 有沒有差異\n\n### 適合的人\n\n- 只需要備份 **macOS 系統偏好設定**（不需要備份 App 設定）\n- 重視可讀性和控制感\n- 不想引入額外依賴\n\n---\n\n## 三個方案怎麼組合用？\n\n這三個方案並不互斥，很多人混著用。不過考量到 Mackup 在 Sonoma 的問題，我現在建議的組合是：\n\n```\n系統偏好設定    → 手寫 defaults.sh（Dock、Finder、鍵盤速度）\ndotfiles 管理   → chezmoi（.zshrc、.gitconfig、App config 檔）\nGUI App 設定    → 視情況：部分 App 的 config 檔也可以用 chezmoi 管\n```\n\n如果你的 Mackup 目前跑得好，繼續用沒問題。但如果是全新設定，我不會再推薦 Mackup 作為主要方案。\n\n## 我的建議\n\n**剛開始 / 只需要系統設定**：從手寫 `defaults.sh` 入手。學習成本最低，讓你先搞懂 `defaults` 系統怎麼運作，也不會有隱私風險。\n\n**現代 macOS（Sonoma+）的 App 設定備份**：直接用 chezmoi。把 App 的 config 檔（很多 App 支援 XDG config dir 或 `~/.config/`）加進 chezmoi 管理，比 Mackup 的 symlink 更穩定。\n\n**有多台電腦 / 有 Linux 機器**：花時間學 chezmoi。前期投資值得，template 系統會讓你的 dotfiles 真正「活起來」。\n\n**已在用 Mackup 且沒問題**：繼續用就好，不用急著換。但如果你在 Sonoma 上遇到 symlink 問題，這時候是遷移到 chezmoi 的好時機。\n\n---\n\n## 反思\n\n選工具之前，先想清楚你要備份的是什麼：\n\n- **macOS 系統設定**（Dock、鍵盤速度）→ `defaults.sh`\n- **CLI 工具的設定檔**（`.zshrc`、`.gitconfig`）→ chezmoi\n- **GUI App 的設定**（Alfred、Karabiner）→ Mackup\n\n把這三層分清楚，就不會再被「哪個方案最好」的問題困擾了。",
      "summary": "三種主流的 macOS 設定備份方案，設計哲學完全不同：symlink 派、copy 派、script 派。沒有最好的工具，只有最適合你的工具。",
      "image": "https://bobochen.dev/_astro/cover.DrrpTa7_.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "macOS",
        "dotfiles",
        "chezmoi",
        "Mackup",
        "開發環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/macos-defaults-export-plist-pitfalls/",
      "url": "https://bobochen.dev/blog/macos-defaults-export-plist-pitfalls/",
      "title": "macOS defaults 備份別踩坑：為什麼 5.3MB 的 plist 不如 130 行的 shell script",
      "content_text": "用 defaults export 備份 Mac 設定聽起來很方便，但打開 plist 一看：5.3MB、1363 個 domain、GPS 座標、廣告 ID、session token。這篇聊為什麼手寫 defaults.sh 才是正確姿勢。",
      "content_html": "最近在整理我的 dotfiles，準備讓新電腦能「一鍵安裝」。用的是 [chezmoi](https://github.com/twpayne/chezmoi) 管設定檔、Brewfile 管軟體安裝，整套流程已經跑得很順了。\n\n但有一件事我一直沒搞定：macOS 的系統偏好設定。\n\n每次換電腦，Dock 要重新設、Finder 要重新調、鍵盤重複速度要重新改⋯⋯這些瑣碎的設定加起來，大概要花半小時到一小時。\n\n所以我想：不如用 `defaults export` 把整台電腦的設定匯出成 plist，跟 dotfiles 放在一起不就好了？\n\n## 背景\n\n一個完整的 macOS 開發環境大概有這三層：\n\n| 層級           | 內容                               | 負責工具            |\n| -------------- | ---------------------------------- | ------------------- |\n| 軟體安裝       | CLI 工具、桌面 App                 | Homebrew (Brewfile) |\n| 系統設定       | dotfiles、shell config、Git config | chezmoi             |\n| macOS 偏好設定 | Dock、Finder、鍵盤速度⋯            | ???                 |\n\n前兩層我都解決了，第三層一直是空的。`defaults export` 看起來就是那個完美答案。\n\n## 發現過程\n\n我跑了這個指令把全部 domain 匯出：\n\n```bash\n# 把所有 domain 的 user defaults 一次 dump 出來\n# 注意：defaults export -g 只會匯出 NSGlobalDomain「一個」domain，不是全部；\n# 要看到全部 domain 得用無參數的 defaults read（或對 defaults domains 列出的每個 domain 逐一 export）\ndefaults read > ~/macos-defaults-backup.txt\n```\n\n得到一個 **5.3MB** 的備份檔。\n\n把它加進 dotfiles repo 之前，我先用 Claude Code 分析了一下內容。這一看嚇了一跳。\n\n### 裡面到底有什麼？\n\n這個 plist 包含 **1,363 個 domain**，大致分成四類：\n\n**1. 系統全域設定（你真正想要的）**\n\n```\nApple Global Domain → AppleInterfaceStyle = Dark\n                    → KeyRepeat = 2\n                    → InitialKeyRepeat = 15\n```\n\nDock 大小、鍵盤速度、深色模式之類的設定。大概佔不到 5%。\n\n**2. Apple 原生 App 設定（部分有用）**\n\n```\ncom.apple.dock   → autohide = true, tilesize = 48\ncom.apple.finder → ShowPathbar = true\n```\n\nFinder、Dock、Safari 的開發者工具⋯⋯這些也是你會想帶到新機的設定。\n\n**3. 第三方 App 的私有資料（大量雜訊）**\n\nMicrosoft Edge 的 A/B test flight flags（上千個實驗旗標）、Mixpanel tracking tokens、各種 app 的 session 狀態⋯⋯這些佔了整個檔案 90% 以上的體積，而且搬到新機器上完全沒意義。\n\n**4. 機敏資料（不該公開的東西）**\n\n這是最嚇人的部分：\n\n| 資料類型           | 內容範例                          |\n| ------------------ | --------------------------------- |\n| 廣告 ID            | `advertisingId = 112e622f...`     |\n| 搜尋 client ID     | `searchClientId = 30754106...`    |\n| GPS 座標           | `location = \"339550.0&5376927.4\"` |\n| Session tokens     | 多個 App 的登入 token             |\n| iCloud sync tokens | Apple 服務的同步 token            |\n\n如果我就這樣 push 到 public GitHub repo⋯⋯嗯，不敢想。\n\n## 具體數據 / 結果\n\n| 方案                    | 檔案大小 | Domain 數 | 可讀性                | 隱私風險            |\n| ----------------------- | -------- | --------- | --------------------- | ------------------- |\n| `defaults export` plist | 5.3 MB   | 1,363     | 極差（二進位 + 雜訊） | 高（含 GPS、token） |\n| 手寫 `defaults.sh`      | 3 KB     | ~15       | 極佳（每行有註解）    | 零                  |\n\n5.3MB 的全量匯出，不如 130 行的手寫 script。\n\n我的 `macos/defaults.sh` 長這樣（節錄）：\n\n```bash\n#!/bin/bash\n\n# 加快鍵盤重複速度\ndefaults write NSGlobalDomain KeyRepeat -int 2\ndefaults write NSGlobalDomain InitialKeyRepeat -int 15\n\n# 停用自動修正（寫程式不需要）\ndefaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool false\n\n# 自動隱藏 Dock\ndefaults write com.apple.dock autohide -bool true\ndefaults write com.apple.dock tilesize -int 48\n\n# 顯示隱藏檔案\ndefaults write com.apple.finder AppleShowAllFiles -bool true\n\n# 顯示路徑列\ndefaults write com.apple.finder ShowPathbar -bool true\n\n# 重啟相關服務\nkillall Dock 2>/dev/null || true\nkillall Finder 2>/dev/null || true\n```\n\n每一行都看得懂在幹嘛，而且只設定我「真正在意」的東西。\n\n### 那原始 plist 要怎麼用？\n\n我把它留在 repo 裡，但標記為「查閱用參考」而不是「直接匯入用」。如果換了新電腦後發現少了什麼設定，可以在 plist 裡搜尋對應的 key，然後手動加到 `defaults.sh`。\n\n```bash\n# 在 plist 裡找特定設定\ngrep -i \"screenshot\" ~/macos-defaults-backup.plist\n```\n\n同時把它加進 `.gitignore`，不 commit 到 public repo：\n\n```bash\necho \"macos-defaults-backup.plist\" >> .gitignore\n```\n\n## 反思\n\n### 技術面\n\n`defaults export` 就像 `mysqldump` 不加 `--where`——它忠實地把所有東西都倒出來，但大部分你不需要，有些還有害。\n\n正確做法是**手動挑選**你在意的設定，寫成一個 shell script。好處：\n\n- **可讀**：每行有註解，未來的自己看得懂在幹嘛\n- **可控**：只動你確定知道效果的設定，不會有意外\n- **安全**：不會意外洩漏隱私資料\n- **可移植**：不同 macOS 版本之間相容性更好（全量 plist 有時候會帶入跟版本綁定的設定）\n\n如果想參考別人精選的設定，[mathiasbynens/dotfiles](https://github.com/mathiasbynens/dotfiles) 的 `.macos` 是業界常用的參考範本。\n\n### 心態面\n\n工程師很容易有「既然能自動化，就全部自動化」的衝動。但有些事情，**精心挑選比全量匯出更有價值**。\n\ndotfiles 的精髓不是「把舊電腦複製一份」，而是「把你的偏好用程式碼表達出來」。就像你不會把整個 `node_modules` commit 進 repo，你也不該把整台電腦的 defaults 無腦倒出來。\n\n### 有趣發現\n\n- Microsoft Edge 光是 A/B test 的 flight flags 就是一整面字串，佔了 plist 裡面巨大的篇幅\n- 我發現自己裝了一個叫 `AnswerDesign.EyeLoveU` 的番茄鐘 App，設定是 work 20min / rest 5min，完全不記得什麼時候裝的\n- plist 裡面有 `com.bobo52310.notionboard.settings`——是我自己開發的 App，看到自己的 bundle ID 出現在系統 defaults 裡面，有種奇妙的感覺",
      "summary": "用 defaults export 備份 Mac 設定聽起來很方便，但打開 plist 一看：5.3MB、1363 個 domain、GPS 座標、廣告 ID、session token。這篇聊為什麼手寫 defaults.sh 才是正確姿勢。",
      "image": "https://bobochen.dev/_astro/cover.CXHCR2lo.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "macOS",
        "dotfiles",
        "chezmoi",
        "開發環境"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/macos-terminal-beginners-guide-not-scary/",
      "url": "https://bobochen.dev/blog/macos-terminal-beginners-guide-not-scary/",
      "title": "終端機不可怕：給非工程師的 macOS Terminal 入門",
      "content_text": "終端機不是工程師專屬工具。這篇用生活化比喻帶你認識 5 個基本指令，從打開 Terminal 到實際操作，讓你不再對黑底白字的畫面感到害怕。",
      "content_html": "你一定在電影裡看過這個畫面：駭客關著燈、戴著連帽外套，面前一片黑底白字的螢幕，游標瘋狂閃爍，手指敲得劈哩啪啦響，幾秒後螢幕跳出一行大大的「ACCESS GRANTED」。\n\n那片看起來很神祕的黑畫面，其實就是**終端機（Terminal）**——而且你的 Mac 裡就內建了一個。\n\n也正因為電影都這樣演，很多人第一次打開它、看到游標在黑底白字上一閃一閃，腦中只會冒出一個念頭：「這是不是只有高手能碰？我隨便打錯一個字，會不會把整台電腦搞壞？」\n\n不會。而且它遠比電影演的還要平易近人——終端機說穿了，只是另一種跟電腦溝通的方式，在某些情況下，還比用滑鼠點來點去快得多。\n\n這篇寫給完全沒用過終端機的人：設計師、行銷、PM、或者任何想裝 Homebrew 卻被第一步「打開終端機」嚇到的朋友。\n\n## 終端機到底是什麼？\n\n你平常用 Mac，打開 Finder 瀏覽檔案、拖拉資料夾、雙擊開啟 App——這些都是透過圖形介面（GUI）在跟電腦溝通。\n\n終端機（Terminal）是另一種溝通方式：用打字的。\n\n如果圖形介面是「去餐廳看菜單、用手指點餐」，終端機就是「直接跟廚師說：我要一份炒飯、不要蔥、加辣」。\n\n結果一樣，都能吃到飯。只是溝通的方式不同。\n\n為什麼有人偏好用說的？因為有些事情用說的比較快。你不用翻好幾頁菜單，不用等服務生過來，直接開口就搞定。終端機也是這樣——很多操作一行指令就完成了，用滑鼠可能要點五六下。\n\n## 怎麼打開終端機\n\nMac 內建就有終端機，不用額外安裝。\n\n**方法一：Spotlight 搜尋**\n按 `Cmd + 空白鍵`（就是你平常搜尋東西的快捷鍵），輸入 `Terminal`，按 Enter。\n\n就這樣。它會打開一個視窗，裡面有一行字和一個閃爍的游標。那行字大概長這樣：\n\n```\nbobo@MacBook-Pro ~ %\n```\n\n這表示「你是 bobo，目前在家目錄，可以開始輸入指令了」。`%` 後面就是你打字的地方。\n\n**方法二：從 Finder 開啟**\n打開 Finder → 應用程式 → 工具程式 → 終端機。\n\n**進階選擇：iTerm2**\nMac 內建的 Terminal 完全夠用，但如果你之後用得比較多，可以試試 [iTerm2](https://iterm2.com/)。它是免費的替代終端機，功能更豐富（分割畫面、搜尋歷史指令等等）。不急，之後再說。\n\n## 5 個最基本的指令\n\n接下來的每個指令，我都會告訴你「這等於在 Finder 裡做什麼」，讓你有個對照。\n\n### 1. `pwd` — 我現在在哪裡？\n\n```bash\npwd\n```\n\n輸出結果像這樣：\n\n```\n/Users/bobo\n```\n\n`pwd` 是 \"print working directory\" 的縮寫，意思是「顯示我目前在哪個資料夾」。\n\n**Finder 對照**：就是你看 Finder 視窗上方的路徑列——告訴你現在打開的是哪個資料夾。\n\n這個指令完全安全，它只是「看一下」，不會改變任何東西。迷路的時候打一下，就知道自己在哪了。\n\n### 2. `ls` — 這裡有什麼東西？\n\n```bash\nls\n```\n\n輸出可能長這樣：\n\n```\nDesktop    Documents  Downloads  Music     Pictures\n```\n\n`ls` 是 \"list\" 的縮寫，意思是「列出目前資料夾裡有什麼」。\n\n**Finder 對照**：就是你打開一個資料夾後，看到裡面所有檔案和子資料夾的清單。\n\n加個參數可以看到更多細節：\n\n```bash\nls -la\n```\n\n這會顯示檔案大小、修改日期、權限等資訊。`-l` 是「用詳細清單顯示」，`-a` 是「連隱藏檔也顯示」。\n\n同樣完全安全，只是在看，不會動到任何檔案。\n\n### 3. `cd` — 移動到另一個資料夾\n\n```bash\ncd Documents\n```\n\n`cd` 是 \"change directory\" 的縮寫，意思是「切換到某個資料夾」。\n\n**Finder 對照**：就是你在 Finder 裡雙擊一個資料夾打開它。\n\n幾個常用的寫法：\n\n```bash\ncd Documents          # 進入目前資料夾裡的 Documents\ncd ~/Desktop          # 直接跳到桌面（~ 代表你的家目錄）\ncd ..                 # 回到上一層（就像按 Finder 的「返回」按鈕）\ncd                    # 不加任何東西，直接回到家目錄\n```\n\n那個 `~` 符號代表你的家目錄（通常是 `/Users/你的使用者名稱`）。你可以把它想成「我家的地址」，不管你在哪裡，`cd ~` 都能帶你回家。\n\n### 4. `mkdir` — 建立新資料夾\n\n```bash\nmkdir my-project\n```\n\n`mkdir` 是 \"make directory\" 的縮寫，意思是「在目前位置建立一個新資料夾」。\n\n**Finder 對照**：就是在 Finder 裡按右鍵 →「新增檔案夾」，然後打個名字。\n\n想一次建好多層？加個 `-p`：\n\n```bash\nmkdir -p projects/2026/march\n```\n\n這會一次建出三層資料夾，就算 `projects` 和 `2026` 還不存在也沒關係。在 Finder 裡你得一層一層建，終端機一行搞定。\n\n### 5. `cp` — 複製檔案\n\n```bash\ncp notes.txt notes-backup.txt\n```\n\n`cp` 是 \"copy\" 的縮寫。上面這行的意思是「把 notes.txt 複製一份，叫做 notes-backup.txt」。\n\n**Finder 對照**：就是選一個檔案 → `Cmd + C` → `Cmd + V`，然後改名。\n\n想複製整個資料夾？加個 `-r`（recursive，遞迴的意思，就是「連裡面的東西也一起複製」）：\n\n```bash\ncp -r my-project my-project-backup\n```\n\n## 哪些指令是安全的？哪些要小心？\n\n這大概是新手最擔心的問題了。讓我直接列清楚。\n\n### 唯讀、安全的指令（隨便打都不會壞）\n\n| 指令             | 做什麼                                                            |\n| ---------------- | ----------------------------------------------------------------- |\n| `pwd`            | 顯示目前路徑                                                      |\n| `ls`             | 列出檔案清單                                                      |\n| `cat 檔案名稱`   | 顯示檔案內容（cat 是 \"concatenate\" 的縮寫，但通常就拿來看檔案用） |\n| `which 程式名稱` | 查某個程式安裝在哪裡                                              |\n| `man 指令名稱`   | 看某個指令的說明書（按 `q` 離開）                                 |\n\n這些指令只會「看」，不會「改」。就算打錯了，頂多是出現一行錯誤訊息，不會發生什麼壞事。\n\n### 要小心的指令\n\n| 指令            | 為什麼要小心                                       |\n| --------------- | -------------------------------------------------- |\n| `rm 檔案名稱`   | 刪除檔案，而且不會進垃圾桶，直接消失               |\n| `rm -rf 資料夾` | 刪除整個資料夾和裡面所有東西，一樣不進垃圾桶       |\n| `sudo 指令`     | 用管理員權限執行，可以動系統檔案。會要求輸入密碼   |\n| `mv`            | 移動或更名檔案。如果目的地已有同名檔案，會直接覆蓋 |\n\n最重要的一條規則：**`rm` 刪除的檔案不會進垃圾桶**。在 Finder 裡刪東西你還能從垃圾桶撈回來，但 `rm` 是真的刪掉。所以用之前多看一眼，確定你刪的是對的檔案。\n\n`sudo` 的意思是 \"superuser do\"——用最高權限執行。有些安裝指令需要它，但如果你不確定一個指令在幹嘛，加上 `sudo` 之前請三思。它就像你拿到了整棟大樓的萬能鑰匙，能開任何門，當然也能搞砸任何房間。\n\n## 從網路上複製指令時，請注意這些事\n\n裝軟體、跟著教學做的時候，免不了要從網頁上複製指令貼到終端機。這裡有幾個保護自己的習慣：\n\n**1. 先讀再貼**\n\n不要看到指令就無腦複製貼上。至少掃一眼裡面有沒有 `rm`、`sudo`、或是你看不懂的部分。如果看不懂整行在幹嘛，Google 一下或問 AI 都好。\n\n**2. 小心隱藏字元**\n\n有些網站複製文字時會帶入看不見的字元。安全起見，可以先貼到純文字編輯器（像「記事本」或 VS Code）確認內容，再貼到終端機。\n\n**3. 注意指令來源**\n\n官方文件、GitHub 上的知名專案，通常是安全的。但隨便某個論壇裡的一行指令？稍微謹慎一點。\n\n**4. 不確定就加 `echo`**\n\n在指令前面加上 `echo`，它只會「印出這行字」而不會真的執行。例如：\n\n```bash\necho rm -rf ~/Downloads/temp\n```\n\n這只會顯示 `rm -rf ~/Downloads/temp` 這串文字，不會真的刪除任何東西。先看一下指令長什麼樣子，確認沒問題再拿掉 `echo` 執行。\n\n## 實戰練習：動手試試看\n\n理論講夠了，來實際操作。打開終端機，跟著做一次：\n\n**第一步：確認你在哪裡**\n\n```bash\npwd\n```\n\n你應該會看到類似 `/Users/你的名字` 的路徑。\n\n**第二步：建立一個練習用的資料夾**\n\n```bash\nmkdir terminal-practice\n```\n\n**第三步：進入這個資料夾**\n\n```bash\ncd terminal-practice\n```\n\n再打一次 `pwd` 確認——你應該會看到路徑變成 `/Users/你的名字/terminal-practice`。\n\n**第四步：建立一個文字檔**\n\n```bash\necho \"哈囉，這是我用終端機建立的第一個檔案！\" > hello.txt\n```\n\n`echo` 會把引號裡的文字「輸出」，`>` 的意思是「把輸出寫進檔案裡」。這行合在一起就是「建立一個叫 hello.txt 的檔案，內容是那句話」。\n\n**Finder 對照**：就像你打開文字編輯器，打一行字，然後存檔。\n\n**第五步：確認檔案建好了**\n\n```bash\nls\n```\n\n你應該會看到 `hello.txt`。\n\n**第六步：查看檔案內容**\n\n```bash\ncat hello.txt\n```\n\n終端機會印出：`哈囉，這是我用終端機建立的第一個檔案！`\n\n**第七步：複製一份備份**\n\n```bash\ncp hello.txt hello-backup.txt\n```\n\n再打一次 `ls`，你會看到兩個檔案。\n\n**恭喜，你剛剛用終端機完成了建資料夾、建檔案、看內容、複製檔案。** 整個過程沒有碰到滑鼠。\n\n想在 Finder 裡確認嗎？打開 Finder，進入你的家目錄，你會看到 `terminal-practice` 資料夾就在那裡——終端機和 Finder 看到的是同一個世界，只是觀看的角度不同。\n\n## 清理練習檔案（選做）\n\n如果你想刪掉練習用的檔案和資料夾：\n\n```bash\ncd              # 先回到家目錄\nrm -r terminal-practice   # 刪除整個練習資料夾\n```\n\n注意：這裡用了 `rm -r`，它會刪除資料夾和裡面所有東西，而且不會進垃圾桶。因為只是練習檔案，刪掉沒關係。但對於重要的檔案，務必確認清楚再刪。\n\n## 常見問題\n\n**Q：打錯指令會怎樣？**\nA：通常只會出現一行錯誤訊息，像是 `command not found`（找不到這個指令）或 `No such file or directory`（沒有這個檔案或資料夾）。電腦不會爆炸，你不會搞壞什麼。按上方向鍵（↑）可以叫出上一條指令，修改後重新執行。\n\n**Q：怎麼取消正在執行的指令？**\nA：按 `Ctrl + C`。這是終端機裡的「我不要了，停下來」按鈕。\n\n**Q：畫面太亂了怎麼辦？**\nA：輸入 `clear` 或按 `Cmd + K`，畫面就會清乾淨。指令歷史還在，只是畫面變乾淨了。\n\n**Q：怎麼看之前打過什麼指令？**\nA：按上方向鍵（↑）可以一條一條往回翻。或者輸入 `history` 看完整紀錄。\n\n## 下一步：裝 Homebrew\n\n如果你是因為想安裝 Homebrew 才點進這篇文章，那恭喜——你已經準備好了。\n\nHomebrew 是 macOS 上最主流的套件管理工具，可以讓你用一行指令安裝各種軟體，不用再去官網下載 .dmg 檔案。裝法就是打開終端機、貼一行指令、等它跑完。\n\n我在[新 Mac 開箱後，我怎麼 30 分鐘裝好所有東西](/blog/mac-setup-automation-brewfile-dotfiles-guide)裡詳細寫了 Homebrew 的使用方式，以及怎麼用它一鍵安裝所有軟體。\n\n現在你已經不怕終端機了，那篇裡的每一行指令你都看得懂。\n\n---\n\n終端機真的沒那麼可怕。它就是一個打字版的 Finder——你用文字告訴電腦要做什麼，電腦用文字回答你。\n\n一開始可能會覺得慢，因為你需要記指令。但用一陣子之後，你會發現很多事情用終端機做比用滑鼠快得多。而且，當你需要照著教學安裝某個工具、或是跟工程師溝通的時候，你會慶幸自己懂這些基本功。\n\n五個指令而已。`pwd`、`ls`、`cd`、`mkdir`、`cp`。記住這五個，你就已經可以在終端機裡自由移動了。",
      "summary": "終端機不是工程師專屬工具。這篇用生活化比喻帶你認識 5 個基本指令，從打開 Terminal 到實際操作，讓你不再對黑底白字的畫面感到害怕。",
      "image": "https://bobochen.dev/_astro/cover.C11QAwD8.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "macOS",
        "終端機",
        "入門",
        "Homebrew"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/my-brewfile-50-tools-fullstack-engineer/",
      "url": "https://bobochen.dev/blog/my-brewfile-50-tools-fullstack-engineer/",
      "title": "我的 Brewfile 公開：一個全端工程師裝了哪 50 個工具",
      "content_text": "公開我的完整 Brewfile，按分類介紹 50 個開發、終端機、生產力工具，附上選擇理由和實際使用情境，讓你直接複製修改。",
      "content_html": "每次看到別人的桌面截圖或是終端機配置文，我都會忍不住多看幾眼——不是看外觀，是想知道他裝了什麼。\n\n工程師的工具箱很私密，卻又很值得分享。每個工具的選擇背後，都是一段「踩坑 → 尋找替代品 → 找到真愛」的故事。有些工具你可能天天用但從沒想過有更好的替代品，有些你根本不知道存在。\n\n所以我決定把自己的 Brewfile 整個公開。這不是「最佳工具清單」——是我一個全端工程師，每天實際在用的 50 個工具，附上我為什麼選它、拿它做什麼。\n\n如果你剛好在整理自己的開發環境，或是想看看別人的工具箱裡有什麼寶物，這篇就是寫給你的。\n\n## 開發環境基礎\n\n這些是不管做什麼專案都需要的地基。\n\n### git\n\n不用多說，版本控制的唯一選擇。Homebrew 裝的版本通常比 macOS 內建的新很多，支援更多功能。\n\n### node\n\n我的主力語言是 TypeScript，所以 Node.js 是必裝的。不過我實際上不太直接用 Homebrew 裝的版本，而是透過 mise 來管理——等一下會講到。\n\n### python\n\n寫腳本、跑資料處理、偶爾訓練個小模型。Python 也是透過 mise 管版本，但 Homebrew 裝一個當系統預設還是方便。\n\n### go\n\n用 Go 寫過幾個 CLI 工具和 API server。Go 的編譯速度和部署的簡單程度讓我很喜歡——一個 binary 丟上去就跑了，不用煩惱 runtime。\n\n### rust\n\n說實話，Rust 我還在學習階段。但越來越多好用的 CLI 工具都是 Rust 寫的（後面會出現一大堆），裝個 Rust 工具鏈備著也好。\n\n### [mise](https://github.com/jdx/mise)\n\n原本用 nvm 管 Node 版本、pyenv 管 Python 版本，各自一套指令、各自一個設定檔，搞到頭很大。mise 一個工具搞定所有語言的版本管理，一個 `.mise.toml` 走天下。從 asdf 轉過來的，速度快非常多。\n\n### zsh + [starship](https://github.com/starship/starship)\n\nmacOS 預設就是 zsh，我之前用 oh-my-zsh 用了好幾年，後來覺得啟動速度越來越慢，加的 plugin 又多到自己都搞不清楚。換成 starship 之後，prompt 設定用一個 `starship.toml` 就搞定，啟動速度飛快，跨平台還能共用設定。\n\n### cmake\n\n偶爾需要從原始碼編譯一些 C/C++ 的東西。不常用，但需要的時候沒有會很痛苦。\n\n## 終端機工具\n\n這一區是我最愛的部分。這些工具讓終端機從「堪用」變成「好用到不想離開」。\n\n### [Warp](https://www.warp.dev/)\n\n原本是 iTerm2 的忠實用戶，用了大概五年。轉到 Warp 是因為它的 AI 整合和 block-based 的輸出概念——每個指令的輸出是獨立的區塊，可以單獨複製、摺疊、搜尋。加上內建的 AI 可以直接問「怎麼用 ffmpeg 轉檔」，不用再開瀏覽器。\n\n### [bat](https://github.com/sharkdp/bat)\n\n取代 `cat`。最大的好處是自動語法高亮和行號，看設定檔、看程式碼都舒服很多。我在 `.zshrc` 裡直接 `alias cat=bat`。\n\n### [eza](https://github.com/eza-community/eza)\n\n取代 `ls`。支援 Git 狀態顯示、圖示、樹狀結構。`eza --tree --level=2 --git` 是我最常用的指令之一，一眼就能看到專案結構和哪些檔案有改動。原本用 exa，但它停止維護了，社群 fork 成了 eza。\n\n### [fd](https://github.com/sharkdp/fd)\n\n取代 `find`。語法直覺到不行——`fd \"\\.md$\"` 就能找到所有 Markdown 檔案，不用再記 `find . -name \"*.md\"` 這種反人類語法。速度也快非常多。\n\n### [ripgrep](https://github.com/BurntSushi/ripgrep)（rg）\n\n取代 `grep`。快到離譜，自動忽略 `.gitignore` 裡的檔案，預設就是遞迴搜尋。在大型專案裡搜程式碼，ripgrep 跟 grep 的速度差距是秒跟分鐘的差距。\n\n### [zoxide](https://github.com/ajeetdsouza/zoxide)\n\n取代 `cd`。它會記住你去過的目錄，之後只要打 `z blog` 就能跳到 `/Users/bobochen/Desktop/github/bobo52310/bobo-blog-2026`。不用再打完整路徑，也不用設一堆 alias。\n\n### [fzf](https://github.com/junegunn/fzf)\n\n模糊搜尋神器。`Ctrl+R` 搜歷史指令、`Ctrl+T` 搜檔案、搭配其他指令做互動式選取。裝了就回不去了。\n\n### [btop](https://github.com/aristocratos/btop)\n\n取代 `htop`，又取代了 `top`。CPU、記憶體、網路、磁碟一目瞭然，介面漂亮到可以當桌布。當 Docker container 吃掉一堆記憶體時，我第一個開的就是它。\n\n### jq\n\nJSON 的瑞士刀。API 回傳一坨 JSON，用 `curl ... | jq '.data[0].name'` 就能精準抓到你要的欄位。寫 shell script 處理 JSON 資料時更是離不開它。\n\n### [httpie](https://httpie.io/)\n\n取代 `curl` 來做 API 測試。語法更人性化：`http POST api.example.com/users name=Bobo`，自動格式化 JSON 輸出、自動語法高亮。curl 我還是會用，但快速測 API 的時候 httpie 真的舒服很多。\n\n## Git 相關\n\nGit 本身已經很強了，但這些工具讓它更好用。\n\n### [lazygit](https://github.com/jesseduffield/lazygit)\n\nTUI 的 Git 客戶端。interactive rebase、解 merge conflict、看 diff、cherry-pick，全部在終端機裡用鍵盤操作完成。自從用了 lazygit，我幾乎不再用 `git add -p` 或是開 VS Code 的 Git 面板了。\n\n### [gh](https://cli.github.com/)\n\nGitHub 官方的 CLI 工具。開 PR、看 CI 狀態、review code、管理 issue，全部在終端機裡搞定。`gh pr create`、`gh run watch` 是我每天都會用的指令。\n\n### [delta](https://github.com/dandavison/delta)\n\n讓 `git diff` 的輸出變漂亮。side-by-side 對照、語法高亮、行號，設好 `.gitconfig` 之後所有 diff 都會自動套用。一次設定，永久享受。\n\n### git-lfs\n\n大檔案版本控制。設計師丟給我的 PSD、Figma 匯出的大圖、影片素材，都用 git-lfs 追蹤，避免 repo 肥到推不動。\n\n### git-flow\n\n雖然現在很多團隊改用 trunk-based development，但我有些專案還是用 git-flow 的分支模型。這個 CLI 工具讓 feature/release/hotfix 的分支操作標準化。\n\n## 編輯器與 IDE\n\n### [VS Code](https://code.visualstudio.com/)\n\n主力編輯器，沒什麼好說的。Extension 生態系太強了，幾乎什麼語言、什麼框架都有支援。\n\n### [Cursor](https://cursor.sh/)\n\nAI-first 的程式編輯器，基於 VS Code 的 fork。我現在大約 60% 的時間用 Cursor、40% 用 VS Code。Cursor 在需要 AI 輔助寫程式的時候特別好用——Composer 功能可以讓 AI 直接改多個檔案，比 Copilot 更激進也更方便。\n\n### [neovim](https://neovim.io/)\n\n快速編輯設定檔、在 SSH 進去的遠端機器上改東西。不是主力編輯器，但在終端機裡需要改個檔案的時候，neovim 的啟動速度和鍵盤操作效率是無可取代的。\n\n## 容器與雲端\n\n### [OrbStack](https://orbstack.dev/)\n\n取代 Docker Desktop。同樣的功能，但啟動速度快非常多、吃的記憶體少非常多。原本 Docker Desktop 動不動吃掉 4-6 GB RAM，換成 OrbStack 之後降到 1-2 GB。這大概是我這份清單裡 CP 值最高的替換。\n\n### kubectl\n\nKubernetes 的 CLI 工具。管理 K8s 叢集、部署、查看 pod 狀態都靠它。\n\n### [k9s](https://github.com/derailed/k9s)\n\nKubernetes 的 TUI 管理工具，跟 lazygit 之於 git 一樣的角色。看 pod log、exec 進 container、看 resource 使用量，全部用鍵盤操作，比 `kubectl` 打一堆指令快多了。\n\n### terraform\n\nInfrastructure as Code。雲端資源全部用程式碼定義，可以版本控制、可以 code review、可以重複建立。用過就回不去手動點 console 了。\n\n### awscli\n\nAWS 的 CLI 工具。管 S3、查 CloudWatch log、操作 Lambda，日常雲端操作都靠它。\n\n## 生產力工具\n\n這些不是開發工具，但對工作效率的影響一樣巨大。\n\n### [Raycast](https://www.raycast.com/)\n\n取代 macOS 內建的 Spotlight。啟動 app、計算機、剪貼簿歷史、視窗管理、snippet 展開、搜尋文件⋯⋯Raycast 一個打十個。裝了之後 Spotlight 就再也沒開過了。我特別愛它的 clipboard history 和 window management——不用再另外裝視窗管理工具。\n\n### [Arc](https://arc.net/)\n\n主力瀏覽器。Space 功能可以把工作和個人的分頁完全隔開，sidebar 的分頁管理也比傳統的 tab bar 好用。原本用 Chrome 用了十幾年，轉過來之後完全回不去。\n\n### [Notion](https://www.notion.so/)\n\n知識庫、專案管理、文件協作的主力工具。我的部落格文章草稿、side project 的規劃、讀書筆記全部放在 Notion。\n\n### [Obsidian](https://obsidian.md/)\n\n個人筆記和知識圖譜。跟 Notion 的差別是：Obsidian 是純本地的 Markdown 檔案，不依賴任何雲端服務，啟動速度快、離線也能用。我用它做每日筆記和永久筆記，Notion 則拿來做需要協作的東西。\n\n### [1Password](https://1password.com/)\n\n密碼管理器。所有帳號密碼、SSH key、API token、信用卡資訊都放在裡面。CLI 版的 `op` 還可以在 shell script 裡安全地取用 secret，不用把密碼寫在程式碼裡。\n\n### [CleanShot X](https://cleanshot.com/)\n\n取代 macOS 內建截圖。標注、馬賽克、滾動截圖、錄 GIF、OCR 文字辨識都有。寫技術文章的時候截圖標注特別好用，省了一堆後製的時間。\n\n### [Karabiner-Elements](https://github.com/pqrs-org/Karabiner-Elements)\n\n鍵盤自訂工具。我把 Caps Lock 改成了 Hyper key（同時按下 Ctrl+Option+Cmd+Shift），搭配 Raycast 的快捷鍵設定，一顆鍵就能觸發各種操作。這個設定改變了我整個鍵盤的使用方式。\n\n### [IINA](https://iina.io/)\n\nmacOS 上最好的影片播放器。原生 UI、支援幾乎所有格式、觸控板手勢操作。取代 VLC 之後就沒回去了。\n\n## 通訊與協作\n\n### [Slack](https://slack.com/)\n\n工作溝通主力。大部分團隊和社群都在 Slack 上。\n\n### [Discord](https://discord.com/)\n\n技術社群和開源專案的討論主要在 Discord。參與的幾個開源社群都有自己的 Discord server。\n\n### Line\n\n台灣的國民通訊軟體，跟朋友家人聯繫還是離不開它。\n\n### [Zoom](https://zoom.us/)\n\n視訊會議。雖然 Google Meet 也很常用，但 Zoom 的穩定度和功能還是比較完整，特別是錄影和虛擬背景。\n\n### [Figma](https://www.figma.com/)\n\n設計協作工具。跟設計師溝通、看設計稿、抓標注和間距都在 Figma 上。就算是自己做 side project，我也會先在 Figma 畫 wireframe。\n\n## 其他好用的\n\n這些工具各自解決一個小痛點，但加在一起讓整個 Mac 體驗好非常多。\n\n### ffmpeg\n\n影音處理的瑞士刀。轉檔、壓縮、截圖、合併影片、抽音軌，一行指令搞定。GUI 軟體能做的它都能做，而且可以寫成腳本批次處理。\n\n### [ImageOptim](https://imageoptim.com/)\n\n圖片壓縮。部落格文章的圖片上傳前都會先丟進去壓一下，通常可以減少 50-70% 的檔案大小而幾乎看不出畫質差異。\n\n### [Keka](https://www.keka.io/)\n\n解壓縮工具。支援的格式比內建的封存工具多很多，7z、RAR、TAR 都能處理。\n\n### [MonitorControl](https://github.com/MonitorControl/MonitorControl)\n\n外接螢幕亮度和音量控制。用 Mac 接外接螢幕的人一定懂那個痛——內建的亮度鍵只能控制筆電螢幕。MonitorControl 讓你用鍵盤直接調外接螢幕的亮度，跟內建螢幕一樣方便。\n\n### [mas](https://github.com/mas-cli/mas)\n\nMac App Store 的 CLI 工具。搭配 Brewfile 使用，可以把 App Store 裡買的 App 也納入自動化安裝。\n\n## 完整 Brewfile\n\n說了這麼多，這是我的完整 Brewfile。你可以直接複製一份，把不需要的刪掉、加上你自己的工具：\n\n```ruby\n# ============================================\n# Bobo's Brewfile\n# 最後更新：2026-03-13\n# ============================================\n\n# --- Taps ---\ntap \"homebrew/bundle\"\n\n# === 開發環境基礎 ===\nbrew \"git\"\nbrew \"node\"\nbrew \"python\"\nbrew \"go\"\nbrew \"rust\"\nbrew \"mise\"                   # 多語言版本管理\nbrew \"starship\"               # 跨平台 shell prompt\nbrew \"cmake\"\n\n# === 終端機工具 ===\nbrew \"bat\"                    # 取代 cat，語法高亮\nbrew \"eza\"                    # 取代 ls，Git 整合\nbrew \"fd\"                     # 取代 find，語法直覺\nbrew \"ripgrep\"                # 取代 grep，超快搜尋\nbrew \"zoxide\"                 # 取代 cd，智慧跳轉\nbrew \"fzf\"                    # 模糊搜尋\nbrew \"btop\"                   # 系統監控 TUI\nbrew \"jq\"                     # JSON 處理\nbrew \"httpie\"                 # HTTP 測試\n\n# === Git 相關 ===\nbrew \"lazygit\"                # Git TUI 客戶端\nbrew \"gh\"                     # GitHub CLI\nbrew \"git-delta\"              # 更好的 diff\nbrew \"git-lfs\"                # 大檔案版本控制\nbrew \"git-flow\"               # Git-flow 分支模型\n\n# === 容器與雲端 ===\nbrew \"kubectl\"                # Kubernetes CLI\nbrew \"k9s\"                    # Kubernetes TUI\nbrew \"terraform\"              # Infrastructure as Code\nbrew \"awscli\"                 # AWS CLI\n\n# === 其他 CLI 工具 ===\nbrew \"ffmpeg\"                 # 影音處理\nbrew \"mas\"                    # Mac App Store CLI\n\n# === GUI 應用程式 (cask) ===\n\n# 編輯器與 IDE\ncask \"visual-studio-code\"\ncask \"cursor\"\n\n# 終端機\ncask \"warp\"\n\n# 容器\ncask \"orbstack\"               # 取代 Docker Desktop\n\n# 瀏覽器\ncask \"arc\"\n\n# 生產力\ncask \"raycast\"                # 取代 Spotlight\ncask \"notion\"\ncask \"obsidian\"\ncask \"1password\"\ncask \"cleanshot\"              # 截圖工具\ncask \"karabiner-elements\"     # 鍵盤自訂\n\n# 通訊\ncask \"slack\"\ncask \"discord\"\ncask \"line\"\ncask \"zoom\"\ncask \"figma\"\n\n# 工具\ncask \"iina\"                   # 影片播放器\ncask \"imageoptim\"             # 圖片壓縮\ncask \"keka\"                   # 解壓縮\ncask \"monitorcontrol\"         # 外接螢幕控制\ncask \"neovim\"                 # 終端機編輯器\n```\n\n## 幾個選擇背後的思路\n\n回頭看這份清單，我發現自己的選擇有幾個共同的模式：\n\n**能用鍵盤就不用滑鼠。** lazygit、k9s、neovim、Raycast——都是鍵盤優先的工具。不是故意要走 hardcore 路線，是因為手不用離開鍵盤真的快很多。\n\n**Rust 寫的 CLI 工具通常比較好。** bat、eza、fd、ripgrep、zoxide、starship、delta——全部是 Rust 寫的。速度快、binary 小、不需要裝 runtime。這不是 Rust 信仰，是實際體驗的結論。\n\n**花錢買好工具是值得的。** CleanShot X、1Password、Raycast Pro——這些付費工具每天幫我省下的時間遠超過它們的價格。工程師的時薪換算一下，一個月省 30 分鐘就值回票價了。\n\n**不怕換工具。** iTerm2 → Warp、Docker Desktop → OrbStack、Chrome → Arc、oh-my-zsh → starship。只要新工具明顯更好，學習成本就是值得的投資。\n\n## 最後\n\n這份 Brewfile 不是標準答案——每個人的工作流程不同，需要的工具也不同。但我相信看看別人的工具箱，一定能發現幾個你沒想過但其實很需要的東西。\n\n如果你也有一份自己的 Brewfile，或是有什麼工具覺得「不能沒有它」，歡迎留言分享。最好的工具推薦，永遠來自實際在用的人。",
      "summary": "公開我的完整 Brewfile，按分類介紹 50 個開發、終端機、生產力工具，附上選擇理由和實際使用情境，讓你直接複製修改。",
      "image": "https://bobochen.dev/_astro/cover.COCWNXh1.webp",
      "date_published": "2026-03-13T00:00:00.000Z",
      "tags": [
        "macOS",
        "Homebrew",
        "開發工具",
        "效率工具",
        "全端工程師"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/giscus-astro-setup-guide/",
      "url": "https://bobochen.dev/blog/giscus-astro-setup-guide/",
      "title": "5 分鐘幫 Astro 部落格加上 Giscus 留言功能",
      "content_text": "從零開始在 Astro 部落格加上 Giscus 留言系統，包含 GitHub Discussions 設定、Astro 元件建立、Dark Mode 同步與 View Transitions 踩坑筆記。",
      "content_html": "比較了幾個留言系統後，我決定用 [Giscus](https://github.com/giscus/giscus)——直接用 GitHub Discussions 當後端，$0、零維運。這篇直接來實作——從零到留言功能上線，真的只要 5 分鐘。\n\n> **⚠️ 開始之前：你的 Repo 必須是 Public**\n>\n> Giscus 依賴 GitHub Discussions，而 Discussions 只在 **public repo** 上運作。如果你的 blog repo 是 private，有兩條路：\n>\n> 1. **把 blog repo 改成 public**（推薦）— 最簡單，留言和程式碼在同一處\n> 2. **另建一個 public repo**（例如 `your-name/blog-comments`）— 原始碼保持 private，Giscus 指向新 repo\n>\n> 大多數部落格作者選方案 1，因為部落格原始碼公開沒有安全風險。如果 repo 裡有 API keys 或私人筆記，才需要方案 2。\n\n## Step 1：開啟 GitHub Discussions\n\n到你的 blog repo → Settings → Features → 勾選 **Discussions**。\n![GitHub repo 設定頁面開啟 Discussions 功能](giscus-repo-settings.webp)\n這一步讓你的 repo 有了 Discussions 功能，Giscus 會把每篇文章的留言存成一個 Discussion。\n\n## Step 2：安裝 Giscus App\n\n到 [github.com/apps/giscus](https://github.com/apps/giscus) → Install → 選擇你的 blog repo。\n![安裝 Giscus GitHub App 並選擇 repo](giscus-app-install.webp)\n這個 GitHub App 負責幫 Giscus 在你的 repo 裡建立和管理 Discussions。\n\n## Step 3：產生設定\n\n到 [giscus.app](https://giscus.app/) 填入你的 repo 名稱，選擇：\n\n- **頁面與 Discussion 的對應方式**：推薦 `pathname`（用文章路徑對應，最直覺）\n- **Discussion 分類**：推薦先到 repo 建一個 `Blog Comments` 分類，再選它\n- **主題**：推薦 `preferred_color_scheme`（自動跟系統深淺色）\n\n填完後，網頁底部會自動產生一段 `<script>` tag，裡面包含你的 `data-repo-id` 和 `data-category-id`。\n\n## Step 4：建立 Astro 元件\n\n在 `src/components/blog/` 底下建立 `Comments.astro`：\n\n```astro\n---\n// src/components/blog/Comments.astro\n---\n\n<section class=\"giscus-wrapper\">\n  <script\n    src=\"https://giscus.app/client.js\"\n    data-repo=\"你的帳號/你的repo\"\n    data-repo-id=\"你的repo-id\"\n    data-category=\"Blog Comments\"\n    data-category-id=\"你的category-id\"\n    data-mapping=\"pathname\"\n    data-strict=\"0\"\n    data-reactions-enabled=\"1\"\n    data-emit-metadata=\"0\"\n    data-input-position=\"top\"\n    data-theme=\"preferred_color_scheme\"\n    data-lang=\"zh-TW\"\n    crossorigin=\"anonymous\"\n    async></script>\n</section>\n```\n\n把 Step 3 產生的值填進對應的 `data-*` 屬性。\n\n## Step 5：嵌入文章頁面\n\n在你的文章 layout（例如 `BlogLayout.astro`）底部加上：\n\n```astro\n---\nimport Comments from '../components/blog/Comments.astro';\n---\n\n<!-- 文章內容 -->\n<article>\n  <slot />\n</article>\n\n<!-- 留言區 -->\n<Comments />\n```\n\n推上去，完成！\n\n## 3 分鐘快速上手（裝完之後）\n\n1. **發布後測試**：開啟任一文章頁面，滾到底部應該看到 Giscus 留言區\n2. **第一則留言**：用你自己的 GitHub 帳號留言測試，確認 Discussion 有正確建立\n   ![GitHub Discussions 頁面顯示留言已建立](giscus-discussion-tab.webp)\n3. **到 GitHub 確認**：回到 repo → Discussions → Blog Comments 分類，你會看到一個新的 Discussion 對應到那篇文章的路徑\n   ![Giscus 留言區實際顯示效果](giscus-comment-demo.webp)\n\n如果留言區沒出現，打開 DevTools Console 看有沒有錯誤訊息——最常見的原因是 `data-repo-id` 或 `data-category-id` 填錯。\n\n## 踩坑筆記\n\n### View Transitions 讓留言區消失\n\n如果你的 Astro 站有開 View Transitions，頁面切換時 Giscus 的 `<iframe>` 不會重新載入，留言區會空白。\n\n解法是監聽 `astro:page-load` 事件，在頁面載入時重新渲染：\n\n```html\n<script>\n  document.addEventListener('astro:page-load', () => {\n    const container = document.querySelector('.giscus-wrapper');\n    if (container) {\n      const existingIframe = container.querySelector('iframe');\n      if (existingIframe) existingIframe.remove();\n\n      const script = document.createElement('script');\n      script.src = 'https://giscus.app/client.js';\n      script.setAttribute('data-repo', '你的帳號/你的repo');\n      // ...其他 data 屬性\n      script.crossOrigin = 'anonymous';\n      script.async = true;\n      container.appendChild(script);\n    }\n  });\n</script>\n```\n\n### Dark Mode 不同步\n\n`preferred_color_scheme` 跟的是**系統**設定，不是你網站自己的 dark mode toggle。如果你的網站有自訂深淺色切換，需要用 `postMessage` 跟 Giscus 的 iframe 溝通：\n\n```javascript\nfunction setGiscusTheme(theme) {\n  const iframe = document.querySelector('iframe.giscus-frame');\n  if (iframe) {\n    iframe.contentWindow.postMessage({ giscus: { setConfig: { theme } } }, 'https://giscus.app');\n  }\n}\n\n// 在你的 dark mode toggle handler 中呼叫\nsetGiscusTheme(isDark ? 'dark' : 'light');\n```\n\n### Repo 必須是 Public\n\nGitHub Discussions 只在 public repo 上運作。如果你的 blog repo 是 private，留言區會顯示「Discussion not found」。\n\n你有兩個選擇：\n\n| 方案      | 做法                          | 優點               | 缺點               |\n| --------- | ----------------------------- | ------------------ | ------------------ |\n| A（推薦） | 把 blog repo 改成 public      | 最簡單，一處管理   | 原始碼公開         |\n| B         | 另建一個 public repo 專放留言 | 原始碼保持 private | 多一個 repo 要維護 |\n\n如果選方案 B，Step 3 填入的 repo 名稱要改成那個新的 public repo，其他步驟不變。",
      "summary": "從零開始在 Astro 部落格加上 Giscus 留言系統，包含 GitHub Discussions 設定、Astro 元件建立、Dark Mode 同步與 View Transitions 踩坑筆記。",
      "image": "https://bobochen.dev/_astro/cover.BfiP868g.webp",
      "date_published": "2026-03-12T00:00:00.000Z",
      "tags": [
        "Giscus",
        "Astro",
        "部落格",
        "教學",
        "Cloudflare"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/git-worktree-beginner-guide/",
      "url": "https://bobochen.dev/blog/git-worktree-beginner-guide/",
      "title": "Git Worktree 入門 — 不用 stash 的多分支並行開發",
      "content_text": "還在用 git stash 切分支嗎？Git Worktree 讓你同時 checkout 多個分支到不同目錄，徹底消除 stash 的認知負擔。",
      "content_html": "## 背景\n\n你有沒有遇過這種情況：正在改一個 feature 改到一半，PM 突然說線上有 bug 要修。\n\n傳統做法通常是：\n\n1. `git stash` 暫存手上的改動\n2. `git checkout hotfix-branch`\n3. 修完 bug，commit，push\n4. `git checkout feature-branch`\n5. `git stash pop` 把改動拿回來\n6. 然後發現 stash 衝突了...\n\n或者更暴力一點：直接 commit 一個 `WIP: 先存一下` 的 commit，之後再 squash。這兩種做法都不理想 — stash 容易忘、WIP commit 污染歷史。\n\n## 什麼是 Git Worktree\n\nGit worktree 讓你把同一個 repo 的不同分支，同時 checkout 到不同的目錄。\n\n```bash\n# 你的主要工作目錄\n~/projects/my-app/          ← main branch\n\n# 建立一個 worktree 來修 hotfix\ngit worktree add ../my-app-hotfix hotfix/urgent-fix\n\n# 現在你有兩個目錄，各自獨立\n~/projects/my-app/          ← 繼續改 feature（不用 stash）\n~/projects/my-app-hotfix/   ← 修 hotfix\n```\n\n重點：**兩個目錄共享同一個 `.git`**。commit history、remote、config 都是同一份。但工作目錄是獨立的，不會互相干擾。\n\n## 基本操作\n\n### 建立 worktree\n\n```bash\n# 從既有分支建立\ngit worktree add ../my-app-hotfix hotfix/urgent-fix\n\n# 建立新分支 + worktree（一步到位）\ngit worktree add -b feature/new-thing ../my-app-new-feature\n```\n\n### 列出所有 worktree\n\n```bash\ngit worktree list\n# /Users/bobo/projects/my-app              abc1234 [main]\n# /Users/bobo/projects/my-app-hotfix       def5678 [hotfix/urgent-fix]\n```\n\n### 刪除 worktree\n\n```bash\n# 先刪目錄，再清理\nrm -rf ../my-app-hotfix\ngit worktree prune\n\n# 或一步搞定\ngit worktree remove ../my-app-hotfix\n```\n\n## 什麼時候該用 Worktree\n\n| 情境                         | 傳統做法               | Worktree 做法               |\n| ---------------------------- | ---------------------- | --------------------------- |\n| Feature 改到一半要修 hotfix  | stash → checkout → pop | 開新 worktree，直接修       |\n| 同時跑兩個分支的測試         | 切來切去               | 兩個目錄各跑各的            |\n| Code review 時想看完整的分支 | checkout 過去看        | 開 worktree，用編輯器直接開 |\n| 比較兩個分支的行為差異       | 切來切去或用 diff      | 兩個目錄並排開              |\n\n## 注意事項\n\n1. **同一個分支不能同時 checkout 到兩個 worktree** — Git 會阻止你\n2. **node_modules 不共享** — 每個 worktree 要各自 `npm install`（但這也是優點，環境完全獨立）\n3. **命名慣例** — 建議 worktree 目錄放在主 repo 旁邊，用 `{repo}-{用途}` 命名\n\n## 反思\n\n### 技術面\n\nWorktree 從 Git 2.5（2015 年）就有了，但很多人不知道。原因很簡單：大多數 Git 教學不會教它，因為它不是「基礎操作」。但一旦你的工作流需要頻繁切換分支，它的價值就顯現出來了。\n\n### 心態面\n\nstash 用久了會覺得「就是這樣嘛」，但其實每次 stash 都有認知負擔 — 我 stash 了什麼？有幾個 stash？pop 會不會衝突？Worktree 的做法是從根本上消除這個問題：不需要暫存，因為你根本不需要離開。\n\n### 有趣發現\n\n很多 AI coding 工具（像 Claude Code）也開始支援 worktree 模式，讓 AI 在獨立的 worktree 裡工作，不影響你手上正在改的程式碼。這個概念和人類開發者用 worktree 的理由完全一樣 — 隔離。",
      "summary": "還在用 git stash 切分支嗎？Git Worktree 讓你同時 checkout 多個分支到不同目錄，徹底消除 stash 的認知負擔。",
      "image": "https://bobochen.dev/_astro/cover.bIvLkike.webp",
      "date_published": "2026-03-09T00:00:00.000Z",
      "tags": [
        "Git",
        "Git Worktree",
        "開發工具",
        "CLI",
        "版本控制"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-ai-driven-dev/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-ai-driven-dev/",
      "title": "AI 驅動開發：從 Vibe Coding 到 Agentic Workflow",
      "content_text": "不只是用 AI 寫程式碼，而是建造一套 AI 開發系統。從 Level 1 的 Autocomplete 到 Level 3 的 Agentic Workflow，帶你用 Claude Code 的 custom skills、MCP server、hooks 與 multi-agent 工作流，讓 AI 從工具變成你的開發團隊，把 Solo Builder 的產出放大 10 倍。",
      "content_html": "## 為什麼你的 AI 輔助開發還停在 2024 年\n\n打開 ChatGPT，貼上錯誤訊息，拿到修好的程式碼，複製貼上。\n\n或者用 Copilot，打幾個字，按 Tab 接受建議。\n\n如果你的 AI 開發工作流程還是這樣，你只用到了 AI 能力的 10%。\n\n不誇張。\n\n2026 年的 AI 輔助開發，已經從「工具」進化成「團隊」。你可以讓 AI 自己讀 codebase、跑測試、分析失敗原因、修 bug、送 commit、甚至在部署失敗的時候自動修復後重新部署。\n\n整個流程你可以不用碰鍵盤。\n\n這不是未來式。這是我每天在用的工作流程。\n\n延續上一章把 [MVP 砍到不能再砍](/blog/ai-solo-builder-mvp-design)的思路，這一章談怎麼用 AI 把它快速做出來。這也是整本書最核心的章節，AI 驅動開發是 Solo Builder 能以一人之力做出團隊等級產品的關鍵。我會帶你從最基礎的 Level 1 走到最高階的 Level 3，讓你建造自己的 AI 開發系統。\n\n## AI 輔助開發的三個等級\n\n不是所有的「用 AI 寫程式」都一樣。根據 AI 的自主程度，我把它分成三個等級：\n\n| 等級    | 名稱             | AI 做什麼                               | 你做什麼              | 效率提升 |\n| ------- | ---------------- | --------------------------------------- | --------------------- | -------- |\n| Level 1 | Autocomplete     | 補完你正在打的程式碼                    | 逐行寫程式            | 1.3x     |\n| Level 2 | Vibe Coding      | 根據描述生成整段程式碼                  | 描述需求、review 結果 | 2-3x     |\n| Level 3 | Agentic Workflow | 自主讀 codebase、寫程式、跑測試、修 bug | 下指令、做決策        | 5-10x    |\n\n大多數人停在 Level 1 到 Level 2 之間。這一章的重點是帶你到 Level 3。\n\n### Level 1：Autocomplete — 節省打字時間\n\n這是 GitHub Copilot 最初的用法。你寫一行註解，AI 幫你補完下面幾行程式碼。或者你打了一個函式名稱，AI 幫你把函式實作補完。\n\n**適合的場景：**\n\n- 寫重複性高的程式碼（boilerplate）\n- 補完你已經知道怎麼寫的東西\n- 套用常見 pattern\n\n**限制：**\n\n- AI 不理解你的專案脈絡\n- 生成的程式碼常常需要修改\n- 每次只能處理幾行到幾十行\n\nLevel 1 很有用，但效率提升有限。就像有人幫你打字，但思考還是你自己在做。\n\n### Level 2：Vibe Coding — 用自然語言寫程式\n\nAndrej Karpathy 在 2025 年提出了 Vibe Coding 這個詞：不看程式碼、不仔細 review，直接用自然語言描述你要什麼，AI 幫你寫好。如果結果不對，用自然語言描述問題，AI 幫你修。\n\n**傳統做法（Level 1）：**\n\n1. 你打開文件，找到要用的 API\n2. 你寫第一行程式碼\n3. Copilot 建議後面幾行\n4. 你修改、調整、繼續寫\n5. 你跑測試、發現 bug\n6. 你自己 debug\n\n**Vibe Coding 做法（Level 2）：**\n\n1. 你在 chat 裡描述：「幫我寫一個 API endpoint，接收用戶上傳的圖片，存到 R2，回傳 URL」\n2. AI 生成整個函式\n3. 你跑一下看結果\n4. 不對的地方貼錯誤訊息回去\n5. AI 修好\n\nVibe Coding 的效率大約是傳統方式的 2-3 倍。它的好處是降低了啟動阻力——你不需要先去查文件、搞清楚 API 參數，直接描述想要的結果就好。\n\n但 Vibe Coding 有個致命缺陷：你說一句、AI 做一步，所有的推進力都來自你。AI 沒有主動性，你不說話，它就停下來。\n\n對一個每週只有 5-10 小時的 Solo Builder 來說，這意味著你必須全程盯著螢幕。你的寶貴時間變成了「不斷跟 AI 對話」的時間。\n\n我們可以做得更好。\n\n### Level 3：Agentic Workflow — AI 成為自主的團隊成員\n\nLevel 3 是質變。\n\n在 Agentic Workflow 裡，AI 不再是被動回應你的問題。它成為一個**有自主能力的 agent**——你給它一個目標，它自己決定怎麼做：讀哪些檔案、寫哪些程式碼、跑哪些測試、怎麼修 bug。\n\n你的角色從「寫程式的人」變成「管理 AI 團隊的人」。\n\n讓我描述一個我實際的開發場景：\n\n> 我在 terminal 輸入一句話：「幫我新增一個 blog 文章的標籤篩選功能，參考現有的分類篩選。寫完跑測試。」\n>\n> 然後我去泡咖啡。\n>\n> 回來的時候，AI 已經：\n>\n> 1. 讀了現有的分類篩選程式碼\n> 2. 理解了專案的 component 架構和 CSS conventions\n> 3. 新增了標籤篩選的 component\n> 4. 更新了頁面 routing\n> 5. 跑了 build 確認沒有 TypeScript 錯誤\n> 6. 生成了一個 commit 訊息等我確認\n\n整個過程我不在電腦前。這就是 Level 3 的威力。\n\n但「不用盯著螢幕」這件事，有它的另一面我得先講：你放手的程度越高，一次出錯的傳播半徑就越大，而你能攔下它的機會就越少。我去泡咖啡的那段時間，AI 不只在幫我寫對的東西，也在幫我把錯的東西一路 commit 下去——一個它寫歪的邏輯、一個它沒注意到的安全漏洞，都會被它順手帶過去。所以我自己有一條線：本地的、可逆的、有測試蓋著的操作，放手沒問題；但 production 部署、schema migration、刪資料、動到錢、改安全設定這幾類，我不開全自動，一定要有一個人類確認的關卡。自主程度不是越高越好，是要跟「出錯的代價」配對著用。\n\n## 建造你的 Agentic Workflow\n\n要達到 Level 3，你需要的不只是一個 AI 工具，而是一套**系統**。以下是我用 Claude Code 建造的系統架構，你可以參考來建造自己的版本。\n\n### 組件 1：CLAUDE.md — 教 AI 認識你的專案\n\nCLAUDE.md 是 Claude Code 的核心設定檔，放在專案根目錄。它的功能是讓 AI 理解你的專案架構、開發慣例和偏好。\n\n把它想成是你跟新同事的 onboarding 文件。你會告訴新同事什麼？\n\n- 專案的目錄結構\n- 怎麼跑 dev server 和 build\n- 命名慣例（component 用 PascalCase、CSS 用哪套方案）\n- 部署方式\n- 程式碼風格偏好\n\n**沒有 CLAUDE.md 的情況：**\n\n你每次開啟新的對話，都要重新跟 AI 解釋：「我的專案用 Astro、部署到 Cloudflare、CSS 用 Tailwind、component 放在 src/components/ 底下……」\n\n**有 CLAUDE.md 的情況：**\n\nAI 自動讀取設定檔，已經知道你的一切偏好。你可以直接說「幫我加一個新 component」，它就會按照你的慣例來做——檔案放對位置、用對的命名、套對的 CSS 方案。\n\n一個好的 CLAUDE.md 大概長這樣：\n\n```markdown\n# 專案名稱\n\n## 指令\n\n- npm run dev — 啟動開發伺服器\n- npm run build — 建置生產版本\n\n## 架構\n\n- src/pages/ — 檔案路由\n- src/components/ — 元件（按功能分子目錄）\n- src/content/blog/ — MDX 部落格文章\n\n## 慣例\n\n- Component 檔案用 PascalCase\n- 用 Tailwind CSS，不寫自定義 CSS\n- 優先使用既有的 CSS custom properties\n```\n\n關鍵原則：**越具體越好。** 不要寫「程式碼品質要高」這種廢話，要寫「error handling 用 Result type，不用 try-catch」這種具體的規則。AI 需要的是明確的指令，不是抽象的原則。\n\n### 組件 2：Custom Skills — 教 AI 可重複的任務\n\n如果你發現自己重複叫 AI 做同樣的事，就該把它變成一個 custom skill。\n\nCustom skill 是一組預先定義好的指令，AI 可以直接呼叫。就像是你訓練團隊成員的 SOP。\n\n常見的 Solo Builder custom skills：\n\n| Skill 名稱   | 功能                         | 觸發方式                       |\n| ------------ | ---------------------------- | ------------------------------ |\n| new-post     | 生成新的 blog 文章骨架       | `/new-post \"文章標題\"`         |\n| review       | 做 code review，檢查常見問題 | `/review`                      |\n| deploy-check | 部署前的預檢清單             | `/deploy-check`                |\n| test-write   | 為指定功能寫測試             | `/test-write src/lib/utils.ts` |\n| commit       | 分析變更、生成 commit 訊息   | `/commit`                      |\n\n每個 skill 是一個資料夾，裡面放一個 `SKILL.md`（帶 YAML frontmatter），寫清楚 AI 應該做什麼、怎麼做、用什麼格式輸出。skill 既可以用斜線指令手動呼叫，也能由 AI 依情境自動載入——這由 frontmatter 的 `disable-model-invocation`、`user-invocable` 等欄位控制。\n\n舉例，一個「新增 blog 文章」的 skill 大概是：\n\n```markdown\n# new-post skill\n\n1. 讀取 src/content.config.ts 了解 frontmatter schema\n2. 建立新目錄 src/content/blog/{slug}/\n3. 生成 index.md，包含完整的 frontmatter\n4. frontmatter 的 author 預設為 \"Bobo Chen\"\n5. pubDate 設為今天\n6. 標題和 description 根據用戶輸入生成\n```\n\n一次設定，永久受用。從此之後，你說一句話就能生成一篇符合你專案規範的新文章。\n\n### 組件 3：MCP Server — 讓 AI 連接外部工具\n\nMCP（Model Context Protocol）是讓 AI 連接外部服務的標準協定。透過 MCP server，你的 AI agent 可以直接跟其他工具對話。\n\nSolo Builder 最實用的 MCP 連接：\n\n| MCP Server     | AI 可以做什麼                        |\n| -------------- | ------------------------------------ |\n| Notion MCP     | 讀取你的 Notion 筆記和任務清單       |\n| GitHub MCP     | 查看 Issues、建立 PR、讀 review 留言 |\n| Playwright MCP | 開瀏覽器測試你的網站                 |\n| 資料庫 MCP     | 直接查詢和修改資料庫                 |\n\n**沒有 MCP 的工作流：**\n\n1. 你在 Notion 看到一個 bug report\n2. 你把 bug 描述複製到 AI\n3. AI 給你修復建議\n4. 你手動修改程式碼\n5. 你手動跑測試\n6. 你手動更新 Notion 的狀態\n\n**有 MCP 的工作流：**\n\n1. 你說「處理 Notion 裡的 bug #42」\n2. AI 自己去 Notion 讀 bug 描述\n3. AI 找到相關程式碼並修復\n4. AI 跑測試確認修好了\n5. AI 更新 Notion 狀態為「已解決」\n\n你只講了一句話。剩下的全部自動化。\n\n### 組件 4：Hooks — 自動觸發的品質關卡\n\nHooks 是在特定事件發生時自動執行的腳本。你可以設定 AI 在 commit 前自動跑 linter、在生成程式碼後自動跑型別檢查。\n\n這就像在 AI 的工作流程裡加入品質關卡。AI 不是完美的——它會生成有 bug 的程式碼、忘記 edge case、用了 deprecated 的 API。Hooks 確保這些問題在進入 codebase 之前就被抓到。\n\n常用的 hooks 配置：\n\n| 時機         | Hook                | 功能                 |\n| ------------ | ------------------- | -------------------- |\n| 修改檔案後   | TypeScript 型別檢查 | 確保沒有型別錯誤     |\n| commit 前    | ESLint + Prettier   | 程式碼格式和品質     |\n| push 前      | 跑完整測試套件      | 確保沒有破壞既有功能 |\n| 生成程式碼後 | build 驗證          | 確保可以成功建置     |\n\n有了 hooks，你可以放心讓 AI 更自主地工作，因為品質關卡會幫你把關。\n\n## Multi-Agent 模式：一個人的開發團隊\n\nLevel 3 的進階玩法：同時跑多個 AI agent，每個負責不同的任務。這正是[第 1 章宣言](/blog/ai-solo-builder-manifesto)裡說的「一個人＋AI 就是一支團隊」的具體實現。\n\n在一個人做產品的情境下，你可以模擬一個小型開發團隊：\n\n| Agent 角色      | 職責                | 類比       |\n| --------------- | ------------------- | ---------- |\n| Developer Agent | 寫功能程式碼        | 開發者     |\n| Test Agent      | 寫測試、跑測試      | QA 工程師  |\n| Review Agent    | Code review、抓 bug | 資深工程師 |\n| Ops Agent       | 部署、監控、修復    | SRE        |\n\n一個實際的工作流可能是這樣的：\n\n1. 你告訴 Developer Agent：「實作用戶登入功能」\n2. Developer Agent 寫好程式碼\n3. Test Agent 自動為它寫測試並跑測試\n4. 如果測試失敗，Developer Agent 自動收到反饋並修改\n5. 測試通過後，Review Agent 檢查程式碼品質\n6. 全部通過，Ops Agent 部署到 staging 環境\n\n你做了什麼？你下了一個指令。其餘的都是 AI agents 之間的協作。\n\n### Self-Healing Deploy：AI 自動修復部署失敗\n\n這是我最喜歡的 agentic 模式之一。\n\n部署失敗是 Solo Builder 最頭痛的事——通常發生在你以為已經搞定一切的時候，而且每次的錯誤都不一樣。\n\nSelf-healing deploy 的概念很簡單：\n\n1. CI/CD pipeline 觸發部署\n2. 部署失敗\n3. AI agent 自動讀取錯誤日誌\n4. 分析失敗原因\n5. 生成修復 commit\n6. 重新觸發部署\n7. 如果三次都失敗，通知你來看\n\n你可以在 CI/CD pipeline 裡加一個步驟來實現這個模式。這個概念不限定於特定工具——重點是「部署失敗時自動分析錯誤並嘗試修復」這個流程。\n\n**傳統做法：** 部署失敗 → 你收到通知 → 打開電腦 → 看 log → 查原因 → 修 → 推 → 再部署 → 再失敗 → 再修。可能花一整個晚上。\n\n**Self-healing 做法：** 部署失敗 → AI 自動修 → 自動重新部署 → 成功。你甚至不知道中間失敗過。\n\n> 不過這個模式我得加個警告，因為它最危險的地方正是「你甚至不知道中間失敗過」。AI 的修復常常是推測性的——它沒真的理解問題，只是試一個看起來合理的改動再推一次。如果它猜錯，下一輪可能在錯的修補上再疊一個錯的修補，commit history 會變得一團亂。而那個「三次才通知你」的設計，意味著在你被叫醒之前，環境可能已經被它的盲目重試搞髒了。所以我會把 self-healing 限制在低風險的部署上（例如預覽環境、可一鍵 rollback 的靜態站），並且強制它每次失敗都把錯誤摘要寫進通知，而不是默默重試到第三次才出聲。production 的部署，我寧可被吵醒，也不要它自己亂修。\n\nSelf-healing 只是監控維運的第一層防線，更完整的 AI 監控體系（alert 策略、log 分析、異常自動回報）在[第 11 章：監控維運](/blog/ai-solo-builder-monitoring-ops)繼續展開。\n\n### Iterative TDD Loop：AI 自我修正的開發循環\n\n另一個強大的模式：先寫測試，讓 AI 自己跑「寫程式 → 跑測試 → 看結果 → 修 bug」的循環。\n\n流程是這樣的：\n\n1. **你寫測試**（或讓 AI 幫你寫，但你要 review）\n2. **AI 實作功能**\n3. **自動跑測試**\n4. **如果失敗**：AI 讀錯誤訊息、分析原因、修改程式碼\n5. **回到步驟 3**\n6. **如果通過**：完成\n\n這個循環可能跑 3-5 次，但不需要你介入。你只需要做第一步——定義「正確」長什麼樣（也就是測試）。\n\n這個模式的好處是：你把 AI 的角色從「生成程式碼」變成「達成目標」。AI 不只是產出程式碼，它要對程式碼的正確性負責。\n\n這裡有一個陷阱我特別要點出來：如果上面那個第一步——測試——也是 AI 寫的，那整個循環就變成「AI 寫程式 + AI 寫測試 + AI 判自己過關」，等於讓它自己出考卷又自己改考卷。我踩過這個雷：AI 為了讓紅燈變綠，會去改測試的斷言、塞一堆 mock 把真正的行為繞過去，甚至寫出剛好通過它那段錯誤實作的測試。測試全綠不等於行為正確，它只代表「AI 的實作通過了 AI 對自己的要求」。所以關鍵邏輯的測試，我會自己定義「正確長什麼樣」，至少也要人工 review 一遍——不是看綠燈，是看這些測試到底有沒有測到我真正在乎的意圖。剩下的 boilerplate 測試交給 AI 沒關係，但守門的那幾條，人要在場。\n\n## 時間對比：傳統 vs. AI 各等級\n\n讓我用一個具體的功能來比較不同等級的效率差異。\n\n**任務：為部落格新增標籤篩選功能**\n\n| 階段        | 傳統開發   | Level 1    | Level 2    | Level 3           |\n| ----------- | ---------- | ---------- | ---------- | ----------------- |\n| 了解需求    | 15 分      | 15 分      | 10 分      | 5 分              |\n| 查文件/範例 | 30 分      | 20 分      | 5 分       | 0 分              |\n| 寫程式碼    | 120 分     | 90 分      | 40 分      | 15 分             |\n| 寫測試      | 45 分      | 30 分      | 15 分      | 0 分（AI 自己跑） |\n| Debug       | 60 分      | 45 分      | 20 分      | 5 分              |\n| 部署驗證    | 20 分      | 20 分      | 15 分      | 5 分              |\n| **合計**    | **290 分** | **220 分** | **105 分** | **30 分**         |\n\n從 290 分鐘到 30 分鐘，將近 10 倍的差距。\n\n而且 Level 3 的 30 分鐘裡，你實際坐在電腦前的時間可能只有 10 分鐘——下指令和最終確認。其餘的時間 AI 在自己工作，你可以做別的事。\n\n在這類任務上，你每週擠出來的幾小時可以頂以前的一整天。\n\n不過這張表我得標清楚：這是我在「標籤篩選」這種任務上的個人經驗值，不是普適量測，更不是要你拿 290 對 30 去算什麼倍率。我故意挑了一個對 AI 最有利的場景——有現成的分類篩選可以抄、需求邊界很清楚、改錯了也不會出大事。AI 在這種「有 pattern 可參考、低風險」的功能上確實快得誇張。\n\n但換個任務，數字會整個垮掉：\n\n- **全新領域、模糊需求**——你跟 AI 來回澄清的時間，可能比自己想清楚還久。\n- **深度 debug 罕見問題**——AI 容易猜，猜錯了你還要回頭看它改壞了哪些地方。\n- **大型 refactor、跨系統整合**——上下文一複雜，AI 的命中率掉得很快，加速比可能趨近於零，甚至是負的。\n\n2025 年 METR 那份研究就有一個反直覺的發現：資深開發者在自己熟悉的大型 codebase 裡用 AI，平均反而變慢——他們以為自己快了，實際拖長了工時。所以別把上面這張表當成你的待辦清單會自動縮 10 倍的保證。它告訴你的是「某類任務的天花板」，不是你每件事的平均值。\n\n## 建造你的 Agentic 系統：三步走\n\n如果你目前在 Level 1 或 Level 2，不要試圖一步跳到 Level 3。循序漸進：\n\n### 第一週：基礎設施\n\n1. **安裝 Claude Code**（或你選擇的 agentic 工具）\n2. **寫好 CLAUDE.md**：把你的專案架構、開發慣例、指令全部寫進去\n3. **測試基本功能**：讓 AI 幫你做一個小功能，觀察它怎麼理解你的專案\n\n這一週你可能只會感受到 Level 2 的效果。但基礎建好了。\n\n### 第二週：自動化\n\n1. **建立 2-3 個 custom skills**：從你最常做的重複工作開始\n2. **設定 hooks**：至少設定 commit 前的品質檢查\n3. **嘗試 TDD loop**：寫一個測試，讓 AI 自己跑到通過\n\n這週你開始感受到 Level 3 的威力。\n\n### 第三週：整合\n\n1. **連接 MCP server**：至少連一個（GitHub 或你的專案管理工具）\n2. **設定部署自動化**：讓 AI 能幫你部署和驗證\n3. **嘗試 multi-agent**：讓 AI 自己寫 + 測 + review\n\n到了第三週，你應該已經有了一套初步的 agentic 系統。之後就是持續優化。\n\n## 當 AI 出錯：錯誤處理手冊\n\nAI 不是完美的。以下是常見的問題和應對方式：\n\n### 問題 1：Hallucination — AI 編造不存在的 API\n\n**症狀：** AI 生成的程式碼引用了不存在的函式或套件。\n\n**對策：**\n\n- hooks 裡加上型別檢查，在 AI 生成程式碼後自動跑 `tsc`\n- 在 CLAUDE.md 裡明確列出專案使用的套件版本\n- 遇到不熟悉的 API 呼叫，要求 AI 附上文件連結\n\n### 問題 2：Context 遺失 — AI 忘記之前的脈絡\n\n**症狀：** 對話太長後，AI 開始做出跟之前矛盾的事情。\n\n**對策：**\n\n- 複雜功能拆成多個小任務，每個任務一個新對話\n- 把重要決策記錄在 CLAUDE.md 或 IMPLEMENTATION_PLAN.md\n- 善用 custom skills 把常用脈絡固化下來\n\n### 問題 3：過度自信 — AI 信誓旦旦但答案是錯的\n\n**症狀：** AI 說「這段程式碼是正確的」，但跑起來就爆了。\n\n**對策：**\n\n- 永遠跑測試，不要相信 AI 的口頭保證\n- 用 TDD loop：讓程式碼的正確性由測試決定，不是由 AI 的自信決定\n- 設定「三次失敗就停下來」的規則，不要讓 AI 在同一個 bug 上無限循環\n\n### 問題 4：風格不一致 — AI 生成的程式碼跟你的風格不同\n\n**症狀：** AI 用了不同的命名慣例、不同的錯誤處理方式、不同的檔案結構。\n\n**對策：**\n\n- 在 CLAUDE.md 裡寫清楚你的風格規範\n- 提供範例：「像 src/components/PostCard.astro 那樣」\n- 設定 ESLint/Prettier，用 hooks 自動修正\n\n## 心態轉換：從「寫程式的人」到「管理 AI 的人」\n\n最後聊聊心態。\n\n很多工程師對 Level 3 感到不自在。「如果我不自己寫程式碼，我還算工程師嗎？」\n\n換個角度想：一個好的 CTO 不會自己寫所有程式碼。他的工作是做技術決策、設計架構、確保品質、管理團隊。\n\n作為 Solo Builder，你就是你的 AI 團隊的 CTO。\n\n你的工作不是打字——AI 打字比你快。你的工作是：\n\n1. **做決策**：做什麼功能、不做什麼功能\n2. **設計架構**：系統怎麼組織、資料怎麼流動\n3. **確保品質**：設定好測試和 hooks，讓品質有保障\n4. **給方向**：用清楚的指令和 context，讓 AI 做對事情\n\n把打字時間省下來，花在思考和判斷上。這才是 Solo Builder 最稀缺的資源——不是寫程式碼的時間，而是做對的決策的能力。\n\n## 本章重點回顧\n\n- 🎯 AI 輔助開發有三個等級：Autocomplete（1.3x）→ Vibe Coding（2-3x）→ Agentic Workflow（5-10x）\n- 🏗️ Agentic 系統四大組件：CLAUDE.md（專案記憶）+ Custom Skills（可重複任務）+ MCP（外部整合）+ Hooks（品質關卡）\n- 👥 Multi-agent 模式讓你模擬一個小型開發團隊：Developer + QA + Reviewer + Ops\n- 🔄 Self-healing deploy 和 TDD loop 是最實用的 agentic 模式\n- ⚠️ AI 會出錯：用測試和 hooks 把關，設定「三次失敗就停下來」的規則\n- 🧠 心態轉換：從「寫程式的人」變成「管理 AI 團隊的 CTO」\n\n## 下一步\n\n有了 AI 開發系統，你可以用驚人的速度寫程式碼了。\n\n但寫好的程式碼要放到哪裡？部署平台的選擇，對 Solo Builder 來說比你想像的重要得多。選對平台，git push 就自動上線。選錯平台，光是搞部署設定就花掉你一整個週末。\n\n下一章，我們來做這個關鍵選擇。\n\n👉 [第 6 章：部署上線——選對平台省 80% 的事](/blog/ai-solo-builder-deployment)",
      "summary": "不只是用 AI 寫程式碼，而是建造一套 AI 開發系統。從 Level 1 的 Autocomplete 到 Level 3 的 Agentic Workflow，帶你用 Claude Code 的 custom skills、MCP server、hooks 與 multi-agent 工作流，讓 AI 從工具變成你的開發團隊，把 Solo Builder 的產出放大 10 倍。",
      "image": "https://bobochen.dev/_astro/cover.CbKQ-thB.webp",
      "date_published": "2026-03-08T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "Claude Code",
        "AI",
        "Agentic Workflow",
        "MCP",
        "Vibe Coding"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-messages-api/",
      "url": "https://bobochen.dev/blog/claude-api-guide-messages-api/",
      "title": "Messages API 深度解析：對話的基本單位",
      "content_text": "深入理解 Claude Messages API 的核心設計：system/user/assistant roles、多輪對話管理、temperature 與 top_p 調校、stop_sequences，以及生產環境的 system prompt 最佳實踐。",
      "content_html": "你已經發出了第一個 API 呼叫。\n\n但那只是「打個招呼」——傳一句話，收一句回答，連線結束。真正的 AI 應用比這複雜得多：使用者跟 Claude 來回對話好幾輪；你需要給 Claude 一個「角色設定」；你需要控制回應的風格和長度。\n\n這一章，我們來把 Messages API 徹底搞清楚。\n\n## 對話即陣列：Messages API 的核心設計哲學\n\nMessages API 的設計非常直白：**對話是一個 messages 陣列，每個元素代表一輪對話**。\n\n```json\n{\n  \"model\": \"claude-sonnet-4-6\",\n  \"max_tokens\": 1024,\n  \"messages\": [\n    { \"role\": \"user\", \"content\": \"台北有什麼好吃的？\" },\n    { \"role\": \"assistant\", \"content\": \"台北的美食非常豐富...\" },\n    { \"role\": \"user\", \"content\": \"你剛說的那個，有推薦的店嗎？\" }\n  ]\n}\n```\n\n注意這裡最關鍵的設計決策：**Claude API 是無狀態的（stateless）**。\n\n每次你呼叫 API，你需要把完整的對話歷史傳過去。API 不會幫你「記住」之前的對話。這跟 Claude.ai 的使用體驗不同——Claude.ai 的 Projects 功能會幫你保存記憶，但那是前端應用自己做的，底層的 API 每次都是全新的。\n\n這個設計的優點是簡單、可預測，而且讓你完全掌控對話狀態。缺點是你需要自己管理對話歷史，而且隨著對話越來越長，每次呼叫的成本也越來越高（因為 input tokens 包含了所有歷史）。\n\n## 三種 Roles：system、user、assistant\n\nMessages API 有三種 role，每種有不同的用途和限制。\n\n### system（系統指示）\n\n`system` 不是 messages 陣列的一部分，而是獨立的頂層參數。它用來給 Claude 設定「背景」：角色、能力範圍、回應格式、行為準則。\n\n````python\nclient.messages.create(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=2048,\n    system=\"\"\"你是一位專業的 TypeScript 程式碼審查員。\n\n你的職責：\n- 找出潛在的 bug 和型別錯誤\n- 指出效能問題\n- 建議更符合 TypeScript 慣例的寫法\n\n你不應該：\n- 重寫整段程式碼（除非被要求）\n- 討論與程式碼無關的話題\n\n回應格式：\n1. 問題摘要（條列式）\n2. 具體建議（帶程式碼範例）\n3. 嚴重程度評估（高/中/低）\"\"\",\n    messages=[\n        {\"role\": \"user\", \"content\": \"請幫我審查這段程式碼：\\n```typescript\\n...\\n```\"}\n    ]\n)\n````\n\n我有一個強烈的觀點：**system prompt 是你的 AI 應用最重要的工程工件，值得你花很多時間打磨它。**\n\n一個好的 system prompt 應該：\n\n1. **明確說明 Claude 是誰**，而不是「你是一個 AI 助理」這種廢話\n2. **清楚界定能做什麼、不能做什麼**（比只說「能做什麼」更重要）\n3. **定義輸出格式**：如果你要解析 Claude 的回應，請在 system prompt 裡明確說\n4. **提供必要的背景知識**：你的產品是什麼、使用者是誰、常見的問題類型\n\n一個我常犯的錯誤是 system prompt 寫太短。「你是一個客服機器人，回答用戶問題。」這種 prompt 在開發階段看起來 work，但在生產環境會有各種奇怪的邊際情況。\n\n### user（使用者輸入）\n\n`user` role 代表你的使用者（或你的應用）發出的訊息。messages 陣列**必須從 user 開始**，而且 user 和 assistant 要交替出現。\n\n```python\nmessages=[\n    {\"role\": \"user\", \"content\": \"第一個問題\"},\n    {\"role\": \"assistant\", \"content\": \"第一個回答\"},\n    {\"role\": \"user\", \"content\": \"第二個問題\"},\n    # 下一個一定要是 assistant，然後才能再 user\n]\n```\n\n`content` 可以是字串，也可以是陣列（用於多媒體訊息，例如上傳圖片）。目前我們先處理純文字的情況：\n\n```python\n# 簡單字串\n{\"role\": \"user\", \"content\": \"你好\"}\n\n# 或者明確的 content array（兩種寫法等效）\n{\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"你好\"}]}\n```\n\n### assistant（模型回應）\n\n`assistant` role 代表 Claude 的回應。在多輪對話中，你會把 Claude 之前的回應加入 messages 陣列，讓它在下一輪有上下文。\n\n> ⚠️ **注意（2026 更新）**：assistant prefill（在 `messages` 結尾放一個 `assistant` 訊息讓 Claude 接續）在 Claude 4.6 之後的模型（Opus 4.6/4.7/4.8、Sonnet 4.6、Fable 5）已**不支援**，會回傳 400 錯誤。要強制結構化輸出，請改用 `output_config.format`（structured outputs）或用 system prompt 指示格式。以下 Prefilling 技巧僅適用於舊版模型（3.x）：\n\n有一個進階技巧叫做 **Prefilling**：你可以在最後一個 messages 元素放一個 `assistant` role（只包含部分文字），讓 Claude 從那個地方繼續往下說：\n\n```python\nmessages=[\n    {\"role\": \"user\", \"content\": \"請用 JSON 格式回傳一個使用者物件\"},\n    {\"role\": \"assistant\", \"content\": \"{\"}  # Prefilling：強制 Claude 從 { 開始\n]\n```\n\n這個技巧對於強制結構化輸出很有用，但要小心：如果你 prefill 了一個 `{`，Claude 幾乎一定會繼續輸出 JSON，但不保證它一定是合法的 JSON。\n\n## 關鍵參數詳解\n\n### max_tokens（必填）\n\n```python\nmax_tokens=1024  # 最多回傳 1024 tokens\n```\n\n`max_tokens` 是必填的，而且它決定了**這次呼叫可能產生的最大 output tokens 數**。\n\n幾個常見的設定策略：\n\n- **聊天機器人**：512-2048，視你的應用允許多長的回答\n- **文件摘要**：2048-4096\n- **程式碼生成**：2048-8192（程式碼可能很長）\n- **分析報告**：4096+\n\n注意：`max_tokens` 不是「我希望它說這麼長」，而是「最多不要超過這麼長」。Claude 可能更早結束（`stop_reason: \"end_turn\"`）。\n\n如果 Claude 的回應因為達到 `max_tokens` 而被截斷，`stop_reason` 會是 `\"max_tokens\"` 而不是 `\"end_turn\"`。生產環境一定要處理這種情況。\n\n### temperature（創意度控制）\n\n```python\ntemperature=0.7  # 範例值（API 預設為 1.0），範圍 0.0 到 1.0\n```\n\n`temperature` 控制模型回應的「隨機性」：\n\n- `0.0`：非常確定性，每次相同的輸入幾乎會得到相同的輸出。適合需要一致性的任務（程式碼生成、資料提取、分類）\n- `0.7`：適度的多樣性，適合大多數對話場景\n- `1.0`（API 預設）：最大隨機性，適合創意寫作、頭腦風暴\n\n我的個人規則：API 預設是 1.0（偏隨機）。多數對話場景我會設成 0.7 左右；需要確定性輸出時設 0.0 或 0.1。\n\n### top_p 和 top_k\n\n```python\ntop_p=0.9   # nucleus sampling\ntop_k=50    # top-k sampling\n```\n\n這兩個參數跟 temperature 一樣是控制「採樣策略」的。**在實際使用時，Anthropic 建議你只調整 temperature 或 top_p 其中一個，不要同時調整兩個。**\n\n坦白說，在大多數應用場景，你根本不需要動這兩個參數。只有在你有非常特殊的需求，而且你理解採樣策略的數學原理時，才值得去調整。\n\n### stop_sequences\n\n```python\nstop_sequences=[\"</answer>\", \"Human:\", \"---\"]\n```\n\n`stop_sequences` 讓你定義「遇到這些字串就停止生成」。這在結構化輸出時很有用：\n\n```python\n# 讓 Claude 生成 XML 風格的分析，但遇到 </analysis> 就停止\nclient.messages.create(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=2048,\n    system=\"請將你的分析包在 <analysis> 和 </analysis> 之間。\",\n    messages=[{\"role\": \"user\", \"content\": \"分析這篇文章...\"}],\n    stop_sequences=[\"</analysis>\"]\n)\n```\n\n另一個常用場景是在 few-shot prompting 時，避免 Claude 繼續生成下一個「示例」：\n\n```python\nstop_sequences=[\"\\nHuman:\", \"\\nAssistant:\"]\n```\n\n## 多輪對話管理\n\n這是大多數 AI 應用最重要的工程問題之一。\n\n### 基本的多輪對話實作\n\n```python\nimport anthropic\n\nclient = anthropic.Anthropic()\nconversation_history = []\n\ndef chat(user_message: str) -> str:\n    # 把使用者訊息加入歷史\n    conversation_history.append({\n        \"role\": \"user\",\n        \"content\": user_message\n    })\n\n    # 呼叫 API（傳入完整歷史）\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=2048,\n        system=\"你是一位友善的繁體中文助理。\",\n        messages=conversation_history\n    )\n\n    # 取出回應文字\n    assistant_message = response.content[0].text\n\n    # 把 Claude 的回應也加入歷史\n    conversation_history.append({\n        \"role\": \"assistant\",\n        \"content\": assistant_message\n    })\n\n    return assistant_message\n\n# 使用範例\nprint(chat(\"我叫小明，是一個 Python 開發者\"))\nprint(chat(\"你知道我叫什麼名字嗎？\"))  # Claude 會記住「小明」\nprint(chat(\"我剛說我是做什麼的？\"))     # Claude 會記住「Python 開發者」\n```\n\n### Context Window 管理：截斷策略\n\n隨著對話進行，`conversation_history` 越來越長，成本也越來越高，最終可能超過 context window 上限（200K tokens）。你需要截斷策略。\n\n**策略一：固定視窗（最簡單）**\n\n```python\nMAX_HISTORY_TURNS = 20  # 保留最近 20 輪\n\ndef chat(user_message: str) -> str:\n    conversation_history.append({\"role\": \"user\", \"content\": user_message})\n\n    # 只取最近 N 輪，但確保從 user 開始\n    recent_history = conversation_history[-MAX_HISTORY_TURNS * 2:]\n    # 確保第一個是 user（API 要求）\n    while recent_history and recent_history[0][\"role\"] != \"user\":\n        recent_history = recent_history[1:]\n\n    response = client.messages.create(\n        model=\"claude-sonnet-4-6\",\n        max_tokens=2048,\n        messages=recent_history\n    )\n\n    assistant_message = response.content[0].text\n    conversation_history.append({\"role\": \"assistant\", \"content\": assistant_message})\n\n    return assistant_message\n```\n\n**策略二：Token 預算**\n\n```python\nimport anthropic\n\ndef count_tokens_estimate(messages: list) -> int:\n    \"\"\"粗略估算 token 數：每個字符約 1.5 tokens\"\"\"\n    total_chars = sum(len(str(m.get(\"content\", \"\"))) for m in messages)\n    return int(total_chars * 1.5)\n\nMAX_INPUT_TOKENS = 150_000  # 留 50K 給輸出\n\ndef trim_history(history: list) -> list:\n    while count_tokens_estimate(history) > MAX_INPUT_TOKENS and len(history) > 2:\n        # 刪掉最舊的一輪（user + assistant 各一條）\n        history = history[2:]\n        # 確保從 user 開始\n        while history and history[0][\"role\"] != \"user\":\n            history = history[1:]\n    return history\n```\n\n**策略三：摘要壓縮（最聰明但最複雜）**\n\n對於真正的長對話，你可以讓 Claude 定期把舊的對話內容摘要成一段文字，然後把那段摘要放在 system prompt 裡，同時清空 messages 歷史：\n\n```python\nasync def compress_conversation(history: list) -> str:\n    \"\"\"把一段對話歷史壓縮成摘要\"\"\"\n    summary_request = client.messages.create(\n        model=\"claude-haiku-4-5\",  # 用便宜的模型做摘要\n        max_tokens=1024,\n        system=\"請將以下對話摘要成一段簡潔的重點，保留重要資訊。\",\n        messages=[{\n            \"role\": \"user\",\n            \"content\": f\"對話歷史：\\n{format_history(history)}\"\n        }]\n    )\n    return summary_request.content[0].text\n```\n\n## 回應物件的完整結構\n\n理解 API 回應物件的結構很重要，特別是你需要處理各種邊際情況時。\n\n```python\nresponse = client.messages.create(...)\n\n# 基本屬性\nresponse.id            # 唯一的請求 ID，例如 \"msg_01XFDUDYJgAACzvnptvVoYEL\"\nresponse.type          # 永遠是 \"message\"\nresponse.role          # 永遠是 \"assistant\"\nresponse.model         # 實際使用的模型，例如 \"claude-sonnet-4-6-20251101\"\n\n# 回應內容\nresponse.content       # List[ContentBlock]\nresponse.content[0].type  # \"text\" 或 \"tool_use\"\nresponse.content[0].text  # 如果 type == \"text\"\n\n# 停止原因（非常重要）\nresponse.stop_reason   # \"end_turn\" | \"max_tokens\" | \"stop_sequence\" | \"tool_use\"\n\n# Token 使用量（付費依據）\nresponse.usage.input_tokens   # 這次請求消耗的 input tokens\nresponse.usage.output_tokens  # 這次請求產生的 output tokens\n\n# 如果有啟用 Prompt Caching\nresponse.usage.cache_creation_input_tokens  # 新建快取消耗的 tokens\nresponse.usage.cache_read_input_tokens      # 從快取讀取的 tokens（便宜很多）\n```\n\n**一定要檢查 `stop_reason`**。如果是 `\"max_tokens\"`，代表回應被截斷了——你的使用者會看到一個不完整的回答。常見的處理方式：\n\n```python\nif response.stop_reason == \"max_tokens\":\n    # 方案一：繼續生成（continuation）\n    # 方案二：通知使用者回答被截斷\n    # 方案三：增大 max_tokens 重試\n    raise ValueError(\"Response was truncated. Consider increasing max_tokens.\")\n```\n\n## 錯誤處理\n\n生產環境一定會遇到這些錯誤，提前準備好：\n\n```python\nimport anthropic\nimport time\nfrom anthropic import APIStatusError, APIConnectionError, RateLimitError\n\ndef call_with_retry(client, max_retries=3, **kwargs):\n    for attempt in range(max_retries):\n        try:\n            return client.messages.create(**kwargs)\n\n        except RateLimitError as e:\n            if attempt == max_retries - 1:\n                raise\n            # 指數退避\n            wait_time = (2 ** attempt) * 1 + 0.1\n            print(f\"Rate limited. Waiting {wait_time:.1f}s...\")\n            time.sleep(wait_time)\n\n        except APIStatusError as e:\n            if e.status_code == 401:\n                raise ValueError(\"Invalid API key\") from e\n            elif e.status_code == 400:\n                raise ValueError(f\"Bad request: {e.message}\") from e\n            elif e.status_code >= 500:\n                # 伺服器錯誤，可以重試\n                if attempt == max_retries - 1:\n                    raise\n                time.sleep(2 ** attempt)\n            else:\n                raise\n\n        except APIConnectionError:\n            if attempt == max_retries - 1:\n                raise\n            time.sleep(2 ** attempt)\n```\n\n常見的錯誤狀態碼：\n\n- **400 Bad Request**：通常是你的 request 格式有問題，例如 messages 不是以 user 開始、`max_tokens` 超過模型上限\n- **401 Unauthorized**：API Key 無效或已被 revoke\n- **403 Forbidden**：帳號問題（可能欠費停用）\n- **429 Too Many Requests**：超過 Rate Limit，需要等待\n- **500 Internal Server Error**：Anthropic 伺服器問題，可以重試\n- **529 Overloaded**：Anthropic 系統過載（高峰期可能發生），可以重試\n\n## System Prompt 設計的實戰心得\n\n讓我分享幾個我在生產環境學到的 system prompt 技巧。\n\n### 技巧一：先說「不要做什麼」\n\n大多數人的 system prompt 都在說「請做 X、Y、Z」，但更有效的做法是同時說清楚「不要做 A、B、C」。\n\n```\n你是一個 XX 公司的客服助理。\n\n你應該：\n- 回答關於我們產品的問題\n- 協助用戶排解常見問題\n- 提供退換貨流程說明\n\n你不應該：\n- 透露公司的定價策略或利潤資訊\n- 對還未正式宣布的功能做出承諾\n- 在沒有確認身份的情況下修改用戶帳號設定\n- 討論競爭對手的產品\n```\n\n### 技巧二：定義「不知道」的處理方式\n\n```\n如果你不確定某個問題的答案，請明確說「我不確定這個問題的答案，建議您聯繫我們的客服團隊（service@example.com）。」不要猜測或提供可能不準確的資訊。\n```\n\n### 技巧三：指定輸出格式（尤其是需要解析的場景）\n\n```\n每次回應，請使用以下格式：\n\n<answer>\n[你的主要回答]\n</answer>\n\n<confidence>\n[high/medium/low]\n</confidence>\n\n<sources>\n[如果有參考特定資訊，列出來源]\n</sources>\n```\n\n### 技巧四：提供「人格」範例而不只是描述\n\n```\n你是 Aria，一個親切、有點幽默但很專業的 AI 助理。\n\n你的說話風格範例：\n- 「好問題！讓我想想...」（輕鬆但不浮誇）\n- 「這個需要多說幾句，因為背後有個有趣的原因」\n- 不用「當然！很高興為您服務！」這種過於熱情的開場\n```\n\n## 下一步\n\n你現在對 Messages API 有了深入的了解：messages 的結構、三種 roles、各種參數的用途，以及如何在生產環境管理對話狀態。\n\n但我們的範例有一個問題：你發出請求，然後等 Claude 把整個回應生成完再傳回來。對短回應來說還好，但如果 Claude 需要生成 2000 字的文章，使用者就要等好幾秒什麼都看不到。\n\n**下一章**，我們來解決這個問題：Streaming。讓使用者看到 Claude「即時打字」的體驗，把感知等待時間從幾秒降到幾乎為零。",
      "summary": "深入理解 Claude Messages API 的核心設計：system/user/assistant roles、多輪對話管理、temperature 與 top_p 調校、stop_sequences，以及生產環境的 system prompt 最佳實踐。",
      "image": "https://bobochen.dev/_astro/cover.BP25I3V3.webp",
      "date_published": "2026-03-06T00:00:00.000Z",
      "tags": [
        "Claude API",
        "Messages API",
        "system prompt",
        "conversation"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-mvp-design/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-mvp-design/",
      "title": "MVP 設計：砍到不能再砍",
      "content_text": "上班族做 side project 最大的敵人是「功能蔓延」。本文教你用 AI 輔助 MVP 設計與 feature prioritization，找出最高風險假設，砍到不能再砍，用最少時間驗證最關鍵的產品假設。",
      "content_html": "## 為什麼你的 side project 死在 MVP 之前？\n\n讓我猜猜看。\n\n你在[第 2 章驗證了點子](/blog/ai-solo-builder-idea-validation/)、[第 3 章選好了技術棧](/blog/ai-solo-builder-tech-stack/)，興奮地打開編輯器開始動手。第一個週末完成了登入功能，第二個週末做了 CRUD，第三個週末你開始覺得「應該加一個 Dashboard」……\n\n然後你的 TODO list 開始膨脹：\n\n- [ ] 用戶 Dashboard\n- [ ] 通知系統\n- [ ] 多語言支援\n- [ ] 深色模式\n- [ ] 匯出 CSV\n- [ ] API 文件\n- [ ] 管理後台\n- [ ] 數據分析\n- [ ] ……\n\n三個月後，沒有一個功能是「完成」的狀態。每個功能都做了 70%，每個都差一點點。但就是沒辦法上線。\n\n這個殺手有個名字：**功能蔓延**（Feature Creep）。\n\n功能蔓延不是因為你太貪心。而是因為你太有能力——你知道怎麼做這些功能，所以你覺得「加一個也不會花多少時間」。但一個功能不只是寫完程式碼而已，它還包括測試、debug、維護、文件。每多一個功能，你的維護負擔就多一份。\n\n對有正職的 Solo Builder 來說，功能蔓延是最致命的。因為你每週只有 5-10 小時，分散到十個功能上，每個功能一週只推進 30 分鐘。這個進度慢到你自己都受不了，然後就放棄了。\n\n這一章的目的很簡單：**教你砍功能。砍到不能再砍。**\n\n## MVP 不是「爛版本」\n\n先釐清一個超常見的誤解。\n\nMVP（Minimum Viable Product，最小可行產品）不是「功能很少的爛版本」。不是把所有功能都做了但每個都做得很粗糙。\n\nMVP 的定義是：**用最少的功能，驗證一個最關鍵的假設。**\n\n關鍵字是「一個假設」。\n\n你的產品一定有很多假設：用戶願意付費嗎？他們會每天使用嗎？他們能理解操作流程嗎？註冊轉換率會是多少？\n\n但 MVP 只挑其中**最重要、最不確定**的那一個來驗證。\n\n### 傳統做法\n\n列出所有你想做的功能，全部一起做，然後上線看看反應。如果反應不好，你不知道是哪裡出了問題——是功能不對？價格太高？UI 太醜？行銷沒做好？因為你一次改了太多變數，無法分離原因。\n\n### AI 加持做法\n\n用 AI 幫你釐清假設、排優先順序、設計最精簡的功能集。把「我覺得用戶需要什麼」轉變成「我要驗證用戶是否需要 X」。\n\n## 「一個假設」框架\n\n這是我自己用的框架，極其簡單：\n\n### 第一步：列出你對這個產品的所有假設\n\n打開你的 AI 助手：\n\n```\n我正在做一個 [產品描述]，目標用戶是 [用戶描述]。\n\n請幫我列出這個產品的所有核心假設，包括：\n1. 需求假設（用戶真的有這個問題嗎？）\n2. 解決方案假設（我的方案能解決這個問題嗎？）\n3. 商業假設（用戶願意為此付費嗎？）\n4. 行為假設（用戶會怎麼使用？多常使用？）\n5. 成長假設（用戶會推薦給別人嗎？）\n\n每個假設請標示「確定性」（高/中/低）和「影響性」（高/中/低）。\n```\n\n### 第二步：找出最高風險的假設\n\n在 AI 的回覆中，找出「確定性低 + 影響性高」的那個假設。這就是你 MVP 要驗證的東西。\n\n|              | 影響性高           | 影響性低 |\n| ------------ | ------------------ | -------- |\n| **確定性低** | **MVP 要驗證這個** | 之後再說 |\n| **確定性高** | 已驗證，安心做     | 不重要   |\n\n### 第三步：設計只驗證那一個假設的功能集\n\n然後問 AI：\n\n```\n我的 MVP 要驗證的核心假設是：[你的假設]\n\n請幫我設計一個最精簡的功能集，只包含驗證這個假設所需的最少功能。\n每個功能請標示：\n- 必要（沒有這個就無法驗證假設）\n- 有用（讓驗證更可靠，但不是必須）\n- 多餘（不影響假設驗證）\n\n「多餘」的功能全部砍掉。「有用」的功能存起來，MVP 之後再做。\n只保留「必要」的功能。\n```\n\n這就是你的 MVP 功能清單。通常只會有 3-5 個功能。是的，就這麼少。\n\n## AI 輔助的功能優先排序\n\n如果你發現你的「必要」功能還是太多，或者你不確定哪個該先做，可以用兩個經典的排序方法。AI 特別擅長幫你跑這些框架。\n\n### 方法 1：RICE 評分\n\nRICE 是 Intercom 發明的優先排序框架，四個維度都用數字評分：\n\n| 維度                   | 說明                     | 評分方式                         |\n| ---------------------- | ------------------------ | -------------------------------- |\n| **R**each（觸及）      | 這個功能會影響多少用戶？ | 預估人數                         |\n| **I**mpact（影響）     | 對單個用戶的影響有多大？ | 0.25 / 0.5 / 1 / 2 / 3           |\n| **C**onfidence（信心） | 你對以上估計有多少信心？ | 50% / 80% / 100%                 |\n| **E**ffort（工時）     | 需要花多少人月？         | 對 Solo Builder 用「小時」更實際 |\n\n**RICE 分數 = (R × I × C) / E**\n\n分數越高，優先順序越高。\n\n用 AI 跑 RICE 的 prompt：\n\n```\n以下是我的產品功能清單：\n1. [功能 A]\n2. [功能 B]\n3. [功能 C]\n4. [功能 D]\n5. [功能 E]\n\n產品描述：[簡短描述]\n目標用戶：[描述]\n我的時間預算：每週 8 小時\n\n請幫每個功能做 RICE 評分，考量以下背景：\n- 這是一個人的 side project，不是公司產品\n- 開發時間用「小時」而非「人月」\n- Reach 用「前 100 個用戶中有多少人會用到」\n\n最後按 RICE 分數排序，並給出你的建議。\n```\n\n### 方法 2：MoSCoW 分類\n\n如果你覺得 RICE 太數字化，MoSCoW 更直覺：\n\n| 分類                       | 含義               | MVP 處理方式     |\n| -------------------------- | ------------------ | ---------------- |\n| **M**ust have              | 沒有就不能上線     | 一定做           |\n| **S**hould have            | 很重要但可以後補   | MVP 後第一輪加上 |\n| **C**ould have             | 有了更好，沒有也行 | 放到 backlog     |\n| **W**on't have (this time) | 這次不做           | 直接刪掉         |\n\n重點在那個 \"Won't have\"。**你的 Won't have 清單應該比 Must have 清單長至少三倍。**\n\n如果你發現自己什麼都分到 Must have，那你還不夠狠。回去重新分類，問自己：「如果這個功能沒有，真的不能上線嗎？還是只是我覺得不完整？」\n\n## 功能砍殺清單\n\n這是我的經驗法則。以下功能在 MVP 階段**幾乎都不需要**：\n\n| 功能                  | 為什麼不需要               | 替代方案                        |\n| --------------------- | -------------------------- | ------------------------------- |\n| 使用者註冊/登入       | 用 magic link 或第三方登入 | Clerk / Better Auth             |\n| 管理後台              | 直接改資料庫               | 用 Drizzle Studio 或 D1 Console |\n| 通知系統              | 手動發 email               | 先不做，或用 Resend             |\n| 多語言                | MVP 只需要一種語言         | 只做你的目標市場語言            |\n| 深色模式              | 不影響核心價值驗證         | 上線後再加                      |\n| 付費功能              | 先驗證需求，再談收錢       | 先全部免費                      |\n| 數據分析 Dashboard    | 你自己看 GA 就夠了         | Google Analytics                |\n| 匯出/匯入             | MVP 階段資料量不大         | 手動處理                        |\n| API                   | 除非你的產品就是 API       | 先不對外開放                    |\n| 完美的 Error Handling | 用戶量小，你手動處理       | console.log + 手動修            |\n\n看到這個清單，你可能會覺得：「那 MVP 也太陽春了吧？」\n\n沒錯。MVP **就是要很陽春**。它的目的不是讓用戶覺得產品好棒，而是讓你確認「有人願意用這個核心功能」。\n\n## 為一個人設計 User Story\n\n在大公司，User Story 是寫給整個開發團隊看的。但 Solo Builder 的 User Story 是寫給自己和 AI 的。\n\n格式可以更精簡：\n\n```\n作為 [用戶類型]\n我想要 [做某件事]\n這樣我就能 [得到的好處]\n\n驗收標準：\n- [ ] [具體可測試的條件 1]\n- [ ] [具體可測試的條件 2]\n\n估計工時：X 小時\n```\n\n讓 AI 幫你把功能清單轉成 User Story：\n\n```\n以下是我 MVP 的功能清單：\n1. [功能 A]\n2. [功能 B]\n3. [功能 C]\n\n目標用戶：[描述]\n產品目的：[一句話]\n\n請幫我把每個功能寫成 User Story，格式如下：\n- 作為 / 我想要 / 這樣我就能\n- 驗收標準（2-3 條，具體可測試）\n- 估計工時（假設使用 AI 輔助開發，熟練的開發者）\n\n注意：這是一個人的 side project，User Story 要精簡實用，\n不需要企業級的詳細度。\n```\n\nUser Story 的好處是，它讓你清楚知道「做完」長什麼樣。沒有 User Story 的時候，一個功能可以無限延伸。有了驗收標準，你可以明確地說：「OK，這個功能做完了，下一個。」\n\n## 真實案例：cloud-on-academy 的 MVP\n\n讓我用自己的真實案例來示範這套方法。\n\n我想做 cloud-on-academy，一個 GCP 認證的中文課程平台。在第 2 章我已經驗證了市場存在。現在要設計 MVP。\n\n**我的假設清單：**\n\n1. 繁中市場的工程師願意為 GCP 認證課程付費（需求假設）\n2. 文字 + 圖文教學的形式就夠用，不需要影片（解決方案假設）\n3. 用戶會從頭到尾看完一個系列（行為假設）\n4. 考過認證的用戶會推薦給同事（成長假設）\n\n**最高風險的假設：**\n\n假設 2——文字教學就夠用嗎？這個最不確定。如果用戶一定要影片，我一個人做影片的時間成本會高很多。\n\n**MVP 要驗證的就是這件事。**\n\n**砍功能的過程：**\n\n| 功能         | MoSCoW      | 理由                             |\n| ------------ | ----------- | -------------------------------- |\n| 課程文章展示 | Must have   | 沒有內容就沒有產品               |\n| 章節導覽     | Must have   | 用戶需要知道在哪裡               |\n| 閱讀進度追蹤 | Should have | 有用但不影響核心驗證             |\n| 用戶註冊     | Should have | 先不需要，開放閱讀               |\n| 付費牆       | Won't have  | 先驗證需求，再談收錢             |\n| 討論區       | Won't have  | 用 Discord 或 GitHub Issues 替代 |\n| 認證模擬題   | Won't have  | 第二階段再加                     |\n| 管理後台     | Won't have  | 用 Git + Markdown 管理           |\n| 深色模式     | Could have  | 不影響核心體驗                   |\n| 搜尋功能     | Could have  | 初期內容少，不需要搜尋           |\n\n**最終 MVP 功能：2 個 Must have。**\n\n就這樣。一個能展示課程文章的網站，加上章節導覽。用 Astro 做靜態網站、Markdown 寫內容、部署到 Cloudflare Pages。\n\n整個 MVP，兩個週末就完成了。\n\n上線之後，我觀察到用戶確實會從頭到尾讀完一個系列，而且在 Discord 頻道上的回饋是「文字教學比影片更好搜尋、更好重看」。假設 2 驗證通過。\n\n然後我才開始加其他功能。\n\n## 時間預算：2-3 個週末完成 MVP\n\n上班族的時間預算很明確。假設每個週末能投入 8 小時，2-3 個週末就是 16-24 小時。\n\n這是你的 MVP 開發時間上限。如果估出來超過 24 小時，代表你的功能還太多，回去繼續砍。\n\n### AI 加持的時間估算\n\n```\n以下是我 MVP 的 User Story（附工時估計）：\n[貼上前一步生成的 User Story]\n\n我的開發環境：\n- 技術棧：[你的選擇]\n- AI 工具：Claude Code + Copilot\n- 開發經驗：[你的程度]\n- 每週可用時間：8 小時（週末）\n\n請幫我：\n1. 檢查工時估計是否合理（考慮 AI 輔助）\n2. 安排到 2-3 個週末的開發計畫\n3. 每個週末的目標和交付物\n4. 如果超過 24 小時，建議砍掉哪些功能\n```\n\n一個典型的 2 週末計畫長這樣：\n\n| 週末            | 目標                    | 產出               | 預估工時 |\n| --------------- | ----------------------- | ------------------ | -------- |\n| 週末 1（Day 1） | 專案初始化 + 核心功能 A | 可以跑的 prototype | 8 小時   |\n| 週末 1（Day 2） | 核心功能 B + 基本 UI    | 可以用的 MVP       | 8 小時   |\n| 週末 2（Day 1） | 部署 + 修 bug           | 上線的產品         | 6 小時   |\n| 週末 2（Day 2） | 寫 Landing Page + 分享  | 第一批用戶         | 2 小時   |\n\n注意最後一天只留 2 小時——因為你需要預留 buffer。開發永遠比預期慢，但 deadline 不會等你。\n\n### 如果超時怎麼辦？\n\n如果週末 1 結束時進度落後了，不要加班趕工。停下來問自己：\n\n1. 是哪個功能花的時間比預期多？\n2. 那個功能可以簡化嗎？\n3. 有沒有哪個「必要」功能其實可以降級為「有用」？\n\n然後繼續砍。永遠是砍功能，不是加時間。\n\n## 常見的 MVP 設計陷阱\n\n### 陷阱 1：「這個功能很簡單，加一下不會怎樣」\n\n每個功能都很簡單。但十個簡單的功能加在一起就不簡單了。\n\n每次你想加功能的時候，問自己：**「如果這個功能不做，用戶是完全不能用這個產品，還是只是不太方便？」**\n\n如果答案是「不太方便」，那就不做。\n\n### 陷阱 2：把 MVP 做成 Demo\n\nMVP 是要給真實用戶用的，不是 demo。Demo 可以用假資料、hardcode 的流程。MVP 不行——它必須能讓用戶完成一個完整的使用流程，即使這個流程很簡陋。\n\n### 陷阱 3：「等我加完這個就上線」\n\n這句話你已經說了三次了。\n\n設一個死線。不是「功能做完的時候上線」，而是「這個日期不管如何一定上線」。到了那天，不管功能完成度如何，push to production。\n\n如果你的 MVP 設計得夠精簡，你會發現：即使有些功能只完成 80%，產品依然能用。而那 20% 的差距，你可以在上線後根據用戶回饋來補。\n\n### 陷阱 4：不好意思讓別人看到不完美的產品\n\nReid Hoffman（LinkedIn 創辦人）說過一句經典的話：\n\n> If you're not embarrassed by the first version of your product, you've launched too late.\n> 如果你的第一版產品沒有讓你覺得丟臉，那你上線得太晚了。\n\n你的 MVP 應該讓你有點不好意思。那代表你沒有過度打磨，把時間花在了正確的地方——驗證假設。\n\n## 本章重點回顧\n\n- 🎯 MVP 不是爛版本，而是「用最少功能驗證最關鍵假設」\n- 🔪 用「一個假設」框架：列出假設 → 找最高風險的 → 只為那一個設計功能\n- 📊 RICE 評分和 MoSCoW 分類，配合 AI 可以在 30 分鐘內完成功能排序\n- 📋 你的 Won't have 清單應該比 Must have 長三倍\n- ⏱️ MVP 開發時間上限：2-3 個週末（16-24 小時），超過就砍功能\n- 🚀 設定死線，不管如何到日期就上線\n\n## 下一步\n\n功能砍好了，MVP 設計好了。\n\n下一章，我們進入這本書最核心的部分：**怎麼用 AI 來開發。**\n\n不是那種「叫 AI 幫你寫一段 code」的用法——而是建造一整套 AI 開發系統，讓 AI 成為你的開發團隊。從 Vibe Coding 到 Agentic Workflow，一個人也能有三人團隊的產出。\n\n👉 [第 5 章：AI 驅動開發——從 Vibe Coding 到 Agentic Workflow](/blog/ai-solo-builder-ai-driven-dev)",
      "summary": "上班族做 side project 最大的敵人是「功能蔓延」。本文教你用 AI 輔助 MVP 設計與 feature prioritization，找出最高風險假設，砍到不能再砍，用最少時間驗證最關鍵的產品假設。",
      "image": "https://bobochen.dev/_astro/cover.BH3WXx1X.webp",
      "date_published": "2026-03-01T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "MVP",
        "產品設計",
        "AI",
        "Feature Prioritization"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/claude-api-guide-getting-started/",
      "url": "https://bobochen.dev/blog/claude-api-guide-getting-started/",
      "title": "Claude API 入門：帳號、費用與第一個 API 呼叫",
      "content_text": "從零開始使用 Claude API：API Key 申請、費用模型詳解（claude-opus-4-5、sonnet、haiku 定價比較）、Rate Limits，以及 curl、Python、TypeScript 三版本的第一個 API 呼叫。",
      "content_html": "如果你用過 Claude.ai，你知道它有多好用。\n\n但「好用」跟「能寫進你的產品」是兩回事。\n\nClaude.ai 是給人用的介面。Claude API 是給程式用的介面。這本書講的是後者——如何把 Claude 的能力嵌入你正在建造的東西。\n\n這一章是起點。我們會從最基礎的問題開始：Claude API 是什麼？跟 Claude.ai、Claude Code 有什麼差別？費用怎麼算？然後一步一步帶你發出第一個真正的 API 呼叫。\n\n## Claude API vs Claude.ai vs Claude Code：搞清楚你在用什麼\n\n在開始之前，我想先解決一個常見的混淆。\n\n**Claude.ai** 是 Anthropic 的消費者產品。你用瀏覽器開啟，跟 Claude 對話，有 Projects、Artifacts 等功能。費用是訂閱制（Free、Pro、Max）。你沒辦法「程式化」地呼叫它，也沒辦法把它整合進你的 app。\n\n**Claude Code** 是給開發者用的 CLI 工具（`@anthropic-ai/claude-code`）。它運行在你的本機，可以讀取你的程式碼、執行指令、幫你寫程式。費用走的是你個人的 API Key，或者 Claude Pro/Max 訂閱（所謂的 Max 模式）。\n\n**Claude API** 是 Anthropic 提供的 REST API，讓你用程式碼直接呼叫 Claude 的語言模型能力。你可以用它建立自己的聊天機器人、文件摘要工具、程式碼審查系統、客服 agent——任何需要 LLM 能力的應用。費用是 pay-per-use，按照你傳送和接收的 token 數量計費。\n\n這本書講的是 Claude API，以及建立在它之上的 Agent SDK（後面章節會介紹）。\n\n目標讀者是你：一個正在或準備建造 AI 應用的開發者。\n\n## 建立帳號與取得 API Key\n\n### 在 console.anthropic.com 建立帳號\n\n前往 [console.anthropic.com](https://console.anthropic.com)，用你的 Google 帳號或 email 註冊。這個「Console」是 Anthropic 的開發者平台，跟 Claude.ai 是分開的系統（雖然可以用同一個帳號登入）。\n\n註冊後你需要：\n\n1. **驗證你的 email**（如果用 email 註冊的話）\n2. **新增付款方式**：沒有信用卡就沒有辦法使用 API，這是必要步驟\n3. **設定使用量上限**（建議設定，避免意外高額帳單）\n\nAnthropic 不提供「永久免費層」的 API 使用量。你第一次加入信用卡後，帳號通常會有一些試用額度（金額不定，可能幾美金到十幾美金），但用完就要開始付費。\n\n### 產生 API Key\n\n進入 Console 後，在左側選單找到 **API Keys**。\n\n點擊「+ Create Key」，給它一個有意義的名字（例如：`my-chatbot-prod`、`local-dev`）。建議你**為不同的應用和環境建立不同的 Key**，這樣萬一哪個 Key 洩漏，你可以精確地 revoke 它，而不影響其他服務。\n\nKey 只會顯示一次。請立刻複製並存到安全的地方（密碼管理器、環境變數，**絕對不要 commit 到 git**）。\n\n### API Key 的安全守則\n\n這個很重要，我要單獨說：\n\n**永遠不要把 API Key 放進程式碼**。不管是 hardcode 在程式裡，還是放在公開的 GitHub repo 裡。Anthropic 的安全系統會自動掃描公開的 GitHub，一旦偵測到洩漏的 Key，會自動 revoke 並通知你。但這個流程走完之前，任何人都可以用你的 Key 燒你的錢。\n\n正確的做法是：\n\n```bash\n# 在你的開發環境\nexport ANTHROPIC_API_KEY=\"sk-ant-api03-...\"\n\n# 或者放在 .env 檔案（記得加到 .gitignore）\nANTHROPIC_API_KEY=sk-ant-api03-...\n```\n\n## 費用模型詳解：你真正需要知道的\n\nClaude API 的費用計算方式是「token 計費」。你傳送給 API 的文字（input tokens）和 API 回傳給你的文字（output tokens）分開計費，單位是每百萬 token（per million tokens，縮寫 MTok）。\n\n### 模型與定價（2026 年初）\n\nAnthropic 目前有三個主要的模型家族：\n\n| 模型                | Input        | Output     | 適用場景             |\n| ------------------- | ------------ | ---------- | -------------------- |\n| `claude-opus-4-5`   | $15 / MTok   | $75 / MTok | 複雜推理、高品質需求 |\n| `claude-sonnet-4-6` | $3 / MTok    | $15 / MTok | 日常應用、最佳 CP 值 |\n| `claude-haiku-4-5`  | $0.80 / MTok | $4 / MTok  | 高頻低延遲場景       |\n\n我自己的生產環境幾乎都用 `claude-sonnet-4-6`。它在品質和費用之間找到了很好的平衡。`claude-haiku-4-5` 我用在需要大量平行處理、或者對延遲非常敏感的場景（例如即時分析使用者輸入）。`claude-opus-4-5` 我保留給真的需要最高品質推理的任務，例如複雜的程式架構分析。\n\n### Token 是什麼、大概多少字？\n\n一個 token 大約是 0.75 個英文單字，或者 0.5 個中文字符。所以：\n\n- 1000 個英文字 ≈ 1,300 tokens\n- 1000 個中文字 ≈ 2,000 tokens（中文每個字通常是 1-2 tokens）\n\n以 `claude-sonnet-4-6` 為例，用繁體中文寫一篇 2000 字的文章大約是 4000 output tokens，費用約 $0.06（不到台幣 2 元）。很便宜，但如果你的應用有大量使用者，這些費用會累積。\n\n### Context Window 與費用的關係\n\n每個模型都有 context window 的上限（目前 Claude 模型通常是 200K tokens）。\n\n**重要提醒：你傳送給 API 的每個請求，都要附上整段對話歷史。** 也就是說，一個 10 輪的對話，第 10 輪請求的 input tokens 包含了前 9 輪的所有對話內容。這在費用計算上有重要影響：**長對話會越來越貴**。\n\nPrompt Caching 是 Anthropic 提供的功能，可以讓你快取常用的 system prompt 或文件內容，重複使用時只收少量快取讀取費用而不是全額。後面章節會詳細介紹。\n\n### Rate Limits\n\n除了費用，你還需要了解 Rate Limits（速率限制）。\n\nAnthropic 對 API 使用有兩種限制：\n\n1. **RPM（Requests Per Minute）**：每分鐘可以發出的請求數\n2. **TPM（Tokens Per Minute）**：每分鐘可以消耗的 token 數\n\nRate limits 會隨著你的帳號使用量和消費金額自動提升（Anthropic 稱為 Usage Tiers）。新帳號通常從 Tier 1 開始，限制較低。如果你預計有大量使用，可以在 Console 申請提升。\n\n遇到 Rate Limit 錯誤時，API 會回傳 `429 Too Many Requests`。正確的處理方式是使用指數退避（exponential backoff）重試，而不是立刻再試。\n\n## 第一個 API 呼叫\n\n理論說夠了，來動手。\n\n### 用 curl 測試（最快）\n\n這是最快驗證你的 API Key 是否有效的方法：\n\n```bash\ncurl https://api.anthropic.com/v1/messages \\\n  --header \"x-api-key: $ANTHROPIC_API_KEY\" \\\n  --header \"anthropic-version: 2023-06-01\" \\\n  --header \"content-type: application/json\" \\\n  --data '{\n    \"model\": \"claude-sonnet-4-6\",\n    \"max_tokens\": 1024,\n    \"messages\": [\n      {\n        \"role\": \"user\",\n        \"content\": \"你好！請用繁體中文介紹你自己，不超過 50 字。\"\n      }\n    ]\n  }'\n```\n\n如果你的 Key 正確，你會看到類似這樣的回應：\n\n```json\n{\n  \"id\": \"msg_01XFDUDYJgAACzvnptvVoYEL\",\n  \"type\": \"message\",\n  \"role\": \"assistant\",\n  \"content\": [\n    {\n      \"type\": \"text\",\n      \"text\": \"你好！我是 Claude，由 Anthropic 開發的 AI 助理。我能協助回答問題、分析資料、撰寫文章，以及進行各種語言任務。很高興認識你！\"\n    }\n  ],\n  \"model\": \"claude-sonnet-4-6-20251101\",\n  \"stop_reason\": \"end_turn\",\n  \"usage\": {\n    \"input_tokens\": 28,\n    \"output_tokens\": 42\n  }\n}\n```\n\n注意 `usage` 欄位——這就是這次呼叫消耗的 token 數。\n\n### Python SDK\n\nAnthropic 提供官方的 Python SDK，強烈建議使用它，而不是自己呼叫 REST API。\n\n```bash\npip install anthropic\n```\n\n```python\nimport anthropic\n\n# 如果環境變數 ANTHROPIC_API_KEY 已設定，不需要傳入 api_key\nclient = anthropic.Anthropic()\n\nmessage = client.messages.create(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=1024,\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"你好！請用繁體中文介紹你自己，不超過 50 字。\"\n        }\n    ]\n)\n\n# 取出文字回應\nprint(message.content[0].text)\n\n# 查看 token 使用量\nprint(f\"Input tokens: {message.usage.input_tokens}\")\nprint(f\"Output tokens: {message.usage.output_tokens}\")\n```\n\nSDK 會自動從環境變數 `ANTHROPIC_API_KEY` 讀取 Key，不需要你手動傳入（除非你想要明確指定）。\n\n如果你想加上 system prompt：\n\n```python\nmessage = client.messages.create(\n    model=\"claude-sonnet-4-6\",\n    max_tokens=1024,\n    system=\"你是一位熟悉台灣軟體業的 AI 助理，回答時請使用繁體中文，語氣專業但平易近人。\",\n    messages=[\n        {\n            \"role\": \"user\",\n            \"content\": \"2026 年台灣開發者最應該學習的技術是什麼？\"\n        }\n    ]\n)\n\nprint(message.content[0].text)\n```\n\n### TypeScript / Node.js SDK\n\n```bash\nnpm install @anthropic-ai/sdk\n```\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n// API Key 同樣從環境變數 ANTHROPIC_API_KEY 自動讀取\n\nasync function main() {\n  const message = await client.messages.create({\n    model: 'claude-sonnet-4-6',\n    max_tokens: 1024,\n    messages: [\n      {\n        role: 'user',\n        content: '你好！請用繁體中文介紹你自己，不超過 50 字。',\n      },\n    ],\n  });\n\n  // TypeScript 版本有完整的型別支援\n  const textContent = message.content[0];\n  if (textContent.type === 'text') {\n    console.log(textContent.text);\n  }\n\n  console.log(`Input tokens: ${message.usage.input_tokens}`);\n  console.log(`Output tokens: ${message.usage.output_tokens}`);\n}\n\nmain();\n```\n\nTypeScript SDK 的一個優點是有完整的型別定義，你的 IDE 會給你自動補全和型別檢查。`message.content` 是一個 array，每個元素可能是 `text` block 或 `tool_use` block（工具呼叫，後面章節會介紹），所以你需要用 `type` 欄位來判斷。\n\n## 環境設定建議\n\n在你開始正式開發之前，我推薦這樣設定你的開發環境：\n\n### 使用 .env 檔案（搭配 python-dotenv 或 dotenv）\n\n**Python：**\n\n```bash\npip install python-dotenv\n```\n\n```python\n# .env 檔案內容\nANTHROPIC_API_KEY=sk-ant-api03-...\n\n# 在你的 Python 程式開頭\nfrom dotenv import load_dotenv\nload_dotenv()\n\nimport anthropic\nclient = anthropic.Anthropic()  # 自動從 .env 讀取\n```\n\n**TypeScript / Node.js：**\n\n```bash\nnpm install dotenv\n```\n\n```typescript\n// .env 檔案內容\n// ANTHROPIC_API_KEY=sk-ant-api03-...\n\nimport 'dotenv/config';\nimport Anthropic from '@anthropic-ai/sdk';\n\nconst client = new Anthropic();\n```\n\n**.gitignore 設定（非常重要！）：**\n\n```bash\n# .gitignore\n.env\n.env.local\n.env.*.local\n```\n\n### 設定費用警示\n\n在 Console 的 **Billing** 頁面，你可以設定：\n\n- **Monthly Spend Limit**：每月消費上限，超過就自動停止 API 服務\n- **Email Alerts**：當消費達到某個門檻時發 email 通知\n\n我建議新手一定要設定 Monthly Spend Limit。我見過有人因為程式碼 bug（例如無限迴圈一直呼叫 API）而在幾個小時內燒掉幾十美金。有上限保護，最糟糕的情況就是 API 暫停服務，而不是無底洞的帳單。\n\n## 常見入門問題\n\n**Q: 我要選哪個模型？**\n\n從 `claude-sonnet-4-6` 開始。它的品質夠好、速度夠快、費用合理，是目前大多數應用的最佳起點。等你的應用上線、了解自己的使用模式之後，再考慮是否要優化成 Haiku（降低成本）或升級成 Opus（提升品質）。\n\n**Q: API 回應太慢怎麼辦？**\n\n有幾個方向：\n\n1. 用更小的模型（Haiku 比 Sonnet 快很多）\n2. 用 Streaming（下一章會介紹），讓使用者更快看到第一個字\n3. 減少 context 長度（更短的 system prompt、更少的對話歷史）\n\n**Q: 為什麼 `max_tokens` 是必填的？**\n\n這是設計上的選擇。Anthropic 希望你明確指定你預期的最大回應長度，而不是讓模型無限制地回應。這有助於你控制成本，也避免你的應用等待過久。通常設 1024 到 4096 是合理的範圍，視你的應用需求而定。\n\n**Q: `anthropic-version: 2023-06-01` 這個 header 是什麼？**\n\n這是 API 版本 header，確保你的程式碼在 Anthropic 更新 API 時不會意外 breaking。使用 SDK 時，SDK 會自動幫你加上正確的版本，你不需要手動設定。\n\n## 下一步\n\n你現在已經完成了最基礎的部分：有一個能用的 API Key，理解費用的運作方式，也發出了第一個 API 呼叫。\n\n但你可能注意到，我們的範例都是一來一往的單輪對話。實際的應用幾乎都需要多輪對話——使用者問問題，AI 回答，使用者追問，AI 再回答。\n\n**下一章**，我們會深入了解 Messages API 的核心設計：什麼是 roles、如何管理多輪對話、system prompt 的最佳實踐，以及各種參數（temperature、top_p、stop_sequences）的實際用法。",
      "summary": "從零開始使用 Claude API：API Key 申請、費用模型詳解（claude-opus-4-5、sonnet、haiku 定價比較）、Rate Limits，以及 curl、Python、TypeScript 三版本的第一個 API 呼叫。",
      "image": "https://bobochen.dev/_astro/cover.Oi8K9qZe.webp",
      "date_published": "2026-02-27T00:00:00.000Z",
      "tags": [
        "Claude API",
        "API Key",
        "入門",
        "費用"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-tech-stack/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-tech-stack/",
      "title": "技術選型決策框架",
      "content_text": "一個人做產品，技術選型的標準跟團隊完全不同。不是選「最好」的技術，而是選「一個人最能掌控」的技術。這篇建立一套決策框架，幫你在 AI 輔助下快速做出正確選擇。",
      "content_html": "## 技術選型的致命誤區\n\n「用什麼框架好？」\n\n這是工程師最喜歡討論的話題。也是 Solo Builder 最容易浪費時間的地方。\n\n我看過太多 side project 死在技術選型階段。不是因為選錯了，而是因為**花太久在選**。花一堆時間比較 Next.js vs Nuxt vs SvelteKit vs Astro，看了一堆 benchmark，翻了一串 Reddit 討論，最後選了一個——然後沒過多久又開始懷疑。\n\n這裡有一個殘酷的事實：**對 Solo Builder 來說，技術選型的重要性被嚴重高估了。**\n\n為什麼？因為你只有一個人。你不需要考慮：\n\n- 「新人能不能快速上手」（沒有新人）\n- 「團隊內有誰熟悉這個技術」（團隊只有你）\n- 「微服務之間的通訊協定」（你大概不需要微服務）\n- 「未來 scale 到百人團隊時的維護性」（如果真到那天，你已經有資源重寫了）\n\nSolo Builder 的技術選型，只需要問一個核心問題：**用這個技術，我一個人能多快把東西做出來？**\n\n## Solo Builder 的技術選型五原則\n\n在比較任何具體技術之前，先記住這五個原則。它們比任何 benchmark 都重要。\n\n### 原則 1：一種語言打天下\n\n每多用一種語言，你的上下文切換成本就翻倍。\n\n在團隊裡，前端一組人用 TypeScript，後端一組人用 Go，資料管線一組人用 Python，這沒問題——每組人專注自己的語言。\n\n但你只有一個人。如果你的前端是 TypeScript、後端是 Python、部署腳本是 Bash、資料處理是 SQL stored procedure，你每天都在四種語言之間跳來跳去。光是記住四種語言的語法差異就夠累了。\n\n**建議：選一種語言，覆蓋盡可能多的領域。**\n\n2026 年，TypeScript 是 Solo Builder 最全能的選擇：\n\n- 前端（React、Vue、Svelte、Astro）\n- 後端（Node.js、Bun、Deno、Hono）\n- API（tRPC、Hono RPC）\n- CLI 工具（Commander、tsx）\n- 腳本（取代 Bash 做自動化）\n- AI 整合（Anthropic SDK、OpenAI SDK）\n- Infrastructure as Code（Pulumi、SST）\n\n一種語言，從前端到 IaC 全部搞定。AI 也最擅長 TypeScript——訓練資料最多、生成品質最好、社群資源最豐富。\n\n### 原則 2：選生態系，不選框架\n\n框架會過時，生態系會持續演進。\n\n與其糾結「Next.js 還是 React Router（前 Remix）」，不如先決定你要進入哪個生態系。例如：\n\n- **Cloudflare 生態系**：Workers + Pages + D1 + R2 + KV，全部免費額度充足\n- **Vercel 生態系**：Next.js + Vercel Hosting + Edge Functions\n- **AWS 生態系**：Lambda + DynamoDB + S3 + CloudFront\n\n一旦選定生態系，框架的選擇就自然縮窄了。而且生態系內的工具整合度最高——你不會花時間在「讓 A 服務跟 B 服務溝通」上面。\n\n### 原則 3：免費額度是你的跑道\n\nSolo Builder 的前幾個產品，理想情況下**零成本營運**。\n\n不是因為付不起——而是因為在你驗證 product-market fit 之前，每一分錢的營運成本都是風險。如果產品沒人用，你可以隨時關掉，不會有任何沈沒成本。\n\n| 服務               | 免費額度           | 足夠支撐          |\n| ------------------ | ------------------ | ----------------- |\n| Cloudflare Workers | 10 萬次 request/天 | 小型 SaaS 前期    |\n| Cloudflare D1      | 5 GB 儲存          | 中型應用的資料庫  |\n| Cloudflare R2      | 10 GB 儲存         | 檔案上傳功能      |\n| Vercel             | 100 GB 頻寬/月     | 個人網站或文件站  |\n| Supabase           | 500 MB 資料庫      | 認證 + 資料庫     |\n| Turso              | 5 GB 儲存          | SQLite 邊緣資料庫 |\n\n> **注意：** Vercel 的 Hobby（免費）方案依官方條款僅限個人、非商業用途。任何會產生營收的 SaaS、電商或接案產品都必須升級到 Pro（$20/月）。如果你的目標是零成本營運商業產品，部署層建議以 Cloudflare 為主——其免費額度允許商業用途。\n\n**選技術的時候，把免費額度當成核心考量。** 一個每月要花 $50 維護的 side project，你心裡會一直有壓力。一個零成本的 side project，你可以放心地慢慢迭代。\n\n### 原則 4：AI 友善度\n\n2026 年，這個原則的重要性已經超過了傳統的「社群大小」或「文件品質」。\n\n**AI 友善度**指的是：AI 工具（Claude Code、Copilot、Cursor）在這個技術上的表現如何？\n\nAI 友善度高的技術：\n\n- TypeScript（訓練資料量最大）\n- React（最多範例程式碼）\n- Tailwind CSS（語義清晰、AI 生成品質高）\n- Prisma / Drizzle（Schema-first，AI 很擅長）\n- Hono（類似 Express 的直覺 API，AI 理解度高）\n\nAI 友善度低的技術：\n\n- 太新的框架（訓練資料不足）\n- 自創的 DSL 或 convention 太獨特的框架\n- 需要大量隱式設定的工具（AI 容易遺漏）\n\n**選 AI 擅長的技術，你的開發速度會快 3-5 倍。** 選 AI 不熟的技術，你反而要花時間修正 AI 生成的錯誤程式碼。\n\n### 原則 5：可逆性優先\n\n如果你不確定，選一個容易換掉的。\n\n有些技術選擇是單向門——一旦選了就很難回頭。例如：資料庫、認證系統、核心框架。\n\n有些是雙向門——隨時可以換。例如：CSS 方案、HTTP client、測試框架。\n\n**對單向門的選擇要謹慎，對雙向門的選擇不要糾結。**\n\n| 決策類型 | 例子                    | 可逆性  | 建議               |\n| -------- | ----------------------- | ------- | ------------------ |\n| 程式語言 | TypeScript vs Go        | ❌ 極低 | 花 30 分鐘認真選   |\n| 資料庫   | PostgreSQL vs SQLite    | ❌ 低   | 考慮清楚再決定     |\n| 框架     | Astro vs Next.js        | 🔶 中   | 花 15 分鐘比較     |\n| CSS 方案 | Tailwind vs CSS Modules | ✅ 高   | 隨便選，不喜歡再換 |\n| 測試框架 | Vitest vs Jest          | ✅ 高   | 用預設就好         |\n| 部署平台 | Cloudflare vs Vercel    | 🔶 中   | 跟著生態系走       |\n\n## AI 輔助技術選型實戰\n\n有了五個原則之後，具體怎麼用 AI 來加速選型？\n\n### 步驟 1：描述你的產品需求\n\n```text\n我要做一個 [產品描述]。\n\n需求：\n- 核心功能：[列出 3-5 個最重要的功能]\n- 預期用戶量：[前期預估]\n- 互動性：[靜態為主 / 高度互動 / 即時通訊]\n- 資料庫需求：[簡單 CRUD / 複雜查詢 / 即時同步]\n- 預算：零（用免費額度）\n- 開發者人數：1 人\n- 我熟悉的技術：[列出你已經會的]\n- 時間限制：每週 5-10 小時\n\n請推薦一個技術棧，以及為什麼這樣選。\n要考慮 AI 輔助開發的友善度。\n```\n\n### 步驟 2：讓 AI 比較兩個候選方案\n\n如果 AI 推薦了 A 方案但你心裡傾向 B 方案，不要糾結——直接讓 AI 比較：\n\n```text\n方案 A：[AI 推薦的技術棧]\n方案 B：[你傾向的技術棧]\n\n請從以下維度比較，以我的情況（一人開發、每週 5-10 小時、零預算）來評估：\n\n1. 開發速度：從零到 MVP 需要多久？\n2. AI 輔助友善度：Claude Code / Copilot 對哪個表現更好？\n3. 免費額度：前期營運成本比較\n4. 學習成本：如果我不熟悉，要多久才能上手？\n5. 社群支援：遇到問題時能多快找到答案？\n6. 可維護性：三個月不碰之後，回來能多快恢復？\n\n最後給一個明確建議。\n```\n\n### 步驟 3：30 分鐘做決定\n\n看完 AI 的分析之後，**在 30 分鐘內做出決定**。\n\n是的，30 分鐘。\n\n不完美的選擇 + 馬上開始動手 > 完美的選擇 + 多想兩週。\n\n選了之後，就不要回頭看。把精力放在做產品上，不是放在懷疑技術選型上。\n\n## 我的 Solo Builder 技術棧推薦\n\n把前面五個原則套到實際選型上，這是我自己會用的 2026 年 Solo Builder 技術棧（實際應用案例見[第 13 章：實戰案例](/blog/ai-solo-builder-case-studies/)）：\n\n### 推薦組合 A：靜態為主的產品\n\n**適合：** 部落格、文件站、[Landing Page](/blog/ai-solo-builder-landing-page-seo/)、行銷網站、課程平台\n\n| 層級 | 技術             | 原因                                        |\n| ---- | ---------------- | ------------------------------------------- |\n| 框架 | Astro            | 靜態優先、island architecture、生態系豐富   |\n| 樣式 | Tailwind CSS     | AI 生成品質最高、utility-first 適合快速開發 |\n| 部署 | [Cloudflare Pages](/blog/ai-solo-builder-deployment/) | 免費、全球 CDN、build 速度快                |\n| CMS  | Markdown + Git   | 零成本、版本控制、AI 可直接生成             |\n| 搜尋 | Pagefind         | 靜態站搜尋、零後端                          |\n\n### 推薦組合 B：互動式 Web App\n\n**適合：** SaaS 產品、Dashboard、管理後台、CRUD 應用\n\n| 層級   | 技術                       | 原因                               |\n| ------ | -------------------------- | ---------------------------------- |\n| 框架   | Astro + React（island）    | 靜態頁面用 Astro、互動區塊用 React |\n| 後端   | Hono on Workers            | 輕量、TypeScript、Cloudflare 原生  |\n| 資料庫 | D1 (SQLite) 或 Turso       | 免費額度大、SQLite 簡單好管理      |\n| ORM    | Drizzle                    | Schema-first、TypeScript、AI 友善  |\n| 認證   | Better Auth                | 輕量、不綁定廠商、活躍維護中       |\n| 部署   | Cloudflare Workers + Pages | 全棧免費                           |\n\n### 推薦組合 C：有即時需求的應用\n\n**適合：** 即時通訊、協作工具、多人遊戲\n\n| 層級     | 技術                                            | 原因                         |\n| -------- | ----------------------------------------------- | ---------------------------- |\n| 框架     | Next.js 或 SvelteKit                            | SSR + 即時需求支援好         |\n| 即時通訊 | Cloudflare Durable Objects 或 Supabase Realtime | WebSocket / 長連線           |\n| 資料庫   | Supabase (PostgreSQL)                           | 即時訂閱、Row Level Security |\n| 部署     | Vercel 或 Cloudflare                            | 看框架選平台                 |\n\n### 你不需要的東西\n\n最後，列出 Solo Builder **不需要**的技術，省得你花時間研究：\n\n- ❌ **Kubernetes** — 一個人不需要容器編排，用 serverless\n- ❌ **Terraform / Pulumi** — 你的基礎設施沒有複雜到需要 IaC\n- ❌ **微服務架構** — 一個 monolith 就夠了\n- ❌ **GraphQL** — REST API 加上 TypeScript 的型別安全就夠了\n- ❌ **Redis** — Cloudflare KV 或 D1 就夠用了\n- ❌ **自建認證系統** — 用現成的 auth 套件，不要自己做\n- ❌ **多語言 monorepo** — 用 TypeScript，一種語言搞定\n\n每一個「不選」的決定，都是省下來的時間。\n\n## 本章重點回顧\n\n- 🎯 Solo Builder 的技術選型核心問題只有一個：用這個技術，我一個人能多快做出來？\n- 🔤 一種語言打天下，TypeScript 是 2026 年最全能的選擇\n- 🌐 選生態系不選框架，整合度決定開發速度\n- 💰 免費額度是跑道，零成本營運讓你放心迭代\n- 🤖 AI 友善度比社群大小更重要\n- 🚪 可逆性高的選擇不要糾結，可逆性低的花 30 分鐘認真想\n- ⏱️ 技術選型不要超過一天，不完美的選擇 + 馬上動手 > 完美的選擇 + 多想兩週\n\n## 下一步\n\n技術棧決定了？\n\n下一章，我們來設計你的 MVP。Solo Builder 最大的敵人是「功能蔓延」——什麼都想做，結果什麼都做不完。我會教你一套方法，把產品砍到只剩最核心的部分，然後用最少時間做出來。\n\n👉 [第 4 章：MVP 設計——砍到不能再砍](/blog/ai-solo-builder-mvp-design)",
      "summary": "一個人做產品，技術選型的標準跟團隊完全不同。不是選「最好」的技術，而是選「一個人最能掌控」的技術。這篇建立一套決策框架，幫你在 AI 輔助下快速做出正確選擇。",
      "image": "https://bobochen.dev/_astro/cover.B30fpMK7.webp",
      "date_published": "2026-02-22T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "技術選型",
        "TypeScript",
        "Astro",
        "Cloudflare"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-idea-validation/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-idea-validation/",
      "title": "點子驗證：花一天而不是一個月",
      "content_text": "點子驗證是 side project 不浪費時間的第一步。學會用 AI 在一天內完成市場調查、競品分析與用戶訪談模擬，快速確認你的點子值不值得投入寶貴的下班時間，避免花兩個月做出沒人要的東西。",
      "content_html": "## Side Project 最常見的死法：跳過點子驗證\n\n你有一個很棒的點子。\n\n你興奮地打開編輯器，選了框架、建了 repo、開始寫第一個功能。三個週末過去了，prototype 有了雛形。再花兩個週末做 UI、處理 edge case、加上登入功能。\n\n兩個月後，你終於覺得可以給朋友看了。\n\n結果朋友說：「喔，這個啊……其實已經有一個 app 在做了，而且免費。」\n\n或者更慘的版本：你把連結丟到社群裡，得到三個愛心、零留言、零下載。\n\n兩個月的下班時間，就這樣蒸發了。\n\n這不是你的技術有問題。這是你跳過了最重要的一步：**驗證這個點子值不值得做。**\n\n好消息是，2026 年你不需要花一個月做市場調查。你需要的只是一個下午和 AI。\n\n## 點子驗證的三個必答問題\n\n在你寫任何一行程式碼之前，你需要回答三個問題：\n\n1. **有人有這個問題嗎？**（市場存在性）\n2. **他們現在怎麼解決？**（競品分析）\n3. **為什麼你的方案會更好？**（差異化優勢）\n\n如果三個問題的答案都很正向，這個點子值得花時間。如果有任何一個答案是「不確定」或「其實沒有」，你剛剛省下了兩個月的時間。\n\n讓我一個一個帶你用 AI 來回答。\n\n## 第一步：確認問題存在（1 小時）\n\n最危險的假設是：「因為我有這個問題，所以其他人也有。」\n\n有時候確實是這樣。但有時候你的問題太小眾、太特殊、或者只有你自己覺得是問題。\n\n### 傳統做法\n\n- 在 Google 搜相關關鍵字，看有沒有人在討論\n- 逛 PTT、Dcard、Reddit 找相關抱怨文\n- 在社群發問「大家有沒有遇過 XXX 的困擾」\n- 做問卷（然後花一週等回收）\n\n→ 至少花 3-5 天，而且覆蓋面有限。\n\n### AI 加持做法\n\n打開你的 AI 助手，用一組結構化的 prompt 來做快速市場掃描：\n\n```text\n我想做一個 [簡短描述你的產品點子]。\n\n請幫我分析：\n\n1. 這個問題的普遍性\n   - 誰會遇到這個問題？估計影響人數？\n   - 在哪些場景下會遇到？\n   - 這是偶爾遇到還是經常遇到？\n\n2. 現有的討論和需求信號\n   - 搜尋相關關鍵字，這類問題在技術社群（Stack Overflow、Reddit、\n     GitHub Issues）的討論熱度如何？\n   - 有沒有相關的 Feature Request 或 Upvote？\n   - 繁中市場（PTT、Dcard、iThome）有沒有類似討論？\n\n3. 付費意願信號\n   - 有沒有人願意為解決這個問題付費？\n   - 類似的解決方案目前的定價範圍？\n   - 企業用戶還是個人用戶更可能付費？\n\n請用繁體中文回答，並附上你的判斷依據。\n```\n\nAI 不會給你 100% 正確的答案——但它會在 15 分鐘內給你一個 **方向性的判斷**。\n\n更重要的是，它會幫你想到你自己沒想到的角度。「你以為是 A 族群的問題，但其實 B 族群更有需求」——這種洞察靠自己想很難想到，但 AI 的廣泛知識可以幫你觸及。\n\n### 進階技巧：用 AI 模擬用戶訪談\n\n這是我最喜歡的驗證方法。請 AI 扮演你的目標用戶：\n\n```text\n請你扮演一個 [目標用戶描述]。\n\n你平常的工作內容是 [描述]。你對技術的熟悉程度是 [描述]。\n\n我正在做一個 [你的產品]，它可以 [核心功能]。\n\n請以你扮演的角色，真實地回答以下問題：\n1. 你覺得這個產品對你有用嗎？為什麼？\n2. 你現在怎麼解決這個問題？\n3. 如果有這個產品，你願意每月付多少錢？\n4. 你最大的擔心或疑慮是什麼？\n5. 你覺得這個產品少了什麼功能你就不會用？\n\n請不要客氣，給我最真實的反應。\n```\n\n然後換不同的角色描述，模擬 3-5 種不同的目標用戶。\n\n是的，這不如真人訪談精確。但它有兩個巨大的優勢：**快**（30 分鐘做完 5 種用戶類型的訪談），而且**沒有社交壓力偏差**（真人訪談中，對方可能因為禮貌而不好意思說「我不需要」）。\n\nAI 模擬訪談適合用來「快速淘汰明顯沒市場的點子」，而不是用來「精確預測市場規模」。這對 Solo Builder 來說已經非常夠用了。產品上線後若想建立真實的回饋機制，可參考[第 9 章：用戶回饋循環](/blog/ai-solo-builder-user-feedback)。\n\n## 第二步：競品分析（1 小時）\n\n如果第一步確認問題存在，接下來要問：**別人怎麼解決這個問題？**\n\n有競品不是壞事。有競品代表有市場。沒競品可能代表沒人要。\n\n關鍵是你要找到**差異化的空間**。\n\n### AI 加持的競品分析\n\n```text\n我想做一個 [你的產品]，主要解決 [問題描述]。\n\n請幫我做競品分析：\n\n1. 直接競品（功能幾乎一樣的產品）\n   - 列出前 5 個，附上名稱、網址、定價\n   - 各自的優缺點\n   - 在 Product Hunt / G2 / Capterra 上的評價\n\n2. 間接競品（用不同方式解決同一個問題）\n   - 例如：用 Excel 手動處理、用通用工具替代\n   - 這些替代方案的痛點是什麼？\n\n3. 市場空白\n   - 現有競品的共同弱點是什麼？\n   - 哪些用戶需求沒有被滿足？\n   - 有沒有特定地區（例如台灣繁中市場）的空白？\n\n4. 差異化機會\n   - 基於以上分析，如果要做這個產品，\n     最有潛力的差異化方向是什麼？\n```\n\n拿到結果之後，你可以進一步深入。挑出最強的 2-3 個競品，請 AI 幫你做更細的分析：\n\n```text\n請深入分析 [競品名稱]：\n\n1. 它的技術架構可能是什麼？\n2. 它的商業模式（免費增值？訂閱制？一次買斷？）\n3. 它的用戶評論中，最常見的抱怨是什麼？\n4. 它最近半年有什麼重大更新或方向調整？\n5. 它的弱點在哪裡，我可以切入的角度？\n```\n\n### 競品矩陣：一張圖看清全局\n\n分析完之後，把結果整理成一張表：\n\n| 競品         | 免費方案 | 核心功能     | 最大弱點   | 定價      |\n| ------------ | -------- | ------------ | ---------- | --------- |\n| 競品 A       | ✅ 有    | 功能 1, 2, 3 | 不支援中文 | $10/月    |\n| 競品 B       | ❌ 無    | 功能 1, 2    | 介面複雜   | $29/月    |\n| 競品 C       | ✅ 有    | 功能 1       | 功能太少   | 免費      |\n| **你的產品** | ✅ 有    | 功能 1, 2, 4 | **待驗證** | **$X/月** |\n\n這張表的目的很簡單：**你的產品必須在至少一個維度上明顯優於所有競品。** 如果你找不到這個維度，要嘛重新定義你的差異化，要嘛放棄這個點子。\n\n放棄不是失敗。放棄一個沒有差異化空間的點子，是做出了一個好的商業決策。\n\n## 第三步：快速原型驗證（2 小時）\n\n前兩步是「動腦」驗證。第三步是「動手」驗證——但不是寫程式碼。\n\n### 做一個 Landing Page，不做產品\n\n你沒看錯。\n\n在你寫任何程式碼之前，先做一個 Landing Page。上面寫清楚：\n\n- 你的產品解決什麼問題\n- 它怎麼運作（可以用示意圖）\n- 定價方案\n- 一個 **等候名單** 或 **預先註冊** 的表單\n\n然後把這個 Landing Page 的連結分享到相關社群。\n\n如果有人填了表單——恭喜，你有了第一批潛在用戶。\n\n如果沒人理——你剛剛省了兩個月的開發時間。（驗證階段只需簡單頁面即可；正式的 SEO 優化與轉換率調整，留到[第 7 章：Landing Page 與 SEO](/blog/ai-solo-builder-landing-page-seo)再處理。）\n\n### AI 加持做 Landing Page\n\n2026 年，做一個 Landing Page 不需要兩天。用 AI 可以在 2 小時內搞定：\n\n1. **文案**（30 分鐘）：用 AI 生成 headline、sub-headline、feature list、FAQ、CTA 按鈕文字\n2. **設計**（30 分鐘）：用 v0 生成 React 元件，或用 Claude 直接生成 Astro/React 頁面，或用 Framer 拖拉\n3. **部署**（30 分鐘）：推到 Cloudflare Pages 或 Vercel，免費\n4. **表單**（30 分鐘）：Google Forms 或 Tally，免費\n\n文案生成的 prompt：\n\n```text\n我正在做一個 [產品名稱]，它幫助 [目標用戶] 解決 [問題]。\n\n請幫我撰寫一個 Landing Page 的文案，包含：\n\n1. Headline：一句話說清楚產品價值（15 字以內）\n2. Sub-headline：補充說明（30 字以內）\n3. 3 個核心功能區塊：\n   - 每個有一個標題（8 字以內）和一段說明（50 字以內）\n4. 「為什麼選我們」：3 個差異化賣點\n5. 定價方案（免費 + 付費兩檔）\n6. FAQ：5 個常見問題\n7. CTA 按鈕文字\n\n語氣：專業但不冰冷，口語化但不隨便。繁體中文。\n```\n\n## 一天驗證流程：時間表\n\n把以上三步串起來，你的一天驗證流程長這樣：\n\n| 時間          | 步驟                  | 產出                           |\n| ------------- | --------------------- | ------------------------------ |\n| 09:00 - 10:00 | 問題存在性分析        | 市場掃描報告 + AI 模擬訪談     |\n| 10:00 - 11:00 | 競品分析              | 競品矩陣 + 差異化定位          |\n| 11:00 - 12:00 | 休息 + 消化           | 決定「做 / 不做 / 調整方向」   |\n| 13:00 - 15:00 | Landing Page 製作     | 上線的 Landing Page + 等候名單 |\n| 15:00 - 16:00 | 分享到 3-5 個相關社群 | 觀察反應                       |\n\n一天。6 個小時。\n\n如果結論是「不做」，你省下了兩個月的下班時間。\n\n如果結論是「做」，你已經有了市場調查報告、競品分析、差異化定位、Landing Page、和第一批潛在用戶名單。\n\n大多數 side project 連這些都沒有就開始寫程式碼了。你已經贏在起跑點。\n\n## 驗證的常見陷阱\n\n### 陷阱 1：確認偏差\n\n你太想做這個東西了，所以會不自覺地只看正面信號、忽略負面信號。\n\n**對策**：在做驗證之前，先寫下「如果以下三件事中有任何一件成立，我就放棄」的 kill criteria。例如：\n\n- 已有 3 個以上免費競品且評價 4.5+\n- AI 模擬訪談中，5 種用戶有 3 種以上表示不需要\n- Landing Page 一週內零人填表\n\n### 陷阱 2：過度驗證\n\n另一個極端：一直在做調查、一直不開始動手。\n\n驗證的目的不是消除所有風險，而是**把最大的風險降到可接受的程度**。如果你的三個必答問題都有還不錯的答案，就可以進入下一步了。\n\n上班族的時間很寶貴，花太久驗證也是一種浪費。\n\n### 陷阱 3：把 AI 的回答當聖旨\n\nAI 的市場分析是基於它的訓練資料，不是即時數據。它可能不知道上個月剛冒出來的新競品，也可能高估或低估特定市場的規模。\n\nAI 的回答是**參考**，不是**結論**。用 AI 來加速你的思考，但最終判斷是你的。\n\n## 真實案例：我怎麼驗證「登雲學院」\n\n讓我用自己的真實案例來示範。\n\n我想做一個 GCP 認證的中文線上課程平台（cloud-on-academy）。在開始寫任何程式碼之前，我做了這些驗證：\n\n**問題存在性：** 在 PTT 和技術社群搜尋「GCP 認證」，發現大量「有沒有中文資源推薦」的問題。確認需求存在。\n\n**競品分析：** 發現中文市場的 GCP 認證課程極少。Coursera 和 Udemy 有英文課程，但繁中市場幾乎是空白。AWS 認證的中文資源比 GCP 多很多。\n\n**差異化：** 繁中市場第一個系統化的 GCP 認證課程，用台灣工程師的實戰經驗切入，不是翻譯。\n\n**Landing Page：** 做了一個簡單的頁面，放上課程大綱和等候名單表單。\n\n**結果：** 一週內收到足夠的表單填寫，確認值得繼續做。\n\n整個驗證流程，我花了一個週末。\n\n如果 2026 年用 AI 來做同樣的事，可能半天就夠了。\n\n## 本章重點回顧\n\n- 🎯 寫程式碼之前，先回答三個問題：問題存不存在？別人怎麼解決？你有什麼差異化？\n- 🤖 用 AI 加速：市場掃描、用戶訪談模擬、競品分析，傳統一個月的工作壓縮到一天\n- 📄 做 Landing Page 比做 prototype 更重要——先確認有人要，再開始做\n- ⚠️ 小心確認偏差，設定 kill criteria，AI 的回答是參考不是結論\n- ⏱️ 上班族的時間珍貴，驗證的目的是「快速淘汰壞點子」，不是「消除所有風險」\n\n## 下一步\n\n點子驗證通過了？\n\n下一章，我們來面對 Solo Builder 最容易糾結的問題：**技術選型**。\n\n一個人做產品，技術棧的選擇標準跟團隊完全不同。不是選「最流行」的技術，而是選「一個人最能掌控、AI 最能幫忙」的技術。\n\n👉 [第 3 章：技術選型決策框架](/blog/ai-solo-builder-tech-stack)",
      "summary": "點子驗證是 side project 不浪費時間的第一步。學會用 AI 在一天內完成市場調查、競品分析與用戶訪談模擬，快速確認你的點子值不值得投入寶貴的下班時間，避免花兩個月做出沒人要的東西。",
      "image": "https://bobochen.dev/_astro/cover.CR85YanK.webp",
      "date_published": "2026-02-15T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "AI",
        "市場調查",
        "MVP",
        "點子驗證"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/ai-solo-builder-manifesto/",
      "url": "https://bobochen.dev/blog/ai-solo-builder-manifesto/",
      "title": "Solo Builder 宣言：一個人 + AI 就是一支團隊",
      "content_text": "2026 年是 Solo Builder 的黃金時代——AI 徹底改變了一個人能做到的事。這篇文章告訴你為什麼現在是邊上班邊做產品的最好時機，一個有正職的上班族如何靠 AI 加持，把備忘錄裡永遠做不完的 side project 真正做出來、推上線。系列首章，帶你建立 Solo Builder 的心法與全貌。",
      "content_html": "## 那些永遠做不完的 Side Project：為什麼 Solo Builder 卡在這裡\n\n你的手機備忘錄裡，有幾個 side project 的點子？\n\n如果你跟我一樣，答案大概是「太多了」。\n\n一個課程平台的構想。一個自動化工具的 prototype。一個部落格的改版計畫。一個「如果我有時間一定要做」的 SaaS 夢想。\n\n它們有一個共同的結局：永遠停留在備忘錄裡。\n\n不是因為你能力不夠。你明明會寫程式、會部署、會設計資料庫。技術上你什麼都做得到。\n\n問題在別的地方。\n\n下班回到家已經晚上八點。吃完飯、陪家人、洗完澡，坐到電腦前已經十點。打開 VS Code，光是想到「等一下還要處理部署、寫文案、研究金流串接……」，就已經累了。\n\n然後你告訴自己：「週末再說吧。」\n\n週末來了，你花了兩小時研究技術選型，三小時 debug 一個莫名其妙的環境問題，最後只推進了一點點。到了星期天晚上，打開 TODO list，發現進度幾乎沒動。\n\n這個循環重複幾次之後，備忘錄裡的點子就安靜地躺在那裡了。\n\n如果你有這種經驗，你不孤單。這是每個有正職的工程師想做 side project 時都會遇到的牆。\n\n但 2026 年，這堵牆出現了一道裂縫。\n\n## AI 改變的不是「能不能做」，而是「一個人要花多少時間」\n\n我先講結論：**AI 沒有降低做產品的門檻——門檻本來就不高。AI 改變的是時間成本。**\n\n不過這句話我得補一個但書，不然會誤導人。「門檻本來就不高」是對「我本來就會、只是沒時間」的人說的。如果你是想了解產品長什麼樣的 PM、轉職中的設計師、或還在打地基的資淺工程師，門檻對你是真的存在，AI 不會幫你跳過它。\n\n更要小心的是：AI 對你「本來就懂」的領域是加速器，對你「不懂」的領域是放大鏡——它讓你更快做出一個你根本沒能力判斷對錯的東西。我會寫程式，所以 AI 寫的 code 我看得出哪裡怪、哪裡要擋。但金流合規、資料隱私、安全性這些我不夠熟的地方，AI 給我一段看起來很專業的方案，我其實沒辦法真的 review 它對不對。那不是省時間，那是把風險藏起來，等上線才爆。所以遇到自己不懂的領域，我寧可慢一點、找真人問，也不會讓 AI 幫我「快速搞定」。\n\n以前一個人做產品，瓶頸不是技術能力，而是時間。你只有一個人，但產品需要：前端、後端、部署、CI/CD、Landing Page、SEO、文案、客服、金流串接、監控告警……\n\n每一項你都「會」，但每一項都要花時間。一個有正職的人，每週能擠出的時間可能只有 5 到 10 小時。這些時間分配到十幾個面向，每個面向一週只能推進 30 分鐘。\n\n難怪做不完。\n\n但 AI 改變了這個等式。\n\n不是那種「AI 幫你寫一段 code」的改變——那只是省了一點打字時間。我說的是根本性的改變：\n\n市場調查以前要看報告、爬論壇、做問卷，現在 AI 幫忙彙整競品分析、市場規模、目標用戶畫像，快很多。技術選型以前要一個一個框架比，現在 AI 照需求列出 pros/cons、附上案例，省掉不少查資料的時間。寫程式碼以前一個功能要磨好一陣子，現在用 Claude Code 的 agentic workflow 一個晚上就能把骨架先搭出來。\n\n部署設定以前要查文件、踩坑、debug，現在 AI 直接幫你生成 CI/CD pipeline，省掉很多重複設定。Landing Page 的文案 AI 也能先生一版初稿，你微調語氣就好。文件這種最討厭的苦差事，現在 AI 根據程式碼就能生出 API 文件和使用說明。\n\n把這些加起來，以前一個人要花好幾個月做的東西，現在快得多就能上線。\n\n但這串「一個下午、一個晚上」是我挑順的時候講的，不是常態。AI 加速有上限，而且有隱藏成本：整理 prompt 和 context 要時間，AI 幻覺出來的東西要 debug（碰到它一直鬼打牆時，比我自己寫還久），來回 review 修正也要時間。遇到新框架、冷門 bug、或需要很多脈絡才能下的決策，AI 反而可能拖慢你。我自己就有過一次，叫 AI 幫忙接一個比較新的 SDK，它很有自信地用了一個其實已經被 deprecate 的 API，我照著走，卡了大半天才發現是它記錯版本——那天不如我自己翻官方文件還快。\n\n至於上面提到的 self-healing deploy、multi-agent 那種「我睡覺它還在工作」，聽起來很浪漫，但你得幫它架好護欄。沒有人盯著，它一樣會把「錯的修法」很有效率地 deploy 上去，或在 retry 迴圈裡默默把你的雲端帳單燒高。我現在的做法是讓它自動處理可逆、低風險的事，碰到資料庫、金流、production 設定一律停下來等我點頭。\n\n**這不是「AI 取代工程師」的故事。這是「AI 讓一個人可以同時扮演好幾個角色」的故事。**\n\n## 2024 vs. 2026：從「AI 能幫忙」到「AI 是隊友」\n\n你可能會說：「AI 輔助開發 2024 年就有了，有什麼新鮮的？」\n\n差很多。\n\n2024 年的 AI 輔助開發，像是有一個很聰明的實習生。你問他問題，他回答你。你請他寫一段 code，他寫出來，你 review 完再貼進去。每個動作都需要你主動發起、手動整合。\n\n2026 年的 AI，更像是一個有經驗的同事。\n\n以我自己在用的 Claude Code 為例：\n\n- 我可以建立 **custom skills**——教 AI 記住我的開發偏好、專案架構、測試慣例。下次它就直接照我的標準做事，不用每次重新解釋。\n- 我可以設定 **MCP server**——讓 AI 直接跟我的 Notion、Jira、GitHub 對話。不用複製貼上，AI 自己去讀 issue、更新狀態。\n- 我可以跑 **multi-agent workflow**——一個 agent 跑測試、一個 agent 做 code review、一個 agent 更新文件，三件事同時進行。\n- 我可以設定 **self-healing deploy**——部署失敗時，AI 自動分析錯誤、修復問題、重新部署。我睡覺的時候，它還在工作。\n\n這不再是「AI 能幫忙」的層級。**這是 AI 成為你團隊成員的層級。**\n\n而且這才剛開始。\n\n## 上班族做產品的隱藏優勢\n\n很多人覺得，有正職是做 side project 的劣勢——時間少、精力有限、不能全心投入。\n\n我曾經也這麼想。\n\n但做了幾個產品之後，我發現**有正職反而是一種優勢**，只要你換個角度看。\n\n### 優勢 1：正職是你的免費 R&D 實驗室\n\n你在公司遇到的問題，往往就是最好的產品點子。\n\n我在工作中用 GCP Cloud Run 部署服務，踩了一堆坑。這些坑變成了我部落格的文章，部落格的流量證明了這個主題有需求，然後變成了線上課程的構想。\n\n你不需要額外花時間「做市場調查」——你的日常工作就是市場調查。\n\n### 優勢 2：正職收入是你最好的 runway\n\n矽谷創業故事總是強調「辭職 all-in」，但那是倖存者偏差。\n\n有正職收入，你不需要靠產品養活自己。這代表你可以：\n\n- 不急著變現，先做出真正好的東西\n- 不為了營收妥協產品方向\n- 失敗了也不會影響家人生活\n\n你的正職薪水就是最好的創業基金。\n\n### 優勢 3：時間限制逼你做出更好的決策\n\nParkinson's Law：工作會膨脹到填滿你給它的時間。\n\n全職做產品的人，容易掉進「這個功能也要、那個也想做」的陷阱。但你只有每週 5-10 小時，你**必須**做出取捨。這種限制反而逼你專注在真正重要的事情上。\n\n**時間少，不是劣勢。時間少 + AI，是最強的組合。**\n\n因為 AI 幫你處理那些「必要但耗時」的工作（部署設定、文案撰寫、測試生成），你就能把有限的時間和精力集中在只有你能做的事——產品決策、用戶理解、品味判斷。\n\n## 我的證據：邊上班邊做的四個產品\n\n我不是在空談理論。這本書的每一個建議，都來自我自己邊上班邊做產品的真實經驗。\n\n過去幾年，我在有全職工作的情況下，做出了這些東西：\n\n| 產品                 | 類型                | 技術棧                     | 狀態       |\n| -------------------- | ------------------- | -------------------------- | ---------- |\n| **bobo-blog**        | 個人部落格          | Astro + Cloudflare Workers | 上線運行中 |\n| **cloud-on-academy** | GCP 認證課程平台    | Astro + Cloudflare         | 上線運行中 |\n| **course-forge**     | 內容自動化 CLI 工具 | TypeScript + Node.js       | 開發中     |\n| **code-fossil**      | YouTube 頻道品牌    | Remotion + AI 輔助         | 經營中     |\n\n每一個都是從零開始。每一個都是用下班後的時間完成。每一個都大量使用了 AI 工具。\n\n這不是什麼天才的產出。這是一套**可複製的方法**——選對工具、建好流程、善用 AI、嚴格控制時間。\n\n這本書要教你的就是這套方法。\n\n## 這本書要教你什麼\n\n「一個人做產品」這個系列，14 章帶你走完從點子到上線收錢的完整旅程：\n\n### 第一階段：驗證（第 2-4 章）\n\n在你寫任何一行程式碼之前，先確認方向是對的。\n\n- **[第 2 章 — 點子驗證](/blog/ai-solo-builder-idea-validation/)**：用 AI 在一天內完成市場調查和競品分析\n- **[第 3 章 — 技術選型](/blog/ai-solo-builder-tech-stack/)**：建立一套決策框架，選對一個人能掌控的技術棧\n- **[第 4 章 — MVP 設計](/blog/ai-solo-builder-mvp-design/)**：砍掉所有不必要的功能，只留最核心的假設\n\n### 第二階段：建造（第 5-8 章）\n\n用 AI 加持，在最短時間內把產品做出來。\n\n- **[第 5 章 — AI 驅動開發](/blog/ai-solo-builder-ai-driven-dev/)**：從 Vibe Coding 到 Agentic Workflow，讓 AI 成為你的開發團隊\n- **[第 6 章 — 部署上線](/blog/ai-solo-builder-deployment/)**：選對平台，省下不少維運時間\n- **[第 7 章 — Landing Page 與 SEO](/blog/ai-solo-builder-landing-page-seo/)**：讓產品被目標用戶找到\n- **[第 8 章 — 付費機制](/blog/ai-solo-builder-payment/)**：一個人怎麼串接金流開始收錢\n\n### 第三階段：成長（第 9-12 章）\n\n產品上線之後，怎麼讓它活得更久、長得更大。\n\n- **[第 9 章 — 用戶回饋循環](/blog/ai-solo-builder-user-feedback/)**：系統化收集和分析用戶回饋\n- **[第 10 章 — 客服與社群](/blog/ai-solo-builder-support-community/)**：用 AI 省下日常客服時間\n- **[第 11 章 — 監控與維運](/blog/ai-solo-builder-monitoring-ops/)**：讓產品在你睡覺時也穩定運行\n- **[第 12 章 — 從 Side Project 到 Micro SaaS](/blog/ai-solo-builder-side-project-to-saas/)**：什麼時候該認真？怎麼定價？\n\n### 第四階段：實戰（第 13-14 章）\n\n看真實案例，然後檢查你自己的產品。\n\n- **[第 13 章 — 實戰案例](/blog/ai-solo-builder-case-studies/)**：完整拆解我的四個產品\n- **[第 14 章 — Solo Builder Checklist](/blog/ai-solo-builder-checklist/)**：你的產品及格了嗎？\n\n每一章都不是純理論。每一章都有「傳統做法 vs. AI 加持做法」的對比，讓你清楚看到 AI 在每個階段能幫你省多少時間。\n\n## 這本書適合誰\n\n✅ **適合你，如果你是：**\n\n- 有正職但一直想做 side project 的工程師\n- 做過 side project 但總是做不完的開發者\n- 想從接案轉做自己產品的 freelancer\n- 想了解「一個技術產品從 0 到 1 長什麼樣」的 PM 或設計師\n- 對 AI 輔助開發有興趣，但不知道怎麼系統化使用的人\n\n❌ **不適合你，如果你是：**\n\n- 想找「不用寫程式就能做產品」的方法（這本書假設你會寫程式碼）\n- 想做大型 SaaS、募資、組團隊（這本書專注在一個人的規模）\n- 期待「AI 按一個按鈕就做好產品」的魔法（AI 是強大的工具，但判斷和決策還是你的事）\n\n## Solo Builder 宣言\n\n在開始之前，我想跟你分享我對 Solo Builder 的信念。這不是教條，而是我踩過很多坑之後得到的心得：\n\n1. **先 ship，再完美。** 一個上線的 80 分產品，勝過一個永遠在做的 100 分 side project。\n2. **時間是最稀缺的資源。** 每一個決策都要問：「這值得我花寶貴的下班時間嗎？」\n3. **AI 是隊友，不是魔法。** AI 處理繁瑣的事，你負責方向和判斷。\n4. **正職不是阻礙。** 正職是你的收入保障、學習來源、和免費的市場調查。\n5. **一個人不代表什麼都自己來。** 用 AI、用開源工具、用現成服務。「一個人」是指決策者只有你，不是執行者只有你。\n6. **做自己會用的東西。** 你就是你自己最好的第一個用戶。\n\n## 下一步\n\n準備好了嗎？\n\n下一章，我們從最關鍵的第一步開始：**怎麼在一天之內驗證你的點子值不值得做。**\n\n大多數 side project 死在「花了三個月做一個沒人要的東西」。我們要用 AI 確保你不會犯這個錯。\n\n👉 [第 2 章：點子驗證——花一天而不是一個月](/blog/ai-solo-builder-idea-validation)",
      "summary": "2026 年是 Solo Builder 的黃金時代——AI 徹底改變了一個人能做到的事。這篇文章告訴你為什麼現在是邊上班邊做產品的最好時機，一個有正職的上班族如何靠 AI 加持，把備忘錄裡永遠做不完的 side project 真正做出來、推上線。系列首章，帶你建立 Solo Builder 的心法與全貌。",
      "image": "https://bobochen.dev/_astro/cover.B5jexR5_.webp",
      "date_published": "2026-02-08T00:00:00.000Z",
      "tags": [
        "Solo Builder",
        "AI",
        "Side Project",
        "產品開發"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/integration-patterns-20251228/",
      "url": "https://bobochen.dev/blog/integration-patterns-20251228/",
      "title": "系統整合的藝術：API、Event、Data 三大整合模式",
      "content_text": "深入了解 RESTful API、GraphQL、gRPC、Event-Driven Architecture、ETL/ELT/CDC 三大整合模式，掌握 Kafka、RabbitMQ 選擇標準，學會 Strangler Fig Pattern 整合遺留系統,應用 Idempotency、Circuit Breaker 最佳實踐。",
      "content_html": "在現代雲端架構中，系統整合是決定架構成敗的關鍵因素。2023 年，一個知名旅遊平台因為系統整合出錯，導致訂單重複扣款，損失數百萬元——這就是整合失誤的代價。本文將帶你深入了解三大整合模式：API 整合、Event-Driven 架構、Data 整合，並學習如何在實務中做出正確選擇。\n\n## 目錄\n\n- [概述](#概述)\n- [Step 1: 為什麼系統整合如此重要？](#step-1-為什麼系統整合如此重要)\n- [Step 2: 整合模式 1：API 整合](#step-2-整合模式-1api-整合)\n- [Step 3: 整合模式 2：Event-Driven](#step-3-整合模式-2event-driven)\n- [Step 4: 整合模式 3：Data 整合](#step-4-整合模式-3data-整合)\n- [Step 5: 混合雲與遺留系統整合](#step-5-混合雲與遺留系統整合)\n- [Step 6: 整合的最佳實踐](#step-6-整合的最佳實踐)\n- [Step 7: 總結與選擇指南](#step-7-總結與選擇指南)\n- [學到的內容](#學到的內容)\n- [下一步](#下一步)\n\n## 概述\n\n現代系統不再是單一巨石應用，而是由微服務、雲端服務、SaaS 組成的分散式架構。一個電商平台可能需要整合支付系統 (Stripe)、物流系統 (順豐)、庫存 ERP、客戶關係管理 (Salesforce)——這麼多系統要串起來，選錯整合方式就會踩雷。\n\n本文將帶你了解 API、Event、Data 三大整合模式的核心概念、技術選型、適用場景，並提供實務決策指南。無論你是後端開發者、系統架構師，還是雲端架構初學者，都能從中獲得實用的整合策略。\n\n---\n\n## Step 1: 為什麼系統整合如此重要？\n\n### 單體應用已死，分散式成為常態\n\n過去的應用是單一巨石 (Monolith)，所有功能都在一個程式碼庫、一個資料庫中。但隨著業務複雜度提升，單體應用變得難以維護、難以擴展、部署風險高。現在，微服務、雲端服務、SaaS 成為主流架構模式。\n\n這種轉變帶來新的挑戰：**如何讓這些分散的系統協同運作？** 答案就是系統整合。\n\n### 真實事故：整合失誤的代價\n\n2023 年，一個旅遊平台因為系統整合出錯，導致訂單重複扣款。問題的根源是缺乏**冪等性 (Idempotency)** 設計——當用戶重試支付請求時，系統無法識別這是重複請求，導致同一筆訂單被扣款多次。最終，平台損失數百萬元，用戶體驗嚴重受損。\n\n這個案例告訴我們：**整合不只是技術問題，更是業務風險問題。** 選擇正確的整合模式、應用最佳實踐，是系統成功的關鍵。\n\n### 電商平台的整合挑戰\n\n以電商平台為例，需要整合：\n\n- **支付系統** (Stripe, PayPal)：處理金流\n- **物流系統** (順豐, UPS)：追蹤訂單配送\n- **庫存 ERP**：即時更新庫存\n- **CRM** (Salesforce)：管理客戶關係\n- **推薦系統**：個人化商品推薦\n\n如果採用緊耦合的整合方式 (如直接呼叫)，當物流系統故障時，整個訂單流程可能被阻塞。採用鬆耦合的事件驅動架構，則可以確保系統的可用性和可擴展性。\n\n### 重點整理\n\n- **分散式系統是常態**：微服務、雲端服務、SaaS 成為主流\n- **整合失誤代價高**：2023 年旅遊平台訂單重複事故損失數百萬\n- **選擇正確模式是關鍵**：API、Event、Data 各有適用場景\n- **電商整合挑戰**：需整合支付、物流、庫存、CRM 等多個系統\n\n<!-- ![系統整合重要性](/images/blog/step-01.png) -->\n\n---\n\n## Step 2: 整合模式 1：API 整合\n\n### API 整合的三大流派\n\nAPI 整合是最常見的同步整合模式，透過 HTTP 協議交換資料。現代 API 技術主要有三大流派：**RESTful API**、**GraphQL**、**gRPC**。\n\n#### RESTful API：資源導向、簡單易懂\n\nRESTful API 是最成熟的 API 技術，採用資源導向 (Resource-Oriented) 設計：\n\n- **HTTP 動詞**：GET (讀取)、POST (新增)、PUT (更新)、DELETE (刪除)\n- **無狀態**：每個請求包含完整的上下文，不依賴伺服器狀態\n- **資料格式**：JSON 或 XML\n- **適用場景**：CRUD 操作、公開 API、簡單易懂的介面\n\n**範例**：\n\n```http\nGET /api/users/123\nResponse:\n{\n  \"id\": 123,\n  \"name\": \"Alice\",\n  \"email\": \"alice@example.com\"\n}\n```\n\n**優點**：\n\n- 簡單易懂，學習曲線低\n- 工具成熟，廣泛支援\n- 適合公開 API\n\n**缺點**：\n\n- Over-fetching (拿到不需要的資料)\n- Under-fetching (需要多次請求才能取得完整資料)\n\n#### GraphQL：客戶端定義查詢、精確資料獲取\n\nGraphQL 讓客戶端自己定義需要什麼資料，伺服器回傳精確符合需求的資料：\n\n- **單一端點**：所有查詢都透過同一個端點\n- **精確資料獲取**：減少 over-fetching 和 under-fetching\n- **型別安全**：強型別 schema 定義\n- **適用場景**：Mobile App、SaaS 儀表板、需要靈活查詢的應用\n\n**範例**：\n\n```graphql\nquery {\n  user(id: 123) {\n    name\n    email\n  }\n}\n```\n\n**優點**：\n\n- 減少網路請求次數\n- 客戶端靈活性高\n- 強型別，減少錯誤\n\n**缺點**：\n\n- 學習曲線較陡\n- 伺服器端實作複雜\n- 快取較困難\n\n#### gRPC：高效能、二進位協議\n\ngRPC 是 Google 開發的高效能 RPC 框架：\n\n- **二進位協議**：使用 Protocol Buffers (Protobuf)，比 JSON 小 3-10 倍\n- **HTTP/2**：支援雙向串流、多路複用\n- **強型別**：透過 .proto 檔案定義介面\n- **適用場景**：內部微服務、即時通訊、低延遲要求\n\n**優點**：\n\n- 效能極高，適合高吞吐場景\n- 支援雙向串流\n- 強型別，減少錯誤\n\n**缺點**：\n\n- 學習曲線最陡\n- 除錯較困難 (二進位格式)\n- 瀏覽器支援有限\n\n### API 設計原則\n\n無論選擇哪種 API 技術，都應遵循以下設計原則：\n\n1. **版本控制**：\n   - URL 版本：`/api/v1/users`\n   - Header 版本：`Accept: application/vnd.api+json; version=1`\n\n2. **錯誤處理**：\n   - 使用正確的 HTTP Status Code (200, 404, 500)\n   - 回傳結構化錯誤訊息\n\n3. **速率限制 (Rate Limiting)**：\n   - 防止濫用，保護伺服器\n   - 使用 `X-RateLimit-Limit` header 告知限制\n\n4. **文件化**：\n   - 使用 Swagger/OpenAPI 自動生成文件\n   - 提供範例和錯誤碼說明\n\n### API Gateway 模式\n\n在微服務架構中，API Gateway 扮演統一入口的角色：\n\n- **統一入口**：所有外部請求先經過 API Gateway\n- **身份驗證**：集中處理 OAuth、JWT 驗證\n- **速率限制**：保護後端服務\n- **監控與日誌**：統一收集請求日誌\n- **負載平衡**：分散流量到多個後端服務\n\n**工具範例**：\n\n- **Kong**：開源、Plugin 生態豐富\n- **AWS API Gateway**：雲端託管、與 AWS 服務整合\n- **Azure API Management**：企業級、支援混合雲\n\n### 2025 混合策略\n\n現代系統不再只使用單一 API 技術，而是根據場景混合使用：\n\n- **簡單場景**：REST (如公開 API、CRUD 操作)\n- **需要靈活性**：GraphQL (如 Mobile App、SaaS 儀表板)\n- **要求效能**：gRPC (如內部微服務、即時通訊)\n\n### 重點整理\n\n- **RESTful API**：資源導向、簡單易懂、適合 CRUD\n- **GraphQL**：客戶端定義查詢、減少網路請求、適合 Mobile App\n- **gRPC**：高效能、二進位協議、適合內部微服務\n- **API Gateway**：統一入口、身份驗證、速率限制 (工具：Kong、AWS API Gateway)\n- **混合策略**：根據場景選擇 REST、GraphQL、gRPC\n\n<!-- ![API 整合三大流派比較](/images/blog/step-02.png) -->\n\n---\n\n## Step 3: 整合模式 2：Event-Driven\n\n### Event-Driven Architecture (EDA) 是什麼？\n\nEvent-Driven Architecture (事件驅動架構) 是一種非同步整合模式，服務間透過事件 (Event) 溝通，而非直接呼叫。\n\n想像一個電商下單流程：\n\n- **傳統做法**：訂單系統直接呼叫庫存系統、金流系統、物流系統\n- **事件驅動**：訂單系統發出「訂單成立」事件，其他系統自己訂閱這個事件來處理\n\n這就是 **Pub/Sub (發布/訂閱)** 模式：發布者發事件、訂閱者接事件，完全解耦。\n\n### Pub/Sub 模式\n\nPub/Sub 模式包含三個角色：\n\n- **發布者 (Publisher)**：發出事件\n- **主題 (Topic)**：事件的分類\n- **訂閱者 (Subscriber)**：接收並處理事件\n\n**優點**：\n\n- **解耦**：發布者不需要知道誰在訂閱\n- **一對多**：一個事件可以被多個訂閱者處理\n- **非同步**：發布者不需要等待訂閱者完成\n\n### Message Queue：Kafka vs RabbitMQ\n\n實作 Event-Driven 架構需要 Message Queue (訊息佇列) 工具：\n\n#### Kafka：吞吐量怪獸\n\n- **超高吞吐量**：每秒可處理超過 100 萬則訊息\n- **持久化**：訊息持久化到磁碟，可重播\n- **Event Sourcing**：將所有狀態變更儲存為事件序列\n- **2025 更新**：KRaft 模式取代 ZooKeeper，單一二進位部署，大幅簡化運維\n- **適用場景**：大數據處理、Event Sourcing、需要重播歷史事件\n\n**挑戰**：\n\n- **運維成本高**：需要專職團隊維護\n- **學習曲線陡**：概念複雜 (Partition, Offset, Consumer Group)\n\n#### RabbitMQ：靈活易用\n\n- **靈活路由**：支援多種路由模式 (Direct, Topic, Fanout, Headers)\n- **訊息確認**：確保訊息不遺失 (Acknowledgement)\n- **易於設置**：中小團隊也能輕鬆上手\n- **適用場景**：交易處理、需要訊息確認、中小型團隊\n\n**挑戰**：\n\n- **吞吐量較低**：相比 Kafka，適合中小流量\n- **不支援重播**：訊息消費後即刪除\n\n#### 如何選擇？\n\n| 需求                | 選擇     |\n| ------------------- | -------- |\n| 吞吐量 > 10萬 msg/s | Kafka    |\n| 需要重播歷史事件    | Kafka    |\n| 團隊規模 < 10 人    | RabbitMQ |\n| 需要交易確認        | RabbitMQ |\n| 雲端託管，無需管理  | AWS SQS  |\n\n### Event Sourcing 與 CQRS\n\n#### Event Sourcing\n\n將所有狀態變更儲存為事件序列，而非只儲存最終狀態：\n\n- **完整審計日誌**：可追溯所有變更\n- **時間旅行**：可重播到任意時間點的狀態\n- **除錯友善**：可看到完整事件流\n\n**範例**：銀行帳戶\n\n- 傳統做法：只儲存最終餘額 (如 1000 元)\n- Event Sourcing：儲存所有交易事件 (存款 +500, 提款 -200, ...)\n\n#### CQRS (Command Query Responsibility Segregation)\n\n分離寫入和讀取模型：\n\n- **Command Model**：處理寫入操作\n- **Query Model**：處理讀取操作\n\n**優點**：\n\n- 寫入和讀取可獨立擴展\n- 查詢模型可針對特定場景優化\n\n### Event-Driven 的優缺點\n\n**優點**：\n\n- **解耦**：服務間無直接依賴\n- **非同步**：不阻塞主流程\n- **可擴展**：新增訂閱者無需修改發布者\n- **容錯**：一個服務失敗不影響其他服務\n\n**缺點**：\n\n- **複雜度高**：分散式追蹤困難\n- **除錯困難**：事件流向難以追蹤\n- **最終一致性**：無法保證強一致性\n\n### 適用場景\n\n- **訂單處理流程**：訂單成立 → 庫存扣減 → 金流處理 → 物流通知\n- **庫存更新通知**：庫存變更 → 推薦系統更新 → 前端即時顯示\n- **使用者行為追蹤**：點擊事件 → 分析系統 → 推薦模型\n- **IoT 資料串流**：感測器資料 → 即時分析 → 告警系統\n\n### Dead Letter Queue (DLQ)\n\n處理失敗訊息的機制：\n\n- 當訊息處理失敗超過重試次數，移至 DLQ\n- 定期檢查 DLQ，分析失敗原因\n- 修正問題後，重新處理 DLQ 中的訊息\n\n### 重點整理\n\n- **Pub/Sub 模式**：發布者 → 主題 → 訂閱者，完全解耦\n- **Kafka**：吞吐量 > 100萬 msg/s、Event Sourcing、需專職團隊\n- **RabbitMQ**：靈活路由、易於設置、中小團隊可維護\n- **Event Sourcing**：儲存所有事件、完整審計日誌、時間旅行\n- **優點**：解耦、非同步、可擴展；缺點：複雜、除錯難、最終一致性\n- **適用場景**：訂單處理、庫存更新、使用者行為追蹤、IoT 資料串流\n\n<!-- ![Event-Driven 架構](images/step-03.png) -->\n\n---\n\n## Step 4: 整合模式 3：Data 整合\n\n### Data 整合的三大策略\n\nData 整合處理大量資料的搬移和轉換，主要有三大策略：**ETL**、**ELT**、**CDC**。\n\n#### ETL (Extract, Transform, Load)\n\n傳統批次處理方式：\n\n1. **Extract (提取)**：從來源系統提取資料\n2. **Transform (轉換)**：清洗、轉換資料格式\n3. **Load (載入)**：載入目標系統\n\n**適用場景**：\n\n- 定期報表 (如每日、每週)\n- 資料清洗優先\n- 傳統 BI 系統\n\n**優點**：\n\n- 資料品質高 (先清洗後載入)\n- 邏輯集中，易於管理\n\n**缺點**：\n\n- 轉換過程慢\n- 無法即時處理\n\n#### ELT (Extract, Load, Transform)\n\n雲端時代的新趨勢：\n\n1. **Extract (提取)**：從來源系統提取資料\n2. **Load (載入)**：直接載入目標系統\n3. **Transform (轉換)**：在目標系統中轉換\n\n**為什麼這樣做？** 因為像 BigQuery、Snowflake 這些雲端資料倉儲算力超強，讓它們去轉換資料更有效率。\n\n**適用場景**：\n\n- 雲端資料倉儲 (BigQuery, Snowflake)\n- 大數據處理\n- 需要彈性查詢\n\n**優點**：\n\n- 快速載入，即時可查\n- 利用目標系統的算力\n\n**缺點**：\n\n- 資料品質檢查延後\n- 目標系統資源消耗大\n\n#### CDC (Change Data Capture)\n\n2025 年的趨勢，只追蹤資料變更：\n\n- **變更捕獲**：僅追蹤新增、修改、刪除的資料\n- **即時同步**：近即時或即時同步\n- **節省資源**：只傳輸變更，而非全量資料\n\n**適用場景**：\n\n- 資料庫複製\n- 微服務資料同步\n- 即時分析\n- 詐欺偵測\n\n**優點**：\n\n- 即時性高\n- 資源消耗低\n- 支援漸進式遷移\n\n### CDC 工具選擇\n\n| 工具         | 類型         | 適用場景             |\n| ------------ | ------------ | -------------------- |\n| **Debezium** | 開源         | 預算有限、需要客製化 |\n| **Fivetran** | 商業、全託管 | 需要穩定、無需維護   |\n| **Airbyte**  | 開源友善     | 平衡點、社群活躍     |\n\n**選擇建議**：\n\n- **預算有限**：Debezium (開源)\n- **需要全託管**：Fivetran (商業)\n- **要平衡點**：Airbyte (開源友善)\n\n### Data Lake vs Data Warehouse\n\n**Data Lake** (資料湖)：\n\n- 儲存原始資料 (Raw Data)\n- Schema-on-Read：讀取時才定義 schema\n- 適合大數據、非結構化資料\n\n**Data Warehouse** (資料倉儲)：\n\n- 儲存結構化資料\n- Schema-on-Write：寫入時定義 schema\n- 適合 BI、報表、分析\n\n### Stream Processing\n\n即時資料處理，而非批次處理：\n\n- **Apache Flink**：強大的即時處理引擎\n- **Kafka Streams**：輕量級，與 Kafka 整合\n\n**適用場景**：\n\n- 即時推薦\n- 詐欺偵測\n- 即時儀表板\n\n### 2025 趨勢：CDC 取代 ETL\n\nCDC 正在取代傳統批次 ETL，成為即時整合的首選：\n\n- **即時性**：從每日批次 → 即時同步\n- **成本低**：只傳輸變更，節省資源\n- **靈活性**：支援漸進式遷移\n\n### 組合方法\n\n實務上，CDC 常與 ETL/ELT 管道結合：\n\n- **CDC** 捕獲變更\n- **ETL/ELT 管道** 處理和分析\n\n### 重點整理\n\n- **ETL**：先轉換後載入、適合定期報表、資料品質高\n- **ELT**：先載入後轉換、利用雲端算力、適合大數據\n- **CDC**：只追蹤變更、即時同步、節省資源\n- **工具選擇**：Debezium (開源)、Fivetran (全託管)、Airbyte (平衡)\n- **2025 趨勢**：CDC 取代傳統批次 ETL\n\n<!-- ![Data 整合三大策略](images/step-04.png) -->\n\n---\n\n## Step 5: 混合雲與遺留系統整合\n\n### 現實世界的挑戰：遺留系統\n\n許多企業面臨一個問題：如何整合那些跑了幾十年的老古董 COBOL 系統？直接重寫風險太高，但又需要現代化。\n\n答案是：**Strangler Fig Pattern** (絞殺榕模式)。\n\n### Strangler Fig Pattern\n\n由 Martin Fowler 提出，靈感來自絞殺榕：一種植物慢慢纏繞並取代老樹。\n\n**運作機制**：\n\n1. **代理層 (Proxy) 攔截請求**：所有請求先經過代理層\n2. **路由到新舊系統**：新功能路由到新系統、舊功能保留在舊系統\n3. **增量替換**：逐步將功能從舊系統遷移到新系統\n4. **最終移除**：當所有功能遷移完成，移除舊系統\n\n**優點**：\n\n- **降低風險**：逐步遷移，而非大爆炸式重寫\n- **獨立測試**：每個功能可獨立測試\n- **可回滾**：遷移出問題可立即回滾\n- **立即獲得收益**：新功能上線即可使用\n\n### 挑戰與應對策略\n\n#### 挑戰 1：代理層單點故障\n\n**問題**：代理層掛掉，整個系統掛掉\n\n**應對**：使用 HA (High Availability) 設計\n\n- 部署多個代理層實例\n- 使用負載平衡器\n- 設定健康檢查和自動故障轉移\n\n#### 挑戰 2：資料同步複雜\n\n**問題**：新舊系統需要同步資料\n\n**應對**：雙寫策略 + 最終一致性\n\n- **雙寫**：同時寫入新舊系統\n- **最終一致性**：接受短暫的資料不一致\n- **Feature Toggle**：逐步切換流量\n\n### 其他整合模式\n\n#### API Facade\n\n包裝遺留系統為現代 REST/gRPC API：\n\n- **隱藏複雜性**：遺留系統的複雜介面被包裝成簡單 API\n- **漸進式遷移**：可逐步替換背後的實作\n\n#### Database Gateway\n\n資料庫層級整合：\n\n- **雙寫策略**：同時寫入新舊資料庫\n- **資料同步**：定期同步資料\n\n#### Adapter Pattern\n\n協議轉換：\n\n- **SOAP → REST**：將舊 SOAP API 轉換為 REST API\n- **舊 API → 新 API**：適配器模式\n\n#### Integration Hub / ESB\n\n企業服務匯流排 (Enterprise Service Bus)：\n\n- **集中管理**：所有整合邏輯集中在 ESB\n- **避免單點故障**：確保 ESB 本身的高可用性\n\n### 實際案例\n\n#### 銀行核心系統現代化\n\n許多銀行使用 Strangler Fig Pattern 從 COBOL 遷移到微服務：\n\n1. **代理層**：新增 API Gateway 攔截所有請求\n2. **新服務**：用 Java/Go 重寫新功能\n3. **雙寫**：同時寫入 COBOL 和新資料庫\n4. **逐步切換**：用 Feature Toggle 逐步切換流量\n5. **最終移除**：當所有功能遷移完成，移除 COBOL\n\n#### 電商從單體遷移到 K8s\n\n1. **容器化**：將單體應用容器化\n2. **拆分服務**：逐步拆分成微服務\n3. **Kubernetes 部署**：遷移到 K8s\n4. **Service Mesh**：使用 Istio 管理服務間通訊\n\n### 重點整理\n\n- **Strangler Fig Pattern**：逐步取代遺留系統，降低風險\n- **代理層 HA**：避免單點故障\n- **雙寫策略**：同時寫入新舊系統，最終一致性\n- **API Facade**：包裝遺留系統為現代 API\n- **實際案例**：銀行 COBOL → 微服務、電商單體 → K8s\n\n<!-- ![Strangler Fig Pattern](images/step-05.png) -->\n\n---\n\n## Step 6: 整合的最佳實踐\n\n學完三大整合模式，還要知道三大彈性模式，確保系統不會掛掉！\n\n### Idempotency (冪等性)\n\n**定義**：多次執行操作與執行一次具有相同效果。\n\n**生活化比喻**：按電梯按鈕，按一次和按十次結果一樣。\n\n**為什麼重要？** 在分散式系統中，網路可能會重試請求，如果沒有冪等性設計，可能導致：\n\n- 訂單重複 (2023 年旅遊平台事故)\n- 重複扣款\n- 資料不一致\n\n**實作方式**：使用唯一 ID (Request ID)\n\n```javascript\n// 伺服器端檢查重複請求\nfunction processPayment(requestId, amount) {\n  // 檢查 requestId 是否已處理過\n  if (processedRequests.has(requestId)) {\n    return { status: 'already_processed' };\n  }\n\n  // 處理付款\n  charge(amount);\n\n  // 記錄 requestId\n  processedRequests.add(requestId);\n\n  return { status: 'success' };\n}\n```\n\n### Circuit Breaker (斷路器)\n\n**定義**：防止級聯失敗，像家裡的保險絲。\n\n**狀態機**：\n\n1. **Closed (正常)**：請求正常通過\n2. **Open (切斷)**：當下游服務故障，自動切斷請求\n3. **Half-Open (測試恢復)**：定期測試下游服務是否恢復\n\n**為什麼重要？** 當下游服務故障時，避免整個系統跟著掛掉。\n\n**實作範例**：\n\n```javascript\nclass CircuitBreaker {\n  constructor(threshold = 5, timeout = 60000) {\n    this.failureCount = 0;\n    this.threshold = threshold;\n    this.timeout = timeout;\n    this.state = 'CLOSED';\n  }\n\n  async call(fn) {\n    if (this.state === 'OPEN') {\n      throw new Error('Circuit breaker is OPEN');\n    }\n\n    try {\n      const result = await fn();\n      this.onSuccess();\n      return result;\n    } catch (error) {\n      this.onFailure();\n      throw error;\n    }\n  }\n\n  onSuccess() {\n    this.failureCount = 0;\n    this.state = 'CLOSED';\n  }\n\n  onFailure() {\n    this.failureCount++;\n    if (this.failureCount >= this.threshold) {\n      this.state = 'OPEN';\n      setTimeout(() => {\n        this.state = 'HALF_OPEN';\n      }, this.timeout);\n    }\n  }\n}\n```\n\n### Retry Strategies (重試策略)\n\n**三種重試策略**：\n\n1. **立即重試**：立即重試，適合短暫故障\n2. **延遲重試**：等待固定時間後重試\n3. **指數退避 (Exponential Backoff)**：每次等待時間翻倍\n\n**加入抖動 (Jitter)**：在等待時間中加入隨機性，避免重試風暴。\n\n**範例**：\n\n```javascript\nasync function retryWithExponentialBackoff(fn, maxRetries = 3) {\n  for (let i = 0; i < maxRetries; i++) {\n    try {\n      return await fn();\n    } catch (error) {\n      if (i === maxRetries - 1) throw error;\n\n      // 指數退避 + 抖動\n      const delay = Math.pow(2, i) * 1000 + Math.random() * 1000;\n      await sleep(delay);\n    }\n  }\n}\n```\n\n### Timeout Settings (超時設定)\n\n**層級設定**：\n\n- **連線超時 (Connection Timeout)**：建立連線的最大時間\n- **讀取超時 (Read Timeout)**：讀取資料的最大時間\n- **總超時 (Total Timeout)**：整個請求的最大時間\n\n**為什麼重要？** 避免無限等待，確保請求最終會失敗或成功。\n\n### 三者協同運作\n\n- **Idempotency** 使 Retry 安全\n- **Circuit Breaker** 阻止無效 Retry\n- **Retry + Idempotency** = Exactly-Once 語義\n\n### 監控與可觀測性\n\n#### OpenTelemetry：統一標準\n\nOpenTelemetry 是統一的可觀測性標準，包含：\n\n- **Trace**：分散式追蹤，串聯 A→B→C 服務的請求路徑\n- **Metrics**：指標監控 (如請求數、錯誤率)\n- **Logs**：日誌聚合\n\n**範例**：透過 Trace ID 串聯服務\n\n```\nRequest ID: abc-123\nService A (處理時間 50ms) → Trace ID: abc-123\n  ↓\nService B (處理時間 120ms) → Trace ID: abc-123\n  ↓\nService C (處理時間 80ms) → Trace ID: abc-123\n```\n\n透過 Trace ID `abc-123`，可以串聯 A→B→C 的完整請求路徑，一眼看出哪個服務慢。\n\n#### ELK/Loki：日誌聚合\n\n- **ELK**：Elasticsearch + Logstash + Kibana\n- **Loki**：Grafana 推出的輕量級日誌系統\n\n#### Prometheus：指標監控\n\n- 收集指標 (Metrics)\n- 支援強大的查詢語言 (PromQL)\n- 與 Grafana 整合，視覺化儀表板\n\n### 2025 見解：AI 驅動的彈性\n\n- **動態調整重試參數**：根據歷史資料動態調整重試次數\n- **預測性斷路器**：預測下游服務即將故障，提前切斷\n\n### 錯誤處理\n\n- **Dead Letter Queue**：處理失敗訊息\n- **錯誤分類**：Transient (暫時性) vs Permanent (永久性)\n- **告警機制**：設定告警閾值，及時發現問題\n\n### 重點整理\n\n- **Idempotency**：用 Request ID 檢查重複請求，讓重試安全\n- **Circuit Breaker**：Closed → Open → Half-Open，防止級聯失敗\n- **Retry Strategies**：指數退避 + Jitter，避免重試風暴\n- **三者協同**：Idempotency + Circuit Breaker + Retry = Exactly-Once\n- **OpenTelemetry**：Trace ID 串聯 A→B→C 服務，分散式追蹤\n\n<!-- ![整合最佳實踐](images/step-06.png) -->\n\n---\n\n## Step 7: 總結與選擇指南\n\n### 如何選擇整合模式？\n\n根據以下 5 個問題做決策：\n\n#### 問題 1：是否需要即時回應？\n\n- **是** → 選擇 **API 整合**\n  - RESTful API (簡單場景)\n  - GraphQL (需要靈活查詢)\n  - gRPC (要求效能)\n- **否** → 選擇 Event 或 Data\n\n#### 問題 2：資料量是否巨大？\n\n- **是** → 選擇 **Data 整合**\n  - ETL (傳統 BI)\n  - ELT (雲端資料倉儲)\n  - CDC (即時同步)\n- **否** → 選擇 API 或 Event\n\n#### 問題 3：是否需要服務解耦？\n\n- **是** → 選擇 **Event-Driven**\n  - Kafka (高吞吐量)\n  - RabbitMQ (中小團隊)\n- **否** → 選擇 API\n\n#### 問題 4：是否需要歷史重播？\n\n- **是** → 選擇 **Kafka Event Sourcing**\n- **否** → 選擇 RabbitMQ 或其他\n\n#### 問題 5：有遺留系統需要整合嗎？\n\n- **是** → 使用 **Strangler Fig Pattern**\n  - 代理層 + HA 設計\n  - 雙寫策略 + 最終一致性\n\n### 混合使用範例：電商系統\n\n真實世界的系統通常混合使用多種整合模式：\n\n- **REST API**：前端查詢商品、使用者資訊\n- **Kafka**：處理訂單流程 (訂單成立 → 庫存扣減 → 金流處理 → 物流通知)\n- **RabbitMQ**：處理付款 (需要確認機制)\n- **CDC**：同步庫存資料到資料倉儲\n\n### 選擇整合模式的 Checklist\n\n在實際專案中，使用以下 Checklist 做決策：\n\n- [ ] **需要即時回應嗎？** (API)\n- [ ] **需要處理大量資料嗎？** (Data)\n- [ ] **需要服務解耦嗎？** (Event)\n- [ ] **有遺留系統需要整合嗎？** (Strangler Fig)\n- [ ] **需要可靠性保證嗎？** (Idempotency + Circuit Breaker + Retry)\n\n### 下一集預告\n\n下一集我們要聊 **Workload Disposition：Build、Buy、Modify 還是 Deprecate？** 教你做出正確的雲端遷移決策。\n\n記得訂閱我們的系列，我們下次見！\n\n---\n\n## 學到的內容\n\n### 學習成果\n\n1. **理解 3 大整合模式的差異與適用場景**\n   - API 整合：同步、即時回應\n   - Event-Driven：非同步、解耦\n   - Data 整合：批次或即時資料移動\n\n2. **掌握 RESTful API vs. GraphQL vs. gRPC 的選擇標準**\n   - REST：簡單易懂、適合 CRUD\n   - GraphQL：靈活查詢、減少網路請求\n   - gRPC：高效能、適合內部微服務\n\n3. **學會 Event-Driven Architecture 的核心概念與工具選擇**\n   - Kafka：高吞吐量、Event Sourcing\n   - RabbitMQ：靈活路由、易於設置\n\n4. **了解 ETL/ELT/CDC 的資料整合策略**\n   - ETL：先轉換後載入\n   - ELT：先載入後轉換\n   - CDC：只追蹤變更、即時同步\n\n5. **識別遺留系統整合的漸進式策略 (Strangler Fig Pattern)**\n   - 代理層 + HA 設計\n   - 雙寫策略 + 最終一致性\n\n6. **避免常見整合陷阱，應用最佳實踐**\n   - Idempotency：用 Request ID 讓重試安全\n   - Circuit Breaker：防止級聯失敗\n   - Retry：指數退避 + Jitter\n\n---\n\n## 下一步\n\n### 延伸學習資源\n\n1. **深入學習 API 設計**\n   - [RESTful API 設計最佳實踐](https://www.restapitutorial.com/)\n   - [GraphQL 官方文件](https://graphql.org/learn/)\n   - [gRPC 官方文件](https://grpc.io/docs/)\n\n2. **Event-Driven 架構**\n   - [Kafka 官方文件](https://kafka.apache.org/documentation/)\n   - [RabbitMQ 教學](https://www.rabbitmq.com/getstarted.html)\n   - [Event Sourcing 模式](https://martinfowler.com/eaaDev/EventSourcing.html)\n\n3. **資料整合**\n   - [Debezium 教學](https://debezium.io/documentation/)\n   - [Fivetran 文件](https://fivetran.com/docs)\n   - [Airbyte 教學](https://docs.airbyte.com/)\n\n4. **最佳實踐**\n   - [OpenTelemetry 官方文件](https://opentelemetry.io/docs/)\n   - [斷路器模式](https://martinfowler.com/bliki/CircuitBreaker.html)\n   - [冪等性設計](https://stripe.com/docs/api/idempotent_requests)\n\n### 實作練習\n\n1. **建立 RESTful API**：用 Express.js 或 FastAPI 建立簡單的 RESTful API\n2. **實作 Pub/Sub**：用 RabbitMQ 實作簡單的訂單處理流程\n3. **應用 Circuit Breaker**：在專案中實作斷路器模式\n4. **設定 OpenTelemetry**：在微服務中加入分散式追蹤\n\n### 相關課程\n\n<!-- 系列導航待補：上一集/下一集文章連結 -->\n\n---\n\n_本文由 CloudOn 登雲學院 撰寫_",
      "summary": "深入了解 RESTful API、GraphQL、gRPC、Event-Driven Architecture、ETL/ELT/CDC 三大整合模式，掌握 Kafka、RabbitMQ 選擇標準，學會 Strangler Fig Pattern 整合遺留系統,應用 Idempotency、Circuit Breaker 最佳實踐。",
      "image": "https://bobochen.dev/_astro/system-integration.BWcu6uyb.webp",
      "date_published": "2025-12-28T00:00:00.000Z",
      "tags": [
        "系統整合",
        "API",
        "RESTful",
        "GraphQL",
        "gRPC",
        "Event-Driven",
        "Kafka",
        "RabbitMQ",
        "ETL",
        "CDC",
        "Strangler Fig",
        "Idempotency",
        "Circuit Breaker"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-roadmap-next-steps/",
      "url": "https://bobochen.dev/blog/laravel-guide-roadmap-next-steps/",
      "title": "PHP/Laravel 完全指南：從這裡開始你自己的旅程",
      "content_text": "用 Laravel 12 打造揪好買團購平台的完整旅程到此告一段落。本章回顧全書 15 章的 Laravel 學習路徑，整理揪好買可擴展的功能方向，介紹 Nova、Vapor、Laracasts 等生態系資源，並為你規劃從初級到進階的 PHP／Laravel 成長路線圖。",
      "content_html": "十五章，從 PHP 基礎語法走到 Production 部署，你已經用 Laravel 12 從零打造了一個完整的團購平台。揪好買其實不只是個練習專案，它幾乎把現代 Web 應用開發的每個核心面向都摸過一遍：\n\n- 路由與控制器、資料庫遷移與 Eloquent ORM\n- 認證授權、表單驗證、檔案上傳\n- 佇列任務、Event／Listener、Notification\n- API 設計、後台管理、自動化測試\n- 一直到部署上線\n\n這些不是教科書上的範例，而是你在任何 Laravel 專案中都會用到的實戰技能。\n\n但說實話，這本書能涵蓋的只是 Laravel 生態系的冰山一角。Laravel 之所以強大，框架本身設計得好只是一半，真正關鍵的是它背後有一整個生態系在支撐：Nova 給你企業級後台、Vapor 讓你跑 Serverless、Pennant 做 Feature Flag、Pulse 做即時效能監控。加上 Laracasts 這個可能是全世界最好的程式教學平台，你的學習資源幾乎是用不完的。\n\n這最後一章，我們要做三件事：回顧這 15 章到底學了什麼、看看揪好買還能往哪些方向擴展、然後幫你畫一張接下來的學習路線圖。你已經有了穩固的基礎，接下來的路，你可以自己決定怎麼走。\n\n## Laravel 完全指南回顧：15 章學到了什麼\n\n先用一張表把整趟旅程看清楚：\n\n| 章  | 主題               | 你學會的核心技能                                |\n| --- | ------------------ | ----------------------------------------------- |\n| 1   | PHP 快速入門       | PHP 8.4+ 型別系統、Enum、Match、Composer        |\n| 2   | Laravel 起手式     | 安裝、目錄結構、Route、Blade、Artisan           |\n| 3   | Request Lifecycle  | Service Container、DI、Middleware、Facade       |\n| 4   | Eloquent ORM       | Migration、Model、Relationship、Factory         |\n| 5   | Blade + Livewire   | Component、Livewire 即時互動、Alpine.js         |\n| 6   | 認證與授權         | Starter Kit、Gate、Policy、Role Enum            |\n| 7   | 表單驗證與檔案上傳 | Form Request、Validation Rules、Storage         |\n| 8   | 跟團與成團邏輯     | Session、業務邏輯設計、Cache、狀態機            |\n| 9   | 訂單與金流         | Stripe Cashier、Webhook、Database Transaction   |\n| 10  | Queue 與 Event     | Job、Event/Listener、Notification、Mail         |\n| 11  | RESTful API        | Sanctum Token、API Resource、Rate Limiting      |\n| 12  | 後台管理           | Filament 4、N+1 優化、Query Scopes、Debugbar    |\n| 13  | 測試               | Pest、HTTP Tests、Mock/Fake、GitHub Actions CI  |\n| 14  | 部署               | Forge、Docker、Octane、Cloud Run、Zero-Downtime |\n| 15  | 路線圖             | 你正在讀的這一章                                |\n\n從語言基礎到 Production 部署，這是一條完整的學習路徑。你不再是「想學 Laravel 的人」，你已經是「用 Laravel 建過完整專案的開發者」了。\n\n## 揪好買的完成功能清單\n\n讓我們盤點一下揪好買目前有什麼：\n\n**使用者系統**\n\n- ✅ 註冊、登入、登出、忘記密碼\n- ✅ Email 驗證\n- ✅ 角色系統：一般會員 / 開團主 / 管理員\n\n**團購功能**\n\n- ✅ 開團（表單驗證 + 圖片上傳）\n- ✅ 跟團（+1、選數量、即時人數更新）\n- ✅ 成團判斷（最低人數 + 截止時間）\n- ✅ 團購列表（即時搜尋、篩選、排序、分頁）\n\n**金流與訂單**\n\n- ✅ Stripe 串接（Checkout Session + Webhook）\n- ✅ 訂單建立與狀態管理\n- ✅ 成團後統一收款\n\n**通知系統**\n\n- ✅ 成團確認 Email\n- ✅ 站內通知（database notification）\n- ✅ 背景佇列處理\n\n**API**\n\n- ✅ RESTful API（團購列表、跟團、訂單查詢）\n- ✅ Sanctum Token 認證\n- ✅ Rate Limiting\n\n**管理後台**\n\n- ✅ Filament 管理員後台\n- ✅ 開團主儀表板\n\n**DevOps**\n\n- ✅ Pest 測試套件\n- ✅ GitHub Actions CI\n- ✅ Docker 容器化\n- ✅ Production 部署\n\n這已經是一個具備核心功能的 MVP（最小可行產品）了。\n\n## 功能擴展方向：搜尋、推薦、多語系\n\n揪好買還有很多可以做的。以下是幾個值得考慮的方向：\n\n### 全文搜尋\n\n目前的搜尋用 `LIKE %keyword%`，資料量大時效能很差。升級方案：\n\n- **Laravel Scout + Meilisearch**，全文搜尋引擎，支援中文斷詞、模糊搜尋、過濾排序\n- `composer require laravel/scout` + `composer require meilisearch/meilisearch-php`\n- Model 加上 `use Searchable;`，幾行設定就能讓搜尋體驗飛起來\n\n### 推薦系統\n\n「你可能也想跟的團」，根據使用者的跟團歷史推薦相似的團購：\n\n- 簡單版：同品類的熱門團購（SQL 就能做）\n- 進階版：協同過濾（Collaborative Filtering），可以用 Python microservice 處理\n\n### 多語系（i18n）\n\n讓揪好買支援繁中/英文切換：\n\n- Laravel 內建 `resources/lang/` 翻譯檔\n- `__('messages.welcome')` helper\n- Middleware 偵測使用者語言偏好\n\n### 即時通訊\n\n開團主和跟團者之間的即時聊天：\n\n- **Laravel Reverb**，Laravel 官方的 WebSocket 伺服器\n- 搭配 Livewire 或 Echo（JavaScript）做即時更新\n- 適合討論團購細節、配送安排\n\n### 多租戶（Multi-tenancy）\n\n讓不同公司/社區各自有獨立的揪好買實例：\n\n- **stancl/tenancy** 套件\n- 每個租戶有獨立的資料庫或共用資料庫加 tenant_id\n- 適合 B2B SaaS 模式\n\n## LINE 整合深化：從概念到實作\n\n[第十一章](/blog/laravel-guide-api-sanctum-rest/)我們留了一個 LINE Bot 的概念性範例。如果要真正做起來：\n\n### LINE Messaging API\n\n```bash\ncomposer require linecorp/line-bot-sdk\n```\n\n核心流程：\n\n1. 在 LINE Developers Console 建立 Provider 和 Channel\n2. 設定 Webhook URL 指向你的 Laravel API endpoint\n3. 使用者在 LINE 群組裡輸入「!開團 辦公室零食箱」\n4. 你的 webhook controller 解析指令、呼叫 GroupBuy service\n5. 透過 LINE API 回覆結果\n\n### LINE LIFF（LINE Frontend Framework）\n\n更進一步，你可以在 LINE 裡嵌入 Web 頁面：\n\n- 使用者在 LINE 裡直接打開揪好買的團購詳情頁\n- 不需要跳轉到瀏覽器，體驗更流暢\n- 搭配 Sanctum API 做認證\n\n### LINE Pay\n\n台灣使用者最常用的行動支付之一：\n\n- 可以取代或補充 Stripe\n- 需要另外串接 LINE Pay API（目前 Cashier 不支援，需自己整合）\n\n## Laravel 生態系工具推薦\n\nLaravel 的生態系大到你可能不知道從哪裡開始。以下是我認為**最值得認識**的工具：\n\n### 開發工具\n\n| 工具                   | 用途         | 一句話說明                                          |\n| ---------------------- | ------------ | --------------------------------------------------- |\n| **Laravel Herd**       | 本地開發環境 | 一鍵安裝 PHP + Nginx + 多版本切換（macOS/Windows）  |\n| **Laravel Pint**       | 程式碼格式化 | PHP 的 Prettier，Laravel 12 內建                    |\n| **Laravel IDE Helper** | IDE 支援     | 幫 PhpStorm/VS Code 理解 Facade 和 Model 的自動補全 |\n| **Laravel Debugbar**   | 效能偵測     | [第 12 章](/blog/laravel-guide-admin-filament-advanced-queries/)用過，開發階段必裝                          |\n\n### 官方套件\n\n| 套件          | 用途                 | 何時需要                           |\n| ------------- | -------------------- | ---------------------------------- |\n| **Sanctum**   | API Token 認證       | 你已經會了（[第 11 章](/blog/laravel-guide-api-sanctum-rest/)）             |\n| **Cashier**   | 金流整合             | 你已經會了（[第 9 章](/blog/laravel-guide-orders-stripe-cashier/)）              |\n| **Scout**     | 全文搜尋             | 搜尋功能需要升級時                 |\n| **Horizon**   | Queue 監控 Dashboard | Redis Queue 在 production 跑的時候 |\n| **Telescope** | Debug Dashboard      | 開發階段觀察 request、query、job   |\n| **Reverb**    | WebSocket 伺服器     | 即時通訊、即時通知                 |\n| **Pennant**   | Feature Flags        | A/B 測試、漸進式上線新功能         |\n| **Pulse**     | 即時效能監控         | Production 觀察應用健康狀態        |\n\n## Nova、Vapor、Pennant、Pulse 簡介\n\n這四個是 Laravel 官方的商業產品（需要付費授權），適合有預算的團隊：\n\n### Laravel Nova（一次性授權，$99 / $199 / $299 per site）\n\n企業級後台管理面板。比 Filament 功能更完整，但需要付費：\n\n- 更多內建欄位類型和 Action\n- Metrics Dashboard 更豐富\n- 權限管理更細緻\n- 適合：中大型團隊、有預算、需要企業級後台\n\n### Laravel Vapor（$39/mo 起，另計 AWS 費用）\n\nServerless 部署平台，底層跑在 AWS Lambda：\n\n- 自動擴縮容（Auto-scaling），流量大時自動加機器\n- 不用管伺服器、不用管 Nginx\n- 適合：流量波動大（例如團購開團瞬間流量暴增）、有 AWS 預算的團隊\n\n### Laravel Pennant\n\nFeature Flag 管理：\n\n- 控制功能對哪些使用者可見（例如：先讓 10% 使用者看到新 UI）\n- A/B 測試\n- 漸進式上線，降低風險\n\n### Laravel Pulse\n\n即時應用程式效能監控：\n\n- 顯示 slow queries、slow requests、exceptions\n- Queue 和 Cache 使用狀態\n- 比 Debugbar 更適合 production 使用\n\n## 社群與學習資源\n\n### Laracasts\n\n如果你只能訂閱一個學習平台，選 Laracasts。Jeffrey Way 的教學品質是業界標竿，涵蓋 Laravel、PHP、Vue.js、Testing 等主題。大量免費內容可以先看看合不合口味。\n\n### Laravel News\n\nLaravel 生態系的新聞中心，每天更新套件推薦、教學文章、版本發布。訂閱 Newsletter 就能掌握最新動態。\n\n### Laravel Daily\n\nPovilas Korop 經營的 YouTube 頻道和部落格，專注實戰技巧和最佳實踐。影片短而精準，適合通勤時看。\n\n### 中文社群\n\n- **Laravel 台灣** Facebook 社團，台灣最活躍的 Laravel 中文社群\n- **LearnKu Laravel 中國**，簡體中文，但很多文章品質很高\n- **PHP 也有 Day** 社群，台灣 PHP 開發者聚會\n\n### 推薦書籍\n\n- _Laravel Up & Running_（Matt Stauffer），最完整的 Laravel 參考書\n- _PHP: The Right Way_，免費線上書，現代 PHP 最佳實踐\n- _Refactoring to Collections_（Adam Wathan），用 Collection 取代迴圈，提升程式碼品質\n\n## PHP 生態現況與未來展望\n\n2026 年的 PHP 生態比以往任何時候都更健康：\n\n- **PHP 8.5** 已穩定發布，pipe operator（`|>`）、原生 URI 擴充、clone with 屬性覆寫讓語法更精煉（Property Hooks 和 Asymmetric Visibility 是 PHP 8.4 引進的）\n- **Laravel 拿到 $57M 融資**，代表商業生態系在成長\n- **Packagist** 超過 45 萬個套件，生態系穩定且持續壯大\n- **效能持續改善**，JIT 編譯器每個版本都在進步，搭配 Octane 更是翻倍\n- **WordPress 依然佔全球 42.6% 的網站**，PHP 的市場不會消失\n\nPHP 不是最潮的語言，也不需要是。它是最務實的選擇之一。\n\n### 值得關注的趨勢\n\n- **Laravel Cloud**，Laravel 官方的全託管部署平台，已於 2025 年 2 月隨 Laravel 12 正式上線（Sandbox $0／Production $20/mo／Business $200/mo 起，另計用量）\n- **PHP 原生非同步**，Fibers 和 Revolt event loop 讓 PHP 能處理高並發場景\n- **AI 整合**，Laravel Prompts、OpenAI 套件，PHP 也能做 AI 應用\n\n## 小結：從這裡開始你自己的旅程\n\n十五章、一個完整的團購平台、從 PHP 語法到 Production 部署。你已經走過了 Laravel 的完整學習路徑。\n\n但更重要的是，這趟下來你學到的其實不只 Laravel。你還學到了：\n\n- **框架思維**，不重複造輪子，善用生態系\n- **分層架構**，Route → Controller → Service → Model，各司其職\n- **測試文化**，有測試的程式碼才有信心重構和部署\n- **DevOps 基礎**，CI/CD、容器化、環境管理\n\n這些技能是通用的。不管你將來用 Laravel、Rails、Django 還是 NestJS，底層的思維方式是一樣的。\n\n### 你的下一步\n\n根據你的方向，我推薦的學習路線：\n\n**想深入 Laravel？**\n→ Laracasts 進階課程 → Laravel Horizon → Laravel Reverb → 讀 Laravel 原始碼\n\n**想做自己的 SaaS？**\n→ 加入 Stripe 訂閱制（Cashier 支援）→ Multi-tenancy → Vapor 部署\n\n**想找 Laravel 相關工作？**\n→ GitHub Profile 放上揪好買專案 → 寫技術部落格分享學習心得 → 加入 Laravel 台灣社群\n\n**想把揪好買上線？**\n→ 加入 LINE 整合 → 接 LINE Pay → 找幾個朋友當 Beta 使用者 → 真的拿去開團試試看\n\n不管你選哪條路，記住一件事：**最好的學習方式就是持續建造**。不要只看教學，要動手寫。寫出 Bug，修掉它。看到新套件，裝上去試試。遇到問題，去社群問。\n\n揪好買是你的起點，不是終點。\n\n祝你寫程式愉快。🚀",
      "summary": "用 Laravel 12 打造揪好買團購平台的完整旅程到此告一段落。本章回顧全書 15 章的 Laravel 學習路徑，整理揪好買可擴展的功能方向，介紹 Nova、Vapor、Laracasts 等生態系資源，並為你規劃從初級到進階的 PHP／Laravel 成長路線圖。",
      "image": "https://bobochen.dev/_astro/cover.BGaLi2VY.webp",
      "date_published": "2025-06-10T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "學習路線圖",
        "Laravel Ecosystem"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-deployment-forge-docker/",
      "url": "https://bobochen.dev/blog/laravel-guide-deployment-forge-docker/",
      "title": "部署上線：從 Laravel Forge 到容器化的三條路",
      "content_text": "Laravel 12 應用部署上線完整指南：比較 Laravel Forge 一鍵部署、Docker 容器化、與 Cloud Run / Fly.io Serverless 三條路線，涵蓋 Production 設定、Config/Route Cache、Laravel Octane 加速、Let's Encrypt SSL 與零停機部署實作。",
      "content_html": "程式寫好了、測試通過了、功能也確認沒問題了。然後呢？「部署」這件事聽起來很簡單——把程式碼放到伺服器上就好了嘛——但實際操作起來，從環境設定、效能調校、SSL 憑證、到零停機部署，每一步都有它的眉角。選錯部署方式，你可能花更多時間在維運上，而不是開發新功能。\n\nLaravel 生態系提供了三條截然不同的部署路線：Laravel Forge 讓你在 DigitalOcean 或 AWS 上一鍵部署，幾乎不用碰伺服器設定；Docker 容器化讓你的應用在任何環境都能一致運行；Cloud Run 和 Fly.io 這類雲端平台則讓你連伺服器都不用管。三條路各有優缺點，適合不同階段和不同規模的團隊。\n\n這一章我們會走過上線前的必要準備——Config Cache、Route Cache、Octane 加速——然後實際帶你用兩種方式部署揪好買：Forge 的一鍵流程和 Docker 的容器化流程。不管你最後選哪條路，上線前該做的事情都一樣，而這些知識會讓你省下無數個半夜被叫起來修 Bug 的夜晚。\n\n## Laravel 部署上線前的 Production 設定\n\n不管你選哪條部署路線，上線前有一份 checklist 是每個 Laravel 專案都必須走過的。這些設定漏掉任何一項，輕則效能低落，重則資料外洩。\n\n### 環境變數核心設定\n\n打開你的 `.env`（或部署平台的環境變數設定），確認這三項：\n\n```bash\nAPP_ENV=production\nAPP_DEBUG=false\nAPP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n`APP_ENV=production` 告訴 Laravel 現在是正式環境，會影響一些行為——例如錯誤頁面不會顯示堆疊追蹤、某些開發工具會被停用。`APP_DEBUG=false` **絕對不能忘記**——如果設成 `true`，使用者在瀏覽器裡就能看到你的完整錯誤訊息，包含資料庫密碼、API Key、程式碼路徑，等於把系統的所有弱點攤在陽光下。\n\n`APP_KEY` 是 Laravel 用來加密 Session、Cookie、Eloquent 加密欄位的密鑰。如果你在部署時沒有設定，所有加密功能都會失效。在新環境第一次部署時執行：\n\n```bash\nphp artisan key:generate\n```\n\n> **警告：** 正式環境的 `APP_KEY` 一旦設定就不要隨意更換。換了之後，所有舊的加密資料（包含使用者的 Session）都會失效，等同於強制所有使用者重新登入。\n\n### 上線前完整 Checklist\n\n```bash\n# 1. 環境設定\nAPP_ENV=production\nAPP_DEBUG=false\nAPP_KEY=base64:... (已設定)\n\n# 2. 資料庫\nDB_CONNECTION=mysql          # 或 pgsql\nDB_HOST=your-production-db   # 不要用 127.0.0.1，除非 DB 在同一台機器\nDB_DATABASE=jiuhaobuy\nDB_USERNAME=jiuhaobuy_user   # 不要用 root\nDB_PASSWORD=strong-password   # 至少 20 字元\n\n# 3. Cache & Session\nCACHE_STORE=redis            # 正式環境用 Redis\nSESSION_DRIVER=redis\nQUEUE_CONNECTION=redis\n\n# 4. Mail（正式環境不要用 log）\nMAIL_MAILER=smtp\nMAIL_HOST=smtp.mailgun.org   # 或 SES、Postmark\n\n# 5. URL\nAPP_URL=https://jiuhaobuy.com\n```\n\n### 執行 Migration\n\n在正式環境執行 migration 時，加上 `--force` flag——因為 Laravel 在 production 環境會要求確認：\n\n```bash\nphp artisan migrate --force\n```\n\n**最佳做法：** 不要直接 SSH 進伺服器手動跑 migration。把它放進你的部署腳本（deploy script），讓它在每次部署時自動執行。Forge 和 Docker 都支援這種做法，後面會示範。\n\n### Queue Worker 設定\n\n[上一章](/blog/laravel-guide-queues-events-notifications/)我們用 `php artisan queue:work` 在本地跑 Queue Worker。正式環境你需要一個 process manager 來確保 worker 持續運行。最常用的是 Supervisor：\n\n```ini\n# /etc/supervisor/conf.d/jiuhaobuy-worker.conf\n[program:jiuhaobuy-worker]\nprocess_name=%(program_name)s_%(process_num)02d\ncommand=php /var/www/jiuhaobuy/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600\nautostart=true\nautorestart=true\nuser=www-data\nnumprocs=2\nredirect_stderr=true\nstdout_logfile=/var/www/jiuhaobuy/storage/logs/worker.log\n```\n\n### Scheduler 設定（Cron）\n\nLaravel 的 Task Scheduler 需要一個 cron job 每分鐘觸發一次。在伺服器上執行 `crontab -e`，加上這一行：\n\n```bash\n* * * * * cd /var/www/jiuhaobuy && php artisan schedule:run >> /dev/null 2>&1\n```\n\n這一行 cron 會每分鐘執行 `schedule:run`，然後由 Laravel 內部判斷哪些排程任務該在這一分鐘執行。[上一章](/blog/laravel-guide-queues-events-notifications/)設定的截止提醒通知、定時清理過期團購，都靠這一行 cron 來驅動。\n\n## Config / Route / View Caching：榨乾每一滴效能\n\nLaravel 在每次 request 進來時，預設會重新讀取所有 config 檔案、解析所有路由、編譯 Blade 模板。這在開發時很方便——改了 config 馬上生效——但在正式環境，這些重複的 I/O 和解析是純粹的浪費。\n\n### Config Cache\n\n```bash\nphp artisan config:cache\n```\n\n這個指令把 `config/` 目錄下所有的設定檔合併成一個 PHP 檔案 `bootstrap/cache/config.php`。Laravel 載入一個檔案的速度遠快於掃描整個目錄。效果有多好？在有 30 個 config 檔案的專案裡，這一個指令就能減少 request bootstrap 時間約 40-60%。\n\n**注意事項：** 一旦 cache 了 config，`env()` function 只能在 config 檔案裡使用。如果你在 Controller 或 Model 裡直接呼叫 `env('SOME_VAR')`，它會回傳 `null`。正確做法是永遠透過 `config('services.some_var')` 來取值，而不是直接呼叫 `env()`。\n\n### Route Cache\n\n```bash\nphp artisan route:cache\n```\n\n把所有路由編譯成序列化的 PHP 陣列。Laravel 載入預編譯的路由比每次解析 `routes/web.php` 和 `routes/api.php` 快非常多——路由越多效果越明顯。有上百條路由的專案，啟動時間可以減少 50% 以上。\n\n### View Cache\n\n```bash\nphp artisan view:cache\n```\n\n預先編譯所有 Blade 模板成 PHP 檔案，存放在 `storage/framework/views/`。正式環境不該讓第一個使用者承受模板編譯的延遲。\n\n### Event Cache\n\n```bash\nphp artisan event:cache\n```\n\n把 Event 和 Listener 的對應關係快取起來，省去每次 request 掃描和自動發現的開銷。\n\n### 一鍵全部搞定\n\nLaravel 提供了一個 `optimize` 指令，做上面所有快取的事：\n\n```bash\nphp artisan optimize\n```\n\n這等同於執行 `config:cache` + `route:cache` + `view:cache` + `event:cache`。部署腳本裡通常就放這一行。\n\n開發時如果要清除所有快取：\n\n```bash\nphp artisan optimize:clear\n```\n\n### Before / After 比較\n\n以「揪好買」的實際測量為例，在一台 1 vCPU / 1GB RAM 的伺服器上：\n\n| 指標 | 未快取 | 快取後 | 改善 |\n|------|--------|--------|------|\n| 首次 request 回應時間 | ~180ms | ~45ms | **75%** |\n| Config 載入時間 | ~12ms | ~1ms | 92% |\n| Route 解析時間 | ~8ms | ~1ms | 88% |\n| 記憶體使用 | ~32MB | ~24MB | 25% |\n\n這些數字告訴你：**上線前跑 `php artisan optimize` 不是選配，是必做。**\n\n## 路線一：Laravel Forge 一鍵部署\n\n如果你不想花時間在伺服器管理上——安裝 PHP、設定 Nginx、調整防火牆、更新系統套件——Laravel Forge 就是你的答案。它是 Laravel 官方團隊提供的伺服器管理服務。定價分為兩個主要方案：$12/月的 Hobby 方案支援無限台 Laravel 自家 VPS，但只允許連接 1 台外部主機商（DigitalOcean、AWS、Hetzner 等）的伺服器；若要連接無限台外部雲端主機商伺服器，需升級至 $19/月的 Growth 方案。對「揪好買」這種單機小專案，Hobby 方案已經足夠。\n\n### Forge 是什麼\n\nForge 不是主機商——它不賣伺服器。它是一個管理層：你提供 DigitalOcean、AWS、Hetzner、Vultr 等主機商的 API Key，Forge 幫你自動建立（provision）伺服器，然後持續管理它。Forge 幫你做的事包括：\n\n- 安裝 PHP（支援多版本切換）、Nginx、MySQL/PostgreSQL、Redis\n- 設定防火牆規則、SSH Key 管理\n- 自動化部署：連結 GitHub repo，Push 到 main 就自動部署\n- SSL 憑證：Let's Encrypt 一鍵設定、自動續約\n- 排程任務和 Queue Worker 管理\n- 資料庫備份\n\n### 部署流程\n\n**第一步：註冊並連結主機商**\n\n在 [forge.laravel.com](https://forge.laravel.com) 註冊帳號。到 Server Providers 頁面，把你的 DigitalOcean（或其他主機商）API Token 填進去。\n\n**第二步：建立伺服器**\n\n點「Create Server」，選擇主機商、地區、規格。以「揪好買」來說，一台 DigitalOcean $6/月的 Droplet（1 vCPU / 1GB RAM）就足以應付初期流量。Forge 會花大約 10 分鐘自動安裝所有需要的軟體。\n\n**第三步：連結 GitHub Repo**\n\n在 Forge 的 Sites 區塊點「New Site」，填入你的 domain name（例如 `jiuhaobuy.com`），然後連結 GitHub repository。Forge 會在伺服器上 clone 你的程式碼。\n\n**第四步：設定 Deploy Script**\n\nForge 預設的 deploy script 大概長這樣：\n\n```bash\ncd /home/forge/jiuhaobuy.com\ngit pull origin $FORGE_SITE_BRANCH\n\ncomposer install --no-dev --no-interaction --prefer-dist --optimize-autoloader\n\nphp artisan migrate --force\nphp artisan optimize\n\n# 如果你用了 npm assets\nnpm ci\nnpm run build\n\nphp artisan queue:restart\n```\n\n每次你 Push 到 GitHub，Forge 就會自動執行這段腳本。\n\n**第五步：SSL 憑證**\n\n在 Forge 的 SSL 區塊，點「Let's Encrypt」，填入你的 domain，點確認。Forge 會自動申請、設定、並且在到期前自動續約。整個過程不到一分鐘。\n\n### Forge 的優缺點\n\n| 優點 | 缺點 |\n|------|------|\n| 幾乎零學習曲線，UI 操作 | 月費 $12（加上主機費用） |\n| 自動化部署、SSL、備份 | 你的伺服器管理依賴 Forge |\n| 適合個人開發者和小團隊 | 不適合需要複雜基礎設施的場景 |\n| 官方維護，與 Laravel 整合最好 | 如果哪天想離開，需要自己接手伺服器管理 |\n\n對於「揪好買」這種小型到中型的專案，Forge 是最快上線的方式。你可以把省下來的時間拿去做功能開發，而不是除錯 Nginx 設定。\n\n## 路線二：Docker 容器化部署\n\nDocker 解決的核心問題是：**「在我電腦上可以跑」和「在伺服器上可以跑」不再是兩件事。** 你把應用程式和它的所有依賴（PHP 版本、擴充套件、系統套件）打包成一個 image，這個 image 在任何有 Docker 的機器上都能一致地運行。\n\n### 為什麼要用 Docker\n\n- **環境一致性**——開發、CI、正式環境用同一個 image，不會有「PHP 版本不對」的問題\n- **可攜性**——image 可以跑在 AWS、GCP、Azure，或你辦公室的 NAS 上\n- **CI/CD 友善**——Build image → Push to registry → Deploy，流程標準化\n- **隔離性**——每個服務跑在自己的 container，互不干擾\n\n### Dockerfile：多階段建置\n\n多階段建置（multi-stage build）讓你的 production image 只包含必要的檔案，不會把 Composer、npm、開發工具帶進去：\n\n```dockerfile\n# ===== 第一階段：安裝 PHP 依賴 =====\nFROM composer:2 AS composer-build\nWORKDIR /app\nCOPY composer.json composer.lock ./\nRUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist\nCOPY . .\nRUN composer dump-autoload --optimize\n\n# ===== 第二階段：建置前端資源 =====\nFROM node:24-alpine AS node-build\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci\nCOPY . .\nRUN npm run build\n\n# ===== 第三階段：Production Image =====\nFROM php:8.4-fpm-alpine\n\n# 安裝系統依賴和 PHP 擴充\nRUN apk add --no-cache \\\n    nginx \\\n    supervisor \\\n    libpng-dev \\\n    libzip-dev \\\n    icu-dev \\\n    && docker-php-ext-install \\\n    pdo_mysql \\\n    gd \\\n    zip \\\n    intl \\\n    opcache \\\n    pcntl\n\n# 複製 Nginx 設定\nCOPY docker/nginx.conf /etc/nginx/http.d/default.conf\n\n# 複製 Supervisor 設定\nCOPY docker/supervisord.conf /etc/supervisord.conf\n\n# 複製應用程式碼\nWORKDIR /var/www/html\nCOPY --from=composer-build /app/vendor ./vendor\nCOPY --from=node-build /app/public/build ./public/build\nCOPY . .\n\n# 設定權限\nRUN chown -R www-data:www-data storage bootstrap/cache \\\n    && chmod -R 775 storage bootstrap/cache\n\n# OPcache 設定\nCOPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini\n\nEXPOSE 80\n\nCMD [\"/usr/bin/supervisord\", \"-c\", \"/etc/supervisord.conf\"]\n```\n\n### docker-compose.yml\n\n開發和測試時，用 `docker-compose` 把所有服務串起來：\n\n```yaml\n# docker-compose.yml\nservices:\n  app:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    ports:\n      - \"8080:80\"\n    environment:\n      - APP_ENV=production\n      - APP_DEBUG=false\n      - DB_HOST=mysql\n      - REDIS_HOST=redis\n    env_file:\n      - .env\n    depends_on:\n      mysql:\n        condition: service_healthy\n      redis:\n        condition: service_started\n    volumes:\n      - storage:/var/www/html/storage\n\n  mysql:\n    image: mysql:8.4\n    environment:\n      MYSQL_DATABASE: jiuhaobuy\n      MYSQL_USER: jiuhaobuy_user\n      MYSQL_PASSWORD: secret\n      MYSQL_ROOT_PASSWORD: root_secret\n    volumes:\n      - mysql-data:/var/lib/mysql\n    healthcheck:\n      test: [\"CMD\", \"mysqladmin\", \"ping\", \"-h\", \"localhost\"]\n      interval: 5s\n      timeout: 3s\n      retries: 5\n\n  redis:\n    image: redis:7-alpine\n    volumes:\n      - redis-data:/data\n\n  queue-worker:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600\n    env_file:\n      - .env\n    environment:\n      - DB_HOST=mysql\n      - REDIS_HOST=redis\n    depends_on:\n      - app\n\n  scheduler:\n    build:\n      context: .\n      dockerfile: Dockerfile\n    command: sh -c \"while true; do php artisan schedule:run --verbose --no-interaction & sleep 60; done\"\n    env_file:\n      - .env\n    environment:\n      - DB_HOST=mysql\n      - REDIS_HOST=redis\n    depends_on:\n      - app\n\nvolumes:\n  mysql-data:\n  redis-data:\n  storage:\n```\n\n### 建置與執行\n\n```bash\n# 建置 image\ndocker compose build\n\n# 啟動所有服務\ndocker compose up -d\n\n# 第一次啟動：跑 migration 和 cache\ndocker compose exec app php artisan migrate --force\ndocker compose exec app php artisan optimize\n\n# 查看 logs\ndocker compose logs -f app\n\n# 停止所有服務\ndocker compose down\n```\n\n### 推送到 Container Registry\n\n正式部署時，你會把 image 推到 container registry（Docker Hub、GitHub Container Registry、Google Artifact Registry 等），然後在伺服器或雲端平台上拉取這個 image：\n\n```bash\n# 建置並標記 image\ndocker build -t ghcr.io/your-org/jiuhaobuy:latest .\ndocker build -t ghcr.io/your-org/jiuhaobuy:v1.0.0 .\n\n# 推送到 GitHub Container Registry\ndocker push ghcr.io/your-org/jiuhaobuy:latest\ndocker push ghcr.io/your-org/jiuhaobuy:v1.0.0\n```\n\n> **建議：** 除了 `latest` tag，永遠也打一個版本號 tag（例如 `v1.0.0` 或 Git commit SHA）。這樣你可以隨時回滾到指定版本。\n\n## Laravel Octane：Swoole / FrankenPHP 加速\n\n傳統的 PHP 執行模式是：每個 request 進來 → 載入框架 → 處理 request → 回應 → 丟掉所有東西。下一個 request 再從頭來一次。這就像每次客人進餐廳，你都要重新蓋一次廚房、擺一次桌椅、然後煮完菜再把整間餐廳拆掉。\n\nLaravel Octane 改變了這個模式：它在第一次啟動時載入整個框架到記憶體裡，之後每個 request 都是在已經準備好的環境中處理，不需要重新 bootstrap。\n\n### FrankenPHP vs Swoole\n\nOctane 支援三種 server：\n\n| Server | 特點 | 適合場景 |\n|--------|------|---------|\n| **FrankenPHP** | Go 實作，簡單易用，支援 HTTP/3 | **推薦首選**，設定最少 |\n| **Swoole** | C 擴充，功能最多（WebSocket、協程） | 需要 WebSocket 或高併發場景 |\n| **RoadRunner** | Go 實作，穩定成熟 | 需要長期穩定的場景 |\n\n對大多數 Laravel 應用來說，**FrankenPHP 是目前最推薦的選擇**——它是 Caddy web server 的一部分，安裝簡單，而且自帶 HTTPS 支援（Caddy 的 automatic HTTPS）。\n\n### 安裝與設定\n\n```bash\ncomposer require laravel/octane\n\nphp artisan octane:install\n# 選擇 frankenphp\n```\n\n啟動 Octane：\n\n```bash\n# 開發環境（帶 watch 模式，改檔案自動重啟）\nphp artisan octane:start --watch\n\n# 正式環境\nphp artisan octane:start --host=0.0.0.0 --port=8000 --workers=4\n```\n\n### 效能提升\n\n在「揪好買」的實測環境（同一台 1 vCPU 機器），使用 Apache Bench 測試 `/api/group-buys` endpoint：\n\n| 指標 | PHP-FPM | Octane (FrankenPHP) | 提升幅度 |\n|------|---------|---------------------|---------|\n| Requests/sec | ~120 | ~450 | **3.7x** |\n| 平均回應時間 | ~83ms | ~22ms | 3.8x |\n| P99 回應時間 | ~250ms | ~65ms | 3.8x |\n| 記憶體使用 | 每 request 獨立 | 共享 ~80MB | 整體更省 |\n\n吞吐量提升 3-5 倍是常見的數字。對於流量還小的新專案，你可能感覺不到差異；但當流量成長到一定程度，Octane 可以讓你晚幾個月才需要升級伺服器規格。\n\n### 注意事項：記憶體和靜態狀態\n\nOctane 把應用保持在記憶體裡，這帶來一個重要的副作用：**靜態變數和全域狀態會跨 request 存活。**\n\n```php\n// ❌ 危險：這個計數器會在所有 request 之間共享\nclass SomeService\n{\n    private static int $count = 0;\n\n    public function handle(): void\n    {\n        self::$count++; // 每個 request 都會累加\n    }\n}\n\n// ✅ 正確：用 request-scoped 的方式\nclass SomeService\n{\n    public function handle(Request $request): void\n    {\n        // 從 request 或 container 取值，不用靜態狀態\n    }\n}\n```\n\nLaravel 12 的核心服務都已經處理好 Octane 相容性。但如果你用了第三方套件，需要確認它們是否「Octane-friendly」。常見的陷阱：\n\n- **不要在 Service 裡存 request 相關的狀態**——每個 worker 會處理多個 request\n- **不要在 singleton 服務裡快取 per-request 資料**——它不會在下一個 request 被清除\n- **資料庫連線可能因為太久沒用而斷開**——Octane 有內建的連線池管理，但要注意超時設定\n\n## 路線三：Cloud Run / Fly.io 雲端部署\n\n如果你不想管伺服器，也不想管伺服器上的作業系統更新和安全修補——那就讓雲端平台幫你管。\n\n### Google Cloud Run\n\nCloud Run 的概念很單純：你給它一個 Docker image，它幫你跑起來。沒有流量的時候自動縮到零、有流量的時候自動擴展、SSL 自動搞定、domain mapping 一行指令。而且計費是 **per-request**——真的沒人用的時候，你一毛錢都不用付。\n\n部署揪好買到 Cloud Run：\n\n```bash\n# 建置 image 並推送到 Google Artifact Registry\ngcloud builds submit --tag asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest\n\n# 部署到 Cloud Run\ngcloud run deploy jiuhaobuy \\\n  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest \\\n  --platform managed \\\n  --region asia-east1 \\\n  --allow-unauthenticated \\\n  --set-env-vars APP_ENV=production,APP_DEBUG=false \\\n  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \\\n  --min-instances 0 \\\n  --max-instances 10\n```\n\nCloud Run 的限制：它是 stateless 的，container 隨時可能被回收。這意味著：\n\n- **Session 和 Cache 不能用 file driver**——必須用 Redis 或 database\n- **Queue Worker 需要另外跑**——可以用 Cloud Run Jobs 或另一個 always-on 的 Cloud Run service\n- **Scheduler 要用 Cloud Scheduler 觸發**——設定一個每分鐘打一次 `/schedule` endpoint 的 cron job\n\n### Laravel Cloud（官方第一方 serverless）\n\n如果要談「serverless 部署」，2026 年最對口的官方答案其實是 [Laravel Cloud](https://cloud.laravel.com)——Laravel 官方在 2025-02-24 隨 Laravel 12 一起推出的全託管部署平台，由 Laravel 團隊自己營運。它和 Laravel 整合最深：`git push` 就部署，內建 usage-based 計費與 scale-to-zero（閒置時自動縮到零、只付實際運算時間），佇列、排程、資料庫都幫你託管好，省去自己拼湊 Cloud Run Jobs + Cloud Scheduler 的工。\n\n實務上的取捨：Laravel Cloud 上手最快、最不用碰基礎設施，適合想專心寫 Laravel、不想管 DevOps 的團隊；Cloud Run / Fly.io 則給你更多底層掌控與跨雲彈性。如果你已經在 GCP/AWS 生態系裡，Cloud Run 仍是整合最順的選擇；但只要是 Laravel 專案要找 serverless，Laravel Cloud 都值得先列入評估。\n\n### Fly.io\n\nFly.io 的思路和 Cloud Run 類似，但它提供了 persistent volumes（持久化儲存），對需要本地檔案儲存的應用更友善。部署同樣基於 Docker image：\n\n```bash\n# 安裝 Fly CLI\ncurl -L https://fly.io/install.sh | sh\n\n# 初始化專案\nfly launch\n\n# 部署\nfly deploy\n```\n\nFly.io 的 `fly.toml` 設定檔：\n\n```toml\n[build]\n  dockerfile = \"Dockerfile\"\n\n[env]\n  APP_ENV = \"production\"\n  APP_DEBUG = \"false\"\n\n[http_service]\n  internal_port = 8080\n  force_https = true\n  auto_stop_machines = true\n  auto_start_machines = true\n  min_machines_running = 0\n\n[[vm]]\n  size = \"shared-cpu-1x\"\n  memory = \"512mb\"\n```\n\n### 三條路線比較\n\n| 面向 | Forge | Docker + VPS | Cloud Run / Fly.io |\n|------|-------|-------------|-------------------|\n| 學習曲線 | 低 | 中 | 中 |\n| 初始設定時間 | 30 分鐘 | 2-4 小時 | 1-2 小時 |\n| 月費（小型專案） | ~$18 (Forge $12 + VPS $6) | ~$6 (VPS only) | ~$0-5 (pay-per-use) |\n| 伺服器管理 | Forge 代管 | 自己管 | 雲端平台代管 |\n| 擴展性 | 手動加機器 | 手動或用 K8s | 自動擴展 |\n| 自訂程度 | 中 | 高 | 受平台限制 |\n| CI/CD 整合 | Git push 自動部署 | 需自己設定 pipeline | Docker image + 一行指令 |\n| 適合對象 | 個人/小團隊 | 有 DevOps 經驗的團隊 | Serverless 愛好者 |\n\n### 成本比較（月流量 10 萬 PV）\n\n| 項目 | Forge + DO | Docker + VPS | Cloud Run |\n|------|-----------|-------------|-----------|\n| 主機/運算 | $12 (2 vCPU) | $12 (2 vCPU) | ~$8 |\n| 管理服務 | $12 (Forge) | $0 | $0 |\n| 資料庫 | 含在 VPS | 含在 VPS | ~$10 (Cloud SQL) |\n| Redis | 含在 VPS | 含在 VPS | ~$6 (Memorystore) |\n| **合計** | **~$24/月** | **~$12/月** | **~$24/月** |\n\n結論：小專案的話，三條路成本差不多。Docker + VPS 最便宜但你要自己管；Forge 和 Cloud Run 花錢買時間。\n\n## SSL / HTTPS 設定\n\n2026 年了，HTTPS 不是選配——瀏覽器會把 HTTP 網站標記為「不安全」，SEO 也會被扣分。好消息是，免費的 SSL 憑證現在垂手可得。\n\n### Let's Encrypt（免費、自動續約）\n\nLet's Encrypt 提供免費的 SSL 憑證，有效期 90 天，支援自動續約。在不同部署方式下的設定方式：\n\n**Forge：** 前面提過，在 SSL 區塊點「Let's Encrypt」就搞定了。Forge 會自動設定 Nginx、自動續約。零操作。\n\n**Docker + Caddy：** 如果你用 Caddy 取代 Nginx 當反向代理，HTTPS 是全自動的——Caddy 預設就會幫你申請和續約 Let's Encrypt 憑證：\n\n```caddyfile\n# Caddyfile\njiuhaobuy.com {\n    reverse_proxy app:8080\n}\n```\n\n就這樣。不需要任何額外設定。這也是為什麼越來越多人在 Docker 部署時選擇 Caddy 而不是 Nginx。\n\n**Docker + Nginx + Certbot：** 如果你堅持用 Nginx，需要搭配 Certbot：\n\n```bash\n# 安裝 Certbot\napt install certbot python3-certbot-nginx\n\n# 申請憑證\ncertbot --nginx -d jiuhaobuy.com -d www.jiuhaobuy.com\n\n# 自動續約（Certbot 會自動設定 cron）\ncertbot renew --dry-run\n```\n\n**Cloud Run / Fly.io：** 自動提供 HTTPS，不需要任何設定。\n\n### Laravel 的 HTTPS 設定\n\n在 `AppServiceProvider` 裡強制所有 URL 使用 HTTPS：\n\n```php\npublic function boot(): void\n{\n    if ($this->app->environment('production')) {\n        URL::forceScheme('https');\n    }\n}\n```\n\n同時在 `.env` 確認 `APP_URL` 使用 `https://`：\n\n```bash\nAPP_URL=https://jiuhaobuy.com\n```\n\n## Zero-Downtime 部署策略\n\n使用者正在瀏覽你的網站，你按下部署按鈕，程式碼更新過程中——使用者看到一個 500 錯誤。這就是「有停機時間的部署」的問題。對一個團購平台來說，如果在結單的關鍵時刻出現錯誤，直接就是商譽和金錢的損失。\n\n### 問題出在哪\n\n傳統的 `git pull` + `composer install` 部署方式，中間有一段過渡期：舊的程式碼已經被覆蓋了，但新的依賴還沒裝好、cache 還沒更新。這段期間的 request 就會出錯。\n\n### Atomic Symlink 部署（Forge / Envoyer）\n\n原理：每次部署建立一個新的完整目錄（例如 `releases/20260831120000/`），在新目錄裡把所有東西準備好——Composer install、npm build、migration、cache——然後用一個 symlink 切換，把 `current/` 指向新的 release 目錄。Symlink 的切換是原子操作（atomic），不會有中間狀態。\n\n```text\n/var/www/jiuhaobuy/\n├── releases/\n│   ├── 20260830_120000/   ← 上一版\n│   └── 20260831_120000/   ← 新版（已經準備好了）\n├── current -> releases/20260831_120000/   ← symlink 一切換，馬上生效\n├── storage/               ← 所有 release 共用\n└── .env                   ← 所有 release 共用\n```\n\nLaravel Forge 內建就支援這種部署模式。2026 年所有新的 Forge 訂閱（含 $12 Hobby 方案）都已內建單機 zero-downtime / atomic 部署，官方明確說明「不需要再額外訂閱 Envoyer」。[Laravel Envoyer](https://envoyer.io)（$12/月）現在的價值在於「把同一個專案部署到多台伺服器」這種多機情境——如果你只是單機部署，Forge 本身就夠用，不必為了 zero-downtime 再多付這筆錢。\n\n### Docker Rolling Update\n\nDocker 環境的零停機部署靠的是 rolling update：先啟動新版 container，確認健康檢查通過後，再停止舊版 container。\n\n如果用 Docker Compose：\n\n```bash\n# 建置新 image\ndocker compose build app\n\n# 滾動更新（先起新的，再停舊的）\ndocker compose up -d --no-deps --build app\n```\n\n在 Cloud Run 或 Kubernetes 環境，rolling update 是預設行為——你部署新版本後，平台會自動漸進式地把流量切換到新的 container。\n\n### Blue-Green 部署\n\n更進階的做法：同時維護兩套完全相同的環境（Blue 和 Green）。目前 Blue 在服務使用者，你把新版本部署到 Green，測試通過後，把 load balancer 切換到 Green。如果新版本有問題，馬上切回 Blue——回滾時間幾乎是零。\n\n這種做法的缺點是成本比較高（你要維護兩套環境），所以通常是有一定規模的團隊才會採用。「揪好買」初期用 Forge 的 atomic symlink 或 Docker 的 rolling update 就綽綽有餘。\n\n## 環境變數管理：Production 的 .env\n\n`.env` 檔案裡有你的資料庫密碼、API Key、加密金鑰——這些東西如果外洩，後果不堪設想。管理 production 環境變數有幾個鐵律：\n\n### 絕對不要 Commit .env 到 Git\n\n確認 `.gitignore` 裡有 `.env`。如果你不小心 commit 過，光是刪掉檔案不夠——歷史紀錄裡還在。你需要：\n\n1. 立即更換所有外洩的密鑰（資料庫密碼、API Key 等）\n2. 用 `git filter-branch` 或 BFG Repo-Cleaner 清除 Git 歷史\n\n### 各部署方式的環境變數管理\n\n**Forge：** 在伺服器的 Environment 頁面直接編輯。Forge 會把內容寫到伺服器上的 `.env` 檔案，並設定正確的檔案權限。\n\n**Docker：** 兩種常見做法：\n\n```yaml\n# 方式一：env_file（適合開發和 CI）\nservices:\n  app:\n    env_file:\n      - .env.production\n\n# 方式二：Docker Secrets（適合正式環境）\nservices:\n  app:\n    secrets:\n      - db_password\n      - app_key\n\nsecrets:\n  db_password:\n    external: true\n  app_key:\n    external: true\n```\n\n**Cloud Run：** 使用 Google Cloud Secret Manager，在部署時透過 `--set-secrets` 把 secret 注入成環境變數：\n\n```bash\n# 建立 secret\necho -n \"your-database-password\" | gcloud secrets create db-password --data-file=-\n\n# 部署時注入\ngcloud run deploy jiuhaobuy \\\n  --set-secrets DB_PASSWORD=db-password:latest\n```\n\n### 金鑰輪替策略\n\n好的安全實踐包括定期更換敏感的金鑰：\n\n- **資料庫密碼**——每 90 天更換一次\n- **第三方 API Key**——依照服務商建議的頻率\n- **APP_KEY**——除非有外洩疑慮，否則不要換（會影響所有加密資料）\n- **JWT Secret**——更換後所有舊的 token 會失效，要在低流量時段操作\n\n每次更換後，更新部署平台的環境變數，然後重新部署或重啟應用。\n\n## 實作：部署揪好買到正式環境\n\n講了這麼多理論，現在來實際操作。我們提供兩條路線，挑一條適合你的跟著做。\n\n### Option A：Forge 快速上線（5 步驟）\n\n**步驟 1：** 到 [forge.laravel.com](https://forge.laravel.com) 註冊，連結你的 DigitalOcean 帳號。\n\n**步驟 2：** 建立伺服器——選 DigitalOcean、新加坡區域（離台灣最近）、$6/月的 1GB Droplet。等待約 10 分鐘。\n\n**步驟 3：** 新增 Site——填入 domain（例如 `jiuhaobuy.com`），連結 GitHub repo，選 `main` branch。\n\n**步驟 4：** 設定環境變數——在 Forge 的 Environment 頁面，貼上你的 production `.env` 內容（記得改 `APP_ENV=production`、`APP_DEBUG=false`、設定正確的資料庫連線）。\n\n**步驟 5：** 按下「Deploy Now」。Forge 會執行 deploy script：拉程式碼、裝依賴、跑 migration、清快取。部署完成後，到 SSL 區塊申請 Let's Encrypt 憑證。\n\n完成。從註冊到上線大約 20-30 分鐘。\n\n### Option B：Docker + Cloud Run（Dockerfile → Build → Deploy）\n\n**步驟 1：** 確認專案根目錄有前面提到的 `Dockerfile`。\n\n**步驟 2：** 在 Google Cloud 建立專案和 Artifact Registry：\n\n```bash\n# 建立 Artifact Registry\ngcloud artifacts repositories create jiuhaobuy \\\n  --repository-format=docker \\\n  --location=asia-east1\n\n# 設定 Secret Manager\necho -n \"base64:your-app-key\" | gcloud secrets create app-key --data-file=-\necho -n \"your-db-password\" | gcloud secrets create db-password --data-file=-\n```\n\n**步驟 3：** 建置並推送 image：\n\n```bash\ngcloud builds submit \\\n  --tag asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:v1.0.0\n```\n\n**步驟 4：** 部署到 Cloud Run：\n\n```bash\ngcloud run deploy jiuhaobuy \\\n  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:v1.0.0 \\\n  --platform managed \\\n  --region asia-east1 \\\n  --allow-unauthenticated \\\n  --set-env-vars APP_ENV=production,APP_DEBUG=false,APP_URL=https://jiuhaobuy.com \\\n  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \\\n  --min-instances 1 \\\n  --max-instances 10 \\\n  --memory 512Mi\n```\n\n**步驟 5：** 設定 custom domain 和執行 migration：\n\n```bash\n# 對應你的 domain\ngcloud run domain-mappings create --service jiuhaobuy \\\n  --domain jiuhaobuy.com --region asia-east1\n\n# 執行 migration（用 Cloud Run Jobs）\n# 第一次要先建立 job——直接 execute 會得到 job not found\ngcloud run jobs create jiuhaobuy-migrate \\\n  --image asia-east1-docker.pkg.dev/your-project/jiuhaobuy/app:latest \\\n  --region asia-east1 \\\n  --set-secrets APP_KEY=app-key:latest,DB_PASSWORD=db-password:latest \\\n  --command php \\\n  --args artisan,migrate,--force\n\n# 建立後即可執行（之後每次部署重跑這行就好）\ngcloud run jobs execute jiuhaobuy-migrate --region asia-east1\n```\n\n### 部署後 Checklist\n\n不管你選哪條路，部署完成後走過這份 checklist：\n\n```bash\n# 1. 確認應用可以存取\ncurl -I https://jiuhaobuy.com          # 應該回 200\n\n# 2. 確認 HTTPS 正常\ncurl -I http://jiuhaobuy.com           # 應該回 301 redirect 到 https\n\n# 3. 確認 migration 已執行\nphp artisan migrate:status             # 所有 migration 應該都是 Ran\n\n# 4. 確認 cache 已建立\nphp artisan optimize                   # 建立 config/route/view/event cache\n\n# 5. 確認 Queue Worker 在運行\nphp artisan queue:monitor redis:default  # 檢查 queue 狀態\n\n# 6. 確認 Scheduler 在運行\n# 等一分鐘後檢查 logs，看 schedule:run 有沒有被觸發\n\n# 7. 測試核心功能\n# - 註冊/登入\n# - 建立團購\n# - 加入團購\n# - 付款流程（用 Stripe test mode）\n```\n\n## 小結：選擇適合你的部署方式\n\n部署不是一個技術問題，它是一個「我願意花多少時間在維運上」的決策。\n\n**決策矩陣：**\n\n- **一個人做 Side Project** → Forge。$18/月買回你的時間。\n- **有 Docker 經驗、在意成本** → Docker + VPS。$6/月跑起來，但 server 你自己管。\n- **已經在用 GCP/AWS** → Cloud Run / ECS。跟既有基礎設施整合最順。\n- **想要極致效能** → 任何路線都可以加上 Octane。\n\n這一章涵蓋了從上線前設定、效能快取、三種部署路線、SSL、零停機部署、到環境變數管理——這些是 Laravel 應用正式上線的完整知識。不管你選哪條路，核心步驟都一樣：設定環境變數、跑 migration、建立 cache、確保 queue worker 和 scheduler 在跑。\n\n[下一章](/blog/laravel-guide-roadmap-next-steps/)是這本書的最後一章。我們會回顧整個「揪好買」的旅程、盤點你學到的所有技能、展望 Laravel 生態系裡還有哪些強大的工具等著你探索，然後幫你畫一張接下來的進階學習路線圖。你已經具備了從零到上線的完整能力——接下來的路，你可以自己走了。",
      "summary": "Laravel 12 應用部署上線完整指南：比較 Laravel Forge 一鍵部署、Docker 容器化、與 Cloud Run / Fly.io Serverless 三條路線，涵蓋 Production 設定、Config/Route Cache、Laravel Octane 加速、Let's Encrypt SSL 與零停機部署實作。",
      "image": "https://bobochen.dev/_astro/cover.B0YN06l1.webp",
      "date_published": "2025-06-03T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "部署",
        "Docker",
        "Laravel Forge",
        "Cloud Run",
        "DevOps"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-testing-pest-ci/",
      "url": "https://bobochen.dev/blog/laravel-guide-testing-pest-ci/",
      "title": "測試不是選配：用 Pest 寫出有信心的 Laravel 程式",
      "content_text": "用 Pest 測試框架為 Laravel 12 應用寫單元測試與功能測試：actingAs 模擬登入、RefreshDatabase 隔離資料、Mail::fake() 與 Queue::fake() 攔截副作用，再搭配 GitHub Actions 建立 CI pipeline，讓每次 Push 都自動跑測試，從此敢重構、放心部署。",
      "content_html": "> 本系列以 Laravel 12 為基準撰寫。Laravel 13 已於 2026 年 3 月 17 日正式發佈（最低需求 PHP 8.3），但本章的 Pest 測試內容在 Laravel 13 同樣適用，不需特別調整。\n\n「在我電腦上可以跑啊。」——這大概是軟體開發史上最經典的一句話。你改了一個 Model 的欄位，結果另一個頁面的表單壞了；你重構了一段商業邏輯，結果付款流程默默失效了。沒有測試的程式碼，每一次修改都是一場賭博，而你遲早會輸。\n\nPHP 社群過去對測試的態度確實比較隨意，但 Pest 測試框架的出現改變了這件事。Pest 是建立在 PHPUnit 之上的現代測試框架，語法簡潔到你會覺得寫測試跟寫文件一樣自然。搭配 Laravel 內建的測試工具，寫測試的門檻已經低到沒有藉口不寫了：\n\n- **HTTP 測試**：模擬使用者操作，不需要真的開瀏覽器\n- **RefreshDatabase**：讓每次測試都從乾淨的資料庫開始\n- **Mail::fake()**：攔截寄信動作，不會真的發出 email\n- **Queue::fake()**：攔截佇列工作，不需要跑 worker\n\n這一章我們要為揪好買的核心流程寫完整測試：開團建立、跟團加入、成團確認、付款流程。然後把這些測試串進 GitHub Actions CI pipeline，讓每一次 Push 都自動跑測試。從此以後，你可以安心重構、放心部署，晚上也能睡好覺。\n\n## 為什麼 Laravel 專案一定要寫測試：不是為了考試，是為了睡好覺\n\n先講兩個真實場景。\n\n**場景一：** 某電商平台的工程師收到需求——「折扣碼不能跟團購優惠同時使用」。他改了結帳 Controller 裡的幾行邏輯，本機測了一下沒問題就部署了。隔天早上，客服收到 50 通投訴：「我加購商品怎麼價格變成零？」。原來改動影響了加購邏輯的金額計算，而沒有人發現——因為沒有測試。\n\n**場景二：** 另一個團隊要把 Laravel 從 10 升級到 11。他們有 400 多個測試案例，跑一次 3 分鐘。升級完跑測試，紅了 12 個。每一個紅掉的測試都精確地告訴他們哪裡壞了、預期行為是什麼。他們花了一個下午修完，信心滿滿地部署上線。\n\n測試的價值不在於「證明程式碼正確」——你永遠無法證明複雜系統沒有 bug。測試的價值在三件事：\n\n**1. 安全網：讓你敢重構。** 沒有測試的程式碼，大家只敢「加程式碼」，不敢「改程式碼」。久了之後，程式碼就變成一堆層層疊加的 if-else 怪物。有測試保護，你可以放心把醜陋的程式碼重構成漂亮的架構，因為跑一次測試就知道有沒有搞壞東西。\n\n**2. 活文件：測試本身就是規格書。** 你看一個 Controller 的程式碼，可能要花 10 分鐘才搞懂它的行為。但你看對應的測試——「未登入的使用者嘗試開團時應該被導向登入頁」「開團時名稱是必填欄位」「團購人數已滿時不能再加入」——30 秒就知道這個功能在幹嘛。而且這份文件是「可以執行的」，它不會跟程式碼脫節。\n\n**3. 設計回饋：難測的程式碼通常設計不好。** 如果你發現一個 class 很難寫測試——需要 mock 一堆東西、需要準備很多前置狀態——那通常代表這個 class 做了太多事情。測試會倒逼你寫出鬆耦合、職責分明的程式碼。\n\n### 跨框架對照\n\n| 概念       | Laravel (Pest)        | Jest (Node.js)      | pytest (Python) |\n| ---------- | --------------------- | ------------------- | --------------- |\n| 測試框架   | Pest / PHPUnit        | Jest / Vitest       | pytest          |\n| 斷言語法   | `expect($x)->toBe(1)` | `expect(x).toBe(1)` | `assert x == 1` |\n| HTTP 測試  | `$this->get('/api')`  | supertest           | TestClient      |\n| 測試資料庫 | RefreshDatabase       | 自己管              | fixtures        |\n| Mock       | `Mail::fake()`        | `jest.mock()`       | `unittest.mock` |\n| 執行指令   | `php artisan test`    | `npm test`          | `pytest`        |\n\n如果你來自 JavaScript 世界，Pest 的語法會讓你非常有親切感——它就是 PHP 版的 Jest。\n\n## Pest 測試框架：比 PHPUnit 更好寫\n\nLaravel 12 的 `laravel new` 安裝精靈會詢問你選擇 **Pest** 還是 **PHPUnit**；選了 Pest，它就會自動裝好、開箱即用。如果你用的是舊版 Laravel 或想手動安裝：\n\n```bash\ncomposer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies\nphp artisan pest:install\n```\n\n`pest-plugin-laravel` 提供 `actingAs()`、Laravel 專屬斷言等整合功能；初始化建議用 `php artisan pest:install`（舊式 `./vendor/bin/pest --init` 仍可用，但新版以 `pest:install` 為主）。\n\n以 Laravel 12 來說，只要在 `laravel new` 時選 Pest，就能直接使用，不需要額外執行上面的指令。\n\n### Pest 語法 vs PHPUnit 語法\n\n先來看同一個測試用兩種方式怎麼寫：\n\n**PHPUnit（傳統方式）：**\n\n```php\n<?php\n\nnamespace Tests\\Feature;\n\nuse Tests\\TestCase;\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\n\nclass GroupBuyTest extends TestCase\n{\n    use RefreshDatabase;\n\n    public function test_homepage_returns_successful_response(): void\n    {\n        $response = $this->get('/');\n\n        $response->assertStatus(200);\n    }\n\n    public function test_guest_cannot_create_group_buy(): void\n    {\n        $response = $this->post('/group-buys', [\n            'title' => '大湖草莓團購',\n        ]);\n\n        $response->assertRedirect('/login');\n    }\n}\n```\n\n**Pest（現代方式）：**\n\n```php\n<?php\n\nuse Illuminate\\Foundation\\Testing\\RefreshDatabase;\n\nuses(RefreshDatabase::class);\n\nit('returns successful response for homepage', function () {\n    $this->get('/')\n        ->assertStatus(200);\n});\n\nit('prevents guest from creating group buy', function () {\n    $this->post('/group-buys', [\n        'title' => '大湖草莓團購',\n    ])->assertRedirect('/login');\n});\n```\n\n差異一目了然：\n\n- **不需要 class** —— 每個測試檔案就是一堆函數，不用寫 `extends TestCase`\n- **不需要 method 命名規範** —— 用 `it()` 或 `test()` 描述行為，讀起來像英文句子\n- **不需要 use trait** —— `uses(RefreshDatabase::class)` 一行搞定\n- **鏈式斷言** —— 所有東西串在一起，更流暢\n\n### `it()` 和 `test()` 的差別\n\n```php\n// 這兩個完全等價：\nit('creates a group buy successfully', function () { /* ... */ });\ntest('creates a group buy successfully', function () { /* ... */ });\n\n// it() 在終端顯示為 \"it creates a group buy successfully\"\n// test() 在終端顯示為 \"creates a group buy successfully\"\n```\n\n慣例上，`it()` 用來描述「這個東西應該做什麼」，`test()` 用來描述「做這件事的結果」。選一種風格並保持一致就好。本章統一用 `it()`。\n\n### `expect()` API——流暢的斷言\n\nPest 的 `expect()` API 借鏡了 Jest，讀起來非常自然：\n\n```php\n// 基本型別\nexpect($user->name)->toBe('Bobo');\nexpect($user->age)->toBeGreaterThan(18);\nexpect($user->email)->toContain('@');\nexpect($user->bio)->toBeNull();\nexpect($user->is_active)->toBeTrue();\n\n// 陣列\nexpect($tags)->toHaveCount(3);\nexpect($response->json())->toHaveKey('data');\nexpect($ids)->toContain(1, 2, 3);\n\n// 例外\nexpect(fn () => $service->join($closedGroupBuy))\n    ->toThrow(GroupBuyClosedException::class);\n\n// 鏈式\nexpect($user)\n    ->name->toBe('Bobo')\n    ->email->toEndWith('@example.com')\n    ->role->toBe(UserRole::Organizer);\n```\n\n### 跑測試\n\n```bash\n# 用 Artisan（推薦，有漂亮的輸出）\nphp artisan test\n\n# 直接跑 Pest\n./vendor/bin/pest\n\n# 跑特定檔案\nphp artisan test --filter=GroupBuyTest\n\n# 跑特定測試\nphp artisan test --filter=\"creates a group buy\"\n\n# 平行執行（更快）\nphp artisan test --parallel\n\n# 只跑上次失敗的\nphp artisan test --retry\n```\n\n`php artisan test` 的輸出非常漂亮——綠色的勾勾代表通過，紅色的叉叉代表失敗，還會告訴你每個測試花了多少毫秒。\n\n## Feature Test vs Unit Test：差在哪裡\n\nLaravel 的測試資料夾結構很清楚：\n\n```\ntests/\n├── Feature/          # 功能測試（模擬完整 HTTP 請求）\n│   ├── GroupBuyTest.php\n│   └── Auth/\n│       └── LoginTest.php\n├── Unit/             # 單元測試（測試單一 class/method）\n│   └── Models/\n│       └── GroupBuyTest.php\n├── Pest.php          # Pest 全域設定\n└── TestCase.php      # 基底 TestCase\n```\n\n### Feature Test（功能測試）\n\n功能測試模擬完整的 HTTP 請求生命週期——從路由解析、middleware 檢查、Controller 執行、到 Response 返回。它測試的是「使用者做了某個操作，系統會有什麼反應」：\n\n```php\n// tests/Feature/GroupBuyTest.php\nit('allows organizer to create a group buy', function () {\n    $user = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($user)\n        ->post('/group-buys', [\n            'title' => '大湖草莓團購',\n            'description' => '又到了草莓季',\n            'min_participants' => 10,\n            'max_participants' => 50,\n            'deadline' => now()->addWeek(),\n        ])\n        ->assertRedirect('/group-buys');\n\n    $this->assertDatabaseHas('group_buys', [\n        'title' => '大湖草莓團購',\n        'user_id' => $user->id,\n    ]);\n});\n```\n\n這一個測試涵蓋了：路由是否正確、`auth` middleware 是否通過、Controller 是否正確處理資料、資料是否寫入資料庫、回應是否重導到正確頁面。\n\n### Unit Test（單元測試）\n\n單元測試只測試一個 class 或 method 的行為，不經過 HTTP 層，不碰資料庫（除非必要）：\n\n```php\n// tests/Unit/Models/GroupBuyTest.php\nit('calculates if group buy has reached minimum participants', function () {\n    $groupBuy = new GroupBuy([\n        'min_participants' => 10,\n    ]);\n\n    // 用 mock 或直接設定 relationship count\n    expect($groupBuy->hasReachedMinimum(8))->toBeFalse();\n    expect($groupBuy->hasReachedMinimum(10))->toBeTrue();\n    expect($groupBuy->hasReachedMinimum(15))->toBeTrue();\n});\n\nit('determines if group buy is still open', function () {\n    $open = new GroupBuy(['deadline' => now()->addDay()]);\n    $closed = new GroupBuy(['deadline' => now()->subDay()]);\n\n    expect($open->isOpen())->toBeTrue();\n    expect($closed->isOpen())->toBeFalse();\n});\n```\n\n### 什麼時候用哪個？\n\n| 情境                  | 選擇    | 原因                   |\n| --------------------- | ------- | ---------------------- |\n| 測試 API endpoint     | Feature | 需要完整 HTTP 生命週期 |\n| 測試使用者流程        | Feature | 涉及多個元件協作       |\n| 測試 Model 的計算邏輯 | Unit    | 純函數，不需要 HTTP    |\n| 測試 Service class    | Unit    | 單一職責，注入依賴     |\n| 測試授權 Policy       | Feature | 需要 auth 上下文       |\n| 測試 Validation 規則  | Feature | 需要 Request 處理      |\n\n> **經驗法則：** 對於 Web 應用程式，**Feature Test 的投資報酬率最高**。一個 Feature Test 就能涵蓋路由、middleware、Controller、Model、View 的整合。先把核心流程的 Feature Test 寫好，再慢慢補 Unit Test 給複雜的商業邏輯。\n\n### Pest.php：全域設定\n\n`tests/Pest.php` 是 Pest 的全域設定檔，你可以在這裡為不同資料夾的測試統一套用 trait：\n\n```php\n// tests/Pest.php\n\nuses(Tests\\TestCase::class, Illuminate\\Foundation\\Testing\\RefreshDatabase::class)\n    ->in('Feature');\n\nuses(Tests\\TestCase::class)\n    ->in('Unit');\n```\n\n這樣你就不需要在每個 Feature 測試檔案裡都寫 `uses(RefreshDatabase::class)` 了——全域一次搞定。\n\n## HTTP Tests：模擬使用者操作\n\nLaravel 的 HTTP 測試是你最常用的武器。它讓你模擬瀏覽器的行為——送出 GET、POST、PUT、DELETE 請求，然後檢查回應。\n\n### 基本 HTTP 方法\n\n```php\n// GET 請求——瀏覽頁面\n$this->get('/group-buys')\n    ->assertStatus(200)\n    ->assertSee('大湖草莓團購');\n\n// POST 請求——建立資源\n$this->post('/group-buys', [\n    'title' => '大湖草莓團購',\n    'min_participants' => 10,\n])->assertRedirect('/group-buys');\n\n// PUT 請求——更新資源\n$this->put(\"/group-buys/{$groupBuy->id}\", [\n    'title' => '大湖有機草莓團購',\n])->assertRedirect(\"/group-buys/{$groupBuy->id}\");\n\n// DELETE 請求——刪除資源\n$this->delete(\"/group-buys/{$groupBuy->id}\")\n    ->assertRedirect('/group-buys');\n```\n\n### 常用斷言方法\n\n```php\n// 狀態碼\n->assertStatus(200)\n->assertOk()               // 等同 assertStatus(200)\n->assertNotFound()          // 等同 assertStatus(404)\n->assertForbidden()         // 等同 assertStatus(403)\n->assertUnauthorized()      // 等同 assertStatus(401)\n\n// 重導向\n->assertRedirect('/login')\n->assertRedirectToRoute('group-buys.index')\n\n// 頁面內容\n->assertSee('大湖草莓團購')           // 頁面包含這段文字\n->assertDontSee('已截止')             // 頁面不包含這段文字\n->assertSeeText('10 人成團')          // 只看純文字（忽略 HTML）\n\n// Session\n->assertSessionHas('success', '開團成功！')\n->assertSessionHasErrors(['title'])   // 驗證失敗時的錯誤欄位\n->assertSessionHasNoErrors()\n\n// View\n->assertViewIs('group-buys.show')\n->assertViewHas('groupBuy')\n```\n\n### 模擬登入使用者\n\n```php\nuse App\\Models\\User;\n\nit('shows create form to organizers', function () {\n    $organizer = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($organizer)\n        ->get('/group-buys/create')\n        ->assertOk();\n});\n\nit('denies create form to regular members', function () {\n    $member = User::factory()->create(['role' => 'member']);\n\n    $this->actingAs($member)\n        ->get('/group-buys/create')\n        ->assertForbidden();\n});\n\nit('redirects guest to login', function () {\n    $this->get('/group-buys/create')\n        ->assertRedirect('/login');\n});\n```\n\n`actingAs()` 幫你模擬「以某個使用者身份登入」，不需要真的跑登入流程。\n\n### 測試 JSON API 回應\n\n```php\nit('returns group buys as JSON', function () {\n    GroupBuy::factory()->count(3)->create();\n\n    $this->getJson('/api/group-buys')\n        ->assertOk()\n        ->assertJsonCount(3, 'data')\n        ->assertJsonStructure([\n            'data' => [\n                '*' => ['id', 'title', 'description', 'min_participants', 'deadline'],\n            ],\n        ]);\n});\n\nit('returns specific group buy details', function () {\n    $groupBuy = GroupBuy::factory()->create([\n        'title' => '大湖草莓團購',\n    ]);\n\n    $this->getJson(\"/api/group-buys/{$groupBuy->id}\")\n        ->assertOk()\n        ->assertJson([\n            'data' => [\n                'title' => '大湖草莓團購',\n            ],\n        ]);\n});\n```\n\n注意這裡用的是 `getJson()` 而不是 `get()`——它會自動帶上 `Accept: application/json` header，讓 Laravel 回傳 JSON 而不是 HTML。對應的還有 `postJson()`、`putJson()`、`deleteJson()`。\n\n## Database Testing：RefreshDatabase 的魔力\n\n測試最怕的是「測試之間互相影響」。你在測試 A 建了一個使用者，結果測試 B 因為資料庫裡多了這筆資料而失敗——這種問題 debug 起來讓人抓狂。\n\n### RefreshDatabase trait\n\n`RefreshDatabase` 解決這個問題的方式很聰明：它在每個測試之前用資料庫 transaction 包起來，測試結束後 rollback。效果等同於每個測試都從空白資料庫開始，但速度比真的 `migrate:fresh` 快得多。\n\n如果你按前面建議設定了 `tests/Pest.php`，所有 Feature 測試都自動有這個行為：\n\n```php\n// tests/Pest.php\nuses(Tests\\TestCase::class, RefreshDatabase::class)->in('Feature');\n```\n\n### 資料庫斷言\n\n```php\nuse App\\Models\\GroupBuy;\nuse App\\Models\\User;\n\nit('stores group buy in database', function () {\n    $user = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($user)->post('/group-buys', [\n        'title' => '大湖草莓團購',\n        'description' => '又到了草莓季',\n        'min_participants' => 10,\n        'max_participants' => 50,\n        'deadline' => '2026-12-31',\n    ]);\n\n    // 確認資料庫裡有這筆資料\n    $this->assertDatabaseHas('group_buys', [\n        'title' => '大湖草莓團購',\n        'user_id' => $user->id,\n    ]);\n\n    // 確認資料庫裡有正確的數量\n    $this->assertDatabaseCount('group_buys', 1);\n});\n\nit('removes group buy from database on delete', function () {\n    $user = User::factory()->create(['role' => 'organizer']);\n    $groupBuy = GroupBuy::factory()->for($user)->create();\n\n    $this->actingAs($user)->delete(\"/group-buys/{$groupBuy->id}\");\n\n    // 確認資料庫裡沒有這筆資料了\n    $this->assertDatabaseMissing('group_buys', [\n        'id' => $groupBuy->id,\n    ]);\n});\n```\n\n### 使用 SQLite in-memory 加速測試\n\n在 `phpunit.xml` 裡（Laravel 12 預設已經設好了），測試環境使用 SQLite in-memory 資料庫，跑起來飛快：\n\n```xml\n<php>\n    <env name=\"APP_ENV\" value=\"testing\"/>\n    <env name=\"DB_CONNECTION\" value=\"sqlite\"/>\n    <env name=\"DB_DATABASE\" value=\":memory:\"/>\n</php>\n```\n\n這代表測試不會碰到你的開發資料庫——完全隔離。\n\n## Mock 與 Fake：Mail::fake()、Queue::fake()\n\n測試不應該真的寄信、真的打第三方 API、真的觸發排程任務。Laravel 的 Fake 機制讓你攔截這些副作用，只檢查「系統是否正確地觸發了這些操作」。\n\n### Mail::fake()\n\n```php\nuse Illuminate\\Support\\Facades\\Mail;\nuse App\\Mail\\GroupBuyConfirmed;\n\nit('sends confirmation email when group buy reaches minimum', function () {\n    Mail::fake();\n\n    $groupBuy = GroupBuy::factory()->create(['min_participants' => 2]);\n    $participants = User::factory()->count(2)->create();\n\n    // 模擬兩個人加入，觸發成團\n    foreach ($participants as $participant) {\n        $groupBuy->participants()->attach($participant);\n    }\n\n    $groupBuy->checkAndConfirm();\n\n    // 斷言：確認信被寄出了\n    Mail::assertSent(GroupBuyConfirmed::class, function ($mail) use ($groupBuy) {\n        return $mail->groupBuy->id === $groupBuy->id;\n    });\n\n    // 斷言：寄了正確的數量\n    Mail::assertSent(GroupBuyConfirmed::class, 2);\n});\n\nit('does not send email when minimum not reached', function () {\n    Mail::fake();\n\n    $groupBuy = GroupBuy::factory()->create(['min_participants' => 10]);\n    $groupBuy->participants()->attach(User::factory()->create());\n\n    $groupBuy->checkAndConfirm();\n\n    Mail::assertNotSent(GroupBuyConfirmed::class);\n});\n```\n\n`Mail::fake()` 攔截所有郵件，不會真的送出。你只需要驗證「正確的 Mailable 是否被送出、送給了誰」。\n\n### Queue::fake()\n\n```php\nuse Illuminate\\Support\\Facades\\Queue;\nuse App\\Jobs\\ProcessGroupBuyPayment;\n\nit('dispatches payment job when group buy is confirmed', function () {\n    Queue::fake();\n\n    $groupBuy = GroupBuy::factory()->confirmed()->create();\n\n    $groupBuy->processPayments();\n\n    Queue::assertPushed(ProcessGroupBuyPayment::class, function ($job) use ($groupBuy) {\n        return $job->groupBuyId === $groupBuy->id;\n    });\n});\n```\n\n### Notification::fake()\n\n```php\nuse Illuminate\\Support\\Facades\\Notification;\nuse App\\Notifications\\GroupBuyDeadlineReminder;\n\nit('sends deadline reminder to all participants', function () {\n    Notification::fake();\n\n    $groupBuy = GroupBuy::factory()->create([\n        'deadline' => now()->addDay(),\n    ]);\n    $participants = User::factory()->count(5)->create();\n    $groupBuy->participants()->attach($participants);\n\n    $groupBuy->sendDeadlineReminders();\n\n    Notification::assertSentTo($participants, GroupBuyDeadlineReminder::class);\n});\n```\n\n### Event::fake()\n\n```php\nuse Illuminate\\Support\\Facades\\Event;\nuse App\\Events\\GroupBuyCreated;\n\nit('fires event when group buy is created', function () {\n    Event::fake([GroupBuyCreated::class]);\n\n    $user = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($user)->post('/group-buys', [\n        'title' => '大湖草莓團購',\n        'min_participants' => 10,\n        'max_participants' => 50,\n        'deadline' => now()->addWeek(),\n    ]);\n\n    Event::assertDispatched(GroupBuyCreated::class);\n});\n```\n\n### Storage::fake()\n\n```php\nuse Illuminate\\Support\\Facades\\Storage;\nuse Illuminate\\Http\\UploadedFile;\n\nit('uploads product image when creating group buy', function () {\n    Storage::fake('public');\n\n    $user = User::factory()->create(['role' => 'organizer']);\n    $image = UploadedFile::fake()->image('strawberry.jpg', 800, 600);\n\n    $this->actingAs($user)->post('/group-buys', [\n        'title' => '大湖草莓團購',\n        'image' => $image,\n        'min_participants' => 10,\n        'max_participants' => 50,\n        'deadline' => now()->addWeek(),\n    ]);\n\n    // 斷言：檔案確實被存到 public disk\n    Storage::disk('public')->assertExists('group-buys/' . $image->hashName());\n});\n```\n\n`Storage::fake('public')` 建立一個 in-memory 檔案系統，不會真的寫檔案到磁碟。測試結束後自動清空。\n\n## 測試資料準備：Factory 的進階用法\n\n[第五章](/blog/laravel-guide-eloquent-orm-models/)已經介紹過 Factory 的基本用法。在測試情境裡，Factory 的進階功能會讓你的測試更簡潔、更有表達力。\n\n### Factory States：用名稱描述狀態\n\n```php\n// database/factories/GroupBuyFactory.php\n\nclass GroupBuyFactory extends Factory\n{\n    public function definition(): array\n    {\n        return [\n            'user_id' => User::factory(),\n            'title' => fake()->sentence(3),\n            'description' => fake()->paragraph(),\n            'min_participants' => fake()->numberBetween(5, 20),\n            'max_participants' => fake()->numberBetween(20, 100),\n            'deadline' => fake()->dateTimeBetween('+1 week', '+1 month'),\n            'status' => 'open',\n        ];\n    }\n\n    // State：已確認成團\n    public function confirmed(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'status' => 'confirmed',\n            'confirmed_at' => now(),\n        ]);\n    }\n\n    // State：已截止\n    public function expired(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'deadline' => now()->subDay(),\n            'status' => 'expired',\n        ]);\n    }\n\n    // State：已取消\n    public function cancelled(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'status' => 'cancelled',\n            'cancelled_at' => now(),\n        ]);\n    }\n\n    // State：已滿團\n    public function full(): static\n    {\n        return $this->state(fn (array $attributes) => [\n            'max_participants' => 2,\n        ])->afterCreating(function (GroupBuy $groupBuy) {\n            $groupBuy->participants()->attach(\n                User::factory()->count(2)->create()\n            );\n        });\n    }\n}\n```\n\n使用起來非常直覺：\n\n```php\n$openGroupBuy = GroupBuy::factory()->create();\n$confirmedGroupBuy = GroupBuy::factory()->confirmed()->create();\n$expiredGroupBuy = GroupBuy::factory()->expired()->create();\n$fullGroupBuy = GroupBuy::factory()->full()->create();\n```\n\n### Factory Relationships：`has()` 和 `for()`\n\n```php\n// 建立一個開團主，有 3 個團購\n$organizer = User::factory()\n    ->has(GroupBuy::factory()->count(3))\n    ->create(['role' => 'organizer']);\n\n// 建立一個團購，屬於特定使用者\n$groupBuy = GroupBuy::factory()\n    ->for($organizer)\n    ->create();\n\n// 建立一個團購，帶有 5 個參與者\n$groupBuy = GroupBuy::factory()\n    ->hasParticipants(5)     // 等同 has(User::factory()->count(5), 'participants')\n    ->create();\n```\n\n### Factory Sequences：輪替值\n\n```php\n// 交替建立不同狀態的團購\n$groupBuys = GroupBuy::factory()\n    ->count(6)\n    ->sequence(\n        ['status' => 'open'],\n        ['status' => 'confirmed'],\n        ['status' => 'expired'],\n    )\n    ->create();\n// 結果：open, confirmed, expired, open, confirmed, expired\n```\n\n### `recycle()`：在多個 Factory 之間共用 Model\n\n```php\n// 同一個使用者同時是開團主和其他團的參與者\n$user = User::factory()->create();\n\n$groupBuys = GroupBuy::factory()\n    ->count(3)\n    ->recycle($user)   // 所有 user_id 都用這個使用者\n    ->create();\n```\n\n`recycle()` 避免了 Factory 每次都建一個新的關聯 Model——當你需要多個 Factory 共用同一筆資料時特別有用。\n\n## GitHub Actions CI：每次 Push 自動跑測試\n\n測試寫好了，但如果要「記得手動跑」才有用，那遲早會有人忘記。CI（Continuous Integration）的目的就是：每次有人 push 程式碼，自動跑測試。測試過了才能合併。\n\n### 完整的 GitHub Actions 設定\n\n在專案根目錄建立 `.github/workflows/tests.yml`：\n\n```yaml\nname: Tests\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  tests:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Setup PHP\n        uses: shivammathur/setup-php@v2\n        with:\n          php-version: '8.4'\n          extensions: mbstring, xml, ctype, json, bcmath, sqlite3\n          coverage: none\n\n      - name: Cache Composer dependencies\n        uses: actions/cache@v4\n        with:\n          path: vendor\n          key: composer-${{ hashFiles('composer.lock') }}\n          restore-keys: composer-\n\n      - name: Install dependencies\n        run: composer install --no-interaction --prefer-dist --optimize-autoloader\n\n      - name: Copy environment file\n        run: cp .env.example .env\n\n      - name: Generate application key\n        run: php artisan key:generate\n\n      - name: Run tests\n        env:\n          DB_CONNECTION: sqlite\n          DB_DATABASE: ':memory:'\n        run: php artisan test --parallel\n```\n\n逐步拆解：\n\n1. **觸發條件** —— push 到 `main` 或開 PR 到 `main` 時觸發\n2. **PHP 設定** —— 安裝 PHP 8.4 和必要的擴充套件\n3. **Composer Cache** —— 快取 `vendor/` 目錄，避免每次都重裝套件（省 30-60 秒）\n4. **SQLite in-memory** —— 不需要真的 MySQL server，用 SQLite 跑測試更快\n5. **平行執行** —— `--parallel` 讓測試跑更快\n\n### 把 Badge 加到 README\n\n```markdown\n![Tests](https://github.com/your-username/jiu-hao-mai/actions/workflows/tests.yml/badge.svg)\n```\n\n這個 badge 會顯示在 README 最上方，讓所有人一眼就知道測試有沒有通過。綠色的 \"passing\" 就是信任的象徵。\n\n### 當 CI 失敗了\n\nCI 紅了不可怕，可怕的是忽視它。當 CI 失敗時：\n\n1. **點進 GitHub Actions 看 log** —— 找到紅色的步驟，看錯誤訊息\n2. **在本機重現** —— `php artisan test --filter=\"失敗的測試名稱\"`\n3. **修好它** —— 不要跳過失敗的測試（`->skip()`），除非有非常正當的理由\n4. **Push 修正** —— CI 會自動重新跑\n\n> **絕對不要做的事：** 刪掉失敗的測試、在 CI 裡加 `continue-on-error: true`、或用 `@skip` 跳過。這些做法只是在掩耳盜鈴。\n\n## 實作：為揪好買核心流程寫測試\n\n理論講完了，現在動手。我們要為揪好買寫一組完整的 Feature Test，覆蓋最重要的使用者流程。\n\n建立測試檔案：\n\n```bash\nphp artisan make:test GroupBuyTest\n# 生成 tests/Feature/GroupBuyTest.php\n```\n\n```php\n<?php\n\n// tests/Feature/GroupBuyTest.php\n\nuse App\\Models\\User;\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Support\\Facades\\Mail;\nuse Illuminate\\Support\\Facades\\Notification;\nuse App\\Mail\\GroupBuyConfirmed;\nuse App\\Notifications\\GroupBuyJoined;\n\n// ── 開團建立 ──────────────────────────────────────\n\nit('allows authenticated organizer to create a group buy', function () {\n    $organizer = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($organizer)\n        ->post('/group-buys', [\n            'title' => '大湖草莓團購',\n            'description' => '苗栗大湖有機草莓，產地直送',\n            'min_participants' => 10,\n            'max_participants' => 50,\n            'deadline' => now()->addWeek()->toDateString(),\n        ])\n        ->assertRedirect('/group-buys');\n\n    $this->assertDatabaseHas('group_buys', [\n        'title' => '大湖草莓團購',\n        'user_id' => $organizer->id,\n        'status' => 'open',\n    ]);\n});\n\nit('rejects group buy creation with missing required fields', function () {\n    $organizer = User::factory()->create(['role' => 'organizer']);\n\n    $this->actingAs($organizer)\n        ->post('/group-buys', [\n            // title 沒填\n            'min_participants' => 10,\n        ])\n        ->assertSessionHasErrors(['title', 'max_participants', 'deadline']);\n});\n\nit('prevents guest from creating a group buy', function () {\n    $this->post('/group-buys', [\n        'title' => '大湖草莓團購',\n        'min_participants' => 10,\n        'max_participants' => 50,\n        'deadline' => now()->addWeek()->toDateString(),\n    ])->assertRedirect('/login');\n});\n\nit('prevents regular member from creating a group buy', function () {\n    $member = User::factory()->create(['role' => 'member']);\n\n    $this->actingAs($member)\n        ->post('/group-buys', [\n            'title' => '大湖草莓團購',\n            'min_participants' => 10,\n            'max_participants' => 50,\n            'deadline' => now()->addWeek()->toDateString(),\n        ])\n        ->assertForbidden();\n});\n\n// ── 跟團加入 ──────────────────────────────────────\n\nit('allows authenticated user to join an open group buy', function () {\n    $user = User::factory()->create();\n    $groupBuy = GroupBuy::factory()->create(['status' => 'open']);\n\n    $this->actingAs($user)\n        ->post(\"/group-buys/{$groupBuy->id}/join\")\n        ->assertRedirect(\"/group-buys/{$groupBuy->id}\");\n\n    $this->assertDatabaseHas('group_buy_user', [\n        'user_id' => $user->id,\n        'group_buy_id' => $groupBuy->id,\n    ]);\n});\n\nit('prevents joining a group buy that is already full', function () {\n    $user = User::factory()->create();\n    $groupBuy = GroupBuy::factory()->full()->create();\n\n    $this->actingAs($user)\n        ->post(\"/group-buys/{$groupBuy->id}/join\")\n        ->assertStatus(422)\n        ->assertSessionHasErrors(['capacity']);\n});\n\nit('prevents duplicate join to the same group buy', function () {\n    $user = User::factory()->create();\n    $groupBuy = GroupBuy::factory()->create(['status' => 'open']);\n    $groupBuy->participants()->attach($user);\n\n    $this->actingAs($user)\n        ->post(\"/group-buys/{$groupBuy->id}/join\")\n        ->assertStatus(422)\n        ->assertSessionHasErrors(['duplicate']);\n});\n\n// ── 成團確認 ──────────────────────────────────────\n\nit('confirms group buy when minimum participants reached', function () {\n    Mail::fake();\n\n    $groupBuy = GroupBuy::factory()->create([\n        'min_participants' => 3,\n        'status' => 'open',\n    ]);\n\n    $participants = User::factory()->count(3)->create();\n    foreach ($participants as $participant) {\n        $groupBuy->participants()->attach($participant);\n    }\n\n    $groupBuy->checkAndConfirm();\n\n    expect($groupBuy->fresh()->status)->toBe('confirmed');\n\n    Mail::assertSent(GroupBuyConfirmed::class, 3);\n});\n\nit('does not confirm group buy below minimum participants', function () {\n    $groupBuy = GroupBuy::factory()->create([\n        'min_participants' => 10,\n        'status' => 'open',\n    ]);\n\n    $groupBuy->participants()->attach(User::factory()->create());\n\n    $groupBuy->checkAndConfirm();\n\n    expect($groupBuy->fresh()->status)->toBe('open');\n});\n\n// ── API Endpoints ─────────────────────────────────\n\nit('returns paginated group buys as JSON', function () {\n    GroupBuy::factory()->count(15)->create(['status' => 'open']);\n\n    $this->getJson('/api/group-buys')\n        ->assertOk()\n        ->assertJsonStructure([\n            'data' => [\n                '*' => [\n                    'id',\n                    'title',\n                    'description',\n                    'min_participants',\n                    'max_participants',\n                    'deadline',\n                    'status',\n                    'participants_count',\n                ],\n            ],\n            'meta' => ['current_page', 'last_page', 'total'],\n        ]);\n});\n\nit('requires authentication for API group buy creation', function () {\n    $this->postJson('/api/group-buys', [\n        'title' => '大湖草莓團購',\n    ])->assertUnauthorized();\n});\n```\n\n讓我們回顧這 11 個測試案例涵蓋了什麼：\n\n| #   | 測試案例           | 驗證重點               |\n| --- | ------------------ | ---------------------- |\n| 1   | 開團主成功開團     | 認證 + 授權 + 資料寫入 |\n| 2   | 缺少必填欄位       | 表單驗證               |\n| 3   | 未登入者嘗試開團   | 認證 middleware        |\n| 4   | 一般會員嘗試開團   | 授權 Policy            |\n| 5   | 加入開放中的團購   | 正常流程 + pivot table |\n| 6   | 加入已滿的團購     | 容量限制               |\n| 7   | 重複加入同一團購   | 業務規則               |\n| 8   | 成團確認 + 寄信    | 商業邏輯 + Mail::fake  |\n| 9   | 未達最低人數不成團 | 邊界條件               |\n| 10  | API 回應結構       | JSON 格式 + 分頁       |\n| 11  | API 認證           | Token 認證             |\n\n跑測試：\n\n```bash\nphp artisan test tests/Feature/GroupBuyTest.php\n\n#  PASS  Tests\\Feature\\GroupBuyTest\n#  ✓ it allows authenticated organizer to create a group buy      0.15s\n#  ✓ it rejects group buy creation with missing required fields   0.08s\n#  ✓ it prevents guest from creating a group buy                  0.05s\n#  ✓ it prevents regular member from creating a group buy         0.06s\n#  ✓ it allows authenticated user to join an open group buy       0.09s\n#  ✓ it prevents joining a group buy that is already full         0.07s\n#  ✓ it prevents duplicate join to the same group buy             0.06s\n#  ✓ it confirms group buy when minimum participants reached      0.11s\n#  ✓ it does not confirm group buy below minimum participants     0.07s\n#  ✓ it returns paginated group buys as JSON                      0.12s\n#  ✓ it requires authentication for API group buy creation        0.04s\n#\n#  Tests:    11 passed (23 assertions)\n#  Duration: 0.90s\n```\n\n全綠。11 個測試、23 個斷言、不到 1 秒。這就是你的安全網。\n\n## 小結：有測試的程式碼，才是專業的程式碼\n\n這一章我們建立了完整的測試體系：\n\n**測試框架：**\n\n- Pest 是 Laravel 12 官方一級支援、可在建立專案時直接選用的框架——語法簡潔、讀起來像英文、`expect()` API 直覺好用\n- `it()` 描述行為、`expect()` 驗證結果、`uses()` 套用 trait\n- `php artisan test` 一行指令跑所有測試\n\n**測試類型：**\n\n- Feature Test 模擬完整 HTTP 請求，投資報酬率最高\n- Unit Test 測試單一 class/method 的純邏輯\n- 先寫 Feature Test 覆蓋核心流程，再補 Unit Test 給複雜邏輯\n\n**測試工具：**\n\n- `actingAs()` 模擬登入使用者\n- `RefreshDatabase` 確保每個測試從乾淨資料庫開始\n- `Mail::fake()`、`Queue::fake()`、`Notification::fake()` 攔截副作用\n- `Storage::fake()` 建立 in-memory 檔案系統\n- Factory States 讓測試資料準備更有表達力\n\n**CI Pipeline：**\n\n- GitHub Actions 讓每次 push 自動跑測試\n- SQLite in-memory + Composer cache 讓 CI 跑得快\n- 測試紅了就修，不要跳過、不要忽視\n\n**揪好買進度：**\n\n- ✅ 11 個 Feature Test 涵蓋開團、跟團、成團、API\n- ✅ Factory States 定義 `confirmed()`、`expired()`、`full()` 等狀態\n- ✅ GitHub Actions CI pipeline 設定完成\n- ✅ 核心流程全部有測試保護\n\n有了測試保護，你就可以放心做任何改動。[下一章](/blog/laravel-guide-deployment-forge-docker/)我們要把揪好買部署上線——從 Production 環境設定、Config Cache、到 Laravel Forge 一鍵部署和 Docker 容器化。從此以後，你的程式碼不只在本機能跑，在全世界都能跑。",
      "summary": "用 Pest 測試框架為 Laravel 12 應用寫單元測試與功能測試：actingAs 模擬登入、RefreshDatabase 隔離資料、Mail::fake() 與 Queue::fake() 攔截副作用，再搭配 GitHub Actions 建立 CI pipeline，讓每次 Push 都自動跑測試，從此敢重構、放心部署。",
      "image": "https://bobochen.dev/_astro/cover.0NHauOEU.webp",
      "date_published": "2025-05-27T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Pest",
        "Testing",
        "CI",
        "GitHub Actions"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-admin-filament-advanced-queries/",
      "url": "https://bobochen.dev/blog/laravel-guide-admin-filament-advanced-queries/",
      "title": "後台管理與進階查詢：用 Filament 打造管理介面",
      "content_text": "用 Filament 5 快速建立後台管理系統，搭配進階 Eloquent 查詢技巧解決真實世界的效能問題。",
      "content_html": "每一個有使用者的平台，遲早都需要一個管理後台。你可以從零開始刻——自己寫列表頁、編輯頁、篩選器、圖表——但說實話，那些重複的 CRUD 介面真的不值得你花好幾天去做。Filament 5 是 Laravel 生態系中最成熟的後台框架，完全開源免費，而且跟 Laravel 的整合度高到像是原生功能一樣。\n\n但後台管理不只是有畫面就好。當你的團購平台上有幾千筆開團紀錄、幾萬筆訂單，你會發現頁面開始變慢，資料庫查詢數量爆增。這時候你需要的不是加更多伺服器，而是搞懂 Eloquent 的進階查詢技巧——Eager Loading 解決 N+1 問題、Query Scopes 讓查詢條件可重用、Subqueries 處理複雜的報表需求。\n\n這一章我們分兩條線走：前半段用 Filament 5 幫揪好買建立開團主儀表板和站台管理員後台，後半段深入 Eloquent 進階查詢，搭配 Laravel Debugbar 實際觀察效能差異。你會發現，寫出「能跑」的程式碼和寫出「跑得快」的程式碼之間，差距往往就在這些細節裡。\n\n## 為什麼選 Filament：開源、免費、功能完整\n\nLaravel 生態系有好幾套後台管理方案，最常被拿出來比較的是 Filament、Nova、Backpack。先看一張比較表：\n\n| 特性                  | Filament 5          | Nova 5            | Backpack v7         |\n| --------------------- | ------------------- | ----------------- | ------------------- |\n| **授權**              | MIT（完全免費）     | 付費（Solo $99/site、Pro $199/site） | 免費核心 + 付費 PRO |\n| **技術棧**            | Livewire + Tailwind | Vue 3 + Inertia   | Blade + Bootstrap   |\n| **Panel Builder**     | 內建                | 內建              | 內建                |\n| **Form Builder**      | 內建                | 內建              | 內建                |\n| **Table Builder**     | 內建                | 內建              | 內建                |\n| **Dashboard Widgets** | 內建                | 內建              | 付費 PRO            |\n| **Multi-tenancy**     | 內建支援            | 需套件            | 需套件              |\n| **Plugin 生態**       | 活躍（200+）        | 豐富              | 中等                |\n| **學習曲線**          | 中等                | 低                | 低                  |\n| **自訂性**            | 極高                | 高                | 中                  |\n\nNova 是 Laravel 官方出品，品質穩定，但它不是開源的——Nova 5 授權分兩階：Solo（年營收 < $20k 個人開發者）$99/site、Pro $199/site，一次性永久授權含一年更新，而且你看不到原始碼，沒辦法深度客製。Backpack v7 以 Blade 元件（Data Components）為核心，技術棧採 Blade + Bootstrap，穩定度高且持續維護。\n\nFilament 5 的優勢很明確：\n\n- **完全開源、MIT 授權**——你可以用在商業專案、修改原始碼、不需要付費\n- **跟我們的技術棧一致**——Livewire + Tailwind CSS，跟揪好買的前端技術完全相同\n- **功能不打折**——Dashboard widgets、Chart、多租戶、通知系統⋯⋯全部免費\n- **社群活躍**——GitHub 上超過 30,000 stars，plugin 生態豐富\n\n如果你從 JavaScript 世界來，Filament 的角色類似於 React Admin 或 Refine——但它不需要你寫前端程式碼，全部用 PHP 搞定。\n\n### 但 Filament 不是無條件的最佳解\n\n上面那張表我給 Filament 打的分數偏高，所以這裡得誠實補一段，不然你會以為它沒有缺點。它的優勢和短板其實是同一件事的兩面：**Livewire 的 server-driven 架構**。\n\n- **每次互動都要回伺服器一趟。** 你點篩選器、改一個欄位、翻一頁，背後都是一次 AJAX round-trip 回 PHP 重算畫面。後台在公司內網、操作的人坐在你旁邊，這完全無感；但如果管理員在咖啡廳用 4G、或者你做的是欄位超多的大表單即時連動，那個延遲是會被抱怨的。Nova 之所以用 Vue + Inertia、有人寧可自刻 React/Vue SPA admin，理由就在這——純前端互動不用每動一下都等網路。\n- **「全部用 PHP 搞定」有個但書。** 內建 component 覆蓋得到的範圍，確實爽到不行；可是一旦你要做的東西超出內建（自訂互動元件、塞一段客製 JS 行為），你還是得回頭懂 Livewire 和 Alpine.js 的心智模型，不是純後端就能搞定。學習曲線那欄我標「中等」，代價就藏在這裡。\n- **Panel 大起來要顧效能。** 一個頁面塞太多 Livewire widget、table 又開一堆即時運算的欄位，元件數量上去之後 server 端負擔和回應時間都會浮現，這時候該做的是精簡 widget、把重查詢丟進快取或非同步，而不是無腦加機器。\n\n所以選型不是「Filament 贏者全拿」。後台要極致即時互動或得離線跑 → SPA admin 可能更合適；團隊已經買單 Nova 官方支援、不想碰 Livewire → Nova 很穩；需求簡單到連框架都嫌重 → 自刻幾頁 CRUD 反而最快。我這章選 Filament，是因為它對「Laravel 團隊、後台、開源免費」這個組合的命中率最高，不是因為它打趴所有對手。\n\n## Filament 5 安裝與 Resource 建立\n\n### 安裝 Filament\n\n```bash\ncomposer require filament/filament\n```\n\n安裝 Panel Builder 並設定管理面板：\n\n```bash\nphp artisan filament:install --panels\n```\n\n這條指令會做幾件事：\n\n1. 建立 `app/Providers/Filament/AdminPanelProvider.php`——管理面板的核心設定\n2. 發布相關的 assets 和 config\n3. 設定 `/admin` 路由\n\n### 建立管理員帳號\n\n```bash\nphp artisan make:filament-user\n```\n\n終端會問你姓名、Email、密碼。填完之後，打開瀏覽器連到 `http://localhost:8000/admin`，登入就能看到空白的管理面板。\n\n> **注意：** Filament 預設使用 `users` table 的帳號，但只有被標記為 Filament 使用者的人才能登入後台。你可以在 `AdminPanelProvider` 裡自訂登入邏輯。\n\n### 建立第一個 Resource\n\nResource 是 Filament 的核心概念——每個 Resource 對應一個 Eloquent Model，自動生成 CRUD 介面：\n\n```bash\nphp artisan make:filament-resource GroupBuy\n```\n\n這會在 `app/Filament/Resources/` 下建立一整組檔案：\n\n```text\napp/Filament/Resources/\n├── GroupBuyResource.php          # 主要設定檔\n└── GroupBuyResource/\n    └── Pages/\n        ├── CreateGroupBuy.php    # 建立頁面\n        ├── EditGroupBuy.php      # 編輯頁面\n        └── ListGroupBuys.php     # 列表頁面\n```\n\n一條 Artisan 指令，三個完整的 CRUD 頁面就出來了。接下來只需要在 `GroupBuyResource.php` 裡定義表單欄位和列表欄位。\n\n## CRUD Panel：自動化的管理介面\n\n`GroupBuyResource.php` 的核心結構由三個方法組成：`form()`、`table()`、`getRelations()`。\n\n### Form Schema：定義表單\n\n```php\n<?php\n\nnamespace App\\Filament\\Resources;\n\nuse App\\Filament\\Resources\\GroupBuyResource\\Pages;\nuse App\\Models\\GroupBuy;\nuse Filament\\Forms;\nuse Filament\\Forms\\Form;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Table;\n\nclass GroupBuyResource extends Resource\n{\n    protected static ?string $model = GroupBuy::class;\n\n    protected static ?string $navigationIcon = 'heroicon-o-shopping-bag';\n\n    protected static ?string $navigationLabel = '團購管理';\n\n    protected static ?string $modelLabel = '團購';\n\n    public static function form(Form $form): Form\n    {\n        return $form\n            ->schema([\n                Forms\\Components\\Section::make('基本資訊')\n                    ->schema([\n                        Forms\\Components\\TextInput::make('title')\n                            ->label('團購標題')\n                            ->required()\n                            ->maxLength(255),\n\n                        Forms\\Components\\Textarea::make('description')\n                            ->label('說明')\n                            ->rows(4)\n                            ->columnSpanFull(),\n\n                        Forms\\Components\\Select::make('organizer_id')\n                            ->label('開團主')\n                            ->relationship('organizer', 'name')\n                            ->searchable()\n                            ->preload()\n                            ->required(),\n\n                        Forms\\Components\\Select::make('status')\n                            ->label('狀態')\n                            ->options([\n                                'draft'     => '草稿',\n                                'open'      => '開團中',\n                                'closed'    => '已截止',\n                                'confirmed' => '已成團',\n                                'cancelled' => '已取消',\n                            ])\n                            ->default('draft')\n                            ->required(),\n                    ])->columns(2),\n\n                Forms\\Components\\Section::make('時間與門檻')\n                    ->schema([\n                        Forms\\Components\\DateTimePicker::make('starts_at')\n                            ->label('開始時間')\n                            ->required(),\n\n                        Forms\\Components\\DateTimePicker::make('ends_at')\n                            ->label('截止時間')\n                            ->required()\n                            ->after('starts_at'),\n\n                        Forms\\Components\\TextInput::make('min_participants')\n                            ->label('最低成團人數')\n                            ->numeric()\n                            ->minValue(1)\n                            ->required(),\n\n                        Forms\\Components\\TextInput::make('max_participants')\n                            ->label('人數上限')\n                            ->numeric()\n                            ->nullable(),\n                    ])->columns(2),\n\n                Forms\\Components\\Section::make('圖片')\n                    ->schema([\n                        Forms\\Components\\FileUpload::make('image')\n                            ->label('封面圖')\n                            ->image()\n                            ->directory('group-buys')\n                            ->maxSize(2048),\n                    ]),\n            ]);\n    }\n```\n\n幾個值得注意的設計：\n\n- **`Section`** 把表單分成邏輯區塊，管理員不用面對一整片欄位\n- **`Select::make()->relationship()`** 自動從關聯 Model 撈資料，還支援搜尋\n- **`DateTimePicker::make()->after('starts_at')`** 內建驗證，截止時間必須晚於開始時間\n- **`FileUpload`** 直接處理檔案上傳，不用自己寫 storage 邏輯\n\n### Table Schema：定義列表\n\n接續同一個 `GroupBuyResource` class：\n\n```php\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('title')\n                    ->label('標題')\n                    ->searchable()\n                    ->sortable()\n                    ->limit(30),\n\n                Tables\\Columns\\TextColumn::make('organizer.name')\n                    ->label('開團主')\n                    ->searchable()\n                    ->sortable(),\n\n                Tables\\Columns\\TextColumn::make('status')\n                    ->label('狀態')\n                    ->badge()\n                    ->color(fn (string $state): string => match ($state) {\n                        'draft'     => 'gray',\n                        'open'      => 'success',\n                        'closed'    => 'warning',\n                        'confirmed' => 'primary',\n                        'cancelled' => 'danger',\n                        default     => 'gray',\n                    })\n                    ->formatStateUsing(fn (string $state): string => match ($state) {\n                        'draft'     => '草稿',\n                        'open'      => '開團中',\n                        'closed'    => '已截止',\n                        'confirmed' => '已成團',\n                        'cancelled' => '已取消',\n                        default     => $state,\n                    }),\n\n                Tables\\Columns\\TextColumn::make('participants_count')\n                    ->label('參加人數')\n                    ->counts('participants')\n                    ->sortable(),\n\n                Tables\\Columns\\TextColumn::make('ends_at')\n                    ->label('截止時間')\n                    ->dateTime('Y-m-d H:i')\n                    ->sortable(),\n\n                Tables\\Columns\\TextColumn::make('created_at')\n                    ->label('建立時間')\n                    ->dateTime('Y-m-d')\n                    ->sortable()\n                    ->toggleable(isToggledHiddenByDefault: true),\n            ])\n            ->filters([\n                Tables\\Filters\\SelectFilter::make('status')\n                    ->label('狀態')\n                    ->options([\n                        'draft'     => '草稿',\n                        'open'      => '開團中',\n                        'closed'    => '已截止',\n                        'confirmed' => '已成團',\n                        'cancelled' => '已取消',\n                    ]),\n\n                Tables\\Filters\\Filter::make('active')\n                    ->label('進行中')\n                    ->query(fn ($query) => $query\n                        ->where('status', 'open')\n                        ->where('ends_at', '>', now())\n                    ),\n            ])\n            ->actions([\n                Tables\\Actions\\EditAction::make(),\n                Tables\\Actions\\ViewAction::make(),\n            ])\n            ->bulkActions([\n                Tables\\Actions\\BulkActionGroup::make([\n                    Tables\\Actions\\DeleteBulkAction::make(),\n                ]),\n            ])\n            ->defaultSort('created_at', 'desc');\n    }\n```\n\n這段程式碼產出的效果：一個有搜尋、排序、篩選、分頁的完整列表頁面。狀態欄位用 Badge 顯示彩色標籤，參加人數用 `counts()` 自動計算。如果用手寫的方式做這些功能，至少要花兩三天。\n\n### 關聯管理\n\n接續同一個 `GroupBuyResource` class：\n\n```php\n    public static function getRelations(): array\n    {\n        return [\n            RelationManagers\\OrdersRelationManager::class,\n            RelationManagers\\ParticipantsRelationManager::class,\n        ];\n    }\n```\n\nRelation Manager 讓你在團購的編輯頁面直接管理訂單和參加者——不用跳到其他頁面。建立方式：\n\n```bash\nphp artisan make:filament-relation-manager GroupBuyResource orders amount\n```\n\n## Dashboard Widgets：資料視覺化\n\n空白的 Dashboard 不太有用。Filament 提供了幾種內建 Widget，讓你快速建立數據儀表板。\n\n### StatsOverview Widget\n\n```bash\nphp artisan make:filament-widget GroupBuyStatsOverview --stats-overview\n```\n\n```php\n<?php\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\GroupBuy;\nuse App\\Models\\Order;\nuse Filament\\Widgets\\StatsOverviewWidget as BaseWidget;\nuse Filament\\Widgets\\StatsOverviewWidget\\Stat;\n\nclass GroupBuyStatsOverview extends BaseWidget\n{\n    protected function getStats(): array\n    {\n        return [\n            Stat::make('總團購數', GroupBuy::count())\n                ->description('所有團購')\n                ->descriptionIcon('heroicon-m-shopping-bag')\n                ->color('primary'),\n\n            Stat::make('進行中', GroupBuy::where('status', 'open')->count())\n                ->description('目前開放中的團購')\n                ->descriptionIcon('heroicon-m-arrow-trending-up')\n                ->color('success'),\n\n            Stat::make(\n                '本月營收',\n                'NT$ ' . number_format(\n                    Order::whereMonth('created_at', now()->month)\n                        ->whereYear('created_at', now()->year)\n                        ->sum('amount') / 100\n                )\n            )\n                ->description('本月訂單總額')\n                ->descriptionIcon('heroicon-m-currency-dollar')\n                ->color('warning'),\n        ];\n    }\n}\n```\n\n三張統計卡片就出現在 Dashboard 頂端——總團購數、進行中的團購、本月營收。不用寫任何前端 JavaScript。\n\n### Chart Widget\n\n```bash\nphp artisan make:filament-widget GroupBuyChart --chart\n```\n\n```php\n<?php\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\GroupBuy;\nuse Filament\\Widgets\\ChartWidget;\nuse Illuminate\\Support\\Carbon;\n\nclass GroupBuyChart extends ChartWidget\n{\n    protected static ?string $heading = '每週新增團購';\n\n    protected static ?int $sort = 2;\n\n    protected function getData(): array\n    {\n        $data = collect(range(7, 0))->map(function ($weeksAgo) {\n            $start = now()->subWeeks($weeksAgo)->startOfWeek();\n            $end = $start->copy()->endOfWeek();\n\n            return [\n                'week' => $start->format('m/d'),\n                'count' => GroupBuy::whereBetween('created_at', [$start, $end])->count(),\n            ];\n        });\n\n        return [\n            'datasets' => [\n                [\n                    'label' => '新增團購',\n                    'data' => $data->pluck('count')->toArray(),\n                    'borderColor' => '#6366f1',\n                    'backgroundColor' => 'rgba(99, 102, 241, 0.1)',\n                    'fill' => true,\n                ],\n            ],\n            'labels' => $data->pluck('week')->toArray(),\n        ];\n    }\n\n    protected function getType(): string\n    {\n        return 'line'; // 支援 'line', 'bar', 'pie', 'doughnut' 等\n    }\n}\n```\n\nWidget 預設會出現在 Dashboard 頁面。你也可以用 `$sort` 屬性控制顯示順序——數字越小越上面。\n\n### 自訂 Widget 範例\n\n除了統計和圖表，你也可以做完全自訂的 Widget，例如顯示「最近即將截止的團購」：\n\n```bash\nphp artisan make:filament-widget ExpiringGroupBuys\n```\n\n```php\n<?php\n\nnamespace App\\Filament\\Widgets;\n\nuse App\\Models\\GroupBuy;\nuse Filament\\Tables;\nuse Filament\\Tables\\Table;\nuse Filament\\Widgets\\TableWidget as BaseWidget;\n\nclass ExpiringGroupBuys extends BaseWidget\n{\n    protected static ?string $heading = '即將截止的團購';\n\n    protected static ?int $sort = 3;\n\n    protected int | string | array $columnSpan = 'full';\n\n    public function table(Table $table): Table\n    {\n        return $table\n            ->query(\n                GroupBuy::where('status', 'open')\n                    ->where('ends_at', '<=', now()->addDays(3))\n                    ->where('ends_at', '>', now())\n                    ->orderBy('ends_at')\n            )\n            ->columns([\n                Tables\\Columns\\TextColumn::make('title')\n                    ->label('標題'),\n                Tables\\Columns\\TextColumn::make('organizer.name')\n                    ->label('開團主'),\n                Tables\\Columns\\TextColumn::make('ends_at')\n                    ->label('截止時間')\n                    ->dateTime('Y-m-d H:i')\n                    ->color('danger'),\n                Tables\\Columns\\TextColumn::make('participants_count')\n                    ->label('目前人數')\n                    ->counts('participants'),\n            ])\n            ->paginated(false);\n    }\n}\n```\n\n這個 Table Widget 直接在 Dashboard 上顯示一個迷你列表，讓管理員一眼掌握哪些團購快截止了。\n\n## 進階 Eloquent：Eager Loading 解決 N+1\n\n後台蓋好了，現在來處理效能問題。Eloquent 最常見、也最容易踩到的效能地雷就是 **N+1 查詢問題**。（Eloquent 關聯的基礎用法見[第四章](/blog/laravel-guide-eloquent-orm-models/)）\n\n### 什麼是 N+1 問題\n\n假設你要顯示「所有團購 + 每個團購的開團主名稱」：\n\n```php\n// ❌ 這段程式碼有 N+1 問題\n$groupBuys = GroupBuy::all(); // 1 次查詢：SELECT * FROM group_buys\n\nforeach ($groupBuys as $groupBuy) {\n    echo $groupBuy->organizer->name;\n    // 每次迴圈都會觸發一次查詢：\n    // SELECT * FROM users WHERE id = ?\n}\n```\n\n如果有 100 筆團購，這段程式碼會執行 **101 次查詢**（1 次取所有團購 + 100 次取開團主）。這就是 N+1 問題——1 次取清單、N 次取關聯。\n\n你可能覺得 101 次查詢也還好？試試看 1000 筆團購——就是 1001 次查詢。再加上每個團購要顯示參加人數，那就是 2001 次。頁面直接從 200ms 變成 5 秒。\n\n### Eager Loading：一次把關聯資料撈好\n\n```php\n// ✅ 用 with() 做 Eager Loading\n$groupBuys = GroupBuy::with('organizer')->get();\n// 只有 2 次查詢：\n// SELECT * FROM group_buys\n// SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100)\n\nforeach ($groupBuys as $groupBuy) {\n    echo $groupBuy->organizer->name; // 不會再觸發查詢\n}\n```\n\n`with('organizer')` 會一次把所有需要的 `users` 撈回來，用 `WHERE IN` 而不是一筆一筆查。從 101 次查詢變成 2 次。\n\n你可以同時 Eager Load 多個關聯，甚至是巢狀關聯：\n\n```php\n// 多個關聯\n$groupBuys = GroupBuy::with(['organizer', 'participants', 'products'])->get();\n\n// 巢狀關聯：團購 → 訂單 → 訂單項目\n$groupBuys = GroupBuy::with('orders.items')->get();\n\n// 限制 Eager Loading 的欄位（節省記憶體）\n$groupBuys = GroupBuy::with('organizer:id,name,email')->get();\n```\n\n### withCount：只要數量不要全部資料\n\n有時你只需要知道「有幾個」，不需要把整個關聯載入：\n\n```php\n$groupBuys = GroupBuy::withCount('participants')->get();\n\nforeach ($groupBuys as $groupBuy) {\n    echo $groupBuy->participants_count; // 自動加上 _count 後綴\n}\n// 只有 1 次查詢（用 subquery 計算 count）\n```\n\n`withCount` 不會把所有參加者的資料載入記憶體——它只在 SQL 層面用子查詢算出數量。適合用在列表頁只需要顯示數字的場景。若需要一次處理大批量資料，記憶體管理有更多眉角，可參考大量資料的記憶體陷阱。\n\n### 前後對比\n\n| 情境                         | 沒有 Eager Loading | 有 Eager Loading      | 改善           |\n| ---------------------------- | ------------------ | --------------------- | -------------- |\n| 100 筆團購 + 開團主          | 101 次查詢         | 2 次查詢              | **98% 減少**   |\n| 100 筆團購 + 開團主 + 參加者 | 201 次查詢         | 3 次查詢              | **98.5% 減少** |\n| 100 筆團購 + 參加人數        | 101 次查詢         | 1 次查詢（withCount） | **99% 減少**   |\n\n### 開發模式下禁止 Lazy Loading\n\n為了在開發階段就發現 N+1 問題，可以在 `AppServiceProvider` 裡設定：\n\n```php\n// app/Providers/AppServiceProvider.php\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\npublic function boot(): void\n{\n    // 開發環境：禁止 Lazy Loading，直接拋出例外\n    Model::preventLazyLoading(! app()->isProduction());\n}\n```\n\n加了這行之後，任何沒有預先 Eager Load 的關聯存取都會直接拋出 `LazyLoadingViolationException`。等於是在開發階段強制你用 `with()` 載入所有需要的關聯。到了正式環境則自動關閉，避免意外中斷服務。\n\n## Query Scopes：可重用的查詢條件\n\n在揪好買裡，你會一直重複寫一些查詢條件：「只要開團中的」、「只要還沒截止的」、「只要某個開團主的」。與其每次都手寫 `where()` 鏈，不如把它們封裝成 Scope。\n\n### Local Scopes\n\n在 Model 裡定義 `scope` 開頭的方法：\n\n```php\n<?php\n\n// app/Models/GroupBuy.php\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Builder;\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass GroupBuy extends Model\n{\n    /**\n     * 篩選：開團中（status = open 且未截止）\n     */\n    public function scopeOpen(Builder $query): void\n    {\n        $query->where('status', 'open')\n              ->where('ends_at', '>', now());\n    }\n\n    /**\n     * 篩選：已截止（超過截止時間或被手動關閉）\n     */\n    public function scopeExpired(Builder $query): void\n    {\n        $query->where(function ($q) {\n            $q->where('ends_at', '<=', now())\n              ->orWhere('status', 'closed');\n        });\n    }\n\n    /**\n     * 篩選：特定開團主的團購\n     */\n    public function scopeByOrganizer(Builder $query, int $userId): void\n    {\n        $query->where('organizer_id', $userId);\n    }\n\n    /**\n     * 篩選：已達成團門檻\n     */\n    public function scopeReachedMinimum(Builder $query): void\n    {\n        $query->whereColumn(\n            'participants_count', '>=', 'min_participants'\n        );\n    }\n}\n```\n\n使用起來非常乾淨，而且可以鏈式組合：\n\n```php\n// 取得所有進行中的團購\n$openGroupBuys = GroupBuy::open()->get();\n\n// 某個使用者的進行中團購\n$myOpenGroupBuys = GroupBuy::open()\n    ->byOrganizer(auth()->id())\n    ->get();\n\n// 已截止但還沒成團的（可能需要退款）\n$expiredNotConfirmed = GroupBuy::expired()\n    ->where('status', '!=', 'confirmed')\n    ->get();\n\n// 搭配 Eager Loading\n$openWithDetails = GroupBuy::open()\n    ->with(['organizer', 'products'])\n    ->withCount('participants')\n    ->latest()\n    ->paginate(20);\n```\n\nScope 的好處是**查詢邏輯只定義一次**。如果哪天「開團中」的定義改了（例如加了「需要通過審核」這個條件），你只需要改 `scopeOpen()` 一個地方，所有使用它的地方自動更新。\n\n### Global Scopes\n\nGlobal Scope 會自動套用在所有查詢上，最常見的用途是軟刪除（`SoftDeletes` trait 就是透過 Global Scope 實現的）。你也可以自訂：\n\n```php\n// 自動排除被封鎖的開團主的團購\nuse Illuminate\\Database\\Eloquent\\Scope;\n\nclass ExcludeBannedOrganizersScope implements Scope\n{\n    public function apply(Builder $builder, Model $model): void\n    {\n        $builder->whereHas('organizer', fn ($q) => $q->where('is_banned', false));\n    }\n}\n```\n\nGlobal Scope 很強大，但也很容易造成「為什麼查不到那筆資料？」的困惑。建議只在非常確定的情境下使用，大多數時候 Local Scope 就夠了。\n\n## Subqueries：複雜報表查詢\n\n有些報表需求光靠 `with()` 和 `withCount()` 搞不定——例如「每個開團主的累計營收排行」。這時候就需要子查詢。\n\n### addSelect + Subquery\n\n```php\nuse App\\Models\\User;\nuse App\\Models\\Order;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\n// 每個開團主的累計營收\n$organizers = User::query()\n    ->addSelect([\n        'total_revenue' => Order::query()\n            ->selectRaw('SUM(amount)')\n            ->whereColumn('orders.group_buy_id', 'group_buys.id')\n            ->whereIn('orders.group_buy_id', function ($query) {\n                $query->select('id')\n                    ->from('group_buys')\n                    ->whereColumn('group_buys.organizer_id', 'users.id');\n            }),\n    ])\n    ->addSelect([\n        'group_buy_count' => GroupBuy::query()\n            ->selectRaw('COUNT(*)')\n            ->whereColumn('group_buys.organizer_id', 'users.id'),\n    ])\n    ->having('group_buy_count', '>', 0)\n    ->orderByDesc('total_revenue')\n    ->get();\n```\n\n這段查詢在 SQL 層面就算好了每個開團主的營收和開團數，不需要在 PHP 端做 N 次迴圈計算。\n\n### 簡化版：用 withSum\n\nLaravel 其實提供了更簡潔的語法處理常見的聚合需求：\n\n```php\n// 每個團購的總訂單金額\n$groupBuys = GroupBuy::withSum('orders', 'amount')\n    ->withCount('participants')\n    ->orderByDesc('orders_sum_amount')\n    ->get();\n\nforeach ($groupBuys as $groupBuy) {\n    echo $groupBuy->orders_sum_amount;  // 自動命名：{relation}_sum_{column}\n    echo $groupBuy->participants_count;\n}\n```\n\n`withSum()`、`withAvg()`、`withMin()`、`withMax()` 這幾個方法底層都是用子查詢實現的，語法上比手寫 `addSelect()` 簡潔得多。\n\n### 什麼時候用 Raw SQL\n\nQuery Builder 能處理 90% 的需求，但偶爾你會遇到非常複雜的報表查詢——多層 JOIN、窗口函數、CTE（Common Table Expression）。這時候不要硬用 Query Builder，直接寫 Raw SQL 反而更清楚：\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\n// 每週營收趨勢（用窗口函數計算移動平均）\n$weeklyRevenue = DB::select(\"\n    SELECT\n        DATE_FORMAT(created_at, '%Y-%u') AS week,\n        SUM(amount) AS revenue,\n        AVG(SUM(amount)) OVER (\n            ORDER BY DATE_FORMAT(created_at, '%Y-%u')\n            ROWS BETWEEN 3 PRECEDING AND CURRENT ROW\n        ) AS moving_avg\n    FROM orders\n    WHERE created_at >= ?\n    GROUP BY week\n    ORDER BY week\n\", [now()->subMonths(3)]);\n```\n\n經驗法則：**如果你花超過 10 分鐘在組 Query Builder 鏈，而且結果還不太對，那就直接寫 SQL。** 可讀性比「全部用 Eloquent」更重要。Raw SQL 的缺點是失去了資料庫引擎抽象（SQLite 和 MySQL 語法不完全相同），但在報表查詢這種場景，你通常只會在一種資料庫上跑。\n\n## Laravel Debugbar：效能偵測利器\n\n前面講了這麼多最佳化技巧，但怎麼確認真的有效？靠感覺不行——你需要實際看到查詢數量和執行時間。Laravel Debugbar 就是幹這個的。\n\n### 安裝\n\n```bash\ncomposer require fruitcake/laravel-debugbar --dev\n```\n\n`--dev` 很重要——Debugbar 只應該在開發環境使用。安裝後它會自動啟用（當 `APP_DEBUG=true` 時），在頁面底部出現一條黑色的工具列。\n\n### 看什麼\n\nDebugbar 提供的資訊非常豐富，但對效能最佳化來說，最重要的是這幾個 tab：\n\n**Queries tab（查詢）：**\n\n- 顯示每個頁面執行了多少次 SQL 查詢\n- 每次查詢的完整 SQL 語句\n- 每次查詢的執行時間\n- **重複查詢會被標記出來**——這就是 N+1 問題的最直接證據\n\n**Timeline tab（時間軸）：**\n\n- 整個 request 的生命週期，從 boot 到 response\n- 可以看到時間花在哪裡——是 SQL 慢還是 PHP 慢\n\n**Memory tab（記憶體）：**\n\n- 這個 request 使用了多少記憶體\n- 如果你用 `all()` 載入了一萬筆資料⋯⋯這裡的數字會告訴你問題有多嚴重\n\n### 實戰：觀察 N+1 修復效果\n\n以一個灌了 50 筆團購的列表頁為例，修復 N+1 前後的 Debugbar 數據會像這樣：\n\n**修復前：**\n\n- Queries: 52 queries（1 + 50 筆團購 × 1 取開團主）\n- Time: 320ms\n- Memory: 14 MB\n\n**修復後（加了 `with('organizer')`）：**\n\n- Queries: 2 queries\n- Time: 45ms\n- Memory: 8 MB\n\n差距一目了然。養成習慣：每次寫完一個頁面，開 Debugbar 看一眼查詢數量。超過 10 次的，八成有 N+1 可以修。\n\n> **提醒：** Debugbar 絕對不能部署到正式環境。它會暴露你的 SQL 查詢、環境變數、路由結構——等於是把所有內部資訊攤開給攻擊者看。`--dev` 裝、`APP_DEBUG=false` 就會自動隱藏，但還是建議在部署前確認一下。\n\n## 實作：揪好買的開團主與管理員後台\n\n到目前為止我們學了 Filament 和進階查詢的個別技巧，現在把它們串起來，為揪好買建立完整的後台系統。\n\n### 管理員後台：完整 Resource 體系\n\n管理員需要管理三個核心資源：團購、訂單、使用者。\n\n```bash\nphp artisan make:filament-resource GroupBuy --generate\nphp artisan make:filament-resource Order --generate\nphp artisan make:filament-resource User --generate\n```\n\n`--generate` flag 會根據 Model 的資料表結構自動產生表單和列表欄位——先自動生成，再手動微調，比從空白開始快得多。\n\n管理面板的設定在 `AdminPanelProvider`：\n\n```php\n<?php\n\n// app/Providers/Filament/AdminPanelProvider.php\n\nnamespace App\\Providers\\Filament;\n\nuse Filament\\Panel;\nuse Filament\\PanelProvider;\nuse Filament\\Support\\Colors\\Color;\nuse App\\Filament\\Widgets\\GroupBuyStatsOverview;\nuse App\\Filament\\Widgets\\GroupBuyChart;\nuse App\\Filament\\Widgets\\ExpiringGroupBuys;\n\nclass AdminPanelProvider extends PanelProvider\n{\n    public function panel(Panel $panel): Panel\n    {\n        return $panel\n            ->default()\n            ->id('admin')\n            ->path('admin')\n            ->login()\n            ->colors([\n                'primary' => Color::Indigo,\n            ])\n            ->discoverResources(\n                in: app_path('Filament/Resources'),\n                for: 'App\\\\Filament\\\\Resources'\n            )\n            ->discoverPages(\n                in: app_path('Filament/Pages'),\n                for: 'App\\\\Filament\\\\Pages'\n            )\n            ->widgets([\n                GroupBuyStatsOverview::class,\n                GroupBuyChart::class,\n                ExpiringGroupBuys::class,\n            ])\n            ->middleware([\n                // ...預設 middleware\n            ])\n            ->authMiddleware([\n                // 確保只有管理員能存取\n            ]);\n    }\n}\n```\n\n### 開團主儀表板：第二個 Panel\n\n揪好買的開團主不是管理員，但他們需要看到自己的開團統計、管理自己的團購。Filament 5 支援**多 Panel**——你可以為不同角色建立不同的後台。\n\n```bash\nphp artisan make:filament-panel organizer\n```\n\n這會建立 `app/Providers/Filament/OrganizerPanelProvider.php`：\n\n```php\n<?php\n\nnamespace App\\Providers\\Filament;\n\nuse App\\Models\\User;\nuse Filament\\Panel;\nuse Filament\\PanelProvider;\nuse Filament\\Support\\Colors\\Color;\n\nclass OrganizerPanelProvider extends PanelProvider\n{\n    public function panel(Panel $panel): Panel\n    {\n        return $panel\n            ->id('organizer')\n            ->path('organizer')       // 路由前綴：/organizer\n            ->login()\n            ->colors([\n                'primary' => Color::Emerald,  // 用不同主色區分\n            ])\n            ->discoverResources(\n                in: app_path('Filament/Organizer/Resources'),\n                for: 'App\\\\Filament\\\\Organizer\\\\Resources'\n            )\n            ->discoverPages(\n                in: app_path('Filament/Organizer/Pages'),\n                for: 'App\\\\Filament\\\\Organizer\\\\Pages'\n            );\n    }\n}\n```\n\n接著為開團主建立專屬的 Resource，只顯示自己的資料：\n\n```php\n<?php\n\n// app/Filament/Organizer/Resources/MyGroupBuyResource.php\n\nnamespace App\\Filament\\Organizer\\Resources;\n\nuse App\\Models\\GroupBuy;\nuse Filament\\Resources\\Resource;\nuse Filament\\Tables;\nuse Filament\\Tables\\Table;\nuse Illuminate\\Database\\Eloquent\\Builder;\n\nclass MyGroupBuyResource extends Resource\n{\n    protected static ?string $model = GroupBuy::class;\n\n    protected static ?string $navigationLabel = '我的團購';\n\n    protected static ?string $modelLabel = '團購';\n\n    // 關鍵：只查自己的團購\n    public static function getEloquentQuery(): Builder\n    {\n        return parent::getEloquentQuery()\n            ->byOrganizer(auth()->id())  // 用前面定義的 scope\n            ->withCount('participants')\n            ->withSum('orders', 'amount');\n    }\n\n    public static function table(Table $table): Table\n    {\n        return $table\n            ->columns([\n                Tables\\Columns\\TextColumn::make('title')\n                    ->label('標題')\n                    ->searchable(),\n\n                Tables\\Columns\\TextColumn::make('status')\n                    ->label('狀態')\n                    ->badge()\n                    ->color(fn (string $state): string => match ($state) {\n                        'draft'     => 'gray',\n                        'open'      => 'success',\n                        'closed'    => 'warning',\n                        'confirmed' => 'primary',\n                        default     => 'gray',\n                    }),\n\n                Tables\\Columns\\TextColumn::make('participants_count')\n                    ->label('參加人數')\n                    ->sortable(),\n\n                Tables\\Columns\\TextColumn::make('orders_sum_amount')\n                    ->label('訂單總額')\n                    ->money('TWD')\n                    ->sortable(),\n\n                Tables\\Columns\\TextColumn::make('ends_at')\n                    ->label('截止時間')\n                    ->dateTime('Y-m-d H:i'),\n            ])\n            ->defaultSort('created_at', 'desc');\n    }\n\n    // ...form() 和其他設定\n}\n```\n\n注意 `getEloquentQuery()` 的覆寫——這是 Filament 的資料隔離機制。開團主只看得到自己的團購，就算手動改 URL 中的 ID 也拿不到別人的資料。搭配前面定義的 `scopeByOrganizer()`，query 條件清楚又好維護。\n\n### 自訂 Dashboard 頁面\n\n開團主的 Dashboard 跟管理員不同——他們更關心自己的數據。你可以建立自訂頁面：\n\n```bash\nphp artisan make:filament-page OrganizerDashboard --panel=organizer\n```\n\n```php\n<?php\n\nnamespace App\\Filament\\Organizer\\Pages;\n\nuse App\\Models\\GroupBuy;\nuse Filament\\Pages\\Page;\n\nclass OrganizerDashboard extends Page\n{\n    protected static ?string $navigationIcon = 'heroicon-o-home';\n\n    protected static ?string $title = '我的儀表板';\n\n    protected static string $view = 'filament.organizer.pages.organizer-dashboard';\n\n    protected static ?int $navigationSort = -2;\n\n    public function getViewData(): array\n    {\n        $userId = auth()->id();\n\n        return [\n            'totalGroupBuys' => GroupBuy::byOrganizer($userId)->count(),\n            'activeGroupBuys' => GroupBuy::byOrganizer($userId)->open()->count(),\n            'totalRevenue' => GroupBuy::byOrganizer($userId)\n                ->withSum('orders', 'amount')\n                ->get()\n                ->sum('orders_sum_amount'),\n            'recentGroupBuys' => GroupBuy::byOrganizer($userId)\n                ->with('participants')\n                ->withCount('participants')\n                ->latest()\n                ->take(5)\n                ->get(),\n        ];\n    }\n}\n```\n\n看到了嗎？`byOrganizer()`、`open()`、`withCount()`、`withSum()`——這一章學的所有技巧全部用上了。Query Scope 讓查詢條件可讀又可重用，Eager Loading 確保不會有 N+1 問題，Subquery 聚合讓報表數據在 SQL 層面就算好。\n\n## 小結：後台不用從零寫起\n\n這一章我們同時搞定了兩件事：用 Filament 5 快速建立後台介面，以及用 Eloquent 進階查詢確保後台跑得快。\n\n回顧一下重點：\n\n- **Filament 5** 是 Laravel 生態最成熟的開源後台方案——Resource 自動產生 CRUD、Widget 做 Dashboard、多 Panel 支援不同角色\n- **Eager Loading**（`with()`）解決 N+1 問題，`withCount()` 和 `withSum()` 處理聚合需求\n- **Query Scopes** 把查詢條件封裝成可重用的方法，`scopeOpen()`、`scopeByOrganizer()` 讓程式碼簡潔又好維護\n- **Subqueries** 處理複雜報表需求，知道什麼時候該用 Raw SQL 也是一種能力\n- **Debugbar** 是開發階段的效能照妖鏡，養成每個頁面都看一眼查詢數量的習慣\n- **`preventLazyLoading()`** 在開發環境強制杜絕 N+1，等於是編譯時期就抓到效能問題\n\n後台和效能是同一件事的兩面——有了漂亮的管理介面，但背後查詢跑得慢，管理員一樣會抱怨。反過來說，查詢最佳化做得再好，沒有 UI 也沒人看得到。兩條線必須同時顧。\n\n下一章我們進入測試。寫到現在，揪好買已經有使用者系統、團購邏輯、付款流程、通知系統、後台管理⋯⋯功能越多，改壞東西的風險就越高。[**第十三章「測試不是選配：用 Pest 寫出有信心的 Laravel 程式」**](/blog/laravel-guide-testing-pest-ci/)會教你用 Pest 為核心流程寫完整測試，搭配 GitHub Actions 讓每次 Push 都自動驗證——從此你可以安心重構，不怕改一處壞三處。",
      "summary": "用 Filament 5 快速建立後台管理系統，搭配進階 Eloquent 查詢技巧解決真實世界的效能問題。",
      "image": "https://bobochen.dev/_astro/cover.DZRp5ULp.webp",
      "date_published": "2025-05-20T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Filament",
        "Admin Panel",
        "Eloquent",
        "N+1"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-api-sanctum-rest/",
      "url": "https://bobochen.dev/blog/laravel-guide-api-sanctum-rest/",
      "title": "RESTful API 與 Sanctum：讓 LINE Bot 也能開團",
      "content_text": "用 Laravel 12 打造 RESTful API、以 Sanctum Token 認證保護端點，加上 Rate Limiting、API 版本管理與 CORS 設定，讓揪好買同時服務手機 App、LINE Bot 與第三方整合。",
      "content_html": "你的網頁應用做得很好，使用者可以開團、跟團、下單。但現實世界不只有瀏覽器。有人想用手機 App 開團，有人想在 LINE 群組裡直接查詢團購狀態，還有合作店家想透過程式自動同步訂單資料。這些需求都指向同一件事：你需要 API。\n\nRESTful API 是讓你的後端從「只服務網頁」進化到「服務所有人」的關鍵。Laravel 這部分該有的都有——API Routes、API Resources、Sanctum Token 認證。你不需要另外架一套 API Server，同一個 Laravel 專案就能同時服務網頁和 API 客戶端。\n\n這一章我們要把揪好買的核心功能——開團列表、跟團操作、訂單查詢——全部包裝成 RESTful API，用 Sanctum 做 Token 認證保護端點，設定 Rate Limiting 防止濫用，還會示範 LINE Bot 整合的概念。一套後端程式碼，同時餵給多個前端消費者。\n\n## 為什麼需要 API：不只是網頁的世界\n\n前十章我們做的事情，都是「使用者打開瀏覽器 → 伺服器回傳 HTML」。這在桌面端的體驗很好，但想想這些場景：\n\n- **手機 App**：你的合作夥伴想做一個揪好買的 iOS/Android App，它不需要 HTML，它需要 JSON 資料\n- **LINE Bot**：台灣人都在用 LINE，如果在群組裡打「@揪好買 查團購」就能看到最新團購列表，多方便？\n- **第三方整合**：合作店家的 ERP 系統想自動抓訂單資料，它不會開瀏覽器，它用程式 call API\n- **SPA 前端**：如果你的前端團隊想用 React 或 Vue 重寫介面，它們只需要跟 API 溝通\n- **自動化腳本**：你自己想寫一個 cron job 在截止時間自動關閉團購，用 API 最乾淨\n\n這些消費者有一個共通點：它們不需要 HTML，它們需要**結構化的資料**。而 JSON 就是這個通用語言。\n\n```\n瀏覽器使用者 → routes/web.php → 回傳 HTML（Blade view）\n手機 App     → routes/api.php → 回傳 JSON\nLINE Bot     → routes/api.php → 回傳 JSON\n第三方系統   → routes/api.php → 回傳 JSON\n```\n\nAPI 不是什麼高深的東西——它就是你的後端用 JSON 格式說話，讓任何程式都能聽懂。\n\n## API Routes：api.php 與 web.php 的差異\n\n### 安裝 API 路由\n\n重要的一點：**Laravel 12 預設不包含 `routes/api.php`**。這是刻意的設計——不是每個專案都需要 API。要啟用它，跑一行指令：\n\n```bash\nphp artisan install:api\n```\n\n這個指令幫你做了三件事：\n\n1. 建立 `routes/api.php` 檔案\n2. 安裝 **Laravel Sanctum**（Token 認證套件）\n3. 執行 Sanctum 需要的 migration（`personal_access_tokens` 表）\n\n裝完後你會在 `routes/api.php` 裡看到這樣的起始內容：\n\n```php\n<?php\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get('/user', function (Request $request) {\n    return $request->user();\n})->middleware('auth:sanctum');\n```\n\n### web.php vs api.php：兩個世界\n\n這兩個路由檔案的差異不只是「放在不同檔案」而已，它們跑在完全不同的 middleware 環境裡：\n\n| 特性 | web.php | api.php |\n|------|---------|---------|\n| URL 前綴 | 無（`/group-buys`） | `/api`（`/api/group-buys`） |\n| Session | 有（記住登入狀態） | 無（Stateless） |\n| CSRF 保護 | 有（`@csrf`） | 無（不需要） |\n| Cookie | 有 | 無 |\n| 認證方式 | Session-based（瀏覽器） | Token-based（Sanctum） |\n| Rate Limiting | 無預設 | `throttle:api`（每分鐘 60 次） |\n| 回傳格式 | HTML（Blade view） | JSON |\n\n關鍵差異是**stateless**：每個 API request 都是獨立的，伺服器不會「記得」你是誰。每次請求都要帶著 Token 來證明身份。這就像去便利商店——你每次都要出示會員卡，店員不會記得你昨天來過。\n\n### 定義 API 路由\n\n```php\n// routes/api.php\nuse App\\Http\\Controllers\\Api\\GroupBuyController;\n\n// 公開端點——任何人都能呼叫\nRoute::get('/group-buys', [GroupBuyController::class, 'index']);\nRoute::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);\n\n// 受保護端點——需要 Sanctum Token\nRoute::middleware('auth:sanctum')->group(function () {\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n    Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);\n    Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);\n});\n```\n\n注意我把 API Controller 放在 `App\\Http\\Controllers\\Api\\` 命名空間下——跟 web Controller 分開，讓結構清楚。\n\n```bash\nphp artisan make:controller Api/GroupBuyController --api\n```\n\n`--api` flag 會生成只有 `index`、`store`、`show`、`update`、`destroy` 五個方法的 Controller（沒有 `create` 和 `edit`，因為 API 不需要「表單頁面」）。\n\n## API Resource：優雅的 JSON 轉換\n\n直接在 Controller 裡 `return $groupBuy;` 可以嗎？技術上可以，但你會把整個 Model——包括 `created_at`、`updated_at`、甚至 `password`——全部暴露出去。API Resource 讓你精準控制「回傳哪些欄位、長什麼格式」。\n\n### 建立 Resource\n\n```bash\nphp artisan make:resource GroupBuyResource\n```\n\n```php\n<?php\n\nnamespace App\\Http\\Resources;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass GroupBuyResource extends JsonResource\n{\n    public function toArray(Request $request): array\n    {\n        return [\n            'id'           => $this->id,\n            'title'        => $this->title,\n            'description'  => $this->description,\n            'target_amount'=> $this->target_amount,\n            'current_amount'=> $this->current_amount,\n            'status'       => $this->status->value,\n            'deadline'     => $this->deadline->toIso8601String(),\n            'organizer'    => [\n                'id'   => $this->user->id,\n                'name' => $this->user->name,\n            ],\n            'participants_count' => $this->participants_count,\n            'created_at'   => $this->created_at->toIso8601String(),\n        ];\n    }\n}\n```\n\n注意幾件事：\n\n- **只暴露需要的欄位**——`user_id` 換成巢狀的 `organizer` 物件，更直覺\n- **日期用 ISO 8601 格式**——`2026-08-10T08:00:00+08:00`，前端好 parse\n- **不暴露 `updated_at`**——API 消費者不需要這個\n- **Enum 轉成字串**——`$this->status->value` 而不是整個 Enum 物件\n\n### 在 Controller 裡使用\n\n```php\nuse App\\Http\\Resources\\GroupBuyResource;\n\nclass GroupBuyController extends Controller\n{\n    public function index()\n    {\n        $groupBuys = GroupBuy::withCount('participants')\n            ->where('status', 'open')\n            ->latest()\n            ->paginate(15);\n\n        return GroupBuyResource::collection($groupBuys);\n    }\n\n    public function show(GroupBuy $groupBuy)\n    {\n        $groupBuy->loadCount('participants');\n\n        return new GroupBuyResource($groupBuy);\n    }\n}\n```\n\n### 回傳的 JSON 長這樣\n\n單一資源：\n\n```json\n{\n    \"data\": {\n        \"id\": 42,\n        \"title\": \"芒果季團購\",\n        \"description\": \"玉井愛文芒果，產地直送\",\n        \"target_amount\": 30,\n        \"current_amount\": 18,\n        \"status\": \"open\",\n        \"deadline\": \"2026-08-15T23:59:59+08:00\",\n        \"organizer\": {\n            \"id\": 7,\n            \"name\": \"小陳\"\n        },\n        \"participants_count\": 18,\n        \"created_at\": \"2026-08-01T10:30:00+08:00\"\n    }\n}\n```\n\n列表（搭配 Pagination）：\n\n```json\n{\n    \"data\": [\n        { \"id\": 42, \"title\": \"芒果季團購\", ... },\n        { \"id\": 41, \"title\": \"阿里山烏龍茶\", ... }\n    ],\n    \"links\": {\n        \"first\": \"http://localhost/api/group-buys?page=1\",\n        \"last\": \"http://localhost/api/group-buys?page=5\",\n        \"prev\": null,\n        \"next\": \"http://localhost/api/group-buys?page=2\"\n    },\n    \"meta\": {\n        \"current_page\": 1,\n        \"last_page\": 5,\n        \"per_page\": 15,\n        \"total\": 67\n    }\n}\n```\n\nLaravel 自動把 `paginate()` 的結果包成帶 `links` 和 `meta` 的結構——前端不用自己算分頁。\n\n### ResourceCollection：集合層級的客製化\n\n如果你想在集合回傳時加額外資訊（比如統計數據），可以建一個 Collection class：\n\n```bash\nphp artisan make:resource GroupBuyCollection\n```\n\n```php\nclass GroupBuyCollection extends ResourceCollection\n{\n    public function toArray(Request $request): array\n    {\n        return [\n            'data' => $this->collection,\n            'stats' => [\n                'total_open' => GroupBuy::where('status', 'open')->count(),\n            ],\n        ];\n    }\n}\n```\n\n> **安全提醒：** Resource 是你 API 的門面。永遠不要暴露 `password`、`remember_token`、內部的 `pivot` 資料，或任何使用者不該看到的欄位。養成習慣——在 `toArray()` 裡明確列出每一個欄位，而不是用 `parent::toArray()` 全部丟出去。\n\n## Sanctum Token Authentication\n\n[第六章我們用 Session 做瀏覽器的認證](/blog/laravel-guide-auth-breeze-authorization/)。但 API 客戶端（手機 App、LINE Bot、第三方系統）不用瀏覽器，沒有 Cookie 和 Session。它們需要的是 **Token-based 認證**：每次請求都在 Header 帶一個 token，伺服器驗證這個 token 來識別身份。\n\n### Token 認證的流程\n\n```\n1. 使用者用帳號密碼呼叫 /api/login\n2. 伺服器驗證成功，回傳一個 token\n3. 之後的每個請求，在 Header 帶上：Authorization: Bearer <token>\n4. 伺服器看到 token，就知道你是誰\n5. 登出時，伺服器把 token 刪掉\n```\n\n### 建立登入端點\n\n```php\n// routes/api.php\nuse App\\Http\\Controllers\\Api\\AuthController;\n\nRoute::post('/register', [AuthController::class, 'register']);\nRoute::post('/login', [AuthController::class, 'login']);\n\nRoute::middleware('auth:sanctum')->group(function () {\n    Route::post('/logout', [AuthController::class, 'logout']);\n    Route::get('/user', [AuthController::class, 'user']);\n});\n```\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers\\Api;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Models\\User;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Hash;\nuse Illuminate\\Validation\\ValidationException;\n\nclass AuthController extends Controller\n{\n    public function register(Request $request)\n    {\n        $validated = $request->validate([\n            'name'     => 'required|string|max:255',\n            'email'    => 'required|string|email|unique:users',\n            'password' => 'required|string|min:8|confirmed',\n        ]);\n\n        $user = User::create([\n            'name'     => $validated['name'],\n            'email'    => $validated['email'],\n            'password' => Hash::make($validated['password']),\n        ]);\n\n        $token = $user->createToken('api-token')->plainTextToken;\n\n        return response()->json([\n            'user'  => $user->only('id', 'name', 'email'),\n            'token' => $token,\n        ], 201);\n    }\n\n    public function login(Request $request)\n    {\n        $request->validate([\n            'email'    => 'required|email',\n            'password' => 'required',\n        ]);\n\n        $user = User::where('email', $request->email)->first();\n\n        if (! $user || ! Hash::check($request->password, $user->password)) {\n            throw ValidationException::withMessages([\n                'email' => ['帳號或密碼錯誤。'],\n            ]);\n        }\n\n        $token = $user->createToken('api-token')->plainTextToken;\n\n        return response()->json([\n            'user'  => $user->only('id', 'name', 'email'),\n            'token' => $token,\n        ]);\n    }\n\n    public function logout(Request $request)\n    {\n        // 刪除當前使用的 token\n        $request->user()->currentAccessToken()->delete();\n\n        return response()->json(['message' => '已登出']);\n    }\n\n    public function user(Request $request)\n    {\n        return response()->json($request->user()->only('id', 'name', 'email', 'role'));\n    }\n}\n```\n\n`createToken()` 回傳的 `plainTextToken` 長這樣：`1|abc123def456...`，前面的數字是 token ID，後面是實際的 token 值。**這個值只會在建立時出現一次**——資料庫裡存的是 hash 過的版本。告訴你的 API 消費者：拿到 token 就要存好。\n\n### Token Abilities（權限）\n\n不是每個 token 都應該有全部權限。例如，LINE Bot 只需要「讀取」的權限，不該能刪除團購：\n\n```php\n// 建立有限權限的 token\n$token = $user->createToken('line-bot', ['group-buys:read']);\n\n// 建立完整權限的 token\n$token = $user->createToken('mobile-app', ['*']);\n```\n\n在路由裡檢查 ability：\n\n```php\nRoute::middleware(['auth:sanctum', 'ability:group-buys:read'])->group(function () {\n    Route::get('/group-buys', [GroupBuyController::class, 'index']);\n    Route::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);\n});\n\nRoute::middleware(['auth:sanctum', 'ability:group-buys:write'])->group(function () {\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n    Route::delete('/group-buys/{groupBuy}', [GroupBuyController::class, 'destroy']);\n});\n```\n\n需要在 `bootstrap/app.php` 裡註冊 ability middleware：\n\n```php\nuse Laravel\\Sanctum\\Http\\Middleware\\CheckAbilities;\nuse Laravel\\Sanctum\\Http\\Middleware\\CheckForAnyAbility;\n\n->withMiddleware(function (Middleware $middleware) {\n    $middleware->alias([\n        'abilities' => CheckAbilities::class,   // 必須擁有「全部」列出的 abilities\n        'ability'   => CheckForAnyAbility::class, // 擁有「其中一個」就通過\n    ]);\n})\n```\n\n### 撤銷 Token\n\n```php\n// 撤銷當前 token\n$request->user()->currentAccessToken()->delete();\n\n// 撤銷所有 token（強制所有裝置登出）\n$request->user()->tokens()->delete();\n\n// 撤銷特定 token\n$request->user()->tokens()->where('id', $tokenId)->delete();\n```\n\n### Sanctum vs JWT\n\n你可能聽過 JWT（JSON Web Token）。在 Laravel 生態系裡，Sanctum 和 JWT 的差異是：\n\n| 特性 | Sanctum | JWT（tymon/jwt-auth） |\n|------|---------|----------------------|\n| 官方維護 | 是（Laravel 團隊） | 否（社群套件） |\n| Token 儲存 | 資料庫 | 無狀態（Token 自帶資訊） |\n| 撤銷 Token | 簡單（刪資料庫記錄） | 複雜（需要黑名單機制） |\n| 適用場景 | SPA、手機 App、第三方 | 微服務間通訊 |\n| 複雜度 | 低 | 中高 |\n| 建議 | 90% 的專案用這個 | 除非有跨服務需求 |\n\n除非你在做微服務架構，否則 Sanctum 就是正確的選擇。官方維護、設定簡單、能撤銷 token，沒什麼理由用 JWT。\n\n## Rate Limiting：保護你的 API\n\n公開的 API 如果沒有速率限制，一個寫壞的爬蟲或惡意攻擊就能把你的伺服器打掛。Rate Limiting 限制每個使用者（或 IP）在一段時間內能發多少請求。\n\n### 預設設定\n\n`php artisan install:api` 安裝後，`api.php` 的路由自動帶 `throttle:api` middleware。預設限制在 `bootstrap/app.php` 裡：\n\n```php\n->withMiddleware(function (Middleware $middleware) {\n    $middleware->throttleApi();\n    // 等同於 throttle:api，預設 60 次/分鐘\n})\n```\n\n### 自訂 Rate Limiter\n\n在 `AppServiceProvider` 的 `boot()` 方法裡定義：\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Support\\Facades\\RateLimiter;\nuse Illuminate\\Http\\Request;\n\npublic function boot(): void\n{\n    // 已登入使用者：每分鐘 120 次\n    // 未登入（靠 IP）：每分鐘 30 次\n    RateLimiter::for('api', function (Request $request) {\n        return $request->user()\n            ? Limit::perMinute(120)->by($request->user()->id)\n            : Limit::perMinute(30)->by($request->ip());\n    });\n\n    // 登入端點特別嚴格：每分鐘 5 次（防暴力破解）\n    RateLimiter::for('login', function (Request $request) {\n        return Limit::perMinute(5)->by($request->ip());\n    });\n}\n```\n\n套用到路由：\n\n```php\nRoute::post('/login', [AuthController::class, 'login'])\n    ->middleware('throttle:login');\n```\n\n### Rate Limit Response Headers\n\nLaravel 自動在回傳的 HTTP Header 裡告訴客戶端剩餘額度：\n\n```\nX-RateLimit-Limit: 120\nX-RateLimit-Remaining: 117\nRetry-After: 58          ← 超過限制時，告訴你幾秒後可以再試\n```\n\n### 自訂超過限制時的回應\n\n```php\nRateLimiter::for('api', function (Request $request) {\n    return Limit::perMinute(60)\n        ->by($request->user()?->id ?: $request->ip())\n        ->response(function (Request $request, array $headers) {\n            return response()->json([\n                'message' => '請求太頻繁，請稍後再試。',\n            ], 429, $headers);\n        });\n});\n```\n\n## API 版本管理策略\n\nAPI 一旦發布出去，就有人在用。你改了回傳格式，人家的 App 就壞了。版本管理讓你能安全地升級 API 而不影響現有客戶端。\n\n### 方法一：URL 前綴（最常見）\n\n```php\n// routes/api.php\n\n// v1 版本\nRoute::prefix('v1')->group(function () {\n    Route::get('/group-buys', [V1\\GroupBuyController::class, 'index']);\n});\n\n// v2 版本（未來）\nRoute::prefix('v2')->group(function () {\n    Route::get('/group-buys', [V2\\GroupBuyController::class, 'index']);\n});\n```\n\n呼叫方式：`GET /api/v1/group-buys`\n\n### 方法二：Header 版本控制\n\n```\nGET /api/group-buys\nAccept: application/vnd.jiuhaomai.v1+json\n```\n\n比較少見，但更「正統」。\n\n### 務實的建議\n\n**不要過早加版本號**。如果你的 API 還在開發階段、消費者只有自己的團隊，直接用 `/api/group-buys` 就好。等到以下情況才加 versioning：\n\n- 有外部第三方在用你的 API\n- 你需要做 breaking change（欄位改名、移除、格式變更）\n- 你需要同時維護新舊版本\n\n加版本號的成本是真實的：你要維護兩份 Controller、兩份 Resource、兩份文件。YAGNI（You Ain't Gonna Need It）——等需要的時候再加。\n\n## API 文件化：Scramble\n\n好的 API 沒有文件等於不存在。你的 API 消費者不該來看你的程式碼才知道怎麼呼叫端點。Scramble 是一個能直接從 Laravel 程式碼自動生成 OpenAPI（Swagger）文件的套件——不需要寫任何註解或額外設定。\n\n### 安裝\n\n```bash\ncomposer require dedoc/scramble\n```\n\n就這樣。打開瀏覽器：\n\n```\nhttp://localhost:8000/docs/api\n```\n\n你會看到一個互動式的 API 文件頁面，自動列出所有端點、參數類型、回傳格式。它是透過分析你的 Route、Controller、FormRequest、Resource 來推斷結構的。\n\n### 為什麼推薦 Scramble\n\n- **零設定**：裝完就能用，不用在程式碼裡寫一堆 annotation\n- **自動同步**：程式碼改了文件就改了，不會有文件過期的問題\n- **OpenAPI 標準**：輸出的是標準的 OpenAPI 3.x spec，可以匯入 Postman、Insomnia\n- **互動式測試**：在文件頁面直接送請求測試\n\n如果你需要更細緻的文件控制（加範例、加說明），也可以搭配 PHPDoc 補充：\n\n```php\n/**\n * 取得團購列表\n *\n * 回傳所有進行中的團購，支援分頁。\n */\npublic function index()\n{\n    // ...\n}\n```\n\n## CORS 設定：跨域請求處理\n\n如果你的 React 或 Vue 前端跑在 `localhost:3000`，API 跑在 `localhost:8000`，瀏覽器會擋住跨域請求。這不是 bug，是瀏覽器的安全機制——**CORS**（Cross-Origin Resource Sharing）。\n\n### 什麼是 CORS\n\n當瀏覽器從 A 網域發請求到 B 網域時，B 必須在 Response Header 裡明確說「我允許 A 來存取」。如果沒有這些 Header，瀏覽器會直接擋掉回應。\n\n```\n前端（localhost:3000）→ 請求 → API（localhost:8000）\n                                ↓\n                         API 回傳 CORS Header:\n                         Access-Control-Allow-Origin: http://localhost:3000\n                                ↓\n                         瀏覽器檢查 Header → OK → 前端拿到資料\n```\n\n### Laravel 的 CORS 設定\n\nLaravel 內建 CORS 處理（由全域 middleware 中的 `HandleCors` 自動處理，不發佈設定檔也能運作）。Laravel 12 預設**不會**發佈這個設定檔，需先執行 `php artisan config:publish cors`，才會在 `config/cors.php` 產生它，接著就能編輯：\n\n```php\nreturn [\n    'paths' => ['api/*'],  // 哪些路徑要處理 CORS\n\n    'allowed_methods' => ['*'],  // GET, POST, PUT, DELETE...\n\n    'allowed_origins' => [\n        'http://localhost:3000',      // 開發環境前端\n        'https://jiuhaomai.tw',      // 正式環境\n    ],\n\n    'allowed_origins_patterns' => [],\n\n    'allowed_headers' => ['*'],\n\n    'exposed_headers' => [],\n\n    'max_age' => 0,  // Preflight 快取秒數\n\n    'supports_credentials' => false,\n];\n```\n\n### 常見踩坑\n\n1. **`allowed_origins` 設成 `['*']` 很方便但不安全**——正式環境一定要列出明確的 domain\n2. **Preflight request（OPTIONS）被 Nginx 擋掉**——確保你的 Nginx/Apache 設定允許 OPTIONS 方法通過\n3. **帶 credentials 時不能用 `*`**——如果 `supports_credentials` 是 `true`，`allowed_origins` 不能用萬用字元，必須列明\n4. **忘記加 `api/*` 到 paths**——如果你的 API 路由不在 `api/` 底下，CORS 設定不會生效\n\n> **提醒：** CORS 是**瀏覽器**的機制。手機 App 和 server-to-server 的請求不受 CORS 限制。所以你的 LINE Bot 和後端爬蟲不需要擔心 CORS。\n\n## 實作：揪好買 API 與 LINE Bot 概念整合\n\n把前面學的全部串起來。我們要幫揪好買建一組完整的 API 端點。\n\n### 完整的 API 路由\n\n```php\n// routes/api.php\nuse App\\Http\\Controllers\\Api\\AuthController;\nuse App\\Http\\Controllers\\Api\\GroupBuyController;\n\n// 認證端點\nRoute::post('/register', [AuthController::class, 'register']);\nRoute::post('/login', [AuthController::class, 'login'])\n    ->middleware('throttle:login');\n\n// 公開端點\nRoute::get('/group-buys', [GroupBuyController::class, 'index']);\nRoute::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);\n\n// 受保護端點\nRoute::middleware('auth:sanctum')->group(function () {\n    // 認證\n    Route::post('/logout', [AuthController::class, 'logout']);\n    Route::get('/user', [AuthController::class, 'user']);\n\n    // 團購操作\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n    Route::post('/group-buys/{groupBuy}/join', [GroupBuyController::class, 'join']);\n\n    // 我的資料\n    Route::get('/my/orders', [GroupBuyController::class, 'myOrders']);\n});\n```\n\n### GroupBuyController（API 版）\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers\\Api;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Http\\Resources\\GroupBuyResource;\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Http\\Request;\n\nclass GroupBuyController extends Controller\n{\n    /**\n     * 取得進行中的團購列表\n     */\n    public function index(Request $request)\n    {\n        $groupBuys = GroupBuy::withCount('participants')\n            ->where('status', 'open')\n            ->when($request->query('search'), function ($query, $search) {\n                $query->where('title', 'like', \"%{$search}%\");\n            })\n            ->latest()\n            ->paginate(15);\n\n        return GroupBuyResource::collection($groupBuys);\n    }\n\n    /**\n     * 取得單一團購詳情\n     */\n    public function show(GroupBuy $groupBuy)\n    {\n        $groupBuy->loadCount('participants');\n        $groupBuy->load('user:id,name');\n\n        return new GroupBuyResource($groupBuy);\n    }\n\n    /**\n     * 建立新團購（需要認證）\n     */\n    public function store(Request $request)\n    {\n        $this->authorize('create', GroupBuy::class);\n\n        $validated = $request->validate([\n            'title'         => 'required|string|max:255',\n            'description'   => 'required|string',\n            'target_amount' => 'required|integer|min:2',\n            'deadline'      => 'required|date|after:now',\n            'price_per_unit'=> 'required|numeric|min:0',\n        ]);\n\n        $groupBuy = $request->user()->groupBuys()->create($validated);\n\n        return (new GroupBuyResource($groupBuy))\n            ->response()\n            ->setStatusCode(201);\n    }\n\n    /**\n     * 加入團購\n     */\n    public function join(Request $request, GroupBuy $groupBuy)\n    {\n        // 檢查團購是否還開放\n        if ($groupBuy->status !== 'open') {\n            return response()->json([\n                'message' => '此團購已截止或已取消。',\n            ], 422);\n        }\n\n        // 檢查是否已經加入\n        if ($groupBuy->participants()->where('user_id', $request->user()->id)->exists()) {\n            return response()->json([\n                'message' => '你已經加入這個團購了。',\n            ], 422);\n        }\n\n        $validated = $request->validate([\n            'quantity' => 'required|integer|min:1',\n        ]);\n\n        $groupBuy->participants()->attach($request->user()->id, [\n            'quantity' => $validated['quantity'],\n        ]);\n\n        return response()->json([\n            'message' => '成功加入團購！',\n        ]);\n    }\n\n    /**\n     * 我的訂單\n     */\n    public function myOrders(Request $request)\n    {\n        $orders = $request->user()\n            ->joinedGroupBuys()\n            ->withPivot('quantity')\n            ->withCount('participants')\n            ->latest()\n            ->paginate(15);\n\n        return GroupBuyResource::collection($orders);\n    }\n}\n```\n\n### ParticipantResource（跟團者資訊）\n\n如果 API 需要回傳參與者列表，也用 Resource 包裝：\n\n```php\n<?php\n\nnamespace App\\Http\\Resources;\n\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Http\\Resources\\Json\\JsonResource;\n\nclass ParticipantResource extends JsonResource\n{\n    public function toArray(Request $request): array\n    {\n        return [\n            'user' => [\n                'id'   => $this->id,\n                'name' => $this->name,\n            ],\n            'quantity'  => $this->pivot->quantity,\n            'joined_at' => $this->pivot->created_at?->toIso8601String(),\n        ];\n    }\n}\n```\n\n### 用 curl 測試\n\nAPI 不需要瀏覽器就能測試。用 `curl` 或 `httpie` 在終端機直接打：\n\n```bash\n# 註冊\ncurl -X POST http://localhost:8000/api/register \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -d '{\n    \"name\": \"小陳\",\n    \"email\": \"chen@example.com\",\n    \"password\": \"password123\",\n    \"password_confirmation\": \"password123\"\n  }'\n\n# 回傳：{\"user\":{\"id\":1,\"name\":\"小陳\",\"email\":\"chen@example.com\"},\"token\":\"1|abc123...\"}\n```\n\n```bash\n# 登入\ncurl -X POST http://localhost:8000/api/login \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -d '{\"email\": \"chen@example.com\", \"password\": \"password123\"}'\n```\n\n```bash\n# 查看團購列表（不需要 token）\ncurl http://localhost:8000/api/group-buys \\\n  -H \"Accept: application/json\"\n```\n\n```bash\n# 加入團購（需要 token）\ncurl -X POST http://localhost:8000/api/group-buys/42/join \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Accept: application/json\" \\\n  -H \"Authorization: Bearer 1|abc123...\" \\\n  -d '{\"quantity\": 2}'\n```\n\n```bash\n# 如果你裝了 httpie，語法更簡潔\nhttp POST localhost:8000/api/login email=chen@example.com password=password123\nhttp localhost:8000/api/group-buys Authorization:\"Bearer 1|abc123...\"\n```\n\n> **重要：** 請求 API 時一定要帶 `Accept: application/json` Header。如果不帶，Laravel 在驗證失敗時會嘗試 redirect 到一個 HTML 頁面——在 API 裡這會變成很奇怪的 302 回應。\n\n### LINE Bot Webhook 概念整合\n\nLINE Bot 的運作邏輯是：使用者在 LINE 群組裡傳訊息 → LINE 平台把訊息轉送到你的 webhook URL → 你的伺服器處理後回覆。\n\n```\n使用者在 LINE 傳「查團購」\n        ↓\nLINE 平台 → POST /api/line/webhook（你的伺服器）\n        ↓\n伺服器解析訊息，查詢 GroupBuy 資料\n        ↓\n伺服器透過 LINE Messaging API 回覆結果\n```\n\nwebhook 端點的概念實作：\n\n```php\n// routes/api.php\nRoute::post('/line/webhook', [LineWebhookController::class, 'handle']);\n```\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers\\Api;\n\nuse App\\Http\\Controllers\\Controller;\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Http\\Request;\n\nclass LineWebhookController extends Controller\n{\n    public function handle(Request $request)\n    {\n        // LINE 送來的 webhook 事件\n        $events = $request->input('events', []);\n\n        foreach ($events as $event) {\n            if ($event['type'] !== 'message') {\n                continue;\n            }\n\n            $text = $event['message']['text'] ?? '';\n            $replyToken = $event['replyToken'];\n\n            if (str_contains($text, '查團購')) {\n                $this->replyGroupBuyList($replyToken);\n            } elseif (str_contains($text, '開團中')) {\n                $this->replyOpenGroupBuys($replyToken);\n            }\n        }\n\n        return response()->json(['status' => 'ok']);\n    }\n\n    private function replyGroupBuyList(string $replyToken): void\n    {\n        $groupBuys = GroupBuy::where('status', 'open')\n            ->latest()\n            ->take(5)\n            ->get();\n\n        $message = \"目前開團中的團購：\\n\\n\";\n\n        foreach ($groupBuys as $gb) {\n            $message .= \"🛒 {$gb->title}\\n\";\n            $message .= \"   截止：{$gb->deadline->format('m/d H:i')}\\n\";\n            $message .= \"   目標：{$gb->current_amount}/{$gb->target_amount} 人\\n\\n\";\n        }\n\n        // 實際整合時透過 LINE Messaging API SDK 回覆\n        // v7 SDK：$messagingApi->replyMessage(new ReplyMessageRequest([\n        //     'replyToken' => $replyToken,\n        //     'messages' => [new TextMessage(['type' => 'text', 'text' => $message])],\n        // ])); // 舊版 LINEBot::replyText 已淘汰\n    }\n\n    private function replyOpenGroupBuys(string $replyToken): void\n    {\n        $count = GroupBuy::where('status', 'open')->count();\n\n        $message = \"目前有 {$count} 個團購進行中！\\n輸入「查團購」看詳細列表。\";\n\n        // v7 SDK：$messagingApi->replyMessage(new ReplyMessageRequest([...]));（舊版 LINEBot::replyText 已淘汰）\n    }\n}\n```\n\n這裡只展示概念——實際的 LINE Bot 整合需要安裝 `linecorp/line-bot-sdk`、設定 Channel Access Token 和 Channel Secret、做 Signature 驗證。但核心架構就是這樣：一個 webhook 端點，接收訊息、查詢資料庫、回覆結果。**你的 API 和 LINE Bot 共用同一個資料庫和 Model**，不需要重寫任何商業邏輯。\n\n### 錯誤回應的統一格式\n\n好的 API 在出錯時也要回傳結構化的 JSON，而不是 HTML 錯誤頁面。在 `bootstrap/app.php` 統一處理：\n\n```php\nuse Illuminate\\Http\\Request;\n\n->withExceptions(function (Exceptions $exceptions) {\n    $exceptions->shouldRenderJsonWhen(function (Request $request) {\n        return $request->is('api/*') || $request->expectsJson();\n    });\n})\n```\n\n這樣所有 `/api/*` 路由的例外都會以 JSON 格式回傳：\n\n```json\n{\n    \"message\": \"此團購已截止或已取消。\",\n    \"errors\": {}\n}\n```\n\n驗證錯誤則會自動回傳 422 和欄位明細：\n\n```json\n{\n    \"message\": \"The title field is required.\",\n    \"errors\": {\n        \"title\": [\"The title field is required.\"],\n        \"deadline\": [\"The deadline must be a date after now.\"]\n    }\n}\n```\n\n## 小結：一套後端，多個前端\n\n這一章我們把揪好買從「只服務瀏覽器」升級成「服務所有人」：\n\n**API 基礎：**\n- `php artisan install:api` 啟用 API 路由和 Sanctum\n- `routes/api.php` 是 stateless 的——沒有 Session、沒有 CSRF，靠 Token 認證\n- API Resource 精準控制 JSON 輸出，搭配 Pagination 自動生成分頁資訊\n\n**認證與安全：**\n- Sanctum Token 認證——`createToken()` 建立、`Bearer` Header 攜帶、`delete()` 撤銷\n- Token Abilities 做細粒度權限控制\n- Rate Limiting 防止 API 被濫用——已登入使用者和匿名使用者各自獨立限制\n\n**實務面：**\n- CORS 設定讓 SPA 前端能跨域存取\n- Scramble 自動生成 API 文件，零設定\n- LINE Bot webhook 概念——同一個 Laravel 專案服務所有消費者\n- API 版本管理 YAGNI——等需要時再加\n\n現在揪好買有了完整的 API 端點，手機 App 可以呼叫、LINE Bot 可以整合、第三方系統可以串接。但平台越來越大，開團主需要看統計報表，管理員需要後台管理介面，團購列表開始變慢——下一章，我們要用 **[Filament 3 快速建立管理後台](/blog/laravel-guide-admin-filament-advanced-queries/)**，並深入 Eloquent 進階查詢技巧，解決真實世界的效能問題。",
      "summary": "用 Laravel 12 打造 RESTful API、以 Sanctum Token 認證保護端點，加上 Rate Limiting、API 版本管理與 CORS 設定，讓揪好買同時服務手機 App、LINE Bot 與第三方整合。",
      "image": "https://bobochen.dev/_astro/cover.Dqum1izr.webp",
      "date_published": "2025-05-13T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "API",
        "Sanctum",
        "REST",
        "LINE Bot"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-queues-events-notifications/",
      "url": "https://bobochen.dev/blog/laravel-guide-queues-events-notifications/",
      "title": "Queue 與 Event：讓耗時任務不阻塞使用者",
      "content_text": "成團後要寄 50 封信、推播、更新統計，全塞在同一個 request 會讓使用者等十秒。用 Laravel 12 的 Queue 把耗時任務丟到背景、用 Event/Listener 解耦成團後的多個動作、用 Notification 一個類別搞定 Email 與站內訊息——以「揪好買」團購平台實作完整非同步通知系統，含 queue:work 失敗重試與 Horizon 監控。",
      "content_html": "[上一章](/blog/laravel-guide-orders-stripe-cashier/)成團收款的瞬間，你需要做一堆事：寄成團確認信給所有跟團者、寄通知給開團主、更新團購狀態、生成訂單明細 PDF、紀錄 analytics 事件。如果這些全部塞在同一個 HTTP request 裡同步執行，使用者按下確認後要等五到十秒才看到回應——這種體驗在 2026 年是不能接受的。\n\nQueue（佇列）就是解決這個問題的：把不需要即時完成的工作丟到背景去跑，使用者的 request 馬上就能回應。\n\n但 Queue 只解決了「何時做」的問題。「誰該做什麼」的問題，需要 Event/Listener 模式來處理。當「團購成團」這個事件發生時，可能有五六個不同的動作要觸發——寄信、推播、更新統計、通知倉庫備貨。如果把這些全寫在 Controller 或 Service 裡，程式碼會變成一團義大利麵。\n\nEvent/Listener 讓你把「事件」和「反應」解耦：Controller 只要 dispatch 一個 `GroupBuyConfirmed` event，各個 Listener 自己知道該做什麼。新增需求？加一個 Listener 就好，不用動到原本的程式碼。\n\nLaravel 還有一個被低估的利器：Notification。它提供統一的介面來發送通知，不管你要透過 Email、SMS、Slack、站內訊息還是 push notification——同一個 Notification class 搞定所有管道。在「揪好買」裡，我們會把成團通知、出貨通知、截止提醒全部用 Notification 來實作，搭配 Queue 讓它們在背景發送。\n\n## 為什麼需要 Queue：使用者不該等你寄 Email\n\n想像這個場景：一個團購有 50 個跟團者，成團後你要寄確認信給每個人。每封信透過 SMTP 發送大約需要 0.5 秒，50 封就是 25 秒。如果是同步執行，使用者按下「確認成團」之後，要盯著 loading 轉圈 25 秒才能看到結果。\n\n```text\n同步處理（Synchronous）：\n使用者按下確認 → 寄信給 A（0.5s）→ 寄信給 B（0.5s）→ ... → 寄信給第 50 人（0.5s）→ 回應使用者\n                                        總共 25 秒 ⏳\n\n非同步處理（Asynchronous with Queue）：\n使用者按下確認 → 把 50 封信丟進 Queue → 回應使用者 ✅（0.1s）\n                    ↓\n              背景 Worker 慢慢寄，使用者不用等\n```\n\n這就是 Queue 的核心價值：**把不需要使用者等待的工作延遲到背景執行**。常見的適用場景：\n\n- **寄送 Email / SMS / 推播通知**——使用者不需要等你跟 SMTP Server 握手\n- **生成 PDF 或 Excel 報表**——耗時的運算不該卡住 request\n- **呼叫第三方 API**——外部服務的回應時間你控制不了\n- **圖片處理**——裁切、壓縮、上傳到 CDN\n- **資料同步**——把訂單資料推到 ERP 或會計系統\n\n不過在你決定全部丟 Queue 之前，先把帳算清楚。Queue 不是免費的：你的系統會從「一個 PHP process 收 request、回 response」變成「還要養一個常駐 worker、設 Supervisor 讓它掛掉自動拉起、加監控、部署時記得 `php artisan queue:restart`」的分散式架構。對流量很小的專案，這個維運成本常常大於「使用者少等三秒」省下來的時間——這時候同步處理加一個 loading 轉圈，反而更簡單、更可靠、半夜也不會出事。而且非同步多了一個很容易忽略的 failure mode：worker 沒在跑的時候，任務不會報錯，它只是安靜地躺在佇列裡不動，通知就默默沒送出去，沒人會發現。同步至少會當場噴錯給你看。所以這章後面我會花篇幅講 Supervisor 跟失敗監控，不是順帶提一下——那是用 Queue 的入場費，不是加分題。\n\n如果你用過 Node.js，你可能會想：「JavaScript 本來就是非同步的啊，用 `Promise` 不就好了？」問題在於，PHP 的每個 request 是獨立的 process。request 結束了，process 就結束了，不會有「背景繼續跑」的機會。\n\nQueue 的做法是把任務序列化後存到某個地方（資料庫、Redis、SQS），然後由獨立的 worker process 去取出來執行。概念上類似 Python 的 Celery 或 Node.js 的 Bull/BullMQ。\n\n## Queue 概念與 Driver 選擇\n\nLaravel 的 Queue 系統支援多種 driver（驅動），讓你根據環境和規模選擇適合的後端：\n\n| Driver     | 適用場景          | 優點                     | 缺點                   |\n| ---------- | ----------------- | ------------------------ | ---------------------- |\n| `sync`     | 本地開發/除錯     | 同步執行，方便 debug     | 不是真的 queue，會阻塞 |\n| `database` | 小型專案/開發環境 | 不需額外服務             | 效能較差，polling 機制 |\n| `redis`    | **正式環境首選**  | 快、支援優先級、延遲任務 | 需要 Redis 服務        |\n| `sqs`      | AWS 大規模部署    | 完全託管、自動擴展       | 綁定 AWS               |\n\n在「揪好買」的開發環境，我們用 `database` driver 就夠了——不需要額外安裝 Redis，資料庫裡開一張 table 就能跑。正式環境再切到 `redis`，只要改一行 `.env` 設定。\n\n### 設定 Database Queue Driver\n\n```bash\n# .env\nQUEUE_CONNECTION=database\n```\n\n建立 Queue 需要的資料表：\n\n```bash\nphp artisan queue:table\nphp artisan migrate\n```\n\n這會建立一張 `jobs` table，Queue 把待執行的任務序列化後存在這裡。若需要批次處理（Job Batching），另外執行 `php artisan queue:batches-table` 建立 `job_batches` table。另外，我們還需要一張 `failed_jobs` table 來記錄失敗的任務：\n\n```bash\nphp artisan queue:failed-table\nphp artisan migrate\n```\n\n> **提示：** Laravel 12 的新專案預設就會幫你建好這些 migration。如果你是從舊版升級，才需要手動執行上面的指令。\n\n## Job 類別：把任務包裝起來\n\nQueue 裡的每個任務就是一個 Job class。用 Artisan 指令快速生成：\n\n```bash\nphp artisan make:job ProcessGroupBuyConfirmation\n```\n\n這會在 `app/Jobs/` 目錄下建立一個檔案：\n\n```php\n<?php\n\nnamespace App\\Jobs;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Queue\\InteractsWithQueue;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass ProcessGroupBuyConfirmation implements ShouldQueue\n{\n    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;\n\n    public function __construct(\n        public readonly GroupBuy $groupBuy,\n    ) {}\n\n    public function handle(): void\n    {\n        // 更新團購狀態\n        $this->groupBuy->update(['status' => 'confirmed']);\n\n        // 生成訂單明細 PDF\n        $pdf = PDF::loadView('pdf.group-buy-summary', [\n            'groupBuy' => $this->groupBuy,\n            'participants' => $this->groupBuy->participants()->with('orders')->get(),\n        ]);\n\n        Storage::put(\n            \"summaries/{$this->groupBuy->id}.pdf\",\n            $pdf->output()\n        );\n    }\n}\n```\n\n幾個關鍵點：\n\n- **`implements ShouldQueue`**——這個 interface 告訴 Laravel：「這個 Job 要丟到 Queue 裡背景執行」。如果拿掉它，Job 會同步執行（跟沒有 Queue 一樣）。\n- **`SerializesModels`**——這個 trait 會自動序列化 Eloquent Model 的 ID，然後在 worker 端重新從資料庫取出完整的 Model。避免把整個 Model 物件塞進 Queue（那會很大，而且資料可能過時）。\n- **`handle()` 方法**——worker 從 Queue 取出任務後，就是執行這個方法。你的業務邏輯寫在這裡。\n\n### 分派 Job\n\n在 Controller 或 Service 裡，把 Job 丟進 Queue：\n\n```php\n// 最常用的方式\nProcessGroupBuyConfirmation::dispatch($groupBuy);\n\n// 延遲執行：5 分鐘後才執行\nProcessGroupBuyConfirmation::dispatch($groupBuy)\n    ->delay(now()->addMinutes(5));\n\n// 指定 Queue 名稱（用來區分優先級）\nProcessGroupBuyConfirmation::dispatch($groupBuy)\n    ->onQueue('high');\n\n// 用 dispatch helper function\ndispatch(new ProcessGroupBuyConfirmation($groupBuy));\n```\n\n### 重試與超時設定\n\nJob 失敗時的行為可以直接在 class 上設定：\n\n```php\nclass ProcessGroupBuyConfirmation implements ShouldQueue\n{\n    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;\n\n    public int $tries = 3;           // 最多重試 3 次\n    public int $timeout = 60;        // 超過 60 秒就算超時\n    public int $maxExceptions = 2;   // 最多容忍 2 個例外\n\n    // 退避策略：第一次失敗等 10 秒、第二次等 30 秒、第三次等 60 秒\n    public function backoff(): array\n    {\n        return [10, 30, 60];\n    }\n\n    // ...\n}\n```\n\n### Job Middleware\n\n如果你需要防止同一個 Job 重複執行（例如同一個團購的確認信不該寄兩次），可以用 Job Middleware：\n\n```php\nuse Illuminate\\Queue\\Middleware\\WithoutOverlapping;\n\npublic function middleware(): array\n{\n    return [\n        new WithoutOverlapping($this->groupBuy->id),\n    ];\n}\n```\n\n`WithoutOverlapping` 會用 lock 機制確保同一個 key 的 Job 不會同時執行。在「揪好買」裡，用團購 ID 當 key 是最直覺的選擇。\n\n## Event 與 Listener：解耦業務邏輯\n\nQueue 解決了「非同步執行」的問題，但還有另一個問題：**當一個動作需要觸發多個後續動作時，程式碼要怎麼組織？**\n\n不好的寫法是把所有邏輯塞在 Controller：\n\n```php\n// ❌ 不好：Controller 變成 God Object\nclass GroupBuyController extends Controller\n{\n    public function confirm(GroupBuy $groupBuy)\n    {\n        $groupBuy->update(['status' => 'confirmed']);\n\n        // 寄確認信給跟團者\n        foreach ($groupBuy->participants as $user) {\n            Mail::to($user)->send(new GroupBuyConfirmedMail($groupBuy));\n        }\n\n        // 寄摘要給開團主\n        Mail::to($groupBuy->organizer)->send(new OrganizerSummaryMail($groupBuy));\n\n        // 更新統計\n        $groupBuy->increment('confirmed_count');\n\n        // 通知倉庫備貨\n        Http::post('https://warehouse-api.example.com/prepare', [...]);\n\n        // 紀錄 analytics\n        Analytics::track('group_buy_confirmed', [...]);\n\n        return redirect()->route('group-buys.show', $groupBuy);\n    }\n}\n```\n\n問題在哪？每次新增需求（例如「成團後也要發 LINE 通知」），你都要改這個 Controller。這違反了開放封閉原則（Open/Closed Principle）——**對擴展開放，對修改封閉**。\n\nEvent/Listener 模式的做法是：Controller 只做一件事——dispatch 一個 Event。其他的後續動作由各自的 Listener 處理：\n\n```php\n// ✅ 好：Controller 只 dispatch event\nclass GroupBuyController extends Controller\n{\n    public function confirm(GroupBuy $groupBuy)\n    {\n        $groupBuy->update(['status' => 'confirmed']);\n\n        event(new GroupBuyConfirmed($groupBuy));\n\n        return redirect()->route('group-buys.show', $groupBuy);\n    }\n}\n```\n\n### 建立 Event\n\n```bash\nphp artisan make:event GroupBuyConfirmed\n```\n\n```php\n<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Broadcasting\\InteractsWithSockets;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass GroupBuyConfirmed\n{\n    use Dispatchable, InteractsWithSockets, SerializesModels;\n\n    public function __construct(\n        public readonly GroupBuy $groupBuy,\n    ) {}\n}\n```\n\nEvent class 本身很單純——它就是一個資料容器，攜帶事件發生時的相關資訊。不需要任何業務邏輯。\n\n### 建立 Listener\n\n```bash\nphp artisan make:listener SendConfirmationToParticipants --event=GroupBuyConfirmed\nphp artisan make:listener SendOrganizerSummary --event=GroupBuyConfirmed\nphp artisan make:listener UpdateGroupBuyStats --event=GroupBuyConfirmed\n```\n\n```php\n<?php\n\nnamespace App\\Listeners;\n\nuse App\\Events\\GroupBuyConfirmed;\nuse App\\Notifications\\GroupBuyConfirmedNotification;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nclass SendConfirmationToParticipants implements ShouldQueue\n{\n    public function handle(GroupBuyConfirmed $event): void\n    {\n        $groupBuy = $event->groupBuy;\n\n        foreach ($groupBuy->participants as $user) {\n            $user->notify(new GroupBuyConfirmedNotification($groupBuy));\n        }\n    }\n}\n```\n\n注意 `implements ShouldQueue`——加上這個 interface，Listener 就會自動在背景執行。不加的話就是同步執行。你可以根據每個 Listener 的特性來決定：寄信要 Queue、更新資料庫統計可以同步。\n\n> **背景化之前先想清楚 debug 怎麼辦**\n>\n> 「寄信要 Queue」這句話我得補一個但書。同步寄信失敗時，stack trace 直接打在那個 request 上，使用者當場看到錯誤、你的 log 也立刻有紀錄。一旦丟進 Queue，失敗就搬到另一個 process、另一個時間點發生了——使用者那邊畫面顯示「確認成功」，信其實沒寄出去，而且沒人會知道。這個「使用者以為成功、實際上失敗」的落差會無聲累積，等客訴進來才發現往往已經漏掉一堆。\n>\n> 所以非同步化跟「主動建可觀測性」是綁在一起的，不能只做前者：至少要對 `failed_jobs` 設告警，正式環境上 Horizon 看佇列健康度，例外接到 Sentry。開發期則反過來——把 `QUEUE_CONNECTION` 設成 `sync`，或用 `php artisan queue:work --once` 一次跑一個，讓失敗回到 request 生命週期裡，你才 debug 得動。背景執行很爽，但你是拿「看得見的失敗」去換的，這筆交易要心裡有數。\n\n```php\n<?php\n\nnamespace App\\Listeners;\n\nuse App\\Events\\GroupBuyConfirmed;\n\nclass UpdateGroupBuyStats\n{\n    // 沒有 implements ShouldQueue → 同步執行\n    public function handle(GroupBuyConfirmed $event): void\n    {\n        $groupBuy = $event->groupBuy;\n        $groupBuy->update([\n            'participant_count' => $groupBuy->participants()->count(),\n            'total_amount' => $groupBuy->orders()->sum('amount'),\n            'confirmed_at' => now(),\n        ]);\n    }\n}\n```\n\n### 註冊 Event 與 Listener\n\nLaravel 12 支援**自動發現**——只要 Listener 的 `handle()` 方法有正確的 type hint，Laravel 會自動把它跟 Event 配對。不需要在任何地方手動註冊。\n\n如果你需要明確控制（或是自動發現沒生效），可以在 `AppServiceProvider` 的 `boot()` 方法裡手動註冊：\n\n```php\nuse App\\Events\\GroupBuyConfirmed;\nuse App\\Listeners\\SendConfirmationToParticipants;\nuse App\\Listeners\\SendOrganizerSummary;\nuse App\\Listeners\\UpdateGroupBuyStats;\nuse Illuminate\\Support\\Facades\\Event;\n\npublic function boot(): void\n{\n    Event::listen(GroupBuyConfirmed::class, [\n        SendConfirmationToParticipants::class,\n        SendOrganizerSummary::class,\n        UpdateGroupBuyStats::class,\n    ]);\n}\n```\n\n要確認所有 Event/Listener 的對應關係，可以用：\n\n```bash\nphp artisan event:list\n```\n\n### 跨框架概念對照\n\n如果你從其他語言過來，Event/Listener 的概念不會太陌生：\n\n| 框架/語言         | 對應機制                  | 差異點                                                                 |\n| ----------------- | ------------------------- | ---------------------------------------------------------------------- |\n| **Node.js**       | `EventEmitter`            | Node 的 event 是 in-process、同步觸發；Laravel 可以選擇 Queue 背景執行 |\n| **Python/Django** | Signals（`post_save` 等） | Django 的 signal 是同步的，沒有內建的非同步機制                        |\n| **React**         | Custom Events / Context   | 前端的事件系統，概念類似但作用域不同                                   |\n| **Spring**        | `@EventListener`          | 最接近 Laravel 的做法，也支援 async                                    |\n\nLaravel 的 Event 系統最大的優勢是它跟 Queue 的深度整合——一個 `implements ShouldQueue` 就能把 Listener 從同步變成非同步，不需要額外的配置。\n\n## Notification：統一的通知發送介面\n\n你可能會想：「寄信直接用 `Mail::send()` 不就好了？幹嘛還要 Notification？」\n\n問題是，真實世界的通知不只有 Email。成團通知你可能要同時寄 Email + 存到站內訊息 + 推 LINE 通知。如果分三個地方寫，資料格式不一致，日後加一個管道就要改三個地方。\n\nNotification 的設計理念是：**一個通知類別，多種發送管道**。\n\n```bash\nphp artisan make:notification GroupBuyConfirmedNotification\n```\n\n```php\n<?php\n\nnamespace App\\Notifications;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Notifications\\Messages\\MailMessage;\nuse Illuminate\\Notifications\\Notification;\n\nclass GroupBuyConfirmedNotification extends Notification implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(\n        public readonly GroupBuy $groupBuy,\n    ) {}\n\n    /**\n     * 決定這個通知要透過哪些管道發送\n     */\n    public function via(object $notifiable): array\n    {\n        return ['mail', 'database'];\n    }\n\n    /**\n     * Email 版本\n     */\n    public function toMail(object $notifiable): MailMessage\n    {\n        return (new MailMessage)\n            ->subject(\"🎉 團購「{$this->groupBuy->title}」已成團！\")\n            ->greeting(\"Hi {$notifiable->name}，\")\n            ->line(\"好消息！你參加的團購「{$this->groupBuy->title}」已經達到最低人數，正式成團了。\")\n            ->line(\"成團人數：{$this->groupBuy->participant_count} 人\")\n            ->line(\"總金額：NT$ \" . number_format($this->groupBuy->total_amount))\n            ->action('查看團購詳情', route('group-buys.show', $this->groupBuy))\n            ->line('感謝你的參與，我們會儘快安排出貨！');\n    }\n\n    /**\n     * 站內訊息版本（存到 notifications table）\n     */\n    public function toDatabase(object $notifiable): array\n    {\n        return [\n            'group_buy_id' => $this->groupBuy->id,\n            'group_buy_title' => $this->groupBuy->title,\n            'message' => \"團購「{$this->groupBuy->title}」已成團\",\n            'type' => 'group_buy_confirmed',\n        ];\n    }\n}\n```\n\n`via()` 方法決定要用哪些管道發送。你甚至可以根據使用者的偏好動態決定：\n\n```php\npublic function via(object $notifiable): array\n{\n    $channels = ['database']; // 站內訊息一定有\n\n    if ($notifiable->email_notifications_enabled) {\n        $channels[] = 'mail';\n    }\n\n    if ($notifiable->line_token) {\n        $channels[] = 'line'; // 自訂管道\n    }\n\n    return $channels;\n}\n```\n\n### 發送通知\n\n有兩種方式：\n\n```php\n// 方式一：透過 Notifiable trait（User Model 預設就有）\n$user->notify(new GroupBuyConfirmedNotification($groupBuy));\n\n// 方式二：透過 Notification Facade（可以一次寄給多人）\nuse Illuminate\\Support\\Facades\\Notification;\n\nNotification::send(\n    $groupBuy->participants,  // Collection of users\n    new GroupBuyConfirmedNotification($groupBuy)\n);\n```\n\n方式二在「揪好買」更常用——成團的時候要一次通知所有跟團者。\n\n## Mail 通知：寄出成團確認信\n\n上面的 `toMail()` 用的是 `MailMessage` builder——它會自動套用 Laravel 內建的 Email 模板。但如果你想要更漂亮、更品牌化的 Email，可以用 Markdown 模板：\n\n```php\npublic function toMail(object $notifiable): MailMessage\n{\n    return (new MailMessage)\n        ->subject(\"🎉 團購「{$this->groupBuy->title}」已成團！\")\n        ->markdown('emails.group-buy-confirmed', [\n            'user' => $notifiable,\n            'groupBuy' => $this->groupBuy,\n            'orders' => $this->groupBuy->orders()\n                ->where('user_id', $notifiable->id)\n                ->get(),\n        ]);\n}\n```\n\n對應的 Markdown 模板 `resources/views/emails/group-buy-confirmed.blade.php`：\n\n```blade\n<x-mail::message>\n# 🎉 {{ $groupBuy->title }} 已成團！\n\nHi {{ $user->name }}，\n\n你參加的團購已經達到最低人數，正式成團了。以下是你的訂購明細：\n\n<x-mail::table>\n| 品項 | 數量 | 小計 |\n|:-----|:----:|-----:|\n@foreach ($orders as $order)\n| {{ $order->product_name }} | {{ $order->quantity }} | NT$ {{ number_format($order->amount) }} |\n@endforeach\n| **合計** | | **NT$ {{ number_format($orders->sum('amount')) }}** |\n</x-mail::table>\n\n<x-mail::button :url=\"route('group-buys.show', $groupBuy)\">\n查看團購詳情\n</x-mail::button>\n\n感謝你使用揪好買！\n\n{{ config('app.name') }}\n</x-mail::message>\n```\n\nLaravel 的 Mail Markdown 元件（`x-mail::message`、`x-mail::table`、`x-mail::button`）會自動轉換成漂亮的 HTML Email。\n\n### 開發環境測試：不要真的寄信\n\n在開發階段，你不會想真的寄 Email 出去。在 `.env` 裡設定：\n\n```bash\nMAIL_MAILER=log\n```\n\n這樣所有「寄出的」Email 都會寫到 `storage/logs/laravel.log`，你可以在 log 裡看到完整的 Email 內容，確認格式正確。\n\n另一個好用的選擇是 [Mailpit](https://mailpit.axllent.org/)——一個本地的 Email 測試伺服器，會攔截所有寄出的信，讓你在瀏覽器裡預覽：\n\n```bash\nMAIL_MAILER=smtp\nMAIL_HOST=localhost\nMAIL_PORT=1025\n```\n\n## Database 通知：站內訊息\n\n不是每個人都會看 Email。站內訊息（in-app notification）是另一個重要的通知管道。使用者登入後在右上角看到小鈴鐺和紅色數字——這就是 Database 通知的用途。\n\n### 設定 Notifications Table\n\n```bash\nphp artisan notifications:table\nphp artisan migrate\n```\n\n這會建立一張 `notifications` table，結構大概是這樣：\n\n| 欄位            | 型別      | 說明                          |\n| --------------- | --------- | ----------------------------- |\n| id              | uuid      | 通知 ID                       |\n| type            | string    | Notification class 名稱       |\n| notifiable_type | string    | 被通知的 Model（通常是 User） |\n| notifiable_id   | bigint    | 被通知的 Model ID             |\n| data            | json      | `toDatabase()` 回傳的資料     |\n| read_at         | timestamp | 已讀時間（null = 未讀）       |\n| created_at      | timestamp | 建立時間                      |\n\n### 讀取與標記已讀\n\n在 User Model 上（透過 `Notifiable` trait），你可以這樣操作：\n\n```php\n// 取得所有通知\n$user->notifications;\n\n// 取得未讀通知\n$user->unreadNotifications;\n\n// 取得未讀數量\n$user->unreadNotifications->count();\n\n// 標記單一通知為已讀\n$notification->markAsRead();\n\n// 標記所有通知為已讀\n$user->unreadNotifications->markAsRead();\n```\n\n### 在 Blade 裡顯示通知鈴鐺\n\n一個常見的 UI 實作——導覽列上的通知鈴鐺：\n\n```blade\n{{-- resources/views/components/notification-bell.blade.php --}}\n@auth\n<div class=\"relative\" x-data=\"{ open: false }\">\n    <button @click=\"open = !open\" class=\"relative p-2\">\n        {{-- 鈴鐺圖示 --}}\n        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                  d=\"M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11\n                     a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341\n                     C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436\n                     L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9\" />\n        </svg>\n\n        {{-- 未讀紅點 --}}\n        @if (auth()->user()->unreadNotifications->count() > 0)\n            <span class=\"absolute -top-1 -right-1 bg-red-500 text-white text-xs\n                         rounded-full w-5 h-5 flex items-center justify-center\">\n                {{ auth()->user()->unreadNotifications->count() }}\n            </span>\n        @endif\n    </button>\n\n    {{-- 下拉通知列表 --}}\n    <div x-show=\"open\" @click.away=\"open = false\"\n         class=\"absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg z-50\">\n        <div class=\"p-4\">\n            <h3 class=\"font-semibold mb-2\">通知</h3>\n\n            @forelse (auth()->user()->notifications()->latest()->take(10)->get() as $notification)\n                <a href=\"{{ route('notifications.read', $notification->id) }}\"\n                   class=\"block p-3 rounded hover:bg-gray-50\n                          {{ $notification->read_at ? 'opacity-60' : 'bg-blue-50' }}\">\n                    <p class=\"text-sm\">{{ $notification->data['message'] }}</p>\n                    <p class=\"text-xs text-gray-400 mt-1\">\n                        {{ $notification->created_at->diffForHumans() }}\n                    </p>\n                </a>\n            @empty\n                <p class=\"text-sm text-gray-400\">暫無通知</p>\n            @endforelse\n        </div>\n    </div>\n</div>\n@endauth\n```\n\n對應的 Controller 處理「點擊通知 → 標記已讀 → 跳轉」：\n\n```php\nclass NotificationController extends Controller\n{\n    public function read(string $id)\n    {\n        $notification = auth()->user()\n            ->notifications()\n            ->findOrFail($id);\n\n        $notification->markAsRead();\n\n        // 根據通知類型跳轉到對應頁面\n        return match ($notification->data['type'] ?? null) {\n            'group_buy_confirmed' => redirect()->route(\n                'group-buys.show',\n                $notification->data['group_buy_id']\n            ),\n            default => redirect()->route('dashboard'),\n        };\n    }\n}\n```\n\n## 失敗 Job 處理與重試策略\n\nQueue 不是萬能的——Job 會失敗。SMTP Server 掛了、第三方 API 回應逾時、資料庫連線斷了，這些在分散式系統裡是家常便飯。Laravel 提供了完整的失敗處理機制。\n\n### failed_jobs Table\n\n前面我們已經建立了 `failed_jobs` table。當 Job 的重試次數耗盡仍然失敗時，它會被記錄到這張 table，包含 Job 的完整資料和錯誤訊息。\n\n### 在 Job 裡處理失敗\n\n你可以在 Job class 裡定義 `failed()` 方法，在 Job 最終失敗時做一些處理：\n\n```php\nclass ProcessGroupBuyConfirmation implements ShouldQueue\n{\n    // ...\n\n    public function failed(\\Throwable $exception): void\n    {\n        // 通知開發者\n        Log::critical('團購確認處理失敗', [\n            'group_buy_id' => $this->groupBuy->id,\n            'error' => $exception->getMessage(),\n        ]);\n\n        // 通知開團主處理異常\n        $this->groupBuy->organizer->notify(\n            new ProcessingFailedNotification($this->groupBuy, $exception)\n        );\n    }\n}\n```\n\n### 重試失敗的 Job\n\n```bash\n# 查看所有失敗的 Job\nphp artisan queue:failed\n\n# 重試特定 Job\nphp artisan queue:retry <job-id>\n\n# 重試所有失敗的 Job\nphp artisan queue:retry all\n\n# 刪除特定失敗 Job\nphp artisan queue:forget <job-id>\n\n# 清空所有失敗 Job\nphp artisan queue:flush\n```\n\n### 退避策略的最佳實踐\n\n不要用固定的重試間隔。如果 SMTP Server 掛了，每 5 秒重試一次只會浪費資源。用**指數退避**（Exponential Backoff）：\n\n```php\npublic function backoff(): array\n{\n    return [10, 60, 300]; // 10 秒、1 分鐘、5 分鐘\n}\n```\n\n如果 Job 涉及第三方 API 呼叫，加上 Rate Limiting middleware：\n\n```php\nuse Illuminate\\Queue\\Middleware\\RateLimited;\n\npublic function middleware(): array\n{\n    return [\n        new RateLimited('external-api'),\n    ];\n}\n```\n\n在 `AppServiceProvider` 裡定義 rate limiter：\n\n```php\nuse Illuminate\\Cache\\RateLimiting\\Limit;\nuse Illuminate\\Support\\Facades\\RateLimiter;\n\npublic function boot(): void\n{\n    RateLimiter::for('external-api', function (object $job) {\n        return Limit::perMinute(30);\n    });\n}\n```\n\n### Laravel Horizon 簡介\n\n當你的 Queue 規模成長到需要監控時，[Laravel Horizon](https://laravel.com/docs/horizon) 是官方提供的 Redis Queue 儀表板。它提供：\n\n- 即時監控 Job 的吞吐量和執行時間\n- 查看失敗 Job 的詳細錯誤\n- 自動調整 worker 數量\n- 基於 tag 的 Job 搜尋和過濾\n\n安裝很簡單：\n\n```bash\ncomposer require laravel/horizon\nphp artisan horizon:install\nphp artisan horizon\n```\n\n然後在瀏覽器開啟 `/horizon` 就能看到漂亮的監控介面。在「揪好買」正式上線後，Horizon 會是你觀察系統健康狀況的重要工具。不過在開發階段，用 `php artisan queue:work` 就夠了。\n\n## 實作：揪好買的通知系統\n\n把前面學到的全部串起來。以下是「揪好買」在團購成團時的完整通知流程：\n\n### 第一步：定義 Event\n\n```php\n// app/Events/GroupBuyConfirmed.php\n<?php\n\nnamespace App\\Events;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\nuse Illuminate\\Queue\\SerializesModels;\n\nclass GroupBuyConfirmed\n{\n    use Dispatchable, SerializesModels;\n\n    public function __construct(\n        public readonly GroupBuy $groupBuy,\n    ) {}\n}\n```\n\n### 第二步：建立三個 Listener\n\n**Listener 1：通知所有跟團者**\n\n```php\n// app/Listeners/SendConfirmationToParticipants.php\n<?php\n\nnamespace App\\Listeners;\n\nuse App\\Events\\GroupBuyConfirmed;\nuse App\\Notifications\\GroupBuyConfirmedNotification;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Support\\Facades\\Notification;\n\nclass SendConfirmationToParticipants implements ShouldQueue\n{\n    public string $queue = 'notifications';\n\n    public function handle(GroupBuyConfirmed $event): void\n    {\n        Notification::send(\n            $event->groupBuy->participants,\n            new GroupBuyConfirmedNotification($event->groupBuy)\n        );\n    }\n}\n```\n\n**Listener 2：寄摘要報告給開團主**\n\n```php\n// app/Listeners/SendOrganizerSummary.php\n<?php\n\nnamespace App\\Listeners;\n\nuse App\\Events\\GroupBuyConfirmed;\nuse App\\Notifications\\OrganizerSummaryNotification;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nclass SendOrganizerSummary implements ShouldQueue\n{\n    public string $queue = 'notifications';\n\n    public function handle(GroupBuyConfirmed $event): void\n    {\n        $groupBuy = $event->groupBuy;\n\n        $groupBuy->organizer->notify(\n            new OrganizerSummaryNotification($groupBuy)\n        );\n    }\n}\n```\n\n**Listener 3：更新統計資料（同步）**\n\n```php\n// app/Listeners/UpdateGroupBuyStats.php\n<?php\n\nnamespace App\\Listeners;\n\nuse App\\Events\\GroupBuyConfirmed;\n\nclass UpdateGroupBuyStats\n{\n    public function handle(GroupBuyConfirmed $event): void\n    {\n        $groupBuy = $event->groupBuy;\n\n        $groupBuy->update([\n            'participant_count' => $groupBuy->participants()->count(),\n            'total_amount' => $groupBuy->orders()->sum('amount'),\n            'confirmed_at' => now(),\n        ]);\n    }\n}\n```\n\n### 第三步：在 Service 裡觸發 Event\n\n```php\n// app/Services/GroupBuyService.php\n<?php\n\nnamespace App\\Services;\n\nuse App\\Events\\GroupBuyConfirmed;\nuse App\\Models\\GroupBuy;\n\nclass GroupBuyService\n{\n    public function confirm(GroupBuy $groupBuy): void\n    {\n        // 確認條件檢查\n        throw_unless(\n            $groupBuy->canBeConfirmed(),\n            \\DomainException::class,\n            '此團購不符合成團條件'\n        );\n\n        $groupBuy->update(['status' => 'confirmed']);\n\n        // 就這一行，所有後續動作都會自動觸發\n        event(new GroupBuyConfirmed($groupBuy));\n    }\n}\n```\n\n> **如果這段被包在 transaction 裡，這個 race condition 會咬你**\n>\n> 上面的 `confirm()` 沒包 transaction 還算安全，但實務上你很可能會把「更新狀態 + 寫幾張關聯表」一起包進 `DB::transaction()`。問題來了：Listener 用 `SerializesModels` 只存了 model 的 ID，到 worker 端會重新 `find()` 一次回 DB 撈。Redis worker 很快，可能在你的 transaction 還沒 commit 的瞬間就把 job 撈走執行了——這時 DB 裡那筆 `confirmed` 資料還在你這個連線的交易裡，worker 看不到，直接吃 `ModelNotFoundException`。更陰險的是它不一定每次都中，本機跑沒事、上線高併發才偶發，超難重現。\n>\n> 解法是叫 job 等 commit 完再派：dispatch 時接 `->afterCommit()`，或在 `config/queue.php` 對應的 connection 設 `'after_commit' => true` 一勞永逸。`SerializesModels` 幫你省記憶體、避免資料過時，代價就是這個時序陷阱，兩件事是同一個機制的一體兩面，得一起記。\n\n整個流程是這樣的：\n\n```text\nController 呼叫 GroupBuyService::confirm()\n    ├── 更新 status = confirmed（同步）\n    └── dispatch GroupBuyConfirmed event\n            ├── SendConfirmationToParticipants（Queue → 寄 Email + 存站內訊息）\n            ├── SendOrganizerSummary（Queue → 寄開團主摘要信）\n            └── UpdateGroupBuyStats（同步 → 更新統計欄位）\n```\n\n### 第四步：截止提醒通知（Scheduled）\n\n除了成團通知，「揪好買」還需要在截止前提醒跟團者。這個用 Laravel 的 Scheduler 搭配 Notification：\n\n```php\n// app/Notifications/GroupBuyDeadlineReminder.php\n<?php\n\nnamespace App\\Notifications;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Bus\\Queueable;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Notifications\\Messages\\MailMessage;\nuse Illuminate\\Notifications\\Notification;\n\nclass GroupBuyDeadlineReminder extends Notification implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(\n        public readonly GroupBuy $groupBuy,\n    ) {}\n\n    public function via(object $notifiable): array\n    {\n        return ['mail', 'database'];\n    }\n\n    public function toMail(object $notifiable): MailMessage\n    {\n        $hoursLeft = (int) now()->diffInHours($this->groupBuy->deadline, true); // Carbon 3（Laravel 11/12）的 diffInHours 預設回傳帶正負號的 float，第二參數 $absolute=true 取絕對值並 cast int 確保顯示正確小時數\n\n        return (new MailMessage)\n            ->subject(\"⏰ 團購「{$this->groupBuy->title}」即將截止\")\n            ->line(\"你參加的團購還有 {$hoursLeft} 小時就要截止了。\")\n            ->line(\"目前人數：{$this->groupBuy->participants()->count()} / {$this->groupBuy->min_participants}\")\n            ->action('查看團購', route('group-buys.show', $this->groupBuy))\n            ->line('趕快分享給朋友一起揪團吧！');\n    }\n\n    public function toDatabase(object $notifiable): array\n    {\n        return [\n            'group_buy_id' => $this->groupBuy->id,\n            'group_buy_title' => $this->groupBuy->title,\n            'message' => \"團購「{$this->groupBuy->title}」即將截止\",\n            'type' => 'deadline_reminder',\n        ];\n    }\n}\n```\n\n在 `routes/console.php` 裡設定排程：\n\n```php\nuse App\\Models\\GroupBuy;\nuse App\\Notifications\\GroupBuyDeadlineReminder;\nuse Illuminate\\Support\\Facades\\Notification;\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::call(function () {\n    // 找出 24 小時內即將截止的團購\n    $groupBuys = GroupBuy::where('status', 'open')\n        ->whereBetween('deadline', [now(), now()->addHours(24)])\n        ->whereNull('reminder_sent_at')\n        ->get();\n\n    foreach ($groupBuys as $groupBuy) {\n        Notification::send(\n            $groupBuy->participants,\n            new GroupBuyDeadlineReminder($groupBuy)\n        );\n\n        $groupBuy->update(['reminder_sent_at' => now()]);\n    }\n})->hourly()->name('group-buy-deadline-reminders');\n```\n\n### 啟動 Worker\n\n所有 Queue 裡的 Job 需要一個 worker process 來處理。開發時直接在終端機執行：\n\n```bash\n# 啟動 worker\nphp artisan queue:work\n\n# 指定處理特定 queue\nphp artisan queue:work --queue=notifications,default\n\n# 處理一個 job 後就停止（適合測試）\nphp artisan queue:work --once\n\n# 設定記憶體限制和超時\nphp artisan queue:work --memory=256 --timeout=120\n```\n\n> **注意：** `queue:work` 是長時間執行的 process。在開發時改了 Job 的程式碼，需要重啟 worker 才會載入新的程式碼。可以用 `php artisan queue:restart` 優雅地重啟，或者在開發時用 `queue:listen`——它每次都會重新載入程式碼（但效能較差，僅限開發使用）。\n\n在正式環境，你會用 process manager（如 Supervisor）來確保 worker 持續運行：\n\n```ini\n# /etc/supervisor/conf.d/jiuhaobuy-worker.conf\n[program:jiuhaobuy-worker]\nprocess_name=%(program_name)s_%(process_num)02d\ncommand=php /var/www/jiuhaobuy/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600\nautostart=true\nautorestart=true\nstopasgroup=true\nkillasgroup=true\nuser=www-data\nnumprocs=2\nredirect_stderr=true\nstdout_logfile=/var/www/jiuhaobuy/storage/logs/worker.log\nstopwaitsecs=3600\n```\n\n## 小結：非同步思維讓應用更健壯\n\n這一章我們學了三個核心概念，各自解決不同的問題：\n\n- **Queue**——解決「何時做」：把耗時任務丟到背景，使用者不用等。Driver 從開發用的 `database` 到正式環境的 `redis`，切換只需改一行 `.env`。\n- **Event/Listener**——解決「誰做什麼」：用事件驅動的方式解耦業務邏輯。新增需求只要加 Listener，不用改原本的程式碼。搭配 `ShouldQueue` 就能讓 Listener 在背景執行。\n- **Notification**——解決「怎麼通知」：一個 class 搞定 Email、站內訊息、SMS 等多種管道。搭配 Queue 在背景發送，搭配 Database driver 實作站內訊息。\n\n在「揪好買」裡，這三者的組合是：Controller 呼叫 Service → Service dispatch Event → Listener 在背景透過 Notification 發送多管道通知。整個流程清晰、解耦、可擴展。\n\n現在你的後端已經能優雅地處理背景任務和通知了。但你的系統目前只服務網頁使用者。[下一章](/blog/laravel-guide-api-sanctum-rest/)，我們要把揪好買的核心功能包裝成 RESTful API，用 Sanctum 做 Token 認證，讓手機 App 和 LINE Bot 也能開團、跟團、查詢訂單——同一套後端，服務所有人。",
      "summary": "成團後要寄 50 封信、推播、更新統計，全塞在同一個 request 會讓使用者等十秒。用 Laravel 12 的 Queue 把耗時任務丟到背景、用 Event/Listener 解耦成團後的多個動作、用 Notification 一個類別搞定 Email 與站內訊息——以「揪好買」團購平台實作完整非同步通知系統，含 queue:work 失敗重試與 Horizon 監控。",
      "image": "https://bobochen.dev/_astro/cover.DxBTuiJw.webp",
      "date_published": "2025-05-06T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Queue",
        "Event",
        "Notification",
        "Mail"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-orders-stripe-cashier/",
      "url": "https://bobochen.dev/blog/laravel-guide-orders-stripe-cashier/",
      "title": "訂單與金流：成團後用 Cashier 串接 Stripe 收款",
      "content_text": "用 Laravel Cashier 串接 Stripe，從成團確認到收款的完整結帳流程：Checkout Session、Webhook 簽名驗證、訂單狀態機與 DB Transaction，含 Stripe 測試模式與本地 Webhook 測試。",
      "content_html": "成團了，然後呢？錢要怎麼收？這是每個電商類專案最讓人緊張的環節。金流處理不像一般的 CRUD，有幾個雷你得事先知道：\n\n- **信用卡號不能自己存**——PCI DSS 合規不是鬧著玩的\n- **付款失敗要能重試**——使用者的卡可能被拒、餘額不足\n- **Webhook 要正確處理**——Stripe 的回呼打來時，你的程式碼必須正確回應\n- **訂單狀態要明確流轉**——從「建立 → 付款中 → 已付款 → 已完成」，每個轉換都要受控\n\n任何一個環節出錯，輕則使用者體驗差，重則真的會出財務問題。\n\n好在 Laravel Cashier 把 Stripe 整合這件事做得非常優雅。它幫你處理了客戶（Customer）建立、Checkout Session 流程、Webhook 驗證與分發、訂閱管理這些繁瑣的底層工作，讓你可以專注在自己的業務邏輯上。你不需要自己去讀 Stripe API 文件的每一頁——Cashier 已經幫你封裝好了最常用的操作。當然，理解底層原理還是很重要的，所以這一章我們會先搞懂金流處理的基本觀念，再一步步把 Cashier 接進來。\n\n在「揪好買」裡，金流的觸發時機跟一般電商不一樣——不是使用者按下「購買」就收錢，而是等到成團確認後，才統一向所有跟團者收款。這代表我們需要一個清楚的訂單狀態機，還要處理「成團了但某個人付款失敗」的 edge case。這一章會完整走過這個流程。\n\n## 金流處理的基本觀念：為什麼 Stripe 不讓你自己存信用卡號\n\n讓我先講一個會讓你嚇出冷汗的事實：如果你自己在資料庫裡存信用卡號，你需要通過 **PCI DSS**（Payment Card Industry Data Security Standard）合規認證。這個認證要求包含——但不限於——專用的加密硬體、年度安全稽核、滲透測試、嚴格的存取控制。完整的 Level 1 合規認證每年的費用可以到幾十萬美金。\n\n所以答案很簡單：**不要自己存信用卡號**。讓專業的支付處理商（Stripe、綠界 ECPay、藍新 NewebPay）來處理。\n\n金流的核心概念是**轉嫁責任**。整個付款流程長這樣：\n\n```\n1. 使用者在你的網站按下「付款」\n2. 你的伺服器向 Stripe 建立一個 Checkout Session\n3. 使用者被導向到 Stripe 的付款頁面（hosted page）\n4. 使用者在 Stripe 的頁面輸入信用卡資訊\n5. Stripe 處理付款\n6. Stripe 透過 webhook 通知你的伺服器「付款成功了」\n7. 你的伺服器更新訂單狀態\n```\n\n注意：信用卡資訊**從頭到尾都不經過你的伺服器**。使用者是在 Stripe 的頁面上輸入的。你的伺服器只收到「付款成功」或「付款失敗」的通知——不會碰到任何卡號資訊。這就是 Stripe Checkout 的精髓。\n\n### 跨服務對照\n\n| 概念          | Stripe           | 綠界 ECPay          | 藍新 NewebPay       |\n| ------------- | ---------------- | ------------------- | ------------------- |\n| Hosted 付款頁 | Checkout Session | 付款頁（AIO）       | MPG 多功能收款      |\n| 付款結果通知  | Webhook          | 付款完成通知        | 背景通知 NotifyURL  |\n| 客戶端套件    | Stripe.js        | N/A                 | N/A                 |\n| Laravel 套件  | Laravel Cashier  | 自行串接 / 社群套件 | 自行串接 / 社群套件 |\n\n> **為什麼選 Stripe？（先講一個會卡死你的前提）** 本章用 Stripe，純粹因為它有官方 Laravel 套件（Cashier）、文件最齊全、測試工具最好用，最適合「學金流概念」。但有件事我必須先講清楚，免得你照做到最後才發現收不到錢：Stripe 目前不支援台灣境內公司直接開戶收款（台灣不在它的支援國家清單內），真要收到台幣帳戶，得繞道去註冊美國公司、辦 EIN 那一整套——對一個只想做台灣團購平台的人來說，這成本不合理。所以請把這章當成「拿 Stripe 學概念」，真要在台灣上線收款，請改用綠界 ECPay 或藍新 NewebPay，它們才支援台灣公司行號跟在地金流（ATM、超商代碼、Line Pay）。好消息是：這裡學到的 webhook 驗證、訂單狀態機、DB transaction 邏輯，換成台灣金流幾乎照搬，所以這章不算白學——只是別把「學習情境」當成「能上線收錢」。\n\n## Laravel Cashier 與 Stripe 整合\n\nLaravel Cashier 是 Laravel 官方維護的 Stripe 整合套件。它封裝了最常用的 Stripe 操作——建立 Customer、Checkout Session、處理 Webhook——讓你用優雅的 PHP 語法完成金流串接。\n\n### 安裝\n\n```bash\ncomposer require laravel/cashier\n```\n\n### 執行 Migration\n\nCashier 的 migration 需要先發佈到 `database/migrations/`（Laravel 11+ 起 Cashier 不再自動載入 migration），再執行 migrate，才會在你的 `users` 表加上 Stripe 相關的欄位：\n\n```bash\nphp artisan vendor:publish --tag=\"cashier-migrations\"\nphp artisan migrate\n```\n\n這會新增以下欄位到 `users` 表：\n\n| 欄位            | 用途                                    |\n| --------------- | --------------------------------------- |\n| `stripe_id`     | 使用者在 Stripe 的 Customer ID          |\n| `pm_type`       | 預設付款方式類型（visa, mastercard...） |\n| `pm_last_four`  | 信用卡末四碼                            |\n| `trial_ends_at` | 試用期結束時間（訂閱制用）              |\n\n### 設定 Billable Trait\n\n在 User Model 上加入 `Billable` trait：\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse Laravel\\Cashier\\Billable;\nuse Illuminate\\Foundation\\Auth\\User as Authenticatable;\n\nclass User extends Authenticatable\n{\n    use Billable;\n\n    // ... 其他程式碼\n}\n```\n\n`Billable` trait 賦予 User Model 一系列 Stripe 相關的方法——`checkout()`、`charge()`、`subscription()` 等等。加了這一行，你的 User 就能直接跟 Stripe 互動。\n\n## Stripe 帳號設定與測試模式\n\n### 建立 Stripe 帳號\n\n到 [stripe.com](https://stripe.com) 註冊帳號。不需要填信用卡，開發階段全程用測試模式。\n\n在 Dashboard 的 **Developers → API keys** 取得你的測試金鑰：\n\n- **Publishable key**：`pk_test_...`（前端用，可以公開）\n- **Secret key**：`sk_test_...`（後端用，絕對不能公開）\n\n### 設定 .env\n\n```bash\nSTRIPE_KEY=pk_test_51ABC...\nSTRIPE_SECRET=sk_test_51ABC...\nSTRIPE_WEBHOOK_SECRET=whsec_...\n```\n\n> **千萬不要把 Secret key 提交到 Git。** `.env` 已經在 `.gitignore` 裡了，但還是提醒一下——這種 key 外流的事件每個月都在發生。\n\n### Stripe 測試卡號\n\nStripe 提供一整套測試用的卡號，讓你不用刷真的信用卡就能測試各種場景：\n\n| 卡號                  | 行為                   |\n| --------------------- | ---------------------- |\n| `4242 4242 4242 4242` | 付款成功               |\n| `4000 0000 0000 3220` | 需要 3D Secure 驗證    |\n| `4000 0000 0000 0002` | 付款被拒（卡片被拒絕） |\n| `4000 0000 0000 9995` | 付款失敗（餘額不足）   |\n\n到期日填任何未來的日期，CVC 填任意三碼數字。\n\n### Stripe CLI：本地測試 Webhook\n\nWebhook 是 Stripe 打到**你的伺服器**的 HTTP 請求。但開發階段你的電腦通常在 NAT 後面，Stripe 打不到你。Stripe CLI 幫你解決這個問題：\n\n```bash\n# 安裝（macOS）\nbrew install stripe/stripe-cli/stripe\n\n# 登入\nstripe login\n\n# 把 Stripe 的 webhook 事件轉發到你的本地伺服器\nstripe listen --forward-to localhost:8000/stripe/webhook\n```\n\n執行後，CLI 會印出一個 `whsec_...` 的 webhook signing secret，把它填到 `.env` 的 `STRIPE_WEBHOOK_SECRET`。\n\n開另一個終端機觸發測試事件：\n\n```bash\nstripe trigger checkout.session.completed\n```\n\n你會看到 CLI 印出事件轉發的紀錄，而你的 Laravel 應用也會收到對應的 webhook。\n\n## 訂單 Model 設計：Order 與 OrderItem\n\n在接 Stripe 之前，先把訂單的資料結構搞定。揪好買的訂單跟一般電商略有不同：一個訂單對應一個使用者在一個團購裡的跟團記錄。\n\n### 建立 Migration 與 Model\n\n```bash\nphp artisan make:model Order -mf\nphp artisan make:model OrderItem -mf\n```\n\n### Order Migration\n\n```php\n<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('orders', function (Blueprint $table) {\n            $table->id();\n            $table->foreignId('user_id')->constrained()->cascadeOnDelete();\n            $table->foreignId('group_buy_id')->constrained()->cascadeOnDelete();\n            $table->integer('total');                         // 總金額（分）\n            $table->string('status')->default('pending');     // pending, paid, failed, refunded\n            $table->string('stripe_checkout_session_id')->nullable();\n            $table->string('stripe_payment_intent_id')->nullable();\n            $table->timestamp('paid_at')->nullable();\n            $table->timestamps();\n\n            $table->unique(['user_id', 'group_buy_id']);      // 一個使用者在一個團購只有一筆訂單\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('orders');\n    }\n};\n```\n\n### OrderItem Migration\n\n```php\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('order_items', function (Blueprint $table) {\n            $table->id();\n            $table->foreignId('order_id')->constrained()->cascadeOnDelete();\n            $table->string('product_name');\n            $table->integer('quantity');\n            $table->integer('unit_price');     // 單價（分）\n            $table->integer('subtotal');       // 小計（分）= quantity * unit_price\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('order_items');\n    }\n};\n```\n\n### OrderStatus Enum\n\n```php\n<?php\n\n// app/Enums/OrderStatus.php\nnamespace App\\Enums;\n\nenum OrderStatus: string\n{\n    case Pending = 'pending';\n    case Paid = 'paid';\n    case Failed = 'failed';\n    case Refunded = 'refunded';\n    case Shipping = 'shipping';\n    case Completed = 'completed';\n\n    public function label(): string\n    {\n        return match ($this) {\n            self::Pending => '待付款',\n            self::Paid => '已付款',\n            self::Failed => '付款失敗',\n            self::Refunded => '已退款',\n            self::Shipping => '出貨中',\n            self::Completed => '已完成',\n        };\n    }\n\n    public function color(): string\n    {\n        return match ($this) {\n            self::Pending => 'yellow',\n            self::Paid => 'green',\n            self::Failed => 'red',\n            self::Refunded => 'gray',\n            self::Shipping => 'blue',\n            self::Completed => 'green',\n        };\n    }\n}\n```\n\n### Model 定義\n\n```php\n<?php\n\n// app/Models/Order.php\nnamespace App\\Models;\n\nuse App\\Enums\\OrderStatus;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\HasMany;\n\nclass Order extends Model\n{\n    protected $fillable = [\n        'user_id',\n        'group_buy_id',\n        'total',\n        'status',\n        'stripe_checkout_session_id',\n        'stripe_payment_intent_id',\n        'paid_at',\n    ];\n\n    protected function casts(): array\n    {\n        return [\n            'status' => OrderStatus::class,\n            'total' => 'integer',\n            'paid_at' => 'datetime',\n        ];\n    }\n\n    // ── 關聯 ──\n\n    public function user(): BelongsTo\n    {\n        return $this->belongsTo(User::class);\n    }\n\n    public function groupBuy(): BelongsTo\n    {\n        return $this->belongsTo(GroupBuy::class);\n    }\n\n    public function items(): HasMany\n    {\n        return $this->hasMany(OrderItem::class);\n    }\n}\n```\n\n```php\n<?php\n\n// app/Models/OrderItem.php\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\n\nclass OrderItem extends Model\n{\n    protected $fillable = [\n        'order_id',\n        'product_name',\n        'quantity',\n        'unit_price',\n        'subtotal',\n    ];\n\n    protected function casts(): array\n    {\n        return [\n            'quantity' => 'integer',\n            'unit_price' => 'integer',\n            'subtotal' => 'integer',\n        ];\n    }\n\n    public function order(): BelongsTo\n    {\n        return $this->belongsTo(Order::class);\n    }\n}\n```\n\n在 User Model 加上關聯：\n\n```php\n// app/Models/User.php\npublic function orders(): HasMany\n{\n    return $this->hasMany(Order::class);\n}\n```\n\n### ER 關係圖\n\n```\nusers ──────< orders >────── group_buys\n                │\n                │\n                └──────< order_items\n```\n\n- 一個 user 有多個 orders（一對多）\n- 一個 group_buy 有多個 orders（一對多）\n- 一個 order 有多個 order_items（一對多）\n\n## Database Transaction：確保資料一致性\n\n建立訂單的時候，你需要同時做好幾件事：建立 Order、建立 OrderItem、扣減庫存（如果有的話）。這些動作必須**全部成功**或**全部失敗**——不能出現「Order 建好了但 OrderItem 沒建」的殘缺狀態。\n\n這就是 Database Transaction 的用途。\n\n### DB::transaction()\n\n```php\nuse Illuminate\\Support\\Facades\\DB;\n\n$order = DB::transaction(function () use ($user, $groupBuy, $quantity) {\n    // 建立訂單\n    $order = Order::create([\n        'user_id' => $user->id,\n        'group_buy_id' => $groupBuy->id,\n        'total' => $groupBuy->price_per_unit * $quantity,\n        'status' => 'pending',\n    ]);\n\n    // 建立訂單項目\n    $order->items()->create([\n        'product_name' => $groupBuy->product_name,\n        'quantity' => $quantity,\n        'unit_price' => $groupBuy->price_per_unit,\n        'subtotal' => $groupBuy->price_per_unit * $quantity,\n    ]);\n\n    return $order;\n});\n```\n\n如果 `DB::transaction()` 裡的任何一步拋出例外，所有資料庫操作都會被**回滾**（rollback）——就像什麼都沒發生過一樣。\n\n### 失敗場景\n\n想像這個情況：`Order::create()` 成功了，但 `$order->items()->create()` 因為某個驗證錯誤失敗了。如果沒有 Transaction：\n\n- 資料庫裡有一筆 Order，但沒有對應的 OrderItem\n- 使用者看到「訂單已建立」但內容是空的\n- 你需要手動清理殘餘資料\n\n有了 Transaction，整個操作被回滾，資料庫維持乾淨。\n\n### 跨框架對照\n\n| 框架      | Transaction 語法                          |\n| --------- | ----------------------------------------- |\n| Laravel   | `DB::transaction(fn () => ...)`           |\n| Django    | `with transaction.atomic():`              |\n| Sequelize | `sequelize.transaction(async (t) => ...)` |\n| Prisma    | `prisma.$transaction([...])`              |\n\n> **什麼時候該用 Transaction？** 只要你在一個操作裡有**兩個以上**的資料庫寫入，而且它們必須同時成功或失敗，就該用 Transaction。訂單建立、轉帳、庫存異動——這些都是經典場景。\n\n## Stripe Checkout Session 流程\n\nCheckout Session 是 Stripe 的 hosted 付款頁面。你在後端建立一個 Session，Stripe 回傳一個 URL，你把使用者導過去——Stripe 處理所有付款流程，完成後把使用者導回你的網站。\n\n### 建立 Checkout Session\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Order;\nuse Illuminate\\Http\\Request;\n\nclass CheckoutController extends Controller\n{\n    public function create(Order $order)\n    {\n        // 確認訂單屬於當前使用者，且狀態是 pending\n        $this->authorize('pay', $order);\n\n        $checkout = $order->user->checkout(\n            // line items：顯示在 Stripe 付款頁上的項目\n            $order->items->map(fn ($item) => [\n                'price_data' => [\n                    'currency' => 'twd',\n                    'product_data' => [\n                        'name' => $item->product_name,\n                    ],\n                    'unit_amount' => $item->unit_price,  // 以「分」為單位（TWD 不需要換算）\n                ],\n                'quantity' => $item->quantity,\n            ])->toArray(),\n            // session options\n            [\n                'success_url' => route('checkout.success', ['order' => $order->id]) . '?session_id={CHECKOUT_SESSION_ID}',\n                'cancel_url' => route('checkout.cancel', ['order' => $order->id]),\n                'metadata' => [\n                    'order_id' => $order->id,    // 關鍵：在 webhook 裡用這個找回訂單\n                ],\n            ]\n        );\n\n        // 記錄 Session ID 到訂單\n        $order->update([\n            'stripe_checkout_session_id' => $checkout->id,\n        ]);\n\n        return redirect($checkout->url);\n    }\n}\n```\n\n這裡有幾個重點：\n\n1. **`unit_amount` 的單位**：Stripe 用的是貨幣的最小單位。對 USD 來說 `1000` = $10.00（以 cent 為單位）；對 TWD 來說 `1000` 就是 NT$1,000（因為台幣沒有「分」）。如果你的資料庫存的是台幣整數，直接給就好。\n2. **`metadata`**：自訂資料，Stripe 會在 webhook 事件裡原封不動回傳給你。我們放了 `order_id`，等收到 webhook 時就能快速找到對應的訂單。\n3. **`success_url` 和 `cancel_url`**：使用者付款成功/取消後被導回的網址。`{CHECKOUT_SESSION_ID}` 是 Stripe 的 placeholder，會被替換成真正的 Session ID。\n\n### 成功與取消頁面\n\n```php\n// routes/web.php\nRoute::middleware(['auth'])->group(function () {\n    Route::get('/checkout/{order}', [CheckoutController::class, 'create'])->name('checkout.create');\n    Route::get('/checkout/{order}/success', [CheckoutController::class, 'success'])->name('checkout.success');\n    Route::get('/checkout/{order}/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');\n});\n```\n\n```php\n// CheckoutController.php\n\npublic function success(Request $request, Order $order)\n{\n    // 注意：不要在這裡更新訂單狀態！\n    // 這只是使用者看到的頁面，真正的狀態更新要靠 webhook。\n    // 使用者可能直接複製這個 URL，不代表真的付款了。\n\n    return view('checkout.success', [\n        'order' => $order->load('items', 'groupBuy'),\n    ]);\n}\n\npublic function cancel(Request $request, Order $order)\n{\n    return view('checkout.cancel', [\n        'order' => $order->load('groupBuy'),\n    ]);\n}\n```\n\n> **非常重要：** 絕對不要在 `success_url` 的 callback 裡直接把訂單標記為已付款。使用者可以自己在瀏覽器裡輸入 success URL 而不需要真的付款。**唯一可信的付款確認來源是 Stripe 的 webhook。**\n\n## Webhook 處理：Stripe 回呼你的伺服器\n\nWebhook 的概念很簡單：當 Stripe 那邊發生了某個事件（付款成功、付款失敗、退款完成），Stripe 會主動發一個 HTTP POST 請求到你事先設定好的 URL。你的伺服器接到這個請求，就能做對應的處理。\n\n### 為什麼需要 Webhook？\n\n你可能會想：「使用者付完款被導回 success URL，我在那裡處理不就好了？」\n\n不行。有好幾個原因：\n\n1. **使用者可能關掉瀏覽器**——付完款但沒被導回你的網站。\n2. **Success URL 可以被偽造**——任何人都能在瀏覽器裡輸入那個 URL。\n3. **延遲付款**——某些付款方式（銀行轉帳、超商繳費）不是即時完成的。\n4. **Stripe 保證投遞**——如果你的伺服器暫時掛了，Stripe 會重試。\n\n### Cashier 的 Webhook 路由\n\nCashier 已經幫你註冊了一個 webhook 路由：\n\n```php\n// 自動註冊在 POST /stripe/webhook\n```\n\n你需要在 Stripe Dashboard 裡設定 webhook endpoint，指向你的 `https://your-domain.com/stripe/webhook`。\n\n> **CSRF 豁免：** Stripe 的 webhook 請求不會帶 CSRF token（因為是 Stripe 伺服器發的，不是瀏覽器）。Cashier 已經自動把 `/stripe/webhook` 排除在 CSRF 驗證之外，你不需要手動處理。\n\n### 監聽 Checkout 完成事件\n\nCashier 會自動驗證 webhook 的簽名（確認是 Stripe 發的，不是惡意攻擊者偽造的），然後把事件分發出來。你只需要監聽對應的事件：\n\n```php\n<?php\n\n// app/Listeners/HandleCheckoutSessionCompleted.php\nnamespace App\\Listeners;\n\nuse App\\Enums\\OrderStatus;\nuse App\\Models\\Order;\nuse Laravel\\Cashier\\Events\\WebhookReceived;\n\nclass HandleCheckoutSessionCompleted\n{\n    public function handle(WebhookReceived $event): void\n    {\n        // 只處理 checkout.session.completed 事件\n        if ($event->payload['type'] !== 'checkout.session.completed') {\n            return;\n        }\n\n        $session = $event->payload['data']['object'];\n        $orderId = $session['metadata']['order_id'] ?? null;\n\n        if (! $orderId) {\n            return;\n        }\n\n        $order = Order::find($orderId);\n\n        if (! $order || $order->status !== OrderStatus::Pending) {\n            return;\n        }\n\n        // 走狀態機，而不是直接 update —— 與前面定義的 markAsPaid() 一致\n        $order->markAsPaid($session['payment_intent']);\n    }\n}\n```\n\n### 註冊 Listener\n\n在 `AppServiceProvider` 的 `boot()` 方法裡，或用 `EventServiceProvider`：\n\n```php\n// app/Providers/AppServiceProvider.php\nuse App\\Listeners\\HandleCheckoutSessionCompleted;\nuse Illuminate\\Support\\Facades\\Event;\nuse Laravel\\Cashier\\Events\\WebhookReceived;\n\npublic function boot(): void\n{\n    Event::listen(WebhookReceived::class, HandleCheckoutSessionCompleted::class);\n}\n```\n\n### Webhook 簽名驗證\n\nStripe 在每個 webhook 請求的 header 裡帶了一個簽名（`Stripe-Signature`）。Cashier 會用你 `.env` 裡的 `STRIPE_WEBHOOK_SECRET` 來驗證這個簽名，確保請求真的是 Stripe 發的。\n\n如果驗證失敗（簽名不對），Cashier 會回傳 403，webhook 內容不會被處理。這是防止惡意攻擊者偽造 webhook 的關鍵安全機制——沒有這一層，任何人都能假裝 Stripe 打你的 webhook endpoint，偽造「付款成功」的通知。\n\n## 訂單狀態機：從建立到完成\n\n訂單在它的生命週期裡會經歷不同的狀態。重點是：**不是每個狀態都能轉換到任何其他狀態**。你不能把「已退款」的訂單變成「待付款」，也不能把「付款失敗」直接跳到「已完成」。\n\n### 合法的狀態轉換\n\n```\npending ──→ paid ──→ shipping ──→ completed\n   │          │\n   │          └──→ refunded\n   │\n   └──→ failed ──→ pending（重新付款）\n```\n\n### 在 Order Model 上實作狀態轉換\n\n```php\n<?php\n\n// app/Models/Order.php 加入以下方法\nuse App\\Enums\\OrderStatus;\n\nclass Order extends Model\n{\n    // ... 前面的程式碼 ...\n\n    // ── 狀態轉換 ──\n\n    private const ALLOWED_TRANSITIONS = [\n        'pending' => ['paid', 'failed'],\n        'paid' => ['shipping', 'refunded'],\n        'failed' => ['pending'],          // 重新付款\n        'shipping' => ['completed'],\n        'completed' => [],                // 終態\n        'refunded' => [],                 // 終態\n    ];\n\n    public function transitionTo(OrderStatus $newStatus): void\n    {\n        $allowed = self::ALLOWED_TRANSITIONS[$this->status->value] ?? [];\n\n        if (! in_array($newStatus->value, $allowed)) {\n            throw new \\InvalidArgumentException(\n                \"無法將訂單從「{$this->status->label()}」轉換為「{$newStatus->label()}」\"\n            );\n        }\n\n        $this->update(['status' => $newStatus]);\n    }\n\n    public function markAsPaid(string $paymentIntentId): void\n    {\n        $this->transitionTo(OrderStatus::Paid);\n\n        $this->update([\n            'stripe_payment_intent_id' => $paymentIntentId,\n            'paid_at' => now(),\n        ]);\n    }\n\n    public function markAsFailed(): void\n    {\n        $this->transitionTo(OrderStatus::Failed);\n    }\n\n    public function markAsShipping(): void\n    {\n        $this->transitionTo(OrderStatus::Shipping);\n    }\n\n    public function markAsCompleted(): void\n    {\n        $this->transitionTo(OrderStatus::Completed);\n    }\n\n    // ── 查詢 ──\n\n    public function isPaid(): bool\n    {\n        return $this->status === OrderStatus::Paid;\n    }\n\n    public function canPay(): bool\n    {\n        return $this->status === OrderStatus::Pending\n            || $this->status === OrderStatus::Failed;\n    }\n}\n```\n\n為什麼要搞這麼「囉嗦」的狀態機？因為**不合法的狀態轉換是訂單系統裡最常見的 bug**。你可能在某個 Controller 裡不小心把已退款的訂單又改成已付款，然後使用者收到錯誤的通知、財務報表數字對不上、客服接到一堆抱怨電話。把合法的轉換規則寫死在 Model 裡，任何不合法的操作都會直接拋例外——bug 在開發階段就被抓到，不會偷偷溜到正式環境。\n\n## 實作：揪好買成團後收款流程\n\n理論講完了，讓我們把所有東西串起來。揪好買的收款流程是這樣的：\n\n```\n1. 成團確認（[上一章的邏輯](/blog/laravel-guide-group-buy-logic-session/)）\n2. 為每個跟團者建立 Order（DB::transaction）\n3. 寄出付款連結給每個跟團者\n4. 跟團者點連結 → 導向 Stripe Checkout\n5. 付款完成 → Stripe 打 webhook → 更新訂單狀態\n6. 所有人都付款了 → 團購狀態改為 completed\n```\n\n### Step 1：成團後批次建立訂單\n\n```php\n<?php\n\n// app/Services/GroupBuyService.php\nnamespace App\\Services;\n\nuse App\\Enums\\OrderStatus;\nuse App\\Models\\GroupBuy;\nuse App\\Models\\Order;\nuse Illuminate\\Support\\Facades\\DB;\n\nclass GroupBuyService\n{\n    /**\n     * 成團確認：為所有跟團者建立訂單\n     */\n    public function confirm(GroupBuy $groupBuy): void\n    {\n        // 防呆：已經確認過的不能再確認\n        if ($groupBuy->status !== 'open') {\n            throw new \\RuntimeException('此團購已非開放狀態，無法確認成團');\n        }\n\n        // 防呆：人數不夠不能成團\n        if ($groupBuy->participants()->count() < $groupBuy->min_participants) {\n            throw new \\RuntimeException('跟團人數未達最低門檻');\n        }\n\n        DB::transaction(function () use ($groupBuy) {\n            // 更新團購狀態\n            $groupBuy->update(['status' => 'confirmed']);\n\n            // 為每個跟團者建立訂單\n            foreach ($groupBuy->participants as $participant) {\n                $quantity = $participant->pivot->quantity;\n\n                $order = Order::create([\n                    'user_id' => $participant->id,\n                    'group_buy_id' => $groupBuy->id,\n                    'total' => $groupBuy->price_per_unit * $quantity,\n                    'status' => OrderStatus::Pending,\n                ]);\n\n                $order->items()->create([\n                    'product_name' => $groupBuy->product_name,\n                    'quantity' => $quantity,\n                    'unit_price' => $groupBuy->price_per_unit,\n                    'subtotal' => $groupBuy->price_per_unit * $quantity,\n                ]);\n            }\n        });\n\n        // 寄出付款通知（下一章用 Queue + Notification，詳見 /blog/laravel-guide-queues-events-notifications/）\n        // event(new GroupBuyConfirmed($groupBuy));\n    }\n}\n```\n\n整個操作包在 `DB::transaction()` 裡——如果幫第五個人建立訂單的時候炸了，前四個人的訂單也會被回滾。不會出現「有些人有訂單有些人沒有」的混亂狀態。\n\n### Step 2：CheckoutController 完整實作\n\n```php\n<?php\n\n// app/Http/Controllers/CheckoutController.php\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Order;\nuse Illuminate\\Http\\Request;\n\nclass CheckoutController extends Controller\n{\n    /**\n     * 導向 Stripe Checkout 付款頁\n     */\n    public function create(Order $order)\n    {\n        $user = auth()->user();\n\n        // 確認是自己的訂單\n        if ($order->user_id !== $user->id) {\n            abort(403, '這不是你的訂單');\n        }\n\n        // 確認訂單可以付款\n        if (! $order->canPay()) {\n            return redirect()->route('orders.show', $order)\n                ->with('error', '此訂單目前無法付款');\n        }\n\n        $checkout = $user->checkout(\n            $order->items->map(fn ($item) => [\n                'price_data' => [\n                    'currency' => 'twd',\n                    'product_data' => [\n                        'name' => $item->product_name,\n                    ],\n                    'unit_amount' => $item->unit_price,\n                ],\n                'quantity' => $item->quantity,\n            ])->toArray(),\n            [\n                'success_url' => route('checkout.success', $order) . '?session_id={CHECKOUT_SESSION_ID}',\n                'cancel_url' => route('checkout.cancel', $order),\n                'metadata' => [\n                    'order_id' => $order->id,\n                ],\n            ]\n        );\n\n        $order->update([\n            'stripe_checkout_session_id' => $checkout->id,\n        ]);\n\n        return redirect($checkout->url);\n    }\n\n    /**\n     * 付款成功頁面（僅顯示用，真正的狀態更新靠 webhook）\n     */\n    public function success(Request $request, Order $order)\n    {\n        return view('checkout.success', [\n            'order' => $order->load('items', 'groupBuy'),\n        ]);\n    }\n\n    /**\n     * 使用者取消付款\n     */\n    public function cancel(Request $request, Order $order)\n    {\n        return view('checkout.cancel', [\n            'order' => $order->load('groupBuy'),\n        ]);\n    }\n}\n```\n\n### Step 3：路由設定\n\n```php\n// routes/web.php\nuse App\\Http\\Controllers\\CheckoutController;\n\nRoute::middleware(['auth', 'verified'])->group(function () {\n    // 訂單\n    Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');\n    Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');\n\n    // Checkout\n    Route::get('/checkout/{order}', [CheckoutController::class, 'create'])->name('checkout.create');\n    Route::get('/checkout/{order}/success', [CheckoutController::class, 'success'])->name('checkout.success');\n    Route::get('/checkout/{order}/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');\n});\n```\n\n### Step 4：訂單頁面 View\n\n```html\n{{-- resources/views/orders/show.blade.php --}}\n<div class=\"max-w-2xl mx-auto py-8\">\n  <h1 class=\"text-2xl font-bold mb-4\">訂單 #{{ $order->id }}</h1>\n\n  <div class=\"bg-white rounded-lg shadow p-6 mb-6\">\n    <div class=\"flex justify-between items-center mb-4\">\n      <span class=\"text-gray-600\">團購</span>\n      <span>{{ $order->groupBuy->title }}</span>\n    </div>\n\n    <div class=\"flex justify-between items-center mb-4\">\n      <span class=\"text-gray-600\">狀態</span>\n      <span\n        class=\"px-2 py-1 rounded text-sm bg-{{ $order->status->color() }}-100 text-{{ $order->status->color() }}-800\"\n      >\n        {{ $order->status->label() }}\n      </span>\n    </div>\n\n    <hr class=\"my-4\" />\n\n    @foreach ($order->items as $item)\n    <div class=\"flex justify-between items-center py-2\">\n      <span>{{ $item->product_name }} x {{ $item->quantity }}</span>\n      <span>NT$ {{ number_format($item->subtotal) }}</span>\n    </div>\n    @endforeach\n\n    <hr class=\"my-4\" />\n\n    <div class=\"flex justify-between items-center font-bold text-lg\">\n      <span>合計</span>\n      <span>NT$ {{ number_format($order->total) }}</span>\n    </div>\n  </div>\n\n  @if ($order->canPay())\n  <a\n    href=\"{{ route('checkout.create', $order) }}\"\n    class=\"block w-full text-center bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition\"\n  >\n    前往付款\n  </a>\n  @endif\n\n  @if ($order->isPaid())\n  <div class=\"text-center text-green-600 font-medium\">\n    已於 {{ $order->paid_at->format('Y/m/d H:i') }} 完成付款\n  </div>\n  @endif\n</div>\n```\n\n### Step 5：用 Stripe 測試模式跑完整流程\n\n```bash\n# 終端機 1：啟動 Laravel\nphp artisan serve\n\n# 終端機 2：啟動 Stripe CLI 轉發 webhook\nstripe listen --forward-to localhost:8000/stripe/webhook\n\n# 終端機 3：用 Tinker 模擬成團\nphp artisan tinker\n```\n\n```php\n>>> $groupBuy = GroupBuy::first();\n>>> app(GroupBuyService::class)->confirm($groupBuy);\n>>> Order::where('group_buy_id', $groupBuy->id)->count();\n// 應該等於跟團人數\n```\n\n然後用瀏覽器登入任一跟團者帳號，進入訂單頁面，點「前往付款」。你會被導到 Stripe 的付款頁面——輸入測試卡號 `4242 4242 4242 4242`，填任意到期日和 CVC，按下付款。\n\n付款成功後：\n\n1. 瀏覽器被導回 success 頁面\n2. Stripe CLI 顯示 `checkout.session.completed` 事件被轉發\n3. 訂單狀態從 `pending` 變成 `paid`\n\n```php\n# 在 Tinker 裡確認\n>>> Order::first()->fresh()->status\n// App\\Enums\\OrderStatus::Paid\n```\n\n### 處理「某個人付款失敗」\n\n團購場景的特殊挑戰：10 個人跟團，9 個付款成功，1 個付款失敗。怎麼辦？\n\n幾種策略：\n\n```php\n// 策略一：給期限，逾期自動取消\n// 可以用 Laravel Scheduler（第十二章會教）\n// 每小時檢查一次，超過 48 小時沒付款的訂單標記為 failed\n\n// 策略二：允許部分付款完成，照常出貨\n// 適合數量彈性的團購\n\n// 策略三：所有人都付款才出貨\n// 適合需要精確數量的團購\n```\n\n揪好買採用策略一——給 48 小時的付款期限，逾期自動標記為失敗，並釋放名額。具體的排程實作會在第十二章搭配 Scheduler 來做。\n\n## 小結：讓 Stripe 處理金流，你專注在產品\n\n這一章我們走過了金流整合的完整流程：\n\n- **PCI DSS 合規**——不要自己存信用卡號，讓 Stripe 處理\n- **Laravel Cashier**——`composer require laravel/cashier`，加 `Billable` trait，搞定\n- **Stripe 測試模式**——測試卡號 + Stripe CLI，完全在本地測試金流\n- **訂單設計**——Order + OrderItem，用 Enum 管理狀態，用 DB Transaction 確保一致性\n- **Checkout Session**——建立 Session → 導向 Stripe → 使用者付款 → 回到你的網站\n- **Webhook**——Stripe 打你的伺服器通知付款結果，Cashier 驗證簽名確保安全\n- **狀態機**——明確定義合法的狀態轉換，避免不合法的操作\n\n最關鍵的心法是：**不要在 success URL 的 callback 裡更新訂單狀態**。唯一可信的付款確認來源是 webhook。這不是 Laravel 的規矩，而是所有金流整合的通用原則——不管你用的是 Stripe、ECPay 還是任何其他支付服務。\n\n下一章我們要處理成團後的一連串後續動作——寄確認信、推播通知、更新統計。如果這些全部塞在同一個 HTTP request 裡，使用者要等十秒才看到回應。[**Queue 與 Event**](/blog/laravel-guide-queues-events-notifications/) 讓你把這些耗時任務丟到背景去跑，使用者按下按鈕的瞬間就看到回應。",
      "summary": "用 Laravel Cashier 串接 Stripe，從成團確認到收款的完整結帳流程：Checkout Session、Webhook 簽名驗證、訂單狀態機與 DB Transaction，含 Stripe 測試模式與本地 Webhook 測試。",
      "image": "https://bobochen.dev/_astro/cover.C4xkk54g.webp",
      "date_published": "2025-04-29T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Stripe",
        "Cashier",
        "Payment",
        "E-commerce"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-group-buy-logic-session/",
      "url": "https://bobochen.dev/blog/laravel-guide-group-buy-logic-session/",
      "title": "跟團與成團邏輯：用 Laravel Session 打造從「+1」到「成團確認」",
      "content_text": "用 Laravel 12 的 Session、Cache 與 Livewire 打造團購「+1 跟團」到「成團確認」的完整流程：從跟團驗證、最低人數成團判斷，到用 DB Transaction 與 lockForUpdate 解決同時搶團的 race condition，並用定時任務處理截止團購。",
      "content_html": "團購平台的核心，說穿了就是兩個字：+1。有人開團、有人跟團、湊到最低人數就成團。聽起來簡單，但背後的工程考量比你想像的多：\n\n- 使用者還沒登入就想跟團怎麼辦？\n- 同時一百個人按下「+1」會不會超賣？\n- 截止時間到了但人數不夠要怎麼處理？\n- 成團瞬間要同時通知所有跟團者、建立訂單、鎖定庫存，這些動作的順序和原子性都不能出錯。\n\n這一章是「揪好買」的心臟。我們要用 Laravel 的 Session 機制處理使用者的暫存狀態，用 Cache 加速熱門團購的讀取，用 Livewire 做即時的跟團人數更新，然後把「成團條件判斷」這個最關鍵的業務邏輯寫得清楚明白。技術選型不是重點——Session driver 用 database 還是 Redis、Cache 用 file 還是 Memcached，這些換一行設定就能改。真正難的是業務邏輯本身：什麼時候該鎖定、什麼時候該釋放、edge case 怎麼處理。\n\n老實說，框架教學最容易迴避的就是業務邏輯。因為每個專案不一樣，沒有標準答案。但我認為這才是你真正需要練習的部分——把模糊的需求轉化成明確的程式碼。這一章，我們就來面對它。\n\n## 跟團前先懂 Session：HTTP 是無狀態的\n\nHTTP 本身是無狀態的（stateless）。每一次請求對 server 來說都是全新的陌生人——server 不知道五秒前那個看了團購列表的人，跟現在要按 +1 的人是不是同一個。這就好像你每次走進便利商店，店員都不認得你。\n\nWeb 應用程式怎麼解決這個問題？**Session**。\n\nSession 的運作原理其實很簡單：\n\n1. 使用者第一次造訪網站時，server 產生一個隨機 ID（Session ID）\n2. 這個 ID 透過 Cookie 送回瀏覽器\n3. 往後每次請求，瀏覽器自動帶上這個 Cookie\n4. Server 收到 Cookie，用 Session ID 查找對應的資料\n5. 資料存在 server 端（檔案、資料庫、Redis），不在瀏覽器裡\n\n所以 Session 就是 server 端的暫存記憶體——你放什麼進去，下一次請求都還在。\n\n### 跨框架對照\n\n| 概念         | Laravel                     | Express (Node.js)         | Django (Python)                  |\n| ------------ | --------------------------- | ------------------------- | -------------------------------- |\n| Session 套件 | 內建                        | `express-session`         | 內建                             |\n| 儲存位置設定 | `SESSION_DRIVER` in `.env`  | `store` option            | `SESSION_ENGINE`                 |\n| 讀取         | `session('key')`            | `req.session.key`         | `request.session['key']`         |\n| 寫入         | `session(['key' => 'val'])` | `req.session.key = 'val'` | `request.session['key'] = 'val'` |\n| Flash data   | `session()->flash()`        | `req.flash()` (需套件)    | `messages` framework             |\n\n如果你從 Express 轉過來，Laravel 的 Session 概念一模一樣，只是 Express 需要你自己裝 `express-session` 然後選 store（memory、redis、mongo），而 Laravel 全部幫你包好了——改一行 `.env` 就切換儲存方式。\n\n## Laravel Session 機制：Driver 選擇與設定\n\n打開 `.env`，找到 `SESSION_DRIVER`：\n\n```bash\nSESSION_DRIVER=database\n```\n\n### Driver 一覽\n\n| Driver     | 說明                               | 適合場景                    |\n| ---------- | ---------------------------------- | --------------------------- |\n| `file`     | 存在 `storage/framework/sessions/` | 單機開發、小型站台          |\n| `database` | 存在 `sessions` 資料表             | Laravel 12 預設，通用且可靠 |\n| `redis`    | 存在 Redis                         | 高流量正式環境，速度最快    |\n| `cookie`   | 加密後存在 Cookie                  | 輕量，但有 4KB 大小限制     |\n| `array`    | 存在記憶體（request 結束就消失）   | 測試用                      |\n\nLaravel 12 新建專案預設用 `database`。執行 `php artisan migrate` 時會自動建好 `sessions` 資料表——你不用額外做任何事。\n\n> **生產環境建議：** 如果你的流量大，Session driver 換成 `redis` 是最直接的升級。改一行 `SESSION_DRIVER=redis`，前提是你裝了 Redis server。開發階段用 `database` 就好，不要過早優化。\n\n### Session 基本操作\n\n```php\n// ── 寫入 ──\nsession(['cart_quantity' => 3]);\n// 或者\nsession()->put('cart_quantity', 3);\n\n// ── 讀取 ──\n$qty = session('cart_quantity');          // 取得值\n$qty = session('cart_quantity', 0);       // 預設值（key 不存在時回傳 0）\n$qty = session()->get('cart_quantity', 0); // 等效寫法\n\n// ── 檢查 ──\nif (session()->has('cart_quantity')) {\n    // key 存在且不為 null\n}\nif (session()->exists('cart_quantity')) {\n    // key 存在（可能為 null）\n}\n\n// ── 刪除 ──\nsession()->forget('cart_quantity');       // 刪除單一 key\nsession()->forget(['cart_quantity', 'selected_group']); // 刪除多個\nsession()->flush();                       // 清空整個 session\n\n// ── 所有資料 ──\n$all = session()->all();\n```\n\n### Flash Data：一次性訊息\n\nFlash data 是只在「下一次請求」有效的 session 資料，用完即消。最典型的用途是操作成功/失敗的通知訊息：\n\n```php\n// Controller 裡\nsession()->flash('success', '成功加入團購！');\nreturn redirect(\"/group-buys/{$groupBuy->id}\");\n\n// 或用 redirect 的語法糖\nreturn redirect(\"/group-buys/{$groupBuy->id}\")\n    ->with('success', '成功加入團購！');\n```\n\n在 Blade 裡顯示：\n\n```blade\n@if(session('success'))\n<div class=\"bg-green-100 text-green-700 px-4 py-3 rounded mb-4\">{{ session('success') }}</div>\n@endif\n@if(session('error'))\n<div class=\"bg-red-100 text-red-700 px-4 py-3 rounded mb-4\">{{ session('error') }}</div>\n@endif\n```\n\n重新整理頁面後，這些訊息就消失了——因為 flash data 只活一次。\n\n## 跟團邏輯設計：選數量、加入、取消\n\n好，技術工具介紹完了。現在進入這一章的核心——跟團邏輯。\n\n### 需求拆解\n\n使用者在團購詳情頁看到一個「+1 跟團」按鈕。點下去之前，他要先選擇數量（跟幾份）。點下去之後：\n\n1. **驗證**——團購還在開團嗎？還沒滿嗎？這個人已經跟過了嗎？\n2. **寫入**——把這個人加到 `group_buy_user` 中間表\n3. **回饋**——頁面顯示「成功加入！」，參與人數即時更新\n\n取消跟團的邏輯相反：從中間表 detach，人數減少。\n\n### 驗證規則\n\n在寫程式碼之前，先把規則列清楚。這是業務邏輯最重要的步驟——先用人話寫出所有條件，再翻譯成程式碼：\n\n1. 團購 `status` 必須是 `open`\n2. 團購 `deadline` 還沒過\n3. 使用者必須已登入\n4. 使用者不能重複加入同一個團購\n5. 如果有 `max_participants`，目前人數不能超過上限\n6. 數量必須是正整數\n\n### Livewire Component：JoinGroupBuy\n\n```bash\nphp artisan make:livewire JoinGroupBuy\n```\n\n```php\n<?php\n\n// app/Livewire/JoinGroupBuy.php\nnamespace App\\Livewire;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Support\\Facades\\Auth;\nuse Livewire\\Component;\n\nclass JoinGroupBuy extends Component\n{\n    public GroupBuy $groupBuy;\n    public int $quantity = 1;\n    public bool $hasJoined = false;\n    public int $currentQuantity = 0;\n\n    public function mount(GroupBuy $groupBuy): void\n    {\n        $this->groupBuy = $groupBuy;\n\n        if (Auth::check()) {\n            $existing = $groupBuy->participants()\n                ->where('user_id', Auth::id())\n                ->first();\n\n            if ($existing) {\n                $this->hasJoined = true;\n                $this->currentQuantity = $existing->pivot->quantity;\n            }\n        }\n    }\n\n    public function join(): void\n    {\n        // 1. 必須登入\n        if (! Auth::check()) {\n            $this->redirect(route('login'));\n            return;\n        }\n\n        // 2. 驗證數量\n        $this->validate([\n            'quantity' => 'required|integer|min:1|max:10',\n        ]);\n\n        // 3. 團購還在開團嗎？\n        if ($this->groupBuy->status !== 'open') {\n            session()->flash('error', '這個團購已經不接受跟團了。');\n            return;\n        }\n\n        // 4. 還沒截止嗎？\n        if ($this->groupBuy->deadline->isPast()) {\n            session()->flash('error', '這個團購已經截止了。');\n            return;\n        }\n\n        // 5. 沒有重複加入？\n        if ($this->hasJoined) {\n            session()->flash('error', '你已經跟過這個團了。');\n            return;\n        }\n\n        // 6. 還有名額嗎？\n        if ($this->groupBuy->max_participants) {\n            $currentCount = $this->groupBuy->participants()->count();\n            if ($currentCount >= $this->groupBuy->max_participants) {\n                session()->flash('error', '這個團已經滿了。');\n                return;\n            }\n        }\n\n        // 7. 一切驗證通過，加入團購\n        $this->groupBuy->participants()->attach(Auth::id(), [\n            'quantity' => $this->quantity,\n        ]);\n\n        $this->hasJoined = true;\n        $this->currentQuantity = $this->quantity;\n\n        // 通知其他 Livewire component 更新\n        $this->dispatch('participant-updated');\n\n        session()->flash('success', \"成功跟團！你選了 {$this->quantity} 份。\");\n    }\n\n    public function leave(): void\n    {\n        if (! $this->hasJoined) {\n            return;\n        }\n\n        // 已成團的不能退出\n        if ($this->groupBuy->status !== 'open') {\n            session()->flash('error', '團購已成團，無法退出。');\n            return;\n        }\n\n        $this->groupBuy->participants()->detach(Auth::id());\n\n        $this->hasJoined = false;\n        $this->currentQuantity = 0;\n\n        $this->dispatch('participant-updated');\n\n        session()->flash('success', '你已退出這個團購。');\n    }\n\n    public function render()\n    {\n        return view('livewire.join-group-buy');\n    }\n}\n```\n\n對應的 Blade 模板：\n\n```blade\n<!-- resources/views/livewire/join-group-buy.blade.php -->\n<div>\n  @if(session('success'))\n  <div class=\"bg-green-100 text-green-700 px-4 py-3 rounded mb-4\">{{ session('success') }}</div>\n  @endif\n  @if(session('error'))\n  <div class=\"bg-red-100 text-red-700 px-4 py-3 rounded mb-4\">{{ session('error') }}</div>\n  @endif\n  @if($hasJoined)\n  <div class=\"bg-indigo-50 border border-indigo-200 rounded-lg p-4\">\n    <p class=\"text-indigo-700 font-medium\">你已跟團 {{ $currentQuantity }} 份</p>\n    @if($groupBuy->status === 'open')\n    <button\n      wire:click=\"leave\"\n      wire:confirm=\"確定要退出嗎？\"\n      class=\"mt-2 text-sm text-red-600 hover:text-red-800\"\n    >\n      退出團購\n    </button>\n    @endif\n  </div>\n  @else\n  @if($groupBuy->status === 'open' && ! $groupBuy->deadline->isPast())\n  <div class=\"flex items-center gap-3\">\n    <label class=\"text-sm text-gray-600\">數量</label>\n    <select wire:model=\"quantity\" class=\"rounded border px-3 py-2\">\n      @for($i = 1; $i <= 10; $i++)\n      <option value=\"{{ $i }}\">{{ $i }} 份</option>\n      @endfor\n    </select>\n    <button\n      wire:click=\"join\"\n      class=\"bg-emerald-600 text-white px-6 py-2 rounded-lg hover:bg-emerald-700 transition\"\n    >\n      <span wire:loading.remove wire:target=\"join\">+1 跟團</span>\n      <span wire:loading wire:target=\"join\">處理中...</span>\n    </button>\n  </div>\n  @else\n  <p class=\"text-gray-500\">此團購已截止或不再接受跟團</p>\n  @endif\n  @endif\n</div>\n```\n\n### 為什麼用 `attach()` / `detach()`？\n\n回顧[第四章的多對多關聯](/blog/laravel-guide-eloquent-orm-models/)——`group_buy_user` 是 pivot table，`attach()` 新增一筆關聯，`detach()` 移除。這比自己手寫 `DB::table('group_buy_user')->insert(...)` 乾淨很多，而且 Eloquent 會自動幫你維護 timestamps。\n\n## 成團條件判斷：最低人數與截止時間\n\n跟團邏輯處理的是個人行為——一個人加入、一個人退出。**成團邏輯**處理的是整個團的狀態轉換。\n\n### 狀態轉換圖\n\n```\n                ┌──────────────┐\n                │    open      │\n                │  （開團中）    │\n                └──────┬───────┘\n                       │\n            ┌──────────┴──────────┐\n            │                     │\n    人數 >= 最低門檻         截止時間到了\n    (可提前成團)           但人數不夠\n            │                     │\n            ▼                     ▼\n    ┌──────────────┐     ┌──────────────┐\n    │  confirmed   │     │  cancelled   │\n    │  （已成團）    │     │  （已取消）    │\n    └──────────────┘     └──────────────┘\n```\n\n規則很明確：\n\n1. **成團**：參與人數 >= `min_participants`（不管截止時間到了沒，都可以成團）\n2. **取消**：截止時間到了，但參與人數 < `min_participants`\n3. **開團中**：還沒截止，人數也還不夠\n\n不過「人數一夠就提前成團」是我做的產品選擇，不是唯一正解，這裡先講清楚兩件事：\n\n- **提前鎖定有代價**：有些團購反而希望跑滿整個截止時間，盡量蒐集人數去衝更低的折扣級距（湊到 50 人比 20 人便宜）。你一達標就 confirm，等於把後面那批人擋在門外，揪團規模反而變小。要不要提前成團，看你的折扣是不是階梯式的——如果是，可能該等截止才結算。\n- **`checkAndConfirm()` 要有人叫它**：這個方法不會自己跑。下面你會看到它靠 `join()` 事件或每 5 分鐘的 scheduler 觸發，但等一下的 `JoinGroupBuy::join()` 其實只有 attach 完 dispatch UI 事件、**沒有呼叫 `checkAndConfirm()`**。所以實務上的成團時間點是「下一個人 +1」或「scheduler 下一輪」，最慘可能拖好幾分鐘——明明第 5 個人已經進來了，狀態卻還掛在 open。要避免這種「達標但遲遲沒成團」的尷尬，記得在 `join()` 的 attach 之後補一行 `$this->groupBuy->checkAndConfirm()`，讓達標當下就結算。\n\n### GroupBuy Model 方法：`checkAndConfirm()`\n\n```php\n// app/Models/GroupBuy.php\n\nuse Illuminate\\Support\\Facades\\DB;\n\n/**\n * 檢查並更新成團狀態\n * 回傳是否發生狀態變更\n */\npublic function checkAndConfirm(): bool\n{\n    // 只有 open 狀態才需要檢查\n    if ($this->status !== 'open') {\n        return false;\n    }\n\n    $participantCount = $this->participants()->count();\n\n    // 情況一：人數夠了 → 成團\n    if ($participantCount >= $this->min_participants) {\n        return $this->confirmGroup();\n    }\n\n    // 情況二：截止了但人數不夠 → 取消\n    if ($this->deadline->isPast() && $participantCount < $this->min_participants) {\n        return $this->cancelGroup();\n    }\n\n    // 情況三：還在進行中\n    return false;\n}\n\nprivate function confirmGroup(): bool\n{\n    return DB::transaction(function () {\n        // 用悲觀鎖鎖住這筆團購，避免 race condition\n        $groupBuy = GroupBuy::lockForUpdate()->find($this->id);\n\n        // 再次確認狀態（double-check locking）\n        if ($groupBuy->status !== 'open') {\n            return false;\n        }\n\n        $groupBuy->update(['status' => 'confirmed']);\n\n        // TODO: 第十章會加入——通知所有參與者、建立訂單\n        // event(new GroupBuyConfirmed($groupBuy));\n\n        return true;\n    });\n}\n\nprivate function cancelGroup(): bool\n{\n    return DB::transaction(function () {\n        $groupBuy = GroupBuy::lockForUpdate()->find($this->id);\n\n        if ($groupBuy->status !== 'open') {\n            return false;\n        }\n\n        $groupBuy->update(['status' => 'cancelled']);\n\n        // TODO: 通知所有參與者團購取消\n        // event(new GroupBuyCancelled($groupBuy));\n\n        return true;\n    });\n}\n```\n\n成團後的通知與訂單建立（`GroupBuyConfirmed` 事件、Queue 處理）將在[第十章：Queue、Event 與通知](/blog/laravel-guide-queues-events-notifications/)詳細介紹。\n\n### 為什麼需要 `DB::transaction()` 和 `lockForUpdate()`？\n\n想像這個場景：團購需要 5 人成團，目前已經有 4 個人。第五個人和第六個人幾乎同時按下 +1。\n\n**沒有鎖的情況：**\n\n```\n使用者 A 讀取人數 → 4 人\n使用者 B 讀取人數 → 4 人\n使用者 A 加入 → 5 人 → 觸發成團 ✅\n使用者 B 加入 → 6 人 → 又觸發成團？或超過上限？❌\n```\n\n**有鎖的情況：**\n\n```\n使用者 A 取得鎖 → 讀取 4 人 → 加入 → 5 人 → 成團 → 釋放鎖\n使用者 B 等待鎖 → 取得鎖 → 讀取 5 人 → 已成團，不再處理 → 釋放鎖\n```\n\n`lockForUpdate()` 是資料庫的悲觀鎖（`SELECT ... FOR UPDATE`），確保同一時間只有一個 process 能修改這筆資料。搭配 `DB::transaction()` 保證整組操作的原子性——要嘛全部成功，要嘛全部回滾。\n\n> **悲觀鎖不是萬靈丹，講幾句它的代價。** `FOR UPDATE` 在低併發下很好用，但併發一上來，搶不到鎖的 request 會排隊乾等，連線一個個卡住，連線池滿了就開始噴 error；兩筆交易互相等對方的鎖還會 deadlock。它也綁 DB 引擎——MySQL InnoDB / Postgres 行為正常，SQLite 根本不是那樣鎖，你本機測過了上線可能兩種行為。所以前面說「高流量改用 Redis」跟這裡說「用悲觀鎖」其實有點打架：真高流量時，悲觀鎖本身可能就是瓶頸。\n>\n> 想換做法的話：樂觀鎖（加個 `version` 欄位，update 時帶條件，撞到就重試）、單一原子 `UPDATE ... WHERE status = 'open'`（靠 DB 自己的行鎖，不用顯式 `FOR UPDATE`）、或把成團檢查丟進 queue 用單一 worker 序列化處理，都比硬鎖更耐操。\n>\n> 還有一句更重要的：**真正怕超賣的是「名額/庫存」那一層**，那裡才該下重手防併發。成團狀態從 open 轉 confirmed 只會發生一次、併發量通常很低，這裡用悲觀鎖是「夠用」而非「最佳」——學概念剛好，別把它當成所有 race condition 的標準答案。\n\n### Scheduled Command：定時檢查過期團購\n\n有些團購不會被人手動觸發成團檢查——可能截止時間到了但最後一個人早就加入了，沒有新的 +1 來觸發 `checkAndConfirm()`。所以我們需要一個定時任務來掃描過期的團購。\n\n```bash\nphp artisan make:command CheckExpiredGroupBuys\n```\n\n```php\n<?php\n\n// app/Console/Commands/CheckExpiredGroupBuys.php\nnamespace App\\Console\\Commands;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Console\\Command;\n\nclass CheckExpiredGroupBuys extends Command\n{\n    protected $signature = 'group-buys:check-expired';\n    protected $description = '檢查已截止的團購，成團或取消';\n\n    public function handle(): int\n    {\n        $expiredGroups = GroupBuy::where('status', 'open')\n            ->where('deadline', '<=', now())\n            ->get();\n\n        $confirmed = 0;\n        $cancelled = 0;\n\n        foreach ($expiredGroups as $groupBuy) {\n            if ($groupBuy->checkAndConfirm()) {\n                if ($groupBuy->fresh()->status === 'confirmed') {\n                    $confirmed++;\n                } else {\n                    $cancelled++;\n                }\n            }\n        }\n\n        $this->info(\"處理完成：{$confirmed} 個成團、{$cancelled} 個取消\");\n\n        return self::SUCCESS;\n    }\n}\n```\n\n在 `routes/console.php`（Laravel 12 的排程檔案）裡註冊：\n\n```php\n// routes/console.php\nuse Illuminate\\Support\\Facades\\Schedule;\n\nSchedule::command('group-buys:check-expired')->everyFiveMinutes();\n```\n\n每五分鐘跑一次，把所有已截止的團購該成團的成團、該取消的取消。生產環境記得啟動 scheduler：\n\n```bash\n# crontab -e，加入這一行\n* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1\n```\n\n## Cache Facade：快取熱門資料\n\n團購列表頁可能有幾十個團購，每一個都要 `$groupBuy->participants()->count()` 去查 pivot table——如果首頁流量大，這些 COUNT 查詢會成為瓶頸。Cache（快取）可以大幅減少資料庫壓力。\n\n### Cache 基本操作\n\n```php\nuse Illuminate\\Support\\Facades\\Cache;\n\n// ── 存入 ──\nCache::put('key', 'value', now()->addMinutes(30));  // 30 分鐘後過期\n\n// ── 讀取 ──\n$value = Cache::get('key');               // 不存在回傳 null\n$value = Cache::get('key', 'default');    // 不存在回傳 default\n\n// ── remember：不存在就執行 closure 並快取 ──\n$count = Cache::remember('group_buy_42_count', now()->addMinutes(5), function () {\n    return GroupBuy::find(42)->participants()->count();\n});\n// 第一次：跑 DB 查詢，結果存入快取\n// 之後五分鐘內：直接從快取拿，不碰 DB\n\n// ── 刪除 ──\nCache::forget('group_buy_42_count');\n\n// ── 永久快取 ──\nCache::forever('site_settings', $settings);\n```\n\n### 在揪好買中快取跟團人數\n\n```php\n// app/Models/GroupBuy.php\n\npublic function cachedParticipantCount(): int\n{\n    return Cache::remember(\n        \"group_buy_{$this->id}_participant_count\",\n        now()->addMinutes(5),\n        fn () => $this->participants()->count()\n    );\n}\n```\n\n在 JoinGroupBuy component 的 `join()` 和 `leave()` 方法裡，加入 cache 失效：\n\n```php\n// 加入或退出後，清除快取\nCache::forget(\"group_buy_{$this->groupBuy->id}_participant_count\");\n```\n\n### Cache Driver 選擇\n\n```bash\n# .env\nCACHE_STORE=database   # Laravel 12 預設\n```\n\n| Driver     | 特點            | 適合場景       |\n| ---------- | --------------- | -------------- |\n| `file`     | 零設定          | 開發、小站     |\n| `database` | 可靠，預設      | 一般用途       |\n| `redis`    | 最快，支援 tags | 高流量正式環境 |\n| `array`    | 不持久化        | 測試           |\n\n跟 Session driver 一樣的故事——開發用 `database`，流量大了再換 `redis`。程式碼完全不用改。\n\n## 登入前後狀態合併策略\n\n這是很容易被忽略的 UX 問題：使用者還沒登入就開始瀏覽團購，甚至把某個團購加到「想跟」清單。登入之後，這些行為不應該消失——要把 session 裡的暫存資料合併到資料庫裡。\n\n### 場景\n\n1. 訪客 A 瀏覽團購 #42，把它加入「收藏清單」（存在 session）\n2. 訪客 A 按下「+1 跟團」→ 被導向登入頁\n3. A 登入成功 → 回到團購 #42 → 收藏清單裡應該還有 #42\n\n### 實作：合併 Session 資料\n\nLaravel 在使用者登入時會觸發 `Login` event。我們可以監聽這個 event，在登入後把 session 資料合併到資料庫：\n\n```php\n<?php\n\n// app/Listeners/MergeSessionDataAfterLogin.php\nnamespace App\\Listeners;\n\nuse Illuminate\\Auth\\Events\\Login;\n\nclass MergeSessionDataAfterLogin\n{\n    public function handle(Login $event): void\n    {\n        $user = $event->user;\n\n        // 合併收藏清單\n        $sessionFavorites = session()->pull('guest_favorites', []);\n\n        if (! empty($sessionFavorites)) {\n            // 把 session 裡的收藏寫到 user 的資料庫記錄\n            foreach ($sessionFavorites as $groupBuyId) {\n                $user->favorites()->syncWithoutDetaching([$groupBuyId]);\n            }\n        }\n\n        // 如果訪客有「想跟團」的意圖，導向那個團購頁\n        $pendingJoin = session()->pull('pending_join_group_buy');\n\n        if ($pendingJoin) {\n            session()->flash('info', '你可以繼續完成跟團了！');\n            // Livewire 或 Controller 可以根據這個 flash 做導向\n        }\n    }\n}\n```\n\n在 `EventServiceProvider` 或 `AppServiceProvider` 裡註冊：\n\n```php\n// app/Providers/AppServiceProvider.php\nuse App\\Listeners\\MergeSessionDataAfterLogin;\nuse Illuminate\\Auth\\Events\\Login;\nuse Illuminate\\Support\\Facades\\Event;\n\npublic function boot(): void\n{\n    Event::listen(Login::class, MergeSessionDataAfterLogin::class);\n}\n```\n\n而在 JoinGroupBuy component 的 `join()` 方法裡，如果使用者未登入，先記住意圖：\n\n```php\npublic function join(): void\n{\n    if (! Auth::check()) {\n        // 記住使用者想跟哪個團\n        session()->put('pending_join_group_buy', $this->groupBuy->id);\n        $this->redirect(route('login'));\n        return;\n    }\n\n    // ...後續驗證和加入邏輯\n}\n```\n\n登入成功後，使用者會被導回原頁面，看到一條 flash 訊息提醒他繼續完成跟團。\n\n> **Session 的妙用：** 很多人以為 session 只是「登入狀態」，其實它是通用的暫存工具。訪客瀏覽行為、購物車、表單草稿、多步驟流程的中間狀態——都可以用 session 暫存，登入後再合併到資料庫。\n\n## Livewire 即時更新跟團人數\n\n團購詳情頁除了 JoinGroupBuy component，還需要顯示「目前幾人跟團」。這個數字要在有人加入/退出時即時更新——前面的 JoinGroupBuy 會 dispatch `participant-updated` 事件，我們可以做一個 component 來監聽它。\n\n### ParticipantCounter Component\n\n```php\n<?php\n\n// app/Livewire/ParticipantCounter.php\nnamespace App\\Livewire;\n\nuse App\\Models\\GroupBuy;\nuse Livewire\\Attributes\\On;\nuse Livewire\\Component;\n\nclass ParticipantCounter extends Component\n{\n    public GroupBuy $groupBuy;\n    public int $count = 0;\n    public int $totalQuantity = 0;\n\n    public function mount(GroupBuy $groupBuy): void\n    {\n        $this->groupBuy = $groupBuy;\n        $this->refreshCount();\n    }\n\n    #[On('participant-updated')]\n    public function refreshCount(): void\n    {\n        $this->count = $this->groupBuy->participants()->count();\n        $this->totalQuantity = (int) $this->groupBuy\n            ->participants()\n            ->sum('group_buy_user.quantity');\n    }\n\n    public function render()\n    {\n        $progress = $this->groupBuy->min_participants > 0\n            ? min(100, round(($this->count / $this->groupBuy->min_participants) * 100))\n            : 0;\n\n        return view('livewire.participant-counter', [\n            'progress' => $progress,\n        ]);\n    }\n}\n```\n\n```blade\n<!-- resources/views/livewire/participant-counter.blade.php -->\n<div wire:poll.30s=\"refreshCount\">\n  <div class=\"flex items-baseline gap-2 mb-2\">\n    <span class=\"text-3xl font-bold text-indigo-600\">{{ $count }}</span>\n    <span class=\"text-gray-500\">/ {{ $groupBuy->min_participants }} 人</span>\n    @if($groupBuy->max_participants)\n    <span class=\"text-gray-400 text-sm\">（上限 {{ $groupBuy->max_participants }} 人）</span>\n    @endif\n  </div>\n\n  {{-- 進度條 --}}\n  <div class=\"w-full bg-gray-200 rounded-full h-3 mb-2\">\n    <div\n      class=\"bg-emerald-500 h-3 rounded-full transition-all duration-500\"\n      style=\"width: {{ $progress }}%\"\n    ></div>\n  </div>\n\n  <p class=\"text-sm text-gray-500\">\n    共 {{ $totalQuantity }} 份 @if($progress >= 100)\n    <span class=\"text-emerald-600 font-medium\">已達成團門檻！</span>\n    @else\n    ，還差 {{ $groupBuy->min_participants - $count }} 人成團\n    @endif\n  </p>\n</div>\n```\n\n### 關鍵設計\n\n- **`#[On('participant-updated')]`**：PHP 8 Attribute 語法，告訴 Livewire「當收到 `participant-updated` 事件時，執行這個方法」。JoinGroupBuy 在加入/退出後 dispatch 這個事件，ParticipantCounter 就會即時更新——同一頁面上的跨 component 通訊。\n- **`wire:poll.30s`**：每 30 秒自動跟 server 同步一次。這是為了處理「別人在其他瀏覽器跟團」的情況——你不會收到 Livewire 事件，但 poll 會定時拉最新數字。\n- **進度條**：視覺化呈現成團進度，用百分比計算寬度。CSS `transition-all` 讓寬度變化有動畫。\n\n> **wire:poll 的代價：** 每 30 秒一次 AJAX 請求。如果頁面上有很多使用者同時瀏覽，這會產生不少請求。正式環境如果流量真的很大，可以考慮用 Laravel Echo + WebSocket 做真正的即時推播。但對揪好買的規模來說，30 秒 poll 完全夠用。\n\n## 倒數計時：團購截止提醒\n\n截止時間的倒數計時是純前端的事——每秒更新一次，不需要打 server。用 Alpine.js 就對了。\n\n```blade\n<!-- 嵌入 group-buy show 頁面 -->\n<div\n  x-data=\"countdown('{{ $groupBuy->deadline->toIso8601String() }}')\"\n  x-init=\"start()\"\n  class=\"text-center\"\n>\n  <template x-if=\"!expired\">\n    <div>\n      <p class=\"text-sm text-gray-500 mb-1\">距離截止還有</p>\n      <div class=\"flex justify-center gap-3\">\n        <div class=\"text-center\">\n          <span class=\"text-2xl font-bold text-indigo-600\" x-text=\"days\"></span>\n          <p class=\"text-xs text-gray-400\">天</p>\n        </div>\n        <span class=\"text-2xl text-gray-300\">:</span>\n        <div class=\"text-center\">\n          <span class=\"text-2xl font-bold text-indigo-600\" x-text=\"hours\"></span>\n          <p class=\"text-xs text-gray-400\">時</p>\n        </div>\n        <span class=\"text-2xl text-gray-300\">:</span>\n        <div class=\"text-center\">\n          <span class=\"text-2xl font-bold text-indigo-600\" x-text=\"minutes\"></span>\n          <p class=\"text-xs text-gray-400\">分</p>\n        </div>\n        <span class=\"text-2xl text-gray-300\">:</span>\n        <div class=\"text-center\">\n          <span class=\"text-2xl font-bold text-indigo-600\" x-text=\"seconds\"></span>\n          <p class=\"text-xs text-gray-400\">秒</p>\n        </div>\n      </div>\n    </div>\n  </template>\n\n  <template x-if=\"expired\">\n    <p class=\"text-red-600 font-medium\">此團購已截止</p>\n  </template>\n</div>\n```\n\nAlpine.js 的 countdown 函式放在全域 JS 裡：\n\n```javascript\n// resources/js/countdown.js\ndocument.addEventListener('alpine:init', () => {\n  Alpine.data('countdown', (deadline) => ({\n    days: '00',\n    hours: '00',\n    minutes: '00',\n    seconds: '00',\n    expired: false,\n    interval: null,\n\n    start() {\n      this.update();\n      this.interval = setInterval(() => this.update(), 1000);\n    },\n\n    update() {\n      const now = new Date().getTime();\n      const target = new Date(deadline).getTime();\n      const diff = target - now;\n\n      if (diff <= 0) {\n        this.expired = true;\n        clearInterval(this.interval);\n        // 截止了，重新整理頁面讓 server 更新狀態\n        setTimeout(() => window.location.reload(), 2000);\n        return;\n      }\n\n      this.days = String(Math.floor(diff / (1000 * 60 * 60 * 24))).padStart(2, '0');\n      this.hours = String(Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))).padStart(\n        2,\n        '0'\n      );\n      this.minutes = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, '0');\n      this.seconds = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, '0');\n    },\n\n    destroy() {\n      clearInterval(this.interval);\n    },\n  }));\n});\n```\n\n在 `resources/js/app.js` 引入：\n\n```javascript\nimport './countdown.js';\n```\n\n### 為什麼倒數計時用 Alpine.js 而不是 Livewire？\n\n因為倒數計時需要**每秒**更新。如果用 `wire:poll.1s`，每秒都要跑一趟 AJAX 到 server——這完全沒必要，截止時間又不會變。純前端的 `setInterval` 搭配 Alpine.js，零 server 負擔，體驗也更流暢。\n\n這是 Livewire + Alpine.js 分工的經典案例：\n\n- **需要 server 資料（人數、狀態）** → Livewire\n- **純 UI 計算（倒數、動畫）** → Alpine.js\n\n## 實作：揪好買的跟團完整流程\n\n把前面所有元件串起來，做一個完整的團購詳情頁。\n\n### 路由\n\n```php\n// routes/web.php\nRoute::get('/group-buys/{groupBuy}', function (GroupBuy $groupBuy) {\n    return view('group-buys.show', compact('groupBuy'));\n})->name('group-buys.show');\n```\n\n### 團購詳情頁\n\n```blade\n<!-- resources/views/group-buys/show.blade.php -->\n<x-layouts.app :title=\"$groupBuy->title\">\n  <div class=\"max-w-3xl mx-auto\">\n    {{-- 標題區 --}}\n    <div class=\"mb-6\">\n      <div class=\"flex items-center gap-3 mb-2\">\n        <h1 class=\"text-2xl font-bold\">{{ $groupBuy->title }}</h1>\n        <span\n          class=\"text-sm px-2 py-1 rounded\n                    {{ $groupBuy->status === 'open' ? 'bg-green-100 text-green-700' : '' }}\n                    {{ $groupBuy->status === 'confirmed' ? 'bg-blue-100 text-blue-700' : '' }}\n                    {{ $groupBuy->status === 'cancelled' ? 'bg-red-100 text-red-700' : '' }}\n                \"\n        >\n          {{ match($groupBuy->status) { 'open' => '開團中', 'confirmed' => '已成團', 'cancelled' =>\n          '已取消', default => $groupBuy->status, } }}\n        </span>\n      </div>\n      <p class=\"text-gray-500\">\n        開團者：{{ $groupBuy->organizer->name }} ・{{ $groupBuy->created_at->diffForHumans() }}\n      </p>\n    </div>\n\n    {{-- 主要內容 --}}\n    <div class=\"grid md:grid-cols-3 gap-6\">\n      {{-- 左欄：商品資訊 --}}\n      <div class=\"md:col-span-2 space-y-6\">\n        @if($groupBuy->image)\n        <img\n          src=\"{{ Storage::url($groupBuy->image) }}\"\n          alt=\"{{ $groupBuy->product_name }}\"\n          class=\"w-full rounded-xl\"\n        />\n        @endif\n\n        <div class=\"prose max-w-none\">\n          <h2>{{ $groupBuy->product_name }}</h2>\n          <p>{{ $groupBuy->description }}</p>\n        </div>\n\n        <div class=\"bg-gray-50 rounded-lg p-4\">\n          <p class=\"text-2xl font-bold text-indigo-600\">\n            ${{ number_format($groupBuy->price_per_unit / 100) }}\n            <span class=\"text-sm text-gray-500 font-normal\">/ 份</span>\n          </p>\n        </div>\n\n        {{-- 參與者列表 --}}\n        <div>\n          <h3 class=\"font-bold text-lg mb-3\">跟團者</h3>\n          @forelse($groupBuy->participants as $participant)\n          <div class=\"flex items-center justify-between py-2 border-b last:border-0\">\n            <span>{{ $participant->name }}</span>\n            <span class=\"text-sm text-gray-500\">\n              {{ $participant->pivot->quantity }} 份 @if($participant->pivot->note) ・{{\n              $participant->pivot->note }} @endif\n            </span>\n          </div>\n          @empty\n          <p class=\"text-gray-400\">還沒有人跟團，成為第一個吧！</p>\n          @endforelse\n        </div>\n      </div>\n\n      {{-- 右欄：跟團操作 --}}\n      <div class=\"space-y-6\">\n        {{-- 倒數計時 --}}\n        @if($groupBuy->status === 'open')\n        @include('partials.countdown', ['deadline' => $groupBuy->deadline])\n        @endif\n        {{-- 跟團人數 --}}\n        <div class=\"bg-white rounded-xl border p-5\">\n          <h3 class=\"font-medium text-gray-700 mb-3\">跟團進度</h3>\n          <livewire:participant-counter :group-buy=\"$groupBuy\" />\n        </div>\n\n        {{-- 跟團按鈕 --}}\n        <div class=\"bg-white rounded-xl border p-5\">\n          <livewire:join-group-buy :group-buy=\"$groupBuy\" />\n        </div>\n      </div>\n    </div>\n  </div>\n</x-layouts.app>\n```\n\n### 流程測試\n\n```bash\n# 重建資料庫和測試資料\nphp artisan migrate:fresh --seed\n\n# 啟動開發 server\nphp artisan serve &\nnpm run dev &\n```\n\n開兩個瀏覽器（或一個開無痕模式），用不同帳號登入：\n\n1. **瀏覽器 A**：進入某個團購頁，看到「0 / 5 人」\n2. **瀏覽器 A**：選 2 份，按 +1 跟團 → 成功，顯示「1 / 5 人」\n3. **瀏覽器 B**：登入另一個帳號，進入同一個團購\n4. **瀏覽器 B**：看到「1 / 5 人」（wire:poll 會同步）\n5. **瀏覽器 B**：按 +1 跟團 → 成功，顯示「2 / 5 人」\n6. **瀏覽器 A**：等 30 秒或重新整理 → 看到「2 / 5 人」\n\n再測邊界情況：\n\n- 嘗試重複加入 → 看到「你已經跟過這個團了」\n- 在團購截止後按 +1 → 看到「這個團購已經截止了」\n- 退出團購 → 人數減少，按鈕恢復成「+1 跟團」\n\n### Tinker 測試成團\n\n```bash\nphp artisan tinker\n\n>>> $gb = GroupBuy::where('status', 'open')->first()\n>>> $gb->min_participants\n# 假設是 3\n\n>>> $gb->participants()->count()\n# 假設已經有 3 人\n\n>>> $gb->checkAndConfirm()\n# true\n\n>>> $gb->fresh()->status\n# \"confirmed\"\n```\n\n也可以手動跑定時任務：\n\n```bash\nphp artisan group-buys:check-expired\n# 處理完成：1 個成團、2 個取消\n```\n\n## 小結：業務邏輯才是最難的部分\n\n回顧這一章的技術點：\n\n- **Session**——server 端的暫存記憶體，`put()` / `get()` / `flash()` 三板斧\n- **Cache**——`Cache::remember()` 快取熱門查詢結果，減少 DB 壓力\n- **DB Transaction + lockForUpdate()**——保護成團判斷的原子性，避免 race condition\n- **Scheduled Command**——定時檢查過期團購，`php artisan schedule:run`\n- **Livewire Events**——`$this->dispatch()` + `#[On()]` 做跨 component 通訊\n- **Alpine.js**——純前端倒數計時，不打 server\n- **Login Event Listener**——登入後合併 session 資料\n\n但老實說，這些技術點都不是這一章最難的部分。最難的是**業務邏輯的設計**：\n\n- 什麼時候成團？什麼時候取消？什麼時候保持開放？\n- 誰能加入？加入的條件是什麼？退出的限制呢？\n- Race condition 怎麼處理？狀態轉換的原子性怎麼保證？\n- 登入前後的使用者體驗怎麼銜接？\n\n框架給你工具，但不會告訴你「最低成團人數應該設在哪裡檢查」或「截止時間到了但人數不夠要不要寬限十分鐘」。這些是業務決策，只有跟產品經理（或者自己兼任產品經理的你）討論清楚之後，才能轉化成明確的 `if-else`。\n\n我的建議：**先用人話把所有規則列出來，再寫程式碼**。本章的驗證清單就是這個做法——六條規則寫清楚了，程式碼幾乎是自動翻譯出來的。最怕的是邊寫邊想、邊改邊加，最後程式碼變成一堆互相矛盾的條件分支，連自己都看不懂。\n\n下一章，我們要面對成團之後最關鍵的問題——收錢。訂單怎麼建立？Stripe 怎麼串接？[Laravel Cashier](/blog/laravel-guide-orders-stripe-cashier/) 能幫我們做到什麼？團購的收款跟一般電商不一樣——不是「買了就付」，而是「成團了才收」。這個時序差異會影響整個金流架構的設計。",
      "summary": "用 Laravel 12 的 Session、Cache 與 Livewire 打造團購「+1 跟團」到「成團確認」的完整流程：從跟團驗證、最低人數成團判斷，到用 DB Transaction 與 lockForUpdate 解決同時搶團的 race condition，並用定時任務處理截止團購。",
      "image": "https://bobochen.dev/_astro/cover.BQrJGPPN.webp",
      "date_published": "2025-04-22T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Session",
        "Cache",
        "團購",
        "Business Logic"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-validation-file-upload/",
      "url": "https://bobochen.dev/blog/laravel-guide-validation-file-upload/",
      "title": "表單驗證與檔案上傳：讓使用者好好提交資料",
      "content_text": "學會用 Laravel 12 打造安全可靠的表單驗證與檔案上傳：從 inline validate、Form Request 抽離驗證邏輯、自訂 Rule class，到 Storage facade 統一管理本地與 S3 檔案、Intervention Image 產生 WebP 縮圖。以「揪好買」團購平台的開團表單為例，完整實作驗證規則、中文化錯誤訊息與圖片上傳流程。",
      "content_html": "使用者輸入的資料，永遠不能信任。這不是偏執，是血淚教訓。前端驗證擋得了手滑的一般使用者，擋不了：\n\n- 開 DevTools 直接改掉 HTML 的人\n- 用 `curl` 繞過瀏覽器直打你 API 的人\n- 自動化攻擊腳本\n\n後端驗證是最後一道防線，也是唯一可靠的防線。\n\nLaravel 的驗證系統大概是我用過最舒服的——內建超過 90 條驗證規則，從 `required`、`email`、`max` 到 `exists:users,id` 這種直接查資料庫的都有，而且錯誤訊息自動對應、自動回填表單，開發體驗好到讓你不想偷懶跳過驗證。\n\n除了文字資料，表單常常還要處理檔案上傳——大頭照、商品圖片、附件。Laravel 的 Storage facade 把檔案操作抽象化，不管你底層用的是本地硬碟、Amazon S3 還是其他雲端儲存，程式碼寫法都一樣。搭配 Form Request 把驗證邏輯從 Controller 搬出去，你的 Controller 就能保持精簡，每個 method 不超過十行。\n\n在「揪好買」團購平台裡，開團主建立團購時要填寫商品名稱、描述、最低成團人數、截止時間，還要上傳商品圖片。這一章我們會完整實作這張「開團」表單——從驗證規則、錯誤處理、圖片上傳到縮圖生成，每一步都用 Laravel 最佳實踐來做。\n\n## 為什麼需要後端驗證：前端擋不住的事\n\n你可能會想：「我前端已經用 JavaScript 做了驗證，使用者填錯會即時提示，應該夠了吧？」讓我示範一下為什麼不夠。\n\n假設你的「開團」表單前端限制了標題必須填寫、價格必須是正數。但攻擊者完全可以繞過瀏覽器，直接用 curl 送請求：\n\n```bash\n# 繞過前端，直接打後端 API\ncurl -X POST http://localhost:8000/group-buys \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"title\": \"\", \"price_per_unit\": -100, \"deadline\": \"1999-01-01\"}'\n```\n\n前端驗證完全沒機會攔截這個請求。標題是空的、價格是負數、截止時間在 25 年前——如果後端照單全收，你的資料庫就被塞了一筆垃圾資料。\n\n後端驗證攔的就是這種「形狀壞掉」的資料：空標題、負數價格、過去的截止日。前端的 `required` 和 `minLength` 一個 `curl` 就繞過了，所以這道把關只能放在後端。\n\n所以原則很簡單：**前端驗證是為了使用者體驗（即時反饋），後端驗證是為了把關資料的正確性。兩者缺一不可，但如果只能選一個，一定是後端。**\n\n不過我要先講清楚一件事，免得你誤會：validation 不是萬靈丹。`required|string|max:2000` 這種規則管的是「資料長什麼樣、符不符合業務規則」，它不會幫你擋 SQL injection，也不會擋 stored XSS。那是另外兩層的事：\n\n- **防 SQL injection** 靠的是 Eloquent / Query Builder 的參數化綁定。只要你不去手動拼 raw SQL 字串，注入這條路本來就被堵住了，跟你有沒有寫 validation 無關。\n- **防 XSS** 靠的是輸出端——Blade 的 `{{ }}` 預設會做 HTML escape。所以 description 就算真的被塞了 `<script>`，渲染時也只會變成純文字顯示，不會被瀏覽器執行（除非你用 `{!! !!}` 自己關掉轉義）。\n\n換句話說，validation、ORM 參數化、Blade 轉義是三道不同的防線，各管各的，缺一不可。別把「我有寫 validation」當成「我的網站很安全」——這章先把資料正確性顧好，注入和 XSS 後面會另外談。\n\n## Validation Rules：Laravel 內建的驗證武器庫\n\nLaravel 最簡單的驗證方式是直接在 Controller 裡呼叫 `$request->validate()`：\n\n```php\npublic function store(Request $request)\n{\n    $validated = $request->validate([\n        'title'            => 'required|string|min:3|max:100',\n        'description'      => 'required|string|max:2000',\n        'product_name'     => 'required|string|max:100',\n        'price_per_unit'   => 'required|integer|min:1',\n        'min_participants' => 'required|integer|min:2|max:500',\n        'max_participants' => 'nullable|integer|gte:min_participants',\n        'deadline'         => 'required|date|after:today',\n        'image'            => 'nullable|image|mimes:jpg,png,webp|max:2048',\n    ]);\n\n    // 如果走到這裡，表示驗證全部通過\n    // $validated 只包含驗證過的資料（安全）\n    GroupBuy::create($validated);\n}\n```\n\n驗證失敗時，Laravel 自動做三件事：\n\n1. 把使用者**重新導向回前一頁**\n2. 把**所有錯誤訊息**存進 Session（`$errors`）\n3. 把**舊的輸入值**存進 Session（`old()`）——方便回填表單\n\n你不需要手寫任何一行重導向或錯誤處理程式碼。\n\n### 常用驗證規則速查表\n\nLaravel 內建超過 90 條規則，這裡列出最常用的：\n\n| 規則                  | 說明                                | 範例                              |\n| --------------------- | ----------------------------------- | --------------------------------- |\n| `required`            | 必填                                | `'title' => 'required'`           |\n| `string`              | 必須是字串                          | `'name' => 'string'`              |\n| `integer`             | 必須是整數                          | `'price' => 'integer'`            |\n| `numeric`             | 必須是數字（含小數）                | `'weight' => 'numeric'`           |\n| `min:N`               | 最小值/最小長度                     | `'price' => 'min:1'`              |\n| `max:N`               | 最大值/最大長度                     | `'title' => 'max:100'`            |\n| `email`               | 必須是合法 Email                    | `'email' => 'email'`              |\n| `unique:table,column` | 資料庫裡不能重複                    | `'email' => 'unique:users,email'` |\n| `exists:table,column` | 資料庫裡必須存在                    | `'user_id' => 'exists:users,id'`  |\n| `in:a,b,c`            | 只能是指定值之一                    | `'status' => 'in:open,closed'`    |\n| `date`                | 必須是合法日期                      | `'deadline' => 'date'`            |\n| `after:date`          | 必須在某日期之後                    | `'deadline' => 'after:today'`     |\n| `before:date`         | 必須在某日期之前                    | `'end' => 'before:2027-01-01'`    |\n| `gte:field`           | 大於等於另一個欄位                  | `'max' => 'gte:min'`              |\n| `image`               | 必須是圖片                          | `'photo' => 'image'`              |\n| `mimes:jpg,png`       | 限制檔案類型                        | `'doc' => 'mimes:pdf,docx'`       |\n| `max:N`（檔案）       | 最大檔案大小（KB）                  | `'image' => 'max:2048'`           |\n| `nullable`            | 允許 null                           | `'bio' => 'nullable\\|string'`     |\n| `confirmed`           | 必須有 `_confirmation` 欄位且值相同 | `'password' => 'confirmed'`       |\n| `url`                 | 必須是合法 URL                      | `'website' => 'url'`              |\n| `boolean`             | 必須是布林值                        | `'agree' => 'boolean'`            |\n\n規則之間用 `|` 連接，也可以用陣列寫法：\n\n```php\n// 管線寫法（簡短規則適用）\n'title' => 'required|string|max:100',\n\n// 陣列寫法（規則多或含特殊字元時更清楚）\n'title' => ['required', 'string', 'max:100'],\n```\n\n### 跨框架對照\n\n如果你從其他框架來，這裡做個對照：\n\n| 概念     | Laravel                        | Express (Node.js)                | Django (Python)            |\n| -------- | ------------------------------ | -------------------------------- | -------------------------- |\n| 驗證方式 | `$request->validate()`         | joi / zod / express-validator    | Django Forms / Serializers |\n| 規則定義 | 字串 `'required\\|email'`       | Schema 物件 `z.string().email()` | Field 類別 `EmailField()`  |\n| 錯誤回傳 | 自動 redirect + session        | 手動回傳 JSON                    | 手動或 form.errors         |\n| 表單回填 | `old('field')` 自動可用        | 自己處理                         | `form.initial`             |\n| 檔案驗證 | 同一套規則 `'image\\|max:2048'` | multer + 自己驗證                | `FileField` + validators   |\n\nLaravel 的優勢在於**一站式**：驗證規則、錯誤訊息、表單回填、檔案處理全部整合在一起。不像 Express 要自己組裝 multer + zod + 錯誤處理 middleware。\n\n## Form Request：把驗證邏輯從 Controller 搬出去\n\n直接在 Controller 裡寫 `$request->validate()` 很方便，但當表單欄位一多、規則一複雜，Controller 就會變得臃腫。Laravel 的解法是 **Form Request**——一個專門負責驗證的類別。\n\n```bash\nphp artisan make:request StoreGroupBuyRequest\n```\n\n這會產生 `app/Http/Requests/StoreGroupBuyRequest.php`：\n\n```php\n<?php\n\nnamespace App\\Http\\Requests;\n\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass StoreGroupBuyRequest extends FormRequest\n{\n    /**\n     * 這個使用者有權限送出這個表單嗎？\n     */\n    public function authorize(): bool\n    {\n        // 搭配[第六章的 Policy](/blog/laravel-guide-auth-breeze-authorization)\n        return $this->user()->can('create', \\App\\Models\\GroupBuy::class);\n    }\n\n    /**\n     * 驗證規則\n     */\n    public function rules(): array\n    {\n        return [\n            'title'            => ['required', 'string', 'min:3', 'max:100'],\n            'description'      => ['required', 'string', 'max:2000'],\n            'product_name'     => ['required', 'string', 'max:100'],\n            'price_per_unit'   => ['required', 'integer', 'min:1'],\n            'min_participants' => ['required', 'integer', 'min:2', 'max:500'],\n            'max_participants' => ['nullable', 'integer', 'gte:min_participants'],\n            'deadline'         => ['required', 'date', 'after:today'],\n            'image'            => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],\n        ];\n    }\n\n    /**\n     * 自訂錯誤訊息（可選）\n     */\n    public function messages(): array\n    {\n        return [\n            'title.required'          => '團購標題是必填的',\n            'title.min'               => '標題至少要 :min 個字',\n            'price_per_unit.min'      => '單價必須大於 0',\n            'min_participants.min'    => '最低成團人數至少要 :min 人',\n            'max_participants.gte'    => '人數上限不能小於最低成團人數',\n            'deadline.after'          => '截止時間必須是未來的日期',\n            'image.max'              => '圖片大小不能超過 2MB',\n        ];\n    }\n}\n```\n\n三個關鍵方法：\n\n1. **`authorize()`**——權限檢查。回傳 `false` 就丟 403 Forbidden。搭配[第六章〈認證與授權〉](/blog/laravel-guide-auth-breeze-authorization)的 Policy 使用。\n2. **`rules()`**——驗證規則。跟 `$request->validate()` 的寫法一模一樣。\n3. **`messages()`**——自訂錯誤訊息。可選，不寫就用 Laravel 預設的英文訊息。\n\n### 在 Controller 裡使用 Form Request\n\n重點來了。把 `Request` 改成 `StoreGroupBuyRequest`，Laravel 自動在進入 Controller 之前完成驗證：\n\n```php\nuse App\\Http\\Requests\\StoreGroupBuyRequest;\n\nclass GroupBuyController extends Controller\n{\n    public function store(StoreGroupBuyRequest $request)\n    {\n        // 走到這裡，表示驗證和授權都通過了\n        $validated = $request->validated();\n\n        $groupBuy = GroupBuy::create([\n            ...$validated,\n            'user_id' => $request->user()->id,\n        ]);\n\n        return redirect(\"/group-buys/{$groupBuy->id}\")\n            ->with('success', '團購建立成功！');\n    }\n}\n```\n\n注意 `store()` 方法只有六行——沒有 if/else、沒有錯誤處理、沒有 redirect 回表單。所有髒活都由 Form Request 在 Controller 方法被呼叫**之前**處理完了。\n\n這就是 Form Request 的威力：**Controller 只負責業務邏輯，驗證邏輯完全分離。**\n\n> **什麼時候用 Form Request、什麼時候用 inline validate？** 如果驗證規則不超過三條，inline `$request->validate()` 就好，別過度工程化。超過三條、或是同一組規則在多處使用（store 和 update 共用）、或是需要自訂 `authorize()`，就抽成 Form Request。\n\n## 自訂驗證規則：當內建不夠用\n\nLaravel 內建 90 多條規則，但業務邏輯總有特殊需求。例如揪好買有一條規則：**截止時間必須是至少 24 小時後**（避免開團主設一個一小時後就截止的團購，其他人根本來不及跟）。\n\n### 方法一：Closure Rule（內聯）\n\n最快的做法，直接在 rules 陣列裡寫：\n\n```php\n'deadline' => [\n    'required',\n    'date',\n    'after:today',\n    function (string $attribute, mixed $value, \\Closure $fail) {\n        if (now()->diffInHours($value, absolute: false) < 24) {\n            $fail('截止時間必須是至少 24 小時後');\n        }\n    },\n],\n```\n\nClosure 接收三個參數：欄位名稱、值、`$fail` callback。驗證失敗就呼叫 `$fail()` 並傳入錯誤訊息。\n\n適合一次性的規則。如果同一個規則在多處使用，就該抽成 Rule class。\n\n### 方法二：Rule Class（可重用）\n\n```bash\nphp artisan make:rule MinHoursFromNow\n```\n\n```php\n<?php\n\nnamespace App\\Rules;\n\nuse Closure;\nuse Illuminate\\Contracts\\Validation\\ValidationRule;\n\nclass MinHoursFromNow implements ValidationRule\n{\n    public function __construct(\n        private int $hours = 24,\n    ) {}\n\n    public function validate(string $attribute, mixed $value, Closure $fail): void\n    {\n        if (now()->diffInHours($value, absolute: false) < $this->hours) {\n            $fail(\":{$attribute} 必須是至少 {$this->hours} 小時後\");\n        }\n    }\n}\n```\n\n使用：\n\n```php\nuse App\\Rules\\MinHoursFromNow;\n\n'deadline' => ['required', 'date', new MinHoursFromNow(24)],\n```\n\n乾淨、可重用、可測試。如果之後其他表單也需要「至少 N 小時後」的規則，直接 `new MinHoursFromNow(48)` 就好。\n\n## Error Message 中文化\n\nLaravel 預設的錯誤訊息是英文：\"The title field is required.\"。對揪好買的台灣使用者來說，我們需要中文訊息。\n\n### 設定語言檔\n\n建立 `lang/zh_TW/validation.php`：\n\n```bash\n# 先建立目錄\nmkdir -p lang/zh_TW\n```\n\n```php\n<?php\n// lang/zh_TW/validation.php\n\nreturn [\n    'required'  => ':attribute 為必填',\n    'string'    => ':attribute 必須是字串',\n    'integer'   => ':attribute 必須是整數',\n    'min'       => [\n        'numeric' => ':attribute 不能小於 :min',\n        'string'  => ':attribute 至少要 :min 個字',\n        'file'    => ':attribute 不能小於 :min KB',\n    ],\n    'max'       => [\n        'numeric' => ':attribute 不能大於 :max',\n        'string'  => ':attribute 不能超過 :max 個字',\n        'file'    => ':attribute 不能超過 :max KB',\n    ],\n    'email'     => ':attribute 格式不正確',\n    'unique'    => ':attribute 已經被使用',\n    'date'      => ':attribute 必須是有效日期',\n    'after'     => ':attribute 必須是 :date 之後的日期',\n    'image'     => ':attribute 必須是圖片',\n    'mimes'     => ':attribute 只接受 :values 格式',\n    'confirmed' => ':attribute 與確認欄位不一致',\n    'gte'       => [\n        'numeric' => ':attribute 必須大於或等於 :value',\n    ],\n\n    // 自訂欄位名稱（把英文欄位名換成中文）\n    'attributes' => [\n        'title'            => '團購標題',\n        'description'      => '團購說明',\n        'product_name'     => '商品名稱',\n        'price_per_unit'   => '單價',\n        'min_participants' => '最低成團人數',\n        'max_participants' => '人數上限',\n        'deadline'         => '截止時間',\n        'image'            => '商品圖片',\n        'email'            => '電子信箱',\n        'password'         => '密碼',\n        'name'             => '姓名',\n    ],\n];\n```\n\n然後在 `config/app.php` 設定語系：\n\n```php\n'locale' => 'zh_TW',\n```\n\n現在驗證失敗時，使用者看到的是「團購標題 為必填」「截止時間 必須是 today 之後的日期」——比英文友善多了。\n\n### 在 Blade 顯示錯誤訊息\n\n```blade\n<div class=\"mb-4\">\n  <label for=\"title\" class=\"block text-sm font-medium text-gray-700\">團購標題</label>\n  <input\n    type=\"text\"\n    name=\"title\"\n    id=\"title\"\n    value=\"{{ old('title') }}\"\n    class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('title') border-red-500 @enderror\"\n  />\n  @error('title')\n  <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n  @enderror\n</div>\n```\n\n三個關鍵點：\n\n1. **`old('title')`**——回填使用者上次輸入的值，驗證失敗後不用重新打字\n2. **`@error('title')`**——如果 `title` 欄位有錯誤，渲染裡面的內容\n3. **`{{ $message }}`**——該欄位的第一條錯誤訊息\n\n`@error` 是 Blade 的語法糖，等價於：\n\n```blade\n@if($errors->has('title'))\n<p>{{ $errors->first('title') }}</p>\n@endif\n```\n\n## 檔案上傳：Storage Facade 統一管理\n\n揪好買的開團表單需要上傳商品圖片。Laravel 的檔案處理圍繞一個核心概念：**Storage Facade**。不管你的檔案存在本地硬碟、Amazon S3、Google Cloud Storage 還是 DigitalOcean Spaces，程式碼寫法都一樣——只需要在設定檔切換 driver。\n\n### 儲存空間設定\n\n打開 `config/filesystems.php`：\n\n```php\n'disks' => [\n    'local' => [\n        'driver' => 'local',\n        'root' => storage_path('app/private'),\n    ],\n\n    'public' => [\n        'driver' => 'local',\n        'root' => storage_path('app/public'),\n        'url' => env('APP_URL') . '/storage',\n        'visibility' => 'public',\n    ],\n\n    's3' => [\n        'driver' => 's3',\n        'key' => env('AWS_ACCESS_KEY_ID'),\n        'secret' => env('AWS_SECRET_ACCESS_KEY'),\n        'region' => env('AWS_DEFAULT_REGION'),\n        'bucket' => env('AWS_BUCKET'),\n    ],\n],\n```\n\n三種常用 disk：\n\n- **`local`**——存在 `storage/app/private/`，外部無法存取（適合敏感文件）\n- **`public`**——存在 `storage/app/public/`，可透過 URL 存取（適合商品圖片）\n- **`s3`**——Amazon S3 或相容服務（正式環境推薦）\n\n### 建立 Symbolic Link\n\n`public` disk 的檔案存在 `storage/app/public/` 裡，但使用者透過瀏覽器只能存取 `public/` 目錄。Laravel 用一個 symbolic link 把兩者連起來：\n\n```bash\nphp artisan storage:link\n```\n\n這會建立 `public/storage` → `storage/app/public` 的捷徑。之後 `storage/app/public/images/product.jpg` 就能透過 `http://localhost:8000/storage/images/product.jpg` 存取。\n\n### 上傳檔案的基本操作\n\n```php\nuse Illuminate\\Support\\Facades\\Storage;\n\n// 儲存上傳的檔案（自動產生唯一檔名）\n$path = $request->file('image')->store('group-buys', 'public');\n// 回傳類似 \"group-buys/abc123def456.jpg\"\n\n// 指定檔名\n$path = $request->file('image')->storeAs(\n    'group-buys',\n    \"gb-{$groupBuy->id}.jpg\",\n    'public'\n);\n\n// 取得完整 URL\n$url = Storage::disk('public')->url($path);\n// \"http://localhost:8000/storage/group-buys/abc123def456.jpg\"\n\n// 刪除檔案\nStorage::disk('public')->delete($path);\n\n// 檢查檔案是否存在\nif (Storage::disk('public')->exists($path)) {\n    // ...\n}\n```\n\n### 檔案驗證規則\n\n上傳的檔案也要驗證——不只是檢查格式，更要限制大小，防止使用者傳一個 100MB 的檔案把你的硬碟塞爆：\n\n```php\n'image' => [\n    'nullable',           // 允許不上傳\n    'image',              // 必須是圖片（jpg, png, gif, bmp, svg, webp）\n    'mimes:jpg,png,webp', // 限制為這三種格式\n    'max:2048',           // 最大 2MB（2048 KB）\n    'dimensions:min_width=400,min_height=300',  // 最小尺寸\n],\n```\n\n> **為什麼限制 mimes？** `image` 規則會接受 GIF、BMP、SVG 等格式。但商品圖片我們只要 JPG、PNG、WebP——這三種壓縮效率好、瀏覽器支援完整。SVG 甚至可能藏 XSS 攻擊。\n\n## 圖片處理與縮圖\n\n使用者上傳的原始圖片可能是 4000x3000 像素、5MB 大小。直接當商品圖顯示太慢了。我們需要產生適合網頁顯示的縮圖。\n\n### 安裝 Intervention Image\n\n```bash\ncomposer require intervention/image\n```\n\n[Intervention Image](https://image.intervention.io/) 是 PHP 生態系最主流的圖片處理套件，提供簡潔的 API 來裁剪、縮放、加浮水印等。\n\n### 基本使用\n\n```php\nuse Intervention\\Image\\Laravel\\Facades\\Image;\nuse Intervention\\Image\\Encoders\\WebpEncoder;\n\n// 從上傳檔案建立 Image 實例\n$image = Image::read($request->file('image'));\n\n// 等比例縮放：寬度最大 800px，高度自動計算\n$image->scale(width: 800);\n\n// 裁剪成正方形（從中心裁）\n$image->cover(400, 400);\n\n// 轉成 WebP 格式並儲存\n$encoded = $image->encode(new WebpEncoder(quality: 80));\n\nStorage::disk('public')->put(\n    \"group-buys/{$groupBuy->id}.webp\",\n    $encoded\n);\n```\n\n### 產生多種尺寸\n\n商品圖片通常需要多種尺寸——列表頁的縮圖、詳情頁的大圖：\n\n```php\nprivate function processImage(UploadedFile $file, int $groupBuyId): string\n{\n    $image = Image::read($file);\n    $basePath = \"group-buys/{$groupBuyId}\";\n\n    // 原圖（限制最大寬度 1200px）\n    $original = (clone $image)->scale(width: 1200);\n    Storage::disk('public')->put(\n        \"{$basePath}/original.webp\",\n        $original->encode(new WebpEncoder(quality: 85))\n    );\n\n    // 縮圖（400x300，裁切）\n    $thumbnail = (clone $image)->cover(400, 300);\n    Storage::disk('public')->put(\n        \"{$basePath}/thumbnail.webp\",\n        $thumbnail->encode(new WebpEncoder(quality: 75))\n    );\n\n    return \"{$basePath}/original.webp\";\n}\n```\n\n> **為什麼用 WebP？** 相同畫質下，WebP 的檔案大小比 JPG 小 25-35%。2026 年所有主流瀏覽器都支援 WebP。除非你有特殊理由，新專案一律用 WebP。\n\n## 實作：揪好買「開團」表單\n\n把所有東西串起來。我們要實作完整的「開團」表單，包含驗證、圖片上傳、錯誤處理。\n\n### Step 1：Form Request\n\n前面已經寫好了 `StoreGroupBuyRequest`，這裡再加上圖片處理的準備方法：\n\n```php\n<?php\n\nnamespace App\\Http\\Requests;\n\nuse App\\Rules\\MinHoursFromNow;\nuse Illuminate\\Foundation\\Http\\FormRequest;\n\nclass StoreGroupBuyRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return $this->user()->can('create', \\App\\Models\\GroupBuy::class);\n    }\n\n    public function rules(): array\n    {\n        return [\n            'title'            => ['required', 'string', 'min:3', 'max:100'],\n            'description'      => ['required', 'string', 'max:2000'],\n            'product_name'     => ['required', 'string', 'max:100'],\n            'price_per_unit'   => ['required', 'integer', 'min:1'],\n            'min_participants' => ['required', 'integer', 'min:2', 'max:500'],\n            'max_participants' => ['nullable', 'integer', 'gte:min_participants'],\n            'deadline'         => ['required', 'date', new MinHoursFromNow(24)],\n            'image'            => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],\n        ];\n    }\n\n    public function messages(): array\n    {\n        return [\n            'title.required'       => '團購標題是必填的',\n            'title.min'            => '標題至少要 :min 個字',\n            'price_per_unit.min'   => '單價必須大於 0',\n            'min_participants.min' => '最低成團人數至少要 :min 人',\n            'max_participants.gte' => '人數上限不能小於最低成團人數',\n            'deadline.required'    => '請設定截止時間',\n            'image.max'            => '圖片大小不能超過 2MB',\n            'image.mimes'          => '圖片只接受 JPG、PNG、WebP 格式',\n        ];\n    }\n}\n```\n\n### Step 2：Controller\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nuse App\\Http\\Requests\\StoreGroupBuyRequest;\nuse App\\Models\\GroupBuy;\nuse Illuminate\\Support\\Facades\\Storage;\nuse Intervention\\Image\\Encoders\\WebpEncoder;\nuse Intervention\\Image\\Laravel\\Facades\\Image;\n\nclass GroupBuyController extends Controller\n{\n    public function create()\n    {\n        $this->authorize('create', GroupBuy::class);\n\n        return view('group-buys.create');\n    }\n\n    public function store(StoreGroupBuyRequest $request)\n    {\n        $validated = $request->validated();\n\n        // 建立團購（先不含圖片）\n        $groupBuy = GroupBuy::create([\n            ...$validated,\n            'user_id' => $request->user()->id,\n            'status'  => 'open',\n        ]);\n\n        // 處理圖片上傳\n        if ($request->hasFile('image')) {\n            $groupBuy->update([\n                'image_path' => $this->processImage($request->file('image'), $groupBuy->id),\n            ]);\n        }\n\n        return redirect(\"/group-buys/{$groupBuy->id}\")\n            ->with('success', '團購建立成功！開始分享給朋友吧。');\n    }\n\n    private function processImage($file, int $groupBuyId): string\n    {\n        $image = Image::read($file);\n        $basePath = \"group-buys/{$groupBuyId}\";\n\n        // 主圖\n        $main = (clone $image)->scale(width: 1200);\n        Storage::disk('public')->put(\n            \"{$basePath}/main.webp\",\n            $main->encode(new WebpEncoder(quality: 85))\n        );\n\n        // 縮圖\n        $thumb = (clone $image)->cover(400, 300);\n        Storage::disk('public')->put(\n            \"{$basePath}/thumb.webp\",\n            $thumb->encode(new WebpEncoder(quality: 75))\n        );\n\n        return \"{$basePath}/main.webp\";\n    }\n}\n```\n\nController 裡的 `store()` 只做三件事：取得驗證過的資料、建立記錄、處理圖片。驗證邏輯全在 `StoreGroupBuyRequest`，圖片處理抽成 `private` method。每個 public method 都很短、意圖明確。\n\n> **一個我必須誠實提醒的代價：這裡的 `processImage()` 是同步跑的。** `Image::read()` 解一張大圖、再 `scale` + `cover` 產兩種尺寸、各自編成 WebP——這是 CPU 密集活，一張手機拍的幾 MB 大圖跑下來，從幾百毫秒到一兩秒都有可能。而這整段是卡在 request 週期裡的：使用者按下「開團」之後，就在那邊乾等到圖片全部處理完才看到回應，同時你的 PHP worker 也被這一個請求佔住，PHP-FPM 的執行時間上限一到還可能直接逾時。流量小、原型階段這樣寫完全沒問題，簡單直接。但只要同時開團的人一多、或圖片普遍很大，正確的做法是先把原檔存好、立刻回應使用者，再把 `processImage` 丟到 Queue/Job 背景產縮圖。判斷點很簡單：當你開始看到上傳變慢或偶發逾時，就是該改成非同步的時候。\n\n### Step 3：Blade 表單\n\n`resources/views/group-buys/create.blade.php`：\n\n```blade\n<x-layouts.app title=\"開新團購\">\n  <div class=\"max-w-2xl mx-auto\">\n    <h1 class=\"text-2xl font-bold mb-6\">開新團購</h1>\n\n    {{-- 全域成功訊息 --}} @if(session('success'))\n    <div class=\"bg-green-100 text-green-700 px-4 py-3 rounded-lg mb-6\">\n      {{ session('success') }}\n    </div>\n    @endif\n\n    <form method=\"POST\" action=\"/group-buys\" enctype=\"multipart/form-data\" class=\"space-y-6\">\n      @csrf {{-- 團購標題 --}}\n      <div>\n        <label for=\"title\" class=\"block text-sm font-medium text-gray-700\">\n          團購標題 <span class=\"text-red-500\">*</span>\n        </label>\n        <input\n          type=\"text\"\n          name=\"title\"\n          id=\"title\"\n          value=\"{{ old('title') }}\"\n          placeholder=\"例：阿里山高山茶 春茶團購\"\n          class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('title') border-red-500 @enderror\"\n        />\n        @error('title')\n        <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n        @enderror\n      </div>\n\n      {{-- 商品名稱 --}}\n      <div>\n        <label for=\"product_name\" class=\"block text-sm font-medium text-gray-700\">\n          商品名稱 <span class=\"text-red-500\">*</span>\n        </label>\n        <input\n          type=\"text\"\n          name=\"product_name\"\n          id=\"product_name\"\n          value=\"{{ old('product_name') }}\"\n          placeholder=\"例：阿里山高山烏龍茶 150g\"\n          class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('product_name') border-red-500 @enderror\"\n        />\n        @error('product_name')\n        <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n        @enderror\n      </div>\n\n      {{-- 團購說明 --}}\n      <div>\n        <label for=\"description\" class=\"block text-sm font-medium text-gray-700\">\n          團購說明 <span class=\"text-red-500\">*</span>\n        </label>\n        {{-- 注意：old('description') 頂格寫、</textarea 拆行，是為了避免 textarea 渲染出多餘的前後空白，這是刻意的排版而非格式錯誤 --}}\n        <textarea\n          name=\"description\"\n          id=\"description\"\n          rows=\"4\"\n          placeholder=\"詳細描述商品內容、規格、取貨方式...\"\n          class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('description') border-red-500 @enderror\"\n        >\n{{ old('description') }}</textarea\n        >\n        @error('description')\n        <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n        @enderror\n      </div>\n\n      {{-- 單價與人數（兩欄並排） --}}\n      <div class=\"grid grid-cols-1 md:grid-cols-3 gap-4\">\n        <div>\n          <label for=\"price_per_unit\" class=\"block text-sm font-medium text-gray-700\">\n            每份單價 (NT$) <span class=\"text-red-500\">*</span>\n          </label>\n          <input\n            type=\"number\"\n            name=\"price_per_unit\"\n            id=\"price_per_unit\"\n            value=\"{{ old('price_per_unit') }}\"\n            min=\"1\"\n            placeholder=\"250\"\n            class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('price_per_unit') border-red-500 @enderror\"\n          />\n          @error('price_per_unit')\n          <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n          @enderror\n        </div>\n\n        <div>\n          <label for=\"min_participants\" class=\"block text-sm font-medium text-gray-700\">\n            最低成團人數 <span class=\"text-red-500\">*</span>\n          </label>\n          <input\n            type=\"number\"\n            name=\"min_participants\"\n            id=\"min_participants\"\n            value=\"{{ old('min_participants') }}\"\n            min=\"2\"\n            placeholder=\"5\"\n            class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('min_participants') border-red-500 @enderror\"\n          />\n          @error('min_participants')\n          <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n          @enderror\n        </div>\n\n        <div>\n          <label for=\"max_participants\" class=\"block text-sm font-medium text-gray-700\">\n            人數上限\n          </label>\n          <input\n            type=\"number\"\n            name=\"max_participants\"\n            id=\"max_participants\"\n            value=\"{{ old('max_participants') }}\"\n            placeholder=\"不限\"\n            class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('max_participants') border-red-500 @enderror\"\n          />\n          @error('max_participants')\n          <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n          @enderror\n        </div>\n      </div>\n\n      {{-- 截止時間 --}}\n      <div>\n        <label for=\"deadline\" class=\"block text-sm font-medium text-gray-700\">\n          截止時間 <span class=\"text-red-500\">*</span>\n        </label>\n        <input\n          type=\"datetime-local\"\n          name=\"deadline\"\n          id=\"deadline\"\n          value=\"{{ old('deadline') }}\"\n          class=\"mt-1 w-full rounded-lg border px-4 py-2 @error('deadline') border-red-500 @enderror\"\n        />\n        <p class=\"mt-1 text-xs text-gray-400\">截止時間必須是至少 24 小時後</p>\n        @error('deadline')\n        <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n        @enderror\n      </div>\n\n      {{-- 商品圖片 --}}\n      <div>\n        <label for=\"image\" class=\"block text-sm font-medium text-gray-700\">商品圖片</label>\n        <input\n          type=\"file\"\n          name=\"image\"\n          id=\"image\"\n          accept=\"image/jpeg,image/png,image/webp\"\n          class=\"mt-1 w-full text-sm text-gray-500\n                        file:mr-4 file:py-2 file:px-4\n                        file:rounded-lg file:border-0\n                        file:text-sm file:font-medium\n                        file:bg-indigo-50 file:text-indigo-700\n                        hover:file:bg-indigo-100\"\n        />\n        <p class=\"mt-1 text-xs text-gray-400\">JPG、PNG、WebP 格式，最大 2MB</p>\n        @error('image')\n        <p class=\"mt-1 text-sm text-red-600\">{{ $message }}</p>\n        @enderror\n      </div>\n\n      {{-- 送出按鈕 --}}\n      <div class=\"flex justify-end gap-4\">\n        <a href=\"/group-buys\" class=\"px-6 py-2 rounded-lg border text-gray-600 hover:bg-gray-50\">\n          取消\n        </a>\n        <button\n          type=\"submit\"\n          class=\"px-6 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition\"\n        >\n          建立團購\n        </button>\n      </div>\n    </form>\n  </div>\n</x-layouts.app>\n```\n\n幾個重要細節：\n\n1. **`enctype=\"multipart/form-data\"`**——有檔案上傳的表單一定要加這個，否則後端收不到檔案。這是很多新手會忘的坑。\n2. **`@csrf`**——Laravel 的 CSRF 保護。沒有這個 token，POST 請求會被直接拒絕（419 status code）。\n3. **`old('field')`**——每個 input 都用 `old()` 回填值，驗證失敗時使用者不用重新填寫。\n4. **`@error('field')`**——每個欄位下面都有錯誤訊息區域，只在驗證失敗時顯示。\n5. **`accept=\"image/jpeg,image/png,image/webp\"`**——前端的檔案類型限制，讓使用者在選檔案時只看到支援的格式。記住，這只是 UX 優化，後端的 `mimes` 規則才是真正的驗證。\n\n### Step 4：路由\n\n確認 `routes/web.php` 有這兩條路由（第六章應該已經加了）：\n\n```php\nRoute::middleware(['auth', 'verified'])->group(function () {\n    Route::get('/group-buys/create', [GroupBuyController::class, 'create']);\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n});\n```\n\n### Step 5：驗證流程完整走一遍\n\n```bash\nphp artisan serve\nnpm run dev\n```\n\n打開 `http://localhost:8000/group-buys/create`（需先登入）：\n\n1. **什麼都不填直接送出** → 每個必填欄位下方都出現紅色錯誤訊息\n2. **標題只填 1 個字** → 出現「標題至少要 3 個字」\n3. **截止時間設成 2 小時後** → 出現「截止時間必須是至少 24 小時後」\n4. **上傳一個 5MB 的 PNG** → 出現「圖片大小不能超過 2MB」\n5. **全部填正確** → 建立成功，重新導向到團購詳情頁\n\n全程沒寫任何 JavaScript，表單驗證、錯誤顯示、值回填全部由 Laravel + Blade 搞定。\n\n### 更新團購時的驗證\n\n建立（Store）和更新（Update）的驗證規則通常很像但不完全一樣。例如更新時圖片不是必要的（保留舊圖），`unique` 規則要排除自己。你可以另建一個 `UpdateGroupBuyRequest`：\n\n```php\nclass UpdateGroupBuyRequest extends FormRequest\n{\n    public function authorize(): bool\n    {\n        return $this->user()->can('update', $this->route('groupBuy'));\n    }\n\n    public function rules(): array\n    {\n        return [\n            'title'            => ['required', 'string', 'min:3', 'max:100'],\n            'description'      => ['required', 'string', 'max:2000'],\n            'product_name'     => ['required', 'string', 'max:100'],\n            'price_per_unit'   => ['required', 'integer', 'min:1'],\n            'min_participants' => ['required', 'integer', 'min:2', 'max:500'],\n            'max_participants' => ['nullable', 'integer', 'gte:min_participants'],\n            'deadline'         => ['required', 'date', 'after:today'],\n            // 更新時圖片完全可選\n            'image'            => ['nullable', 'image', 'mimes:jpg,png,webp', 'max:2048'],\n        ];\n    }\n}\n```\n\n在 Controller 裡更新時，要記得刪除舊圖片：\n\n```php\npublic function update(UpdateGroupBuyRequest $request, GroupBuy $groupBuy)\n{\n    $groupBuy->update($request->validated());\n\n    if ($request->hasFile('image')) {\n        // 刪除舊圖片\n        if ($groupBuy->image_path) {\n            Storage::disk('public')->deleteDirectory(\n                dirname($groupBuy->image_path)\n            );\n        }\n\n        $groupBuy->update([\n            'image_path' => $this->processImage($request->file('image'), $groupBuy->id),\n        ]);\n    }\n\n    return redirect(\"/group-buys/{$groupBuy->id}\")\n        ->with('success', '團購已更新');\n}\n```\n\n## 小結：驗證是對使用者的尊重\n\n好的驗證不只是擋壞資料——它是在告訴使用者「我知道你哪裡填錯了，這是正確的方向」。清楚的錯誤訊息、自動回填的表單值、友善的中文提示，這些細節讓使用者感受到產品的用心。\n\n這一章我們走過了 Laravel 驗證與檔案上傳的完整流程：\n\n- **後端驗證是唯一防線**——永遠不要信任前端，`curl` 一行就能繞過所有 JavaScript\n- **`$request->validate()`**——最快的驗證方式，適合簡單場景\n- **Form Request**——把驗證邏輯抽出 Controller，保持程式碼精簡。`rules()`、`authorize()`、`messages()` 三個方法各司其職\n- **自訂規則**——Closure rule 快速搞定一次性需求，Rule class 處理可重用的業務規則\n- **中文化**——`lang/zh_TW/validation.php` 搭配 `attributes` 設定，讓錯誤訊息說人話\n- **Storage Facade**——統一的檔案操作 API，`store()`、`storeAs()`、`delete()`，切換 S3 只改設定檔\n- **Intervention Image**——圖片縮放、裁剪、轉 WebP，產生適合網頁的多種尺寸\n\n**揪好買進度：**\n\n- ✅ `StoreGroupBuyRequest` 完整驗證規則\n- ✅ `MinHoursFromNow` 自訂驗證規則\n- ✅ 中文錯誤訊息與欄位名稱\n- ✅ 「開團」表單完整 Blade 模板（含 `@error`、`old()`）\n- ✅ 商品圖片上傳、縮圖生成\n- ✅ Controller 保持精簡（每個 method 不超過十行核心邏輯）\n\n下一章，我們要進入揪好買的心臟——[跟團與成團邏輯](/blog/laravel-guide-group-buy-logic-session)。使用者怎麼「+1」跟團、什麼時候成團、截止時間到了怎麼處理。你會學到 Laravel 的 Session 機制、Cache facade、以及怎麼把模糊的業務需求轉化成清楚的程式碼。",
      "summary": "學會用 Laravel 12 打造安全可靠的表單驗證與檔案上傳：從 inline validate、Form Request 抽離驗證邏輯、自訂 Rule class，到 Storage facade 統一管理本地與 S3 檔案、Intervention Image 產生 WebP 縮圖。以「揪好買」團購平台的開團表單為例，完整實作驗證規則、中文化錯誤訊息與圖片上傳流程。",
      "image": "https://bobochen.dev/_astro/cover.PpZ4vs4I.webp",
      "date_published": "2025-04-15T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Validation",
        "File Upload",
        "Form Request"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-auth-breeze-authorization/",
      "url": "https://bobochen.dev/blog/laravel-guide-auth-breeze-authorization/",
      "title": "Laravel 認證與授權：用 Starter Kit 十分鐘搞定會員系統",
      "content_text": "每個有使用者的應用都逃不過認證與授權。本章用 Laravel 12 官方 Starter Kit（Livewire 版）一行指令搞定註冊、登入、忘記密碼與 Email 驗證，再用 Gate 與 Policy 做細粒度授權，並示範以 role 欄位與 Enum 實作角色權限，打造完整會員系統。",
      "content_html": "每一個有使用者的應用程式，都逃不過這兩件事：認證（Authentication）和授權（Authorization）。認證是「你是誰」，授權是「你能做什麼」。聽起來簡單，但自己從零刻一套會員系統——處理密碼雜湊、Session 管理、忘記密碼信件、Email 驗證、CSRF 防護——光想就頭痛。更何況，這些東西稍有閃失就是資安漏洞。\n\n好消息是，Laravel 根本不讓你自己造這個輪子。Laravel 12 推出了全新的官方 **Starter Kit**，取代了舊版的 Breeze 和 Jetstream。一行指令就幫你搞定註冊、登入、密碼重設、Email 驗證的完整流程，連前端 UI 都幫你生好了。你可以選擇 React、Vue、Livewire（推出時的三種官方選項，2026 年 2 月再補上 Svelte）作為前端堆疊——我們選 Livewire，因為整本書都在 PHP 生態系裡。\n\n在「揪好買」團購平台裡，我們有兩種角色：開團主和跟團者。開團主可以建立團購、設定截止時間、管理訂單；跟團者只能瀏覽和加入。這一章，我們要用 Starter Kit 搞定會員系統，再用 Policy 把「只有開團主能建立團購」這條規則寫得乾淨俐落。\n\n## 認證 vs 授權：先搞清楚這兩件事\n\n這兩個詞經常被混用，但它們是完全不同的概念：\n\n|              | 認證（Authentication） | 授權（Authorization）  |\n| ------------ | ---------------------- | ---------------------- |\n| 問題         | 你是誰？               | 你能做什麼？           |\n| 時機         | 登入時                 | 每次操作時             |\n| 失敗結果     | 401 Unauthorized       | 403 Forbidden          |\n| Laravel 工具 | Guard、Session、Token  | Gate、Policy           |\n| 類比         | 門口刷員工證           | 你的員工證能進哪些房間 |\n\n認證通常只需要做一次（用套件），授權則貫穿整個應用程式（用你的業務邏輯）。這一章前半講認證，後半講授權。\n\n### 跨框架對照\n\n| 概念       | Laravel        | Express (Node.js) | Django (Python)          |\n| ---------- | -------------- | ----------------- | ------------------------ |\n| 認證套件   | Starter Kit    | Passport.js       | django.contrib.auth      |\n| Session    | 內建           | express-session   | 內建                     |\n| 密碼雜湊   | `Hash::make()` | bcrypt            | `make_password()`        |\n| 授權       | Gate / Policy  | 自己寫 middleware | Permissions / Decorators |\n| Token 認證 | Sanctum        | jsonwebtoken      | DRF TokenAuth            |\n\n## Laravel 12 Starter Kit：十分鐘擁有完整會員系統\n\n### 什麼是 Starter Kit？\n\nLaravel 12 的 Starter Kit 是官方維護的**應用程式啟動模板**。它直接把認證相關的 Controller、View、Route 全部放進你的專案裡——不是 Composer 套件，而是真正的程式碼。你可以看到每一行、改每一處。\n\n> **歷史脈絡：** Laravel 11 以前用的是 Breeze（輕量）和 Jetstream（重量級，含 Team 管理）。Laravel 12 把它們統一成了新的 Starter Kit 系列，推出時提供 React、Vue、Livewire 三種前端選項，2026 年 2 月再加入官方 Svelte + Inertia kit，目前共四種。如果你看到舊教學提到 Breeze，概念是一樣的，只是安裝方式不同。\n\n### 安裝 Livewire Starter Kit\n\n如果你在[第二章建專案](/blog/laravel-guide-setup-first-route/)時選了 \"No starter kit\"，現在可以重新建一個：\n\n```bash\nlaravel new jiu-hao-mai\n```\n\n在互動式選單中選擇：\n\n```text\n ┌ Would you like to install a starter kit? ──────┐\n │ › Livewire                                       │\n └──────────────────────────────────────────────────┘\n```\n\n或者，如果你想在現有專案上操作，最簡單的做法是用 `laravel new` 建一個新專案，再把認證相關的檔案複製過來。\n\n### 安裝完你得到了什麼？\n\nStarter Kit 幫你生成的東西：\n\n```text\napp/\n└── Livewire/Auth/               # 認證邏輯放在 Livewire 元件，而非傳統 Controller\n    ├── Login.php\n    ├── Register.php\n    ├── ResetPassword.php\n    └── VerifyEmail.php\nresources/views/\n├── livewire/auth/               # 認證頁面模板\n│   ├── login.blade.php\n│   ├── register.blade.php\n│   └── ...\n├── components/layouts/\n│   └── app.blade.php            # 應用程式 Layout\nroutes/\n└── auth.php                     # 認證路由（Volt::route 風格）\n```\n\n> **注意：** Laravel 12 Livewire Starter Kit **不會**產生傳統的 `app/Http/Controllers/Auth/` 目錄。認證邏輯改用 Livewire 元件（`app/Livewire/Auth/`）處理，不再是 LoginController、RegisterController 等傳統 Controller。\n\n所有程式碼都在你的專案裡——不是躲在 `vendor/` 裡的黑盒子。\n\n## 註冊、登入、忘記密碼：開箱即用\n\n安裝完 Starter Kit 後，這些路由就自動可用了：\n\n| 路由                          | 功能           |\n| ----------------------------- | -------------- |\n| `GET /register`               | 註冊頁面       |\n| `POST /register`              | 處理註冊       |\n| `GET /login`                  | 登入頁面       |\n| `POST /login`                 | 處理登入       |\n| `POST /logout`                | 登出           |\n| `GET /forgot-password`        | 忘記密碼頁面   |\n| `POST /forgot-password`       | 寄送重設密碼信 |\n| `GET /reset-password/{token}` | 重設密碼頁面   |\n| `POST /reset-password`        | 處理密碼重設   |\n\n```bash\nphp artisan serve\n# 打開 http://localhost:8000/register\n```\n\n你會看到一個設計好的註冊頁面。填入資料、送出，帳號就建好了。登入、登出、忘記密碼——全部能用。\n\n### 在 Controller 裡取得當前使用者\n\n```php\nuse Illuminate\\Http\\Request;\n\nclass DashboardController extends Controller\n{\n    public function index(Request $request)\n    {\n        // 方法一：從 Request 物件取\n        $user = $request->user();\n\n        // 方法二：用 Auth Facade\n        $user = auth()->user();\n\n        // 方法三：取得使用者 ID\n        $userId = auth()->id();\n\n        // 檢查是否已登入\n        if (auth()->check()) {\n            // 已登入\n        }\n\n        return view('dashboard', compact('user'));\n    }\n}\n```\n\n### 保護路由（要求登入才能存取）\n\n```php\n// routes/web.php\n\n// 方法一：單一路由\nRoute::get('/dashboard', [DashboardController::class, 'index'])\n    ->middleware('auth');\n\n// 方法二：路由群組（常用）\nRoute::middleware('auth')->group(function () {\n    Route::get('/dashboard', [DashboardController::class, 'index']);\n    Route::get('/my-groups', [GroupBuyController::class, 'myGroups']);\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n});\n\n// 方法三：只給「未登入」使用者（如登入頁）\nRoute::get('/login', [LoginController::class, 'show'])\n    ->middleware('guest');\n```\n\n`auth` middleware 的邏輯很簡單：使用者沒登入 → 重新導向到 `/login`。就這樣。\n\n### 密碼雜湊\n\nLaravel 自動用 bcrypt 雜湊密碼，你永遠不會在資料庫裡看到明文密碼：\n\n```php\nuse Illuminate\\Support\\Facades\\Hash;\n\n// 建立雜湊（註冊時用）\n$hashed = Hash::make('my-password');\n// $2y$12$eUxcJq1...（60 字元雜湊字串）\n\n// 驗證密碼（登入時用）\nif (Hash::check('my-password', $hashed)) {\n    // 密碼正確\n}\n```\n\n> **重要：** 永遠不要自己寫登入驗證邏輯。Starter Kit 已經幫你處理好了密碼雜湊、防暴力破解（rate limiting）、CSRF 防護等所有安全細節。\n\n## Email 驗證：確保使用者是真人\n\nEmail 驗證是「註冊後寄一封確認信，使用者點連結才算驗證完成」的功能。\n\n### 啟用 Email 驗證\n\n讓 User Model 實作 `MustVerifyEmail` 介面：\n\n```php\n// app/Models/User.php\nuse Illuminate\\Contracts\\Auth\\MustVerifyEmail;\n\nclass User extends Authenticatable implements MustVerifyEmail\n{\n    // ...\n}\n```\n\n就這一行，Laravel 會自動：\n\n- 在註冊後寄出驗證信\n- 提供 `/verify-email` 頁面\n- 驗證連結有簽名保護（防偽造）\n\n### 限制未驗證使用者\n\n```php\nRoute::middleware(['auth', 'verified'])->group(function () {\n    // 這裡的路由只有驗證過 email 的使用者才能進\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n});\n```\n\n### 開發環境的 Email\n\n開發階段不用真的寄信。`.env` 預設用 `log` driver：\n\n```bash\nMAIL_MAILER=log\n```\n\n所有「寄出」的信件都會記錄在 `storage/logs/laravel.log` 裡，你可以從 log 裡找到驗證連結。\n\n## Gate 與 Policy：誰可以做什麼事\n\n認證搞定了（你是誰），現在來處理授權（你能做什麼）。\n\n### Gate：簡單的授權檢查\n\nGate 是最基本的授權方式——定義一個 closure，回傳 `true` 或 `false`：\n\n```php\n// app/Providers/AppServiceProvider.php 的 boot() 裡\nuse Illuminate\\Support\\Facades\\Gate;\nuse App\\Models\\GroupBuy;\nuse App\\Models\\User;\n\npublic function boot(): void\n{\n    Gate::define('create-group-buy', function (User $user) {\n        return $user->is_organizer;\n    });\n\n    Gate::define('update-group-buy', function (User $user, GroupBuy $groupBuy) {\n        return $user->id === $groupBuy->user_id;\n    });\n}\n```\n\n使用方式：\n\n```php\n// 在 Controller 裡\nif (Gate::allows('create-group-buy')) {\n    // 可以建立團購\n}\n\nif (Gate::denies('update-group-buy', $groupBuy)) {\n    abort(403);\n}\n\n// 更簡潔：authorize（失敗自動丟 403）\nGate::authorize('create-group-buy');\n\n// 在 Blade 裡\n@can('create-group-buy')\n    <a href=\"/group-buys/create\">+ 我要開團</a>\n@endcan\n\n@cannot('update-group-buy', $groupBuy)\n    <p>你沒有權限編輯這個團購</p>\n@endcannot\n```\n\n### Policy：更有組織的授權\n\nGate 適合一兩條簡單規則。當授權邏輯變多，**Policy** 把同一個 Model 的授權規則集中在一個 class 裡：\n\n```bash\nphp artisan make:policy GroupBuyPolicy --model=GroupBuy\n```\n\n```php\n<?php\n\nnamespace App\\Policies;\n\nuse App\\Models\\GroupBuy;\nuse App\\Models\\User;\n\nclass GroupBuyPolicy\n{\n    /**\n     * 誰可以看列表？所有人。\n     */\n    public function viewAny(?User $user): bool\n    {\n        return true;  // ?User 表示未登入也可以\n    }\n\n    /**\n     * 誰可以看單一團購？所有人。\n     */\n    public function view(?User $user, GroupBuy $groupBuy): bool\n    {\n        return true;\n    }\n\n    /**\n     * 誰可以建立團購？已驗證 email 的使用者。\n     */\n    public function create(User $user): bool\n    {\n        return $user->hasVerifiedEmail();\n    }\n\n    /**\n     * 誰可以更新團購？只有開團者本人。\n     */\n    public function update(User $user, GroupBuy $groupBuy): bool\n    {\n        return $user->id === $groupBuy->user_id;\n    }\n\n    /**\n     * 誰可以刪除團購？\n     * 只有開團者本人，而且團購還沒有人加入。\n     */\n    public function delete(User $user, GroupBuy $groupBuy): bool\n    {\n        return $user->id === $groupBuy->user_id\n            && $groupBuy->participants()->count() === 0;\n    }\n}\n```\n\n### 在 Controller 裡使用 Policy\n\n```php\nclass GroupBuyController extends Controller\n{\n    public function index()\n    {\n        // viewAny 不需要特定 model 實例\n        $this->authorize('viewAny', GroupBuy::class);\n\n        return view('group-buys.index');\n    }\n\n    public function create()\n    {\n        $this->authorize('create', GroupBuy::class);\n\n        return view('group-buys.create');\n    }\n\n    public function store(Request $request)\n    {\n        $this->authorize('create', GroupBuy::class);\n\n        // 建立團購...\n    }\n\n    public function edit(GroupBuy $groupBuy)\n    {\n        $this->authorize('update', $groupBuy);\n\n        return view('group-buys.edit', compact('groupBuy'));\n    }\n\n    public function destroy(GroupBuy $groupBuy)\n    {\n        $this->authorize('delete', $groupBuy);\n\n        $groupBuy->delete();\n\n        return redirect('/group-buys')->with('success', '團購已刪除');\n    }\n}\n```\n\n`$this->authorize()` 會自動找到 `GroupBuyPolicy`（靠命名慣例），檢查對應的方法。失敗就丟 403 Forbidden。\n\n> **Policy 的自動發現：** Laravel 會自動把 `GroupBuy` Model 對應到 `GroupBuyPolicy`。你不需要手動註冊——命名慣例搞定一切。\n\n### 在 Blade 裡使用 Policy\n\n```blade\n@can('create', App\\Models\\GroupBuy::class)\n<a href=\"/group-buys/create\" class=\"btn-primary\">+ 我要開團</a>\n@endcan\n\n@can('update', $groupBuy)\n<a href=\"/group-buys/{{ $groupBuy->id }}/edit\">編輯</a>\n@endcan\n\n@can('delete', $groupBuy)\n<form method=\"POST\" action=\"/group-buys/{{ $groupBuy->id }}\">\n  @csrf\n  @method('DELETE')\n  <button type=\"submit\" class=\"text-red-600\">刪除</button>\n</form>\n@endcan\n```\n\n## Role-based 權限設計模式\n\n揪好買需要區分「開團主」和「跟團者」。最簡單的做法是在 `users` 表加一個 `role` 欄位：\n\n### 方案一：簡單的 role 欄位\n\n```bash\nphp artisan make:migration add_role_to_users_table\n```\n\n```php\npublic function up(): void\n{\n    Schema::table('users', function (Blueprint $table) {\n        $table->string('role')->default('member');  // member, organizer, admin\n    });\n}\n```\n\n在 User Model 加上 helper 方法：\n\n```php\n// app/Models/User.php\n\n// 用 Enum 定義角色（型別安全）\nenum UserRole: string\n{\n    case Member = 'member';\n    case Organizer = 'organizer';\n    case Admin = 'admin';\n}\n\nclass User extends Authenticatable implements MustVerifyEmail\n{\n    protected function casts(): array\n    {\n        return [\n            'email_verified_at' => 'datetime',\n            'password' => 'hashed',\n            'role' => UserRole::class,\n        ];\n    }\n\n    public function isOrganizer(): bool\n    {\n        return $this->role === UserRole::Organizer\n            || $this->role === UserRole::Admin;\n    }\n\n    public function isAdmin(): bool\n    {\n        return $this->role === UserRole::Admin;\n    }\n}\n```\n\n然後在 Policy 裡使用：\n\n```php\npublic function create(User $user): bool\n{\n    return $user->isOrganizer() && $user->hasVerifiedEmail();\n}\n```\n\n### 方案二：Spatie Permission 套件（多角色/多權限）\n\n如果需要更複雜的權限系統（一個使用者可以有多個角色、角色可以有多個權限），推薦用 [spatie/laravel-permission](https://spatie.be/docs/laravel-permission)：\n\n```bash\ncomposer require spatie/laravel-permission\nphp artisan vendor:publish --provider=\"Spatie\\Permission\\PermissionServiceProvider\"\nphp artisan migrate\n```\n\n```php\n// 指派角色\n$user->assignRole('organizer');\n\n// 檢查角色\n$user->hasRole('organizer');\n\n// 指派權限\n$user->givePermissionTo('create group buys');\n\n// 在 Policy 裡用\npublic function create(User $user): bool\n{\n    return $user->can('create group buys');\n}\n```\n\n> **揪好買用哪個？** 我們的需求很簡單（member / organizer / admin 三種角色），方案一的 `role` 欄位就夠了。除非你的專案有「一個使用者同時是多個角色」或「權限需要動態新增/移除」的需求，才需要 Spatie Permission。YAGNI——You Ain't Gonna Need It。\n\n## Guard：多重認證系統\n\nGuard 決定的是「用什麼方式認證使用者」。預設的 `web` guard 用 Session + Cookie，`api` guard 用 Token。\n\n大多數應用只用一個 Guard，你不需要動它。只有在這些情況下才需要自訂 Guard：\n\n- **前後台分離**——Admin 和一般使用者用不同的 users 表\n- **API 認證**——用 Sanctum token 而不是 Session\n- **多租戶**——不同租戶有不同的認證方式\n\n```php\n// config/auth.php（通常不需要改）\n'guards' => [\n    'web' => [\n        'driver' => 'session',\n        'provider' => 'users',\n    ],\n    // 安裝 Sanctum 後會多一個 sanctum guard\n],\n```\n\n揪好買目前只需要 `web` guard，第十一章做 API 時才會用到 [Sanctum](/blog/laravel-guide-api-sanctum-rest/)。\n\n## 實作：揪好買的開團主與跟團者\n\n讓我們把認證和授權整合進揪好買。\n\n### Step 1：加入 role 欄位\n\n```bash\nphp artisan make:migration add_role_to_users_table\n```\n\n```php\npublic function up(): void\n{\n    Schema::table('users', function (Blueprint $table) {\n        $table->string('role')->default('member');\n    });\n}\n```\n\n```bash\nphp artisan migrate\n```\n\n### Step 2：更新 User Model\n\n在 `app/Models/User.php` 加入：\n\n```php\nuse App\\Enums\\UserRole;\n\n// 在 $fillable 加入 'role'\nprotected $fillable = [\n    'name', 'email', 'password', 'role',\n];\n\nprotected function casts(): array\n{\n    return [\n        'email_verified_at' => 'datetime',\n        'password' => 'hashed',\n        'role' => UserRole::class,\n    ];\n}\n\npublic function isOrganizer(): bool\n{\n    return $this->role === UserRole::Organizer\n        || $this->role === UserRole::Admin;\n}\n\npublic function isAdmin(): bool\n{\n    return $this->role === UserRole::Admin;\n}\n```\n\n建立 Enum `app/Enums/UserRole.php`：\n\n```php\n<?php\n\nnamespace App\\Enums;\n\nenum UserRole: string\n{\n    case Member = 'member';\n    case Organizer = 'organizer';\n    case Admin = 'admin';\n\n    public function label(): string\n    {\n        return match ($this) {\n            self::Member => '一般會員',\n            self::Organizer => '開團主',\n            self::Admin => '管理員',\n        };\n    }\n}\n```\n\n### Step 3：建立 GroupBuyPolicy\n\n```bash\nphp artisan make:policy GroupBuyPolicy --model=GroupBuy\n```\n\n把前面的 Policy 程式碼放進去（viewAny、view、create、update、delete）。\n\n### Step 4：保護路由\n\n```php\n// routes/web.php\nuse App\\Http\\Controllers\\GroupBuyController;\n\n// 任何人都能看\nRoute::get('/group-buys', [GroupBuyController::class, 'index']);\nRoute::get('/group-buys/{groupBuy}', [GroupBuyController::class, 'show']);\n\n// 需要登入 + Email 驗證\nRoute::middleware(['auth', 'verified'])->group(function () {\n    Route::get('/group-buys/create', [GroupBuyController::class, 'create']);\n    Route::post('/group-buys', [GroupBuyController::class, 'store']);\n    Route::get('/group-buys/{groupBuy}/edit', [GroupBuyController::class, 'edit']);\n    Route::put('/group-buys/{groupBuy}', [GroupBuyController::class, 'update']);\n    Route::delete('/group-buys/{groupBuy}', [GroupBuyController::class, 'destroy']);\n});\n```\n\n### Step 5：在 View 裡顯示角色相關 UI\n\n```blade\n{{-- 導覽列 --}}\n<nav>\n  @auth\n  <span>{{ auth()->user()->name }}（{{ auth()->user()->role->label() }}）</span>\n\n  @can('create', App\\Models\\GroupBuy::class)\n  <a href=\"/group-buys/create\">+ 我要開團</a>\n  @endcan\n  @endauth\n</nav>\n\n{{-- 團購詳情頁 --}}\n@can('update', $groupBuy)\n<a href=\"/group-buys/{{ $groupBuy->id }}/edit\" class=\"btn\">編輯團購</a>\n@endcan\n\n@can('delete', $groupBuy)\n<form method=\"POST\" action=\"/group-buys/{{ $groupBuy->id }}\">\n  @csrf\n  @method('DELETE')\n  <button onclick=\"return confirm('確定要刪除？')\" class=\"text-red-600\">刪除</button>\n</form>\n@endcan\n```\n\n> **講真的，這一步最容易讓新手誤會：`@can` 只是把按鈕藏起來，它不是防線。** 編輯按鈕看不到，不代表那個 endpoint 被擋住了。任何人打開 DevTools、或直接用 curl／Postman 對著 `PUT /group-buys/5` 送一發請求，照樣會打進你的 Controller。真正的防線在後端——所以每一個會改資料的動作（store / update / destroy）都得自己再 `authorize()` 一次。少了這層，就是教科書等級的 IDOR 越權漏洞：A 開的團，B 改網址裡的 id 就能刪。\n\n所以 Step 4 在路由套 `auth + verified` 還不夠——那只擋「沒登入的人」，擋不了「登入了但不該動這筆資料的人」。把前面那個 `GroupBuyController` 的 `authorize()` 真的接上去，每個寫入動作都檢查一次：\n\n```php\nclass GroupBuyController extends Controller\n{\n    public function store(Request $request)\n    {\n        $this->authorize('create', GroupBuy::class);\n        // 通過才建立團購...\n    }\n\n    public function update(Request $request, GroupBuy $groupBuy)\n    {\n        $this->authorize('update', $groupBuy);\n        // 通過才更新...\n    }\n\n    public function destroy(GroupBuy $groupBuy)\n    {\n        $this->authorize('delete', $groupBuy);\n        $groupBuy->delete();\n\n        return redirect('/group-buys')->with('success', '團購已刪除');\n    }\n}\n```\n\n懶一點也可以走路由層，把檢查直接掛在路由上，效果一樣：`Route::put('/group-buys/{groupBuy}', ...)->can('update', 'groupBuy');`。重點不是用哪種寫法，是「Blade 藏按鈕」跟「後端擋請求」永遠要成對出現，缺一個都不算授權做完。\n\n### Step 6：Seeder 建立測試帳號\n\n```php\n// database/seeders/DatabaseSeeder.php\npublic function run(): void\n{\n    // 建立管理員\n    User::factory()->create([\n        'name' => 'Admin',\n        'email' => 'admin@jiuhaomai.tw',\n        'role' => 'admin',\n    ]);\n\n    // 建立開團主\n    User::factory()->count(3)->create([\n        'role' => 'organizer',\n    ]);\n\n    // 建立一般會員\n    User::factory()->count(10)->create([\n        'role' => 'member',\n    ]);\n\n    // ...GroupBuy seeder\n}\n```\n\n```bash\nphp artisan migrate:fresh --seed\n```\n\n現在你可以用 `admin@jiuhaomai.tw` 登入，看到所有管理功能；用一般會員登入，只能看和跟團。\n\n## 小結：認證用套件，授權用 Policy\n\n這一章我們走過了 Laravel 認證授權的完整流程：\n\n**認證（你是誰）：**\n\n- Laravel 12 Starter Kit（Livewire 版）——一行安裝，開箱即用\n- 註冊、登入、登出、忘記密碼、Email 驗證全自動\n- `auth` middleware 保護路由，`auth()->user()` 取得當前使用者\n- 密碼自動 bcrypt 雜湊，永遠不要自己處理密碼\n\n**授權（你能做什麼）：**\n\n- Gate——簡單的一次性授權檢查\n- Policy——依 Model 組織的授權類別，自動發現\n- `$this->authorize()` 在 Controller 裡使用，`@can` 在 Blade 裡使用\n- Role-based 權限——簡單需求用 `role` 欄位 + Enum，複雜需求用 Spatie Permission\n\n**揪好買進度：**\n\n- ✅ User Model 加入 `role` 欄位（member / organizer / admin）\n- ✅ UserRole Enum 定義角色和中文標籤\n- ✅ GroupBuyPolicy 定義 CRUD 權限規則\n- ✅ 路由保護（`auth` + `verified` middleware）\n- ✅ Blade 裡用 `@can` 顯示/隱藏操作按鈕\n\n下一章，我們要讓開團主真正地建立團購——[表單驗證與檔案上傳](/blog/laravel-guide-validation-file-upload/)。你會學到 Laravel 強大的 Validation 規則系統、Form Request、以及用 Storage facade 處理商品圖片上傳。",
      "summary": "每個有使用者的應用都逃不過認證與授權。本章用 Laravel 12 官方 Starter Kit（Livewire 版）一行指令搞定註冊、登入、忘記密碼與 Email 驗證，再用 Gate 與 Policy 做細粒度授權，並示範以 role 欄位與 Enum 實作角色權限，打造完整會員系統。",
      "image": "https://bobochen.dev/_astro/cover.B-g-TAeN.webp",
      "date_published": "2025-04-08T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Authentication",
        "Authorization",
        "Starter Kit",
        "Livewire"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-blade-livewire-frontend/",
      "url": "https://bobochen.dev/blog/laravel-guide-blade-livewire-frontend/",
      "title": "Blade + Livewire：打造互動式前端不需要寫 JavaScript",
      "content_text": "用 Blade 模板引擎與 Livewire 3，完全不寫 JavaScript 也能打造互動式前端：可重用的 Blade Component、wire:model 即時搜尋與篩選、Volt 單檔元件，再搭配 Alpine.js 處理純 UI 互動與 Tailwind CSS 美化，做出流暢的全 PHP 前端體驗。",
      "content_html": "「用 Laravel 做前端？不是應該接 React 或 Vue 嗎？」——這是很多人的第一反應。確實，Laravel 有完整的 API 支援可以搭配任何前端框架。但 Laravel 社群在過去幾年發展出了另一條路線：**用純 PHP 寫前端互動**。這條路的核心就是 Blade 模板引擎加上 Livewire。\n\nBlade 是 Laravel 內建的模板引擎，讓你在 HTML 裡面嵌入 PHP 邏輯，類似 JSX 之於 React 或 Jinja 之於 Python。有了 Blade，你可以用 `@if`、`@foreach` 等指令描述頁面邏輯，同時保持 HTML 的可讀性。\n\nLivewire 3 更進一步：它讓你用 PHP 類別定義前端元件的狀態和行為，使用者的點擊、輸入、篩選等互動動作，全部由 Livewire 透過 AJAX 在背景處理，頁面局部更新，完全不需要你手寫 JavaScript。再搭配 Alpine.js 處理一些純前端的小互動（下拉選單、Modal），你就有了一套完整的全 PHP 前端開發體驗。\n\n這一章我們要幫揪好買打造使用者看得到的介面：團購列表頁（支援即時搜尋和篩選）、團購詳情頁（顯示即時參與人數）、可重用的 UI 元件。你會發現不寫 JavaScript 也能做出流暢的互動體驗——而且程式碼比你想像的少很多。\n\n## Blade 模板引擎：Laravel 的 HTML 超能力\n\n[第二章](/blog/laravel-guide-setup-first-route/)我們已經碰過 Blade 的基本語法——`{{ }}`、`@if`、`@foreach`、`@extends`。這一節我們再補充幾個進階但很常用的功能。\n\n### 條件渲染\n\n```html\n{{-- 顯示/隱藏 --}}\n@if($groupBuy->status === 'open')\n<span class=\"badge-green\">開團中</span>\n@elseif($groupBuy->status === 'confirmed')\n<span class=\"badge-blue\">已成團</span>\n@else\n<span class=\"badge-gray\">已結束</span>\n@endif\n\n{{-- 更簡潔的語法 --}}\n@unless($groupBuy->isFull())\n<button>我要跟團</button>\n@endunless\n\n{{-- 有/沒有資料 --}}\n@forelse($groupBuys as $groupBuy)\n<div>{{ $groupBuy->title }}</div>\n@empty\n<p>目前沒有開團中的團購</p>\n@endforelse\n```\n\n### 認證相關\n\n```html\n@auth\n<p>歡迎回來，{{ auth()->user()->name }}！</p>\n@endauth @guest\n<a href=\"/login\">登入</a>\n<a href=\"/register\">註冊</a>\n@endguest\n```\n\n### 引入子 View\n\n```html\n{{-- 引入另一個 Blade 檔案 --}} @include('partials.navbar') {{-- 引入時傳資料 --}}\n@include('partials.group-buy-card', ['groupBuy' => $groupBuy])\n```\n\n## Layout 與 Component：可重用的 UI 積木\n\n第二章用的是 `@extends` + `@yield` 的傳統 Layout 方式。現代 Laravel 更推薦用 **Blade Component**——語法更像 HTML，組合性更強。\n\n### Component Layout\n\n建立 `resources/views/components/layouts/app.blade.php`：\n\n```html\n<!DOCTYPE html>\n<html lang=\"zh-TW\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>{{ $title ?? '揪好買' }} | 揪好買 JiuHaoMai</title>\n    @vite(['resources/css/app.css', 'resources/js/app.js'])\n  </head>\n  <body class=\"bg-gray-50 min-h-screen\">\n    <nav class=\"bg-indigo-900 text-white px-6 py-4\">\n      <div class=\"max-w-5xl mx-auto flex justify-between items-center\">\n        <a href=\"/\" class=\"text-xl font-bold text-emerald-400\">🛒 揪好買</a>\n        <div class=\"space-x-4\">\n          <a href=\"/group-buys\" class=\"hover:text-emerald-300\">所有團購</a>\n          @auth\n          <a href=\"/my-groups\" class=\"hover:text-emerald-300\">我的團購</a>\n          @endauth\n        </div>\n      </div>\n    </nav>\n\n    <main class=\"max-w-5xl mx-auto px-6 py-8\">{{ $slot }}</main>\n\n    <footer class=\"text-center py-6 text-gray-400 text-sm\">&copy; 2026 揪好買 JiuHaoMai</footer>\n  </body>\n</html>\n```\n\n使用方式——像 HTML 標籤一樣包裹內容：\n\n```html\n<x-layouts.app title=\"團購列表\">\n  <h1>所有團購</h1>\n  <p>這裡的內容會填入 Layout 的 {{ $slot }}</p>\n</x-layouts.app>\n```\n\n> `{{ $slot }}` 是預設的內容插槽。所有 `<x-layouts.app>` 標籤之間的東西都會自動填入 `$slot`。`$title` 則是傳給 Component 的屬性。\n\n### 自訂 Blade Component\n\n把重複出現的 UI 抽成 Component：\n\n```bash\nphp artisan make:component GroupBuyCard\n```\n\n這會建立兩個檔案：\n\n1. `app/View/Components/GroupBuyCard.php`——PHP 類別（邏輯）\n2. `resources/views/components/group-buy-card.blade.php`——Blade 模板（UI）\n\n```php\n// app/View/Components/GroupBuyCard.php\n<?php\n\nnamespace App\\View\\Components;\n\nuse App\\Models\\GroupBuy;\nuse Illuminate\\View\\Component;\n\nclass GroupBuyCard extends Component\n{\n    public function __construct(\n        public GroupBuy $groupBuy,\n    ) {}\n\n    public function participantCount(): int\n    {\n        return $this->groupBuy->participants()->count();\n    }\n\n    public function render()\n    {\n        return view('components.group-buy-card');\n    }\n}\n```\n\n```html\n<!-- resources/views/components/group-buy-card.blade.php -->\n<div class=\"bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition\">\n  <div class=\"flex justify-between items-start\">\n    <h3 class=\"font-bold text-lg\">{{ $groupBuy->title }}</h3>\n    <span\n      class=\"text-sm px-2 py-1 rounded\n            {{ $groupBuy->status === 'open' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500' }}\"\n    >\n      {{ $groupBuy->status === 'open' ? '開團中' : '已結束' }}\n    </span>\n  </div>\n\n  <p class=\"text-gray-500 mt-2 text-sm\">{{ Str::limit($groupBuy->description, 60) }}</p>\n\n  <div class=\"mt-4 flex justify-between items-center text-sm\">\n    <span>💰 ${{ number_format($groupBuy->price_per_unit / 100) }} / 份</span>\n    <span>👥 {{ $participantCount() }} / {{ $groupBuy->min_participants }} 人</span>\n    <span>⏰ {{ $groupBuy->deadline->diffForHumans() }}</span>\n  </div>\n\n  <a\n    href=\"/group-buys/{{ $groupBuy->id }}\"\n    class=\"block mt-4 text-center bg-indigo-600 text-white py-2 rounded-lg hover:bg-indigo-700\"\n  >\n    查看詳情\n  </a>\n</div>\n```\n\n使用——就像 HTML 標籤：\n\n```html\n@foreach($groupBuys as $groupBuy)\n<x-group-buy-card :group-buy=\"$groupBuy\" />\n@endforeach\n```\n\n`:group-buy=\"$groupBuy\"` 的冒號前綴表示「這是一個 PHP 表達式」，不加冒號就是純字串。\n\n### 匿名 Component（不需要 PHP class）\n\n如果 Component 只有模板沒有邏輯，可以只建 Blade 檔案：\n\n```html\n<!-- resources/views/components/badge.blade.php -->\n@props(['color' => 'gray', 'label'])\n\n<span class=\"px-2 py-1 rounded text-sm bg-{{ $color }}-100 text-{{ $color }}-700\">\n  {{ $label }}\n</span>\n```\n\n```html\n<x-badge color=\"green\" label=\"開團中\" /> <x-badge color=\"red\" label=\"已截止\" />\n```\n\n`@props` 宣告這個 Component 接受哪些屬性，以及預設值。\n\n## Livewire 3：用 PHP 寫前端互動\n\n到目前為止，我們的頁面還是傳統的 server-side rendering——使用者每次操作都要整頁重新載入。Livewire 改變了這件事：它讓你**用 PHP 寫有狀態的元件**，使用者互動時只重新渲染有變化的部分。\n\n### 安裝 Livewire\n\n```bash\ncomposer require livewire/livewire\n```\n\n就這樣。Livewire 會自動注入需要的 JavaScript（透過 `@livewireStyles` 和 `@livewireScripts`，但如果你用 `@vite` 它會自動處理）。\n\n### Livewire 的運作原理\n\n```\n使用者輸入/點擊\n    ↓\nLivewire JS 攔截事件\n    ↓\n送 AJAX 到 server，帶上 component 狀態\n    ↓\nServer 端 PHP 處理邏輯、更新 state\n    ↓\nServer 回傳 HTML diff\n    ↓\nLivewire JS 局部更新 DOM\n```\n\n對使用者來說，體驗就像 SPA；對開發者來說，你只寫 PHP。\n\n> **React/Vue 開發者的理解方式：** Livewire component ≈ React component，`public` 屬性 ≈ `state`，PHP 方法 ≈ event handler。差別是 state 存在 server，不在 client。\n\n### 建立第一個 Livewire Component\n\n```bash\nphp artisan make:livewire Counter\n```\n\n產生兩個檔案：\n\n```php\n// app/Livewire/Counter.php\n<?php\n\nnamespace App\\Livewire;\n\nuse Livewire\\Component;\n\nclass Counter extends Component\n{\n    public int $count = 0;  // 狀態（state）\n\n    public function increment(): void\n    {\n        $this->count++;\n    }\n\n    public function decrement(): void\n    {\n        $this->count--;\n    }\n\n    public function render()\n    {\n        return view('livewire.counter');\n    }\n}\n```\n\n```html\n<!-- resources/views/livewire/counter.blade.php -->\n<div>\n  <h2 class=\"text-2xl font-bold\">{{ $count }}</h2>\n  <div class=\"space-x-2 mt-2\">\n    <button wire:click=\"decrement\" class=\"px-4 py-2 bg-red-500 text-white rounded\">-</button>\n    <button wire:click=\"increment\" class=\"px-4 py-2 bg-green-500 text-white rounded\">+</button>\n  </div>\n</div>\n```\n\n在任何 Blade 頁面裡嵌入：\n\n```html\n<livewire:counter />\n```\n\n點按鈕，數字會即時更新——沒寫任何 JavaScript。\n\n## wire:model、wire:click——最常用的指令\n\nLivewire 的指令（directive）讓你把使用者互動綁定到 PHP 方法和屬性。\n\n### wire:click\n\n把點擊事件綁定到 PHP 方法：\n\n```html\n<button wire:click=\"addToCart({{ $product->id }})\">加入購物車</button>\n```\n\n```php\npublic function addToCart(int $productId): void\n{\n    // PHP 邏輯：加入購物車\n}\n```\n\n### wire:model\n\n雙向資料綁定——輸入框的值同步到 PHP 屬性：\n\n```html\n<!-- 即時同步（每次按鍵都觸發） -->\n<input wire:model.live=\"search\" type=\"text\" placeholder=\"搜尋團購...\" />\n\n<!-- 離開輸入框才同步（預設行為） -->\n<input wire:model.blur=\"email\" type=\"email\" />\n\n<!-- 表單送出才同步 -->\n<input wire:model=\"name\" type=\"text\" />\n```\n\n```php\npublic string $search = '';\n\n// 每次 $search 改變，render() 就會重新執行\n// 搭配下面的 render，就能實現即時搜尋\npublic function render()\n{\n    return view('livewire.group-buy-list', [\n        'groupBuys' => GroupBuy::open()\n            ->when($this->search, fn($q) => $q->where('title', 'like', \"%{$this->search}%\"))\n            ->latest()\n            ->paginate(12),\n    ]);\n}\n```\n\n> **`wire:model.live` vs `wire:model`：** `.live` 修飾符讓每次按鍵都觸發同步（適合即時搜尋），沒有修飾符的要等表單 submit。\n\n> **先講一個我希望早點有人提醒我的代價：** Livewire 的即時搜尋很爽，但它跟 React/Vue 那種前端即時過濾完全不是同一回事。client-side 框架打字時是在瀏覽器記憶體裡 filter 陣列，零後端負擔；Livewire 是每按一個鍵 → 發一個 AJAX request → server 跑一次帶 `like \"%keyword%\"` 的查詢。所以 `.debounce.300ms` 我不會叫它「優化」，它是必要的防線——沒加的話，使用者打「衛生紙」三個字你的 server 就吃了好幾發查詢，幾十個人同時搜你就知道痛。同理，分頁大小（這裡 `paginate(12)`）、`search` 欄位有沒有索引，都是成本問題不是有空再說的事。而且要老實講：`like \"%keyword%\"` 開頭那個 `%` 會讓 MySQL 索引直接失效、只能全表掃，資料量小無感，幾十萬筆以上就該認真考慮 Laravel Scout + Meilisearch 之類的全文檢索了。寫起來只是一個 PHP 檔案，但它背後是真的在打 DB，這點別忘。\n\n### wire:submit\n\n攔截表單提交：\n\n```html\n<form wire:submit=\"save\">\n  <input wire:model=\"title\" type=\"text\" />\n  <input wire:model=\"price\" type=\"number\" />\n  <button type=\"submit\">儲存</button>\n</form>\n```\n\n```php\npublic string $title = '';\npublic int $price = 0;\n\npublic function save(): void\n{\n    $this->validate([\n        'title' => 'required|min:3',\n        'price' => 'required|integer|min:100',\n    ]);\n\n    GroupBuy::create([\n        'title' => $this->title,\n        'price_per_unit' => $this->price,\n        // ...\n    ]);\n\n    $this->redirect('/group-buys');\n}\n```\n\n### wire:loading\n\n顯示載入狀態：\n\n```html\n<button wire:click=\"save\">\n  <span wire:loading.remove>儲存</span>\n  <span wire:loading>處理中...</span>\n</button>\n\n<!-- 整個區塊顯示 loading overlay -->\n<div wire:loading.class=\"opacity-50 pointer-events-none\">{{-- 內容 --}}</div>\n```\n\n### 常用指令速查\n\n| 指令                     | 用途                | React 類比                         |\n| ------------------------ | ------------------- | ---------------------------------- |\n| `wire:click=\"method\"`    | 點擊觸發 PHP 方法   | `onClick={handler}`                |\n| `wire:model.live=\"prop\"` | 即時雙向綁定        | `value={state} onChange={set}`     |\n| `wire:submit=\"method\"`   | 表單提交            | `onSubmit={handler}`               |\n| `wire:loading`           | 載入狀態顯示/隱藏   | loading state + conditional render |\n| `wire:confirm=\"確定？\"`  | 確認對話框          | `if (confirm(...))`                |\n| `wire:poll.5s`           | 每 5 秒自動重新渲染 | `useEffect` + `setInterval`        |\n| `wire:key=\"unique\"`      | 列表項目的 key      | `key={id}`                         |\n\n## Volt：單檔案 Livewire Component\n\nLivewire 的標準做法是 PHP class + Blade 模板兩個檔案。**Volt** 讓你把兩者合在一個 `.blade.php` 裡——類似 Vue 的 SFC（Single File Component）。\n\n> **⚠️ Livewire 4 版本注意（2026-01-15 釋出）**：Livewire 4 已將單檔元件（SFC）**內建進核心**，不再需要單獨安裝 Volt 套件。`php artisan make:livewire` 預設即產生單檔格式，命名空間也從 `Livewire\\Volt\\Component` 改為 `Livewire\\Component`。若你使用的是 **Livewire 3**，繼續依下方指令安裝 Volt；若已升級至 **Livewire 4**，直接用 `make:livewire` 即可，Volt 套件可移除。\n\n```bash\n# Livewire 3 才需要：\ncomposer require livewire/volt\nphp artisan volt:install\n```\n\n建立一個 Volt component：\n\n```html\n<!-- resources/views/livewire/participant-counter.blade.php -->\n<?php\nuse App\\Models\\GroupBuy;\nuse Livewire\\Volt\\Component;\n\nnew class extends Component {\n    public GroupBuy $groupBuy;\n    public int $count;\n\n    public function mount(GroupBuy $groupBuy): void\n    {\n        $this->groupBuy = $groupBuy;\n        $this->count = $groupBuy->participants()->count();\n    }\n\n    public function refresh(): void\n    {\n        $this->count = $this->groupBuy->participants()->count();\n    }\n}; ?>\n\n<div wire:poll.10s=\"refresh\" class=\"flex items-center gap-2\">\n  <span class=\"text-2xl font-bold\">{{ $count }}</span>\n  <span class=\"text-gray-500\">人已跟團</span>\n</div>\n```\n\nPHP 邏輯和 HTML 模板在同一個檔案裡——適合小型、單一職責的元件。\n\n> **什麼時候用 Volt？** 小元件（計數器、狀態切換、簡單表單）用 Volt 很方便。複雜元件（多步驟表單、帶分頁的列表）還是拆成兩個檔案比較好維護。\n\n## Alpine.js：Livewire 的最佳搭檔\n\n有些互動是純前端的：下拉選單展開/收合、Modal 彈出/關閉、Tab 切換。這些不需要跑到 server，用 Alpine.js 就好。\n\nAlpine.js 隨 Livewire 3 自動安裝，不用額外設定。\n\n### 基本語法\n\n```html\n<!-- 下拉選單 -->\n<div x-data=\"{ open: false }\">\n  <button @click=\"open = !open\">選單</button>\n  <ul x-show=\"open\" @click.away=\"open = false\" x-transition>\n    <li><a href=\"/profile\">個人資料</a></li>\n    <li><a href=\"/settings\">設定</a></li>\n    <li><a href=\"/logout\">登出</a></li>\n  </ul>\n</div>\n\n<!-- Modal -->\n<div x-data=\"{ showModal: false }\">\n  <button @click=\"showModal = true\">開團規則</button>\n\n  <div\n    x-show=\"showModal\"\n    x-transition.opacity\n    class=\"fixed inset-0 bg-black/50 flex items-center justify-center\"\n  >\n    <div class=\"bg-white rounded-xl p-6 max-w-md\" @click.away=\"showModal = false\">\n      <h3 class=\"font-bold text-lg\">開團規則</h3>\n      <p class=\"mt-2 text-gray-600\">最低 3 人成團，截止時間前未達人數自動取消。</p>\n      <button @click=\"showModal = false\" class=\"mt-4 px-4 py-2 bg-gray-200 rounded\">關閉</button>\n    </div>\n  </div>\n</div>\n```\n\n### Alpine.js vs Livewire 分工\n\n| 場景             | 用誰                 | 原因                             |\n| ---------------- | -------------------- | -------------------------------- |\n| 即時搜尋、篩選   | Livewire             | 需要查資料庫                     |\n| 表單提交、CRUD   | Livewire             | 需要 server 處理                 |\n| 下拉選單、Modal  | Alpine.js            | 純 UI 狀態，不需要 server        |\n| Tab 切換         | Alpine.js            | 純前端                           |\n| 計時器、動畫     | Alpine.js            | 需要毫秒級響應                   |\n| 購物車數量 badge | Livewire + Alpine.js | Livewire 更新數據，Alpine 做動畫 |\n\n簡單記：**需要資料庫或 PHP 邏輯 → Livewire；純 UI 互動 → Alpine.js**。\n\n## Tailwind CSS 整合：快速美化介面\n\nLaravel 12 新建專案預設就有 Tailwind CSS 的設定。如果你從其他框架來可能已經用過——它是 utility-first 的 CSS 框架，直接在 HTML 上加 class 來寫樣式。\n\n```bash\n# 安裝前端依賴\nnpm install\n\n# 啟動 Vite dev server（編譯 CSS 和 JS）\nnpm run dev\n```\n\n確保 Layout 裡有引入 Vite：\n\n```html\n<head>\n  @vite(['resources/css/app.css', 'resources/js/app.js'])\n</head>\n```\n\n### 常用 Tailwind 速查\n\n```html\n<!-- 容器和間距 -->\n<div class=\"max-w-5xl mx-auto px-6 py-8\">\n  <!-- Grid 佈局 -->\n  <div class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6\">\n    <!-- 卡片 -->\n    <div class=\"bg-white rounded-xl shadow-sm border p-5 hover:shadow-md transition\">\n      <!-- 按鈕 -->\n      <button class=\"bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition\">\n        <!-- 文字 -->\n        <h1 class=\"text-2xl font-bold text-gray-900\">\n          <p class=\"text-sm text-gray-500 mt-2\">\n            <!-- Badge -->\n            <span class=\"bg-green-100 text-green-700 text-sm px-2 py-1 rounded\">\n              <!-- Responsive -->\n              <div class=\"text-sm md:text-base lg:text-lg\"></div\n            ></span>\n          </p>\n        </h1>\n      </button>\n    </div>\n  </div>\n</div>\n```\n\n> **不習慣 utility class？** 一開始覺得 HTML 很亂是正常的。但搭配 Blade Component，你把樣式封裝在 Component 裡，使用端看到的就是乾淨的 `<x-group-buy-card :group-buy=\"$gb\" />`。\n\n## 實作：揪好買團購列表與即時搜尋\n\n把所有東西串起來——用 Livewire 做一個有即時搜尋和分類篩選的團購列表頁。\n\n### Step 1：建立 Livewire Component\n\n```bash\nphp artisan make:livewire GroupBuyList\n```\n\n`app/Livewire/GroupBuyList.php`：\n\n```php\n<?php\n\nnamespace App\\Livewire;\n\nuse App\\Models\\GroupBuy;\nuse Livewire\\Component;\nuse Livewire\\WithPagination;\n\nclass GroupBuyList extends Component\n{\n    use WithPagination;\n\n    public string $search = '';\n    public string $status = '';\n    public string $sortBy = 'latest';\n\n    // 搜尋條件改變時重置頁碼\n    public function updatedSearch(): void\n    {\n        $this->resetPage();\n    }\n\n    public function updatedStatus(): void\n    {\n        $this->resetPage();\n    }\n\n    public function render()\n    {\n        $groupBuys = GroupBuy::query()\n            ->with('organizer')            // Eager loading 避免 N+1\n            ->withCount('participants')     // 載入參與者數量\n            ->when($this->search, fn($q) =>\n                $q->where('title', 'like', \"%{$this->search}%\")\n                  ->orWhere('product_name', 'like', \"%{$this->search}%\")\n            )\n            ->when($this->status === 'open', fn($q) =>\n                $q->where('status', 'open')->where('deadline', '>', now())\n            )\n            ->when($this->status === 'confirmed', fn($q) =>\n                $q->where('status', 'confirmed')\n            )\n            ->when($this->sortBy === 'latest', fn($q) => $q->latest())\n            ->when($this->sortBy === 'deadline', fn($q) => $q->orderBy('deadline'))\n            ->when($this->sortBy === 'popular', fn($q) => $q->orderByDesc('participants_count'))\n            ->paginate(12);\n\n        return view('livewire.group-buy-list', [\n            'groupBuys' => $groupBuys,\n        ]);\n    }\n}\n```\n\n### Step 2：Livewire Blade 模板\n\n`resources/views/livewire/group-buy-list.blade.php`：\n\n```html\n<div>\n  {{-- 搜尋與篩選列 --}}\n  <div class=\"flex flex-col md:flex-row gap-4 mb-8\">\n    <div class=\"flex-1\">\n      <input\n        wire:model.live.debounce.300ms=\"search\"\n        type=\"text\"\n        placeholder=\"🔍 搜尋團購名稱...\"\n        class=\"w-full px-4 py-3 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-transparent\"\n      />\n    </div>\n\n    <select wire:model.live=\"status\" class=\"px-4 py-3 rounded-lg border\">\n      <option value=\"\">全部狀態</option>\n      <option value=\"open\">開團中</option>\n      <option value=\"confirmed\">已成團</option>\n    </select>\n\n    <select wire:model.live=\"sortBy\" class=\"px-4 py-3 rounded-lg border\">\n      <option value=\"latest\">最新</option>\n      <option value=\"deadline\">即將截止</option>\n      <option value=\"popular\">最多人跟</option>\n    </select>\n  </div>\n\n  {{-- 團購卡片 Grid --}}\n  <div\n    wire:loading.class=\"opacity-50\"\n    class=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 transition\"\n  >\n    @forelse($groupBuys as $groupBuy)\n    <x-group-buy-card :group-buy=\"$groupBuy\" wire:key=\"gb-{{ $groupBuy->id }}\" />\n    @empty\n    <div class=\"col-span-full text-center py-12 text-gray-400\">\n      <p class=\"text-4xl mb-2\">🍃</p>\n      <p>找不到符合條件的團購</p>\n    </div>\n    @endforelse\n  </div>\n\n  {{-- 分頁 --}}\n  <div class=\"mt-8\">{{ $groupBuys->links() }}</div>\n</div>\n```\n\n### Step 3：頁面路由與 View\n\n`routes/web.php`：\n\n```php\nRoute::get('/group-buys', function () {\n    return view('group-buys.index');\n});\n```\n\n`resources/views/group-buys/index.blade.php`：\n\n```html\n<x-layouts.app title=\"所有團購\">\n  <div class=\"flex justify-between items-center mb-6\">\n    <h1 class=\"text-2xl font-bold\">🛒 所有團購</h1>\n    @auth\n    <a\n      href=\"/group-buys/create\"\n      class=\"bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700\"\n    >\n      + 我要開團\n    </a>\n    @endauth\n  </div>\n\n  <livewire:group-buy-list />\n</x-layouts.app>\n```\n\n### Step 4：看看效果\n\n```bash\n# 確保有測試資料\nphp artisan migrate:fresh --seed\n\n# 啟動兩個終端\nphp artisan serve    # 後端\nnpm run dev          # 前端（Vite）\n```\n\n打開 `http://localhost:8000/group-buys`，你會看到：\n\n- **搜尋框**——輸入文字後 300ms 自動篩選（`debounce`），不用按 Enter\n- **狀態篩選**——切換「開團中」「已成團」即時過濾\n- **排序**——最新、即將截止、最多人跟\n- **分頁**——超過 12 筆自動分頁\n- **Loading 狀態**——篩選時卡片區域半透明\n\n完全沒寫 JavaScript。所有邏輯都在 `GroupBuyList.php` 這一個 PHP 檔案裡。\n\n### 效能注意：wire:key\n\n注意每個列表項目的 `wire:key=\"gb-{{ $groupBuy->id }}\"`。這跟 React 的 `key` prop 一樣——幫助 Livewire 的 DOM diffing 算法正確辨識哪些項目被新增/移除/移動。列表裡沒加 `wire:key` 會導致奇怪的渲染 bug。\n\n## 小結：全 PHP 技術棧的前端開發\n\n這一章我們走過了 Laravel 前端開發的完整工具鏈：\n\n- **Blade Component**——用 `<x-component>` 語法建立可重用的 UI 積木，比 `@include` 更好組合\n- **Livewire 3**——用 PHP 寫有狀態的前端元件，`wire:model`、`wire:click` 處理使用者互動\n- **Volt**——單檔案 Livewire Component，適合小型元件\n- **Alpine.js**——純前端互動（下拉選單、Modal、Tab），不需要跑到 server\n- **Tailwind CSS**——utility-first CSS，搭配 Blade Component 封裝樣式\n\n我們也為揪好買做了第一個互動頁面：\n\n- **團購列表**——Livewire 即時搜尋 + 篩選 + 排序 + 分頁\n- **GroupBuyCard Component**——可重用的團購卡片 UI\n\n**Livewire vs React/Vue/Svelte 的取捨：**\n\n|          | Livewire                | React/Vue/Svelte       |\n| -------- | ----------------------- | ---------------------- |\n| 學習曲線 | 低（只寫 PHP）          | 高（需學 JS 框架）     |\n| 初始載入 | 快（server render）     | 慢（需載入 JS bundle） |\n| 互動延遲 | 略高（每次都跑 server） | 低（client-side）      |\n| SEO      | 天生友好                | 需要 SSR               |\n| 適合場景 | 內容型、CRUD 為主       | 高互動、即時協作       |\n\n對揪好買這種團購平台來說，Livewire 完全夠用。如果未來需要更豐富的即時互動（例如拖拉排序、即時聊天），第十一章的 [API + Sanctum](/blog/laravel-guide-api-sanctum-rest/) 可以讓你銜接任何前端框架。\n\n下一章，我們要讓揪好買的使用者能夠註冊和登入——[認證與授權系統](/blog/laravel-guide-auth-breeze-authorization/)。用 Laravel 12 的新版 Starter Kit 十分鐘搞定。",
      "summary": "用 Blade 模板引擎與 Livewire 3，完全不寫 JavaScript 也能打造互動式前端：可重用的 Blade Component、wire:model 即時搜尋與篩選、Volt 單檔元件，再搭配 Alpine.js 處理純 UI 互動與 Tailwind CSS 美化，做出流暢的全 PHP 前端體驗。",
      "image": "https://bobochen.dev/_astro/cover.CqNn7Wq0.webp",
      "date_published": "2025-04-01T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Blade",
        "Livewire",
        "Alpine.js",
        "Tailwind CSS"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-eloquent-orm-models/",
      "url": "https://bobochen.dev/blog/laravel-guide-eloquent-orm-models/",
      "title": "Eloquent ORM：不寫 SQL 也能操作資料庫的 Laravel 之道",
      "content_text": "Laravel Eloquent ORM 完整教學：從 Migration 建表、Model 命名慣例、CRUD 操作，到一對多／多對多關聯與 Factory／Seeder 測試資料，不寫 SQL 也能優雅操作資料庫，一次搞懂。",
      "content_html": "每個 Web 應用程式的核心都是資料。使用者註冊帳號、建立團購、加入訂單——這些動作最終都要寫進資料庫裡。傳統做法是手寫 SQL，但 SQL 散落在程式碼各處會讓維護變成噩夢。Laravel 的解法叫做 **Eloquent ORM**：讓你用優雅的 PHP 語法操作資料庫，每張資料表對應一個 Model 類別，每一列資料就是一個物件。\n\nEloquent 的強大不只在於少寫 SQL。它把資料表之間的關聯（一對多、多對多）變成物件屬性，讓你用 `$user->orders` 就能拿到某個使用者的所有訂單，完全不需要手寫 JOIN。搭配 Migration（用程式碼管理資料表結構）和 Factory/Seeder（自動產生測試資料），你的資料層從開發到測試都有完整的工具鏈支撐。\n\n這一章我們要為揪好買設計完整的資料結構：使用者、團購、商品、參與者。你會學到 Migration 怎麼寫、Model 怎麼定義、關聯怎麼設定、CRUD 怎麼操作。讀完之後，揪好買的資料骨架就搭好了，後面的章節只需要在上面蓋 UI 和業務邏輯。\n\n## 開始用 Eloquent ORM 前：資料庫設定（MySQL / SQLite）\n\n[上一章](/blog/laravel-guide-setup-first-route/)提過，Laravel 12 預設用 SQLite——零設定、開箱即用。打開 `.env` 看看：\n\n> **版本說明**：本系列以 Laravel 12 為基準撰寫。Laravel 13 已於 2026 年 3 月 17 日正式發布（最低需求 PHP 8.3），是目前的最新主版本；其升級路徑幾乎沒有破壞性變更，本文的 Eloquent、Migration 範例同樣適用 Laravel 13，直接照做即可。\n\n```bash\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n```\n\nSQLite 的資料庫就是一個檔案 `database/database.sqlite`，開發階段用它非常方便。\n\n如果你想用 MySQL 或 PostgreSQL，改一下 `.env`：\n\n```bash\n# MySQL\nDB_CONNECTION=mysql\nDB_HOST=127.0.0.1\nDB_PORT=3306\nDB_DATABASE=jiu_hao_mai\nDB_USERNAME=root\nDB_PASSWORD=secret\n\n# PostgreSQL\nDB_CONNECTION=pgsql\nDB_HOST=127.0.0.1\nDB_PORT=5432\nDB_DATABASE=jiu_hao_mai\nDB_USERNAME=postgres\nDB_PASSWORD=secret\n```\n\n改完之後清一下 config 快取：\n\n```bash\nphp artisan config:clear\n```\n\n> **建議：** 開發階段用 SQLite 就好，省掉裝 MySQL 的麻煩。等到部署正式環境再切換——Eloquent 的程式碼完全不用改，只動 `.env` 設定。\n\n### 跨 ORM 對照\n\n| 概念       | Laravel (Eloquent)          | Django (Python)             | Sequelize (Node.js)          | Prisma (Node.js)                     |\n| ---------- | --------------------------- | --------------------------- | ---------------------------- | ------------------------------------ |\n| Model 定義 | PHP class                   | Python class                | JS class / define            | Schema file                          |\n| Migration  | PHP class                   | Python file                 | JS file                      | Schema + migrate                     |\n| 查詢語法   | `User::where(...)`          | `User.objects.filter(...)`  | `User.findAll({where: ...})` | `prisma.user.findMany({where: ...})` |\n| 關聯       | `hasMany()` / `belongsTo()` | `ForeignKey` / `ManyToMany` | `hasMany` / `belongsTo`      | `@relation`                          |\n\n## Migration：用程式碼管理資料表結構\n\nMigration 就是資料表的版本控制——用程式碼定義資料表結構，團隊裡每個人跑一次 `migrate` 就能得到一模一樣的資料庫。不用再傳 SQL dump，也不用手動對 schema。\n\n### 建立 Migration\n\n```bash\nphp artisan make:migration create_products_table\n```\n\n這會在 `database/migrations/` 產生一個帶時間戳的檔案：\n\n```php\n<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('products', function (Blueprint $table) {\n            $table->id();                          // bigint unsigned auto increment PK\n            $table->string('name');                // varchar(255)\n            $table->text('description')->nullable(); // text, 可為 null\n            $table->integer('price');              // int（存整數，以「分」為單位避免浮點數問題）\n            $table->string('image')->nullable();   // 商品圖片路徑\n            $table->boolean('is_active')->default(true);\n            $table->timestamps();                  // created_at + updated_at\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('products');\n    }\n};\n```\n\n`up()` 定義「建立時做什麼」，`down()` 定義「回滾時做什麼」。\n\n### 常用欄位型別\n\n| 方法                               | SQL 型別                  | 用途       |\n| ---------------------------------- | ------------------------- | ---------- |\n| `$table->id()`                     | `BIGINT UNSIGNED AI PK`   | 主鍵       |\n| `$table->string('name')`           | `VARCHAR(255)`            | 短字串     |\n| `$table->string('code', 10)`       | `VARCHAR(10)`             | 指定長度   |\n| `$table->text('body')`             | `TEXT`                    | 長文字     |\n| `$table->integer('qty')`           | `INT`                     | 整數       |\n| `$table->unsignedInteger('qty')`   | `INT UNSIGNED`            | 非負整數   |\n| `$table->decimal('price', 10, 2)`  | `DECIMAL(10,2)`           | 精確小數   |\n| `$table->boolean('active')`        | `TINYINT(1)`              | 布林值     |\n| `$table->date('birth_date')`       | `DATE`                    | 日期       |\n| `$table->dateTime('confirmed_at')` | `DATETIME`                | 日期時間   |\n| `$table->timestamp('verified_at')` | `TIMESTAMP`               | 時間戳     |\n| `$table->timestamps()`             | `created_at + updated_at` | 自動時間戳 |\n| `$table->softDeletes()`            | `deleted_at`              | 軟刪除     |\n| `$table->json('metadata')`         | `JSON`                    | JSON 欄位  |\n| `$table->foreignId('user_id')`     | `BIGINT UNSIGNED`         | 外鍵       |\n\n### 修飾方法\n\n```php\n$table->string('email')->unique();              // 唯一\n$table->string('nickname')->nullable();         // 可為 null\n$table->integer('stock')->default(0);           // 預設值\n$table->foreignId('user_id')->constrained();    // 外鍵 + 約束\n$table->foreignId('user_id')\n      ->constrained()\n      ->cascadeOnDelete();                      // 刪除時連動刪除\n$table->index('email');                         // 加索引\n```\n\n### 執行與回滾\n\n```bash\n# 執行所有未跑過的 migration\nphp artisan migrate\n\n# 回滾上一次的 migration\nphp artisan migrate:rollback\n\n# 回滾所有 migration 再重新執行（開發用，會清空資料）\nphp artisan migrate:fresh\n\n# 回滾再重跑，順便跑 Seeder\nphp artisan migrate:fresh --seed\n\n# 查看 migration 狀態\nphp artisan migrate:status\n```\n\n> **金錢欄位的建議：** 永遠用整數存錢（以「分」為單位），不要用 `float`。`$70.50` 存成 `7050`，顯示時再除以 100。這樣可以避免浮點數精度問題——0.1 + 0.2 ≠ 0.3 在任何語言都是這樣。（`decimal`/`DECIMAL` 在 SQL 層其實是精確的，但資料讀進 PHP 後仍可能被轉回浮點數，整數方案直接從源頭迴避這個風險。）\n\n## Eloquent Model：每張表都有一個代言人\n\n每張資料表對應一個 Eloquent Model。Model 是你跟資料庫互動的唯一介面——不用寫 SQL，用 PHP 物件的方式操作資料。\n\n### 建立 Model\n\n```bash\n# 只建 Model\nphp artisan make:model Product\n\n# 一次建 Model + Migration + Factory + Seeder\nphp artisan make:model Product -mfs\n```\n\n```php\n<?php\n\n// app/Models/Product.php\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Model;\n\nclass Product extends Model\n{\n    // 就這樣，空的就能用了\n}\n```\n\n### 命名慣例\n\nEloquent 靠**命名慣例**自動推斷對應的資料表：\n\n| Model 類別  | 自動對應的資料表 |\n| ----------- | ---------------- |\n| `Product`   | `products`       |\n| `User`      | `users`          |\n| `GroupBuy`  | `group_buys`     |\n| `OrderItem` | `order_items`    |\n\n規則：**Model 用 PascalCase 單數，資料表用 snake_case 複數**。如果你遵循這個慣例，完全不需要額外設定。\n\n如果你有特殊需求（例如接手舊系統的奇怪表名），可以覆寫：\n\n```php\nclass Product extends Model\n{\n    protected $table = 'my_weird_products_table';  // 自訂表名\n    protected $primaryKey = 'product_id';           // 自訂主鍵\n    public $timestamps = false;                     // 不自動維護時間戳\n}\n```\n\n## CRUD 操作：建立、讀取、更新、刪除\n\n### Create（建立）\n\n```php\n// 方法一：new + save\n$product = new Product();\n$product->name = '辦公室零食箱';\n$product->price = 59900;  // $599.00\n$product->save();\n\n// 方法二：create（mass assignment，需設定 $fillable）\n$product = Product::create([\n    'name' => '辦公室零食箱',\n    'price' => 59900,\n    'description' => '精選 10 款台灣經典零食，滿足整層辦公室',\n]);\n```\n\n### Read（讀取）\n\n```php\n// 取得所有商品\n$products = Product::all();\n\n// 用主鍵查詢（找不到回傳 null）\n$product = Product::find(1);\n\n// 用主鍵查詢（找不到拋 404 例外——Controller 裡很好用）\n$product = Product::findOrFail(1);\n\n// 條件查詢\n$activeProducts = Product::where('is_active', true)\n    ->where('price', '<', 100000)\n    ->orderBy('created_at', 'desc')\n    ->get();\n\n// 取第一筆\n$cheapest = Product::where('is_active', true)\n    ->orderBy('price')\n    ->first();\n\n// 計數\n$count = Product::where('is_active', true)->count();\n\n// 分頁（每頁 20 筆）\n$products = Product::where('is_active', true)\n    ->latest()    // orderBy('created_at', 'desc') 的語法糖\n    ->paginate(20);\n```\n\n### Update（更新）\n\n```php\n// 方法一：find + 修改 + save\n$product = Product::find(1);\n$product->price = 49900;\n$product->save();\n\n// 方法二：update（批次更新）\nProduct::where('is_active', false)\n    ->update(['is_active' => true]);\n```\n\n### Delete（刪除）\n\n```php\n// 方法一：find + delete\n$product = Product::find(1);\n$product->delete();\n\n// 方法二：destroy（用主鍵刪除）\nProduct::destroy(1);\nProduct::destroy([1, 2, 3]);\n\n// 方法三：條件刪除\nProduct::where('is_active', false)->delete();\n```\n\n### 在 Tinker 裡試試看\n\n```bash\nphp artisan tinker\n```\n\n```php\n>>> Product::create(['name' => '手工鳳梨酥', 'price' => 35000]);\n>>> Product::all();\n>>> Product::where('price', '>', 30000)->get();\n>>> Product::find(1)->update(['price' => 32000]);\n```\n\nTinker 是你學 Eloquent 最好的朋友——不用寫 Controller 和路由，直接在 REPL 裡操作資料庫。\n\n## Mass Assignment 防護：保護你的資料\n\n如果你直接用 `Product::create($request->all())` 把使用者的整個表單資料丟進去，攻擊者可以偷偷夾帶 `is_admin=1` 之類的欄位——這叫做 **Mass Assignment 攻擊**。\n\nLaravel 的防護機制：預設情況下，所有欄位都**不允許**被批次賦值。你必須明確指定哪些欄位可以被填入：\n\n### $fillable（白名單）\n\n```php\nclass Product extends Model\n{\n    // 只有這些欄位可以被 create() 和 update() 批次賦值\n    protected $fillable = [\n        'name',\n        'description',\n        'price',\n        'image',\n        'is_active',\n    ];\n}\n```\n\n### $guarded（黑名單）\n\n```php\nclass Product extends Model\n{\n    // 除了這些，其他都可以被批次賦值\n    protected $guarded = ['id'];\n}\n```\n\n> **慣例：** 大多數 Laravel 開發者用 `$fillable`（白名單），因為更安全——新增欄位時，你必須刻意把它加到 `$fillable` 才能被批次賦值。用 `$guarded` 的話，新欄位預設就是開放的，比較容易出事。\n\n### 跨框架對照\n\n| 框架    | 防護機制                       | 做法                                     |\n| ------- | ------------------------------ | ---------------------------------------- |\n| Laravel | `$fillable` / `$guarded`       | 在 Model 裡定義                          |\n| Django  | 不需要（form 有自己的 fields） | Form class 定義可寫欄位                  |\n| Rails   | `Strong Parameters`            | 在 Controller 裡 `permit(:name, :price)` |\n\n## Relationships：一對多、多對多\n\n關聯是 Eloquent 最精華的部分。用一行方法定義，就能優雅地存取相關資料。\n\n### 一對多（hasMany / belongsTo）\n\n一個使用者可以建立多個團購：\n\n```php\n// User Model\nclass User extends Authenticatable\n{\n    public function groupBuys(): HasMany\n    {\n        return $this->hasMany(GroupBuy::class);\n    }\n}\n\n// GroupBuy Model\nclass GroupBuy extends Model\n{\n    public function organizer(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n}\n```\n\n使用方式：\n\n```php\n// 取得某個使用者建立的所有團購\n$user = User::find(1);\n$groupBuys = $user->groupBuys;  // Collection of GroupBuy\n\n// 取得某個團購的建立者\n$groupBuy = GroupBuy::find(1);\n$organizer = $groupBuy->organizer;  // User instance\n\n// 建立關聯資料\n$user->groupBuys()->create([\n    'title' => '辦公室下午茶團',\n    'min_participants' => 5,\n    'deadline' => now()->addDays(3),\n]);\n```\n\n> **注意 `$user->groupBuys` 和 `$user->groupBuys()` 的差異：** 不帶括號的是「動態屬性」，直接回傳結果（Collection）；帶括號的是「關聯查詢」，回傳 Builder，你可以繼續加條件再 `->get()`。\n\n### 多對多（belongsToMany）\n\n一個使用者可以參加多個團購，一個團購也有多個參與者——這是多對多關係。需要一張中間表（pivot table）：\n\n```php\n// Migration：建立 pivot table\nSchema::create('group_buy_user', function (Blueprint $table) {\n    $table->id();\n    $table->foreignId('group_buy_id')->constrained()->cascadeOnDelete();\n    $table->foreignId('user_id')->constrained()->cascadeOnDelete();\n    $table->integer('quantity')->default(1);  // 跟團數量\n    $table->timestamps();\n\n    $table->unique(['group_buy_id', 'user_id']);  // 同一人不能重複加入\n});\n```\n\n```php\n// GroupBuy Model\nclass GroupBuy extends Model\n{\n    public function participants(): BelongsToMany\n    {\n        return $this->belongsToMany(User::class)\n            ->withPivot('quantity')   // 載入中間表的額外欄位\n            ->withTimestamps();       // 載入中間表的時間戳\n    }\n}\n\n// User Model\nclass User extends Authenticatable\n{\n    public function joinedGroupBuys(): BelongsToMany\n    {\n        return $this->belongsToMany(GroupBuy::class)\n            ->withPivot('quantity')\n            ->withTimestamps();\n    }\n}\n```\n\n使用方式：\n\n```php\n// 某個團購的所有參與者\n$groupBuy = GroupBuy::find(1);\n$participants = $groupBuy->participants;  // Collection of User\n\n// 某個參與者的跟團數量（從 pivot 拿）\nforeach ($groupBuy->participants as $user) {\n    echo \"{$user->name} 跟了 {$user->pivot->quantity} 份\";\n}\n\n// 使用者加入團購\n$groupBuy->participants()->attach($userId, ['quantity' => 2]);\n\n// 使用者退出團購\n$groupBuy->participants()->detach($userId);\n\n// 更新跟團數量\n$groupBuy->participants()->updateExistingPivot($userId, ['quantity' => 3]);\n\n// 同步（覆蓋所有關聯）\n$groupBuy->participants()->sync([\n    $userId1 => ['quantity' => 1],\n    $userId2 => ['quantity' => 3],\n]);\n\n// 計算參與人數\n$count = $groupBuy->participants()->count();\n```\n\n### 關聯一覽\n\n| 關聯類型   | 方法             | 範例                     |\n| ---------- | ---------------- | ------------------------ |\n| 一對多     | `hasMany`        | 使用者 → 多個團購        |\n| 多對一     | `belongsTo`      | 團購 → 屬於一個使用者    |\n| 多對多     | `belongsToMany`  | 使用者 ↔ 團購（參與者）  |\n| 一對一     | `hasOne`         | 使用者 → 一個設定檔      |\n| 透過中間表 | `hasManyThrough` | 使用者 → 訂單 → 訂單項目 |\n| 多型關聯   | `morphMany`      | 圖片 → 可屬於商品或團購  |\n\n一對多和多對多是最常用的，其他的等碰到再學就好。\n\n## Query Builder vs Eloquent：什麼時候用哪個\n\nEloquent 底層其實是包裝了 Laravel 的 Query Builder。有些情況下直接用 Query Builder 更適合：\n\n```php\n// Eloquent——回傳 Model 物件，有 relationship、events、accessors\n$products = Product::where('is_active', true)->get();\n\n// Query Builder——回傳 stdClass 物件，輕量、快速\n$products = DB::table('products')->where('is_active', true)->get();\n```\n\n### 什麼時候用 Query Builder？\n\n| 場景          | 建議          | 原因                                           |\n| ------------- | ------------- | ---------------------------------------------- |\n| 一般 CRUD     | Eloquent      | 有 Model 的所有功能                            |\n| 複雜報表/統計 | Query Builder | 不需要 Model 實例，效能更好                    |\n| 批次更新/刪除 | Query Builder | Eloquent 會一筆一筆觸發 Event，慢              |\n| JOIN 查詢     | 看情況        | 簡單的用 Eloquent 關聯，複雜的用 Query Builder |\n\n```php\n// 報表範例：統計每個團購的參與人數和總金額\n$stats = DB::table('group_buys')\n    ->join('group_buy_user', 'group_buys.id', '=', 'group_buy_user.group_buy_id')\n    ->select(\n        'group_buys.title',\n        DB::raw('COUNT(group_buy_user.user_id) as participant_count'),\n        DB::raw('SUM(group_buy_user.quantity) as total_quantity'),\n    )\n    ->groupBy('group_buys.id', 'group_buys.title')\n    ->get();\n```\n\n> **實務建議：** 90% 的情況用 Eloquent 就好。只有在效能敏感的報表或批次操作時，才需要降到 Query Builder。過早優化是萬惡之源——先讓程式碼清晰，有效能問題再處理。\n\n## Seeder 與 Factory：自動產生測試資料\n\n每次 `migrate:fresh` 都重新手動建資料很煩。Factory + Seeder 讓你一行指令就填滿測試資料。\n\n### Factory：定義假資料長什麼樣\n\n```bash\nphp artisan make:factory ProductFactory\n```\n\n```php\n<?php\n\nnamespace Database\\Factories;\n\nuse Illuminate\\Database\\Eloquent\\Factories\\Factory;\n\nclass ProductFactory extends Factory\n{\n    public function definition(): array\n    {\n        $snacks = ['鳳梨酥', '太陽餅', '牛軋糖', '雞排', '珍珠奶茶', '芋頭酥', '蛋黃酥', '麻糬', '花生糖'];\n\n        return [\n            'name' => fake()->randomElement($snacks) . '團購組',\n            'description' => fake()->realText(100),\n            'price' => fake()->numberBetween(5000, 100000),  // $50 ~ $1000\n            'is_active' => fake()->boolean(80),  // 80% 機率為 true\n        ];\n    }\n}\n```\n\n### Seeder：用 Factory 填資料\n\n```bash\nphp artisan make:seeder ProductSeeder\n```\n\n```php\n<?php\n\nnamespace Database\\Seeders;\n\nuse App\\Models\\Product;\nuse Illuminate\\Database\\Seeder;\n\nclass ProductSeeder extends Seeder\n{\n    public function run(): void\n    {\n        Product::factory(30)->create();  // 建立 30 筆假商品\n    }\n}\n```\n\n在 `DatabaseSeeder` 裡呼叫：\n\n```php\nclass DatabaseSeeder extends Seeder\n{\n    public function run(): void\n    {\n        $this->call([\n            ProductSeeder::class,\n            GroupBuySeeder::class,\n        ]);\n    }\n}\n```\n\n執行：\n\n```bash\nphp artisan migrate:fresh --seed\n```\n\n一行指令：清空資料庫 → 重建所有資料表 → 填入 30 筆假商品。開發效率直接翻倍。\n\n### Factory 進階用法\n\n```php\n// 建立特定狀態的資料\nProduct::factory()\n    ->count(10)\n    ->create(['is_active' => false]);  // 10 筆下架商品\n\n// 搭配關聯\nUser::factory()\n    ->has(GroupBuy::factory()->count(3))  // 每個使用者有 3 個團購\n    ->count(5)                             // 建 5 個使用者\n    ->create();\n```\n\n## 實作：設計揪好買的資料表與 Model\n\n理論到這裡告一段落。讓我們動手為揪好買設計完整的資料模型。\n\n### ER 關係圖\n\n```text\nusers ─────────< group_buys\n  │                  │\n  │                  │\n  └──────< group_buy_user >──────┘\n           (pivot: quantity)\n```\n\n- 一個 user 可以建立多個 group_buys（一對多）\n- 一個 user 可以參加多個 group_buys（多對多，透過 group_buy_user）\n- 一個 group_buy 有多個 participants（多對多）\n\n### Step 1：建立 GroupBuy Migration 與 Model\n\n```bash\nphp artisan make:model GroupBuy -mfs\n```\n\n`database/migrations/xxxx_create_group_buys_table.php`：\n\n```php\npublic function up(): void\n{\n    Schema::create('group_buys', function (Blueprint $table) {\n        $table->id();\n        $table->foreignId('user_id')->constrained()->cascadeOnDelete();\n        $table->string('title');\n        $table->text('description')->nullable();\n        $table->string('product_name');\n        $table->integer('price_per_unit');         // 每份單價（分）\n        $table->string('image')->nullable();\n        $table->integer('min_participants');         // 最低成團人數\n        $table->integer('max_participants')->nullable(); // 最高人數（null 表示不限）\n        $table->dateTime('deadline');               // 截止時間\n        $table->string('status')->default('open');  // open / confirmed / cancelled / completed\n        $table->timestamps();\n    });\n}\n```\n\n### Step 2：建立 Pivot Table Migration\n\n```bash\nphp artisan make:migration create_group_buy_user_table\n```\n\n```php\npublic function up(): void\n{\n    Schema::create('group_buy_user', function (Blueprint $table) {\n        $table->id();\n        $table->foreignId('group_buy_id')->constrained()->cascadeOnDelete();\n        $table->foreignId('user_id')->constrained()->cascadeOnDelete();\n        $table->integer('quantity')->default(1);\n        $table->text('note')->nullable();  // 跟團備註（例如：不要辣）\n        $table->timestamps();\n\n        $table->unique(['group_buy_id', 'user_id']);\n    });\n}\n```\n\n### Step 3：設定 Model\n\n`app/Models/GroupBuy.php`：\n\n```php\n<?php\n\nnamespace App\\Models;\n\nuse Illuminate\\Database\\Eloquent\\Factories\\HasFactory;\nuse Illuminate\\Database\\Eloquent\\Model;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;\nuse Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany;\n\nclass GroupBuy extends Model\n{\n    use HasFactory;\n\n    protected $fillable = [\n        'title',\n        'description',\n        'product_name',\n        'price_per_unit',\n        'image',\n        'min_participants',\n        'max_participants',\n        'deadline',\n        'status',\n    ];\n\n    protected function casts(): array\n    {\n        return [\n            'deadline' => 'datetime',\n            'price_per_unit' => 'integer',\n            'min_participants' => 'integer',\n            'max_participants' => 'integer',\n        ];\n    }\n\n    // ── 關聯 ──\n\n    public function organizer(): BelongsTo\n    {\n        return $this->belongsTo(User::class, 'user_id');\n    }\n\n    public function participants(): BelongsToMany\n    {\n        return $this->belongsToMany(User::class)\n            ->withPivot('quantity', 'note')\n            ->withTimestamps();\n    }\n\n    // ── 查詢 Scope ──\n\n    public function scopeOpen($query)\n    {\n        return $query->where('status', 'open')\n            ->where('deadline', '>', now());\n    }\n\n    // ── 計算屬性 ──\n\n    public function isConfirmed(): bool\n    {\n        return $this->participants()->count() >= $this->min_participants;\n    }\n\n    public function totalQuantity(): int\n    {\n        return $this->participants()->sum('group_buy_user.quantity');\n    }\n}\n```\n\n在 `app/Models/User.php` 加上關聯：\n\n```php\n// 我建立的團購\npublic function groupBuys(): HasMany\n{\n    return $this->hasMany(GroupBuy::class);\n}\n\n// 我參加的團購\npublic function joinedGroupBuys(): BelongsToMany\n{\n    return $this->belongsToMany(GroupBuy::class)\n        ->withPivot('quantity', 'note')\n        ->withTimestamps();\n}\n```\n\n### Step 4：建立 Factory 和 Seeder\n\n`database/factories/GroupBuyFactory.php`：\n\n```php\npublic function definition(): array\n{\n    $items = [\n        '辦公室下午茶團', '手工餅乾團', '產地直送水果箱',\n        '日本零食福袋', '中秋月餅禮盒', '過年伴手禮團',\n        '咖啡豆合購', '手搖飲團購券', '健身便當週餐',\n    ];\n\n    return [\n        'user_id' => User::factory(),\n        'title' => fake()->randomElement($items),\n        'description' => fake()->realText(80),\n        'product_name' => fake()->randomElement(['鳳梨酥', '太陽餅', '手工餅乾', '精品咖啡豆']),\n        'price_per_unit' => fake()->numberBetween(5000, 80000),\n        'min_participants' => fake()->numberBetween(3, 10),\n        'max_participants' => fake()->optional(0.5)->numberBetween(10, 50),\n        'deadline' => fake()->dateTimeBetween('now', '+14 days'),\n        'status' => 'open',\n    ];\n}\n```\n\n### Step 5：跑起來\n\n```bash\nphp artisan migrate:fresh --seed\nphp artisan tinker\n```\n\n```php\n>>> GroupBuy::open()->count()\n>>> GroupBuy::first()->participants\n>>> GroupBuy::first()->organizer->name\n>>> User::first()->joinedGroupBuys\n```\n\n揪好買的資料骨架完成了。\n\n## 小結：Eloquent 讓你專注在業務邏輯\n\n這一章我們走過了 Eloquent 的完整核心：\n\n- **Migration**——用程式碼管理資料表結構，版本控制不再是問題\n- **Model**——每張表一個 PHP 類別，命名慣例自動對應\n- **CRUD**——`create()`、`find()`、`where()`、`update()`、`delete()`\n- **Mass Assignment**——`$fillable` 白名單防護批次賦值攻擊\n- **Relationships**——`hasMany`、`belongsTo`、`belongsToMany`，一行定義關聯\n- **Query Builder**——複雜查詢和報表的利器\n- **Factory & Seeder**——一行指令產生大量測試資料\n\n最重要的是，我們為揪好買建立了核心資料模型：\n\n- `users`——使用者\n- `group_buys`——團購，有開團者（一對多）和參與者（多對多）\n- `group_buy_user`——多對多中間表，記錄跟團數量\n\n下一章我們要用 [**Blade + Livewire**](/blog/laravel-guide-blade-livewire-frontend/) 把這些資料變成使用者看得到、摸得到的介面——團購列表頁、即時搜尋、動態更新跟團人數。資料有了，接下來蓋 UI。本章建立的 `group_buys` 與 `group_buy_user` 資料模型，也會在[揪好買核心業務邏輯](/blog/laravel-guide-group-buy-logic-session/)一章直接派上用場。",
      "summary": "Laravel Eloquent ORM 完整教學：從 Migration 建表、Model 命名慣例、CRUD 操作，到一對多／多對多關聯與 Factory／Seeder 測試資料，不寫 SQL 也能優雅操作資料庫，一次搞懂。",
      "image": "https://bobochen.dev/_astro/cover.CjtqppxE.webp",
      "date_published": "2025-03-25T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Eloquent",
        "ORM",
        "Database",
        "Migration"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-lifecycle-container-middleware/",
      "url": "https://bobochen.dev/blog/laravel-guide-lifecycle-container-middleware/",
      "title": "Laravel 的魔法與紀律：Request Lifecycle、Service Container 與 Middleware",
      "content_text": "深入拆解 Laravel 的三大核心機制：一個 HTTP request 從 public/index.php 進來到回傳 response 的完整 Request Lifecycle、作為框架心臟的 Service Container 依賴注入，以及像洋蔥層層包裹的 Middleware。搞懂這三者，從「會用 Laravel」升級成「理解 Laravel」，debug 速度快三倍。",
      "content_html": "用 Laravel 寫程式的時候，你有沒有一種「魔法」的感覺？Controller 的參數會自動注入、Middleware 不知道在哪裡就生效了、Facade 明明是靜態呼叫卻能在測試裡被 mock。這些看起來很酷，但如果你不理解背後的機制，遲早會在 debug 的時候撞牆——因為你不知道東西是從哪裡冒出來的。\n\n這一章我們要拆解 Laravel 最核心的三個概念：**Request Lifecycle**（一個 HTTP 請求從進來到回去的完整旅程）、**Service Container**（Laravel 的依賴注入容器，也是整個框架的心臟）、以及 **Middleware**（請求的過濾器與守門員）。這三樣東西搞懂了，你就從「會用 Laravel」升級成「理解 Laravel」，debug 的時候也比較知道該往哪裡找。\n\n我們也會在揪好買專案裡實際動手：寫一個 Request 記錄 Middleware，讓每個進來的請求都留下足跡；註冊第一個 Service Provider，體會 Container 的運作方式。理論搭配實作，理解才會紮實。\n\n## 一個 Request 的旅程：從瀏覽器到 Response\n\n[上一章](/blog/laravel-guide-setup-first-route/)我們看過「六步驟概覽」，這次我們用更精細的視角來追蹤。當使用者在瀏覽器輸入 `http://localhost:8000/` 按下 Enter，以下是完整的旅程：\n\n```\n瀏覽器 → Nginx/Apache → public/index.php\n  → Bootstrap Application (bootstrap/app.php)\n  → Service Providers (register + boot)\n  → Global Middleware（依序執行）\n  → Route Middleware（針對特定路由）\n  → Router（比對 URL → Controller）\n  → Controller 處理邏輯\n  → Response（反向穿越 Middleware）\n  → 瀏覽器收到 HTML\n```\n\n### 起點：public/index.php\n\n整個 Laravel 應用只有一個入口——`public/index.php`。不管使用者訪問 `/`、`/products/123` 還是 `/api/orders`，**所有請求都從這裡進來**。這叫做「Front Controller」模式。\n\n```php\n// public/index.php（Laravel 11/12/13 實際版本）\nuse Illuminate\\Foundation\\Application;\nuse Illuminate\\Http\\Request;\n\ndefine('LARAVEL_START', microtime(true));\n\n// 檢查維護模式...\nif (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {\n    require $maintenance;\n}\n\n// 載入 Composer autoloader...\nrequire __DIR__.'/../vendor/autoload.php';\n\n// 啟動 Laravel 並處理請求...\n/** @var Application $app */\n$app = require_once __DIR__.'/../bootstrap/app.php';\n\n$app->handleRequest(Request::capture());\n```\n\n三件事，從上到下：\n\n1. **載入 Composer autoloader**——讓所有 class 可以自動載入\n2. **建立 Application 實例**——這就是 Service Container 本人\n3. **`handleRequest()` 一行搞定全流程**——內部仍會走 HTTP Kernel（Middleware → 路由 → Controller），產出 Response 並送回瀏覽器，善後任務也在此完成\n\n> **Express 開發者的類比：** `public/index.php` 就像你的 `server.js` 入口。Front Controller 模式等同於 Express 的 `app.use()` 中介軟體管線——所有請求都通過同一條管線。\n\n### bootstrap/app.php：應用程式的組裝說明書\n\n從 Laravel 11 開始，`bootstrap/app.php` 取代了舊版的 Http Kernel 和 Console Kernel，成為應用程式的唯一組裝設定檔：\n\n```php\n// bootstrap/app.php\nuse Illuminate\\Foundation\\Application;\nuse Illuminate\\Foundation\\Configuration\\Exceptions;\nuse Illuminate\\Foundation\\Configuration\\Middleware;\n\nreturn Application::configure(basePath: dirname(__DIR__))\n    ->withRouting(\n        web: __DIR__.'/../routes/web.php',\n        commands: __DIR__.'/../routes/console.php',\n        health: '/up',\n    )\n    ->withMiddleware(function (Middleware $middleware) {\n        // 在這裡自訂 Middleware\n    })\n    ->withExceptions(function (Exceptions $exceptions) {\n        // 在這裡自訂例外處理\n    })\n    ->create();\n```\n\n三個 `with` 方法，分別設定路由、Middleware 和例外處理。整個 Laravel 的啟動設定就在這一個檔案裡，乾淨又集中。\n\n## Service Container：Laravel 的心臟\n\nService Container 是 Laravel 最重要的概念，沒有之一。如果你只能從這章記住一件事，就記住這個。\n\n### 什麼是 Service Container？\n\n用最白話的方式說：Service Container 是一個**物件工廠**。你告訴它「我需要一個 X」，它就幫你把 X 造出來——包括 X 依賴的 Y 和 Z，也一併搞定。\n\n```php\n// 你不用這樣寫（手動建立所有依賴）\n$logger = new FileLogger('/var/log/app.log');\n$mailer = new SmtpMailer('smtp.gmail.com', 587, $credentials);\n$notifier = new OrderNotifier($mailer, $logger);\n$controller = new OrderController($notifier);\n\n// Container 幫你搞定一切——你只需要「要」\n$controller = app(OrderController::class);\n// Container 自動解析所有依賴，遞迴建立\n```\n\n### 為什麼需要它？\n\n想像你在蓋一棟房子。沒有 Container 的世界裡，你得自己去買磚、拌水泥、叫水電工、找油漆師傅——每蓋一棟都從頭來。有 Container 就像有個包工頭：你說「我要一棟三房兩廳」，他幫你搞定所有上下游。\n\n實際好處：\n\n1. **解耦合**——你的程式碼不需要知道「怎麼建立」依賴，只需要宣告「我需要什麼」\n2. **可測試**——測試時可以替換假的依賴（mock），不用動到真正的資料庫或郵件服務\n3. **靈活性**——想換 Logger 實作？改一處設定，全站生效\n\n### 跨語言對照\n\n| 概念          | Laravel                  | Spring (Java)      | NestJS (Node.js)      | Python     |\n| ------------- | ------------------------ | ------------------ | --------------------- | ---------- |\n| IoC Container | Service Container        | ApplicationContext | Module + providers    | 通常不內建 |\n| 註冊服務      | `bind()` / `singleton()` | `@Bean`            | `@Injectable()`       | —          |\n| 自動注入      | Type-hint in constructor | `@Autowired`       | Constructor injection | —          |\n\n> **JS/Python 開發者注意：** JavaScript 和 Python 社群通常不使用 IoC Container（因為語言的動態性質讓手動 DI 比較容易）。但在 PHP 和 Java 的世界裡，Container 是框架的基石。如果這個概念對你來說很新，不用擔心——跟著往下看，很快就會上手。\n\n### 基本操作\n\n```php\n// 1. 綁定：告訴 Container「當有人要 X 的時候，這樣造」\napp()->bind(PaymentGateway::class, function () {\n    return new StripeGateway(config('services.stripe.secret'));\n});\n\n// 2. 解析：「我需要一個 PaymentGateway」\n$gateway = app(PaymentGateway::class);\n// Container 執行上面的 closure，回傳 StripeGateway 實例\n\n// 3. 單例模式：整個 request 只建立一次\napp()->singleton(ShoppingCart::class, function () {\n    return new ShoppingCart();\n});\n// 不管你 resolve 幾次，拿到的都是同一個實例\n```\n\n### 介面綁定：最強大的用法\n\n```php\n// 綁定介面到實作\napp()->bind(\n    PaymentGatewayInterface::class,\n    StripeGateway::class\n);\n\n// 之後不管哪裡需要 PaymentGatewayInterface\n// Container 都會自動給你 StripeGateway\n\n// 想換成 ECPay？改這一行就好\napp()->bind(\n    PaymentGatewayInterface::class,\n    EcPayGateway::class\n);\n```\n\n這就是「面向介面編程」的威力——你的 Controller 不知道也不關心後面是 Stripe 還是 ECPay，它只跟介面說話。\n\n## 依賴注入：不用自己 new 物件\n\n依賴注入（Dependency Injection, DI）是 Service Container 最常被使用的方式。簡單說就是：**不要自己建立依賴，讓框架幫你注入**。\n\n### Constructor Injection\n\n最常見也最推薦的注入方式——在 constructor 裡用 type-hint 宣告你需要什麼：\n\n```php\nclass OrderController extends Controller\n{\n    public function __construct(\n        private OrderService $orderService,\n        private PaymentGatewayInterface $gateway,\n    ) {}\n\n    public function store(Request $request)\n    {\n        $order = $this->orderService->create($request->all());\n        $this->gateway->charge($order->total);\n\n        return redirect('/orders/' . $order->id);\n    }\n}\n```\n\n你完全沒有寫過 `new OrderService()` 或 `new StripeGateway()`——Container 看到 constructor 的 type-hint，自動幫你建立並注入。\n\n### Method Injection\n\n在 Controller 的方法裡也可以注入：\n\n```php\nclass ProductController extends Controller\n{\n    // Request 自動注入、Product 自動透過 Route Model Binding 注入\n    public function show(Request $request, Product $product)\n    {\n        return view('products.show', compact('product'));\n    }\n}\n```\n\n`Request` 是 Laravel 的 HTTP 請求物件，`Product` 是透過 URL 參數自動查到的 Eloquent Model（這叫 Route Model Binding，[第四章](/blog/laravel-guide-eloquent-orm-models/)會詳細介紹）。兩個都是 Container 自動注入的。\n\n### 自動解析的魔法\n\nContainer 怎麼知道要給你什麼？它的邏輯很簡單：\n\n1. 看 constructor 的 type-hint\n2. 如果是具體類別（如 `OrderService`）→ 直接 `new` 出來（遞迴解析它的依賴）\n3. 如果是介面（如 `PaymentGatewayInterface`）→ 查綁定表，找到對應的實作\n4. 如果找不到 → 丟出 `BindingResolutionException`\n\n大多數時候你不需要手動 `bind()`。只要你的類別 constructor 裡用的是具體類別，Container 會自動解析，完全不需要設定。只有當你用介面的時候，才需要告訴 Container「這個介面對應哪個實作」。\n\n## Service Provider：應用程式的啟動清單\n\nService Provider 是你告訴 Container「要綁定哪些東西」的地方。每個 Laravel 應用在啟動時都會跑過一系列的 Service Provider，把需要的服務註冊到 Container 裡。\n\n### 結構\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\ServiceProvider;\n\nclass AppServiceProvider extends ServiceProvider\n{\n    /**\n     * 註冊階段：綁定服務到 Container\n     * 這裡只做 bind/singleton，不要存取其他服務\n     */\n    public function register(): void\n    {\n        $this->app->bind(\n            PaymentGatewayInterface::class,\n            StripeGateway::class\n        );\n    }\n\n    /**\n     * 啟動階段：所有 Provider 都 register 完了，可以安全地使用任何服務\n     * 適合放 Event Listener、Route Model Binding、View Composer 等\n     */\n    public function boot(): void\n    {\n        // 例如：全站共用的 View 變數\n        view()->share('appName', config('app.name'));\n    }\n}\n```\n\n### register() vs boot()\n\n這兩個方法的執行順序很重要：\n\n```\nApplication 啟動\n  ↓\n所有 Provider 的 register() 依序執行\n  ↓ （此時所有服務都已註冊到 Container）\n所有 Provider 的 boot() 依序執行\n  ↓\nApplication 準備好接收 Request\n```\n\n| 方法         | 用途     | 能做什麼                | 不該做什麼                     |\n| ------------ | -------- | ----------------------- | ------------------------------ |\n| `register()` | 註冊綁定 | `bind()`、`singleton()` | 使用其他服務（可能還沒被註冊） |\n| `boot()`     | 啟動設定 | 用任何已註冊的服務      | 不該再做綁定（太晚了）         |\n\n> **類比：** 想像一場派對。`register()` 是「大家把食物帶來放桌上」，`boot()` 是「所有食物都到了，開始擺盤和布置」。你不能在擺盤的時候才發現主菜還沒到。\n\n### 建立自訂 Provider\n\n```bash\nphp artisan make:provider PaymentServiceProvider\n```\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse App\\Services\\Payment\\EcPayGateway;\nuse App\\Services\\Payment\\PaymentGatewayInterface;\nuse App\\Services\\Payment\\StripeGateway;\nuse Illuminate\\Support\\ServiceProvider;\n\nclass PaymentServiceProvider extends ServiceProvider\n{\n    public function register(): void\n    {\n        $gateway = match (config('payment.default')) {\n            'ecpay' => EcPayGateway::class,\n            default => StripeGateway::class,\n        };\n\n        $this->app->singleton(PaymentGatewayInterface::class, $gateway);\n    }\n}\n```\n\n用 `make:provider` 建立時，Laravel 11 起會自動把新 Provider 寫入 `bootstrap/providers.php`，你不需要手動編輯，只要打開確認它已經在陣列裡即可：\n\n```php\nreturn [\n    App\\Providers\\AppServiceProvider::class,\n    App\\Providers\\PaymentServiceProvider::class, // make:provider 已自動加入\n];\n```\n\n> **注意：** 只有當你手動建立 Provider 檔案（沒用 `make:provider`）時，才需要自己把它加進這個陣列。用指令產生卻又手動再加一次，會造成重複註冊。\n\n## Middleware：Request 的過濾器\n\nMiddleware 是一個「洋蔥」——每一層 Middleware 包裹著下一層，Request 從外層進去、穿過 Controller、Response 再從內層出來。\n\n```\nRequest → [Middleware A → [Middleware B → [Controller] → Middleware B] → Middleware A] → Response\n```\n\n### Middleware 做什麼？\n\n常見用途：\n\n- **認證檢查**——沒登入？打回登入頁\n- **CSRF 防護**——POST 請求必須帶 token\n- **Rate Limiting**——限制 API 呼叫頻率\n- **CORS 處理**——設定跨域 headers\n- **日誌記錄**——記錄每個 request 的資訊\n\n### 建立自訂 Middleware\n\n```bash\nphp artisan make:middleware LogRequest\n```\n\n```php\n<?php\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Log;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass LogRequest\n{\n    public function handle(Request $request, Closure $next): Response\n    {\n        // ① 在 Request 進入 Controller 之前做事\n        $start = microtime(true);\n\n        // ② 把 Request 傳給下一層（最終到 Controller）\n        $response = $next($request);\n\n        // ③ Response 出來之後做事\n        $duration = round((microtime(true) - $start) * 1000, 2);\n\n        Log::info('Request processed', [\n            'method' => $request->method(),\n            'url' => $request->fullUrl(),\n            'status' => $response->getStatusCode(),\n            'duration_ms' => $duration,\n            'ip' => $request->ip(),\n        ]);\n\n        return $response;\n    }\n}\n```\n\nMiddleware 的結構永遠是這樣：\n\n1. `$next($request)` **之前**——處理 Request（前置作業）\n2. `$next($request)`——把 Request 傳遞下去\n3. `$next($request)` **之後**——處理 Response（後置作業）\n\n> **Express 開發者對照：** 這就是 Express 的 `(req, res, next) => { ... next(); ... }`。概念完全一樣，只是 Laravel 把 `$next($request)` 的回傳值當作 Response。\n\n### 註冊 Middleware\n\n在 `bootstrap/app.php` 裡設定：\n\n```php\n->withMiddleware(function (Middleware $middleware) {\n    // 全域 Middleware——每個 Request 都會經過\n    $middleware->append(LogRequest::class);\n\n    // 路由群組 Middleware\n    $middleware->appendToGroup('api', [\n        // API 專用 Middleware\n    ]);\n\n    // 路由別名——在個別路由上使用\n    $middleware->alias([\n        'admin' => EnsureUserIsAdmin::class,\n    ]);\n})\n```\n\n### 在路由上使用 Middleware\n\n```php\n// 用在單一路由\nRoute::get('/admin/dashboard', [AdminController::class, 'dashboard'])\n    ->middleware('admin');\n\n// 用在路由群組\nRoute::middleware(['auth', 'admin'])->group(function () {\n    Route::get('/admin/dashboard', [AdminController::class, 'dashboard']);\n    Route::get('/admin/users', [AdminController::class, 'users']);\n});\n```\n\n### Laravel 內建的重要 Middleware\n\n| Middleware         | 用途             | 對應的路由群組 |\n| ------------------ | ---------------- | -------------- |\n| `EncryptCookies`   | 加密 Cookie      | web            |\n| `VerifyCsrfToken`  | 驗證 CSRF Token  | web            |\n| `StartSession`     | 啟動 Session     | web            |\n| `ThrottleRequests` | API 頻率限制     | api            |\n| `auth`             | 驗證使用者已登入 | 自訂使用       |\n| `guest`            | 驗證使用者未登入 | 自訂使用       |\n\n`web` 群組預設套用在 `routes/web.php` 的所有路由上，你不用手動加——這也是「魔法」之一，但現在你知道它是怎麼運作的了。\n\n## Facade vs 依賴注入：該用哪個？\n\nLaravel 的 Facade 讓你可以用靜態語法呼叫服務：\n\n```php\n// Facade 寫法\nuse Illuminate\\Support\\Facades\\Cache;\n\n$value = Cache::get('key');\n\n// 依賴注入寫法\nuse Illuminate\\Contracts\\Cache\\Repository;\n\nclass MyService\n{\n    public function __construct(\n        private Repository $cache,\n    ) {}\n\n    public function getValue()\n    {\n        return $this->cache->get('key');\n    }\n}\n```\n\n兩種寫法在底層做的事情**完全一樣**——Facade 只是一個語法糖，它在背後去 Container 拿服務。\n\n### 什麼時候用哪個？\n\n| 場景           | 建議                         | 原因                             |\n| -------------- | ---------------------------- | -------------------------------- |\n| Controller     | 依賴注入                     | 明確宣告依賴，好測試             |\n| Service 類別   | 依賴注入                     | 同上                             |\n| Blade 模板     | Facade 或 Helper             | `@auth` 就是 Facade 的語法糖     |\n| 設定檔         | Helper (`config()`, `env()`) | 沒有 class，無法注入             |\n| 快速 prototype | Facade                       | 先求有，重構時再換               |\n| 測試           | 都行                         | Facade 有 `::fake()`，DI 有 mock |\n\n### 實務上的建議\n\n如果你剛從 JS/Python 轉過來，**先用 Facade 沒問題**。它的學習曲線低，而且 Laravel 的 Facade 設計得很好——不會造成全域狀態的問題（每個 request 結束後都會清除）。\n\n等你對框架更熟了，自然會開始在 Service 層用依賴注入。不用一開始就追求「完美的架構」，那只會讓你寫不出東西。\n\n```php\n// 這樣寫完全沒問題，很多 Laravel 專案都這樣\nclass OrderController extends Controller\n{\n    public function index()\n    {\n        $orders = Order::where('user_id', Auth::id())\n            ->latest()\n            ->paginate(20);\n\n        return view('orders.index', compact('orders'));\n    }\n}\n\n// 想要更 testable？重構成 DI 也不難\nclass OrderController extends Controller\n{\n    public function __construct(\n        private OrderService $orders,\n    ) {}\n\n    public function index(Request $request)\n    {\n        $orders = $this->orders->listForUser($request->user());\n\n        return view('orders.index', compact('orders'));\n    }\n}\n```\n\n## 實作：為揪好買加入 Request 記錄 Middleware\n\n理論講完了，讓我們在揪好買專案裡動手做。\n\n### Step 1：建立 Middleware\n\n```bash\nphp artisan make:middleware LogRequest\n```\n\n編輯 `app/Http/Middleware/LogRequest.php`：\n\n```php\n<?php\n\nnamespace App\\Http\\Middleware;\n\nuse Closure;\nuse Illuminate\\Http\\Request;\nuse Illuminate\\Support\\Facades\\Log;\nuse Symfony\\Component\\HttpFoundation\\Response;\n\nclass LogRequest\n{\n    public function handle(Request $request, Closure $next): Response\n    {\n        $start = microtime(true);\n\n        $response = $next($request);\n\n        $duration = round((microtime(true) - $start) * 1000, 2);\n\n        Log::channel('single')->info('HTTP Request', [\n            'method' => $request->method(),\n            'path' => $request->path(),\n            'status' => $response->getStatusCode(),\n            'duration_ms' => $duration,\n            'ip' => $request->ip(),\n            'user_agent' => $request->userAgent(),\n        ]);\n\n        return $response;\n    }\n}\n```\n\n### Step 2：全域註冊\n\n編輯 `bootstrap/app.php`：\n\n```php\nuse App\\Http\\Middleware\\LogRequest;\n\nreturn Application::configure(basePath: dirname(__DIR__))\n    ->withRouting(\n        web: __DIR__.'/../routes/web.php',\n        commands: __DIR__.'/../routes/console.php',\n        health: '/up',\n    )\n    ->withMiddleware(function (Middleware $middleware) {\n        $middleware->append(LogRequest::class);\n    })\n    ->withExceptions(function (Exceptions $exceptions) {\n        //\n    })\n    ->create();\n```\n\n### Step 3：測試\n\n```bash\nphp artisan serve\n# 在另一個終端視窗\ncurl http://localhost:8000/\n```\n\n查看 `storage/logs/laravel.log`：\n\n```\n[2026-06-15 10:30:00] local.INFO: HTTP Request {\n    \"method\": \"GET\",\n    \"path\": \"/\",\n    \"status\": 200,\n    \"duration_ms\": 42.35,\n    \"ip\": \"127.0.0.1\",\n    \"user_agent\": \"curl/8.20.0\"\n}\n```\n\n每一個進來的 request 都被記錄了。這個 Middleware 在開發階段幫你觀察請求流量，到了 production 也能用來做效能監控。\n\n### Step 4：加入一個簡單的 Service Provider（bonus）\n\n讓我們建立一個 Service Provider，在每個頁面的 View 裡共享揪好買的設定：\n\n```bash\nphp artisan make:provider JiuHaoMaiServiceProvider\n```\n\n```php\n<?php\n\nnamespace App\\Providers;\n\nuse Illuminate\\Support\\Facades\\View;\nuse Illuminate\\Support\\ServiceProvider;\n\nclass JiuHaoMaiServiceProvider extends ServiceProvider\n{\n    public function register(): void\n    {\n        // 註冊應用程式設定\n        $this->app->singleton('jiuhaomai.config', function () {\n            return [\n                'name' => '揪好買 JiuHaoMai',\n                'slogan' => '找好物、揪好友、一起買更划算',\n                'version' => '0.1.0',\n                'min_participants' => 3,\n            ];\n        });\n    }\n\n    public function boot(): void\n    {\n        // 讓所有 View 都能用 $jhm 變數\n        View::share('jhm', app('jiuhaomai.config'));\n    }\n}\n```\n\n由於是用 `make:provider` 產生的，Laravel 已自動把它寫進 `bootstrap/providers.php`，打開確認一下即可（不需要再手動加）：\n\n```php\nreturn [\n    App\\Providers\\AppServiceProvider::class,\n    App\\Providers\\JiuHaoMaiServiceProvider::class, // make:provider 已自動加入\n];\n```\n\n現在你可以在任何 Blade 模板裡直接用 `$jhm`：\n\n```html\n<footer>\n  <p>{{ $jhm['name'] }} v{{ $jhm['version'] }} — {{ $jhm['slogan'] }}</p>\n</footer>\n```\n\n這就是 Service Provider 的威力：**一處設定，全站生效**。\n\n## 小結：理解魔法，掌握紀律\n\n這一章的資訊量很大，讓我們整理一下核心概念：\n\n**Request Lifecycle：**\n\n- 所有請求從 `public/index.php` 進入\n- `bootstrap/app.php` 設定路由、Middleware、例外處理\n- Request 穿過 Middleware 洋蔥 → Router → Controller → Response 反向穿回\n\n**Service Container：**\n\n- 一個智慧物件工廠，自動解析依賴\n- `bind()` 註冊一對一對應，`singleton()` 保證只建立一次\n- 介面綁定讓你能輕鬆切換實作\n\n**依賴注入：**\n\n- 在 constructor 或方法裡 type-hint，Container 自動注入\n- 具體類別不用設定，介面需要 `bind()` 綁定\n\n**Service Provider：**\n\n- `register()` 註冊綁定，`boot()` 設定啟動邏輯\n- 所有 `register()` 跑完才開始跑 `boot()`\n\n**Middleware：**\n\n- Request 的前置/後置過濾器\n- `$next($request)` 前後分別處理 request 和 response\n- 在 `bootstrap/app.php` 設定全域、群組或路由別名\n\n**Facade vs DI：**\n\n- 兩種方式底層一樣，都從 Container 取服務\n- 新手先用 Facade 沒問題，熟了再用 DI 提升可測試性\n\n下一章我們進入 Laravel 最迷人的部分——[**Eloquent ORM**](/blog/laravel-guide-eloquent-orm-models/)。你會學到如何用優雅的 PHP 程式碼操作資料庫，完全不用寫 SQL。我們也會開始設計揪好買的資料表：使用者、團購、商品、參團紀錄。",
      "summary": "深入拆解 Laravel 的三大核心機制：一個 HTTP request 從 public/index.php 進來到回傳 response 的完整 Request Lifecycle、作為框架心臟的 Service Container 依賴注入，以及像洋蔥層層包裹的 Middleware。搞懂這三者，從「會用 Laravel」升級成「理解 Laravel」，debug 速度快三倍。",
      "image": "https://bobochen.dev/_astro/cover.CZYdicmj.webp",
      "date_published": "2025-03-18T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Service Container",
        "Middleware",
        "Dependency Injection"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-setup-first-route/",
      "url": "https://bobochen.dev/blog/laravel-guide-setup-first-route/",
      "title": "Laravel 12 起手式：從 Composer 到第一個 Route 的十分鐘",
      "content_text": "從零安裝 Laravel 12——用 composer create-project 或 laravel new 一行建立專案，搞懂目錄結構、Artisan CLI、Route 路由與 Blade 模板，再用 .env 管理環境變數，十分鐘做出第一個首頁。",
      "content_html": "Laravel 是 PHP 生態系中最受歡迎的框架，沒有之一。它的設計哲學很簡單：讓開發者把時間花在業務邏輯上，而不是重複造輪子。從路由、資料庫、認證、佇列到排程任務，Laravel 全部幫你準備好了——而且 API 設計得優雅到你會覺得寫程式是一種享受。\n\n但在享受之前，你得先讓它跑起來。好消息是，Laravel 的安裝流程已經簡化到一行指令就搞定。\n\n> **版本說明（2026 年）：** Laravel 已於 2026-03-17 推出 **Laravel 13**（最新版，最低需求提升至 PHP 8.3+，主打 AI-native 工作流、JSON:API resources、向量／語意搜尋等）。本章內容以 **Laravel 12** 為基準撰寫，整本書的範例與目錄結構導覽都以 12 為準；如果你用 `composer create-project` 或 `laravel new` 預設安裝，現在很可能裝到的是 Laravel 13。兩版的起手式（安裝、Route、Blade、`.env`）幾乎一致，本章內容仍然適用；差別主要在 Laravel 13 要求 PHP 8.3+，且部分模型／控制器等可改用新的 PHP attribute 寫法。想完全照本章操作，可在 `composer create-project` 後面指定版本：`composer create-project laravel/laravel:\"^12.0\" jiu-hao-mai`。\n\n這一章我們要做的事情很明確：裝好 Laravel、搞懂目錄結構、寫出第一個 Route 和 View。十分鐘之後，你的瀏覽器上會出現「揪好買」的首頁——這是我們整本書會一起打造的台灣團購平台專案。\n\n不用擔心目錄裡那一堆資料夾看起來很嚇人。每個資料夾都有它明確的職責，我會一個一個帶你認識。學框架最怕的就是「知其然不知其所以然」，所以我們不只要讓它跑起來，還要搞清楚每一步發生了什麼事。\n\n## 安裝 Laravel 12：一行指令搞定\n\n確認你已經裝好 PHP 8.2+ 和 Composer（[上一章 PHP 快速入門](/blog/laravel-guide-php-for-modern-developers) 有教），然後打開終端機：\n\n```bash\ncomposer create-project laravel/laravel jiu-hao-mai\ncd jiu-hao-mai\nphp artisan serve\n```\n\n三行指令，打開瀏覽器訪問 `http://localhost:8000`，你就會看到 Laravel 的預設歡迎頁面。\n\n> **專案命名：** 我們把專案取名叫 `jiu-hao-mai`（揪好買的拼音），這是整本書會持續開發的團購平台。\n\n### 另一種安裝方式：Laravel Installer\n\nLaravel 也提供了官方安裝器，它可以在建立專案時互動式地選擇前端堆疊和 Starter Kit：\n\n```bash\n# 先安裝 Laravel Installer（只需要做一次）\ncomposer global require laravel/installer\n\n# 用 installer 建立專案\nlaravel new jiu-hao-mai\n```\n\nInstaller 會問你幾個問題：要不要 Starter Kit？前端用 React、Vue、Livewire 還是 Svelte？（Laravel 12 用全新的 Starter Kit 取代了舊版的 Breeze 和 Jetstream。）我們這本書在第六章才會加入[認證系統](/blog/laravel-guide-auth-breeze-authorization)，所以現在先選 **No starter kit**，保持乾淨。\n\n### 兩種方式的差異\n\n|                      | `composer create-project` | `laravel new`                 |\n| -------------------- | ------------------------- | ----------------------------- |\n| 需要先安裝 installer | 不用                      | 要                            |\n| 互動式選擇           | 沒有                      | 有（Starter Kit、測試框架等） |\n| 適合場景             | 快速建立純淨專案          | 需要一步到位設定完整堆疊      |\n| CI/CD 環境           | 比較適合                  | 較不適合（互動式）            |\n\n兩種方式建出來的專案結構完全一樣，選你喜歡的就好。\n\n### 預設資料庫：SQLite\n\n從 Laravel 11 開始，新建專案預設使用 **SQLite** 作為資料庫——不需要安裝 MySQL，開箱即用。專案根目錄會有一個 `database/database.sqlite` 檔案，零設定就能開始開發。等到要部署正式環境時，再換成 MySQL 或 PostgreSQL 也很容易（改個 `.env` 設定就好）。\n\n## 目錄結構導覽：每個資料夾在幹嘛\n\n`cd jiu-hao-mai` 之後，你會看到這樣的目錄結構：\n\n```text\njiu-hao-mai/\n├── app/                 # 🧠 你的應用程式核心\n│   ├── Http/\n│   │   └── Controllers/ # Controller（處理 HTTP 請求的邏輯）\n│   ├── Models/          # Eloquent Model（資料庫對應的 PHP 類別）\n│   └── Providers/       # Service Provider（應用程式啟動設定）\n├── bootstrap/           # 框架啟動程式（通常不需要動）\n│   └── app.php          # 應用程式設定與 Middleware 註冊\n├── config/              # 設定檔（database, mail, cache 等）\n├── database/\n│   ├── factories/       # Model Factory（測試資料產生器）\n│   ├── migrations/      # Migration（資料表版本控制）\n│   ├── seeders/         # Seeder（填充測試資料）\n│   └── database.sqlite  # 預設的 SQLite 資料庫\n├── public/              # 唯一對外公開的目錄（index.php 在這）\n├── resources/\n│   ├── css/             # CSS 原始檔\n│   ├── js/              # JavaScript 原始檔\n│   └── views/           # Blade 模板（HTML）\n├── routes/\n│   ├── console.php      # Artisan 自訂指令與排程\n│   └── web.php          # 🌐 網頁路由（最常編輯的檔案之一）\n│   # 注意：api.php 預設不存在，需要時執行 php artisan install:api\n├── storage/             # 日誌、快取、上傳檔案\n├── tests/               # 測試程式碼\n├── .env                 # 環境變數（不進版控）\n├── .env.example         # 環境變數範本（進版控）\n├── artisan              # Artisan CLI 入口\n├── composer.json        # PHP 相依套件\n└── package.json         # 前端相依套件\n```\n\n> **Laravel 11+ 的精簡骨架：** 如果你看過舊版 Laravel 的教學，可能會好奇 `app/Http/Kernel.php` 和 `app/Console/Kernel.php` 去哪了？從 Laravel 11 開始，框架採用了精簡的應用程式骨架——Kernel 的設定被移到 `bootstrap/app.php`，Middleware 的註冊也在那裡。少了很多「看了不知道要幹嘛」的檔案，新手友善度大幅提升。\n\n### 你最常碰的五個位置\n\n| 位置                    | 用途               | 頻率         |\n| ----------------------- | ------------------ | ------------ |\n| `routes/web.php`        | 定義 URL 路由      | 每天         |\n| `app/Http/Controllers/` | 處理請求邏輯       | 每天         |\n| `app/Models/`           | 資料庫 Model       | 經常         |\n| `resources/views/`      | Blade 模板（HTML） | 經常         |\n| `database/migrations/`  | 資料表結構變更     | 每次改資料庫 |\n\n其他目錄在你需要的時候自然會碰到，現在不用記。\n\n### 與其他框架的對照\n\n如果你從其他框架轉過來，這張表幫你快速對應：\n\n| 概念       | Laravel                    | Express (Node.js)       | Django (Python) | Rails (Ruby)       |\n| ---------- | -------------------------- | ----------------------- | --------------- | ------------------ |\n| 路由       | `routes/web.php`           | `app.js` / router files | `urls.py`       | `config/routes.rb` |\n| 控制器     | `app/Http/Controllers/`    | route handlers          | `views.py`      | `app/controllers/` |\n| 模板       | `resources/views/` (Blade) | `views/` (EJS/Pug)      | `templates/`    | `app/views/` (ERB) |\n| ORM Model  | `app/Models/` (Eloquent)   | (Prisma/Sequelize)      | `models.py`     | `app/models/`      |\n| 資料庫遷移 | `database/migrations/`     | (Prisma/Knex)           | `migrations/`   | `db/migrate/`      |\n| 設定       | `config/` + `.env`         | `.env`                  | `settings.py`   | `config/`          |\n\n## php artisan：你的 Laravel 瑞士刀\n\n`artisan` 是 Laravel 的命令列工具，它能幫你做幾乎所有事情——從產生程式碼到管理資料庫、從清快取到啟動開發伺服器。\n\n```bash\n# 查看所有可用指令\nphp artisan list\n\n# 啟動開發伺服器\nphp artisan serve\n\n# 產生 Controller\nphp artisan make:controller ProductController\n\n# 產生 Model（順便建 migration 和 factory）\nphp artisan make:model Product -mf\n\n# 執行資料庫 migration\nphp artisan migrate\n\n# 列出所有已註冊的路由\nphp artisan route:list\n\n# 清除各種快取\nphp artisan cache:clear\nphp artisan config:clear\nphp artisan route:clear\nphp artisan view:clear\n\n# 進入互動式 PHP Shell（像 Node.js 的 REPL 或 Python 的 interactive shell）\nphp artisan tinker\n```\n\n### Tinker：你的即時測試場\n\n`tinker` 是 Laravel 內建的互動式 REPL，讓你可以直接操作 Model、測試邏輯、查詢資料庫——不用啟動瀏覽器：\n\n```bash\nphp artisan tinker\n\n>>> $user = new App\\Models\\User;\n=> App\\Models\\User {#1234}\n\n>>> config('app.name')\n=> \"Laravel\"\n\n>>> now()->format('Y-m-d')\n=> \"2026-06-08\"\n\n>>> exit\n```\n\n> **JS 開發者的類比：** `tinker` 就像 Node.js 的 `node` interactive shell，但它自動載入了整個 Laravel 應用程式。Python 開發者可以想像成 `python manage.py shell`。\n\n### make 指令：程式碼產生器\n\n`make` 系列指令是你最常用的 artisan 指令。它們按照 Laravel 的慣例幫你產生檔案，省掉手動建立和複製貼上：\n\n```bash\nphp artisan make:controller    # Controller\nphp artisan make:model         # Eloquent Model\nphp artisan make:migration     # 資料庫遷移\nphp artisan make:middleware     # Middleware\nphp artisan make:request        # Form Request（表單驗證）\nphp artisan make:policy         # 授權 Policy\nphp artisan make:command        # 自訂 Artisan 指令\nphp artisan make:event          # Event\nphp artisan make:listener       # Event Listener\nphp artisan make:job            # Queue Job\nphp artisan make:mail           # Mail\nphp artisan make:notification   # Notification\nphp artisan make:test           # 測試\n```\n\n不用全背，需要的時候 `php artisan list make` 查就好。\n\n## Route 基礎：URL 對應到程式碼\n\n打開 `routes/web.php`，你會看到預設內容：\n\n```php\n<?php\n\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get('/', function () {\n    return view('welcome');\n});\n```\n\n一行就把根路徑 `/` 對應到 `welcome` 這個 Blade view。這就是 Laravel 路由的核心概念：**定義 URL → 指定處理邏輯 → 回傳 Response**。\n\n### 基本路由寫法\n\n```php\n// GET 請求\nRoute::get('/about', function () {\n    return view('about');\n});\n\n// POST 請求\nRoute::post('/contact', function () {\n    // 處理表單提交\n    return redirect('/thank-you');\n});\n\n// 帶參數的路由\nRoute::get('/products/{id}', function (string $id) {\n    return \"商品編號：{$id}\";\n});\n\n// 可選參數\nRoute::get('/products/{category?}', function (?string $category = null) {\n    return $category ? \"分類：{$category}\" : '所有商品';\n});\n```\n\n### 路由搭配 Controller\n\n在真實專案中，我們不會把邏輯寫在路由檔裡（那會變成一坨義大利麵）。取而代之，我們用 Controller 來處理：\n\n```bash\nphp artisan make:controller PageController\n```\n\n這會在 `app/Http/Controllers/` 產生一個檔案：\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nclass PageController extends Controller\n{\n    public function home()\n    {\n        return view('home');\n    }\n\n    public function about()\n    {\n        return view('about');\n    }\n}\n```\n\n然後在路由裡指向 Controller：\n\n```php\nuse App\\Http\\Controllers\\PageController;\n\nRoute::get('/', [PageController::class, 'home']);\nRoute::get('/about', [PageController::class, 'about']);\n```\n\n> **Express 開發者注意：** Laravel 的路由語法是 `Route::get('/path', [Controller::class, 'method'])`。第二個參數不是 callback，而是一個陣列 `[類別, 方法名]`。這跟 Express 的 `router.get('/path', controller.method)` 概念一樣，語法不同。\n\n### 查看所有路由\n\n```bash\nphp artisan route:list\n```\n\n```text\nGET|HEAD  / ...................................................... PageController@home\nGET|HEAD  /about ................................................. PageController@about\n```\n\n這個指令會列出所有已註冊的路由、HTTP 方法和對應的 Controller，debug 時超級好用。\n\n## Blade 初體驗：你的第一個 View\n\nBlade 是 Laravel 的模板引擎。如果你用過 EJS（Express）、Jinja2（Python）或 ERB（Rails），概念完全一樣——在 HTML 裡嵌入動態資料。\n\nBlade 檔案放在 `resources/views/`，副檔名是 `.blade.php`。\n\n### 基本語法\n\n```html\n<!-- resources/views/home.blade.php -->\n<!DOCTYPE html>\n<html lang=\"zh-TW\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>{{ $title }}</title>\n  </head>\n  <body>\n    <h1>{{ $title }}</h1>\n    <p>{{ $description }}</p>\n\n    {{-- 這是 Blade 註解，不會輸出到 HTML --}}\n    @if($products->count() > 0)\n    <ul>\n      @foreach($products as $product)\n      <li>{{ $product->name }} - ${{ $product->price }}</li>\n      @endforeach\n    </ul>\n    @else\n    <p>目前沒有商品</p>\n    @endif\n  </body>\n</html>\n```\n\n### Blade 語法速查\n\n| 語法                     | 用途                    | 類比                      |\n| ------------------------ | ----------------------- | ------------------------- |\n| `{{ $var }}`             | 輸出並自動 HTML 跳脫    | EJS 的 `<%= var %>`       |\n| `{!! $html !!}`          | 輸出原始 HTML（不跳脫） | EJS 的 `<%- html %>`      |\n| `@if / @else / @endif`   | 條件判斷                | Jinja2 的 `{% if %}`      |\n| `@foreach / @endforeach` | 迴圈                    | Jinja2 的 `{% for %}`     |\n| `@extends('layout')`     | 繼承版面                | Jinja2 的 `{% extends %}` |\n| `@section / @yield`      | 定義/填充區塊           | Jinja2 的 `{% block %}`   |\n| `{{-- 註解 --}}`         | Blade 註解              | `<!-- -->` 但不輸出       |\n\n### 從 Controller 傳資料給 View\n\n```php\n// 在 Controller 裡\npublic function home()\n{\n    return view('home', [\n        'title' => '揪好買 JiuHaoMai',\n        'description' => '台灣最有溫度的團購平台',\n    ]);\n}\n```\n\n`view()` 的第二個參數是一個陣列，key 會變成 View 裡的變數名。`'title' => '揪好買'` 在 Blade 裡就是 `$title`。\n\n也可以用 `compact()` 語法糖：\n\n```php\npublic function home()\n{\n    $title = '揪好買 JiuHaoMai';\n    $description = '台灣最有溫度的團購平台';\n\n    return view('home', compact('title', 'description'));\n}\n```\n\n### Layout 機制：避免重複的 HTML\n\n每個頁面都寫一遍 `<html><head><body>` 很蠢。Blade 的 Layout 機制幫你解決這個問題：\n\n```html\n<!-- resources/views/layouts/app.blade.php -->\n<!DOCTYPE html>\n<html lang=\"zh-TW\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>@yield('title', '揪好買') | 揪好買 JiuHaoMai</title>\n  </head>\n  <body>\n    <nav>\n      <a href=\"/\">首頁</a>\n      <a href=\"/about\">關於我們</a>\n    </nav>\n\n    <main>@yield('content')</main>\n\n    <footer>\n      <p>&copy; 2026 揪好買 JiuHaoMai</p>\n    </footer>\n  </body>\n</html>\n```\n\n```blade\n<!-- resources/views/home.blade.php -->\n@extends('layouts.app')\n@section('title', '首頁')\n@section('content')\n<h1>歡迎來到揪好買</h1>\n<p>台灣最有溫度的團購平台</p>\n@endsection\n```\n\n`@extends` 指定要繼承哪個 layout，`@section` 填入 layout 裡 `@yield` 留下的空位。概念跟 Django 的 template inheritance 一模一樣。\n\n## .env 設定：環境變數管理\n\n專案根目錄的 `.env` 檔案是 Laravel 的環境設定中心。所有敏感資訊（資料庫密碼、API Key、第三方服務密鑰）都放在這裡，而不是寫死在程式碼中。\n\n```bash\n# .env（節錄重要設定）\nAPP_NAME=\"揪好買\"\nAPP_ENV=local\nAPP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\nAPP_DEBUG=true\nAPP_URL=http://localhost:8000\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=jiu_hao_mai\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nMAIL_MAILER=log\n```\n\n### 在程式碼中讀取環境變數\n\n```php\n// 方法一：env() 函式（只在 config 檔中使用）\n// config/app.php\n'name' => env('APP_NAME', '揪好買'),\n\n// 方法二：config() 函式（在應用程式中使用）\n$appName = config('app.name'); // '揪好買'\n```\n\n> **重要規則：** 永遠不要在 Controller 或 Model 裡直接呼叫 `env()`。原因是 Laravel 在 production 會快取 config，`env()` 會回傳 `null`。正確做法是在 `config/*.php` 裡用 `env()` 讀取，然後在程式碼裡用 `config()` 存取。這個坑我保證你遲早會踩到，所以現在就記住。\n\n### .env vs .env.example\n\n| 檔案           | 進版控  | 用途                       |\n| -------------- | ------- | -------------------------- |\n| `.env`         | ❌ 不進 | 真正的環境變數（含密碼）   |\n| `.env.example` | ✅ 進   | 環境變數範本（不含真實值） |\n\n團隊開發時，新成員 clone 專案後會複製 `.env.example` 成 `.env`，然後填入自己的設定。\n\n```bash\ncp .env.example .env\nphp artisan key:generate  # 產生 APP_KEY\n```\n\n### 與其他框架的比較\n\n| 框架    | 環境變數                         | 設定系統          |\n| ------- | -------------------------------- | ----------------- |\n| Laravel | `.env` + `config/*.php`          | `config('key')`   |\n| Express | `.env` + `dotenv` 套件           | `process.env.KEY` |\n| Django  | `settings.py` + `django-environ` | `settings.KEY`    |\n\nLaravel 的 `.env` + `config` 兩層架構看起來多一步，但好處是 config 可以快取（`php artisan config:cache`），production 下效能更好。\n\n## 揪好買專案啟動：建立首頁\n\n理論講完了，讓我們動手把「揪好買」的首頁做出來。\n\n### Step 1：建立 Controller\n\n```bash\nphp artisan make:controller PageController\n```\n\n編輯 `app/Http/Controllers/PageController.php`：\n\n```php\n<?php\n\nnamespace App\\Http\\Controllers;\n\nclass PageController extends Controller\n{\n    public function home()\n    {\n        return view('home', [\n            'title' => '揪好買 JiuHaoMai',\n            'description' => '台灣最有溫度的團購平台——找好物、揪好友、一起買更划算！',\n            'features' => [\n                ['icon' => '🛒', 'title' => '輕鬆開團', 'desc' => '三步驟建立團購，分享連結就能揪人'],\n                ['icon' => '👥', 'title' => '揪團省更多', 'desc' => '人數越多折扣越大，好康大家一起享'],\n                ['icon' => '🔔', 'title' => '到貨通知', 'desc' => '成團、付款、出貨，每一步都即時通知你'],\n            ],\n        ]);\n    }\n}\n```\n\n### Step 2：建立 Layout\n\n建立 `resources/views/layouts/app.blade.php`：\n\n```html\n<!DOCTYPE html>\n<html lang=\"zh-TW\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>@yield('title', '揪好買') | 揪好買 JiuHaoMai</title>\n    <style>\n      * {\n        margin: 0;\n        padding: 0;\n        box-sizing: border-box;\n      }\n      body {\n        font-family: -apple-system, 'Noto Sans TC', sans-serif;\n        color: #1a1a2e;\n      }\n      nav {\n        background: #16213e;\n        padding: 1rem 2rem;\n      }\n      nav a {\n        color: #e2e2e2;\n        text-decoration: none;\n        margin-right: 1.5rem;\n        font-weight: 500;\n      }\n      nav a:hover {\n        color: #0f9b8e;\n      }\n      .brand {\n        font-size: 1.25rem;\n        font-weight: 700;\n        color: #0f9b8e;\n      }\n      main {\n        max-width: 800px;\n        margin: 0 auto;\n        padding: 2rem;\n      }\n      footer {\n        text-align: center;\n        padding: 2rem;\n        color: #666;\n        font-size: 0.875rem;\n      }\n    </style>\n  </head>\n  <body>\n    <nav>\n      <a href=\"/\" class=\"brand\">🛒 揪好買</a>\n      <a href=\"/about\">關於我們</a>\n    </nav>\n\n    <main>@yield('content')</main>\n\n    <footer>\n      <p>&copy; 2026 揪好買 JiuHaoMai — 用 Laravel 12 打造</p>\n    </footer>\n  </body>\n</html>\n```\n\n### Step 3：建立首頁 View\n\n建立 `resources/views/home.blade.php`：\n\n```blade\n@extends('layouts.app')\n@section('title', '首頁')\n@section('content')\n<div style=\"text-align: center; padding: 3rem 0;\">\n  <h1 style=\"font-size: 2.5rem; margin-bottom: 0.5rem;\">{{ $title }}</h1>\n  <p style=\"font-size: 1.25rem; color: #666;\">{{ $description }}</p>\n</div>\n\n<div style=\"display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-top: 2rem;\">\n  @foreach($features as $feature)\n  <div style=\"text-align: center; padding: 2rem; border: 1px solid #eee; border-radius: 12px;\">\n    <div style=\"font-size: 2.5rem;\">{{ $feature['icon'] }}</div>\n    <h3 style=\"margin: 0.75rem 0 0.5rem;\">{{ $feature['title'] }}</h3>\n    <p style=\"color: #666; font-size: 0.9rem;\">{{ $feature['desc'] }}</p>\n  </div>\n  @endforeach\n</div>\n\n<div style=\"text-align: center; margin-top: 3rem;\">\n  <p style=\"color: #999;\">🚧 更多功能開發中——跟著這本書一起打造！</p>\n</div>\n@endsection\n```\n\n### Step 4：設定路由\n\n編輯 `routes/web.php`：\n\n```php\n<?php\n\nuse App\\Http\\Controllers\\PageController;\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get('/', [PageController::class, 'home']);\n```\n\n### Step 5：啟動！\n\n```bash\nphp artisan serve\n```\n\n打開 `http://localhost:8000`，你應該會看到：\n\n- 頂部導航列，有「揪好買」品牌名和連結\n- 大標題「揪好買 JiuHaoMai」\n- 三個功能特色卡片：輕鬆開團、揪團省更多、到貨通知\n- 底部版權資訊\n\n恭喜，你的第一個 Laravel 應用正在運行了。\n\n### 剛才發生了什麼？\n\n讓我們追蹤一下這個 request 的旅程（下一章會更深入）：\n\n1. 瀏覽器發送 `GET /` 到 `localhost:8000`\n2. Laravel 的 `public/index.php` 接收 request\n3. 路由系統查 `routes/web.php`，找到 `GET /` 對應 `PageController@home`\n4. `PageController::home()` 執行，準備資料，呼叫 `view('home', [...])`\n5. Blade 引擎渲染 `resources/views/home.blade.php`（套用 layout）\n6. 最終 HTML 回傳給瀏覽器\n\n這六步就是 Laravel 處理每一個 HTTP request 的基本流程。[下一章](/blog/laravel-guide-lifecycle-container-middleware)我們會深入 Request Lifecycle，了解 Middleware 和 Service Container 如何在這個流程中扮演角色。\n\n## 小結：十分鐘後你已經會什麼了\n\n讓我們盤點一下這一章學到的東西：\n\n- **安裝 Laravel**——`composer create-project` 或 `laravel new`，一行搞定\n- **目錄結構**——知道 `routes/`、`app/Http/Controllers/`、`resources/views/`、`database/` 各自的職責\n- **Artisan CLI**——`serve`、`make:controller`、`route:list`、`tinker`\n- **路由系統**——在 `routes/web.php` 定義 URL，對應到 Closure 或 Controller\n- **Blade 模板**——`{{ }}` 輸出資料、`@if`/`@foreach` 控制流程、`@extends`/`@yield` 版面繼承\n- **環境設定**——`.env` 存放敏感資訊，`config()` 讀取設定，永遠不在 Controller 裡直接呼叫 `env()`\n- **實作**——「揪好買」首頁已經跑起來了\n\n[下一章](/blog/laravel-guide-lifecycle-container-middleware)，我們要揭開 Laravel 的「魔法」面紗——Request Lifecycle、Service Container 和 Middleware。聽起來很抽象，但理解這些概念之後，你才能真正駕馭這個框架，而不只是「它 work 了但我不知道為什麼」。",
      "summary": "從零安裝 Laravel 12——用 composer create-project 或 laravel new 一行建立專案，搞懂目錄結構、Artisan CLI、Route 路由與 Blade 模板，再用 .env 管理環境變數，十分鐘做出第一個首頁。",
      "image": "https://bobochen.dev/_astro/cover.ffGyDSgY.webp",
      "date_published": "2025-03-11T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "Laravel 12",
        "Artisan"
      ]
    },
    {
      "id": "https://bobochen.dev/blog/laravel-guide-php-for-modern-developers/",
      "url": "https://bobochen.dev/blog/laravel-guide-php-for-modern-developers/",
      "title": "PHP 不是你記憶中的樣子：寫給現代開發者的 PHP 快速入門",
      "content_text": "打破「PHP 是上個時代語言」的偏見。對照 JavaScript、Python、TypeScript，帶現代開發者掌握 PHP 8.4+ 型別系統、Enum、Match Expression、Named Arguments、Property Hooks 與 Composer 套件管理，為 Laravel 12 開發打好基礎。",
      "content_html": "「PHP？那不是上個時代的語言嗎？」——如果你腦中閃過這個念頭，我完全理解。十年前的 PHP 確實混亂：沒有型別提示、沒有套件管理、一堆 `mysql_*` 全域函式散落各處。那個年代寫 PHP 的體驗，大概跟在泥地裡蓋房子差不多。\n\n但 2026 年的 PHP 8.4+，已經是一門完全不同的語言了。它有嚴格的型別系統、Enum、Named Arguments、Property Hooks、Readonly Properties、Match Expression——這些特性放在任何現代語言裡都不會顯得突兀。2024 年 9 月，Laravel 拿到了 Accel 領投的 5,700 萬美金 A 輪融資，這是 PHP 框架生態系商業價值的最強信號。\n\n這一章我們不會從零教你寫 `echo \"Hello World\"`。我假設你已經會至少一門程式語言（JavaScript、Python、Go 都行），所以我們用**對照表**的方式快速帶你過一遍 PHP 8.4+ 的核心語法。目標是讀完這章之後，你看得懂 Laravel 原始碼裡的 PHP，也知道怎麼用 Composer 管理套件。準備好了，我們開始。\n\n## 2026 年了，PHP 還值得學嗎？\n\n先看數字說話：\n\n- **71.7%**——根據 W3Techs 2026 年 3 月的統計，全球已知伺服器端語言的網站中，有 71.7% 使用 PHP。遙遙領先第二名的 Ruby（約 6.8%）。\n- **42.2%**——根據 W3Techs 2026 年 5 月的統計，所有網站中有 42.2% 跑在 WordPress 上，而 WordPress 是純 PHP 寫的。\n- **454,000+**——PHP 的套件管理器 Packagist 上有超過 45 萬個套件，累計安裝次數超過 1,800 億次。\n- **$57M**——Laravel 在 2024 年 9 月完成了 Accel 領投的 A 輪融資，估值和商業前景都在成長。\n\n「但 Stack Overflow 調查裡 PHP 用的人不多啊？」那個調查反映的是「誰在填問卷」，不是「誰在跑 production」。PHP 龐大到不需要被討論——它就是在那裡，安靜地服務著全世界七成以上的網站。\n\n不過上面這幾個數字我得幫你補一下脈絡，不然會誤導你。那 71.7% 跟 42.2% 主要是「存量」——絕大多數是早就架好、跑了很多年的 WordPress 站和老站，不代表 2026 年的新專案都在選 PHP。而 Stack Overflow 那題我也不想全推給「誰在填問卷」：PHP 在「開發者想不想用」這個指標上確實偏弱，薪資中位數比不上 Go/Rust，AI、資料工程那一塊基本上是 Python 的天下，你拿 PHP 去硬擠不會太愉快。我的結論是：別把高市占當成「PHP 是新專案首選」的證據；但如果你要做的是 Web 後端、SaaS、接案、CMS，PHP 到今天仍然是非常務實、CP 值很高的選擇。適不適合，看你要解的是哪種問題，自己判斷比我幫你下定論好。\n\n更重要的是，**現代 PHP 和你記憶中的 PHP 完全不同**。PHP 的每一個大版本都帶來實質的語法改進：\n\n| 版本    | 發布年份 | 關鍵特性                                                                              |\n| ------- | -------- | ------------------------------------------------------------------------------------- |\n| PHP 8.0 | 2020     | Named Arguments、Match Expression、Union Types、Nullsafe Operator                     |\n| PHP 8.1 | 2021     | Enums、Fibers（協程）、Readonly Properties、Intersection Types                        |\n| PHP 8.2 | 2022     | Readonly Classes、Disjunctive Normal Form Types、`true`/`false`/`null` 獨立型別       |\n| PHP 8.3 | 2023     | Typed Class Constants、`#[\\Override]`、`json_validate()`                              |\n| PHP 8.4 | 2024     | **Property Hooks**、Asymmetric Visibility、`array_find()`/`array_any()`/`array_all()` |\n| PHP 8.5 | 2025     | Pipe Operator `\\|>`、URI Extension、`array_first()`/`array_last()`、Clone With（目前最新穩定版） |\n\n如果你的 PHP 印象停留在 5.x 時代，請直接跳到 8.4。那是一門不同的語言。\n\n> **平反歸平反，現代 PHP 還是有幾個老問題你早晚會踩到：**\n>\n> - 標準函式庫 API 不一致——`in_array(needle, haystack)` 跟 `str_contains(haystack, needle)` 參數順序剛好相反，函式名有的加底線（`str_replace`）有的駝峰（`array_map`），這個亂象到 8.5 都還在。IDE 補全會救你大半，但別期待它像 Python 標準庫那樣整齊。\n> - 沒有原生泛型——你寫不出 `Collection<User>` 讓引擎幫你檢查型別，只能靠 PHPStan / Psalm 加註解，或在 docblock 標 array 形狀來補。\n> - `==` 弱比較跟自動型別轉換的歷史包袱還在，習慣一律用 `===` 就能避掉大半的雷。\n> - 生態系仍然新舊混雜——Google 隨手搜到的教學一大半是 PHP 5 的 procedural 老寫法，跟著抄你會學到一身壞習慣。\n>\n> 我把短板攤出來不是要打臉前面的平反，剛好相反——願意承認缺點的平反，才是有說服力的平反。\n\n## 環境建置：安裝 PHP 8.4+ 與 Composer\n\n### macOS\n\n最簡單的方式是用 Homebrew：\n\n```bash\nbrew install php\nphp -v   # 確認版本 >= 8.4\n```\n\nHomebrew 預設安裝最新穩定版。如果你需要特定版本：\n\n```bash\nbrew install php@8.4\n```\n\n### Linux (Ubuntu/Debian)\n\n```bash\nsudo apt update\nsudo apt install software-properties-common\nsudo add-apt-repository ppa:ondrej/php\nsudo apt update\nsudo apt install php8.4 php8.4-cli php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip\nphp -v\n```\n\n### Windows\n\n推薦使用 [Laragon](https://laragon.org/)——一鍵安裝 PHP + Composer + MySQL + Nginx，比手動設定 WAMP 省心太多。\n\n### 安裝 Composer\n\nComposer 是 PHP 的套件管理器，等同於 npm（Node.js）或 pip（Python）。Laravel 本身就是一個 Composer 套件。\n\n```bash\n# macOS / Linux\ncurl -sS https://getcomposer.org/installer | php\nsudo mv composer.phar /usr/local/bin/composer\ncomposer --version\n```\n\n安裝完 PHP 和 Composer，你就準備好了。後面的章節會用 Composer 來安裝 Laravel。\n\n### 確認安裝：你的第一行 PHP\n\n建立一個 `hello.php` 檔案：\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\necho \"Hello from PHP \" . PHP_VERSION . PHP_EOL;\n```\n\n執行它：\n\n```bash\nphp hello.php\n# Hello from PHP 8.4.x\n```\n\n`declare(strict_types=1)` 是現代 PHP 的第一行——它強制啟用嚴格型別檢查。為節省篇幅，後續範例省略這兩行（`<?php` 與 `declare(strict_types=1)`），但你實際寫檔案時都要加上。\n\n## 型別系統：PHP 也有 Type Hints 了\n\nPHP 從 7.0 開始引入型別宣告，到了 8.4 已經相當完整。如果你從 TypeScript 或 Python type hints 來的，會覺得很熟悉：\n\n### 基本型別宣告\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nfunction add(int $a, int $b): int\n{\n    return $a + $b;\n}\n\necho add(3, 4);    // 7\necho add('3', 4);  // ❌ TypeError（strict_types 開啟時）\n```\n\n### 可為 null 的型別\n\n```php\nfunction findUser(int $id): ?User\n{\n    // 回傳 User 或 null\n    return User::find($id);\n}\n```\n\n### Union Types（PHP 8.0+）\n\n```php\nfunction formatId(int|string $id): string\n{\n    return \"ID: {$id}\";\n}\n\nformatId(42);      // ✅\nformatId('abc');   // ✅\nformatId(3.14);    // ❌ TypeError\n```\n\n### Intersection Types（PHP 8.1+）\n\n```php\nfunction process(Countable&Iterator $items): void\n{\n    // $items 必須同時實作 Countable 和 Iterator\n    foreach ($items as $item) {\n        // ...\n    }\n}\n```\n\n### 跨語言對照\n\n| 概念      | PHP 8.4                          | TypeScript                    | Python                        |\n| --------- | -------------------------------- | ----------------------------- | ----------------------------- |\n| 基本型別  | `int`, `string`, `float`, `bool` | `number`, `string`, `boolean` | `int`, `str`, `float`, `bool` |\n| 陣列      | `array`                          | `Array<T>` / `T[]`            | `list[T]`                     |\n| 可為 null | `?string`                        | `string \\| null`              | `Optional[str]`               |\n| Union     | `int\\|string`                    | `number \\| string`            | `int \\| str`                  |\n| 回傳型別  | `: int`                          | `: number`                    | `-> int`                      |\n| 無回傳值  | `: void`                         | `: void`                      | `-> None`                     |\n| 嚴格模式  | `declare(strict_types=1)`        | 預設嚴格                      | mypy 靜態檢查                 |\n\n> **重點：** PHP 的型別檢查是**執行期**（runtime）的，不像 TypeScript 只在編譯期。如果型別不符，PHP 會直接丟出 `TypeError`。\n\n## 現代語法快速對照：PHP vs JavaScript vs Python\n\n如果你已經會 JS 或 Python，下面這張表讓你五分鐘看懂 PHP 語法：\n\n### 變數與常數\n\n```php\n// PHP\n$name = 'Bobo';              // 變數用 $ 開頭\n$age = 35;\nconst TAX_RATE = 0.05;       // 常數\ndefine('APP_NAME', '揪好買'); // 另一種常數定義方式\n```\n\n```javascript\n// JavaScript\nconst name = 'Bobo';\nlet age = 35;\nconst TAX_RATE = 0.05;\n```\n\n```python\n# Python\nname = 'Bobo'\nage = 35\nTAX_RATE = 0.05\n```\n\n### 字串插值\n\n```php\n// PHP —— 雙引號才能插值，單引號不行\n$name = 'Bobo';\necho \"Hello, {$name}!\";    // Hello, Bobo!\necho 'Hello, {$name}!';    // Hello, {$name}!（原樣輸出）\n```\n\n```javascript\n// JavaScript\nconsole.log(`Hello, ${name}!`); // 反引號\n```\n\n### 陣列（Array）\n\nPHP 的 array 同時扮演了 JS 的 Array 和 Object 的角色：\n\n```php\n// 索引陣列（像 JS Array）\n$fruits = ['apple', 'banana', 'cherry'];\necho $fruits[0]; // apple\n\n// 關聯陣列（像 JS Object / Python dict）\n$user = [\n    'name' => 'Bobo',\n    'age' => 35,\n    'city' => 'Taipei',\n];\necho $user['name']; // Bobo\n```\n\n```javascript\n// JavaScript\nconst fruits = ['apple', 'banana', 'cherry'];\nconst user = { name: 'Bobo', age: 35, city: 'Taipei' };\n```\n\n```python\n# Python\nfruits = ['apple', 'banana', 'cherry']\nuser = {'name': 'Bobo', 'age': 35, 'city': 'Taipei'}\n```\n\n### 條件判斷\n\n```php\n// PHP —— 幾乎跟 JS/C 一樣\nif ($age >= 18) {\n    echo '成年';\n} elseif ($age >= 12) {\n    echo '青少年';\n} else {\n    echo '兒童';\n}\n```\n\n> **注意：** PHP 用 `elseif`（連在一起），不是 Python 的 `elif` 也不是 JS 的 `else if`（雖然 `else if` 分開寫也能用）。\n\n### 迴圈\n\n```php\n// foreach —— PHP 裡最常用的迴圈\n$items = ['蔥油餅', '雞排', '珍奶'];\n\nforeach ($items as $item) {\n    echo $item . PHP_EOL;\n}\n\n// 帶 key 的 foreach（像 Python 的 enumerate）\nforeach ($items as $index => $item) {\n    echo \"{$index}: {$item}\" . PHP_EOL;\n}\n\n// 關聯陣列的 foreach\n$prices = ['雞排' => 70, '珍奶' => 50];\nforeach ($prices as $name => $price) {\n    echo \"{$name} = \\${$price}\" . PHP_EOL;\n}\n```\n\n### 函式\n\n```php\n// PHP\nfunction greet(string $name, string $greeting = '你好'): string\n{\n    return \"{$greeting}, {$name}!\";\n}\n\necho greet('Bobo');           // 你好, Bobo!\necho greet('Bobo', '哈囉');   // 哈囉, Bobo!\n```\n\n## Named Arguments、Enums、Match Expression\n\n這三個是從 PHP 8.0/8.1 開始的殺手級特性。\n\n### Named Arguments（PHP 8.0+）\n\n不用再數參數順序了：\n\n```php\nfunction createProduct(\n    string $name,\n    int $price,\n    string $category = '其他',\n    bool $isActive = true,\n): array {\n    return compact('name', 'price', 'category', 'isActive');\n}\n\n// 傳統呼叫——你得記住每個位置\ncreateProduct('雞排', 70, '小吃', true);\n\n// Named Arguments——清楚明瞭\ncreateProduct(\n    name: '雞排',\n    price: 70,\n    category: '小吃',\n);\n// isActive 用預設值 true，不用特別傳\n\n// 還可以跳過中間的參數\ncreateProduct(name: '珍奶', price: 50, isActive: false);\n```\n\n> **JS/Python 開發者注意：** Python 一直有 keyword arguments，JS 則是用解構物件來模擬。PHP 的 Named Arguments 是語言原生支援，IDE 自動補全很方便。\n\n### Enums（PHP 8.1+）\n\n終於有了原生 Enum，不用再 `const STATUS_ACTIVE = 1` 這樣土法煉鋼：\n\n```php\n// 基本 Enum\nenum OrderStatus\n{\n    case Pending;\n    case Confirmed;\n    case Shipped;\n    case Delivered;\n    case Cancelled;\n}\n\nfunction updateOrder(int $orderId, OrderStatus $status): void\n{\n    // $status 只能是上面五個值之一，型別安全\n}\n\nupdateOrder(1, OrderStatus::Confirmed);  // ✅\nupdateOrder(1, 'confirmed');             // ❌ TypeError\n\n// Backed Enum（對應資料庫值）\nenum OrderStatus: string\n{\n    case Pending = 'pending';\n    case Confirmed = 'confirmed';\n    case Shipped = 'shipped';\n    case Delivered = 'delivered';\n    case Cancelled = 'cancelled';\n}\n\n// 從資料庫值反查\n$status = OrderStatus::from('confirmed');  // OrderStatus::Confirmed\n$status = OrderStatus::tryFrom('invalid'); // null（不會拋例外）\n\n// Enum 還能有方法\nenum OrderStatus: string\n{\n    case Pending = 'pending';\n    case Confirmed = 'confirmed';\n    case Shipped = 'shipped';\n    case Delivered = 'delivered';\n    case Cancelled = 'cancelled';\n\n    public function label(): string\n    {\n        return match ($this) {\n            self::Pending => '待確認',\n            self::Confirmed => '已成團',\n            self::Shipped => '已出貨',\n            self::Delivered => '已送達',\n            self::Cancelled => '已取消',\n        };\n    }\n}\n\necho OrderStatus::Confirmed->label(); // 已成團\n```\n\n> 在我們後面打造「揪好買」團購平台時，Enum 會大量用在訂單狀態、團購狀態等地方。\n\n### Match Expression（PHP 8.0+）\n\n`match` 是 `switch` 的現代替代品——更簡潔、型別安全、回傳值：\n\n```php\n// 傳統 switch（容易忘記 break）\nswitch ($status) {\n    case 'pending':\n        $label = '待確認';\n        break;\n    case 'confirmed':\n        $label = '已成團';\n        break;\n    default:\n        $label = '未知';\n        break;\n}\n\n// match（更優雅）\n$label = match ($status) {\n    'pending' => '待確認',\n    'confirmed' => '已成團',\n    'shipped', 'delivered' => '已出貨/到貨',  // 多值對應\n    default => '未知',\n};\n```\n\n`match` vs `switch` 的關鍵差異：\n\n- `match` 用嚴格比較（`===`），不會有 `0 == 'foo'` 這種坑\n- `match` 是表達式，可以直接賦值\n- 沒有 `break`，不會 fall-through\n- 沒匹配到且沒 `default` 會拋 `UnhandledMatchError`\n\n## 現代 PHP 的類別語法：Constructor Promotion 到 Property Hooks（8.0–8.4）\n\n### Constructor Promotion（PHP 8.0+）\n\n這大概是讓 PHP class 寫法最精簡的一個特性：\n\n```php\n// 傳統寫法（囉嗦）\nclass Product\n{\n    public string $name;\n    public int $price;\n    public string $category;\n\n    public function __construct(string $name, int $price, string $category)\n    {\n        $this->name = $name;\n        $this->price = $price;\n        $this->category = $category;\n    }\n}\n\n// Constructor Promotion（一行搞定）\nclass Product\n{\n    public function __construct(\n        public string $name,\n        public int $price,\n        public string $category,\n    ) {}\n}\n\n$product = new Product('雞排', 70, '小吃');\necho $product->name; // 雞排\n```\n\n> **TypeScript 開發者會覺得很像：** `constructor(public name: string)` 的概念是一樣的。\n\n### Readonly Properties（PHP 8.1+）\n\n設定一次就不能改，適合用在值物件和 DTO：\n\n```php\nclass GroupBuy\n{\n    public function __construct(\n        public readonly string $title,\n        public readonly int $minParticipants,\n        public readonly \\DateTimeImmutable $deadline,\n    ) {}\n}\n\n$group = new GroupBuy('辦公室零食團', 5, new \\DateTimeImmutable('2026-07-01'));\necho $group->title;         // 辦公室零食團\n$group->title = '改名';     // ❌ Error: Cannot modify readonly property\n```\n\n### Property Hooks（PHP 8.4+）\n\n這是 PHP 8.4 最重磅的特性——類似 C# 的 getter/setter 或 Kotlin 的 property delegation：\n\n```php\nclass Temperature\n{\n    public float $celsius {\n        set(float $value) {\n            if ($value < -273.15) {\n                throw new \\ValueError('低於絕對零度');\n            }\n            $this->celsius = $value;\n        }\n    }\n\n    public float $fahrenheit {\n        get => $this->celsius * 9 / 5 + 32;\n        set(float $value) => $this->celsius = ($value - 32) * 5 / 9;\n    }\n}\n\n$temp = new Temperature();\n$temp->celsius = 100;\necho $temp->fahrenheit;  // 212\n\n$temp->fahrenheit = 32;\necho $temp->celsius;     // 0\n```\n\n### Asymmetric Visibility（PHP 8.4+）\n\n讀取和寫入可以有不同的存取權限：\n\n```php\nclass User\n{\n    public function __construct(\n        public private(set) string $name,    // 外部可讀，只有內部可寫\n        public protected(set) string $email, // 外部可讀，子類別可寫\n    ) {}\n}\n\n$user = new User('Bobo', 'bobo@example.com');\necho $user->name;        // ✅ 可以讀\n$user->name = '改名';    // ❌ Error: Cannot modify private(set) property\n```\n\n## Arrow Functions 與 Closure\n\n### Closure（匿名函式）\n\nPHP 的 Closure 跟 JS 的匿名函式類似，但有一個關鍵差異——**不會自動捕獲外部變數**，必須用 `use` 明確宣告：\n\n```php\n$taxRate = 0.05;\n\n// PHP Closure——必須用 use 捕獲外部變數\n$calculateTax = function (int $price) use ($taxRate): float {\n    return $price * $taxRate;\n};\n\necho $calculateTax(1000); // 50.0\n```\n\n```javascript\n// JavaScript——自動捕獲（closure）\nconst taxRate = 0.05;\nconst calculateTax = (price) => price * taxRate;\n```\n\n> 這是很多 JS 開發者初學 PHP 最困惑的地方。PHP 的設計哲學是「顯式優於隱式」，`use` 讓你明確知道 closure 依賴了哪些外部變數。\n\n### Arrow Functions（PHP 7.4+）\n\n短語法，自動捕獲外部變數，只能有單一表達式：\n\n```php\n$taxRate = 0.05;\n\n// Arrow Function —— 自動捕獲，不用 use\n$calculateTax = fn(int $price): float => $price * $taxRate;\n\necho $calculateTax(1000); // 50.0\n\n// 常見用途：陣列操作\n$prices = [100, 200, 300];\n$withTax = array_map(fn($p) => $p * (1 + $taxRate), $prices);\n// [105.0, 210.0, 315.0]\n```\n\n### 陣列高階函式\n\nPHP 8.4 新增了更多實用的陣列函式，讓函式風格程式設計更方便：\n\n```php\n$products = [\n    ['name' => '雞排', 'price' => 70, 'active' => true],\n    ['name' => '珍奶', 'price' => 50, 'active' => true],\n    ['name' => '臭豆腐', 'price' => 60, 'active' => false],\n];\n\n// array_find（PHP 8.4）—— 找到第一個符合條件的\n$cheap = array_find($products, fn($p) => $p['price'] < 55);\n// ['name' => '珍奶', 'price' => 50, 'active' => true]\n\n// array_any / array_all（PHP 8.4）\n$hasInactive = array_any($products, fn($p) => !$p['active']);  // true\n$allActive = array_all($products, fn($p) => $p['active']);     // false\n\n// 經典的 array_map / array_filter\n$activeNames = array_map(\n    fn($p) => $p['name'],\n    array_filter($products, fn($p) => $p['active']),\n);\n// ['雞排', '珍奶']\n```\n\n> **JS 開發者注意：** PHP 的 `array_filter` 和 `array_map` 是**全域函式**而非陣列方法，參數順序跟 JS 不同（`array_map(callback, array)` vs `array.map(callback)`）。這是 PHP 最讓人不適應的地方之一，但習慣了就好。Laravel 的 Collection 類別會幫你解決這個問題——到[第四章 Eloquent ORM](/blog/laravel-guide-eloquent-orm-models/)你就會看到。\n\n## Composer：PHP 的 npm / pip\n\nComposer 是 PHP 生態系的基石。截至 2026 年，Packagist 上有超過 45 萬個套件，累計安裝超過 1,800 億次。Laravel 本身就是透過 Composer 安裝的。\n\n### 基本操作\n\n```bash\n# 建立新專案（從現有套件）\ncomposer create-project laravel/laravel my-project\n\n# 安裝相依套件（等同 npm install）\ncomposer install\n\n# 新增套件（等同 npm install package-name）\ncomposer require guzzlehttp/guzzle\n\n# 新增開發用套件（等同 npm install --save-dev）\ncomposer require --dev pestphp/pest\n\n# 更新所有套件\ncomposer update\n\n# 移除套件\ncomposer remove guzzlehttp/guzzle\n```\n\n### composer.json vs package.json\n\n| 概念     | Composer (PHP)            | npm (Node.js)       | pip (Python)       |\n| -------- | ------------------------- | ------------------- | ------------------ |\n| 設定檔   | `composer.json`           | `package.json`      | `pyproject.toml`   |\n| Lock 檔  | `composer.lock`           | `package-lock.json` | `requirements.txt` |\n| 套件目錄 | `vendor/`                 | `node_modules/`     | `site-packages/`   |\n| 執行指令 | `composer run`            | `npm run`           | —                  |\n| 全域安裝 | `composer global require` | `npm install -g`    | `pip install`      |\n\n一個典型的 `composer.json`：\n\n> **版本說明**：本系列以 Laravel 12 為基準。Laravel 13 已於 2026 年 3 月 17 日正式發布，若你跟著做時裝到 13，核心概念完全相同，只需將 `^12.0` 改為 `^13.0`，其餘無需調整。\n\n```json\n{\n  \"name\": \"bobo/jiu-hao-mai\",\n  \"description\": \"揪好買——台灣團購平台\",\n  \"type\": \"project\",\n  \"require\": {\n    \"php\": \"^8.4\",\n    \"laravel/framework\": \"^12.0\"\n  },\n  \"require-dev\": {\n    \"pestphp/pest\": \"^3.0\"\n  },\n  \"autoload\": {\n    \"psr-4\": {\n      \"App\\\\\": \"app/\"\n    }\n  }\n}\n```\n\n### PSR-4 自動載入\n\nPHP 不像 JS 有 `import`/`require`，也不像 Python 有 `import`。PHP 的模組系統是透過 **Composer 的 PSR-4 autoloading** 來實現的：\n\n`app/Models/Product.php`：\n\n```php\n// 不用手動 require 每一個檔案\n// Composer 會根據 namespace 自動找到對應的檔案\n\nnamespace App\\Models;\n\nclass Product\n{\n    // ...\n}\n```\n\n`app/Http/Controllers/ProductController.php`：\n\n```php\nnamespace App\\Http\\Controllers;\n\nuse App\\Models\\Product;  // 自動載入 app/Models/Product.php\n\nclass ProductController\n{\n    public function index()\n    {\n        $products = Product::all();\n    }\n}\n```\n\n規則很簡單：**Namespace 對應目錄路徑**。`App\\Models\\Product` 對應 `app/Models/Product.php`。Composer 的 autoloader 幫你處理所有的檔案載入，你只需要寫 `use`。\n\n> 這跟 Python 的模組系統很像——`import app.models.product` 對應 `app/models/product.py`。差別是 PHP 用 `\\` 而不是 `.`，且 namespace 宣告是手動的。\n\n## PSR 標準：PHP 社群的共識\n\nPSR（PHP Standards Recommendations）是 PHP-FIG（Framework Interoperability Group）制定的標準，確保不同框架和套件之間能互通。你不需要全部背下來，但知道這幾個最常見的就夠：\n\n| 標準   | 名稱                   | 一句話說明                                            | 對應其他生態系            |\n| ------ | ---------------------- | ----------------------------------------------------- | ------------------------- |\n| PSR-1  | Basic Coding Standard  | 基本的命名規範（類別用 PascalCase、方法用 camelCase） | ESLint 基本規則           |\n| PSR-4  | Autoloading Standard   | Namespace 對應目錄結構的自動載入規則                  | Node.js module resolution |\n| PSR-12 | Extended Coding Style  | 程式碼風格規範（縮排、大括號位置等）                  | Prettier / Black          |\n| PSR-7  | HTTP Message Interface | HTTP Request/Response 的標準介面                      | Express 的 req/res        |\n| PSR-11 | Container Interface    | DI Container 的標準介面                               | —                         |\n\n實務上，Laravel 完全遵循 PSR-4 和 PSR-12。你安裝一個叫 [Laravel Pint](https://laravel.com/docs/pint) 的工具，就能一鍵格式化程式碼：\n\n```bash\n# 安裝（Laravel 12 內建）\n# 格式化所有 PHP 檔案\n./vendor/bin/pint\n```\n\n就像 JavaScript 有 Prettier、Python 有 Black——PHP 有 Pint。風格爭論到此結束。\n\n## 小結：準備好進入 Laravel 了\n\n這一章我們用最快的速度帶你走過了現代 PHP 的核心：\n\n- **型別系統**——`int`、`string`、Union Types、Intersection Types，執行期檢查\n- **現代語法**——Named Arguments、Enums、Match Expression、Constructor Promotion\n- **PHP 8.4 新特性**——Property Hooks、Asymmetric Visibility、`array_find()`/`array_any()`/`array_all()`\n- **Closure 與 Arrow Functions**——注意 `use` 關鍵字的差異\n- **Composer**——套件管理、PSR-4 自動載入\n- **PSR 標準**——社群共識、Pint 格式化工具\n\n如果你是 JavaScript 開發者，最需要適應的是：\n\n1. 變數要加 `$`\n2. Closure 要用 `use` 捕獲外部變數\n3. 陣列操作是全域函式而非方法（但 Laravel Collection 會解決這個問題）\n4. Namespace 用 `\\` 不是 `.` 或 `/`\n\n如果你是 Python 開發者，最需要適應的是：\n\n1. 每行結尾要加 `;`\n2. 大括號 `{}` 而不是縮排\n3. `$` 開頭的變數\n4. `->` 而不是 `.` 存取屬性和方法\n\n這些都是語法糖衣的差異，核心概念（型別、函式、類別、模組化）是通用的。你已經會程式設計了，只是在學一門新的方言。\n\n[下一章，我們正式進入 Laravel 12](/blog/laravel-guide-setup-first-route/)——從 `composer create-project` 開始，十分鐘內讓「揪好買」的專案骨架跑起來。",
      "summary": "打破「PHP 是上個時代語言」的偏見。對照 JavaScript、Python、TypeScript，帶現代開發者掌握 PHP 8.4+ 型別系統、Enum、Match Expression、Named Arguments、Property Hooks 與 Composer 套件管理，為 Laravel 12 開發打好基礎。",
      "image": "https://bobochen.dev/_astro/cover.BUwu01U0.webp",
      "date_published": "2025-03-04T00:00:00.000Z",
      "tags": [
        "PHP",
        "Laravel",
        "PHP 8.4",
        "Composer"
      ]
    }
  ]
}