7장. 웹 UI 연동과 배포
실습 2 — 대화가 쌓이는 채팅 화면
4장의 대화 체인(prompt | model | StrOutputParser())을 채팅 화면에 붙입니다. 대화 기록은 st.session_state.messages 에 쌓고, 체인은 st.cache_resource 로 한 번만 만듭니다. 모델 자리에는 1장의 가짜 모델(FakeListChatModel)을 두어 API 키 없이 돌아갑니다. 가짜 모델은 질문과 상관없이 정해 둔 답을 차례로 말합니다. 앱 파일은 out_ch07_chat.py 로 저장합니다.
1단계. 채팅 앱 파일 쓰기
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단계. 첫 화면과 첫 질문
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단계. 질문 두 개를 더 보내기
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단계. 새 손님으로 열어 보기
# 같은 앱을 새로 열면 새 손님(새 브라우저 탭)이다
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로 함께 쓰므로, 가짜 모델의 순번도 함께 이어집니다.