183 行 llmctl:把 4 个启动脚本收成一个 CLI,总控该管什么、不该管什么

0. 现场

一个放零散工具的目录,ls 出来是这样:

sql 复制代码
$ wc -l * | sort -rn
    1803 total
     380 rag-index.py
     323 llm-chat-sync.py
     207 llm38-proxy.py
     183 llmctl
     109 capture-proxy.py
      90 rag-search.py
      73 rag-inc-test.sh
      56 test-chat-rag.sh
      56 rag-encode-var.py
      50 dl-qwen38.sh
      46 rag-build.sh
      42 rag-ask.sh
      40 rag-abtest.py
      39 start-llm38-detached.py
      32 rag-abtest2.py
      29 start-llm.sh
      26 start-llmchat-detached.py
      22 start-chat.sh

18 个文件、1803 行。其中最扎眼的是最后四行 ------四个"启动脚本",start-llm.sh(29)、start-llm38-detached.py(39)、start-chat.sh(22)、start-llmchat-detached.py(26),加起来 116 行。

四个文件,两个职责(起模型 / 起界面),各写了两遍。


1. 四个启动脚本是怎么长出来的

把 mtime 拉出来排一下,因果链很清楚:

时间 文件 行数 引入它的那个约束
10-01 17:49 start-llm.sh 29 起模型:case 选型号 → --port → 前台跑
10-01 18:59 start-llm38-detached.py 39 父 shell 一退服务就死 → 双 fork + setsid 脱离
10-02 11:56 start-chat.sh 22 聊天页是另一个进程,需单独起
10-02 23:23 start-llmchat-detached.py 26 子进程继承了 http_proxy → 访问 127.0.0.1 被代理拦走 → 剥环境重写
10-03 11:55 llmctl 183 上面四个全部收掉

坑 1:nohup 不够,得真正脱离

第一版 start-llm.sh 是前台跑的("$LLAMA" ... "$@"),把终端占住;换成 nohup ... & 之后,父 shell 一退出进程还是没了 。最终解法是 start-llm38-detached.py 里的双 fork:

python 复制代码
pid = os.fork()
if pid > 0:
    sys.exit(0)
os.setsid()
pid = os.fork()
if pid > 0:
    sys.exit(0)
os.execvpe(PY, [PY, SERVER], env)

坑 2:环境变量会被继承,包括不该继承的那些

start-llmchat-detached.py 多出来的那几行,是这篇文章里我最想强调的一处:

python 复制代码
env = dict(os.environ)
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
    env.pop(k, None)
env["NO_PROXY"] = "127.0.0.1,localhost"

症状 :服务在跑、端口在听,客户端就是连不上。 根因 :子进程从调用方继承了 http_proxy,于是对 127.0.0.1 的请求也被送进了代理 ,代理当然不认识这个地址。 修法 :显式剥掉四个代理变量,并把本机地址加进 NO_PROXY。

这条规矩后来被我固化成两个动作:任何脚本里对 127.0.0.1 的探测,要么 curl --noproxy '*',要么先把代理变量清干净。

到这里,"启动服务"这件事被拆成了 4 个脚本。每个都只记住"上一次的坑"。


2. 收口:一个 CLI、五个子命令

llmctl 的结构非常朴素------一张元数据表 + 五个函数 + 一个 case 分发。

2.1 元数据集中在一处

bash 复制代码
# 模型 key -> "显示名|文件名|端口|alias|是否多模态(0/1)"
meta() {
  case "$1" in
    30)  echo "Qwen3-Coder-30B-A3B Q8_0|Qwen3-Coder-30B-A3B-Instruct-Q8_0.gguf|8080|qwen3-coder-30b-local|0";;
    38)  echo "Qwen3.8-27B Q8_0|Qwen3.8-27B-Q8_0.gguf|8082|qwen3.8-27b-local|1";;
    32b) echo "Qwen2.5-Coder-32B Q4_K_M|qwen2.5-coder-32b-instruct-q4_k_m.gguf|8081|qwen2.5-coder-32b-local|0";;
    *)   echo "";;
  esac
}

一个模型的所有属性(名字、文件名、端口、别名、是否多模态)只在这一处定义。 之前散在四个脚本里的时候,加一个模型要改四处,漏一处就是"端口对不上"。

2.2 use:先停干净,再起,再等就绪

这是核心子命令,完整流程如下(有删节):

bash 复制代码
use() {
  local key="$1"
  local m=$(meta "$key")
  [ -z "$m" ] && { echo "❌ 未知模型:$key (用 30 / 38 / 32b)"; exit 1; }
  local NAME FILE PORT ALIAS VISION; IFS='|' read -r NAME FILE PORT ALIAS VISION <<< "$m"
  local PATH_="$MODELS/$FILE"

① 前置校验:拦在启动之前

bash 复制代码
  [ -f "$PATH_" ] || { echo "❌ 模型文件不存在:$PATH_"; exit 1; }
  # 体积不够也不让起(会拉爆/报错)
  if [ "$key" = "38" ]; then
    local sz=$(stat -f%z "$PATH_" 2>/dev/null)
    if [ "${sz:-0}" -lt 26000000000 ]; then
      echo "❌ 还没下完(当前 $((sz/1000000000))GB / 26GB),等下载完成再 use 38"
      exit 1
    fi
  fi

② 停:先卸守护项,再杀进程,再等端口真空出来

bash 复制代码
  # 带 KeepAlive 的 launchd 守护项会把 pkill 掉的进程自动拉回来 → 必须先后台卸载它
  local PLIST="$HOME/Library/LaunchAgents/com.user.llm38.plist"
  if [ -f "$PLIST" ] && launchctl print "gui/$(id -u)/com.user.llm38" >/dev/null 2>&1; then
    launchctl unload "$PLIST" 2>/dev/null && echo "  已卸载守护项"
  fi
  pkill -f "llama-b11306/llama-server" 2>/dev/null

  # 关键:不能 kill 完就走,要轮询确认端口真的释放(发了信号 ≠ 进程没了)
  local i=0
  while lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1 && [ $i -lt 20 ]; do
    sleep 0.5; i=$((i+1))
  done

③ 起 + 等就绪(轮询健康检查,不是 sleep)

bash 复制代码
  nohup "$LLAMA" --model "$PATH_" --alias "$ALIAS" -ngl 99 -c 16384 \
    --host 127.0.0.1 --port "$PORT" $EXTRA > "/tmp/llm_${key}.log" 2>&1 &

  i=0
  while ! curl -s -o /dev/null -m 2 "http://127.0.0.1:$PORT/health" && [ $i -lt 120 ]; do
    sleep 1; i=$((i+1))
  done
  if curl -s -o /dev/null -m 2 "http://127.0.0.1:$PORT/health"; then
    echo "  ✅ 就绪"
  else
    echo "  ⚠️ 超时未就绪,看日志:/tmp/llm_${key}.log"
  fi

④ 状态落盘(给别的组件读)

bash 复制代码
  cat > "$STATE" <<JSON
{
  "port": ${PORT:-0},
  "model": "${ALIAS:-}",
  "name": "${NAME:-}",
  "vision": ${VISION:-0}
}
JSON

界面读这个文件,就知道该把请求发到哪个端口------这就是"收口"的价值:四个脚本时代,界面得靠人去启动参数里对端口。

2.3 status:一屏看清

bash 复制代码
mem_free_gb() {   # 用 vm_stat 算 free + speculative,比"看了活动监视器估一个"靠谱
  local pf=$(vm_stat | awk '/Pages free:/{gsub("\\.","",$3);print $3}')
  local ps=$(vm_stat | awk '/Pages speculative:/{gsub("\\.","",$3);print $3}')
  echo $(( (${pf:-0}+${ps:-0}) * 16384 / 1000000000 ))
}

listening() {   # 判"端口在不在听"------所有状态的唯一真相源
  lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 && echo "🟢" || echo "⚪️"
}

实测输出:

bash 复制代码
════════ 本地 LLM 状态 ═════════
空闲内存:约 11GB / 64GB
聊天页  :http://127.0.0.1:3000  🟢
对话落盘:今日 0 轮  →  $MODELS/llm-chat/chats/2026-10-09.jsonl

模型端口:
  30B    Qwen3-Coder-30B Q8  :8080          ⚪️
  38     Qwen3.8-27B Q8    :8082            🟢
  32B    Qwen2.5-Coder-32B :8081            ⚪️

当前激活:⚪️ Qwen3-Coder-30B-A3B Q8_0
════════════════════════════════

先记住这一屏。下面第五节要拿它开刀。

2.4 分发:默认子命令

bash 复制代码
case "${1:-status}" in
  status) status;;
  use)    use "${2:-}";;
  chat)   chat;;
  stop)   stop;;
  codex)  codex "${2:-}";;
  *) echo "用法:llmctl [status|use 30|38|32b|chat|stop|codex 30|38|32b|cloud]";;
esac

${1:-status}------不带参数就是查状态,这是用得最多的操作,值得设成默认。


3. 五条设计原则(每条都有对应代码)

# 原则 在 llmctl 里的落点
1 状态要"探",不要"记" listening() 用 lsof 探端口;状态文件只记"意图"
2 互斥关系写成代码 use 第一步永远是"全停"(64GB 装不下两个)
3 停不干净会连累后续 先 launchctl unload 再 pkill,再轮询端口释放
4 前置校验拦在代价之前 文件存在性 + 体积阈值,都在启动前
5 "就绪"要有可验证的定义 轮询 /health,超时报日志路径

第 3 条值得展开一句,因为它踩过:pkill 返回成功 ≠ 端口已释放。"执行了动作"与"结果发生"是两件事,中间要加确认。


4. 边界:什么不该塞进来

llmctl 只有 183 行,比它收掉的四个脚本(116 行)多不了多少------多出来的部分是状态可见与错误处理,不是功能。这是刻意的:

  • 不含业务逻辑:本机那套本地检索(切块 / 向量化 / 排序)一行都没进来。总控只负责"把检索服务拉起来",不知道"文档怎么切"。
  • 不做自动决策 :没有"自动挑最合适的模型"这类逻辑。总控要的是确定性------出问题时只调试一套逻辑。
  • 不收一次性任务:检索入口脚本的行为是"用时起、用完停",不需要常驻,也就不需要进总控。

判断标准一句话:常驻服务归总控,随用随走的工具归自己。


5. 实测出来的三个缺陷(含没修的)

写完不测等于没写。我拿这个 CLI 做了几组实测,挖出三个问题------三个都还没修。

缺陷 1:状态文件与实际进程漂移(未修)

回到 2.3 那屏输出。上半段是探测 的(8082 🟢),"当前激活"那行是读文件的(说 30B)。两个数据源打架。

对数:

bash 复制代码
$ cat $STATE                      # 意图态
{
  "port": 8080,
  "model": "qwen3-coder-30b-local",
  "name": "Qwen3-Coder-30B-A3B Q8_0"
}

$ ps -eo args | grep -- '--port 8082'   # 真实态
llama-server --model Qwen3.8-27B-Q8_0.gguf --alias qwen3.8-27b-local --port 8082

文件说 30B / 8080,实际跑的是 38 / 8082。

问题代码就是 status 里这段------它只读文件,不探端口:

bash 复制代码
if [ -f "$STATE" ]; then
  local nm=$(grep -o '"name"[ ]*:[ ]*"[^"]*"' "$STATE" | head -1 | sed 's/.*:[ ]*"//;s/"//')
  echo "当前激活:$(listening $(grep -o '"port"[ ]*:[ ]*[0-9]*' "$STATE" | grep -o '[0-9]*')) $nm"
fi

注意它甚至同时用了两种来源:端口状态(探的)和模型名(读的)------所以才会出现"⚪️ + 30B"这种自相矛盾的输出。

修法:谁在听端口谁就是激活的,从探测结果反推;文件只用来提示"记录可能陈旧"。

为什么先写出来不先修 :这个状态漂移是我这次最想留下的记录。一个总控最容易骗的人是它自己的作者------它会打印一屏绿色,其中一半真探测、一半读文件,而它不会告诉你哪半是哪个。

缺陷 2:退出码不一致(未修)

bash 复制代码
$ llmctl foobar;  echo $?
用法:llmctl [status|use 30|38|32b|chat|stop|codex 30|38|32b|cloud]
0                                   # ← 参数写错,却报成功

$ llmctl use 99;  echo $?
❌ 未知模型:99 (用 30 / 38 / 32b)
1                                   # ← 同一个脚本,另一种态度

case 的 *) 分支只 echo 没 exit 1。危害是:别的脚本用 llmctl xxx && next 串它时,参数写错会被当成成功,继续往下走。

一致性约定应该是:只要没办成你要的事,退出码就不该是 0。

缺陷 3:同一个阈值写了两遍(未修)

"模型下完了没有"这个判断,代码里的阈值是 26000000000(26GB),而 status 打印的是 26.6GB。两个数都是我写的,只是从来没对过。

这类问题和前两个同源:脚本里凡是"应该一致"的东西,只要不主动让它一致,它就会不一致。


6. 复现清单

本文引用的文件与实测行数:

文件 行数 作用
llmctl 183 总控 CLI(本文主角,5 个子命令)
rag-index.py 380 语义索引构建(不属于总控,边界示例)
rag-search.py 90 检索查询(同上)
rag-build.sh 46 索引构建入口
rag-ask.sh 42 检索入口(按需起停,一次性任务,不进总控)

四个被收掉的启动脚本合计 116 行 (start-llm.sh 29 + start-llm38-detached.py 39 + start-chat.sh 22 + start-llmchat-detached.py 26);整目录 18 个文件、1803 行。

实测命令(全部只读,可直接复现):

bash 复制代码
wc -l * | sort -rn                    # 脚本清单与行数
llmctl status                         # 一屏状态(含第五节那个漂移现场)
llmctl foobar; echo $?                # 缺陷 2:退出码 0
llmctl use 99; echo $?                # 对照:退出码 1

7. 小结

  • 总控管的是状态,不是功能。 它要的不是聪明,是诚实:状态探测得来、动作执行完要确认、失败要能用退出码表达。
  • 补丁堆到第四个就该换结构。 出现"第四个启动脚本"时,缺的已经不是功能,是一个统一入口。
  • 写脚本的尽头不是更自动化,是更可观测。 自动化省几次敲键盘,可观测性让你在出问题时不用猜。
相关推荐
文谦南宁AI普工2 小时前
RAG 检索全对,回答全错?用位置扫描 10 分钟定位 Lost in the Middle
人工智能·aigc
野生码农AI实战2 小时前
一句追问查出停滞 50 天的口径烂账,我把团队规则的锚换到了数字团队花名册
人工智能
宇擎智脑科技2 小时前
Sirchmunk 深度解析(一):一个无需向量数据库的自进化搜索引擎架构设计
人工智能·rag·sirchmunk
小魚8842 小时前
豆包工作的核心能力有哪些
人工智能
用户287043906162 小时前
给一个终端编码代理装上一颗确定性情绪内核:Persisto Mate 的实现记录
人工智能
用户202252215062 小时前
实现变便宜了,品味才是稀缺:一个 AI 产品经理的 Vibe Coding 复盘
人工智能
雪雪爱冲浪2 小时前
AI 智能体如何通过 auth.md 注册 Bright Date:完整实操指南
大数据·人工智能
会议咨询2 小时前
2026年电子工程、先进制造技术与人工智能国际会议(EATA 2026)
人工智能·电子工程·先进制造