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

7장. 웹 UI 연동과 배포

실습 3 — 스트리밍 답과 키 읽기

실습 2의 채팅 화면을 두 가지 고칩니다. 답을 st.write_stream 으로 흘려 보여 주고, API 키를 st.secrets 에서 먼저 찾고 없으면 .env 에서 읽는 get_api_key() 를 넣습니다. 이 실습은 키를 찾기만 하고 모델에 쓰지 않으므로 가짜 모델로 돌아가고, 키가 없어도 됩니다. 화면에는 키 자체가 아니라 키를 읽은 곳만 보입니다. 앱 파일은 out_ch07_stream.py 로 저장합니다.

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.language_models.fake_chat_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


def get_api_key():
    """클라우드에서는 Secrets 칸, 내 PC 에서는 .env 에서 키를 읽는다. (키, 읽은 곳) 을 돌려준다"""
    try:
        return st.secrets["OPENAI_API_KEY"], "Secrets"
    except (FileNotFoundError, KeyError):
        # secrets.toml 이 없거나 그 안에 키가 없으면 2차시처럼 .env 를 읽는다
        load_dotenv(find_dotenv(usecwd=True))
        return os.getenv("OPENAI_API_KEY"), ".env"


@st.cache_resource
def make_stream_chain():
    """스트리밍을 보여 줄 대화 체인(가짜 모델)"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", "너는 구름다리여행의 친절한 여행 비서다."),
        MessagesPlaceholder("history"),
        ("human", "{question}"),
    ])
    model = FakeListChatModel(responses=["제주도 2박 3일이면 첫날은 동쪽 해안, 둘째 날은 오름 산책을 추천합니다."])
    return prompt | model | StrOutputParser()


st.title("구름다리여행 여행 비서")
key, where = get_api_key()
with st.sidebar:
    st.header("설정")
    # 키 자체는 찍지 않는다. 어디서 읽었는지와 찾았는지만 보인다
    st.caption(f"키를 읽은 곳: {where}")
    st.caption(f"키를 찾았나: {bool(key)}")

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_stream_chain()
    with st.chat_message("assistant"):
        # chain.stream 이 내놓는 조각을 write_stream 이 말풍선에 이어 쓰고, 다 쓴 글을 돌려준다
        reply = st.write_stream(chain.stream({"history": st.session_state.messages, "question": question}))
    st.session_state.messages.extend([
        {"role": "user", "content": question},
        {"role": "assistant", "content": reply},
    ])
'''

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

코드가 의미하는 것 get_api_key() 는 열쇠함(Secrets)을 먼저 열고, 없으면 2장의 서랍(.env)을 여는 차례를 함수 하나에 담았습니다. 돌려주는 값에 읽은 곳을 함께 넣어, 화면에는 키 대신 출처만 보이게 했습니다. 답 쪽은 chain.invoke 대신 st.write_stream(chain.stream(...)) 으로 바꿔, 조각이 오는 대로 말풍선에 이어 쓰고 다 쓴 글을 reply 로 받습니다.

2단계. 내 PC 처럼 열기(.env 에서 읽기)

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

st.cache_resource.clear()

# secrets.toml 이 없으니 get_api_key 는 .env 를 읽는다
at = AppTest.from_file("out_ch07_stream.py").run()
for c in at.sidebar.caption:
    print(c.value)
출력
키를 읽은 곳: .env
키를 찾았나: True

코드가 의미하는 것 secrets.toml 이 없는 data 폴더에서 앱을 여는 것은 내 PC 에서 streamlit run 을 한 경우와 같습니다. st.secrets 가 FileNotFoundError 를 내고 except 가 이를 잡아 .env 로 넘어갔습니다. 사이드바의 caption 두 줄로 어느 길을 탔는지만 확인하고, 키 자체는 보지 않습니다.

3단계. 클라우드처럼 열기(Secrets 에서 읽기)

Python
cloud = AppTest.from_file("out_ch07_stream.py")
# Secrets 칸에 시험용 값을 넣는다. 진짜 키가 아니고, 이 실습은 모델에 키를 쓰지 않는다
cloud.secrets["OPENAI_API_KEY"] = "시험용-가짜-값"
cloud.run()
for c in cloud.sidebar.caption:
    print(c.value)
출력
키를 읽은 곳: Secrets
키를 찾았나: True

코드가 의미하는 것 cloud.secrets[...] 는 Community Cloud 의 Secrets 칸을 시험 코드 안에서 흉내 냅니다. 값이 있으니 get_api_key() 는 .env 까지 가지 않고 첫 줄에서 돌아옵니다. 넣은 값은 시험용 글이고 이 실습은 모델에 키를 쓰지 않으므로 진짜 키가 필요 없습니다.

4단계. 흘려 쓴 답이 기록에 남는지 보기

Python
at.chat_input[0].set_value("제주도 2박 3일 일정 짜 주세요").run()

reply = at.chat_message[-1].markdown[0].value
print("말풍선:", reply)
saved = at.session_state["messages"][-1]
print("기록의 마지막:", saved["role"], "/", type(saved["content"]).__name__, "/ 말풍선과 같은가:", saved["content"] == reply)
출력
말풍선: 제주도 2박 3일이면 첫날은 동쪽 해안, 둘째 날은 오름 산책을 추천합니다.
기록의 마지막: assistant / str / 말풍선과 같은가: True

코드가 의미하는 것 질문 하나를 보내 write_stream 이 돌려준 글이 기록에 그대로 들어갔는지 봅니다. 기록의 content 가 보통 글(str)이고 말풍선 글과 같으면, 다음 질문 때 체인은 흘려 쓴 답을 앞 대화로 그대로 받습니다. 스트리밍으로 바꿔도 기록을 다루는 코드는 실습 2와 같다는 뜻입니다.

이 실습이 뜻하는 것

이 실습은 주방 창을 열어 요리하는 모습이 보이게 하고, 금고 열쇠를 집에서는 서랍에서, 가게에서는 열쇠함에서 꺼내게 정해 두는 일과 같습니다. 화면을 고치고(1단계), 집에서 열어 보고(2단계), 가게에서 열어 보고(3단계), 흘려 보인 요리가 주문표에도 제대로 적혔는지 봅니다(4단계). 여기서 열린 창이 st.write_stream, 서랍이 .env, 열쇠함이 Secrets 칸입니다. 비유와 다른 점은, 열쇠를 어디서 꺼냈는지는 코드가 알아서 고르므로 같은 앱 파일을 내 PC 와 클라우드에 그대로 둔다는 것입니다.

단계출력뜻
1. 앱 파일56 줄실습 2의 앱에 get_api_key() 와 st.write_stream 을 더했습니다
2. 내 PC읽은 곳 .env, 찾았나 Truesecrets.toml 이 없어 FileNotFoundError 를 잡고 과목 폴더의 .env 를 읽었습니다
3. 클라우드읽은 곳 Secrets, 찾았나 TrueSecrets 칸에 값이 있으면 .env 까지 가지 않고 그것을 씁니다
4. 흘려 쓴 답말풍선과 기록이 같은 글(str)st.write_stream 이 이어 붙여 돌려준 글이 그대로 기록에 남았습니다

한 줄로 줄이면, 같은 앱 파일이 내 PC 에서는 .env, 클라우드에서는 Secrets 에서 키를 찾고, 흘려 쓴 답도 보통 글처럼 기록에 남는다는 것을 확인한 실습입니다. 2단계의 "찾았나 True" 는 과목 폴더의 .env 에 OPENAI_API_KEY 줄이 있어서입니다. 이 교안을 만든 PC 의 .env 에는 견본의 자리표시(발급받은_인증키)가 들어 있었습니다. .env 를 만들지 않았다면 "찾았나: False" 로 나옵니다. 4단계에서 말풍선 글과 기록의 마지막 글이 같았으므로, 다음 질문 때 체인은 흘려 쓴 답을 앞의 대화로 그대로 받습니다.

코드 설명

  • get_api_key() 는 st.secrets["OPENAI_API_KEY"] 를 먼저 꺼내고, secrets.toml 이 없을 때의 FileNotFoundError 와 키 이름이 없을 때의 KeyError 를 잡아 .env 로 넘어갑니다. 같은 앱 파일이 내 PC 와 클라우드에서 그대로 돕니다.
  • load_dotenv(find_dotenv(usecwd=True)) 는 2장에서 쓴 줄 그대로입니다. streamlit run 을 실행한 폴더에서 위로 올라가며 .env 를 찾습니다.
  • st.write_stream(chain.stream({...})) 는 체인이 내놓는 글 조각을 말풍선에 이어 쓰고, 다 쓴 뒤 이어 붙인 글을 돌려줍니다. 그 글을 그대로 기록에 넣습니다.
  • cloud.secrets["OPENAI_API_KEY"] = "시험용-가짜-값" 은 AppTest 에서만 쓰는 Secrets 칸입니다. 진짜 키를 시험 코드에 적지 않습니다.
← 이전 절7장 · 실습 2 — 대화가 쌓이는 채팅 화면다음 절 →7장 · 실습 4 — 진짜 모델을 붙인 채팅 화면