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 b59317415d
commit 0ac61434df
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