fix: 修复记忆力差和跨群聊上下文泄漏
记忆嵌入修复: - 新增 memory.APIEmbedder 使用 text-embedding-3-small 替代 SimpleEmbedder - Extractor 保存记忆时自动生成向量嵌入 - Embedder 接口增加 IsAvailable() 方法 跨群聊上下文隔离: - Thinker 新增 thinkSessionID 字段,performThink 启动时绑定会话 - storeThought 优先使用绑定的 session 推送思考结果 - 防止思考过程中其他群消息改变 activeSessionID 导致串台 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIEmbedder generates text embeddings via OpenAI-compatible API.
|
||||
type APIEmbedder struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewAPIEmbedder creates a new embedding service.
|
||||
func NewAPIEmbedder(baseURL, apiKey, model string) *APIEmbedder {
|
||||
return &APIEmbedder{
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type embRequest struct {
|
||||
Input []string `json:"input"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type embResponse struct {
|
||||
Data []embData `json:"data"`
|
||||
Error *embError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type embData struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
}
|
||||
|
||||
type embError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Embed generates an embedding vector for the given text.
|
||||
func (e *APIEmbedder) Embed(ctx context.Context, text string) ([]float64, error) {
|
||||
if !e.IsAvailable() {
|
||||
return nil, fmt.Errorf("embedding service not available")
|
||||
}
|
||||
|
||||
reqBody := embRequest{
|
||||
Input: []string{text},
|
||||
Model: e.model,
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal embedding request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", e.baseURL+"/embeddings", bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create embedding request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedding response: %w", err)
|
||||
}
|
||||
|
||||
var embResp embResponse
|
||||
if err := json.Unmarshal(body, &embResp); err != nil {
|
||||
return nil, fmt.Errorf("parse embedding response: %w", err)
|
||||
}
|
||||
|
||||
if embResp.Error != nil {
|
||||
return nil, fmt.Errorf("embedding API error: %s", embResp.Error.Message)
|
||||
}
|
||||
|
||||
if len(embResp.Data) == 0 {
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
return embResp.Data[0].Embedding, nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if the embedding service is configured.
|
||||
func (e *APIEmbedder) IsAvailable() bool {
|
||||
return e.apiKey != "" && e.baseURL != ""
|
||||
}
|
||||
@@ -6,14 +6,16 @@ import (
|
||||
"fmt"
|
||||
"git.yeij.top/AskaEth/Cyrene/pkg/logger"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/model"
|
||||
)
|
||||
|
||||
// Extractor 记忆提取器 —— 从对话中提取结构化记忆
|
||||
type Extractor struct {
|
||||
store *Store
|
||||
llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
||||
store *Store
|
||||
llmChat func(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
||||
embedder Embedder // 可选:为保存的记忆生成向量嵌入
|
||||
}
|
||||
|
||||
// NewExtractor 创建记忆提取器
|
||||
@@ -26,6 +28,11 @@ func NewExtractor(store *Store, llmChat func(ctx context.Context, messages []mod
|
||||
}
|
||||
}
|
||||
|
||||
// SetEmbedder sets the embedder for generating vector embeddings on saved memories.
|
||||
func (e *Extractor) SetEmbedder(embedder Embedder) {
|
||||
e.embedder = embedder
|
||||
}
|
||||
|
||||
// ExtractAndStore 从一轮对话中提取记忆并存储
|
||||
// 异步执行,不阻塞主流程
|
||||
func (e *Extractor) ExtractAndStore(ctx context.Context, userID, sessionID, userMessage, assistantResponse string) {
|
||||
@@ -54,6 +61,21 @@ func (e *Extractor) storeMemories(ctx context.Context, userID, sessionID string,
|
||||
mem.SessionID = sessionID
|
||||
mem.Source = "conversation"
|
||||
|
||||
// 生成向量嵌入(异步,不阻塞主流程)
|
||||
if e.embedder != nil && e.embedder.IsAvailable() {
|
||||
embedCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
embedding, embErr := e.embedder.Embed(embedCtx, mem.Content)
|
||||
cancel()
|
||||
if embErr != nil {
|
||||
logger.Printf("[memory] 嵌入生成失败: %v,将保存无嵌入的记忆", embErr)
|
||||
} else {
|
||||
mem.Embedding = make([]float32, len(embedding))
|
||||
for i, v := range embedding {
|
||||
mem.Embedding[i] = float32(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := e.findSimilar(ctx, userID, &mem)
|
||||
if err == nil && existing != nil {
|
||||
e.mergeMemory(ctx, existing, &mem)
|
||||
|
||||
@@ -20,6 +20,7 @@ type Retriever struct {
|
||||
// Embedder 文本嵌入接口
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, text string) ([]float64, error)
|
||||
IsAvailable() bool
|
||||
}
|
||||
|
||||
// SimpleEmbedder 基于关键词的简单嵌入(MVP阶段可用,无需外部API)
|
||||
@@ -43,6 +44,9 @@ func (e *SimpleEmbedder) Embed(ctx context.Context, text string) ([]float64, err
|
||||
return vec, nil
|
||||
}
|
||||
|
||||
// IsAvailable returns true (SimpleEmbedder is always available as fallback).
|
||||
func (e *SimpleEmbedder) IsAvailable() bool { return true }
|
||||
|
||||
// NewRetriever 创建记忆检索器
|
||||
func NewRetriever(store *Store, embedder Embedder) *Retriever {
|
||||
if embedder == nil {
|
||||
|
||||
Reference in New Issue
Block a user