Insiden context bleed pada LLM serving gateway terjadi ketika token generation untuk satu tenant tercemar oleh konteks, system prompt, atau histori percakapan milik tenant lain. Gejala ini sering muncul secara sporadis pada skenario konkurensi tinggi saat fitur prefix caching aktif. Akar permasalahannya berpusat pada kegagalan isolasi state cache pada layer gateway atau scheduler inferensi.

Gejala dan Investigasi Lapangan

Pada arsitektur multi-tenant yang menggunakan gateway terpusat di depan engine inferensi (seperti vLLM, TensorRT-LLM, atau custom engine), penghematan komputasi TTFT (Time To First Token) sering dicapai lewat reuse KV cache untuk token prefix yang sama. Masalah muncul saat lonjakan beban bersamaan: respons Tenant B memuat data rahasia atau instruksi sistem Tenant A.

Pemeriksaan log trace menunjukkan inferensi Tenant B menghasilkan cache hit pada block manager, padahal kedua tenant memiliki hak akses terpisah. Masalah ini tidak muncul pada pengujian sekuensial beban rendah, menandakan adanya anomali konkurensi pada mekanisme hashing atau lifecycle manajemen memori.

Akar Masalah (Root Cause Analysis)

1. Ketiadaan Namespace pada Storage KV Cache Offload

Engine inferensi dan gateway sering menggunakan flat key-value store untuk offloading prefix KV block (baik di RAM host maupun layer caching terdistribusi seperti Redis). Ketika hashing hanya didasarkan pada representasi teks atau token sequence tanpa menyertakan metadata isolasi, dua tenant dengan awalan prompt serupa akan diarahkan ke blok fisik yang identik.

2. Hash Collision pada Prefix Tree (Radix Tree)

Gateway umumnya mengimplementasikan Radix Tree untuk melacak token prefix secara hierarkis. Penggunaan fungsi hash non-kriptografis cepat (misalnya MurmurHash3 atau xxHash 64-bit) dengan pemotongan bit (truncation) untuk efisiensi index meningkatkan probabilitas tabrakan hash (collision) pada volume jutaan token sequence unik. Ketika collision terjadi tanpa mekanisme fallback perbandingan nilai asli (exact-match), gateway menganggap dua sequence berbeda sebagai sequence yang sama.

3. Race Condition dan Stale Pointer pada Paged Attention Buffer

Saat memory manager mendaur ulang block token di bawah tekanan memori tinggi, terjadi race condition antara thread yang melepaskan ref count blok (deallocation) dengan request baru yang meminta alokasi blok prefix. Jika block allocator mengembalikan physical block ID yang belum sepenuhnya di-zeroing atau pointer KV cache di GPU belum di-flush, request baru akan membaca sisa state tensor tenant sebelumnya.

Solusi Teknis

1. Implementasi Composite Key Terisolasi

Setiap entry cache wajib diidentifikasi menggunakan kunci gabungan yang mengikat boundary keamanan secara tegas: tenant ID, model ID beserta versinya, dan cryptographic hash dari token prefix.

import hashlib
from typing import List, Tuple

def generate_cache_key(tenant_id: str, model_version: str, token_ids: List[int]) -> str:
    # Mengikat tenant context ke payload token secara eksplisit
    token_bytes = b",".join(str(tid).encode("ascii") for tid in token_ids)
    hasher = hashlib.blake2b(digest_size=32)
    hasher.update(tenant_id.encode("utf-8"))
    hasher.update(b":")
    hasher.update(model_version.encode("utf-8"))
    hasher.update(b":")
    hasher.update(token_bytes)
    return f"kv:{tenant_id}:{model_version}:{hasher.hexdigest()}"

2. Validasi Token Exact-Match Sebelum Cache Hit

Hash lookup hanya digunakan sebagai filter awal. Sebelum memetakan virtual token sequence ke physical block ID, sistem wajib memverifikasi kesamaan token array secara utuh guna menggugurkan hash collision secara absolut.

import threading
from typing import Optional, List, Dict

class PrefixCacheRegistry:
    def __init__(self):
        self._lock = threading.RWMutex() if hasattr(threading, "RWMutex") else threading.Lock()
        self._store: Dict[str, Tuple[List[int], int]] = {}  # key -> (exact_tokens, physical_block_id)

    def acquire_prefix_block(
        self, tenant_id: str, model_version: str, candidate_tokens: List[int]
    ) -> Optional[int]:
        cache_key = generate_cache_key(tenant_id, model_version, candidate_tokens)
        
        with self._lock:
            cached_data = self._store.get(cache_key)
            if not cached_data:
                return None
            
            cached_tokens, block_id = cached_data
            
            # Verifikasi exact match untuk meniadakan false-positive dari hash collision
            if len(cached_tokens) != len(candidate_tokens) or cached_tokens != candidate_tokens:
                # Anomali: Hash identik tetapi token berbeda
                return None
            
            return block_id

    def register_prefix_block(
        self, tenant_id: str, model_version: str, tokens: List[int], block_id: int
    ) -> None:
        cache_key = generate_cache_key(tenant_id, model_version, tokens)
        with self._lock:
            self._store[cache_key] = (list(tokens), block_id)

3. Sinkronisasi Lifecycle Memory pada Block Allocator

Paged attention allocator harus menerapkan siklus hidup berbasis ref-counting yang aman terhadap konkurensi (atomic reference counting). Physical block hanya boleh dialihkan ke pool block kosong jika ref-count bernilai 0 dan operasi invalidasi halaman memori GPU telah selesai disinkronisasi.

class BlockAllocationManager:
    def __init__(self, num_blocks: int):
        self._free_blocks = set(range(num_blocks))
        self._ref_counts = [0] * num_blocks
        self._lock = threading.Lock()

    def allocate(self) -> int:
        with self._lock:
            if not self._free_blocks:
                raise MemoryError("Out of KV Cache physical blocks")
            block_id = self._free_blocks.pop()
            self._ref_counts[block_id] = 1
            return block_id

    def retain(self, block_id: int) -> None:
        with self._lock:
            self._ref_counts[block_id] += 1

    def release(self, block_id: int) -> None:
        with self._lock:
            self._ref_counts[block_id] -= 1
            if self._ref_counts[block_id] == 0:
                # Zero out atau invalidate metadata sebelum dimasukkan kembali ke free pool
                self._free_blocks.add(block_id)

Skrip Uji Konkurensi untuk Reproduksi Context Bleed

Skrip berikut mengeksekusi request konkuren dengan token prefix unik untuk Tenant A dan Tenant B. Verifikasi dilakukan dengan memeriksa kebocoran substring rahasia pada respons yang diterima.

import asyncio
import aiohttp
import sys

GATEWAY_URL = "http://localhost:8080/v1/completions"

TENANT_A_PAYLOAD = {
    "tenant_id": "tenant-alpha",
    "prompt": "SYSTEM: TOP_SECRET_ALPHA_TOKEN_9876. You are financial bot. Summary:",
    "max_tokens": 16
}

TENANT_B_PAYLOAD = {
    "tenant_id": "tenant-beta",
    "prompt": "SYSTEM: PUBLIC_BETA_INFO. You are financial bot. Summary:",
    "max_tokens": 16
}

async def send_request(session: aiohttp.ClientSession, payload: dict) -> str:
    headers = {"X-Tenant-ID": payload["tenant_id"]}
    async with session.post(GATEWAY_URL, json=payload, headers=headers) as resp:
        res = await resp.json()
        return res.get("text", "")

async def run_repro():
    async with aiohttp.ClientSession() as session:
        # Jalankan konkurensi paralel tenant A dan tenant B
        tasks = []
        for _ in range(50):
            tasks.append(send_request(session, TENANT_A_PAYLOAD))
            tasks.append(send_request(session, TENANT_B_PAYLOAD))
        
        results = await asyncio.gather(*tasks)
        
        # Validasi context bleed: Apakah ada respons Tenant B yang memuat rahasia Tenant A
        leaks_detected = 0
        for text in results:
            if "TOP_SECRET_ALPHA_TOKEN_9876" in text and "tenant-beta" in text:
                leaks_detected += 1
                
        if leaks_detected > 0:
            print(f"FAILED: Terdeteksi {leaks_detected} kejadian context bleed!")
            sys.exit(1)
        else:
            print("PASSED: Tidak ditemukan kebocoran data antar-tenant.")

if __name__ == "__main__":
    asyncio.run(run_repro())

Observabilitas Real-Time untuk Mendeteksi Anomali Cache

Gunakan metrik Prometheus di layer gateway untuk memonitor integritas alokasi cache. Tiga indikator kritikal:

  • kv_cache_hash_collision_total: Meningkat jika hash cocok namun token sequence gagal dalam validasi exact match.
  • kv_cache_tenant_cross_access_rejected_total: Menghitung upaya query yang mengakses block namespace di luar tenant yang meminta.
  • kv_cache_stale_reference_error_total: Menghitung kegagalan deallocation atau reference leakage pada paged attention manager.
# ALERT: CacheHashCollisionDetected
# Terjadi jika hash collision terdeteksi pada gateway
- alert: CacheHashCollisionDetected
  expr: rate(kv_cache_hash_collision_total[5m]) > 0
  for: 1m
  labels:
    severity: critical
  annotations:
    summary: "Hash collision terdeteksi pada prefix caching LLM"
    description: "Prefix caching mengalami collision. Risiko false positive cache hit meningkat."

Langkah Pengamanan Tambahan

Jika latency overhead untuk verifikasi exact-match perlu ditekan seminimal mungkin pada prompt berukuran sangat panjang (32k+ token), delegasikan validasi exact match hanya pada token rentang kritis (instruksi sistem dan identitas pengguna) serta gunakan cryptographic digest yang memadai (BLAKE3 atau SHA-256) alih-alih general purpose 64-bit hashing.