TypeSafe

TypeSafe (Jev) 모델

Jev는 언어 모델이 아니에요. 텍스트와 타입이 지정된 질문을 주면 각 질문을 신뢰도(confidence)와 함께 답해요. 텍스트를 작성하지는 않아요.

TypeSafeModel을 쓰면 무언가를 결정하는 일을 맡은 에이전트를 Jev에서도 다른 모델처럼 실행할 수 있어요. output_type의 각 필드가 하나의 질문이 되고, 프롬프트는 텍스트이며, 답이 출력으로 돌아와요. 그래서 여러 필드를 가진 Pydantic 모델은 한 번의 요청으로 여러 값을 추출해요. 모델 이름만 바꾸면 같은 에이전트가 언어 모델에서도 실행되므로 둘을 비교할 수 있어요.

출처: 문서

본문

설치하기

TypeSafeModel을 쓰려면 pydantic-ai-slim(또는 pydantic-ai)을 typesafe 옵션 그룹과 함께 설치하면 돼요.

pip install "pydantic-ai-slim[typesafe]"
uv add "pydantic-ai-slim[typesafe]"

구성하기

TypeSafe API를 통해 Jev를 사용하려면 TypeSafe 계정에서 API 키를 받아 환경 변수로 설정하세요:

export TYPESAFE_API_KEY='your-api-key'

그러면 Jev가 채워야 할 output_type과 함께 TypeSafeModel을 이름으로 사용할 수 있어요:

from enum import Enum

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Verdict(str, Enum):
    """Run it, reject it, or ask a human: reversible work runs, destructive or secret-leaking work is rejected."""

    run = 'run'
    reject = 'reject'
    ask = 'ask'


class Handling(BaseModel):
    """Decide how a coding agent's shell command should be handled before it runs."""

    verdict: Verdict
    irreversible: bool = Field(description='Would running this destroy data or leak secrets?')


agent = Agent('typesafe:jev-latest', output_type=Handling)
result = agent.run_sync('rm -rf ./build')
print(result.output)
#> verdict=<Verdict.ask: 'ask'> irreversible=True

아니면 모델 이름만으로 모델을 직접 초기화해도 돼요:

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModel

model = TypeSafeModel('jev-latest')
agent = Agent(model, output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

모델 이름

jev-latestjev-preview는 TypeSafe가 릴리스를 배포할 때 이동하는 별칭이에요. preview 빌드가 있으면 jev-preview가 앞서 실행돼요. 버전이 지정된 id도 (목록에 있든 없든) 받아들여져요:

from pydantic_ai import Agent

agent = Agent('typesafe:jev-1.13.0', output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

ModelResponse.model_name은 답한 버전 id를 보고해요. 그래서 jev-latest에 대해 기록된 실행도 어떤 모델이 만들었는지 알 수 있어요.

질문이 어디로 가는가

Jev는 두 가지를 분리해서 받아요. 판단할 재료(the material to judge)와 그것에 대해 물어볼 질문(the questions). TypeSafe 자신의 지침에 따르면 상태(state)는 "콘텐츠와 뒷받침하는 사실"을 담고 질문은 "그 재료에 대해 모델이 내려야 할 판단"을 담아요. 따라서 프롬프트는 판단당하는 것만 있고, 질문은 출력 타입에 있어요.

이것은 언어 모델이 가르쳐주는 습관과 반대예요. 언어 모델에서는 질문과 재료가 하나의 프롬프트에 함께 들어가고 모델이 그것들을 정리하죠. Jev는 그렇게 하지 않아요. 프롬프트에 적힌 질문은 판단당할 텍스트가 되고, Jev는 그것을 판단해요. 거의 아무것도 그걸 잡아주지 않아요. 물어볼 것이 전혀 없는 예/아니오는 요청이 전송되기 전에 거부돼요. 알몸의 bool이나 유계 float 출력은 의존할 필드 이름이 없어서, description도 instructions도 없으면 질문을 담지 못해 UserError가 돼요. 하지만 bool 필드 는 거부되지 않아요. 그 이름만으로 물어볼 수 있기 때문이에요. 그래서 질문을 잘못된 곳에 둔 것을 오류로 잡을 거라고 기대하면 안 돼요.

질문을 필드에 두고, 프롬프트는 티켓만 담게 하세요:

from typing import Literal

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')
    area: Literal['billing', 'bug', 'account', 'other'] = Field(description='Which team owns it?')


agent = Agent('typesafe:jev-latest', output_type=Ticket)
result = agent.run_sync(
    'You have charged me twice and my account is now overdrawn. I need this reversed today.'
)
print(result.output)
#> urgent=True area='billing'

단일 질문에는 에이전트의 instructions가 같은 일을 해요. Jev는 두 표기법을 동일하게 답해요. 그래도 출력 타입을 선호하세요. 각 필드가 자체 질문을 담아서 한 요청에서 여러 질문을 물을 수 있는데, 그게 Jev가 빠른 부분이거든요. 모든 질문에 적용되는 프레이밍(판단할 목소리, 도메인, 재료가 무엇인지)에는 instructions를 쓰고, 질문 자체는 질문이 하나이고 묘사할 필드가 없을 때만 사용하세요.

필드당 하나씩만 물어라

TypeSafe는 이것을 자체 가이드에서 "아마 가장 중요한 개념"이라고 불러요. 언어 모델에서 넘어오지 않는 유일한 습관이에요. 각 필드에 지식 있는 사람이 1초 안에 내리는 종류의 판단을 물으세요. 여러 가지를 한꺼번에 저울질하는 질문은 실패하지 않아요. 낮은 신뢰도의 그럴듯한 숫자를 반환하고, 나중에 알게 되죠.

그래서 하나의 필드에 'Is this a good pitch?'라고 물어보는 대신 세 개로 나눠 코드에서 결합하세요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Pitch(BaseModel):
    """Assess a startup pitch."""

    large_market: bool = Field(description='Does this address a market worth more than $1B a year?')
    technically_feasible: bool = Field(description='Could a small team build this with current technology?')
    differentiated: bool = Field(description='Does this do something competitors do not already do?')

    @property
    def promising(self) -> bool:
        return sum([self.large_market, self.technically_feasible, self.differentiated]) >= 2


agent = Agent('typesafe:jev-latest', output_type=Pitch)
result = agent.run_sync('A dashboard that shows every SaaS subscription a company pays for.')
print(result.output)
#> large_market=True technically_feasible=True differentiated=False
print(result.output.promising)
#> True

추가 필드는 거의 공짜예요. 모든 필드가 같은 요청에서 나가므로, 일부 입력에서만 필요한 필드는 시간이 아니라 토큰만 들여요.

Jev가 답할 수 있는 것

출력 타입의 각 필드는 질문이고, 전부 한 번의 요청으로 나가요. 중첩 모델의 필드는 그 자체로 하나의 질문이고, 옵션 리스트는 옵션당 하나의 예/아니오로 펼쳐져요:

필드 타입 질문
bool, 또는 Literal[True, False] 예 또는 아니오 Jev의 확률이 typesafe_boolean_threshold(0.5) 이상일 때 True
Literal[...] 또는 문자열 Enum 하나 고르기 선택된 옵션
ge=0이고 포함적 상한(le=)이 있는 float 예의 확률 반올림되지 않은 Jev의 확률, 필드 자체 단위로
0, 1, 2, ...IntEnum이고 각 멤버 아래 docstring 루브릭 대비 점수 가장 가까운 레벨
Literal 또는 Enumlist 옵션당 예/아니오 하나 Jev가 예라고 한 옵션들
Literal 또는 Enum에서 bool로의 dict 옵션당 예/아니오 하나 모든 옵션과 답
Literal[...] 또는 Enum, 또는 None 하나 고르거나, 아무것도 안 고르기 옵션 또는 None
이것들의 중첩 모델 outer.inner로 물어보는 필드들 모델

다른 타입의 필드는 요청이 전송되기 전에 UserError가 돼요. 메시지는 필드 이름을 말하고 지원되는 것을 나열해요. 예상할 것은 str, 무계의 intfloat, datetime, 옵션-대-예/아니오가 아닌 무엇이든의 dict, 그리고 필드로서의 모델 유니온이에요. 이것은 Jev가 채우라고 요청받는 타입의 필드에 관한 거예요. Jev가 채울 수 없는 유니온 멤버은 오류가 아니에요. 여전히 라우트로 제시되고, 그것을 고르면 그 단계를 Jev 뒤의 모델에게 넘겨줘요.

문구가 어디서 오는가

Jev가 읽는 것 어디서 오는가
질문 필드의 description(Field(description=...)), 또는 필드에 description이 없을 때 Enum 필드의 클래스 docstring
모든 질문의 목표 출력 타입의 docstring, 또는 툴의 description
모든 질문의 공유 프레이밍 에이전트의 instructions
각 옵션의 의미 스키마에서 그 옵션의 description

알몸의 bool, Literal, floatoutput_type이면 묘사할 필드가 없는 단일 질문이므로, 아래 신뢰도 예제에서처럼 에이전트의 instructions가 질문이 돼요.

스키마가 옵션을 묘사하지 않으면 Jev는 이름만으로 그것을 봐요. 그래서 LiteralEnum 옵션의 이름을 그것이 의미하는 바대로 지어야 해요. Literal은 옵션별 의미를 쓸 곳이 없어요. 두 옵션의 차이를 설명해야 하는 곳에서는 UseEnumMemberDocstrings를 믹스인한 Enum을 쓰고 각 멤버 아래에 docstring을 두면, 스키마의 각 옵션에 description이 붙어요.

각 매핑이 하는 일

숫자 필드의 경계는 두 번째 질문이 아니라 그것이 묻는 단위예요. ge=0, le=1은 Jev가 주는 대로의 확률이고, ge=0, le=100은 같은 답을 백분율로 쓴 것이에요. 옵션으로 키가 지정되고 bool로 값이 지정된 dict는 그 옵션들의 list가 묻는 것과 같은 것을 물어요(각각 예/아니오 하나). 답만 달라서, Jev가 예라고 한 것만이 아니라 모든 옵션을 유지해요.

선택적 pick-one 필드인 Area | None은 같은 질문에 옵션 하나가 더 있는 것이에요. "None of these." 그리고 Jev가 그것을 고르면 답은 None이에요. 낮은 신뢰도를 None으로 읽는 것이 아니라 명시적 옵션이에요. 그것은 필드의 신뢰도가 담당하는 일이죠. None이 고를 옵션 하나 더가 되어야 하므로 Literal 또는 Enum 문자열만 선택적일 수 있어요.

루브릭은 대안의 집합이 아니라 정렬된 레벨의 집합이에요. 0부터 위로 정수인데, 적어도 둘, 많아야 열 개이며, 각 레벨은 그것이 무엇을 의미하는지 말하는 스키마의 description이 필요해요. 정렬은 숫자 자체의 것이므로 레벨을 선언한 순서는 상관없어요. Jev는 루브릭을 따라 위치로 답하는데, 그것은 레벨 사이에 놓여요. 필드는 가장 가까운 것을 받고, 반올림은 올림이에요. 반올림되지 않은 위치는 provider_details['scores']에 있어요.

레벨의 description은 옵션의 의미가 하는 것과 같은 방식으로 스키마에 도달해요. 그래서 UseEnumMemberDocstrings를 믹스인한 IntEnum이 선언하는 방법이에요. 알몸의 Literal[0, 1, 2]나 평범한 IntEnumUserError예요. 레벨은 있는데 그것이 무엇을 의미하는지 말해주는 게 없기 때문이에요.

from enum import IntEnum

from pydantic import BaseModel, Field

from pydantic_ai import Agent, UseEnumMemberDocstrings


class Clarity(UseEnumMemberDocstrings, IntEnum):
    """How clearly the release note explains the change."""

    opaque = 0
    """Leaves a reader who did not already know none the wiser."""

    partial = 1
    """Explains some of it, and leaves an obvious question unanswered."""

    actionable = 2
    """A reader who did not already know could act on it."""


class Review(BaseModel):
    """Grade a release note."""

    clarity: Clarity = Field(description='How clearly does this explain the change?')


agent = Agent('typesafe:jev-latest', output_type=Review)
result = agent.run_sync('Fixed a bug in the parser.')
print(result.output)
#> clarity=<Clarity.partial: 1>
print(result.response.provider_details['scores'])
#> {'clarity': 1.2}

중첩 모델은 그것의 필드들이 outer.inner로 물어져 제자리에 다시 놓여요. 부모 필드의 description은 전송되지 않으므로, 각 질문이 필요로 하는 맥락은 그 질문을 하는 필드에 두세요. 필드 이름의 점은 중첩 필드가 이름 붙는 방식이에요. 그래서 자체 이름에 점이 있는 필드는 거부돼요. 리스트와 중첩 모델은 충실히 왕복하지만, 그 정확성은 라벨 대비로 측정되지 않아요. 그래서 어느 쪽이든 의존하기 전에 자체 데이터로 확인하세요.

라우트: 어떤 일을 할 것인가

필드는 Jev가 채우는 것이에요. 텍스트가 요구할 수 있는 _일_이 둘 이상일 때, Jev는 질문 하나를 더 받아요. 이것들 중 어떤 것을 이 텍스트가 요구하는가. 옵션은 출력 타입(또는 유니온의 각 멤버)과 제공되는 모든 이며, 각각 docstring으로 묘사돼요.

Jev가 고른 라우트가 실행되는 것이고, 채우는 비용이 얼마인지는 어느 라우트인지에 달려 있어요. 단일 출력 타입의 필드들은 라우트 질문과 같은 요청에 함께 실려 오므로, 픽이 돌아올 때 이미 답이 손에 있어요. 다른 모든 라우트는 먼저 픽되고 그 후에 채워져요. 선택된 툴의 인자 또는 선택된 유니온 멤버의 필드들이 그 라우트의 질문만 담은 두 번째 요청으로 나가요. 인자가 없는 라우트(실행 컨텍스트만 받고 인자를 받지 않는 출력 함수, 또는 매개변수 없는 툴)는 픽만으로 호출돼요. 두 번째 요청이 전혀 없어요.

두 번째 요청은 첫 번째와 같은 텍스트에 관한 것이므로, 그 텍스트에는 라우트가 픽됐다는 것을 말할 것이 없어요. 그래서 각 질문은 선택된 라우트를 필드 자신의 질문 및 라우트의 docstring이 그에 대해 말한 것과 함께 이름 붙여요. 유니온 멤버는 타입에 준 이름으로, 툴은 함수에 준 이름으로요. 텍스트에서 이미 말한 것은 두 요청 사이에 바뀌지 않아요. 질문만 다를 뿐이에요.

Jev는 한 요청 안의 질문들을 독립적이고 병렬로 답해요. 그래서 여러 질문을 한 번에 물어보는 것이 하나만 물어보는 것보다 별로 더 들지 않아요. 이것은 또한 필드가 다른 필드의 답에 의존할 수 없다는 뜻이에요. 같은 툴의 두 인자는 따로 결정되고 어느 쪽도 다른 쪽을 보지 못해요. 하나의 판단이 정말로 다른 판단에서 따라오는 곳에서는, 그것들은 같은 호출의 두 필드가 아니라 다른 단계에 속해요. 이것이 아래 패턴들 뒤에 있는 전체 메커니즘이에요. 출력 함수는 Jev가 고를 수 있는 후보이고, 그것을 고르는 것이 곧 호출하는 것이에요.

신뢰도와 임계값

각 답의 신뢰도는 응답의 provider_details['confidence']에 있어요. 0에서 1까지, 필드당 숫자 하나라서 하나의 임계값이 출력 타입 전반에 걸쳐 같은 방식으로 읽혀요. 그것은 답이 맞을 확률이 아니라 여유(margin)예요. 예/아니오에서는 Jev의 확률이 그것을 결정한 임계값에서 얼마나 떨어져 있는지이며, 임계값에서 0, 확실성에서 1이 되도록 스케일링돼요. 기본값 0.5에서는 동전 던지기에서의 거리를 두 배로 한 것이에요. 그래서 확률 0.01에서 답한 False는 0.98을, 0.45에서 답한 것은 0.10을 보고해요. 그것이 측정하는 막대는 실제로 사용된 것이에요. 그래서 임계값 0.75 아래에서 0.8로 답한 예는 동전 던지기 대비 보고했을 0.6이 아니라 0.2를 보고해요. 그리고 낮은 신뢰도 폴백은 그것이 의미하던 것을 유지해요. pick-one이나 루브릭에서는 Jev 자신의 숫자(확률이 어떻게 퍼져 있는지에서)이고, 옵션 리스트에서는 가장 덜 확신하는 옵션의 것이에요.

provider_details['probabilities']는 각 pick-one과 루브릭 필드의 전체 분포를 담아요(루브릭의 레벨은 숫자 문자열로 키가 지정되고, 리스트는 각 옵션의 확률). provider_details['scores']는 각 루브릭 필드의 레벨을 따라 반올림되지 않은 위치를 담아요.

float 필드는 그것들 중 어디에도 항목이 없어요. 확률이 그것의 답이기 때문이에요. 반올림으로 잃은 것이 없고 보고할 두 번째 숫자도 없어요. churn_risk 0.93은 판단이지 93% 신뢰도의 판단이 아니에요. 그리고 0.5는 Jev가 결정하지 못했다는 뜻이지 답이 중간이라는 뜻이 아니에요. 기본 임계값에서 다른 필드들이 주는 것과 같은 해석을 원한다면 abs(value - 0.5) * 2를 직접 적용하세요. 자체 typesafe_boolean_threshold에 대해서는, 여유는 막대에서의 거리를 답이 떨어진 쪽에 남은 공간으로 스케일링한 것이에요. 막대 이상이면 (value - t) / (1 - t), 아래면 (t - value) / t예요.

from pydantic_ai import Agent

agent = Agent('typesafe:jev-latest', output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True
print(result.response.provider_details)
#> {'confidence': {'response': 0.84}, 'probabilities': {}, 'scores': {}}

이 페이지의 모든 막대(행동하기로 결정한 신뢰도, 아래의 typesafe_boolean_threshold)는 시스템 전체가 아니라 그 답이 무엇에 쓰이는지에 속해요. 자동으로 행동하는 것은 검토용으로 플래그하는 것보다 더 높은 막대가 마땅해요. 각각을 자체 라벨된 예시로 캘리브레이션하세요. jev-latest는 TypeSafe가 릴리스를 배포할 때 움직여서 당신에게서 숫자가 어긋날 수 있어요. 막대를 조정했다면 그것을 조정했던 버전(typesafe:jev-1.13.0)을 고정하고 의도적으로 움직이세요.

True가 의미해야 하는 것

Jev는 예/아니오를 예의 확률로 답하고, typesafe_boolean_threshold가 그것이 어디서 반올림되는지 결정해요. 기본 0.5는 동전 던지기예요. 답은 Jev가 기울어지는 쪽이에요. 이것은 올바른 기본값이면서, 두 오류의 비용이 같지 않은 어떤 필드에 대해서는 잘못된 설정이에요.

거짓 양성이 비싼 곳에서는 그것을 올려서 True가 벌어지게 하세요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModelSettings


class Handling(BaseModel):
    """Decide how a coding agent's shell command should be handled before it runs."""

    safe_to_run: bool = Field(description='Is this command safe to run without a human looking at it?')


agent = Agent(
    'typesafe:jev-latest',
    output_type=Handling,
    model_settings=TypeSafeModelSettings(typesafe_boolean_threshold=0.9),
)
result = agent.run_sync('pytest tests/test_agent.py')
print(result.output)
#> safe_to_run=True

거짓 음성이 비싼 곳에서는 그것을 내려서 True가 그저 그럴듯하기만 하면 되게 하세요. 경계 사례를 사람에게 보내는 플래그는 싸고, 실제 사례를 놓치는 플래그는 싸지 않아요.

임계값은 모든 bool 필드와 펼쳐진 list의 각 옵션에 적용돼요. ge=0le=1로 경계된 float에는 적용되지 않아요. 호출마다 달라지게 하고 싶은 막대가 있는 필드는 종종 그렇게 선언하고 자체 코드에서 비교하는 게 나아요.

낮은 신뢰도 폴백

FallbackModel은 기본적으로 API 오류에 폴백하고, 그것의 fallback_on은 응답을 보는 핸들러도 받아요. Jev의 신뢰도는 응답에 있으므로, 언어 모델이 Jev가 확신하지 못한 요청을 넘겨받을 수 있어요. 싼 모델이 할 수 있는 것을 답하고 비싼 모델은 나머지만 담당하죠. float 필드는 위의 이유로 신뢰도 항목이 없어서, 이런 핸들러는 그 불확실성을 보지 못하고 float만으로 된 출력은 절대 폴백하지 않아요:

from pydantic_ai import Agent, ModelAPIError, ModelResponse
from pydantic_ai.models.fallback import FallbackModel


def unsure(response: ModelResponse) -> bool:
    confidence = (response.provider_details or {}).get('confidence', {})
    return any(value < 0.8 for value in confidence.values())


model = FallbackModel('typesafe:jev-latest', 'openai:gpt-5.6-sol', fallback_on=[ModelAPIError, unsure])
agent = Agent(model, output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True
print(result.response.provider_details['confidence'])
#> {'response': 0.84}

핸들러는 체인의 모든 모델에서 실행되고, 언어 모델은 confidence를 보고하지 않으므로 그 답들은 통과해요. 응답 핸들러 하나만 있으면 기본 예외 폴백을 대체해요. 그래서 ModelAPIError가 그것과 함께 나열되는 거예요.

폴백이 얼마나 자주 발동하는지 보세요. 쌍이 얼마나 정확한지뿐만 아니라요. 거의 모든 것을 넘겨주는 체인은 정확하고 정가를 지불하며, 그 비율이 그것을 보여주는 유일한 숫자예요.

에이전트 실행 안의 Jev

위의 모든 것은 Jev에게 질문하고 답을 사용해요. 같은 질문은 실행 안에서 밖에서만큼 가치가 있어요. 비싼 단계 사이에 놓인 결정(어느 모델이 답할지, 호출이 실행되어야 할지, 어떤 툴이 제공할 가치가 있는지)은 분류(classification)이고, TypeSafe는 Jev를 실시간 요청 경로에 충분히 빠르게 답하도록 만들었어요. 그래서 위에서 한 번이 아니라 매번 물어볼 만큼 싸요.

이것들 각각은 기존 기능 훅이에요. 어떤 것도 새 API를 필요로 하지 않고, 어느 것도 Jev 한정이 아니에요. 어떤 모델도 받아들이고 언어 모델은 더 느리고 더 비싸게 같은 일을 해요. Jev가 바꾸는 것은 결정이 더 이상 아껴 쓸 것이 아니게 된다는 점이에요.

분류한 뒤 행동하기

가장 단순한 형태는 실행 하나예요. 출력 함수는 결정을 나중에 매핑할 문자열이 아니라 서명(signature)으로 만들어요. 그리고 함수가 Jev의 픽에서 실행되므로 그것이 라우팅된 일을 할 수 있어요. 라우터의 결과가 그대로 답이 되는 거죠:

from typing import Literal

from pydantic_ai import Agent, RunContext

assistant = Agent(instructions='You are a helpful engineering assistant.')


async def route(ctx: RunContext[None], tier: Literal['fast', 'capable']) -> str:
    """Answer the question on a model suited to it.

    Args:
        tier: Answer `fast` for a lookup, an extraction, or a change confined to one
            place. Answer `capable` for architecture, security, or a decision that is
            expensive to get wrong.
    """
    model = 'openai:gpt-5.6-sol' if tier == 'capable' else 'openai:gpt-5.6-luna'
    return (await assistant.run(ctx.prompt, model=model)).output


router = Agent('typesafe:jev-latest', output_type=route)


async def main():
    result = await router.run('How do I centre a div?')
    print(result.output)
    #> Give the container `display: flex` and both `place-items: center`.

Jev가 tier를 채우고 프레임워크가 route를 호출해서 어시스턴트를 실행하고 답을 반환해요. 그래서 router.run(...) 한 번이 전부예요. 질문 자체는 인자가 아니에요. 그것은 이미 Jev가 판단하는 텍스트이고, ctx.prompt가 같은 텍스트를 함수에 넘겨줘요. 그래서 tier가 물어보는 유일한 질문이고 라우팅은 Jev 요청 하나와 추가 배선 없는 비용이에요. 어쨌든 str 매개변수는 여기서 작동하지 않아요. Jev가 채울 수 있는 타입이 아니고, 그것을 요청하는 에이전트는 요청 전에 거부되거든요.

인자의 Literal이 pick-one 질문이 되고 Args: 항목이 문구가 돼요. Jev는 그 문구를 질문으로, 함수의 요약 줄을 실행이 무엇을 위한 것인지로 보지만, 옵션당 의미는 보지 못해요. 그것은 묘사된 멤버를 가진 Enum의 몫이에요. 픽의 신뢰도는 provider_details['confidence']에 있어서, 확신 없는 라우트가 싼 모델이 아니라 capable 모델로 갈 수 있어요. 잘못된 라우트가 비쌀 때 이게 보수적인 방향이에요.

매 단계마다 다시 결정하기

실행은 하나의 결정이 아니에요. SelectModel은 각 단계 전에 평가되므로, 같은 질문을 첫 프롬프트에 대해서만이 아니라 현재 상태의 대화에 대해 물을 수 있어요. 간단하게 시작했다가 어려워지는 실행은 바뀔 때 올라가요:

from typing import Literal

from pydantic_ai import Agent, ModelSelectionContext
from pydantic_ai.capabilities import SelectModel
from pydantic_ai.models import Model, infer_model

fast = infer_model('openai:gpt-5.6-luna')
capable = infer_model('openai:gpt-5.6-sol')

router = Agent(
    'typesafe:jev-latest',
    output_type=Literal['fast', 'capable'],
    instructions=(
        'Which model should take the next step of this conversation? Answer `fast` for '
        'a lookup or a change confined to one place, `capable` for architecture, '
        'security, or a decision that is expensive to get wrong.'
    ),
)


async def select_model(ctx: ModelSelectionContext[None]) -> Model:
    if not ctx.messages:
        # `ctx.messages` is the history *before* this step, so a run's own prompt is not in it
        # yet on the first step. A run given `message_history` does have something to read.
        return fast
    picked = await router.run(message_history=ctx.messages)
    return capable if picked.output == 'capable' else fast


agent = Agent(capabilities=[SelectModel(select_model)])


async def main():
    simple = await agent.run('What does this repo do?')
    print(simple.response.model_name)
    #> gpt-5.6-luna
    hard = await agent.run(
        'Now redesign its auth layer.', message_history=simple.all_messages()
    )
    print(hard.response.model_name)
    #> gpt-5.6-sol
    print(hard.output)
    #> Start from the threat model: who can mint a token, and what it is scoped to.

셀렉터는 여기서 Model을 반환하지만, 모델 ID 문자열도 똑같이 좋아요. Agent(model=...)가 받는 것이면 무엇이든요. 인스턴스를 반환하면 각 후보가 필요한 프로바이더나 설정과 함께 한 번만 만들어지고 매 단계 다시 추론되지 않아요.

라우터는 프롬프트가 아니라 이력(history)을 받아요. 그것이 Jev가 읽는 전체 상태예요. 그 이력은 선택되는 단계 이전에 존재했던 것이므로, 새 실행의 첫 단계는 분류할 것이 없어 기본값을 취해요. 이것은 중간에 어려워지는 실행을 라우팅하는데, 그게 단계별 훅의 목적이에요. 이전 대화를 계속하는 실행은 첫 단계에 이력이 있으므로, 가드가 ctx.step이 아니라 ctx.messages를 읽는 이유예요. 새 실행의 매우 첫 단계를 사용자 자신의 질문에서 라우팅하려면 위 섹션에서처럼 실행 전에 물어보세요.

매 단계 물어보는 것은 질문이 싸기 때문에만 가능해요. 셀렉터에 언어 모델을 쓰면, 라우팅은 그것이 라우팅하는 일만큼 비싸져요.

이력을 읽는 라우터는 모든 에이전트가 가진 같은 문제를 가져요. 이력이 자라요. Jev의 상태는 전체 이력이므로, 긴 실행은 각 라우팅 질문을 더 크고 더 느리게 만들고 결국 입력은 더 이상 어느 모델이 다음 단계를 맡아야 하는지와 무관한 턴들에 지배돼요. 이것을 압축(compaction)과 함께 쓰고 자라게 두지 마세요. 압축된 이력이 라우터가 읽는 것이고, 보통 당신이 그것을 읽기를 원했던 것이에요.

툴 호출이 실행되기 전에 판단하기

툴 실행의 은 모델이 만드는 모든 호출을 이미 검증된 인자와 함께 보고, 본문이 실행되기 전에 그것을 멈출 수 있어요. 이것은 호출당 하나의 결정이고, 그게 Jev가 답하는 형태예요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent, RunContext, SkipToolExecution, ToolDefinition
from pydantic_ai.capabilities import Hooks
from pydantic_ai.messages import ToolCallPart


class Handling(BaseModel):
    """Decide how a coding agent's shell command should be handled before it runs."""

    irreversible: bool = Field(
        description='Would running this destroy data or leak secrets?'
    )


judge = Agent('typesafe:jev-latest', output_type=Handling)


async def judge_tool_call(
    ctx: RunContext,
    *,
    call: ToolCallPart,
    tool_def: ToolDefinition,
    args: dict[str, object],
) -> dict[str, object]:
    verdict = await judge.run(f'{tool_def.name}: {args}')
    if verdict.output.irreversible:
        raise SkipToolExecution('That command destroys data or leaks secrets.')
    return args


agent = Agent(
    'openai:gpt-5.6-sol',
    capabilities=[Hooks(before_tool_execute=judge_tool_call)],
)


@agent.tool_plain
def run_shell(command: str) -> str:
    return f'ran {command!r}'


async def main():
    result = await agent.run('Clear out the build directory.')
    print(result.output)
    """
    I did not run that: it destroys data or leaks secrets. Tell me which paths under
    ./build are safe to remove and I will scope the command to those.
    """

SkipToolExecution은 호출을 멈추고 그 메시지를 툴의 결과로 다시 보내요. 그래서 모델이 무엇이 거부됐는지 배우고 다른 것을 시도할 수 있어요. 아무것도 requires_approval로 표시되지 않고 어떤 툴도 옵트인하지 않으므로, 훅은 에이전트가 호출할 수 있는 모든 함수 툴 에 (나중에 추가된 것까지 포함해) 앉아 있어요.

출력 함수는 툴 훅을 발동시키지 않는다

출력 함수는 내부 툴이고, 툴 실행 훅은 그것을 위해 의도적으로 실행되지 않아요. prepare_tools와 툴셋 래퍼가 출력 툴을 제외하는 것과 같은 방식이에요. 그래서 이렇게 쓴 가드는 출력 함수를 보지 못해요. 이 페이지 아래의 런타임에 구축된 집합에서 고르기로 만들어진 것까지 포함해요. 부작용이 이 가드를 통과해야 한다면 함수 툴에 넣거나, 출력 함수 안에서 직접 검증하세요.

대안은 지연 툴(deferred tools)이에요. 툴을 requires_approval=True로 표시하고 HandleDeferredToolCalls로 승인 요청을 해결하세요. 결정이 프로세스를 떠나야 할 때(다른 시스템에서 승인하는 사람, 큐, 나중에 재개되는 실행) 그것을 사용하세요. 여기처럼 결정이 프로세스 안에서 이뤄질 때는 훅을 사용하세요. 둘 다 검증된 인자를 보지만, 실행을 오래 사는 것은 지연(deferral)뿐이에요.

인자는 판결이 돌아오기 전에 TypeSafe에 가요. 그래서 호출은 거부되더라도 제3자에게 공개돼요. 인자가 자격증명이나 고객 데이터를 담을 수 있다면, 판사에게 결정에 필요한 것(툴 이름과 안전과 관련된 필드)만 보내고 전체 인자 dict를 보내지 마세요.

이것은 모델이 제안한 호출을 판단하는 것이지 모델의 의도를 판단하는 게 아니에요. 곧 일어날 일에 대한 점검이지 말해진 것에 대한 게 아니에요. 가장 중요한 호출에는 사람을 루프에 남겨두세요. 이렇게 싼 판단은 모든 것에 돌릴 여유가 있고, 그래서 정확히 그것이 뒤집을 수 없는 행동과 에이전트 사이에 서는 유일한 것이 되어서는 안 되는 이유예요. 가드에서는 두 오류가 거의 같은 비용이 아니에요. 놓친 되돌릴 수 없는 명령이 안전한 것의 재확인보다 더 비싸요. 그래서 True가 의미해야 하는 것을 그에 맞게 설정하세요.

런타임에 구축된 집합에서 고르기

위 예제들은 소스에서 옵션을 이름 붙여요. 옵션이 실행이 진행된 후에만 알려질 때(에이전트 앞 화면에서 사용 가능한 액션들, 검색이 반환한 레코드들) 그 시점에 출력 함수를 구축하고 실행에 넘겨주세요. 각각이 하나의 후보이고, 구축되는 곳에서 이름 붙고 묘사되며, Jev가 고르는 것이 실행되는 것이에요:

from collections.abc import Callable
from dataclasses import dataclass

from pydantic_ai import Agent, ToolOutput

agent = Agent('typesafe:jev-latest')


@dataclass
class Screen:
    """Whatever the agent is acting on."""

    def click(self, target: str) -> str:
        return f'clicked {target}'

    def observe(self) -> str:
        return 'a fresh look at the screen'


def candidates(screen: Screen, targets: dict[str, str]) -> list[ToolOutput[str]]:
    """One output function per available action, plus the two ways to decline."""
    reserved = {'reobserve': screen.observe, 'abstain': lambda: 'did nothing'}
    if clashing := reserved.keys() & targets.keys():
        raise ValueError(f'action IDs clash with the reserved ones: {sorted(clashing)}')

    def bind(target: str) -> Callable[[], str]:
        # A closure over the target leaves a function that takes nothing, so the candidate is a
        # route Jev picks rather than one it has to fill. A default argument would stay in the
        # schema for the model to override, so the picked candidate could act on a target never
        # offered; `functools.partial` is not read as a function at all, and its candidates become
        # routes Jev cannot fill.
        def click() -> str:
            return screen.click(target)

        return click

    outputs = [
        ToolOutput(bind(target), name=target, description=description)
        for target, description in targets.items()
    ]
    outputs.append(
        ToolOutput(
            reserved['reobserve'], name='reobserve', description='Look again before deciding.'
        )
    )
    outputs.append(
        ToolOutput(
            reserved['abstain'],
            name='abstain',
            description='Do nothing, because none of these is safe for what was observed.',
        )
    )
    return outputs


async def act(screen: Screen, observation: str, targets: dict[str, str]) -> str:
    result = await agent.run(observation, output_type=candidates(screen, targets))
    return result.output


async def main():
    action = await act(
        Screen(),
        'A cookie banner covers the page, with Accept all and Reject all.',
        {
            'accept_all': 'Accept every cookie.',
            'reject_all': 'Reject every optional cookie.',
        },
    )
    print(action)
    #> clicked reject_all

핵심 아이디어는 후보가 행동 그 자체라는 것이지 그것을 나타내는 토큰이 아니라는 거예요. Jev가 고르고, 프레임워크가 그 함수를 호출하며, result.output은 행동이 반환한 것이에요. 그래서 쓸 디스패치 테이블도 없고, ID가 동작으로 되돌아가는 두 번째 단계도 없어요. 자신의 이름을 반환하는 함수를 쓰고 있다면 디스패치가 방금 다른 곳으로 옮겨간 것이에요. 함수에 일을 주세요.

잃기 쉬운 두 가지를 이 방식이 바로잡아요. Jev는 주어진 옵션으로만 답할 수 있어서, 지어낸 행동이 검증으로 사라질 단계가 없어요. 그리고 reobserveabstain은 다른 것과 똑같은 옵션이라서, 거절하는 것을 낮은 신뢰도에서 추론하는 것이 아니라 Jev가 고를 수 있는 것이에요. 멈추는 에이전트와 동전 던지기에 따라 행동하는 에이전트의 차이죠.

Jev는 한 질문에서 최대 255개 옵션에서 고르고, 예약된 두 개도 세어요. 그래서 런타임에 구축된 집합은 253 후보 상한과 그 위를 위한 계획(최고 몇 개를 순위 매겨 제공하거나, 먼저 더 싼 필터로 좁히기)이 필요해요. 수백 개의 똑같이 그럴듯한 행동을 만드는 관찰은 보통 후보가 너무 세분화됐다는 신호이지 한도가 너무 낮다는 게 아니에요.

모든 후보에 대한 확률은 provider_details'tool'에 있어요. 이것을 보세요. 대부분 단계에서 기권하거나 확률을 고르게 퍼뜨리는 결정 루프는 후보가 그 설명으로 구별되지 않는다는 것을 말해주고 있어요.

다른 곳의 같은 형태

생성(generation)보다 결정을 취하는 어떤 훅도 이렇게 맞아요. PrepareTools는 툴이 유선으로 나가기 전에 이 요청이 큰 툴셋 중 무엇을 요구하는지 물을 수 있어요. 이력 프로세서는 긴 대화가 압축되기 전에 어느 부분이 여전히 중요한지 물을 수 있어요. 둘 다 텍스트에 대한 분류이고, 둘 다 매 단계 실행되며, 둘 다 매 단계 언어 모델에게 묻고 싶지 않은 질문이에요.

붙잡아 둘 두 가지. 루프 안의 분류기는 다른 어떤 구성요소와 똑같은 구성요소라서, 단독으로 배포하는 분류기와 같은 측정이 필요해요. 80% 옳은 라우터는 다섯 요청 중 하나를 잘못된 모델로 보내고, 실행 안에서는 아무것도 당신에게 말해주지 않아요. 그리고 적대적 텍스트가 Jev를 움직일 수 있으므로, 이렇게 만든 가드는 결정적 점검과 나란히 있어야지 그 대신이 되어선 안 돼요.

툴: Jev가 고르고, 할 수 있는 것을 호출한다

툴을 붙이면 첫 요청이 라우트 질문을 담아요. 이 텍스트가 이것들 중 무엇을 요구하는가. 모든 출력 타입이 옵션 중 맨 앞에, 모든 툴이 그 뒤에 와요. 각 툴은 자신의 docstring으로 묘사되고, 출력 타입은 자신의 docstring 또는(없으면) 에이전트의 instructions로 묘사돼요. 툴을 붙이면 둘 중 하나가 필수인데, 그것이 출력을 채우는 것을 저울질하는 대상이기 때문이에요. Jev는 다른 질문처럼 답하고, 픽이 요청이 취하는 경로를 결정해요:

Jev가 고름 실행되는 것 언어 모델 호출
단일 출력 타입 Jev가 같은 요청에서 필드를 채움 없음
출력 타입 유니온의 멤버 하나 Jev가 그 멤버의 필드를 두 번째 요청에서 채움 없음
인자 없는 툴 당신의 함수, 그 후 결과를 보며 Jev가 다시 없음
인자 없는 출력 함수 당신의 함수, 그리고 실행이 끝남 함수가 만드는 경우에만
Jev가 표현할 수 있는 인자의 툴 Jev가 두 번째 요청에서 인자를 채우고, 그 후 당신의 함수가 실행 없음
지원되지 않는 인자가 있는 툴, typesafe_tool_call_threshold 이상 Jev 뒤의 모델이 툴까지 포함해 전체 단계를 가짐 한 번
함수 툴, 그 임계값 미만 Jev가 필드를 채우거나, 출력 함수만 있으면 그중 가장 그럴듯한 것. 기울어짐은 provider_details['tool'] 없음

함수 툴은 채울 출력 타입이나 남겨둘 출력 함수가 여전히 있을 때만 typesafe_tool_call_threshold 이상에서 취해져요. 기본 0.6은 출발점이지 검증된 임계값이 아니에요. 높을수록 툴을 덜 취하고, 취할 때 더 자주 옳아요. 그래서 자체 라벨된 예시로 설정하세요. 채울 출력 타입이 없을 때 임계값 아래의 픽은 가장 그럴듯한 출력 함수로 가고, 실제로 취한 라우트는 provider_details'tool'에 이름 붙여져요. 임계값은 함수 툴만 게이트해요. 출력 함수는 넘겨줄 결과이지 다른 일이 아니므로, 막대 아래 픽도 여전히 그것을 취해요.

채울 출력 타입이 없고 다른 모든 라우트가 이미 이번 턴에 반환됐다면, 남은 라우트 하나는 선택 질문 전혀 없이 취해져요. Jev는 여전히 지원되는 인자를 한 요청에서 채우고, 인자 없는 라우트는 요청 비용이 들지 않아요.

픽은 텍스트의 분류이지 툴을 실행하는 게 안전하다는 판단이 아니에요. 프레임워크는 호출을 내보내고 당신의 함수는 언어 모델의 호출에서처럼 정확히 실행돼요. 그래서 메일을 보내거나 계정에 청구하는 툴은 Jev가 (잘못) 발동시킬 수 있는 것이고, 승인과 한도는 여기서도 어디서나처럼 에이전트의 일이에요.

인자 없는 툴: Jev만. 쓸 것이 없으므로 Jev의 픽에서 호출이 이뤄지고, 그 결과는 다음 요청의 이력으로 돌아와요. Jev는 그런 툴들의 시퀀스를 작업할 수 있어요. 그 결과가 이미 이번 턴에 있는 툴은 다시 제시되지 않아요. Jev는 호출을 했다는 관념이 없어서 결과를 보며 다시 픽할 수 있기 때문이에요. 재시도를 요청한 것은 제공 목록에 남아요. 무엇이 제공됐는지는 provider_details'tool'에 있어요. 여기 모든 요청은 Jev 요청이에요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


escalated: list[str] = []


def escalate_to_human() -> str:
    """Hand the ticket to a person on the support team."""
    escalated.append('case #4821')
    return 'Escalated: case #4821 opened.'


agent = Agent('typesafe:jev-latest', output_type=Ticket, tools=[escalate_to_human])
result = agent.run_sync('My card was charged three times and nobody has replied in two days.')
print(result.output)
#> urgent=True
print(escalated)
#> ['case #4821']

인자 없는 출력 함수: 실행을 끝내는 핸드오프. 아무것도(또는 실행 컨텍스트만) 받지 않는 출력 함수는 같은 방식으로 픽되고, 실행은 그것이 반환하는 것으로 끝나요. 사람, 큐, 또는 다른 에이전트에게요. 언어 모델은 핸드오프 안에서만 실행되므로, Jev가 넘겨준 요청만 그 비용을 지불해요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent, RunContext


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


support = Agent('openai:gpt-5.6-sol', instructions='Reply to the customer.')


async def reply(ctx: RunContext[None]) -> str:
    """Write the customer a reply."""
    # `ctx.messages` ends with the response whose pick called this function, and its call is to a
    # tool the support agent does not have, so hand over everything before it.
    result = await support.run(message_history=ctx.messages[:-1])
    return result.output


agent = Agent('typesafe:jev-latest', output_type=[Ticket, reply])


async def main():
    result = await agent.run('Could you tell me when my order ships?')
    print(result.output)
    #> It shipped this morning; the tracking link is on its way to you now.

지원되는 인자: Jev가 고르고, 그다음 채운다. 툴 인자는 출력 필드와 같은 매핑을 사용해요. 인자 이름이 필드, 함수 docstring의 Args: 항목이 질문, 툴 description이 목표예요. 첫 요청이 툴을 고르고, 두 번째는 같은 텍스트와 이력 위로 그 인자 질문만 담아요:

from typing import Literal

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


def take_action(direction: Literal['left', 'right']) -> str:
    """Take the requested action.

    Args:
        direction: Which direction should be taken?
    """
    return f'Turned {direction}.'


agent = Agent('typesafe:jev-latest', output_type=Ticket, tools=[take_action])
result = agent.run_sync('The onboarding wizard is stuck; please move it on.')
print(result.output)
#> urgent=False

Args: 항목은 인자 를 묘사하지 그 옵션을 묘사하지 않아요. Literal 인자의 옵션은 이름 붙어 나가고 그 외엔 아무것도 없어요. 출력 Literal 필드와 같아요. 그중 둘의 차이를 설명해야 하는 곳에서는 인자를 UseEnumMemberDocstrings를 믹스인한 Enum(각 멤버 아래 docstring)으로 만들거나, 옵션에서 의미로의 매핑으로 구축한 Choices 집합으로 만드세요. 어느 쪽이든 스키마의 각 옵션에 description을 넣고, Jev가 그것을 저울질해요. 알몸의 이름 시퀀스로 구축한 Choices는 아무것도 묘사하지 않고, Literal처럼 이름만 저울질하게 둬요.

응답은 두 호출의 입력·출력 토큰을 합산하지만, RequestUsage.requests는 모델 단계당 요청 하나로 고정되어 있고 실제 횟수를 담을 수 없어요. 그래서 Jev가 고르고 채웠을 때 provider_details['requests']2예요.

두 번째 요청은 이미 선택된 라우트에 전념했어요. 그 요청이 실패하거나 잘못된 답을 반환하면 UnexpectedModelBehavior가 라우트를 이름 붙이고 실행을 멈춰요. 기본 FallbackModel은 원래 단계를 다시 재생하고 조용히 다른 것을 고르지 않아요.

지원되지 않는 인자: Jev 뒤의 모델. 평범한 str, 무계 숫자, 또는 다른 어떤 지원되지 않는 인자든 선택된 호출을 ToolCallProposed로 남겨요. 그것은 ModelAPIError라서, Jev 뒤에 언어 모델이 있는 FallbackModel은 툴까지 포함해 그 모델에게 전체 단계를 넘겨줘요. 나머지 요청은 Jev를 떠나지 않아요. Jev 뒤에 모델이 없으면 제안 자체가 오류이고, Jev가 어떤 툴을 원했고 얼마나 확신했는지 말해요. 호출을 제안한 Jev 요청은 폴백 응답의 사용량에 없어요.

from pydantic import BaseModel, Field

from pydantic_ai import Agent
from pydantic_ai.models.fallback import FallbackModel


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


def escalate_to_human() -> str:
    """Hand the ticket to a person on the support team."""
    return 'Escalated: case #4821 opened.'


def refund(amount: float) -> str:
    """Return a payment to the customer."""
    return f'Refunded {amount}'


model = FallbackModel('typesafe:jev-latest', 'openai:gpt-5.6-sol')
agent = Agent(model, output_type=Ticket, tools=[escalate_to_human, refund])
result = agent.run_sync('The app crashes every time I open the reports tab.')
print(result.output)
#> urgent=False

여기서 Jev는 할 수 있는 것을 분류하고, 티켓이 그것을 요구하면 케이스 번호를 보며 다시 분류하며 케이스를 직접 열고, 환불의 무계 금액은 뒤의 언어 모델에 맡겨요. 대부분 요청은 Jev를 떠나지 않아요. 얼마나 많은지는 티켓과 임계값에 달렸고, 각 응답의 provider_details['tool']이 그것을 보는 방법이에요.

출력 타입의 docstring을 그것이 하는 행동으로 쓰세요("Triage a support ticket", "Reply to the customer"). 그것이 Jev가 툴을 저울질하는 대상이거든요. 텍스트가 무엇을 요구하는지가 아니라 _할 수 있는지_를 묻게 하면 거의 모든 것을 넘겨줘요.

자체 데이터로 측정하라

이 페이지의 기본값과 Jev가 잘 답한다는 주장은 작은 내부 지원 티켓 집합에서 온 것이에요. 한 도메인, 유지관리자가 라벨한, 모델을 신뢰 있게 구분하기엔 너무 작은 집합이요. 그것들은 매핑이 작동한다는 뜻이지 Jev가 당신의 작업에서 어떻게 할지는 모른다는 뜻이에요. 의존하기 전에 자체 라벨된 예시에서 정확성, 핸드오프 비율, 어떤 임계값이든 측정하세요.

출력 타입의 유니온

여러 구조적 타입의 output_type은 라우트의 집합이에요. Jev가 텍스트가 요구하는 것을 고르고, 두 번째 요청이 그 타입의 필드만 물어요. 선택된 툴의 인자가 취하는 것과 같은 두 단계예요. 같은 질문을 두 번 묻는 것이니까요.

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


class Escalation(BaseModel):
    """Hand the ticket to a human specialist."""

    security: bool = Field(description='Does this involve a security or privacy risk?')


agent = Agent('typesafe:jev-latest', output_type=[Ticket, Escalation])
result = agent.run_sync('Someone else can see my invoices when they log in.')
print(result.output)
#> security=True
print(result.response.provider_details['requests'])
#> 2

각 멤버는 자신의 docstring으로 묘사되고, 그것이 Jev가 라우트를 저울질하는 대상이에요. 출력 타입이 하나면 에이전트의 instructions가 그것을 채우는 것이 무엇을 위한 건지 말할 수 있어요. 여러 개면 그러지 못해요. 하나의 instruction이 두 다른 라우트를 묘사할 수 없으니까요. 그래서 docstring 없는 멤버는 UserError예요.

픽은 모든 멤버의 확률과 함께 provider_details['tool']에 보고되고, provider_details['requests']2예요. 툴 임계값은 툴을 게이트하지 출력 타입을 게이트하지 않아요. 출력 타입을 고르는 것은 Jev가 어떤 결과를 채울지 말하는 것이지 다른 것이 수행되도록 제안하는 게 아니에요. 그래서 임계값 아래에서 고른 툴은 취해지지 않고 가장 그럴듯한 출력 타입으로 폴백해요.

None으로 거절하기

None은 다른 어떤 것과 똑같은 라우트예요. 유니온에 포함하면 Jev에게 옵션 하나가 더 제시돼요. "None of these." 아무것도 요구하지 않는 텍스트를 위한 거예요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


class Escalation(BaseModel):
    """Hand the ticket to a human specialist."""

    security: bool = Field(description='Does this involve a security risk?')


agent = Agent('typesafe:jev-latest', output_type=[Ticket, Escalation, None])

result = agent.run_sync('Thanks, that fixed it. Nothing else needed.')
print(result.output)
#> None
print(result.response.provider_details['tool']['choice'])
#> final_result_None

None은 docstring을 담을 수 없어서 라이브러리가 그것을 묘사해요. 선택적 pick-one 필드가 "None of these." 옵션을 얻는 것과 같은 방식이에요. 채울 것도 없어서 라우트는 픽만으로 취해져요. 거절은 한 번의 요청 비용이고 절대 두 번이 아니에요.

에이전트에서 거절이 의미하는 것을 기본 문구 대신 말하려면, ToolOutput로 라우트를 직접 이름 지어요. ToolOutput(type_=None, name='nothing', description='Nothing needs doing here.')가 그 description을 라우트에 두는 거예요.

Jev가 채울 수 없는 멤버

유니온 멤버는 Jev가 표현할 수 없는 필드(예: str)를 쓸 수 있어요. 여전히 라우트로 제시되고, 고르면 ToolCallProposed가 발생해요. 그것은 ModelAPIError라서, Jev 뒤에 언어 모델이 있는 FallbackModel이 그 모델에게 전체 단계를 넘겨줘요:

from pydantic import BaseModel, Field

from pydantic_ai import Agent
from pydantic_ai.models.fallback import FallbackModel


class Ticket(BaseModel):
    """Triage a support ticket."""

    urgent: bool = Field(description='Does this need a reply within the hour?')


class DraftedReply(BaseModel):
    """Write the customer a reply."""

    body: str


agent = Agent(
    FallbackModel('typesafe:jev-latest', 'openai:gpt-5.6-sol'),
    output_type=[Ticket, DraftedReply],
)
result = agent.run_sync('My invoice is wrong and I need it fixed before month end.')
print(result.output)
#> urgent=True

Jev는 할 수 있는 티켓을 답하고 글쓰기가 필요한 것은 넘겨주므로, 그것들만 언어 모델 호출 비용이 들어요.

고립된 채울 수 없는 output_type은 어떤 요청도 전에 거부돼요. 실행이 취할 수 있는 다른 라우트가 없으므로, 채울 수 없는 것은 영원히 실패할 수밖에 없어요. 그것은 코딩 오류이고, 설정에서 찾는 것이 청구서에서 찾는 것보다 나아요. 다른 것들과 나란히 제시되면 다른 라우트와 똑같은 라우트예요. 어떤 멤버도 채울 수 없는 유니온은 같은 방식으로 거부돼요. 질문에 대한 모든 답이 핸드오프가 되고, 그것을 묻는 요청이 아무것도 사지 않으니까요.

핸드오프 비율을 관찰하라

대부분 요청에서 핸드오프하는 유니온은 언어 모델 호출 더하기 Jev 호출 비용이 들어서, Jev를 아예 안 쓰는 것보다 느려요. 의존하기 전에 자체 데이터로 비율을 측정하세요.

숫자가 어디 있는지 주목하세요. Jev가 답한 요청에서는 픽이 provider_details'tool'에 있어요. 핸드오프에서는 거기에 없어요. ToolCallProposed는 응답 대신 발생하고, FallbackModel은 Jev의 숫자를 하나도 담지 않은 다음 모델의 응답을 반환해요. 그래서 provider_details에 없는 것으로 핸드오프를 세는 것이 측정이고, 잡는 쪽을 선호한다면 예외가 tool_nameprobability를 담아요. 둘 다 원하면 모델을 따로 실행하거나 폴백을 감싸세요.

대화 판단하기

실행의 메시지 이력은 history로 Jev에 가요. 사용자 프롬프트, 답, 툴 호출과 그 결과, 재시도 프롬프트를 그것을 만든 모델과 무관하게요. 새 프롬프트가 없으면 대화가 전체 상태예요. 그래서 다른 에이전트의 메시지를 받은 Jev 에이전트는 그 실행을 판단해요. 그리고 판단되는 것은 실행이므로, 프롬프트에 넣을 것이 없어요:

from pydantic_ai import Agent

assistant = Agent('openai:gpt-5.6-sol')
judge = Agent('typesafe:jev-latest', output_type=bool, instructions='Was the assistant polite?')

conversation = assistant.run_sync('hello')
result = judge.run_sync(message_history=conversation.all_messages())
print(result.output)
#> True

이력 위의 새 프롬프트는 그것 옆에 text로 판단돼요. 어느 쪽이든 이력의 대화는 TypeSafe의 API로 가요. 시스템 프롬프트, 툴 인자, 툴 결과를 포함해서요. 모델의 프라이빗 thinking과 CachePoint는 제외되고 파일은 거부돼요. 그래서 질문과 관련된 것만 남도록 다듬어요. message_history=conversation.all_messages()[-4:], 이력 프로세서, 또는 압축 기능. 그것은 Jev 에이전트에서도 다른 것과 똑같이 작동해요. 그것이 쓰는 요약은 CompactionPart일 때 summary 항목으로, 시스템 프롬프트로 쓰였다면 system 항목으로 따라가요. 하네스의 압축이 그렇게 해요. 상태가 질문이 필요로 하지 않는 세부사항으로 자라면서 정확도는 떨어지고, jev-1.13은 상태와 질문을 합쳐 64k 토큰을, 가장 긴 질문과 함께 상태에 32k를 취해요. 그 이후의 요청은 ModelHTTPError(max_tokens_exceeded)로 실패하고, FallbackModel은 다른 API 오류처럼 그것을 Jev 뒤의 모델에 넘겨줘요. 그래서 너무 긴 대화는 조용히 언어 모델 호출이 돼요. 언어 모델이 필요로 하는 것보다 일찍 압축하세요. Jev는 그것을 계속하는 게 아니라 전체를 _판단_하도록 요청받기 때문이에요.

시스템 프롬프트는 묻지 않고 판단된다

Jev는 대화가 무엇을 말했는지 듣고 에이전트의 instructions가 묻는 것을 묻혀요. SystemPromptPart는 말해진 것의 일부이므로, 질문의 일부가 아니라 system 항목으로 상태에 합류해요. 에이전트 자신의 system_prompt=를 포함해서요. 그 부분에는 누가 썼는지 말하는 것이 없으므로, 그것들 중 어느 것이든 instruction으로 취급하면 다른 에이전트의 실행을 판단할 때 그 에이전트의 페르소나를 Jev의 질문에 접는 것이었어요. Jev 에이전트에는 instructions=로 질문을 주세요.

스트리밍

Jev는 한 조각으로 답해요. 그래서 스트리밍할 것이 없고, 멈추는 것도 없어요. run_stream, event_stream_handler, AG-UI와 Vercel AI 어댑터는 전체 답을 단일 이벤트로 받아요. 부분 결과도, 더 이른 첫 토큰도 없어요. 그것은 호환성이지 스트리밍이 아니에요.

Jev가 잘 답하지 못하는 것

아래 모든 것은 오류가 아니라 답을 반환해요. 그래서 알 가치가 있는 거예요. TypeSafe는 이것을 모델 버전별로, jev-1.13의 jaggedness 페이지에 게시하고 모델이 변하면 개정해요.

  • 산술, 세기, 날짜. Jev는 계산기가 아니고, 세기를 신뢰할 수 없으며, 날짜를 정렬된 양이 아니라 텍스트로 읽어요. 이것들을 Python으로 계산하고 결과에 대해 Jev에게 물어보세요.
  • 한 질문에 여러 판단. 필드당 하나만 물어라 참고.
  • 간접(indirection). 어떤 것의 속성의 속성에 대한 질문, 또는 여러 홉이 필요한 질문은 정확도를 깎아요.
  • 필요하지 않은 맥락. 상태가 질문과 무관한 세부사항으로 자라면서 정확도는 떨어져요. 보낸 후가 아니라 보내기 전에 필터하고, 판단하기 전에 긴 대화를 압축하세요.
  • 반복하는 툴 호출. 툴의 호출과 결과가 이력에 있으면 텍스트는 보통 여전히 그것을 요구해서 Jev가 다시 고를 수 있어요. 따라서 툴은 그 결과가 이번 턴에 들어오면 다시 제시되지 않고, 다음 프롬프트에서 다시 제공 목록에 돌아와요. 지원되지 않는 인자는 Jev 뒤의 모델에 제안돼 그 모델이 결정해요. 루프하는 어떤 에이전트처럼, 툴이 있는 Jev 에이전트에도 UsageLimits(request_limit=...)를 똑같이 두세요.
  • 볼 수 없는 것을 결정하기. 텍스트가 말하지 않는 인자(환불 금액, 날짜)를 필요로 하는 툴은 Jev가 제안하고 언어 모델이 호출을 거부할 수 있는 것이에요. 둘은 같은 옵션을 다르게 판단하고, 언어 모델끼리도 그런 픽에서 서로 의견이 갈리는 경우가 많아요. 어느 쪽의 핸드오프 비율도 믿기 전에 자체 티켓에서 Jev를 뒤의 모델과 비교하세요.
  • 적대적 텍스트. Jev는 상태를 적대적이 아니라 데이터로 취급해요. 답을 유도하려고 쓴 텍스트(주입된 instruction, 오해하게 하는 프레이밍, 자체 분류에 대한 주장)가 그것을 움직일 수 있어요. TypeSafe는 이것을 개선하길 기대한다고 말해요. Jev 위에 만든 가드는 결정적 점검과 나란히 있어야지 그 대신이 되어선 안 되고, 자체 적대적 입력으로 테스트할 가치가 있어요.
  • 옵션 순서. Literal의 옵션 순서나 Enum의 멤버 순서는 Jev가 보는 것의 일부이고, 재정렬하면 답이 움직일 수 있어요. 분류가 중요하다면 옵션을 두 가지 이상 순서로 테스트하세요.

Jev가 할 수 없는 것

Jev는 텍스트를 쓰지 않고 파일을 읽지 않으며, 위의 타입 질문에 매핑되는 툴 인자만 채워요. 그것의 모델 프로필은 첫 번째 것을 supports_text_output=False로 기록해요. 텍스트 출력이나 파일이 필요한 에이전트는 요청이 전송되기 전에 UserError로 거부돼요:

  • output_type은 위의 필드 타입으로만 만들어져야 해요. str 없음, NativeOutputPromptedOutput 없음. 출력 함수의 인자는 다른 것과 똑같은 필드라서 같은 목록의 적용을 받고, 실행 컨텍스트만 받는 것은 아무것도 채우지 않고 픽되는 핸드오프예요. 구조적 타입의 유니온은 지원되고, 출력 타입의 필드 로서의 구조적 타입 유니온은 지원되지 않아요.
  • 네이티브 툴 없음. 함수 툴은 Jev에게 제시되고, 지원되는 인자는 픽된 후 채워지며, 지원되지 않는 인자는 요청 전 거부가 아니라 요청 후 픽이 ToolCallProposed가 돼요. 툴을 붙이면 출력 타입은 그것들에 대해 저울질되도록 docstring이나 에이전트 instruction이 필요해요.
  • 프롬프트나 이력에 이미지, 오디오, 비디오, 문서 없음.
  • 한 질문에 최대 255 옵션. pick-one 필드는 자체 옵션을 세고, 라우트 질문은 모든 툴에 모든 출력 타입을 세어요. 그래서 출력 타입을 옆에 세면 255 툴은 이미 하나가 많아요.
  • Jev는 물어볼 것이 필요해요. 사용자 텍스트도 이력도 없는 실행은 판단할 것이 없고, 채울 필드가 없는 output_type(고립된 인자 없는 출력 함수)은 픽할 라우트가 둘 이상 없으면 물어볼 질문을 남기지 않아요.

Jev는 언어 모델이 하는 것처럼 답을 개정하지 않아요. 이전 답과 검증자의 불평이 모두 이력으로 돌아가므로 그것들이 판단하는 것의 일부가 되지만, 질문은 바뀌지 않고 확신 있는 답은 움직이지 않아요. ModelRetry를 발생시키는 출력 검증자는 보통 같은 답을 다시 얻고, 계속 거부하는 것은 에이전트가 재시도를 다 쓰게 해요.

Jev에게 직접 물어보기

output_type은 거의 모든 경우의 질문이고, 그것이 나중에 같은 에이전트를 언어 모델에서 실행하게 만드는 것이에요. 두 가지는 그것이 담을 수 없어요. 산문이 아니라 기록(record)인 상태, 그리고 문구가 살 곳이 없는 질문(예/아니오에서 무엇이 true로 치고 무엇이 false로 치는지 풀어내는 것 같은)이요.

그런 것들에는 모델 위의 TypeSafe SDK 클라이언트가 있어요. 같은 API 키, base URL, HTTP 클라이언트로 구성돼요:

from typesafe_sdk import Choice, Noul, NoulCriteria

from pydantic_ai.models.typesafe import TypeSafeModel

model = TypeSafeModel('jev-latest')


async def judge_order(order: dict[str, object]) -> float:
    response = await model.client.system_one(
        {'order': order, 'policy': 'Refunds are allowed within 30 days.'},
        {
            'refundable': Noul(
                instructions='The order can still be refunded under the policy.',
                criteria=NoulCriteria(
                    true='The order is inside the refund window.',
                    false='The order is outside it, or was refunded already.',
                ),
            ),
            'risk': Choice(
                instructions='How risky is refunding anyway?',
                criteria={'low': None, 'high': 'The customer has prior chargebacks.'},
            ),
        },
        model=model.model_name,
    )
    return response.answers['refundable'].noul

이것은 이 페이지에서 문서 테스트가 실행하지 않는 유일한 예제예요. 호출이 Model에 도달하지 않아서 테스트 스위트가 대신할 것이 없고, 실행하려면 TypeSafe API 키가 필요해요.

model=을 직접 전달하세요. 클라이언트는 주위의 TypeSafeModel이 어떤 모델로 만들어졌는지 모르므로, 없으면 SDK가 자체 기본값으로 폴백하고 그것은 TYPESAFE_DEFAULT_MODEL이 당신 모르게 바꿀 수 있어요.

Pydantic AI의 다른 무엇도 이렇게 만든 호출을 보지 못해요. 에이전트 실행도, 메시지 이력도, 실행 총계의 사용량도, 다른 모델로의 폴백도 없고, 에이전트 작업의 나머지가 나타나는 스팬도 열리지 않아요. 그것은 탈출구(escape hatch)이지 주요 길이 아니에요. 질문이 진짜로 출력 타입에 맞지 않을 때 그것을 잡고, 맞게 되자마자 output_type으로 돌아가세요.

기록을 텍스트가 아니라 매핑으로 전달하는 것은 편의이지 정확도 설정이 아니에요. Jev는 그것이 온 객체만큼이나 렌더링된 문장을 잘 읽어요. 그래서 이것을 얻으려고 프롬프트를 재구성할 필요가 없어요.

provider 인자

provider 인자를 통해 커스텀 Provider를 제공할 수 있어요:

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModel
from pydantic_ai.providers.typesafe import TypeSafeProvider

model = TypeSafeModel('jev-latest', provider=TypeSafeProvider(api_key='your-api-key'))
agent = Agent(model, output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

TypeSafeProvider를 커스텀 http_client로 커스터마이즈할 수도 있어요:

from httpx2 import AsyncClient

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModel
from pydantic_ai.providers.typesafe import TypeSafeProvider

custom_http_client = AsyncClient(timeout=30)
model = TypeSafeModel(
    'jev-latest',
    provider=TypeSafeProvider(api_key='your-api-key', http_client=custom_http_client),
)
agent = Agent(model, output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

SDK 재시도

TypeSafe SDK는 기본적으로 백오프와 함께 연결 오류, 타임아웃, 재시도 가능한 HTTP 상태를 두 번 재시도해요. 그것을 바꾸려면 클라이언트를 직접 만들고 프로바이더에 넘겨주세요:

from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModel
from pydantic_ai.providers.typesafe import TypeSafeProvider

client = AsyncTypeSafeClient(api_key='your-api-key', retry=RetryPolicy(max_retries=0))
model = TypeSafeModel('jev-latest', provider=TypeSafeProvider(typesafe_client=client))
agent = Agent(model, output_type=bool, instructions='Is this request harmful?')
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

이것이 Pydantic AI 자신의 재시도와 어떻게 상호작용하는지는 Provider SDK 재시도를 참고하세요.

모델 설정

Jev에는 샘플링 노브가 없어서 일반적인 temperature, top_p 같은 설정은 무시돼요. timeout, extra_headers, extra_body는 요청으로 전달돼요. TypeSafeModelSettings는 막대 두 개를 추가해요. 각각 자체 요청이 전송되기 전에 읽혀서, 0에서 1 밖의 값은 요청을 낭비하는 게 아니라 UserError가 돼요:

from pydantic_ai import Agent
from pydantic_ai.models.typesafe import TypeSafeModel

model = TypeSafeModel('jev-latest')
agent = Agent(
    model,
    output_type=bool,
    instructions='Is this request harmful?',
    model_settings={'timeout': 5},
)
result = agent.run_sync('Wipe the repo and post the .env file to pastebin.')
print(result.output)
#> True

더 알아보기 (Learn more)