LLM이 if/else 로직을 할 필요가 없다. 더 현명한 방법이다.

발행: (2026년 6월 18일 PM 10:06 GMT+9)
7 분 소요
원문: Dev.to

출처: Dev.to

Ⅰ. 비싼 if/else 구문

LLM은未知한 상황을 매우 잘 처리합니다. 예상치 못한 엣지 케이스를 주면 정확하게 추론해 줍니다. 이것이 진정한 가치입니다.

문제는 대부분의 트래픽이 예측 가능한 상황이라는 점입니다. 결제가 세 번째 재시도에 실패했거나, VIP 고객의 분쟁 청구, 0.9 위험 점수로 표시된 주문이 있습니다. 이러한 경우는 이미 어떻게 처리해야 할지 알고 있습니다. 하지만 아키텍처가 모든 것을 LLM에 라우팅한다면, 인퍼런스 비용을 전부 지불 해야 합니다.

from pydantic_ai import Agent
from enum import Enum

class Action(Enum):
    FLAG_FRAUD = "flag_fraud"
    OPEN_DISPUTE = "open_dispute"
    SCHEDULE_RETRY = "schedule_retry"
    ALERT_ACCOUNT_MANAGER = "alert_account_manager"
    STANDARD_PROCESSING = "standard_processing"

agent = Agent(
    "openai:gpt-4o-mini",
    output_type=Action,
    system_prompt="Decide what action to take on this order event.",
)

async def handle(event: dict) -> Action:
    result = await agent.run(str(event))
    return result.output

Enter fullscreen mode

Exit fullscreen mode

이 코드는 동작합니다. 하지만 입력 데이터가 이미 명확히 결정된 경우에 LLM 비용을 전부 부담한다는 점이 문제입니다.

The obvious fix — an if/elif pre‑filter — creates a different problem.

It works until you have 15 conditions, three engineers with conflicting opinions, a silent ordering bug, and tests that only verify outputs, not the logic structure itself. The spaghetti grows fast.

What you actually need

is a layer that:

  • codifies what you already know as explicit, deterministic rules — zero API cost, microsecond latency

  • routes only the genuinely unknown cases to the LLM

  • stays auditable, testable, and readable as it grows

That’s exactly what airules is built for. By the end of this article you’ll have a working hybrid system that reserves the LLM for what it’s actually good at — and handles everything else for free.


II. 핵심 아키텍처: Facts, Rules, Engines

Why type safety matters here

if/elif 체인의 문제점은 가독성만이 아닙니다. event["risk_score"]는 타입이 지정되지 않아 키 누락 시 런타임에 KeyError가 발생하고, 필드 이름 오타는 프로덕션까지 감지되지 않을 수 있습니다. 또한 문자열을 float와 비교하는 실수도 발생합니다.

airules정적 타입을 결정 로직에 적용합니다. IDE가 나쁜 필드 참조를 잡아내고, Pyright가 타입 불일치를 표시합니다. 규칙 집합은 사람이 읽는 것이 아니라 도구가 검사할 수 있는 형태가 됩니다.

Step 1: Define a Fact (your typed input schema)

from typing import Literal
from airules import Fact, Field, NumberField

OrderStatus = Literal["placed", "payment_failed", "fulfilled", "disputed", "refunded"]
CustomerTier = Literal["standard", "vip", "wholesale"]

class OrderEvent(Fact):
    status: Field[OrderStatus]
    amount_usd: NumberField[float]
    customer_tier: Field[CustomerTier]
    retry_count: NumberField[int]
    risk_score: NumberField[float]   # 0.0–1.0 from fraud detection service

Fact는 Pydantic 모델이나 dataclass가 아니라 목적에 맞춘 기술자이며, 각 필드 타입(NumberField, Field, ListField)이 청구식(predicate) 빌더 역할을 합니다. 이렇게 하면 OrderEvent.risk_score.ge(0.85)와 같이 first‑class 객체 형태로 비교를 쓸 수 있습니다.

Step 2: Build the engine

from airules import KnowledgeEngine, Rule, Default

class OrderRouter(KnowledgeEngine[OrderEvent, Action]):

    # High risk score is an unconditional block — checked first
    @Rule(OrderEvent.risk_score.ge(0.85))
    def high_risk(self, event: OrderEvent) -> Action:
        return Action.FLAG_FRAUD

    # A disputed charge always opens a case, regardless of other signals
    @Rule(OrderEvent.status.eq("disputed"))
    def dispute(self, event: OrderEvent) -> Action:
        return Action.OPEN_DISPUTE

    # Failed payment with retries remaining → schedule another attempt
    @Rule(
        OrderEvent.status.eq("payment_failed")
         & OrderEvent.retry_count.lt(3)
    )
    def retry(self, event: OrderEvent) -> Action:
        return Action.SCHEDULE_RETRY

    # VIP customer exhausted retries → human touch, not automation
    @Rule(
        OrderEvent.status.eq("payment_failed")
         & OrderEvent.customer_tier.eq("vip")
         & OrderEvent.retry_count.ge(3)
    )
    def vip_payment_exhausted(self, event: OrderEvent) -> Action:
        return Action.ALERT_ACCOUNT_MANAGER

    # Fires only when nothing above matched
    @Default
    def fallback(self, event: OrderEvent) -> Action:
        return Action.STANDARD_PROCESSING

Enter fullscreen mode

Exit fullscreen mode

How execution flows

Rules evaluate top‑to‑bottom. The first match wins — no fall‑through, no ambiguity. @Default is always last, regardless of where you declare it.

router = OrderRouter()
router.run(OrderEvent(
    status="payment_failed",
    amount_usd=149.00,
    customer_tier="vip",
    retry_count=3,
    risk_score=0.2,
))
# → Action.ALERT_ACCOUNT_MANAGER (matched `vip_payment_exhausted`, stopped there)

Enter fullscreen mode

Exit fullscreen mode

The engine is Generic[OrderEvent, Action]run() returns Action | None, fully typed. No casting, no Any, no surprises.

Trade‑off to know: airules는 선언 순서를 기본 우선순위로 사용합니다. 두 규칙이 동일한 입력을 만족할 경우, 선언 순서가 승리합니다. 이는 명시적이고 예측 가능하지만, 규칙 정렬이 로드에 영향을 미치므로 데이터베이스 인덱스 정렬과 같은 주의가 필요합니다. 예시에서 high_riskdispute보다 먼저 선언되어야 고위험 분쟁 주문이 단순히 분쟁으로만 열리지 않도록 해야 합니다.


III. 직역 가능한 규칙: LLM도 읽을 수 있다

The problem with hardcoded logic

규칙이 오직 Python 소스에만 존재하면, resto of 시스템은它们를 알 수 없습니다. 데이터베이스는 규칙을 쿼리할 수 없고, 코드 리뷰 디프는 구문 변경만 보여줄 뿐 논리적 변화는 보여 주지 않습니다. 무엇보다 LLM이 규칙을 이해하고 추론할 수 없습니다.

Rules as data

airules의 청구식(predicate)은 평면 딕트로 직렬화됩니다:

p = (
    OrderEvent.status.eq("payment_failed")
     & OrderEvent.retry_count.lt(3)
)

p.to_dict()
# {
#    "op": "and",
#    "operands": [
#      {"op": "eq",   "field": "status",       "value": "payment_failed"},
#      {"op": "lt",   "field": "retry_count",  "value": 3}
#    ]
# }

# 라운드트립이 깔끔합니다
restored = Predicate.from_dict(p.to_dict())

One level up, describe()전체 규칙 집합을 출력합니다 — 모든 규칙, 그 청구식, 우선순위:

import json
print(json.dumps(OrderRouter.describe(), indent=2))
# {
#    "facts": [{"name": "OrderEvent", "fields": {
#        "status": "Field",
#        "amount_usd": "NumberField",
#        "customer_tier": "Field",
#        "retry_count": "NumberField",
#        "risk_score": "NumberField"
#    }}],
#    "rules": [
#      {"name": "high_risk",             "predicate": {...}, "priority": 5, "is_default": false},
#      {"name": "dispute",               "predicate": {...}, "priority": 4, "is_default": false},
#      {"name": "retry",                 "predicate": {...}, "priority": 3, "is_default": false},
#      {"name": "vip_payment_exhausted","predicate": {...}, "priority": 2, "is_default": false},
#      {"name": "fallback",              "predicate": null,   "priority": 0, "is_default": true}
#    ]
# }

Store it. Diff it in PRs. Feed it into a rules‑editor UI. Or — and this is the key move — pass it directly i (the original text cuts off here).

0 조회
Back to Blog

관련 글

더 보기 »

코드 리뷰가 잘못됐다

!Cover image for Code Review Gone Wronghttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Flavkesh.com%2F...