AI Note · Claude Code Crash Course
AI 코딩 에이전트 직접 만들기 - 셋업부터 첫 실행 모드까지
가상 환경 셋업 → LLM 연결과 안정화 → CLI 애플리케이션 골격까지 가이드
AI 코딩 에이전트의 첫 걸음 - 챗봇과 무엇이 다른가, 그리고 왜 가상 환경부터 시작하는가
- AI 코딩 에이전트가 단순 챗봇과 무엇이 다른지 한 문단으로 설명할 수 있다
- 도구·기억·상황 이해·피드백 루프 — 에이전트의 4대 능력을 구분해 말할 수 있다
- "의존성"과 "가상 환경"이 왜 필요한지 비유로 풀어낼 수 있다
- 터미널에서 Python 가상 환경을 직접 만들고, Hello World로 검증할 수 있다
- 다음 강의(LLM 연결)로 자연스럽게 넘어갈 준비가 되어 있다
요즘 AI 코딩 에이전트(AI Coding Agent)라는 이름이 부쩍 자주 들린다. Claude Code, Cursor, Codex, Gemini CLI… 처음에는 다들 이렇게 생각했다 - "그냥 LLM에 '이렇게 짜줘' 하면 코드 만들어 주는 거 아닌가? 뭐 그리 복잡할 게 있겠어?"
그런데 안을 들여다보면 이야기가 다르다. AI 에이전트가 진짜 똑똑해지는 지점은 LLM 자체의 성능보다, 주변 도구를 잘 다루고, 기억하고, 실수에서 배우는 구조에 있다. 이 강의에서는 그런 에이전트를 직접 한 번 만들어 본다. 첫 걸음으로 오늘은 "왜 가상 환경이 필요한가"까지 다룬다.
[1-1] AI 에이전트, 단순한 챗봇이 아니다
AI 에이전트를 처음 마주할 때 가장 흔한 오해는 "결국 챗봇 아냐?"다. 실제로 AI 에이전트는 챗봇과 결정적인 차이가 있다.
예를 들어 "이 폴더의 파일들을 정리해서 한 페이지 요약 보고서 만들어줘"라는 요청을 받았을 때 —
- 챗봇 : "이런 식으로 정리해 보세요" 하고 가이드 문장을 돌려준다.
- 에이전트 : 폴더를 스스로 열고, 파일을 읽고, 요약을 작성해 새 파일로 저장하기까지 한다.
겉보기에는 비슷해 보여도, 안에서 일어나는 일은 전혀 다르다. 에이전트는 단발성 응답이 아니라 계획 → 실행 → 점검 → 다시 시도의 루프를 스스로 돈다.
[1-2] 에이전트가 가진 네 가지 핵심 능력(기능)
그렇다면 에이전트를 에이전트답게 만드는 능력은 무엇일까. 네 가지로 정리하면 다음과같다.
여기에 더해, 너무 복잡한 일은 작은 보조 에이전트(sub-agent)들을 만들어 위임한다. 회계 검증은 회계 담당, 문구 다듬기는 카피 담당 - 사람 조직의 분업과 같은 그림이다.
이 네 가지는 새로운 개념이 아니다. "신입 직원에게 일을 맡길 때 우리가 자연스럽게 기대하는 것" — 도구 사용, 메모, 우선순위 판단, 피드백 수용 — 그대로다. AI 에이전트도 같은 기준으로 바라보면 된다.
[1-3] 에이전트는 실제로 무슨 일을 할 수 있는가
위 네 가지 능력이 합쳐졌을 때, 실제 작업 능력은 다음과 같이 펼쳐진다.
| 기능 | 설명 · 사무국 적용 예시 |
|---|---|
| 코드 읽기/쓰기 | 기존 코드를 이해하고 새 코드를 만든다. 자동화 스크립트가 필요할 때 가장 먼저 쓰이는 능력. |
| 명령 실행 | "이 PDF 50장을 모두 그림으로 변환" 같은 일을 직접 명령으로 수행한다. |
| 웹 검색 | 최신 시세·정책·뉴스를 가져와 즉석에서 표·리포트로 정리. |
| 기억하기 | "우리 협회 어투", "회원사 양식" 같은 반복 맥락을 저장해 매번 다시 설명하지 않게 한다. |
| 계획 세우기 | 큰 작업을 여러 단계로 쪼개 우선순위까지 정한다. |
| 실수에서 배우기 | 실행이 실패하면 이유를 파악해 다른 방법으로 재시도한다. |
| 확장 가능 | 사용자가 직접 새 도구를 추가하거나 외부 서비스와 연결할 수 있다 (이후 13강 MCP 참고). |
[1-4] 왜 가상 환경부터 시작하는가
이제 직접 만들기로 들어가자. 그런데 첫 줄의 코드를 짜기 전에, 먼저 해야 할 일이 있다. 바로 가상 환경(Virtual Environment) 구성이다.
[1] 의존성, 그리고 의존성 충돌
AI 에이전트를 만들려면 여러 외부 라이브러리(파이썬 패키지)가 필요하다. 이걸 통틀어 의존성(dependency)이라고 부른다. 문제는 - 프로젝트마다 필요한 의존성 버전이 다르다는 점이다.
[2] 가상 환경이라는 해법
가상 환경은 이름 그대로 프로젝트마다 독립된 부엌을 만들어 주는 장치다. 각 부엌에는 그 프로젝트가 필요한 재료(라이브러리)만 따로 들어 있고, 다른 부엌에 무엇이 있든 영향을 받지 않는다.
[1-5] [실습] 가상 환경 만들고 Hello World 찍기
여기부터는 손을 움직이자. 터미널을 열고 그대로 따라하면 된다. Mac은 "터미널" 앱, Windows는 "PowerShell" 또는 "Windows Terminal"을 검색해 실행한다.
$ mkdir AI_agent
$ cd AI_agent
$ python -m venv .venv
# 만약 위 명령이 안 되면
$ python3 -m venv .venv
.venv라는 이름의 독립된 부엌(가상 환경)이 생성된다.$ source .venv/bin/activate
PS> .venv\Scripts\Activate.ps1
(.venv) 표시가 붙는다.PS> Set-Content -Path main.py -Value 'print("hello world")'
(.venv) $ python main.py
hello world
python: command not found→python3로 다시 시도- Windows에서 활성화 스크립트 실행이 막힘 → PowerShell을 관리자 권한으로 열고
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned한 번 실행 - 활성화는 됐는데 모듈을 못 찾음 → 활성화된 가상 환경 안에서
pip install을 다시 했는지 확인
[1-6] LLM 연결 — 다음 스텝으로 가는 다리
가상 환경까지 마련했으니, 이제 에이전트의 "뇌"를 연결할 차례다.
LLM(Large Language Model)은 에이전트의 두뇌다. "이 코드를 고쳐라"라는 요청이 들어오면, 에이전트는 LLM에게 "어떻게 고치면 될까?"를 묻고, 그 답을 받아 실제 행동(파일 수정, 명령 실행 등)을 한다.
[1-7] 핵심 정리 · 실습 체크리스트
- AI 에이전트는 챗봇이 아니라 "행동하는 시스템"이다. 답이 아니라 결과물을 만든다.
- 에이전트가 똑똑한 이유는 LLM 자체가 아니라 도구·기억·상황 이해·피드백 루프가 결합된 구조 덕이다.
- 의존성 충돌을 막으려면 프로젝트마다 독립된 가상 환경을 둔다.
- 가상 환경 만들기는
venv 생성 → 활성화 → 안에서 실행세 단계로 끝난다. - 다음 단계는 LLM 연결 — 에이전트의 뇌를 끼우는 일이다.
체크리스트
- 터미널에서
python --version이 정상 출력되는지 확인했다 AI_agent폴더를 만들고 그 안으로 이동했다.venv가상 환경을 생성했다- 활성화 후 프롬프트 앞에
(.venv)표시를 확인했다 - main.py 파일을 만들어
print("hello world")를 실행했다 - 도구·기억·상황 이해·피드백 루프 — 네 가지를 입으로 한 번 말해 봤다
LLM Connection & Reliability - OpenRouter, 스트리밍, 그리고 지수 백오프
- 왜 OpenAI 라이브러리만으로 OpenRouter·Anthropic·기타 LLM에 모두 연결할 수 있는지 설명할 수 있다
LLMClient클래스를 싱글톤 패턴으로 직접 만들 수 있다- 스트리밍과 비스트리밍 응답을 구분하고, 각각 언제 써야 하는지 판단할 수 있다
StreamEvent · EventType · TextDelta · TokenUsage데이터 구조를 설계할 수 있다yield와return의 차이를 이해하고 비동기 제너레이터를 작성할 수 있다- 지수 백오프(Exponential Backoff)로 재시도 로직을 구현할 수 있다
지난 섹션에서 가상 환경까지 깔았다. 이제 그 위에 에이전트의 뇌인 LLM을 올린다. 그런데 단순히 "LLM에 메시지 한 번 보내고 답 받기"로는 충분하지 않다. 실서비스에서 부딪히는 세 가지를 함께 풀어야 한다.
① 어떤 모델이든 갈아끼울 수 있어야 한다. Anthropic, OpenAI, Mistral, Llama… 한 군데 묶이면 안 된다.
② 응답이 길어도 사용자가 기다리지 않아야 한다. 스트리밍이 필요하다.
③ 실패해도 무너지지 않아야 한다. 속도 제한·네트워크 오류에 재시도로 버텨야 한다.
[2-1] 왜 LLM 클라이언트를 직접 설계하는가
"그냥 OpenAI 라이브러리 한 줄 쓰면 되는 거 아닌가?"라고 생각할 수 있다. 맞기도 하고 틀리기도 하다. 한 번 실험할 때는 충분하지만, 에이전트는 LLM과 수십·수백 번 대화해야 한다. 그때마다 똑같은 에러 처리, 재시도, 응답 파싱을 매번 적는 건 낭비다.
[2-2] OpenAI 라이브러리 + Base URL 트릭
처음에 헷갈리는 부분이다 — "OpenAI 라이브러리인데 OpenRouter에 연결한다고?" 가능한 이유는 OpenAI 라이브러리가 base URL을 바꿀 수 있게 만들어 놨기 때문이다.
[2-3] OpenRouter — 여러 LLM을 하나의 창구로
OpenRouter는 한 마디로 "LLM 통합 인터페이스"다. 여러 회사의 모델을 한 곳에서 검색·호출할 수 있고, 다음 세 가지 장점이 강의에서 강조된다.
| 장점 | 내용 |
|---|---|
| 안정적인 라우팅 | 모델별 가동 시간(uptime)을 보고 가장 안정적인 제공자로 자동 전환. |
| 모니터링 지표 | 처리량(throughput), 지연 시간(latency), 가동률을 한 화면에서 확인. |
| 무료 모델 풀 | Anthropic·OpenAI 자체 API는 유료지만, OpenRouter는 다양한 무료 모델을 제공. |
[실습 준비] OpenRouter API 키 발급
openrouter.ai 접속 → 회원가입. 신용카드 정보는 요구되지 않는다.
키 이름(예: AI Agent)과 만료 기간을 지정. 발급 후 화면에 한 번만 표시된다.
한 번 닫으면 다시 볼 수 없으므로 비밀번호 매니저나 메모에 저장.
.env)나 시크릿 매니저를 통해 관리한다.[2-4] [실습] LLMClient 클래스 만들기 (싱글톤)
핵심 아이디어는 두 가지 — 싱글톤 패턴(연결을 하나만 유지)과 모델 선택의 유연성(연결 시점이 아니라 호출 시점에 모델을 지정)이다.
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 비스트리밍
- 전체 응답이 완성될 때까지 기다린 후 한 번에 받음
- 15초짜리 응답이면 15초간 깜깜
- 적합 : 내부 처리용 — 요약, 컨텍스트 압축
- 토큰이 만들어지는 즉시 청크 단위로 전송
- 첫 30개 토큰이 1초 만에 도착, 이후 계속 흘러옴
- 적합 : 사용자에게 보여주는 모든 응답
단일 진입점 — 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라는 통합 이벤트 객체를 만든다.
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는 "에피타이저 → 메인 → 디저트" 한 코스씩 차례로. 두 번째가 스트리밍에 딱이다.[2-9] try/except와 지수 백오프
| 오류 유형 | 원인 | 대응 |
|---|---|---|
| RateLimitError | 분당/일당 호출 한도 초과 | 지수 백오프로 잠시 후 재시도 |
| APIConnectionError | 네트워크 단절·DNS 오류 | 지수 백오프로 잠시 후 재시도 |
| APIError | 서버 측 오류·잘못된 응답 | 재시도 없이 즉시 ERROR 이벤트 yield |
지수 백오프 — 점점 길게 기다리기
같은 요청을 즉시 재시도하면 다시 실패할 가능성이 높다. 기다리는 시간을 점점 늘리면서 재시도하는 것이 지수 백오프다. 공식은 단순하다 — wait_time = 2 ** attempt.
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] 핵심 정리 · 실습 체크리스트
- LLM과의 통신은 한 모듈(
LLMClient)에 몰아넣는다. - OpenAI 라이브러리의 base URL을 OpenRouter로 바꾸면 한 코드로 모든 LLM에 연결된다.
- 스트리밍은 사용자용, 비스트리밍은 내부 처리용. 한 진입점에서 분기한다.
- 모든 응답은
StreamEvent로 통합한다. EventType만 보면 텍스트인지 완료인지 오류인지 안다. yield는 함수를 일시정지하고 호출자에게 값을 흘린다. 비동기 제너레이터의 본질.- 일시적 오류는 지수 백오프, 영구적 오류는 즉시 ERROR.
체크리스트
- OpenRouter에 가입하고 API 키를 발급받았다
- openai 라이브러리를 가상 환경에 설치했다
LLMClient클래스를 싱글톤 패턴으로 작성했다EventType · TextDelta · TokenUsage · StreamEvent데이터 구조를 정의했다- 스트리밍 모드로 LLM에 메시지를 보내고, 청크가 실시간으로 출력되는 것을 확인했다
- 일부러 잘못된 API 키를 넣어 보고 백오프 작동을 확인했다
yield와return의 차이를 한 문장으로 설명할 수 있다
CLI Application Foundation - Click · Agent 클래스 · Rich로 만든 첫 단일 실행 모드
- "에이전틱 루프"의 의미를 코드 차원에서 설명할 수 있다
StreamEvent와AgentEvent가 왜 따로 존재해야 하는지 설명할 수 있다AgentEvent클래스에 헬퍼 클래스 메서드를 직접 만들 수 있다async with컨텍스트 매니저로 LLM 연결을 자동 정리할 수 있다- Click으로 함수를 CLI 명령으로 만들고, 동기/비동기 충돌을 asyncio.run으로 풀 수 있다
- Rich의
Console+ 테마로 어시스턴트 응답을 보기 좋게 출력할 수 있다
[전체 그림] 이 섹션에서 만드는 5단 구조
[3-1] 왜 Agent 클래스를 따로 만드는가
지난 섹션의 LLMClient는 "LLM과의 통신만" 책임진다. 그런데 에이전트는 LLM 호출 외에도 컨텍스트 관리, 도구 호출, 세션 관리, 오류 회복 같은 일을 해야 한다. 이걸 모두 LLMClient에 우겨 넣으면 책임이 뒤섞인다.
[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에 보낼 통합 이벤트
- TEXT_DELTA · MESSAGE_COMPLETE · ERROR
- LLM 응답 자체에만 관심
- LLMClient 내부에서 발생
- 턴 시작/종료, 도구 호출, 컨텍스트 압축, 루프 감지…
- 에이전트의 모든 생명주기 활동
- Agent가 main 코드에 보내는 통합 신호
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 카탈로그
생명주기
텍스트 응답
대화 턴
고급 기능 (이후 강의용)
[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 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 명령으로 만들기
(.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의 짝꿍
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 · 어시스턴트 출력
(.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에 없다EventType을 StreamEventType으로 이름 바꿨음. 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: 가드 추가@dataclass 데코레이터를 붙였던 것이 원인. StreamEventType은 순수 Enum이어야 한다[3-12] 핵심 정리 · 실습 체크리스트
- Agent 클래스는 흐름의 지휘자. LLMClient는 통신만, Agent가 컨텍스트·도구·세션을 책임진다.
- 에이전틱 루프 = 컨텍스트 + LLM 호출 + 도구 실행의 한 회로.
AgentEvent는 LLM 응답을 넘어 에이전트의 모든 활동을 UI에 전달하는 통합 신호.async with는 자원 정리 자동화의 핵심.- Click은 동기 함수만 받는다.
asyncio.run()으로 비동기 세계를 깨우는 짝꿍 패턴을 쓴다. - CLI 클래스가 Agent와 TUI 사이의 중재자 역할을 한다.
- Rich는
Console + Theme으로 색상·스트리밍 출력을 깔끔하게 처리한다.
체크리스트
- agent / agent.py 와 agent / events.py 파일을 만들었다
AgentEventTypeEnum에 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 "안녕"명령어로 어시스턴트 응답이 한 줄에 흐르듯 출력
섹션 1~3을 관통하는 한 가지 인사이트
[전체 그림] PART 1에서 우리가 만든 것
- AI 에이전트는 "답"이 아니라 "결과물"을 만드는 시스템이다.
- 똑똑함의 비밀은 LLM이 아니라 도구·기억·컨텍스트·피드백 루프다.
- 가상 환경은 의존성 충돌을 막는 독립된 부엌. 시작 전 무조건 만든다.
- OpenAI 라이브러리의 base URL 트릭으로 어떤 LLM에든 같은 코드로 연결한다.
- 모든 응답은
StreamEvent→AgentEvent두 단계의 통합 이벤트로 흐른다. - 실서비스의 안정성은 try/except + 지수 백오프에서 온다.
- 코드의 명료함은 계층 분리(CLI · Agent · LLMClient · TUI)에서 온다.
📚 참고 자료 · 강의 첨부 링크
- DOCS OpenRouter 공식 문서
- DOCS Click 공식 문서
- DOCS Rich 공식 문서
여기까지 따라왔다면, 우리는 단순히 LLM을 호출하는 스크립트가 아니라 "명령어로 부르는 에이전트의 첫 모양"을 손에 쥔 셈이다. 다음 PART부터는 컨텍스트·프롬프트·도구가 차례로 붙는다. 골격이 단단하니, 새 살이 붙어도 무너지지 않는다.
'인공지능과 오토메이션 > Automation' 카테고리의 다른 글
| Claude Memory 기능 정리 (0) | 2026.05.08 |
|---|---|
| Claude·Cowork·Skills 종합 가이드 (0) | 2026.05.08 |
| AI PPT 제작 올인원 클래스 - 하루만에 끝내는 AI PPT 자동화 (0) | 2026.04.23 |
| Claude Opus 4.7 실전 - 리뷰3일 써본 사람들이 말하는 진짜 후기 (0) | 2026.04.17 |
| KB 지식 비타민 | AI는 소프트웨어 기업의 비즈니스 모델 붕괴를 불러올까 (0) | 2026.03.25 |