Files
sundynix-agentix/sundynix-dispatcher/cmd/dispatcher/main.go
T
Blizzard aec7ad949c feat(model): 工作模型与 JARVIS 语音模型分开配置 + 语音任务路由到快模型
模型配置加"用途"维度:工作主力(chat,要强)与 JARVIS 语音(voice,要快/低时延)各配各激活,
语音任务走语音模型池,未配置则透明回落工作模型——不影响现有功能。

- contract: ConfigKindVoice="voice" + Meta[model_profile]=voice(与 intent==report 同类路由)
- gateway: ServeConfig/broadcastActive 循环纳入 voice;submitVoiceTask 打 model_profile=voice 标记
- dispatcher: 第二个 llm.Pool(voicePool)吃 voice 配置热更新;board.useVoice 从 Meta 派生(含快照);
  Orchestrator.agentPool(b) 按黑板选池——语音且语音池就绪→语音池,否则工作池;
  agent 生成路径(graph/react/coordinator/compose)全改走 agentPool(b),报告/护栏/记忆固定工作池
- admin: 模型页三 Tab(工作主力/JARVIS语音/向量化),复用 ModelManager;api Kind 加 voice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:39:17 +08:00

146 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Command dispatcher 启动 sundynix-dispatcher —— 第 4 层 AI Agent 调度集群。
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/sundynix/sundynix-dispatcher/internal/eino"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
"github.com/sundynix/sundynix-shared/contract"
"github.com/sundynix/sundynix-shared/health"
"github.com/sundynix/sundynix-shared/otelx"
"github.com/sundynix/sundynix-shared/prompts"
"github.com/sundynix/sundynix-shared/secrets"
)
func main() {
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
otelx.SetupSlog("sundynix-dispatcher") // 结构化日志 + 链路感知(任务日志带 trace_id)
// 链路追踪:消费 span 续上 gateway 的 trace,再向下展开节点/工具/LLM span。
shutdownTrace, _ := otelx.Init(context.Background(), "sundynix-dispatcher")
defer func() { _ = shutdownTrace(context.Background()) }()
// 受管 prompt:各包 init 已登记内置默认,这里加载 PROMPTS_FILE 覆盖(不重编译即可改/回滚)。
prompts.LoadFile()
natsURL := envOr("NATS_URL", "nats://localhost:4222")
pool := llm.NewPool() // 工作主力模型池(chat
voicePool := llm.NewPool() // JARVIS 语音模型池(voice;未配置则空、语音任务回落 pool)
breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心
// Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。
llmChat := func(ctx context.Context, sys, user string) (string, error) {
return pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
}
eval := harness.NewEvaluator(pool.Ready, llmChat)
// Harness: 输入护栏 Tier2 —— 对网关标记的灰区输入做 LLM 越狱裁决(模型就绪时启用)。
guardian := harness.NewClassifier(pool.Ready, llmChat)
sub := dnats.MustConnect(natsURL)
defer sub.Close()
// 配置控制面:先订阅热更新,再后台重试拉初始配置(容忍 gateway 晚于本服务启动,
// 避免一次性请求扑空后只能干等热更新 → 降级桩跑全程)。
if _, err := sub.SubscribeModelConfigUpdated(pool.SetConfig); err != nil {
log.Printf("[dispatcher] subscribe model config: %v", err)
}
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
// 语音模型(JARVIS)配置:同机制热更新 + 后台重试。未配置语音模型时拉不到、voicePool 保持空,
// 语音任务在 agentPool 里透明回落工作模型池——不影响现有功能。
if _, err := sub.SubscribeVoiceConfigUpdated(voicePool.SetConfig); err != nil {
log.Printf("[dispatcher] subscribe voice model config: %v", err)
}
go sub.FetchVoiceConfigWithRetry(context.Background(), voicePool.SetConfig)
// Prompt 控制面:拉激活集覆盖内置默认 + 订阅热更新(管理端激活某版即生效,不重启)。
go sub.FetchPromptsWithRetry(context.Background(), prompts.ApplyOverrides)
if _, err := sub.SubscribePromptsUpdated(prompts.ApplyOverrides); err != nil {
log.Printf("[dispatcher] subscribe prompts: %v", err)
}
// sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)、
// 任务状态回写(StatusSink)、HITL 审批等待(ApprovalWaiter)与评测落库(EvalSink)出口。
orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub, sub, sub)
if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err)
}
orch.SetGuardian(guardian) // 输入护栏 Tier2
orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费
orch.SetVoicePool(voicePool) // JARVIS 语音任务用快模型(未配置则回落工作模型)
// HITL 持久化中断/恢复:开 checkpoint 存储 + 审批决定流,审批节点改走中断模型
// compose.Interrupt 落盘释放 goroutine、抗 dispatcher 重启)。任一步失败则降级回阻塞模型。
var drainApprovals func(context.Context)
if kv, kerr := sub.Checkpoints(context.Background(), contract.BucketCheckpoints, 24*time.Hour); kerr != nil {
log.Printf("[dispatcher] 开 checkpoint 存储失败,审批降级为阻塞模型: %v", kerr)
} else if serr := sub.EnsureApprovalStream(context.Background()); serr != nil {
log.Printf("[dispatcher] 建审批决定流失败,审批降级为阻塞模型: %v", serr)
} else {
orch.SetCheckpoints(kv) // 启用中断模型
if drain, cerr := sub.ConsumeApprovals(context.Background(), orch.HandleApprovalDecision); cerr != nil {
log.Printf("[dispatcher] 启动审批决定消费者失败: %v", cerr)
} else {
drainApprovals = drain
log.Println("[dispatcher] HITL 中断/恢复已启用(checkpoint + 审批决定流)")
}
}
// 健康心跳:dispatcher 无 HTTP/工具端点,挂一个 NATS 应答让管理端「服务状态」探到它在线。
startedAt := time.Now()
if unsub, herr := sub.ServeHealth(func() []byte {
data, _ := json.Marshal(map[string]any{
"ok": true,
"service": "dispatcher",
"model": pool.ModelName(),
"ready": pool.Ready(),
"uptime_s": int(time.Since(startedAt).Seconds()),
"models": pool.ModelHealth(), // 主备链每模型实时健康/熔断态(供管理端展示)
})
return data
}); herr != nil {
log.Printf("[dispatcher] serve health: %v", herr)
} else {
defer func() { _ = unsub() }()
}
// HTTP 健康探针:给 k8s/LB 直接探(此前只有 NATS ServeHealth,编排器够不着)。
// readiness = NATS 连接可用(能收任务);liveness = 进程能应答。
healthShutdown := health.Serve("dispatcher", envOr("DISPATCHER_HEALTH_ADDR", ":8091"), sub.IsConnected)
defer func() {
sctx, scancel := context.WithTimeout(context.Background(), 5*time.Second)
defer scancel()
healthShutdown(sctx)
}()
// 监听退出信号,优雅停止消费。
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Println("[dispatcher] consuming sundynix.tasks.* (Ctrl-C to quit)")
if err := sub.ConsumeTasks(ctx, orch.Handle); err != nil && err != context.Canceled {
log.Fatalf("[dispatcher] exit: %v", err)
}
// 优雅停机:停审批决定消费者并等在途 resume 跑完(与任务 drain 同语义)。
if drainApprovals != nil {
dctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
drainApprovals(dctx)
cancel()
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}