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>
This commit is contained in:
Blizzard
2026-07-22 11:39:17 +08:00
parent b6927247da
commit aec7ad949c
15 changed files with 102 additions and 50 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ export async function me(): Promise<AuthUser | null> {
}
// ---- 模型配置(id 为雪花字符串)----
export type Kind = "chat" | "embedding";
export type Kind = "chat" | "voice" | "embedding";
export interface Model {
id: string;
+36 -29
View File
@@ -1,9 +1,17 @@
import { useState } from "react";
import { ModelManager } from "../components/ModelManager";
import type { Kind } from "../api";
// 模型配置页:工作主力(chat) / JARVIS 语音(voice) / 向量化(embedding) 三 Tab 统一管理。
// 工作与语音分开:编排/报告用强模型(chat),JARVIS 语音对话用快模型(voice, 低时延)——各配各激活。
const TABS: { key: Kind; label: string }[] = [
{ key: "chat", label: "工作主力模型" },
{ key: "voice", label: "JARVIS 语音模型" },
{ key: "embedding", label: "向量化模型" },
];
// 模型配置页:Chat / Embedding 双 Tab 统一管理。
export function ModelsPage() {
const [tab, setTab] = useState<"chat" | "embedding">("chat");
const [tab, setTab] = useState<Kind>("chat");
return (
<div className="space-y-6">
@@ -11,46 +19,46 @@ export function ModelsPage() {
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-150 pb-4">
<div>
<h3 className="text-base font-semibold text-gray-800"></h3>
<p className="text-xs text-gray-400">LLMEmbedding</p>
<p className="text-xs text-gray-400">/· JARVIS · RAG</p>
</div>
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
<button
onClick={() => setTab("chat")}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
tab === "chat"
? "bg-white text-violet-700 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
(Chat)
</button>
<button
onClick={() => setTab("embedding")}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
tab === "embedding"
? "bg-white text-violet-700 shadow-sm"
: "text-gray-500 hover:text-gray-700"
}`}
>
(Embedding)
</button>
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
tab === t.key ? "bg-white text-violet-700 shadow-sm" : "text-gray-500 hover:text-gray-700"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* 模型管理组件 */}
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
{tab === "chat" ? (
{tab === "chat" && (
<ModelManager
kind="chat"
title="对话模型配置 (chat → Dispatcher)"
title="工作主力模型 (chat → Dispatcher 编排/报告)"
baseUrlHint="https://api.deepseek.com"
modelHint="deepseek-v4-pro"
/>
)}
{tab === "voice" && (
<ModelManager
kind="voice"
title="JARVIS 语音模型 (voice → 语音对话,选快模型抢首字时延)"
baseUrlHint="https://api.deepseek.com"
modelHint="deepseek-chat"
/>
) : (
)}
{tab === "embedding" && (
<ModelManager
kind="embedding"
title="向量化模型配置 (embedding → mcp-go RAG)"
title="向量化模型 (embedding → mcp-go RAG)"
baseUrlHint="https://dashscope.aliyuncs.com/compatible-mode/v1"
modelHint="text-embedding-v3"
/>
@@ -59,4 +67,3 @@ export function ModelsPage() {
</div>
);
}
+11 -3
View File
@@ -34,7 +34,8 @@ func main() {
natsURL := envOr("NATS_URL", "nats://localhost:4222")
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
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) {
@@ -53,6 +54,12 @@ func main() {
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)
@@ -66,8 +73,9 @@ func main() {
if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err)
}
orch.SetGuardian(guardian) // 输入护栏 Tier2
orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费
orch.SetGuardian(guardian) // 输入护栏 Tier2
orch.SetUsageSink(sub) // 成本护栏:token 用量回写网关累计/计费
orch.SetVoicePool(voicePool) // JARVIS 语音任务用快模型(未配置则回落工作模型)
// HITL 持久化中断/恢复:开 checkpoint 存储 + 审批决定流,审批节点改走中断模型
// compose.Interrupt 落盘释放 goroutine、抗 dispatcher 重启)。任一步失败则降级回阻塞模型。
@@ -18,6 +18,7 @@ type boardSnapshot struct {
UID string `json:"uid,omitempty"`
SID string `json:"sid,omitempty"`
Query string `json:"query,omitempty"`
UseVoice bool `json:"use_voice,omitempty"`
Profile string `json:"profile,omitempty"`
History []*schema.Message `json:"history,omitempty"`
KB string `json:"kb,omitempty"`
@@ -35,6 +36,7 @@ func (b *board) MarshalJSON() ([]byte, error) {
UID: b.uid,
SID: b.sid,
Query: b.query,
UseVoice: b.useVoice,
Profile: b.profile,
History: b.history,
KB: b.kb,
@@ -56,6 +58,7 @@ func (b *board) UnmarshalJSON(data []byte) error {
b.uid = s.UID
b.sid = s.SID
b.query = s.Query
b.useVoice = s.UseVoice
b.profile = s.Profile
b.history = s.History
b.kb = s.KB
@@ -56,9 +56,10 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t
flow, ferr := dsl.Parse(t.Graph)
plan := dsl.Compile(t.Graph)
b := &board{
uid: meta(t, contract.MetaUserID),
sid: meta(t, contract.MetaSessionID),
query: plan.Query,
uid: meta(t, contract.MetaUserID),
sid: meta(t, contract.MetaSessionID),
query: plan.Query,
useVoice: meta(t, contract.MetaModelProfile) == contract.ModelProfileVoice, // 语音任务 → 走语音模型池
}
// 无图/空图:退化为 compose 单轮对话。
@@ -17,7 +17,7 @@ import (
// START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。
// 模型未就绪 / 编译失败时降级回 runAgent(同样的流式回流,保证不回归)。
func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) {
cm := o.pool.ChatModel()
cm := o.agentPool(b).ChatModel() // 语音任务走语音模型池
if cm == nil {
o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩
return
@@ -154,7 +154,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
usedFuncNames := map[string]bool{}
for i, spec := range specs {
sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix
run := o.specialistRunner(ctx, spec, sys, byName)
run := o.specialistRunner(ctx, b, spec, sys, byName)
if run == nil {
tr.info("coordinator", "system", "专家跳过", "无可用模型:"+spec.Name)
continue
@@ -175,7 +175,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
// specialistRunner 构造专家执行闭包:带工具且模型支持函数调用→react.Agent.Generate;否则→ChatModel.Generate。
// 无可用模型返回 nil(该专家被跳过)。
func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) {
func (o *Orchestrator) specialistRunner(ctx context.Context, b *board, spec specialistSpec, sys string, byName map[string]tool.BaseTool) func(context.Context, string) (string, error) {
var tools []tool.BaseTool
for _, tn := range spec.Tools {
if t, ok := byName[tn]; ok {
@@ -183,7 +183,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
}
}
if len(tools) > 0 {
if tcm := o.pool.ToolCallingModel(); tcm != nil {
if tcm := o.agentPool(b).ToolCallingModel(); tcm != nil {
ag, err := react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: tcm,
ToolsConfig: compose.ToolsNodeConfig{Tools: tools},
@@ -202,7 +202,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
}
// 工具型专家但模型不支持函数调用 → 退纯对话(下方)
}
cm := o.pool.ChatModel()
cm := o.agentPool(b).ChatModel()
if cm == nil {
return nil
}
@@ -219,7 +219,7 @@ func (o *Orchestrator) specialistRunner(ctx context.Context, spec specialistSpec
// Anthropic 配方(分解→定制简报→并行派发→综合)。无 ToolCallingModel / 0 可用专家 → 降级单 agent。
func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
specs := parseSpecialists(n.Config)
tcm := o.pool.ToolCallingModel()
tcm := o.agentPool(b).ToolCallingModel()
if tcm == nil || len(specs) == 0 {
tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话")
o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调"))
+5 -3
View File
@@ -21,6 +21,7 @@ const defaultAgentSystem = "你是 sundynix-agentix 平台的 AI 助手。"
type board struct {
uid, sid string
query string
useVoice bool // 该任务用 JARVIS 语音模型池(网关 Meta[model_profile]==voice
profile string
history []*schema.Message
kb string // 最近一个检索节点的 owner 作用域库名(供 map 并行各项检索)
@@ -178,10 +179,11 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
var reasoning strings.Builder
onReasoning := func(s string) { reasoning.WriteString(s) }
var err error
if o.pool.Ready() {
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
pool := o.agentPool(b) // 语音任务走语音模型池(否则工作池)
if pool.Ready() {
err = pool.ChatStream(ctx, toChatMessages(msgs), send, onReasoning)
} else {
err = o.pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
err = pool.StreamText(ctx, replyFor(msgs), func(tok []byte) { send(string(tok)) })
}
if rc := reasoning.String(); rc != "" {
tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200)))
@@ -99,6 +99,7 @@ const specialistTimeout = 3 * time.Minute
// Orchestrator 把每个 DSL 任务动态编译为 Eino 图并执行(记忆召回 → 工具节点 → 注入 → 流式)。
type Orchestrator struct {
pool LLM
voicePool LLM // JARVIS 语音模型池(可为 nil → 语音任务回落 pool)
breaker *harness.CircuitBreaker
eval *harness.Evaluator
sink TokenSink
@@ -122,6 +123,19 @@ func NewOrchestrator(pool LLM, breaker *harness.CircuitBreaker, eval *harness.Ev
return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec, status: status, approval: approval, evalSink: evalSink}, nil
}
// SetVoicePool 注入 JARVIS 语音模型池(可选)。注入且就绪时,标了 model_profile==voice 的任务
// 用它跑,抢首字时延;不注入或未就绪则语音任务透明回落工作模型池(o.pool)。
func (o *Orchestrator) SetVoicePool(p LLM) { o.voicePool = p }
// agentPool 按黑板选本任务该用的模型池:语音任务且语音池就绪 → 语音池;否则工作池。
// 只用于面向用户的 agent 生成(对话/协作);报告/护栏/记忆抽取等固定走工作池。
func (o *Orchestrator) agentPool(b *board) LLM {
if b != nil && b.useVoice && o.voicePool != nil && o.voicePool.Ready() {
return o.voicePool
}
return o.pool
}
// SetGuardian 注入输入护栏 Tier2 的 LLM 分类器(可选;不注入则灰区任务直接放行执行)。
func (o *Orchestrator) SetGuardian(c *harness.Classifier) { o.guard = c }
@@ -170,7 +170,7 @@ func (o *Orchestrator) discoverTools(subject func(string) string, b *board, task
// runReactAgent 执行带"自主工具"的 agent 节点:模型在 ReAct 循环里自行决定调哪些 MCP 工具。
// 模型不支持函数调用 / 无工具时降级回普通 runAgent。最终答复流式回流;工具调用由适配器落轨迹。
func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *board, system string, n dsl.Node, tr *execTracer, node string) {
tcm := o.pool.ToolCallingModel()
tcm := o.agentPool(b).ToolCallingModel()
tools := o.agentTools(b, taskID, tr)
if tcm == nil || len(tools) == 0 {
tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话")
@@ -146,6 +146,16 @@ func (s *Subscriber) FetchModelConfigWithRetry(ctx context.Context, apply func(*
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply)
}
// SubscribeVoiceConfigUpdated 订阅 JARVIS 语音模型配置热更新(可空——未配置语音模型时不生效)。
func (s *Subscriber) SubscribeVoiceConfigUpdated(onUpdate func(*contract.ModelConfig)) (func() error, error) {
return s.inner.SubscribeConfigUpdated(contract.ConfigKindVoice, onUpdate)
}
// FetchVoiceConfigWithRetry 后台重试拉取初始语音模型配置(未配置则一直拿不到,语音任务回落工作模型)。
func (s *Subscriber) FetchVoiceConfigWithRetry(ctx context.Context, apply func(*contract.ModelConfig)) {
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindVoice, apply)
}
// FetchPromptsWithRetry 后台重试拉取初始激活 prompt 集(覆盖内置默认)。
func (s *Subscriber) FetchPromptsWithRetry(ctx context.Context, apply func(map[string]string)) {
s.inner.RequestPromptsWithRetry(ctx, apply)
+2 -2
View File
@@ -74,8 +74,8 @@ func main() {
bus := nats.MustConnect(natsURL) // 接入 NATS 零拷贝骨干网 + 声明任务流
defer bus.Close()
// 配置控制面:按 kind 响应消费方(Dispatcher=chat / mcp-go=embedding)的配置请求。
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
// 配置控制面:按 kind 响应消费方(Dispatcher=chat/voice / mcp-go=embedding)的配置请求。
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} {
k := kind
if _, err := bus.ServeConfig(k, func() *contract.ModelConfig {
return db.ActiveConfig(context.Background(), k) // chat 含 Fallbacks(其它模型作备用)
+1 -1
View File
@@ -566,7 +566,7 @@ func (h *Handler) TestModel(c *gin.Context) {
// broadcastActive 重新广播各 kind 当前激活配置,触发对应消费方热更新。
// chat 配置带 Fallbacks(其它已登记 chat 模型作备用),dispatcher 据此重建 failover 链。
func (h *Handler) broadcastActive(ctx context.Context) {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding} {
for _, kind := range []string{contract.ConfigKindChat, contract.ConfigKindEmbedding, contract.ConfigKindVoice} {
if cfg := h.db.ActiveConfig(ctx, kind); cfg != nil {
// 广播失败 = dispatcher/mcp-go 拿不到新配置,症状是"控制台改了模型却不生效",
// 而改配置的人这边一切正常。必须留痕,否则只能靠猜。
@@ -65,6 +65,7 @@ func (s *voiceSession) submitVoiceTask(transcript, graphOverride string) (string
task.Meta[contract.MetaUserID] = s.uid
task.Meta[contract.MetaTenantID] = billingTenant
task.Meta[contract.MetaSessionID] = s.sessionID
task.Meta[contract.MetaModelProfile] = contract.ModelProfileVoice // 语音任务走 JARVIS 快模型(未配则回落工作模型)
if err := s.h.launchCore(ctx, s.uid, task); err != nil {
return "", err
+7 -1
View File
@@ -153,8 +153,14 @@ const (
// 配置控制面按 kind 寻址:sundynix.config.<kind>.get / .updated。
// Gateway 持有配置,消费方(Dispatcher/mcp-go)经 NATS 取用/订阅变更。
ConfigKindChat = "chat" // 对话模型(Dispatcher
ConfigKindChat = "chat" // 工作主力对话模型(Dispatcher 默认用,要强
ConfigKindEmbedding = "embedding" // 向量模型(mcp-go RAG 用)
ConfigKindVoice = "voice" // JARVIS 语音对话模型(Dispatcher 给语音任务用,要快/低时延)
// MetaModelProfile 指定该任务用哪档模型:为 ModelProfileVoice 时 Dispatcher 走语音模型池
// (未配置语音模型则透明回落工作模型)。语音任务由网关置此标记,与 intent==report 同类路由。
MetaModelProfile = "model_profile"
ModelProfileVoice = "voice"
// 报告生成:Task.Meta[MetaIntent]==IntentReport 时,Dispatcher 走专用多步编排
// (规划大纲 → 各章节并行检索+撰写 → 汇聚 → 渲染 Word),而非通用对话图。