12 步搭建研究智能体:搜索循环、来源排序、事实把关

Build a research agent in 12 steps: search loops, source ranking, fact gates

中文译文 · 14k 字

一句话摘要

12 步搭建研究智能体的方法

用 12 步构建一个研究代理:搜索回路、来源排序、事实闸门 2026 年 7 月 13 日 · 12 分钟阅读 · 查看原文 ↗ AI LinkedIn 我见过的每一个失败的代理,都不是因为它笨。它失败,是因为回路里没有任何东西被允许说「不」。 这就是全部问题。一个研究代理搜索、阅读、总结,然后递给你一段话。这段话很流畅。这段话里有一句引用。那句引用并不存在。回路里从没有哪个人被分派去检查。 我不再试图让模型更小心。我开始构建那个能抓住它的回路。 这是一份 12 步路线图,通往一个拒绝发布自己幻觉的研究代理。不用框架。一个 while 循环、一个打分函数,和三道闸门。 开始吧…… 回路 1 —— 搜索回路 - 写一个主张,而不是一个主题 大多数研究代理被交给一个主题。「研究 AI 代理的可靠性。」那不是一个问题,而是一个方向,而一个被指向某个方向的代理会一直走,直到用完 token。 一个可研究的问题,是一个你可能犯错的问题。写下主张,然后写下那个能杀死它的东西。 那个模糊词集合是一件钝器,而且它管用。如果你的问题里包含「最好」,你不是在研究,你是在购物。 在你写代理的任何一行之前,先跑这个过滤器:如果没有任何结果能改变答案,那这个代理就无事可做。 - 把一个主张扩成四个查询 一个问题,四个角度。天真的代理会搜索它预期能找到的东西,而搜索引擎极其擅长给你你预期的东西。 四行,而第三行干了绝大部分活。没有那个反驳查询,你的代理会找到十五篇互相认同的文章,它们全都引用同一篇预印本,然后报告一个并不存在的共识。 一个只搜索确认的代理,不是在研究。它是在装点一个你早就有的结论。 - 在饱和时停,而不是在数量上停 这是第一道闸门。 几乎每个代理都在 N 个结果之后停。N 是一个有人随手敲进去的魔法数字。正确的停止条件是信息性的:当新结果不再携带新信息时停。 连续两批的新颖度低于 15%,搜索就结束。有时它在第二个查询就触发。有时它永远不触发,而你因此得知这个问题比你想的更大——这本身就是一个发现。 这道闸门不是为了省 token。它是为了让代理知道「广泛阅读」和「原地打转」之间的区别。 - 先规范化,再去重 五个结果,一个事实。其中三个是同一篇新闻稿,标题不同。 剥掉跟踪参数、去掉 www、规范化结尾斜杠、做哈希。然后用标题抓近似重复。 这是全文最不光彩的一个函数,但它对你的输出的改变,超过你将来写下的任何提示词。 转载不是佐证。十家媒体跑同一条通讯社稿子,是一个来源戴着十顶帽子。 回路 2 —— 来源排序 - 分类:一手、二手、聚合 在你给一个来源打分之前,你得先知道它是哪种东西。三个层级,而且边界并不微妙。 是的,这份清单是有立场的。是的,你的清单会不一样。这正是重点:清单是一个政策,不是一个事实,所以它该待在你的代码里,好让你能跟它争辩。 注意,这里 Substack 是聚合器,我的也是。一个把我的通讯当成一手来源的代理,是一个我不会把自己的写作托付给它的代理。 - 给层级、时效、数据、独立性打分 光层级是一个糟糕的排序器。一篇三年前的、里面没有数字的一手来源,会输给一篇最近发表的、公布了样本量的二手来源。 四个轴,一个数字。 独立性那一项是隐藏的杀招。一个域名返回六个结果,不该拿六票。除以它自己的出现频率,一个吵闹的网站自己就会安静下来。 缺失发布日期得 0.5 分,而不是 0 分。一个无日期来源是不确定的,不是一文不值的。把不确定编码成不确定。 - 剪枝到一个上下文预算 第二道闸门。 Top-k 是错的抽象。模型没有一个放五个来源的槽位,它有一个按字符计量的窗口,而一篇又长又低排名的来源,会吃掉一篇又短又高排名的来源需要的空间。 注意这里是 continue 而不是 break。一个装不下的来源是被跳过,而不是终止。下一个可能就够小了。 按质量排序,按成本准入。两个不同的问题,两个不同的函数。 - 记录冲突,绝不平摊 这是大多数研究代理犯下它们真正罪过的地方。它们找到两个互相矛盾来源,然后产出一句各打五十大板的句子。没有人说过那句话。它是一具用真实零件拼出来的幻觉。 粗糙的关键词匹配。它把那一对浮出来,然后由你——或者第二次模型过一遍——决定那个分歧意味着什么。它永远不做的是悄悄把分歧消解掉。 来源之间的分歧,是你的代理能找到的最有价值的东西。一个把它藏起来的代理,已经摧毁了唯一值得拥有的信息。 回路 3 —— 事实闸门 - 把草稿原子化成主张 你没法核实一段话。你可以核实一个句子。 两行。这不是系统里复杂的那部分,而它是让后面每一步都成为可能的那个步骤。核实运作在足够小、能判真假的单位上。 - 把每个主张绑定到一个来源 每个句子要么拿到一个来源,要么拿到一个标签。没有第三种选项,也没有无罪的推定。 0.30 这个下限是一个旋钮。把它调高,真陈述会被标记为无来源。把它调低,代理会开心地把「天是蓝的」绑到一篇讲半导体的论文上。 在你自己的草稿上调一次,然后把数字记下来。 - 把引用对着正文把关 第三道闸门,也是我最在乎的一道。 一个模型编造事实,不像它编造措辞那么频繁。它读三篇关于某位研究者的文章,吸收大意,然后产出一个带引号的、从没人说过的句子。事实活了下来。归属是虚构的。 精确子串匹配。不是模糊的,不是语义的。如果那个字符串不在来源里,这个句子就不发。 - 交付未核实台账 最后一步,是把管线变成回路的那一步。 不要丢弃那些被标记的句子。收集它们。无法核实的东西,就是你「还不知道什么」的地图,也是下一次遍历的输入。 三种状态,不是两种。rejected 是编造,它永不发出。unsourced 可能是真的,它只在带标签的情况下发出。sourced 挣到了它的位置。 把 unsourced 清单作为新主张喂回第 1 步。那是第五拍。那才是回路。 这些闸门到底抓住了什么 我拿一个刻意的陷阱跑过它:一份草稿,包含一个来源良好的统计、一个被争议的发现、一个没有任何来源支持的自信概括,以及一句我编造的引用。 看最后那两行。 大多数团队根本不量这个。这句话正是每一篇 AI 文章开头那种统计的形状。十之有九。百分之九十的团队。听起来像数据。它是一种情绪。FLAG。 一位研究者把它称作「代理回路的静默崩塌」。我在跑脚本前三十秒才编出来这句话,而闸门在完全不了解世界的情况下抓住了它。它只是问,那个字符串在不在来源里。STOP。 这两句本来都会发出去。它们谁都不是智力的失败。 闭合回路 十二步,三个回路,三道闸门。 搜索回路产出来源。排序回路把来源变成一个预算规模的语料库。事实回路把一份草稿变成三份清单。而第 12 步的 unsourced 清单,变成了第 1 步的主张,这就是整件事重新开始的地方——更锋利。 注意闸门坐在哪里:每个回路的末尾。饱和终结搜索,预算终结排序,引用终结核实。一个没有闸门的回路,只是一个代理在高速地自我认同。 两年来,AI 研究工具的卖点一直是速度。一分钟读一百个来源。一秒钟总结一篇论文。那个阶段正在结束,因为事实证明,一个读了一百个来源、却说不清自己引的是哪一个的代理,根本没给你省下任何工作。它把工作挪到了下游、挪给了你,而且恰好是在你不再盯着的那一刻。 优势不是阅读。优势是拒绝。 挑一道你没在跑的闸门。大概是引用闸门,因为它只要十一行,而且抓的是真正会伤到你的东西。今天就把它发上去。然后是下一道。 ## 提示词 ``` @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 ``` ``` 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] ``` ``` 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] ``` ``` 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 ``` ``` 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" ``` ``` 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]] ``` ``` 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 None ``` ``` 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:]) ``` ``` 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, ) ``` ``` 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) ``` ``` 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() ``` ``` 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" ``` ``` 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 ``` 标签:# X # AI # Linkedin 相关文章 Clay's Full LinkedIn GTM Playbook ($3.1B Valuation) Grok Bot + Kimi K3. Here's how to build an AI team that generates $100k/month. How To Build a One-Person Company Using Grok Bot

原文参考:https://maxed.wiki/posts/build-a-research-agent-in-12-steps-search-loops-source-ranking-fact-gates/ (Maxed.wiki,本页为站内中文整理)