1. 周末在外面,我的Agent卡死在家里的电脑上
这个周末我在外面办事,掏出手机想看看早上让Agent跑的数据清洗任务进行得怎么样了。结果它停在一个请求确认的节点上——发现订单表里有5000多条重复数据,要我决定是直接删除还是标记保留。而我人在高铁上,面前只有手机,打不开电脑,也没法登进那台跑着Agent的主机。
那一刻我意识到,我家里跑着的已经不是一个两个脚本,而是十几个干活的AI Agent:有定时抓新闻做摘要的,有分析销售数据的,有自动整理周报的,还有给客户项目做批量文件处理的。它们都长在自己的电脑或服务器里,平时很能干,可一旦需要中途介入、查看状态、调整参数,我就陷入了“只能干瞪眼”的状态。
所以后来我花了两周时间,搭了一套手机远程操控AI Agent的完整体系。这篇文章不是讲概念,全程是我的实际改造记录:包括Agent服务化改造思路、手机端控制通道的选型对比、统一调度服务的代码实现、飞书机器人的接入方法,以及这一年用下来踩过的坑和现在每天的真实操作流程。目标读者是那些已经自己搭过Agent、但是苦于“人在外面管不了”的人;如果你还没搭过Agent,也没关系,我会把涉及的基础原理一并讲清楚,你完全可以按文章的步骤从零照做。
先说一个容易被忽视的事实:手机并不是真的在跑Agent,手机只是变成“遥控器”。所有Agent仍然跑在性能足够的服务器或家用主机上,手机负责下发指令、查看状态、做确认决策。这套体系能否成立,核心在于能不能打通一条稳定可靠的控制通道,并且让Agent具备被外部调度的能力。
2. 先搞懂Agent的运行形态,再谈手机遥控
2.1 常见Agent架构下,哪些能被远程调度
我陆陆续续接触过不少Agent项目,也看过别人整理的AI Agent学习路线和主流架构讨论。总结下来,日常接触的Agent大致分三类,能不能被远程控制,直接看它属于哪种形态。
第一类是单机脚本型Agent。就是用一个Python脚本把大模型调用、工具调用、流程控制都写在一起,跑起来之后按顺序执行,执行完就退出。比如你写了一个调用某大模型接口做文本摘要的脚本,从命令行传参数进去,它跑完打印结果就结束。这种Agent最普遍,但也是最难远程调的——因为它没有常驻服务,没有对外接口,你只能“启动它”或“等它结束”,中间没法看进度,出了问题只能翻日志。
第二类是编排框架型Agent。比如LangGraph、CrewAI、AutoGen这类,它们引入了状态图、多角色协作、检查点持久化等机制,Agent可以在图中暂停、等待人工确认、恢复执行。这类Agent本身具备“可以被中断再恢复”的基础,只要在外部包一层调度入口,远程控制就顺理成章。
第三类是事件驱动常驻型Agent。最近Rust语言在Agent基础设施层越来越常见,因为很多团队希望Agent常驻在内存里,用事件驱动的方式监听外部输入,比如收到一条新消息就触发处理流程。Rust写出来的Agent运行时性能高、内存占用低、并发安全,适合用来承载多个Agent实例。我自己也把部分调度器换成了Rust写的守护进程,整体体验确实比Python常驻服务更稳。
所以“手机远程操控所有Agent”这句话,字面上有点夸张,实际含义是:把不同形态的Agent统一接到同一个调度服务上,然后手机只跟调度服务对话。要能做到这一点,前提就是Agent本身必须“可被调度”,也就是常驻化、服务化、接口化。
2.2 远程调度离不开的三个组件:任务队列、状态存储、日志
不管Agent内部用什么架构,远程控制说到底就是三件事:把任务发进去、把状态读出来、把结果拿回来。我把它们拆成了三个基础组件:
任务队列负责接收外部下发的新任务,避免“你手机一点,Agent这边手忙脚乱”。我一开始没做队列,手机端连续发两条指令,服务端直接重复启动了同一个Agent两次,一次数据写冲突就让任务全部失败。后来我改成:所有指令先落队列,再逐个消费,同一个Agent同时只允许一个任务在跑。
状态存储负责记住每个任务当前在干什么。这一步很重要,因为Agent任务常常跑几十分钟,你不可能一直盯着。我把每个任务的状态写成结构化记录:pending、running、waiting_confirmation、done、failed、killed。这样一来,手机端只需要问一句“现在什么状态”,调度服务就能给出准确回答。
日志是排查问题的窗口。远程场景下你看不见终端输出,唯一能信任的就是落盘的日志。我给每个Agent单独开一个日志文件,文件名带任务ID,调度服务提供日志查询接口。人在外面遇到Agent不干活的时候,先看日志基本能定位八成问题。
2.3 顺带聊两个“Token”,很多人一开始搞混
接触Agent接入工作的人,或者去查“Agent Token是什么意思”的时候,通常会碰见两种Token,含义完全不同,不能混为一谈。
第一种是模型计费层面的Token。大模型API按token数计费,一个token大约对应一个汉字的0.5到1.5倍不等。远程调度Agent的时候,你得关心每个任务大概消耗多少输入输出token,因为Agent在长任务里可能反复调用模型,累积成本很容易吓人一跳。我见过有人让Agent自动写周报,结果循环构建提示词越叠越长,一次任务烧掉几块钱,就是因为没控制上下文长度。
第二种是接口鉴权层面的访问Token。这是你自己生成的密钥,调度服务要求每个请求都带上,没带就拒绝。手机遥控场景下,调度服务暴露在网络上,如果接口裸奔,任何人都能往你的Agent下发任务,几行命令就能让你服务器变成别人的自动机器。所以这层Token必须加,后面安全章节我会详细讲配置。
这两件事搞不清,后面做调度方案的时候很容易在“成本控制”和“权限控制”上同时踩坑。
3. 手机遥控通道选型:SSH、Web控制台、IM机器人、管理平台怎么选
3.1 四种通道的真实体验对比
我最早想过直接用手机SSH连服务器。手机上的SSH客户端装了好几个,最终全部放弃。原因很实际:手机键盘用命令行操作实在太痛苦了,哪怕只是敲一行“python3 main.py --config x.yaml”,在屏幕上也要戳半天;更麻烦的是SSH会话在外面经常断,断了你都不知道Agent是不是还在跑,还是已经死掉了。SSH适合应急,不适合日常。
我也试过给Agent主机装一个Web管理控制台。这个方案体验好不少,手机浏览器打开就能看到任务列表、启动按钮、日志界面。问题在于Web控制台什么都得自己写,常见的那些Agent管理平台又和自研脚本耦合不上。为了几个任务专门做一套前端界面,短期可以,长期维护成本有点高。
我还试过直接用市场上现成的Agent管理平台和低代码编排工具。这类平台界面友好,可视化拖拽流程,部署起来快。问题是它们的工作流引擎跟我的自研Agent并不通用,我为了把现有十几个Agent迁进去,还得写一堆连接器,大大小小的接口适配非常琐碎,最后只保留了其中一个用在客户项目上。
最后我选的是“IM机器人通道”。原理很简单:在飞书群里建一个机器人,手机往群里发消息,机器人通过Webhook回调把内容推送到调度服务,调度服务解析指令并执行Agent,再把结果以消息形式发回群里。
这个方案真正解决了手机端的交互问题:我不需要打开SSH工具,不需要访问网页控制台,手机自带的消息应用就能完成全部操作。而且IM本身就是为移动端设计的,通知推送、历史记录、多端同步全都现成。
3.2 为什么IM机器人是最适合手机的入口
做一次简单对比就能看出来:
| 通道 | 手机端操作体验 | 二次开发成本 | 稳定性 | 推荐程度 |
|---|---|---|---|---|
| SSH命令行 | 差,键盘痛苦、易断线 | 低 | 看网络 | 应急用 |
| 自建Web控制台 | 好,可视化 | 高,前端后端都要写 | 取决于主机 | 有精力可做 |
| 现成Agent管理平台 | 好,开箱即用 | 中,接口适配费劲 | 依赖平台 | 适合新项目 |
| IM机器人 | 很好,消息即指令 | 低,解析文本即可 | 很稳,有推送保证 | 最推荐 |
我把IM机器人作为手机上的“主入口”,把Web管理API作为“管理面”留给电脑端或本机脚本调用,再留着SSH做最底层的兜底。三个通道职责分开:手机日常交互走IM,自动化运维走API,真出大事才上SSH。
3.3 网络通路:本地主机没公网IP怎么办
手机要远程控制,控制服务必须有公网可达的地址。最简单的场景是Agent跑在云服务器上,本身就有公网IP,那直接用Nginx反向代理挂一层HTTPS就完事。
但很多人和我情况类似,Agent跑在家里的一台Linux主机上,没有固定公网IP。我目前的拓扑是:家里主机只向公网服务器保持一条主动建立的加密通道;公网服务器上部署Nginx,把所有HTTPS请求通过这条通道转回家里主机的本地端口。手机和飞书机器人实际上都是跟公网服务器通信,家里的主机始终是“主动连出去”,不需要在路由器上开任何端口。
也就是说,最终从手机发起的链路是这样的:手机在飞书群里发送指令 → 飞书服务器回调到公网服务器 → Nginx把请求反向代理回家里主机的调度服务 → 调度服务校验Token并启动对应Agent → Agent产生结果后,调度服务再调飞书的Webhook接口把结果发回群里。
这条路线稳不稳定,取决于三件事:公网服务器的带宽和可用性、调度服务的健壮性、飞书回调的验签是否做对了。我会在下一章详细写调度服务本身的实现。
4. 把本地Agent改造成可远程调度的服务:完整实操
4.1 标准接入约定:每个Agent配一份manifest
既然目标是“手机遥控所有Agent”,第一步不是写代码,而是定一套接入标准。我在服务器上建了一个统一目录,叫/opt/agent-hub/agents/,每个Agent占一个子目录,里面必须有一个manifest.yaml文件,内容大概长这样:
name: weekly_report description: 每周自动汇总业务数据并生成报告 entry: main.py runtime: python3 timeout_sec: 1800 notify_on: - done - error - checkpoint调度服务启动时扫描所有Agent目录,读取manifest,把可用Agent全部注册进内存和数据库。这样新增一个Agent只需要丢一个目录进去,调度服务不需要改代码。
manifest里我特别设计了一个notify_on字段,用来声明这个Agent什么时候需要主动通知手机端。比如weekly_report会在“完成”“出错”“需要人工确认”这三种情况下触发消息推送。这么做的原因是策略和实现分离:Agent内部不用关心消息要发到哪里,它只需要往状态表里写一个事件,调度服务看到事件类型属于notify_on集合,就自动推送到飞书。后面如果想换通知渠道,只改调度服务一个地方。
4.2 用FastAPI写统一调度服务
我选FastAPI来写调度服务,没有特别复杂的原因,就是它写异步接口快、自带Swagger文档、性能足够支撑这种小规模调度场景。核心文件是/opt/agent-hub/app.py。
先看最基础的启动接口实现:
import os import asyncio import sqlite3 import subprocess from datetime import datetime from fastapi import FastAPI, Request, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials app = FastAPI() AGENT_TOKEN = os.getenv("AGENT_TOKEN", "") security = HTTPBearer() @app.post("/api/agents/{name}/start") async def start_agent( name: str, request: Request, credentials: HTTPAuthorizationCredentials = Depends(security) ): if credentials.credentials != AGENT_TOKEN: raise HTTPException(status_code=401, detail="invalid token") manifest = load_manifest(name) if not manifest: raise HTTPException(status_code=404, detail="agent not found") body = await request.json() args = body.get("args", []) # 同一Agent不允许并发运行 running = get_running_task(name) if running: raise HTTPException(status_code=409, detail=f"agent already running, task_id={running['id']}") log_path = f"/var/log/agent-hub/{name}_{now_str()}.log" proc = await asyncio.create_subprocess_exec( manifest["runtime"], f"/opt/agent-hub/agents/{name}/{manifest['entry']}", *args, stdout=open(log_path, "a"), stderr=subprocess.STDOUT, cwd=f"/opt/agent-hub/agents/{name}" ) task_id = create_task(name, proc.pid, status="running", log_path=log_path) return {"task_id": task_id, "pid": proc.pid, "log": log_path}这里有几个关键点:
第一,认证走HTTPBearer,每次请求必须带Authorization: Bearer <token>。我生成Token之后放在/opt/agent-hub/.env里,由systemd服务加载,不硬编码到代码中。
第二,同一个Agent只能并发跑一个任务。这个限制极其重要,我没有一开始就加,后来被数据重复处理坑了一次。原因很直接:Agent大多操作的是同一组文件或数据库,两个实例同时跑,轻则互相覆盖写坏数据,重则触发重复扣费。
第三,我用asyncio.create_subprocess_exec而不是os.system,主要是为了拿到异步进程句柄,后续可以做超时控制和停止操作。Agent进程的stdout和stderr都重定向到同一个日志文件,远程排查时只看这个文件就够了。
停止接口和查询接口也一并实现了:
@app.post("/api/agents/{name}/stop") async def stop_agent(name: str, request: Request, credentials=Depends(security)): # 鉴权同上 running = get_running_task(name) if not running: raise HTTPException(status_code=404, detail="no running task") pid = running["pid"] try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass update_task_status(running["id"], "stopped") return {"task_id": running["id"], "status": "stopped"}停止用的是SIGTERM,给Agent一个机会处理收尾逻辑,比如回滚未完成的写入、释放临时文件。某些Agent处理不了SIGTERM,卡在阻塞调用上,这时候就得靠超时机制强制SIGKILL,稍后会说。
查询状态的接口就更简单了,直接读SQLite里最新一条任务记录:
@app.get("/api/agents/{name}/status") async def agent_status(name: str, credentials=Depends(security)): run = get_latest_task(name) if not run: return {"agent": name, "status": "idle"} return {"agent": name, "status": run["status"], "start_time": run["start_time"]}4.3 任务表设计:远程调度的“记账本”
调度服务的数据都落在SQLite里,主要是因为它零配置、单文件、备份方便。任务表建表语句这样写:
CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_name TEXT NOT NULL, status TEXT NOT NULL, pid INTEGER, args TEXT, start_time TEXT NOT NULL, end_time TEXT, exit_code INTEGER, log_path TEXT, error_msg TEXT );我在实践中往这个表里加了两列很有用的信息:args和error_msg。args记录每次任务的参数,方便事后回溯“当时到底跑了什么”。error_msg记录Agent非零退出时的最后一段错误摘要,这样手机端查状态的时候,不需要打开日志就能先看到大概的错误原因。
状态更新由两类途径触发:一是手机端显式调用停止接口,二是Agent进程退出时,调度服务的后台监控协程发现进程结束了,自动把状态从running改成done或failed。
这个“后台监控协程”是整套系统稳定性的关键。我的做法是启动一个常驻的asyncio任务,每秒扫描一次所有running状态的任务,检查对应的PID是否还存在。如果发现进程已经不在了,就读取进程退出码,退出码为0则标done,非0则读日志最后200个字节存到error_msg,标记failed,并触发飞书通知。
4.4 用systemd让调度服务常驻
调度服务本身不能因为终端关闭就退出。我写了一个systemd unit文件,放在/etc/systemd/system/agent-hub.service:
[Unit] Description=Agent Hub Control Service After=network.target [Service] WorkingDirectory=/opt/agent-hub EnvironmentFile=/opt/agent-hub/.env ExecStart=/usr/bin/python3 /opt/agent-hub/app.py Restart=always RestartSec=5 User=agent Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target注意几个细节:Restart=always保证进程崩溃后5秒自动拉起;EnvironmentFile用来加载环境变量,Token不会出现在代码仓库里;User=agent用一个专用系统账号运行,避免权限过大。
部署完执行:
sudo systemctl daemon-reload sudo systemctl enable --now agent-hub sudo systemctl status agent-hub4.5 接入飞书机器人:手机发第一条指令
调度服务具备API后,接下来把飞书机器人接上。步骤不复杂:
在飞书开放平台创建企业自建应用,添加“机器人”能力,拿到App ID和App Secret;然后在事件订阅里配置请求地址为公网服务器的回调URL,比如https://agent.example.com/webhook/feishu;再开通接收消息的权限,订阅im.message.receive_v1事件。
调度服务加一个回调路由:
@app.post("/webhook/feishu") async def feishu_webhook(request: Request): data = await request.json() token = data.get("token", "") if token != FEISHU_VERIFICATION_TOKEN: raise HTTPException(status_code=401, detail="invalid feishu token") # 首次配置时飞书会发一个challenge请求,需要原样返回 if "challenge" in data: return {"challenge": data["challenge"]} event = data.get("event", {}) msg_type = event.get("message", {}).get("message_type") if msg_type != "text": return {"code": 0} text = event["message"]["content"] chat_id = event["message"]["chat_id"] # 解析指令并执行,后续加详细处理逻辑 await handle_command(text, chat_id) return {"code": 0}注意飞书的回调有加密开关,我建议开启加密模式。开启后事件内容里的content不是明文,需要用Encrypt Key解密。我踩过这个坑,一度以为是代码bug,调试半天才发现是没配置加密。加密模式实际配置也很简单,飞书SDK里封装了解密方法,把回调数据传进去就得到明文字典。
handle_command这个函数负责解析手机端发来的消息文本。我目前支持的指令格式很简单,每个Agent一行,能覆盖日常九成需求:
/run weekly_report:启动weekly_report这个Agent,无参数/run data_clean --source=orders_2025.csv --mode=dedupe:启动Agent并传参/status weekly_report:查询某个Agent当前状态/status:查询所有Agent状态/stop weekly_report:停止Agent任务/logs weekly_report:返回最近50行日志
解析逻辑初版用正则匹配每个指令前缀,后面我换成了更通用的规则:第一段必须是斜杠加动作,后面接Agent名,再用--key=value的形式传参。这套规则在飞书里很好使,手机输入法也会记忆常用指令,熟练后甚至比在电脑上敲命令快。
指令上来之后,调度服务会回调飞书另一个接口——发送消息Webhook,把执行结果推回群里。发送消息需要拿tenant_access_token,飞书API要求先用App ID和App Secret换取。我会在第一次发送时缓存Token,等快过期再刷新,避免每次都重新请求。
到这里,整条链路就通了:我在手机上打开飞书群,输入/run weekly_report,发送,几秒后群里的机器人回复“任务已启动,task_id=123,当前状态running”。
5. 手机端的一天:从下发任务到交付结果
5.1 通勤路上的一次完整任务流
我用一个真实的工作流来展示手机日常操作。早高峰地铁上,我接到另一个项目组需求,说有一份新的订单导出文件需要清洗并做基础分析。
手机操作如下:打开飞书群,给机器人发一条消息:
/run data_pipeline --source=orders_2025_latest.csv --clean=true --gen_report=true调度服务解析参数后,把指令原样存进任务表的args字段,然后启动Agent进程。Agent开始跑,第一步读取csv文件,第二步调用大模型接口做字段语义识别,第三步按规则清洗重复项,第四步生成分析报告。
我这边收到飞书推送一条消息:
任务已启动:data_pipeline task_id: 245 预计耗时:约30分钟 日志:/var/log/agent-hub/data_pipeline_2025xxxx.log然后我该干嘛干嘛。二十分钟后,群里又弹出一条消息:
⏸ 任务需要确认:data_pipeline 发现订单表存在重复记录 5021 条。 建议操作:输入 /confirm 245 yes 继续删除,或 /confirm 245 no 跳过。这就是前面提到的checkpoint机制。Agent在跑的过程中发现数据质量和预期不符,它没有自己做决定,而是触发了一个需要人工确认的事件。在普通本地场景里,这个确认动作是在终端里敲一个y;现在改成手机上回一条消息。
我在手机上输入/confirm 245 yes,调度服务更新任务状态为running,并把确认信号写到一个临时确认文件里。Agent轮询到这个确认文件后,继续执行删除重复记录和生成报告的工作。又过了十分钟,机器人推送最终结果:
✅ 任务完成 data_pipeline task_id: 245 删除重复记录:5021条 生成报告:orders_2025_cleaned_summary.md 报告摘要:本周订单量较上周增长12.4%,退货率下降1.8%。整个过程我只打开了飞书,全程没有碰电脑。这是最典型的“远程操控所有Agent”的日常。
5.2 中途人工确认是怎么回传的
上面出现的“人工确认”是整套设计里我最看重的能力。很多Agent任务跑得久不是因为计算量大,而是因为它跑一会儿就需要人做判断。这种判断如果只能坐在电脑前做,那远程控制的价值就少了一半。
我的实现思路不复杂:在调度服务里维护一个waiting_confirmation状态和一个确认桶。Agent在执行到需要判断的节点时,不是自己卡住,而是向调度服务写入一条中间事件,然后进入一个只要不收到确认信号就一直挂起的等待状态。
# Agent内部代码示意 def need_human_confirm(question): # 写事件到状态表 write_event("checkpoint", question) # 等待确认文件出现,最多等1800秒 for _ in range(1800): if os.path.exists(f"/tmp/confirm_{task_id}"): result = read_confirm(f"/tmp/confirm_{task_id}") return result time.sleep(1) raise TimeoutError("等待人工确认超时")手机端确认时,调调度服务的/api/tasks/{task_id}/confirm接口,传入yes或no,调度服务把答案写入确认文件,Agent的轮询循环读取到结果后继续跑。
这个方案虽然朴素,但胜在稳定,没有任何网络IPC的额外依赖。Agent本地文件系统就是控制面和执行面的共享通道。对远程控制来说,能少一个中间环节就少一个,因为每一层都可能断。
5.3 定时任务、状态批量查询和日志追踪
手机遥控不光是“手动下发任务”,很多Agent是周期性的。比如我每天早上要看竞品动态,这个需求如果每天手动发一次指令就太傻了。
我给调度服务加了一个简单的定时任务表,支持cron表达式。手机端发一条消息:
/schedule add daily_news 0 8 * * * /run news_summary调度服务解析后写入SQLite定时任务表,然后每天凌晨8点自动调用一次news_summaryAgent。任务跑完的结果照常推送到飞书群,也就是说我早上起床打开手机,群里已经躺着一份整理好的竞品动态摘要。
查询所有Agent状态的指令是/status,返回结果是一个列表,每条显示Agent名、当前状态、最近一次任务的起止时间。我用这个指令代替了“登服务器敲ps aux”这个动作,整个调度面都搬到了手机聊天框里。
日志追踪这块,我在/logs指令里返回最近50行日志时,会先做一次简单的内容过滤,把明显的堆栈信息、API错误码高亮成人类可读的摘要。我不建议把原始日志全部推到手机上,屏幕小,刷起来累,而且很多日志是给机器看的,人眼直接读反而容易漏掉关键信息。
6. 远程控制最容易翻车的两个环节:安全与恢复
6.1 鉴权、验签、最小暴露面的三层防线
远程控制的第一道线是安全。我这套系统牵涉到两条对外通道:飞书回调地址和Agent调度API。如果任一条被滥用,风险都不小。
先说调度API。它只监听在本地127.0.0.1的8000端口,并没有直接对公网开放。公网Nginx只做两件事:把飞书回调路径/webhook/feishu反向代理到本地8000端口;把手机上需要访问的HTTPS请求代理过去,并在Nginx层做基础鉴权。
更关键的一层是回调验签。飞书事件订阅有Verification Token,每次回调都会带上。我在webhook入口校验这个Token,不一致直接拒绝。如果你同时开启了加密模式,还要先解密再校验消息里的时间戳,防止重放攻击。我自己因为偷懒,最初只校验了Token没解密,结果发现攻击者可以伪造消息体,后来老老实实把解密加上,安全感完全不同。
第三是权限最小化。调度服务运行在一个名叫agent的系统账号下,这个账号只对/opt/agent-hub和/var/log/agent-hub有读写权限,没有sudo权限,也不能访问其他系统目录。就算调度接口被攻破,攻击者能控制的资源也极其有限。
这三层做下来,我从手机端发起一个指令到Agent真正开始执行,中间要经过:飞书服务端 → 公网Nginx → 飞书验签 → 调度API Token校验 → 系统账号权限隔离。每一层都是独立认证,任何一层被绕过,后面还有兜底。
6.2 进程崩溃、任务挂死、超时熔断的恢复手段
远程控制最大的噩梦是Agent进程挂了你不知道,或者它没挂但永远跑不完。这两种情况都需要自动恢复机制。
我监控协程每秒钟检查一次running任务的PID,发现进程消失就自动更新状态并通知手机端。这解决了“进程意外退出没人知道”的问题。
真正难处理的是“进程还活着但卡死了”。有些Agent调第三方API时会无限重试,有些会在循环里出不来。我采用的策略是双层超时:
第一层在manifest里配置timeout_sec,任务运行超过这个时间,调度服务先发一个通知到飞书,提醒“任务即将超时”,然后发送SIGTERM。如果SIGTERM发出后60秒进程还不退出,再发SIGKILL强制结束,同时把任务标记为failed,并记录一段“agent stopped due to timeout”到error_msg。
第二层是日志大小兜底。远程控制的Agent经常在循环里打日志,日志文件无限增长会很快占满磁盘。我写了个简单的rotate逻辑:日志文件超过50MB时,调度服务自动把当前日志改名归档,并重启Agent进程,同时推送告警。这样即使Agent失控,磁盘也不会先崩溃。
6.3 实测中最容易踩的三个坑
这套系统跑了将近一年,踩了不少坑,挑三个最有代表性的说。
第一个坑是重复回调。飞书的Webhook机制在网络异常时会自动重试同一条消息,也就是说我发一次/run weekly_report,调度服务可能收到两三次。我没做幂等处理,结果某周一早上收到飞书群三条“任务已启动”的推送,一查数据库,同一个任务被创建了三条记录,Agent进程也被起了三个。修复方案就是在创建任务前先查同名Agent是否已有running状态,有就直接拒绝,同时给Webhook事件加一个event_id去重表。
第二个坑是子进程变成僵尸。FastAPI的异步创建子进程后,如果父进程没有正确调用wait()或communicate(),执行完的子进程会残留成僵尸进程,占据PID直到父进程退出。我的监控协程一开始只检查PID是否存活,没回收僵尸进程,系统运行几天后PID被大量占用。后来在协程里补上了proc.wait()调用,主动回收退出码,这个问题才彻底解决。
第三个坑是Agent内部写死绝对路径。好几个Agent改造成远程调度后,在手机端下发参数明明都传对了,任务还是秒失败。最后查日志发现,Agent代码里硬编码了/home/olduser/data/source.csv这种路径,换账号换目录之后找不到文件。这不算技术问题,但远程控制场景最常见。我现在的做法是:调度服务启动Agent前,先检查manifest里声明的输入文件路径是否存在,不存在就直接在飞书群里返回错误,而不是等Agent跑到一半才报错。
7. 一年用下来,有些话想对做Agent的人说
先说结论:手机远程操控Agent这件事,真正价值并不是让你在路边拿手机“写代码”,而是把Agent从“只能在本机面前操作的程序”变成“随时可以响应你指令的远程同事”。我觉得这套体系最适合三类场景:需要跑几十分钟以上的长任务、需要在执行中人工确认判断的批处理、以及每天固定时间点自动跑的定时任务。反过来,那些几秒钟就能跑完、结果当场就打印的交互式小工具,真没必要纳入手机遥控体系,手机反而打断了你直接在电脑上操作更顺手的工作流。
如果有计划做类似的远程控制体系,我会建议从一开始就把“控制面”和“执行面”分开。控制面是手机、IM机器人、API这套东西,它只关心指令下发和状态回传;执行面是Agent本体,它只关心怎么完成业务任务。你在开发任何新Agent的时候,都顺手给它留一个标准入口和标准事件输出,将来接入调度服务就是往里丢一个manifest的事。这样坚持下来,“所有Agent”这句口号就会越来越接近现实。
我个人在后半年的操作习惯也稳定下来了:早上出门前会用手机看一眼后台Agent状态,像出门前确认门窗有没有关;白天有临时数据需求直接在飞书里一条指令发下去,通勤路上的碎片时间顺手做完确认;晚上到家后打开电脑翻一遍当天的任务日志,把异常记录归档到周报里。手机遥控的不是某种炫技,而是一套让自己不用总守在电脑前的从容感。