恆春民宿業者面對的直訂困境,其實用一張餐巾紙就算得清楚:如果 Google Ads 年度廣告費低於同等訂單數的 OTA 佣金,直訂就划算。OTA 的主導地位來自三層疊加優勢——遠超個別物業上限的廣告預算、多年消費者累積的品牌信任、以及全職數位行銷團隊日常操盤。佣金遠比外界想像的高。但高意圖搜尋流量確實存在:一個已經決定要去恆春、只在考慮直訂還是透過平台的旅客,正在等人截住他。Google Ads 是那個截點。
問題在於 Google Ads 是每週的工作。月報是事後記錄:業主打開月底報表的時候,三週的預算已經走完,而我在第二週本該做的調整,到第四週早就不是正確的調整。bnb-ads-manager 要解決的問題,是讓每週帳戶級關鍵字管理在這個量級變得可行——這個規模的客戶,沒有任何廣告代理願意接案。
四個長駐服務,每週才動一次
第一版架在 n8n Railway 上。四個長駐服務:n8n Main、n8n Worker、Redis、PostgreSQL。觸發機制是 IMAP 輪詢——一封週報信進到監控信箱就啟動 pipeline。Google Ads API 拉關鍵字成效;Google Sheets 的 Keyword_Inventory 分頁作為狀態機;AI agent(OpenAI via LangChain)分析彙整後的資料;結果寫成任務列落進 Sheets;一封夾著審核按鈕的 HTML 信發出去。業主在 Gmail 點按鈕,Railway webhook 收到請求,Ads mutation 執行。
系統能跑。但四個服務 24 小時長駐,為了一個每月實際做事四次的工具。
開始寫 Python 之前,我先做了一輪 Railway 服務稽核。PostgreSQL 的用途讓整個基礎設施決策改了方向:這個資料庫完全不載任何業務邏輯——它只是 LangChain AI 聊天記憶的後端。關鍵字庫存、任務歷史、操作 payload,從一開始就全都住在 Sheets。移除 Railway 等於移除閒置成本,功能零損失——不是 trade-off,是糾正。
Python 改寫版把兩段 pipeline 都搬進 GitHub Actions。分析器跑在 cron(UTC 0 1 * * 1,台北時間週一早上九點),把 LLM 輸出和操作 payload 寫進 Sheets 的任務列。執行器是手動 workflow_dispatch——審核信裡附上 task ID,業主貼到 GitHub Actions UI 輸入欄觸發 Ads mutation。比原本的 email 按鈕多一個動作。換來零長駐容器、基礎設施成本接近零。
Google Ads、Sheets、Gmail 現在共用同一個 OAuth client,一組 refresh token,一個憑證管理面。
14 天保護規則需要兩個執行點
早期出現過一次震盪:系統建議移除某個關鍵字,我沒動;下週,系統建議加回同一個字。三天的曝光資料什麼結論都下不了,根據它做決策等於在燒錢,同時可能傷害 Quality Score。
直覺反應是修 Prompt,叫模型更保守。問題在於 Prompt 是建議性的:只要特定關鍵字的數字看起來夠有說服力,模型就會跟著資料走。限制條件必須放在模型推理之外:
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)
decisions.py 裡的 filter_protected_removals() 把 LLM 的 remove_suggestions 拆成兩個清單:可執行和被攔截。兩個清單都出現在審核信裡。業主看得到 Claude 想移除什麼、以及為什麼沒有執行。被攔截的建議不是靜默消失,而是透明呈現的決策。系統 Prompt 設了 14 天規則;執行層再強制執行一次,不管 LLM 輸出寫了什麼。兩道防線各擋一部分。
同一個函式還有第二道防護:如果 LLM 輸出的 criterionId 在本地庫存快照裡找不到對應,直接捨棄。模型在資料稀疏時偶爾會捏造 ID。在送出 API mutation 之前先比對庫存快照,這類錯誤就在進入 Google Ads 之前被過濾掉。
脈絡是通用建議和有用建議的分水嶺
餵原始 CSV 給模型,得到的建議放在任何民宿都能套——也就是對哪間都沒有特別的用。每個帳戶的系統 Prompt 裡都注入一個 topic_facts 區塊,把這間物業能說和不能說的事實編碼進去:
🛑【民宿事實與禁忌】
1. 玩水:❌禁大型泳池/深水。✅強調:陽台戲水池、兒童戲水池、泡腳池 (安全/水淺)。
2. 景觀:❌禁海景/山景/View。✅強調:市區便利、走路到全聯/夜市。
3. 賣點:烤肉 BBQ、KTV、麻將、親子友善。
系統 Prompt 用中文寫。不是因為分析不能用英文跑,是因為推理必須跟業主對自己物業的認知保持一致。「兒童戲水池」和「走路到夜市」用中文表達的質感,翻成英文再翻回來會變薄。讓模型用英文推理、中文輸出,等於在物業事實和建議之間多加了一層翻譯。
有了這個脈絡區塊,模型才能同時處理搜尋趨勢和季節性。「這個關鍵字現在量低,但六週後旺季回來,觀察而不移除」——這個結論需要同時知道搜尋資料的走向和這間民宿的實際淡旺季節奏。沒有脈絡注入,模型只看得到 CSV。
輸出格式透露的業主心理學
初始 JSON schema 有三個欄位:monitor、add_suggestions、remove_suggestions。前幾週的生產觀察發現:業主看到純行動清單的第一反應是緊繃——什麼出問題了、燒了多少錢。這個框架在業主讀完第一行之前就觸發了損失趨避。
加上 mvp——目前最在做事的那個關鍵字——改變了整封信的基調。一個「目前跑得好的是……」的錨點放在行動建議之前,業主從防禦模式進入評估模式。schema 的改動是一個欄位,對業主參與度的影響不是邊際效果。
MVP 資格條件也進了系統 Prompt:關鍵字必須累積至少 14 天的資料,且 CTR 超過 1% 或有轉換。沒有這道資料下限,模型會把三天、兩次點擊的新字提名為本週 MVP。
換供應商只改環境變數
LLM 客戶端包在一層薄薄的 Protocol 介面後面:
class LLMClient(Protocol):
def analyze(self, system_prompt: str, user_payload: dict[str, Any]) -> dict[str, Any]: ...
def build_llm_client(cfg: Config) -> LLMClient:
if cfg.llm_provider == "anthropic":
return AnthropicLLMClient(cfg.anthropic_api_key, cfg.llm_model)
if cfg.llm_provider == "openai":
return OpenAILLMClient(cfg.openai_api_key, cfg.llm_model)
raise RuntimeError(f"unknown LLM_PROVIDER: {cfg.llm_provider}")
預設是 Claude,透過環境變數設定。保留 OpenAI 實作,是因為原本的 n8n 系統跑 OpenAI。同一個介面讓兩套模型在相同 Prompt 下直接比較——改一個環境變數,程式碼不動,輸出並排對照。
靜態系統 Prompt 大約 1,500 tokens。兩個帳戶在同一個 GitHub Actions job 裡前後幾分鐘完成分析,第二次呼叫直接命中 prompt cache,不需要重新上傳同一個 token 區塊。每月四次執行不是成本主因,但值得在設計階段就建進去。
低估的東西
在資料稀疏的情況下——剛起步的活動、上線不到一週的關鍵字、沒有轉換紀錄的廣告組——讓模型穩定輸出結構化 JSON 需要的 Prompt 迭代和輸出驗證,遠超過預估。模型在資料充足時的行為容易描述;在資料幾乎空白時「模型會對 MVP 說什麼」,是個問法就已經不明顯的 Prompt 設計問題。
低估得更徹底的是審核信本身。讓兩個恆春民宿業主每週一打開那封信、讀完第一段、對一個具體建議採取行動——這是介面設計問題,不是 AI 問題。結構化 JSON 和 Sheets 稽核紀錄是基礎設施。HTML 信決定什麼排第一、呈現多少推理過程、call-to-action 長什麼樣——那才是產品。這就是這個量級的客戶花錢買週度廣告管理服務實際上得到的東西:不只是分析,是讓分析變得夠清楚、足以採取行動的溝通。