본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 18. 01:06

왜 당신의 AI 에이전트 코드는 스파게티가 되는가 — 그리고 어떻게 이를 풀어낼 것인가

요약

AI 에이전트 개발 시 발생하는 흔한 문제점은 LLM 호출을 일반적인 결정론적 함수처럼 취급하여 명령형 제어 흐름과 결합할 때 발생합니다. 이로 인해 무한 루프, 예측 불가능한 상태 변화, 과도한 API 호출 등의 혼돈에 빠지기 쉽습니다. 근본 원인은 '명령형 코드 + 확률적 호출 = 혼돈'의 조합에서 비롯됩니다.

핵심 포인트

  • LLM 호출은 결정론적이지 않고 확률적이므로 일반 함수처럼 다루어서는 안 됩니다.
  • AI 에이전트 로직을 구성할 때 명령형 제어 흐름, 확률적 결정, 부수 효과를 한데 섞으면 예측 불가능한 코드가 됩니다.
  • 에이전트의 안정성을 높이기 위해 모델의 역할을 '플래너(Planner)'와 '실행기(Executor)' 두 개의 별개 작업으로 분리해야 합니다.

나의 에이전트 작성 방식을 바꾼 새벽 3시의 호출(Pager)

몇 달 전, 나는 고객을 위해 깔끔하다고 생각한 에이전트를 출시했습니다. 이 에이전트는 웹 페이지를 스크레이핑(Scraping)하고, 이를 요약한 다음, 콘텐츠에 따라 결과를 서로 다른 다운스트림 도구(Downstream tools)로 라우팅(Routing)했습니다. 개발 환경에서는 아주 잘 작동했습니다. 첫 일주일 동안은 아주 잘 작동했습니다. 그러다 새벽 3시에 호출(Paged)을 받았습니다.

에이전트가 루프(Loop)에 빠진 것이었습니다. 도구 중 하나가 타임아웃(Timeout)되어 부분적인 응답을 반환했고, LLM은 작업이 완료되지 않았다고 "결정"하여 동일한 도구를 다시 호출했고, 또 다른 부분적인 응답을 받았으며, 이런 식으로 반복되었습니다. 내가 이를 발견했을 때쯤에는 하룻밤 사이에 약 4,000건의 API 호출을 소모한 상태였습니다.

해결 과정은 즐겁지 않았습니다. 에이전트 로직이 if 문, 재시도 데코레이터(Retry decorators), 프롬프트 템플릿(Prompt templates), 그리고 LLM이 "DONE"이라고 말할 때 종료되도록 설계된 while 루프 전반에 흩어져 있었기 때문입니다. 스포일러를 하자면, 모델이 가끔 "DONE"이라고 말하지 않을 때가 있었습니다.

근본 원인: 명령형 코드(Imperative code) + 확률적 호출(Stochastic calls) = 혼돈

내가 계속해서 목격하고(그리고 계속 저지르고 있는) 실수는 LLM 호출을 다른 일반적인 함수처럼 취급하는 것입니다. 그렇지 않습니다. 일반적인 함수는 주어진 입력에 대해 결정론적(Deterministic)인 출력을 반환합니다. 하지만 LLM 호출은 확률적인(Probable) 출력을 반환하며, 그 출력이 제어 흐름(Control flow)을 주도합니다.

다음 요소들을 혼합하면서 그 사이에 어떠한 구조적 경계도 두지 않는다면:

  • 명령형 제어 흐름 (if/else, while, recursion)
  • 확률적 결정 (모델이 다음 단계를 "결정")
  • 부수 효과 (Side effects: 도구 호출, DB 쓰기, API 요청)

결국 종료(Termination), 재시도(Retries), 또는 부분적 상태(Partial state)에 대해 추론할 수 없는 코드가 만들어집니다. 내가 말하는 예시는 다음과 같습니다:

def run_agent ( task ):
    history = [{ " role " : " user " , " content " : task }]
    while True :
        # 실수하기 쉬운 부분 (the footgun)
        response = call_llm ( history )
        history . append ( response )
        if " DONE " in response [ " content " ]: 
            return response
        if response . get ( " tool_call " ):
            result = execute_tool ( response [ " tool_call " ])
            history . append ({ " role " : " tool " , " content " : result })
        # 만약 두 분기 중 어느 것도 실행되지 않으면, 무한 루프에 빠집니다.

모델이 루프의 변수(Loop variant)이자 본체(Body)가 되어버린 것입니다.

"내가 지금 어느 단계에 있는가?"와 "모델이 다음에 무엇을 원하는가?" 사이의 구분이 없습니다. 모델이 혼란에 빠지면, 당신의 프로그램도 혼란에 빠집니다.

단계 1: 플래너 (Planner)와 실행기 (Executor)를 분리하십시오.
실제로 도움이 되었던 첫 번째 리팩터링 (Refactor)은 모델의 역할을 두 개의 별개 작업으로 나누고, 이들이 동일한 루프 내에서 실행되지 않도록 하는 것이었습니다.

플래너 (Planner): 작업으로부터 정적인 계획을 생성합니다. 단 한 번의 LLM 호출.

plan = planner_llm(task)

{step, tool, args} 리스트를 반환합니다.

실행기 (Executor): 계획을 결정론적 (Deterministically)으로 따라갑니다.

for step in plan:
    result = run_step(step)
    if not result.ok:
        break  # 리뷰어에게 넘기십시오, 계속 추측하게 두지 마십시오

이제 루프는 유한한 리스트에 대한 일반적인 for 문이 되었습니다. 모델은 더 이상 런타임 (Runtime)에 제어 흐름 (Control flow)을 주도하지 않습니다. 모델은 사전에 계획을 한 번 구축했을 뿐입니다. 무언가 잘못되면, 검사하거나 수정하거나 다시 실행할 수 있는 구체적인 계획이 남습니다.

트레이드오프 (Tradeoff): 적응형 재계획 (Adaptive replanning) 능력을 잃게 됩니다. 모델이 실행 도중에 도구 (Tool)의 출력에 반응할 수 없습니다. 제가 구축한 에이전트 워크로드 (Agent workloads)의 약 70% 정도에서는 이 방식이 괜찮습니다. 나머지 30%를 위해서는 재계획 (Replanning)이 필요하며, 이는 단계 2로 이어집니다.

단계 2: 상태 머신 (State machine)을 명시적으로 만드십시오.
재계획이 필요한 경우, 핵심 요령은 당신의 에이전트가 챗봇 (Chatbot)인 척하는 것을 멈추는 것입니다. 그것은 상태 머신 (State machine)입니다. 상태를 실제화하십시오.

STATES = ["planning", "executing", "reviewing", "done", "failed"]

def step(state, ctx):
    if state == "planning":
        ctx.plan = planner_llm(ctx.task)
        return "executing"
    if state == "executing":
        if ctx.cursor >= len(ctx.plan):
            return "reviewing"
        result = run_step(ctx.plan[ctx.cursor])
        ctx.cursor += 1
        if not result.

ok : return " reviewing " # 리뷰어가 무엇을 할지 결정하도록 함
    return " executing "

if state == " reviewing " :
    decision = reviewer_llm ( ctx ) # "done" | "replan" | "fail"
    return { " done " : " done " , " replan " : " planning " , " fail " : " failed " }[ decision ]

이제 당신은 다음을 할 수 있습니다:
- 상태(state)당 총 반복 횟수 제한 ( assert ctx.cursor < MAX_STEPS )
- 단계(step) 사이의 ctx를 유지하여 충돌 후에도 재개할 수 있도록 함
- 모든 전이(transition)를 로그로 남겨 새벽 3시의 디버깅(debugging)을 감당 가능하게 만듦
- 어떤 상태에서 어떤 LLM 호출이 발생할 수 있는지 제한 (리뷰 중에 갑작스러운 도구 호출(tool calls) 방지)

이것은 제가 2년 전에 누군가에게 보여주었으면 했던 패턴입니다. 이는 Erlang의 gen_statem이나 모든 워크플로우 엔진(workflow engine)과 같은 아이디어입니다: "내가 어떤 상태에 있는가"를 "모델이 여기서 무엇을 해야 하는가"로부터 분리하는 것입니다.

단계 3: 모델의 출력을 파싱(parse)하지 말고, 제약(constrain)하십시오
제 인생의 수많은 시간을 잡아먹었던 또 다른 종류의 버그: 모델이 거의 맞지만 약간 틀린 것을 반환하고, 파서(parser)가 조용히 실패하거나 도구 호출(tool call)을 환각(hallucinate)하는 경우입니다.
해결책은 구조화된 출력(structured output)입니다. 대부분의 제공업체는 이제 API 수준에서 JSON 스키마(JSON schema) 제약을 지원합니다.
이를 사용하십시오:

schema = {
    " type " : " object " ,
    " properties " : {
        " action " : {
            " enum " : [ " call_tool " , " finish " , " ask_user " ]
        },
        " tool " : { " type " : " string " },
        " args " : { " type " : " object " },
    },
    " required " : [ " action " ],
}

response = call_llm ( history , response_format = { " type " : " json_schema " , " json_schema " : schema }, )

# response.action은 세 가지 문자열 중 하나임이 보장됩니다.
# 더 이상 "DONE" / "Done" / "done." / "I am done." 와 같은 분기 처리가 필요 없습니다.

스키마 제약 출력을 사용할 수 없는 경우(일부 오래된 모델은 지원하지 않음), 최소한 결과를 가지고 무엇을 하기 전에 pydantic이나 zod로 검증(validate)하고, 검증 실패를 예외(exception)가 아닌 알려진 상태(known state)로 취급하십시오.

예방: 이제 배포 전에 제가 실행하는 체크리스트
충분히 데이고 난 후, 저는 이것을 모니터 옆에 붙여둡니다:
- 제한된 반복(Bounded iterations). LLM 호출을 포함하는 모든 루프에는 엄격한 상한선이 있습니다. `while True`는 사용하지 않습니다.
- 명시적 상태(Explicit states).

만약 제가 냅킨 위에 상태 다이어그램 (state diagram)을 그릴 수 없다면, 그 에이전트는 너무 복잡한 것입니다.

구조화된 출력 (Structured output). 제어 흐름 (control flow)을 구동하는 모든 모델 응답은 스키마 검증 (schema-validated)을 거칩니다.

멱등적 도구 (Idempotent tools). 도구 호출 (tool calls)은 재시도될 수 있음을 가정합니다. 부수 효과 (Side effects)는 요청 ID (request ID)별로 관리됩니다.

관측 가능성 우선 (Observability first). 모든 상태 전이 (state transition)는 LLM 호출의 입출력과 함께 로그로 기록됩니다. 재현 (replay)할 수 없다면, 디버깅 (debug)할 수 없습니다.

테스트된 실패 모드 (Tested failure modes). 모델이 쓰레기 값을 반환하거나, 타임아웃이 발생하거나, 존재하지 않는 도구에 대한 도구 호출을 반환하는 상황을 가정한 통합 테스트 (integration tests)를 보유하고 있습니다. 에이전트는 루프를 도는 것이 아니라 우아하게 실패 (fail gracefully)해야 합니다.

새벽 3시에 호출되는 페이저 (pager)는 더 이상 발생하지 않습니다. 에이전트들은 겉보기에는 훨씬 덜 인상적으로 보입니다. 이제 극적인 재귀 루프 (recursive loops) 대신 지루한 상태 머신 (state machines)이 되었기 때문입니다. 하지만 실제로 작동합니다. 흥미로운 작업은 플래너 (planner)와 리뷰어 (reviewer) 프롬프트로 이동했으며, 그것이 원래 있어야 할 곳이었습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
1

댓글

0