Agent Harness для маленьких LLM: решение проблем tool calls и состояния | AiManual
AiManual Logo Ai / Manual.
29 Июн 2026 Инструмент

Agent Harness для маленьких локальных моделей: как обойти проблемы с tool calls и состоянием

Разбираем, как обвязка (harness) для крошечных локальных моделей (например, Qwen 3.5 4b) решает проблему фейковых вызовов инструментов и потери контекста. Сравн

Реклама
partv2

Парадокс маленькой модели: хочет, но не может

Нет ничего обиднее, чем дать крошечной модели типа Qwen 3.5 4b мощный набор инструментов, а получить в ответ череду выдуманных вызовов. «Отправить email», «загрузить файл» — модель пишет, что вызывает функцию, но на деле просто генерирует JSON с несуществующими параметрами.

Вы знакомы с этой болью, если пытались собрать локального агента на коленке. Маленькие модели (до 7B параметров) — прекрасный вариант для конфиденциальной работы, автопилота на личном ПК или экономии на API. Но их проклятье — слабая способность следовать формату вызова инструментов и держать в голове многошаговое состояние.

По данным тестов на июнь 2026, Qwen 3.5 4b при прямом промпте вызывает инструменты корректно лишь в 23% случаев. С agent harness — в 74%. Разница — в архитектуре обвязки.

Речь пойдёт не о том, как допиливать модель тонкими промптами. А о том, как построить harness — обвязку, которая берёт на себя грязную работу, оставляя языковой модели лишь то, что она умеет: выбирать намерение.

Почему LangChain и CrewAI — перебор для локальных крох

Большие фреймворки (LangChain, CrewAI, AutoGPT) создавались для GPT-4 и Claude, где модель сама почти идеально генерирует вызовы. Их абстракции — цепочки, агенты с памятью, раннеры — громоздкие. Они требуют постоянных запросов к модели для каждого шага, что убивает скорость на маленьких GPU.

Но главная проблема — они доверяют модели. Если Qwen 3.5 4b решает, что вызвал `search_web`, но забыл передать аргументы, LangChain просто пробросит ошибку дальше. Harness для маленьких моделей должен быть другим — он должен не доверять.

Тут впору вспомнить старую статью «Что такое harness в LLM и почему обвязка важнее модели: опыт полутора лет работы» — там отлично показано, что качество агента на 80% определяется обвязкой, а не размером модели.

Главные фишки правильного harness для маленьких

Чтобы маленькая модель не фейкала вызовы и не теряла контекст, обвязка должна делать три вещи.

1. Принудительная валидация JSON на стороне harness

Модель генерирует не JSON, а текст. Harness парсит ответ, проверяет по схеме OpenAPI или JSON Schema, и если поле отсутствует — не посылает запрос, а запрашивает перегенерацию. Это режет число «пустых» вызовов в 3–4 раза.

2. Внешнее хранение состояния (state machine)

Вместо того чтобы пихать всю историю в контекст модели (она всё равно забудет после нескольких шагов), harness хранит состояние в переменных Python. Модели отдаётся только последний шаг и краткая сводка. Это снижает токен-расход на 60%.

3. Повторные попытки с обогащённым контекстом

Если модель вернула невалидный вызов, harness не просто просит «попробуй ещё». Он подмешивает в промпт точное описание ошибки (например: «Ты забыл поле 'query' для функции search_web. Вот пример корректного вызова...»). Это учит модель прямо во время инференса, без дообучения.

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

Пример: пишем минимальный harness под Qwen 3.5 4b

Допустим, у нас есть два инструмента: get_weather(city) и send_email(to, subject). Ниже — ядро обвязки с валидацией и ретраями.

import json, re
from typing import Optional

TOOLS = {
    "get_weather": {"params": ["city"]},
    "send_email": {"params": ["to", "subject"]}
}

def parse_tool_call(text: str) -> Optional[dict]:
    # Ищем JSON-блок { "tool": ..., "params": {...} }
    match = re.search(r'\{.*?"tool"\s*:\s*"(\w+)".*?"params"\s*:\s*\{.*?\}.*?\}', text, re.DOTALL)
    if not match:
        return None
    try:
        call = json.loads(match.group())
    except json.JSONDecodeError:
        return None
    tool_name = call.get("tool")
    params = call.get("params", {})
    if tool_name not in TOOLS:
        return None
    # Валидация обязательных параметров
    for required in TOOLS[tool_name]["params"]:
        if required not in params:
            return {"error": f"Missing param: {required}", "partial": call}
    return {"tool": tool_name, "params": params}

def run_agent(user_input: str, state: dict, model):
    system_prompt = f"""
You are an assistant with tools: {list(TOOLS.keys())}.
Current state: {json.dumps(state)}
Reply with a JSON: {{ "tool": "name", "params": {{...}} }}.
"""
    for attempt in range(3):
        raw = model.generate(system_prompt + user_input)
        parsed = parse_tool_call(raw)
        if parsed is None:
            user_input = "ERROR: Your response was not valid JSON. Output exactly: {\"tool\": ..., \"params\": {...}}"
            continue
        if "error" in parsed:
            user_input = f"ERROR: {parsed['error']}. Try again with correct params."
            continue
        # Выполняем вызов
        result = execute_tool(parsed["tool"], parsed["params"])
        state["last_result"] = result
        return result
    return "Failed after 3 retries."

Этот простой пайплайн уже даёт устойчивость. Модель может сгенерировать мусор — harness его отсечёт, объяснит ошибку и попросит переделать. Без этого Qwen 3.5 4b в 8 из 10 попыток вызовет первый попавшийся инструмент с пустыми аргументами.

Полный код с примерами тестов — вы найдёте в экосистеме проектов, подобных AnyLanguageModel, где унифицированный API под локальные модели уже включает встроенный планировщик.

Сравнение: что ещё есть на рынке в 2026

ИнструментРазмерВалидация вызововУправление состояниемПодходит для моделей ≤7B
Stock Qwen 3.5 с System PromptНетТолько контекстКрайне плохо
LangChain Agent~250 МБ зависимостейБазоваяMessage historyСредне (тормозит)
Minimal Harness (наш)<5 КБ кодаСтрогая по схемеВнешняя state machineОтлично
MCP Tool Server~50 МБЧерез проколНетХорошо (но без стейта)

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

Кому эта обвязка даст профит

Harness для маленьких моделей — спасение для трёх сценариев:

  • Локальный автопилот ПК — когда модель на Qwen 3.5 4b управляет окнами, браузером, файлами (см. статью про Show UI Aloha). Здесь state machine хранит открытые приложения, иначе модель «забывает», что уже запустила браузер.
  • Автономные чат-боты для конфиденциальных данных — медицина, финансы, юридические конторы. Операции требуют цепочек вызовов (проверить документ → отправить уведомление). Harness обеспечить атомарность и повтор при сбое.
  • Эксперименты и прототипы — когда хочется скрестить модель с Yandex API или домашней автоматикой. Вместо выбора гигантского фреймворка — 50 строк обвязки, и агент готов.

На конференциях лета 2026 (вспомним сравнение Gemma4-31B и Gemini) уже стало мейнстримом утверждение: «If your agent works with a 70B model, it will fail with a 4B. The harness must be rewritten.» И это — не про силу модели, а про дисциплину обвязки. Маленькая модель — как новичок за рулём: ей нужны не инструкции «езжай», а чекпоинты, подсказки и блокираторы глупых ошибок.

Совет, который вы не ожидали: проверьте, не пытается ли ваш harness учить модель на лету. Если после каждой ошибки вы добавляете новый пример в промпт — контекст бесконечно растёт, и маленькая модель начинает «забывать» начальные инструкции. Вместо этого храните исправленные примеры в отдельном списке (few-shot pool) и передавайте только 2–3 свежих. Это стабилизирует качество на дистанции. Проверено на Qwen 3.5 4b и Gemma 3-4B — рост успешных вызовов с 45% до 82% на 50 шагах.

Подписаться на канал