Your First Agent in 50 Lines
We build a minimal working agent in Python: a while loop, two tools, a call dispatcher, and honest stop conditions.
Lesson content is currently available in Russian.
What you will learn
- →Собирать полный агентный цикл на чистом Python без фреймворков.
- →Подключать инструменты через словарь-диспетчер и JSON-схемы.
- →Реализовывать условия остановки: финальный ответ и потолок шагов.
Скелет: описания инструментов и сами функции
Агент состоит из трёх частей: описания инструментов для модели, реальных функций и цикла. Начнём с первых двух. Описание — это JSON-схема, которую провайдер передаст модели; функция — обычный Python. Держите их рядом, чтобы схема не расходилась с кодом: рассинхрон между схемой и сигнатурой — самый частый баг первых агентов.
import json
from openai import OpenAI
client = OpenAI()
def get_weather(city: str) -> str:
fake = {"Berlin": "+19, ясно", "Kyiv": "+24, гроза"}
return fake.get(city, f"нет данных по городу {city}")
def calculator(expression: str) -> str:
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
return "ошибка: недопустимые символы"
return str(eval(expression)) # только для учебной песочницы!
TOOLS = [
{"type": "function", "function": {
"name": "get_weather",
"description": "Текущая погода в городе. Аргумент city — название на английском.",
"parameters": {"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]}}},
{"type": "function", "function": {
"name": "calculator",
"description": "Считает арифметическое выражение, например (2+3)*4.",
"parameters": {"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"]}}},
]
REGISTRY = {"get_weather": get_weather, "calculator": calculator}
Обратите внимание на две детали. Описания написаны как документация для стажёра: что делает функция и в каком формате аргумент — модель прочитает ровно это и ничего больше. REGISTRY — словарь «имя → функция» — это диспетчер: он избавляет от цепочки if/elif и делает добавление третьего инструмента однострочным. Заглушка погоды и «калькулятор» на eval здесь сознательно примитивны: сегодня цель — цикл, а не инструменты; настоящие контракты инструментов — тема следующего урока.
Почему схема выглядит именно так? Формат parameters — это JSON Schema: type задаёт тип значения, properties перечисляет поля, required — обязательные из них. Провайдер проверит аргументы по схеме до того, как они попадут к вам, но полагаться только на это нельзя: модель вполне может прислать город кириллицей, хотя описание просит английский, — для неё описание аргумента рекомендация, а не гарантия. Нормализация и валидация аргументов внутри самой функции — ваша обязанность, а не паранойя.
Цикл: решение, исполнение, наблюдение
Теперь сердце агента — цикл. Модель либо возвращает вызовы инструментов, либо текст; текст означает, что работа закончена. История начинается с system-сообщения — постоянных правил поведения — и задачи пользователя; дальше она пополняется только внутри цикла. Каждая итерация — один «шаг» агента: единственный вызов модели плюс исполнение всех инструментов, которые она запросила на этом шаге.
def run_agent(task: str, max_steps: int = 10) -> str:
messages = [
{"role": "system", "content": "Ты аккуратный ассистент. "
"Пользуйся инструментами, не выдумывай данные."},
{"role": "user", "content": task},
]
for step in range(max_steps):
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS)
msg = response.choices[0].message
if not msg.tool_calls: # текст = финальный ответ
return msg.content
messages.append(msg) # намерение — в историю
for call in msg.tool_calls:
fn = REGISTRY[call.function.name]
args = json.loads(call.function.arguments)
print(f"[{step}] {call.function.name}({args})")
result = fn(**args)
messages.append({"role": "tool",
"tool_call_id": call.id, "content": str(result)})
return "остановлен: превышен лимит шагов"
print(run_agent("Какая погода в Киеве и сколько будет 17*23?"))
Проследите поток данных: намерение модели (msg с tool_calls) обязательно попадает в историю до результатов, а каждый результат привязан к вызову через tool_call_id — провайдер отклонит историю, где ответ инструмента не соответствует вызову или вовсе отсутствует. Оставить вызов «висящим» без результата нельзя: следующий запрос к API с такой историей вернёт ошибку валидации. Модель вполне может запросить оба инструмента за один шаг — и погоду, и умножение, — внутренний for это обрабатывает штатно. Результат приводится к строке (str(result)): содержимое tool-сообщения — всегда текст, даже если функция вернула число или словарь; для сложных структур сериализуйте в JSON явно. Наконец, print с номером шага — ваш первый трейс: привыкайте видеть каждый шаг агента глазами, это сэкономит часы отладки, когда поведение станет неочевидным.
Условия остановки: почему max_steps обязателен
У нашего цикла два выхода: модель вернула текст (штатный) и исчерпан max_steps (аварийный). Второй кажется формальностью, но он обязателен с первого дня. Агент без потолка шагов — это бесконечный цикл, который жжёт деньги: модель может зациклиться, вызывая один и тот же инструмент с теми же аргументами, особенно когда инструмент возвращает ошибку, которую она не понимает. Десять шагов для учебного агента — разумный потолок; в production лимит подбирают под задачу и дополняют бюджетом токенов и денег — этим займётся урок про guardrails.
Важно различать причины остановки и сообщать их честно. «Модель дала финальный ответ» и «нас выкинуло по лимиту» — принципиально разные исходы: первый можно показывать пользователю, второй — сигнал о проблеме, который надо логировать и разбирать. Верните вместе с текстом причину остановки (кортеж или маленький dataclass) — вызывающий код скажет спасибо.
Третий, менее очевидный выход из цикла — ошибка самого инструмента. Если функция бросит исключение, наш код упадёт целиком, и агент умрёт вместе с накопленной историей. Пока мы это допускаем сознательно; в следующем уроке научимся превращать исключение в результат-ошибку, которую модель видит и обходит. Запомните текущее ограничение — это ваш первый пункт технического долга, и вы о нём знаете.
Поэкспериментируйте: дайте задачу, требующую обоих инструментов; дайте задачу без инструментов вообще («переведи фразу»); дайте невыполнимую («погода на Марсе») и посмотрите, признается ли модель или начнёт крутиться. Три этих прогона расскажут о поведении цикла больше, чем любой текст.
Practice
Агент с двумя инструментами и трейсом
Соберите агента из урока и доведите его: добавьте третий инструмент now(), возвращающий текущую дату и время; замените print на запись шагов в список trace (шаг, инструмент, аргументы, результат); верните из run_agent причину остановки (final / max_steps) вместе с ответом. Прогоните три сценария: задача на два инструмента, задача без инструментов, невыполнимая задача.
Deliverable
Рабочий скрипт до ~60 строк плюс вывод трёх прогонов с трейсом шагов и причиной остановки для каждого.
Checkpoint
Объясните путь одного шага агента: что возвращает модель, что делает ваш код и что попадает в историю сообщений.
- ✓ Описана развилка: tool_calls — исполняем, текст — финальный ответ.
- ✓ Указано, что и намерение модели, и результат инструмента добавляются в историю, результат — с tool_call_id.
- ✓ Объяснено, зачем нужен max_steps и чем аварийная остановка отличается от штатной.
Check yourself
Sources