- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败); NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**; 新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512) - P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64), 组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变 - P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态); loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源 - 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5) - 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 + n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达; **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原 - 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
313 lines
9.8 KiB
Go
313 lines
9.8 KiB
Go
package memory
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// 工作记忆的窗口大小与摘要常量。
|
|
const (
|
|
// defaultWorkingWindow 是 NewWorkingMemory 以非正 size
|
|
// 调用时滑动窗口默认保留的消息数。
|
|
defaultWorkingWindow = 20
|
|
|
|
// summaryLastTextMax 是朴素摘要中所含末条消息文本
|
|
// 的最大 rune 数,用于保持自摘要紧凑。
|
|
summaryLastTextMax = 80
|
|
|
|
// maxWorkingSessions 是工作记忆同时保留的**会话**窗口数上限。
|
|
// 进程内只有一个 WorkingMemory(loop 的每会话滑动窗口),而会话数
|
|
// 随渠道消息无界增长,因此超出上限时淘汰最久未用的会话窗口,
|
|
// 使内存有界(design/03 §0 层①)。
|
|
maxWorkingSessions = 64
|
|
)
|
|
|
|
// window 是一条会话窗口的可变状态:消息滑动窗口 + 摘要。
|
|
//
|
|
// 它不持锁:所有方法都要求调用方已持有 WorkingMemory.mu
|
|
// (读锁或写锁),这正是窗口与摘要共用同一临界区的原因。
|
|
type window struct {
|
|
messages []model.Message
|
|
summary string
|
|
}
|
|
|
|
// add 把 msg 追加到窗口,并在窗口超出 max 时淘汰最旧的消息。
|
|
func (w *window) add(msg model.Message, max int) {
|
|
w.messages = append(w.messages, msg)
|
|
w.trim(max)
|
|
}
|
|
|
|
// replace 用 msgs 替换窗口内容,只保留最后 max 条(msgs 是该会话
|
|
// 当前对话的完整快照时,窗口即「最近 max 条」的滑窗)。传入的消息
|
|
// 逐条深拷贝,因此调用方后续修改自己的切片不会影响窗口。
|
|
func (w *window) replace(msgs []model.Message, max int) {
|
|
out := make([]model.Message, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
out = append(out, cloneMessage(m))
|
|
}
|
|
w.messages = out
|
|
w.trim(max)
|
|
}
|
|
|
|
// trim 只保留窗口最后 max 条消息。
|
|
func (w *window) trim(max int) {
|
|
if max <= 0 || len(w.messages) <= max {
|
|
return
|
|
}
|
|
overflow := len(w.messages) - max
|
|
copy(w.messages, w.messages[overflow:])
|
|
w.messages = w.messages[:max]
|
|
}
|
|
|
|
// snapshot 返回窗口消息的深拷贝,最旧的在前。返回的切片与每条
|
|
// 消息的 content 部分都与内部状态脱离,因此调用方可以随意修改。
|
|
func (w *window) snapshot() []model.Message {
|
|
out := make([]model.Message, len(w.messages))
|
|
for i, m := range w.messages {
|
|
out[i] = cloneMessage(m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// summarize 在已设置摘要时返回存储的摘要,否则返回由当前
|
|
// 窗口推导的朴素结构化摘要。它从不修改状态。
|
|
func (w *window) summarize() string {
|
|
if w.summary != "" {
|
|
return w.summary
|
|
}
|
|
return naiveSummary(w.messages)
|
|
}
|
|
|
|
// sessionWindow 是一条会话的窗口及其最近使用序号,序号供
|
|
// 超出上限时淘汰最久未用的会话(见 maxWorkingSessions)。
|
|
type sessionWindow struct {
|
|
win window
|
|
lastUsed uint64
|
|
}
|
|
|
|
// WorkingMemory 是每会话的短期记忆:当前对话消息的
|
|
// 滑动窗口加一份摘要(design §0 layer ①)。它是
|
|
// 代理推理所依据的即时上下文。
|
|
//
|
|
// 窗口有界:超出上限的添加会淘汰最旧的消息。
|
|
// Summarize 返回经 SetSummary 设置的摘要,或按需计算的
|
|
// 朴素结构化摘要。LLM 生成的摘要推迟到 M4;管线
|
|
// 已就位,摘要器可调用 SetSummary。
|
|
//
|
|
// 两种使用方式并存:
|
|
//
|
|
// - 默认窗口(Add/Window/SetSummary/Summarize):无会话键的
|
|
// 单窗口路径,保持该类型最初的语义。
|
|
// - 会话窗口(RecordSession/SessionWindow/SummarizeSession/
|
|
// SetSessionSummary):**按会话 id 隔离**的窗口,循环在回合
|
|
// 落定时写入、在组装上下文时读取。进程内只有一个
|
|
// WorkingMemory 实例(loop.Options.WorkingMemory),因此没有
|
|
// 会话键隔离时,群聊/陌生人的窗口会混进主人的工作记忆 ——
|
|
// 这是隔离红线(design/03 §5),会话路径即为此而设。
|
|
//
|
|
// WorkingMemory 并发安全;写路径以写锁串行,
|
|
// 读路径在读锁下读取。
|
|
type WorkingMemory struct {
|
|
mu sync.RWMutex
|
|
def window
|
|
sessions map[string]*sessionWindow
|
|
max int
|
|
seq uint64
|
|
}
|
|
|
|
// NewWorkingMemory 构建一个工作记忆,滑动窗口最多
|
|
// maxMessages 条消息。非正 maxMessages 选取 defaultWorkingWindow。
|
|
func NewWorkingMemory(maxMessages int) *WorkingMemory {
|
|
if maxMessages <= 0 {
|
|
maxMessages = defaultWorkingWindow
|
|
}
|
|
return &WorkingMemory{max: maxMessages}
|
|
}
|
|
|
|
// Add 把 msg 追加到**默认**窗口,并在窗口超出上限时
|
|
// 淘汰最旧的消息。它写入的是无会话键的单窗口;按会话隔离的
|
|
// 写入走 RecordSession。
|
|
func (w *WorkingMemory) Add(msg model.Message) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.def.add(msg, w.max)
|
|
}
|
|
|
|
// Window 返回默认窗口当前消息的深拷贝,最旧的在前。
|
|
// 返回的切片与每条消息的 content 部分都与内部
|
|
// 状态脱离,因此调用方可以随意修改。
|
|
func (w *WorkingMemory) Window() []model.Message {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
|
|
return w.def.snapshot()
|
|
}
|
|
|
|
// SetSummary 记录一条显式摘要(例如 M4 中由 LLM 产出)到默认窗口。
|
|
func (w *WorkingMemory) SetSummary(s string) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.def.summary = s
|
|
}
|
|
|
|
// Summarize 在已设置摘要时返回存储的摘要,否则返回由默认窗口
|
|
// 推导的朴素结构化摘要。它从不修改状态,因此
|
|
// 重复调用返回与调用时窗口一致的值。
|
|
func (w *WorkingMemory) Summarize() string {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
|
|
return w.def.summarize()
|
|
}
|
|
|
|
// RecordSession 把会话 sessionID 的窗口置为 msgs 的最后
|
|
// maxMessages 条(msgs 是该会话当前对话的完整快照,因此结果
|
|
// 即「最近 N 条」的滑窗)。空会话 id 是空操作 —— 无键的写入无从
|
|
// 隔离,绝不落到默认窗口,以免污染无会话键的读点。
|
|
//
|
|
// 循环在回合落定时以该 turn 的完整对话调用它:会话恢复时,checkpoint
|
|
// 还原出的历史随同一个 turn 一起进来,窗口因此被回填,而不是
|
|
// 从空白重启(design §1 durable execution)。
|
|
func (w *WorkingMemory) RecordSession(sessionID string, msgs []model.Message) {
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
sw := w.sessionLocked(sessionID)
|
|
sw.win.replace(msgs, w.max)
|
|
}
|
|
|
|
// SessionWindow 返回会话 sessionID 窗口的深拷贝(最旧的在前)。
|
|
// 该会话没有窗口时返回空切片。
|
|
func (w *WorkingMemory) SessionWindow(sessionID string) []model.Message {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
|
|
sw, ok := w.sessions[sessionID]
|
|
if !ok {
|
|
return []model.Message{}
|
|
}
|
|
return sw.win.snapshot()
|
|
}
|
|
|
|
// SetSessionSummary 为会话 sessionID 记录一条显式摘要(LLM 摘要器
|
|
// 的落点,design §3 §6)。空会话 id 是空操作。
|
|
func (w *WorkingMemory) SetSessionSummary(sessionID, s string) {
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
w.sessionLocked(sessionID).win.summary = s
|
|
}
|
|
|
|
// SummarizeSession 返回会话 sessionID 的摘要:已由 SetSessionSummary
|
|
// 显式设置时返回它,否则返回该会话窗口的朴素结构化摘要。
|
|
// 该会话没有窗口(或窗口为空)时返回 "",因此调用方可以据此
|
|
// 跳过前导块。它从不修改状态。
|
|
func (w *WorkingMemory) SummarizeSession(sessionID string) string {
|
|
w.mu.RLock()
|
|
defer w.mu.RUnlock()
|
|
|
|
sw, ok := w.sessions[sessionID]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return sw.win.summarize()
|
|
}
|
|
|
|
// sessionLocked 返回会话 sessionID 的窗口(必要时新建),并把它
|
|
// 标为最近使用;新建会使保留的会话数超出上限时,先淘汰最久
|
|
// 未用的那个,使会话窗口数有界。它要求持有 w.mu 的**写锁**。
|
|
func (w *WorkingMemory) sessionLocked(sessionID string) *sessionWindow {
|
|
if w.sessions == nil {
|
|
w.sessions = make(map[string]*sessionWindow)
|
|
}
|
|
w.seq++
|
|
sw, ok := w.sessions[sessionID]
|
|
if !ok {
|
|
if len(w.sessions) >= maxWorkingSessions {
|
|
w.evictOldestLocked()
|
|
}
|
|
sw = &sessionWindow{}
|
|
w.sessions[sessionID] = sw
|
|
}
|
|
sw.lastUsed = w.seq
|
|
return sw
|
|
}
|
|
|
|
// evictOldestLocked 删除最久未使用的会话窗口。它要求持有 w.mu 的
|
|
// 写锁;空 map 时是空操作。
|
|
func (w *WorkingMemory) evictOldestLocked() {
|
|
oldestKey := ""
|
|
var oldest uint64
|
|
for key, sw := range w.sessions {
|
|
if oldestKey == "" || sw.lastUsed < oldest {
|
|
oldestKey, oldest = key, sw.lastUsed
|
|
}
|
|
}
|
|
if oldestKey != "" {
|
|
delete(w.sessions, oldestKey)
|
|
}
|
|
}
|
|
|
|
// cloneMessage 返回 m 的副本,其 content 部分与源脱离。
|
|
// model.Content 的每个字段都是值类型,因此复制 parts
|
|
// 切片就足够了。
|
|
func cloneMessage(m model.Message) model.Message {
|
|
parts := make([]model.Content, len(m.Content))
|
|
copy(parts, m.Content)
|
|
m.Content = parts
|
|
return m
|
|
}
|
|
|
|
// naiveSummary 构造 "{n} 条消息, {role} {count}, ...; 最近: {text}"。
|
|
// 它是纯函数:不读取也不修改任何窗口状态。
|
|
func naiveSummary(messages []model.Message) string {
|
|
if len(messages) == 0 {
|
|
return ""
|
|
}
|
|
counts := make(map[model.Role]int)
|
|
last := ""
|
|
for _, m := range messages {
|
|
counts[m.Role]++
|
|
last = messageText(m)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.WriteString(strconv.Itoa(len(messages)))
|
|
b.WriteString(" 条消息")
|
|
for _, role := range []model.Role{model.RoleSystem, model.RoleUser, model.RoleAssistant, model.RoleTool} {
|
|
if c := counts[role]; c > 0 {
|
|
b.WriteString(", ")
|
|
b.WriteString(string(role))
|
|
b.WriteByte(' ')
|
|
b.WriteString(strconv.Itoa(c))
|
|
}
|
|
}
|
|
if last != "" {
|
|
b.WriteString("; 最近: ")
|
|
b.WriteString(truncateRunes(last, summaryLastTextMax))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// truncateRunes 把 s 截到最多 n 个 rune。Unicode 安全:它绝不把
|
|
// 多字节 rune 切一半。
|
|
func truncateRunes(s string, n int) string {
|
|
if utf8.RuneCountInString(s) <= n {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
return string(runes[:n])
|
|
}
|