Files
life-helper/docs/design/218-gemma-real-integration/fn-gemma_llm_service.md
joungmin a1f3c5f85d [Architect] #218 Real Gemma 4 + flutter_gemma 0.16.5 design spec
- docs/design/218-gemma-real-integration/README.md (Draft) — 12 섹션 + AC 10
- docs/design/218-gemma-real-integration/fn-gemma_llm_service.md (Draft) — load/generateStructured/_appendSchemaInstruction/_collectFunctionCall 4 함수 명세
- 모델: Gemma 4 E2B QAT 모바일 (HF litert-community)
- flutter_gemma 0.16.5 + ModelType.gemma4 native function calling 확인
- 신규 ADR 발행 안 함 (ADR-0003 결정 #3 유지)
- 변경 범위: gemma_llm_service.dart 본문 교체, _kModelUrl/Sha 상수 치환, main.dart 조건부 override, AndroidManifest + ProGuard
- out of scope: #219 F1 / #220 F2 광범위 / #221 AC10 / #222 keystore

Refs #218
2026-06-12 14:54:28 +09:00

18 KiB
Raw Blame History

함수 설계서: GemmaLlmService.load + generateStructured + 보조 (#218)

부모 설계서: ./README.md · 상태: Draft 작성: [AI] Architect (2026-06-12) · 구현 대상: app/lib/data/ai/gemma_llm_service.dart · 테스트: app/test/data/ai/gemma_llm_service_test.dart

이 문서는 GemmaLlmService 가 노출하는 2 개 public 메서드 + 2 개 file-private 헬퍼를 한 묶음으로 설계한다. 모두 flutter_gemma 0.16.5 의 native 경계를 다루므로 한 문서에서 다루는 게 응집도 측면에서 옳다.

# 함수 가시성
§A GemmaLlmService.load() public
§B GemmaLlmService.generateStructured(prompt, schema) public
§C _appendSchemaInstruction(prompt, schema) file-private (@visibleForTesting)
§D _collectFunctionCall(stream, expectedName) file-private (@visibleForTesting)

unload() 는 단순 (await _model?.close(); _loaded = false;) 이므로 별도 섹션 없음.


§A. GemmaLlmService.load()

1. 시그니처

@override
Future<void> load();

2. 책임 (단일 책임, 1줄)

디스크의 modelPath 모델 파일을 flutter_gemma native runtime 으로 메모리 적재하고 _loaded = true 로 표시한다.

3. 입력

파라미터 타입 제약/검증 설명
(instance field) modelPath String 절대 경로, File(path).existsSync() == true 가정 생성자에서 주입. ModelLifecycle 가 다운로드 + SHA 검증 완료 시점에만 유효
(top-level const) _hfToken String String.fromEnvironment('HF_TOKEN', defaultValue: ''). 빈 문자열도 허용 (이미 다운로드 완료된 모델은 토큰 불필요할 수 있음) 빌드 시 --dart-define=HF_TOKEN=hf_xxx 으로 주입

4. 출력

  • 반환: Future<void>.
  • 부수효과:
    • FlutterGemma.initialize(huggingFaceToken: _hfToken) — top-level _initialized 가드로 1회만.
    • FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install() — flutter_gemma 의 active model 슬롯에 모델 등록.
    • 인스턴스 필드 _modelFlutterGemma.getActiveModel(maxTokens: 2048) 결과 저장.
    • 인스턴스 필드 _loaded = true.

5. 동작 / 알고리즘

  1. if (_loaded) return; — idempotent.
  2. if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);
  3. top-level guard: if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; }
  4. await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();
  5. _model = await FlutterGemma.getActiveModel(maxTokens: 2048);
  6. _loaded = true;
  7. (no try/catch here — 모든 예외 caller 에 그대로 전파. suggestFrame 의 outer catch 가 graceful 처리)

6. 에러 & 실패 모드

조건 처리 반환/예외
modelPath 의 파일 부재 early throw FileSystemException
_hfToken 빈 문자열인데 flutter_gemma 가 토큰 요구 flutter_gemma 의 throw 그대로 Exception (OQ-B 에서 정확 타입 확정)
MediaPipe / LiteRT native OOM native exception → Dart 변환 Exception / PlatformException
installModel 중간에 disk 권한 에러 flutter_gemma 의 throw 그대로 FileSystemException
getActiveModelnull (모델 등록 실패) guard → throw StateError('active model missing after install')

7. 엣지케이스

  • 두 번째 호출: _loaded == true → 즉시 return. 같은 GemmaLlmService 인스턴스에서 unload()load() 재호출은 정상 동작 (top-level _initialized 는 유지, install 만 재실행).
  • 다른 인스턴스에서 이미 active model 있음: flutter_gemma 0.16.5 의 installModel 이 active slot 교체 — 우리는 단일 인스턴스 가정이라 무영향.
  • modelPath 가 .litertlm 인데 ModelType.gemma4 와 불일치: 형식 자동 감지 (확장자 기반). 실패 시 throw.
  • 앱 background → foreground 사이클: _model 핸들 유지. native runtime 이 OS 에 의해 강제 종료된 경우 첫 inference 호출에서 에러 → caller 가 unload() + load() retry 결정 (v1 은 retry 없음, graceful 빈 리스트).

8. 복잡도 / 성능

  • 시간: cold start 13 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시.
  • 공간: peak RAM ≈ 1.5GB (Gemma 4 E2B Q4_0). QAT 1GB 변종 채택 시 ≈ 1GB.
  • 호출 빈도: 사용자 1 세션 당 01 회 (#219 F1 의 60s idle unload 가 들어오면 다회 가능).

9. 의존성

  • package:flutter_gemma/flutter_gemma.dart (^0.16.5)
  • dart:io (File)
  • String.fromEnvironment('HF_TOKEN') (build-time inject)
  • ModelLifecycle (직접 import 안 함 — _loaded 보장 책임만 caller 에 위임)

10. 테스트 케이스

flutter_gemma native 직접 호출은 단위 테스트에서 모킹 불가능 (final class 가능성). 본 함수는 E2E (실 단말, AC-7) 로만 검증. 단위 테스트는 §C / §D 에 집중.

  • [E2E] modelPath 가 실 모델 → _loaded == true + 후속 generateStructured 1회 성공.
  • [unit] modelPath 가 미존재 파일 → FileSystemException (File.exists() 만 검증, flutter_gemma 미진입).
  • [unit] 두 번 호출 → 두 번째는 noop (counter 증가 X).

11. 추적성

  • 인수조건: #218 AC-1 (build 성공) + AC-6 (cold start 3s 이내) + AC-9 (OOM graceful).
  • 관련 ADR: ADR-0003 (on-device LLM Gemma, 결정 #2 — E2B 단일).

§B. GemmaLlmService.generateStructured(prompt, schema)

1. 시그니처

@override
Future<Map<String, dynamic>> generateStructured(
  String prompt,
  Map<String, dynamic> schema,
);

2. 책임 (단일 책임, 1줄)

loaded 상태의 Gemma 4 모델에 prompt + JSON Schema 를 전달하여 단일 function call 응답 (args: Map) 을 받아 반환한다.

3. 입력

파라미터 타입 제약/검증 설명
prompt String non-empty. caller 가 .length ≤ 4096 보장 (#215 buildFewShotPrompt). 시스템 prompt + few-shot + 사용자 raw text
schema Map<String, dynamic> schema['name']: String, schema['parameters']: Map 필수. 본 설계에선 항상 kFrameCandidatesSchema 고정. function calling JSON Schema (#215 §6)

4. 출력

  • 반환: Future<Map<String, dynamic>>FunctionCallResponse.args 그대로. kFrameCandidatesSchema 기준이면 { "candidates": [...] } 구조.
  • 부수효과:
    • flutter_gemma chat session 1개 생성 후 chat.close() 으로 정리.
    • 모델 latent state 변경 (다음 호출은 fresh chat).
    • log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시).

5. 동작 / 알고리즘

1. if (!_loaded) throw StateError('LlmService not loaded');
2. final augmented = _appendSchemaInstruction(prompt, schema);
3. final chat = await _model!.createChat();
4. try {
5.   await chat.addQueryChunk(Message.text(text: augmented, isUser: true));
6.   final stream = chat.generateChatResponseAsync();
7.   final fnName = schema['name'] as String;
8.   final args = await _collectFunctionCall(stream, fnName);
9.   return args;
10. } finally {
11.   await chat.close();   // 항상 정리
12. }

caller (#215 suggestFrame) 가 .timeout(Duration(seconds: 10)) 적용 → timeout 시 본 함수의 await 가 throw 됨 → finally 의 chat.close() 가 실행되어 native session leak 방지.

6. 에러 & 실패 모드

조건 처리 반환/예외
_loaded == false early throw StateError
schema['name'] 또는 schema['parameters'] 누락 _appendSchemaInstruction 가 throw ArgumentError
stream 이 FunctionCallResponse emit 안 함 (Text 만, 또는 empty) _collectFunctionCall 가 throw FormatException('no function call emitted')
다른 name 의 FCR _collectFunctionCall 가 throw FormatException('unexpected function: ${actualName}')
stream 자체 error event catch → throw (본문은 log 안 함, name 만) FormatException('stream error')
caller timeout finally 에서 close, exception 전파 (caller 의 TimeoutException)
native runtime crash flutter_gemma 가 PlatformException 그대로 전파

7. 엣지케이스

  • 첫 token 이 Thinking → Text → FCR 순서: §D 가 첫 FCR 만 채택, 나머지 skip.
  • FCR 두 번 emit: 첫 번째 채택 후 break — stream 미소진 채 chat.close() 호출. flutter_gemma 가 graceful 처리 가정 (OQ).
  • argsnull: §D 에서 검사, throw FormatException('null args').
  • args['candidates'] 가 Map 으로 옴 (List 아님): 본 함수 책임 밖. caller 의 parseFrameCandidates (#215) 가 FormatException 으로 처리.
  • prompt UTF-8 길이 vs token 길이 불일치: caller 책임. 본 함수는 prompt 길이 검증 X.

8. 복잡도 / 성능

  • 시간: warm 0.52초 / cold (load 직후) 추가 13초. function calling 1턴이라 stream 길이 짧음 (~200 token).
  • 공간: chat 인스턴스 ~ 수십 MB (KV cache). close 시 회수.
  • 호출 빈도: habit 생성 화면 진입 시 사용자 trigger. throttle 5회/세션 (#215).

9. 의존성

  • flutter_gemma: FlutterGemma.getActiveModel 결과의 createChat / Message.text / ModelResponse.
  • _appendSchemaInstruction (§C)
  • _collectFunctionCall (§D)

10. 테스트 케이스

  • [unit] _loaded = falseStateError. (직접 검증)
  • [E2E] AC-7 — 실 단말에서 prompt + kFrameCandidatesSchemaargs['candidates'] 3개 반환.
  • [unit] caller timeout 시 finally close 호출 확인 — 간접 (_collectFunctionCall 가 await never-completing future 일 때 외부 timeout → exception 후 chat.close mock 카운터).

11. 추적성

  • 인수조건: #218 AC-6 (latency), AC-7 (E2E candidates), AC-9 (graceful).
  • 관련 ADR: ADR-0003 결정 #3 (function calling).

§C. _appendSchemaInstruction(prompt, schema)

1. 시그니처

@visibleForTesting
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);

파일 내부에선 _appendSchemaInstruction 으로 호출, 테스트는 public appendSchemaInstruction 으로 re-export.

2. 책임 (단일 책임, 1줄)

prompt 본문 끝에 Gemma 4 chat template 이 인식할 function call 안내 (name + JSON Schema) 를 append 한다.

3. 입력

파라미터 타입 제약/검증 설명
prompt String non-empty 시스템 + few-shot + 사용자 입력
schema Map<String, dynamic> schema['name']: String, schema['parameters']: Map 필수 function calling schema

4. 출력

  • 반환: Stringprompt + '\n\n' + 안내문 형태.
  • 부수효과: 순수 함수.

5. 동작 / 알고리즘

1. final name = schema['name'];
2. if (name is! String || name.isEmpty) throw ArgumentError('schema.name missing');
3. final params = schema['parameters'];
4. if (params is! Map) throw ArgumentError('schema.parameters missing');
5. final description = schema['description'] as String? ?? '';
6. final paramsJson = const JsonEncoder().convert(params);
7. final block = [
     '',
     '',
     '## Function call instruction',
     'You MUST respond by calling the function `$name`.',
     if (description.isNotEmpty) description,
     'Arguments must conform to this JSON Schema:',
     '```json',
     paramsJson,
     '```',
   ].join('\n');
8. return prompt + block;

순수 함수라 deterministic. 같은 입력에 대해 항상 같은 출력.

6. 에러 & 실패 모드

조건 처리 반환/예외
schema['name'] 누락/빈 문자열 throw ArgumentError('schema.name missing')
schema['parameters'] 가 Map 아님 throw ArgumentError('schema.parameters missing')
prompt 가 빈 문자열 허용 (append 만) OK

7. 엣지케이스

  • params 가 빈 Map → {} JSON 으로 직렬화. caller 가 의도한 경우면 OK (본 설계엔 발생 안 함).
  • description 누락 → 해당 라인 생략.
  • prompt 끝에 이미 \n\n 있음 → 결과 \n\n\n\n. Gemma 4 tokenizer 가 무시.

8. 복잡도 / 성능

  • O(N) — JsonEncoder 가 schema 깊이에 비례. kFrameCandidatesSchema 는 작아서 < 1ms.

9. 의존성

  • dart:convert (JsonEncoder).
  • package:flutter/foundation.dart (@visibleForTesting).

10. 테스트 케이스

  • [unit] kFrameCandidatesSchema 입력 → 반환 string 에 'emit_frame_candidates''\"L2\"' / '\"L3\"' 포함.
  • [unit] schema['name'] 없음 → ArgumentError.
  • [unit] schema['parameters']ListArgumentError.
  • [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증).
  • [unit] prompt 끝 trim 없이 그대로 append 되는지 — exact string compare.

11. 추적성

  • 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요).
  • 관련 ADR: ADR-0003 결정 #3.

§D. _collectFunctionCall(stream, expectedName)

1. 시그니처

@visibleForTesting
Future<Map<String, dynamic>> collectFunctionCall(
  Stream<ModelResponse> stream,
  String expectedName,
);

2. 책임 (단일 책임, 1줄)

Stream<ModelResponse> 에서 FunctionCallResponse(name == expectedName)args 만 추출한다. TextResponse/ThinkingResponse 는 skip, 잘못된 name 은 throw.

3. 입력

파라미터 타입 제약/검증 설명
stream Stream<ModelResponse> flutter_gemma 의 union 타입. TextResponse | FunctionCallResponse | ThinkingResponse 가정 chat.generateChatResponseAsync() 결과
expectedName String non-empty 매칭할 function name (e.g. 'emit_frame_candidates')

4. 출력

  • 반환: Future<Map<String, dynamic>> — 첫 매칭 FCR 의 args.
  • 부수효과: 없음 — stream 의 첫 매칭 이벤트 후 await for 빠져나옴 (cancelSubscription 자동).

5. 동작 / 알고리즘

1. Map<String, dynamic>? result;
2. String? wrongName;
3. try {
4.   await for (final event in stream) {
5.     if (event is FunctionCallResponse) {
6.       if (event.name == expectedName) {
7.         result = Map<String, dynamic>.from(event.args ?? const {});
8.         break;
9.       } else {
10.        wrongName = event.name;
11.        break;     // 잘못된 함수 — 빠른 실패
12.      }
13.    }
14.    // TextResponse / ThinkingResponse 는 무시 (continue)
15.  }
16. } catch (e) {
17.   throw FormatException('stream error');   // e.toString() 폐기 (prompt 누설 방지)
18. }
19. if (wrongName != null) {
20.   throw FormatException('unexpected function: $wrongName');
21. }
22. if (result == null) {
23.   throw FormatException('no function call emitted');
24. }
25. return result;

event.argsnull 이면 빈 Map 으로 대체 → caller 의 parseFrameCandidates 가 빈 candidates 로 처리하여 빈 리스트 반환.

6. 에러 & 실패 모드

조건 처리 반환/예외
stream done 까지 FCR 없음 check after loop FormatException('no function call emitted')
다른 name 의 FCR break + check FormatException('unexpected function: ...')
stream error event (native crash 등) catch FormatException('stream error') (원본 e 폐기 — 본문 누설 X)
event.args == null 빈 Map 으로 대체 후 return (no throw)

7. 엣지케이스

  • 첫 이벤트가 곧바로 FCR: 정상. Text/Thinking 없이 바로 break.
  • Text + Text + FCR + FCR (두 번째 FCR 이 정답 name): 첫 FCR 의 name 검증으로 break — wrongName 으로 throw. v1 정책: 첫 FCR 만 신뢰. (Gemma 4 가 다중 FCR 보내는 경우 거의 없음. 발생 시 prompt 개선 신호.)
  • Thinking → FCR 순서: Thinking skip 후 FCR 채택. OK.
  • stream 이 무한 (timeout 없음): caller 의 .timeout(10s) 에 의존. 본 함수는 자체 timeout X.
  • event 가 null (Dart stream 에 null event): await for 에서 false-match → skip. (실제로는 발생 안 함, 방어 안 함.)

8. 복잡도 / 성능

  • O(N) — N = stream 이벤트 수. function calling 응답은 보통 ≤ 10 events. ~수십 ms.

9. 의존성

  • package:flutter_gemma/flutter_gemma.dartModelResponse / FunctionCallResponse / TextResponse / ThinkingResponse 타입.

10. 테스트 케이스

핵심 단위 테스트 슬롯. flutter_gemma response 클래스를 Stream.fromIterable([...]) 로 fake 주입 가능.

  • [unit] [FunctionCallResponse('emit_frame_candidates', {'candidates': [...3개...]})]args 반환.
  • [unit] [TextResponse('hello'), FunctionCallResponse('emit_frame_candidates', {...})] → Text skip 후 args 반환.
  • [unit] [ThinkingResponse('...'), TextResponse('...'), FunctionCallResponse('emit_frame_candidates', {})] → 빈 args Map 반환 (no throw).
  • [unit] [FunctionCallResponse('wrong_name', {})]FormatException('unexpected function: wrong_name').
  • [unit] [TextResponse('only text')] (FCR 없이 done) → FormatException('no function call emitted').
  • [unit] Stream.error(...) event → FormatException('stream error') (원본 메시지 미포함 검증).
  • [unit] [FunctionCallResponse('emit_frame_candidates', null)] → 빈 Map 반환 ({}), no throw.
  • [unit] [] 빈 stream → FormatException('no function call emitted').

11. 추적성

  • 인수조건: #218 AC-7 (FCR 수집 성공), AC-9 (graceful — FormatException 이 caller 의 빈 리스트 반환으로 전환).
  • 관련 ADR: ADR-0003 결정 #3.

부록: 자가 점검

  • §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움.
  • §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버.
  • §C 순수성 강조 — _appendSchemaInstruction 은 외부 I/O 0, deterministic.
  • 프라이버시: §B 와 §D 모두 catch 시 e.toString() 폐기 (prompt 본문 누설 방지).
  • timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 .timeout(10s) 에 의존. finally close 로 native session leak 방지.
  • @visibleForTesting 으로 file-private 함수도 단위 테스트 가능.
  • AC-7 의 E2E 부분은 §A load + §B generateStructured 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.