1편에서는 왜 / 무엇을 만드는지를 다뤘다. 이 글은 어떻게 돌리는지에 대한 기록이다. 결정의 이유, 잘못 짚었던 지점, 그래서 어떻게 갈아엎었는지를 위주로.
https://merbl-filter.vercel.app/posts
처음엔 BERT 다국어 모델 (bert-base-multilingual-uncased-sentiment) 로 [긍정 / 부정 / 중립] 식 감성 분류를 돌렸다. 잘 안 됐다. 이 모델이 별점 리뷰로 학습된 거라 "철렁했습니다", "놀랐어요" 같은 충격받은 독자 반응까지 죄다 부정으로 찍어버렸다. 사실 처음부터 도구가 다른 일을 하고 있던 거다 — 내가 원한 건 "읽을만한 댓글이냐" 였지 "좋은 평이냐" 가 아니었다.
그래서 paraphrase-multilingual-MiniLM-L12-v2 로 갈아엎었다. 이건 분류 모델이 아니라 문장 임베딩 모델이다. 댓글을 384차원 벡터로 만들고, 미리 준비해둔 예시 세트와의 코사인 유사도로 점수를 매기는 방식.
분류 라벨을 모델에 학습시키는 대신 예시 25개를 직접 작성해서 박아뒀다.
// 댓글 임베딩 vs 예시 임베딩 — 가장 가까운 쪽을 본다
const bestShow = showScores.reduce((a, b) => (a.sim > b.sim ? a : b));
const bestHide = Math.max(...hideScores);
// -1~1 범위의 차이를 0~100으로 정규화
const raw = (bestShow.sim - bestHide + 1) / 2;
const score = Math.round(raw * 100);
return score >= 50
? { label: 'worth_reading', tag: bestShow.tag, score }
: { label: 'noise', tag: 'noise', score };
학습 단계가 필요 없으니 예시만 손보면 분류 기준이 바로 바뀐다. 잘못 걸러진 댓글이 보이면 그걸 예시에 추가하기만 하면 됨. 이게 분류 모델 대비 가장 큰 실용적 차이였다.
모든 댓글을 모델에 다 통과시킬 필요는 없다. "굳이 모델 돌릴 필요 없는 것들" 은 정규식으로 먼저 쳐낸다.
// 15자 미만, ㅎㅋㅠ만으로 구성, 노골적 스팸 키워드 → 사전 차단
if (text.length < 15) return { label: 'noise', score: 5 };
if (/^[ㅎㅋㅠㅜㅡ~^.!?\s]+$/.test(text)) return { label: 'noise', score: 5 };
if (SPAM_KEYWORDS.some((k) => text.includes(k))) return { label: 'spam', score: 0 };
스팸 키워드는 "구독", "방문해주세요", "놀러오세요", "이웃추가" 4개로 좁혔다. 처음엔 URL 정규식, 인칭 대명사("저는/제가") 같은 것도 넣었는데 오탐이 많아서 다 뺐다. "규칙은 명백한 것만" 이 결국 답이었다.
Phase 2는 WebLLM + Qwen2.5-1.5B-Instruct-q4f16_1-MLC 조합. 댓글 단위 분류는 Phase 1로 충분하지만, 본문 요약과 댓글 반응 분석 같은 생성이 필요한 일은 LLM이 들어가야 한다.
WebLLM은 WebGPU를 요구한다. 없으면 그냥 못 돌리는데, 그걸 모르고 모델을 받기 시작하면 900MB를 다운로드한 뒤 에러가 난다. 그래서 다운로드 전에 미리 차단한다.
if (typeof navigator === 'undefined' || !('gpu' in navigator)) {
setPhase2Error('이 기기는 WebGPU를 지원하지 않습니다.');
return;
}
이 한 줄이 있고 없고가 사용자 경험 차이가 크다. 모바일에서 잠깐 들어왔다가 모델 받느라 데이터만 까먹는 일이 없어진다.
let _engine: unknown = null; // ← 모듈 레벨
export function useWebLLM() {
// ...
}
React 컴포넌트가 마운트/언마운트될 때마다 900MB 모델을 GPU에 다시 올리면 죽는다. 엔진 인스턴스를 모듈 스코프에 박아두면 페이지 이동에도 유지된다. "훅 안에 useRef" 가 아니라 "모듈 안에 let" 인 이유.
context_window_size: 32768
Qwen 2.5는 native 32K를 지원한다. 댓글 100개 + 본문까지 다 넣어도 입력은 들어간다. 이게 안 되면 청킹·요약 캐스케이드 같은 부가 로직이 필요해지는데, 그걸 안 해도 되는 게 컸다. 다만 들어가는 것과 잘 답하는 것은 다른 문제라서, 그게 다음 절(2-Stage 추론) 의 동기가 됐다.
LLM에 본문 전체와 질문을 한 번에 던지면, 소형 모델은 질문과 무관한 구절까지 답변에 끌고 들어온다. 그래서 추론을 두 단계로 쪼갰다.
Stage 1 (temp 0.1, 짧게): "질문과 관련 있는 구절만 인용해라"
↓ 추출된 구절
Stage 2 (temp 0.2~0.4, stream): "이 구절로만 답해라"
Stage 1의 출력은 인용 구절 5개 이하로 제한하고, Stage 2에서는 그 구절만 컨텍스트로 사용한다. 환각이 줄어드는 게 체감으로 느껴진다.
다만 본문이 짧을 땐 Stage 1을 스킵한다.
const STAGE1_THRESHOLD = 2000;
const needsStage1 = postBody.length > STAGE1_THRESHOLD;
짧은 본문은 Stage 1을 거치면 오히려 중요 문장이 추출 단계에서 빠질 위험이 있다. 모델한테 두 번 일 시키는 비용보다 손실이 큼.
"3줄 요약해줘" 라고 했는데 답변에 댓글이 섞여 나오는 일이 있었다. 본문이랑 댓글을 한 컨텍스트에 다 넣어두니 LLM이 뭘 요약해야 하는지 헷갈리는 것.
세 가지 모드로 쪼갰다.
| 모드 | 컨텍스트 | 쓰임 |
|---|---|---|
post | 본문만 | 본문 요약, 핵심 정리 |
comments | 댓글만 | 댓글 반응 분석, 스팸 찾기 |
all | 둘 다 | 본문+댓글 종합 |
각 모드마다 시스템 프롬프트가 다르다. post 모드 프롬프트엔 "댓글은 무시하라", comments 모드 프롬프트엔 "본문 정보는 추측하지 마라" 가 들어간다. 모드는 사용자가 토글할 수도 있고, 키워드 감지(detectContext()) 로 자동 결정되기도 한다.
const isSummary = /요약|핵심|정리|3줄/.test(input);
const temperature = isSummary ? 0.2 : 0.4;
요약은 정확성이 우선이라 낮게, 분석·코멘트는 약간의 표현 다양성이 필요해서 높게. 일률적으로 0.7 같은 값을 쓰는 것보다 체감 품질이 명확히 좋았다.
frequency_penalty: 0.6
presence_penalty: 0.4
소형 모델이 같은 표현·같은 문장을 반복하는 경향이 있다. frequency_penalty 를 0.6 정도까지 올리니 "~입니다. ~입니다. ~입니다." 같은 패턴이 눈에 띄게 줄었다.
모든 시스템 프롬프트의 끝에 COMMON_RULES 한 덩이를 붙여놨다.
- 본문에 없는 사실은 만들지 마라
- 가짜 URL, 가짜 인용을 만들지 마라
- "분석해보겠습니다" 같은 메타 텍스트 금지
- 같은 문장을 반복하지 마라
이런 방어 규칙을 시스템 프롬프트마다 따로 적으면 동기화가 안 된다. 한 덩이로 빼고 모드별 프롬프트 끝에 합치는 식.
아직 미흡한 부분:
이 글은 모델·프롬프트 위주였다. 그런데 이 모든 게 동작하기 전에, 그냥 "Worker가 안 뜬다" 는 문제로 며칠을 날렸다. 다음 편은 그 기록이다.
3편 — Web Worker 3중 버그를 잡은 기록
webpack이 react-dom을 같이 묶는 함정, COEP/CORP, Blob URL과 WASM 경로