Перейти к основному содержанию
VOID
← База кейсов использования ИИ

Как сократить расход токенов у ИИ-агента

Проверили, сколько стоит одна и та же правка Python-кода, если передать модели весь файл или только нужный фрагмент. Через VOID API отправили по три запроса каждого вида к claude-haiku-4.5. Вход уменьшился с 1756 до 359 токенов на запрос; все шесть решений прошли одинаковые проверки поведения. Ниже приведены задача, настройки, фактические списания и код для повторения. Это контролируемый тест одного шага работы с кодом. Он показывает эффект сокращения контекста на конкретной задаче; расход полного агентского сеанса нужно измерять отдельно.

Исправляем отключение памяти в Telegram-боте

В первоначальном bot.py история обрезалась срезом [-2 * HISTORY_TURNS:]. При HISTORY_TURNS=3 сохранялись последние три пары сообщений. При значении 0 выражение превращалось в [-0:] и сохраняло весь список. Настройка отключения памяти работала неверно.

Мы попросили модель вынести это поведение в keep_history(history, turns). Контракт: ноль возвращает пустой список, отрицательное значение вызывает ValueError, положительное сохраняет последние пары. Исходный список менять нельзя.

Задача имеет короткую проверку, поэтому можно сравнить расходы и убедиться, что оба варианта контекста дали подходящее решение.

Фрагмент, переданный в коротком варианте

HISTORY_TURNS = 3
history = context.user_data.get("history", [])
context.user_data["history"] = (
    history + [user_message, {"role": "assistant", "content": reply}]
)[-2 * HISTORY_TURNS:]

Статья про Telegram-бота с исправленным кодом

Условия сравнения

Замер выполнен 23 октября 2026 года через /v1/chat/completions, модель claude-haiku-4.5. В обоих вариантах использовались одна системная инструкция, одинаковое поручение, temperature=0, max_tokens=400 и отключённые автоматические повторы SDK.

В полном варианте к поручению добавлялся первоначальный bot.py целиком. В коротком варианте передавались только настройка HISTORY_TURNS и участок обрезки истории. Исходный файл и скрипт сравнения приведены ниже.

Порядок шести запросов: полный, короткий, короткий, полный, полный, короткий. Каждый запрос начинал новый диалог. Агентские инструменты чтения файлов и запуска команд в эти запросы не входили: ответы модели проверялись отдельным Python-кодом.

Одинаковое поручение для обоих вариантов

В Python-боте история ограничивается выражением
(history + [user_message, assistant_message])[-2 * HISTORY_TURNS:].
При HISTORY_TURNS=0 оно сохраняет всю историю вместо отключения памяти.
Напиши функцию keep_history(history, turns), которая исправляет это поведение.
history содержит полный список чередующихся сообщений user/assistant, только полные пары.
turns - целое число. При turns=0 верни пустой список; при turns<0 подними ValueError.
При turns>0 верни последние turns полных пар или все сообщения, если их меньше.
Всегда возвращай новый список и не меняй исходный.
Верни только одну функцию keep_history на Python без Markdown, импортов и объяснений.

Токены и списания за шесть запросов

В каждой группе все три запроса вернули одинаковое число входных и выходных токенов. Полный файл потребовал 1756 входных токенов и 59 выходных; фрагмент потребовал 359 входных и 64 выходных. Поле cached_tokens во всех шести ответах было равно 0.

Стоимость в таблице взята из истории запросов VOID. На момент замера тариф составлял $0,57 за миллион входных и $2,20 за миллион выходных токенов.

Все шесть функций прошли восемь проверок поведения. Перед проверкой мы удалили Markdown-ограждения из ответов; сам Python-код не редактировали. Вариант с фрагментом сделал лишнюю копию списка, но сохранил требуемое поведение.

В этом тесте экономия возникла за счёт уменьшения входа. Более короткий ответ модель не дала: в коротком варианте выход вырос с 59 до 64 токенов. Для другой правки, модели или истории соотношение может быть иным.

Фактические результаты

Вариант   Запросы  Вход/запрос  Выход/запрос  Стоимость запроса, USD
Весь файл 3        1756        59           $0.00113072
Фрагмент  3        359         64           $0.00034543

Три запроса с полным файлом: $0.00339216
Три запроса с фрагментом:    $0.00103629

Решение при полном файле

def keep_history(history, turns):
    if turns < 0:
        raise ValueError("turns must be non-negative")
    if turns == 0:
        return []
    return history[-2 * turns:]

Решение при коротком контексте

def keep_history(history, turns):
    if turns < 0:
        raise ValueError("turns must be non-negative")
    if turns == 0:
        return []
    return (history + [])[-(2 * turns):]

Как проверяли качество решения

Проверяли сохранение нуля, одной, двух, трёх и девяти пар при наличии трёх пар в истории. В каждом случае функция должна вернуть новый список и оставить исходный без изменений. Ещё две проверки использовали пустую историю, последняя ожидала ValueError для отрицательного числа пар.

Чтобы проверить полученную функцию самостоятельно, просмотрите её код и сохраните в solution.py без Markdown-ограждений. Код ниже сохраните в test_history.py и выполните python test_history.py. Эти проверки относятся к заданному контракту: на вход подаются полные пары user/assistant.

test_history.py

from solution import keep_history

history = [
    {"role": role, "content": str(turn)}
    for turn in range(3)
    for role in ("user", "assistant")
]
original = list(history)
for turns, expected in (
    (0, []),
    (1, history[-2:]),
    (2, history[-4:]),
    (3, history),
    (9, history),
):
    result = keep_history(history, turns)
    assert result == expected
    assert result is not history
    assert history == original

assert keep_history([], 0) == []
assert keep_history([], 3) == []
try:
    keep_history(history, -1)
except ValueError:
    pass
else:
    raise AssertionError("Отрицательное число пар должно вызывать ValueError")

print("8 проверок поведения пройдены")

Что из этого применить к своему агенту

Для локальной правки сначала найдите нужную функцию и передайте её вместе с требованиями. Если ошибка связана с вызывающим кодом, типами или настройками, добавьте эти участки. На нашей задаче логика Telegram, создание клиента и работа со счётчиком не влияли на результат keep_history.

Для длинного лога сохраните полный файл отдельно, а модели передайте имя упавшего теста, место ошибки и диагностическое сообщение. Если после сокращения приходится заново читать тот же лог, включите эти дополнительные обращения в сравнение расходов.

При работе с инструментами сохраняйте связку вызова и результата. Обрезка обычного списка сообщений по числу элементов может оставить ответ инструмента без соответствующего вызова. Для таких диалогов используйте сокращение контекста, которое понимает структуру сообщений.

После изменения контекста запускайте те же проверки результата. Меньшая цена ошибочного решения ещё не означает, что задача стала обходиться дешевле.

Скрипт для повторения сравнения

Используйте Python-окружение и API-ключ из инструкции про бота. Для этого скрипта нужны openai==3.37.0 и python-dotenv==1.2.4. В .env укажите VOID_API_KEY и VOID_MODEL=claude-haiku-4.5. Токен Telegram не используется.

Сохраните приведённый ниже исходник первого варианта бота как bot-baseline.py, а скрипт сравнения как compare_context.py. Запустите python compare_context.py bot-baseline.py. Скрипт прочитает файл как текст и отправит шесть платных запросов к модели; сам исходник бота он не запускает.

Ответы и usage сохраняются после каждого запроса в comparison.json. syntax_ok проверяет только синтаксис. Поведение нужно проверить кодом из предыдущего раздела. Если любой запрос завершится ошибкой, скрипт остановится без автоматического повтора.

При повторении числа могут отличаться из-за версии модели, форматирования файла и состояния кэша. Для собственных задач сравнивайте полную стоимость до принятого результата, включая неудачные попытки и повторные чтения.

compare_context.py

import ast
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import sys

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv(Path(__file__).resolve().parent / ".env")
model = os.environ["VOID_MODEL"]
task = """В Python-боте история ограничивается выражением
(history + [user_message, assistant_message])[-2 * HISTORY_TURNS:].
При HISTORY_TURNS=0 оно сохраняет всю историю вместо отключения памяти.
Напиши функцию keep_history(history, turns), которая исправляет это поведение.
history содержит полный список чередующихся сообщений user/assistant, только полные пары.
turns - целое число. При turns=0 верни пустой список; при turns<0 подними ValueError.
При turns>0 верни последние turns полных пар или все сообщения, если их меньше.
Всегда возвращай новый список и не меняй исходный.
Верни только одну функцию keep_history на Python без Markdown, импортов и объяснений."""
short_context = """HISTORY_TURNS = 3
history = context.user_data.get("history", [])
context.user_data["history"] = (
    history + [user_message, {"role": "assistant", "content": reply}]
)[-2 * HISTORY_TURNS:]"""

if len(sys.argv) != 2:
    raise SystemExit("Запуск: python compare_context.py bot.py")

full_context = Path(sys.argv[1]).read_text()
report = {"model": model, "date": datetime.now(timezone.utc).isoformat(), "runs": []}
output = Path(__file__).resolve().parent / "comparison.json"
with OpenAI(
    api_key=os.environ["VOID_API_KEY"],
    base_url="https://void-api.tech/v1",
    timeout=75,
    max_retries=0,
) as client:
    for variant in ("full", "focused", "focused", "full", "full", "focused"):
        context = full_context if variant == "full" else short_context
        response = client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": "Ты исправляешь код Python. Соблюдай заданный контракт функции."},
                {"role": "user", "content": task + "\n\nИсходный код:\n" + context},
            ],
            temperature=0,
            max_tokens=400,
        )
        code = response.choices[0].message.content or ""
        if code.startswith("```python\n") and code.rstrip().endswith("```"):
            code = code.strip()[len("```python\n"):-3].strip()
        try:
            ast.parse(code)
            syntax_ok = True
        except SyntaxError:
            syntax_ok = False
        row = {
            "variant": variant,
            "response_id": response.id,
            "usage": response.usage.model_dump() if response.usage else None,
            "syntax_ok": syntax_ok,
            "answer": code,
        }
        report["runs"].append(row)
        output.write_text(json.dumps(report, ensure_ascii=False, indent=2))
        print(json.dumps({"variant": variant, "usage": row["usage"], "syntax_ok": syntax_ok}, ensure_ascii=False))

print(f"Ответы и токены сохранены в {output.name}. Проверьте поведение функций отдельно от синтаксиса.")

Настройка Python и API-ключа

Посмотреть фактические списания

Исходный файл для повторения замера

Это первоначальная версия файла, которую мы передавали модели в полном варианте. В ней сохранён участок с ошибкой при HISTORY_TURNS=0, поскольку он нужен для воспроизведения задачи. Исправленный вариант для использования находится в статье про бота.

Сохраните этот блок как bot-baseline.py. Для сравнения токенов выполнять его не требуется.

bot-baseline.py: исходные данные эксперимента

import logging
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

from dotenv import load_dotenv
from openai import APIError, AsyncOpenAI
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters

ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")
OWNER_ID = int(os.getenv("TELEGRAM_ALLOWED_USER_ID", "0"))
MODEL = os.environ["VOID_MODEL"]
DAILY_REQUESTS = 20
HISTORY_TURNS = 3
MAX_INPUT_CHARS = 3000
MAX_OUTPUT_TOKENS = 600

logging.basicConfig(level=logging.WARNING)
client = AsyncOpenAI(
    api_key=os.environ["VOID_API_KEY"],
    base_url="https://void-api.tech/v1",
    timeout=60.0,
    max_retries=0,
)
db = sqlite3.connect(ROOT / "quota.sqlite3")
db.execute("CREATE TABLE IF NOT EXISTS quota (day TEXT PRIMARY KEY, used INTEGER NOT NULL)")
db.commit()


def reserve_request():
    day = datetime.now(timezone.utc).date().isoformat()
    with db:
        cursor = db.execute(
            "INSERT INTO quota(day, used) VALUES (?, 1) "
            "ON CONFLICT(day) DO UPDATE SET used = used + 1 WHERE used < ?",
            (day, DAILY_REQUESTS),
        )
        return cursor.rowcount == 1


async def start(update, context):
    await update.message.reply_text(
        f"Ваш Telegram ID: {update.effective_user.id}.\n"
        "Укажите его в TELEGRAM_ALLOWED_USER_ID и перезапустите бота.\n"
        "Команда /reset очищает историю диалога."
    )


async def reset(update, context):
    if update.effective_user.id != OWNER_ID:
        return
    context.user_data.clear()
    await update.message.reply_text("История очищена. Дневной счётчик сохранён.")


async def answer(update, context):
    if update.effective_user.id != OWNER_ID:
        await update.message.reply_text("Это личный бот. Доступ к модели закрыт.")
        return
    text = update.message.text.strip()
    if not text or len(text) > MAX_INPUT_CHARS:
        await update.message.reply_text(f"Отправьте от 1 до {MAX_INPUT_CHARS} символов.")
        return
    if not reserve_request():
        await update.message.reply_text("Дневной лимит исчерпан. Обновление в 00:00 UTC.")
        return

    history = context.user_data.get("history", [])
    user_message = {"role": "user", "content": text}
    messages = [
        {"role": "system", "content": "Отвечай по-русски, кратко и по существу."},
        *history,
        user_message,
    ]
    try:
        response = await client.chat.completions.create(
            model=MODEL,
            messages=messages,
            max_tokens=MAX_OUTPUT_TOKENS,
        )
    except APIError as error:
        logging.warning(
            "Void API: %s, HTTP %s", type(error).__name__, getattr(error, "status_code", None)
        )
        await update.message.reply_text(
            "Не удалось получить ответ. Проверьте ключ, баланс и модель в VOID. "
            "Попытка учтена в дневном лимите."
        )
        return

    choice = response.choices[0] if response.choices else None
    reply = (choice.message.content or "").strip() if choice else ""
    if not reply:
        await update.message.reply_text("Модель не вернула текст. Попытка учтена в лимите.")
        return
    context.user_data["history"] = (
        history + [user_message, {"role": "assistant", "content": reply}]
    )[-2 * HISTORY_TURNS:]

    for offset in range(0, len(reply), 2000):
        await update.message.reply_text(reply[offset : offset + 2000])
    if choice.finish_reason == "length":
        await update.message.reply_text("Ответ остановлен по лимиту токенов.")
    if response.usage:
        print(
            f"model={response.model} input={response.usage.prompt_tokens} "
            f"output={response.usage.completion_tokens}",
            flush=True,
        )


async def on_error(update, context):
    logging.warning("Ошибка обработчика: %s", type(context.error).__name__)


async def shutdown(application):
    await client.close()
    db.close()


def build_app():
    app = (
        Application.builder()
        .token(os.environ["TELEGRAM_BOT_TOKEN"])
        .concurrent_updates(False)
        .post_shutdown(shutdown)
        .build()
    )
    private = filters.ChatType.PRIVATE
    app.add_handler(CommandHandler("start", start, filters=private))
    app.add_handler(CommandHandler("reset", reset, filters=private))
    app.add_handler(MessageHandler(private & filters.TEXT & ~filters.COMMAND, answer))
    app.add_error_handler(on_error)
    return app


if __name__ == "__main__":
    build_app().run_polling(allowed_updates=[Update.MESSAGE], drop_pending_updates=True)

Исправленный бот

Что учитывать за пределами этого примера

Мы проверили небольшую функцию с явным контрактом. Из результата нельзя определить, насколько уменьшатся расходы на большой рефакторинг, поиск ошибки в нескольких файлах или задачу с веб-поиском.

Если меняете модель, сравнивайте стоимость нескольких законченных задач и число неудач. Если используете кэш, учитывайте фактические cache-поля и соответствующие тарифы: cached_tokens может уже входить в общее число входных токенов, его нельзя прибавлять второй раз.

Для первой проверки своего агента выберите повторяемую задачу, зафиксируйте исходные файлы и критерии готовности. Затем измените один фактор, например объём передаваемого кода, и сопоставьте расходы на принятые решения.

Каталог моделей и тарифы

Справочник VOID API

Отключение повторов в OpenAI SDK