AI & Machine Learning

AI 에이전트 구축 방법 - 프레임워크 없이 만드는 루프부터 운영까지

junetapa 2026. 8. 11 12 min read

에이전트를 만드는 일의 8할은 모델을 고르는 것이 아니라 루프를 설계하는 것이다. 프레임워크를 걷어내고 최소 루프부터 직접 굴려보면, 어디서 비용이 새고 어디서 무한 반복이 시작되는지가 코드 위에 그대로 보인다.

워크플로와 에이전트는 다른 물건이다

2026년 들어 "AI 에이전트"라는 말이 붙은 프로젝트를 뜯어보면 절반 이상은 사실 워크플로다. 둘의 차이는 규모나 도구 개수가 아니라 제어권이 어디 있는가 하나다.

워크플로는 다음 단계를 코드가 정한다. 문서를 읽고, 요약하고, 번역하고, 저장한다. 순서는 개발자가 짜 두었고 모델은 각 칸을 채울 뿐이다. 에이전트는 다음 단계를 모델이 정한다. 무엇을 더 알아봐야 하는지, 어떤 도구를 부를지, 이제 끝내도 되는지를 모델이 판단한다.

이 구분이 중요한 이유는 비용과 예측 가능성이 여기서 갈리기 때문이다. 워크플로는 호출 횟수가 코드에 박혀 있어 비용이 계산된다. 에이전트는 같은 입력에도 어떤 날은 3번, 어떤 날은 14번 호출한다. 그래서 에이전트를 쓸지 말지는 취향이 아니라 판단 기준의 문제다.

에이전트가 맞는 조건
  • 단계를 미리 다 적을 수 없다 - "이 설계 문서를 코드로 옮겨줘"는 되고, "이 PDF에서 제목만 뽑아줘"는 아니다
  • 결과가 지연과 비용을 정당화한다 - 사람이 30분 걸릴 일이어야 한다
  • 모델이 실제로 그 일을 할 수 있다 - 못 하는 일에 루프를 씌우면 실패를 반복할 뿐이다
  • 틀렸을 때 잡아낼 수 있다 - 테스트, 리뷰, 롤백 중 하나는 있어야 한다

넷 중 하나라도 "아니오"라면 더 단순한 층으로 내려가는 편이 낫다. 단일 호출로 되는 일에 에이전트를 얹으면 비용은 5배가 되고 결과는 더 나빠진다.

최소 루프 - 40줄로 도는 에이전트

에이전트 루프의 정체는 while 문 하나다. 모델을 부르고, 도구를 부르라고 하면 부르고, 결과를 돌려주고, 끝났다고 할 때까지 반복한다. 그게 전부다.

Python - 최소 에이전트 루프
import anthropic

client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_input}]

while True:
    response = client.messages.create(
        model="claude-opus-5",
        max_tokens=16000,
        tools=tools,
        messages=messages,
    )

    # 모델이 더 부를 도구가 없으면 종료
    if response.stop_reason == "end_turn":
        break

    # 어시스턴트 응답을 통째로 이력에 넣는다 (tool_use 블록 보존)
    messages.append({"role": "assistant", "content": response.content})

    # 요청된 도구를 모두 실행하고 결과를 모은다
    results = []
    for block in response.content:
        if block.type == "tool_use":
            output = execute_tool(block.name, block.input)
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,   # 반드시 원본 id와 짝을 맞춘다
                "content": output,
            })

    # 결과는 «하나의» user 메시지로 되돌린다
    messages.append({"role": "user", "content": results})

짧지만 여기에 초보자가 가장 많이 틀리는 지점 세 개가 이미 들어 있다.

01

응답을 통째로 넣는다

텍스트만 뽑아 content에 문자열로 넣으면 tool_use 블록이 사라진다. 그러면 다음 턴에서 도구 결과가 어디에 붙는지 모델이 알 수 없고, API는 짝이 맞지 않는 요청으로 거절한다. response.content를 그대로 append 한다.

02

병렬 호출 결과는 한 메시지에 담는다

모델은 한 번의 응답에서 도구를 여러 개 부를 수 있다. 이때 결과를 메시지 여러 개로 쪼개 보내면 API는 받아주지만, 모델은 "병렬 호출은 잘 안 먹히는구나"를 학습해 다음부터 하나씩 부르기 시작한다. 지연이 조용히 몇 배가 된다.

03

실패한 도구도 결과를 돌려준다

도구가 예외를 던졌다고 결과를 빼먹으면 안 된다. {"is_error": true, "content": "파일을 찾을 수 없음: config.yml"} 처럼 이유를 적어 돌려준다. 모델은 그 문장을 읽고 다른 경로를 시도한다. 결과를 누락시키면 그 자리에서 루프가 깨진다.

모델은 무엇을 고를까. 판단 품질이 곧 루프 품질이라 상위 모델을 기본으로 두되, 읽기만 하는 하위 작업은 아래로 내리는 편이 비용 효율이 좋다. 2026년 8월 기준 백만 토큰당 단가는 이렇다.

모델 ID컨텍스트입력 / 출력쓰는 자리
claude-opus-51M$5 / $25판단하는 본 루프, 긴 호흡의 작업
claude-sonnet-51M$3 / $15물량이 많은 실서비스 경로
claude-haiku-4-5200K$1 / $5분류, 추출 같은 단순 하위 작업

표의 값은 정가 기준이다. claude-sonnet-5는 2026년 8월 31일까지 도입 단가가 적용돼 실제로는 백만 토큰당 $2 / $10로 청구된다. 물량이 큰 경로라면 이 기간 안에 실측해 두는 편이 이후 비용 산정에 도움이 된다.

참고

루프 도중에 모델을 바꾸면 프롬프트 캐시가 통째로 날아간다. 캐시는 모델 단위로 잡히기 때문이다. 저렴한 모델을 섞고 싶다면 본 루프는 한 모델로 유지하고, 하위 작업만 별도 호출로 떼어내는 편이 낫다.

도구를 붙인다

루프가 돌면 다음은 도구다. 여기서 성능을 가르는 것은 코드가 아니라 설명문이다. 모델은 도구의 구현을 보지 못한다. 이름, 설명, 파라미터 스키마만 읽고 언제 부를지 판단한다.

가장 흔한 실패는 설명이 짧은 것이다. 한 줄짜리 설명은 거의 항상 과소 호출을 부른다. 최근 모델들은 확실하지 않으면 도구를 안 부르는 쪽으로 기운다. 그래서 설명에는 무엇을 하는지만이 아니라 언제 불러야 하는지를 적어야 한다.

도구 정의 - 트리거 조건을 설명에 넣는다
{
  "name": "search_orders",
  "description": (
      "주문 데이터베이스를 조회한다. "
      "사용자가 주문 상태, 배송 일자, 환불 진행 상황을 물을 때 호출한다. "
      "대화에 이미 나온 주문 정보로 답할 수 있으면 호출하지 않는다. "
      "반환값에는 주문번호, 상태 코드, 최근 이벤트 3건이 들어 있고 "
      "결제 수단 정보는 포함되지 않는다."
  ),
  "input_schema": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string", "description": "주문번호. 예: ORD-20260811-0042"},
      "include_history": {"type": "boolean", "description": "이벤트 이력 포함 여부. 기본 false"}
    },
    "required": ["order_id"],
    "additionalProperties": false
  },
  "strict": true
}

strict: true를 켜면 입력이 스키마를 정확히 따르도록 강제된다. 검증 코드를 줄일 수 있고, 잘못된 파라미터로 도구가 터지는 사고가 사라진다. 다만 additionalProperties: falserequired가 함께 있어야 한다.

bash 하나로 다 할까, 도구를 나눌까

셸 도구 하나만 주면 모델은 거의 무엇이든 할 수 있다. 대신 호출하는 쪽은 문자열 하나만 받는다. 어떤 명령이 위험하고 어떤 것이 안전한지 구분할 수 없으니 승인 게이트도 병렬 실행도 붙일 수 없다.

기준은 이렇다. 되돌리기 어려운 동작은 전용 도구로 승격시킨다. 메일 발송, 결제, 삭제, 외부 API 쓰기가 여기 해당한다. 전용 도구여야 "이 작업 진행할까요?" 확인을 붙일 수 있고, 감사 로그에 무엇을 했는지 남길 수 있다. 반대로 읽기 전용 탐색은 셸로 묶어도 손해가 없다.

참고

도구 호출의 규격 자체 - 제공사별 필드 이름, 병렬 호출 처리, 비용 구조 - 는 AI 에이전트 툴 콜링 설계 가이드에 따로 정리해 두었다. 이 글은 그 위에서 루프를 어떻게 굴릴지를 다룬다.

도구가 스무 개를 넘어가기 시작하면 전부를 매 요청에 실어 보내는 것 자체가 비용이 된다. 이때는 도구 검색(tool search)을 쓴다. 스키마를 지연 로딩으로 표시해 두면 모델이 필요한 것만 찾아 불러온다. 중요한 점은 이 방식이 기존 프롬프트 접두사를 덧붙이는 방향이라 캐시를 깨지 않는다는 것이다. 도구 목록을 통째로 갈아끼우면 캐시가 전부 무효화된다.

컨텍스트와 메모리를 어디까지 넣을까

컨텍스트 창이 100만 토큰이 되면서 "다 넣으면 되지 않나"라는 생각이 늘었다. 실제로 돌려보면 그렇지 않다. 넣은 만큼 비용이 붙고, 오래된 도구 결과가 쌓일수록 모델은 지금 무엇이 중요한지 헷갈린다.

컨텍스트는 세 층으로 나눠 생각하면 정리된다.

바뀌는 주기두는 자리
고정 지침, 도구 정의배포 때만맨 앞. 캐시 구간
세션 단위 문맥 (사용자, 프로젝트)세션당 한 번고정 구간 다음
턴마다 바뀌는 것 (질문, 도구 결과)매 호출맨 뒤

이 순서를 지키는 이유는 프롬프트 캐시가 접두사 일치로 동작하기 때문이다. 앞쪽 한 바이트만 바뀌어도 그 뒤가 전부 무효가 된다. 시스템 프롬프트 머리에 현재 시각: 2026-08-11 22:31 같은 줄을 넣는 순간, 그 아래 전부가 매번 새로 계산된다.

캐시를 조용히 깨는 것들
  • 시스템 프롬프트에 들어간 타임스탬프, UUID, 요청 ID
  • 키 정렬 없이 직렬화한 JSON - 같은 데이터가 매번 다른 문자열이 된다
  • 사용자마다 달라지는 도구 목록 - 도구는 맨 앞에 실려 캐시가 사용자별로 쪼개진다
  • 루프 도중의 모델 변경

캐시가 실제로 먹고 있는지는 응답의 usage.cache_read_input_tokens로 확인한다. 같은 접두사로 반복 호출하는데 이 값이 계속 0이라면 위 목록 중 하나가 범인이다. 캐시 읽기는 기본 입력 단가의 10분의 1 수준이라, 긴 시스템 프롬프트를 쓰는 에이전트에서는 이 한 줄이 월 비용을 좌우한다.

세션을 넘는 기억

대화가 끝나도 남아야 하는 정보는 컨텍스트가 아니라 파일에 둔다. 메모리 도구를 붙여 모델이 직접 읽고 쓰게 하는 방식이 가장 단순하다. 형식을 정해 주는 것이 핵심이다. "한 파일에 교훈 하나, 맨 위에 한 줄 요약"처럼 규칙을 주면 다음 세션에서 검색이 된다. 규칙 없이 쌓게 두면 몇 주 뒤에는 읽을 수 없는 로그 더미가 된다.

멈추는 조건 - 무한 루프와 비용

에이전트를 처음 굴려보면 거의 반드시 한 번은 겪는다. 같은 도구를 같은 인자로 계속 부르면서 끝나지 않는 루프다. 원인은 대개 셋 중 하나다. 도구가 빈 결과를 돌려주는데 모델이 그걸 "아직 못 찾았다"로 읽거나, 종료 조건이 프롬프트에만 있고 코드에 없거나, 실패한 도구 결과를 누락시켜 모델이 같은 시도를 반복하는 경우다.

대책은 프롬프트가 아니라 코드에 박는다. 세 겹으로 두면 웬만한 사고는 걸린다.

Python - 세 겹의 정지 조건
MAX_TURNS = 25
seen_calls = set()
turn = 0

while True:
    turn += 1

    # (1) 하드 캡 - 어떤 경우에도 여기서 끊긴다
    if turn > MAX_TURNS:
        raise RuntimeError(f"턴 한도 초과: {MAX_TURNS}")

    response = client.messages.create(
        model="claude-opus-5",
        max_tokens=16000,
        tools=tools,
        messages=messages,
        # (2) 모델에게 남은 예산을 알려 스스로 마무리하게 한다
        output_config={
            "effort": "high",
            "task_budget": {"type": "tokens", "total": 200000},
        },
        betas=["task-budgets-2026-03-13"],
    )

    if response.stop_reason == "end_turn":
        break

    for block in response.content:
        if block.type == "tool_use":
            # (3) 같은 도구를 같은 인자로 두 번 부르면 그 사실을 알려준다
            key = (block.name, json.dumps(block.input, sort_keys=True))
            if key in seen_calls:
                output = "직전에 동일한 인자로 호출한 결과와 같다. 다른 접근을 시도할 것."
            else:
                seen_calls.add(key)
                output = execute_tool(block.name, block.input)

세 겹의 성격이 서로 다르다는 점이 중요하다. 턴 한도는 하드 캡이다. 무슨 일이 있어도 여기서 멈춘다. 작업 예산은 모델이 인지하는 값이다. 남은 토큰이 줄어드는 것을 모델이 알기 때문에, 잘리는 대신 스스로 우선순위를 정하고 마무리한다. 최소 2만 토큰부터 설정할 수 있다. 중복 감지는 루프의 가장 흔한 형태를 직접 끊는다.

작업 예산과 max_tokens는 다르다

max_tokens는 응답 하나의 상한이고 모델은 이 값을 모른다. 넘으면 문장 중간에서 잘린다. task_budget은 루프 전체의 예산이고 모델이 남은 양을 본다. 넘기 전에 정리하고 끝낸다. 둘 다 필요하다. 전자는 안전장치, 후자는 페이스 조절이다.

비용 쪽에서 실제로 효과가 큰 조정은 노력 수준(effort)이다. 대부분의 작업은 high가 균형점이고, 코딩과 도구를 많이 쓰는 작업은 xhigh에서 결과가 좋아진다. 반대로 분류나 단순 조회에 max를 쓰면 지연과 비용만 올라간다. 한 단계 낮춰서 품질이 유지되는지 실제 평가셋으로 재보는 편이 낫다.

관측과 평가 없이는 고칠 수 없다

에이전트 디버깅이 어려운 이유는 실패가 한 지점에서 나지 않기 때문이다. 12번째 도구 호출이 이상했던 것이 아니라 3번째 호출에서 잘못된 파일을 읽었고, 그 뒤 아홉 턴이 그 위에서 쌓인 것이다. 최종 출력만 보면 절대 안 보인다.

기록해야 할 최소 항목은 이렇다. 이것만 남겨도 대부분의 사고는 재현된다.

  • 턴 번호, 그 턴의 stop_reason
  • 호출한 도구 이름과 인자 전문, 반환값의 앞부분과 전체 길이
  • 턴별 usage - 입력, 출력, 캐시 읽기, 캐시 쓰기 토큰
  • 요청 ID - 문제를 보고할 때 추적의 시작점이 된다

특히 토큰 사용량은 턴별로 남겨야 의미가 있다. 전체 합계만 보면 "많이 썼다"는 것밖에 알 수 없지만, 턴별로 보면 어느 도구가 컨텍스트를 폭발시켰는지 한눈에 잡힌다. 실무에서는 검색 결과를 통째로 돌려주는 도구 하나가 전체 비용의 절반을 먹고 있는 경우가 흔하다.

평가는 작게, 그러나 고정해서

에이전트는 같은 입력에도 다르게 동작하므로 "한 번 돌려보니 잘 되더라"는 근거가 되지 않는다. 그렇다고 처음부터 큰 평가 체계를 만들 필요는 없다. 실제로 겪은 실패 케이스 열 개를 고정해 두고, 프롬프트나 도구를 고칠 때마다 그 열 개를 돌리는 것으로 충분히 시작된다.

기준은 최종 답이 맞았는지 하나만 두지 않는다. 몇 턴 만에 끝났는지, 어떤 도구를 몇 번 불렀는지, 토큰을 얼마나 썼는지를 함께 본다. 답은 맞았는데 턴이 두 배로 늘었다면 그것도 회귀다.

검증을 지시하지 않는다

최신 모델들은 시키지 않아도 스스로 결과를 확인한다. "마지막에 반드시 검증 단계를 넣어라" 같은 지침을 남겨두면 과잉 검증이 일어나 턴과 비용만 늘어난다. 이전 모델용으로 넣어둔 검증 지시는 지우고 다시 재보는 편이 낫다. 검증 로직이 정말 필요하다면 프롬프트가 아니라 코드에 두는 쪽이 확실하다.

프레임워크로 갈아탈 시점

여기까지 직접 만들어 보면, 프레임워크가 무엇을 대신해 주는지가 명확해진다. 대부분은 루프 그 자체다. SDK가 제공하는 툴 러너를 쓰면 위의 while 문을 직접 쓸 필요가 없다. 함수만 정의하면 스키마 생성, 호출, 결과 반환까지 알아서 돈다.

Python - 툴 러너를 쓰면 루프가 사라진다
from anthropic import beta_tool

@beta_tool
def get_order(order_id: str) -> str:
    """주문 상태를 조회한다.

    Args:
        order_id: 주문번호. 예: ORD-20260811-0042
    """
    return query_db(order_id)

runner = client.beta.messages.tool_runner(
    model="claude-opus-5",
    max_tokens=16000,
    tools=[get_order],
    messages=[{"role": "user", "content": user_input}],
)

for message in runner:
    print(message)

루프를 직접 쓸 이유가 남아 있는 경우는 생각보다 적다. 승인 게이트, 로깅, 결과 가공은 툴 러너의 턴별 훅으로 다 된다. 정말 직접 써야 하는 상황은 SDK가 만들 수 없는 요청 형태를 쓰거나, 루프 중간에 전혀 다른 작업을 끼워 넣어야 할 때 정도다.

여기서 한 단계 더 올라가는 선택지가 갈린다. 정리하면 네 가지다.

방식내가 쓰는 것고르는 기준
직접 루프while 문 전부제어를 전부 쥐고 싶을 때
툴 러너도구 함수만대부분의 경우. 기본값으로 두면 된다
다중 에이전트 프레임워크역할과 협업 규칙독립된 갈래로 일이 쪼개질 때
관리형 에이전트설정과 도구 결과루프와 실행 환경까지 맡기고 싶을 때

세 번째로 넘어가는 신호는 명확하다. 하나의 루프가 읽기만으로 컨텍스트를 다 채우거나, 서로 무관한 갈래 여러 개를 동시에 진행해야 할 때다. 이 지점에서는 하위 에이전트에게 나눠 주고 보고만 받는 구조가 실제로 싸고 빠르다. 다만 하위 에이전트는 매번 문맥을 새로 세워야 하므로, 직접 몇 번 부르면 끝날 일에 쓰면 손해다.

프레임워크별 성격 차이 - CrewAI, AutoGen, LangGraph 계열의 설계 철학과 어디서 갈리는지 - 는 AI 에이전트 개발 CrewAI AutoGen 비교에 정리해 두었다. 다만 순서를 바꿔 읽지 않기를 권한다. 루프를 한 번 직접 굴려본 뒤에 읽으면 각 프레임워크가 무엇을 숨기고 있는지가 보이고, 그 전에 읽으면 이름만 외우게 된다.

마무리

에이전트 구축에서 어려운 부분은 모델을 부르는 코드가 아니다. 그건 40줄이면 끝난다. 어려운 것은 언제 멈출지, 무엇을 기억할지, 실패를 어떻게 알아챌지를 정하는 일이고, 이건 전부 프레임워크 밖에 있다.

그래서 순서를 뒤집지 않는 편이 좋다. 프레임워크를 먼저 고르면 그 프레임워크가 정해 둔 답을 그대로 쓰게 된다. 최소 루프를 먼저 굴려 보면 내 문제에 맞는 정지 조건과 컨텍스트 전략이 뭔지 알게 되고, 그다음에 고른 프레임워크는 훨씬 오래 간다.

실제로 만들어 본 입장에서 가장 효과가 컸던 세 가지를 꼽으면 이렇다. 첫째, 하한과 상한은 프롬프트가 아니라 코드에 박는다. 프롬프트에 적은 규칙은 언젠가 무시된다. 둘째, 턴별 토큰 사용량을 처음부터 남긴다. 나중에 붙이려면 사고가 난 뒤다. 셋째, 도구 설명에 "언제 부르는지"를 적는다. 이 한 줄이 모델을 바꾸는 것보다 결과를 크게 움직인다.

AI 에이전트 에이전트 구축 툴 콜링 LLM 개발 Claude API 에이전트 아키텍처
junetapa
junetapa
AI 도구를 직접 써보고 솔직한 경험을 공유하는 개발자.
Twitter Facebook URL 복사