use crate::base::{memory_variables_to_messages, BaseMemory};
use crate::buffer::ConversationBufferMemory;
use async_trait::async_trait;
use lc_core::language_models::{BaseChatModel, LLMResult};
use lc_core::runnables::{LcelError, Runnable, RunnableConfig};
use lc_schema::Message;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use tokio::sync::Mutex;
pub type SharedMemory = Arc<Mutex<Box<dyn BaseMemory>>>;
const DEFAULT_MAX_SESSIONS: usize = 100;
pub struct RunnableWithMessageHistory<L> {
llm: Arc<L>,
mode: HistoryMode,
}
enum HistoryMode {
Shared(SharedMemory),
Sessions {
factory: Arc<dyn Fn(&str) -> SharedMemory + Send + Sync>,
cache: Mutex<SessionCache>,
max_sessions: usize,
default: SharedMemory,
},
}
struct SessionCache {
slots: HashMap<String, SharedMemory>,
order: VecDeque<String>,
}
impl<L> RunnableWithMessageHistory<L> {
pub fn new(llm: L, memory: impl BaseMemory + 'static) -> Self {
Self {
llm: Arc::new(llm),
mode: HistoryMode::Shared(Arc::new(Mutex::new(Box::new(memory)))),
}
}
pub fn with_session_history<F>(llm: L, factory: F) -> Self
where
F: Fn(&str) -> SharedMemory + Send + Sync + 'static,
{
Self {
llm: Arc::new(llm),
mode: HistoryMode::Sessions {
factory: Arc::new(factory),
cache: Mutex::new(SessionCache {
slots: HashMap::new(),
order: VecDeque::new(),
}),
max_sessions: DEFAULT_MAX_SESSIONS,
default: Arc::new(Mutex::new(Box::new(
ConversationBufferMemory::new(),
))),
},
}
}
pub fn with_max_sessions(mut self, max: usize) -> Self {
if let HistoryMode::Sessions { max_sessions, .. } = &mut self.mode {
*max_sessions = max.max(1);
}
self
}
pub fn memory(&self) -> SharedMemory {
match &self.mode {
HistoryMode::Shared(m) => m.clone(),
HistoryMode::Sessions { default, .. } => default.clone(),
}
}
async fn select_memory(
&self,
config: &Option<RunnableConfig>,
) -> Result<SharedMemory, LcelError> {
match &self.mode {
HistoryMode::Shared(m) => Ok(m.clone()),
HistoryMode::Sessions {
factory,
cache,
max_sessions,
..
} => {
let session_id = config
.as_ref()
.and_then(|c| c.configurable_value("session_id"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
LcelError::Chain(
"RunnableWithMessageHistory(session mode) 缺少 configurable.session_id"
.to_string(),
)
})?;
let mut cache = cache.lock().await;
if let Some(memory) = cache.slots.get(session_id) {
return Ok(memory.clone());
}
if cache.slots.len() >= *max_sessions {
if let Some(oldest) = cache.order.pop_front() {
cache.slots.remove(&oldest);
}
}
let memory = factory(session_id);
cache
.slots
.insert(session_id.to_string(), memory.clone());
cache.order.push_back(session_id.to_string());
Ok(memory)
}
}
}
}
#[async_trait]
impl<L> Runnable<String, LLMResult> for RunnableWithMessageHistory<L>
where
L: BaseChatModel + 'static,
L::Error: Into<LcelError>,
{
type Error = LcelError;
async fn invoke(
&self,
input: String,
config: Option<RunnableConfig>,
) -> Result<LLMResult, LcelError> {
let memory = self.select_memory(&config).await?;
let mut memory = memory.lock().await;
let mut messages = {
let vars = memory
.load_memory_variables(&HashMap::new())
.await
.map_err(|e| LcelError::Chain(format!("load memory: {e}")))?;
memory_variables_to_messages(&vars)
};
messages.push(Message::human(&input));
let result = self
.llm
.chat(messages, config)
.await
.map_err(Into::into)?;
let inputs = HashMap::from([("input".to_string(), input)]);
let outputs = HashMap::from([("output".to_string(), result.content.clone())]);
if let Err(e) = memory.save_context(&inputs, &outputs).await {
log::warn!("记忆写回失败(模型答案仍照常返回): {e}");
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::Stream;
use lc_core::language_models::{BaseChatModel, BaseLanguageModel};
use lc_core::runnables::RunnableConfig;
use lc_schema::MessageType;
use serde_json::json;
use std::pin::Pin;
use std::sync::Mutex as StdMutex;
struct TestChatModel {
seen: Arc<StdMutex<Vec<Vec<Message>>>>,
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for TestChatModel {
type Error = LcelError;
async fn invoke(
&self,
input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, LcelError> {
self.seen.lock().unwrap().push(input.clone());
let last = input
.last()
.map(|m| m.content.clone())
.unwrap_or_default();
Ok(LLMResult {
content: format!("reply to: {last}"),
..Default::default()
})
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for TestChatModel {
fn model_name(&self) -> &str {
"test-llm"
}
fn get_num_tokens(&self, text: &str) -> usize {
text.len()
}
fn with_temperature(self, _temp: f32) -> Self
where
Self: Sized,
{
self
}
fn with_max_tokens(self, _max: usize) -> Self
where
Self: Sized,
{
self
}
}
#[async_trait]
impl BaseChatModel for TestChatModel {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, LcelError> {
self.invoke(messages, config).await
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<
Pin<Box<dyn Stream<Item = Result<String, LcelError>> + Send>>,
LcelError,
> {
unimplemented!("stream_chat not needed for tests")
}
}
fn session_factory(
_session_id: &str,
) -> SharedMemory {
Arc::new(Mutex::new(Box::new(
ConversationBufferMemory::new().with_return_messages(true),
) as Box<dyn BaseMemory>))
}
#[tokio::test]
async fn reads_memory_writes_back_round_trip() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen: seen.clone() };
let memory = ConversationBufferMemory::new().with_return_messages(true);
let pipe = RunnableWithMessageHistory::new(llm, memory);
let r1 = pipe.invoke("我叫什么名字".to_string(), None).await.unwrap();
assert_eq!(r1.content, "reply to: 我叫什么名字");
let r2 = pipe.invoke("再问一次".to_string(), None).await.unwrap();
assert_eq!(r2.content, "reply to: 再问一次");
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 2, "应调用模型两次");
assert_eq!(calls[0].len(), 1);
assert_eq!(calls[0][0].content, "我叫什么名字");
assert_eq!(calls[1].len(), 3);
assert_eq!(calls[1][0].content, "我叫什么名字");
assert!(matches!(calls[1][0].message_type, MessageType::Human));
assert!(matches!(calls[1][1].message_type, MessageType::AI));
assert_eq!(calls[1][2].content, "再问一次");
}
#[tokio::test]
async fn memory_accumulates_across_invocations() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen: seen.clone() };
let memory = ConversationBufferMemory::new().with_return_messages(true);
let pipe = RunnableWithMessageHistory::new(llm, memory);
for turn in ["你好", "你在吗", "再见"] {
pipe.invoke(turn.to_string(), None).await.unwrap();
}
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 3);
assert_eq!(calls[2].len(), 5); assert_eq!(calls[2][4].content, "再见");
}
#[tokio::test]
async fn session_history_same_session_shares_memory() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen: seen.clone() };
let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
let cfg = RunnableConfig::new().with_configurable("session_id", json!("s1"));
let r1 = pipe.invoke("我叫什么名字".to_string(), Some(cfg.clone())).await.unwrap();
assert_eq!(r1.content, "reply to: 我叫什么名字");
let r2 = pipe.invoke("再问一次".to_string(), Some(cfg)).await.unwrap();
assert_eq!(r2.content, "reply to: 再问一次");
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[1].len(), 3);
assert_eq!(calls[1][0].content, "我叫什么名字");
assert!(matches!(calls[1][1].message_type, MessageType::AI));
}
#[tokio::test]
async fn session_history_different_sessions_isolated() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen: seen.clone() };
let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
pipe.invoke("我是 s1".to_string(), Some(cfg_s1.clone())).await.unwrap();
pipe.invoke("还在 s1".to_string(), Some(cfg_s1)).await.unwrap();
pipe.invoke("我是 s2".to_string(), Some(cfg_s2)).await.unwrap();
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 3);
assert_eq!(calls[1].len(), 3, "s1 第二轮应看到历史");
assert_eq!(calls[2].len(), 1, "s2 第一轮应无历史");
}
#[tokio::test]
async fn session_history_missing_session_id_errors() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen };
let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory);
let err = pipe.invoke("你好".to_string(), None).await.unwrap_err();
assert!(matches!(err, LcelError::Chain(_)));
let cfg_no_sid = RunnableConfig::new();
let err = pipe
.invoke("你好".to_string(), Some(cfg_no_sid))
.await
.unwrap_err();
assert!(matches!(err, LcelError::Chain(_)));
}
struct BlockingChatModel {
seen: Arc<StdMutex<Vec<Vec<Message>>>>,
entered: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
blocked: Arc<StdMutex<bool>>,
}
#[async_trait]
impl Runnable<Vec<Message>, LLMResult> for BlockingChatModel {
type Error = LcelError;
async fn invoke(
&self,
input: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<LLMResult, LcelError> {
self.seen.lock().unwrap().push(input.clone());
let should_block = {
let mut blocked = self.blocked.lock().unwrap();
if !*blocked {
*blocked = true;
true
} else {
false
}
};
self.entered.notify_one();
if should_block {
self.release.notified().await;
}
let last = input
.last()
.map(|m| m.content.clone())
.unwrap_or_default();
Ok(LLMResult {
content: format!("reply to: {last}"),
..Default::default()
})
}
}
#[async_trait]
impl BaseLanguageModel<Vec<Message>, LLMResult> for BlockingChatModel {
fn model_name(&self) -> &str {
"blocking-test-llm"
}
fn get_num_tokens(&self, text: &str) -> usize {
text.len()
}
fn with_temperature(self, _temp: f32) -> Self
where
Self: Sized,
{
self
}
fn with_max_tokens(self, _max: usize) -> Self
where
Self: Sized,
{
self
}
}
#[async_trait]
impl BaseChatModel for BlockingChatModel {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, LcelError> {
self.invoke(messages, config).await
}
async fn stream_chat(
&self,
_messages: Vec<Message>,
_config: Option<RunnableConfig>,
) -> Result<
Pin<Box<dyn Stream<Item = Result<String, LcelError>> + Send>>,
LcelError,
> {
unimplemented!("stream_chat not needed for tests")
}
}
#[tokio::test]
async fn session_cache_evicts_oldest_when_over_capacity() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let llm = TestChatModel { seen: seen.clone() };
let pipe = RunnableWithMessageHistory::with_session_history(llm, session_factory)
.with_max_sessions(2);
let cfg_s1 = RunnableConfig::new().with_configurable("session_id", json!("s1"));
let cfg_s2 = RunnableConfig::new().with_configurable("session_id", json!("s2"));
let cfg_s3 = RunnableConfig::new().with_configurable("session_id", json!("s3"));
pipe.invoke("s1-turn1".to_string(), Some(cfg_s1.clone()))
.await
.unwrap();
pipe.invoke("s2-turn1".to_string(), Some(cfg_s2))
.await
.unwrap();
pipe.invoke("s3-turn1".to_string(), Some(cfg_s3))
.await
.unwrap();
pipe.invoke("s1-turn2".to_string(), Some(cfg_s1))
.await
.unwrap();
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 4);
assert_eq!(
calls[3].len(),
1,
"M2a: s1 槽被淘汰后重入应为全新会话(无历史)"
);
}
#[tokio::test]
async fn concurrent_same_session_invokes_do_not_lose_history() {
let seen = Arc::new(StdMutex::new(Vec::new()));
let entered = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let blocked = Arc::new(StdMutex::new(false));
let llm = BlockingChatModel {
seen: seen.clone(),
entered: entered.clone(),
release: release.clone(),
blocked,
};
let pipe = Arc::new(RunnableWithMessageHistory::new(
llm,
ConversationBufferMemory::new().with_return_messages(true),
));
let p1 = pipe.clone();
let h1 = tokio::spawn(async move { p1.invoke("第一轮".to_string(), None).await });
entered.notified().await;
let p2 = pipe.clone();
let h2 = tokio::spawn(async move { p2.invoke("第二轮".to_string(), None).await });
release.notify_one();
let r1 = h1.await.unwrap().unwrap();
let r2 = h2.await.unwrap().unwrap();
assert_eq!(r1.content, "reply to: 第一轮");
assert_eq!(r2.content, "reply to: 第二轮");
let calls = seen.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(
calls[1].len(),
3,
"M2b: 第二轮应看到第一轮完整对话(user+ai+user),而非空历史"
);
assert_eq!(calls[1][0].content, "第一轮");
assert_eq!(calls[1][2].content, "第二轮");
}
}