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:
gongzhiyong
2026-06-21 22:08:55 +08:00
co-authored by Claude Opus 4.8
parent 98fb0d2bdb
commit 74f5ce2f97
3 changed files with 82 additions and 6 deletions
+6 -3
View File
@@ -91,11 +91,14 @@ spec:
- name: JINA_API_KEY - name: JINA_API_KEY
valueFrom: valueFrom:
secretKeyRef: { name: swarm-jina-key, key: JINA_API_KEY } 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 - name: AGENT_LAUNCH_MIN_POOL
value: "16" value: "2"
- name: AGENT_LAUNCH_POOL_SIZE - name: AGENT_LAUNCH_POOL_SIZE
value: "16" value: "2"
- name: AGENT_LAUNCH_MAX_POOL - name: AGENT_LAUNCH_MAX_POOL
value: "64" value: "64"
- name: MAX_AGENTS_PER_USER - name: MAX_AGENTS_PER_USER
+11 -3
View File
@@ -460,6 +460,8 @@ def plan_launch_specs(
orchestrator_url: str, orchestrator_url: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
git_env: Optional[Dict[str, str]] = None, git_env: Optional[Dict[str, str]] = None,
count_override: Optional[int] = None,
id_start: int = 0,
) -> List[AgentLaunchSpec]: ) -> List[AgentLaunchSpec]:
"""Pure: plan the agent pool for a run (count capped by the per-user limit) + each agent's env. """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 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 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. 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"] caps = pool_capabilities() or ["general"]
base = model_api_base() base = model_api_base()
mid = model_id(body) mid = model_id(body)
task_timeout = resolve_task_timeout(body) # #70:透传执行超时,与 run budget.duration_seconds 取较小 task_timeout = resolve_task_timeout(body) # #70:透传执行超时,与 run budget.duration_seconds 取较小
specs: List[AgentLaunchSpec] = [] specs: List[AgentLaunchSpec] = []
for i in range(count): for i in range(count):
cap_csv = caps[i % len(caps)] idx = id_start + i
cap_csv = caps[idx % len(caps)]
env = { env = {
"ORCHESTRATOR_URL": orchestrator_url, "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, "AGENT_CAPABILITIES": cap_csv,
"OPENAI_API_BASE": base, "OPENAI_API_BASE": base,
# Non-secret execution-timeout knob; inline like the other config env (never a Secret). # Non-secret execution-timeout knob; inline like the other config env (never a Secret).
+65
View File
@@ -449,6 +449,69 @@ async def finalize_dispatch(agent, task, *, decision=None, dispatch_event=None)
return True 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): async def compute_convergence_report(run, tasks, *, terminal: bool):
"""Build a run-state snapshot and evaluate the convergence report (#12, shadow by default). """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 # Start background tasks
failure_task = asyncio.create_task(failure_detection_loop()) failure_task = asyncio.create_task(failure_detection_loop())
dispatch_task = asyncio.create_task(task_dispatch_loop()) dispatch_task = asyncio.create_task(task_dispatch_loop())
autoscale_task = asyncio.create_task(agent_autoscale_loop())
yield yield
@@ -858,6 +922,7 @@ async def lifespan(app: FastAPI):
logger.info("Shutting down orchestrator...") logger.info("Shutting down orchestrator...")
failure_task.cancel() failure_task.cancel()
dispatch_task.cancel() dispatch_task.cancel()
autoscale_task.cancel()
await redis_client.disconnect() await redis_client.disconnect()