Files
life-helper/docs/design/311-llm-warmup/README.md
joungmin 1fa4f24a8a [02-Architect] #311 design spec + UX-Reviewer persona for LLM warm-up
- docs/design/311-llm-warmup/README.md — 기능 설계서. ChatWarmupController (5-state) + GemmaLlmService _loadingFuture concurrent guard + ModelLifecycle.quickCheck (lightweight ready).
- docs/design/311-llm-warmup/UX-REVIEW.md — UX-Reviewer parallel pass. Strong 4 + Suggest 2 권고. 입력창 enabled 유지 (타이핑 가능) + hintText 만 교체 + 상태-행동 분리.
- docs/design/311-llm-warmup/fn-chat_warmup_controller.md — start/retry 상세 + 빠른 경로 (isLoaded 시 Loading skip).
- docs/design/311-llm-warmup/fn-concurrent_load_guard.md — _loadingFuture 패턴 + whenComplete cleanup.
- .claude/agents/ux-reviewer.md — 신규 페르소나 (02-Architect 단계 내 parallel reviewer, 카테고리 부여 X).

AC 8 → 12 (UX 신규 4건 통합). OQ 3건 모두 해소. ADR 없음 (backward-compatible 추가).

Refs #311 #260
2026-06-15 11:41:03 +09:00

20 KiB

설계서: ChatScreen LLM warm-up (#311)

상태: Draft 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #311 · 관련 ADR: 없음 (Backward-compatible 추가) · 구현 파일: app/lib/state/chat_warmup_provider.dart (신규) · app/lib/data/ai/llm_service.dart (수정) · app/lib/data/ai/model_lifecycle.dart (quickCheck 추가) · app/lib/data/ai/gemma_llm_service.dart (concurrent load guard) · app/lib/ui/screens/chat_screen.dart (warmup binding) · 테스트: app/test/state/chat_warmup_test.dart (신규) · app/test/data/ai/model_lifecycle_test.dart (quickCheck 케이스 추가) · app/test/ui/chat_screen_test.dart (warmup 라벨/disabled 케이스 추가)

1. 목적 (Why)

Planner 목표 인용: ChatScreen 진입 시 LLM 모델을 백그라운드로 warm-up 하여, 첫 send 의 perceived latency 에서 cold load (수 초) 를 제거한다.

현재 chat_providers.dart:131llm.load() 가 첫 userTurn 시점에 lazy 실행된다. Gemma 4 E2B 의 native runtime 초기화 + installModel.fromFile().install() + getActiveModel(maxTokens: 2048) 가 합쳐 수 초가 걸려, 사용자는 첫 메시지 send 직후 빈 화면을 본다. 본 이슈는 그 비용을 사용자 입력 전(ChatScreen mount 시점) 으로 이동시킨다.

2. 범위 (Scope)

  • 포함:
    • ChatScreen mount 시 백그라운드 llm.load() 트리거.
    • 로드 상태(idle/loading/ready/failed/unavailable) 노출 + 입력창 binding.
    • GemmaLlmService.load() / MockLlmService.load() 의 concurrent-call 가드.
    • ModelLifecycle.quickCheck() — SHA-256 해싱 없이 ready 여부 추정 (warmup gate 전용).
    • Widget 테스트 (loading 라벨 노출 → 완료 → 사라짐).
  • 제외 (out of scope):
    • HabitCreateScreen 의 AI 제안 (frame suggestion) warm-up — 동일 패턴 필요 시 별도 후속 이슈.
    • 다운로드 자체 진행률 UI — 이미 SettingsScreen 에 존재 (#218).
    • #219 idle auto-unload 구현 — 본 이슈는 entry point 만 정의.
    • 추론 자체 (KV-cache warm 등) 의 추가 최적화 — load() 호출까지만.

3. 인수조건 (Acceptance Criteria)

Planner AC 8개 + UX-Reviewer 신규 AC 4개 (UX-REVIEW.md 흡수). QA 가 이걸로 판정.

  • AC1 ChatScreen mount → background llm.load() 시작. 이미 isLoaded 면 no-op.
  • AC2 ModelLifecycle.quickCheck() != ready 일 때 warmup 시도하지 않음. 다운로드 미완 / opt-out / corrupt 상태에서 spurious load 방지.
  • AC3 로드 진행 중 입력창은 enabled: true 유지 (사용자가 메시지를 미리 작성할 수 있음). send 버튼만 disabled + CircularProgressIndicator(strokeWidth:2). 입력창 hintText"AI 준비 중… 첫 시작은 몇 초 걸려요" 로 교체. ← UX R1+R3 흡수.
  • AC4 로드 완료 시 send 버튼 활성, hintText 가 평상시 "습관 추가, 기록, 카탈로그 질문…" 로 복귀. 첫 send 가 cold load 비포함 수준 latency 로 응답.
  • AC5 로드 실패 시 기존 chat_screen error container 재사용. 메시지는 상태만 기술, 행동은 별도 OutlinedButton('다시 시도') 가 담당 (error container 내부, 우측 정렬). ← UX R5+R6 흡수.
  • AC6 Warmup 진행 중 사용자가 ChatScreen 을 떠나도 race / leak 없음. autoDispose StateNotifier + _disposed 가드.
  • AC7 ChatSessionController.userTurn 의 lazy load 와 백그라운드 warmup 이 동시 호출되어도 안전. GemmaLlmService._loadingFuture 가드로 중복 native init 차단.
  • AC8 Widget 테스트:
    • delay mock → spinner + hintText 교체 노출 → 완료 후 send 활성 + hintText 복귀.
    • error mock → error container + [다시 시도] 버튼 노출 + 클릭 시 retry.
    • quickCheck() = missing → warmup 시도 안 함 + UI 변화 없음 (첫 send 시 기존 lazy 경로).
  • AC9 (UX R1+R2) Warmup 중 입력창은 타이핑 가능. send 만 disabled.
  • AC10 (UX R2) Warmup ready 전이 시점에 입력창에 비어있지 않은 텍스트가 있으면 send 자동 활성화. 자동 send 는 X.
  • AC11 (UX R4) isLoaded=true 인 재진입 시 Loading state 가 1 frame 이라도 노출되지 않음 (위젯 테스트로 verify).
  • AC12 (UX R5) 실패 메시지 본문에 "다시 시도해주세요" 같은 명령형 문구 금지. 행동은 버튼이 담당.

4. 컨텍스트 & 제약

  • 의존성:
    • LlmService (load/isLoaded/startChat) — 기존 인터페이스 유지.
    • ModelLifecycle (checkAvailability 기존, quickCheck 신규) — meta_kv DAO 의존.
    • Riverpod (StateNotifier, FutureProvider, autoDispose).
  • 제약:
    • flutter_gemma 0.16.5FlutterGemma.initialize 는 isolate 당 1회 (_initialized 가드 있음). installModel + getActiveModel 은 idempotent 가 아니다 — 두 번째 호출 시 동작 미정의. concurrent load 가드 필수.
    • ModelLifecycle.checkAvailability() 가 SHA-256 ~2.4GB 해싱을 포함. ChatScreen mount 마다 호출하면 비용 과대. quickCheck 분리.
    • autoDispose StateNotifier 라이프사이클: ChatScreen pop 시 dispose 호출, 진행 중 future 가 unmounted state 변경 시도하면 안 됨.
  • 가정:
    • GemmaLlmService.load() 비용 ≈ native init + mmap + getActiveModel. KV-cache warm 은 첫 inference 시 발생 (별도 트랙). → R3 해소.
    • 사용자가 SettingsScreen 에서 모델을 ready 상태로 만든 적이 있다 (meta_kv 의 ai_model_sha256 가 채워져 있다). 그렇지 않으면 quickCheck=missing → warmup skip → 기존 lazy 경로 fallback.

5. 아키텍처 개요

  • 모듈/파일:

    • 신규: app/lib/state/chat_warmup_provider.dartChatWarmupController (StateNotifier) + chatWarmupProvider.
    • 수정: app/lib/data/ai/llm_service.dartMockLlmService._loadingFuture 가드 추가.
    • 수정: app/lib/data/ai/gemma_llm_service.dart_loadingFuture 가드 추가.
    • 수정: app/lib/data/ai/model_lifecycle.dartquickCheck() 메서드 추가.
    • 수정: app/lib/ui/screens/chat_screen.dartinitState 에서 warmup 트리거, body 에 상태 binding.
  • 데이터 흐름:

ChatScreen.initState
   └─> ref.read(chatWarmupProvider.notifier).start()
          ├─> meta_kv 의 ai_opt_in / ai_model_path / ai_model_sha256 조회 (ModelLifecycle.quickCheck)
          │     ├─> ready 아님    → state = unavailable, 종료 (기존 lazy 경로 fallback)
          │     └─> ready
          ├─> state = loading
          ├─> llm.load()  ── concurrent 가드 (_loadingFuture 공유) ──┐
          │                                                          ↓
          │                                          ChatSessionController.userTurn 의 llm.load() 호출 시 동일 future 반환
          ├─> 성공 → state = ready
          └─> 실패 → state = failed(message)

ChatScreen.build (Consumer)
   ├─> warmup.state == loading
   │     ├─ 입력창: enabled:true, hintText="AI 준비 중… 첫 시작은 몇 초 걸려요"
   │     └─ send: disabled + spinner
   ├─> warmup.state == failed
   │     ├─ error container: 상태 메시지만
   │     └─ [다시 시도] OutlinedButton (container 내부, 우측 정렬) → controller.retry()
   ├─> warmup.state == ready
   │     ├─ 입력창: hintText="습관 추가, 기록, 카탈로그 질문…" (평상)
   │     └─ send: 텍스트 비어있지 않으면 즉시 활성 (AC10)
   ├─> warmup.state == unavailable → 정상 입력창 (warmup 라벨 X, 첫 send 시 lazy 경로)
   └─> warmup.state == idle (lifecycle race) → 정상 입력창 (안전 기본값)
  • I/O ↔ 순수 로직 경계:
    • I/O: ModelLifecycle.quickCheck() (meta_kv read + file existsSync), LlmService.load() (native init).
    • 순수: ChatWarmupController 의 state 전이는 의존성 주입된 함수만 호출 — 단위 테스트로 모든 분기 검증.

6. 데이터 모델

ChatWarmupState (sealed, chat_warmup_provider.dart)

sealed class ChatWarmupState { const ChatWarmupState(); }
final class ChatWarmupIdle extends ChatWarmupState { const ChatWarmupIdle(); }
final class ChatWarmupLoading extends ChatWarmupState { const ChatWarmupLoading(); }
final class ChatWarmupReady extends ChatWarmupState { const ChatWarmupReady(); }
final class ChatWarmupFailed extends ChatWarmupState {
  final String message;
  const ChatWarmupFailed(this.message);
}
/// quickCheck != ready — warmup 자체를 시도하지 않은 상태.
/// UI 는 정상 입력창 표시 (첫 send 시 기존 lazy 경로).
final class ChatWarmupUnavailable extends ChatWarmupState { const ChatWarmupUnavailable(); }

ModelLifecycle.quickCheck() 반환 타입

  • 재사용: 기존 ModelAvailability enum (ready / missing / corrupt / downloading).
  • 차이: SHA-256 재해싱을 건너뛴다. 파일 존재 + meta_kv 의 ai_model_path / ai_model_sha256 가 모두 채워져 있으면 ready 로 간주. 손상 감지는 checkAvailability() (cold 경로) 에 위임.

입력창 binding (UX 흡수)

  • chat_screen.dart 의 TextField enabled = state.isStreaming == false.
    • warmupState 와 무관 — 사용자가 warmup 중에도 메시지를 미리 작성 가능 (UX R1).
  • TextField hintText:
    • warmupState is ChatWarmupLoading"AI 준비 중… 첫 시작은 몇 초 걸려요"
    • 그 외 → "습관 추가, 기록, 카탈로그 질문…"
  • send 버튼: state.isStreaming || warmupState is ChatWarmupLoading || textIsEmpty 면 disabled. Loading 일 때는 spinner 표시.
  • send 자동 활성 (AC10): warmup 가 ready 로 전이될 때 입력창 텍스트가 비어있지 않으면 send 가 자동으로 enabled 로 바뀜 (텍스트 controller listener 가 이미 처리하므로 별도 코드 거의 없음).

마이크로카피 사전 (UX-Reviewer 채택본)

상태 한국어 라벨 위치
warmup loading hintText: AI 준비 중… 첫 시작은 몇 초 걸려요 입력창
warmup ready hintText: 습관 추가, 기록, 카탈로그 질문… 입력창 (기존 유지)
warmup unavailable (라벨 변경 없음)
warmup failed (file missing) error container 본문: AI 모델 파일을 찾을 수 없어요. + [설정으로 가기] error container
warmup failed (other) error container 본문: AI 를 시작하지 못했어요. + [다시 시도] error container

7. 함수 명세 (Function Specs)

함수 책임(1줄) 시그니처(잠정) 입력 출력 에러/실패 복잡?
ChatWarmupController.start 모델 ready 체크 → load 호출 → state 전이 Future<void> start() (deps via ctor) void failed → ChatWarmupFailed(msg) 복잡 (fn-chat_warmup_controller.md)
ChatWarmupController.retry failed/idle 에서 start 재호출 Future<void> retry() void (start 동일) 단순
ModelLifecycle.quickCheck SHA 해싱 없이 meta_kv + file existence 만으로 ready 추정 Future<ModelAvailability> quickCheck() (this.meta) ModelAvailability DB 예외 → corrupt (보수적) 단순
GemmaLlmService.load (수정) concurrent 호출 시 같은 Future 반환 Future<void> load() void (기존 동일) 복잡 (fn-concurrent_load_guard.md)
MockLlmService.load (수정) 동일한 concurrent 가드 적용 (테스트 일관성) Future<void> load() void (기존 동일) 단순
_ChatScreenState.initState (수정) mount 시 chatWarmupProvider.notifier.start() 호출 void initState() void (controller 가 흡수) 단순
_ChatScreenState._buildInputRow (신규 추출) warmup 상태 ↔ TextField/send 버튼 binding Widget _buildInputRow(ChatWarmupState, ChatSessionState) states Widget 단순

복잡 함수 2 개 → 개별 fn-*.md. 단순 함수는 본 표로 충분.

8. 흐름 / 알고리즘

Happy path (사용자가 SettingsScreen 에서 옵트인 + 다운로드 완료한 상태)

  1. 사용자가 HabitListScreen AppBar 의 🤖 탭 → ChatScreen push.
  2. initStatechatWarmupProvider.notifier.start().
  3. quickCheck() 반환 ready → state = ChatWarmupLoading.
  4. UI rebuild → 입력창 자리에 "AI 준비 중…" + spinner. send 버튼 영역에는 작은 spinner.
  5. llm.load() 백그라운드 진행 (수 초). 그동안 사용자는 메시지 입력 불가능.
  6. load() 성공 → state = ChatWarmupReady → UI rebuild → 정상 입력창.
  7. 사용자 send → ChatSessionController.userTurn 내부 llm.load()isLoaded 체크로 즉시 통과 → 곧바로 inference.

quickCheck 가 ready 가 아닌 경우 (다운로드 미완 / opt-out / first-run)

  1. quickCheck() 반환 missing/downloading/corrupt → state = ChatWarmupUnavailable.
  2. UI 는 정상 입력창 (warmup 라벨 X). 사용자 send 시 기존 lazy userTurn 경로 → llm.load() 가 호출되면 어차피 FileSystemException('model file missing') 등으로 실패 → 기존 chat_screen error container 에 표시.
  3. 즉, warmup 은 "사용자가 이미 옵트인+다운로드 완료한 케이스" 만 최적화. 다른 케이스는 기존 동작 유지 (변화 없음).

Concurrent load

  1. ChatScreen mount → warmup → llm.load() (Future A 진행 중).
  2. (race) 사용자가 매우 빠르게 send → userTurn 내부 llm.load() 호출.
  3. GemmaLlmService.load() 내부 _loadingFuture != null 이면 그 future 를 반환. native init 중복 X.
  4. Future A 완료 시 두 caller 모두 정상 진행.

Failure + retry

  1. load() 가 throw (예: native init 실패, 파일 권한 변경, OOM) → catch.
  2. state = ChatWarmupFailed("AI 모델 준비에 실패했어요. 다시 시도해주세요.") + 내부 error code 로깅 (사용자 노출 X).
  3. UI: error container + OutlinedButton('다시 시도'). 탭 → controller.retry().
  4. retry = 단순히 state = ChatWarmupIdle 로 reset 후 start() 재호출.

Unmount race

  1. start() 진행 중 사용자가 back 버튼 → ChatScreen.dispose() → autoDispose → controller.dispose().
  2. dispose() 에서 _disposed = true 플래그.
  3. start() 의 await llm.load() 완료 후 if (_disposed) return; 가드 → state 변경 시도 skip.

9. 엣지케이스 & 에러 처리

케이스 처리
meta_kv DB 가 lock / 손상 quickCheck catch → corrupt 반환 → state = ChatWarmupUnavailable (warmup skip). 첫 send 시 정상 에러 경로.
LlmService.load() 가 throw (FileSystemException) state = ChatWarmupFailed. 메시지: "AI 모델 파일을 찾을 수 없어요. 설정에서 다시 다운로드해주세요."
load() 가 throw (Native init 실패 — OOM / 런타임 호환성) state = ChatWarmupFailed. 메시지: "AI 시작 중 오류가 발생했어요. 잠시 후 다시 시도해주세요."
concurrent load — 두 caller 동시 호출 _loadingFuture 가드로 단일 future 공유. 두 caller 모두 완료 시점에 unblocked.
사용자가 warmup 중 ChatScreen 떠남 _disposed 가드 → state 변경 skip. 메모리는 native runtime 이 보유 (다음 진입 시 isLoaded=true → no-op).
AI opt-in 이 false 인 상태에서 ChatScreen 직접 진입 (불가능한 케이스 — 🤖 아이콘 자체가 hidden) 안전 기본값으로 quickCheck=missing → unavailable.
ChatScreen 재진입 (앞서 load 됨) isLoaded=truellm.load() 즉시 return → state = ready 빠르게 전이 (사용자 인지 어려운 수 ms). 라벨 깜빡임 방지 위해 — race 처리: state 초기값을 ChatWarmupIdle 로 두고, start() 가 quickCheck 직후 isLoaded 체크해서 이미 loaded 면 곧바로 ChatWarmupReady (Loading 단계 skip).

10. 테스트 계획

테스트 케이스 AC mapping
chat_warmup_test.dartstart() happy quickCheck=ready + load delay 100ms → state 시퀀스 [Idle → Loading → Ready] AC1, AC3, AC4
chat_warmup_test.dartstart() skip when already loaded isLoaded=true → state 시퀀스 [Idle → Ready] (Loading 없음) AC1
chat_warmup_test.dartstart() unavailable quickCheck=missing → state = Unavailable, load 호출 안 됨 AC2
chat_warmup_test.dartstart() failure load throws → state = Failed(msg) AC5
chat_warmup_test.dartretry after failure Failed → retry() → Loading → Ready AC5
chat_warmup_test.dartunmount race start() 진행 중 dispose() → state 변경 시도 skip AC6
chat_warmup_test.dartconcurrent load shares future start() + userTurn 시뮬 동시 → load 1회만 호출 AC7
model_lifecycle_test.dartquickCheck ready (신규) meta_kv 채워짐 + 파일 존재 → ready (SHA 안 함) AC2
model_lifecycle_test.dartquickCheck missing (신규) 파일 없음 → missing AC2
chat_screen_test.dartwarmup loading label (신규) delay mock → "AI 준비 중…" 라벨 + spinner 노출 AC3
chat_screen_test.dartwarmup ready hides label (신규) 완료 후 라벨 사라지고 send 활성 AC4
chat_screen_test.dartwarmup failed shows retry (신규) error mock → error container + 재시도 버튼 AC5

모킹 전략: MockLlmServiceloadDelay / loadThrows 필드 추가 (테스트 helper). ModelLifecycle 은 in-memory MetaDao + MemoryFileSystem 패턴 (기존 model_lifecycle_test.dart 의 fake storage 재사용).

11. 리스크 & 대안 검토

리스크 대안 선택 근거
R1: concurrent load race (a) controller-level coordination (b) service-level _loadingFuture guard (b) frame suggestion 등 다른 caller 도 보호. service 가 진실의 원천.
R3: load() 비용 정의 (a) load = mmap only (b) load = mmap + dummy inference (KV-cache warm) (a) 코드 확인 결과 현재 load() = native init + mmap + getActiveModel. KV-cache warm 은 첫 inference 시 발생. (b) 는 별도 트랙 (#312 이슈와 묶일 수 있음).
R4: SHA-256 재해싱 비용 (a) quickCheck 메서드 신설 (file existence + meta_kv 만) (b) checkAvailability 결과를 Riverpod 캐시 (a) (b) 는 무효화 시점 (다운로드 완료/재시작) 관리 부담. (a) 는 명시적 의도 표현 + SHA 검증은 SettingsScreen 의 cold path 에 남김.
R-extra: warmup 비용이 너무 커서 사용자가 chat 안 쓸 때도 GPU/RAM 점유 (a) ChatScreen 진입 시 warmup (이 설계) (b) HabitListScreen 🤖 hover/long-press 시 (c) opt-in tier (사용자 선택) (a) (b) 모바일 hover 없음. (c) 옵션 폭증. ChatScreen 진입 = "사용자가 곧 쓸 의도 명시" 의 가장 강한 신호.

ADR 분리 안 함: 모든 결정이 backward-compatible 추가. LlmService 인터페이스 변경 없음, ModelLifecycle.quickCheck 도 추가 메서드. 되돌리기 어렵지 않음.

12. 미해결 질문 (Open Questions)

UX-Reviewer 패스로 모두 해소. 본 섹션은 의도적으로 비어 있음.

  • OQ-1 microcopy 결정 → UX R3 채택, 마이크로카피 사전 §6 으로 이관.
  • OQ-2 재시도 버튼 위치 → UX R6 채택, error container 내부 우측 정렬.
  • OQ-3 재진입 깜빡임 → UX R4 endorse, fn-spec 의 빠른 경로로 Loading skip. min display time 같은 인위 지연은 금지 (안티패턴).

13. UX 리뷰 흡수 노트

  • 본 설계서는 UX-REVIEW.md 의 Strong 4건 (R1, R2, R4, R5) 모두 채택, Suggest 2건 (R3, R6) 채택.
  • 신규 AC4건 (AC9-AC12) 통합.
  • 마이크로카피 사전 §6 으로 이관.
  • 다음 페르소나 (03-Developer) 는 README 만 보면 충분. UX-REVIEW.md 는 결정 과정의 기록 으로 보존.

14. 참조

  • Planner 산출물: Redmine #311 ## [AI] Planner 섹션.
  • 관련 follow-up: #219 (idle auto-unload), #220 (purge try/catch — 본 이슈의 concurrent load guard 와 동일 정신).
  • 기존 설계: docs/design/218-gemma-real-integration/, docs/design/260-gemma-tool-calling/.