2 Commits

Author SHA1 Message Date
8b11d77c97 [Developer] #657 모델 파일명 .litertlm fix + 레거시 마이그레이션 + 에뮬레이터 dev 인프라
- ModelConfig.filename 기본값 gemma4_e2b_q4.bin → gemma-4-E2B-it.litertlm (#342 근본 원인)
- _migrateLegacyPath: 기존 .bin 설치본 rename + meta 갱신 (재다운로드 2.4GB 방지, 멱등)
- maxTokens 2048 유지 + 1024 회귀 방지 주석 (KV cache < prefill signature 시 추론 실패)
- LLM_BACKEND dart-define: 에뮬레이터 CPU 강제 (SwiftShader GPU 네이티브 SIGABRT 회피)
- GEMMA_MODEL_URL dart-define: 호스트 로컬 모델 서버 주입 (기본값 = HF URL 불변)
- debug 전용 cleartext manifest (10.0.2.2 모델 서버용, release 불변)
- 마이그레이션 테스트 4건 신규 (AC-2/3/4). 171 passed, analyze clean

2026-07-14 에뮬레이터 E2E 검증: 다운로드→로드→tool call 왕복 전 구간 성공.

Refs #657, #342

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:15:29 +09:00
b8e74d7dd9 [Architect] #657 설계서 — 모델 파일명 .litertlm 강제 + 레거시 .bin 마이그레이션
#342 실단말 LLM 로드 실패의 근본 원인 fix 설계. LiteRT-LM 은 파일
확장자로 컨테이너 포맷을 판별하므로 .bin 파일명이면 engine_create 가
전 백엔드에서 실패. maxTokens 1024 가설은 기각 (추론 자체를 깸, 2048 필수).

Refs #657

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 16:15:29 +09:00
6 changed files with 218 additions and 6 deletions

View File

@@ -4,4 +4,8 @@
to allow setting breakpoints, to provide hot reload, etc. to allow setting breakpoints, to provide hot reload, etc.
--> -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Debug-only: emulator testing pulls the Gemma model from a host-local
HTTP server (GEMMA_MODEL_URL=http://10.0.2.2:...). Release builds
keep the platform default (cleartext blocked). -->
<application android:usesCleartextTraffic="true"/>
</manifest> </manifest>

View File

@@ -13,6 +13,20 @@ import 'llm_service.dart';
/// local file path generally does not require the token. /// local file path generally does not require the token.
const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: ''); const String _hfToken = String.fromEnvironment('HF_TOKEN', defaultValue: '');
/// Backend override for dev builds. The Android emulator advertises a
/// software Vulkan adapter (SwiftShader) that SIGABRTs LiteRT-LM's GPU
/// init natively — the Dart-level gpu→cpu fallback can't catch a native
/// abort, so emulator runs must force CPU: `--dart-define=LLM_BACKEND=cpu`.
/// Unset (default) keeps the SDK's gpu→cpu fallback for real devices.
const String _backendOverride =
String.fromEnvironment('LLM_BACKEND', defaultValue: '');
PreferredBackend? get _preferredBackend => switch (_backendOverride) {
'cpu' => PreferredBackend.cpu,
'gpu' => PreferredBackend.gpu,
_ => null,
};
/// One-shot guard so [FlutterGemma.initialize] runs at most once per /// One-shot guard so [FlutterGemma.initialize] runs at most once per
/// isolate. Re-init is unsupported by the underlying plugin. /// isolate. Re-init is unsupported by the underlying plugin.
bool _initialized = false; bool _initialized = false;
@@ -70,7 +84,15 @@ class GemmaLlmService implements LlmService {
modelType: ModelType.gemma4, modelType: ModelType.gemma4,
fileType: ModelFileType.litertlm, fileType: ModelFileType.litertlm,
).fromFile(modelPath).install(); ).fromFile(modelPath).install();
final model = await FlutterGemma.getActiveModel(maxTokens: 2048); // #342 root cause was the model *filename* (.bin — LiteRT-LM rejects it;
// must be .litertlm), NOT the KV cache size. maxTokens stays 2048: the
// Gemma 4 E2B compiled graph requires a cache ≥ its prefill signature —
// 1024 fails tensor allocation (DYNAMIC_UPDATE_SLICE prepare, verified
// on-emulator 2026-07-09).
final model = await FlutterGemma.getActiveModel(
maxTokens: 2048,
preferredBackend: _preferredBackend,
);
_model = model; _model = model;
_loaded = true; _loaded = true;
} }

View File

@@ -66,11 +66,16 @@ class _ProdStorage implements StorageAdapter {
class ModelConfig { class ModelConfig {
final Uri url; final Uri url;
final String expectedSha256; final String expectedSha256;
/// MUST keep the `.litertlm` extension: LiteRT-LM sniffs the container
/// format from the file extension, and a `.bin` name makes native
/// engine_create fail with "Model may be invalid" on every backend
/// (#342 root cause, verified on-emulator 2026-07-09).
final String filename; final String filename;
const ModelConfig({ const ModelConfig({
required this.url, required this.url,
required this.expectedSha256, required this.expectedSha256,
this.filename = 'gemma4_e2b_q4.bin', this.filename = 'gemma-4-E2B-it.litertlm',
}); });
} }
@@ -94,6 +99,26 @@ class ModelLifecycle {
return p.join(dir.path, config.filename); return p.join(dir.path, config.filename);
} }
/// #657 AC-2/3/4: pre-.litertlm installs saved the model as
/// `gemma4_e2b_q4.bin`, which LiteRT-LM's engine_create rejects. Renaming
/// the existing file (same bytes — SHA meta stays valid) spares those
/// users a 2.4GB re-download. Idempotent; a failed rename leaves state
/// untouched so the next availability check retries.
Future<void> _migrateLegacyPath() async {
final metaPath = await meta.find(AiMetaKeys.modelPath);
if (metaPath == null) return;
final canonical = await _modelPath();
if (metaPath == canonical) return;
final legacy = File(metaPath);
if (!legacy.existsSync()) return;
try {
await legacy.rename(canonical);
} catch (_) {
return;
}
await meta.put(AiMetaKeys.modelPath, canonical);
}
/// Lightweight ready estimate for warm-up gating (#311). /// Lightweight ready estimate for warm-up gating (#311).
/// ///
/// Skips the SHA-256 re-hash that [checkAvailability] performs — for a /// Skips the SHA-256 re-hash that [checkAvailability] performs — for a
@@ -117,6 +142,8 @@ class ModelLifecycle {
return ModelAvailability.downloading; return ModelAvailability.downloading;
} }
await _migrateLegacyPath();
final pathStr = await meta.find(AiMetaKeys.modelPath); final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr == null) return ModelAvailability.missing; if (pathStr == null) return ModelAvailability.missing;
@@ -142,6 +169,8 @@ class ModelLifecycle {
return ModelAvailability.downloading; return ModelAvailability.downloading;
} }
await _migrateLegacyPath();
final pathStr = await meta.find(AiMetaKeys.modelPath); final pathStr = await meta.find(AiMetaKeys.modelPath);
if (pathStr == null) return ModelAvailability.missing; if (pathStr == null) return ModelAvailability.missing;

View File

@@ -16,10 +16,15 @@ import 'providers.dart';
/// File ≈ 2.41GB; SHA-256 pinned for integrity check. /// File ≈ 2.41GB; SHA-256 pinned for integrity check.
/// ///
/// Tests / placeholder builds may override `modelLifecycleProvider` with /// Tests / placeholder builds may override `modelLifecycleProvider` with
/// fixture URLs. Production builds optionally inject a private mirror via /// fixture URLs. Dev/mirror builds may inject an alternate download origin
/// `--dart-define=GEMMA_MODEL_URL=...` (see main.dart). /// via `--dart-define=GEMMA_MODEL_URL=...` (e.g. a host-local server when
const _kModelUrl = /// testing on the Android emulator). SHA-256 stays pinned regardless of
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm'; /// origin, so a tampered mirror still fails verification.
const _kModelUrl = String.fromEnvironment(
'GEMMA_MODEL_URL',
defaultValue:
'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm',
);
const _kModelSha256 = const _kModelSha256 =
'181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c'; '181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c';

View File

@@ -250,4 +250,80 @@ void main() {
await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ'); await meta.put(AiMetaKeys.modelSha, 'expected_but_actual_will_differ');
expect(await lc.checkAvailability(), ModelAvailability.corrupt); expect(await lc.checkAvailability(), ModelAvailability.corrupt);
}); });
group('#657 legacy .bin filename migration', () {
// Pre-.litertlm installs: file on disk + meta path both use the old
// `gemma4_e2b_q4.bin` name that LiteRT-LM rejects.
const legacyName = 'gemma4_e2b_q4.bin';
const canonicalName = 'model.litertlm';
ModelLifecycle makeLc(String expectedSha) => ModelLifecycle(
meta: meta,
config: ModelConfig(
url: Uri.parse(url),
expectedSha256: expectedSha,
filename: canonicalName,
),
storage: storage,
);
Future<String> seedLegacy(List<int> payload) async {
final legacyPath = '${tmp.path}/$legacyName';
File(legacyPath).writeAsBytesSync(payload);
await meta.put(AiMetaKeys.optIn, 'true');
await meta.put(AiMetaKeys.modelPath, legacyPath);
await meta.put(
AiMetaKeys.modelSha, sha256.convert(payload).toString());
await meta.put(AiMetaKeys.downloadState, 'completed');
return legacyPath;
}
test('quickCheck renames legacy file + updates meta path (AC-2)',
() async {
final payload = utf8.encode('legacy model bytes');
final legacyPath = await seedLegacy(payload);
final lc = makeLc(sha256.convert(payload).toString());
expect(await lc.quickCheck(), ModelAvailability.ready);
expect(File(legacyPath).existsSync(), isFalse);
final canonicalPath = '${tmp.path}/$canonicalName';
expect(File(canonicalPath).existsSync(), isTrue);
expect(await meta.find(AiMetaKeys.modelPath), canonicalPath);
});
test('checkAvailability migrates and SHA meta stays valid (AC-2)',
() async {
final payload = utf8.encode('legacy model bytes');
await seedLegacy(payload);
final lc = makeLc(sha256.convert(payload).toString());
expect(await lc.checkAvailability(), ModelAvailability.ready);
expect(File('${tmp.path}/$canonicalName').existsSync(), isTrue);
});
test('migration is idempotent — second check is a no-op (AC-3)',
() async {
final payload = utf8.encode('legacy model bytes');
await seedLegacy(payload);
final lc = makeLc(sha256.convert(payload).toString());
expect(await lc.quickCheck(), ModelAvailability.ready);
expect(await lc.quickCheck(), ModelAvailability.ready);
expect(
await meta.find(AiMetaKeys.modelPath),
'${tmp.path}/$canonicalName',
);
});
test('meta path file gone → skip migration, stays missing (AC-4)',
() async {
await meta.put(AiMetaKeys.optIn, 'true');
await meta.put(AiMetaKeys.modelPath, '${tmp.path}/$legacyName');
await meta.put(AiMetaKeys.modelSha, 'irrelevant');
final lc = makeLc('irrelevant');
expect(await lc.quickCheck(), ModelAvailability.missing);
expect(File('${tmp.path}/$canonicalName').existsSync(), isFalse);
});
});
} }

View File

@@ -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) |