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

7장. 웹 UI 연동과 배포

실습 2 — 대화가 쌓이는 채팅 화면

4장의 대화 체인(prompt | model | StrOutputParser())을 채팅 화면에 붙입니다. 대화 기록은 st.session_state.messages 에 쌓고, 체인은 st.cache_resource 로 한 번만 만듭니다. 모델 자리에는 1장의 가짜 모델(FakeListChatModel)을 두어 API 키 없이 돌아갑니다. 가짜 모델은 질문과 상관없이 정해 둔 답을 차례로 말합니다. 앱 파일은 out_ch07_chat.py 로 저장합니다.

1단계. 채팅 앱 파일 쓰기

Python
from pathlib import Path

APP_CODE = r'''
import streamlit as st
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

st.title("구름다리여행 여행 비서")
st.caption("연습용 가짜 모델이 정해 둔 답을 차례로 말합니다.")


# ---------- 챗봇 로직(주방): 화면과 상관없는 부분 ----------
@st.cache_resource
def make_chat_chain():
    """4차시의 대화 체인. 서버가 켜질 때 한 번만 만들어 모든 손님이 함께 쓴다"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "너는 구름다리여행의 친절한 여행 비서다. 두 문장 이내로 답한다."),
        MessagesPlaceholder("history"),
        ("human", "{question}"),
    ])
    model = FakeListChatModel(responses=[
        "좋습니다. 며칠 일정으로 생각하세요?",
        "2박 3일이면 동쪽 해안 일정을 추천합니다.",
        "아이와 함께라면 첫날은 숙소 근처에서 쉬는 일정을 권합니다.",
    ])
    return prompt | model | StrOutputParser()


def answer(chain, messages, question):
    """지금까지의 기록과 새 질문을 받아 답 글을 돌려준다"""
    return chain.invoke({"history": messages, "question": question})


# ---------- 화면(홀): 질문을 받고 말풍선을 그린다 ----------
chain = make_chat_chain()

# 다시 돌아도 남는 메모장에 대화 기록을 둔다(4차시의 기록 상자 자리)
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)
    # 기록(이번 질문 전까지)과 새 질문을 챗봇 로직에 넘긴다
    reply = answer(chain, st.session_state.messages, question)
    with st.chat_message("assistant"):
        st.markdown(reply)
    # 질문과 답을 둘 다 기록에 넣는다
    st.session_state.messages.extend([
        {"role": "user", "content": question},
        {"role": "assistant", "content": reply},
    ])
'''

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

코드가 의미하는 것 앱 파일 하나 안에서 위쪽은 주방(make_chat_chain, answer), 아래쪽은 홀(말풍선과 입력창)로 나눴습니다. @st.cache_resource 를 붙인 체인은 모든 손님이 함께 쓰는 육수 솥이고, st.session_state.messages 는 손님마다 꽂아 둔 주문표입니다. 체인을 부른 뒤에 extend 로 질문과 답을 함께 적는 차례가 모델이 같은 질문을 두 번 받지 않게 합니다.

2단계. 첫 화면과 첫 질문

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

# 앞 실습의 캐시가 섞이지 않게 비운다(서버를 새로 켠 것과 같다)
st.cache_resource.clear()

at = AppTest.from_file("out_ch07_chat.py").run()
print("제목:", at.title[0].value, "/ 말풍선:", len(at.chat_message), "개")
print("입력창의 안내 글:", at.chat_input[0].placeholder)

# 질문 하나를 보낸다
at.chat_input[0].set_value("다음 달에 제주도 가요").run()
for m in at.chat_message:
    print(f"  [{m.name}] {m.markdown[0].value}")
출력
제목: 구름다리여행 여행 비서 / 말풍선: 0 개
입력창의 안내 글: 여행에 대해 물어보세요
  [user] 다음 달에 제주도 가요
  [assistant] 좋습니다. 며칠 일정으로 생각하세요?

코드가 의미하는 것 st.cache_resource.clear() 는 앞 실습에서 남은 캐시를 비워 서버를 새로 켠 상태로 맞춥니다. at.chat_input[0].set_value(...).run() 은 손님이 첫 질문을 치고 Enter 를 누른 실행이고, 이 실행에서 answer() 가 한 번 불려 말풍선 한 쌍이 생깁니다. m.name 으로 말풍선의 역할을 읽어 user 와 assistant 가 제자리에 그려졌는지 확인합니다.

3단계. 질문 두 개를 더 보내기

Python
for q in ["2박 3일이요", "아이 둘이랑 가요"]:
    at.chat_input[0].set_value(q).run()

print("말풍선:", len(at.chat_message), "개")
for m in at.chat_message:
    print(f"  [{m.name}] {m.markdown[0].value}")

# 메모장에 쌓인 기록을 앱 밖에서 들여다본다
history = at.session_state["messages"]
print("기록:", len(history), "개 / 역할:", [m["role"] for m in history])
출력
말풍선: 6 개
  [user] 다음 달에 제주도 가요
  [assistant] 좋습니다. 며칠 일정으로 생각하세요?
  [user] 2박 3일이요
  [assistant] 2박 3일이면 동쪽 해안 일정을 추천합니다.
  [user] 아이 둘이랑 가요
  [assistant] 아이와 함께라면 첫날은 숙소 근처에서 쉬는 일정을 권합니다.
기록: 6 개 / 역할: ['user', 'assistant', 'user', 'assistant', 'user', 'assistant']

코드가 의미하는 것 질문을 이어 보낼 때마다 스크립트는 처음부터 다시 돌고, 반복문이 기록의 지난 말풍선을 다시 그린 뒤 새 한 쌍을 더합니다. at.session_state["messages"] 로 화면 뒤의 기록을 직접 읽어, 말풍선 수와 기록 수가 함께 늘어나는지 견줍니다. 진짜 모델이면 이 기록이 MessagesPlaceholder("history") 로 들어가 앞의 대화를 알게 됩니다.

4단계. 새 손님으로 열어 보기

Python
# 같은 앱을 새로 열면 새 손님(새 브라우저 탭)이다
guest = AppTest.from_file("out_ch07_chat.py").run()
print("새 손님의 말풍선:", len(guest.chat_message), "개")

guest.chat_input[0].set_value("부산 가고 싶어요").run()
print("새 손님의 기록:", len(guest.session_state["messages"]), "개 / 첫 손님의 기록:", len(at.session_state["messages"]), "개")
print("새 손님이 받은 답:", guest.chat_message[-1].markdown[0].value)
출력
새 손님의 말풍선: 0 개
새 손님의 기록: 2 개 / 첫 손님의 기록: 6 개
새 손님이 받은 답: 좋습니다. 며칠 일정으로 생각하세요?

코드가 의미하는 것 guest 는 새 브라우저 탭으로 들어온 손님입니다. st.session_state 는 손님마다 따로라 기록이 섞이지 않고, st.cache_resource 의 체인은 함께 쓰므로 가짜 모델의 순번이 이어졌습니다. 손님별 것과 함께 쓸 것을 나눠 두는 이 짜임이 여러 사람이 쓰는 챗봇 웹 앱의 기본입니다.

이 실습이 뜻하는 것

이 실습은 홀 직원이 손님 테이블마다 주문표를 꽂아 두고, 주문이 올 때마다 주문표와 함께 주방에 넘기는 일과 같습니다. 홀과 주방을 나눠 짓고(1단계), 첫 손님의 첫 주문을 받고(2단계), 주문을 이어 받고(3단계), 옆 테이블에 새 손님을 앉힙니다(4단계). 여기서 주문표가 st.session_state.messages, 주방이 make_chat_chain() 과 answer(), 아침에 끓여 둔 육수가 @st.cache_resource 로 한 번 만든 체인입니다. 비유와 다른 점은, 이 주방(가짜 모델)은 주문표를 읽지 않고 정해 둔 답을 차례로 내놓는다는 것입니다.

단계출력뜻
1. 앱 파일55 줄챗봇 로직(체인과 answer)과 화면(말풍선과 입력창)을 한 파일 안에서 나눠 적었습니다
2. 첫 질문말풍선 0개 → 2개처음에는 기록이 비어 말풍선이 없고, 질문 하나에 손님과 비서의 말풍선 한 쌍이 생겼습니다
3. 이어 묻기말풍선 6개, 기록 6개앞의 두 개는 기록에서 꺼내 다시 그렸고, 기록에는 user 와 assistant 가 번갈아 쌓였습니다
4. 새 손님새 손님 0개 → 2개, 첫 손님 6개손님마다 메모장이 따로라 기록이 섞이지 않았습니다. 가짜 모델의 순번은 함께 써서 새 손님은 첫째 답을 받았습니다

한 줄로 줄이면, 4장의 대화 체인에 st.session_state 와 st.cache_resource 만 더하면 손님마다 대화가 쌓이는 채팅 화면이 된다는 것을 본 실습입니다. 3단계의 마지막 질문을 보낸 실행에서 스크립트는 처음부터 다시 돌았지만, 메모장의 기록 4개를 말풍선으로 다시 그린 뒤 새 한 쌍을 더해 말풍선 6개가 되었습니다. 4단계의 새 손님은 말풍선 0개에서 시작했고, 첫 손님의 기록은 6개 그대로였습니다. 새 손님이 받은 답이 "좋습니다. 며칠 일정으로 생각하세요?"인 것은 함께 쓰는 가짜 모델이 답 세 개를 다 쓰고 처음으로 돌아갔기 때문입니다. 진짜 모델은 순번 없이 받은 기록을 읽고 답하므로 이런 일이 없습니다.

코드 설명

  • @st.cache_resource 를 붙인 make_chat_chain() 은 서버가 켜진 뒤 처음 부를 때만 체인을 만들고, 그 뒤로는 같은 체인을 돌려줍니다. 손님이 몇 명이든, 질문을 몇 번 하든 체인은 하나입니다.
  • answer(chain, st.session_state.messages, question) 은 본문 "챗봇 로직과 웹 화면 잇기" 절의 함수 그대로입니다. 화면 코드는 이 한 줄로 챗봇 로직을 부릅니다.
  • st.session_state.messages.extend([...]) 는 질문과 답을 한꺼번에 기록에 넣습니다. 체인을 부른 뒤에 넣으므로 모델은 같은 질문을 두 번 받지 않습니다.
  • at.chat_input[0].placeholder 는 입력창의 안내 글, m.name 은 말풍선의 이름("user", "assistant"), m.markdown[0].value 는 말풍선 안의 글입니다.
  • guest 는 at 과 다른 세션이라 st.session_state 가 따로입니다. 그래서 기록이 섞이지 않습니다. 체인은 st.cache_resource 로 함께 쓰므로, 가짜 모델의 순번도 함께 이어집니다.
← 이전 절7장 · 실습 1 — 첫 화면 만들고 다시 실행 세기다음 절 →7장 · 실습 3 — 스트리밍 답과 키 읽기