作者在 Mac mini 上 24/7 运行自主编码系统,SSH 手工干预效率太低,于是做了手机端控制面板,实现暂停、引导、审批和状态查看。
我的 Mac mini 7×24 小时跑着一套全自动实现系统。很长一段时间里,介入的唯一方式就是 SSH 进去手动 kill 进程。于是我给自己做了一个远程控制面板,在手机上随时可以暂停、引导、审批和检查这个 Agent。这篇文章会讲设计思路(基于文件的命令队列、审批门控和心跳),以及其中关键的代码实现,最后是六个月的运行经验带来的五条教训。
先说整体架构:一个编排模块(orchestrator)会并行启动多个基于 Claude Code(写这篇文章时是 v2.1.x)的实现 Agent,每个 Agent 从队列里取任务工作。它会做规划、写代码、跑测试、提交,然后继续下一个任务。它在我睡觉时跑,在我在健身房时跑,在我坐火车没有笔记本时也跑。
最后这种情况就是问题所在。🚨
发生了三件事:
Agent 会钻进兔子洞。一个"把这个配置 key 改个名"的任务,会因为 Agent 认为旧名字在所有地方都容易混淆而变成一个涉及 40 个文件的重构。等我回过神来,已经是六个小时之后了。
它会遇到不应该独自做决定的场景。删除一个迁移?推送到共享分支?轮换凭证?Agent 收到指令,遇到这些情况要停下来等人。但"等人"的意思是"等我坐到桌前才行"。
我完全不知道它此刻在做什么。日志都在那台机器上,想看就得开终端、开 VPN、tail -f。吃饭的时候掏出手机是做不到这些事的。
我的第一反应是"那就用手机 SSH 上去"。试了两周。在凌晨两点半睡眼惺忪的时候,在六英寸的屏幕上敲 kill -9,很可能会 kill 错进程。我干过,两次。
我真正需要的是一个远程控制器,而不是一个远程 Shell。几个大大的、安全的按钮,加一个清晰的状态显示。
动笔之前我定了三条规则:
Agent 不得依赖这个面板。面板挂了,Agent 继续跑;Agent 挂了,面板告知这一点即可。不共享进程,不共享数据库。
每条命令必须是幂等的、安全可重复的。移动网络会重试。"暂停"点两下不能产生任何奇怪的结果。
读廉价,写受控。任何有链接的人都能看状态(反正也有认证挡着),但写命令需要二次验证。
整个系统分三部分,通过文件系统通信:
Phone[Phone browser] -->|HTTPS| Dash[Dashboard server\nFastAPI]
Dash -->|writes JSON| Queue[(commands/ dir)]
Dash -->|reads| Status[(status.json + logs)]
Agent[Autonomous agent loop] -->|polls every 5s| Queue
Agent -->|writes every 30s| Status
没错,就是一个目录里的 JSON 文件组成的队列。没有 Redis,没有 Postgres,没有消息队列。这部分我会在"教训"一节里为这个选择辩护。
每条命令是一个文件。文件名是 UUID,内容是一个超小的 JSON 文档。Agent 的主循环在每个任务步骤之间轮询这个目录:
# agent side: runs between every tool call / task step
import json, os, time
from pathlib import Path
COMMANDS = Path("~/agent/commands").expanduser()
PROCESSED = COMMANDS / "processed"
def drain_commands(state):
for f in sorted(COMMANDS.glob("*.json")):
try:
cmd = json.loads(f.read_text())
except json.JSONDecodeError:
f.rename(PROCESSED / f"{f.name}.corrupt")
continue
kind = cmd.get("kind")
if kind == "pause":
state.paused = True
elif kind == "resume":
state.paused = False
elif kind == "abort_task":
state.abort_current = True
elif kind == "steer":
# appended to the next prompt as a user instruction
state.pending_notes.append(cmd["text"])
elif kind == "approve":
state.approvals.add(cmd["request_id"])
elif kind == "deny":
state.denials.add(cmd["request_id"])
f.rename(PROCESSED / f.name)
steer 命令是我用得最多的。它不会打断任何东西,只是说:"在你下一步之前,先读一下我的这条备注"。比如说"别动计费模块,我本地正在改",或者"那个不稳定的测试是已知问题,跳过继续"。编排模块把它作为高优先级用户消息注入到下一次 prompt 里。
这个功能改变了我的睡眠质量。😴
Agent 有一系列未经人类批准就不能执行的操作:删除版本库以外的文件、force-push、针对非本地数据库运行迁移、触碰密钥文件等。遇到这些情况时,它不会失败也不会猜,而是写一条审批请求,然后以这个 request ID 为关键字阻塞等待:
# agent side
def request_approval(action: str, detail: str, timeout_s: int = 6 * 3600) -> bool:
req_id = uuid.uuid4().hex
(REQUESTS / f"{req_id}.json").write_text(json.dumps({
"id": req_id,
"action": action,
"detail": detail,
"created": time.time(),
}))
notify_phone(f"Approval needed: {action}") # push notification
deadline = time.time() + timeout_s
while time.time() < deadline:
drain_commands(state)
if req_id in state.approvals:
return True
if req_id in state.denials:
return False
time.sleep(5)
return False # timeout = deny, always
超时等于拒绝。永远。我在这上面反复纠结过。如果一个 Agent 把沉默当作同意,它迟早会在凌晨三点我熟睡时做出不可逆的操作。沉默意味着"现在不行",任务会被搁置,而不是被丢弃。
在面板上,一条审批请求渲染成一张卡片,上面有操作内容、详情(通常是 diff 或命令),以及两个大按钮。绿色和红色。就这样。
Agent 每 30 秒以及每个任务边界后会写入一条 status.json:
{
"ts": 1789740000,
"state": "running",
"current_task": "Add retry to webhook delivery",
"step": 14,
"tokens_today": 812000,
"last_commit": "a1f3c9e",
"pending_approvals": 1,
"agents_active": 3
}
面板读取它之后,做了一件如果没有就会低估它价值的事:显示心跳的"新鲜度"。如果 ts 超过 90 秒,顶部状态条变成琥珀色。超过 5 分钟,变成红色。这一单个指标,在我还没意识到任何问题之前,就捕获了两次进程挂起和一次磁盘空间耗尽事件。
核心代码大约 300 行的 FastAPI(Python 3.13)加一个只有原生 JavaScript 的 HTML 模板。没有框架。它能在手机上使用,因为我最初就是用手机设计的:
顶部:状态徽章、心跳时效、当前任务。
中部:待审批卡片(如果有的话)。
底部:四个按钮。暂停、继续、终止当前任务、引导(点击后打开文本框)。
最后 50 行日志放在一个可折叠区域里。
写操作需要经过一个 authenticator 应用生成的一次性验证码。读操作只需要反向代理的基本认证。服务端作为独立的后台服务运行,所以 Agent 重启不影响它,而且它从不导入 Agent 代码库里的任何东西。
@app.post("/cmd/{kind}")
def post_command(kind: str, body: CommandIn, totp: str = Header(...)):
if kind not in ALLOWED_KINDS:
raise HTTPException(400)
if not verify_totp(totp):
raise HTTPException(403)
payload = {"kind": kind, "created": time.time(), **body.model_dump()}
tmp = COMMANDS / f".{uuid.uuid4().hex}.tmp"
tmp.write_text(json.dumps(payload))
tmp.rename(COMMANDS / f"{uuid.uuid4().hex}.json") # atomic
return {"ok": True}
先写临时文件,再 rename。Agent 永远不会看到一条写到一半的命令。
Discord 上有人喷过我这个选择。好吧,事实是:这个队列零依赖、重启后依然完好、用 ls 就能查看、用 cat 就能调试。六个月下来它从未成为故障点。我跑过的每一个"真正的"队列在某时刻都需要人盯着。对于单机、单消费者、低吞吐量的控制通道,文件系统完胜。💡
我先做了暂停和终止,因为它们看起来像是安全功能。实际上我使用引导的频率是前两者的十倍。大多数介入不是"停止一切",而是"你缺少上下文,给你补充,继续"。让 Agent 能够在不丢失当前位置的情况下接收任务中途的备注,把很多本会成为"终止"的操作变成了小的修正。
任何有人类参与的自主系统,最终都会遇到人类不在场的情况。提前决定好这种情况下怎么办。让你能睡安稳的答案是"搁置任务,继续下一个"。让你周末泡汤的答案是" N 分钟后默认同意"。
不是日志。不是任务名。是 Agent 上一次说"我还活着"距今多少秒。其他任何东西都可能出错或过期,但这个数字告诉你能不能信任屏幕上的其他信息。
使用这个面板的人是在凌晨两点、半睡半醒、用手机操作的我。按钮要大。破坏性操作要有确认。常见路径不需要任何文本输入。Agent 是精密的;遥控器应该朴素。⚠️
有两个方向我正在推进:
审批打包。目前每个需要审批的操作都是独立的请求。当 Agent 在做迁移时,我会连着收到五个审批请求。我希望它能把相关的请求合并成一张卡片、一个决策。
只读分享。有个队友想观察 Agent 在共享版本库上工作,但不想能操控它。这意味着要把读和写的认证正确地分开,而不是靠在反向代理上偷懒。
更长期来看,我希望面板能展示 Agent 为什么正在做这件事,而不只是在做什么。如果心跳里加一个简短的"当前推理"字段,由 Agent 用一句话来写,会大大改善这种情况。
如果你正在跑任何一种长生命周期的 AI 编程 Agent,而你的介入方式还是"SSH 进去 kill 掉",那就做一个远程控制器吧。周末就能搞定。命令队列 40 行,审批门控 30 行,面板一个下午就够。你不会再想回到在手机上 tail -f 的日子了。
如果这篇文章对你有帮助,就在 Dev.to 上关注我 🚀 —— 我会陆续写出这个自主系统的其他部分:编排器、自我修复的 Agent 和可观测层。如果你自己也做过类似的东西,欢迎在评论区分享你的"引导"等价物是什么样的,我想借鉴你的思路。