我最后把链路拆成三层:Hermes 只访问本机 SearXNG;SearXNG 在配置里显式把 HTTP/HTTPS 出站请求交给 Mihomo;Mihomo 单独负责订阅、健康检查和自动选节点。这样做的好处不是“配置更酷”,而是出问题时可以分层看:Hermes 有没有请求本地搜索,SearXNG 有没有返回 JSON,Mihomo 有没有可用节点,订阅本身有没有解析失败。
整体链路是这样:
Hermes Agent
-> http://localhost:8888
-> SearXNG
-> http://mihomo:7890
-> Mihomo 自动选择可用节点
-> 海外搜索引擎
宿主机上只暴露本机端口:
127.0.0.1:8888 -> SearXNG
127.0.0.1:7897 -> Mihomo mixed proxy,用于调试
127.0.0.1:9097 -> Mihomo controller,用于本机管理
SearXNG 和 Mihomo 在同一个 Docker 网络里,所以 SearXNG 不访问宿主机的 127.0.0.1:7897,而是访问容器名:
http://mihomo:7890
SearXNG 里最容易漏的两处
Hermes 调 SearXNG 时会请求 JSON。SearXNG 官方 Search API 文档也写得很清楚:format 参数能不能用,取决于 settings.yml 里 search.formats 是否启用;请求未启用的格式会返回 403。所以这里不能只保留默认 HTML:
search:
safe_search: 0
autocomplete: ""
formats:
- html
- json
第二处是代理。不要只指望 Docker 环境变量或系统级 http_proxy 被应用层稳定继承。SearXNG 的请求是它自己发起的,最直接的办法是在 outgoing.proxies 里写清楚:
outgoing:
request_timeout: 15.0
max_request_timeout: 40.0
extra_proxy_timeout: 10
proxies:
"http://": "http://mihomo:7890"
"https://": "http://mihomo:7890"
SearXNG 默认配置示例里能看到 all://: 这种写法,所以它不是一个天然错误的配置项。但在我这次使用的环境里,all://: 没有按预期匹配,最后稳定下来的是把 http:// 和 https:// 分开写。文章里保留这个边界,是为了避免把一次实测写成所有版本的定论。
完整的 SearXNG 关键配置可以压到下面这样:
use_default_settings: true
general:
instance_name: "Hermes SearXNG"
search:
safe_search: 0
autocomplete: ""
formats:
- html
- json
server:
secret_key: "请替换成随机密钥"
limiter: false
image_proxy: true
bind_address: "0.0.0.0"
valkey:
url: valkey://valkey:6379/0
outgoing:
request_timeout: 15.0
max_request_timeout: 40.0
extra_proxy_timeout: 10
proxies:
"http://": "http://mihomo:7890"
"https://": "http://mihomo:7890"
engines:
- name: bing
disabled: false
shortcut: bi
- name: bing news
disabled: false
shortcut: bin
- name: google
disabled: false
shortcut: go
timeout: 8.0
- name: brave
disabled: false
shortcut: br
timeout: 8.0
- name: duckduckgo
disabled: false
shortcut: ddg
timeout: 8.0
- name: startpage
disabled: false
shortcut: sp
timeout: 8.0
- name: wikipedia
disabled: false
shortcut: wp
- name: arxiv
disabled: false
shortcut: arx
- name: sogou
disabled: true
- name: 360search
disabled: true
ui:
static_use_hash: true
这里有一个小坑:SearXNG 默认引擎配置里,Bing 新闻的 name 是 bing news,engine 才是 bing_news。在 use_default_settings: true 下,engines 是按 name 合并覆盖的,所以这里要写 name: bing news,不要写成 name: bing_news。
Mihomo 只做一件事:给 SearXNG 一个稳定出口
Mihomo 这边不需要接管整台服务器,也不需要让 Docker 全局走代理。它只开一个 mixed port,让同一个 Docker 网络里的 SearXNG 能访问:
mixed-port: 7890
allow-lan: true
bind-address: '*'
mode: rule
log-level: info
ipv6: false
external-controller: 0.0.0.0:9090
secret: "请替换成随机密钥"
profile:
store-selected: true
store-fake-ip: true
proxy-providers:
sub:
type: http
url: "你的订阅地址"
interval: 3600
path: ./proxy_providers/sub.yaml
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 300
timeout: 5000
lazy: false
expected-status: 204
proxy-groups:
- name: AUTO
type: url-test
use:
- sub
url: https://www.gstatic.com/generate_204
interval: 300
timeout: 5000
tolerance: 50
lazy: false
expected-status: 204
- name: PROXY
type: select
proxies:
- AUTO
- DIRECT
use:
- sub
rules:
- MATCH,PROXY
这份配置的意思很窄:订阅每 3600 秒更新一次,节点每 300 秒做一次健康检查,AUTO 用 url-test 按延迟选择节点,所有流量最后匹配到 PROXY。新配置第一次启动时,PROXY 列表里第一个就是 AUTO;如果以后你在控制面板里手动切过节点,store-selected: true 会保留选择,这时“默认走 AUTO”就不一定成立了。
还要注意订阅格式。proxy-providers 适合 provider 格式或能被 Mihomo 作为 provider 解析的订阅;有些服务商给的是完整 Clash 配置,有些给的是节点列表。如果日志里报解析失败,不要先怀疑 SearXNG,先去服务商后台切成 Clash Meta、Mihomo 或 Proxy Provider 格式。
改造脚本
下面这个脚本适合已经把 SearXNG 放在 /opt/searxng 的机器。它会备份现有 docker-compose.yml 和 searxng/settings.yml,复用本机已有的 metacubex/mihomo 镜像,并把 SearXNG、Valkey、Mihomo 放进同一个 compose 项目。
脚本不会主动拉取 Mihomo 镜像;SearXNG 和 Valkey 如果本机没有对应镜像,docker compose up 是否能启动取决于你的本机镜像和网络环境。完全离线环境里,先把三个镜像都准备好。
sudo bash -s <<'EOF'
set -euo pipefail
APP_DIR="/opt/searxng"
COMPOSE_FILE="$APP_DIR/docker-compose.yml"
SETTINGS_FILE="$APP_DIR/searxng/settings.yml"
MIHOMO_DIR="$APP_DIR/mihomo"
if [ ! -f "$COMPOSE_FILE" ]; then
echo "未找到 $COMPOSE_FILE,请确认 SearXNG 是否部署在 /opt/searxng"
exit 1
fi
if [ ! -f "$SETTINGS_FILE" ]; then
echo "未找到 $SETTINGS_FILE,请确认 SearXNG settings.yml 路径"
exit 1
fi
if docker image inspect metacubex/mihomo:latest >/dev/null 2>&1; then
MIHOMO_IMAGE="metacubex/mihomo:latest"
else
MIHOMO_IMAGE="$(docker images --format '{{.Repository}}:{{.Tag}}' \
| awk -F: '$1=="metacubex/mihomo" && $2!="<none>" {print; exit}')"
fi
if [ -z "${MIHOMO_IMAGE:-}" ]; then
echo "未检测到可用的本地 metacubex/mihomo 镜像。"
echo "请先确认:docker images | grep -i mihomo"
exit 1
fi
echo "检测到本地 Mihomo 镜像:$MIHOMO_IMAGE"
LOCAL_MIHOMO_IMAGE="searxng-mihomo-local:latest"
docker tag "$MIHOMO_IMAGE" "$LOCAL_MIHOMO_IMAGE"
printf "请输入你的 Clash/Mihomo 订阅 URL: " > /dev/tty
IFS= read -r -s SUB_URL < /dev/tty
printf "\n" > /dev/tty
if [ -z "$SUB_URL" ]; then
echo "订阅 URL 为空,退出。"
exit 1
fi
mkdir -p "$MIHOMO_DIR/proxy_providers"
chmod 700 "$MIHOMO_DIR"
TS="$(date +%Y%m%d-%H%M%S)"
cp -a "$COMPOSE_FILE" "$COMPOSE_FILE.bak.$TS"
cp -a "$SETTINGS_FILE" "$SETTINGS_FILE.bak.$TS"
echo "已备份:"
echo " $COMPOSE_FILE.bak.$TS"
echo " $SETTINGS_FILE.bak.$TS"
MIHOMO_SECRET="$(openssl rand -hex 16)"
cat > "$MIHOMO_DIR/config.yaml" <<YAML
mixed-port: 7890
allow-lan: true
bind-address: '*'
mode: rule
log-level: info
ipv6: false
external-controller: 0.0.0.0:9090
secret: "$MIHOMO_SECRET"
profile:
store-selected: true
store-fake-ip: true
proxy-providers:
sub:
type: http
url: "$SUB_URL"
interval: 3600
path: ./proxy_providers/sub.yaml
health-check:
enable: true
url: https://www.gstatic.com/generate_204
interval: 300
timeout: 5000
lazy: false
expected-status: 204
proxy-groups:
- name: AUTO
type: url-test
use:
- sub
url: https://www.gstatic.com/generate_204
interval: 300
timeout: 5000
tolerance: 50
lazy: false
expected-status: 204
- name: PROXY
type: select
proxies:
- AUTO
- DIRECT
use:
- sub
rules:
- MATCH,PROXY
YAML
chmod 600 "$MIHOMO_DIR/config.yaml"
OLD_SECRET="$(grep -E '^[[:space:]]*secret_key:' "$SETTINGS_FILE" | head -n1 | sed -E "s/.*secret_key:[[:space:]]*['\"]?([^'\"]+)['\"]?.*/\1/" || true)"
if [ -z "$OLD_SECRET" ] || [ "$OLD_SECRET" = "secret_key:" ]; then
OLD_SECRET="$(openssl rand -hex 32)"
fi
cat > "$SETTINGS_FILE" <<YAML
use_default_settings: true
general:
instance_name: "Hermes SearXNG"
search:
safe_search: 0
autocomplete: ""
formats:
- html
- json
server:
secret_key: "$OLD_SECRET"
limiter: false
image_proxy: true
bind_address: "0.0.0.0"
valkey:
url: valkey://valkey:6379/0
outgoing:
request_timeout: 15.0
max_request_timeout: 40.0
extra_proxy_timeout: 10
proxies:
"http://": "http://mihomo:7890"
"https://": "http://mihomo:7890"
engines:
- name: bing
disabled: false
shortcut: bi
- name: bing news
disabled: false
shortcut: bin
- name: google
disabled: false
shortcut: go
timeout: 8.0
- name: brave
disabled: false
shortcut: br
timeout: 8.0
- name: duckduckgo
disabled: false
shortcut: ddg
timeout: 8.0
- name: startpage
disabled: false
shortcut: sp
timeout: 8.0
- name: wikipedia
disabled: false
shortcut: wp
- name: arxiv
disabled: false
shortcut: arx
- name: sogou
disabled: true
- name: 360search
disabled: true
ui:
static_use_hash: true
YAML
cat > "$COMPOSE_FILE" <<YAML
services:
searxng:
image: docker.io/searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "127.0.0.1:8888:8080"
volumes:
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
- searxng-cache:/var/cache/searxng:rw
depends_on:
- valkey
- mihomo
networks:
- searxng-net
logging:
driver: json-file
options:
max-size: "2m"
max-file: "3"
valkey:
image: docker.io/valkey/valkey:8-alpine
container_name: searxng-valkey
restart: unless-stopped
command: valkey-server --save 30 1 --loglevel warning
volumes:
- valkey-data:/data
networks:
- searxng-net
logging:
driver: json-file
options:
max-size: "2m"
max-file: "3"
mihomo:
image: $LOCAL_MIHOMO_IMAGE
container_name: searxng-mihomo
restart: unless-stopped
command: ["-d", "/root/.config/mihomo"]
ports:
- "127.0.0.1:7897:7890"
- "127.0.0.1:9097:9090"
volumes:
- ./mihomo:/root/.config/mihomo
networks:
- searxng-net
logging:
driver: json-file
options:
max-size: "2m"
max-file: "3"
networks:
searxng-net:
volumes:
searxng-cache:
valkey-data:
YAML
cd "$APP_DIR"
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
echo "未检测到 docker compose / docker-compose"
exit 1
fi
echo
echo "启动服务..."
$DC up -d --no-build
echo
echo "等待服务启动..."
sleep 12
echo
echo "容器状态:"
$DC ps
echo
echo "Mihomo 最近日志:"
$DC logs --tail=80 mihomo || true
echo
echo "测试 Mihomo 本机代理端口 127.0.0.1:7897:"
curl -I -sS --max-time 25 --proxy http://127.0.0.1:7897 https://www.gstatic.com/generate_204 || true
echo
echo
echo "测试 SearXNG JSON 搜索:"
curl -sS --max-time 50 "http://127.0.0.1:8888/search?q=openai%20gpt&format=json" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print("OK:", len(d.get("results", [])), "results")' || true
echo
echo "完成。"
echo "Hermes 继续使用:SEARXNG_URL=http://localhost:8888"
echo "SearXNG 出站代理:searxng -> http://mihomo:7890"
echo "Mihomo 本机调试代理:http://127.0.0.1:7897"
echo "Mihomo 控制端口:http://127.0.0.1:9097"
echo "Mihomo 配置文件:$MIHOMO_DIR/config.yaml"
EOF
Hermes 只保留搜索入口
Hermes 侧不用知道 Mihomo。它只需要知道本机有一个 SearXNG:
nano ~/.hermes/.env
写入:
SEARXNG_URL=http://localhost:8888
然后在 ~/.hermes/config.yaml 里指定搜索后端:
web:
search_backend: "searxng"
如果你装的是 Hermes 官方 SearXNG skill,可以继续使用:
hermes skills install official/research/searxng-search
如果 Hermes 是 user service:
systemctl --user restart hermes
systemctl --user status hermes --no-pager
这里还有一个边界:SearXNG 负责搜索,不负责网页正文抽取。Hermes 文档里也把 search backend 和 extract backend 分开。如果后续要让 Hermes 读取搜索结果页面正文,还需要再给 web.extract_backend 配 Firecrawl、Tavily、Exa、Parallel 或其他抽取方案。
验证不要跳层
先测 Mihomo,不要一上来就问 Hermes:
curl -I --proxy http://127.0.0.1:7897 https://www.gstatic.com/generate_204
能返回 HTTP 状态,说明宿主机调试代理端口可用。然后测 SearXNG JSON:
curl -s --max-time 50 \
"http://127.0.0.1:8888/search?q=openai%20gpt&format=json" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d.get("results", [])), "results")'
如果这里出现 403,优先看 search.formats 有没有 json。如果这里超时或结果为空,再看 SearXNG 日志:
cd /opt/searxng
docker compose logs -f searxng
然后在 Hermes 里发一条会触发搜索的请求:
请联网搜索 OpenAI GPT 的最新资料,列出来源链接。
日志里如果出现 /search?...format=json,说明 Hermes 已经在调本地 SearXNG。再看 Mihomo:
cd /opt/searxng
docker compose logs -f mihomo
重点看订阅更新、provider、health check、AUTO 这些信息。如果 Mihomo 连订阅都没解析出来,SearXNG 配得再对也没用。
几个不要省掉的边界
第一,不要把端口暴露到公网。本文里的 compose 只绑定本机:
ports:
- "127.0.0.1:8888:8080"
- "127.0.0.1:7897:7890"
- "127.0.0.1:9097:9090"
不要改成:
0.0.0.0:8888:8080
0.0.0.0:7897:7890
0.0.0.0:9097:9090
否则搜索服务、代理端口和 Mihomo 控制端口都有被公网滥用的风险。external-controller: 0.0.0.0:9090 是容器内监听,真正决定外部能不能访问的是 compose 的端口绑定。
第二,如果 Google 或 Startpage 总是超时,不要急着把整套链路推翻。先保留 Bing、Brave、DuckDuckGo、Wikipedia、arXiv,等 Mihomo 订阅和健康检查稳定以后,再打开更容易触发验证码或超时的引擎。
第三,脚本的回滚点很直接:
cd /opt/searxng
cp docker-compose.yml.bak.你的时间戳 docker-compose.yml
cp searxng/settings.yml.bak.你的时间戳 searxng/settings.yml
docker compose up -d
我更倾向于这种拆层方案,而不是给整台服务器套全局代理。全局代理的问题是影响面太大,出了问题以后很难判断是系统环境、Docker、应用配置还是代理节点本身。Hermes、SearXNG、Mihomo 各自只做一件事,排障的时候反而省事。
参考资料
- Hermes Agent:Web Search & Extract
- Hermes Agent:Free meta-search via SearXNG
- SearXNG Search API
- SearXNG settings.yml
- SearXNG outgoing settings
- SearXNG engines settings
- SearXNG 默认 settings.yml
- Mihomo proxy-providers configuration
- Mihomo url-test proxy group
写作附记
这篇保留了原始材料里最有操作价值的部分:分层架构、SearXNG JSON、显式 outgoing proxy、Mihomo provider 和健康检查、Docker Compose、Hermes 配置、验证命令、常见问题和回滚。压掉的是重复解释,以及容易误导的绝对化表述;例如 all://: 不是通用无效配置,只是在这次环境里没有按预期匹配。
另外修正了一处配置名:SearXNG 的 Bing 新闻引擎应写 name: bing news。如果写成 name: bing_news,它和默认引擎名对不上,风险比普通拼写问题更高。
原始提示词
用户提供了一份题为《中国境内服务器部署 Hermes + SearXNG + Mihomo:让 Hermes 搜索走代理并自动选择可用节点》的完整部署材料,要求整理分析、确认文章内容没有错误,然后调用博客写作 skill 写成文章。
材料包含:背景、目标架构、为什么不用系统代理、SearXNG settings.yml、Mihomo config.yaml、Docker Compose、改造脚本、Hermes 配置、验证链路、常见问题、回滚方法和最终效果。