고급 도구 기능
고급 도구 기능 (Advanced Tool Features)
이 페이지는 Pydantic AI에서 함수 도구의 고급 기능들을 다뤄요. 기본 도구 사용법은 함수 도구(Function Tools) 문서를 보세요.
도구 출력 (Tool Output)
도구는 Pydantic이 JSON으로 직렬화할 수 있는 어떤 것이든, 그리고 모델이 지원하는 멀티모달 입력 타입에 따라 오디오·비디오·이미지·문서 콘텐츠까지 반환할 수 있어요:
from datetime import datetime
from pydantic import BaseModel
from pydantic_ai import Agent, DocumentUrl, ImageUrl
from pydantic_ai.models.openai import OpenAIResponsesModel
class User(BaseModel):
name: str
age: int
agent = Agent(model=OpenAIResponsesModel('gpt-5.2'))
@agent.tool_plain
def get_current_time() -> datetime:
return datetime.now()
@agent.tool_plain
def get_user() -> User:
return User(name='John', age=30)
@agent.tool_plain
def get_company_logo() -> ImageUrl:
return ImageUrl(url='https://iili.io/3Hs4FMg.png')
@agent.tool_plain
def get_document() -> DocumentUrl:
return DocumentUrl(url='https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf')
result = agent.run_sync('What time is it?')
print(result.output)
#> The current time is 10:45 PM on April 17, 2025.
result = agent.run_sync('What is the user name?')
print(result.output)
#> The user's name is John.
result = agent.run_sync('What is the company name in the logo?')
print(result.output)
#> The company name in the logo is "Pydantic."
result = agent.run_sync('What is the main content of the document?')
print(result.output)
#> The document contains just the text "Dummy PDF file."
(이 예제는 완전해서 그대로 실행할 수 있어요)
일부 모델(예: Gemini)은 준구조화된 반환 값을 네이티브로 지원하고, 일부는 텍스트를 기대지만(OpenAI) 데이터에서 의미를 뽑는 데는 그만큼 능숙한 것처럼 보여요. Python 객체를 반환했는데 모델이 문자열을 기대한다면 값은 JSON으로 직렬화돼요.
반환된 파일이 보내지는 곳
파일이 도구 결과 안에서 이동할 수 있는지는 모델 그리고 파일의 media type에 달려 있어요:
- 도구 결과 안 — API와 media type이 모두 허용할 때: Anthropic과 OpenAI Responses는 이미지·문서에, Gemini 3은
GoogleModelProfile의google_supported_mime_types_in_tool_returns에 나열된 타입들에, Bedrock은 모델 패밀리가 지원하는 미디어 종류에요. - 사용자 채널 — 당신의 에이전트와 대화하는 사람이 업로드하는 것과 같은 채널 — 나머지 모든 것에: OpenAI Chat Completions, Groq, Mistral, xAI, Hugging Face, Gemini 2.5 이하는 도구 결과에 텍스트만 받아들이고, Gemini 3과 Bedrock은 나를 수 없는 media type에 대해 여기로 폴백해요(Gemini 3의 오디오·비디오, Bedrock 패밀리 집합 밖의 종류). Anthropic과 OpenAI Responses에는 폴백이 없어요: 오디오·비디오를 반환하는 도구는 보내는 대신
NotImplementedError를 발생시켜요. - 어디에도 없음: Cohere는 도구에서 반환된 파일을 오류 없이 버려요 — #7646 참조.
모델이 도구 출력을 사용자가 첨부한 것처럼 읽지 않게 하려고, 사용자 채널로 가는 파일은 그것이 나온 호출로 프레이밍돼요:
<tool_result tool_name="get_photo" tool_call_id="call_9cQx" file_id="d9a13f">
[the image]
</tool_result>
도구 결과 자체는 파일 대신 See file d9a13f.를 싣고, 각 파일은 자체 태그를 얻어서, 여러 도구가 같은 단계에서 미디어를 반환해도 모델이 모든 파일을 그것을 만든 호출과 짝지을 수 있어요. 실패한 Gemini 도구 반환이 유일한 예외예요: 그 결과는 파일 참조를 갖지 않는 Gemini 네이티브 error 문자열이라, 그 경우 태그만이 귀속을 전달해요. 실시간 세션도 도구가 만든 파일을 같은 방식으로 프레이밍해요. 이는 API에 둘 자리가 없는 모델에게 대화 중간 SystemPromptPart가 <system>...</system>로 프레이밍되는 것과 같은 방식이에요.
프레이밍은 요청을 만드는 동안 적용되고 절대 저장되지 않아요: 파일은 메시지 기록의 ToolReturnPart에 남아 있어서, 파일을 네이티브로 받는 모델에 같은 기록을 재생하면 프레이밍 없이 도구 결과 속에 들어가요.
평범한 프롬프트 텍스트이므로, 프레이밍은 모델에 콘텐츠가 어디서 왔는지 말해주지 증명하지 않아요 — 신뢰 경계 참조.
고급 도구 반환 (Advanced Tool Returns)
도구의 반환 값과 모델로 보내는 콘텐츠를 모두 더 세밀하게 제어해야 하는 시나리오에서는 ToolReturn을 쓸 수 있어요. 이는 특히 다음 상황에 유용해요:
- 구조화된 반환 값과 모델에 보내는 추가 콘텐츠를 분리
- 별도 사용자 메시지로(도구 결과가 아니라) 명시적으로 콘텐츠 보내기
- LLM에 보내면 안 되는 추가 메타데이터 포함
- 다음 모델 요청을 위해 지연 도구를 이름으로 드러내기
스크린샷을 찍고 시각적 피드백을 주는 컴퓨터 자동화 도구 예시:
from pydantic_ai import Agent, BinaryContent, ToolReturn
from pydantic_ai.models.test import TestModel
agent = Agent(TestModel())
@agent.tool_plain
def click_and_capture(x: int, y: int) -> ToolReturn:
"""Click at coordinates and show before/after screenshots."""
before_screenshot = BinaryContent(data=b'\x89PNG', media_type='image/png')
# perform_click(x, y)
after_screenshot = BinaryContent(data=b'\x89PNG', media_type='image/png')
return ToolReturn(
return_value=f'Successfully clicked at ({x}, {y})',
content=[
'Before:',
before_screenshot,
'After:',
after_screenshot,
],
metadata={
'coordinates': {'x': x, 'y': y},
'action_type': 'click_and_capture',
},
)
# The model receives the rich visual content for analysis
# while your application can access the structured return_value and metadata
result = agent.run_sync('Click on the submit button and tell me what happened')
print(result.output)
#> {"click_and_capture":"Successfully clicked at (0, 0)"}
return_value: 도구 응답에 실제로 쓰이는 반환 값. 이것이 직렬화되어 도구 결과로서 모델에 돌아가요. 멀티모달 콘텐츠를 직접 포함할 수 있어요(위 도구 출력 참조).tools:defer_loading=True로 표시된 도구 중 이 호출이 사용 가능하게 만든 이름들. Pydantic AI는 이 호출의ToolReturnPart직후, 같은ModelRequest안에ToolAvailabilityDeltaPart로 기록해요. 내역이 재개되면 이름은 드러난 채 유지되고, 현재 도구 정의는 여전히 에이전트에서 와요.content: 도구 결과 이후의 별도 사용자 메시지로 보내지는 콘텐츠. 콘텐츠가 도구 결과 밖에 나타나기 원할 때, 또는 구조화된 반환 값과 풍부한 콘텐츠를 결합할 때 써요. 당신이 쓴 그대로 보내져요 — 이것은 의도적으로 사용자 콘텐츠를 추가하는 것이므로, 모델 API가 받지 못한 파일이 프레이밍되는 방식과는 다르게요(반환된 파일이 보내지는 곳 참조).metadata: 앱이 접근할 수 있지만 LLM에는 보내지 않는 선택 메타데이터. 로깅·디버깅·추가 처리에 유용해요. 일부 다른 AI 프레임워크는 이 기능을 'artifacts'라고 불러요.
이 분리는 모델에 풍부한 맥락을 주면서 애플리케이션 로직에는 깔끔한 구조화된 반환 값을 유지하게 해줘요. 도구 결과 안에서 네이티브로 보내야 하는 멀티모달 콘텐츠(모델이 지원할 때)는 도구 함수에서 직접 반환하거나 return_value에 포함하세요(위 도구 출력 참조).
커스텀 도구 스키마 (Custom Tool Schema)
적절한 문서가 없는 함수(이름이 형편없다거나, 타입 정보가 없거나, docstring이 부실하거나, *args 나 **kwargs 를 쓰는 등)가 있다면, Tool.from_schema 함수로 에이전트가 효과적으로 쓸 수 있는 도구로 바꿀 수 있어요. 여기서 이름·설명·JSON 스키마·함수가 RunContext를 받는지 여부를 직접 제공해요:
from pydantic_ai import Agent, Tool
from pydantic_ai.models.test import TestModel
def foobar(**kwargs) -> str:
return kwargs['a'] + kwargs['b']
tool = Tool.from_schema(
function=foobar,
name='sum',
description='Sum two numbers.',
json_schema={
'additionalProperties': False,
'properties': {
'a': {'description': 'the first number', 'type': 'integer'},
'b': {'description': 'the second number', 'type': 'integer'},
},
'required': ['a', 'b'],
'type': 'object',
},
takes_ctx=False,
)
test_model = TestModel()
agent = Agent(test_model, tools=[tool])
result = agent.run_sync('testing...')
print(result.output)
#> {"sum":0}
Pydantic AI는 여기서 도구 인수를 검증하지 않아요. 키워드 인수로 그냥 넘겨줘요.
엄격 모드 (Strict Mode)
일부 공급자는 도구 호출에 대해 strict 모드를 지원해서, 도구 호출 인수가 항상 도구의 JSON 스키마를 따르도록 모델을 제약해요. 모델이 인수를 자유롭게 생성한 뒤 사후 검증하도록 두는 대신, 공급자가 생성 자체를 제한해 처음부터 스키마 밖 인수가 나오지 않게 해요. 이는 모든 도구 등록 메커니즘(@agent.tool, @agent.tool_plain, Tool, FunctionToolset.add_function 등)과 ToolDefinition에서 쓸 수 있는 strict 플래그로 제어돼요:
from pydantic import BaseModel
from pydantic_ai import Agent
class Reservation(BaseModel):
restaurant: str
party_size: int
outdoor_seating: bool
dietary_notes: list[str]
agent = Agent('openai:gpt-5')
@agent.tool_plain(strict=True)
def book_table(reservation: Reservation) -> str:
return f'Booked a table for {reservation.party_size} at {reservation.restaurant}.'
strict 모드는 이런 구조화된 인수에서 진가를 발휘해요: 없으면 모델이 party_size를 문자열로 내거나, outdoor_seating을 생략하거나, 추가 프로퍼티를 지어낼 수 있는데, strict 생성은 검증 재시도에 의존하는 대신 그것들을 처음부터 배제해요.
strict 모드는 인수가 스키마와 정확히 일치함을 보장하므로, 모든 스키마가 그 아래에서 표현될 수는 없어요: 일부 공급자는 모든 프로퍼티가 required에 나열되고 객체가 additionalProperties: false를 설정하길 요구해요. 이런 방식으로 표현할 수 없는 스키마는 손실 있게 변환되거나 그 도구에 대해 플래그가 무시될 수 있어요 — 그래서 strict=True는 공급자가 지킬 수 있는 곳 어디든 strict 모드를 강제 하라는 요청으로 읽는 게 가장 좋아요.
Pydantic AI는 strict 플래그를 OpenAI, Anthropic, Google, Bedrock 모델에 대해 네이티브 스키마 강제 기능으로 변환해요. 나머지 공급자는 무시해요. 각 공급자의 기본 기능은 달라요:
| 공급자 | 동작 |
|---|---|
| OpenAI | 엄격 도구 정의. 도구 스키마가 strict 호환일 때 자동 활성화. strict=True는 강제 |
| Anthropic | 엄격 도구 정의. strict=True로 옵트인하지 않으면 꺼짐 |
| Bedrock | 지원 모델에서 엄격 도구 스펙. strict=True로 옵트인하지 않으면 꺼짐 |
| Google (Gemini) | 선언된 스키마에 모델이 따르게 하는 Gemini의 VALIDATED function-calling 모드. Gemini 2.5 이상에서는 기본 활성화 — VALIDATED는 스키마 변경이 필요 없어서 공짜 개선이에요 — strict=False로 옵트아웃 가능. Gemini의 모드는 요청 전역이에요: strict=False인 함수·출력 도구가 하나라도 있으면 전체 요청이 AUTO에 머물러요. VALIDATED는 Gemini 프리뷰 기능이에요 |
엄격성 값 (Strictness values)
strict 플래그는 bool | None이에요:
True— 공급자가 도구 스키마에 대해 strict 모드를 지원하는 곳 어디든 강제.False— 그 도구엔 절대 strict 모드를 쓰지 않음. Google에서는strict=False인 도구(함수든 출력이든) 하나가 전체 요청을VALIDATED가 아니라AUTO로 유지시켜요.None(기본) — 공급자별 결정: OpenAI는 스키마가 strict 호환이면 활성화, Google은 지원 모델에서VALIDATED기본, Anthropic과 Bedrock은strict=True로 명시 옵트인하지 않으면 꺼짐.
여러 도구를 한 번에 strict로 켜려면 에이전트 전역 동적 도구를 써서 각 ToolDefinition에 strict=True를 설정하세요.
동적 도구 (Dynamic Tools)
도구는 선택적으로 다른 함수 prepare로 정의할 수 있어요. 이 함수는 런의 각 단계에서 호출되어 모델에 넘겨지는 도구의 정의를 커스터마이즈하거나, 그 단계에서 도구를 완전히 생략할 수 있어요.
prepare 메서드는 어떤 도구 등록 메커니즘의 prepare kwarg로도 등록할 수 있어요:
@agent.tool데코레이터@agent.tool_plain데코레이터Tooldataclass
prepare 메서드는 타입이 ToolPrepareFunc예요. RunContext와 미리 만들어진 ToolDefinition을 받아요. 그 정의를 그대로 또는 수정해서 반환하거나, 새 정의를 반환하거나, None을 반환해 그 단계에서 도구를 생략할 수 있어요.
의존성 값이 42일 때만 도구를 포함하는 간단한 prepare 메서드예요. 이전 예시처럼, 실제 모델을 호출하지 않고 행동을 보여주기 위해 TestModel을 써요.
from pydantic_ai import Agent, RunContext, ToolDefinition
agent = Agent('test')
async def only_if_42(
ctx: RunContext[int], tool_def: ToolDefinition
) -> ToolDefinition | None:
if ctx.deps == 42:
return tool_def
@agent.tool(prepare=only_if_42)
def hitchhiker(ctx: RunContext[int], answer: str) -> str:
return f'{ctx.deps} {answer}'
result = agent.run_sync('testing...', deps=41)
print(result.output)
#> success (no tool calls)
result = agent.run_sync('testing...', deps=42)
print(result.output)
#> {"hitchhiker":"42 a"}
(이 예제는 완전해서 그대로 실행할 수 있어요)
다음 예시는 deps 값에 따라 name 파라미터의 설명을 바꿔요. 변화를 주려고 Tool dataclass로 이 도구를 만들어요.
from __future__ import annotations
from typing import Literal
from pydantic_ai import Agent, RunContext, Tool, ToolDefinition
from pydantic_ai.models.test import TestModel
def greet(name: str) -> str:
return f'hello {name}'
async def prepare_greet(
ctx: RunContext[Literal['human', 'machine']], tool_def: ToolDefinition
) -> ToolDefinition | None:
d = f'Name of the {ctx.deps} to greet.'
tool_def.parameters_json_schema['properties']['name']['description'] = d
return tool_def
greet_tool = Tool(greet, prepare=prepare_greet)
test_model = TestModel()
agent = Agent(test_model, tools=[greet_tool], deps_type=Literal['human', 'machine'])
result = agent.run_sync('testing...', deps='human')
print(result.output)
#> {"greet":"hello a"}
print(test_model.last_model_request_parameters.function_tools)
"""
[
ToolDefinition(
name='greet',
parameters_json_schema={
'additionalProperties': False,
'properties': {
'name': {'type': 'string', 'description': 'Name of the human to greet.'}
},
'required': ['name'],
'type': 'object',
},
toolset_id='<agent>',
)
]
"""
(이 예제는 완전해서 그대로 실행할 수 있어요)
에이전트 전역 동적 도구 (Agent-wide Dynamic Tools)
도구별 prepare 메서드 외에도, 에이전트 전역 prepare_tools 함수를 정의할 수 있어요. 이 함수는 런의 각 단계에서 호출되어 그 단계 동안 에이전트에 사용 가능한 모든 도구 정의 목록을 필터링하거나 수정할 수 있어요. 여러 도구를 한 번에 켜거나 끄거나, 현재 맥락에 기반한 전역 로직을 적용할 때 특히 유용해요.
prepare_tools 함수는 ToolsPrepareFunc 타입이어야 해요. RunContext와 ToolDefinition 리스트를 받아, 그 단계에 노출할 도구 정의를 반환해요. 모든 도구를 그대로 유지하려면 tool_defs 인수를, 아무 도구도 노출하지 않으려면 []를 반환하세요.
참고 — prepare_tools에 넘겨지는 도구 정의 목록은 일반 함수 도구와 에이전트에 등록된 어떤 toolset의 도구를 모두 포함하지만, 출력 도구는 포함하지 않아요. 출력 도구를 수정하려면 대신 prepare_output_tools 함수를 설정하면 돼요.
모델이 OpenAI 모델이면 모든 도구를 strict로 만드는 예시:
from dataclasses import replace
from pydantic_ai import Agent, RunContext, ToolDefinition
from pydantic_ai.capabilities import PrepareTools
from pydantic_ai.models.test import TestModel
async def turn_on_strict_if_openai(
ctx: RunContext, tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
if ctx.model.system == 'openai':
return [replace(tool_def, strict=True) for tool_def in tool_defs]
return tool_defs
test_model = TestModel()
agent = Agent(test_model, capabilities=[PrepareTools(turn_on_strict_if_openai)])
@agent.tool_plain
def echo(message: str) -> str:
return message
agent.run_sync('testing...')
assert test_model.last_model_request_parameters.function_tools[0].strict is None
# Set the system attribute of the test_model to 'openai'
test_model._system = 'openai'
agent.run_sync('testing with openai...')
assert test_model.last_model_request_parameters.function_tools[0].strict
(이 예제는 완전해서 그대로 실행할 수 있어요)
의존성(ctx.deps)이 True일 때 이름으로 도구를 조건부로 걸러내는 또 다른 예시:
from pydantic_ai import Agent, RunContext, Tool, ToolDefinition
from pydantic_ai.capabilities import PrepareTools
def launch_potato(target: str) -> str:
return f'Potato launched at {target}!'
async def filter_out_tools_by_name(
ctx: RunContext[bool], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
if ctx.deps:
return [tool_def for tool_def in tool_defs if tool_def.name != 'launch_potato']
return tool_defs
agent = Agent(
'test',
tools=[Tool(launch_potato)],
capabilities=[PrepareTools(filter_out_tools_by_name)],
deps_type=bool,
)
result = agent.run_sync('testing...', deps=False)
print(result.output)
#> {"launch_potato":"Potato launched at a!"}
result = agent.run_sync('testing...', deps=True)
print(result.output)
#> success (no tool calls)
(이 예제는 완전해서 그대로 실행할 수 있어요)
prepare_tools로 할 수 있는 것:
- 현재 모델·의존성·기타 맥락에 따라 도구 동적 활성화/비활성화
- 도구 정의 전역 수정(예: 모든 도구를 strict 모드로, 설명 변경 등)
도구별 prepare와 에이전트 전역 prepare_tools를 둘 다 쓰면, 도구별 prepare가 먼저 각 도구에 적용되고, 그 결과 도구 정의 목록으로 prepare_tools가 호출돼요.
도구 선택 (Tool Choice)
ModelSettings의 tool_choice 설정은 요청 중 모델이 쓸 수 있는 도구를 제어해요. 도구를 비활성화하거나, 도구 사용을 강제하거나, 사용 가능한 도구를 제한하는 데 유용해요.
Pydantic AI는 함수 도구(@agent.tool, toolset, MCP로 등록한 도구)와 출력 도구(구조화된 출력에 쓰는 내부 도구)를 구분해요.
옵션
| 값 | 설명 |
|---|---|
'auto' (기본) |
모델이 도구를 쓸지 결정. 모든 도구 사용 가능 |
'none' |
함수 도구 비활성화. 모델은 텍스트로 응답하거나 출력 도구를 쓸 수 있음 |
'required' |
모델이 함수 도구를 쓰도록 강제. 출력 도구는 제외하므로, capability로 동적으로 설정하거나 직접 모델 요청을 써야 함. agent.run()에서 정적으로 설정하면 오류 발생 |
['tool_a', ...] |
이름으로 특정 도구만. 출력 도구 제외 — 'required'와 같은 동적/직접 요구 사항 |
ToolOrOutput(function_tools=['...']) |
함수 도구를 제한하면서 모든 출력 도구는 자동 포함 |
지연 로딩으로 숨겨진 도구는 tool_choice와 상호작용해요: 아직 숨겨진 도구는 이름으로 강제해도 무시되고, 명시적 선택은 요청한 도구가 모두 숨겨졌을 때만 오류를 내요. 'required'는 모든 함수 도구가 숨겨졌을 때 오류를 내요. 스키마가 지연된 채 선언된 도구는 강제할 수 있어요. tools 목록 밖에 드러난 정의를 나르는 공급자(OpenAI Responses additional_tools)에서는, 이름 강제가 선언된 도구만 대상으로 할 수 있으므로, 드러난 도구도 이름으로 강제할 수 없어요.
예시
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic_ai.settings import ToolOrOutput
agent = Agent(TestModel())
@agent.tool_plain
def get_weather(city: str) -> str:
return f'Sunny in {city}'
@agent.tool_plain
def get_time(city: str) -> str:
return f'12:00 in {city}'
# Pass tool_choice via model_settings
result = agent.run_sync('Hello', model_settings={'tool_choice': 'none'})
# Use ToolOrOutput to restrict to specific function tools while allowing output
result = agent.run_sync(
'Hello', model_settings={'tool_choice': ToolOrOutput(function_tools=['get_weather'])}
)
Capabilities를 통한 동적 도구 선택
tool_choice='required'와 ['tool_a', ...]는 출력 도구를 제외하므로, 어느 하나를 정적으로 설정하면 매 단계마다 도구 호출을 강제하고 에이전트가 최종 응답을 만들 수 없게 돼요. agent.run()은 정적 기준값(Agent.run의 model_settings 인수, 에이전트 자체 model_settings, 또는 기저 모델 기본값)에서 이 값을 감지하면 UserError를 발생시켜요.
tool_choice를 단계별로 바꾸려면 — 예를 들어 첫 단계에서 특정 도구를 강제하고 그다음 모델이 결정하게 하려면 — capability의 get_model_settings에서 호출 가능한 것을 반환하세요. 그 호출 가능한 것은 RunContext를 받는데 ctx.messages와 ctx.run_step에 완전히 접근할 수 있어서, 런에서 이미 무슨 일이 있었는지 검사해 적응할 수 있어요.
from pydantic_ai import Agent, ModelSettings, RunContext
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.messages import ModelRequest, ToolReturnPart
class RequireFirstCall(AbstractCapability):
"""Force `tool_name` to be called successfully before anything else."""
def __init__(self, tool_name: str) -> None:
self.tool_name = tool_name
def get_model_settings(self):
def settings(ctx: RunContext) -> ModelSettings:
called = any(
isinstance(part, ToolReturnPart) and part.tool_name == self.tool_name
for message in ctx.messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if called:
return ModelSettings()
return ModelSettings(tool_choice=[self.tool_name])
return settings
agent = Agent('openai:gpt-5.2', capabilities=[RequireFirstCall('get_weather')])
@agent.tool_plain
def get_weather(city: str) -> str:
return f'Sunny in {city}'
capability가 공급하는 설정은 단계별로 해석되므로, 호출 가능한 것이 반환한 tool_choice는 단계에 걸쳐 바뀌는 것으로 신뢰되며 기준값 검증기로 거부되지 않아요. 에이전트 루프 없이 단일 모델 요청만 하려면 대신 pydantic_ai.direct.model_request를 쓰세요.
공급자 지원 (Provider Support)
모든 공급자가 'auto'와 'none'을 지원해요. 다른 옵션의 핵심 차이:
| 공급자 | 'required' |
특정 도구 | 참고 |
|---|---|---|---|
| OpenAI | ✓ | ✓ | 전체 지원 |
| Anthropic | ⚠️ | ⚠️ | extended thinking와는 지원 안 됨. adaptive thinking은 호환 |
| ✓ | ✓ | ||
| Bedrock | ✓ | 단일만 | 여러 도구는 'any' 모드로 폴백 |
| Groq/HuggingFace | ✓ | 단일만 | 여러 도구는 'required' 모드로 폴백 |
| Mistral | ✓ | ✓ | 'required'를 'any' 모드로 매핑 |
| Cohere | ✓ | ✓ | 'required'를 'REQUIRED'로 매핑. 명명된 부분집합은 tools 배열을 다듬어 적용 |
| xAI | ✓ | ✓ | 일부 모델은 강제를 지원하지 않을 수 있음. 'auto'로 폴백 |
OpenAIChatModel에 기반한 모델 클래스 — Cerebras, Crusoe, GitHub Copilot, Ollama, OpenRouter, Snowflake, Z.AI, Bedrock Mantle Chat — 는 두 가지 예외를 빼고 OpenAI 행처럼 동작해요. Ollama는 tool_choice를 미지원으로 문서화하고 무시해요. OpenRouter는 강제 도구 선택과 thinking을 결합할 수 없는 모델에서, 추론을 조용히 떨어뜨리는 대신 명시적 'required'나 명명된 부분집합에 UserError를 발생시켜요. Pydantic AI가 추론한 강제는 대신 'auto'로 폴백해요.
프롬프트 캐싱 함의
tool_choice로 사용 가능한 도구 세트를 제한하면 공급자 프롬프트 캐시를 무효화할 수 있어요. 대부분의 공급자 API가 전체 tools 배열로 캐시하기 때문이에요. Pydantic AI는 두 가지 방식으로 도구 세트를 제한해요:
- API 수준 필터링(캐시 보존): 전체 tools 배열을 보내고 공급자에 부분집합만 허용하라고 말함. OpenAI Responses(
allowed_tools), Google(allowed_function_names), Bedrock(단일 도구 강제 시)이 써요. - 클라이언트측 필터링(캐시 깨짐): 요청 전에 tools 배열을 다듬음. 공급자 API에 해당 경우에 대한 네이티브 필터가 없을 때 써요.
아래 표는 Pydantic AI가 클라이언트측으로 필터링해야 해서 캐시를 깨는 경우를 다뤄요:
| 공급자 | 캐시 깨는 경우 |
|---|---|
| Anthropic | tool_choice가 여러 도구 목록, 또는 extended thinking을 쓰는 단일 도구 또는 강제를 지원하지 않는 모델의 단일 도구 |
| OpenAI Chat | tool_choice가 여러 도구 목록, 또는 강제를 지원하지 않는 모델의 단일 도구 |
| Bedrock | tool_choice가 여러 도구 목록, 또는 thinking이 켜진 단일 도구 또는 강제를 지원하지 않는 모델의 단일 도구 |
| Groq / HuggingFace | tool_choice가 여러 도구 목록 |
| Mistral | tool_choice가 목록(어떤 크기든) — API가 특정 도구 이름을 받지 않음 |
| Cohere | tool_choice가 목록(어떤 크기든) — API가 특정 도구 이름을 받지 않음 |
| xAI | tool_choice가 여러 도구 목록, 또는 강제를 지원하지 않는 모델의 단일 도구 |
| OpenAI Responses | 절대 없음 — allowed_tools가 모든 경우를 네이티브로 처리 |
절대 없음 — allowed_function_names가 모든 경우를 네이티브로 처리 |
캐시 히트 보존이 중요하다면 "Never"로 표시된 공급자/경우를 선호하거나, 제한 목록 대신 전체 세트를 유지하는 ToolOrOutput을 쓰세요.
도구 실행, 재시도, 실패 (Tool Execution, Retries, and Failures)
도구가 실행되면 그 인수(LLM이 제공)가 먼저 Pydantic을 사용해 함수 시그니처에 대해 검증돼요(선택적 검증 컨텍스트 포함). 검증이 실패하면(예: 잘못된 타입, 누락된 필수 인수) ValidationError가 발생하고, 프레임워크가 검증 세부사항을 담은 RetryPromptPart를 자동 생성해요. 이 프롬프트는 LLM에 다시 보내져서 오류를 알리고 파라미터를 고쳐 도구 호출을 재시도하게 해요.
도구 자체 로직이 정상 결과를 만들 수 없을 때, 모델이 다음에 무엇을 하길 원하는지에 따라 예외를 선택하세요:
ModelRetry— 모델이 수정된 인수나 다른 접근으로 도구 호출을 다시 시도하길 원할 때.ToolFailed— 도구 호출을 실패한 결과로 모델에 보고해야 할 때. 도구의 재시도 예산을 소비하지 않아요.
다른 어떤 예외든 에이전트 런 밖으로 전파되고 모델에 다시 보내지지 않아요.
도구 재시도 요청하기
ModelRetry를 올리면 예외 메시지를 담은 RetryPromptPart가 생성돼요. 그 프롬프트는 LLM에 다시 보내져 도구 호출을 고치거나, 다른 도구를 고르거나, 다른 접근을 시도하게 해요.
from pydantic_ai import ModelRetry
def my_flaky_tool(query: str) -> str:
if query == 'bad':
# Tell the LLM the query was bad and it should try again
raise ModelRetry("The query 'bad' is not allowed. Please provide a different query.")
# ... process query ...
return 'Success!'
ValidationError와 ModelRetry는 둘 다 설정된 재시도 한계를 따르는데, 도구별로는 Tool(max_retries=N)(또는 @agent.tool(retries=N)), toolset별로는 FunctionToolset(max_retries=N), 에이전트 전역으로는 Agent(retries={'tools': N})로 설정하며, 그 순서로 우선 적용돼요. 에이전트 전역 기본값은 agent.run(retries={'tools': N})(그리고 run_sync/run_stream/iter, 또는 런 블록을 위한 agent.override())로 런별 오버라이드할 수 있어요. 런별 값은 우선순위 체인 맨 아래의 에이전트 전역 기본값을 대체하므로, 명시적 도구·toolset 한계가 여전히 이겨요. 이 런타임 호출 지점에서 맨 int는 두 예산을 모두 오버라이드해요(생성과 일치) — retries={'tools': N}이나 retries={'output': N} 같은 dict를 넘기면 하나만 바꿔요.
도구 재시도는 도구별로 추적돼요: 모든 함수 도구는 자체 카운터를 갖고, 런 전체에 걸친 전역 '도구 호출' 예산은 공유하지 않아요. 도구가 ModelRetry를 올리거나 인수 검증이 실패하면 오직 그 도구의 카운터만 진행돼요. 도구 함수 안에서 ctx.max_retries는 그 도구의 집행 한계를, ctx.retry는 그 도구 자체의 카운터를 반영해요. 도구가 카운터를 소진하면 런은 메시지 'Tool {name!r} exceeded max retries count of {N}. Consider raising the retry limit, or see the docs on tool retries: https://pydantic.dev/docs/ai/tools-toolsets/tools-advanced/#tool-retries'를 가진 UnexpectedModelBehavior를 발생시켜요. 사용자가 제공한 toolset은 toolset별 값이 설정되지 않았을 때 기본으로 에이전트 전역 도구 재시도 기본값(또는 그 런별 오버라이드)을 상속해요.
어떤 재시도 한계가 이기나
두 개의 독립 예산 — 도구 예산(함수/출력 도구별)과 출력 예산(출력 검증) — 은 각각 같은 계층 우선순위로 해결돼요. 값을 설정한 첫 번째 계층이 이기고, 설정 안 된 계층은 다음으로 넘어가요:
| 우선순위 (높음 먼저) | 설정 방법 | 설정하는 예산 |
|---|---|---|
| 1. 도구별 한계 | @agent.tool(retries=N) / Tool(max_retries=N); 출력 도구는 ToolOutput(max_retries=N) |
그 도구 하나 |
| 2. toolset별 한계 | FunctionToolset(max_retries=N) 또는 MCPToolset(max_retries=N) |
그 toolset 안의 도구 |
| 3. 오버라이드 블록 | agent.override(retries=...) |
도구 및/또는 출력 |
| 4. 런별 인수 | agent.run(retries=...) (그리고 run_sync/run_stream/iter) |
도구 및/또는 출력 |
| 5. 런별 스펙 | agent.run(spec={'retries': ...}) |
도구 및/또는 출력 |
| 6. 에이전트 전역 기본 | Agent(retries=...) |
도구 및/또는 출력 |
| 7. 내장 기본 | -- | 1 |
계층 3-6에서 맨 int는 두 예산을 모두 그 값으로 설정하는 반면, AgentRetries dict는 이름 붙인 키만 설정해요({'tools': N}, {'output': N}, 또는 둘 다). 계층 3-5는 에이전트 전역 기본값(계층 6)을 오버라이드하지만 더 구체적인 도구별(계층 1)이나 toolset별(계층 2) 한계는 절대 오버라이드하지 않아요.
실패한 도구 결과 보고하기
모든 도구 실패가 수정 요청인 건 아니에요. 호출이 완료됐지만 실패했을 때 — 리소스가 없다거나, 연산이 지원되지 않거나, 업스트림 서비스가 확정적 오류를 반환했다거나 — 보통 모델이 실패 결과를 보고 그다음 무엇을 할지 결정하길 원해요. 이를 위해 ToolFailed를 올리세요:
from pathlib import Path
from pydantic_ai import ToolFailed
def read_file(path: str) -> str:
file_path = Path(path)
if not file_path.is_file():
raise ToolFailed(f'File not found: {path}')
return file_path.read_text()
예외 메시지는 outcome='failed'인 ToolReturnPart로 메시지 기록에 기록돼요. 모델 API가 도구 결과에 네이티브 error/failed-status 필드를 가지면 Pydantic AI가 그걸 써요. 네이티브 오류 채널이 없는 API에서는 모델에 보이는 콘텐츠가 {"error": ...}로 JSON 프레이밍되어 실패가 여전히 명확해요. 실패 결과는 Pydantic AI 메시지 기록에 보존돼요. 프로토콜 어댑터는 그 기록이 왕복될 때 자체 캐리어가 필요할 수 있어요. AG-UI에 설명된 대로요. 호출은 텔레메트리에서 오류로 추적돼요.
ModelRetry와 달리 ToolFailed는 도구별 재시도 예산을 소비하지 않아요. 반복 실패를 묶는 것은 런 수준의 UsageLimits 몫이에요 — 구체적으로 request_limit요, 왜냐하면 tool_calls_limit은 성공한 도구 호출만 세기 때문이에요.
경험칙: 수정과 함께 다시 시도하길 원하면 ModelRetry, 호출이 끝났고 결과가 실패면 ToolFailed. MCP 서버 도구 오류에 대해서는 tool_error_behavior 구성으로 같은 선택이 가능해요.
도구 검증·실행 훅에서도 ModelRetry나 ToolFailed를 올릴 수 있어요. 이는 모든 도구에 try/except를 반복하지 않고 제3자 예외를 변환할 때 유용해요. 오류 훅과 도구 실행 훅을 보세요.
ToolFailed는 함수 도구, 그 args_validator, 도구 검증·실행 훅에서 처리돼요. 출력 함수와 출력 검증기는 모델이 다시 시도하길 원할 때 ModelRetry를 써요. 거기서 ToolFailed는 출력 처리 오류 훅이 복구하지 않는 한 런을 중단시키는 평범한 예외예요.
도구 타임아웃 (Tool Timeout)
도구가 무한히 실행되지 않도록 실행 타임아웃을 설정할 수 있어요. 도구가 타임아웃을 초과하면 재시도 가능한 실패로 취급되고 재시도 프롬프트가 모델에 보내져요(재시도 한계에 포함).
import asyncio
from pydantic_ai import Agent
# Set a default timeout for the agent's own tools
agent = Agent('test', tool_timeout=30)
@agent.tool_plain
async def slow_tool() -> str:
"""This tool will use the agent's default timeout (30 seconds)."""
await asyncio.sleep(10)
return 'Done'
@agent.tool_plain(timeout=5)
async def fast_tool() -> str:
"""This tool has its own timeout (5 seconds) that overrides the agent default."""
await asyncio.sleep(1)
return 'Done'
- 에이전트 수준 타임아웃:
Agent에tool_timeout을 설정해 그에 등록된 도구에 기본 타임아웃 적용. - 도구별 타임아웃:
@agent.tool,@agent.tool_plain,Tooldataclass로 개별 도구에timeout설정. 에이전트 수준 기본값을 오버라이드.
타임아웃이 발생하면 도구는 재시도 가능한 실패로 취급되고, 모델은 "Timed out after {timeout} seconds." 메시지를 가진 재시도 프롬프트를 받아요. 이는 검증 오류나 명시적 ModelRetry 예외처럼 도구의 재시도 한계에 포함돼요.
두 설정 모두 에이전트 자체 도구를 뒷받침하는 FunctionToolset이 강제해요. MCP 서버, 외부 toolset, 커스텀 AbstractToolset이 서빙하는 도구는 그것들을 읽지 않아요 — 그런 마감은 서버나 전송 수준에서 묶으세요. 도구 타임아웃이 런의 다른 마감과 어떻게 관련되는지는 타임아웃을 보세요.
도구에서 런 취소하기
도구가 RunContext.cancel()을 호출하면 전체 런을 중단할 수 있어요 — 예를 들어 추가 작업이 무의미하다는 것을 발견했거나, 도구가 RunContext를 잡고 있는 동안 정지 신호가 앱에 도달했을 때요. 런 밖에서는 어떤 실행 메서드에든 CancellationToken을 넘기세요. 런은 진행 중인 것을 취소를 요청하고 호출자에게 RunCancelled를 발생시켜요. 병렬 실행되는 비동기 도구 태스크는 취소·소진되지만, Python은 동기 도구의 워커 스레드를 강제로 멈출 수 없어요. 취소가 워커를 기다리거나 백그라운드에서 끝나게 둘 수 있어요. 어느 쪽이든 그것의 최종 결과는 부수 효과가 남는 동안 폐기돼요. 이미 완료된 도구 호출은 결과를 메시지 기록에 유지하고, 결과를 만든 적 없는 도구 호출은 그 기록이 재사용될 때 자동으로 복구돼요. 두 취소 표면 모두 런과 같은 프로세스에 있어야 하므로, durable execution 직렬화 경계(예: Temporal 액티비티)를 넘지 못해요.
자세한 전체 그림(런 밖 취소, 취소된 런 상태 접근 포함)은 런 취소를 보세요.
커스텀 인수 검증기 (Custom Args Validator)
args_validator 파라미터로 Pydantic 스키마 검증 후, 도구 실행 전에 실행되는 커스텀 검증을 정의할 수 있어요. 비즈니스 로직 검증, 교차 필드 검증, 또는 인간 승인 전에 지연 도구의 인수를 검증할 때 유용해요.
검증기는 RunContext를 첫 인수로 받고, 그다음 도구 함수와 같은 파라미터를 받아요. 성공 시 None을 반환하고, 모델이 인수를 고쳐 다시 시도하게 하려면 ModelRetry를, 재시도 대신 모델이 적응해야 할 종결 실패를 보고하려면 ToolFailed를, 또는 호출을 지연시키려면 ApprovalRequired / CallDeferred를 올려요.
from pydantic_ai import Agent, DeferredToolRequests, ModelRetry, RunContext
agent = Agent('test', deps_type=int, output_type=[str, DeferredToolRequests])
def validate_sum_limit(ctx: RunContext[int], x: int, y: int) -> None:
"""Validate that the sum doesn't exceed the limit from deps."""
if x + y > ctx.deps:
raise ModelRetry(f'Sum of x and y must not exceed {ctx.deps}')
# Validation runs *before* approval is requested, so the model can
# fix bad args without bothering the user.
@agent.tool(requires_approval=True, args_validator=validate_sum_limit)
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int:
"""Add two numbers (sum must not exceed the configured limit)."""
return x + y
result = agent.run_sync('add 5 and 3', deps=100)
assert isinstance(result.output, DeferredToolRequests)
# The validated args are ready for the user to approve
print(result.output.approvals[0].args)
#> {'x': 0, 'y': 0}
(이 예제는 완전해서 그대로 실행할 수 있어요)
스키마 검증이 실패하거나 args_validator가 ModelRetry를 올리면, 오류 메시지는 재시도 프롬프트(다시 시도하라는 지시와 함께)로 LLM에 다시 보내지고 도구의 retries 설정을 따르죠. args_validator가 ToolFailed를 올리면, 모델은 재시도 대신 적응해야 할 실패한 도구 결과를 받고, 재시도 예산은 그대로 남아요. 지연 도구에서 검증은 지연 시점에 실행돼요 — 유효한 인수를 가진 도구 호출만 지연돼요.
검증기는 호출 자체를 지연시킬 수 있어요. 도구 함수처럼요 — 그리고 좋지 않은 인수가 인간에게 승인을 요청받기 전에 거부되므로, 그 결정을 내리기 더 좋은 자리예요. ApprovalRequired나 CallDeferred를 올리는 검증기는 재시도 예산도 소비하지 않아요: 인수가 유효했으므로, 지연은 실패가 아니라 의도적 결정이에요. 도구 함수는 실행되지 않고, 호출은 런의 다른 지연 도구 호출에 합류해요: HandleDeferredToolCalls 핸들러가 있으면 그것으로 인라인 해결되거나, 런의 DeferredToolRequests 출력에 표면화돼요. 호출이 승인되면 검증기가 다시 실행돼요 — 이번엔 RunContext.tool_call_approved가 True로 설정된 채 — 그리고 도구가 실행돼요.
여기서는 불가능한 금액은 통째로 거부되고, 큰 금액은 인간 앞에 놓여요:
from pydantic_ai import (
Agent,
ApprovalRequired,
DeferredToolRequests,
ModelMessage,
ModelResponse,
ModelRetry,
RunContext,
TextPart,
ToolCallPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
agent = Agent(deps_type=int, output_type=[str, DeferredToolRequests])
def validate_transfer(ctx: RunContext[int], amount: int) -> None:
if amount > ctx.deps:
raise ModelRetry(f'Amount must not exceed {ctx.deps}') # (1)
if amount > 100 and not ctx.tool_call_approved:
raise ApprovalRequired() # (2)
@agent.tool(args_validator=validate_transfer)
def transfer_funds(ctx: RunContext[int], amount: int) -> str:
return f'Transferred {amount}'
def call_transfer(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('transfer_funds', {'amount': 500})])
return ModelResponse(parts=[TextPart('done')])
result = agent.run_sync('transfer 500', deps=1000, model=FunctionModel(call_transfer))
assert isinstance(result.output, DeferredToolRequests)
print(result.output.approvals[0].args)
#> {'amount': 500}
모델이 불가능한 금액은 인간이 그 호출을 보기도 전에 스스로 고칠 수 있어요. (1) — 검증을 통과한 호출만 인간 앞에 놓여요. (2)
(이 예제는 완전해서 그대로 실행할 수 있어요)
args_validator 파라미터는 @agent.tool, @agent.tool_plain, Tool, Tool.from_schema, FunctionToolset에서 쓸 수 있어요. 검증기는 동기 또는 비동기 함수일 수 있어요.
durable execution 아래에서 args_validator를 가진 도구는 엔진이 그 toolset을 감싸는 어디든 전용 검증 액티비티·단계·태스크를 얻어요. 검증기는 I/O를 수행할 수 있고 호출을 재시도·실패·지연시킬 수 있어요. args_validator가 없는 도구는 추가 durable 단위를 예약하지 않아요.
검증 결과는 FunctionToolCallEvent의 args_valid 필드로 드러나요. 이는 모든 검증 — 스키마 검증과 커스텀 args_validator 검증(설정된 경우) — 을 반영해요: True는 모든 검증이 통과했음을, False는 실패했음을, None은 검증이 수행되지 않았음을('early' end 전략으로 건너뛴 도구 호출이나 실행 없이 해결된 지연 도구 호출처럼) 의미해요.
병렬 도구 호출 및 동시성
모델이 한 응답에서 여러 도구 호출을 반환하면, Pydantic AI는 asyncio.create_task로 그것들을 동시에 스케줄해 모델이 방출한 순서대로 실행해요.
특정 도구가 다른 것과 겹치지 않게 하려면 sequential=True로 표시하세요. 그러면 장벽처럼 동작해요: 모델이 그보다 먼저 방출한 도구가 먼저 끝나고, 그것은 혼자 실행되며, 그 이후에 방출된 도구는 그것이 끝나야 시작돼요.
from pydantic_ai import Agent, ModelMessage, ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import AgentInfo, FunctionModel
agent = Agent()
calls: list[str] = []
@agent.tool_plain
def fetch_record(record_id: int) -> str:
calls.append(f'fetch_record({record_id})')
return f'record-{record_id}'
@agent.tool_plain(sequential=True)
def write_to_database(record: str) -> str:
calls.append(f'write_to_database({record!r})')
return 'written'
def call_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1: # first request: ask for both tools at once
return ModelResponse(
parts=[
ToolCallPart('fetch_record', {'record_id': 1}),
ToolCallPart('write_to_database', {'record': 'data'}),
]
)
return ModelResponse(parts=[TextPart('done')])
result = agent.run_sync('store the record', model=FunctionModel(call_tools))
print(result.output)
#> done
# `write_to_database` waited for `fetch_record` to finish before running.
print(calls)
#> ['fetch_record(1)', "write_to_database('data')"]
함수 도구를 등록할 때 sequential 플래그를 넘길 수 있고, 출력 도구에도 같은 장벽이 ToolOutput(sequential=True)로 가능해요(출력 도구 병렬 처리 제어 참조). 어떤 도구가 호출됐든 전체 런의 도구를 직렬로 실행하려면, 런을 with agent.parallel_tool_call_execution_mode('sequential') 컨텍스트 매니저로 감싸거나, 모델 설정에서 parallel_tool_calls=False를 설정하세요.
비동기 함수는 이벤트 루프에서, 동기 함수는 스레드로 오프로드돼요. 최고 성능을 얻으려면 항상 비동기 함수를 쓰되, 블로킹 I/O(대신 논블로킹 라이브러리를 쓸 방법이 없을 때)나 CPU 바운드 작업(numpy, scikit-learn 연산 같은)을 할 때만 동기 함수를 쓰세요. 그래야 단순 함수가 불필요하게 스레드로 오프로드되지 않아요.
동기 도구는 별도 스레드에서 실행돼요 — 동기 함수는 워커 스레드에서 실행되므로, 그것이 contextvars.ContextVar에 설정한 값은 함수 밖에서 보이지 않고, asyncio.get_running_loop() 같은 asyncio API는 워커 스레드에 이벤트 루프가 없어서 오류를 내요. 컨텍스트 변수 읽기는 여전히 동작하지만, 쓰기 제한은 내부적으로 그것들을 쓰는 라이브러리(추적·로깅 통합)에도 적용돼요. 도구가 이 중 아무거나 필요하면 async로 만들어요. 같은 제약이 에이전트가 대신 실행하는 어떤 동기 함수 — 훅, 시스템 프롬프트 함수, 출력 함수, 내역 처리기 — 에도 적용돼요.
장기 실행 서버용 스레드 실행기
기본적으로 동기 함수는 anyio.to_thread.run_sync로 스레드에 오프로드되는데, 그것은 필요할 때 에페메랄 스레드를 만들어요. 장기 실행 서버(예: FastAPI)에서 이 스레드들은 지속 트래픽에서 누적되어 메모리 성장으로 이어질 수 있어요.
스레드 수명을 제어하려면 UseThreadExecutor capability(에이전트별) 또는 Agent.using_thread_executor() 컨텍스트 매니저(전역)로 바운드된 ThreadPoolExecutor를 제공하세요:
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pydantic_ai import Agent
from pydantic_ai.capabilities import UseThreadExecutor
# Per-agent: pass as a capability
executor = ThreadPoolExecutor(max_workers=16, thread_name_prefix='agent-worker')
agent = Agent('openai:gpt-5.2', capabilities=[UseThreadExecutor(executor)])
# Global: wrap your server lifespan
@asynccontextmanager
async def lifespan(app):
executor = ThreadPoolExecutor(max_workers=16)
with Agent.using_thread_executor(executor):
yield
executor.shutdown(wait=True)
도구 실행 제한 — UsageLimits(tool_calls_limit=...)로 런 내 도구 실행을 상한할 수 있어요. 카운터는 성공적인 도구 호출 이후에만 증가해요. 구조화된 출력에 쓰이는 출력 도구는 tool_calls 메트릭에 세지 않아요.
출력 도구 호출
모델이 다른 도구와 병렬로 최종 결과 — 출력 도구 호출, 구조화된 네이티브/프롬프트 또는 이미지 출력 — 를 만들어내면, 에이전트의 end_strategy 파라미터가 이 도구 호출들을 어떻게 실행할지 제어해요. 기본 'graceful' 전략은 최종 결과를 찾은 후에도 모든 함수 도구가 실행되게 하고 남은 출력 도구는 건너뛰어요. 'exhaustive' 전략은 더 나아가 모든 출력 도구도 실행해요. 둘 다 도구에 항상 실행해야 할 부수 효과(로깅, 알림, 메트릭 갱신)가 있을 때 유용해요.
end_strategy가 함수 도구·출력 도구·비도구 출력과 어떻게 동작하는지에 대한 자세한 내용은 최종 결과와 나란한 도구 호출을 보세요.
도구 검색 (Tool Search)
도구가 많은 에이전트(예: 수십 개 엔드포인트를 노출하는 MCP 서버)는 어떤 작업 전에 도구 정의에 많은 입력 토큰을 쓸 수 있고, 사용 가능한 도구가 ~30-50개를 넘어가면 선택 정확도가 현저히 떨어져요. 지연 로딩(deferred loading)으로 도구를 표시하면 그것들이 모델 초기 컨텍스트에서 숨겨져요. 모델은 필요할 때 키워드로 숨겨진 도구를 발견해요.
지시·도구·모델 설정·훅이 함께 여행하는 워크플로 번들 에 대해서는 온디맨드 capabilities를 보세요. 같은 메커니즘 위에 짓되, 개별 도구 수준이 아니라 번들 수준에서 공개해요.
닿을 때쯤이면:
- 에이전트가 ~10+ 도구 또는 ~10k 토큰 이상의 도구 정의를 노출하거나
- 도구가 서로 다른 도메인을 다루고(예: 여러 MCP 서버) 요청마다 부분집합만 관련되거나
- toolset이 커지고 있고 여유(headroom)가 필요하거나
일 때 써요. 소형·핫 toolset이라 매 턴마다 모든 도구를 쓰는 경우엔 건너뛰세요 — 전부 지연하면 발견 왕복만 추가되고 이득이 없어요. 경험칙으로 가장 자주 쓰는 몇 개 도구는 열심히 로드하고, 긴 꼬리는 지연시키세요.
옵트인하려면 개별 Tool / @agent.tool / @agent.tool_plain 등록에 defer_loading=True를 설정하거나, 전체 toolset(MCPToolset 포함)에 .defer_loading()을 쓰세요 — 특정 도구를 숨기려면 이름 목록을, 전부 숨기려면 None을 넘기세요.
지연 도구가 생기면 검색은 자동 주입된 ToolSearch capability가 처리해요:
- 네이티브 공급자 검색 — 지원 모델에서(Anthropic Sonnet 4.5+, Opus 4.5+, Haiku 4.5+는 BM25/regex로, OpenAI Responses는 GPT-5.4+에서). 지연 도구는 와이어 위
defer_loading으로 공급자에 보내지고 공급자가 가시성을 관리해요. - 커스텀 호출 —
ToolSearch(strategy=...)로 사용자 제공 검색 함수. 우리 쪽에서 실행되지만, 지원되는 곳에서는 공급자의 클라이언트 실행 네이티브 표면(Anthropictool_reference블록, OpenAIexecution='client')을 통해 라우팅되어 모델이 일반 함수 도구가 아니라 도구 검색 호출을 보게 해요. - 로컬 폴백 — 나머지 모든 모델에서:
search_tools함수 도구가 키워드를 도구 이름·설명에 매칭.
Pydantic AI는 가능할 때마다 네이티브 검색을 선호해요. 발견 교환이 append-only(tool_search_call + tool_search_output 쌍)로 일어나고 각 도구가 작성한 defer_loading 값은 안정적으로 유지되어, 라운드에 걸쳐 프롬프트 캐싱을 보존하기 때문이에요. 로컬 폴백에서는 드러난 도구가 안정 정의와 별도로 추적되고 발견된 후에만 보내져요.
발견 가능한 모든 지연 도구는 모델이 이미 발견한 도구를 포함해 전체 런 동안 검색 코퍼스에 남아요. 검색을 반복하는 것은 안전하고, 모델이 컴팩션 후처럼 이전 발견이 더 이상 보이지 않을 때 도구를 복구하게 해줘요. 기본 키워드 전략에서 미발견 매치는 항상 이미 사용 가능한 것보다 위에 순위되므로, max_results가 목록을 다듬을 때 이미 사용 가능한 도구가 미발견 매치를 밀어내지 않아요 — 남은 슬롯만 채워요.
지연 정의를 집계하거나 감싸는 toolset은 get_tools 안에서 ctx.is_tool_available(tool_def)로 가시성을 확인할 수 있어요. 정의는 지연되지 않았거나 이름이 내역에 드러났을 때 사용 가능해요. toolset이 들고 있는 정의를 넘기세요. 이름 형태는 현재 해결된 ctx.tools 스냅샷에 같은 테스트를 적용하며 모델 요청 훅과 도구 실행용이에요.
번들로 게이트된 도구는 온디맨드 capabilities가 다뤄요. 그것들은 검색 코퍼스의 일부가 아니에요.
기록된 각 드러남은 에이전트 이벤트 스트림의 ToolAvailabilityDeltaEvent와 해당하는 영속 Vercel AI 데이터 청크/AG-UI 액티비티 스냅샷으로도 표면화돼요.
공급자 어댑터는 각 가용성 델타를 지원하는 히스토리 기반 드러냄 메커니즘으로 투영해요. Anthropic은 각 드러난 정의를 defer_loading=True로 tools에 선언해요 — capability 전용 런에서는 맨 앞에(어떤 검색 표면도 그것들을 노출할 수 없으므로), 혼합 런에서는 드러날 때 추가하며 그때까지 capability 소유 정의를 보류해요 — 그리고 같은 요청의 네이티브 tool_addition 블록에서 참조해요. OpenAI Responses는 드러난 정의를 추가된 additional_tools 입력 항목으로 나르죠: 결코 선언되지 않은 도구는 항목에만 여행하고, 이미 tools 항목으로 선언된 지연 도구는 그 항목을 유지하고 그 항목이 그것을 드러내요. 다른 모델은 스키마가 이미 보일 때 새로 사용 가능한 도구를 알리거나, 결과가 보류된 스키마를 드러내야 할 때 합성 도구 검색 교환을 받아요.
모델이 도구를 잘 찾게 하려면 일관된 접두사를 가진 설명적인 이름(github_*, slack_*, mortgage_*)을 주고 사용자가 검색할 만한 키워드를 도구 설명에 넣으세요. 검색은 한 번에 몇 개의 매치만 반환하므로, 모델은 반복할 수 있어요(검색 → 발견 → 호출 → 다시 검색) — 지시사항이 그것을 유도할 수 있어요: "필요한 도구가 안 보이면 주제로 검색해."
from pydantic_ai import Agent
agent = Agent('anthropic:claude-sonnet-4-6')
@agent.tool_plain(defer_loading=True)
def mortgage_calculator(principal: float, rate: float, years: int) -> str:
"""Calculate monthly mortgage payment for a home loan."""
monthly_rate = rate / 100 / 12
n_payments = years * 12
payment = principal * (monthly_rate * (1 + monthly_rate) ** n_payments) / ((1 + monthly_rate) ** n_payments - 1)
return f'${payment:.2f}/month'
MCP 서버는 .defer_loading()로 모든 도구를 검색 뒤에 숨겨요:
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset
mcp = MCPToolset('http://localhost:8000/mcp')
agent = Agent('anthropic:claude-sonnet-4-6', toolsets=[mcp.defer_loading()])
ToolSearch 구성하기
전략을 제어하거나 커스텀 검색 함수를 제공하려면 명시적 ToolSearch capability를 넘기세요:
from collections.abc import Sequence
from pydantic_ai import Agent, RunContext
from pydantic_ai.capabilities import ToolSearch
from pydantic_ai.tools import ToolDefinition
def fuzzy_search(
ctx: RunContext, queries: Sequence[str], tools: Sequence[ToolDefinition]
) -> list[str]:
"""Match tools whose name or description contains any query word."""
needles = [n for q in queries for n in q.lower().split()]
return [
t.name
for t in tools
if any(n in t.name.lower() or n in (t.description or '').lower() for n in needles)
]
agent = Agent('anthropic:claude-sonnet-4-6', capabilities=[ToolSearch(strategy=fuzzy_search)])
@agent.tool_plain(defer_loading=True)
def mortgage_calculator(principal: float, rate: float, years: int) -> str:
"""Calculate monthly mortgage payment for a home loan."""
monthly_rate = rate / 100 / 12
n_payments = years * 12
payment = principal * (monthly_rate * (1 + monthly_rate) ** n_payments) / ((1 + monthly_rate) ** n_payments - 1)
return f'${payment:.2f}/month'
사용 가능한 strategy 값:
strategy |
알고리즘 | 동작 |
|---|---|---|
None (기본) |
공급자의 네이티브 알고리즘(가능할 때), 아니면 로컬 키워드 매칭 | Anthropic 네이티브 BM25(Sonnet 4.5+/Opus 4.5+/Haiku 4.5+), OpenAI 서버 실행 tool_search(GPT-5.4+), 그 외 로컬 키워드 매칭 |
'keywords' |
로컬 키워드 오버랩 | 키워드 알고리즘은 우리 쪽에서 실행되지만, 와이어 형태는 적응: 지원되는 곳에서 클라이언트 실행 네이티브(Anthropic, OpenAI)로 프롬프트 캐시를 따뜻하게, 그 외 일반 search_tools 함수 도구 |
'bm25' / 'regex' |
Anthropic 네이티브 | Anthropic이 서버 실행. 다른 공급자(OpenAI, Google 등)에서는 다른 알고리즘을 조용히 대체하지 않고 요청이 실패 |
호출 가능 (ctx, queries, tools) -> names |
사용자 정의 | 'keywords'와 같은 실행 모드 처리: 지원 공급자에서 클라이언트 실행 네이티브, 그 외 로컬 search_tools 함수 도구 |
실행 모드(서버 실행, 클라이언트 실행 네이티브, 로컬 폴백)는 선택한 알고리즘과 현재 공급자에서 자동 도출돼요 — 사용자가 직접 고르지 않아요. 네이티브 실행은 가능할 때마다 선호돼요. 발견 라운드에 걸쳐 모델이 보는 도구 목록을 안정적으로 유지해 Anthropic·OpenAI 프롬프트 캐싱을 보존하기 때문이에요.
도구 검색을 네이티브 지원하는 공급자에서 로컬 keywords 알고리즘을 강제하려면 ModelProfile.supported_native_tools를 오버라이드해 ToolSearchTool을 제외하세요. 그러면 capability가 로컬 search_tools 함수 도구로 폴백해요.
크로스 공급자 히스토리 재생 — 한 턴은 한 공급자에서, 다음은 다른 공급자에서 실행될 수 있어요(예: FallbackModel 또는 런 사이에 model= 전환). 발견된 도구 상태는 전환에 걸쳐 보존돼요:
- 로컬 형태
search_tools내역이 네이티브 지원 공급자(Anthropic, OpenAI)에 렌더링되면, 공급자의 네이티브 도구 검색 와이어로 승격되어 발견된 도구 스키마가 모델을 재검색하게 하지 않고defer_loading=True에서 잠금 해제돼요. - 네이티브 형태
tool_search내역을 다른 공급자에 렌더링하면 — 대상이 자체 네이티브 도구 검색을 갖든 아니든 — 먼저 공급자 중립search_tools교환으로 번역된 다음 대상의 지원 형태로 렌더링돼요. 공급자 자체 네이티브 내역은 정확한 재생 형태를 유지해요.
도구 가용성 히스토리 이식성
저장된 내역은 도구가 호출 가능해지는 것을 모델 주도 검색 또는 앱 주도 가용성 변경으로 설명할 수 있어요. Pydantic AI는 그 내역을 다른 모델에 재생할 때 그 구분을 보존해요:
| 저장된 표현 | Anthropic tool_addition_mode='by_reference' |
Anthropic tool_addition_mode=None |
OpenAI Responses 네이티브 검색 + tool_addition_mode='with_definitions' |
네이티브 검색 없는 first-party OpenAI Responses, tool_addition_mode='with_definitions' |
OpenAI 호환 Responses tool_addition_mode=None |
Gemini (tool_addition_mode=None) |
OpenAI Chat Completions (tool_addition_mode=None) |
|---|---|---|---|---|---|---|---|
로컬 search_tools 호출·결과 |
네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 로컬 검색 + additional_tools |
로컬 검색 | 로컬 검색 | 로컬 검색 |
| Anthropic 네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 로컬 검색 + additional_tools |
로컬 검색 | 로컬 검색 | 로컬 검색 |
| OpenAI 네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 로컬 검색 + additional_tools |
로컬 검색 | 로컬 검색 | 로컬 검색 |
ToolAvailabilityDeltaPart |
tool_addition |
네이티브 검색 | additional_tools |
additional_tools |
공지 또는 로컬 검색 | 공지 | 공지 |
search_tools 결과 + metadata['discovered_tools'] |
네이티브 검색 | 네이티브 검색 | 네이티브 검색 | 로컬 검색 | 로컬 검색 | 로컬 검색 | 로컬 검색 |
여기서 네이티브 검색은 짝 지어진 공급자 네이티브 검색 호출·결과와 네이티브 검색 도구를 의미해요. 검색 가능한 지연 도구는 지연 코퍼스에 남아요. 로컬 검색은 짝 지어진 search_tools 함수 호출·결과와 로컬 검색 도구를 의미하고, 드러난 도구는 eager 함수 도구로 존재해요 — 단, with_definitions 대상에서는 구조화 검색 결과가 추가로 additional_tools 항목에 실리고 드러난 정의가 tools 항목을 차지하는 대신 거기로 여행해요(구조화된 발견을 나를 게 없는 평문 레거시 결과, 마지막 행, 은 평문 로컬 검색으로 남아요). capability 전용 코퍼스에서는 공급자 네이티브 가용성 변경이 검색 교환이나 검색 도구를 모두 포함하지 않아요. 혼합 코퍼스에서는 검색 도구가 검색 가능하게 남는 도구를 위해 와이어에 남아요. Anthropic capability 도구는 그 tool_addition이 방출될 때 지연 정의로 추가돼요.
진짜 검색은 모델이 한 일의 증거예요: 쿼리를 골랐고 매치를 받았어요. 그 교환을 tool_addition이나 additional_tools로 다시 쓰면 모델 주도 발견을 앱 주도 제어로 잘못 바꿔요. 따라서 공급자 네이티브 가용성 변경은 ToolAvailabilityDeltaPart에만 쓰여요. 그 제어 원시 타입이 없는 대상에서는, 스키마가 이미 보일 때 대화 중간 시스템 지시가 The following tool(s) are now available: {names}를 알려요. 완전한 로컬 검색 교환은 그 결과가 실제로 보류된 스키마를 드러내야 할 때만 합성되어, 도구가 defer_loading 뒤에 잠긴 채 남지 않아요.
도구 발견과 메시지 기록 — 발견된 도구는 타입화된 메시지 파트 — 검색 호출/결과 쌍과 ToolAvailabilityDeltaPart — 로 추적돼요. 내역 처리기가 그 증거를 제거하면 다음 요청에서 도구가 숨겨진 채로 되돌아가요. 보존 계약은 메시지 기록 처리를 보세요.
자세한 내용은 ToolDefinition.defer_loading과 지연 로딩을 보세요.
도구 검색과 프롬프트 캐싱
프롬프트 캐싱은 안정적인 접두사에 키잉돼요: 공급자는 요청 시작부터 가장 길게 변하지 않은 토큰 연속을 캐시하는데, 대략 도구 정의 → 시스템/지시 → 메시지 기록 순서예요. 어느 계층이든 변경되면 그 계층과 그 뒤 전부의 캐시를 무효화해요 — 그래서 대부분의 공급자에서 도구 정의를 변경·추가·제거·재정렬하면 캐시가 무효화돼요. 도구 정의가 맨 앞에 있기 때문이에요.
도구 검색은 모든 모델에서 동작하지만, 모델이 네이티브 도구 검색을 지원하는 경우에만 캐시를 보존 해요 — Anthropic Sonnet 4.5+, Opus 4.5+, Haiku 4.5+, OpenAI Responses는 GPT-5.4+에서요. 거기서 발견은 append-only이고 지연 도구는 프롬프트 접두사에 들어가지 않으므로, 동일한 접두사가 발견 라운드에 걸쳐 캐시에서 다시 읽혀요. 다른 모든 모델 — Google, 구형 Anthropic·OpenAI 모델 포함 — 에서는 로컬 search_tools 폴백이 발견된 도구를 tools 배열에 추가해 드러내는데, 이는 각 발견 턴에서 도구 정의부터 거슬러 캐시된 접두사를 무효화해요(Google에서는 안정 system_instruction이 도구 블록보다 앞에 있어 재사용될 수 있어요 — 아래 관련 캐싱 제어 참조).
왜 Gemini 도구 검색은 접두사를 절대 따뜻하게 유지하지 못하나 — 네이티브 도구 검색은 발견을 공급자측 원시 타입에 넘겨 발견된 도구를 요청 접두사 밖에 유지함으로써 캐시를 보존해요. Gemini의 API는 그런 원시 타입을 노출하지 않아요 — Anthropic의 bm25/regex 도구 검색이나 OpenAI Responses의 tool_search와 달리 — 그래서 Gemini의 도구 검색은 항상 로컬 search_tools 함수 도구로 폴백하고, 그것은 각 매치를 tools 배열에 추가해 드러내요. Gemini는 요청 접두사를 캐시하고 도구 정의가 그 앞에 있으므로, 새 도구를 드러내는 발견 턴마다 도구 블록부터 거슬러 캐시를 무효화해요. 이것은 버전 게이트가 아니라 누락된 원시 타입 제한이에요: Anthropic·OpenAI에서는 최신 모델이 네이티브 도구 검색을 지원 하지만, 어떤 Gemini 모델도 지원하지 않아요.
지연은 컨텍스트를 아끼는 것 — 동적 등록이 아니에요: 어느 전략이든 모델이 닿을 수 있는 모든 도구는 에이전트나 toolset에 처음부터 선언되어야 해요. 지연은 쓰이지 않는 정의를 모델 컨텍스트에서(그리고 네이티브로 캐시된 접두사에서) 벗어나게 할 뿐이에요. 에이전트가 설정된 적 없는 완전히 새로운 도구를 등록하게 해주진 않아요. 공급자가 대화 중간에 도구 선언을 추가할 수 있는 곳 — OpenAI Responses의 additional_tools 항목 — 에서는 요청 tools 블록에 결코 없던 도구도 접두사를 건드리지 않고 호출 가능해질 수 있어요. 선언이 추가된 입력 항목으로 여행하기 때문이에요.
온디맨드 capabilities의 경우, 새 도구 정의를 드러내지 않는 capability(지시나 모델 설정만)를 로드하면 네이티브 도구 검색이 없어도 모든 공급자에서 캐시를 보존해요. Anthropic은 지연 항목을 캐시 키에서 제외해요: capability 전용 런은 턴 1부터 그것들을 사전 공지해 일회성 지연 전조를 초기 접두사의 일부로 만들고, 혼합 런은 그 전조를 검색 가능한 코퍼스를 통해 지불하고 캐시된 접두사 밖에 드러난 지연 항목을 추가해요. 어떤 Anthropic capability 드러냄도 런 중간에 지연 전조를 도입하지 않아요. First-party OpenAI Responses는 tools[]를 바꾸지 않고 additional_tools 입력 항목으로 드러냄을 추가해요. 그 외에는 드러난 도구가 네이티브 도구처럼, 그리고 로드 시 도구 정의를 다시 쓰는 지연 prepare_tools/prepare_output_tools 훅처럼, 도구 정의 접두사에 들어가요. 전체 분해는 캐시 함의를 보세요.
진정으로 개방형 도구 우주를 위해서는 모든 것을 하나의 안정 도구로 라우팅하세요. harness CodeMode capability는 여러 도구를 정의가 바이트 안정으로 유지되는 하나의 run_code 도구로 접어요. 새로 발견된 도구는 새 도구 스키마가 아니라 샌드박스 안 호출 가능한 것으로 표면화되어, 발견에 걸쳐 도구 정의 접두사 — 와 그 캐시 — 를 온전히 유지해요.
트레이스에서 보기
네이티브 도구 검색으로는 지연 카탈로그가 캐시된 접두사에 결코 들어가지 않으므로, 모델이 새 도구를 발견해도 동일한 요청 접두사가 다음 턴에 캐시에서 다시 읽혀요 — cache_read_tokens는 따뜻하게 유지돼요:
Anthropic: 지연 도구와 함께 캐시된 도구 + 시스템 접두사가 캐시에서 재읽힘 — Logfire에서 보기
단일 도구 정의를 바꾸면 전체 접두사가 대신 재생성돼요 — 한 도구의 설명을 편집한 같은 요청은 캐시 읽기를 기록하지 않아요.
관련 캐싱 제어
tool_choice로 활성 도구를 제한해도 Pydantic AI가 배열을 클라이언트측으로 다듬어야 할 때 캐시를 무효화할 수 있어요 — 공급자별 분해와 캐시 보존 대안(allowed_tools,allowed_function_names,ToolOrOutput)은 프롬프트 캐싱 함의를 보세요.- 메시지에 명시적 캐시 브레이크포인트를 두려면
CachePoint를 쓰세요(Anthropic, Bedrock, OpenRouter가 존중). Anthropic의 도구·시스템·지시 캐싱 설정은 Anthropic 프롬프트 캐싱 아래 문서화돼 있어요. - 접두사 앞에서 도구 정의를 캐시하는 공급자 — Anthropic, OpenAI, xAI — 에서 단일 도구 설명을 편집하면 캐시된 접두사가 무효화돼요. Google의 암시적 캐시는 다른 레이아웃(그
system_instruction이 도구 블록보다 앞에 있는 별도 필드)에 접두사 기반이라, 큰 안정 시스템 지시가 도구 목록이 바뀌어도 캐시 히트를 유지할 수 있어요. 명시적CachedContent는 그 대신 도구를 구성상 캐시의 불변 부분으로 고정해요.