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
|
- 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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user