챗봇 프로젝트가 어그러지는 방식은 대개 비슷합니다. “LLM으로 뭐든 답하는 챗봇” 으로 시작해서, 데모는 잘 되는데 실서비스에 올리면 엉뚱한 답을 하고, 그때부터 프롬프트를 계속 덧붙이다가 아무도 손 못 대는 상태가 됩니다.
이 글은 그 앞단 — 무엇에 답할 챗봇인지 먼저 정하는 일과, 그걸 구현할 프레임워크 선택을 다룹니다.
1. 요구사항에서 시나리오 뽑기
먼저 정해야 하는 것은 “안 하는 일”
챗봇 설계의 첫 단계는 범위를 좁히는 것입니다. “모든 질문에 답한다”는 목표는 달성 여부를 잴 수 없고, 사용자 기대만 부풀립니다.
범위 안: 학사 규정, 수강신청 절차, 장학금 종류와 자격
범위 경계: 개인 성적·수강 내역 (인증 필요)
범위 밖: 학과 선택 상담, 진로 조언, 타 기관 업무
범위 밖 질문에 어떻게 응답할지까지가 설계입니다. “죄송합니다”만 하고 끝내면 사용자는 막힙니다.
실제 문의 로그에서 시작합니다
가상 시나리오를 상상해서 만들면 실제 트래픽과 어긋납니다. 상담 이력, 게시판 질문, FAQ 조회수가 최고의 출발점입니다.
1. 최근 6개월 문의 1,000건을 수집한다
2. 유형별로 분류한다 (수강신청 32%, 장학금 21%, 휴복학 18% …)
3. 상위 유형이 전체의 몇 %를 덮는지 본다
4. 상위 3~5개 유형만으로 1차 범위를 잡는다
보통 상위 5개 유형이 전체 문의의 70~80% 를 덮습니다. 나머지 롱테일에 매달리기보다 이쪽을 확실히 하는 게 체감 효과가 큽니다.
유형별로 필요한 것이 다릅니다
| 유형 | 예 | 필요한 구조 |
|---|---|---|
| 단순 조회 | “휴학 신청 기간은?” | RAG 한 번 |
| 조건부 안내 | “저 복수전공 가능해요?” | 슬롯 채우기 (학기, 평점) |
| 다단계 절차 | “수강신청 어떻게 해요?” | 단계별 안내 + 진행 상태 |
| 개인 데이터 | “제 이수 학점은?” | 인증 + DB 조회 |
| 범위 밖 | “학식 맛있어요?” | 정중한 거절 + 대안 안내 |
조건부 안내가 설계 난이도를 결정합니다. 답이 사용자 상황에 따라 갈리므로, 되물어서 조건을 채워야 합니다.
User: 복수전공 신청하고 싶어요
Bot: 복수전공은 2개 학기 이상 이수하고 평점 2.5 이상이어야 합니다.
현재 몇 학기 이수하셨나요? ← 슬롯 1
User: 3학기요
Bot: 평점은 2.5 이상이신가요? ← 슬롯 2
User: 2.8이요
Bot: 두 조건을 모두 충족하십니다. 신청은 ...
되묻는 횟수가 3회를 넘으면 사용자가 이탈합니다. 필수 조건만 묻고 나머지는 “~인 경우에는” 식으로 조건부 안내로 돌리는 게 낫습니다.
2. 대화 상태를 무엇으로 관리할 것인가
LLM 챗봇이라고 상태를 전부 대화 이력에 맡기면 안 됩니다.
나쁨: 대화 이력을 통째로 프롬프트에 넣고 모델이 알아서 하기를 기대
→ 길어질수록 앞부분을 잊고, 비용이 계속 늘고, 디버깅 불가
좋음: 명시적 상태 + 필요한 이력만
→ state = { intent: "복수전공", slots: {학기: 3, 평점: null}, step: 2 }
명시적 상태를 두면 얻는 것이 큽니다.
- 어디서 막혔는지 보입니다. “평점 슬롯이 안 채워져 이탈” 같은 분석이 가능해집니다
- 재개할 수 있습니다. 사용자가 나갔다 돌아와도 이어집니다
- 테스트할 수 있습니다. 상태 전이를 단위 테스트로 검증합니다
질의 재작성은 필수입니다
RAG 글에서도 짚었지만, 멀티턴에서 이게 없으면 검색이 무너집니다.
User: 휴학 신청 언제까지예요?
Bot: 매 학기 개시일로부터 30일 이내입니다.
User: 그럼 복학은요?
검색어를 "그럼 복학은요?" 로 쓰면 → 아무것도 못 찾음
재작성: "복학 신청 기간은 언제까지인가?" → 정상 검색
이력 관리 전략
| 방식 | 내용 | 언제 |
|---|---|---|
| 전체 유지 | 모든 턴을 그대로 | 짧은 대화(5턴 이내) |
| 최근 N턴 | 최근 것만 슬라이딩 | 대부분의 경우 기본값 |
| 요약 + 최근 | 오래된 것은 요약하고 최근은 원문 | 긴 상담형 대화 |
| 구조화 상태만 | 이력 대신 추출된 슬롯만 유지 | 절차형 대화 |
절차형 대화에서는 마지막이 가장 좋습니다. 사용자가 뭐라고 말했든 “학기=3, 평점=2.8”만 들고 있으면 되므로, 컨텍스트가 일정하게 유지됩니다.
3. 폴백과 상담사 연결
답을 못 하는 경우를 정상 동작으로 설계해야 합니다.
못 하는 이유가 여러 가지입니다
| 상황 | 대응 |
|---|---|
| 검색 점수가 임계값 미만 | LLM을 아예 호출하지 않고 폴백 |
| 범위 밖 주제 | 담당 부서 안내 |
| 개인 정보 필요 | 로그인 유도 |
| 같은 질문 3회 반복 | 상담사 연결 제안 |
| 부정적 감정 감지 | 상담사 연결 제안 |
| 시스템 오류 | 사과 + 다른 경로 안내 |
검색 점수 임계값 폴백이 환각 방지의 마지막 방어선입니다.
results = hybrid_search(query)
if not results or results[0].score < THRESHOLD:
return ("해당 내용은 제가 가진 자료에서 확인되지 않습니다. "
"학사지원팀(내선 1234)으로 문의해 주세요.")
# 임계값을 넘을 때만 LLM 호출
return generate_answer(query, results)
LLM을 부르지 않는 것이 핵심입니다. 관련 없는 문서를 주고 “모르면 모른다고 해”라고 부탁하는 것보다, 애초에 안 부르는 쪽이 훨씬 확실합니다.
상담사 연결은 맥락을 넘겨야 합니다
나쁨: "상담사를 연결해 드리겠습니다" → 사용자가 처음부터 다시 설명
좋음: 상담사 화면에 함께 전달
- 대화 전문
- 추출된 슬롯 (학기=3, 평점=2.8)
- 봇이 못 답한 이유 (검색 결과 없음 / 범위 밖)
4. 프레임워크 선택 — LangChain, LlamaIndex, 직접 구현
철학이 다릅니다
| LangChain | LlamaIndex | |
|---|---|---|
| 출발점 | 오케스트레이션. LLM 호출을 어떻게 엮을까 | 데이터. 내 문서를 어떻게 LLM에 연결할까 |
| 중심 추상화 | Runnable, Chain, Agent | Index, Node, Retriever, QueryEngine |
| 강한 곳 | 복잡한 흐름, 도구 연동, 다양한 통합 | 문서 적재·색인·검색 파이프라인 |
| 상태 관리 | LangGraph (별도, 그래프 기반) | Workflow (이벤트 기반) |
| 통합 개수 | 매우 많음 | 데이터 소스·벡터 DB 중심으로 많음 |
둘 다 요즘은 서로의 영역을 상당히 커버합니다. LlamaIndex도 에이전트를 지원하고 LangChain도 문서 로더가 많습니다. 그래도 어느 쪽이 더 자연스럽게 되는지는 여전히 갈립니다.
LangChain 의 LCEL
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
chain = (
RunnableParallel(
context=retriever | format_docs,
question=RunnablePassthrough(),
)
| prompt
| llm
| StrOutputParser()
)
answer = chain.invoke("휴학 신청 기간은?")
| 로 단계를 잇는 방식입니다. 스트리밍·비동기·배치가 전 구간에서 자동으로 따라옵니다. 흐름이 선형이면 읽기 좋습니다.
분기·순환·상태가 들어가면 LangGraph 로 넘어갑니다.
from langgraph.graph import StateGraph, END
graph = StateGraph(ChatState)
graph.add_node("classify", classify_intent)
graph.add_node("retrieve", retrieve_docs)
graph.add_node("fill_slots", ask_missing_slot)
graph.add_node("generate", generate_answer)
graph.add_node("fallback", fallback_message)
graph.add_conditional_edges(
"classify",
lambda s: s["intent_type"],
{
"simple": "retrieve",
"slotted": "fill_slots",
"out_of_scope": "fallback",
},
)
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)
앞서 말한 명시적 상태 관리가 LangGraph에서는 기본 구조입니다. 체크포인터를 붙이면 대화 재개도 따라옵니다. 슬롯 채우기처럼 되돌아가는 흐름이 있으면 LCEL보다 이쪽이 맞습니다.
LlamaIndex 의 인덱스 중심
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.core.postprocessor import SentenceTransformerRerank
documents = SimpleDirectoryReader("./docs").load_data()
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine(
similarity_top_k=20,
node_postprocessors=[SentenceTransformerRerank(top_n=5)],
)
response = query_engine.query("휴학 신청 기간은?")
print(response.source_nodes) # 출처가 기본으로 따라온다
“문서 → 색인 → 질의”가 한 줄씩입니다. 재순위화 같은 후처리도 파이프라인 옵션으로 붙습니다. RAG가 중심인 프로젝트라면 초기 속도가 확실히 빠릅니다.
계층적 검색(문서 요약 인덱스 → 상세 인덱스), 부모-자식 청킹 같은 RAG 고급 패턴이 기본 제공되는 것도 강점입니다.
직접 구현이 나을 때
프레임워크가 항상 이득은 아닙니다.
| 프레임워크가 유리 | 직접 구현이 유리 |
|---|---|
| 프로토타입을 빨리 만들어야 함 | 흐름이 단순함 (검색 → 프롬프트 → 호출) |
| 여러 벡터 DB·로더를 갈아 끼움 | 성능·비용을 토큰 단위로 통제해야 함 |
| 팀이 그 프레임워크에 익숙 | 디버깅이 중요함 — 추상화 아래를 봐야 함 |
| 에이전트·도구가 많음 | 의존성을 줄여야 함 (배포·보안 심사) |
솔직한 관찰 하나는, RAG 챗봇의 핵심 로직은 생각보다 짧다는 것입니다.
# 프레임워크 없이도 이 정도
def answer(question, history):
q = rewrite_query(question, history) # LLM 호출 1
docs = hybrid_search(q, filters=...) # ES 호출
docs = rerank(q, docs)[:5] # 재순위 모델
if not docs or docs[0].score < THRESHOLD:
return FALLBACK
return llm_generate(q, docs) # LLM 호출 2
어려운 것은 이 뼈대가 아니라 검색 품질, 평가, 운영입니다. 프레임워크가 그걸 대신해 주지는 않습니다.
실무에서 자주 보는 절충은 이렇습니다.
색인·검색 파이프라인 → LlamaIndex 나 직접 구현 (데이터 처리에 강한 쪽)
대화 흐름·상태 → LangGraph 나 직접 구현 (상태 관리가 필요한 쪽)
한쪽 프레임워크에 전부 밀어 넣지 않고, 강한 부분만 쓰는 것입니다.
선택할 때 확인할 것
- 추상화를 뚫고 내려갈 수 있는가 — 최종 프롬프트와 실제 요청을 볼 수 있어야 합니다. 못 보면 디버깅이 불가능합니다
- 버전 안정성 — LLM 프레임워크는 변화가 빠릅니다. 마이너 버전에 API가 깨질 수 있다는 걸 감안해야 합니다
- 관측성 연동 — 어떤 추적 도구를 붙일 수 있는지
5. 설계 체크리스트
프로젝트 시작 전에 이 표를 채울 수 있어야 합니다.
| 항목 | 정해야 할 것 |
|---|---|
| 범위 | 답할 유형 / 안 답할 유형 |
| 데이터 | 어떤 문서, 누가 갱신, 갱신 주기 |
| 인증 | 개인 정보 질문을 다루는가 |
| 대화 상태 | 슬롯이 필요한 시나리오가 있는가 |
| 폴백 | 임계값, 폴백 문구, 반복 실패 시 동작 |
| 핸드오프 | 상담사 연결 조건과 넘길 맥락 |
| 평가 | 골든셋, 성공 지표 |
| 로깅 | 무엇을 남길 것인가 (다음 글 주제) |
이게 안 채워진 상태로 프롬프트부터 쓰기 시작하면, 나중에 전부 다시 합니다.
정리
| 질문 | 답 |
|---|---|
| 설계의 첫 단계는 | 안 하는 일을 정하는 것. 범위 밖 응답까지가 설계 |
| 시나리오는 어디서 | 상상이 아니라 실제 문의 로그. 상위 5개 유형이 보통 70~80%를 덮는다 |
| 대화 상태는 | 이력에 맡기지 말고 명시적 상태(intent, slots, step)로 |
| 멀티턴 검색이 깨진다 | 질의 재작성 없이는 안 된다 |
| 환각 최후 방어선은 | 검색 점수 임계값에서 LLM을 아예 호출하지 않는 것 |
| LangChain vs LlamaIndex | 오케스트레이션이면 LangChain(+LangGraph), 데이터·색인 중심이면 LlamaIndex. 섞어 써도 된다 |
| 프레임워크가 꼭 필요한가 | 아니다. RAG 뼈대는 짧다. 어려운 건 검색 품질·평가·운영이고 그건 대신 안 해 준다 |
참고 문헌
- LangChain 공식 문서: https://python.langchain.com/docs/introduction/
- LangGraph 공식 문서: https://langchain-ai.github.io/langgraph/
- LlamaIndex 공식 문서: https://docs.llamaindex.ai/
- LlamaIndex — Advanced Retrieval: https://docs.llamaindex.ai/en/stable/optimizing/advanced_retrieval/advanced_retrieval/