Telegram-бот с LLM: один ключ, предсказуемый счёт
Технический разбор от команды go2llm, не отзыв клиента. Код рабочий и воспроизводимый. Все суммы — расчёт по опубликованному прайсу (каталог v38, 2026-09-27), а не замер чужого счёта.
Задача
Telegram-бот, который отвечает на сообщения через LLM. Требования к нему как к продакшену, а не к демо:
- расход предсказуем и ограничен сверху, а не «посмотрим в конце месяца»;
- один пользователь не может выесть бюджет всего бота;
- бот не падает целиком, когда апстрим отдал
502; - ключ ограничен на стороне шлюза, даже если в коде ошибка.
Главный риск здесь не технический, а арифметический. Бот с открытым /start получает трафик, который вы не контролируете. Без верхней границы один цикл «пользователь шлёт 200 сообщений подряд» превращается в счёт.
Как было
Типичный первый вариант, который работает ровно до момента, когда бот кто-то находит:
# bot.py — ДО
from aiogram import Bot, Dispatcher, types
from openai import OpenAI
bot = Bot("TELEGRAM_TOKEN")
dp = Dispatcher()
client = OpenAI(api_key="sk-...")
@dp.message()
async def handle(message: types.Message):
resp = client.chat.completions.create( # блокирующий вызов в async
model="gpt-4o-mini",
messages=[{"role": "user", "content": message.text}],
) # нет max_tokens
await message.answer(resp.choices[0].message.content)
Что тут не так, по пунктам:
max_tokensне задан — верхней границы стоимости одного ответа нет.- Синхронный клиент внутри
async defблокирует event loop. - Нет квоты на пользователя: один чат может сгенерировать любой объём.
- Нет глобального лимита частоты — упрётся в rate limit ключа.
- Стоимость нигде не учитывается, обратной связи по расходу нет.
- Любое исключение из SDK роняет хендлер и оставляет пользователя без ответа.
Что делаем
1. Берём ключ и фиксируем модель
export GO2LLM_API_KEY=gk_sk_ВАШ_КЛЮЧ
export TELEGRAM_TOKEN=ваш_токен_бота
curl -sS https://go2llm.tech/v1/models | jq -r '.data[].id' | grep -E 'deepseek|glm|luna'
Model ID берётся строкой из каталога. Приблизительное написание даёт 404 model_not_found.
2. Ставим квоту на ключ на стороне шлюза
Это первый рубеж обороны, и он не зависит от вашего кода. На каждый ключ настраиваются: квота в долларах, список разрешённых моделей, IP allow-list, срок действия, лимит 20 RPM по умолчанию.
Практика для бота: отдельный ключ, квота на уровне месячного бюджета, в списке моделей — только та, на которой бот работает. Тогда даже случайный model="claude-opus-5" в коде вернёт 403 model, а не счёт.
Важно: ключ, созданный с пустым полем квоты, идёт без отдельного лимита — если нужен потолок, выставьте его при создании. Квота 0 означает «без отдельного лимита», расход ограничен только балансом.
3. Считаем верхнюю границу одного ответа
max_tokens — это и есть контракт на стоимость. Дальше всё умножается: max_tokens × цена_выхода — потолок расхода на выход одного сообщения. Держите системный промпт и историю короткими: вход тоже платный.
4. Три рубежа лимитов, а не один
- Шлюз: квота ключа в долларах и 20 RPM.
- Приложение, глобально: собственный лимитер под 20 RPM, чтобы не ловить
429. - Приложение, на пользователя: дневной бюджет в долларах, который списывается
по фактическому cost_usd из ответа.
Третий рубеж — ключевой. Ограничивать пользователя количеством сообщений неточно: сообщения разной длины стоят по-разному. Ограничивайте деньгами.
Как стало
Полный рабочий бот на aiogram 3 и асинхронном OpenAI SDK. Состояние держится в памяти процесса — для одного инстанса этого достаточно; для нескольких замените _spend на Redis.
# bot.py — ПОСЛЕ
import asyncio
import logging
import os
import time
from collections import defaultdict, deque
import openai
from aiogram import Bot, Dispatcher, F, types
from aiogram.filters import Command
from openai import AsyncOpenAI
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("bot")
MODEL = os.environ.get("LLM_MODEL", "deepseek-v4-flash")
MAX_TOKENS = 400 # потолок стоимости одного ответа
USER_DAILY_USD = 0.02 # дневной бюджет на пользователя
GLOBAL_RPM = 18 # с запасом под лимит ключа в 20 RPM
HISTORY_TURNS = 4 # сколько пар реплик тащим в контекст
SYSTEM = "Ты лаконичный ассистент. Отвечай по делу, без вступлений."
llm = AsyncOpenAI(
api_key=os.environ["GO2LLM_API_KEY"],
base_url="https://go2llm.tech/v1",
max_retries=0, # ретраим сами, см. ниже
timeout=60.0,
)
bot = Bot(os.environ["TELEGRAM_TOKEN"])
dp = Dispatcher()
_spend: dict[tuple[int, str], float] = defaultdict(float) # (user_id, YYYY-MM-DD) -> $
_history: dict[int, deque] = defaultdict(lambda: deque(maxlen=HISTORY_TURNS * 2))
_calls: deque[float] = deque()
_gate = asyncio.Lock()
def _today() -> str:
return time.strftime("%Y-%m-%d", time.gmtime())
async def _rate_gate() -> None:
"""Глобальный лимитер: не больше GLOBAL_RPM вызовов за скользящую минуту."""
async with _gate:
while True:
now = time.monotonic()
while _calls and now - _calls[0] > 60.0:
_calls.popleft()
if len(_calls) < GLOBAL_RPM:
_calls.append(now)
return
await asyncio.sleep(60.0 - (now - _calls[0]) + 0.05)
async def _ask(messages: list[dict]) -> tuple[str, float]:
"""Возвращает (текст, фактическая стоимость в USD)."""
delay = 0.8
for attempt in range(4):
await _rate_gate()
try:
resp = await llm.chat.completions.create(
model=MODEL,
max_tokens=MAX_TOKENS,
messages=messages,
)
cost = float(getattr(resp.usage, "cost_usd", 0.0) or 0.0)
return resp.choices[0].message.content, cost
except openai.APIStatusError as exc:
code = ""
try:
code = (exc.response.json().get("error") or {}).get("code", "")
except Exception:
pass
if exc.status_code == 402:
raise RuntimeError("insufficient_balance") from exc
if exc.status_code == 429 and code == "quota":
raise RuntimeError("key_quota") from exc
if exc.status_code not in (429, 500, 502, 503, 504):
raise
retry_after = exc.response.headers.get("retry-after")
wait = float(retry_after) if retry_after else delay
log.warning("retry %s status=%s code=%s in %.1fs",
attempt, exc.status_code, code, wait)
await asyncio.sleep(wait)
delay = min(delay * 2, 20.0)
except (openai.APIConnectionError, openai.APITimeoutError):
await asyncio.sleep(delay)
delay = min(delay * 2, 20.0)
raise RuntimeError("upstream_unavailable")
@dp.message(Command("start"))
async def start(message: types.Message):
await message.answer(
f"Модель: {MODEL}. Лимит ответа: {MAX_TOKENS} токенов.\n"
f"Дневной бюджет: ${USER_DAILY_USD:.2f}. /usage — сколько потрачено."
)
@dp.message(Command("usage"))
async def usage(message: types.Message):
spent = _spend[(message.from_user.id, _today())]
await message.answer(
f"Сегодня: ${spent:.6f} из ${USER_DAILY_USD:.2f}"
)
@dp.message(F.text)
async def handle(message: types.Message):
uid = message.from_user.id
key = (uid, _today())
if _spend[key] >= USER_DAILY_USD:
await message.answer("Дневной лимит исчерпан. Возвращайтесь завтра.")
return
hist = _history[uid]
messages = [{"role": "system", "content": SYSTEM}]
messages += list(hist)
messages.append({"role": "user", "content": message.text[:4000]})
await bot.send_chat_action(message.chat.id, "typing")
try:
text, cost = await _ask(messages)
except RuntimeError as exc:
reason = {
"insufficient_balance": "Баланс шлюза исчерпан, бот временно не отвечает.",
"key_quota": "Квота ключа исчерпана, бот временно не отвечает.",
"upstream_unavailable": "Поставщик недоступен, попробуйте через минуту.",
}.get(str(exc), "Временная ошибка, попробуйте ещё раз.")
log.error("llm failed: %s", exc)
await message.answer(reason)
return
_spend[key] += cost
hist.append({"role": "user", "content": message.text[:4000]})
hist.append({"role": "assistant", "content": text})
log.info("uid=%s cost_usd=%.8f day_total=%.6f", uid, cost, _spend[key])
await message.answer(text)
if __name__ == "__main__":
asyncio.run(dp.start_polling(bot))
Вариант со стримингом, если нужен эффект «печатает». Финальный usage-блок с cost_usd приходит в конце потока, поэтому учёт делается там же:
async def _ask_stream(messages: list[dict]):
await _rate_gate()
stream = await llm.chat.completions.create(
model=MODEL,
max_tokens=MAX_TOKENS,
messages=messages,
stream=True,
stream_options={"include_usage": True},
)
buf: list[str] = []
cost = 0.0
async for chunk in stream:
if chunk.usage is not None:
cost = float(getattr(chunk.usage, "cost_usd", 0.0) or 0.0)
if chunk.choices and chunk.choices[0].delta.content:
buf.append(chunk.choices[0].delta.content)
return "".join(buf), cost
Важное ограничение стрима: до начала ответа шлюз может использовать совместимый fallback-маршрут на разрешённых классах ошибок, но после первого байта или валидного SSE-события переключение не выполняется. Оборвавшийся поток нельзя повторить автоматически — либо отдавайте пользователю то, что успело прийти, либо явно предлагайте повторить запрос. В боте на Telegram это ещё и упирается в лимит частоты editMessageText, поэтому обновлять сообщение чаще, чем раз в секунду, смысла нет.
Ручная проверка ключа перед запуском бота:
curl -sS https://go2llm.tech/v1/chat/completions \
-H "Authorization: Bearer $GO2LLM_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"deepseek-v4-flash","max_tokens":120,
"messages":[{"role":"user","content":"Ответь одним словом: работает"}]}' \
| jq '{text: .choices[0].message.content, cost: .usage.cost_usd}'
Сколько это стоит
Расчёт по опубликованному прайсу, каталог v38 от 2026-09-27, а не замер у клиента. Проверить: /pricing и GET /api/pricing.
Профиль одного сообщения: системный промпт плюс четыре пары реплик истории — примерно 1 000 входных токенов, и max_tokens = 400 на выход. Считаем худший случай, когда выход упирается в потолок.
deepseek-v4-flash, цены 0.0226 / 0.0679 $ за 1M:
- вход:
1 000 / 1 000 000 × 0.0226 = $0.0000226 - выход:
400 / 1 000 000 × 0.0679 = $0.00002716 - итого одно сообщение: $0.00004976
Отсюда:
- welcome-грант $1 при регистрации:
1 / 0.00004976 ≈ 20 096сообщений; - дневной бюджет пользователя $0.02:
0.02 / 0.00004976 ≈ 402сообщения в сутки; - бот на 10 000 сообщений в день:
10 000 × 0.00004976 = $0.4976в сутки,
то есть × 30 = $14.93 в месяц.
Та же нагрузка на других моделях (1 000 in / 400 out, 10 000 сообщений в день, 30 дней):
| Модель | in | out | Одно сообщение | В месяц |
|---|---|---|---|---|
glm-5.3-flash | 0.018 | 0.06 | $0.000042 | $12.60 |
deepseek-v4-flash | 0.0226 | 0.0679 | $0.00004976 | $14.93 |
gpt-5.6-luna | 0.04 | 0.24 | $0.000136 | $40.80 |
claude-haiku-4-5 | 0.13 | 0.65 | $0.00039 | $117.00 |
claude-sonnet-5 | 0.329 | 1.64 | $0.000985 | $295.50 |
Справочный рублёвый эквивалент по курсу ЦБ 86,3793 ₽ за $1 на 01.09.2026: $14.93 ≈ 1 290 ₽ в месяц. Баланс и списания ведутся в USD.
Обратите внимание: разница между верхней и нижней строкой — 23×, при том что ключ один и код бота не меняется, меняется значение MODEL. Это и есть повод сначала измерить качество на дешёвой модели, а не начинать с дорогой.
Тарификация pay-as-you-go, подписок нет. Минимальное пополнение $1, оплата рублями через СБП или криптовалютой.
Грабли
1. Отсутствие max_tokens — самая дорогая строка кода. Без него потолок стоимости ответа задаётся моделью, а не вами. При 402 insufficient_balance первое, что нужно сделать, — пополнить баланс либо уменьшить max_tokens.
2. 429 quota и 429 rate_limit — разные ситуации. rate_limit означает «слишком часто», лечится ожиданием Retry-After. quota означает «квота ключа исчерпана», и повторы бесполезны: нужно менять настройку ключа. В коде выше они разведены намеренно.
3. Ретраить 402 нельзя. Баланс от повторов не появится. Шлюз резервирует оценочную стоимость до вызова, списывает фактическую и освобождает остаток резерва, баланс не уходит ниже нуля — но серия повторов просто сожжёт rate limit и задержит остальных пользователей.
4. Учёт по количеству сообщений вместо учёта по деньгам. Сообщение с длинной историей стоит в разы дороже короткого. Счётчик должен складывать cost_usd из ответа, а не инкрементироваться на единицу.
5. Оборвавшийся стрим нельзя переиграть. После первого валидного SSE-события fallback не сработает. Планируйте UX на этот случай заранее.
6. Пользователь присылает картинку или голосовое. В текстовые endpoint'ы изображения, аудио, видео и PDF не принимаются — придёт 400 unsupported_content_type. Фильтр F.text в примере выше это и закрывает: нетекстовые сообщения до LLM не доходят. Полный список кодов — /docs/errors.
cost_usd приходит и в стриме. В OpenAI-совместимом SSE финальный usage-блок содержит то же поле usage.cost_usd, что и обычный ответ, так что счётчик расхода работает одинаково в обоих режимах. Указывать stream_options.include_usage при этом не нужно: шлюз выставляет его сам, когда в запросе стоит stream: true.
Программно прочитать остаток баланса нельзя. Публичного endpoint'а для этого нет: по ключу gk_sk_… доступны только /v1/messages, /v1/chat/completions и /v1/images/generations. Баланс и расход видны в кабинете по сессии. Поэтому бот должен вести собственный счётчик по cost_usd из ответов и обрабатывать 402 insufficient_balance как штатный сценарий, а не опрашивать шлюз о балансе.
Лимит 20 RPM считается на ключ, суммарно по всем моделям и endpoints, а не на аккаунт и не на модель. Несколько инстансов бота с одним ключом делят эти 20 запросов в минуту между собой — если инстансов больше одного, разносите их по разным ключам или ставьте общую очередь. Отдельно действует лимит параллельных запросов на аккаунт, по умолчанию 2. Механизма самостоятельного повышения лимитов в кабинете нет — по этому вопросу пишите в @go2llm_support.
Итог
Предсказуемый счёт у LLM-бота получается не из выбора «дешёвой модели», а из трёх независимых границ: max_tokens на ответ, дневной бюджет на пользователя по фактическому cost_usd, квота и список моделей на самом ключе. Первые две живут в вашем коде, третья — на стороне шлюза и работает даже при ошибке в коде.
Один ключ gk_sk_… покрывает все опубликованные модели, поэтому смена MODEL в конфиге — это смена цены без миграции, нового биллинга и второго ключа.
Документация — /docs, цены — /pricing, коды ошибок — /docs/errors.