featured.svg

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

tactics-tuning.svg

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

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

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

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

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

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

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

npm run test:jev

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

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

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

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

結語與工程省思

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

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

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

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

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

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