research

ZeroClaw — Agent Loop

ZeroClaw — Agent Loop

Layer 2 학습. 실제 소스 코드 기반. 파일: src/agent/agent.rs, src/agent/dispatcher.rs


전체 흐름 — turn() 함수

turn(user_message)이 agent loop의 핵심 진입점이야. 한 번의 사용자 입력을 처리하는 전체 흐름:

turn(user_message)
  │
  ├─ 1. 첫 호출이면 system prompt 구성 → history 앞에 추가
  │
  ├─ 2. memory에서 관련 context 로드
  │      memory.recall(user_message) → 관련 기억 검색
  │
  ├─ 3. user message 가공
  │      "[2026-03-22 15:30:00 KST] {context}{user_message}"
  │      → history에 push
  │
  ├─ 4. 모델 분류 (query classification)
  │      키워드 기반으로 어떤 모델로 라우팅할지 결정
  │
  └─ 5. tool call loop (최대 max_tool_iterations회)
           │
           ├─ history → provider messages 변환 (dispatcher)
           ├─ 응답 캐시 확인 (temperature==0.0일 때만)
           ├─ provider.chat(messages, tools) 호출
           ├─ 응답 파싱 → (text, Vec<ParsedToolCall>)
           │
           ├─ tool_calls 없음? → 최종 응답 반환 ✅
           │
           └─ tool_calls 있음?
                → execute_tools(calls) 실행
                → 결과 history에 추가
                → trim_history()
                → 루프 계속

Agent 구조체 — 핵심 필드

pub struct Agent {
    provider: Box<dyn Provider>,          // LLM 호출
    tools: Vec<Box<dyn Tool>>,            // 실행 가능한 도구들
    tool_specs: Vec<ToolSpec>,            // LLM에 노출할 도구 명세
    memory: Arc<dyn Memory>,             // 기억 저장소
    tool_dispatcher: Box<dyn ToolDispatcher>, // tool call 파싱/포맷
    history: Vec<ConversationMessage>,   // 대화 히스토리
    config: AgentConfig,                 // max_tool_iterations 등
    model_name: String,
    temperature: f64,
}

history가 agent의 "상태"야. 모든 대화, tool call, 결과가 여기 쌓여.


ToolDispatcher — 핵심 추상화

로컬 LLM과 클라우드 LLM의 tool call 방식이 다른 문제를 해결하는 trait.

pub trait ToolDispatcher: Send + Sync {
    fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>);
    fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
    fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage>;
    fn should_send_tool_specs(&self) -> bool;
    fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String;
}

구현체 두 개:

XmlToolDispatcher — 로컬 LLM용

should_send_tool_specs() → false
  → 도구 목록을 API에 전달하지 않음
  → 대신 system prompt에 텍스트로 주입

LLM 응답 파싱:
  텍스트에서 <tool_call>...</tool_call> 태그 찾기
  예: <tool_call>{"name":"shell","arguments":{"command":"ls"}}</tool_call>

결과 포맷:
  <tool_result name="shell" status="ok">
  ls output here
  </tool_result>

<think>...</think> 태그 자동 제거 (Qwen 등 reasoning 모델용).

NativeToolDispatcher — Claude/OpenAI용

should_send_tool_specs() → true
  → tools 배열을 API 요청에 포함
  → LLM이 tool_calls 배열로 직접 반환

LLM 응답 파싱:
  response.tool_calls 배열에서 직접 읽기
  → JSON arguments 파싱

결과 포맷:
  ConversationMessage::ToolResults(Vec<ToolResultMessage>)
  → API에 role: "tool" 메시지로 전달

선택 로직 (from_config)

let dispatcher = match config.agent.tool_dispatcher.as_str() {
    "native" => Box::new(NativeToolDispatcher),
    "xml"    => Box::new(XmlToolDispatcher),
    _  => if provider.supports_native_tools() {
              Box::new(NativeToolDispatcher)
          } else {
              Box::new(XmlToolDispatcher)
          }
};

로컬 llama.cpp → XmlToolDispatcher 자동 선택.


turn() 코드 핵심 — tool call loop

for _ in 0..self.config.max_tool_iterations {
    // 1. history를 provider가 이해할 수 있는 형식으로 변환
    let messages = self.tool_dispatcher.to_provider_messages(&self.history);

    // 2. LLM 호출
    let response = self.provider.chat(
        ChatRequest {
            messages: &messages,
            tools: if self.tool_dispatcher.should_send_tool_specs() {
                Some(&self.tool_specs)
            } else {
                None
            },
        },
        &effective_model,
        self.temperature,
    ).await?;

    // 3. 응답 파싱 → (text, tool_calls)
    let (text, calls) = self.tool_dispatcher.parse_response(&response);

    // 4. tool call 없으면 종료
    if calls.is_empty() {
        // history에 추가, trim, 반환
        return Ok(final_text);
    }

    // 5. tool 실행
    let results = self.execute_tools(&calls).await;

    // 6. 결과 history에 추가
    self.history.push(ConversationMessage::AssistantToolCalls { ... });
    self.history.push(self.tool_dispatcher.format_results(&results));
    self.trim_history();
    // 루프 계속
}

// max_tool_iterations 초과
anyhow::bail!("Agent exceeded maximum tool iterations ({})", ...)

병렬 tool 실행

async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
    if !self.config.parallel_tools {
        // 직렬: 순서대로 하나씩
        for call in calls { results.push(self.execute_tool_call(call).await); }
        return results;
    }
    // 병렬: join_all로 동시 실행
    futures_util::future::join_all(calls.iter().map(|c| self.execute_tool_call(c))).await
}

parallel_tools = false가 기본값. 일정 관리 프로그램처럼 VRAM이 하나인 경우엔 직렬이 맞아.


Memory 통합

매 turn마다 관련 기억을 컨텍스트로 주입:

// user message 앞에 관련 메모리 자동 삽입
let context = memory_loader.load_context(memory, user_message, session_id).await;

let enriched = if context.is_empty() {
    format!("[{now}] {user_message}")
} else {
    format!("{context}[{now}] {user_message}")
};

Query Classification — 모델 라우팅

키워드 기반으로 어떤 모델로 보낼지 결정:

[query_classification]
enabled = true

[[query_classification.rules]]
hint = "fast"
keywords = ["quick", "brief", "tldr"]
priority = 10
// "quick summary please" 입력 시
effective_model = "hint:fast"  → anthropic/claude-haiku-4-5 로 라우팅

가벼운 질문은 haiku, 복잡한 작업은 sonnet으로 자동 분리 가능.


History 관리

ConversationMessage 3가지 변형:
  Chat(ChatMessage)                          → 일반 대화 (system/user/assistant)
  AssistantToolCalls { text, tool_calls, reasoning_content }
                                             → LLM의 tool call 요청
  ToolResults(Vec<ToolResultMessage>)        → tool 실행 결과

trim_history(): max_history_messages 초과 시 system 메시지 보존하고 오래된 것 삭제.


전체 데이터 흐름 다이어그램

사용자 입력
    │
    ▼
memory.recall() → 관련 기억 검색
    │
    ▼
[timestamp + context] + user_message → history
    │
    ▼
┌──────────────────────────────────────┐
│  tool call loop (max N회)            │
│                                      │
│  dispatcher.to_provider_messages()   │
│    → Vec<ChatMessage>                │
│            │                         │
│            ▼                         │
│  provider.chat(messages, tools?)     │
│    → ChatResponse                    │
│            │                         │
│            ▼                         │
│  dispatcher.parse_response()         │
│    → (text, Vec<ParsedToolCall>)     │
│            │                         │
│    tool calls 없음 ──→ 최종 반환 ✅  │
│            │                         │
│            ▼                         │
│  tool.execute(args) × N              │
│    → Vec<ToolExecutionResult>        │
│            │                         │
│  dispatcher.format_results()         │
│    → history에 push                  │
│            │                         │
│    trim_history() → 루프 계속       │
└──────────────────────────────────────┘

내 프로젝트 적용

// 학교 크롤러 tool 추가 예시
struct FetchAssignmentTool;

#[async_trait]
impl Tool for FetchAssignmentTool {
    fn name(&self) -> &str { "fetch_assignments" }
    // ...
}

// config.toml
// [agent]
// max_tool_iterations = 20
// parallel_tools = false      ← VRAM 하나라서
// tool_dispatcher = "auto"    ← llama.cpp면 xml, claude면 native 자동 선택

관련