인공지능과 오토메이션/Automation

AI 코딩 에이전트 직접 만들기 — 셋업부터 첫 실행 모드까지

토니치코 2026. 5. 3. 10:38
Claude Code Crash Course · 통합본 PART 1 (Tistory 자동링크 차단 적용)

AI Note · Claude Code Crash Course 

▶ PART 1 

AI 코딩 에이전트 직접 만들기 - 셋업부터 첫 실행 모드까지

Claude Code, Cursor, Codex 같은 AI 코딩 에이전트가 안에서 어떻게 돌아가는지 직접 만들어 보면서 살펴보는 정리.
가상 환경 셋업 → LLM 연결과 안정화 → CLI 애플리케이션 골격까지 가이드
PART
# 1
난이도
★★☆☆☆ → ★★★☆☆
SECTION 01
▶ Section 1

AI 코딩 에이전트의 첫 걸음 - 챗봇과 무엇이 다른가, 그리고 왜 가상 환경부터 시작하는가

Claude Code · Cursor · Codex · Gemini CLI 같은 AI 코딩 에이전트가 똑똑해지는 진짜 이유와, 우리가 직접 하나 만들어 보기 위한 가장 첫 번째 준비 작업
난이도 ★☆☆☆☆ 입문
▶ LEARNING GOALS
이 섹션이 끝나면 다음을 할 수 있습니다.
  • AI 코딩 에이전트가 단순 챗봇과 무엇이 다른지 한 문단으로 설명할 수 있다
  • 도구·기억·상황 이해·피드백 루프 — 에이전트의 4대 능력을 구분해 말할 수 있다
  • "의존성"과 "가상 환경"이 왜 필요한지 비유로 풀어낼 수 있다
  • 터미널에서 Python 가상 환경을 직접 만들고, Hello World로 검증할 수 있다
  • 다음 강의(LLM 연결)로 자연스럽게 넘어갈 준비가 되어 있다

요즘 AI 코딩 에이전트(AI Coding Agent)라는 이름이 부쩍 자주 들린다. Claude Code, Cursor, Codex, Gemini CLI… 처음에는 다들 이렇게 생각했다 - "그냥 LLM에 '이렇게 짜줘' 하면 코드 만들어 주는 거 아닌가? 뭐 그리 복잡할 게 있겠어?"

그런데 안을 들여다보면 이야기가 다르다. AI 에이전트가 진짜 똑똑해지는 지점은 LLM 자체의 성능보다, 주변 도구를 잘 다루고, 기억하고, 실수에서 배우는 구조에 있다. 이 강의에서는 그런 에이전트를 직접 한 번 만들어 본다. 첫 걸음으로 오늘은 "왜 가상 환경이 필요한가"까지 다룬다.

[1-1] AI 에이전트, 단순한 챗봇이 아니다

AI 에이전트를 처음 마주할 때 가장 흔한 오해는 "결국 챗봇 아냐?"다. 실제로 AI 에이전트는 챗봇과 결정적인 차이가 있다.

챗봇은 "답"을 돌려주고, 에이전트는 "행동"을 한다. 같은 LLM을 쓰더라도 그 결과물의 형태가 다르다.

예를 들어 "이 폴더의 파일들을 정리해서 한 페이지 요약 보고서 만들어줘"라는 요청을 받았을 때 —

  • 챗봇 : "이런 식으로 정리해 보세요" 하고 가이드 문장을 돌려준다.
  • 에이전트 : 폴더를 스스로 열고, 파일을 읽고, 요약을 작성해 새 파일로 저장하기까지 한다.

겉보기에는 비슷해 보여도, 안에서 일어나는 일은 전혀 다르다. 에이전트는 단발성 응답이 아니라 계획 → 실행 → 점검 → 다시 시도의 루프를 스스로 돈다.

챗봇이 "비서가 옆에서 답을 알려주는" 그림이라면, 에이전트는 "비서가 직접 책상에 앉아 일을 처리하고 결과물을 가져오는" 그림이다. 일을 시킨 사람은 검토와 결정만 한다.

[1-2] 에이전트가 가진 네 가지 핵심 능력(기능)

그렇다면 에이전트를 에이전트답게 만드는 능력은 무엇일까. 네 가지로 정리하면 다음과같다.

① 도구 (Tools)
파일을 읽고 쓰고, 명령을 실행하고, 인터넷에서 자료를 가져오는 "연장통". 에이전트가 손을 움직일 수 있게 해 주는 본체.
② 기억력 (Memory)
대화·작업 도중 알게 된 정보, 사용자 선호, 작업 이력을 잊지 않고 다음 작업에 다시 활용한다.
③ 상황 이해 (Context)
한정된 컨텍스트 안에서 무엇이 중요한지 골라낸다. 불필요한 정보는 버리고 핵심만 유지한다.
④ 피드백 루프
실행 결과를 보고 "아, 이건 잘못됐네"를 스스로 인지해 다음 시도를 다르게 한다. 한 번에 안 되면 다시 도는 회로.

여기에 더해, 너무 복잡한 일은 작은 보조 에이전트(sub-agent)들을 만들어 위임한다. 회계 검증은 회계 담당, 문구 다듬기는 카피 담당 - 사람 조직의 분업과 같은 그림이다.
이 네 가지는 새로운 개념이 아니다. "신입 직원에게 일을 맡길 때 우리가 자연스럽게 기대하는 것" — 도구 사용, 메모, 우선순위 판단, 피드백 수용 — 그대로다. AI 에이전트도 같은 기준으로 바라보면 된다.

[1-3] 에이전트는 실제로 무슨 일을 할 수 있는가

위 네 가지 능력이 합쳐졌을 때, 실제 작업 능력은 다음과 같이 펼쳐진다.

기능설명 · 사무국 적용 예시
코드 읽기/쓰기기존 코드를 이해하고 새 코드를 만든다. 자동화 스크립트가 필요할 때 가장 먼저 쓰이는 능력.
명령 실행"이 PDF 50장을 모두 그림으로 변환" 같은 일을 직접 명령으로 수행한다.
웹 검색최신 시세·정책·뉴스를 가져와 즉석에서 표·리포트로 정리.
기억하기"우리 협회 어투", "회원사 양식" 같은 반복 맥락을 저장해 매번 다시 설명하지 않게 한다.
계획 세우기큰 작업을 여러 단계로 쪼개 우선순위까지 정한다.
실수에서 배우기실행이 실패하면 이유를 파악해 다른 방법으로 재시도한다.
확장 가능사용자가 직접 새 도구를 추가하거나 외부 서비스와 연결할 수 있다 (이후 13강 MCP 참고).
이 모든 능력은 결국 한 문장으로 모인다. "채팅 답변이 아니라 업무 결과물을 생산하는 시스템" — 그게 AI 에이전트의 본질이다.

[1-4] 왜 가상 환경부터 시작하는가

이제 직접 만들기로 들어가자. 그런데 첫 줄의 코드를 짜기 전에, 먼저 해야 할 일이 있다. 바로 가상 환경(Virtual Environment) 구성이다.

[1] 의존성, 그리고 의존성 충돌

AI 에이전트를 만들려면 여러 외부 라이브러리(파이썬 패키지)가 필요하다. 이걸 통틀어 의존성(dependency)이라고 부른다. 문제는 - 프로젝트마다 필요한 의존성 버전이 다르다는 점이다.

요리에 비유하면 이렇다. 프로젝트 A는 설탕 버전 1.0이 필요한데, 프로젝트 B는 설탕 버전 2.0이 필요하다고 해 보자. 부엌에 설탕통이 하나뿐이면 둘 중 하나는 망가진다. 컴퓨터도 똑같다.

[2] 가상 환경이라는 해법

가상 환경은 이름 그대로 프로젝트마다 독립된 부엌을 만들어 주는 장치다. 각 부엌에는 그 프로젝트가 필요한 재료(라이브러리)만 따로 들어 있고, 다른 부엌에 무엇이 있든 영향을 받지 않는다.

STEP 1프로젝트 폴더
STEP 2가상 환경 생성
STEP 3활성화
STEP 4안에서 코드 실행

[1-5] [실습] 가상 환경 만들고 Hello World 찍기

여기부터는 손을 움직이자. 터미널을 열고 그대로 따라하면 된다. Mac은 "터미널" 앱, Windows는 "PowerShell" 또는 "Windows Terminal"을 검색해 실행한다.

1
프로젝트 폴더 만들기
TERMINAL
$ mkdir AI_agent
$ cd AI_agent
2
가상 환경 생성
TERMINAL
$ python -m venv ​.venv
# 만약 위 명령이 안 되면
$ python3 -m venv ​.venv
현재 폴더 안에 ​.venv라는 이름의 독립된 부엌(가상 환경)이 생성된다.
3
가상 환경 활성화
TERMINAL · macOS / Linux
$ source ​.venv/bin/activate
TERMINAL · Windows (PowerShell)
PS> ​.venv\Scripts\Activate​.ps1
활성화에 성공하면 프롬프트 앞에 (​.venv) 표시가 붙는다.
4
파일 생성 후 Hello World 실행
파일 생성 (PowerShell)
PS> Set-Content -Path main​.py -Value 'print("hello world")'
실행
(​.venv) $ python main​.py
hello world
▶ 자주 막히는 지점
  • python: command not foundpython3로 다시 시도
  • Windows에서 활성화 스크립트 실행이 막힘 → PowerShell을 관리자 권한으로 열고 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned 한 번 실행
  • 활성화는 됐는데 모듈을 못 찾음 → 활성화된 가상 환경 안에서 pip install을 다시 했는지 확인

[1-6] LLM 연결 — 다음 스텝으로 가는 다리

가상 환경까지 마련했으니, 이제 에이전트의 "뇌"를 연결할 차례다.

AI 에이전트
LLM에 질문
답변 수신
행동 결정

LLM(Large Language Model)은 에이전트의 두뇌다. "이 코드를 고쳐라"라는 요청이 들어오면, 에이전트는 LLM에게 "어떻게 고치면 될까?"를 묻고, 그 답을 받아 실제 행동(파일 수정, 명령 실행 등)을 한다.

[1-7] 핵심 정리 · 실습 체크리스트

★ KEY TAKEAWAYS
  1. AI 에이전트는 챗봇이 아니라 "행동하는 시스템"이다. 답이 아니라 결과물을 만든다.
  2. 에이전트가 똑똑한 이유는 LLM 자체가 아니라 도구·기억·상황 이해·피드백 루프가 결합된 구조 덕이다.
  3. 의존성 충돌을 막으려면 프로젝트마다 독립된 가상 환경을 둔다.
  4. 가상 환경 만들기는 venv 생성 → 활성화 → 안에서 실행 세 단계로 끝난다.
  5. 다음 단계는 LLM 연결 — 에이전트의 뇌를 끼우는 일이다.

체크리스트

  • 터미널에서 python --version이 정상 출력되는지 확인했다
  • AI_agent 폴더를 만들고 그 안으로 이동했다
  • ​.venv 가상 환경을 생성했다
  • 활성화 후 프롬프트 앞에 (​.venv) 표시를 확인했다
  • main​.py 파일을 만들어 print("hello world")를 실행했다
  • 도구·기억·상황 이해·피드백 루프 — 네 가지를 입으로 한 번 말해 봤다
END · SECTION 01
SECTION 02
▶ Section 2

LLM Connection & Reliability - OpenRouter, 스트리밍, 그리고 지수 백오프

에이전트의 두뇌가 될 LLM에 안정적으로 연결하기. 어떤 모델이든 끼울 수 있는 클라이언트 설계, 응답을 받는 두 가지 방식, 그리고 실패해도 다시 일어서게 만드는 재시도 전략까지.
난이도 ★★☆☆☆ 초중급
▶ LEARNING GOALS
이 섹션이 끝나면 다음을 할 수 있습니다.
  • 왜 OpenAI 라이브러리만으로 OpenRouter·Anthropic·기타 LLM에 모두 연결할 수 있는지 설명할 수 있다
  • LLMClient 클래스를 싱글톤 패턴으로 직접 만들 수 있다
  • 스트리밍과 비스트리밍 응답을 구분하고, 각각 언제 써야 하는지 판단할 수 있다
  • StreamEvent · EventType · TextDelta · TokenUsage 데이터 구조를 설계할 수 있다
  • yieldreturn의 차이를 이해하고 비동기 제너레이터를 작성할 수 있다
  • 지수 백오프(Exponential Backoff)로 재시도 로직을 구현할 수 있다

지난 섹션에서 가상 환경까지 깔았다. 이제 그 위에 에이전트의 뇌인 LLM을 올린다. 그런데 단순히 "LLM에 메시지 한 번 보내고 답 받기"로는 충분하지 않다. 실서비스에서 부딪히는 세 가지를 함께 풀어야 한다.

① 어떤 모델이든 갈아끼울 수 있어야 한다. Anthropic, OpenAI, Mistral, Llama… 한 군데 묶이면 안 된다.
② 응답이 길어도 사용자가 기다리지 않아야 한다. 스트리밍이 필요하다.
③ 실패해도 무너지지 않아야 한다. 속도 제한·네트워크 오류에 재시도로 버텨야 한다.

[2-1] 왜 LLM 클라이언트를 직접 설계하는가

"그냥 OpenAI 라이브러리 한 줄 쓰면 되는 거 아닌가?"라고 생각할 수 있다. 맞기도 하고 틀리기도 하다. 한 번 실험할 때는 충분하지만, 에이전트는 LLM과 수십·수백 번 대화해야 한다. 그때마다 똑같은 에러 처리, 재시도, 응답 파싱을 매번 적는 건 낭비다.

📡 응답 가져오기
LLM에게 메시지를 보내고 답을 받는 단일 진입점.
🌊 스트리밍 처리
생성되는 즉시 청크 단위로 실시간 응답.
🔁 재시도 로직
속도 제한·연결 오류 발생 시 지수 백오프로 다시 시도.
🔌 모델 갈아끼우기
OpenRouter 경유로 어떤 LLM이든 같은 인터페이스로 호출.
LLM 클라이언트는 결국 "LLM과 대화하는 모든 코드를 한 곳에 모은 창구"다. 에이전트의 다른 부분이 LLM의 종류·에러 형태·응답 포맷을 모르게 만드는 게 핵심 목적이다.

[2-2] OpenAI 라이브러리 + Base URL 트릭

처음에 헷갈리는 부분이다 — "OpenAI 라이브러리인데 OpenRouter에 연결한다고?" 가능한 이유는 OpenAI 라이브러리가 base URL을 바꿀 수 있게 만들어 놨기 때문이다.

콘센트 비유로 보자. OpenAI 라이브러리는 220V 표준 플러그다. 그런데 본사 콘센트(OpenAI) 말고도 같은 규격이면 어디에든 꽂힌다. OpenRouter, Together, Groq, 자체 호스팅 모델까지 — URL만 바꿔 끼우면 된다.

[2-3] OpenRouter — 여러 LLM을 하나의 창구로

OpenRouter는 한 마디로 "LLM 통합 인터페이스"다. 여러 회사의 모델을 한 곳에서 검색·호출할 수 있고, 다음 세 가지 장점이 강의에서 강조된다.

장점내용
안정적인 라우팅모델별 가동 시간(uptime)을 보고 가장 안정적인 제공자로 자동 전환.
모니터링 지표처리량(throughput), 지연 시간(latency), 가동률을 한 화면에서 확인.
무료 모델 풀Anthropic·OpenAI 자체 API는 유료지만, OpenRouter는 다양한 무료 모델을 제공.

[실습 준비] OpenRouter API 키 발급

1
OpenRouter 가입

openrouter​.ai 접속 → 회원가입. 신용카드 정보는 요구되지 않는다.

2
Keys 메뉴에서 새 키 발급

키 이름(예: AI Agent)과 만료 기간을 지정. 발급 후 화면에 한 번만 표시된다.

3
안전한 곳에 보관

한 번 닫으면 다시 볼 수 없으므로 비밀번호 매니저나 메모에 저장.

API 키는 절대 GitHub에 그대로 올리면 안 된다. 본문에는 하드코딩하지만, 실서비스용 코드에서는 환경 변수(​.env)나 시크릿 매니저를 통해 관리한다.

[2-4] [실습] LLMClient 클래스 만들기 (싱글톤)

핵심 아이디어는 두 가지 — 싱글톤 패턴(연결을 하나만 유지)과 모델 선택의 유연성(연결 시점이 아니라 호출 시점에 모델을 지정)이다.

client / llm​.py
from openai import AsyncOpenAI
class LLMClient:
    def __init__(self):
        self​.client: AsyncOpenAI | None = None
        self​.api_key  = "sk-or-v1-..."   # 추후 환경 변수로 이동
        self​.base_url = "https://openrouter​.ai/api/v1"
    def get_client(self) -> AsyncOpenAI:
        if self​.client is None:
            self​.client = AsyncOpenAI(api_key=self​.api_key, base_url=self​.base_url)
        return self​.client
    async def close(self):
        if self​.client:
            await self​.client​.close()
            self​.client = None
연결 시점에는 모델을 정하지 않는다. 메시지를 보낼 때마다 모델을 자유롭게 바꿀 수 있게 만드는 것이 포인트다.

[2-5] 스트리밍 vs 비스트리밍

비스트리밍 (Non-streaming)
  • 전체 응답이 완성될 때까지 기다린 후 한 번에 받음
  • 15초짜리 응답이면 15초간 깜깜
  • 적합 : 내부 처리용 — 요약, 컨텍스트 압축
스트리밍 (Streaming)
  • 토큰이 만들어지는 즉시 청크 단위로 전송
  • 첫 30개 토큰이 1초 만에 도착, 이후 계속 흘러옴
  • 적합 : 사용자에게 보여주는 모든 응답
LLM은 자기회귀적(auto-regressive)이다. 한 번에 한 토큰씩 만들어 낸다. 비스트리밍은 책 한 권을 다 인쇄해서 건네는 거고, 스트리밍은 페이지가 인쇄되는 대로 한 장씩 넘겨주는 거다.

단일 진입점 — chat_completion()

async def chat_completion(
    self, messages: list, stream: bool = True,
    model: str = "mistralai/mistral-small-...",
):
    client = self​.get_client()
    kwargs = {"model": model, "messages": messages}
    if stream:
        async for event in self​._stream_response(client, kwargs):
            yield event
    else:
        yield await self​._non_stream_response(client, kwargs)

[2-6] 응답 데이터 구조 — StreamEvent

응답은 단순한 텍스트가 아니다. 텍스트 청크일 수도, 완료 신호일 수도, 오류일 수도 있다. 이걸 일관되게 다루기 위해 StreamEvent라는 통합 이벤트 객체를 만든다.

client / response​.py
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class EventType(Enum):
    TEXT_DELTA       = "text_delta"
    MESSAGE_COMPLETE = "message_complete"
    ERROR            = "error"
@dataclass
class TextDelta:
    content: str
    def __str__(self):
        return self​.content
@dataclass
class TokenUsage:
    prompt_tokens:     int = 0
    completion_tokens: int = 0
    total_tokens:      int = 0
    cached_tokens:     int = 0
    def __add__(self, other: "TokenUsage") -> "TokenUsage":
        return TokenUsage(
            prompt_tokens     = self​.prompt_tokens     + other​.prompt_tokens,
            completion_tokens = self​.completion_tokens + other​.completion_tokens,
            total_tokens      = self​.total_tokens      + other​.total_tokens,
            cached_tokens     = self​.cached_tokens     + other​.cached_tokens,
        )
@dataclass
class StreamEvent:
    type: EventType
    text_delta:    TextDelta  | None = None
    error:         str        | None = None
    finish_reason: str        | None = None
    usage:         TokenUsage | None = None

[2-7] _stream_response · _non_stream_response 구현

[1] 비스트리밍 — 한 번에 받아오기

async def _non_stream_response(self, client, kwargs) -> StreamEvent:
    response = await client​.chat​.completions​.create(**kwargs)
    choice   = response​.choices[0]
    text_delta = TextDelta(content=choice​.message​.content)
    usage = TokenUsage(
        prompt_tokens     = response​.usage​.prompt_tokens,
        completion_tokens = response​.usage​.completion_tokens,
        total_tokens      = response​.usage​.total_tokens,
    )
    return StreamEvent(
        type          = EventType​.MESSAGE_COMPLETE,
        text_delta    = text_delta,
        finish_reason = choice​.finish_reason,
        usage         = usage,
    )

[2] 스트리밍 — 청크를 흘려보내기

async def _stream_response(self, client, kwargs):
    response = await client​.chat​.completions​.create(**kwargs, stream=True)
    finish_reason, usage = None, None
    async for chunk in response:
        if chunk​.usage:
            usage = TokenUsage(...)
        if chunk​.choices:
            choice = chunk​.choices[0]
            if choice​.delta​.content:
                yield StreamEvent(
                    type       = EventType​.TEXT_DELTA,
                    text_delta = TextDelta(content=choice​.delta​.content),
                )
            if choice​.finish_reason:
                finish_reason = choice​.finish_reason
    yield StreamEvent(
        type          = EventType​.MESSAGE_COMPLETE,
        finish_reason = finish_reason,
        usage         = usage,
    )

[2-8] yield vs return — 비동기 제너레이터의 핵심

return — "끝났습니다"
  • 값을 반환하고 함수를 완전히 종료
  • 한 번 호출 = 한 번 답
yield — "이것 먼저, 다음 더 있어요"
  • 값을 반환하고 함수를 일시정지
  • 호출자가 다음을 요청하면 그 자리에서 재개
음식점 비유 — return은 "주문하신 풀코스가 한 접시에 다 담겼습니다." yield는 "에피타이저 → 메인 → 디저트" 한 코스씩 차례로. 두 번째가 스트리밍에 딱이다.

[2-9] try/except와 지수 백오프

오류 유형원인대응
RateLimitError분당/일당 호출 한도 초과지수 백오프로 잠시 후 재시도
APIConnectionError네트워크 단절·DNS 오류지수 백오프로 잠시 후 재시도
APIError서버 측 오류·잘못된 응답재시도 없이 즉시 ERROR 이벤트 yield

지수 백오프 — 점점 길게 기다리기

같은 요청을 즉시 재시도하면 다시 실패할 가능성이 높다. 기다리는 시간을 점점 늘리면서 재시도하는 것이 지수 백오프다. 공식은 단순하다 — wait_time = 2 ** attempt.

시도 11초
시도 22초
시도 34초
import asyncio
from openai import RateLimitError, APIConnectionError, APIError
async def _stream_response_with_retry(self, client, kwargs):
    max_retries = 3
    for attempt in range(max_retries + 1):
        try:
            async for event in self​._stream_response(client, kwargs):
                yield event
            return  # 성공하면 즉시 종료
        except (RateLimitError, APIConnectionError) as e:
            if attempt < max_retries:
                wait_time = 2 ** attempt
                await asyncio​.sleep(wait_time)
                continue
            yield StreamEvent(type=EventType​.ERROR, error=str(e))
            return
        except APIError as e:
            yield StreamEvent(type=EventType​.ERROR, error=str(e))
            return

[2-10] 핵심 정리 · 실습 체크리스트

★ KEY TAKEAWAYS
  1. LLM과의 통신은 한 모듈(LLMClient)에 몰아넣는다.
  2. OpenAI 라이브러리의 base URL을 OpenRouter로 바꾸면 한 코드로 모든 LLM에 연결된다.
  3. 스트리밍은 사용자용, 비스트리밍은 내부 처리용. 한 진입점에서 분기한다.
  4. 모든 응답은 StreamEvent로 통합한다. EventType만 보면 텍스트인지 완료인지 오류인지 안다.
  5. yield는 함수를 일시정지하고 호출자에게 값을 흘린다. 비동기 제너레이터의 본질.
  6. 일시적 오류는 지수 백오프, 영구적 오류는 즉시 ERROR.

체크리스트

  • OpenRouter에 가입하고 API 키를 발급받았다
  • openai 라이브러리를 가상 환경에 설치했다
  • LLMClient 클래스를 싱글톤 패턴으로 작성했다
  • EventType · TextDelta · TokenUsage · StreamEvent 데이터 구조를 정의했다
  • 스트리밍 모드로 LLM에 메시지를 보내고, 청크가 실시간으로 출력되는 것을 확인했다
  • 일부러 잘못된 API 키를 넣어 보고 백오프 작동을 확인했다
  • yieldreturn의 차이를 한 문장으로 설명할 수 있다
END · SECTION 02
SECTION 03
▶ Section 3

CLI Application Foundation - Click · Agent 클래스 · Rich로 만든 첫 단일 실행 모드

단순한 LLM 호출 코드를 "사용자가 명령어로 부르는 진짜 애플리케이션"으로 끌어올린다. Agent 클래스가 등장해 흐름을 잡고, Click이 입력을, Rich가 출력을 책임지는 — 진짜 에이전트의 골격을 세우는 단계.
난이도 ★★★☆☆ 중급
▶ LEARNING GOALS
이 섹션이 끝나면 다음을 할 수 있습니다.
  • "에이전틱 루프"의 의미를 코드 차원에서 설명할 수 있다
  • StreamEventAgentEvent가 왜 따로 존재해야 하는지 설명할 수 있다
  • AgentEvent 클래스에 헬퍼 클래스 메서드를 직접 만들 수 있다
  • async with 컨텍스트 매니저로 LLM 연결을 자동 정리할 수 있다
  • Click으로 함수를 CLI 명령으로 만들고, 동기/비동기 충돌을 asyncio​.run으로 풀 수 있다
  • Rich의 Console + 테마로 어시스턴트 응답을 보기 좋게 출력할 수 있다

[전체 그림] 이 섹션에서 만드는 5단 구조

LAYER 1 · 입력CLI 애플리케이션 (Click)
 
LAYER 2 · 흐름Agent 클래스 (에이전틱 루프)
 
LAYER 3 · 이벤트AgentEvent 처리
 
LAYER 4 · 출력UI 렌더링 (Rich · TUI)
 
LAYER 5 · 실행Run Single Mode

[3-1] 왜 Agent 클래스를 따로 만드는가

지난 섹션의 LLMClient는 "LLM과의 통신만" 책임진다. 그런데 에이전트는 LLM 호출 외에도 컨텍스트 관리, 도구 호출, 세션 관리, 오류 회복 같은 일을 해야 한다. 이걸 모두 LLMClient에 우겨 넣으면 책임이 뒤섞인다.

Agent는 흐름을 지휘한다. LLMClient는 메시지를 보내고 받을 뿐, 어떤 메시지를 어떤 순서로 어떤 도구와 함께 보낼지는 Agent가 결정한다.
📋 Configurator
설정을 한 곳에서 다루는 객체. 모델 이름, API 키, 시스템 프롬프트.
📂 Session
컨텍스트와 세션 상태를 담는 그릇. 각 세션은 자신만의 컨텍스트를 갖는다.

[3-2] 에이전틱 루프 — 한 문장으로 정의하기

에이전틱 루프 = 컨텍스트를 처리하고, LLM을 호출하고, 도구를 실행하는 하나의 회로. 한 번 돌아갈 수도 있고, 도구 호출이 더 필요하면 여러 번 돌 수도 있다.

async def _agentic_loop(self):
    # 1) LLM 호출  2) 응답 스트리밍  3) (나중에) 도구 호출
    async for stream_event in self​.client​.chat_completion(self​.messages):
        yield AgentEvent​.text_delta(stream_event​.text_delta​.content)

[3-3] AgentEvent — UI에 보낼 통합 이벤트

StreamEvent — LLM 통신 이벤트
  • TEXT_DELTA · MESSAGE_COMPLETE · ERROR
  • LLM 응답 자체에만 관심
  • LLMClient 내부에서 발생
AgentEvent — 에이전트 활동 이벤트
  • 턴 시작/종료, 도구 호출, 컨텍스트 압축, 루프 감지…
  • 에이전트의 모든 생명주기 활동
  • Agent가 main 코드에 보내는 통합 신호
main 코드(혹은 CLI)는 AgentEvent만 알면 된다. StreamEvent는 LLMClient ↔ Agent 사이의 내부 신호. 외부에는 노출되지 않는다.
agent / events​.py
from dataclasses import dataclass, field
from enum import Enum
class AgentEventType(Enum):
    AGENT_START         = "agent_start"
    AGENT_END           = "agent_end"
    AGENT_ERROR         = "agent_error"
    TEXT_DELTA          = "text_delta"
    TEXT_COMPLETE       = "text_complete"
    TURN_STARTED        = "turn_started"
    TURN_ENDED          = "turn_ended"
    TOOL_CALL_EXECUTION = "tool_call_execution"
    CONTEXT_COMPACTED   = "context_compacted"
    LOOP_DETECTED       = "loop_detected"
@dataclass
class AgentEvent:
    type: AgentEventType
    data: dict = field(default_factory=dict)
    @classmethod
    def agent_start(cls, message: str):
        return cls(type=AgentEventType​.AGENT_START, data={"message": message})
    @classmethod
    def text_delta(cls, content: str):
        return cls(type=AgentEventType​.TEXT_DELTA, data={"content": content})
    # agent_end, agent_error, text_complete 도 같은 패턴

[3-4] AgentEventType 카탈로그

생명주기

AGENT_START에이전트 작업 시작
AGENT_END최종 응답·토큰 사용량과 함께 종료
AGENT_ERROR에이전트 단계의 오류

텍스트 응답

TEXT_DELTA스트리밍 청크 한 조각
TEXT_COMPLETE텍스트 응답 완성

대화 턴

TURN_STARTED한 사이클 시작 (도구 호출 시 여러 번)
TURN_ENDED한 사이클 종료

고급 기능 (이후 강의용)

TOOL_CALL_EXECUTION도구 호출 실행 단계
CONTEXT_COMPACTED컨텍스트 압축 발생
LOOP_DETECTED무한 루프 감지

[3-5] [실습] Agent 코드 — agentic_loop · run · with

[1] StreamEvent → AgentEvent 변환

from client​.response import StreamEventType
from agent​.events import AgentEvent
async def _agentic_loop(self):
    response_text = ""
    async for event in self​.client​.chat_completion(self​.messages):
        if event​.type == StreamEventType​.TEXT_DELTA:
            content = event​.text_delta​.content
            response_text += content
            yield AgentEvent​.text_delta(content)
        elif event​.type == StreamEventType​.ERROR:
            yield AgentEvent​.agent_error(event​.error)
    yield AgentEvent​.text_complete(response_text)
▶ 이름 변경 한 가지
지난 섹션에서 만든 EventType은 이제 StreamEventType으로 이름을 바꾼다. AgentEventType과 헷갈리지 않게 하기 위해서다.

[2] 외부 진입점 — run()

async def run(self, message: str):
    yield AgentEvent​.agent_start(message)
    self​.messages​.append({"role": "user", "content": message})
    final_response: str | None = None
    async for event in self​._agentic_loop():
        if event​.type == AgentEventType​.TEXT_COMPLETE:
            final_response = event​.data["content"]
        yield event
    yield AgentEvent​.agent_end(final_response, usage=None)
final_response는 반드시 None으로 초기화. 초기화 없이 쓰면 "TEXT_COMPLETE 이벤트가 안 오는 경로"에서 UnboundLocalError가 난다.

[3-6] async with — 왜 컨텍스트 매니저인가

수동 정리 (실수 유발)
agent = Agent(...)
try:
    async for e in agent​.run(msg):
        ...
finally:
    await agent​.client​.close()
async with (자동 정리)
async with Agent(...) as agent:
    async for e in agent​.run(msg):
        ...
# 블록을 벗어나면 자동으로 close
class Agent:
    async def __aenter__(self):
        return self
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self​.client:
            await self​.client​.close()
            self​.client = None

[3-7] Click — main을 CLI 명령으로 만들기

TERMINAL
(​.venv) $ pip install click
import click
@click​.command()
@click​.argument("prompt", required=False)
def main(prompt: str | None = None):
    # prompt 없음 → 인터랙티브 모드 / prompt 있음 → 단일 실행
    ...
데코레이터역할
@click.command()이 함수를 CLI 명령으로 등록
@click.argument()위치 인자 정의 (예: ai_agent "What is your name?")
@click.option()옵션 정의 (예: --count 3)

[3-8] 동기 main + 비동기 run — Click과 async의 짝꿍

Click은 동기 세계에 사는 친구다. 비동기 세계의 async def run을 부르려면 "asyncio​.run()"이라는 다리를 통해야 한다. main은 동기로 두고, 안에서 비동기를 호출하는 패턴이다.
import asyncio
import click
@click​.command()
@click​.argument("prompt", required=False)
def main(prompt: str | None = None):
    asyncio​.run(_run(prompt))   # 동기 main이 비동기를 깨움
async def _run(prompt):
    cli = CLI(...)
    await cli​.run_single(prompt)

[3-9] CLI 클래스 + process_message — UI 계층 분리

class CLI:
    def __init__(self, configurator):
        self​.configurator = configurator
        self​.agent: Agent | None = None
        self​.tui = TUI()
    async def run_single(self, prompt: str):
        async with Agent(self​.configurator) as agent:
            self​.agent = agent
            await self​.process_message(prompt)
    async def process_message(self, message: str):
        if self​.agent is None:
            return None
        assistant_streaming = False
        async for event in self​.agent​.run(message):
            if event​.type == AgentEventType​.TEXT_DELTA:
                if not assistant_streaming:
                    self​.tui​.begin_assistant()
                    assistant_streaming = True
                self​.tui​.stream_assistant_delta(event​.data["content"])
            elif event​.type == AgentEventType​.TEXT_COMPLETE:
                if assistant_streaming:
                    self​.tui​.end_assistant()
                    assistant_streaming = False
            elif event​.type == AgentEventType​.AGENT_ERROR:
                error = event​.data​.get("error", "unknown error")
                self​.tui​.console​.print(f"[error]{error}[/error]")

[3-10] Rich — TUI · AgentTheme · 어시스턴트 출력

TERMINAL
(​.venv) $ pip install rich

[1] AgentTheme — 색상 정책 한 곳에 모으기

from rich​.theme import Theme
agent_theme = Theme({
    "info":      "cyan",
    "warning":   "yellow",
    "error":     "bright_red bold",
    "success":   "green",
    "assistant": "bright_blue",
})

[2] get_console — 전역 싱글톤 Console

from rich​.console import Console
_console: Console | None = None
def get_console() -> Console:
    global _console
    if _console is None:
        _console = Console(theme=agent_theme, highlight=False)
    return _console

[3] TUI 클래스 — 어시스턴트 출력 전담

from rich​.rule import Rule
from rich​.text import Text
class TUI:
    def __init__(self):
        self​.console = get_console()
        self​.assistant_stream_open = False
    def begin_assistant(self):
        self​.console​.print()
        self​.console​.print(Rule(Text("Assistant", style="assistant")))
        self​.assistant_stream_open = True
    def stream_assistant_delta(self, content: str):
        self​.console​.print(content, end="", markup=False)
    def end_assistant(self):
        if self​.assistant_stream_open:
            self​.console​.print()
            self​.assistant_stream_open = False
end=""는 줄바꿈을 막아 스트리밍 청크가 같은 줄에 쌓이게. markup=False는 모델이 우연히 [red] 같은 텍스트를 뱉어도 그대로 출력되게 한다.

[3-11] 디버깅 일지 — 실제로 만난 4가지 오류

단일 실행 모드 작동시키기까지의 디버깅 기록
ImportError — EventType이 client​.response에 없다
EventTypeStreamEventType으로 이름 바꿨음. import 문을 from client​.response import StreamEventType로 수정
코루틴 객체가 출력됨 (실행이 안 됨)
self​.process_message(...) 앞에 await 누락. await self​.process_message(...)로 수정
AttributeError: 'NoneType' object has no attribute 'content'
스트리밍 마지막 청크에서 text_delta가 None일 수 있음. if event​.text_delta is not None: 가드 추가
TEXT_DELTA 이벤트가 ERROR로도 인식됨
Enum에 실수로 @dataclass 데코레이터를 붙였던 것이 원인. StreamEventType은 순수 Enum이어야 한다

[3-12] 핵심 정리 · 실습 체크리스트

★ KEY TAKEAWAYS
  1. Agent 클래스는 흐름의 지휘자. LLMClient는 통신만, Agent가 컨텍스트·도구·세션을 책임진다.
  2. 에이전틱 루프 = 컨텍스트 + LLM 호출 + 도구 실행의 한 회로.
  3. AgentEvent는 LLM 응답을 넘어 에이전트의 모든 활동을 UI에 전달하는 통합 신호.
  4. async with자원 정리 자동화의 핵심.
  5. Click은 동기 함수만 받는다. asyncio​.run()으로 비동기 세계를 깨우는 짝꿍 패턴을 쓴다.
  6. CLI 클래스가 Agent와 TUI 사이의 중재자 역할을 한다.
  7. Rich는 Console + Theme으로 색상·스트리밍 출력을 깔끔하게 처리한다.

체크리스트

  • agent / agent​.py 와 agent / events​.py 파일을 만들었다
  • AgentEventType Enum에 10개 값을 모두 정의했다
  • AgentEvent에 헬퍼 클래스 메서드 5개를 만들었다
  • Agent 클래스에 __aenter__/__aexit__를 구현했다
  • click과 rich를 가상 환경에 설치했다
  • @click.command + @click.argument로 main을 CLI로 만들었다
  • 동기 main에서 asyncio​.run() 으로 비동기 run을 호출하는 구조
  • CLI 클래스의 run_single + process_message를 작성
  • TUI 클래스에 begin/end_assistant + stream_assistant_delta를 구현
  • ai_agent "안녕" 명령어로 어시스턴트 응답이 한 줄에 흐르듯 출력
END · SECTION 03
PART 1 · 종합 정리

섹션 1~3을 관통하는 한 가지 인사이트

우리는 가상 환경에서 시작해 진짜 실행 가능한 CLI 에이전트의 골격까지 만들었다. 핵심 흐름을 한 번 더 짚어 본다.

[전체 그림] PART 1에서 우리가 만든 것

SECTION 01
Foundation
가상 환경 + Hello World
SECTION 02
LLM Client
싱글톤 + 스트리밍 + 백오프
SECTION 03
CLI Application
Agent + Click + Rich
★ PART 1 - 7가지 교훈
  1. AI 에이전트는 "답"이 아니라 "결과물"을 만드는 시스템이다.
  2. 똑똑함의 비밀은 LLM이 아니라 도구·기억·컨텍스트·피드백 루프다.
  3. 가상 환경은 의존성 충돌을 막는 독립된 부엌. 시작 전 무조건 만든다.
  4. OpenAI 라이브러리의 base URL 트릭으로 어떤 LLM에든 같은 코드로 연결한다.
  5. 모든 응답은 StreamEventAgentEvent 두 단계의 통합 이벤트로 흐른다.
  6. 실서비스의 안정성은 try/except + 지수 백오프에서 온다.
  7. 코드의 명료함은 계층 분리(CLI · Agent · LLMClient · TUI)에서 온다.
▶ NEXT · PART 2 
Context · Prompt Engineering · Tool Calling
PART 2에서는  messages 리스트를 정식 컨텍스트 매니저로 분리하고, 시스템 프롬프트 설계·도구 호출·툴 레지스트리까지 — 에이전트가 진짜 "행동"하기 시작하는 단계를 다룬다.

📚 참고 자료 · 강의 첨부 링크


여기까지 따라왔다면, 우리는 단순히 LLM을 호출하는 스크립트가 아니라 "명령어로 부르는 에이전트의 첫 모양"을 손에 쥔 셈이다. 다음 PART부터는 컨텍스트·프롬프트·도구가 차례로 붙는다. 골격이 단단하니, 새 살이 붙어도 무너지지 않는다.

— PART 1 마무리 노트