Google Cloud의 Vertex AI Agent Builder로 데모를 프로덕션으로 전환하기
이 글은 Vertex AI Agent Builder를 사용해 실시간 데이터 조회, 도구 오케스트레이션, 모니터링을 통합한 AI 기반 여행 어시스턴트를 구축하고, 프로덕션 환경에서 신뢰성 있게 동작하도록 만드는 과정과 과제를 단계별로 설명합니다.
이 글은 AI 번역본입니다. 오역 가능성이 있다면 댓글로 알려주세요.
Created on September 12|Last edited on September 12
Comment
많은 사용 사례에서는 AI 에이전트 데모를 만드는 일이 비교적 간단합니다. 하지만 그 같은 데모를 프로덕션에서 신뢰성 있게 동작하게 만드는 일은 전혀 다른 이야기입니다. 유망한 프로토타입과 신뢰할 수 있고 확장 가능한 에이전트 사이의 간극이 데모와 유용한 제품을 가르는 지점입니다. 많은 팀이 통제된 환경에서는 인상적인 기능을 선보이지만, 실제 환경의 과제에 직면하면 모델이 무너지는 모습을 보곤 합니다.
이 글의 목표는 다음을 구축하는 것입니다 AI 여행 에이전트 Vertex AI Agent Builder와 함께 통합하기 W&B Weave 모니터링을 수행하고, 프로덕션 준비가 된 시스템을 배포할 때 따라오는 다양한 과제들을 해결합니다. 우리는 에이전트가 실시간 호텔 및 레스토랑 추천을 조회하고, 사용자 입력을 동적으로 처리하며, 문맥에 따라 도구 호출을 지능적으로 관리할 수 있도록 하는 데 초점을 맞출 것입니다. 또한 입력 처리, 도구 상호작용, 전반적인 에이전트 동작과 관련해 발생한 수많은 예기치 못한 문제들을 함께 살펴보겠습니다.

목차
Google Cloud IAM 권한 구성 Vertex AI Agent Builder로 에이전트 설정하기1. SerpAPI 키 발급받기2. Google Cloud Run Functions 만들기(Cloud Run UI 사용)3. OpenAPI 스키마와 도구 설명 작성 4. Vertex 대화형 에이전트 대시보드에서 “Playbook” 만들기 5. 에이전트에 사용할 LLM 구성Python에서 Vertex 에이전트에 접근하기 Weave로 비용과 사용량 추적 에이전트를 구축하고 개선하며 얻은 핵심 교훈1. 디버깅을 위해 입력과 출력을 로깅하는 것은 중요합니다.2. 에이전트 신뢰도는 튜닝이 필요합니다 3. 도구 함수는 신중하게 설계해야 합니다 4. 올바른 도구를 사용하려면 프롬프트가 중요합니다5. 엣지 케이스는 선제적으로 테스트해야 합니다6. 오류를 사용자에게 드러내면 사용성이 개선됩니다마무리 생각
Google Cloud IAM 권한 구성
시작하기 전에 Google Cloud 계정을 보유하고 시스템에 Google Cloud CLI를 설정했는지, 그리고 필요한 IAM 권한을 추가했는지 확인하세요. 설정 방법에 대한 자세한 내용은 다음을 참고하세요 이 글, 자세한 단계별 절차를 안내합니다.
위 단계에서 지정한 IAM 역할 외에, 아래에 표시된 역할들도 추가해야 합니다.

Vertex AI Agent Builder로 에이전트 설정하기
이제 에이전트를 본격적으로 작업할 준비가 되었습니다. 이를 위해 API를 설정하고, 도구를 정의한 뒤, 다단계 워크플로를 가능하게 하는 “Playbook”에 통합합니다(자세한 내용은 아래에서 다룹니다). 아래 단계들을 순서대로 설명하겠습니다:
1. SerpAPI 키 발급받기
SerpAPI는 호텔과 레스토랑 데이터를 가져오는 데 사용됩니다. 사용하려면 API 키가 필요합니다. 아직 없다면 다음에서 가입하세요 SerpAPI 그리고 키를 생성합니다. 이 키는 클라우드 함수에서 API 호출을 수행하는 데 필요합니다.
2. Google Cloud Run Functions 만들기(Cloud Run UI 사용)
호텔 및 음식점 검색을 위한 API 호출을 처리하기 위해 Cloud Run Functions Web UI Editor를 사용해 두 개�� 함수를 만듭니다. 이 프로젝트에서는 서버리스 설계, 자동 확장, 사용량 기반 과금 모델 덕분에 Google Cloud Run 함수가 유용했습니다. 또한 Cloud Run 함수는 트래픽 급증을 효율적으로 처리하고, 인프라 관리를 하지 않아도 컨테이너화된 애플리케이션을 지원합니다.
각 함수는 사용자 질의에서 파라미터를 추출한 뒤 외부 API를 호출합니다. 아래는 이에 대한 구현입니다 foodSearch:
const https = require('https');const API_KEY = "YOUR_SERPAPI_KEY";exports.foodSearch = (req, res) => {console.log("🔵 Function triggered");try {const { location, latitude, longitude, query, cuisine } = req.body;if (!query) {console.error("❌ Missing 'query' parameter:", req.body);return res.status(400).json({ error: "Missing required parameter 'query'" });}// Construct search querylet searchQuery = query;if (cuisine) {searchQuery += ` ${cuisine}`;}let searchParams;if (latitude && longitude) {console.log(`🌍 Using latitude/longitude: ${latitude}, ${longitude}`);searchParams = `ll=@${latitude},${longitude},14z`;} else if (location) {console.log(`📍 Using location string: ${location}`);searchParams = `location=${encodeURIComponent(location)}`;} else {console.error("❌ Missing location parameters (either 'location' or 'latitude/longitude' required)");return res.status(400).json({ error: "Missing location parameters. Provide 'location' or 'latitude/longitude'." });}const url = `https://serpapi.com/search?engine=google_local&q=${encodeURIComponent(searchQuery)}&${searchParams}&api_key=${API_KEY}`;console.log("🟠 Requesting:", url);https.get(url, (apiRes) => {let data = '';apiRes.on('data', (chunk) => {data += chunk;});apiRes.on('end', () => {console.log("🟢 API Response received");try {const jsonResponse = JSON.parse(data);if (!jsonResponse.local_results || jsonResponse.local_results.length === 0) {console.warn("⚠️ No food places found in API response.");return res.status(404).json({ error: "No food places found" });}const foodPlaces = jsonResponse.local_results.map(place => ({name: place.title || "Unknown",rating: place.rating || "N/A",reviews: place.reviews || 0}));console.log("✅ Sending response:", foodPlaces);res.setHeader('Content-Type', 'application/json');res.status(200).json({ food_places: foodPlaces });} catch (err) {console.error("🔴 JSON Parse Error:", err.message);res.status(500).json({ error: "Failed to parse API response", details: data });}});}).on('error', (err) => {console.error("❌ API Request Error:", err.message);res.status(500).json({ error: "Failed to fetch data from API", details: err.message });});} catch (error) {console.error("❌ Function Error:", error.message);res.status(500).json({ error: "Server error", details: error.message });}};
여기서 파이썬을 계속 쓰지 못해 죄송합니다. 파이썬 Cloud Function에서 requests 라이브러리를 사용하던 중 몇 가지 문제를 겪어, 함수를 Node.js로 변환해야 했습니다.
💡
그 foodSearch 함수는 사용자의 질의(예: “sushi”), 위치 정보(위도/경도 또는 도시 이름)를 추출하고 SerpAPI로 외부 API 요청을 보냅니다. 질의가 존재하는지 검증한 뒤 API 요청 URL을 동적으로 구성하고, Google의 로컬 검색에서 결과를 가져옵니다. 그런 다음 응답을 사용자에게 반환하기 전에 결과를 포맷합니다.
마찬가지로, 제 시작 부분을 공유하겠습니다 hotelSearch cloud 함수로, 동일한 패턴을 따르되 위치, 체크인 날짜, 체크아웃 날짜를 기준으로 호텔을 조회합니다. 이 함수의 전체 구현을 보고 싶다면, 제가 함수를 공유하겠습니다 여기.
const https = require('https');const API_KEY = "YOUR_SERPAPI_KEY";exports.hotelSearch = (req, res) => {console.log("🔵 Function triggered");try {const { location, check_in_date, check_out_date, adults } = req.body;let { currency } = req.body;if (!location || !check_in_date || !check_out_date || !adults) {console.error("❌ Missing parameters:", req.body);return res.status(400).json({ error: "Missing required parameters", received: req.body });}// Default currency to USD if not providedcurrency = currency || "USD";const url = `https://serpapi.com/search?engine=google_hotels&q=${encodeURIComponent(location)}&check_in_date=${encodeURIComponent(check_in_date)}&check_out_date=${encodeURIComponent(check_out_date)}&adults=${encodeURIComponent(adults)}¤cy=${encodeURIComponent(currency)}&api_key=${API_KEY}`;console.log("🟠 Requesting:", url);https.get(url, (apiRes) => {let data = '';apiRes.on('data', (chunk) => {data += chunk;});apiRes.on('end', () => {console.log("🟢 API Response received");try {const jsonResponse = JSON.parse(data);if (!jsonResponse.properties || jsonResponse.properties.length === 0) {console.warn("⚠️ No hotels found in API response.");return res.status(404).json({ error: "No hotels found" });}const hotels = jsonResponse.properties.map(hotel => ({name: hotel.name || "No Name",price: hotel.rate_per_night?.lowest || "N/A",currency: currency, // Include currency for claritylat: hotel.gps_coordinates?.latitude || null,lon: hotel.gps_coordinates?.longitude || null,link: hotel.link || "N/A"}));console.log("✅ Sending response:", hotels);res.setHeader('Content-Type', 'application/json');res.status(200).json({ hotels });} catch (err) {console.error("🔴 JSON Parse Error:", err.message);res.status(500).json({ error: "Failed to parse API response", details: data });}});}).on('error', (err) => {console.error("❌ API Request Error:", err.message);res.status(500).json({ error: "Failed to fetch data from API", details: err.message });});} catch (error) {console.error("❌ Function Error:", error.message);res.status(500).json({ error: "Server error", details: error.message });}};
그 hotelSearch 함수는 사용자의 위치, 체크인 및 체크아웃 날짜, 성인 수를 입력으로 받습니다. 그런 다음 SerpAPI의 호텔 검색 API 호출을 구성하여 결과를 가져오고, 이를 포맷한 뒤 사용자에게 반환합니다. 함수는 필수 매개변수가 모두 제공되었는지 확인하며, 통화 값이 제공되지 않은 경우 기본값으로 USD를 사용합니다.
3. OpenAPI 스키마와 도구 설명 작성
이제 Vertex AI Agent Builder 대시보드에서 “Conversational Agents” 섹션으로 이동하세요.

"Tools" 패널을 선택한 뒤 "Create a Tool"을 클릭하면, 에이전트가 도구를 어떻게 사용할지에 대한 세부 설정을 구성할 수 있습니다. 여기에서 에이전트가 우리 도구를 사용할 수 있도록 하는 OpenAPI 스키마를 생성합니다. 이 스키마는 각 함수와의 상호작용 방식을 정의하며, 요청 매개변수, 엔드포인트, 그리고 예상 응답을 명시합니다. 전체 스키마는 다음에서 공유하겠습니다 음식 검색기 그리고 the 호텔 검색기 에서 Github 저장소또한 유용한 자료도 함께 공유하겠습니다 문서 도구용 스키마를 만드는 방법에 대해 알아보려면
Vertex의 대화형 에이전트 대시보드에 있는 제 “Food Searcher” 도구 스크린샷입니다.

추가로 “hotel searcher” 도구의 스크린샷도 첨부합니다.

스키마와 설명을 준비했으면 Cloud Run에 함수를 배포하세요. 초기 테스트 단계에서는 인증되지 않은 함수 요청을 허용했지만, 운영 환경에서는 인증된 호출을 사용하는 것을 강력히 권장합니다. Cloud Functions를 안전하게 보호하려면 IAM Google Cloud Console에서 서비스 계정에 Cloud Run Invoker 권한을 부여해 인증이 필요하도록 구성하세요.
4. Vertex 대화형 에이전트 대시보드에서 “Playbook” 만들기
함수를 배포한 뒤 Vertex AI Agent Builder에 도구로 추가하고, 각 도구를 언제 어떻게 사용할지 규정하는 지침을 Playbook에 정의하세요.
Vertex AI에서 플레이북은 특정 시나리오에서 에이전트의 동작을 안내하는 구조화된 지침 집합입니다. 플레이북은 에이전트가 다양한 사용자 의도에 어떻게 응답할지, 언제 도구를 호출할지, 그리고 대화를 어떻게 동적으로 처리할지를 정의합니다. 이를 통해 에이전트가 질의를 관리할 때 일관된 논리를 따르도록 하고, 상호작용을 더 신뢰할 수 있고 문맥을 인식하는 방식으로 만들어 줍니다.

에이전트에 사용되는 기본 플레이북의 스크린샷입니다. 사용한 목표 프롬프트는 비교적 일반적인 편이라 여러분의 상황에 최적일 수도, 아닐 수도 있지만, 이 에이전트의 테스트에서는 잘 작동했습니다. 지침 섹션에서는 사용 가능한 도구를 어떻게 활용하길 원하는지 에이전트에게 명확히 안내합니다.
이 설정을 완료하면, 에이전트는 플레이북에 정의된 구조화된 로직을 따르면서 실시간 API 호출을 통해 여행 관련 질의에 응답할 수 있습니다.
5. 에이전트에 사용할 LLM 구성
도구와 플레이북 구성이 끝났다면, 다음 단계는 에이전트의 응답을 구동할 LLM을 설정하는 것입니다. Vertex AI Agent Builder에서 Generative AI 설정에서 생성 모델을 선택할 수 있습니다.

이 프로젝트에서는 LLM으로 Gemini 1.5 Flash(프리뷰)를 사용합니다. 이 글을 읽는 시점에는 Agent Builder에서 Gemini 2.0 Flash를 사용할 수 있을 가능성이 높지만, 본 프로젝트를 구축할 당시에는 Gemini 1.5 Flash만 사용할 수 있었습니다. 또한 입력과 출력에 대한 토큰 한도를 각각 조정할 수 있습니다. 입력 토큰 한도는 8k, 출력 토큰 한도는 512로 설정했습니다. 모델이 응답을 생성할 때 사용할 수 있는 토큰 수의 상한이므로, 이 한도를 늘리고 싶을 수 있습니다. 실제로 단어 하나는 보통 2~4개의 토큰으로 구성됩니다. 이 설정은 비교적 긴 입력 프롬프트를 처리하면서도 응답을 간결하게 유지하도록 균형을 맞춰 줍니다. 토큰 한도를 높이면 지연 시간과 비용이 증가할 수 있으므로, 특정 사용 사례에 가장 적합한 구성을 찾기 위해 테스트하는 것이 중요합니다.
온도 설정은 응답의 변동성을 제어합니다. 값이 낮을수록 모델의 결정성이 높아지고, 값이 높을수록 응답의 다양성이 커집니다. ���기서는 온도를 1로 설정해 예측 가능성과 유연성의 균형을 맞췄습니다. 즉, 에이전트가 과도하게 경직되지 않으면서도 비교적 잘 구조화된 지침을 따를 수 있습니다.
LLM 구성이 완료되면, 에이전트는 이제 플레이북에 정의된 로직을 따르면서 실시간 API 데이터를 기반으로 질의를 처리하고, 도구를 동적으로 호출하며, 응답을 생성할 준비가 됩니다.
프롬프트와 도구를 여러 번 다듬은 끝에, 주변의 호텔과 레스토랑을 꽤 잘 찾아주는 에이전트를 구축할 수 있었습니다. 아래에 제가 에이전트와 나눴던 대화의 스크린샷을 공유하겠습니다.

Python에서 Vertex 에이전트에 접근하기
이제 에이전트를 사용할 코드를 작성할 준비가 되었습니다. 사용자와 에이전트 간의 채팅 시스템을 구성하는 것과 더불어, 대화 내용과 토큰 사용량을 기록하기 위해 Weave로 로깅도 설정하겠습니다. 이를 통해 에이전트가 도구와 상호작용하는 방식은 물론, 각 상호작용에서 얼마나 많은 컨텍스트가 사용되는지 추적하고, 에이전트가 얼마나 효율적으로 동작하는지 더 잘 파악할 수 있습니다. 함수의 입력과 출력, 토큰 소모량을 로깅함으로써 오류를 잡아내고, 비용을 최적화하며, 프롬프트 전략을 개선할 수 있습니다.
우리 에이전트로 추론을 실행하는 코드입니다:
import uuidimport jsonimport weavefrom google.cloud import dialogflowcx_v3beta1 as dialogflowweave_client = weave.init("vertex-agent")# Add token cost configurationweave_client.add_cost(llm_id="gemini_flash_002",prompt_token_cost=0.00001875, # Cost per 1,000 characters when <= 128k input tokenscompletion_token_cost=0.000075 # Cost per 1,000 characters)# Dialogflow CX configurationPROJECT_ID = "your gcloud project"LOCATION_ID = "us-central1"AGENT_ID = "your agent ID"LANGUAGE_CODE = "en"SESSION_ID = str(uuid.uuid4()) # Generate a persistent session IDdef simple_token_count(text: str) -> int:"""Simple token count excluding whitespaces."""return len(text.replace(" ", ""))@weave.opdef run_dialogflow_inference(text, session_id=SESSION_ID):"""Sends a text query to Dialogflow CX and returns bot reply, tool input params, and tool output."""api_endpoint = f"{LOCATION_ID}-dialogflow.googleapis.com"client_options = {"api_endpoint": api_endpoint}session_client = dialogflow.SessionsClient(client_options=client_options)session_path = f"projects/{PROJECT_ID}/locations/{LOCATION_ID}/agents/{AGENT_ID}/sessions/{session_id}"text_input = dialogflow.TextInput(text=text)query_input = dialogflow.QueryInput(text=text_input, language_code=LANGUAGE_CODE)request = dialogflow.DetectIntentRequest(session=session_path, query_input=query_input)# Count input tokens before making the requestinput_tokens = simple_token_count(text)response = session_client.detect_intent(request=request)# Convert response to JSON for inspectionresponse_dict = json.loads(response.__class__.to_json(response))print(json.dumps(response_dict, indent=2)) # Print full response for debugging# Extract bot reply safelybot_reply = "No response from agent."qr = response_dict.get("queryResult", {})rm = qr.get("responseMessages", [])if rm and rm[0].get("text", {}).get("text"):bot_reply = rm[0]["text"]["text"][0]# Count output tokensoutput_tokens = simple_token_count(bot_reply)# Extract toolCall data from actionTracingInfotool_input_params = Nonetool_output = Noneaction_tracing_info = qr.get("generativeInfo", {}).get("actionTracingInfo", {})if "actions" in action_tracing_info:for action in action_tracing_info["actions"]:if "toolUse" in action:tool_call_data = action["toolUse"]tool_input_params = tool_call_data.get("inputActionParameters", {}).get("requestBody", {})tool_output = tool_call_data.get("outputActionParameters", {}).get("200", {})print("TOOL INPUT PARAMS:", json.dumps(tool_input_params, indent=2))print("TOOL OUTPUT:", json.dumps(tool_output, indent=2))# Adjust token counts for tool input/outputoutput_tokens += simple_token_count(str(tool_input_params))input_tokens += simple_token_count(str(tool_output))# Return response with token usage informationreturn {"bot_reply": bot_reply,"tool_input_params": tool_input_params,"tool_output": tool_output,"model": "gemini_flash_002","usage": {"input_tokens": input_tokens,"output_tokens": output_tokens,"total_tokens": input_tokens + output_tokens}}# Example chat loopif __name__ == "__main__":print("Start chatting with the agent (type 'exit' to stop):")while True:user_text = input("You: ")if user_text.lower() in ["exit", "quit"]:break# Set AGENT_ID and SESSION_ID for each call inside mainwith weave.attributes({"AGENT_ID": AGENT_ID, "SESSION_ID": SESSION_ID}):response = run_dialogflow_inference(user_text, SESSION_ID)print(f"Bot: {response['bot_reply']}")if response["tool_input_params"]:print(f"Function Input Params: {response['tool_input_params']}")if response["tool_output"]:print(f"Function Output: {response['tool_output']}")print(f"Token Usage: {response['usage']}")
이 스크립트는 사용자가 에이전트와 상호작용할 수 있는 채팅 루프를 초기화하고, 각 상호작용별 토큰 사용량을 추적합니다. 각 질의는 Dialogflow CX로 전송되며, 응답은 봇의 답변, 도구 호출, 그리고 출력으로 파싱됩니다. 도구가 사용된 경우 입력과 출력이 추출되어 표시되며, 이를 통해 에이전트가 어떻게 의사결정을 내리는지 완전한 가시성을 확보할 수 있습니다. 또한 Weave 속성을 사용해 세션과 에이전트 세부 정보를 로깅하므로, 프로덕션 환경에�� 시스템을 실행할 때 관측 가능성을 높일 수 있습니다.
이 설정을 통해 실제 상호작용을 바탕으로 에이전트를 지속적으로 평가하고 개선할 수 있으며, 기대한 대로 동작하도록 보장하는 동시에 리소스 사용량도 통제할 수 있습니다.
Weave로 비용과 사용량 추적
LLM은 실행 비용이 높을 수 있으므로, 입력·출력 토큰 사용량을 로깅하면 효율성을 분석하고 시스템을 최적화하는 데 도움이 됩니다. 하지만 가격 책정 기준으로 토큰을 사용하는 다른 많은 LLM과 달리, Gemini 1.5 모델은 문자 수를 기준으로 가격이 책정됩니다.
이 차이를 고려하기 위해 공백 문자를 제외한 문자 수를 세어 토큰 사용량을 근사하는 간단한 함수를 사용합니다. 이를 통해 각 상호작용이 얼마나 많은 처리 자원을 소모하는지 대략적으로 추정할 수 있습니다. 현재 Dialogflow CX API는 토큰 사용량을 반환하지 않으므로, 당분간은 리소스 사용량을 추적하기 위해 수동으로 사용량을 추정합니다. 이 정보는 에이전트 추론 함수의 출력에 포함된 usage 딕셔너리에 기록되며, 각 응답에 대해 입력, 출력, 총 문자 수가 로깅됩니다.

Weave 통합은 세션 세부 정보를 로깅함으로써 여기서도 핵심적인 역할을 합니다 (AGENT_ID 그리고 SESSION_ID), 시간이 지나면서 대화를 추적하는 데 도움이 됩니다. 이를 통해 문제를 더 쉽게 디버그하고, 비효율적인 도구 사용을 식별하며, 프로덕션 환경에서 에이전트가 다양한 도구와 어떻게 상호작용하는지 모니터링할 수 있습니다. 추적을 단순화하기 위해 우리는 with weave.attributes({...}) 세션 ID를 함수 인수로 전달하지 않으면서도 올바르게 로깅되도록 보장하기 위해 컨텍스트 매니저를 사용합니다. 이렇게 하면 나중에 세션 단위로 상호작용을 필터링하고 분석할 수 있어, 함수 시그니처를 수정하지 않고도 특정 대화를 쉽게 조사할 수 있습니다. 아래는 Weave에서 세션 ID로 필터링하는 방법을 보여주는 스크린샷입니다:

토큰 비용과 카운팅이 올바르게 작동하도록 하려면, 반환문에 모델 식별자와 usage 딕셔너리를 포함해야 합니다. usage 필드는 입력 토큰, 출력 토큰, 총 토큰 사용량을 추적하며, 모델 식별자는("gemini_flash_002)는 비용 계산에 필요합니다. 이 값들이 없으면 Weave는 요청을 올바른 가격 책정 모델과 연결하거나 토큰 소비량을 추정할 수 없습니다.
"model": "gemini_flash_002","usage": {"input_tokens": input_tokens,"output_tokens": output_tokens,"total_tokens": input_tokens + output_tokens}
또한 Gemini 모델은 토큰이 아니라 문자 수를 기준으로 과금하므로, 가격 정보는 Weave를 사용해 명시적으로 구성합니다. 이를 위해 Weave를 초기화하고 다음을 사용합니다. add_cost() 프롬프트와 컴플리션 모두에 대해 1,000자당 비용을 정의하기 위해:
weave_client = weave.init("vertex-agent")weave_client.add_cost(llm_id="gemini_flash_002",prompt_token_cost=0.00001875, # Cost per 1,000 characters when <= 128k input tokenscompletion_token_cost=0.000075 # Cost per 1,000 characters when <= 128k input tokens)
모델 식별자와 토큰 사용량 값이 없으면 비용 추적이 올바르게 작동하지 않아, 지출을 모니터링하고 최적화하기 어려워집니다.
에이전트를 구축하고 개선하며 얻은 핵심 교훈
신뢰할 수 있는 AI 에이전트를 구축하는 일은 단순히 도구를 통합하는 것을 넘어, 실제 환경에서 일관되고 정확하게 동작하도록 보장하는 것입니다. 테스트와 반복을 거치며 도구 오케스트레이션, 도구의 입력/출력 의존성, 그리고 모호성 처리와 같은 과제를 드러내는 몇 가지 핵심 교훈이 도출되었습니다.
1. 디버깅을 위해 입력과 출력을 로깅하는 것은 중요합니다.
AI 에이전트를 모니터링하는 작업은 전통적인 LLM 기반 애플리케이션을 추적하는 것보다 훨씬 더 복잡합니다. 여러 도구, API, 그리고 검색 시스템에 의존하는 에이전트는 잘 준비된 데모에서는 드러나지 않는 새로운 복잡성을 야기합니다. 비용, 응답 지연, 실패 양상이 규모가 커질수록 모두 확대되며, 적절한 가시성이 없으면 디버깅은 악몽이 됩니다.
가장 큰 교훈 중 하나는 함수의 입력과 출력을 로깅하는 것이 얼마나 중요한가입니다. 명확한 로깅이 없으면 에이전트가 올바른 데이터를 가져오지 못하는 이유나 특정 도구 호출이 예상대로 동작하지 않는 이유를 진단하기 어렵습니다. 초기 디버깅 단계에서 Vertex AI Agent Builder의 채팅 시뮬레이터가 매우 유용했는데, 실시간으로 정확한 도구 호출, 그 입력, 그리고 출력을 보여주었기 때문입니다. 덕분에 오류 지점을 쉽게 특정할 수 있었고, 에이전트의 도구 선택 처리 방식을 정교하게 개선할 수 있었습니다. 하지만 에이전트를 프로덕션에 배포한 이후에는 지속적인 모니터링이 그에 못지않게 중요하며, 이 지점에서 Weave가 역할을 합니다. Weave를 통합하면 배포 이후에도 가시성을 유지하기 위해 도구 사용 현황, 입력 패턴, 실패 사례에 대한 상세 로그를 수집할 수 있습니다. 이를 통해 사용자 신고나 수동 재현에만 의존하지 않고도 디버깅에 필요한 모든 데이터를 확보할 수 있습니다.
2. 에이전트 신뢰도는 튜닝이 필요합니다
프로덕션 수준의 에이전트를 구축할 때 더 미묘한 과제 중 하나는, 에이전트가 추가 정보를 요청하는 것과 현재 맥락에서 가진 정보만으로 진행하는 것 사이의 균형을 조정하는 일입니다. 에이전트가 핵심 정보를 확인하지 않은 채 자동으로 도구를 실행하면, 예를 들어 “kc”를 잘못된 위치로 해석하는 등 틀린 가정에 기반해 동작할 위험이 있습니다. 반대로 명확화 질문을 너무 많이 하면 사용자 경험이 느려지고 비효율적으로 느껴집니다. 아래는 제 에이전트가 꽤나 자명한 질문을 던진 예시입니다.

테스트를 통해, 에이전트가 이미 이 문제에 대해 일부 내장된 처리 로직을 가지고 있음이 분명해졌습니다. 다만 도구와 플레이북 프롬프트를 조정하면 추가 정보를 요청할지 여부를 판단할 때 더 조심스럽게 또는 덜 조심스럽게 동작하도록 만들 수 있습니다. 특정 매개변수에 대해 높은 수준의 확신이 필요한 함수라면, 프롬프트에서 명시적인 확인을 유도해야 합니다. 반대로 도구가 누락된 세부 정보를 합리적으로 추론할 수 있다면, 프롬프트를 조정해 사용자를 지나치게 캐묻지 않고 진행하도록 할 수 있습니다. 이러한 수준의 제어는 도구가 호출되기 전에 어느 정도의 검증을 수행할지 미세 조정할 수 있게 해 주며, 구체적인 사용 사례에 따라 에이전트가 정확성과 효율성의 균형을 맞추도록 보장합니다.
3. 도구 함수는 신중하게 설계해야 합니다
신뢰할 수 있는 도구 함수를 만든다는 것은 각 함수가 개별적으로 잘 작동하는 데서 그치지 않고, 에이전트의 더 넓은 워크플로 안에서 서로 매끄럽게 상호작용하도록 보장하는 일입니다. LLM은 함수 호출 사이에서 일종의 중개자 역할을 하기 때문에, 한 도구의 출력과 다른 도구의 입력을 어떻게 연결할지 추론해야 합니다. 이로 인해, 입력 타입이 명확히 정의되고 코드에서 출력이 명시적으로 전달되는 전통적 애플리케이션에는 없는 복잡성이 생깁니다. LLM이 자신의 추론 과정에 부합하는 방식으로 구조화된 데이터를 받지 못하면, 함수 호출이 신뢰성을 잃거나 아예 실패할 수 있습니다.
이 문제의 가장 분명한 사례 중 하나는 호텔 인근 식당을 검색할 때 나타났습니다. 초기에 음식 검색 도구는 “Kansas City, MO”처럼 범위가 넓은 위치 입력을 사용했기 때문에, 호텔의 구체적인 위치 정보가 없어 결과의 관련성이 떨어졌습니다. 또한 음식 검색 도구는 호텔 이름만으로는 정확한 결과를 반환할 만큼 견고하지도 않았습니다. 이를 해결하기 위해 호텔 검색 함수가 위도/경도 좌표를 반환하도록 수정했고, 그 결과 음식 검색기가 도시 단위의 모호한 검색에 의존하지 않고 정밀한 위치를 기준으로 추천을 좁힐 수 있게 되었습니다.
도구 출력의 구조화를 올바르게 하는 것뿐만 아니라, 반환되는 대량의 데이터를 처리하는 과제도 있습니다. 도구 검색 결과가 너무 많으면 LLM이 과부하되어 컨텍스트 윈도 문제를 일으키고, 에이전트가 관련된 인사이트를 추출하기 어려워질 수 있습니다. 반대로 반환되는 데이터가 지나치게 적으면 응답이 불완전하거나 도움이 되지 않을 수 있습니다. 가장 좋은 방법은 사용할 도구 호출 횟수, LLM의 컨텍스트 길이, 비용 제약 등을 고려해 세부 정보의 수준을 동적으로 조정하는 것입니다.
LLM이 함수 호출을 오케스트레이션하는 역할을 맡고 있기 때문에, 정교하게 작성된 프롬프트는 잘 구조화된 함수 출력만큼이나 중요합니다. 호텔 검색 함수를 수정하는 것에 더해, 음식 검색 도구의 프롬프트도 조정하여 위도와 경도 좌표가 있을 경우 이를 사용하도록 만들었고, 도시 이름만이 아니라 정밀한 위치를 기준으로 검색이 이루어지도록 보장했습니다. 이런 작지만 필수적인 개선들은 다음 함수 호출에서 도구 출력이 실제로 활용될 수 있도록 하고, 실패를 줄이며, 전반적인 정확도를 높이는 데 큰 차이를 만들어 냅니다.
함수 출력에 겉보기에는 사소해 보이는 변경만 가해도 LLM이 이후 도구를 사용하는 방식에 영향을 미쳐, 예기치 않은 실패나 잘못된 도구 선택으로 이어질 수 있습니다. LLM은 엄격한 프로그래밍 규칙을 따르기보다 응답을 동적으로 해석하기 때문에, 필드 이름을 바꾸거나 출력 형식을 약간만 수정하는 작은 변화도 에이전트가 필수 매개변수를 잘못 해석하거나 전달하지 못하게 만들 수 있습니다. 그렇기 때문에 변경 규모가 얼마나 작아 보이든, 매번 수정 후 에이전트를 철저히 테스트하는 것이 필수적입니다. 도구 출력과 프롬프트 전반의 일관성을 보장하면, 시간이 지남에 따라 에이전트의 신뢰도를 떨어뜨릴 수 있는 연쇄적 실패를 예방할 수 있습니다.
4. 올바른 도구를 사용하려면 프롬프트가 중요합니다
에이전트의 효과성은 단순히 올바른 도구를 갖추는 데서 끝나지 않습니다. 그 도구를 어떻게 사용하도록 프롬프트가 에이전트를 잘 안내하느냐에 달려 있습니다. 앞서 논의한 여러 과제들, 예를 들어 가능한 경우 음식 검색 도구가 위도와 경도 좌표를 사용하도록 보장하는 문제 등은, 기반 함수의 변경이라기보다 프롬프트 조정을 통해 대부분 해결되었습니다. 이는 도구 실행을 안내하는 데 있어 정교하게 작성된 프롬프트가 얼마나 중요한지를 잘 보여줍니다.
예를 들어:
- 우리 예시에서 오케스트레이션 프롬프트가 호텔 검색 결과에 위도와 경도를 반드시 포함해야 한다는 점을 명확히 지정하지 않으면, 에이전트가 계속해서 도시 이름에 의존하게 되어 음식점 검색의 정밀도가 떨어질 수 있습니다.
- 각 도구별 프롬프트에는 해당 도구를 언제, 어떻게 사용해야 하는지에 대한 명시적인 지침을 포함해 불필요하거나 중복된 호출을 방지해야 합니다.
프롬프트 문구를 조금만 다듬어도 도구 선택과 실행 정확도에 극적인 영향을 미칠 수 있습니다. 각 도구의 프롬프트에 입력 요구사항, 기대 출력, 그리고 다른 도구에서 결과를 어떻게 활용해야 하는지까지 명확히 규정하는 일은 함수 자체의 구현만큼이나 중요합니다.
5. 엣지 케이스는 선제적으로 테스트해야 합니다
위에서 겪은 문제들과 마찬가지로, 에이전트를 중단시키거나 부정확한 결과를 반환하게 만드는 예기치 못한 상황은 언제든 발생할 수 있습니다. 이러한 이슈는 개발 단계, 특히 통제된 입력으로 테스트할 때는 항상 명확하게 드러나지 않습니다. 실제 환경에서는 다양한 사람들이 놀랍고 예측하기 어려운 방식으로 에이전트와 상호작용합니다. 요청을 다른 방식으로 표현하거나, 불완전한 정보를 제공하거나, 에이전트가 처리하도록 설계되지 않은 약어와 축약 표현을 사용할 수 있습니다.
이로 인해, 실제 사용자와의 테스트는 다른 방식으로는 발견하기 어려운 엣지 케이스를 밝혀내는 가장 효과적인 방법 중 하나입니다. 사용자가 시스템과 자연스럽게 상호작용하는 방식을 관찰하면 프롬프트를 다듬고, 도구 실행을 개선하며, 에이전트가 실제 환경에서 제대로 활용될 수 있도록 보장하는 데 도움이 됩니다. 실제 사용 시나리오에서 에이전트를 많이 테스트할수록 실패하지 않고 예기치 않은 입력에 적응하고 대응하는 능력이 향상됩니다.
반드시 해결해야 할 주요 실패 유형 중 하나는 사용자가 구현된 도구의 범위를 벗어나는 상세하고 사실 기반의 질문을 할 때입니다. 예를 들어 사용자가 “그 호텔에 헬스장이 있나요?”라고 물었는데, 호텔 편의시설을 조회하는 전용 도구가 없다면 에이전트는 의미 있는 답변을 내놓지 못할 수 있습니다. 이를 처리하기 위해서는 스위치 문의 기본 분기와 유사한 포괄 처리용 도구를 구현해야 합니다. 이 도구는 특정 기능으로 직접 다루지 못하는 질의를 외부 API 조회, 일반 지식 검색 기능 트리거, 또는 대체 자료로의 안내와 같은 방식으로 처리하려고 시도합니다.
폴백 메커니즘이 없다면, 에이전트가 처리하도록 명시적으로 설계되지 않은 세부 사항을 물어볼 때 필연적으로 막다른 길에 부딪히게 됩니다. 이러한 공백을 선제적으로 고려해 두면, 시스템은 견고함을 유지하고 사용자가 에이전트를 도움이 되지 않거나 신뢰할 수 없다고 느끼는 상황을 피할 수 있습니다.
6. 오류를 사용자에게 드러내면 사용성이 개선됩니다
도구 호출이 실패했을 때, 사용자에게 알려야 할까요? 이상적인 해결책은 에이전트가 실패를 자동으로 처리하고 재시도하는 것이지만, 그것이 불가능하다면 사용자에게 상황을 알리고 에이전트를 수정할 수 있게 하는 편이 (제 생각에는) 처음부터 다시 시작하도록 강제하는 것보다 대개 더 낫습니다.
한 가지 접근 방식은 오류 요약기를 구현하는 것입니다. 이 구성 요소는 실패를 분석하고, 무엇이 잘못되었는지 쉬운 언어로 설명하며, 에이전트가 재시도해야 할지 또는 사용자에게 추가 설명을 요청해야 할지를 결정합니다. 이렇게 하면 에이전트가 아무 말 없이 실패하거나 모호한 오류 메시지만 반환해 사용자가 막히는 상황을 예방할 수 있습니다.
문제를 사용자가 직접 수정하게 만드는 것은 이상적이지 않지만, 처음부터 전부 다시 시작하도록 강요하는 것보다는 여전히 더 나은 경험입니다. 오류 요약기는 실행 가능한 인사이트를 쉽게 드러내도록 도와주며, 이를 통해 에이전트가 조정된 파라미터로 도구 호출을 다시 시도하거나 사용자가 불필요한 좌절 없이 더 정확한 입력을 제공할 수 있게 합니다.
마무리 생각
프로덕션 수준의 AI 에이전트를 구축하는 일은 반복적이고 점진적인 과정입니다. 가장 큰 과제는 단순히 API를 호출하는 것이 아니라, 입력과 출력이 매끄럽게 맞물리도록 보장하고, 자동화와 사용자 확인 사이의 균형을 잡으며, 프롬프트를 효과적으로 구성하는 데 있습니다.
도구 상호작용을 기록하고, 엣지 케이스를 테스트하며, 입력 검증을 다듬고, 필요할 때 실패를 드러내면 에이전트는 실제 환경에서 훨씬 더 신뢰할 수 있고 적응력 있게 됩니다.
우리 에이전트는 개념을 시연하기에는 충분히 잘 작동하지만, 완벽과는 거리가 있습니다. 실제 환경에서 정말 유용해지려면 여전히 해결해야 할 문제도 많고, 처리해야 할 엣지 케이스와 개선할 점도 많습니다. 이번 정리가 기본 데모에서 더 신뢰할 수 있는 수준으로 발전시키기 위해 무엇이 필요한지 감을 잡는 데 도움이 되었기를 바랍니다.
비슷한 작업을 하고 있거나 이런 유형의 에이전트를 더 견고하게 만드는 아이디어가 있다면, 아래 댓글로 알려 주세요.
Add a comment