LLM 파이프라인을 위한 상태 유지형 프로바이더 폴백(Stateful provider fallback): FSM 패턴
요약
다단계 LLM 파이프라인에서 프로바이더 장애 시 상태를 유지하며 폴백을 구현하는 FSM 패턴을 소개합니다. llm-nano-vm을 활용하여 예외가 아닌 도구 결과로서 실패를 처리하는 방법과 구현 시 주의할 버그를 다룹니다.
핵심 포인트
- 단일 요청 단위의 게이트웨이 폴백과 다단계 파이프라인의 차이점 설명
- FSM(상태 머신) 내에서 실패를 도구(TOOL) 결과로 처리하여 분기 구현
- llm-nano-vm 사용 시 비동기 실행(async) 처리의 중요성
- AST 엔진의 조건문 내 문자열 리터럴 평가 오류 주의
게이트웨이 수준의 LLM 폴백 (LiteLLM, Bifrost, Kong AI Gateway)은 개별 HTTP 요청 단위로 작동합니다. 한 프로바이더(provider)에 대한 요청이 실패하면, 게이트웨이는 다른 프로바이더를 대상으로 재시도합니다. 작업 단위가 단일 완료 호출(single completion call)인 경우에는 이것이 적절한 도구입니다.
하지만 작업 단위가 다단계 파이프라인(multi-step pipeline)인 경우에는 적절한 도구가 아닙니다. 게이트웨이는 "3단계 중 2단계"라는 개념이 없기 때문입니다. 게이트웨이는 요청을 볼 뿐, 상태 머신(state machine) 내의 위치를 인식하지 못합니다.
이 포스트에서는 llm-nano-vm 0.8.6을 사용하여 명시적인 FSM 전이(transition)로서 프로바이더 폴백을 구현하는 과정을 살펴봅니다. 여기에는 실제 패키지(모의 객체가 아닌)를 사용하면서 마주친 두 가지 버그에 대한 내용도 포함되어 있습니다.
문제 정의 (Problem statement)
3단계 파이프라인:
collect_application → verify_income → policy_decision
verify_income은 LLM을 호출합니다. 파이프라인 중간에 LLM 프로바이더가 사용 불가능해질 수 있습니다. 우리는 파이프라인이 다른 프로바이더를 통해 완료되기를 원하며, 영수증(Receipt, nano-vm의 결정론적 실행 후 아티팩트)에 정확히 어떤 일이 일어났는지 표시되기를 원합니다.
메커니즘: 예외(exception)가 아닌 도구(TOOL) 결과로서의 실패
llm-nano-vm의 네이티브 LLM 단계(step) 유형은 실패 시 분기점(branch point)을 제공하지 않습니다. 어댑터(adapter)에서 오류가 발생하면 해당 단계는 FAILED로 표시되고 트레이스(trace)가 중단됩니다. 분기를 생성하려면, 예외를 포착(catch)하고 센티널 값(sentinel value)을 반환하는 TOOL 단계 내에 LLM 호출을 작성해야 합니다:
async def attempt_llm_step(**kwargs):
step_id = kwargs["step_id"]
try:
...
그러면 FSM 프로그램은 해당 센티널을 기준으로 분기합니다:
Step(
id="try_s2",
type=StepType.TOOL,
...
이것이 핵심 메커니즘입니다. 프로바이더의 실패는 런타임이 전파하는 예외가 아니라, FSM이 평가하는 하나의 값이 됩니다.
버그 #1: ExecutionVM.run은 비동기(async)입니다
README를 대충 훑어본다면 놓치기 쉽습니다. vm.run()은 Trace가 아니라 코루틴 (coroutine)을 반환합니다. 해결 방법은 최상위 수준에서 asyncio.run(vm.run(program, context=...))를 사용하는 것이며, LLM 어댑터를 호출하는 모든 도구 (tool) 함수는 async def로 정의해야 합니다. ExecutionVM은 도구별로 inspect.iscoroutinefunction(fn)을 확인하여 그에 따라 await를 수행합니다.
버그 #2: ASTEngine 조건문에서 문자열 리터럴(string literals)이 작동하지 않음
우리의 첫 번째 조건문 버전은 다음과 같았습니다:
condition="try_s2.output == 'PROVIDER_FAILED'"
이것은 오류 없이 파싱됩니다. 하지만 항상 False로 평가됩니다. 엔진을 직접 테스트하여 이를 확인했습니다:
from nano_vm.vm import eval_condition
ctx = {"try_s2": {"output": "PROVIDER_FAILED"}}
eval_condition("try_s2.output == 'PROVIDER_FAILED'", ctx)
...
llm-nano-vm의 ASTEngine (v0.8.6)은 ==, !=, >, <, in, not_in, and, or, not, contains를 지원하지만, 비교 연산의 우항 (right-hand side)은 따옴표로 묶인 문자열 리터럴이 아니라 숫자 또는 $var 참조여야 합니다. 작동하는 패턴은 숫자 센티널 (numeric sentinel)을 사용하는 것입니다:
condition="$provider_ok < 1"
이 사항은 이제 단순한 구전 지식이 아니라 프로젝트의 엄격한 제약 사항으로 문서화되었습니다.
두 가지 실패 시나리오
python receipt_demo.py --failure-mode retry # 3번의 시도 동안 단계적으로 성능이 저하된 후 전환됨
python receipt_demo.py --failure-mode hard # 한 번 실패하면 즉시 전환됨
hard 모드의 출력:
S2 verify_income
EVENT: ProviderUnavailable (CLAUDE)
ACTION: switch_provider claude → gpt
...
두 시나리오 모두에서 trace_hash가 동일한 이유
trace_hash는 단계별 결과의 머클 체인 (Merkle chain)에 대한 SHA-256 값입니다. retry와 hard 모드 모두 정확히 동일한 FSM 경로를 탐색합니다. 재시도 루프 (retry loop)가 attempt_llm_step 도구 (TOOL) 내부에 포함되어 있기 때문에, FSM은 어떤 경우에도 단 하나의 도구 단계 결과만을 보게 됩니다. 동일한 경로 → 동일한 해시 (hash). 이것은 우연히 설명해야 할 현상이 아니라 구조적 특성입니다. 만약 경로가 갈라졌다면 해시도 달라졌을 것입니다.
현재의 한계
- 폴백 체인(Fallback chain)은 점수가 매겨지거나 순위가 지정된 선택지가 아니라 고정된 목록(
claude → gpt → qwen)입니다. - 활성 상태 확인 폴링(Active health-check polling)이 없습니다. Bifrost가 명시한 ~11μs 오버헤드 수준의 활성 탐지(active detection)와 달리, 실패는 시도 시에만 감지됩니다.
- 데모의
MockAdapter는 실제 프로바이더 API를 호출하지 않습니다. 이는 API 키 없이도 데모를 재현할 수 있도록 설계상 결정론적(deterministic)으로 동작합니다.
이것이 대체하는 것이 아니라, 무엇과 결합되는가
LiteLLM과 같은 게이트웨이는 여전히 HTTP 계층에서 모델 라우팅(model routing), 속도 제한(rate limiting), 비용 추적(cost tracking)을 담당합니다. 이 FSM 패턴은 파이프라인 상태를 인식하는(pipeline-state-aware) 폴백을 담당합니다. 즉, "프로바이더가 중단되었을 때 파이프라인이 무엇을 하고 있었는가, 그리고 완료되었는가?"라는 질문에 답합니다. 이 둘은 서로 다른 계층이며, 동일한 질문에 대한 경쟁적인 해답이 아닙니다.
Repo: provider-fallback-demo
pip install "llm-nano-vm[litellm]"
python receipt_demo.py --both
다음 단계: switch_provider를 OpenTelemetry 스팬(span)으로 방출하여, Receipt JSON에서만 보이는 대신 기존 대시보드에 나타나도록 하는 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기