Free-LLMs

Как устроен LLM-агент: инструменты, память, планирование и контроль выполнения

Цикл агента на Python: инструменты, журнал, лимит шагов и проверка результата.

Agents
Архитектура LLM-агента
🤖
Мониторинг бесплатных моделей в Telegram Бот @freellms_bot присылает новые бесплатные модели и AI-акции.
→

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

Четыре части агента

ЧастьЧто это в кодеТипичный сбой
МодельВызов chat.completions с toolsВыдумывает инструмент или аргумент
ИнструментыФункции плюс JSON Schema их аргументовНеоднозначное описание, лишние права
ПамятьСписок messages, журнал шагов, внешнее хранилищеПереполнение контекста, потеря фактов
КонтрольЛимит шагов, проверка результата, обработка ошибокЗацикливание, ложное «готово»

Модель в этой схеме ничего не исполняет: она возвращает текст с намерением, а исполнение и ответственность за последствия остаются в вашем коде.

Инструменты: схема как контракт

Инструмент для модели — это имя, описание и JSON Schema аргументов. В документации OpenAI по function calling есть флаг strict: при true вызов гарантированно соответствует схеме, но для этого в каждом объекте нужен additionalProperties: false, а все поля перечислены в required; необязательные поля помечают типом null. Там же есть parallel_tool_calls: значение false гарантирует ноль или один вызов за ход.

Не полагайтесь на строгий режим как на единственную защиту. Совместимые API у разных провайдеров поддерживают его по-разному, а часть ключевых слов схемы (например, pattern) в строгом режиме может быть отклонена. Поэтому проверку аргументов в примере выполняет собственный валидатор на jsonschema.

Anthropic в приложении к своей статье советует «poka-yoke» для инструментов: менять аргументы так, чтобы ошибиться было труднее. Их пример: после перехода на обязательные абсолютные пути модель перестала ошибаться с файлами в SWE-bench. В нашем примере то же делает правило «заголовок начинается с [TEST] », зашитое в инструмент, а не только в промпт.

Память: три уровня

  • Рабочая память — сам список messages. Всё, что агент помнит внутри запуска, оплачивается токенами на каждом шаге.
  • Журнал состояния — запись о каждом шаге вне контекста: инструмент, аргументы, успех, время. Это память для вас: по журналу восстанавливают ход событий и ловят повторы.
  • Долговременная память — база или векторное хранилище, откуда агент достаёт факты инструментом; см. статью про RAG на своих документах.

Когда контекст растёт, сжимайте старые результаты инструментов до сводки в коде, а журнал оставляйте полным.

Планирование: когда достаточно цикла

Для задачи из трёх шагов отдельный планировщик не нужен: хватает цикла, в котором модель на каждом шаге видит результат предыдущего. Явный план оправдан, когда шагов много или порядок критичен. Тогда последовательность «поиск, создание, проверка» выносят в код, а модель только заполняет параметры. Это уже воркфлоу, и для многих продуктовых сценариев он надёжнее. В нашем примере порядок задан промптом, а правильность итога гарантирует проверка в коде.

Контроль выполнения

  • Лимит шагов. Цикл имеет жёсткий верхний предел (MAX_STEPS), после него возвращается статус step_limit, а не очередной запрос к модели.
  • Детектор повторов. Если одна и та же пара «инструмент, аргументы» встретилась больше двух раз, агент остановлен: он застрял.
  • Ошибки инструментов как данные. Исключение превращается в ответ {"ok": false, "error": ...}, который модель видит и может исправить. Неизвестный инструмент, битый JSON и нарушение схемы обрабатываются так же.
  • Идемпотентность. Повторный create_ticket с теми же аргументами возвращает прежний id, а не плодит дубли.
  • Проверка результата по состоянию системы. Фраза «тикет создан» от модели ничего не доказывает; функция verify смотрит в хранилище. Если проверка провалена, модели один раз возвращается причина.

Код цикла агента

Пример работает с любым OpenAI-совместимым эндпоинтом: задайте LLM_BASE_URL, LLM_API_KEY и LLM_MODEL. Модель должна поддерживать вызов инструментов; бесплатные варианты и их лимиты собраны в гиде по бесплатным LLM API, а эндпоинт OpenRouter подходит для быстрой проверки. Зависимости: pip install openai jsonschema.

import hashlib
import json
import os
import time

from jsonschema import Draft202012Validator

MODEL = os.environ.get("LLM_MODEL", "your-model-id")
MAX_STEPS = 6

TICKETS: dict[str, dict] = {}
IDEMPOTENCY: dict[str, str] = {}


class ToolError(Exception):
    """Ошибка, которую полезно показать модели."""


def search_tickets(query: str) -> dict:
    hits = [t for t in TICKETS.values() if query.lower() in t["title"].lower()]
    return {"count": len(hits), "tickets": hits[:5]}


def create_ticket(title: str, body: str, priority: str) -> dict:
    if not title.startswith("[TEST] "):
        raise ToolError("title must start with '[TEST] ' in the sandbox")
    key = hashlib.sha256(f"{title}|{body}|{priority}".encode()).hexdigest()
    if key in IDEMPOTENCY:  # повтор того же вызова не создаёт дубль
        return {"id": IDEMPOTENCY[key], "duplicate": True}
    ticket_id = f"T-{len(TICKETS) + 1}"
    TICKETS[ticket_id] = {"id": ticket_id, "title": title, "body": body,
                          "priority": priority}
    IDEMPOTENCY[key] = ticket_id
    return {"id": ticket_id, "duplicate": False}


def get_ticket(ticket_id: str) -> dict:
    if ticket_id not in TICKETS:
        raise ToolError(f"ticket {ticket_id} not found")
    return TICKETS[ticket_id]


def spec(name, description, properties, required):
    return {"type": "function", "function": {
        "name": name, "description": description, "strict": True,
        "parameters": {"type": "object", "properties": properties,
                       "required": required, "additionalProperties": False}}}


TOOLS = {
    "search_tickets": (search_tickets, spec(
        "search_tickets", "Search tickets by a title substring.",
        {"query": {"type": "string", "minLength": 2}}, ["query"])),
    "create_ticket": (create_ticket, spec(
        "create_ticket", "Create a TEST ticket. Title must start with '[TEST] '.",
        {"title": {"type": "string", "maxLength": 120},
         "body": {"type": "string", "maxLength": 2000},
         "priority": {"type": "string", "enum": ["low", "normal", "high"]}},
        ["title", "body", "priority"])),
    "get_ticket": (get_ticket, spec(
        "get_ticket", "Read one ticket by id, e.g. T-1.",
        {"ticket_id": {"type": "string", "pattern": "^T-[0-9]+$"}}, ["ticket_id"])),
}
TOOL_SPECS = [s for _, s in TOOLS.values()]

SYSTEM = (
    "You create test tickets. First search for a duplicate, then create the "
    "ticket, then read it back with get_ticket. Finish with one short sentence "
    "that contains the ticket id. Do not call tools you are not given."
)


def execute(name: str, raw_args: str) -> dict:
    """Единая точка исполнения: ошибки превращаются в данные, а не в падение."""
    if name not in TOOLS:
        return {"ok": False, "error": f"unknown tool '{name}'",
                "available": sorted(TOOLS)}
    func, tool_spec = TOOLS[name]
    try:
        args = json.loads(raw_args or "{}")
    except json.JSONDecodeError as e:
        return {"ok": False, "error": f"arguments are not valid JSON: {e.msg}"}
    schema = tool_spec["function"]["parameters"]
    errors = sorted(Draft202012Validator(schema).iter_errors(args),
                    key=lambda e: list(e.path))
    if errors:
        return {"ok": False, "error": "; ".join(
            f"{'/'.join(map(str, e.path)) or 'args'}: {e.message}" for e in errors[:3])}
    try:
        return {"ok": True, "result": func(**args)}
    except ToolError as e:
        return {"ok": False, "error": str(e)}
    except Exception as e:  # неожиданный сбой инструмента не должен валить цикл
        return {"ok": False, "error": f"internal tool error: {type(e).__name__}"}


def verify(created_ids: list[str]) -> str | None:
    """Проверка по состоянию системы, а не по словам модели."""
    if not created_ids:
        return "No ticket was created."
    t = TICKETS.get(created_ids[-1])
    if not t or not t["title"].startswith("[TEST] "):
        return "The created ticket is missing or has a wrong title."
    return None


def run_agent(client, goal: str, max_steps: int = MAX_STEPS) -> dict:
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": goal}]
    log, created, seen = [], [], {}
    verify_retries = 1
    for step in range(1, max_steps + 1):
        resp = client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOL_SPECS,
            tool_choice="auto", temperature=0)
        msg = resp.choices[0].message
        entry = {"role": "assistant", "content": msg.content}
        if msg.tool_calls:
            entry["tool_calls"] = [
                {"id": c.id, "type": "function",
                 "function": {"name": c.function.name,
                              "arguments": c.function.arguments}}
                for c in msg.tool_calls]
        messages.append(entry)

        if not msg.tool_calls:
            problem = verify(created)
            if problem is None:
                return {"status": "done", "answer": msg.content, "steps": step, "log": log}
            if verify_retries == 0:
                return {"status": "failed", "reason": problem, "steps": step, "log": log}
            verify_retries -= 1
            messages.append({"role": "user", "content": f"Check failed: {problem} Fix it."})
            continue

        for call in msg.tool_calls:
            sig = (call.function.name, call.function.arguments)
            seen[sig] = seen.get(sig, 0) + 1
            if seen[sig] > 2:  # зацикливание на одном и том же вызове
                return {"status": "failed", "reason": "repeated call", "steps": step, "log": log}
            t0 = time.monotonic()
            out = execute(call.function.name, call.function.arguments)
            if out["ok"] and call.function.name == "create_ticket":
                created.append(out["result"]["id"])
            log.append({"step": step, "tool": call.function.name,
                        "args": call.function.arguments, "ok": out["ok"],
                        "ms": round((time.monotonic() - t0) * 1000)})
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(out, ensure_ascii=False)})
    return {"status": "step_limit", "steps": max_steps, "log": log}


if __name__ == "__main__":
    from openai import OpenAI

    client = OpenAI(base_url=os.environ.get("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
                    api_key=os.environ["LLM_API_KEY"])
    result = run_agent(client, "Create a test ticket about a broken login button, priority high.")
    print(json.dumps(result, ensure_ascii=False, indent=2))

Как читать этот код

Функция execute — единственное место, где модель касается реального мира: инструмент известен, JSON валиден, аргументы проходят схему, и только потом вызов функции. В run_agent три выхода, и каждый имеет свой статус: done (проверка пройдена), failed (повтор вызова или провал проверки после повторной попытки) и step_limit. Вызывающий код различает их и решает, что делать дальше.

Что добавить перед продакшеном

  • Таймаут на каждый вызов и общий бюджет запуска. Для необратимых действий нужно подтверждение человека: руководство OpenAI советует не отключать подтверждения инструментов.
  • Минимальные права. OWASP называет корнями избыточной агентности лишние функции, лишние разрешения и лишнюю автономность.
  • Метрики: доля успешных запусков, число шагов, частота ошибок инструментов; подход описан в статье про LLMOps.

Итого

Агент — это цикл вокруг модели, а надёжность создают не умные промпты, а ограничители: схема аргументов, лимит шагов, ошибки как данные, идемпотентность и проверка итога по состоянию системы. Начните с трёх инструментов и жёсткого лимита, а планирование усложняйте потом. О том, как удешевить такой цикл маленькими моделями, читайте в материале про маршрутизацию в агентах, а о защите от внедрения инструкций — в статье про prompt injection.

FL
Редакция Free-LLMsКоманда каталога бесплатных LLM API. Проверяем лимиты провайдеров и тестируем модели и инструменты руками.

Читайте также