3장. LangChain 기본 구조와 LCEL
실습 3 — 구름다리여행 Q&A 챗봇 체인
실습 1의 FAQ 프롬프트에 진짜 모델(ChatOpenAI)과 StrOutputParser 를 이어 구름다리여행의 Q&A 챗봇을 만듭니다. 1장의 규칙 챗봇이 놓친 손님 질문과 FAQ 밖의 질문을 물어, 두 챗봇을 견줍니다. 1장 실습 1이 저장한 out_rule_bot_result.csv 를 읽으므로 1장 실습 1을 먼저 해야 합니다. API 키가 과목 폴더의 .env 에 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.
1단계. 모델과 FAQ 프롬프트 준비하기
import os
import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
# 과목 폴더의 .env 파일에서 OPENAI_API_KEY 를 읽어 환경 변수에 넣는다
# find_dotenv(usecwd=True): 지금 폴더(data)에서 위로 올라가며 .env 를 찾는다
load_dotenv(find_dotenv(usecwd=True))
# 모델 이름도 .env 에서 바꿀 수 있게 하고, 없으면 gpt-4.1-mini 를 쓴다
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
# 규정 안내라 온도 0, 답은 200 토큰까지, 30초 넘게 걸리면 멈춘다
model = ChatOpenAI(model=MODEL, temperature=0, max_tokens=200, timeout=30)
# 실습 1과 같은 FAQ 프롬프트(절이 바뀌면 처음부터 다시 실행되므로 다시 만든다)
faq = pd.read_csv("faq.csv")
faq_text = "\n".join(f"- {q} → {a}" for q, a in zip(faq["질문"], faq["답"]))
SYSTEM = """너는 구름다리여행의 상담 챗봇이다.
아래 FAQ 에 있는 내용으로만 짧게 답한다.
FAQ 에 없는 내용이면 "그 내용은 FAQ 에 없어 상담원에게 연결해 드릴게요."라고 답한다.
[FAQ]
{faq}"""
prompt = ChatPromptTemplate.from_messages([("system", SYSTEM), ("human", "{question}")]).partial(faq=faq_text)
print("모델:", MODEL)
print("남은 빈칸:", prompt.input_variables)모델: gpt-4.1-mini
남은 빈칸: ['question']코드가 의미하는 것 load_dotenv(find_dotenv(usecwd=True)) 로 키를 코드 밖의 .env 에서 읽고, ChatOpenAI(..., temperature=0, ...) 로 규정 안내에 맞게 한결같이 답하는 모델을 정합니다. 프롬프트는 실습 1의 틀을 partial 로 FAQ 까지 채워 두었으므로, 이 챗봇에 남은 입력은 손님 질문 하나뿐입니다.
2단계. 체인을 잇고 질문 하나 하기
# 프롬프트 → 모델 → 글만 꺼내기. 1차시에서 가짜 모델로 만든 모양 그대로다
chain = prompt | model | StrOutputParser()
answer = chain.invoke({"question": "캐리어 무게 제한이 궁금해요"})
print(answer)국내선 상품은 15kg 짐 한 개가 포함되어 있습니다.코드가 의미하는 것 prompt | model | StrOutputParser() 는 실습 2의 체인에서 가짜 모델 자리에 진짜 모델을 끼운 것입니다. 부르는 줄 chain.invoke({"question": …}) 은 그대로이고, 이제 모델이 질문을 실제로 읽고 FAQ 에서 맞는 답을 고릅니다. 예상 출력에 적은 답의 글은 실제로 돌리면 표현이 달라질 수 있습니다.
3단계. 손님 질문 여섯 개를 batch 로 묻기
# 1차시 규칙 챗봇이 틀린 질문 넷, FAQ 밖의 질문 둘
PICK = [
"캐리어 무게 제한이 궁금해요",
"여행을 못 가게 됐는데 위약금이 있나요?",
"출발일을 다음 주로 미룰 수 있을까요?",
"고양이도 데려갈 수 있어요?",
"짐 싸는 요령 알려 주세요",
"제주도 날씨 어때요?",
]
# 진짜 모델은 질문을 읽고 답하므로 동시에 보내도 짝이 섞이지 않는다
answers = chain.batch([{"question": q} for q in PICK])
for q, a in zip(PICK, answers):
print(f"Q. {q}\nA. {a}")Q. 캐리어 무게 제한이 궁금해요
A. 국내선 상품은 15kg 짐 한 개가 포함되어 있습니다.
Q. 여행을 못 가게 됐는데 위약금이 있나요?
A. 출발 10일 전까지 취소하면 전액 환불되고, 그 뒤로는 약관에 따라 일부만 돌려드립니다.
Q. 출발일을 다음 주로 미룰 수 있을까요?
A. 출발 7일 전까지 마이페이지에서 한 번 무료로 날짜를 바꿀 수 있습니다.
Q. 고양이도 데려갈 수 있어요?
A. 반려동물 동반 상품은 '함께 여행' 메뉴에서만 예약할 수 있습니다.
Q. 짐 싸는 요령 알려 주세요
A. 그 내용은 FAQ 에 없어 상담원에게 연결해 드릴게요.
Q. 제주도 날씨 어때요?
A. 그 내용은 FAQ 에 없어 상담원에게 연결해 드릴게요.코드가 의미하는 것 chain.batch 로 질문 여섯 개를 한 번에 보내는 것은 시험 질문 묶음으로 챗봇을 점검하는 일입니다. PICK 에 규칙 챗봇이 놓친 질문과 FAQ 밖 질문을 섞은 것은, 낱말이 아닌 뜻으로 알아듣는지와 모를 때 정해 둔 말로 넘기는지를 함께 보려는 것입니다.
4단계. 규칙 챗봇과 견주고 저장하기
# 1차시 실습 1의 채점 결과에서 같은 질문의 정답 의도와 규칙 챗봇의 추측을 꺼낸다
rule = pd.read_csv("out_rule_bot_result.csv").set_index("질문")
result = pd.DataFrame({
"질문": PICK,
"의도": [rule.loc[q, "의도"] for q in PICK],
"규칙 챗봇": [rule.loc[q, "추측"] for q in PICK],
# LLM 이 "FAQ 에 없다"고 답했는지
"FAQ 밖이라 함": ["상담원에게 연결" in a for a in answers],
"LLM 답": answers,
})
print(result[["질문", "의도", "규칙 챗봇", "FAQ 밖이라 함"]].to_string(index=False))
result.to_csv("out_ch03_qa_answers.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch03_qa_answers.csv") 질문 의도 규칙 챗봇 FAQ 밖이라 함
캐리어 무게 제한이 궁금해요 수하물 없음 False
여행을 못 가게 됐는데 위약금이 있나요? 환불 없음 False
출발일을 다음 주로 미룰 수 있을까요? 예약변경 없음 False
고양이도 데려갈 수 있어요? 반려동물 없음 False
짐 싸는 요령 알려 주세요 없음 수하물 True
제주도 날씨 어때요? 없음 없음 True
저장: out_ch03_qa_answers.csv코드가 의미하는 것 rule.loc[q, "추측"] 으로 1장 규칙 챗봇의 결과를 같은 질문 옆에 붙여, 두 챗봇을 같은 시험지로 견줍니다. "상담원에게 연결" in a 처럼 모를 때 할 말을 정확히 정해 두면 답의 글이 조금 바뀌어도 코드로 셀 수 있습니다. 넷째 열은 예상 출력에 기댄 값이므로 실제로 돌린 뒤 다시 확인합니다.
이 실습이 뜻하는 것
이 실습은 1장에 ARS 번호 메뉴로 받던 손님 전화를, 안내문을 손에 쥔 상담원에게 맡겨 보는 일과 같습니다. 상담원(모델)과 안내문(FAQ 프롬프트)을 준비하고(1단계), 전화 한 통을 받고(2단계), 여섯 통을 한꺼번에 받고(3단계), ARS 의 결과와 견줍니다(4단계). 여기서 안내문이 FAQ 를 채운 시스템 메시지, 상담원이 ChatOpenAI 입니다. 비유와 다른 점은, 사람 상담원은 안내문에 없는 일도 상식으로 답하지만, 이 챗봇은 FAQ 밖이면 모른다고 답하라고 우리가 막아 두었다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 준비 | 남은 빈칸 ['question'] | FAQ 는 채워 두었으니 체인에는 질문만 넣습니다 |
| 2. 질문 하나 | 15kg 짐 한 개 | 규칙 챗봇이 "없음"으로 놓친 "캐리어 무게"를 수하물 답으로 이었습니다 |
| 3. batch | 넷은 FAQ 의 답, 둘은 상담원 연결 | "위약금"을 환불로, "미룰"을 예약 변경으로, "고양이"를 반려동물로 알아들었습니다 |
| 4. 견주기 | 규칙 챗봇은 넷을 "없음", 하나를 "수하물"로 틀림 | 규칙 챗봇이 틀린 다섯 질문을 LLM 챗봇은 모두 맞게 다뤘습니다 |
한 줄로 줄이면, 가짜 모델로 맞춰 본 체인의 모양에 진짜 모델을 끼워 FAQ 챗봇을 만든 실습입니다. 예상 출력대로라면 규칙 챗봇이 틀린 다섯 질문을 LLM 챗봇은 모두 바로 다룹니다. 낱말이 아니라 뜻을 읽기 때문입니다. "짐 싸는 요령"처럼 낱말("짐")은 겹쳐도 FAQ 에 없는 질문은 시스템 메시지의 지시대로 상담원에게 넘깁니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라집니다. 특히 "위약금" 질문처럼 FAQ 를 풀어 써야 하는 답은 표현이 달라지기 쉽고, FAQ 밖 질문에도 일반 상식으로 답하려 할 때가 있으니 실제로 돌린 뒤 넷째 열을 다시 확인합니다. 질문 여섯 개만 본 결과라 20개 전체로 채점하면 숫자가 다를 수 있습니다.
코드 설명
ChatPromptTemplate.from_messages([...]).partial(faq=faq_text)는 틀을 만들고 FAQ 를 바로 채운 것입니다. 남은 빈칸이question하나라서 체인에는{"question": …}만 넣습니다.prompt | model | StrOutputParser()는 1장에서 가짜 모델로 만든 체인과 모양이 같습니다. 가운데 부품만 진짜 모델로 바뀌었습니다.chain.batch([...])는 질문 여섯 개를 한꺼번에 보냅니다. 요청 여섯 번의 요금이 들고, 하나씩 보내는 것보다 빨리 끝납니다.rule.loc[q, "추측"]은set_index("질문")으로 질문을 행 이름으로 삼은 표에서, 그 질문의 "추측" 칸을 꺼냅니다. loc 은 location(자리)을 줄인 말입니다."상담원에게 연결" in a는 답에 "모른다"는 정해 둔 말이 들어 있는지 봅니다. 시스템 메시지에서 모를 때 할 말을 정확히 정해 두었기 때문에 이렇게 셀 수 있습니다.