포스트

게임에 원하는 오픈소스 LLM 자유롭게 넣는 방법 (C#, Unity)

게임에 원하는 오픈소스 LLM 자유롭게 넣는 방법 (C#, Unity)

어제 글에서 8.7M LLM은 분위기용이고, 다음 빌드는 1B 이상으로 옮겨갈 예정이라고 적어뒀다. 그 작업의 결과 정리. TinyLlama 1.1B Chat이 Unity 데스크탑 게임에서 영어로 답한다. 빌드는 900MB 늘어났다. 통합하기까지 NuGet 의존성, 버전 매칭, native search path 추적이 다 한 번씩 발목을 잡았다. 다음 사람은 이 글대로 따라가면 한 시간 안에 끝낼 수 있게 정리해뒀다.

ORT GenAI가 뭔가

Microsoft가 만든 ONNX Runtime용 LLM 추론 라이브러리다. MIT 라이선스. raw ONNX Runtime은 그냥 텐서 추론기라 LLM을 돌리려면 토크나이저, KV cache, sampling을 직접 짜야 한다. ORT GenAI는 이 셋을 묶어서 Llama, Phi, TinyLlama 같은 생성형 모델을 CPU나 GPU에서 바로 돌릴 수 있게 해준다. 즉 1B 이상 production 모델을 게임에 넣을 때 가장 표준적인 방법이고, 이 글 본편이 다루는 게 그 방법이다.

이 글에서 다루는 것

본편은 1B급 모델(TinyLlama 1.1B Chat 기준)을 Unity에 ORT GenAI로 통합하는 4단계다. NuGet 패키지 받기, Plugins 폴더 구조, HuggingFace 모델을 ORT GenAI 형식으로 변환, C# wrapper 작성 순서. 막혔을 때 빠른 체크리스트와 빌드 사이즈 영향, 모델 옵션 비교도 같이 둔다. 부록 A는 8M에서 수십M 정도의 작은 모델을 raw ONNX Runtime만으로 돌리는 방법과 그때 필요한 ByteLevel BPE 토크나이저 자체 구현 코드를 통째로 붙여뒀다. 작은 모델용 코드는 어제 글의 본체이기도 하니, 그쪽이 필요한 사람은 부록만 봐도 된다.

누가 쓰면 좋은가

Unity 게임이나 앱에 production-ready LLM(1B 이상)을 통합하고 싶은 사람. Unity Sentis가 모델을 거부하는 경우(INT8 양자화나 GroupQueryAttention 같은 이유). llama.cpp 기반(LLM for Unity)이 아니라 ONNX 표준 방법으로 가고 싶은 사람. Win과 Mac arm64 cross-platform이 필요한 사람.

미리 알면 시간 아끼는 네 가지

이 네 개만 확실히 짚으면 절반은 끝난다.

첫째, ORT GenAI 패키지는 raw ORT와 별도이고, 거기에 NuGet 패키지 세 개를 동반으로 받아야 한다. Microsoft.Extensions.AI.Abstractions, Microsoft.ML.OnnxRuntimeGenAI, Microsoft.ML.OnnxRuntimeGenAI.Managed다.

둘째, API 버전 매칭이 가장 큰 함정이다. GenAI 0.13.x은 ORT API v23이라 ORT 1.23.0 이상이 필수다. 1.21이나 1.22로는 동작하지 않는다.

셋째, native DLL은 같은 폴더에 둬야 Windows LoadLibrary가 의존성을 찾는다. onnxruntime.dllonnxruntime-genai.dll이 같이 있어야 한다.

넷째, 디버깅의 핵심은 Python ctypes로 dll을 직접 로드해보는 것이다. Unity가 던지는 에러 메시지(<unknown assembly>)는 거의 무용지물이다.

Step 1: NuGet에서 받을 패키지 (정확한 버전)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 1. Native + Managed 둘 다 받음 (ORT GenAI는 .nupkg가 분리됨)
curl -L "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntimeGenAI" -o ort_genai.nupkg
curl -L "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntimeGenAI.Managed" -o ort_genai_managed.nupkg

# 2. ORT 1.23.0 이상 (GenAI 0.13.x 필수 매칭)
curl -L "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime/1.23.0" -o ort.nupkg
curl -L "https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.Managed/1.23.0" -o ort_managed.nupkg

# 3. GenAI Managed의 transitive 의존성 7개
for pkg in \
  "Microsoft.Extensions.AI.Abstractions/9.8.0" \
  "System.Text.Json/8.0.6" \
  "Microsoft.Bcl.AsyncInterfaces/8.0.0" \
  "System.Text.Encodings.Web/8.0.0" \
  "System.Buffers/4.5.1" \
  "System.Memory/4.5.5" \
  "System.Runtime.CompilerServices.Unsafe/6.0.0" \
  "System.Threading.Tasks.Extensions/4.5.4"; do
  name=$(echo $pkg | cut -d/ -f1)
  curl -L "https://www.nuget.org/api/v2/package/$pkg" -o "${name}.nupkg"
done

각 nupkg는 zip이라 unzip하고 lib/netstandard2.0/*.dll을 꺼내면 된다.

Step 2: Unity Plugins 폴더 구조 (핵심)

이 구조로 두면 의존성 검색이 실패한다.

1
2
Assets/Plugins/onnxruntime/x86_64/onnxruntime.dll
Assets/Plugins/onnxruntime-genai/x86_64/onnxruntime-genai.dll  ← 다른 폴더

이렇게 둬야 통과한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Assets/Plugins/onnxruntime/
  Microsoft.ML.OnnxRuntime.dll                  (managed)
Assets/Plugins/onnxruntime-genai/
  Microsoft.ML.OnnxRuntimeGenAI.dll              (managed)
  Microsoft.Extensions.AI.Abstractions.dll       (transitive)
  System.Text.Json.dll                            (transitive)
  Microsoft.Bcl.AsyncInterfaces.dll
  System.Text.Encodings.Web.dll
  System.Buffers.dll
  System.Memory.dll
  System.Runtime.CompilerServices.Unsafe.dll
  System.Threading.Tasks.Extensions.dll
  x86_64/
    onnxruntime.dll                              ← 같은 폴더에
    onnxruntime-genai.dll
  macOS/
    libonnxruntime.dylib
    libonnxruntime-genai.dylib

핵심은 onnxruntime.dllPlugins/onnxruntime/이 아니라 Plugins/onnxruntime-genai/x86_64/에 두는 것이다. Windows LoadLibrary는 호출 dll의 디렉토리를 search path에 우선 추가하기 때문이다.

Step 3: 모델 변환 (TinyLlama 1.1B Chat 예시)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# pip install onnxruntime-genai transformers torch huggingface_hub

# 1. base model 다운로드
from huggingface_hub import snapshot_download
snapshot_download(
    repo_id="TinyLlama/TinyLlama-1.1B-Chat-v1.0",
    local_dir="./tinyllama_base",
    allow_patterns=["*.json", "*.model", "*.safetensors", "*.txt"],
)

# 2. ORT GenAI 형식으로 변환 (INT4)
# python -m onnxruntime_genai.models.builder \
#   -m ./tinyllama_base \
#   -o ./tinyllama_genai_int4 \
#   -p int4 -e cpu \
#   --extra_options int4_block_size=32

결과 디렉토리.

1
2
3
4
5
6
7
tinyllama_genai_int4/
  genai_config.json
  model.onnx           (165KB header)
  model.onnx.data      (909MB INT4 weights)
  tokenizer.json
  tokenizer_config.json
  chat_template.jinja

이 폴더 통째로 Assets/StreamingAssets/tinyllama/에 복사한다.

Step 4: C# Wrapper

ORT GenAI 0.13.x API 기준.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
using Microsoft.ML.OnnxRuntimeGenAI;
using UnityEngine;
using System.IO;

public class TinyLlamaInference : MonoBehaviour
{
    public static TinyLlamaInference Instance { get; private set; }
    public bool IsLoaded { get; private set; }
    public string LoadError { get; private set; }

    Model _model;
    Tokenizer _tokenizer;

    void Awake()
    {
        if (Instance != null) { Destroy(this); return; }
        Instance = this;
        TryLoad();
    }

    void OnDestroy()
    {
        _tokenizer?.Dispose();
        _model?.Dispose();
    }

    void TryLoad()
    {
        try
        {
            string dir = Path.Combine(Application.streamingAssetsPath, "tinyllama");
            if (!File.Exists(Path.Combine(dir, "genai_config.json")))
            {
                LoadError = $"genai_config.json missing at {dir}";
                return;
            }
            _model = new Model(dir);
            _tokenizer = new Tokenizer(_model);
            IsLoaded = true;
            Debug.Log("[TinyLlama] loaded");
        }
        catch (System.Exception e)
        {
            LoadError = e.Message;
            Debug.LogWarning($"[TinyLlama] load failed: {e.Message}");
        }
    }

    const string SystemPrompt =
        "You are an assistant. Reply briefly in english (1 sentence, under 20 words).";

    public string Chat(string userText, int maxNewTokens = 80,
                       float temperature = 0.7f, int topK = 40, float topP = 0.9f)
    {
        if (!IsLoaded) return "";
        try
        {
            // Zephyr or TinyLlama Chat template
            string prompt =
                $"<|system|>\n{SystemPrompt}</s>\n" +
                $"<|user|>\n{userText}</s>\n" +
                $"<|assistant|>\n";

            using var inputIds = _tokenizer.Encode(prompt);
            int promptLen = inputIds[0].Length;

            using var p = new GeneratorParams(_model);
            p.SetSearchOption("max_length", promptLen + maxNewTokens);
            p.SetSearchOption("temperature", temperature);
            p.SetSearchOption("top_k", topK);
            p.SetSearchOption("top_p", topP);
            p.SetSearchOption("do_sample", true);

            using var gen = new Generator(_model, p);
            gen.AppendTokenSequences(inputIds);
            while (!gen.IsDone())
                gen.GenerateNextToken();   // 0.13.x는 ComputeLogits 통합

            var seq = gen.GetSequence(0);
            int newLen = seq.Length - promptLen;
            if (newLen <= 0) return "";
            var newTokens = new int[newLen];
            for (int i = 0; i < newLen; i++) newTokens[i] = (int)seq[promptLen + i];

            return _tokenizer.Decode(newTokens).Trim();
        }
        catch (System.Exception e)
        {
            Debug.LogWarning($"[TinyLlama] Chat: {e.Message}");
            return "";
        }
    }
}

API 주의: 0.5.x는 SetInputSequencesComputeLogits였고, 0.13.x는 AppendTokenSequencesGenerateNextToken만 쓴다. 위 코드는 0.13.x 기준이다.

막혔을 때 빠른 체크리스트

버전 한 세트는 ORT GenAI 0.13.x, ORT 1.23.0 이상, Microsoft.Extensions.AI.Abstractions 9.8.0이다. 이 셋이 같이 가야 한다.

네이티브 dll 위치는 onnxruntime.dllonnxruntime-genai.dll이 반드시 같은 폴더(Plugins/onnxruntime-genai/x86_64/)에 있어야 한다.

managed dll은 Microsoft.ML.OnnxRuntime.dll만 별도(Plugins/onnxruntime/)에 두고, 나머지 9개는 Plugins/onnxruntime-genai/에 둔다.

dll을 교체했을 때는 Unity Editor를 재시작해야 DLL lock이 풀린다.

그래도 막히면 python -c "import ctypes; ctypes.CDLL('path/to/onnxruntime-genai.dll')"로 직접 로드해본다. 정확한 에러가 콘솔에 찍힌다. Unity는 <unknown assembly>만 줘서 무용하다.

빌드 사이즈 영향 (TinyLlama 1.1B INT4 기준)

항목사이즈
StreamingAssets/tinyllama/ (모델 + 토크나이저)909MB
Plugins/onnxruntime-genai/ (managed + native + 의존성)25MB
Plugins/onnxruntime/ (managed only)0.2MB
추가 빌드 사이즈약 935MB

zip 압축 후 700MB 정도다. 인디 게임에 넣을지는 신중하게 결정해야 한다.

모델 옵션 정리

모델사이즈 (INT4 ONNX)속도Quality
GuppyLM 8.7M10MB매우 빠름toy 수준 (분위기만)
TinyLlama 1.1B Chat909MB빠름 (CPU 1~2 tok/s)의미 있는 응답
Phi-3-mini 3.8B약 2GB보통매우 좋음
Llama-3.2-1B Instruct약 600MB빠름좋음

게임 desktop 배포 기준으로는 1.1B 정도가 sweet spot이다.

어디에 가장 적합한가

텍스트 기반 AI 게임(NPC chat이나 text adventure)이라면 1.1B면 충분하다. 그래픽 게임의 분위기 layer 정도라면 8.7M로도 충분하고, 1B는 사이즈 부담이 크다. Visual Novel이나 Interactive Fiction처럼 텍스트 비중이 큰 장르엔 1B 이상이 값을 한다. 모바일은 INT4여도 부담이 커서 신중해야 한다.

Unity와 ORT GenAI는 표준 방법이긴 한데 NuGet 의존성, 버전 매칭, DLL search path 세 함정이 있다. 위 단계대로 따라가면 그 함정을 다 우회한다.

부록 A: 작은 모델은 raw ORT로 충분하다

8M에서 수십M 정도의 작은 모델은 ORT GenAI를 안 쓰고 raw ORT만으로도 충분하다. KV cache 안 써도 매 forward가 ms 단위이고, 토크나이저는 GPT-2 ByteLevel BPE를 200줄로 직접 구현 가능하다. Microsoft.ML.Tokenizers 의존성 hell도 우회할 수 있다.

A.1 GuppyOrtInference.cs (raw ORT wrapper, top-k sampling 포함)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using UnityEngine;

public class GuppyOrtInference : MonoBehaviour
{
    public static GuppyOrtInference Instance { get; private set; }
    public bool IsLoaded { get; private set; }
    public string LoadError { get; private set; }

    InferenceSession _session;
    string _inputName;
    string _outputName;
    GuppyTokenizer _tokenizer;
    public bool TokenizerLoaded => _tokenizer != null && _tokenizer.IsLoaded;

    void Awake()
    {
        if (Instance != null) { Destroy(this); return; }
        Instance = this;
        TryLoad();
    }

    void OnDestroy()
    {
        _session?.Dispose();
        _session = null;
    }

    void TryLoad()
    {
        try
        {
            string modelPath = Path.Combine(Application.streamingAssetsPath, "guppy", "model.onnx");
            if (!File.Exists(modelPath))
            {
                LoadError = $"model.onnx not found at {modelPath}";
                return;
            }

            var options = new SessionOptions();
            options.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
            options.IntraOpNumThreads = 1;
            _session = new InferenceSession(modelPath, options);

            foreach (var kv in _session.InputMetadata) { _inputName = kv.Key; break; }
            foreach (var kv in _session.OutputMetadata) { _outputName = kv.Key; break; }

            string tokPath = Path.Combine(Application.streamingAssetsPath, "guppy", "tokenizer.json");
            if (File.Exists(tokPath))
            {
                _tokenizer = new GuppyTokenizer();
                _tokenizer.Load(tokPath);
            }

            IsLoaded = true;
        }
        catch (Exception e)
        {
            LoadError = e.Message;
            Debug.LogWarning($"[GuppyOrt] load failed: {e.Message}");
        }
    }

    public string GenerateText(string prompt, int maxNewTokens = 12, int eosTokenId = 2,
                               int topK = 20, float temperature = 0.85f)
    {
        if (!IsLoaded || !TokenizerLoaded || string.IsNullOrEmpty(prompt)) return "";
        var inputIds = _tokenizer.Encode(prompt);
        if (inputIds.Count == 0) return "";
        var output = Generate(inputIds, maxNewTokens, eosTokenId, topK, temperature);
        return output.Count == 0 ? "" : _tokenizer.Decode(output).Trim();
    }

    public List<int> Generate(List<int> inputIds, int maxNewTokens = 12, int eosTokenId = 2,
                              int topK = 0, float temperature = 1f)
    {
        if (!IsLoaded || inputIds == null || inputIds.Count == 0) return new List<int>(0);

        var working = new List<long>(inputIds.Count + maxNewTokens);
        foreach (var i in inputIds) working.Add(i);

        var generated = new List<int>(maxNewTokens);
        for (int step = 0; step < maxNewTokens; step++)
        {
            int next = topK > 0 ? PredictNextSampled(working, topK, temperature)
                                : PredictNextGreedy(working);
            if (next < 0 || next == eosTokenId) break;
            generated.Add(next);
            working.Add(next);
        }
        return generated;
    }

    float[] LastLogits(List<long> ids, out int vocab)
    {
        vocab = 0;
        var tensor = new DenseTensor<long>(ids.ToArray(), new[] { 1, ids.Count });
        var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, tensor) };
        using var results = _session.Run(inputs);
        var logits = results[0].AsTensor<float>();
        var dims = logits.Dimensions;
        if (dims.Length != 3) return null;
        int seq = dims[1]; vocab = dims[2];
        if (seq < 1 || vocab < 1) return null;
        var arr = new float[vocab];
        for (int v = 0; v < vocab; v++) arr[v] = logits[0, seq - 1, v];
        return arr;
    }

    int PredictNextGreedy(List<long> ids)
    {
        var logits = LastLogits(ids, out int vocab);
        if (logits == null) return -1;
        int best = 0; float bestVal = float.NegativeInfinity;
        for (int v = 0; v < vocab; v++)
            if (logits[v] > bestVal) { bestVal = logits[v]; best = v; }
        return best;
    }

    int PredictNextSampled(List<long> ids, int topK, float temperature)
    {
        var logits = LastLogits(ids, out int vocab);
        if (logits == null) return -1;
        int k = Math.Min(Math.Max(1, topK), vocab);

        var idx = new int[vocab];
        for (int v = 0; v < vocab; v++) idx[v] = v;
        Array.Sort(idx, (a, b) => logits[b].CompareTo(logits[a]));

        float invT = 1f / Math.Max(0.01f, temperature);
        float maxL = logits[idx[0]] * invT;
        float sum = 0f;
        var probs = new float[k];
        for (int i = 0; i < k; i++)
        {
            probs[i] = (float)Math.Exp(logits[idx[i]] * invT - maxL);
            sum += probs[i];
        }
        for (int i = 0; i < k; i++) probs[i] /= sum;

        float r = (float)UnityEngine.Random.value;
        float cum = 0f;
        for (int i = 0; i < k; i++)
        {
            cum += probs[i];
            if (r < cum) return idx[i];
        }
        return idx[k - 1];
    }
}

A.2 GuppyTokenizer.cs (HuggingFace ByteLevel BPE 직접 구현)

GPT-2와 Llama 계열 ByteLevel BPE는 동일 알고리즘이다. Microsoft.ML.Tokenizers 의존성(Microsoft.Bcl.Memory, System.Text.Json, Microsoft.Bcl.HashCode) hell을 피하려면 200줄 직접 구현이 더 깔끔하다. Newtonsoft.Json만 의존하면 된다(Unity 기본 포함).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
using UnityEngine;

public class GuppyTokenizer
{
    Dictionary<string, int> _vocab = new Dictionary<string, int>();
    Dictionary<int, string> _reverseVocab = new Dictionary<int, string>();
    Dictionary<string, int> _mergesRank = new Dictionary<string, int>();
    Dictionary<byte, char> _byteToUnicode;
    Dictionary<char, byte> _unicodeToByte;
    Regex _preTokenize;
    Dictionary<string, List<string>> _bpeCache = new Dictionary<string, List<string>>();

    public bool IsLoaded { get; private set; }

    public bool Load(string tokenizerJsonPath)
    {
        try
        {
            var root = JObject.Parse(File.ReadAllText(tokenizerJsonPath));
            var model = root["model"] as JObject;
            if (model == null) return false;

            var vocabObj = model["vocab"] as JObject;
            foreach (var kv in vocabObj) _vocab[kv.Key] = kv.Value.Value<int>();
            foreach (var kv in _vocab) _reverseVocab[kv.Value] = kv.Key;

            var mergesArr = model["merges"] as JArray;
            int rank = 0;
            foreach (var m in mergesArr)
            {
                string mergeKey = m is JArray pair && pair.Count == 2
                    ? $"{pair[0]} {pair[1]}" : m.Value<string>();
                _mergesRank[mergeKey] = rank++;
            }

            var added = root["added_tokens"] as JArray;
            if (added != null)
                foreach (var t in added)
                {
                    int id = t["id"].Value<int>();
                    string content = t["content"].Value<string>();
                    _vocab[content] = id;
                    _reverseVocab[id] = content;
                }

            BuildByteUnicodeMaps();
            _preTokenize = new Regex(
                @"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+",
                RegexOptions.Compiled);

            IsLoaded = true;
            return true;
        }
        catch (Exception e) { Debug.LogWarning($"[GuppyTok] {e.Message}"); return false; }
    }

    // GPT-2 ByteLevel: bytes 0-255를 안전한 unicode characters로 매핑
    void BuildByteUnicodeMaps()
    {
        var bs = new List<int>();
        for (int b = '!'; b <= '~'; b++) bs.Add(b);
        for (int b = 0xA1; b <= 0xAC; b++) bs.Add(b);
        for (int b = 0xAE; b <= 0xFF; b++) bs.Add(b);
        var cs = new List<int>(bs);
        int n = 0;
        for (int b = 0; b < 256; b++)
            if (!bs.Contains(b)) { bs.Add(b); cs.Add(256 + n); n++; }

        _byteToUnicode = new Dictionary<byte, char>(256);
        _unicodeToByte = new Dictionary<char, byte>(256);
        for (int i = 0; i < bs.Count; i++)
        {
            _byteToUnicode[(byte)bs[i]] = (char)cs[i];
            _unicodeToByte[(char)cs[i]] = (byte)bs[i];
        }
    }

    public List<int> Encode(string text)
    {
        var ids = new List<int>();
        if (!IsLoaded || string.IsNullOrEmpty(text)) return ids;

        foreach (Match m in _preTokenize.Matches(text))
        {
            var bytes = Encoding.UTF8.GetBytes(m.Value);
            var sb = new StringBuilder(bytes.Length);
            foreach (var b in bytes) sb.Append(_byteToUnicode[b]);
            foreach (var tok in ApplyBpe(sb.ToString()))
                if (_vocab.TryGetValue(tok, out int id)) ids.Add(id);
        }
        return ids;
    }

    List<string> ApplyBpe(string word)
    {
        if (_bpeCache.TryGetValue(word, out var cached)) return cached;

        var pieces = new List<string>(word.Length);
        foreach (var c in word) pieces.Add(c.ToString());
        if (pieces.Count <= 1) { _bpeCache[word] = pieces; return pieces; }

        while (true)
        {
            string bestL = null, bestR = null;
            int bestRank = int.MaxValue;
            for (int i = 0; i < pieces.Count - 1; i++)
            {
                string key = $"{pieces[i]} {pieces[i + 1]}";
                if (_mergesRank.TryGetValue(key, out int rank) && rank < bestRank)
                { bestRank = rank; bestL = pieces[i]; bestR = pieces[i + 1]; }
            }
            if (bestL == null) break;

            var merged = new List<string>(pieces.Count);
            int j = 0;
            while (j < pieces.Count)
            {
                if (j < pieces.Count - 1 && pieces[j] == bestL && pieces[j + 1] == bestR)
                { merged.Add(bestL + bestR); j += 2; }
                else { merged.Add(pieces[j]); j++; }
            }
            pieces = merged;
            if (pieces.Count == 1) break;
        }

        _bpeCache[word] = pieces;
        return pieces;
    }

    public string Decode(List<int> ids)
    {
        if (!IsLoaded || ids == null || ids.Count == 0) return "";
        var sb = new StringBuilder();
        foreach (var id in ids)
            if (_reverseVocab.TryGetValue(id, out var tok)) sb.Append(tok);

        var bytes = new List<byte>(sb.Length);
        foreach (var c in sb.ToString())
            if (_unicodeToByte.TryGetValue(c, out var b)) bytes.Add(b);
        return Encoding.UTF8.GetString(bytes.ToArray());
    }
}

위 두 파일은 그대로 복사해서 Assets/Scripts/에 넣으면 작동한다(Unity 6, .NET Standard 2.1, Newtonsoft.Json 패키지 활성화 가정).

환경: Unity 6000.3.11f1, ORT 1.23.0, ORT GenAI 0.13.1, Microsoft.Extensions.AI.Abstractions 9.8.0.

대표 이미지는 TinyLlama 공식 로고 (Apache 2.0).

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.