AI 에이전트 파이프라인 설계 — 실행 루프를 8단계로 쪼개기

🌐 English

LLM 에이전트를 처음 만들면 대개 이렇게 시작합니다. "메시지를 받아서, 프롬프트를 만들고, 모델을 부르고, 툴을 실행하고, 결과를 다시 넣고, 반복." 이 흐름을 함수 하나에 몰아넣으면 200줄쯤에서 잘 돌아갑니다. 그러다 요구가 붙습니다. 세션 요약을 넣어야 하고, 토큰이 넘치면 잘라내야 하고, 중간에 훅(hook)을 걸어야 하고, 특정 에이전트는 툴 실행을 건너뛰어야 합니다. 어느 순간 그 함수는 1,500줄이 되고 아무도 손대지 못하게 됩니다.

해법은 익숙합니다. 루프를 단계(stage)로 쪼개는 것입니다. 이 글은 에이전트 실행 루프를 8단계로 나눈 설계와, 그렇게 나눌 때 반드시 걸리는 문제들을 정리합니다.

8단계는 무엇인가

논리적으로 에이전트의 한 턴은 이렇게 흘러갑니다.

  1. context — 작업 공간, 설정 파일, 사용자 정보를 확정한다
  2. history — 이전 대화와 세션 요약을 불러온다
  3. prompt — 시스템 프롬프트를 조립한다
  4. think — LLM을 호출한다
  5. act — 모델이 요청한 툴을 실행한다
  6. observe — 툴 결과를 대화에 되먹인다
  7. memory — 이번 턴에서 남길 것을 저장한다
  8. summarize — 세션을 요약하고 마무리한다

여기서 첫 번째 함정이 있습니다. 8단계가 전부 매 반복마다 도는 게 아닙니다. context·history·prompt는 턴 시작에 한 번이면 충분하고, think·act·observe는 모델이 툴을 더 부르는 동안 계속 돌아야 하며, memory·summarize는 루프가 끝난 뒤 한 번입니다. 그래서 실제 구조는 세 구역으로 나뉩니다.

type Pipeline struct {

    setup     []Stage // 턴 시작에 1회 (context/history/prompt)

    iteration []Stage // 툴 루프마다 반복 (think/act/observe)

    finalize  []Stage // 턴 종료에 1회 (memory/summarize)

}

func NewDefaultPipeline(deps Deps) *Pipeline {

    return &Pipeline{

        setup:     []Stage{NewContextStage(&deps)},

        iteration: []Stage{

            NewThinkStage(&deps),

            NewPruneStage(&deps),    // 토큰 초과 시 잘라내기

            NewToolStage(&deps),     // act

            NewObserveStage(&deps),

            NewCheckpointStage(&deps),

        },

        finalize:  []Stage{NewFinalizeStage(&deps)},

    }

}

Stage 인터페이스는 최대한 작게

단계 인터페이스는 두 개 메서드면 충분합니다. 이름과 실행. 여기에 흐름 제어가 필요한 단계만 선택적으로 인터페이스를 하나 더 구현하게 합니다.

type StageResult int

const (

    Continue  StageResult = iota // 다음 단계로

    BreakLoop                    // 반복 루프 정상 종료

    AbortRun                     // 실행 전체 중단

)

type Stage interface {

    Name() string                                  // 로깅·트레이싱용

    Execute(ctx context.Context, st *RunState) error

}

// 흐름을 제어해야 하는 단계만 추가로 구현한다.

// 구현하지 않은 단계는 파이프라인이 Continue로 간주한다.

type StageWithResult interface {

    Stage

    Result() StageResult

}

Go의 선택적 인터페이스 패턴입니다. 모든 단계가 Result()를 구현하도록 강제하면 대부분의 단계가 return Continue 한 줄만 쓰는 보일러플레이트를 갖게 됩니다. 필요한 단계만 구현하고, 파이프라인은 타입 어서션으로 확인합니다.

for _, stage := range p.iteration {

    if err := stage.Execute(ctx, state); err != nil {

        return nil, fmt.Errorf("iter %d %s: %w", state.Iteration, stage.Name(), err)

    }

    // 중단 신호는 즉시 반영 — 남은 단계를 건너뛴다

    if swr, ok := stage.(StageWithResult); ok && swr.Result() == AbortRun {

        state.ExitCode = AbortRun

        break

    }

}

핵심 원칙: 단계는 무상태, 상태는 한 곳에

여기가 설계의 중심입니다. 단계 객체는 상태를 갖지 않고, 모든 가변 상태는 하나의 구조체에 모아 포인터로 넘깁니다.

// 한 번의 실행 동안 공유되는 가변 상태. 모든 단계에 포인터로 전달된다.

type RunState struct {

    // 실행 시작에 정해지고 끝까지 안 바뀌는 값

    Input    *RunInput

    Model    string

    Provider Provider

    // 여러 단계가 함께 읽고 쓰는 메시지 버퍼

    Messages *MessageBuffer

    // 단계별 하위 상태 — 이름으로 소유권이 드러난다

    Context ContextState

    Think   ThinkState

    Tool    ToolState

    Observe ObserveState

    Iteration int

    ExitCode  StageResult

}

왜 이렇게 하냐면, 단계가 자기 필드에 상태를 들고 있으면 같은 파이프라인 인스턴스를 두 번 실행할 수 없기 때문입니다. 동시에 여러 대화를 처리하는 순간 지난 실행의 값이 새 실행에 새어 들어갑니다. 단계를 무상태로 두면 파이프라인 하나를 만들어 두고 요청마다 RunState만 새로 만들면 됩니다.

하위 상태를 Think, Tool처럼 단계 이름으로 묶는 것도 의도적입니다. 필드를 평평하게 늘어놓으면 몇 달 뒤 "이 값을 누가 쓰지"를 추적할 수 없습니다. 이름이 소유권을 문서화합니다.

단계 사이의 결합은 콜백으로 끊는다

context 단계는 작업 공간을 확인하고, 파일을 읽고, 프롬프트를 조립하고, 히스토리를 불러옵니다. 이걸 전부 단계 안에 직접 구현하면 파이프라인 패키지가 저장소·파일시스템·프롬프트 빌더에 전부 의존하게 됩니다. 대신 의존성을 함수 필드로 주입합니다.

type Deps struct {

    ResolveWorkspace  func(ctx context.Context, in *RunInput) (*Workspace, error)

    LoadContextFiles  func(ctx context.Context, userID string) ([]ContextFile, bool)

    LoadHistory       func(ctx context.Context, key string) ([]Message, string)

    BuildMessages     func(ctx context.Context, in *RunInput, h []Message, sum string) ([]Message, error)

    CallLLM           func(ctx context.Context, st *RunState, req ChatRequest) (*ChatResponse, error)

    Config            Config

}

// 주입 안 된 기능은 그냥 건너뛴다 — 테스트에서 부분 조립이 가능해진다

if s.deps.LoadHistory != nil && state.Input.SessionKey != "" {

    history, summary := s.deps.LoadHistory(ctx, state.Input.SessionKey)

    state.Messages.SetHistory(history)

    state.Context.Summary = summary

}

nil 체크가 지저분해 보이지만 실전에서 값을 합니다. 테스트에서 필요한 콜백만 채운 파이프라인을 만들 수 있어서, think 단계를 검증하려고 저장소 목(mock)을 통째로 준비할 필요가 없습니다.

think 단계가 실제로 하는 일

가장 단순해 보이는 단계가 사실 제일 지저분합니다. LLM 호출은 실패 양상이 다양하기 때문입니다.

func (s *ThinkStage) Execute(ctx context.Context, st *RunState) error {

    s.result = Continue

    resp, err := s.deps.CallLLM(ctx, st, buildRequest(st))

    if err != nil {

        return fmt.Errorf("llm call: %w", err)

    }

    // 사용량 누적 — 캐시 토큰까지 반드시 함께 집계한다

    if resp.Usage != nil {

        st.Think.TotalUsage.PromptTokens += resp.Usage.PromptTokens

        st.Think.TotalUsage.CompletionTokens += resp.Usage.CompletionTokens

        st.Think.TotalUsage.CacheReadTokens += resp.Usage.CacheReadTokens

    }

    // 잘림 처리: 툴 인자가 잘렸을 때만 재시도한다.

    // 툴 없는 텍스트 잘림은 '긴 답변'이지 오류가 아니다.

    if resp.FinishReason == "length" && len(resp.ToolCalls) > 0 {

        st.Think.TruncRetries++

        if st.Think.TruncRetries >= maxTruncRetries {

            s.result = AbortRun

            return nil

        }

        st.Messages.AppendPending(hintMessage())

        return nil // 다음 반복에서 재시도

    }

    // 툴 호출이 없으면 최종 답변 — 루프를 끝낸다

    if len(resp.ToolCalls) == 0 {

        s.result = BreakLoop

        return nil

    }

    st.Messages.AppendPending(assistantMessage(resp))

    return nil

}

주목할 점은 "잘림"을 두 종류로 구분한다는 것입니다. 툴 호출 인자가 잘리면 JSON이 깨져 실행할 수 없으니 재시도해야 합니다. 반면 툴 없는 순수 텍스트가 잘린 건 그냥 답변이 길었던 것이고, 그대로 사용자에게 전달하는 게 맞습니다. 이 구분을 안 하면 긴 답변마다 헛되이 재시도합니다.

BreakLoop와 AbortRun을 다르게 다루기

흐름 제어 신호가 두 개인 이유가 있습니다.

  • BreakLoop(정상 종료)는 이번 반복의 남은 단계를 모두 마친 뒤 루프를 나갑니다. observe 단계가 최종 응답을 수집해야 하고, checkpoint 단계가 상태를 저장해야 하기 때문입니다. 여기서 성급히 나가면 사용자에게 갈 답변이 사라집니다.
  • AbortRun(비정상 중단)은 즉시 나갑니다. 이미 복구 불가능한 상태라 남은 단계를 돌리는 게 무의미하거나 위험합니다.

그리고 finalize 구역은 취소된 컨텍스트에서도 반드시 실행되어야 합니다. 사용자가 중간에 취소했더라도 지금까지의 대화는 저장돼야 하니까요.

// 취소 신호를 떼어낸 컨텍스트로 마무리 단계를 돌린다

finalizeCtx := context.WithoutCancel(ctx)

for _, stage := range p.finalize {

    if err := stage.Execute(finalizeCtx, state); err != nil {

        // 마무리 실패는 로그만 남기고 결과를 반환한다

        slog.Warn("finalize stage error", "stage", stage.Name(), "err", err)

    }

}

이 구조가 실제로 주는 것

  • 단계 추가가 목록 한 줄입니다. 권한 검사, 비용 가드, 프롬프트 주입 방어 같은 걸 넣을 때 기존 코드를 건드리지 않습니다.
  • 트레이싱이 공짜로 붙습니다. 모든 단계가 Name()을 가지니 실행 시간·에러를 단계별로 기록하는 코드를 파이프라인에 한 번만 쓰면 됩니다. "어느 단계가 느린가"를 바로 답할 수 있습니다.
  • 단계 단위 테스트가 가능합니다. RunState를 손으로 만들어 넣고 한 단계만 실행해 결과를 검사하면 됩니다.
  • 에이전트 종류별로 다른 파이프라인을 조립할 수 있습니다. 툴이 필요 없는 단순 응답 에이전트는 툴 단계를 빼고 조립하면 그만입니다.

정리

  • 에이전트 루프는 setup / iteration / finalize 세 구역으로 갈라진다 — 8단계를 일렬로 두면 안 된다
  • 단계는 무상태, 가변 상태는 RunState 하나에 모아 포인터로 전달
  • 흐름 제어는 선택적 인터페이스로 — 필요한 단계만 구현
  • BreakLoop는 이번 반복을 마치고, AbortRun은 즉시 나간다
  • 외부 의존은 콜백 주입으로 끊어 파이프라인 패키지를 가볍게 유지
  • finalize는 취소된 컨텍스트에서도 돌아야 한다

파이프라인화의 진짜 이득은 성능이 아니라 변경 가능성입니다. 6개월 뒤 새 요구가 들어왔을 때, 1,500줄 함수를 읽는 대신 단계 하나를 추가하고 목록에 끼워 넣을 수 있느냐 — 그 차이가 전부입니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기