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

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

종료 조건과 max_steps — 루프는 언제 멈추는가

한 줄 요약

루프가 멈추는 길은 둘입니다. 모델이 도구를 더 요청하지 않으면 그것이 최종 답변이고 정상 종료입니다. 그 일이 일어나지 않을 때를 대비해 최대 반복 횟수 max_steps 를 걸어 두고, 한도에 닿으면 티켓을 접수해 상담원에게 넘깁니다. 그리고 도구가 실패해도 루프가 죽지 않도록, 실패도 관찰로 바꿔 모델에게 돌려줍니다.


1. 정상 종료 — 모델이 도구를 그만 부를 때

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

언제 끝낼지는 모델이 정합니다. 필요한 것을 다 알았다고 판단하면 도구 요청 대신 글로 답합니다. 우리 코드는 "도구 요청이 없는 응답"을 보고 그 글을 돌려주며 함수를 끝냅니다.

모델이 그 판단을 잘하도록 시스템 프롬프트에 한 줄을 넣었습니다.

- 필요한 조회를 모두 마친 뒤에 최종 답변을 합니다.

한 번 묻고 답하던 지금까지의 프롬프트에는 없던 문장입니다. 조회가 남았는데 서둘러 답해 버리는 것을 막으려는 것입니다.


2. 보험 — max_steps

모델이 계속 도구를 요청하면 어떻게 될까요. 반복문이 끝나지 않고, 호출할 때마다 요금이 나갑니다. 그래서 반복 횟수에 한도를 둡니다.

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

while True:가 아니라 for … in range(1, max_steps + 1)입니다. 여덟 번을 돌고도 답이 나오지 않으면 반복문을 빠져나와 티켓을 접수하고, 그 번호가 든 안내문을 돌려줍니다.

끝나는 길 조건 돌려주는 것
정상 종료 응답에 도구 요청이 없다 모델의 답변
한도 도달 max_steps번을 돌았다 티켓을 접수하고, 티켓번호가 든 안내문

한도에 닿았을 때 오류를 내지 않는다는 점을 봅니다. 고객 쪽에서는 프로그램이 멈추는 것보다 상담원에게 넘겨졌다는 안내를 받는 편이 낫습니다. 한도에 닿았다는 것은 "이 문의는 자동으로 처리할 범위를 넘었다"는 신호로 다룹니다.

여기서 create_ticket을 부른 것은 모델이 아니라 우리 코드입니다. 도구는 평범한 파이썬 함수이므로 코드에서도 부를 수 있습니다. 8장에서 모델이 "접수하겠다"고 말만 하고 도구를 부르지 않는 일이 있다는 것을 봤습니다. 이 자리는 모델에게 맡기지 않았으므로 그런 일이 없습니다.

하지 않은 일을 했다고 말하지 않습니다. "상담원에게 연결해 드릴게요"라고만 돌려주고 티켓을 만들지 않으면, 고객은 기다리는데 아무도 연락하지 않습니다. 1장에서 정한 규칙이 "하지 않은 일을 했다고 말하지 않는다"와 "넘길 때는 정확히 넘긴다"였습니다.


3. 한도는 얼마가 알맞은가

한도를 정할 때는 답을 쓰는 것도 한 바퀴라는 점을 셈에 넣습니다. 이번 장의 문의는 주문 목록 조회에 한 바퀴, 세 건 상세 조회에 한 바퀴, 답변에 한 바퀴로 모두 세 바퀴가 듭니다. 도구를 n번에 걸쳐 불러야 하는 문의는 한도가 적어도 n+1이어야 합니다.

  • 한도가 너무 작으면 풀 수 있는 문의도 답까지 가지 못하고 티켓으로 넘어갑니다.
  • 한도가 너무 크면 헛도는 루프가 오래 돕니다. 바퀴가 늘수록 한 번의 입력도 커집니다.

이번 장의 기본값은 8입니다. 이 문의는 세 바퀴면 끝나므로 넉넉한 값입니다. 다른 문의는 몇 바퀴가 드는지 「실습문제와 해답」의 문제 2에서 세어 봅니다.


4. 도구가 실패해도 루프는 계속된다

행동 단계의 코드를 다시 봅니다.

fn = tools.get(fc.name)
try:
    result = fn(**fc.args) if fn else {"error": f"없는 도구: {fc.name}"}
except Exception as e:          # 도구 실패도 Observation 으로 전달
    result = {"error": str(e)}

두 가지 실패를 막습니다.

실패 어떻게 되나
모델이 없는 도구 이름을 요청했다 tools.get이 None을 돌려주고, {"error": "없는 도구: …"}가 결과가 된다
도구를 실행하다 예외가 났다 (인자 이름이 틀렸다 등) except가 잡아 {"error": …}가 결과가 된다

어느 쪽이든 프로그램은 멈추지 않고, 오류 내용이 관찰로 모델에게 갑니다. 모델은 그것을 읽고 다른 도구를 써 보거나 고객에게 사정을 설명할 수 있습니다.

8장에서 도구가 스스로 예외로 멈추지 않게 만들었습니다. 그런데도 루프에 try가 또 있는 것은, 도구 바깥에서 생기는 실패가 있기 때문입니다. 모델이 없는 도구를 요청하거나 없는 인자를 넣으면 도구 함수는 시작도 하기 전에 실패합니다. 도구 안의 try로는 잡을 수 없습니다.


5. 이 루프에 아직 없는 것

이번 장의 루프가 가진 안전장치는 max_steps와 try 둘입니다. 없는 것을 적어 둡니다.

없는 것 무슨 일이 생길 수 있나
토큰을 세는 코드 한 문의에 얼마를 썼는지 모른다
같은 도구를 같은 인자로 되풀이하는지 보는 코드 제자리를 맴돌아도 한도까지 돈다
고객의 입력을 살피는 코드 "지시를 무시하고…" 같은 입력이 그대로 루프에 들어간다

지금은 여기서 멈춥니다. 무엇을 달든 달 자리가 있어야 하고, 이번 장에서 그 자리(반복문)를 손에 넣었습니다. 장치를 다는 일은 10장에서 합니다.


핵심 정리

  • 정상 종료는 모델이 도구를 더 요청하지 않을 때입니다. 끝낼 때를 정하는 것은 모델입니다.
  • max_steps 는 모델이 끝내지 않을 때를 대비한 보험입니다. 한도에 닿으면 오류를 내지 않고 티켓을 실제로 접수한 뒤 그 번호를 안내합니다.
  • 답을 쓰는 것도 한 바퀴입니다. 한도는 필요한 바퀴 수보다 넉넉하게 잡습니다. 이번 장의 문의는 세 바퀴, 기본 한도는 8입니다.
  • 없는 도구 요청과 도구 예외는 {"error": …}로 바꿔 관찰로 돌려줍니다. 루프는 계속됩니다.
  • 이 루프에는 아직 토큰 계측, 반복 감지, 입력 검사가 없습니다(10장).
← 이전 절contents — 루프의 기억은 리스트 하나다다음 절 →따라하기 — ReAct 루프 만들기
오명운 · macro@prag-ai.com