AI 공부

18. 상품 핫딜 헌팅 에이전트 (2) - Tool call 자율 에이전트와 에이전트 시스템 완성

kdb1248 2026. 8. 8. 17:13

목차

0. 들어가며 — 가격은 맞히게 됐다, 이제 무엇의 가격을 맞힐지 정할 차례
1. 상품 딜은 어디서 찾나 - RSS 피드를 긁어오는 코드
2. 비정형 데이터를 정형으로 - Structured Outputs와 제약 디코딩
3. 네 번째 에이전트 - 30건 딜 중 5건을 골라내는 ScannerAgent
4. 다섯 번째 에이전트 - 딜 알림을 보내주는 메세징 에이전트
5. 먼저 규칙으로 조율하기 - PlanningAgent
6. 이제 실행 순서를 LLM에게 맡긴다 - 툴 콜과 자율 에이전트
7. 피날레 - 프레임워크와 Gradio 이용 UI, 그리고 실제로 찾아낸 딜
마무리 — 에이전틱 AI, 그리고 8주의 회고

 

0. 들어가며 — 가격은 맞히게 됐다, 이제 무엇의 가격을 맞힐지 정할 차례

지난 글에서 Week 8의 절반을 정리했다.

1) 파인튜닝한 Llama 3.2를 Modal에 올려 항상 켜져 있는 서비스로 만들고(SpecialistAgent)
2) 상품 80만 개를 Chroma DB(벡터스토어)에 넣어 GPT-5.1에게 유사 상품을 RAG로 쥐여주고(FrontierAgent)
3) Week 6의 신경망도 가격 예측에 쓰고 (neural network Agent)
4) 세 개를 섞어 앙상블 에이전트 을 만들었다(EnsembleAgent)

 

그래서 지금 가능한 것은 다음과 같다.

agent.price("Shure MV7+ Podcast Microphone")

-> (출력) 261.21

꽤 쓸 만해 보이지만, 여기엔 빠진 게 하나 있다. 저 따옴표 안에 무엇을 넣을지는 여전히 내가 정한다는 것.

지금까지 만든 가격 예측 에이전트는 "이 상품 얼마야?"라고 질문을 넣으면 거기에 답하는 도구일 뿐이고,

질문 자체는 사람이 만들어서 넣어주고 있다.

 

이번 글(Week 8의 남은 영역) 에서 채우는 게 정확히 그 부분이다.

1) 인터넷에서 상품 딜을 긁어오고
2) 그중 살펴볼 만한 것을 스스로 고르고
3) 값을 매기고
4) 진짜 상품의 본래 가치 대비 싸다 싶으면 나한테 알림을 보내는 것까지

 

사람이 하는 일은 프로그램을 켜두는 것뿐이 된다.

Week 8의 남은 일정은 이렇다.

- Day 3: 딜을 모아오고 고르는 에이전트와 알림을 보내는 에이전트를 만듬

- Day 4: 이 에이전트들을 조율하는 플래너를 두 가지 방식으로 만들어봄. 하나는 순서를 코드에 박아둔 방식, 다른 하나는 순서를 LLM에게 맡기는 방식.

- Day 5: Day 1~4에서 만든 에이전트 시스템을 전부 묶어서 화면(UI)을 붙이는 피날레

 

그리고 이 글이 Ed donner의 LLM Engineering 강의 정리의 마지막 글이다.

글 마무리에는 Week 8 결론과 함께 8주 코스 전체를 돌아보는 내용을 담았다.


 

1. 상품 딜은 어디서 찾나 - RSS 피드를 긁어오는 코드

에이전트가 상품의 가격 예측을 해보려면 먼저 "어떤 상품이 지금 얼마에 팔리고 있는지"를 알아야 한다.

(해당 상품이 정말 모델의 가격 예측 대비 싸게 나온지 판단하기 위함)

 

그 원천 데이터를 가져오는 코드가 agents/deals.py다.

여기엔 LLM이 한 줄도 안 들어간다. 그냥 웹에서 긁어오는 코드다.

 

딜은 dealnews의 RSS 피드에서 온다

강의는 dealnews.com이라는 미국 핫딜 사이트의 RSS 피드 3개를 쓴다.

feeds = [
    "https://www.dealnews.com/c142/Electronics/?rss=1",
    "https://www.dealnews.com/c39/Computers/?rss=1",
    "https://www.dealnews.com/f1912/Smart-Home/?rss=1",
]

각각 전자제품 / 컴퓨터 / 스마트홈 카테고리다. 주석에는 자동차, 홈&가든 피드도 적혀 있는데 원하면 늘리면 된다.

RSS를 읽는 건 feedparser라는 오픈소스 라이브러리가 다 해준다.

@classmethod
def fetch(cls, show_progress: bool = False) -> List[Self]:
    deals = []
    feed_iter = tqdm(feeds) if show_progress else feeds
    for feed_url in feed_iter:
        feed = feedparser.parse(feed_url)
        for entry in feed.entries[:10]:   # 피드당 최신 10건만
            deals.append(cls(entry))
            time.sleep(0.05)              # 사이트에 부담 주지 않도록 잠깐 쉼
    return deals

피드 3개 × 10건 = 30건. 이게 한 사이클에서 다루는 후보 딜의 양이다.

RSS 요약만으로는 부족하다

여기서 좀 의외였던 부분.

feedparser가 준 항목에는 제목과 짧은 요약만 들어 있는데, 이걸로는 가격을 판단하기에 정보가 부족하다.

그래서 ScrapedDeal은 생성될 때 딜의 상세 페이지까지 한 번 더 직접 방문한다.

def __init__(self, entry: Dict[str, str]):
    self.title = entry["title"]
    self.summary = extract(entry["summary"])
    self.url = entry["links"][0]["href"]
    stuff = requests.get(self.url).content              # 상세 페이지를 직접 가져옴
    soup = BeautifulSoup(stuff, "html.parser")
    content = soup.find("div", class_="content-section").get_text()
    content = content.replace("\nmore", "").replace("\n", " ")
    if "Features" in content:
        self.details, self.features = content.split("Features", 1)   # 설명 / 스펙 분리
    else:
        self.details = content
        self.features = ""
    self.truncate()

BeautifulSoup으로 content-section div를 찾아 텍스트만 뽑고, "Features"라는 단어를 기준으로 앞뒤를 잘라 설명(details)과 스펙(features)으로 나눈다.

=> dealnews 페이지 구조에 딱 맞춰 짠 코드다. 사이트가 개편되면 바로 깨진다. 이 점이 2장에서 다룰 이야기의 복선이 된다.

모델에 보내기 전에 길이를 자른다

__init__ 마지막 줄의 truncate()가 하는 일은 단순하다.

def truncate(self):
    self.title = self.title[:100]
    self.details = self.details[:500]
    self.features = self.features[:500]

이유는 주석에 그대로 적혀 있다.

Limit the fields to a sensible length to avoid sending too much info to the model

-> 모델에 너무 많은 정보를 보내지 않도록 각 필드 길이를 적당히 제한한다

30건을 통째로 프롬프트에 밀어 넣을 거라서, 한 건이 길어지면 그대로 토큰 비용과 지연으로 돌아온다.

그리고 모델에게 보낼 때는 describe()가 한 건을 한 덩어리 텍스트로 만들어준다.

def describe(self):
    return f"Title: {self.title}\nDetails: {self.details.strip()}\nFeatures: {self.features.strip()}\nURL: {self.url}"

실제로 긁어보면

deals = ScrapedDeal.fetch(show_progress=True)

-> (출력) 100%|██████████| 3/3 [00:54<00:00, 18.15s/it]

len(deals)

-> (출력) 30

30건 모으는 데 54초가 걸렸다. RSS 자체는 금방인데, 30번의 상세 페이지 방문이 시간을 다 잡아먹는다.

한 건을 열어보면 이렇게 생겼다.

deals[10].describe()
Title: Logitech Gaming Deals at Woot: Up to 54% off + extra $5 off + free shipping w/ Prime
Details: Woot is discounting a range of Logitech gaming gear, from racing wheels and pedals to headsets,
keyboards, mice, and speakers. Using promo code "LOGITECHWOOT" takes an extra $5 off, and new customers
get an additional $10 off. The lineup spans steep markdowns, like the Logitech G Pro Mouse League of
Legends Edition at $59.99, down 54% off its regular price, alongside refurbished options such as the
G435 LIGHTSPEED headset at $24.99 for shoppers who don't need new condition. Coupon ends July 18.
Features: 
URL: https://www.dealnews.com/Logitech-Gaming-Deals-at-Woot-Up-to-54-off-extra-5-off-free-shipping-w-Prime/21897103.html?iref=rss-c39

여기서 문제가 보인다. 이 딜은 상품 하나가 아니라 여러 제품 묶음이고, 가격도 "최대 54% 할인", "추가 $5 off", "$59.99", "$24.99"가 뒤섞여 있다.

=> 이 텍스트를 그대로 가격 예측 에이전트에 넣을 수는 없다. 사람이 읽어도 "그래서 뭘 얼마에 파는 건데?"가 바로 안 나온다.

즉 지금 손에 들어온 건 30개의 자유 형식 텍스트 덩어리이고,

다음 단계에 필요한 건 상품 설명 한 문단과 숫자 하나(가격)로 정리된 데이터다.

비정형 데이터를 정형으로 바꾸는 작업이 필요하고, 그게 2장의 주제다.

 

느낀점

  • 이번장은 AI 기술보다는 데이터를 AI에게 어떻게 전달할것인가와 이를 위한 파이프라인에 대한 고민임
  • 실무에서도 에이전트를 만들어가면 갈수록, 결국은 에이전트에 컨텍스트로 넣어줄 데이터/지식 성격을 어떻게 적재하고, 또 이를 지속적으로 업데이트해가며 적재할 수 있는 파이프라인에 대한 고민이 늘어나는 것 같다. 
  • 그런점에서 이번 장은 실제 상품 딜의 원천 소스를 정하고, 부족할 수 있는 정보를 상세페이지까지 들어가서 한번 더 긁고, llm 에게 넣는 토큰비용/지연시간 고려해서 데이터를 자르는 등 실제 어찌보면 가장 중요할 수 있는 데이터 정제과정에서의 관점을 다뤄볼 수 있다는 게 의미가 있었다.

2. 비정형 데이터를 정형으로 - Structured Outputs와 제약 디코딩

1장 끝에서 손에 쥔 건 30개의 자유 형식 텍스트였다. 이걸 다음 단계로 넘기려면 이런 형태가 되어야 한다.

  1. 상품 자체를 설명하는 문단 하나 (할인 조건을 제외한 본질적인 상품 설명)
  2. 실제 판매가 숫자 하나
  3. 원본 URL

Day 3의 주제가 바로 이 변환이다.

강의 노트에 적어둔 한 줄로 요약하면 "비정형 데이터를 파싱해서 정형화된 형태로 변환"하는 것.

 

이 대목에서 강사가 짚는 맥락이 재미있었다.

이런 작업은 몇 년 전까지만 해도 잘 풀리지 않는 문제였다. 도메인마다 파서를 따로 만들었는데, 문제는 형식이 조금만 달라지면 바로 깨진다는 것이다.

1장에서 본 soup.find("div", class_="content-section") 같은 코드가 딱 그렇다.

dealnews가 클래스 이름 하나만 바꿔도 끝이다.

(실제로 전 회사에서 AI B2B 사업을 하는 과정에서 파서를 납품할때도 고객사, 파싱하려는 대상에 따라 결국 다 수작업과 튜닝이 맞춤형 개발이 들어갈 수 밖에 없다고 많이 이야기했던게 기억이 난다.)

 

LLM이 이 문제를 바꿔줬다고 강사는 말한다.

강사는 이걸 "지능형 파싱(intelligent parsing)"이라고 부른다.

형식을 규칙으로 기술하는 게 아니라, 모델이 읽고 이해해서 원하는 구조로 뽑아주는 방식이다.

이력서 파싱처럼 예전에는 정규식과 규칙으로 힘들게 하던 일들이 LLM으로 훨씬 쉬워진 게 같은 맥락이다.

=> 즉 Structured Outputs는 단순한 편의 기능이 아니라, 그동안 안 풀리던 문제 하나를 통째로 대체하는 도구다.

 

먼저 원하는 형태를 파이썬 클래스로 정의한다

Structured Outputs를 쓰는 방식은 이렇다.

pydantic의 BaseModel을 상속한 클래스를 정의하면, 모델이 그 클래스의 인스턴스를 돌려준다.

agents/deals.py에 정의된 클래스가 이것이다.

class Deal(BaseModel):
    product_description: str = Field(
        description="Your clearly expressed summary of the product in 3-4 sentences. Details of the item are much more important than why it's a good deal. Avoid mentioning discounts and coupons; focus on the item itself."
    )
    price: float = Field(
        description="The actual price of this product, as advertised in the deal. Be sure to give the actual price; for example, if a deal is described as $100 off the usual $300 price, you should respond with $200"
    )
    url: str = Field(description="The URL of the deal, as provided in the input")


class DealSelection(BaseModel):
    deals: List[Deal] = Field(
        description="Your selection of the 5 deals that have the most detailed, high quality description and the most clear price."
    )

여기서 눈여겨볼 게 Field(description=...)다. 그냥 개발자용 주석처럼 보이지만 아니다.

특히 price 필드의 설명을 보면 거의 프롬프트다.

if a deal is described as $100 off the usual $300 price, you should respond with $200

-> "$300짜리를 $100 할인"이라고 쓰여 있으면 $200이라고 답해라

 

1장의 Logitech 딜에서 봤던 "최대 54% 할인", "추가 $5 off" 같은 함정을 여기서 미리 막고 있는 것이다.

=> description은 사람을 위한 문서이자 동시에 LLM에게 주는 힌트다. 두 역할을 겸한다.

 

여기서 내가 착각하고 있던 게 하나 있었다.

나는 막연히 "AI가 JSON을 잘 배워서 특정 형식으로 잘 뱉어주는구나" 정도로 생각했는데, 실제 동작은 그게 아니었다.

전체 흐름은 이렇다.

① 개발자가 pydantic 클래스를 정의
        ↓
② pydantic이 JSON Schema를 자동 생성
        ↓
③ OpenAI SDK가 그 스키마를 시스템 프롬프트에 삽입
   "You must output JSON matching this schema."
        ↓
④ 모델은 JSON '텍스트'를 생성
        ↓
⑤ SDK가 그 JSON을 파싱
        ↓
⑥ pydantic이 파이썬 객체로 변환
        ↓
⑦ 개발자는 deal.price 처럼 그냥 꺼내 쓴다

핵심은 ④다. 모델이 파이썬 객체를 만드는 게 아니라, 스키마를 만족하는 JSON 텍스트를 만든다.

객체로 바꿔주는 건 SDK와 pydantic이다.

그리고 진짜 트릭은 제약 디코딩이다

여기까지만 보면 "그래서 결국 프롬프트로 부탁하는 거 아냐?"라는 의문이 남는다.

실제로 초기 Function Calling 시절에는 그랬다. "JSON으로 답해줘"라고 프롬프트를 주면 대체로 잘 되지만, 가끔 앞에 Sure!를 붙이거나 마지막에 쉼표를 잘못 넣어서 파싱이 깨졌다.

 

지금은 다르다. 강의에서 언급되는 게 inference time constrained decoding(추론 시점 제약 디코딩)이다.

LLM은 한 번에 문장을 만드는 게 아니라 토큰을 하나씩 뽑는다. 매 순간 "다음 토큰이 무엇일지"에 대한 확률 분포를 계산한다.

예를 들어 { 까지 생성한 상태라면 다음 후보가 이럴 수 있다.

"name"   40%
"age"    20%
"hello"  15%
"}"      10%

원래대로면 hello가 나올 가능성도 있다. 그런데 스키마에 hello라는 필드가 없다면, OpenAI는 그 토큰의 확률을 아예 0으로 만들어버린다. 선택 후보에서 제거하는 것이다.

일반 생성:   모든 토큰 -> 확률 높은 것 선택
제약 디코딩: 모든 토큰 -> 스키마 위반 토큰 제거 -> 남은 것 중에서 선택

타입도 마찬가지다.

age가 integer로 정의돼 있으면 "thirty"라는 토큰은 확률이 높든 낮든 나올 수 없다.

=> 예전은 "JSON으로 답해줘"는 부탁이었다면, 이 방식은 강제다.

모델이 더 똑똑해져서 형식을 잘 지키는 게 아니라, 형식을 어기는 선택지 자체를 출력 단계에서 없애버리는 것이다.

 

"inference time 테크닉"이라고 부르는 이유도 여기서 나온다.

학습으로 모델 가중치를 바꾸는 게 아니라, 학습이 끝난 모델이 답을 생성하는 그 순간에만 제약을 건다.

 

실제로 호출해보면

호출 자체는 허무할 정도로 간단하다. response_format에 아까 만든 클래스를 그대로 넘기면 끝이다.

response = openai.chat.completions.parse(
    model=MODEL,                    # gpt-5-mini
    messages=messages,
    response_format=DealSelection,  # 여기에 pydantic 클래스를 그대로
    reasoning_effort="minimal",
)
results = response.choices[0].message.parsed
results

30건의 딜 텍스트를 통째로 넣고 12.5초 만에 결과가 왔다.

DealSelection(deals=[Deal(product_description='Samsung M70H is a 55" Mini LED 4K smart TV that uses
a dense mini-LED backlight array to improve contrast and brightness compared with standard LED
panels. ...', price=350.0, url='https://www.dealnews.com/products/Samsung/...'), Deal(...

results.deals를 돌면서 꺼내 보면 이렇게 나온다.

30건의 자유 형식 텍스트가 상품 설명 + 가격 + URL 구조로 정리되어 돌아왔다

 

1장에서 봤던 그 뒤죽박죽한 텍스트가, 상품 설명 한 문단과 숫자 하나로 정리되어 나왔다.

게다가 price는 float 형식이라 바로 계산에 쓸 수 있고, 파싱 실패를 대비한 try/except도 필요 없다.

 

느낀점

  • 나는 Structured Output을 "LLM의 출력 형식 맞춰주는 옵션" 정도로만 알고 썼고 실제 LLM, SDK, Pydantic 각각의 역할이 뭔지 몰랐는데 이를 단계적으로 나눠서 이해해볼 수 있어서 좋았다. 특히 트랜스포머 기반 모델이 다음 토큰의 확률들을 예측 하는 단계에서, 이런 스키마를 통한 제약을 줌으로써 특정 토큰은 아예 다음 토큰 출력에서 배제 시킨다는 설명을 듣고서 왜 이게 잘 작동할 수 있는지 납득이 됐다.
  • 실무에서 LLM 출력 형식 제약을 둘때 참고가 될 수 있는 영역이었다. 
  • Field(description=...)이 문서이자 프롬프트라는 점도 인상적이었다. 스키마 설계가 곧 프롬프트 엔지니어링이 되는 셈이라, 어떤 필드를 어떤 이름과 설명으로 둘지가 결과 품질에 직접 영향을 준다. 특히 실무에서 에이전트를 만들 때 이런 description 영역은 llm 한테 자동으로 짜달라고 하는 경우도 많지만, 작게 변경했는데도 결과 품질에 영향을 줄 수 있는 부분이고 기획자도 변경에 관여할 수 있는 영역이기에 해당 부분도 에이전트 기획/개발 시 참고해야겠다는 생각을 했다. 

3. 네 번째 에이전트 - 30건 딜 중 5건을 골라내는 ScannerAgent

2장에서 한 건 노트북에서 직접 호출해본 것이었다. 이걸 시스템의 부품으로 쓰려면 클래스로 감싸야 한다.

그렇게 만든 게 agents/scanner_agent.py의 ScannerAgent, 이 시스템의 네 번째 에이전트다.

글 17에서 만든 세 개(SpecialistAgent, FrontierAgent, EnsembleAgent)가 "이 상품 얼마?"에 답하는 쪽이었다면, ScannerAgent는 그 질문 자체를 만들어오는 역할이다.

 

모든 에이전트는 같은 부모를 갖는다

글 17 1장에서 봤던 Agent 추상 클래스를 여기서도 상속한다.

class ScannerAgent(Agent):
    MODEL = "gpt-5-mini"
    name = "Scanner Agent"
    color = Agent.CYAN

name과 color만 지정하면 부모의 log()가 알아서 이름표와 색을 붙여준다.

에이전트가 여러 개 동시에 돌아가면 로그가 뒤섞이는데, 색으로 구분하면 누가 무슨 일을 하고 있는지 바로 보인다. 이 색 코드가 나중에 7장에서 Gradio 화면의 로그 패널로 그대로 이어진다.

 

프롬프트에 가드가 여러 겹 들어간다

2장에서 Field(description=...)이 프롬프트 역할을 한다고 했는데, 그것만으로는 부족해서 시스템 프롬프트에도 같은 취지의 경고를 반복한다.

SYSTEM_PROMPT = """You identify and summarize the 5 most detailed deals from a list, by selecting deals that have the most detailed, high quality description and the most clear price.
Respond strictly in JSON with no explanation, using this format. You should provide the price as a number derived from the description. If the price of a deal isn't clear, do not include that deal in your response.
Most important is that you respond with the 5 deals that have the most detailed product description with price. It's not important to mention the terms of the deal; most important is a thorough description of the product.
Be careful with products that are described as "$XXX off" or "reduced by $XXX" - this isn't the actual price of the product. Only respond with products when you are highly confident about the price.
"""

핵심 문장 두 개만 옮기면 이렇다.

If the price of a deal isn't clear, do not include that deal in your response.

-> 가격이 명확하지 않은 딜은 아예 응답에 넣지 마라

Be careful with products that are described as "$XXX off" - this isn't the actual price of the product.

-> "$XXX 할인"이라고 적힌 상품을 조심해라. 그건 실제 가격이 아니다

1장의 Logitech 딜 같은 걸 걸러내라는 지시다. 그리고 유저 프롬프트 앞뒤로도 같은 말을 한 번 더 붙인다.

USER_PROMPT_SUFFIX = "\n\nInclude exactly 5 deals, no more."

=> 같은 지시를 스키마 설명, 시스템 프롬프트, 유저 프롬프트 세 군데에 나눠 넣었다. 중복처럼 보이지만 "가격을 잘못 읽으면 시스템 전체가 헛일을 한다"는 걸 생각하면 납득이 간다. 가격은 이 시스템의 입력값이고, 여기가 틀리면 뒤의 가격 예측도 알림도 전부 무의미해진다.

 

이미 본 딜은 다시 보지 않는다

scan() 전에 호출되는 fetch_deals()가 하는 일이 하나 더 있다.

def fetch_deals(self, memory) -> List[ScrapedDeal]:
    self.log("Scanner Agent is about to fetch deals from RSS feed")
    urls = [opp.deal.url for opp in memory]              # 과거에 다뤘던 딜의 URL
    scraped = ScrapedDeal.fetch()
    result = [scrape for scrape in scraped if scrape.url not in urls]   # 중복 제거
    self.log(f"Scanner Agent received {len(result)} deals not already scraped")
    return result

memory는 과거에 이미 처리한 기회(Opportunity)들의 목록이다. 거기 있는 URL은 후보에서 빼버린다.

작아 보이는 코드지만 의미가 있다. 이게 없으면 5분마다 돌 때마다 같은 딜을 계속 다시 평가하고, 같은 알림을 계속 보내게 된다. 에이전트가 "이전에 무엇을 했는지"를 기억한다는 것 자체가 나중에 다룰 에이전틱 AI의 특징 중 하나다.

 

스캔의 본체

def scan(self, memory: List[str] = []) -> Optional[DealSelection]:
    scraped = self.fetch_deals(memory)
    if scraped:
        user_prompt = self.make_user_prompt(scraped)
        self.log("Scanner Agent is calling OpenAI using Structured Outputs")
        result = self.openai.chat.completions.parse(
            model=self.MODEL,
            messages=[
                {"role": "system", "content": self.SYSTEM_PROMPT},
                {"role": "user", "content": user_prompt},
            ],
            response_format=DealSelection,
            reasoning_effort="minimal",
        )
        result = result.choices[0].message.parsed
        result.deals = [deal for deal in result.deals if deal.price > 0]   # 가격 0 이하는 제외
        self.log(f"Scanner Agent received {len(result.deals)} selected deals with price>0 from OpenAI")
        return result
    return None

2장에서 본 호출이 그대로 들어 있고, 앞에 수집과 중복 제거, 뒤에 price > 0 필터가 붙었다.

프롬프트로 아무리 당부해도 가격이 0으로 오는 경우가 있으니 코드로 한 번 더 막는 것이다.

=> LLM에게 맡긴 부분과 코드로 보장하는 부분이 나뉘어 있다. 판단은 모델이, 최소한의 안전장치는 코드가.

=> 항상 에이전트를 만들 때 설계해야하는 중요한 원칙 중에 하나인 거 같다. (프롬프트 성격의 것들에도 강제를 해두지만, 백엔드 코드 상으로도 강제를 해둬야 안전하게 돌아갈 수 있다는 것)

 

실제로 돌려보면

agent = ScannerAgent()
result = agent.scan()

45.8초가 걸렸다. 이 중 대부분은 1장에서 본 RSS 수집 시간이다.

수집 30건 -> Structured Outputs 호출 -> 가격이 있는 5건 선별까지 로그에 그대로 찍힌다

이번에 뽑힌 5건은 이렇다.

  1. Apple AirPods Pro 3 (2025) 리퍼 - $150
  2. Samsung M70H 55인치 4K Mini LED TV - $350
  3. Acer 15.6인치 포터블 모니터 PM161Q 리퍼 - $37
  4. Shark Matrix Plus 로봇청소기 - $249.99
  5. Apple MacBook Air M1 13.3인치 (2020) 리퍼 - $399

2장에서 뽑힌 목록과 다르다. 같은 코드지만 RSS를 다시 긁어오기 때문에 그사이 피드가 바뀌었고, 모델의 선택도 매번 완전히 같지는 않다. 이 시스템은 실행할 때마다 다른 걸 본다.

 

테스트용 딜도 준비되어 있다

ScannerAgent에는 test_scan()이라는 메서드가 하나 더 있다. 하드코딩된 딜 4건을 그냥 DealSelection 객체로 만들어 돌려준다.

def test_scan(self, memory: List[str] = []) -> Optional[DealSelection]:
    results = { "deals": [ ... ] }   # Hisense TV, Poly Studio P21, Lenovo IdeaPad, Dell G15
    return DealSelection(**results)

RSS를 긁고 모델을 부르는 데 45초씩 걸리니, 뒷단을 개발할 때는 이 고정 데이터로 돌리라는 것이다. 6장에서 실제로 이걸 쓴다.

 

느낀점

  • 프롬프트에 같은 경고를 세 군데 반복해서 넣은 게 처음엔 지저분해 보였는데, 생각해보면 이 시스템에서 긁어온 상품 딜의 가격은 사람이 검증하지 않고 그대로 다음 단계로 흘러가는 값이다. 검증 없이 흘러가는 값일수록 앞단에서 겹겹이 막아둘 수밖에 없다는 게 이해가 됐다. 
  • LLM에게 맡길 부분과 코드로 보장할 부분을 나눈 방식도 참고가 됐다. 어떤 딜이 좋은지 고르는 판단은 모델에게 주고, "가격은 0보다 커야 한다" 같은 명확한 규칙은 코드로 처리한다. 실무에서도 이 경계선을 어디에 그을지가 결국 설계의 핵심인 것 같다. 각 에이전트의 시나리오별로, 그리고 실수하면 안되는 영역인지 등의 중요성등을 고려해서 이런 백엔드/모델 간의 역할 경계를 잘 만들어주는게 에이전트 개발에서의 중요한 지점이라고 생각이 든다. 
  • 딜 중복 서치를 방지하기 위해 memory를 참조한다는 게 인상적이었다. 꼭 복잡한 메모리 시스템이 아니더라도, 비즈니스 로직이 돌아가기 위해 필수적이라면 이런 메모리 시스템을 만드는게 필요하다는 걸 느꼈다. 

4. 다섯 번째 에이전트 - 딜 알림을 보내주는 메세징 에이전트

딜을 모으고 5개만 골라내는 에이전트를 위에 스캐너 에이전트로 설명했다. 

이 딜들에 대해 가격 예측 에이전트가 돌아, 정말로 상품의 본질적 가치 대비 싼 딜인지 말해줄 거고 

이를 기반으로 최종적으로 사람에게 추천할 딜이 있을 때 나에게 알려주는 통로가 필요하다.

그게 다섯 번째 에이전트인 MessagingAgent다.

 

Pushover로 알림을 받는다

강의는 Pushover라는 서비스를 쓴다. 계정을 만들고 애플리케이션을 하나 등록하면 키 두 개가 나오는데, 그걸 .env에 넣어두면 된다.

PUSHOVER_USER=u로 시작하는 사용자 키
PUSHOVER_TOKEN=a로 시작하는 애플리케이션 토큰

원래 강의는 휴대폰에 Pushover 앱을 설치해서 받는 방식인데, 나는 앱 대신 브라우저 웹 푸시로 대체했다.

Pushover가 Chrome 웹 푸시를 지원해서 앱 없이도 알림을 받을 수 있다.

 

 

보내는 것 자체는 그냥 HTTP POST 한 번이다

알림 발송 코드는 놀랄 만큼 단순하다.

def push(self, text):
    self.log("Messaging Agent is sending a push notification")
    payload = {
        "user": self.pushover_user,
        "token": self.pushover_token,
        "message": text,
        "sound": "cashregister",   # 금전등록기 소리
    }
    requests.post(pushover_url, data=payload)

sound="cashregister"는 강사의 취향이다. 돈 버는 소식이니 금전등록기 소리로 울린다.

노트북에서 바로 테스트해보면 이렇게 된다.

agent = MessagingAgent()
agent.push("SUCH A MASSIVE DEAL!!")

보낸 그대로 알림이 온다. 여기까지는 그냥 알림 API 연동이고 AI와는 상관이 없다.

 

알림을 보내는 방법이 두 가지다

MessagingAgent에는 알림을 보내는 메서드가 두 개 있고, 성격이 완전히 다르다.

첫 번째는 alert().

전달해야하는 주요 값(예측 가격, 할인 가격, 상품 설명 등) 을 사전에 정규화해서 정해놓고 코드에서 내려오는 값을 문자열로 이어 붙인다.

def alert(self, opportunity: Opportunity):
    text = f"Deal Alert! Price=${opportunity.deal.price:.2f}, "
    text += f"Estimate=${opportunity.estimate:.2f}, "
    text += f"Discount=${opportunity.discount:.2f} :"
    text += opportunity.deal.product_description[:10] + "... "
    text += opportunity.deal.url
    self.push(text)

정확하지만 사람이 읽기엔 좀 딱딱하다. 상품 설명은 앞 10글자만 들어간다.

 

두 번째는 notify(). 여기서 Claude가 등장한다.

MODEL = "claude-sonnet-4-5"

def craft_message(self, description: str, deal_price: float, estimated_true_value: float) -> str:
    user_prompt = "Please summarize this great deal in 2-3 sentences to be sent as an exciting push notification alerting the user about this deal.\n"
    user_prompt += f"Item Description: {description}\nOffered Price: {deal_price}\nEstimated true value: {estimated_true_value}"
    user_prompt += "\n\nRespond only with the 2-3 sentence message which will be used to alert & excite the user about this deal"
    response = completion(
        model=self.MODEL,
        messages=[{"role": "user", "content": user_prompt}],
    )
    return response.choices[0].message.content


def notify(self, description: str, deal_price: float, estimated_true_value: float, url: str):
    self.log("Messaging Agent is using Claude to craft the message")
    text = self.craft_message(description, deal_price, estimated_true_value)
    self.push(text[:200] + "... " + url)   # 알림 길이 제한 때문에 200자 컷

상품 설명, 딜가, 추정가 세 개를 주고 "사용자가 솔깃할 만한 2~3문장으로 써달라"고 요청한다. 그리고 그 결과를 200자로 자른 뒤 URL을 붙여 발송한다.

여기서 쓰는 completion()은 litellm이다. 지금까지 OpenAI는 openai 라이브러리로 불렀는데, Claude는 litellm으로 부른다. 여러 회사의 모델을 같은 인터페이스로 호출할 수 있게 해주는 라이브러리다.

=> 이 시스템이 특정 회사 모델에 묶여 있지 않다는 게 여기서 드러난다. 딜 선별은 gpt-5-mini, 가격 추정은 파인튜닝 Llama와 gpt-5.1,딥러닝 신경망, 알림 문구는 claude-sonnet-4-5. 각 자리에 맞는 모델을 그냥 갖다 쓴다.

 

실제로 Claude에게 써달라고 해보면 다음과 같이 나온다다.

agent.notify("A special deal on Sumsung 60 inch LED TV going at a great bargain", 300, 1000, "www.samsung.com")

입력은 오타(Sumsung)까지 섞인 짧은 문장 하나와 숫자 두 개뿐이다. 그런데 알림으로 도착한 문구는 이랬다.

🔥 INCREDIBLE DEAL ALERT! Get a Samsung 60" LED TV for just $300 - that's 70% OFF the $1,000
value! This massive screen at an unbeatable price won't last long - grab it now!... www.samsung.com

-> 삼성 60인치 LED TV를 단돈 $300에. $1,000 가치 대비 70% 할인이다. 이 가격은 오래 안 간다.

시키지 않았는데 오타를 고쳤고, $300과 $1,000을 받아서 70%라는 할인율을 직접 계산해 넣었다.

 

아래에서부터 MASSIVE DEAL 테스트, SUCH A MASSIVE DEAL 테스트, 그리고 Claude가 쓴 삼성 TV 알림. 맨 위 로보락 알림은 6장에서 실제로 받은 것이다

 

 

alert()  -> Deal Alert! Price=$300.00, Estimate=$1000.00, Discount=$700.00 :A special d... www.samsung.com
notify() -> 🔥 INCREDIBLE DEAL ALERT! Get a Samsung 60" LED TV for just $300 - that's 70% OFF ...

둘 중 어느 쪽을 쓰는지는 5장과 6장에서 갈린다. 규칙 기반 플래너는 alert()를, 자율 플래너는 notify()를 쓴다.

느낀점

  • LLM을 "판단"에만 쓴다고 생각하기 쉬운데, 여기서는 마지막 문장을 다듬는 용도로 쓴다. 상품 설명과 숫자 두 개를 사람이 읽고 싶은 문장으로 바꾸는 일. 정확도가 크게 중요하지 않고 표현력이 중요한 지점이라 오히려 잘 어울린다.
  • 특히나 이 부분을 alert 처럼 백엔드에서 내려오는 값으로만 구성하는 방법도 있지만, 그럼 챗봇처럼 딱 정형화된 답변이 나올 수도 있다. 기획적인 의도에 따라, 정해진 형식대로 나오게 하고 싶은지 좀 더 표현력있는 AI 처럼 나오게 하고 싶은지에 따라서 선택이 달라질 수 있을 거 같다. 에이전트 응답 UX를 만들 떄 고려할 부분이다. 
  • 각 기능마다 다른 회사의 모델을 쓴다는 게 인상적이었다. 용도에 따라 모델을 다르게 가져가는 부분도 인상적인 포인트 중 하나였다.

 

5. 먼저 규칙으로 조율하기 - PlanningAgent

지금까지 에이전트를 다섯 개 만들었다.

  1. SpecialistAgent - Modal에 올린 파인튜닝 Llama로 가격 예측 (글 17)
  2. FrontierAgent - RAG로 유사 상품을 찾아 gpt-5.1전달해준뒤 가격 예측 시킴(글 17)
  3. NeuralNetworkAgent - Week 6의 신경망으로 가격 예측 (글 17)
  4. ScannerAgent - RSS에서 딜을 긁어 5건 선별 (3장)
  5. MessagingAgent - 알림 발송 (4장)

그리고 1~3번을 묶은 EnsembleAgent가 있다.

부품은 다 나왔는데 아직 아무도 이들을 순서대로 부르지 않는다. 그 역할을 하는 게 플래닝 에이전트다.

 

먼저 순서를 코드에 박아둔 버전을 본다.

 

세 에이전트를 하나로 묶는다

class PlanningAgent(Agent):
    name = "Planning Agent"
    color = Agent.GREEN
    DEAL_THRESHOLD = 50

    def __init__(self, collection):
        self.log("Planning Agent is initializing")
        self.scanner = ScannerAgent()
        self.ensemble = EnsembleAgent(collection)
        self.messenger = MessagingAgent()
        self.log("Planning Agent is ready")

생성자에서 세 에이전트를 전부 만들어 들고 있는다.

collection은 글 17에서 만든 Chroma 벡터스토어이고, EnsembleAgent가 RAG를 돌리는 데 쓴다.

 

DEAL_THRESHOLD = 50은 알림 기준선이다. 추정가와 실제 딜가의 차이가 $50를 넘어야 알림을 보낸다.

딜 하나를 기회로 바꾸는 계산

def run(self, deal: Deal) -> Opportunity:
    self.log("Planning Agent is pricing up a potential deal")
    estimate = self.ensemble.price(deal.product_description)
    discount = estimate - deal.price
    self.log(f"Planning Agent has processed a deal with discount ${discount:.2f}")
    return Opportunity(deal=deal, estimate=estimate, discount=discount)

계산 자체는 뺄셈 한 줄이다. 앙상블이 추정한 가격에서 실제 판매가를 빼면 그게 할인폭이다.

여기서 Opportunity는 agents/deals.py에 정의된 또 하나의 pydantic 클래스다.

class Opportunity(BaseModel):
    """
    A class to represent a possible opportunity: a Deal where we estimate
    it should cost more than it's being offered
    """
    deal: Deal
    estimate: float
    discount: float

주석을 옮기면 "우리가 추정한 가격이 실제 판매가보다 높은 딜" = 기회다.

=> Deal(딜)과 Opportunity(기회)를 나눠둔 게 깔끔하다. 딜은 인터넷에서 긁어온 사실이고, 기회는 우리 모델이 판단을 얹은 결과다. 사실과 판단을 다른 타입으로 분리해둔 셈이다.

 

전체 순서

def plan(self, memory: List[str] = []) -> Optional[Opportunity]:
    self.log("Planning Agent is kicking off a run")
    selection = self.scanner.scan(memory=memory)                      # 1. 딜 수집·선별
    if selection:
        opportunities = [self.run(deal) for deal in selection.deals[:5]]   # 2. 5건 전부 가격 추정
        opportunities.sort(key=lambda opp: opp.discount, reverse=True)     # 3. 할인폭 내림차순 정렬
        best = opportunities[0]
        self.log(f"Planning Agent has identified the best deal has discount ${best.discount:.2f}")
        if best.discount > self.DEAL_THRESHOLD:                            # 4. 기준 넘으면 알림
            self.messenger.alert(best)
        self.log("Planning Agent has completed a run")
        return best if best.discount > self.DEAL_THRESHOLD else None
    return None

 

딜을 스캔해 5건의 딜을 가져오고,

5건을 다 가격 추정을 돌리고,

할인폭을 계산한뒤 할인폭이 큰 순서대로 정렬하고,

정해둔 할인폭 기준을 넘는 딜이 있으면 opportunity 로 알린다.

 

여기서 알림은 4장에서 본 두 방법 중 alert(), 즉 기계적으로 값을 이어 붙이는 쪽이다.

 

이 방식의 장점은 분명하다

이 코드는 예측 가능하다.

딜 5건이면 앙상블 호출이 정확히 5번이고, 스캔은 1번이고, 알림은 최대 1번이다.

비용도 시간도 미리 계산할 수 있다. 뭔가 잘못되면 어느 줄에서 틀렸는지 바로 찾을 수 있다.

글 17 1장에서 인용한 강사의 경고와도 일맥상통한다. 문제를 만나자마자 에이전트 아키텍처로 뛰어들지 말고, 단순한 것부터 시작하라는 이야기.

다만 한 가지가 코드에 고정되어 있다. 무엇을 언제 할지에 대한 순서다.

scan -> 5건 전부 가격 추정 -> 할인폭 정렬 -> 1등이 할인폭 $50 넘으면 알림

이 순서를 바꾸려면 코드를 고치는 방법 밖에 없다. 딜 5건 중 명백히 가망 없는 게 있어도 전부 가격추정 하고, 알림은 무조건 1등 1건이다.

=> 6장에서는 이 순서를 코드에서 빼내 LLM에게 넘긴다. 같은 부품, 같은 목표인데 조율하는 주체만 바뀐다.

 

느낀점

  • 이 장은 새로운 개념이 없는데도 넣어둔 이유가 있다고 느꼈다. 다음 장의 자율 에이전트를 이해하려면 "순서를 코드가 정하는 상태"를 먼저 눈으로 봐야 대비가 되기 때문이다. 강의 구성이 친절하다고 느낀 대목이었다.
  • 실무에서 에이전트를 만들 때도 결국 이 질문으로 돌아오는 것 같다. 이 순서를 사람이 정할 것인가, 모델이 정하게 둘 것인가. 정답이 하나로 정해져 있지 않고, 예측 가능성이 중요한 기능이냐 유연성이 중요한 기능이냐에 따라 달라진다. 실제 최근에도 실무에서 에이전트를 기획하는 과정에서 이 시나리오를 사람이 정해둔 순서대로 동작하게 할것이냐, 모델이 자율적으로 정하게 할것이냐에 대한 고민들을 계속 하게 되는 거 같다. 
  • Deal과 Opportunity를 타입으로 분리한 게 작지만 좋았다. 긁어온 사실과 우리가 얹은 판단을 섞어두면 나중에 "이 숫자는 어디서 나온 거지"를 추적하기 어려워진다.

 

6. 이제 실행 순서를 LLM에게 맡긴다 - 툴 콜과 자율 에이전트

5장의 플래너는 실행 순서가 코드에 박혀 있었다. 이번 장에서는 그 순서를 빼낸다.

부품은 그대로 두고, 무엇을 언제 부를지만 LLM이 정하게 한다.

 

강의는 여기서 에이전틱 AI가 무엇인지 정의하고 넘어간다.

에이전틱 AI의 다섯 가지 특징

강사가 제시하는 hallmarks of an agentic AI solution(에이전틱 AI 솔루션의 특징) 다섯 가지다.

  1. breaking a larger problem into smaller steps carried out by individual processes/models
    -> 큰 문제를 작은 단계로 나누고, 각 단계를 개별 프로세스나 모델이 처리한다
  2. using tools / function calling / structured output
    -> 툴, 함수 호출, 구조화된 출력을 쓴다
  3. an agent environment in which agents can collaborate
    -> 에이전트들이 협업할 수 있는 환경이 있다
  4. a planning agent that coordinates activities
    -> 활동을 조율하는 플래닝 에이전트가 있다
  5. autonomy & memory - existing beyond a chat with a human
    -> 자율성과 메모리. 사람과의 대화 한 번을 넘어서 존재한다

지금까지 만든 것으로 보면 1번은 에이전트 여섯 개로, 2번은 2장의 Structured Outputs로, 3번은 Agent 부모 클래스와 로깅으로, 4번은 5장의 플래너로 이미 채웠다.

남은 게 5번, 그리고 2번의 "툴"이다. 이번 장이 그 두 개를 채운다.

먼저 가짜 함수로 원리를 확인한다

 

Day 4 노트북은 곧바로 진짜 에이전트를 붙이지 않는다. 3장에서 본 test_scan()의 하드코딩 딜을 쓰고, 함수 세 개도 가짜로 만든다.

test_results = ScannerAgent().test_scan()

def scan_the_internet_for_bargains() -> str:
    print("Fake function to scan the internet - this returns a hardcoded set of deals")
    return test_results.model_dump_json()

def estimate_true_value(description: str) -> str:
    print(f"Fake function to estimating true value of {description[:20]}... - this always returns $300")
    return f"Product {description} has an estimated true value of $300"

def notify_user_of_deal(description: str, deal_price: float, estimated_true_value: float, url: str) -> str:
    print(f"Fake function to notify user of {description} which costs {deal_price} and estimate is {estimated_true_value}")
    return "notification sent ok"

추정가는 무조건 $300을 돌려주고, 알림은 실제로 보내지 않고 print만 한다.

45초짜리 RSS 수집도, 돈 드는 모델 호출도 없이 툴 콜 흐름만 확인하려는 것이다.

 

함수를 LLM에게 설명하는 방법

LLM은 파이썬 함수를 직접 볼 수 없다. 그래서 함수마다 "이런 게 있고 이런 인자를 받는다"를 JSON으로 설명해줘야 한다.

estimate_function = {
    "name": "estimate_true_value",
    "description": "Given the description of an item, estimate how much it is actually worth",
    "parameters": {
        "type": "object",
        "properties": {
            "description": {
                "type": "string",
                "description": "The description of the item to be estimated"
            },
        },
        "required": ["description"],
        "additionalProperties": False
    }
}

2장의 pydantic 스키마와 형태가 거의 같다. 다른 점은 방향이다.

2장은 "이 형식으로 답을 내놔라"였고, 여기는 "이런 함수를 부를 수 있다"이다.

알림 함수의 설명에는 이런 문장이 들어간다.

Send the user a push notification about the single most compelling deal; only call this one time

-> 가장 매력적인 딜 하나에 대해서만 푸시 알림을 보내라. 이 함수는 한 번만 호출해라

함수 설명란에 "한 번만 불러라"라는 행동 지침이 들어가 있다. 여기도 description이 곧 프롬프트다.

 

세 개를 리스트로 묶으면 준비 끝이다.

tools = [
    {"type": "function", "function": scan_function},
    {"type": "function", "function": estimate_function},
    {"type": "function", "function": notify_function},
]

핵심은 while 루프 하나다

툴 콜의 동작 원리는 생각보다 단순하다.

done = False
while not done:
    response = openai.chat.completions.create(model=MODEL, messages=messages, tools=tools)
    if response.choices[0].finish_reason == "tool_calls":     # 모델이 함수를 부르고 싶어함
        message = response.choices[0].message
        results = handle_tool_call(message)                   # 우리가 대신 실행
        messages.append(message)                              # 요청과
        messages.extend(results)                              # 결과를 대화에 추가
    else:
        done = True                                           # 더 부를 게 없으면 종료

모델이 함수를 부르고 싶으면 finish_reason이 "tool_calls"로 온다. 그러면 우리가 실제로 그 함수를 실행하고, 요청과 결과를 대화 기록에 붙여서 다시 모델에게 넘긴다. 모델이 더 이상 부를 게 없다고 판단하면 그냥 텍스트로 답하고 루프가 끝난다.

handle_tool_call이 하는 일은 이름으로 함수를 찾아 인자를 풀어 실행하는 것뿐이다.

def handle_tool_call(message):
    results = []
    for tool_call in message.tool_calls:
        tool_name = tool_call.function.name
        arguments = json.loads(tool_call.function.arguments)   # 모델이 채운 인자
        tool = globals().get(tool_name)
        result = tool(**arguments) if tool else {}
        results.append({"role": "tool", "content": json.dumps(result), "tool_call_id": tool_call.id})
    return results

=> 모델이 함수를 실행하는 게 아니다. 모델은 "이 함수를 이 인자로 불러줘"라고 말할 뿐이고, 실행은 우리 코드가 한다. 결과를 다시 넣어주면 모델이 그걸 보고 다음 판단을 한다. 이 왕복이 반복되는 게 툴 콜 루프다.

그리고 지시는 이게 전부다

system_message = "You find great deals on bargain products using your tools, and notify the user of the best bargain."

user_message = """
First, use your tool to scan the internet for bargain deals. Then for each deal, use your tool to estimate its true value.
Then pick the single most compelling deal where the price is much lower than the estimated true value, and use your tool to notify the user.
Then just reply OK to indicate success.
"""

-> 먼저 툴로 인터넷에서 딜을 스캔해라. 그다음 각 딜마다 툴로 실제 가치를 추정해라. 그리고 가격이 추정 가치보다 훨씬 낮은 가장 매력적인 딜 하나를 골라 툴로 사용자에게 알려라. 그다음 OK라고만 답해라

5장의 plan() 메서드와 내용은 비슷한데, 이건 파이썬이 아니라 영어 문장이다. 그리고 "5건"이나 "$50" 같은 구체적인 숫자가 없다. 몇 번 부를지, 어떤 걸 고를지는 모델이 정한다.

 

가짜를 진짜로 바꾼다

원리를 확인했으니 agents/autonomous_planning_agent.py에서 가짜 함수를 실제 에이전트 호출로 교체한다.

class AutonomousPlanningAgent(Agent):
    name = "Autonomous Planning Agent"
    color = Agent.GREEN
    MODEL = "gpt-5.1"

    def scan_the_internet_for_bargains(self) -> str:
        self.log("Autonomous Planning agent is calling scanner")
        results = self.scanner.scan(memory=self.memory)
        return results.model_dump_json() if results else "No deals found"

    def estimate_true_value(self, description: str) -> str:
        self.log("Autonomous Planning agent is estimating value via Ensemble Agent")
        estimate = self.ensemble.price(description)
        return f"The estimated true value of {description} is {estimate}"

test_scan() 자리에 진짜 ScannerAgent가, 무조건 $300 돌려주던 자리에 EnsembleAgent가 들어갔다. 툴 스키마와 while 루프는 그대로다.

알림 쪽에는 가드가 하나 더 들어간다.

def notify_user_of_deal(self, description, deal_price, estimated_true_value, url) -> Dict:
    if self.opportunity:
        self.log("Autonomous Planning agent is trying to notify the user a 2nd time; ignoring")
    else:
        self.log("Autonomous Planning agent is notifying user")
        self.messenger.notify(description, deal_price, estimated_true_value, url)
        ...
    return "Notification sent ok"

이미 알림을 보냈으면 두 번째 호출은 그냥 무시한다. 툴 설명에 "한 번만 불러라"라고 써뒀는데도 코드로 한 번 더 막는다.

=> 3장에서 본 것과 같은 패턴이다. 프롬프트로 부탁하고, 코드로 보장한다. 특히 자율성을 준 상황에서는 이 가드가 더 중요해진다. 순서를 모델이 정한다는 건 예상 못 한 순서도 나올 수 있다는 뜻이기 때문이다.

그리고 여기서 쓰는 알림은 notify(), 즉 4장에서 본 Claude가 문구를 쓰는 쪽이다.

실제로 돌려보면

agent = AutonomousPlanningAgent(collection)
agent.plan()

2분 26.4초가 걸렸다. 로그를 따라가 보면 모델이 이렇게 움직였다.

초록색 자율 플래너가 하늘색 스캐너를 부르고, 그다음 노란색 앙상블 안에서 빨강·보라·자주색 에이전트가 차례로 값을 내놓는다

 

색깔로 누가 말하고 있는지가 바로 보인다. 초록은 자율 플래너, 하늘은 스캐너, 노랑은 앙상블, 그 안에서 빨강(Specialist), 보라(Frontier), 자주(Neural Network)가 각자 값을 낸다.

딜 5건에 대한 앙상블 결과는 이렇게 나왔다.

1건: Specialist $299.00 / Frontier $289.00 / NN  $81.68 -> Ensemble $269.27
2건: Specialist $220.00 / Frontier $179.00 / NN $252.94 -> Ensemble $190.49
3건: Specialist $299.00 / Frontier $649.99 / NN $148.03 -> Ensemble $564.69
4건: Specialist $220.00 / Frontier $849.99 / NN $318.34 -> Ensemble $733.83
5건: Specialist $299.00 / Frontier $749.99 / NN $152.52 -> Ensemble $645.14

앙상블 가중치는 Frontier 0.8, Specialist 0.1, Neural Network 0.1이다. 3건을 넣어 검산해보면 649.99 × 0.8 + 299 × 0.1 + 148.03 × 0.1 = 564.69로 맞는다.

여기서 눈에 띄는 건 신경망의 편차다. $81.68이나 $148.03처럼 다른 둘과 크게 벌어지는 값을 낸다. 글 17에서 앙상블이 RAG 단독보다 겨우 $0.27 나았던 이유가 이 로그에서 좀 더 실감이 났다. 가중치를 0.1로 낮게 준 데는 이유가 있었던 것이다.

그리고 로그의 마지막 부분은 이렇다.

마지막 줄의 OK가 유저 메시지에서 요청했던 그 OK다. 모델이 할 일을 다 마쳤다고 판단하고 텍스트로 답한 것이고, 그 순간 while 루프가 종료됐다.

다섯 번째 평가가 끝나자 플래너가 알림을 결정하고, Claude가 문구를 만들어 발송한 뒤 OK로 마무리한다

 

모델이 고른 건 3번째 딜, 로보락 Qrevo였다. 딜가 $400에 앙상블 추정 $564.69, 차이는 약 $164.

그리고 4장에서 봤던 그 알림이 도착했다.

🤖 Amazing Deal Alert! Save $164 on the Roborock Qrevo robot vacuum & mop combo with powerful
8,000Pa suction, self-emptying station, and automatic mop washing—now just $400! This smart
cleaning powerh... https://www.dealnews.com/Roborock-Qrevo-8-000-Pa-Robot-Vacuum-and-Mop-for-400-free-shipping/...

반환값은 Opportunity 객체다.

Opportunity(deal=Deal(product_description='Roborock Qrevo is a self-emptying robot vacuum and mop
that delivers 8,000Pa HyperForce suction and includes an all-in-one docking station that washes
and self-empties. ...

한 사이클에 모델이 몇 번 호출되나

강의 노트에 적어둔 숫자가 있다. 한 사이클에 모델 호출이 34번, 그중 LLM이 29번이고 신경망이 5번이다.

세어보면 이렇다.

딜 5건 각각에 대해 전처리(llama3.2) 1번, Specialist(파인튜닝 Llama) 1번, Frontier의 임베딩 1번과 gpt-5.1 1번, 신경망 1번. 여기에 스캐너의 gpt-5-mini 1번, 알림 문구의 claude-sonnet-4-5 1번, 그리고 자율 플래너 자신이 툴 콜 루프를 도는 gpt-5.1 호출들이 더해진다.

한 번 돌 때 다섯 회사의 서로 다른 모델이 서른 번 넘게 불려 나간다는 이야기다.

느낀점

  • 툴 콜의 원리가 while 루프 하나라는 게 의외로 허무했다. finish_reason을 보고 함수를 대신 실행해 결과를 다시 넣어주는 것뿐인데, 그게 반복되면서 "스스로 일하는 것처럼" 보인다. 복잡한 프레임워크 없이도 에이전트가 성립한다는 강사의 말이 여기서 이해가 됐다.
  • 가짜 함수로 흐름만 먼저 확인하고 나중에 진짜로 갈아 끼우는 방식이 좋았다. 실무에서 에이전트를 만들 때도 툴 하나하나가 느리고 비싸서 전체를 돌려보며 디버깅하기가 부담스러운데, 인터페이스만 맞춰두고 껍데기로 먼저 돌려보는 접근은 그대로 쓸 만하다.
  • 모델에게 자율성을 준다는 게 결국 "어디까지 맡기고 어디부터 막을 것인가"의 문제라는 걸 다시 확인했다. 알림 중복 방지처럼 사용자에게 직접 노출되는 부분은 프롬프트만 믿지 않고 코드로 잠가둔다. 자율성이 커질수록 오히려 가드는 더 촘촘해져야 한다는 게 인상적이었다.
  • 다만 여기서 한 가지 짚고 싶은 게 있다. 5장의 규칙 기반 플래너는 딜 5건이면 앙상블 5번으로 끝이 정해져 있는데, 자율 플래너는 모델이 몇 번 부를지 모른다. 실행할 때마다 비용과 시간이 달라진다는 뜻이다. 자율성을 얻는 대신 예측 가능성을 내놓는 거래인 셈이다.
  • 짧은 프롬프트 지시만으로 모델이 툴 호출을 자율적으로 해나가며, 의도했던 결과를 내는게 인상적이었다. 생각보다 툴 정의가 잘 되어있다면, 이런 플로우는 모델에게 자율적으로 맡겨도 동작할 수 있다는 게 인상적이었다. 실무에서 에이전트 기획할 떄도 모든 시나리오에서 에이전트의 실행순서를 강제하기 보다, 어느정도 자율적으로 동작할 수 있는 시나리오를 찾는것도 오히려 모델의 능력을 덜 제한하는 길이 아닐까 라는 생각이 들었다.

 

7. 피날레 - 프레임워크와 Gradio 이용 UI, 그리고 실제로 찾아낸 딜

에이전트 여섯 개와 플래너 두 종류까지 다 만들었다.

남은 건 이걸 계속 돌아가는 하나의 프로그램으로 묶고 화면을 붙이는 일이다.

기억을 파일에 남긴다

deal_agent_framework.py의 DealAgentFramework가 전체를 감싸는 껍데기다.

class DealAgentFramework:
    DB = "products_vectorstore"
    MEMORY_FILENAME = "memory.json"

    def __init__(self):
        init_logging()
        client = chromadb.PersistentClient(path=self.DB)
        self.memory = self.read_memory()
        self.collection = client.get_or_create_collection("products")
        self.planner = None

Chroma 벡터스토어를 열고, memory.json을 읽어들인다.

3장에서 ScannerAgent가 중복 제거에 쓰던 그 memory가 여기서 파일로 관리된다.

def run(self) -> List[Opportunity]:
    self.init_agents_as_needed()
    logging.info("Kicking off Planning Agent")
    result = self.planner.plan(memory=self.memory)
    logging.info(f"Planning Agent has completed and returned: {result}")
    if result:
        self.memory.append(result)
        self.write_memory()      # 파일에 저장
    return self.memory

한 사이클이 끝나고 기회를 찾았으면 memory에 추가하고 파일에 쓴다. 프로그램을 껐다 켜도 이전에 뭘 찾았는지가 남는다.

=> 6장에서 본 다섯 번째 특징, "autonomy & memory - existing beyond a chat with a human"이 이 JSON 파일 하나로 구현된다. 거창한 게 아니라 그냥 파일이다.

 

init_agents_as_needed()도 눈에 띈다. 플래너를 미리 만들지 않고 처음 필요할 때 만든다. 앙상블 에이전트를 만드는 순간 신경망 가중치를 읽고 Chroma를 붙잡아야 하니 무겁기 때문이다.

여기서 하나 짚어둘 게 있다

프레임워크가 임포트하는 플래너는 이것이다.

from agents.planning_agent import PlanningAgent

6장에서 만든 AutonomousPlanningAgent가 아니라 5장의 규칙 기반 PlanningAgent다. 실제 실행 로그를 봐도 초록색 라벨이 [Planning Agent]로 찍히고, 알림도 "using Claude to craft the message" 없이 바로 발송된다. alert() 쪽이라는 뜻이다.

즉 자율 플래너는 Day 4에서 원리를 확인하는 용도로 만들어졌고, 최종 앱은 예측 가능한 규칙 기반 플래너로 돌아간다. 갈아 끼우려면 이 import 한 줄만 바꾸면 되긴 한다.

 

5장 끝에서 "예측 가능성이 중요한 기능이냐 유연성이 중요한 기능이냐"라고 썼는데, 5분마다 자동으로 도는 프로그램에서는 강사도 예측 가능한 쪽을 골랐다는 게 재미있었다.

터미널 색깔을 화면 색깔로 바꾼다

3장에서 각 에이전트에 색을 지정했던 게 여기서 쓰인다. 로깅에 들어 있는 건 터미널용 ANSI 색 코드라서 브라우저에서는 깨진다. 그래서 log_utils.py가 매핑을 들고 있다.

mapper = {
    BG_BLACK+RED: "#dd0000",
    BG_BLACK+GREEN: "#00dd00",
    BG_BLACK+YELLOW: "#dddd00",
    BG_BLACK+BLUE: "#0000ee",
    BG_BLACK+MAGENTA: "#aa00dd",
    BG_BLACK+CYAN: "#00dddd",
    BG_BLACK+WHITE: "#87CEEB",
    BG_BLUE+WHITE: "#ff7800",
}

def reformat(message):
    for key, value in mapper.items():
        message = message.replace(key, f'<span style="color: {value}">')
    message = message.replace(RESET, '</span>')
    return message

ANSI 코드를 <span style="color: ...">로 치환하는 것뿐이다. 단순한데 화면에서 보면 효과가 좋다.

로그를 실시간으로 흘려보낸다

price_is_right.py에서 제일 재미있었던 부분이다. 에이전트가 도는 데 2분 넘게 걸리는데, 그동안 화면이 멈춰 있으면 곤란하다.

class QueueHandler(logging.Handler):
    def __init__(self, log_queue):
        super().__init__()
        self.log_queue = log_queue

    def emit(self, record):
        self.log_queue.put(self.format(record))

로깅 핸들러를 하나 만들어서 로그를 큐에 넣는다. 그리고 에이전트는 별도 스레드에서 돌린다.

def worker():
    result = do_run()
    result_queue.put(result)

thread = threading.Thread(target=worker)
thread.start()

메인 쪽은 큐를 계속 확인하면서 새 로그가 있으면 화면에 뿌리는 제너레이터다.

while True:
    try:
        message = log_queue.get_nowait()
        log_data.append(reformat(message))
        yield log_data, html_for(log_data), final_result or initial_result
    except queue.Empty:
        try:
            final_result = result_queue.get_nowait()
            ...

=> 로깅을 UI 스트리밍 채널로 쓴다는 발상이 좋았다. 에이전트 코드는 그냥 self.log(...)를 부를 뿐인데, 그게 터미널에도 찍히고 브라우저 패널에도 실시간으로 흐른다. 화면을 위해 에이전트 코드를 고칠 필요가 없다.

5분마다 알아서 돈다

timer = gr.Timer(value=300, active=True)
timer.tick(run_with_logging, inputs=[log_data], outputs=[log_data, logs, opportunities_dataframe])

gr.Timer에 300초를 주면 5분마다 전체 사이클이 다시 돈다. 사람이 버튼을 누를 필요가 없다.

딜 목록의 행을 클릭하면 그 딜의 알림을 다시 보내는 기능도 있다.

def do_select(selected_index: gr.SelectData):
    opportunities = self.get_agent_framework().memory
    row = selected_index.index[0]
    opportunity = opportunities[row]
    self.get_agent_framework().planner.messenger.alert(opportunity)

오른쪽에는 벡터 시각화가 들어간다. 글 17에서 만든 80만 개 임베딩 중 800개를 뽑아 t-SNE로 3차원으로 줄이고, 카테고리별로 색을 입힌 3D 산점도다.

@classmethod
def get_plot_data(cls, max_datapoints=2000):
    result = collection.get(include=["embeddings", "documents", "metadatas"], limit=max_datapoints)
    vectors = np.array(result["embeddings"])
    categories = [metadata["category"] for metadata in result["metadatas"]]
    colors = [COLORS[CATEGORIES.index(c)] for c in categories]
    tsne = TSNE(n_components=3, random_state=42, n_jobs=-1)
    reduced_vectors = tsne.fit_transform(vectors)
    return documents, reduced_vectors, colors

기능적으로 꼭 필요하진 않은데, 이 시스템이 뒤에서 뭘 깔고 있는지 보여주는 역할을 한다.

Day 5 노트북은 화면을 세 단계로 쌓는다

바로 완성본을 보여주지 않고 Gradio를 조금씩 늘려간다.

  1. 제목과 설명 줄만 있는 빈 화면
  2. 딜 테이블(gr.Dataframe)을 붙이고 예시 데이터 한 줄을 넣어본 화면
  3. DealAgentFramework를 연결해서 실제 memory를 테이블에 뿌리고, 행 클릭 시 알림이 가게 한 화면

그리고 마지막 셀에서 완성본을 실행한다.

!uv run price_is_right.py

실행하면 이런 화면이 뜬다

위쪽은 지금까지 발굴한 딜 테이블, 왼쪽 아래는 에이전트별로 색이 입혀진 실시간 로그, 오른쪽 아래는 상품 임베딩 3D 시각화

 

왼쪽 아래 로그 패널을 보면 3장에서 지정한 색이 그대로 살아 있다.

그리고 실제로 찾아낸 딜

테이블에 세 건이 올라와 있는데, 위의 두 건(삼성 갤럭시 워치 울트라, 아이폰 14 프로 맥스)은 reset_memory()가 남겨두는 기본값이다. 세 번째가 내가 돌려서 실제로 찾은 딜이다.

Roborock Qrevo (8,000Pa 로봇청소기 겸 물걸레)
딜가       $400.00
추정가     $769.86
할인폭     $369.86

memory.json에도 그대로 저장돼 있다.

{
  "deal": {
    "product_description": "Roborock Qrevo is a self-emptying robot vacuum and mop with 8,000Pa HyperForce suction ...",
    "price": 400.0,
    "url": "https://www.dealnews.com/Roborock-Qrevo-8-000-Pa-Robot-Vacuum-and-Mop-for-400-free-shipping/21905563.html?iref=rss-f1912"
  },
  "estimate": 769.8555787963868,
  "discount": 369.8555787963868
}

여기서 하나 짚고 갈 게 있다. 6장의 자율 플래너 실행에서도 같은 로보락 Qrevo가 걸렸는데, 그때 추정가는 $564.69였고 여기서는 $769.86이다. 딜가는 똑같이 $400인데 추정치가 $200 넘게 차이 난다.

두 번은 다른 실행이다. 전처리 단계에서 llama3.2가 문장을 매번 조금씩 다르게 다시 쓰고, 그 문장으로 RAG 검색과 gpt-5.1 호출이 다시 이뤄지니 결과가 달라진다. 글 17에서 확인한 앙상블의 평균 오차가 $30.70이었던 걸 생각하면, 같은 상품에 $200 차이는 결코 작은 편차가 아니다.

=> 화면에 숫자가 딱 떠 있으면 확정된 값처럼 보이지만, 실제로는 실행할 때마다 흔들리는 값이다.

 

느낀점

  • 로깅을 UI 스트리밍 채널로 재활용한 구조가 인상적이었다. 에이전트는 자기 일만 하고 로그만 남기는데, 그게 관측 수단이자 사용자 화면이 된다. 에이전트 시스템에서 "지금 뭐 하고 있는지 보여주기"는 생각보다 중요한 UX인데, 별도 진행률 API 같은 걸 만들지 않고 이미 있는 로그로 해결했다.
  • 자율 플래너를 만들어놓고 최종 앱에는 규칙 기반을 쓴 선택이 오히려 현실적으로 느껴졌다. 뭐가 더 좋은지는 돌릴려는 시나리오 상황에 따라 다른 것. 예측 가능성이 중요한 기능이냐 유연성이 중요한 기능이냐 등.

 

마무리 — 에이전틱 AI, 그리고 8주의 회고

Day 5 노트의 마지막에 강사는 처음 이야기로 돌아간다. 에이전트의 정의 세 가지다.

ai systems that can do work for you independently
ai systems where an llm controls the workflow
an agent runs tools in a loop to achieve a goal

-> 독립적으로 당신을 대신해 일을 하는 AI 시스템

-> LLM이 워크플로를 통제하는 AI 시스템

-> 목표를 달성하기 위해 툴을 루프로 돌리는 것

글 17 1장에서 이 세 문장을 처음 봤을 때는 그냥 정의 나열로 읽혔는데, 시스템을 다 만들고 나서 다시 보니 각각이 코드의 어느 부분인지가 보인다.

  1. 독립적으로 일한다 -> gr.Timer(value=300)로 5분마다 스스로 돌고, memory.json으로 이전 실행을 기억한다
  2. LLM이 워크플로를 통제한다 -> 6장의 자율 플래너. 스캔할지 평가할지 알릴지를 gpt-5.1이 정한다
  3. 툴을 루프로 돌린다 -> finish_reason == "tool_calls"를 보는 while 루프 하나

거창한 에이전트 프레임워크는 하나도 안 썼다.

CrewAI도 LangGraph도 없이, pydantic 클래스 몇 개와 while 루프 하나와 JSON 파일 하나로 위 세 가지가 다 성립했다.

완성된 시스템을 다시 보면

한 사이클에 일어나는 일을 순서대로 적으면 이렇다.

  1. dealnews RSS 3개에서 딜 30건을 긁는다 (1장)
  2. gpt-5-mini가 Structured Outputs로 5건을 골라 상품 설명과 가격으로 정리한다 (2·3장)
  3. 각 딜마다 llama3.2가 문장을 전처리하고, Modal의 파인튜닝 Llama와 RAG를 태운 gpt-5.1과 신경망이 각자 가격을 추정해 앙상블로 통합된 가격 추정값을 전한다 (글 17)
  4. 추정가에서 딜가를 뺀 값이 가장 큰 딜을 고르고, 기준을 넘으면 알린다 (5장)
  5. claude-sonnet-4-5가 알림 문구를 쓰고 Pushover로 발송한다 (4장)
  6. 결과를 memory.json에 남긴다. 그 상품 URL은 다음 사이클에서 제외된다 (7장)

모델 호출 서른 번 남짓, 다섯 회사의 모델이 각자 자기 자리에서 한 번씩 불려 나온다.

그리고 실제로 로보락 청소기 하나를 찾아냈다. 딜가 $400에 추정가 $769.86.

여기까지가 Week 8이다.

8주를 돌아보며

이 글이 이 강의 정리의 18번째, 마지막 글이다.

처음에 웹사이트 하나 요약하는 코드로 시작해서 자율 에이전트 시스템까지 왔으니 꽤 먼 길이었다.

주차별로 무엇을 했고 그때 뭘 느꼈는지 되짚어본다.

 

Week 1

GPT API와 Ollama를 활용한 로컬모델로 웹사이트를 요약했다.

별거 아닌 기능이었는데, 그때 남긴 말이 "이전까지는 막연하게 프론티어 모델과 오픈소스 모델 성능 차이가 난다고 머리로만 알고 있었다면, 처음 오픈소스 모델도 직접 써보면서 어떤 부분에서 차이가 있는지를 느꼈다"였다. 

Week 2

Gradio로 챗봇을 만들고 tool calling과 멀티모달을 붙였다. 회사에서 말로만 듣던 tool calling을 처음 코드로 만져본 주차다. 그때 익힌 것이 이번 6장의 자율 에이전트로 그대로 이어졌다. 그때는 함수 하나를 부르는 수준이었는데, 이번엔 그 원리로 에이전트 전체가 돌아갔다.

Week 3

Hugging Face, 토크나이저와 chat_template, 양자화, 그리고 print(model)로 트랜스포머 구조를 뜯어봤다. 모델 내부를 처음 들여다본 주차이고, 여기서 익힌 양자화 개념이 Week 7의 QLoRA로 연결됐다.

Week 4

LLM 평가였다. 벤치마크와 리더보드를 정리하면서 남긴 결론이 이거였다. "벤치마크 성능이 더 좋은 모델이라 하더라도, 실제 특정 task에서 잘 먹히냐는 결국 돌려봐야 안다." 그리고 모델 지표는 최소 기준을 통과시키는 용도로 쓰고 그 뒤는 비즈니스 지표로 판단해야 한다는 이야기. 이후 주차에서 실제 서비스들을 만들어보며 이 문장이 더 생각났던 거 같다.

Week 5

RAG였다. 구현하고, RAG 평가 시스템을 만들고, Advanced RAG까지 갔다. "단순하게 RAG를 구축하는 것 자체는 쉽다. 하지만 실제 돌려봤을 때 세부 케이스들에서 잘 안 되는 걸 보니" 기업들이 왜 PoC와 BMT를 요구하는지 이해됐다고 썼다. 그리고 완전 자동화는 어렵고 결국 사람 손을 탄다는 것도.

Week 6

데이터 큐레이션과 프론티어 모델 파인튜닝이었다. "데이터를 모은다가 아니라 분포를 설계한다"가 큐레이션의 본질이었고, 결과는 예상 밖이었다. 학습을 전혀 안 시킨 GPT가 자체 학습시킨 모델을 이겼고, 파인튜닝은 오히려 성능을 떨어뜨렸다.

Week 7

QLoRA로 3B짜리 Llama 3.2를 파인튜닝했다. 여기서 Week 6의 실패가 설명됐다. 파인튜닝은 만능 성능 향상기가 아니라 작은 전문가를 만드는 도구라는 것. 그리고 "학습과 모델 개발은 계산이 아니라 관찰과 판단"이라는 게 eval loss 곡선을 들여다보며 남은 문장이었다.

Week 8

앞의 7주에서 만든 조각을 전부 꺼내 하나로 조립했다. Week 7의 파인튜닝 모델은 Modal에 올려 SpecialistAgent가 됐고, Week 5의 RAG는 FrontierAgent가 됐고, Week 6의 신경망은 NeuralNetworkAgent가 됐다. Week 2의 tool calling은 자율 플래너가 됐고, Week 3의 양자화 지식은 그 모델들을 GPU에 올리는 기반이 됐다.

 

전체 느낀점

  • 이 8주의 코스를 하기 전에 나는 여기저기서 각종 용어들은 들어봤지만, 그게 정말로 코드단에서 어떻게 동작하냐에 대한 감은 없는 상황이었다. 실제 RAG, Tool calling, agent, fine tuning, gradio, ollama 등등 실제 코드로 작게나마 LLM 엔지니어링 일련의 과정을 돌려보며 AI 서비스가 만들어지는데 있어 뒤에서 돌아가는 작업들에 대한 이해도를 많이 키울 수 있었다. 이 부분이 현재 회사에서 에이전트를 기획해나가는 데 있어서도 많은 이해도를 높여줬다고 생각한다. 
  • 8주 내내 코드를 직접 돌려본 게 컸다. 글 1에서 썼던 "머리로만 알고 있던 것과 직접 해보는 것은 다르다"가 마지막 글까지 그대로 유효했다. 읽기만 했으면 남지 않았을 것들이 손으로 돌려보니 남았다.
  • 또한, 이렇게 블로그로 남기는 과정도 나에게 큰 의미가 있었던 거 같다. 단순히 한번 강의를 듣고 실행하는 것만으로는 내 것이 되는 느낌이 안들었는데 블로그를 작성하면서 좀 더 지식이 체화가 되는 느낌이었다. 
  • 그리고 이렇게 직접 코드 단에서 작업을 해나가면서 AI, 엔지니어링에 더 많은 관심을 가지게 되는 계기가 된 거 같다. 이 부분은 현재 회사에서 일하면서도 많은 긍정적인 영향을 주고 있는 부분이라고 생각한다. (실제로 회사에서도 이전보다 엔지니어 분들과 기술적인 소통도 많이 하게 된 거 같고, 실제 간단한 시스템이나 PoC 는 직접 만들기도 하게 됐다) 
  • 그렇기 때문에 앞으로도 이런 기술에 대한 공부, AI에 대한 공부는 계속 이어나가야 겠다는 생각이다. 

여기까지가 Ed Donner의 LLM Engineering 8주 과정 정리다.


지금까지 Ed Donner의 LLM Engineering 8주 과정을 18편의 글로 정리했었습니다. 

작년 하반기엔 갑자기 이직 준비로 인해 공부가 중간에 끊기도 하고, 

또 강의를 듣고 공부하는 것과 블로그로 내용을 옮기는 시점간에 차이가 좀 있다 보니 완결이 생각보다 걸렸습니다. 

 

앞으로는 해당 강사의 에이전트 강의를 좀 더 들어보게 될 거 같구요. 회사에서 진행중인 에이전트/데이터 엔지니어링 스터디 관련 내용들도 좀 올려 보게 될 수도 있을 거 같습니다. 

이와 별개로 공부글 외에, 블로그에서 한동안 멈춰 있었던 제 커리어 관련 글도 좀 작성해보려 합니다. 

 

그래서 다음글 부터는 AI 공부글과, 제 커리어/실무 관련된 블로그 글을 좀 섞어서 올리게 될 거 같습니다. 

아주 확정은 아니지만, 올해 초 회사를 옮긴 뒤 작년에 다녔던 회사에서 배웠던 것들을 한번 정리해서 올려야지 하고 있었는데

그 글을 좀 적고 공유해보는 걸 가장 먼저 하게 되지 않을까 싶어요. 그 중간중간 에이전트/데이터 공부한 내용을 올리게 될 수도 있을 거 같구요. 

 

항상 글 읽어주시는 분들 감사합니다. 

또 찾아뵙겠습니다. 

 

Profile:
Linkedin