diff --git a/docs/design/657-litertlm-filename/README.md b/docs/design/657-litertlm-filename/README.md new file mode 100644 index 0000000..9d1ba09 --- /dev/null +++ b/docs/design/657-litertlm-filename/README.md @@ -0,0 +1,76 @@ +# 설계서: LLM 로드 실패 근본 원인 fix — 모델 파일명 .litertlm + 레거시 마이그레이션 (#657) + +> **상태**: Approved +> **작성**: [AI] Architect · **최종수정**: 2026-07-14 +> **추적성** — Redmine: #657 (← #342 §2 out-of-scope follow-up) · 관련 ADR: 없음 +> · 구현 파일: `app/lib/data/ai/model_lifecycle.dart`, `app/lib/data/ai/gemma_llm_service.dart`, `app/lib/state/ai_providers.dart`, `app/android/app/src/debug/AndroidManifest.xml` +> · 테스트: `app/test/data/ai/model_lifecycle_test.dart` (마이그레이션 신규 케이스) + +## 1. 목적 (Why) + +v0.4.1 실 단말에서 LLM 로드가 `BackendInitException: Failed to create engine. Model may be invalid` 로 실패 (#342). 2026-07-09 에뮬레이터 재현으로 근본 원인 확정: + +- **근본 원인**: `ModelConfig.filename` 기본값이 `gemma4_e2b_q4.bin`. LiteRT-LM 네이티브 엔진은 **파일 확장자로 컨테이너 포맷을 판별**하므로 `.bin` 이름이면 `litert_lm_engine_create` 가 전 백엔드(gpu·cpu)에서 실패한다. 같은 바이트의 파일을 `.litertlm` 으로 rename 하면 통과. +- **기각된 가설**: maxTokens 2048→1024 (GPU alloc 여유 가설). 1024는 오히려 추론을 깬다 — Gemma 4 E2B 컴파일 그래프의 prefill signature 보다 KV cache 가 작아져 `DYNAMIC_UPDATE_SLICE failed to prepare → Failed to invoke the compiled model` (llm_litert_compiled_model_executor.cc:738). **2048 필수.** + +검증: 2026-07-14 에뮬레이터 E2E — 2.41GB 다운로드(SHA-256 통과) → 엔진 로드(xnnpack_cache 생성) → 채팅 tool call 왕복(`list_active_habits` → OK → 한국어 응답) 전 구간 성공. + +## 2. 범위 (Scope) + +- **포함**: + - A. **파일명 fix (프로덕션)**: `ModelConfig.filename` 기본값 → `gemma-4-E2B-it.litertlm`. 확장자 요구사항을 필드 doc comment 로 고정. + - B. **레거시 마이그레이션 (프로덕션)**: 기존 설치본은 디스크에 `.bin` 파일 + meta `ai_model_path` 가 `.bin` 경로. 파일명만 바꾸면 이 사용자들은 여전히 로드 실패 → 2.4GB 재다운로드 강요. `ModelLifecycle` 이 meta path ≠ canonical path 를 감지하면 **파일 rename + meta 갱신** (같은 바이트이므로 SHA meta 유지). + - C. **dev 인프라 (에뮬레이터 테스트 루프)**: + - `GEMMA_MODEL_URL` dart-define — 호스트 로컬 모델 서버(`http://10.0.2.2:...`) 주입. 기본값 = 기존 HuggingFace URL (동작 불변). + - `LLM_BACKEND` dart-define — 에뮬레이터는 SwiftShader(소프트웨어 Vulkan)가 GPU init 을 **네이티브 SIGABRT** 로 죽이고 Dart 폴백은 native abort 를 못 잡음 → `cpu` 강제 필요. 기본값 = 미지정(SDK gpu→cpu 폴백, 실단말 동작 불변). + - debug manifest `usesCleartextTraffic` — dart:io 도 Android cleartext 정책을 따르므로 debug 빌드만 허용. release 불변. +- **제외 (out of scope)**: + - 실단말 검증 (#657 종료 조건이지만 사용자 단말 필요 — 별도 세션). + - `download()` 의 per-chunk `meta.put` 스로틀(~2MB/s) 및 non-Range 서버 resume corruption — follow-up 후보. + +## 3. 인수조건 (Acceptance Criteria) + +- [ ] **AC-1** 신규 설치: 다운로드된 모델 파일명이 `gemma-4-E2B-it.litertlm`, `checkAvailability()` = ready, 엔진 로드 성공. +- [ ] **AC-2** 레거시 설치(`.bin` 파일 + meta path): 첫 availability 체크에서 파일이 canonical 이름으로 rename 되고 meta `ai_model_path` 가 갱신되며 결과는 ready. 재다운로드 없음. +- [ ] **AC-3** 마이그레이션 멱등: 두 번째 호출은 no-op. rename 실패(파일 잠김 등) 시 corrupt/missing 으로 강등하지 않고 기존 상태 보존 (다음 기회 재시도). +- [ ] **AC-4** meta path 파일이 없으면 마이그레이션 skip, 기존 missing 흐름 유지. +- [ ] **AC-5** `maxTokens: 2048` 유지 + 근거 주석 (1024 회귀 방지). +- [ ] **AC-6** `LLM_BACKEND`/`GEMMA_MODEL_URL` 미지정 빌드는 기존 동작과 바이트 동일 (기본값 경로). +- [ ] **AC-7** release manifest 에 cleartext 허용 없음 (debug sourceSet 한정). +- [ ] **AC-8** 기존 테스트 전건 회귀 없음 + `flutter analyze` clean. + +## 4. 함수 등재 (Function Inventory) + +| 함수 | 위치 | 복잡도 | 설계 | +|------|------|--------|------| +| `ModelLifecycle._migrateLegacyPath()` | model_lifecycle.dart | 분기 4 (meta 없음 / 동일 경로 / 파일 없음 / rename 실패) | 아래 §5 | +| `ModelConfig.filename` 기본값 변경 | model_lifecycle.dart | 상수 | doc comment 로 확장자 불변식 고정 | +| `_preferredBackend` getter | gemma_llm_service.dart | switch 3분기 | dart-define → enum 매핑, 미지정 = null | +| `_kModelUrl` 상수 | ai_providers.dart | 상수 | dart-define, 기본값 = HF URL | + +## 5. 마이그레이션 설계 (`_migrateLegacyPath`) + +``` +quickCheck() / checkAvailability() + └─ (opt-in ✓, downloadState not in-progress 확인 후) + _migrateLegacyPath(): + metaPath = meta[ai_model_path] // null → return (기존 missing 흐름) + canonical = supportDir/config.filename + if metaPath == canonical → return // 이미 정상 (멱등) + if !File(metaPath).exists → return // 파일 유실 — missing 흐름에 위임 + try File(metaPath).rename(canonical) // 같은 디렉터리 — 원자적 + catch → return // 보수적: 상태 불변, 다음 기회 재시도 + meta[ai_model_path] = canonical // SHA meta 는 그대로 (같은 바이트) +``` + +- **호출 지점**: `quickCheck`/`checkAvailability` 둘 다, opt-in·downloadState 게이트 통과 직후. 콜드 패스에서 한 번 rename 되면 이후는 문자열 비교 1회라 hot path(quickCheck) 비용 무시 가능. +- **왜 availability 체크 안에서**: 앱의 모든 LLM 진입점(#311 warm-up, 설정 화면, lazy load)이 availability 체크를 먼저 지나므로 별도 시동 훅 불필요. +- **rename 실패를 삼키는 이유**: 실패해도 사용자 상태는 "이전과 동일하게 로드 실패"일 뿐 악화되지 않고, 다음 체크에서 재시도된다. 예외를 올리면 availability 가 corrupt 로 오판된다 (명시적 트레이드오프). + +## 6. 리스크 & 검증 + +| 리스크 | 대응 | +|--------|------| +| 레거시 사용자 `.bin` rename 후에도 로드 실패 | 에뮬레이터에서 동일 시나리오 재현 검증 완료 (rename → 엔진 통과). 실단말 검증이 종료 조건 | +| dart-define 미지정 빌드 동작 변화 | 기본값 = 기존값 (AC-6). 테스트 회귀로 확인 | +| cleartext 가 release 로 누출 | debug sourceSet 매니페스트만 수정 (AC-7) |