Clash API 節點自動切換:延遲偵測與腳本化配置

透過 Clash API 與自動化程式,定期檢查代理節點狀態並切換至較穩定的路線。文章適合熟悉 YAML 的技術使用者,涵蓋策略群組、排程執行、錯誤記錄及實際部署注意事項。

Clash 的策略群組可以使用 url-testfallbackload-balance 自動處理節點選擇,但在節點數量多、供應商品質不穩定,或需要把切換結果寫入日誌時,直接透過外部控制器 API 執行檢測會更容易掌握。這種做法不是修改訂閱來源,而是由外部程式讀取目前已載入的代理清單,測試節點延遲,再把較合適的節點寫回策略群組。

本文以 mihomo 相容的 External Controller 為例,示範一個可以在 Linux、macOS 或 Windows 執行的 Python 腳本。腳本只負責三件事:取得指定策略群組的成員、逐一測試節點、在符合條件時切換目前選擇。訂閱更新、規則比對和 TUN 流量接管仍然由 Clash 客戶端與內核負責。

先保護 external-controller

外部控制器具備讀取設定、切換節點,甚至重新載入設定的能力,不要監聽在 0.0.0.0 後直接暴露到區域網路或公網。一般單機使用建議綁定 127.0.0.1:9090,並設定 secret;如果確實需要遠端管理,應放在 VPN 或經過驗證的反向代理後方。

先準備 API 控制器與策略群組

在設定檔的最上層加入外部控制器相關欄位。external-controller 決定 API 監聽位址和連接埠,secret 是 API 驗證用的密鑰。修改後要重新載入設定,並確認客戶端的內核確實是 mihomo,而不是不支援相同 API 路徑的舊內核。

external-controller: 127.0.0.1:9090
secret: "change-this-to-a-long-random-string"

proxy-groups:
  - name: Auto
    type: select
    proxies:
      - node-a
      - node-b
      - node-c
      - DIRECT

select 群組適合由腳本明確指定節點。若改用 url-test,內核會自行以測試 URL 和延遲門檻選擇節點,腳本通常只需要讀取狀態和記錄結果。兩種方式不要混用同一個群組:如果群組類型是 url-test,內核可能在腳本切換後再次按照自己的計時器改回另一個節點。

群組類型 選擇方式 適合情境 腳本注意事項
select 手動或 API 指定 需要固定目前節點,或自行定義切換條件 切換後狀態容易預測
url-test 依測試 URL 和 tolerance 自動選擇 希望內核週期性選擇較快節點 不要讓外部腳本與內核同時搶著選擇
fallback 優先使用可連線的前順位節點 重視可用性多於最低延遲 延遲較低不一定會被選中
load-balance 依演算法分配請求 多節點分攤流量 不適合以單一目前節點作為切換目標

策略名稱、節點名稱和密鑰都可能包含特殊字元,腳本不應該自行拼接 YAML 或假設名稱一定是英文。API 呼叫時把名稱交給 HTTP 用戶端編碼,並把回應內容記錄下來,遇到名稱找不到時才能分辨是設定問題還是編碼問題。

用 delay API 測試節點並設定判斷條件

mihomo 的代理延遲測試介面通常是 GET /proxies/{proxy}/delay。請求需要帶上測試網址 url 與逾時時間 timeout,例如使用 https://www.gstatic.com/generate_204 或其他能快速回應的 HTTPS 位址。不同網路環境對測試網址的可達性不同,腳本應選擇自己所在地確實能連通的 URL,不要把某個測試網址的失敗直接等同於代理節點失效。

# 查看所有代理與策略群組
curl -s \
  -H "Authorization: Bearer change-this-to-a-long-random-string" \
  http://127.0.0.1:9090/proxies

# 測試單一節點,逾時 5 秒
curl -sG \
  -H "Authorization: Bearer change-this-to-a-long-random-string" \
  --data-urlencode "url=https://www.gstatic.com/generate_204" \
  --data-urlencode "timeout=5000" \
  http://127.0.0.1:9090/proxies/node-a/delay

成功回應通常包含 delay 整數,單位是毫秒。回應逾時、DNS 失敗、TLS 錯誤或節點不存在時,HTTP 狀態碼或 JSON 內容可能不同,因此程式不能只用「有沒有 delay 欄位」判斷。實務上建議同時設定三個條件:單次測試上限、最少有效節點數,以及切換門檻。

  • 單次逾時:可先用 5000 毫秒;節點普遍較遠時再提高到 8000 或 10000 毫秒。
  • 最少有效節點數:例如至少取得兩個有效結果,避免只有一個節點回應時就誤判為最佳。
  • 切換門檻:新節點比目前節點快至少 50 毫秒,或目前節點測試失敗時才切換,避免延遲小幅波動造成頻繁切換。
  • 連續失敗次數:建議至少連續兩輪失敗才標記節點不穩定,短暫丟包不應立即觸發大範圍切換。

延遲測試結果不等於完整的使用體驗。它只反映測試 URL 從本機經代理送出並取得回應所需的時間,不能直接代表串流、遊戲、檔案下載或特定網站的速度。腳本應把它當成排序訊號,而不是絕對品質評分。

Python 腳本:測試、比較與切換

下面的範例使用 Python 標準函式庫之外的 requests 套件。先執行 python -m pip install requests,再把 API_SECRETGROUP_NAME 和測試網址改成自己的值。腳本只處理 select 群組;如果目標群組是 url-test,應改用讀取狀態的模式,不要直接覆寫選擇。

import json
import logging
import os
import sys
from urllib.parse import quote

import requests

API_BASE = os.getenv("CLASH_API", "http://127.0.0.1:9090")
API_SECRET = os.getenv("CLASH_SECRET", "change-this-to-a-long-random-string")
GROUP_NAME = os.getenv("CLASH_GROUP", "Auto")
TEST_URL = os.getenv(
    "CLASH_TEST_URL",
    "https://www.gstatic.com/generate_204",
)
TIMEOUT_MS = 5000
SWITCH_MARGIN_MS = 50
REQUEST_TIMEOUT = 8

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

session = requests.Session()
session.headers.update({
    "Authorization": f"Bearer {API_SECRET}",
})


def api_get(path, params=None):
    response = session.get(
        API_BASE + path,
        params=params,
        timeout=REQUEST_TIMEOUT,
    )
    response.raise_for_status()
    return response.json()


def api_put(path, payload):
    response = session.put(
        API_BASE + path,
        json=payload,
        timeout=REQUEST_TIMEOUT,
    )
    response.raise_for_status()
    return response.json() if response.content else {}


def main():
    encoded_group = quote(GROUP_NAME, safe="")
    proxies = api_get("/proxies")["proxies"]

    if GROUP_NAME not in proxies:
        raise RuntimeError(f"找不到策略群組: {GROUP_NAME}")

    group = proxies[GROUP_NAME]
    if group.get("type") != "Selector":
        raise RuntimeError(
            f"{GROUP_NAME} 不是 select 群組,"
            "請不要由腳本強制覆寫"
        )

    candidates = [
        name for name in group.get("all", [])
        if name not in {"DIRECT", "REJECT"}
        and name in proxies
        and proxies[name].get("type") not in {
            "Selector", "URLTest", "Fallback", "LoadBalance"
        }
    ]

    results = []
    for name in candidates:
        encoded_name = quote(name, safe="")
        try:
            data = api_get(
                f"/proxies/{encoded_name}/delay",
                params={
                    "url": TEST_URL,
                    "timeout": str(TIMEOUT_MS),
                },
            )
            delay = int(data["delay"])
            results.append((delay, name))
            logging.info("%s: %d ms", name, delay)
        except (requests.RequestException, KeyError, ValueError) as exc:
            logging.warning("%s: 測試失敗: %s", name, exc)

    if not results:
        raise RuntimeError("沒有任何節點取得有效延遲")

    results.sort()
    best_delay, best_name = results[0]
    current_name = group.get("now")

    current_delay = None
    for delay, name in results:
        if name == current_name:
            current_delay = delay
            break

    should_switch = (
        current_name is None
        or current_delay is None
        or best_delay + SWITCH_MARGIN_MS < current_delay
    )

    if should_switch and best_name != current_name:
        api_put(
            f"/proxies/{encoded_group}",
            {"name": best_name},
        )
        logging.info(
            "已切換: %s -> %s (%d ms)",
            current_name,
            best_name,
            best_delay,
        )
    else:
        logging.info(
            "維持 %s;最佳結果為 %s (%d ms)",
            current_name,
            best_name,
            best_delay,
        )


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        logging.error("%s", exc)
        sys.exit(1)

切換策略群組時使用 PUT /proxies/{group},請求內容是 {"name":"節點名稱"}。範例先取得群組目前的 now,再測試同一群組中的成員。如果目前節點本輪測試失敗,程式會允許切換到任何有效節點;如果目前節點仍然正常,只有新節點快過門檻才切換。

先用乾跑模式驗證

第一次部署時可把 api_put() 暫時改成只記錄預計切換的節點,先觀察一至兩天的延遲分布。確認節點名稱、群組名稱和測試 URL 都正確後,再開啟實際切換,能避免腳本因名稱錯誤或測試站點不可達而反覆改動設定。

排程執行、錯誤記錄與部署細節

自動切換腳本不應該以極短間隔執行。每 30 秒測試一次會增加 DNS、TLS 和節點連線負擔,也容易因網路瞬間抖動反覆切換。一般桌面使用可從每 10 至 15 分鐘一輪開始;如果主要需求是故障備援,可以把週期縮短,但仍應搭配連續失敗和切換門檻。

Linux 可用 systemd timer 執行。把密鑰放在只有服務帳號可讀取的環境檔,不要直接寫入公開的 shell history 或腳本倉庫:

# /etc/clash-switcher.env
CLASH_API=http://127.0.0.1:9090
CLASH_SECRET=change-this-to-a-long-random-string
CLASH_GROUP=Auto
CLASH_TEST_URL=https://www.gstatic.com/generate_204

# /etc/systemd/system/clash-switcher.service
[Unit]
Description=Clash proxy node selector

[Service]
Type=oneshot
EnvironmentFile=/etc/clash-switcher.env
ExecStart=/usr/bin/python3 /opt/clash-switcher/select.py

# /etc/systemd/system/clash-switcher.timer
[Unit]
Description=Run Clash node selector periodically

[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
Persistent=true

[Install]
WantedBy=timers.target

建立檔案後執行 sudo systemctl daemon-reloadsudo systemctl enable --now clash-switcher.timer,再用 journalctl -u clash-switcher.service 查看執行結果。Persistent=true 會讓系統離線後在下一次啟動補跑一次,但不會補齊所有錯過的週期。

Windows 可以使用「工作排程器」建立基本工作,觸發條件設為每 15 分鐘執行,動作填寫 Python 執行檔路徑與腳本路徑。工作排程器的「起始於」欄位要填腳本所在目錄,否則相對路徑的日誌檔可能被寫到 System32。macOS 則可使用 launchd;如果只是個人電腦,也可以先用系統的排程工具驗證腳本,再決定是否常駐。

記錄項目 建議內容 用途
測試時間 本地時間或 UTC 時間 判斷是否在特定時段普遍變慢
節點名稱 API 回傳的完整名稱 對照訂閱更新後是否改名
延遲與錯誤 毫秒數、逾時、HTTP 狀態 分辨慢與完全不可用
切換前後 舊節點、新節點、觸發原因 追蹤頻繁切換與門檻是否合理

部署前檢查與常見錯誤

腳本能成功呼叫 API,不代表切換後所有流量都會走新節點。首先要確認使用中的流量入口確實指向該策略群組:rules 裡可能寫的是另一個群組名稱,或者目前模式是 globaldirect,此時切換 Auto 不會影響實際請求。其次要確認訂閱更新不會重建或覆蓋自訂的 proxy-groups,否則下一次更新後 API 中可能已經沒有原本的群組。

  • 回應 401:檢查 Authorization: Bearer 格式,以及設定檔中的 secret 是否已重新載入。
  • 回應 404:通常是節點或群組名稱沒有進行 URL 編碼,包含空格、斜線或特殊符號時特別常見。
  • 回應 400:檢查 PUT 的 JSON 是否是 {"name":"..."},節點名稱必須存在於該策略群組的 all 清單。
  • 所有節點都逾時:先在同一台電腦測試 API 和外部測試網址,再確認訂閱是否過期、DNS 是否正常,以及節點伺服器是否整體故障。
  • 節點不斷來回切換:提高 SWITCH_MARGIN_MS、延長排程週期,或要求連續兩輪結果都符合條件後才切換。
  • 切換成功但應用程式不變:檢查應用程式是否使用系統代理、TUN 或獨立的代理設定,並確認規則實際命中的策略群組。

最後,API 自動切換應該只修改目前運作狀態,不要讓腳本直接改寫訂閱檔案或長期保存節點密碼。每次更新訂閱後重新檢查策略群組成員,並保留最近的執行日誌;當服務商更改節點名稱、刪除節點或變更設定格式時,能更快定位問題。若需求只是「從可用節點中選延遲最低者」,優先考慮原生的 url-test;只有在需要自訂門檻、外部告警、跨群組判斷或歷史統計時,才值得維護獨立腳本。

排查之前先確認客戶端與內核版本

本文提到的 proxy-server-nameserverunified-delaytcp-concurrent 三個欄位需要 mihomo 內核支援。原版 Clash 內核已停止維護,欄位不生效時先確認內核類型,再對照日誌逐項排查。

前往下載頁 查看安裝教學

下載 Clash 客戶端