에이전트 개발의 다음 단계: 제어 흐름(Control Flow) 도입하기
최근 ‘Agents need control flow, not more prompts’라는 글을 보며 깊이 공감했습니다. 초기 LLM 기반 에이전트를 개발할 때는 프롬프트 엔지니어링이 곧 개발의 전부인 것처럼 느껴졌습니다. 하지만 시스템이 복잡해지고 ZeroClaw와 같은 멀티 에이전트 런타임을 다루게 되면서, ‘프롬프트’가 아닌 ‘구조’가 핵심임을 절실히 깨닫게 되었습니다.
이번 글에서는 복잡한 프롬프트를 덜어내고, 확장 가능한 에이전트 시스템을 위해 **제어 흐름(Control Flow)**을 아키텍처에 도입하는 방법을 실제 코드 예제와 함께 정리해 보겠습니다.
문제 정의: 프롬프트 한계와 ‘의존성 지옥’
단일 에이전트에게 “A 작업을 하고, 결과가 양수면 B를, 음수면 C를 실행한 뒤, 마지막으로 D를 요약해"라고 프롬프트로 지시하는 것은 쉽지 않습니다. LLM은 문맥을 잃어버리거나, 논리적 분기(if/else)를 무시하고 직행해버리는 경우가 빈번하기 때문입니다.
특히 [ZeroClaw] 멀티 에이전트 아키텍처 설계안에서 논의된 것처럼, 여러 에이전트가 협력하는 환경에서는 프롬프트만으로는 다음과 같은 문제가 발생합니다.
- 비결정성(Non-determinism): 같은 입력이라도 LLM이 다른 경로를 선택할 수 있습니다.
- 디버깅 난이도: 에러가 발생했을 때 프롬프트의 어느 부분이 잘못된 지 찾기 어렵습니다.
- 확장성 부족: 새로운 단계를 추가할 때마다 기존의 거대한 프롬프트를 수정해야 합니다.
해결책: 그래프 기반 제어 흐름 (Graph-based Control Flow)
해결책은 LLM에게 “무엇(What)“을 할지 결정하게 하고, “어떻게(How)” 실행할지는 외부의 상태 머신(State Machine)이나 그래프 워크플로우에 위임하는 것입니다. 이를 통해 우리는 에이전트의 사고 자체를 제어하는 것이 아니라, 에이전트 간의 데이터 흐름과 실행 순서를 제어하게 됩니다.
Rust와 같은 안정적인 언어로 이러한 런타임을 구성하면 [ZeroClaw]에서 목표로 하는 고성능 및 안정성을 확보할 수 있습니다.
구현 예제: Rust로 간단한 상태 머신 러너 만들기
복잡한 프레임워크 없이, Rust의 Enum을 활용해 간단한 에이전트 워크플로우를 구현해 보겠습니다. 이 코드는 에이전트가 수행해야 할 단계(Step)를 정의하고, 이전 단계의 결과에 따라 다음 단계를 결정하는 구조입니다.
// 1. 에이전트의 상태와 실행 컨텍스트 정의
#[derive(Debug, Clone)]
struct AgentContext {
current_data: String,
step_count: usize,
}
// 2. 워크플로우의 각 단계(Step) 정의
enum WorkflowStep {
Initialize,
ProcessData,
ValidateResult, // 결과에 따라 다음 단계가 결정됨
Finalize,
Complete,
}
// 3. 각 단계별 실행 로직 구제화
impl WorkflowStep {
fn execute(&self, ctx: &mut AgentContext) -> WorkflowStep {
match self {
WorkflowStep::Initialize => {
println!("[Step 1] 데이터 초기화 중...");
ctx.current_data = "Sample Input Data".to_string();
ctx.step_count += 1;
WorkflowStep::ProcessData
},
WorkflowStep::ProcessData => {
println!("[Step 2] LLM을 통해 데이터 처리 중: {}", ctx.current_data);
// 실제로는 여기서 MCP 클라이언트 등을 호출하여 LLM 추론을 수행
ctx.current_data = format!("Processed: {}", ctx.current_data);
ctx.step_count += 1;
WorkflowStep::ValidateResult
},
WorkflowStep::ValidateResult => {
println!("[Step 3] 결과 검증 중...");
// 비즈니스 로직: 예를 들어 결과 길이가 10보다 작으면 재시작(혹은 에러 처리)
if ctx.current_data.len() < 10 {
println!("데이터가 부족하여 초기화 단계로 돌아갑니다.");
WorkflowStep::Initialize // 루프 구조
} else {
println!("검증 통과. 완료 단계로 이동합니다.");
WorkflowStep::Finalize
}
},
WorkflowStep::Finalize => {
println!("[Step 4] 최종 결과 저장: {}", ctx.current_data);
WorkflowStep::Complete
},
WorkflowStep::Complete => {
println!("워크플로우 완료.");
WorkflowStep::Complete // 종료 상태 유지
}
}
}
}
// 4. 메인 실행 루프 (Runner)
fn run_agent_workflow() {
let mut ctx = AgentContext {
current_data: String::new(),
step_count: 0,
};
let mut current_step = WorkflowStep::Initialize;
// 최대 10회 반복 방지 (Safety Breaker)
for _ in 0..10 {
if matches!(current_step, WorkflowStep::Complete) {
break;
}
current_step = current_step.execute(&mut ctx);
}
}
fn main() {
run_agent_workflow();
}
코드를 통한 얻는 이점
위의 예제처럼 단순히 match 문과 Enum을 사용하더라도 얻을 수 있는 효과는 큽니다.
- 명확한 흐름 시각화: 코드만 보고도 시스템이 어떻게 흘러가는지 파악할 수 있습니다. [Discord MCP Gateway 아키텍처]처럼 복잡한 요청을 처리할 때 유지보수성이 크게 향상됩니다.
- 상태 관리:
AgentContext구조체를 통해 메모리 상태를 명시적으로 관리합니다. LLM의 컨텍스트 윈도우가 터지더라도 Rust 런타임은 현재 단계를 정확히 알고 있습니다. - 에러 핸들링: 특정 단계에서 실패했을 때, 프롬프트 재시도가 아니라 코드 레벨에서
Retry로직이나Fallback단계로 쉽게 넘어갈 수 있습니다.
결론: 에이전트는 소프트웨어다
우리는 LLM을 ‘마법’처럼 다루는 시대를 지나, LLM을 ‘하나의 모듈’로 바라보는 시대로 넘어가고 있습니다. [Cloud Monitor]나 [LLM 설정 개선] 프로젝트에서 겪었던 시행착오들은 결국 견고한 소프트웨어 아키텍처 위에 AI를 얹어야 한다는 것을 보여줍니다.
프롬프트를 더 길게 작성하는 대신, Rust나 Python으로 제어 흐름을 명확히 정의하는 런타임을 구축해 보세요. 에이전트의 행동이 예측 가능해지고, 시스템이 튼튼해지는 것을 느끼실 수 있을 것입니다.
다음 포스트에서는 이러한 제어 흐름 위에서 실제로 여러 LLM 호출을 병렬로 처리하여 성능을 극대화하는 방법에 대해 다루어보겠습니다.