- ADR-0003: on-device LLM Gemma 4 E2B Q4_0 + flutter_gemma 도입 결정. 5개 대안(클라우드/정적확장/Llama/E4B/APK번들) 기각 사유 명시. - docs/design/215-gemma-frame-suggest/: 설계서 게이트 통과 산출물. README.md (12 섹션 전부 + AC10 + OQ6 + 함수 15개) + fn-suggest_frame.md (suggestFrame/buildFewShotPrompt/parseFrameCandidates) + fn-model_lifecycle.md (LlmService/GemmaLlmService/ModelLifecycle). - graceful degradation 전면: AI 실패 시 throw 없이 빈 리스트 + 수동 입력 유지. - LlmService 추상화로 도메인 ↔ flutter_gemma 경계 분리 (테스트 가능성). Refs #215
382 lines
13 KiB
Markdown
382 lines
13 KiB
Markdown
# 함수 설계서: `ModelLifecycle` + `LlmService` (#215)
|
||
|
||
> **부모 설계서**: ./README.md · **상태**: Draft
|
||
> **작성**: [AI] Architect · **구현**: `app/lib/data/ai/model_lifecycle.dart`, `llm_service.dart`, `gemma_llm_service.dart` (TBD) · **테스트**: `app/test/data/ai/{model_lifecycle,gemma_llm_service}_test.dart` (TBD)
|
||
|
||
> 본 문서는 모델 수명주기와 추론 인터페이스를 묶어 다룬다. 모두 I/O 경계 — flutter_gemma + 파일시스템 + 네트워크 + 네이티브 메모리.
|
||
|
||
---
|
||
|
||
## §A. `LlmService` (추상 인터페이스)
|
||
|
||
### 1. 시그니처
|
||
|
||
```dart
|
||
abstract class LlmService {
|
||
Future<void> load();
|
||
Future<void> unload();
|
||
bool get isLoaded;
|
||
Future<Map<String, dynamic>> generateStructured(
|
||
String prompt,
|
||
Map<String, dynamic> schema,
|
||
);
|
||
}
|
||
```
|
||
|
||
### 2. 책임 (1줄)
|
||
|
||
LLM 백엔드를 도메인에 노출하는 단일 인터페이스. 구현체는 `GemmaLlmService` (flutter_gemma) 와 `MockLlmService` (테스트).
|
||
|
||
### 3. 입력 / 출력
|
||
|
||
| 메서드 | 입력 | 출력 |
|
||
|--------|------|------|
|
||
| `load()` | (구현체 내부에서 path 결정) | void. 모델 메모리 상주 |
|
||
| `unload()` | none | void. 메모리 회수 |
|
||
| `isLoaded` | none | bool |
|
||
| `generateStructured(prompt, schema)` | prompt string, function calling JSON schema | parsed JSON map |
|
||
|
||
### 4. 부수효과
|
||
|
||
- `load`: 모델 파일 read + 네이티브 메모리 할당.
|
||
- `unload`: 네이티브 메모리 해제.
|
||
- `generateStructured`: 네이티브 추론 호출. prompt 본문은 로그 X (privacy).
|
||
|
||
### 5. 인터페이스 계약 (구현체가 반드시 지켜야 할 것)
|
||
|
||
- `load` 가 throw 하지 않고 정상 종료하면 `isLoaded == true`.
|
||
- `generateStructured` 호출 시 `!isLoaded` → throw `StateError("not loaded")`.
|
||
- `generateStructured` 응답은 schema 의 `parameters` 와 일치 — 위반 시 throw `FormatException`.
|
||
- `unload` 는 idempotent. `!isLoaded` 상태에서 호출해도 noop.
|
||
- timeout 은 호출자가 `.timeout(...)` 로 부여. 본 인터페이스는 별도 타임아웃 처리 X.
|
||
|
||
### 6. 추적성
|
||
|
||
- 인수조건: #215 AC-6 (응답), AC-9 (graceful — throw 종류 명세).
|
||
- 관련 ADR: ADR-0003.
|
||
|
||
---
|
||
|
||
## §B. `GemmaLlmService` (flutter_gemma 구현체)
|
||
|
||
### 1. 시그니처
|
||
|
||
```dart
|
||
class GemmaLlmService implements LlmService {
|
||
GemmaLlmService({
|
||
required this.modelPath,
|
||
FlutterGemma? gemma,
|
||
});
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 2. 책임 (1줄)
|
||
|
||
`flutter_gemma` 를 호출해 Gemma 4 E2B Q4_0 모델을 디스크에서 로드하고 function calling 응답을 받는다.
|
||
|
||
### 3. 입력
|
||
|
||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||
|----------|------|-----------|------|
|
||
| `modelPath` | String | 절대 경로, 파일 존재 | `ModelLifecycle.checkAvailability` 가 ready 일 때만 전달 |
|
||
| `gemma` | `FlutterGemma?` | nullable (DI 용) | 테스트 시 mock 주입 가능 |
|
||
|
||
### 4. 출력
|
||
|
||
- `load`: void. 내부 `_loaded = true`.
|
||
- `generateStructured`: parsed JSON (function calling 의 arguments).
|
||
- `unload`: void. 내부 `_loaded = false`.
|
||
|
||
### 5. 동작 / 알고리즘
|
||
|
||
```
|
||
load:
|
||
1. if _loaded: return (idempotent)
|
||
2. gemma = FlutterGemma()
|
||
3. await gemma.init(modelPath: modelPath, backend: 'litert_lm_android')
|
||
- flutter_gemma 의 정확한 API 는 패키지 문서 확정 후 (OQ-1)
|
||
4. _loaded = true
|
||
catch:
|
||
- OOM → throw StateError("OOM during load")
|
||
- File not found → throw FileSystemException
|
||
- 기타 → rethrow
|
||
|
||
generateStructured(prompt, schema):
|
||
1. if !_loaded: throw StateError("not loaded")
|
||
2. response = await gemma.generateWithFunctionCalling(
|
||
prompt: prompt,
|
||
functions: [
|
||
{"name": "emit_frame_candidates", "parameters": schema},
|
||
],
|
||
)
|
||
3. if response.functionCall == null:
|
||
throw FormatException("no function call")
|
||
4. return response.functionCall.arguments // already parsed Map
|
||
catch:
|
||
- FormatException → rethrow (호출자가 결정)
|
||
|
||
unload:
|
||
1. if !_loaded: return
|
||
2. await gemma.close()
|
||
3. _loaded = false
|
||
```
|
||
|
||
### 6. 에러 & 실패 모드
|
||
|
||
| 조건 | 처리 | 반환/예외 |
|
||
|------|------|-----------|
|
||
| 모델 파일 없음 | rethrow | FileSystemException |
|
||
| OOM | catch + 변환 | StateError("OOM") |
|
||
| init crash (native) | rethrow | Exception (구체 클래스는 flutter_gemma 정의) |
|
||
| function call 없는 응답 | throw | FormatException |
|
||
| schema 위반 응답 | flutter_gemma 가 거부 + retry → 최종 실패 시 FormatException | (패키지가 처리) |
|
||
|
||
### 7. 엣지케이스
|
||
|
||
- 동시에 두 번 `load()` 호출 → idempotent. 두 번째는 noop.
|
||
- `load()` 진행 중 `unload()` 호출 → 첫 load 완료 후 unload (Lock 사용 권장).
|
||
- 추론 도중 앱 background → flutter_gemma 가 native 측 isolate 로 계속. UI 가 result 받음.
|
||
- 추론 도중 unload 호출 → race condition. v1 은 `_inferring = true` flag 로 unload 지연.
|
||
|
||
### 8. 복잡도 / 성능
|
||
|
||
- `load`: cold 1–3초 (디스크 → RAM). 1.5GB read.
|
||
- `generateStructured`: cold (모델 첫 호출) 1–2초, warm 0.5–2초.
|
||
- `unload`: < 100ms.
|
||
- 메모리: 모델 상주 ≈ 1.5GB.
|
||
|
||
### 9. 의존성
|
||
|
||
- `flutter_gemma` 패키지 (latest, pubspec.lock 동결).
|
||
- `dart:async`.
|
||
|
||
### 10. 테스트 케이스
|
||
|
||
- [ ] **정상 load → generate → unload**: dummy 1KB fixture 모델 + mock FlutterGemma 주입.
|
||
- [ ] **!loaded 상태 generateStructured**: throw StateError.
|
||
- [ ] **load idempotent**: 두 번 호출 → 두 번째 noop, `isLoaded == true`.
|
||
- [ ] **unload idempotent**: 두 번 호출 → 두 번째 noop.
|
||
- [ ] **OOM 시뮬레이션**: mock 이 OOM throw → StateError 변환.
|
||
- [ ] **function call 없는 응답**: mock 응답 → FormatException.
|
||
- [ ] **schema 일치 응답**: mock 응답 → arguments map 반환.
|
||
|
||
### 11. 추적성
|
||
|
||
- 인수조건: #215 AC-6, AC-7, AC-9.
|
||
- 관련 ADR: ADR-0003 (flutter_gemma + LiteRT-LM Android).
|
||
- Open Question: OQ-1 (모델 URL), OQ-4 (timeout).
|
||
|
||
---
|
||
|
||
## §C. `ModelLifecycle.checkAvailability`
|
||
|
||
### 1. 시그니처
|
||
|
||
```dart
|
||
Future<ModelAvailability> checkAvailability();
|
||
|
||
enum ModelAvailability { ready, missing, corrupt, downloading }
|
||
```
|
||
|
||
### 2. 책임 (1줄)
|
||
|
||
디스크의 모델 파일 존재 + SHA-256 무결성 + meta_kv 상태를 종합해 가용성을 결정한다.
|
||
|
||
### 3. 입력 / 출력
|
||
|
||
- 입력: none (내부에서 `MetaKvDao` + `path_provider` 사용).
|
||
- 출력: `ModelAvailability` enum.
|
||
|
||
### 4. 동작 / 알고리즘
|
||
|
||
```
|
||
1. opt_in = await metaKv.get('ai_opt_in')
|
||
- 'false' or null → return missing (사용자가 끔)
|
||
|
||
2. state = await metaKv.get('ai_download_state')
|
||
- 'downloading' or 'paused' → return downloading
|
||
|
||
3. path = await metaKv.get('ai_model_path')
|
||
- null → return missing
|
||
|
||
4. file = File(path)
|
||
- !exists → return missing (외부 삭제 시나리오)
|
||
|
||
5. expectedSha = await metaKv.get('ai_model_sha256')
|
||
- null → return corrupt
|
||
|
||
6. actualSha = sha256.convert(await file.readAsBytes()).toString()
|
||
- != expectedSha → return corrupt
|
||
|
||
7. return ready
|
||
```
|
||
|
||
> 실제 1.5GB 파일 SHA-256 매번 계산은 비용 큼. v1 은 **앱 시작 시 1회만** 검증 + 결과 캐시. 매번 호출 시 캐시 hit. v1.1 에서 마지막 검증 시각 + invalidation 정책 검토.
|
||
|
||
### 5. 에러 & 실패 모드
|
||
|
||
| 조건 | 처리 | 반환 |
|
||
|------|------|------|
|
||
| meta_kv I/O 실패 | catch | missing |
|
||
| file read 실패 (권한) | catch | corrupt |
|
||
| SHA 계산 도중 OOM (희박) | catch | corrupt |
|
||
|
||
### 6. 복잡도 / 성능
|
||
|
||
- 첫 호출 (해시 계산): 1.5GB read + SHA-256 → 5–15초. **앱 시작 시 백그라운드 isolate 에서 수행**.
|
||
- 이후 캐시: < 5ms.
|
||
|
||
### 7. 의존성
|
||
|
||
- `MetaKvDao` (#204), `path_provider`, `crypto` (SHA-256), `dart:io`.
|
||
|
||
### 8. 테스트 케이스
|
||
|
||
- [ ] **ready**: 올바른 path + 일치 SHA → ready.
|
||
- [ ] **missing (opt_out)**: ai_opt_in = false → missing.
|
||
- [ ] **missing (path null)**: ai_model_path = null → missing.
|
||
- [ ] **missing (file 없음)**: path 는 있으나 file 부재 → missing.
|
||
- [ ] **downloading**: state = paused → downloading.
|
||
- [ ] **corrupt**: SHA mismatch → corrupt.
|
||
|
||
### 9. 추적성
|
||
|
||
- 인수조건: #215 AC-4 (무결성), AC-9 (graceful).
|
||
|
||
---
|
||
|
||
## §D. `ModelLifecycle.download`
|
||
|
||
### 1. 시그니처
|
||
|
||
```dart
|
||
Stream<DownloadProgress> download({String? modelUrl});
|
||
|
||
class DownloadProgress {
|
||
final int bytesReceived;
|
||
final int totalBytes; // -1 if unknown
|
||
final DownloadState state; // downloading | paused | completed | failed
|
||
final String? errorMessage;
|
||
}
|
||
```
|
||
|
||
### 2. 책임 (1줄)
|
||
|
||
Gemma 4 E2B Q4_0 모델 파일을 HTTP Range 로 백그라운드 다운로드 + 일시정지/재개 + SHA-256 검증.
|
||
|
||
### 3. 입력
|
||
|
||
| 파라미터 | 타입 | 제약/검증 | 설명 |
|
||
|----------|------|-----------|------|
|
||
| `modelUrl` | String? | 기본값 = config 상수 (OQ-1) | 테스트 / 추후 swap 시 override |
|
||
|
||
### 4. 출력
|
||
|
||
- `Stream<DownloadProgress>` — 진행률 broadcast.
|
||
- 부수효과: 디스크 파일 write, meta_kv 4개 키 update.
|
||
|
||
### 5. 동작 / 알고리즘
|
||
|
||
```
|
||
1. tempPath = await pathProvider.getApplicationSupportDirectory() + '/gemma4_e2b_q4.bin.tmp'
|
||
2. finalPath = without '.tmp'
|
||
3. existingBytes = File(tempPath).existsSync() ? File(tempPath).lengthSync() : 0
|
||
4. await metaKv.set('ai_download_state', 'downloading')
|
||
5. await metaKv.set('ai_download_bytes', existingBytes.toString())
|
||
|
||
6. yield DownloadProgress(existingBytes, -1, downloading)
|
||
|
||
7. response = await http.get(modelUrl, headers: {'Range': 'bytes=$existingBytes-'})
|
||
- 200 (full) or 206 (partial)
|
||
|
||
8. totalBytes = existingBytes + response.contentLength
|
||
|
||
9. stream = response.stream
|
||
10. for each chunk in stream:
|
||
- if pauseRequested: break (state='paused', save bytes)
|
||
- if cancelRequested: delete tempPath, state='failed', break
|
||
- file.writeAsBytes(chunk, mode: append)
|
||
- existingBytes += chunk.length
|
||
- await metaKv.set('ai_download_bytes', existingBytes.toString())
|
||
- yield DownloadProgress(existingBytes, totalBytes, downloading)
|
||
|
||
11. if completed:
|
||
- rename tempPath → finalPath
|
||
- actualSha = sha256(finalPath)
|
||
- if actualSha != EXPECTED_SHA:
|
||
- delete finalPath, state='failed', yield failed
|
||
- return
|
||
- await metaKv.set('ai_model_path', finalPath)
|
||
- await metaKv.set('ai_model_sha256', actualSha)
|
||
- await metaKv.set('ai_download_state', 'completed')
|
||
- yield DownloadProgress(totalBytes, totalBytes, completed)
|
||
|
||
12. catch network error:
|
||
- retry 3회 exponential backoff (1s, 2s, 4s)
|
||
- 최종 실패 → state='paused' (자동 재개 가능), yield failed with message
|
||
```
|
||
|
||
> `EXPECTED_SHA` 는 모델 파일의 알려진 hash. OQ-1 에서 URL + SHA 함께 확정.
|
||
|
||
### 6. 에러 & 실패 모드
|
||
|
||
| 조건 | 처리 | 반환 (stream emit) |
|
||
|------|------|---------------------|
|
||
| 네트워크 끊김 | retry 3회 → paused | `failed` (자동 재개 가능 표시) |
|
||
| 디스크 공간 부족 | catch IOException | `failed` ("저장 공간 부족") |
|
||
| SHA mismatch | 파일 삭제 + state failed | `failed` ("다운로드 손상") |
|
||
| HTTP 416 (Range 위반) | 0 부터 재시작 | `downloading` from 0 |
|
||
| 사용자 취소 | tempPath 삭제 + meta_kv clear | `failed` ("취소됨") |
|
||
| 사용자 일시정지 | tempPath 보존 + state=paused | `paused` |
|
||
|
||
### 7. 엣지케이스
|
||
|
||
- 다운로드 완료 직전 앱 종료 → tempPath 만 존재 (rename 미실행). 재시작 시 `checkAvailability` = downloading 상태로 인식 → 재개 시 nearly complete.
|
||
- 두 번 동시 호출 → v1 은 lock 으로 단일 인스턴스 보장.
|
||
- WiFi → cellular 전환 — 본 함수는 신경 X. Settings 의 "WiFi only" 토글이 별도 차단 (OQ-3).
|
||
|
||
### 8. 복잡도 / 성능
|
||
|
||
- 시간: 1.5GB @ 50Mbps = 약 4분. WiFi 200Mbps = 약 1분.
|
||
- SHA 검증: 5–15초.
|
||
- 메모리: 스트림 chunk 만 (≤ 64KB).
|
||
|
||
### 9. 의존성
|
||
|
||
- `http` 또는 `dio` (range request 지원).
|
||
- `path_provider`, `crypto`, `dart:io`.
|
||
- `MetaKvDao`.
|
||
|
||
### 10. 테스트 케이스
|
||
|
||
- [ ] **fresh download (small fixture)**: mock HTTP server 가 1KB 응답 → completed, file 존재, SHA 매치.
|
||
- [ ] **resume**: 512B 다운로드 후 pause → 재개 시 Range=512- 요청 → completed.
|
||
- [ ] **SHA mismatch**: mock 이 잘못된 byte 전송 → failed + file 삭제.
|
||
- [ ] **network error → retry → success**: mock 이 첫 2번 throw, 3번째 OK → completed.
|
||
- [ ] **cancel**: 진행 중 cancel → tempPath 삭제 + meta_kv clear.
|
||
- [ ] **pause + 앱 재시작 시뮬레이션**: bytes 보존 → 다시 download() 호출 시 Range header.
|
||
- [ ] **HTTP 416**: mock 이 416 → 0부터 재시작.
|
||
|
||
### 11. 추적성
|
||
|
||
- 인수조건: #215 AC-3 (일시정지/재개/취소), AC-4 (무결성).
|
||
- Open Question: OQ-1 (URL), OQ-3 (cellular).
|
||
|
||
---
|
||
|
||
## §E. 통합 점검
|
||
|
||
- `LlmService` 인터페이스만 도메인에 노출. 도메인 (`suggest_frame.dart`) 은 `flutter_gemma` 를 import 하지 않는다 (I/O ↔ 순수 경계).
|
||
- `ModelLifecycle.purge` (단순 함수, fn 분리 안 함):
|
||
|
||
```
|
||
1. await unload() (혹시 로드 중)
|
||
2. path = meta_kv.get('ai_model_path')
|
||
3. if path != null: File(path).delete() // idempotent
|
||
4. meta_kv.delete(['ai_model_path', 'ai_model_sha256', 'ai_download_state', 'ai_download_bytes'])
|
||
5. return freedBytes
|
||
```
|
||
|
||
- 모든 메서드는 graceful — `suggestFrame` 호출 흐름에서 모델 로드 실패 / 다운로드 실패 시 throw 하지 않고 호출자가 `[]` 빈 결과를 반환받도록 한다. UI 가 사용자 메시지 결정 (AC-9 graceful degradation 보장).
|