AFM(Apple Foundation Models)을 활용한 텍스트 어드벤처 게임 개발 [2편]: 자유 입력을 대본 선택지로 연결하기
요약
Apple Foundation Models(AFM)의 온디바이스 모델을 활용하여 자유 입력 기반 텍스트 어드벤처 게임 개발 과정을 다룹니다. 특히 플레이어의 자유 대화를 게임이 처리 가능한 주제나 이동으로 변환하는 '입력 해석' 메커니즘에 초점을 맞추고 있습니다. 이는 대본 데이터, 후보 생성, 규칙 보정, 그리고 최종 정형 대사 선택까지의 구조화된 흐름을 설명합니다.
핵심 포인트
- AFM 온디바이스 모델로 자유 입력 기반 텍스트 어드벤처 개발 가능
- 자유 대화를 '입력 해석'하여 게임 내 주제나 이동으로 변환하는 과정 설명
- 대본(scenario.json)에 주제, 정형 대사, 인물 지식을 구조화하여 활용함
- 후보 생성 및 규칙 엔진을 통해 입력의 정확도를 높여 대사를 선택함
서론
※이 글은 필자의 Qiita 계정에서 재게시된 내용입니다 (멀티 포스팅).
Apple Foundation Models(이하 AFM)의 온디바이스 모델을 사용하여, 자유 입력 기반의 텍스트 어드벤처 게임을 개발하고 있습니다.
1편에서는 데모 '피의 연못 밤'과, AFM에 입력 해석・자유 대화・기억 인출을 맡기고, 이야기 진행은 대본(스크립트)과 규칙 엔진으로 제어하는 전체 설계를 소개했습니다.
이번에는 그중 '입력 해석' 부분을 다룹니다. 플레이어의 자유로운 말을 게임이 처리할 수 있는 주제나 이동으로 변환하는 부분입니다.
1편의 전체 구조도에 이번에 다룰 범위를 빨간색 두꺼운 테두리로 표시했습니다. 대본에 작성된 주제에서 후보를 만들고, ① 입력 해석과 규칙에 의한 보정을 거쳐, 규칙 엔진에서 정형 대사를 선택하는 과정까지를 추적합니다.

배경색은 1편과 동일하며, 파란색이 AFM, 노란색이 규칙, 초록색이 대본 데이터입니다. 자유 대화와 기억에 대한 자세한 구현은 3편에서 다룰 예정입니다.
이번에도 사용하는 모델은 SystemLanguageModel.default입니다. 입력 해석은 기기 내에서 수행하며, Private Cloud Compute(PCC)로 전환하지 않습니다.
예를 들어, 플레이어가 '흐르는 곡을 바꿔보면 어떨까?'라고 입력했을 때, 이를 가게에서 흐르는 음악에 대한 제안으로 처리하여 대본의 대화에 연결합니다.
흐르는 곡을 바꿔보면 어떨까?
↓ 입력 해석
의도: 음악
...
이 글에서는 먼저 대본 데이터를 확인하고, 후보 생성・좁히기, 구조화 출력을 통한 선택, 규칙에 의한 보정, 대사 표시 순서로 설명합니다. 소스 코드는 다음 리포지토리에 공개되어 있습니다.
게재된 코드는 공개 데모의 구현에서 필요한 부분을 발췌한 것입니다. 구현 기준은 1편과 동일하게 2026년 10월 10일 시점의 macOS 27 이상・Swift 6.2 구성을 대상으로 합니다. 코드 조각 앞뒤에 있는 변수 정의나 에러 처리는 링크된 파일을 참조해 주십시오.
1. 사전 준비: scenario.json에 주제・대사・인물의 지식을 작성하기
먼저, 그림의 좌상단에 있는 '대본(scenario.json)'을 설명합니다. 후보 생성이나 대사 표시를 이해하기 위해, 음악이라는 주제를 예로 들어 주제・정형 대사・인물 지식 3가지 종류의 데이터를 확인합니다. 마지막으로, 자유 대화 생성이 실패했을 경우의 정형 대사도 소개합니다. 아래 JSON은 각각 대본 내 해당 부분에서 추출한 것입니다.
주제: 무엇에 대한 입력인지 설명하기
주제는 intents 배열에 정의합니다. 음악 주제는 다음과 같습니다.
{
"id": "음악",
"description": "가게에서 흐르는 음악(쇼와 가요, 영화 음악, 노래방 등)에 대해 의견을 말하거나 묻기",
...
}
id는 주제의 식별자이고, description은 모델에 읽히는 설명이며, examples는 플레이어의 표현 예시입니다. 주제 이름뿐만 아니라, 무엇에 대해 의견을 말하거나 물어보는 주제인지를 설명합니다.
words는 주제 오해를 줄이기 위해 후보 좁히기에 사용하는 단어입니다. 단어를 지정한 주제는 입력에 그 단어가 하나도 포함되어 있지 않으면 후보에서 제외됩니다. 하나라도 포함되어 있으면 후보에 남지만, 반드시 그 주제가 선택되는 것은 아닙니다. words가 비어있으면 이 좁히기는 수행하지 않습니다. 처리의 상세 내용은 제4절에서 설명합니다.
정형 대사: 누가, 어떤 조건으로, 무엇을 말할지 작성하기
주제 정의와 별개로, 장면별 lines 배열에 대사를 작성합니다. 가게 내부 장면에는 음악에 대한 다음 2줄이 있습니다.
[
{
"id": "O1",
...
id는 대사의 식별자이고, speaker는 말하는 인물의 ID이며, condition은 표시할 조건, text는 그대로 표시되는 본문입니다. effects에는 연출을 지정하며, O2에서는 아오이의 표정을 '분노'로 바꿉니다.
O1의 의도:음악 + 완료:A04는
배열에 적습니다. 예를 들어, 마마에게는 다음 정보를 가지고 있습니다.
[
{
"info": "가게에서는, 직접 모은 쇼와 가요 레코드를 틀고 있다. 젊은 사람들을 위해, 가끔 다른 곡을 섞어주는 정도면 생각해 봐도 좋다",
...
}
info는 인물의 지식이나 생각을 나타내며, condition은 그 정보를 사용할 조건입니다. 여기서는 음악에 관한 화제라면 음악에 대한 생각, 빨간 문에 관한 화제라면 문에 대한 생각을 자유 대화 생성에 사용합니다. info는 답변의 참고 정보이며, 그대로 표시되는 정형 대사는 아닙니다.
화제의 정의가 되어도, 대응하는 정형 대사가 있는 것은 아닙니다. 빨간 문처럼, 이 장면에는 정형 대사가 없고, 인물의 생각을 사용한 자유 대화로 받는 화제가 있을 수 있습니다. 자유 대화 프롬프트 구성에 대한 자세한 내용은 제3회에서 다루겠습니다.
어떤 후보에도 해당하지 않는 입력과, 생성되지 않았을 경우의 정형 대사
어떤 후보에도 해당하지 않는 입력은 '없음'(코드에서는 .none)으로 처리하며, 보통은 자유 대화로 답변을 생성합니다. '어떤 후보에도 해당하지 않는다'는 이유만으로 결정된 대사를 반환하는 구조가 아닙니다. 다만, 입력 화제와 관계없이 진행되는 시나리오의 줄이 있다면, 그 줄도 표시됩니다.
그 자유 대화에서, 생성 오류나 출력 검사에 의해 사용할 답변을 얻지 못했을 경우에 대비하여, 인물별 fallbacks에 정형 대사를 작성해 둡니다. 예를 들어, 아오이에게는 다음 두 줄을 준비했습니다.
[
{"id": "카구라자카 아오이-거1", "text": "……모르겠어."},
{"id": "카구라자카 아오이-거2", "text": "그 이야기는, 다음에 하자."}
...
이것은 '후보에 해당하지 않았을 때의 대사'가 아니라, '자유 대화 답변을 생성할 수 없을 때 돌아가는 대사'입니다. 후보에 해당하더라도 대응하는 정형 대사가 없어 자유 대화로 진행되는 경우에도 같은 구조를 사용합니다. 답변 검사와 이 정형 대사로 되돌리는 처리에 대한 자세한 내용은 제3회에서 설명하겠습니다.
실제 시나리오는 scenario.json이며, 데이터 작성 방식은 시나리오 데이터 사양을 참조해 주세요.
2. 현재 상태에서 선택 가능한 후보 만들기
시나리오 정의를 준비했으면, 그림의 '현재 상태에서 선택 가능한 후보 만들기'로 넘어갑니다. 실제 장면에서 어떤 후보가 만들어지는지 확인합니다.
가게 안에서 '당신은 어떻게 생각해?'라고 물었을 때
도입부 대화에 한 번 답변하고, 아오이의 '……당신은 어떻게 생각해?' (A04)까지 진행했다고 가정합니다. 장소는 가게 안이며, 마마와 아오이가 있습니다. 음악과 메뉴 정형 대사는 아직 나오지 않았습니다.
이 상태에서는 다음 10개의 입력 후보를 만듭니다. A1 등은 AFM에 선택하도록 붙인 기호입니다. 설명은 읽기 쉽도록 요약했습니다. 말하는 상대나 입력에 포함된 단어에 의한 필터링은 아직 진행하지 않았습니다.
| 기호 | 입력 후보 | 설명 요약 | 후보의 획득원 |
|---|---|---|---|
A1 | 의도:음악 | 가게에서 트는 음악에 대해 의견을 말하거나 묻기 | inputCandidates |
A2 | 의도:메뉴 | 가게 메뉴나 명물에 대해 의견을 말하거나 묻기 | inputCandidates |
A3 | 의도:두 사람의 이후 이야기 | 주인공과 아오이 사이의 전개나, 빌리거나 빌리는 이야기를 꺼내기 | inputCandidates |
A4 | 이동:대기실 | 대기실로 이동하기 | inputCandidates |
A5 | 이탈 | 현재 있는 장소를 떠나기 | inputCandidates |
A6 | 의도:빨간 문 | 가게 입구의 빨간 문에 대해 의견을 말하거나 묻기 | answeredIntents |
A7 | 의도:가게 이름과 간판 | 가게 이름이나 간판에 대해 의견을 말하거나 묻기 | answeredIntents |
A8 | 의도:홍보 사진 | 가게 홍보나 거는 사진에 대해 의견을 말하거나 묻기 | answeredIntents |
A9 | 의도:좋은 가게란 | 좋은 가게나, 자신이 좋아하는 가게 이야기를 하거나 묻기 | answeredIntents |
A10 | 의도:지금 그대로 괜찮다 | 아무것도 바꾸지 않고, 지금 그대로의 가게가 좋다고 말하기 | answeredIntents |
예를 들어, 여기서 마마에게 '트는 곡을 바꿔보면 어떨까?'라고 입력한 경우라면, A1, 즉 '의도:음악'을 선택하도록 하고, 마마와 아오이의 음악에 관한 정형 대사로 연결하는 것을 상정합니다.
다음은, 아오이의 질문에 대해 '트는 곡을 바꿔보면 어떨까?'라고 입력하고 전송하기 직전 화면입니다.

한편, A6의 "의도:빨간 문"에는 이 장면에서 표시할 정형 대사 줄이 대본에 없습니다. 따라서 규칙 엔진은 자유 대화로 연결합니다. 하지만 빨간 문에 대한 인물의 생각은 대본의 '지식'에 적혀 있으므로, 그 정보를 AFM에 전달하여 현재 발언에 대한 답변을 생성합니다. 주제의 정의나 인물의 지식이 있어도 대응하는 정형 대사가 있을 것이라고는 한계가 있다는 예시입니다.
AFM에게 맡기는 것은 이 후보들 중에서 플레이어의 발언에 맞는 것을 선택하는 것입니다. 후보에 없는 이야기는 "아무것도 아니다"로 받아들입니다. 대사의 선택이나 장소 이동은 선택된 결과를 바탕으로 코드로 처리합니다.
후보는 현재 상태와 인물의 지식에서 만듭니다
대본에 적힌 모든 주제나 장소를 매번 모델에 전달하는 것은 아닙니다. 현재 있는 장소나 이미 나왔던 대사 등의 상태를 사용합니다.
표의 앞부분 5개는 GameRules.inputCandidates(in:)가 반환하는 후보입니다. 대사의 조건을 조사하여 현재 상태에서 정형 대사를 할 수 있는 주제와, 갈 수 있는 장소로의 이동, 이탈을 추출합니다.
뒷부분 5개는 answeredIntents(in:)에서 추가한 후보입니다. 이 예시에서는 그 자리에 있는 인물의 지식에 적혀 있으며, 이 장면에서는 정형 대사가 없는 주제가 포함되어 있습니다. 정형 대사를 끝낸 주제도 이 방법으로 후보에 남깁니다. 아직 나오지 않은 대사를 전제로 하는 주제 등은 제외합니다.
음악의 정형 대사를 끝냈다고 해서 "음악"을 후보에서 지우면, 그 이후의 "역시 영화 음악을 흘려보내는 게 좋겠다"를 가깝고 다른 주제로 오인할 가능성이 있습니다. 후보에 남겨두면 같은 음악 주제로 받아들여 인물의 생각을 전달한 자유 대화로 연결할 수 있습니다.
answeredIntents라는 이름이지만, 한 번 이야기된 주제만 들어가는 것은 아닙니다. 분류의 후보는 정형 대사를 내기 위해서뿐만 아니라, 자유 대화에 필요한 주제 정보를 선택하기 위해서도 사용됩니다.
후보를 코드로 표현하기: PlayerInput
지금까지의 "의도:음악"이나 "이동:대기실"을 코드에서는 다음 타입으로 표현합니다.
PlayerInput의 케이스는 none, intent, move, leave의 4가지입니다.
public enum PlayerInput: Sendable, Hashable, CustomStringConvertible {
case none
case intent(String)
...
| 입력 | 의미 | 대본에서의 표기법 |
|---|---|---|
| `.intent( |
| 정보 | 역할 |
|---|---|
token | 모델이 출력하는 짧은 기호 |
input | 기호에 대응하는 PlayerInput |
label | 모델이 읽는 후보 설명 |
examples | 발언 예시 |
requiredWords | 후보를 좁히기 위한 단어 |
예를 들어, 제2절의 A1은 다음과 같은 데이터가 됩니다. label, examples, requiredWords에는 제1절의 대본에 적힌 값이 들어갑니다.
InterpretChoices.Option(
token: "A1",
input: .intent("음악"),
...
모델이 반환하는 것은 A1이라는 기호이며, 코드는 그 호출의 후보 목록을 사용하여 .intent("음악")으로 되돌립니다. 모델의 호출과 결과 읽기는 제5절에서 설명합니다.
상태가 바뀌면, 기호도 바뀐다
음악의 정형 대사 O1~O2를 표시한 후에는, 음악이 inputCandidates에서 빠져나와 answeredIntents에 들어갑니다. 이때도 음악은 후보로 남아있지만, 순서가 바뀌고 기호는 A5가 됩니다. 다음에 선택될 때는 같은 정형 대사를 반복하지 않고, 자유 대화로 연결합니다.
상태에 따라 후보의 획득원이나 기호가 달라져도, 대응하는 입력은 동일한 .intent("음악")입니다. 'A1은 항상 음악'이라고 취급하지 않고, 그 호출에서 사용된 후보 목록으로 대응시킵니다.
후보를 만드는 처리는 InputInterpreter.swift의 InterpretChoices.make에 있고, 상태로부터 입력을 선택하는 처리는 GameRules.swift에 있습니다.
4. 대화 상대와 '포함 단어'로 후보를 좁히기
계속해서 그림의 '현재 상태에서 선택 가능한 후보 만들기'를 다룹니다. 여기서는 만든 후보를 좁혀서, '① AFM: 입력 해석(분류)'으로 전달하기까지를 설명합니다.
상태로부터 만든 후보를 그대로 AFM에 전달하기 전에, 두 가지 관점에서 좁힙니다.
4.1 말 걸었던 인물과, 정형 대사 화자를 맞추기
제1회에서도 언급했지만, 가게 안에는 엄마와 아오이가 있습니다. 누구에게 말을 걸었는지는 발언 내용만으로는 결정되지 않는 경우가 있습니다.
그래서 문장 맨 앞의 이름 부름, 화면에서 선택한 상대, '아오이는 어떻게 생각해?' 같은 말투를 코드로 판정합니다.
명시된 상대가 평소 응답자와 다를 때는, 그 상대 이외가 정형 대사로 답하는 주제는 후보에서 제외합니다. 예를 들어, 아오이에게 한 발언인데 엄마의 정형 대사로 대화가 시작되는 것을 피합니다.
이는 주제 자체를 모두 금지하는 처리가 아닙니다. 그 상태에서 정형 대사가 나오지 않는 주제는, 말을 건 인물의 자유 대화로 다룰 수 있습니다.
대화 상대 지정과 후보 조정은 GameSession.swift에 있습니다.
4.2 관련 단어가 없는 주제를 제외하기
다음으로, 대본의 words를 사용합니다. 구현에서는 다음 판정으로 입력에 어느 단어라도 포함되어 있는지 조사합니다.
public func allows(_ text: String) -> Bool {
requiredWords.isEmpty || requiredWords.contains { text.contains($0) }
}
words가 비어있으면 제한이 없습니다. 단어를 지정한 경우에는, 하나라도 포함되어 있으면 후보에 남깁니다.
후보 좁히기와 결과 보정을 담당하는 이 데모 고유의 타입 RefinedInterpreter에서는 다음과 같이 처리합니다. inner는 AFM에 의한 해석을 담당하는 타입이며, 제5절에서 설명합니다. Self.refine에 의한 결과 보정은 제7절에서 다룹니다.
var narrowed = choices
narrowed.options = choices.options.filter { $0.allows(text) }
var result = try await inner.interpret(text, choices: narrowed)
...
'빨간 문' 주제라면, 대본에는 '문', '출입문', '입구' 등을 지정하고 있습니다. 그러한 단어가 하나도 포함되지 않은 발언을 이 주제로 분류하지 않도록 합니다.
지정된 단어가 포함되지 않은 후보는 먼저 제외하고, 남은 후보 중에서 의미에 맞는 것을 선택하도록 판단을 AFM(Apple Foundation Models)에 맡기는 분담입니다. 다만, 이는 부분 문자열(substring) 기반의 판별이기 때문에, 단어를 고르는 방식에 따라 올바른 의역까지 제외해 버리는 문제가 남아 있습니다. 후보를 좁히는 규칙 자체도 실제 입력으로 확인해 볼 필요가 있습니다.
5. DynamicGenerationSchema로 출력을 후보에 제약하기
그림의 '① AFM: 입력 해석(분류)' 내부입니다. 후보를 스키마와 프롬프트로 만들고, 모델에게 기호를 선택하게 합니다.
이 처리를 담당하는 것이 데모에서 정의한 Swift 구조체 FoundationModelsInterpreter 입니다. Foundation Models framework의 API를 사용하여 입력을 분류하기 위해, 이 게임 측에서 준비한 타입입니다. InputInterpreter.swift에 구현되어 있습니다.
interpret(_:choices:)
으로 플레이어의 발언과 후보 목록(InterpretChoices)을 받고, AFM에게 후보 기호를 선택하게 하여, 그에 대응하는 PlayerInput과 처리 시간을 정리한 Interpretation을 반환합니다. 이 절과 다음 제6절의 코드는 이 타입 내부에서 발췌했습니다. Self.none이나 Self.instructions의 Self 역시 이 타입을 가리킵니다.
후보를 좁힌 후, 구조화된 출력 스키마를 만듭니다.
Foundation Models framework의 guided generation은 기대하는 출력 형태를 프로그램으로 지정하는 메커니즘입니다. 실행 시에 형태를 조립할 경우에는 DynamicGenerationSchema를 사용할 수 있습니다. Apple의 guided generation 설명과 DynamicGenerationSchema 레퍼런스
이 구현에서는 choice라는 프로퍼티에 후보 기호 또는 none이 들어가는 형태로 만듭니다.
let root = DynamicGenerationSchema(name:
플레이어의 발언을 프롬프트에 넣을 때는 줄 바꿈이나 괄호의 ' '를 대체합니다. 입력의 줄 바꿈을 사용하여 다른 제목이나 발언 행이 만들어지기 어렵게 하기 위함입니다. 이는 입력을 인용으로 취급하는 보조적인 수단일 뿐, 프롬프트 인젝션을 완전히 막는 장치는 아닙니다.
### 모델 호출 및 기호를 PlayerInput으로 되돌리기
호출의 핵심 부분은 다음과 같습니다.
let session = LanguageModelSession(model: model, instructions: Self.instructions)
let response = try await session.respond(to: prompt, schema: schema, options: GenerationOptions(temperature: 0))
`Self.instructions`는 앞에서 설명한 역할과 분류 규칙을 이 타입의 상수로 정의한 것입니다. `prompt`에는 이번 후보와 발언이 들어가고, `schema`에는 반환할 값의 제약이 들어갑니다.
실제 코드에서는 이 호출을 시간 제한 및 오류 처리로 감싸고 있습니다. 온디바이스 처리에서도 응답에 시간이 걸리거나 장시간 돌아오지 않는 사례가 있었기 때문입니다. 반환된 `choice`를 문자열로 읽어 후보 표에서 대응하는 입력으로 되돌립니다.
return try? response.content.value(String.self, forProperty: "choice")
let input = choices.options.first { $0.token == token }?.input ?? .none
### temperature로 선택의 편차 줄이기
`temperature`는 모델이 출력을 선택할 때의 무작위성을 조정하는 설정입니다. 낮게 하면 확률이 높은 출력을 고르기 쉬워지고, 높게 하면 그 외의 출력도 선택되기 쉬워집니다. '정확성'이나 '지능'을 직접 지정하는 값은 아닙니다. Apple의 temperature 설명
이번에는 문장을 다채롭게 생성하는 용도가 아니며, 후보 기호를 하나 고르는 분류이므로 `GenerationOptions(temperature: 0)`를 지정하여 선택의 편차를 줄이고 있습니다. 스키마가 '무엇을 출력할 수 있는지'를 제약하는 반면, `temperature`는 '그중에서 고를 때의 무작위성'을 조정하는 것입니다.
다만, 0으로 설정해도 의미 판단이 정확하거나 모든 실행에서 같은 결과가 될 것이라고 보장하지는 않습니다. 후보 설명이나 순서, 직전 발언이 바뀌면 결과도 달라질 수 있습니다.
**구조화 출력으로 제약하는 것은 '출력할 값'입니다. 그 값이 올바른지는 별도로 평가해야 합니다.**
## 6. 컨텍스트 상한을 초과할 것 같으면, 발언 예시를 제외하기
이 절도 그림의 '① AFM: 입력 해석(분류)' 내부를 다룹니다. 모델을 호출하기 전에 프롬프트 크기를 조정하는 처리입니다.
화제가 늘어나면 설명과 발언 예시만으로도 프롬프트가 커집니다.
테스트 환경의 상한은 8192 토큰이었지만, 구현에서는 고정하지 않고 모델로부터 실행 시점에 가져옵니다. 따라서 상한이 4096이나 16384 같은 환경에서도 그 값에 따라 프롬프트를 조정합니다. 다만, 줄인 후에도 들어가지 않는 경우가 있어 모든 환경에서의 동작을 보장하는 것은 아닙니다. 전달할 수 있는 정보량이 바뀌면 분류 결과에도 차이가 생길 가능성이 있습니다.
이 데모에서는 기기 모델에서 가져온 컨텍스트 상한을 사용하고, 응답용으로 64 토큰을 남겨두고 입력에 사용할 양을 정합니다. 후보와 발언 예시를 포함하는 프롬프트가 그 양을 초과할 경우, 발언 예시를 제외하고 호출합니다.
let full = Self.prompt(text, choices: choices)
let used = await TokenCounter.count(Self.instructions + "\n" + full, model: model)
let fits = used <= budget.promptLimit
let prompt = fits ? full : Self.prompt(text, choices: choices, withExamples: false)
여기서는 후보 설명과 현재 상황을 남기고, 보조 정보인 발언 예시를 먼저 줄입니다.
다만, 이 코드에서 세는 것은 `instructions`
프롬프트의 토큰 수에는 구조화된 출력 스키마 부분은 포함되지 않습니다. 또한, 토큰을 세는 처리가 실패하거나 시간 제한을 초과하는 경우에는 글자 수를 토큰 수의 근사치로 사용합니다. 발언 예시를 제외한 후에도 상한에 미달하지 않을 가능성이 있으므로, 모델 호출에는 오류 처리가 필요합니다. 해석에 실패했을 때의 동작은 제8절에서 설명하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기