들어가기 전에

요즘 업무를 하다보면 claude code 나 cursor 같은 ai agent 를 자주 사용하게 된다. 여기저기서 줏어들은 지식을 기반으로 통밥을 굴려 이 프로그램이 어떤 식으로 굴러가는지는 피상적으로 파악은 하고 있었다. 하지만 실제로 코드를 까본 경험은 없었다. 내가 경험한 대부분의 에이전트는 이미 너무 기능이 많고 성숙해져있어 코드 베이스가 필요 이상으로 복잡한 탓이다.

그러다 마침 아주 기초적인 형태의 AI 에이전트 코드를 발견하게 되었다. gemini-writer 라는 프로젝트다. 소설이나 단편 글을 쓰기 위한 도구인 것 같은데. 코드가 생각보다 단순했다. 하지만 그 단순함 속에 AI 에이전트의 본질이 고스란히 담겨 있는 것 같아 분석을 해보기로 했다.

이 글은 그 코드를 따라가면서, AI 에이전트가 실제로 LLM 과 어떤 대화를 주고받고, 어떤 흐름으로 작업을 수행하는지를 차근차근 풀어본 기록이다.

도구 사용 능력

AI 에이전트를 한 문장으로 정의하면 이렇다. LLM 이 도구 (tool) 를 사용할 수 있도록 만든 시스템.

모델이 “이 작업을 하려면 파일을 만들어야겠다"라고 판단하면, 실제로 파일을 만드는 함수가 호출된다. “여기에 내용을 추가해야겠다"라고 생각하면, 그 내용이 실제 파일에 기록된다.

gemini-writer 코드를 보면 이 구조가 선명하게 드러난다.

# writer.py 의 메인 루프 일부
response = client.models.generate_content(
    model=MODEL_NAME,
    contents=contents,
    config=generate_config,
)

# 모델 응답에서 function call 추출
function_calls_list = []
for part in model_content.parts:
    if hasattr(part, 'function_call') and part.function_call:
        fc = part.function_call
        function_calls_list.append({
            "name": fc.name,
            "args": dict(fc.args) if fc.args else {}
        })

# 감지된 도구 실행
for fc in function_calls_list:
    func_name = fc["name"]
    args = fc["args"]
    tool_func = tool_map.get(func_name)
    result = tool_func(**args)

흐름을 따라가보자.

  1. 클라이언트가 LLM 에게 현재 상황 (contents) 과 사용 가능한 도구 목록 (tools) 을 보낸다.
  2. LLM 은 상황을 판단하고, “지금은 파일을 만들어야 해"라고 결론내리면 function_call 을 응답에 포함시킨다.
  3. 에이전트는 이 function_call 을 파싱해서, 실제로 해당 Python 함수를 실행한다.
  4. 실행 결과를 다시 LLM 에게 돌려보낸다.
  5. LLM 은 그 결과를 바탕으로 다음 행동을 결정한다.

이 흐름이 에이전트의 기본 동작 원리다.

도구 정의

재미있는 점은, LLM 이 실제로 그 함수를 실행하는 게 아니라는 거다. LLM 은 그저 “이런 도구가 있어요"라는 설명을 듣고, 언제 무엇을 써야 할지 판단할 뿐이다.

gemini-writer 는 세 가지 도구를 제공한다.

# utils.py 에서
def get_tool_definitions() -> types.Tool:
    return types.Tool(
        function_declarations=[
            types.FunctionDeclaration(
                name="create_project",
                description="Creates a new project folder in the 'output' directory...",
                parameters=types.Schema(...)
            ),
            types.FunctionDeclaration(
                name="write_file",
                description="Writes content to a markdown file...",
                parameters=types.Schema(...)
            ),
            types.FunctionDeclaration(
                name="compress_context",
                description="INTERNAL TOOL - Automatically called when token limit approached...",
                parameters=types.Schema(...)
            )
        ]
    )

각 도구에는 이름, 설명, 그리고 어떤 파라미터를 받아야 하는지가 적혀 있다. 이 정의를 LLM 에게 보여주면, 모델은 “아, 내가 파일을 쓰고 싶으면 write_file 이라는 이름을 호출하고, filename 과 content 를 넘겨주면 되는구나"라고 이해한다.

말하자면, LLM 에게 도구 사용 설명서를 한 부 건네는 셈이다.

실제 도구의 구현

한편, 실제로 일을 하는 함수들도 따로 존재한다.

# tools/writer.py 에서
def write_file_impl(filename: str, content: str, mode: Literal["create", "append", "overwrite"]) -> str:
    project_folder = get_active_project_folder()
    if not project_folder:
        return "Error: No active project folder..."
    
    file_path = os.path.join(project_folder, filename)
    
    if mode == "create":
        if os.path.exists(file_path):
            return f"Error: File '{filename}' already exists..."
        with open(file_path, 'w', encoding='utf-8') as f:
            f.write(content)
        return f"Successfully created file '{filename}'..."

LLM 이 write_file 호출을 요청하면, 에이전트는 이 write_file_impl 함수를 찾아서 실행한다. 그리고 그 결과를 문자열로 바꿔서 다시 LLM 에게 돌려보낸다.

여기서 중요한 건, LLM 이 실제 파일 시스템을 건드리지 않는다는 점이다. LLM 은 그저 “파일을 만들겠습니다"라고 선언할 뿐이고, 실제 작업은 Python 코드가 담당한다.

생각 -> 행동 -> 관찰

gemini-writer 의 메인 루프를 보면, AI 에이전트의 작동 방식이 한눈에 들어온다.

1. 사용자 입력을 contents  추가
2. LLM 에게 contents  tools  보내서 응답 받음
3. 응답에서 function call  있는지 확인
4. function call  있으면:
   - 해당 Python 함수 실행
   - 실행 결과를 function response  포장
   - 결과를 contents  추가 (사용자 입력 형태로)
5. function call  없으면:
   - 작업 완료로 간주하고 루프 종료
6. token  확인, 필요하면 압축
7. 2  돌아가서 반복

이 루프는 gemini-writer 의 경우 최대 300 회까지 반복되도록 설정되어 있다. 각 반복을 iteration 이라고 부르는데, 이게 바로 에이전트가 “한 번 생각하고 한 번 행동하는” 단위다.

context 관리

AI 에이전트를 설계할 때 가장 신경 써야 하는 부분 중 하나가 context 관리다. LLM 은 한 번에 처리할 수 있는 토큰 수에 제한이 있다. gemini-writer 의 경우 Gemini 모델을 사용하는데, 이건 100 만 토큰까지 지원한다. 꽤 넉넉해 보이지만, 긴 작업을 하다 보면 결국 한계에 부딪히고 말 것이다.

그래서 gemini-writer 는 자동 압축 기능을 탑재하고 있다.

# writer.py 에서
TOKEN_LIMIT = 1000000
COMPRESSION_THRESHOLD = 900000  # 90% 에서 압축 시작

# 메인 루프에서
token_count = estimate_token_count(client, MODEL_NAME, contents)
if token_count >= COMPRESSION_THRESHOLD:
    compression_result = compress_context_impl(
        messages=simple_messages,
        client=client,
        model=MODEL_NAME,
        keep_recent=10
    )

토큰 수가 90 만을 넘으면, 과거 대화를 요약해서 압축한다. 최근 10 개 메시지는 그대로 두고, 그 이전은 모두 요약본 하나로 치환하는 방식이다.

압축 함수는 다시 LLM 을 호출해서 요약을 만든다.

# tools/compression.py 에서
summary_prompt = """Please provide a comprehensive summary of the conversation history below. Include:
1. The main task or goal discussed
2. Key decisions made
3. Files created and their purposes
4. Progress made so far
5. Any important context for continuing the work
"""

summary_response = client.models.generate_content(
    model=model,
    contents=contents,
    config=types.GenerateContentConfig(temperature=0.7)
)

즉, LLM 이 과거 대화를 요약해서, 그 요약본을 바탕으로 다음 작업을 계속하는 구조다.

복구 모드

gemini-writer 는 복구 모드를 지원한다. 작업 도중 인터럽트가 걸리거나, 토큰 한계에 도달하면, 에이전트는 현재 상태를 파일로 저장한다.

./my_project/.context_summary_20260406_222702.md

이 파일에는 그 때까지의 대화 요약이 담겨 있다. 그리고 다음에 이 파일로 재시작하면, 에이전트는 이전 상태를 복원하고 작업을 이어갈 수 있다.

# writer.py 에서
if args.recover:
    context = load_context_from_file(args.recover)
    initial_message = f"[RECOVERED CONTEXT]\n\n{context}\n\nPlease continue..."