详细教程:自托管 SearXNG 加搜索网关解决 Agent 重复搜索、API 烧钱和幻觉引文三大痛点,2GB RAM 即可运行。
本周 Hacker News 上,一篇关于 Web Search API 的文章拿到了近 500 个 upvotes。评论区主要吐槽三点:按查询收费太贵、rate limit 卡脖子、还有 Agent "编造" 引用来源。我自己给团队做过三个内部 Agent:查 changelog 的、总结 CVE 的、回答 vendor 文档问题的。这三个都需要联网搜索。有过几次 agent 在循环里重复调用 search、账单暴涨的经历之后,我转向了自托管架构,加了几层保护层。这篇文章就来分享这套方案。简单到在一台 2GB 内存的 VPS 上就能跑起来。
最常见的错误是直接给 LLM 一个 search(query) 工具去调外部 API。Agent 没有"省钱"这个概念。它完全可能在一次 session 里把同一个问题搜 5 遍,或者遇到难题时一口气搜 30 个差不多的 query。
所以我在中间加了一层 search gateway,专门干四件事:缓存、限速、抓取内容、检查引用。
flowchart LR
A[LLM Agent] -->|tool call| B[Search Gateway]
B --> C{Cache hit?}
C -->|yes| B
C -->|no| D[Rate Limiter]
D --> E[SearXNG self-hosted]
E --> F[Google / Bing / DDG / Wikipedia]
B --> G[Fetcher + trafilatura]
G --> H[Citation Checker]
H --> A
SearXNG 是一个开源的元搜索引擎,聚合多个搜索引擎的结果并返回 JSON。你不需要按 query 付费,代价是上游引擎在调用过密时会封 IP——rate limiter 就是用来处理这个问题的。
SearXNG 默认只返回 HTML,所以要在 settings.yml 里开启 JSON 格式输出。很多人卡在这一步:直接传 format=json 收到 403 Forbidden,不知道为什么。
mkdir -p ~/searxng/config && cd ~/searxng
# 创建最小化配置,开启 JSON 输出
cat > config/settings.yml <<'EOF'
use_default_settings: true
server:
secret_key: "$(openssl rand -hex 32)"
limiter: false
bind_address: "0.0.0.0"
search:
formats:
- html
- json
safe_search: 0
EOF
# 替换真正的 secret_key(因为 heredoc 用了 'EOF' 不会自动展开)
sed -i "s|\$(openssl rand -hex 32)|$(openssl rand -hex 32)|" config/settings.yml
docker run -d --name searxng \
-p 127.0.0.1:8888:8080 \
-v $(pwd)/config:/etc/searxng \
--restart unless-stopped \
searxng/searxng:latest
# 测试
curl -s 'http://127.0.0.1:8888/search?q=python+3.13+release&format=json' \
| jq '.results[:3] | .[] | {title, url}'
注意:绑定在 127.0.0.1 上,不暴露到外网。一个公网的 SearXNG 实例如果没有 limiter,几天内就会被 bots 滥用,你的 IP 也会被上游引擎列入黑名单。
Gateway 用 Python 3.12 写,依赖 httpx 0.27 和 diskcache 5.6。没上 Redis,因为几个内部 agent 用磁盘缓存完全够用,还不用额外部署服务。
import hashlib, time, threading
import httpx
from diskcache import Cache
SEARX_URL = 'http://127.0.0.1:8888/search'
cache = Cache('./search_cache')
class TokenBucket:
def __init__(self, rate_per_min: int):
self.capacity = rate_per_min
self.tokens = rate_per_min
self.refill = rate_per_min / 60.0
self.last = time.monotonic()
self.lock = threading.Lock()
def acquire(self):
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.refill)
self.last = now
if self.tokens < 1:
wait = (1 - self.tokens) / self.refill
time.sleep(wait)
self.tokens = 0
else:
self.tokens -= 1
bucket = TokenBucket(rate_per_min=20)
def normalize(q: str) -> str:
return ' '.join(q.lower().split())
def search(query: str, max_results: int = 5, ttl: int = 6 * 3600) -> list[dict]:
key = 'q:' + hashlib.sha256(normalize(query).encode()).hexdigest()
if (hit := cache.get(key)) is not None:
return hit
bucket.acquire()
r = httpx.get(SEARX_URL, params={'q': query, 'format': 'json'}, timeout=15)
r.raise_for_status()
results = [
{'title': x.get('title'), 'url': x['url'], 'snippet': x.get('content', '')}
for x in r.json().get('results', [])[:max_results]
]
cache.set(key, results, expire=ttl)
return results
有几个值得注意的细节。normalize() 函数让 "Python 3.13 Release" 和 "python 3.13 release" 共用同一份缓存。根据我的实际日志,仅此一项就让缓存命中率从约 18% 升到了约 35%。TTL 6 小时适合技术类问题。如果你的 agent 是查新闻的,可以降到 15–30 分钟。
还应该再设一层 session 级别的 budget,比如每个用户问题最多搜 8 次。Budget 用完时,tool 返回 "Search budget exhausted, answer with what you have"。现代 LLM 对这个提示理解得很好,会主动停止循环。
搜索引擎返回的 snippet 通常只有一两句话,不够回答问题。Agent 需要读取页面正文,但直接把原始 HTML 塞进 context 既费 token 又有噪音。我用 trafilatura 1.12 来提取主体内容。
更关键的是 citation check 环节。LLM 生成带引用的回答后,gateway 会验证这些引用是否真的出现在来源页面里。
participant Agent
participant Gateway
participant Web
Agent->>Gateway: fetch(url)
Gateway->>Web: GET url
Web-->>Gateway: HTML
Gateway-->>Agent: clean text (max 4000 chars)
Agent->>Gateway: verify(answer, citations)
Gateway-->>Agent: invalid citations list
Agent->>Agent: rewrite or drop claim
import trafilatura
from rapidfuzz import fuzz
def fetch(url: str, max_chars: int = 4000) -> str:
key = 'p:' + hashlib.sha256(url.encode()).hexdigest()
if (hit := cache.get(key)) is not None:
return hit
html = trafilatura.fetch_url(url)
text = trafilatura.extract(html, include_comments=False) or ''
text = text[:max_chars]
cache.set(key, text, expire=24 * 3600)
return text
def verify_citations(citations: list[dict], threshold: int = 85) -> list[dict]:
"""citations: [{'url': ..., 'quote': ...}]"""
bad = []
for c in citations:
page = fetch(c['url'], max_chars=20000)
score = fuzz.partial_ratio(c['quote'].lower(), page.lower())
if score < threshold:
bad.append({**c, 'score': score})
return bad
我用的是 rapidfuzz 3.x 的 partial_ratio,而不是精确匹配,因为 LLM 常会轻微改动标点或空格。阈值 85 是我在约 200 条回答上测试后得出的。阈值再低一点,编造的引用就会漏进来;再高一点,合法的引用会被误判。
当 verify_citations 返回非空列表时,我会把它连同指示一起打回给 LLM:"以下引用未在来源中找到,请删除或修正相应 claim。"通常一轮就能让回答变干净。
上游引擎会封你。 Google 是封得最快的。在 settings.yml 里多配置几个引擎做备选:DuckDuckGo、Brave、Wikipedia、Stack Overflow。看日志 docker logs searxng:出现 CAPTCHA 或 suspended 就是该降速的信号。
通过网页内容做 prompt injection 是真实存在的。 网页里可能包含 "ignore previous instructions" 这样的句子。把 fetch 回来的内容包在明确的 delimiter 里,比如 <untrusted_content>...</untrusted_content>,并在 system prompt 里说明这是数据而非指令。绝对不要让 agent 既有读取网页权限又有执行 shell 权限,中间不经过确认步骤。
记录所有 query。 把 query、cache hit/miss 和延迟写入 JSONL 文件。一周后你就能看出 agent 常搜什么,可以把相关知识直接塞进 system prompt,大幅减少搜索次数。
尊重 robots.txt,不要狂爬。 Fetcher 只应该拿 agent 真正需要的 URL,不要递归跟链接。
Web search 是你能给 LLM Agent 的最有用工具,也是最容易让你烧钱或砸牌子的——如果 agent 乱引用的活。现在就做起来:
在 agent 和互联网之间架一层 gateway,别让 LLM 直接调搜索 API。
自托管 SearXNG(searxng/searxng:latest),绑定在 localhost,记得开启 formats: [json]。
缓存经过 normalize 的 query,TTL 按内容类型选:技术文档 6 小时,新闻 15–30 分钟。
用 token bucket 配合 session 级 budget 阻止无限循环搜索。
在把回答返回给用户之前,用 fuzzy match 检查引用。
把网页内容当作不可信输入,和你对待用户表单输入的方式一样。
整套方案不到 150 行 Python 加一个 Docker 容器。对小团队的需求来说,它跑得很稳,搜索成本接近于零。先从这里起步,等日志数据告诉你确实需要了,再切换到付费 API。