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 자동 선택
관련
- trait-system — Tool trait 원형
- provider-implementations — Provider + ReliableProvider
- config-schema — agent 설정
- overview — ZeroClaw 학습 지도