# 함수 설계서: `GemmaLlmService.load` + `generateStructured` + 보조 (#218) > **부모 설계서**: ./README.md · **상태**: Draft (v2) > **작성**: [AI] Architect (2026-06-12) · **구현 대상**: `app/lib/data/ai/gemma_llm_service.dart` · **테스트**: `app/test/data/ai/gemma_llm_service_test.dart` ## 변경 이력 (v2, 2026-06-12 Developer 검증 후) flutter_gemma 0.16.5 의 `InferenceChat` 구현을 직접 읽어 확인한 결과: - Gemma 4 (ModelType.gemma4) 의 function calling 은 **SDK 가 `createChat(tools: [Tool(...)])` 의 tools 목록에서 `<|tool>declaration:...` 토큰을 직접 렌더**한다 (`lib/core/chat.dart:94`). - 따라서 §C `_appendSchemaInstruction` 는 Gemma 4 에선 **double-wrap** 을 유발한다. v2 에선 **§C 제거**, §B 는 `Tool` 객체를 `createChat` 에 전달하는 방식으로 변경. - §D `_collectFunctionCall` 는 변경 없음 — 여전히 `Stream` 에서 첫 `FunctionCallResponse` 만 추출. 남은 4 함수 (§A load / §B generateStructured / §C deprecated / §D collectFunctionCall) 중 코드 대상은 3 개. 이 문서는 `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. 시그니처 ```dart @override Future 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`. - **부수효과**: - `FlutterGemma.initialize(huggingFaceToken: _hfToken)` — top-level `_initialized` 가드로 1회만. - `FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install()` — flutter_gemma 의 active model 슬롯에 모델 등록. - 인스턴스 필드 `_model` 에 `FlutterGemma.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` | | `getActiveModel` 가 `null` (모델 등록 실패) | 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 1–3 초 (모델 파일 read + native init + tokenizer load). 첫 호출 만, 이후 캐시. - **공간**: peak RAM ≈ 1.5–2GB (Gemma 4 E2B Q4 .litertlm, 가중치 ~1.3GB + KV cache + activation). disk ≈ 2.41GB. - **호출 빈도**: 사용자 1 세션 당 0–1 회 (#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. 시그니처 ```dart @override Future> generateStructured( String prompt, Map 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` | `schema['name']: String`, `schema['parameters']: Map` 필수. 본 설계에선 항상 `kFrameCandidatesSchema` 고정. | function calling JSON Schema (#215 §6) | ### 4. 출력 - **반환**: `Future>` — `FunctionCallResponse.args` 그대로. `kFrameCandidatesSchema` 기준이면 `{ "candidates": [...] }` 구조. - **부수효과**: - flutter_gemma chat session 1개 생성 후 `chat.close()` 으로 정리. - 모델 latent state 변경 (다음 호출은 fresh chat). - log: prompt length, latency, FCR 수신 여부 (prompt 본문 X — 프라이버시). ### 5. 동작 / 알고리즘 (v2) ``` 1. if (!_loaded) throw StateError('LlmService not loaded'); 2. final fnName = schema['name'] as String; 3. final fnDesc = (schema['description'] as String?) ?? ''; 4. final fnParams = schema['parameters'] as Map; 5. final tool = Tool(name: fnName, description: fnDesc, parameters: fnParams); 6. final chat = await _model!.createChat( modelType: ModelType.gemma4, supportsFunctionCalls: true, toolChoice: ToolChoice.required, // 강제 FCR tools: [tool], ); 7. try { 8. await chat.addQueryChunk(Message.text(text: prompt, isUser: true)); 9. final stream = chat.generateChatResponseAsync(); 10. final args = await _collectFunctionCall(stream, fnName); 11. return args; 12. } finally { 13. await chat.close(); // 항상 정리 14. } ``` 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). - **`args` 가 `null`**: §D 에서 검사, throw `FormatException('null args')`. - **`args['candidates']` 가 Map 으로 옴 (List 아님)**: 본 함수 책임 밖. caller 의 `parseFrameCandidates` (#215) 가 `FormatException` 으로 처리. - **prompt UTF-8 길이 vs token 길이 불일치**: caller 책임. 본 함수는 prompt 길이 검증 X. ### 8. 복잡도 / 성능 - **시간**: warm 0.5–2초 / cold (load 직후) 추가 1–3초. 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 = false` → `StateError`. (직접 검증) - [E2E] AC-7 — 실 단말에서 prompt + `kFrameCandidatesSchema` → `args['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. (DEPRECATED — v2) `_appendSchemaInstruction(prompt, schema)` > **v2 결정**: Gemma 4 SDK 가 `Tool` 객체에서 직접 declaration 토큰을 렌더하므로, prompt 측에서 schema 안내문을 덧붙이면 double-wrap 이 된다. **본 함수는 구현하지 않는다.** > > 아래 §C 본문은 v1 (gemmaIt fallback) 시나리오용 참고 자료로 보존하나, v2 코드 대상에서 제외한다. 단위 테스트도 작성하지 않는다. 원본 본문 (참고용): ### 1. 시그니처 ```dart @visibleForTesting String appendSchemaInstruction(String prompt, Map 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` | `schema['name']: String`, `schema['parameters']: Map` 필수 | function calling schema | ### 4. 출력 - **반환**: `String` — `prompt + '\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']` 가 `List` → `ArgumentError`. - [unit] 같은 입력 2회 호출 → 동일 string (순수성 검증). - [unit] `prompt` 끝 trim 없이 그대로 append 되는지 — exact string compare. ### 11. 추적성 - 인수조건: #218 AC-7 (모델이 FCR 로 응답하려면 안내문이 필요). - 관련 ADR: ADR-0003 결정 #3. --- ## §D. `_collectFunctionCall(stream, expectedName)` ### 1. 시그니처 ```dart @visibleForTesting Future> collectFunctionCall( Stream stream, String expectedName, ); ``` ### 2. 책임 (단일 책임, 1줄) `Stream` 에서 **첫 `FunctionCallResponse(name == expectedName)`** 의 `args` 만 추출한다. `TextResponse`/`ThinkingResponse` 는 skip, 잘못된 name 은 throw. ### 3. 입력 | 파라미터 | 타입 | 제약/검증 | 설명 | |----------|------|-----------|------| | `stream` | `Stream` | flutter_gemma 의 union 타입. `TextResponse \| FunctionCallResponse \| ThinkingResponse` 가정 | `chat.generateChatResponseAsync()` 결과 | | `expectedName` | `String` | non-empty | 매칭할 function name (e.g. `'emit_frame_candidates'`) | ### 4. 출력 - **반환**: `Future>` — 첫 매칭 FCR 의 `args`. - **부수효과**: 없음 — stream 의 첫 매칭 이벤트 후 `await for` 빠져나옴 (cancelSubscription 자동). ### 5. 동작 / 알고리즘 ``` 1. Map? 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.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.args` 가 `null` 이면 빈 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.dart` — `ModelResponse` / `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. --- ## 부록: 자가 점검 - [x] §A~§D 4 함수 모두 시그니처 + 책임 + 에러 + 테스트 케이스 채움. - [x] §D 테스트 케이스 8개 — flutter_gemma 의 ModelResponse 변종 (Text/Thinking/FCR/error/empty) 모두 커버. - [x] §C 순수성 강조 — `_appendSchemaInstruction` 은 외부 I/O 0, deterministic. - [x] 프라이버시: §B 와 §D 모두 catch 시 `e.toString()` 폐기 (prompt 본문 누설 방지). - [x] timeout 책임 분리: 본 모듈은 timeout 미구현, caller 의 `.timeout(10s)` 에 의존. finally close 로 native session leak 방지. - [x] `@visibleForTesting` 으로 file-private 함수도 단위 테스트 가능. - [x] AC-7 의 E2E 부분은 §A `load` + §B `generateStructured` 에서만 검증, §C/§D 는 단위 테스트로 100% 커버.