본문 바로가기

AI Agent

[실전] 매 스텝마다 LLM이 다음 할 일을 고른다 — 자동 워크플로우 Orchestrator 구현기

반응형

AI Agent 시스템을 만들다 보면 이런 고민이 생깁니다.

"워크플로우를 미리 다 정의해두면, 예상 못 한 요청이 들어왔을 때 대응이 안 되는데?"

기존 수동 워크플로우 방식은 task를 미리 정의된 순서에 따라 실행했어요. 그래서 유연하지 못했습니다. 이번 글에서는 그 한계를 깨고, LLM이 매 스텝마다 다음 task를 동적으로 선택하는 자동 워크플로우 구성 방식의 핵심 동작 원리를 설명해 드릴게요.


수동 vs 자동, 뭐가 다른가

구분 수동 워크플로우 자동 워크플로우

task 선택 미리 정의된 순서대로 매 스텝마다 LLM이 동적 선택
input 추출 전체 input 한 번에 추출 이전 task 결과를 다음 input으로 활용
실행 방식 병렬/순차 (독립적) 결과 누적 → 반복 → done
유연성 낮음 높음

핵심 차이는 "워크플로우를 미리 짜두냐, 실행하면서 짜냐" 입니다.


전체 흐름

자동 워크플로우 구성의 핵심은 이 루프입니다.

step_router → history_check → entity_extractor → task_executor → (다시 step_router)

사용자 요청이 들어오면 이 루프를 계속 돌면서 task를 하나씩 선택하고 실행합니다. LLM이 "이제 충분하다"고 판단하면 done을 반환하고 루프를 빠져나와 최종 응답을 생성해요.


핵심 노드 4개

1. step_router — 다음 task를 고르는 두뇌

자동 워크플로우의 핵심 노드입니다. 매 스텝마다 LLM이 호출되어 "지금 상황에서 다음에 뭘 해야 하는가" 를 판단해요.

LLM이 판단할 때 사용하는 데이터

step_router는 아래 세 가지 데이터를 LLM에게 넘깁니다.

[사용 가능한 Agent 목록]     ← AgentRegistry에서 가져온 AgentCard 리스트
[사용자 요청]                ← 원본 user_query
[지금까지 실행한 작업]       ← execution_history (또는 요약본)

각각의 역할이 명확해요.

Agent 목록은 LLM이 선택할 수 있는 옵션이에요. 각 AgentCard에는 이름, 설명, when_to_use 항목이 있어서 LLM이 "이 에이전트는 이럴 때 쓰는 거구나"를 판단할 수 있어요. 단, step_router 프롬프트 길이를 최소화하기 위해 when_to_use에서 랜덤 1문장만 선택해서 넘깁니다. entity_extraction_prompt 같은 상세 정보는 이 단계에서 포함하지 않아요.

사용자 요청은 LLM이 전체 목표를 잃지 않게 해줍니다. 루프가 여러 번 돌아도 원래 요청이 뭔지 매번 상기시켜주는 거예요.

execution_history가 가장 중요합니다. 지금까지 어떤 task를 실행했고, 각 task의 결과가 뭐였는지를 담고 있어요. LLM은 이걸 보고 "이미 검색은 했으니 이제 요약을 해야겠다", "요약도 끝났으니 이제 done이다" 같은 판단을 내려요. execution_history가 없으면 LLM은 같은 task를 반복 선택하거나 이미 완료된 작업을 다시 하려 할 수 있어요.

LLM이 하는 판단

LLM은 위 세 가지 데이터를 종합해서 아래 두 가지 중 하나를 결정합니다.

첫째, 다음 task 선택입니다. Agent 목록에서 지금 상황에 가장 적합한 에이전트를 고르는 거예요. 단순히 이름만 고르는 게 아니라, 왜 이 task를 선택했는지 reasoning도 함께 반환해요.

둘째, done 반환입니다. 사용자 요청을 처리하기에 충분한 결과가 execution_history에 쌓였다고 판단되면 done을 반환해서 루프를 종료합니다.

LLM output은 아래 형태예요.

{
  "next_task": "요약 에이전트 또는 done",
  "reasoning": "검색 결과가 이미 있으므로 요약 단계로 진행합니다"
}

reasoning을 함께 저장하는 이유는 나중에 execution_history를 볼 때 "왜 이 순서로 실행됐는지" 추적하기 위해서예요.

프롬프트에 명시적으로 넣은 가이드

"이미 실행한 task를 불필요하게 반복하지 마세요."

이 한 줄이 중요합니다. LLM이 execution_history를 보고 이미 완료된 task를 또 선택하는 걸 방지하는 가이드예요. 프롬프트 수준에서 중복 실행을 억제하는 거죠.

분기 조건

step_router의 출력에 따라 세 가지 분기가 생겨요.

next_task == "done"          → final_response_node로 이동
current_step >= max_steps    → final_response_node로 강제 이동 (무한루프 방지)
그 외                        → history_check_node로 이동

2. history_check_node — 메모리 관리자

LLM 호출 없이 순수하게 토큰 수만 측정하는 노드예요.

스텝이 쌓일수록 execution_history가 길어지고, 그대로 다음 step_router에 넘기면 LLM context 용량을 초과할 수 있어요. 그걸 막기 위해 매 스텝마다 토큰 수를 체크합니다.

execution_history 토큰 수 측정
  └─ 임계값 미만 → entity_extractor로 바로 이동
  └─ 임계값 초과 → history_summarizer 거쳐서 이동

임계값을 넘으면 history_summarizer가 LLM으로 이력을 요약해서 압축하고, 이후 step_router에서는 원본 대신 요약본을 사용해요. 정보 손실을 최소화하면서 context 길이를 줄이는 전략이에요.

orchestrator:
  history_token_threshold: 2000

3. entity_extractor — input을 꺼내는 파서

step_router가 다음 task를 골랐으면, 그 task에 필요한 input을 추출해야 해요. 이게 entity_extractor의 역할입니다.

[추출 스키마]  ← AgentCard의 input_schema
[추출 가이드]  ← AgentCard의 entity_extraction_prompt
[사용자 요청]
[이전 task 결과]

→ task input 추출

여기서 이전 task 결과를 참조한다는 점이 핵심이에요. 예를 들어 검색 에이전트가 문서를 가져왔으면, 다음 요약 에이전트는 그 결과를 input으로 받아서 처리합니다. 각 task가 독립적으로 실행되는 게 아니라, 결과가 체인처럼 연결되는 거예요.

추출할 수 없는 값은 null로 반환하고, AgentCard에 entity_extraction_prompt를 정의해두면 LLM이 도메인에 맞게 더 정확하게 추출할 수 있어요.


4. task_executor — 실제 실행기

entity_extractor가 input을 뽑아줬으면 실제로 sub-agent를 호출합니다.

AgentCaller.call_one(
    agent_name=next_task,
    payload={
        "session_id": session_id,
        "task": next_task,
        "payload": next_task_input
    }
)

is_direct=True인 task는 sub-agent 호출 없이 LLM이 직접 답변해요. 단순한 질문은 굳이 sub-agent까지 안 거쳐도 되니까요.

실행 결과는 ExecutionStep으로 만들어서 execution_history에 쌓고, 다시 step_router로 돌아갑니다.

ExecutionStep(
    step=current_step,
    task_name=next_task,
    reasoning=next_task_reasoning,   # step_router가 이 task를 선택한 이유
    input=next_task_input,           # entity_extractor가 추출한 input
    result=task_result               # 실제 실행 결과
)

실제 실행 흐름 예시

"최신 AI 논문 찾아서 요약해줘"라는 요청이 들어왔다고 가정해볼게요.

Step 1
  step_router
    판단 데이터: Agent 목록 + "최신 AI 논문 찾아서 요약해줘" + execution_history(비어있음)
    판단 결과: "검색부터 해야 함" → 검색 에이전트 선택
  entity_extractor → query: "최신 AI 논문"
  task_executor → 검색 결과 반환
  execution_history: [Step 1 저장]

Step 2
  step_router
    판단 데이터: Agent 목록 + "최신 AI 논문 찾아서 요약해줘" + execution_history(Step 1 결과 있음)
    판단 결과: "검색 완료, 이제 요약해야 함" → 요약 에이전트 선택
  entity_extractor → content: Step 1의 검색 결과 (이전 결과 참조)
  task_executor → 요약 결과 반환
  execution_history: [Step 1, Step 2 저장]

Step 3
  step_router
    판단 데이터: Agent 목록 + "최신 AI 논문 찾아서 요약해줘" + execution_history(Step 1, 2 결과 있음)
    판단 결과: "검색도 했고 요약도 했으니 충분" → done 반환
  final_response_node → 최종 응답 생성

워크플로우를 미리 정의하지 않았지만, LLM이 매 스텝마다 execution_history를 보면서 스스로 순서를 구성했어요.


무한루프 방지 전략

자동 워크플로우의 가장 큰 리스크는 루프가 끝나지 않는 것입니다. 두 가지 안전장치를 뒀어요.

첫째, max_steps 설정입니다. config에서 최대 실행 스텝 수를 지정하고, 초과하면 강제로 final_response_node로 넘어갑니다.

orchestrator:
  max_steps: 10

둘째, step_router 프롬프트 가이드입니다. "이미 실행한 task를 불필요하게 반복하지 마세요"라는 명시적 지시를 넣어서 LLM이 execution_history를 보고 중복 선택을 스스로 억제하도록 유도했어요.


마무리

자동 워크플로우 구성의 핵심은 세 가지입니다.

첫째, LLM이 매 스텝마다 Agent 목록 + 사용자 요청 + execution_history 세 가지 데이터를 종합해서 다음 task를 직접 선택합니다. 둘째, 이전 task 결과가 다음 task의 input으로 자연스럽게 흘러가면서 체인이 형성됩니다. 셋째, max_steps와 프롬프트 가이드로 무한루프를 이중으로 방지합니다.

워크플로우를 미리 하드코딩하지 않아도 되니 새로운 sub-agent를 추가할 때 기존 코드를 건드릴 필요가 없어요. AgentCard만 잘 정의해주면 LLM이 알아서 언제 쓸지 판단합니다. 😄


 

반응형