恆春的民宿業者在廣告戰場上對打的是 OTA 平台。OTA 的佣金遠比外界想像的高,那些佣金背後養著全職的數位行銷團隊、多年累積的品牌信任,以及任何十幾間房的民宿都不可能匹敵的廣告預算。可用的策略只有一個:精準——把每一塊預算放在真的會轉換為直接訂房的關鍵字上,不容許任何浪費。
精準有一個靜悄悄的失效模式。廣告帳戶跑久了,關鍵字清單開始腐化:沒有轉換的字詞和優質字詞並排,預算往長尾擴散,有效出價被稀釋,整體 CPA 緩慢上升。上升的速度慢到幾個月後才察覺,那幾個月的數據早已偏掉。治理層是防止這件事的機制——沒有它,精準策略會安靜地失效,沒有警報,只有越來越難看的成效等著你事後去診斷。
這是我替恆春幾家民宿建的 Google Ads 關鍵字治理系統。每週從廣告帳戶抓取啟用中的關鍵字、同步庫存、過濾保護期內的字詞、把其餘字詞連同成效資料交給 Claude 評估、人工確認後寫回廣告帳戶。讓這套系統運作的不是 LLM,而是狀態存在哪裡。
架構決策的核心,是誰是行動者
Postgres schema 畫到一半,我停下來問了自己:系統在凌晨產出建議,誰查資料庫狀態?我。業者不認同某個移除決策,誰把資料庫裡的記錄翻譯成可讀的說明給她看?還是我。那個後台是讓我居中轉譯的工具,不是讓她直接行動的工具。
她每天早上本來就會開啟的那張試算表——對照訂單跟 OTA 後台——就讓它當資料庫。
核心是 Keyword_Inventory 工作表,每列一個關鍵字,七個欄位追蹤完整生命週期:
| 欄位 | 說明 |
|---|---|
CriterionID | Google Ads criterion ID |
Keyword | 搜尋字詞 |
AdGroupID | 所屬廣告群組 |
Status | Active 或 Removed |
Date_Added | 加入日期(YYYY-MM-DD) |
Date_Removed | 移除日期;仍在投放則留空 |
Reason | 最近一次異動的說明(人話) |
Reason 欄是這個設計的核心。每次移除,Python 寫進去的是人話:「30 天、3 次點擊、0 次轉換」、「與表現更好的同義詞重疊」。她拿手機就能讀,不需要問任何人。更重要的是:她如果不認同,直接把 Status 改回 Active、清掉 Date_Removed,下次執行就把那個關鍵字當活的來處理。她讀的是原始資料,不是我整理過的摘要。改變的不只是誰能看到資訊,而是誰真正掌控決策。
兩張表,兩個職責
每個帳戶使用兩張工作表。Keyword_Inventory 是關鍵字生命週期的狀態機。第二張表是控制帳本。控制帳本連結分析與執行兩個階段,追蹤待處理的任務與人工確認狀態。
這個結構對應到兩個階段的 pipeline。分析器每週一早上透過 GitHub Actions cron 自動執行,同時也支援手動觸發。每次執行:從 Google Ads 拉取啟用中的關鍵字、同步庫存、過濾保護期內的字詞、把其餘字詞連同成效資料交給 Claude、建立操作 payload。Claude 回傳結構化的評估結果:{ mvp, monitor, add_suggestions, remove_suggestions }——最佳表現者、觀察名單、建議新增的字詞、建議移除的字詞。
分析器接著產生一個任務 ID(格式為 TASK-XXXX),把任務列附加到控制帳本(狀態 PENDING)、寫入序列化的操作 payload,然後寄一封 HTML 信給我。信裡有執行器 workflow 的連結,以及這個任務 ID。Pipeline 在這裡停下來。
要實際執行,我必須從信件裡取出任務 ID,到 GitHub Actions 手動觸發執行器 workflow,指定 task_id 和 action(add 或 remove)。執行器讀取任務列,對 Google Ads 執行對應的操作,用結果更新庫存(新增的關鍵字寫入 Date_Added,移除的關鍵字寫入 Date_Removed 和 Reason),最後把任務標記為 DONE。
這不是「我看過再讓它執行」的慣例。這是架構上的硬約束——沒有人工輸入,執行根本不會發生。沒有排程執行,沒有自動套用,不可能意外觸發一批操作。你必須從信件裡主動拿到那個任務 ID 才能觸發執行器。兩段式結構讓人工確認變得無法跳過。
14 天保護期,強制兩次
關鍵字需要資料才能被有意義地評估。三天的曝光是雜訊,不是訊號。保護期規定:關鍵字至少投放 14 天後,才能進入移除候選名單。
這條規則寫在 LLM 的 system prompt 裡。同時也在程式碼裡作為硬過濾器,套用在 Claude 的輸出上,在任何操作傳到執行器之前:
def is_protected(date_added: str, today: date) -> bool:
parsed = _parse_date(date_added)
if parsed is None:
return True # 無法解析就視為受保護
return (today - parsed) < timedelta(days=PROTECTION_DAYS)
兩道防線,因為 LLM 在資料壓力下偶爾會忽略規則。Prompt 告訴 Claude 規則是什麼;程式碼讓違規變得不可能,不管 Claude 回傳什麼。Date_Added 格式有問題而無法解析,預設視為受保護——保守的失效模式。
LLM system prompt 裡還有每家民宿的專屬知識:這家民宿真正在賣什麼(烤肉 BBQ、KTV、戲水池、走路到夜市的距離)、不能誠實主張的是什麼(海景、山景、大泳池)、哪些關鍵字類別不管點擊數據多好都該排除。讓一位 PPC 專家逐帳戶手動套用這些判斷,是這些民宿無法負擔的持續性成本。把判斷編碼進 system prompt 一次、在每次評估週期強制執行,才讓這件事在經濟上成立。
從四個容器到零基礎設施
被這套系統取代的,是跑在 Railway 上的 n8n:四個長期在線的容器(main、worker、Redis、Postgres)。其中 Postgres 只用來存 n8n 的 AI 對話記憶,不承載任何業務邏輯。月費約 10 美元,每天 24 小時運行,每週實際被使用幾分鐘。
新系統完全在 GitHub Actions 免費額度上運行。沒有長期在線的容器,沒有持久資料庫,所有狀態都在試算表裡。Anthropic API 的 LLM 費用:兩個帳戶每週執行,費用是分錢等級。靜態 system prompt 約 1,500 tokens,啟用 prompt caching。兩個帳戶在同一個每週 job 內執行時,第二次呼叫命中快取。
把確認機制從信件裡的 webhook 按鈕改成手動觸發的 workflow_dispatch,移除了最後一個需要長期在線的基礎設施。代價是 UX 稍微退步:一次點擊變成一次複製貼上加上 GitHub 介面操作。對一個每週只被一個人用一次的工具來說,這個取捨可以接受——而且日後要替換成 Cloudflare Worker 之類的代理,不需要改動執行器的設計。
並行問題的最乾淨解法
用試算表當狀態機,讓人不安的是並行寫入。兩個執行同時讀到某列是 Active,同時決定移除,其中一個默默覆蓋另一個的決策——而另一個可能已經對廣告帳戶執行了操作。
考慮過 file lock、租約欄位、輕量的 Redis flag。每一種都讓系統往設計邊界外走。如果維持這個關鍵字管理工具本身需要維運負擔,使用試算表的成本計算就失去意義。
實際的解法:讓並行條件不存在。cron 和 workflow_dispatch 理論上可以重疊,分析器 workflow 的 concurrency: cancel-in-progress 處理這個情況。執行器從不排程,只在手動觸發時執行,每次一個 action。以每週一次、幾個廣告活動的規模,並行執行停留在理論層面,不是實際問題。
這套系統跨兩個帳戶運行,沒有發生過並行意外。讓我知道它真的在運作的那一刻,不是第一次自動評估跑正確的時候,而是業者第一次自己改掉一個移除決策、沒有通知我的時候。