Alarm! Сразу сниму возможное возражение - для части читателей Хабра эта история будет выглядеть банальной, потому что я буду писать про базу. Да, фоновым задачам не надо выдавать полный набор инструментов. Да, cron на LLM нельзя настраивать так же бездумно, как обычный shell-скрипт. Да, статус лучше читать из готового файла, а не каждый раз собирать заново.

Я с этим не спорю. Просто в моем случае эти очевидные вещи стали заметны именно  в цифрах когда мы начали пользоваться агентом не в тестовом режиме и несколько мелких настроек превратились в миллионы токенов в день.

Поэтому это не статья про новый способ оптимизации LLM, скорее короткий разбор того, где именно у нас утекали токены и какие артефакты помогли это увидеть. Если вы уже давно так делаете, ниже вряд ли будет открытие. Если только переводите агента из прототипа в рабочий режим, возможно, сэкономите себе пару вечеров, ну или убедитесь что все правильно делаете уже.

Что было в системе

Речь про рабочего AI-агента внутри OpenClaw. У него есть память, доступ к файлам, cron-задачи, отдельные сессии, браузерная автоматизация, субагенты и несколько внутренних процедур для проверки состояния.

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

Проблема была в фоновых процессах - healthcheck, watchdog, короткие статусные проверки и служебные cron-задачи по идее должны были стоить мало, но на практике часть из них поднимала почти такой же контекст, как обычная рабочая задача.

Первый сигнал я увидел не в биллинге, а в локальном отчете по сессиям. У нас есть небольшой скрипт, который проходит по jsonl-файлам сессий, забирает usage из сообщений и группирует расход по категориям. Отчет пишет результат в status-cache, чтобы потом не пересчитывать все в основной сессии.

Отчет до и после доработки:

{
  "generatedAt": "2026-07-03T15:00:12.943365+00:00",
  "day": "2026-07-03",
  "status": "WARN",
  "totalTokens": 4347737,
  "sessionCount": 91,
  "categories": {
    "dashboard-label-watchdog": {
      "sessions": 30,
      "tokens": 3746428
    },
    "shell-healthchecks": {
      "sessions": 45,
      "tokens": 565276
    },
    "kill-runaway-sessions": {
      "sessions": 16,
      "tokens": 360033
    }
  }
}
{
  "generatedAt": "2026-07-04T15:00:09.975484+00:00",
  "day": "2026-07-15",
  "status": "OK",
  "totalTokens": 414014,
  "sessionCount": 38,
  "categories": {
    "dashboard-label-watchdog": {
      "sessions": 15,
      "tokens": 191377
    },
    "shell-healthchecks": {
      "sessions": 15,
      "tokens": 131445
    },
    "kill-runaway-sessions": {
      "sessions": 8,
      "tokens": 91192
    }
  }

Даже если каждая фоновая задача кажется мелкой, в сумме она может набрать заметный дневной расход.

Где именно терялись токены

Самая крупная утечка нашлась в tools.аllow

В OpenClaw перед запуском модели описываются доступные инструменты. Это удобно, но описание инструмента тоже занимает место в контексте - название, параметры, ограничения, пояснения. Когда инструментов пять, это не проблема, но когда их больше 200, сам список инструментов становится большим куском промпта.

У части cron-задач не был явно задан короткий tools.аllow, поэтому они наследовали полный набор основного агента, при этом сами задачи были мелкими. Одна смотрела состояние окружения, другая проверяла зависшие сессии, третья обновляла статусный файл и им, очевидно, не нужны были инструменты для WordPress, Elementor, генерации изображений и других несвязанных сценариев. Но они все равно попадали в контекст.

Описания содержали полный список доступных инструментов и это съедало около 50 тысяч токенов еще до полезной работы. Тут не было проблемы модели или провайдера. Мы просто давали маленькой задаче слишком большой рабочий пул.

Сравнение конфигурации до и после

"model": "openai-codex/gpt-5.5"

дорогая модель по умолчанию

"lightContext": false

полный контекст

"toolsAllow": ["*"]

200+ инструментов → ~50k токенов в промпте

"timeoutSeconds": 30

короткий timeout

"schedule": { "kind": "every", "everyMs": 900000 }

каждые 15 минут

"model": "litellm/ollama-cloud-gemma4-31b"

дешевая модель

"lightContext": true

минимальный контекст

"toolsAllow": ["read", "exec", "cron"]

3 инструмента

"timeoutSeconds": 60

адекватный timeout

"schedule": { "kind": "every", "everyMs": 1800000 }

каждые 30 минут

Вторая проблема была в частоте cron

У нас были проверки, которые запускались раз в 10 или 15 минут. Для обычного скрипта это нормально, но для задачи, которая каждый раз поднимает LLM-сессию, уже спорно.

Ну и само собой мы увеличили интервалы. Healthcheck перешел с 10 минут на 30, Watchdog с 15 минут на 30, проверка зависших сессий с 30 минут на 60. Ничего важного после этого не сломалось, просто система перестала несколько раз в час платить за проверки, которым не нужна такая частая реакция.

Задача

До

После

Экономия

dashboard-label-watchdog

*/15 мин, toolsAllow 200+, timeout 30с

*/30 мин, toolsAllow 5, timeout 60с

~3.7M/день

shell-healthchecks

каждые 10 мин

каждые 30 мин

~565k/день

kill-runaway-sessions

каждые 30 мин

каждый час

~360k/день

Итого

~4.6M/день (84% фонового потребления)

Третья проблема проявилась на статусных вопросах

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

Например, один такой разбор у нас стоил около 1,35 млн токенов. Поэтому мы сделали отдельную задачу, которая заранее собирает token report и пишет его в файл. Основная сессия при статусном вопросе просто читает готовую сводку.

После этого похожий ответ стал стоить около 2 тысяч токенов. Не потому что агент начал лучше рассуждать, а потому что он перестал каждый раз пересчитывать одно и то же.

Скрытый текст

#!/usr/bin/env python3

"""Token usage report generator. Run by cron, writes structured report."""

import json, os

from datetime import datetime, timezone

from collections import defaultdict

sessions_dir = "/home/node/.openclaw/agents/main/sessions"

today = datetime.now(timezone.utc).strftime("%Y-%m-%d")

report_dir = ".openclaw/runtime/token-report"

os.makedirs(report_dir, exist_ok=True)

today_files = []

for fname in os.listdir(sessions_dir):

    if not fname.endswith(".jsonl"):

        continue

    fpath = os.path.join(sessions_dir, fname)

    try:

        with open(fpath) as f:

            first_line = f.readline().strip()

            if not first_line:

                continue

            meta = json.loads(first_line)

    except Exception:

        continue

    created = meta.get("createdAt") or meta.get("timestamp")

    if not created:

        continue

    try:

        if isinstance(created, (int, float)):

            dt = datetime.fromtimestamp(created / 1000 if created > 1e12 else created, tz=timezone.utc)

        else:

            dt = datetime.fromisoformat(created.replace("Z", "+00:00"))

    except Exception:

        continue

    if dt.strftime("%Y-%m-%d") != today:

        continue

    total_tokens = 0

    input_tokens = 0

    output_tokens = 0

    calls = 0

    models_used = defaultdict(int)

    first_user_msg = ""

    with open(fpath) as f:

        for line in f:

            line = line.strip()

            if not line:

                continue

            try:

                entry = json.loads(line)

            except Exception:

                continue

            msg = entry.get("message", {})

            if isinstance(msg, dict):

                usage = msg.get("usage", {})

                if usage:

                    t = usage.get("totalTokens", 0) or 0

                    total_tokens += t

                    input_tokens += usage.get("inputTokens", 0) or 0

                    output_tokens += usage.get("outputTokens", 0) or 0

                    m = msg.get("model", "unknown")

                    models_used[m] += t

                if msg.get("role") == "user" and not first_user_msg:

                    content = msg.get("content", "")

                    if isinstance(content, str):

                        first_user_msg = content[:80]

                    elif isinstance(content, list):

                        for part in content:

                            if isinstance(part, dict) and part.get("type") == "text":

                                first_user_msg = part.get("text", "")[:80]

                                break

            if entry.get("role") == "assistant" or (isinstance(msg, dict) and msg.get("role") == "assistant"):

                calls += 1

    # Categorize

    if "dashboard-label-watchdog" in first_user_msg:

        category = "dashboard-label-watchdog"

    elif "kill-runaway-sessions" in first_user_msg:

        category = "kill-runaway-sessions"

    elif "shell-healthchecks" in first_user_msg:

        category = "shell-healthchecks"

    elif "token-watchdog" in first_user_msg or "token-usage" in first_user_msg:

        category = "token-watchdog"

    elif "token-report" in first_user_msg:

        category = "token-report"

    elif first_user_msg:

        category = "user-query"

    else:

        category = "other"

    today_files.append({

        "category": category,

        "total": total_tokens,

        "input": input_tokens,

        "output": output_tokens,

        "calls": calls,

        "models": dict(models_used),

    })

# Group by category

by_cat = defaultdict(lambda: {"total": 0, "input": 0, "output": 0, "calls": 0, "sessions": 0, "models": defaultdict(int)})

for e in today_files:

    c = e["category"]

    by_cat[c]["total"] += e["total"]

    by_cat[c]["input"] += e["input"]

    by_cat[c]["output"] += e["output"]

    by_cat[c]["calls"] += e["calls"]

    by_cat[c]["sessions"] += 1

    for m, t in e["models"].items():

        by_cat[c]["models"][m] += t

sorted_cats = sorted(by_cat.items(), key=lambda x: x[1]["total"], reverse=True)

grand_total = sum(d["total"] for , d in sortedcats)

# Determine status

status = "OK"

if grand_total > 5_000_000:

    status = "CRITICAL"

elif grand_total > 3_000_000:

    status = "WARN"

# Write markdown report

now = datetime.now(timezone.utc)

now_str = now.strftime("%Y-%m-%d %H:%M UTC")

lines = []

lines.append("# Token Usage Report — " + today)

lines.append("Generated: " + now_str)

lines.append("Status: " + status + " | Total: " + f"{grand_total:,}" + " tokens | Sessions: " + str(len(today_files)))

lines.append("")

lines.append("## By Category")

lines.append("")

lines.append("| Category | Sessions | Tokens | % | Calls | Top Model |")

lines.append("|---|---|---|---|---|---|")

for cat, data in sorted_cats:

    pct = data["total"] / grand_total * 100 if grand_total > 0 else 0

    top_model = "N/A"

    if data["models"]:

        top_model = sorted(data["models"].items(), key=lambda x: x[1], reverse=True)[0][0].split("/")[-1]

    lines.append("| " + cat + " | " + str(data["sessions"]) + " | " + f"{data['total']:,}" + " | " + f"{pct:.1f}%" + " | " + str(data["calls"]) + " | " + top_model + " |")

lines.append("")

lines.append("## By Model")

lines.append("")

all_models = defaultdict(int)

for , data in sortedcats:

    for m, t in data["models"].items():

        all_models[m.split("/")[-1]] += t

for m, t in sorted(all_models.items(), key=lambda x: x[1], reverse=True):

    pct = t / grand_total * 100 if grand_total > 0 else 0

    lines.append("- " + m + ": " + f"{t:,}" + " (" + f"{pct:.1f}%" + ")")

lines.append("")

lines.append("## Thresholds")

lines.append("- WARN: >3,000,000 tokens/day")

lines.append("- CRITICAL: >5,000,000 tokens/day")

with open(os.path.join(report_dir, "latest.md"), "w") as f:

    f.write("\n".join(lines) + "\n")

# Write status.json

status_data = {

    "generatedAt": now.isoformat(),

    "day": today,

    "status": status,

    "totalTokens": grand_total,

    "sessionCount": len(today_files),

    "categories": {cat: {"sessions": d["sessions"], "tokens": d["total"]} for cat, d in sorted_cats},

}

with open(os.path.join(report_dir, "status.json"), "w") as f:

    json.dump(status_data, f, indent=2)

print("TOKEN_REPORT " + status + " total=" + f"{grand_total:,}" + " sessions=" + str(len(today_files)))

Что получилось после ревизии

Основной эффект дали три изменения:

  1. Фоновые задачи перестали получать полный набор инструментов.

  2. Частоту cron приблизили к реальной потребности. 

  3. Статусные ответы начали читаться из заранее подготовленного файла. 

Итоговая экономия по трем главным cron-задачам получилась около 4,6 млн токенов в день - это примерно 84 процента прежнего фонового расхода.

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

Финальный вывод

Для меня здесь главный урок был не в том, что tools.аllow надо резать, это действительно очевидно.

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

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

А как вы снижаете расходы у своих агентов? Режете tools, выносите диагностику в отдельные процессы, кешируете статусы или еще что нибудь инетресное? Поделитесь, может получится что-то дополнительно сделать, буду благодарен.