본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 09:39

TypeScript로 구현하는 에이전틱 루프(Agentic Loop) 그 너머: 오케스트레이터(Orchestrator) 패턴을 이용한 쇼핑

요약

멀티 에이전트 시스템에서 LLM의 에이전틱 루프가 가진 예측 불가능성과 비결정론적 문제를 해결하기 위한 오케스트레이터 패턴을 소개합니다. TypeScript를 사용하여 쇼핑 어시스턴트 시나리오를 바탕으로 제어 흐름을 구조화하는 방법을 다룹니다.

핵심 포인트

  • 에이전틱 루프의 한계인 높은 지연 시간과 비결정론적 동작 분석
  • 오케스트레이터 패턴을 통한 명확한 제어 흐름 및 계획 수립
  • TypeScript를 활용한 멀티 에이전트 시스템 구현 예시
  • 프로덕션 환경에서의 관찰 가능성 및 신뢰성 확보 방안

이 포스트는 Amogh Ubale (Stackademic)가 작성한 "Beyond the Agentic Loop: The Orchestrator Pattern for Multi-Agent Systems"에서 설명된 패턴을 TypeScript로 구현한 것입니다. 원문은 일반적인 에이전트(generic agents)를 사용한 Python 기반이지만, 여기서는 아이디어를 그대로 유지하면서 세 가지 실행 모드가 구체적인 대상을 다룰 수 있도록 **쇼핑 어시스턴트 (shopping assistant)**로 테마를 변경했습니다. 모든 설계 권한은 해당 기사에 있으며, 먼저 그 글을 읽어보시길 권장합니다.

등장인물: 몇몇 쇼핑 에이전트들

패턴을 살펴보기 전, 상황을 먼저 보겠습니다. 데모는 몇 가지 단일 목적 에이전트(single-purpose agents)를 지원하는 작은 상점 어시스턴트입니다:

  • Catalog (카탈로그) — 제공되는 카테고리를 나열하거나 키워드 및 가격별로 제품을 검색합니다.
  • Inventory (재고) — 제품의 재고 및 가용성을 확인합니다.
  • Pricing (가격) — 현재 가격 및 진행 중인 프로모션을 조회합니다.
  • Reviews (리뷰) — 제품의 평점 및 리뷰 하이라이트를 가져옵니다.
  • Order (주문) — 제품 주문을 진행합니다.

고객의 요청은 이 중 하나만 필요할 수도 있고, 여러 개가 동시에 필요할 수도 있으며, 엄격한 순서에 따라 몇 개가 필요할 수도 있습니다. 요청이 어떤 형태를 요구하는지 결정하는 것이 바로 오케스트레이터 (orchestrator)의 역할입니다.

문제점: while 루프로서의 LLM

멀티 에이전트 시스템 (multi-agent system)을 구축하는 기본 방식은 **에이전틱 루프 (agentic loop)**입니다. 모델에게 도구(tools) 꾸러미를 건네주고 스스로 운전하게 만드는 방식입니다.

생각(think) → 도구 호출(call a tool) → 결과 관찰(observe the result) → 다시 생각(think again) → 다른 도구 호출(call another tool) → …

LLM이 두뇌(brain)이자 제어 흐름(control flow) 역할을 모두 수행합니다. 이는 놀라울 정도로 유연하며, 작업이 개방적이고 단계가 사전에 정해져 있지 않을 때 적합한 도구입니다. 하지만 프로덕션 환경에서는 세 가지 까다로운 특성을 가집니다:

  • 예측 불가능한 형태 (Unpredictable shape). 모든 "생각 (think)" 단계는 또 다른 LLM 왕복 (round-trip) 과정입니다. 따라서 3개의 에이전트가 참여하는 작업이 3번의 호출로 끝날지 9번이 될지는 실행해 보기 전까지 알 수 없으며, 이에 따라 지연 시간 (latency)도 요동칩니다. (본문 기사에서는 대표적인 3개 에이전트 쿼리가 루프를 통해 약 7회의 호출을 소모한다고 기록했습니다. 실제 소요 시간과 비용도 그에 따라 달라지지만, 실제로 뼈아픈 부분은 바로 이 예측 불가능성입니다.)
  • 비결정론 (Non-determinism). 동일한 질문이라도 매번 다른 경로를 택할 수 있습니다. 이는 동작을 추론하기 어렵게 만들며, _주문하기 (placing an order)_와 같은 부작용 (side effects)이 발생하는 작업에 신뢰를 갖기 어렵게 만듭니다.
  • 낮은 관찰 가능성 (Poor observability). "왜 그렇게 행동했는가?"라는 질문에 답하려면 추론과 도구 호출 (tool calls)이 뒤섞인 기록을 다시 재생해야 합니다. _계획 (plan)_이 단일한 곳에 존재하지 않습니다.

만약 어떤 에이전트가 존재하고 무엇을 하는지 이미 알고 있다면, 모든 요청마다 개방형 추론 루프 (open-ended reasoning loop)를 사용하는 것은 작업에 필요한 것보다 과도한 자유를 부여하는 것입니다.

패턴: 한 번 결정하고, 결정론적으로 실행하라

오케스트레이터 (orchestrator)의 핵심 전략은 결정과 실행을 분리하는 것입니다. 모델이 루프를 돌게 내버려 두는 대신, 그 사이에 평범하고 결정론적인 (deterministic) 코드를 배치하여 정확히 두 번의 LLM 호출을 수행합니다.

쿼리 ──▶ [라우팅 (ROUTE): LLM #1] ──▶ [실행 (EXECUTE): 에이전트들, LLM 없음] ──▶ [합성 (SYNTHESIZE): LLM #2] ──▶ 답변
  1. 라우팅 (Route) — 어떤 에이전트를 실행할지 선택하는 것만이 유일한 임무인 하나의 LLM 호출입니다.
  2. 실행 (Execute) — 일반적인 애플리케이션 코드가 해당 에이전트들을 실행합니다. 여기에는 LLM이 사용되지 않습니다.
  3. 합성 (Synthesize) — 구조화된 결과물을 산문 (prose) 형태로 변환하는 하나의 LLM 호출입니다.

실행되는 에이전트의 수와 상관없이 매번 두 번의 호출만 발생합니다. 이 고정된 형태가 핵심입니다. 즉, 어떤 일이 일어나기 전에 검사할 수 있는 계획, 모델의 기분에 좌우되지 않는 지연 시간, 그리고 병렬로 확장할 수 있는 독립적인 작업이 가능해집니다. (비용도 더 저렴합니다. 기사에 따르면 동일한 쿼리가 약 7회 대신 약 2회의 호출로 처리됩니다. 하지만 비용이 핵심은 아닙니다. 핵심은 _결과 (outcomes)_입니다.)

1. 레지스트리 (The registry): 에이전트는 그저 함수일 뿐이다

에이전트는 이름, 설명 (라우터를 위한 용도), 인자 (arguments)를 위한 JSON-Schema, 그리고 execute 함수로 구성됩니다. 그 이상은 아무것도 없습니다.

// src/server/orchestrator/types.ts
export type ExecuteFn = (args: AgentArgs, context: AgentContext) => Promise<AgentResult>;

...

"레지스트리 (registry)"는 단순한 인프로세스 (in-process) 객체입니다. 즉, 에이전트들은 수동으로 등록됩니다.
의도적으로 Redis, 데이터베이스, HTTP 셀프 등록 (self-registration) 등을 사용하지 않았습니다. 덕분에 인프라 구축 없이도 전체 시스템을 실행하고 테스트할 수 있습니다.

// src/server/orchestrator/registry.ts
export const REGISTRY: Record<string, AgentDefinition> = {
  catalog_agent__list_categories: catalogCategoriesAgent,
...

toolDefinitions()는 라우터 (router)가 보는 OpenAI 도구 (tool) 형식으로 매핑되는 프로젝트를 생성합니다. 각 에이전트는 하나의 함수 도구 (function tool)가 되며, 여기에 곧 만나게 될 하나의 **메타 도구 (meta-tool)**가 추가됩니다.

2. Route: 단 한 번의 의사결정 LLM 호출

라우터에게는 매우 직설적인 시스템 프롬프트 (system prompt)가 주어집니다: 도구를 선택하라, 답변하지 마라.

// src/server/orchestrator/router.ts
const SYSTEM_PROMPT = `You are a query router. Your ONLY job is to decide which tool(s) to call.
Rules:
...

우리는 모델을 temperature: 0tool_choice: "auto" 설정으로 호출한 다음, 모델이 호출한 도구들을 다시 읽어옵니다. 이 도구 호출 (tool-call) 목록의 형태가 곧 실행 계획 (execution plan)입니다. 우리는 모델에게 "답변"을 요구하지 않고, 오직 "선택"만을 요구합니다.

// src/server/orchestrator/router.ts
export async function route(query: string): Promise<RouteDecision> {
  const response = await getOpenAIClient().chat.completions.create({
...

따라서 라우터는 세 가지 결과로 압축됩니다:

  • 하나의 도구 → single
  • 여러 개의 도구 → parallel
  • plan_execution 메타 도구 → sequential

3. Execute: 패턴의 핵심 (LLM 미사용)

이 단계는 병렬 (parallel) 방식과 순차 (sequential) 방식이 실제로 갈라지는 지점이며, 모델이 개입하지 않는 순수 TypeScript 영역입니다.

// src/server/orchestrator/executor.ts
export async function* executeStream(mode: Mode, steps: PlanStep[]): AsyncGenerator<ExecEvent, AgentContext> {
  const results: AgentContext = {};
...

두 분기(branch)를 나란히 비교해 보십시오:

  • 병렬 (Parallel) 방식은 Promise.all을 사용합니다. 에이전트들이 독립적이므로 모두 동시에 실행되며, 비용은 각 에이전트의 합계가 아니라 가장 느린 에이전트의 시간에 맞춰 지불하게 됩니다. _"iPhone 15의 가격, 평점, 재고는 무엇인가요?"_라는 질문은 서로 연관성이 없는 세 가지 조회 작업이 되므로, 이를 한꺼번에 실행합니다.
  • 순차 (Sequential) 방식은 각 단계가 누적된 results를 자신의 context로 전달받는 순서가 있는 for 루프입니다. 이것이 나중에 실행되는 에이전트가 이전 에이전트의 출력을 소비하는 방식입니다. _"1000달러 미만의 노트북을 찾고, 재고를 확인한 다음, 주문하세요"_와 같은 작업은 병렬로 처리할 수 없습니다. 주문 단계는 검색 결과로 생성된 제품 정보가 필요하기 때문입니다.

(제너레이터(generator)는 각 에이전트 전후로 작은 이벤트를 yield합니다. 이는 전송 계층(transport)에서 진행 상황을 보여주기 위한 용도일 뿐, 로직을 변경하지는 않습니다.)

4. plan_execution: 에이전트가 아닌 신호(signal)

라우터가 "이것들을 순서대로 수행하라"고 어떻게 말할까요? 코드를 실행하지 않는 메타 도구(meta-tool)를 통해 가능합니다:

// src/server/orchestrator/registry.ts
export const PLAN_EXECUTION_TOOL = "plan_execution";
// ...해당 도구의 스키마(schema)는 { reason, steps: [{ tool, args, reason }] }를 요구합니다}

라우터가 plan_execution을 선택하면, 오케스트레이터(orchestrator)는 순차(sequential) 모드로 전환됩니다. 원문에서는 이를 순수하게 하나의 _신호(signal)_로 취급하며, 순서 지정 및 데이터 전달 방식은 명시하지 않았습니다. 이 저장소(repo)는 데모가 실제로 엔드 투 엔드(end-to-end)로 작동할 수 있도록 한 가지 의도적인 기능을 추가했습니다. 바로 plan_execution이 순서가 지정된 steps를 반환하도록 하고, 실행기(executor)가 results를 컨텍스트(context)로서 전달하도록 만든 것입니다. 그러면 주문 에이전트가 검색을 통해 찾은 제품을 해결(resolve)합니다 (src/server/lib/resolve-product.tsresolveTargetProduct 참조). 이것이 패턴 다이어그램과 실제로 실행 가능한 구현체 사이의 차이점입니다.

5. 합성(Synthesize): 유일한 창의적 호출

에이전트들이 구조화된 데이터(structured data)를 생성하고 나면, 두 번째 LLM 호출이 이를 답변으로 변환합니다. 이 단계는 "작문"이 필요한 유일한 단계이므로, 더 높은 연산 자원(warmer)을 사용하며 토큰을 스트리밍(streaming) 방식으로 출력합니다.

// src/server/orchestrator/synthesizer.ts
export async function* synthesizeStream(query: string, results: AgentContext): AsyncGenerator<string> {
  const stream = await getOpenAIClient().chat.completions.create({
...

이를 통해 얻을 수 있는 것

세 단계를 결합함으로써 얻는 보상은 루프(loop)의 고충(pain points)과 정확히 반대되는 것입니다. 그리고 우리가 이 패턴을 선택해야 하는 진짜 이유는 가격표가 아니라, 바로 이러한 가능성(enablements) 때문입니다.

  • 신뢰할 수 있는 계획. 결정 사항은 단일한 검사 가능한 객체인 RouteDecision으로 나타나며, 이는 어떤 에이전트(agent)가 실행되기 _전(before)_에 생성됩니다. 이를 로그로 남기거나, 단언(assert)하거나, 게이트(gate)를 설정하거나, 다시 재생(replay)할 수 있습니다. 이것이 바로 에이전트가 실제로 주문을 넣도록 허용할 수 있는 안전장치입니다.
  • 디버깅 가능성 (Debuggability). 실행(execute) 단계는 결정론적(deterministic)이므로, 버그가 발생했을 때 매 실행마다 다른 트랜스크립트(transcript) 속에 숨어버리는 대신 매번 동일하게 재현됩니다.
  • 무료로 얻는 병렬성 (Parallelism). 독립적인 작업은 Promise.all로 처리됩니다. 모델에게 병렬 처리를 수행하도록 가르칠 필요가 없습니다.
  • 테스트 가능한 핵심 (A testable core). 중간 단계에는 LLM이 포함되어 있지 않기 때문에, executeStream은 스텁 레지스트리(stub registry)를 사용하여 유닛 테스트(unit-test)를 할 수 있는 일반적인 비동기(async) 함수입니다. API 키도 필요 없고, 불안정성(flakiness)도 없습니다.
  • 예측 가능한 실행 (Predictable runs) (지루하지만 유용한 점). 요청이 한 개의 에이전트에 닿든 다섯 개의 에이전트에 닿든 항상 두 번의 LLM 호출이 발생합니다. 따라서 지연 시간(latency)을 수치화할 수 있으며, 부수적으로 비용도 더 낮아집니다.

샘플 쿼리 → 라우팅 방식

쿼리 (Query)모드 (Mode)에이전트 (Agents)
what do you have?singlecatalog_agent__list_categories
...
동일한 에이전트, 동일한 데이터 — 라우터(router)가 실행의 **형태(shape)**를 결정합니다.

루프가 여전히 승리하는 경우

이것은 "오케스트레이터는 좋고, 루프는 나쁘다"라는 뜻이 아닙니다. 에이전틱 루프 (agentic loop)는 작업이 진정으로 탐색적일 때 적합한 도구입니다. 즉, 단계를 미리 알 수 없거나, 도구 세트 (toolset)가 개방적(open-ended)이거나, 에이전트가 발견한 내용에 따라 실행 도중 재계획 (re-plan)이 필요한 경우입니다. 오케스트레이터 (orchestrator)는 그러한 적응성 (adaptability)을 예측 가능성 (predictability)과 맞바꾸는 것이며, 이는 사용자가 에이전트들을 사전에 열거할 수 있다고 가정합니다. 또한 여기서 라우터 (router) 자체가 단일 LLM 호출 (call)이라는 점에 유의하십시오. 따라서 설계상 이 라우터가 한 번도 본 적 없는 진정으로 새로운 멀티 홉 (multi-hop) 계획을 세우는 것은 범위를 벗어납니다.

이 글의 프레임워크를 기억하십시오: 탐색에는 루프를, 프로덕션에는 오케스트레이터를 사용하십시오. 만약 이미 에이전트들을 알고 있고, 제한된 지연 시간 (bounded latency), 병렬 실행 (parallel execution), 그리고 디버깅 가능한 실행 (debuggable runs)이 필요하다면 — 모델에게 한 번 묻고, 실행하고, 합성(synthesize)하십시오. 두 번의 호출로 끝납니다.

전체 샘플 코드

Twitter @roamingcode를 통해 언제든 편하게 연락해 주세요.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0