지식위키

FableLayer Router — 비용 인식 Sonnet/Opus 라우팅

한 줄 요약

작업 신호 6개 중 하나라도 켜지면 기본 sonnetopus로 상향하는, 결정론적이고 비용을 의식한 모델 등급 선택기다. 왜 배우나: “어떤 작업에 비싼 모델을 붙일지”를 사람 감이 아니라 명시적 규칙으로 자동화하면서, 라우팅이 모델 능력을 전이시키는 것이 아니라는 한계를 코드 레벨에서 못 박는 패턴을 익히기 위해서다.

그림

flowchart TD
    A["TaskSpec 입력<br/>kind, dependency_depth, files_touched,<br/>deep_reasoning, context_pct,<br/>gate_failures, explicit_opus"] --> B["_fired_signals(task)<br/>6개 신호를 순서대로 평가"]
    B --> C{"발화한 신호가<br/>하나라도 있나?"}
    C -->|"있음 (fired 비어있지 않음)"| D["model = opus<br/>reason = escalated to opus by: ..."]
    C -->|"없음 (fired 비어있음)"| E["model = sonnet<br/>reason = default sonnet; no escalation"]
    D --> F["note 에 항상<br/>capability not transferable 부착"]
    E --> F
    F --> G["RouteDecision<br/>model, fired_signals, note 반환"]
flowchart LR
    S1["dep_depth>=3"] --> OR{"OR<br/>하나라도 True"}
    S2["deep_reasoning"] --> OR
    S3["files>=3"] --> OR
    S4["context_pct>=0.8"] --> OR
    S5["gate_failures>=2"] --> OR
    S6["explicit_opus"] --> OR
    OR -->|True| OPUS["opus 상향"]
    OR -->|False| SON["sonnet 유지"]

쉽게 풀기

이 라우터는 병원 응급실의 분류(triage) 간호사와 같다. 들어오는 환자(작업)를 보고 “일반 진료실로 보낼지, 중환자실로 보낼지”를 정해진 체크리스트로 판단한다.

  • 기본값은 일반 진료실(sonnet). 싸고 빠르니 대부분의 작업은 여기로 간다.
  • 체크리스트에는 6개 항목이 있다. 예를 들어 “건드리는 파일이 3개 이상인가”, “문맥 창이 80% 이상 찼는가”, “검증 게이트를 2번 이상 실패했는가” 같은 위험 신호다.
  • 이 6개 중 단 하나라도 해당되면 곧장 중환자실(opus, 더 비싸고 강한 모델)로 올려보낸다. 여러 개 켜져도 행동은 같다. “하나라도 켜지면 상향”이라는 OR 규칙이다.
  • 특별 항목 하나가 더 있다: explicit_opus. 사용자가 “무조건 중환자실”이라고 직접 지정한 경우다. 이건 다른 신호와 무관하게 항상 상향을 유지한다(강등 불가).

가장 중요한 비유: 이 분류 간호사는 “환자를 중환자실로 보내는 것”이지, “일반 진료실 의사에게 중환자실 의사의 실력을 옮겨주는 것”이 아니다. 라우팅은 어떤 모델을 쓸지 고를 뿐, 모델의 능력 자체를 다른 모델로 전이시키지 못한다. 그래서 모든 결정에 “capability not transferable”이라는 경고 문구가 항상 붙는다.

핵심 정리

신호 이름트리거 조건의미
dep_depth>=3dependency_depth >= 3의존성 깊이가 깊은 작업
deep_reasoningdeep_reasoning == True깊은 추론이 필요하다고 표시됨
files>=3files_touched >= 3건드리는 파일이 많음
context_pct>=0.8context_pct >= 0.8문맥 창 80% 이상 사용
gate_failures>=2gate_failures >= 2검증 게이트 2회 이상 실패
explicit_opusexplicit_opus == True사용자가 opus 직접 지정
항목
기본 모델sonnet (DEFAULT_MODEL)
상향 모델opus (ESCALATED_MODEL)
상향 규칙6개 신호 중 1개 이상 발화 시 (OR)
결정성ESCALATION_SIGNALS 정의 순서대로 평가 → fired_signals 순서 고정

[!note]- 반환 객체 RouteDecision 의 3개 필드

  • model: 선택된 모델 등급 문자열 ("sonnet" 또는 "opus")
  • fired_signals: 발화한 신호 이름들의 튜플. 정의 순서대로 결정론적. 아무것도 안 켜지면 빈 튜플.
  • note: 사람이 읽는 설명 + 항상 "capability not transferable: routing selects model tier, it does not transfer capability across models." 포함.

[!note]- 설계상 핵심 불변식 3가지

  • 결정론: 같은 TaskSpec 입력 → 항상 같은 RouteDecision. 신호 평가는 부수효과 없는 순수 predicate.
  • 단조 상향(비강등): explicit_opus가 켜지면 다른 신호가 어떻든 opus 유지. route 함수 자체가 신호 발화 시 무조건 opus이므로 “켜진 신호를 끄는” 경로가 없다.
  • 능력 비전이: note 문구가 코드 상수 CAPABILITY_NOTE로 박혀 있어 모든 결정에 강제 부착. 라우팅을 능력 향상 수단으로 오해하는 것을 차단.

실제 예시

# fablelayer/router.py — 신호 정의와 평가 (발췌)
DEFAULT_MODEL = "sonnet"
ESCALATED_MODEL = "opus"
CAPABILITY_NOTE = "capability not transferable"

DEP_DEPTH_THRESHOLD = 3
FILES_TOUCHED_THRESHOLD = 3
CONTEXT_PCT_THRESHOLD = 0.8
GATE_FAILURES_THRESHOLD = 2

# 6개 상향 predicate — 하나라도 True 면 opus
ESCALATION_SIGNALS = (
    _Signal("dep_depth>=3", lambda t: t.dependency_depth >= DEP_DEPTH_THRESHOLD),
    _Signal("deep_reasoning", lambda t: bool(t.deep_reasoning)),
    _Signal("files>=3", lambda t: t.files_touched >= FILES_TOUCHED_THRESHOLD),
    _Signal("context_pct>=0.8", lambda t: t.context_pct >= CONTEXT_PCT_THRESHOLD),
    _Signal("gate_failures>=2", lambda t: t.gate_failures >= GATE_FAILURES_THRESHOLD),
    _Signal("explicit_opus", lambda t: bool(t.explicit_opus)),
)

def _fired_signals(task: TaskSpec) -> tuple[str, ...]:
    # 정의 순서대로 결정론적으로 발화 신호 반환
    return tuple(sig.name for sig in ESCALATION_SIGNALS if sig.predicate(task))
# fablelayer/router.py — 라우팅 본체 (발췌)
def route(task: TaskSpec) -> RouteDecision:
    fired = _fired_signals(task)
    if fired:
        model = ESCALATED_MODEL
        reason = "escalated to opus by: " + ", ".join(fired)
    else:
        model = DEFAULT_MODEL
        reason = "default sonnet; no escalation signal fired"
    note = f"{reason}. {CAPABILITY_NOTE}: routing selects model tier, it does not transfer capability across models."
    return RouteDecision(model=model, fired_signals=fired, note=note)
# 사용 예 — 직접 호출 시나리오
# 1) 가벼운 작업: 신호 0개 → sonnet 유지
route(TaskSpec(kind="edit"))
#   -> RouteDecision(model="sonnet", fired_signals=(), note="default sonnet; ... capability not transferable: ...")

# 2) 큰 변경: files_touched=5 → files>=3 발화 → opus
route(TaskSpec(kind="refactor", files_touched=5))
#   -> RouteDecision(model="opus", fired_signals=("files>=3",), note="escalated to opus by: files>=3. ...")

# 3) 복합 신호: 깊은 추론 + 게이트 실패 누적
route(TaskSpec(kind="debug", deep_reasoning=True, gate_failures=3))
#   -> fired_signals=("deep_reasoning", "gate_failures>=2"), model="opus"

참고: 소스에는 모듈 docstring이 “FL3/FL4 Smart Model Router”로 표기되며, 임계값들은 “INTERFACE 계약”이라 주석되어 외부와 합의된 고정 상수임을 명시한다.

요약 & 셀프체크

3줄 요약:

  • 기본 sonnet, 6개 위험 신호 중 하나라도 켜지면 opus로 상향하는 OR 기반 결정론적 라우터다.
  • explicit_opus는 강등 불가 신호이고, 발화 신호는 ESCALATION_SIGNALS 정의 순서로 결정론적으로 기록된다.
  • 모든 결정에 “capability not transferable” 문구를 강제해, 라우팅이 모델 능력을 전이시키지 않는다는 한계를 못 박는다.

질문:

  1. files_touched=2, context_pct=0.85인 TaskSpec을 라우팅하면 model과 fired_signals는 각각 무엇인가? (힌트: 파일 신호는 미발화, 문맥 신호는 발화)
  2. 같은 입력을 두 번 route 했을 때 결과가 항상 동일하다고 보장하는 코드상 근거 두 가지는?
  3. 라우터가 note에 CAPABILITY_NOTE를 항상 붙이는 설계 의도는 무엇이며, 어떤 오해를 막으려는 것인가?

연결

FL_개요 · _분석축_루브릭