昨天 Day 23 把答案串流回去了——token 一個個冒出來,使用者不用盯著空白乾等。但答案吐完、最後一個字浮現,這次互動就真的結束了嗎?對使用者來說是;對一套企業內部系統來說,故事還差最後一筆——這一瞬間發生過什麼,得留下可追溯的紀錄。從今天起進入第七章「可觀測性與稽核」,先談最基本、也最硬性的那一塊:稽核紀錄(audit log)。
本篇結構:
企業內部系統有一條繞不過去的要求:誰、在什麼時候、來問了什麼、系統怎麼回的、有沒有被擋下,事後都要查得到。這不是「有空再做的 nice to have」,是法遵(compliance)層級的硬性要求。出了爭議、被抽查、要追責的時候,你得能攤開紀錄說清楚事情的經過。
這套平台自己不產生答案——答案出自後端引擎、知識存於知識庫,這點 Day 1 就定了調。但「誰來過、做了什麼」這件事,發生在 Portal 這道入口,所以留痕的責任天然落在它身上。每一次 /chat 請求走到尾聲,Portal 就寫下一筆稽核紀錄,把這次互動的完整脈絡封存起來。
關鍵字是脈絡。它不是只記一行「某人來過」,而是要能還原:哪組追蹤碼、哪位使用者、丟給哪個助理跟哪家模型、問了什麼、答了什麼、結果是成功還是被擋、花了多久。一次互動,一列紀錄,自成一個可閱讀的故事。
直接看一筆寫出來的稽核紀錄:
{
"timestamp": "2026-06-22T09:15:03.661Z",
"requestId": "a1b2c3d4",
"userId": "12345678",
"agentName": "internal-assistant",
"llmProvider": "gemini-http",
"userInputMasked": "我的員編是 ***,AD 帳號被鎖了,順便想查內湖分行的營業時間。",
"answerMasked": "請依下列步驟解鎖帳號…內湖分行營業時間 09:00–15:30。",
"status": "success",
"piiInput": ["EMPLOYEE_ID"],
"durationMs": 249
}
這就是小林那題的稽核紀錄。逐欄看,每個欄位都有講究:
| 欄位 | 本例的值 | 講究 |
|---|---|---|
requestId |
a1b2c3d4 |
這次請求的 correlation id,也就是 Day 9 講過的追蹤碼(詳見表後)。 |
userId |
12345678 |
一個 8 碼的集團員工編號 corpId。這個值不是從本次輸入裡擷取的(詳見下一節)。 |
agentName |
internal-assistant |
這次互動丟給了哪個助理。 |
llmProvider |
gemini-http |
背後接的是哪家模型。 |
userInputMasked |
我的員編是 ***… |
落進日誌的一律是遮罩後的版本。原文裡的「員編 123456」在這裡是 ***。 |
answerMasked |
請依下列步驟… |
答案同樣是遮罩後版本——從問句到答案,原始那串數字始終沒有落地。 |
status |
success |
三態之一,見下表。 |
piiInput |
["EMPLOYEE_ID"] |
記下這次輸入命中了哪幾類 PII(即個資),方便日後統計「哪類個資最常被輸入」。 |
durationMs |
249 |
這次互動花了多久,順帶成為觀測延遲的一個資料點。 |
requestId 值得多說兩句:Day 9 講過,靠這組追蹤碼能把非同步系統裡散落各處的足跡串回一條時間軸。稽核紀錄帶上同一個 id,意味著它跟那次請求的每一行日誌都能對得起來——日誌裡循這個 id 看到的處理過程,跟稽核紀錄裡的結論,是同一件事的兩面。
其中 userInputMasked 和 answerMasked 名字裡都帶 Masked——這是刻意的命名約定,提醒看紀錄的人它們已經過遮罩。
status 是一個三態列舉,被守門擋下的請求一樣要留痕,而且更要留痕——因為「有人嘗試了什麼、被我們擋下」往往才是稽核最想看的:
| 狀態 | 意義 |
|---|---|
success |
互動正常完成,答案送回使用者。 |
blocked |
被守門擋下,請求未進入後端。 |
error |
處理過程出錯。 |
這份資料模型是 append-only(只增不改):一次互動一列,沒有 update、沒有 delete。這是稽核可信度的底線——一份能被回頭竄改的紀錄,等於沒有紀錄。所以稽核紀錄的寫入路徑刻意設計成只能往後加,不開放修改既有列。
《死亡筆記本》最讓人記得的其實是那套規則:寫下去就算數、不能塗改、每一筆都有後果。稽核紀錄想要的正是這種份量——寫了就不可否認、不可竄改,出事時它就是唯一的事實來源。只是得誠實說,這篇後面會再展開:現在這套的「不可改」還只是「程式沒開修改的門」的君子協定,還沒真的做到筆記本那種改不了的鐵則。
把 Day 8 埋的伏筆收回來。那天我們講「身分始於 SSO(single sign-on,單一登入)」:小林是誰,在他登入那一刻、通過企業身分中心(IdP)驗證、拿到那張帶 HMAC 簽章的 EMP_INFO cookie 時就確立了,而且可以驗章——任何竄改都會讓簽章對不上、整張作廢。
稽核紀錄的 userId 取的正是這個源頭:登入時由 SSO 確立、可驗章的身分。為什麼非得這樣?反過來想就懂了——如果 userId 是從「使用者本次輸入」裡撈出來的,那任何人只要在問句裡打「我是員編 999999」,稽核紀錄上就會掛別人的名字。稽核紀錄要能拿來追責,前提是上面的身分不可偽造;唯一不可偽造的來源,是登入時驗過章的那個身分,不是這次對話的內容。
所以同一筆紀錄裡會出現一個有點微妙的對比,正好說明兩者的身分地位完全不同:
| 欄位 | 本例的值 | 它是什麼 | 為什麼這樣處理 |
|---|---|---|---|
userInputMasked(自述員編) |
123456 → *** |
既是 PII、又是不可信的輸入 | 遮掉:不可採信、且屬個資 |
userId |
12345678 |
來自驗過章的可信源頭 | 保留:唯一可拿來追責的身分 |
總結:身分的可信源頭在登入,對話只是沿用。
這也正是整個連載要在第三章先把 SSO 講透、才往後走的原因——它撐起了後面每一處「我們知道這是誰做的」。
寫稽核紀錄是個副作用,而副作用最該守住的紀律是:不准拖累、更不准弄壞主流程。這裡要兌現 Day 6 立下的那條鐵律。
Day 6 講過,reactive 的事件迴圈上絕不能出現阻塞呼叫,一個同步的 DB 寫入卡在事件迴圈上,等於凍住那條執行緒上所有人的請求。而寫日誌——尤其寫進資料庫——正是典型的阻塞工作。所以這裡的處理是 fire-and-forget(交辦即忘):主流程把「寫這筆紀錄」這件事丟給(offload)獨立的彈性執行緒池,交辦完立刻回頭把答案送給小林,不等寫入完成。
// ✗ 在事件迴圈上同步寫稽核 → 凍住整條執行緒、拖慢所有人
auditSink.write(record)
// ✓ 丟到彈性執行緒池,主流程立即釋放(fire-and-forget)
elasticPool.submit { auditSink.write(record) }
這帶來一個取捨,得誠實講:
因為不等寫入完成,萬一寫入失敗,主流程也不會因此報錯——失敗只記一則警告,使用者照樣拿到答案。極端情況下(比如服務當機的那一瞬間),可能漏掉最後幾筆稽核。
我們選擇承擔這個風險,因為反過來的代價更不能接受:讓稽核這個副作用去阻塞、甚至搞砸使用者的正常對話,是本末倒置。副作用服務於主流程,不該反噬主流程——這跟 Day 10 那條 fail-open/fail-closed 的思路同源,都是按風險量級決定誰讓步。
但這裡得誠實再走一步:這個取捨其實偷換了一個概念。Day 6 的鐵律要求的是「別在事件迴圈上同步寫」,不等於「可以靜默丟掉稽核」。兩者能兼得:寫入非阻塞的同時,寫失敗就落地到本地持久佇列,或推進 Pub/Sub,配上重試與死信(dead letter,收容重試不成的訊息)告警,做到 at-least-once(至少送達一次)交付,既不卡事件迴圈、也不讓紀錄消失。fire-and-forget 只是 PoC(概念驗證)最省的選法,不是 reactive 逼出的唯一解。要補一點:at-least-once 有個逃不掉的副作用——重試會造成重複事件,所以每筆稽核得配一個不重複的 eventId,連同原請求的 correlationId 一起帶,寫入端拿 eventId 當唯一鍵去重,重送才不會被誤記成兩次操作。
兩條路的分岔點不在「要不要等」,全在「寫失敗之後」:

而它最尷尬的地方在時機:當機、被攻擊、異常流量這些最需要追責的時刻,正是它最會丟紀錄的時刻(服務一起當機,連那則警告也沒了)——對一般 SaaS 可接受,對金融內控不行。
這跟明天 Day 25 的教訓「別把該自己做的事賭在別人會做」其實是同一種樂觀,只是還沒套回稽核自己身上;正式版這道交付保證(持久佇列或 Pub/Sub + 重試 + 死信)還沒補。
最後一個設計點是寫入目標可抽換。這延續了整個連載的主旋律——依賴反轉(Day 12 起講的那套):上層只丟出「寫一筆稽核」這個動作,至於這筆紀錄落到哪、用什麼形式落,由設定決定,主流程一行不動。
portal:
audit:
provider: slf4j # 預設:當 log 寫到 stdout;在 Cloud Run 上由平台收進 Cloud Logging
# provider: postgres # 切成寫資料庫(GCP 上即 Cloud SQL for PostgreSQL),同一介面、由設定切換
兩個 provider 的取捨對照如下:
provider |
寫入目標 | 基礎設施 | 定位 |
|---|---|---|---|
slf4j(預設) |
當日誌寫到 stdout → Cloud Run 收進 Cloud Logging | 零額外基礎設施 | 目前主力;本地開發、PoC 階段不必先架資料庫就能驗證稽核這條路通不通 |
postgres |
寫進 Cloud SQL for PostgreSQL | 需要一座 Cloud SQL 執行個體 | 設計上備好的另一個出口,預留給稽核量與查詢需求長大、需要結構化查詢與長期保存之後 |
對上層程式而言這兩者沒有差別,差的只是設定那一行。
在 GCP(Cloud Run)上要老實講兩點。其一,
slf4j這條路不是落到實體磁碟——容器的本機檔是 ephemeral、重啟即失,它實際上是把稽核當日誌寫到 stdout、由平台收進 Cloud Logging;postgres(即 Cloud SQL)是規劃中的出口、尚未是主力。其二,真要當合規等級的長期留痕,Cloud Logging 預設保存期有限,得再補一手:把日誌 bucket 設成鎖定保存,或經 Log Router 匯到 BigQuery(可查詢)與 GCS + Bucket Lock(不可竄改),或乾脆走 Cloud SQL。無論落到哪,上層都只丟出「寫一筆稽核」——切換是換零件、不是改寫,這正是依賴反轉在稽核這一站的兌現。
還有一點得跟前面那句「append-only 是底線」對齊著講清楚:目前的 append-only 比較像應用層的君子協定,不是儲存層強制的不可竄改。 slf4j 這條主力路寫出去的紀錄,有足夠權限的人仍能刪掉整個日誌 bucket、或改動保存設定;它「只追加不修改」靠的是「程式沒開 update/delete 的路」,不是儲存本身禁止這麼做。金融稽核要的不可否認性,得壓到儲存層才算數:資料庫層的 INSERT-only 權限、WORM(寫一次、永不可改)儲存,或對每筆紀錄串一條雜湊鏈讓任何竄改都留下斷點。所以正文那句「能被回頭竄改的紀錄等於沒有紀錄」是對的標準,但要老實承認——現在這套還沒走到那個標準,append-only 目前是設計意圖,還不是儲存層保證的事實。
寫到這裡,稽核紀錄的骨架算是立起來了:
但有一個前提我今天一路講得很篤定,明天得回頭戳破它——我說「落進日誌的一律是遮罩後的版本」,可萬一上游守門被設成 mock 關掉了呢(Day 12 起就能這樣抽換)?那原文豈不是直接漏進日誌?
明天 Day 25,我們把稽核紀錄自己那道保底遮罩攤開來看:為什麼不能假設上游遮過,以及這道缺失曾經漏掉的一個真實合規 bug。