- Published on
BabyAGI 도구루프 무한반복 차단 - Rust+WASM 샌드박스
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 BabyAGI 스타일 에이전트를 운영하다 보면 가장 먼저 터지는 이슈가 도구루프(tool loop) 입니다. 모델이 search나 browser 같은 도구를 반복 호출하며 전진하지 못하거나, 동일한 입력을 살짝 바꿔 재시도하면서 비용과 시간을 태웁니다. 더 심각한 경우 외부 API를 과도하게 두드려 레이트리밋을 유발하고, 워커가 점유되어 전체 큐가 밀립니다.
이 글은 Rust로 정책(guard)을 구현하고, 도구 실행을 WASM 샌드박스에 가둬 무한반복을 구조적으로 차단하는 방법을 제안합니다. 핵심은 “프롬프트로 훈육”이 아니라 실행 계층에서 강제하는 안전장치입니다.
왜 BabyAGI에서 무한반복이 생기나
에이전트 루프는 대개 다음 패턴을 갖습니다.
- 목표(goal)와 현재 상태(state)를 모델에 제공
- 모델이
next_action을 결정 tool_call이면 도구 실행 후 결과를 다시 모델에 제공final이면 종료
문제는 2~3이 수렴 조건 없이 돌아갈 때입니다. 대표 원인:
- 관찰(observation) 품질 부족: 도구 결과가 길거나 노이즈가 많아 모델이 핵심 신호를 못 잡음
- 동일 실패의 재시도: 네트워크 오류, 인증 실패, 429 등에서 “다시 해보자”가 반복
- 보상 구조의 결함: “도구를 호출하면 뭔가 진전”이라는 잘못된 휴리스틱
- 상태 업데이트 부재: 도구 결과를 요약/구조화하지 않아 상태가 사실상 안 바뀜
특히 429나 외부 API 제한이 걸리면 “잠깐 기다리고 다시 호출”을 무한히 하기도 합니다. 이 부분은 토큰버킷 등으로 상위 레벨에서 같이 잡아야 합니다. 관련해서는 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기도 함께 보는 것을 권장합니다.
목표: 프롬프트가 아니라 런타임에서 차단
무한반복 방지의 정답은 단순합니다.
- 예산(budget): 호출 횟수, 시간, 비용, 토큰, 외부 API 쿼터
- 진전(progress): 상태가 실제로 개선되는지 판정
- 정책(policy): 반복 패턴을 탐지하고 강제 중단/완화
- 격리(isolation): 도구 실행이 시스템을 망치지 못하게 샌드박싱
여기서 Rust+WASM을 쓰는 이유는 다음과 같습니다.
- Rust는 정책 엔진을 안전하고 빠르게 구현하기 좋음(메모리 안전, 성능)
- WASM은 도구 실행을 권한 제한된 VM 안에 넣어 폭주/탈주를 막기 좋음
- “도구 실행”을 프로세스/컨테이너로 격리하는 방법도 있지만, WASM은 더 가볍고 시작이 빠름
아키텍처 개요
구성 요소를 세 덩어리로 나눕니다.
- Orchestrator(호스트): 에이전트 루프, LLM 호출, 상태 저장
- Guard(정책 엔진, Rust): 루프 중단 조건, 반복 탐지, 예산 관리
- Tool Sandbox(WASM 런타임): 실제 도구 실행(HTTP 호출, 파싱, 계산 등)
흐름은 다음과 같습니다.
- Orchestrator가 모델 응답을 받음
tool_call이면 Guard에pre_check요청- Guard가 예산/반복/진전 여부를 보고
allow또는deny allow이면 WASM 샌드박스에서 도구 실행- 실행 결과를 Guard에
post_observe로 전달해 상태/지표 업데이트
Guard 설계: “반복”을 정량화하기
반복은 감각이 아니라 지표로 잡아야 합니다. 추천하는 최소 지표는 아래 4가지입니다.
1) Step budget
- 최대 스텝 수
max_steps - 동일 도구 연속 호출
max_same_tool_streak
2) Time budget
- 에이전트 전체 TTL
agent_ttl_ms - 도구 실행 타임아웃
tool_timeout_ms
3) Cost/Quota budget
- LLM 토큰 상한, 외부 API 호출 상한
- 특정 도구별 쿼터(예:
search는 10회,browser는 5회)
4) Progress budget(핵심)
“진전이 없는 반복”을 잡기 위해 상태를 해시로 정규화합니다.
state_digest = hash(normalize(state))- 매 스텝마다
digest가 바뀌지 않으면no_progress_count++ no_progress_count가 임계치 초과 시 중단
여기서 normalize가 중요합니다. 예를 들어 “현재까지의 결론 요약” 같은 짧은 필드만 추출하고, 타임스탬프/랜덤/긴 로그는 제거해야 합니다.
Rust 정책 엔진 예시
아래는 매우 단순화한 Guard 예시입니다. 실제로는 도구별 정책과 사용자별 쿼터, 작업 우선순위 등을 더합니다.
use std::collections::HashMap;
use blake3::Hasher;
#[derive(Clone, Debug)]
pub struct Budgets {
pub max_steps: u32,
pub max_no_progress: u32,
pub max_same_tool_streak: u32,
pub agent_ttl_ms: u64,
}
#[derive(Clone, Debug)]
pub struct GuardState {
pub steps: u32,
pub no_progress: u32,
pub same_tool_streak: u32,
pub last_tool: Option<String>,
pub last_digest: Option<[u8; 32]>,
pub started_at_ms: u64,
pub tool_counts: HashMap<String, u32>,
}
#[derive(Clone, Debug)]
pub enum GuardDecision {
Allow,
Deny { reason: String },
}
fn digest_state(normalized: &str) -> [u8; 32] {
let mut hasher = Hasher::new();
hasher.update(normalized.as_bytes());
*hasher.finalize().as_bytes()
}
pub fn pre_check(
budgets: &Budgets,
st: &mut GuardState,
now_ms: u64,
tool_name: &str,
) -> GuardDecision {
st.steps += 1;
if st.steps > budgets.max_steps {
return GuardDecision::Deny { reason: "max_steps exceeded".into() };
}
if now_ms.saturating_sub(st.started_at_ms) > budgets.agent_ttl_ms {
return GuardDecision::Deny { reason: "agent ttl exceeded".into() };
}
if st.last_tool.as_deref() == Some(tool_name) {
st.same_tool_streak += 1;
} else {
st.same_tool_streak = 1;
st.last_tool = Some(tool_name.to_string());
}
if st.same_tool_streak > budgets.max_same_tool_streak {
return GuardDecision::Deny { reason: "same tool streak exceeded".into() };
}
let c = st.tool_counts.entry(tool_name.to_string()).or_insert(0);
*c += 1;
GuardDecision::Allow
}
pub fn post_observe(
budgets: &Budgets,
st: &mut GuardState,
normalized_state: &str,
) -> GuardDecision {
let d = digest_state(normalized_state);
if st.last_digest.as_ref() == Some(&d) {
st.no_progress += 1;
} else {
st.no_progress = 0;
st.last_digest = Some(d);
}
if st.no_progress > budgets.max_no_progress {
return GuardDecision::Deny { reason: "no progress".into() };
}
GuardDecision::Allow
}
포인트는 post_observe에서 정규화된 상태 문자열만 받는다는 점입니다. 이 문자열을 어떻게 만들지(요약/구조화)가 곧 품질입니다.
WASM 샌드박스: 도구 실행을 안전하게 가두기
WASM 샌드박스의 목표는 3가지입니다.
- 도구 코드가 무한루프에 빠져도 호스트가 죽지 않게
- 파일 시스템, 프로세스, 네트워크 등 권한을 최소화
- 실행 시간/메모리/호출을 측정하고 제한
런타임으로는 wasmtime가 무난합니다. 네트워크는 기본적으로 막고, 필요한 경우에만 호스트 함수를 통해 제한적으로 제공합니다(예: 특정 도메인만 허용, 요청당 타임아웃 강제).
Wasmtime로 실행 제한 걸기(연료 fuel)
Wasmtime는 연료 기반으로 실행량을 제한할 수 있습니다. 아래 예시는 개념 코드입니다.
use wasmtime::*;
pub fn run_tool_wasm(wasm_bytes: &[u8], fuel: u64) -> anyhow::Result<i32> {
let mut config = Config::new();
config.consume_fuel(true);
let engine = Engine::new(&config)?;
let module = Module::new(&engine, wasm_bytes)?;
let mut store = Store::new(&engine, ());
store.add_fuel(fuel)?;
let instance = Instance::new(&mut store, &module, &[])?;
let func = instance.get_typed_func::<(), i32>(&mut store, "run")?;
let result = func.call(&mut store, ())?;
Ok(result)
}
- 연료를 다 쓰면 트랩이 나고 실행이 중단됩니다.
- CPU 타임아웃은 연료와 별개로, 호스트 레벨에서 추가로 걸어주는 게 안전합니다.
호스트 함수로 제한된 네트워크 제공
도구가 HTTP가 필요하다면 WASM 안에서 마음대로 소켓을 열게 하지 말고, 호스트가 제공하는 함수로만 요청하게 하세요.
- 허용 도메인 allowlist
- 요청당 타임아웃
- 응답 바이트 상한
- 동시 요청 수 제한
이 방식은 “도구가 루프를 돌며 외부 API를 두드리는 행위”를 정책적으로 차단하기 쉬워집니다.
루프 차단 전략: “중단”만이 답은 아니다
무한반복을 감지했을 때 항상 즉시 실패로 끝내면 UX가 나빠질 수 있습니다. 운영에서는 보통 3단계로 처리합니다.
- 완화(mitigate): 관찰을 요약해서 다시 제공, 도구 결과를 압축
- 강제 전환(divert): 다른 도구로 유도하거나, 플랜 재수립 단계로 이동
- 중단(abort): 예산 초과/반복 확정이면 종료
예를 들어 no_progress가 2~3회 쌓이면 아래처럼 “재계획” 프롬프트를 강제할 수 있습니다.
- 지금까지 시도 요약
- 실패 원인 후보 나열
- 다음 스텝은
tool_call금지, 텍스트로만 해결책 제시
이때도 중요한 건 “프롬프트로만 해결”이 아니라, Guard가 다음 액션 타입을 제한하는 식으로 강제하는 것입니다.
상태 정규화(normalization) 실전 팁
no progress 판정의 성패는 정규화에 달려 있습니다.
- 도구 결과 원문 전체를 상태에 넣지 말고,
facts,open_questions,next_steps같은 구조로 변환 - URL, 타임스탬프, 랜덤 ID는 제거하거나 마스킹
- 긴 텍스트는 모델이든 규칙이든 요약하되, 요약도 매번 흔들리지 않게 템플릿화
예시(상태 스키마):
{
"goal": "...",
"facts": ["...", "..."],
"constraints": ["..."],
"attempts": [
{"tool": "search", "query": "...", "result": "no relevant"}
],
"decision": "need different approach"
}
이 JSON을 직렬화한 문자열을 normalized_state로 쓰면, “실제로 내용이 바뀌었는지”를 안정적으로 감지할 수 있습니다.
운영 관점: 장애로 번지기 전에 관측하라
도구루프는 애플리케이션 버그이면서 동시에 인프라 장애의 트리거입니다.
- 워커가 점유되어 큐 적체
- 외부 API 429로 연쇄 실패
- 메모리 증가 또는 OOM
컨테이너 환경이라면 반복 폭주가 OOMKilled로 이어지기도 합니다. 이때는 애플리케이션 레벨(Guard)과 인프라 레벨(리소스 제한/오토스케일)을 함께 봐야 합니다. 관련해서는 EKS Pod CrashLoopBackOff? OOMKilled 5분 진단도 참고하면 좋습니다.
최소한 아래 메트릭은 반드시 남기세요.
- 에이전트별
steps,same_tool_streak,no_progress - 도구별 호출 횟수/실패율/타임아웃
- 중단 사유(
max_steps,no_progress,ttl,rate_limited) - 사용자/테넌트별 비용 지표
테스트 전략: 재현 가능한 “루프 시나리오” 만들기
무한반복은 재현이 어려워 보이지만, 의도적으로 만들 수 있습니다.
- 항상 동일한 결과를 반환하는 더미 도구(진전 없음)
- 429를 계속 반환하는 더미 HTTP
- 응답이 매우 긴 HTML(요약 실패 유도)
그리고 Guard가 기대대로 동작하는지 단위 테스트로 박아두세요.
#[test]
fn denies_on_no_progress() {
let budgets = Budgets {
max_steps: 100,
max_no_progress: 2,
max_same_tool_streak: 100,
agent_ttl_ms: 60_000,
};
let mut st = GuardState {
steps: 0,
no_progress: 0,
same_tool_streak: 0,
last_tool: None,
last_digest: None,
started_at_ms: 0,
tool_counts: Default::default(),
};
assert!(matches!(post_observe(&budgets, &mut st, "state-a"), GuardDecision::Allow));
assert!(matches!(post_observe(&budgets, &mut st, "state-a"), GuardDecision::Allow));
assert!(matches!(post_observe(&budgets, &mut st, "state-a"), GuardDecision::Deny { .. }));
}
정리: Rust+WASM로 “도구는 실행되되, 폭주하지 않게”
BabyAGI 도구루프 무한반복은 프롬프트 튜닝만으로는 재발합니다. 안정적인 해법은 다음 조합입니다.
- Rust Guard로
step,time,quota,progress예산을 강제 - 상태 정규화 + 해시로 “진전 없음”을 정량 판정
- WASM 샌드박스로 도구 실행을 격리하고 연료/타임아웃/권한을 제한
- 운영 메트릭으로 중단 사유를 관측해 정책을 계속 보정
이 구조를 깔아두면, 모델이 어떤 행동을 하더라도 시스템은 “유한한 비용과 시간” 안에서만 움직입니다. 에이전트는 똑똑해질 수 있지만, 서비스는 반드시 예측 가능해야 합니다.