iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 28 篇

Day 18(下)|LLM Application SLI:把「慢」拆到能修的位置

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

結論先說:事件契約定義好只是起點;真正決定值班工程師能不能在深夜迅速定位問題的,是欄位有沒有放對位置、告警門檻有沒有踩對消耗速度、以及 incident 發生時能不能照這組訊號縮小排查範圍。

承接上文:Day 18(上)把一次 /ask 拆成 system SLI 與 AI SLI 兩層訊號,定義了 request_received、first_token 等九個時間點,並用 AskEvent/AskTiming 兩個 dataclass 把契約寫死,也談了 P95 的意義與 token 成本這個常被忽略的維度。本篇接著把契約落地:怎麼在 FastAPI 記下這些事件、哪些欄位不能塞進 Prometheus label、哪些訊號該告警,以及幾個真實事故如何驗證這套判讀框架。

⑦ 讀者 DIY:在 FastAPI 入口記下 request 邊界

接下來的程式是讀者可自行放進 Day 18 的 DIY 或既有服務的最小版本。

本文沒有建立、安裝、啟動或驗證任何 DIY;下面每一段程式碼旁邊,都會多寫一段「這在驗證前面哪個論點」與「跑起來預期看到什麼、容易踩到什麼坑」,讓不打算真的執行 DIY 的讀者,也能看懂這段實作在做什麼、為什麼要這樣寫。

開始前,讀者需要一個可啟動的 FastAPI application,以及一個不含敏感 prompt、完整回答或使用者識別資料的 structured log sink。

為什麼先從 middleware 開始,而不是先寫 AskEvent

一個常見的直覺是先把第⑥節那份完整的 AskEvent 契約寫出來,直接套進 handler。但這裡刻意先從最外層、最簡單的 HTTP middleware 開始,呼應第①節「System SLI 與 AI SLI 分開量」的立場:middleware 量的是最不會出錯的邊界——請求進來、回應出去,即使 handler 內部的 workflow 邏輯還沒寫,也已經能回答「這個 API 通不通、多慢」。先做出最容易做對的一層,再逐步往內補細節事件,比一次到位更不容易半途而廢。

FastAPI 官方的 HTTP middleware 會在 request 進入 path operation 前與 response 回傳前執行;適合量 API request 邊界。FastAPI Middleware 文件

在 app/main.py 加入最小 middleware:

import json
import logging
from time import perf_counter

from fastapi import FastAPI, Request

app = FastAPI()
logger = logging.getLogger("ai_sli")


@app.middleware("http")
async def record_request_boundary(request: Request, call_next):
    started_at = perf_counter()
    request_id = request.headers.get("x-request-id", "generated-in-handler")

    try:
        response = await call_next(request)
    except Exception:
        logger.exception(
            "ask_request_failed",
            extra={
                "request_id": request_id,
                "route": request.url.path,
                "end_to_end_ms": round((perf_counter() - started_at) * 1000, 2),
            },
        )
        raise

    elapsed_ms = round((perf_counter() - started_at) * 1000, 2)
    logger.info(
        "ask_request_finished",
        extra={
            "request_id": request_id,
            "route": request.url.path,
            "http_status": response.status_code,
            "end_to_end_ms": elapsed_ms,
        },
    )
    response.headers["x-request-id"] = request_id
    return response

這段 middleware 量得到 HTTP request 的外框。

它量不到 TTFT,也不知道 retrieval 是否成功。

別急著批評它不夠;那正好說明不同的事件應在不同層記錄。

HTTP middleware 不該猜測 handler 內部發生什麼事。

這段驗證了前面哪個論點:它示範第⑤節「先定義一次回答的邊界」裡最外層的那一段——request_received 與 response_sent。它同時也是一個活的反例:故意只做這一層,讓讀者實際感受到只有這層資訊時能回答什麼(API 通不通、多慢),不能回答什麼(慢在哪一段)。

實際跑起來預期看到什麼:對任何一個已存在的 endpoint 送出請求,log 應該會多一行 ask_request_finished,帶著 request_id、route、http_status、end_to_end_ms。把 endpoint 換成一個人為加了 await asyncio.sleep(2) 的假 handler,end_to_end_ms 應該穩定落在 2000ms 附近,驗證這個 middleware 量到的確實是 handler 實際花的時間。

容易踩到的坑:第一,若用 uvicorn --workers N 啟動多個 process,logger 沒有跨 process 共享狀態——log 各自輸出沒問題,但若想在 middleware 裡做「進行中請求計數」這類 in-memory 統計,每個 worker 會各自維護一份、數字對不起來,得靠 Redis 或 Prometheus 的 multiprocess 模式處理。第二,若 call_next(request) 內部的例外被更內層的 exception handler 攔截並轉成 200,這裡記到的 http_status 會是轉換後的狀態碼,不是原始例外——要區分「優雅降級成 200」與「真的正常完成」,得靠 workflow 層的 outcome 補,http_status 不夠用。

在 workflow 內補上階段事件

以下範例把之前的 AskEvent 放進 handler。

函式名稱的實作由讀者接到自己的 retriever 與 model client;範例先展示事件應在哪一刻寫入。

from time import perf_counter


async def answer_policy_question(question: str) -> dict[str, object]:
    event = new_ask_event()
    event.timing.workflow_started = perf_counter()
    event.technical_status = "running"

    try:
        event.timing.retrieval_started = perf_counter()
        documents = await retrieve_policy_documents(question)
        event.timing.retrieval_finished = perf_counter()
        event.retrieval_count = len(documents)

        if not documents:
            event.outcome = "insufficient_context"
            event.workflow_status = "completed"
            event.quality_status = "not_evaluated"
            event.safety_status = "not_applicable"
            return {
                "answer": "現有文件沒有足夠資訊可以回答這個問題。",
                "request_id": event.request_id,
            }

        event.timing.model_started = perf_counter()
        completion = await call_model(question=question, documents=documents)
        event.timing.generation_finished = perf_counter()

        parsed = parse_answer(completion)
        validate_sources(parsed, documents)

        event.outcome = "completed"
        event.technical_status = "succeeded"
        event.workflow_status = "completed"
        event.quality_status = "not_evaluated"
        event.safety_status = "not_evaluated"
        return {
            "answer": parsed.text,
            "sources": parsed.source_ids,
            "request_id": event.request_id,
        }
    except ProviderTimeoutError as error:
        event.outcome = "technical_failed"
        event.technical_status = "failed"
        event.error_class = type(error).__name__
        raise
    finally:
        event.timing.response_sent = perf_counter()
        logger.info("ask_event", extra=event.to_log_record())

注意 finally。

無論 workflow 成功、資料不足或 provider timeout,事件都要有一個結尾。

否則你只會在 dashboard 看見成功請求,然後誤以為平均延遲很健康。

也注意 quality_status: "not_evaluated"。

它不等於 quality pass。

如果讀者接了第⑥節後半的 AskCost 擴充欄位,這個 finally 區塊也是它該掛進去的位置:response_sent 之後、logger.info 之前補上 event_cost = AskCost(prompt_tokens=completion.usage.prompt_tokens, ...)。放在 finally 而非 try 最後一行,理由跟 response_sent 一樣——即使 completion 因 provider timeout 從未賦值,也該老實記下「沒有 token 用量可回報」,而不是讓 finally 存取不存在的變數,拋出新例外蓋掉原本的錯誤。

Day 19 會處理 quality 與 task success;在那之前,保留未知值。

這段驗證了前面哪個論點:它是第⑥節整份 AskEvent 契約唯一一次被放進「看起來完整」的業務函式裡,直接證明第⑤節那張「區段對照表」不是紙上談兵——retrieval_started/retrieval_finished 包住 retrieval 區段,model_started/generation_finished 包住 generation 區段,中間若有 gap(例如 prompt 組裝耗時),會自然反映在可以事後算出來的空隙裡。

實際跑起來預期看到什麼:把 retrieve_policy_documents 換成回傳空 list 的假函式,應該看到 outcome: "insufficient_context",且 model_started、generation_finished、tool_finished 全部是 None——沒有文件就不該假裝呼叫過模型。把 call_model 換成拋出 ProviderTimeoutError 的假函式,應該看到 outcome: "technical_failed"、generation_finished 同樣是 None。兩種情況下 response_sent 都應該有值,這是 finally 保證的。

容易踩到的坑:最常見的錯誤是把 event.timing.xxx = perf_counter() 寫在 try 區塊「之後」而不是「之前」——例如呼叫 retrieve_policy_documents 之後才記 retrieval_started,量到的其實是「檢索做完、回頭補記」的時間,retrieval_ms 會被系統性低估。另一個錯誤是疊了多個 except 卻忘記每一支都設 event.outcome——AskEvent 預設 outcome: Outcome = "completed",忘記顯式改成 technical_failed 的失敗請求,最後會被 finally 記成 outcome: "completed",成為「看起來正常、實際上失敗了」的幽靈資料,正是「semantic failure 沒有錯誤訊號」在事件記錄層的翻版。

把上面兩種情境(provider timeout、正常完成)實際跑過 to_log_record(),log 裡應該分別看到類似下面兩筆結構完全一致、只有欄位值不同的記錄——這種「結構固定、值不同」正是事件契約存在的意義,值班工程師不用每次都重新理解一筆 log 的形狀:

{
  "request_id": "req_8f2c1a3b9e4d4f6a",
  "trace_id": "trace_8f2c1a3b9e4d4f6a",
  "workflow_name": "policy_qa",
  "workflow_version": "2026-09-18",
  "model_name": "configured-at-deploy-time",
  "outcome": "completed",
  "technical_status": "succeeded",
  "quality_status": "not_evaluated",
  "retrieval_count": 3,
  "queue_delay_ms": 12.4,
  "retrieval_ms": 84.1,
  "ttft_ms": null,
  "generation_ms": 612.7,
  "end_to_end_ms": 731.9
}
{
  "request_id": "req_1d9a7c4e2b8f4a3c",
  "trace_id": "trace_1d9a7c4e2b8f4a3c",
  "workflow_name": "policy_qa",
  "workflow_version": "2026-09-18",
  "model_name": "configured-at-deploy-time",
  "outcome": "technical_failed",
  "technical_status": "failed",
  "error_class": "ProviderTimeoutError",
  "retrieval_count": 3,
  "queue_delay_ms": 15.0,
  "retrieval_ms": 91.3,
  "ttft_ms": null,
  "generation_ms": null,
  "end_to_end_ms": 5108.6
}

第二筆記錄裡 generation_ms 是 null,end_to_end_ms 卻仍然有值——這正是 finally 保證的行為:即使 generation 沒有完成,response_sent 仍然被記錄下來(可能是逾時後回傳的錯誤訊息),端到端時間依然可以算出來,只是它不代表「回答完成的時間」,而是「這筆請求從頭到尾在系統裡待了多久」,兩者在失敗案例裡是不同的意義,這也是為什麼欄位命名不能只有一個籠統的 latency_ms。

串流時怎麼量 TTFT

串流回應不能只在 handler return 時才記第一個 token。

要在 iterator 第一次產生可見內容時落時間點。

下面是概念範例。

from collections.abc import AsyncIterator
from time import perf_counter


async def measured_token_stream(
    upstream: AsyncIterator[str],
    event: AskEvent,
) -> AsyncIterator[str]:
    has_sent_first_token = False

    async for token in upstream:
        if token and not has_sent_first_token:
            event.timing.first_token = perf_counter()
            has_sent_first_token = True
        yield token

    event.timing.generation_finished = perf_counter()

真正接到 StreamingResponse 時,讀者還要確認兩件事。

第一,第一個上游 callback 不一定等於第一個 client 可見 token;中間可能有 buffer、proxy 或輸出格式轉換。

第二,yield dependency 的 teardown 在 streaming response 結束後才執行,長時間 stream 可能意外持有 database connection 或其他資源。FastAPI Streaming Response 文件

因此要在服務端將 TTFT 命名成例如 server_first_token_ms,不要假裝它是瀏覽器端已渲染時間。

若產品真的要量使用者端感受,可再由前端上報 first_rendered_token_at;兩種資料應用同一個 request_id 關聯,而不是硬塞在同一個 timer。

這段驗證了前面哪個論點:它直接對應第⑤節「三個延遲數字,回答三個不同問題」與 Dan Luu 那段「server 端量到的不是使用者感受到的」——measured_token_stream 刻意只在「上游 iterator 第一次產生內容」時打點,而不是在「client 收到」時打點,正是要讓讀者看見 server_first_token_ms 這個命名裡「server」兩個字不是多餘的贅字,而是誠實標註量測邊界的必要提醒。

實際跑起來預期看到什麼:把 upstream 換成一個 async def fake_stream(): yield "a"; await asyncio.sleep(0.1); yield "b" 這樣的假 generator,event.timing.first_token 應該在第一次 yield "a" 附近落下,且與 event.timing.model_started 的差值接近 0(因為假 generator 幾乎沒有延遲);如果在 fake_stream 開頭先 await asyncio.sleep(0.5) 再 yield,這個 0.5 秒應該完整反映在 ttft_ms 裡,而不是被吃掉。

容易踩到的坑:第一,若上游 SDK 拿到完整回應後才切成假的「串流」回傳,first_token 量到的其實是「整個生成都做完後,第一次切片」的時間,TTFT 會跟 generation_finished 幾乎相等——這不是計時邏輯錯了,要往上查 provider client 有沒有做真正的 token-level streaming。第二,若 async for token in upstream 被包在 asyncio.wait_for() 整體逾時裡,逾時觸發時 generation_finished 不會被寫到——與其編造一個看似合理的結束時間,不如老實留 None。

⑧ 不要把所有欄位塞進 Prometheus label

看到前面的 event schema,很自然會想把每個欄位做成 label。

請先停一下。

下面是高 cardinality 的反例:

ask_request_duration_seconds{
  request_id="req_42...",
  prompt="使用者完整問題...",
  model_response="模型完整回答...",
  trace_id="trace_42..."
}

這會讓每一筆 request 都變成一條新的 time series。

原因在 Prometheus 的資料模型:一條 time series 由 metric 名稱加上全部 label 的組合唯一決定。label 值一旦帶進高基數欄位(像 request_id 這種每筆都不同的值),每一筆 request 產生的不是「同一條 series 多一筆資料點」,而是「一條全新的 series」。series 數量直接決定 Prometheus 要在記憶體裡維護多少 head chunk、一次查詢要掃描多少序列;series 數量從數千長到數百萬,通常不是平滑地變慢,而是某個時間點記憶體用量與查詢延遲一起跳增。這種跳增又特別容易撞上 incident,因為 incident 期間 request 量與 request_id 種類本來就在暴增,你需要 Prometheus 反應最快的時候,恰好是它最吃緊的時候。

Prometheus 不需要承受這種痛苦,你也不需要在 incident 時等它慢慢回敬你。

為什麼是「跳增」而不是「漸漸變慢」

這裡值得多解釋一句「為什麼不是平滑變慢」。Prometheus 把每條 active time series 的最近資料點都保存在記憶體裡的 head chunk,series 數量增加就要維護更多 head chunk;只要記憶體還夠用,延遲增幅通常還算溫和。但記憶體是硬上限,一旦逼近上限,作業系統開始頻繁換頁,延遲曲線就從緩升轉成幾乎垂直的跳增——這正是為什麼高 cardinality 問題經常被描述成「昨天都好好的,今天流量一多整個炸掉」,而不是提早幾天就看得到警訊的漸進式劣化。

常見的誤解是以為「只要不對 request_id 這種完全唯一的欄位打 label 就安全」。其實危險的不是單一欄位的基數,而是所有 label 值組合起來的基數:route 20 種、model_route 5 種、outcome 5 種、workflow_version 若一天發布 10 次、保留 30 天有 300 種——相乘後一條 metric 可能瞬間衍生出 20 × 5 × 5 × 300 = 150,000 條 time series,而沒有任何一個欄位單獨看起來「高基數」。這正是為什麼 workflow_version 每個 commit 都不同,仍可能讓 cardinality 持續升高——不是它本身邪惡,是它會和其他維度做乘法。

較穩定的 metrics 維度可以是:

route
workflow_name
workflow_version
response_mode
outcome
model_route
fallback_triggered

即使是這些 label 也要節制。

workflow_version 若每個 commit 都不同,仍可能讓 cardinality 持續升高。

可考慮只保留 release version,完整 commit SHA 留在 log 與 trace。

資料 最適合的位置 原因
request_id、trace_id log / trace 每次請求都不同
prompt、response、retrieved documents 受權限控管的 trace / audit store 可能敏感且量大
outcome、response_mode metric label 值域小,可聚合
TTFT、queue delay、e2e duration histogram / trace 可看分布與單筆路徑
model route、fallback metric label 或 resource attribute 值域需受控
token 數、cost estimate counter / histogram / trace 需明確單位與抽樣策略

這個分層也讓 incident 調查有合理入口:先用 metrics 找到何時與哪種 outcome 變壞,再以 trace 和 log 找到單一失敗路徑。

不是反過來在億萬個 log 內搜尋「慢」。

一個把欄位放對位置的最小範例

把上面的分層原則寫成程式碼,大概會長這樣。這裡用 prometheus-client 示範一個 histogram,只帶低基數 label;高基數欄位則走 to_log_record() 產生的 structured log,兩者用 request_id 事後關聯。

from prometheus_client import Histogram

ASK_REQUEST_DURATION = Histogram(
    "ask_request_duration_seconds",
    "End-to-end duration for /ask requests",
    labelnames=["route", "outcome", "response_mode"],
    buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 20, 30),
)


def record_metrics(record: dict[str, object]) -> None:
    end_to_end_ms = record.get("end_to_end_ms")
    if end_to_end_ms is None:
        return  # 沒有量到就不上報,不要用 0 假裝完成

    ASK_REQUEST_DURATION.labels(
        route="/ask",
        outcome=record["outcome"],
        response_mode=record.get("response_mode", "unknown"),
    ).observe(end_to_end_ms / 1000)

留意 bucket 邊界的選法:這裡刻意在 1 秒與 2 秒之間放了切點,因為第⑤節的對照表顯示聊天介面的 TTFT 門檻常落在這個區間;若只設 (0.5, 5, 50) 這種粗略切法,「1 秒內有沒有開始講話」會被壓進過寬的桶子裡看不出細節。bucket 邊界跟 cardinality 是同一種取捨的兩面:label 太細讓 series 爆炸,bucket 太粗讓分布資訊丟失,都要刻意選,不能用預設值打發。

⑨ 用多種情境測量本身,而不是只測 happy path

在接 Prometheus、OpenTelemetry 或任何 vendor 之前,先讓事件契約通過幾個人工可判讀的案例。

以下表格可以直接變成 DIY 的 fixture 清單。

情境 注入方式 預期事件 不該發生的誤判
正常串流 model 逐 token 回傳 first_token 與 generation_finished 都有值 TTFT 被記成整段 generation
queue 壅塞 暫停 worker 或提高併發 queue_delay_ms 上升 被算成 model latency
無文件可答 retrieval 回空陣列 insufficient_context,非 technical failure 當成 API 500
provider timeout model client 到時失敗 technical_failed 與 error_class 在 finally 前沒有事件
parser 壞掉 回傳不合契約輸出 technical/workflow status 可診斷 HTTP 200 被列為 completed
safety 拒絕 policy engine 拒絕 tool safety_blocked 被當成 model timeout
retrieval 灌入過量文件 fake retriever 回傳 20 篇文件而非平常的 2-3 篇 prompt_tokens 明顯升高、outcome 仍是 completed latency 正常就當作沒事,cost 不會被順便檢查到

這些 fixture 不一定都要真的打到外部模型。

一開始用 fake retriever、fake token stream 和明確 timeout 即可。

先確認事件語意,再接真實 dependency;否則 provider 剛好正常時,你只能驗證 happy path。

用假物件把「情境」變成可重複執行的 fixture

上面那張表格若只停在文件層次,遇到 incident 時仍然只能憑印象猜「當時是不是這種情況」。比較踏實的做法,是把每一列都寫成一個 pytest fixture,讓「provider timeout 時事件契約長什麼樣子」變成一行指令就能重跑的問題,而不是每次 incident 都要重新在腦中模擬一次。

import pytest


class FakeRetriever:
    def __init__(self, documents: list[str] | None = None) -> None:
        self._documents = documents or []

    async def __call__(self, question: str) -> list[str]:
        return self._documents


class FakeModelClient:
    def __init__(self, *, delay: float = 0, raises: Exception | None = None) -> None:
        self._delay = delay
        self._raises = raises

    async def __call__(self, **kwargs: object) -> str:
        await asyncio.sleep(self._delay)
        if self._raises is not None:
            raise self._raises
        return "這是一段假的模型回答。"


@pytest.fixture
def no_context_scenario() -> FakeRetriever:
    return FakeRetriever(documents=[])


@pytest.fixture
def provider_timeout_scenario() -> FakeModelClient:
    return FakeModelClient(raises=ProviderTimeoutError("provider did not respond in time"))


@pytest.fixture
def slow_generation_scenario() -> FakeModelClient:
    return FakeModelClient(delay=3.0)

有了這些 fixture,表格裡的七種情境可以各自對應一個測試函式,斷言 event.outcome、event.timing 裡哪些欄位有值、哪些該維持 None。這批測試不需要連上任何外部服務,跑起來是秒級的,可以放進 CI pipeline,每次改動 handler 邏輯都自動驗證情境還是被正確分類——這是「先確認事件語意,再接真實 dependency」在工程實踐上的具體樣子。

範例:檢查時間欄位而不是只看 print

讀者可為事件契約寫一個小測試。

def test_missing_first_token_stays_unknown() -> None:
    event = new_ask_event()
    event.timing.workflow_started = event.timing.request_received + 0.2
    event.timing.response_sent = event.timing.request_received + 1.2

    record = event.to_log_record()

    assert record["queue_delay_ms"] == 200.0
    assert record["ttft_ms"] is None
    assert record["end_to_end_ms"] == 1200.0

這個測試的價值很小,卻能防止一個常見回歸:有人為了讓 dashboard 少出現空值,把 None 改成 0。

測試通過只能證明事件轉換函式符合這個 fixture。

它不能證明 production 的 TTFT 被正確量到,也不能證明網路端沒有 buffering。

這個證據邊界要寫清楚。

這批測試只佔驗證金字塔的最底層

        ┌─────────────────────┐
        │  production 真實流量  │  ← 只有這一層能證明「使用者實際感受到什麼」
        │  (尚未在本文驗證)    │
        ├─────────────────────┤
        │   staging 端對端重放  │  ← 接真的 retrieval / model,但流量是重放的
        │  (尚未在本文驗證)    │
        ├─────────────────────┤
        │  fixture 情境測試      │  ← 本節做的事:FakeRetriever/FakeModelClient
        │  (六種情境,秒級)    │
        ├─────────────────────┤
        │   單元測試(契約邏輯) │  ← to_log_record()、duration 計算
        └─────────────────────┘

金字塔最底層跑得快、成本低,但只能驗證「程式邏輯有沒有照契約定義的規則走」,證明不了 retrieval 服務真的在 84ms 內回應。往上一層的 staging 重放接近真實 dependency,但流量是人為重放的;只有最上層的 production 真實流量,才會出現 fixture 沒想到的組合,例如某個使用者剛好在 retrieval 慢的同時觸發了 provider timeout。越往上越貼近真實,也越貴、越難重現失敗。本文與對應的 DIY 只做到底下兩層,是刻意的取捨——先把契約定義對,比急著接 production 流量更重要。

⑩ 哪些數字可以告警?哪些只值得調查?

LLM workflow 的每個訊號都可以畫圖,不代表每個訊號都該叫醒人。

建議先分三類。

類型 範例 建議處理
立即影響使用者的技術故障 5xx、provider timeout、queue 無法消化 依 SLO / burn rate 告警
需要 investigation 的品質訊號 citation-valid ratio 降低、insufficient_context 上升 建立 ticket、抽樣評估、比對資料變更
容量與成本訊號 token throughput 下滑、token 使用增加 容量規劃、route 或 prompt 檢討

insufficient_context 比例升高是一個好例子。

它可能代表索引壞了,也可能代表公司剛更新政策、使用者開始問一批新問題,而系統誠實地說不知道。

直接把它當 outage 會製造噪音。

完全忽略它又會錯過 retriever 失效。

比較好的第一步是:和 deploy、retrieval index version、文件更新時間以及 query category 一起看。

Google SRE 對監控的提醒仍然適用:監控必須能帶來可採取的行動,而不是收集所有可收集的資料。Google SRE Workbook:Monitoring

「該不該叫醒人」背後的判斷,其實是一道消耗速度的算式

前面把訊號分成三類,但「立即影響使用者的技術故障」這一類要怎麼決定告警門檻,值得展開一點。單純看瞬間數字(例如「5xx 比例超過 5%」)容易有兩種失衡:門檻設太敏感,尖峰流量下的正常抖動也會觸發;門檻設太保守,真正的故障要拖很久才會被發現。

比較穩健的做法,是不看瞬間值,而看「錯誤預算消耗的速度」——這個概念 Day 20 會正式展開,這裡先用簡化版本說明。假設目標是 99.5%(一個月的錯誤預算是 0.5%),錯誤率瞬間衝到 20%,消耗速度是正常速度的 40 倍,整月預算會在不到一小時內燒光,值得立刻叫醒人。但若錯誤率只從 0.3% 微幅升到 0.6%,燒錢速度只比正常快一點,可能拖上好幾天才燒光,更適合開一張 ticket、白天處理。

消耗速度 = 目前錯誤率 / SLO 允許的錯誤率

消耗速度 40x  → 一小時內燒光整月預算 → page
消耗速度 2x   → 半個月燒光整月預算   → 開 ticket,上班時間處理
消耗速度 0.8x → 預算還在累積          → 不用管

這個思路對 AI SLI 一樣適用,只是要換算對象:把「錯誤率」換成「insufficient_context 比例」或「safety block 比例」,一樣可以問「照這個速度,這個月的品質預算什麼時候會燒光」,而不是只用一條瞬間門檻線來決定該不該緊張。差別在於,technical 故障通常有明確、可驗證的「壞」的定義(5xx 就是壞),品質類訊號則經常需要人工抽樣確認「這批上升是不是真的壞」,所以消耗速度算出來即使很快,也建議先進 investigation,而非自動 page——這正是為什麼前面的分類表把兩者的建議處理方式寫得不一樣。

一個可討論的初始 SLO 草案

以下不是建議直接抄到 production 的門檻。

它是讓團隊開始討論分子、分母與例外的草案。

Service: policy Q&A /ask
Window: rolling 28 days

Technical availability SLI
  numerator: requests with a completed HTTP response and no technical_failed outcome
  denominator: eligible /ask requests, excluding caller-cancelled requests by documented rule

Interactive responsiveness SLI
  numerator: streaming requests whose server_first_token_ms <= agreed threshold
  denominator: eligible streaming /ask requests

Workflow completion diagnostic
  numerator: requests with workflow_status=completed
  denominator: eligible /ask requests

此處刻意沒有填一個看起來很專業的百分比或毫秒數。

沒有 traffic baseline、使用者任務與成本資料之前,任何精確門檻都只是猜測。

Day 20 會把 error budget 放進這條線;屆時才討論「違反多少才要停止變更」會比較合理。

業界不是只有本文在做這種換算:Grafana Labs 剛好在同一個月發了一篇幾乎同款的文章

上面那份草案刻意留白,沒有填任何具體百分比,容易讓人懷疑「這種把 SLO 套進 AI 品質訊號的做法,是不是本系列自己想出來、業界根本沒人這樣做」。剛好在本文擴寫的同一個月,Grafana Labs 發了一篇文章直接示範了同一套換算,值得拿來對照。Grafana Labs:What if your agent's hallucinations had a budget?

那篇文章把 agent 的行為拆成好幾個各自獨立量測的面向——groundedness、fulfillment(有沒有真的完成使用者要求的任務)、toxicity、information leakage、prompt injection resistance,外加 latency 與 cost。跟本文②段的七個 AI SLI 訊號不是同一組欄位,這是預期中的事:本文從「一次 /ask 的時間軸與工具呼叫」切入,Grafana 那篇從「一段對話整體的行為品質」切入,顆粒度不同,卻在做同一件事——把「品質」拆成可以獨立量測、獨立設門檻的訊號,而不是壓成一個籠統的「好不好」。

真正值得引用的,是文章描述語意失敗的那句話,幾乎是本文②段「技術成功不保證語意成功」的逐字迴響:一次回答可以在「800 毫秒內完成、幾乎不花任何 token 成本、拋出零個 error」,同時卻「自信滿滿、文從字順、但完全講錯」。快、便宜、沒有錯誤訊號——三個都綠燈,答案還是可能是錯的。這不是巧合式的用詞雷同,而是兩篇文章從各自的方向撞進同一個事實:只要故障不透過 exception 或高延遲現身,傳統監控就註定看不到它。

文章示範的儀表板,是一個 30 天滾動窗口的 helpfulness SLO,目標訂在 95%,剩下 5% 是這個面向的 error budget。這個數字不是重點,跟本文一樣只是「先讓團隊有東西可以吵」的起點。真正值得借用的,是文章對 error budget 的另一種用法:budget 還沒燒完時,鼓勵團隊拿它去做更大膽的嘗試——換更激進的 prompt、換新模型、放寬某個 guardrail;budget 快燒光時才收斂節奏。這跟 Day 20 要談的傳統 error budget 邏輯(燒光就凍結變更)方向一致,但多了一層「budget 是許可證,不只是煞車皮」的框架,跟前面「消耗速度」的算式互補而非重複。

這篇文章也剛好呼應 Day 19 預告的伏筆:它用 evaluator(另一個 LLM 或 regex 規則)替每輪對話打分,而 evaluator 本身判斷得準不準,是 Day 19 要正面處理的問題——SLO 框架量到的,可能是「agent 表現得好不好」,也可能只是「evaluator 覺得好不好」,兩者不永遠一致。

⑪ Incident 時怎麼讀這組 SLI?

想像 dashboard 在 10:20 開始顯示:end-to-end P95 從 4 秒升到 18 秒。

不要立刻對模型下結論。

照下面順序縮小範圍。

1. 受影響的是全部 route,還是只有 /ask?
2. queue_delay_ms 是否先上升?
3. retrieval_ms、provider wait、generation_ms,哪一段真的變慢?
4. timeout / retry / fallback 是否同時增加?
5. workflow_version、model route、index version 是否剛好變更?
6. technical failure 與 quality signal 是否一起變?

判讀 A:queue delay 上升,TTFT 跟著上升

這通常先查 worker concurrency、queue depth、rate limit 與 admission control。

把更多 timeout 加到 provider client,無法讓排隊中的工作自己跑完。

若 worker 已飽和,retry 甚至可能讓 queue 更長。

判讀 B:queue 正常,retrieval 變慢

先比對 index version、資料庫連線池、embedding dependency 與 query latency。

也要看 retrieval count 是否突然變大。

有時候不是搜尋系統壞了,而是新的 prompt 讓每題都要求更多 context,結果把後面的 model input 一起拖慢。

判讀 C:TTFT 正常,end-to-end 變慢

這代表使用者很快看到開始輸出,慢的區段可能在 generation 長度、tool 或後處理。

查看 output token 分布與 tool count。

如果 response 已經開始 streaming,還要釐清 tool 是否真的在使用者可見答案之後才跑;不同 workflow 的正確設計不同,不能只從一個長尾判斷 bug。

判讀 D:HTTP 200 正常,quality_failed 增加

這不是 latency incident,卻可能更嚴重。

回到 Day 7:技術成功不是語意成功。

此時應保存可安全調查的 trace、版本與 evaluator 結果,將代表案例加入 dataset,再決定 rollback prompt、index 或 model route。

不要為了把 error rate 維持好看而把它藏進 completed。

判讀 E:全部 route 一起變慢,故障點卻離「AI」最遠

真實世界很少乾脆地照著判讀 A 到 D 分類,OpenAI 在 2024 年 12 月 4 日的事故是一個好對照。OpenAI 官方事後報告

當天發生兩波故障。第一波是全域 load balancer 設定錯誤,四分鐘內 100% 的 API 請求收到 HTTP 530,訊號很明確,幾乎不需要判讀。

第二波比較難查。一次 DNS cache 系統升級,觸發了身分驗證(identity)系統與 DNS cache 之間一個原本沒被記錄、也不必要的隱藏依賴,讓需要驗證身分的請求開始卡住,最長延遲到 30 秒,45% 的 API 請求收到 499(client 端等不下去先斷線),持續了 90 分鐘。

從使用者角度看,這 90 分鐘的體感就是「AI 突然變得異常慢」。若只照判讀 A 到 D 的直覺排查,工程師很容易先查 model provider——但真正故障點在鏈路最前面,離 retrieval、generation 都很遠的身分驗證與 DNS 這一層。

判讀線索有兩個:故障只影響需要身分驗證的請求而非隨機分布,代表問題在 admission 這段;45% 的 499 與 30 秒延遲同時出現,代表 client 端主動放棄連線,不是被正常排隊擋住——這正是「timeout / retry 是否同時增加」要問的問題。

這也印證事件契約要把 admission、queue_delay_ms、provider_wait 分開記錄的價值:分開記錄才有機會在第一輪就被指到正確方向;若都算進同一個 latency_ms,很容易被誤診成「provider 變慢」,在錯的地方查大半天。

判讀 F:平均看起來正常,只有 P99 在飆

還有一種情況跟前面五種性質不太一樣:dashboard 上的平均 latency、甚至 P50 都風平浪靜,只有 P99 明顯拉高,且集中在 retrieval_ms 或 tool_ms。這值得回頭想第⑤節扇出的討論——若 retrieval 平行查詢多個 shard、tool 平行呼叫多個 API,《The Tail at Scale》給過數學解釋:平行數量夠多,「等全部回來」踩到慢 leaf 的機率會被系統性放大,而這個放大效應天生只出現在尾端百分位,不會反映在平均值或 P50。

判讀第一步,是問「扇出的 leaf 數量最近有沒有變多」——向量索引是否增加了 shard、workflow 呼叫的 tool 數量是否變多。若 leaf 數量沒變,才照一般邏輯查某個特定 leaf 是否本身變慢。分辨這兩種情況很重要:前者是架構層面的取捨,後者才是單點故障,適合直接找到變慢的元件修。

把六種判讀收進同一張決策圖

六種判讀分別回答不同的問題,實際 on-call 時很難記得先問哪一題。下面這張圖把它們串成一條可以照著走的路徑,起點永遠是那六個排查步驟裡的第一步:

                end-to-end P95 突然升高
                         │
           ┌─────────────┴─────────────┐
           │ 只有 /ask 慢,其他 route 正常? │
           └─────────────┬─────────────┘
              是│                  │否 → 判讀 E(故障點離 AI 最遠)
                 ▼
        queue_delay_ms 是否先上升?
           是│              │否
             ▼                ▼
        判讀 A(worker/     retrieval_ms 是否變慢?
        admission 層)        是│           │否
                                ▼             ▼
                          判讀 B(index/   TTFT 是否也正常?
                          embedding 層)    是│        │否
                                             ▼          ▼
                                       判讀 C(generation/  判讀 F(只有
                                       tool/後處理層)      P99 在飆,查
                                                            扇出 leaf 數)
                    另一條並行檢查:
                    HTTP 狀態正常,quality_failed 增加?→ 判讀 D(語意層,不是 latency incident)

這張圖不是要取代判斷力,六個判讀底下實際排查時仍然要看版本變更、依賴狀態這些細節;它的作用是縮小「一開始該往哪裡看」的猜測範圍,把原本可能同時查三四個方向的 on-call,收斂成一條可以循序驗證的路徑。

判讀 G:時好時壞、看起來像被攻擊,其實是設定檔悄悄變了形狀

上面六種判讀有一個共同的隱藏假設:症狀是持續的——P95 持續偏高,或 quality_failed 持續增加,只要盯著同一張圖,趨勢不會自己反覆橫跳。但真實世界還有一種更難纏的症狀形狀:系統每隔幾分鐘自己好一次、又壞一次,這種週期性起伏本身就會誤導判讀方向,把工程師帶去查一個完全錯誤的假設。Cloudflare 在 2025 年 11 月 18 日的一次全球性中斷,是這種症狀形狀的乾淨案例。Cloudflare 官方事後報告

故障起點離「AI」和「latency」都很遠:一次資料庫權限變更,把 ClickHouse 對某些 system table 原本隱性允許的存取,改成需要顯性授權。這個變更立意良善(安全性強化),卻讓一段產生 Bot Management「feature file」的內部查詢,開始從 default 與 r0 兩個 database 同時拿回重複欄位——檔案筆數從平常約 120 筆暴增到超過 200 筆。這段查詢原本悄悄假設「metadata 回應裡只有一種 schema」,這個假設在權限變更後被打破,系統其他部分完全不知情。

權限變更(fault)
  → 查詢回傳重複欄位,檔案變成 200+ 筆(error,此時對外仍正常)
  → Bot Management 硬編碼 200 個 feature 的記憶體上限被超過
  → Rust 程式碼 panic:unwrap() on an Err value
  → 核心 proxy(FL2)大量回傳 HTTP 5xx(failure,使用者開始有感)

這條鏈路正是本文一路強調的「Fault → Error → Failure」——多拿了幾欄資料的當下,系統還沒有任何對外可見異常,要等到硬編碼上限被踩過、panic 被觸發,才變成使用者看得到的東西。真正讓事故難查的是接下來的事:產生設定檔的 ClickHouse 節點分批更新,好壞檔案交替被拉取,服務每隔約五分鐘自己恢復一次、又壞一次,這種規律讓工程師一度懷疑是 DDoS(同時 Cloudflare 自家 status page 也碰巧掛掉,進一步誤導判斷),直到方向轉回「回滾 Bot Management 設定檔」才真正解決——從故障發生算起,將近六個小時。

時間(UTC) 事件
11:05 資料庫權限變更部署
11:20 大量網路故障開始,工程師一度懷疑是 DDoS
13:37 團隊確認方向:回滾 Bot Management 設定檔
14:30 舊版 feature file 全網部署完成,主要影響解除
17:06 所有服務完全恢復

放回本文的判讀框架,這個案例帶來兩個教訓。第一,「症狀有沒有週期性起伏」值得先問——dashboard 上的 5xx 比例若呈規律鋸齒狀而非單調上升,暗示「某個東西正在分批推播、分批生效」,該優先查最近的分階段 rollout,而不是先假設外部攻擊。第二,「系統自己覺得好轉」不代表故障已解除——這呼應本文①段「基礎設施健康 ≠ 使用者體驗健康」的第三種樣貌:連「系統覺得自己健康」這個訊號本身都在騙人,短暫的恢復窗口反而把排查方向帶偏。

Cloudflare 自己的結論是:「要像對待使用者輸入一樣,強化對自己產生的設定檔的驗證」——真正的根因不是查詢寫錯,是內部系統產生的資料悄悄變了形狀,卻沒有 schema 層的檢查攔下來。這跟本文⑥段「事件契約要有版本、變更要能追溯」是同一種疾病的兩種病灶:一個發生在可觀測性資料,一個發生在功能設定資料,共同病理都是「把內部產生的資料當成永遠可信」。事後報告另提到「開放更多 global kill switch」,也呼應判讀 A:模組沒有獨立的緊急關閉開關,故障只能靠回滾整條發布管線止血,處理時間自然被拉長。

⑫ 今日驗收清單

本文的實作段落由讀者自行執行;下列清單是可重跑的驗收條件,不是本文已完成的實測聲明。

  • [ ] 每個 /ask request 都會產生可關聯的 request_id。
  • [ ] 每筆 workflow event 都包含 workflow_version、model 與 retrieval index 的版本資訊。
  • [ ] request_received、workflow_started、retrieval_finished 與 response_sent 的定義已寫在程式碼或 README。
  • [ ] streaming workflow 會在第一個可見 token 的伺服器端事件記下 TTFT。
  • [ ] 沒有 TTFT 的 workflow 會寫 null 或明確模式,而不是 0。
  • [ ] technical_failed、insufficient_context、quality_failed 與 safety_blocked 沒有被塞進同一個 success label。
  • [ ] request ID、prompt、完整回答與 trace ID 沒有被當作 Prometheus label。
  • [ ] 正常、queue 壅塞、無 context 與 provider timeout 至少各有一個 fixture。
  • [ ] 測試會驗證缺漏時間維持未知值。
  • [ ] dashboard 的 TTFT 分母只包含定義為 streaming 的 request。
  • [ ] 任何 SLO 門檻都列出分子、分母、窗口與排除規則。
  • [ ] 文章或 README 清楚標註哪些是 local fixture 證據,哪些尚未在 production 驗證。
  • [ ] 事件契約的欄位變更有版本號可追溯,不是直接覆蓋舊定義(見 ⑥ 節「欄位契約要有版本」)。
  • [ ] Prometheus label 組合的基數有被估算過(label 數相乘,不是只檢查單一欄位),而不是等 /metrics 變慢才回頭查。
  • [ ] 高扇出(fan-out)的路徑(多分片 retrieval、平行 tool call)有各自的內部逾時,不是全部共用外層那一個。
  • [ ] 系統內部自己產生的設定或索引檔案(而非只有外部使用者輸入)也有 schema 驗證,不會因為來源是「自己人」就跳過檢查。
  • [ ] token 用量與估算成本有獨立於 latency 之外的追蹤機制,不會被「latency 正常、outcome 是 completed」直接掩蓋。

最後五項是這兩輪擴寫新加的,理由很直接:前九項驗證的是「事件契約有沒有定義好」,但契約定義好之後,還有事情會在生產環境咬人——契約會改版而沒人追蹤是哪個版本在跑,label 組合會在沒人注意的時候悄悄超過記憶體預算,扇出架構會在個別元件都健康的狀況下讓 P99 自己長出一條原本不存在的尾巴,內部系統自己產生的資料(設定檔、索引檔案)一旦悄悄變了形狀,也可能像 Cloudflare 2025 年 11 月的那次事故一樣繞過所有「只驗證外部輸入」的防線直接炸穿系統,而 token 用量與成本這個維度,則可能在前面每一項都打勾的情況下獨自失控。這五項不是「做完事件契約就自動满足」的副產品,是需要額外檢查的獨立條件。

⑬ 今天帶走的不是更多 metrics,而是更少猜測

一個「LLM 很慢」的 ticket,可能是 queue、retrieval、provider、輸出長度、tool、proxy buffer,也可能根本不是 latency 問題,而是錯答被使用者重新問了三次。

把 request 拆成可定義的事件,沒有魔法地讓系統變快。

它讓下一次變慢時,團隊不必從一張平均 latency 圖開始猜。

這也是為什麼本文花不少篇幅講 Dean 與 Barroso 那篇論文——扇出架構下,個別元件平均延遲再正常,只要並行呼叫數量夠多,「至少一個慢」發生的機率就會被放大到接近必然。這不會因為多加幾個 metrics 而自動現形;只有先把 retrieval、tool 這些扇出點的時間戳跟其他階段分開記錄,才有機會在 dashboard 上看到那條被平均值蓋住的尾巴。這正是①到⑫節一路在建立的事:不是多裝儀表,是讓每個時間點各自對應一個可指認、可歸責的系統元件。

Day 19 會跨過更麻煩的一條線:quality、safety 與 task success 要怎麼量,才不會把 evaluator 分數當成真相。

這次擴寫加進來的幾件事,其實都指向同一個提醒。

Cloudflare 那次事故提醒的是:連「系統覺得自己健康」這個訊號本身,都可能是假的——週期性的短暫恢復,把工程師的判斷帶往完全錯誤的方向長達數小時。

Grafana Labs 那篇文章提醒的是:本文談的這套「把品質拆成獨立訊號、套上 error budget」的做法,不是本系列自己想像出來的骨架,業界正在往同一個方向收斂。

AskCost 提醒的是:latency 綠燈、outcome 是 completed,不代表這次回答便宜——成本可以在所有其他訊號都正常的情況下,自己悄悄超支。

tool call 的語意驗證與 safety block 的分級提醒的是:一個看似乾淨的枚舉值(outcome、safety_blocked)底下,經常藏著好幾種需要不同處理速度的情境,攤平成單一狀態,反而會讓真正急迫的那一種被稀釋掉。

這幾件事表面上分散在不同段落,實際上都是同一個核心立場的不同案例:任何一個看起來「正常」的訊號,都值得先問一句「正常」量的到底是什麼、又量不到什麼。這正是本文標題那句「把慢拆到能修的位置」真正想強調的事——拆解的目的不是產生更多圖表,是讓每一次「正常」的宣告,背後都有一個明確的、可以被戳破的定義。

Day 19 預告

Day 18 把一次 AI request 的等待與失敗拆成可追查的事件;下一篇會把「回答有沒有真的幫使用者完成事情」變成可抽樣、可回歸、但不假裝絕對客觀的 quality 與 safety 訊號。

延伸閱讀


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 18(上)|LLM Application SLI:把「慢」拆到能修的位置
下一篇
Day 19(上)|Task Success、Quality、Safety SLO:不能只由模型幫自己打分
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言