티스토리 뷰

어떤 문제는 다음 단계가 실행 중 발견한 정보에 따라 바뀌는 순간부터 고정 체인에 잘 들어맞지 않습니다. 이때 진짜 설계 질문은 “에이전트가 강력한가”가 아니라, 모델에게 줄 도구 선택지를 얼마나 좁고 명확하게 정의할 수 있는가입니다.

에이전트를 마법처럼 보면 운영이 금방 흐려집니다. 반대로 에이전트를 런타임 제어 루프로 보면 무엇을 관찰해야 하는지 선명해집니다. 어떤 도구를 왜 골랐는지, 실패했을 때 무엇을 봐야 하는지, 반복이 어디서 멈춰야 하는지가 중요해집니다.

이 글은 AI App Patterns 101 시리즈의 4번째 글입니다. 여기서는 에이전트와 도구 패턴이 언제 정당화되는지, 그리고 도구 선택을 어떻게 관찰 가능하고 디버깅 가능한 구조로 만들지 살펴봅니다.

이 글에서 다룰 문제

  • 언제 고정 체인 대신 AgentExecutor를 쓰는 편이 맞을까요?
  • 계산기와 검색 도구 사이에서 LLM이 올바르게 고를 수 있게 하려면 도구 설명을 어떻게 써야 할까요?
  • 도구를 선택하는 에이전트를 디버깅할 때 어떤 실행 흔적이 중요할까요?

에이전트는 모든 단계를 미리 하드코딩하는 대신, 모델이 런타임에 도구 호출 경로를 선택하게 하는 제어기입니다.

이 글에서 다룰 내용

 

AI App Patterns 101 (4/6)

예제 코드: github.com/yeongseon-books/ai-app-patterns-101

지금까지 만든 체인은 모두 실행 경로가 고정되어 있었습니다. 입력이 들어오고, 단계가 순서대로 실행되고, 출력이 나갑니다. 에이전트 패턴은 여기서 달라집니다. LLM이 어떤 도구를 호출할지 결정하고, 그 결과를 읽고, 다음에 무엇을 할지 다시 결정합니다. 필요하다면 또 다른 도구를 호출할 수도 있고, 그 시점에서 최종 답을 낼 수도 있습니다.

다룰 주제는 다음과 같습니다.

  • 에이전트와 체인의 차이
  • 도구 정의와 등록
  • ReAct 에이전트 구성
  • 여러 도구 조합하기

에이전트 vs 체인

고정 체인과 동적 에이전트

고정 체인과 동적 에이전트


Chain은 입력 → 단계 A → 단계 B → 출력으로 흐릅니다. 실행 경로는 설계 시점에 결정됩니다.

Agent는 입력 → LLM 추론 → 도구 선택 → 도구 실행 → 결과 관찰 → 필요하면 반복 → 최종 답변으로 흐릅니다. 실행 경로는 런타임에 결정됩니다.

에이전트는 ReAct(Reason + Act) 루프를 사용합니다. 즉 Thought → Action → Observation을 반복하다가, LLM이 답하기에 정보가 충분하다고 판단하는 순간 멈춥니다. LLM은 자신의 추론을 적고, 도구 이름을 고르고, 인자를 넣고, 도구 출력을 읽은 뒤 다시 추론합니다.


도구 정의

도구 레지스트리와 선택 표면

도구 레지스트리와 선택 표면

 

LangChain에서 도구는 @tool로 장식한 Python 함수입니다. docstring이 LLM이 도구를 고를 때 읽는 설명이 됩니다. 따라서 대충 쓸 수 없습니다. 모호한 docstring은 잘못된 도구 선택으로 곧장 이어집니다.

import math
import os
from datetime import datetime

from langchain_core.tools import tool

@tool
def calculate(expression: str) -> str:
    """
    Evaluate a mathematical expression and return the result.
    Examples: '2 + 3 * 4', 'sqrt(16)', 'pow(2, 10)'
    Uses Python expression syntax. Only math functions are allowed.
    """
    try:
        allowed = {
            "sqrt": math.sqrt,
            "pow": math.pow,
            "abs": abs,
            "round": round,
            "pi": math.pi,
            "e": math.e,
        }
        result = eval(expression, {"__builtins__": {}}, allowed)
        return str(result)
    except Exception as exc:
        return f"calculation error: {exc}"

@tool
def get_current_time(timezone: str = "Asia/Seoul") -> str:
    """
    Return the current date and time.
    The timezone parameter accepts a timezone name (default: Asia/Seoul).
    """
    now = datetime.now()
    return f"current time: {now.strftime('%Y-%m-%d %H:%M')} ({timezone})"

@tool
def word_count(text: str) -> str:
    """
    Return the word count and character count of the given text.
    """
    words = len(text.split())
    chars = len(text)
    chars_no_space = len(text.replace(" ", ""))
    return f"words: {words}, characters: {chars} (excluding spaces: {chars_no_space})"

@tool
def unit_convert(value: float, from_unit: str, to_unit: str) -> str:
    """
    Convert a value between units.
    Supported conversions: km/mile, kg/lb, celsius/fahrenheit, m/ft.
    Example: value=100, from_unit='km', to_unit='mile'
    """
    conversions = {
        ("km", "mile"): lambda x: x * 0.621371,
        ("mile", "km"): lambda x: x * 1.60934,
        ("kg", "lb"): lambda x: x * 2.20462,
        ("lb", "kg"): lambda x: x * 0.453592,
        ("celsius", "fahrenheit"): lambda x: x * 9 / 5 + 32,
        ("fahrenheit", "celsius"): lambda x: (x - 32) * 5 / 9,
        ("m", "ft"): lambda x: x * 3.28084,
        ("ft", "m"): lambda x: x * 0.3048,
    }
    key = (from_unit.lower(), to_unit.lower())
    if key not in conversions:
        return f"unsupported conversion: {from_unit} to {to_unit}"
    result = conversions[key](value)
    return f"{value} {from_unit} = {result:.4f} {to_unit}"

ReAct 에이전트 만들기

Thought action observation 루프

Thought action observation 루프

 

import os

from langchain.agents import AgentExecutor, create_react_agent
from langchain_core.prompts import PromptTemplate
from langchain_groq import ChatGroq

llm = ChatGroq(
    model="llama-3.1-8b-instant",
    api_key=os.environ["GROQ_API_KEY"],
)

tools = [calculate, get_current_time, word_count, unit_convert]

# ReAct prompt — instructs the LLM to follow the Thought/Action/Observation loop
react_prompt = PromptTemplate.from_template("""
You are an AI assistant that answers questions using the tools available to you.

Available tools:
{tools}

Tool names: {tool_names}

You MUST follow this exact format:

Question: the question to answer
Thought: think about how to approach the question
Action: the name of the tool to use (must be one from the tool names list)
Action Input: the input to pass to the tool
Observation: the result returned by the tool
... (repeat Thought/Action/Action Input/Observation as needed)
Thought: I now know the final answer
Final Answer: the final answer to the question

Begin!

Question: {input}
Thought: {agent_scratchpad}
""")

agent = create_react_agent(llm=llm, tools=tools, prompt=react_prompt)
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=5,
    handle_parsing_errors=True,
)

questions = [
    "What is 2 to the power of 10?",
    "What time is it now?",
    "How many miles is 100 kilometers?",
    "Count the words in this text, then multiply by 2: 'The quick brown fox jumps over the lazy dog'",
]

for question in questions:
    print(f"\n{'=' * 60}")
    print(f"question: {question}")
    result = agent_executor.invoke({"input": question})
    print(f"final answer: {result['output']}")

에이전트 추론 관찰하기

실행 흔적과 중단 조건

실행 흔적과 중단 조건

 

verbose=True를 주면 콘솔에 Thought, Action, Action Input, Observation이 모두 출력됩니다. 단순한 질문은 보통 한 번의 라운드로 끝납니다. 단어 수를 세고 그 결과에 2를 곱하는 식의 2단계 질문은 보통 두 라운드가 필요하고, 첫 번째 도구 출력이 두 번째 계산의 입력으로 이어집니다.

max_iterations는 무한 루프를 막는 안전장치입니다. 실용적인 작업은 대개 5~10회 안에서 충분합니다.


도구 오류를 우아하게 처리하기

Observation으로 되돌리는 도구 오류

Observation으로 되돌리는 도구 오류

 

도구가 처리되지 않은 예외를 던지면 에이전트는 멈춥니다. 반대로 도구 안에서 예외를 잡아 설명 문자열을 반환하면 에이전트는 계속 실행됩니다. 그 문자열이 Observation이 되고, LLM은 다른 접근을 시도하거나 실패 이유를 설명할 수 있습니다.

멘탈 모델은 “도구 예외를 죽이지 말고 에이전트의 관찰값으로 바꾸라”입니다. 에이전트 루프는 실패 신호까지 읽고 다음 행동을 결정하는 제어기이기 때문입니다.

@tool
def safe_divide(a: float, b: float) -> str:
    """Divide a by b. Returns an error message if b is zero."""
    if b == 0:
        return "error: cannot divide by zero"
    return str(a / b)

이 코드에서 먼저 볼 점

  • main.pyAgentExecutor 데모를 계산기 실행기와 검색 실행기로 나누어 가장 작은 신뢰 가능한 도구 선택 패턴을 보여 줍니다.
  • 각 도구는 @tool(return_direct=True)를 사용해 선택된 도구 결과를 즉시 돌려줍니다.
  • 짧은 프롬프트와 좁은 도구 설명이 함수 호출 실패 모드를 줄입니다.

어디서 자주 헷갈릴까요?

  • 에이전트는 자동으로 더 똑똑해지지 않습니다. 런타임 유연성을 얻는 대신 예측 가능성을 일부 포기합니다.
  • 도구가 약하면 에이전트도 약합니다. 병목은 LLM이 아니라 도구 인터페이스일 때가 많습니다.
  • 검색 도구와 RAG는 멀리서 보면 비슷해 보일 수 있지만, 하나는 도구 호출이고 다른 하나는 프롬프트 문맥 주입입니다.

체크리스트

  • 각 도구에 명확한 설명과 입력 형태가 있다
  • AgentExecutor가 계산기 도구를 한 번 호출한다
  • AgentExecutor가 검색 도구를 한 번 호출한다
  • 선택된 도구 결과가 호출자에게 직접 반환된다

정리

에이전트 패턴은 체인 기반 LLM 앱을 여러 단계와 여러 도구를 가로질러 추론할 수 있는 시스템으로 확장합니다. docstring은 LLM이 도구를 고를 때 가진 거의 유일한 신호입니다. 주석처럼 쓰지 말고 계약처럼 다뤄야 합니다. 도구는 좁고 분명해야 합니다. 하나의 책임, 예외 대신 오류 메시지, 같은 입력에 같은 동작이 기본입니다.

다음 글에서는 워크플로 자동화를 다룹니다. 각 단계가 데이터를 변환해 다음 단계로 넘기는 다단계 체인 설계입니다.

시리즈 목차


참고 자료

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
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
글 보관함