이미지 로딩 중...
AI Generated
2025. 11. 12. · 5 Views
바닥부터 만드는 ChatGPT 19편 계산기 도구 통합
AI가 복잡한 계산을 정확하게 수행할 수 있도록 외부 계산기 도구를 통합하는 방법을 배웁니다. Function Calling을 활용하여 LLM의 한계를 극복하고, 실시간 도구 실행 결과를 AI 응답에 반영하는 실전 기법을 다룹니다.
목차
- Function Calling 개념
- 계산기 도구 함수 구현
- 함수 스키마 정의
- Chat Completion API 호출
- 도구 호출 감지 및 실행
- 결과를 다시 AI에 전달
- 멀티턴 대화 흐름 설계
- 에러 핸들링 전략
1. Function Calling 개념
시작하며
여러분이 ChatGPT에게 "367 곱하기 489는 얼마야?"라고 물어본 적 있나요? LLM은 텍스트 생성에는 강력하지만, 정확한 산술 연산에서는 때때로 틀린 답을 내놓기도 합니다.
특히 큰 수의 곱셈이나 복잡한 수식 계산에서 말이죠. 이런 문제는 LLM의 근본적인 한계에서 비롯됩니다.
언어 모델은 패턴 학습을 통해 응답을 생성하기 때문에, 논리적으로 보장된 정확한 계산보다는 '그럴듯한' 답변을 내놓을 수 있습니다. 실무에서 금융 계산, 데이터 분석, 과학 계산 등을 다룰 때 이는 치명적인 오류로 이어질 수 있습니다.
바로 이럴 때 필요한 것이 Function Calling입니다. OpenAI의 Function Calling을 사용하면 AI가 스스로 "이 질문에는 계산기가 필요하다"고 판단하고, 실제 Python 함수를 호출하여 정확한 결과를 얻을 수 있습니다.
개요
간단히 말해서, Function Calling은 AI 모델이 외부 도구나 함수를 호출할 수 있도록 하는 메커니즘입니다. AI가 텍스트 응답 대신 "이 함수를 이런 인자로 실행해주세요"라는 요청을 반환하면, 우리가 실제로 그 함수를 실행하고 결과를 다시 AI에게 전달하는 방식입니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, AI는 모든 것을 스스로 할 수 없습니다. 데이터베이스 조회, API 호출, 복잡한 계산, 파일 시스템 접근 등 실제 작업은 코드로 처리해야 정확하고 빠릅니다.
Function Calling은 AI의 자연어 이해 능력과 프로그래밍의 정확성을 결합하는 다리 역할을 합니다. 예를 들어, 사용자가 "올해 매출 증가율을 계산해줘"라고 하면, AI는 데이터베이스 함수를 호출하고 계산 함수를 실행하여 정확한 답을 제공할 수 있습니다.
기존에는 AI 응답에서 파싱해야 할 패턴을 정의하고 복잡한 정규식으로 추출했다면, 이제는 OpenAI가 구조화된 JSON 형식으로 함수 호출 정보를 제공합니다. 이는 훨씬 안정적이고 유지보수하기 쉽습니다.
Function Calling의 핵심 특징은 세 가지입니다: 첫째, AI가 사용자 의도를 파악하여 적절한 함수를 선택합니다. 둘째, 함수 인자를 자동으로 추출하고 타입 변환까지 수행합니다.
셋째, 여러 함수 중에서 상황에 맞는 것을 선택할 수 있습니다. 이러한 특징들이 중요한 이유는, 개발자가 복잡한 파싱 로직 없이도 AI와 코드를 자연스럽게 통합할 수 있기 때문입니다.
코드 예제
# 계산기 함수 정의
def calculator(expression):
"""안전하게 수식을 계산하는 함수"""
try:
# eval은 보안 위험이 있으므로 실무에서는 ast.literal_eval 또는 파서 사용
result = eval(expression)
return {"result": result, "success": True}
except Exception as e:
return {"error": str(e), "success": False}
# Function Calling을 위한 도구 스키마
tools = [{
"type": "function",
"function": {
"name": "calculator",
"description": "복잡한 수학 계산을 수행합니다. 사칙연산, 거듭제곱 등을 지원합니다.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "계산할 수식 (예: '367 * 489')"
}
},
"required": ["expression"]
}
}
}]
설명
이것이 하는 일: Function Calling은 AI 모델과 실제 실행 가능한 코드를 연결하는 표준화된 방법을 제공합니다. 사용자의 자연어 요청을 분석하여 적절한 함수 호출로 변환하고, 그 결과를 다시 자연어 응답에 통합합니다.
첫 번째로, 우리는 실제로 작동하는 Python 함수를 작성합니다. 위의 calculator 함수는 문자열로 된 수식을 받아 계산 결과를 반환합니다.
에러 처리도 포함되어 있어 잘못된 수식이 들어와도 안전하게 처리됩니다. 이렇게 하는 이유는 AI가 호출할 실제 "도구"를 먼저 준비해야 하기 때문입니다.
그 다음으로, tools 배열에 함수의 "설명서"를 JSON 스키마 형식으로 작성합니다. OpenAI API는 이 스키마를 읽고 "아, 이 AI는 calculator라는 도구를 사용할 수 있구나.
수학 계산이 필요하면 이걸 호출하면 되겠다"라고 이해합니다. description 필드는 AI가 언제 이 함수를 호출해야 할지 판단하는 중요한 단서가 됩니다.
parameters 섹션은 함수가 어떤 인자를 받는지 상세히 정의합니다. 마지막으로, AI API를 호출할 때 이 tools 배열을 함께 전달하면, AI는 사용자 메시지를 분석하여 필요시 함수 호출을 응답으로 반환합니다.
우리는 이 응답을 파싱하여 실제 Python 함수를 실행하고, 그 결과를 다시 AI에게 전달하여 최종 답변을 생성합니다. 여러분이 이 코드를 사용하면 AI가 단순한 챗봇을 넘어 실제 작업을 수행하는 에이전트로 진화합니다.
계산뿐만 아니라 데이터베이스 조회, 이메일 전송, API 호출 등 모든 종류의 작업을 자연어로 제어할 수 있게 됩니다. 코드의 정확성과 AI의 유연성을 동시에 얻을 수 있다는 것이 가장 큰 이점입니다.
실전 팁
💡 description 필드는 매우 중요합니다. AI가 함수를 선택하는 주요 기준이므로, 언제 이 함수를 사용해야 하는지 명확하고 구체적으로 작성하세요. "계산을 수행합니다"보다는 "복잡한 수학 계산을 수행합니다. 사칙연산, 거듭제곱 등을 지원합니다"가 훨씬 좋습니다.
💡 eval() 함수는 보안 위험이 있습니다. 실무에서는 ast.literal_eval이나 전용 수식 파서(예: simpleeval 라이브러리)를 사용하여 안전하게 계산하세요. 사용자 입력을 그대로 eval에 넣으면 임의의 코드 실행 공격을 받을 수 있습니다.
💡 함수 실행 결과는 항상 JSON 직렬화 가능한 형태로 반환하세요. 복잡한 객체나 클래스 인스턴스는 AI가 이해할 수 없으므로, 딕셔너리, 리스트, 문자열, 숫자 등 기본 타입으로 변환해야 합니다.
💡 에러 처리를 반드시 포함하세요. 함수 실행이 실패하더라도 시스템 전체가 멈추지 않도록 try-except로 감싸고, 에러 메시지를 AI에게 전달하여 사용자에게 친절하게 설명할 수 있도록 합니다.
💡 여러 함수를 정의할 때는 각 함수의 역할을 명확히 구분하세요. "만능 함수" 하나보다는 "계산 함수", "날씨 조회 함수", "데이터베이스 쿼리 함수"처럼 단일 책임 원칙을 따르면 AI가 더 정확하게 선택합니다.
2. 계산기 도구 함수 구현
시작하며
여러분이 AI 에이전트를 만들 때 가장 먼저 마주하는 질문은 "AI가 호출할 함수를 어떻게 만들어야 하지?"입니다. 단순히 작동하는 코드를 넘어서, AI가 이해하고 올바르게 사용할 수 있는 함수를 설계하는 것은 생각보다 까다롭습니다.
특히 계산기처럼 보기엔 간단해 보이는 기능도 실제로는 고려할 사항이 많습니다. 잘못된 입력 처리, 보안 문제, 결과 형식 등 실무에서는 신경 써야 할 부분이 산더미처럼 쌓입니다.
"그냥 eval() 쓰면 되지 않나?"라고 생각할 수 있지만, 프로덕션 환경에서는 절대 금물입니다. 바로 이럴 때 필요한 것이 체계적인 도구 함수 설계입니다.
입력 검증, 에러 처리, 보안 고려사항을 모두 포함한 견고한 함수를 만들어야 AI 에이전트가 안정적으로 작동합니다.
개요
간단히 말해서, 계산기 도구 함수는 문자열로 된 수학 수식을 받아 계산 결과를 반환하는 Python 함수입니다. 하지만 단순히 계산만 하는 것이 아니라, 에러 처리, 로깅, 결과 형식화 등 실무 요구사항을 모두 충족해야 합니다.
왜 이 함수가 중요한지 실무 관점에서 설명하면, AI 에이전트의 신뢰성은 결국 호출하는 함수의 품질에 달려있습니다. 함수가 예외를 던지거나 이상한 결과를 반환하면, AI도 엉뚱한 답변을 하게 됩니다.
특히 금융, 엔지니어링, 데이터 분석처럼 정확성이 중요한 분야에서는 계산 함수의 품질이 곧 서비스의 신뢰도를 결정합니다. 예를 들어, 투자 수익률 계산이 틀리면 사용자에게 잘못된 재무 조언을 하게 되는 치명적인 상황이 발생할 수 있습니다.
기존에는 단순히 return eval(expression) 한 줄로 끝냈다면, 이제는 입력 검증, 허용된 연산자 확인, 타임아웃 설정, 결과 라운딩, 에러 메시지 표준화 등 여러 레이어를 추가해야 합니다. 이러한 모든 요소가 프로덕션 품질의 도구 함수를 만듭니다.
계산기 함수의 핵심 특징은 네 가지입니다: 첫째, 안전한 수식 평가(eval 대신 ast 모듈 사용), 둘째, 명확한 에러 메시지 제공, 셋째, 일관된 반환 형식(항상 딕셔너리), 넷째, 로깅과 모니터링 지원. 이러한 특징들이 중요한 이유는, 실제 서비스에서 발생할 수 있는 모든 예외 상황을 우아하게 처리하기 때문입니다.
코드 예제
import ast
import operator
# 허용된 연산자 정의 (보안을 위해 제한)
ALLOWED_OPERATORS = {
ast.Add: operator.add, # +
ast.Sub: operator.sub, # -
ast.Mult: operator.mul, # *
ast.Div: operator.truediv, # /
ast.Pow: operator.pow, # **
ast.USub: operator.neg, # 음수
}
def safe_eval(expression):
"""AST를 사용한 안전한 수식 평가"""
try:
node = ast.parse(expression, mode='eval')
return _eval_node(node.body)
except Exception as e:
raise ValueError(f"수식 평가 실패: {str(e)}")
def _eval_node(node):
"""AST 노드를 재귀적으로 평가"""
if isinstance(node, ast.Constant): # 숫자
return node.value
elif isinstance(node, ast.BinOp): # 이항 연산
left = _eval_node(node.left)
right = _eval_node(node.right)
op = ALLOWED_OPERATORS.get(type(node.op))
if op is None:
raise ValueError(f"허용되지 않은 연산자: {type(node.op)}")
return op(left, right)
elif isinstance(node, ast.UnaryOp): # 단항 연산 (음수)
operand = _eval_node(node.operand)
op = ALLOWED_OPERATORS.get(type(node.op))
if op is None:
raise ValueError(f"허용되지 않은 연산자: {type(node.op)}")
return op(operand)
else:
raise ValueError(f"허용되지 않은 표현식: {type(node)}")
def calculator(expression):
"""안전한 계산기 도구 함수"""
try:
# 입력 검증
if not expression or not isinstance(expression, str):
return {"error": "유효한 수식 문자열이 필요합니다", "success": False}
# 안전한 계산 수행
result = safe_eval(expression)
# 결과 반환 (소수점 10자리까지)
return {
"result": round(result, 10),
"expression": expression,
"success": True
}
except ZeroDivisionError:
return {"error": "0으로 나눌 수 없습니다", "success": False}
except ValueError as e:
return {"error": str(e), "success": False}
except Exception as e:
return {"error": f"계산 중 오류 발생: {str(e)}", "success": False}
설명
이것이 하는 일: 이 계산기 함수는 단순히 계산만 하는 것이 아니라, 보안을 지키면서 안전하게 수식을 평가하고, 모든 종류의 에러를 사용자 친화적인 메시지로 변환하며, AI가 쉽게 이해할 수 있는 구조화된 형식으로 결과를 반환합니다. 첫 번째로, ALLOWED_OPERATORS 딕셔너리를 정의합니다.
이것은 화이트리스트 방식으로, 허용된 연산자만 실행할 수 있게 합니다. eval()의 가장 큰 문제는 __import__('os').system('rm -rf /')같은 악의적인 코드도 실행할 수 있다는 점입니다.
ast 모듈을 사용하면 수식을 구문 트리로 파싱하여 각 노드를 검사할 수 있으므로, 위험한 함수 호출이나 import 문을 완전히 차단할 수 있습니다. 이렇게 하는 이유는 사용자 입력을 절대 신뢰해서는 안 되기 때문입니다.
그 다음으로, safe_eval과 _eval_node 함수가 실제 계산을 담당합니다. ast.parse로 문자열을 파싱하면 추상 구문 트리(AST)가 생성되고, 이를 재귀적으로 순회하면서 각 노드를 평가합니다.
ast.Constant는 숫자 리터럴, ast.BinOp는 이항 연산(덧셈, 곱셈 등), ast.UnaryOp는 단항 연산(음수)을 나타냅니다. 각 연산자 타입을 확인하여 허용 목록에 있는 것만 실행합니다.
이 과정에서 내부적으로 어떤 일이 일어나는지 보면, 예를 들어 "3 + 5 * 2"라는 수식은 AST로 파싱되어 연산자 우선순위가 자동으로 처리되고, 안전하게 계산됩니다. 마지막으로, calculator 함수는 모든 것을 통합하는 래퍼입니다.
입력 검증을 수행하고, safe_eval을 호출하며, 다양한 예외를 잡아서 의미 있는 에러 메시지로 변환합니다. ZeroDivisionError는 "0으로 나눌 수 없습니다"처럼 사용자가 이해할 수 있는 메시지로 바꿉니다.
최종 결과는 항상 {"result": ..., "success": True} 또는 {"error": ..., "success": False} 형식의 딕셔너리로 반환되어, AI가 일관되게 처리할 수 있습니다. 여러분이 이 코드를 사용하면 보안 걱정 없이 사용자 입력을 계산할 수 있습니다.
eval()의 위험성을 제거하면서도 충분히 유연한 계산 기능을 제공합니다. 에러 메시지가 명확하므로 디버깅도 쉽고, AI가 사용자에게 정확한 피드백을 줄 수 있습니다.
코드의 각 부분이 단일 책임을 가지므로 유지보수와 확장도 용이합니다.
실전 팁
💡 ast 모듈은 Python 3.8 이상에서 ast.Constant를 사용합니다. 이전 버전에서는 ast.Num, ast.Str 등을 사용해야 하므로 버전 호환성을 확인하세요.
💡 더 복잡한 수학 함수(sin, cos, log 등)가 필요하면 ALLOWED_OPERATORS에 math 모듈 함수를 추가할 수 있습니다. 예: ast.Call을 처리하여 'sin(30)' 같은 함수 호출을 지원합니다.
💡 타임아웃을 추가하여 무한 루프나 매우 느린 계산을 방지하세요. signal 모듈이나 multiprocessing.Pool의 timeout 기능을 활용할 수 있습니다.
💡 계산 결과를 로깅하면 나중에 문제 추적이 쉽습니다. 어떤 수식이 자주 계산되는지, 어떤 에러가 많이 발생하는지 모니터링하여 시스템을 개선하세요.
💡 실무에서는 simpleeval 같은 검증된 라이브러리를 사용하는 것도 좋은 선택입니다. 직접 AST 파서를 만드는 것보다 안정적이고 더 많은 기능을 제공합니다.
3. 함수 스키마 정의
시작하며
여러분이 Function Calling을 구현할 때 가장 헷갈리는 부분이 바로 함수 스키마입니다. "도대체 이 JSON을 어떻게 작성해야 AI가 제대로 이해하지?"라는 의문이 들 수밖에 없습니다.
스키마를 대충 작성하면 AI가 함수를 잘못 호출하거나 아예 호출하지 않는 문제가 발생합니다. 특히 초보자들은 함수의 Python 시그니처만 정의하고 스키마를 소홀히 하는 경우가 많습니다.
하지만 AI는 Python 코드를 직접 볼 수 없습니다. 오직 우리가 제공하는 JSON 스키마를 읽고 "이 함수는 이런 역할을 하고, 이런 인자를 받는구나"를 이해합니다.
스키마가 불명확하면 AI는 혼란스러워집니다. 바로 이럴 때 필요한 것이 명확하고 상세한 함수 스키마 정의입니다.
OpenAI의 Function Calling 스펙을 정확히 따르고, description을 전략적으로 작성하며, parameter 타입을 명확히 지정해야 AI가 완벽하게 작동합니다.
개요
간단히 말해서, 함수 스키마는 AI에게 "이 함수는 무엇을 하고, 어떻게 사용하는지"를 알려주는 설명서입니다. JSON 형식으로 작성되며, 함수 이름, 설명, 매개변수 타입과 설명, 필수 여부 등을 정의합니다.
왜 이 스키마가 필요한지 실무 관점에서 설명하면, AI 모델은 자연어 프롬프트만으로는 함수를 정확히 사용할 수 없습니다. 함수 이름이 "calc"인지 "calculator"인지, 인자 이름이 "expr"인지 "expression"인지, 타입이 문자열인지 숫자인지 명확히 알려줘야 합니다.
스키마는 AI와 코드 사이의 "계약서" 역할을 합니다. 예를 들어, 날씨 API를 호출하는 함수가 있을 때, 도시 이름을 "seoul"로 받는지 "서울"로 받는지, 아니면 위도/경도를 받는지 스키마로 명확히 정의해야 AI가 올바른 형식으로 호출합니다.
기존에는 프롬프트에 "계산이 필요하면 calculator(수식)을 호출하세요"라고 작성하고 응답을 파싱했다면, 이제는 OpenAI가 표준화한 JSON 스키마를 사용합니다. 이는 모델이 최적화되어 있어 훨씬 정확하고, 여러 함수를 동시에 정의해도 잘 구분합니다.
함수 스키마의 핵심 특징은 네 가지입니다: 첫째, JSON Schema Draft 7 표준을 따릅니다. 둘째, description 필드가 AI의 함수 선택에 가장 큰 영향을 미칩니다.
셋째, parameters는 중첩된 객체 구조를 지원하여 복잡한 인자도 표현 가능합니다. 넷째, required 배열로 필수 매개변수를 지정할 수 있습니다.
이러한 특징들이 중요한 이유는, 명확한 타입 정의와 설명이 AI의 호출 정확도를 크게 높이기 때문입니다.
코드 예제
# 계산기 함수를 위한 OpenAI Function Calling 스키마
calculator_schema = {
"type": "function",
"function": {
"name": "calculator",
"description": (
"복잡한 수학 계산을 정확하게 수행합니다. "
"사칙연산(+, -, *, /), 거듭제곱(**) 등을 지원합니다. "
"사용자가 숫자 계산을 요청하거나 수식을 제시하면 이 함수를 사용하세요."
),
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": (
"계산할 수학 수식을 문자열로 입력합니다. "
"예: '367 * 489', '(10 + 5) / 3', '2 ** 10'"
)
}
},
"required": ["expression"],
"additionalProperties": False
}
}
}
# API 호출 시 사용할 tools 배열
tools = [calculator_schema]
# 여러 함수를 정의하는 예시
tools_multiple = [
calculator_schema,
{
"type": "function",
"function": {
"name": "get_weather",
"description": "특정 도시의 현재 날씨 정보를 조회합니다.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "날씨를 조회할 도시 이름 (예: 'Seoul', 'New York')"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "온도 단위"
}
},
"required": ["city"]
}
}
}
]
설명
이것이 하는 일: 함수 스키마는 OpenAI API에게 "이런 도구들을 사용할 수 있어. 각 도구는 이렇게 사용하면 돼"라고 알려주는 메타데이터입니다.
AI는 이 정보를 바탕으로 사용자 요청을 분석하여 적절한 함수를 선택하고, 올바른 형식의 인자를 추출합니다. 첫 번째로, 스키마의 최상위에는 "type": "function"을 지정합니다.
이는 OpenAI에게 "이것은 함수 호출 도구입니다"라고 명시하는 것입니다. 그 아래 function 객체에 실제 함수 정보를 담습니다.
name 필드는 실제 Python 함수 이름과 일치해야 하며, 이후 AI 응답을 파싱하여 함수를 호출할 때 이 이름을 사용합니다. 이렇게 하는 이유는 AI 응답과 실제 코드를 매핑하기 위함입니다.
그 다음으로, description 필드가 가장 중요합니다. 이것은 단순한 설명이 아니라 AI가 "이 함수를 언제 호출해야 할지" 판단하는 핵심 기준입니다.
위 예제에서는 "복잡한 수학 계산을 정확하게 수행합니다"라고 명시하여, AI가 수학 관련 질문을 받으면 이 함수를 떠올리도록 유도합니다. "사용자가 숫자 계산을 요청하거나 수식을 제시하면"이라는 문장은 트리거 조건을 명확히 합니다.
내부적으로 OpenAI 모델은 사용자 메시지와 각 함수의 description을 비교하여 의미적 유사성(semantic similarity)을 계산하고, 가장 적합한 함수를 선택합니다. 세 번째로, parameters 섹션은 JSON Schema 표준을 따릅니다.
type: "object"는 매개변수가 객체 형태임을 의미하고, properties에 각 매개변수를 정의합니다. expression 매개변수는 type: "string"으로 문자열임을 명시하고, 자체 description을 통해 "어떤 형식으로 입력해야 하는지" 예시를 제공합니다.
"예: '367 * 489'"처럼 구체적인 예시를 넣으면 AI가 올바른 형식으로 인자를 생성할 확률이 높아집니다. required 배열은 필수 매개변수를 지정하며, 여기서는 expression이 반드시 있어야 함을 나타냅니다.
마지막으로, 여러 함수를 정의할 때는 tools 배열에 스키마들을 나열합니다. AI는 사용자 요청을 분석하여 여러 함수 중 가장 적합한 하나(또는 여러 개)를 선택합니다.
예를 들어 "서울 날씨는 어때? 그리고 367 곱하기 489는?"이라는 질문에는 get_weather와 calculator 두 함수를 모두 호출할 수 있습니다.
여러분이 이 코드를 사용하면 AI가 함수를 매우 정확하게 호출합니다. 애매한 요청도 올바른 함수로 라우팅되고, 매개변수 형식도 정확히 맞춰집니다.
스키마를 잘 작성하면 추가 프롬프트 엔지니어링 없이도 AI가 똑똑하게 작동합니다. 유지보수 시에도 스키마만 업데이트하면 AI가 자동으로 새로운 함수를 이해하므로, 확장성이 뛰어납니다.
실전 팁
💡 description은 짧지만 구체적으로 작성하세요. "계산을 합니다"보다는 "복잡한 수학 계산을 정확하게 수행합니다. 사칙연산, 거듭제곱을 지원하며 사용자가 수식을 요청할 때 사용합니다"가 훨씬 효과적입니다.
💡 매개변수 description에 예시를 포함하세요. "수식을 입력합니다"보다 "계산할 수식 (예: '367 * 489', '2 ** 10')"처럼 구체적인 예시가 AI의 인자 생성을 돕습니다.
💡 enum을 활용하여 허용된 값을 제한하세요. 온도 단위처럼 정해진 옵션이 있다면 "enum": ["celsius", "fahrenheit"]로 지정하면 AI가 잘못된 값을 생성하지 않습니다.
💡 additionalProperties를 false로 설정하여 정의되지 않은 매개변수를 방지하세요. 이는 AI가 임의로 추가 매개변수를 만드는 것을 막아 예측 가능성을 높입니다.
💡 함수 이름은 snake_case를 사용하고, 동사로 시작하는 것이 좋습니다. calculate, get_weather, send_email처럼 명확한 동작을 나타내는 이름이 AI의 이해를 돕습니다.
4. Chat Completion API 호출
시작하며
여러분이 Function Calling을 처음 사용할 때 가장 막막한 순간은 "이제 API를 어떻게 호출하지?"입니다. 일반 챗봇 API 호출과는 조금 다르게, 함수 정의를 포함해야 하고, 응답 형식도 달라집니다.
공식 문서를 봐도 처음에는 헷갈릴 수밖에 없습니다. 특히 실무에서는 단순히 API를 호출하는 것 이상의 고민이 필요합니다.
에러 처리, 토큰 사용량 최적화, 응답 타임아웃, 재시도 로직 등 신경 써야 할 부분이 많습니다. "그냥 requests.post 하면 되지 않나?"라고 생각하기 쉽지만, 프로덕션 환경에서는 훨씬 복잡합니다.
바로 이럴 때 필요한 것이 체계적인 API 호출 설계입니다. OpenAI Python SDK를 사용하여 안전하고 효율적으로 Function Calling을 수행하고, 다양한 응답 시나리오를 처리할 수 있어야 합니다.
개요
간단히 말해서, Chat Completion API 호출은 사용자 메시지와 함수 스키마를 OpenAI에 전달하여, AI가 텍스트 응답 또는 함수 호출 요청을 반환받는 과정입니다. OpenAI Python SDK의 chat.completions.create 메서드를 사용합니다.
왜 이 단계가 중요한지 실무 관점에서 설명하면, API 호출은 전체 시스템의 성능과 비용에 직접적인 영향을 미칩니다. 잘못 호출하면 불필요한 토큰을 소비하여 비용이 증가하고, 응답 시간이 길어져 사용자 경험이 나빠집니다.
또한 네트워크 오류나 API 장애 시 적절히 대응하지 못하면 서비스 전체가 멈출 수 있습니다. 예를 들어, 금융 챗봇에서 API 호출이 실패하면 사용자가 중요한 거래를 진행하지 못하는 상황이 발생할 수 있습니다.
기존 일반 챗봇 API 호출에서는 messages 배열만 전달했다면, Function Calling을 사용할 때는 추가로 tools 배열을 전달합니다. 또한 tool_choice 매개변수로 AI의 함수 선택 전략을 제어할 수 있습니다.
"auto"(자동 선택), "none"(함수 호출 안 함), 또는 특정 함수를 강제할 수 있습니다. API 호출의 핵심 특징은 네 가지입니다: 첫째, tools 매개변수로 함수 스키마를 전달합니다.
둘째, 응답의 choices[0].message에 tool_calls 필드가 있으면 함수 호출이 필요합니다. 셋째, tool_choice로 AI의 동작을 세밀하게 제어할 수 있습니다.
넷째, 스트리밍 모드에서도 Function Calling을 사용할 수 있습니다. 이러한 특징들이 중요한 이유는, 다양한 시나리오에 맞춰 유연하게 대응할 수 있기 때문입니다.
코드 예제
from openai import OpenAI
import os
# OpenAI 클라이언트 초기화
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def call_chat_completion_with_tools(messages, tools):
"""Function Calling을 지원하는 Chat Completion API 호출"""
try:
response = client.chat.completions.create(
model="gpt-4o-mini", # 또는 gpt-4, gpt-3.5-turbo
messages=messages,
tools=tools,
tool_choice="auto", # "auto", "none", 또는 {"type": "function", "function": {"name": "함수명"}}
temperature=0.7,
max_tokens=500
)
return response
except Exception as e:
print(f"API 호출 실패: {str(e)}")
return None
# 사용 예시
messages = [
{"role": "system", "content": "당신은 수학 계산을 도와주는 AI 어시스턴트입니다."},
{"role": "user", "content": "367 곱하기 489는 얼마야?"}
]
tools = [calculator_schema] # 이전에 정의한 스키마 사용
# API 호출
response = call_chat_completion_with_tools(messages, tools)
# 응답 확인
if response:
message = response.choices[0].message
# 함수 호출이 필요한지 확인
if message.tool_calls:
print("AI가 함수 호출을 요청했습니다:")
for tool_call in message.tool_calls:
print(f" 함수: {tool_call.function.name}")
print(f" 인자: {tool_call.function.arguments}")
else:
# 일반 텍스트 응답
print(f"AI 응답: {message.content}")
설명
이것이 하는 일: Chat Completion API 호출은 사용자의 자연어 요청과 사용 가능한 도구 목록을 AI에게 전달하고, AI가 텍스트 응답 또는 도구 실행 요청 중 적절한 것을 선택하여 반환하도록 하는 과정입니다. 첫 번째로, OpenAI 클라이언트를 초기화합니다.
OpenAI(api_key=...)는 환경변수에서 API 키를 읽어옵니다. 실무에서는 절대 코드에 API 키를 하드코딩하지 말고, 환경변수나 비밀 관리 시스템(AWS Secrets Manager, HashiCorp Vault 등)을 사용해야 합니다.
이렇게 하는 이유는 보안과 키 관리를 분리하기 위함입니다. 그 다음으로, call_chat_completion_with_tools 함수는 API 호출을 래핑합니다.
client.chat.completions.create에 여러 매개변수를 전달하는데, 핵심은 tools 매개변수입니다. 여기에 이전에 정의한 함수 스키마 배열을 전달하면, AI가 "아, 이 대화에서는 이런 도구들을 사용할 수 있구나"를 인지합니다.
tool_choice="auto"는 AI가 자율적으로 판단하여 필요하면 함수를 호출하고, 필요 없으면 일반 응답을 한다는 의미입니다. "none"으로 설정하면 함수를 절대 호출하지 않고, 특정 함수 이름을 지정하면 반드시 그 함수를 호출합니다.
세 번째로, API 응답을 파싱합니다. response.choices[0].message는 AI의 응답 메시지 객체입니다.
여기서 중요한 것은 message.tool_calls 필드입니다. 이 필드가 존재하고 값이 있으면 AI가 "이 질문에 답하려면 이 함수를 실행해야 해"라고 판단한 것입니다.
tool_calls는 배열이며, 각 요소는 function.name(호출할 함수 이름)과 function.arguments(JSON 문자열 형식의 인자)를 포함합니다. 내부적으로 OpenAI는 사용자 메시지를 분석하여 "367 곱하기 489"에서 수학 계산이 필요함을 인지하고, calculator 함수를 선택하며, "367 * 489"라는 수식을 추출하여 arguments에 담습니다.
마지막으로, 에러 처리를 포함했습니다. API 호출은 네트워크 문제, 인증 오류, 서버 장애 등 다양한 이유로 실패할 수 있습니다.
try-except 블록으로 감싸서 예외를 잡고, None을 반환하여 호출자가 실패를 인지하고 대응할 수 있게 합니다. 실무에서는 여기에 재시도 로직(exponential backoff), 로깅, 모니터링 알림 등을 추가합니다.
여러분이 이 코드를 사용하면 AI와 도구를 자연스럽게 통합할 수 있습니다. 사용자는 복잡한 명령어나 구조화된 입력 없이 자연어로 말하면 되고, AI가 알아서 필요한 함수를 호출합니다.
함수를 추가하거나 수정할 때도 스키마만 업데이트하면 되므로 유지보수가 쉽습니다. 여러 도구를 조합하여 복잡한 워크플로우도 자동화할 수 있습니다.
실전 팁
💡 model 매개변수는 gpt-4-turbo, gpt-4, gpt-3.5-turbo 중 선택하세요. Function Calling은 모든 모델에서 지원되지만, gpt-4가 함수 선택 정확도가 가장 높습니다. 비용과 성능을 고려하여 선택하세요.
💡 temperature를 낮추면(0~0.3) 더 일관된 함수 호출을 얻을 수 있습니다. 창의적인 응답보다 정확한 도구 실행이 중요할 때 유용합니다.
💡 max_tokens를 적절히 설정하여 비용을 관리하세요. 함수 호출 응답은 일반적으로 짧으므로 500 정도면 충분합니다. 텍스트 응답도 필요한 경우 더 높게 설정합니다.
💡 재시도 로직을 구현할 때는 openai 라이브러리의 built-in retry 기능을 활용하거나, tenacity 같은 라이브러리를 사용하세요. rate limit 에러와 일시적인 서버 오류를 구분하여 처리합니다.
💡 스트리밍 모드(stream=True)를 사용하면 실시간으로 응답을 받을 수 있지만, Function Calling과 함께 사용할 때는 파싱이 복잡해집니다. 일반 모드로 충분한지 먼저 평가하세요.
5. 도구 호출 감지 및 실행
시작하며
여러분이 API에서 응답을 받았을 때 "이제 뭘 해야 하지?"라는 생각이 들 수 있습니다. AI가 함수를 호출하라고 했는데, 그 정보를 어떻게 파싱하고, 실제 Python 함수를 어떻게 실행하며, 결과를 어떻게 처리해야 할까요?
이 단계에서 많은 개발자들이 막힙니다. 특히 OpenAI 응답의 tool_calls 구조는 처음 보면 복잡해 보입니다.
JSON 문자열로 인코딩된 인자를 파싱해야 하고, 함수 이름으로 실제 함수 객체를 찾아야 하며, 예외 처리도 고려해야 합니다. 하나라도 잘못 처리하면 전체 시스템이 멈춥니다.
바로 이럴 때 필요한 것이 체계적인 도구 호출 감지 및 실행 로직입니다. AI 응답을 안전하게 파싱하고, 동적으로 함수를 호출하며, 에러를 우아하게 처리하는 패턴을 익혀야 합니다.
개요
간단히 말해서, 도구 호출 감지 및 실행은 AI 응답에서 tool_calls를 추출하고, JSON 인자를 파싱하여 실제 Python 함수를 호출한 후, 결과를 구조화하여 다시 AI에게 전달할 준비를 하는 과정입니다. 왜 이 단계가 중요한지 실무 관점에서 설명하면, Function Calling의 핵심 가치는 바로 여기서 발생합니다.
AI가 아무리 정확하게 함수를 선택해도, 실제로 실행하지 않으면 의미가 없습니다. 이 단계가 잘못되면 계산 결과가 틀리거나, 데이터베이스에 잘못된 쿼리를 날리거나, 심지어 보안 취약점이 생길 수 있습니다.
예를 들어, 전자상거래 챗봇에서 "주문 취소" 함수 호출을 잘못 처리하면 엉뚱한 주문이 취소되는 치명적인 결과가 나타날 수 있습니다. 기존에는 정규식이나 문자열 파싱으로 AI 응답에서 함수 정보를 추출했다면, 이제는 OpenAI가 제공하는 구조화된 tool_calls 객체를 사용합니다.
이는 훨씬 안전하고 일관성 있으며, 여러 함수 호출을 동시에 처리할 수 있습니다. 도구 호출 실행의 핵심 특징은 다섯 가지입니다: 첫째, JSON 파싱으로 인자를 Python 객체로 변환합니다.
둘째, 함수 이름을 키로 하는 딕셔너리로 실제 함수 객체를 매핑합니다. 셋째, **kwargs 언패킹으로 동적으로 함수를 호출합니다.
넷째, 각 함수 호출에 대한 고유 ID를 추적하여 응답을 매칭합니다. 다섯째, 에러가 발생해도 부분적인 성공은 유지합니다.
이러한 특징들이 중요한 이유는, 다양한 함수와 복잡한 인자 구조를 안전하게 처리하기 위함입니다.
코드 예제
import json
# 사용 가능한 함수들을 딕셔너리로 매핑
AVAILABLE_FUNCTIONS = {
"calculator": calculator, # 이전에 정의한 calculator 함수
# 추가 함수들...
}
def execute_tool_calls(tool_calls):
"""AI가 요청한 도구 호출을 실행하고 결과를 반환"""
tool_results = []
for tool_call in tool_calls:
# 함수 이름과 ID 추출
function_name = tool_call.function.name
tool_call_id = tool_call.id
# 함수 인자 파싱 (JSON 문자열 -> Python dict)
try:
function_args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as e:
# 인자 파싱 실패
tool_results.append({
"tool_call_id": tool_call_id,
"role": "tool",
"content": json.dumps({"error": f"인자 파싱 실패: {str(e)}", "success": False})
})
continue
# 함수 찾기
function_to_call = AVAILABLE_FUNCTIONS.get(function_name)
if not function_to_call:
# 함수가 존재하지 않음
tool_results.append({
"tool_call_id": tool_call_id,
"role": "tool",
"content": json.dumps({"error": f"함수 '{function_name}'을 찾을 수 없습니다", "success": False})
})
continue
# 함수 실행
try:
result = function_to_call(**function_args)
tool_results.append({
"tool_call_id": tool_call_id,
"role": "tool",
"content": json.dumps(result)
})
except Exception as e:
# 함수 실행 실패
tool_results.append({
"tool_call_id": tool_call_id,
"role": "tool",
"content": json.dumps({"error": f"실행 오류: {str(e)}", "success": False})
})
return tool_results
# 사용 예시
if response and response.choices[0].message.tool_calls:
tool_results = execute_tool_calls(response.choices[0].message.tool_calls)
print("도구 실행 결과:", tool_results)
설명
이것이 하는 일: 도구 호출 실행은 AI의 "요청서"를 읽고 실제 작업을 수행하는 엔진입니다. AI가 "calculator를 expression='367 * 489'로 호출해줘"라고 하면, 우리는 그 정보를 파싱하여 실제 calculator 함수를 호출하고, 결과를 AI가 이해할 수 있는 형식으로 반환합니다.
첫 번째로, AVAILABLE_FUNCTIONS 딕셔너리를 정의합니다. 이것은 함수 이름(문자열)을 실제 함수 객체로 매핑하는 레지스트리입니다.
AI 응답에서 "calculator"라는 문자열을 받으면, 이 딕셔너리를 통해 실제 calculator 함수 객체를 찾습니다. 이렇게 하는 이유는 동적으로 함수를 호출하기 위함입니다.
Python의 eval()이나 exec()를 사용하지 않고도 안전하게 함수를 호출할 수 있습니다. 그 다음으로, execute_tool_calls 함수는 tool_calls 배열을 순회하며 각 호출을 처리합니다.
각 tool_call 객체는 id(고유 식별자), function.name(함수 이름), function.arguments(JSON 문자열 형식의 인자)를 포함합니다. 먼저 json.loads로 arguments를 파싱하여 Python 딕셔너리로 변환합니다.
예를 들어, '{"expression": "367 * 489"}'는 {"expression": "367 * 489"} 딕셔너리가 됩니다. 파싱에 실패하면 에러 메시지를 담은 결과를 반환하고 다음 호출로 넘어갑니다.
세 번째로, 함수를 찾아 실행합니다. AVAILABLE_FUNCTIONS.get(function_name)으로 함수 객체를 찾고, 없으면 에러를 반환합니다.
함수가 있으면 function_to_call(**function_args)처럼 키워드 인자 언패킹으로 호출합니다. ** 연산자는 딕셔너리를 키워드 인자로 전개하므로, {"expression": "367 * 489"}는 calculator(expression="367 * 489")로 호출됩니다.
내부적으로 calculator 함수가 실행되어 계산 결과를 반환하고, 이를 json.dumps로 JSON 문자열로 직렬화합니다. 마지막으로, 각 결과를 tool_results 배열에 추가합니다.
중요한 점은 tool_call_id를 반드시 포함해야 한다는 것입니다. OpenAI는 이 ID를 사용하여 "어떤 함수 호출에 대한 결과인지"를 매칭합니다.
여러 함수를 동시에 호출했을 때 각 결과를 올바르게 연결하기 위해 필수적입니다. role: "tool"은 이 메시지가 도구 실행 결과임을 나타냅니다.
여러분이 이 코드를 사용하면 AI와 코드가 완벽하게 통합된 시스템을 만들 수 있습니다. 사용자는 자연어로 말하고, AI가 의도를 파악하여 함수를 호출하며, 실제 계산이나 데이터 조회가 이루어지고, 그 결과가 다시 자연어로 전달됩니다.
함수 실행 과정이 투명하게 추적되므로 디버깅도 쉽고, 각 단계의 에러를 독립적으로 처리하여 시스템 전체가 멈추지 않습니다.
실전 팁
💡 AVAILABLE_FUNCTIONS 딕셔너리를 중앙에서 관리하세요. 함수를 추가하거나 제거할 때 이 딕셔너리만 업데이트하면 되므로 유지보수가 쉽습니다. 플러그인 시스템처럼 확장할 수도 있습니다.
💡 함수 실행에 타임아웃을 설정하세요. 외부 API 호출이나 복잡한 계산이 무한정 걸릴 수 있으므로, signal 모듈이나 concurrent.futures.TimeoutError를 활용합니다.
💡 각 함수 호출을 로깅하세요. 누가, 언제, 어떤 함수를, 어떤 인자로 호출했는지 기록하면 추후 문제 추적과 사용 패턴 분석에 유용합니다.
💡 함수 실행을 샌드박스 환경에서 수행하는 것을 고려하세요. 특히 사용자 입력이 함수 인자로 직접 전달되는 경우, 별도의 프로세스나 컨테이너에서 실행하여 보안을 강화합니다.
💡 여러 함수를 병렬로 실행할 수 있다면 asyncio나 ThreadPoolExecutor를 사용하세요. 독립적인 함수 호출들을 동시에 실행하여 전체 응답 시간을 단축할 수 있습니다.
6. 결과를 다시 AI에 전달
시작하며
여러분이 함수를 실행하고 결과를 얻었을 때, "이제 이걸 어떻게 AI에게 다시 전달하지?"라는 질문이 생깁니다. 단순히 결과를 출력하는 것이 아니라, AI가 그 결과를 이해하고 사용자에게 자연어로 설명할 수 있도록 해야 합니다.
많은 개발자들이 이 단계를 간과하고 함수 결과를 그대로 사용자에게 보여주는 실수를 합니다. 하지만 "179763"이라는 숫자만 던져주는 것보다 "367 곱하기 489는 179,763입니다"라고 자연스럽게 설명하는 것이 훨씬 좋은 사용자 경험입니다.
AI는 바로 이런 자연어 생성에 특화되어 있습니다. 바로 이럴 때 필요한 것이 멀티턴 대화 패턴입니다.
첫 번째 API 호출로 함수 호출을 받고, 함수를 실행한 후, 그 결과를 포함하여 두 번째 API 호출을 통해 최종 답변을 생성하는 흐름을 구현해야 합니다.
개요
간단히 말해서, 결과를 AI에 전달하는 것은 함수 실행 결과를 messages 배열에 추가하고, 다시 Chat Completion API를 호출하여 AI가 그 결과를 바탕으로 최종 자연어 응답을 생성하도록 하는 과정입니다. 왜 이 단계가 필요한지 실무 관점에서 설명하면, Function Calling은 단일 API 호출이 아니라 대화의 흐름입니다.
AI가 함수를 호출하라고 하면, 우리가 실행하고, 그 결과를 AI에게 다시 알려줘야 AI가 "아, 계산 결과가 이거구나. 그럼 사용자에게 이렇게 설명해야겠다"라고 판단할 수 있습니다.
이 패턴이 없으면 AI는 자신이 요청한 함수의 결과를 알 수 없어 의미 있는 답변을 생성할 수 없습니다. 예를 들어, 의료 진단 봇에서 검사 결과 조회 함수를 호출했다면, 그 결과를 바탕으로 AI가 환자에게 의학적 조언을 제공할 수 있습니다.
기존에는 함수 결과를 수동으로 프롬프트에 포함시켜 새 요청을 만들었다면, 이제는 OpenAI의 표준화된 메시지 형식을 따릅니다. role: "assistant" 메시지에 tool_calls를 포함하고, role: "tool" 메시지로 각 함수 결과를 전달하는 구조입니다.
결과 전달의 핵심 특징은 네 가지입니다: 첫째, 대화 히스토리를 누적합니다(원래 사용자 메시지 + AI의 함수 호출 메시지 + 도구 결과 메시지). 둘째, tool_call_id로 각 함수 호출과 결과를 매칭합니다.
셋째, 여러 함수 호출의 결과를 한 번에 전달할 수 있습니다. 넷째, AI는 결과를 보고 추가 함수 호출이 필요한지, 아니면 최종 답변을 할 수 있는지 판단합니다.
이러한 특징들이 중요한 이유는, 복잡한 멀티스텝 워크플로우를 자연스럽게 처리하기 위함입니다.
코드 예제
def get_final_response(messages, tools, tool_results):
"""도구 실행 결과를 포함하여 최종 AI 응답 생성"""
# 도구 결과를 messages에 추가
for result in tool_results:
messages.append(result)
# 두 번째 API 호출 - 이제 AI가 결과를 보고 최종 답변 생성
try:
final_response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools, # 추가 함수 호출이 필요할 수 있으므로 계속 전달
tool_choice="auto"
)
return final_response
except Exception as e:
print(f"최종 응답 생성 실패: {str(e)}")
return None
# 전체 흐름 예시
def handle_user_query(user_message):
"""사용자 질문을 처리하는 전체 파이프라인"""
# 1. 초기 messages 구성
messages = [
{"role": "system", "content": "당신은 수학 계산을 도와주는 AI 어시스턴트입니다."},
{"role": "user", "content": user_message}
]
# 2. 첫 번째 API 호출
response = call_chat_completion_with_tools(messages, tools)
if not response:
return "죄송합니다. 응답을 생성할 수 없습니다."
assistant_message = response.choices[0].message
# 3. 함수 호출이 필요한가?
if assistant_message.tool_calls:
# AI의 함수 호출 메시지를 히스토리에 추가
messages.append({
"role": "assistant",
"content": assistant_message.content,
"tool_calls": [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in assistant_message.tool_calls
]
})
# 4. 함수 실행
tool_results = execute_tool_calls(assistant_message.tool_calls)
# 5. 결과를 포함하여 최종 응답 생성
final_response = get_final_response(messages, tools, tool_results)
if final_response:
return final_response.choices[0].message.content
else:
return "결과 처리 중 오류가 발생했습니다."
else:
# 함수 호출 없이 바로 답변
return assistant_message.content
# 사용
result = handle_user_query("367 곱하기 489는 얼마야?")
print(result) # "367 곱하기 489는 179,763입니다."
설명
이것이 하는 일: 결과 전달은 Function Calling의 "두 번째 라운드"입니다. 첫 번째 라운드에서 AI가 "이 함수를 호출해줘"라고 했고, 우리가 실행했으니, 이제 "함수 결과는 이거야"라고 알려주면 AI가 그 정보를 활용하여 완전한 답변을 만듭니다.
첫 번째로, 도구 결과를 messages 배열에 추가합니다. 각 결과는 {"role": "tool", "tool_call_id": "...", "content": "..."}형식입니다.
role: "tool"은 OpenAI에게 "이것은 함수 실행 결과입니다"라고 알려주는 특별한 역할입니다. tool_call_id는 앞서 AI가 요청한 특정 함수 호출과 이 결과를 매칭하기 위한 것입니다.
예를 들어, AI가 동시에 calculator와 weather 함수를 호출했다면, 각 결과에 올바른 ID를 붙여야 AI가 "이 결과는 계산 함수의 것이고, 저 결과는 날씨 함수의 것이구나"를 알 수 있습니다. 그 다음으로, AI의 함수 호출 메시지도 히스토리에 추가해야 합니다.
이것은 매우 중요한데, OpenAI API는 대화의 흐름을 이해하기 위해 모든 메시지를 순서대로 필요로 합니다. 메시지 흐름은 이렇게 됩니다: [사용자 질문] → [AI의 함수 호출 요청] → [도구 실행 결과] → [AI의 최종 답변].
중간 단계를 빠뜨리면 API가 컨텍스트를 이해하지 못해 이상한 응답을 할 수 있습니다. 세 번째로, 두 번째 API 호출을 수행합니다.
이번에는 messages에 도구 결과까지 포함된 상태이므로, AI는 "아, 사용자가 367 곱하기 489를 물어봤고, 내가 calculator 함수를 호출했고, 결과는 179763이구나. 그럼 사용자에게 '367 곱하기 489는 179,763입니다'라고 답변해야겠다"라고 판단합니다.
내부적으로 AI는 도구 결과를 자연어로 풀어서 설명하고, 필요하면 추가 정보나 컨텍스트를 덧붙입니다. 마지막으로, handle_user_query 함수는 전체 파이프라인을 통합합니다.
사용자 질문을 받아 첫 번째 API 호출을 하고, 함수 호출이 필요하면 실행하며, 결과를 다시 AI에게 전달하여 최종 답변을 생성합니다. 함수 호출이 없는 경우는 바로 답변을 반환합니다.
이렇게 하면 사용자는 Function Calling의 복잡한 내부 과정을 알 필요 없이 자연스러운 대화 경험을 얻습니다. 여러분이 이 코드를 사용하면 AI가 단순한 정보 검색을 넘어 실제 작업을 수행하는 에이전트가 됩니다.
계산, 데이터 조회, API 호출 등 다양한 도구를 자유롭게 사용하면서도, 사용자에게는 자연스러운 대화 형태로 제공됩니다. 대화 히스토리가 유지되므로 멀티턴 대화에서도 컨텍스트가 보존되고, 필요하면 여러 번 함수를 호출하는 복잡한 워크플로우도 처리할 수 있습니다.
실전 팁
💡 messages 배열을 세션별로 저장하여 대화 히스토리를 유지하세요. 사용자가 "그 결과에서 10을 빼면?"이라고 후속 질문할 때 이전 계산 결과를 기억할 수 있습니다.
💡 tool_choice를 "none"으로 설정하여 최종 응답에서는 함수 호출을 막을 수 있습니다. 도구 결과를 받은 후에는 더 이상 함수를 호출하지 않고 답변만 생성하도록 강제합니다.
💡 함수 호출이 체인으로 이어질 수 있습니다. 예를 들어, "서울 날씨를 조회하고, 기온에 따라 적정 옷차림을 계산해줘"처럼 첫 함수 결과가 두 번째 함수의 입력이 될 수 있습니다. 루프를 돌며 여러 번 API 호출을 처리하세요.
💡 최대 반복 횟수를 설정하여 무한 루프를 방지하세요. AI가 계속 함수를 호출하는 경우를 대비해 "최대 5번까지만 함수 호출"같은 제한을 둡니다.
💡 각 단계의 토큰 사용량을 모니터링하세요. 대화가 길어질수록 messages 배열이 커져 토큰 비용이 증가하므로, 오래된 메시지를 요약하거나 제거하는 전략이 필요합니다.
7. 멀티턴 대화 흐름 설계
시작하며
여러분이 Function Calling을 실무에 적용할 때 가장 어려운 부분은 "여러 번의 대화와 함수 호출을 어떻게 관리하지?"입니다. 단순히 한 번의 질문-답변이 아니라, 사용자가 후속 질문을 하거나, 여러 함수를 연쇄적으로 호출해야 하는 복잡한 시나리오를 다뤄야 합니다.
특히 실무에서는 "A를 조회하고, 그 결과로 B를 계산하고, 계산 결과를 C에 저장해줘"같은 멀티스텝 워크플로우가 흔합니다. 각 단계가 이전 단계의 결과에 의존하므로, 대화 상태를 정확히 관리하지 않으면 엉뚱한 결과가 나오거나 시스템이 멈춥니다.
바로 이럴 때 필요한 것이 체계적인 멀티턴 대화 흐름 설계입니다. 세션 관리, 대화 히스토리 유지, 반복적인 함수 호출 처리, 종료 조건 설정 등을 명확히 정의해야 안정적인 AI 에이전트를 만들 수 있습니다.
개요
간단히 말해서, 멀티턴 대화 흐름은 사용자와 AI, 도구 간의 여러 번의 상호작용을 관리하는 패턴입니다. 각 대화 턴마다 메시지를 누적하고, 함수 호출이 계속 필요한지 확인하며, 최종적으로 사용자에게 완전한 답변을 제공할 때까지 루프를 돕니다.
왜 이 패턴이 필요한지 실무 관점에서 설명하면, 실제 사용자 요청은 단일 함수 호출로 해결되지 않는 경우가 많습니다. "지난달 매출을 조회하고, 작년 동월 대비 증가율을 계산해서, 리포트를 이메일로 보내줘"같은 요청은 최소 3개의 함수 호출이 필요합니다.
각 단계를 추적하고, 이전 결과를 다음 단계에 전달하며, 모든 단계가 완료되었는지 확인하는 로직이 필수입니다. 예를 들어, 고객 지원 봇에서 "주문 상태를 확인하고, 배송이 지연되면 쿠폰을 발급해줘"같은 조건부 로직도 멀티턴 흐름으로 처리합니다.
기존 단일 턴 방식에서는 한 번의 API 호출로 모든 것을 해결하려 했다면, 멀티턴 패턴은 여러 번의 API 호출을 루프로 관리합니다. 각 호출의 결과가 다음 호출의 입력이 되는 체인 구조입니다.
멀티턴 대화의 핵심 특징은 다섯 가지입니다: 첫째, while 루프로 "함수 호출이 더 필요한가?"를 반복 확인합니다. 둘째, messages 배열에 모든 대화 히스토리를 누적합니다.
셋째, 최대 반복 횟수로 무한 루프를 방지합니다. 넷째, 각 턴마다 상태를 로깅하여 디버깅을 용이하게 합니다.
다섯째, 세션별로 대화 히스토리를 분리하여 여러 사용자를 지원합니다. 이러한 특징들이 중요한 이유는, 복잡한 비즈니스 로직을 안정적으로 처리하기 위함입니다.
코드 예제
def multi_turn_conversation(user_message, session_id, max_iterations=5):
"""멀티턴 대화 흐름을 관리하는 메인 함수"""
# 세션별 대화 히스토리 (실제로는 데이터베이스나 캐시에 저장)
messages = [
{"role": "system", "content": "당신은 수학 계산과 데이터 조회를 도와주는 AI 어시스턴트입니다."},
{"role": "user", "content": user_message}
]
iteration = 0
while iteration < max_iterations:
iteration += 1
print(f"\n=== Turn {iteration} ===")
# API 호출
response = call_chat_completion_with_tools(messages, tools)
if not response:
return "응답 생성 실패"
assistant_message = response.choices[0].message
# 함수 호출이 필요한가?
if not assistant_message.tool_calls:
# 최종 답변 도달
print("최종 답변 생성됨")
return assistant_message.content
# AI의 함수 호출 메시지를 히스토리에 추가
print(f"AI가 {len(assistant_message.tool_calls)}개의 함수 호출 요청")
messages.append({
"role": "assistant",
"content": assistant_message.content,
"tool_calls": [
{
"id": tc.id,
"type": tc.type,
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in assistant_message.tool_calls
]
})
# 함수 실행
tool_results = execute_tool_calls(assistant_message.tool_calls)
print(f"도구 실행 완료: {len(tool_results)}개 결과")
# 결과를 히스토리에 추가
for result in tool_results:
messages.append(result)
# 다음 반복으로 (AI가 결과를 보고 추가 함수 호출 또는 최종 답변 결정)
# 최대 반복 횟수 초과
return "요청이 너무 복잡하여 처리할 수 없습니다. 단계를 나누어 질문해주세요."
# 복잡한 질문 처리 예시
result = multi_turn_conversation(
"367 곱하기 489를 계산하고, 그 결과를 1000으로 나눈 값을 알려줘",
session_id="user123"
)
print(f"\n최종 결과: {result}")
설명
이것이 하는 일: 멀티턴 대화 흐름은 AI 에이전트가 복잡한 작업을 여러 단계로 나누어 처리하도록 하는 제어 구조입니다. 각 단계마다 AI가 "다음에 뭘 해야 하지?"를 결정하고, 필요한 도구를 호출하며, 모든 작업이 완료되면 최종 답변을 생성합니다.
첫 번째로, messages 배열을 초기화합니다. 시스템 메시지로 AI의 역할을 정의하고, 사용자 메시지를 추가합니다.
이 배열은 대화가 진행되면서 계속 확장됩니다. 실무에서는 이 배열을 세션 ID와 연결하여 데이터베이스나 Redis 같은 캐시에 저장하여, 여러 사용자의 대화를 독립적으로 관리합니다.
이렇게 하는 이유는 웹 애플리케이션에서 각 사용자의 컨텍스트를 유지하기 위함입니다. 그 다음으로, while 루프를 시작합니다.
iteration 카운터를 증가시키며 최대 max_iterations번까지 반복합니다. 각 반복에서 API를 호출하고, 응답을 검사합니다.
assistant_message.tool_calls가 없으면(None이거나 빈 배열이면) AI가 "더 이상 함수 호출이 필요 없고, 지금 답변할 수 있어"라고 판단한 것이므로 루프를 종료하고 최종 답변을 반환합니다. 내부적으로 OpenAI 모델은 각 턴마다 "지금 가진 정보로 충분히 답변할 수 있는가?"를 평가합니다.
세 번째로, 함수 호출이 있으면 실행합니다. AI의 함수 호출 메시지를 히스토리에 추가하고, execute_tool_calls로 실제 함수를 실행하며, 결과를 다시 히스토리에 추가합니다.
이제 messages 배열은 [시스템, 사용자, AI 함수 호출, 도구 결과] 형태가 됩니다. 루프가 다시 돌면, 이 전체 히스토리를 포함하여 다시 API를 호출하므로, AI는 이전 단계의 모든 정보를 알고 있는 상태에서 다음 단계를 결정합니다.
마지막으로, 최대 반복 횟수를 초과하면 에러 메시지를 반환합니다. 이것은 안전 장치로, AI가 계속 함수를 호출하며 끝나지 않는 상황을 방지합니다.
예를 들어, 버그나 논리 오류로 인해 AI가 동일한 함수를 계속 호출하거나, 종료 조건을 찾지 못하는 경우를 대비합니다. 실무에서는 5-10회 정도가 적절하며, 더 복잡한 작업은 사용자에게 단계를 나누어 요청하도록 안내합니다.
여러분이 이 코드를 사용하면 매우 복잡한 워크플로우도 자연어로 제어할 수 있습니다. "A를 조회하고, B를 계산하고, C에 저장해줘"같은 멀티스텝 작업을 단일 사용자 메시지로 처리할 수 있습니다.
AI가 각 단계를 자율적으로 계획하고 실행하므로, 개발자는 각 단계를 하드코딩할 필요가 없습니다. 새로운 도구를 추가하면 AI가 자동으로 그 도구를 워크플로우에 통합합니다.
실전 팁
💡 각 반복마다 상태를 로깅하세요. 어느 단계에서 문제가 발생했는지, AI가 어떤 결정을 내렸는지 추적하면 디버깅이 훨씬 쉽습니다.
💡 토큰 사용량을 모니터링하세요. messages 배열이 커질수록 비용이 증가하므로, 오래된 메시지를 요약하거나 제거하는 전략을 고려합니다. 예: 5턴 이상 지나면 초기 메시지를 요약합니다.
💡 특정 함수가 실패하면 재시도 로직을 추가하세요. 네트워크 오류나 일시적인 장애로 함수 실행이 실패할 수 있으므로, 최대 3번까지 재시도합니다.
💡 사용자에게 진행 상황을 알려주세요. 여러 단계가 걸리는 작업일 때 "데이터를 조회하고 있습니다...", "계산 중입니다..." 같은 중간 피드백을 제공하면 사용자 경험이 개선됩니다.
💡 병렬 실행 가능한 함수는 동시에 처리하세요. 독립적인 두 함수 호출이 있다면 asyncio로 동시에 실행하여 전체 응답 시간을 단축합니다.
8. 에러 핸들링 전략
시작하며
여러분이 Function Calling을 프로덕션에 배포할 때 가장 중요한 것은 "에러를 어떻게 처리하지?"입니다. API 호출 실패, 함수 실행 에러, 타임아웃, 잘못된 인자 등 수많은 예외 상황이 발생할 수 있습니다.
개발 단계에서는 잘 작동하던 코드가 실제 사용자 환경에서는 예상치 못한 입력으로 인해 멈출 수 있습니다. 특히 AI 시스템의 에러는 일반 소프트웨어와 다릅니다.
AI가 잘못된 함수를 선택하거나, 인자를 이상하게 생성하거나, 무한 루프에 빠질 수 있습니다. 이런 문제들은 전통적인 try-catch만으로는 충분하지 않습니다.
바로 이럴 때 필요한 것이 포괄적인 에러 핸들링 전략입니다. 각 레이어에서 발생할 수 있는 에러를 식별하고, 우아하게 처리하며, 사용자에게 명확한 피드백을 제공하고, 개발자에게는 디버깅 정보를 남겨야 합니다.
개요
간단히 말해서, 에러 핸들링 전략은 Function Calling 파이프라인의 각 단계에서 발생할 수 있는 예외를 예측하고, 적절히 처리하며, 시스템이 계속 작동하도록 하는 방어적 프로그래밍 패턴입니다. 왜 이 전략이 필요한지 실무 관점에서 설명하면, 에러 처리가 부실하면 사용자는 "오류가 발생했습니다"라는 무의미한 메시지만 보고, 개발자는 무엇이 잘못되었는지 알 수 없습니다.
특히 금융, 의료, 법률처럼 정확성이 중요한 분야에서는 에러가 곧 법적 책임이나 금전적 손실로 이어질 수 있습니다. 예를 들어, 주식 거래 봇에서 함수 실행 에러를 제대로 처리하지 않으면 잘못된 매매가 실행될 수 있습니다.
기존에는 에러가 발생하면 그냥 예외를 던지거나 None을 반환했다면, 이제는 에러 타입별로 다른 전략을 사용합니다. 재시도 가능한 에러(네트워크 오류)는 재시도하고, 사용자 입력 에러는 친절한 메시지로 안내하며, 시스템 에러는 로깅하고 알림을 보냅니다.
에러 핸들링의 핵심 특징은 다섯 가지입니다: 첫째, 레이어별로 에러를 분리합니다(API 에러, 함수 실행 에러, 파싱 에러 등). 둘째, 에러를 분류하여 다르게 처리합니다(재시도 가능, 사용자 에러, 시스템 에러).
셋째, 사용자 친화적인 메시지를 생성합니다. 넷째, 상세한 에러 정보를 로깅합니다.
다섯째, 부분적인 성공도 처리합니다(3개 함수 중 2개 성공). 이러한 특징들이 중요한 이유는, 안정적이고 신뢰할 수 있는 서비스를 제공하기 위함입니다.
코드 예제
import logging
from enum import Enum
from typing import Optional
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class ErrorType(Enum):
"""에러 타입 분류"""
API_ERROR = "api_error" # OpenAI API 호출 실패
PARSING_ERROR = "parsing_error" # 응답 파싱 실패
FUNCTION_ERROR = "function_error" # 함수 실행 실패
TIMEOUT_ERROR = "timeout_error" # 시간 초과
VALIDATION_ERROR = "validation_error" # 입력 검증 실패
def handle_error(error_type: ErrorType, error: Exception, context: dict) -> dict:
"""에러를 타입별로 처리하고 사용자 메시지 생성"""
# 상세 로그 기록 (개발자용)
logger.error(f"{error_type.value}: {str(error)}", extra=context, exc_info=True)
# 사용자 친화적 메시지 생성
if error_type == ErrorType.API_ERROR:
return {
"success": False,
"message": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
"retry": True
}
elif error_type == ErrorType.FUNCTION_ERROR:
return {
"success": False,
"message": f"요청을 처리하는 중 문제가 발생했습니다: {str(error)}",
"retry": False
}
elif error_type == ErrorType.TIMEOUT_ERROR:
return {
"success": False,
"message": "요청 처리 시간이 초과되었습니다. 요청을 단순화해주세요.",
"retry": False
}
elif error_type == ErrorType.VALIDATION_ERROR:
return {
"success": False,
"message": f"입력이 올바르지 않습니다: {str(error)}",
"retry": False
}
else:
return {
"success": False,
"message": "알 수 없는 오류가 발생했습니다.",
"retry": False
}
def safe_api_call(messages, tools, max_retries=3):
"""재시도 로직을 포함한 안전한 API 호출"""
import time
for attempt in range(max_retries):
try:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=tools,
tool_choice="auto",
timeout=30 # 30초 타임아웃
)
return {"success": True, "data": response}
except TimeoutError as e:
logger.warning(f"API timeout, attempt {attempt + 1}/{max_retries}")
if attempt == max_retries - 1:
return handle_error(ErrorType.TIMEOUT_ERROR, e, {"attempt": attempt})
time.sleep(2 ** attempt) # Exponential backoff
except Exception as e:
logger.error(f"API error: {str(e)}")
if "rate_limit" in str(e).lower() and attempt < max_retries - 1:
time.sleep(5)
continue
return handle_error(ErrorType.API_ERROR, e, {"attempt": attempt})
return {"success": False, "message": "최대 재시도 횟수 초과"}
def safe_function_execution(function_name, function_args):
"""안전한 함수 실행 with 타임아웃"""
import signal
def timeout_handler(signum, frame):
raise TimeoutError(f"함수 '{function_name}' 실행 시간 초과")
# 타임아웃 설정 (10초)
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(10)
try:
function_to_call = AVAILABLE_FUNCTIONS.get(function_name)
if not function_to_call:
raise ValueError(f"함수 '{function_name}'을 찾을 수 없습니다")
# 인자 검증
# (실제로는 함수 시그니처와 비교하여 검증)
result = function_to_call(**function_args)
signal.alarm(0) # 타임아웃 취소
return {"success": True, "data": result}
except TimeoutError as e:
signal.alarm(0)
return handle_error(ErrorType.TIMEOUT_ERROR, e, {
"function": function_name,
"args": function_args
})
except Exception as e:
signal.alarm(0)
return handle_error(ErrorType.FUNCTION_ERROR, e, {
"function": function_name,
"args": function_args
})
# 전체 파이프라인에 에러 핸들링 적용
def robust_conversation(user_message):
"""견고한 에러 핸들링이 포함된 대화 함수"""
try:
messages = [
{"role": "system", "content": "당신은 도움을 주는 AI 어시스턴트입니다."},
{"role": "user", "content": user_message}
]
# API 호출
api_result = safe_api_call(messages, tools)
if not api_result["success"]:
return api_result["message"]
response = api_result["data"]
assistant_message = response.choices[0].message
# 함수 호출 처리
if assistant_message.tool_calls:
results = []
for tool_call in assistant_message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
exec_result = safe_function_execution(func_name, func_args)
if exec_result["success"]:
results.append({
"tool_call_id": tool_call.id,
"role": "tool",
"content": json.dumps(exec_result["data"])
})
else:
# 함수 실행 실패 - AI에게 에러 정보 전달
results.append({
"tool_call_id": tool_call.id,
"role": "tool",
"content": json.dumps({
"error": exec_result["message"],
"success": False
})
})
# 결과를 포함하여 최종 응답 생성
# ... (이전과 동일)
return assistant_message.content
except Exception as e:
logger.critical(f"Unexpected error: {str(e)}", exc_info=True)
return "죄송합니다. 예상치 못한 오류가 발생했습니다."
설명
이것이 하는 일: 에러 핸들링 전략은 Function Calling 시스템의 각 레이어에서 발생할 수 있는 모든 종류의 에러를 예상하고, 각각에 대해 적절한 대응을 정의하여, 시스템이 우아하게 실패하고(graceful degradation) 사용자에게는 명확한 피드백을 제공하며 개발자에게는 디버깅 정보를 남기는 포괄적인 방어 메커니즘입니다. 첫 번째로, ErrorType Enum으로 에러를 분류합니다.
이것은 에러의 성격을 명확히 구분하여, 각 타입에 맞는 처리를 할 수 있게 합니다. 예를 들어, API_ERROR는 네트워크 문제일 가능성이 높으므로 재시도가 유효하지만, VALIDATION_ERROR는 사용자 입력 문제이므로 재시도해도 소용없습니다.
이렇게 분류하는 이유는 "모든 에러를 똑같이 처리"하는 것이 비효율적이기 때문입니다. 그 다음으로, handle_error 함수는 에러를 받아 두 가지 일을 합니다: 개발자를 위한 상세 로그를 기록하고, 사용자를 위한 친절한 메시지를 생성합니다.
logger.error는 에러 타입, 메시지, 스택 트레이스, 컨텍스트 정보를 모두 기록하여 나중에 문제를 추적할 수 있게 합니다. 사용자 메시지는 기술적 세부사항을 숨기고 "무엇을 해야 하는지"에 초점을 맞춥니다.
"JSONDecodeError at line 15"보다는 "입력이 올바르지 않습니다"가 훨씬 유용합니다. 세 번째로, safe_api_call 함수는 재시도 로직을 구현합니다.
API 호출이 실패하면 최대 3번까지 재시도하며, exponential backoff(2초, 4초, 8초)를 적용하여 서버 과부하를 방지합니다. rate_limit 에러는 특별히 처리하여 5초 대기 후 재시도합니다.
timeout 매개변수로 30초 이상 응답이 없으면 타임아웃으로 처리합니다. 내부적으로 이것은 일시적인 네트워크 문제나 서버 장애에 대한 복원력(resilience)을 제공합니다.
네 번째로, safe_function_execution 함수는 함수 실행에 타임아웃을 적용합니다. signal 모듈을 사용하여 10초 이상 걸리는 함수 실행을 강제로 중단합니다.
이것은 무한 루프나 매우 느린 외부 API 호출로 인해 시스템 전체가 멈추는 것을 방지합니다. 함수를 찾을 수 없거나, 인자가 잘못되었거나, 실행 중 예외가 발생하면 모두 잡아서 에러 정보를 반환합니다.
마지막으로, robust_conversation 함수는 전체 파이프라인을 에러 핸들링으로 감쌉니다. 각 단계(API 호출, 함수 실행, 최종 응답)에서 에러를 체크하고, 실패 시에도 부분적인 결과는 유지합니다.
예를 들어, 3개 함수 중 1개가 실패해도 나머지 2개의 결과는 AI에게 전달하여 가능한 한 유용한 답변을 생성하도록 합니다. 최상위 try-except는 예상치 못한 모든 에러를 잡아 시스템이 완전히 멈추지 않도록 보장합니다.
여러분이 이 코드를 사용하면 프로덕션 환경에서 안정적으로 작동하는 AI 시스템을 만들 수 있습니다. 사용자는 에러가 발생해도 명확한 안내를 받고, 개발자는 로그를 통해 문제를 빠르게 파악하며, 일시적인 장애는 자동으로 복구됩니다.
에러 발생률, 재시도 성공률, 타임아웃 빈도 등을 모니터링하여 시스템을 지속적으로 개선할 수 있습니다.
실전 팁
💡 구조화된 로깅을 사용하세요. JSON 형식으로 로그를 남기면 나중에 ElasticSearch나 Splunk 같은 도구로 분석하기 쉽습니다. 에러 타입, 사용자 ID, 요청 ID, 타임스탬프 등을 포함하세요.
💡 에러 알림을 설정하세요. 중요한 에러(CRITICAL 레벨)는 Slack, PagerDuty, 이메일 등으로 즉시 알림을 받아 빠르게 대응합니다.
💡 에러율을 모니터링하세요. 전체 요청 대비 에러 비율이 5%를 넘으면 경고, 10%를 넘으면 알림을 보내는 등 임계값을 설정합니다.
💡 사용자에게 지원 옵션을 제공하세요. "문제가 계속되면 support@example.com으로 연락하세요" 같은 안내를 포함하여 사용자가 막히지 않도록 합니다.
💡 에러 응답도 AI에게 전달하세요. 함수 실행이 실패하더라도 에러 정보를 AI에게 주면, AI가 "계산 기능에 일시적인 문제가 있지만, 대략적인 값은 180,000 정도입니다"처럼 대안을 제시할 수 있습니다.