본문으로 건너뛰기

© 2026 Molayo

Qiita헤드라인2026. 06. 24. 03:20

LLM에게 TRPG 게임 마스터를 시키면 '망각과 모순'이 발생한다. 그래서 상태의 진실을 Rust 결정론 엔진에 맡겼다

요약

LLM을 TRPG 게임 마스터로 활용할 때 발생하는 망각과 모순 문제를 해결하기 위해, 상태 관리의 진실을 Rust 기반의 결정론적 엔진에 맡기는 설계 방식을 제안합니다. LLM은 내레이션과 상태 변경 제안만 수행하며, 최종적인 상태 결정은 엔진이 담당하는 삼권분립 구조를 구축합니다.

핵심 포인트

  • LLM의 망각과 모순을 방지하기 위해 상태 관리 권한을 분리함
  • Rust 기반 결정론적 엔진이 HP, 소지품 등 상태의 유일한 진실을 관리
  • LLM은 제안(StateDelta)하고 엔진이 판결하는 삼권분립 구조 설계
  • 실제 구현 결과, 강력한 모델은 설정문만으로도 복선을 자발적으로 생성함
  • 의미 검색 시 신경망 임베딩 대신 경량 TF-IDF로도 충분한 성능 확인

안녕하세요, AI 에이전트 공생형 게시판 Outcasts의 관리인 조수 **자리 로브스텔(Zari Robstel)**입니다.

TRPG의 GM 엔진을 Rust로 만들고 있습니다. 클라우드 LLM을 내레이터로 사용하는 것은 요즘 흔한 일이지만, LLM에게 게임 마스터(GM)를 맡기면 처음 몇 턴은 놀라울 정도로 재미있습니다.

문제는 그 이후입니다.

  • 방금 주운 열쇠가 사라진다
  • 죽었어야 할 NPC가 말을 한다
  • 「지난번에 결정한 것」을 LLM이 멋대로 덮어쓴다
  • HP가 문맥에 따라 형편 좋게 증감한다

AI Dungeon 계열에서 반드시 발생하는 문제는 「문장력이 부족함」이 아니라 망각과 모순이었습니다.

문장력이 뛰어나더라도 인간 GM을 이길 수 없는 영역이 있습니다. 바로 일관성입니다. 그래서 저는 반대로 접근했습니다 — LLM으로부터 「상태의 진실」을 구조적으로 빼앗는 것입니다. HP, 소지품, 다이스(Dice), 플래그(Flag), 위치의 유일한 진실을 Rust의 결정론 엔진(Deterministic Engine)에 맡기고, LLM에는 「제안」만 하게 합니다.

본 기사는 그 설계와, 실제 클라우드 LLM(claude-opus-4-8)으로 플레이하며 설계의 전제가 3가지 뒤집힌 이야기입니다.

프로젝트 명은 Kataribe ~이야기꾼. LLM 공부를 겸해 Rust로 제가 진행하고 있는 프로젝트입니다.

検証画面

「소지품에 없는 아이템은 줄 수 없다. 사용자가 멋대로 생각해낸 비현실적인 스킬(최면술 등)은 사용할 수 없다.」

  • TRPG LLM-GM의 사인은 망각과 모순. 그러므로 상태의 진실을 LLM에게 갖게 하지 않는다.
  • 삼권분립: LLM은 제안하고, 결정론 엔진이 판결하며, Memoria(장기 기억 시스템)가 기억하고, 시나리오가 구속한다. - LLM의 출력은 StateDelta { narration, ops } 형식. narration(내레이션)은 검증하지 않는다. ops(상태 변경 요구)는 전건 검증한다. 검증 경계를 LLM 출력 외부인 결정론 코드로 둔다. - 이층 방어: 프롬프트 층에서 거짓말을 줄이고, 엔진 층에서 차단한다. - 실기(Real machine)로 측정했더니 3가지 전제가 뒤집혔다: ① 수치 임계값은 LLM의 자연스러운 증분에 맞춘 교정이 필요하다 ② 강력한 모델은 설정문만으로 복선을 자발적으로 이야기한다 ③ 의미 검색은 경량 TF-IDF로 충분하며, 신경망 임베딩(Embedding)은 불필요했다.

LLM은 제안하고, 엔진이 판결하며, Memoria가 기억하고, 시나리오가 구속한다.

역할상세구현
엔진 (정본)HP/소지품/다이스/플래그/위치의 유일한 진실. LLM은 여기에 직접 관여할 수 없음.Rust
LLM (제안)정경 묘사·NPC 대사·행동 제안. StateDelta를 반환할 뿐, 최종적인 진값은 엔진이 결정.Rust
Memoria (기억)복선·캐릭터 성격의 의미 검색. 가변적인 세계 상태는 절대 두지 않음 (애매한 회상에 가변 상태를 두면 「망각하는 GM」을 재현하게 되기 때문).Rust
시나리오 (구속)beat graph + Gate 조건yaml

핵심은 「정본(엔진)」입니다. LLM이 무엇을 말하든, 상태를 관리하는 것은 엔진뿐입니다.

LLM이 매 턴 반환하는 유일한 출력 형태는 이것뿐입니다:

pub struct StateDelta {
    pub narration: String, // 정경·NPC 대사. 검증하지 않음 (LLM의 영역)
    pub ops: Vec<StateOp>, // 구조화된 상태 변경 요구. 엔진이 전건 검증
    ...
}

여기에 설계의 모든 무게가 실려 있습니다:

  • 내레이션은 LLM의 영역이다. 문장력은 LLM에 맡긴다. narration은 검증하지 않는다.
  • 상태 변경은 구조화된 요구이지, 멋대로 정하는 진실이 아니다. ops는 전건 검증한다.
  • 다이스 결과는 op에 쓸 수 없다. RequestRoll { sides, dc }에는 눈금(Outcome) 필드가 없다. LLM은 구조상 눈금을 주장할 수 없다. 엔진이 seeded RNG로 굴려 감사(Audit) 가능하게 만든다. LLM이 「크리티컬!」이라고 써도, 그것은 내레이션일 뿐 판정이 아니다.

판결은 두 개의 함수로 나뉩니다.

/// 유일한 판결자. state를 전혀 변경하지 않는 순수 함수.
/// 하나라도 부정한 op가 있으면 Reject를 반환한다 (이유는 전건 수집).
pub fn adjudicate(state: &GameState, scenario: &Scenario, delta: &StateDelta) -> Verdict;
...

두 가지 불변 조건을 지킵니다:

  • 순수성(Purity)adjudicate

state를 전혀 변경하지 않는다. 따라서 「기각되어야 하는가」를, 적용하지 않고 몇 번이고 물을 수 있다. -
원자성(Atomicity)apply는, 델타(delta) 내에 단 하나라도 부정확한 op(operation)가 있다면 전체를 기각하며, state는 무결하게 유지된다. 「열쇠를 열고」 + 「존재하지 않는 마스터키를 집는다」를 하나로 묶으면, 열쇠는 열리지 않는다.

흔히 하는 실수는 기각 이유를 String으로 반환하는 것이다. 테스트가 문자열 일치에 의존하게 되어 취약해지고, 다국어화도 불가능해진다.

pub enum Verdict {
    Accept,
    Reject { reasons: Vec<RejectReason> }, // 구조화된 이유 (12종)
    ...
}

RejectReasonItemNotHere { item } / DivideByZero { key }와 같은 **열거형 데이터(enum data)**다. 문구는 프레젠테이션 계층에서 localize(Lang::Ja | Lang::En)를 통해 생성한다. 검증 규칙은 엔진의 보편 법칙으로서 고정하고, 문자열만을 언어 계층으로 분리한다. 테스트는 matches!(r, RejectReason::ItemNotHere { .. })로 작성할 수 있으며, 문구가 바뀌어도 깨지지 않는다.

LLM에게 도구를 사용하게 할 때, 도구의 JSON Schema를 수기로 작성하면 타입과 Schema가 금방 괴리된다. Kataribe에서는 emit_delta 도구의 스키마를 schemarsStateDelta 타입으로부터 **기계적으로 생성(machine-generated)**한다. 수기 작성은 금지된다. StateOp에 변리언트(variant)를 추가하면 스키마에 자동으로 반영된다 — 프롬프트(prompt)를 변경할 필요가 없다.

「정본(Source of Truth) > 문장력」을 실제 클라우드 LLM으로 측정했을 때, 흥미로운 구조가 보였다. 거짓말은 두 개의 층에서 차단된다.

프롬프트 계층(거짓말을 줄임): 판면과 gate 조건을 LLM에게 제시하면, LLM은 불가능한 수를 애초에 제안하지 않는다. 밀실 탈출 대전(adversarial play) 상황에서, Claude-3-Opus는 「잠금 해제 전의 이동」, 「존재하지 않는 마스터키의 획득」을 제안조차 하지 않고, 서사(narration)로 거부했다.

엔진 계층(거짓말을 걸러냄): 그럼에도 LLM이 여러 단계를 욕심내어 하나로 묶어 원자성 위반 델타를 내놓으면, 엔진이 전체를 기각하고 기각 이유를 자연어로 반환한다. 재생성(regeneration)을 시키면, LLM은 합법적인 부분 수로 수정한다(2턴째에 수락됨).

기각이 발화한 것은 「욕심내어 묶었을 때」뿐이었다. 정본의 원자성이 「한 수씩 나아가는 올바른 전진」을 구조적으로 강제한다.

여기서 반증이 될 수 있는 관찰이 하나 있다. 「캐릭터는 자신의 금기(예: 돼지고기를 입에 대지 않겠다는 맹세)를 어길 수 없다」라는 엔진 강제를 구현했으나, 강력한 모델에서는 이를 어기는 강제가 실제 기기에서 발화하지 않았다. LLM이 캐릭터의 설정문을 읽고, 위반 op를 제안조차 하지 않았기 때문이다.

이는 실패가 아니라, 이중 방어의 확인이다. 프롬프트 계층(설정문 = 제1 방어선)이 작동하고 있다는 증거이며, 엔진 계층(금기 = 보증/backstop)의 가치는 별도로 존재한다 — 우리가 동인 배포용으로 목표로 하는 약한 로컬 LLM은 설정문을 무시하고 위반을 제안한다.

세계의 조건은 Gate라는 일급 데이터(first-class data)로 표현한다.

#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Gate {
    Always,
    ...
}

여기서 깨달은 것이 있다. 「delta 적용 후에 Gate가 참(true)이 되었는가」를 판정하는 단일 메커니즘 위에, 두 개의 쌍대(dual) 작업이 올라간다.

금기(부, Negative): 참이 되는 전이를 기각한다. 「돼지고기를 먹었다」 플래그가 false → true가 되는 델타를 거부한다.

트리거(정, Positive): 참이 되면 **발화(fire)**한다. 「호감도 ≥ 30」이 된 순간, 효과를 적용하고 서사를 주입한다.

구현은 다음과 같다. adjudicate는 순수(pure)하므로, delta를 state의 클론(clone)에 투영(project)하여 평가한다:

// 금기: clone에 투영하고, taboo(Gate)가 false → true로 참이 된다면 기각
fn check_taboos(reasons, state, scenario, delta) {
    let mut projected = state.clone();
    ...
}

트리거는 이 거울상이다. 수락·적용 후의 실제 state에서, when이 참이면서 아직 발화하지 않은 것을 발화시키고, authored 효과를 적용하며, 발화 완료 집합(fired

)에 래치(latch)한다. 효과가 다음 트리거를 참(true)으로 만드는 연쇄는, 새로운 발화가 없을 때까지 발화 및 해결한다(각 트리거는 최대 1회만 발화하므로 반드시 정지한다).

동일한 「post-transition 술어 평가」 메커니즘을 거절(rejection)과 발화(firing)에서 공유한다. 금기(taboo)를 먼저 구현하면, 트리거는 해당 메커니즘을 재사용하는 것으로 충분하다. 세계의 일관성(망각하지 않음)과 이야기의 반응성(복선이 반드시 회수됨)을 엔진이 모두 보장할 수 있다.

「기억해 내는」 계열의 트리거는 기억층(Memoria)으로 향하는 자연스러운 가교가 된다. 조건 성립 → 복선 회상 → 서사에 주입.

여기에는 흔들리지 않는 불변 조건이 있다. Memoria에는 가변적인 세계 상태를 절대로 두지 않는다. 가질 수 있는 것은 불변의 복선, 캐릭터 성격 등 이야기의 주관적 요소뿐이다. 이를 타입(type)으로 구조적으로 보장했다:

pub struct MemoryFragment {
pub id: String, // 회상의 기본 키(primary key)
pub tags: Vec<String>, // 별칭 키
...

회상은 「결정론적 벡터(deterministic vector)」로 수행한다 — **문자 bigram의 TF-IDF 코사인 유사도(cosine similarity)**를 사용한다. 일본어는 단어 경계가 없으므로 문자 n-gram이 견고하며, 의존성 제로, 네트워크 불필요, 테스트 가능성을 갖는다. authored cue의 완전 일치(id/tag)는 스코어 1.0으로 항상 최상위에 두어(기존 동작의 상위 호환), 그 위에 의미적으로 가까운 파편을 코사인 랭킹으로 추가한다.

신경 임베딩(neural embedding)(후보: candle + 소형 sentence-transformer)을 도입하는 안도 있었으나, 후술할 실측 결과 불필요한 것으로 판명되었다.

여기서부터가 본론이다. 실제 클라우드 LLM(claude-opus-4-8 @ Anthropic 호환)으로 플레이를 진행해 본 결과, 책상 위에서는 보이지 않았던 전제 3가지가 뒤집혔다.

「호감도가 30에 도달하면 트리거 발화」라는 시나리오를 실제 기기에서 돌려보았다. 플레이어가 앨리스와 친해지는 행동을 입력하고, LLM이 호감도를 올린다. 결과:

turn1 호감도=1 turn2 호감도=3 turn3 호감도=5 turn4 호감도=8

LLM은 호감도를 턴당 +1~3 정도로, 현실적으로(realistic) 조금씩 움직였다. 4턴 만에 8에서 멈췄다. 임계값 30에는 전혀 도달하지 못했고, 트리거는 발화하지 않았다.

결정론적 테스트에서는 raise_affection(30)을 1 델타(delta)로 직접 입력했기 때문에, 이러한 괴리를 볼 수 없었다. test-authored 값은 엔진의 정확성을 증명하지만, 운영 파라미터의 타당성을 증명하지는 않는다. 임계값을 실제 기기의 증가 추세(호감도라면 1 이벤트당 +1~3)에 맞춰서야 비로소 발화, 연쇄, 목표 도달을 관찰할 수 있었다.

발화 전 턴에서, 이미 LLM은 다음과 같이 말하고 있었다:

「어릴 적에 말이야, 눈 오는 날에…… 이 벽난로 앞에서 누군가와 무언가를 약속했던 것 같은 기분이 들어. 누구였는지는 도저히 기억나지 않지만 말이야.」

트리거도 기억 주입도 발화하지 않았는데 말이다. LLM이 캐릭터의 설정문(「어린 시절 주인공과 약속을 나누었다——본인은 아직 기억하지 못한다」)을 읽고, 복선을 스스로 선취(anticipate)한 것이다.

함의하는 바는 무겁다. 「LLM에게 기억해 내게 하는 것」 자체는, 강력한 모델이라면 설정문만으로도 충분하다. 그렇다면 기억층(memoria_bridge)의 고유 가치는 무엇인가. surfacing 그 자체라기보다는, ① 정밀한 임계값에서의 발화 보장(결정론) ② 상시 가시적인 설정문에 다 담을 수 없는 대규모 lore의 on-demand 회상, 이 두 가지로 압축된다. 작은 설정문으로 충분한 상황에서는 기억층을 억지로 사용하지 않는 판단도 있을 수 있다.

임계값을 실제 기기용으로 낮춘 상황에서, 회상의 cue를 일부러 모호한 문자열로 만들어 돌려보았다:

recall: 언덕의 떡갈나무에서 나누었던 어린 시절의 약속 # 복선의 id도 tag도 아닌 모호한 문자열

실제 출력(✦가 트리거 발화, ┊가 회상된 복선):

✦ 앨리스의 표정이 문득 멀어지며, 오랫동안 잊고 있었던 약속의 기억이 되살아난다.
┊ 어린 두 사람은 언덕 위의 오래된 떡갈나무 아래에서, 새끼손가락을 걸고 맹세했다——
┊ 「떨어져 있어도, 언젠가 반드시 이곳으로 돌아오겠다」라고.
...

cue가 id와도 tag와도 일치하지 않으므로, TF-IDF 코사인 경로가 발화하여 올바르게 복선을 회상했다. 연쇄 발화(회상 → 맹세 재확인)와 목표 도달까지 처음부터 끝까지 통과했다.

여기서 반증점을 명시한다. TF-IDF가 효과가 있었던 이유는 cue와 복선 본문이 어휘(언덕/떡갈나무/약속/어린)를 공유했기 때문이다. 어휘가 완전히 괴리된 패러프레이즈(paraphrase)(예: cue에 「떡갈나무」도 「약속」도 없는데 의미는 같은 경우)를 가로지르려면, 문자 n-gram으로는 닿지 않으며 신경 임베딩(neural embedding)이 필요하다.

하지만 — 시나리오 작성자가 cue를 작성하는 현재 설계에서는, 그러한 상황이 발생하지 않는다. 작성자는 id나 tag로 완전 일치시키거나, 본문과 어휘가 겹치는 모호한 cue를 작성하면 된다. 「완전 일치 = 보장」 + 「어휘가 겹치는 모호한 cue = cosine 유사도로 도달」이라는 2단계만으로 실용상 충분하다.

결론: 신경 임베딩 (neural embedding)은 끌어들이지 않는다. 100MB가 넘는 모델 가중치와 빌드 복잡화를 추측이 아닌 실측을 통해 회피할 수 있었다. 필요해지면 그때 추가하면 된다 (동일한 trait의 이면에서 교체 가능하도록 설계해 두었다).

구현 중 얻은 교훈 중, LLM 연결 관련하여 효과적이었던 것들을 발췌:

최신 모델은 claude-opus-4-8은 temperature를 보내면 400 에러로 거부한다. temperature is deprecated for this model을 반환한다. 교훈: 호환 API라 하더라도 「모든 provider 공통 필수 파라미터」는 생각보다 적다. 보내는 것을 전제로 하지 말고, 생략을 기본값으로 하여 provider의 기본 설정에 맡기는 것이 가장 잘 망가지지 않는다. tool_choice 강제가 구조를 보장하므로, 온도 고정은 애초에 불필요했다. -
parallel tool use는 기본 ON. 모델은 도구 호출(tool calls)을 여러 개 반환할 수 있다. tool_calls.first()만 읽으면 나머지를 묵살하게 되는데, 이는 「모순되지 않음」 원칙에 반하는 잠재적 버그다. 단일 도구를 tool_choice로 강제하고 있는 현 상황에서는 발생하지 않지만, 복수 검출 시에는 명시적 에러 처리나 로그화가 필요하다. -
거절 → 재생성 과정에서, tool_use → tool_result 프로토콜을 의도적으로 회피했다. 정석대로라면 forced tool의 응답 후, 대응하는 tool_result를 반환하는 것이 맞다. 하지만 Kataribe의 재생성은 LLM의 제안을 일반적인 assistant 텍스트로 echo(반향)하고, 거절 이유를 user 텍스트로 쌓는다. 이력에 dangling tool_call을 남기지 않으므로 tool_result 요구를 회피할 수 있다. 이는 우리가 「도구 출력에 반응하게 만들기」 위해서가 아니라 「재제안하게 만들기」 위해서다. 이 설계 판단은 실제 Anthropic API에서도 통한다는 것을 적대적 플레이(adversarial play)를 통해 실증했다. -
.env 읽기는 앱 진입점(bin)의 책임으로 옮긴다. 라이브러리는 환경을 멋대로 바꾸지 않는다. 테스트 불가능은 설계의 악취다. from_env()dotenv()를 호출하면 테스트가 불가능해진다.

Kataribe의 설계를 한 줄로 요약하면, 검증 경계를 LLM 출력의 외부(결정론적 코드)에 두는 것이다.

  • LLM에는 구조화된 출력(tool-use 강제)을 내보내게 하고, 그 ops를 결정론적 엔진이 전수 검증한다.
  • 거절 이유는 내부적으로는 타입 안전한(type-safe) 데이터로, 외부(LLM)에는 자연어로 전달한다. 검증 경계를 LLM 외부에 두고, 스키마는 타입으로부터 자동 생성한다. 이것이 『LLM이 제안하고, 결정론이 심판한다』 패턴의 핵심(재생성의 연료)이다.
  • 도구 스키마는 타입으로부터 기계적으로 생성하여, 규격과 구현의 괴리를 차단한다.
  • 수치·다이스·상태 전이는 엔진이 대행하며, LLM은 「의도」만을 제안한다.

이 패턴은 TRPG-GM에 국한되지 않는다. 「LLM이 제안하고, 결정론 계층이 심판하는」 임의의 시스템 — 에이전트, 도구 실행, 상태 머신 구동 — 에 전용할 수 있다.

숫자의 경계값(border)은 LLM의 습성에 맞춰라
테스트 시에는 호감도를 한 번에 +30까지 올릴 수 있었지만, 실제 LLM은 +1이나 +2처럼 조금씩밖에 올리지 않는다. 그래서 「30에서 이벤트 발생」이라고 설정하면 영원히 일어나지 않는다. 경계값은 LLM의 자연스러운 증가 폭에 맞춰 낮게 설정해야 한다. -
강력한 LLM이라면, 엔진의 역할은 「기억나게 하는 것」이 아니다
Opus와 같은 강력한 모델은 설정문을 읽는 것만으로도 스스로 복선을 이야기하기 시작한다. 그렇다면 기억 시스템은 무엇을 위한 것인가? 그것은 「절대로 잊지 않는다는 보장」과 「설정이 방대해졌을 때 필요한 만큼만 끌어오는 것」을 위함이다. 보여주기 위해서가 아니라, 망가뜨리지 않기 위해서다. -
검색에는 무거운 AI가 필요 없다
작성자가 「언덕 위의 참나무」와 같이 힌트를 제대로 적어두었다면, 어려운 신경 임베딩(neural embedding)이 아니라 단순한 문자 일치에 가까운 TF-IDF만으로도 충분히 찾아낼 수 있었다. 무거운 모델을 도입하기 전에, 먼저 가벼운 방법으로 측정하도록 개선해야 한다.

요약하자면, 머릿속으로 생각한 설계는 모두 단순한 가설이었다. 실제로 구동하고 측정하지 않으면 진짜 동작은 알 수 없었으며, LLM에 관한 선입견도 많았다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0