Google ADK 및 Cloud Run을 사용하여 Streamlit에 RAG AI 에이전트 배포

1. 소개

이 Codelab에서는 커피숍을 위한 대화형 AI 바리스타 에이전트를 빌드합니다. Google의 오픈소스 에이전트 개발 키트 (ADK)Gemini 3.5 Flash 모델을 사용하여 검색 증강 생성 (RAG)을 구현하여 모의 메뉴 데이터 세트에 에이전트의 추천을 그라운딩합니다. 마지막으로 Streamlit 사용자 인터페이스에서 에이전트를 래핑하고 Cloud Run에 배포합니다.

실습할 내용

  • 커피 상품, 태그, 알레르기 유발 물질이 포함된 RAG 데이터 소스 (menu.json)를 만듭니다.
  • ADK LlmAgent를 사용하여 AI 에이전트를 빌드하고 Python 도구를 연결하여 메뉴 데이터를 로드합니다.
  • 대화 기록을 관리하는 Streamlit 채팅 애플리케이션으로 에이전트를 래핑합니다.
  • 소스 기반 배포를 사용하여 Streamlit 앱을 Cloud Run에 배포합니다.
  • RAG 그라운딩 및 알레르기 유발 물질 인식 테스트

아키텍처 다이어그램

필요한 항목

  • 웹브라우저(예: Chrome)
  • 결제가 사용 설정된 Google Cloud 프로젝트.
  • Python에 대한 기본 지식

이 Codelab은 초보자를 포함한 모든 수준의 개발자를 대상으로 합니다.

예상 비용: 미화 1달러 미만

2. 시작하기 전에

Google Cloud 프로젝트 만들기

  1. Google Cloud 콘솔에서 Google Cloud 프로젝트를 선택하거나 만듭니다.
  2. Cloud 프로젝트에 결제가 사용 설정되어 있는지 확인합니다.

Cloud Shell 시작

  1. Google Cloud 콘솔 상단에서 Cloud Shell 활성화를 클릭합니다.

Cloud Shell 활성화

  1. 인증을 확인합니다.

Cloud Shell 승인

  gcloud auth list
  1. 활성 프로젝트가 설정되어 있는지 확인합니다.
  gcloud config get project

표시된 프로젝트 ID가 올바르지 않거나 설정되지 않은 경우 다음을 실행합니다.

  gcloud config set project <YOUR_PROJECT_ID>

API 사용 설정

다음 명령어를 실행하여 필요한 모든 API를 사용 설정합니다.

gcloud services enable \
 run.googleapis.com \
 aiplatform.googleapis.com \
 cloudbuild.googleapis.com

3. 프로젝트 설정

이 단계에서는 프로젝트 환경 변수를 초기화하고 프로젝트의 작업 디렉터리를 만듭니다.

  1. 활성 Cloud Shell 세션에서 다음 프로젝트 환경 변수를 초기화합니다.
  export PROJECT_ID=$(gcloud config get-value project)

참고: 가장 가까운 리전 사용

가장 가까운 리전을 찾아 다음 명령어에서 insert-region-here로 바꿉니다.

  export REGION=[insert-region-here]
  1. coffee-barista-agent라는 새 프로젝트 디렉터리를 만들고 이 디렉터리로 변경합니다.
  mkdir coffee-barista-agent && cd coffee-barista-agent

4. 모의 메뉴 데이터 소스 만들기

AI 바리스타가 존재하지 않는 항목을 환각하지 않도록 하려면 로컬 메뉴 데이터 세트를 만들어야 합니다. 에이전트는 런타임에 맞춤 도구를 통해 이 파일을 읽습니다.

  1. Cloud Shell 편집기에서 menu.json를 만들고 엽니다.
  cloudshell edit menu.json
  1. 다음 JSON 콘텐츠를 편집기에 붙여넣고 파일을 저장합니다.
[
  {
    "name": "Espresso Solo",
    "description": "A single shot of rich, bold espresso.",
    "price": 2.50,
    "tags": ["strong", "hot", "dairy-free", "sugar-free"],
    "allergens": []
  },
  {
    "name": "Oat Milk Honey Latte",
    "description": "Creamy steamed oat milk with espresso and a touch of honey.",
    "price": 5.00,
    "tags": ["sweet", "hot", "dairy-free"],
    "allergens": []
  },
  {
    "name": "Cold Brew Coffee",
    "description": "Smooth, slow-steeped cold brew served over ice.",
    "price": 4.00,
    "tags": ["strong", "cold", "dairy-free", "sugar-free"],
    "allergens": []
  },
  {
    "name": "Seasonal Pumpkin Latte",
    "description": "Spiced pumpkin sauce, espresso, and steamed milk, topped with whipped cream.",
    "price": 5.50,
    "tags": ["sweet", "hot", "seasonal"],
    "allergens": ["dairy"]
  },
  {
    "name": "Classic Croissant",
    "description": "Flaky, buttery traditional French pastry.",
    "price": 3.50,
    "tags": ["bakery", "savory"],
    "allergens": ["wheat", "dairy"]
  },
  {
    "name": "Vegan Blueberry Muffin",
    "description": "Soft, sweet muffin packed with real blueberries, entirely plant-based.",
    "price": 3.75,
    "tags": ["bakery", "sweet", "dairy-free", "vegan"],
    "allergens": ["wheat"]
  },
  {
    "name": "Nitro Cold Brew",
    "description": "Cold brew infused with nitrogen for a super smooth, creamy head.",
    "price": 4.50,
    "tags": ["strong", "cold", "dairy-free", "sugar-free"],
    "allergens": []
  },
  {
    "name": "Iced Caramel Macchiato",
    "description": "Chilled milk and vanilla syrup marked with espresso and caramel drizzle.",
    "price": 5.25,
    "tags": ["sweet", "cold"],
    "allergens": ["dairy"]
  }
]
  1. JSON 파일의 형식이 올바른지 확인합니다.
  cat menu.json | python3 -m json.tool > /dev/null && echo "Valid JSON!"

💬 토론: 로컬 JSON과 라이브 데이터베이스

실시간 데이터베이스 대신 간단한 로컬 menu.json 파일을 사용하는 이유는 무엇인가요?

빠른 튜토리얼이나 프로토타입의 경우 로컬 JSON 파일을 사용하면 초기 데이터베이스 설정 시간과 복잡성을 없앨 수 있습니다. 하지만 실제 엔터프라이즈 프로덕션 애플리케이션에서는 에이전트를 Cloud Firestore, AlloyDB, Cloud SQL과 같은 관리형 데이터베이스에 연결합니다.

실시간 데이터베이스를 사용하면 커피숍 관리자가 컨테이너 이미지를 다시 빌드하거나 애플리케이션 코드를 재배포하지 않고도 시즌 상품을 추가하거나, 가격을 업데이트하거나, 알레르기 유발 물질 태그를 동적으로 조정할 수 있습니다. Codelab의 뒷부분에서 선택사항으로 라이브 데이터베이스를 사용합니다.

5. ADK 에이전트 빌드

이제 필요한 패키지를 설치하고 핵심 ADK 에이전트 로직을 빌드합니다. get_menu() 도구를 정의하고 LlmAgent에 전달합니다.

  1. Cloud Shell 편집기에서 requirements.txt를 만들고 엽니다.
  cloudshell edit requirements.txt
  1. 다음 종속 항목을 편집기에 붙여넣고 파일을 저장합니다.
google-adk==2.2.0
streamlit==1.58.0
  1. Cloud Shell 편집기에서 agent.py를 만들고 엽니다.
  cloudshell edit agent.py
  1. 다음 코드를 agent.py에 붙여넣습니다.
# agent.py
import json

from google.adk.agents import LlmAgent

# [START get_menu]
def get_menu() -> str:
    """Retrieves the coffee shop menu from menu.json.

    Returns:
        str: A JSON string representing the list of menu items.
    """
    try:
        with open("menu.json", "r") as f:
            menu_data = json.load(f)
            return json.dumps(menu_data)
    except Exception as e:
        return json.dumps({"error": f"Could not retrieve menu: {str(e)}"})
# [END get_menu]

# Create the barista agent
barista_agent = LlmAgent(
    name="barista_agent",
    model="gemini-3.5-flash",
    instruction="""You are a friendly barista at ☕ Coffee Shop.
Your job is to recommend drinks and pastries to customers based on their preferences.

Rules you MUST follow:
1.  You must recommend items ONLY from the menu returned by get_menu().
2.  Do NOT recommend or suggest any item that is not present in the menu.
3.  If a user's preference is vague or unclear, ask exactly ONE friendly clarifying question to narrow down what they want (e.g., cold or hot, sweet or strong, coffee or pastry).
4.  Be warm and welcoming, but remain professional.
5.  Ground your recommendations in the actual tags, descriptions, and allergens listed in the menu (e.g., if a user is dairy-free, recommend ONLY items tagged 'dairy-free' or with no dairy allergens).
""",
    tools=[get_menu]
)

from google.adk.apps import App

# Define the App object
app = App(
    name="coffee_barista_app",
    root_agent=barista_agent
)
  1. Cloud Shell 편집기에서 app.py를 만들고 엽니다.
  cloudshell edit app.py
  1. 다음 코드를 app.py에 붙여넣습니다.
# app.py
import streamlit as st
import json

# Set page config for a premium look
st.set_page_config(
    page_title="☕ Coffee Shop - Barista Bot",
    page_icon="☕",
    layout="wide",
    initial_sidebar_state="expanded"
)

# Custom CSS to make the header sticky (adapts to light/dark themes)
st.markdown("""
<style>
    div[data-testid="element-container"]:has(.header-container),
    div.element-container:has(.header-container) {
        position: sticky;
        top: 2.875rem;
        z-index: 999;
        background-color: transparent;
        padding-bottom: 10px;
    }
</style>
""", unsafe_allow_html=True)

# App Header (using inline styles for the permanent coffee theme look)
st.markdown("""
<div class="header-container" style="text-align: center; padding: 20px; background: linear-gradient(135deg, #8B5E3C, #6F4E37); color: white; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
    <h1 style="margin: 0; font-size: 2.5rem; font-weight: 700; color: white;">☕ ☕ Coffee Shop</h1>
    <p style="margin: 5px 0 0 0; font-size: 1.1rem; opacity: 0.9; color: white;">Your friendly AI Barista is ready to help you find the perfect drink or pastry!</p>
</div>
""", unsafe_allow_html=True)

# Load Menu for the sidebar
# [START load_menu]
try:
    with open("menu.json", "r") as f:
        menu_items = json.load(f)
except Exception as e:
    st.error(f"Error loading menu: {e}")
    menu_items = []
# [END load_menu]

# Sidebar Menu & Configuration
with st.sidebar:
    st.markdown("## ☕ Coffee Shop Menu")
    st.markdown("Explore our offerings and ask the barista for recommendations.")
    st.markdown("---")

    for item in menu_items:
        with st.container(border=True):
            st.markdown(f"**{item['name']}**  •  **${item['price']:.2f}**")
            st.caption(item['description'])

            # Tags & Allergens as native badges
            tags = " ".join([f"`{t}`" for t in item.get("tags", [])])
            if tags:
                st.markdown(tags)

            allergens = ", ".join(item.get("allergens", []))
            if allergens:
                st.markdown(f"⚠️ *Allergens: {allergens}*")

# Chat Interface
if "session_id" not in st.session_state:
    import uuid
    st.session_state.session_id = str(uuid.uuid4())

if "runner" not in st.session_state:
    from google.adk.runners import InMemoryRunner
    from agent import app
    st.session_state.runner = InMemoryRunner(app=app)

if "messages" not in st.session_state:
    st.session_state.messages = [
        {"role": "assistant", "content": "Welcome to ☕ Coffee Shop! What can I get started for you today?"}
    ]

# Display existing messages
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

# User Input
if prompt := st.chat_input("Ask for recommendations (e.g., 'What dairy-free pastries do you have?')"):
    # Display user message
    with st.chat_message("user"):
        st.markdown(prompt)
    st.session_state.messages.append({"role": "user", "content": prompt})

    # Generate response
    with st.chat_message("assistant"):
        try:
            import asyncio

            # Run the ADK runner asynchronously using asyncio.run
            async def fetch_response():
                return await st.session_state.runner.run_debug(
                    prompt,
                    session_id=st.session_state.session_id
                )

            res_events = asyncio.run(fetch_response())

            response_text = "".join([
                part.text
                for event in res_events
                if event.content and event.content.parts
                for part in event.content.parts
                if part.text
            ])

            st.markdown(response_text)
            st.session_state.messages.append({"role": "assistant", "content": response_text})
        except Exception as e:
            st.error(f"Apologies, I ran into an error: {e}")

💬 토론: 모델 절충점 및 검색 토큰 효율성

전체 메뉴 텍스트를 에이전트의 시스템 요청 사항에 붙여넣는 대신 함수 도구를 호출하여 메뉴를 가져오는 이유는 무엇인가요?

토큰 경제! 프롬프트에 8개의 항목을 넣는 것은 저렴하지만, 커피숍이 맞춤 재료를 포함한 500개의 항목으로 확장되면 어떻게 될까요? 대규모 데이터 세트를 시스템 프롬프트에 직접 붙여넣으면 프롬프트 토큰 수가 늘어나 모든 단일 쿼리에서 트랜잭션 비용과 API 응답 지연 시간이 증가합니다.

ADK 도구를 사용하면 에이전트는 필요한 경우에만 메뉴를 읽도록 동적으로 요청합니다. LLM은 관련 메뉴 데이터만 컨텍스트로 수신하여 프롬프트 토큰 크기를 최소화합니다.

💬 토론: 메모리 상태 및 프로덕션 저장소

사용자가 브라우저 탭을 닫을 때 Streamlit의 st.session_state 내에 저장된 채팅 기록이 유지되나요?

아닙니다. st.session_state는 완전한 인메모리이며 활성 브라우저 연결에 고유합니다. 사용자가 페이지를 새로고침하거나 탭을 닫으면 바리스타와의 대화 기록이 손실됩니다.

프로덕션 애플리케이션의 경우 ADK 러너를 Cloud Firestore 또는 Redis와 같은 영구 스토리지 백엔드에 연결합니다. ADK는 페이지 새로고침과 기기 간에 채팅 기록을 간단하게 저장하고 재개할 수 있는 기본 서비스 추상화 (예: SessionService)를 제공합니다.

6. Cloud Run에 에이전트 배포

Cloud Run의 기본 제공 빌드팩을 사용하여 소스에서 직접 Streamlit 애플리케이션을 배포합니다. 최소 권한의 원칙을 따르려면 기본 Compute Engine 서비스 계정을 사용하는 대신 전용 커스텀 서비스 계정을 사용하여 만들고 배포합니다.

  1. 전용 서비스 계정을 만듭니다.
  gcloud iam service-accounts create barista-agent-sa \
    --description="Service account for Coffee Barista ADK agent on Cloud Run" \
    --display-name="Barista Agent Service Account"
  1. 새 서비스 계정에 Gemini Enterprise Agent Platform 사용자 역할 (roles/aiplatform.user)을 부여합니다.
  gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member="serviceAccount:barista-agent-sa@$PROJECT_ID.iam.gserviceaccount.com" \
    --role="roles/aiplatform.user"
  1. gcloud run deploy를 사용하여 서비스를 배포하고 --service-account 플래그를 통해 새 서비스 계정 이메일을 전달합니다.
gcloud run deploy coffee-barista \
  --source . \
  --region $REGION \
  --allow-unauthenticated \
  --labels dev-tutorial=codelab-streamlit-rag-adk \
  --command "/cnb/lifecycle/launcher" \
  --args "sh,-c,python3 -m streamlit run app.py --server.port=\$PORT --server.address=0.0.0.0 --server.enableCORS=false --server.enableXsrfProtection=false" \
  --service-account "barista-agent-sa@$PROJECT_ID.iam.gserviceaccount.com" \
  --set-env-vars GOOGLE_GENAI_USE_VERTEXAI=TRUE,GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GOOGLE_CLOUD_LOCATION=global
  1. 배포가 완료되면 명령어 출력에서 서비스 URL을 찾습니다.

💬 토론: 컨테이너와 소스 배포 및 IAM 보안

Dockerfile 또는 Procfile을 만들지 않고 gcloud run deploy --source를 사용하여 Cloud Run에 배포했습니다. Cloud Run은 Python 앱을 컴파일하고 실행하는 방법을 어떻게 파악했을까요?

Cloud Run은 백그라운드에서 빌드팩을 사용하여 저장소를 분석합니다. 엔진은 requirements.txt 및 Python 소스 파일의 존재를 감지하면 Python 런타임 컨테이너를 자동으로 컴파일하고 패키징합니다.

맞춤 Dockerfile를 작성하면 컨테이너의 시스템 패키지와 기본 레이어를 완전히 제어할 수 있습니다. Procfile는 컨테이너를 완전히 구성하지 않고 시작 명령어를 선언하는 더 간단한 방법입니다. 하지만 빠른 배포를 위해서는 소스 (--source)에서 배포하는 것이 매우 효율적입니다.

기본 Compute Engine 서비스 계정을 사용하는 대신 커스텀 서비스 계정 barista-agent-sa를 만드는 추가 단계를 거친 이유는 무엇인가요?

안전이 최우선입니다. 기본 Compute Engine 서비스 계정에는 기본적으로 매우 광범위한 편집자 권한이 있습니다. 기본 서비스 계정으로 Cloud Run 컨테이너를 실행하면 앱에 보안 버그가 있는 경우 공격자가 Google Cloud 프로젝트의 다른 리소스를 읽거나 쓰거나 삭제할 수 있습니다.

전용 서비스 계정을 만들고 roles/aiplatform.user 역할만 할당하면 최소 권한 원칙을 따를 수 있습니다. 앱에는 Gemini를 호출하는 데 필요한 액세스 권한만 있고 그 이상은 없습니다.

7. RAG 동작 테스트

웹브라우저에서 Cloud Run 서비스 URL을 열고 AI 바리스타에게 질문하여 그라운딩 및 안전 제약 조건을 테스트합니다.

  1. 메뉴 내 요청: '강하고 따뜻한 음료를 추천해 줘'라고 요청합니다. 예상: 에이전트가 에스프레소를 추천합니다.
  2. 메뉴에 없는 항목 묻기: '말차 프라푸치노 있나요?'라고 묻습니다. 예상 대답: 에이전트가 정중하게 거절하며 메뉴에 없다고 설명합니다.
  3. 알레르기 유발 물질을 고려한 요청: 질문: '유당 불내증이 있는데 뭘 먹을 수 있어?'예상: 상담사가 유제품이 없는 메뉴 항목 (예: 오트밀 라떼, 에스프레소, 콜드브루)만 추천합니다. 카푸치노나 크루아상은 추천하지 않습니다.

RAG 동작 테스트

8. 선택사항: 벡터 검색을 사용하여 Firestore에서 에이전트 그라운딩

프로덕션 시나리오에서는 메뉴를 변경하려면 컨테이너 이미지를 다시 빌드하고 Cloud Run 서비스를 다시 배포해야 하므로 로컬 menu.json 파일에 메뉴 항목을 저장하는 것이 이상적이지 않습니다.

애플리케이션을 동적이고 확장 가능하게 만들려면 메뉴 데이터를 Cloud Firestore로 이전하고 벡터 검색을 사용하여 시맨틱 유사성을 기반으로 가장 관련성이 높은 메뉴 항목만 검색하면 됩니다.

벡터 검색을 사용하여 Firestore 통합

1. Firestore API 사용 설정 및 데이터베이스 초기화

다음 명령어를 실행하여 Firestore API를 사용 설정하고 Native 모드에서 coffee-menu라는 Firestore 데이터베이스를 만듭니다.

gcloud services enable firestore.googleapis.com

gcloud firestore databases create --database="coffee-menu" --location=$REGION

참고: API 사용 설정이 전파되는 데 1~2분 정도 걸릴 수 있습니다. 데이터베이스 생성 명령어에서 API [firestore.googleapis.com] not enabled on project... Would you like to enable and retry? 메시지가 표시되면 Y를 입력하여 계속 진행하거나 1분 정도 기다린 후 명령어를 다시 실행합니다.

2. 메뉴 데이터로 Firestore 시드

menu.json 파일의 메뉴 항목으로 Firestore 데이터베이스를 빠르게 시드하려면 Cloud Shell에서 Python 스크립트를 로컬로 실행하면 됩니다.

  1. Cloud Shell에 Firestore 및 GenAI 클라이언트 라이브러리를 로컬로 설치하여 시드 스크립트를 실행합니다.
pip3 install google-cloud-firestore==2.27.0 google-genai==2.11.0
  1. 시딩 스크립트 seed.py를 만듭니다.
cloudshell edit seed.py
  1. 다음 코드를 seed.py에 붙여넣습니다.
# seed.py
import json
import os
from google import genai
from google.cloud import firestore
from google.cloud.firestore_v1.vector import Vector

db = firestore.Client(database="coffee-menu")
client = genai.Client(
   vertexai=True,
   project=os.environ.get("PROJECT_ID"),
   location=os.environ.get("REGION", "us-central1")
)

with open("menu.json", "r") as f:
   menu_items = json.load(f)

for item in menu_items:
   # Use the name as the document ID
   doc_id = item["name"].lower().replace(" ", "-")

   # Generate text embedding using Vertex AI text-embedding-004 model
   text_to_embed = f"{item['name']}: {item['description']}"
   response = client.models.embed_content(
       model="text-embedding-004",
       contents=text_to_embed,
   )
   embedding = response.embeddings[0].values

   # Add embedding vector to the menu item data
   item["embedding"] = Vector(embedding)

   db.collection("menu").document(doc_id).set(item)

print("Firestore menu collection seeded with vector embeddings successfully!")
  1. 스크립트를 실행합니다.
python3 seed.py

3. Firestore 벡터 색인 만들기

메뉴 항목에 대해 벡터 검색을 수행하려면 Firestore 데이터베이스의 embedding 필드에 복합 벡터 색인을 만들어야 합니다.

Cloud Shell 터미널에서 다음 명령어를 실행합니다.

gcloud firestore indexes composite create \
 --collection-group=menu \
 --query-scope=COLLECTION \
 --database="coffee-menu" \
 --field-config=field-path=embedding,vector-config='{"dimension":"768", "flat": "{}"}'

참고: Firestore 색인 생성은 백그라운드에서 실행되며 완료하는 데 몇 분 정도 걸릴 수 있습니다. 색인이 빌드되는 동안 Codelab의 다음 단계를 진행할 수 있습니다.

4. 서비스 계정에 Firestore 액세스 권한 부여

Cloud Run 서비스가 Firestore를 쿼리하려면 서비스 계정에 Cloud Datastore 사용자 (roles/datastore.user) 역할을 부여해야 합니다.

gcloud projects add-iam-policy-binding $PROJECT_ID \
 --member="serviceAccount:barista-agent-sa@$PROJECT_ID.iam.gserviceaccount.com" \
 --role="roles/datastore.user"

참고: 네이티브 모드에서 Cloud Firestore를 사용하지만 Google Cloud는 통합 Cloud Datastore IAM 역할 (roles/datastore.viewer 또는 roles/datastore.user)을 사용하여 액세스 제어를 관리합니다.

5. 코드 업데이트

이제 menu.json에서 읽는 대신 Firestore에서 메뉴를 가져오도록 코드를 업데이트합니다.

  1. Cloud Shell 편집기에서 requirements.txt를 엽니다.
cloudshell edit requirements.txt
  1. 파일 끝에 Firestore 및 GenAI 클라이언트 라이브러리를 추가하고 저장합니다.
google-cloud-firestore==2.27.0
google-genai==2.11.0
  1. Cloud Shell 편집기에서 agent.py를 엽니다.
cloudshell edit agent.py
  1. agent.py에서 # [START get_menu] 블록을 찾아 # [START get_menu]부터 # [END get_menu]까지 전체를 다음 Firestore 구현으로 바꿉니다.
# [START get_menu]
from google import genai
from google.cloud import firestore
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
from google.cloud.firestore_v1.vector import Vector

def get_menu(query: str) -> str:
   """Retrieves coffee shop menu items matching the user's query.

   Args:
       query: The search query or preference to find matching menu items.

   Returns:
       str: A JSON string representing the list of top matching menu items.
   """
   try:
       # Initialize clients
       db = firestore.Client(database="coffee-menu")
       client = genai.Client()

       # Generate embedding for the search query
       response = client.models.embed_content(
           model="text-embedding-004",
           contents=query,
       )
       query_vector = response.embeddings[0].values

       # Search the Firestore database using Vector Search
       results = db.collection("menu").find_nearest(
           vector_field="embedding",
           query_vector=Vector(query_vector),
           distance_measure=DistanceMeasure.COSINE,
           limit=3,
       ).stream()

       menu_data = []
       for doc in results:
           item = doc.to_dict()
           # Remove embedding field to save tokens
           item.pop("embedding", None)
           menu_data.append(item)

       return json.dumps(menu_data)
   except Exception as e:
       return json.dumps({"error": f"Could not retrieve menu: {str(e)}"})
# [END get_menu]
  1. Cloud Shell 편집기에서 app.py를 엽니다.
cloudshell edit app.py
  1. app.py에서 # [START load_menu] 블록을 찾아 # [START load_menu]부터 # [END load_menu]까지 다음 Firestore 로드 로직으로 완전히 바꿉니다.
# [START load_menu]
from google.cloud import firestore

try:
   db = firestore.Client(database="coffee-menu")
   docs = db.collection("menu").stream()
   menu_items = []
   for doc in docs:
       item = doc.to_dict()
       item.pop("embedding", None)
       menu_items.append(item)
except Exception as e:
   st.error(f"Error loading menu from Firestore: {e}")
   menu_items = []
# [END load_menu]

6. Cloud Run에 다시 배포

업데이트된 애플리케이션을 배포합니다.

gcloud run deploy coffee-barista \
 --source . \
 --region $REGION \
 --allow-unauthenticated \
 --command "/cnb/lifecycle/launcher" \
 --args "sh,-c,python3 -m streamlit run app.py --server.port=\$PORT --server.address=0.0.0.0 --server.enableCORS=false --server.enableXsrfProtection=false" \
 --service-account "barista-agent-sa@$PROJECT_ID.iam.gserviceaccount.com" \
 --set-env-vars GOOGLE_GENAI_USE_VERTEXAI=TRUE,GOOGLE_CLOUD_PROJECT=$PROJECT_ID,GOOGLE_CLOUD_LOCATION=global

7. Firestore 통합 확인

Firestore에 대한 에이전트의 연결을 테스트하려면 Firestore에 새 메뉴 항목을 직접 추가하고 에이전트가 이를 추천하는지 확인합니다.

  1. Cloud Shell에서 다음 명령어를 실행하여 Python을 사용하여 Firestore의 menu 컬렉션에 새 문서를 작성합니다.
python3 -c "
import os
from google import genai
from google.cloud import firestore
from google.cloud.firestore_v1.vector import Vector

db = firestore.Client(database='coffee-menu')
client = genai.Client(
   vertexai=True,
   project=os.environ.get('PROJECT_ID'),
   location=os.environ.get('REGION', 'us-central1')
)

name = 'Matcha Green Tea Latte'
desc = 'Creamy steamed milk infused with premium Japanese matcha powder.'
res = client.models.embed_content(
   model='text-embedding-004',
   contents=f'{name}: {desc}'
)
embedding = res.embeddings[0].values

db.collection('menu').document('matcha-latte').set({
   'name': name,
   'description': desc,
   'price': 5.50,
   'tags': ['sweet', 'hot', 'dairy-free'],
   'allergens': [],
   'embedding': Vector(embedding)
})
print('Successfully added Matcha Latte with vector embeddings!')
"
  1. 브라우저에서 Streamlit 앱을 새로고침하여 채팅 세션을 지우고 새 데이터베이스 상태를 로드합니다.
  2. 다음 사항에 유의하세요.
    • Matcha Green Tea Latte가 사이드바 메뉴에 자동으로 표시됩니다.
    • 챗봇에 "말차 음료가 있나요?"라고 질문합니다.
    • 상담사는 방금 추가한 설명과 가격으로 새로운 Matcha Green Tea Latte를 추천해야 합니다. 이를 통해 에이전트가 라이브 Firestore 데이터베이스에서 직접 쿼리 기반임을 확인할 수 있습니다.

9. 삭제

Google Cloud 결제 계정에 지속적으로 요금이 청구되지 않도록 하려면 배포된 Cloud Run 서비스와 커스텀 서비스 계정을 삭제하세요.

Cloud Run 서비스를 삭제합니다.

gcloud run services delete coffee-barista --region $REGION --quiet

커스텀 서비스 계정을 삭제합니다.

gcloud iam service-accounts delete barista-agent-sa@$PROJECT_ID.iam.gserviceaccount.com --quiet

(선택사항) Firestore 데이터베이스를 삭제합니다 (만든 경우).

gcloud firestore databases delete --database="coffee-menu" --quiet

선택사항: 전체 프로젝트를 삭제합니다. ⚠️이 실습을 위해 전용 프로젝트를 만든 경우에만 이 단계를 따르세요.

gcloud projects delete $PROJECT_ID

10. 축하합니다

수고하셨습니다 Google의 ADK와 Cloud Run을 사용하여 검색 증강 생성 (RAG) AI 바리스타 에이전트를 빌드하고 배포했습니다.

학습한 내용

  • Python으로 간단한 RAG 도구를 구성합니다.
  • ADK LlmAgentInMemoryRunner 활용
  • Streamlit에서 상태 저장 채팅 환경을 만듭니다.
  • 소스 기반 빌드를 사용하여 Streamlit을 Cloud Run에 배포합니다.

참조 문서