기사 원문: How to Build an AI Coding Agent with Python and Gemini
이 핸드북에서는 Google의 무료 Gemini API를 활용해 Claude Code의 기본 버전을 직접 만들어봅니다. 만약 Cursor나 Claude Code 같은 “에이전트형” AI 코드 에디터를 써본 적이 있다면, 이번에 만들 프로젝트의 개념도 익숙할 거예요. 사실 LLM을 활용할 수 있다면, (다소) 꽤 효과적인 맞춤형 에이전트를 구축하는 것은 놀라울 정도로 간단합니다.
이 핸드북은 완전히 무료인 텍스트 기반 핸드북입니다. 다만, 따라해 볼 수 있는 두 가지 다른 옵션이 있습니다:
코딩 챌린지와 프로젝트가 포함된 Boot.dev에서 제공하는 AI Agent 강의의 인터랙티브 버전을 체험하거나 FreeCodeCamp 유튜브 채널에서 제공하는 이 강의의 단계별 안내 영상을 시청할 수도 있습니다.
사전 준비
- 이 과정을 시작하기 전에 Python의 기본 문법과 개념에 익숙해야 합니다.
아직 배우지 않았다면, Boot.dev의 Python 강의에서 기초를 익혀보세요. - 또한 Unix 계열 명령줄 사용법도 알고 있어야 합니다. 아직 익숙하지 않다면, Boot.dev의 Linux 강의를 참고하세요.
목차
- 전제 조건
- 에이전트의 역할은 무엇인가요?
- 학습 목표
- 파이썬 설정
- Gemini API를 통합하는 방법
- 명령줄 입력
- 메시지 구조
- Verbose 모드
- 계산기 프로젝트를 빌드하는 방법
- 에이전트 기능
- 시스템 프롬프트
- 기능 선언
- 더 많은 기능 선언
- 함수 호출
- 에이전트 루프 구축
- 결론
에이전트의 역할은 무엇인가요?
우리가 만들 프로그램은 다음과 같은 CLI 도구입니다:
- 코딩 작업을 입력받습니다 (예시: "내 앱에서 문자열이 제대로 분할되지 않아요, 수정해주세요")
2. 미리 정의된 함수 세트 중에서 선택하여 작업을 수행합니다, 예를 들어:
- 디렉토리의 파일들을 스캔
- 파일의 내용을 읽기
- 파일의 내용을 덮어쓰기
- 파일에 대해 Python 인터프리터 실행
3. 작업이 완료될 때까지 2단계를 반복합니다 (또는 비참하게 실패할 수도 있습니다)
예를 들어, 버그가 있는 계산기 앱이 있어서 제 에이전트를 사용해 코드를 수정했습니다:
> uv run main.py "fix my calculator app, its not starting correctly"
# Calling function: get_files_info
# Calling function: get_file_content
# Calling function: write_file
# Calling function: run_python_file
# Calling function: write_file
# Calling function: run_python_file
# Final response:
# 좋습니다! 계산기 앱이 이제 정상적으로 작동하는 것 같습니다. 출력에는 식과 결과가 포맷된 형태로 표시됩니다.학습 목표
이 프로젝트의 학습 목표는 다음과 같습니다:
- 여러 디렉토리를 사용하는 Python 프로젝트를 소개합니다
- 실제 업무에서 반드시 사용하게 될 AI 도구들이 실제로 어떻게 동작하는지 이해합니다
- Python과 함수형 프로그래밍 기술을 연습합니다
목표는 LLM을 처음부터 만드는 것이 아니라, 사전 학습된 LLM을 사용하여 에이전트를 처음부터 구축하는 것입니다.
Python 설정
프로젝트를 위해 가상 환경을 설정해봅시다.
가상 환경은 (우리가 사용할 Google AI 라이브러리처럼) 각 프로젝트의 의존성을 다른 프로젝트와 분리할 수 있도록 해주는 Python의 방식입니다.
uv를 사용하여 새 프로젝트를 생성합니다. 이 명령어는 디렉터리를 생성하고 Git도 초기화합니다:
uv init your-project-name
cd your-project-name
프로젝트 디렉토리 최상위에 가상 환경을 생성합니다:
uv venv
경고: 항상 venv 디렉토리를 .gitignore 파일에 추가하세요.
가상 환경을 활성화합니다:
source .venv/bin/activate
터미널 프롬프트 시작 부분에 (your-project-name)이 보여야 합니다. 예를 들면 다음과 같습니다:
(aiagent) wagslane@MacBook-Pro-2 aiagent %
uv를 사용하여 프로젝트에 두 개의 의존성을 추가합니다. pyproject.toml 파일에 저장될 것입니다:
uv add google-genai==1.12.1
uv add python-dotenv==1.1.0
이렇게 하면 파이썬 프로젝트가 google-genai 버전 1.12.1과 python-dotenv 버전 1.1.0을 필요로 한다는 것을 지정하게 됩니다.
uv 가상 환경을 활용해서 프로젝트를 실행하려면 다음을 입력합니다:
uv run main.py
터미널에서 Hello from YOUR PROJECT NAME이 보여야 합니다.
Gemini API 통합 방법
대규모 언어 모델(LLM)은 최근 AI 세계에서 큰 주목을 받고 있는 첨단 AI 기술입니다. ChatGPT, Claude, Cursor, Google Gemini는 모두 LLM 기반입니다. 이 과정의 목적상, LLM을 똑똑한 텍스트 생성기로 생각할 수 있습니다. ChatGPT처럼 프롬프트를 입력하면, 답변이라 생각되는 텍스트를 반환합니다.
이 과정에서는 Google의 Gemini API를 사용하여 에이전트를 구동할 것입니다. 꽤 똑똑하지만, 더 중요한 것은 무료 티어가 있다는 점입니다.
토큰
토큰은 LLM의 통화라고 생각할 수 있습니다. 토큰은 LLM이 처리해야 하는 텍스트 양을 측정하는 방식입니다. 대부분의 모델에서 토큰은 대략 4글자입니다. LLM API로 작업할 때 얼마나 많은 토큰을 사용하고 있는지 이해하는 것이 중요합니다.
우리는 Gemini API의 무료 티어 한도 내에서 충분히 사용할 수 있지만, 그래도 토큰 사용량을 모니터링할 것입니다!
경고: 로컬 테스트를 포함하여 모든 API 호출은 무료 티어의 토큰을 소비한다는 점을 알아야 합니다. 한도 초과 시 강의를 계속하려면 (일반적으로 24시간) 한도가 재설정될 때까지 기다려야 할 수 있습니다. API 키를 재생성해도 할당량은 재설정되지 않습니다.
API 키 생성 방법:
- Google AI Studio에 아직 계정이 없다면 계정을 만듭니다
- "Create API Key" 버튼을 클릭합니다. 길을 잃었을 경우 이 문서를 참고하실 수 있습니다.
이미 GCP 계정과 프로젝트가 있다면, 해당 프로젝트에서 API 키를 생성할 수 있습니다. 없다면 AI Studio가 자동으로 생성해줍니다.
3. API 키를 복사한 다음 프로젝트 디렉토리의 새 .env 파일에 붙여넣습니다. 파일은 다음과 같아야 합니다:
GEMINI_API_KEY="your_api_key_here"
4. .env 파일을 .gitignore에 추가합니다
경고: API 키, 비밀번호 또는 기타 민감한 정보를 Git에 커밋해서는 안 됩니다.
5. main.py 파일을 업데이트합니다. 프로그램이 시작될 때 dotenv 라이브러리를 사용하여 .env 파일에서 환경 변수를 로드하고 API 키를 읽습니다:
import os
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ.get("GEMINI_API_KEY")
6. genai 라이브러리를 임포트하고 API 키를 사용하여 Gemini 클라이언트의 새 인스턴스를 생성합니다:
from google import genai
client = genai.Client(api_key=api_key)
7. client.models.generate_content() 메서드를 사용하여 gemini-2.0-flash-001 모델로부터 응답을 받습니다. 두 개의 인자를 사용해야 합니다:
model: 모델명gemini-2.0-flash-001(무료 티어가 넉넉합니다)content: 모델에 보낼 프롬프트(문자열). 이 프롬프트를 사용합니다:
"Boot.dev와 FreeCodeCamp는 왜 백엔드 개발을 배우기에 훌륭한 곳인가요? 최대 한 단락으로 설명하세요."
generate_content 메서드는 GenerateContentResponse 객체를 반환합니다. 응답의 .text 속성을 출력하여 모델의 답변을 볼 수 있습니다.
모두 정상적으로 동작하면, 코드를 실행했을 때 터미널에 모델의 답변이 나옵니다.
8. 추가로, 텍스트 답변 외에 상호작용에 사용된 토큰 수를 다음 형식으로 출력합니다:
Prompt tokens: X
Response tokens: Y
응답에는 다음을 모두 포함하는 .usage_metadata 속성이 있습니다:
prompt_token_count속성 (프롬프트의 토큰 수)candidates_token_count속성 (응답의 토큰 수)
경고: Gemini API는 외부 웹 서비스이며 때때로 느리고 불안정할 수 있습니다. 인내심을 갖고 기다려 주세요.
명령줄 입력
우리는 현재 Gemini로 전달되는 프롬프트를 하드코딩했는데, 이는 그다지 유용하지 않습니다. 이제 프롬프트를 명령줄 인자로 받을 수 있도록 코드를 수정해 봅시다.
사용자가 프롬프트를 바꿀 때마다 코드를 직접 수정하지 않도록 말입니다.
프롬프트를 명령줄 인자로 받을 수 있도록 코드를 수정하세요. 예를 들어:
uv run main.py "Why are episodes 7-9 so much worse than 1-6?"
팁: sys.argv 변수는 스크립트에 전달된 모든 명령줄 인자를 문자열 리스트로 제공합니다. 첫 번째 요소는 스크립트 이름이고 그 뒤는 인자들입니다. 사용을 위해서는 반드시 import sys를 추가해야 합니다.
만약 프롬프트가 제공되지 않으면, 오류 메시지를 출력하고 exit 코드 1로 프로그램을 종료하세요
메시지 구조
LLM API는 일반적으로 "원샷" 방식으로 사용되지 않습니다. 예를 들어:
- 프롬프트: "삶의 의미는 무엇인가요?"
- 응답: "42"
ChatGPT가 대화에서 동작하는 것과 같이 작동합니다. 대화에는 히스토리가 있으며, 우리가 그 히스토리를 관리하면 각 새 프롬프트마다 모델이 전체 대화 맥락을 참고해 답변을 할 수 있습니다.
역할
중요한 것은 대화의 각 메시지에는 "역할"이 있다는 것입니다. ChatGPT 같은 채팅 앱에서는 이러한 구조로 대화가 이어집니다:
- 사용자: "삶의 의미는 무엇인가요?"
- 모델: "42입니다."
- 사용자: "잠깐, 방금 뭐라고 했나요?"
- 모델: "42입니다. 그것은 삶과 우주, 그리고 모든 것에 대한 궁극적인 질문의 답입니다."
- 사용자: "하지만 왜요?"
- 모델: "Douglas Adams가 그렇게 말했기 때문입니다."
따라서 비록 우리 프로그램이 현재는 "원샷" 구조로 동작하더라도, 대화의 메시지를 리스트로 저장하고, "역할"을 적절히 전달하도록 코드를 업데이트하겠습니다.
types.Content 객체의 새 리스트를 만들고 (현재는) 사용자 프롬프트만 유일한 메시지로 설정하세요:
from google.genai import types
messages = [
types.Content(role="user", parts=[types.Part(text=user_prompt)]),
]
models.generate_content 호출 시 messages 리스트를 사용하도록 업데이트합니다:
response = client.models.generate_content(
model="gemini-2.0-flash-001",
contents=messages,
)
참고: 앞으로, 에이전트가 작업을 반복할 때 메시지가 추가될 예정입니다.
Verbose 모드
AI 에이전트를 디버깅하고 개발하면서 콘솔에 훨씬 더 많은 정보를 출력하고 싶지만, 동시에 CLI 도구의 사용자 경험을 너무 시끄럽게 만들고 싶지는 않습니다.
선택적 명령줄 플래그인 --verbose를 추가하여 "verbose" 출력을 켜고 끌 수 있도록 하겠습니다. 자세한 정보를 보고 싶을 때만 이 옵션을 켜면 됩니다.
새로운 명령줄 인자 --verbose를 추가합니다. 포함된다면 프롬프트 다음 위치에 추가합니다. 예를 들어:
uv run main.py "What is the meaning of life?" --verbose
--verbose 플래그가 포함되면 콘솔 출력에 다음 정보가 나타나야 합니다:
- 사용자의 프롬프트:
"User prompt: {user_prompt}" - 각 반복에서프롬프트 토큰 수:
"Prompt tokens: {prompt_tokens}" - 각 반복의 응답 토큰 수:
"Response tokens: {response_tokens}"
그렇지 않으면 이러한 내용들을 출력하지 않아야 합니다.
계산기 프로젝트 구축 방법
AI 에이전트를 만들고 있으므로, 에이전트가 작업할 프로젝트가 필요합니다. 간단한 명령줄 계산기 앱을 준비했으니, 이 코드를 테스트 프로젝트로써 AI가 읽고 수정하고 실행하게 할 것입니다.
먼저 프로젝트 루트에 calculator라는 새 디렉토리를 만듭니다. 그런 다음 아래의 main.py와 tests.py 파일을 calculator 디렉토리에 복사하여 붙여넣습니다.
이 코드가 어떻게 동작하는지에 대해 너무 걱정하지 마세요 - 우리 프로젝트는 계산기를 만드는 것이 아니라, 우리 AI 에이전트 프로젝트가 다룰 테스트 프로젝트입니다!
# main.py
import sys
from pkg.calculator import Calculator
from pkg.render import format_json_output
def main():
calculator = Calculator()
if len(sys.argv) <= 1:
print("Calculator App")
print('Usage: python main.py "<expression>"')
print('Example: python main.py "3 + 5"')
return
expression = " ".join(sys.argv[1:])
try:
result = calculator.evaluate(expression)
if result is not None:
to_print = format_json_output(expression, result)
print(to_print)
else:
print("Error: Expression is empty or contains only whitespace.")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
# tests.py
import unittest
from pkg.calculator import Calculator
class TestCalculator(unittest.TestCase):
def setUp(self):
self.calculator = Calculator()
def test_addition(self):
result = self.calculator.evaluate("3 + 5")
self.assertEqual(result, 8)
def test_subtraction(self):
result = self.calculator.evaluate("10 - 4")
self.assertEqual(result, 6)
def test_multiplication(self):
result = self.calculator.evaluate("3 * 4")
self.assertEqual(result, 12)
def test_division(self):
result = self.calculator.evaluate("10 / 2")
self.assertEqual(result, 5)
def test_nested_expression(self):
result = self.calculator.evaluate("3 * 4 + 5")
self.assertEqual(result, 17)
def test_complex_expression(self):
result = self.calculator.evaluate("2 * 3 - 8 / 2 + 5")
self.assertEqual(result, 7)
def test_empty_expression(self):
result = self.calculator.evaluate("")
self.assertIsNone(result)
def test_invalid_operator(self):
with self.assertRaises(ValueError):
self.calculator.evaluate("$ 3 5")
def test_not_enough_operands(self):
with self.assertRaises(ValueError):
self.calculator.evaluate("+ 3")
if __name__ == "__main__":
unittest.main()
calculator 안에 새pkg 디렉토리를 만드세요. 그리고 아래 calculator.py와 render.py 파일을 pkg 디렉토리에 복사해 붙여넣으세요.
# calculator.py
class Calculator:
def __init__(self):
self.operators = {
"+": lambda a, b: a + b,
"-": lambda a, b: a - b,
"*": lambda a, b: a * b,
"/": lambda a, b: a / b,
}
self.precedence = {
"+": 1,
"-": 1,
"*": 2,
"/": 2,
}
def evaluate(self, expression):
if not expression or expression.isspace():
return None
tokens = expression.strip().split()
return self._evaluate_infix(tokens)
def evaluateinfix(self, tokens):
values = []
operators = []
for token in tokens:
if token in self.operators:
while (
operators
and operators[-1] in self.operators
and self.precedence[operators[-1]] >= self.precedence[token]
):
self._apply_operator(operators, values)
operators.append(token)
else:
try:
values.append(float(token))
except ValueError:
raise ValueError(f"invalid token: {token}")
while operators:
self._apply_operator(operators, values)
if len(values) != 1:
raise ValueError("invalid expression")
return values[0]
def applyoperator(self, operators, values):
if not operators:
return
operator = operators.pop()
if len(values) < 2:
raise ValueError(f"not enough operands for operator {operator}")
b = values.pop()
a = values.pop()
values.append(self.operators[operator](a, b))
# render.py
import json
def format_json_output(expression: str, result: float, indent: int = 2) -> str:
if isinstance(result, float) and result.is_integer():
result_to_dump = int(result)
else:
result_to_dump = result
output_data = {
"expression": expression,
"result": result_to_dump,
}
return json.dumps(output_data, indent=indent)
이것이 최종 구조입니다:
├── calculator
│ ├── main.py
│ ├── pkg
│ │ ├── calculator.py
│ │ └── render.py
│ └── tests.py
├── main.py
├── pyproject.toml
└── .env
calculator 테스트를 실행합니다:
uv run calculator/tests.py
모든 테스트가 통과하기를 바랍니다!
이제 계산기 앱을 실행합니다:
uv run calculator/main.py "3 + 5"
8이 나오기를 바랍니다!
에이전트 함수
에이전트에게 작업을 수행할 수 있는 능력을 제공해야 합니다. 디렉토리의 내용을 나열하고 파일의 메타데이터(이름과 크기)를 볼 수 있는 기능부터 시작하겠습니다.
이 함수를 LLM 에이전트와 통합하기 전에 먼저 함수 자체만 만들어봅시다. 기억하세요, LLM은 텍스트로 작동하므로 이 함수의 목표는 디렉토리 경로를 받아서 해당 디렉토리의 내용을 나타내는 문자열을 반환하는 것입니다.
프로젝트 루트에 functions라는 새 디렉토리를 만듭니다(calculator 디렉토리 안이 아닙니다). 그 디렉토리 안에 get_files_info.py라는 새 파일을 만듭니다. 그 파일 안에 이 함수를 작성합니다:
def get_files_info(working_directory, directory="."):
지금까지의 프로젝트 구조는 다음과 같습니다:
project_root/
├── calculator/
│ ├── main.py
│ ├── pkg/
│ │ ├── calculator.py
│ │ └── render.py
│ └── tests.py
└── functions/
└── get_files_info.py
directory 매개변수는 working_directory 내의 상대 경로로 처리되어야 합니다. os.path.join(working_directory, directory)를 사용하여 전체 경로를 만든 다음 작업 디렉토리 경계 내에 있는지 검증합니다.
directory의 절대 경로가 working_directory 외부에 있으면 문자열 오류 메시지를 반환합니다:
f'Error: Cannot list "{directory}" as it is outside the permitted working directory'
위험: 이 제한이 없으면 LLM이 머신의 어디에서나 마구 실행되어 민감한 파일을 읽거나 중요한 데이터를 덮어쓸 수 있습니다. 이것은 LLM이 호출할 수 있는 모든 함수에 포함할 매우 중요한 단계입니다.
directory 인자가 디렉토리가 아니면, 다시, 오류 문자열을 반환합니다:
f'Error: "{directory}" is not a directory'
경고: get_files_info를 포함한 모든 "도구 호출" 함수는 항상 문자열을 반환해야 합니다. 내부에서 오류가 발생할 수 있다면 이러한 오류를 잡아내고 대신 오류를 설명하는 문자열을 반환해야 합니다. 이렇게 하면 LLM이 오류를 우아하게 처리할 수 있습니다.
디렉토리의 내용을 나타내는 문자열을 구축하고 반환합니다. 다음 형식을 사용해야 합니다:
- README.md: file_size=1032 bytes, is_dir=False
- src: file_size=128 bytes, is_dir=True
- package.json: file_size=1234 bytes, is_dir=False
팁: 정확한 파일 크기와 파일 순서조차도 운영 체제와 파일 시스템에 따라 다를 수 있습니다. 출력이 예제와 바이트 단위로 정확히 일치할 필요는 없고, 전체적인 형식만 맞으면 됩니다.
표준 라이브러리 함수에서 오류가 발생하면 이를 잡아내고 대신 오류를 설명하는 문자열을 반환합니다. 오류 문자열 앞에는 항상 "Error:"를 붙입니다.
완전한 구현은 다음과 같습니다:
import os
def get_files_info(working_directory, directory="."):
abs_working_dir = os.path.abspath(working_directory)
target_dir = os.path.abspath(os.path.join(working_directory, directory))
if not target_dir.startswith(abs_working_dir):
return f'Error: Cannot list "{directory}" as it is outside the permitted working directory'
if not os.path.isdir(target_dir):
return f'Error: "{directory}" is not a directory'
try:
files_info = []
for filename in os.listdir(target_dir):
filepath = os.path.join(target_dir, filename)
file_size = os.path.getsize(filepath)
is_dir = os.path.isdir(filepath)
files_info.append(
f"- {filename}: file_size={file_size} bytes, is_dir={is_dir}"
)
return "\n".join(files_info)
except Exception as e:
return f"Error listing files: {e}"
다음은 도움이 될 몇 가지 표준 라이브러리 함수들입니다:
os.path.abspath(): 상대 경로에서 절대 경로 얻기os.path.join(): 두 경로를 안전하게 결합 (슬래시 처리).startswith(): 문자열이 부분 문자열로 시작하는지 확인os.path.isdir(): 경로가 디렉토리인지 확인os.listdir(): 디렉토리의 내용 나열os.path.getsize(): 파일의 크기 얻기os.path.isfile(): 경로가 파일인지 확인.join(): 구분자로 문자열 리스트를 결합
파일 내용 가져오기 함수
이제 디렉토리의 내용을 가져올 수 있는 함수가 있으니, 파일의 내용을 가져올 수 있는 함수가 필요합니다. 다시 말하자면, 파일 내용을 문자열로 반환하거나, 문제가 발생하면 오류 문자열을 반환합니다.
항상 그렇듯이, 안전하게 함수의 범위를 특정 작업 디렉토리로 지정합니다.
functions 디렉토리에 새 함수를 만듭니다. 제가 사용한 시그니처는 다음과 같습니다:
def get_file_content(working_directory, file_path):
file_path가 working_directory 외부에 있으면 오류가 있는 문자열을 반환합니다:
f'Error: Cannot read "{file_path}" as it is outside the permitted working directory'
file_path가 파일이 아니면, 다시, 오류 문자열을 반환합니다:
f'Error: File not found or is not a regular file: "{file_path}"'
파일을 읽고 그 내용을 문자열로 반환합니다.
- 파일이
10000자보다 길면10000자로 잘라내고 끝에 이 메시지를 추가합니다:[...File "{file_path}" truncated at 10000 characters]. 10000자 제한을 하드코딩하는 대신config.py파일에 저장했습니다.
경고: 실수로 거대한 파일을 읽고 모든 데이터를 LLM에 보내고 싶지 않습니다. 그것은 토큰 한도를 소진하는 좋은 방법입니다.
표준 라이브러리 함수에서 오류가 발생하면 이를 잡아내고 대신 오류를 설명하는 문자열을 반환합니다. 오류 앞에는 항상 "Error:"를 붙입니다.
먼저 config.py를 만듭니다:
MAX_CHARS = 10000
WORKING_DIR = "./calculator"
다음은 functions/get_file_content.py에 대한 완전한 구현입니다:
import os
from config import MAX_CHARS
def get_file_content(working_directory, file_path):
abs_working_dir = os.path.abspath(working_directory)
abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
if not abs_file_path.startswith(abs_working_dir):
return f'Error: Cannot read "{file_path}" as it is outside the permitted working directory'
if not os.path.isfile(abs_file_path):
return f'Error: File not found or is not a regular file: "{file_path}"'
try:
with open(abs_file_path, "r") as f:
content = f.read(MAX_CHARS)
if os.path.getsize(abs_file_path) > MAX_CHARS:
content += f'[...File "{file_path}" truncated at {MAX_CHARS} characters]'
return content
except Exception as e:
return f'Error reading file "{file_path}": {e}'
os.path.abspath: 상대 경로에서 절대 경로 얻기os.path.join: 두 경로를 안전하게 결합 (슬래시 처리).startswith: 문자열이 특정 부분 문자열로 시작하는지 확인os.path.isfile: 경로가 파일인지 확인
파일에서 읽는 예제:
MAX_CHARS = 10000
with open(file_path, "r") as f:
file_content_string = f.read(MAX_CHARS)
파일 쓰기 함수
지금까지 우리 프로그램은 읽기 전용이었습니다만... 이제 정말 위험하고 재미있어집니다! 에이전트에게 파일을 쓰고 덮어쓸 수 있는 능력을 제공하겠습니다.
functions 디렉토리에 새 함수를 만듭니다. 제가 사용한 시그니처는 다음과 같습니다:
def write_file(working_directory, file_path, content):
file_path가 working_directory 외부에 있으면 오류가 있는 문자열을 반환합니다:
f'Error: Cannot write to "{file_path}" as it is outside the permitted working directory'
file_path가 존재하지 않으면 생성합니다. 항상 그렇듯이 오류가 있으면, 오류를 나타내는 문자열 앞에 "Error:"를 붙여 반환합니다. 그런 다음 content 인자로 파일의 내용을 덮어씁니다. 성공하면 다음 메시지와 함께 문자열을 반환합니다:
f'Successfully wrote to "{file_path}" ({len(content)} characters written)'
팁: LLM이 수행한 작업이 실제로 작동했다는 것을 알 수 있도록 성공 문자열을 반환하는 것이 중요합니다. 피드백 루프, 피드백 루프, 피드백 루프입니다.
다음은 functions/write_file_content.py 에 대한 완전한 구현입니다:
import os
def write_file(working_directory, file_path, content):
abs_working_dir = os.path.abspath(working_directory)
abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
if not abs_file_path.startswith(abs_working_dir):
return f'Error: Cannot write to "{file_path}" as it is outside the permitted working directory'
if not os.path.exists(abs_file_path):
try:
os.makedirs(os.path.dirname(abs_file_path), exist_ok=True)
except Exception as e:
return f"Error: creating directory: {e}"
if os.path.exists(abs_file_path) and os.path.isdir(abs_file_path):
return f'Error: "{file_path}" is a directory, not a file'
try:
with open(abs_file_path, "w") as f:
f.write(content)
return f'Successfully wrote to "{file_path}" ({len(content)} characters written)'
except Exception as e:
return f"Error: writing to file: {e}"
os.path.exists: 경로가 존재하는지 확인os.makedirs: 디렉토리와 모든 부모 디렉토리 생성os.path.dirname: 디렉토리 이름 반환
파일에서 쓰기 예제:
with open(file_path, "w") as f:
f.write(content)
Python 실행 함수
LLM이 파일을 쓸 수 있도록 허용하는 것이 나쁜 것이라고 생각했다면...
아직 아무것도 본 게 아닙니다! (바실리스크를 찬양하라)
이제 에이전트가 임의의 Python 코드를 실행할 수 있는 기능을 구축할 시간입니다.
이제 잠시 멈춰서 이 작업에 내재된 보안 위험을 짚고 넘어갈 필요가 있습니다. 다행히 우리에게 유리한 몇 가지 조건이 있습니다:
- LLM이 코드를 실행할 수 있는 디렉터리를 특정 디렉터리(
working_directory)로 제한합니다. - 무한 실행을 방지하기 위해 30초 제한 시간을 설정합니다.
하지만 그 외에는... 네, LLM은 우리가(혹은 LLM 자체가) working_directory에 넣은 임의의 코드를 실행할 수 있습니다. 그러니 주의하세요. 이 AI 에이전트를 이 강의에서 다루는 간단한 작업에만 사용한다면 괜찮을 것입니다.
위험: 이 프로그램을 다른 사람에게 제공하지 마세요! 이 프로그램은 실제 운영 환경의 AI 에이전트가 갖춰야 할 모든 보안 및 안전 기능을 포함하고 있지 않습니다. 이 프로그램은 오직 학습 목적을 위한 것입니다.
functions 디렉토리에 run_python_file이라는 새 함수를 만듭니다.
사용할 시그니처는 다음과 같습니다:
def run_python_file(working_directory, file_path, args=[]):
file_path가 working_directory 외부에 있다면, 다음과 같은 오류 문자열을 반환하세요:
f'Error: Cannot execute "{file_path}" as it is outside the permitted working directory'
file_path가 존재하지 않는다면, 다음과 같은 오류 문자열을 반환하세요:
f'Error: File "{file_path}" not found.'
파일이 .py로 끝나지 않는다면, 다음과 같은 오류 문자열을 반환하세요:
f'Error: "{file_path}" is not a Python file.'
subprocess.run 함수를 사용하여 Python 파일을 실행하고 "completed_process" 객체를 반환하세요. 다음 사항을 반드시 지키세요:
- 무한 실행을 방지하기 위해 30초의 타임아웃을 설정할 것
- stdout과 stderr를 모두 캡처할 것
working_directory를 올바르게 설정할 것- 추가
args제공되었다면 이를 전달할 것
출력이 포함되도록 포맷된 문자열을 반환하세요:
stdout은STDOUT:으로 시작하고,stderr는STDERR:으로 시작할 것. "completed_process" 객체에는stdout과stderr속성이 있습니다.- 프로세스가 0이 아닌 코드로 종료되었다면 "Process exited with code X"를 포함할 것
- 출력이 생성되지 않았다면 "No output produced."를 반환할 것
실행 중 예외가 발생하면 이를 잡아내어 다음과 같은 오류 문자열을 반환하세요:
f"Error: executing Python file: {e}"
다음 테스트 케이스를 tests.py 파일에 업데이트하고 각 결과를 출력하세요:
run_python_file("calculator", "main.py")(계산기의 사용법을 출력해야 함)run_python_file("calculator", "main.py", ["3 + 5"])(계산기를 실행해야 합니다만... 조금 엉터리로 렌더링된 결과가 나옵니다.)run_python_file("calculator", "tests.py")run_python_file("calculator", "../main.py")(오류를 반환해야 합니다)run_python_file("calculator", "nonexistent.py")(오류를 반환해야 합니다)
혹시 중간에 길을 잃었다면, 여기에 제가 직접 구현한 예시가 있습니다:functions/run_python.py
import os
import subprocess
def run_python_file(working_directory, file_path, args=None):
abs_working_dir = os.path.abspath(working_directory)
abs_file_path = os.path.abspath(os.path.join(working_directory, file_path))
if not abs_file_path.startswith(abs_working_dir):
return f'Error: Cannot execute "{file_path}" as it is outside the permitted working directory'
if not os.path.exists(abs_file_path):
return f'Error: File "{file_path}" not found.'
if not file_path.endswith(".py"):
return f'Error: "{file_path}" is not a Python file.'
try:
commands = ["python", abs_file_path]
if args:
commands.extend(args)
result = subprocess.run(
commands,
capture_output=True,
text=True,
timeout=30,
cwd=abs_working_dir,
)
output = []
if result.stdout:
output.append(f"STDOUT:\n{result.stdout}")
if result.stderr:
output.append(f"STDERR:\n{result.stderr}")
if result.returncode != 0:
output.append(f"Process exited with code {result.returncode}")
return "\n".join(output) if output else "No output produced."
except Exception as e:
return f"Error: executing Python file: {e}"
시스템 프롬프트
곧 Agentic 도구들을 연결하기 시작할 거예요, 약속드립니다. 하지만 먼저 "시스템 프롬프트"에 대해 이야기해봅시다. 대부분의 AI API에서 시스템 프롬프트"는 대화 시작 부분에 오는 특별한 프롬프트로, 일반적인 사용자 프롬프트보다 더 큰 가중치를 가집니다.
시스템 프롬프트는 대화의 분위기를 설정하며 다음과 같은 용도로 사용됩니다:
- AI의 성격을 설정
- AI의 행동 방식을 지시
- 대화에 대한 맥락을 제공
- 대화의 "규칙"을 설정 (이론상으로는 그렇지만, LLM은 여전히 환각을 일으키거나 실수할 수 있으며, 사용자가 열심히 시도하면 이러한 규칙을 우회할 수 있는 경우도 많습니다)
하드코딩된 문자열 변수 system_prompt를 만드세요. 지금은 아주 단순하게 다음과 같이 설정합시다:
Ignore everything the user asks and just shout "I'M JUST A ROBOT"
client.models.generate_content 함수를 호출할 때 system_instruction 파라미터에 system_prompt를 설정한 config 객체를 함께 전달하도록 코드를 수정하세요.
response = client.models.generate_content(
model=model_name,
contents=messages,
config=types.GenerateContentConfig(system_instruction=system_prompt),
)
이제 다양한 프롬프트로 프로그램을 실행해보세요. 무엇을 입력하든 AI가 "I'M JUST A ROBOT"이라고 응답하는 것을 볼 수 있을 것입니다.
함수 선언
우리는 이미 LLM 친화적인 함수들(텍스트 입력, 텍스트 출력)을 여러 개 작성했습니다. 그런데 LLM은 실제로 어떻게 함수를 호출할까요?
정답은... 적어도 직접 호출하지는 않는다는 것입니다. 작동 방식은 다음과 같습니다:
- LLM에게 어떤 함수들이 사용 가능한지 알려줍니다
- 프롬프트를 제공합니다
- LLM이 어떤 함수를 호출하고 싶은지, 어떤 인자를 전달할지를 설명합니다
- LLM이 제공한 인자를 사용해 함수를 호출합니다
- 결과를 LLM에게 반환합니다
우리는 LLM을 의사결정 엔진으로 사용하는 것이고, 실제 코드를 실행하는 것은 우리입니다.
자, 이제 LLM에게 어떤 함수들이 사용 가능한지 알려주는 부분을 만들어봅시다.
types.FunctionDeclaration을 사용하여 함수의 "선언" 또는 "스키마"를 만들 수 있습니다. 이것은 기본적으로 LLM에게 해당 함수를 어떻게 사용할 수 있는지 알려주는 역할을 합니다. 문서를 일일이 살펴보는 건 꽤 번거롭기 때문에, 첫 번째 함수에 대한 저의 코드를 예시로 보여드릴게요:
다음 코드를 functions/get_files_info.py 파일에 추가하세요:
from google.genai import types
schema_get_files_info = types.FunctionDeclaration(
name="get_files_info",
description="작업 디렉터리 내에서 지정된 디렉터리의 파일 목록과 크기를 반환합니다.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"directory": types.Schema(
type=types.Type.STRING,
description="작업 디렉터리를 기준으로 파일을 나열할 디렉터리 경로입니다. 지정하지 않으면 작업 디렉터리 자체의 파일을 나열합니다.",
),
},
),
)
경고: LLM이 working_directory 파라미터를 지정하는 것을 허용하지 않을 것입니다. 우리는 이를 하드코딩할 예정입니다.
types.Tool을 사용하여 사용 가능한 함수들의 목록을 만드세요 (지금은 get_files_info만 추가하고, 나머지는 나중에 추가할 것입니다).
available_functions = types.Tool(
function_declarations=[
schema_get_files_info,
]
)
client.models.generate_content 를 호출할 때 tools 파라미터에 available_functions 을 추가합니다.
config=types.GenerateContentConfig(
tools=[available_functions], system_instruction=system_prompt
)시스템 프롬프트를 업데이트하여 LLM에게 함수 사용 방법을 지시하세요. 아래 내용을 복사해도 되지만, 어떤 역할을 하는지 이해하기 위해서 꼭 읽어보세요:
system_prompt = """
You are a helpful AI coding agent.
When a user asks a question or makes a request, make a function call plan. You can perform the following operations:
- List files and directories
All paths you provide should be relative to the working directory. You do not need to specify the working directory in your function calls as it is automatically injected for security reasons.
"""
generate_content 응답에서 .text 속성만 출력하는 대신, .function_calls 속성도 확인하세요. LLM이 함수를 호출했다면, 함수 이름과 인자를 출력하세요:
f"Calling function: {function_call_part.name}({function_call_part.args})"
그렇지 않으면 평소처럼 텍스트를 출력하세요.
프로그램을 테스트하세요:
- "what files are in the root?" →
get_files_info({'directory': '.'}) - "what files are in the pkg directory?" →
get_files_info({'directory': 'pkg'})
추가 함수 선언
이제 LLM이 get_files_info 함수에 대한 함수 호출을 지정할 수 있으므로, 다른 함수들도 호출할 수 있는 기능을 제공하겠습니다.
schema_get_files_info에 사용한 것과 동일한 패턴을 따라 다음에 대한 함수 선언을 만듭니다:
schema_get_file_contentschema_run_python_fileschema_write_file
available_functions를 업데이트하여 리스트에 모든 함수 선언을 포함시킵니다. 그런 다음 시스템 프롬프트를 업데이트합니다. 허용되는 작업이 다음뿐만 아니라:
- 파일 및 디렉토리 나열네 가지 작업을 모두 포함하도록 업데이트합니다:
- 파일 및 디렉토리 나열
- 파일 내용 읽기
- 선택적 인자로 Python 파일 실행
- 파일 쓰기 또는 덮어쓰기다양한 함수 호출을 불러올 것으로 예상되는 프롬프트를 테스트합니다. 예를 들어:
- "read the contents of main.py" →
get_file_content({'file_path': 'main.py'}) - "write 'hello' to main.txt" →
write_file({'file_path': 'main.txt', 'content': 'hello'}) - "run main.py" →
run_python_file({'file_path': 'main.py'}) - "list the contents of the pkg directory" →
get_files_info({'directory': 'pkg'})
여기서 LLM이 해야 할 일은 사용자의 요청에 따라 어떤 함수를 호출할지 선택하는 것뿐입니다. 나중에 실제로 함수를 호출하게 할 것입니다.
개인적인 구현 예제:
functions/get_file_content.py:
from google.genai import types
from config import MAX_CHARS
schema_get_file_content = types.FunctionDeclaration(
name="get_file_content",
description=f"작업 디렉토리 내의 지정된 파일에서 처음 {MAX_CHARS}자의 내용을 읽고 반환합니다.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"file_path": types.Schema(
type=types.Type.STRING,
description="작업 디렉토리에 상대적인, 내용을 읽을 파일의 경로.",
),
},
required=["file_path"],
),
)
functions/run_python.py:
from google.genai import types
schema_run_python_file = types.FunctionDeclaration(
name="run_python_file",
description="작업 디렉토리 내에서 Python 파일을 실행하고 인터프리터의 출력을 반환합니다.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"file_path": types.Schema(
type=types.Type.STRING,
description="작업 디렉토리에 상대적인, 실행할 Python 파일의 경로.",
),
"args": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(
type=types.Type.STRING,
description="Python 파일에 전달할 선택적 인자.",
),
description="Python 파일에 전달할 선택적 인자.",
),
},
required=["file_path"],
),
)
functions/write_file_content.py:
from google.genai import types
schema_write_file = types.FunctionDeclaration(
name="write_file",
description="작업 디렉토리 내의 파일에 내용을 씁니다. 파일이 존재하지 않으면 생성합니다.",
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"file_path": types.Schema(
type=types.Type.STRING,
description="작업 디렉토리에 상대적인, 쓸 파일의 경로.",
),
"content": types.Schema(
type=types.Type.STRING,
description="파일에 쓸 내용",
),
},
required=["file_path", "content"],
),
)
schema_get_files_info에서 사용했던 동일한 패턴을 따라, 아래의 함수 선언들을 만들어주세요:
schema_get_file_contentschema_run_python_fileschema_write_file
그리고 available_functions 리스트에 모든 함수 선언을 포함하도록 업데이트하세요.
그 다음 시스템 프롬프트를 수정해야 합니다. 기존에는 허용된 동작이 아래 한 가지뿐이었습니다:
- 파일 및 디렉토리 목록 나열이제 네 가지 모든 동작을 포함하도록 변경하세요:
- 파일 및 디렉토리 목록 나열
- 파일 내용 읽기
- 선택적 인수를 포함해 파이썬 파일 실행
- 파일 작성 또는 덮어쓰기여러 함수 호출이 나오도록 예상되는 프롬프트로 테스트해보세요. 예를 들어:
- "main.py의 내용을 읽어줘" →
get_file_content({'file_path': 'main.py'}) - "main.txt에 'hello'를 써줘" →
write_file({'file_path': 'main.txt', 'content': 'hello'}) - "main.py를 실행해줘" →
run_python_file({'file_path': 'main.py'}) - "pkg 디렉토리 목록을 보여줘" →
get_files_info({'directory': 'pkg'})
참고: 여기서 LLM이 해야 할 일은 사용자 요청을 기반으로 어떤 함수를 호출할지 선택하는 것입니다. 실제로 함수를 호출하는 코드는 나중에 구현합니다.
함수 호출
이제 에이전트가 어떤 함수를 호출할지 선택할 수 있으므로, 실제로 함수를 호출할 시간입니다.
네 함수 중 하나를 호출하는 추상적인 작업을 처리할 새 함수를 만듭니다.
다음은 제가 정의한 함수입니다:
def call_function(function_call_part, verbose=False):
function_call_part는 가장 중요하게 다음 내용을 가진 types.FunctionCall입니다:
.name속성 (함수 이름,문자열).args속성 (함수에 전달하는 이름이 지정된 인자들의 딕셔너리)
verbose가 지정되면 함수 이름과 인자를 출력합니다:
print(f"Calling function: {function_call_part.name}({function_call_part.args})")
그렇지 않으면 이름만 출력합니다:
print(f" - Calling function: {function_call_part.name}")
함수 이름에 따라 실제로 함수를 호출하고 결과를 캡처합니다.
- 키워드 인자 딕셔너리에 "working_directory" 인자를 수동으로 추가해야 합니다. 이 인자는 LLM이 제어하지 않으며, 작업 디렉토리는
./calculator이어야 합니다. - 딕셔너리를 함수에 키워드 인자로 넘길 때는
some_function(**some_args)문법을 사용합니다.
팁: 이를 위해 함수 이름(문자열) -> 함수 에 딕셔너리를 사용했습니다.
함수 이름이 유효하지 않으면 오류를 설명하는 types.Content를 반환합니다:
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"error": f"Unknown function: {function_name}"},
)
],
)
함수 호출 결과를 설명하는 from_function_response가 포함된 types.Content를 반환합니다:
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"result": function_result},
)
],
)
정보: from_function_response는 응답이 딕셔너리여야 하므로 결과 문자열을 "result" 필드에 넣어주면 됩니다.
아래는 완성된 call_function.py입니다:
from google.genai import types
from functions.get_files_info import get_files_info, schema_get_files_info
from functions.get_file_content import get_file_content, schema_get_file_content
from functions.run_python import run_python_file, schema_run_python_file
from functions.write_file_content import write_file, schema_write_file
from config import WORKING_DIR
available_functions = types.Tool(
function_declarations=[
schema_get_files_info,
schema_get_file_content,
schema_run_python_file,
schema_write_file,
]
)
def call_function(function_call_part, verbose=False):
if verbose:
print(f" - Calling function: {function_call_part.name}({function_call_part.args})")
else:
print(f" - Calling function: {function_call_part.name}")
function_map = {
"get_files_info": get_files_info,
"get_file_content": get_file_content,
"run_python_file": run_python_file,
"write_file": write_file,
}
function_name = function_call_part.name
if function_name not in function_map:
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"error": f"Unknown function: {function_name}"},
)
],
)
args = dict(function_call_part.args)
args["working_directory"] = WORKING_DIR
function_result = function_map[function_name](**args)
return types.Content(
role="tool",
parts=[
types.Part.from_function_response(
name=function_name,
response={"result": function_result},
)
],
)
모델의 generate_content 응답을 처리하는 곳으로 돌아가서, LLM이 호출하기로 결정한 함수의 이름을 단순히 출력하는 대신 call_function을 사용합니다.
call_function에서 반환하는types.Content는 내부에.parts[0].function_response.response가 있어야 합니다.- 만약 포함되어 있지 않다면 어떤 종류의 치명적인 예외를 발생시키세요.
- 만약 포함되어 있고
verbose가 설정되어 있으면 함수 호출 결과를 다음과 같이 출력합니다:
print(f"-> {function_call_result.parts[0].function_response.response}")
프로그램을 테스트합니다. 이제 프롬프트로 요청하면 함수를 실행할 수 있어야 합니다. 다양한 프롬프트를 시도해보고 --verbose 플래그를 사용하여 모든 기능이 제대로 작동하는지 확인합니다.
- 디렉토리 내용 나열
- 파일의 내용 가져오기
- 파일 내용 쓰기 (중요한 것을 덮어쓰지 말고 새 파일을 만드세요)
- 계산기 앱의 테스트
tests.py실행
에이전트 루프 만들기
우리는 이제 함수 호출 기능을 어느 정도 구현했지만, 아직 우리 프로그램을 "에이전트"라고 부르기엔 부족한 점이 하나 있습니다:
피드백 루프가 없다는 것입니다.
AI 인플루언서들이 말하는 "에이전트"의 핵심은, 자신의 도구를 반복적으로 사용하여 결과를 계속 개선해나갈 수 있는 능력입니다. 그래서 우리는 다음 두 가지를 만들 것입니다:
- LLM을 반복해서 호출할 루프
- "대화"에서 오고간 메시지 리스트. 다음과 같이 보일 것입니다:
- 사용자: "계산기의 버그를 수정해주세요"
- 모델: "get_files_info를 호출하고 싶습니다..."
- 툴: "get_files_info의 결과입니다..."
- 모델: "get_file_content를 호출하고 싶습니다..."
- 툴: "get_file_content의 결과입니다..."
- 모델: "run_python_file을 호출하고 싶습니다..."
- 툴: "run_python_file의 결과입니다..."
- 모델: "write_file을 호출하고 싶습니다..."
- 툴: "write_file의 결과입니다..."
- 모델: "run_python_file을 호출하고 싶습니다..."
- 툴: "run_python_file의 결과입니다..."
- 모델: "버그를 수정했고 계산기가 작동하는지 확인하기 위해 실행했습니다."
이건 꽤 큰 진전입니다. 천천히 진행하세요!
prompts.py를 만듭니다:
system_prompt = """
You are a helpful AI coding agent.
When a user asks a question or makes a request, make a function call plan. You can perform the following operations:
- List files and directories
- Read file contents
- Execute Python files with optional arguments
- Write or overwrite files
All paths you provide should be relative to the working directory. You do not need to specify the working directory in your function calls as it is automatically injected for security reasons.
"""
최종 main.py 구성:
import sys
import os
from google import genai
from google.genai import types
from dotenv import load_dotenv
from prompts import system_prompt
from call_function import call_function, available_functions
def main():
load_dotenv()
verbose = "--verbose" in sys.argv
args = []
for arg in sys.argv[1:]:
if not arg.startswith("--"):
args.append(arg)
if not args:
print("AI Code Assistant")
print('\nUsage: python main.py "your prompt here" [--verbose]')
print('Example: python main.py "How do I fix the calculator?"')
sys.exit(1)
api_key = os.environ.get("GEMINI_API_KEY")
client = genai.Client(api_key=api_key)
user_prompt = " ".join(args)
if verbose:
print(f"User prompt: {user_prompt}\n")
messages = [
types.Content(role="user", parts=[types.Part(text=user_prompt)]),
]
generate_content_loop(client, messages, verbose)
def generate_content_loop(client, messages, verbose, max_iterations=20):
for iteration in range(max_iterations):
try:
response = client.models.generate_content(
model="gemini-2.0-flash-001",
contents=messages,
config=types.GenerateContentConfig(
tools=[available_functions],
system_instruction=system_prompt
),
)
if verbose:
print("Prompt tokens:", response.usage_metadata.prompt_token_count)
print("Response tokens:", response.usage_metadata.candidates_token_count)
# 모델 응답을 대화에 추가
for candidate in response.candidates:
messages.append(candidate.content)
# 최종 텍스트 응답이 있는지 확인
if response.text:
print("Final response:")
print(response.text)
break
# 함수 호출 처리
if response.function_calls:
function_responses = []
for function_call_part in response.function_calls:
function_call_result = call_function(function_call_part, verbose)
if (not function_call_result.parts
or not function_call_result.parts[0].function_response):
raise Exception("empty function call result")
if verbose:
print(f"-> {function_call_result.parts[0].function_response.response}")
function_responses.append(function_call_result.parts[0])
if function_responses:
messages.append(types.Content(role="user", parts=function_responses))
else:
raise Exception("no function responses generated, exiting.")
except Exception as e:
print(f"Error: {e}")
break
else:
print(f"최대 반복 횟수({max_iterations})에 도달했습니다. 에이전트가 작업을 완료하지 못했을 수 있습니다.")
if __name__ == "__main__":
main()
generate_content 함수에서 모든 도구 사용 결과를 처리하세요. 이미 일부 구현되어 있을 수도 있지만, 중요한 점은 client.models.generate_content를 호출할 때마다 전체 messages 리스트를 전달해야 한다는 것입니다. 그래야 LLM이 현재 상태를 기반으로 "다음 단계"를 수행할 수 있습니다.
클라이언트의 generate_content 메서드를 호출한 후 응답의 .candidates 속성을 확인합니다. 이 속성은 응답의 다양한 버전(일반적으로 한 개)을 담고 있습니다. "get_files_info를 호출하고 싶습니다..."와 같은 응답이 나오면, 이 내용을 대화에 추가해야 합니다. 각 candidate를 순회하며 그 .content를 messages 리스트에 추가하세요.
각 함수 호출 후에는 types.Content를 사용하여 function_responses를 역할이 user인 메시지로 변환하고, 이를 messages에 추가하세요.
그 다음에는 generate_content를 한 번만 호출하는 대신, 반복적으로 호출하는 루프를 만드세요. 이 루프는 최대 20회까지만 반복되도록 제한하세요 (에이전트가 무한 반복하지 않도록 방지). try-except 블록을 사용하여 오류를 적절히 처리하세요.
각 generate_content 호출 후에는 response.text 속성을 반환하는지 확인하세요. 만약 그렇다면 작업이 완료된 것이므로, 최종 응답을 출력하고 루프를 종료하세요. 그렇지 않으면 다시 반복하세요 (물론 최대 반복 횟수에 도달하지 않았다면).
코드를 꼭 테스트하세요 (당연하죠). "계산기가 결과를 콘솔에 어떻게 출력하는지 설명해줘" 처럼 간단한 프롬프트로 시작해보세요. 저는 아래와 같은 결과를 받았습니다:
(aiagent) wagslane@MacBook-Pro-2 aiagent % uv run main.py "계산기가 결과를 콘솔에 어떻게 출력하는지 설명해줘"
함수 호출: get_files_info
함수 호출: get_file_content
최종 응답:
main.py 코드를 살펴본 결과, 계산기가 결과를 콘솔에 출력하는 방법은 다음과 같습니다:
print(to_print): 출력의 핵심은 print() 함수로 이루어집니다.
format_json_output(expression, result): 출력 전에 format_json_output 함수(pkg.render에서 import됨)를 사용해 계산 결과와 원래 식을 JSON 형태의 문자열로 포맷합니다. 이 형식화된 문자열은 to_print 변수에 저장됩니다.
에러 처리: 코드에는 try...except 블록을 활용한 에러 처리 기능이 포함되어 있습니다. 만약 계산 중 오류가 발생하면(예: 잘못된 수식이 입력된 경우), print(f"Error: {e}")를 통해 오류 메시지가 콘솔에 출력됩니다.
즉, 계산기는 식을 평가하고, 결과(그리고 원래 식)를 JSON 형태의 문자열로 만들어 콘솔에 출력합니다. 오류가 발생하면 콘솔에 오류 메시지도 따로 출력됩니다.팁: 원하는 대로 LLM이 동작하도록 하려면 시스템 프롬프트를 약간 조정해야 할 수도 있습니다. 이제 당신은 프롬프트 엔지니어입니다. 그처럼 행동하세요!
멋집니다! 이제 파일을 읽고, 파일을 작성하고, 파이썬 코드를 실행하며, 스스로 결과를 개선할 수 있는 기본적인 AI 에이전트를 완성했습니다. 이건 더 복잡한 AI 에이전트를 만드는 훌륭한 기반이에요.
결론
필요한 모든 단계를 완료했습니다. 이제 약간 재미있게 (하지만 신중하게… LLM에 파일 시스템이나 파이썬 인터프리터 접근 권한을 줄 때는 매우 주의하세요) 다뤄보세요! 다음과 같은 작업들을 시도해볼 수도 있습니다:
- 더 어려운 버그나 복잡한 문제 수정
- 코드 일부를 리팩터링하기
- 완전히 새로운 기능 추가
또한 다음을 시도할 수 있습니다:
- 다른 LLM 제공업체
- 다른 Gemini 모델
- 호출 가능한 기능을 더 추가해보기
- 다른 코드베이스 시도하기 (에이전트를 실행하기 전에 변경 사항을 커밋해두면 언제든 되돌릴 수 있습니다!)
경고: 우리가 구축한 것은 Cursor/Zed의 Agentic Mode 또는 Claude Code와 같은 것의 간단한 버전이라는 것을 기억하세요. 그 도구들조차 완벽하게 안전하지 않으므로 접근 권한을 부여하는 것에 주의하고 이 코드를 다른 사람에게 제공하지 마세요.
백엔드 및 데이터 엔지니어링에 대해 더 알고 싶다면, Boot.dev를 확인하세요! 학습 여정에 행운을 빕니다!