서비스 학습
BACKEND · STEP 02 · 2026-09-06

FastAPI와 추론 서버 사이에
안정적인 번역 경계를 만든다

TurboFieldfare inference adapter는 모델 자체가 아니다. FastAPI 내부의 생성 요청을 TurboFieldfare HTTP JSON으로 번역하고, 응답과 실패를 서비스가 이해하는 일정한 형식으로 되돌리는 연결 계층이다.

HTTPX2 AsyncClientPydanticProtocol76 testsLive Gemma verified
Why this step

왜 채팅 route보다 Adapter를 먼저 만드는가?

브라우저 요청을 받는 route가 TurboFieldfare의 URL, max_completion_tokens, OpenAI 응답의 choices[0], 오류 JSON까지 직접 알게 되면 추론 서버의 작은 변경이 route·RAG·MCP·test 전체로 퍼진다. 반대로 application이 generate(GenerationRequest)만 알면 TurboFieldfare 전용 지식은 한 파일에 머문다.

이번 단계의 질문: “FastAPI 코드가 Gemma를 직접 로드하지 않으면서도, 검증된 내부 계약을 통해 macOS의 기존 TurboFieldfareServer를 호출할 수 있는가?” 실제로 host와 Docker 양쪽에서 확인했다.
Mental model

Adapter는 두 언어를 아는 통역사다

ApplicationGenerationRequest

messages, max_output_tokens, temperature

Translate outTurbo JSON

model, max_completion_tokens, stream=false

Swift serverGemma 실행

OpenAI-compatible response

Translate inGenerationResult

text, finish_reason, TokenUsage

여기서 “OpenAI-compatible”은 모든 OpenAI API를 지원한다는 뜻이 아니다. 현재 Swift 서버가 제공하는 정확한 세 endpoint와 필드만 사용한다. Adapter는 예상한 응답 구조를 Pydantic으로 다시 검증하므로 HTTP 200이라도 필수 필드가 틀리면 성공으로 위장하지 않는다.

실제 파일 구조와 책임

아래 파일들 사이를 요청이 어떤 순서로 옮겨 다니는지는 요청 흐름 연결도에서 흐름별로 볼 수 있다.

주요 파일 발췌 · __init__.py 등은 생략 be/app/ ├── main.py client 생성·종료, adapter 조립 ├── core/config.py upstream 주소·model·timeout 설정 ├── engines/ │ ├── base.py 모델 독립 내부 계약 │ ├── turbofieldfare.py TurboFieldfare wire format 번역 │ └── smoke.py 실제 연결·생성 관찰 도구 ├── api/ │ ├── router.py route 묶음을 모아 app에 연결 │ ├── dependencies.py route에 engine 전달 │ └── routes/ │ ├── system.py /healthz와 /readyz │ └── chat.py POST /api/v1/chat · 오류를 status로 변환 └── schemas/ ├── system.py 두 상태 endpoint의 JSON 계약 └── chat.py 브라우저 요청·응답 계약과 입력 상한
소스답하는 질문상세 문서
engines/base.py서비스가 추론 엔진에 요구하는 최소 능력은?내부 계약
engines/turbofieldfare.py내부 객체와 Swift JSON은 어떻게 변환되는가?Adapter 구현
main.py생성·probe client를 왜 lifespan 동안 각각 공유하는가?main.py 상세
core/config.pyhost와 Docker 주소 및 timeout은 어떻게 나뉘는가?config.py 상세
api/router.pymain.py는 어떻게 URL 경로를 모른 채 route를 붙이는가?Route 모으기
api/dependencies.pyroute는 구체 adapter를 어떻게 모른 채 사용하는가?의존성 주입
api/routes/system.pyLiveness와 readiness는 무엇이 다른가?상태 route
schemas/system.py상태 응답의 JSON 형태는 어디에서 강제되는가?응답 schema
schemas/chat.py브라우저가 보낼 것과 받을 것은 어디에서 정해지는가?브라우저 계약
api/routes/chat.py추론 실패 하나가 어떻게 HTTP status가 되는가?채팅 route
engines/smoke.py웹 port 없이 실제 adapter를 어떻게 관찰하는가?Smoke test
Verification

이 단계의 자동 테스트는 무엇을 각각 붙잡는가

현재 47 passed는 여섯 파일에서 나온다. 아래 표는 각 파일이 지키는 서로 다른 질문을 정리한다. 테스트는 대부분 추론 서버 없이 실행되며, 실제 Gemma 생성 확인은 smoke.py가 담당한다.

테스트 파일붙잡는 질문상세 문서
tests/test_health.py추론 서버가 죽어도 /healthz는 200을 유지하는가?상태 endpoint 검증
tests/test_config.py잘못된 URL과 timeout이 시작 순간에 거부되는가?설정 검증
tests/test_chat.py채팅 API가 입력을 거르고 오류를 올바른 status로 바꾸는가?채팅 API 검증
tests/test_readiness_isolation.py생성 요청이 연결을 점유해도 readiness가 답하는가?연결 분리 검증
tests/test_inference_timeouts.py실제 TCP에서 deadline과 취소가 전파되는가?시간 초과·취소 검증
tests/test_inference_lifespan.py정상·예외·취소 종료에서 client가 모두 닫히는가?수명 관리 검증
tests/test_turbofieldfare_adapter.pyupstream 오류가 정해진 코드로 변환되는가?오류 변환 검증
증명하지 않는 것: 이 47개는 실제 Gemma 생성 품질, 브라우저 disconnect 연동, streaming 취소를 검증하지 않는다. 그 확인은 4단계에 남아 있다.

이 backend를 실행하기 위한 주변 파일도 함께 문서화되어 있다. 설치할 package와 버전 고정은 requirements.txt, 환경변수 견본과 host.docker.internal 주소 문제는 .env.example, container image로 만드는 과정과 .dockerignore의 역할은 be/Dockerfile 상세에서 읽는다.

Boundary

범용 계약과 TurboFieldfare wire format을 구분한다

Application 내부TurboFieldfare로 보낼 때분리 이유
max_output_tokensmax_completion_tokens특정 API의 명명법을 application에 퍼뜨리지 않는다.
input_tokensusage.prompt_tokens다른 엔진이 다른 응답 구조를 써도 usage 소비자는 유지된다.
cached_input_tokensprompt_tokens_details.cached_tokensTurboFieldfare의 prefix KV 재사용 지표를 보존한다.
InferenceError("busy")HTTP 429 + queue_fullroute가 Swift 오류 envelope를 직접 해석하지 않는다.
이 구조가 Qwen을 바로 실행하게 해주는가?

아니다. Adapter 분리는 “호출하는 쪽”의 교체 비용을 줄일 뿐이다. Qwen을 실제로 실행하려면 tokenizer, chat template, MoE geometry, weight 변환, Metal kernel 등 추론 runtime 포팅이 별도로 필요하다. 다만 그 runtime이 같은 내부 계약을 구현하면 route와 RAG 코드는 덜 바뀐다.

Lifecycle

왜 HTTP client를 요청마다 만들지 않는가?

Uvicorn startup

lifespan이 HTTPX2 AsyncClient 두 개를 만든다. 생성용은 최대 연결 4개, probe용은 최대 2개로 서로 다른 풀을 사용한다. 이 시점에는 네트워크 연결이나 모델 로딩이 발생하지 않는다.

Adapter 조립

두 client, model ID, probe·generation timeout을 Adapter에 전달하고 application.state에 둔다.

여러 요청이 공유

연결 pool이 keep-alive TCP 연결을 재사용한다. 요청마다 client를 만들 때 생기는 handshake와 socket 낭비를 줄인다.

Uvicorn shutdown

finally로 참조를 제거하고 async context가 두 client와 열린 연결을 닫는다. TurboFieldfare는 FastAPI가 시작한 process가 아니므로 종료하지 않는다.

Failure model

오류를 숨기지 않고, 외부 문구에도 종속되지 않는다

관찰된 조건내부 error code의미
연결 거절·DNS·network 오류unavailable연결 수립 또는 통신에 실패했다. 응답 도중 단절도 포함될 수 있다.
정해진 시간을 초과timeout연결 또는 작업이 deadline을 넘었다.
connection pool 대기 초과 또는 HTTP 429busy현재 수용 여력이 없다.
400 + context_length_exceededcontext_too_long모델의 문맥 길이 제한을 넘었다.
400 + invalid_messageinvalid_message메시지 내용이나 순서가 잘못됐다.
404 + model_not_foundmodel_mismatch설정 모델을 서버가 제공하지 않는다.
그 밖의 HTTP 4xx (429 제외)request_rejected미지의 오류·깨진 오류 JSON도 안전한 공통 범주로 처리한다.
5xx 또는 예상하지 않은 비-200 status (4xx 제외)upstream_error서버 오류뿐 아니라 따라가지 않는 3xx redirect·예상하지 않은 2xx도 이 범주다.
HTTP 200이지만 JSON 계약 불일치invalid_response응답을 신뢰하고 전달할 수 없다.
설정한 model ID가 목록/응답과 다름model_mismatch잘못된 server나 model에 연결했을 수 있다.

원래 upstream 오류 문구와 생성 텍스트는 exception 문자열에 복사하지 않는다. 이후 로그가 추가되어도 prompt나 답변이 우연히 노출될 가능성을 줄이기 위해서다.

Liveness vs readiness

/healthz/readyz는 서로 다른 질문이다

GET /healthz
FastAPI가 응답하는가?

TurboFieldfare가 꺼져 있어도 200이다. process와 기본 HTTP stack의 생존 신호다.

GET /readyz
설정한 모델에 도달하는가?

/health/v1/models를 호출한다. 연결 실패나 model 불일치면 503이다.

중요한 한계: readiness 200은 그 순간 server와 model ID가 확인됐다는 뜻이다. 긴 생성 성공, queue 빈자리, 답변 품질까지 보장하지 않는다. Docker의 HEALTHCHECK도 상태를 표시할 뿐, Docker Engine 단독으로 unhealthy container를 자동 재시작하지 않는다.
Review fixes · 2026-09-06

검토에서 발견한 네 문제를 어떻게 보완했나?

보완 항목변경과 증거코드 해설
Readiness 연결 분리생성 4개 점유 중에도 상태 전용 연결로 검사. 정상 모델 200, 다른 모델 503/model_mismatch.연결 분리 테스트
오류 분류 보강문맥 초과·메시지 오류·모델 불일치를 구분. 알려진 status·code만 매핑하고 원문은 비노출.오류 변환 테스트
실제 회귀 검증실제 deadline·pool timeout·취소 후 socket 반환 및 후속 요청, 정상·예외·취소 lifespan 정리 확인.시간 초과·취소
수명 관리
학습 문서 정정서비스용 용어 사전을 별도로 적용. FastAPI Router·app.state와 MoE·React 의미를 구분하고 코드·결과 갱신.용도별 연결 구조
연결 풀과 대기열: max_connections는 TCP 연결 수만 제한한다. 빈 연결을 기다리는 요청 개수까지 제한하지 않는다. 생성 4 + probe 2는 worker별 client 설정이며, Gemma를 6개 동시에 실행한다는 뜻도 아니다. 상태 확인 풀 자체나 실제 upstream의 과부하까지 제거한 것은 아니다.

2단계 보완 시점의 자동 테스트는 47 passed였고, 3단계 채팅 API까지 더해 현재 76 passed다. 수정된 local-moe-be:step2 이미지도 빌드했다. 외부 네트워크 없는 임시 container에서 non-root 실행, /healthz=200, 연결 불가 시 /readyz=503/unavailable, 새 오류 schema와 lifespan 종료를 확인했다. 이 Docker 검증은 ASGI TestClient 경로이며 실제 Gemma 생성이나 공개 port 연결을 재측정한 것은 아니다.

Observed result

자동 검증과 초기 live 검증을 구분해서 읽기

Unit/API76 passed

자동 검증

초기 Host liveHTTP 200

health·models·generate

초기 Docker livehealthy

user=app

초기 실제 연결reachable

host.docker.internal

GET /readyz → HTTP 200 {"status":"ready","dependency":"inference","error_code":null} POST /v1/chat/completions through adapter text="READY." finish_reason="stop" input=18 output=3 total=21 cached_input=0

위 생성 기록은 연결 분리 전 초기 구현의 실제 Gemma 결과이며, 이번 문서 검토의 재측정값은 아니다. 요청 문자열 자체가 Reply with exactly READY.였고 결과도 READY.였으므로, 마침표가 있다는 이유만으로 지시 불이행이라고 해석할 수 없다. Adapter는 응답 문자열을 그대로 전달했다. 당시 host와 Docker의 생성은 각각 약 13초였고 cached token 관찰값은 0이었다. Smoke 명령은 이전 assistant 응답과 후속 user 메시지를 이어 보내지 않으므로, 이 관찰만으로 대화 continuation의 캐시 재사용을 검증한 것은 아니다.

증명한 것: lifecycle, 실제 Swift endpoint, model ID 확인, 비스트리밍 생성, 응답 검증·변환, Docker→host 연결이 동작한다.
아직 증명하지 않은 것: browser용 채팅 route, SSE streaming, disconnect 취소, tool call, 대화 저장, 인증은 의도적으로 다음 단계에 남아 있다.

용어 설명은 어떻게 서비스 문맥을 유지하는가?

각 HTML은 data-study-context="backend" 또는 frontend 값을 선언한다. service-terms.js가 먼저 서비스용 설명을 준비하고, 공통 terms.js가 이를 반영해 굵은 밑줄과 클릭 팝업을 만든다. 폴더 경로를 추측하지 않고 HTML의 명시적 문맥을 사용하므로 문서 폴더를 옮겨도 의미가 바뀌지 않는다.

Backend의 Router는 HTTP 요청을 함수로 연결하며, app.state는 server의 공유 객체 보관 공간이다. React의 state는 화면 렌더링에 쓰이고, 기존 MoE 학습 페이지의 Router는 계속 Expert 선택기로 설명된다. 코드 블록·SVG 내부에는 팝업용 HTML을 끼워 넣지 않아 코드 표시와 도형 글자가 변형되지 않게 했다.

문서 검증을 다시 실행하는 방법

# 프로젝트 루트에서 be/.venv/bin/python 00_docs/service/tools/check_docs.py node 00_docs/service/tools/check_browser.mjs

첫 도구는 Python 표준 라이브러리만 사용해 17개 HTML의 상대 링크·asset 존재, 용어 사전 로딩 순서, 전체 소스 6개와 Adapter 코드 구간 5개의 일치를 확인한다. 둘째는 Node 22와 Mac의 Chrome을 이용해 1440·820·390px에서 페이지 전체 가로 넘침, JavaScript 오류, 외부 HTTP resource 요청 여부와 실제 용어 팝업을 검사한다. npm 설치나 문서용 HTTP server는 필요 없다.

두 검사를 통과했다. Chrome 임시 프로필만 사용하며 기존 브라우저 프로필에는 접근하지 않는다. 이는 실제 Safari·iPad에서 Apple Pencil까지 재검증했다는 뜻이 아니다.

← 이전Nginx 설정다음 →모델 독립 계약