브라우저가 부를 수 있는
첫 번째 API
2단계까지 engine.generate()를 부르는 코드는 smoke.py 하나뿐이었다. 이 파일이 그 호출을 HTTP 뒤에 놓는다. 실패를 어떤 status로 번역할지 정하는 것이 절반의 일이다.
이 파일을 만든 이유
2단계에서 Adapter까지 완성했지만 브라우저는 아무것도 부를 수 없었다. /healthz와 /readyz는 상태만 알려주고, 실제 생성은 smoke.py라는 명령줄 도구에서만 일어났다.
이 파일이 하는 일은 두 가지다. 번역과 판정이다. 브라우저 JSON을 엔진 계약으로 바꾸고, 엔진이 던진 실패 하나를 브라우저가 행동할 수 있는 HTTP status로 바꾼다.
저장은 하지 않는다. 대화를 기억하지 않으므로 client가 매번 전체 대화를 다시 보낸다. 저장은 7단계, streaming은 4단계다.
전체 구조에서의 위치
경로가 /chat으로 선언돼 있는데 실제로는 /api/v1/chat이다. 접두사는 router.py가 붙인다. 이 파일은 자기가 어느 버전 아래에 놓일지 모른다.
전체 코드
import logging
from typing import Annotated, Final
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from app.api.dependencies import get_inference_engine
from app.engines.base import (
GenerationRequest,
InferenceEngine,
InferenceError,
InferenceErrorCode,
Message,
)
from app.schemas.chat import ChatError, ChatRequest, ChatResponse, ChatUsage
logger = logging.getLogger(__name__)
# Readiness answers 503 for every failure because it asks one yes/no question.
# Chat separates them so the browser can tell "fix your input" from "retry later".
_ERROR_STATUS: Final[dict[InferenceErrorCode, int]] = {
"invalid_message": status.HTTP_400_BAD_REQUEST,
"request_rejected": status.HTTP_400_BAD_REQUEST,
"context_too_long": status.HTTP_413_CONTENT_TOO_LARGE,
"busy": status.HTTP_429_TOO_MANY_REQUESTS,
"timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"unavailable": status.HTTP_503_SERVICE_UNAVAILABLE,
"model_mismatch": status.HTTP_503_SERVICE_UNAVAILABLE,
"upstream_error": status.HTTP_502_BAD_GATEWAY,
"invalid_response": status.HTTP_502_BAD_GATEWAY,
}
# The upstream never reports a queue estimate, so this is a fixed conservative hint.
_RETRY_AFTER_SECONDS: Final = 2
router = APIRouter(tags=["chat"])
def _error_response(error: InferenceError) -> JSONResponse:
"""Map one engine failure to a status, keeping upstream detail server-side."""
http_status = _ERROR_STATUS.get(error.code, status.HTTP_502_BAD_GATEWAY)
logger.warning(
"chat_inference_failed code=%s upstream_status=%s http_status=%s",
error.code, error.upstream_status, http_status,
)
headers = (
{"Retry-After": str(_RETRY_AFTER_SECONDS)}
if http_status == status.HTTP_429_TOO_MANY_REQUESTS
else None
)
return JSONResponse(
status_code=http_status,
content=ChatError(error_code=error.code).model_dump(),
headers=headers,
)
@router.post(
"/chat",
response_model=ChatResponse,
status_code=status.HTTP_200_OK,
responses={code: {"model": ChatError} for code in sorted(set(_ERROR_STATUS.values()))},
summary="Generate one answer without storing the conversation",
)
async def chat(
request: ChatRequest,
engine: Annotated[InferenceEngine, Depends(get_inference_engine)],
) -> ChatResponse | JSONResponse:
"""Translate one browser request into the engine contract and back."""
try:
result = await engine.generate(
GenerationRequest(
messages=tuple(
Message(role=message.role, content=message.content)
for message in request.messages
)
)
)
except InferenceError as error:
return _error_response(error)
return ChatResponse(
id=result.id,
model=result.model,
text=result.text,
finish_reason=result.finish_reason,
usage=ChatUsage(
input_tokens=result.usage.input_tokens,
output_tokens=result.usage.output_tokens,
total_tokens=result.usage.total_tokens,
cached_input_tokens=result.usage.cached_input_tokens,
),
)
오류 매핑표 — 이 파일의 핵심
# Readiness answers 503 for every failure because it asks one yes/no question.
# Chat separates them so the browser can tell "fix your input" from "retry later".
_ERROR_STATUS: Final[dict[InferenceErrorCode, int]] = {
"invalid_message": status.HTTP_400_BAD_REQUEST,
"request_rejected": status.HTTP_400_BAD_REQUEST,
"context_too_long": status.HTTP_413_CONTENT_TOO_LARGE,
"busy": status.HTTP_429_TOO_MANY_REQUESTS,
"timeout": status.HTTP_504_GATEWAY_TIMEOUT,
"unavailable": status.HTTP_503_SERVICE_UNAVAILABLE,
"model_mismatch": status.HTTP_503_SERVICE_UNAVAILABLE,
"upstream_error": status.HTTP_502_BAD_GATEWAY,
"invalid_response": status.HTTP_502_BAD_GATEWAY,
}
같은 InferenceError를 두 route가 다르게 해석한다. 이것이 route 계층이 하는 일을 가장 잘 보여주는 지점이다.
| 오류 코드 | /readyz | /api/v1/chat | 브라우저가 할 일 |
|---|---|---|---|
invalid_message | 503 | 400 | 입력을 고친다 |
request_rejected | 503 | 400 | 입력을 고친다 |
context_too_long | 503 | 413 | 대화를 줄인다 |
busy | 503 | 429 | 잠시 후 재시도 |
timeout | 503 | 504 | 재시도 가능 |
unavailable | 503 | 503 | 서버가 꺼져 있다 |
model_mismatch | 503 | 503 | 설정 문제. 사용자는 못 고친다 |
upstream_error | 503 | 502 | 추론 서버 내부 오류 |
invalid_response | 503 | 502 | 응답을 신뢰할 수 없다 |
/readyz는 “준비됐나?”라는 예/아니오 하나를 묻는다. 그래서 모든 실패가 503이다. 채팅은 브라우저가 4xx(내 잘못)와 5xx(서버 잘못)를 구분해 재시도할지 안내할지 정해야 하므로 갈랐다.
변환 함수 — 정보를 줄여서 내보낸다
def _error_response(error: InferenceError) -> JSONResponse:
"""Map one engine failure to a status, keeping upstream detail server-side."""
http_status = _ERROR_STATUS.get(error.code, status.HTTP_502_BAD_GATEWAY)
logger.warning(
"chat_inference_failed code=%s upstream_status=%s http_status=%s",
error.code, error.upstream_status, http_status,
)
headers = (
{"Retry-After": str(_RETRY_AFTER_SECONDS)}
if http_status == status.HTTP_429_TOO_MANY_REQUESTS
else None
)
return JSONResponse(
status_code=http_status,
content=ChatError(error_code=error.code).model_dump(),
headers=headers,
)
.get(..., 502)의 fallback
매핑표에 없는 코드가 오면 502로 떨어진다. 하지만 이 fallback에 의존하지 않는다. 테스트가 InferenceErrorCode의 모든 값이 표에 있는지 검사하므로, 새 코드를 추가하면 status를 정하기 전까지 테스트가 실패한다.
upstream_status는 로그로만 나간다
응답 본문에는 error_code 하나뿐이다. “추론 서버가 500을 줬다”는 사실은 운영자에게 필요한 정보이지 브라우저가 할 수 있는 일이 없다. base.py가 예외에 담아 둔 값이 여기서 처음으로 쓰인다.
WARNING app.api.routes.chat chat_inference_failed code=unavailable upstream_status=None http_status=503
Retry-After: 2는 추정치가 아니다
추론 서버가 대기 시간을 알려주지 않으므로 고정값이다. 429에만 붙인다. 없으면 client가 얼마나 기다릴지 전혀 모르므로, 보수적인 값을 주는 편이 낫다는 판단이다. _RETRY_AFTER_SECONDS 한 곳에서 바꾼다.
HTTPException이 아니라 JSONResponse
HTTPException(detail=...)을 쓰면 본문이 {"detail": ...}로 감싸진다. 우리가 정한 계약은 {"error_code": "..."}이므로 응답 객체를 직접 만든다. FastAPI는 Response 객체를 받으면 response_model 검증을 건너뛴다.
route 함수 — 성공 경로
@router.post(
"/chat",
response_model=ChatResponse,
status_code=status.HTTP_200_OK,
responses={code: {"model": ChatError} for code in sorted(set(_ERROR_STATUS.values()))},
summary="Generate one answer without storing the conversation",
)
async def chat(
request: ChatRequest,
engine: Annotated[InferenceEngine, Depends(get_inference_engine)],
) -> ChatResponse | JSONResponse:
request: ChatRequest가 검증을 일으킨다
타입 표시 하나로 FastAPI가 본문을 파싱하고 ChatRequest로 검증한다. 함수 안에서 검사하는 코드가 없다. 실패하면 함수는 실행조차 되지 않고 422가 나간다.
Depends(get_inference_engine)
dependencies.py가 lifespan이 만든 Adapter를 꺼내 준다. 이 route는 TurboFieldfareAdapter라는 이름을 모른다. 아는 것은 InferenceEngine이라는 능력뿐이다.
responses={...}
매핑표에서 status 목록을 만들어 OpenAPI에 싣는다. /docs에서 400·413·429·502·503·504가 각각 ChatError 모양이라고 표시된다. 표를 고치면 문서가 따라온다.
반환 타입이 ChatResponse | JSONResponse
성공은 model, 실패는 Response 객체다. response_model을 명시했으므로 OpenAPI 문서는 흔들리지 않는다.
try:
result = await engine.generate(
GenerationRequest(
messages=tuple(
Message(role=message.role, content=message.content)
for message in request.messages
)
)
)
except InferenceError as error:
return _error_response(error)
ChatMessage를 Message로 한 개씩 바꾼다. 필드가 같아서 불필요해 보이지만, 이 한 줄이 브라우저 계약과 엔진 계약을 분리해 둔 대가이자 이득이다. 7단계에서 브라우저 메시지에 id가 붙어도 엔진 계약은 그대로다.
생성 옵션을 넘기지 않는다. GenerationRequest의 기본값 max_output_tokens=128, temperature=0.2가 적용된다. 서버가 소유한다는 결정이며, ChatRequest의 extra="forbid"가 client의 지정을 막는다.
실제 Gemma로 확인한 결과
TurboFieldfareServer를 켜고 실제로 확인했다.
POST /api/v1/chat
{"messages":[{"role":"user","content":"MoE가 무엇인지 두 문장으로 설명해줘."}]}
200 · 29.2초
{"id":"chatcmpl-333fad1c...","model":"gemma-4-26b-a4b-it","finish_reason":"stop",
"usage":{"input_tokens":26,"output_tokens":74,"total_tokens":100,"cached_input_tokens":0},
"text":"MoE(Mixture of Experts)는 모델 전체를 사용하는 대신 학습된 여러 전문가(Expert)
네트워크 중 일부만을 선택적으로 활성화하여 연산 효율을 높이는 구조입니다. ..."}
2번째 턴에서 cached_input_tokens가 99 / 124로 올라갔다. 저장하지 않는 API라 client가 전체 대화를 다시 보내는데도, 서버의 prompt cache가 앞부분을 재사용하므로 비용이 그대로 늘지는 않는다.
POST /api/v1/chat → 503
{"error_code":"unavailable"}
서버 로그: WARNING chat_inference_failed code=unavailable upstream_status=None http_status=503
prompt 유출: 0회 · traceback 없음
관찰된 한계 — 128 토큰은 짧다
실제로 써 보니 답변이 자주 잘린다. 두 번째와 세 번째 요청 모두 finish_reason이 length였다.
usage: {"output_tokens": 128, ...}
finish_reason: "length"
text 끝: "... ### 1. 핵심 개념: 왜 Transformer인가?\n\n" ← 문장 도중에 멈춤
GenerationRequest의 기본값 128이 짧은 답변 한 문단 정도라서 그렇다. 실제 채팅으로 쓰려면 512~1024 정도가 필요하다. 다만 8GB 환경에서 토큰당 약 0.4초가 걸리므로, 1024 토큰이면 한 답변에 7분이 걸린다. 4단계의 streaming이 필요한 이유가 여기서 분명해진다 — 완성될 때까지 기다리는 방식으로는 쓸 수 없다.
이 값을 설정으로 빼서 조절할지는 다음 작업에서 정할 항목이다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
| 500 + traceback | InferenceError를 잡지 않음 | try/except가 빠지면 이렇게 된다. 이 파일의 단위 1 상태가 그랬다 |
본문이 {"detail": ...} | HTTPException을 씀 | 계약은 {"error_code": ...}다 |
| 422인데 원인을 모름 | schema 검증 실패 | detail[0].loc이 어느 필드인지 알려준다 |
| 새 오류 코드가 502로 나감 | 매핑표에 추가하지 않음 | 테스트가 먼저 실패하므로 배포 전에 잡힌다 |
429인데 Retry-After가 없음 | 헤더 조건이 status와 어긋남 | busy만 429다 |
| 답변이 항상 잘림 | 기본값 128 토큰 | 위 “관찰된 한계” 참고 |
설계 선택과 대안
왜 exception handler로 등록하지 않았나?
@app.exception_handler(InferenceError)로 등록하면 route 본문이 성공 경로만 남아 깔끔하다. 그러나 application 전체에 걸리므로 /readyz의 503 정책과 충돌하고, main.py를 고쳐야 한다. 지금은 이 정책이 필요한 route가 하나뿐이라 눈에 보이는 곳에 뒀다. 4단계에서 streaming route가 같은 매핑을 쓰게 되면 그때 공용 모듈로 옮길 만하다.
logger 이름을 __name__으로 한 이유
main.py는 service_name으로 로거를 만든다. 여기서는 app.api.routes.chat이 찍혀 어느 파일에서 난 경고인지 바로 보인다. 로그 형식에 %(name)s가 있어서 가능한 구분이다.
생성 옵션을 client에게 열어 주면?
실험하기는 편하다. 대신 8GB·단일 GPU 환경에서 client가 서버 부하를 결정하게 된다. max_output_tokens=100000 한 번이면 다른 요청이 전부 막힌다. 상한을 서버가 강제하는 방식으로 열어 줄 수는 있고, 그때는 ChatRequest에 선택 필드를 추가하면 된다.
저장을 지금 넣지 않은 이유
대화를 저장하려면 database·migration·소유자 개념이 필요하고, 그건 5~7단계다. 저장 없이 먼저 만들면 “생성이 되는가”와 “저장이 되는가”를 따로 검증할 수 있다. 지금 실패하면 원인은 추론 경로뿐이다.
이전 단계와 다음 파일
이 route가 검증에 쓰는 계약은 schemas/chat.py, 번역해서 넘기는 내부 계약은 engines/base.py, 실제로 Gemma를 부르는 구현은 turbofieldfare.py에 있다. 경로에 /api/v1을 붙이는 곳은 router.py다.
다음 단계는 streaming이다. 위에서 본 대로 완성을 기다리는 방식은 실사용이 어렵다. 4단계에서 base.py에 조각 타입이 추가되고, 이 파일에 StreamingResponse를 쓰는 route가 생긴다.