- 설계서 218-gemma-real-integration/README.md → Approved + AC 체크박스 채움 + 실제 구현/테스트 파일 경로 추적성 갱신
- fn-gemma_llm_service.md → Approved (v2)
- reference/215-ai-frame-suggest.md → v0.3.0 (commit da60dd1 핀)
- guides/ai-help-onboarding.md → 적용 버전 v0.3.0 + RAM 4GB 요구사항 명시
- docs/README.md 인덱스 v0.3.0 표기
AC-7 (실 단말 E2E) 만 DEFER — 사용자 실기 검증 결과로 별도 갱신.
Refs #218
20 KiB
함수 설계서: GemmaLlmService.load + generateStructured + 보조 (#218)
부모 설계서: ./README.md · 상태: Approved (v2, 2026-06-12 — Reviewer
1b90f58, Release v0.3.0) 작성: [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:...<tool|>토큰을 직접 렌더한다 (lib/core/chat.dart:94). - 따라서 §C
_appendSchemaInstruction는 Gemma 4 에선 double-wrap 을 유발한다. v2 에선 §C 제거, §B 는Tool객체를createChat에 전달하는 방식으로 변경. - §D
_collectFunctionCall는 변경 없음 — 여전히Stream<ModelResponse>에서 첫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. 시그니처
@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 슬롯에 모델 등록.- 인스턴스 필드
_model에FlutterGemma.getActiveModel(maxTokens: 2048)결과 저장. - 인스턴스 필드
_loaded = true.
5. 동작 / 알고리즘
if (_loaded) return;— idempotent.if (!await File(modelPath).exists()) throw FileSystemException('model file missing', modelPath);- top-level guard:
if (!_initialized) { await FlutterGemma.initialize(huggingFaceToken: _hfToken); _initialized = true; } await FlutterGemma.installModel(modelType: ModelType.gemma4).fromFile(modelPath).install();_model = await FlutterGemma.getActiveModel(maxTokens: 2048);_loaded = true;- (no
try/catchhere — 모든 예외 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+ 후속generateStructured1회 성공. - [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 — 프라이버시).
- flutter_gemma chat session 1개 생성 후
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<String, dynamic>;
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 에서 검사, throwFormatException('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. 시그니처
@visibleForTesting
String appendSchemaInstruction(String prompt, Map<String, dynamic> schema);
파일 내부에선
_appendSchemaInstruction으로 호출, 테스트는 publicappendSchemaInstruction으로 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. 출력
- 반환:
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. 시그니처
@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.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.
부록: 자가 점검
- §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+ §BgenerateStructured에서만 검증, §C/§D 는 단위 테스트로 100% 커버.