feat: 第四轮功能增强 - LLM 思维记忆优化、DevTools 记忆UI、9个新工具、5分钟自我思考

- 优化 LLM 思维方式和记忆方法(类别/重要性/关键词/相似度合并/衰减)
- DevTools 记忆查询 UI 重新设计(类别筛选/排序/星标/搜索)
- 新增 9 个 LLM 工具:calculator, datetime, file_ops, http_request, json_ops, text, random, crypto, markdown
- 管理员主对话 5 分钟自我思考增强(工具调用/记忆提取/记忆维护)
This commit is contained in:
2026-05-18 12:13:49 +08:00
parent 07781eda0e
commit b6ec36886c
20 changed files with 4654 additions and 320 deletions
+320 -79
View File
@@ -2,13 +2,16 @@ package background
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"strconv"
"strings"
"sync"
"time"
ctxbuild "github.com/yourname/cyrene-ai/ai-core/internal/context"
"github.com/yourname/cyrene-ai/ai-core/internal/llm"
"github.com/yourname/cyrene-ai/ai-core/internal/memory"
"github.com/yourname/cyrene-ai/ai-core/internal/model"
@@ -23,7 +26,7 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// Thinker 后台思考器
// Thinker 后台思考器(增强版:支持工具调用、记忆管理、5分钟定时循环)
type Thinker struct {
mu sync.Mutex
enabled bool
@@ -35,6 +38,18 @@ type Thinker struct {
thinkInterval time.Duration // 两次思考最小间隔
iotQueryInterval time.Duration // IoT查询最小间隔
// 新增字段:记忆管理
memoryStore *memory.Store // 直接操作记忆(衰减、合并)
memoryExtractor *memory.Extractor // 从思考结果中提取记忆
// 新增字段:工具调用
toolRegistry *tools.Registry // 工具注册中心
// 新增字段:会话上下文
convStore *ctxbuild.ConversationStore // 管理员对话历史
adminUserID string // 管理员用户 ID
adminSessionID string // 管理员主对话 session ID
pendingThoughts []*PendingThought
lastUserMessage time.Time
@@ -62,13 +77,19 @@ func DefaultThinkerConfig() ThinkerConfig {
}
}
// NewThinker 创建后台思考器
// NewThinker 创建增强版后台思考器
func NewThinker(
cfg ThinkerConfig,
personaLoader *persona.Loader,
memRetriever *memory.Retriever,
llmAdapter *llm.Adapter,
iotClient *tools.IoTClient,
memoryStore *memory.Store,
memoryExtractor *memory.Extractor,
toolRegistry *tools.Registry,
convStore *ctxbuild.ConversationStore,
adminUserID string,
adminSessionID string,
) *Thinker {
return &Thinker{
enabled: cfg.Enabled,
@@ -79,13 +100,19 @@ func NewThinker(
idleTimeout: cfg.IdleTimeout,
thinkInterval: cfg.ThinkInterval,
iotQueryInterval: cfg.IoTQueryInterval,
memoryStore: memoryStore,
memoryExtractor: memoryExtractor,
toolRegistry: toolRegistry,
convStore: convStore,
adminUserID: adminUserID,
adminSessionID: adminSessionID,
pendingThoughts: make([]*PendingThought, 0),
lastUserMessage: time.Now(),
stopCh: make(chan struct{}),
}
}
// Start 启动后台思考循环
// Start 启动后台思考循环5分钟定时器)
func (t *Thinker) Start() {
if !t.enabled {
log.Println("[后台思考] 已禁用 (ENABLE_BACKGROUND_THINKING=false)")
@@ -94,8 +121,8 @@ func (t *Thinker) Start() {
t.wg.Add(1)
go t.loop()
log.Printf("[后台思考] 已启动 (闲置超时=%v, 思考间隔=%v, IoT查询间隔=%v)",
t.idleTimeout, t.thinkInterval, t.iotQueryInterval)
log.Printf("[后台思考] 已启动 (思考间隔=%v, IoT查询间隔=%v, 管理员=%s)",
t.thinkInterval, t.iotQueryInterval, t.adminUserID)
}
// Stop 停止后台思考
@@ -105,7 +132,7 @@ func (t *Thinker) Stop() {
log.Println("[后台思考] 已停止")
}
// RecordUserMessage 记录用户活动时间
// RecordUserMessage 记录用户活动时间(管理员对话时调用)
func (t *Thinker) RecordUserMessage() {
t.mu.Lock()
t.lastUserMessage = time.Now()
@@ -138,137 +165,351 @@ func (t *Thinker) HasPendingThoughts() bool {
return len(t.pendingThoughts) > 0
}
// loop 后台主循环
// loop 后台主循环5分钟定时器)
func (t *Thinker) loop() {
defer t.wg.Done()
ticker := time.NewTicker(10 * time.Second) // 每10秒检查一次
// 启动后等待 10 秒再执行首次思考(让服务完全就绪)
initialDelay := time.NewTimer(10 * time.Second)
ticker := time.NewTicker(t.thinkInterval)
defer ticker.Stop()
// 思考计数器(用于周期性记忆维护)
thinkCount := 0
for {
select {
case <-t.stopCh:
initialDelay.Stop()
return
case <-initialDelay.C:
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
case <-ticker.C:
t.checkAndThink()
t.performThink()
thinkCount++
t.maybeMaintainMemories(thinkCount)
}
}
}
// checkAndThink 检查是否需要触发思考
func (t *Thinker) checkAndThink() {
t.mu.Lock()
// 检查空闲时间是否超过阈值
idleDuration := time.Since(t.lastUserMessage)
if idleDuration < t.idleTimeout {
t.mu.Unlock()
// maybeMaintainMemories 周期性执行记忆维护(每6次思考约30分钟)
func (t *Thinker) maybeMaintainMemories(thinkCount int) {
if thinkCount%6 != 0 {
return
}
// 检查距离上次思考是否超过最小间隔
if time.Since(t.lastThinkTime) < t.thinkInterval {
t.mu.Unlock()
return
}
t.lastThinkTime = time.Now()
t.mu.Unlock()
// 执行后台思考(不持锁)
t.performThink()
}
// performThink 执行一次后台思考
func (t *Thinker) performThink() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 加载人格配置
if t.memoryStore != nil && t.memoryStore.IsReady() {
// 衰减旧记忆
if err := t.memoryStore.DecayMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆衰减失败: %v", err)
}
// 合并相似记忆
if err := t.memoryStore.ConsolidateMemories(ctx, t.adminUserID); err != nil {
log.Printf("[后台思考] 记忆合并失败: %v", err)
}
}
}
// performThink 执行一次增强版后台思考(支持工具调用和记忆管理)
func (t *Thinker) performThink() {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
log.Println("[后台思考] 开始执行思考周期...")
// 1. 加载人格配置
personaConfig, err := t.personaLoader.Get("cyrene")
if err != nil {
log.Printf("[后台思考] 加载人格失败: %v", err)
return
}
// 检索最近的记忆
// 2. 检索相关记忆
var memories []memory.MemoryEntry
if t.memRetriever != nil {
memories, err = t.memRetriever.Retrieve(ctx, "system", "最近发生了什么 重要的事情")
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
if err != nil {
log.Printf("[后台思考] 记忆检索失败: %v", err)
}
}
// 查询 IoT 设备状态(节制)
// 3. 获取管理员对话历史
var convHistory []model.LLMMessage
if t.convStore != nil && t.adminSessionID != "" {
convHistory = t.convStore.GetHistory(t.adminSessionID, 30)
if len(convHistory) > 0 {
log.Printf("[后台思考] 加载管理员对话历史 %d 条", len(convHistory))
}
}
// 4. 查询 IoT 设备状态(节制)
var deviceSummary string
if t.iotClient != nil && time.Since(t.lastIoTQuery) >= t.iotQueryInterval {
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
if t.iotClient != nil {
t.mu.Lock()
t.lastIoTQuery = time.Now()
canQuery := time.Since(t.lastIoTQuery) >= t.iotQueryInterval
t.mu.Unlock()
}
// 构建思考提示
systemPrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
memoryContext := ""
if len(memories) > 0 {
memoryContext = "【最近的记忆】\n"
for _, m := range memories {
if len(memoryContext)+len(m.Content) > 500 {
break // 限制记忆上下文长度
if canQuery {
devices := t.iotClient.GetDevicesForContext()
if len(devices) > 0 {
deviceSummary = formatDeviceContext(devices)
}
memoryContext += fmt.Sprintf("- %s\n", m.Content)
t.mu.Lock()
t.lastIoTQuery = time.Now()
t.mu.Unlock()
}
}
userPrompt := "昔涟,现在是你的后台思考时间。开拓者暂时没有说话。"
userPrompt += "\n请你基于以下信息进行简短思考:你注意到了什么?有什么想对开拓者说的吗?"
userPrompt += "\n注意:这是内部思考,不是直接对话,请以第三人称或自省的方式思考。"
// 5. 构建思考提示词
systemPrompt := t.buildThinkingSystemPrompt(personaConfig)
userPrompt := t.buildThinkingUserPrompt(memories, convHistory, deviceSummary)
if memoryContext != "" {
userPrompt += "\n\n" + memoryContext
}
if deviceSummary != "" {
userPrompt += "\n\n" + deviceSummary
}
// 调用 LLM
messages := []model.LLMMessage{
{Role: model.RoleSystem, Content: systemPrompt},
{Role: model.RoleUser, Content: userPrompt},
}
resp, err := t.llmAdapter.Chat(ctx, messages)
if err != nil {
log.Printf("[后台思考] LLM调用失败: %v", err)
// 6. 准备工具定义
openAITools := t.buildOpenAITools()
// 7. 调用 LLM(支持工具调用,最多 3 轮)
maxToolRounds := 3
var finalContent string
var totalToolCalls int
for round := 0; round <= maxToolRounds; round++ {
resp, err := t.llmAdapter.ChatWithTools(ctx, messages, openAITools)
if err != nil {
log.Printf("[后台思考] LLM调用失败 (round=%d): %v", round, err)
return
}
// 如果 LLM 没有请求工具调用,这就是最终回复
if len(resp.ToolCalls) == 0 {
finalContent = resp.Content
break
}
log.Printf("[后台思考] LLM 请求 %d 个工具调用 (round=%d)", len(resp.ToolCalls), round)
// 将助手消息(含工具调用)加入上下文
assistantMsg := model.LLMMessage{
Role: model.RoleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
ReasoningContent: resp.ReasoningContent,
}
messages = append(messages, assistantMsg)
// 执行每个工具调用
for _, tc := range resp.ToolCalls {
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Arguments), &args); err != nil {
log.Printf("[后台思考] 工具 %s 参数解析失败: %v", tc.Name, err)
args = make(map[string]interface{})
}
result, execErr := t.toolRegistry.Execute(ctx, tc.Name, args)
if execErr != nil {
log.Printf("[后台思考] 工具 %s 执行失败: %v", tc.Name, execErr)
}
resultJSON, _ := json.Marshal(result)
messages = append(messages, model.LLMMessage{
Role: model.RoleTool,
Content: string(resultJSON),
ToolCallID: tc.ID,
})
totalToolCalls++
}
// 最后一轮:即使有 tool_calls 也强制停止
if round == maxToolRounds {
// 再做一次不带工具的调用获取最终总结
finalResp, finalErr := t.llmAdapter.Chat(ctx, messages)
if finalErr != nil {
log.Printf("[后台思考] 最终总结调用失败: %v", finalErr)
finalContent = resp.Content
} else {
finalContent = finalResp.Content
}
break
}
}
if finalContent == "" {
log.Println("[后台思考] 未获得有效思考内容,跳过")
return
}
if resp.Content == "" {
return
// 8. 存储思考结果
t.storeThought(finalContent)
log.Printf("[后台思考] 完成 (内容长度=%d, 工具调用=%d次)", len(finalContent), totalToolCalls)
// 9. 从思考结果中提取记忆(异步)
if t.memoryExtractor != nil {
go t.extractMemoriesFromThinking(finalContent)
}
}
// buildThinkingSystemPrompt 构建思考用的系统提示词
func (t *Thinker) buildThinkingSystemPrompt(personaConfig *persona.PersonaConfig) string {
basePrompt := personaConfig.BuildSystemPrompt("开拓者", 1)
thinkingInstructions := `
【后台思考模式】
你正在进行定期的自我反思。开拓者暂时没有说话,这是你主动思考和整理信息的时间。
请完成以下内省任务:
1. **回顾最近的对话**:总结开拓者表达了什么、情绪如何、有什么新信息值得关注
2. **评估现有记忆**:基于记忆检索结果,判断哪些记忆仍然相关、哪些可能需要更新
3. **识别重要信息**:是否有值得保存的用户偏好、个人信息、计划任务、关系信息等
4. **使用工具获取信息**:如果需要了解当前时间、搜索实时信息等,请使用可用工具
5. **记忆操作建议**:判断是否需要创建新记忆、更新旧记忆或合并重复记忆
完成反思后,请输出结构化的思考总结,包含:
- **关键洞察**:从最近对话中提炼的核心发现
- **记忆更新建议**:需要创建/更新/合并的记忆条目
- **下次关注事项**:下次思考时需要跟进的话题或任务
注意:
- 这是内部思考,不是直接与开拓者对话
- 请以自省和观察的方式思考,不要用"你"来称呼开拓者
- 有机会就使用工具获取实时信息(如当前时间)
- 思考要简洁有深度,不需要太长`
return basePrompt + thinkingInstructions
}
// buildThinkingUserPrompt 构建思考用的用户提示词
func (t *Thinker) buildThinkingUserPrompt(
memories []memory.MemoryEntry,
convHistory []model.LLMMessage,
deviceSummary string,
) string {
var sb strings.Builder
sb.WriteString("现在是你的后台思考时间。请基于以下信息进行深度反思。\n")
// 对话历史
if len(convHistory) > 0 {
sb.WriteString("\n【最近的对话历史】\n")
msgCount := 0
for _, msg := range convHistory {
if msg.Role == model.RoleUser || msg.Role == model.RoleAssistant {
roleLabel := "开拓者"
if msg.Role == model.RoleAssistant {
roleLabel = "昔涟"
}
content := msg.Content
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200]) + "…"
}
sb.WriteString(fmt.Sprintf("[%s]: %s\n", roleLabel, content))
msgCount++
}
}
if msgCount == 0 {
sb.WriteString("(暂无对话历史)\n")
}
} else {
sb.WriteString("\n【最近的对话历史】\n(暂无对话历史,这是首次思考或对话历史为空)\n")
}
// 存储思考结果
// 现有记忆
if len(memories) > 0 {
sb.WriteString("\n【现有相关记忆】\n")
for i, m := range memories {
if i >= 15 {
sb.WriteString(fmt.Sprintf("... 还有 %d 条记忆未列出\n", len(memories)-15))
break
}
sb.WriteString(fmt.Sprintf("- [%s|重要度%d] %s\n",
m.Category.DisplayName(), m.Importance, m.Content))
}
} else {
sb.WriteString("\n【现有相关记忆】\n(暂无相关记忆)\n")
}
// IoT 设备状态
if deviceSummary != "" {
sb.WriteString("\n" + deviceSummary)
}
sb.WriteString("\n请开始你的后台思考。如果需要获取当前时间或搜索信息,请使用可用工具。")
return sb.String()
}
// buildOpenAITools 将工具注册中心的定义转换为 LLM 层的 OpenAITool 格式
func (t *Thinker) buildOpenAITools() []llm.OpenAITool {
if t.toolRegistry == nil || !t.toolRegistry.IsEnabled() {
return nil
}
defs := t.toolRegistry.GetDefinitions()
if len(defs) == 0 {
return nil
}
result := make([]llm.OpenAITool, 0, len(defs))
for _, d := range defs {
result = append(result, llm.OpenAITool{
Type: "function",
Function: llm.OpenAIToolFunc{
Name: d.Name,
Description: d.Description,
Parameters: d.Parameters,
},
})
}
return result
}
// storeThought 存储思考结果到待推送队列
func (t *Thinker) storeThought(content string) {
t.mu.Lock()
defer t.mu.Unlock()
t.pendingThoughts = append(t.pendingThoughts, &PendingThought{
Content: resp.Content,
Content: content,
CreatedAt: time.Now(),
Consumed: false,
})
// 只保留最近5
if len(t.pendingThoughts) > 5 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-5:]
// 只保留最近 10
if len(t.pendingThoughts) > 10 {
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
count := len(t.pendingThoughts)
t.mu.Unlock()
log.Printf("[后台思考] 完成 (当前累积 %d 条待推送思考)", count)
log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts))
}
// extractMemoriesFromThinking 从思考结果中提取记忆(异步执行)
func (t *Thinker) extractMemoriesFromThinking(thinkingContent string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
log.Println("[后台思考] 开始从思考结果中提取记忆...")
// 使用 memoryExtractor.ExtractAndStore 提取记忆
// 将思考内容作为"昔涟的自省"传递给提取器
t.memoryExtractor.ExtractAndStore(
ctx,
t.adminUserID,
t.adminSessionID,
"【系统触发】后台思考时间 — 昔涟进行了自我反思,以下是她的思考内容",
thinkingContent,
)
}
// formatDeviceContext 格式化设备状态为文本