실무 Multi-Agent 오케스트레이션 11장 · 프레임워크 기반 에이전트 구성 5 / 7 ← 이전목차다음 → TechLead Cro

11장. 프레임워크 기반 에이전트 구성

따라하기 — LangChain으로 에이전트 다시 만들기

출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.

목표

9~10장의 상담 에이전트를 LangChain v1의 create_agent로 다시 만듭니다. 질문 세 개(회귀 세트)를 보내고, 실행 추적으로 어떤 도구가 어떤 순서로 불렸는지 확인합니다.


0. 실습 준비

이 장의 실습 고객 — C003 김도윤 고객(VIP 등급)으로 로그인한 상태라고 정해 두고 실습합니다.

VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.

$ conda activate myenv

이번 장은 새로 설치할 것이 없습니다. LangChain은 2장에서 requirements.txt로 함께 설치했습니다. 8장에서 만든 haru_tools.py가 폴더 맨 위에 있어야 합니다.


1. 파일 만들기

haru-market 폴더 맨 위에 새 파일을 만듭니다.

lesson11_langchain_agent.py

아래 코드 전체를 복사해 붙여 넣고 저장합니다.

# -*- coding: utf-8 -*-
"""[11장] LangChain v1 으로 에이전트 재구성 + 실행 추적

9~10장에서 ReAct 루프를 직접 만들었다. 이제 같은 것을 LangChain v1 의
create_agent 로 만든다.

프레임워크가 대신해 주는 것: 루프, 도구 왕복, 메시지 관리, 체크포인트
그대로 남는 것(= 여전히 우리 일): 도구 설계, 시스템 프롬프트, 가드레일, 권한 경계

실행:  python lesson11_langchain_agent.py
"""
import os

from langchain.agents import create_agent
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI

from config import MODEL
from haru_tools import CustomerSession, make_tools

# ── 1. 모델 연결 — langchain-google-genai 가 Gemini 를 LangChain 규격으로 감싼다
# temperature 는 넣지 않는다. Gemini 3 계열은 어댑터가 모델 기본값을 쓰도록 권장한다.
llm = ChatGoogleGenerativeAI(
    model=MODEL,
    google_api_key=os.getenv("GOOGLE_API_KEY"),
)

# ── 2. 도구 등록 — 8장 도구 계층을 @tool 로 감싼다
session = CustomerSession("C003")
raw = make_tools(session)

# @tool 데코레이터는 함수의 타입힌트 + 독스트링으로 도구 스키마를 만든다.
# (google-genai 자동 호출 때와 같은 원리 — 프레임워크가 달라도 본질은 같다)
get_my_orders = tool(raw["get_my_orders"])
get_order_status = tool(raw["get_order_status"])
search_products = tool(raw["search_products"])
check_stock = tool(raw["check_stock"])
create_ticket = tool(raw["create_ticket"])

TOOLS = [get_my_orders, get_order_status, search_products, check_stock, create_ticket]

SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
반드시 도구로 조회한 실제 데이터로만 답합니다.
- 상품·가격·재고는 search_products / check_stock 을 호출한 결과로만 말합니다.
  도구를 호출하지 않고 상품 정보를 언급하는 것은 금지입니다.
- 고객은 이미 로그인되어 있습니다. 주문 조회가 필요한데 주문번호가 없으면
  고객에게 되묻지 말고 get_my_orders 로 직접 찾습니다.
- 도구로 확인한 사실과 확인할 수 없는 사실을 구분합니다. 조회 결과에 없다는 이유만으로
  문제가 없다고 단정하지 않습니다. 주문 목록은 결제 승인·출금 내역이 아니므로,
  주문 조회만으로 이중 결제 여부를 확인했다고 말하지 않습니다.
- 도구로 확인하거나 해결할 수 없어 상담원 확인이 필요한 문의는 create_ticket 을
  실제로 호출합니다. 요약에 고객의 요청과 확인하지 못한 사항을 함께 적습니다.
- create_ticket 결과가 created=True일 때만 반환된 ticket_id로 접수 완료를 안내합니다.
  무엇을 확인할 수 없었는지 밝히고, 상담원 확인을 위한 요청이 접수됐다고 설명합니다.
  아직 실시간으로 상담원에게 연결된 것처럼 말하지 않습니다.
  도구가 실패하면 접수에 실패했다고 알리고, 접수번호나 완료 사실을 지어내지 않습니다.
존댓말, 3~5문장."""

# ── 3. 에이전트 생성 — 9장에 직접 짰던 while 루프가 이 한 줄로
agent = create_agent(
    model=llm,
    tools=TOOLS,
    system_prompt=SYSTEM,
)


def run_traced(question: str):
    """stream 으로 실행 과정을 추적한다 — '왜 그 도구를 골랐나'를 본다."""
    print(f"\n고객: {question}")
    final_text = ""
    # stream_mode="values": 매 단계의 전체 상태(messages)를 받는다
    seen = 0
    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": question}]},
        stream_mode="values",
    ):
        msgs = chunk["messages"]
        for msg in msgs[seen:]:          # 새로 추가된 메시지만 출력
            kind = msg.__class__.__name__
            if kind == "AIMessage" and getattr(msg, "tool_calls", None):
                for tc in msg.tool_calls:
                    print(f"  [추적] 도구 결정 → {tc['name']}({tc['args']})")
            elif kind == "ToolMessage":
                print(f"  [추적] 도구 결과 ← {str(msg.content)}")
            elif kind == "AIMessage" and msg.content:
                # content 는 문자열일 수도, 콘텐츠 블록 리스트일 수도 있다
                if isinstance(msg.content, str):
                    final_text = msg.content
                else:
                    final_text = "".join(
                        b.get("text", "") for b in msg.content
                        if isinstance(b, dict))
        seen = len(msgs)
    print(f"하루: {final_text}")
    return final_text


if __name__ == "__main__":
    # ── 회귀 테스트 세트: 프롬프트나 도구를 바꿀 때마다 이 세트를 다시 돌린다 ──
    regression_set = [
        "제 최근 주문 상태 알려주세요.",
        "무드등 가격이랑 재고 알려주세요.",
        "결제가 이중으로 된 것 같아요. 확인해 주시고 안 되면 상담원 연결해 주세요.",
    ]
    for q in regression_set:
        run_traced(q)

    print("""
──────────────────────────────────────────────────────────
직접 구현(9장) vs LangChain(11장)
  같은 것   : ReAct 루프의 구조, 도구 설계, 프롬프트
  얻은 것   : 루프 코드 대신 표준화된 실행·스트리밍·체크포인트
  남은 책임 : 도구 권한(8장), 비용 상한(10장)은 프레임워크가 안 해 준다
이 에이전트 구조는 21장 '전문 에이전트 3종'의 뼈대가 된다.
──────────────────────────────────────────────────────────""")

2. 코드에서 볼 곳

파일은 네 덩어리입니다.

덩어리 하는 일 9~10장에서는
1. 모델 연결 ChatGoogleGenerativeAI로 Gemini를 감싼다 get_client()
2. 도구 등록 8장의 함수를 tool()로 감싼다 tools=list(tools.values())
3. 에이전트 생성 create_agent(model, tools, system_prompt) react_loop 함수 전체
run_traced stream으로 과정을 받아 출력한다 루프 안의 print

에이전트를 만드는 부분은 이것이 전부입니다.

agent = create_agent(
    model=llm,
    tools=TOOLS,
    system_prompt=SYSTEM,
)

도구는 여섯 개 중 다섯 개만 등록했습니다. get_my_membership은 TOOLS에 없습니다. 등록하지 않은 도구는 모델이 부를 수 없습니다. 도구 목록이 곧 에이전트가 할 수 있는 일의 범위입니다.

시스템 프롬프트는 10장보다 지시가 구체적입니다.

- 상품·가격·재고는 search_products / check_stock 을 호출한 결과로만 말합니다.
  도구를 호출하지 않고 상품 정보를 언급하는 것은 금지입니다.
- 고객은 이미 로그인되어 있습니다. 주문 조회가 필요한데 주문번호가 없으면
  고객에게 되묻지 말고 get_my_orders 로 직접 찾습니다.
- 도구로 확인한 사실과 확인할 수 없는 사실을 구분합니다. 조회 결과에 없다는 이유만으로
  문제가 없다고 단정하지 않습니다. 주문 목록은 결제 승인·출금 내역이 아니므로,
  주문 조회만으로 이중 결제 여부를 확인했다고 말하지 않습니다.
- 도구로 확인하거나 해결할 수 없어 상담원 확인이 필요한 문의는 create_ticket 을
  실제로 호출합니다. 요약에 고객의 요청과 확인하지 못한 사항을 함께 적습니다.
- create_ticket 결과가 created=True일 때만 반환된 ticket_id로 접수 완료를 안내합니다.
  무엇을 확인할 수 없었는지 밝히고, 상담원 확인을 위한 요청이 접수됐다고 설명합니다.
  아직 실시간으로 상담원에게 연결된 것처럼 말하지 않습니다.
  도구가 실패하면 접수에 실패했다고 알리고, 접수번호나 완료 사실을 지어내지 않습니다.
존댓말, 3~5문장.

이 규칙들은 회귀 세트의 세 질문에서 확인할 동작을 정합니다. "도구로 조회한 데이터로만 답하라"는 일반적인 말 대신, 어떤 경우에 어떤 도구를 부르는지를 적었습니다. 3장에서 본 "지켰는지 확인할 수 있는 규칙"입니다. 지켰는지는 추적에서 확인합니다.


3. 실행하기

실행하기 전에 짐작해 보세요.

  • 두 번째 질문 "무드등 가격이랑 재고 알려주세요."에서 도구는 몇 번 불릴까요? 어떤 순서일까요?
  • 세 번째 질문에서 모델은 create_ticket을 바로 부를까요, 다른 도구를 먼저 부를까요?
$ python lesson11_langchain_agent.py

모델을 여덟 번쯤 호출하므로 10~20초 걸립니다.

이전 프롬프트의 실행 결과 (문장은 실행할 때마다 달라집니다)

고객: 제 최근 주문 상태 알려주세요.
  [추적] 도구 결정 → get_my_orders({})
  [추적] 도구 결과 ← [{"order_id": "HR20260726004", "product_name": "콜드브루 원액 500ml 2병", "option": "단일...
하루: 고객님의 최근 주문 내역을 확인해 드립니다. 가장 최근에 주문하신 상품은 '콜드브루 원액 500ml 2병'이며 주문번호는 HR20260726004이고 현재 '결제완료' 상태입니다. 그 이전 주문인 무선 블루투스 이어폰(HR20260725002)은 '배송중'인 것으로 조회됩니다. 추가로 궁금하신 점이 있으시면 언제든지 말씀해 주세요.

고객: 무드등 가격이랑 재고 알려주세요.
  [추적] 도구 결정 → search_products({'keyword': '무드등'})
  [추적] 도구 결과 ← [{"product_id": "P017", "product_name": "LED 무드등", "category": "홈·인테리어", "price"...
  [추적] 도구 결정 → check_stock({'product_id': 'P017'})
  [추적] 도구 결과 ← {"found": true, "product_id": "P017", "product_name": "LED 무드등", "stock": 110, "...
하루: 현재 'LED 무드등' 상품은 19,900원에 판매되고 있습니다.
현재 재고는 110개가 준비되어 있어 여유롭게 구매하실 수 있습니다.
터치 3단 밝기 기능이 있어 침실 협탁용으로 인기가 많은 제품입니다.
추가로 궁금하신 점이 있으시면 언제든지 말씀해 주세요!

고객: 결제가 이중으로 된 것 같아요. 확인해 주시고 안 되면 상담원 연결해 주세요.
  [추적] 도구 결정 → get_my_orders({})
  [추적] 도구 결과 ← [{"order_id": "HR20260726004", "product_name": "콜드브루 원액 500ml 2병", "option": "단일...
  [추적] 도구 결정 → create_ticket({'urgency': '보통', 'category': '계정결제', 'summary': '고객 결제 이중 출금 확인 요청 및 상담원 연결 접수'})
  [추적] 도구 결과 ← {"created": true, "ticket_id": "T00011", "message": "티켓 T00011 접수 완료. 상담원이 순차적으로...
하루: 결제 이중 출금 문제로 확인을 요청하셨네요. 시스템 확인 결과 직접 처리하기 어려운 사안이라 즉시 상담원 연결 티켓(번호: T00011)으로 접수해 드렸습니다. 담당 상담원이 내용을 확인한 후 빠르게 도와드릴 예정이니 조금만 기다려 주시기 바랍니다.
(이하 생략)

티켓 번호는 지금까지 만든 티켓 수에 따라 다르게 나옵니다.

위에 실은 보완된 프롬프트로 다시 실행한 세 번째 답변입니다.

하루: 주문 목록을 조회해 보았으나, 주문 조회만으로는 카드사 결제 승인이나 출금 내역을 파악할 수 없어 이중 결제 여부를 확실히 확인하기 어렵습니다. 고객님의 이중 결제 의심 문의와 상담원 연결 요청에 대해 티켓(접수번호: T00004)이 정상적으로 접수되었습니다. 확인 후 상담원이 순차적으로 안내해 드릴 예정이오니 잠시만 기다려 주시기 바랍니다.

실행 추적에는 get_my_orders와 create_ticket이 있었고, 파일에도 T00004가 저장됐습니다. 티켓 요약에는 주문 조회로 결제 승인·출금 내역 및 이중 결제 여부를 확인할 수 없다는 내용이 포함됐습니다. 접수 여부는 에이전트가 판단했습니다. 이번 결과는 한 번의 실제 실행에서 확인한 것이며, 프롬프트가 모든 실행의 성공을 보장하지는 않습니다.

이렇게 나오면 원인과 조치
ModuleNotFoundError: No module named 'langchain' (myenv)가 꺼져 있거나 라이브러리를 설치하지 않았습니다. conda activate myenv 후 pip install -r requirements.txt
ModuleNotFoundError: No module named 'haru_tools' 8장의 haru_tools.py가 폴더 맨 위에 없습니다
UserWarning: … temperature will be ignored ChatGoogleGenerativeAI(...)에 temperature=를 넣었습니다. 그 줄을 지웁니다

4. 무엇을 관찰했나

루프를 짜지 않았는데 루프가 돌았다

이 파일에는 for step in range(...)도, function_calls를 확인하는 if도 없습니다. 그런데 두 번째 질문에서 도구 결정 → 결과 → 도구 결정 → 결과 → 답변이 이어졌습니다. 9장에서 손으로 짠 반복을 create_agent가 대신 돌렸습니다.

추적의 모양도 9장의 로그와 같습니다. Action:이 [추적] 도구 결정 →으로 이름만 바뀌었습니다.

세 질문 모두 기대한 도구가 불렸다

질문 추적에 나타난 도구 기대와 비교
최근 주문 상태 get_my_orders 같다. 주문번호를 되묻지 않았다
무드등 가격과 재고 search_products → check_stock 같다
이중 결제, 상담원 연결 get_my_orders → create_ticket create_ticket 앞에 조회가 하나 더 있다

세 번째 질문에서 모델은 티켓을 만들기 전에 주문 목록을 먼저 봤습니다. "확인해 주시고"라는 말에 반응한 것으로 보입니다. 우리 도구에는 결제 내역을 보는 기능이 없으므로 주문 목록으로는 이중 결제를 확인할 수 없고, 결국 티켓으로 넘겼습니다. 틀린 동작은 아니지만 모델을 한 번 더 호출했습니다. 추적이 없었다면 알 수 없었을 비용입니다.

답의 숫자를 추적과 맞춰 본다

두 번째 답변의 "19,900원"과 "110개"를 봅니다.

  • 110은 추적의 check_stock 결과에 "stock": 110으로 보입니다.
  • 19,900원은 search_products 결과에 들어 있습니다.

search_products가 돌려준 상품 ID P017을 모델이 다음 호출의 인자로 넘긴 것도 보입니다. 앞 도구의 결과를 읽고 다음 도구를 고른 것은 모델입니다.

답변에 "터치 3단 밝기 기능이 있어 침실 협탁용으로 인기가 많은 제품"이라는 말도 있습니다. data/products.csv에서 P017의 설명을 열어 보면 이렇게 적혀 있습니다.

터치 3단 밝기, 침실 협탁용 무드등.

"터치 3단 밝기"와 "침실 협탁용"은 데이터에 있습니다. "인기가 많은"은 없습니다. 모델이 덧붙인 말입니다. 프롬프트에 "도구로 조회한 실제 데이터로만 답합니다"라고 적었는데도 그렇습니다. 가격이나 재고처럼 틀리면 바로 문제가 되는 내용은 아니지만, 프롬프트가 보장이 아니라는 것을 다시 확인한 셈입니다. 그리고 이것을 가려낼 수 있었던 것은 답의 내용을 추적과 데이터까지 거슬러 올라가 맞춰 봤기 때문입니다.

답변에서 굵은 글씨 표시가 사라졌다

10장의 답변에는 **가 섞여 있었습니다. 이번에는 없고, 길이도 서너 문장입니다. 프롬프트 끝의 "존댓말, 3~5문장"이 작동했습니다.

이 파일에 없는 것

10장에서 만든 것 가운데 이 파일로 옮겨 오지 않은 것이 있습니다.

  • 문의 1건 토큰 상한
  • 입력 가드
  • 스텝을 다 썼을 때 티켓으로 넘기는 처리

create_agent가 대신 해 주지 않습니다. 에이전트가 모델을 몇 번 부르고 토큰을 얼마나 썼는지조차 이 출력에는 없습니다. 실습문제에서 토큰을 세고 호출 횟수를 제한해 봅니다.

실행 끝에 출력되는 요약 상자에 "체크포인트"라는 말이 나옵니다. 체크포인트(checkpoint) 는 에이전트의 상태를 저장해 두었다가 나중에 이어서 실행하는 기능입니다. 이번 장에서는 쓰지 않습니다(23장).


5. 지금 폴더의 모습

haru-market/
├── config.py
├── haru_tools.py
├── (lesson02 ~ lesson08 파일)
├── lesson09_react_loop.py
├── lesson10_guardrails.py
├── lesson11_langchain_agent.py   ← 이번 장
├── data/
└── memory_store/
    └── tickets.json

핵심 정리

  • create_agent(model, tools, system_prompt) 한 번으로 9장의 루프와 같은 반복이 돌았습니다.
  • 세 질문 모두 기대한 도구가 추적에 나타났습니다. 확인한 것은 문장이 아니라 도구 호출입니다.
  • 답의 숫자는 추적의 도구 결과와 맞춰 볼 수 있습니다. 맞춰 보니 데이터에 없는 꾸밈말이 하나 섞여 있었습니다.
  • 도구를 고르고 순서를 정한 것은 모델, 실행한 것은 우리 함수, 반복을 돌린 것은 프레임워크입니다.
  • 토큰 상한과 입력 가드는 옮겨 오지 않으면 없습니다.
← 이전 절실행 추적 — 답변이 아니라 과정을 본다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com