Погодный ИИ-агент на DeepSeek: OpenAI Agents SDK + Pydantic
Простой, но настоящий агент: модель DeepSeek, живой инструмент с данными Gismeteo и строгий контракт ответа на Pydantic. Ниже — полный код, реальный прогон и два грабля, о которых молчат туториалы.
«Простой LLM-агент» — это не чат-болталка, а три вещи: модель, которой доверяешь рассуждение, инструменты, которые дают ей факты, и контракт ответа, который проверяется кодом, а не надеждой. В этой статье собираем минимального погодного консультанта на связке DeepSeek + OpenAI Agents SDK + Pydantic и прогоняем его вживую.
Стек и установка
OpenAI Agents SDK — официальный SDK оркестрации агентов: инструменты-функции, трассировка и типизированные контракты из коробки. DeepSeek подключается через него же, потому что API DeepSeek совместим с OpenAI. Версии на момент написания (сентябрь 2026): openai-agents 0.22.0, pydantic 2.13.5 — мы проверили код именно на них.
pip install openai-agents pydantic httpx
Ключ DeepSeek выпускается в кабинете platform.deepseek.com и живёт в переменной окружения. В официальной документации DeepSeek текущие модели — deepseek-v4-flash (быстрая и дешёвая) и deepseek-v4-pro (сильнее в рассуждениях); для агента с простыми инструментами flash более чем достаточен.
Подключаем DeepSeek как модель агента
SDK по умолчанию использует Responses API OpenAI. Для OpenAI-совместимых провайдеров в документации SDK предусмотрен отдельный класс — OpenAIChatCompletionsModel: ему передаётся обычный клиент OpenAI с нужным base_url.
import os
from openai import AsyncOpenAI
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
client = AsyncOpenAI(
api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com", # OpenAI-compatible endpoint
)
model = OpenAIChatCompletionsModel(model="deepseek-v4-flash", openai_client=client)
Инструмент: живая погода с Gismeteo
Модель не знает сегодняшнюю температуру — её знает инструмент. Декоратор function_tool превращает асинхронную функцию в инструмент: схема аргументов строится из аннотаций, а docstring становится описанием для модели. Страница города Gismeteo отдаёт текущую температуру в кастомном элементе temperature-value, поэтому парсинг — одна регулярка; первая пара значений — «сейчас» и «ощущается как».
import re
import httpx
from agents import function_tool
CITY_SLUGS = {
"москва": "weather-moscow-4368",
"санкт-петербург": "weather-sankt-peterburg-4079",
"казань": "weather-kazan-4364",
}
@function_tool
async def get_gismeteo_weather(city: str) -> str:
"""Current temperature and feels-like from the city page on Gismeteo."""
slug = CITY_SLUGS.get(city.strip().lower())
if slug is None:
return "City not found. Available: " + ", ".join(sorted(CITY_SLUGS))
url = f"https://www.gismeteo.ru/{slug}/"
async with httpx.AsyncClient(timeout=15, headers={"User-Agent": "Mozilla/5.0"}) as http:
page = (await http.get(url)).text
temps = re.findall(r'<temperature-value[^>]*value="(-?\d+)"[^>]*from-unit="c"', page)
if len(temps) < 2:
return "Could not parse weather from the Gismeteo page."
return f"{city}: now {temps[0]}C, feels like {temps[1]}C (Gismeteo, {url})"
Обратите внимание на возвращаемые строки «город не найден» и «не удалось разобрать»: инструмент обязан отвечать текстом, который модель поймёт, а не падать исключением — так агент сможет сам исправить ошибку, например попросить уточнить город.
Контракт ответа — Pydantic
Ответ агента — не свободный текст, а структура: её удобно показывать в интерфейсе, писать в базу и тестировать.
from pydantic import BaseModel, Field, ValidationError
class WeatherReport(BaseModel):
city: str = Field(description="City the report is about")
temperature_c: int = Field(description="Current temperature, C")
feels_like_c: int = Field(description="Feels-like temperature, C")
advice: str = Field(description="Practical advice: what to wear and take")
Тут главный сюрприз для тех, кто читал документацию SDK невнимательно. Параметр output_type с Pydantic-моделью на маршруте Chat Completions уходит провайдеру как response_format типа json_schema. Мы проверили вживую: DeepSeek на сентябрь 2026 отвечает ошибкой 400 «This response_format type is unavailable now» — поддерживается только json_object. Поэтому честный рабочий паттерн такой: просим модель вернуть JSON текстом и валидируем его Pydantic-моделью, с одним повтором на случай кривого JSON. Контракт живёт в вашем коде, а не в доброте провайдера — это даже надёжнее.
def parse_report(text: str) -> WeatherReport:
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
raise ValueError("No JSON in model output: " + text[:200])
return WeatherReport.model_validate_json(text[start:end + 1])
Сам агент и запуск
import asyncio
from agents import Agent, Runner
SCHEMA_HINT = ('Reply with a single JSON object and nothing else: '
'{"city": str, "temperature_c": int, '
'"feels_like_c": int, "advice": str}.')
agent = Agent(
name="weather-consultant",
model=model,
instructions=(
"You are a weather consultant. Detect the city from the question, "
"call get_gismeteo_weather and write a short practical report. "
"Use exactly the temperatures returned by the tool. " + SCHEMA_HINT
),
tools=[get_gismeteo_weather],
)
async def main() -> None:
result = await Runner.run(agent, "Is it warm in Moscow now? What should I wear?")
try:
report = parse_report(result.final_output)
except (ValidationError, ValueError) as err:
result = await Runner.run(agent, f"Your previous reply failed validation: {err}. "
f"Return only the JSON per schema.")
report = parse_report(result.final_output)
print(f"{report.city}: {report.temperature_c}C, feels like {report.feels_like_c}C")
print("Advice:", report.advice)
asyncio.run(main())
Реальный прогон этого кода (deepseek-v4-flash, 4 сентября 2026):
Москва: 17C, ощущается 16C Совет: На улице прохладно, тепло однозначно не будет. Наденьте лёгкую куртку, джемпер или свитер, а также непромокаемую обувь — вечером может стать свежо.
Агент сам вызвал инструмент по городу из вопроса, подставил фактические температуры и дал человеческий совет. Весь прогон — пара секунд и доли копейки по тарифам flash.
Что улучшить дальше
- deepseek-v4-pro для сложных сценариев: сравнить «сейчас», «вечером» и «на выходных» с несколькими вызовами инструмента.
- Кэш погоды на 10–15 минут: один и тот же город не дёргает Gismeteo на каждом запросе.
- Больше городов и поиск слагов через страницу поиска Gismeteo вместо жёсткого словаря.
- Телеграм-бот поверх: тот же агент, но с каналом доставки — архитектура не меняется.
Главный вывод: «простой агент» перестаёт быть игрушкой, когда у него есть факты (инструменты) и контракт (Pydantic). Всё остальное — полчаса кода.
