將閒置 Pixel 3 改造為 Omarchy 桌面麥克風插件 Pocket Mic

從 Shell/Python 到 Rust 重構 · ADB 音訊串流 · PipeWire 虛擬音訊源 · Quickshell 狀態列整合
featured.svg

前陣子在整理抽屜時,翻出了退役已久的 Google Pixel 3。這款 2018 年推出的手機雖然早已停止系統版本更新,但硬體功能依然正常。閒置在抽屜裡有些可惜。

恰巧最近在我的 Omarchy Linux 桌面環境 上,日常遠端會議(Google Meet、Discord)與語音輸入的需求增加——特別是 Omarchy 整合的 voxtype(預設長按 F9 進行 push-to-talk,亦可透過 Super + Ctrl + X 或在 ~/.config/hypr/bindings.lua 自訂綁定至 Meh 鍵組合的 Ctrl + Alt + Shift + V 切換,呼叫本地 Whisper 模型進行語音辨識)。桌上型電腦通常沒有內建麥克風,若不想常態配戴耳機,就得外接一支獨立麥克風佔用桌面空間。

Pixel 系列內建多麥克風硬體矩陣與聲學降噪調校,收音品質比許多常見的視訊鏡頭或平價外接麥克風更好。如果能把這台 Pixel 3 作為電腦的專屬桌上型麥克風使用,會是很實用的硬體重用方式。

市面上現成的 Android 麥克風方案(例如透過區域網路 WebRTC 或特定第三方應用程式)大多需要額外開啟瀏覽器分頁,且延遲相對不穩定。我希望的運作方式是:

  • 隨插即用,支援 USB 與無線 ADB。
  • 直接註冊為 Linux 系統層級的 PipeWire / PulseAudio 音訊輸入裝置,名稱清楚顯示為 Pixel 3 Microphone。
  • 整合進 Omarchy 桌面——在 Quickshell 狀態列提供常駐圖示,點擊展開即時選單,支援熱插拔與斷線自動重連,且具備全鍵盤操控能力。

最初我使用了一包 Bash 腳本搭配 Python(pysocat + connect.py)進行概念驗證。雖然功能可行,但在設備探測與狀態監聽時反覆啟動 Python 直譯器會帶來不必要的冷啟動開銷,且在行程異常中斷時的資源清理也較難保證。

因此,我將桌面端串流核心改寫為 Rust,整合成單一二進位程式的 Pocket Mic(patrick.pocketmic)插件。本文記錄這套音訊串流的架構設計、從腳本走向 Rust 的重構考量、Quickshell 雙重插件模型,以及在 Linux 音訊子系統中的實作細節。

端到端音訊傳輸架構

要將 Android 手機上的麥克風訊號低延遲轉送至 Linux 桌面,並被系統各個應用程式識別為標準錄音設備,資料管線需要跨越 Android 系統、ADB 橋接層、Linux IPC 與 PipeWire 音訊核心:

audiosource-pipeline.svg

整個流程在底層依序由四個核心階段組成:

Android 端:原始 PCM 音訊擷取

手機端執行開源輕量級 App fr.dzx.audiosource。App 在前景啟動錄音服務,透過 Android 原生 AudioRecord API 擷取 44.1 kHz、16-bit PCM 單聲道(mono)音訊。擷取後的未壓縮 PCM 資料流不經過額外編碼(減少運算負擔與編解碼延遲),直接寫入手機本地的抽象 Unix domain socket(localabstract:audiosource)。

Linux 桌面音訊核心:PipeWire / PulseAudio 虛擬音訊源建立

現代 Linux 發行版大多採用 PipeWire 作為音訊伺服器,並提供 PulseAudio 相容介面。在開始串流前,pocket-mic 會先呼叫 pactl 動態載入 module-pipe-source:

pactl load-module module-pipe-source \
    source_name="android-89ay04l" \
    channels=1 \
    format=s16 \
    rate=44100 \
    file="/tmp/pocket-mic-1234-5678/audio" \
    source_properties="device.description=\"Pixel 3 Microphone\""

值得注意的是:正是 module-pipe-source 負責在指定路徑建立具名管線 (FIFO),並同時在系統中註冊虛擬錄音裝置 Pixel 3 Microphone。

串流轉送與低延遲橋接:Rust pocket-mic 核心

接著,pocket-mic 建立並接管音訊串流:

  1. 通訊埠轉送:透過 adb forward localabstract:android-xxxx localabstract:audiosource 將手機端的抽象 socket 轉送至本機。
  2. 原生抽象 Socket 連線:直接使用 Rust 提供的 std::os::linux::net::SocketAddrExt,連線至本機的抽象 Unix domain socket,無需依賴外部工具或腳本封裝。
  3. 開啟 FIFO 管線:連線成功後,pocket-mic 以 O_NONBLOCK | O_NOFOLLOW 開啟剛才由 PulseAudio 建立的 FIFO(若 PipeWire 尚未完成建立,會在 ENXIO 錯誤上持續等待並重試最多 10 秒)。
  4. 延遲與原子寫入機制:
    • 在串流迴圈 forward_pcm 中,每次讀取 PCM 後以 1024 位元組為單位寫入 FIFO。在 Linux 上 PIPE_BUF 為 4096 位元組,小於等於 PIPE_BUF 的寫入符合 POSIX 原子寫入保證。
    • 透過 libc::fcntl 將管線容量鎖定在 4096 位元組(一個記憶體分頁大小):
    // 限制管線緩衝區大小為 4096 位元組,確保原子寫入且無音訊積壓
    unsafe {
        libc::fcntl(pipe.as_raw_fd(), libc::F_SETPIPE_SZ, 4096);
    }
    若應用程式端讀取暫時落後,非阻塞寫入會主動略過區塊,徹底避免管線積累過期音訊。

Omarchy 桌面整合

最上層則是專為 Omarchy 打造的 Quickshell 插件,負責監聽設備插拔、管理後台 pocket-mic 守護行程,並在狀態列提供連線與控制介面。

為什麼從 Shell / Python 重構為 Rust?

在第一版原型中,系統由 Bash 腳本(audiosource)與 Python 腳本(connect.py)組成。功能雖然能運作,但在長期日常使用中,浮現了幾項架構問題:

  1. 設備探測與輪詢開銷 (Discovery Overhead): 早期原型使用 Python 執行 --list 設備探測,每次執行都得重新初始化 Python 直譯器、載入標準庫模組與解析 JSON,帶來明顯的冷啟動延遲。改用 Rust 原生二進位檔後,單次探測僅需約 20 毫秒(主要開銷為 adb devices 子行程,相較於先前啟動 Python 直譯器大幅精簡)。更進一步地,在後續架構優化中,我們將設備探測從背景常駐計時器改為「展開選單時按需探測」,讓日常桌面閒置時的探測開銷徹底歸零。
  2. 資源釋放的脆弱性 (Brittle Cleanup): 在 Bash 中使用 trap,或在 Python 中使用訊號處理程序來卸載 PulseAudio 模組與清理 FIFO,一旦遭遇父行程被非正常終止、未捕獲的例外或孤兒行程問題,系統中容易殘留虛擬音訊設備與暫存檔案。
  3. 宣告式 CLI 與型別安全 (Type-Safe CLI & Process Management): 改用 Rust 後,透過 clap 定義宣告式 CLI 結構體與子命令,嚴謹驗證參數衝突與全域選項;並透過 thiserror 與 anyhow 建立結構化錯誤模型,搭配 prctl(PR_SET_PDEATHSIG, SIGTERM) 與獨立行程群組隔離,確保子行程生命週期嚴密綁定,不再依賴未結構化的字串錯誤與外部 Python 環境。

實作細節:Linux 音訊處理與 Rust RAII 機制

改寫為 Rust 後,程式架構與資源管理更加清晰。以下是實作過程中的核心設計:

RAII Drop 模式:自動資源釋放

在 Rust 中,我們為管理串流生命週期的 Session 實作了 Drop trait:

struct Session<'a> {
    runner: &'a Runner,
    serial: &'a str,
    name: String,
    directory: PathBuf,
    module: Option<String>,
    load_attempted: bool,
    forwarded: bool,
}

impl Drop for Session<'_> {
    fn drop(&mut self) {
        if let Some(module) = &self.module {
            self.runner.cleanup("pactl", &["unload-module", module]);
        } else if self.load_attempted {
            // 若 load-module 超時但伺服器端可能已建立模組,主動掃描並卸載匹配的殘留模組
            if let Ok(listing) = self.runner.execute(
                "pactl",
                &["list", "modules", "short"],
                Duration::from_secs(3),
                true,
            ) {
                for line in listing.lines().filter(|line| module_matches(line, &self.name)) {
                    self.runner.cleanup("pactl", &["unload-module", line.split_whitespace().next().unwrap()]);
                }
            }
        }
        if self.forwarded {
            self.runner.cleanup(
                "adb",
                &["-s", self.serial, "forward", "--remove", &format!("localabstract:{}", self.name)],
            );
        }
        let _ = fs::remove_dir_all(&self.directory);
    }
}

透過 RAII 機制,無論程式是因為手機離線、使用者主動停止、遭遇 I/O 錯誤或是收到中斷訊號,一旦 session 離開作用域,編譯器就會確保觸發 drop,依序卸載 PulseAudio 模組、移除 ADB forward 規則並刪除暫存目錄。

特別值得一提的是 else if self.load_attempted 的邊界保護:若 load-module 在客戶端等待超時但 PulseAudio 伺服器端實際已建立模組,Drop 會主動掃描 pactl list modules short 並精確卸載同名模組,防止伺服器端在高負載下洩漏殘留裝置。

宣告式 CLI 與子命令架構(clap)

在第一版 Rust 原型中,命令列參數採用手動字串匹配處理,當功能擴充至設備列舉、音量控制與 APK 安裝時,參數驗證變得脆弱且難以維護。

重構後引入 clap(v4 derive),將 CLI 定義為宣告式結構體與子命令列舉:

#[derive(Debug, Parser)]
#[command(version, about)]
pub struct Cli {
    #[arg(short = 's', long, global = true, value_name = "SERIAL")]
    pub serial: Option<String>,

    #[arg(long, global = true, value_name = "PATH")]
    pub config: Option<PathBuf>,

    #[arg(long, conflicts_with_all = ["once", "retry"])]
    pub list: bool,

    #[arg(long, global = true, conflicts_with = "retry")]
    pub once: bool,

    #[command(subcommand)]
    pub command: Option<Action>,
}

#[derive(Clone, Debug, PartialEq, Eq, Subcommand)]
pub enum Action {
    Run,
    List,
    Install {
        #[arg(value_name = "APK", env = "AUDIOSOURCE_APK")]
        apk: PathBuf,
    },
    Volume {
        #[arg(value_name = "LEVEL")]
        level: String,
    },
    Build {
        #[arg(long)]
        release: bool,
    },
}

透過 clap 的型別系統與屬性巨集,帶來幾項架構優勢:

  • 靈活的全域參數:-s / --serial 與 --config 宣告為 global = true,無論使用者將其放置於子命令之前或之後(例如 pocket-mic -s phone volume 100% 或 pocket-mic volume 100% -s phone),都能正確解析。
  • 嚴謹的互斥校驗:透過 conflicts_with 防止矛盾參數傳入(例如 --list 與子命令衝突、--once 與 --retry 衝突),在進入主邏輯前自動截斷無效指令。
  • 環境變數整合:Install 子命令直接綁定 AUDIOSOURCE_APK 環境變數,兼顧腳本自動化與手動執行的便利性。

結構化錯誤處理與行程防護(thiserror 與 PR_SET_PDEATHSIG)

過去在呼叫外部命令(adb、pactl)時,多數腳本或原型容易將錯誤簡化為無型別的字串訊息,難以區分「使用者主動中斷」、「指令逾時」還是「ADB 連線拒絕」。

在 Rust 核心中,我們透過 thiserror 定義了專屬的 ProcessError 列舉:

#[derive(Debug, Error)]
pub enum ProcessError {
    #[error("Stopped")]
    Stopped,
    #[error("{program}: {source}")]
    Io {
        program: String,
        #[source]
        source: io::Error,
    },
    #[error("{program}: timed out after {timeout:?}")]
    TimedOut { program: String, timeout: Duration },
    #[error("{program}: {status}: {message}")]
    Failed {
        program: String,
        status: ExitStatus,
        message: String,
    },
    #[error("{program}: {stream} reader failed")]
    ReaderPanicked {
        program: String,
        stream: &'static str,
    },
    #[error("{0}")]
    Connect(String),
}

底層以精確的錯誤型別表達故障原因,上層呼叫端則配合 anyhow::Context 注入業務層脈絡(例如 .context("Discover devices"))。在單元測試中,我們可以直接使用 matches! 驗證錯誤型別與終止碼,而終端使用者也能看見包含具體成因的完整錯誤鏈。

在子行程管理層面,為了避免父行程中斷時留下無人管理的孤兒行程,pocket-mic 在 pre_exec 階段調用 Linux 原生 prctl:

// 建立獨立行程群組,並在父行程結束時發送 SIGTERM,確保無殘留子行程
command.process_group(0);
unsafe {
    command.pre_exec(move || {
        if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
            return Err(io::Error::last_os_error());
        }
        if libc::getppid() != parent {
            libc::_exit(1);
        }
        Ok(())
    });
}

當父行程意外崩潰或收到退出訊號時,Linux 核心會保證向衍生出的子行程發送 SIGTERM;同時,配合獨立的行程群組(process_group(0)),在逾時或清理時可直接透過 libc::kill(-(child.id() as i32), libc::SIGKILL) 清理整個行程樹,徹底杜絕懸掛的管線描述子或孤兒行程。

裝置名稱的多詞引號處理(PipeWire Quoting)

在載入 PulseAudio 模組時,傳入的裝置描述通常包含空格(如 Pixel 3 Microphone)。在 PipeWire 底層的屬性解析器中,引號會經過模組參數與屬性列表的雙重解析。若只包一層引號,名稱常會在第一個空格處被截斷,最後在系統設定中看到的裝置名稱會變成單詞 "Pixel"。

在 Rust 模組中,我們對字串進行兩層跳脫處理:

fn pulse_quote(value: &str) -> String {
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

pub fn description_property(description: &str) -> String {
    let property = format!("device.description={}", pulse_quote(description));
    format!("source_properties={}", pulse_quote(&property))
}

經此處理,傳入 pactl 的字串為 source_properties="device.description=\"Pixel 3 Microphone\"",PipeWire 便能正確解析出完整名稱。

精確卸載與同名前綴防護

在每次連線時,為了避免重複載入同名來源,程式會先呼叫 pactl list modules short 尋找既有模組。此處必須精準匹配整個名稱,避免誤卸載名稱具有相同前綴的其他音訊模組:

pub fn module_matches(line: &str, name: &str) -> bool {
    let fields: Vec<_> = line.split_whitespace().collect();
    fields.get(1) == Some(&"module-pipe-source")
        && fields[2..].iter().any(|field| {
            field
                .strip_prefix("source_name=")
                .is_some_and(|value| value.trim_matches('"') == name)
        })
}

Android 12 與 Android 13+ 的動態權限相容

Pixel 3 的最後一個官方系統版本為 Android 12。在測試自動授權邏輯時,程式在 Pixel 3 上會發生錯誤。

原因在於通知權限 android.permission.POST_NOTIFICATIONS 是在 Android 13(API 33)才引入的動態執行階段權限。在 Android 12 的 Pixel 3 上執行 pm grant ... POST_NOTIFICATIONS 會被系統回傳失敗。

因此在 Rust 檢查邏輯中,將兩者分開處理:

for permission in [
    "android.permission.POST_NOTIFICATIONS",
    "android.permission.RECORD_AUDIO",
] {
    let granted = dumpsys
        .lines()
        .any(|line| line.contains(&format!("{permission}:")) && line.contains("granted=true"));
    if !granted
        && runner.adb(serial, &["exec-out", "pm", "grant", PACKAGE, permission]).is_err()
        && permission == "android.permission.RECORD_AUDIO"
    {
        return Err("Could not grant microphone permission; grant it in Android app settings".into());
    }
}

通知權限即使授權未成功也僅視為選用項目略過,唯獨麥克風錄音權限必須確保成功。這樣一來,Android 12 的 Pixel 3 也能順利自動取得授權並啟動串流。

Quickshell 插件架構:雙重角色設計

在 Omarchy Linux 桌面架構 中,桌面 UI 與狀態列是基於 Quickshell (Qt6/QML) 運作。在 manifest.json 中,我們將插件註冊為 patrick.pocketmic,並同時啟用 service 與 bar-widget:

{
  "schemaVersion": 1,
  "id": "patrick.pocketmic",
  "name": "Pocket Mic",
  "version": "1.0.0",
  "description": "Turn your Android phone into a desktop microphone from the bar, with automatic reconnection",
  "kinds": ["service", "bar-widget"],
  "entryPoints": {
    "service": "Service.qml",
    "barWidget": "BarWidget.qml"
  },
  "barWidget": {
    "displayName": "Pocket Mic",
    "category": "Audio",
    "allowMultiple": false,
    "defaultSection": "right"
  }
}

背景常駐服務:Service.qml

將後台管理抽離為獨立單例服務,使音訊串流與狀態列 UI 解耦,避免狀態列重新渲染或選單關閉時中斷音訊通話:

  • worker 程序生命週期管理:使用者選取設備後,直接啟動 pocket-mic --serial <serial>。若手機意外拔除或斷線,服務會自動進入 5 秒重試倒數,直到手機重新插入便自動恢復連線。
  • 設備探測與就地更新:提供 refreshDevices() 函式,透過 pocket-mic --list 取得線上設備的原始 ADB 狀態(如 device、unauthorized、offline、no permissions)並更新反應式屬性 devices。
  • 零背景盲目輪詢:不同於早期版本在背景常駐 5 秒定時器,最新架構完全移除了背景輪詢定時器,將探測控制權交由前端選單,徹底消除了桌面閒置時的背景開銷。
readonly property string binaryPath: decodeURIComponent(
    Qt.resolvedUrl("pocket-mic").toString().replace(/^file:\/\//, "")
)

Process {
    id: worker
    command: [root.binaryPath, "--serial", root.selectedSerial]
    // ...
}

狀態列與即時控制面板:BarWidget.qml

  • 按需輪詢機制 (On-Demand Polling):將設備探測計時器綁定在選單展開狀態(popupOpen):
    Timer {
        interval: 5000
        running: root.popupOpen && root.controller !== null
        repeat: true
        triggeredOnStart: true
        onTriggered: root.controller.refreshDevices()
    }
    當使用者點擊圖示展開面板時,triggeredOnStart: true 立即執行一次設備探測,讓使用者能第一時間看到最新狀態;選單保持展開時,每 5 秒更新一次;一旦關閉選單,探測排程立即休眠。閒置狀態下維持真正的零子行程開銷。
  • 手動刷新按鈕:面板右上角提供 󰑐 重新整理按鈕,支援隨時手動點擊觸發 controller.refreshDevices()。
  • 狀態圖示與提示:未啟用時顯示半透明的休眠麥克風圖示 󰍭;串流時點亮為 󰍬,並提供懸停狀態說明。
  • 彈出式控制面板 (PopupCard):比對序號就地更新模型,確保選單平順更新且不丟失焦點。
  • 鍵盤導航支援:配合 Omarchy 的鍵盤操作慣例,支援使用 Tab 鍵在不同設備間跳轉,並按下 Space 或 Enter 快速啟動或停用。

下圖為插件在 Omarchy 桌面狀態列中的實際運作畫面——未啟用時狀態列顯示為休眠麥克風圖示 󰍭,點選設備後立即轉為串流狀態,圖示點亮為 󰍬:

未連線休眠狀態 串流進行中狀態
pocketmic-dropdown-stopped.png pocketmic-dropdown-streaming.png

日常體驗:會議通話與語音聽寫

將 Pocket Mic 整合至日常工作後,使用體驗相當穩定。

遠端會議與通話

開會時只需用一條 USB 傳輸線將 Pixel 3 連接電腦,狀態列右上角的麥克風圖示隨即變亮。點擊圖示或用鍵盤快捷鍵勾選,系統層級的 PipeWire 音訊核心便會載入虛擬音訊源,在 Omarchy 的 Audio 面板中自動識別為 Pixel 3 Microphone 並設為預設輸入源:

pipewire-audio-source.png

在 Google Meet 與 Discord 的麥克風選單裡就能直接選取 Pixel 3 Microphone。Pixel 3 內建的硬體消噪與指向性收音表現良好,對話時背景的機械鍵盤敲擊聲能被有效抑制,不需要額外開啟降噪軟體。

隨時呼叫 voxtype 本地 Whisper 聽寫

在日常開發或寫作時,無論是長按 F9(push-to-talk,放開即停止)或是按下 Ctrl + Alt + Shift + V(切換錄音開關),桌上立著的 Pixel 3 都是方便的桌面麥克風。說完一段話,文字便即時輸入至螢幕游標處,辨識率因為清晰的收音大幅提升。

發熱與功耗表現

因為手機端只執行純粹的 AudioRecord 與 socket 寫入,螢幕處於休眠關閉狀態,沒有額外的 UI 繪製與複雜編碼運算,Pixel 3 在持續收音數小時的情況下完全處於常溫狀態,不會發燙,同時電腦的 USB 孔還能維持手機微量補電。

結語:延續硬體價值的實踐

在電子產品更迭頻繁的環境下,一支舊手機往往因為不再支援新版作業系統或電池衰退而閒置。然而它內部搭載的感測器、音訊晶片與麥克風硬體素質,依然足以勝任許多周邊設備的需求。

從最初的 Shell + Python 概念驗證腳本,到最後透過 Rust RAII 與 Linux 抽象通訊協定重構成型,Pocket Mic 展現了現代 Linux 與 Wayland 桌面的擴充能力:不需要依賴特定商業軟體,就能讓退役的手機在日常工作中發揮實際價值。

如果你手邊也有一台閒置的 Android 手機,不妨也把它接上電腦試試看。

專案原始碼與安裝說明:


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

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

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

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

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

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

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

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

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

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

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

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

在這段約 17 秒的實戰錄影中,視訊精確記錄至第一星區全數清空(SECTOR CLEAR,得分 2,600),幾個關鍵指標發生了劇烈的變化:

  • 通關時間大幅縮減:清空第一星區(得分達到 2,600)的耗時從原本的 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.ts 的 steer() 函式中,我為了圖省事,寫下了這樣的邏輯:

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

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

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

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

在 src/game.ts 的 snapshot() 中,我為快照擴充了專屬的 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.ts 的 steer() 中,我將 reposition 從 evade 徹底剝離,實作了精緻的雙階段姿態控制:

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. 領域特徵工程永遠無可取代:不要指望模型在幾百毫秒內從原始座標矩陣中自行頓悟出「我現在漂移太快了」或「所有敵人都背對著我」。把領域常識預先提煉為緊湊的數值(如 bestAimError 與 imminentThreats),能為模型節省大量的推理負擔。
  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.ts 的 snapshot() 中,我並沒有把整張畫布的座標直接丟給模型,而是寫了一段簡潔的幾何前處理,將空間狀態萃取為最多 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):將絕對座標轉換成「相對於飛船當前朝向」的角度(0° 為正前方,負值為左,正值為右),讓模型能以最符合飛行員本能的視角進行決策。
  4. 中心距離排序取樣:選取距離飛船中心最近的 10 顆隕石餵給模型,控制酬載大小並聚焦局部威脅。

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

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

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

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

在 server/pilot.ts 的 questionsFor() 中,我設計在單一 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 的機率常顯著提升;而當周遭被多顆碎石包圍時,evade 與 reposition 的機率就會大幅上揚,真實展現出 AI 在戰術權衡時的量化評估。

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

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

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

前置量預判射擊(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 選擇了 evade 或 reposition,飛船該往哪裡逃?

這裡在地控制器並沒有進行昂貴的連續路徑射線追蹤,而是採用了高效的幾何候選取樣: 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. 戰術分支整併:在當前的本機控制器中,evade 與 reposition 共用相同的候選取樣逃逸分支。雖然兩者在 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.ts 的 FlightComputer 中,我設計了幾項關鍵的防禦性機制:

世代計數器與過期丟棄(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 秒的節奏。
  • 伺服器入場限制:後端以程序區域變數 busy 與 performance.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(契約優先)成為常見的架構選擇之一:先透過 OpenAPI 或 Protobuf 定義明確的 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)中的標準方法命名(List、Get、Create、Update、Delete)、欄位行為與版本控制提供了詳細的實踐參考。
  • 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 是否收到正確的 Request、Env 與 ExecutionContext?
  • 非同步快取工作是否交給 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.jsonc 以 secrets.required 列出 NPS_API_KEY:wrangler 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 已經替我們提供的型別

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

request 是 browser 送進來的 Web Request,所以可以安全使用 request.method、request.url。Response 是 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;本例中的 Env 由 wrangler types 從 wrangler.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.default;ExecutionContext 的 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.json 把 test/**/* 也納入 strict typecheck。unit test 的 env: Env 會要求測試提供 Worker 所需的 NPS_API_KEY,而 worker.fetch(request, env, ctx) 也會在編譯期檢查 Request、Env 和 ExecutionContext 參數。若改了 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 原始碼不用匯入 Request、Response 或 ExportedHandler。前面的小範例手動寫 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.json 的 strict: 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 原則——快取的是可重複使用的成功結果,不是暫時的失敗。