9장. 에이전트 루프 직접 구현
실습문제와 해답
9장의 루프를 직접 측정하고, 다른 질문을 넣고, 실행 과정을 화면에 표시해 봅니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다. 모두 haru_tools.py가 폴더에 있어야 합니다.
문제 1 — 바퀴마다 토큰을 재기
lesson09_react_loop.py의 루프를 새 파일 ex09_token_meter.py에 옮겨 적고, 바퀴마다 다음을 출력하게 고치세요.
- 그 바퀴에 보낸
contents의 덩어리 수 - 입력 토큰(
response.usage_metadata.prompt_token_count)과 지금까지의 누적 토큰(total_token_count의 합) - 모델이 요청한 도구 이름들
루프가 끝난 뒤에는 contents에 쌓인 덩어리를 role과 함께 차례로 출력합니다. 실행하기 전에 짐작해 보세요. 입력 토큰은 바퀴마다 얼마씩 늘까요?
해답 보기
from google.genai import types
from config import MODEL, get_client
from haru_tools import CustomerSession, make_tools
client = get_client()
tools = make_tools(CustomerSession("C003"))
SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
복잡한 문의는 도구를 여러 번 사용해 단계적으로 해결합니다.
- 주문번호를 모르면 먼저 get_my_orders 로 주문을 찾습니다.
- 반드시 도구로 조회한 데이터로만 답합니다.
- 필요한 조회를 모두 마친 뒤에 최종 답변을 합니다. 존댓말."""
question = "지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요."
contents = [types.Content(role="user", parts=[types.Part.from_text(text=question)])]
config = types.GenerateContentConfig(
system_instruction=SYSTEM, temperature=1.0, tools=list(tools.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
total = 0
for step in range(1, 9):
response = client.models.generate_content(model=MODEL, contents=contents, config=config)
u = response.usage_metadata
total += u.total_token_count
calls = [fc.name for fc in response.function_calls or []]
print(f"[step {step}] contents {len(contents)}개 → 입력 {u.prompt_token_count}토큰, "
f"누적 {total}토큰, 요청한 도구 {calls if calls else '없음(최종 답변)'}")
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
result = tools[fc.name](**fc.args)
parts.append(types.Part.from_function_response(name=fc.name, response={"result": result}))
contents.append(types.Content(role="user", parts=parts))
print("\ncontents 에 쌓인 것")
for i, c in enumerate(contents, 1):
kinds = []
for p in c.parts:
if p.text:
kinds.append("글")
elif p.function_call:
kinds.append(f"도구 요청({p.function_call.name})")
elif p.function_response:
kinds.append(f"도구 결과({p.function_response.name})")
print(f" {i}. role={c.role:<5} {', '.join(kinds)}")
$ python ex09_token_meter.py
실행 결과 (도구를 요청하는 순서는 실행마다 다를 수 있습니다)
[step 1] contents 1개 → 입력 694토큰, 누적 706토큰, 요청한 도구 ['get_my_orders']
[step 2] contents 3개 → 입력 1427토큰, 누적 2226토큰, 요청한 도구 ['get_order_status', 'get_order_status', 'get_order_status']
[step 3] contents 5개 → 입력 2003토큰, 누적 4467토큰, 요청한 도구 없음(최종 답변)
contents 에 쌓인 것
1. role=user 글
2. role=model 도구 요청(get_my_orders)
3. role=user 도구 결과(get_my_orders)
4. role=model 도구 요청(get_order_status), 도구 요청(get_order_status), 도구 요청(get_order_status)
5. role=user 도구 결과(get_order_status), 도구 결과(get_order_status), 도구 결과(get_order_status)
해설 — 세 가지를 읽습니다.
입력 토큰이 바퀴마다 늘었습니다. 694 → 1,427 → 2,003. 고객의 질문은 한 번뿐인데 입력이 세 배가 됐습니다. 늘어난 만큼이 contents에 쌓인 도구 요청과 결과입니다. 주문 여덟 건의 목록이 들어간 2바퀴에서 733토큰, 상세 세 건이 들어간 3바퀴에서 576토큰이 더해졌습니다.
누적은 4,467토큰입니다. 세 바퀴째의 입력 2,003토큰 안에는 첫 바퀴에 보낸 694토큰이 다시 들어 있습니다. 같은 내용을 바퀴마다 다시 보내는 것이 루프의 비용 구조입니다.
첫 바퀴가 이미 694토큰입니다. 질문 한 문장이 이만큼일 리 없습니다. 시스템 프롬프트와 도구 여섯 개의 선언이 들어 있기 때문입니다(7장).
아래쪽의 contents 목록은 user → model → user → model → user로 번갈아 나옵니다. 도구 결과가 role=user로 들어간 것, 4번과 5번 덩어리에 조각이 세 개씩 든 것을 확인합니다.
이 파일에는 원래 루프의 try와 max_steps 도달 시의 티켓 접수를 넣지 않았습니다. 재는 데 필요한 부분만 남긴 것입니다.
문제 2 — 질문에 따라 바퀴 수가 달라지는지 보기
react_loop를 불러와 서로 다른 질문 세 개를 차례로 실행하는 파일 ex09_questions.py를 만드세요. 코드는 한 줄도 바꾸지 않고 질문만 바꿉니다.
- "제 멤버십 등급이랑 포인트 알려주세요."
- "LED 무드등 지금 재고 있나요?"
- "지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요."
실행하기 전에 짐작해 보세요. 각 질문은 몇 바퀴가 들까요? 어떤 도구가 어떤 순서로 불릴까요?
- 힌트:
from lesson09_react_loop import react_loop. 이 파일의 실행부는if __name__ == "__main__":아래에 있으므로 불러와도 원래 질문이 다시 실행되지 않습니다.
해답 보기
from lesson09_react_loop import react_loop
questions = [
"제 멤버십 등급이랑 포인트 알려주세요.",
"LED 무드등 지금 재고 있나요?",
"지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요.",
]
for q in questions:
print(f"\n고객: {q}")
answer = react_loop(q)
print(f"하루: {answer}")
$ python ex09_questions.py
실행 결과 (문장은 실행마다 다릅니다)
고객: 제 멤버십 등급이랑 포인트 알려주세요.
[step 1] Action: get_my_membership({})
[step 2] 최종 답변 생성 → 루프 종료
하루: 현재 고객님의 하루클럽 멤버십 등급은 **VIP** 등급이시며, 보유 중인 하루포인트는 **12,500원**입니다.
추가로 궁금한 점이 있으시다면 언제든 말씀해 주세요!
고객: LED 무드등 지금 재고 있나요?
[step 1] Action: search_products({'keyword': 'LED 무드등'})
[step 2] Action: check_stock({'product_id': 'P017'})
[step 3] 최종 답변 생성 → 루프 종료
하루: 'LED 무드등'(상품 ID: P017) 현재 재고는 **110개** 있습니다. 구매 가능하십니다!
고객: 지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요.
[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건입니다. 상품명, 택배사, 송장번호 정보를 정리해 드립니다.
(이하 생략)
해설 — 같은 반복문이 질문에 따라 다르게 돌았습니다. 세 번 실행해 세 번 모두 같은 흐름이었습니다.
| 질문 | 바퀴 수 | 불린 도구 |
|---|---|---|
| 멤버십 등급과 포인트 | 2 | get_my_membership |
| 무드등 재고 | 3 | search_products → check_stock |
| 배송중 주문의 송장번호 | 3 | get_my_orders → get_order_status 세 건 |
바퀴 수를 정한 것은 모델입니다. 우리 코드에는 "멤버십 질문은 두 바퀴"라는 말이 없습니다. 반복문은 요청이 오면 실행하고 결과를 붙일 뿐입니다.
두 번째 질문을 봅니다. check_stock은 상품ID(P017)를 인자로 받는데, 고객은 상품ID를 말한 적이 없습니다. 모델은 먼저 search_products로 상품을 찾고, 그 결과에서 상품ID를 꺼내 다음 바퀴의 인자로 썼습니다. 세 번째 질문에서 주문번호가 주문 목록에서 나온 것과 같은 일입니다. 앞 바퀴의 관찰이 다음 바퀴의 재료가 됩니다.
세 질문 모두 마지막 바퀴는 도구 없이 답만 썼습니다. 답을 쓰는 것도 한 바퀴이므로, 도구를 한 번 부르는 문의는 두 바퀴, 두 번에 걸쳐 부르는 문의는 세 바퀴가 듭니다. 기본 한도 8은 이 문의들에 넉넉합니다.
문제 3 — 관찰을 눈에 보이게 하기
「따라하기」의 로그에는 Action 줄만 있고, 도구가 무엇을 돌려줬는지는 보이지 않습니다. 루프를 새 파일 ex09_observe.py에 옮겨 적고, 도구를 실행할 때마다 관찰(Observation)을 한 줄로 요약해 출력하게 고치세요.
get_my_orders의 결과(리스트)는 건수와 "주문번호=상태"를 나열한다.get_order_status의 결과(딕셔너리)는 상품명, 상태, 택배사, 송장번호를 적는다.
질문은 「따라하기」와 같습니다. 실행하기 전에 짐작해 보세요. 주문 목록에는 주문이 몇 건 들어 있을까요? 그중 모델이 상세 조회한 것은 몇 건일까요?
해답 보기
from google.genai import types
from config import MODEL, get_client
from haru_tools import CustomerSession, make_tools
client = get_client()
tools = make_tools(CustomerSession("C003"))
SYSTEM = """당신은 하루마켓 고객지원 상담원 '하루'입니다.
복잡한 문의는 도구를 여러 번 사용해 단계적으로 해결합니다.
- 주문번호를 모르면 먼저 get_my_orders 로 주문을 찾습니다.
- 반드시 도구로 조회한 데이터로만 답합니다.
- 필요한 조회를 모두 마친 뒤에 최종 답변을 합니다. 존댓말."""
question = "지금 배송중인 제 주문들 전부, 상품명이랑 택배사·송장번호를 정리해서 알려주세요."
contents = [types.Content(role="user", parts=[types.Part.from_text(text=question)])]
config = types.GenerateContentConfig(
system_instruction=SYSTEM, temperature=1.0, tools=list(tools.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
)
for step in range(1, 9):
response = client.models.generate_content(model=MODEL, contents=contents, config=config)
if not response.function_calls:
print(f"[step {step}] 최종 답변: {response.text.strip()}")
break
contents.append(response.candidates[0].content)
parts = []
for fc in response.function_calls:
result = tools[fc.name](**fc.args)
print(f"[step {step}] Action : {fc.name}({dict(fc.args)})")
# 관찰을 눈에 보이게 한다 — 목록이면 건수와 상태, 한 건이면 핵심 값만
if isinstance(result, list):
summary = ", ".join(f"{r['order_id']}={r['status']}" for r in result)
print(f"[step {step}] Observation: {len(result)}건 — {summary}")
else:
print(f"[step {step}] Observation: {result['product_name']} / "
f"{result['status']} / {result['courier']} {result['tracking_no']}")
parts.append(types.Part.from_function_response(
name=fc.name, response={"result": result}))
contents.append(types.Content(role="user", parts=parts))
$ python ex09_observe.py
실행 결과 (문장은 실행마다 다릅니다)
[step 1] Action : get_my_orders({})
[step 1] Observation: 8건 — HR20260726004=결제완료, HR20260725002=배송중, HR20260722033=배송완료, HR20260721001=배송완료, HR20260720022=결제완료, HR20260711064=배송중, HR20260706059=배송중, HR20260628003=배송완료
[step 2] Action : get_order_status({'order_id': 'HR20260725002'})
[step 2] Observation: 무선 블루투스 이어폰 / 배송중 / 한진택배 34622188536
[step 2] Action : get_order_status({'order_id': 'HR20260711064'})
[step 2] Observation: 스테인리스 텀블러 500ml / 배송중 / CJ대한통운 56102174598
[step 2] Action : get_order_status({'order_id': 'HR20260706059'})
[step 2] Observation: UV차단 버킷햇 / 배송중 / 우체국택배 90937026152
[step 3] 최종 답변: 현재 배송 중인 주문은 총 3건입니다. 상품명, 택배사, 송장번호 정보를 정리해 드립니다.
해설 — 로그에 Observation 줄이 생기자 모델이 무엇을 보고 다음 행동을 정했는지가 보입니다.
step 1의 관찰에는 주문이 8건 들어 있습니다. 결제완료 2건, 배송완료 3건, 배송중 3건입니다. step 2에서 모델이 조회한 것은 그중 배송중인 세 건(HR20260725002, HR20260711064, HR20260706059)뿐입니다. 관찰의 =배송중과 다음 Action의 주문번호를 맞춰 보면 하나도 빠지지 않고 하나도 남지 않습니다.
| 로그 줄 | ReAct에서 | 누가 했나 |
|---|---|---|
Action |
행동 | 고른 것은 모델, 실행한 것은 우리 코드 |
Observation |
관찰 | 도구가 돌려준 값. 우리 코드가 contents에 붙였다 |
| (보이지 않음) | 생각 | 모델. 관찰을 읽고 다음 Action을 정했다 |
관찰의 모양이 다음 행동을 정합니다. get_my_orders가 주문마다 order_id와 status를 키로 돌려주었기 때문에, 모델은 상태로 고르고 주문번호를 그대로 인자에 쓸 수 있었습니다. 8장에서 목록 도구가 요약만 주도록 만든 것, 6장에서 반환값의 키 이름을 뜻이 드러나게 지은 것이 여기서 쓰입니다.
step 2의 관찰 세 줄과 최종 답변도 맞춰 봅니다. 답에 나오는 택배사와 송장번호는 모두 이 세 줄에 있는 값입니다. 관찰을 찍어 두면 답의 숫자가 어디서 왔는지 로그만으로 확인할 수 있습니다.