iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0

昨天 Day 27 我們把第二條整合路講完了——Agent 軌讓一個 LLM 自己決定要不要去呼叫後端工具,鬆耦合、有彈性,但也意味著「一題到底會打幾次模型」這件事,從工程師手裡交到了模型手裡。一個 agent 處理一題,背後可能是三次、五次、十次 LLM 呼叫,每一次都在燒 token、燒錢。今天要面對的,就是這個被前一天順手翻出來的副作用:每一次模型呼叫的頻率與成本,怎麼在錢真的花出去之前,就被閘門卡下來? Day 26 我們才把「花了多少」變成看得見的曲線,但那是事後的帳;今天要做的是事前的閘。

本篇結構:

  • 記帳很好,但記帳擋不住人
  • 一前一後兩道閘,剛好卡住呼叫的兩端
  • 三個必須講白的妥協

記帳很好,但記帳擋不住人

先把這兩件事的差別講清楚,因為它正是今天這篇存在的理由。Day 26 那套可觀測性,做的是把 token 用量記成可聚合的指標——上線之後你能看到「這個月燒了多少、哪家供應商最貴」。這很有用,但它的時態是過去式:它只告訴你「已經花掉了多少」,而且是在錢已經出去之後才告訴你。

營運上更急的,往往是反過來那個問題。某個使用者,或更糟,某個被盜用的帳號,在短時間內瘋狂發問——你能不能在帳單膨脹之前就把它擋下來,而不是等月底對帳才發現爆了?這正是 Day 3 我拿來當對照錨點的 LiteLLM 那類 AI Gateway 的核心賣點:每分鐘的 token 與請求數上限(TPM/RPM)、每個使用者的預算上限,把超量流量在閘道層直接回絕。當時我留了一個伏筆,說這道閘門怎麼設計、為什麼分前後兩層是 Day 28 的事;今天就把 Day 3 那張對照表裡「用量閘門」那一格展開——Portal 怎麼把預算控制這件事,落在自己的閘道層裡。

概括地說,兩者的分工是:記帳是觀測,朝後看;閘門是控制,朝前擋。 它們用的是同一份 token 數字,但解的是不同的問題:

面向 記帳(Day 26 可觀測性) 閘門(Day 28 用量閘門)
本質 觀測 控制
時態 朝後看(已經花掉的) 朝前擋(錢出去之前)
回答的問題 算得準不準 來不來得及攔
動作時點 錢花出去之後才告訴你 在帳單膨脹之前回絕

一前一後兩道閘,剛好卡住呼叫的兩端

這道用量閘門不擺在單一個點,而是拆成「一前一後」兩個位置,剛好夾住 LLM 呼叫的兩端。會這樣拆,是因為「頻率」和「成本」這兩種限制,各卡一處最划算。

前面那道,是請求一進門時的 預檢閘。還沒開始跑這一題之前先過一關——這裡本來就在做 Day 16 的提示注入攔截、Day 17 的個資遮罩,現在再加一條「每個使用者的請求頻率上限」。如果某人這段時間發的請求數已經超標,直接在這裡回絕,連後面的 agent 都不啟動。頻率擋在進門最省事,因為它進門數一下人頭就知道超沒超,不必勞動後面任何一個零件。這道頻率閘用的是滑動視窗(跟下面的成本視窗同一種語意);演算法挑哪一種其實是個有後果的決定——固定視窗會在整點交界放過接近兩倍的瞬間尖峰,令牌桶(token bucket)又刻意容許一定程度的爆量,真要擋「帳號被盜、短時間狂發」這種瞬間暴量,挑對演算法跟設對數字一樣重要。

後面那道,是逐次呼叫的 記帳與准入閘。一個 agent 處理一題可能呼叫 LLM 很多次(尤其 Day 27 那條 Agent 軌,次數由模型決定),所以每一次呼叫之前都先查一次帳本:「這個使用者這段時間的累計呼叫數、或累計成本,是不是已經到頂了?」到頂就擋下這一次、回「已達上限」;沒到頂就放行,並在呼叫完成後,把這次真實的 token 用量換算成成本記進帳本。累計成本之所以非得逐次累加不可,是因為它得等每一次呼叫真的回來、拿到真實 token 數才算得準——你沒辦法在進門時就預知一題會打幾次模型。一個是「人頭」、一個是「跑表」,硬塞在同一個位置反而兩邊都做不好。

兩道閘的分工對照:

預檢閘 記帳與准入閘
位置 請求一進門 逐次 LLM 呼叫之前/之後
限制種類 頻率(人頭) 成本/呼叫數(跑表)
觸發頻率 每題一次 每次 LLM 呼叫
判斷依據 這段時間的請求數是否超標 累計呼叫數或累計成本是否到頂
超限動作 直接拒絕,agent 根本不啟動 擋下這次,回「已達本期使用成本上限」
為何卡這裡 進門數人頭最省事,不勞動後面零件 得等呼叫回來拿到真實 token 數才算得準
請求進門
   │
   ├─ 預檢閘:注入攔截 / 個資遮罩 / 每人請求數上限
   │            └─ 超限 ─▶ 直接拒絕,agent 根本不啟動
   │
   ▼  agent 開始處理一題(可能多次呼叫 LLM)
   │
   └─ 每次呼叫前:查帳本「呼叫數 or 累計成本到頂了嗎?」
        │            └─ 到頂 ─▶ 擋下這次,回「已達本期使用成本上限」
        ▼  沒到頂
      實際呼叫 LLM ──(主 provider 掛了→自動轉備援)── 呼叫後記帳(token + 成本)

帳本本身其實不懂「錢」,它記的是 token。要把 token 換成美元,靠的是一張設定好的價目表——每千個輸入 token、每千個輸出 token 各算多少錢,而且按供應商分開設(因為 Day 26 講過,prompt 與 completion 單價往往不同,各家報價也不同)。價目表的查找有三層退路:

  1. 查到該供應商的價,就用它。
  2. 查不到某家供應商的價,退回一個「預設」價。
  3. 連預設都沒設,就當這次呼叫零成本——這一點等下講妥協時很關鍵。
portal:
  llm:
    gating:
      enabled: true                 # 記帳開關(預設開:但只記不擋)
      window-seconds: 3600          # 統計視窗:滑動的一小時
      max-calls-per-window: 0       # 每人每視窗最多幾次呼叫,0 = 不限
      max-cost-usd-per-window: 1.0  # 每人每視窗累計成本上限,這裡設 $1
      fallback-provider: mock       # 主 provider 掛了就轉這個
      cost:                         # token → 美元 的價目表,按 provider 分
        openai-http:
          input-per-1k: 0.00015
          output-per-1k: 0.0006
        # 沒列到的 provider → 退回 default;連 default 都沒設 → 當零成本

在這份設定下,某個使用者一小時內累計成本逼近 $1,他的下一次呼叫就會被擋掉、收到一則「已達本期使用成本上限」。這裡要特別留意 window-seconds 那個「滑動」的語意——它算的是「過去這一小時」這個一直往前滾的視窗,不是「整點到整點」那種固定區間。所以使用者撞到上限後,不會等到下個整點就重置,而是得真的等時間一分一秒滾過去、把最早那些呼叫滑出視窗,額度才一點一點長回來。這個差別在你向使用者解釋「為什麼還沒解禁」時一定會碰到。

「令牌桶」你可能沒什麼感覺,但查克拉你一定懂:它有限、每放一次忍術就耗掉一些、用光了就發不出招,得等它隨時間慢慢回滿。用量閘門就是給每個人一條查克拉——每次呼叫模型都在耗,累計到上限就發不出下一招;而這裡用的滑動視窗,正是「額度隨時間一分一秒回補」那種回法,不是整點一次全部補滿。

被擋的那次落到日誌上大概長這樣,跟整條處理鏈用的是 Day 9 那同一個 correlation id,事後循 req= 就能拼回現場:

2026-06-22 09:15:03.412  INFO [req=a1b2c3d4] usage-gate : cost window check used=$0.987 limit=$1.00 -> PASS
2026-06-22 09:15:08.701  WARN [req=a1b2c3d4] usage-gate : cost limit reached used=$1.012 limit=$1.00 -> BLOCK

三個必須講白的妥協

這一節我得把代價講得比機制還清楚,因為它直接決定了這道閘門現在能不能拿去當真正的營運防線——答案是:還不能,它是個 PoC(概念驗證)階段的骨架,有三個寫進設計裡的妥協。

妥協 現在的狀態 要補強成正式防線
軟上限,不是硬上限 查帳在呼叫前、記帳在呼叫後,並行請求會集體超支 改成預扣 → 結算
帳本只活在單機記憶體 多實例各記各的,全域上限算不準 帳本外移共享儲存,或交給外部 Gateway
預設全關 出廠只記帳、不擋人、價目表沒設當零成本 上線時手動開限流、填上數字

第一,它是軟上限,不是硬上限。 回頭看後段閘的流程:「查帳本」在呼叫前、「記帳」在呼叫後——而且非得在呼叫後不可,因為一次呼叫到底燒多少 token,要等模型回應回來才算得準。問題就卡在這個時間差上:放行與否的決策,發生在「這次到底花多少」還不知道的時候。如果同一個使用者在這個空檔裡同時打進好幾個請求,它們進門查帳本時都看到「還沒到頂」,於是全部被放行,等陸續回來記帳才發現集體超支。

這裡要修正一個直覺上的誤解:光把「記帳」做成共享儲存的原子遞增,並不能把它變成硬上限——原子遞增解的是「兩個寫互相覆蓋」,而這裡的洞是「放行的當下,成本還不存在」。真正的硬上限得換一套作法:預扣 → 結算(reserve → reconcile)——呼叫前先原子地預扣一筆「這次最多可能花多少」,用這筆預扣去擋人(扣不下去就拒絕),等回應回來再把預扣結算成真實成本、多退少補。換句話說,硬上限要的是「先卡額度、後對帳」,不是「事後記得快一點」。PoC 階段先接受了這點超支,沒走到預扣那一步。

左右並排看這個競態,以及預扣為什麼堵得住它:

https://ithelp.ithome.com.tw/upload/images/20260829/20183385ztAOM9794k.png

第二,帳本只活在單一台機器的記憶體裡。 Day 4 講過 Portalreactive、無持久化(persistence)的設計,這在大多數地方是優點,但對「累計用量」這種需要橫跨多次請求記憶的東西就成了限制。現在這本帳就是一個 in-memory 結構,跑單一實例時準;可是 Day 27 才說過,改用無狀態的 Streamable HTTP 正是為了能水平擴展——一旦展開到多個實例,同一個使用者的請求被負載平衡分到不同台,每台只看得到自己經手的那部分用量,全域上限自然算不準(極端情況下 N 台等於把上限放大 N 倍)。這也正是為什麼框架裡預留了一個對接外部 Gateway(像 LiteLLM 這種專門做這件事的)的轉接骨架。這就接回 Day 3 那段:我提過 Portal 並不排斥把成本控管外包出去。真要在多機環境精準控管,最終的路是把帳本外移到共享儲存(GCP 上典型是 Memorystore for Redis,用它的原子遞增跨實例共用同一份帳本),或乾脆把這道閘整個交給外部 Gateway 去算,而不是靠每台自己數人頭。只是這段接線目前只立了骨架,還沒接。

而且帳本一外移到 Redis,等於替用量閘門接上一個新的單點依賴,於是冒出一個 Day 10 那把尺正好該量的問題:Redis 連不上時,這道閘該 fail-open(放行、暫時不擋)還是 fail-closed(擋死、寧可拒絕)? 按 Day 10 的分級原則(Day 30 會再總結),用量閘門擋的是成本與濫用、不是安全邊界,可用性的權重較高,合理的預設是 fail-open——Redis 掛了,寧可這段時間擋不準,也不該因為記不了帳就把所有正常使用者擋在門外。但這又回到妥協三的老問題:這個行為得有人有意識地定下來,否則就會變成「Redis 一掛、閘門靜默失效、沒人發現」。這道 fail 策略目前也還沒明定。

第三,預設全關——出廠只記帳、不擋人。 看回那份 YAML:max-calls-per-window 預設是 0(不限),成本上限要你自己填才生效,連價目表沒設都當零成本。也就是說,這東西開箱即用的行為是「乖乖記帳、但不擋任何人、甚至不算錢」。這是刻意的——要不要開限流、上限設多少,是營運上線時根據實際流量和預算做的決定,不該由程式預設替你做主;而且預設替你擋人,比預設不擋更危險,你可能在完全不知情的狀況下把正常使用者擋在門外。但它的反面是:如果你部署完就以為「有這道閘=有保護」,那是錯覺,你只是裝了個記帳本,門其實開著。得有人真的去把開關打開、把數字填上,這道閘才開始擋人。

把這三條合起來看其實是同一個誠實的姿態:骨架立起來了、位置卡對了(一前一後兩道、剛好夾住呼叫的兩端,帳本與價目表也拆得乾淨),但「軟、單機、預設關」這三個詞標記了它離正式營運防線還有的距離。要補強,只是把每一處的妥協逐個換成正式實作,不必重畫架構。

在成本控制這件事上,把這距離講清楚,比讓人誤以為錢已經被鎖死要安全得多——自以為有防線,比明知沒防線更危險。

明天 Day 29,我們把鏡頭轉到最前端:文字、截圖、語音三種完全不同形態的輸入,怎麼共用同一套後端身分與守門(Channels BFF),以及 Agent 工具安全層目前的真實接線狀態——哪些已接進工具呼叫邊界實際生效,哪一道還沒接,到底長什麼樣。


上一篇
Day 27|終於來到Agent 軌與 MCP
下一篇
Day 29|多模態通路 Channels BFF + 未來安全層
系列文
轉生到全端工程師沒多久就要負責公司的大平台??30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言