提问背景 Question:
以下是ai聊天记录保存在数据库和内存的程序,正常情况下,它能回忆起之前的聊天内容,形成对话。但是浏览器刷新一下,之前的聊天内容就没有记忆了。为了解决这个问题,我刷新页面的时候重新从数据库读取了以前的聊天内容,再加入内存,但好像无效。帮我看看原因。package ai
import (
"database/sql"
"strings"
"stv/fun"
"sync"
"time"
"google.golang.org/genai"
)
// 设置管理对象
var SessionConfig = struct {
MaxHistoryPairs int
}{
MaxHistoryPairs: 15,
}
var sessionStore sync.Map
// GetUserHistory 依然保持公开,供外部读取上下文
func GetUserHistory(sessionID string) []genai.Content {
history, ok := sessionStore.Load(sessionID)
if !ok {
return []genai.Content{}
}
return history.([]genai.Content)
}
// 内部私有方法:强制要求所有写入操作必须通过 append 逻辑
func saveUserHistory(sessionID string, history []genai.Content) {
sessionStore.Store(sessionID, history)
}
// AppendPairAndTrim: 唯一的合法写入路径
func AppendPairAndTrim(sessionID string, userContent, modelContent genai.Content) {
history := GetUserHistory(sessionID)
history = append(history, userContent, modelContent)
// 强制执行裁剪,确保不会超额
maxRecords := SessionConfig.MaxHistoryPairs
if len(history) > maxRecords {
history = history[len(history)-maxRecords:]
}
saveUserHistory(sessionID, history)
}
// 读取聊天记录到内存,并展示初始化到聊天窗口中
func LoadUserHistoryToMemory(userID int64, sessionID string) error {
// 查询最近20条(对应10对对话,根据需求可调)记录
rows, err := fun.DB.Query(
"SELECT question, description, content FROM cms_article WHERE user_id = ? ORDER BY id DESC LIMIT 15",
userID,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var q, d, c string
if err := rows.Scan(&q, &d, &c); err != nil {
continue
}
userC := NewContent("user", q)
modelC := NewContent("model", c)
AppendPairAndTrim(sessionID, userC, modelC) // 写入会话数据库
}
return nil
}
// msg_type,0为文本,1为语音
func SaveAIGeneratedData(user_id int, userQuestion string, resp AIStructuredResponse, msg_type int) error {
// 开启事务
tx, err := fun.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
articleID := fun.SnowNode.Generate().Int64() // 生成雪花 ID
now := time.Now().Unix()
// 2. 插入文章主表 cms_article
articleQuery := `
INSERT INTO cms_article (id, question, title, description, content, source, ip, score, status, created_at, updated_at, user_id, msg_type)
VALUES (?, ?, ?, ?, ?, 'gemini', NULL, ?, ?, ?, ?, ?, ?)
`
_, err = tx.Exec(articleQuery, articleID, userQuestion, resp.Title, resp.Desc, resp.Text, resp.Score, resp.Status, now, now, user_id, msg_type)
if err != nil {
return err
}
// 3. 处理标签 tags
for _, tagName := range resp.Tags {
tagName = strings.TrimSpace(tagName)
if tagName == "" {
continue
}
// 3.1 尝试在 cms_tag 中查找或创建该标签
var tagID int64
err = tx.QueryRow("SELECT id FROM cms_tag WHERE name = ?", tagName).Scan(&tagID)
if err == sql.ErrNoRows {
// 找不到则创建新标签(使用雪花 ID)
tagID = fun.SnowNode.Generate().Int64() // 生成雪花 ID
_, err = tx.Exec("INSERT INTO cms_tag (id, name, count, created_at, updated_at) VALUES (?, ?, 1, ?, ?)", tagID, tagName, now, now)
if err != nil {
return err
}
} else if err != nil {
return err
} else {
// 找到了则将该标签下的文章计数自增 1
_, err = tx.Exec("UPDATE cms_tag SET count = count + 1, updated_at = ? WHERE id = ?", now, tagID)
if err != nil {
return err
}
}
// 3.2 绑定文章和标签的关系到 cms_article_tag 关联表中
_, err = tx.Exec("INSERT OR IGNORE INTO cms_article_tag (article_id, tag_id) VALUES (?, ?)", articleID, tagID)
if err != nil {
return err
}
}
// 提交事务
err = tx.Commit()
if err != nil {
return err
}
return nil
}
// 前端需要将 AI 的回复也存入历史时,调用这个简单的辅助函数
func NewContent(role string, text string) genai.Content {
contents := genai.Text(text)
if len(contents) > 0 && contents[0] != nil {
res := *contents[0]
res.Role = role
return res
}
return genai.Content{Role: role}
}
解决刷新页面AI失忆问题
你的代码在浏览器刷新后“加载历史记忆无效”,主要存在以下几个致命原因:
1. 致命原因分析
原因一:时序完全颠倒(最严重)
你的 SQL 查询使用的是 ORDER BY id DESC LIMIT 15,这会查出最新的 15 条记录(例如:第 15 条、第 14 条……第 1 条)。
在 LoadUserHistoryToMemory 的循环中,你直接按照这个倒序顺序调用 AppendPairAndTrim 写入内存。这导致内存中历史记录的时序变成了**“从新到旧”**。大模型读取上下文时,会看到“未来的对话发生在过去”,导致其逻辑混乱,无法正常回应。
原因二:裁剪长度变成单数,导致 Role 错位报错
在你的 AppendPairAndTrim 方法中:
maxRecords := SessionConfig.MaxHistoryPairs // 值为 15
if len(history) > maxRecords {
history = history[len(history)-maxRecords:]
}
这里 maxRecords 是 15(单数)。当你循环装载 8 对对话(16条内容)时,切片长度超过 15,触发裁剪。裁剪掉最前面的一条后,剩余 15 条,此时第一条记录的 Role 变成了 "model"。Google Gemini API 强制要求历史上下文的第一条必须是 "user",且必须是 user-model 交替。Role 错位会导致 API 直接请求失败或被过滤。
原因三:循环内高频操作 sync.Map 造成性能浪费
在 LoadUserHistoryToMemory 循环中,每次都从 sessionStore 读取、追加、裁剪再写回,没有必要。应该一次性从数据库正序组装好 []genai.Content 后,一次性写入内存。
2. 修复后的完整 Go 代码
请使用以下优化后的代码替换你原有的方法:
package ai
import (
"database/sql"
"strings"
"stv/fun"
"sync"
"time"
"google.golang.org/genai"
)
// 设置管理对象
var SessionConfig = struct {
MaxHistoryPairs int // 保存的最大对话“对”数(1对 = 1个user + 1个model)
}{
MaxHistoryPairs: 10, // 10对 = 20条记录
}
var sessionStore sync.Map
// GetUserHistory 依然保持公开,供外部读取上下文
func GetUserHistory(sessionID string) []genai.Content {
history, ok := sessionStore.Load(sessionID)
if !ok {
return []genai.Content{}
}
return history.([]genai.Content)
}
// 内部私有方法:保存会话
func saveUserHistory(sessionID string, history []genai.Content) {
sessionStore.Store(sessionID, history)
}
// AppendPairAndTrim: 唯一的合法写入路径
func AppendPairAndTrim(sessionID string, userContent, modelContent genai.Content) {
history := GetUserHistory(sessionID)
history = append(history, userContent, modelContent)
// 强制执行裁剪,确保不会超额。必须乘以 2,保证裁剪后第一条依然是 user,且成对出现
maxRecords := SessionConfig.MaxHistoryPairs * 2
if len(history) > maxRecords {
history = history[len(history)-maxRecords:]
}
saveUserHistory(sessionID, history)
}
// 读取聊天记录到内存(修复了顺序颠倒与角色截断问题)
func LoadUserHistoryToMemory(userID int64, sessionID string) error {
// 使用子查询:先拿到最新的 10 对(20条)记录,再将其按 ID 正序(从旧到新)排列
query := `
SELECT question, content FROM (
SELECT id, question, content
FROM cms_article
WHERE user_id = ?
ORDER BY id DESC
LIMIT ?
) AS sub
ORDER BY id ASC
`
// 我们需要读取 MaxHistoryPairs 数量的对话
rows, err := fun.DB.Query(query, userID, SessionConfig.MaxHistoryPairs)
if err != nil {
return err
}
defer rows.Close()
var tempHistory []genai.Content
for rows.Next() {
var q, c string
if err := rows.Scan(&q, &c); err != nil {
continue
}
// 构造标准对话组
userC := NewContent("user", q)
modelC := NewContent("model", c)
// 依次追加到临时切片中(此时已是绝对正序:旧 -> 新)
tempHistory = append(tempHistory, userC, modelC)
}
// 一次性覆盖写入内存,避免循环内部重复读写锁
saveUserHistory(sessionID, tempHistory)
return nil
}
// 前端需要将 AI 的回复也存入历史时,调用这个简单的辅助函数
func NewContent(role string, text string) genai.Content {
contents := genai.Text(text)
if len(contents) > 0 && contents[0] != nil {
res := *contents[0]
res.Role = role
return res
}
return genai.Content{Role: role}
}
3. 注意事项(前后端联调)
- SessionID 的生命周期:用户刷新浏览器时,前端建立的 WebSocket 连接或发送的 HTTP 请求中,携带的
sessionID必须保持一致(例如存在浏览器的localStorage或Cookie中,而不是存在 JS 内存变量中。如果存在 JS 内存中,刷新页面后sessionID会变,后端将无法把读取的历史记录和新会话匹配)。 - 调用时机:当用户刷新页面,前端重新建立连接,并且传入
userID和sessionID时,首先调用LoadUserHistoryToMemory(userID, sessionID),然后再允许用户发送新消息。这样大模型就能完美回忆起先前的对话了。