3장. LangChain 기본 구조와 LCEL
실습 1 — 프롬프트 템플릿 만들고 채워 보기
1장의 faq.csv(구름다리여행의 자주 묻는 질문 10개)를 글로 묶어 시스템 메시지에 넣는 채팅 프롬프트 틀을 만들고, 손님 질문을 넣어 채운 메시지를 찍어 봅니다. 채운 메시지의 토큰 수도 셉니다. FAQ 와 손님 질문은 1장에서 만든 연습용 가상 데이터입니다. 모델을 부르지 않으므로 API 키가 없어도 됩니다.
1단계. FAQ 를 글로 묶기
import pandas as pd
# 구름다리여행의 자주 묻는 질문 10개(의도, 질문, 답)
faq = pd.read_csv("faq.csv")
# 한 줄에 "- 질문 → 답" 꼴로 이어 붙여, 프롬프트에 넣을 글 하나로 만든다
faq_text = "\n".join(f"- {q} → {a}" for q, a in zip(faq["질문"], faq["답"]))
print("FAQ 수:", len(faq))
print("묶은 글의 글자 수:", len(faq_text))
# 앞의 두 줄만 본다
print("\n".join(faq_text.splitlines()[:2]))FAQ 수: 10
묶은 글의 글자 수: 546
- 예약 날짜를 바꿀 수 있나요? → 출발 7일 전까지 마이페이지에서 한 번 무료로 바꿀 수 있습니다.
- 예약을 취소하면 환불되나요? → 출발 10일 전까지는 전액, 그 뒤로는 약관에 따라 일부만 돌려드립니다.코드가 의미하는 것 zip(faq["질문"], faq["답"]) 과 "\n".join(...) 은 표로 된 FAQ 를 모델이 읽을 수 있는 글 한 덩어리로 바꾸는 일입니다. 모델은 표를 받지 못하고 메시지의 글만 받으므로, 챗봇에 지식을 넣으려면 먼저 이렇게 글로 펴야 합니다. 이 글이 이 챗봇이 아는 것의 전부가 됩니다.
2단계. 채팅 프롬프트 틀 만들기
from langchain_core.prompts import ChatPromptTemplate
# 시스템 메시지: 역할, FAQ 안에서만 답하라는 제약, 모를 때 할 말, 그리고 FAQ 를 넣을 빈칸 {faq}
SYSTEM = """너는 구름다리여행의 상담 챗봇이다.
아래 FAQ 에 있는 내용으로만 짧게 답한다.
FAQ 에 없는 내용이면 "그 내용은 FAQ 에 없어 상담원에게 연결해 드릴게요."라고 답한다.
[FAQ]
{faq}"""
# 시스템 줄과 질문 줄 두 개로 틀을 만든다
prompt = ChatPromptTemplate.from_messages([("system", SYSTEM), ("human", "{question}")])
print("빈칸:", prompt.input_variables)
print("메시지 틀 수:", len(prompt.messages))빈칸: ['faq', 'question']
메시지 틀 수: 2코드가 의미하는 것 SYSTEM 글은 챗봇의 근무 수칙(역할, 답할 범위, 모를 때 할 말)이고, {faq} 는 그 아래에 안내문을 끼울 자리입니다. from_messages 로 시스템 줄과 질문 줄을 나눠 두었으므로 손님 글은 늘 human 줄에만 들어가 규칙과 섞이지 않습니다.
3단계. FAQ 는 먼저 채우고, 질문만 넣어 완성하기
# partial: FAQ 는 늘 같으므로 먼저 채워 둔다. 이제 질문만 넣으면 된다
faq_prompt = prompt.partial(faq=faq_text)
print("남은 빈칸:", faq_prompt.input_variables)
# 손님 질문 하나를 넣어 메시지를 완성한다
value = faq_prompt.invoke({"question": "캐리어 무게 제한이 궁금해요"})
messages = value.to_messages()
# 시스템 메시지는 길어서 앞의 다섯 줄과 마지막 줄만 본다
system_lines = messages[0].content.splitlines()
print(f"[{messages[0].type}] {len(system_lines)}줄")
print("\n".join(system_lines[:5]))
print("…")
print(system_lines[-1])
print(f"[{messages[1].type}]", messages[1].content)남은 빈칸: ['question']
[system] 15줄
너는 구름다리여행의 상담 챗봇이다.
아래 FAQ 에 있는 내용으로만 짧게 답한다.
FAQ 에 없는 내용이면 "그 내용은 FAQ 에 없어 상담원에게 연결해 드릴게요."라고 답한다.
[FAQ]
…
- 강아지와 함께 갈 수 있나요? → 반려동물 동반 상품은 '함께 여행' 메뉴에서만 예약할 수 있습니다.
[human] 캐리어 무게 제한이 궁금해요코드가 의미하는 것 partial(faq=faq_text) 는 손님마다 바뀌지 않는 안내문을 한 번만 채워 두는 일입니다. 실제 챗봇도 요청마다 같은 시스템 메시지에 손님 질문만 바꿔 끼우며, to_messages() 로 펼친 두 메시지가 모델에게 실제로 가는 것입니다.
4단계. 토큰 세고 저장하기
from pathlib import Path
import tiktoken
# 1차시에서 쓴 GPT-4o 계열의 토큰 사전
enc = tiktoken.get_encoding("o200k_base")
# 손님 질문 앞의 다섯 개로 채워 보고, 메시지마다 토큰을 센다
questions = pd.read_csv("user_questions.csv")
rows = []
for q in questions["질문"].head(5):
msgs = faq_prompt.invoke({"question": q}).to_messages()
rows.append({"질문": q, "시스템 토큰": len(enc.encode(msgs[0].content)), "질문 토큰": len(enc.encode(msgs[1].content))})
table = pd.DataFrame(rows)
print(table.to_string(index=False))
# 보내는 토큰 가운데 시스템 메시지(FAQ)가 차지하는 몫
share = table["시스템 토큰"].sum() / (table["시스템 토큰"].sum() + table["질문 토큰"].sum())
print("시스템 메시지의 몫:", round(float(share), 3))
# 3단계에서 채운 프롬프트를 글로 남긴다. to_string(): 역할 이름을 붙여 한 글로 펼친다
Path("out_ch03_faq_prompt.txt").write_text(value.to_string(), encoding="utf-8")
print("저장: out_ch03_faq_prompt.txt") 질문 시스템 토큰 질문 토큰
짐 두 개 가져가도 되나요? 362 9
해외에서 다치면 보상받을 수 있나요? 362 14
여권 기간이 4개월 남았는데 괜찮나요? 362 15
제주도 날씨 어때요? 362 9
카드로 3개월 나눠서 낼 수 있나요? 362 16
시스템 메시지의 몫: 0.966
저장: out_ch03_faq_prompt.txt코드가 의미하는 것 enc.encode 로 메시지마다 토큰을 세는 것은 요청 한 번의 무게를 재는 일입니다. 시스템 메시지는 질문과 상관없이 요청마다 다시 보내지므로, FAQ 가 길어질수록 모든 요청이 함께 무거워집니다. value.to_string() 을 파일로 남기면 모델에게 무엇이 갔는지 나중에 사람이 읽어 확인할 수 있습니다.
이 실습이 뜻하는 것
이 실습은 은행 창구의 입금표 양식을 만들어 두고 손님마다 빈칸을 채워 보는 일과 같습니다. 안내문(FAQ)을 글 한 장으로 옮겨 적고(1단계), 빈칸 둘({faq}, {question})이 있는 양식을 만들고(2단계), 늘 같은 안내문 칸은 미리 채워 둔 뒤 손님 질문만 넣고(3단계), 채운 양식의 분량을 잽니다(4단계). 여기서 양식이 ChatPromptTemplate, 미리 채운 안내문이 partial, 분량이 토큰 수입니다. 비유와 다른 점은, 입금표는 손님이 직접 채우지만 프롬프트 틀은 우리 코드가 채워 모델에게 보낸다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 글로 묶기 | FAQ 10개, 546자 | 표의 질문과 답이 "- 질문 → 답" 꼴의 열 줄, 546자짜리 글 하나가 됐습니다 |
| 2. 틀 만들기 | 빈칸 ['faq', 'question'], 메시지 틀 2개 | 시스템 줄의 {faq} 와 질문 줄의 {question} 이 한 목록에 모였습니다. [FAQ] 는 빈칸으로 읽히지 않았습니다 |
| 3. 채우기 | 남은 빈칸 ['question'], 시스템 15줄 | FAQ 를 먼저 채우니 빈칸이 질문 하나만 남았습니다. 시스템 메시지는 지시 3줄, 빈 줄, [FAQ], FAQ 10줄로 15줄입니다 |
| 4. 토큰 | 시스템 362, 질문 9~16, 몫 0.966 | 질문이 바뀌어도 시스템 토큰은 늘 362 이고, 보내는 토큰의 96.6% 가 FAQ 쪽입니다 |
한 줄로 줄이면, FAQ 를 넣는 프롬프트 틀을 만들고 질문만 바꿔 채우는 법을 익힌 실습입니다. 4단계의 숫자를 눈여겨보세요. 손님은 9~16 토큰짜리 짧은 질문을 했지만, 요청마다 FAQ 362 토큰이 함께 갑니다. 질문 하나에 보내는 토큰의 97% 가까이가 늘 같은 FAQ 입니다. FAQ 가 열 개라 이 정도지만, 백 개, 천 개로 늘면 요청마다 다 보내기 어려워집니다. 질문과 관련된 내용만 골라 보내는 방법이 6장의 RAG 입니다.
코드 설명
"\n".join(f"- {q} → {a}" for q, a in zip(…))는 FAQ 표의 질문과 답을 한 줄씩 짝지어 글 하나로 잇습니다. 모델은 표를 읽지 못하므로 글로 바꿔 넣습니다.SYSTEM의{faq}는 빈칸이고,[FAQ]는 대괄호라서 빈칸이 아닙니다. 중괄호만 빈칸으로 읽힙니다.prompt.partial(faq=faq_text)는{faq}를 먼저 채운 새 틀을 돌려줍니다. 원래prompt는 그대로 남으므로, 다른 FAQ 를 넣은 틀도 따로 만들 수 있습니다.value.to_string()은 메시지마다System:,Human:같은 역할 이름을 붙여 한 글로 펼칩니다. 모델에 무엇이 가는지 파일로 남겨 두고 읽어 보기에 좋습니다.- 토큰은 메시지의 글만 센 어림값입니다. 1장에서 본 것처럼 실제 API 는 메시지마다 역할 표시 같은 토큰을 몇 개씩 더 붙입니다.