FDE 面試指南 Part 52:百萬級 Agent Tool-Calling 的全域非同步並行優化與扇出控制

大多數工程師遇到「Agent 要並行呼叫 15 個 API」時,第一反應是 asyncio.gather(),然後假設「全部回來再合併」。
真正的 Staff FDE 知道:gather 是把所有雞蛋放進同一個計時炸彈
正確答案不是「更快地等待」,而是動態熔斷、投機複製、強制截止、局部渲染——在 1.5 秒內交出 80% 的答案,比等 30 秒的「完美答案」更有價值。
系統設計的成熟度,體現在你如何優雅地處理你控制不了的那 20%


面試情境

面試官:「你負責一個 AI 理財 Agent 的後端架構。用戶問:『幫我分析我持有的 15 檔美股今天的技術指標。』Agent 需要並行呼叫 15 次外部股票 Data API。請問:(1)如果單純用 asyncio.gather() 並行發起,你能預期哪些生產環境問題?(2)你會如何設計一個能應對 API 超時、Rate Limit、部分失敗的進階工具執行引擎?請從架構、程式碼模式、降級策略三個維度說明。」


一、核心問題:為什麼 gather() 在生產環境是炸彈

1.1 問題的表面現象

理財 Agent 接到用戶指令:「分析我持有的 AAPL、TSLA、NVDA… 等 15 檔美股的技術指標」。

Agent 的工具調用計畫很清楚:針對每一個股票代號,呼叫一次 get_stock_indicators(ticker) ——這是 15 次獨立的外部 HTTP 請求。

最直覺的實作是:

1results = await asyncio.gather(
2    *[get_stock_indicators(ticker) for ticker in tickers]
3)

順序執行的基準延遲:15 calls × 平均 2s per call = 30 秒。用戶體驗直接崩潰。

asyncio.gather() 並行理論延遲:最慢那一個請求的時間 = 如果有一個卡住 10 秒,整體就是 10 秒。

1.2 gather() 在生產環境的三個致命缺陷

缺陷一:木桶短板效應

asyncio.gather() 預設行為:等待所有協程完成(或任一拋出異常)。若外部 API 有 3 個因 Rate Limit 卡在 15 秒逾時,整個 gather 就被這 3 個卡死。12 個已完成的結果在記憶體中空等,對用戶毫無價值。

缺陷二:無熔斷保護

gather() 本身不知道「某個 API 連續失敗了幾次」。一個已知死亡的 API 節點,gather 下一次調用還是會傻乎乎地等它的完整 timeout。

缺陷三:無法局部渲染

gather() 是全有或全無。12 個股票數據早已就緒,前端卻必須等所有 15 個才能渲染——或者一個異常就全部爆掉。

1.3 為什麼這個問題在百萬級場景下被放大

當每天有 10 萬次這類查詢時:

  • 外部 API 的 P99 延遲通常是 P50 的 5–10 倍
  • Rate Limit 觸發頻率與並發請求數成正比
  • 一個 API 節點的瞬時抖動,在 gather() 架構下,會傳染給所有等待它的用戶請求

核心張力:我們需要並行(降低延遲),又需要防禦(不被最慢的拖垮),還需要局部可用(有啥用啥)。這三個需求在 asyncio.gather() 裡天然衝突。


二、三個演進階段

╔══ Phase 1(POC / < 10K 用戶)══╗

目標:先讓 Agent 能並行跑起來,比順序執行快即可。

┌────────────────────────────────────────┐
│  LangGraph Agent                       │
│  (生成 15 個 ToolCall 物件)            │
└──────────────┬─────────────────────────┘
               │  15 parallel calls
               ▼
┌────────────────────────────────────────┐
│  asyncio.gather(*tool_calls,           │
│      return_exceptions=True)           │
│                                        │
│  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐  │
│  │AAPL  │ │TSLA  │ │NVDA  │ │... 12│  │
│  │ API  │ │ API  │ │ API  │ │ APIs │  │
│  └──────┘ └──────┘ └──────┘ └──────┘  │
└──────────────┬─────────────────────────┘
               │  等最慢的那一個
               ▼
┌────────────────────────────────────────┐
│  彙整所有結果 → 回傳給 LLM             │
└────────────────────────────────────────┘

Phase 1 實作重點

  • 使用 return_exceptions=True,讓單個失敗不爆炸整個 gather
  • 對每個 call 加獨立 asyncio.wait_for(coro, timeout=5.0)
  • 失敗的直接傳回 None,LLM 提示中說明「部分數據不可用」

成本 / 複雜度

  • 開發成本:2 天
  • 基礎設施:單台 Cloud Run 服務,無額外依賴
  • 延遲改善:30s → 約 5s(最慢的 API 5 秒逾時)
  • 殘留問題:沒有熔斷(已死 API 每次都等 5 秒);沒有投機執行;前端等全部完成才渲染

╔══ Phase 2(MVP / 10K–200K 用戶)══╗

目標:加入 Circuit Breaker 與 asyncio.wait() 替代 gather,實現真正的部分結果回傳。

┌───────────────────────────────────────────────────┐
│  LangGraph Agent (Cloud Run)                      │
│  發出 15 個 ToolCall Spec                         │
└────────────────────┬──────────────────────────────┘
                     │
                     ▼
┌───────────────────────────────────────────────────┐
│  Tool Execution Engine (Cloud Run Job)            │
│                                                   │
│  ┌─────────────────────────────────────────────┐  │
│  │  Circuit Breaker Registry                   │  │
│  │  (per-API state: CLOSED / OPEN / HALF-OPEN) │  │
│  └──────────────────┬──────────────────────────┘  │
│                     │  跳過 OPEN 狀態的 API        │
│                     ▼                             │
│  asyncio.wait(tasks, timeout=5.0,                 │
│               return_when=FIRST_EXCEPTION)        │
│                                                   │
│  ┌──────┐ ┌──────┐ ┌──────┐  ┌──────────────┐    │
│  │AAPL  │ │TSLA  │ │NVDA  │  │[OPEN: 跳過]  │    │
│  └──────┘ └──────┘ └──────┘  └──────────────┘    │
└────────────────────┬──────────────────────────────┘
                     │  done, pending = asyncio.wait(...)
                     ▼
┌───────────────────────────────────────────────────┐
│  取 done 集合的結果 + 標記 pending 為 timeout      │
│  → 組裝 PartialResult JSON → 回傳給 LLM           │
└───────────────────────────────────────────────────┘

Phase 2 新增組件

組件說明vs Phase 1
Circuit Breaker Registry追蹤每個 API 的失敗次數與狀態Phase 1 無此概念
asyncio.wait()可拿到 done/pending 兩個集合,靈活處理替代 gather
PartialResult 協議LLM prompt 中有標準的「缺失說明」格式Phase 1 靠 None 混搭
Structured Logging每個 tool call 記錄 latency、statusPhase 1 靠 print

成本 / 複雜度

  • 開發成本:1 週
  • 基礎設施:Cloud Memorystore Redis(Circuit Breaker 狀態共享,多副本 Cloud Run)
  • 延遲:約 5s → 約 2s(Circuit Breaker 跳過已知壞節點,不再傻等)
  • 殘留問題:無投機執行(抖動時還是要等 timeout);無前端 SSE 即時推送;Fan-out 無上限

╔══ Phase 3(Scale / 200K–1M+ 用戶)══╗

目標:完整的防禦性扇出引擎——Speculative Execution + Hard Deadline + Partial Rendering + Fan-out 熔斷器。

┌──────────────────────────────────────────────────────────────────┐
│  LangGraph Agent (GKE / Cloud Run)                               │
│  解析用戶意圖 → 生成 ToolCallPlan (15 個 ToolCall Spec)          │
└──────────────────────────┬───────────────────────────────────────┘
                           │  gRPC / HTTP2
                           ▼
┌──────────────────────────────────────────────────────────────────┐
│  Tool Execution Gateway (Cloud Run — 獨立微服務)                 │
│                                                                  │
│  ┌──────────────────────────────────────────────────────────┐    │
│  │  Fan-Out Controller                                      │    │
│  │  max_concurrent=20 → 超過進 asyncio.Queue               │    │
│  │  Hard Deadline Timer: T=0 開始計時,T=1500ms 強制截止   │    │
│  └───────────────────────┬──────────────────────────────────┘    │
│                          │ 並發發起 15 個 Task                   │
│          ┌───────────────┼────────────────────┐                  │
│          ▼               ▼                    ▼                  │
│  ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐  │
│  │ AAPL Task    │ │ TSLA Task    │ │ NVDA Task                │  │
│  │ (回傳: 200ms)│ │ (回傳: 350ms)│ │ (T=800ms 未回 Header)   │  │
│  └──────┬───────┘ └──────┬───────┘ └────────────┬─────────────┘  │
│         │                │                      │ 觸發            │
│         │                │              ┌───────▼──────────────┐  │
│         │                │              │ Speculative Request  │  │
│         │                │              │ (備用節點 Race)      │  │
│         │                │              └───────┬──────────────┘  │
│         │                │                      │ 誰先回傳用誰    │
│         ▼                ▼                      ▼                 │
│  ┌──────────────────────────────────────────────────────────┐    │
│  │  Result Aggregator                                       │    │
│  │  T=1500ms → 強制截止剩餘請求                             │    │
│  │  已完成: 12 檔  |  超時: TSLA, NVDA, AMD               │    │
│  └───────────────────────┬──────────────────────────────────┘    │
└──────────────────────────┼───────────────────────────────────────┘
                           │
              ┌────────────┴─────────────────┐
              ▼                              ▼
┌─────────────────────────┐   ┌─────────────────────────────────┐
│  SSE Stream             │   │  LLM (Gemini / Vertex AI)       │
│  (前端即時推送          │   │  接收 PartialResult JSON:        │
│   已完成結果)           │   │  Warning: TSLA,NVDA,AMD timeout │
│                         │   │  → 仍可產出 12 檔分析報告       │
└─────────────────────────┘   └─────────────────────────────────┘

Phase 3 新增組件

組件說明vs Phase 2
Speculative ExecutionT+800ms 未收到 header → 發起複製請求賽跑Phase 2 靠 Circuit Breaker 跳過,但抖動仍等
Hard Deadline (1500ms)asyncio.wait(timeout=1.5),強制截止剩餘Phase 2 timeout=5.0,UX 較差
Fan-Out Semaphoreasyncio.Semaphore(20),超過進隊列Phase 2 無上限,可能打爆下游
SSE Partial RenderingCloud Run → 前端 SSE 即時推送每個完成的股票Phase 2 全有或全無
Speculative Pool備用節點池,接受 Speculative RequestPhase 2 無此設計

成本 / 複雜度

  • 開發成本:3 週
  • 基礎設施:Cloud Run (Gateway) + Redis (CB 狀態) + Pub/Sub (非同步回調) + SSE 推送層
  • 月成本增量:約 $200–400/月(Redis + 額外 Cloud Run 費用)
  • 延遲:從最差 30s(順序)→ P50: 800ms,P99: 1.5s(硬截止)
  • 殘留問題:Speculative Execution 增加 API 呼叫成本(最多 2× 某些請求);備用節點池需維運

三、Speculative Execution 實作深度解析

3.1 核心思路:用冗餘換延遲

Speculative Execution 的概念來自 CPU 分支預測與 Bigtable 論文中的「Hedged Request」。核心思路是:

與其等一個慢節點,不如同時多問一個,誰先回答就用誰的。

關鍵參數設定:

  • 800ms 觸發點:外部股票 API 的 P50 延遲約 300ms,P90 約 600ms。800ms 是「很可能有問題」的判斷閾值。在此之前不觸發(避免浪費 API 配額)。
  • 備用節點:可以是同一 API 的另一個 Region Endpoint,或是備用的數據供應商(如從 Provider A 切到 Provider B)。

3.2 Python asyncio 實作模式

 1import asyncio
 2from typing import Optional
 3
 4async def speculative_tool_call(
 5    primary_coro,
 6    fallback_coro_factory,
 7    speculative_threshold_ms: float = 800.0,
 8) -> dict:
 9    """
10    在 speculative_threshold_ms 後,若 primary 未完成,
11    同時發起 fallback,兩者賽跑,取先回傳者。
12    """
13    primary_task = asyncio.create_task(primary_coro)
14    fallback_task: Optional[asyncio.Task] = None
15
16    try:
17        # 等待 primary,最多等 speculative_threshold_ms
18        done, pending = await asyncio.wait(
19            {primary_task},
20            timeout=speculative_threshold_ms / 1000.0
21        )
22
23        if primary_task in done:
24            # primary 在閾值內完成,直接返回
25            return primary_task.result()
26
27        # primary 超過閾值,發起 fallback 賽跑
28        fallback_task = asyncio.create_task(fallback_coro_factory())
29
30        race_done, race_pending = await asyncio.wait(
31            {primary_task, fallback_task},
32            return_when=asyncio.FIRST_COMPLETED
33        )
34
35        winner = next(iter(race_done))
36        # 取消輸家
37        for loser in race_pending:
38            loser.cancel()
39
40        return winner.result()
41
42    except asyncio.CancelledError:
43        primary_task.cancel()
44        if fallback_task:
45            fallback_task.cancel()
46        raise

3.3 為什麼用 asyncio.wait() 而非 asyncio.gather()

這是面試中高頻考點,差異如下:

維度asyncio.gather()asyncio.wait()
異常處理預設第一個異常立刻傳播,終止所有異常存在 task 物件中,可選擇性處理
部分結果無法在完成前取得中間結果done/pending 分開,隨時可取 done 的結果
取消控制return_exceptions=True 才不爆炸天然支援對 pending 集合個別 cancel
超時語意asyncio.wait_for(gather(…)) 超時會取消全部timeout 參數回傳當下 done/pending,不取消
適用場景簡單的「全部成功才繼續」生產級「部分成功就繼續,處理剩餘」

核心差異asyncio.wait() 超時後返回的 pending set 中的 task 仍在運行,你可以選擇繼續等、取消、或忽略。這正是 Hard Deadline + Partial Result 的基礎。

3.4 成本分析

Speculative Execution 的代價是增加 API 呼叫次數:

  • 觸發投機的比例:假設 P90 = 600ms,P95 = 1000ms,則約有 5% 的請求會觸發投機
  • 15 個 tool calls,預期觸發投機:0.75 個(< 1 個)
  • 額外 API 費用:若每次 API 呼叫 $0.001,15 calls 正常費用 $0.015,投機後約 $0.0158(+5%)

結論:5% 的成本增加換來 P95 延遲從 1000ms 降至 800ms,ROI 極高。


四、Circuit Breaker 狀態機設計

4.1 三態狀態機

                  連續失敗 ≥ 3 次
    ┌─────────────────────────────────────┐
    │                                     ▼
┌───┴────┐    所有請求正常          ┌──────────┐
│ CLOSED │◀─────────────────────────│ HALF-OPEN│
│(正常)  │                          │(試探)    │
└───┬────┘                          └──────┬───┘
    │                                      │
    │ 連續失敗 ≥ 3 次                       │ 單次請求失敗
    ▼                                      │
┌──────────┐   等待 30 秒後自動切換         │
│  OPEN    │──────────────────────────────▶│
│(熔斷)    │                               
└──────────┘   直接返回 fallback,不發起請求

4.2 Redis-backed Circuit Breaker(多副本共享狀態)

當 Tool Execution Gateway 有多個 Cloud Run 副本時,Circuit Breaker 狀態必須在 Redis 中共享,避免每個副本各自計算失敗次數、無法正確熔斷。

關鍵 Redis 資料結構:

KEY: cb:stock_api:{provider}:{ticker}
HASH:
  state: "CLOSED" | "OPEN" | "HALF_OPEN"
  failure_count: 0
  last_failure_ts: <unix_timestamp>
  open_until_ts: <unix_timestamp>  # OPEN 狀態到期時間
TTL: 300s  # 5 分鐘不活躍自動清除

狀態轉換規則

  • CLOSED → OPEN:failure_count >= 3(在 60 秒滑動視窗內)
  • OPEN → HALF_OPEN:current_ts > open_until_ts(30 秒後自動)
  • HALF_OPEN → CLOSED:下一次請求成功
  • HALF_OPEN → OPEN:下一次請求失敗,重置 30 秒計時

4.3 Circuit Breaker 對延遲的影響

假設某個 API Provider 的節點發生故障:

狀態行為對用戶的延遲影響
CLOSED(正常)正常發起請求正常延遲 300ms
CLOSED(抖動中)等待超時 5s最差 5000ms
OPEN(熔斷後)立即返回 fallback,不等待0ms(直接跳過)
HALF_OPEN(試探)發起一次探測請求視該次請求而定

熔斷器在 Phase 2 將「卡住 5 秒等已知壞節點」的場景消除,這是從 5s 降到 2s 的主要貢獻。


五、Hard Deadline 與 Graceful Degradation

5.1 1500ms 截止線設計

Hard Deadline 是整個系統最重要的 UX 承諾:無論發生什麼,1500ms 後用戶一定能看到回應

 1async def execute_tool_fanout(
 2    tool_calls: list[ToolCallSpec],
 3    hard_deadline_ms: float = 1500.0,
 4    speculative_threshold_ms: float = 800.0,
 5    max_concurrent: int = 20,
 6) -> PartialResult:
 7
 8    semaphore = asyncio.Semaphore(max_concurrent)
 9
10    async def bounded_call(spec):
11        async with semaphore:
12            return await speculative_tool_call(
13                primary_coro=invoke_tool(spec, node="primary"),
14                fallback_coro_factory=lambda: invoke_tool(spec, node="backup"),
15                speculative_threshold_ms=speculative_threshold_ms,
16            )
17
18    tasks = {
19        asyncio.create_task(bounded_call(spec)): spec
20        for spec in tool_calls
21    }
22
23    # Hard Deadline:等待最多 1500ms
24    done, pending = await asyncio.wait(
25        tasks.keys(),
26        timeout=hard_deadline_ms / 1000.0
27    )
28
29    # 強制取消所有未完成的請求
30    timeout_specs = []
31    for task in pending:
32        task.cancel()
33        timeout_specs.append(tasks[task])
34
35    # 彙整已完成的結果
36    completed_results = []
37    error_specs = []
38    for task in done:
39        try:
40            completed_results.append(task.result())
41        except Exception:
42            error_specs.append(tasks[task])
43
44    return PartialResult(
45        results=completed_results,
46        timeout_tickers=[s.ticker for s in timeout_specs],
47        error_tickers=[s.ticker for s in error_specs],
48        total_elapsed_ms=...,
49    )

5.2 PartialResult 如何傳給 LLM

將部分結果包裝成 LLM 能理解的系統提示格式:

[系統訊息補充]
注意:以下分析基於 12 檔股票的數據。
以下股票因 API 超時(>1500ms)無法取得數據:TSLA, NVDA, AMD。
請在分析報告中明確說明這 3 檔股票的數據暫時缺失,並根據現有 12 檔數據提供最佳分析。

LLM(Gemini)接收此提示後,能夠:

  1. 在報告開頭說明數據缺失的股票
  2. 根據 12 檔股票提供完整的技術分析
  3. 建議用戶稍後重新查詢缺失的 3 檔股票

這比「等 30 秒後回傳 0 個結果」或「等 30 秒後回傳 15 個結果」都要好得多。

5.3 前端 SSE Partial Rendering

前端不需要等 1500ms 截止才能看到數據。透過 Server-Sent Events,每完成一個股票的數據就立即推送:

事件流時序:
T=0ms    → SSE 連線建立,前端顯示「載入中...」
T=180ms  → SSE event: {ticker: "AAPL", data: {...}} → 前端渲染 AAPL 卡片
T=250ms  → SSE event: {ticker: "MSFT", data: {...}} → 前端渲染 MSFT 卡片
T=380ms  → SSE event: {ticker: "AMZN", data: {...}} → 前端渲染 AMZN 卡片
...
T=1200ms → SSE event: {ticker: "GOOG", data: {...}} → 前端渲染第 12 張卡片
T=1500ms → SSE event: {type: "deadline", timeout: ["TSLA","NVDA","AMD"]}
T=1500ms → SSE event: {type: "complete", llm_analysis: "..."}
T=1500ms → SSE 連線關閉

用戶實際感知:前 200ms 就看到第一個結果出現,而非盯著空白等 30 秒。


六、Fan-Out Semaphore 與背壓控制

6.1 為什麼要限制並發數

即使在 asyncio 中,「並發 15 個請求」聽起來不多,但在百萬級場景下:

  • 每秒 1000 個用戶發起此類查詢
  • 每次查詢 15 個 tool calls
  • 瞬時並發 HTTP 請求數:1000 × 15 = 15,000 個同時在飛的 HTTP 請求

下游 API Provider 的 Rate Limit 通常以「每秒 X 個請求」計算。若每秒 15,000 個請求打向同一個 API,Rate Limit 會立刻觸發。

6.2 Fan-Out Semaphore 設計

┌─────────────────────────────────────────────┐
│  Fan-Out Controller                          │
│                                             │
│  max_concurrent = 20                        │
│  ┌───────────────────────────────────────┐  │
│  │  asyncio.Semaphore(20)               │  │
│  │                                       │  │
│  │  Running:  [1][2][3]...[20]          │  │
│  │  Waiting:  [21][22][23]...           │  │  ← asyncio.Queue
│  └───────────────────────────────────────┘  │
│                                             │
│  全域 Rate Limit: 200 req/s                 │
│  (Token Bucket in Redis)                    │
└─────────────────────────────────────────────┘

兩層限流

  1. 本地 Semaphore(asyncio.Semaphore):限制單個 Gateway 實例的並發 HTTP 請求數,防止連接池耗盡。建議值:20–50。
  2. 全域 Rate Limit(Redis Token Bucket):跨多個 Gateway 實例,控制對單個外部 API Provider 的總請求率,防止觸發 Rate Limit。

6.3 背壓傳遞

當 Fan-Out Semaphore 滿了,新進來的 tool calls 需要排隊等待。這個等待時間要算入 Hard Deadline 的計時中:

T=0ms   → 15 個 ToolCall 進入 Controller
T=0ms   → 前 20 個(本例只有 15 個,全部放行)
T=800ms → TSLA Task 超時閾值 → 觸發 Speculative Request
T=1500ms → Hard Deadline → 強制截止剩餘

若有 25 個 ToolCall(超過 Semaphore 限制 20):

T=0ms    → 前 20 個立即放行,5 個進隊列等待
T=200ms  → Task #3 完成 → Semaphore 釋放 → 隊列中的 Task #21 開始
...
T=1500ms → Hard Deadline → 不管隊列中還有幾個,全部取消

七、可觀測性:Traces、Metrics、Logs 的症狀鏈

7.1 正常運行時的可觀測性基線

每個 Tool Call 必須產生完整的 span(使用 OpenTelemetry):

Trace: user_query_12345
  └── span: tool_fanout_execute (duration: 1.2s)
        ├── span: tool_call_AAPL (duration: 180ms, status: OK)
        ├── span: tool_call_TSLA (duration: 1500ms, status: TIMEOUT)
        │     └── span: speculative_request_TSLA (duration: 920ms, status: OK)
        ├── span: tool_call_NVDA (duration: 320ms, status: OK)
        └── span: circuit_breaker_check (duration: 2ms)

7.2 症狀到診斷的鏈條

觀測到的症狀Metrics 訊號診斷結論處置動作
P99 延遲突然從 1.5s 跳到 5stool_call_timeout_rate > 20%某個 API Provider 節點故障確認 CB 是否正確 OPEN;檢查備用節點
Speculative 觸發率 > 30%speculative_trigger_rate = 35%Speculative 閾值 800ms 設太低,或 API P90 變差調高閾值到 1200ms,或聯繫 API Provider
所有用戶的 partial_count < 10timeout_ticker_count > 5 per queryHard Deadline 1500ms 設太短調高到 2000ms,或優化 Speculative Pool 節點
Redis 連接數爆增redis_connections = 95% maxCircuit Breaker 鎖爭用,連接池不足增加 Redis 連接池;或改用本地 LRU 快取 CB 狀態

7.3 關鍵 Metrics 清單

# 必須監控的 4 個指標
tool_fanout_duration_ms{p50, p90, p99}    # 整體 Fan-Out 耗時
tool_call_timeout_rate{by_provider}        # 各 API Provider 的超時率
speculative_trigger_rate                   # 投機執行觸發率
circuit_breaker_state{provider, state}     # 各 CB 的當前狀態

# 告警閾值
tool_fanout_duration_ms p99 > 1600ms → PagerDuty (超過 Hard Deadline 緩衝)
tool_call_timeout_rate > 15% → Slack 警告
circuit_breaker_state{state="OPEN"} count > 2 → PagerDuty

八、為什麼選 X 不選 Y

選擇選 X 的理由不選 Y 的理由Flip Condition
asyncio.wait() vs asyncio.gather()done/pending 分開;超時後不取消 task;可取部分結果gather() 超時取消全部;return_exceptions 仍需等最慢的若所有 API 都非常穩定(P99 < 500ms),gather 夠用,實作更簡單
Speculative Execution vs 直接等超時800ms 後自動賽跑,P99 延遲從 1500ms 降至 ~900ms;用戶感知更流暢純等超時:每次 P95+ 請求都要等足 1500ms 截止線若 API 費用極高(每次 $0.1+),投機複製成本不可接受,改用 Retry-only
Hard Deadline 1500ms vs 動態超時給 LLM 的處理時間固定,整體 UX 可預測;易於 SLA 承諾動態超時:每次回傳時間不同,前端 Loading 動畫難以設計若分析結果必須完整(金融合規報告),不可截斷,需動態等待
Circuit Breaker + Redis vs 本地狀態多副本 Cloud Run 共享 CB 狀態;一個副本感知到失敗,全部副本立刻熔斷本地狀態:每個副本各自計算,同一壞 API 要每個副本都失敗 3 次才熔斷若只有單副本(小流量),本地 CB 實作更簡單,無需 Redis 依賴
SSE Partial Rendering vs WebSocketSSE 是單向串流,HTTP/1.1 即可;無狀態,Cloud Run 天然支援WebSocket:雙向連線,需 sticky session 或 pub/sub relay;Cloud Run 支援但複雜度高若需要用戶端主動推訊息給伺服器(如「停止分析」指令),改用 WebSocket
asyncio.Semaphore(20) vs 無限並發保護下游 API 不被打爆;防止連接池耗盡;Rate Limit 可預測無限並發:瞬間 15,000 個請求打向 API Provider,必然觸發 Rate Limit,造成大規模超時若下游 API 是自有服務(無 Rate Limit,連接池無限),可提高 Semaphore 至 100+,甚至無限

九、電路斷路器的 Flip 場景詳解

9.1 Circuit Breaker 配置的關鍵決策

三個核心參數的工程權衡:

參數過小的代價過大的代價推薦值
failure_threshold(熔斷閾值)誤熔斷:一次偶發失敗就熔斷反應遲鈍:壞節點要失敗很多次才熔斷3 次(在 60s 視窗內)
open_duration(OPEN 持續時間)頻繁試探,壞節點未恢復就繼續熔斷節點已恢復但 CB 還在 OPEN,浪費流量30s
half_open_probe(試探請求數)1 次失敗就回 OPEN,恢復太慢大量試探,若節點剛恢復不穩定會再次熔斷1 次(保守)

9.2 OPEN 狀態下的 Fallback 策略

當某個 API 的 CB 為 OPEN,有三種 Fallback:

  1. 備用 Provider:立刻切換到備用股票數據商(如 Alpha Vantage → Polygon.io),延遲稍增但不超時
  2. 快取數據:返回最近一次成功的數據(標記 data_age: 5min),LLM 知悉後在報告中說明
  3. 直接標記缺失:將此股票加入 timeout_tickers 列表,告知 LLM 忽略此檔股票

優先順序:備用 Provider > 快取數據 > 直接缺失標記。


十、系統效應:量化前後對比

10.1 延遲優化

場景順序執行Phase 1 gatherPhase 2 wait+CBPhase 3 完整引擎
全部 API 正常(P50)30,000ms400ms380ms300ms
1 個 API 抖動(P90)30,000ms5,000ms(等超時)1,800ms(CB 跳過)900ms(投機賽跑)
3 個 API 故障(P99)30,000ms15,000ms2,000ms1,500ms(硬截止)
全部 API 正常(P99)30,000ms2,000ms900ms800ms

核心改善:從最差 30s → Phase 3 的硬截止 1.5s,20 倍延遲改善

10.2 成本對比

方案API 呼叫次數(15 檔股票)月費(10萬次查詢)工程複雜度
順序執行15 次(串行)$150
Phase 1 gather15 次(並行)$150
Phase 2 wait+CB15 次(並行,CB 跳過壞節點)$135(省 10%:CB 跳過避免無效呼叫)
Phase 3 完整引擎15–16 次(約 5% 投機複製)$142(比 Phase 2 多 5% 投機)

10.3 可靠性對比

指標順序執行Phase 1Phase 2Phase 3
單 API 故障時系統可用率60%(1/15 失敗即卡死)80%(gather 等超時)95%(CB 快速切換)99%(CB + 投機)
P99 響應時間30,000ms15,000ms2,000ms1,500ms
部分結果可用率0%(全有全無)0%(gather 等最慢)85%99%
Rate Limit 觸發率< 1%(串行)35%(瞬間爆發)8%(Semaphore 限流)2%(雙層限流)

10.4 前端用戶體驗對比

體驗指標舊版(順序)Phase 3
首個數據可見時間 (FCP)30,000ms180ms(第一個 SSE event)
全部數據可見時間30,000ms1,500ms(硬截止)
零結果率10%(全部超時時)0.1%(幾乎總有部分結果)
用戶放棄率(> 10s 無回應)45%2%

十一、面試答題要點

面試官問:「asyncio.gather() 有什麼問題?你會如何設計一個生產級的 Tool Fan-Out 引擎?」

「asyncio.gather() 的根本問題是『木桶短板』語意:它等待所有 coroutine 完成,一個慢 API 就能拖垮全部。我的解法分三層:第一,用 asyncio.wait() 替代 gather,它的 done/pending 分離讓我能在硬截止時間(1500ms)到達時,立刻取走已完成的結果,不等剩餘的;第二,在 800ms 閾值觸發 Speculative Execution,對超時的 API 同時向備用節點發起複製請求賽跑,誰先回傳用誰,P99 延遲從 1500ms 降至 900ms;第三,Redis-backed Circuit Breaker 追蹤各 API 節點的失敗狀態,連續失敗 3 次立即熔斷 30 秒,讓後續請求不再傻等已知壞節點的超時;最後,前端用 SSE 實現 Partial Rendering,每完成一個股票立刻推送,用戶在 180ms 後就看到第一個結果,整體從用戶等 30 秒變成 1.5 秒內交出 12 檔高品質分析——20 倍的延遲改善,成本僅增加 5%。」


延伸閱讀

  • Jeff Dean & Luiz André Barroso, “The Tail at Scale” (2013) — Hedged Request 的原始論文
  • Python asyncio 官方文件:asyncio.wait() vs asyncio.gather() 語意差異
  • Martin Fowler, “Circuit Breaker” pattern — circuitbreaker.io
  • Netflix Hystrix → Resilience4j — JVM 生態的 Circuit Breaker 參考實作
  • OpenTelemetry Python SDK — 為 asyncio Tool Calls 加入 Trace Span

系列導航

Part 51:多模態 Agent 的 RAG 混合檢索架構設計 | Part 53:下一篇主題

Yen

Yen

Yen