Files
smartmate/backend/service/agentsvc/agent_meta.go
Losita d91784d65f Version: 0.5.9.dev.260315
 为原有流式聊天链路补充“聊天结束后异步调用 LLM 生成对话标题并落库”的机制,相关测试已通过
📄 新增“获取对话元信息”接口,便于前端统一获取对话的各类信息,包括上述异步生成的标题
2026-03-15 19:54:49 +08:00

225 lines
7.3 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 agentsvc
import (
"context"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
einoModel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
arkModel "github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
)
const (
// conversationTitleTimeout 是异步标题生成的超时时间。
// 该过程不在主请求链路里,但仍要设置上限,避免后台协程长时间阻塞。
conversationTitleTimeout = 4 * time.Second
// conversationTitleHistoryLimit 限制参与“生成标题”的最近消息条数。
// 只取最近几轮可减少 token 成本,同时足够概括当前会话主题。
conversationTitleHistoryLimit = 8
// conversationTitleMaxChars 是标题最大字符数(按 rune 计)。
// 控制标题长度,避免前端展示溢出。
conversationTitleMaxChars = 24
)
const conversationTitlePrompt = `你是 SmartFlow 的会话标题生成器。
请基于给定对话内容,生成一个简短中文标题。
要求:
1) 只输出标题文本,不要解释,不要加引号,不要 markdown。
2) 标题长度控制在 8~20 个中文字符,尽量自然、口语化。
3) 不要出现“用户/助手/对话/聊天记录”等泛化词。
4) 如果内容是任务提醒类,标题应体现核心事项。`
// GetConversationMeta 返回单个会话的元信息(供前端轮询/主动拉取)。
// 说明:
// 1) 该接口和 SSE 流解耦,不依赖流式 header
// 2) title 允许为空,前端可根据 has_title 决定是否展示占位文案。
func (s *AgentService) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.GetConversationMetaResponse, error) {
chat, err := s.repo.GetConversationMeta(ctx, userID, strings.TrimSpace(chatID))
if err != nil {
return nil, err
}
title := ""
if chat.Title != nil {
title = strings.TrimSpace(*chat.Title)
}
return &model.GetConversationMetaResponse{
ConversationID: chat.ChatID,
Title: title,
HasTitle: title != "",
MessageCount: chat.MessageCount,
LastMessageAt: chat.LastMessageAt,
Status: chat.Status,
}, nil
}
// ensureConversationTitleAsync 在后台异步生成并写入会话标题。
// 设计约束:
// 1) 仅在“标题为空”时尝试生成,避免覆盖用户已确认/已存在标题;
// 2) 失败只记日志,不影响当前聊天链路;
// 3) 标题素材优先来自 Redis 历史(命中快、与当前上下文一致)。
func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) {
if s == nil || s.repo == nil || s.agentCache == nil {
return
}
if strings.TrimSpace(chatID) == "" {
return
}
go func() {
// 1. 后台任务使用独立超时上下文,避免受请求 ctx 取消影响。
ctx, cancel := context.WithTimeout(context.Background(), conversationTitleTimeout)
defer cancel()
// 2. 先查当前标题;若已存在则直接返回,不做多余模型调用。
title, exists, err := s.repo.GetConversationTitle(ctx, userID, chatID)
if err != nil {
log.Printf("异步生成会话标题失败(读取标题失败) chat=%s err=%v", chatID, err)
return
}
if !exists || strings.TrimSpace(title) != "" {
return
}
// 3. 从 Redis 读取当前会话历史,作为标题生成素材。
history, err := s.agentCache.GetHistory(ctx, chatID)
if err != nil {
log.Printf("异步生成会话标题失败(读取历史失败) chat=%s err=%v", chatID, err)
return
}
if len(history) == 0 {
return
}
// 4. 调用模型生成标题,并做格式清洗。
generated, err := s.generateConversationTitle(ctx, history)
if err != nil {
log.Printf("异步生成会话标题失败(模型生成失败) chat=%s err=%v", chatID, err)
return
}
if strings.TrimSpace(generated) == "" {
return
}
// 5. 只在标题仍为空时写入,保证并发幂等。
if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil {
log.Printf("异步生成会话标题失败(写库失败) chat=%s err=%v", chatID, err)
}
}()
}
// generateConversationTitle 使用聊天模型从近期历史生成标题。
func (s *AgentService) generateConversationTitle(ctx context.Context, history []*schema.Message) (string, error) {
modelInst := s.pickTitleModel()
if modelInst == nil {
return "", fmt.Errorf("标题生成模型未初始化")
}
// 1. 只取最近 N 条,降低 token 并聚焦当前会话主题。
trimmed := tailMessages(history, conversationTitleHistoryLimit)
prompt := buildConversationTitleUserPrompt(trimmed)
if strings.TrimSpace(prompt) == "" {
return "", fmt.Errorf("缺少可用历史内容")
}
messages := []*schema.Message{
schema.SystemMessage(conversationTitlePrompt),
schema.UserMessage(prompt),
}
// 2. 标题生成属于结构化短输出,关闭 thinking 并限制 tokens降低延迟与发散。
resp, err := modelInst.Generate(ctx, messages,
ark.WithThinking(&arkModel.Thinking{Type: arkModel.ThinkingTypeDisabled}),
einoModel.WithTemperature(0.2),
einoModel.WithMaxTokens(40),
)
if err != nil {
return "", err
}
if resp == nil {
return "", fmt.Errorf("标题生成模型返回为空")
}
return normalizeConversationTitle(resp.Content), nil
}
// pickTitleModel 选择用于标题生成的模型。
// 优先 worker成本低、速度快worker 不可用时回退 strategist。
func (s *AgentService) pickTitleModel() *ark.ChatModel {
if s.AIHub == nil {
return nil
}
if s.AIHub.Worker != nil {
return s.AIHub.Worker
}
return s.AIHub.Strategist
}
// buildConversationTitleUserPrompt 把消息历史拼成可读文本供模型总结。
func buildConversationTitleUserPrompt(messages []*schema.Message) string {
var builder strings.Builder
builder.WriteString("请根据以下对话内容生成标题:\n")
for _, msg := range messages {
if msg == nil {
continue
}
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
// 单条消息做长度裁剪,避免超长回复把标题主题“冲淡”。
content = trimRunes(content, 80)
role := "助手"
if strings.EqualFold(strings.TrimSpace(string(msg.Role)), string(schema.User)) {
role = "用户"
}
builder.WriteString(role)
builder.WriteString("")
builder.WriteString(content)
builder.WriteString("\n")
}
return strings.TrimSpace(builder.String())
}
func tailMessages(messages []*schema.Message, limit int) []*schema.Message {
if limit <= 0 || len(messages) <= limit {
return messages
}
return messages[len(messages)-limit:]
}
// normalizeConversationTitle 清洗模型输出,确保可直接展示/存库。
func normalizeConversationTitle(raw string) string {
text := strings.TrimSpace(raw)
if text == "" {
return ""
}
if idx := strings.Index(text, "\n"); idx >= 0 {
text = strings.TrimSpace(text[:idx])
}
text = strings.Trim(text, "\"'“”‘’《》[]【】")
text = strings.TrimPrefix(text, "标题:")
text = strings.TrimPrefix(text, "标题:")
text = strings.TrimSpace(text)
text = trimRunes(text, conversationTitleMaxChars)
return strings.TrimSpace(text)
}
func trimRunes(text string, limit int) string {
if limit <= 0 || text == "" {
return ""
}
if utf8.RuneCountInString(text) <= limit {
return text
}
runes := []rune(text)
return string(runes[:limit])
}