feat(admin): Agent 并发 - 后台可调同时对话上限并展示 Agent 服务状态

新增运行策略 agentMaxSessions(默认 30,范围 1-64),保存后同时下发到内嵌进程闸门与独立 yingce-agent 的 /v1/limit,降低上限不会中断已开始的对话。系统性能页展示 Agent 服务健康、进行中/排队数量,并可直接修改上限。
This commit is contained in:
ddcat666
2026-09-30 03:37:54 +08:00
parent a0f025aa96
commit 4957f8bc22
17 changed files with 408 additions and 29 deletions

View File

@@ -222,27 +222,83 @@ func RuntimeDir() (string, error) {
return "", errors.New("Agent runtime files are missing; set CANVAS_PI_RUNTIME_DIR")
}
var (
agentSlotsOnce sync.Once
agentSlots chan struct{}
const (
defaultAgentProcessLimit = 30
maxAgentProcessLimit = 64
)
func acquireAgentProcess(ctx context.Context) (func(), error) {
agentSlotsOnce.Do(func() {
limit := 8
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 1000 {
limit = parsed
}
type agentProcessGate struct {
mu sync.Mutex
limit int
active int
wake chan struct{}
}
var processGate = newAgentProcessGate()
func newAgentProcessGate() *agentProcessGate {
limit := defaultAgentProcessLimit
if raw := strings.TrimSpace(os.Getenv("CANVAS_AGENT_MAX_PROCESSES")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil {
limit = clampAgentProcessLimit(parsed)
}
agentSlots = make(chan struct{}, limit)
})
select {
case agentSlots <- struct{}{}:
return func() { <-agentSlots }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
return &agentProcessGate{limit: limit, wake: make(chan struct{})}
}
func clampAgentProcessLimit(value int) int {
if value < 1 {
return defaultAgentProcessLimit
}
if value > maxAgentProcessLimit {
return maxAgentProcessLimit
}
return value
}
// SetProcessLimit changes how many embedded Agent processes may run at once.
// Lowering the limit does not stop a process that has already started.
func SetProcessLimit(value int) (active int, limit int) {
processGate.mu.Lock()
defer processGate.mu.Unlock()
processGate.limit = clampAgentProcessLimit(value)
close(processGate.wake)
processGate.wake = make(chan struct{})
return processGate.active, processGate.limit
}
func ProcessUsage() (active int, limit int) {
processGate.mu.Lock()
defer processGate.mu.Unlock()
return processGate.active, processGate.limit
}
func acquireAgentProcess(ctx context.Context) (func(), error) {
for {
processGate.mu.Lock()
if processGate.active < processGate.limit {
processGate.active++
processGate.mu.Unlock()
return releaseAgentProcess, nil
}
wake := processGate.wake
processGate.mu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-wake:
}
}
}
func releaseAgentProcess() {
processGate.mu.Lock()
if processGate.active > 0 {
processGate.active--
}
close(processGate.wake)
processGate.wake = make(chan struct{})
processGate.mu.Unlock()
}
func nodeMemoryMB() string {

View File

@@ -24,6 +24,25 @@ func fakeRuntime(t *testing.T, script string) {
t.Setenv("CANVAS_PI_RUNTIME_DIR", dir)
}
func TestSetProcessLimitDoesNotStopActiveWork(t *testing.T) {
t.Cleanup(func() { SetProcessLimit(defaultAgentProcessLimit) })
release, err := acquireAgentProcess(context.Background())
if err != nil {
t.Fatal(err)
}
active, limit := SetProcessLimit(1)
if active != 1 || limit != 1 {
t.Fatalf("active=%d limit=%d, want active 1 and limit 1", active, limit)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
if _, err := acquireAgentProcess(ctx); err == nil {
t.Fatal("lowered limit still admitted another process")
}
release()
SetProcessLimit(defaultAgentProcessLimit)
}
func noopBridge() Bridge {
handler := func(context.Context, map[string]json.RawMessage) (any, error) { return map[string]any{"ok": true}, nil }
return Bridge{Model: handler, Tool: handler, Event: handler}

View File

@@ -14,6 +14,68 @@ import (
agentruntime "infinite-canvas/backend/internal/agent/runtime"
)
type Status struct {
OK bool `json:"ok"`
Active int `json:"active"`
Limit int `json:"limit"`
Queued int `json:"queued"`
}
func Inspect(ctx context.Context, endpoint string) (Status, error) {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
if endpoint == "" {
return Status{}, fmt.Errorf("YINGCE_AGENT_URL is empty")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"/healthz", nil)
if err != nil {
return Status{}, err
}
response, err := remoteClient.Do(request)
if err != nil {
return Status{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return Status{}, fmt.Errorf("yingce-agent health returned HTTP %d", response.StatusCode)
}
var status Status
if err := json.NewDecoder(io.LimitReader(response.Body, 4096)).Decode(&status); err != nil {
return Status{}, err
}
return status, nil
}
func SetLimit(ctx context.Context, endpoint, serviceToken string, limit int) error {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
serviceToken = strings.TrimSpace(serviceToken)
if endpoint == "" {
return fmt.Errorf("YINGCE_AGENT_URL is empty")
}
if len(serviceToken) < 32 {
return fmt.Errorf("YINGCE_AGENT_TOKEN must contain at least 32 characters")
}
payload, err := json.Marshal(map[string]int{"maxSessions": limit})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint+"/v1/limit", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Authorization", "Bearer "+serviceToken)
request.Header.Set("Content-Type", "application/json")
response, err := remoteClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
detail, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
return fmt.Errorf("yingce-agent limit returned HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(detail)))
}
return nil
}
// Run executes one Agent session on the independent yingce-agent service.
// The Go process keeps the bridge, billing, and tool execution. A configured
// remote endpoint never falls back to an embedded Node process: doing so can

View File

@@ -116,4 +116,14 @@ if (!response.ok) {
if !called {
t.Fatal("remote runtime did not call the Go tool bridge")
}
if err := SetLimit(ctx, endpoint, token, 16); err != nil {
t.Fatal(err)
}
status, err := Inspect(ctx, endpoint)
if err != nil {
t.Fatal(err)
}
if status.Limit != 16 {
t.Fatalf("limit = %d, want 16", status.Limit)
}
}

View File

@@ -3,14 +3,18 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"strconv"
"strings"
"time"
agentruntime "infinite-canvas/backend/internal/agent/runtime"
"infinite-canvas/backend/internal/agent/yingceagent"
"infinite-canvas/backend/internal/buildinfo"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/platform"
"infinite-canvas/backend/internal/repository"
"github.com/redis/go-redis/v9"
@@ -28,9 +32,23 @@ type AdminSystemPerformance struct {
Disk SystemPerformanceDisk `json:"disk"`
Database repository.DatabaseRuntimeStats `json:"database"`
Redis SystemPerformanceRedis `json:"redis"`
Agents []SystemPerformanceAgent `json:"agents"`
Build buildinfo.Info `json:"build"`
}
type SystemPerformanceAgent struct {
ID string `json:"id"`
Name string `json:"name"`
Mode string `json:"mode"`
Endpoint string `json:"endpoint"`
Healthy bool `json:"healthy"`
Active int `json:"active"`
Limit int `json:"limit"`
Queued int `json:"queued"`
ConfiguredLimit int `json:"configuredLimit"`
StatusMessage string `json:"statusMessage,omitempty"`
}
type SystemPerformanceHost struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
@@ -149,17 +167,98 @@ func (s *Service) AdminSystemPerformance(ctx context.Context, actor *model.User)
disk := collectSystemDisk(s.dataDir)
databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx)
redisStats := s.collectRedisPerformance(ctx)
agents := s.collectAgentServices(ctx)
status := "healthy"
if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) {
if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) || agentServicesDegraded(agents) {
status = "degraded"
}
return &AdminSystemPerformance{
CollectedAt: time.Now(), Status: status,
Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable},
Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Build: buildinfo.Current(),
Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Agents: agents, Build: buildinfo.Current(),
}, nil
}
type AgentSessionLimitRequest struct {
MaxSessions int `json:"maxSessions"`
}
func (s *Service) UpdateAgentSessionLimit(ctx context.Context, actor *model.User, maxSessions int) (*SystemPerformanceAgent, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
if maxSessions < platform.MinRuntimeAgentSessions || maxSessions > platform.MaxRuntimeAgentSessions {
return nil, BadAuthRequest(fmt.Sprintf("Agent 同时对话上限必须是 %d-%d 的整数", platform.MinRuntimeAgentSessions, platform.MaxRuntimeAgentSessions))
}
policy, err := s.RuntimePolicy()
if err != nil {
return nil, err
}
policy.Task.AgentMaxSessions = maxSessions
if _, err := s.UpdateRuntimePolicySetting(actor, policy); err != nil {
return nil, err
}
if err := s.applyAgentSessionLimit(ctx, maxSessions); err != nil {
return nil, err
}
agents := s.collectAgentServices(ctx)
if len(agents) == 0 {
return nil, fmt.Errorf("Agent 服务状态不可用")
}
return &agents[0], nil
}
func (s *Service) applyAgentSessionLimit(ctx context.Context, maxSessions int) error {
agentruntime.SetProcessLimit(maxSessions)
endpoint := strings.TrimSpace(os.Getenv("YINGCE_AGENT_URL"))
if endpoint == "" {
return nil
}
return yingceagent.SetLimit(ctx, endpoint, os.Getenv("YINGCE_AGENT_TOKEN"), maxSessions)
}
func (s *Service) collectAgentServices(ctx context.Context) []SystemPerformanceAgent {
configured := platform.DefaultRuntimeAgentSessions
if policy, err := s.RuntimePolicy(); err == nil && policy.Task.AgentMaxSessions > 0 {
configured = policy.Task.AgentMaxSessions
}
endpoint := strings.TrimSpace(os.Getenv("YINGCE_AGENT_URL"))
if endpoint == "" {
active, limit := agentruntime.ProcessUsage()
return []SystemPerformanceAgent{{
ID: "embedded", Name: "画布 Agent", Mode: "embedded", Endpoint: "本进程",
Healthy: true, Active: active, Limit: limit, ConfiguredLimit: configured,
StatusMessage: "本地开发由后端进程直接启动,不单独占用容器。",
}}
}
service := SystemPerformanceAgent{
ID: "yingce-agent", Name: "画布 Agent", Mode: "remote", Endpoint: endpoint,
ConfiguredLimit: configured,
}
status, err := yingceagent.Inspect(ctx, endpoint)
if err != nil {
service.StatusMessage = "独立 Agent 服务不可达。正在进行的对话会失败,不会自动改回本进程执行。"
return []SystemPerformanceAgent{service}
}
service.Healthy = status.OK
service.Active = status.Active
service.Limit = status.Limit
service.Queued = status.Queued
if status.Limit != configured {
service.StatusMessage = "运行中的上限与已保存配置不一致,重新保存一次即可应用。"
}
return []SystemPerformanceAgent{service}
}
func agentServicesDegraded(agents []SystemPerformanceAgent) bool {
for _, agent := range agents {
if !agent.Healthy {
return true
}
}
return false
}
func collectSystemMemory() SystemPerformanceMemory {
var stats runtime.MemStats
runtime.ReadMemStats(&stats)

View File

@@ -161,6 +161,7 @@ func TestRuntimePolicyBackfillsAgentStepLimitsForLegacyJSON(t *testing.T) {
task := value["task"].(map[string]any)
delete(task, "agentStepMaxOutputTokens")
delete(task, "agentStepTimeoutSeconds")
delete(task, "agentMaxSessions")
trimmed, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
@@ -176,6 +177,9 @@ func TestRuntimePolicyBackfillsAgentStepLimitsForLegacyJSON(t *testing.T) {
if effective.Task.AgentStepMaxOutputTokens != platform.DefaultRuntimeAgentStepOutputTokens {
t.Fatalf("legacy json 未回填单步输出上限: %d", effective.Task.AgentStepMaxOutputTokens)
}
if effective.Task.AgentMaxSessions != platform.DefaultRuntimeAgentSessions {
t.Fatalf("legacy json 未回填同时对话上限: %d", effective.Task.AgentMaxSessions)
}
if effective.Task.AgentStepTimeoutSeconds != platform.DefaultRuntimeAgentStepTimeout {
t.Fatalf("legacy json 未回填单步超时: %d", effective.Task.AgentStepTimeoutSeconds)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -166,6 +167,7 @@ func (s *Service) StartWorker() {
s.startResourceDeletionWorker(ctx)
s.startSkillSyncWorker(ctx)
s.startPaymentWorker(ctx)
go s.syncAgentSessionLimit()
s.runWorkerLoop(func(ctx context.Context) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
@@ -180,6 +182,19 @@ func (s *Service) StartWorker() {
})
}
func (s *Service) syncAgentSessionLimit() {
policy, err := s.RuntimePolicy()
if err != nil {
slog.Warn("agent session limit load failed", "error", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := s.applyAgentSessionLimit(ctx, policy.Task.AgentMaxSessions); err != nil {
slog.Warn("agent session limit apply failed", "error", err)
}
}
func (s *Service) BeginDrain() { s.backgroundWorkers().BeginDrain() }
func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().Stop(ctx) }

View File

@@ -23,6 +23,26 @@ func RegisterAdminSystemPerformanceRoutes(r *gin.RouterGroup, svc *service.Servi
ok(c, result)
})
r.PUT("/admin/system-performance/agent-limit", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<10)
var request service.AgentSessionLimitRequest
if err := c.ShouldBindJSON(&request); err != nil {
failService(c, service.BadAuthRequest("Agent 并发请求无效"))
return
}
result, err := svc.UpdateAgentSessionLimit(c.Request.Context(), actor, request.MaxSessions)
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.POST("/admin/system-performance/cache/clear", func(c *gin.Context) {
actor, err := currentUser(c, svc)
if err != nil {

View File

@@ -14,6 +14,7 @@ func TestAdminSystemPerformanceRoutesAreRegistered(t *testing.T) {
RegisterAdminSystemPerformanceRoutes(router.Group("/api"), &service.Service{})
wanted := map[string]bool{
"GET /api/admin/system-performance": false,
"PUT /api/admin/system-performance/agent-limit": false,
"POST /api/admin/system-performance/cache/clear": false,
}
for _, route := range router.Routes() {

View File

@@ -36,6 +36,9 @@ const (
MinRuntimeAgentStepTimeoutSeconds = 30
MaxRuntimeAgentStepTimeoutSeconds = 3_600
DefaultRuntimeAgentStepTimeout = 0
MinRuntimeAgentSessions = 1
MaxRuntimeAgentSessions = 64
DefaultRuntimeAgentSessions = 30
)
// agentStepMaxOutputTokensDefault 读取部署级覆盖并按同一套范围夹取;环境变量无法表达"不限制"(0),
@@ -90,6 +93,8 @@ type RuntimeTaskPolicy struct {
AgentStepMaxOutputTokens int `json:"agentStepMaxOutputTokens"`
// AgentStepTimeoutSeconds 是画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。
AgentStepTimeoutSeconds int `json:"agentStepTimeoutSeconds"`
// AgentMaxSessions 是同时进行中的画布 Agent 轮次上限。审批等待不占用名额。
AgentMaxSessions int `json:"agentMaxSessions"`
}
type RuntimeRequestPolicy struct {
@@ -173,6 +178,7 @@ func DefaultRuntimePolicy() RuntimePolicySetting {
DefaultTimeoutMinutes: 10,
AgentStepMaxOutputTokens: agentStepMaxOutputTokensDefault(),
AgentStepTimeoutSeconds: agentStepTimeoutSecondsDefault(),
AgentMaxSessions: DefaultRuntimeAgentSessions,
},
Request: RuntimeRequestPolicy{
TaskCreatePerMinute: 30,
@@ -213,6 +219,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
AudioTimeoutMinutes: maxRuntimeTimeoutMinutes, VideoTimeoutMinutes: maxRuntimeTimeoutMinutes,
StoryboardTimeoutMinutes: maxRuntimeTimeoutMinutes, DefaultTimeoutMinutes: maxRuntimeTimeoutMinutes,
AgentStepMaxOutputTokens: MaxRuntimeAgentStepOutputTokens, AgentStepTimeoutSeconds: MaxRuntimeAgentStepTimeoutSeconds,
AgentMaxSessions: MaxRuntimeAgentSessions,
}
value.Request = RuntimeRequestPolicy{
TaskCreatePerMinute: maxRuntimeRate,
@@ -437,6 +444,9 @@ func validateRuntimePolicy(value RuntimePolicySetting) error {
if task.AgentStepTimeoutSeconds != 0 && (task.AgentStepTimeoutSeconds < MinRuntimeAgentStepTimeoutSeconds || task.AgentStepTimeoutSeconds > MaxRuntimeAgentStepTimeoutSeconds) {
return kernel.BadAuthRequest(fmt.Sprintf("Agent 单步超时必须是 0 或 %d-%d 秒的整数 (0 表示沿用文本任务超时)", MinRuntimeAgentStepTimeoutSeconds, MaxRuntimeAgentStepTimeoutSeconds))
}
if task.AgentMaxSessions < MinRuntimeAgentSessions || task.AgentMaxSessions > MaxRuntimeAgentSessions {
return kernel.BadAuthRequest(fmt.Sprintf("Agent 同时对话上限必须是 %d-%d 的整数", MinRuntimeAgentSessions, MaxRuntimeAgentSessions))
}
request := value.Request
for label, item := range map[string]int{
"任务创建频控": request.TaskCreatePerMinute,

View File

@@ -27,6 +27,7 @@ type (
AdminAuditPage = app.AdminAuditPage
AdminCacheClearGroupResult = app.AdminCacheClearGroupResult
AdminCacheClearRequest = app.AdminCacheClearRequest
AgentSessionLimitRequest = app.AgentSessionLimitRequest
AdminCacheClearResult = app.AdminCacheClearResult
AdminChannelModelFetchResult = app.AdminChannelModelFetchResult
AdminChannelModelImportRequest = app.AdminChannelModelImportRequest

View File

@@ -42,7 +42,7 @@ services:
image: ${CANVAS_YINGCE_AGENT_IMAGE:?请先配置 CANVAS_YINGCE_AGENT_IMAGE(必须包含镜像 digest)}
environment:
YINGCE_AGENT_TOKEN: ${YINGCE_AGENT_TOKEN:?请先配置 YINGCE_AGENT_TOKEN}
MAX_CONCURRENT_SESSIONS: ${YINGCE_AGENT_MAX_SESSIONS:-8}
MAX_CONCURRENT_SESSIONS: ${YINGCE_AGENT_MAX_SESSIONS:-30}
NODE_MAX_OLD_SPACE_SIZE: ${YINGCE_AGENT_NODE_MEMORY_MB:-512}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/healthz"]

View File

@@ -77,6 +77,14 @@ const agentFields: PolicyField[] = [
min: 0,
max: 3_600,
},
{
group: "task",
name: "agentMaxSessions",
label: "同时对话上限",
extra: "同时进行中的画布 Agent 轮次,新安装默认 30。审批等待不占名额,降低上限不会中断已开始的对话。",
unit: "个",
max: 64,
},
];
const rateFields: PolicyField[] = [

View File

@@ -1,8 +1,8 @@
import { App, Button, Progress, Skeleton, Switch } from "antd";
import { Activity, AlertTriangle, Cpu, Database, Gauge, HardDrive, MemoryStick, RefreshCw, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
import { App, Button, InputNumber, Progress, Skeleton, Switch } from "antd";
import { Activity, AlertTriangle, Bot, Cpu, Database, Gauge, HardDrive, MemoryStick, RefreshCw, Server, ShieldCheck, Trash2, Wifi } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { clearRuntimeCache, getSystemPerformance, type SystemPerformance } from "@/services/api/system-performance";
import { clearRuntimeCache, getSystemPerformance, updateAgentSessionLimit, type SystemPerformance } from "@/services/api/system-performance";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
@@ -61,6 +61,8 @@ export default function SystemPerformancePage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [clearing, setClearing] = useState(false);
const [savingAgentLimit, setSavingAgentLimit] = useState(false);
const [agentLimitDraft, setAgentLimitDraft] = useState<number | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
const [loadError, setLoadError] = useState("");
const mountedRef = useRef(true);
@@ -149,7 +151,7 @@ export default function SystemPerformancePage() {
return (
<AdminPageFrame
title="系统性能"
description="紧凑查看主机、数据库、Redis 和安全运行时缓存;敏感连接信息不会在此展示。"
description="紧凑查看主机、数据库、Redis、画布 Agent 和安全运行时缓存;敏感连接信息不会在此展示。"
scroll
actions={<><label className="admin-performance-auto"><Switch size="small" checked={autoRefresh} onChange={setAutoRefresh} /><span>15 秒刷新</span></label><Button icon={<RefreshCw className="size-4" />} loading={refreshing} onClick={() => void load(false)}>刷新</Button></>}
>
@@ -213,6 +215,30 @@ export default function SystemPerformancePage() {
</div>
</div>
{(data.agents || []).map((agent) => {
const draft = agentLimitDraft ?? agent.configuredLimit;
return <SettingsSectionCard key={agent.id} layout="stacked" icon={<Bot className="size-4" />} title="画布 Agent 服务" description="同时进行中的对话占用一个名额。审批等待不占用。降低上限不会中断已经开始的对话,也不会在这台机器上再启动容器。" status={{ label: agent.healthy ? "服务可连接" : "服务不可达", color: agent.healthy ? "success" : "error" }} footer={<><span className="admin-performance-cache-note"><Gauge className="size-3.5" />新安装默认 30,一般不用再改</span><span style={{ display: "flex", gap: 8, alignItems: "center" }}><InputNumber min={1} max={64} precision={0} value={draft} onChange={(value) => setAgentLimitDraft(typeof value === "number" ? value : draft)} /><Button type="primary" loading={savingAgentLimit} disabled={draft === agent.configuredLimit} onClick={() => {
setSavingAgentLimit(true);
void updateAgentSessionLimit(draft).then(() => {
message.success("已应用 Agent 同时对话上限");
setAgentLimitDraft(null);
return load(false);
}).catch((error: unknown) => {
message.error(error instanceof Error ? error.message : "保存 Agent 上限失败");
}).finally(() => setSavingAgentLimit(false));
}}>保存上限</Button></span></>}>
<FactGrid>
<Fact label="服务" value={agent.name} />
<Fact label="运行位置" value={agent.mode === "remote" ? "独立服务" : "后端进程"} />
<Fact label="地址" value={agent.endpoint} mono />
<Fact label="进行中 / 上限" value={`${formatNumber(agent.active)} / ${formatNumber(agent.limit)}`} />
<Fact label="排队" value={formatNumber(agent.queued)} />
<Fact label="已保存上限" value={formatNumber(agent.configuredLimit)} />
<Fact label="说明" value={agent.statusMessage || "同一台机器提高上限即可,不需要再启动容器。"} />
</FactGrid>
</SettingsSectionCard>;
})}
<SettingsSectionCard layout="stacked" icon={<ShieldCheck className="size-4" />} title="缓存维护" description="只清理可安全重建的运行时状态,保留业务数据与活动并发租约。" status={<AdminStatusBadge label="白名单清理" tone="success" />} footer={<><span className="admin-performance-cache-note"><Gauge className="size-3.5" />清理后频控与线路健康状态会重新计算</span><Button danger icon={<Trash2 className="size-4" />} loading={clearing} onClick={requestClear}>清理运行时缓存</Button></>}>
<div className="admin-performance-cache-groups">
{(data?.redis.cacheGroups || []).map((group) => <div key={group.id}><span>{group.label}</span><strong>{formatNumber(group.keys)}</strong><small>keys</small></div>)}

View File

@@ -350,6 +350,8 @@ export type RuntimeTaskPolicy = {
agentStepMaxOutputTokens: number;
/** 画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。 */
agentStepTimeoutSeconds: number;
/** 同时进行中的画布 Agent 轮次上限。审批等待不占用名额。 */
agentMaxSessions: number;
};
export type RuntimeRequestPolicy = {

View File

@@ -105,9 +105,23 @@ export type SystemPerformance = {
cacheGroups: SystemPerformanceCacheGroup[];
statusMessage?: string;
};
agents: SystemPerformanceAgent[];
build: { version: string; commit: string; buildTime: string; goVersion: string };
};
export type SystemPerformanceAgent = {
id: string;
name: string;
mode: "remote" | "embedded";
endpoint: string;
healthy: boolean;
active: number;
limit: number;
queued: number;
configuredLimit: number;
statusMessage?: string;
};
export type RuntimeCacheClearResult = {
scope: "runtime";
deletedKeys: number;
@@ -120,6 +134,10 @@ export function getSystemPerformance(signal?: AbortSignal) {
return http.get<SystemPerformance>("/admin/system-performance", { signal });
}
export function updateAgentSessionLimit(maxSessions: number) {
return http.put<SystemPerformanceAgent>("/admin/system-performance/agent-limit", { maxSessions });
}
export function clearRuntimeCache() {
return http.post<RuntimeCacheClearResult>("/admin/system-performance/cache/clear", { scope: "runtime" });
}

View File

@@ -4,7 +4,7 @@ import { createServer } from "node:http";
import { dirname } from "node:path";
const token = process.env.YINGCE_AGENT_TOKEN || "";
const maxSessions = positiveInt(process.env.MAX_CONCURRENT_SESSIONS, 8, 1000);
let maxSessions = positiveInt(process.env.MAX_CONCURRENT_SESSIONS, 30, 64);
const memoryMB = positiveInt(process.env.NODE_MAX_OLD_SPACE_SIZE, 512, 8192);
const runtimePath = process.env.YINGCE_AGENT_RUNTIME || "/app/agent-runtime/agent-runtime.mjs";
const port = Number(process.env.PORT || 8081);
@@ -48,13 +48,21 @@ function acquire(request) {
});
}
function admitWaiters() {
while (waiters.length > 0 && active < maxSessions) {
active += 1;
waiters.shift().resolve();
}
}
function release() {
const waiter = waiters.shift();
if (waiter) {
waiter.resolve();
if (waiters.length > 0 && active <= maxSessions) {
waiters.shift().resolve();
admitWaiters();
return;
}
active = Math.max(0, active - 1);
admitWaiters();
}
function readBody(request) {
@@ -85,7 +93,27 @@ function writeRuntimeError(response, message) {
const server = createServer(async (request, response) => {
if (request.method === "GET" && request.url === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, active, limit: maxSessions }));
response.end(JSON.stringify({ ok: true, active, limit: maxSessions, queued: waiters.length }));
return;
}
if (request.method === "PUT" && request.url === "/v1/limit") {
if (!authorized(request)) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "unauthorized" }));
return;
}
try {
const body = JSON.parse((await readBody(request)).toString("utf8"));
const next = positiveInt(body.maxSessions, 0, 64);
if (!next) throw new Error("maxSessions must be an integer from 1 to 64");
maxSessions = next;
admitWaiters();
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, active, limit: maxSessions, queued: waiters.length }));
} catch (error) {
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: error instanceof Error ? error.message : "invalid limit" }));
}
return;
}
if (request.method !== "POST" || request.url !== "/v1/runs") {