포스트

LLM 에이전트 기초 (3) - Agent Loop: 관측, 판단, 행동의 순환과 종료 조건

한 번의 도구 왕복을 while 루프로 감아 에이전트를 완성하고, 종료 조건과 실패 처리, 무한 반복 진단을 다룹니다.

LLM 에이전트 기초 (3) - Agent Loop: 관측, 판단, 행동의 순환과 종료 조건

LLM 에이전트 기초 시리즈의 3편입니다. 2편의 한 번짜리 왕복을 루프로 감아 에이전트를 완성합니다.

한 번으로는 끝나지 않는 요청

2편에서 만든 코드는 도구를 한 번 부르고 답을 냅니다. 도구 목록을 넘기고, stop_reasontool_use로 돌아오면 도구를 실행하고, 그 결과를 붙여 한 번 더 호출해 최종 답을 받는 구조였습니다. 도구 하나로 끝나는 요청이라면 이걸로 충분합니다.

문제는 “이수진 주문 찾아서 이어폰 환불해 줘” 같은 요청입니다. 이 문장을 처리하려면 도구를 세 번 불러야 합니다. 고객 이름밖에 없으니 search_orders로 주문 목록을 가져와 이어폰의 주문번호를 알아내야 하고, 환불 가능한 상태인지 get_order로 확인해야 하고, 그다음에야 request_refund를 부를 수 있습니다. 이 순서는 우리가 코드에 적어 넣은 것이 아니라 모델이 정합니다.

2편 코드로 이 요청을 넣고 search_orders 결과를 돌려주면, 두 번째 응답의 stop_reason과 텍스트가 이렇게 나옵니다.

1
2
tool_use
주문 목록을 확인했습니다. 무선 이어폰 주문(20260801-0012)의 상태를 조회하겠습니다.

stop_reason이 또 tool_use입니다. 모델은 다음 도구를 부르려고 했는데 우리 코드가 거기서 끝나 버렸습니다. 텍스트만 뽑아 출력했으니 예고만 남고 조회는 일어나지 않았습니다. 환불도 당연히 접수되지 않았습니다.

고칠 곳은 한 군데입니다. stop_reasontool_use인 동안 계속 돌면 됩니다.

루프의 정체

에이전트 루프(agent loop) 라는 이름이 붙어 있지만 실체는 반복문 하나입니다. 세 가지가 순환합니다.

  • 관측(observation): 도구 실행 결과를 대화에 붙입니다.
  • 판단(reasoning): 모델이 대화를 보고 다음 행동을 정합니다. 도구를 더 부를지 답을 낼지가 여기서 갈립니다.
  • 행동(action): 모델이 요청한 도구를 우리가 실행합니다.
flowchart LR
    A[사용자 요청] --> B[모델 판단]
    B -->|tool_use| C[도구 실행]
    C -->|도구 결과| B
    B -->|end_turn| D[최종 답변]

2편과 달라지는 것은 화살표 하나, 도구 실행에서 모델 판단으로 되돌아가는 선입니다. 이 선이 없으면 파이프라인이고 있으면 에이전트입니다. 순서를 코드에 적지 않아도 되는 이유가 여기 있습니다. 우리는 도구를 실행해 결과를 돌려주는 일만 구현하고, 어떤 도구를 어떤 순서로 부를지는 매 바퀴 모델이 정합니다. 같은 순환을 그래프로 조립하는 방법은 LangGraph 기초에 따로 정리해 두었습니다.

run_agent 구현

loop.py를 새로 만듭니다. 2편에서 만든 agent.pyclient, MODEL, run_tool을 그대로 가져다 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# loop.py
import json

from agent import client, MODEL, run_tool
from tools import TOOL_SPECS

MAX_STEPS = 10


def run_agent(user_message: str, system: str = "", max_steps: int = MAX_STEPS) -> str:
    messages = [{"role": "user", "content": user_message}]
    for step in range(max_steps):
        response = client.messages.create(
            model=MODEL, max_tokens=4096, system=system,
            tools=TOOL_SPECS, messages=messages,
        )
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason != "tool_use":
            return "".join(b.text for b in response.content if b.type == "text")

        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            try:
                output = run_tool(block.name, block.input)
                is_error = "error" in output
            except Exception as exc:
                output, is_error = {"error": str(exc)}, True
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(output, ensure_ascii=False),
                "is_error": is_error,
            })
        messages.append({"role": "user", "content": results})
    return "단계 한도에 도달했습니다."

40줄이 안 되는 이 함수가 시리즈의 중심입니다. 4편의 컨텍스트 관리, 5편의 승인 게이트, 6편의 위임을 모두 이 함수 안에 붙입니다. 지금 짚어 둘 규칙은 네 가지입니다.

  • response.content를 통째로 넣습니다. 텍스트만 뽑아 문자열로 넣으면 tool_use 블록이 사라지고, tool_resulttool_use_id가 가리킬 대상이 없어져 400이 납니다.
  • 한 응답의 도구 결과는 하나의 user 메시지에 전부 담습니다. results에 모아 한 번에 append하는 이유이며, 병렬 도구 호출 절에서 다시 다룹니다.
  • tool_use_id는 대응하는 블록의 id와 같아야 합니다. 직접 만들거나 순번을 쓰면 안 됩니다.
  • 루프 탈출은 stop_reason으로 판단합니다. 텍스트 내용이나 도구 이름으로 판단하지 않습니다.

thinking 파라미터는 넣지 않았습니다. Claude Opus 5는 thinking이 기본으로 켜져 있어 생략하면 adaptive로 동작합니다. temperature 같은 샘플링 파라미터는 Opus 5에서 제거되어 넣으면 400이 납니다.

실행 궤적 보여주기

루프 안에서 무슨 일이 일어나는지 봐야 합니다. 도구 실행 지점이 run_tool 한 곳뿐이라 여기에 print 두 줄만 넣으면 궤적이 전부 보입니다.

1
2
3
4
5
6
7
8
9
def run_tool(name: str, args: dict) -> dict:   # agent.py
    print(f"  [도구] {name} {args}")
    result = TOOL_FUNCS[name](**args)
    print(f"  [결과] {result}")
    return result


if __name__ == "__main__":                     # loop.py
    print(run_agent("이수진 주문 찾아서 이어폰 환불해 줘"))
1
2
3
4
5
6
7
8
9
  [도구] search_orders {'customer': '이수진'}
  [결과] [{'order_id': '20260801-0012', 'item': '무선 이어폰', ...}, {'order_id': '20260803-0447', ...}]
  [도구] get_order {'order_id': '20260801-0012'}
  [결과] {'order_id': '20260801-0012', 'price': 89000, 'status': '배송완료', ...}
  [도구] request_refund {'order_id': '20260801-0012', 'reason': '고객 환불 요청'}
  [결과] {'order_id': '20260801-0012', 'status': '환불접수', 'reason': '고객 환불 요청'}

이수진 고객님의 무선 이어폰 주문(20260801-0012, 89,000원) 환불을 접수했습니다.
2026년 8월 3일 배송완료된 건이며, 현재 상태는 환불접수로 변경되었습니다.

세 단계 궤적이 나왔습니다. 여기서 봐야 할 것은 결과의 정확성이 아니라 순서입니다. 우리 코드 어디에도 “먼저 search_orders를 부르고 그다음 get_order를 부른다”는 문장이 없습니다. run_agentstop_reason을 확인하고 도구를 실행할 뿐이고, 순서를 정한 것은 모델입니다. 모델은 첫 결과에서 이어폰의 주문번호를 골랐고, 상세로 배송완료 상태임을 확인한 뒤 환불을 접수했습니다. reason 인자도 사용자 문장에서 만들어 낸 것입니다.

같은 요청을 다시 돌리면 궤적이 달라질 수 있습니다. search_orders 결과에 이미 상태가 들어 있으므로 get_order를 건너뛸 수도 있습니다. 에이전트가 매번 같은 경로로 답하지 않는다는 점은 8편에서 다시 다룹니다.

종료 조건 네 가지

루프를 만들었으니 멈추는 문제가 생깁니다. 실무에서 필요한 종료 조건은 네 가지입니다.

첫째, stop_reasontool_use가 아닌 경우입니다. 정상 종료입니다. 다만 end_turn(할 말을 다 함), max_tokens(출력 한도), refusal(거절)은 의미가 다르므로 나눠서 처리해야 합니다.

둘째, max_steps 도달입니다. for step in range(max_steps)가 이 역할을 합니다. 이걸 while True로 바꾸면 안 됩니다. 루프 한 바퀴는 API 호출 한 번이고, 다섯 바퀴째 요청에는 앞선 네 번의 응답과 도구 결과가 전부 실려 갑니다. 무한 루프에 빠지면 요금이 바퀴 수의 제곱에 가깝게 늘어납니다. 도구가 세 개인 지금은 10이면 넉넉하고, 도구가 늘어나면 20에서 30을 씁니다.

셋째, 도구 실패 반복입니다. 같은 도구가 연속으로 실패하면 모델이 스스로 회복할 가능성이 낮으므로 연속 실패 횟수를 세서 끊습니다.

넷째, 같은 도구를 같은 인자로 반복하는 경우입니다. 모델이 이전 결과를 이해하지 못한 상황이며, 호출 서명을 집합에 넣어 두고 잡습니다.

앞의 run_agent에 네 가지를 넣으면 이렇게 바뀝니다. 루프 바깥에 상태 두 개를 두고 기존 코드 세 군데를 손보는 것이 전부입니다.

아래 코드는 이어지는 한 덩어리가 아닙니다. 기존 run_agent의 서로 다른 네 지점에 각각 넣는 조각이며, 주석의 위치 표시를 보고 넣으세요. 그대로 복사해 붙이면 동작하지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# [지점 1] for step in range(max_steps) 바로 앞
    seen_calls, consecutive_errors = set(), 0

# [지점 2] 기존의 stop_reason 확인 자리를 이걸로 교체
        if response.stop_reason != "tool_use":           # (a) 정상 종료
            if response.stop_reason == "max_tokens":
                raise RuntimeError("출력이 잘렸습니다. max_tokens를 올리세요.")
            if response.stop_reason == "refusal":
                raise RuntimeError("모델이 요청을 거절했습니다.")
            return "".join(b.text for b in response.content if b.type == "text")

# [지점 3] for block in response.content 안, run_tool 호출 자리를 이걸로 교체
            signature = (block.name, json.dumps(block.input, sort_keys=True))
            if signature in seen_calls:                  # (d) 같은 호출 반복
                output = {"error": "같은 도구를 같은 인자로 다시 호출했습니다. "
                                   "이전 결과를 쓰거나 다른 접근을 시도하세요."}
                is_error = True
            else:
                seen_calls.add(signature)
                ...                                      # 기존 try/except 그대로

# [지점 4] results를 messages에 넣은 직후
        consecutive_errors = consecutive_errors + 1 if any(
            r["is_error"] for r in results) else 0
        if consecutive_errors >= 3:                      # (c) 실패 반복
            return "도구 실행이 반복해서 실패했습니다."

중복 호출을 감지했을 때 예외를 던지지 않고 에러 결과를 돌려준다는 점이 중요합니다. 방금 같은 호출을 했다고 알려주면 모델이 다른 접근을 시도할 여지가 생기고, 그래도 반복하면 연속 실패 카운터가 끊습니다.

읽기 전용 도구와 쓰기 도구를 구분할 필요도 있습니다. search_orders를 두 번 부르는 것은 낭비일 뿐이지만, request_refund를 두 번 부르면 환불이 두 번 접수됩니다. 되돌릴 수 없는 도구 앞에 사람을 세우는 일은 5편에서 다룹니다.

도구가 실패할 때

도구는 실패합니다. 없는 주문번호가 들어오거나, 외부 API가 타임아웃되거나, 인자 타입이 틀립니다. 흔히 하는 실수는 실패한 도구의 결과를 아예 안 보내는 것입니다. except 절에서 continue로 넘어가 결과를 빠뜨리면 다음 호출에서 400이 납니다. assistant 메시지에는 tool_use 블록이 있는데 대응하는 tool_result가 없기 때문입니다.

보낼 때는 is_errorTrue로 세웁니다. 그러면 모델이 정상 결과가 아니라 실패로 인식하고 복구를 시도합니다. run_agent("주문번호 20260801-9999 환불 처리해 줘")로 없는 주문번호를 넣어 보겠습니다.

1
2
3
4
5
6
7
8
  [도구] get_order {'order_id': '20260801-9999'}
  [결과] {'error': '주문번호 20260801-9999를 찾을 수 없습니다.'}
  [도구] search_orders {'customer': '이수진'}
  [결과] [{'order_id': '20260801-0012', ...}, {'order_id': '20260803-0447', ...}]

주문번호 20260801-9999는 조회되지 않습니다. 오타일 가능성이 있어 확인해 보니
비슷한 번호로 20260801-0012(무선 이어폰, 배송완료) 주문이 있습니다.
이 주문을 환불할까요, 아니면 다른 주문번호를 알려주시겠습니까?

모델이 실패를 받고 멈추지 않았습니다. 조회에 실패하자 다른 도구로 후보를 찾아본 뒤 사용자에게 확인을 요청했고, 없는 주문번호로 환불을 강행하지도 않았습니다.

is_error 없이 결과만 보내도 모델이 문맥으로 실패를 알아챌 수는 있지만, 그건 모델의 독해력에 기대는 것입니다. run_agentis_error = "error" in output 한 줄로 판정할 수 있는 것은 0편에서 도구 세 개가 실패를 전부 {"error": ...} 형태로 돌려주도록 통일해 두었기 때문입니다. 도구를 추가할 때도 이 규약을 지키면 됩니다.

병렬 도구 호출

한 응답에 tool_use 블록이 여러 개 올 수 있습니다. Claude API는 병렬 도구 호출이 기본으로 켜져 있어서, “이수진 주문 두 건의 상태를 각각 확인해 줘”처럼 서로 의존하지 않는 요청이면 get_order 두 건이 한 응답에 함께 옵니다. run_agentresponse.content를 순회하며 tool_use 블록을 전부 처리하는 것이 이 때문입니다. 첫 블록만 처리하고 break하면 나머지 결과가 빠져 400이 납니다.

지켜야 할 규칙이 하나 더 있습니다. 결과는 반드시 하나의 user 메시지에 전부 담아야 합니다. 결과마다 messages.append를 호출해 나눠 보내면 형식상 통과하더라도 모델은 병렬로 요청한 것이 순차적으로 돌아왔다고 학습하고, 이후 병렬 호출을 그만둡니다. 도구 다섯 개를 한 번에 부를 수 있는 요청이 다섯 바퀴로 늘어납니다.

도구가 서로 상태를 건드려 동시 실행이 위험하다면, 결과를 쪼개지 말고 병렬 호출 자체를 끕니다.

1
2
3
4
5
response = client.messages.create(
    model=MODEL, max_tokens=4096, system=system,
    tools=TOOL_SPECS, messages=messages,
    tool_choice={"type": "auto", "disable_parallel_tool_use": True},
)

이러면 한 응답에 tool_use 블록이 최대 하나만 옵니다. 순서가 결정적이 되고 로그도 읽기 쉬워지지만 그만큼 왕복이 늘어납니다. 기본은 켜 두고 도구가 공유 자원을 건드릴 때만 끄는 편이 낫습니다.

무한 반복 진단

에이전트를 붙이고 가장 자주 만나는 증상은 같은 도구를 계속 부르는 것입니다. max_steps가 끊어 주기는 하지만 끊는 것과 고치는 것은 다릅니다. 원인은 대개 셋 중 하나입니다.

하나, 도구 결과가 모델이 기대한 형식이 아닙니다. 도구가 빈 리스트를 돌려주는데 그것이 결과 없음인지 조회 실패인지 구분되지 않으면, 모델은 인자를 조금씩 바꿔 가며 계속 시도합니다. 결과를 명시적으로 만듭니다.

1
2
3
4
5
6
def search_orders(customer: str) -> list[dict] | dict:
    found = [{"order_id": oid, **o} for oid, o in ORDERS.items()
             if o["customer"] == customer]
    if not found:
        return {"error": f"고객 '{customer}'의 주문이 없습니다."}
    return found

빈 결과에 error를 달면 is_errorTrue로 잡히고, 모델은 같은 도구를 다시 부르는 대신 다른 접근을 찾습니다.

둘, 도구 설명과 실제 동작이 다릅니다. description에는 “주문 목록을 가져옵니다”라고 적혀 있는데 실제로는 최근 30일 주문만 돌려준다면, 모델은 없는 주문을 계속 찾습니다. 도구 설명은 모델이 읽는 유일한 명세이므로 “30일 이전 주문은 조회되지 않습니다” 같은 제약을 그대로 적습니다.

셋, 종료 조건을 시스템 프롬프트가 알려주지 않습니다. 모델은 답을 내도 되는 기준을 스스로 정하고, 기준이 없으면 확인을 반복합니다. 언제 멈춰도 되는지를 적어 줍니다.

1
2
3
4
5
6
7
8
9
SYSTEM = """당신은 온라인 쇼핑몰의 주문 처리 담당자입니다.
도구로 요청을 처리하고, 처리가 끝나면 결과를 요약해 답하세요.

- 필요한 정보를 모두 확인했으면 더 조회하지 말고 바로 답하세요.
- 같은 도구를 같은 인자로 두 번 부르지 마세요. 이전 결과를 그대로 쓰세요.
- 도구가 error를 돌려주면 같은 호출을 반복하지 말고 다른 방법을 찾으세요.
- 정보가 부족해 진행할 수 없으면 추측하지 말고 사용자에게 물으세요."""

run_agent("이수진 주문 찾아서 이어폰 환불해 줘", system=SYSTEM)

세 가지 모두 루프 코드의 문제가 아니라는 점이 중요합니다. run_agent는 이미 맞게 돌고 있고, 무한 반복은 대부분 도구 쪽이나 프롬프트 쪽에서 고칩니다. 로그에 도구 이름과 인자를 찍어 두는 이유가 여기에 있습니다. 궤적을 보면 어느 결과에서 모델이 막혔는지가 바로 보입니다.

루프는 도는데 대화가 무거워진다

에이전트가 완성됐습니다. 도구를 여러 번 이어 부르고, 순서를 스스로 정하고, 실패하면 복구를 시도하고, 정해진 조건에서 멈춥니다.

그런데 run_agentmessages 리스트는 한 번도 줄어들지 않습니다. 매 바퀴 assistant 응답과 도구 결과가 append되고 그 전체가 다음 요청에 다시 실려 갑니다. 열 바퀴를 돌면 마지막 요청에는 앞선 아홉 바퀴가 전부 들어 있습니다.

지금은 도구 결과가 주문 두 건짜리 작은 JSON이라 티가 나지 않습니다. 검색 도구가 문서 스무 개를 돌려주면 이야기가 달라집니다. 도구 결과 하나가 5,000토큰이면 열 바퀴에 5만 토큰이고, 그것이 매 바퀴 반복해서 입력으로 청구됩니다. 컨텍스트 한도에 닿는 것보다 요금이 먼저 문제가 됩니다.

4편에서 토큰을 세는 방법, 도구 결과에서 버릴 것을 버리는 방법, 대화가 길어졌을 때 압축하는 방법으로 run_agent가 스무 턴을 넘겨도 버티게 만듭니다.

다음 글: LLM 에이전트 기초 (4) - Context: 토큰 예산, 도구 결과 다이어트, 압축

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.