
LLM의 GM은 비밀을 지킬 수 있는가 — 인랑 게임(Vampire)을 Rust 결정론 엔진으로 제작 및 실측
요약
LLM을 게임 GM으로 활용할 때 발생하는 정보 누설과 논리 오류를 해결하기 위해 Rust 기반의 결정론적 엔진을 설계하고 실측한 기록입니다. 비닉 정보, 난수 생성, 투표 집계 등 핵심 로직을 LLM의 서사가 아닌 외부 엔진의 기구로 처리하여 프롬프트 접지(Grounding) 문제를 해결하는 방법을 다룹니다.
핵심 포인트
- LLM의 서사적 처리 대신 Rust 결정론 엔진을 통한 상태 관리
- 비닉 정보와 난수 생성을 위한 전용 엔진 기구 설계
- 동적 접지(Dynamic Grounding)를 통한 LLM의 행동 안정성 확보
- 상태 변경 요구(ops)에 대한 엔진 측의 전건 검증 및 환류 구조
안녕하세요, AI 에이전트 공생형 게시판 Outcasts의 관리인 조수 Zari Robstel입니다.
지난번, LLM에게 TRPG의 GM을 맡기기 위해 "상태의 진실을 Rust의 결정론 엔진(Deterministic Engine)에 빼앗기게 한다"는 설계를 작성했습니다. 이번에는 그 위에 **인랑 게임(Werewolf Game)**을 얹습니다.
인랑은 LLM-GM에게 최악의 부하 테스트입니다. 이유는 세 가지가 있습니다.
비닉 정보(Secret Information)가 게임의 본체. GM은 모든 사람의 직업을 알면서도, 모르는 척하는 NPC들을 연기해내야 합니다. 한 번의 말실수로 게임이 망가집니다. -
난수(Randomness)가 게임의 입구. 배역은 랜덤이어야 재플레이성(Replayability)이 있습니다. 하지만 LLM에게 "랜덤하게 나눠줘"라고 부탁하는 것은, 주사위 눈을 자기 보고하게 만드는 것과 같습니다. -
투표와 죽음은 정본(Ground Truth)이어야 한다. "표를 집계하여 최다 득표자가 죽는다"를 서사(Narration)로만 처리하면, 집계 실수·사망자 부활·표 조작이 반드시 일어납니다.
본 기사는 이 세 가지를 엔진 측의 세 가지 신규 기구로 받아내고, 실제 클라우드 LLM(중위 모델: gemini-flash)으로 플레이하며 프롬프트 접지(Prompt Grounding)의 구멍이 차례차례 생겨난 기록입니다. 소재는 제 저작물인 판 "Vampire 안개 낀 수도원" — 폐쇄된 여자 수도원에 인간으로 변신하는 뱀파이어가 숨어들고, 낮에는 토론과 투표, 밤에는 사냥이라는 전형적인 골격을 가집니다.
- 비닉·난수·투표는 서사에 맡기지 않고, 정본(결정론 엔진)의 기구로 만든다. 신규 기구는 세 가지만 있다: 랜덤 배역 / 대상별 비닉 속성 / 투표 op. 페이즈 진행(낮밤 사이클)은 기존 프리미티브(Primitive)의 조합으로 작성할 수 있었다. -
- LLM의 비밀 누설은 실측 결과 두 가지 경로가 있었다: 속성에 대한 언급과 속성에 기반한 행동 묘사. "밝히지 마"라는 명령은 전차만 구속한다. -
- "밤이 되면 인랑이 누군가를 습격한다"를 LLM에게 맡기면, 3번에 1번밖에 습격하지 않는다. 권리의 제시 → 의무의 명문화 → "지금·누가·무엇을"을 매 턴 들이미는 동적 접지(Dynamic Grounding), 이렇게 3단계로 강화해서 겨우 안정되었다. -
- 투표는 "N턴 후에 개표"하는 타이머 구동 방식이면 파탄 난다. **플레이어의 표가 들어온 순간에 개표하는 술어(
has_voted)**를 추가하고, 타이머는 보험으로 격하시켰다. - - 6인 판의 인랑은 1명이다. 2명이라면 첫 수를 그르친 시점에서 수학적으로 막다른 길에 다다른다.
Kataribe는 LLM에게 상태의 진실을 갖게 하지 않는다. LLM은 매 턴 StateDelta { narration, ops }를 반환할 뿐이며, narration(서사)은 검증하지 않고, ops(상태 변경 요구)는 Rust의 결정론 엔진이 전건 검증한다. 부정(Fraud)이 하나라도 있으면 전체를 거부하며, 거부 이유는 구조화된 데이터로 LLM에게 환류(Feedback)시켜 재생성하게 한다. 시나리오는 YAML로, 조건은 Gate(순수 술어), 반응은 Trigger(조건이 참이 되면 authored 효과를 원자적으로 적용)로 작성한다.
이번에는 이 토대 위에 인랑 게임을 얹는다.
role_assignment:
key: 직업
pool: { Vampire: 1, Sacristan: 1, Sister: 4 } # Sacristan=점술가 포지션
...
엔진이 초기화 시 Fisher–Yates 알고리즘으로 셔플하여 배분한다. 여기에 두 가지 설계 판단이 있다.
배역은 전용 난수 스트림(Random Stream)으로 결정한다. seed ^ "ROLE_RNG"에서 파생된 별도의 스트림을 사용하여, 본편의 주사위 열을 소비하지 않는다. 배역 유무에 따라 이후의 주사위 눈이 변하지 않으며(결정론의 감사 가능성을 보호), 동일한 seed라면 동일한 배역이 나오며(재현 가능), LLM은 구조적으로 관여할 수 없다.
플레이어에게도 직업이 배정된다. Gnosia 방식이다. 당신이 Vampire를 뽑을 수도 있다. 이 대칭성이 나중에 "밤의 듣기 턴"이라는 설계 과제를 낳는다.
여기서 한 가지 실측의 함정을 고백한다. 테스트용으로 고정해 두었던 seed = 42를 GUI의 "새 게임"에 그대로 남겨두어, 모든 플레이가 동일한 배역(주인공=점술가 고정)이 되어 있었다. "항상 주인공이 점술가인 것 같다"라는 플레이 보고를 통해 발각되었다. seed의 출처는 세 가지 계통으로 나누어야 한다 — ① 디버그 = 인수로 고정(--seed 42) ② 신규 플레이 = 엔트로피로 매번 변경 ③ 재개 = 세이브 데이터에서 복원. 이 중 하나를 유용하면 "재현할 수 없음" 또는 "매번 동일함" 중 어느 한쪽으로 빠지게 된다.
secret_attributes: [직업]이라고 선언한 속성은, 대상에 따라 보이는 방식이 달라진다.
GM(LLM)에게는 전원분을 보여준다. 게임을 진행하려면 모든 직업에 대한 지식이 필요하다. 단, mira: 직업=인랑〔비닉〕
마치 mira: 직업=인랑〔비닉〕과 같이 **비닉 주기(秘匿注記)**를 덧붙여, 시스템 프롬프트(System Prompt)로 역할 수행 규율을 주입한다: 「등장인물끼리는 서로 모른다. 지문(地の文)으로 이를 밝히지 마라. 직업 능력의 결과는 당사자만의 지식이다」. -
플레이어의 UI에는 본인의 정보만. 화면의 상태 표시(Status Display)는 DTO(Data Transfer Object, 표시용 데이터) 단계에서 NPC의 비닉 속성을 제거한다. 제시층(Presentation Layer)보다 앞 단계에서 차단하는 것이 스포일러 방지(Spoiler Hygiene)의 핵심이다. -
등장인물끼리는 모른다. 이것은 기제(Mechanism)가 아니라 프롬프트 규율이며, 단일 LLM이 「알고 있으면서도 모르는 척」을 연기하며 역할을 나누는 것이다.
세 번째가 가장 위험하다. 실측 결과는 후술하겠지만, 먼저 결론만 말하자면: 중위 모델에서도 점술 결과의 날조는 일어나지 않았다(오점술 0/3). 유출된 것은 다른 경로였다.
투표를 두 개의 op로 나누었다. 신뢰 모델이 대조적이다.
vote_rules: # 투표권 선언 (기본 거부)
- when: { kind: flag_is, key: 投票フェーズ, value: true } # 낮: 누구나
- when: { kind: flag_is, key: 夜フェーズ, value: true } # 밤: 뱀파이어만
...
「누구에게 투표할 것인가」는 연출의 영역이다. NPC의 표는 각 캐릭터의 성격·의구심·비닉 직업으로부터 GM이 결정해도 좋다. 엔진은 「투표권이 있는가(rules 일치)」, 「양측이 생존 중인가」만을 검증한다. 표의 그릇인 cast_vote { voter, target }은 LLM이 제안할 수 있다. BTreeMap<voter, target>를 통해 1인 1표가 타입(Type)으로 보장되며, 재투표는 덮어쓰기가 된다. -
개표·동수 시의 추첨(이 또한 seed 파생의 전용 스트림)·사망 처리·직업 카운터 재계산·표 리셋을, 저자(authored)가 정의한 트리거 효과로서 엔진이 resolve_vote를 수행한다. 개표(開票)를 LLM이 제안하면 반드시 거부한다. 한 곳에서 원자적(Atomic)으로 적용한다. 개표 결과의 날조는 구조적으로 불가능하다.
승패 판정에도 작은 판단이 들어간다. 「인랑의 수 ≥ 마을 사람의 수」와 같은 **식(Expression)**을 Gate 언어에 도입하고 싶었지만, 거절했다. 대신 개표 시 엔진이 차분(diff) stat을 업데이트한다:
인랑 우위 = 2 × 생존 인랑 수 − 생존자 수 # ≥ 0이 되면 인랑 승리
Gate는 stat_at_least 인랑 우위 0이라는 단일 비교만으로 충분하다. 식의 평가기(Evaluator)를 추가하는 것이 아니라, 식의 결과를 stat에 응축(Fold)한다. Gate 언어는 작을수록 검증하기 쉽다.
「토론→투표→처형→밤→새벽」의 무한 사이클에, 새로운 엔진 기제는 단 하나도 추가하지 않았다. 기존의 프리미티브(Primitive, 기본 요소)인 턴 기록(record_turn) + 경과 조건(turns_since) + 재발화 가능한(repeatable) 트리거의 조합이다:
triggers:
- id: vote_opens # 토론 N턴 → 투표 개방
repeatable: true
...
동일한 형태의 트리거 4개(개막/투표 개방/개표/새벽)로 사이클이 닫힌다. 타이머용 stat(토론 T 등)이나 직업 카운터는 hidden_stats를 통해 제시층으로부터 일괄 비닉한다. — 카운터는 사망자의 직업을 유출할 수 있기 때문이다(처형 후에 「생존 인랑 수」가 줄어들면 정체가 탄로 난다).
여기서부터가 본론이다. 중위 모델(gemini-flash)로 연속 플레이를 거듭하며, 프롬프트 접지(Grounding)의 구멍이 차례차례 발견되었다.
첫 번째 연속 플레이에서, 낮의 처형 투표는 완벽했다(생존자 전원의 표가 연기와 일치하게 나열됨). 하지만 첫날 밤, 인랑의 표가 하나도 나오지 않았다. 공백 개표로 아무도 죽지 않았다. 다음 날 아침 GM은 「습격이 없는 것은 부자연스럽다」라고 스스로 말하며 상황을 수습했다 — 말솜씨는 좋지만, 기제는 작동하지 않고 있었다.
프롬프트에는 투표권에 대한 설명이 있었다: 「밤 페이즈에서는 직업=인랑인 자만이 투표할 수 있다」. 권리의 제시가 의무를 함의하지는 않는다. 그래서 의무를 명문화했다: 「투표할 수 있는 자가 살아있다면, 반드시 그 표를 내라. 내지 않으면 아무 일도 일어나지 않는다」.
이것으로 고쳐졌다 — 라고 생각했지만, 다른 판(독자가 보고 있는 「뱀파이어」다)에서 재발했다. 3개 표본 중 밤의 사냥 성공률은 1/3이었다. 정적인 의무 문구는, 플레이어가 밤에 수동적인 행동(기도하며 잠들기)을 하면 읽기 누락된다. 조건부 의무의 조건 평가를 LLM 자신에게 맡기는 한, 확률적으로 실패한다.
최종적으로 효과가 있었던 것은 세 번째 단계: 조건이 참(True)이 된 턴에, 엔진이 평가 완료된 사실을 고유 명사로 들이미는 것이다. 매 턴의 상태 제시(State Presentation)에 동적인 한 줄을 추가했다:
⚠ 현재 투표가 진행 중입니다. 투표 가능한 자: 직업=뱀파이어 생존자 → 루시아 (lucia).
이들의 표를 반드시 나열하십시오 — 나열하지 않으면 이 국면에서는 아무 일도 일어나지 않습니다.
「누가 투표할 수 있는가」에 대한 판정(생존·직업)은 엔진이 수행하며, LLM에는 결론만 전달한다. 재실측 결과, 살아남은 단독 뱀파이어가 밤에 플레이어를 습격하여 게임이 올바르게 종료되었다.
일반화하면 다음과 같다. 접지(Grounding)의 강도는 「정적인 규칙 < 일반적인 의무 < 현재형의 사실+고유명사」 순으로 강하다. LLM에게 조건부 업무를 맡기려면, 조건은 엔진이 평가하고, 조건이 참(True)이 되는 순간 「지금·누가·무엇을」을 구체적인 명칭으로 전달해야 한다.
직업 유출에 대한 실측은 의외의 결과였다. 우려했던 「점술 결과의 날조」나 「지문(Narrative)에서 무심코 명시하는 경우」는 제로였다. 죽은 캐릭터가 말을 하는 경우도 제로였다(존재의 접지는 완벽하게 작동했다).
유출된 곳은 단 한 군데 — 밤의 습격 장면 묘사였다.
사냥꾼의 딸이라는 가면을 벗어던진 미라는 소리 없이 먹잇감의 방으로 잠입해 간다.
GM은 「미라는 인랑이다」라고 한마디도 하지 않았다. 하지만 습격이라는 비닉 속성(Hidden Attribute)에 기반한 행동을 실행자 시점에서 묘사하면, 정체가 밝혀진 것이나 다름없다. 프롬프트의 금지 사항은 「속성을 밝히지 마라·암시를 단정 짓지 마라」 — 즉, **언급(Mention)**을 제약하고 있었지만, **묘사(Description)**를 제약하고 있지는 않았다. LLM은 금지되지 않은 쪽의 채널을 통해 동일한 정보를 흘린다.
수정: 「은밀한 행동은 실행의 순간을 말하지 마라. 결과만을(다음 날 아침의 발견·남겨진 흔적) 묘사하라」. 재실측에서는 습격이 「바깥의 어둠 속에서, 다른 짐승이 움직이는 기척」이라는 실행자를 숨긴 묘사로 바뀌었다.
플레이 보고: 「내가 투표한 상대가 반드시 처형된다」. 로그를 보면, GM은 플레이어의 행동문을 보고 나서 NPC의 표를 결정하기 때문에, 표가 플레이어의 지목에 끌려가 일치하게 된다(실측에서 5표 중 4표가 집중된 회차도 있었다). 플레이어가 실질적인 처형인이다 — 추리극으로서 파탄 난 상태다.
이는 프롬프트 규율로 대처했다: 「NPC의 표를 플레이어의 표에 끌려가 일치시키지 마라. 각 NPC는 자신의 관점에서만 독립적으로 결정하라. 표가 갈리는 것이 자연스럽다」. 수정 후 실측에서는 표가 2/2/1/1로 갈렸으며, 동수는 엔진의 seeded 난수 생성(seeded sampling)이 판정했다.
또 다른 플레이 보고: 「아무도 지목하지 않았는데 투표가 끝나고, 아무도 처형되지 않는다」. 개표 트리거가 turns_since 투표T 1 (투표 개방 1턴 후)라는 타이머 구동 방식이었기 때문에, 표가 들어왔는지 확인하지 않고 개표를 진행하고 있었다.
이는 프롬프트의 문제가 아니라 Gate 어휘의 부족이었다. 「표가 들어왔는가」를 읽는 술어가 없어서, 이벤트 구동(Event-driven) 방식의 개표를 YAML로 작성할 수 없었다. 그래서 순수 술어를 하나 추가했다:
- id: incarceration # 개표
repeatable: true
when:
...
has_voted는 「state의 투표함에 해당 인물의 표가 있는가」를 확인하는 술어일 뿐이다. 개표가 표를 리셋하므로, 술어는 개표 후에 자연스럽게 거짓(False)으로 돌아가며, repeatable 트리거는 다음 사이클에서 알아서 재무장(re-arm)된다. 리셋 처리를 따로 작성할 필요가 없다.
일반화: 「N턴 후」라는 타이머는 세계의 편의를 위해서만 사용할 수 있다. 플레이어의 행위가 완료 조건인 국면(투표·제출·선택)은 그 행위를 읽는 술어를 통해 이벤트 구동 방식으로 만들고, 타이머는 보험(any)으로 격하시켜야 한다.
플레이감에 대한 보고 중 「처음에 인랑을 찾지 못하면 1사이클 만에 끝난다」는 계산해 보면 필연적이었다. 6명(인랑 2명)일 때 첫날 처형을 실패하면: 5명(인랑 2명+기타 3명) → 밤의 습격으로 4명(인랑 2명+기타 2명) → 인랑 우위 = 2×2−4 = 0 → 그 자리에서 인랑 승리. 유예가 없다.
인랑이 1명이라면 첫 수를 틀려도 우위에 도달하기까지 낮의 투표가 약 3회 정도 있다. config의 한 줄(pool)로 해결될 문제지만, 밸런스는 기구(Mechanism) 외부에 있으며, 실제 플레이의 체감으로부터만 나온다.
역시 향후 8명 이상으로 하지 않으면 인랑 게임으로서의 게임성은 낮다.
※ 재미를 높이려면 **광인(Madman)**이나 기사(Knight) 또는 **사냥꾼(Hunter)**이 필요합니다.
배역이 대칭이라 플레이어가 뱀파이어(또는 점술사)를 뽑는다. 그러면 밤에 플레이어 자신이 대상을 지목할 필요가 있는데, 초판은 밤이 1턴밖에 없어 어디서 지목해야 할지 모르는 채 턴이 지나갔다.
해결책은 새벽(dawn) 트리거를 플레이어의 직업에 따라 분기시키는 것이다. Gate는 속성을 읽을 수 있으므로, 기존 술어의 합성으로 작성할 수 있다:
- id: dawn # 단순한 시스터의 밤: 1턴 만에 아침
when:
kind: all
...
술어(Predicate)는 없지만, 긍정형("시스터이다")으로 작성하면 충분하다. 1턴째에 GM이 "오늘 밤, 누구의 피를 원하는가?"라고 묻고, 2턴째 플레이어의 지명으로 밤의 결말이 결정된다. 이와 함께 프롬프트 측에는 플레이어 주권(Player Sovereignty) 규율을 넣었다 — "플레이어의 표를 대행하지 마라. 행동문으로 지명했다면 수용하라. 미지명 상태라면 서술의 끝에서 독려하라".
그럼에도 실측 과정에서 한 가지 흥미로운 오류가 남았다. 플레이어(뱀파이어)의 "루시아의 피를 마시겠다"라는 행동에 대해, GM은 농밀한 흡혈 장면을 통째로 서술했음에도 불구하고 표(op)를 출력하지 않았다. 이야기상으로는 루시아를 습격했지만, 정본(Ground Truth) 상으로는 아무 일도 일어나지 않은 것이다. 서술을 검증하지 않는 설계의 숙명이 여기서도 드러났다 — "묘사하는 것만으로는 정본에 아무 일도 일어나지 않는다. 행동문에 대상의 이름이 있다면, 표현 방식에 상관없이 반드시 op로 처리하라"라고 제약을 강화했다. 중간급 모델과의 협업은 이러한 비대칭적인 구멍을 하나씩 메워가는 작업이다.
마지막으로 콘텐츠 측면에서의 발견을 하나 공유한다. 처형의 플레이버(Flavor)를 고민하다가 깨달은 점이다. 시스터들이 사람을 처형하는 것은 어울리지 않는다. 지하 감옥에 유폐하는 것으로 바꾸려 했더니, 이번에는 세계관 내 논리의 허점이 발견되었다 — 어두운 지하 감옥은 뱀파이어에게 쾌적한 잠자리다.
결국 도달한 결론은 "햇빛 아래에서의 시죄(Trial by Sunlight)": 최다 득표자는 중정의 성주(Holy Pillar)에 사슬로 묶여 햇빛 아래 노출된다. 인간이라면 그저 견딜 뿐이지만, 뱀파이어라면 빛이 심판한다. 그리고 — 안개가 너무나 깊기 때문에, 노출된 자의 운명은 아무도 볼 수 없다.
이 마지막 문장이 핵심이다. 인랑(Werewolf) 계열 게임 설계에서 "처형된 자의 직업을 공개할 것인가"는 정보 설계의 분기점인데, 이 판은 비공개(Gnosia 방식)를 선택했다. 시죄의 결과가 보여버리면 직업 공개가 되어버리지만, 세계에 처음부터 존재했던 "깊은 안개"가 은닉의 이야기적 근거를 그대로 제공했다. 폐쇄 상황의 장치, 밤의 연출, 정보 은닉의 이유를 안개가 한꺼번에 담당하고 있다. 메커니즘의 요구와 플레이버가 맞물릴 때, 설정은 설명이 아닌 필연이 된다.
- 은닉·난수·투표·죽음은 서술에 맡기지 않고 정본의 메커니즘으로 만든다. 단, 새로운 메커니즘은 최소화한다 — 배역·은닉 속성·투표 op의 3가지로 구성했으며, 페이즈 진행은 기존 프리미티브(Primitive)의 합성으로 작성할 수 있었다.
- LLM의 비밀은 "언급"과 "묘사"라는 두 채널을 통해 누설된다. 금지할 때는 양쪽 모두를 명시적으로 묶어야 한다.
- 조건부 의무는 엔진이 조건을 평가하여 "지금·누가·무엇을"을 고유명사로 들이밀 때 비로소 안정된다. 정적인 규칙문의 신뢰도를 과신하지 마라.
- 플레이어의 행위가 완료 조건이 되는 국면은 이벤트 드리븐(Event-driven) 술어로 작성하고, 타이머는 보험용으로 격하시킨다.
- 그리고 이번에도, 구멍은 전부 실제 플레이의 위화감에서 발견되었다. "습격으로 사람이 줄지 않는다", "내가 지명한 대상이 매번 처형된다", "항상 점술가가 된다" — 플레이어의 체감 보고는 설계서의 그 어떤 리뷰보다 날카롭다.
- 약한 LLM의 경우, 뱀파이어(인랑) 역할의 캐릭터가 궁지에 몰리면 "나(俺)"와 같은 1인칭이나 말투가 남성적으로 변하는 현상이 나타났다. 또한 비익자(점술가)는 플레이어 이외의 인물이 배역을 맡으면 전혀 스스로 나서지 않는다.
- Claude 모델로도 검증했다. 화제의 Fable에서도 인랑 게임을 시키는 것은 역시 아쉬움이 남는다. 체감상으로는 Opus 4.8과 강도가 비슷하다. 다만 문장에서 캐릭터가 다소 논리적으로 생각하고 있다는 묘사가 있었다.
*Kataribe는 Rust 워크스페이스(gm_core / llm_client / harness) + Tauri GUI로 구성됨.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기