Skip to content
This page has been auto-translated and may contain errors.View in English

에이전트

docs.scrimba.com

**에이전트**란 루프 안에서 실행되며, 작업이 끝날 때까지 다음에 호출할 도구를 계속 선택해 나가는 모델을 말합니다. 이 장의 나머지 부분은 그것이 정확히 무엇을 뜻하는지, 어디에서 제 값을 하는지, 그리고 어디에서 조용히 대가를 치르게 되는지를 다룹니다.

에이전트 루프

도구 사용에서는 모델이 함수를 하나 호출하고 그 결과를 다시 돌려보냈습니다. 하지만 어떤 작업은 여러 단계가 필요하고, 그 단계를 미리 알 수 없는 경우도 있습니다. "가장 싼 항공권을 찾아서 예약해줘"라는 요청은 검색하고, 비교하고, 일정을 확인한 다음 예약하는 순서를 의미할 수 있는데, 모델은 그 순서를 진행하면서 스스로 파악해 나가야 합니다.

그래서 도구 호출 하나만 처리하는 대신, 전체 과정을 루프 안에 넣습니다. 이렇게 반복되는 순환 구조가 바로 **에이전트 루프**입니다. 작업이 끝날 때까지 한 턴이 계속 반복됩니다.

  1. 대화 내용과 사용 가능한 도구 목록을 모델에 전달합니다.
  2. 모델은 도구를 호출하거나 최종 답변을 내놓습니다.
  3. 도구를 호출했다면 그것을 실행하고, 결과를 대화에 추가한 다음 1단계로 돌아갑니다.
  4. 최종 답변을 내놓았다면 멈춥니다.

매 턴마다 모델은 이전 도구 결과를 포함해 지금까지 일어난 모든 일을 보고, 다음에 할 일을 결정합니다. 도구 사용과의 차이는 이것 하나입니다. 모델이 작업을 마쳤다고 판단할 때까지 계속 반복한다는 점입니다.

진짜 달라지는 것은 누가 방향을 정하느냐입니다. 지금까지 살펴본 모든 장에서는 코드가 제어 흐름을 결정했습니다. 언제 모델을 호출할지, 언제 도구를 실행할지, 다음에 무엇을 할지를 여러분이 정했습니다. 에이전트는 그 결정권을 모델에게 넘깁니다. 루프는 "다음에 무엇을 해야 하는가?"를 계속 묻고, 모델의 예측이 매번 그 답이 됩니다. 여러분은 제어권을 유연성과 교환하는 셈이고, 그 교환이 바로 도달 범위와 위험의 근원입니다.

그 이면에서는 새로운 일이 일어나지 않습니다. 점점 커지는 컨텍스트에 대한 동일한 예측일 뿐이며, 도구 사용에서의 왕복이 반복되는 것입니다. 모델은 계획을 세워놓고 그것을 따르는 게 아닙니다. 매 턴마다 지금 볼 수 있는 것을 기준으로 다음 한 걸음으로 가장 그럴듯한 것을 예측할 뿐이고, 그런 다음 루프가 다시 돌아갑니다.

이것이 잘 맞아떨어지면 목표를 향해 추론하는 것처럼 보입니다. 그렇지 않으면 에이전트는 스스로 맴돌거나 막다른 길로 빠져버립니다. 시스템의 어느 부분도 전체 계획을 붙잡고 있지 않기 때문입니다.

Juno에이전트 루프 에이전트는 도구 사용 루프를 반복하는 것입니다. 대화와 도구를 전달하면 모델이 도구를 호출하거나 최종 답변을 내놓고, 도구를 실행해서 결과를 다시 넣어주는 과정을 답이 나올 때까지 반복합니다. 매 턴마다 모델은 이전의 모든 결과를 보고 다음 단계를 고릅니다. 단순한 도구 사용에 비해 추가된 유일한 개념은 호출 하나만 처리하는 대신 반복한다는 것뿐입니다.

도구 사용에서는 호출 하나를 처리하고 결과를 돌려주었습니다. 에이전트는 그 왕복 과정을 루프로 감싸서, 모델이 끝났다고 판단할 때까지 실행하는 것입니다. 그 루프가 바로 **에이전트 루프**입니다. 핵심은 이것입니다. 단계의 순서를 미리 코드로 박아넣지 않고, 매 턴마다 모델이 다음 단계를 고르게 하는 것입니다.

이 순환은 네 단계로 이루어집니다.

  1. 대화 내용과 도구 목록을 모델에 전달합니다.
  2. 모델은 도구 호출이나 최종 텍스트 답변으로 응답합니다.
  3. 도구를 요청했다면 실행하고, 각 결과를 대화에 추가한 다음 반복합니다.
  4. 답변을 내놓았다면 멈추고 반환합니다.

이 구조가 동작하는 원리는 모델이 매 턴마다 계속 늘어나는 전체 대화를 본다는 데 있습니다. 처음 요청, 지금까지 모델이 호출한 모든 도구, 그리고 돌아온 모든 결과까지 말이죠. 그렇게 누적된 컨텍스트가 에이전트의 유일한 기억입니다. 매 예측은 지금까지의 전체 이력을 조건으로 삼기 때문에, 이전 결과에 의존하는 단계들을 이어나갈 수 있는 것입니다.

미리 짚어둘 만한 세부 사항이 하나 있습니다. 2단계는 한 번에 하나가 아니라 여러 개의 도구 호출을 반환할 수 있습니다. 세 도시의 날씨를 확인하는 것처럼 호출들이 서로 의존하지 않을 때는 모델이 한 턴에 세 개를 모두 요청할 수 있고, 여러분은 그것들을 함께 실행합니다. 어떤 호출이 이전 호출의 결과를 필요로 할 때는 모델이 그 결과를 기다린 후에야 다음 호출을 요청할 수 있으므로, 그런 호출들은 서로 다른 턴에 걸쳐 나타납니다. 루프는 두 경우를 모두 처리합니다. 이번 턴에 돌아온 호출들을 모두 실행하고, 모든 결과를 넣은 다음 다시 예측하는 것입니다.

이 안에는 전역적인 계획이 존재하지 않습니다. 모델은 전체 작업의 지도를 보고 예측하는 게 아니라, 지금 볼 수 있는 것을 기준으로 다음 최선의 한 걸음을 예측합니다. 그것이 바로 에이전트와 여러분이 지금까지 작성해온 고정된 파이프라인의 차이이며, 같은 루프가 조사 단계를 이어가는 데도, 스스로 맴도는 데도 쓰일 수 있는 이유입니다.

Juno에이전트 루프 에이전트는 도구 사용 왕복 과정을 루프로 만든 것입니다. 대화와 도구를 전달하면 모델이 도구를 호출하거나 답변하고, 도구를 실행해서 결과를 다시 넣어주는 과정을 답이 나올 때까지 반복합니다. 계속 늘어나는 대화가 에이전트의 유일한 기억이므로, 각 단계는 그 이전의 모든 결과를 조건으로 삼습니다. 한 턴은 서로 독립적인 여러 도구 호출을 한꺼번에 반환할 수도 있고, 서로 의존하는 경우에는 하나씩 반환할 수도 있습니다. 전역 계획은 어디에도 존재하지 않습니다. 매 단계가 새롭게 다음 한 걸음을 추측하는 것입니다.

에이전트는 도구 사용 왕복 과정을 루프에 넣고 운전대를 넘겨준 것, 즉 **에이전트 루프**입니다. 모델이 매 턴 다음 도구를 고르고, 여러분이 그것을 실행하고, 결과를 다시 넣어주는 과정을 모델이 호출 대신 최종 답변을 내놓을 때까지 반복합니다. 이 루프는 네 단계로 이루어지지만, 흥미로운 부분은 그게 아닙니다. 흥미로운 부분은 이 구조가 무엇을 주고 무엇을 주지 않느냐입니다.

순환 구조는 다음과 같습니다.

  1. 현재까지의 대화와 도구 스키마를 전달합니다.
  2. 모델이 도구 호출이나 최종 답변을 반환합니다.
  3. 호출이 있으면 실행하고 결과를 추가한 다음 반복합니다.
  4. 최종 답변이 나오면 반환합니다.

한 턴이 여러 개의 도구 호출을 반환할 수 있습니다. 모델은 호출들이 서로 독립적이라고 판단하면(데이터 의존성이 없는 세 건의 조회처럼) 병렬로 호출을 내놓고, 하나의 결과가 다른 호출에 필요하면 턴에 걸쳐 순차적으로 호출을 내놓습니다. 이것을 외부에서 강제할 수는 없습니다. 작업이 병렬화되는지는 모델이 추론한 작업 성격에 따른 모델의 판단이므로, 여러분이 병렬적이라고 생각한 작업이 순차적으로 돌아와 더 느려질 수도 있고, 순차적이라고 생각한 작업이 한꺼번에 처리해야 할 배치로 돌아올 수도 있습니다.

여기서 결정적인 사실이 하나 있고, 모든 실패 유형이 결국 이것으로 거슬러 올라갑니다. 전역적인 계획은 존재하지 않습니다. 모델은 작업의 지도를 붙잡고 그것을 실행에 옮기는 게 아닙니다. 매 턴마다 현재 대화를 기준으로 다음 단계를 예측하며, 그 대화가 곧 시스템 전체의 상태입니다. 여러분의 프롬프트와 지금까지의 모든 호출 및 결과가 누적된 것 말이죠.

매 단계는 그 앞 단계를 그대로 믿고, 전체를 점검하는 장치는 어디에도 없습니다. 그래서 목표를 향해 추론하는 것처럼 보이는 에이전트도, 사실은 국지적으로 그럴듯한 추측들을 이어간 것에 불과하며, 일이 잘 풀렸을 때 그것들이 우연히 맞아떨어진 결과일 뿐입니다.

지금 마음에 새겨둘 만한 결론이 있습니다. 안전장치를 다루는 절에서 이것을 다시 다룰 것이기 때문입니다. 상태가 오직 계속 늘어나는 기록뿐이고 매 단계가 그것을 조건으로 삼기 때문에, 초기의 잘못된 도구 결과는 교정되지 않고 그 위에 계속 쌓여갑니다. 오류는 앞으로 전파되면서 누적되고, 루프의 어느 부분도 그것을 알아챌 위치에 있지 않습니다. 에이전트의 신뢰성 작업이란 대부분 바로 이 하나의 속성과 싸우는 일입니다.

Juno에이전트 루프 에이전트는 모델이 방향을 잡는 도구 사용 왕복 루프입니다. 모델이 다음 도구를 고르고, 여러분이 실행하고, 결과를 다시 넣어주는 과정을 답이 나올 때까지 반복합니다. 상태 전체는 계속 늘어나는 기록이며, 전역 계획은 어디에도 없고 오직 이전 단계를 조건으로 한 다음 단계 추측들의 연쇄만 있습니다. 한 턴은 모델이 독립적이라고 판단하면 병렬 호출을, 서로 의존한다고 판단하면 순차 호출을 반환하는데, 이것을 여러분이 통제할 수는 없습니다. 하나만 기억하세요. 매 단계는 그 앞 단계를 그대로 믿기 때문에, 초기의 잘못된 결과는 전체를 점검하는 장치 없이 그대로 앞으로 누적됩니다.

최소한의 에이전트

이 루프를 코드로 옮기면 다음과 같습니다. 도구 사용에서 만든 도구 정의와 구현을 그대로 재사용하고, 모델이 더 이상 도구를 요청하지 않을 때까지 실행되는 루프로 감쌉니다.

python
import json

def run_agent(user_text, tools, tool_impls, max_steps=6):
    messages = [{"role": "user", "content": user_text}]
    step = 0

    while step < max_steps:
        response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
        message = response.choices[0].message
        messages.append(message)

        calls = message.tool_calls
        if not calls:
            return message.content  # 도구를 원하지 않음: 이것이 최종 답변

        # 요청된 도구를 모두 실행하고 결과를 다시 넣어준다
        for call in calls:
            args = json.loads(call.function.arguments)
            result = tool_impls[call.function.name](args)
            messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

        step += 1

    return "Stopped: the agent reached its step limit."  # 안전장치

위에서 아래로 읽어보면 이렇습니다. 모델을 호출하고, 그 메시지를 대화에 쌓고, 도구를 요청했다면 모두 실행해서 결과를 추가한 다음 다시 반복합니다. 모델이 도구 호출 대신 텍스트를 반환하면 그것이 최종 답변이고, 여러분은 그것을 반환합니다.

max_steps 값은 **단계 제한**을 설정하는데, 이는 루프가 폭주하지 않도록 막아주는 안전장치이며, 다음 절에서 다룰 내용입니다. 여기 나온 형태는 특정 제공사의 SDK를 따른 것입니다. 필드 이름은 제공사마다 다르지만, 루프 구조는 어디서나 동일합니다.

Juno최소한의 에이전트 최소한의 에이전트는 모델 호출을 감싼 루프입니다. 모델을 실행하고, 모델이 요청한 도구를 실행하고, 결과를 다시 넣어주는 과정을 반복합니다. 모델이 도구 호출 대신 텍스트를 반환하면 그 텍스트가 최종 답변입니다. 단계 제한은 나중에 덧붙이는 옵션이 아니라 반드시 있어야 할 안전장치입니다.

이 루프를 코드로 옮기면 다음과 같습니다. 도구 사용에서 만든 도구 정의와 구현을 재사용합니다. 모델이 더 이상 도구를 요청하지 않거나 단계 상한에 걸릴 때까지 실행됩니다.

python
import json

def run_agent(user_text, tools, tool_impls, max_steps=6):
    messages = [{"role": "user", "content": user_text}]
    step = 0

    while step < max_steps:
        response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
        message = response.choices[0].message
        messages.append(message)

        calls = message.tool_calls
        if not calls:
            return message.content  # 최종 답변

        for call in calls:
            args = json.loads(call.function.arguments)
            try:
                result = tool_impls[call.function.name](args)
            except Exception as err:
                result = {"error": str(err)}  # 실패를 드러내되 멈추지 않는다
            messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

        step += 1

    return "Stopped: the agent reached its step limit."

이 형태는 특정 제공사의 SDK를 따른 것이라 필드 이름은 다를 수 있지만, 모든 루프에는 다음 요소가 있습니다. 모델의 메시지를 추가하고, 도구를 요청했는지에 따라 분기하고, 요청된 도구를 실행하고, 각 결과를 해당 호출 id에 연결된 tool 메시지로 다시 넣어주는 것입니다.

가장 단순한 버전에서 꼭 바꿔야 할 한 가지는 도구 실행을 감싸는 try/except입니다. 도구가 실패했을 때 그 예외가 루프를 죽이게 두지 마세요. 예외를 잡아서 오류를 모델에게 도구 결과로 돌려주세요. 그러면 모델은 다음 턴에 "그 조회가 실패했다"는 사실을 읽고 적응할 수 있습니다. 다른 인자로 재시도하거나, 다른 도구를 써보거나, 작업을 완료할 수 없다고 사용자에게 알릴 수 있죠. 충돌은 모델에게 회복할 기회를 전혀 주지 않습니다. 이 오류를 결과로 취급하기 패턴은 모델이 루프 안에서 행동할 수 있는 정보를 가지고 계속 진행하도록 해줍니다.

이 루프가 실행되는 동안 추적해야 할 것이 두 가지 있습니다. 첫째, 대화는 계속 늘어나기만 합니다. 매 턴마다 모델의 메시지와 모든 도구 결과가 추가되므로, 다섯 번째 단계에 이르면 모델은 첫 번째부터 네 번째 단계까지의 모든 내용을 다시 읽는 셈입니다. 그것이 에이전트의 기억이자 동시에 비용이기도 합니다. 각 단계마다 누적된 컨텍스트 전체를 다시 보내므로, 단계당 토큰 비용은 작업이 진행될수록 올라갑니다. 열 단계짜리 에이전트는 저렴한 호출 열 번이 아니라, 계속 커지는 대화 위에서 이루어지는 열 번의 호출입니다.

둘째, 그 증가가 바로 max_steps가 무한 루프를 막는 것 이상의 의미를 갖는 이유입니다. 작업에 맞게 값을 정하세요. 날씨 질문에는 두세 단계면 충분하지만, 조사 작업이라면 열 단계 정도가 필요할 수도 있습니다. 정상적으로 실행됐을 때 필요한 단계 수보다 약간 여유 있게 상한을 설정해서, 정당하게 오래 걸리는 작업은 끝낼 수 있게 하면서도 갇혀버린 작업은 예산을 다 쓰기 전에 끊어내도록 하세요.

Juno최소한의 에이전트 루프는 이렇게 동작합니다. 모델의 메시지를 추가하고, 도구를 요청했는지에 따라 분기하고, 도구를 실행하고, 각 결과를 tool 메시지로 다시 넣습니다. 도구 호출을 try/except로 감싸고 오류를 충돌시키는 대신 다시 넣어줘서 모델이 재시도하거나 회복할 수 있게 하세요. 대화는 계속 늘어나기만 한다는 점을 주의하세요. 매 단계마다 대화 전체를 다시 보내므로, 작업이 진행될수록 단계당 토큰 비용이 올라갑니다. max_steps는 작업에 맞게, 정상적인 실행보다 약간 여유 있게 설정하세요.

도구 사용에서 만든 도구 계층을 재사용한 루프입니다. 특정 제공사의 SDK 형태라 필드 이름은 다를 수 있지만, 구조는 그대로입니다.

python
import json

def run_agent(user_text, tools, tool_impls, max_steps=6):
    messages = [{"role": "user", "content": user_text}]
    step = 0

    while step < max_steps:
        response = client.chat.completions.create(model=MODEL, messages=messages, tools=tools)
        message = response.choices[0].message
        messages.append(message)

        calls = message.tool_calls
        if not calls:
            return message.content

        for call in calls:
            args = json.loads(call.function.arguments)
            try:
                result = tool_impls[call.function.name](args)
            except Exception as err:
                result = {"error": str(err)}  # 모델이 이것을 읽고 다음 턴에 적응할 수 있다
            messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})

        step += 1

    return "Stopped: the agent reached its step limit."

이 최소한의 루프는 동작은 맞지만 그대로 배포할 수는 없으며, 이 두 가지 사이의 간극이 바로 이 절에서 다룰 내용입니다. 이 코드에는 실무에서 문제를 일으키는 세 가지 속성이 있습니다.

오류를 드러내되 절대 충돌시키지 마세요. try/except는 실패한 도구를 모델이 읽을 수 있는 결과로 다시 돌려줍니다. 옳은 방식이지만, 여러분이 무엇을 하고 있는지 정확히 이해해야 합니다. 모델에게 오류 문자열을 넘겨주고, 그것을 합리적으로 회복하리라 믿고 맡기는 것입니다. 모델은 같은 잘못된 호출을 재시도할 수도 있고, 전혀 다른 접근을 생각해낼 수도 있고, 사과하고 멈출 수도 있습니다.

오류를 다시 넣어주는 것은 에이전트를 살아있게 하지만, 회복이 신뢰할 만하게 이루어지는 것을 보장하지는 않습니다. 중요한 일에는 도구별 재시도 횟수를 제한하고, 반복된 실패는 모델이 감으로 해결하게 두는 루프가 아니라 여러분이 직접 처리하는 종료 상태로 취급하세요.

대화 기록은 계속 늘어나기만 하고, 여기에는 두 가지 누적 비용이 따릅니다. 매 호출이 누적된 이력 전체를 다시 보내므로 단계당 토큰 비용이 올라가고, 그래서 오래 실행되는 작업은 매 턴마다 점점 커지는 비용을 치르지, 고정된 비용을 치르지 않습니다. 그리고 대화가 길어질수록 모델은 다음 행동을 결정하기 위해 예전 도구 출력을 더 많이 읽어야 하는데, 이것이 바로 컨텍스트 윈도우 장에서 다룬 중간에서 길을 잃는 효과가 작용하기 시작하는 지점입니다. 긴 컨텍스트 중간에 묻힌 정보에 모델이 덜 주의를 기울이는 현상이죠. 에이전트를 제약해야 할 초기 지침이 누적된 결과들 아래 가라앉아 점점 덜 주목받게 됩니다. 긴 에이전트는 요란하게 실패하지 않습니다. 조용히 표류합니다.

max_steps는 예산이지, 버그 방지책이 아닙니다. 이것이 필요한 이유는 언제 멈출지를 모델이 결정하고, 그 결정이 틀릴 수 있기 때문입니다. 한 단계에서 맴돌거나, 두 도구 사이를 왔다 갔다 하거나, 막다른 길로 빠질 수 있습니다.

이 숫자는 실질적인 비용 상한선입니다. 최악의 경우 지출은 대략 max_steps에 실행 후반부의 전체 컨텍스트 호출 비용을 곱한 값이므로, 컨텍스트가 무거운 작업에 여유로운 상한을 두면 청구서도 여유로워집니다. 정당한 최장 실행 시간에 여유분을 더해 상한을 정하고, 실행이 습관적으로 그 상한에 도달한다면 경보를 울리세요(그것은 상한이 너무 낮다는 신호가 아니라 에이전트가 갇혀 있다는 신호입니다). 그리고 끝나지 않는 에이전트를 덮어 가리려고 그 값을 습관적으로 올리려는 유혹은 참으세요.

Juno최소한의 에이전트 최소한의 루프는 동작은 맞지만 그대로 배포할 수 없습니다. 도구 오류를 다시 넣어줘서 모델이 적응할 수 있게 하되, 재시도 횟수를 제한하고 반복된 실패는 종료 상태로 취급하세요. 오류를 드러내는 것만으로는 회복이 신뢰할 만해지지 않기 때문입니다. 대화 기록은 계속 늘어나기만 하므로 단계당 토큰 비용이 올라가고, 초기 지침이 예전 결과들 아래 묻혀 에이전트가 표류하게 됩니다. max_steps는 버그 방지책이 아니라 비용 상한선입니다. 정당한 최장 실행에 여유분을 더해 값을 정하고, 실행이 계속 그 상한에 걸린다면 에이전트가 갇혀 있는 것이니 무작정 숫자를 올리지 마세요.

멈추기와 안전장치

모델이 언제 멈출지 결정하게 두는 루프는 멈추지 못할 수도 있습니다. 도구를 계속 호출하거나, 같은 일을 반복하거나, 막다른 길로 빠질 수 있습니다. 매 단계가 토큰으로 비용이 매겨지는 모델 호출이기 때문에, 폭주하는 에이전트는 돈과 시간을 빠르게 태워버립니다.

그래서 루프 주변에 두는 한계선, 즉 **안전장치**는 나중에 덧붙이는 다듬기 작업이 아니라 설계의 일부입니다.

  • 단계 제한. max_steps처럼 루프 횟수를 제한합니다. 제한에 도달하면 멈추고 반환합니다.
  • 도구 입력값 검증. 모델이 인자를 선택하므로, 실제 영향을 미치는 작업을 실행하기 전에 검증하세요.
  • 도구가 할 수 있는 일을 제한. 에이전트에게 필요한 최소한의 권한만 주세요. 읽기만 하는 도구가 삭제하는 도구보다 안전합니다.
  • 비용을 지켜보기. 작업당 여러 번의 모델 호출은 빠르게 늘어나므로, 토큰을 추적하고 예산을 정하세요.

이 모든 것을 하나로 묶는 흐름은 이렇습니다. 모델이 즉흥적으로 경로를 찾아가므로, 모델이 하지 않을 제한을 여러분이 대신 걸어두는 것입니다. 에이전트가 적어도 한 번은 잘못된 행동을 할 것이라고 가정하고, 그 상황을 견딜 수 있게 만드세요.

Juno멈추기와 안전장치 에이전트는 스스로 멈출 시점을 결정하므로 영원히 루프를 돌 수 있고, 모든 루프는 토큰 비용을 발생시킵니다. 항상 단계 제한을 두고, 모델이 고른 인자를 검증하고, 도구에는 필요한 최소한의 권한만 주세요. 작업당 여러 번의 모델 호출이 빠르게 쌓이므로 비용을 추적하세요.

모델이 언제 멈출지 결정하는 루프는 멈추지 못할 수 있고, 매 단계가 토큰으로 비용이 매겨지는 모델 호출이기 때문에 갇혀버린 에이전트는 예산을 빠르게 태워버립니다. 안전장치는 설계의 일부입니다. 반드시 넣어야 할 네 가지는 다음과 같습니다.

  • 단계 제한. max_steps로 루프를 제한하고, 제한에 도달하면 깔끔하게 반환합니다. 폭주하는 루프를 막는 확실한 정지선입니다.
  • 도구 입력값 검증. 모델이 인자를 선택하며, 잘못 고를 수도 있고 유도당해 잘못 고르게 될 수도 있습니다. 실제 영향을 미치는 도구가 실행되기 전에 검증하세요. id 없는 삭제, 음수 금액의 이체, 허용된 디렉토리를 벗어난 경로 같은 것들 말이죠.
  • 최소 권한. 각 도구에는 작업에 필요한 가장 좁은 범위의 권한만 주세요. 읽기 전용이 읽기-쓰기보다 낫고, 범위가 정해진 조회가 원시 데이터베이스 접근보다 낫고, 고정된 수신자 목록이 임의 발송보다 낫습니다.
  • 비용 상한선. 호출당이 아니라 전체 실행에 걸쳐 토큰을 추적하고, 실행이 예산을 넘으면 멈추세요.

이것들을 하나로 묶는 핵심은, 도구에 전달되는 인자가 모델에서 나온다는 것, 즉 결국 모델이 읽은 어떤 텍스트에서 간접적으로 나온다는 사실입니다. 어떤 도구가 웹페이지를 가져왔고 그 페이지에 지침이 담겨 있었다면, 그 지침은 이제 모델의 컨텍스트 안에 들어가 다음 도구 호출을 좌우할 수 있습니다. 이것이 **프롬프트 인젝션**입니다. 컨텍스트 안의 신뢰할 수 없는 텍스트가 에이전트의 행동에 영향을 미치는 현상입니다. 방어책은 프롬프트에 있지 않습니다. 모든 도구 입력값을 검증하고 모든 도구의 권한 범위를 좁혀서, 완전히 탈취된 인자라 해도 실제 피해를 줄 수 없도록 만드는 데 있습니다.

Juno멈추기와 안전장치 네 가지 안전장치는 다듬기가 아니라 설계입니다. max_steps 정지선, 영향력 있는 모든 도구의 입력값 검증, 최소 권한 범위 설정, 전체 실행에 걸친 토큰 예산입니다. 입력값 검증이 중요한 이유는 도구 인자가 모델에서 나온다는 것, 즉 모델이 읽은 텍스트에서 나온다는 것입니다. 그래서 지침이 담긴 페이지를 가져오는 것이 프롬프트 인젝션이 되어 다음 호출을 좌우할 수 있습니다. 입력값을 검증하고 권한을 좁혀서, 탈취된 인자라도 피해를 줄 수 없게 하세요.

모델이 언제 멈출지 결정하는 루프는 멈추지 못할 수 있고, 매 단계가 토큰으로 비용이 매겨지는 호출이므로 이 실패 유형은 기본적으로 비쌉니다. 네 가지 안전장치(단계 제한, 입력값 검증, 최소 권한, 비용 상한선)는 기본 조건입니다. 주목할 부분은 입력값 검증이 왜 상식적인 점검이 아니라 보안 통제인가 하는 점입니다.

도구 인자는 모델의 출력이고, 모델의 출력은 컨텍스트에 있는 모든 것을 조건으로 삼습니다. 여기에는 에이전트가 외부에서 가져온 텍스트도 포함됩니다. 에이전트가 웹페이지, 문서, 이메일, 또는 여러분이 통제하지 않는 도구 결과를 읽는 순간, 신뢰할 수 없는 텍스트가 컨텍스트에 들어와 지침을 실어 나를 수 있습니다. 이것이 **프롬프트 인젝션**입니다. 입력 안의 악의적인 콘텐츠가 모델을 여러분이 의도하지 않은 행동으로 몰아가는 것입니다. 단발성 프롬프트에서는 이것이 그저 골칫거리 수준이지만, 에이전트에서는 살아있는 공격 수단입니다. 모델의 다음 행동이 실제 세계에 영향을 미치는 도구 호출이고, 주입된 텍스트가 그 호출에 영향을 미칠 수 있기 때문입니다.

적대적인 상황이라고 가정하고 설계하세요. 실제로 그렇기 때문입니다. 지켜야 할 세 가지 원칙이 있습니다.

  • 모든 도구 인자를 차단 목록이 아니라 허용 목록에 대해 검증하세요. 유효한 인자가 어떤 모습인지(이 id 형식, 이 수신자 집합, 이 디렉토리) 정의하고 나머지는 모두 거부하세요. 공격자가 만들어낼 수 있는 모든 잘못된 입력을 나열할 수는 없지만, 유효한 입력들은 나열할 수 있습니다.
  • 완전히 탈취된 호출도 견딜 수 있도록 도구의 권한을 제한하세요. 인자가 공격자에게 통제당했다고 가정하고, 그렇다면 그 도구가 무엇을 할 수 있는지 물어보세요. 실제 답이 "누구에게나 이메일을 보낼 수 있다"거나 "어떤 행이든 삭제할 수 있다"라면, 프롬프트를 얼마나 신중하게 작성했든 그 도구는 너무 넓은 권한을 갖고 있는 것입니다. 최소 권한은 모델이 탈취되었을 때도 유효한 통제 수단이며, 프롬프트는 애초에 통제 수단이 전혀 아닙니다.
  • 되돌릴 수 없는 행동에는 사람을 개입시키세요. 돈을 보내거나, 데이터를 삭제하거나, 고객에게 이메일을 보내는 일은 확인 절차를 거치게 하세요. 에이전트가 제안하고, 사람이나 더 엄격한 검사가 최종 결정을 내리는 것입니다.

이 모든 것이 프롬프트 안에 있을 수 없는 더 깊은 이유는, 시스템 프롬프트의 지침과 가져온 콘텐츠 안에 주입된 지침이 모델에게는 똑같은 종류의 것, 즉 컨텍스트 안의 텍스트로 도착하기 때문입니다. 모델은 어느 하나를 다른 것보다 우선시할 신뢰할 만한 방법이 없습니다. 그래서 프롬프트만으로 안전을 확보할 수는 없습니다. 유효한 경계는 모델 바깥에 있습니다. 검증된 입력값과 권한이 제한된 도구입니다. 이것은 안전과 한계에서 더 폭넓게 다루는 내용과 이어집니다.

Juno멈추기와 안전장치 네 가지 안전장치는 기본 조건이고, 그중 깊이 생각해볼 가치가 있는 것은 입력값 검증입니다. 이것이 보안 통제이기 때문입니다. 도구 인자는 모델이 읽은 모든 것을 조건으로 한 모델의 출력이므로, 가져온 페이지나 이메일이 주입된 지침을 실어 나를 수 있고 다음 호출을 좌우할 수 있습니다. 에이전트에서는 그 호출이 실제 세계에 영향을 미칩니다. 허용 목록에 대해 검증하고, 탈취된 인자도 견딜 수 있도록 모든 도구의 권한을 제한하고, 되돌릴 수 없는 행동은 사람이 승인하게 하세요. 프롬프트만으로는 안전을 확보할 수 없습니다. 주입된 지침과 시스템 지침은 모델에게 똑같은 종류의 텍스트이므로, 경계는 모델 바깥에 있어야 합니다.

워크플로우가 에이전트보다 나은 경우

에이전트는 흥미로운 도구라서, 필요하지 않을 때도 손이 가기 쉽습니다. 간단한 규칙은 이것입니다. 이미 단계를 알고 있다면 에이전트를 쓰지 마세요. 각 단계가 고정된 순서로 실행되는 평범한 코드, 즉 **워크플로우**로 작성하세요.

워크플로우는 더 안정적이고, 더 저렴하고, 디버깅하기도 훨씬 쉽습니다. 모델이 경로를 찾아내기를 바라는 대신 여러분이 그 경로를 직접 통제하기 때문입니다. 에이전트는 그 단계들이 진행 과정에서 알게 되는 것에 따라 달라지는, 열린 형태의 작업을 위해 아껴두세요.

에이전트가 필요할 것처럼 보이는 많은 기능이 실제로는 구조화된 출력 호출이나 손수 연결한 RAG 조회로 더 잘 처리됩니다. 단계를 미리 적어둘 수 없을 때만 에이전트를 쓰세요.

Juno워크플로우가 에이전트보다 나은 경우 이미 단계를 알고 있다면 에이전트를 쓰는 대신 워크플로우로 고정해서 작성하세요. 더 저렴하고, 더 안정적이고, 디버깅하기 쉽습니다. 에이전트는 단계가 진행 과정에서 알게 되는 것에 따라 달라지는 열린 작업을 위해 아껴두세요. "에이전트" 기능이라 불리는 많은 것이 사실은 손수 연결한 구조화된 출력이나 RAG 호출입니다.

에이전트는 흥미로운 도구이고, 바로 그래서 필요하지 않을 때도 손을 뻗게 됩니다. 판단 기준은 질문 하나로 정리됩니다. 단계를 미리 적어둘 수 있는가? 그렇다면 여러분이 원하는 것은 **워크플로우**입니다. 고정된 순서로 이루어진 평범한 코드로서의 단계이지, 에이전트가 아닙니다.

워크플로우는 에이전트가 어려워하는 모든 면에서 이깁니다. 더 저렴합니다. 루프 대신 한두 번의 모델 호출만 하면 되니까요. 더 안정적입니다. 매 턴마다 다시 결정되는 대신 경로가 고정되어 있으니까요. 그리고 디버깅하기도 훨씬 쉽습니다. 모델이 선택한 행동의 연쇄 속 어딘가가 아니라, 검사할 수 있는 정해진 단계에서 실패가 발생하니까요.

실제로 적용해볼 만한 판별법이 있습니다. "이 상담 티켓을 요약하고, 긴급도를 분류하고, 적절한 대기열로 보내라"는 요청은 알려진 순서로 이루어진 알려진 단계를 가지므로 워크플로우입니다. 요약, 분류, 긴급도를 얻는 구조화된 출력 호출 한 번에 결과에 따라 라우팅하는 평범한 코드를 더한 것이죠. "이 고객의 최근 세 건의 주문이 왜 실패했는지 조사해줘"는 알려진 단계가 없고, 경로가 각 조회에서 무엇을 알게 되느냐에 따라 달라지므로 에이전트입니다.

여기서 함정은, 많은 기능이 열려 있는 것처럼 보이지만 실제로는 그렇지 않다는 점입니다. 문서에서 필드를 추출하는 일, 여러분의 문서 자료를 바탕으로 답하는 일, 들어오는 메일을 분류하는 일 같은 것들은 에이전트가 필요한 일처럼 보이지만, 실제로는 구조화된 출력 호출이나 코드로 감싼 RAG 조회로 귀결됩니다. 기본값은 워크플로우로 두고, 도저히 스크립트로 짤 수 없을 정도로 예측 불가능한 작업만 에이전트를 쓸 자격을 얻게 하세요.

Juno워크플로우가 에이전트보다 나은 경우 판단은 질문 하나로 정리됩니다. 단계를 미리 적어둘 수 있는가? 그렇다면 워크플로우, 즉 평범한 코드로서의 단계를 만드세요. 더 저렴하고, 더 안정적이고, 정해진 지점에서 디버깅할 수 있으니까요. "티켓을 요약하고 분류하고 라우팅하기"는 워크플로우입니다. 구조화된 출력 호출 한 번에 라우팅 코드를 더한 것이죠. "이 주문들이 왜 실패했는지 조사하기"는 에이전트입니다. 경로가 각 단계에서 알게 되는 것에 따라 달라지니까요. 기본값은 워크플로우로 두고, 작업이 에이전트를 쓸 자격을 스스로 증명하게 하세요.

에이전트는 사람들이 쓰고 싶어 하는 도구이고, 바로 그래서 어울리지 않는 곳에서도 쓰입니다. 판단 기준은 짧습니다. 단계를 미리 적어둘 수 있다면, 적어두세요. 그것이 **워크플로우**입니다. 고정된 순서로 이루어진 코드로서의 단계이며, 형태를 알고 있는 작업이라면 비용, 안정성, 디버깅 가능성 모든 면에서 에이전트를 앞섭니다.

그 이유는 곧바로 아키텍처로 거슬러 올라갑니다. 에이전트는 전역 계획이 없고 오류를 앞으로 계속 누적시키므로, 그 신뢰성은 전체를 점검하는 장치 없이 모든 단계가 제대로 진행되어야 하는 확률의 곱입니다. 여러분이 작성한 워크플로우는 정의상 계획을 갖고 있으며, 각 단계를 독립적으로 검사하고, 테스트하고, 검증할 수 있습니다. 워크플로우에서 무언가가 깨지면 특정 줄에서 깨집니다. 에이전트에서 무언가가 깨지면 모델이 선택한 행동들의 기록 어딘가에서 깨지고, 여러분은 그 기록으로부터 모델이 무엇을 생각했는지 재구성해야 합니다.

이제 더 어려운 경우를 봅시다. "단계를 알고 있는가"는 쉬운 절반일 뿐이니까요. 작업이 열려 있어도 에이전트가 잘못된 선택일 수 있습니다. 잘못된 단계의 대가가 너무 커서, 다음 한 걸음을 추측하는 존재에게 그것을 맡길 수 없을 때 말이죠. 여행을 예약하거나, 돈을 옮기거나, 프로덕션 데이터를 수정하는 에이전트는 열려 있는 작업이면서도, 계획 없이 인젝션에 취약하고 오류가 누적되는 루프가 조종대를 잡고 있게 두고 싶지 않은 곳이기도 합니다. 그럴 때 올바른 설계는 대개 혼합형입니다. 모델이 계획하거나 제안하고, 고정된 워크플로우와 사람의 승인 절차가 실행을 담당해서, 예측 불가능한 부분이 되돌릴 수 없는 부분에 직접 닿지 않도록 하는 것입니다.

그래서 질문은 두 가지입니다. 단계를 알고 있는가, 그리고 잘못된 단계의 대가는 무엇인가.

알려진 단계는 워크플로우로 갑니다. 대가가 싸고 되돌릴 수 있는 알려지지 않은 단계는 에이전트가 제 값을 하는 지점입니다. 대가가 비싸고 되돌릴 수 없는 알려지지 않은 단계는 자유롭게 돌아가는 루프가 아니라 제약된 설계를 원합니다.

에이전트는 대가가 싸고 되돌릴 수 있는 열린 작업에 어울리며, 그 외에는 별로 그렇지 않습니다. "에이전트"라 불리는 기능 중 놀랄 만큼 많은 부분이 실제로는 코드로 감싼 구조화된 출력 호출이나 RAG 조회이고, 그대로 두는 편이 더 낫습니다.

Juno워크플로우가 에이전트보다 나은 경우 질문은 하나가 아니라 두 개입니다. 단계를 알고 있는가, 그리고 잘못된 단계의 대가는 무엇인가. 알려진 단계는 워크플로우로 가야 합니다. 워크플로우는 계획을 갖고 있고 에이전트는 결코 갖지 못하기 때문에 비용, 안정성, 디버깅 면에서 에이전트를 앞섭니다. 대가가 싸고 되돌릴 수 있는 알려지지 않은 단계는 에이전트가 제 자리를 찾는 곳입니다. 대가가 비싸고 되돌릴 수 없는 알려지지 않은 단계는 혼합형을 원합니다. 모델이 제안하고, 고정된 워크플로우와 사람의 승인 절차가 실행해서, 오류가 누적되는 루프가 되돌릴 수 없는 부분에는 절대 닿지 않게 하는 것입니다.

실전에서

위에서 본 run_agent 함수가 전부입니다. 질문 하나, 도구 모음, 그리고 그 구현을 넘겨주면, 끝날 때까지 또는 단계 제한에 걸릴 때까지 반복됩니다. 작은 작업으로 이 루프가 제 값을 하는 모습을 볼 수 있습니다.

python
answer = run_agent(
    "What's the weather in Oslo and should I bring an umbrella?",
    tools,        # 도구 사용 장에 나온 get_weather 포함
    tool_impls,   # {"get_weather": ...}
)
# 에이전트는 get_weather를 호출하고, "cloudy"를 읽은 다음, 마지막 한 턴에 답변한다:
# "오슬로는 흐리고 18도이니, 우산을 챙기는 게 좋겠습니다."

도구 하나, 루프 하나, 답변 하나입니다. 같은 구조가 형태를 바꾸지 않고 여러 도구와 여러 단계로 확장됩니다. 이만큼 능력 있는 것을 만들 수 있게 되면, 다음 질문은 그것이 실제로 잘 작동하는지를 어떻게 아느냐입니다. 이것이 바로 평가에서 다룰 내용입니다.

Juno실전에서run_agent 루프가 전체 패턴입니다. 질문, 도구, 그리고 그 구현을 넘겨주면, 모델이 더 이상 도구를 요청하지 않거나 단계 제한에 걸릴 때까지 반복됩니다. 도구 하나와 루프 하나는 형태를 바꾸지 않고 여러 개로 확장됩니다. 다음 질문은 그것이 실제로 잘 작동하는지 어떻게 아느냐이고, 이것이 평가입니다.

run_agent 함수가 전체 패턴입니다. 질문, 도구, 그 구현을 넘겨주면, 모델이 요청을 멈추거나 상한에 걸릴 때까지 반복됩니다.

python
answer = run_agent(
    "What's the weather in Oslo and should I bring an umbrella?",
    tools,        # 도구 사용 장에 나온 get_weather 포함
    tool_impls,   # {"get_weather": ...}
)
# 에이전트는 get_weather를 호출하고, "cloudy"를 읽은 다음, 마지막 한 턴에 답변한다.

도구 하나, 루프 하나, 답변 하나이고, 같은 형태가 여러 도구와 여러 단계로 확장됩니다. 공짜로 확장되지 않는 것은 그것이 실제로 작동한다는 확신입니다. 워크플로우는 단계별로 테스트할 수 있지만, 에이전트는 입력마다 다른 경로를 밟으므로, 어제 잘 작동한 실행이 오늘 실패하는 실행에 대해 알려주는 것은 거의 없습니다.

그래서 에이전트를 만들 때 함께 만들어야 할 다음 것이 바로 측정 방법입니다. 하나의 훌륭한 데모를 믿는 대신, 대표적인 작업들을 모아 실행해보고 결과를 확인하는 것이죠. "내가 시도했을 때는 잘 됐다"를 추적 가능한 숫자로 바꾸는 일, 이것이 바로 평가에서 다루는 내용입니다.

Juno실전에서run_agent 루프가 전체 패턴이고, 도구 하나와 루프 하나는 형태를 바꾸지 않고 여러 개로 확장됩니다. 함정은 확신입니다. 에이전트는 입력마다 다른 경로를 밟으므로, 훌륭한 데모도 다음 실행에 대해 별로 알려주지 못합니다. 하나의 성공을 믿는 대신 대표적인 작업들에 걸쳐 측정 방법을 만드세요. 이것이 평가가 하는 일입니다.

run_agent 함수가 전체 패턴이고, 아래 예제는 최소한의 형태입니다. 도구 하나, 루프 하나, 답변 하나입니다.

python
answer = run_agent(
    "What's the weather in Oslo and should I bring an umbrella?",
    tools,        # 도구 사용 장에 나온 get_weather 포함
    tool_impls,   # {"get_weather": ...}
)
# get_weather가 "cloudy"를 반환하고, 모델은 마지막 한 턴에 답한다.

이 형태는 형태를 바꾸지 않고 여러 도구와 여러 단계로 확장되며, 이것이 바로 함정입니다. 실패 유형도 함께 확장되는데 코드는 그대로이기 때문입니다. 에이전트는 경로 의존적입니다. 서로 다른 입력이 모델이 선택한 서로 다른 단계의 연쇄를 유발하므로, 입력 하나에 대한 테스트는 아직 보지 못한 입력에 대해 거의 아무것도 알려주지 않습니다. 샘플링에서 오는 비결정성 때문에 같은 입력조차 두 번 다른 경로를 밟을 수 있습니다.

그래서 에이전트와 함께 다음으로 만들어야 할 것은 측정이고, 이는 워크플로우에 필요한 것과는 다른 종류의 측정입니다. 하나의 기대 출력을 단언하는 게 아니라, 대표적인 작업 여러 개를 샘플링하고 결과의 분포를 읽는 것입니다. 예산 안에서 끝나는 빈도, 올바른 답에 도달하는 빈도, 어디서 맴도는지, 최악의 경우 비용이 얼마나 드는지 말이죠. 이것이 바로 에이전트에 맞는 형태의 평가이며, 위 절들에서 다룬 신뢰성, 비용, 안전성에 대한 우려를 운 좋게 성공한 데모가 아니라 근거로 삼을 수 있는 숫자로 바꾸는 유일한 방법입니다.

Juno실전에서run_agent가 전체 패턴이고, 형태 면에서는 확장되지만 실패 유형도 함께 확장됩니다. 에이전트는 경로 의존적이고 샘플링이 비결정성을 더하므로, 입력 하나에 대한 테스트는 알려주는 게 거의 없고 훌륭한 데모도 그보다 더 적게 알려줍니다. 에이전트에 맞는 방식으로 측정하세요. 대표적인 작업 여러 개를 샘플링하고 분포를 읽으세요. 예산 안에서 끝나는 빈도, 어디서 맴도는지, 최악의 경우 비용이 얼마인지 말이죠. 이것이 평가가 하는 일이고, 위에서 다룬 신뢰성과 비용에 대한 우려를 근거로 삼을 수 있는 숫자로 바꾸는 방법입니다.