로컬 LLM에게 분석 결과 설명을 맡겼다가 템플릿으로 바꾼 이유
BioAI Market에서 Ollama(qwen3:30b)에게 DE 분석 결과와 논문 초록을 설명하게 했을 때 생긴 문제를 커밋 기록으로 되짚었다. 원인 상당수는 모델이 아니라 내가 넘긴 입력이었다. 결국 숫자는 템플릿이 찍게 바꿨고, 그 템플릿에도 한계가 있었다.
BioAI Market(웹 기반 오믹스 분석 플랫폼, 소개 글)에는 분석이 끝나면 LLM이 결과를 한국어로 설명해 주는 기능이 있었다. 결과 설명, 리포트 초안, 논문 초록 요약에 모두 로컬 LLM을 썼다. 이 글은 그 과정에서 생긴 문제와, 2026년 2월 17일 결과 출력을 템플릿으로 바꾼 이유를 정리한 것이다.
예전에 이 주제를 다른 블로그에 몇 편으로 나눠 썼다. 거기 적었던 환각 사례의 구체적인 수치(매칭 개수와 비율, 민감도·특이도, 표본 수 같은 것)는 지금 기록으로 확인할 수 없어서 모두 뺐다. 이 글은 git 저장소의 커밋 메시지와 코드만 근거로 했다.
환경
- LLM 서버: RTX 3090 서버의 Ollama. 기본 모델은 처음에
gemma3:27b였다. 2026년 2월 15일 커밋("change default model to qwen3:30b (MoE, better for RTX 3090)")에서qwen3:30b로 바꿨다. - 폴백 후보로
deepseek-r1:32b, 빠른 응답용으로gemma3:12b-it-qat가 설정돼 있었다.
qwen3:30b는 전체 파라미터 약 300억 개 중 토큰마다 약 30억 개만 쓰는 MoE 모델이다(공식 이름이 Qwen3-30B-A3B, "A3B"가 활성 파라미터 3B라는 뜻). 예전 글에 비교 대상으로 적었던 qwen3:72b는 존재하지 않는 모델이다. 72B는 Qwen2.5 세대에 있던 크기다. 속도 수치도 예전 글에 적었지만 측정 기록이 없어서 뺐다.
LLM을 쓴 곳은 세 군데였다.
- 분석 에이전트의 결과 설명: QC, DE, pathway 같은 도구를 실행한 뒤 그 결과를 LLM이 풀어 쓴다.
- 리포트 생성(
generate_report): 분석 결과로 논문 형식의 초안을 만든다. - 문헌 분석: PubMed 초록에서 핵심 발견을 뽑고 요약한다(1월 28일 커밋 "AI Co-Scientist research platform").
무엇이 잘못됐나: 커밋 메시지가 남긴 원인들
2월 16~17일 커밋들을 차례로 보면, 이때 가장 먼저 손댄 문제는 LLM이 없는 사실을 지어내는 것이 아니었다. 멀쩡한 분석을 "실패"라고 보고하는 것이었다. 그리고 원인 상당수는 모델이 아니라 내 쪽에 있었다.
잘린 입력. 도구 결과를 JSON 문자열로 만들어 3,000자에서 잘라 넘겼다. 커밋 메시지(2월 16일, "prevent LLM hallucination in report interpretation")에 적힌 원인은 이렇다. "LLM received truncated data + no guidance, hallucinated failure." 잘린 데이터를 받은 모델이 결과가 없다고 판단하고 "분석 실패", "undefined"라고 썼다. 8,000자로 늘리고, 도구가 성공했으면 결과는 유효하다는 시스템 프롬프트를 붙였다.
경고를 실패로 읽음. QC 결과의 상태가 warning이거나 CV가 100%를 넘는 값이 보이면, 모델이 실패 보고서를 썼다(같은 날 커밋 "add explicit instructions to prevent Ollama from hallucinating failure"). 프로테오믹스 데이터에서 일부 단백질의 CV가 높은 건 흔한 일이다. 이 맥락을 모델이 알 수 없으니 프롬프트에 직접 적었다.
필드 이름이 안 맞음. 이게 제일 부끄러운 원인이다. DE 결과는 log2FC, pValue(카멜 표기)로 저장되는데, 리포트 프롬프트를 만드는 코드는 log2fc, pvalue를 읽었다. QC 쪽은 존재하지 않는 summary.total_samples 같은 필드를 읽었다. 그래서 프롬프트에는 실제 값 대신 0과 N/A가 들어갔고, 모델은 그 0과 N/A를 근거로 "분석이 제대로 되지 않았다"고 썼다(커밋 "handle field name case mismatch in report generation", "correct QC and pathway field names in report prompt"). 모델이 지어낸 게 아니라, 내가 틀린 숫자를 넣었고 모델은 그 숫자를 충실히 설명한 것이다. 같은 종류의 실수가 2월 27일에도 다시 나왔다("AI interpretation field name mismatch (log2fc→log2FC, pvalue→adjustedPValue)").
그다음에 나온 것들. 입력을 고친 뒤에는 우리가 흔히 환각이라고 부르는 출력을 막는 규칙을 넣었다. 2월 17일 커밋("strengthen anti-hallucination prompt") 메시지에 적힌 내용은 "no fake URLs, no generated p-values/enrichment, no fake tool names"다. 프롬프트에는 실제로 이런 규칙이 들어갔다.
저장소 src/lib/agents/agentic-engine.ts 의 시스템 프롬프트에서 발췌
1. 도구가 반환한 데이터만 사용하세요. 도구 결과에 없는 수치, 통계, p-value,
enrichment ratio를 절대 생성하지 마세요.
2. URL/다운로드 링크를 만들지 마세요. ... 가짜 URL 금지.
3. 존재하지 않는 도구를 언급하지 마세요.
...
6. pathway enrichment 수치를 지어내지 마세요.
3월 1일에는 쥐·생쥐 데이터를 사람 기준으로 해석하는 문제 때문에 생물종 정보를 에이전트에 넘기도록 고쳤다("prevent human-centric hallucination on rat/mouse data").
여기서 말하고 싶은 것은 규칙을 아무리 늘려도 "숫자를 지어내지 마라"는 부탁일 뿐이라는 점이다. 지켜졌는지는 매번 사람이 확인해야 한다.
2월 17일: 숫자는 템플릿이 찍는다
그래서 구조를 바꿨다. 커밋 "template-based result rendering (no LLM hallucination)"의 내용은 이렇다.
- 도구별 결과 템플릿을 만든다(DB 대조, QC, DE, pathway).
- 결과를 낼 때 템플릿을 먼저 시도한다. 실행된 도구가 모두 템플릿을 갖고 있으면 LLM을 아예 부르지 않는다.
- 템플릿이 없는 도구가 섞였을 때만 LLM 해석으로 넘어간다.
같은 날 리포트 생성과 해석 도구에도 템플릿이 붙었다. 이 커밋 메시지에는 "no double hallucination", "LLM-generated content clearly marked as AI interpretation"라고 적혀 있다. LLM이 쓴 문단은 그렇다고 표시하고, 그 문단을 다시 LLM에게 요약시키지 않는다는 뜻이다.
// 저장소 src/lib/agents/agentic-engine.ts 에서 발췌 (로그 출력 일부 생략)
for (const [stepId, data] of executionResults.entries()) {
const step = plan.steps.find(s => s.id === stepId)
if (step?.tool && data) {
const rendered = renderToolResult(step.tool, data as Record<string, unknown>)
if (rendered) {
templateParts.push(rendered)
} else {
allTemplated = false
}
}
}
// 모든 결과가 템플릿으로 렌더링된 경우 → LLM 호출 스킵
if (templateParts.length > 0 && allTemplated) {
finalResponse = templateParts.join('\n\n---\n\n')
}
// 저장소 src/lib/agents/tools/result-templates.ts 에서 발췌
const TEMPLATE_REGISTRY: Record<string, (data: ToolResultData) => string> = {
'validate_dep_biomarkers': renderValidateDepBiomarkers,
'run_qc_analysis': renderQCAnalysis,
'run_de_analysis': renderDEAnalysis,
'run_pathway_analysis': renderPathwayAnalysis,
'generate_report': renderGenerateReport,
'run_interpretation': renderInterpretation,
}
이렇게 하면 표에 나오는 개수, 비율, p값은 코드가 도구 결과에서 그대로 꺼내 찍는다. 모델이 숫자를 바꿀 경로가 없다.
템플릿이 막지 못하는 것
템플릿은 입력을 그대로 옮길 뿐이다. 입력이 틀리면 틀린 걸 정확하게 옮긴다.
DE 결과를 바이오마커 DB와 대조하는 도구는 연관 질병을 disgenet_score 순으로 골라 (score: …)로 표시한다. 그런데 앞 글에 적었듯이, 1월 6일 시드 마이그레이션 일부는 이 점수 칸을 0.7 + random() * 0.3으로 채웠다. LLM이 숫자를 지어내지 못하게 막아 놓고, DB에 들어 있던 지어낸 숫자는 템플릿이 그대로 내보낼 수 있었던 것이다. 환각을 막는 일은 출력단에서 끝나지 않는다. 숫자가 처음 들어오는 곳까지 거슬러 올라가 확인해야 한다.
분모도 같이 보여 줘야 한다. 이 도구는 유의한 DEP 중 앞의 50개만 DB에서 찾는다. 매칭률도 그 50개를 분모로 계산한다. 템플릿 표에는 "검색 대상" 행이 따로 있어서 분모를 볼 수 있게 했다. 2월 17일 같은 날 커밋("fix match rate")에서 매칭률을 연관 건수가 아니라 고유 DEP 수로 세도록 고친 것도 같은 맥락이다. 한 단백질이 여러 질병과 연결되면 연관 건수 기준 매칭률은 부풀려진다.
"검증"이 아니라 "주석 달기"였다
이 도구의 이름은 validate_dep_biomarkers였고, 예전 글에서는 "바이오마커 자동 검증"이라고 불렀다. 실제로 하는 일은 이름 대조다.
// 저장소 src/lib/agents/tools/executors.ts 에서 발췌
// symbol, name, aliases에서 매칭 검색
const { data: biomarkerMatches } = await supabase
.from('biomarkers')
.select('id, name, symbol, type, description, clinical_significance, validation_status')
.or(`symbol.ilike.${symbol},name.ilike.%${symbol}%`)
.limit(3)
DEP 하나에 대해 DB의 기호가 같거나(대소문자 무시), 이름에 그 문자열이 들어 있는 바이오마커를 최대 3개 가져온다. 이것은 "이 단백질이 우리 DB에 바이오마커로 등록돼 있고, 이런 질병과 연결돼 있다"는 주석(annotation)이다. 내 실험에서 그 단백질이 바이오마커로 검증됐다는 뜻이 아니다. 검증은 독립 코호트에서 다시 측정하고, 진단 성능을 따로 평가해야 하는 일이다. 저장소에도 이미 "Auto-Annotation Engine"이라는 이름의 기능이 따로 있었으니(2025년 12월 30일 커밋), 처음부터 annotation이라고 불렀어야 했다.
이름 대조 방식에는 한계가 분명했다.
- 약어와 정식 이름.
name ILIKE '%EGFR%'는 "Epidermal Growth Factor Receptor"라는 이름과 맞지 않는다. 약어가 이름 안에 글자 그대로 들어 있지 않기 때문이다. 이런 경우는 기호 칸이나 별칭 목록으로 맞춰야 한다. 그런데 위 코드는 주석에 aliases를 적어 놓고도 별칭 칸(aliases)은 조회하지 않는다. - 부분 문자열. 반대로 짧은 기호는 이름 어딘가에 그 글자가 들어간 엉뚱한 단백질과도 맞는다.
%…%대조는 늘리기는 쉬워도 틀린 짝을 걸러 주지 않는다. - UniProt accession. DE 결과의 단백질 ID가
sp|P02768|ALBU_HUMAN같은 형식이면, 이 도구의 기호 추출 함수는 가운데의 accession(P02768)을 꺼낸다. DB는 유전자 기호 기준이라 대조가 잘 되지 않았다. 2월 25일 커밋("UniProt→Symbol for biomarker matching")에서 대시보드 쪽에 accession을 유전자 기호로 바꾸는 단계를 넣었다. 커밋 메시지에 적힌 효과는 "significantly more DB hits instead of all 'novel candidates'"다. 그전에는 이런 ID로 올린 데이터가 거의 전부 "신규 후보"로 떨어졌다는 뜻이다.
그래도 쓸모는 있었다. 2월 17일 이 도구로 테스트 데이터를 돌리다가 PSA, CYFRA 21-1, TK1, PD-L1이 DB에 없다는 걸 알게 됐다. 같은 날 이 넷을 추가하는 마이그레이션 파일의 첫 줄은 "Add 4 missing biomarkers identified by validate_dep_biomarkers test"다. DB의 빈 곳은 실제 데이터를 돌려 봐야 드러난다.
논문 초록 요약은 어떻게 했나
예전 글에는 "논문 100편을 LLM으로 요약했다"는 이야기와 함께, 모델이 지어낸 DOI, 표본 수, 교신저자 같은 사례를 적었다. 그 사례들은 지금 기록으로 확인할 수 없어서 뺐다. 대신 저장소에 남아 있는 문헌 분석 코드(src/lib/agents/research/literature-analyzer.ts, 1월 28일 커밋)가 어떻게 생겼는지 적는다. 템플릿 전환과 같은 원칙이 이미 일부 들어 있다.
- 서지 정보는 모델이 아니라 PubMed가 준다. PMID, 제목, 저자, 저널, 초록은 NCBI E-utilities(esearch, efetch)로 받아 온다. 모델에게 DOI나 저자를 물어볼 일이 없다.
- 모델은 초록에서 자유 서술만 뽑는다. 다섯 편씩 묶어 초록을 주고, 논문별 핵심 발견 한 문장, 관련 유전자·단백질, 카테고리를 JSON으로 받는다.
- 모델이 돌려준 PMID는 원래 묶음과 대조한다. 응답의 PMID가 그 묶음에 없으면 그 항목은 버린다. JSON 파싱에 실패하면 제목 기반의 기본 항목으로 대신한다.
// 저장소 src/lib/agents/research/literature-analyzer.ts 에서 발췌
const paper = batch.find(p => p.pmid === item.pmid || p.pmid === String(item.pmid))
if (paper && item.finding) {
findings.push({
statement: item.finding || '',
confidence: 0.7,
source: paper,
entities: item.entities || [],
category: item.category || 'general',
})
}
고칠 점도 같은 코드에 보인다. 초록은 앞 1,000자에서 잘라 넣는다. 초록의 결과 문장이 뒤쪽에 있으면 모델은 그 부분을 보지 못한다. 그리고 confidence: 0.7은 모든 항목에 똑같이 붙는 상수다. 화면에 "신뢰도 0.7"로 보이면 측정한 값처럼 읽힌다. 근거가 없는 숫자는 아예 표시하지 않는 편이 낫다. 모델이 뽑은 "핵심 발견" 문장이 초록 내용과 맞는지 따로 채점한 기록도 저장소에는 없다.
정리
- 모델이 "실패"나 "undefined"를 말하면, 먼저 내가 넘긴 입력을 찍어 본다. 이번 경우 상당수는 잘린 JSON과 틀린 필드 이름이었다.
- 개수, 비율, p값 같은 숫자는 코드가 결과 데이터에서 직접 찍는다. LLM은 숫자가 없는 해석 문단에만 쓰고, 그 문단은 AI가 쓴 것으로 표시한다.
- 서지 정보는 API에서 받는다. 모델 출력은 원래 입력의 ID와 대조한다.
- 템플릿은 DB만큼만 정확하다. 점수 칸에 무엇이 들어 있는지부터 확인한다.
- 이름 대조 결과를 "검증"이라고 부르지 않는다.
DB와 검색 쪽 이야기는 바이오마커 DB에 시맨틱 검색을 붙이며 배운 것에, 이 프로젝트 전체를 커밋 기록으로 되돌아본 글은 BioAI Market 1인 개발 회고에 있다.
관련 글
프로테오믹스 분석 플랫폼을 직접 만들어봤다
2월 23일 · 10 min read
BioinformaticsBioAI Market 1인 개발 회고 — 커밋 기록으로 다시 본 석 달
10월 11일 · 15 min read
Bioinformatics바이오마커 DB에 시맨틱 검색을 붙이며 배운 것 — 임베딩 1,141개, pgvector, 그리고 시드 데이터
10월 11일 · 22 min read
Proteomics공동연구자 의뢰로 Park et al. 2026을 재현하다 — 종간 ECM 프로테오믹스 분석에서 3번 반복하며 잡은 것들
5월 19일 · 20 min read