Streamlit으로 대화·기억·Streaming 구현하기
LangChain RAG를 Streamlit의 chat_input·chat_message·session_state·write_stream과 연결해 대화 기록과 Streaming 응답을 만드는 구조를 쉽게 설명합니다.
LangChain으로 사내 문서 RAG 챗봇 만들기 7/9
문서를 준비하는 단계부터 검색, 대화 화면, 배포와 평가까지 하나씩 연결하는 학습 기록
지금까지 만든 RAG는 터미널에서 질문하고 검색 결과를 확인했습니다. 개발자에게는 충분하지만 축제 담당 학생에게 “Python 함수를 호출하세요”라고 말할 수는 없습니다.
Streamlit은 Python 코드로 입력창과 대화 말풍선을 만들 수 있게 해줍니다. 하지만 화면을 그리는 것, 대화를 기억하는 것, LLM이 답을 생성하는 것은 서로 다른 일입니다. 이 경계를 나눠야 새로고침과 Streaming에서 대화가 꼬이지 않습니다.
이번 글에서는 st.chat_input, st.chat_message, st.session_state와 st.write_stream을 LangChain Chat Model 흐름에 연결합니다. 실제 API Key나 모델은 사용하지 않았습니다.
이 글은 시리즈 순서에 맞춰 2026년 6월 18일에 배치했습니다. API는 2026년 8월 16일 Streamlit과 LangChain 공식 문서를 기준으로 확인했습니다.
웹 화면은 왜 매번 다시 그려질까?
Streamlit 앱은 사용자가 입력하거나 버튼을 누르면 Python 스크립트를 위에서부터 다시 실행합니다. 이를 Rerun이라고 생각하면 쉽습니다.
칠판을 매 질문마다 지우고 다시 그리는 것과 비슷합니다. 이전 대화를 일반 변수에만 넣으면 다음 실행에서 사라집니다. 그래서 사용자 Session마다 값을 유지하는 st.session_state가 필요합니다.
Session State는 영구 Database가 아닙니다. 브라우저 연결과 앱 실행 환경에 묶인 상태이므로 재시작 후에도 반드시 남는 대화 기록으로 취급하면 안 됩니다.
화면·기억·모델의 역할을 나누자
| 구성 요소 | 맡은 일 | 주의할 점 |
|---|---|---|
st.chat_input |
사용자의 새 질문 받기 | 빈 안내 문구는 접근성에 불리함 |
st.chat_message |
사용자와 AI Message 표시 | 표시했다고 모델이 기억하는 것은 아님 |
st.session_state |
Rerun 사이에 현재 Session의 대화 보관 | 영속 저장소와 다름 |
| LangChain Chat Model | 전달받은 Message로 답 생성 | 과거 Message를 자동으로 아는 것은 아님 |
st.write_stream |
도착한 조각을 화면에 순서대로 표시 | 총 생성 시간을 자동으로 줄이지 않음 |
핵심은 저장한 대화를 다시 모델 입력에 포함하는 것입니다. 화면에 과거 말풍선이 보인다는 사실만으로 Model이 그 내용을 받은 것은 아닙니다.
한 번의 대화는 이렇게 흐른다
질문 하나가 대화 화면에 돌아오기까지
Streamlit은 화면과 Session 상태를 관리하고, LangChain은 Message를 모델 호출 흐름에 연결합니다.
다음 질문에서도 이어지는 대화현재 Session의 기록을 다시 전달할 뿐 모델 가중치가 바뀌거나 영구 학습되는 것은 아닙니다.
현재 단계: 질문 입력
가장 작은 Streamlit 대화 화면
먼저 모델 없이 입력과 말풍선만 연결합니다.
import streamlit as st
st.title("학교 축제 안내 챗봇")
if "messages" not in st.session_state:
st.session_state.messages = []
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
if user_text := st.chat_input("축제 안내문에 대해 물어보세요"):
st.session_state.messages.append({
"role": "user",
"content": user_text,
})
with st.chat_message("user"):
st.markdown(user_text)
Streamlit 공식 예제와 같은 기본 구조입니다. Session State가 없으면 Rerun 때 대화 목록을 다시 그릴 자료가 없습니다.
사용자 입력을 Markdown으로 표시할 때 HTML을 무분별하게 허용하지 않습니다. 업로드 파일을 받는다면 확장자 확인만으로 안전하다고 생각하지 말고 크기·형식·저장 경로를 검증해야 합니다.
LangChain Streaming을 어떻게 붙일까?
Streaming은 모델이 완성한 답 전체를 기다리지 않고 생성된 일부를 순서대로 받는 방식입니다. 첫 글자를 더 일찍 볼 수 있지만 모델의 총 계산량이 자동으로 줄어드는 것은 아닙니다.
다음 예제는 Message 목록을 모델에 전달하고 문자열 조각만 화면으로 내보냅니다.
import streamlit as st
from langchain_openai import ChatOpenAI
model = ChatOpenAI(model="<사용 가능한 모델 이름>")
def stream_answer(messages):
for chunk in model.stream(messages):
if isinstance(chunk.content, str):
yield chunk.content
if user_text := st.chat_input("축제 안내문에 대해 물어보세요"):
user_message = {"role": "user", "content": user_text}
st.session_state.messages.append(user_message)
with st.chat_message("user"):
st.markdown(user_text)
with st.chat_message("assistant"):
answer = st.write_stream(
stream_answer(st.session_state.messages)
)
st.session_state.messages.append({
"role": "assistant",
"content": answer,
})
st.write_stream은 iterable의 문자열 조각을 순서대로 표시하고 완성된 값을 돌려줄 수 있습니다. 응답을 Session State에 넣어야 다음 Rerun에서도 다시 보입니다.
이 코드는 공식 문서의 흐름을 단순화한 학습 예제입니다. 모델 제공자에 따라 chunk.content가 문자열 외의 Content Block을 포함할 수 있으므로 실제 연동에서는 사용하는 Model의 응답 형식을 확인해야 합니다.
RAG Context는 어디에 넣을까?
앞 글의 Retriever로 현재 질문과 관련된 문서를 찾은 뒤 System Message나 Prompt Template의 Context 영역에 넣습니다. 대화 전체와 검색 문서를 무작정 합치면 입력 Token이 빠르게 늘어납니다.
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
(
"system",
"아래 Context만 참고해 답하세요. "
"근거가 없으면 확인할 수 없다고 말하세요.\n\n"
"Context:\n{context}",
),
("placeholder", "{conversation}"),
("human", "{question}"),
])
검색용 질문은 대화의 마지막 Message만으로 충분하지 않을 수 있습니다. “그건 몇 시까지야?”처럼 앞 문맥이 필요한 질문은 검색 전에 독립적인 질문으로 바꾸는 과정이 필요할 수 있습니다.
Chat History와 RAG 문서는 목적이 다릅니다. History는 방금 나눈 대화이고, RAG Context는 외부 문서에서 찾은 근거입니다.
Streaming 중 연결이 끊기면?
화면에 일부 문장만 보인 상태에서 브라우저가 닫힐 수 있습니다. 이때 완성되지 않은 답을 정상 Message로 저장할지, 실패 표시와 함께 보관할지 정해야 합니다.
모델 호출 취소가 제공자까지 전달되는지도 확인해야 합니다. 화면이 사라졌다고 외부 API의 계산과 비용이 반드시 중단되는 것은 아닙니다.
다음 상태를 구분하면 좋습니다.
- 생성 중
- 사용자 취소
- Model 오류
- 일부 답변 수신 후 실패
- 정상 완료
오류가 났는데 빈 Assistant Message를 성공처럼 저장하면 다음 대화 문맥도 오염됩니다.
대화가 길어지면 모두 보내도 될까?
모델에는 한 요청에서 처리할 수 있는 Context 범위가 있습니다. Session State에 Message가 계속 쌓여도 모두 보낼 수 있는 것은 아닙니다.
최근 Message만 유지하거나 오래된 대화를 요약할 수 있습니다. 사용자 이름 같은 장기 정보와 현재 질문에 필요한 짧은 대화를 분리할 수도 있습니다.
무엇을 버릴지는 업무에 따라 다릅니다. 금융 문의처럼 감사 기록이 필요하다면 모델에 전달할 Memory와 별도로 전체 Chat History를 안전한 저장소에 보관해야 할 수 있습니다.
여러 사용자의 대화는 섞이지 않을까?
st.session_state는 사용자 Session별로 값을 나누는 데 도움을 줍니다. 하지만 외부 Database에 저장할 때는 인증된 사용자와 Conversation ID의 소유권을 서버가 확인해야 합니다.
사용자가 URL이나 입력값으로 보낸 Conversation ID를 그대로 믿으면 다른 사람의 기록을 불러올 수 있습니다. 로그에도 Prompt, 검색 문서와 개인정보를 무조건 남기지 않습니다.
실행과 배포 전에 확인할 것
이 Astro 블로그 저장소에서는 Streamlit을 설치하거나 실행하지 않았습니다. 별도 Python 프로젝트에서 다음 구조를 준비할 수 있습니다.
festival-chatbot/
├─ app.py
├─ rag.py
├─ ingest.py
└─ requirements.txt
API Key는 소스 코드나 공개 저장소에 넣지 않습니다. 로컬에서는 환경 변수나 승인된 비밀 저장 방식을 사용하고, 배포 환경에서도 Secret 관리 기능을 확인해야 합니다.
자주 생기는 오해
화면에 과거 대화가 보이면 모델도 기억하나요?
아닙니다. 화면 표시와 Model 입력은 별개입니다. 과거 Message를 다음 요청에 다시 포함해야 합니다.
Session State는 Database인가요?
아닙니다. Rerun 사이의 사용자 Session 상태를 유지하는 도구입니다. 영구 보존과 여러 서버 공유가 필요하면 별도 저장소가 필요합니다.
Streaming은 답변 전체를 더 빨리 만드나요?
반드시 그렇지 않습니다. 첫 부분을 일찍 보여 체감 지연을 줄일 수 있지만 총 완료 시간과 계산량은 별도입니다.
Chat History를 전부 넣으면 더 똑똑해지나요?
항상 그렇지 않습니다. Token과 비용이 늘고 오래된 잘못된 문맥이 답에 영향을 줄 수 있습니다.
세 줄 요약
- Streamlit의 chat 요소는 화면을 만들고 Session State는 Rerun 사이의 대화를 보관하지만 모델이 자동으로 기억하게 하지는 않습니다.
- 다음 요청에 과거 Message와 검색 Context를 다시 전달해야 대화형 RAG가 이어집니다.
- Streaming은 첫 응답을 빨리 보여줄 수 있지만 취소·부분 실패·Context 길이와 민감정보 저장을 별도로 설계해야 합니다.
다음 편 예고
챗봇 화면이 생겼지만 답변 형식이 매번 달라지면 사용자는 읽기 어렵고 후속 코드도 처리하기 어렵습니다. 예시를 보여주면 원하는 모양에 가까워질까요?
다음 편 — Few-shot으로 답변 형식 개선하고 배포하기에서 예시 Prompt와 공개 배포의 경계를 살펴봅니다.