Hermes 搜索不稳定时,把 SearXNG 的出站交给 Mihomo

Hermes 接上 SearXNG 以后,表面上已经把搜索问题收回到了本机:SEARXNG_URL=http://localhost:8888。但境内服务器真正容易卡住的地方不在 Hermes 到 SearXNG 这一跳,而在 SearXNG 自己还要访问 Google、DuckDuckGo、Brave、Startpage 这些搜索源。

我最后把链路拆成三层: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.ymlsearch.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 新闻的 namebing newsengine 才是 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 秒做一次健康检查,AUTOurl-test 按延迟选择节点,所有流量最后匹配到 PROXY。新配置第一次启动时,PROXY 列表里第一个就是 AUTO;如果以后你在控制面板里手动切过节点,store-selected: true 会保留选择,这时“默认走 AUTO”就不一定成立了。

还要注意订阅格式。proxy-providers 适合 provider 格式或能被 Mihomo 作为 provider 解析的订阅;有些服务商给的是完整 Clash 配置,有些给的是节点列表。如果日志里报解析失败,不要先怀疑 SearXNG,先去服务商后台切成 Clash MetaMihomoProxy Provider 格式。

改造脚本

下面这个脚本适合已经把 SearXNG 放在 /opt/searxng 的机器。它会备份现有 docker-compose.ymlsearxng/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 各自只做一件事,排障的时候反而省事。

参考资料

写作附记

这篇保留了原始材料里最有操作价值的部分:分层架构、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 配置、验证链路、常见问题、回滚方法和最终效果。
金融IT程序员的瞎折腾、日常生活的碎碎念
使用 Hugo 构建
主题 StackJimmy 设计