昨天把助理部署成 Cloud Run 服務之後,被加進名單的同事透過 gcloud run services proxy 就能在瀏覽器裡問它上週哪支廣告花最多錢,可是每天早上要看的其實就是那幾件事,花了多少、功勞算誰的、有沒有哪裡出狀況,這幾題還是得有人坐下來一題一題問,哪天忙起來忘了問,那一天就沒有日報。
把這件事交給排程聽起來只是設一個鬧鐘,實際動手才發現鬧鐘只解決幾點開始,開始之後先問哪一題、問到一半助理沒回應要不要再問一次、今天的額度被同事用完了怎麼辦、早上不小心被啟動兩次會不會多出一份,這些都不是鬧鐘會管的事,而且每一個決定都和錢有關,因為助理每答一題就是呼叫一次模型。
所以今天把這件事拆成兩個各司其職的服務,Cloud Scheduler 只負責準時,Cloud Workflows 負責照順序做事以及每一步失敗了該怎麼辦,被呼叫的還是昨天那個助理,一行程式都沒有改。
今日核心目標:
整條路上有三個身分,前一段只拿得到叫下一段做事的權限。
| 段落 | 服務 | 負責什麼 | 用哪個身分 | 這個身分做得到的事 |
|---|---|---|---|---|
| 幾點跑 | Cloud Scheduler | 每天 08:30 請 Workflows 建立一次執行 | martech-scheduler |
只能啟動專案裡的流程,查不到資料也連不到助理 |
| 照什麼順序、失敗怎麼辦 | Cloud Workflows | 問三題、決定重試或放棄、寫日報表 | martech-pipeline-runner |
連得到助理、寫得進 martech_dw |
| 查資料、寫回答 | Day 26 的助理服務 | 查 BigQuery、呼叫 Gemini、過護欄 | martech-assistant |
和昨天完全相同 |
這個設計有三個刻意的安排。
第一,日報由助理來寫,流程自己不呼叫 Gemini,流程要做的只是用程式的身分去問昨天那個服務,這樣護欄、每日花費上限、問答紀錄全部沿用,排程問的每一題和同事在瀏覽器裡問的每一題走的是同一條路,不會因為是排程就多出一條沒有人檢查的捷徑。
第二,排程和流程用不同的身分,Cloud Scheduler 用的服務帳號只有啟動流程這一項權限,它查不到任何資料也呼叫不了助理,這個身分外流時能做的事只有啟動專案裡的流程,真正做事的權限在流程綁定的服務帳號上,要照實說的是流程用的 martech-pipeline-runner 是整個系列共用的身分,權限比這個流程需要的多,正式環境建議替日報流程另開一個權限剛好的服務帳號。
第三,流程的結果不能只看 HTTP 狀態碼,只要問題有進到問答那一步,助理都回 HTTP 200,被護欄擋下、額度用完、沒有在上限內答完,全部只寫在回應的 status 欄位裡,流程如果只看 200 就當成功,日報表裡會出現一份三題都是「今天的額度用完了」的日報,而且執行紀錄上一片綠色。
Cloud Scheduler 做的事只有一件,時間到了就對一個網址送出請求,今天這個網址是 Workflows 的「建立一次執行」,請求送出去、對方回覆已經建立,排程的工作就結束了,它不會等流程跑完,也不知道日報最後有沒有寫出來。
Cloud Workflows 則是把一連串步驟寫成一份 YAML,每一步可以呼叫一個網址、指定變數、做判斷、跑迴圈,重點是它替每一步提供了重試和錯誤處理的寫法,而且每一次執行都留下紀錄,跑到哪一步、花了多久、在哪裡停下來都查得到,要連每一步的請求與回應都記下來得另外開啟呼叫紀錄,今天沒有開,因為開了之後助理的回答原文會跟著進到 Cloud Logging。
把這兩件事分開看,很多原本模糊的問題就有了歸屬,幾點跑、用哪個時區是排程的事,先做什麼後做什麼、失敗了要不要再試是流程的事,同一份流程可以被排程啟動,也可以被人手動啟動,今天的測試就是這樣做的。
昨天的助理不允許未驗證的請求,每個請求都要帶 Google 簽發的身分權杖,同事用的是自己的 Google 帳號,流程沒有帳號可以登入,它靠的是部署時綁定的服務帳號。
- call_assistant:
call: http.post
args:
url: ${assistant_url + path}
headers:
X-Martech-Chat: "1"
Content-Type: application/json
body:
question: ${q.text}
auth:
type: OIDC
audience: ${assistant_url}
timeout: 150
result: resp
auth 那三行就是全部,Workflows 會用流程的服務帳號產生一張身分權杖放進請求,audience 是這張權杖要給誰看的,不寫的話預設會等於請求的網址也就是帶著路徑的那一串,這裡明確寫成助理服務不含路徑的網址,Cloud Run 收到之後檢查權杖是不是 Google 簽的、對象是不是自己、這個身分有沒有這個服務的 roles/run.invoker,昨天的部署腳本已經先替 martech-pipeline-runner 加上這個角色,照腳本做的話今天不用再動權限,昨天是一步一步手動做的話,今天的部署腳本會檢查並且印出要補的那一行指令。
昨天的避坑提過 gcloud 產生的權杖是開發用的、會過期,正式的服務對服務呼叫要用指定對象的服務帳號權杖,今天就是那個做法,流程裡沒有任何一把金鑰,也沒有人需要保管權杖。
助理的問答紀錄裡這幾題的提問者是 martech-pipeline-runner@...,和同事的帳號分得出來,日後要查哪些問題是排程問的,用這一欄篩就好。
有一個安排和安全有關,助理的網址不是流程的啟動參數,它在部署時用環境變數寫進流程,原因是流程會替這個網址簽一張身分權杖,網址如果可以由啟動的人指定,一個只有啟動權限的身分就能叫流程把權杖送到任何地方。
昨天的助理被要求回答控制在四句話以內,這是為了讓同事在對話裡看得舒服,但也代表不能丟一句「幫我寫今天的日報」就拿到一整份報告,所以日報拆成三個固定的問題。
| 題 | 標題 | 問題 | 助理用的工具 |
|---|---|---|---|
| 1 | 上週花費 | 上週的廣告費總共是多少?花最多錢的是哪一支廣告? | get_ad_spend |
| 2 | 通路功勞 | 上週的訂單功勞哪個通路分到最多?前三名各是多少? | get_channel_attribution |
| 3 | 成效異常 | 廣告成效有沒有出現過異常?最需要注意的是哪一筆,原因是什麼? | get_anomaly_diagnosis |
三題各開一場新的對話,沒有放在同一場裡接著問,原本的規劃是同一場對話問三題,動手前算了一下決定分開,同一場對話每多問一題,前面查到的資料都要再送給模型一次,助理每次呼叫的輸入上限是 6,000 個 Token,第三題很可能頂到上限,分開問每一題都從頭開始,比較便宜,而且一題失敗不會影響另外兩題。
每問完一題,流程就把結果寫一列到 BigQuery 的 daily_reports,不等三題都問完才一起寫,這樣流程中途停掉時前面問到的還在。
CREATE TABLE IF NOT EXISTS martech_dw.daily_reports (
report_date DATE,
execution_id STRING,
trigger_source STRING,
section_no INT64,
section_title STRING,
question STRING,
answer STRING,
status STRING,
detail STRING,
session_id STRING,
created_at TIMESTAMP
)
PARTITION BY report_date;
一列是某一次執行的某一題,status 只有三種值,ok 是答完了,failed 是問了但沒有答案,skipped 是根本沒問,detail 留下原因,這張表只加不刪。
這是今天花最多時間的地方,也是流程和鬧鐘真正的差別。
先看重試,Workflows 的重試寫法很簡單,替一個步驟指定什麼樣的錯誤要重試、最多幾次、間隔多久,難的是決定哪些錯誤可以重試,因為助理每答一題就花一次錢,重試一題等於可能再花一次。
| 流程收到的錯誤 | 助理那一邊的狀況 | 重試嗎 |
|---|---|---|
| HTTP 429 | Cloud Run 的執行個體都在忙,請求沒有進到程式 | 重試 |
HTTP 503,回應是助理自己寫的 unavailable |
程式回報現在不能用,還沒呼叫模型 | 重試 |
| 連線沒有建立 | 請求根本沒有送到 | 重試 |
| 其他的 HTTP 503 | 可能是請求處理到一半執行個體出狀況 | 不重試 |
| 逾時、連線中途斷掉 | 助理可能還在跑,也可能已經呼叫過模型 | 不重試 |
原則只有一句,確定助理還沒開始處理的錯才重試,最多 2 次,間隔 10 秒和 20 秒,503 那兩列是寫完之後請另一方檢查才分開的,我原本的寫法是 503 一律重試,被指出 Cloud Run 平台自己也會回 503,那時候模型可能已經被呼叫,同一題會被問到三次。
這樣取捨的代價是偶發的逾時會讓那一題空著,我選擇讓日報缺一題,也不要讓排程在沒有人看著的時候重複花錢,缺題的日報事後可以手動重跑,重跑會三題全部重問。
再看助理有回應但沒有答案的情況,流程把 status 分成三類處理。
| 助理回的 status | 流程怎麼做 |
|---|---|
passed、claims_flagged、exits_sealed,或後兩者同時出現,而且回答不是空的 |
記成 ok |
daily_cap(今天的額度用完) |
這一題記 failed,後面的題目不再問,記成 skipped |
| 其他(被護欄擋下、沒有答完、出錯) | 這一題記 failed,換下一題 |
額度用完之後不再問,是因為後面的題目問了也只會拿到同一句話,被護欄擋下則只影響那一題,另外兩題照問。
最後是整次執行算成功還是失敗,三題沒有全部答完,流程會在把三列都寫進日報表之後主動丟出錯誤,讓這次執行以失敗結束,原因是 Workflows 的執行清單是最容易被看到的地方,一份缺題的日報如果顯示成功,沒有人會點進去看。
我實際讓第 2 題失敗了一次,做法是啟動流程時帶一個測試用的參數,讓指定的題號改打一個不存在的路徑,結果第 1、3 題照常答完並寫進日報表,第 2 題記成 failed、原因是 http_404,整次執行在 36.7 秒後以失敗結束。
排程偶爾會重複觸發,官方文件自己也寫了這件事很少見但可能發生,再加上人會手動重跑,所以流程一開始先查今天有沒有任何一次執行已經三題都答完,有就直接結束,不發問。
我在排程觸發的那一次跑完之後手動再啟動一次,流程 1.4 秒就結束,回傳 skipped_existing,助理一次都沒有被呼叫,這一次是免費的。
真的要重問的時候啟動流程時帶 force,新的三列會加進日報表,舊的不會被蓋掉,讀日報用的 view 每天只取答完最多題的那一次執行,一樣多就取最新的,所以前面那次故意缺一題的執行雖然比較晚,view 顯示的還是排程那一次完整的日報。
為了確認排程真的會自己啟動流程,我把排程的時間暫時改成幾分鐘之後、啟用,然後什麼都不做,排程設定的 16:32 一到,流程在 16:32:00.9 開始執行,33.2 秒後三題都答完。
日報表裡的三列是這樣的。
| 題 | 助理的回答(原文) |
|---|---|
| 上週花費 | 上週(2026/09/07 至 2026/09/13)全站廣告費用總計為 123,957 元。其中花最多錢的廣告素材是 cr-meta-trn-p1,累計花費 12,938 元。 |
| 通路功勞 | 上週(2026/09/07 至 2026/09/13)分到最多訂單功勞的通路是 newsletter / email。採用時間衰減模型計算,前三名分別為:1. newsletter / email:97.1 筆訂單(佔 22.75%) 2. (direct) / (none):85 筆訂單(佔 19.9%) 3. meta / paid_social:79.9 筆訂單(佔 18.71%) |
| 成效異常 | 廣告成效曾出現過 4 次異常紀錄,包含素材疲乏、競價變貴與追蹤碼失效等狀況。最需要注意的是 2026-08-27 的「全站追蹤碼失效」事件,其判讀信心度高達 0.95。原因為當天廣告點擊數(1,386 次)與後台訂單(37 筆)皆維持正常,但網站追蹤到的購買數卻驟降為 0 筆,導致追蹤營收 ROAS 歸零。 |
第二、三題的原文有分行,這裡為了放進表格接成一行,字沒有動。
今天一共有四次執行。
| 台北時間 | 怎麼啟動 | 結果 | 花了多久 | 呼叫模型 | 費用(新台幣) |
|---|---|---|---|---|---|
| 16:25 | 手動,三題都改打不存在的路徑 | 三題 http_404,執行失敗 |
15.1 秒 | 0 次 | 0 元 |
| 16:32 | 排程自己觸發 | 三題答完,執行成功 | 33.2 秒 | 7 次 | 0.52 元 |
| 16:34 | 手動,不帶參數 | 今天已有日報,直接跳過 | 1.4 秒 | 0 次 | 0 元 |
| 16:36 | 手動,第 2 題故意失敗 | 兩題答完、一題 http_404,執行失敗 |
36.7 秒 | 4 次 | 0.33 元 |
第一次那三個 404 是好消息,404 代表請求通過了 Cloud Run 的身分檢查、進到助理的程式、程式說沒有這個路徑,如果流程的身分沒有權限,拿到的會是 403,所以這個不花錢的測試同時確認了身分、寫入和失敗的處理,測試腳本會檢查三題是不是都是 404,不是就判定沒過。
有四件事要照實交代。
第一,這份日報每天都會長得一樣,合成資料停在 2026-09-16,助理把隔天當成今天,所以不管哪一天跑,上週指的都是 9/7 到 9/13,今天示範的是排程和編排,接上每天更新的資料之後內容才會變。
第二,同一題問兩次,數字相同但用字不同,16:32 那次寫的是信心度 0.95,16:36 那次寫的是信心度 95%,廣告費 123,957 元、最貴的素材 12,938 元、點擊 1,386 次、訂單 37 筆兩次都一樣,數字來自工具查到的結果所以不會變,句子是模型寫的所以每次不同,日報如果要逐字比對前一天有沒有變化,要比的是數字不是文字。
第三,日報和同事的提問共用助理每天新台幣 3 元的額度,日報一份大約 0.5 元,排程設在早上 08:30,額度在台北時間換日時已經歸零,同事也還沒開始用,缺題的機會不大,但白天手動重跑的時候就可能遇到額度已經被用完。
第四,這幾次呼叫在 Day 25 的儀表板上算在 Day 26 那一列,因為助理服務寫進用量表的標籤是固定的,要單獨看日報花了多少,儲存庫的報表是用對話編號把日報表對回助理的問答紀錄,提問者那一欄也一併列出來。
run.app 和 googleapis.com 的網址都算內部步驟,今天的流程一次執行估計只有幾十步,每天跑一次一個月下來也在免費額度內,Cloud Scheduler 每個帳單帳戶有 3 個工作免費,不看執行次數,今天只用了 1 個,要注意暫停的工作也算一個| 項目 | 今天的費用(新台幣) | 說明 |
|---|---|---|
| 模型呼叫 | 0.86 元 | 排程那一次三題 7 次呼叫 0.52 元,缺一題那一次兩題 4 次呼叫 0.33 元,兩筆未四捨五入的金額相加是 0.8551,手動那一次開跑前印出的預估是兩題 0.30 到 0.60 元 |
| Workflows | 0 元 | 四次執行都在免費額度內 |
| Cloud Scheduler | 0 元 | 1 個工作,在 3 個的免費額度內 |
| BigQuery | 0 元 | 查詢與寫入都在每月免費額度內 |
~/ai-driven-martech-pipeline 執行 git pull,取得 Day 27 新增的 workflows/ 與 scripts/deploy_daily_report.sh
martech-scheduler 和 Cloud Scheduler 的 API 是那一次加的,部署腳本如果說 workflowexecutions.googleapis.com 還沒啟用,照它印出來的那一行啟用就好bash scripts/deploy_assistant.sh
cd ~/ai-driven-martech-pipeline && git pull && bash scripts/deploy_daily_report.sh && bash workflows/run.sh --free
這一行會部署流程、建立排程(先暫停)、跑一次不花錢的測試,全程不呼叫模型。
bash scripts/deploy_daily_report.sh
腳本先檢查兩個服務帳號、三個 API、助理服務和它的權限,缺哪一樣會直接說,接著建立日報表與 view、部署流程、建立排程,其中兩個指令值得看一下。
gcloud workflows deploy martech-daily-report \
--location us-central1 \
--source workflows/daily_report.yaml \
--service-account martech-pipeline-runner@你的專案.iam.gserviceaccount.com \
--set-env-vars "ASSISTANT_URL=助理的網址,DATASET=martech_dw"
--service-account 決定流程用誰的身分做事,--set-env-vars 把助理的網址寫死在流程裡。
gcloud scheduler jobs create http martech-daily-report-morning \
--location us-central1 \
--schedule "30 8 * * *" \
--time-zone "Asia/Taipei" \
--uri "https://workflowexecutions.googleapis.com/v1/projects/你的專案/locations/us-central1/workflows/martech-daily-report/executions" \
--http-method POST \
--message-body '{"argument": "{\"trigger\": \"scheduler\"}"}' \
--oauth-service-account-email martech-scheduler@你的專案.iam.gserviceaccount.com \
--max-retry-attempts 0
--time-zone 不寫就是 UTC,08:30 會變成台北時間下午四點半,--message-body 裡流程的參數要先變成一個 JSON 字串再放進 argument,所以裡面的引號要加反斜線,建立排程的指令沒有先暫停的選項,腳本會在建好之後緊接著把它暫停,再讀回來確認真的是暫停的。
bash workflows/run.sh --free
三題都改打不存在的路徑,預期三題都是 http_404、這次執行以失敗結束、14 項檢查全部通過,看到 http_403 代表流程的服務帳號少了助理的 roles/run.invoker。
bash workflows/run.sh
腳本會先印出估價和助理今天已經花了多少,輸入 yes 才啟動流程,跑完會印出日報、每一次執行的結果和這幾題各花了多少,想看缺一題的日報長什麼樣子可以再跑 bash workflows/run.sh --break 2,第 2 題會故意失敗,另外兩題照問所以會花兩題的錢。
gcloud scheduler jobs resume martech-daily-report-morning --location us-central1
啟用之後排程會在每天早上 08:30 啟動流程,一次約新台幣 0.5 元(腳本印的估計範圍是 0.5 到 0.9 元),跟著做的話確認過會動就可以暫停。
http_404,執行 15.1 秒後以失敗結束skipped_existing,沒有呼叫模型ok 和一列 failed,執行以失敗結束martech-pipeline-runner
gcloud scheduler jobs pause martech-daily-report-morning --location us-central1
暫停之後不會再自己跑,流程留著不收費,明天的 Day 28 會用到這個流程和日報表,要跟著做的話先不要刪,確定都不用了再刪掉排程和流程,暫停的排程還是佔一個免費額度。
gcloud scheduler jobs delete martech-daily-report-morning --location us-central1
gcloud workflows delete martech-daily-report --location us-central1
我自己的排程在驗證完之後已經改回每天 08:30 並且暫停。
status 裡,這幾種情況 HTTP 狀態都是 200,流程要自己讀這個欄位,而且只把明確列出來的幾種當成答完,沒看過的值一律當成失敗args,又在流程裡把它重新指定成空的,部署時直接被拒絕,訊息是 cannot assign to a reserved variable 'args',改成另外用一個變數接參數才通過,官方範例也用 args 當參數名稱,所以被拒絕的是重新指定這個動作,流程的參數只能讀,好在這種錯部署時就會被擋下,不用等到執行no,有些 YAML 解析器會把它讀成 false,我用本機的工具試過確實如此,yes、no、on、off 這一批字當鍵名或值都可能出事,所以在部署之前就改成 num,沒有去試 Workflows 自己會怎麼讀今天用 Cloud Workflows 寫了一個日報流程,依序向昨天的助理問三個固定問題,每問完一題寫一列到 BigQuery,再用 Cloud Scheduler 定時啟動它,排程和流程各用一個服務帳號,排程的身分只拿到啟動流程的權限,助理的程式一行都沒有改,實際跑了四次,不花錢的測試確認身分和失敗的處理,排程在 16:32 自己觸發的那一次 33.2 秒答完三題,同一天再跑一次流程 1.4 秒就跳過,故意讓一題失敗的那一次留下了缺一題的紀錄並以失敗結束,一共呼叫模型 11 次,花了新台幣 0.86 元。
回到篇名,早上一進辦公室日報已經寫好這件事,機制今天做到了,排程會自己啟動、流程會自己問完並存好,但離一份每天都值得打開的日報還差兩步,第一是資料,合成資料不會更新所以內容每天相同,第二是日報現在躺在 BigQuery 的表裡,要看得自己去查,沒有人會每天早上打開 BigQuery,另外有幾件事今天沒有實際遇到,逾時與連線錯誤、額度用完之後的跳過、429 與 503 的重試都只有寫進流程,排程也只驗證過臨時改成 16:32 的那一次,早上 08:30 那一班還沒有真的跑過。
明日預告:Day 28《廣告出狀況時,讓 Slack 機器人第一時間通知你》,把今天的日報和成效異常送進 Slack,日報寫好的時候、流程失敗的時候都會有訊息,不用再自己去查。