手上有一台跑 WordPress 的 CentOS 6 VPS:kernel 2.6.32、glibc 2.12、2020 年就 EOL,官方 yum 源早就搬進墳場(vault.centos.org)。某天負載飆到 8,我第一個念頭是「讓 AI agent 上去查」,第二個念頭是「這台機器連 Node.js 都裝不了」。
最後的解法是一支 100 多行的 bash script:只用 curl + jq,直接打 LLM 的 Anthropic 相容 API,讓模型提出 shell 指令、我核准、結果餵回去,循環到問題解決。沒有 Python、沒有 Node、沒有任何 build chain。
這篇文章記錄它的設計思路、踩過的每一個坑,以及一次真實的戰績:它獨立診斷出一場針對 WordPress 搜尋功能的 SEO 垃圾流量攻擊。
為什麼不直接裝現成的 AI CLI?
因為老機器有一堵「runtime 牆」:
- 主流的 AI CLI 工具(Claude Code、各家 agent 框架)需要 Node 18+ 或 Python 3.9+,而它們的預編譯二進位檔要求 glibc 2.17 以上——CentOS 6 的 glibc 是 2.12,連啟動都啟動不了。
- 從原始碼編譯?先得升級 gcc,升級 gcc 先得……依賴地獄,在生產機器上不值得。
- yum 源已 EOL,裝任何東西都要先改 repo 指向 vault,而 jq 當年根本不在 base repo。
但退一步想:agent 的本質只是「HTTPS POST 一個 JSON,解析回傳,再 POST」。這件事 curl 和 jq 就能做,而這兩樣東西任何一台老 Linux 都有(jq 甚至有靜態編譯的單一 binary,wget 下來就能跑)。API 相容層把複雜度移到了雲端,本地只需要會講 JSON。
核心設計:模型提議,人類核准,結果回流
Agentic loop 的最小可行形態
整個 agent 只有一個 tool:bash。循環長這樣:
- 把你的訊息附加到對話歷史(session 檔),POST 給 API。
- 模型回傳純文字 → 印出來,結束(它認為做完了)。
- 模型回傳
tool_use(一條 shell 指令)→ 顯示$ 指令,問你run? [y/N]。 - 執行結果截斷後包成
tool_result餵回去,回到第 1 步,最多 25 輪。
系統提示詞裡寫明這台機器的「地形」:沒有 systemd(要用 service、chkconfig)、日誌在 /var/log/messages、procps 是舊版(free -m 要看 -/+ buffers/cache 那行)、yum 源已 EOL。這些上下文讓模型不會提出在新系統上合理、在 CentOS 6 上根本跑不動的指令。
三種信任等級,用環境變數切換
核准閘門是這個設計裡唯一的「安全邊界」——模型永遠只能提議指令。依任務風險選一種模式:
- 預設模式:每條指令都要按 y。適合你在意的機器。
AUTO_RO=1:白名單內的唯讀指令(ls、cat、df、ps、grep、tail、service * status……)自動放行,任何會改變系統狀態的指令仍然要問。排查類任務 90% 的輪次都不會打擾你,但restart、rm一定會停下來。YOLO=1:全自動。這是 root 在執行模型輸出的任何字串,幻覺出來的rm -rf /var/log/ *(注意那個空格)在執行前和正常指令長得一模一樣。只在你有 snapshot 的拋棄式 VPS 上用。
對話歷史就是一個 JSON 檔
API 本身是無狀態的,所以「記憶」必須自己管:整個 messages 陣列存在 /tmp/agent_session.json,每一步都落盤。這帶來幾個特性:
agent "continue"可以從上次中斷處接續——Ctrl-C、curl 掛掉、達到輪數上限都不丟進度。agent --new "..."開新對話;session 太大時貼上最後的摘要重開,是最便宜的「上下文壓縮」。- 它只是 JSON,壞了可以肉眼檢查、手動修。
踩坑全記錄(這些才是文章存在的理由)
以下每個坑都是實際炸過的,按出現順序排列。
坑一:TLS 握手過得了,憑證驗證過不了
CentOS 6 的 OpenSSL 1.0.1e 支援 TLS 1.2,所以連得上現代 API 端點;但系統內建的 CA bundle 是十幾年前的,驗證鏈可能直接失敗。排查順序:先 curl -v 單獨打一次 API,確認是 TLS 問題還是腳本問題;確認是 CA 問題後,對「只跟一個已知主機講話」的拋棄式機器,務實的選項是加 -k,而不是折騰整個 CA store。
坑二:128KB 的 MAX_ARG_STRLEN 天花板
session 越滾越大,某天 curl 突然報 Argument list too long。Linux 對單一命令列參數有 128KB 上限(跟 ARG_MAX 總量無關),而請求 body 是用 -d "$(jq ...)" 當參數傳的。修法有兩層:
- curl 的 body 改成寫進暫存檔,用
-d @file傳。 - 更隱蔽的是:每個
jq -n --argjson g "$MSG"也把整個 session 當參數傳,遲早撞同一堵牆。改成全部走 stdin(jq ... <<<"$MSG"),大資料永遠不進 argv,這類 bug 整個根除。
坑三:磁碟快滿,寫壞自己的記憶
最慘的一次:磁碟 92% 滿,printf > session.json 寫到一半截斷,session 變成壞 JSON。下一輪 jq 解析失敗輸出空字串,空字串又被存回去覆蓋,然後空的 body POST 出去拿到 Invalid request Error。一個 I/O 錯誤,連鎖反應毀掉整段對話。防線要築三層:
- 原子寫入:先寫暫存檔再
mv,寫失敗時舊的好檔案原封不動。 - 載入時驗證:
jq -e .先驗 session 檔,壞掉就備份成.corrupt.<timestamp>並重開,而不是餵給下游。 - 每個 jq 都接
|| die:第一個 JSON 錯誤就帶著明確訊息中止,不讓空值一路傳染到 API。
坑四:當機留下「孤兒 tool_use」,API 拒收整段歷史
如果程式死在「模型回了 tool_use」和「tool_result 寫入」之間,歷史裡就有一筆沒人回答的工具呼叫。API 嚴格校验這一點,整段對話直接拒收。修法是啟動時自癒:掃描歷史,對每個缺 tool_result 的 tool_use,補一筆合成的 (session interrupted before execution; command not run)——這同時也是對模型誠實:那條指令真的沒跑。
坑五:思考型模型會吃掉 max_tokens
新一代模型預設開啟延伸思考,而思考 token 計入 max_tokens 上限。設 4096 時,模型可能花 3800 思考、只剩 300 回答,回覆被截斷還看起來像「做完了」。對策:上限調到 16384(只設天花板,模型想停就停,不按上限計費),並在 stop_reason == "max_tokens" 時印出明確警告,讓截斷無所遁形。
實戰:它抓到了一場搜尋垃圾流量攻擊
第一次正式出任務是「2 核機器負載 8,找出原因」。大約十幾輪唯讀排查後,它給出的結論:
- 某個 WordPress vhost 正被灌搜尋端點:
GET /?s=<spam keyword>,當天 22,861 次,持續約 3 req/s。 - 關鍵字是 SEO 投毒垃圾(發票 spam、釣魚子域),來源是輪換的住宅 IP——每個 IP 只發幾百次,單純封 IP 沒用。
- 貴的原因:每個
?s=請求都讓 WordPress 對 posts 表跑LIKE '%keyword%'全表掃描,直接解釋了 mysqld 67% CPU。 - 同場加映:背景的
/baker.php之類漏洞探測全是 404,檔案無異動——是濫用,不是入侵,這個區分避免了過度反應。
它接著給出分級處置建議(mod_rewrite 直接 403 掉 spam query、關閉站內搜尋、上 CDN、fail2ban 降噪),在我核准後完成設定、configtest、graceful restart,負載回落。整個過程我做的事只有:按了幾次 y。
安裝與使用
依賴只有 jq(yum 裝不到,直接用靜態 binary):
curl -kLo /usr/local/bin/jq \
https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 \
&& chmod +x /usr/local/bin/jq
把下面的 script 存成 /usr/local/bin/agent 並 chmod +x。它打的是 Anthropic 相容端點(本文以 Kimi Code 的 https://api.kimi.com/coding 為例;換成任何相容服務只需改 API_BASE、MODEL 與金鑰):
#!/bin/bash
# agent - minimal agentic sysadmin loop for old servers (CentOS 6, curl+jq only)
# Usage: agent "task" | agent --new "task"
# Modes: default=approve all | AUTO_RO=1=auto read-only | YOLO=1=unattended
set -u
: "${KIMI_API_KEY:?export KIMI_API_KEY first}"
API=${API_BASE:-https://api.kimi.com/coding}
M=${MODEL:-k3}
MAXTOK=${MAX_TOKENS:-16384}
ITERS=${MAX_ITERS:-25}
OUTCAP=${OUT_CAP:-8000}
SESSION=${AGENT_SESSION:-/tmp/agent_session.json}
CURL="curl -sS --max-time 300" # 憑證驗證失敗時在此加 -k
[ "${1:-}" = "--new" ] && { rm -f "$SESSION"; shift; }
[ $# -eq 0 ] && { echo "usage: agent [--new] <message>"; exit 1; }
SYS="You are a sysadmin agent on a CentOS 6 server: kernel 2.6.32, no systemd (use
'service X restart', 'chkconfig'), logs in /var/log/messages, old procps (read the
'-/+ buffers/cache' row of free -m), yum repos are EOL (vault.centos.org).
Investigate with read-only commands first. Use the bash tool one step at a time.
Stop and summarise when done."
TOOLS='[{"name":"bash","description":"Run a shell command on this server",
"input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}]'
# 原子寫入:先寫暫存檔再 mv,寫失敗不會覆蓋好的歷史
save() {
printf '%s' "$1" > "$SESSION.tmp.$$" && mv -f "$SESSION.tmp.$$" "$SESSION" \
|| { rm -f "$SESSION.tmp.$$"; echo "!! could not write session (disk full?)" >&2; exit 1; }
}
die() { echo "!! $1" >&2; exit 1; }
# 載入 session,壞檔備份後重開
if [ -f "$SESSION" ]; then
if jq -e . "$SESSION" >/dev/null 2>&1; then
MSG=$(cat "$SESSION")
else
cp "$SESSION" "$SESSION.corrupt.$(date +%s)"
echo "!! session corrupt; backed up, starting fresh" >&2
MSG='[]'
fi
else
MSG='[]'
fi
# 自癒:為沒有 tool_result 的 tool_use 補上「未執行」結果
MSG=$(jq '
. as $m
| reduce range(0; length) as $i ([];
. + [$m[$i]]
| if $m[$i].role == "assistant" then
([$m[$i].content[]? | select(.type=="tool_use") | .id]) as $ids
| ([$m[$i+1].content[]? | select(.type=="tool_result") | .tool_use_id]) as $got
| ($ids - $got) as $missing
| if ($missing | length) > 0 then
. + [{role:"user",
content: [$missing[] | {type:"tool_result", tool_use_id: .,
content:"(session interrupted before execution; command not run)"}]}]
else . end
else . end)
' <<<"$MSG") || die "jq failed repairing session"
MSG=$(jq --arg t "$*" '. + [{role:"user",content:$t}]' <<<"$MSG") \
|| die "jq failed appending message"
save "$MSG"
SZ=$(wc -c < "$SESSION")
[ "$SZ" -gt 400000 ] && \
echo "!! session is ${SZ} bytes; consider: agent --new \"<paste last summary>\"" >&2
run_cmd() {
eval "$1" 2>&1 | head -c "$OUTCAP" | iconv -f utf-8 -t utf-8 -c
}
for i in $(seq "$ITERS"); do
BODY=$(mktemp /tmp/agent_body.XXXXXX)
jq --arg m "$M" --arg s "$SYS" --argjson t "$TOOLS" --argjson k "$MAXTOK" \
'{model:$m,max_tokens:$k,system:$s,tools:$t,messages:.}' <<<"$MSG" > "$BODY" \
|| { rm -f "$BODY"; die "jq failed building request"; }
R=$($CURL "$API/v1/messages" \
-H "x-api-key: $KIMI_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H 'content-type: application/json' \
-d @"$BODY")
rm -f "$BODY"
echo "$R" | jq -e . >/dev/null 2>&1 || die "API call failed (curl/TLS): $R"
echo "$R" | jq -e .error >/dev/null && { echo "$R" | jq .error >&2; exit 1; }
[ "${VERBOSE:-0}" = 1 ] && echo "$R" | jq -c '.usage' >&2
[ "$(echo "$R" | jq -r '.stop_reason')" = "max_tokens" ] && \
echo "!! response truncated at max_tokens=$MAXTOK; raise MAX_TOKENS" >&2
echo "$R" | jq -r '.content[]?|select(.type=="text")|.text'
C=$(echo "$R" | jq '.content') || die "jq failed reading response"
MSG=$(jq --argjson c "$C" '. + [{role:"assistant",content:$c}]' <<<"$MSG") \
|| die "jq failed appending assistant turn"
save "$MSG"
TU=$(echo "$R" | jq -c '[.content[]?|select(.type=="tool_use")]')
[ "$(echo "$TU"|jq length)" = 0 ] && exit 0
RES='[]'
for id in $(echo "$TU"|jq -r '.[].id'); do
CMD=$(echo "$TU"|jq -r --arg i "$id" '.[]|select(.id==$i)|.input.cmd')
printf '\n\033[33m$ %s\033[0m\n' "$CMD"
if [ "${YOLO:-0}" = 1 ]; then
ok=y
elif [ "${AUTO_RO:-0}" = 1 ]; then
case "$CMD" in
ls*|cat*|free*|df*|du*|ps*|top\ -b*|netstat*|ss*|tail*|head*|grep*|uptime*|vmstat*|iostat*|w\ *|service\ *\ status*|chkconfig\ --list*|find*\ -ls)
ok=y ;;
*)
read -r -p 'run? [y/N] ' ok </dev/tty ;;
esac
else
read -r -p 'run? [y/N] ' ok </dev/tty
fi
[ "$ok" = y ] && OUT=$(run_cmd "$CMD") || OUT='user declined'
echo "$OUT" | head -20
RES=$(jq -n --argjson r "$RES" --arg i "$id" --arg o "${OUT:-(no output)}" \
'$r+[{type:"tool_result",tool_use_id:$i,content:$o}]') \
|| die "jq failed building tool_result"
done
MSG=$(jq --argjson r "$RES" '. + [{role:"user",content:$r}]' <<<"$MSG") \
|| die "jq failed appending tool results"
save "$MSG"
done
echo "!! hit $ITERS-iteration limit; run: agent \"continue\"" >&2
日常使用:
export KIMI_API_KEY=sk-your-key
agent --new "load average is 8 on a 2-core box, find out why"
agent "yes, apply the fix" # 接續同一個 session
AUTO_RO=1 agent "disk is 92% full, find the biggest offenders"
VERBOSE=1 agent "continue" # 顯示每次呼叫的 token 用量
限制與取捨
- 它不是 Claude Code:沒有檔案編輯工具、沒有計畫模式、沒有上下文壓縮。它解決的是「老機器上跑不了任何現成工具」的場景,解法是在那個約束下做到 80 分。
- session 會線性變胖:每一輪都把整段歷史重送一次。400KB 以上就該帶著摘要
--new,順便省 token 費。 - 核准閘門擋得住幻覺,擋不住你按錯 y:黃色那行
$ 指令印出來時,還是要看一眼再按。 - 訂閱型 API 有速率窗口:序列循環不會撞並發上限,但長時間除錯會吃掉額度,任務要切小。
結語
這個專案最有價值的部分不是那 100 行 bash,而是它證明的一件事:agent 的門檻不在本地,在 API。當相容層把工具呼叫、思考、串流都放在雲端,本地需要的只是一個會 POST JSON 的東西——哪怕那個東西是 2010 年的 curl。
如果你機房裡也有一台「太老不能升級、又老到必須有人顧」的機器,希望這篇文章和這支 script 能讓它第一次擁有一個隨傳隨到的助手——而且這個助手在做任何危險動作之前,都會先問你。