Distributed Server 환경에서 LLM 에이전트를 분산 처리하고 상태를 보존하는 방법

멀티플레이 게임 서버에서 LLM 에이전트 요청을 여러 워커로 분산하면서 대화 문맥과 행동 상태를 안전하게 보존하는 설계를 다룬다. Redis, 작업 큐, 멱등성 키, 스냅샷 기반 복구로 지연·중복·장애를 제어하는 실전 패턴을 정리한다.

✓ 복사 완료!
챕터게임 백엔드 & 데이터베이스
난이도중급
상태● 검토 완료
대상 엔진
Engine Agnostic
최종 업데이트
Distributed Server 환경에서 LLM 에이전트를 분산 처리하고 상태를 보존하는 방법

핵심 요약

Distributed Server 환경의 LLM 에이전트는 요청 처리는 무상태 워커로 분산하고 에이전트의 권위 상태는 Redis 또는 데이터베이스에 단일 버전으로 보존하는 방식이 가장 단순하고 안전하다. 요청마다 agentId, sessionId, requestId, expectedVersion을 함께 전달하고 상태 갱신은 낙관적 잠금 또는 원자적 스크립트로 처리하면 중복 실행과 순서 역전을 막을 수 있다.

핵심은 LLM의 자연어 응답을 곧바로 게임 상태로 믿지 않는 것이다. LLM은 의도와 후보 행동을 만들고 게임 서버는 검증된 명령만 적용하는 권위 서버(authoritative server)로 남아야 한다.

LLM 에이전트 분산 처리에서 무엇이 어려운가?

일반 HTTP API는 요청 하나를 처리하고 응답하면 끝나는 경우가 많다. 반면 게임 속 LLM NPC나 운영 에이전트는 이전 대화, 현재 목표, 쿨다운, 인벤토리, 퀘스트 진행도처럼 지속되는 상태를 가진다.

여러 워커가 동시에 같은 에이전트를 처리하면 다음 문제가 생긴다.

문제발생 예시대응 원칙
중복 실행네트워크 재시도로 같은 행동 요청이 두 번 도착requestId 기반 멱등성
순서 역전느린 요청 A가 빠른 요청 B 뒤에 완료상태 버전 검사
동시 갱신 충돌워커 두 개가 동일한 NPC 목표를 변경원자적 갱신 또는 낙관적 잠금
워커 장애LLM 호출 중 프로세스가 종료큐 재시도와 상태 스냅샷
잘못된 게임 행동모델이 존재하지 않는 아이템 사용을 제안서버 측 명령 검증

권위 상태와 비권위 상태는 어떻게 나눌까?

상태를 전부 프롬프트에 넣거나 전부 Redis에 넣을 필요는 없다. 게임 규칙에 영향을 주는 값과 LLM 추론에만 필요한 값을 나누는 것이 좋다.

구분예시저장 위치갱신 주체
권위 상태HP, 좌표, 소지 골드, 퀘스트 완료 여부게임 DB 또는 월드 서버게임 서버
에이전트 상태목표, 단기 기억 요약, 마지막 발화, 행동 쿨다운Redis와 영속 스냅샷에이전트 서비스
파생 문맥주변 플레이어 요약, 최근 이벤트 10개요청 시 조립컨텍스트 빌더
일회성 작업 상태LLM 호출 중 여부, 재시도 횟수작업 큐워커

권위 상태를 LLM 서비스가 직접 수정하게 만들면 치팅 방어, 롤백, 운영 추적이 어려워진다. LLM 서비스는 move_to, start_dialogue, offer_quest 같은 명령 후보를 만들고 게임 서버가 범위·권한·쿨다운을 검사한 뒤 적용해야 한다.

LLM 워커와 Redis 및 권위 게임 서버 사이의 상태 흐름

LLM 에이전트 요청을 어떻게 분산 처리할까?

아래 구조는 Node.js 22, Redis 7, PostgreSQL 16 같은 조합에서도 그대로 적용할 수 있다. 특정 언어보다 메시지 계약과 상태 갱신 규칙이 중요하다.

flowchart LR
  G[Game Server] -->|AgentTask| Q[Redis Streams 또는 작업 큐]
  Q --> W1[LLM Worker 1]
  Q --> W2[LLM Worker 2]
  W1 --> R[(Redis Agent State)]
  W2 --> R
  W1 -->|Command Proposal| G
  W2 -->|Command Proposal| G
  G --> P[(PostgreSQL Authoritative State)]

1. 에이전트 단위로 순서를 정한다

전역 순서는 필요 없다. 대신 같은 agentId에 대한 작업만 순서가 보장되면 된다. 파티션 키를 agentId로 두거나 소비자가 상태 버전을 확인해 오래된 작업을 폐기한다.

{
  "requestId": "01JQ4Y0D9V8S4K3A1M2N",
  "agentId": "npc-merchant-42",
  "sessionId": "zone-7:match-918",
  "expectedVersion": 18,
  "trigger": "player_dialogue",
  "event": {
    "playerId": "player-1004",
    "text": "물약 있어요?"
  }
}

expectedVersion은 작업을 만들 당시의 에이전트 상태 버전이다. 워커가 완료할 때 현재 버전이 달라졌다면 결과를 적용하지 않고 최신 상태로 다시 작업을 만들거나 폐기한다.

2. LLM 출력은 구조화된 명령으로 제한한다

자연어만 받으면 파싱 규칙이 늘어나고 검증 범위도 넓어진다. JSON Schema 또는 도구 호출(function calling)으로 허용 행동을 좁힌다.

{
  "reply": "체력 물약은 30골드입니다.",
  "commands": [
    {
      "type": "open_shop",
      "shopId": "merchant-42",
      "targetPlayerId": "player-1004"
    }
  ],
  "memoryPatch": {
    "summary": "player-1004가 체력 물약 가격을 문의했다."
  }
}

게임 서버는 shopId 존재 여부, 대상 플레이어 거리, 상점 이용 가능 상태를 검사한다. 검증 실패 시 명령만 거부하고 발화는 안전한 범위에서 표시할 수 있다.

3. 상태 갱신을 한 번의 원자적 작업으로 만든다

가장 작은 구현은 Redis의 WATCH와 MULTI/EXEC, 또는 Lua 스크립트다. 비교할 값은 현재 상태 버전과 마지막 처리 요청 ID다. 아래 예제는 에이전트 상태 전체를 JSON 문자열 하나로 저장해 Lua 스크립트에서 비교와 갱신을 원자적으로 처리한다.

const saveAgentStateScript = `
local raw = redis.call('GET', KEYS[1])
local state = raw and cjson.decode(raw) or { version = 0 }
local requestId = ARGV[2]
if state.lastRequestId == requestId then return { 0, 'duplicate' } end
local expectedVersion = tonumber(ARGV[1])
if tonumber(state.version) ~= expectedVersion then
  return { 0, 'stale', tostring(state.version) }
end

local patch = cjson.decode(ARGV[3])
for field, value in pairs(patch) do
  if field ~= 'version' and field ~= 'lastRequestId' then state[field] = value end
end
state.version = expectedVersion + 1
state.lastRequestId = requestId
redis.call('SET', KEYS[1], cjson.encode(state))
return { 1, tostring(state.version) }
`;

async function saveAgentState(agentId, expectedVersion, requestId, patch) {
  const key = `agent:${agentId}:state`;
  const result = await redis.eval(saveAgentStateScript, {
    keys: [key],
    arguments: [String(expectedVersion), requestId, JSON.stringify(patch)]
  });
  if (Number(result[0]) === 0) {
    return { applied: false, reason: result[1], currentVersion: result[2] };
  }
  return { applied: true, version: Number(result[1]) };
}

Redis는 스크립트를 원자적으로 실행하므로 두 워커가 같은 버전을 읽고 모두 덮어쓰는 경쟁 조건을 막는다. 실제 서비스에서는 patch의 허용 필드를 검증하고, 같은 요청에 같은 응답을 돌려줘야 한다면 requestId별 처리 결과도 별도로 보존한다.

상태 보존은 Redis만으로 충분한가?

실시간 응답에는 Redis가 적합하지만 Redis만을 장기 저장소로 두면 운영 사고나 데이터 만료 정책의 영향을 크게 받는다. 권위 게임 데이터는 PostgreSQL 같은 영속 저장소에 남기고 에이전트 메모리는 Redis에 캐시한 뒤 주기적으로 스냅샷을 저장하는 구성이 실용적이다.

데이터권장 보존 방식복구 기준
게임 월드 상태트랜잭션 DBDB 레코드
단기 대화 문맥Redis TTL만료 허용
장기 기억 요약PostgreSQL JSONB 또는 문서 저장소최신 스냅샷
작업 진행 상태Redis Streams 소비자 그룹pending 항목 재할당
감사 로그append-only 이벤트 테이블requestId 추적

스냅샷은 매 발화마다 저장할 필요가 없다. 목표 변경, 퀘스트 상태 변경, 장기 기억 갱신처럼 의미 있는 전환에서 저장하면 비용과 복구 정확도 사이의 균형을 맞출 수 있다.

장애와 재시도는 어떻게 처리할까?

멱등성 키를 먼저 설계한다

requestId는 클라이언트 재시도, 큐 재전달, 워커 타임아웃으로 발생하는 중복을 하나의 요청으로 묶는다. 처리 결과도 requestId와 함께 짧은 TTL로 저장하면 같은 요청에 같은 결과를 돌려줄 수 있다.

재시도 가능한 오류만 재시도한다

LLM API의 429, 일시적 5xx, 네트워크 타임아웃은 지수 백오프로 재시도할 수 있다. JSON Schema 검증 실패나 권한 없는 게임 명령은 재시도해도 해결되지 않으므로 실패로 기록하고 안전한 기본 응답으로 전환한다.

재시도 지연은 다음처럼 상한과 난수를 둔다.

delayn=min(30000,500×2n)+jitterdelay_n = min(30000, 500 \times 2^n) + jitter

여기서 jitter는 여러 워커가 동시에 재시도하는 현상을 줄이기 위한 임의 지연이다.

워커가 죽어도 작업을 잃지 않는다

Redis Streams 소비자 그룹을 사용한다면 ACK 이전의 pending 메시지를 다른 워커가 회수할 수 있다. 다만 회수된 작업도 기존 작업과 중복될 수 있으므로 최종 상태 저장 지점의 requestId 멱등성 검사가 반드시 필요하다.

LLM 에이전트 상태 보존 구현 체크리스트

  1. 게임 규칙을 바꾸는 값은 게임 서버와 DB를 권위 저장소로 정한다.
  2. 모든 에이전트 작업에 agentId, requestId, expectedVersion을 넣는다.
  3. LLM 출력은 자유 텍스트가 아닌 검증 가능한 구조화 명령으로 받는다.
  4. 상태 비교와 갱신을 Redis Lua 또는 DB 트랜잭션으로 원자화한다.
  5. Redis 단기 상태와 영속 스냅샷의 복구 경계를 문서화한다.
  6. 재시도, 중복, 오래된 버전, 명령 검증 실패를 별도 메트릭으로 기록한다.

자주 묻는 질문 (FAQ)

에이전트마다 Redis 분산 락이 필요한가?

항상 필요하지는 않다. expectedVersion 비교와 원자적 상태 갱신으로 충돌을 처리할 수 있다. 한 에이전트의 작업을 반드시 직렬 처리해야 하거나 LLM 호출 비용을 줄여야 할 때만 짧은 TTL의 락을 추가한다.

대화 전체를 매번 프롬프트에 넣어야 하는가?

아니다. 최근 몇 턴과 장기 기억 요약을 조합하면 된다. 전체 로그는 감사와 분석용으로 저장하고 모델 입력에는 현재 행동에 필요한 최소 문맥만 넣는 편이 비용과 지연에 유리하다.

LLM 응답이 늦으면 게임 틱을 멈춰야 하는가?

멈추면 안 된다. LLM 작업은 비동기로 처리하고 NPC는 대기 행동이나 마지막으로 검증된 행동을 유지한다. 응답이 도착했을 때도 상태 버전이 맞을 때만 적용한다.

정리

분산 LLM 에이전트의 안정성은 더 많은 워커보다 명확한 상태 소유권에서 시작한다. 워커는 수평 확장 가능한 추론 실행기, Redis는 빠른 조정 계층, 게임 서버와 DB는 검증 가능한 권위 계층으로 역할을 나누면 멀티플레이 환경에서도 장애와 중복 요청을 예측 가능하게 다룰 수 있다.