05 / 06 · Best Practices
🐍 Armin Ronacher
Создатель Flask и Jinja2. Простые, быстрые, наблюдаемые инструменты — и честность о том что не работает.
🐍
Armin Ronacher
Создатель Flask и Jinja2 · Director of Engineering, Sentry · Python-экосистема
"Простые, быстрые, наблюдаемые инструменты > сложные MCP"
CLI вместо MCP
Plain SQL
Observable tools
Email → stdout
Честность о провалах
🐍 Контекст: создатель Flask — ценитель простоты
Armin Ronacher создал Flask — микрофреймворк который стал стандартом для Python web разработки именно благодаря принципу простоты и явности. Тот же принцип он применяет к инструментам для CC.
Его статьи — одни из самых честных в индустрии: он описывает не только что работает, но и что не работает. «Things that didn't work» — редкий пример публичного признания ошибок от практикующего эксперта.
⚠️
Частая ошибка новичков: создают MCP-сервер для каждой внешней системы, не задумываясь о latency и сложности. В итоге CC вызывает медленный webhook-MCP который отвечает за 3–5 секунд — и вся сессия тормозит. Armin's правило: если инструмент не отвечает за 100мс — сначала ищите более простой путь (CLI, прямой SQL, stdout в dev-режиме). MCP оправдан только там где CLI невозможен.
🔧 Практика 1: Tool Design Rules — три закона инструментов
1
Инструменты должны быть быстрыми, defensive и observable
Armin сформулировал три закона для инструментов которые используются с CC. Нарушение любого из них ведёт к проблемам:
- < 100ms — инструмент должен отвечать быстро. Медленный инструмент «замораживает» Claude в ожидании, прерывает поток мышления, увеличивает стоимость операции.
- Defensive — инструмент должен быть написан защитно против «LLM chaos monkey»: Claude может вызвать инструмент с неожиданными параметрами, в неожиданном порядке, несколько раз подряд.
- Observable — вывод инструмента должен быть читаем и для Claude, и для человека. Если человек не понимает что вернул инструмент — Claude тоже вероятно не понимает.
✅ Хороший инструмент
Отвечает за <100ms
Обрабатывает некорректные параметры
Возвращает читаемый текст/JSON
Идемпотентен (повторный вызов безопасен)
Детали ошибок ясны для Claude
❌ Плохой инструмент
Требует 2–10 секунд
Падает при неожиданных параметрах
Возвращает бинарные данные / HTML
Имеет side effects при повторе
Ошибка не объясняет что пошло не так
🗂 Практика 2: Plain SQL > ORM для агентов
2
Агенты отлично пишут SQL и верифицируют через query logs
Armin категорически предпочитает plain SQL вместо ORM при работе с CC. Причина: ORM скрывает реальные запросы. Claude не может видеть что происходит в базе данных — он только знает что написал ORM-код который «должен работать».
С plain SQL: Claude пишет запрос → видит EXPLAIN ANALYZE → оптимизирует → видит query logs → проверяет корректность. Полная observability на каждом шаге.
«Agents write excellent SQL and can verify it against query logs» — SQL — один из языков где LLM действительно силён, не нужно скрывать его за абстракцией.
-- Агент пишет запрос явно
SELECT
s.id,
s.status,
s.expires_at,
u.email,
p.name AS plan_name,
p.price_cents
FROM subscriptions s
JOIN users u ON u.id = s.user_id
JOIN plans p ON p.id = s.plan_id
WHERE s.status = 'active'
AND s.expires_at < NOW() + INTERVAL '7 days'
ORDER BY s.expires_at ASC
LIMIT 100;
-- Агент проверяет план выполнения
EXPLAIN ANALYZE [запрос выше];
-- Видит индексы, стоимость, оптимизирует если нужно
📧 Практика 3: Email → stdout в debug-режиме
3
Агент сам читает «отправленное письмо» и достаёт ссылку
Armin описывает элегантный паттерн для тестирования auth-флоу с email: в dev-режиме вместо реальной отправки письма — вывод содержимого в stdout. CC читает вывод → достаёт ссылку → открывает её → завершает auth-флоу.
Это позволяет агенту полностью автономно тестировать registration, password reset, email verification — без внешних сервисов, без реального email, без ручного копирования ссылок.
Принцип: сделай dev-режим максимально observable для CC. Всё что обычно скрыто (email, очереди, async tasks) — вывести в stdout в режиме разработки.
import os
from flask_mail import Mail
class DevMailBackend:
"""В dev-режиме: печатаем письмо в stdout вместо отправки"""
def send(self, message):
if os.getenv('FLASK_ENV') == 'development':
# Claude читает этот вывод и достаёт ссылку
print(f"\n[EMAIL CAPTURED]")
print(f"To: {message.recipients}")
print(f"Subject: {message.subject}")
print(f"Body:\n{message.body}")
print(f"[/EMAIL]\n")
return
# Production: реальная отправка
return self._real_send(message)
🚫 Практика 4: Что НЕ работает (честный разбор)
4
Armin убрал большинство того что «должно было помогать»
Armin откровенно описывает что он убрал из своего workflow — после того как попробовал и убедился что это не помогает или вредит:
- Slash-команды — отказался от 5 штук. Overhead на поддержку slash-команд превышал пользу от них.
- Сложные hooks — оставил только deny-list (заблокированные действия). Hooks с бизнес-логикой — слишком ненадёжны.
- Sub-agents для реализации — оставил только для research/exploration, не для написания кода. «Sub-agents для реализации — источник нестабильности».
- Большинство MCP — оставил минимум. Для большинства задач CLI удобнее и надёжнее.
"I've removed everything that Claude can do on its own. The best tool is often no tool at all — just Claude and a shell."
🖥 Практика 5: CLI вместо MCP где возможно
5
Одна строка в CLAUDE.md описывает CLI-инструмент лучше чем MCP
Armin предпочитает CLI-инструменты над MCP где возможно. Причина: CLI проще в настройке, понятнее в отладке, быстрее в ответе, и Claude уже обучен на их использовании.
Паттерн: описать в CLAUDE.md какие CLI-инструменты доступны и как их использовать. Одна строка описания часто лучше чем весь MCP-сервер.
## Доступные CLI-инструменты
### База данных
- Запросы: `psql -U app -d mydb -c "SELECT ..."`
- Миграции: `alembic upgrade head` (или `alembic downgrade -1` для отката)
- Состояние: `alembic current`
### GitHub
- PR: `gh pr view 123`, `gh pr diff 123`
- Issues: `gh issue list --state open --label bug`
- CI статус: `gh run list --branch main`
### Контейнеры
- Логи: `docker logs -f container_name --tail 100`
- Выполнить: `docker exec -it container_name bash`
### Не использовать MCP для этого — CLI быстрее
📊 Что работает vs не работает: итоговая таблица
| Категория | Работает у Armin | Не работает |
| База данных |
Plain SQL + query logs |
ORM (скрывает запросы) |
| Инструменты |
CLI описанные в CLAUDE.md |
Большинство MCP-серверов |
| Email / async |
Email → stdout в dev |
Slash-команды (overhead) |
| Автоматизация |
Simple hooks (deny only) |
Сложные hooks с логикой |
| Агенты |
Sequential agent loop |
Sub-agents для реализации |
| Тестирование |
Observable dev-mode |
Внешние сервисы в тестах |
💡
Тонкость для опытных: правило «defensive tool» Armin включает обработку идемпотентности — если CC вызывает ваш инструмент дважды с теми же параметрами (это случается при retry-логике агента), результат не должен задвоиться. Добавляйте в инструменты проверку типа if already_applied: return existing_result, особенно для операций записи в БД или отправки уведомлений.
🔑 Ключевой вывод
Простота как конкурентное преимущество
Armin Ronacher применяет тот же принцип что сделал Flask успешным: делать вещи простыми, явными и понятными. Для CC: простые инструменты → надёжный feedback. Observable dev-mode → Claude может завершить задачу автономно. CLI вместо сложных MCP → меньше точек отказа. Честность о провалах → другие могут избежать тех же ошибок.
🔧 Детали: как проектировать быстрые инструменты
Armin делится конкретными техниками для создания инструментов которые удовлетворяют правилу <100ms:
import time
import json
from typing import Optional
def get_user_subscriptions(user_id: int, status: Optional[str] = None) -> str:
"""
Инструмент для получения подписок пользователя.
Fast: кешируем на 60 сек, используем индекс по user_id
Defensive: валидируем все параметры перед запросом
Observable: возвращаем читаемый JSON с метаданными
"""
start = time.time()
# Defensive: валидация параметров
if not isinstance(user_id, int) or user_id <= 0:
return json.dumps({"error": "user_id must be positive integer", "user_id": user_id})
valid_statuses = ["active", "cancelled", "expired", None]
if status not in valid_statuses:
return json.dumps({"error": f"status must be one of {valid_statuses}"})
# Fast: прямой SQL с индексом, без ORM overhead
results = db.execute(
"SELECT id, status, expires_at FROM subscriptions WHERE user_id = %s"
+ (" AND status = %s" if status else ""),
[user_id] + ([status] if status else [])
).fetchall()
elapsed_ms = (time.time() - start) * 1000
# Observable: структурированный вывод с метаданными
return json.dumps({
"user_id": user_id,
"count": len(results),
"subscriptions": [dict(r) for r in results],
"query_ms": round(elapsed_ms, 2) # Observable: время запроса
}, default=str, indent=2)
💡 Паттерн observable dev-mode: расширенный вариант
Armin применяет паттерн «вывод в stdout» не только к email, но ко всем async операциям в dev-режиме:
import os
DEV_MODE = os.getenv('FLASK_ENV') == 'development'
class DevEmailBackend:
def send(self, to, subject, body):
if DEV_MODE:
print(f"\n[EMAIL] To={to} Subject={subject}\n{body}\n[/EMAIL]")
return
real_send(to, subject, body)
class DevQueueBackend:
def enqueue(self, job_class, **kwargs):
if DEV_MODE:
# В dev: выполняем синхронно, агент сразу видит результат
print(f"\n[QUEUE] {job_class.__name__}({kwargs})\n")
job_class(**kwargs).run()
return
real_queue.enqueue(job_class, **kwargs)
class DevWebhookBackend:
def notify(self, url, payload):
if DEV_MODE:
print(f"\n[WEBHOOK] {url}\n{payload}\n[/WEBHOOK]")
return
requests.post(url, json=payload)
💬 Цитаты Armin об инструментах для агентов
"I've removed everything that Claude can do on its own. The best tool is often no tool at all — just Claude and a shell."
"Agents write excellent SQL and can verify it against query logs. Don't hide that power behind an ORM."
"A tool that takes 2 seconds to respond is not a tool — it's a speed bump. Design for under 100ms or don't design it."
"Make your dev environment maximally observable. If Claude can see it, Claude can verify it. If it's hidden, you're just hoping."