feat(agent): 经验记忆 - 按任务相关度排序召回,并过期清理待批准记忆

召回按主题、情境和中文双字命中加权;30天未处理的待批准记忆会在记录和压缩调度时过期,成功计数写入运行态以免事件窗口截断后误拦。
This commit is contained in:
ddcat666
2026-10-08 10:54:40 +08:00
parent 05f90caf78
commit 5acddb9ecb
7 changed files with 229 additions and 28 deletions

View File

@@ -48,6 +48,7 @@ const (
cloudAgentLessonSearchTokenMax = 8
cloudAgentRememberLessonMaxPerRun = 3
cloudAgentRememberLessonPendingPerUser = 50
cloudAgentPendingLessonTTL = 30 * 24 * time.Hour
cloudAgentRememberLessonApprovedMax = 300
cloudAgentMemoryImportMax = 200
)
@@ -121,12 +122,16 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
if cloudAgentRememberLessonCount(state) >= cloudAgentRememberLessonMaxPerRun {
return nil, BadAuthRequest(fmt.Sprintf("本轮最多记录 %d 条经验,请合并成更通用的一条", cloudAgentRememberLessonMaxPerRun))
}
now := time.Now()
if _, err := repo.ExpirePendingAgentLessons(userID, now.Add(-cloudAgentPendingLessonTTL), now, cloudAgentRememberLessonPendingPerUser); err != nil {
return nil, err
}
pending, err := repo.CountAgentLessonsByAuthor(userID, model.AgentLessonStatusPending)
if err != nil {
return nil, err
}
if pending >= cloudAgentRememberLessonPendingPerUser {
return nil, BadAuthRequest("待你批准的记忆已经比较多,请先到「设置 → Agent 记忆」处理后再记新的")
return nil, BadAuthRequest(fmt.Sprintf("当前有 %d 条待批准记忆,已达到上限 %d;请先到「设置 → Agent 记忆」批准或删除后再记新的", pending, cloudAgentRememberLessonPendingPerUser))
}
var args struct {
Topic string `json:"topic"`
@@ -170,7 +175,6 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
return nil, err
}
}
now := time.Now()
entry := &model.AgentLesson{
ID: newID(), Topic: topic, Situation: situation, Lesson: lesson, Source: source,
Category: cloudAgentNormalizeLessonCategory(args.Category),
@@ -214,13 +218,23 @@ func cloudAgentLessonEligibleSuccesses(state *cloudAgentRuntime) int {
continue
}
name, _ := event.Payload["toolName"].(string)
switch name {
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
continue
if cloudAgentLessonToolEligible(name) {
count++
}
count++
}
return count
if count > state.LessonEligibleToolSuccesses {
return count
}
return state.LessonEligibleToolSuccesses
}
func cloudAgentLessonToolEligible(name string) bool {
switch name {
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
return false
default:
return true
}
}
func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
@@ -236,7 +250,10 @@ func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
count++
}
}
return count
if count > state.RememberLessonSuccesses {
return count
}
return state.RememberLessonSuccesses
}
func cloudAgentLessonFingerprint(situation, lesson, stepsJSON string) string {
@@ -397,20 +414,34 @@ func (s *Service) cloudAgentLessonsBlock(userID, taskText string) string {
}
func cloudAgentPickLessonIndex(lessons []model.AgentLesson, taskText string, limit int) ([]model.AgentLesson, int) {
matched := make([]model.AgentLesson, 0)
rest := make([]model.AgentLesson, 0, len(lessons))
for _, lesson := range lessons {
if cloudAgentLessonMatchesTask(lesson, taskText) {
matched = append(matched, lesson)
} else {
rest = append(rest, lesson)
}
type rankedLesson struct {
lesson model.AgentLesson
score int
}
ranked := make([]rankedLesson, 0, len(lessons))
for _, lesson := range lessons {
ranked = append(ranked, rankedLesson{lesson: lesson, score: cloudAgentLessonTaskScore(lesson, taskText)})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
if ranked[i].lesson.Hits != ranked[j].lesson.Hits {
return ranked[i].lesson.Hits > ranked[j].lesson.Hits
}
return ranked[i].lesson.UpdatedAt.After(ranked[j].lesson.UpdatedAt)
})
index := make([]model.AgentLesson, 0, len(ranked))
matchedN := 0
for _, item := range ranked {
if item.score > 0 {
matchedN++
}
index = append(index, item.lesson)
}
index := append(matched, rest...)
if limit > 0 && len(index) > limit {
index = index[:limit]
}
matchedN := len(matched)
if matchedN > len(index) {
matchedN = len(index)
}
@@ -476,20 +507,34 @@ func cloudAgentLessonsBlock(view cloudAgentLessonView) string {
}
func cloudAgentLessonMatchesTask(lesson model.AgentLesson, taskText string) bool {
return cloudAgentLessonTaskScore(lesson, taskText) > 0
}
func cloudAgentLessonTaskScore(lesson model.AgentLesson, taskText string) int {
haystack := cloudAgentLessonNormalize(taskText)
if haystack == "" {
return false
return 0
}
score := 0
for _, needle := range cloudAgentLessonNeedles(lesson) {
if utf8.RuneCountInString(needle) >= 3 && strings.Contains(haystack, needle) {
return true
runes := []rune(needle)
isHanPhrase := len(runes) >= 2 && unicode.Is(unicode.Han, runes[0]) && unicode.Is(unicode.Han, runes[1])
if (!isHanPhrase && utf8.RuneCountInString(needle) < 3) || !strings.Contains(haystack, needle) {
continue
}
weight := 1
if strings.Contains(cloudAgentLessonNormalize(lesson.Topic), needle) {
weight = 4
} else if strings.Contains(cloudAgentLessonNormalize(lesson.Situation), needle) {
weight = 2
}
score += weight
}
return false
return score
}
func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
needles := make([]string, 0, 8)
needles := make([]string, 0, 24)
seen := map[string]bool{}
push := func(value string) {
value = cloudAgentLessonNormalize(value)
@@ -499,6 +544,14 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
seen[value] = true
needles = append(needles, value)
}
pushCJKBigrams := func(value string) {
runes := []rune(cloudAgentLessonNormalize(value))
for index := 0; index+1 < len(runes); index++ {
if unicode.Is(unicode.Han, runes[index]) && unicode.Is(unicode.Han, runes[index+1]) {
push(string(runes[index : index+2]))
}
}
}
var steps []model.AgentLessonStep
if json.Unmarshal([]byte(lesson.StepsJSON), &steps) == nil {
for _, step := range steps {
@@ -509,13 +562,16 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
return r == '.' || r == '-' || r == '_' || r == ' '
}) {
push(chunk)
pushCJKBigrams(chunk)
}
pushCJKBigrams(lesson.Situation)
return needles
}
func cloudAgentLessonGenericToken(token string) bool {
switch token {
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the":
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the",
"画布", "分镜", "镜头", "视频", "图片", "生成", "任务", "操作", "处理", "使用", "通过", "进行", "一个", "这个", "可以", "需要", "如果", "然后", "之后", "之前", "结果", "内容", "相关":
return true
}
return false