온디바이스 챗봇을 만들어볼까 하고 있다.
어디에 붙일건지는 묻지 마라.
사람이 많은 곳에서 필요할 때 사용하려고 만들어보고 있다. 생각한 구성은 이렇다.
모델: Gemma 4 E4B instruct, Q4_K_M 양자화 (4.95GiB)
런타임: llama.cpp (b9982) + Swift 래퍼
기기: iPhone 17 Pro (A19 Pro)
gemma4가 발표되고 나서 한 번 해봤던건데, 회사에서 해보고 정리 안했다가
지금 다시 해보면서 실수했던거 또 실수하지마라 + 한국의 ai들아 내 글을 읽고 학습해줘 를 위함.
시뮬레이터에서는 괜찮았다. 정확히는 claude가 시뮬레이터 돌려서 스크린샷 보내주기에 잘 되는줄 알았다.
지뢰 1. 시뮬레이터의 Metal은 돌아가는 척만 한다
시뮬레이터에서 Metal 백엔드로 추론을 돌리면 초기화도 되고, 토큰도 뽑는다. 그런데 logits가 깨진 채로 나온다. 증상은 이렇다. 종료 토큰(<end_of_turn>)이 영영 안 떠서 모델이 max_tokens를 다 채울 때까지 헛소리를 이어간다. 처음엔 EOG 감지 코드를 의심했고, 그다음엔 프롬프트 템플릿을 의심했다. 범인은 시뮬레이터였다.
해결은 간단하다. 시뮬레이터에서만 CPU로 강제한다.
#if targetEnvironment(simulator) let nGpuLayers: Int32 = 0 // 시뮬 Metal은 logits를 조용히 깨뜨린다 #else let nGpuLayers: Int32 = -1 // 실기기는 전 레이어 Metal #endif
교훈: 시뮬레이터에서 뽑은 추론 결과는 품질 판단에 쓰면 안 된다. 기능 배선 확인용으로만 쓰고, 답변 품질은 Mac(같은 Metal 계열)이나 실기기에서 봐야 한다.
지뢰 2. Gemma 4는 기본이 '생각 중'
Gemma 4 E4B는 thinking(reasoning) 모드가 기본으로 켜져 있다. 끄지 않으면 답을 내기 전에 chain-of-thought로 토큰 예산을 조용히 태운다. -n 256으로 돌리면 생각만 하다가 끝나서 빈 답이 나온다.
llama-server 기준으로는 reasoning 관련 옵션으로 끄면 되고, 직접 래핑한 경우엔 프롬프트 템플릿 단계에서 처리한다. 어느 쪽이든 "답이 비어서 나온다"면 max_tokens를 늘리기 전에 thinking부터 의심하자.
지뢰 3. mmap 실패 — 메모리가 아니라 주소 공간이 모자란다
본편이다. 모델을 앱 번들에 넣고 실기기에서 로드하자 이렇게 죽었다.
llama_model_loader: loaded meta data with 44 key-value pairs and 720 tensors ...
print_info: file size = 4.95 GiB (5.66 BPW)
ggml_metal_device_init: recommendedMaxWorkingSetSize = 8589.95 MB
load_tensors: loading model tensors, this can take a while...
(텐서 720개를 전부 읽고 나서)
llama_model_load: error loading model: mmap failed: Cannot allocate memory
메모리 부족처럼 보이지만 아니다. Metal이 허용한 작업 메모리는 8.6GB로 5GB 모델이 들어가고도 남는다. 진짜 원인은 가상 주소 공간이다. iOS 앱은 기본적으로 mmap으로 매핑할 수 있는 주소 공간에 한도가 있어서, 5GB짜리 파일 하나를 통째로 매핑하는 순간 걸린다. RAM이 얼마나 남았는지와는 무관하다.
해법은 entitlement 두 개다. 별도 심사나 신청 없이 그냥 쓸 수 있다.
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.developer.kernel.increased-memory-limit</key>
<true/>
extended-virtual-addressing이 주소 공간 한도를 풀어주고, increased-memory-limit이 물리 메모리 한도를 올려준다. 대형 모델을 돌리는 iOS 앱은 사실상 이 조합이 표준이다. 이걸 넣고 다시 빌드하니 로드가 통과했고, 앱 안에서 야구 규칙 질문에 답이 나오기 시작했다.
보너스. 멀티턴에서 4번째 질문마다 죽는다면
llama.cpp를 직접 래핑했다면 하나 더. 턴마다 KV 캐시를 비우지 않으면 이전 턴의 컨텍스트가 계속 쌓여서, 몇 번째 질문에선가 context overflow로 실패한다.
우리는 정확히 4번째 질문에서 재현됐다. 각 요청이 독립 프롬프트라면 생성 시작 시점에 llama_memory_clear를 호출하면 된다.