大多數 CI 管線都假設同一函式用相同輸入呼叫兩次會回傳相同的輸出。這個假設一旦 LLM 呼叫進入測試套件就會失效。對 GPT-4 或 Claude 提出同樣的問題兩次,可能會得到兩個不同(但都正確)的答案。開發 LLM 支援功能的團隊,常常因此對接觸到 LLM 的程式路徑幾乎不寫測試,或是對精確字串輸出做斷言,然後在第一次失敗時就停用測試。一旦功能上線,提示詞變更或模型升級就可能無聲無息地破壞行為,上述做法都無法持續。
解決方法不是找更聰明的斷言函式庫,而是把你要測試的東西拆成三個不同確定性保證的層級,並將每個層級對應到不同的 CI 工作。
第一層:合約測試(確定性,每次 PR 執行)
合約測試永遠不會呼叫真實模型。它對管線產出的形狀做斷言,而不是內容。如果你的協調器預期 LLM 回傳 {title, tags, price_usd, full_content},合約測試會把預先準備好的回應送進解析/驗證層,檢查是否拋出例外、必要欄位是否存在,以及型別是否相符。
# test_contract.py
import json
import jsonschema
import pytest
PRODUCT_SCHEMA = {
"type": "object",
"required": ["title", "tags", "price_usd", "full_content"],
"properties": {
"title": {"type": "string", "minLength": 5},
"tags": {"type": "array", "minItems": 1},
"price_usd": {"type": "number", "minimum": 1},
"full_content": {"type": "string", "minLength": 200},
},
}
@pytest.mark.parametrize("fixture_file", [
"fixtures/well_formed.json",
"fixtures/missing_price.json",
"fixtures/empty_content.json",
])
def test_pipeline_handles_llm_output(fixture_file):
payload = json.load(open(fixture_file))
result = parse_and_validate(payload) # your own code
if fixture_file == "fixtures/well_formed.json":
jsonschema.validate(result, PRODUCT_SCHEMA)
else:
assert result.rejected_reason is not None
Enter fullscreen mode Exit fullscreen mode
這些測試資料是存放於版本庫中的靜態 JSON 檔案。它們只需數毫秒即可執行,不需要 API 金鑰,就能找出真正導致服務中斷的錯誤類型:解析器無法處理模型省略的欄位、預期字串卻收到 null、提示詞修改後的結構漂移。此層級在每次提交都執行,並且應阻擋合併。
第二層:重播測試(確定性,每次 PR 執行)
介於「假 JSON 測試資料」與「呼叫真實 API」之間的是基於錄製檔的重播:先錄製一次真實模型回應、存到磁碟,之後每次 CI 執行時就重播它,而非再次打網路。這與 HTTP 測試使用的 VCR 概念相同,只是套用到 LLM 呼叫。
# conftest.py
import json, os, hashlib
CASSETTE_DIR = "tests/cassettes"
def llm_call_with_cassette(prompt, real_call_fn):
key = hashlib.sha256(prompt.encode()).hexdigest()[:16]
path = f"{CASSETTE_DIR}/{key}.json"
if os.path.exists(path):
return json.load(open(path))
if os.environ.get("CI") and not os.environ.get("RECORD_CASSETTES"):
raise RuntimeError(f"Missing cassette for prompt hash {key}; run with RECORD_CASSETTES=1 locally")
response = real_call_fn(prompt)
json.dump(response, open(path, "w"))
return response
Enter fullscreen mode Exit fullscreen mode
錄製檔會像其他測試資料一樣提交到版本庫。當你刻意修改提示詞時,可在本地用 RECORD_CASSETTES=1 重新錄製,把錄製檔的差異視為 PR 審核的一部分(這常常能讓審核者在發佈前發現回歸問題),再提交新的錄製檔。CI 執行這些測試時不會碰網路,因此既快速又免費,但仍能用真實模型輸出(而非手寫測試資料)來驗證解析與商業邏輯。
第三層:即時冒煙測試(非確定性,每晚執行,不在 PR 上執行)
此層級會使用真實 API 金鑰與真實成本呼叫實際模型。它的目的是回答一個問題:自上次檢查以來,模型的行為是否發生了我們錄製檔無法偵測到的漂移?因為輸出會變化,所以不能斷言相等,而應斷言屬性:回應是否為有效 JSON、是否符合結構、是否透過嵌入(而非字串比對)計算出的語意相似度分數高於門檻、token 用量是否在成本預算內。
def test_live_semantic_similarity(embed_fn, cosine_sim):
reference = load_reference_embedding("golden_answer.json")
live_output = call_real_llm(PROMPT)
similarity = cosine_sim(embed_fn(live_output["full_content"]), reference)
assert similarity > 0.80 # loose bound; catches total drift, not phrasing
Enter fullscreen mode Exit fullscreen mode
這正是 CircleCI 工作流程模型發揮作用的地方。在 .circleci/config.yml 中定義兩個工作流程:一個在每次 PR 時執行第一、二層;另一個排程工作流程每晚(或透過核准工作手動觸發)執行第三層,如此一來,即時呼叫的偶發失敗就不會阻擋合併。
version: 2.1
jobs:
fast-tests:
docker: [{ image: cimg/python:3.12 }]
steps:
- checkout
- run: pip install -r requirements.txt
- run: pytest tests/test_contract.py tests/test_replay.py -v
live-smoke-tests:
docker: [{ image: cimg/python:3.12 }]
steps:
- checkout
- run: pip install -r requirements.txt
- run:
name: Enforce per-run cost ceiling
command: python scripts/check_budget.py --max-usd 2.00
- run: pytest tests/test_live_smoke.py -v --reruns 1
workflows:
on-pr:
jobs:
- fast-tests
nightly-live-check:
triggers:
- schedule:
cron: "0 6 * * *"
filters:
branches: { only: main }
jobs:
- live-smoke-tests
Enter fullscreen mode Exit fullscreen mode
這裡有兩個細節值得注意。首先,live 工作加上 --reruns 1 表示單次 API 逾時不應通知值班人員;連續兩次失敗才算真正警訊。其次,check_budget.py 步驟在 pytest 呼叫之前執行,根據固定測試提示詞的 token 數估算成本,若提示詞變更會超出每日美元上限則直接失敗——這能捕捉有人放大提示詞上下文視窗而讓夜間帳單暴增 10 倍的情境,而非事後才收到帳單。
為什麼要拆分,而不是「多重試」
跳過這種拆分的團隊通常會落入兩種失敗模式:PR 因為等待速率受限 API 的不穩定網路呼叫而被阻擋數分鐘;或是測試斷言過於寬鬆,即使管線回傳垃圾資料也能通過。把合約正確性(第一層)、針對已知良好真實輸出的回歸偵測(第二層),以及即時模型漂移偵測(第三層)分開,讓每一層都能使用適合其確定性保證的斷言方式,並讓 CircleCI 依照各自的成本與速度特性安排執行時機。阻擋 PR 的路徑保持快速且免費;昂貴、不穩定、需要真實金錢的路徑則每天執行一次,讓人類能在不延誤發佈的情況下進行分類處理。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.