14장. Streamlit 화면과 Docker 컨테이너
실습 2 — 채팅 화면과 대화 기억
이 PC 의 Ollama(qwen2.5:3b)와 대화하는 채팅 화면을 out_app_chat.py 로 만들고, AppTest 로 두 번 물어 대화 기록이 남는지 확인합니다. 화면은 openai 라이브러리 없이 Ollama 고유 주소(/api/chat)를 표준 라이브러리로 부릅니다. 화면 서버에 들일 패키지를 줄이려고입니다.
1단계. 채팅 화면 파일 쓰기
import json, urllib.request
from pathlib import Path
# 오래 쓴 Ollama 모델이 깨진 답을 내는 일을 막으려고 모델을 내렸다 다시 올린다(10차시)
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()
Path("out_app_chat.py").write_text('''"""Ollama 채팅 화면. 실행: streamlit run out_app_chat.py"""
import json
import os
import urllib.request
import streamlit as st
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") # 주소와 모델은 설정으로 받는다
MODEL = os.environ.get("CHAT_MODEL", "qwen2.5:3b")
def chat(messages):
body = {"model": MODEL, "messages": messages, "stream": False, "options": {"temperature": 0, "seed": 42}}
req = urllib.request.Request(OLLAMA + "/api/chat", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())["message"]["content"].strip()
st.title("사내 도우미 채팅")
with st.sidebar:
system = st.text_area("시스템 지시", "당신은 친절한 사내 도우미입니다. 한두 문장으로 답하세요.", key="system")
if st.button("대화 지우기"):
st.session_state.messages = []
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.write(m["content"])
if prompt := st.chat_input("물어보세요"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.write(prompt)
with st.chat_message("assistant"):
with st.spinner("답을 쓰는 중"):
recent = st.session_state.messages[-10:] # 최근 다섯 번의 주고받음만 보낸다
answer = chat([{"role": "system", "content": system}] + recent)
st.write(answer)
st.session_state.messages.append({"role": "assistant", "content": answer})
''', encoding="utf-8")
print("화면 파일:", Path("out_app_chat.py").stat().st_size, "바이트")화면 파일: 1893 바이트2단계. 첫 질문 보내기
import time
from streamlit.testing.v1 import AppTest
at = AppTest.from_file(str(Path("out_app_chat.py").resolve()), default_timeout=180).run()
t = time.perf_counter()
at.chat_input[0].set_value("제 이름은 김솔내이고 물류팀에서 일해요. 기억해 주세요.").run()
print(f"{time.perf_counter() - t:.1f}초")
for m in at.chat_message:
print(f"[{m.name}]", m.markdown[0].value)8.3초
[user] 제 이름은 김솔내이고 물류팀에서 일해요. 기억해 주세요.
[assistant] 김솔내님, 반갑습니다. 물류팀에서 일을 하시다니요. 필요한 것이 있으시면 언제든지 도와드릴 수 있습니다.3단계. 앞의 대화를 기억하는지 묻기
at.chat_input[0].set_value("제 이름과 부서가 뭐였죠?").run()
last = at.chat_message[-1].markdown[0].value
print("[assistant]", last)
print("기록 수:", len(at.session_state.messages), "· 이름을 기억함:", "김솔내" in last, "· 부서를 기억함:", "물류" in last)[assistant] 제 이름은 김솔내이고, 부서는 물류팀이었습니다.
기록 수: 4 · 이름을 기억함: True · 부서를 기억함: True4단계. 대화 지우기
at.sidebar.button[0].click().run() # 사이드바의 "대화 지우기"
print("지운 뒤 기록 수:", len(at.session_state.messages), "· 말풍선 수:", len(at.chat_message))
at.chat_input[0].set_value("제 이름이 뭐였죠?").run()
print("[assistant]", at.chat_message[-1].markdown[0].value)지운 뒤 기록 수: 0 · 말풍선 수: 0
[assistant] 당신의 이름은 제가 모른다고 답변드릴 수 있습니다.이 실습이 뜻하는 것
이 실습은 메모장을 든 안내 데스크 직원과 같습니다. 먼저 안내 데스크를 차리고(1단계), 손님이 이름과 부서를 말합니다(2단계). 손님이 "제 이름이 뭐였죠?"라고 다시 물으면 직원은 메모장을 보고 답합니다(3단계). 메모장을 지우면 같은 질문에 답하지 못합니다(4단계). 여기서 안내 데스크가 채팅 화면, 직원이 qwen2.5:3b 모델, 메모장이 st.session_state.messages 입니다. 비유와 다른 점은, 사람은 머리로 기억하지만 모델은 아무것도 기억하지 않고 화면이 메모장을 매번 통째로 다시 읽어 준다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 화면 파일 | 1893 바이트 | 채팅 화면 파일이 저장되었습니다 |
| 2. 첫 질문 | 8.3초 | 첫 답까지 걸린 시간입니다. 모델을 내렸다가 다시 올리는 시간이 들어 있고, 이 PC 에서 잰 값입니다 |
| 2. 첫 답 | 김솔내님, 반갑습니다. … | 모델이 이름과 부서를 받아 인사했습니다. 사용자와 모델의 말풍선이 하나씩 그려졌습니다 |
| 3. 다시 묻기 | 제 이름은 김솔내이고, 부서는 물류팀이었습니다. | 이름과 부서를 맞혔습니다. "제 이름은"으로 받은 것은 작은 모델의 말투 실수입니다 |
| 3. 기록 | 기록 수: 4, True, True | 질문 둘과 답 둘이 기록에 쌓였고, 답에 "김솔내"와 "물류"가 모두 들어 있었습니다 |
| 4. 지우기 | 지운 뒤 기록 수: 0 · 말풍선 수: 0 | 메모장을 비우자 화면의 말풍선도 모두 사라졌습니다 |
| 4. 다시 묻기 | 당신의 이름은 제가 모른다고 … | 기록이 없으니 모델도 이름을 모릅니다. 기억이 모델이 아니라 화면의 기록에 있었다는 증거입니다 |
한 줄로 줄이면, 채팅의 "기억"은 화면이 대화 기록을 들고 있다가 질문할 때마다 함께 보내는 것임을 보인 실습입니다. 화면 코드는 최근 10개의 말(다섯 번의 주고받음)만 보내므로, 대화가 길어지면 앞의 말은 잊습니다. 이번에는 두 번만 물어서 그 한계는 아직 드러나지 않았습니다.
코드 설명
st.session_state.messages는 다시 실행돼도 남는 대화 기록입니다.if "messages" not in st.session_state로 처음 한 번만 빈 목록을 만들고, 실행마다 그 기록을 말풍선으로 다시 그립니다. 첫 질문의 답은 8.3초 걸렸는데, 모델을 내렸다가 다시 올리는 시간이 들어 있습니다.- 3단계의 "제 이름과 부서가 뭐였죠?"에 모델은 "제 이름은 김솔내이고, 부서는 물류팀이었습니다"라고 답했습니다. 모델 자체는 대화를 기억하지 못하고, 화면이 기록(4개)을 함께 보냈기 때문에 앞의 말을 안 것입니다. 주어를 "제 이름은"으로 받아 말한 것은 작은 모델의 말투 실수입니다.
- 4단계에서 사이드바의 "대화 지우기"를 누르자 기록과 말풍선이 모두 0개가 되었고, 같은 질문에 모델은 이름을 모른다고 답했습니다.
at.sidebar.button처럼 사이드바 안의 요소는at.sidebar아래에서 찾습니다. st.chat_input은 화면 아래쪽에 입력 칸을 두고, 사용자가 보낸 그 실행에서만 글을 돌려줍니다.if prompt := st.chat_input(...)꼴로 받은 때만 모델을 부릅니다.