Giving Your LLM Agent Web Search Without Burning Money: Self-hosted SearXNG, Caching and Citation Checks
Tuần này trên Hacker News, một bài về Web Search API đạt gần 500 điểm. Phần bình luận chủ yếu xoay quanh ba nỗi khổ: chi phí theo từng query, rate limit và chuyện agent "bịa" nguồn trích dẫn. Mình đã làm vài agent nội bộ cho team: agent tra changelog thư viện, agent tóm tắt CVE và agent trả lời câu hỏi về tài liệu vendor. Cả ba đều cần web search. Sau vài lần nhận hóa đơn API tăng vọt chỉ vì agent tự gọi search trong vòng lặp, mình chuyển sang kiến trúc tự host và thêm vài lớp bảo vệ. Bài này chia sẻ setup đó. Nó đủ đơn giản để một dev chạy trên VPS 2GB RAM.
Kiến trúc tổng quan: đừng cho agent gọi thẳng ra internet
Sai lầm phổ biến nhất là đưa cho LLM một tool search(query) gọi thẳng tới API bên ngoài. Agent không có khái niệm tiết kiệm. Nó sẵn sàng search lại cùng một câu 5 lần trong một session, hoặc search 30 query gần giống nhau khi gặp câu hỏi khó.
Vì vậy mình đặt một lớp search gateway ở giữa, lo bốn việc: cache, rate limit, fetch nội dung và kiểm tra trích dẫn.
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 là metasearch engine mã nguồn mở. Nó gom kết quả từ nhiều engine và trả về JSON. Bạn không phải trả tiền theo query. Đổi lại, bạn phải chấp nhận các engine upstream thỉnh thoảng chặn IP nếu gọi quá dày, và rate limiter sinh ra để xử lý đúng chuyện này.
## Bước 1: Dựng SearXNG bằng Docker trong 5 phút
Mặc định SearXNG chỉ trả HTML, nên bạn phải bật format JSON trong `settings.yml`. Đây là chỗ nhiều người bị kẹt: gọi `format=json` liền nhận `403 Forbidden` mà không hiểu vì sao.
```bash
mkdir -p ~/searxng/config && cd ~/searxng
# Tạo settings tối thiểu, bật JSON output
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
# Thay secret_key thật (heredoc dùng 'EOF' nên không tự expand)
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
# Test
curl -s 'http://127.0.0.1:8888/search?q=python+3.13+release&format=json' \
| jq '.results[:3] | .[] | {title, url}'
```
Lưu ý: mình bind vào `127.0.0.1`, không mở ra ngoài. Một instance SearXNG public không có limiter sẽ bị bot dùng ké trong vài ngày, và IP của bạn sẽ bị các engine upstream blacklist.
## Bước 2: Search gateway với cache và rate limit
Gateway viết bằng Python 3.12, dùng `httpx` 0.27 và `diskcache` 5.6. Mình không dùng Redis vì với vài agent nội bộ thì disk cache là quá đủ, lại không cần thêm service.
```python
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
```
Có vài chi tiết đáng để ý. Hàm `normalize()` giúp `"Python 3.13 Release"` và `"python 3.13 release"` dùng chung cache. Trên log thực tế của mình, chỉ riêng việc này đã giúp cache hit rate tăng từ khoảng 18% lên khoảng 35%. TTL 6 tiếng hợp với câu hỏi kỹ thuật. Nếu agent của bạn tra tin tức thì hạ xuống 15–30 phút.
Bạn cũng nên đặt thêm một **budget theo session**, ví dụ tối đa 8 lần search cho mỗi câu hỏi của user. Khi hết budget, tool trả về chuỗi `"Search budget exhausted, answer with what you have"`. LLM hiện đại hiểu thông điệp này khá tốt và sẽ dừng vòng lặp.
## Bước 3: Fetch nội dung và chặn trích dẫn bịa
Snippet từ search engine thường chỉ dài 1–2 câu và không đủ để trả lời. Agent cần đọc nội dung trang, nhưng đưa nguyên HTML vào context thì vừa tốn token vừa nhiễu. Mình dùng `trafilatura` 1.12 để trích phần nội dung chính.
Phần quan trọng hơn là **citation check**. Sau khi LLM viết câu trả lời kèm trích dẫn, gateway kiểm tra xem đoạn được trích có thực sự xuất hiện trong trang nguồn hay không.
```mermaid
sequenceDiagram
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
```
```python
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
```
Mình dùng `rapidfuzz` 3.x với `partial_ratio` thay vì so khớp chính xác, vì LLM hay sửa nhẹ dấu câu hoặc khoảng trắng. Ngưỡng 85 là con số mình rút ra sau khi chạy thử trên khoảng 200 câu trả lời. Ngưỡng thấp hơn thì lọt trích dẫn bịa, còn cao hơn thì đánh rớt trích dẫn hợp lệ.
Khi `verify_citations` trả về danh sách không rỗng, mình đưa lại cho LLM kèm chỉ thị: "Những trích dẫn sau không tìm thấy trong nguồn, hãy bỏ hoặc sửa claim tương ứng." Thường chỉ cần một vòng là câu trả lời sạch.
## Những bài học xương máu khi chạy production
- **Upstream engine sẽ chặn bạn.** Google là engine chặn nhanh nhất. Trong `settings.yml`, hãy bật thêm các engine như DuckDuckGo, Brave, Wikipedia và Stack Overflow để có fallback. Theo dõi log `docker logs searxng`: thấy `CAPTCHA` hoặc `suspended` là lúc cần giảm rate.
- **Prompt injection qua nội dung web là có thật.** Trang web có thể chứa câu như "ignore previous instructions". Hãy bọc nội dung fetch về trong delimiter rõ ràng, ví dụ `<untrusted_content>...</untrusted_content>`, và nói rõ trong system prompt rằng đây là dữ liệu, không phải lệnh. Tuyệt đối đừng cho agent vừa đọc web vừa có quyền chạy shell mà không qua bước xác nhận.
- **Log mọi query.** Ghi query, cache hit/miss và latency vào một file JSONL. Sau một tuần, bạn sẽ thấy agent hay search những gì và có thể đưa kiến thức đó vào system prompt để bớt hẳn số lần search.
- **Tôn trọng `robots.txt` và đừng crawl dồn dập.** Fetcher chỉ nên lấy những URL agent thực sự cần, không đi theo link đệ quy.
## Kết luận
Web search là tool hữu ích nhất bạn có thể đưa cho một LLM agent, và cũng là tool dễ làm bạn tốn tiền hoặc mất uy tín nhất nếu agent trích dẫn bừa. Những việc nên làm ngay:
1. **Đặt một gateway ở giữa** agent và internet, đừng để LLM gọi thẳng search API.
2. **Tự host SearXNG** (`searxng/searxng:latest`), bind vào localhost và nhớ bật `formats: [json]`.
3. **Cache với query đã normalize** và chọn TTL theo loại nội dung: 6 tiếng cho tài liệu kỹ thuật, 15–30 phút cho tin tức.
4. **Dùng token bucket cùng budget theo session** để chặn vòng lặp search vô tận.
5. **Kiểm tra trích dẫn bằng fuzzy match** trước khi trả câu trả lời cho user.
6. **Coi nội dung web là untrusted input**, giống hệt cách bạn đối xử với form input của user.
Toàn bộ setup trên chưa tới 150 dòng Python và một container Docker. Với nhu cầu của một team nhỏ, nó chạy ổn định mà chi phí search gần như bằng 0. Hãy bắt đầu từ đây, rồi chỉ chuyển sang API trả phí khi số liệu trong log cho thấy bạn thực sự cần.
All rights reserved