feat(memory): 添加记忆系统模块
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user