讓 Jev 學會煞車:從被冷落的 Reposition 到 17 秒通關的戰術調校

透過狀態特徵工程、提示詞決策邊界與在地反向推進,打破 AI 飛行盲區
featured.svg

在上一篇專題中,我記錄了如何為經典街機《Asteroids》打造雙層飛行副駕駛:由 TypeSafe AI 的 System One 結構化決策模型 Jev 負責宏觀戰術判斷,在地 TypeScript 控制器則以 120 Hz 固定步長負責物理致動。那套架構成功讓飛船無傷清空了第一星區(Sector 01)。

然而在持續把玩與檢視飛行儀表板日誌時,我察覺到一個相當尷尬的現象:

在系統提供給 Jev 的三種戰術姿態(interceptevadereposition)中,reposition(重新佔位)在實戰中幾乎從未被選取過。

統計連續數場飛行紀錄後,數據呈現極端的兩極化分佈:一旦空域出現撞擊危險,Jev 幾乎 100% 選擇 evade;而當周遭暫時安全時,Jev 則壓倒性地選擇 intercept。被寄予厚望的 reposition 選取率甚至不足 2%,幾乎形同虛設。

更嚴重的後遺症在於:缺乏主動佔位與控速機制的飛船,在無重力太空中總是帶著極高的慣性橫衝直撞(漂移速度經常飆破 250 px/s)。即便 Jev 想鎖定下一顆目標,飛船也往往因為速度過快而難以轉向瞄準,只能任由慣性帶著船體在星圖中漫長滑行。上一版通關第一星區足足花費了 84 秒。

這促使我著手進行了一場深入的戰術重構:如何讓 Jev 理解何時該減速重新佔位,並讓在地控制器真正落實相應的物理動力學?

先來看調校完成後的最新成果。

成果驗證:17 秒極速通關與均衡戰術

在經過特徵工程、提示詞決策邊界與在地煞車實作後,Jev 在實戰中的飛行身段有了質的飛躍。

以下是同一款遊戲、同一位 Jev 飛行員在調校後錄製的實戰紀錄:

在這段約 21 秒的完整視訊中,幾個關鍵指標發生了劇烈的變化:

  • 通關時間大幅縮減:清空第一星區(得分達到 2,600 並進入 Sector 02)的耗時從原本的 84 秒急遽縮短至 17 秒,效率提升了近 5 倍。
  • 戰術分佈達成動態平衡:在通關的 17 秒內,Jev 總計執行了 20 次戰術決策。其中 reposition 7 次、intercept 6 次、evade 7 次,徹底打破了過去非打即逃的二元困境。
  • 飛行姿態洗鍊沉穩:飛船不再失控地以高速在全場漂移甩尾,而是呈現出清晰的作戰節奏——高速逼近 ➔ 逆向噴射煞車 ➔ 穩定轉身鎖定 ➔ 伺機精準點放 ➔ 規避破片。

這項轉變是如何實現的?背後正是「狀態特徵工程」、「提示詞操作邊界」與「物理致動分立」三者的環環相扣。

診斷:為什麼模型過去從不選 Reposition?

回頭檢視最初的設計,我發現了兩個致命的工程盲點:

盲點一:提示詞語意重疊與狀態貧乏

在最初的提示詞中,我對 reposition 的描述僅有寥寥數語:

reposition: 'Move to a clearer part of the field to regain room to aim. Fire opportunistically.'

對模型而言,「移動到開闊處以重新獲取瞄準空間」這句話在語意上存在巨大的模糊性:

  • 如果空域有迫近的隕石,模型自然認為逃往開闊處就是 evade 的責任。
  • 如果空域沒有迫近的隕石,模型看到遠處有敵人,自然直覺地認為應該上前進攻(intercept)。

此外,原本送給模型的雷達快照只包含單純的相對距離、方位角與接近速度。模型「看不到」全域威脅的總體評估,更「不知道」自己當前到底有沒有對準任何一顆隕石的射擊角度。在缺乏領域特徵支撐的情況下,模型只能在進攻與逃亡之間粗糙搖擺。

盲點二:在地控制器缺乏專屬執行邏輯

更致命的是,當初在瀏覽器端 src/game.tssteer() 函式中,我為了圖省事,寫下了這樣的邏輯:

// 舊版實作:evade 與 reposition 共用相同的 24 方位逃逸分支
if (reflex || maneuver === 'evade' || maneuver === 'reposition') {
  // 24 方位候選落點取樣 ...
}

這意味著即便 Jev 在某些情境下選了 reposition,在地控制器做的事情也跟 evade 完全相同:同樣是在全速逃竄,根本無法達到「調整航向、降低過剩慣性、建立穩定射擊射角」的戰術目的。

解法一:感知特徵工程(Tactics Feature Extraction)

要讓模型做出精密的戰術抉擇,首先必須提供具有明確戰術語意的指標,而不是丟一堆未經加工的 raw 座標讓模型猜測。

src/game.tssnapshot() 中,我為快照擴充了專屬的 tactics 物件與每顆隕石的 leadBearing

snapshot(sequence: number): FlightSnapshot {
  const round = (n: number) => Math.round(n * 10) / 10;
  const asteroids = this.rocks.map(r => {
    const d = delta(this.ship, r), dist = length(d);
    // ...
    return {
      id: r.id,
      distance: round(dist),
      bearing: round(angleDifference(Math.atan2(d.y, d.x), this.ship.angle) * 180 / Math.PI),
      // 關鍵新增:包含移動目標動態前置量的真實瞄準夾角
      leadBearing: round(angleDifference(leadAngle(this.ship, r), this.ship.angle) * 180 / Math.PI),
      radius: r.radius,
      closingSpeed: round(-dot / (dist || 1)),
      closestApproach: round(Math.hypot(d.x + v.x * t, d.y + v.y * t) - r.radius - SHIP_RADIUS),
      secondsToClosest: round(t),
    };
  }).sort((a, b) => a.distance - b.distance).slice(0, 10);

  const inRange = asteroids.filter(r => r.distance < 580);
  return {
    sequence,
    wave: this.wave,
    lives: this.lives,
    ship: {
      speed: round(Math.hypot(this.ship.vx, this.ship.vy)),
      heading: round(wrap(this.ship.angle * 180 / Math.PI, 360)),
      invulnerable: this.ship.shield > 0,
    },
    // 戰術衍生特徵
    tactics: {
      // 1. 全場即將在 0.8 秒內闖入安全包絡線的急迫威脅總數
      imminentThreats: this.rocks.filter(r => imminentThreat(this.ship, r)).length,
      // 2. 當前船頭已對齊前置角且在射程內的有效射擊機會數
      firingOpportunities: this.rocks.filter(r => canFireAt(this.ship, r)).length,
      // 3. 射程內所有候選隕石中,最小的絕對瞄準偏差角(度)
      bestAimError: inRange.length ? Math.min(...inRange.map(r => Math.abs(r.leadBearing))) : null,
    },
    asteroids,
  };
}

這項特徵工程的價值在於:

  1. tactics.imminentThreats:讓模型一目了然全場是否有迫在眉睫的碰撞威脅,將「生存防禦」從模稜兩可的推測轉化為確鑿的整數計數。
  2. tactics.bestAimError:如果當前速度很高且射程內的最佳目標都在船尾(例如 bestAimError = 180°),模型就能明確判定「目前沒有任何良好的射擊窗口,盲目開火只會浪費機會」。
  3. leadBearing:消除了目標運動造成的視覺角度盲區,直接給出砲管到底需要修正多少度才能擊中目標。

解法二:提示詞決策邊界(Prompt Boundaries Tuning)

特徵到位後,第二步是在 server/pilot.ts 中重塑決策樹邏輯,為三個選項劃清非重疊的操作邊界。

export function questionsFor(snapshot: FlightSnapshot) {
  // ...
  return {
    maneuver: choice(
      'Choose the most useful maneuver for the next roughly 1.2 seconds in Asteroids. Survive and destroy rocks. Units are pixels, pixels/second, degrees, seconds. Bearing 0 is ahead; negative is left. leadBearing includes moving-target lead. `tactics.imminentThreats` counts asteroids entering a safety envelope within 0.8 seconds across the whole field. `tactics.firingOpportunities` counts currently aligned in-range shots across the whole field. `tactics.bestAimError` is the smallest absolute leadBearing among in-range radar candidates, or null if none are in range. First consider immediate danger: evade takes priority over preparing an attack. With no immediate danger, consider whether to prepare or attack: speed above about 180 or no aligned shots with bestAimError above about 45 degrees favors reposition. At controlled speed with a useful firing angle, intercept; distant targets alone favor approaching with intercept, not reposition. Negative closestApproach predicts overlap at constant velocity over up to 5 seconds, not necessarily immediate danger. secondsToClosest=0 may describe a receding rock; positive closingSpeed means approaching. A local reflex handles emergencies in every maneuver. Shield is temporary.',
      {
        intercept: 'Attack from a usable firing angle at controlled speed, or close the distance to out-of-range targets. Lead shots and approach when appropriate.',
        evade: 'Escape an imminent collision indicated by tactics.imminentThreats or a clear approaching near-term threat. Accelerate toward a clear endpoint. High ship speed alone is NOT a reason to evade when threats are absent and asteroids are receding or well separated; use reposition to brake in that situation.',
        reposition: 'No imminent threat, but the ship is drifting too fast (roughly above 180 px/s) OR needs a large turn to get a shot. Even if a shot is currently aligned, excessive safe drift is a reason to reposition. Counter-thrust to reduce speed, then coast and aim without accelerating toward the target. Fire opportunistically. This is preparation and braking, not emergency escape.',
      }
    ),
    target: choice(
      'Independently choose the most useful asteroid to aim at if intercept or reposition is selected. Prefer a nearby target with a small absolute leadBearing or a closing threat that can be destroyed. Reposition can first brake before aiming. Use none if no useful target exists. This answer cannot see the maneuver answer.',
      targets
    ),
  };
}

這次提示詞調校的核心關鍵在於:

  • 明確的優先級階層imminentThreats > 0 時,evade 享有絕對優先權;
  • 定量的操作門檻:當威脅為 0 時,若航速大於 180 px/s 或射擊偏差角大於 45°,模型被引導優先選擇 reposition
  • 排他性邊界釐清:在 evade 的定義中特別強調「無威脅時的高航速不是逃跑的理由,應使用 reposition 進行煞車」;在 reposition 的定義中特別標明「這是準備與煞車,不是逃生」。

解法三:在地控制器分立——主動逆向推進煞車

即使模型精確下達了 reposition 指令,如果底層控制器沒有實作對應的物理行為,指令依然是一紙空文。

在《Asteroids》的牛頓慣性物理中,太空船沒有大氣阻力。如果你想在太空中停下來或變換航向,單純轉動船頭不會改變任何速度向量!唯一的煞車方式是:將船頭旋轉至目前速度向量的完全反方向,啟動引擎進行 反向噴射推進 (Counter-Thrust Braking)。

src/game.tssteer() 中,我將 repositionevade 徹底剝離,實作了精緻的雙階段姿態控制:

export function steer(game: Game, maneuver: Maneuver, targetId: string | null): PilotOutput {
  const ship = game.ship;
  let target = game.rocks.find(r => r.id === targetId);
  if (!target) target = [...game.rocks].sort((a, b) => length(delta(ship, a)) - length(delta(ship, b)))[0];
  if (!target) return { controls: IDLE, reflex: false, target: null };

  const reflex = game.rocks.some(r => imminentThreat(ship, r));
  let desired: number, thrust = false;

  if (reflex || maneuver === 'evade') {
    // 1. 規避分支:24 方位候選落點取樣,尋找空間淨距最大方向加速脫離
    let best = -Infinity; desired = ship.angle;
    for (let i = 0; i < 24; i++) {
      const a = i * Math.PI / 12;
      const point = { x: ship.x + ship.vx * 0.45 + Math.cos(a) * 180, y: ship.y + ship.vy * 0.45 + Math.sin(a) * 180 };
      const clearance = Math.min(...game.rocks.map(r => length(delta(point, { x: r.x + r.vx * 0.7, y: r.y + r.vy * 0.7 })) - r.radius));
      const value = clearance - Math.abs(angleDifference(a, ship.angle)) * 28;
      if (value > best) { best = value; desired = a; }
    }
    thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.75;
  } else if (maneuver === 'reposition') {
    // 2. 重新佔位分支:逆向噴射減速,隨後慣性滑行鎖定目標射角
    if (Math.hypot(ship.vx, ship.vy) > 120) {
      // 階段 1:航速超過 120 px/s,瞄準速度向量的反方向 (-vx, -vy)
      desired = Math.atan2(-ship.vy, -ship.vx);
      // 嚴格等待船頭完全轉到反方向(誤差小於 0.35 徑度 / 約 20 度)才點火反推!
      thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.35;
    } else {
      // 階段 2:航速已受控 (< 120 px/s),關閉推進器,船頭轉向目標移動前置角
      desired = leadAngle(ship, target);
      thrust = false;
    }
  } else {
    // 3. 截擊分支:對準目標前置角,距離遠且未超速時加速進逼
    desired = leadAngle(ship, target);
    thrust = length(delta(ship, target)) > 260 && Math.hypot(ship.vx, ship.vy) < 170
      && Math.abs(angleDifference(desired, ship.angle)) < 0.45;
  }

  const error = angleDifference(desired, ship.angle);
  // 全域伺機開火:船頭對齊任何一顆隕石的預估前置角且通過 0.15s 冷卻即擊發
  const fire = game.rocks.some(r => canFireAt(ship, r));
  return { controls: { turn: Math.max(-1, Math.min(1, error * 3.5)), thrust, fire }, reflex, target: target.id };
}

整套架構的決策分流與執行流程如下圖所示:

tactics-tuning.svg

這個雙階段設計帶來了三大優勢:

  1. 防止無效自旋加速:在航速尚未降下來前,若未對準反向就貿然噴射,只會讓飛船沿著錯誤的切線越轉越快。透過 angleDifference < 0.35 門檻,飛船一定會先轉到位,再穩健煞車。
  2. 慣性滑行瞄準:當速度低於 120 px/s 時,飛船嚴格熄火滑行,將全部角速度用於鎖定目標前置角,不再向前推擠衝撞目標。
  3. 伺機擊發不中斷:即使飛船正在逆向煞車,只要旋轉中的船頭掠過任何隕石的前置航角,全域伺機開火(opportunistic firing)依然會順手補上一槍,不浪費任何消滅敵人的機會。

離線驗證:定型化場景評估(Evaluation Fixtures)

在正式讓模型接管飛行前,如何驗證 Jev 真的掌握了這套戰術邊界,而不是在單一場次中碰巧走運?

scripts/evaluate-pilot.ts 中,我設計了 4 組涵蓋極端戰術邊界的測試情境(fixtures):

const cases = [
  { name: '受控速度下的開闊正面目標', expected: 'intercept', speed: 0, heading: 0, distance: 300, rockVx: 0 },
  { name: '背向目標高速漂移(需煞車)', expected: 'reposition', speed: 260, heading: Math.PI, distance: -300, rockVx: 0 },
  { name: '空域安全但目標全在身後(偏差角 180°)', expected: 'reposition', speed: 0, heading: Math.PI, distance: 300, rockVx: 0 },
  { name: '即將迎面撞上的急迫隕石', expected: 'evade', speed: 0, heading: 0, distance: 60, rockVx: -100 },
];

執行實際呼叫 TypeSafe API 的評估腳本:

npm run test:jev

終端機印出了令人振奮的結果:

{"scenario":"受控速度下的開闊正面目標","expected":"intercept","matched":true,"probabilities":{"intercept":0.89,"evade":0.06,"reposition":0.05},"latencyMs":326}
{"scenario":"背向目標高速漂移(需煞車)","expected":"reposition","matched":true,"probabilities":{"reposition":0.52,"evade":0.34,"intercept":0.14},"latencyMs":172}
{"scenario":"空域安全但目標全在身後(偏差角 180°)","expected":"reposition","matched":true,"probabilities":{"reposition":0.68,"intercept":0.16,"evade":0.16},"latencyMs":129}
{"scenario":"即將迎面撞上的急迫隕石","expected":"evade","matched":true,"probabilities":{"evade":0.97,"intercept":0.03,"reposition":0.00},"latencyMs":143}

在 4/4 全部命中的情境測試中,我們清晰看到了 Jev 戰術意識的確立:

  • 面對身後的敵人(bestAimError = 180°),reposition 獲得了 68% 的高票勝出;
  • 面對超速漂移(260 px/s),即便目標在射程內,reposition 也以 52% 壓倒了盲目進攻;
  • 面對迎面撞擊,evade 以 97% 的壓倒性置信度主導避難;
  • 一旦空域安全且航速穩定,intercept 便以 89% 的機率主動迎擊。

結語與工程省思

從 84 秒到 17 秒,這場看似簡單的戰術調校給了我非常深刻的體會。

在當前 AI Agent 的開發過程中,很多團隊在遇到模型表現不佳時,直覺反應往往是:

  • 「是不是模型不夠大?換旗艦模型試試?」
  • 「提示詞是不是不夠長?再寫五百字叮囑它要注意各種狀況。」

但這次 hello-jev 的實作證明了完全不同的工程事實:

  1. AI 的盲區往往源於人類定義的模糊:當你給模型一個名為 reposition 的選項,卻沒有在提示詞中為它劃定清晰的定量觸發條件(何時該選、何時不該選),模型自然會選擇更有把握的極端選項(進攻或逃跑)。
  2. 領域特徵工程永遠無可取代:不要指望模型在幾百毫秒內從原始座標矩陣中自行頓悟出「我現在漂移太快了」或「所有敵人都背對著我」。把領域常識預先提煉為緊湊的數值(如 bestAimErrorimminentThreats),能為模型節省大量的推理負擔。
  3. 思考與致動必須對稱 (Symmetry of Brain & Actuator):如果在認知層提供了三種姿態,在執行層卻只實作了兩種物理分支,整個系統的智慧就會產生斷層。在無重力物理中,「減速重新佔位」需要專門的反向噴射與慣性滑行狀態機;只有底層致動器具備足夠的物理能力,上層模型的決策才能真正開花結果。

當星圖中的這艘小飛船學會沉著地逆向點火煞車時,它才真正脫離了盲目逃竄的程式碼反射,展現出宛如人類王牌飛行員般的沉穩戰術。


讓 Jev 幫你開太空船:Asteroids 飛行副駕駛的雙層架構設計

從語意雷達快照、TypeSafe 結構化決策到 120 Hz 物理模擬與在地防衛反射
featured.svg

1979 年由 Atari 推出的經典街機遊戲《Asteroids》(隕石狂飆),是許多人對大型電玩的最早記憶之一:一艘三角形的小太空船漂浮在無重力深空中,利用牛頓慣性推進與 360 度旋轉穿梭於隕石群之間。擊中巨石會分裂成中型碎塊,碎塊再裂成小型疾馳的礫石,而螢幕邊緣更具備環狀包覆(toroidal wraparound)——從左側飛出就會從右側現身。

小時候玩這款遊戲,總覺得最致命的往往不是手眼協調,而是局勢判斷:你究竟應該主動迎擊迎面而來的威脅、朝安全空曠處重新卡位,還是全力急轉閃避即將撞上的碎屑?

最近為了深入練習現代 TypeScript,順便摸索 TypeSafe AI 推出的 System One 結構化決策模型 Jev,我動手寫了一個練習專案:hello-jev。我用 TypeScript 與 Canvas 從頭打造了這款經典遊戲,並串接 Jev 實作了一具即時的「AI 飛行副駕駛(autopilot flight computer)」。不同於輸出自由文字的生成式 LLM,Jev 專門處理型別化的離散決策與機率分佈,非常適合擔任需要嚴謹邏輯的戰術大腦。

你可以隨時手動駕駛,按下 J 鍵交給 Jev 接手;而在副駕駛接管期間,右側的飛行儀表板會即時展示 Jev 當前的戰術決策、機率分佈(probabilities)、置信度(confidence)與伺服器 SDK 延遲。只要你一碰方向鍵或空白鍵開火,系統會在鍵盤事件中立即切換模式,並在下一個物理步無縫套用手動輸入。

先來看看 Jev 與在地控制器協同作戰的實機錄影。在這段約 84 秒的完整戰況中,自動駕駛成功清空了第一星區(Sector 01,得分達 2,600 並順利挺進 Sector 02),三艘飛船全數完好無損,右側飛行電腦更即時呈現了完整的戰術決策、機率分佈與呼叫紀錄:

但仔細想想,讓雲端 AI 模型來開太空船,這件事在工程上真的行得通嗎?

即時遊戲與 AI 模型的衝突

如果我們用最直覺的思維來設計 AI 飛行員,直覺的想法可能是:

「每一幀都截圖或把太空船座標餵給模型,問它現在該按『向左轉』、『推進』還是『開火』?」

這種做法只要一跑起來,就會立刻撞牆:

  1. 幀率預算的極限 (Frame Budget):遊戲實體物理模擬通常採用固定步長循環(fixed-step simulation)。在 hello-jev 中,物理模擬以 120 Hz 執行(每步約 8.3 毫秒,由 requestAnimationFrame 驅動累加器),這與瀏覽器的畫面渲染幀率解耦,若遇掉幀會在單一畫面幀中連續補償多個物理步。即使是最快的雲端推論 API,單次呼叫加上網路往返通常也需要數十至數百毫秒。用高延遲的網路 API 驅動毫秒級的連續物理迴圈,飛船早就撞毀幾十次了。
  2. 浮點幾何與向量運算的盲區:大語言模型擅長語意關聯與戰術權衡,但讓它在心智中做即時的二維三角函數計算(例如預判 640 px/s 子彈速度與兩團漂移物體的交會前置角),往往不穩定且消耗大量運算資源。
  3. 無重力環狀空間的複雜度:遊戲邊界具有循環環形(wraparound)特性。當飛船在 x = 10,隕石在 x = 990,幾何歐氏距離看似 980,但實際跨邊界距離只有 20。若把原始座標直接丟給模型,它很難迅速掌握環狀拓撲結構。

那麼,我在打造 hello-jev 時,是如何化解這個矛盾的?

答案就是現代自主機器人與自駕車領域行之有年的架構思維: 雙層控制架構 (Hierarchical Control Architecture)——將「宏觀戰術評估」與「微觀物理執行」徹底解耦。

雙層架構:思考者與行動者的分工

hello-jev 的設計中,我把飛行副駕駛精確切分為兩層不同頻率的系統:

  • 宏觀戰術層 (Deliberative Tactic Layer / Jev): 運行頻率低(在正常連續運作下,名義上約每 1.2 秒發送一次請求,且保證隨時最多只有一個在途請求)。它不負責「現在轉動 0.05 徑度」這種微操,而是回答宏觀問題:「面對目前的空域威脅,我該採取哪種戰術姿態(攔截、閃避或重新佔位)?如果攔截,優先鎖定哪顆隕石?」
  • 微觀物理執行層 (Reactive Controller / 120 Hz 在地控制器): 以確定性演算法在瀏覽器本機端以 120 Hz 固定步長執行。它接收 Jev 最新下達的戰術指令,並直接讀取當前飛船與隕石的即時幾何座標,計算出每個微步精準的轉向控制量(turn)、推進脈衝(thrust)與前置量射擊(lead aiming)。
  • 即時防衛反射機制 (Emergency Reflex Check): 在地控制器內建持續性的防衛反射檢測。在每個物理微步中,控制器會外推未來 0 至 0.8 秒內的近距威脅,只要有任何隕石進入安全包絡線(距離小於半徑加上 44 像素),無論當前是否在等待 Jev 回應,都會強制切換為逃逸姿態,有效降低等待模型回應期間的碰撞風險。

整個系統的資料流向如下圖所示:

architecture.svg

這套架構讓 Jev 專注發揮其巨觀場景判斷的高級能力,而把高頻率、需要精準數值解算的物理驅動,留給瀏覽器端輕量高效率的 TypeScript。

雷達快照:將連續空間幾何語意化

要讓 Jev 做出精明判斷,第一步是:如何把複雜的 2D 物理狀態餵給模型?

src/game.tssnapshot() 中,我並沒有把整張畫布的座標直接丟給模型,而是寫了一段簡潔的幾何前處理,將空間狀態萃取為最多 10 顆鄰近隕石的「雷達快照(FlightSnapshot)」:

snapshot(sequence: number): FlightSnapshot {
  const round = (n: number) => Math.round(n * 10) / 10;
  const asteroids = this.rocks.map(r => {
    // 1. 計算考慮環狀邊界(wraparound)後的真實相對位移向量
    const d = delta(this.ship, r), dist = length(d);
    // 2. 計算相對速度向量
    const v = { x: r.vx - this.ship.vx, y: r.vy - this.ship.vy };
    // 3. 假設相對速度恆定,透過內積計算最接近時間點 t(限制在 0 到 5 秒之間)
    const dot = d.x * v.x + d.y * v.y;
    const t = Math.max(0, Math.min(5, -dot / (v.x * v.x + v.y * v.y || 1)));

    return {
      id: r.id,
      distance: round(dist),
      // 相對於船頭的方位角(-180 到 180 度,0 度表示正前方)
      bearing: round(angleDifference(Math.atan2(d.y, d.x), this.ship.angle) * 180 / Math.PI),
      radius: r.radius,
      // 徑向接近速度(正值表示正在接近,負值表示遠離)
      closingSpeed: round(-dot / (dist || 1)),
      // 線性外推下,最接近時的兩者預估表面間距(負值代表若雙方維持等速直線運動將發生碰撞)
      closestApproach: round(Math.hypot(d.x + v.x * t, d.y + v.y * t) - r.radius - SHIP_RADIUS),
      secondsToClosest: round(t),
    };
  }).sort((a, b) => a.distance - b.distance).slice(0, 10);

  return {
    sequence,
    wave: this.wave,
    lives: this.lives,
    ship: {
      speed: round(Math.hypot(this.ship.vx, this.ship.vy)),
      heading: round(wrap(this.ship.angle * 180 / Math.PI, 360)),
      invulnerable: this.ship.shield > 0,
    },
    asteroids,
  };
}

這段前處理的設計考量包含:

  1. 消除環形維度的歧義:透過 delta(from, to) 計算最短環形位移(1000×700 視口),輸出乾淨的相對距離向量。
  2. 直接給出衍生指標 (Derived Metrics):模型不需要拿速度與位置去解二階方程。快照在假設相對速度恆定(constant relative velocity)的前提下,提供 5 秒時間視界內的線性外推預估:secondsToClosest 是預估的最接近時間點(而非必定相撞的倒數計時),closestApproach 則是基於碰撞球體半徑估算的最小表面淨距。
  3. 相對航向角 (Relative Bearing):將絕對座標轉換成「相對於飛船當前朝向」的角度( 為正前方,負值為左,正值為右),讓模型能以最符合飛行員本能的視角進行決策。
  4. 中心距離排序取樣:選取距離飛船中心最近的 10 顆隕石餵給模型,控制酬載大小並聚焦局部威脅。

向 Jev 發問:TypeSafe 結構化查詢與機率決策

有了語意清晰的雷達快照,我在後端伺服器 server/pilot.ts 中是如何向 Jev 索取決策的?

這裡使用的是 TypeSafe AI SDK 的 @typesafe-ai/sdk。不同於傳統大模型總是輸出一段難以約束格式的文字,TypeSafe 提供了一種專注於型別化結構決策的介面:client.systemOne()choice()

一次往返問完兩個獨立問題

server/pilot.tsquestionsFor() 中,我設計在單一 SDK 呼叫中同時提出兩個相互獨立的結構化問題:

export function questionsFor(snapshot: FlightSnapshot) {
  // 動態建立當前可選的目標隕石清單
  const targets: Record<string, string> = {
    none: 'No useful asteroid target; focus on survival or wait for the next wave.',
  };
  for (const r of snapshot.asteroids) {
    targets[r.id] = `Asteroid ${r.id}; details are in asteroids. Choose using danger, firing alignment, distance, and ease of interception.`;
  }

  return {
    // 問題 1:決定當前戰術姿態
    maneuver: choice(
      'Choose the best tactical maneuver for the next roughly 1.2 seconds in Asteroids. Survive and destroy rocks. State uses pixels, pixels/second, degrees, and seconds. Bearing 0 is straight ahead; negative is left. Negative closestApproach means a predicted collision if velocities stay constant. secondsToClosest=0 can mean a rock is moving away: inspect closingSpeed (positive means approaching). A local reflex avoids imminent collisions. Favor intercept when there is no approaching collision threat; reposition when crowded or moving too fast to aim; evade when a near collision is predicted. Shield gives temporary protection, not permanent safety.',
      {
        intercept: 'Aim at a selected asteroid, lead the shot, and fire. Approach distant targets at moderate speed.',
        evade: 'Prioritize thrust toward open space to escape a collision threat. Fire opportunistically.',
        reposition: 'Move to a clearer part of the field to regain room to aim. Fire opportunistically.',
      }
    ),
    // 問題 2:若選擇攔截,獨立評估最佳目標
    target: choice(
      'Independently choose the most useful asteroid to aim at if intercept is selected. Prefer a nearby, aligned target or a closing threat that can be destroyed. Use none if no useful target exists. This answer cannot see the maneuver answer.',
      targets
    ),
  };
}

為什麼是 System 1?

在認知科學中,丹尼爾·康納曼(Daniel Kahneman)將人類思維劃分為直覺快速的「系統一(System 1)」與審慎慢速的「系統二(System 2)」。

在即時街機遊戲裡,你不會想等待模型進行一長串思維鏈(Chain-of-Thought)推理,寫出「首先我觀察到隕石 A 正在接近,因此我推論…」這樣的冗長文字。每一毫秒的文字生成都在浪費寶貴的反應時間。

client.systemOne() 正是針對快速直覺分類與離散決策設計:

const response = await client.systemOne({
  state: { ...snapshot, asteroids: snapshot.asteroids.map(r => ({ ...r })) },
  questions: questionsFor(snapshot),
}, { signal });

原生機率分佈與置信度

更關鍵的是,TypeSafe 的 choice 傳回的不僅僅是最終選定的標籤(如 "intercept"),還包含了完整的機率分佈與代表分佈集中程度的置信度 (confidence):

const m = response.answers.maneuver;
// m.choice: 'intercept'
// m.confidence: 0.78  // 衡量選項機率分佈的集中度
// m.probabilities: { intercept: 0.82, evade: 0.11, reposition: 0.07 }

需要特別釐清的是:confidence 反映的是整個機率分佈的明確程度,它既不是獲勝選項的單一機率,也不代表動作的成功率或存活率。在 hello-jev 的前端儀表板中,我們將這組機率直接渲染為動態長條圖(probability bars)與置信百分比,供駕駛員即時監控模型的決策偏好。而在程式碼控制層,飛船直接採納勝出的 choice,並未設置信心度門檻來阻擋行動。

當前方空域開闊且有一顆正前方接近的隕石時,實測中 intercept 的機率常顯著提升;而當周遭被多顆碎石包圍時,evadereposition 的機率就會大幅上揚,真實展現出 AI 在戰術權衡時的量化評估。

微觀物理執行:在地控制器的解算細節

Jev 下達了宏觀指示,例如 maneuver = 'intercept'target = 'r04'。接下來的 1.2 秒內,瀏覽器端必須以 120 Hz 的物理頻率,把這個意圖轉換成飛船的具體動作。

這些邏輯全實作在 src/game.tssteer() 函式中。

前置量預判射擊(Lead Shooting)

如果戰術是 intercept,飛船絕不能朝著目標當前的位置開火——因為子彈飛行需要時間,直接對準現有座標必定射偏。

在遊戲物理中,砲彈發射時會繼承飛船當前速度(初始相對船頭速度為 BULLET_SPEED = 640 px/s,世界座標速度為飛船速度加上子彈初速)。在地控制器會計算相對速度向量,進行一階前置量預估:

const d = delta(ship, target);
// 一階飛行時間估算
const travel = length(d) / BULLET_SPEED;

// 預判目標在 travel 秒後的位移向量,計算理想瞄準角度
const desired = Math.atan2(
  d.y + (target.vy - ship.vy) * travel,
  d.x + (target.vx - ship.vx) * travel
);

// 只有當距離較遠 (>260px)、飛船速度不過快 (<170px/s) 且船頭大致對齊時才開推進
const thrust = length(d) > 260 && Math.hypot(ship.vx, ship.vy) < 170
  && Math.abs(angleDifference(desired, ship.angle)) < 0.45;

// 計算目前朝向與目標朝向的夾角誤差,平滑轉動船頭
const error = angleDifference(desired, ship.angle);
const turn = Math.max(-1, Math.min(1, error * 3.5));

這裡控制器並非求解複雜的移動目標截擊二元方程,而是利用相對速度做一次一階外推,即能在微步循環中極為經濟地打出精確的前置量攔截。

24 方位候選落點評估(Candidate-heading Sampling)

如果觸發了緊急避險反射,或是 Jev 選擇了 evadereposition,飛船該往哪裡逃?

這裡在地控制器並沒有進行昂貴的連續路徑射線追蹤,而是採用了高效的幾何候選取樣: 24 方位候選落點評估

let best = -Infinity;
let desired = ship.angle;

// 將 360 度空間均分為 24 個候選航向(每 15 度一個測試方位)
for (let i = 0; i < 24; i++) {
  const a = i * Math.PI / 12;
  // 啟發式測試落點:當前速度漂移 0.45 秒加上 180 像素的固定方向位移
  const point = {
    x: ship.x + ship.vx * 0.45 + Math.cos(a) * 180,
    y: ship.y + ship.vy * 0.45 + Math.sin(a) * 180,
  };
  // 將所有隕石線性外推至 0.7 秒後的位置,計算該落點與所有隕石的最短安全淨距(clearance)
  const clearance = Math.min(...game.rocks.map(r =>
    length(delta(point, { x: r.x + r.vx * 0.7, y: r.y + r.vy * 0.7 })) - r.radius
  ));

  // 評估函數:落點安全淨距越大越好,但大幅轉向會扣分(保持航向穩定性)
  const value = clearance - Math.abs(angleDifference(a, ship.angle)) * 28;
  if (value > best) {
    best = value;
    desired = a;
  }
}
// 只有當船頭轉向已對齊最佳逃逸方位(誤差小於 0.75 徑度 / 約 43 度)時才啟動推進
const thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.75;

這項演算法的核心概念在於評估「預期目的地之安全空間」與「轉向成本」,而不是證明整條飛行動態路徑絕對無礙。每秒鐘執行 120 次這段計算,飛船就能在雜亂無章的隕石風暴中靈活找到開闊空域。

控制器分工與邊界處理

深入檢視控制器實作,會發現兩項有趣的工程細節:

  1. 戰術分支整併:在當前的本機控制器中,evadereposition 共用相同的候選取樣逃逸分支。雖然兩者在 Jev 的模型語意與介面展示上代表不同的戰術意圖(緊急逃亡 vs. 拉開距離重新佔位),但在微觀幾何執行上,尋找最開闊落點的邏輯是一致的。
  2. 目標回退與全面性伺機開火:若 Jev 選擇的目標隕石被提前擊毀,或者模型回傳 none(映射為 null),在地控制器會自動回退鎖定最近的隕石,因此 none 並不會禁止飛船開火。更進一步地,開火檢測會遍歷場上所有隕石:
const fire = game.rocks.some(r => {
  const d = delta(ship, r), t = length(d) / BULLET_SPEED;
  const bearing = Math.atan2(d.y + (r.vy - ship.vy) * t, d.x + (r.vx - ship.vx) * t);
  // 在 580px 射程內,且船頭朝向落入目標角半徑加安全裕度範圍內
  return length(d) < 580 && Math.abs(angleDifference(bearing, ship.angle)) < Math.atan2(r.radius + 7, length(d));
});

只要船頭在旋轉過程中恰好掃過任何一顆隕石的預估前置角,且通過引擎 0.15 秒的射擊冷卻(cooldown),系統就會伺機補槍,絕不放過任何順手消滅威脅的機會。

工程防禦性設計:應對網路延遲與非確定性

把網路 API 嵌入遊戲主循環,最考驗工程師的從來不是理想狀態,而是出問題時如何優雅降級(graceful degradation)。

src/pilot.tsFlightComputer 中,我設計了幾項關鍵的防禦性機制:

世代計數器與過期丟棄(Generation Invalidation)

想像一個情境:飛船在第 1 星區發出了一個決策請求,但在回應傳回前,飛船不幸撞毀重生,或玩家手動按下了暫停。此時如果舊的決策姍姍來遲並被採納,飛船就會執行上一個生命週期的過時指令。

為了解決這個問題,我為 FlightComputer 設計了世代機制:

export class FlightComputer {
  private generation = 0;
  private controller: AbortController | null = null;

  reset(): void {
    // 每次狀態重大變更(重生、換關、暫停、手動接管),立即遞增世代並嘗試中止在途請求
    this.generation++;
    this.controller?.abort();
    this.controller = null;
    this.decision = null;
    this.thinking = false;
  }

  async tick(snapshot: FlightSnapshot): Promise<void> {
    const generation = this.generation;
    // ... 發送 fetch 請求 ...
    const data = await response.json();

    // 如果在等待期間世代已經改變,無條件直接丟棄該回應!
    if (generation !== this.generation) return;
    // ...
  }
}

任何狀態重設都會發送 AbortController.abort() 嘗試中斷網路連線;即便遠端推論無法立即中止,回傳當下只要比對 generation !== this.generation,舊回應就會被立刻拋棄,確保控制權不被幽靈決策干擾。

決策存活時間(Decision TTL)

網路偶爾會出現抖動。如果 Jev 的回應因網路卡頓延遲了 2 秒才抵達,當時的戰場情勢早已截然不同。

在程式碼中我定義了 DECISION_TTL = 4500(4.5 秒)。值得注意的是,決策的時間戳記並非記錄回傳當下,而是記錄發起請求時的起算時間

this.decision = decision;
this.receivedAt = started; // 以請求發起時間 started 作為有效基準點
this.nextAt = Math.max(started + 1200, this.now() + 100);
fresh(): FlightDecision | null {
  return this.decision && this.now() - this.receivedAt < DECISION_TTL ? this.decision : null;
}

這意味著如果一次請求花了 2 秒才返回,它在客戶端的剩餘有效壽命就只剩下約 2.5 秒。如果超過 4.5 秒仍未收到更新,fresh() 便判定決策過期並回傳 null

當決策過期或尚未抵達時,在地控制器會進入明確標記的 LOCAL SAFETY 狀態:維持基本的避難漂移航向,並關閉開火系統,等待 Jev 的下一筆有效決策到來。此外,前端還設有 4 秒的瀏覽器端中斷計時器,而後端 SDK 亦配置了 3.5 秒的逾時限制(timeout: 3500, retry: { maxRetries: 0 }),層層把關避免請求無休止掛起。

請求節流與固定退避

伺服器端與前端互相配合限流:

  • 名義循環節流:正常無干擾運作時,前端下一次請求排定在 Math.max(started + 1200, this.now() + 100),維持約 1.2 秒的節奏。
  • 伺服器入場限制:後端以程序區域變數 busyperformance.now() - lastRequest < 800 作為最小入場間隔防護,避免多重呼叫撞車。
  • 固定時間退避:一旦 API 遭遇錯誤(如配額超限或網路異常),前端會關閉 SDK 的自動重試,直接啟動固定 5 秒的冷卻排程(this.nextAt = this.now() + 5000),並在儀表板即時警示,防止雪崩效應。

人類駕駛優先原則(Human Takeover)

在任何自主駕駛系統中,最重要的一條守則是: 人類隨時擁有最高優先權

src/main.ts 中,無論飛船正由 Jev 執行多麼自信的攔截動作,只要玩家按下左轉、右轉、前進或開火的任何一個按鍵,系統會在按鍵事件中立即切換模式(setMode(false))並重設所有飛行電腦狀態。手動控制在下一個 120 Hz 物理步即時生效,不帶任何拖泥帶水。

結語與架構啟示

這次為了練習 TypeScript 與探索 Jev AI 而動手打造 hello-jev,整個實作過程讓我對即時人機協同系統有了很多深刻的體會。

在當前 AI Agent 的開發浪潮中,許多人常陷入一種迷思:試圖把所有事情(甚至是底層物理運算或連續控制)全部交給大模型解決。一旦模型表現不好,就試圖塞入更長的提示詞或換用更昂貴的旗艦模型。

但打造 hello-jev 的經驗讓我體會到另一條更健康、更接地氣的工程路徑:

  1. 讓模型做它最擅長的事:大模型的核心價值在於語意理解、動態目標權衡與戰術姿態決策。不要讓它做二維矩陣乘法或微積分,那些交給 CPU 的幾行代數運算既便宜又精準。
  2. 語意特徵工程依然關鍵:提供給模型的上下文不應是未加工的原始傾印(raw dump),而是經過幾何轉換、語意豐富的衍生特徵(如相對方位、最近距離預估與接近速度)。輸入越貼近領域認知,模型給出的決策就越精確。
  3. 分層與防衛性設計是系統韌性的基石:結合高頻確定性控制器、反射避險機制、世代失效檢查與嚴格 TTL,才能打造出即便面對網路延遲與偶發故障,依然不會失控的安全混合智慧系統。

透過這個小專案,我一方面更熟悉了現代 TypeScript 的強型別推導與非同步生命週期管理,另一方面也親身體驗了 TypeSafe AI 的結構化查詢在即時決策上的潛力。

這套「雙層分工+確定性防衛」的架構模式,其適用範圍遠遠不止於 Asteroids 這種街機遊戲。在即時金融風控、邊緣物聯網(IoT)控制,或是各類需要人機協同操作的 AI 系統中,這種思考方式都極具參考價值。

下次在思考如何將 AI 引入即時或高互動系統時,不妨想想這艘在隕石群中靈巧穿梭的小太空船——讓 Jev 負責看清星圖,讓在地程式碼穩穩握住操縱桿。


時隔 20 年,有比 Joshua Bloch 的演講更好的 API 設計指南嗎?

從 2006 年的 in-process 介面哲學,到分散式系統、deep modules 與 AI agent 時代的典範轉移

二十年前(2006 年 9 月),我在部落格寫了一篇簡短的筆記 Josh Bloch on API Design,推薦了 Joshua Bloch 著名的演講與投影片《How to Design a Good API and Why it Matters》(亦可參考他在 Google 的 Tech Talk 演講影片)。當時我在文末寫下了這段心得:

「其實軟體開發者的大部分工作就是和一大堆的 API 打交道,我是最討厭使用那種設計不良的 API,因為往往要用更多的 client code 來完成功能或者避過設計的缺陷。怎麼去設計『良好的 API』正是所有軟體開發者要必備的技巧,Joshua 所提出的這些設計準則,都是相當值得參考學習的。」

一轉眼二十年過去了。最近在社群上看到一個很有深度的大哉問:時隔將近二十年,在 API 設計這個領域,到底有沒有任何資源真正超越了 Joshua Bloch 的這場經典演講?

這個提問促使我重新把那份經典的投影片翻出來重溫,也對照了過去二十年間整個軟體工程界在 API 設計上的演化。

簡潔的結論是:就基礎介面哲學而言,沒有任何單一資源能夠完全取代 Joshua Bloch;但現代軟體工程已經發展出更加全面且深入的資源,足以應對現代 API 在維運、分散式架構與網路環境中的現實挑戰。

歷久彌新的核心哲學:為什麼 Bloch 的原則依然適用?

為什麼二十年過去了,大家依然將 Joshua Bloch 的演講(以及他在《Effective Java》中的原則)奉為圭臬?

因為 Bloch 當年談論的重點,並非特定語言語法或特定框架的技術細節,而是觸及了認知心理學與軟體工程的本質矛盾——人類大腦的工作記憶(working memory)十分有限,而軟體系統的複雜度卻永遠在膨脹。

Bloch 提出的幾個核心信條,放到今天依然字字珠璣:

容易學習,難以誤用

好的 API 應該做到「Easy to learn」、「Easy to use, even without documentation」與「Hard to misuse」。它的行為要符合開發者的直覺(Principle of Least Astonishment),讓做對的事情變得很自然,讓犯錯在編譯期或呼叫當下就被阻擋。如果一個 API 需要呼叫者小心翼翼地遵循隱含的順序假設,那它就是一枚未爆彈。

儘早回報錯誤

Fail fast 原則指出,錯誤一旦發生,就應該立即在最靠近源頭的地方暴露出來,而不是吞下錯誤、回傳魔術數字,或是帶著損壞的狀態繼續執行,最終在數百行之外引發莫名其妙的崩潰。

最小化公開介面與資訊隱藏

When in doubt, leave it out.(猶豫不決時,就不要放進公開介面)。API 一旦公開,每一個方法、每一個欄位都是對外做出的長期承諾。增加功能永遠容易,移除或修改壞設計卻會破壞相容性。資訊隱藏不只是封裝實作細節,更是為未來的重構保留自由度。

命名即核心概念

名稱是建立心理模型(mental model)的基石。好的命名不需要翻閱文件就能心領神會;命名要前後一致、避免隱晦縮寫,並且能夠精準表達職責邊界。

文件也是 API 的一部分

任何必須透過閱讀底層實作程式碼才能搞懂如何使用的 API,都是不合格的設計。規格與文件的缺失,本質上就是 API 的缺陷。

這些原則之所以歷久彌新,是因為過去二十年來,硬體效能與架構工具大幅演進,但人類工程師的大腦結構與認知侷限並沒有改變。只要 API 的使用者還是人,Bloch 的哲學就永遠不會過時。

二十年間的典範轉移:API 的戰場如何擴大?

既然原則未變,那為什麼我們今天不能「只讀」Joshua Bloch?

原因在於:Bloch 的演講誕生於 2000 年代中期,主要圍繞在 Java 與 process 內部的類別庫介面(in-process class & library interfaces,最典型的代表就是他親手操刀的 Java Collections Framework)。

在那樣的語境下,呼叫發生在同一塊記憶體位址空間內、同步完成、沒有網路延遲,也不會有網路分割(network partition)。因此,Bloch 的內容自然缺乏了現代工程不可或缺的面向:網路邊界、雲端規模的向後相容、冪等性(idempotency)、分散式工作流以及跨平台 schema 規範。

在過去二十年間,軟體架構經歷了劇烈的典範轉移,API 的設計維度延伸到了以下面向:

api-evolution.svg

從 in-process 到 out-of-process

當 API 跨出記憶體邊界,走入分散式系統與雲端網路時,原本單純的函式呼叫就必須面對分散式運算的殘酷現實:

  • 非同步與長任務(long-running operations):當一個操作需要數十秒甚至數分鐘才能完成,API 往往不再適合同步阻塞連線,而更常採用非同步輪詢(polling)或事件通知模型。
  • 分頁策略(pagination):海量資料無法一次載入記憶體,傳統以 offset 為基礎的分頁容易遇到資料漂移與效能瓶頸,現代 API 越來越常採用 cursor 作為分頁策略。
  • 網路不可靠性與冪等性(idempotency):在分散式系統中,超時不代表失敗。為了讓客戶端能安全重試,API 通常會透過冪等鍵(idempotency key)等機制,在伺服器正確實作時,避免或降低重複建立資源的風險。

契約優先與開發者體驗

二十年前的開發方式往往是寫好程式碼後,再藉由 Javadoc 產生說明。現代分散式與跨語言架構下,contract-first(契約優先)成為常見的架構選擇之一:先透過 OpenAPIProtobuf 定義明確的 schema 契約,作為團隊跨語言通訊、mock 測試與自動化 SDK 產生(如 Fern)的核心契約(single source of truth),並追求更短的 time to first call。

AI agent 時代的 API 設計

在當前這個時代,API 的消費者已經不再侷限於人類工程師,越來越多是由大型語言模型驅動的 AI agent(自主代理人)。

當 API 成為 LLM 的 tool calling(函式呼叫)對象,或是透過 MCP(Model Context Protocol)接入代理人系統時,Bloch 的「難以誤用」原則被賦予了全新的意義:

  • Schema 的精確與去歧義:人類工程師遇到含糊的參數說明可能還會去查原始碼或詢問同事,但 AI agent 更容易產生誤解或幻覺。
  • 語意自明的工具描述:函式的描述(description)會直接成為模型可見上下文的一部分。工具的適用情境、先決條件與邊界約束都應盡可能清晰明確。
  • 錯誤回傳的可操作性(actionable errors):當 agent 呼叫失敗時,回傳的錯誤訊息若只是模糊的「Bad Request」,模型難以自行修正;若能具體指出哪一個欄位格式不合、有效範圍為何,agent 就更有機會根據錯誤提示進行自我修復(self-correction)。

現代的延伸與超越:兩大分支與代表資源

如果你想要在 Bloch 的哲學基礎上建立更符合當代工程實踐的技能樹,現代的最佳資源取決於你的設計場景是網路服務(REST / gRPC)還是程式碼層級的程式庫(libraries / SDKs):

網路與伺服器端 API 設計

針對網路通訊、微服務與分散式架構,我會優先推薦以下三本書:

  • JJ Geewax 的《API Design Patterns》(Manning 出版):這本書是 Bloch 的經典建議在 Web 與 RPC 時代極具啟發性的延伸閱讀。它提供了一套與特定協議無關的架構模式,專門解決 Bloch 當年從未涉足的現實挑戰:分頁機制、冪等性 token、背景長任務(long-running operations)、批次請求以及安全的局部更新(partial updates)。
  • Arnaud Lauret 的《The Design of Web APIs, Second Edition》(Manning 出版):作者以「API Handyman」為名寫作。這本書專注於 HTTP/REST 與 web 服務的人體工學(ergonomics)與易用性,成功將 Bloch 風格的人本設計原則轉譯到 URI 層級結構、payload 契約、錯誤格式以及可探索性(discoverability)上。
  • James Higginbotham 的《Principles of Web API Design: Delivering Value with APIs and Microservices》(Addison-Wesley 出版):這本書非常適合架構師研讀。它採用 job stories 與領域驅動設計(DDD)的方法,引導開發者以企業業務能力邊界來劃分 API,而不是單純將底層資料庫的 schema 直接暴露成介面。

程式庫與模組端 API 設計

如果你希望在程式碼與模組設計層面,將 Bloch 的哲學提升到更高的理論維度,這兩本書是極具代表性的參考:

  • John Ousterhout 的《A Philosophy of Software Design》(2021 年第二版):如果說有哪本書能把 Bloch 的哲學推向更高的抽象層次,那就是這本當代經典。Ousterhout 提出了著名的 deep modules(深模組)概念——一個優秀的模組應該擁有極其簡單直覺的介面,背後卻隱藏了巨大的實作複雜度;相反地,那些只添加無謂樣板程式碼的 shallow modules(淺模組)則是他極力批判的反模式。這本書對「如何設計介面以隱藏複雜度」給出了無比嚴謹的論證。
  • Jaroslav Tulach 的《Practical API Design: Confessions of a Java Framework Architect》:作者是 NetBeans 平台的創始人與架構師。這本書是一部詳盡深入的教科書,專門探討二進位層級的向後相容性(binary compatibility)、棄用生命週期(deprecation lifecycles),以及如何在跨越多年與多個大版本的演進中,盡可能不破壞呼叫端的既有程式碼。

業界指標規範

多家科技公司也將自身的 API 設計經驗整理為公開指南,成為 Bloch 核心哲學在真實生產環境中的延伸參考:

  • Google Cloud API Design Guide:彙整了 Google 內部常用的設計慣例,針對資源導向架構(resource-oriented architecture)中的標準方法命名(ListGetCreateUpdateDelete)、欄位行為與版本控制提供了詳細的實踐參考。
  • Microsoft REST API Guidelines:在 GitHub 上歷經實戰檢驗的規範,詳細規定了錯誤結構、URL 模式、查詢字串、標頭設計以及冪等操作的最佳實踐。
  • Zalando RESTful API Guidelines:深獲開源社群推崇的規範,特別聚焦於 JSON payload、OpenAPI / JSON Schema、超媒體(hypermedia)以及微服務間的通訊準則。

當程式碼全由 AI agent 撰寫,API 設計還重要嗎?

Joshua Bloch 當年的講題是《How to Design a Good API and Why it Matters》。但在軟體開發日益走向自主自動化的今天,一個不可迴避、也經常被工程師熱烈討論的問題浮現了:如果未來的程式碼絕大部分、甚至完全是由 AI agent 撰寫,人類不再逐行推敲語法,那麼「API 設計」到底還重不重要?

乍看之下,既然大型語言模型具備極強的程式碼理解能力,似乎再糟糕的介面它都能讀懂並產生相應的呼叫。但從實際的工程實踐來看,情況恰好相反:當程式碼主要由 AI agent 撰寫時,良好的 API 設計不僅依然重要,甚至比過去由人類手寫程式碼時更加關鍵。

這背後有三個深層原因:

上下文視窗的極限與深模組的必要性

雖然部分現代模型已支援數十萬甚至百萬 token 的上下文視窗(context window),但模型的注意力分配並非均勻且無限。

如果一個系統的 API 設計不良、職責邊界模糊,或是充滿了缺乏封裝的淺模組(shallow modules),AI agent 在處理任何單一任務時,就容易把寶貴的上下文容量耗費在周邊實作細節與樣板程式碼上。這不僅可能增加推論成本與延遲,也更容易稀釋模型對核心業務邏輯的專注度。

相反地,Bloch 所強調的「資訊隱藏」與 Ousterhout 倡導的「deep modules」——用極其精煉的介面遮蔽龐大的實作複雜度——恰好是降低 agent 認知負擔的利器。良好的 API 讓模型可以在極小的局部上下文中,進行高精確度的推理與程式碼產生。

「難以誤用」成為 AI agent 的安全護欄

人類工程師在遇到設計不良、充滿陷阱的 API 時,通常會感到困惑、皺起眉頭,然後停下來去查閱原始碼或與同事討論;但 AI agent 不一定會主動質疑介面設計,反而可能快速產生看似合理卻繞過缺陷的程式碼,甚至產生幻覺,將錯誤掩蓋在更深層的呼叫鏈中。

在自主迴圈(agentic loop)中,一個不直覺、容易被誤用的 API 會導致 agent 陷入反覆嘗試、修復失敗的死循環。Bloch 當年提倡的「Hard to misuse」與「Fail fast」,在人類時代是為了提升開發體驗與減少除錯時間;而在 AI 時代,它們實質上升級成了安全設計的重要一環,有助於防範自主代理人產生災難性錯誤。

實作可以隨時丟棄,但契約永遠長存

在 AI 輔助與自主編程的時代,撰寫「程式碼實作」的邊際成本正在急劇下降。一段不夠優雅的演算法、一個效能欠佳的函式,AI agent 可以在某些情境下快速重寫多個版本。

然而,API 契約(contract)卻無法隨意推倒重來。API 是跨模組、跨服務、甚至跨多個自主 agent 協同作業時關鍵的共識邊界之一。實作是消耗品,隨生隨滅;但介面是架構的骨架,一旦確立,就錨定了系統的溝通成本與演進彈性。

當人類開發者逐漸從「逐行撰寫程式碼的工人」轉變為「系統架構的設計者與審查者」,我們用來引導、約束並與 AI agent 溝通的最核心語言,正是 API。

總結:如何選擇適合你的現代指南?

回到最初的問題:時隔二十年,有比 Joshua Bloch 更好的 API 設計指南嗎?

答案很清晰:沒有任何單一資源能奪走 Joshua Bloch 作為入門哲學的桂冠;但現代工程師必須根據自己的工作場景,選讀相應的現代資源:

二十年前,我們在單一程式記憶體裡追求「容易做對,難以做錯」;二十年後的今天,我們面對的是跨越網路、微服務與 AI agent 的全新的疆界。載體在變,但那個最根本的追求始終未變。


從 Cloudflare Worker 學 TypeScript

用 National Parks Passport scratchbook 的 edge proxy、unit test 與 E2E test,認識 TypeScript 如何檢查程式

最近在做 US National Parks Passport scratchbook 時,我們需要從手機 app 讀取美國國家公園管理局(NPS)的 alerts 和 visitor centers。NPS Developer API 有 API key,也有每小時的使用量限制;把 key 放進 mobile client,等於交給每一位安裝 app 的人。

因此我們在 Cloudflare Worker 放了一層很小的 proxy:client 只呼叫自己的 Worker,Worker 從 secret binding 取得 NPS_API_KEY,再向官方 API 請求。它也嘗試把成功回應快取 30 分鐘,讓同一個 data center 的後續 request 有機會直接回傳,減少重複打上游。

最初版本是 JavaScript,功能沒有問題;但把它搬到 TypeScript 後,才發現這種小小的 serverless 程式正是學 TypeScript 的好材料。它的型別不是為了讓程式碼看起來正式,而是在流量真的抵達 edge 前,把環境設定、Web API 和錯誤路徑說清楚。

worker-proxy.svg

為什麼 serverless Worker 特別適合 TypeScript?

Worker 不需要管理伺服器,但不代表它沒有 production 風險。相反地,部署後的例外會在 edge 發生:某個 secret 沒有綁定、把 request 的屬性拼錯、或把不該快取的上游錯誤存進 cache,都可能直接影響使用者。

TypeScript 不會取代測試,也不會阻止 NPS API 在網路上失敗;它處理的是另一類問題:在執行前檢查程式對資料和平台 contract 的假設是否一致。對這支 Worker 而言,這包括:

  • 程式碼是否以正確的名稱讀取 env 的 secret?
  • fetch handler 是否收到正確的 RequestEnvExecutionContext
  • 非同步快取工作是否交給 ctx.waitUntil()
  • catch 裡的值是否能安全轉成可回傳的錯誤訊息?

先替 Worker 的環境命名

在 JavaScript 裡,env.NPS_API_KEY 可以直接讀;如果 secret 名稱打成 NPS_APIKEY,通常只能等到某個請求走進 production 才發現是 undefined。TypeScript 的第一步很單純:用 interface 描述這個 Worker 依賴的 binding。

export interface Env {
  NPS_API_KEY: string;
}

Env 不是執行期的 secret,也不會把 key 寫進 bundle;它是給 compiler 的契約。接著在 handler 的第二個參數標上 Env

async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
  // env.NPS_API_KEY 是 string
}

這裡先手動宣告 interface Env,方便看出程式依賴哪些 binding;完整 Worker 則使用 Wrangler 產生的全域 Env,避免手寫型別和部署設定分開維護。

現在拼錯 env.NPS_API_KEY 的名稱就會在 npm run typecheck 被指出。這是編譯期檢查,不代表 TypeScript 讀過 Cloudflare 帳號設定。本文在 wrangler.jsoncsecrets.required 列出 NPS_API_KEYwrangler types 會據此產生 Env,Wrangler 也會在本機開發與部署時檢查必要 secret 是否已設定。實際 secret 值仍要透過 wrangler secret put NPS_API_KEY 或 Cloudflare Dashboard 設定。

這支 proxy 的完整骨架

以下是 National Parks Passport Worker 專案中的 worker/src/index.ts。它提供 /alerts/visitorcenters 兩個 GET endpoint,也接受 CORS 的 OPTIONS preflight;兩個 endpoint 都要求 parkCode,並把真正的 API key 留在 Worker 環境裡。

// CORS enables browser access; it does not restrict callers or prevent quota abuse.
const CORS_HEADERS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Accept, Content-Type",
  "Access-Control-Max-Age": "86400",
};

function withCors(response: Response): Response {
  const headers = new Headers(response.headers);
  for (const [name, value] of Object.entries(CORS_HEADERS)) {
    headers.set(name, value);
  }

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers,
  });
}

function jsonResponse(body: unknown, status: number): Response {
  return withCors(
    new Response(JSON.stringify(body), {
      status,
      headers: {
        "Cache-Control": "no-store",
        "Content-Type": "application/json; charset=utf-8",
      },
    }),
  );
}

export default {
  async fetch(
    request: Request,
    env: Env,
    ctx: ExecutionContext,
  ): Promise<Response> {
    if (request.method === "OPTIONS") {
      return withCors(new Response(null, { status: 204 }));
    }

    if (request.method !== "GET") {
      const response = jsonResponse({ error: "Method Not Allowed" }, 405);
      response.headers.set("Allow", "GET, OPTIONS");
      return response;
    }

    const url = new URL(request.url);
    const path = url.pathname;
    if (path !== "/alerts" && path !== "/visitorcenters") {
      return jsonResponse(
        { error: "Endpoint not found. Use /alerts or /visitorcenters" },
        404,
      );
    }

    const parkCode = url.searchParams.get("parkCode")?.trim().toLowerCase();
    if (!parkCode) {
      return jsonResponse(
        { error: "Missing required query parameter: parkCode" },
        400,
      );
    }

    const apiKey = env.NPS_API_KEY?.trim();
    if (!apiKey) {
      console.error("NPS_API_KEY binding is missing or empty.");
      return jsonResponse({ error: "Worker is not configured correctly" }, 500);
    }

    const npsUrl = new URL(`https://developer.nps.gov/api/v1${path}`);
    npsUrl.searchParams.set("parkCode", parkCode);
    if (path === "/visitorcenters") {
      npsUrl.searchParams.set("limit", "10");
    }

    // The cache key contains only this Worker URL and normalized public query data.
    const cacheUrl = new URL(request.url);
    cacheUrl.pathname = path;
    cacheUrl.search = "";
    cacheUrl.searchParams.set("parkCode", parkCode);
    const cacheKey = new Request(cacheUrl.toString(), { method: "GET" });
    const cache = caches.default;

    try {
      const cachedResponse = await cache.match(cacheKey);
      if (cachedResponse) {
        return withCors(cachedResponse);
      }
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      console.error("NPS response cache lookup failed:", message);
    }

    try {
      const upstreamResponse = await fetch(npsUrl.toString(), {
        headers: {
          "X-Api-Key": apiKey,
          Accept: "application/json",
          "User-Agent": "ScratchbookNationalParks/1.0",
        },
      });

      const response = withCors(upstreamResponse);
      // Cache successful full responses; the Cache API cannot store 206 responses.
      const shouldCache = upstreamResponse.ok && upstreamResponse.status !== 206;
      response.headers.set(
        "Cache-Control",
        shouldCache ? "public, max-age=1800" : "no-store",
      );

      if (shouldCache) {
        ctx.waitUntil(
          cache.put(cacheKey, response.clone()).catch((err: unknown) => {
            const message = err instanceof Error ? err.message : String(err);
            console.error("NPS response cache write failed:", message);
          }),
        );
      }

      return response;
    } catch (err: unknown) {
      const message = err instanceof Error ? err.message : String(err);
      console.error("NPS upstream request failed:", message);
      return jsonResponse({ error: "Upstream NPS gateway error" }, 502);
    }
  },
} satisfies ExportedHandler<Env>;

export default 是這個模組的預設匯出;Wrangler 會把這個物件視為 Worker 入口,並在每個 request 到達時呼叫它的 fetch()

有幾個容易忽略的細節。上游 URL 需要包含 path,所以 /alerts 會轉成 https://developer.nps.gov/api/v1/alerts,而不是只有 API 的 base URL。apiKey 透過 X-Api-Key 標頭傳遞給 NPS API,而不是放在上游 URL 裡;這樣做能避免 secret 出現在 URL、存取記錄(access logs)或中間代理的日誌中。快取鍵使用公開的 Worker URL、路徑與正規化後的 parkCode,不包含 secret 或其他不影響上游結果的查詢參數。

這個 Worker 也處理 browser 的 CORS preflight:OPTIONS 回傳 204,且成功、錯誤與快取命中的回應都會帶上 CORS 標頭。Access-Control-Allow-Origin: * 適用於這份公開資料;若改成需要 cookie 或其他憑證的 API,就不能搭配萬用來源。

NPS_API_KEY 留在 Worker 只避免 app 使用者直接取得 key,不會限制誰能呼叫這個公開 endpoint。caches.default 也不是 rate limit:cache miss 仍會打到 NPS,而不同的 parkCode 可以持續製造 miss。NPS 文件列出的預設 hourly limit 是每把 key 每小時 1,000 次,實際限制可能依 API 服務而異;公開部署前應替 Worker 設定合適的 Cloudflare rate limit 或其他 abuse control,並留意 NPS 用量。NPS API 使用指南列出限額與回應中的 X-RateLimit headers。

Cloudflare 已經替我們提供的型別

RequestResponseExecutionContext 都是 Workers runtime 的 API 型別。把它們直接寫在函式簽名上,閱讀程式碼時就能知道每個值的責任。

request 是 browser 送進來的 Web Request,所以可以安全使用 request.methodrequest.urlResponse 是 handler 的回傳契約;Promise<Response> 表示這是一個非同步 handler,但最後一定要交回 HTTP 回應。

ctx 則是容易被忽略、卻很適合 edge 程式的部分:

ctx.waitUntil(
  cache.put(cacheKey, response.clone()).catch((err: unknown) => {
    const message = err instanceof Error ? err.message : String(err);
    console.error("NPS response cache write failed:", message);
  }),
);

waitUntil() 告訴 Worker runtime:可以先把 response 回給使用者,但請讓 cache.put() 在背景繼續完成。response.clone() 也很重要,因為 response body 是 stream;一份要交給 client,另一份才交給 cache,不能共用同一個已被讀取的 body。若 cache.put() 拋出例外,catch 會在背景記錄 log;不過 Cache API 也可能完成呼叫卻沒有留下快取項目,因此不能把呼叫成功當成儲存保證。

satisfies:驗證 contract,又不犧牲推論

檔案結尾的這一行是這次 migration 最值得記住的語法:

} satisfies ExportedHandler<Env>;

satisfies 是 TypeScript 4.9 引入的 operator。它會檢查左邊的 Worker 物件是否符合右邊的 ExportedHandler<Env> contract;例如 handler 名稱、fetch 的參數和回傳值不對,compiler 會報錯。

ExportedHandler 是 Cloudflare Workers 為預設匯出物件提供的型別 contract。尖括號裡的 Env 是泛型參數,代表這支 Worker 的 bindings;本例中的 Envwrangler typeswrangler.jsonc 產生,因此 fetch 的第二個參數會包含 NPS_API_KEY。這個型別只在 typecheck 時檢查程式碼;必要 secret 是否已設定,則由 Wrangler 的設定與部署檢查負責。

// 讀作:使用 Env bindings 的 Cloudflare Worker handler 型別。
type NpsWorker = ExportedHandler<Env>;

它和把整個物件直接註記成 ExportedHandler<Env> 的差別,在於 satisfies 仍保留左邊物件本身推論出的精確型別,不會把它整體「變寬」成 annotation 的型別。對只有一個 fetch 的 Worker 看起來差異不大;但當物件還有自訂欄位、或想保留更精確的回傳值推論時,這個特性很有用。

換句話說,它像是在說:「請確認這是合法的 Cloudflare Worker handler,但不要抹掉我這個物件原本知道的細節。」

unknown 不是麻煩,而是 catch 的安全欄杆

strict TypeScript 不應假設所有被拋出的值都是 Error。JavaScript 允許 throw "network down"throw 42,甚至丟出任意 object。因此 catch 到的值應該是 unknown

catch (err: unknown) {
  const message = err instanceof Error ? err.message : String(err);
  console.error("NPS upstream request failed:", message);
  return jsonResponse({ error: "Upstream NPS gateway error" }, 502);
}

err instanceof Error 的 true 分支,TypeScript 知道可以讀 err.message;其餘情況先經過 String(err),log 裡仍有可診斷的訊息,不會因為硬讀不存在的 .message 再引發第二個例外。對外只回傳通用的 502 訊息,避免把 runtime 或網路細節交給呼叫端。這種根據條件把較寬型別收窄到可安全操作範圍的過程,叫做 type narrowing。

快取真正保護的是成功回應

這支 proxy 的目的之一是保護 NPS API 的 hourly rate limit。直覺上,拿到上游回應後就 cache.put() 很合理,但 production 裡有個危險的反例:NPS 暫時回 403、429 或 5xx 時,若我們照樣快取,接下來 30 分鐘的每個使用者都只會讀到那個失敗。

所以快取必須放在成功條件後面:

const shouldCache = upstreamResponse.ok && upstreamResponse.status !== 206;
response.headers.set(
  "Cache-Control",
  shouldCache ? "public, max-age=1800" : "no-store",
);

if (shouldCache) {
  ctx.waitUntil(
    cache.put(cacheKey, response.clone()).catch((err: unknown) => {
      const message = err instanceof Error ? err.message : String(err);
      console.error("NPS response cache write failed:", message);
    }),
  );
}

Response.ok 代表 HTTP status 在 200 到 299;不過 Cloudflare Cache API 不接受 206 Partial Content,所以這支 Worker 另外排除 206。失敗回應使用 Cache-Control: no-store,不會被 Worker cache 寫入,也不會要求 browser 或中間 cache 保留。這裡的取捨是刻意的:下一個 request 仍可能再打上游。若未來需要處理短暫 outage,可以另行設計 stale cache 或短 TTL 的 error policy;不要不加區分地把錯誤快取 30 分鐘。

還有一個 edge cache 的範圍問題:caches.default 的項目只存在處理這個 request 的 data center,不會自動複製到其他 Cloudflare edge location,也不能搭配 tiered cache。因此它能減少同一個 data center 後續請求打到 NPS 的次數,不代表同一區域或全球的請求都能共用這份快取。cache.put() 也不保證每次都成功儲存;30 分鐘是這支程式要求的 TTL。Cache-Control: public, max-age=1800 同時允許 browser 與中間 cache 保存成功回應,這裡的資料是公開資訊。詳情可參考 Cloudflare Cache API 文件

測試會驗證型別以外的事

TypeScript 可以檢查程式和測試程式是否符合型別 contract,但它不會執行 Worker,也不會證明每個分支真的回傳正確結果。這裡用兩種測試補上不同的觀察範圍:unit test 隔離 Worker handler 的邏輯;E2E test 則透過 HTTP 呼叫實際部署的 Worker,確認它和 NPS API 接在一起後的行為。

Unit test:隔離 handler 的分支

test/proxy.spec.ts 用 Vitest 直接呼叫匯出的 worker.fetch()。測試會用 spy 假造上游 fetch(),也會以記憶體中的 Map 模擬 caches.defaultExecutionContext 的 mock 則會收集 waitUntil() 交付的背景工作,讓測試可以等 cache write 完成後再檢查結果。這樣不需要真的呼叫 NPS,就能穩定檢查 Worker 自己的處理邏輯。

例如,這個測試確認 secret 走 X-Api-Key header、正規化後的 parkCode 進入上游 URL,以及成功 response 會排入快取:

fetchSpy.mockResolvedValueOnce(
  new Response(JSON.stringify({ data: [] }), { status: 200 }),
);

const { ctx, pendingPromises } = createMockContext();
const request = new Request(
  "https://national-parks.simplypatrick.workers.dev/alerts?parkCode=%20YOSE%20",
);
const response = await worker.fetch(request, env, ctx);

expect(response.status).toBe(200);
const [, init] = fetchSpy.mock.calls[0];
expect(new Headers(init?.headers).get("X-Api-Key")).toBe(env.NPS_API_KEY);
await Promise.all(pendingPromises);
expect(mockCache.put).toHaveBeenCalledTimes(1);

目前的 unit tests 也檢查 CORS preflight、非支援的 HTTP method 和路徑、缺少或空白的 parkCode、未設定的 API key、兩個 NPS endpoint 的 query、cache hit、上游錯誤與 206 不寫入快取、cache lookup 失敗後仍繼續呼叫上游,以及網路錯誤時回傳通用的 502。它們很適合快速確認輸入驗證、安全邊界和錯誤分支;由於上游與 cache 都是 mock,這些測試本身不會驗證 Cloudflare 的實際 edge 行為。

E2E test:走過實際部署路徑

test/proxy.e2e.spec.ts 用標準 fetch() 對 Worker URL 發 HTTP request。預設目標是正式 Worker;設定 WORKER_URL 可以改指測試部署或本機 wrangler dev。測試會以十個不同區域的 park code 查 alerts 與 visitor centers,也會檢查大小寫和前後空白的正規化,以及 OPTIONS、缺少參數、不支援的路徑和 POST 等 HTTP 行為。

以 alerts 測試為例,這裡的 assertion 會檢查實際回應,而不是 mock handler 的內部呼叫:

const response = await fetch(`${BASE_URL}/alerts?parkCode=${code}`);

expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe("*");
expect(response.headers.get("cache-control")).toContain("public");

const body = (await response.json()) as {
  data?: Array<{ id: string; title: string }>;
};
expect(Array.isArray(body.data)).toBe(true);

E2E test 會透過實際 HTTP endpoint 走過 Worker 的 request path;若指定的是部署中的 Worker,也會經過 Cloudflare runtime 和正式部署設定,cache miss 時再呼叫 NPS。它比 unit test 更接近使用者實際走過的路徑,但不會強制每個測試都命中或 miss cache。代價是它需要網路和可用的測試 Worker、secret 與上游 API,NPS 資料或服務暫時異常時也可能失敗。這組測試會查詢多個 park code,cache miss 仍會消耗 NPS API quota;不要把它當成完全離線、每次都相同的 unit test。npm run test:e2e 會使用 WORKER_URL 指定的位置,而目前預設值是公開的正式 Worker URL,執行前要留意 target。

TypeScript 在測試裡幫了什麼?

tsconfig.jsontest/**/* 也納入 strict typecheck。unit test 的 env: Env 會要求測試提供 Worker 所需的 NPS_API_KEY,而 worker.fetch(request, env, ctx) 也會在編譯期檢查 RequestEnvExecutionContext 參數。若改了 binding 名稱、handler 參數或 response 型別卻沒同步修正測試,tsc --noEmit 就能先指出程式和測試之間的落差;編輯器也能根據 Vitest 型別提供 assertion 與 mock 的補完。

不過 TypeScript 不會替執行期 JSON 做驗證。E2E 範例裡的 as { data?: ... } 只是告訴 compiler 我們預期的形狀,真正檢查 data 是不是 array 的是 Vitest assertion。unit test 把精簡的 fake context 轉成 ExecutionContext 型別,也是測試替身的邊界;它不會因此變成 Cloudflare runtime。型別、mock 測試與 live E2E 各自提供不同證據,合起來才比較容易定位問題。

讓型別檢查進入日常流程

Worker 的 package.json 很精簡:

{
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "cf-typegen": "wrangler types",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "test:unit": "vitest run test/proxy.spec.ts",
    "test:e2e": "vitest run test/proxy.e2e.spec.ts",
    "test:watch": "vitest"
  }
}

先在 wrangler.jsonc 宣告這支 Worker 必須設定的 secret:

{
  "secrets": {
    "required": ["NPS_API_KEY"]
  }
}

這讓 wrangler types 能穩定產生 NPS_API_KEY 型別,不必依賴開發機上的 .dev.vars.env。Wrangler 也會在本機開發與部署時檢查這個必要 secret。接著產生 Cloudflare 對應的宣告檔:

npm run cf-typegen

它會產生 worker-configuration.d.ts,提供 Worker runtime 型別,也依 Wrangler 設定產生全域 Env 宣告,包含 NPS_API_KEY。這是 TypeScript 編譯期讀取的型別,不會變成執行期 JavaScript,所以 Worker 原始碼不用匯入 RequestResponseExportedHandler。前面的小範例手動寫 interface Env 是為了介紹型別 contract;完整 Worker 則直接使用生成的 Env,避免兩份宣告日後不同步。詳情可參考 Cloudflare TypeScript 文件

無論採用哪種方式,都要讓 tsconfig.json 把生成檔納入 typecheck;本文的設定用 include 收錄 worker-configuration.d.ts。接著在部署前執行:

npm run typecheck
npm run test:unit
npm run deploy

tsc --noEmit 只做檢查,不產生 JavaScript;Wrangler 仍負責本機開發、打包與部署。npm run test:unit 不依賴 live NPS API;需要檢查整條部署路徑時,再用 npm run test:e2e,並先確認 WORKER_URL 指向預期環境。npm test 會讓 Vitest 執行兩個測試檔,所以目前也會跑 E2E test,預設連到正式 Worker。tsconfig.jsonstrict: true 值得保留:它會讓可為空的值、隱含 any 和未處理的型別情況更早浮出來,而不是在 edge log 裡才第一次見面。

從 JavaScript migration 帶走的事

這個 Worker 沒有突然變得很複雜。它仍是一個驗證 request、向 NPS 發送請求、快取成功結果並回傳 response 的小程式。TypeScript 把 secret 名稱、handler 參數、背景工作和錯誤值的 contract 變成編譯器可檢查的資訊;unit tests 會逐條走過 handler 的成功與失敗分支,E2E tests 再確認部署環境中的實際 HTTP 行為。

我很喜歡把這類 edge proxy 當作 TypeScript 的實戰題目:程式碼短、每個型別都有現實意義,也能看出型別檢查和測試如何互補。型別能先抓出程式結構不一致的地方;要確認回應真的正確,還是得讓測試執行程式,甚至走過部署中的 Worker。過程也帶出一個 production 原則——快取的是可重複使用的成功結果,不是暫時的失敗。


Lock-free 資料結構實戰

深入解構 CAS 狀態轉移、Tagged Pointer、Hazard Pointer、EBR 與 RCU 的架構權衡與實作細節
featured.svg

在上一篇 《C++ 與 Rust 記憶體模型:從 Memory Ordering、硬體快取到 Safe Publication》 中,我們建立了對記憶體順序、編譯器重排、Store Buffer 與快取一致性的完整認知。我們明白了單一原子操作的成功,並不自動等同於周邊資料已經同步可見;唯有透過 Acquire-Release 配對,才能在多核心之間拉起堅固的因果同步橋樑。

掌握了記憶體模型這張入場券後,我們終於可以直面高效能並行程式設計的聖杯——無鎖資料結構(Lock-free Data Structures)

許多人剛接觸無鎖程式設計時,常有一種美麗的幻想:「只要把所有 std::mutex 拿掉,改用 CAS(Compare-And-Swap)迴圈,程式碼就會奇蹟般地變快且沒有鎖衝突。」

然而,現實往往十分殘酷。當你拿掉互斥鎖的保護後,除了必須自行處理狀態移轉與記憶體順序外,還會立刻遭遇並行運算領域的兩大深水區:

  1. 拓撲移轉的幽靈——ABA 問題:指標位址相同,真的代表鏈結結構沒有改變過嗎?
  2. 記憶體生命週期的深淵——安全記憶體回收(Safe Memory Reclamation, SMR):當一個節點被某個執行緒從資料結構中拔除時,其他並行的讀者執行緒可能正剛讀出指標準備解引用(dereference)。如果此時直接 delete 或釋放記憶體,就會引發致命的 Use-After-Free 記憶體損毀。

本文將以經典的 Treiber Stack 為基準,透過現代 C++20 與 Rust 的雙語言實作,深入解構 CAS 原語、狀態重試機制,並全面剖析 Tagged Pointer、Hazard Pointer、Epoch-Based Reclamation (EBR) 與 RCU 四大安全記憶體回收架構的設計權衡。

從 Memory Ordering 到 Lock-Free:思維範式轉移

在傳統的並行架構中,互斥鎖(Mutex)扮演著全能守護者的角色。當一個執行緒進入 mutex.lock()mutex.unlock() 包夾的臨界區(Critical Section)時,Mutex 同時為我們解決了三件事:

mutex-vs-lockfree.svg

當我們決定移除 Mutex 時,上述三項保護全部煙消雲散。我們必須:

  1. CAS(Compare-And-Swap) 取代臨界區互斥,讓多個執行緒樂觀競爭狀態轉移。
  2. Acquire / Release / Relaxed 精確標註每次原子讀寫,確保資料初始化與發布具備因果先後。
  3. 引進 安全記憶體回收(SMR) 機制,接手原先由鎖保護的物件生命週期。

既然 Mutex 能如此滴水不漏地提供三重防護,為什麼並行系統工程師依然對「擺脫互斥鎖」如此執著?這就必須從 Mutex 在微觀硬體與宏觀作業系統排程中所背負的隱形成本談起。

互斥鎖的真實代價:從輕量原子到核心態深淵

許多人直覺認為「鎖就只是一道門,排隊通過即可」。但在現代多核心架構與搶佔式作業系統下,Mutex 的運作遠比想像中複雜。現代主流作業系統的 Mutex(如 Linux 的 Futex 或 Windows 的 SRWLock)均採用「快慢雙路徑」設計:

mutex-cost-paths.svg

這套混合架構雖然極大化了無競爭情境的效能,但只要並發度提高並引發持續競爭,Mutex 的連鎖代價便會逐一浮現:

上下文切換與核心態調度開銷

在無競爭的快樂路徑(Happy Path)上,mutex.lock() 僅是一次純使用者態的原子操作,耗時通常在 10 到 25 奈秒之間。然而,一旦多個執行緒同時爭搶鎖,自旋未果的執行緒就必須發起系統呼叫(如 Linux 的 SYS_futex),將自己掛起並交出 CPU。

一次完整的上下文切換(Context Switch)涉及暫存器狀態保存、進入核心態排程器佇列、切換虛擬記憶體頁表與上下文環境。這段延遲直接暴增至 1,000 到 5,000 奈秒(數量級約在 1 到 5 微秒,視架構與負載而定),比一次純記憶體操作慢了整整兩到三個數量級。

快取污染與快取行彈跳

當一個被掛起的執行緒在數微秒後被排程器重新喚醒時,極有可能被分派到另一個閒置的 CPU 核心上執行。這會帶來嚴重的快取懲罰:

  • 該執行緒原本在 L1/L2 快取中快取的熱點資料早已失效(Cold Cache Misses),甚至需要重新走 TLB 查表。
  • 多個核心爭奪同一個 Mutex 內部狀態變數時,每次加鎖與解鎖的原子寫入都會觸發快取一致性協定(如 MESI/MOESI)在多核心互連架構上引發大量無效化訊息。這會造成嚴重的快取行彈跳(Cacheline Bouncing),使該快取行的互連流量飽和,甚至拖慢共享同一快取階層的其他無關工作。

護送效應與擴展性懸崖

當系統面臨高度並發時,鎖的等待佇列會形成惡性循環的「護送效應」(Convoy Effect)。由於每個執行緒被喚醒時都要承擔額外的核心調度延遲,即使臨界區內的運算只有短短 20 奈秒,整個系統的推進節奏也會被微秒級的喚醒排程完全綁架。

此時系統不僅面臨阿姆達爾定律(Amdahl’s Law)所定義的循序上限(臨界區限制了理論最大加速比),更會觸發嚴重的負向擴展(Negative Scaling):增加更多的 CPU 核心非但無法提升吞吐量,反而因為更激烈的鎖競爭、快取行彈跳風暴與微秒級排程調度延遲放大,導致整體效能呈懸崖式暴跌。

優先權反轉

在具備執行緒優先權的即時或多工作業系統中,Mutex 存在著名的結構性缺陷——優先權反轉(Priority Inversion):

  • 低優先權執行緒獲取了鎖,進入臨界區。
  • 隨後,中優先權執行緒搶佔了 CPU(中優先權工作不需要該鎖,但優先級高於低優先權)。
  • 此時高優先權執行緒被喚醒,需要獲取同一個鎖,但由於低優先權執行緒被中優先權卡住而無法執行,高優先權執行緒被迫無限期等待。

1997 年美國 NASA 的火星探測器「火星拓荒者號」(Mars Pathfinder)便曾因為資訊匯流排 Mutex 發生優先權反轉,導致高優先權的資料收集任務嚴重逾時,觸發系統看門狗(Watchdog)不斷重新開機。

不可組合性與死鎖風險

鎖缺乏代數上的組合性(Locks do not compose)。兩個單獨看來無懈可擊、內部各自用 Mutex 保護的執行緒安全函式,在交叉呼叫時若未能嚴格遵循完全一致的加鎖順序,就會在多執行緒交錯下瞬間觸發死鎖(Deadlock)。

為什麼需要無鎖:不可妥協的場景與系統保證

正因為 Mutex 存在上述硬體與排程層面的代價,在特定工程領域中,我們並非單純為了「追求理論效能」,而是面對硬性約束時「不得不採用無鎖」。無鎖資料結構的核心價值在於提供兩大不可替代的保證:系統級進度保證低且可預測的長尾延遲

系統級進度保證:杜絕命運綁定

基於互斥鎖的系統存在著嚴重的命運綁定(Fate Sharing):一旦持有鎖的執行緒因為任何非預期事件(被作業系統強制暫停、遭遇分頁置換 Page Fault、觸發例外、甚至被外部訊號直接殺死),所有正在等待該鎖的其他執行緒都將全數卡死或永久阻塞

無鎖演算法消除了「鎖的所有權」概念。沒有任何一個執行緒具備獨佔資源的特權;執行緒之間只透過硬體原語競逐狀態轉移。即使某個執行緒在執行途中被作業系統隨機凍結,其他執行緒依然能各憑本事持續推進。

消除長尾延遲

在金融高頻交易(HFT)、即時競價、分散式儲存引擎與資料庫 WAL(Write-Ahead Log)的關鍵路徑上,系統衡量標準不是「平均延遲(P50)有多低」,而是「99.9% 甚至 99.99% 的長尾延遲(P99 / P99.9)有多穩定」。

Mutex 偶發的鎖競爭與 Context Switch 會在微秒層級造成嚴重的延遲抖動(Jitter)。Lock-Free 演算法將操作完全收斂在純使用者態與 CPU 執行單元內,徹底拔除了核心態排程介入的可能性,提供了極高確定性的延遲表現。

嚴格禁止睡眠的極限環境

在許多系統底層場景中,呼叫任何可能讓執行緒陷入睡眠的 Mutex 操作,在語法或規範上是被絕對嚴格禁止的

  • 中斷處理常式(ISR)與訊號處理函式(Signal Handlers):硬體中斷與 Linux 訊號發生在特殊的非同步上下文,該上下文根本沒有獨立的排程實體可供掛起,且必須保證非同步信號安全(Async-Signal-Safe)。一旦在此處嘗試加鎖並陷入睡眠,將導致系統崩潰或瞬間死鎖。
  • 即時音訊處理(Real-time Audio DSP):在 CoreAudio、JACK 或 ASIO 等音訊框架中,處理回呼函式必須在數微秒至數百微秒的嚴格時限內將音訊緩衝區填滿。若因 Mutex 競爭觸發 Context Switch,哪怕只延誤了 10 微秒,都會引發音訊緩衝區欠載(Buffer Underrun),在使用者耳機中產生刺耳的破音或爆音。
  • 非同步協程執行緒池(Async Runtime Worker Threads):在 Tokio、Go Runtime 等 M:N 協程排程體系中,少數幾個工作執行緒負責輪番執行成千上萬個非同步協程(Coroutines / Tasks)。如果某個協程的工作函式呼叫了傳統的 Blocking Mutex 並陷入核心態睡眠,將導致該 OS 執行緒上排隊的所有其他協程一併陷入飢餓與癱瘓。

進度保證等級:Lock-Free、Wait-Free 與 Obstruction-Free

在學術與系統規格中,「無鎖」並非單純指「程式碼中沒有搜尋到 mutex 關鍵字」,而是對系統並行推進能力的嚴格數學承諾:

  • Obstruction-Free(無障礙):最弱的保證。只要一個執行緒在執行時其他執行緒全部暫停,該執行緒保證能在有限步驟內完成操作。若多個執行緒持續活鎖競爭,則不保證進展。
  • Lock-Free(無鎖):保證整個系統在巨觀上持續有進度(System-wide Progress)。即使作業系統在任何時刻隨機將某些執行緒掛起(Suspend)或降低優先權,剩餘的執行緒中至少有一個一定能持續完成操作。但個別執行緒可能會遭遇飢餓(Starvation)。
  • Wait-Free(無等待):最強的進度保證。每一個執行緒都保證能在有限的步驟內(Bounded Steps)完成操作,徹底杜絕了個別執行緒的飢餓現象。通常需要搭配昂貴的協同幫忙(Helping Scheme)機制。

一般的並行佇列與堆疊多半落在 Lock-Free 等級。

核心原子原語:Compare-And-Swap (CAS) 的運作機制

Lock-Free 資料結構的心臟是 CAS 操作。在 C++ 中體現為 atomic::compare_exchange_weakcompare_exchange_strong;在 Rust 中則為 AtomicPtr::compare_exchange_weakcompare_exchange

CAS 操作將「比較目前值是否符合預期」與「寫入新數值」合併為語言層級不可分割的原子讀改寫(Read-Modify-Write, RMW)操作(在底層硬體上可能對應單一指令如 x86 CMPXCHG、或 ARM/RISC-V 的 LL/SC 指令對):

cas-mechanism.svg

如果目標記憶體目前的值等於 expected,硬體就將其替換為 desired 並回傳成功;如果不等於,硬體放棄寫入,並自動將目前記憶體的最新值寫回 expected 變數中,回傳失敗。

為什麼 CAS 需要兩個 Memory Ordering 參數?

檢視 C++ 與 Rust 的 CAS 函式簽名,你會發現它們都要求傳入兩個 ordering:

// C++
bool compare_exchange_weak(T& expected, T desired,
                           std::memory_order success,
                           std::memory_order failure);
// Rust
pub fn compare_exchange_weak(
    &self,
    current: *mut T,
    new: *mut T,
    success: Ordering,
    failure: Ordering
) -> Result<*mut T, *mut T>;

為什麼不能只用一個 ordering?

  • 成功時(Success):執行了實質的讀改寫(RMW),我們正在發布新的狀態給其他執行緒,因此可以使用 ReleaseAcquireAcqRelSeqCst
  • 失敗時(Failure):沒有發生任何寫入操作!失敗的 CAS 本質上是一次純讀取的載入(將目標位址的最新值寫回 expected)。因為沒有寫入,所以在語意上失敗 ordering 不能要求 ReleaseAcqRel 效果;同時在 C++ 規範中,失敗 ordering 不得嚴於成功 ordering(例如成功為 Relaxed 時,失敗不得為 Acquire);而在 Rust 中若傳入不合法的失敗 ordering 亦會在執行期引發 panic。

weakstrong 的本質差異

  • compare_exchange_strong:保證只有在當前值不等於 expected 時才會回傳 false
  • compare_exchange_weak:允許在當前值明明等於 expected 的情況下,依然因為硬體因素(例如 ARM/RISC-V 的 LL/SC 快取行被踢出、中斷或 context switch)而發生虛假失敗(Spurious Failure)。

最佳實踐原則:在絕大多數 Lock-free 演算法中,CAS 本身就被包裹在一個重試迴圈裡。此時通常建議優先考慮 compare_exchange_weak。在弱記憶體架構(如 ARM64)上,weak 能直接映射到最精簡的單對 LL/SC(ldaxr/stlxr)指令,免去 strong 為了防止硬體虛假失敗所額外生成的內部重試迴圈包裝(但在 x86 等強記憶體架構上,兩者皆編譯為 lock cmpxchg,效能表現相同)。

經典實戰:Treiber Stack 雙語言基準實作(不含 SMR)

Treiber Stack(由 R. Kent Treiber 於 1986 年提出)是無鎖領域中最基礎、最優雅的資料結構。它本質上是一個單向鏈結串列,所有操作都僅集中在單一原子指標——head 上:

treiber-stack-topology.svg

⚠️ 重要實作前提:本節程式碼為不含 SMR(安全記憶體回收)的教學基準實作,旨在精確解構 CAS 狀態轉移與 Acquire/Release 配對。它的正確性嚴格建立在三個限制邊界假設上:

  1. 節點不得就地釋放(不得中途呼叫 delete 或歸還給記憶體配置器)。
  2. 節點不得重複 push,且呼叫端不得在其他執行緒可能持有指標時修改節點欄位。
  3. 節點生命週期維持至所有執行緒完全 join 離場後,由擁有者統一釋放(杜絕並行執行時的 Use-After-Free)。 在未引入後續章節探討的 SMR 安全回收協定前,切勿將此裸指標實作直接移植至生產環境!

C++20 實作

#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>

template <typename T>
class TreiberStack {
public:
    struct Node {
        T data;
        Node* next{nullptr};
        explicit Node(T val) : data(std::move(val)) {}
    };

private:
    // 注意:在主流 64 位元架構上 std::atomic<Node*>::is_always_lock_free 為 true。
    // 在特殊嵌入式或非原生字長環境下,需透過 is_lock_free() 確認硬體無鎖保證。
    std::atomic<Node*> head_{nullptr};

public:
    TreiberStack() = default;
    ~TreiberStack() = default;

    // 禁止複製與搬移以確保原子指標位址固定
    TreiberStack(const TreiberStack&) = delete;
    TreiberStack& operator=(const TreiberStack&) = delete;

    void push(Node* new_node) {
        // 步驟 1:使用 relaxed load 取得目前的 head
        // 理由:我們此處只是讀取指標位址賦予 new_node->next,
        // 尚未 dereference 舊節點,不需要任何跨執行緒同步。
        Node* old_head = head_.load(std::memory_order_relaxed);

        // 步驟 2:CAS 迴圈
        // 成功的 CAS 使用 release:確保 new_node 的 data 與 next 初始化完成後才發布。
        // 失敗的 CAS 使用 relaxed:若失敗,old_head 會被自動更新,直接進入下一輪。
        do {
            new_node->next = old_head;
        } while (!head_.compare_exchange_weak(
            old_head, new_node,
            std::memory_order_release,
            std::memory_order_relaxed));
    }

    Node* pop() {
        // 步驟 1:使用 acquire load 取得目前的 head
        // 理由:我們接下來必須安全讀取 old_head->next,因此必須與當初 push 該節點的 release store 同步!
        Node* old_head = head_.load(std::memory_order_acquire);

        while (old_head != nullptr) {
            // 讀取下一個節點(此時依賴前面 load 或上一次失敗 CAS 的 acquire 同步)
            Node* next_node = old_head->next;

            // 步驟 2:CAS 嘗試將 head 移向 next_node
            // 成功:使用 acquire 保持防禦性同步。
            // 失敗:關鍵!失敗 ordering 必須也是 acquire!
            // 理由:若 CAS 失敗,old_head 會被更新為另一個執行緒剛 push 上去的新節點;
            // 下一輪迴圈會立刻執行 old_head->next,此處的 acquire 才能與該執行緒 push 的 release 建立因果同步!
            if (head_.compare_exchange_weak(
                    old_head, next_node,
                    std::memory_order_acquire,
                    std::memory_order_acquire)) {
                return old_head;
            }
        }
        return nullptr; // stack 為空
    }
};

int main() {
    TreiberStack<int> stack;
    typename TreiberStack<int>::Node n1(10);
    typename TreiberStack<int>::Node n2(20);

    // 多執行緒並行 Push
    std::thread t1([&] { stack.push(&n1); });
    std::thread t2([&] { stack.push(&n2); });
    t1.join();
    t2.join();

    // 多執行緒並行 Pop
    typename TreiberStack<int>::Node* res1 = nullptr;
    typename TreiberStack<int>::Node* res2 = nullptr;

    std::thread t3([&] { res1 = stack.pop(); });
    std::thread t4([&] { res2 = stack.pop(); });
    t3.join();
    t4.join();

    assert(res1 != nullptr && res2 != nullptr);
    assert(res1 != res2);
    assert(res1->data + res2->data == 30);
    assert(stack.pop() == nullptr);

    std::cout << "Treiber Stack operations completed successfully.\n";
}

Rust 實作

在 Rust 中,裸指標的解引用屬於 unsafe 操作。Rust 的型別系統迫使我們明確寫出哪些假設是由程式員擔保的:

use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::Arc;
use std::thread;

pub struct Node<T> {
    pub data: T,
    pub next: *mut Node<T>,
}

impl<T> Node<T> {
    pub fn new(data: T) -> Self {
        Self {
            data,
            next: std::ptr::null_mut(),
        }
    }
}

pub struct TreiberStack<T> {
    head: AtomicPtr<Node<T>>,
}

// 實作 Send 與 Sync:這是作者對型別安全不變量的承諾
// 只要 T: Send,節點所有權在 pop 時即可安全轉移跨執行緒存取;
// 由於內部 head 為 AtomicPtr,亦滿足多執行緒共用參照的安全性。
unsafe impl<T: Send> Send for TreiberStack<T> {}
unsafe impl<T: Send> Sync for TreiberStack<T> {}

impl<T> TreiberStack<T> {
    pub fn new() -> Self {
        Self {
            head: AtomicPtr::new(std::ptr::null_mut()),
        }
    }

    pub fn push(&self, new_node: *mut Node<T>) {
        assert!(!new_node.is_null());
        let mut old_head = self.head.load(Ordering::Relaxed);
        loop {
            // 安全保證:呼叫者保證在此刻獨占 new_node
            unsafe {
                (*new_node).next = old_head;
            }

            // 成功 Release 發布新節點;失敗 Relaxed 載入最新 head 重試
            match self.head.compare_exchange_weak(
                old_head,
                new_node,
                Ordering::Release,
                Ordering::Relaxed,
            ) {
                Ok(_) => break,
                Err(actual) => old_head = actual,
            }
        }
    }

    pub fn pop(&self) -> *mut Node<T> {
        let mut old_head = self.head.load(Ordering::Acquire);
        while !old_head.is_null() {
            // 安全保證:基準範例保證節點在全部執行緒離場前不會被 free
            let next_node = unsafe { (*old_head).next };

            // 成功 Acquire 獲取;失敗 Acquire 確保下輪解引用 actual 時具備同步保護
            match self.head.compare_exchange_weak(
                old_head,
                next_node,
                Ordering::Acquire,
                Ordering::Acquire,
            ) {
                Ok(_) => return old_head,
                Err(actual) => old_head = actual,
            }
        }
        std::ptr::null_mut()
    }
}

// 輔助型別:封裝裸指標以符合 Send 要求,安全傳遞進 thread::spawn
struct SendPtr<T>(*mut Node<T>);
unsafe impl<T: Send> Send for SendPtr<T> {}
impl<T> SendPtr<T> {
    fn into_inner(self) -> *mut Node<T> {
        self.0
    }
}

fn main() {
    let stack = Arc::new(TreiberStack::new());
    let mut node1 = Box::new(Node::new(10));
    let mut node2 = Box::new(Node::new(20));

    let n1_ptr = SendPtr(&mut *node1 as *mut Node<i32>);
    let n2_ptr = SendPtr(&mut *node2 as *mut Node<i32>);

    let s1 = Arc::clone(&stack);
    let t1 = thread::spawn(move || s1.push(n1_ptr.into_inner()));
    let s2 = Arc::clone(&stack);
    let t2 = thread::spawn(move || s2.push(n2_ptr.into_inner()));
    t1.join().unwrap();
    t2.join().unwrap();

    let s3 = Arc::clone(&stack);
    let t3 = thread::spawn(move || SendPtr(s3.pop()));
    let s4 = Arc::clone(&stack);
    let t4 = thread::spawn(move || SendPtr(s4.pop()));

    let r1 = t3.join().unwrap().into_inner();
    let r2 = t4.join().unwrap().into_inner();

    assert!(!r1.is_null() && !r2.is_null());
    assert_ne!(r1, r2);

    let sum = unsafe { (*r1).data + (*r2).data };
    assert_eq!(sum, 30);
    assert!(stack.pop().is_null());

    println!("Rust TreiberStack executed safely.");
}

線性化點(Linearization Point)剖析

在並行驗證理論中,一個無鎖操作必須存在一個確切的原子瞬間,使得該操作在該時刻「邏輯生效」,這被稱為線性化點

  • push 的線性化點:CAS 成功將 head 指向 new_node 的那一瞬間。
  • pop 的線性化點(非空):CAS 成功將 head 指向 next_node 的那一瞬間。
  • pop 的線性化點(為空):當 head.load(Acquire) 讀到 nullptr 的那一瞬間。

致命幽靈:ABA 問題深度剖析

現在,讓我們放寬前面基準實作的假設:「允許節點被重複使用或歸還配置器」。災難立刻降臨。

這就是並行計算中最著名的陷阱——ABA 問題

ABA 問題如何發生?

假設目前的 Stack 拓撲為 head -> [A] -> [B] -> [C]

此時有兩個執行緒 T1 與 T2 正在執行 pop()

aba-problem-timeline.svg

拆解 ABA 的本質

當 T1 喚醒並嘗試執行 CAS 時,它僅僅比對了 head == A。 在 T1 的眼裡:「head 的記憶體位址依然是 A,代表沒有人動過這個 stack!」 但實際上,整個 Stack 經歷了 A → B → A 的劇烈變動:

  1. B 早已被 T2 取走並可能已被 delete 釋放。
  2. 節點 A 雖然位址回到頂端,但它的 next 已經從原先的 B 變成了 C。
  3. T1 盲目地將 head 覆寫為暫存的 B,直接導致:
    • 資料遺失:節點 C 脫離了鏈結串列,發生記憶體洩漏。
    • 記憶體損毀(Use-After-Free):head 指向了一個已經被 T2 釋放的無效位址 B,下一個呼叫 pop() 的執行緒解引用 head->next 時將立刻崩潰(Crash / Segment Fault)!

ABA 的核心教訓是:指標位址相等,絕不代表資料結構的內部拓撲與歷史狀態未曾改變

四大安全記憶體回收機制(Safe Memory Reclamation)

為了解決 ABA 問題以及多執行緒並行釋放節點時的 Use-After-Free,學界與工業界發展出了四種主流機制。在深入細節前,我們必須先釐清一條核心界線:Tagged Pointer 與後三種 SMR 機制的防護目標有著本質區別

  • Tagged Pointer:本質是「狀態變更偵測協定」。它透過版本號偵測指標位址相同時的拓撲演變,但計數器仍有溢位循環(Wraparound)的理論可能,且它完全無法防護節點的解引用生命週期
  • Hazard Pointer / EBR / RCU:屬於真正的「安全記憶體回收(SMR)協定」。它們的主要目標是確保「只要有任何讀者可能解引用某個節點,該節點的物理記憶體就絕不會被釋放」。在工程實踐中,這直接消除了最常見的 ABA 根源(即節點被釋放後遭記憶體配置器原地重用);但若演算法本身存在非記憶體重用引起的語意 ABA,仍需搭配版本號處理。
smr-architecture-taxonomy.svg

Tagged / Versioned Pointer(代數標記指標)

最直接的直覺是:既然只看指標會被騙,那我們在指標旁邊加上一個單調遞增的版本號(Tag / Counter)!

每次對 head 進行修改時,不僅更新指標,還將版本號加 1:

狀態演變:
(指標 A, 版本 1) ➜ (指標 B, 版本 2) ➜ (指標 C, 版本 3) ➜ (指標 A, 版本 4)

當 T1 甦醒嘗試執行 CAS 時:

  • T1 預期的狀態是 (A, 1)
  • 目前 head 的實際狀態是 (A, 4)
  • CAS 判定兩者不相等,失敗!ABA 被成功攔截。

實作方式與限制

  1. 雙倍寬度 CAS(DWCAS):在 64-bit 系統上,指標佔 64-bit,計數器佔 64-bit,總共需要 128-bit 的原子 CAS(x86-64 的 CMPXCHG16B 或 AArch64 的 CASP)。需注意在部分平臺上 128-bit 原子操作可能非硬體原生無鎖(需透過 is_lock_free() 驗證),且在高頻競爭下版本計數器仍存在循環繞回(Wraparound)風險。
  2. 指標壓縮(Pointer Packing):在 64-bit 虛擬位址空間中,指標僅使用規範位址(Canonical Address)的低位元。在四級分頁(48-bit 虛擬位址)下,使用者空間位址的高 16 位元可被借用作為版本標記;若在五級分頁(57-bit 虛擬位址)架構下,則僅剩最高 7 位元可用。重大相容性限制:嵌入標記後的指標並非合法位址,解引用前必須先透過 bitmask 遮罩清除標記;且在 AArch64 架構上,硬體功能如頂部字節忽略(TBI)、記憶體標記擴展(MTE)與指標認證(PAC)皆會使用指標高位元,任意壓縮指標會破壞跨平台可攜性。
  3. 重大盲點:Tagged Pointer 能偵測狀態改變,但完全無法保護記憶體生命週期!如果在 T1 讀取 old_head->next 的瞬間,節點 A 的記憶體已經被 T2 歸還給作業系統(munmap),T1 的讀取操作依然會直接觸發硬體分頁錯誤(Page Fault)崩潰!

Hazard Pointer(風險指標)

由 Maged Michael 於 2004 年提出(論文發表於 2002 年),已正式納入 C++26 標準庫std::hazard_pointer)。

Hazard Pointer 的設計哲學是:讀者在存取某個節點前,先在全域公開的看板(Hazard Pointer Array)上宣告:「我正在閱讀指標 P,誰都不准釋放它!」

hazard-pointer-flow.svg

回收流程(Retire)

當某個執行緒成功將節點 P 從 Stack 中 pop 出來時,它不能立刻釋放 P。而是將 P 放入該執行緒私有的「待回收清單(Retired List)」。 當 Retired List 累積到一定閾值時,執行緒發動垃圾回收:

  1. 掃描系統中所有執行緒目前登記的 Hazard Pointer 看板。
  2. 如果節點 P 出現在任何一個看板上,說明仍有讀者正在讀取它,保留 P
  3. 如果沒有任何看板引用 P,說明所有讀者都已經知曉 P 已被移除,安全釋放 Pdeletefree)。
  • 優點:記憶體回收上限有嚴格保證,不會因個別執行緒暫停而導致記憶體無限制膨脹;對單一節點保護精確。
  • 缺點:讀者存取節點時需寫入並重新驗證當前執行緒的 Hazard Slot,且回收端(Reclaimer)掃描所有執行緒看板時需負擔全域記憶體屏障開銷;此外走訪長鏈結串列時需要動態維護多個 Hazard Slot。

Epoch-Based Reclamation (EBR)

Epoch-Based Reclamation 是目前高效能無鎖資料結構(特別是 Rust 生態系,如標竿庫 crossbeam-epoch)最廣泛採用的方案。

EBR 不去逐一追蹤每一個節點,而是將整個系統的時間切分成 世代(Epoch,例如 0, 1, 2):

epoch-reclamation.svg

EBR 的黃金準則

  1. 任何執行緒在存取無鎖結構前,必須先呼叫 guard = epoch::pin()。這會將當前執行緒標記為活躍,並綁定在當前全域 Epoch。
  2. 只要執行緒處於 pin() 狀態,它所看見的所有節點都保證不會被釋放
  3. 被移除的節點會被標記退役(Retire)並丟入當前 Epoch 的垃圾箱。
  4. 推進世代:當系統發現所有曾活躍於前一世代的執行緒皆已離場(不再 pin 在較舊世代)時,全域 Epoch 即可安全推進至下一代。
  5. 安全回收:處於 (E - 2) 世代的垃圾,保證沒有任何存活的讀者能看見,可以安全批次釋放
  • 優點:在 pin() 期間,讀取任何節點完全不需要任何原子寫入或硬體屏障,效能幾乎等同於讀取一般指標,非常適合走訪大規模樹狀結構或跳躍表(SkipList)。
  • 缺點(致命傷):若有任何一個執行緒呼叫了 pin() 後發生長久停頓(例如執行耗時計算、被作業系統搶佔或發生 I/O 阻塞),全域 Epoch 將無法推進。這會導致垃圾回收被無限期延宕,系統中累積的退役節點記憶體無界膨脹,最終引發 OOM(Out of Memory)!值得澄清的是,這只會卡住記憶體回收流程,其他執行緒在資料結構本身的 push/pop 運作依然能持續推進。

Read-Copy-Update (RCU)

RCU 是 Linux 核心中支撐百萬級網路轉發與檔案系統路由的核心機制,亦有使用者空間實作(Userspace RCU, liburcu)。此外,C++26 草案亦已正式納入 <rcu> 標頭檔支援。

RCU 特別針對 讀極多、寫極少(Read-Mostly)的資料結構設計:

  • Reader:透過 rcu_read_lock() 進入臨界區,不執行任何鎖定、不修改任何共享計數器,直接讀取指標。讀取開銷近乎為零。
  • Writer:不能就地修改資料。必須先複製一份舊資料副本,在副本上完成修改,接著透過原子指標替換(Release store)將入口切換到新版本。
  • 寬限期(Grace Period)與非阻塞回呼:切換入口後,舊版本資料不能立刻釋放。Writer 可呼叫 synchronize_rcu() 阻塞等待,直到切換前就已進入臨界區的所有舊讀者全部執行完畢離場;或呼叫非阻塞的 call_rcu() 註冊回呼函式,待寬限期結束後由背景機制非同步安全銷毀舊版本資料。

四大記憶體回收機制架構對比

機制 保護粒度 讀取端開銷 記憶體上限保證 對執行緒長久停頓(Stall)敏感度 典型應用場景
Tagged Pointer 單一指標 零額外讀取負擔(需 DWCAS) 無(無法解決釋放引發的崩潰) 不敏感 僅防範拓撲 ABA,需搭配其他回收方案
Hazard Pointer 個別節點 中等(每次解引用需寫入全域狀態並下屏障) 嚴格保證(垃圾量有明確上限) 極佳(單一執行緒暫停僅卡住少數節點) 節點數量少、執行緒可能隨機掛起或有即時性要求之系統
Epoch-Based (EBR) 整段操作臨界區 極低(僅進入/離開時標記,走訪零負擔) 弱(取決於最慢的執行緒) 極高(單一執行緒停頓會拖垮全域回收) 高效能記憶體快取、跨執行緒並行 Map/SkipList(如 crossbeam
RCU 整個資料版本 近乎為零(普通指標存取) 弱(寬限期內需維持雙版本) 高(需等待寬限期排空) 路由表、設定檔更新、讀極多寫極少之系統服務

全部改成 SeqCst,也救不了生命週期

並行開發者常犯的一個危險認知是:「既然 memory ordering 這麼複雜,我乾脆把全專案的原子操作全部改成 std::memory_order_seq_cstOrdering::SeqCst,這樣不就萬無一失了嗎?」

讓我們用一個最殘酷的時序交錯來擊碎這個幻想:

假設 head 指標全域採用 SeqCst,不使用任何 SMR 回收防護:

執行緒 1 (Reader)                     執行緒 2 (Reclaimer)
------------------------------------------------------------
Node* p = head.load(SeqCst);
// 此時 p 存放節點 A 的記憶體位址
                                      head.compare_exchange_strong(p, p->next, SeqCst);
                                      // 成功將 A 從鏈結中移除!
                                      delete p; // 釋放節點 A 的記憶體!

int val = p->data; // USE-AFTER-FREE 災難!
// 記憶體已被釋放,甚至已被作業系統收回,直接觸發 Segment Fault!

在這個時序中:

  • 執行緒 1 的 load 是完全合法的 SeqCst
  • 執行緒 2 的 CAS 與 delete 也是完全合法的 SeqCst
  • 整個過程中沒有任何違反記憶體順序的情形發生

然而,程式依然崩潰了

這是因為:Memory Ordering 管的是變數寫入與觀察的可見性因果;而 SMR 管的是底層記憶體區塊實體是否依然存活合法! 最強的記憶體順序也無法穿越空間,阻止作業系統將已經 free 掉的記憶體分頁標記為無效。

Lock-Free 程式碼審查三大檢驗清單

在審查任何無鎖演算法或資料結構時,請務必按照以下三大獨立維度逐一檢驗:

  1. 狀態轉移正確性(State Transition & CAS):
    • CAS 的條件是否足以代表系統真實狀態?
    • 是否存在指標重複配置造成的 ABA 偽成功?(是否需要 Tagged Pointer 或版本號?)
    • 失敗重試路徑是否會發生死迴圈或活鎖?
  2. 可見性同步保證(Memory Ordering):
    • 新節點在寫入原子變數對外發布前,內部資料是否使用 Release 確立因果?
    • 讀者在解引用指標前,是否使用 Acquire 與發布者建立 synchronizes-with
    • CAS 的失敗 ordering 是否正確排除了 Release,並在需要重試解引用時維持了 Acquire
  3. 記憶體生命週期安全(Safe Memory Reclamation):
    • 當指標被載入到執行緒暫存器後,到真正存取完畢期間,該記憶體區塊是否受到 HP 或 EBR 的保護?
    • 節點退役(Retire)後,是否有嚴格的寬限期或計數比對機制,確認所有並行讀者全數離場才執行物理釋放?

AI 在無鎖程式設計中的角色:輔助撰寫與形式驗證

隨著大型語言模型(LLM)與 AI 輔助程式設計工具(如 Claude Code)的普及,許多開發者開始嘗試讓 AI 撰寫或最佳化無鎖資料結構。在一般業務邏輯中,AI 能大幅提升產能;但在並行程式設計——尤其是弱記憶體模型與硬體原子原語的世界裡,AI 究竟是強大夥伴,還是潛伏的定時炸彈?

我們應該如何正確運用 AI 來輔助無鎖程式設計與程式碼驗證?

為什麼不能盲目信任 AI 寫出的 Lock-Free 程式碼?

無鎖程式設計本質上是極端交錯狀態下的離散數學。而現今的 LLM 是基於機率分佈預測下一個 token 的模型,擅長提取常見模式,但在面對非直覺的底層硬體特性時,極易產生隱蔽盲區:

  • 幻覺與過度簡化(The Naive Loop Illusion):當你要求 AI「用 C++ 寫一個無鎖 Stack」時,它幾乎千篇一律會給出教科書式的裸指標 CAS 迴圈。這段程式碼在單執行緒或低並發測試時完全正常,但 AI 往往對 ABA 問題隻字不提,更完全忽略了節點被 delete 後並行讀者引發的 Use-After-Free 崩潰。
  • 記憶體順序的隨機性:在要求 AI 最佳化效能時,AI 經常在沒有精確因果依據的情況下,將 SeqCst 降級為 Relaxed,遺漏關鍵的 Acquire 讀取屏障或 Release 發布屏障;或者在 CAS 失敗分支上錯誤地配置了未定義的記憶體順序。
  • 生命週期管理的缺失:實作一個健全的 Hazard Pointer 或 EBR 機制需要極度嚴謹的批次狀態機(包含執行緒註冊、看板掃描、世代三代推進等)。AI 往往只能生成空有函式骨架的虛構實作,在真實高並發壓力下瞬間被記憶體洩漏或懸置指標擊垮。

未經嚴格驗證的 AI 無鎖程式碼,最危險之處在於它看起來無比正確——編譯毫無警告,單元測試跑過一萬次也全數通過,卻在生產環境的 ARM64 伺服器連續運行數天後偶然觸發非法記憶體存取。

AI 的真實價值:作為對抗性審查員(Adversarial Reviewer)

既然不能讓 AI 閉眼裸寫核心無鎖邏輯,AI 的真正威力該如何發揮?答案是翻轉角色:不要讓 AI 當「架構師」,而是讓它擔任「對抗性質疑者」(Red Teaming Reviewer)。

人類工程師在審查自己撰寫的並行程式碼時,極容易陷入思維定勢(Confirmation Bias),預設執行緒會照著自己構想的理想順序前進。而 AI 沒有這種心理負擔,只要給予正確的約束,它能高效扮演挑錯的黑客角色。

提示詞設計策略:逼問邊界交錯

在請 AI 審查人類撰寫的無鎖程式碼時,避免使用「這段程式碼有沒有問題?」這種寬泛提問,而應當給予明確的底層架構約束:

針對弱記憶體模型的審查 Prompt 範例
「請扮演資深並行系統核心工程師。審查以下 C++20 無鎖佇列的 pop() 實作。請特別假設運行於 ARM64 架構(具備 Store Buffer 與弱記憶體順序):

  1. 請檢查 head.load 與後續存取內部欄位之間,是否存在任何指令重排(Reordering)可能讀取到未初始化的資料?
  2. 請構造一個精確的雙執行緒交錯時序(Interleaving trace),證明在特定的 CAS 失敗重試路徑上,是否可能發生 ABA 或存取已被釋放的記憶體?
  3. 列出所有你認為可以進一步降級或必須升級的 std::memory_order,並嚴格給出因果依據。」

在這種對抗性約束下,AI 能極其迅速地指出人類肉眼容易漏看的分支細節,例如「CAS 失敗時 expected 更新與下一次重試讀取之間的屏障漏洞」。

終極閉環:AI 結合符號模型檢查(Formal Verification)

無論是人類專家還是 AI 審查,本質上都依賴經驗推演,無法窮舉龐大的並行交錯空間。要在工程上獲得真正的數學級確定性,必須將 AI 融入形式化驗證工具鏈(Formal Verification Toolchain):

formal-verification-loop.svg

讓 AI 生成 Loom 與 GenMC 形式測試 Harness

撰寫形式化測試套件(如 Rust 的 loom 或 C++ 的 GenMC)相當繁瑣,需將所有原生型別替換為模型檢查器的特定原語。這恰好是 AI 的絕佳施展場域:

  • Prompting 任務:「將以下這段生產級 Treiber Stack 的 Rust 實作,改寫為 loom 模型測試案例。使用 loom::sync::atomicloom::thread,設定 2 個執行緒同時進行並行 push 與 pop,驗證是否滿足 LIFO 屬性與記憶體無外洩。」
  • AI 能在數秒內生成規範的驗證環境,讓模型檢查器窮舉成千上萬種合法執行緒排程。

讓 AI 解讀反例追蹤日誌(Counterexample Trace)

當模型檢查器發現錯誤時,輸出的反例日誌往往龐大無比,列出數十個排程步驟與記憶體存取歷史。人類工程師需要花費數小時追蹤究竟是哪一個執行緒的哪一行指令觸發了狀態不一致。

此時將反例日誌餵給 AI:

「這是在執行 GenMC 模型檢查時回報的失敗日誌。請追蹤 Execution Graph,指出在第幾個 step 時發生了哪兩個記憶體存取的因果斷裂?是哪一行程式碼的 memory ordering 不足以建立 synchronizes-with?」

AI 具備強大的符號模式匹配能力,能精準從數百行交錯記錄中萃取出關鍵的因果漏洞,並給予精準的修復建議。

透過「人類定架構 ➔ AI 擬推演並編寫測試模型 ➔ 符號檢查器嚴密證明 ➔ AI 解讀反例日誌」的閉環體系,我們才能真正將 AI 的敏捷性與形式驗證的數學嚴謹性結合,打造出堅不可摧的生產級無鎖系統。

結語:何時該用 Lock-Free?

無鎖資料結構擁有極致的吞吐潛力與避免優先權反轉(Priority Inversion)的優雅特質,但它的實作代價無比高昂。每一行看似平凡的指標操作背後,都牽動著 CPU 管線排空、快取一致性廣播、ABA 防護與記憶體延後回收的複雜協同。

在工程實踐中,我們應當抱持審慎客觀的態度:

  • 95% 的業務場景:優先使用現代作業系統高度最佳化的標準鎖(如基於 Futex 的 std::mutex 或 Rust 的 parking_lot::Mutex)。現代互斥鎖在無競爭情況下僅是一次輕量的原子 CAS,開銷僅數十奈秒,且心智負擔極低。
  • 高頻交易、核心驅動與底層基礎設施:在極度要求低延遲、不可容忍鎖定阻塞(如即時音訊處理、高並發網路事件循環 Reactor)的關鍵路徑上,投入精力設計並驗證 Lock-Free 結構。
  • 站在巨人的肩膀上:若需要使用無鎖結構,盡量選用經過工業級形式化驗證(如 TLA+)與龐大測試套件(如 ThreadSanitizer、Loom)錘鍊的成熟庫(如 Rust 的 crossbeam,C++ 的 Folly 或 Intel TBB),切忌在未經深思熟慮前自行在生產環境手寫無鎖記憶體回收器。

透過這兩篇文章的梳理,我們從底層硬體快取與記憶體模型的微觀世界,一路跨越至無鎖拓撲與安全記憶體回收的宏觀架構。並行程式設計雖然充滿挑戰,但只要掌握了因果順序與生命週期的雙重視角,看似詭譎多變的多執行緒世界,終將呈現出清晰嚴謹的工程之美。