Python & AI Tutorials Logo
LangChain & LangGraph

14. 에이전트 루프 구축하기

13장에서 우리는 LLM의 도구 호출 요청을 실행하고 결과를 돌려주는 방법을 배웠습니다. 그러나 이때 학습한 내용은 도구 호출이 한 번으로 끝나는 상황을 전제로 한 것이었습니다. 실제로는 최종 답변에 필요한 정보를 모두 얻을 때까지 도구 호출이 여러 차례 이어져야 하는 경우가 많습니다.

예를 들어, "프랑스의 인구를 찾은 다음 그것에 2를 곱하라"는 요청을 생각해 보겠습니다. 이 작업은 최소한 두 번의 도구 호출이 필요합니다. 먼저 인구를 조회하고, 그 결과를 받은 후에야 계산을 수행할 수 있습니다. 두 번째 호출은 첫 번째 결과에 의존하기 때문에, 한 번의 도구 호출로 완료할 수 없는 것입니다.

이 장에서는 13장의 단일 사이클을 루프로 전환합니다. 모델이 도구 호출을 요청하는 한 계속 도구를 실행하고, LLM 스스로 도구 사용을 멈출 때까지 반복하는 구조입니다. 그다음, 루프가 끝없이 도는 것을 방지하는 안전 제한을 추가하고, 도구 실패에도 에이전트가 멈추지 않도록 오류 처리하는 방법을 다루겠습니다.

14.1) 단일 사이클에서 루프로

14.1.1) 에이전트 루프는 어떻게 동작하는가?

도입부에서 살펴본 것처럼, 앞 단계의 결과를 보고 다음 행동을 결정해야 하는 작업은 한 번의 도구 호출로 처리할 수 없는 경우가 많습니다. 모델이 도구를 호출하고, 결과를 확인하고, 다시 판단하는 과정을 반복해야 합니다. 이 반복을 가능하게 하는 것이 에이전트 루프이며, 세 단계로 구성됩니다.

  • Think — LLM이 지금까지의 대화를 읽고 다음에 무엇을 할지 판단합니다. 도구 사용이 필요하면 tool_calls를 통해 도구 호출을 요청하고, 필요 없으면 최종 답변을 반환합니다.
  • Acttool_calls에 지정된 도구를 실행합니다.
  • Observe — 도구 실행 결과를 확인하고, 결과를 ToolMessage에 넣어 대화에 추가합니다.

에이전트 루프는 LLM이 더 이상 도구를 요청하지 않을 때까지 이 세 단계를 반복하는 것입니다. 이 패턴은 ReAct(Reason + Act)라고도 불리며, 추론과 행동을 번갈아 수행하는 것이 핵심입니다.

아니오

사용자 입력

THINK
LLM 호출

tool_calls
존재?

ACT
도구 실행

OBSERVE
실행 결과 확인

최종 답변

tool_calls가 존재하면 도구를 실행하고 결과를 대화에 추가한 뒤 다시 LLM을 호출하고, tool_calls가 없으면 LLM이 최종 답변을 반환한 것이므로 루프를 종료합니다. 이제 이 구조를 코드로 구현하겠습니다.

14.1.2) Think-Act-Observe 루프 구현하기

앞에서 살펴본 Think-Act-Observe 루프를 코드로 구현하겠습니다. 먼저 도구와 모델을 준비합니다.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain.tools import tool
 
# 도구 정의
@tool
def get_weather(city: str) -> str:
    """도시의 현재 날씨를 가져옵니다."""
    fake_data = {"Tokyo": "18°C, cloudy", "Cairo": "31°C, sunny"}
    return fake_data.get(city, f"No weather data for {city}.")
 
@tool
def calculate(expression: str) -> str:
    """간단한 산술 표현식을 계산합니다. 예: '3 * 21'."""
    return str(eval(expression))  # 주의: eval()은 보안에 취약합니다. 프로덕션에서는 사용하지 마세요.
 
# 도구 바인딩
tools = [get_weather, calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}

13장에서는 도구를 한 번 실행하고 끝났지만, 이제는 LLM이 도구 호출 요청을 멈출 때까지 반복합니다. while True 루프 안에서 LLM을 호출하고, LLM 응답에 tool_calls가 있으면 도구를 실행한 뒤 다시 LLM을 호출합니다. tool_calls가 없으면 LLM이 최종 답변을 반환한 것이므로 루프를 종료합니다.

python
def run_agent(user_input: str) -> str:
    """LLM이 최종 답변을 반환할 때까지 Think-Act-Observe 루프를 실행합니다."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    while True:
        # THINK: LLM을 호출하여 판단을 요청합니다
        print("THINK: LLM에게 판단을 요청합니다")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # 종료 조건: tool_calls가 없으면 최종 답변입니다
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE: 요청된 도구를 실행하고 결과를 대화에 추가합니다
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            print(f"ACT: '{tool_call['name']}' 도구 호출, 인자={tool_call['args']}")
            
            tool_message = selected_tool.invoke(tool_call)
            print(f"OBSERVE: {tool_message.content}")
            
            messages.append(tool_message)

13장의 코드와 비교해 보겠습니다. 13장에서는 도구를 실행한 뒤 LLM을 마지막으로 한 번 더 호출하여 최종 답변을 받았습니다. 14장에서는 이 과정을 while True 안에 넣고, 매 반복마다 tool_calls 유무를 검사하여 종료 여부를 판단합니다. 핵심 구성 요소는 13장과 동일하며, 반복 구조만 추가된 것입니다.

실행해 보겠습니다.

python
answer = run_agent("What's the weather in Tokyo, and is it warm enough for a walk?")
print(f'최종 응답: {answer}')

출력:

THINK: LLM에게 판단을 요청합니다
ACT: 'get_weather' 도구 호출, 인자={'city': 'Tokyo'}
OBSERVE: 18°C, cloudy
THINK: LLM에게 판단을 요청합니다
최종 응답: Right now in Tokyo it’s 18°C (about 64°F) and cloudy.
That temperature is generally mild and comfortable for a walk for most people.

출력을 보면 THINK → ACT → OBSERVE → THINK의 흐름을 확인할 수 있습니다. 첫 번째 반복에서 LLM은 get_weather 호출을 요청했고, 두 번째 반복에서는 날씨 결과를 보고 최종 답변을 생성했습니다. 이때 tool_calls가 비어 있으므로 최종 답변을 반환하면서 루프가 종료되었습니다.

이제 도입부에서 예로 들었던 두 번째 호출이 첫 번째 결과에 의존하는 경우, 즉 앞 단계의 결과가 있어야 다음 단계를 실행할 수 있는 경우를 테스트하겠습니다.

python
answer = run_agent("Get the temperature in Cairo, then multiply the number by 3.")
print(f'최종 응답: {answer}')

출력:

THINK: LLM에게 판단을 요청합니다
ACT: 'get_weather' 도구 호출, 인자={'city': 'Cairo'}
OBSERVE: 31°C, sunny
THINK: LLM에게 판단을 요청합니다
ACT: 'calculate' 도구 호출, 인자={'expression': '31 * 3'}
OBSERVE: 93
THINK: LLM에게 판단을 요청합니다
최종 응답: Current temperature in Cairo: 31°C. Multiplied by 3 = 93.

이번에는 루프가 세 번 반복되었습니다.

  1. 첫 번째 반복 — LLM이 get_weather("Cairo")를 요청합니다.
  2. 두 번째 반복"31°C, sunny" 결과를 보고, LLM이 calculate("31 * 3")을 요청합니다. 온도를 확인한 후에야 계산식을 만들 수 있었습니다.
  3. 세 번째 반복"31°C, sunny" 결과와 "93" 결과를 보고, LLM이 최종 답변을 반환했습니다.

14.2) 안전 제한 추가하기

앞에서 구현한 루프에는 하나의 종료 조건만 있습니다. LLM 응답에 tool_calls가 없을 때 루프를 빠져나오는 것입니다. 정상적인 경우에는 이것으로 충분하겠지만, LLM이 도구 호출을 멈추지 않는 경우에는 어떻게 될까요?

예를 들어, 도구가 항상 모호한 결과를 반환하면 LLM은 더 나은 결과를 기대하며 같은 도구를 계속 호출할 수 있습니다. while True 루프이므로 LLM이 멈추지 않으면 프로그램도 멈추지 않습니다. API 호출 비용이 계속 발생하면서 무한히 실행되는 것입니다.

이 문제를 해결하는 가장 간단한 방법은 루프 실행 횟수에 상한선을 두는 것입니다. while Truefor step in range(max_steps)로 바꾸면, LLM이 무엇을 하든 max_steps번 반복한 이후에는 반드시 종료됩니다.

python
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """max_steps 이내에서 Think-Act-Observe 루프를 실행합니다."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        # 종료 조건: tool_calls가 없으면 최종 답변입니다
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            messages.append(tool_message)
 
    # max_steps에 도달: 최종 답변 없이 루프가 종료되었습니다
    return f"[최대 반복 횟수({max_steps})에 도달하여 중단되었습니다]"

이전 코드와 비교하면, 바뀐 것은 두 가지입니다. while Truefor step in range(max_steps)로 바뀌었고, 루프가 max_steps에 도달했을 때 반환값이 추가되었습니다. 이제 루프는 두 가지 방식으로 종료됩니다. LLM이 스스로 최종 답변을 반환하거나(자연 종료), max_steps에 도달하는 것(안전 종료)입니다.

안전 제한이 실제로 작동하는지 확인하겠습니다. 절대 유용한 결과를 반환하지 않는 도구를 만들어서, LLM이 도구 호출을 멈추지 않는 상황을 만들어 보겠습니다.

python
@tool
def unhelpful_search(query: str) -> str:
    """정보를 검색합니다."""
    return "No results found. Try rephrasing your query."
 
llm_with_bad_tool = llm.bind_tools([unhelpful_search])
tool_map_bad = {unhelpful_search.name: unhelpful_search}
 
def run_agent_bad(user_input: str, max_steps: int = 5) -> str:
    messages = [
        SystemMessage(content=(
            "You must ALWAYS use the unhelpful_search tool to find information. "
            "You are NOT allowed to answer from your own knowledge. "
            "If the tool returns no results, you MUST rephrase and search again. "
            "Keep searching until you find the answer."
        )),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        print(f"--- Step {step + 1} ---")
        ai_message = llm_with_bad_tool.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map_bad[tool_call["name"]]
            tool_message = selected_tool.invoke(tool_call)
            print(f"ACT: '{tool_call['name']}' → {tool_message.content}")
            messages.append(tool_message)
 
    return f"[최대 반복 횟수({max_steps})에 도달하여 중단되었습니다]"
 
answer = run_agent_bad("What is the population of France?")
print(f"\n최종 응답: {answer}")

출력:

--- Step 1 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 2 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 3 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 4 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
--- Step 5 ---
ACT: 'unhelpful_search' → No results found. Try rephrasing your query.
 
최종 응답: [최대 반복 횟수(5)에 도달하여 중단되었습니다]

max_steps가 없었다면 이 루프는 끝나지 않았을 것입니다. max_steps=5에 의해 5번의 반복 후 강제로 종료되었습니다.

max_steps의 적절한 값은 에이전트 복잡도에 따라 다릅니다. 너무 낮으면 복잡한 작업이 중간에 잘릴 수 있고, 너무 높으면 문제가 생겼을 때 비용이 커지게 됩니다. 15~25 정도가 일반적인 시작점이며, 실제 워크로드를 보면서 조정하면 됩니다.

14.3) 루프에서 도구 오류 처리하기

14.1과 14.2에서 구현한 루프는 도구가 항상 정상적으로 실행된다고 가정하고 있습니다. 하지만 도구가 예외를 발생시키면 어떻게 될까요? 현재 코드에는 예외 처리가 없으므로, 도구 실행 중에 예외가 발생하면 에이전트 전체가 중단됩니다.

12장에서 우리는 도구 내부에서 try/except로 예외를 잡아 오류 메시지 문자열로 반환하는 방법을 배웠습니다. 도구가 그렇게 만들어져 있다면 문제가 없지만, 모든 도구가 내부적으로 오류를 처리하는 것은 아닙니다. 외부 라이브러리나 API를 호출하는 도구는 예상치 못한 예외를 발생시킬 수 있습니다.

이런 경우를 대비하여 루프 레벨에서도 오류를 처리하는 게 좋습니다. 방법은 간단합니다. 도구 실행을 try/except로 감싸고, 예외가 발생하면 오류 메시지를 ToolMessage에 넣어 LLM에게 전달합니다. LLM은 이 오류 메시지를 읽고 수정된 인자로 다시 시도하거나, 다른 접근 방식을 선택할 수 있습니다. 이것을 자기 교정(self-correction)이라고 합니다.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain.tools import tool
 
# 도구 정의
@tool
def calculate(expression: str) -> str:
    """간단한 산술 표현식을 계산합니다. 예: '3 * 21'."""
    return str(eval(expression))  # 주의: eval()은 보안에 취약합니다. 프로덕션에서는 사용하지 마세요.
 
# 도구 바인딩
tools = [calculate]
llm = ChatOpenAI(model="gpt-5-mini")
llm_with_tools = llm.bind_tools(tools)
tool_map = {t.name: t for t in tools}
 
def run_agent(user_input: str, max_steps: int = 10) -> str:
    """도구 실패 시 오류를 LLM에게 전달하여 자기 교정을 가능하게 하는 에이전트 루프."""
    messages = [
        SystemMessage(content="You are a helpful assistant."),
        HumanMessage(content=user_input),
    ]
 
    for step in range(max_steps):
        # THINK
        print("THINK: LLM에게 판단을 요청합니다")
        ai_message = llm_with_tools.invoke(messages)
        messages.append(ai_message)
 
        if not ai_message.tool_calls:
            return ai_message.content
 
        # ACT + OBSERVE
        for tool_call in ai_message.tool_calls:
            selected_tool = tool_map[tool_call["name"]]
            try:
                print(f"ACT: '{tool_call['name']}' 도구 호출, 인자={tool_call['args']}")
                tool_message = selected_tool.invoke(tool_call)
                print(f"OBSERVE: {tool_message.content}")
            except Exception as e:
                print(f"OBSERVE: Error - {e}")
                tool_message = ToolMessage(
                    content=f"Error: {e}",
                    tool_call_id=tool_call["id"],
                )
            messages.append(tool_message)
 
    return f"[최대 반복 횟수({max_steps})에 도달하여 중단되었습니다]"

14.2의 코드와 비교하면, 달라진 것은 try/except뿐입니다. 도구가 예외를 발생시키면 오류 메시지를 ToolMessage에 담아 대화에 추가합니다. 이때 실패한 호출에도 해당 tool_call_id를 가진 ToolMessage를 반드시 추가해야 합니다. LLM은 다음 반복에서 이 오류를 보고 다음 행동을 결정하게 됩니다.

자기 교정이 동작하는 것을 확인하겠습니다. calculate 도구에 0으로 나누기를 시도하여 예외가 발생하는 상황을 만들어 보겠습니다.

python
answer = run_agent("Use the calculator tool to compute 10 / 0")
print(f"최종 응답: {answer}")

출력:

THINK: LLM에게 판단을 요청합니다
ACT: 'calculate' 도구 호출, 인자={'expression': '10 / 0'}
OBSERVE: Error - division by zero
THINK: LLM에게 판단을 요청합니다
최종 응답: I used the calculator tool and it returned an error: "division by zero."
 
Explanation: 10 / 0 is undefined in ordinary arithmetic, so it cannot produce a finite number.
 
Would you like me to:
- compute the one-sided limits,
- show the IEEE-754 floating-point result,
- or evaluate a different expression?

첫 번째 반복에서 LLM은 calculate("10 / 0")을 요청했고, ZeroDivisionError가 발생했습니다. try/except가 이 예외를 잡아서 오류 메시지를 LLM에게 전달했습니다. 두 번째 반복에서 LLM은 오류 메시지를 보고, 0으로 나누기가 불가능하다는 것을 사용자에게 설명하는 최종 답변을 반환했습니다. try/except가 없었다면 첫 번째 ZeroDivisionError에서 프로그램이 중단되었을 것입니다.