LangChain 챗봇4장 · 대화형 챗봇과 메모리 전략15 / 22← 이전목차다음 →MetaContents

4장. 대화형 챗봇과 메모리 전략

실습 2 — 구조화된 출력으로 요청 글에서 조건 뽑기

실습 1의 틀(TripRequest)을 진짜 모델에 건네고, 손님이 쓴 요청 글 10개에서 조건을 뽑습니다. 뽑은 조건을 사람이 정리한 정답과 견주어 칸마다 맞은 수를 셉니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 교안은 gpt-4.1-mini 를 기본으로 하고, 교수자가 정한 모델이 있으면 .env 의 OPENAI_MODEL 로 바꿉니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

1단계. 모델과 틀 준비하기

Python
import os

import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

# 과목 폴더의 .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")
model = ChatOpenAI(model=MODEL, temperature=0)


class TripRequest(BaseModel):
    """손님의 여행 조건 (실습 1과 같은 틀)"""
    destination: str = Field(description="여행 목적지. 도시나 섬 이름")
    nights: int = Field(description="몇 박인지. 2박 3일이면 2", ge=1, le=30)
    adults: int = Field(description="어른 수", ge=1)
    children: int = Field(default=0, description="아이 수. 없으면 0", ge=0)
    budget: int = Field(description="일행 전체 예산(만 원). 150만 원이면 150", ge=0)
    interests: list[str] = Field(description="관심사 낱말 목록. 예: 바다, 맛집")


prompt = ChatPromptTemplate.from_messages([
    ("system", "너는 구름다리여행의 상담 직원이다. 손님의 요청 글을 읽고 상담 카드의 칸을 채운다."),
    ("human", "{request}"),
])
# with_structured_output: 틀을 모델에 건네고, 답을 TripRequest 객체로 받는다
extractor = prompt | model.with_structured_output(TripRequest)

trips = pd.read_csv("trip_requests.csv")
print("모델:", MODEL, "/ 요청 글:", len(trips), "개")
출력
모델: gpt-4.1-mini / 요청 글: 10 개

코드가 의미하는 것 load_dotenv(find_dotenv(usecwd=True)) 는 키를 코드에 적지 않고 .env 파일에서 읽어 오는 줄이라, 코드를 남에게 보여 줘도 키가 드러나지 않습니다. temperature=0 은 같은 요청 글에 되도록 같은 답을 내게 해서, 조건 뽑기처럼 정답이 있는 일에 맞춥니다. prompt | model.with_structured_output(TripRequest) 는 상담원에게 빈 카드를 건네는 체인이고, 끝에 글로 바꾸는 파서가 없는 것은 답이 처음부터 카드(객체)로 오기 때문입니다.

2단계. 요청 글 하나로 시험하기

Python
text = trips.loc[0, "요청"]
print("요청:", text)

trip = extractor.invoke({"request": text})
# 돌아온 것은 글이 아니라 TripRequest 객체다
print(type(trip).__name__)
print(trip.model_dump())
출력
요청: 다음 달에 제주도로 2박 3일 가려고 해요. 어른 둘, 아이 둘이고 예산은 150만 원쯤이에요. 바다랑 맛집이 좋아요.
TripRequest
{'destination': '제주도', 'nights': 2, 'adults': 2, 'children': 2, 'budget': 150, 'interests': ['바다', '맛집']}

코드가 의미하는 것 extractor.invoke({"request": text}) 한 번이 요청 글 하나를 상담 카드 한 장으로 바꾸는 일입니다. 돌아온 값이 TripRequest 객체라는 것은 Pydantic 검사를 이미 거쳤다는 뜻이라, 박수나 인원을 숫자로 다시 바꾸는 코드가 필요 없습니다. 이 단계의 출력은 예상 출력이므로 칸에 든 값은 실제 실행에서 달라질 수 있습니다.

3단계. 열 개 모두 뽑아 저장하기

Python
rows = []
for text in trips["요청"]:
    trip = extractor.invoke({"request": text})
    rows.append(trip.model_dump())

# 뽑은 조건을 표로 만들고, 관심사 목록은 정답처럼 ; 로 잇는다
got = pd.DataFrame(rows)
got["interests"] = got["interests"].map(";".join)
got.insert(0, "번호", trips["번호"])
print(got.to_string(index=False))

got.to_csv("out_ch04_extracted.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch04_extracted.csv")
출력
 번호 destination  nights  adults  children  budget   interests
  1         제주도       2       2         2     150       바다;맛집
  2          부산       1       1         0      30         카페
  3          강릉       3       4         0     200      온천;산책
  4          경주       2       2         1      80       역사 유적
  5          여수       1       2         0      50         밤바다
  6          전주       1       3         0      60      먹거리;한옥마을
  7          속초       2       2         3     120       바다;시장
  8          제주       4       2         0     300  바다;드라이브;맛집
  9          통영       7       1         0     100         섬 여행
 10          가평       1       2         2      50      캠핑;물놀이
저장: out_ch04_extracted.csv

코드가 의미하는 것 요청 글마다 extractor.invoke 를 한 번씩 부르므로 모델 호출도 열 번입니다. model_dump() 로 펼친 사전을 모아 pd.DataFrame 에 넣으면 칸 이름이 그대로 열 이름이 되어, 줄글 답이었다면 필요했을 글 자르기 없이 표가 됩니다. 관심사 목록을 ";".join 으로 잇는 것은 정답 표와 같은 꼴로 맞춰 다음 단계에서 견주기 위해서입니다.

4단계. 정답과 견주기

Python
# 정답 열(한국어)과 뽑은 칸(영어)을 짝짓는다
PAIRS = {"목적지": "destination", "박수": "nights", "어른": "adults", "아이": "children", "예산": "budget"}

total = 0
for ko, en in PAIRS.items():
    same = trips[ko] == got[en]          # 행마다 같으면 True
    total += int(same.sum())
    print(f"{ko}: {int(same.sum())} / {len(trips)}")
print("다섯 칸 합:", total, "/", len(trips) * len(PAIRS))

# 관심사는 낱말이 하나라도 겹치면 맞은 것으로 센다
overlap = [bool(set(a.split(";")) & set(b.split(";"))) for a, b in zip(trips["관심사"], got["interests"])]
print("관심사가 하나라도 겹친 행:", sum(overlap), "/", len(trips))

# 어긋난 칸만 모아 본다
for ko, en in PAIRS.items():
    for i in trips.index[trips[ko] != got[en]]:
        print(f"{trips.loc[i, '번호']}번 {ko}: 정답 {trips.loc[i, ko]} / 모델 {got.loc[i, en]}")
출력
목적지: 9 / 10
박수: 9 / 10
어른: 10 / 10
아이: 10 / 10
예산: 10 / 10
다섯 칸 합: 48 / 50
관심사가 하나라도 겹친 행: 6 / 10
8번 목적지: 정답 제주도 / 모델 제주
9번 박수: 정답 6 / 모델 7

코드가 의미하는 것 trips[ko] == got[en] 은 사람이 채운 카드와 모델이 채운 카드를 칸마다 대조하는 채점표입니다. 숫자와 목적지는 글자 그대로 견주고, 관심사는 낱말이 하나라도 겹치면 맞다고 보는 것처럼 칸의 성격에 따라 채점 규칙을 달리했습니다. 맞은 수만 보지 않고 어긋난 칸을 따로 찍는 것은, 프롬프트나 description 에서 고칠 곳을 찾기 위해서입니다. 예상 출력에서 어긋났다고 적은 칸은 실제 실행에서 달라질 수 있습니다.

이 실습이 뜻하는 것

이 실습은 상담원에게 빈 상담 카드 열 장을 건네 손님 요청 글을 옮겨 적게 하고, 선임이 정리해 둔 카드와 대조해 보는 일과 같습니다. 카드 양식을 건네고(1단계), 한 장으로 시험하고(2단계), 열 장을 모두 채우고(3단계), 칸마다 대조합니다(4단계). 여기서 빈 카드가 TripRequest, 카드를 채우는 상담원이 model.with_structured_output(TripRequest) 입니다. 비유와 다른 점은, 상담원은 "제주"와 "제주도"가 같은 곳인 줄 알지만 우리 채점 코드(==)는 글자가 다르면 틀렸다고 센다는 것입니다.

단계출력뜻
2. 하나 시험TripRequest 객체줄글이 아니라 틀의 객체가 돌아옵니다. trip.nights 처럼 바로 꺼냅니다
3. 열 개어른·아이·예산은 모두 숫자숫자 칸은 처음부터 정수라 그대로 표로 모입니다
4. 다섯 칸48 / 50숫자로 적힌 조건은 거의 다 맞혔습니다
4. 어긋난 칸8번 "제주", 9번 7박글자가 다른 이름, "일주일"처럼 사람마다 달리 읽는 말에서 어긋났습니다
4. 관심사6 / 10"밤바다"와 "야경", "먹거리"와 "맛집"처럼 뜻이 같은 낱말, "역사 유적"과 "역사"처럼 붙여 쓴 낱말이 어긋났습니다

한 줄로 줄이면, 틀을 건네면 모델은 칸을 빠짐없이 채우지만 칸 안의 값은 여전히 확인해야 한다는 것을 본 실습입니다. 예상 출력에서 5번(1인당 25만 원씩 둘)의 예산은 description 에 "일행 전체 예산"이라고 적어 두어 50 으로 맞았다고 보았습니다. 어긋난 곳은 목적지 이름의 표기와 "일주일"의 박수처럼 사람이 정해 주어야 하는 것입니다. 목적지는 고를 수 있는 이름을 Literal 로 정하고, "일주일은 6박으로 친다" 같은 규칙은 description 에 적으면 줄일 수 있습니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 관심사 낱말과 맞은 칸 수는 여러분의 출력과 다를 수 있습니다.

코드 설명

  • model.with_structured_output(TripRequest) 는 틀을 붙인 모델을 만듭니다. prompt | 뒤에 이으면 3장의 체인과 같은 모양으로 씁니다. 끝에 StrOutputParser() 를 붙이지 않습니다. 이미 객체로 돌아오기 때문입니다.
  • trip.model_dump() 는 객체를 사전으로 바꿉니다. 사전의 목록을 pd.DataFrame(rows) 에 넣으면 칸 이름이 열 이름이 됩니다.
  • trips[ko] == got[en] 은 두 열을 행마다 견줘 True·False 열을 만듭니다. .sum() 으로 True 의 개수, 곧 맞은 행 수를 셉니다.
  • set(a.split(";")) & set(b.split(";")) 는 두 낱말 묶음의 겹치는 부분(교집합)입니다. 비어 있지 않으면 bool(...) 이 True 입니다.
  • out_ch04_extracted.csv 에는 모델이 뽑은 조건이 남습니다. 정답 표와 나란히 열어 어긋난 칸을 눈으로 확인해 보세요.
← 이전 절4장 · 실습 1 — Pydantic 틀로 여행 조건 확인하기다음 절 →4장 · 실습 3 — 가짜 모델로 세션별 기록 확인하기