Files
sundynix-agentix/sundynix-dispatcher/internal/llm/pool.go
T
Blizzard d84b1eceb5 feat(dispatcher): Eino 采纳 Phase A —— llm.Pool 内部换成 Eino ChatModel 组件
把手写的 OpenAI HTTP/SSE 客户端换成 eino-ext/openai 的 ChatModel 组件:
- SetConfig 用激活配置构建 ChatModel(热更新=重建实例,读写锁保护)
- ChatStream → cm.Stream(StreamReader 逐 chunk);Chat → cm.Generate
- 对外签名零变更(Chat/ChatStream/StreamText/Ready/ModelName),
  graph.go / report.go / memory_extract.go 不动
- Ready() 语义升级为「ChatModel 构建成功」,更准
- StreamText 降级桩保留

验收:make test-go 全模块绿;真实链路提交任务经新组件流式出答复
(28 字 + eval 1.00,Stream/Generate 两条路径都过)。

为 Phase B(BindTools 函数调用 + ADK)铺好底层组件。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:54:31 +08:00

179 lines
4.7 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"
"sync"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-shared/contract"
)
// 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{} }
// SetConfig 热更新后端配置:重建 ChatModel 实例(控制面变更时调用)。
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
var cm model.BaseChatModel
if cfg != nil && cfg.Ready() {
built, err := openai.NewChatModel(context.Background(), &openai.ChatModelConfig{
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Timeout: requestTimeout,
})
if err != nil {
fmt.Printf("[llm] 构建 ChatModel 失败(降级桩运行): %v\n", err)
} else {
cm = built
}
}
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\n", cfg.Provider, cfg.BaseURL, cfg.Model)
}
}
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 }
// ModelName 返回当前激活的对话模型名(未配置则空)—— 供服务状态面板展示。
func (p *Pool) ModelName() string {
if cfg := p.config(); cfg != nil {
return cfg.Model
}
return ""
}
// ChatStream 流式推理,逐 token 回调 onToken(经 Eino ChatModel.Stream)。
// 仅在 Ready() 时可用(调用方据此决定真实推理或降级桩)。
func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(string)) error {
cm := p.model()
if cm == nil {
return fmt.Errorf("no model configured")
}
sr, err := cm.Stream(ctx, toSchema(msgs))
if err != nil {
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.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")
}
out, err := cm.Generate(ctx, toSchema(msgs))
if err != nil {
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 间隔。
const (
timeToFirstToken = 700 * time.Millisecond
interTokenDelay = 60 * time.Millisecond
)
// StreamText 按节奏把给定文本流式回调(未配置真实后端时的降级桩)。
func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)) error {
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))
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
}