Враждебная проверка исследования: исследователи, критики на чужой модели и судья со схемой

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

· 16 мин чтения

Коротко

  • Исследователи работают параллельно и сдают отчёт по JSON-схеме: утверждение плюс источник. Критики на другой модели открывают эти источники и ищут дыры. Судья через llm-task выносит решение тоже по схеме, а цикл с ограничением отправляет спорное на второй круг.
  • Собирается двумя способами: скриптом Swarm в Code Mode (agents.run со schema, phase(), while с ограничением) или без Code Mode, через sessions_spawn с collect: true и outputSchema и через agents_wait.
  • Честная граница: Code Mode экспериментальный, прерванный рестартом скрипт с места не продолжится, коллекторы не умеют спрашивать одобрение, а критик с доступом к источникам надёжнее самопроверки, но истины не гарантирует.

Кому и когда

Вы поручаете агенту исследование: сравнить три библиотеки для очереди задач, проверить утверждение из чужой статьи перед тем, как на него сослаться, разобрать, что изменилось в API между версиями. Один агент вернёт аккуратный текст. Ошибку в нём вы найдёте позже, когда библиотеку уже выбрали.

Модель плохо проверяет сама себя. В работе Large Language Models Cannot Self-Correct Reasoning Yet показано, что без внешней обратной связи модель редко исправляет свои рассуждения, а иногда после «самопроверки» отвечает хуже. В исследовании LLM-as-a-judge среди перекосов модели-судьи назван self-enhancement bias: судья склонен выше оценивать ответы своей же модели. Отсюда приём. Проверяет не тот, кто писал, и проверяющему дают то, чего не хватает самопроверке: внешнюю обратную связь. Здесь это источники, которые критик открывает сам. Другая модель снимает перекос в пользу своих ответов, но без источников смена модели мало что даёт.

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

Собрано по документации и исходникам OpenClaw 2026.9.7; скрипты и конфиги запуском не проверялись, имена моделей и тексты заданий условные.

Схема потока

  1. Исследование. N исследователей идут параллельно, по одному на кандидата или подвопрос. Каждый сдаёт JSON: список утверждений, у каждого id и источник.
  2. Враждебная проверка. На каждый отчёт свой критик на другой модели. Задание: открыть источники и найти неподтверждённое, устаревшее и противоречивое. Ответ тоже JSON: какое утверждение, что с ним не так, насколько это серьёзно.
  3. Решение. Судья на третьей модели получает последние отчёты и критику и возвращает одно из трёх: принять, перепроверить спорное, эскалировать человеку. Спорное он называет парами «кандидат + id утверждения», чтобы одинаковые id у разных исследователей не путались. Это шлюз решения (decision gate): от его ответа зависит, будет ли второй круг.
  4. Второй круг, если судья попросил. Перезапускаются только кандидаты со спорным. Исследователь получает прошлый отчёт, спорные утверждения и замечания критика к ним и сдаёт исправленный отчёт целиком. Критик проверяет его заново. Кругов не больше двух.
  5. Если круги кончились, а спор остался, решение переводится в эскалацию.
  6. Синтез. Итоговый текст пишется из последних отчётов и только из утверждений, переживших критику. Отброшенное и упавшие ветки перечисляются отдельно.
  7. Человек. В конце, и только если есть эскалация или открытые вопросы.

Из чего собрано

  • Swarm: параллельные дочерние агенты-коллекторы из скрипта, структурные результаты, лимиты.
  • Code Mode: модель пишет небольшую программу на JavaScript, а инструменты вызывает из неё как функции.
  • LLM Task: одиночный вызов модели без инструментов, который возвращает JSON по схеме.
  • Субагенты: параметры sessions_spawn, в том числе model и runTimeoutSeconds.
  • Ask user: структурный вопрос человеку с вариантами ответа.

Главное понятие — коллектор. Обычный субагент, закончив работу, сам присылает результат родителю (announce). Коллектор, ребёнок с collect: true, ничего не присылает. Он оставляет результат, и родитель забирает его явно. Поэтому пачку из десяти исследователей можно дождаться одной точкой сбора, без потока сообщений в сессию.

Настройка

Заведите под исследования отдельного агента. Code Mode и llm-task тогда включаются только у него, а расходы видны отдельной строкой.

{
  plugins: {
    entries: {
      "llm-task": {
        enabled: true,
        llm: {
          allowModelOverride: true,
          allowedCompletionModels: ["<модель агента по умолчанию>", "<провайдер-3>/<модель>"],
        },
      },
    },
  },
  agents: {
    entries: {
      research: {
        tools: {
          codeMode: true,
          alsoAllow: ["llm-task"],
          swarm: { maxConcurrent: 8, maxTotalPerGroup: 40 },
        },
      },
    },
  },
}

Что здесь происходит:

  • Swarm включён по умолчанию, отдельного переключателя ему не нужно. А вот Code Mode, без которого в скрипте нет agents.run, включается отдельно. Здесь он включён явно для одного агента.
  • Глобальные agents.run, phase и log появляются в скрипте, только если в каталоге инструментов есть родной sessions_spawn и политика инструментов его разрешает. MCP-инструмент с тем же именем не подходит.
  • swarm у агента переопределяет общие лимиты. Значения по умолчанию: 32 ребёнка выполняются одновременно, 50 живых в группе, 200 за всю жизнь группы. Здесь они урезаны под задачу, об этом в разделе про стоимость.
  • Блок llm у llm-task — разрешения со стороны хоста. Судья в скрипте зовёт модель явно, поэтому нужен allowModelOverride. Список allowedCompletionModels ограничивает каждый вызов, так что в нём должны быть и модель судьи, и модель агента по умолчанию.

Откатить: уберите research из agents.entries и llm-task из plugins.entries и перезапустите Gateway.

Детям можно дать отдельного, урезанного агента: с меньшим набором инструментов и дешёвой моделью. Документация так и советует: задать tools.swarm.defaultAgentId, разрешить его в subagents.allowAgents и поставить ему tools.swarm: false, чтобы он сам не запускал рои. Готового агента worker в OpenClaw нет, его надо завести.

Вариант 1: скрипт Swarm

Скрипт пишет и запускает сам агент через инструмент exec в Code Mode. Ниже то, что он должен написать. Синтаксис взят из примеров tools/swarm.md: параллельный разбег с Promise.allSettled и цикл с шлюзом решения.

Скрипт целиком (≈100 строк)
const RESEARCH_MODEL = "<провайдер-1>/<модель>";
const CRITIC_MODEL = "<провайдер-2>/<модель>";   // не модель исследователя
const JUDGE_MODEL = "<провайдер-3>/<модель>";    // ни исследователя, ни критика
const candidates = ["A", "B", "C"];

const claimsSchema = {
  type: "object",
  properties: {
    subject: { type: "string" },
    claims: {
      type: "array",
      items: {
        type: "object",
        properties: { id: { type: "string" }, text: { type: "string" }, source: { type: "string" } },
        required: ["id", "text", "source"],
        additionalProperties: false,
      },
    },
  },
  required: ["subject", "claims"],
  additionalProperties: false,
};

const critiqueSchema = {
  type: "object",
  properties: {
    holes: {
      type: "array",
      items: {
        type: "object",
        properties: {
          claimId: { type: "string" },
          problem: { type: "string" },
          severity: { type: "string", enum: ["fatal", "major", "minor"] },
        },
        required: ["claimId", "problem", "severity"],
        additionalProperties: false,
      },
    },
  },
  required: ["holes"],
  additionalProperties: false,
};

const verdictSchema = {
  type: "object",
  properties: {
    decision: { type: "string", enum: ["accept", "recheck", "escalate"] },
    contested: {
      type: "array",
      items: {
        type: "object",
        properties: { subject: { type: "string" }, claimId: { type: "string" } },
        required: ["subject", "claimId"],
        additionalProperties: false,
      },
    },
    openQuestions: { type: "array", items: { type: "string" } },
  },
  required: ["decision", "contested", "openQuestions"],
  additionalProperties: false,
};

// latest[subject] = { report, holes }: последний отчёт кандидата и критика к нему
// holes: null — критик упал, утверждения этого отчёта не проверены
const latest = {};
const failures = [];
let tasks = candidates.map((c) => ({ subject: c, focus: "всё" }));
let verdict = null, pass = 0;

while (pass < 2) {
  pass += 1;
  phase(`Исследование, круг ${pass}`);
  const research = await Promise.allSettled(tasks.map((t) =>
    agents.run(`Исследуй ${t.subject} для задачи «очередь задач в Python». ${t.focus} ` +
      `Каждое утверждение — с id и URL источника. Нет источника — не пиши. Поле subject: ${t.subject}.`,
      { label: `research-${t.subject}-${pass}`, model: RESEARCH_MODEL, schema: claimsSchema })));
  const fresh = [];
  research.forEach((o, i) => {
    if (o.status === "fulfilled") {
      const report = { ...o.value, subject: tasks[i].subject };
      latest[report.subject] = { report, holes: null };
      fresh.push(report);
    } else failures.push({ lane: `research-${tasks[i].subject}-${pass}`, error: String(o.reason) });
  });

  phase(`Враждебная проверка, круг ${pass}`);
  const checks = await Promise.allSettled(fresh.map((r) =>
    agents.run(`Ты скептичный рецензент. Открой каждый источник и найди утверждения, ` +
      `которые он не подтверждает, устарели или противоречат друг другу:\n${JSON.stringify(r)}`,
      { label: `critic-${r.subject}-${pass}`, model: CRITIC_MODEL, schema: critiqueSchema })));
  checks.forEach((o, i) => {
    if (o.status === "fulfilled") latest[fresh[i].subject].holes = o.value.holes;
    else failures.push({ lane: `critic-${fresh[i].subject}-${pass}`, error: String(o.reason) });
  });

  phase(`Решение, круг ${pass}`);
  const judged = await llm_task({
    prompt: "Реши по отчётам и критике: принять, перепроверить спорное или эскалировать человеку. " +
      "Спорное называй парами subject + claimId. holes: null значит, что отчёт не проверен.",
    input: { latest, failures },
    schema: verdictSchema,
    model: JUDGE_MODEL,
  });
  verdict = judged.json;
  log(`Круг ${pass}: ${verdict.decision}, спорных ${verdict.contested.length}, модель судьи ${judged.model}`);
  if (verdict.decision !== "recheck") break;

  const disputed = {};
  for (const c of verdict.contested) {
    if (!latest[c.subject]) continue;
    if (!disputed[c.subject]) disputed[c.subject] = [];
    disputed[c.subject].push(c.claimId);
  }
  tasks = Object.keys(disputed).map((subject) => {
    const ids = disputed[subject];
    const prev = latest[subject];
    return {
      subject,
      focus: "Перепроверь спорные утверждения и верни отчёт целиком: неспорное без изменений, " +
        "спорное исправь с новым источником или убери. " +
        `Спорное: ${JSON.stringify(prev.report.claims.filter((x) => ids.includes(x.id)))}. ` +
        `Замечания критика: ${JSON.stringify((prev.holes || []).filter((h) => ids.includes(h.claimId)))}. ` +
        `Прошлый отчёт: ${JSON.stringify(prev.report)}.`,
    };
  });
  if (tasks.length === 0) break;
}

if (verdict.decision === "recheck") {
  verdict = { ...verdict, decision: "escalate",
    openQuestions: verdict.openQuestions.concat(["Круги исчерпаны, спорное осталось"]) };
}

phase("Синтез");
const synthesis = await agents.run(
  `Напиши вывод только из утверждений, которые пережили критику. Отдельно перечисли отброшенное, ` +
  `спорное и упавшие ветки:\n${JSON.stringify({ latest, verdict, failures })}`,
  { label: "synthesis", model: RESEARCH_MODEL });
return { synthesis, verdict, failures, latest };

На что смотреть в этом скрипте:

  • agents.run со schema. Ребёнку добавляется служебный инструмент structured_output, ответ проверяется по схеме, на ошибку даётся одна подсказка исправиться. Не исправился, упал или истёк по времени — промис отклоняется с SwarmAgentError, у которой есть runId, status и message.
  • Promise.allSettled, а не Promise.all. Promise.all падает на первой же ошибке и остальные результаты не собирает. Документация прямо советует сохранять готовое, сообщать об упавших ветках и не перезапускать пачку автоматически.
  • latest вместо общей кучи отчётов. Судья и синтез видят только последнюю версию каждого кандидата, а спорное адресовано парой «кандидат + id».
  • phase() и log() показывают этапы и короткие заметки в виджете прогресса Swarm в Control UI. Ожидания они не добавляют: если интерфейса нет, скрипт не ждёт.
  • llm_task. Внутри Code Mode инструменты плагинов доступны как функции, а дефис в имени заменяется подчёркиванием. Функция возвращает details инструмента: решение судьи в .json, модель, которая реально отвечала, в .model.
  • Цикл ограничен. pass < 2 здесь и есть условие остановки. Лимит maxTotalPerGroup документация называет последней страховкой от разбегания, а не заменой ясного условия выхода.

Почему судья — llm-task, а не ещё один agents.run. Вызов llm-task идёт без транскрипта агента, без инструментов и без запасной модели. Если выбранный рантайм не умеет такой изолированный вызов, тот падает до обращения к модели, а не превращается молча в обычный ход агента. Судье не нужно ничего открывать: всё, что он должен взвесить, уже лежит в input. Критикам, наоборот, инструменты нужны: без чтения источников критика превращается в спор мнений.

Вариант 2: без Code Mode

Если Code Mode включать не хочется, те же коллекторы доступны как обычные инструменты. Агенту нужны разрешённые sessions_spawn и agents_wait. Сами они в строгий профиль инструментов не добавляются, даже при включённом Swarm.

Запуск одного критика:

{
  "task": "Ты скептичный рецензент. Открой каждый источник в отчёте и найди неподтверждённое… <отчёт>",
  "collect": true,
  "groupId": "critique-pass-1",
  "label": "critic-A-1",
  "model": "<провайдер-2>/<модель>",
  "outputSchema": { "type": "object", "properties": { "holes": { "type": "array" } }, "required": ["holes"] },
  "runTimeoutSeconds": 900
}

Ответ на запуск — квитанция со status: "accepted" и runId. Сохраните все runId, это ваша страховка на случай сбоя. Дальше агент собирает результаты:

{ "ids": ["<runId-1>", "<runId-2>", "<runId-3>"], "timeoutSeconds": 120 }

agents_wait возвращается, как только завершился хотя бы один ребёнок из списка или истёк таймаут. По умолчанию таймаут 30 секунд, максимум 600 (waitTimeoutSecondsMax), за вызов — до 1000 id. В ответе три массива: completed (у каждого status — done, failed, killed или timeout, — result, при схеме structured или schemaError, и usage с токенами), pending и errors.

В следующий вызов передавайте только оставшиеся pending. Ждать коллекторов через sessions_yield нельзя: он для обычных субагентов, которые сами присылают результат.

При разборе элемент может нести частичный structured и при этом иметь status: "failed". Порядок проверки задан документацией: непустой error, потом schemaError, потом непустой result.

Судью в этом варианте агент вызывает как обычный инструмент llm-task, с тем же prompt, input, schema и model.

Скрипт короче и сам держит цикл, но runId из agents.run вы увидите лишь в ошибке. Низкоуровневый путь многословнее, зато у вас на руках все runId и токены по каждому ребёнку, а после сбоя это решает.

Где человек

В конце и только по открытым вопросам. Скрипт возвращает родительскому агенту verdict. Если там escalate или непустой openQuestions, агент задаёт вопрос через ask_user: от одного до трёх вопросов, у каждого от двух до четырёх вариантов, свободный ответ добавляется сам. В Telegram, Discord, Slack и Mattermost один вопрос с одиночным выбором приходит кнопками.

Три ограничения:

  • ask_user есть только в основной сессии. Ни коллекторы, ни обычные субагенты его не получают, поэтому спросить из середины роя нельзя. Вопрос задаёт родитель, когда скрипт уже вернул результат.
  • Ожидание по умолчанию 900 секунд, допустимо от 30 до 3600. Если ответа нет, инструмент возвращает no_answer, и агент продолжает по своему усмотрению. Если без человека вывод публиковать нельзя, запишите это правилом в AGENTS.md: при no_answer — не публиковать, а сохранить отчёт.
  • Коллекторы не открывают окно одобрения. Действие ребёнка, которому нужно одобрение, просто отклоняется. Поэтому исследователям и критикам давайте инструменты, которым одобрение не нужно: поиск и чтение страниц.

Сколько стоит и как посчитать

Считать надо вызовы. При трёх кандидатах первый круг — это 3 исследователя, 3 критика и 1 судья. Второй круг добавляет до 3 исследователей, 3 критиков и ещё одного судью, плюс синтез: итого до 15 вызовов модели. Из них 13 — дети группы, судья через llm-task в группу не входит. Сверху идут ходы самого родителя на exec и wait: в этой арифметике их нет, но в расходе они есть. Дороже всех обычно критики: они заново открывают источники.

Как мерить:

  • В варианте 2 у каждого ребёнка в ответе agents_wait есть usage с inputTokens и outputTokens. Сложите их.
  • Отдельный агент даёт отдельную строку в общей сводке: openclaw gateway usage-cost --agent research --days 7. Команда собирает её из логов сессий.
  • Документация Code Mode советует сравнивать полный расход корня вместе с потомками на типичных задачах, прежде чем включать режим широко.

Жёсткого потолка расходов нет: лимиты Swarm ограничивают число детей, а не токены. maxTotalPerGroup: 40 при двух кругах с запасом покрывает 13 детей и отсекает разбегание, если скрипт перепишут без ограничения кругов.

Где ломается

  • Code Mode экспериментальный. Он в списке экспериментальных функций, а их форма и поведение меняются быстрее стабильных настроек. Исполнитель по умолчанию, Node, — это node:vm в рабочем потоке, а не граница безопасности. Изоляцию даёт исполнитель QuickJS (executor: "quickjs").
  • Рестарт обрывает скрипт. Прогон Code Mode живёт в памяти процесса, и после рестарта Gateway старый прогон не оживает. Gateway может восстановить прерванный ход из транскрипта, но с ограничениями на инструменты, поэтому рассчитывайте, что скрипт придётся запускать заново. Прерванные дети завершаются со статусом прерывания и сами не перезапускаются, а результаты завершившихся коллекторов сохраняются. Поэтому держите runId и не запускайте заново то, что уже завершилось. Каждый вызов exec или wait ограничен 10 секундами: если дети ещё работают, вызов вернёт waiting, и агент продолжит через wait. Подвешенный прогон живёт 900 секунд, таких прогонов в процессе не больше 64.
  • Лимиты считают детей, а не смысл. 32 одновременно — остальные встают в очередь. 50 живых в группе и 200 за жизнь группы — сверх этого запуск отклоняется с именем настройки в ошибке. Поднять maxConcurrent не значит поднять два других лимита.
  • Без model критик окажется на модели исследователя. Ребёнок без model наследует модель запросившего (или subagents.model из конфига), и «враждебная» проверка становится самопроверкой. Ставьте model каждому критику явно, особенно в JSON запуска из варианта 2. Неверное значение может отклонить запуск — проверяйте ошибку в квитанции. Фактическую модель смотрите в транскрипте ребёнка, у судьи — в поле model ответа llm-task.
  • Судья тоже модель. На модели критика он рискует подыгрывать «своему» (тот же self-enhancement bias), поэтому здесь он на третьей. Если третьей модели нет, учитывайте этот перекос при чтении вердикта.
  • Схема проверяет форму, а не правду. Поле source с URL пройдёт валидацию, даже если по ссылке написано обратное. У разных моделей бывают общие слепые пятна. Числа и версии сверяйте кодом, не моделью.
  • Дети одноразовые. Swarm запускает разовых коллекторов, API для многоходового работника с состоянием нет. Второй круг — это новые дети, которым прошлый отчёт и критика передаются текстом.
  • Не любой ребёнок может быть коллектором. Режим коллектора работает только с родными субагентами OpenClaw: ACP (подключение внешних оболочек вроде Claude Code), привязка к треду, видимые и постоянные сессии не поддерживаются. Вложенные коллекторы документация не советует.
  • В оболочке Codex параллельного запуска нет. Там вызовы динамических инструментов идут по очереди, и Promise.all не отправит несколько sessions_spawn одновременно. Запускайте детей в цикле: уже принятые работают, пока отправляются следующие.

Когда брать другое

  • Один-два исследователя. Документация советует оставлять Swarm для пачек от пяти похожих детей, а для одного или нескольких брать обычный sessions_spawn, который сам присылает результат.
  • Ответ проверяется кодом. Тест, сверка числа с таблицей, запрос к API. Модель-критик здесь дороже и хуже скрипта.
  • Исследование на дни, которое должно переживать рестарты. Swarm не годится: нужны сохранённые этапы, например промежуточные отчёты в файлах и запуск по этапам.
  • Фиксированный конвейер с одобрениями. Lobster: шаги, точки одобрения, продолжение после паузы. Сохранённых workflow-определений у Swarm нет и в планах документации тоже.
  • Суждение, которое не сводится к фактам. Подходит ли библиотека вашей команде, решает человек. Враждебная проверка подготовит ему материал без дыр, но решение не примет.