앱에 이미 쌓아둔 데이터를 먼저 쓰고, 거기서 답이 안 나올 때만 LLM을 부르는 하이브리드 채팅을 Flutter로 만든 과정입니다.
지금은 혼자 보니까 무료 API(Groq)만 갖고 돌아갑니다.
개인적으로 키우는 도마뱀과 동물들을 위해 반려동물 건강 가이드 앱을 만들고 있습니다. 앱 안에 동물 별 건강 정보를 JSON으로 넣어뒀는데, 이런 질문에는 답을 줄 수 없었습니다.
정적 위키는 "ㅁㅁ견종의 적정 체중: 5~8kg"이라고만 알려줍니다. 상황에 따른 구체적 질문에는 무력합니다.
그렇다고 LLM에게 모든 걸 맡기면? 앱 안에 검증해둔 데이터가 있는데 그걸 무시하고 LLM의 일반 지식에만 의존하게 됩니다. 할루시네이션 위험은 덤이고요.
원하는 구조는 이겁니다:
사용자 질문
→ 앱 내부 데이터에서 관련 정보 찾기
→ 있으면: 그 데이터를 LLM에게 컨텍스트로 넘겨서 답변 생성
→ 없으면: LLM 일반 지식으로 답변 + "일반 지식 기반" 표시
LLM을 "만능 답변기"로 쓰는 게 아니라, 내 데이터를 읽고 설명해주는 도우미로 쓰는 겁니다.
┌──────────────────────────────────────────┐
│ 사용자 질문 입력 │
└───────────────┬──────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 1. 질문 분석 — 무슨 주제에 대한 질문인가? │
│ 키워드 매칭으로 품종(breed) + 카테고리 감지 │
└───────────────┬──────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 2. 내부 데이터 검색 │
│ 감지된 품종 + 카테고리로 JSON 데이터 조회 │
│ → 관련 스니펫 추출 │
└───────────────┬──────────────────────────┘
│
┌───────┴───────┐
▼ ▼
데이터 있음 데이터 없음
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 시스템 프롬프트 │ │ 시스템 프롬프트 │
│ + 내부 데이터 │ │ (데이터 없이) │
│ + 질문 │ │ + 질문 │
└──────┬──────┘ └──────┬──────┘
│ │
└───────┬────────┘
▼
┌──────────────────────────────────────────┐
│ 3. LLM API 호출 (Groq, 무료) │
└───────────────┬──────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ 4. 앱 코드가 출처/경고를 강제 첨부 │
│ 데이터 있었음 → "📎 출처: PetMD..." │
│ 데이터 없었음 → "⚠️ 일반 지식 기반" │
└──────────────────────────────────────────┘
핵심은 LLM을 항상 부르되, 내부 데이터가 있으면 컨텍스트로 넘긴다는 점입니다. 내부 데이터만으로 답변을 완성하는 게 아니라, LLM이 내부 데이터를 "참고"해서 자연어 답변을 만들도록 하는 구조입니다.
| 프로바이더 | 무료 티어 | OpenAI 호환 | 한국어 |
|---|---|---|---|
| Groq | 1,000 요청/일, 카드 불필요 | ✅ | 양호 |
| DeepSeek | 크레딧제 | ✅ | 양호 |
| Gemini | 15 RPM | ❌ (자체 SDK) | 중간 |
| OpenAI | 유료 | 네이티브 | 우수 |
Groq는 가입만 하면 하루 1,000번 무료로 쓸 수 있고, OpenAI 호환 API라서 나중에 프로바이더를 바꾸고 싶으면 URL과 모델명만 교체하면 됩니다.
// llm_api_repository.dart
Future<String> sendChat(List<Map<String, String>> messages) async {
final response = await http.post(
Uri.parse('https://api.groq.com/openai/v1/chat/completions'),
headers: {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
},
body: jsonEncode({
'model': 'llama-3.3-70b-versatile',
'messages': messages,
'max_tokens': 1024,
'temperature': 0.3, // 사실 기반 답변 → 낮게
}),
);
final data = jsonDecode(response.body);
return data['choices'][0]['message']['content'];
}
OpenAI SDK 없이 http 패키지 하나로 충분합니다. temperature: 0.3은 창의적 답변보다 정확한 답변이 중요한 도메인 특화 앱에 맞는 설정입니다.
LLM에게 "이 질문이 무슨 주제야?"라고 물어볼 수도 있지만, 그러면 API를 두 번 부르게 됩니다. 대신 키워드 매칭으로 로컬에서 처리합니다.
// context_builder.dart
// 카테고리 키워드
static const _categoryKeywords = {
'joints': ['관절', '슬개골', '고관절', '디스크', '연골'],
'skin': ['피부', '알러지', '탈모', '가려움', '아토피'],
'diet': ['사료', '간식', '영양제', '칼슘', '급여량'],
'vaccination': ['백신', '접종', '예방', '항체', '부스터'],
// ...
};
Set<String> detectCategories(String question) {
final matched = <String>{};
for (final entry in _categoryKeywords.entries) {
for (final keyword in entry.value) {
if (question.contains(keyword)) {
matched.add(entry.key);
break;
}
}
}
return matched;
}
"골든리트리버 관절 영양제 뭐가 좋아?" → joints + diet 카테고리 감지. 이 결과로 내부 데이터에서 관절·영양 관련 정보만 꺼냅니다.
"관절 영양제 뭐가 좋아?" 다음에 "그러면 겨울에는?"이라고 물으면 키워드가 없습니다. 이때는 이전 대화 4개를 스캔해서 카테고리를 계승합니다.
if (categories.isEmpty) {
final history = chatRepo.getRecentMessages(conversationId, limit: 4);
for (final msg in history) {
categories = detectCategories(msg.content);
if (categories.isNotEmpty) break;
}
}
형태소 분석 없는 단순 키워드 매칭이지만, 도메인이 좁으면 이 정도로 충분합니다.
여기가 이 구조의 가치를 결정하는 부분입니다. 같은 LLM이라도 컨텍스트 품질에 따라 답변이 극적으로 달라집니다.
BuildContextResult buildContext(String question, String? breedId) {
// 1. 품종 + 카테고리 감지
final breed = detectBreed(question) ?? breedId;
final categories = detectCategories(question);
// 2. 내부 데이터에서 관련 스니펫 추출
String? healthSnippet;
List<String> sources = [];
if (breed != null && categories.isNotEmpty) {
final healthInfo = healthInfoRepo.getHealthInfo(breed);
healthSnippet = extractSnippet(healthInfo, categories);
sources = filterSources(categories); // 카테고리 관련 출처만
}
// 3. 시스템 프롬프트 조립
final systemPrompt = '''
반려동물 건강 전문 AI. 주요 견종별 건강 정보 제공.
${healthSnippet != null ? '\n[앱 데이터]\n$healthSnippet' : ''}
규칙:
- [앱 데이터]가 있으면 참고하되, "앱 데이터에 따르면" 같은 메타 언급 없이 바로 답변.
- [앱 데이터]가 없어도 일반 지식으로 성실히 답변.
- 한국어, 간결체. 출처표기 금지 — 출처는 앱이 자동으로 붙입니다.
''';
return BuildContextResult(
messages: [
{'role': 'system', 'content': systemPrompt},
...recentHistory, // 최근 6개 메시지
{'role': 'user', 'content': question},
],
hasHealthData: healthSnippet != null,
sources: sources,
);
}
포인트 세 가지:
① 내부 데이터를 시스템 프롬프트에 [앱 데이터]로 삽입합니다. LLM은 이걸 "자기가 아는 것"처럼 자연스럽게 답변에 녹여냅니다.
② hasHealthData 플래그를 반환합니다. 이 값으로 나중에 "출처 있음/없음"을 앱 코드가 결정합니다. LLM에게 판단을 맡기지 않습니다.
③ 출처도 카테고리별로 필터링합니다. 관절 질문에 백신 관련 출처를 보여주면 신뢰가 떨어집니다.
이 구조에서 가장 중요한 설계 결정입니다.
처음에는 프롬프트에 "답변 끝에 출처를 달아줘"라고 시켰습니다. 결과:
| 시도 | 프롬프트 | 결과 |
|---|---|---|
| v1 | "답변 기반이 앱 데이터인지 구분 표시해줘" | "앱 데이터에 따르면..." 장황한 서문 |
| v3 | "출처 규칙 5개항 상세 지시" | 규칙을 일관되게 안 따름 |
| v4 | "출처표기 금지 — 출처는 앱이 자동으로 붙입니다" | 해결 |
LLM은 답변 생성에만 집중시키고, 메타데이터는 앱 코드가 처리합니다:
// chat_providers.dart — AI 응답 수신 후
String finalContent = aiResponse;
if (contextResult.hasHealthData) {
final sourceText = contextResult.sources
.map((s) => '- $s')
.join('\n');
finalContent += '\n\n📎 출처:\n$sourceText';
} else {
finalContent += '\n\n⚠️ 일반 지식 기반 답변입니다';
}
이렇게 하면 출처 표시 일관성이 100%가 됩니다. LLM 재량에 맡겼을 때는 40% 정도였습니다.
사용자가 보는 화면:
📎 출처: PetMD, AKC Canine Health⚠️ 일반 지식 기반 답변입니다사용자는 이 답변이 검증된 데이터에 근거한 건지, LLM의 일반 지식인지 바로 알 수 있습니다.
여기까지면 "잘 만든 챗봇"입니다. 한 단계 더 간 부분은 대화에서 재사용 가능한 지식을 자동으로 추출·축적하는 시스템입니다.
Day 1: "강아지 피부에 빨간 반점이 생겼어" → AI 답변 → 지식으로 저장 (confidence: 0.5)
Day 3: "알러지인지 어떻게 구분해?" → 기존 지식을 컨텍스트에 추가 → 더 정확한 답변
Day 10: 비슷한 피부 질문 → 캐시 히트! API 호출 없이 즉시 응답
반복 질문에 대한 답변이 점점 좋아지고, 일정 수준 이상이면 API를 부르지 않아 한도도 아낍니다.
이 시스템의 가장 큰 위험입니다. 틀린 답변이 캐시되면 계속 틀린 답을 줍니다. 차단 장치 세 가지:
Groq 무료 티어는 하루 1,000 요청, 500K 토큰입니다. 한 번 요청에 얼마나 쓰는지 관리해야 합니다.
| 세그먼트 | ~토큰 |
|---|---|
| 시스템 프롬프트 | 200 |
| 내부 데이터 스니펫 | 400~800 |
| 대화 히스토리 (최근 6개) | 800~1,200 |
| 사용자 질문 | 50~100 |
| 응답 예산 | 1,024 |
| 합계 | ~3,000~4,000 |
Groq의 Llama 3.3 70B는 128K 컨텍스트이므로 매우 여유 있습니다. 내부 데이터 스니펫을 카테고리별로 필터링하는 이유가 여기에도 있습니다 — 전체 데이터를 때려넣으면 토큰 낭비입니다.
도메인에 종속된 부분과 아닌 부분을 분리하면 재사용할 수 있습니다.
그대로 쓸 수 있는 것:
도메인별로 바꿔야 하는 것:
인터페이스 하나로 정리하면:
abstract class ChatConfig {
String get systemPrompt;
Map<String, List<String>> get categoryKeywords;
Map<String, List<String>> get entityKeywords;
Future<({String snippet, List<String> sources})> buildDataSnippet(
String entityId, Set<String> categories);
}
이 인터페이스만 구현하면 요리 레시피 앱이든, 건강 관리 앱이든, 법률 상담 앱이든 같은 구조로 돌아갑니다.
