HTTP 본문 종료가 답변 완료를 뜻하지 않는 이유
Chat Completions 스트림에서 HTTP 200을 받았더라도 답변 전체의 완료를 보장하지는 않습니다. 유효한 답변 조각이 일부 도착한 뒤 HTTP 본문이 예외 없이 끝나더라도, 완료 이벤트가 없으면 모델 응답의 정상 완료를 확인할 수 없습니다. TCP 연결이 끊기거나 HTTPX가 읽기 예외를 내는 경우와는 다른 실패 조건입니다.
기존 _stream_committed는 비동기 iterator가 정상 종료되면 바로 회로에 성공을 기록했습니다. 따라서 장애 후 복구를 확인하는 half-open 요청에서 같은 일이 발생하면, 잘린 응답뿐 아니라 로컬 서버의 호출 재개까지 잘못 판단할 수 있었습니다.
완료 이벤트 없는 복구 확인 요청의 판정 경계| 관찰 지점 | 기존 기준 | 변경한 기준 |
|---|
| 응답 시작 | 첫 유효 SSE 이벤트를 확보한 뒤 로컬 응답으로 확정 |
|---|
| HTTP 본문 종료 | iterator가 정상 종료되면 성공 | 완결된 [DONE] 수신까지 확인 |
|---|
| 종료 이벤트 누락 | 정상 마지막 body 전송·복구 성공 | StreamTruncatedError·복구 실패 기록 |
|---|
스트리밍은 유지하고 완료 증거만 별도 추적
- 답변 조각은 받은 순서대로 중계
전체 응답을 모으지 않고 확보한 초기 조각과 이후 조각을 그대로 전달합니다. 종료 추적기는 중계 바이트를 관찰하며, 응답 내용을 고치거나 다른 모델의 출력을 이어 붙이지 않습니다.
- 전송 청크와 SSE 이벤트 경계를 분리
[DONE] 문자열이나 CRLF가 여러 청크에 나뉘어 도착해도 같은 이벤트로 인식합니다. 빈 줄로 이벤트가 완결되기 전에는 종료로 인정하지 않으며, 미완결 라인·이벤트의 누적 크기는 64KiB 상한으로 제한합니다.
- 완료를 확인한 뒤에만 성공 기록
iterator의 정상 종료와 완료 이벤트 수신을 모두 확인한 뒤 record_success를 호출합니다. 종료 이벤트 누락은 실패로 기록하고 정상 마지막 body를 보내지 않아, 호출자가 불완전 전송을 감지할 수 있도록 합니다.
[DONE]은 이 게이트웨이가 중계하는 Chat Completions 응답의 계약이며, 모든 SSE 스트림에 공통으로 적용되는 종료 표준은 아닙니다. 종료 추적기는 후속 JSON의 모든 필드나 답변 품질을 검증하지 않고, 종료 이벤트 이후 추가 바이트를 차단하는 역할도 맡지 않습니다.
되돌릴 수 있는 시점까지만 모델 전환
- 첫 유효 이벤트 전
- 생성 요청의 로컬 장애가 폴백 대상이면 외부 모델을 시도합니다. 임베딩과 비폴백 4xx까지 외부로 재전송하는 정책은 아닙니다.
- 응답 시작 후
- 이미 전달한 로컬 답변을 다른 모델의 내용과 섞지 않습니다. 완료 이벤트 없이 끝나거나 전송 장애가 발생하면 HTTP 200을 뒤늦게 502로 바꾸거나 [DONE]을 합성하지 않고, 시작된 응답을 불완전 전송으로 끝냅니다.
- 회로 복구 판정
- half-open 복구 요청이 완료되지 않았다면 다시 OPEN으로 전환합니다. 일반 CLOSED 상태의 요청은 연속 실패 임계값을 따르므로, 모든 실패 한 번이 즉시 차단을 뜻하지는 않습니다.
- 취소·자원 정리
- 호출자 취소와 downstream 전송 실패는 로컬 추론 실패와 구분합니다. 완료하지 못한 probe 슬롯을 반환하고, 취소된 scope에서도 upstream 응답을 닫도록 정리 작업을 보호합니다.
완료 여부와 회로 상태를 함께 확인
2026.09 추가 재현에서는 실제 Uvicorn·HTTPX·ASGI 중계와 원본 회로 차단기를 사용했습니다. 로컬 HTTP 서버가 같은 답변 조각을 보내고 Content-Length에 맞춰 정상 종료하되 [DONE]만 생략하도록 했습니다. 현재 소스에서 중계 메서드 하나만 이전 구현으로 복원해, 같은 입력을 전후 각 5회 비교했습니다.
완료 이벤트 누락 · 전후 각 5회 동일 결과| 검증 항목 | 변경 전 | 변경 후 |
|---|
| HTTP 시작 상태 | 200 | 200 |
|---|
| 전달한 응답 | 122 bytes · 같은 답변 조각과 SHA-256 |
|---|
| 정상 마지막 body | 전송 | 미전송 |
|---|
| completed 기록 | true | false |
|---|
| HTTPX 수신 결과 | 정상 종료 | RemoteProtocolError |
|---|
| half-open 이후 | CLOSED | OPEN |
|---|
| 응답 시작 후 폴백 | 0회 | 0회 |
|---|
정상 [DONE] 대조도 전후 각 5회 실행해 양쪽 모두 정상 완료·회로 복구를 확인했습니다. LF·CR·CRLF에 걸친 종료 이벤트 분할, 마지막 빈 줄 누락, 첫 유효 이벤트 전후의 장애를 포함한 32개 HTTP 실험으로 종료 판정과 폴백 경계를 대조했습니다.
별도로 원본의 스트림 분류·폴백·응답 시작 기한·회로·중계·관측 회귀 218개를 실행해 통과했습니다. 호출자 취소와 downstream 전송 실패에서 upstream 정리·probe 반환을 확인하는 테스트도 포함됩니다. 이 회귀는 운영 저장 경로만 임시 경로로 대체했고, 실제 Windows 운영 작업은 실행하지 않았습니다.
업스트림 응답은 통제된 fixture이며 실제 모델 품질이나 GPU 처리량을 측정한 실험은 아닙니다. 검증 대상은 원본의 중계·완료 판정·회로·관측 계약입니다. 게이트웨이가 불완전 전송을 드러내는 것과 모든 호출자가 오류를 올바르게 표시·재시도하는 것은 별도 책임입니다.