요약:
- '대부분의 질문은 LLM에 도달하지 않습니다. 작은 클라이언트 측 매처가 즉시 답변하고, 확신이 서지 않으면 조용히 두고 다음 단계로 넘깁니다.'
- '틀리지만 자신감 있는 답변은 느리지만 정확한 답변보다 나쁩니다. 따라서 모든 임계값은 다음 단계로 넘기도록 조정되었습니다.'
- 'POST를 사용하는 SSE는 EventSource가 POST를 지원하지 않기 때문에 fetch와 직접 구현한 파서가 필요합니다.'
- '잠자는 무료 티어 백엔드는 부팅 애니메이션 동안의 웜업 ping으로 처리되며, 이것이 바로 부팅 애니메이션이 존재하는 이유이기도 합니다.'
chat page로 이동하면 터미널 형태로 저와 대화하고 있습니다. 이 글은 제가 그것을 구축한 방법 중 파트 1입니다: 브라우저 측면입니다. [파트 2 - 곧 공개]에서는 서버에 대해 다룹니다.
세부적인 비밀이나 정확한 설정 값들은 건너뛰겠습니다. 이것은 청사진이 아니라 재미있는 부분들을 둘러보는 투어입니다.
1. 가장 좋은 LLM 호출은 하지 않는 호출이다
Amit.AI의 첫 번째 버전은 정적 FAQ 매처였습니다. AI가 아닌, '질문이 이런 모양이면 저 답변을 보여준다'는 방식이었습니다. 빠르고 무료였지만, 창의적인 방식으로 자신감 있게 틀리기도 했습니다.
그 후 실제 LLM 백엔드를 추가했고, 당연한 움직임은 모든 것을 그것으로 보내는 것이었습니다. 하지만 저는 세 가지 이유 때문에 그렇게 하지 않았습니다:
- 무료 티어 LLM 할당량은 적고, 방문자들은 계속 '무엇을 하느냐?', '사용 가능한가?' 같은 질문을 합니다.
- 백엔드는 아무도 없을 때 잠듭니다. FAQ 답변은 그것이 깨어있을 필요가 없습니다.
- 즉각적인 답변이 스트리밍되는 답변보다 더 나은 느낌을 줍니다.
따라서 채팅은 두 가지 계층으로 구성됩니다. Tier 0는 브라우저에 존재하며 확신하는 내용에 대해 답변합니다. 나머지 모든 것은 백엔드로 전달됩니다.
→ 확신하면? 답변한다
message → guardrails → FAQ matcher -|
→ 확신하지 못하면? LLM으로 다음 단계로 넘긴다
기본적으로 작은 무료 의도 라우터
돌이켜보면, Tier 0 매처는 **의도 라우터(intent router)**를 저렴하게 구현한 것입니다. 비싼 LLM이 개입하기 전에 메시지로 무엇을 할지 결정하는 초기 단계입니다.
실제적이고 제대로 된 버전의 아이디어들이 있습니다:
- TypeSafe AI의 Jev는 채팅보다는 소프트웨어 내부의 의사결정 모델로 소개되었습니다. 구조화된 질문을 하면 확률과 함께 유형이 지정된(typed) 결정을 반환하므로, 신뢰도가 높으면 코드가 작동하고 낮으면 에스컬레이션할 수 있습니다.
- Laya는 텍스트를 전혀 생성하지 않는 오픈 소스(Apache-2.0) "System 1" 의사결정 엔진입니다. 주어진 텍스트에 대해
choice, score 그리고 예/아니오 질문에 답하며, 라우팅 및 프롬프트 가드레일을 위한 프리셋을 제공합니다.
제 방식은 ML과는 다소 유사한 형태를 띠고 있습니다. 모델도 없고, 학습도 없고, GPU도 없습니다. 단지 토큰 중첩 점수(token-overlap score)와 몇 가지 임계값(thresholds), 그리고 JSON 파일만 사용합니다. 방문자의 브라우저에서 무료로 실행됩니다. "신뢰도"는 메시지가 아래 게이트를 통과하는지를 나타내며, 신뢰도가 낮으면 "큰 모델(big model)"로 전송합니다.
이것은 위 두 가지보다 훨씬 멍청하지만, 또한 ₹0이고 즉각적입니다. 몇십 개의 FAQ 주제를 가진 포트폴리오 챗봇이라면 그 트레이드오프는 괜찮습니다.
2. 언제 침묵해야 하는지 아는 매처(matcher)
매처는 세 가지 중 하나, 즉 guard, faq 또는 defer를 반환합니다. 의도적으로 네 번째 "확신할 수 없음"이라는 답변은 없습니다. defer는 조용히 처리됩니다. 방문자는 타이핑 표시기(typing indicator)가 조금 더 오래 보이는 것만 볼 뿐입니다.
첫 버전에서는 하나의 유사성 임계값을 사용했는데, 제가 웃겨워할 정도로 실패했습니다. "Java에 얼마나 능숙한가요?"라고 물었더니, 다른 단어들이 중첩되었기 때문에 제 React 답변을 기꺼이 반환했습니다.
해결책은 단일 점수에 의존하는 것을 멈추는 것이었습니다. FAQ 답변은 모든 게이트를 통과할 때만 나갑니다:
- **정확한 구문 일치(exact phrasing)**가 항상 승리합니다.
- 그렇지 않다면, 방문자의 대부분의 내용어(content words)는 하나의 FAQ 구문에 의해 커버되어야 합니다. 그래야 "Java"가 "good"과 "you" 뒤에 숨을 수 없습니다.
- 최소한 두 개의 의미 있는 단어가 중첩되어야 합니다.
- 승자는 경쟁자보다 명확하게 높은 점수를 받아야 합니다. 근접한 후보들이 있다는 것은 매처가 추측하고 있다는 뜻이므로,
defer합니다.
‘that’, ‘more’ 및 ‘those’ 같은 단어는 매처(matcher)가 기억력이 없기 때문에 첫 번째 차례 이후의 모든 차례를 지연시키도록 강제합니다. “Tell me more”는 매번 다른 의미를 가집니다.
튜닝을 하는 동안 제가 세운 규칙은 다음과 같습니다: 확신에 차 있지만 틀리는 것이 느리지만 맞는 것보다 더 나쁘다. 제가 만든 모든 변경 사항은 지연(defer) 쪽으로 기울어졌습니다.
불용어(stopword)의 함정
**불용어(stopword)**란 매처가 점수 계산 시 무시하는 채움 단어(filler word)로, “what”, “are”, “the” 또는 “you”와 같습니다. 오직 남아 있는 내용어(content words)만이 위쪽의 중복(overlap) 및 커버리지(coverage) 게이트에 기여합니다.
여기 문제가 있습니다. 제 FAQ는 수신자(terminal)가 바로 저이기 때문에 2인칭으로 작성되었습니다: “What are your skills?” 하지만 방문자는 그것을 모릅니다. 그들은 Amit라는 사람에 대한 사이트를 보고 “what are his skills?” 또는 “what is Amit's stack?”이라고 질문합니다.
첫 번째 경우를 살펴보겠습니다:
- FAQ 문구 “what are your skills”는 하나의 내용어인
skills로 축소됩니다. (“what”, “are” 및 “your”는 모두 불용어입니다.)
- 방문자의 “what are his skills”는
his와 skills로 축소되는데, 이는 his가 불용어가 아니었기 때문입니다.
- 방문자가 가진 두 개의 내용어 중 하나만 커버되므로 커버리지는 0.5이며, 0.7 게이트보다 낮습니다. 매처는 지연(defer)하고, 즉시 답변할 수 있었던 질문이 LLM으로 넘어갑니다.
해결책은 his, him, her, their, amit 및 mondal을 불용어 목록에 추가하는 것이었습니다. 이 단어들은 오직 한 사람에 대한 사이트에서는 아무런 의미를 가지지 않으므로, 점수 계산 시 보이지 않아야 합니다. 이제 두 문구 모두 skills로 축소되며 즉시 답변이 가능합니다.
물론 반대편도 있습니다.
백엔드는 Server-Sent Events로 응답을 스트리밍하므로 토큰이 생성되는 대로 나타납니다. 브라우저에는 이를 위한 내장 EventSource가 있지만, 이는 GET 요청만 할 수 있고 저는 대화 내용을 POST해야 합니다.
그래서 저는 fetch를 사용하고 바디(body) 스트림을 직접 읽었습니다. 핵심은 한 줌의 코드에 담겨 있습니다:
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
...
SSE 프레임은 빈 줄로 구분되며, 네트워크 청크는 그 중간에서 끝날 수 있습니다. 따라서 버퍼링하고 \n\n으로 분할한 다음, 남은 부분을 다음 읽기에 보관해야 합니다. 이 한 가지 디테일 때문에 대부분의 직접 구현 파서가 실패합니다.
세 가지 이벤트 유형이 있습니다: token, done, 그리고 error. UI는 첫 번째 토큰이 도착하면 빈 AI 버블을 표시하고 그 안에 내용을 추가합니다. 타이핑 인디케이터는 첫 토큰이 도착할 때까지 유지되므로, 대기 시간이 멈춘 것처럼 보이지 않습니다.
한 가지 안전 습관: 메시지 텍스트는 {{ }}로 렌더링하며 절대 v-html을 사용하지 않습니다. 모델 출력은 신뢰할 수 없는 입력(untrusted input)이므로, 챗봇이 제 페이지에 HTML을 주입하도록 내버려 두지 않을 것입니다.
4. 잠자는 백엔드
제 백엔드는 유휴 상태가 되면 작동을 멈추는 무료 티어에서 실행됩니다. 낮잠에서 깨어난 후의 첫 요청은 시간이 오래 걸릴 수 있습니다.
저는 이를 계층적으로 처리했습니다:
- Tier 0 (기본): 일반적인 기능들을 다룹니다. 대부분의 방문자는 백엔드가 잠들어 있다는 사실을 알아차리지 못합니다.
- 웜업 핑(A warm-up ping): 채팅 페이지가 로드되는 순간, 브라우저는 조용히 백엔드의 상태 엔드포인트에 핑을 보냅니다. 질문을 입력할 때쯤이면 종종 이미 깨어 있습니다.
- 관대한 첫 토큰 타임아웃, 그 다음 전체 타임아웃. 이후에는 절망적인 스피너 대신 친근한 오프라인 메시지를 표시합니다.
부팅 애니메이션(가짜 > initializing... 줄)은 부분적으로 멋져 보이기 때문에 존재하며, 부분적으로는 핑이 몇 초 동안 시간을 벌어주기 때문입니다. 제가 처음부터 그렇게 계획했다고 가장하지 않겠습니다.
5. 양쪽의 가드레일(Guardrails)
가드레일(Guardrail)은 답변이 생성되기 전에 실행되는 저렴한 패턴 검사입니다. 메시지가 채팅 시스템이 다루지 않아야 하는 내용처럼 보이면, 미리 준비된 답장(canned reply)을 받게 되며, FAQ나 LLM은 그 내용을 전혀 볼 수 없습니다. 가드레일에는 두 가지 종류가 있습니다.
- 탈옥(Jailbreak) 스타일 메시지: "이전 지침을 모두 무시해라" 또는 "네가 ~라고 가정하라"와 같은 유형입니다. 이 경우, 답변은 해당 터미널이 나의 전문 프로필에 한정되어 있음을 정중하게 알립니다.
- 개인 정보 요청: 전화번호, 집 주소, 생년월일 등과 같은 사적인 세부 정보를 요구하는 경우입니다. 이때는 그러한 정보는 이곳에서 공유되지 않는다고 답변하며 연락처(Contact) 페이지로 안내합니다.
이와 동일한 아이디어가 서로 다른 이유로 두 번 존재합니다.
| 위치 | 역할 | 존재하는 이유 |
|---|
| 브라우저 | 속도 | 차단된 메시지는 네트워크 요청이나 느린 백엔드 대기 시간 없이 즉시 답변을 받습니다. |
| 서버 | 강제 적용(Enforcement) | 누구나 내 UI를 건너뛰고 API를 직접 호출할 수 있습니다. 브라우저에서 실행되는 코드는 편의성일 뿐, 결코 보안 경계가 될 수 없으므로, 서버는 모든 것을 재검사하고 최종 결정권을 가집니다. |
따라서 프론트엔드(frontend) 복사본은 일반적인 경우를 빠르고 편리하게 처리하며, 백엔드(backend) 복사본이 실제로 무언가를 보호하는 역할을 합니다. 만약 이 둘 사이에 의견 불일치가 생기면, 서버의 결정이 우선합니다.
어려웠던 부분은 패턴을 작성하는 것이 아니라, 무고한 질문을 차단하지 않는 것이었습니다. 제 초기 버전들은 너무 직설적이었습니다.
- 단순히
number라는 단어만으로 "당신이 구축한 프로젝트의 **개수(number)**는 무엇인가요?"를 차단했습니다.
- 단순히
address라는 단어만으로 "성능 문제를 어떻게 다루었는지(address) 설명해 주시겠어요?"를 차단했습니다.
- 단순히
dob가 포함된 경우("A*dob*e")도 일치시켜서 차단했습니다.
해결책은 두 가지 규칙을 사용하는 것입니다: 항상 단어 경계(\b)를 사용하고, 단어가 아닌 *구(phrase)*와 일치시키는 것입니다. 제가 신경 쓰는 요청은 "전화번호(phone number)"와 "집 주소(home address)"이며, 단순히 "개수(number)"나 "주소(address)"만으로는 충분하지 않습니다. 실제 채용 담당자들을 짜증 나게 하는 가드레일은 가드레일이 없는 것보다 더 나쁩니다. 그래서 의심스러울 때는 메시지를 서버와 프롬프트로 전달하여, 이들이 문맥을 판단하는 데 더 능숙하게 하도록 했습니다.
6. 제 역할을 다하는 작은 개선점들
- 프론트엔드 답변 캐시(A frontend answer cache). 같은 질문을 탭에서 두 번 할 경우, 두 번째 답변은
sessionStorage에서 가져오며 브라우저를 벗어나지 않습니다. 스토리지에 오류가 발생하거나 차단될 수 있으므로, 메모리 내의 Map이 진실 공급원(source of truth)이며 스토리지 기능은 보너스입니다.
- 히스토리 자르기(History trimming). 각 지연된 메시지에는 최근 몇 번의 대화 턴만 포함됩니다. 같은 질문을 두 번 할 경우, 가장 최신 쌍만 유지되므로 모델에 자신의 반복 내용을 먹이지 않습니다.
- 로컬에서 답변한 메시지에 대한 가짜 생각 시간(A fake thinking pause). 5ms 만에 응답하는 것은 어색하게 느껴지며, 짧고 약간 무작위적인 지연 시간이 더 인간적으로 느껴집니다. 이것은 거짓말이지만, 저는 그것에 만족합니다.
- 입력창의 문자 카운터 링(A character counter ring). 메시지 제한에 가까워질수록 채워지는 입력창의 원형 표시줄로, 백엔드가 '안 됩니다'라고 말할 필요가 없게 합니다.
다음 내용
이것이 브라우저 측 구현입니다. 흥미로운 질문들은 다른 쪽에 있습니다. 벡터 데이터베이스 없이 바이오 정보를 LLM에 어떻게 기반화(ground)할 것인가, 무료 모델이 한도에 도달했을 때 무슨 일이 발생하는가, 그리고 사람들이 저에게 바보 같은 말을 하도록 하는 것을 어떻게 막을 수 있는가 등이 그것입니다.
이것이 파트 2이며 곧 공개될 예정입니다.