AI Agent Pipeline Design: Splitting the Execution Loop into 8 Stages

🌐 한국어

Most people build their first LLM agent the same way: take a message, build a prompt, call the model, run the tools, feed the results back, repeat. Pack that into one function and it works beautifully at around 200 lines. Then the requirements arrive. You need session summaries. You need to trim when tokens overflow. You need hooks in the middle. Some agents must skip tool execution entirely. At some point that function is 1,500 lines and nobody will touch it.

The fix is a familiar one: split the loop into stages. This article covers a design that breaks the agent loop into eight stages, and the problems you inevitably hit when you do.

What the Eight Stages Are

Logically, one agent turn flows like this.

  1. context — settle the workspace, config files, and user information
  2. history — load prior conversation and the session summary
  3. prompt — assemble the system prompt
  4. think — call the LLM
  5. act — execute whatever tools the model requested
  6. observe — feed tool results back into the conversation
  7. memory — persist what this turn should leave behind
  8. summarize — summarize the session and wrap up

Here's the first trap. These eight do not all run on every iteration. context, history, and prompt need to happen once at the start of a turn; think, act, and observe must keep cycling while the model asks for more tools; memory and summarize run once after the loop. So the real structure has three zones.

type Pipeline struct {

    setup     []Stage // once at turn start (context/history/prompt)

    iteration []Stage // repeats per tool loop (think/act/observe)

    finalize  []Stage // once at turn end (memory/summarize)

}

func NewDefaultPipeline(deps Deps) *Pipeline {

    return &Pipeline{

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

        iteration: []Stage{

            NewThinkStage(&deps),

            NewPruneStage(&deps),    // trim when over the token budget

            NewToolStage(&deps),     // act

            NewObserveStage(&deps),

            NewCheckpointStage(&deps),

        },

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

    }

}

Keep the Stage Interface Tiny

Two methods are enough: a name and an execute. Then let only the stages that need flow control optionally implement one more interface.

type StageResult int

const (

    Continue  StageResult = iota // proceed to the next stage

    BreakLoop                    // exit the iteration loop normally

    AbortRun                     // abort the entire run

)

type Stage interface {

    Name() string                                  // for logging and tracing

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

}

// Only stages that must steer the loop implement this.

// Stages that don't are treated as Continue by the pipeline.

type StageWithResult interface {

    Stage

    Result() StageResult

}

This is Go's optional interface pattern. Force every stage to implement Result() and most of them end up with a single boilerplate return Continue. Let the ones that need it opt in, and have the pipeline check with a type assertion.

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)

    }

    // An abort signal takes effect immediately — skip the remaining stages

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

        state.ExitCode = AbortRun

        break

    }

}

The Core Rule: Stateless Stages, State in One Place

This is the center of the design. Stage objects hold no state; all mutable state lives in a single struct passed by pointer.

// Shared mutable state for one run. Passed by pointer through every stage.

type RunState struct {

    // Set at run start, immutable thereafter

    Input    *RunInput

    Model    string

    Provider Provider

    // Message buffer that several stages read and write

    Messages *MessageBuffer

    // Per-stage substates — the name documents ownership

    Context ContextState

    Think   ThinkState

    Tool    ToolState

    Observe ObserveState

    Iteration int

    ExitCode  StageResult

}

The reason matters: if a stage keeps state in its own fields, you can't run the same pipeline instance twice. The moment you handle two conversations concurrently, values from one run leak into another. Keep stages stateless and you can build the pipeline once and create a fresh RunState per request.

Grouping substates under stage names like Think and Tool is deliberate too. Flatten those fields and six months later you can't trace who writes what. The name documents ownership.

Break Coupling Between Stages with Callbacks

The context stage resolves a workspace, reads files, assembles a prompt, and loads history. Implement all of that inside the stage and your pipeline package now depends on your store, your filesystem, and your prompt builder. Instead, inject dependencies as function fields.

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

}

// Anything not injected is simply skipped — partial assembly becomes possible in tests

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

}

The nil checks look untidy but they earn their keep. You can build a pipeline with only the callbacks you care about, so verifying the think stage doesn't require standing up a whole store mock.

What the think Stage Actually Does

The stage that looks simplest is the messiest, because LLM calls fail in a variety of ways.

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)

    }

    // Accumulate usage — always include cache tokens in the tally

    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

    }

    // Truncation: retry only when tool call arguments were cut off.

    // Text-only truncation with no tool calls is a long answer, not an error.

    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 // retry on the next iteration

    }

    // No tool calls means this is the final answer — end the loop

    if len(resp.ToolCalls) == 0 {

        s.result = BreakLoop

        return nil

    }

    st.Messages.AppendPending(assistantMessage(resp))

    return nil

}

Note that it distinguishes two kinds of truncation. Cut-off tool call arguments leave broken JSON you can't execute, so you retry. Plain text cut off with no tool calls just means the answer was long, and it should go to the user as-is. Skip that distinction and you'll retry pointlessly on every long response.

Treating BreakLoop and AbortRun Differently

There's a reason for two flow-control signals.

  • BreakLoop (normal completion) exits after finishing every remaining stage in this iteration. The observe stage still has to collect the final response and the checkpoint stage still has to persist state. Leave early and the answer meant for the user disappears.
  • AbortRun exits immediately. The state is already unrecoverable, so running the remaining stages is pointless or unsafe.

And the finalize zone must run even on a cancelled context. If the user cancelled midway, the conversation so far still needs to be saved.

// Run finalize stages on a context with cancellation detached

finalizeCtx := context.WithoutCancel(ctx)

for _, stage := range p.finalize {

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

        // A finalize failure logs and still returns a result

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

    }

}

What This Structure Actually Buys You

  • Adding a stage is one line in a list. Permission checks, cost guards, prompt-injection defenses — none of them require touching existing code.
  • Tracing comes free. Every stage has a Name(), so recording duration and errors per stage is written once in the pipeline. "Which stage is slow?" becomes answerable immediately.
  • Stages are unit-testable. Hand-build a RunState, run one stage, inspect the result.
  • You can assemble different pipelines per agent type. A simple response agent with no tools just gets assembled without the tool stage.

Summary

  • The agent loop splits into setup / iteration / finalize — don't lay all eight stages out in a straight line
  • Stages are stateless; mutable state lives in one RunState passed by pointer
  • Flow control goes through an optional interface — only the stages that need it implement it
  • BreakLoop finishes the iteration; AbortRun leaves immediately
  • Break external dependencies with injected callbacks to keep the pipeline package light
  • finalize has to run even on a cancelled context

The real payoff of a pipeline isn't performance, it's changeability. Six months from now, when a new requirement lands, can you add one stage and slot it into a list instead of reading a 1,500-line function? That difference is the whole thing.

댓글

이 블로그의 인기 게시물

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

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

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