0.11.5: 旗舰版第六轮完善(节点Agent程序/裸机安装脚本/集群冷备部署方案)

- cmd/agent-node:独立节点执行器,心跳/认领/执行/上报闭环;prompt 走本地
  LLM 或网关,http 仅 http(s) 禁重定向限 1MiB,mcp/skill/数字员工经网关
  转发,custom 回显;不执行任意 shell 命令,串行执行 + 优雅退出。
- scripts/install-agent-node.sh:裸机安装 + systemd 加固(0600 配置/
  NoNewPrivileges/ProtectSystem)。
- deploy/CLUSTER.md:单机/冷备/集群形态,多副本网关 compose override +
  nginx least_conn 入口,冷备 master key 同步与恢复演练要点。
- 修复 agent claim 未解包 apiresponse 信封;真实 Agent 进程端到端验证
  (注册→心跳上线→custom/http 任务认领执行上报→管理端成功可见);
  25 包测试通过。
This commit is contained in:
LLMGuardX Dev
2026-08-13 14:39:35 +08:00
parent d534865b33
commit 277f80dc82
6 changed files with 725 additions and 0 deletions
+30
View File
@@ -113,3 +113,33 @@ copying the bundle to a deployment host.
- 节点令牌沿用心跳同一套 SHA-256 + constant-time 校验,未知节点与错误令牌
返回同一错误,不泄露节点存在性。
- 个人限流按 (tool_id, portal_user_id) 独立窗口计数,个人倍数不影响全局额度。
## 0.11.5 — 旗舰版第六轮完善
发布时间:2026-08-13
新增交付:
- **智能体节点 Agent 程序**(`cmd/agent-node`):可独立部署到裸机/VM 的节点
执行器。周期心跳(上报版本/能力/最近错误)刷新在线状态;轮询认领节点池
任务;按类型执行——prompt(优先本地 OpenAI 兼容 LLM,缺省经网关)、
http(仅 http(s)、禁重定向、响应限 1 MiB)、mcp_invoke / skill_run /
digital_employee(经网关运行时转发,需 AGENT_GATEWAY_KEY)、custom(回显);
凭认领令牌上报结果/错误,失败交由网关按退避重试;串行执行 + 优雅退出 +
JSON 结构化日志。安全边界:不执行任意 shell 命令,载荷为网关下发的
类型化 JSON,节点令牌经环境注入。
- **裸机安装脚本**(`scripts/install-agent-node.sh`):自动构建二进制、
安装到 /usr/local/bin、生成 0600 权限节点配置(含令牌)、注册 systemd
服务并启动;支持 AGENT_GATEWAY_URL/AGENT_CODE/AGENT_TOKEN 等环境配置。
- **集群/冷备部署方案**(`deploy/CLUSTER.md`):单机/冷备/集群三种形态对照;
compose override 多副本网关 + nginx least_conn 入口示例;冷备数据层
备份与恢复要点(master key 同步、WAL 归档、MinIO 异地);生产建议清单。
端到端验证:
- 真实 Agent 进程注册节点 → 心跳上线(版本/能力上报) → 下发 custom/http
任务 → Agent 认领执行并上报 → 管理端任务成功、结果可见;日志含
认领/上报全链路。修复 Agent claim 未解包网关 apiresponse 信封的问题。
至此旗舰版矩阵中所有可独立实施的功能与交付物完成;剩余依赖外部条件:
企微/钉钉/飞书真实平台联调、多租户物理隔离、企业环境内的节点批量部署。
+460
View File
@@ -0,0 +1,460 @@
// Command agent-node 是智能体节点 Agent:向 AI Gateway 注册的心跳 + 任务执行器。
//
// 职责:
// - 周期性心跳(上报版本/能力/最近错误),刷新在线状态;
// - 轮询认领节点池任务(POST /v1/agent/nodes/{code}/tasks/claim);
// - 按任务类型执行:prompt(本地/网关 LLM)、http(受控出站)、mcp_invoke /
// skill_run / digital_employee(经网关运行时转发)、custom(回显);
// - 凭认领令牌上报结果或错误,失败交给网关按退避重试。
//
// 安全边界:
// - 不执行任意 shell 命令;http 任务只允许 http(s) 绝对地址且拒绝重定向;
// - 任务载荷是网关下发的可信 JSON,节点只做类型化执行;
// - 节点令牌经环境变量/配置文件注入,不经任务载荷传递。
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
type config struct {
GatewayURL string
Code string
Token string
Version string
Name string
Capabilities []string
// LLM 执行:优先本地 OpenAI 兼容端点,缺省回退网关。
LLMBaseURL string
LLMAPIKey string
// 转发类任务(mcp/skill/数字员工)与回退 LLM 用的网关 API Key。
GatewayKey string
HeartbeatEvery time.Duration
ClaimEvery time.Duration
HTTPTimeout time.Duration
}
func loadConfig() (config, error) {
cfg := config{
GatewayURL: strings.TrimRight(env("AGENT_GATEWAY_URL", "http://127.0.0.1:8080"), "/"),
Code: env("AGENT_CODE", ""),
Token: env("AGENT_TOKEN", ""),
Version: env("AGENT_VERSION", "0.11.4-agent"),
Name: env("AGENT_NAME", ""),
LLMBaseURL: strings.TrimRight(env("AGENT_LLM_BASE_URL", ""), "/"),
LLMAPIKey: env("AGENT_LLM_API_KEY", ""),
GatewayKey: env("AGENT_GATEWAY_KEY", ""),
HeartbeatEvery: durEnv("AGENT_HEARTBEAT_SECONDS", 30*time.Second),
ClaimEvery: durEnv("AGENT_CLAIM_SECONDS", 5*time.Second),
HTTPTimeout: durEnv("AGENT_HTTP_TIMEOUT_SECONDS", 60*time.Second),
}
for _, capability := range strings.Split(env("AGENT_CAPABILITIES", "tool_exec,llm,http"), ",") {
if value := strings.TrimSpace(capability); value != "" {
cfg.Capabilities = append(cfg.Capabilities, value)
}
}
if cfg.Code == "" || cfg.Token == "" {
return cfg, errors.New("AGENT_CODE 与 AGENT_TOKEN 必填(管理端登记节点后交付)")
}
if cfg.HeartbeatEvery < time.Second || cfg.ClaimEvery < time.Second || cfg.HTTPTimeout < time.Second {
return cfg, errors.New("心跳/认领/超时秒数必须 >= 1")
}
return cfg, nil
}
func env(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func durEnv(key string, fallback time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if parsed, err := time.ParseDuration(value + "s"); err == nil {
return parsed
}
}
return fallback
}
type agent struct {
cfg config
client *http.Client
logger *slog.Logger
// lastError 是最近一次心跳/执行错误,随下一次心跳上报。
lastError string
}
func main() {
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := loadConfig()
if err != nil {
logger.Error("invalid configuration", "error", err)
os.Exit(1)
}
a := &agent{
cfg: cfg,
client: &http.Client{Timeout: cfg.HTTPTimeout},
logger: logger,
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
logger.Info("agent node starting", "gateway", cfg.GatewayURL, "code", cfg.Code, "capabilities", cfg.Capabilities)
run(ctx, a)
logger.Info("agent node stopped")
}
func run(ctx context.Context, a *agent) {
heartbeatTicker := time.NewTicker(a.cfg.HeartbeatEvery)
claimTicker := time.NewTicker(a.cfg.ClaimEvery)
defer heartbeatTicker.Stop()
defer claimTicker.Stop()
// 启动立即做一次心跳与认领,便于部署后快速验证。
if err := a.heartbeat(ctx); err != nil {
a.logger.Warn("initial heartbeat failed", "error", err)
a.lastError = err.Error()
} else {
a.lastError = ""
}
for {
select {
case <-ctx.Done():
return
case <-heartbeatTicker.C:
if err := a.heartbeat(ctx); err != nil {
a.logger.Warn("heartbeat failed", "error", err)
a.lastError = err.Error()
} else {
a.lastError = ""
}
case <-claimTicker.C:
a.processOne(ctx)
}
}
}
// heartbeat 上报版本/能力/最近错误。
func (a *agent) heartbeat(ctx context.Context) error {
payload, _ := json.Marshal(map[string]any{
"version": a.cfg.Version,
"capabilities": capabilityMap(a.cfg.Capabilities),
"metadata": map[string]string{"name": a.cfg.Name, "agent": "agent-node"},
"error": a.lastError,
})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.GatewayURL+"/v1/agent/nodes/"+a.cfg.Code+"/heartbeat", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Agent-Token", a.cfg.Token)
response, err := a.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
return fmt.Errorf("heartbeat HTTP %d", response.StatusCode)
}
return nil
}
// processOne 认领并执行一个任务(串行;一次一个)。
func (a *agent) processOne(ctx context.Context) {
claimCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
task, err := a.claim(claimCtx)
if err != nil {
a.logger.Warn("claim failed", "error", err)
a.lastError = "claim: " + err.Error()
return
}
if task == nil {
return
}
a.logger.Info("task claimed", "task_id", task.ID, "task_type", task.TaskType)
result, runErr := a.execute(ctx, *task)
if runErr != nil {
a.logger.Warn("task execution failed", "task_id", task.ID, "error", runErr)
a.lastError = "task " + task.ID[:8] + ": " + runErr.Error()
}
if err := a.complete(ctx, task.ID, task.ClaimToken, result, runErr); err != nil {
a.logger.Warn("task report failed", "task_id", task.ID, "error", err)
a.lastError = "report: " + err.Error()
return
}
a.logger.Info("task reported", "task_id", task.ID, "success", runErr == nil)
}
type claimedTask struct {
ID string `json:"id"`
TaskType string `json:"task_type"`
Payload json.RawMessage `json:"payload"`
ClaimToken string `json:"claim_token"`
}
func (a *agent) claim(ctx context.Context) (*claimedTask, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.GatewayURL+"/v1/agent/nodes/"+a.cfg.Code+"/tasks/claim", nil)
if err != nil {
return nil, err
}
request.Header.Set("X-Agent-Token", a.cfg.Token)
response, err := a.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if response.StatusCode/100 != 2 {
return nil, fmt.Errorf("claim HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(raw)))
}
// 网关统一 apiresponse 信封:{code,msg,data:{task}}。
var envelope struct {
Code int `json:"code"`
Data struct {
Task *claimedTask `json:"task"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, err
}
if envelope.Code != 200 {
return nil, fmt.Errorf("claim 业务错误 code=%d", envelope.Code)
}
return envelope.Data.Task, nil
}
func (a *agent) complete(ctx context.Context, taskID, claimToken string, result map[string]any, runErr error) error {
var errorText string
if runErr != nil {
errorText = runErr.Error()
if len(errorText) > 4000 {
errorText = errorText[:4000]
}
}
payload, _ := json.Marshal(map[string]any{"claim_token": claimToken, "result": result, "error": errorText})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.GatewayURL+"/v1/agent/nodes/"+a.cfg.Code+"/tasks/"+taskID+"/complete", bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Agent-Token", a.cfg.Token)
response, err := a.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode/100 != 2 {
return fmt.Errorf("complete HTTP %d", response.StatusCode)
}
return nil
}
// execute 按任务类型执行。载荷来自受管网关,字段类型不符返回错误。
func (a *agent) execute(ctx context.Context, task claimedTask) (map[string]any, error) {
var payload map[string]any
if err := json.Unmarshal(task.Payload, &payload); err != nil {
return nil, fmt.Errorf("task payload 不是 JSON 对象: %w", err)
}
switch task.TaskType {
case "prompt":
return a.executePrompt(ctx, payload)
case "http":
return a.executeHTTP(ctx, payload)
case "mcp_invoke":
return a.forwardGateway(ctx, "/v1/mcp-servers/"+stringValue(payload, "code")+"/tools/"+stringValue(payload, "tool")+"/invoke", map[string]any{"input": payload["args"]})
case "skill_run":
return a.forwardGateway(ctx, "/v1/skills/"+stringValue(payload, "code")+"/render", map[string]any{"variables": payload["variables"]})
case "digital_employee":
return a.forwardGateway(ctx, "/v1/digital-employees/"+stringValue(payload, "code")+"/chat/completions", map[string]any{"messages": payload["messages"]})
case "custom":
return map[string]any{"echo": payload}, nil
}
return nil, fmt.Errorf("不支持的节点任务类型 %s", task.TaskType)
}
// executePrompt 调用本地 LLM(OpenAI 兼容);未配置时回退网关 /v1/chat/completions。
func (a *agent) executePrompt(ctx context.Context, payload map[string]any) (map[string]any, error) {
model := stringValue(payload, "model")
messages, _ := payload["messages"].([]any)
if model == "" || len(messages) == 0 {
return nil, errors.New("prompt 任务需要 model 与 messages")
}
if a.cfg.LLMBaseURL != "" {
return a.callOpenAICompatible(ctx, a.cfg.LLMBaseURL+"/chat/completions", a.cfg.LLMAPIKey, model, messages)
}
if a.cfg.GatewayKey == "" {
return nil, errors.New("未配置 AGENT_LLM_BASE_URL 或 AGENT_GATEWAY_KEY,无法执行 prompt 任务")
}
body, _ := json.Marshal(map[string]any{"model": model, "messages": messages, "stream": false})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.GatewayURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+a.cfg.GatewayKey)
response, err := a.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if response.StatusCode/100 != 2 {
return nil, fmt.Errorf("网关 LLM HTTP %d: %s", response.StatusCode, truncate(string(raw), 300))
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
answer, _ := firstChoiceContent(decoded)
return map[string]any{"answer": answer, "source": "gateway"}, nil
}
// callOpenAICompatible 调用本地 OpenAI 兼容端点(节点侧 LLM)。
func (a *agent) callOpenAICompatible(ctx context.Context, endpoint, apiKey, model string, messages []any) (map[string]any, error) {
body, _ := json.Marshal(map[string]any{"model": model, "messages": messages, "stream": false})
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := a.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if response.StatusCode/100 != 2 {
return nil, fmt.Errorf("本地 LLM HTTP %d: %s", response.StatusCode, truncate(string(raw), 300))
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
answer, _ := firstChoiceContent(decoded)
return map[string]any{"answer": answer, "source": "local"}, nil
}
// executeHTTP 执行受控 HTTP 任务:只允许 http(s),拒绝重定向,响应限 1 MiB。
func (a *agent) executeHTTP(ctx context.Context, payload map[string]any) (map[string]any, error) {
target := strings.TrimSpace(stringValue(payload, "url"))
method := strings.ToUpper(strings.TrimSpace(stringValue(payload, "method")))
if method == "" {
method = "GET"
}
parsed, err := url.Parse(target)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
return nil, errors.New("http 任务 url 必须是 http(s) 绝对地址")
}
var body io.Reader
if value, ok := payload["body"].(string); ok && strings.TrimSpace(value) != "" {
body = strings.NewReader(value)
}
request, err := http.NewRequestWithContext(ctx, method, parsed.String(), body)
if err != nil {
return nil, err
}
if headers, ok := payload["headers"].(map[string]any); ok {
for key, value := range headers {
request.Header.Set(key, fmt.Sprint(value))
}
}
client := &http.Client{
Timeout: a.cfg.HTTPTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("http 任务不允许重定向")
},
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if len(raw) > 1<<20 {
return nil, errors.New("http 任务响应超过 1 MiB")
}
var decoded any = string(raw)
if json.Unmarshal(raw, &decoded) != nil {
decoded = string(raw)
}
return map[string]any{"status_code": response.StatusCode, "body": decoded}, nil
}
// forwardGateway 经网关运行时执行 mcp/skill/数字员工(需要 AGENT_GATEWAY_KEY)。
func (a *agent) forwardGateway(ctx context.Context, path string, bodyValue map[string]any) (map[string]any, error) {
if a.cfg.GatewayKey == "" {
return nil, errors.New("未配置 AGENT_GATEWAY_KEY,无法执行网关转发类任务")
}
body, _ := json.Marshal(bodyValue)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.cfg.GatewayURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+a.cfg.GatewayKey)
response, err := a.client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(response.Body, 2<<20))
if response.StatusCode/100 != 2 {
return nil, fmt.Errorf("网关转发 HTTP %d: %s", response.StatusCode, truncate(string(raw), 300))
}
var decoded map[string]any
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, err
}
return decoded, nil
}
func capabilityMap(capabilities []string) map[string]bool {
result := map[string]bool{}
for _, capability := range capabilities {
result[capability] = true
}
return result
}
func stringValue(payload map[string]any, key string) string {
value, _ := payload[key].(string)
return strings.TrimSpace(value)
}
func firstChoiceContent(decoded map[string]any) (string, bool) {
choices, _ := decoded["choices"].([]any)
if len(choices) == 0 {
return "", false
}
choice, _ := choices[0].(map[string]any)
message, _ := choice["message"].(map[string]any)
content, _ := message["content"].(string)
return content, strings.TrimSpace(content) != ""
}
func truncate(value string, max int) string {
runes := []rune(value)
if len(runes) > max {
return string(runes[:max]) + "…"
}
return value
}
+115
View File
@@ -0,0 +1,115 @@
# 集群与冷备部署方案
本文档说明旗舰版「单机 / 冷备 / 集群」部署形态的落地方式。当前工程的所有有状态
组件(PostgreSQL / Redis / MinIO)仍建议单点或托管服务,无状态控制面
(gateway-api / 各 worker)可按本文档横向扩展。
## 1. 部署形态对照
| 形态 | 说明 | 适用 |
|---|---|---|
| 单机 | 全部容器在一台主机(docker-compose.yml 默认) | 开发/小团队 |
| 冷备 | 数据层定期备份 + 备用主机完整镜像,主故障时切换 | 生产入门 |
| 集群 | 无状态组件多副本 + 数据层托管/高可用 | 生产规模化 |
## 2. 为什么可以横向扩展控制面
- **outbox worker**:`SKIP LOCKED` 租约 + Redis Stream 消费组,已支持多实例
并发消费,事件不会重复投递(worker 天然多副本安全);
- **gateway-api**:无本地状态,会话/限流/配额在 Redis,权威数据在 PostgreSQL,
任意副本可服务同一请求;审计批量 COPY 由 `audit_events` 分区 + advisory lock
维护,多副本写入安全;
- **调度器/通知/维护 worker**:全部基于数据库租约或消费组,多副本安全;
- **节点 Agent**:与网关是拉模型(心跳/认领),天然分布式。
## 3. 集群部署(compose override 示例)
`deploy/docker-compose.cluster.yml`:
```yaml
# 用法: docker compose -f docker-compose.yml -f docker-compose.cluster.yml up -d
services:
gateway-api:
deploy:
replicas: 3
# 集群模式下网关端口不应直接暴露(由入口 nginx 负载均衡到各副本),
# 移除主机端口绑定:
ports: []
gateway-api-lb:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./nginx-cluster.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- gateway-api
```
`deploy/nginx-cluster.conf`(入口负载均衡,round-robin 到各副本):
```nginx
upstream gateway_replicas {
least_conn;
server gateway-api:8080 max_fails=3 fail_timeout=10s;
server gateway-api:8080 max_fails=3 fail_timeout=10s;
server gateway-api:8080 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
server_tokens off;
client_max_body_size 256m;
location / {
proxy_pass http://gateway_replicas;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
}
}
```
说明:
- Compose 的 `deploy.replicas` 会为 `gateway-api` 创建多个容器,`least_conn`
负载均衡到全部副本;副本间无会话亲和需求(会话令牌在 Redis);
- worker 类服务保持单副本即可(多副本也安全,但无收益);
- 网关不再发布主机端口,所有流量经 `gateway-api-lb`
## 4. 冷备
数据层(权威):
- PostgreSQL: `pg_dump -Fc` 每日全量 + WAL 归档;恢复演练用
`pg_restore` 到备用主机;建议配合 `pgBackRest`/云厂商 PITR;
- Redis critical(会话/限流/配额):`APPENDONLY yes` 已在 compose 开启,定期
`redis-cli BGSAVE` 并备份 dump.rdb;丢失只会导致会话失效/限流重置,
不会损坏权威数据;
- MinIO: 对象存储桶异地备份(`mc mirror`)或托管 S3 兼容服务。
备用主机:
- 镜像完整代码与 compose 配置;`deploy/.env`(含 CREDENTIAL_MASTER_KEY)必须
同步——**master key 丢失会导致全部加密凭据不可解密**;
- 故障切换:恢复 DB/Redis 备份 → `docker compose up -d` → 验证
`healthz` 与登录;
- 建议每月做一次恢复演练(切换 runbook 见 `docs/cutover-runbook.md`)。
## 5. 多租户与集群的注意事项
- 会话/限流 Redis 需所有副本共享同一 critical Redis(或 Sentinel 高可用);
- `X-Forwarded-For` 由入口 nginx 统一设置,gateway-api 的限流/审计 IP 以
可信代理逻辑解析(见 `internal/identity/ratelimit.go`),不要直连暴露副本;
- 节点 Agent 指向入口地址(`AGENT_GATEWAY_URL`),经 LB 到达任意副本,拉模型
无粘性要求。
## 6. 生产环境建议清单
- [ ] 数据库连接池上限按副本数调整(`DATABASE_MAX_CONNS`),避免连接耗尽;
- [ ] PostgreSQL `max_connections` 与 shared_buffers 按主机内存配置;
- [ ] 入口启用 TLS(nginx/certbot 或云 LB),`CREDENTIAL_MASTER_KEY`
密钥管理服务注入,不进 compose 文件;
- [ ] 审计月分区自动维护已由 maintenance worker 执行,多副本由 advisory lock
互斥;
- [ ] 监控:healthz/readyz + Prometheus `/metrics` 接入现有监控;
- [ ] 备份与恢复演练纳入变更流程。
+15
View File
@@ -496,3 +496,18 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l
(下发→认领→伪造令牌拒绝→成功/失败上报→退避重试→达上限→取消冲突→重试);
HTTP 端到端:下发→认领(返回认领令牌)→上报→succeeded,管理端列表不泄露
认领令牌;任务完成站内信已验证。
# 追加:旗舰版功能完善第六轮(0.11.52026-08-13
1. **节点 Agent**(`cmd/agent-node`):裸机执行器,心跳/认领/执行/上报闭环。
- 安全边界:http 任务仅 http(s) 绝对地址 + 禁重定向 + 1 MiB 响应上限;
prompt 任务可走本地 LLM 或经网关(凭 AGENT_GATEWAY_KEY);不执行
任意 shell 命令;节点令牌只经环境变量注入,不进入任务载荷;
- claim 响应按网关 apiresponse 信封解包(修复根级解析 bug)。
2. **裸机安装**(`scripts/install-agent-node.sh`):systemd 服务 + 0600 配置,
NoNewPrivileges + ProtectSystem 加固。
3. **集群/冷备方案**(`deploy/CLUSTER.md`):无状态控制面多副本依据
(outbox SKIP LOCKED/Redis 会话/审计分区 advisory lock),入口
least_conn 负载均衡,冷备含 master key 同步与恢复演练要点。
4. 实测:真实 Agent 进程注册→心跳上线→custom/http 任务认领执行上报→
管理端成功可见。
@@ -228,3 +228,17 @@ MinIO 对象存储与管理端/个人文件仓库;pgvector + Ollama(bge-m3)
物理隔离需明确部署形态。
- 智能体节点远程安装/裸机部署脚本:任务下发与执行协议已就绪并端到端验证,
节点 Agent 安装包/注册脚本需在目标环境实施。
## 十一、0.11.5 完成情况(2026-08-13 第六轮完善)
| 交付物 | 状态 | 说明 |
|---|---|---|
| 节点 Agent 程序 | ✅ | cmd/agent-node 独立二进制,裸机心跳/认领/执行/上报闭环,实测通过 |
| 裸机安装脚本 | ✅ | scripts/install-agent-node.sh + systemd 加固 |
| 集群/冷备部署方案 | ✅ | deploy/CLUSTER.md + compose override + 冷备要点 |
旗舰版矩阵其余未完成项均为外部依赖:
- 企微/钉钉/飞书真实平台联调(需企业开放平台凭据);
- 多租户物理隔离(需明确部署形态);
- 企业环境内的节点批量部署与真实业务任务接入(协议与工具已就绪)。
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# install-agent-node.sh — 在裸机/虚拟机安装 AI Gateway 智能体节点 Agent。
#
# 用法:
# sudo AGENT_GATEWAY_URL=https://gw.example.com \
# AGENT_CODE=worker-01 \
# AGENT_TOKEN=agn_xxxx(管理端登记节点后交付) \
# [AGENT_NAME=] [AGENT_LLM_BASE_URL=] [AGENT_LLM_API_KEY=] \
# [AGENT_GATEWAY_KEY=] [AGENT_CAPABILITIES=tool_exec,llm,http] \
# bash scripts/install-agent-node.sh
#
# 动作:
# 1. 安装 Go 工具链(缺失时)并构建 cmd/agent-node;
# 2. 安装二进制到 /usr/local/bin/ai-agent-node;
# 3. 生成 /etc/ai-agent-node/env(权限 600,含节点令牌);
# 4. 安装 systemd 服务 ai-agent-node.service 并启动。
set -euo pipefail
AGENT_GATEWAY_URL="${AGENT_GATEWAY_URL:-http://127.0.0.1:8080}"
AGENT_CODE="${AGENT_CODE:?AGENT_CODE 必填(管理端登记的节点编码)}"
AGENT_TOKEN="${AGENT_TOKEN:?AGENT_TOKEN 必填(管理端登记节点后交付的令牌)}"
AGENT_NAME="${AGENT_NAME:-$AGENT_CODE}"
AGENT_VERSION="${AGENT_VERSION:-0.11.4-agent}"
AGENT_CAPABILITIES="${AGENT_CAPABILITIES:-tool_exec,llm,http}"
AGENT_LLM_BASE_URL="${AGENT_LLM_BASE_URL:-}"
AGENT_LLM_API_KEY="${AGENT_LLM_API_KEY:-}"
AGENT_GATEWAY_KEY="${AGENT_GATEWAY_KEY:-}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
INSTALL_DIR="/usr/local/bin"
ENV_FILE="/etc/ai-agent-node/env"
SERVICE_FILE="/etc/systemd/system/ai-agent-node.service"
echo "==> 1/4 构建 agent-node 二进制"
if ! command -v go >/dev/null 2>&1; then
echo " 未找到 go,尝试通过 apt 安装(仅 Debian/Ubuntu)"
apt-get update -qq && apt-get install -y -qq golang-go
fi
(
cd "$REPO_DIR"
GOFLAGS=-mod=mod go build -trimpath -ldflags "-s -w" -o /tmp/ai-agent-node ./cmd/agent-node
)
echo "==> 2/4 安装二进制到 $INSTALL_DIR/ai-agent-node"
install -m 0755 /tmp/ai-agent-node "$INSTALL_DIR/ai-agent-node"
rm -f /tmp/ai-agent-node
echo "==> 3/4 写入节点配置 $ENV_FILE(权限 600,含令牌)"
install -d -m 0700 /etc/ai-agent-node
umask 077
cat > "$ENV_FILE" <<EOF
AGENT_GATEWAY_URL=$AGENT_GATEWAY_URL
AGENT_CODE=$AGENT_CODE
AGENT_TOKEN=$AGENT_TOKEN
AGENT_NAME=$AGENT_NAME
AGENT_VERSION=$AGENT_VERSION
AGENT_CAPABILITIES=$AGENT_CAPABILITIES
AGENT_LLM_BASE_URL=$AGENT_LLM_BASE_URL
AGENT_LLM_API_KEY=$AGENT_LLM_API_KEY
AGENT_GATEWAY_KEY=$AGENT_GATEWAY_KEY
EOF
echo "==> 4/4 安装并启动 systemd 服务"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=AI Gateway Agent Node ($AGENT_CODE)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=$ENV_FILE
ExecStart=$INSTALL_DIR/ai-agent-node
Restart=always
RestartSec=5
# 节点进程只读本地文件系统;LLM/网关出站不受限。
ProtectSystem=strict
ReadWritePaths=/tmp
NoNewPrivileges=true
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable ai-agent-node.service >/dev/null 2>&1
systemctl restart ai-agent-node.service
echo "==> 完成:节点 $AGENT_CODE 已安装并启动"
echo " 查看日志: journalctl -u ai-agent-node -f"
echo " 心跳/任务状态: 管理端 → 安全与审计 → 智能体节点 / 节点任务"