이 글은 AI 에이전트(Agent) 개발 시 필수적인 함수호출 방법을 오픈소스를 이용해 구현해 본다. 이를 위해, Gemma3(젬마) LLM(Large Language Model) 기반 Ollama 활용 Function Call(펑션콜) 실습 내용을 소개하고 실행 결과를 확인한다. 아울러, 이런 함수호출 방식의 한계점을 개선하기 위한 솔류션을 나눔한다. 이 실습의 결과는 다음과 같다.
이 글은 다음 내용을 포함한다.
- AI 에이전트 구현을 위한 함수 호출 방법
- Ollama 를 통한 Gemma3 사용법
- 채팅 형식 프롬프트 및 메모리 사용법
- Gradio 기반 웹 앱 개발
- Function call 의 한계와 솔류션
AI 에이전트 내부 Function call 메커니즘(Akriti, 2025)
이 글의 구현 코드는 다음 링크에서 확인할 수 있다.
Gemma 3는 구글이 개발해 2025년 3월 10일에 출시한 LLM으로, 차세대 경량 오픈 멀티모달 AI 모델로, 텍스트와 이미지를 동시에 처리할 수 있는 기능을 갖추고 있다. 이 모델은 다양한 크기와 사양으로 제공되어 단일 GPU 또는 TPU 환경에서도 실행 가능하다.
Gemma 3는 1B, 4B, 12B, 27B의 네 가지 모델 크기로 제공되며, 각각 10억, 40억, 120억, 270억 개의 파라미터를 갖추고 있다. 1B 모델은 텍스트 전용으로 32K 토큰의 입력 컨텍스트를 지원하고, 4B, 12B, 27B 모델은 멀티모달 기능을 지원하며 128K 토큰의 입력 컨텍스트를 처리할 수 있다. 이는 이전 Gemma 모델보다 16배 확장된 크기로, 훨씬 더 많은 양의 정보를 한 번에 처리할 수 있게 해준다.
이 모델은 텍스트와 이미지 데이터를 동시에 처리하고 이해하는 멀티모달 기능을 제공한다. 이미지 해석, 객체 인식, 시각적 질의응답 등 다양한 작업을 수행할 수 있으며, 텍스트 기반 작업에 시각적 정보를 효과적으로 활용할 수 있도록 지원한다.
Welcome Gemma 3: Google's all new multimodal, multilingual, long context open LLM
Gemma 3는 140개 이상의 언어를 지원하여 전 세계 다양한 언어 사용자를 대상으로 하는 AI 애플리케이션 개발에 매우 유리하다. 사용자는 자신의 모국어로 Gemma 3와 상호작용할 수 있으며, 다국어 기반의 텍스트 분석 및 생성 작업도 효율적으로 수행할 수 있다.
이 모델은 다양한 작업 수행 능력을 갖추고 있다. 질문 답변, 텍스트 요약, 논리적 추론, 창의적인 텍스트 형식 생성(시, 스크립트, 코드, 마케팅 문구, 이메일 초안 등), 이미지 데이터 분석 및 추출 등 광범위한 자연어 처리 및 컴퓨터 비전 관련 작업을 수행할 수 있다. 또한, 함수 호출 및 구조화된 출력을 지원하여 개발자들이 특정 작업을 자동화하고 에이전트 기반의 경험을 구축하는 데 도움을 준다.
Gemma 3는 다양한 도구 및 프레임워크와 원활하게 통합된다. Hugging Face Transformers, Ollama, JAX, Keras, PyTorch, Google AI Edge, UnSloth, vLLM, Gemma.cpp 등 다양한 개발 도구 및 프레임워크와 호환되어 개발자들이 자신이 익숙한 환경에서 Gemma 3를 쉽게 활용하고 실험할 수 있다.
이 모델은 다양한 벤치마크 테스트에서 동급 모델 대비 최첨단 성능을 입증했다. 특히, Chatbot Arena Elo Score에서 1338점을 기록하며, 여러 오픈 소스 및 상용 모델보다 높은 성능을 보였다.
Gemma 3는 오픈 모델로, 개방형 가중치를 제공하여 사용자가 자유롭게 조정하고 배포할 수 있다. Kaggle과 Hugging Face에서 다운로드 가능하며, Creative Commons 및 Apache 2.0 라이선스를 따름으로써, 개발자와 연구자에게 VLM 기술에 대한 접근성을 높여준다.
개발 환경
개발 환경은 다음과 같다. 미리 설치, 가입한다.
설치되어 있다면, 다음 명령을 터미널에서 실행한다.
ollama pull gemma3:4b
gemma3:4b GPU VRAM 소모량
이제 다음과 같이 모델을 실행해 볼 수 있다.
참고로, GPU VRAM 등을 고려해 더 성능이 좋은 파라메터수 많은 대형 모델을 사용할 수도 있다.
처리 프로세스
이 실습 프로그램의 프로세스는 다음과 같다.
Gradio 앱이 시작되면, 사용자의 입력이 발생하고 이 입력은 process_message 함수에 전달된다. 이 함수는 사용자의 메시지를 chat_history에 추가하여 대화 기록을 저장한다. 이후 모델에게 전달할 대화 문맥을 구성하기 위해 messages 리스트가 생성된다.
그 다음 단계에서는 ollama.chat 함수를 통해 언어 모델에게 응답을 요청하게 되며, 이 응답 내에 함수 호출이 포함되어 있는지를 확인한다. 만약 응답에 함수 호출이 포함되어 있다면, 이를 parse_function_call 함수를 통해 파싱한다.
파싱된 함수가 google_search라면, 모델이 검색을 원한다고 판단하여 검색 쿼리를 추출하고 검색 수행 예정임을 사용자에게 안내하는 메시지를 추가한다. 이후 실제로 google_search 함수를 실행하여 외부 검색을 수행한다.
검색 결과는 다시 chat_history에 저장되며, 이 결과를 바탕으로 언어 모델에게 재질문을 하여 더 정확하고 완성된 응답을 유도한다. 모델이 생성한 최종 응답은 chat_history에 마지막으로 추가되고, 이 전체 대화 기록이 사용자에게 반환된다.
이 구조는 사용자의 질의에 따라 외부 정보까지 능동적으로 검색하고 반영할 수 있는 LLM 기반 AI 에이전트의 대표적인 흐름을 보여준다.
다음은 이 순서도를 보여준다.
구현하기
터미널에서 다음 라이브러리를 설치한다.
pip install langchain-core langchain-openai gradio ollama requests python-dotenv pydantic
새로운 파이썬 파일(
코드 참고)을 생성한 후, 우선, 필요한 라이브러리를 임포트한다.
import gradio as gr
import ollama
import requests, json, os
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
load_dotenv()
SERPER_API_KEY = os.getenv('SERPER_API_KEY')
그리고, 사용하는 API 키를 가져온다. 이를 위해, 미리 .env 파일을 다음과 같이 만들어 놓고, 해당 API를 입력해 놓야야 한다.
# .env
SERPER_API_KEY=<YOUR API KEY>
파라메터에서 검색 질의문, 함수호출명과 파라메터를 정의한다. 아울러, 질의 결과를 명확히 데이터항목으로 추출하기 위해서 검색 결과가 될 데이타항목(타이틀, 링크, 스닙펫) 형식을 pydantic의 basemodel을 이용해 명확히 정의한다. 그리고, LLM 호출 결과를 펑션콜이 가능한 형식으로 변환하기 위한 파싱 함수인 parse_function_call 을 정의한다.
class SearchParameters(BaseModel):
query: str = Field(..., description="Search term to look up")
class FunctionCall(BaseModel):
name: str
parameters: Dict[str, Any]
class SearchResult(BaseModel):
title: str
link: str
snippet: str
def to_string(self) -> str:
return f"Title: {self.title}\nLink: {self.link}\nSnippet: {self.snippet}"
def google_search(query: str) -> SearchResult:
"""Perform a Google search using Serper.dev API"""
try:
url = "https://google.serper.dev/search"
payload = json.dumps({"q": query})
headers = {
'X-API-KEY': SERPER_API_KEY,
'Content-Type': 'application/json'
}
response = requests.post(url, headers=headers, data=payload)
response.raise_for_status() # 잘못된 상태 코드에 대해 예외 발생
results = response.json()
if not results.get('organic'):
raise ValueError("No search results found.")
first_result = results['organic'][0]
return SearchResult(
title=first_result.get('title', 'No title'),
link=first_result.get('link', 'No link'),
snippet=first_result.get('snippet', 'No snippet available.')
)
except Exception as e:
print(f"Search error: {str(e)}")
raise
def parse_function_call(response: str) -> Optional[FunctionCall]:
"""Parse the model's response to extract function calls"""
try:
# Clean the response and find JSON structure
response = response.strip()
start_idx = response.find('{')
end_idx = response.rfind('}') + 1
if start_idx == -1 or end_idx == 0:
return None
json_str = response[start_idx:end_idx]
data = json.loads(json_str)
return FunctionCall(**data)
except Exception as e:
print(f"Error parsing function call: {str(e)}")
return None
gemma에 지시할 시스템 프롬프트 명령을 정의한다. prompt_system_message는 이 챗봇이 어떻게 동작해야 하는지, 그리고 어떤 기준으로 답변을 해야 하는지에 대한 지침을 제공하는 역할을 한다. 이 메시지는 챗봇이 2024년까지의 정보를 학습한 AI 어시스턴트임을 명확히 하고, 사용자의 질문에 대해 가능한 경우에는 바로 답변을 하되, 최신 정보나 불확실한 내용, 시의성이 있는 질문에 대해서는 반드시 펑션콜을 통해 검색 기능을 활용해야 함을 명시한다. 이전 대화 내용이 함께 입력으로 주어지기 때문에, 챗봇은 이 대화 맥락을 참고하여 일관성 있고 상황에 맞는 답변을 해야 한다고 안내한다. 참고로, 준수해야 할 gemma3의 function call 형식은 다음과 같다.
검색이 필요한 상황과 그렇지 않은 상황을 구체적으로 구분하여, 챗봇이 임의로 정보를 추정하거나 추가하지 않고, 검색 결과에 기반한 사실만을 간결하게 전달하도록 유도한다. 검색이 필요한 경우에는 정해진 JSON 형식으로만 응답하도록 하여, 시스템이 함수 호출 방식으로 검색을 처리할 수 있게 한다.
# 프롬프트 시스템 메세지 정의
prompt_system_message = """You are an AI assistant with training data up to 2024. Answer questions directly when possible, and use search when necessary.
You will receive previous conversation messages as part of the input. Use these prior messages to maintain context and provide coherent, context-aware answers.
DECISION PROCESS:
1. For historical events before 2024:
- Answer directly from your training data.
2. For events in 2024:
- If you are certain, answer directly.
- If you are unsure, use search.
3. For events after 2024 or current/recent information:
- Always use search.
4. For timeless information (scientific facts, concepts, etc.):
- Answer directly from your training data.
ALWAYS USE SEARCH if the question:
- Contains words like "current", "latest", "now", "present", "today", "recent"
- Asks about someone in a changing position (champion, president, CEO, etc.)
- Requests information that might have changed since 2024
- Is time-sensitive and does not specify a time period
FUNCTION CALL FORMAT:
When you need to search, respond WITH ONLY THE JSON OBJECT, no other text, no backticks:
{
"name": "google_search",
"parameters": {
"query": "your search query"
}
}
SEARCH FUNCTION:
{
"name": "google_search",
"description": "Search for real-time information",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term"
}
},
"required": ["query"]
}
}
WHEN ANSWERING BASED ON SEARCH RESULTS:
- Use ONLY facts found in the search results below.
- Do NOT add any dates or information not present in the search results.
- Do NOT make assumptions about timing or events.
- Quote dates exactly as they appear in the results.
- Keep your answer concise and factual.
"""
gemma에 전달할 메시지는 프롬프트 지시문, 사용자 질문을 포함한 이전 채팅 이력 메시지 등을 모두 포함한다. 이를 ollama LLM 에 전달할 수 있는 형식으로 변환하는 함수를 다음과 같이 준비한다.
# 메시지 리스트를 생성하는 함수
def filter_memory(memory):
"""assistant의 검색 안내 메시지를 memory에서 제외"""
return [
msg for msg in memory
if not (
msg["role"] == "assistant" and (
msg["content"].startswith("Searching for:") or
msg["content"].startswith("Searched for:")
)
)
]
def build_messages(chat_history, user_input=None, prompt_system_message=prompt_system_message, N=6, search_result=None):
"""
최근 N개 메시지와 system 메시지를 합쳐 messages 리스트를 만듭니다.
search_result가 있으면, user_input 대신 검색 결과 기반 프롬프트를 추가합니다.
"""
memory = chat_history[-N:] if len(chat_history) > N else chat_history[:-1]
filtered_memory = filter_memory(memory)
messages = [{"role": "system", "content": prompt_system_message}] + filtered_memory
if search_result is not None:
messages.append({
"role": "user",
"content": (
"Refer to the following search result and provide a concise, factual answer based only on this information:\n"
f"{search_result.to_string()}"
)
})
elif user_input is not None:
messages.append({"role": "user", "content": user_input})
return messages
이제 process_message 함수를 구현한다. 이 함수는 사용자의 입력과 기존 채팅 기록을 받아 AI 모델과의 대화 흐름을 관리하는 역할을 한다.
먼저 사용자의 메시지를 채팅 기록에 추가하고, 이전 대화 내용(메모리)을 추출하여 시스템 메시지와 함께 모델에 전달할 메시지 목록을 구성한다. 이 메시지 목록을 Ollama 모델에 전달하여 응답을 받는다. 모델의 응답이 함수 호출(JSON) 형태라면, 그 내용을 파싱하여 검색이 필요한 경우 검색 쿼리를 추출한다.
검색이 필요하다고 판단되면, 검색 중임을 알리는 메시지를 채팅 기록에 추가하고, 실제로 검색을 수행한다. 검색 결과를 다시 채팅 기록에 반영한 뒤, 이 결과를 포함한 새로운 메시지 목록을 만들어 모델에 전달하여 최종 답변을 받는다. 최종적으로 받은 답변 역시 채팅 기록에 추가한다.
검색이 필요하지 않은 경우에는 모델의 응답을 바로 채팅 기록에 추가한다. 이 과정에서 각 단계별로 최신 채팅 기록을 반환하여, 사용자 인터페이스가 실시간으로 대화 상태를 갱신할 수 있도록 한다.
함수 실행 중 오류가 발생하면, 오류 메시지를 채팅 기록에 추가하여 사용자에게 알린다.
# Model name
MODEL_NAME = "gemma3"
def process_message(user_input, chat_history):
"""Process user message and update chat history"""
try:
# 사용자 메시지를 기록에 추가
chat_history.append({"role": "user", "content": user_input})
search_info = None
# 최근 N개 메시지만 memory에 포함 (예: 최근 6개)
N = 6
messages = build_messages(chat_history, user_input=user_input, N=N)
# 모델로부터 응답 받기
response = ollama.chat(
model=MODEL_NAME,
messages=messages
)
model_response = response['message']['content']
# 함수 호출로 응답을 파싱 시도
function_call = parse_function_call(model_response)
if function_call and function_call.name == "google_search":
# 검색 파라미터 검증
search_params = SearchParameters(**function_call.parameters)
search_query = search_params.query
# 검색 정보 기록에 추가
search_info = f"Searching for: {search_query}"
chat_history.append({"role": "assistant", "content": search_info})
yield chat_history
# 검색 실행
search_result = google_search(search_query)
# 검색 결과로 정보 업데이트
search_info = f"Searched for: {search_query}\n\nResult:\n{search_result.to_string()}"
chat_history[-1] = {"role": "assistant", "content": search_info}
yield chat_history
# 검색 결과 기반 메시지 생성
messages = build_messages(chat_history, N=N, search_result=search_result)
# 검색 결과를 포함해 모델로부터 최종 응답 받기
final_response = ollama.chat(
model=MODEL_NAME,
messages=messages
)
assistant_response = final_response['message']['content']
else:
# 함수 호출이 없으면 직접 응답 반환
assistant_response = model_response
# 최종 응답을 기록에 업데이트
if search_info:
chat_history.append({"role": "assistant", "content": f" Response:\n{assistant_response}"})
else:
chat_history.append({"role": "assistant", "content": assistant_response})
yield chat_history
except Exception as e:
error_msg = f"An error occurred: {str(e)}"
chat_history.append({"role": "assistant", "content": error_msg})
yield chat_history
이제 Gradio UI 를 정의하고, 메인 엔트리에서 이 앱을 실행한다.
# Gradio 인터페이스 생성
with gr.Blocks(css="footer {visibility: hidden}") as demo:
gr.Markdown("""
# Agent based on Gemma3 using Function Call
""")
chatbot = gr.Chatbot(
height=500,
show_label=False,
avatar_images=(None, "https://api.dicebear.com/9.x/identicon/svg?seed=Mason"),
type="messages"
)
with gr.Row():
msg = gr.Textbox(
scale=5,
show_label=False,
placeholder="Ask me anything...",
container=False
)
submit_btn = gr.Button("Send", scale=1)
with gr.Row():
clear_btn = gr.Button("Clear Chat")
# 이벤트 핸들러 설정
msg.submit(
process_message,
[msg, chatbot],
[chatbot],
)
submit_btn.click(
process_message,
[msg, chatbot],
[chatbot],
)
clear_btn.click(
lambda: [],
None,
chatbot,
queue=False
)
# 메시지 전송 후 텍스트박스 비우기
msg.submit(lambda: "", None, msg)
submit_btn.click(lambda: "", None, msg)
if __name__ == "__main__":
demo.launch(inbrowser=True, share=True)
실행
앞에 구현된 앱을 실행한다. 그리고, 적절한 질문을 입력해 본다. 다음과 같이 실행되면 성공한 것이다.
펑션콜 문제 개선 방법
실제로 질의해보면 불명확한 프롬프트 입력 등에서 부적절한 함수 호출이 수행되는 것을 알 수 있다. 이를 개선하기 위해 다음 사항을 고려한다.
함수 호출이 필요한 상황, 호출 방식(JSON 포맷 등), 호출 예시를 SYSTEM_MESSAGE에 명확하게 안내해야 한다. 함수 호출이 아닌 일반 답변을 하면 안 된다는 점을 반복적으로 강조한다.
예시 프롬프트:
"질문에 답변하기 위해 함수 호출이 필요하다고 판단되면 반드시 아래 JSON 형식으로만 응답하라. 다른 텍스트나 설명은 절대 포함하지 마라."
함수의 목적, 파라미터, 반환값, 사용 예시를 상세하게 기술한다. 각 파라미터의 타입, 필수 여부, 설명을 명확히 한다. 함수가 처리할 수 없는 입력(예: 빈 문자열, 잘못된 타입 등)에 대한 예외 상황도 명시한다.
SYSTEM_MESSAGE 또는 user message에 함수 호출이 필요한 질문과 그에 대한 올바른 함수 호출 예시를 여러 개 포함시킨다. 예시가 많을수록 모델이 패턴을 더 잘 학습한다.
모델이 함수 호출을 하지 않거나 잘못된 형식으로 응답하면, 내부적으로 "함수 호출이 필요합니다. 반드시 JSON 형식으로만 응답하세요."와 같은 추가 프롬프트로 재요청한다.
모델이 JSON 외의 텍스트를 섞어서 반환할 수 있으므로, 파싱 로직에서 JSON 부분만 추출하거나, 불완전한 JSON도 최대한 보완해서 파싱하도록 한다.
SYSTEM_MESSAGE에 "함수 호출이 필요한 상황에서는 반드시 함수 호출을 우선적으로 고려하라"는 문구를 추가한다. "만약 함수 호출이 필요하지 않다고 판단되면, 그 이유를 설명하지 말고 바로 답변만 하라." 등 불필요한 설명을 억제한다.
최신 GPT-4 Turbo 등 함수 호출에 최적화된 모델을 사용한다. temperature, top_p 등 파라미터를 낮춰 일관된 응답을 유도한다.
실제 사용자 입력 중 함수 호출이 누락된 사례를 수집하여, SYSTEM_MESSAGE나 예시 프롬프트를 지속적으로 개선한다.
이외에 잘 활용되는 함수에 대한 파인튜닝을 수행해 본다.
마무리
본 글은 ollama 를 이용한 gemma3 모델을 로딩해 Agent 개발 시 핵심이 되는 function call을 구현해 보았다. 실행해 보면 알겠지만, 펑션콜은 프롬프트 입력에 따라 민감하게 동작한다는 것을 알 수 있다. 그러므로, 함수 호출 방식은 적절히 LLM 오케스트레이션 및 튜닝되어야 한다는 것을 알 수 있다.
레퍼런스
댓글 없음:
댓글 쓰기