將閒置 Pixel 3 改造為 Omarchy 桌面麥克風插件 Pocket Mic
從 Shell/Python 到 Rust 重構 · ADB 音訊串流 · PipeWire 虛擬音訊源 · Quickshell 狀態列整合
前陣子在整理抽屜時,翻出了退役已久的 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 音訊核心:
整個流程在底層依序由四個核心階段組成:
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 建立並接管音訊串流:
通訊埠轉送 :透過 adb forward localabstract:android-xxxx localabstract:audiosource 將手機端的抽象 socket 轉送至本機。
原生抽象 Socket 連線 :直接使用 Rust 提供的 std::os::linux::net::SocketAddrExt,連線至本機的抽象 Unix domain socket,無需依賴外部工具或腳本封裝。
開啟 FIFO 管線 :連線成功後,pocket-mic 以 O_NONBLOCK | O_NOFOLLOW 開啟剛才由 PulseAudio 建立的 FIFO(若 PipeWire 尚未完成建立,會在 ENXIO 錯誤上持續等待並重試最多 10 秒)。
延遲與原子寫入機制 :
在串流迴圈 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)組成。功能雖然能運作,但在長期日常使用中,浮現了幾項架構問題:
設備探測與輪詢開銷 (Discovery Overhead):
早期原型使用 Python 執行 --list 設備探測,每次執行都得重新初始化 Python 直譯器、載入標準庫模組與解析 JSON,帶來明顯的冷啟動延遲。改用 Rust 原生二進位檔後,單次探測僅需約 20 毫秒(主要開銷為 adb devices 子行程,相較於先前啟動 Python 直譯器大幅精簡)。更進一步地,在後續架構優化中,我們將設備探測從背景常駐計時器改為「展開選單時按需探測」,讓日常桌面閒置時的探測開銷徹底歸零。
資源釋放的脆弱性 (Brittle Cleanup):
在 Bash 中使用 trap,或在 Python 中使用訊號處理程序來卸載 PulseAudio 模組與清理 FIFO,一旦遭遇父行程被非正常終止、未捕獲的例外或孤兒行程問題,系統中容易殘留虛擬音訊設備與暫存檔案。
宣告式 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 ]
// ...
}
按需輪詢機制 (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 桌面狀態列中的實際運作畫面——未啟用時狀態列顯示為休眠麥克風圖示 ,點選設備後立即轉為串流狀態,圖示點亮為 :
未連線休眠狀態
串流進行中狀態
日常體驗:會議通話與語音聽寫
將 Pocket Mic 整合至日常工作後,使用體驗相當穩定。
遠端會議與通話
開會時只需用一條 USB 傳輸線將 Pixel 3 連接電腦,狀態列右上角的麥克風圖示隨即變亮。點擊圖示或用鍵盤快捷鍵勾選,系統層級的 PipeWire 音訊核心便會載入虛擬音訊源,在 Omarchy 的 Audio 面板中自動識別為 Pixel 3 Microphone 並設為預設輸入源:
在 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 飛行盲區
在上一篇專題中,我記錄了如何為經典街機《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 飛行員在調校後錄製的實戰紀錄:
Your browser does not support the video tag.
在這段約 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 完全相同:同樣是在全速逃竄,根本無法達到「調整航向、降低過剩慣性、建立穩定射擊射角」的戰術目的。
要讓模型做出精密的戰術抉擇,首先必須提供具有明確戰術語意的指標,而不是丟一堆未經加工的 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 ,
};
}
這項特徵工程的價值在於:
tactics.imminentThreats :讓模型一目了然全場是否有迫在眉睫的碰撞威脅,將「生存防禦」從模稜兩可的推測轉化為確鑿的整數計數。
tactics.bestAimError :如果當前速度很高且射程內的最佳目標都在船尾(例如 bestAimError = 180°),模型就能明確判定「目前沒有任何良好的射擊窗口,盲目開火只會浪費機會」。
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 };
}
整套架構的決策分流與執行流程如下圖所示:
這個雙階段設計帶來了三大優勢:
防止無效自旋加速 :在航速尚未降下來前,若未對準反向就貿然噴射,只會讓飛船沿著錯誤的切線越轉越快。透過 angleDifference < 0.35 門檻,飛船一定會先轉到位,再穩健煞車。
慣性滑行瞄準 :當速度低於 120 px/s 時,飛船嚴格熄火滑行,將全部角速度用於鎖定目標前置角,不再向前推擠衝撞目標。
伺機擊發不中斷 :即使飛船正在逆向煞車,只要旋轉中的船頭掠過任何隕石的前置航角,全域伺機開火(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 的評估腳本:
終端機印出了令人振奮的結果:
{"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 的實作證明了完全不同的工程事實:
AI 的盲區往往源於人類定義的模糊 :當你給模型一個名為 reposition 的選項,卻沒有在提示詞中為它劃定清晰的定量觸發條件(何時該選、何時不該選),模型自然會選擇更有把握的極端選項(進攻或逃跑)。
領域特徵工程永遠無可取代 :不要指望模型在幾百毫秒內從原始座標矩陣中自行頓悟出「我現在漂移太快了」或「所有敵人都背對著我」。把領域常識預先提煉為緊湊的數值(如 bestAimError 與 imminentThreats),能為模型節省大量的推理負擔。
思考與致動必須對稱 (Symmetry of Brain & Actuator):如果在認知層提供了三種姿態,在執行層卻只實作了兩種物理分支,整個系統的智慧就會產生斷層。在無重力物理中,「減速重新佔位」需要專門的反向噴射與慣性滑行狀態機;只有底層致動器具備足夠的物理能力,上層模型的決策才能真正開花結果。
當星圖中的這艘小飛船學會沉著地逆向點火煞車時,它才真正脫離了盲目逃竄的程式碼反射,展現出宛如人類王牌飛行員般的沉穩戰術。
讓 Jev 幫你開太空船:Asteroids 飛行副駕駛的雙層架構設計
從語意雷達快照、TypeSafe 結構化決策到 120 Hz 物理模擬與在地防衛反射
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),三艘飛船全數完好無損,右側飛行電腦更即時呈現了完整的戰術決策、機率分佈與呼叫紀錄:
Your browser does not support the video tag.
但仔細想想,讓雲端 AI 模型來開太空船,這件事在工程上真的行得通嗎?
即時遊戲與 AI 模型的衝突
如果我們用最直覺的思維來設計 AI 飛行員,直覺的想法可能是:
「每一幀都截圖或把太空船座標餵給模型,問它現在該按『向左轉』、『推進』還是『開火』?」
這種做法只要一跑起來,就會立刻撞牆:
幀率預算的極限 (Frame Budget):遊戲實體物理模擬通常採用固定步長循環(fixed-step simulation)。在 hello-jev 中,物理模擬以 120 Hz 執行(每步約 8.3 毫秒,由 requestAnimationFrame 驅動累加器),這與瀏覽器的畫面渲染幀率解耦,若遇掉幀會在單一畫面幀中連續補償多個物理步。即使是最快的雲端推論 API,單次呼叫加上網路往返通常也需要數十至數百毫秒。用高延遲的網路 API 驅動毫秒級的連續物理迴圈,飛船早就撞毀幾十次了。
浮點幾何與向量運算的盲區 :大語言模型擅長語意關聯與戰術權衡,但讓它在心智中做即時的二維三角函數計算(例如預判 640 px/s 子彈速度與兩團漂移物體的交會前置角),往往不穩定且消耗大量運算資源。
無重力環狀空間的複雜度 :遊戲邊界具有循環環形(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 回應,都會強制切換為逃逸姿態,有效降低等待模型回應期間的碰撞風險。
整個系統的資料流向如下圖所示:
這套架構讓 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 ,
};
}
這段前處理的設計考量包含:
消除環形維度的歧義 :透過 delta(from, to) 計算最短環形位移(1000×700 視口),輸出乾淨的相對距離向量。
直接給出衍生指標 (Derived Metrics):模型不需要拿速度與位置去解二階方程。快照在假設相對速度恆定(constant relative velocity)的前提下,提供 5 秒時間視界內的線性外推預估:secondsToClosest 是預估的最接近時間點(而非必定相撞的倒數計時),closestApproach 則是基於碰撞球體半徑估算的最小表面淨距。
相對航向角 (Relative Bearing):將絕對座標轉換成「相對於飛船當前朝向」的角度(0° 為正前方,負值為左,正值為右),讓模型能以最符合飛行員本能的視角進行決策。
中心距離排序取樣 :選取距離飛船中心最近的 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 次這段計算,飛船就能在雜亂無章的隕石風暴中靈活找到開闊空域。
控制器分工與邊界處理
深入檢視控制器實作,會發現兩項有趣的工程細節:
戰術分支整併 :在當前的本機控制器中,evade 與 reposition 共用相同的候選取樣逃逸分支。雖然兩者在 Jev 的模型語意與介面展示上代表不同的戰術意圖(緊急逃亡 vs. 拉開距離重新佔位),但在微觀幾何執行上,尋找最開闊落點的邏輯是一致的。
目標回退與全面性伺機開火 :若 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 的經驗讓我體會到另一條更健康、更接地氣的工程路徑:
讓模型做它最擅長的事 :大模型的核心價值在於語意理解、動態目標權衡與戰術姿態決策。不要讓它做二維矩陣乘法或微積分,那些交給 CPU 的幾行代數運算既便宜又精準。
語意特徵工程依然關鍵 :提供給模型的上下文不應是未加工的原始傾印(raw dump),而是經過幾何轉換、語意豐富的衍生特徵(如相對方位、最近距離預估與接近速度)。輸入越貼近領域認知,模型給出的決策就越精確。
分層與防衛性設計是系統韌性的基石 :結合高頻確定性控制器、反射避險機制、世代失效檢查與嚴格 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 的設計維度延伸到了以下面向:
從 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 設計
針對網路通訊、微服務與分散式架構,我會優先推薦以下三本書:
程式庫與模組端 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 核心哲學在真實生產環境中的延伸參考:
當程式碼全由 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 和錯誤路徑說清楚。
為什麼 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 對應的宣告檔:
它會產生 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 原則——快取的是可重複使用的成功結果,不是暫時的失敗。