Mengintegrasikan Large Language Model (LLM) sebagai pengambil keputusan dalam turn-based game engine kompleks seperti Magic: The Gathering (MTG) memunculkan masalah konkurensi serius. Latensi inferensi LLM yang berkisar antara 1 hingga 15 detik membuka celah desinkronisasi state tree, eksekusi aksi ganda (double-action), dan konflik transisi giliran jika pemain lain atau timer server memicu aksi simultan.
Artikel ini membahas arsitektur sinkronisasi state game turn-based via worker LLM menggunakan antrean Redis/BullMQ, serialisasi turn dengan distributed lock, serta validasi integritas menggunakan optimistic locking.
Problem Konkurensi: Latensi LLM vs Determinisme Engine
Game engine berbasis aturan ketat mengasumsikan setiap transisi state bersifat atomik dan deterministik. Hadirnya LLM worker memperkenalkan tiga kegagalan utama:
- Desinkronisasi State Tree: LLM membaca state pada turn
N. Selama proses inferensi berlangsung, trigger pasif atau interupsi turn mengubah state menjadiN+1. Eksekusi output LLM pada stateN+1merusak kontinuitas logis permainan. - Double-Action via Timeout: Keterlambatan respons worker menyebabkan sistem menganggap LLM timed-out dan fallback dijalankan. Jika worker LLM akhirnya merespons dan engine mengeksekusinya tanpa validasi, aksi terduplikasi.
- Token Exhaustion: Mengirim seluruh riwayat event log game yang kompleks ke dalam prompt menghabiskan kuota context window dan memperlambat waktu inferensi secara signifikan.
Arsitektur Sistem: Queue, Locking, dan State Store
Untuk memastikan aksi dieksekusi secara serial dan deterministik, sistem memisahkan penerimaan perintah, evaluasi AI, dan mutasi state ke dalam lapisan terisolasi:
- State Store (PostgreSQL / Redis): Menyimpan state tree canonical beserta integer
versionuntuk mendeteksi mutasi paralel. - Snapshot Cache (Redis): Menyimpan state teringkas (hanya entitas aktif, prioritas, dan legal actions) untuk dipasok ke prompt LLM tanpa membebani database utama.
- BullMQ Worker: Mengelola antrean giliran LLM dengan backpressure dan dead-letter queue (DLQ).
- Distributed Lock (Redlock): Mengunci ID sesi game saat LLM mengevaluasi giliran agar tidak ada event eksternal yang mengeksekusi transisi state secara bersamaan.
Skema Payload Event Queue
Job yang dikirim ke antrean BullMQ harus membawa metadata versi state saat job dibuat. Hindari menyematkan seluruh object state ke dalam payload antrean untuk menghindari stale data.
{
"gameId": "gm_8f92a1bc",
"turnNumber": 14,
"phase": "MAIN_PHASE_1",
"activePlayerId": "llm_bot_agent",
"expectedStateVersion": 108,
"legalActions": [
{ "action": "CAST_SPELL", "cardId": "c_lightning_bolt", "target": "player_1" },
{ "action": "PASS_PRIORITY" }
],
"snapshotRef": "snap:gm_8f92a1bc:v108",
"timeoutMs": 8000
}Implementasi Worker: Redlock dan Idempotency Engine
Snippet TypeScript berikut mendemonstrasikan konsumsi job oleh worker BullMQ. Worker mengunci sesi via distributed lock, memanggil model inferensi, dan memvalidasi integritas state sebelum melakukan commit mutasi ke database canonical.
import { Worker, Job } from 'bullmq';
import Redlock from 'redlock';
import Redis from 'ioredis';
import { db } from './database';
const redisClient = new Redis(process.env.REDIS_URL);
const redlock = new Redlock([redisClient], {
retryCount: 3,
retryDelay: 200,
});
interface TurnJobData {
gameId: string;
expectedStateVersion: number;
legalActions: Array<{ action: string; cardId?: string; target?: string }>;
snapshotRef: string;
timeoutMs: number;
}
export const turnWorker = new Worker<TurnJobData>('llm-turn-queue', async (job: Job) => {
const { gameId, expectedStateVersion, legalActions, snapshotRef, timeoutMs } = job.data;
const lockKey = `locks:game:${gameId}`;
let lock;
try {
// 1. Dapatkan Distributed Lock untuk serialisasi turn
lock = await redlock.acquire([lockKey], timeoutMs);
} catch (err) {
throw new Error(`Gagal memperoleh lock untuk game ${gameId}. Giliran sedang diproses.`);
}
try {
// 2. Fetch snapshot terkompresi dari Redis Cache untuk context LLM
const promptContext = await redisClient.get(snapshotRef);
if (!promptContext) {
throw new Error(`Snapshot ${snapshotRef} expired atau tidak ditemukan.`);
}
// 3. Eksekusi inferensi LLM
const llmDecision = await callLLMDecisionEngine(promptContext, legalActions);
// 4. Mutasi DB Canonical menggunakan Optimistic Locking
const result = await db.transaction(async (trx) => {
const currentGame = await trx('game_states')
.where({ id: gameId })
.first();
// Cek desinkronisasi: apakah state berubah selama inferensi?
if (!currentGame || currentGame.version !== expectedStateVersion) {
throw new Error(`State mismatch: DB version ${currentGame?.version} != expected ${expectedStateVersion}`);
}
// Validasi idempotensi aksi dari LLM terhadap legal actions
const isValid = legalActions.some(a => a.action === llmDecision.action && a.cardId === llmDecision.cardId);
if (!isValid) {
throw new Error(`LLM mengembalikan illegal action: ${JSON.stringify(llmDecision)}`);
}
// Commit state baru dan inkrementasi version
return trx('game_states')
.where({ id: gameId, version: expectedStateVersion })
.update({
last_action: JSON.stringify(llmDecision),
version: expectedStateVersion + 1,
updated_at: new Date(),
});
});
return result;
} finally {
// Selalu lepas lock
await lock.release().catch(() => {});
}
}, { connection: redisClient });
async function callLLMDecisionEngine(context: string, legalActions: any) {
// Implementasi HTTP call ke OpenAI / Anthropic / local runtime
// ponytail: fallback hardcoded PASS_PRIORITY jika network drop
return legalActions[legalActions.length - 1];
}Strategi Snapshot Caching
Alih-alih menyalin serialized JSON game board seutuhnya, bangun state view khusus LLM yang hanya mencatat entitas relevan. Simpan di Redis dengan TTL terikat durasi turn:
- State Projection: Filter data tersembunyi (kartu di tangan lawan, library tersembunyi). Proyeksikan state hanya ke data publik dan tangan bot itu sendiri.
- TTL Cache Singkat: Terapkan
SET snapshotRef payload EX 60. Jika giliran berganti atau dibatalkan, snapshot kadaluwarsa otomatis tanpa membebani memory store. - Differential Log: Jika game memiliki histori panjang, jangan inject log langkah 1 ke
N. Kirimkan current snapshot ditambah maksimal 3 aksi terakhir sebagai prompt sequence.
Trade-off dan Edge Cases
Peringatan: Penggunaan Redlock memastikan tidak ada dua worker mengeksekusi satu turn game pada saat bersamaan. Namun, distributed lock tidak menggantikan validasi atomic DB. Optimistic concurrency check (
WHERE version = X) wajib tetap ada di level persistensi data canonical.
- Clock Drift pada Redis Nodes: Redlock mengasumsikan drift clock antar node Redis minimal. Gunakan satu instance Redis cluster yang dikelola dengan baik daripada setup multi-master kustom yang rentan time skew.
- Worker Crashing: Jika LLM worker crash sebelum melepaskan lock, atur TTL lock proporsional dengan timeout API LLM (misalnya 10 detik). Hindari TTL lock bernilai permanen atau terlalu lama untuk mencegah deadlock sesi game.
- Stale Prediction Retry: Ketika transaksi DB melempar
State mismatch, jangan langsung retry job LLM dengan state lama. Gagalkan job, baca ulang state DB terkini, dan buat job baru dengan versi DB terbaru.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!