AI 할루시네이션 줄이기: 프로덕션에서 진짜 먹히는 그라운딩, RAG, 가드레일 가이드
지난 화요일에 AI 기능 배포했어요. 목요일에 유저가 스크린샷을 찍었는데, AI가 존재하지 않는 대법원 판례를 자신 있게 인용하고 있더라고요. 금요일엔 챗봇이 만들어내지도 않은 제품 기능을 설명한다고 CS 문의가 47개 쌓였어요. 월요일엔 PM이 "일단 면책 문구 하나 넣어..."라고 하고 있었고요.
익숙한 시나리오 아닌가요? 2026년 초 기준으로 개발자의 29%만이 AI 출력을 신뢰하고 있어요 — 2024년 40%에서 더 떨어졌죠. AI 생성 코드의 거의 절반이 충분한 리뷰 없이 코드베이스에 들어가고 있고요. 핵심 문제는 LLM이 고장 난 게 아니라, 고장 날 때 잡아줄 인프라 없이 배포한다는 거예요.
할루시네이션은 패치로 고치는 버그가 아니에요. LLM이 돌아가는 구조 자체에서 나오는 거거든요. 근데 그렇다고 손 놓고 있을 수는 없잖아요. LLM을 쓸 만한 수준으로 신뢰할 수 있게 만드는 엔지니어링이 지금 빠르게 발전하고 있고, "RAG 하나 달면 끝"이 아닌 훨씬 깊은 이야기예요.
이 글에서는 할루시네이션 대응의 전체 그림을 다뤄요. 모델이 왜 헛소리하는지부터, 그라운딩, RAG 파이프라인, 출력 가드레일, 프로덕션 모니터링까지. 각 기법마다 바로 복붙 가능한 TypeScript 코드도 같이 있어요.
LLM이 할루시네이션하는 이유: 엔지니어 관점 멘탈 모델
할루시네이션을 고치려면 왜 생기는지부터 알아야 해요. 논문 얘기 말고, 실무에서 언제 터질지 감 잡는 데 도움 되는 멘탈 모델이요.
자동완성 머신
LLM은 뭔가를 "알고 있는" 게 아니에요. 앞의 토큰들이 주어졌을 때 가장 그럴듯한 다음 토큰을 예측하는 거예요. GPT-5에 "프랑스의 수도는?"이라고 물으면, 사실을 찾아보는 게 아니라 학습 데이터상 그 프롬프트 뒤에 "파리"가 나올 확률이 가장 높아서 "파리"를 생성하는 거예요.
이 차이가 중요한 이유는, 할루시네이션이 언제 발생하는지를 설명해주기 때문이에요:
-
낮은 신뢰도 구간. 여러 후보가 비슷한 확률일 때, 모델은 하나를 골라요. 가끔 틀린 걸 고르고요.
-
학습 데이터 공백. 학습 데이터에 없거나 드문 정보를 만나면, 있는 걸로 대충 짜맞춰요. 기본 모드가 "모르겠다"가 아니라 "일단 그럴듯하게 만들어내기"거든요.
-
지시어 따르기 압력. "항상 답변해"라고 지시하면, 진짜 답이 없어도 답변해요. 도움을 주라는 지시와 정확하라는 지시가 충돌하는 거예요.
-
컨텍스트 윈도우 오버플로우. 관련 정보가 긴 컨텍스트 중간에 묻혀 있으면, 모델이 그걸 무시하고 내장 기억(파라메트릭 메모리)에서 생성하는 경우가 있어요. "가운데에서 잃어버리는(lost in the middle)" 문제예요.
-
포맷 유도 조작. JSON이나 표 같은 구조화된 출력을 요청하면, 모델이 필수 필드를 비워두는 대신 값을 지어내서 채울 수 있어요.
할루시네이션 분류
할루시네이션이라고 다 같은 게 아니에요. 유형을 나눠야 대응도 달라지거든요:
| 유형 | 뭔데? | 예시 | 위험도 |
|---|---|---|---|
| 사실 날조 | 진짜처럼 들리는 팩트를 지어냄 | "React useState 훅은 15.3에서 나왔다" | 🔴 높음 |
| 출처 날조 | 없는 출처를 갖다 붙임 | "2024 StackOverflow 설문에 따르면..." (엉터리) | 🔴 높음 |
| 자신만만한 확대 해석 | 맞는 말에서 시작해 틀린 데까지 감 | "PostgreSQL은 100TB 테이블 네이티브 지원" | 🟡 중간 |
| 기능 사칭 | 안 한 걸 한 것처럼 행세 | "DB 검색해서 3건 찾았습니다" (실제로 안 함) | 🔴 높음 |
| 앞뒤가 안 맞는 말 | 자기가 한 말이랑 모순 | "X는 맞다" → 나중에 "X는 틀리다" | 🟡 중간 |
| 시점 착각 | 시기를 헷갈림 | 학습 전 사실을 최근 이벤트에 적용 | 🟡 중간 |
유형마다 처방이 달라요. 사실 날조엔 그라운딩, 출처 날조엔 인용 검증, 기능 사칭엔 도구 사용 강제. 하나씩 방어벽을 쌓아볼게요.
레이어 1: 그라운딩 — 모델을 현실에 고정시키기
그라운딩은 모델한테 "이 자료만 보고 대답해"라고 못 박는 거예요. 신뢰할 수 있는 소스를 직접 먹이고, 거기서 벗어나지 못하게 하는 거죠. 할루시네이션 대응의 1번 기본기예요.
시스템 프롬프트 설계
시스템 프롬프트가 첫 번째 방어선인데, 대부분의 개발자가 자기도 모르게 할루시네이션을 부추기는 프롬프트를 쓰고 있어요:
// ❌ 할루시네이션을 유도하는 시스템 프롬프트 const badSystemPrompt = `당신은 Acme Corp의 친절한 고객 지원 에이전트입니다. 모든 고객 질문에 친절하고 상세하게 답변하세요.`; // ✅ 할루시네이션을 줄이는 시스템 프롬프트 const goodSystemPrompt = `당신은 Acme Corp의 고객 지원 에이전트입니다. 중요 규칙: 1. 제공된 컨텍스트 문서를 기반으로만 답변하세요. 2. 컨텍스트에 답이 없으면 다음과 같이 말하세요: "해당 정보를 갖고 있지 않습니다. 담당자에게 연결해 드릴게요." 3. 절대로 제품 기능, 가격, 정책을 만들어내지 마세요. 4. 정보를 인용할 때는 구체적인 문서 섹션을 참조하세요. 5. 확실하지 않은 내용이 있으면 명시적으로 불확실하다고 말하세요. 당신의 지식 기준일 이후 최신 정보는 없을 수 있습니다. 확실하지 않으면 docs.acme.com 문서를 안내하세요.`;
핵심 패턴은 이래요:
- "몰라"라고 말해도 된다는 허락. 모델은 원래 뭐라도 내뱉으려 해요. "모르면 모른다고 해"를 직접 적어줘야 해요.
- 지식 범위 못 박기. "너한테 준 문서가 네 지식의 전부야"를 명확하게요.
- 출처 달기 강제. 인용을 붙이게 만들면 가짜 인용이 눈에 띄니까 알아서 줄어요.
- 확신에 브레이크. "확실하지 않으면 솔직히 말해"가 모델의 자기 검열 기능을 켜줘요.
Few-Shot 그라운딩 예시
"이럴 땐 대답 안 해도 돼"를 가르치는 데 Few-Shot 예시가 생각보다 잘 먹혀요:
const groundingExamples = [ { role: "user", content: "Enterprise 플랜의 업타임 보장은 몇 퍼센트인가요?" }, { role: "assistant", content: "가격 문서 (섹션 3.2)에 따르면, Enterprise 플랜에는 99.99% 업타임 SLA가 포함되어 있으며, 이를 초과하는 다운타임에 대해 금전적 크레딧이 제공됩니다." }, { role: "user", content: "SAP 연동을 지원하나요?" }, { role: "assistant", content: "현재 연동 문서에서 SAP은 확인되지 않습니다. docs.acme.com/integrations에서 최신 연동 목록을 확인하시거나, 맞춤 연동에 대해 논의할 수 있는 연동팀에 연결해 드릴 수 있습니다." } ];
두 번째 예시가 핵심이에요. 정보를 만들어내지 않으면서도 여전히 도움이 되는 "모르겠다" 응답의 형태를 모델에게 가르쳐주는 거예요.
온도와 샘플링 제어
온도를 낮춘다고 할루시네이션이 사라지진 않지만, 창작 활동은 확실히 줄여줘요:
import OpenAI from 'openai'; const openai = new OpenAI(); async function groundedCompletion( systemPrompt: string, context: string, userQuery: string ) { const response = await openai.chat.completions.create({ model: 'gpt-5', temperature: 0.1, // 사실 기반 작업에는 낮은 온도 top_p: 0.9, // 약간 제한된 nucleus sampling frequency_penalty: 0.3, // 반복 패턴 억제 presence_penalty: 0.0, // 다양성 강제 안 함 (정확도 우선) messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: `컨텍스트:\n${context}\n\n질문: ${userQuery}` } ], }); return response.choices[0].message.content; }
중요한 포인트: 온도 0 = "할루시네이션 제로"가 아니에요. 그냥 매 스텝에서 1등 토큰만 찍는다는 뜻이에요. 근데 그 1등이 할루시네이션이면? (모델이 진짜로 답을 모르는 경우) 온도 0이 최대 자신감으로 거짓말해요.
레이어 2: RAG — 모델에게 필요한 걸 먹이기
RAG(검색 증강 생성)는 할루시네이션 줄이기에 가장 많이 쓰이는 기법이에요. 모델 머릿속에 든 거 쓰지 말고, 문서를 직접 찾아서 프롬프트에 꽂아주는 거죠.
근데 대부분의 RAG 구현이 할루시네이션 감소 효과가 영 별로예요. 왜냐면 검색 품질에만 매달리고, 진짜 문제의 반쪽인 가져온 컨텍스트를 모델이 어떻게 소화하느냐는 신경을 안 쓰거든요.
할루시네이션을 진짜 줄이는 RAG 파이프라인
import { OpenAIEmbeddings } from '@langchain/openai'; import { SupabaseVectorStore } from '@langchain/community/vectorstores/supabase'; import { createClient } from '@supabase/supabase-js'; // 1단계: 청킹 전략이 임베딩 모델보다 중요 function intelligentChunk(document: string, metadata: Record<string, any>) { // 나쁨: 문장 중간에서 잘리는 고정 크기 청크 // 좋음: 컨텍스트를 보존하는 의미 기반 청크 const sections = document.split(/\n## /); return sections.map((section, index) => ({ content: index === 0 ? section : `## ${section}`, metadata: { ...metadata, sectionIndex: index, documentTitle: metadata.title, sectionTitle: section.split('\n')[0]?.trim() || 'Introduction', previousSection: index > 0 ? sections[index - 1].slice(-200) : null, nextSection: index < sections.length - 1 ? sections[index + 1].slice(0, 200) : null, } })); } // 2단계: 관련성 점수를 포함한 검색 async function retrieveWithScoring( query: string, vectorStore: SupabaseVectorStore, options: { topK: number; scoreThreshold: number } ) { const results = await vectorStore.similaritySearchWithScore( query, options.topK * 2 // 넉넉히 검색한 뒤 필터링 ); const relevant = results .filter(([_, score]) => score >= options.scoreThreshold) .slice(0, options.topK); if (relevant.length === 0) { return { documents: [], confidence: 'none', message: '충분히 관련된 문서를 찾지 못했습니다' }; } return { documents: relevant.map(([doc, score]) => ({ content: doc.pageContent, metadata: doc.metadata, relevanceScore: score })), confidence: relevant[0][1] > 0.85 ? 'high' : 'moderate', message: null }; } // 3단계: 인용을 강제하는 컨텍스트 인식 생성 async function generateWithRAG( query: string, retrievalResult: Awaited<ReturnType<typeof retrieveWithScoring>> ) { if (retrievalResult.confidence === 'none') { return { answer: "지식 베이스에서 이 질문에 정확하게 답할 수 있는 정보를 찾지 못했습니다. 질문을 다시 표현해 주시거나, 전문가 연결을 도와드릴까요?", citations: [], confidence: 'none' }; } const contextBlock = retrievalResult.documents .map((doc, i) => `[출처 ${i + 1}: ${doc.metadata.documentTitle} > ${doc.metadata.sectionTitle}]\n${doc.content}`) .join('\n\n---\n\n'); const response = await openai.chat.completions.create({ model: 'gpt-5', temperature: 0.1, messages: [ { role: 'system', content: `당신은 정밀한 어시스턴트입니다. 제공된 출처만을 기반으로 답변하세요. 규칙: - [출처 N] 표기를 사용하여 인용하세요 - 출처가 질문에 완전히 답하지 못하면, 답할 수 있는 부분과 없는 부분을 구분하세요 - 출처에 명시적으로 나와 있지 않은 내용은 절대 추론하지 마세요 - 출처 간 모순이 있으면 양쪽 모두 인용과 함께 제시하세요` }, { role: 'user', content: `출처:\n${contextBlock}\n\n질문: ${query}` } ] }); return { answer: response.choices[0].message.content, citations: retrievalResult.documents.map(d => d.metadata), confidence: retrievalResult.confidence }; }
RAG에서 할루시네이션을 유발하는 5가지 실수
RAG를 붙여도 여전히 할루시네이션이 발생하는 이유가 있어요:
1. 관련성 임계값 없음. RAG 파이프라인이 항상 뭔가를 반환하면, 모델은 무관한 컨텍스트로부터 답을 짜내려 해요. 컨텍스트가 아예 없는 것보다 더 나빠요 — 모델에게 거짓 자신감을 주니까요.
// ❌ 무관한 결과도 항상 반환 const results = await vectorStore.similaritySearch(query, 5); // ✅ 관련성 임계값 이상만 반환 const results = await vectorStore.similaritySearchWithScore(query, 10); const filtered = results.filter(([_, score]) => score > 0.75);
2. 청크가 너무 잘게 썰려 있음. 200토큰짜리 조각으로 쪼개면 맥락이 날아가요. "비율은 4.5%"만 보이고, 이게 이미 바뀐 2023년 가격 섹션이었다는 건 안 보이거든요.
3. 메타데이터가 없음. 제목, 날짜, 섹션 계층 정보가 없으면 모델 입장에서 뭐가 공식 문서고 뭐가 3년 된 블로그인지 구분을 못 해요.
4. 컨텍스트 과다 주입. 15개 청크를 프롬프트에 때려 넣으면, 모델이 관련 있는 걸 찾기 어려워해요. "가운데에서 잃어버리는" 효과 때문에 15개 중 4~12번째에 있는 정보는 무시당하기 쉬워요.
5. "모르겠다" 경로 없음. 컨텍스트에 답이 없어도 항상 답을 생성하면 할루시네이션 해요. 명시적인 답변 유보 경로가 필요해요.
하이브리드 검색: 양쪽의 장점
순수 벡터 유사도 검색은 키워드 정확 매치를 놓치고, 순수 키워드 검색은 의미적 유사성을 놓쳐요. 하이브리드 검색은 둘을 결합해요:
async function hybridSearch( query: string, supabase: any, options: { topK: number; vectorWeight: number } ) { // 벡터 유사도 검색 const vectorResults = await supabase.rpc('match_documents', { query_embedding: await embedQuery(query), match_threshold: 0.7, match_count: options.topK * 2, }); // PostgreSQL ts_rank 기반 풀텍스트 검색 const keywordResults = await supabase.rpc('search_documents', { search_query: query, match_count: options.topK * 2, }); // Reciprocal Rank Fusion (RRF)으로 결과 결합 const scores = new Map<string, number>(); const k = 60; // RRF 상수 vectorResults.data?.forEach((doc: any, rank: number) => { const id = doc.id; const score = (scores.get(id) || 0) + options.vectorWeight / (k + rank + 1); scores.set(id, score); }); keywordResults.data?.forEach((doc: any, rank: number) => { const id = doc.id; const weight = 1 - options.vectorWeight; const score = (scores.get(id) || 0) + weight / (k + rank + 1); scores.set(id, score); }); const ranked = Array.from(scores.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, options.topK); return ranked; }
레이어 3: 출력 가드레일 — 생성 이후에 잡기
그라운딩은 할루시네이션을 줄이고, RAG도 줄여요. 가드레일은 그래도 빠져나온 것들을 잡는 안전망이에요.
구조 검증
가장 단순하면서도 효과적인 가드레일: 출력이 예상 구조와 맞는지 검증하는 거예요.
import { z } from 'zod'; const ProductRecommendationSchema = z.object({ productName: z.string().min(1), productId: z.string().regex(/^PROD-\d{6}$/), price: z.number().positive(), inStock: z.boolean(), reasoning: z.string().min(20), }); async function validateOutput( llmOutput: string, schema: z.ZodSchema, knownProducts: Map<string, any> ) { let parsed; try { parsed = schema.parse(JSON.parse(llmOutput)); } catch (e) { return { valid: false, error: '구조 검증 실패', data: null }; } // 실제 데이터와 대조 const realProduct = knownProducts.get(parsed.productId); if (!realProduct) { return { valid: false, error: `제품 ID ${parsed.productId}는 존재하지 않음`, data: null }; } const issues: string[] = []; if (Math.abs(parsed.price - realProduct.price) > 0.01) { issues.push(`가격 불일치: LLM이 ${parsed.price}라고 했지만 실제는 ${realProduct.price}`); } if (parsed.inStock !== realProduct.inStock) { issues.push(`재고 상태 불일치: LLM이 ${parsed.inStock}라고 했지만 실제는 ${realProduct.inStock}`); } if (issues.length > 0) { return { valid: false, error: issues.join('; '), data: parsed }; } return { valid: true, error: null, data: parsed }; }
LLM-as-Judge: 자기 검증
첫 번째 응답을 두 번째 LLM 호출로 크로스체크하는 방법이에요. 돈이 더 들긴 하는데, 구조 검증으로 안 잡히는 미묘한 할루시네이션을 잡을 수 있어요:
async function selfVerify( originalQuery: string, context: string, generatedAnswer: string ): Promise<{ isGrounded: boolean; issues: string[]; confidence: number }> { const verificationPrompt = `당신은 팩트체커입니다. AI가 생성한 답변이 제공된 컨텍스트에 의해 완전히 뒷받침되는지 검증하세요. 컨텍스트: ${context} 원래 질문: ${originalQuery} 생성된 답변: ${generatedAnswer} 답변을 문장 단위로 분석하세요. 각 주장에 대해: 1. 컨텍스트에 직접 뒷받침되나요? (SUPPORTED) 2. 컨텍스트에서 합리적으로 추론 가능한가요? (INFERRED) 3. 컨텍스트에 없나요? (UNSUPPORTED) 4. 컨텍스트와 모순되나요? (CONTRADICTED) JSON으로 응답: { "claims": [ { "claim": "...", "status": "SUPPORTED|INFERRED|UNSUPPORTED|CONTRADICTED", "evidence": "..." } ], "overallGrounded": true/false, "confidence": 0.0-1.0, "issues": ["..."] }`; const verification = await openai.chat.completions.create({ model: 'gpt-5', temperature: 0, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: '당신은 정밀한 팩트체커입니다. 엄격하게 검증하세요.' }, { role: 'user', content: verificationPrompt } ] }); return JSON.parse(verification.choices[0].message.content!); }
비용 최적화: 모든 응답을 검증할 필요는 없어요. 선택적 검증을 구현하면 돼요:
function shouldVerify(response: string, context: any): boolean { // 고위험 응답은 항상 검증 if (context.category === 'medical' || context.category === 'legal') return true; // 숫자가 포함된 응답 (조작 가능성 높음) if (/\d+%|\$\d+|\d+ (users|customers|times)/.test(response)) return true; // 구체적 출처를 인용하는 응답 if (/according to|as stated in|문서에 따르면/.test(response)) return true; // 짧은 확인 응답은 건너뛰기 if (response.length < 100) return false; // 나머지는 10% 랜덤 샘플링 return Math.random() < 0.1; }
인용 검증
모델이 출처를 인용했다고 할 때, 그 인용이 실제로 존재하고 주장을 뒷받침하는지 검증하기:
async function verifyCitations( answer: string, providedSources: Array<{ id: string; content: string; title: string }> ): Promise<{ verified: boolean; fabricatedCitations: string[]; unsupportedClaims: string[]; }> { const citationPattern = /\[출처 (\d+)\]/g; const citedSources = new Set<number>(); let match; while ((match = citationPattern.exec(answer)) !== null) { citedSources.add(parseInt(match[1])); } const fabricatedCitations: string[] = []; const unsupportedClaims: string[] = []; for (const sourceNum of citedSources) { if (sourceNum > providedSources.length || sourceNum < 1) { fabricatedCitations.push(`[출처 ${sourceNum}]은 존재하지 않음`); } } return { verified: fabricatedCitations.length === 0 && unsupportedClaims.length === 0, fabricatedCitations, unsupportedClaims }; }
레이어 4: 신뢰도 스코어링 — 모르는 걸 아는 것
강력한데 의외로 잘 안 쓰는 기법이에요. 모델한테 "이 답변 얼마나 확신해?"를 물어보고, 확신도가 낮으면 유저한테 안 보내는 거예요.
토큰 수준 신뢰도
OpenAI의 GPT-5는 logprobs를 지원해요 — 생성된 각 토큰의 로그 확률이에요. (참고: Anthropic의 Claude API는 현재 logprobs를 지원하지 않아요. Claude에는 자기 보고 신뢰도를 사용하세요.) 확률이 낮은 토큰은 할루시네이션 신호예요:
⚠️ GPT-5 주의사항:
logprobs는reasoning_effort를"none"으로 설정해야만 사용 가능해요. 다른 reasoning 레벨에서 logprobs를 사용하면 에러가 발생해요.
async function getConfidenceScore( prompt: string, systemPrompt: string ): Promise<{ response: string; confidence: number; lowConfidenceSpans: Array<{ text: string; probability: number }>; }> { const completion = await openai.chat.completions.create({ model: 'gpt-5', temperature: 0, logprobs: true, top_logprobs: 3, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: prompt } ] }); const content = completion.choices[0].message.content!; const logprobs = completion.choices[0].logprobs?.content || []; const avgLogProb = logprobs.reduce( (sum, token) => sum + token.logprob, 0 ) / logprobs.length; const confidence = Math.exp(avgLogProb); const lowConfidenceSpans: Array<{ text: string; probability: number }> = []; let currentSpan = ''; let spanMinProb = 1; for (const token of logprobs) { const prob = Math.exp(token.logprob); if (prob < 0.5) { currentSpan += token.token; spanMinProb = Math.min(spanMinProb, prob); } else if (currentSpan) { lowConfidenceSpans.push({ text: currentSpan, probability: spanMinProb }); currentSpan = ''; spanMinProb = 1; } } if (currentSpan) { lowConfidenceSpans.push({ text: currentSpan, probability: spanMinProb }); } return { response: content, confidence, lowConfidenceSpans }; }
자기 보고 신뢰도
모델한테 "이거 얼마나 자신 있어?"를 직접 물어보는 방법이에요. 혼자 쓰면 좀 아쉽지만, 다른 시그널이랑 섞으면 꽤 쓸 만해요:
async function generateWithConfidence(query: string, context: string) { const response = await openai.chat.completions.create({ model: 'gpt-5', temperature: 0.1, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: `제공된 컨텍스트를 기반으로 질문에 답하세요. 답변의 각 부분에 대해 신뢰도를 평가하세요: - HIGH: 컨텍스트에 직접 명시됨 - MEDIUM: 컨텍스트에서 합리적 추론 가능 - LOW: 컨텍스트에 잘 뒷받침되지 않음 - NONE: 순수한 추측 JSON으로 응답: { "answer": "답변", "confidence_breakdown": [ { "claim": "...", "confidence": "HIGH|MEDIUM|LOW|NONE", "source": "..." } ], "overall_confidence": "HIGH|MEDIUM|LOW|NONE", "caveats": ["중요한 제한 사항"] }` }, { role: 'user', content: `컨텍스트:\n${context}\n\n질문: ${query}` } ] }); const result = JSON.parse(response.choices[0].message.content!); if (result.overall_confidence === 'LOW' || result.overall_confidence === 'NONE') { return { ...result, shouldEscalate: true, userMessage: `이 답변에 대한 신뢰도가 낮아요. ${result.caveats?.join(' ') || '해당 질문에 관련된 정보가 부족할 수 있습니다.'}` }; } return { ...result, shouldEscalate: false }; }
레이어 5: 프로덕션 모니터링 — 측정해야 개선할 수 있다
안 재면 못 고쳐요. 프로덕션에서 할루시네이션 비율을 트래킹하는 방법을 봐요.
할루시네이션 지표 트래킹
interface HallucinationEvent { id: string; timestamp: Date; query: string; response: string; hallucinationType: 'factual' | 'source' | 'instruction' | 'coherence' | 'temporal'; severity: 'critical' | 'moderate' | 'minor'; detectionMethod: 'user_report' | 'guardrail' | 'self_verify' | 'citation_check'; context: { model: string; temperature: number; ragUsed: boolean; retrievalScore: number | null; tokenConfidence: number; }; } class HallucinationMonitor { private events: HallucinationEvent[] = []; getMetrics(timeWindow: { start: Date; end: Date }) { const windowEvents = this.events.filter( e => e.timestamp >= timeWindow.start && e.timestamp <= timeWindow.end ); return { totalResponses: windowEvents.length, hallucinationRate: windowEvents.length / (windowEvents.length || 1), byType: this.groupBy(windowEvents, 'hallucinationType'), bySeverity: this.groupBy(windowEvents, 'severity'), byDetectionMethod: this.groupBy(windowEvents, 'detectionMethod'), }; } private groupBy<T>(arr: T[], key: keyof T) { return arr.reduce((acc, item) => { const k = String(item[key]); acc[k] = (acc[k] || 0) + 1; return acc; }, {} as Record<string, number>); } }
유저 피드백 루프
제일 정직한 시그널은 유저한테서 와요. 👎 + "이거 틀렸어요" 버튼 하나만 달아도 파이프라인 개선에 금광이에요.
async function handleFeedback(feedback: { responseId: string; feedbackType: 'hallucination' | 'incorrect' | 'helpful' | 'not_helpful'; userComment?: string; correction?: string; }) { await db.insert(feedbackTable).values({ responseId: feedback.responseId, type: feedback.feedbackType, comment: feedback.userComment, correction: feedback.correction, timestamp: new Date(), }); if (feedback.feedbackType === 'hallucination') { await db.insert(knownHallucinationsTable).values({ responseId: feedback.responseId, userCorrection: feedback.correction, reviewStatus: 'pending', }); } }
전부 합치기: 방어 종심 파이프라인
어떤 기법 하나로 할루시네이션을 없앨 수 없어요. 프로덕션 접근법은 방어 종심(defense-in-depth)이에요:
async function safeAIResponse( query: string, userContext: { userId: string; category: string } ): Promise<{ response: string; confidence: string; citations: any[]; verified: boolean; }> { // 레이어 1: 컨텍스트 검색 및 점수 매기기 (RAG) const retrieval = await retrieveWithScoring(query, vectorStore, { topK: 5, scoreThreshold: 0.7, }); // 레이어 2: 그라운딩된 응답 생성 const generation = await generateWithRAG(query, retrieval); // 레이어 3: 신뢰도 스코어링 const confidence = await getConfidenceScore(query, groundedSystemPrompt); // 레이어 4: 선택적 검증 if (shouldVerify(generation.answer, userContext)) { const verification = await selfVerify( query, retrieval.documents.map(d => d.content).join('\n'), generation.answer ); if (!verification.isGrounded) { return { response: "정확한 정보를 드리기 위해 확인했는데, " + "처음 답변의 일부를 완전히 검증하기 어려웠어요. " + "확인된 내용만 알려드릴게요.", confidence: 'low', citations: generation.citations, verified: false, }; } } // 레이어 5: 모니터링 await hallucinationMonitor.trackResponse({ query, response: generation.answer, context: retrieval.documents.map(d => d.content).join('\n'), model: 'gpt-5', ragScore: retrieval.documents[0]?.relevanceScore || null, confidence: confidence.confidence, }); return { response: generation.answer, confidence: generation.confidence, citations: generation.citations, verified: true, }; }
할루시네이션 감소 체크리스트
LLM 기능을 프로덕션에 배포하기 전에 확인할 것들:
배포 전
- 시스템 프롬프트가 "모르겠다" 응답을 명시적으로 허용
- 사실 기반 작업에 온도 ≤ 0.3 설정
- RAG 파이프라인에 관련성 임계값 설정 (단순 top-K가 아님)
- 문서 청크에 메타데이터 포함 (제목, 날짜, 섹션)
- 구조화된 출력에 스키마 검증 적용
- 인용이 있으면 인용 검증 구현
- 신뢰도 스코어링 통합 (logprobs 또는 자기 보고)
- 저신뢰 출력에 대한 폴백 응답 준비
배포 후
- 할루시네이션 모니터링 대시보드 운영
- 유저 피드백 메커니즘 구축 (좋아요/싫어요 + 신고)
- 신고 기반 할루시네이션 데이터셋 축적
- 주간 단위 할루시네이션 지표 리뷰
- 프롬프트 개선을 위한 A/B 테스트 프레임워크
- 치명적 할루시네이션 급증 시 알림 설정
결론
할루시네이션은 안 사라져요. LLM의 작동 원리 자체가 "다음에 올 확률 높은 토큰 찍기"인 이상, 그럴듯한 거짓말을 할 가능성이 항상 있어요. RLHF든 파인튜닝이든 프롬프트 장인이든, 완전히 없애는 건 불가능해요.
하지만 그렇다고 신뢰할 수 있는 AI 기능을 못 만든다는 건 아니에요. 2026년에 AI 제품을 성공적으로 운영하는 팀들은 할루시네이션을 없애는 마법 프롬프트를 찾은 게 아니에요. 모델 주변에 엔지니어링 인프라를 쌓은 거예요: 그라운딩, 검색, 가드레일, 신뢰도 스코어링, 모니터링, 피드백 루프까지.
핵심 인사이트: LLM 출력을 유저 입력처럼 취급하세요. 아무 유저 입력이나 검증 없이 신뢰하진 않잖아요. LLM 출력도 마찬가지예요.
가장 큰 리스크를 해결하는 레이어부터 시작하세요. 유저가 조작된 사실을 보고 있다면 관련성 임계값이 있는 RAG를 구현하세요. 가짜 인용을 보고 있다면 인용 검증을 추가하세요. 뭘 보고 있는지 모른다면 모니터링부터 붙이세요.
완벽을 노리는 게 아니에요. "이 정도 오류율이면 OK"를 정하고, 그걸 감지하고, 재고, 시간이 지나면서 깎아나갈 인프라를 만드는 거예요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요