feat(memory): 添加记忆系统模块

This commit is contained in:
徐涛
2026-06-08 08:42:43 +08:00
parent 1fe7f02281
commit 2ecc0b4001
9 changed files with 1244 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
//! 对话记忆 —— 多轮对话消息管理,复用 `llm::compact` 的压缩逻辑。
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::llm::compact::{CompactConfig, CompactState, microcompact, should_compact};
use crate::llm::types::OpenaiChatMessage;
use crate::memory::error::MemoryError;
use crate::memory::store::MemoryStore;
use crate::memory::types::MemoryItem;
/// 对话消息管理策略。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MemoryStrategy {
/// 滑动窗口:达到上限时删除最旧消息。
SlidingWindow,
/// 保留所有消息(仅压缩,不删除)。
Full,
}
/// 对话记忆配置。
#[derive(Debug, Clone)]
pub struct ConversationMemoryConfig {
pub strategy: MemoryStrategy,
pub max_turns: usize,
pub compact_config: Option<CompactConfig>,
}
impl Default for ConversationMemoryConfig {
fn default() -> Self {
Self {
strategy: MemoryStrategy::SlidingWindow,
max_turns: 50,
compact_config: Some(CompactConfig::default()),
}
}
}
/// 对话记忆 —— 按 session 管理多轮对话消息历史。
///
/// 内部维护 `Vec<OpenaiChatMessage>` 热缓存(供 `llm::compact` 直接操作),
/// `MemoryStore` 用作冷持久化层。
pub struct ConversationMemory {
store: Arc<dyn MemoryStore>,
session_id: String,
config: ConversationMemoryConfig,
/// 热缓存:消息列表,供 `llm::compact` 直接操作。
messages: Vec<OpenaiChatMessage>,
/// 与 `messages` 一一对应的存储 ID(保持稳定以便淘汰时精准删除)。
message_ids: Vec<String>,
/// 压缩断路器状态。
compact_state: CompactState,
}
impl ConversationMemory {
/// 创建一个新的 ConversationMemory。
pub fn new(
store: Arc<dyn MemoryStore>,
session_id: impl Into<String>,
config: ConversationMemoryConfig,
) -> Self {
Self {
store,
session_id: session_id.into(),
config,
messages: Vec::new(),
message_ids: Vec::new(),
compact_state: CompactState::new(),
}
}
/// 获取 session id。
pub fn session_id(&self) -> &str {
&self.session_id
}
/// 获取配置。
pub fn config(&self) -> &ConversationMemoryConfig {
&self.config
}
/// 从 MemoryStore 加载历史消息到热缓存。
pub async fn load(&mut self) -> Result<(), MemoryError> {
let filter = crate::memory::types::MemoryFilter {
prefix: Some(self.session_prefix()),
..Default::default()
};
let items = self.store.list(&filter).await?;
let mut pairs: Vec<(String, OpenaiChatMessage, OffsetDateTime)> = Vec::with_capacity(items.len());
for item in items {
match serde_json::from_str::<OpenaiChatMessage>(&item.content) {
Ok(msg) => pairs.push((item.id, msg, item.created_at)),
Err(e) => {
return Err(MemoryError::Serialization(format!(
"load message {} failed: {e}",
item.id
)));
}
}
}
// 按 created_at 升序排列
pairs.sort_by_key(|p| p.2);
self.message_ids = pairs.iter().map(|p| p.0.clone()).collect();
self.messages = pairs.into_iter().map(|p| p.1).collect();
Ok(())
}
/// 添加一条消息。
///
/// 写入热缓存并通过 `MemoryStore` 持久化。如有需要,触发淘汰和压缩。
pub async fn add_message(&mut self, msg: OpenaiChatMessage) -> Result<(), MemoryError> {
let now = OffsetDateTime::now_utc();
let index = self.messages.len();
let id = self.make_message_id(index, &now);
// 写入热缓存
self.messages.push(msg);
self.message_ids.push(id.clone());
// 同步到冷存储
let item = MemoryItem {
id: id.clone(),
content: serde_json::to_string(self.messages.last().unwrap())
.map_err(|e| MemoryError::Serialization(e.to_string()))?,
metadata: serde_json::json!({ "session_id": &self.session_id, "index": index }),
created_at: now,
};
self.store.save(item).await?;
// 触发淘汰和压缩
self.maybe_evict_and_compact().await;
Ok(())
}
/// 获取完整消息历史。
pub fn get_history(&self) -> &[OpenaiChatMessage] {
&self.messages
}
/// 清空所有消息。
pub async fn clear(&mut self) -> Result<(), MemoryError> {
let to_delete = std::mem::take(&mut self.message_ids);
self.messages.clear();
self.compact_state = CompactState::new();
for id in to_delete {
self.store.delete(&id).await?;
}
Ok(())
}
/// 当前消息数量。
pub fn len(&self) -> usize {
self.messages.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.messages.is_empty()
}
fn session_prefix(&self) -> String {
format!("conv:{self}:", self = self.session_id)
}
fn make_message_id(&self, index: usize, now: &OffsetDateTime) -> String {
format!("{}{:010}_{}", self.session_prefix(), index, now.unix_timestamp_nanos())
}
async fn maybe_evict_and_compact(&mut self) {
// 1. Sliding window 淘汰:删除最旧消息
if self.config.strategy == MemoryStrategy::SlidingWindow {
while self.messages.len() > self.config.max_turns {
if let Some(removed_id) = self.message_ids.first().cloned() {
let _ = self.store.delete(&removed_id).await;
}
self.messages.remove(0);
self.message_ids.remove(0);
}
}
// 2. 压缩(复用 llm::compact
if let Some(ref compact_config) = self.config.compact_config {
if should_compact(&self.messages, compact_config, &self.compact_state) {
let keep_recent = compact_config.keep_recent;
let freed = microcompact(&mut self.messages, keep_recent);
if freed > 0 {
self.compact_state.record_success();
} else {
// 没有 token 被释放(可能没找到可压缩的 tool result
let _ = self.compact_state.record_failure();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::OpenaiChatMessage;
use crate::memory::InMemoryStore;
use crate::memory::MemoryStore;
fn user_text(s: &str) -> OpenaiChatMessage {
OpenaiChatMessage::user_text(s)
}
#[tokio::test]
async fn add_and_get_history() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let mut conv = ConversationMemory::new(store, "session1", ConversationMemoryConfig::default());
conv.add_message(user_text("hello")).await.unwrap();
conv.add_message(user_text("world")).await.unwrap();
assert_eq!(conv.len(), 2);
assert_eq!(conv.get_history().len(), 2);
}
#[tokio::test]
async fn sliding_window_evicts_oldest() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let config = ConversationMemoryConfig {
strategy: MemoryStrategy::SlidingWindow,
max_turns: 3,
compact_config: None,
};
let mut conv = ConversationMemory::new(store, "s1", config);
for i in 0..5 {
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
}
assert_eq!(conv.len(), 3);
}
#[tokio::test]
async fn full_strategy_no_evict() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let config = ConversationMemoryConfig {
strategy: MemoryStrategy::Full,
max_turns: 3,
compact_config: None,
};
let mut conv = ConversationMemory::new(store, "s1", config);
for i in 0..5 {
conv.add_message(user_text(&format!("msg-{i}"))).await.unwrap();
}
// Full 策略不删除消息
assert_eq!(conv.len(), 5);
}
#[tokio::test]
async fn clear_empties_messages() {
let store = Arc::new(InMemoryStore::new()) as Arc<dyn MemoryStore>;
let mut conv = ConversationMemory::new(store.clone(), "s1", ConversationMemoryConfig::default());
conv.add_message(user_text("hello")).await.unwrap();
assert!(!conv.is_empty());
conv.clear().await.unwrap();
assert!(conv.is_empty());
}
}