Как собрать research-агента за 12 шагов: поисковые циклы, ранжирование источников и fact gates
Полный перевод X Article о том, как построить research-агента с поисковыми циклами, ранжированием источников и fact gates.
Как собрать research-агента за 12 шагов: поисковые циклы, ранжирование источников и fact gates
Источник: https://x.com/de1lymoon/status/2076620451007119673
Автор: Alex
Каждый research-агент, который я видел в неудаче, проваливался не потому, что был глупым. Он проваливался потому, что внутри цикла ничему не было позволено сказать «нет».
В этом вся проблема. Research-агент ищет, читает, суммаризирует и отдаёт вам абзац. Абзац звучит гладко. В нём есть цитата. Цитаты не существует. Никому в цикле не была поручена проверка.
Я перестал пытаться сделать модель «более осторожной». Вместо этого я начал строить цикл, который её ловит.
Это 12-шаговая дорожная карта research-агента, который отказывается публиковать собственные галлюцинации. Без фреймворка. Только while loop, scoring function и три gate.
Поехали.

Цикл 1 — поисковые циклы
01. Пишите утверждение, а не тему
Большинству research-агентов дают тему: «Исследуй надёжность AI-агентов». Это не вопрос, а направление; агент, которому указали направление, будет идти, пока не закончатся токены.
Исследуемый вопрос — это вопрос, в котором можно оказаться неправым. Сначала напишите утверждение, затем напишите то, что могло бы его убить.
from dataclasses import dataclass
import re
@dataclass
class Claim:
text: str
falsifier: str
def is_researchable(self) -> bool:
vague = {"best", "good", "bad", "better", "important", "significant"}
words = set(re.findall(r"[a-z]+", self.text.lower()))
return bool(self.falsifier) and not (words & vague)Набор vague — грубый инструмент, и он работает. Если в вашем вопросе есть слово «best», вы не исследуете — вы выбираете товар.
c = Claim(
text="Do LLM agents degrade on long horizon tasks?",
falsifier="A benchmark showing flat success rate past step 7",
)
assert c.is_researchable()Запускайте этот фильтр до того, как напишете первую строку агента: если ни один результат не способен изменить ответ, агенту нечего делать.
02. Разверните одно утверждение в четыре запроса
Один вопрос — четыре угла. Наивный агент ищет то, что ожидает найти, а поисковые системы прекрасно умеют давать вам именно то, что вы ожидаете.

def expand_queries(claim: Claim, n: int = 4) -> list[str]:
core = claim.text.rstrip("?.").lower()
return [
core, # confirmation
f"{core} evidence", # support
f"{core} criticism OR disputed OR retracted", # refutation
f"{core} original study OR primary source OR dataset", # provenance
][:n]Четыре строки, и третья делает большую часть работы. Без refutation-запроса агент найдёт пятнадцать статей, которые соглашаются друг с другом, все цитируют один и тот же preprint, и доложит о консенсусе, которого не существует.
Агент, который ищет только подтверждение, не занимается исследованием. Он украшает вывод, который у вас уже был.
03. Останавливайтесь по насыщению, а не по количеству
Вот первый gate.
Почти каждый агент останавливается после N результатов. N — магическое число, которое кто-то однажды напечатал. Правильное условие остановки информационное: остановитесь, когда новые результаты перестали приносить новую информацию.
from dataclasses import field
@dataclass
class Saturation:
seen: set[str] = field(default_factory=set)
novelty_window: list[float] = field(default_factory=list)
threshold: float = 0.15
window: int = 2
def observe(self, batch) -> float:
if not batch:
return 0.0
fresh = sum(1 for r in batch if r.fingerprint() not in self.seen)
for r in batch:
self.seen.add(r.fingerprint())
novelty = fresh / len(batch)
self.novelty_window.append(novelty)
return novelty
def exhausted(self) -> bool:
if len(self.novelty_window) < self.window:
return False
return all(n < self.threshold for n in self.novelty_window[-self.window:])Две последовательные пачки ниже 15% новизны — и поиск завершён. Иногда это срабатывает на втором запросе. Иногда не срабатывает вообще, и вы узнаёте, что вопрос шире, чем думали, — это тоже результат.
Gate нужен не для экономии токенов. Он нужен, чтобы агент отличал широкое чтение от хождения по кругу.
04. Канонизируйте, затем дедуплицируйте
Пять результатов, один факт. Три из них — один и тот же пресс-релиз с разными заголовками.

Уберите tracking-параметры, удалите www, нормализуйте trailing slash, захешируйте. Затем ловите почти-дубликаты по заголовку.
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
from difflib import SequenceMatcher
import hashlib, re
TRACKING = re.compile(r"^(utm_|fbclid|gclid|ref|mc_)")
def canonical(url: str) -> str:
parts = urlsplit(url.strip())
host = parts.netloc.lower().removeprefix("www.")
query = [(k, v) for k, v in parse_qsl(parts.query) if not TRACKING.match(k)]
path = parts.path.rstrip("/") or "/"
return urlunsplit(("https", host, path, urlencode(sorted(query)), ""))
def dedupe(results, near: float = 0.88):
kept, seen = [], set()
for r in results:
fp = r.fingerprint()
if fp in seen:
continue
if any(SequenceMatcher(None, r.title.lower(), k.title.lower()).ratio() > near
for k in kept):
continue
seen.add(fp)
kept.append(r)
return keptЭто самая негламурная функция в статье, и она меняет результат сильнее любого промпта, который вы когда-либо напишете.
Синдикация — не подтверждение. Десять изданий, перепечатавших один wire story, — это один источник в десяти шляпах.
Цикл 2 — ранжирование источников
05. Классифицируйте: primary, secondary, aggregator
Прежде чем оценивать источник, нужно понять, что это вообще за вещь. Три уровня, и границы не такие уж тонкие.

PRIMARY = ("arxiv.org", "nature.com", "sec.gov", "who.int",
"nih.gov", "europa.eu", "bls.gov")
AGGREGATOR = ("medium.com", "substack.com", "reddit.com",
"news.ycombinator.com", "x.com", "linkedin.com")
def classify(r) -> str:
d = r.domain()
if any(d.endswith(p) for p in PRIMARY) or d.endswith(".gov"):
return "primary"
if any(d.endswith(a) for a in AGGREGATOR):
return "aggregator"
return "secondary"Да, этот список субъективен. Да, ваш список будет выглядеть иначе. В этом и смысл: список — это policy, а не факт, поэтому ему место в коде, где с ним можно спорить.
Обратите внимание: Substack здесь aggregator. Мой тоже. Агент, который считает мою рассылку primary source, — агент, которому я бы не доверил даже собственный текст.
06. Оценивайте tier, свежесть, данные и независимость
Один только tier — плохой ранжировщик. Трёхлетний primary source без чисел проигрывает свежему secondary source, который публикует размер выборки.
Четыре оси, одно число.
from datetime import datetime, timezone
TIER_WEIGHT = {"primary": 1.0, "secondary": 0.6, "aggregator": 0.25}
def recency(published: str | None, half_life_days: float = 540.0) -> float:
if not published:
return 0.5
try:
then = datetime.fromisoformat(published).replace(tzinfo=timezone.utc)
except ValueError:
return 0.5
age = (datetime.now(timezone.utc) - then).days
return 0.5 ** (age / half_life_days)
def has_data(r) -> float:
signal = re.search(r"\d{2,}|\bdataset\b|\bsample\b|\bn\s*=\s*\d+", r.snippet, re.I)
return 1.0 if signal else 0.0
def score(r, domain_counts: dict[str, int]) -> float:
tier = TIER_WEIGHT[classify(r)]
independence = 1.0 / domain_counts[r.domain()]
return round(
0.45 * tier
+ 0.25 * recency(r.published)
+ 0.20 * has_data(r)
+ 0.10 * independence,
4,
)Independence — скрытый герой. Один домен, вернувший шесть результатов, не должен получать шесть голосов. Делите на собственную частоту, и громкий сайт сам становится тише.
Отсутствующая дата публикации получает 0.5, а не 0. Недатированный источник неопределёнен, а не бесполезен. Кодируйте неопределённость как неопределённость.
07. Обрезайте под context budget
Второй gate.
Top-k — неправильная абстракция. У модели нет слота на пять источников; у неё есть окно, измеряемое в символах, и длинный низкоранговый источник может съесть место, которое было нужно короткому высокоранговому.
def fit_budget(ranked, budget_chars: int):
out, used = [], 0
for r, _ in ranked:
cost = len(r.snippet) + len(r.title) + 80 # 80 = citation overhead
if used + cost > budget_chars:
continue
out.append(r)
used += cost
return outОбратите внимание на continue вместо break. Источник, который не помещается, пропускается, но не завершает цикл. Следующий может оказаться достаточно маленьким.
Ранжируйте по качеству, допускайте по стоимости. Это два разных вопроса и две разные функции.
08. Записывайте конфликты, никогда не усредняйте их
Именно здесь большинство research-агентов совершает настоящий грех. Они находят два источника, которые не согласны друг с другом, и выдают предложение, которое «делит разницу». Никто такого предложения не говорил. Это галлюцинация, собранная из правдивых частей.

NEGATION = re.compile(
r"\b(no|not|never|fails?|disput|refut|contradict|retract)\w*", re.I
)
def find_conflicts(results):
pos = [r for r in results if not NEGATION.search(r.snippet)]
neg = [r for r in results if NEGATION.search(r.snippet)]
return [(p.domain(), n.domain()) for p in pos[:2] for n in neg[:2]]Грубый keyword matching. Он поднимает пару, а затем вы — или второй проход модели — решаете, что означает расхождение. Чего он никогда не делает: тихо не «разрешает» конфликт.
Несогласие между источниками — самое ценное, что может найти агент. Агент, который его прячет, уничтожает единственную информацию, ради которой всё это стоило делать.
Цикл 3 — fact gates
09. Разбейте черновик на атомарные утверждения
Нельзя проверить абзац. Можно проверить предложение.

SENT = re.compile(r"(?<=[.!?])\s+")
def atomize(draft: str) -> list[str]:
return [s.strip() for s in SENT.split(draft.strip()) if len(s.strip()) > 15]Две строки. Это не самая сложная часть системы, но именно этот шаг делает возможными все следующие. Проверка работает с единицами, достаточно маленькими, чтобы быть истинными или ложными.
10. Привяжите каждое утверждение к источнику
Каждое предложение получает источник — или получает label. Третьего варианта нет, презумпции невиновности тоже.
STOP = set("the a an of in on and or to is are was were for with that this it as by".split())
def keywords(text: str) -> set[str]:
return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower()) if w not in STOP}
def bind(statement: str, corpus, floor: float = 0.30):
kw = keywords(statement)
if not kw:
return None
best, best_overlap = None, 0.0
for r in corpus:
overlap = len(kw & keywords(r.snippet)) / len(kw)
if overlap > best_overlap:
best, best_overlap = r, overlap
return best if best_overlap >= floor else NoneFloor 0.30 — ручка настройки. Поставите высоко — истинные утверждения будут помечаться как unsourced. Поставите низко — агент с радостью привяжет «небо голубое» к статье о полупроводниках.
Настройте это один раз на собственных черновиках и запишите число.
11. Прогоните цитаты через текст источника
Третий gate, и самый важный для меня.
Модель не так часто выдумывает факты, как выдумывает формулировки. Она читает три статьи об исследователе, впитывает суть и выдаёт предложение в кавычках, которого никто никогда не говорил. Факты выжили. Атрибуция — фикция.

QUOTED = re.compile(r'"([^"]{10,})"')
def citation_gate(statement: str, source) -> tuple[bool, str]:
quotes = QUOTED.findall(statement)
if not quotes:
return True, "ok"
if source is None:
return False, "quote with no source"
for q in quotes:
if q.lower() not in source.snippet.lower():
return False, f"quote not found in {source.domain()}"
return True, "ok"Точное substring match. Не fuzzy, не semantic. Если строки нет в источнике — предложение не выходит наружу.
12. Отгружайте ledger непроверенного
Последний шаг превращает pipeline в loop.
Не выбрасывайте отмеченные предложения. Собирайте их. То, что не удалось проверить, — это карта того, чего вы ещё не знаете, и вход для следующего прохода.
@dataclass
class Verdict:
statement: str
status: str # sourced | unsourced | rejected
source: str | None
note: str
def verify(draft: str, corpus) -> list[Verdict]:
out = []
for s in atomize(draft):
src = bind(s, corpus)
ok, note = citation_gate(s, src)
if not ok:
out.append(Verdict(s, "rejected", src.domain() if src else None, note))
elif src is None:
out.append(Verdict(s, "unsourced", None, "no source above floor"))
else:
out.append(Verdict(s, "sourced", src.domain(), "ok"))
return outТри состояния, не два. rejected — фабрикация, она никогда не публикуется. unsourced может быть правдой и выходит только с маркировкой. sourced заслужило своё место.
Скормите unsourced-список обратно в шаг 1 как новые claims. Это пятый удар. Это и есть loop.
Что gates на самом деле ловят
Я прогнал это на намеренной ловушке: черновик с одной хорошо подтверждённой статистикой, одним спорным выводом, одним уверенным обобщением без поддержки источников и одной цитатой, которую я выдумал.
Посмотрите на последние две строки.
Большинство команд вообще это не измеряет. Это предложение — точная форма статистики, с которой начинается каждая AI-статья: «девять из десяти», «90% команд». Звучит как данные. На деле это настроение. FLAG.
Один исследователь назвал это «the silent collapse of agent loops». Я придумал это за тридцать секунд до запуска скрипта, и gate поймал цитату, ничего не зная о мире. Он просто спросил, есть ли строка в источнике. STOP.
Обе строки могли бы уйти в публикацию. Ни одна из них не является провалом интеллекта.
Замкните цикл
Двенадцать шагов, три цикла, три gate.
Search loop производит источники. Ranking loop превращает источники в корпус, помещающийся в budget. Fact loop превращает черновик в три списка. А unsourced-список из шага 12 становится claims в шаге 1 — и всё начинается снова, уже острее.
Обратите внимание, где стоят gates: в конце каждого цикла. Saturation завершает поиск, budget завершает ранжирование, citation завершает проверку. Цикл без gate — это просто агент, который быстро соглашается сам с собой.
Два года AI research tools продавали скорость. Прочитать сто источников за минуту. Суммаризировать статью за секунду. Эта фаза заканчивается, потому что выяснилось: агент, который прочитал сто источников и не может сказать, какой именно он цитирует, не сэкономил вам работу. Он перенёс её вниз по потоку — к вам, ровно в тот момент, когда вы перестали смотреть.
Преимущество не в чтении. Преимущество — в отказе.
Выберите один gate, который вы ещё не используете. Скорее всего, citation gate: он занимает одиннадцать строк и ловит именно то, что реально может навредить. Внедрите его сегодня. Потом следующий.