Beacon Chat
대화를 통해 인용 자료의 신뢰도를 능동적으로 끌어올리는 LLM 챗봇
- 무엇
- 건설 현장을 3D로 찍어 관리하는 SaaS 위에 올린 LLM 챗 서버와 패널 UI다.
- 왜
- "3층 안전 지적이 몇 건이냐"는 물음에는 숫자 하나로 공사가 멈추고 정산이 갈린다.
- 어떻게
- 검색 결과가 전수인지 표본인지를 코드가 먼저 등급으로 판정하고, 그 등급이 모델이 쓸 수 있는 문장의 형태를 제한한다. 등급이 낮으면 되물어 범위를 좁힌다.
- 새로움
- 모델이 확신을 흉내 내는 대신 대화로 확신을 얻는다. 표본 인용이 전수 인용으로 승격되는 경로가 대화 안에 있다.
역할 분담
챗 서버는 단독으로 구현했고, 제품 전체는 기술 PM(TPM) 으로 세 팀과 함께 이끌었다.
| 범위 | 담당 |
|---|---|
| 챗 서버 · 패널 UI · 에이전트 오케스트레이션 하네스 | 함어진 (단독) |
| MCP 서버 | Andrew (Algorithm 팀) |
| AWS Lambda 배포 환경 구축 | Jerry · Lyva (Backend 팀) |
모든 수치에 확정 등급을 매기고, 역질문으로 범위를 좁혀 "최소 N건"을 "정확히 N건"으로, 표본 인용을 전수 인용으로 승격시키는 건설 현장 검색 시스템
10장을 보여주면서 "10장을 찾았다"고 말하지 않는 챗봇입니다.
건설 현장에서 "3층 안전 지적사항이 몇 건이냐"는 잡담이 아닙니다. 그 숫자로 작업이 중지되고 하도급 정산이 갈립니다.
그런데 LLM 챗봇이 숫자를 틀리는 가장 흔한 경로는 환각이 아닙니다. 아무도 거짓말하지 않았는데 결과적으로 거짓말이 되는 경로가 따로 있습니다. 실제로 측정해 보니 이랬습니다.
- 검색 엔진은 "조건에 맞는 게 전부 몇 건인지"를 응답에 담아 보냅니다.
- 그 앞의 MCP 서버가 파싱 과정에서 그 필드를 버립니다. 그러고는 잘라내고 남은 개수에
Total:이라는 라벨을 붙입니다. - 우리 챗 서버는 모델이 요청한 개수가 10을 넘으면 조용히 10으로 줄입니다.
- 모델은 받은 10건을 세어 "10건을 찾았습니다"라고 답합니다.
사용자는 12장을 요청했고, 10장을 봤고, 그게 전부라고 믿습니다.
이 프로젝트는 그 사슬을 등급 체계로 끊고, 거기서 한 걸음 더 나가 대화로 등급을 올립니다.
무엇을 만들었나
건설 현장을 3D로 촬영해 관리하는 B2B SaaS 위에 올린 LLM 챗 서버(TypeScript) 와, 두 개의 앱에 붙여 쓰는 패널 UI(Angular) 입니다. 사용자는 촬영된 현장 데이터를 자연어로 검색합니다.
모델은 문장만 씁니다. 개수·부재·실패에 대한 판단은 전부 코드가 합니다.
어디까지 검증했나
사내 데모를 3단계(CLI → 브라우저 → 전체 UI)로 나눠, dev·qa 실제 배포본에 실계정·실토큰으로 왕복시켜 통과시켰습니다. 마지막 단계는 기능 구현과 내부 검증을 마쳤고 지금은 UI/UX를 다듬고 있습니다.
아직 프로덕션 사용자 트래픽은 없습니다. 이 문서의 어떤 숫자도 사용자 규모를 주장하지 않습니다.
규모
테스트 코드가 소스 코드의 2.14배 — 서버 소스 25,283줄 vs 테스트 54,227줄 (약 2,817 케이스)
패널 UI 10,284줄 · 브라우저 e2e 16개 · 상태 전이표 116행(금지된 전이 포함) · CLI 재현 스크립트 17개 · 외부 계약 경계 103개 등록 · 31일간 18번 릴리스.
내가 한 일
서버·패널·검증 도구를 설계하고 구현했습니다. 그리고 코드를 고칠 권한이 없는 외부 팀의 API를 측정 리포트로 두 번 바꿨습니다.
흔한 LLM 챗봇과 뭐가 다른가
| # | 흔한 구현 (ChatGPT 클론 · RAG 튜토리얼 · MCP 툴 호출 데모) | 이 프로젝트 |
|---|---|---|
| 1 | 모델이 받은 배열 길이를 세어 "N개를 찾았습니다"라고 답한다 | 말할 후보가 되는 모든 숫자를 말하기 전에 4등급(정확 / 하한 / 표시분 / 불명)으로 판정한다. 등급마다 쓸 수 있는 문장 형태가 정해져 있고, 불명이면 숫자를 아예 못 쓴다 |
| 2 | 애매하면 되묻는다 (프롬프트에 그렇게 써 둔다) | 되묻기가 등급을 올리는 연산이다. 좁힌 조건이 한 페이지에 들어가고 그 뒤가 없다는 게 확인되면, 화면의 카드가 표본에서 전수로 바뀌고 등급이 올라간다 |
| 3 | 검색 결과가 비면 "없습니다"라고 답한다 | "없다"고 말하려면 자격이 필요하다. 서버가 집계 정보를 붙여 "이건 내 검색 결과다"라고 표시한 응답에서만 부재를 주장한다. 자격이 없으면 아무 말도 안 한다 |
| 4 | 툴이 실패했는지를 isError 하나로 판단한다 |
HTTP 200으로 위장해서 오는 실패를 잡아낸다. 판단 근거는 에러 문구가 아니라 정상 응답이면 반드시 붙는 메타데이터가 없다는 사실이다 — 그래서 상대가 문구를 바꿔도 안 깨진다 |
| 5 | 권한을 시스템 프롬프트로 지시한다 | 툴을 호출하기 전에 막는다. 데이터가 아예 안 오니까 유출이 구조적으로 불가능하다. 게다가 플래너는 라이선스 관련 단어를 일부러 모른다(프롬프트에 그 단어가 있으면 테스트가 깨진다). 이 축에만 전용 테스트 14개 파일 · 131 케이스 |
| 6 | happy path 스크린샷이 데모 검증의 전부다 | 금지된 전이까지 표로 적은 116행 + CLI 재현 17개 + 브라우저 e2e 16개. 게다가 e2e가 붙는 가짜 서버 자체를 계약 테스트로 검증한다 |
| 7 | 혼자 만들고 혼자 배포한다 | 외부 팀 API를 측정 리포트로 두 번 바꿨다(백엔드 조회 상한 30→200, 커서 페이지네이션 도입 · 도달 가능한 결과 16→38). 받은 구현이 요청과 다른 부분은 우리 쪽에서 방어 조건으로 막았다 |
대표 사례 셋
① 검색 서버가 "이게 끝"이라고 해도 우리 코드가 그 말을 안 믿는다.
데이터 조회 툴은 자연어를 SQL로 바꿔 실행하는데, 그때 LIMIT 3000을 자동으로 붙입니다. 결과가 3,000건에 딱 차면 전체 개수도 3,000으로 고정되고, 페이지를 다 넘겼을 때 "더 없음"이라고 답합니다. 파노라마가 7,961장인 프로젝트에서 실제로 그랬습니다. 서버가 자신 있게 틀린 겁니다. 우리 판정 모듈은 이 포화를 감지해서 전체 개수를 "하한값"으로 다시 분류하고 "더 있음"으로 뒤집습니다. 이 재분류가 어느 쪽으로도 거짓이 되지 않는다는 증명을 코드 주석에 적어 뒀습니다.
② 테스트는 계속 초록불인데 한 번도 동작한 적 없는 경로가 있었다.
데모를 처음 돌렸더니 시나리오 4개가 전부 실패했습니다. 최근 회귀를 의심하고 커밋 이력을 문자열 단위로 뒤져 보니(git log -S), 릴리스 태그 6개 전부에 같은 결함이 있었습니다. 회귀가 아니라 처음부터 죽어 있던 코드였죠. 그런데 그 경로 위의 작업 항목 21장이 이미 완료 처리돼 있었고, 규칙은 전부 지켜진 상태였습니다(머지 검증 100% 통과). 그래서 버그를 고치는 대신 파이프라인을 바꿨습니다 — 양쪽을 이어 붙여 돌리는 테스트를 의무화하고, 계약 점검이 오래됐으면 릴리스를 거부하게 하고, 외부 계약 경계 103개를 목록으로 고정하고, "게이트를 만들 때 그걸 누가 부르는지 같은 커밋에서 답하라" 는 규칙을 세웠습니다. 이 규칙을 기존 자산에 적용하자마자 죽어 있던 게이트 3개가 바로 나왔습니다. 그중 하나는 배포 전 스모크 테스트였는데, 실행 런타임이 설치돼 있지 않아 exit 127로 죽으면서 clean install한 사람에게는 한 번도 돌지 않고 있었습니다.
③ 실패가 성공인 척하고 도착했다.
검색 API가 502로 죽었는데, 그게 isError: false + 평범한 텍스트 한 덩어리로 왔습니다. 우리 서버의 실패 감지기가 isError 하나뿐이라 모델이 에러 문자열을 데이터로 받아서 그 위에 답을 지어냈고, 사용자에게는 실패 신호가 아무 데도 안 떴습니다. 해법은 "0건이면 실패"가 아니었습니다 — 반대로 측정해 보니 진짜 0건 응답에도 메타데이터가 붙어 있었기 때문입니다. 그래서 판별 기준을 "정상 응답이면 반드시 붙는 메타데이터가 없다" 로 잡았고, 에러 문구 자체는 판정에 쓰지 않습니다. 상대가 문구를 바꿔도 안 깨지게요.
한계
- 응답 속도 목표 미달. 목표는 첫 토큰까지 P95 3.5초인데, 실측은 콜드 스타트 약 30초, 웜 상태 20초 이내입니다. 원인의 절반은 아직 안 만든 모델 입력 압축이고, 그게 다음 우선순위입니다.
- 프로덕션 지표가 없습니다. 계측 코드(메트릭 발행, 데이터 웨어하우스 적재)는 다 짜 뒀지만 집계할 트래픽이 아직 없습니다. 사용자 규모·에러율·비용은 일부러 비워 뒀습니다.
- 프롬프트로 지시한 규칙은 지켜진다는 보장이 없습니다. 모델에게 지시하는 형태의 규칙은 "지시문이 프롬프트에 들어갔는지"만 테스트할 수 있습니다. 실제로 모델이 지시를 어긴 사례를 측정했고, 사용자에게 보이는 결과를 구한 건 프롬프트가 아니라 UI 쪽 중복 제거 코드였습니다.
- 권한 전수 테스트는 아직 안 만들었습니다. 설계상 명세한 120셀 전수 검증은 미착수고, 지금은 14개 파일 · 131 케이스의 부분 커버리지입니다.
표기: 코드명 Beacon · 2026-08 · 회사·제품·인명·내부 호스트는 일반화했고, 코드 심볼과 수치는 실제 값입니다.