10장. 루프 제어와 가드레일
비용 가드 — 스텝 상한과 토큰 상한
한 줄 요약
비용 가드는 두 개의 숫자입니다. 모델을 몇 번까지 부를 것인가(max_steps), 문의 한 건에 토큰을 얼마까지 쓸 것인가(max_session_tokens). 상한은 감으로 정하지 않고 정상 문의가 쓰는 토큰을 재서 정하며, 상한에 닿은 것은 오류가 아니라 상담원에게 넘기라는 신호로 다룹니다. 넘길 때는 말로만 하지 않고 티켓을 실제로 접수합니다.
1. 루프에서는 토큰이 왜 빨리 느는가
루프는 돌 때마다 contents 전체를 다시 보냅니다. 여기에는 고객 질문, 모델이 요청한 도구 호출, 도구가 돌려준 결과가 모두 쌓여 있습니다. 그리고 매번 시스템 프롬프트와 도구 선언 전체도 함께 갑니다.
| 스텝 | 모델에게 보내는 것 |
|---|---|
| 1 | 시스템 프롬프트 + 도구 선언 + 질문 |
| 2 | 위 전부 + 모델의 도구 요청 + 도구 결과 |
| 3 | 위 전부 + 두 번째 도구 요청 + 두 번째 도구 결과 |
스텝이 늘면 호출 횟수만 느는 것이 아니라 한 번의 호출도 점점 커집니다. 「따라하기」에서 이 숫자를 직접 봅니다.
2. 상한을 얼마로 잡을 것인가 — 재서 정한다
상한은 정상 문의가 실제로 쓰는 토큰을 먼저 재고, 그보다 넉넉히 높게 잡습니다. 이 과정에서는 문의 한 건의 상한을 20,000토큰으로 둡니다. 「따라하기」에서 정상 문의가 이 가운데 얼마를 쓰는지 직접 확인합니다.
상한이 주는 것은 말할 수 있게 된다는 점입니다. "문의 한 건은 어떤 일이 있어도 20,000토큰을 크게 넘지 않는다." 상한이 없으면 이 문장을 쓸 수 없습니다.
상한은 평소에는 닿지 않는 높이에 둡니다. 정상 문의를 방해하지 않으면서 사고만 잡는 자리입니다.
3. 토큰을 세는 자리
2장에서 본 usage_metadata가 여기서 다시 쓰입니다.
spent = 0
for step in range(1, max_steps + 1):
response = client.models.generate_content(
model=MODEL, contents=contents, config=config)
spent += response.usage_metadata.total_token_count
print(f" [step {step}] 누적 토큰 {spent:,} / 상한 {max_session_tokens:,}")
if spent > max_session_tokens:
raise BudgetExceeded(f"토큰 상한 초과 ({spent:,})")
if not response.function_calls:
# 안전 설정에 걸리면 text 가 None 일 수 있다 — 빈 답 대신 안내 문구
return response.text or EMPTY_ANSWER
세는 자리가 호출 직후, 종료 판정보다 앞입니다. 그래야 마지막 답변에 쓴 토큰까지 빠짐없이 셉니다.
한 가지를 미리 알아 둡니다. 이 방식은 이미 쓴 뒤에 압니다. 상한을 넘긴 호출 한 번은 벌써 끝났고 요금도 나갔습니다. 막는 것은 그다음 호출입니다. 그래서 실제로 쓰는 토큰은 상한보다 조금 많을 수 있습니다.
4. 상한을 넘으면 — 예외로 알린다
상한을 넘었을 때 루프가 할 수 있는 일을 비교합니다.
| 방법 | 문제 |
|---|---|
| 빈 문자열을 돌려준다 | 부른 쪽이 사고가 났는지 모른다 |
| 루프 안에서 사과 문구를 돌려준다 | 정상 답변과 구별되지 않는다. 대응을 바꿀 수도 없다 |
| 예외를 던진다 | 부른 쪽이 반드시 알게 되고, 어떻게 대응할지 고를 수 있다 |
예외(exception) 는 "정상적으로 계속할 수 없는 일이 생겼다"는 것을 알리는 파이썬의 장치입니다. Exception을 물려받은 클래스를 하나 만들면 우리만의 예외 종류가 됩니다.
class BudgetExceeded(Exception):
pass
루프는 raise BudgetExceeded(...)로 알리기만 하고, 부른 쪽이 except BudgetExceeded로 받아 무엇을 할지 정합니다.
try:
print(f"하루: {guarded_react_loop(q)}")
except BudgetExceeded as e:
# 예외를 받은 쪽이 대응한다 — 여기서도 티켓을 실제로 접수한다
print(f"[비용 가드 발동] {e} → 세션 종료, 티켓 전환")
print(f"하루: {handoff_to_ticket(q, '토큰 상한 초과')}")
이 파일에서 부른 쪽이 정한 대응은 티켓 접수입니다. "티켓 전환"이라고 출력만 하고 티켓을 만들지 않으면 화면에 찍힌 말이 거짓이 됩니다. 그래서 바로 다음 줄에서 handoff_to_ticket을 불러 실제로 접수하고, 고객에게 갈 안내문을 출력합니다.
8장에서 "도구는 예외로 죽지 않고 항상 값을 돌려준다"고 했습니다. 그것과 어긋나지 않습니다. 도구의 결과는 모델이 읽어야 하므로 값이어야 하고, 루프의 중단은 우리 코드끼리 주고받는 신호이므로 예외가 맞습니다.
5. 한도에 닿으면 — 티켓으로 넘긴다
9장의 루프는 max_steps를 다 쓰면 티켓을 접수했습니다. 이번 장에는 멈추는 길이 둘(스텝 상한, 토큰 상한)이므로, 티켓을 접수하는 부분을 함수 하나로 꺼냈습니다.
def handoff_to_ticket(question: str, reason: str) -> str:
"""자동 처리를 멈추고 티켓을 실제로 접수한 뒤, 티켓번호가 든 안내문을 돌려준다."""
ticket = tools["create_ticket"](
category="기타", urgency="보통",
summary=f"자동 처리 {reason} 문의: {question[:50]}")
return (f"문의가 복잡해 상담원 확인이 필요합니다. "
f"티켓({ticket['ticket_id']})으로 접수해 드렸어요.")
| 멈춘 까닭 | 알리는 방법 | 누가 handoff_to_ticket을 부르나 |
|---|---|---|
| 스텝 상한 | 루프가 끝난다 | 루프 함수의 마지막 줄 — return handoff_to_ticket(question, "스텝 초과") |
| 토큰 상한 | BudgetExceeded 예외 |
예외를 받은 쪽 — handoff_to_ticket(q, '토큰 상한 초과') |
어느 길로 멈추든 티켓이 실제로 만들어지고, 고객은 티켓번호를 받습니다. reason은 티켓의 요약에 들어가 상담원이 "왜 넘어왔는지"를 알 수 있게 합니다.
여기서는 모델이 도구를 고른 것이 아닙니다. 우리 코드가 create_ticket을 직접 불렀습니다. 도구는 평범한 파이썬 함수이므로 코드에서도 부를 수 있습니다. 모델에게 맡기지 않았으므로 "접수하겠다고 말만 하는" 일이 이 자리에서는 일어나지 않습니다.
1장에서 문의 로그의 "미해결 2건"을 0건으로 만들겠다고 했습니다. 해결도 못 하고 사람에게 넘기지도 못한 문의가 미해결입니다. 한도에 닿은 문의를 티켓으로 넘기면 그 문의는 미해결로 남지 않습니다.
핵심 정리
- 루프는 매번 대화 전체를 다시 보내므로 스텝이 늘수록 한 번의 호출도 커집니다.
- 토큰 상한은 정상 문의가 쓰는 토큰을 재서 그보다 넉넉히 높게 정합니다.
- 토큰은 호출 직후에 셉니다. 상한은 넘긴 뒤에 알게 되므로 조금 초과할 수 있습니다.
- 토큰 상한 초과는 예외(
BudgetExceeded) 로 알리고, 대응은 부른 쪽이 정합니다. - 스텝 상한이든 토큰 상한이든 닿으면
handoff_to_ticket으로 티켓을 실제로 접수합니다. 한도에 닿은 것은 오류가 아니라 업무 신호입니다. - 하지 않은 일을 했다고 말하지 않습니다. "티켓 전환"이라고 출력하는 줄 옆에는 티켓을 만드는 줄이 있어야 합니다.