大家在做 Agent 开发或者部署 AI 应用时,有没有遇到过这种情况:为了让一个自动化任务 7×24 小时在线,特意开了一台云服务器或者本地虚拟机,配好 Python 环境、拉起 Agent 服务。结果一周下来,真正跑任务的时间可能只有几个小时,剩下的时间机器都在空转。CPU 占用不高,内存占着,云账单却照样扣费。本文围绕这个典型场景,从常驻 VM 到按需算力,完整梳理 Agent 运行环境的选型、部署、调度和成本控制方案。无论你是刚接触 Agent 开发的新手,还是已经在生产环境维护 Agent 服务的开发者,都能在这篇文章里找到可以落地的思路和代码。
先说清楚一个概念:Agent 空转指的是 Agent 进程在运行,但并没有实际处理请求或执行有效任务。它可能是在等待队列里的消息,可能在轮询某个接口,也可能只是你没有正确设置退出条件。无论哪种情况,空转都意味着三件事:浪费算力、浪费内存、浪费成本。更麻烦的是,空转还会掩盖一些代码问题,比如循环里没有 sleep 导致的 CPU 占用、长连接没有设置超时导致的线程堆积。这些坑如果不提前处理,等流量一上来,问题就会集中爆发。
1. Agent 的运行环境与算力模型
1.1 Agent 到底是什么
Agent 一词在开发社区里出现频率越来越高。不同场景下,Agent 的含义差别很大:
- 在 AI 领域,Agent 通常指能够感知环境、做出决策并执行动作的智能体,比如基于大语言模型(LLM)的自主 Agent,能够调用工具、阅读文档、操作浏览器。
- 在自动化运维领域,Agent 往往指部署在目标机器上的守护进程,负责采集指标、执行命令、上报状态。
- 在 RPA 领域,Agent 则侧重于模拟人工操作,处理表格、网页、客户端应用。
本文讨论的 Agent 更偏向第一类:具备自主决策能力的 AI Agent,同时兼有常驻服务的特性。也就是说,它既要能接收外部请求,又要能根据内部状态自主触发任务。
1.2 常驻 VM 的算力模型
常驻 VM(虚拟机)是跑 Agent 最朴素的方式。它的算力模型是“包月制”:你买一个固定规格的虚拟机,比如 2 核 4G,每个月支付固定费用,无论 CPU 利用率是 0% 还是 99%,价格都一样。
这种模型的优点是:
- 环境稳定,状态持久化,Agent 的会话数据、临时文件都可以保留在磁盘上。
- 调试方便,SSH 上去就能看日志、改代码、重启服务。
- 网络固定,可以使用固定的公网 IP 或内网 IP,方便回调、Webhook。
缺点也很明显:
- 成本不随业务量变化,低峰期也在花钱。
- 单点风险,虚拟机一旦宕机,Agent 就终止。
- 资源利用率低,如果 Agent 是间歇性任务,大量算力被浪费。
1.3 按需算力的计算模型
按需算力是相对于常驻资源而言的。它的核心思想是:只在需要执行任务时创建计算资源,任务执行完毕就释放资源。按量计费、弹性扩缩容。
具体表现形式有:
- 容器化部署:任务触发时启动容器,任务结束后销毁容器。
- Serverless 函数:将 Agent 的核心逻辑封装成函数,由平台自动调度。
- 竞价实例 / 弹性实例:使用云厂商提供的低成本实例,配合自动释放机制。
- 混合模式:保留一个常驻调度器,按需拉起工作节点。
按需算力的优点是成本与业务量挂钩,低峰期不花钱,高峰期不会被打满。缺点是需要额外的调度和控制逻辑,架构复杂度会上升。
1.4 为什么 Agent 特别适合按需算力
这是本文的核心观点之一。传统 Web 服务需要常驻,因为用户随时随地可能发请求,响应延迟要低。但很多 Agent 任务并不是实时的,它的执行模式通常是“触发-运行-结束-等待下一次触发”。
比如:
- 每天凌晨抓取外部数据,处理后写入数据库。
- 每周生成一份业务分析报告。
- 收到邮件通知后,自动执行某个流程。
- 用户通过聊天机器人提交任务,Agent 执行后台操作。
这类任务的共同点是:执行时间有限,执行间隔较长。如果用常驻 VM,一整天都在空转;如果用按需算力,任务触发时才启动环境,跑完立即释放,成本能下降一个量级。
1.5 本文的技术选型范围
为了让文章有实操价值,下面会围绕几个主流方向展开:
- 本地虚拟机(VMware / VirtualBox)搭建 Agent 开发环境。
- Linux 常驻服务方式部署 Agent(systemd 管理)。
- 基于 Docker 实现按需算力调度。
- 基于 Python 实现一个最小可用的 Agent 调度器。
- 数据库建表、任务状态管理和异常重试。
这些方案不绑定特定云厂商,你可以根据自己的环境移植。
2. 环境准备与版本说明
2.1 基础环境
为了完整演示本文案例,建议准备以下环境。如果你已经有类似的开发环境,可以跳过部分内容,但建议统一看一下版本兼容问题。
操作系统:本文示例在 Ubuntu 22.04 LTS 上验证通过。Windows 和 macOS 的差别主要在于虚拟机软件和 systemd 的使用上,如果你在 Windows 上开发,建议使用 WSL 2 或虚拟机。
开发语言:Python 3.10+。Agent 开发领域 Python 生态最完善,本文的核心调度器也用 Python 编写。如果你使用 Node.js 或 Go,思路是通用的,只需替换代码实现。
容器运行时:Docker 20.10+,Docker Compose v2。如果你不想安装 Docker,也可以把按需算力的方案退化为 Python 脚本直接拉起子进程,但容器更接近生产环境。
虚拟机软件(可选):VMware Workstation Pro 或 Oracle VM VirtualBox。如果你需要在本地跑虚拟机模拟常驻 VM 环境,可以参照第二部分。
数据库(可选):SQLite 或 PostgreSQL。任务调度状态存储用 SQLite 演示很方便,生产环境建议换 PostgreSQL 或 MySQL。
2.2 版本兼容性提醒
以下坑点非常常见,先提前列出来:
- VMware 和 WSL 2 的 Hyper-V 冲突。如果你启用了 Windows 的 Hyper-V 或 WSL 2,VMware Workstation 可能会报错无法运行虚拟机。解决办法是在 Windows 功能中关闭 Hyper-V,或者使用支持 Hyper-V 的 VMware 版本。当前主流版本的 VMware Workstation Pro 已经兼容 WSL 2,如果你使用的是旧版本,建议升级。
- Python 3.9 和 Python 3.10 在类型语法上有差异,部分 Agent 框架对版本有要求。建议使用 pyenv 或 conda 管理 Python 版本。
- Docker Desktop 在 Windows 上依赖 WSL 2,安装前确保 BIOS 中开启了虚拟化。
2.3 推荐项目结构
agent-on-demand/ ├── agent/ │ ├── __init__.py │ ├── core.py # Agent 核心逻辑 │ ├── tasks.py # 任务定义 │ └── config.py # 配置文件 ├── scheduler/ │ ├── __init__.py │ ├── scheduler.py # 调度器入口 │ └── storage.py # 任务状态管理 ├── deploy/ │ ├── Dockerfile # Agent 容器镜像 │ └── docker-compose.yml # 按需算力编排 ├── scripts/ │ ├── start.sh # 启动脚本 │ ├── stop.sh # 停机脚本 │ └── health.sh # 健康检查 ├── data/ │ └── tasks.db # SQLite 数据库文件 └── requirements.txt2.4 安装 Python 依赖
mkdir -p agent-on-demand cd agent-on-demand python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install fastapi uvicorn requests psycopg2-binary关于依赖的说明:
- fastapi 和 uvicorn 用于提供 HTTP 接口,方便触发 Agent 任务或查询任务状态。
- requests 用于调用外部 API,模拟 Agent 的实际业务逻辑。
- psycopg2-binary 是 PostgreSQL 驱动,如果使用 SQLite 可以不安装。
如果你只需要调度器,不需要 HTTP API,可以只安装 requests。不过加一个 HTTP 接口在调试的时候会方便很多。
2.5 安装 Docker 与 Docker Compose
sudo apt update sudo apt install -y docker.io docker-compose-v2 sudo usermod -aG docker $USER newgrp dockerDocker 的作用是把 Agent 代码打包成镜像,按需创建容器实例。后面的按需算力调度器会通过 Docker SDK 或命令行来拉起和停止容器。
安装完成后验证一下:
docker --version docker compose version如果你使用 Windows 或 macOS,直接安装 Docker Desktop 即可。
3. 常驻 VM 方案:稳定但昂贵的 Agent 运行基座
3.1 虚拟机与物理机的选择
先回答一个问题:开发 Agent 时,到底需不需要虚拟机?
如果你的目标只是写一个简单的脚本,每次手动运行,那完全不需要虚拟机,直接在本机跑就行。虚拟机适合这些场景:
- 你需要模拟多个操作系统环境,验证 Agent 在不同系统上的行为。
- 你希望把开发环境和一个隔离的“生产环境”分开,避免污染本机依赖。
- 你需要在团队中共享环境配置,虚拟机快照比重新搭环境更省事。
- 你的 Agent 需要访问特定设备或驱动,直接运行在宿主机上存在安全隐患。
如果你选择了虚拟机,开发时建议分配如下配置:
| 资源项 | 推荐值 | 说明 |
|---|---|---|
| CPU | 2 核 | 日常开发调试够用,编译大型依赖时可临时调高 |
| 内存 | 4 GB | Agent 框架 + Python 解释器 + 浏览器自动化建议至少 4GB |
| 磁盘 | 40 GB | 建议使用动态分配磁盘,初期占用小,后续按需扩容 |
| 网络 | NAT 或桥接 | 开发环境用 NAT 方便,生产模拟用桥接 |
3.2 在虚拟机中安装 Ubuntu 的要点
在 VMware 或 VirtualBox 中安装 Ubuntu,有几个点要注意:
- 安装时建议选择“最小安装”,避免带入大量用不到的软件包。
- 磁盘分区选择“使用整个磁盘并设置 LVM”,方便后期扩容。
- 用户名建议用全小写英文,避免某些工具对非 ASCII 路径兼容性差。
- 安装完成后先更新系统,再安装开发环境。
sudo apt update sudo apt upgrade -y sudo apt install -y build-essential git curl wget3.3 在常驻 VM 中部署 Agent 服务
假设你已经有一个 Agent 服务,入口文件是agent/main.py。为了让它在虚拟机重启后自动启动、崩溃后自动拉起,我们使用 systemd 来管理。
创建一个 systemd 服务文件:
sudo vim /etc/systemd/system/agent.service文件内容如下:
[Unit] Description=AI Agent Service After=network.target Wants=network-online.target [Service] Type=simple User=ubuntu WorkingDirectory=/home/ubuntu/agent-on-demand Environment="PYTHONUNBUFFERED=1" Environment="AGENT_ENV=production" ExecStart=/home/ubuntu/agent-on-demand/venv/bin/python /home/ubuntu/agent-on-demand/agent/main.py Restart=on-failure RestartSec=10 KillSignal=SIGINT TimeoutStopSec=30 [Install] WantedBy=multi-user.target各个配置项的作用:
After=network.target确保网络服务先启动。Restart=on-failure表示 Agent 异常退出时 systemd 会自动拉起。RestartSec=10设置重启间隔,避免频繁重启。KillSignal=SIGINT让 Agent 收到 SIGINT 信号后可以执行清理逻辑。TimeoutStopSec=30给 Agent 30 秒时间去优雅退出。
启动并设置开机自启:
sudo systemctl daemon-reload sudo systemctl enable agent.service sudo systemctl start agent.service查看运行状态:
sudo systemctl status agent.service journalctl -u agent.service -f如果日志里出现ImportError或者ModuleNotFoundError,大概率是WorkingDirectory或者虚拟环境路径没配对。
3.4 常驻 VM 的资源空转问题
systemd 管理只是保证 Agent 稳定运行,但没有解决资源空转的问题。
假设你的虚拟机是 4 核 8G,Agent 是一个每天凌晨跑一次的数据处理任务,其余时间都在等待定时触发。那么:
- CPU 利用率长期低于 5%。
- 内存占用固定 2GB 以上(包含了操作系统、Python 解释器、加载的库)。
- 云厂商依然按包月价格收费。
- 磁盘写入不多,但机器占用时间拉满。
如果你只运行一个小 Agent,常驻 VM 的单月成本可能比实际算力消耗高 5 到 10 倍。当 Agent 数量增长到几十个时,这笔成本就更可观了。
3.5 什么时候选择常驻 VM
按需算力不是万能的。有些场景依然必须使用常驻 VM:
- Agent 需要维持长连接(WebSocket、TCP),外部服务会主动推送数据,不能断线。
- Agent 依赖本地文件系统存储大量状态,并且需要实时读取。
- Agent 对外提供低延迟 API,按需启动会带来秒级甚至分钟级冷启动延迟。
- 你的 Agent 依赖特定的 GPU 驱动和 CUDA 环境,容器化的复杂度很高。
在这些场景下,常驻 VM 的稳定性优势可以覆盖成本劣势。更合理的做法是“常驻一个轻量调度器 + 按需拉起重活”,但这个方案我们在下一章展开。
4. 按需算力方案:让 Agent 真正“用完即走”
4.1 整体架构设计
按需算力方案的核心是三个组件:
- 调度器(Scheduler):负责接收任务触发信号,记录任务状态,调用容器运行时创建执行环境。
- 执行器(Executor):实际运行 Agent 逻辑的容器实例,跑完就退出。
- 存储(Storage):保存任务状态、执行日志、重试次数。
整个流程是:
- 外部通过 HTTP 接口提交一个任务。
- 调度器把任务写入数据库,状态为 pending。
- 调度器调用 Docker API 创建容器,容器里运行 Agent 程序。
- 容器内部执行完毕后退出,返回结果。
- 调度器检测到容器退出,更新任务状态为 succeeded 或 failed。
这样一来,只有任务执行期间才有容器在消耗 CPU 和内存。任务提交前和执行结束后,整个集群的状态是干净的。
4.2 用 Dockerfile 封装 Agent 运行环境
先在项目根目录创建 Dockerfile:
# 文件路径:deploy/Dockerfile FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY agent/ ./agent/ COPY scheduler/ ./scheduler/ ENV PYTHONUNBUFFERED=1 ENV AGENT_ENV=container CMD ["python", "agent/main.py"]为什么使用python:3.10-slim而不是完整版python:3.10?
- slim 镜像体积小,拉取速度快,冷启动更快。
- 基础工具已经够用,如果 Agent 需要编译依赖,可以在安装时临时安装 build-essential。
- 生产环境尽量追求镜像最小化,减少攻击面。
4.3 编写 Agent 核心逻辑
为了让示例完整,这里写一个轻量 Agent 核心模块。它的作用是接收一个任务参数,然后执行“模拟耗时任务”,最终把结果写入标准输出。
# 文件路径:agent/main.py import time import json import os import sys def run_task(task_payload: dict) -> dict: """ 核心任务执行函数。 这里只是示例,实际项目可以替换成调用 LLM API、处理文件、爬取网页等操作。 """ task_id = task_payload.get("task_id", "unknown") duration = task_payload.get("duration", 5) print(f"[Agent] Task {task_id} started, sleeping {duration}s", flush=True) # 模拟一个耗时操作 for i in range(duration): time.sleep(1) progress = int((i + 1) / duration * 100) print(f"[Agent] progress: {progress}%", flush=True) result = { "task_id": task_id, "status": "success", "worker": os.uname().nodename, "message": f"Task {task_id} completed." } print(f"[Agent] Task {task_id} finished: {json.dumps(result)}", flush=True) return result if __name__ == "__main__": payload = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {} run_task(payload)这里有几个细节:
flush=True保证输出立即写入标准输出,防止容器日志延迟。os.uname().nodename在容器里返回的是容器的主机名,可以用来确认当前任务是在哪个容器里执行的。- 通过命令行参数传入任务参数,比环境变量更直观,适合 JSON 格式的任务负载。
4.4 编写调度器核心代码
调度器是核心中的核心。这里用 Python 的subprocess调用 Docker 命令来创建和销毁容器,避免引入额外的 Docker SDK 依赖,代码更透明。
# 文件路径:scheduler/scheduler.py import json import subprocess import time import sqlite3 import uuid from datetime import datetime, timezone DB_PATH = "data/tasks.db" DOCKER_IMAGE = "agent-on-demand:latest" DB_CONTAINER_NAME_PREFIX = "agent-worker" def init_db(): """初始化任务状态表""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, error_msg TEXT ) """) conn.commit() conn.close() def create_task(payload: dict) -> str: """创建一条新任务,返回任务ID""" task_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute( "INSERT INTO tasks (id, payload, status, created_at) VALUES (?, ?, ?, ?)", (task_id, json.dumps(payload), "pending", now) ) conn.commit() conn.close() return task_id def update_task_status(task_id: str, status: str, error_msg: str = ""): """更新任务状态""" now = datetime.now(timezone.utc).isoformat() conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() if status == "running": cursor.execute( "UPDATE tasks SET status = ?, started_at = ? WHERE id = ?", (status, now, task_id) ) elif status in ("succeeded", "failed"): cursor.execute( "UPDATE tasks SET status = ?, finished_at = ?, error_msg = ? WHERE id = ?", (status, now, error_msg, task_id) ) else: cursor.execute( "UPDATE tasks SET status = ? WHERE id = ?", (status, task_id) ) conn.commit() conn.close() def run_docker_container(task_id: str, payload: dict) -> bool: """ 拉起一个容器执行 Agent 任务。 容器名带有 task_id 的后缀,保证唯一性。 """ container_name = f"{DB_CONTAINER_NAME_PREFIX}-{task_id[:8]}" payload_json = json.dumps(payload) cmd = [ "docker", "run", "--rm", "--name", container_name, "--memory", "512m", "--cpus", "0.5", DOCKER_IMAGE, "python", "agent/main.py", payload_json ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) print(f"[Scheduler] Stdout: {result.stdout}") print(f"[Scheduler] Stderr: {result.stderr}") return result.returncode == 0 except subprocess.TimeoutExpired: print(f"[Scheduler] Task {task_id} timed out, killing container...") subprocess.run(["docker", "kill", container_name], capture_output=True) return False def process_pending_tasks(): """查询所有 pending 状态的任务,依次执行""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute("SELECT id, payload FROM tasks WHERE status = 'pending'") rows = cursor.fetchall() conn.close() for row in rows: task_id, payload_str = row payload = json.loads(payload_str) print(f"[Scheduler] Start task {task_id}") update_task_status(task_id, "running") try: success = run_docker_container(task_id, payload) if success: update_task_status(task_id, "succeeded") else: update_task_status(task_id, "failed", error_msg="container exit code != 0") except Exception as e: update_task_status(task_id, "failed", error_msg=str(e)) print(f"[Scheduler] Task {task_id} done.") if __name__ == "__main__": init_db() # 示例:模拟提交两个测试任务 sample_task_1 = { "task_id": "demo-001", "duration": 3 } sample_task_2 = { "task_id": "demo-002", "duration": 5 } tid1 = create_task(sample_task_1) tid2 = create_task(sample_task_2) print(f"Created tasks: {tid1}, {tid2}") process_pending_tasks()4.5 调度器代码拆解
逐段解释一下调度器的关键设计:
init_db():在 SQLite 中创建 tasks 表。字段包括任务 ID、载荷、状态、创建时间、开始时间、结束时间、错误信息。state 字段决定任务是否被处理。
create_task():生成 UUID 作为任务 ID,避免并发环境下任务 ID 冲突。将任务状态初始化为 pending。
run_docker_container():这是按需算力的关键点。通过 subprocess 调用docker run命令,使用--rm参数,容器退出后自动删除。--memory 512m和--cpus 0.5是资源限制参数,限制容器最大使用资源,防止单个任务拖垮宿主机。
process_pending_tasks():轮询数据库,找到所有 pending 状态的任务,按顺序执行。执行过程中先更新为 running,再拉起容器,根据返回码更新最终状态。
4.6 构建镜像并运行
docker build -t agent-on-demand:latest -f deploy/Dockerfile .构建完成后运行调度器:
mkdir -p data python scheduler/scheduler.py预期输出大致如下:
Created tasks: 16e9a5c1-xxxx-xxxx-xxxx-xxxxxxxxxxxx, 9f3b57d2-xxxx-xxxx-xxxx-xxxxxxxxxxxx [Scheduler] Start task 16e9a5c1-xxxx-xxxx-xxxx-xxxxxxxxxxxx [Scheduler] Stdout: [Agent] Task demo-001 started, sleeping 3s [Scheduler] Stdout: [Agent] progress: 33% [Scheduler] Stdout: [Agent] progress: 67% [Scheduler] Stdout: [Agent] progress: 100% [Scheduler] Stdout: [Agent] Task demo-001 finished: {"task_id": "demo-001", "status": "success", ...} [Scheduler] Task 16e9a5c1-xxxx-xxxx-xxxx-xxxxxxxxxxxx done.再次查询数据库:
sqlite3 data/tasks.db "select id, status, substr(payload, 1, 50) from tasks;"可以看到两个任务状态都已经变为 succeeded。
4.7 扩展为定时触发模式
上面的例子是手动提交任务,实际项目中往往是定时触发。可以用一个最简单的 while 循环实现:
# 文件路径:scheduler/cron_runner.py import time import sys from scheduler import create_task, process_pending_tasks, init_db def main(): init_db() print("[CronRunner] started.") while True: # 这里可以写具体的定时判断逻辑,例如到某个时间点则提交任务 # 本文示例:每天凌晨 2 点提交一个耗时 10 秒的任务 current_hour = time.localtime().tm_hour current_min = time.localtime().tm_min if current_hour == 2 and current_min == 0: payload = { "task_id": f"daily-report-{time.strftime('%Y%m%d')}", "duration": 10 } create_task(payload) print(f"[CronRunner] Submitted daily report task at {time.strftime('%Y-%m-%d %H:%M:%S')}") # 执行所有等待中的任务 process_pending_tasks() # 避免重复提交,可以加一个时间标记 time.sleep(60) if __name__ == "__main__": main()这个写法虽然简单,但在生产环境中不推荐。它的缺陷是:
- 如果调度器进程崩溃,任务不会自动补跑。
- 缺少锁机制,多个调度器实例同时运行会导致重复执行。
- 定时规则硬编码在代码里,不灵活。
生产环境建议使用 APScheduler、Celery Beat 或者 Kubernetes CronJob,微信文章里我们保留这个简化版本,主要是为了展示按需算力的最小链路。
4.8 优雅停机与资源回收
容器方案有一个天然优势:容器执行完就删除了,资源马上释放。但需要注意以下两点:
- 如果 Agent 内部创建了子进程,容器可能不会立即退出。此时可以在 Dockerfile 里设置
STOPSIGNAL SIGINT,让容器收到停止信号时向主进程发送 SIGINT。 - 如果容器卡死,调度器需要设置执行超时。上面的代码已经用
timeout=600做了保护,超时后执行docker kill。
5. 常驻 VM 与按需算力方案对比
5.1 两种方案的选型对比
| 维度 | 常驻 VM | 按需算力(容器) |
|---|---|---|
| 启动速度 | 秒级(已有进程),分钟级(冷启动虚拟机) | 毫秒到秒级(容器冷启动) |
| 成本模型 | 包月/包年,低峰期也付费 | 按量计费,仅执行期间产生开销 |
| 状态持久化 | 自然持久化,磁盘上见 | 需要挂载数据卷或外部存储 |
| 调试便捷性 | 高,SSH 直连,可断点调试 | 中等,容器退出后日志需要单独收集 |
| 资源管理 | 手工配置,不易细粒度限制 | 可通过--cpus、--memory精细限制 |
| 弹性扩缩容 | 弱,需要手工扩容休息 | 强,按任务数量动态生成容器 |
| 故障隔离 | 弱,一个服务挂掉可能影响同机其他服务 | 强,容器之间相互隔离 |
5.2 成本对比示例
假设你有 10 个 Agent 任务,每个任务每天运行 10 分钟,一个月总运行时间:
10 个任务 × 10 分钟 × 30 天 = 3000 分钟 = 50 小时常驻 VM 方案:假设一台 2 核 4G 虚拟机,包月费用按常见云厂商价格估算,约 100 元。10 个任务可能只需要 2~3 台虚拟机就能跑完,一个月成本 200~300 元,并且这些虚拟机 24 小时在线。
按需算力方案:50 小时的有效运行时长,按容器实例计费,假设每小时 0.1 元(具体价格因规格而异),一个月成本可能只要 5~10 元,再加上少量调度器常驻成本。
常驻 VM 适合长时间在线的服务,按需算力适合间歇性任务。两者成本差异可达一个数量级。
5.3 混合方案:调度器常驻,工作节点按需
这是目前比较推荐的生产模式:
- 保留一个很小的常驻 VM(1 核 1G 或更小),只运行调度器、任务队列、数据库。
- 实际的 Agent 执行环境按需创建,可以是本机 Docker 容器,也可以是远程 Kubernetes Pod。
- 调度器常驻成本很低,Agent 执行资源按量供给。
这种方案兼顾了稳定性和成本。调度器不跑重活,对资源要求极低;Agent 任务在隔离环境中运行,即使某一次任务导致容器崩溃,也不会影响调度器。
6. 常见问题与排查思路
先列出按需算力方案中最高频的 6 个问题,并给出可操作的排查步骤。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
docker: command not found | 调度器所在机器没有安装 Docker 或 Docker 不在 PATH 中 | 确认docker --version能输出版本;使用 Docker Desktop 时确认已启动 |
| 容器启动后立刻退出,日志为空 | Dockerfile 中CMD写错、入口文件不存在 | 本地直接运行python agent/main.py验证;使用docker run --rm agent-on-demand:latest /bin/bash进入容器排查 |
| Agent 内部调用了外部 API,但容器内网络不通 | 容器默认网络模式是 bridge,可能出现 DNS 或路由问题 | 使用docker run --network host测试;检查主机防火墙;如果使用 Docker Desktop,检查代理设置 |
| 任务执行超时但容器没有退出 | Agent 内部存在死循环或等待锁 | 设置合理的timeout;在 Agent 代码中增加最大执行时间判断;使用docker inspect查看容器状态 |
| 多个任务同时执行导致宿主机资源耗尽 | 没有对并发数做限制 | 调度器增加信号量控制最大并发数;容器启动参数设置--cpus与--memory |
| 容器执行成功但调度器认为失败 | subprocess 捕获输出逻辑有问题,或 Agent 返回非零退出码 | 检查result.returncode;在 Agent 代码末尾显式sys.exit(0) |
6.1 “容器内没有看到预期的日志”
这是一个常见问题。Docker 容器默认会把标准输出和标准错误合并吗?不会。如果你没有设置--log-driver,默认的 json-file 日志驱动会分别记录 stdout 和 stderr,但docker logs默认会同时显示两者。
如果日志没出现,先手动运行:
docker run --rm agent-on-demand:latest python agent/main.py '{"task_id":"debug","duration":2}'如果这个命令有输出,说明镜像没问题。接下来查调度器的 subprocess 捕获逻辑,上面代码使用capture_output=True,打印的都是result.stdout和result.stderr,不会丢日志。
6.2 Docker 在 Windows 上运行慢或不稳定
Windows 上的 Docker Desktop 默认使用 WSL 2 后端。如果你的机器配置偏低,或者 WSL 2 与虚拟机软件冲突,表现就是 Docker 启动慢、容器启动慢。
解决办法:
- 把 WSL 2 默认版本调到最新:
wsl --update。 - 在
.wslconfig文件中限制内存,例如[wsl2] memory=4GB processors=2。 - 如果项目文件放在 Windows 文件系统下,容器访问速度会变慢,建议把项目放在 WSL 2 文件系统内部(
\\wsl$\路径)。 - 如果遇到 Hyper-V 冲突,确认启用 Windows 虚拟机监控程序平台后,VMware 和 Docker 都能用。
6.3 SQLite 并发写入报错
调度器同时处理多个任务时,可能出现database is locked错误。SQLite 是文件型数据库,并发写能力有限。
解决办法:
- 连接数据库时设置
timeout=10,例如sqlite3.connect(DB_PATH, timeout=10)。 - 使用
WAL模式:cursor.execute("PRAGMA journal_mode=WAL")。 - 简单场景可以继续用 SQLite,并发量上来后迁移到 PostgreSQL。
迁移到 PostgreSQL 时,只需更换存储层实现,调度器主流程不用大改。
6.4 容器内时区不对
Docker 镜像默认时区是 UTC。如果你的 Agent 需要按北京时间调度或记录时间,一定要在 Dockerfile 中设置时区。
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone如果你的基础镜像是精简版,可能没有tzdata,需要先安装:
RUN apt-get update && apt-get install -y tzdata && \ ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ echo "Asia/Shanghai" > /etc/timezone6.5 任务重复执行
重复执行的常见原因有三个:
- 调度器在任务执行过程中崩溃,任务状态还停留在 running,重启后没有恢复机制。
- 多个调度器实例同时轮询数据库,同一个任务被多个实例捞到。
- 网络超时导致前一个请求结果未返回,客户端重试时又提交了一份。
解决办法:
- 任务表增加
worker_id和lease_expire_at字段,任务被某个调度器领走后写入租约时间,其他调度器跳过。 - 调度器重启时,把所有 running 状态超过一定时限的任务重置为 pending。
- 数据库对任务 ID 加唯一约束,客户端反复提交相同 ID 时直接返回已有任务信息。
6.6 容器执行的宿主机 CPU 100%
按需算力不是完全没有危险,如果某个 Agent 任务内部出现无限循环,它会消耗掉分配给它的 CPU。加上--cpus 0.5后,最多使用半个 CPU 核心。但如果你创建了大量容器,每个都用 0.5 CPU,宿主机依然可能打满。
建议:
- 调度器内置最大并发数限制,比如同一时刻最多 4 个容器。
- 监控宿主机负载
top或htop,设置告警阈值。 - 如果任务中有循环,建议在 Agent 代码中加入步数限制或时间限制,超过后抛异常退出。
7. 最佳实践与工程建议
7.1 给 Agent 设置明确的退出条件
这是最容易被忽略的点。Agent 不是普通 Web 服务,它的“任务”有时是一条消息,有时是一个文件,有时是一个数据库变更事件。很多空转问题的根因是 —— 你根本没有告诉 Agent“什么时候算干完”。
最佳实践:
- 每个任务提供超时配置,默认值建议 30 分钟,超过自动终止。
- 每个任务入口记录开始时间、结束时间、执行时长。
- Agent 空闲等待时,使用有超时的轮询,而不是死循环。
# 示例:带超时的队列消费 import queue import time task_queue = queue.Queue() WAIT_TIMEOUT = 5 # 5秒无新任务进入就退出循环 while True: try: task = task_queue.get(timeout=WAIT_TIMEOUT) run_task(task) except queue.Empty: print("[Agent] No task, exiting.") break如果你的 Agent 一直在等待里空转,重启进程反而更安全。
7.2 任务数据与执行环境解耦
按需算力最怕的就是“执行完状态丢了”。容器是临时环境,一旦销毁,里面的数据都会消失。因此,Agent 要处理的输入数据、要写入的输出结果,都应该放到外部存储。
推荐的组件分工:
- 输入数据:通过任务 payload 传入,或者存放在对象存储 / 共享数据库中。
- 中间状态:使用 Redis 等外部缓存,不要写在本地文件。
- 输出结果:写入数据库、对象存储,或通过消息队列发送给下游。
- 日志:通过 stdout 输出,由容器运行时收集,例如 Docker 的 json-file 驱动或云平台的日志服务。
7.3 日志规范与可观测性
容器按需销毁后,如果日志只在容器内,你就永远找不到问题现场了。
建议:
- Agent 每打印一条日志,都带上任务 ID 和时间戳。
- 日志使用 JSON 格式,方便日志服务解析。
import json import logging from datetime import datetime, timezone class AgentLogger: @staticmethod def log(task_id: str, level: str, message: str): entry = { "time": datetime.now(timezone.utc).isoformat(), "task_id": task_id, "level": level, "message": message } print(json.dumps(entry), flush=True) AgentLogger.log(task_id, "INFO", "task started")生产环境可以使用structlog或loguru,但在边缘场景下,自己写一个 JSON logger 足够清晰。
7.4 安全边界与最小权限
Agent 执行任务时,往往需要访问外部系统。安全边界要做到:
- 容器内使用独立的 Linux 用户运行,而不是 root。Dockerfile 中可以使用:
RUN useradd -m agent USER agent- 环境变量中的密钥使用 Docker secret 或云平台的密钥管理服务,不要明文写入镜像。
- 容器网络尽量使用自定义网络,并关闭不需要的端口。
- Agent 访问外部系统的凭证,遵循最小权限原则,只授予完成任务必需的权限。
7.5 版本管理与镜像标签
镜像 tag 不要使用latest作为生产环境的唯一标签。每次发布新版本时,使用带版本号的 tag:
docker build -t agent-on-demand:1.0.0 . docker tag agent-on-demand:1.0.0 agent-on-demand:latest在上面的调度器代码中,DOCKER_IMAGE变量指定为agent-on-demand:latest,实际生产环境应替换为:
DOCKER_IMAGE = "registry.example.com/agent-on-demand:1.0.0"这样可以做到版本可回滚。
7.6 监控告警建议
按需算力不代表不需要监控。你要监控的对象是:
- 任务调度延迟:从任务提交到容器启动的时间。
- 任务执行成功率:失败任务占比。
- 容器启动失败率:镜像拉取失败、资源不足等。
- 宿主机资源水位:CPU、内存、磁盘剩余空间。
- 数据库连接数:如果使用 PostgreSQL,注意连接池数量。
最简单的监控方式:
watch -n 5 'docker ps | wc -l; docker images | wc -l; free -h'更完整的方案可以使用 Prometheus + Grafana,不过这是另一个大话题了。只要保证最核心的“任务成功率”能够被看到,就有能力快速发现问题。
7.7 从模拟脚本走向生产架构
本文的调度器示例是单机方案,适合学习和小规模任务。如果你的 Agent 数量扩张到几十个、上百个,建议按下面的路径演进:
- 单机 SQLite + 单进程调度器(本文)。
- 单机 PostgreSQL + 多进程调度器。
- 分布式消息队列(RabbitMQ / Kafka)+ 多客户端消费。
- Kubernetes CronJob / Argo Workflows 调度容器任务。
每一层演进都只解决当前一个核心问题。第一层解决“有和无”,第二层解决“并发和可靠性”,第三层解决“解耦和扩展”,第四层解决“大规模编排和可观测性”。不要把第一层方案当成生产最终形态,也不要在小规模场景里杀鸡用牛刀。
8. 总结与下一步
8.1 本文核心要点回顾
围绕“别让 Agent 空转”这个主题,我们从资源成本出发,梳理了两套 Agent 运行方案:
- 常驻 VM 方案适合需要低延迟、长连接、状态稳定的服务型 Agent,成本高但稳定性好。
- 按需算力方案适合间歇性、批处理型 Agent,成本低、弹性好,但需要额外的调度和状态管理逻辑。
- 实际项目中可以使用混合方案:一个轻量调度器常驻,Agent 工作负载按需创建容器执行。
- 给出完整的 Dockerfile、调度器 Python 代码、systemd 配置文件,可以直接复制到自己的项目中验证。
8.2 下一步学习方向
如果你想把这套方案真正落地,建议按下面顺序继续深入:
- 熟练掌握 Docker 镜像构建与容器生命周期管理。
- 学习 Docker Compose 或 Kubernetes 的基本部署方式。
- 调研云平台的容器实例产品,了解按量计费逻辑。
- 给 Agent 增加结构化日志和任务追踪 ID,为可观测性打底。
- 深入了解 OpenAI Function Calling 或类似 Agent 框架的调用方式,把本文的调度器和真实业务结合。
8.3 从成本思维开始设计系统
最后想分享一个经验:设计 Agent 系统时,不要先画架构图,不要先选框架,先画一张算力账单。算出你的任务一天实际运行多久,多少次是空闲等待,多少资源承载了多少有效请求。把“空转率”作为和“任务成功率”一样重要的指标来关注。
当你把成本思维放进系统设计里,很多方案选择就会变得清晰:该用常驻 VM 还是按需算力,该用单机脚本还是容器编排,该加监控还是不加监控,答案都会自动浮出来。
如果你在搭建或迁移过程中遇到过其他问题,欢迎在评论区留言。后续我会继续写 Agent 开发相关的实战内容,包括 Agent 框架选型、任务队列设计、容器化部署细节等,记得留意更新。
如果你觉得这篇内容对你有帮助,可以收藏备用,也欢迎分享给身边正在为 Agent 算力发愁的朋友。