서비스 학습
SOURCE · be/app/api/routes/chat.py · 2026-09-12

브라우저가 부를 수 있는
첫 번째 API

2단계까지 engine.generate()를 부르는 코드는 smoke.py 하나뿐이었다. 이 파일이 그 호출을 HTTP 뒤에 놓는다. 실패를 어떤 status로 번역할지 정하는 것이 절반의 일이다.

이 파일을 만든 이유

2단계에서 Adapter까지 완성했지만 브라우저는 아무것도 부를 수 없었다. /healthz/readyz는 상태만 알려주고, 실제 생성은 smoke.py라는 명령줄 도구에서만 일어났다.

이 파일이 하는 일은 두 가지다. 번역판정이다. 브라우저 JSON을 엔진 계약으로 바꾸고, 엔진이 던진 실패 하나를 브라우저가 행동할 수 있는 HTTP status로 바꾼다.

저장은 하지 않는다. 대화를 기억하지 않으므로 client가 매번 전체 대화를 다시 보낸다. 저장은 7단계, streaming은 4단계다.

전체 구조에서의 위치

브라우저POST /api/v1/chatmessages 배열
검증ChatRequest형태·크기 — 여기서 걸리면 422
번역이 파일GenerationRequest 로
실행engine.generate()Adapter → Gemma

경로가 /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_message503400입력을 고친다
request_rejected503400입력을 고친다
context_too_long503413대화를 줄인다
busy503429잠시 후 재시도
timeout503504재시도 가능
unavailable503503서버가 꺼져 있다
model_mismatch503503설정 문제. 사용자는 못 고친다
upstream_error503502추론 서버 내부 오류
invalid_response503502응답을 신뢰할 수 없다

/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)

ChatMessageMessage로 한 개씩 바꾼다. 필드가 같아서 불필요해 보이지만, 이 한 줄이 브라우저 계약과 엔진 계약을 분리해 둔 대가이자 이득이다. 7단계에서 브라우저 메시지에 id가 붙어도 엔진 계약은 그대로다.

생성 옵션을 넘기지 않는다. GenerationRequest의 기본값 max_output_tokens=128, temperature=0.2가 적용된다. 서버가 소유한다는 결정이며, ChatRequestextra="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)
         네트워크 중 일부만을 선택적으로 활성화하여 연산 효율을 높이는 구조입니다. ..."}
첫 응답29.2초74 토큰
2번째 턴 cache80%99 / 124 토큰 재사용
추론 서버 꺼짐503unavailable
전체 테스트76 passed

2번째 턴에서 cached_input_tokens99 / 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_reasonlength였다.

usage: {"output_tokens": 128, ...}
finish_reason: "length"
text 끝: "... ### 1. 핵심 개념: 왜 Transformer인가?\n\n"   ← 문장 도중에 멈춤

GenerationRequest의 기본값 128이 짧은 답변 한 문단 정도라서 그렇다. 실제 채팅으로 쓰려면 512~1024 정도가 필요하다. 다만 8GB 환경에서 토큰당 약 0.4초가 걸리므로, 1024 토큰이면 한 답변에 7분이 걸린다. 4단계의 streaming이 필요한 이유가 여기서 분명해진다 — 완성될 때까지 기다리는 방식으로는 쓸 수 없다.

이 값을 설정으로 빼서 조절할지는 다음 작업에서 정할 항목이다.

자주 발생하는 오류

증상원인진단
500 + tracebackInferenceError를 잡지 않음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.pyservice_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가 생긴다.

← 이전브라우저 계약다음 →실제 Smoke test