feat(launcher): 弹性 agent 池 — 按需扩缩取代固定预起
agent_autoscale_loop(ENABLE_AGENT_AUTOSCALING):初始只起 AGENT_LAUNCH_MIN_POOL 个, 按每个 run 的 PENDING 任务积压超过空闲 agent 时 top-up,上限 MAX_POOL/per-user cap。 plan_launch_specs 加 count_override/id_start 支持非碰撞增补。trivial run 用少量 agent, 重 run 自动扩展。fail-soft,backend=none 时 no-op。 manifest:MIN/POOL 16→2 + ENABLE_AGENT_AUTOSCALING=1(初始小、按需长)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
98fb0d2bdb
commit
74f5ce2f97
@@ -91,11 +91,14 @@ spec:
|
||||
- name: JINA_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: { name: swarm-jina-key, key: JINA_API_KEY }
|
||||
# --- agent 池窗口 ---
|
||||
# --- agent 池窗口(弹性)---
|
||||
# 初始只起 2 个;autoscale loop 按 PENDING 积压按需扩到 MAX/per-user 上限。
|
||||
- name: ENABLE_AGENT_AUTOSCALING
|
||||
value: "1"
|
||||
- name: AGENT_LAUNCH_MIN_POOL
|
||||
value: "16"
|
||||
value: "2"
|
||||
- name: AGENT_LAUNCH_POOL_SIZE
|
||||
value: "16"
|
||||
value: "2"
|
||||
- name: AGENT_LAUNCH_MAX_POOL
|
||||
value: "64"
|
||||
- name: MAX_AGENTS_PER_USER
|
||||
|
||||
@@ -460,6 +460,8 @@ def plan_launch_specs(
|
||||
orchestrator_url: str,
|
||||
user_id: Optional[str] = None,
|
||||
git_env: Optional[Dict[str, str]] = None,
|
||||
count_override: Optional[int] = None,
|
||||
id_start: int = 0,
|
||||
) -> List[AgentLaunchSpec]:
|
||||
"""Pure: plan the agent pool for a run (count capped by the per-user limit) + each agent's env.
|
||||
|
||||
@@ -467,18 +469,24 @@ def plan_launch_specs(
|
||||
the configured pool so the pool is capability-diverse (the seed task has no required caps, so
|
||||
any agent can claim it; subtasks proposed later route by capability). `git_env` (from
|
||||
git_launch_env) carries GIT_REPO_URL so each agent clones the bound repo into its workspace.
|
||||
|
||||
Elastic top-up: pass ``count_override`` (the delta to add) + ``id_start`` (current agent count)
|
||||
to plan ADDITIONAL agents with non-colliding ids (``agent-{id_start+i+1}``). The caller is
|
||||
responsible for capping the delta against MAX_POOL and the per-user limit (the autoscale loop).
|
||||
"""
|
||||
count = launch_count(pool_size=pool_size, limit=limit, connected_user_agents=connected_user_agents)
|
||||
count = count_override if count_override is not None else launch_count(
|
||||
pool_size=pool_size, limit=limit, connected_user_agents=connected_user_agents)
|
||||
caps = pool_capabilities() or ["general"]
|
||||
base = model_api_base()
|
||||
mid = model_id(body)
|
||||
task_timeout = resolve_task_timeout(body) # #70:透传执行超时,与 run budget.duration_seconds 取较小
|
||||
specs: List[AgentLaunchSpec] = []
|
||||
for i in range(count):
|
||||
cap_csv = caps[i % len(caps)]
|
||||
idx = id_start + i
|
||||
cap_csv = caps[idx % len(caps)]
|
||||
env = {
|
||||
"ORCHESTRATOR_URL": orchestrator_url,
|
||||
"AGENT_ID": f"{run.swarm_id}-agent-{i+1}",
|
||||
"AGENT_ID": f"{run.swarm_id}-agent-{idx+1}",
|
||||
"AGENT_CAPABILITIES": cap_csv,
|
||||
"OPENAI_API_BASE": base,
|
||||
# Non-secret execution-timeout knob; inline like the other config env (never a Secret).
|
||||
|
||||
@@ -449,6 +449,69 @@ async def finalize_dispatch(agent, task, *, decision=None, dispatch_event=None)
|
||||
return True
|
||||
|
||||
|
||||
async def _maybe_topup_agents(run, backlog: int) -> None:
|
||||
"""Elastic top-up for one run: if its PENDING backlog exceeds idle connected agents and the
|
||||
pool is below the cap, launch the deficit (capped by MAX_POOL and the run's per-user limit).
|
||||
New agents get non-colliding ids (id_start = current pool size). Fail-soft."""
|
||||
swarm_id = run.swarm_id
|
||||
prefix = f"{swarm_id}-agent-"
|
||||
live = sum(1 for aid in manager.active_connections if aid.startswith(prefix))
|
||||
launched = run.metadata.get("launched_agents") or []
|
||||
pool = max(live, len(launched)) # avoid re-launching agents still connecting
|
||||
idle = sum(1 for a in await agent_registry.get_idle_agents()
|
||||
if a.agent_id.startswith(prefix) and a.agent_id in manager.active_connections
|
||||
and agent_has_capacity(a.agent_id))
|
||||
body = run.request_body or {}
|
||||
cap = min(agent_launcher.max_pool_size(), max_agents_per_user(body))
|
||||
if backlog <= idle or pool >= cap:
|
||||
return
|
||||
delta = min(backlog - idle, cap - pool)
|
||||
if delta <= 0:
|
||||
return
|
||||
user_id = ((body.get("metadata") or {}).get("runtime_headers") or {}).get("x_user_id")
|
||||
specs = agent_launcher.plan_launch_specs(
|
||||
run, body, connected_user_agents=0, limit=max_agents_per_user(body),
|
||||
pool_size=delta, model_key=agent_launcher.resolve_model_key(body),
|
||||
orchestrator_url=agent_launcher.orchestrator_ws_url(), user_id=user_id,
|
||||
git_env=agent_launcher.resolve_git_grant(body), count_override=delta, id_start=pool)
|
||||
launched_ids = await agent_launcher.launch(specs, swarm_id=swarm_id)
|
||||
if launched_ids:
|
||||
run.metadata["launched_agents"] = launched + launched_ids
|
||||
await swarm_runtime.save_run(run)
|
||||
logger.info("autoscale: run %s +%d agents (backlog=%d idle=%d pool=%d cap=%d)",
|
||||
swarm_id, len(launched_ids), backlog, idle, pool, cap)
|
||||
|
||||
|
||||
async def agent_autoscale_loop():
|
||||
"""Elastic agent pool (demand-driven). Initial launch stays small (AGENT_LAUNCH_MIN_POOL); this
|
||||
loop grows each run's pool only as its PENDING-task backlog demands, up to MAX_POOL / per-user
|
||||
cap — so trivial runs use few agents and heavy runs scale out. Gated by ENABLE_AGENT_AUTOSCALING;
|
||||
no-op when the launch backend is 'none'. Fail-soft (never raises into the event loop)."""
|
||||
if os.getenv("ENABLE_AGENT_AUTOSCALING", "false").lower() not in {"1", "true", "yes"}:
|
||||
return
|
||||
if agent_launcher.launch_backend() == "none":
|
||||
return
|
||||
interval = float(os.getenv("AUTOSCALE_INTERVAL_SECONDS", "5"))
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
if await task_queue.get_pending_count() == 0:
|
||||
continue
|
||||
backlog: Dict[str, list] = {} # swarm_id -> [run, count]
|
||||
for task in await task_queue.get_all_tasks():
|
||||
if task.status != TaskStatus.PENDING:
|
||||
continue
|
||||
run = await swarm_runtime.get_run_for_task(task.task_id)
|
||||
if not run or run.status != "running":
|
||||
continue
|
||||
entry = backlog.setdefault(run.swarm_id, [run, 0])
|
||||
entry[1] += 1
|
||||
for run, n in backlog.values():
|
||||
await _maybe_topup_agents(run, n)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in agent autoscale loop: {e}")
|
||||
|
||||
|
||||
async def compute_convergence_report(run, tasks, *, terminal: bool):
|
||||
"""Build a run-state snapshot and evaluate the convergence report (#12, shadow by default).
|
||||
|
||||
@@ -851,6 +914,7 @@ async def lifespan(app: FastAPI):
|
||||
# Start background tasks
|
||||
failure_task = asyncio.create_task(failure_detection_loop())
|
||||
dispatch_task = asyncio.create_task(task_dispatch_loop())
|
||||
autoscale_task = asyncio.create_task(agent_autoscale_loop())
|
||||
|
||||
yield
|
||||
|
||||
@@ -858,6 +922,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Shutting down orchestrator...")
|
||||
failure_task.cancel()
|
||||
dispatch_task.cancel()
|
||||
autoscale_task.cancel()
|
||||
await redis_client.disconnect()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user