Files
sundynix-agentix/sundynix-dispatcher/internal/llm/pool.go
T
Blizzard e80e481f9f feat(llm): T2.3 输出缓存 —— 同输入命中跳过 LLM 调用(省成本+提速)
在 eino model 层加 cachingModel 装饰器,包在 failover 链最外层:命中直接跳过整条链。

- 只缓存 Generate(非流式):Stream 是用户可见的创作型输出、重复率低且回放复杂,透传不缓存。
- 键 = 模型名 + 绑定工具哈希 + 消息内容哈希,不同模型/工具集/输入互不串味。
- 默认 TTL 60s(env LLM_CACHE_TTL_S,0=关):只覆盖短窗内的重试/双发/重复点击 —— 这类
  几乎一定同一意图,命中省成本+提速;又短到不让助手对同一问题长期"复读"。容量上限
  LLM_CACHE_MAX(512) 满则随机淘汰。换激活模型 → 键含模型名自然失效。

测试:命中跳底层/不同输入不串味/TTL 过期重调/流式不缓存/工具集不同键/TTL=0 禁用。

诚实说明:本平台以创作型流式为主、且 Generate 输入(专家简报/评测)每次都变,**真实命中率
天然偏低**——主要吃"短窗内逐字相同"的重试/双发。机制正确、零风险(可 env 关),但不是大
成本杠杆;更大的省钱项(语义缓存/确定性工具结果缓存)是后续。Prompt 版本管理(T2.3 另一半)
未做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:40:20 +08:00

312 lines
10 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.
// Package llm 抽象 LLM Pool(第三方在线 API / 自部署)的配置与流式推理。
// 底层用 Eino 的 ChatModel 组件(eino-ext/openaiOpenAI 兼容);本层只负责
// 配置热更新、降级桩与对外的稳定签名(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-shared/contract"
"github.com/sundynix/sundynix-shared/otelx"
)
// 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
}
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
if cfg != nil && cfg.Ready() && !forceStub() {
cm = buildWithFallbacks(cfg)
}
p.mu.Lock()
p.cfg = cfg
p.cm = cm
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(降级桩);备用单个失败 → 跳过该备用,不影响主链。
func buildWithFallbacks(cfg *contract.ModelConfig) model.BaseChatModel {
primary, err := buildChatModel(cfg)
if err != nil {
fmt.Printf("[llm] 构建主 ChatModel 失败(降级桩运行): %v\n", err)
return nil
}
ptcm, ok := primary.(model.ToolCallingChatModel)
if !ok {
return primary // 不支持 WithTools(无法包 failover/cache)→ 直接用主模型
}
// 主链:主模型 +(可用的)备用模型串成 failover。
chain := ptcm
models := []model.ToolCallingChatModel{ptcm}
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)
}
}
if len(models) > 1 {
fmt.Printf("[llm] 启用模型 failover:主 %s + %d 个备用\n", cfg.Model, len(models)-1)
chain = newFailoverModel(models, func(idx int, ferr error) {
fmt.Printf("[llm] 模型 failover:第 %d 个模型失败(%v),切下一个\n", idx, ferr)
})
}
// 缓存包在最外层:命中直接跳过整条 failover 链(省成本+提速)。键含模型名 → 换模型自然失效。
return withCache(chain, cfg.Model)
}
// 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"。
// - vLLMopenai server 在 /v1,默认不校验 key(除非 --api-key 启动)→ 补 /v1 + 占位 "EMPTY"。
// - openai-compatibleDeepSeek/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
}