feat(admin): Agent 并发 - 后台可调同时对话上限并展示 Agent 服务状态
新增运行策略 agentMaxSessions(默认 30,范围 1-64),保存后同时下发到内嵌进程闸门与独立 yingce-agent 的 /v1/limit,降低上限不会中断已开始的对话。系统性能页展示 Agent 服务健康、进行中/排队数量,并可直接修改上限。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -350,6 +350,8 @@ export type RuntimeTaskPolicy = {
|
||||
agentStepMaxOutputTokens: number;
|
||||
/** 画布 Agent 单步模型调用的秒级墙钟;0 表示沿用文本任务超时。 */
|
||||
agentStepTimeoutSeconds: number;
|
||||
/** 同时进行中的画布 Agent 轮次上限。审批等待不占用名额。 */
|
||||
agentMaxSessions: number;
|
||||
};
|
||||
|
||||
export type RuntimeRequestPolicy = {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user