브라우저가 보낼 것과 받을 것을
따로 못 박는다
engines/base.py의 계약과 필드가 거의 같다. 그런데도 따로 만든 이유가 이 파일의 전부다. 하나는 우리 코드끼리의 약속이고, 다른 하나는 바깥에 공개하는 약속이다.
이 파일을 만든 이유
engines/base.py에 이미 Message가 있다. 그대로 쓰면 이 파일의 절반이 필요 없다. 그런데도 따로 만든 이유는 두 계약이 앞으로 서로 다른 속도로 변하기 때문이다.
| engines/base.py | schemas/chat.py | |
|---|---|---|
| 누구와의 약속 | FastAPI ↔ 추론 엔진 (우리 코드끼리) | FastAPI ↔ 브라우저 (바깥에 공개) |
| 바꾸면 생기는 일 | 우리가 같이 고치면 끝 | 이미 배포된 client가 깨진다 |
| 누가 값을 정하나 | route가 채워 넣는다 | 사용자가 보낸 값 |
| 입력 제한 | 없음 (내부 호출은 신뢰) | 있음 (바깥 입력은 의심) |
마지막 줄이 핵심이다. 제한은 바깥 경계에만 둔다. base.py에 넣으면 smoke.py나 테스트 같은 내부 호출까지 같은 제약을 받는다.
전체 구조에서의 위치
이 파일에는 함수가 하나도 없다. 변환은 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.py의 Contract와 설정이 같다. 그래도 상속하지 않고 따로 둔 것이 분리의 실체다. 상속했다면 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 — 모델이 보기도 전에 |
| 문맥을 실제로 넘김 | TurboFieldfareServer | 413 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.tsx의 prompt.trim()과 같은 일을 서버가 다시 하는 것이다. 브라우저 검사는 우회할 수 있으므로 서버가 최종 판단을 한다.
검사 순서
field_validator의 기본 모드는 after다. min_length·max_length가 먼저 돌고, 통과한 값에만 이 함수가 실행된다. 16,001자짜리 메시지는 validator에 닿지도 않는다.
role이 Literal 3개인 이유
base.py의 Message와 같은 세 값이다. 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.py의 GenerationRequest와 같은 이유다.
오류 메시지에 실제 숫자를 넣는다
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_reason | stop 또는 length | 답변이 잘렸는지 FE가 알 수 있다 |
usage | 토큰 4종 | cached_input_tokens로 prompt cache 재사용을 관찰 |
error_code | 실패 범주 하나 | 원문 대신 코드만. 자세한 내용은 서버 로그 |
ChatError가 base.py의 InferenceErrorCode를 그대로 가져다 쓴다. 오류 코드가 늘면 응답 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)는 모델 전체를 사용하는 대신 ..."
}
같은 대화를 이어서 두 번째 턴을 보내면 cached_input_tokens가 달라진다.
usage: {"input_tokens": 124, "cached_input_tokens": 99, ...}
↑ 80% 를 다시 계산하지 않았다
이것이 ChatUsage에 cached_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.py의 Message를 그대로 쓰면 안 되나?
지금은 필드가 같아서 동작한다. 문제는 7단계다. 저장이 생기면 브라우저 메시지에 id나 created_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에서 읽는다.