Зачем Pydantic AI-инженеру?
Посмотри на типичный код без Pydantic — агент получает JSON-ответ от LLM и пытается с ним работать:
import json
# LLM вернул строку — парсим вручную
raw = '{"action": "search", "query": "asyncio tutorial", "max_results": "5"}'
data = json.loads(raw)
# Каждый доступ — потенциальный KeyError или TypeError
action = data["action"] # А вдруг ключа нет?
query = data["query"] # А вдруг None?
max_results = data["max_results"] # Это строка "5", а не число!
# Забудешь привести тип — баг в рантайме
results = search(query, limit=max_results) # TypeError: limit должен быть int!
Теперь то же самое с Pydantic:
from pydantic import BaseModel
class SearchAction(BaseModel):
action: str
query: str
max_results: int = 5 # Значение по умолчанию
raw = '{"action": "search", "query": "asyncio tutorial", "max_results": "5"}'
# Pydantic автоматически: парсит JSON, валидирует поля, приводит "5" → 5
data = SearchAction.model_validate_json(raw)
print(data.action) # "search"
print(data.max_results) # 5 (int, не строка!)
print(type(data.max_results)) # <class 'int'>
# Передаём в функцию — всё типизировано
results = search(data.query, limit=data.max_results) # Работает!
Это не просто удобство — это надёжность. В production-агентах Pydantic используется повсеместно: для валидации ответов LLM, описания инструментов, конфигурации и состояния агента.
Валидация
Автоматическая проверка типов и ограничений при создании объекта
Конвертация
Автоматическое приведение типов: "5" → 5, "true" → True
JSON Schema
Генерация схемы для LLM tool calling — одной строкой
Settings
Конфигурация агента из .env файла с валидацией
Установка
# Основная библиотека
pip install pydantic
# Для работы с .env файлами (будем использовать в конце гайда)
pip install pydantic-settings python-dotenv
# Проверка установленной версии (нам нужна v2)
python -c "import pydantic; print(pydantic.__version__)" # 2.x.x
В 2023 году вышел Pydantic v2 — переписан на Rust, работает ~5–50x быстрее. API изменился: dict() → model_dump(), parse_raw() → model_validate_json(). Все примеры в этом гайде используют v2. Если видишь старый код — вероятно это v1.
BaseModel — основа всего
Любая Pydantic-модель наследует BaseModel. Поля объявляются как атрибуты класса с аннотациями типов:
from pydantic import BaseModel
from datetime import datetime
class AgentMessage(BaseModel):
role: str # Обязательное поле
content: str # Обязательное поле
timestamp: datetime # Pydantic умеет парсить строки в datetime
tokens_used: int = 0 # Необязательное, значение по умолчанию
# Создание экземпляра — все поля валидируются сразу
msg = AgentMessage(
role="assistant",
content="Вот результаты поиска...",
timestamp="2025-03-22T10:00:00", # Строка автоматически → datetime
)
print(msg.role) # "assistant"
print(msg.tokens_used) # 0 (значение по умолчанию)
print(type(msg.timestamp)) # <class 'datetime.datetime'>
# Сериализация в dict
print(msg.model_dump())
# {'role': 'assistant', 'content': 'Вот результаты...', 'timestamp': datetime(...), 'tokens_used': 0}
# Сериализация в JSON-строку
print(msg.model_dump_json())
# {"role":"assistant","content":"Вот результаты...","timestamp":"2025-03-22T10:00:00","tokens_used":0}
Optional поля и значения по умолчанию
from pydantic import BaseModel
from typing import Optional
class ToolCall(BaseModel):
tool_name: str
arguments: dict
result: Optional[str] = None # Может быть None
error: str | None = None # Синтаксис Python 3.10+
retries: int = 0
metadata: dict = {} # Мutable default — Pydantic обрабатывает правильно
# Создание без опциональных полей
call = ToolCall(tool_name="web_search", arguments={"query": "pydantic tutorial"})
print(call.result) # None
print(call.retries) # 0
# Обновление — модели иммутабельны по умолчанию, используй model_copy
updated = call.model_copy(update={"result": "Нашли 10 результатов", "retries": 1})
print(updated.result) # "Нашли 10 результатов"
print(call.result) # None — оригинал не изменился
Field() — метаданные и ограничения
Field() добавляет ограничения, описания и псевдонимы полей. Описания особенно важны для LLM tool calling — они попадают в JSON Schema:
from pydantic import BaseModel, Field
from typing import Literal
class WebSearchTool(BaseModel):
"""Инструмент для поиска информации в интернете."""
query: str = Field(
description="Поисковый запрос на русском или английском языке",
min_length=2,
max_length=500,
)
max_results: int = Field(
default=5,
description="Количество результатов поиска (от 1 до 20)",
ge=1, # greater or equal — не меньше 1
le=20, # less or equal — не больше 20
)
search_type: Literal["web", "news", "academic"] = Field(
default="web",
description="Тип поиска: web — общий, news — новости, academic — научные статьи",
)
language: str = Field(
default="ru",
pattern=r"^[a-z]{2}$", # Строго 2 буквы: "ru", "en", "de"
description="Язык результатов (ISO 639-1 код)",
)
# Pydantic проверит все ограничения при создании
tool = WebSearchTool(query="asyncio python tutorial", max_results=10)
print(tool.model_dump())
# Попытка нарушить ограничение → ValidationError
from pydantic import ValidationError
try:
bad = WebSearchTool(query="ok", max_results=50) # max_results > 20!
except ValidationError as e:
print(e)
# 1 validation error for WebSearchTool
# max_results
# Input should be less than or equal to 20 [type=less_than_equal, ...]
Когда ты передаёшь схему инструмента в LLM через tool calling, модель видит именно эти описания. Чем точнее описание — тем правильнее LLM заполняет параметры. description в Field() — это инструкции для модели.
Вложенные модели
Поля Pydantic-модели могут быть другими Pydantic-моделями — Pydantic автоматически валидирует вложенные данные:
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class Message(BaseModel):
role: str
content: str
class ToolResult(BaseModel):
tool_name: str
output: str
success: bool
duration_ms: float
class AgentStep(BaseModel):
step_number: int
thought: str
action: Optional[str] = None
tool_result: Optional[ToolResult] = None # Вложенная модель
timestamp: datetime = Field(default_factory=datetime.now)
class AgentState(BaseModel):
session_id: str
goal: str
messages: list[Message] = [] # Список моделей
steps: list[AgentStep] = []
is_complete: bool = False
final_answer: Optional[str] = None
# Создание из вложенных dict — Pydantic разберёт автоматически
state = AgentState(
session_id="abc-123",
goal="Найти информацию об asyncio",
messages=[
{"role": "user", "content": "Расскажи об asyncio"}, # dict → Message
{"role": "assistant", "content": "Asyncio — это..."},
],
steps=[
{
"step_number": 1,
"thought": "Нужно поискать информацию",
"action": "web_search",
"tool_result": { # dict → ToolResult
"tool_name": "web_search",
"output": "Нашли 5 статей",
"success": True,
"duration_ms": 423.5,
}
}
]
)
print(type(state.messages[0])) # <class 'Message'>
print(type(state.steps[0].tool_result)) # <class 'ToolResult'>
print(state.steps[0].tool_result.success) # True
# Сериализация всего состояния в JSON
import json
json_str = state.model_dump_json(indent=2)
print(json_str[:200])
Валидация и обработка ошибок
При любом нарушении Pydantic бросает ValidationError с подробным описанием всех найденных ошибок — не только первой:
from pydantic import BaseModel, Field, ValidationError
class LLMConfig(BaseModel):
model: str
temperature: float = Field(ge=0.0, le=2.0)
max_tokens: int = Field(gt=0, le=128000)
api_key: str = Field(min_length=10)
try:
config = LLMConfig(
model="gpt-4o",
temperature=5.0, # Ошибка: > 2.0
max_tokens=-100, # Ошибка: <= 0
api_key="short", # Ошибка: < 10 символов
)
except ValidationError as e:
print(f"Найдено {e.error_count()} ошибок:")
print(e)
# 3 validation errors for LLMConfig
# temperature
# Input should be less than or equal to 2 [type=less_than_equal, ...]
# max_tokens
# Input should be greater than 0 [type=greater_than, ...]
# api_key
# String should have at least 10 characters [type=string_too_short, ...]
# Программный доступ к ошибкам
for error in e.errors():
field = " → ".join(str(loc) for loc in error["loc"])
print(f" {field}: {error['msg']}")
@field_validator — кастомная валидация
Когда встроенных ограничений недостаточно — пишем свой валидатор:
from pydantic import BaseModel, Field, field_validator
ALLOWED_MODELS = {"gpt-4o", "gpt-4o-mini", "claude-sonnet-4-5", "claude-haiku-4-5-20251001"}
class AgentConfig(BaseModel):
model: str
system_prompt: str
max_iterations: int = Field(default=10, ge=1, le=100)
@field_validator("model")
@classmethod
def validate_model(cls, v: str) -> str:
if v not in ALLOWED_MODELS:
allowed = ", ".join(sorted(ALLOWED_MODELS))
raise ValueError(f"Неизвестная модель '{v}'. Доступны: {allowed}")
return v
@field_validator("system_prompt")
@classmethod
def clean_system_prompt(cls, v: str) -> str:
"""Убираем лишние пробелы и проверяем длину."""
v = v.strip()
if len(v) < 10:
raise ValueError("System prompt слишком короткий (минимум 10 символов)")
if len(v) > 10000:
raise ValueError("System prompt слишком длинный (максимум 10000 символов)")
return v
# Валидная конфигурация
config = AgentConfig(
model="gpt-4o",
system_prompt=" Ты — полезный AI-ассистент. ", # Пробелы уберутся
)
print(config.system_prompt) # "Ты — полезный AI-ассистент." (без пробелов)
# Невалидная модель → ValidationError
try:
bad = AgentConfig(model="gpt-5", system_prompt="Привет")
except Exception as e:
print(e) # Value error, Неизвестная модель 'gpt-5'...
@model_validator — валидация всей модели
Для проверки зависимостей между полями используется @model_validator:
from pydantic import BaseModel, model_validator
from typing import Optional
class RAGConfig(BaseModel):
use_rag: bool = False
vector_db_url: Optional[str] = None
embedding_model: Optional[str] = None
top_k: int = 5
@model_validator(mode="after")
def check_rag_dependencies(self) -> "RAGConfig":
"""Если RAG включён — нужны обязательные поля."""
if self.use_rag:
if not self.vector_db_url:
raise ValueError("При use_rag=True нужно указать vector_db_url")
if not self.embedding_model:
raise ValueError("При use_rag=True нужно указать embedding_model")
return self
# Работает
config = RAGConfig(use_rag=False)
# Ошибка — RAG включён, но нет URL
try:
bad = RAGConfig(use_rag=True, embedding_model="text-embedding-3-small")
except Exception as e:
print(e) # Value error, При use_rag=True нужно указать vector_db_url
Structured Output от LLM
Это главный паттерн для агентов. LLM должен вернуть структурированные данные — используем Pydantic для валидации ответа:
OpenAI Structured Outputs
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
import asyncio
client = AsyncOpenAI()
# Определяем структуру, которую должен вернуть LLM
class TaskPlan(BaseModel):
"""План выполнения задачи агентом."""
goal_understood: str = Field(description="Как агент понял задачу")
steps: list[str] = Field(description="Список шагов для выполнения")
required_tools: list[str] = Field(description="Инструменты, которые понадобятся")
estimated_complexity: str = Field(
description="Оценка сложности: low / medium / high"
)
clarification_needed: bool = Field(
description="Нужны ли уточнения от пользователя"
)
async def plan_task(user_request: str) -> TaskPlan:
response = await client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06", # Только эти модели поддерживают structured outputs
messages=[
{"role": "system", "content": "Ты — AI-агент. Составь план выполнения задачи."},
{"role": "user", "content": user_request},
],
response_format=TaskPlan, # Передаём Pydantic модель напрямую!
)
# OpenAI автоматически вернёт распарсенный объект
return response.choices[0].message.parsed
async def main():
plan = await plan_task("Напиши отчёт о тенденциях в AI за 2025 год")
print(f"Цель: {plan.goal_understood}")
print(f"Шаги: {plan.steps}")
print(f"Инструменты: {plan.required_tools}")
print(f"Сложность: {plan.estimated_complexity}")
# Полная типизация — никакого dict["key"]!
asyncio.run(main())
Anthropic / Claude — Pydantic через инструменты
import anthropic
from pydantic import BaseModel, Field
import json
client = anthropic.Anthropic()
class SentimentAnalysis(BaseModel):
"""Результат анализа тональности текста."""
sentiment: str = Field(description="positive / negative / neutral")
confidence: float = Field(description="Уверенность от 0.0 до 1.0", ge=0, le=1)
key_phrases: list[str] = Field(description="Ключевые фразы, определившие тональность")
summary: str = Field(description="Краткое объяснение оценки")
def analyze_sentiment(text: str) -> SentimentAnalysis:
# Генерируем JSON Schema из Pydantic модели
schema = SentimentAnalysis.model_json_schema()
message = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=500,
tools=[{
"name": "save_analysis",
"description": "Сохрани результат анализа тональности",
"input_schema": schema, # Pydantic → JSON Schema
}],
tool_choice={"type": "tool", "name": "save_analysis"}, # Принудительный вызов
messages=[{
"role": "user",
"content": f"Проанализируй тональность текста:\n\n{text}"
}],
)
# Извлекаем аргументы вызова инструмента
tool_use = next(b for b in message.content if b.type == "tool_use")
# Валидируем через Pydantic
return SentimentAnalysis.model_validate(tool_use.input)
# Использование
result = analyze_sentiment("Это потрясающий продукт, очень доволен покупкой!")
print(f"Тональность: {result.sentiment}") # positive
print(f"Уверенность: {result.confidence}") # 0.95
print(f"Фразы: {result.key_phrases}") # ["потрясающий продукт", "очень доволен"]
Ручной парсинг JSON от LLM
Иногда LLM возвращает обычный JSON в тексте. Pydantic помогает распарсить его безопасно:
from pydantic import BaseModel, ValidationError
import re
class AgentDecision(BaseModel):
action: str
tool: str | None = None
arguments: dict = {}
reasoning: str
def parse_llm_response(raw_text: str) -> AgentDecision | None:
"""Извлекаем JSON из ответа LLM и валидируем через Pydantic."""
# LLM часто оборачивает JSON в ```json ... ``` блоки
json_match = re.search(r"```json\s*(.*?)\s*```", raw_text, re.DOTALL)
if json_match:
json_str = json_match.group(1)
else:
# Пробуем весь текст как JSON
json_str = raw_text.strip()
try:
return AgentDecision.model_validate_json(json_str)
except ValidationError as e:
print(f"LLM вернул невалидный JSON: {e}")
return None
# Пример
llm_output = '''
Я решил выполнить поиск.
```json
{
"action": "use_tool",
"tool": "web_search",
"arguments": {"query": "pydantic v2 tutorial"},
"reasoning": "Нужна актуальная информация о Pydantic v2"
}
```
'''
decision = parse_llm_response(llm_output)
if decision:
print(f"Действие: {decision.action}")
print(f"Инструмент: {decision.tool}")
print(f"Аргументы: {decision.arguments}")
JSON Schema для Tool Calling
Pydantic умеет генерировать JSON Schema из модели — именно этот формат используется для описания инструментов агента:
from pydantic import BaseModel, Field
import json
class CodeExecutionTool(BaseModel):
"""Выполняет Python-код в безопасной sandbox-среде."""
code: str = Field(
description="Python-код для выполнения. Должен быть корректным синтаксически."
)
timeout_seconds: int = Field(
default=30,
description="Максимальное время выполнения в секундах",
ge=1,
le=120,
)
capture_output: bool = Field(
default=True,
description="Захватывать ли stdout/stderr"
)
# Генерируем схему
schema = CodeExecutionTool.model_json_schema()
print(json.dumps(schema, indent=2, ensure_ascii=False))
# Вывод:
# {
# "description": "Выполняет Python-код в безопасной sandbox-среде.",
# "properties": {
# "code": {
# "description": "Python-код для выполнения...",
# "title": "Code",
# "type": "string"
# },
# "timeout_seconds": {
# "default": 30,
# "description": "Максимальное время выполнения...",
# "maximum": 120,
# "minimum": 1,
# "title": "Timeout Seconds",
# "type": "integer"
# },
# ...
# },
# ...
# }
# Использование в OpenAI tool calling
openai_tool = {
"type": "function",
"function": {
"name": "execute_code",
"description": CodeExecutionTool.__doc__,
"parameters": schema,
}
}
# Или сразу передаём в Anthropic
anthropic_tool = {
"name": "execute_code",
"description": CodeExecutionTool.__doc__,
"input_schema": schema,
}
pydantic-settings — конфигурация агента
Агент читает API-ключи и настройки из переменных окружения. pydantic-settings делает это безопасно и с валидацией:
# .env — не коммить в Git!
OPENAI_API_KEY=sk-proj-...
ANTHROPIC_API_KEY=sk-ant-...
TAVILY_API_KEY=tvly-...
AGENT_MODEL=gpt-4o
AGENT_MAX_ITERATIONS=20
AGENT_TEMPERATURE=0.7
AGENT_DEBUG=false
VECTOR_DB_URL=http://localhost:6333
VECTOR_DB_COLLECTION=agent_memory
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class AgentSettings(BaseSettings):
# Читает из переменных окружения или .env файла
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False, # OPENAI_API_KEY = openai_api_key
)
# API ключи — SecretStr чтобы не светить в логах
openai_api_key: SecretStr = Field(description="OpenAI API key")
anthropic_api_key: SecretStr | None = None
tavily_api_key: SecretStr | None = None
# Настройки агента
agent_model: str = "gpt-4o"
agent_max_iterations: int = Field(default=10, ge=1, le=100)
agent_temperature: float = Field(default=0.7, ge=0.0, le=2.0)
agent_debug: bool = False
# Vector DB
vector_db_url: str = "http://localhost:6333"
vector_db_collection: str = "agent_memory"
def get_openai_key(self) -> str:
"""Возвращает ключ как строку (SecretStr скрывает его в repr)."""
return self.openai_api_key.get_secret_value()
# Синглтон — создаём один раз, переиспользуем
@lru_cache(maxsize=1)
def get_settings() -> AgentSettings:
return AgentSettings()
# Использование в коде
settings = get_settings()
print(settings.agent_model) # "gpt-4o"
print(settings.openai_api_key) # ***** (скрыт в выводе!)
print(settings.get_openai_key()) # sk-proj-... (реальное значение)
print(settings.agent_debug) # False (str "false" → bool автоматически)
Используй SecretStr для всех секретных значений. При логировании, print() или сериализации в JSON они автоматически маскируются как *****. Это предотвращает случайную утечку ключей в логи.
Паттерны для AI-агентов
Собираем всё вместе — типичные Pydantic-паттерны в реальном агенте:
from pydantic import BaseModel, Field
from typing import Literal, Optional
from datetime import datetime
import asyncio
from openai import AsyncOpenAI
# ---- 1. Определяем инструменты через Pydantic ----
class WebSearchInput(BaseModel):
"""Поиск информации в интернете."""
query: str = Field(description="Поисковый запрос")
num_results: int = Field(default=5, ge=1, le=10)
class CalculatorInput(BaseModel):
"""Вычисляет математическое выражение."""
expression: str = Field(description="Математическое выражение, например '2 + 2'")
# ---- 2. Типизируем ответ агента ----
class AgentAction(BaseModel):
"""Решение агента: что делать дальше."""
thinking: str = Field(description="Внутренние рассуждения агента")
action_type: Literal["use_tool", "final_answer"] = Field(
description="Тип действия"
)
tool_name: Optional[str] = Field(
default=None,
description="Имя инструмента (если action_type == 'use_tool')"
)
tool_input: Optional[dict] = Field(
default=None,
description="Аргументы инструмента"
)
final_answer: Optional[str] = Field(
default=None,
description="Финальный ответ (если action_type == 'final_answer')"
)
# ---- 3. Типизируем состояние агента ----
class Step(BaseModel):
number: int
action: AgentAction
result: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.now)
class AgentRun(BaseModel):
session_id: str
user_query: str
steps: list[Step] = []
final_answer: Optional[str] = None
status: Literal["running", "complete", "failed"] = "running"
# ---- 4. Агент ----
client = AsyncOpenAI()
TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": WebSearchInput.__doc__,
"parameters": WebSearchInput.model_json_schema(), # Схема из Pydantic!
}
},
{
"type": "function",
"function": {
"name": "calculator",
"description": CalculatorInput.__doc__,
"parameters": CalculatorInput.model_json_schema(),
}
}
]
async def run_agent(query: str) -> AgentRun:
run = AgentRun(session_id="demo-001", user_query=query)
messages = [
{"role": "system", "content": "Ты — полезный агент с инструментами."},
{"role": "user", "content": query},
]
for step_num in range(1, 11): # Максимум 10 шагов
response = await client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=TOOLS,
)
choice = response.choices[0]
if choice.finish_reason == "stop":
# Агент дал финальный ответ
run.final_answer = choice.message.content
run.status = "complete"
break
if choice.finish_reason == "tool_calls":
# Агент вызвал инструмент
tool_call = choice.message.tool_calls[0]
tool_name = tool_call.function.name
# Валидируем аргументы через Pydantic
if tool_name == "web_search":
args = WebSearchInput.model_validate_json(tool_call.function.arguments)
result = f"Результаты поиска по '{args.query}': ..." # Реальный поиск
elif tool_name == "calculator":
args = CalculatorInput.model_validate_json(tool_call.function.arguments)
result = str(eval(args.expression)) # В prod — безопасный eval!
# Добавляем в историю
messages.append(choice.message)
messages.append({"role": "tool", "tool_call_id": tool_call.id, "content": result})
return run # Типизированный результат!
async def main():
run = await run_agent("Сколько будет 123 * 456?")
print(f"Статус: {run.status}")
print(f"Ответ: {run.final_answer}")
asyncio.run(main())
Шпаргалка
from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic_settings import BaseSettings
from typing import Optional, Literal
from datetime import datetime
# 1. Базовая модель
class MyModel(BaseModel):
name: str
count: int = 0
tags: list[str] = []
# 2. Field с ограничениями
class Strict(BaseModel):
value: float = Field(ge=0, le=1)
label: str = Field(min_length=1, max_length=100, description="Для LLM tool calling")
# 3. Создание экземпляра
obj = MyModel(name="test", count="5") # "5" → 5 автоматически
# 4. Сериализация
obj.model_dump() # → dict
obj.model_dump_json() # → JSON строка
obj.model_dump(exclude={"name"}) # Исключить поля
# 5. Десериализация
MyModel.model_validate({"name": "x", "count": 1}) # из dict
MyModel.model_validate_json('{"name": "x", "count": 1}') # из JSON строки
# 6. Схема для LLM
MyModel.model_json_schema() # → dict с JSON Schema
# 7. Копирование с изменениями
new_obj = obj.model_copy(update={"count": 42})
# 8. @field_validator
class WithValidation(BaseModel):
email: str
@field_validator("email")
@classmethod
def check_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Невалидный email")
return v.lower()
# 9. @model_validator
class CrossField(BaseModel):
start: int
end: int
@model_validator(mode="after")
def check_order(self) -> "CrossField":
if self.end <= self.start:
raise ValueError("end должен быть больше start")
return self
# 10. Settings из .env
class Settings(BaseSettings):
api_key: str
debug: bool = False
model_config = {"env_file": ".env"}
settings = Settings() # Читает OPENAI_API_KEY из окружения
Практическое задание
Задание: типизированный инструментарий агента
- Создай Pydantic-модель
EmailToolдля инструмента отправки писем: получатель, тема, тело письма, список CC (необязательный). Добавь валидацию email через@field_validator - Создай модель
AgentResponseс полями:action(Literal с 3+ вариантами),confidence(float 0–1),explanation,next_tool(Optional) - Напиши функцию
parse_response(json_str: str) -> AgentResponse | None, которая парсит JSON от LLM с обработкойValidationError - Создай
AppSettings(BaseSettings)с API-ключами и настройками. Убедись, что ключи хранятся какSecretStr - Вызови
AgentResponse.model_json_schema()и посмотри, какую схему получишь — именно её нужно передавать в LLM