fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/mlops/첫 토큰이 나간 뒤에는 재시도할 수 없다
mlops

첫 토큰이 나간 뒤에는 재시도할 수 없다

라우터의 fallback 은 "실패하면 다른 모델로 넘긴다" 로 요약된다. 그런데 이 문장은 요청이 실패로 끝났을 때만 성립한다. LLM 응답은 대부분 streaming 으로 나간다. 사용자 화면에 토큰이 이미 흐르고 있는 상태에서 제공자가 오류를 내면 넘길 곳이 없다. 정확히는 넘길 수는 있는데, 넘긴 결과가 앞에 나간 것과 이어지지 않는다. 이 글은 그...

2026.09.08·8 min read·91 views·SERIES · Model Router · 5/8

Model Router 시리즈 5번이다. 앞 글 캐시를 보고 인스턴스를 고른다에서 아래 계층의 인스턴스 선택을 다뤘다. 이 글은 그 선택이 이미 끝난 뒤, 응답을 흘려보내는 중에 장애가 났을 때를 다룬다.

라우터의 fallback 은 "실패하면 다른 모델로 넘긴다" 로 요약된다. 그런데 이 문장은 요청이 실패로 끝났을 때만 성립한다.

LLM 응답은 대부분 streaming 으로 나간다. 사용자 화면에 토큰이 이미 흐르고 있는 상태에서 제공자가 오류를 내면 넘길 곳이 없다. 정확히는 넘길 수는 있는데, 넘긴 결과가 앞에 나간 것과 이어지지 않는다.

이 글은 그 경계가 어디이고 경계 뒤에 무엇이 남는지를 다룬다.

막히는 이유가 두 층에서 온다

층이유
HTTP상태 코드와 헤더를 200 으로 이미 보냈다. Transfer-Encoding: chunked 로 청크가 나가기 시작하면 그 이전으로 되돌릴 수 없다
모델temperature 와 top-p 샘플링 때문에 같은 입력으로도 매번 다른 출력이 나온다. 이어 쓰는 모델이 다르면 앞부분과 문체와 논리가 어긋난다

앞의 것은 프로토콜 제약이고 뒤의 것은 LLM 고유의 제약이다.

둘 중 하나만 있으면 우회할 수 있다. HTTP 제약만 있다면 서버가 응답을 버퍼에 모아 두고 다 끝난 뒤에 한 번에 보내면 된다. 대신 사용자가 첫 토큰을 볼 때까지 기다려야 하므로 streaming 을 쓰는 이유 자체가 없어진다. 모델 제약만 있다면 같은 모델로 다시 부르면 된다. 그런데 HTTP 제약 때문에 이미 나간 텍스트를 회수할 수 없다.

둘이 함께 있어서 "아무 일 없었던 것처럼" 다른 모델로 바꾸는 것이 불가능하다.

경계

구간가능한 것
요청 전송 후 첫 토큰 전전체를 다른 모델로 재시도할 수 있다. 사용자에게 아무것도 노출되지 않았다
첫 토큰 이후재시도가 아니라 이어쓰기이거나 중단이다

LiteLLM 이 이 경계를 코드로 구현하고 있다. MidStreamFallbackError 라는 별도 예외 타입을 두고, 첫 토큰 전후를 다른 fallback 경로로 처리한다.

예외 타입을 나눈 것 자체가 설계 판단이다. 같은 RateLimitError 라도 첫 토큰 전에 났는지 후에 났는지에 따라 할 수 있는 일이 다르므로, 오류 분류에 시점을 넣었다. 직접 라우터를 만들 때도 오류 타입에 이 시점을 담아야 상위 로직이 판단할 수 있다.

첫 토큰 이후의 선택지 셋

방법얻는 것치르는 것
끊고 오류를 드러낸다구현이 단순하고 응답 정합성이 깨지지 않는다생성된 부분이 버려지고 사용자가 다시 요청해야 한다
이어 쓴다이미 본 텍스트를 버리지 않고 체감 지연이 준다접두 메시지 토큰이 더 들고, 원래 system prompt 와 충돌할 수 있으며, 대상 모델이 이어쓰기를 지원해야 한다
버리고 처음부터전체 문체와 논리가 일관된다이미 보인 텍스트를 지워야 하고 토큰 비용이 두 배가 된다

첫 번째가 대부분의 OpenAI 호환 서버의 기본 동작이다. 두 번째를 LiteLLM 의 mid-stream fallback 이 쓴다. 세 번째를 프로덕션에서 채택했다는 공개 사례는 찾지 못했다.

세 번째가 공개 사례를 찾기 어려운 이유는 클라이언트 요건 때문으로 보인다. 이미 화면에 보인 텍스트를 지우려면 클라이언트가 그 동작을 지원해야 하고, 사용자는 글자가 나타났다 사라지는 것을 본다.

접두 메시지의 길이는 구현이 정한다. 토큰 100개 안팎이라는 값을 보긴 했는데 어느 구현의 값인지 확인하지 못해 기준으로 쓰지 않는다.

이어쓰기에는 조건이 하나 더 있다. 대상 모델이 prefix 이어쓰기를 지원해야 한다. 지원하지 않는 모델로 넘겨 실패한다는 보고가 있다. 이어쓰기를 선택지로 두려면 fallback 목록의 모델마다 이 지원 여부를 확인해야 한다.

기능이 있다는 것과 믿을 수 있다는 것이 다르다

LiteLLM 의 mid-stream fallback 에 미해결 이슈가 여럿 열려 있다.

  • sync streaming 에서 동작하지 않는다. 특히 응답을 늦게 여는 제공자에서
  • 429 가 mid-stream 에 나고 fallback 설정이 없으면 CPU 를 100% 쓰며 멈춘다
  • mid-stream timeout 이 실패로 기록되지 않고 성공으로 집계된다

마지막이 가장 위험하다. 실패를 성공으로 세면 그 지표로 정책을 판단할 수 없다. 모델을 골라주는 계층이 대신 가져오는 제약에서 집계 지표가 문제를 알아채는 역할을 한다고 적었는데, 그 지표 자체가 틀리면 알아챌 방법이 없어진다.

직접 만들 때도 이것을 하나의 검증 항목으로 둔다. mid-stream 에서 끊긴 요청이 성공 카운터에 들어가지 않는지를 확인한다. streaming 은 응답을 열 때 성공으로 기록하기 쉬운 구조라 이 실수가 나온다.

SSE 로 오류를 알리는 방식에 공식 규격이 없다

정상 종료는 data: [DONE] 한 줄로 마친다. 이건 관행으로 굳었다.

중간 오류는 다르다. OpenAI 공식 문서에 고정 스키마가 없다. 커뮤니티 스레드에 직원이 "특수 케이스로 처리 중이며 향후 status 를 더할 수도 있다" 고 답한 정도다.

실무에서 관찰되는 형태가 둘이다.

형태모양
error 객체만 보낸다data: {"error": {"message": ..., "type": ..., "code": ...}}
정상 종료 형태로 마친다표준 chunk 스키마에 error 를 얹고 finish_reason: "error" 로 끝낸다

어느 형태든 HTTP 상태 코드는 200 인 채로 본문 안에서만 오류를 알린다. 헤더가 이미 나갔기 때문이다.

라우터를 만들 때 이 사실이 두 곳에 영향을 준다.

  • 상태 코드로 성공 여부를 판정할 수 없다. 200 으로 끝난 응답 안에 오류가 들어 있을 수 있으므로 본문의 마지막 청크를 봐야 한다
  • 제공자마다 형태가 다르다. 여러 제공자를 하나의 인터페이스로 묶는 라우터가 이 차이를 흡수해야 한다

OpenAI 가 mid-stream 오류 스키마를 공식화했는지, LiteLLM 외의 다른 구현이 mid-stream fallback 을 지원하는지는 확인하지 못했다.

timeout 을 무엇으로 재는가

streaming 응답에는 "응답이 끝나지 않았다" 와 "응답이 멈췄다" 를 가르는 기준이 필요하다.

방식정의
토큰 간격내용을 담은 청크 사이의 간격을 잰다. 내용 없는 청크는 타이머를 되돌리지 않는다
전체 시간요청을 연 시점부터 응답이 끝날 때까지 잰다. 토큰이 계속 나와도 한도에 걸리면 끊는다

표준이 없고 구현마다 다르다. LLM Gateway 는 전체 시간 기준으로 non-streaming 10분, streaming 20분을 준다. 어느 방식이 더 널리 쓰이는지는 확인하지 못했다.

토큰 간격 방식에서 "내용 없는 청크는 타이머를 되돌리지 않는다" 는 조건이 중요하다. 연결을 유지하기 위해 빈 청크를 보내는 제공자가 있어서, 그것을 진행으로 세면 응답이 멈춘 상태를 영원히 감지하지 못한다.

LiteLLM 에는 stream_timeout 이 첫 청크에 적용되지 않는 이슈가 있다. 지연된 응답을 받은 뒤에야 timeout 이 난다. 첫 토큰 전이 유일하게 다른 모델로 넘길 수 있는 구간인데, 그 구간의 timeout 이 동작하지 않으면 경계 앞에서 대응할 기회를 놓친다.

끊긴 streaming 을 이어받기

SSE 표준에 방법이 있다. 연결이 끊기면 브라우저가 재연결하며 Last-Event-ID 헤더를 붙이고, 서버가 그 값으로 어디부터 재개할지 판단한다.

표준이 지원한다는 것과 LLM 서버가 구현했다는 것은 다르다. 실제로 이어받으려면 서버에 별도 구현이 필요하다.

구현 방식이 둘 관찰된다.

  • 응답에 세션 식별자를 주고 재연결 시 그 식별자로 재개를 요청한다
  • 이미 생성된 토큰을 수명과 크기 상한을 둔 버퍼에 담아 두고, 재연결 시 모델을 다시 부르지 않고 버퍼에서 다시 보낸다

뒤의 방식이 흥미로운 것은 모델을 다시 부르지 않는다는 점이다. 연결이 끊긴 것과 생성이 멈춘 것을 분리해, 생성은 계속 진행하고 버퍼에 쌓아 둔다. 클라이언트가 돌아오면 못 받은 부분부터 다시 보낸다.

이 방식으로 해결되는 것은 네트워크 단절이다. 제공자가 오류를 낸 경우는 버퍼에 이어질 내용이 없으므로 여전히 위의 선택지 셋으로 돌아간다.

OpenAI 나 Anthropic 이 이 재개를 API 차원에서 제공하는지는 확인하지 못했다. 확인한 사례는 모두 라우터나 미들웨어에서 직접 만든 것이다.

설계할 때 정해 둘 것 넷

이 글의 내용을 라우터 설계 항목으로 옮기면 넷이 된다.

항목정할 것
첫 토큰 전 장애다른 모델로 넘긴다. 사용자에게 아무것도 노출되지 않았으므로 이 구간을 최대한 넓게 쓴다
첫 토큰 후 장애끊거나, 이어 쓰거나, 처음부터 다시 한다. 셋 중 무엇인지 미리 정한다
결정 기록어느 쪽을 골랐는지와 어느 모델이 얼마나 생성했는지를 남긴다
집계 정합성mid-stream timeout 이 성공으로 집계되지 않는 것을 확인한다

첫 항목의 "구간을 넓게 쓴다" 는 실제 설정으로 옮길 수 있다. 첫 청크에 적용되는 timeout 을 짧게 두면 응답이 느린 제공자를 경계 앞에서 걸러낸다. 그 값을 길게 두면 넘길 수 있는 구간을 스스로 좁힌다.

정리

  • 첫 토큰이 나간 뒤 재시도가 막히는 이유가 두 층에서 온다. HTTP 는 헤더를 이미 보냈고, 모델은 샘플링 때문에 같은 출력을 다시 내지 않는다.
  • 첫 토큰 전은 다른 모델로 전체 재시도가 가능한 유일한 구간이다. LiteLLM 은 MidStreamFallbackError 로 이 시점을 오류 타입에 담았다.
  • 첫 토큰 후에 남는 것은 셋이다. 끊거나, 이어 쓰거나, 처음부터 다시 한다. 이어쓰기는 대상 모델이 prefix 이어쓰기를 지원해야 한다.
  • mid-stream timeout 이 성공으로 집계되는 문제가 실제로 보고돼 있다. 실패를 성공으로 세면 그 지표로 정책을 판단할 수 없다.
  • SSE 중간 오류에 공식 스키마가 없다. HTTP 상태 코드는 200 이므로 본문의 마지막 청크를 봐야 성공 여부를 안다.
  • SSE 의 Last-Event-ID 로 이어받는 것은 표준에 있지만 서버 구현이 따로 필요하다. 확인한 사례는 모두 라우터나 미들웨어에서 직접 만든 것이다.

다음 편

다음 글에서는 여기까지 정한 것을 실제 설정으로 옮긴다. LiteLLM proxy 의 routing_strategy 다섯, 오류 종류로 나뉜 fallback 세 키, cooldown 과 오류 유형별 임계치, 예산 계층이 주제다.

LiteLLM 로 라우팅 정책을 설정하는 방법으로 이어진다.

참고 링크

  • Chunked transfer encoding
  • OpenRouter Streaming 문서
  • OpenAI 커뮤니티. streaming 중 오류가 나면
  • LLM Gateway. Request Timeouts
  • Ably. Resume tokens and last-event IDs
  • Ably. Resumable LLM streaming
  • Idempotency Is Not Optional in LLM Pipelines
  • BerriAI/litellm 이슈 22296
  • BerriAI/litellm 이슈 26015
  • BerriAI/litellm 이슈 27967
  • BerriAI/litellm 이슈 29602
on this page
  • 01막히는 이유가 두 층에서 온다
  • 02경계
  • 03첫 토큰 이후의 선택지 셋
  • 04기능이 있다는 것과 믿을 수 있다는 것이 다르다
  • 05SSE 로 오류를 알리는 방식에 공식 규격이 없다
  • 06timeout 을 무엇으로 재는가
  • 07끊긴 streaming 을 이어받기
  • 08설계할 때 정해 둘 것 넷
  • 09정리
  • 10다음 편
  • 11참고 링크
tags
#Model Router#LLM 서빙#streaming#study📚 Model Router
← PREVIOUS캐시를 보고 인스턴스를 고른다NEXT →LiteLLM 로 라우팅 정책을 설정하는 방법

이런 글도

  • mlops

    같은 문제를 회사마다 다른 층에서 푼다

    2026.09.08
  • mlops

    캐시를 보고 인스턴스를 고른다

    2026.09.08
  • mlops

    어느 모델로 보낼지 무엇으로 정하는가

    2026.09.08
  • mlops

    모델을 골라주는 계층이 대신 가져오는 제약

    2026.09.08

댓글 (0)