본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 15. 14:18

BoxAgnts 도구 시스템 (6) — 멀티 프로바이더 적응 및 에이전트 쿼리 루프

요약

BoxAgnts의 도구 시스템이 다양한 AI 모델 벤더 간의 API 호환성 문제를 해결하는 방법을 다룹니다. Provider 추상화 계층과 Transformer를 통해 Anthropic, OpenAI, Gemini 등 서로 다른 API 형식을 통합 관리하는 구조를 설명합니다.

핵심 포인트

  • Provider 추상화 계층을 통해 LLM 벤더 간 API 형식 차이 해결
  • LlmProvider 트레이트를 활용한 비동기 스트림 통합 관리
  • Transformer를 통한 벤더별 메시지 형식 변환 자동화
  • 새로운 모델 추가 시 확장성이 용이한 구조 설계

최하위 레벨의 WASM 샌드박스(sandbox)부터 최상위 레벨의 Tool 트레이트(trait)에 이르기까지, BoxAgnts의 도구 시스템은 "도구를 어떻게 안전하게 실행할 것인가"라는 문제를 해결했습니다. 하지만 도구는 궁극적으로 AI 모델에 의해 호출되어야 하며, 이는 두 가지 엔지니어링 문제, 즉 AI 벤더 간의 API 형식이 완전히 호환되지 않는다는 점과 대화 흐름(conversation flow) 및 도구 실행의 교차 오케스트레이션(interleaved orchestration) 문제를 야기합니다. 이 두 문제는 각각 Provider 추상화 계층과 Agent 쿼리 루프(query loop)를 통해 해결됩니다.

Provider 추상화: LLM 벤더에 구애받지 않기

다양한 유형의 AI 모델 API는 요청 형식(request format), 응답 형식(response format), 그리고 에러 핸들링(error handling) 방식에서 크게 다릅니다.

요청 측면부터 살펴보겠습니다. Anthropic은 역할을 userassistant로 나누고 시스템 프롬프트(system prompt)를 독립적인 최상위 system 필드로 분리합니다. OpenAI는 시스템 프롬프트를 role: "system" 메시지로 취급하며, Google Gemini는 system_instruction을 요청 본문(request body)의 최상위에 두지만 또 다른 형식을 사용합니다. 만약 상위 계층의 Agent 루프가 이러한 차이점을 직접 처리해야 한다면, 코드는 거대한 match provider_id { ... } 분기문이 될 것입니다.

BoxAgnts의 솔루션은 세 가지 추상화 계층을 도입합니다:

계층 1: ProviderRequest / ProviderResponse 통합 데이터 모델

// provider_types.rs
pub struct ProviderRequest {
    pub messages: Vec<ApiMessage>,
...

Agent 루프는 오직 이 두 구조체만 다루며, 사용자가 Anthropic을 설정했는지 또는 OpenAI를 설정했는지 알 필요가 없습니다.

계층 2: LlmProvider 트레이트 (trait)

pub trait LlmProvider: Send + Sync {
    fn id(&self) -> &ProviderId;
    async fn create_message_stream(
...

create_message_streamPin<Box<dyn Stream>>을 반환합니다. 이는 여러 스트림(stream) 유형을 통합하기 위한 Rust 비동기(async) 생태계의 표준 관용구입니다 (Java의 Stream<T> 또는 Python의 AsyncIterator와 유사함). 각 Provider 구현체는 내부적으로 자체적인 HTTP 요청 생성, 인증, 그리고 SSE 파싱(parsing)을 처리하며, 외부에는 통합된 StreamEvent를 노출합니다.

Layer 3: Transformer (메시지 형식 변환)

Transformer는 벤더(vendor) 간의 형식 차이를 제거하는 "라스트 마일(last mile)"을 처리합니다:

// transformers/anthropic.rs
pub fn to_anthropic_request(req: &ProviderRequest) -> AnthropicMessagesRequest { ... }

...

Transformer는 순수 함수(pure functions)입니다. 즉, 통합된 형식을 입력받아 벤더 형식으로 출력합니다. 새로운 Provider를 추가하려면 새로운 Transformer와 그에 상응하는 LlmProvider 구현체만 추가하면 됩니다. 공유된 ProviderRegistry는 Provider ID를 통해 구현체를 조회합니다:

pub struct ProviderRegistry {
    providers: HashMap<ProviderId, Arc<dyn LlmProvider>>,
    default_provider_id: ProviderId,
...

스트리밍 프로토콜 및 SSE 파싱 (Parsing)

모든 Provider의 스트리밍 상호작용은 SSE (Server-Sent Events)에 의존합니다. 하지만 각 벤더의 SSE 이벤트 세분성(granularity)과 의미론(semantics)은 서로 다릅니다:

  • Anthropic의 content_block_start / content_block_delta / content_block_stop은 3단계 이벤트 계층 구조를 형성합니다. 단일 ContentBlock은 시작부터 종료까지 여러 개의 SSE 메시지에 걸쳐 존재합니다.
  • OpenAI의 choices[0].delta는 명시적인 블록 시작/종료가 없는 평면적인 델타(flat delta) 형태입니다.
  • Google Gemini는 자체적인 스트리밍 형식을 가진 gRPC-web 프로토콜을 사용합니다.

BoxAgnts의 stream_parser 모듈은 이러한 모든 차이점을 소화하여 통합된 StreamEvent 열거형(enum)을 노출합니다:

pub enum StreamEvent {
    TextDelta { text: String },
    ToolUseStart { id: String, name: String },
...

각 Provider의 스트림 파서는 내부적으로 유한 상태 머신(finite state machine)으로 동작합니다. Anthropic을 예로 들면 다음과 같습니다:

Wait for message_start
  │
  ├── message_start ──► extract model, initial usage
...

StreamAccumulator는 현재 메시지의 모든 ContentBlock 상태를 유지합니다:

pub struct StreamAccumulator {
    text_blocks: Vec<TextBlock>,
    tool_use_blocks: HashMap<String, ToolUseBlock>,
...

MessageStop이 도착하면, finish() 함수가 축적된 모든 블록을 하나의 완전한 Message로 조립하며, stop_reason과 최종 UsageInfo를 반환합니다.

에이전트 쿼리 루프 (The Agent Query Loop)

스트림 파서(stream parser)가 SSE 이벤트를 구조화된 Message로 변환했습니다. 다음으로, query::run_query_loop()가 이 Message를 도구 시스템(tool system)으로 전달합니다.

핵심 흐름:

loop {
    // 1. 메시지 히스토리 + 시스템 프롬프트 (system Prompt) + 도구 목록 (tool list)을 AI 모델로 전송
    let request = CreateMessageRequest::builder(model, max_tokens)
...

주의 깊게 살펴볼 몇 가지 세부 사항:

도구 목록 주입 전략 (Tool list injection strategy). 각 API 호출 라운드마다 전체 도구 목록(모든 도구의 이름, 설명, 그리고 input_schema)을 tools 필드에 담아 AI 모델로 전송합니다. 이는 고정된 토큰 오버헤드(token overhead)를 발생시킵니다. 즉, 도구가 많아질수록 라운드당 "도구 설명 토큰 (tool description tokens)"이 증가합니다. 도구가 20개를 초과하면 이 오버헤드는 상당해집니다 (라운드당 잠재적으로 수천 개의 토큰). BoxAgnts의 현재 전략은 전체 주입(full injection) 방식이며, 향후 Anthropic의 tool_choice와 유사한 도구 선택 및 그룹화 메커니즘 도입을 고려하고 있습니다.

MaxTokens 복구 (MaxTokens recovery). 모델이 응답 중간에 출력 토큰 제한(output token limit)을 소진하더라도, 이는 진정한 의미의 "실패"가 아니라 단지 말을 다 마치지 못한 상태입니다. BoxAgnts는 모델이 계속 진행할 수 있도록 복구 메시지("Output token limit hit. Resume directly...")를 자동으로 주입합니다. 이 루프는 최대 3회까지 실행됩니다. 만약 3번의 시도 후에도 여전히 max_tokens에 도달한다면, 해당 작업은 실제로 너무 긴 것이므로 시스템은 포기하고 부분적인 결과(partial results)를 반환합니다.

취소 메커니즘 (Cancellation mechanism). CancellationToken은 tokio 생태계에서 빌려온 것입니다. 사용자가 프론트엔드에서 "중지 (Stop)" 버튼을 클릭하면, WebSocket 핸들러가 해당 토큰을 취소하며, run_query_loop는 다음 체크 시점에 QueryOutcome::Cancelled를 반환합니다.

비용 추적 (Cost tracking). 각 API 호출 라운드가 끝난 후, CostTracker는 현재 모델의 가격을 누적합니다 (입력/출력 토큰별로 별도 산정되며, 모델마다 가격이 다릅니다). 누적 비용이 budget_limit_usd를 초과하면 QueryOutcome::BudgetExceeded가 반환됩니다. 비용 정보는 WebSocket을 통해 프론트엔드 대시보드(Dashboard)로 실시간 전송됩니다.

에러 핸들링 및 재시도 전략 (Error Handling and Retry Strategy)

AI API 호출에는 몇 가지 전형적인 실패 모드(failure modes)가 있습니다:

에러 유형 (Error Type)일반적인 HTTP 코드전략 (Strategy)
속도 제한 (Rate Limit)429지수 백오프 (Exponential backoff) 재시도, Retry-After 헤더 준수
...

지수 백오프 (Exponential backoff)는 Duration에 곱셈을 적용하여 1s → 2s → 4s → 8s 간격의 인터벌을 사용합니다. 529 (Overloaded)의 경우, 모델 전환 (model switching)이 추가로 지원됩니다. 사용자가 폴백 모델 (fallback model)을 설정한 경우 (예: claude-sonnet-4-5 과부하 시 claude-haiku-4-5로 전환), 후속 호출은 자동으로 폴백 모델을 사용합니다.

프로바이더 확장성 (Provider Extensibility)

새로운 프로바이더 (Provider)를 추가하는 단계는 명확합니다:

  1. providers/ 아래에 새로운 모듈을 추가하고, LlmProvider 트레이트 (trait)를 구현합니다.
  2. 해당하는 트랜스포머 (Transformer)를 구현합니다 (형식 변환이 필요한 경우).
  3. registry.rsprovider_from_key()에 등록합니다.
  4. model_registry.rs에 해당 프로바이더의 지원 모델 목록을 추가합니다.

openai_compat_providers 모듈은 지름길 역할을 합니다. OpenAI API 형식을 사용하는 서비스 (DeepSeek, OpenCode, 다양한 국내 모델 등)의 경우, API 베이스 URL (base URL)과 API 키 설정만 필요하며, 프로바이더 코드를 별도로 작성할 필요가 없습니다. 이러한 서비스들은 동일한 OpenAI 호환 SSE 파서 (SSE parser)와 요청 빌더 (Request builder)를 공유하며, 오직 설정값만 다릅니다.

// 설정 예시
"deepseek": {
    "provider_id": "deepseek",
...

요약 (Summary)

프로바이더 (Provider) 추상화와 에이전트 쿼리 루프 (Agent query loop)는 BoxAgnts 도구 시스템의 "엔진"을 구성합니다:

  • **Provider 추상화 (Provider abstraction)**는 3단계 디커플링(ProviderRequest/Response 통합 데이터 모델 → LlmProvider trait → Transformer 형식 변환)을 통해 12개의 AI API를 통합하는 문제를 해결합니다. 새로운 Provider를 추가하려면 trait 구현과 등록만 수행하면 됩니다. 공유된 SSE 파서(parser)와 Request 빌더(builder)는 openai_compat 모듈을 통해 통합 비용을 더욱 절감합니다.

  • **에이전트 쿼리 루프 (Agent query loop)**는 SSE 상태 머신(state machine) 파싱, ToolUse 탐지, 도구 디스패치(dispatch), 그리고 결과 피드백의 폐쇄 루프(closed loop)를 통해 대화와 도구 실행의 인터리브된 오케스트레이션(interleaved orchestration)을 달성합니다. MaxTokens 자동 복구(최대 3회 시도) 및 지수 백오프(exponential backoff) 재시도 전략은 긴 작업에 대한 신뢰성을 보장합니다.

  • 이 두 계층의 공통된 특징은 **의존성 역전 (dependency inversion)**입니다. 에이전트 루프는 특정 AI 벤더에 의존하지 않으며, Provider 구현은 특정 대화 오케스트레이션 로직에 의존하지 않습니다. 모든 결합은 trait 인터페이스를 통해 디커플링됩니다.

비용 추적 (CostTracker + AtomicF64) 및 취소 메커니즘 (CancellationToken)은 프로덕션 환경에 필요한 운영 가시성(observability)과 사용자 제어 기능을 제공합니다.

참고 문헌 (References)

참고 문헌 (References)

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0