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

브라우저가 보낼 것과 받을 것을
따로 못 박는다

engines/base.py의 계약과 필드가 거의 같다. 그런데도 따로 만든 이유가 이 파일의 전부다. 하나는 우리 코드끼리의 약속이고, 다른 하나는 바깥에 공개하는 약속이다.

이 파일을 만든 이유

engines/base.py에 이미 Message가 있다. 그대로 쓰면 이 파일의 절반이 필요 없다. 그런데도 따로 만든 이유는 두 계약이 앞으로 서로 다른 속도로 변하기 때문이다.

engines/base.pyschemas/chat.py
누구와의 약속FastAPI ↔ 추론 엔진 (우리 코드끼리)FastAPI ↔ 브라우저 (바깥에 공개)
바꾸면 생기는 일우리가 같이 고치면 끝이미 배포된 client가 깨진다
누가 값을 정하나route가 채워 넣는다사용자가 보낸 값
입력 제한없음 (내부 호출은 신뢰)있음 (바깥 입력은 의심)

마지막 줄이 핵심이다. 제한은 바깥 경계에만 둔다. base.py에 넣으면 smoke.py나 테스트 같은 내부 호출까지 같은 제약을 받는다.

전체 구조에서의 위치

브라우저JSON사용자가 만든 값
여기ChatRequest형태·크기 검사
번역routeGenerationRequest 로
돌아올 때ChatResponseGenerationResult 를 JSON 으로

이 파일에는 함수가 하나도 없다. 변환은 routes/chat.py가 하고, 이 파일은 “무엇이 올바른 모양인가”만 선언한다.

전체 코드

from typing import Final, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from app.engines.base import InferenceErrorCode


# TurboFieldfareServer owns the real limit (--max-context, default 16384 tokens) and
# reports context_too_long. FastAPI cannot tokenize, so these character caps are only
# a coarse guard: they stop absurd payloads before an 8 GB machine tokenizes them,
# and stay far above any conversation that would actually fit.
_MAX_MESSAGES: Final = 100
_MAX_MESSAGE_CHARS: Final = 16_000
_MAX_TOTAL_CHARS: Final = 64_000


class ChatContract(BaseModel):
    """Browser-facing JSON, deliberately separate from the engine contract."""

    model_config = ConfigDict(frozen=True, extra="forbid")


class ChatMessage(ChatContract):
    role: Literal["system", "user", "assistant"]
    content: str = Field(min_length=1, max_length=_MAX_MESSAGE_CHARS)

    @field_validator("content")
    @classmethod
    def require_visible_text(cls, value: str) -> str:
        """Trim the edges; whitespace alone carries nothing for the model to answer."""
        trimmed = value.strip()
        if not trimmed:
            raise ValueError("content must contain visible characters")
        return trimmed


class ChatRequest(ChatContract):
    """One stateless turn; the client resends the whole conversation."""

    messages: tuple[ChatMessage, ...] = Field(
        min_length=1, max_length=_MAX_MESSAGES
    )

    @model_validator(mode="after")
    def limit_conversation_size(self) -> "ChatRequest":
        """Reject payloads no context window could hold, before the model sees them."""
        total = sum(len(message.content) for message in self.messages)
        if total > _MAX_TOTAL_CHARS:
            raise ValueError(
                f"conversation is {total} characters, over the {_MAX_TOTAL_CHARS} limit"
            )
        return self


class ChatUsage(ChatContract):
    input_tokens: int = Field(ge=0)
    output_tokens: int = Field(ge=0)
    total_tokens: int = Field(ge=0)
    cached_input_tokens: int = Field(ge=0)


class ChatResponse(ChatContract):
    """What the browser receives; the server keeps none of it."""

    id: str
    model: str
    text: str
    finish_reason: Literal["stop", "length"]
    usage: ChatUsage


class ChatError(ChatContract):
    """Only the stable code; upstream wording never reaches the browser."""

    error_code: InferenceErrorCode

ChatContract — 네 class의 부모

class ChatContract(BaseModel):
    """Browser-facing JSON, deliberately separate from the engine contract."""

    model_config = ConfigDict(frozen=True, extra="forbid")

base.pyContract와 설정이 같다. 그래도 상속하지 않고 따로 둔 것이 분리의 실체다. 상속했다면 base.py의 설정을 바꿀 때 브라우저 API 동작까지 같이 바뀐다.

extra="forbid"가 여기서 하는 일

모르는 필드를 거부한다. 이것이 “생성 옵션은 서버가 소유한다”는 결정을 강제하는 장치다. 브라우저가 {"messages": [...], "max_output_tokens": 4096}을 보내면 422로 거부된다. 무시하고 넘어가면 client는 값이 반영됐다고 착각한다.

frozen=True

요청 객체는 route를 통과하는 동안 바뀌지 않는다. 검사를 통과한 값과 엔진에 전달되는 값이 같음을 보장한다.

입력 상한 — 숫자를 어떻게 정했나

# TurboFieldfareServer owns the real limit (--max-context, default 16384 tokens) and
# reports context_too_long. FastAPI cannot tokenize, so these character caps are only
# a coarse guard: they stop absurd payloads before an 8 GB machine tokenizes them,
# and stay far above any conversation that would actually fit.
_MAX_MESSAGES: Final = 100
_MAX_MESSAGE_CHARS: Final = 16_000
_MAX_TOTAL_CHARS: Final = 64_000

FastAPI에는 tokenizer가 없다. 그래서 “이 대화가 문맥에 들어가는가”를 판단할 수 없다. 문자 수는 토큰 수의 나쁜 대리 지표다 — 영어는 토큰당 약 4자, 한국어는 약 1.5자다.

그래서 상한을 일부러 느슨하게 잡았다. 조이면 실제로는 들어갈 대화를 우리가 잘못 거부한다. 판정은 두 층으로 갈린다.

상황누가 막나응답
말도 안 되는 크기 (100개 초과, 64,000자 초과)이 파일422 — 모델이 보기도 전에
문맥을 실제로 넘김TurboFieldfareServer413 context_too_long — 정확한 판정

서버 기본값은 --max-context 16384 토큰이다. 64,000자는 그 문맥이 담을 수 있는 양보다 위에 있어서, 정상적인 대화가 이 상한에 먼저 걸리는 일은 없다.

ChatMessage — 공백만 있는 메시지를 막는다

class ChatMessage(ChatContract):
    role: Literal["system", "user", "assistant"]
    content: str = Field(min_length=1, max_length=_MAX_MESSAGE_CHARS)

    @field_validator("content")
    @classmethod
    def require_visible_text(cls, value: str) -> str:
        """Trim the edges; whitespace alone carries nothing for the model to answer."""
        trimmed = value.strip()
        if not trimmed:
            raise ValueError("content must contain visible characters")
        return trimmed
min_length=1만으로는 부족하다

" "는 길이가 1이라 통과한다. 공백 세 개도 마찬가지다. 모델에게 보낼 내용이 없는데도 생성 요청이 나가고, 8GB 환경에서 30초를 낭비한다.

validator가 값을 고쳐서 돌려준다

거부만 하지 않고 strip()한 값을 반환한다. 그래서 " 안녕 "은 엔진에 "안녕"으로 도착한다. App.tsxprompt.trim()과 같은 일을 서버가 다시 하는 것이다. 브라우저 검사는 우회할 수 있으므로 서버가 최종 판단을 한다.

검사 순서

field_validator의 기본 모드는 after다. min_length·max_length가 먼저 돌고, 통과한 값에만 이 함수가 실행된다. 16,001자짜리 메시지는 validator에 닿지도 않는다.

roleLiteral 3개인 이유

base.pyMessage와 같은 세 값이다. Gemma의 chat template이 인식하는 역할이기 때문이다. 다만 브라우저가 system을 보낼 수 있다는 점은 현재 열려 있는 문제다. 인증이 붙는 6단계에서 정할 항목이다.

ChatRequest — 대화 전체 크기를 본다

class ChatRequest(ChatContract):
    """One stateless turn; the client resends the whole conversation."""

    messages: tuple[ChatMessage, ...] = Field(
        min_length=1, max_length=_MAX_MESSAGES
    )

    @model_validator(mode="after")
    def limit_conversation_size(self) -> "ChatRequest":
        """Reject payloads no context window could hold, before the model sees them."""
        total = sum(len(message.content) for message in self.messages)
        if total > _MAX_TOTAL_CHARS:
            raise ValueError(
                f"conversation is {total} characters, over the {_MAX_TOTAL_CHARS} limit"
            )
        return self
docstring의 "stateless"가 3단계의 정의다

서버는 이전 대화를 기억하지 않는다. 그래서 client가 매번 전체 대화를 다시 보낸다. 저장이 생기는 7단계가 되면 client는 conversation_id만 보내게 되고, 이 schema가 바뀐다.

field_validator가 아니라 model_validator

총 길이는 필드 하나로 판단할 수 없다. 모든 메시지를 합쳐야 알 수 있으므로 객체 전체가 만들어진 뒤에 검사한다. mode="after"가 “필드 검증이 끝난 뒤”를 뜻한다.

tuple인가

frozen=True는 필드 재할당만 막는다. list였다면 request.messages.append(...)가 통과한다. base.pyGenerationRequest와 같은 이유다.

오류 메시지에 실제 숫자를 넣는다

conversation is 80000 characters, over the 64000 limit처럼 나온다. 얼마나 줄여야 하는지 client가 알 수 있다. 다만 이 값은 사용자 입력의 길이일 뿐 내용이 아니므로 노출해도 안전하다.

ChatResponse와 ChatError — 돌려주는 것

class ChatResponse(ChatContract):
    """What the browser receives; the server keeps none of it."""

    id: str
    model: str
    text: str
    finish_reason: Literal["stop", "length"]
    usage: ChatUsage


class ChatError(ChatContract):
    """Only the stable code; upstream wording never reaches the browser."""

    error_code: InferenceErrorCode
필드무엇을 알려주나왜 내보내나
id이 응답의 식별자사용자가 “이 답변이 이상하다”고 할 때 로그와 맞춰본다
model실제로 답한 모델설정과 다른 서버에 붙었는지 확인
finish_reasonstop 또는 length답변이 잘렸는지 FE가 알 수 있다
usage토큰 4종cached_input_tokens로 prompt cache 재사용을 관찰
error_code실패 범주 하나원문 대신 코드만. 자세한 내용은 서버 로그

ChatErrorbase.pyInferenceErrorCode를 그대로 가져다 쓴다. 오류 코드가 늘면 응답 schema가 자동으로 따라간다. schemas/system.py가 이미 쓰던 방식이다.

실제 값으로 확인한 결과

TurboFieldfareServer를 켜고 실제 Gemma로 확인한 응답이다.

POST /api/v1/chat   {"messages":[{"role":"user","content":"MoE가 무엇인지 두 문장으로 설명해줘."}]}

200 · 29.2초
{
  "id": "chatcmpl-333fad1ceec94fd1a745a1dae21bfaf7",
  "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)는 모델 전체를 사용하는 대신 ..."
}
첫 응답29.2초74 토큰 생성
cache 재사용0%첫 요청이라 prefix 없음
finish_reasonstop상한에 걸리지 않음

같은 대화를 이어서 두 번째 턴을 보내면 cached_input_tokens가 달라진다.

usage: {"input_tokens": 124, "cached_input_tokens": 99, ...}
                                     ↑ 80% 를 다시 계산하지 않았다

이것이 ChatUsagecached_input_tokens를 넣은 이유다. 저장하지 않는 API라서 client가 매번 전체 대화를 다시 보내는데도, 서버의 prompt cache가 앞부분을 재사용하므로 비용이 선형으로 늘지 않는다. 실험 016(long session prefix reuse)에서 측정하던 지표를 그대로 서비스에서 관찰할 수 있다.

거부되는 입력 — 실제 응답

{"messages":[{"role":"user","content":"   "}]}
  → 422  Value error, content must contain visible characters

{"messages":[{"role":"user","content":"  안녕  "}]}
  → 검증 통과. 엔진에는 "안녕" 으로 도착

{"messages":[{"role":"user","content":"x"*16001}]}
  → 422  String should have at most 16000 characters

{"messages":[...], "max_output_tokens":4096}
  → 422  extra="forbid" 가 거부. 생성 옵션은 서버가 소유

네 경우 모두 추론 엔진이 호출되지 않는다. 8GB 환경에서 30초짜리 생성을 낭비하지 않으려면 거절이 최대한 앞에서 일어나야 한다.

자주 발생하는 오류

증상원인진단
422 extra_forbidden본문에 선언되지 않은 필드생성 옵션은 서버가 정한다. messages만 보낸다
422인데 이유를 모름detail[0].loc에 위치가 있다어느 메시지의 어느 필드인지 배열 index로 알려준다
공백 메시지가 통과하던 시절의 동작min_length=1만 믿음validator가 필요하다
긴 대화가 422로 막힘우리 상한에 먼저 걸림의도적으로 느슨하니, 정상 대화라면 상한을 재검토한다
413이 아니라 422가 옴우리 상한이 먼저 걸린 것413은 서버가 문맥 초과를 판정했을 때만 나온다

설계 선택과 대안

base.pyMessage를 그대로 쓰면 안 되나?

지금은 필드가 같아서 동작한다. 문제는 7단계다. 저장이 생기면 브라우저 메시지에 idcreated_at이 붙는데, 그때 base.py까지 바꾸면 추론 엔진 계약이 저장 기능 때문에 오염된다. 지금 나눠 두면 그 시점에 이 파일만 고치면 된다.

상한을 설정(config.py)으로 빼지 않은 이유

Pydantic 검증은 요청을 파싱하는 시점에 일어나서 실행 중 설정을 읽기 어렵다. 설정으로 빼려면 route 안에서 따로 검사해야 하고, 그러면 거부 경로가 422와 400 두 갈래로 갈린다. 지금은 상한이 느슨한 안전장치일 뿐이고 진짜 한계는 서버가 판정하므로, 한 경로로 유지하는 편이 단순하다.

왜 응답에 upstream_status를 넣지 않나?

브라우저가 할 수 있는 일이 없다. 추론 서버가 500을 줬다는 사실은 운영자에게 필요한 정보이지 사용자에게 필요한 정보가 아니다. 그래서 서버 로그에만 남긴다.

마지막 메시지가 user여야 한다는 규칙은?

넣지 않았다. assistant로 끝나는 대화를 보내도 통과한다. FE가 답변 placeholder까지 함께 보내는 실수를 잡아주지만, 규칙을 하나 더 만드는 일이라 이번 단계 범위 밖으로 뒀다.

이전 단계와 다음 파일

이 계약을 실제로 변환하는 코드는 routes/chat.py에 있다. 변환 대상인 내부 계약은 engines/base.py, 같은 방식으로 만들어진 상태 endpoint 계약은 schemas/system.py에서 읽는다.

← 이전상태 응답 계약다음 →채팅 route 구현