在上一篇專題中,我記錄了如何為經典街機《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 飛行員在調校後錄製的實戰紀錄:
在這段約 21 秒的完整視訊中,幾個關鍵指標發生了劇烈的變化:
- 通關時間大幅縮減:清空第一星區(得分達到 2,600 並進入 Sector 02)的耗時從原本的 84 秒急遽縮短至 17 秒,效率提升了近 5 倍。
- 戰術分佈達成動態平衡:在通關的 17 秒內,Jev 總計執行了 20 次戰術決策。其中
reposition7 次、intercept6 次、evade7 次,徹底打破了過去非打即逃的二元困境。 - 飛行姿態洗鍊沉穩:飛船不再失控地以高速在全場漂移甩尾,而是呈現出清晰的作戰節奏——高速逼近 ➔ 逆向噴射煞車 ➔ 穩定轉身鎖定 ➔ 伺機精準點放 ➔ 規避破片。
這項轉變是如何實現的?背後正是「狀態特徵工程」、「提示詞操作邊界」與「物理致動分立」三者的環環相扣。
診斷:為什麼模型過去從不選 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,
};
}這項特徵工程的價值在於:
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 的評估腳本:
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 的實作證明了完全不同的工程事實:
- AI 的盲區往往源於人類定義的模糊:當你給模型一個名為
reposition的選項,卻沒有在提示詞中為它劃定清晰的定量觸發條件(何時該選、何時不該選),模型自然會選擇更有把握的極端選項(進攻或逃跑)。 - 領域特徵工程永遠無可取代:不要指望模型在幾百毫秒內從原始座標矩陣中自行頓悟出「我現在漂移太快了」或「所有敵人都背對著我」。把領域常識預先提煉為緊湊的數值(如
bestAimError與imminentThreats),能為模型節省大量的推理負擔。 - 思考與致動必須對稱 (Symmetry of Brain & Actuator):如果在認知層提供了三種姿態,在執行層卻只實作了兩種物理分支,整個系統的智慧就會產生斷層。在無重力物理中,「減速重新佔位」需要專門的反向噴射與慣性滑行狀態機;只有底層致動器具備足夠的物理能力,上層模型的決策才能真正開花結果。
當星圖中的這艘小飛船學會沉著地逆向點火煞車時,它才真正脫離了盲目逃竄的程式碼反射,展現出宛如人類王牌飛行員般的沉穩戰術。