ouroboros · 멀티 백엔드 프로바이더 어댑터 / 런타임 추상화

한 줄 요약

ouroboros는 위층이 보는 인터페이스를 딱 1개로 고정하고, 그 아래에 Claude·Codex·Gemini 같은 백엔드마다 “번역기”를 둬서, 설정 한 줄만 바꾸면 어떤 모델 위에서도 같은 워크플로가 돌게 한다. → 왜 배우나: 특정 모델/CLI에 묶이지 않는 시스템의 핵심 패턴(계약 1개 + 번역기 N개)을 익히기 위해서다.

그림

flowchart TD
    A["위층 워크플로<br/>(인터뷰 · Seed추출 · 평가)"] -->|"통일 메시지 + 요청설정"| B["어댑터 생성<br/>create_llm_adapter()"]
    B -->|"백엔드 이름 정규화<br/>resolve_llm_backend()"| C{"백엔드 분기<br/>(factory)"}
    C -->|claude_code| D1["ClaudeCodeAdapter<br/>SDK 호출"]
    C -->|codex| D2["CodexCliLLMAdapter<br/>codex exec 서브프로세스"]
    C -->|gemini| D3["GeminiCLIAdapter<br/>+ 이벤트 번역기"]
    C -->|litellm| D4["LiteLLMAdapter<br/>(완성 전용 API)"]
    D1 --> E["통일 응답<br/>또는 안전한 에러(Result.err)"]
    D2 --> E
    D3 --> E
    D4 --> E
    E --> A

쉽게 풀기

비유: 다국어 콜센터

상담원(위층 코드)은 한국어만 한다. 그런데 전화는 영어·일본어·중국어로도 온다. 상담원이 모든 언어를 배우는 대신 언어별 통역사를 두면, 상담원은 늘 한국어로 말하고 통역사가 알아서 번역한다. 상대가 어느 나라 사람인지조차 신경 쓸 필요가 없다. ouroboros가 정확히 이 구조다.

  • 상담원 = 위층 워크플로 → “메시지 보내고 답 받기”만 안다
  • 통역사 = 어댑터 → 백엔드마다 1개, 실제 번역 담당
  • 각 나라 고객 = 백엔드 → Claude Code, Codex, Gemini, Copilot, Goose, OpenCode, Hermes, Kiro, pi, gjc, litellm…
flowchart LR
    U["위층(상담원)<br/>한국어만"] -->|통일 메시지| AD["어댑터(통역사)"]
    AD -.번역.-> B1["Claude"]
    AD -.번역.-> B2["Codex"]
    AD -.번역.-> B3["Gemini"]
    B1 & B2 & B3 -.응답 번역.-> AD --> U

1단계 — 위층은 “계약” 하나만 안다

위층이 아는 건 하나다. “메시지 묶음과 설정을 주면 답을 돌려준다.” 이 약속이 **Protocol(계약)**이다. 답이 Claude SDK에서 왔는지, codex exec에서 왔는지, Gemini JSON 스트림에서 왔는지 위층은 모른다.

2단계 — 백엔드를 바꾸려면 설정 한 줄

llm.backend 값 한 줄만 고치면 갈아탄다. 새 CLI가 등장해도 통역기 파일 하나만 추가하면 끝, 위층 코드는 손대지 않는다.

3단계 — 계약은 사실 두 종류

일의 성격이 달라 약속도 둘이다.

  • 완성(completion) 계약 = “한 번 묻고 한 번 답하기”(단답형). → LLMAdapter
  • 에이전트 런타임 계약 = “도구를 써가며 여러 턴 일하고 진행 상황을 실시간 스트리밍”. → AgentRuntime

4단계 — 정직하게 능력을 선언한다

모든 백엔드가 같은 기능을 지원하진 않는다. 어떤 CLI는 “도구 제한”을 진짜 강제(하드)하지만, 어떤 CLI는 프롬프트로 “제발 이 도구만”이라 부탁만 한다(소프트). ouroboros는 이 차이를 숨기지 않고 능력 카탈로그에 적어 둬, 위층이 “이 백엔드선 이게 보장 안 됨”을 알게 한다.

핵심 정리

구성요소역할파일
LLMAdapter 계약한 번 묻고 한 번 답하기(완성)providers/base.py
AgentRuntime 계약도구 쓰며 스트리밍orchestrator/adapter.py
factory백엔드 이름 → 구체 어댑터 선택providers/factory.py
능력 카탈로그백엔드별 별칭·능력 단일 진실원backends/capabilities.py
이벤트 번역기CLI 원시출력 → 내부 이벤트 dictgemini_event_normalizer.py 등

능력 선언 3단계 ParamSupport

RuntimeCapabilities는 백엔드가 무엇을 네이티브로 지원하나를 명시한다. 각 항목은 NATIVE(진짜 지원) / TRANSLATED(우회 번역) / IGNORED(무시)로 정직하게 표기. 예: Codex는 시스템 프롬프트를 user 메시지에 합쳐 넣으므로 TRANSLATED.

실제 예시

모든 어댑터가 따르는 단 하나의 계약

# 파일경로: src/ouroboros/providers/base.py
class LLMAdapter(Protocol):
    async def complete(
        self,
        messages: list[Message],
        config: CompletionConfig,
    ) -> Result[CompletionResponse, ProviderError]:
        ...

→ 핵심: 재시도는 내부 처리, 예상 가능한 실패는 예외 대신 Result.err(ProviderError)로 돌려준다. 위층이 에러를 받아도 프로그램은 멈추지 않는다.

요청 한 건이 흐르는 길

위층은 Message 리스트 + CompletionConfig만 만들어 await adapter.complete(...)를 부른다. 그 뒤 어댑터 내부에서 일어나는 일은 백엔드마다 다르다.

sequenceDiagram
    participant U as 위층 워크플로
    participant A as 어댑터
    participant B as 백엔드 CLI/SDK
    U->>A: complete(messages, config)
    A->>A: system 분리 + 백엔드 형태로 번역
    A->>B: 실행(SDK query / codex exec …)
    alt 성공
        B-->>A: 원시출력(텍스트/JSONL)
        A->>A: 통일 응답으로 포장(토큰 못 얻으면 0)
        A-->>U: Result.ok(CompletionResponse)
    else 실패
        B-->>A: 예외/빈응답/타임아웃
        A-->>U: Result.err(ProviderError)
    end

백엔드 이름 정규화 (factory)

# 파일경로: src/ouroboros/providers/factory.py
def resolve_llm_backend(backend: str | None = None) -> str:
    candidate = (backend or get_llm_backend()).strip().lower()
    try:
        resolved = resolve_llm_backend_name(candidate)
    except ValueError as exc:
        raise ValueError(f"Unsupported LLM backend: {candidate}") from exc
    return "claude_code" if resolved == "claude" else resolved

백엔드의 진짜 능력을 정직하게 선언

# 파일경로: src/ouroboros/orchestrator/codex_cli_runtime.py
@property
def capabilities(self) -> RuntimeCapabilities:
    # Codex는 system 프롬프트·도구 가이드를 user 메시지에 합쳐 넣으므로
    # 네이티브 파라미터가 아닌 TRANSLATED로 표기
    return replace(
        FULL_CAPABILITIES,
        system_prompt_support=ParamSupport.TRANSLATED,
        tool_restriction_support=ParamSupport.TRANSLATED,
    )

CLI 원시출력 → 내부 이벤트로 번역

각 CLI 출력(평문/NDJSON)을 내부 이벤트 dict로 바꾼다. 보장 키 5개: type, content, raw, is_error, metadata.

# 파일경로: src/ouroboros/providers/gemini_event_normalizer.py
def normalize_line(self, raw_line: str) -> dict[str, Any]:
    stripped = raw_line.strip()
    if stripped.startswith("{") or stripped.startswith("["):
        return self._normalize_json_line(stripped, original=raw_line)
    return self._normalize_text_line(stripped, original=raw_line)

요약 & 셀프체크

3줄 요약

  1. 위층은 “메시지 보내고 답 받기” 계약 하나만 알고, 백엔드별 차이는 어댑터(통역사)가 흡수한다.
  2. 계약은 두 종류 — 한 번에 답하는 완성(LLMAdapter)과 도구 쓰며 스트리밍하는 에이전트 런타임(AgentRuntime).
  3. 백엔드마다 능력이 다르므로(하드/소프트 강제, envelope 등) 능력 카탈로그에 정직하게 선언하고, 새 백엔드는 “계약 구현 + 카탈로그 등록 + factory 분기”로 추가한다.

스스로 답해보기

  • 백엔드를 갈아탈 때 무엇만 바꾸면 되고, 위층 코드는 왜 손댈 필요가 없을까?
  • 완성 계약과 에이전트 런타임 계약을 굳이 나눈 이유는?
  • complete()가 실패 시 예외 대신 Result.err을 돌려주도록 한 이유는?

연결

OB_개요 · _분석축_루브릭 · OB_40_orchestrator-execution-loop · OB_20_spec-engine-seed-and-double-diamond