// Package llm 抽象 LLM Pool(第三方在线 API / 自部署)的配置与流式推理。 // 底层用 Eino 的 ChatModel 组件(eino-ext/openai,OpenAI 兼容);本层只负责 // 配置热更新、降级桩与对外的稳定签名(Chat / ChatStream / StreamText)。 package llm import ( "context" "fmt" "io" "os" "strconv" "strings" "sync" "time" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/components/model" "github.com/cloudwego/eino/schema" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-shared/contract" "github.com/sundynix/sundynix-shared/otelx" ) // ModelHealth 是主备链上单个模型的实时健康态(供管理端展示 failover/熔断可见)。 type ModelHealth struct { Provider string `json:"provider"` Model string `json:"model"` Role string `json:"role"` // primary / fallback State string `json:"state"` // closed(在线) / open(熔断中) / half-open(半开探测) / single(无备用链) Fails int `json:"fails"` // 闭合态连续失败计数 } // requestTimeout 是单次推理请求的上限。 const requestTimeout = 120 * time.Second // ChatMessage 是一条对话消息(role: system/user/assistant)。 type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } // Pool 维护当前激活的后端配置 + 据此构建的 Eino ChatModel(控制面经 NATS 下发,可热更新)。 type Pool struct { mu sync.RWMutex cfg *contract.ModelConfig cm model.BaseChatModel // 由 SetConfig 用激活配置构建;未配置时为 nil health []ModelHealth // 主备链各模型静态信息(provider/model/role),与 breakers 同序 breakers []*harness.CircuitBreaker // 与 health 一一对应;无 failover 链时为对应 nil } func NewPool() *Pool { return &Pool{} } // forceStub 报告是否强制走降级桩(LLM_FORCE_STUB=1)——压测平台自身吞吐时用, // 绕开真实 LLM 推理与计费,只压全链路 plumbing(网关→NATS→调度→工具RTT→回流)。 func forceStub() bool { return os.Getenv("LLM_FORCE_STUB") == "1" } // SetConfig 热更新后端配置:用激活配置(含备用模型)重建 ChatModel(控制面变更时调用)。 func (p *Pool) SetConfig(cfg *contract.ModelConfig) { var cm model.BaseChatModel var health []ModelHealth var breakers []*harness.CircuitBreaker if cfg != nil && cfg.Ready() && !forceStub() { cm, health, breakers = buildWithFallbacks(cfg) } p.mu.Lock() p.cfg = cfg p.cm = cm p.health = health p.breakers = breakers p.mu.Unlock() if cfg != nil { // 不打印 api_key。 fmt.Printf("[llm] model config set: provider=%s base=%s model=%s fallbacks=%d\n", cfg.Provider, normalizeBaseURL(cfg), cfg.Model, len(cfg.Fallbacks)) } } // buildWithFallbacks 构建主模型,并把可用的备用模型串成 failover 链(无备用则直接返回主模型)。 // 主模型构建失败 → 返回 nil(降级桩);备用单个失败 → 跳过该备用,不影响主链。 // 第二/三返回值为主备链的健康态元信息(provider/model/role)与对应熔断器(无 failover 链时为 nil), // 供 Pool.ModelHealth 上报管理端。 func buildWithFallbacks(cfg *contract.ModelConfig) (model.BaseChatModel, []ModelHealth, []*harness.CircuitBreaker) { primary, err := buildChatModel(cfg) if err != nil { fmt.Printf("[llm] 构建主 ChatModel 失败(降级桩运行): %v\n", err) return nil, nil, nil } ptcm, ok := primary.(model.ToolCallingChatModel) if !ok { // 不支持 WithTools(无法包 failover/cache)→ 单模型,无独立熔断器。 return primary, []ModelHealth{{Provider: cfg.Provider, Model: cfg.Model, Role: "primary"}}, []*harness.CircuitBreaker{nil} } // 主链:主模型 +(可用的)备用模型串成 failover。metas 与 models 同序。 models := []model.ToolCallingChatModel{ptcm} metas := []ModelHealth{{Provider: cfg.Provider, Model: cfg.Model, Role: "primary"}} for i := range cfg.Fallbacks { fb := cfg.Fallbacks[i] if !fb.Ready() { continue } fbm, ferr := buildChatModel(&fb) if ferr != nil { fmt.Printf("[llm] 构建备用模型 %s 失败,跳过: %v\n", fb.Model, ferr) continue } if t, ok := fbm.(model.ToolCallingChatModel); ok { models = append(models, t) metas = append(metas, ModelHealth{Provider: fb.Provider, Model: fb.Model, Role: "fallback"}) } } var chain model.ToolCallingChatModel = ptcm breakers := make([]*harness.CircuitBreaker, len(models)) // 无 failover 时全 nil if len(models) > 1 { fmt.Printf("[llm] 启用模型 failover:主 %s + %d 个备用\n", cfg.Model, len(models)-1) fm := newFailoverModel(models, func(idx int, ferr error) { fmt.Printf("[llm] 模型 failover:第 %d 个模型失败(%v),切下一个\n", idx, ferr) }) copy(breakers, fm.breakers) // 每模型熔断器(同序),供上报态 chain = fm } // 缓存包在最外层:命中直接跳过整条 failover 链(省成本+提速)。键含模型名 → 换模型自然失效。 return withCache(chain, cfg.Model), metas, breakers } // ModelHealth 返回当前主备链各模型的实时健康态(含每模型熔断状态),供管理端展示。 func (p *Pool) ModelHealth() []ModelHealth { p.mu.RLock() defer p.mu.RUnlock() out := make([]ModelHealth, len(p.health)) for i, h := range p.health { out[i] = h if i < len(p.breakers) && p.breakers[i] != nil { snap := p.breakers[i].Snapshot() out[i].State = snap.State.String() out[i].Fails = snap.Fails } else { out[i].State = "single" // 无备用链 → 该模型无独立熔断器 } } return out } // buildChatModel 据 provider 归一化连接参数后构建 OpenAI 兼容 ChatModel。 // vLLM 与 Ollama 都暴露 OpenAI 兼容 API(底层 go-openai 请求 {base}/chat/completions), // 故统一走 openai 客户端,仅差在 BaseURL 是否带 /v1 与是否需要占位 key: // - Ollama:端点固定为 {host}/v1,且不校验 api_key → 缺省补 /v1 + 占位 key "ollama"。 // - vLLM:openai server 在 /v1,默认不校验 key(除非 --api-key 启动)→ 补 /v1 + 占位 "EMPTY"。 // - openai-compatible(DeepSeek/OpenAI 等在线):BaseURL 原样(DeepSeek 两种都收,OpenAI 默认带 /v1)。 func buildChatModel(cfg *contract.ModelConfig) (model.BaseChatModel, error) { return openai.NewChatModel(context.Background(), &openai.ChatModelConfig{ APIKey: apiKeyOrPlaceholder(cfg), BaseURL: normalizeBaseURL(cfg), Model: cfg.Model, Timeout: requestTimeout, }) } // 本地后端 provider 标识(与控制台下拉、contract.ModelConfig.Provider 对齐)。 const ( providerOllama = "ollama" providerVLLM = "vllm" ) // normalizeBaseURL 为本地后端补全 /v1 端点(Ollama/vLLM 的 OpenAI 兼容 API 均在 /v1, // 漏写会打到 /chat/completions 而 404);在线 provider 原样返回。 func normalizeBaseURL(cfg *contract.ModelConfig) string { base := strings.TrimRight(cfg.BaseURL, "/") switch strings.ToLower(cfg.Provider) { case providerOllama, providerVLLM: if base != "" && !strings.HasSuffix(base, "/v1") { base += "/v1" } } return base } // apiKeyOrPlaceholder 为不校验 key 的本地后端补占位符(openai 客户端要求 api_key 非空)。 func apiKeyOrPlaceholder(cfg *contract.ModelConfig) string { if cfg.APIKey != "" { return cfg.APIKey } switch strings.ToLower(cfg.Provider) { case providerOllama: return "ollama" case providerVLLM: return "EMPTY" } return cfg.APIKey } func (p *Pool) config() *contract.ModelConfig { p.mu.RLock() defer p.mu.RUnlock() return p.cfg } func (p *Pool) model() model.BaseChatModel { p.mu.RLock() defer p.mu.RUnlock() return p.cm } // Ready 报告是否已配置可用后端(且 ChatModel 构建成功)。 func (p *Pool) Ready() bool { return p.model() != nil } // ChatModel 返回当前 Eino ChatModel 组件(用于 compose.Graph 编排);未就绪则 nil。 func (p *Pool) ChatModel() model.BaseChatModel { return p.model() } // ToolCallingModel 返回支持函数调用的模型(用于 ReAct agent);未就绪 / 不支持则 nil。 func (p *Pool) ToolCallingModel() model.ToolCallingChatModel { if tcm, ok := p.model().(model.ToolCallingChatModel); ok { return tcm } return nil } // ModelName 返回当前激活的对话模型名(未配置则空)—— 供服务状态面板展示。 func (p *Pool) ModelName() string { if cfg := p.config(); cfg != nil { return cfg.Model } return "" } // ChatStream 流式推理,逐 token 回调 onToken(经 Eino ChatModel.Stream)。 // onReasoning(可空)接收 reasoning 模型(DeepSeek-R1 / Qwen3 思考 / QwQ 等)的「思考过程」分片: // 思考阶段的分片只有 ReasoningContent、Content 为空,本就不会污染答案;onReasoning 让调用方 // 可把思考流单独 surface 到观测轨迹。仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。 func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string), onReasoning func(string)) error { cm := p.model() if cm == nil { return fmt.Errorf("no model configured") } ctx, span := otelx.Tracer().Start(ctx, "llm.stream", trace.WithAttributes( attribute.String("llm.model", p.ModelName()), attribute.Int("llm.messages", len(msgs)), )) defer span.End() sr, err := cm.Stream(ctx, toSchema(msgs)) if err != nil { span.RecordError(err) return fmt.Errorf("llm stream: %w", err) } defer sr.Close() for { chunk, rerr := sr.Recv() if rerr == io.EOF { return nil } if rerr != nil { return fmt.Errorf("llm stream recv: %w", rerr) } if chunk.ReasoningContent != "" && onReasoning != nil { onReasoning(chunk.ReasoningContent) } if chunk.Content != "" { onToken(chunk.Content) } } } // Chat 非流式:经 Eino ChatModel.Generate 拿到整段文本。 // 报告生成的「规划大纲 / 撰写章节」等需要拿到完整结果再继续,用它而非流式。 func (p *Pool) Chat(ctx context.Context, msgs []ChatMessage) (string, error) { cm := p.model() if cm == nil { return "", fmt.Errorf("no model configured") } ctx, span := otelx.Tracer().Start(ctx, "llm.generate", trace.WithAttributes( attribute.String("llm.model", p.ModelName()), attribute.Int("llm.messages", len(msgs)), )) defer span.End() out, err := cm.Generate(ctx, toSchema(msgs)) if err != nil { span.RecordError(err) return "", fmt.Errorf("llm generate: %w", err) } return out.Content, nil } // toSchema 把内部 ChatMessage 转为 Eino schema.Message。 func toSchema(msgs []ChatMessage) []*schema.Message { out := make([]*schema.Message, 0, len(msgs)) for _, m := range msgs { var role schema.RoleType switch m.Role { case "system": role = schema.System case "assistant": role = schema.Assistant default: role = schema.User } out = append(out, &schema.Message{Role: role, Content: m.Content}) } return out } // ---- 占位降级(未配置后端时)---- // 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。 // 可经 env 调整(压测平台吞吐时设 0 → 近瞬时桩,测出 plumbing 天花板而非被桩节奏掩盖)。 var ( timeToFirstToken = envDuration("LLM_STUB_TTFT_MS", 700*time.Millisecond) interTokenDelay = envDuration("LLM_STUB_INTERTOKEN_MS", 60*time.Millisecond) ) // envDuration 读毫秒 env(允许 0),缺省回退 def。 func envDuration(key string, def time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { return time.Duration(n) * time.Millisecond } } return def } // StreamText 按节奏把给定文本流式回调(未配置真实后端时的降级桩)。 func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)) error { if timeToFirstToken > 0 { select { case <-ctx.Done(): return ctx.Err() case <-time.After(timeToFirstToken): } } for _, tok := range tokenize(text) { select { case <-ctx.Done(): return ctx.Err() default: } onToken([]byte(tok)) if interTokenDelay > 0 { time.Sleep(interTokenDelay) } } return nil } func tokenize(s string) []string { out := make([]string, 0, len(s)) for _, r := range s { out = append(out, string(r)) } return out }