SK플래닛 ai활용 데이터엔지니어 과정 2기/ML & DL

LLM 2 - 서비스 구축 실습

dev-lee 2026. 5. 29. 17:53

LLM 서비스 구축 · RAG

프롬프트 엔지니어링 개념을 실제 동작하는 서비스로 구현함.
Streamlit(프론트) + FastAPI(백엔드) + Bedrock + LangChain으로 "식사 메뉴 추천 AI"를 구성하고,
이후 RAG + Vector DB(FAISS)를 도입하여 LLM이 모르는 데이터를 추론에 활용함.

1. 서비스 아키텍처

화면(Streamlit)과 API(FastAPI)를 분리하고, LLM 호출은 별도 모듈로 격리하는 계층형 구조로 설계함.

1.1 구성 요소

계층  역할  기술
프론트엔드 채팅 화면, 입력/출력 처리 Streamlit
백엔드 /chat API 제공, 프론트 ↔ Bedrock 중계 FastAPI + uvicorn
LLM 모듈 프롬프트 구성, Bedrock 호출, 체인 구성 LangChain (langchain-aws)
모델 서빙 LLM 추론 담당 AWS Bedrock
  • 서비스 주제 — 점심/저녁 식사 메뉴 추천 (날씨·기분·단체여부·예산·MBTI 등 상황 기반)
  • 프론트와 백엔드 분리 — 프론트는 화면만, 백엔드는 LLM 통신만 담당하여 책임을 나눔

1.2 프로젝트 구조

/
├─ .env              : 환경변수 (Bedrock 키, 리전, 모델 ID)
├─ .gitignore        : 가상환경/.env 등 git 미반영 처리
├─ requirements.txt  : 패키지 목록
├─ app.py            : 프론트엔드 (Streamlit)
├─ server.py         : 백엔드 (FastAPI)
└─ llm/__init__.py   : Bedrock 통신 + LangChain 체인 모듈

1.3 환경 설정

# 가상환경 구축 및 활성화 (Windows)
python -m venv llm_venv
./llm_venv/Scripts/activate

# 패키지 설치
pip install -r requirements.txt
# requirements.txt
fastapi          # 백엔드 구성
uvicorn          # FastAPI 구동
streamlit        # 프론트 구성
requests         # 프론트 -> 백엔드 요청
boto3            # AWS SDK
langchain-aws    # 랭체인 AWS 전용
langchain-core   # 랭체인 코어
python-dotenv    # .env 로드
# .env (키는 git에 올리지 않음)
AWS_REGION='us-east-1'
MODEL_ID='google.gemma-3-27b-it'
AWS_BEARER_TOKEN_BEDROCK='bedrock-api-key-...'

키 정보는 .env로 분리하고 .gitignore에 등록함. 코드에는 os.getenv()로 주입받아 자격증명이 소스에 노출되지 않도록 함.


2. 백엔드 — FastAPI

프론트에서 들어온 채팅 입력을 받아 LLM 체인을 호출하고, 응답을 다시 프론트로 전달하는 중계 서버.

2.1 server.py

from fastapi import FastAPI
from pydantic import BaseModel   # 입력 구조 정의 + 유효성 검사
from llm import chain            # LLM 모듈의 체인 import

app = FastAPI(title='식사 메뉴 추천 AI')

# 요청 데이터 구조 정의
class UserRequest(BaseModel):
    query: str

@app.post('/chat')
async def chat(req: UserRequest):
    try:
        response = chain.invoke({"user_input": req.query})
        return {"response": response.content}
    except Exception as e:
        return {"response": f"에러 {e}"}
  • Pydantic BaseModel — 요청 JSON의 구조(query: str)를 강제하여 잘못된 입력을 사전 차단
  • chain.invoke() — LangChain 체인 호출, 입력 변수명(user_input)이 프롬프트 템플릿과 일치해야 함
  • response.content — LangChain 응답 객체에서 본문 텍스트만 추출

2.2 요청 흐름

클라이언트 채팅 입력 -> POST /chat -> 프롬프트 구성 -> Bedrock 호출 -> 응답 파싱 -> 프론트 전달

화면이 없는 순수 API 서버임. try/except로 LLM 호출 실패 시에도 서버가 죽지 않고 에러 메시지를 응답으로 반환하도록 처리함.


3. LLM 모듈 — Bedrock + LangChain

Bedrock 클라이언트 생성부터 Few-Shot 프롬프트, 체인 구성까지 담당하는 핵심 모듈.

3.1 클라이언트 / 모델 구성

import os, boto3
from dotenv import load_dotenv
from langchain_aws import ChatBedrockConverse
from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate

load_dotenv()

# Bedrock 클라이언트
bedrock_client = boto3.client(
    service_name='bedrock-runtime',
    region_name=os.getenv('AWS_REGION')
)

# LLM 객체
llm = ChatBedrockConverse(
    client=bedrock_client,
    model_id=os.getenv('MODEL_ID'),
    max_tokens=512,
    temperature=0.7
)
  • ChatBedrockConverse — Bedrock의 converse API를 LangChain 인터페이스로 래핑, 모델명만 바꿔도 동일 코드로 호출 가능
  • temperature=0.7 — 추천 서비스 특성상 약간의 다양성을 허용
  • max_tokens=512 — 응답 길이 제한

3.2 Few-Shot 프롬프트 구성

# 1. 예시 데이터 (입력 -> 모범 출력)
fewshot_samples = [
    {"input": "오늘 점심 메뉴 추천해줘. 비가 오고 있어서 나가기 귀찮아. 혼자 먹을 거야.",
     "output": "비 오는 날, 집에서 편하게 즐길 수 있는 '따뜻한 김치수제비와 해물파전'을 추천합니다! ..."},
    # ... (회식, 데이트, 스트레스 상황 등 4개)
]

# 2. 예시 1건의 포맷 (human -> ai)
fewshot_samples_format = ChatPromptTemplate.from_messages([
    ('human', '{input}'),
    ('ai',    '{output}')
])

# 3. Few-Shot 프롬프트로 변환
fewshot_prompt = FewShotChatMessagePromptTemplate(
    examples=fewshot_samples,
    example_prompt=fewshot_samples_format
)
  • Few-Shot의 목적 — 파인튜닝 없이 "일정한 품질의 결과물"을 유도, 답변 톤과 형식을 예시로 학습시킴
  • FewShotChatMessagePromptTemplate — 예시를 실제 대화(human/ai) 형태로 주입하여 채팅 모델에 자연스럽게 전달
  • 상황 다양화 — 비 오는 날 혼밥, 단체 회식, 데이트, 스트레스 등 시나리오를 골고루 넣어 일반화 유도

3.3 체인 구성

# 최종 프롬프트 = 페르소나(system) + 예시(few-shot) + 사용자 입력(human)
last_prompt = ChatPromptTemplate.from_messages([
    ('system', '당신은 직장인들의 식사 메뉴 고민을 해결해주는 계획적인 메뉴 추천 전문가입니다. ...'),
    fewshot_prompt,           # 샘플 삽입
    ('human', '{user_input}') # 실제 사용자 질의
])

# 단방향 체인 (프롬프트 -> LLM)
chain = last_prompt | llm

프롬프트는 페르소나 → Few-Shot 예시 → 사용자 입력 순으로 쌓임. 파이프 연산자(|)로 prompt | llm 체인을 만들면, 어떤 모델을 쓰든 chain.invoke() 하나로 호출 인터페이스가 통일됨.


4. 프론트엔드 — Streamlit

파이썬만으로 채팅 UI를 구성하고, 대화 맥락은 세션 상태로 관리함.

4.1 세션 상태 기반 대화 관리

import streamlit as st
import requests as req

API_URL = 'http://localhost:8000/chat'

st.set_page_config(page_title='식사 메뉴 추천')
st.title('AI 식사 메뉴 추천')

# 대화 내용 저장 공간 (최초 1회만 초기화)
if "messages" not in st.session_state:
    st.session_state.messages = [
        {'role': 'assistant', 'content': '안녕하세요! 오늘 식사 메뉴를 추천해드릴게요. 정보를 입력해주세요.'}
    ]

# 이전 대화 내용 화면 출력
for msg in st.session_state.messages:
    with st.chat_message(msg['role']):
        st.markdown(msg['content'])
  • st.session_state — 재실행(rerun) 사이에도 대화 내역을 유지하는 저장소, 새로고침 전까지 맥락 보존
  • 초기화 가드 — if "messages" not in st.session_state로 최초 1회만 기본 안내 메시지 세팅
  • st.chat_message — role(user/assistant)에 따라 채팅 말풍선 UI 자동 렌더링

4.2 채팅 입력 처리

if prompt := st.chat_input('현재 상황을 자세하게 입력하세요...'):
    # 1. 사용자 입력 저장 및 출력
    st.session_state.messages.append({'role': 'user', 'content': prompt})
    with st.chat_message('user'):
        st.markdown(prompt)

    # 2. "생각 중" 연출
    with st.chat_message('assistant'):
        msg_holder = st.empty()
        msg_holder.markdown('생각 중입니다...')

    # 3. 백엔드 호출
    try:
        res = req.post(API_URL, json={"query": prompt})
        result = res.json().get('response', '...') if res.status_code == 200 else f'서버 오류 {res.status_code}'
    except Exception as e:
        result = f'서버 오류 {e}'

    # 4. 응답 출력 및 저장
    msg_holder.markdown(result)
    st.session_state.messages.append({'role': 'assistant', 'content': result})
  • st.chat_input — 하단 고정 입력창, 왈러스 연산자(:=)로 입력 여부 판정과 값 할당을 동시에 처리
  • st.empty() placeholder — "생각 중" 메시지를 먼저 띄우고, 응답이 오면 같은 자리를 덮어쓰기
  • 상태 동기화 — 사용자/AI 메시지를 모두 session_state에 append하여 다음 rerun에서도 누적 표시

프론트는 LLM을 직접 모르고 오직 /chat API만 호출함. 화면 로직과 추론 로직이 분리되어 모델을 교체해도 프론트 코드는 손댈 필요가 없음.


5. RAG — 검색 증강 생성

LLM이 학습하지 않은 데이터(사내 데이터, 최신 정보)를 검색하여 프롬프트에 함께 전달하는 기법.

5.1 RAG vs 파인튜닝

항목  파인튜닝  RAG
방식 신규 데이터로 모델 재학습 추론 시 외부 데이터를 검색하여 주입
비용 학습 비용 높음 Vector DB 업데이트만 하면 됨 (효율적)
최신성 재학습 전까지 고정 DB만 갱신하면 즉시 반영
보안 사내 데이터를 학습에 노출 데이터를 외부 학습에 넘기지 않음
  • RAG 사용 동기 — 사내 데이터는 노출하기 싫지만, LLM의 강력한 추론/생성 능력은 쓰고 싶을 때
  • 대상 데이터 — 회사 데이터, 개인 데이터, LLM 생성 시점 이후 발생한 최신 데이터 등
  • 흐름 — 추론 요청 시 관련 데이터를 검색해 프롬프트에 첨부 → (질의 + 검색 결과) 형태로 LLM 전달

5.2 Vector DB

  • 역할 — RAG를 위한 장기 기억 저장소, 유사도 기반 검색 기능 제공
  • 데이터 형태 — 자연어를 토크나이저로 벡터화하여 저장
  • 임베딩 교체 — BedrockEmbeddings에서 모델만 바꾸면 토크나이저 교체 가능
제품  특징
Pinecone 관리형, 무료 계정은 DB 1개 제한
Milvus / Qdrant 대규모 처리에 적합한 오픈소스
Chroma / FAISS 메모리 기반, 로컬 실습에 적합

최신 흐름은 클라우드 중심에서 온프레미스·디바이스 AI로 이동 중임. 데이터 주권과 비용 문제로 인해 사내 구축 + RAG 조합이 현실적 대안이 됨.


6. RAG 실습 — FAISS

인메모리 검색 -> 대량 문서 청킹/저장 -> 검색 + 추론 체인까지 3단계로 RAG를 구현함.

6.1 기본 — 인메모리 FAISS

from langchain_community.vectorstores import FAISS
from langchain_aws import BedrockEmbeddings

# LLM이 모르는 데이터라고 가정
data = [
    "맥도날드의 대표 제품은 빅맥이다.",
    "삼성은 한국의 최대 기업이다.",
    "6월 3일에 선거가 예정되어 있다.",
    "서브노티카2가 최근 OBT를 시작하였다."
]

# 임베딩 모델 (자연어 -> 분절 -> 벡터화 -> 패딩)
tokenizer = BedrockEmbeddings(
    model_id="amazon.titan-embed-text-v2:0",
    region_name=os.getenv('AWS_REGION')
)

# 텍스트를 벡터화하여 메모리 DB에 적재
vector_db = FAISS.from_texts(data, tokenizer)

# 유사도 기반 검색
docs = vector_db.similarity_search("요즘 OBT를 시작한 게임이 뭐였지")
print(docs[0].page_content)  # -> "서브노티카2가 최근 OBT를 시작하였다."
  • BedrockEmbeddings — 자연어를 벡터로 변환하는 임베딩 모델, 검색·저장 모두 동일 모델 사용
  • FAISS.from_texts — 문자열 리스트를 벡터화하여 메모리 기반 DB 구성
  • similarity_search — 질의와 의미적으로 가장 가까운 문서를 반환 (키워드 일치가 아닌 유사도 기반)

6.2 대량 문서 — 청킹 & 저장

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
import glob

# 1. 문서 로드
files = glob.glob('./rag/data/*.txt')
raw_docs = [TextLoader(file, encoding='utf-8').load()[0] for file in files]

# 2. 청크 단위 분할
splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,      # 자르는 단위
    chunk_overlap=100,   # 문맥 유지를 위한 겹침 구간
)
splits = splitter.split_documents(raw_docs)

# 3. 벡터화 후 DB 적재 및 로컬 저장
vector_db = FAISS.from_documents(splits, tokenizer)
vector_db.save_local('hp-story')
  • 청크(chunk) — 말뭉치를 토큰 제한에 맞춰 일정 크기로 분할한 단위, 크기 설정이 검색 성능에 직접 영향
  • chunk_overlap — 청크 경계에서 문맥이 끊기지 않도록 앞뒤를 겹쳐 자름
  • save_local — 한 번 구축한 DB를 파일(index.faiss, index.pkl)로 저장하여 재사용
  • 최적 청크 크기 — 정답이 없으므로 여러 차례 시도하며 성능을 비교해 찾아야 함

6.3 RAG 체인 — 검색 + 추론

from langchain_aws import ChatBedrock
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

# 저장된 DB 로드
vector_db = FAISS.load_local('hp-story', tokenizer, allow_dangerous_deserialization=True)

# 프롬프트 (context + 질문)
prompt = ChatPromptTemplate.from_template('''
    다음의 제공된 context를 사용하여 질문에 답변해주세요.
    문맥에서 답을 찾을 수 없다면 "잘 모르겠음"이라고 답변하세요.
    <context>{context}</context>
    질문 : {user_input}
''')

# 리트리버 (상위 3개 청크 참조)
retriever = vector_db.as_retriever(search_kwargs={"k": 3})

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# LCEL 파이프라인
rag_chain = (
    {"context": retriever | format_docs, "user_input": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

res = rag_chain.invoke('톰 마볼로 리들의 능력은?')
  • as_retriever(k=3) — 유사도 상위 3개 청크를 검색하는 리트리버 생성
  • format_docs — 검색된 여러 청크를 하나의 context 문자열로 병합
  • RunnablePassthrough — 사용자 질문을 검색과 프롬프트에 동시에 흘려보냄
  • StrOutputParser — LLM 응답 객체에서 문자열만 추출 (.content 수동 호출 불필요)
  • LCEL — | 연산자로 검색 → 프롬프트 → LLM → 파싱을 하나의 흐름으로 연결

메뉴 추천 체인은 prompt | llm 단방향이었지만, RAG 체인은 앞단에 검색(retriever) + 결합(format_docs) 단계가 추가됨. 결과적으로 프롬프트가 (검색된 문맥 + 질문) 형태로 구성되어, LLM이 학습하지 않은 데이터에도 근거 기반 답변을 생성함.


7. 요약

주제  핵심 내용
아키텍처 Streamlit(프론트) / FastAPI(백엔드) / LangChain(LLM) 계층 분리
백엔드 /chat API가 체인을 호출하고 응답을 중계, Pydantic으로 입력 검증
LLM 모듈 ChatBedrockConverse + Few-Shot으로 일정 품질의 추천 유도
프론트 session_state로 대화 맥락 유지, API만 호출하여 모델과 분리
RAG 파인튜닝 없이 외부 데이터를 검색·주입, 비용/보안 효율적
Vector DB BedrockEmbeddings로 벡터화, FAISS로 유사도 검색
RAG 체인 리트리버 + LCEL로 검색 → 프롬프트 → LLM → 파싱 통일

핵심은 체인 인터페이스의 통일임. 메뉴 추천이든 RAG든 결국 chain.invoke() 하나로 호출되며, 차이는 체인 앞단에 검색 단계가 붙느냐일 뿐임. 프롬프트 엔지니어링(LLM 1)에서 다룬 Few-Shot·페르소나 설계가 LangChain 위에서 그대로 코드화되고, RAG가 더해지면서 "모델이 모르는 데이터"까지 다룰 수 있는 실용 서비스로 확장됨.