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

View File

@@ -736,3 +736,110 @@ func TestAdminAgentLessonsFiltersByUser(t *testing.T) {
t.Fatalf("不筛选时应看到全部:%d(%v)", len(all), err)
}
}
func TestRememberLessonGateSurvivesEventWindowAndCheckpoint(t *testing.T) {
state := &cloudAgentRuntime{LessonEligibleToolSuccesses: 1}
for i := 0; i < repository.CloudAgentJournalWindow; i++ {
state.Events = append(state.Events, CloudAgentEvent{Type: "message_delta"})
}
if got := cloudAgentLessonEligibleSuccesses(state); got != 1 {
t.Fatalf("delta 窗口不得抹掉已记录的合格成功数:got %d", got)
}
state.Events = nil
run := &model.CloudAgentExecution{}
if err := cloudAgentSave(run, state); err != nil {
t.Fatal(err)
}
restored, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
if restored.LessonEligibleToolSuccesses != 1 || cloudAgentLessonEligibleSuccesses(&restored) != 1 {
t.Fatalf("checkpoint 恢复后写入门禁必须保持:%+v", restored)
}
}
func TestPendingAgentLessonsExpireWithoutTouchingApproved(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
entries := []*model.AgentLesson{
{ID: newID(), Topic: "old-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
{ID: newID(), Topic: "fresh-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now},
{ID: newID(), Topic: "old-approved", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
}
for _, entry := range entries {
if err := repo.Create(entry); err != nil {
t.Fatal(err)
}
}
changed, err := repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500)
if err != nil || changed != 1 {
t.Fatalf("应只过期一条旧 pending:changed=%d err=%v", changed, err)
}
for _, want := range []struct{ id, status string }{
{entries[0].ID, model.AgentLessonStatusRejected},
{entries[1].ID, model.AgentLessonStatusPending},
{entries[2].ID, model.AgentLessonStatusApproved},
} {
got, findErr := repo.AgentLessonForUser("user-a", want.id)
if findErr != nil || got.Status != want.status {
t.Fatalf("记忆 %s 状态不符:got=%+v err=%v", want.id, got, findErr)
}
}
}
func TestRememberLessonExpiresOldPendingBeforeApplyingLimit(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
created := now
if index == 0 {
created = old
}
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: created, UpdatedAt: created}
if err := repo.Create(entry); err != nil {
t.Fatal(err)
}
}
state := &cloudAgentRuntime{
Request: CloudAgentRequest{PermissionMode: "auto"},
LessonEligibleToolSuccesses: 1,
}
call := rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`)
if _, err := cloudAgentRememberLesson(repo, "user-a", state, call); err != nil {
t.Fatalf("过期一条后应可新建待审记忆:%v", err)
}
pending, err := repo.CountAgentLessonsByAuthor("user-a", model.AgentLessonStatusPending)
if err != nil || pending != cloudAgentRememberLessonPendingPerUser {
t.Fatalf("新写入后待审数应回到上限:%d (%v)", pending, err)
}
}
func TestRememberLessonPendingLimitErrorShowsCountAndSettings(t *testing.T) {
repo := agentLessonTestRepo(t)
now := time.Now()
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now}
if err := repo.Create(entry); err != nil {
t.Fatal(err)
}
}
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}, LessonEligibleToolSuccesses: 1}
_, err := cloudAgentRememberLesson(repo, "user-a", state, rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`))
if err == nil || !strings.Contains(err.Error(), "50") || !strings.Contains(err.Error(), "设置 → Agent 记忆") {
t.Fatalf("拒绝信息应包含待审数量和处理入口:%v", err)
}
}
func TestLessonIndexRanksTaskRelevanceBeforeHitsAndMatchesTwoHanRunes(t *testing.T) {
now := time.Now()
unrelated := model.AgentLesson{ID: "unrelated", Topic: "video.duration", Situation: "竖屏视频生成时长", Hits: 9000, UpdatedAt: now}
relevant := model.AgentLesson{ID: "relevant", Topic: "对象绑定", Situation: "预演对象绑定角色时", Hits: 0, UpdatedAt: now.Add(-time.Hour)}
index, matched := cloudAgentPickLessonIndex([]model.AgentLesson{unrelated, relevant}, "请处理对象绑定", 20)
if matched != 1 || len(index) != 2 || index[0].ID != "relevant" {
t.Fatalf("任务相关记忆应先于高频无关记忆,且两字短语应命中:matched=%d index=%+v", matched, index)
}
}

View File

@@ -217,12 +217,15 @@ func (s *Service) startAgentMemoryCompactScheduler() {
}
func (s *Service) dispatchDueAgentMemoryCompacts() {
now := time.Now()
if _, err := s.repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500); err != nil {
log.Printf("agent memory pending cleanup: %v", err)
}
rows, err := s.repo.ScheduledAgentMemorySettings(50)
if err != nil {
log.Printf("agent memory compact schedule: %v", err)
return
}
now := time.Now()
started := 0
for index := range rows {
setting := rows[index]

View File

@@ -169,9 +169,11 @@ type cloudAgentRuntime struct {
// PiAssistantResponses counts successful assistant message_end events from
// the Pi runtime. Completion must not be inferred from a clean Node exit:
// a provider/session error can otherwise be reported as a successful run.
PiAssistantResponses int `json:"piAssistantResponses,omitempty"`
IsGenerating bool `json:"isGenerating,omitempty"`
LastError string `json:"lastError,omitempty"`
PiAssistantResponses int `json:"piAssistantResponses,omitempty"`
LessonEligibleToolSuccesses int `json:"lessonEligibleToolSuccesses,omitempty"`
RememberLessonSuccesses int `json:"rememberLessonSuccesses,omitempty"`
IsGenerating bool `json:"isGenerating,omitempty"`
LastError string `json:"lastError,omitempty"`
// EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。
EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"`
// StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。

View File

@@ -142,6 +142,9 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 {
return errors.New("Agent runtime budget or step is invalid")
}
if state.LessonEligibleToolSuccesses < 0 || state.RememberLessonSuccesses < 0 {
return errors.New("Agent lesson success counters are invalid")
}
if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds {
return errors.New("Agent runtime confirmation round is invalid")
}

View File

@@ -16,6 +16,14 @@ import (
)
func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool {
if state != nil && err == nil {
if cloudAgentLessonToolEligible(call.Function.Name) {
state.LessonEligibleToolSuccesses++
}
if call.Function.Name == "remember_lesson" {
state.RememberLessonSuccesses++
}
}
payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments}
if call.Function.Name == "skill_read_file" {
var args struct {

View File

@@ -125,6 +125,28 @@ func (r *Repository) CountAgentLessonsByAuthor(userID, status string) (int64, er
return count, err
}
func (r *Repository) ExpirePendingAgentLessons(userID string, createdBefore, updatedAt time.Time, limit int) (int64, error) {
if limit <= 0 || limit > 1000 {
limit = 500
}
query := r.db.Model(&model.AgentLesson{}).Select("id").
Where("status = ? AND created_at <= ?", model.AgentLessonStatusPending, createdBefore)
if userID = strings.TrimSpace(userID); userID != "" {
query = query.Where("author_user_id = ?", userID)
}
var ids []string
if err := query.Order("created_at ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
update := r.db.Model(&model.AgentLesson{}).
Where("id IN ? AND status = ?", ids, model.AgentLessonStatusPending).
Updates(map[string]any{"status": model.AgentLessonStatusRejected, "updated_at": updatedAt})
return update.RowsAffected, update.Error
}
func (r *Repository) AdminAgentLessons(status, userID, keyword string, limit int) ([]model.AgentLesson, error) {
query := r.db.Model(&model.AgentLesson{})
if trimmed := strings.TrimSpace(status); trimmed != "" {