Files
mayfly-go/server/internal/ai/session/manager.go
meilin.huang 3768cef62d feat: ai助手优化等
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 22:37:10 +08:00

446 lines
15 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 session
import (
"context"
"fmt"
"mayfly-go/pkg/gox"
"mayfly-go/pkg/logx"
"mayfly-go/pkg/utils/collx"
"mayfly-go/pkg/utils/stringx"
"sync"
"time"
"unicode/utf8"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
// Manager 会话管理器
// 负责会话缓存管理和生命周期管理,底层存储委托给 Store 实现
type Manager struct {
store Store // 底层存储
summaryConfig *SummaryConfig // 摘要配置(可选)
summarizing sync.Map // sessionKey -> struct{},防止同一会话并发摘要
}
// NewManager 创建会话管理器
// store: 底层存储实现 (如 JSONLStore, MemoryStore 等)
func NewManager(store Store) *Manager {
m := &Manager{
store: store,
}
return m
}
// WithSummaryConfig 设置摘要配置(选项模式)
func (m *Manager) WithSummaryConfig(config *SummaryConfig) *Manager {
if config != nil {
m.summaryConfig = config
}
return m
}
// GetHistory 获取会话历史消息Manager 层处理 Skip 优化和摘要组装)
// 注意:返回 schema.Message 切片,因为上层(如 adk需要 schema.Message 格式
func (m *Manager) GetHistory(ctx context.Context, key string, opts ...GetOption) ([]adk.Message, error) {
// 应用选项配置
options := defaultGetOptions()
for _, opt := range opts {
opt(options)
}
// 获取元数据,检查是否有摘要和 Skip
meta, err := m.GetMeta(ctx, key)
if err != nil {
logx.WarnfContext(ctx, "get meta error in GetHistory: %v", err)
// 如果获取元数据失败,直接返回 Store 的历史消息
sessionMsgs, err := m.store.GetHistory(ctx, key, options.messageLimit)
if err != nil {
return nil, err
}
return ToAdkMessages(sessionMsgs), nil
}
// 计算 Store 层实际需要读取的消息数量
// 目标:只读取未摘要的消息,避免读取已摘要的历史记录
var storeLimit int
if meta != nil && meta.Count > meta.Skip {
unsummarizedCount := meta.Count - meta.Skip
// 取未摘要消息数和用户限制中的较小值,避免加载过多数据
if options.messageLimit > 0 && options.messageLimit < unsummarizedCount {
storeLimit = options.messageLimit
} else {
storeLimit = unsummarizedCount
}
logx.DebugfContext(ctx, "count=%d, skip=%d, unsummarizedCount=%d, messageLimit=%d, storeLimit=%d",
meta.Count, meta.Skip, unsummarizedCount, options.messageLimit, storeLimit)
} else if meta != nil && meta.Count <= meta.Skip {
// 所有消息都已被摘要,无需从 Store 读取原始消息
if meta.Summary != "" {
summaryMsg := &schema.Message{
Role: schema.System,
Content: fmt.Sprintf("[之前的对话摘要]\n%s\n\n[以下是新的对话内容]", meta.Summary),
}
return []adk.Message{summaryMsg}, nil
}
return []adk.Message{}, nil
} else {
storeLimit = options.messageLimit
}
// 从 Store 获取历史消息(返回最后 storeLimit 条,即所有未摘要的消息)
sessionMsgs, err := m.store.GetHistory(ctx, key, storeLimit)
if err != nil {
return nil, err
}
// 从所有未摘要的消息中,取最新的 messageLimit 条
if options.messageLimit > 0 && len(sessionMsgs) > options.messageLimit {
sessionMsgs = sessionMsgs[len(sessionMsgs)-options.messageLimit:]
logx.DebugfContext(ctx, "trimmed to latest %d messages from %d unsummarized messages",
options.messageLimit, len(sessionMsgs)+options.messageLimit)
}
// 转换为 schema.Message
messages := ToAdkMessages(sessionMsgs)
// 如果存在摘要,将其作为系统消息前置
if meta != nil && meta.Summary != "" {
summaryMsg := &schema.Message{
Role: schema.System,
Content: fmt.Sprintf("[之前的对话摘要]\n%s\n\n[以下是新的对话内容]", meta.Summary),
}
messages = append([]adk.Message{summaryMsg}, messages...)
logx.DebugfContext(ctx, "prepended summary message, total %d messages", len(messages))
}
return messages, nil
}
// AppendMsgs 追加消息到会话
func (m *Manager) AppendMsgs(ctx context.Context, key string, msgs ...adk.Message) error {
if key == "" || len(msgs) == 0 {
return nil
}
// 转换为 Message
sessionMsgs := FromAdkMessages(msgs)
// 追加消息到底层存储Store 只负责存储,不更新元数据)
if err := m.store.AppendMsgs(ctx, key, sessionMsgs...); err != nil {
return err
}
meta, err := m.store.GetMeta(ctx, key)
if err != nil {
return err
}
// 如果元数据不存在,创建新会话
if meta == nil {
// 元数据不存在,创建新的
meta = &SessionMeta{
Key: key,
CreatedAt: time.Now(),
}
meta.Extra.Set("title", stringx.Truncate(msgs[0].Content, 50, 30, "..."))
}
// 计算新增消息的Token数量使用 CompletionTokens + 内容长度估算,避免 TotalTokens 的累积重复计算)
totalTokens := collx.ArrayReduce(msgs, 0, func(totalToken int, msg adk.Message) int {
responseMeta := msg.ResponseMeta
if responseMeta != nil && responseMeta.Usage != nil {
// 使用 CompletionTokens仅 assistant 生成部分),更准确地反映单条消息的实际 token 数
return totalToken + responseMeta.Usage.CompletionTokens
}
// 无 Usage 时按内容长度估算
return totalToken + estimateTokens(msg.Content)
})
// 保存元数据
meta.Count += len(msgs)
meta.TokenCount += totalTokens
meta.UpdatedAt = time.Now()
if err := m.store.SaveMeta(ctx, meta); err != nil {
return err
}
// 异步检查并执行摘要(如果配置了摘要功能)
if m.summaryConfig != nil && m.summaryConfig.Enabled {
// 快速预检查:只有当未摘要消息数接近阈值时才启动 goroutine避免无意义调度
unsummarizedCount := meta.Count - meta.Skip
keepCount := m.summaryConfig.KeepRecentCount
if keepCount <= 0 {
keepCount = DefaultSummaryKeepCount
}
if unsummarizedCount >= m.summaryConfig.MessageThreshold {
// 使用 context.WithoutCancel 防止请求结束后 context 被取消导致摘要中断
summaryCtx := context.WithoutCancel(ctx)
gox.Go(func() {
if err := m.CheckAndSummarize(summaryCtx, key); err != nil {
logx.ErrorfContext(summaryCtx, "auto summarize error: %v", err)
}
})
}
}
return nil
}
// Delete 删除会话
// 同时删除历史消息、元数据和缓存
func (m *Manager) Delete(ctx context.Context, key string) error {
// 先清空历史消息(可选,确保数据一致性)
if err := m.store.ClearHistory(ctx, key); err != nil {
return fmt.Errorf("manager: clear history: %w", err)
}
// 再删除元数据
return m.store.DeleteMeta(ctx, key)
}
// List 列出所有会话
// 从 Store 加载最新的会话元数据列表
func (m *Manager) List(ctx context.Context) ([]*SessionMeta, error) {
return m.store.ListMetas(ctx)
}
// ClearHistory 清空会话历史消息(保留元数据)
func (m *Manager) ClearHistory(ctx context.Context, key string) error {
// 调用 Store 清空历史
return m.store.ClearHistory(ctx, key)
}
// GetMeta 获取会话元数据
func (m *Manager) GetMeta(ctx context.Context, key string) (*SessionMeta, error) {
return m.store.GetMeta(ctx, key)
}
// SaveMeta 保存会话元数据
func (m *Manager) SaveMeta(ctx context.Context, meta *SessionMeta) error {
return m.store.SaveMeta(ctx, meta)
}
// CheckAndSummarize 检查并执行自动摘要(使用 Manager 内部配置)
func (m *Manager) CheckAndSummarize(ctx context.Context, sessionKey string) error {
// 使用 Manager 内部的配置
if m.summaryConfig == nil || !m.summaryConfig.Enabled {
return nil
}
config := m.summaryConfig
// 获取会话元数据
meta, err := m.GetMeta(ctx, sessionKey)
if err != nil {
return fmt.Errorf("get session meta: %w", err)
}
if meta == nil {
return nil
}
// 计算未摘要的消息数
unsummarizedCount := meta.Count - meta.Skip
// 获取保留消息数配置
keepCount := config.KeepRecentCount
if keepCount <= 0 {
keepCount = DefaultSummaryKeepCount
}
// 检查是否达到阈值(消息数量或 token 数量)
needSummarize := false
reason := ""
// 条件1未摘要消息数 >= (阈值 + 保留数),确保摘要有足够的压缩价值
// 例如threshold=5, keepCount=3则需要至少 8 条消息才触发摘要
minTriggerCount := config.MessageThreshold + keepCount
if unsummarizedCount >= minTriggerCount {
needSummarize = true
reason = fmt.Sprintf("unsummarized count %d >= minTriggerCount (%d + %d)",
unsummarizedCount, config.MessageThreshold, keepCount)
} else if meta.TokenCount >= config.TokenThreshold && unsummarizedCount > keepCount {
// 条件2Token 数 >= 阈值,且消息数 > 保留数Token 触发时放宽条件)
needSummarize = true
reason = fmt.Sprintf("token count %d >= threshold %d and unsummarized count %d > keepCount %d",
meta.TokenCount, config.TokenThreshold, unsummarizedCount, keepCount)
}
if !needSummarize {
logx.DebugfContext(ctx, "skip summarize: unsummarized=%d, minTriggerCount=%d (threshold=%d + keepCount=%d), tokens=%d",
unsummarizedCount, minTriggerCount, config.MessageThreshold, keepCount, meta.TokenCount)
return nil
}
logx.InfofContext(ctx, "trigger auto summarize, reason: %s", reason)
// 执行摘要
return m.summarizeSession(ctx, sessionKey, meta, config)
}
// summarizeSession 对会话进行摘要处理(增量摘要)
func (m *Manager) summarizeSession(ctx context.Context, sessionKey string, meta *SessionMeta, config *SummaryConfig) error {
// 防止同一 session 并发执行摘要
if _, loaded := m.summarizing.LoadOrStore(sessionKey, struct{}{}); loaded {
logx.DebugfContext(ctx, "summarization already in progress for session %s, skip", sessionKey)
return nil
}
defer m.summarizing.Delete(sessionKey)
keepCount := config.KeepRecentCount
if keepCount <= 0 {
keepCount = DefaultSummaryKeepCount
}
// 计算需要读取的消息数:未摘要的消息总数
// 直接调用 store.GetHistory避免 Manager.GetHistory 内部重复读取 meta
unsummarizedCount := meta.Count - meta.Skip
if unsummarizedCount <= 0 {
logx.WarnfContext(ctx, "no unsummarized messages found, skip summarization")
return nil
}
// 从 Store 层精确读取未摘要的消息(仅原始消息,不含摘要)
rawMessages, err := m.store.GetHistory(ctx, sessionKey, unsummarizedCount)
if err != nil {
return fmt.Errorf("get history: %w", err)
}
if len(rawMessages) == 0 {
logx.WarnfContext(ctx, "no unsummarized messages found after store query, skip summarization")
return nil
}
logx.InfofContext(ctx, "summarizing %d unsummarized messages (skip=%d, count=%d, keep=%d)",
len(rawMessages), meta.Skip, meta.Count, keepCount)
// 获取摘要器
summarizer := config.Summarizer
if summarizer == nil {
logx.WarnfContext(ctx, "summarizer is not configured")
return nil
}
// 组装完整的摘要输入:旧摘要(如有)+ 未摘要的原始消息
var fullContext []adk.Message
// 如果存在旧摘要,作为 System 消息前置
if meta.Summary != "" {
summaryMsg := &schema.Message{
Role: schema.System,
Content: fmt.Sprintf("[之前的对话摘要]\n%s\n\n[以下是新的对话内容]", meta.Summary),
}
fullContext = append(fullContext, summaryMsg)
logx.DebugfContext(ctx, "prepended old summary to context")
}
// 追加所有未摘要的原始消息(转换为 schema.Message
fullContext = append(fullContext, ToAdkMessages(rawMessages)...)
logx.DebugfContext(ctx, "full context for summarization: %d messages (1 system + %d raw)",
len(fullContext), len(rawMessages))
// 裁剪需要摘要的消息:保留最后 keepCount 条,摘要前面的部分
// 注意:触发条件已保证 len(fullContext) > keepCount
var messagesToSummarize []adk.Message
if len(fullContext) > keepCount {
// 只取前面的部分进行摘要(包含旧摘要 System 消息)
messagesToSummarize = fullContext[:len(fullContext)-keepCount]
logx.DebugfContext(ctx, "will summarize %d messages, keeping last %d messages",
len(messagesToSummarize), keepCount)
} else {
// 理论上不会走到这里(触发条件已过滤),但保留作为防御性编程
logx.WarnfContext(ctx, "unexpected: full context count (%d) <= keepCount (%d), skip summarization",
len(fullContext), keepCount)
return nil
}
// 使用裁剪后的消息生成新摘要
summaryText, err := summarizer.GenerateSummary(ctx, messagesToSummarize)
if err != nil {
logx.ErrorfContext(ctx, "generate summary error: %v", err)
// 摘要生成失败不影响主流程,仅记录日志
return nil
}
// 计算新的 Skip 值:跳过已被摘要的原始消息(不包括 System 摘要消息)
newSkipCount := meta.Skip + len(rawMessages) - keepCount
if newSkipCount < 0 {
newSkipCount = 0
}
// 更新元数据:设置新摘要和 Skip 偏移量
meta.Summary = summaryText
meta.Skip = newSkipCount
// 重新计算保留消息的 token 数(只计算最近 keepCount 条消息)
meta.TokenCount = 0
if len(fullContext) >= keepCount {
// 只计算保留的最近消息的 token
for _, msg := range fullContext[len(fullContext)-keepCount:] {
meta.TokenCount += messageTokens(msg)
}
} else {
// 如果历史消息少于 keepCount计算所有消息的 token
for _, msg := range fullContext {
meta.TokenCount += messageTokens(msg)
}
}
meta.UpdatedAt = time.Now()
// 重新读取最新的元数据,避免并发覆盖
latestMeta, err := m.GetMeta(ctx, sessionKey)
if err != nil {
logx.WarnfContext(ctx, "get latest meta error: %v, using current meta", err)
latestMeta = meta // 降级:使用当前 meta
} else if latestMeta != nil {
// 合并:保留最新的 Count 和 TokenCount但使用新计算的 Summary 和 Skip
latestMeta.Summary = meta.Summary
latestMeta.Skip = meta.Skip
latestMeta.TokenCount = meta.TokenCount // 使用重新计算的 token 数
latestMeta.UpdatedAt = meta.UpdatedAt
meta = latestMeta
}
if err := m.SaveMeta(ctx, meta); err != nil {
return fmt.Errorf("save meta: %w", err)
}
logx.InfofContext(ctx, "summarize completed, summary length: %d, skipped messages: %d, kept messages: %d",
len(summaryText), newSkipCount, keepCount)
// 发布会话摘要完成事件,供外部模块(如长期记忆提取)订阅处理
// 使用事件总线解耦session 包不感知下游消费者
EventBus.Publish(ctx, EventTopicSummarized, &SummarizedEvent{
UserId: meta.UserId,
SessionKey: sessionKey,
Summary: summaryText,
Skip: newSkipCount,
Count: meta.Count,
})
return nil
}
// estimateTokens 按内容长度估算 token 数
func estimateTokens(content string) int {
if content == "" {
return 0
}
// 混合中英文场景下的保守估算:
// 中文字符 ≈ 1 token/字,英文 ≈ 0.25 token/字符
// utf8.RuneCountInString 对中文返回字数,对英文返回字母数
// 取 rune 数的一半作为保守估计
return utf8.RuneCountInString(content) / 2
}
// messageTokens 获取单条消息的 token 数,优先使用 CompletionTokens否则估算
func messageTokens(msg adk.Message) int {
if msg.ResponseMeta != nil && msg.ResponseMeta.Usage != nil {
return msg.ResponseMeta.Usage.CompletionTokens
}
return estimateTokens(msg.Content)
}