서론
지난 글에서는 오픈형 Jev 호환 모델인 open-jev-deberta-v3-large를 AWS 도쿄 리전의 Databricks에서 구동했습니다. AI Runtime으로 평가하고 Model Serving의 RTX PRO 6000으로 서비스하고 있습니다.
이전 엔드포인트는 Model Serving 표준의 독자적인 요청 형식이었습니다.
반면, Jev를 사용한 코드는 TypeSafe AI의 API나 공식 SDK에 맞춰 작성되어 있는 경우가 많습니다.
- 이미 TypeSafe의 SDK로 작성된 코드를 그대로 Databricks 상의 모델에 적용하고 싶을 때
- 우선은 로컬 코드를 변경하지 않고 자체 환경에서 구동했을 때의 동작을 확인하고 싶을 때
이러한 케이스에 대응할 수 있도록, 이번에는 open-jev를 TypeSafe AI의 API와 동일한 사양으로 공개해 보았습니다.
그 결과, 공식 Python SDK(typesafe-sdk)의 base_url과 api_key만 변경하면 Databricks 상의 open-jev를 호출할 수 있게 되었습니다.
검증에 사용된 코드는 지난번과 동일한 리포지토리에 추가했습니다.
이번에 하고 싶은 것
TypeSafe의 공식 SDK를 사용한 코드는 예를 들면 다음과 같습니다.
from typesafe_sdk import TypeSafeClient, Noul
client = TypeSafeClient(api_key="<TypeSafe의 API 키>")
r = client.system_one(...)
이것을 연결처와 인증 정보만 수정하여 Databricks 상의 open-jev를 대상으로 하는 것이 이번 목표입니다.
client = TypeSafeClient(
base_url="https://<app-name>-<workspace-id>.aws.databricksapps.com", # Databricks Apps의 URL
api_key="<Databricks의 OAuth 토큰>",
...
공식 SDK는 원래 base_url
(또는 환경 변수 TYPESAFE_BASE_URL)
로 연결처를 변경할 수 있도록 되어 있습니다.
SDK 문서에도
SDK(이번에는 0.7.3)의 소스를 읽어보면, 응답 타입은 OpenAPI 정의로부터 자동 생성되었으며, pydantic의 strict 모드로 검증되고 있었습니다.
숫자 타입이나, score의 legend / probabilities / 의 키("0", "1", ...)까지 사양대로 반환해야 합니다.
구성
Model Serving의 엔드포인트는 URL이 /serving-endpoints/<이름>/invocations로 정해져 있습니다.
요청과 응답 역시 MLflow 형식(dataframe_records나 predictions)으로 감싸집니다.
따라서 TypeSafe와 동일한 /v1/systemone 경로를 만들기 위해, Databricks Apps에서 작은 게이트웨이를 구축하는 2단계 구조로 만들었습니다.
TypeSafe 공식 SDK / curl
│ POST /v1/systemone, GET /v1/models
│ Authorization: Bearer <Databricks의 OAuth 토큰>
...
역할은 다음과 같이 분리했습니다.
| 부품 | 파일 | 역할 |
|---|
| 변환 로직 | typesafe_adapter.py | TypeSafe 요청 ⇄ open-jev 변환, 422 바리에이션 처리 |
| 서빙용 모델 | typesafe_pyfunc.py, register_typesafe.py | 변환 로직을 결합한 pyfunc를 env_pack과 함께 Unity Catalog에 등록 |
| 게이트웨이 | typesafe_gateway/ (app.py, app.yaml) | TypeSafe와 동일한 경로로 받아서 엔드포인트로 중계 |
변환 규칙은 모두 모델 측에 맡기고, 게이트웨이는 내용물(중심)을 보지 않고 중계하는 역할만 하도록 했습니다.
이렇게 하면 엔드포인트를 직접 호출해도 같은 결과를 얻을 수 있고, 모델을 교체할 때도 게이트웨이에 손댈 필요가 없습니다.
① TypeSafe의 질문을 open-jev용으로 변환하기
open-jev는 "질문별로 선택지 텍스트 리스트를 채점하는" 모델입니다.
따라서 TypeSafe의 질문을 다음과 같이 open-jev의 선택지에 맞게 조정했습니다.
| TypeSafe | open-jev에서의 처리 |
|---|
choice의 criteria (이름 → 설명문) | 이름을 선택지 텍스트로 전달하고, 반환하는 choice도 이름으로 합니다. |
score의 criteria (레벨 배열) | 배열을 그대로 순서가 있는 레벨로 전달하고, legend를 {"0": ..., "1": ...} 형태로 반환합니다. |
noul | open-jev의 noul ("no" / "yes" 2지 선택지)을 그대로 사용하며, p(yes)를 반환합니다. |
객체나 배열의 state・instructions | JSON 문자열로 만들어 전달합니다. |
usage.input_tokens | 실제 입력된 토큰 수입니다. |
usage.output_tokens | 문장을 생성하지 않으므로 0입니다. |
흥미로운 점은 noul입니다. open-jev의 noul은 내부적으로 "no"와 "yes"의 2지 선택지로 구현되어 있습니다.
따라서 TypeSafe의 criteria.true / criteria.false (Yes와 No 각각의 설명문)가 들어올 때는, 이 두 설명을 선택지 텍스트로 대체하는 것만으로 자연스럽게 대응할 수 있습니다.
변환 결과는 TypeSafe의 응답 형태에 맞추어 구성하여 반환합니다.
def build_answers(items, probs):
answers = {}
for it, p in zip(items, probs):
...
이 변환 로직은 torch나 MLflow에 의존하지 않는 순수한 Python으로 만들었습니다.
따라서 실제로 공식 SDK가 구성한 요청을 넣어보고, 반환된 응답을 SDK의 파서로 읽을 수 있는지 직접 확인할 수 있습니다.
설명 문은 어떻게 전달하는 것이 좋을까
여기서 고민했던 부분은 choice의 설명문 처리 방식이었습니다.
TypeSafe에서는 "billing: Charges, payments, invoices, and refunds"와 같이 설명문을 덧붙이는 것이 일반적입니다.
반면에 open-jev는 "fees & charges"처럼 짧은 라벨로 학습되었습니다.
그래서 전달 방식에 따라 정확도가 어떻게 달라지는지 AI Runtime (A10)에서 측정해 보았습니다.
| 항목 | 내용 |
|---|
| 스크립트 | eval_criteria_modes.py (AI Runtime CLI로 실행) |
| 평가 데이터 | kotoba-lang/typed-decisions data-fam/test.jsonl (커밋 e65215d) 중 banking77과 SST-5의 2,000개 텍스트 |
| 방법 | 정답 라벨이 있는 질문을 TypeSafe 형식으로 재작성하고, 선택지에는 수동 설명문을 추가함 |
| 질문 유형 | 전달 방식 | accuracy |
|---|---|
| choice (2,000문) | 학습 시의 라벨 그대로 (참고) | 0.866 |
| 이름만 | 0.855 | |
|
| 이름: 설명문 | 0.807 | |
| 설명문만 | 0.745 | |
| noul (2,000문) | criteria를 사용하지 않음 | 0.935 |
| true / false의 설명문을 2지선다로 변경 | 0.914 |
결과는 다소 의외여서, 설명문을 추가할수록 정확도가 떨어졌습니다.
학습 때와 같은 '짧은 라벨' 형태에 가깝게 전달하는 것이 모델이 선호하는 방식인 것 같습니다.
그래서 기본값은 다음과 같이 설정했습니다. 둘 다 등록 시 설정에서 전환 가능합니다.
choice는 이름만을 전달합니다. 이름의 _는 공백으로 대체되므로, fees_and_charges처럼 의미가 명확한 이름을 붙이는 것이 좋습니다. -
noul의 criteria는 instructions가 없을 때만 사용합니다.
② Model Serving에 올리기
서빙용 모델은 지난번과 마찬가지로 MLflow의 env_pack (GPU Express Deployment)으로 등록했습니다.
RTX PRO 6000은 Blackwell 세대 GPU이므로, CUDA 12.8 이상 PyTorch가 포함된 환경 전체를 패킹하여 서빙 측에서 그대로 복원할 필요가 있습니다.
자세한 내용은 지난 기사를 참고해 주십시오.
pyfunc는 한 줄에 TypeSafe의 요청 1건을 JSON 문자열로 받는 형태로 만들었습니다.
TypeSafe의 questions는 질문명마다 내용의 형태가 달라지므로, MLflow의 타입으로 분해하기보다는 문자열 그대로 받아 변환 로직에 맡기는 것이 더 자연스럽습니다.
# 입력 라인: {
## 인증은 Apps가 처리해준다
호출하는 쪽에서는 `Authorization: Bearer <Databricks의 OAuth 토큰>`만 붙여주면 됩니다. 이는 TypeSafe SDK가 `api_key`를 그대로 Bearer 토큰으로 전송하는 동작과 일치합니다. 앱 코드에는 토큰을 검증하는 처리가 없습니다.
호출하는 쪽에서는
**엔드포인트는 앱의 서비스 프린시펄로 호출 가능**
`app.yaml`에서 엔드포인트를 리소스로 선언하고 'Can query' 권한만 부여하면 됩니다. 토큰이나 시크릿을 앱에 내장할 필요가 없습니다.
**접근 권한은 Databricks 내부에서 관리 가능**
누가 앱을 호출할 수 있는지, 앱이 어떤 엔드포인트를 호출할 수 있는지를 워크스페이스의 권한으로 관리할 수 있습니다.
command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
env:
- name: SERVING_ENDPOINT
...
`model`에는 `jev-latest`도 받을 수 있도록 되어 있어, SDK의 기본값 그대로 사용해도 작동합니다.
배포는 Databricks CLI로 몇 개의 명령어로 끝납니다.
databricks apps create --json '{
"name": "open-jev-typesafe-gw",
"resources": [{"name": "serving-endpoint",
...
## ④ 공식 SDK에서 호출해보기
드디어 공식 `typesafe-sdk`에서 호출해봅니다.
`sdk_smoke_test.py`를 사용했습니다.
SDK 측 코드는 `base_url`과 `api_key` 외에는 평소와 같습니다.
```python
from typesafe_sdk import TypeSafeClient, Choice, Noul, Score
with TypeSafeClient(api_key=databricks_token, base_url=app_url) as client:
r = client.system_one(
...
실행 결과는 다음과 같습니다.
models: ['jev-latest', 'open-jev-deberta-v3-large']
first call 0.9s model=open-jev-deberta-v3-large usage=input_tokens=62 output_tokens=0
requests_refund.noul = 0.917
...
models.list()와 system_one() (noul / choice / score)가 SDK의 엄격한 검증을 거쳐 평소처럼 사용되었습니다. * state를 객체로 전달하는 방식도 그대로 작동했습니다. * 잘못된 요청은 SDK의 TypeSafeUnprocessableEntityError (422)로 반환됩니다. * TypeSafe 문서에 나와 있는 curl 예시를 그대로 보내도, TypeSafe와 같은 형태의 응답이 돌아옵니다.
속도에 대해서는, SDK에서 호출한 왕복 시간이 p50 80ms, 최대 167ms였습니다. 조건은 일본 국내 PC에서 도쿄 리전으로 3문제 요청을 30회입니다.
게이트웨이를 한 단계 거치더라도 충분히 인터랙티브하게 사용할 수 있는 속도라고 생각합니다.
어려웠던 점 (ハマりどころ)
마지막으로, 만들면서 깨달은 몇 가지를 공유합니다.
- SDK는 응답을 엄격하게 검증한다 - 확률 등의 수치는 반드시 float로 반환해야 합니다.
- score의
legend와 probabilities는 `
토큰 없는 요청은 302로 처리됩니다 - 인증되지 않은 요청에는 Apps가 로그인 화면으로 리디렉션 응답을 반환합니다
OAuth 토큰에는 유효기간이 있습니다 -
databricks auth token
으로 얻은 토큰은 약 1시간 후에 만료됩니다. 장시간 구동되는 앱에서 호출하는 경우, 호출 측에서 토큰을 갱신하는 메커니즘을 준비해야 합니다.
scale-to-zero 엔드포인트는 첫 번째 호출에 시간이 걸립니다 - SDK의
timeout
을 길게 설정해 두면 안심할 수 있습니다.
맺음말
이번에는 open-jev를 TypeSafe AI와 동일한 사양으로 공개하여, 공식 SDK 그대로 Databricks 상의 모델을 호출할 수 있도록 해보았습니다.
Model Serving에 변환 로직이 포함된 모델을 올려 RTX PRO 6000에서 고속 추론을 수행하고 -
Databricks Apps로 작은 게이트웨이를 세워 TypeSafe와 동일한 경로 및 인증 형태로 맞추었습니다. 공식 SDK는
. 기존 코드를 수정하지 않고, Databricks의 관리 하에서 구동할 수 있는 base_url
과 api_key
만 변경하면 됩니다.
모델도 API도 데이터도 모두 Databricks 안에서 완결됩니다. Unity Catalog를 통한 권한 관리 및 버전 관리 위에서 운영할 수 있다는 점이 든든하게 느껴졌습니다.
향후 계획
이번 검증을 통해 TypeSafe 방식의 '설명문이 있는 선택지'는 open-jev가 학습한 레이블 형태와 잘 맞지 않는다는 것을 알게 되었습니다.
설명문이 포함된 데이터로 추가 학습하거나, 일본어 데이터로 평가 및 튜닝함으로써 정확도를 높일 여지는 아직 많이 남아있다고 생각합니다.
지난번 숙제였던 모델 최적화와 함께, 계속해서 그 방법을 찾아가고자 합니다.
참고 자료
- 지난 글: 오픈된 Jev(open-jev)를 Databricks의 AI Runtime과 RTX PRO 6000 Model Serving으로 구동해 본 경험
- hiouchiy/open-jev-on-databricks (GitHub)
- TypeSafe AI API Reference
- TypeSafe Python SDK — Usage
- com-kotobalabs/open-jev-deberta-v3-large (Hugging Face)
- kotoba-lang/typed-decisions (GitHub)
- Databricks Apps (Databricks 문서)
- Serve custom LLMs — GPU workload types (Databricks 문서)
토론