서비스 학습
SOURCE · be/app/engines/turbofieldfare.py · 2026-09-06

내부 생성 계약을
Swift HTTP API로 번역한다

이 파일만 TurboFieldfare의 endpoint와 JSON 모양을 안다. 요청을 보내는 것보다 중요한 일은 응답을 검증하고, 불안정한 외부 실패를 안정된 내부 오류로 바꾸는 것이다.

이 파일을 만든 이유

TurboFieldfareServer는 /health, /v1/models, /v1/chat/completions를 제공한다. 이 경로와 choices[0].message.content 같은 구조를 route마다 반복하면 upstream 변경 때 수정 지점이 늘어난다. Adapter는 모든 TurboFieldfare 전용 지식을 한 경계에 모은다.

소유권 경계: Adapter는 전달받은 두 HTTP client나 TurboFieldfare process를 소유하지 않는다. 두 Client는 FastAPI lifespan이 열고 닫으며, model process는 macOS에서 별도로 실행한다.

import와 반환 type을 먼저 읽기

import asyncio
from typing import Literal, TypeVar

import httpx2
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator

from app.engines.base import (
    GenerationRequest, GenerationResult, InferenceError, InferenceErrorCode,
    ModelInfo, TokenUsage,
)

WireResponse = TypeVar("WireResponse", bound=_WireModel)

읽기 순서를 위해 import와 TypeVar 선언을 모아 표시했다. 실제 파일의 WireResponse 선언은 아래의 응답 model class 정의 뒤에 있다. asyncio는 비동기 시간 제한, httpx2는 HTTP 전송, Pydantic import는 JSON 구조 검증을 맡는다. base.py에서 가져온 type은 서비스 내부의 요청·결과·오류 계약이다.

TypeVar("WireResponse", bound=_WireModel)는 “_WireModel 계열 중 호출자가 선택한 type”을 나타낸다. schema: type[WireResponse]에 _Health class를 넣으면 _request의 반환 type도 _Health로 이어지도록 정적 검사기에 알려 준다. TypeVar가 JSON을 검증하거나 객체를 만드는 것은 아니며 실제 검증은 schema.model_validate_json(...)이 한다.

응답용 private Pydantic model

class _WireModel(BaseModel):
    """Validate the supported upstream subset; tolerate unrelated new fields."""

    model_config = ConfigDict(strict=True, extra="ignore")


class _Health(_WireModel):
    status: Literal["ok"]


class _Model(_WireModel):
    id: str = Field(min_length=1)
    object: Literal["model"]


class _Models(_WireModel):
    object: Literal["list"]
    data: list[_Model]


class _Message(_WireModel):
    role: Literal["assistant"]
    content: str
    tool_calls: list[dict] = Field(default_factory=list, max_length=0)


class _Choice(_WireModel):
    index: Literal[0]
    message: _Message
    finish_reason: Literal["stop", "length"]


class _PromptDetails(_WireModel):
    cached_tokens: int = Field(ge=0)


class _Usage(_WireModel):
    prompt_tokens: int = Field(ge=0)
    completion_tokens: int = Field(ge=0)
    total_tokens: int = Field(ge=0)
    prompt_tokens_details: _PromptDetails

    @model_validator(mode="after")
    def check_counts(self) -> "_Usage":
        if self.total_tokens != self.prompt_tokens + self.completion_tokens:
            raise ValueError("Inconsistent total token count")
        if self.prompt_tokens_details.cached_tokens > self.prompt_tokens:
            raise ValueError("Cached token count exceeds prompt token count")
        return self


class _Completion(_WireModel):
    id: str = Field(min_length=1)
    object: Literal["chat.completion"]
    model: str
    choices: list[_Choice] = Field(min_length=1, max_length=1)
    usage: _Usage
strict=Trueextra="ignore"가 함께 있는 이유

strict=True는 문자열 "12"를 정수 12로 자동 보정하지 않는다. 필수 field가 잘못된 type이면 upstream 계약 오류로 본다. 반면 extra="ignore"는 우리가 쓰지 않는 새 field가 추가됐다는 이유만으로 전체 서비스를 깨지 않게 한다. 즉, 필요한 값에는 엄격하고 무관한 확장에는 관대하다.

왜 tool call을 조용히 버리지 않는가?

Tool을 요청한 응답은 content만 읽으면 의미가 사라질 수 있다. 현재 내부 계약에는 tool call이 없으므로 max_length=0으로 명시적으로 거절한다. 기능이 없는 것보다 더 위험한 것은 있는 척하며 데이터를 잃는 것이다.

생성자: 두 client를 목적별로 보관한다

def __init__(
    self, generation_client: httpx2.AsyncClient, *,
    probe_client: httpx2.AsyncClient, model: str,
    probe_timeout: float, generation_timeout: float,
) -> None:
    self._generation_client = generation_client
    self._probe_client = probe_client
    self._model = model
    self._probe_timeout = probe_timeout
    self._generation_timeout = generation_timeout

generation_client는 답변 생성용, probe_client는 상태·모델 목록 확인용이다. * 뒤의 인수는 이름을 붙여 전달해야 하므로 조립 지점에서 probe_client=...가 드러난다. 이 함수는 client를 만들거나 연결을 열지 않고 참조와 설정값을 보관한다.

_request(..., client=...)는 사용할 client를 명시적으로 받는다. 따라서 공통 JSON 검증과 오류 처리 코드를 유지하면서도 두 연결 풀이 섞이지 않는다. 구체적인 생성·종료 책임은 main.py의 lifespan에 있다.

오류 JSON에서 안전한 종류만 뽑는다

class _ErrorDetail(_WireModel):
    # Deliberately exclude message/param: they can contain user content.
    code: str


class _ErrorEnvelope(_WireModel):
    error: _ErrorDetail


_KNOWN_REQUEST_ERRORS: dict[tuple[int, str], InferenceErrorCode] = {
    (400, "context_length_exceeded"): "context_too_long",
    (400, "invalid_message"): "invalid_message",
    (404, "model_not_found"): "model_mismatch",
}


def _error_code(response: httpx2.Response) -> InferenceErrorCode:
    """Keep only allowlisted categories; unknown/malformed bodies use status."""
    if response.status_code == 429:
        return "busy"
    if 400 <= response.status_code < 500:
        try:
            error = _ErrorEnvelope.model_validate_json(response.content)
        except ValidationError:
            return "request_rejected"
        return _KNOWN_REQUEST_ERRORS.get(
            (response.status_code, error.error.code), "request_rejected",
        )
    return "upstream_error"
실제 upstream 응답내부 오류호출자가 구분할 수 있는 대응
400 + context_length_exceededcontext_too_long대화 길이·생성 길이 제한 검토, 대화 축소 또는 요약
400 + invalid_messageinvalid_message내용·역할·메시지 순서 확인
404 + model_not_foundmodel_mismatch서버 주소와 model ID 설정 확인
429busy수용 여력 부족 표시, 호출자 정책에 따른 재요청 판단
그 밖의 4xx (429 제외), 해당 4xx의 알 수 없거나 깨진 오류 JSONrequest_rejectedHTTP status로만 안전하게 분류
5xx·3xx·예상하지 않은 2xx 등 그 밖의 비-200 statusupstream_errorupstream 장애 또는 예상한 API 계약과의 불일치 확인
왜 status와 code를 함께 확인하는가?

(404, "model_not_found")처럼 두 값을 묶어 allowlist의 key로 사용한다. 예를 들어 HTTP 500 본문에 context_length_exceeded가 있어도 서버 실패를 사용자 입력 문제로 오해하지 않고 upstream_error로 둔다. 429는 본문이 깨져도 busy다. 이 매핑은 현재 Swift의 OpenAIModels.swift, HTTPServer.swift, ServerInference.swift에서 확인한 계약에 맞춘다.

원본 message를 읽지 않아도 되는가?

알려진 종류만 구분하면 호출자는 다음 행동을 결정할 수 있다. _ErrorDetail에는 code만 선언하고 상속받은 extra="ignore"로 message·param은 보관하지 않는다. code도 임의 문자열을 그대로 반환하지 않고 고정된 내부 범주로만 바꾼다. 검증 자체가 실패해도 Pydantic 오류를 전파하지 않고 fallback을 쓴다.

문맥을 자동 삭제하거나 잘못된 메시지를 자동 수정하는 코드는 없다. 이번 단계는 적절한 대응을 가능하게 하는 분류까지만 담당한다.

공통 HTTP 경로 _request()

async def _request(
    self, method: str, path: str, schema: type[WireResponse], *,
    client: httpx2.AsyncClient, deadline: float, payload: dict | None = None,
) -> WireResponse:
    try:
        # A total deadline, including pool wait and upstream queue time.
        async with asyncio.timeout(deadline):
            response = await client.request(method, path, json=payload)
    except httpx2.PoolTimeout:
        raise InferenceError("busy") from None
    except (httpx2.TimeoutException, TimeoutError):
        raise InferenceError("timeout") from None
    except httpx2.RequestError:
        raise InferenceError("unavailable") from None

    if response.status_code != 200:
        raise InferenceError(
            _error_code(response), upstream_status=response.status_code,
        )
    try:
        return schema.model_validate_json(response.content)
    except ValidationError:
        # Do not leak validation errors: they may contain generated text.
        raise InferenceError("invalid_response") from None
1. 상대 경로 요청

선택된 생성용·확인용 client의 base URL 뒤에 /health 같은 상대 경로가 붙는다.

2. 전체 deadline

asyncio.timeout이 client.request 구간의 pool 대기·연결·본문 수신을 제한한다. 그 뒤의 동기 JSON 검증은 포함하지 않는다.

3. 상태 code 정규화

알려진 status·code 조합은 세부 범주로, 나머지는 status 기반 공통 범주로 바꾼다.

4. JSON 재검증

200 응답도 schema에 맞아야만 Python object로 반환한다.

raise ... from None은 왜 쓰는가?

기본 traceback 출력에서 앞서 발생한 예외의 연결 표시를 생략한다. 원본 예외·HTTP body를 메모리에서 지우거나 __context__ 접근까지 막는 보안 기능은 아니다. 따라서 raw exception이나 response body를 따로 로그에 남기지 않는 정책도 필요하다. Adapter를 호출하는 route는 안정된 InferenceError만 처리한다. 그렇다고 원인을 무시하는 것은 아니다. code와 upstream status를 별도로 남기며, 향후 구조화 log에서 요청 ID와 함께 관찰한다.

전체 deadline이라는 말의 정확한 범위
generate: payload 구성 → [asyncio.timeout: 풀 대기 → TCP 연결 → 응답 본문 수신] → HTTP status 분류 → JSON 검증 → model ID 확인 → 내부 결과 구성 check_ready: [health 요청의 deadline] → [models 요청의 별도 deadline] → 설정 model ID 확인

기본 5초는 readiness 전체가 아니라 각 HTTP 검사 요청에 적용된다. health가 4초, models가 4초 걸리면 각각은 제한 안이지만 두 요청을 합친 readiness는 약 8초가 될 수 있다. 또한 asyncio의 취소는 event loop가 실행 기회를 얻어야 전달되므로 CPU를 오래 점유하는 동기 코드를 강제로 끊는 hard real-time 제한은 아니다. 현재 테스트는 응답을 보류한 실제 socket에서 timer가 작동하는지를 확인한다.

timeout과 사용자의 취소는 같은 오류인가?

아니다. 실제 전체 deadline을 넘으면 asyncio.timeout이 만든 TimeoutErrorInferenceError("timeout")으로 바꾼다. 연결 풀의 자리만 기다리다 제한을 넘으면 PoolTimeout을 먼저 잡아 busy로 구분한다.

호출자가 작업을 취소해서 발생한 CancelledError는 잡지 않으므로 그대로 전파된다. 실제 socket 테스트에서 양쪽 요청의 취소·시간 초과 후 연결이 닫히고 다음 요청이 가능한지 확인했다. 이것만으로 원격 Gemma GPU 작업의 즉각적인 중단까지 증명한 것은 아니며, browser disconnect 연동은 streaming 단계다.

실제 deadline·pool timeout·취소 테스트 읽기

자동 retry는 없다. 생성 요청이 실제로 실행됐지만 응답만 끊어진 상황에서 재시도하면 같은 답변 생성을 두 번 수행할 수 있다. 8GB 환경의 queue를 더 압박하고 대화 순서를 망칠 수 있어 retry 정책은 호출자가 명시적으로 결정해야 한다.

Readiness: 생존과 model ID를 함께 확인한다

async def list_models(self) -> tuple[ModelInfo, ...]:
    result = await self._request(
        "GET", "/v1/models", _Models,
        client=self._probe_client, deadline=self._probe_timeout,
    )
    return tuple(ModelInfo(id=model.id) for model in result.data)

async def check_ready(self) -> ModelInfo:
    await self._request(
        "GET", "/health", _Health,
        client=self._probe_client, deadline=self._probe_timeout,
    )
    models = await self.list_models()
    for model in models:
        if model.id == self._model:
            return model
    raise InferenceError("model_mismatch")

/health만 보면 다른 설정의 TurboFieldfare가 같은 port에 떠 있어도 ready로 오판할 수 있다. 그래서 model 목록에 설정한 ID가 실제로 있는지까지 확인한다. 현재 설정의 기본값은 gemma-4-26b-a4b-it이며 이 method에 고정된 문자열은 아니다. 반대로 readiness에서 token 생성을 하지 않는 이유는 probe 한 번마다 수초의 GPU·SSD 작업과 queue 점유가 생기기 때문이다.

Generation: 내부 요청을 전송 JSON으로 바꾼다

async def generate(self, request: GenerationRequest) -> GenerationResult:
    payload = {
        "model": self._model,
        "messages": [message.model_dump() for message in request.messages],
        "max_completion_tokens": request.max_output_tokens,
        "temperature": request.temperature,
        "stream": False,
    }
    result = await self._request(
        "POST", "/v1/chat/completions", _Completion,
        client=self._generation_client,
        deadline=self._generation_timeout, payload=payload,
    )
    if result.model != self._model:
        raise InferenceError("model_mismatch")
    choice = result.choices[0]
    return GenerationResult(
        id=result.id, model=result.model, text=choice.message.content,
        finish_reason=choice.finish_reason,
        usage=TokenUsage(
            input_tokens=result.usage.prompt_tokens,
            output_tokens=result.usage.completion_tokens,
            total_tokens=result.usage.total_tokens,
            cached_input_tokens=result.usage.prompt_tokens_details.cached_tokens,
        ),
    )

실제 값의 변환

내부 값wire JSON응답 후 내부 값
max_output_tokens=16"max_completion_tokens":16output_tokens=3
temperature=0"temperature":0.0샘플링 결과 자체는 text로 전달
Message(role="user", ...)messages[0]choices[0].message.content
없음"stream":falseGenerationResult 하나

stream=false는 이번 단계의 의도적인 경계다. SSE는 chunk를 읽으면서 즉시 browser로 넘기고 client disconnect 때 upstream을 취소해야 하므로 별도의 lifecycle을 갖는다. 다음 streaming 단계에서 버퍼링 없이 추가한다.

초기 실제 서버 검증 결과와 해석

HTTP Request: GET /health → 200 HTTP Request: GET /v1/models → 200 HTTP Request: POST /v1/chat/completions → 200 GenerationResult: text = "READY." finish_reason = "stop" input_tokens = 18 output_tokens = 3 total_tokens = 21 cached_input_tokens = 0

MockTransport test는 정확한 outgoing JSON, 400·429·500 mapping, 연결 거절, token 합계 불일치를 검사했다. Live test는 같은 코드가 macOS host와 Docker container에서 실제 Swift server까지 도달함을 확인했다.

보완 후 회귀 검증

새 검증은 실제 모델 대신 통제된 loopback 서버를 사용한다. 생성 연결 4개 포화 중 readiness 정상 동작, 모델 불일치 유지, 실제 시간 제한·취소 후 연결 정리, 안전한 오류 분류를 확인했다.

연결 분리 검증 · 오류 분류·비노출 검증

← 이전모델 독립 계약다음 →Lifespan에서 조립