실무 Multi-Agent 오케스트레이션 9장 · 에이전트 루프 직접 구현 5 / 7 ← 이전목차다음 → TechLead Cro

9장. 에이전트 루프 직접 구현

따라하기 — ReAct 루프 만들기

목표

SDK의 자동 함수 호출을 끄고, 생각 → 행동 → 관찰을 되풀이하는 반복문을 직접 씁니다. 그리고 한 번의 조회로는 풀 수 없는 문의를 보내, 모델이 몇 바퀴에 걸쳐 어떤 도구를 불렀는지를 [step 1], [step 2] 로그로 따라갑니다.


0. 실습 준비

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

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

$ conda activate myenv

이번 장은 새로 설치할 것이 없습니다. 이번 장의 파일은 8장에서 만든 haru_tools.py를 불러 씁니다. 폴더 맨 위에 있는지 확인합니다.

$ ls haru_tools.py

없으면 실행할 때 ModuleNotFoundError: No module named 'haru_tools'가 납니다. 8장의 「따라하기 1」로 돌아가 먼저 만듭니다.


1. 파일 만들기

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

lesson09_react_loop.py

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

# -*- coding: utf-8 -*-
"""[9장] ReAct 루프 직접 구현 — 프레임워크 없이

문제 상황: "지금 배송중인 제 주문들 전부, 택배사·송장번호를 알려주세요."
  한 번의 도구 호출로는 못 푼다.
  ① 내 주문 목록 조회 → ② 배송중인 주문 고르기 → ③ 주문마다 상세 조회 → ④ 정리해서 답변.
  ③에서 어떤 주문번호를 조회할지는 ①의 결과를 봐야 알 수 있다.
  모델이 결과를 보고 '다음에 뭘 할지'를 반복해서 정해야 한다 = 에이전트 루프(Agent Loop).

ReAct 패턴: Thought(생각) → Action(도구 호출) → Observation(결과 관찰) 반복.
이번 장에서는 SDK의 자동 함수 호출을 끄고, 이 루프를 반복문으로 직접 만든다.
(11장에서 LangChain이 이 루프를 대신하게 된다 — 원리를 알고 넘어가는 것이 목적)

실행:  python lesson09_react_loop.py
"""
from google.genai import types

from config import MODEL, get_client
from haru_tools import CustomerSession, make_tools

client = get_client()
session = CustomerSession("C003")
tools = make_tools(session)

SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
복잡한 문의는 도구를 여러 번 사용해 단계적으로 해결합니다.
- 주문번호를 모르면 먼저 get_my_orders 로 주문을 찾습니다.
- 반드시 도구로 조회한 데이터로만 답합니다.
- 필요한 조회를 모두 마친 뒤에 최종 답변을 합니다. 존댓말."""


def react_loop(question: str, max_steps: int = 8) -> str:
    """ReAct 루프를 직접 구현한다.

    매 반복(step):
      1) 지금까지의 대화(contents)를 모델에 보낸다
      2) 모델 응답에 function_call 이 있으면 → Action: 함수 실행
         결과를 Observation 으로 contents 에 추가하고 다음 반복
      3) function_call 이 없으면 → 최종 답변. 루프 종료
    """
    contents: list[types.Content] = [
        types.Content(role="user", parts=[types.Part.from_text(text=question)])
    ]
    config = types.GenerateContentConfig(
        system_instruction=SYSTEM,
        temperature=1.0,
        tools=list(tools.values()),
        # 자동 실행 OFF — 루프의 주도권을 우리 코드가 잡는다
        automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
    )

    for step in range(1, max_steps + 1):
        response = client.models.generate_content(
            model=MODEL, contents=contents, config=config)

        # ── 종료 조건: 더 이상 도구를 부르지 않으면 최종 답변 ──
        if not response.function_calls:
            print(f"  [step {step}] 최종 답변 생성 → 루프 종료")
            return response.text

        # ── Action: 모델이 요청한 도구를 전부 실행 ──
        contents.append(response.candidates[0].content)   # 모델의 결정 기록
        observation_parts = []
        for fc in response.function_calls:
            fn = tools.get(fc.name)
            print(f"  [step {step}] Action: {fc.name}({dict(fc.args)})")
            try:
                result = fn(**fc.args) if fn else {"error": f"없는 도구: {fc.name}"}
            except Exception as e:          # 도구 실패도 Observation 으로 전달
                result = {"error": str(e)}
            observation_parts.append(
                types.Part.from_function_response(name=fc.name,
                                                  response={"result": result}))
        # ── Observation: 실행 결과를 대화에 추가 ──
        # 도구 결과는 role="user" 로 돌려준다
        # (기본 모델은 role="tool" 을 400 오류로 거절한다)
        contents.append(types.Content(role="user", parts=observation_parts))

    # 스텝 상한 도달 — 넘기겠다고 말만 하지 않고, 실제로 티켓을 접수한 뒤 번호를 안내한다
    ticket = tools["create_ticket"](
        category="기타", urgency="보통",
        summary=f"자동 처리 스텝 초과 문의: {question[:50]}")
    return (f"문의가 복잡해 상담원 확인이 필요합니다. "
            f"티켓({ticket['ticket_id']})으로 접수해 드렸어요.")


if __name__ == "__main__":
    # 여러 스텝을 강제하는 질문: 주문 목록에는 송장번호가 없다.
    # → 목록 조회 후, 배송중인 주문들을 하나씩 상세 조회해야 답할 수 있다.
    q = "지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요."
    print(f"고객: {q}\n")
    answer = react_loop(q)
    print(f"\n하루: {answer}")

    print("""
──────────────────────────────────────────────────────────
루프 로그에서 확인할 것
  - step 1: get_my_orders (주문번호를 모르니 목록부터)
  - step 2+: 필요한 주문만 get_order_status 로 상세 확인
  - 마지막 step: 도구 호출 없이 최종 답변 → 종료
  - max_steps 를 다 쓰면: 티켓을 실제로 접수하고 티켓번호를 안내
남은 문제: 이 루프는 폭주할 수 있다 (무한 반복·토큰 폭탄) → 10장 가드레일
──────────────────────────────────────────────────────────""")

2. 코드에서 볼 곳

부분 하는 일
session = CustomerSession("C003") 김도윤 고객으로 로그인했다고 칩니다
tools = make_tools(session) 그 세션에 묶인 도구 여섯 개
SYSTEM 여러 번 조회해도 되고, 조회를 다 마친 뒤 답하라는 지시
react_loop(question, max_steps=8) 이번 장의 본체. 반복문

react_loop 안은 다섯 토막입니다.

토막 코드 ReAct에서
준비 contents = [질문], config = …(disable=True) —
모델 호출 response = client.models.generate_content(...) Thought
갈림길 if not response.function_calls: return response.text 종료
도구 실행 for fc in response.function_calls: … fn(**fc.args) Action
결과 추가 contents.append(types.Content(role="user", parts=observation_parts)) Observation

(1) 자동 실행을 끈다

config = types.GenerateContentConfig(
    system_instruction=SYSTEM,
    temperature=1.0,
    tools=list(tools.values()),
    # 자동 실행 OFF — 루프의 주도권을 우리 코드가 잡는다
    automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)

8장까지는 maximum_remote_calls=5처럼 횟수만 정해 주고 반복은 SDK에 맡겼습니다. 여기서는 disable=True로 끕니다. 이제 모델이 도구를 요청해도 SDK는 실행하지 않고 그 요청을 응답에 담아 돌려주기만 합니다. 5장에서 왕복을 손으로 돌릴 때 쓴 설정과 같습니다.

도구는 여전히 파이썬 함수 그대로 tools=에 넘깁니다. 선언은 SDK가 만들어 줍니다. 꺼진 것은 실행뿐입니다.

(2) 한 바퀴

for step in range(1, max_steps + 1):
    response = client.models.generate_content(
        model=MODEL, contents=contents, config=config)

    # ── 종료 조건: 더 이상 도구를 부르지 않으면 최종 답변 ──
    if not response.function_calls:
        print(f"  [step {step}] 최종 답변 생성 → 루프 종료")
        return response.text

    # ── Action: 모델이 요청한 도구를 전부 실행 ──
    contents.append(response.candidates[0].content)   # 모델의 결정 기록

매 바퀴 contents 전체를 보냅니다. 도구 요청이 없으면 거기서 함수가 끝나고, 있으면 모델의 응답을 contents에 붙인 뒤 실행으로 넘어갑니다.

(3) 결과를 돌려준다

            observation_parts.append(
                types.Part.from_function_response(name=fc.name,
                                                  response={"result": result}))
        # ── Observation: 실행 결과를 대화에 추가 ──
        # 도구 결과는 role="user" 로 돌려준다
        # (기본 모델은 role="tool" 을 400 오류로 거절한다)
        contents.append(types.Content(role="user", parts=observation_parts))

도구 결과는 {"result": …}로 감싸 function_response 조각으로 만듭니다. 한 바퀴에 도구를 세 개 실행했으면 조각 세 개를 한 덩어리에 담아 role="user"로 붙입니다.

(4) 한도를 다 쓰면 티켓을 접수한다

    # 스텝 상한 도달 — 넘기겠다고 말만 하지 않고, 실제로 티켓을 접수한 뒤 번호를 안내한다
    ticket = tools["create_ticket"](
        category="기타", urgency="보통",
        summary=f"자동 처리 스텝 초과 문의: {question[:50]}")
    return (f"문의가 복잡해 상담원 확인이 필요합니다. "
            f"티켓({ticket['ticket_id']})으로 접수해 드렸어요.")

반복문을 다 돌고도 답이 나오지 않았을 때 오는 자리입니다. 8장의 create_ticket을 우리 코드가 직접 부르고, 돌려받은 티켓번호를 안내문에 넣습니다. 이번 실행은 세 바퀴 만에 끝나므로 이 자리에 오지 않습니다.


3. 실행하기

실행하기 전에 짐작해 보세요. 종이에 적어 두고 로그와 맞춰 봅니다.

  • 루프는 몇 바퀴 돌까요?
  • 각 바퀴에서 어떤 도구가 몇 번 불릴까요? 김도윤 고객의 배송중 주문은 세 건입니다.
$ python lesson09_react_loop.py

5~15초 걸립니다.

실행 결과 (문장은 실행할 때마다 달라집니다)

고객: 지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요.

  [step 1] Action: get_my_orders({})
  [step 2] Action: get_order_status({'order_id': 'HR20260725002'})
  [step 2] Action: get_order_status({'order_id': 'HR20260711064'})
  [step 2] Action: get_order_status({'order_id': 'HR20260706059'})
  [step 3] 최종 답변 생성 → 루프 종료

하루: 현재 배송 중인 주문은 총 3건입니다. 상품명과 택배사, 송장번호를 정리해 드립니다.

1. **무선 블루투스 이어폰** (주문번호: HR20260725002)
   - 택배사: 한진택배
   - 송장번호: 34622188536

2. **스테인리스 텀블러 500ml** (주문번호: HR20260711064)
   - 택배사: CJ대한통운
   - 송장번호: 56102174598

3. **UV차단 버킷햇** (주문번호: HR20260706059)
   - 택배사: 우체국택배
   - 송장번호: 90937026152

추가로 궁금한 점이 있으시면 언제든 말씀해 주세요!
(이하 생략)

4. 무엇을 관찰했나

세 바퀴, 모델 호출 세 번, 도구 실행 네 번

바퀴 모델이 한 것 우리 코드가 한 것
step 1 get_my_orders를 요청 실행하고 주문 목록을 contents에 추가
step 2 get_order_status를 세 개 요청 세 번 실행하고 결과 세 개를 contents에 추가
step 3 도구 요청 없이 답변 답변을 돌려주고 종료

"목록을 먼저 보고, 배송중인 것만 골라 상세를 본다"는 순서는 코드 어디에도 없습니다. 반복문은 "요청이 오면 실행하고 결과를 붙인다"만 합니다. 순서는 모델이 정했습니다.

step 2의 주문번호는 어디서 왔는가

HR20260725002, HR20260711064, HR20260706059. 고객의 질문에 없던 번호입니다. step 1의 결과에서 왔습니다. 모델은 주문 목록에서 상태가 "배송중"인 세 건을 골라 그 번호를 인자로 썼습니다.

배송완료인 주문은 조회하지 않았다는 것도 봅니다. 김도윤 고객의 주문은 세 건보다 많습니다. 모델은 필요한 것만 골랐습니다. 앞 바퀴의 관찰이 다음 바퀴의 재료가 된다는 것이 이 세 줄에 그대로 보입니다.

한 바퀴에 세 개를 요청했다

[step 2]가 세 줄입니다. 모델이 한 응답에 도구 요청 세 개를 담았고, 우리 반복문이 그것을 차례로 실행했습니다. 사람이 나눈 계획에서는 "고르기"와 "한 건씩 조회하기"가 따로였는데, 모델은 한 바퀴에 묶었습니다. 주문을 하나씩 따로 요청했다면 다섯 바퀴가 걸렸을 일입니다.

답의 숫자는 모두 도구에서 왔다

답에 나온 택배사와 송장번호를 [step 2]의 조회 대상과 맞춰 봅니다. 주문번호 세 개가 그대로이고, 송장번호는 get_order_status가 돌려준 값입니다. 모델이 지어낸 숫자가 없습니다.

다만 답에 굵은 글씨 표시(**) 가 섞여 있습니다. 3장의 HARU_SYSTEM_V1에 있던 형식 규칙이 이번 장의 SYSTEM에는 없기 때문입니다. 채팅 화면에 그대로 내보내려면 프롬프트에 형식을 다시 적어야 합니다.

여러분의 결과가 다르다면

LLM의 출력은 실행마다 달라질 수 있습니다. step 2의 세 요청이 한 바퀴가 아니라 여러 바퀴로 나뉘어 나올 수도 있습니다. 그래도 마지막 바퀴에 도구 요청 없이 답이 나오고, 답의 송장번호가 조회 결과와 같다면 루프는 제대로 돈 것입니다.


5. 지금 폴더의 모습

haru-market/
├── config.py
├── data/
├── haru_tools.py                 ← 이번 장이 불러 쓰는 파일
├── …
├── lesson08_safe_tools.py
├── lesson09_react_loop.py        ← 이번 장
└── memory_store/
    └── tickets.json

이번 장의 질문은 세 바퀴 만에 끝나 한도에 닿지 않으므로 티켓이 접수되지 않습니다. memory_store는 달라지지 않습니다.


핵심 정리

  • 자동 함수 호출을 disable=True 로 끄고 반복문을 직접 썼습니다. 꺼진 것은 실행뿐이고 선언은 그대로 SDK가 만듭니다.
  • 이번 실행은 세 바퀴였습니다. 목록 조회 → 상세 조회 세 건 → 답변.
  • step 2의 주문번호 세 개는 step 1의 결과에서 나왔습니다. 순서를 정한 것은 모델입니다.
  • 모델은 한 응답에 도구 요청 세 개를 담았고, 우리 코드가 그것을 모두 실행해 한 덩어리로 돌려줬습니다.
  • 도구 결과는 role="user" 로 contents에 붙였습니다.
  • 이번 장의 산출물은 react_loop() 입니다.
← 이전 절종료 조건과 max_steps — 루프는 언제 멈추는가다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com