Less Is More: Android 앱에 On-Device Small Language Model 통합할 때 실제로 겪는 엔지니어링 문제들
Less Is More: Engineering Challenges of On-Device Small Language Model Integration in a Mobile Application
TL;DR Highlight
Wordle 게임에 온디바이스 SLM(Gemma 4 E2B, Qwen3 0.6B)을 5일간 붙여보면서 발견한 5가지 실패 유형과 8가지 실용 해결책 정리
Who Should Read
모바일 앱에 LLM 기능을 오프라인/프라이버시 보장 방식으로 탑재하려는 Android 개발자. 특히 클라우드 API 없이 온디바이스 추론을 프로덕션에 적용하려는 상황.
Core Mechanics
- SLM은 클라우드 모델의 축소판이 아님. JSON을 출력하라고 해도 마크다운 코드펜스로 감싸거나, JSON 키를 출력 언어로 번역하거나, 잘못된 UTF-8을 뱉는 등 전혀 다른 실패 패턴을 보임.
- 단어 길이 같은 수치 제약 준수율이 심각하게 낮음. '5글자 단어를 줘'라고 해도 Qwen3 0.6B 기준 초기엔 30~50% 위반. CRITICAL 마커 + 구체적 예시 추가 후에도 10~15% 위반이 남아서 결국 단어 선택 자체를 큐레이션 리스트로 이관함.
- 같은 채팅 세션에서 3~5번 생성 후 출력 품질이 급격히 저하됨. KV 캐시(모델이 이전 대화를 기억하는 메모리) 포화가 원인이며, Qwen3 0.6B는 3번, Gemma 4 E2B는 5~7번 후 반복적·무의미한 응답 시작.
- LLM이 담당하는 출력 필드 수를 줄일수록 성공률이 기하급수적으로 오름. 필드마다 독립적으로 85% 성공률이면, 7개 필드 스키마는 32% 완전 성공, 2개 필드는 72%로 계산됨.
- WorkManager를 LLM 퍼즐 생성에 쓰면 안 됨. 하루에 버그 7개 발생. WorkManager는 프로세스 재시작 후에도 SUCCEEDED 상태를 캐시해서 사용자가 생성 화면을 보고 있는 포그라운드 작업에는 근본적으로 맞지 않음. Kotlin 코루틴으로 교체하자 버그 7개가 한 번에 사라짐.
- 9개 모델 평가 후 최종적으로 2개만 출시. Gemma 3 1B는 8시간 만에 탈락하고 절반 크기인 Qwen3 0.6B로 교체. 모델마다 프롬프트 튜닝 프로파일이 달라서 지원 모델 수 = 별개의 AI 통합 수와 같음.
- 최종 아키텍처는 단어 선택은 큐레이션 JSON 파일, LLM은 힌트 3개만 생성, LLM 실패 시 결정론적 fallback 제공. LLM이 한 번도 성공하지 않아도 게임은 완전히 플레이 가능.
- 프롬프트 엔지니어링 팁: ISO 코드('pt') 대신 전체 언어명('Brazilian Portuguese') 사용, 추상적 규칙보다 구체적 거부 예시 제시, 시스템 프롬프트·유저 프롬프트 양쪽에 언어 지정을 반복하면 언어 드리프트가 줄어듦.
Evidence
- 단어 길이 위반율: 프롬프트 최적화 전 30~50%, CRITICAL 마커+구체적 예시 추가 후 10~15%로 감소. 단어 생성을 큐레이션 리스트로 이관 후 0%.
- JSON 코드펜스 래핑: 초기 대부분의 호출에서 발생. '코드펜스 금지' 프롬프트 추가 후 20~30%로 감소. 구조적 파싱(Strategy 5) 적용 시 Qwen3 0.6B에서 약 15~20%의 성공 파싱에 기여.
- Pixel 7 Pro 기준 Gemma 4 E2B: CPU 백엔드에서 디코딩 약 30 tok/s, 초기화 약 10초. Qwen3 0.6B: 약 35 tok/s, 초기화 약 5초. 50토큰짜리 힌트 생성에 1~2초 소요.
- WorkManager 제거 커밋(6ce6435) 하나로 코드 767줄 삭제, 576줄 추가. 순 191줄 감소와 함께 하루 동안 발생한 버그 7개가 모두 해결됨.
How to Apply
- LLM에게 단어 선택 + 힌트 생성을 동시에 맡기고 있다면, 단어는 검증된 JSON 에셋 파일로 분리하고 LLM은 힌트만 쓰게 바꾸면 된다. 이 하나의 변경으로 단어 길이 위반, 반복 단어, 존재하지 않는 단어, 잘못된 언어 단어 문제가 동시에 해결됨.
- SLM의 JSON 파싱을 단순 역직렬화로 처리하고 있다면, UTF-8 정제 → 코드펜스 제거 → 직접 파싱 → 정규식 추출 → 키 이름 무시하고 값 타입으로 필드 추론하는 5단계 파이프라인으로 교체하면 된다. 특히 다국어 앱에서 모델이 JSON 키를 번역해버리는 케이스를 마지막 단계가 잡아줌.
- 같은 세션에서 여러 번 LLM을 호출하는 배치 생성 로직이 있다면, 3~5번마다 세션을 새로 만드는 로테이션을 추가하면 된다. 재시도 시에는 빈 프롬프트 반복 대신 '이전 응답이 거부된 이유: 단어가 7글자인데 5글자를 요청함'처럼 구체적 실패 이유를 포함하면 재시도 성공률이 올라감.
Code Example
// 1. 5단계 방어적 파싱 파이프라인
private fun parseHints(rawText: String): List<String>? {
val sanitized = rawText.replace("\uFFFD", "") // UTF-8 정제
val stripped = sanitized
.replace(Regex("```json\\s*"), "")
.replace(Regex("```\\s*"), "") // 코드펜스 제거
// 직접 JSON 파싱 시도
tryDirectParse(stripped)?.let { return it }
// 정규식으로 JSON 추출
val jsonMatch = Regex("\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}")
.find(stripped)?.value
jsonMatch?.let { tryDirectParse(it) }?.let { return it }
// 키 이름 무시하고 값 타입으로 추론
return tryStructuralParse(stripped)
}
// 다국어 힌트 키 지원
val hintsKeys = setOf("hints", "dicas", "pistas", "clues", "indices", "consejos")
// 2. 세션 로테이션으로 KV 캐시 포화 방지
const val SESSION_ROTATION = 5
var puzzleIndex = 0
while (puzzleIndex < count) {
val chunkSize = (count - puzzleIndex).coerceAtMost(SESSION_ROTATION)
engineManager.createChatSession(systemPrompt).use { session ->
repeat(chunkSize) {
generateSinglePuzzle(session, lastFailureReason)
puzzleIndex++
}
}
}
// 3. 실패 이유 포함한 컨텍스트 재시도
val retryPrompt = """
Your previous response was rejected:
word has ${rejectedWord.length} letters but we asked for $targetLength.
The word MUST have exactly $targetLength letters.
Try again with a DIFFERENT word.
""".trimIndent()
// 4. 최종 힌트 전용 프롬프트 (Day 5)
val systemPrompt = """
You write short, clear word-game hints in $lang.
Rules:
- ALL hints MUST be written in $lang. Never write hints in English or any other language.
- Do NOT include the word in any hint.
- Each hint should be one short sentence.
- Return ONLY a JSON object: {"hints": ["hint1", "hint2", "hint3"]}
""".trimIndent()
val userPrompt = """
Write 3 hints for this word IN $lang.
Do NOT write hints in English unless the language is English.
Word: "$word"
""".trimIndent()
// 5. 결정론적 폴백 (LLM 실패 시 게임 중단 방지)
fun fallbackHints(word: String, language: String): List<String> = when (language) {
"pt" -> listOf(
"É algo que as pessoas conhecem",
"Pode ser encontrado no dia a dia",
"Tem ${word.length} letras"
)
"es" -> listOf(
"Es algo que la gente conoce",
"Se puede encontrar en la vida cotidiana",
"Tiene ${word.length} letras"
)
else -> listOf(
"It is something people know",
"It can be found in everyday life",
"It has ${word.length} letters"
)
}Terminology
관련 논문
확장 가능한 Synthetic Data 생성을 위한 Dynamic Context Evolution
VTS + Semantic Memory + Adaptive Prompt 3가지 메커니즘으로 구성된 프레임워크는 LLM 대량 synthetic data 생성 시 배치 간 중복·반복 현상을 완전히 제거한다.
Karpathy 워크플로우에서 영감받아 사전 컴파일된 Wiki로 세션당 토큰 90%+ 절감
사전에 정리된 코드베이스 Wiki를 활용하면 Claude 세션당 토큰 사용량을 90% 이상 줄인다.
3개월치 AI 생성 코드를 전부 삭제했다. 그리고 배운 것들.
AI로 작성된 코드베이스를 70% 삭제 후 2주 만에 재작성하니 절반 크기로 줄어들면서 완전한 이해 가능성을 확보했다.
원시인 말투로 토큰 60% 절약하는 압축 프롬프트 기법
관사·접속사·조동사를 제거한 전보체 스타일은 LLM 응답 토큰을 60% 감소시킨다.
Claude에게 원시인 말투를 가르쳐 output 토큰 75% 절약하기
짧은 문장 강제 프롬프트는 output 토큰을 75% 감소시키지만 실제 비용 절감은 3~4% 수준에 그친다.
ChatGPT로 229lbs에서 176lbs로 감량 성공한 경험 공유
저자가 ChatGPT를 개인 헬스 코치처럼 활용해 수개월 내 과학적 근거 기반의 체중 감량에 성공했다.
짧을수록 좋다: Function-Calling 에이전트에서 Chain-of-Thought 토큰 예산의 비단조적 효과
Function-Calling 에이전트는 CoT를 32토큰으로 제한할 때 최고 성능을 달성하며, 256토큰으로 확장하면 성능이 저하된다.
Related Resources
Original Abstract (Expand)
On-device Small Language Models (SLMs) promise fully offline, private AI experiences for mobile users (no cloud dependency, no data leaving the device). But is this promise achievable in practice? This paper presents a longitudinal practitioner case study documenting the engineering challenges of integrating SLMs (Gemma 4 E2B, 2.6B parameters; Qwen3 0.6B, 600M parameters) into Palabrita, a production Android word-guessing game. Over a 5-day development sprint comprising 204 commits (~90 directly AI-related), the system underwent a radical transformation: from an ambitious design where the LLM generated complete structured puzzles (word, category, difficulty, and five hints as JSON) to a pragmatic architecture where curated word lists provide the words and the LLM generates only three short hints, with a deterministic fallback if it fails. We identify five categories of failures specific to on-device SLM integration: output format violations, constraint violations, context quality degradation, latency incompatibility, and model selection instability. For each failure category, we document the observed symptoms, root causes, and the prompt engineering and architectural strategies that effectively mitigated them, including multi-layer defensive parsing, contextual retry with failure feedback, session rotation, progressive prompt hardening, and systematic responsibility reduction. Our findings demonstrate that on-device SLMs are viable for production mobile applications, but only when the developer accepts a fundamental constraint: the most reliable on-device LLM feature is one where the LLM does the least. We distill our experience into eight actionable design heuristics for practitioners integrating SLMs into mobile apps.