LangChain 챗봇7장 · 웹 UI 연동과 배포17 / 22← 이전목차다음 →MetaContents

7장. 웹 UI 연동과 배포

실습 4 — 진짜 모델을 붙인 채팅 화면

실습 3의 앱에서 가짜 모델 자리에 ChatOpenAI 를 끼워, 진짜로 답하는 여행 비서 웹 화면(out_ch07_openai_chat.py)을 만듭니다. 키는 get_api_key() 로 읽고, 키가 없으면 화면에 안내를 띄우고 멈춥니다. 기록은 최근 10개만 모델에 보냅니다. 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 교안은 gpt-4.1-mini 를 기본으로 하고, 교수자가 정한 모델이 있으면 .env 의 OPENAI_MODEL 로 바꿉니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

1단계. 진짜 모델 앱 파일 쓰기

Python
from pathlib import Path

APP_CODE = r'''
import os

import streamlit as st
from dotenv import find_dotenv, load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_openai import ChatOpenAI


def get_api_key():
    """클라우드에서는 Secrets 칸, 내 PC 에서는 .env 에서 키를 읽는다"""
    try:
        return st.secrets["OPENAI_API_KEY"]
    except (FileNotFoundError, KeyError):
        load_dotenv(find_dotenv(usecwd=True))
        return os.getenv("OPENAI_API_KEY")


@st.cache_resource
def make_openai_chain(api_key, model_name):
    """진짜 모델의 대화 체인. 키와 모델 이름이 같으면 한 번만 만든다"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "너는 구름다리여행의 친절한 여행 비서다. 손님이 앞에서 말한 조건을 기억해 두 문장 이내로 답한다."),
        MessagesPlaceholder("history"),
        ("human", "{question}"),
    ])
    # api_key: 환경 변수 대신 읽어 온 키를 직접 넘긴다
    model = ChatOpenAI(model=model_name, api_key=api_key, temperature=0)
    return prompt | model | StrOutputParser()


st.title("구름다리여행 여행 비서")
api_key = get_api_key()
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
with st.sidebar:
    st.header("설정")
    st.caption(f"모델: {MODEL}")
    st.caption("최근 10개 메시지만 모델에 보냅니다.")

if not api_key:
    # st.error: 빨간 안내 상자. st.stop: 이 줄 아래를 실행하지 않고 멈춘다
    st.error("API 키가 없습니다. .env 나 Secrets 에 OPENAI_API_KEY 를 넣어 주세요.")
    st.stop()

if "messages" not in st.session_state:
    st.session_state.messages = []
for m in st.session_state.messages:
    with st.chat_message(m["role"]):
        st.markdown(m["content"])

if question := st.chat_input("여행에 대해 물어보세요"):
    with st.chat_message("user"):
        st.markdown(question)
    chain = make_openai_chain(api_key, MODEL)
    # 4차시의 Window 처럼 최근 10개만 보낸다
    history = st.session_state.messages[-10:]
    with st.chat_message("assistant"):
        reply = st.write_stream(chain.stream({"history": history, "question": question}))
    st.session_state.messages.extend([
        {"role": "user", "content": question},
        {"role": "assistant", "content": reply},
    ])
'''

Path("out_ch07_openai_chat.py").write_text(APP_CODE.lstrip(), encoding="utf-8")
print("저장: out_ch07_openai_chat.py,", len(APP_CODE.strip().splitlines()), "줄")
출력
저장: out_ch07_openai_chat.py, 62 줄

코드가 의미하는 것 실습 3의 앱에서 가짜 모델 자리에 ChatOpenAI(model=…, api_key=api_key, temperature=0) 를 끼워 진짜 요리사를 들였습니다. make_openai_chain(api_key, model_name) 은 인자가 같으면 보관한 체인을 돌려주는 캐시 함수라, 모델 이름이 바뀌면 새 체인을 만듭니다. 키가 없으면 st.error 로 안내하고 st.stop() 으로 그 아래를 멈추며, 모델에는 messages[-10:] 로 최근 10개만 보내 4장의 Window 처럼 보내는 양을 줄입니다.

2단계. 첫 화면 열기

Python
import streamlit as st
from streamlit.testing.v1 import AppTest

st.cache_resource.clear()

at = AppTest.from_file("out_ch07_openai_chat.py").run()
print("제목:", at.title[0].value)
print("사이드바:", [c.value for c in at.sidebar.caption])
# 키가 없으면 빨간 안내 상자가 하나 생긴다
print("오류 안내:", [e.value for e in at.error])
출력
제목: 구름다리여행 여행 비서
사이드바: ['모델: gpt-4.1-mini', '최근 10개 메시지만 모델에 보냅니다.']
오류 안내: []

코드가 의미하는 것 첫 화면은 모델을 부르지 않고 키를 찾았는지만 가립니다. at.error 가 비어 있으면 st.stop() 앞에서 멈추지 않고 입력창까지 그려졌다는 뜻이고, 키가 없으면 여기에 빨간 상자의 글이 담깁니다. 예상 출력에서는 사이드바에 모델 이름과 "최근 10개" 안내가 보이고 오류 안내는 비어 있습니다.

3단계. 이어지는 질문 두 개 보내기

Python
for q in ["다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.", "지금까지 말한 조건을 정리해 주세요."]:
    # 진짜 모델은 답에 몇 초가 걸리므로 기다리는 시간을 60초로 늘린다
    at.chat_input[0].set_value(q).run(timeout=60)

for m in at.chat_message:
    print(f"[{m.name}] {m.markdown[0].value}")
출력
[user] 다음 달에 제주도로 2박 3일 가요. 아이 둘이랑 가요.
[assistant] 아이 둘과 함께하는 제주도 2박 3일 여행이군요. 이동이 짧은 동쪽 해안 일정을 추천드립니다.
[user] 지금까지 말한 조건을 정리해 주세요.
[assistant] 다음 달, 제주도 2박 3일, 아이 둘과 함께하는 여행입니다. 동쪽 해안 중심 일정으로 준비해 드릴게요.

코드가 의미하는 것 질문을 보낼 때마다 ChatOpenAI 가 실제로 불리므로 run(timeout=60) 으로 기다리는 시간을 늘렸습니다. 둘째 질문에는 조건이 없어도 st.session_state 의 기록이 history 로 함께 가므로 모델이 앞의 조건을 정리할 수 있습니다. 예상 출력의 답 글은 유추해 적은 것이라, 키를 넣고 돌리면 문장은 달라집니다.

4단계. 기록과 오류 확인하기

Python
history = at.session_state["messages"]
print("기록:", len(history), "개")
print("오류:", len(at.exception), "개")
출력
기록: 4 개
오류: 0 개

AppTest 로 확인한 뒤에는 같은 파일을 브라우저로 열어, 답이 한 조각씩 흘러나오는 모습을 직접 봅니다. 아래 명령은 이 교안을 만들 때 실행하지 않은 안내용 명령입니다.

PowerShell
# data 폴더에서 앱을 띄운다. 브라우저에 http://localhost:8501 이 열린다
streamlit run out_ch07_openai_chat.py
# 다른 번호로 열고 싶으면 --server.port 를 준다(server.port = 서버가 손님을 받는 문 번호)
streamlit run out_ch07_openai_chat.py --server.port 8502

코드가 의미하는 것 at.session_state["messages"] 의 개수와 at.exception 의 개수로 앱이 대화를 쌓으며 오류 없이 돌았는지 봅니다. 기록 수는 보낸 질문 수로 정해지므로 모델과 상관없이 같아야 하는 값이고, 오류 수는 0 이어야 통과입니다. 그 뒤 streamlit run 으로 브라우저에서 열어, AppTest 가 보여 주지 못하는 흘러나오는 모습을 눈으로 봅니다.

이 실습이 뜻하는 것

이 실습은 연습용 마네킹 요리사를 내보내고 진짜 요리사를 주방에 들이는 일과 같습니다. 주방에 진짜 요리사를 앉히고(1단계), 가게를 켜 보고(2단계), 손님의 주문을 두 번 이어 받고(3단계), 주문표와 사고 기록을 확인합니다(4단계). 여기서 진짜 요리사가 ChatOpenAI, 주문표를 최근 열 장만 넘기는 일이 messages[-10:] 입니다. 비유와 다른 점은, 이 요리사는 주문 한 번마다 요금을 받으므로 앱을 열어 둔 채 남에게 주소를 알려 주면 그 사람의 질문 값도 키 주인이 낸다는 것입니다.

단계출력뜻
2. 첫 화면오류 안내 없음.env 에서 키를 찾아 st.stop() 앞에서 멈추지 않았습니다
3. 첫 질문"아이 둘과 함께하는 제주도 2박 3일"질문에 든 조건으로 답했습니다
3. 둘째 질문다음 달, 제주도 2박 3일, 아이 둘조건이 없는 질문에도 st.session_state 의 기록이 함께 가서 정리했습니다
4. 기록4개, 오류 0개질문과 답 두 쌍이 쌓였고 앱은 오류 없이 돌았습니다

한 줄로 줄이면, 4장의 여행 비서를 웹 화면에 올리는 데 필요한 것은 "기록을 st.session_state 에 두기"와 "체인을 캐시로 한 번 만들기" 두 가지였다는 것을 본 실습입니다. 4장에서 RunnableWithMessageHistory 와 세션 id 가 하던 일을 st.session_state 가 대신했습니다. 기록 수(4개)는 모델과 상관없이 같지만, 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 여러분의 답은 문장이 다를 수 있습니다. .env 가 없거나 키가 비어 있으면 2단계의 오류 안내에 빨간 상자의 글이 찍히고, 3단계는 입력창이 없어 IndexError 가 납니다.

코드 설명

  • make_openai_chain(api_key, model_name) 은 인자가 있는 캐시 함수입니다. st.cache_resource 는 인자가 같으면 보관한 체인을, 모델 이름이 바뀌면 새 체인을 만듭니다.
  • ChatOpenAI(model=…, api_key=api_key, temperature=0) 의 api_key 는 환경 변수 대신 넘겨주는 키입니다. st.secrets 에서 읽은 키를 그대로 넘길 수 있습니다.
  • st.error(...) 와 st.stop() 은 키가 없을 때 안내를 띄우고 그 아래를 실행하지 않게 합니다. 키 없이 모델을 불러 401 오류가 나는 것보다 손님에게 친절합니다.
  • at.run(timeout=60) 은 한 번 실행을 기다리는 시간을 60초로 늘립니다. 기본값 3초로는 진짜 모델의 답을 기다리지 못할 수 있습니다.
  • streamlit run … --server.port 8502 의 --server.port 는 앱을 여는 문 번호입니다. 다른 앱이 8501 을 쓰고 있으면 Streamlit 이 저절로 다음 번호를 고르기도 합니다.
← 이전 절7장 · 실습 3 — 스트리밍 답과 키 읽기다음 절 →7장 · 실습 5 — 문서 챗봇 웹 화면