라우터의 fallback 은 "실패하면 다른 모델로 넘긴다" 로 요약된다. 그런데 이 문장은 요청이 실패로 끝났을 때만 성립한다. LLM 응답은 대부분 streaming 으로 나간다. 사용자 화면에 토큰이 이미 흐르고 있는 상태에서 제공자가 오류를 내면 넘길 곳이 없다. 정확히는 넘길 수는 있는데, 넘긴 결과가 앞에 나간 것과 이어지지 않는다. 이 글은 그...
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 에 미해결 이슈가 여럿 열려 있다.
마지막이 가장 위험하다. 실패를 성공으로 세면 그 지표로 정책을 판단할 수 없다. 모델을 골라주는 계층이 대신 가져오는 제약에서 집계 지표가 문제를 알아채는 역할을 한다고 적었는데, 그 지표 자체가 틀리면 알아챌 방법이 없어진다.
직접 만들 때도 이것을 하나의 검증 항목으로 둔다. mid-stream 에서 끊긴 요청이 성공 카운터에 들어가지 않는지를 확인한다. streaming 은 응답을 열 때 성공으로 기록하기 쉬운 구조라 이 실수가 나온다.
정상 종료는 data: [DONE] 한 줄로 마친다. 이건 관행으로 굳었다.
중간 오류는 다르다.
OpenAI 공식 문서에 고정 스키마가 없다.
커뮤니티 스레드에 직원이 "특수 케이스로 처리 중이며 향후 status 를 더할 수도 있다" 고 답한 정도다.
실무에서 관찰되는 형태가 둘이다.
| 형태 | 모양 |
|---|---|
| error 객체만 보낸다 | data: {"error": {"message": ..., "type": ..., "code": ...}} |
| 정상 종료 형태로 마친다 | 표준 chunk 스키마에 error 를 얹고 finish_reason: "error" 로 끝낸다 |
어느 형태든 HTTP 상태 코드는 200 인 채로 본문 안에서만 오류를 알린다. 헤더가 이미 나갔기 때문이다.
라우터를 만들 때 이 사실이 두 곳에 영향을 준다.
OpenAI 가 mid-stream 오류 스키마를 공식화했는지, LiteLLM 외의 다른 구현이 mid-stream fallback 을 지원하는지는 확인하지 못했다.
streaming 응답에는 "응답이 끝나지 않았다" 와 "응답이 멈췄다" 를 가르는 기준이 필요하다.
| 방식 | 정의 |
|---|---|
| 토큰 간격 | 내용을 담은 청크 사이의 간격을 잰다. 내용 없는 청크는 타이머를 되돌리지 않는다 |
| 전체 시간 | 요청을 연 시점부터 응답이 끝날 때까지 잰다. 토큰이 계속 나와도 한도에 걸리면 끊는다 |
표준이 없고 구현마다 다르다. LLM Gateway 는 전체 시간 기준으로 non-streaming 10분, streaming 20분을 준다. 어느 방식이 더 널리 쓰이는지는 확인하지 못했다.
토큰 간격 방식에서 "내용 없는 청크는 타이머를 되돌리지 않는다" 는 조건이 중요하다. 연결을 유지하기 위해 빈 청크를 보내는 제공자가 있어서, 그것을 진행으로 세면 응답이 멈춘 상태를 영원히 감지하지 못한다.
LiteLLM 에는 stream_timeout 이 첫 청크에 적용되지 않는 이슈가 있다.
지연된 응답을 받은 뒤에야 timeout 이 난다.
첫 토큰 전이 유일하게 다른 모델로 넘길 수 있는 구간인데, 그 구간의 timeout 이 동작하지 않으면 경계 앞에서 대응할 기회를 놓친다.
SSE 표준에 방법이 있다.
연결이 끊기면 브라우저가 재연결하며 Last-Event-ID 헤더를 붙이고, 서버가 그 값으로 어디부터 재개할지 판단한다.
표준이 지원한다는 것과 LLM 서버가 구현했다는 것은 다르다. 실제로 이어받으려면 서버에 별도 구현이 필요하다.
구현 방식이 둘 관찰된다.
뒤의 방식이 흥미로운 것은 모델을 다시 부르지 않는다는 점이다. 연결이 끊긴 것과 생성이 멈춘 것을 분리해, 생성은 계속 진행하고 버퍼에 쌓아 둔다. 클라이언트가 돌아오면 못 받은 부분부터 다시 보낸다.
이 방식으로 해결되는 것은 네트워크 단절이다. 제공자가 오류를 낸 경우는 버퍼에 이어질 내용이 없으므로 여전히 위의 선택지 셋으로 돌아간다.
OpenAI 나 Anthropic 이 이 재개를 API 차원에서 제공하는지는 확인하지 못했다. 확인한 사례는 모두 라우터나 미들웨어에서 직접 만든 것이다.
이 글의 내용을 라우터 설계 항목으로 옮기면 넷이 된다.
| 항목 | 정할 것 |
|---|---|
| 첫 토큰 전 장애 | 다른 모델로 넘긴다. 사용자에게 아무것도 노출되지 않았으므로 이 구간을 최대한 넓게 쓴다 |
| 첫 토큰 후 장애 | 끊거나, 이어 쓰거나, 처음부터 다시 한다. 셋 중 무엇인지 미리 정한다 |
| 결정 기록 | 어느 쪽을 골랐는지와 어느 모델이 얼마나 생성했는지를 남긴다 |
| 집계 정합성 | mid-stream timeout 이 성공으로 집계되지 않는 것을 확인한다 |
첫 항목의 "구간을 넓게 쓴다" 는 실제 설정으로 옮길 수 있다. 첫 청크에 적용되는 timeout 을 짧게 두면 응답이 느린 제공자를 경계 앞에서 걸러낸다. 그 값을 길게 두면 넘길 수 있는 구간을 스스로 좁힌다.
MidStreamFallbackError 로 이 시점을 오류 타입에 담았다.prefix 이어쓰기를 지원해야 한다.Last-Event-ID 로 이어받는 것은 표준에 있지만 서버 구현이 따로 필요하다. 확인한 사례는 모두 라우터나 미들웨어에서 직접 만든 것이다.다음 글에서는 여기까지 정한 것을 실제 설정으로 옮긴다.
LiteLLM proxy 의 routing_strategy 다섯, 오류 종류로 나뉜 fallback 세 키, cooldown 과 오류 유형별 임계치, 예산 계층이 주제다.
LiteLLM 로 라우팅 정책을 설정하는 방법으로 이어진다.