a2d184b7ec
新增 sundynix-shared/otelx:otelx.Init(ctx,服务名) 注册 W3C 传播器 + OTLP/HTTP 批量导出(默认 localhost:4318,docker 里的 Jaeger);OTEL_SDK_DISABLED=true 关导出。 Jaeger 不在线 / 导出器构建失败都不阻断启动(可观测性是增益而非依赖)。 - NATS 跨进程传播(无现成中间件):bus/trace.go 的 natsHeaderCarrier + inject/extract, PublishTask/CallTool 注入 traceparent,ConsumeTasks/ServeTool 抽出续上 → 链路跨总线连成一棵树。 - 埋点:gateway 挂 otelgin(HTTP server span,链路根);dispatcher task.execute → node.<kind>(每节点,nctx 下传使工具/LLM 挂到节点下)→ llm.stream/llm.generate; bus 自动出 tool.call(client)↔tool.serve(server) 成对跨服务 span。 - docker-compose 加 jaeger all-in-one(UI :16686,OTLP :4318)。 - 依赖修复:otlptracehttp 触发 genproto 单体(旧)vs 拆分模块 ambiguous import(milvus 拉旧版), pin genproto 至后拆分版(go.work 工作区全局生效)。 - production_readiness.md 1.1 更新为「已实现」。 验证:真实 input→retriever→agent 任务在 Jaeger 出 14 span / 3 服务的完整树, 跨 NATS(publish→consume)、跨服务(tool.call→tool.serve)均连通, 瓶颈 kb_search 692ms、llm 1597ms 一眼可见;四模块 build+vet+test 全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
// Package llm 抽象 LLM Pool(第三方在线 API / 自部署)的配置与流式推理。
|
||
// 底层用 Eino 的 ChatModel 组件(eino-ext/openai,OpenAI 兼容);本层只负责
|
||
// 配置热更新、降级桩与对外的稳定签名(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"
|
||
"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{} }
|
||
|
||
// 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 }
|
||
|
||
// 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)。
|
||
// 仅在 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")
|
||
}
|
||
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.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 间隔。
|
||
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
|
||
}
|