A Production MultiAgent Memory System Splits Short-Term Context from Four-Layer Long-Term Recall
1. Background and Overall Architecture Overview
In a MultiAgent platform, a single Agent request may simultaneously pass through the model, MCP/A2A tools, RAG, Workflow, and Sandbox, while also needing to remember user preferences, task progress, and collaboration agreements across multiple rounds of dialogue and cross-session collaboration. Therefore, the memory module is not an independent add-on, but part of the Agent execution chain.
In this project, memory is not simply stuffing all history into the Prompt. Instead, it separately handles session context, cross-session information, and contextual association:
Session Continuation: Short-term history cannot support anaphora resolution and task continuation.
Cross-Session Reuse: User preferences and stable facts cannot be reused across sessions.
Contextual Association: New messages cannot establish associations with existing context.
Choosing MemOS: In an evaluation with 1540 questions and 10 test users, the composite score was 74.33%, with both single-hop and multi-hop performance meeting the standard (the evaluation is only for selection reference and does not represent production SLA).
Overall Architecture: The backend is based on Spring Boot 3 and Java 17, with Agent orchestration using AgentScope. Newly created Agents default their longMemoryProvider to MEMOS, but openLongMemory is off by default. When enabled, short-term session history and long-term memory are loaded in parallel at the start of a request. After the session ends, onSessionEndAsync asynchronously completes filtering, deduplication, and long-term consolidation. Short-term memory uses MySQL as the persistence base and Redis as a hot cache; long-term memory uses MemOS as the primary path, while retaining MySQL/Mem0 routing as compatibility paths.
This article only expands on the memory module's implementation: including the four-layer memory model, reading and writing of short-term history, MemOS retrieval by scope, and asynchronous consolidation after a session ends.
2. Memory Architecture Overview
Agent Four-Layer Memory Model
The four layers correspond to different lifecycles: Working Memory serves only the current single reasoning step; Session Memory saves the message history within a session; User Memory records preferences and stable facts shared across Agents, corresponding to MemOS's user_profile; Agent Memory saves task experience and collaboration agreements for a specific Agent, corresponding to agent_{agentId}. After a session ends, new messages are judged and deduplicated, then consolidated from the Session layer to the User or Agent layer.
Agent Four-Layer Memory Model: Reading, scope, and asynchronous consolidation chain for Working, Session, User, and Agent Memory.
Memory Types
The four-layer model and MemOS types are two separate classification dimensions: The four-layer model describes the lifecycle and scope of information; MemOS's text_mem, pref_mem, skill_mem, tool_mem describe the content form of memory and cannot be directly mapped one-to-one to the four layers.
Current Routing Behavior: When longMemoryProvider is not configured, the Agent does not exist, or reading the configuration fails, MemoryRpcServiceImpl selects MySQL. It only enters the corresponding Provider when explicitly configured as MEMOS or Mem0. MemOS query exceptions are logged by the Provider itself and return empty results; currently, it does not automatically switch to MySQL.
Memory Loading Flow
The memory path for a single request can be summarized as: After AgentExecutor#execute parses context parameters, it starts short-term memory and long-term memory Futures in parallel within memoryLoadExecutor. The short-term chain reads ConversationMemory; the long-term chain calls queryMemory, where MemoryRpcServiceImpl selects MEMOS, Mem0, or MySQL based on the Agent configuration.
After long-term memory retrieval returns, the platform groups by
user_profileand agent, filters sensitive or low-relevance content, and limits the number of items per scope.Then, using a fixed token budget as default, it prioritizes satisfying the user profile, and allocates the remaining budget to Agent-specific memory.
The final result is written into
AgentContext, participating in subsequent model calls along with short-term messages; after the session ends, long-term memory consolidation and context summarization are asynchronously triggered.
Implementation Boundaries: The platform's ConversationMemory is responsible for session history reading and window trimming; AgentScope Harness's state store is responsible for session state persistence, using shared workspace file storage when a Sandbox is present, and falling back to in-memory storage when there is no Sandbox. When the current ModelInvoker main chain constructs Harness messages, it uses the current round's userMsg as the entry point, so it cannot be simply understood that Redis/MySQL history is unconditionally directly spliced into every AgentScope Prompt.
Short-term Memory vs Long-term Memory
Key Components and Methods
3. Short-term Memory Loading Details
Short-term memory (Session Memory) saves the chronologically ordered dialogue history within the current session, serving as the direct context for anaphora resolution and multi-turn reasoning. The implementation focuses on read latency, Redis/MySQL fallback, and Token window control.
Loading Flowchart
Call Chain Description:
AgentExecutorgetsAgentConfig→ModelBindConfigDto→contextRoundsfromAgentContext.AgentExecutorcallschatMemory.get(conversationId, contextRounds * 3).chatMemoryis theConversationMemoryinterface, implemented byConversationApplicationServiceImpl.The implementation class prioritizes reading from Redis, falling back to MySQL on failure.
Key Implementation
The complete implementation also includes message type conversion, system message splitting, and tool call information processing. Below, only the main path of "read → alternate validation → Token window judgment" is retained to facilitate understanding of how short-term memory forms the model context.
// DTO has been converted to Message, system/tool messages have been split
trimAlternationFromEnd(chatMsgs);
int windowSize = MAX_TOKEN_WINDOW_SIZE - SUMMARY_TOKEN_BUDGET;
int totalTokens = 0, recentTokens = 0;
boolean overLimit = false;
List<Message> recentWindow = new ArrayList<>();
for (int i = chatMsgs.size() - 1; i >= 0; i--) {
Message message = chatMsgs.get(i);
int tokens = TikTokensUtil.tikTokensCount(message.getText());
totalTokens += tokens;
if (!overLimit && recentTokens + tokens <= windowSize) {
recentWindow.add(0, message);
recentTokens += tokens;
} else {
overLimit = true;
}
}
if (totalTokens <= MAX_TOKEN_WINDOW_SIZE) {
removeLeadingAssistant(chatMsgs);
return mergeSystemAndChat(systemMsgs, chatMsgs);
}
removeLeadingAssistant(recentWindow);
String summaryMd = iMemoryRpcService.getConversationSummaryMd(Long.parseLong(cid));
if (StringUtils.isNotBlank(summaryMd)) {
recentWindow.add(0, new SystemMessage(summaryMd));
}
return recentWindow;
Redis prioritized reading, class description: ConversationApplicationServiceImpl#getMessagesFromCache first reads the Redis List, converts JSON to ChatMessageDto upon hit, and reverses to oldest-first; on miss or read exception, it queries MySQL, then writes back in reverse order using leftPush, sets CACHE_TTL_SECONDS, and uses rightPop to evict the oldest messages according to MAX_CACHED_MESSAGES. Item-by-item parsing and exception logging are defensive code, not expanded here.
Redis Storage Structure Implementation Details
Uses leftPush for writing, with the latest message at index 0; the current implementation uses range(0, Long.MAX_VALUE) to read the Redis list, then performs window trimming at the application layer; uses rightPop to remove the oldest messages. Note that the lastN parameter of ConversationMemory#get(String, int) currently does not participate in the Redis range boundary calculation; the actual read range is jointly determined by the Redis list content and the subsequent Token window. Redis only serves as a hot cache, with MySQL still acting as the cold-start fallback source and persistence safety net.
Token Window Control Strategy
4. Short-term Memory Writing Details
Write Sequence Diagram
The meaning of dual-write------MySQL: Persistence safety net to prevent data loss. Redis: High-performance reads support real-time dialogue. Fallback mechanism: When Redis fails, it can still fallback read from MySQL.
messages.forEach(message -> {
String conversationId0 = conversationId;
if (conversationId0.startsWith("agent:")) {
// Non-ChatMessage from child Agents are not written to the main session, avoiding virtual session pollution of the main chain.
if (message instanceof ChatMessageDto) {
conversationId0 = conversationId.replace("agent:", "");
} else {
return;
}
}
ChatMessageDto chatMessage = ...; // Type conversion, text cleaning, and tenant field completion
ConversationMessage conversationMessage = ...;
// Write to MySQL first, Redis only serves as an invalidatable hot cache.
Long messageId = TenantFunctions.callWithTenantId(chatMessage.getTenantId(),
() -> conversationDomainService.addConversationMessage(conversationMessage));
chatMessage.setIndex(messageId);
try {
String key = generateConversationKey(conversationId0);
redisUtil.leftPush(key, JSON.toJSONString(chatMessage));
redisUtil.expire(key, CACHE_TTL_SECONDS);
long size = redisUtil.size(key);
// Latest message is at the head; when exceeding 200 items, evict the oldest from the tail.
if (size > MAX_CACHED_MESSAGES) {
redisUtil.rightPop(key);
}
} catch (Exception e) {
// Cache failure does not roll back completed MySQL writes; reads will fall back to the database.
log.warn("Failed to cache message to Redis, conversationId={}", conversationId0, e);
}
});
After short-term messages are written, onSessionEndAsync also checks the session summary: summary generation is triggered only when un-summarized messages reach 20. The summary injection budget is about 2000 tokens, and it is cached in Redis for 1 hour. This separates high-frequency dialogue writes from low-frequency summary merging, avoiding calling the summary model every round.
5. Long-term Memory Loading Implementation Details
Load Sequence Diagram
Parallel Loading Design
At the Agent execution entry point AgentExecutor#execute, we use CompletableFuture to implement parallel loading of short-term memory and long-term memory:
final int finalContextRounds = contextRounds;
CompletableFuture<List<Message>> contextMessagesFuture = CompletableFuture.supplyAsync(() -> {
// contextRounds=0 is a valid configuration, directly skip short-term history reading.
if (finalContextRounds <= 0) {
return new ArrayList<Message>();
}
return new ArrayList<>(
chatMemory.get(agentContext.getConversationId(), finalContextRounds * 3));
}, memoryLoadExecutor);
CompletableFuture<Map<String, String>> longMemoryFuture = CompletableFuture.supplyAsync(() -> {
if (agentContext.getAgentConfig().getOpenLongMemory() != AgentConfig.OpenStatus.Open) {
return Collections.emptyMap();
}
try {
AgentComponentConfigDto modelComponentConfig =
agentContext.getAgentConfig().getModelComponentConfig();
// queryMemory requires a bound model targetId; skip long-term retrieval if not bound.
if (modelComponentConfig == null || modelComponentConfig.getTargetId() == null) {
return Collections.emptyMap();
}
boolean justKeywordMatch = resolveJustKeywordMatch(agentContext);
// originalMessage is the main query term; context is empty here, short-term history does not reversely enhance this query.
return conversationApplicationService.queryMemory(
agentContext.getUser().getTenantId(), agentContext.getUser().getId(),
agentContext.getAgentConfig().getId(), modelComponentConfig.getTargetId(),
agentContext.getOriginalMessage(), "", justKeywordMatch,
agentContext.isFilterSensitive());
} catch (Exception e) {
// Long-term memory is an enhancement capability; let the main dialogue continue on query failure.
log.warn("Query long-term memory failed", e);
return Collections.emptyMap();
}
}, memoryLoadExecutor);
// This is the convergence point for parallel results; the main flow waits for both memory chains to complete.
agentContext.setContextMessages(contextMessagesFuture.join());
Map<String, String> longMemoryMap = longMemoryFuture.join();
Design Points: Uses a dedicated thread pool memoryLoadExecutor to avoid contention on ForkJoinPool.commonPool; long-term memory does not block short-term memory, querying with an empty context first.
Boundaries and Degradation: When openLongMemory is off, the long-term Future directly returns an empty Map; when the Agent is not bound to a model, long-term retrieval is skipped; query exceptions also record a warning and return an empty result, allowing the main dialogue to continue. Currently, long-term queries use originalMessage as the main retrieval term, and the reading of short-term memory does not wait for each other, so the parallelism brings execution overlap, not "enhancing the query after short-term history has been injected."
Semantic Retrieval Implementation
MemOS Search request retains the following parameters; when context is non-empty, the platform only takes the first 200 characters and appends them after userMessage as the query. Although the call chain carries justKeywordSearch, the current MemosMemoryApplicationService does not switch retrieval mode based on this, still fixedly calling MemOS Search.
List<String> readableCubeIds = new ArrayList<>();
readableCubeIds.add("user_profile");
if (agentId != null) {
readableCubeIds.add("agent_" + agentId);
}
MemOSClient.SearchRequest searchRequest = new MemOSClient.SearchRequest();
// One Search simultaneously covers the user profile and the current Agent's memory cube.
searchRequest.setQuery(buildSearchQuery(userMessage, context));
searchRequest.setUserId(memOSUserId);
searchRequest.setTopK(DEFAULT_TOP_K);
searchRequest.setMode("fast");
searchRequest.setRelativity(0.45);
searchRequest.setDedup("mmr");
searchRequest.setReadableCubeIds(readableCubeIds);
searchRequest.setIncludePreference(true);
searchRequest.setPrefTopK(6);
List<MemOSMemory> searchResults =
memOSClient.searchMemory(searchRequest).getMemories();
// The conversion phase filters low-score results, truncates single item content, then returns in descending score order.
result.addAll(convertToMemoryUnitDTOs(searchResults, tenantId, userId, agentId));
result.sort((a, b) -> {
if (a.getScore() == null && b.getScore() == null) return 0;
if (a.getScore() == null) return 1;
if (b.getScore() == null) return -1;
return Double.compare(b.getScore(), a.getScore());
});
Search Parameter Description:
readableCubeIds currently includes user_profile and agent_{agentId}; preference retrieval is also enabled, with prefTopK=6 used to supplement user preferences. The retrieval results are subsequently filtered by score < 0.3 and truncated to 1000 characters per item, preventing low-relevance or overly long content from crowding the context.
Token Budget Allocation Strategy
The following snippet continues from the retrieval results in 5.2, where userProfileRaw and agentMemoryRaw are texts aggregated by scope. To prevent memory from overwhelming the Agent context, we designed a Token Budget allocation mechanism.
int budget = DEFAULT_LONG_MEMORY_TOKEN_BUDGET; // 4000 tokens
int userProfileRawTokens =
TikTokensUtil.tikTokensCount(userProfileRaw != null ? userProfileRaw : "");
// user_profile takes up to 60%; when actual usage is less, the remaining budget is given to Agent memory.
if (userProfileRawTokens <= budget * 0.6) {
userProfile = userProfileRaw;
agentMemory = truncateLongMemory(agentMemoryRaw, budget - userProfileRawTokens);
} else {
userProfile = truncateLongMemory(userProfileRaw, (int) (budget * 0.6));
// Recalculate the token count after truncation to avoid bringing the pre-truncation estimate into the remaining budget.
int userProfileTokens =
TikTokensUtil.tikTokensCount(userProfile != null ? userProfile : "");
agentMemory = truncateLongMemory(agentMemoryRaw, budget - userProfileTokens);
}
The truncation algorithm uses line-by-line truncation to preserve complete semantics:
String[] lines = longMemory.split("\n", -1);
for (String line : lines) {
// Accumulate tokens line by line, trying to keep the complete content of one memory item.
int lineTokens = TikTokensUtil.tikTokensCount(line + "\n");
if (currentTokens + lineTokens > budget) {
break;
}
truncated.append(line);
currentTokens += lineTokens;
}
user_profile takes up to 60% of the budget; if actual usage is less, the remaining space is allocated to Agent memory. Both types of content accumulate tokens line by line, stopping when the budget is reached, avoiding splitting a single memory item.
6. Long-term Memory Writing Implementation Details
Write Sequence Diagram
Write Entry and Deduplication
When a session ends, the system triggers the memory write flow, first using a Redis Set to record the hash of processed messages and perform deduplication.
Class Description: MemoryRpcServiceImpl is responsible for write orchestration after a session ends. onSessionEndAsync first acquires a session-level Redis lock, then processSessionEnd filters new content for this session by message hash and records processed hashes in advance. Subsequently, it selects a provider based on the Agent configuration: the MemOS path first performs LLM memory judgment, then hands off to MemoryPersistenceServiceImpl for persistence; MySQL and Mem0 paths assemble the complete context and the latest user input before calling createMemory. After memory processing is complete, the same asynchronous thread continues to check whether a context summary needs to be generated.
Design Points:
Distributed lock prevents concurrent processing of the same session.
MD5(role:content)generates a unique message identifier.TTL of 7 days automatically cleans up expired records.
onSessionEndAsync uses a 10-minute session lock, and the TTL for processed message hashes is 7 days. The hash is written before LLM judgment and MemOS persistence to block duplicate end events. However, this layer is best-effort: when Redis read fails, the current implementation treats the entire session as new content and continues processing; hash write failures are only logged. There is no automatic replay after external service failures, so the operations side needs to monitor both duplicate writes and incomplete processing anomalies.
LLM Intelligent Judgment
The complete Prompt defines memory categories, scope, importance, and JSON output constraints. Below, only the new message marking and model call mainline are retained. judge first calls the LLM; on call exception or empty return, it degrades to rule-based judgment solely on new messages. When the LLM return is not empty, it also uniformly fills in default fields, filters empty content, and truncates single memory items to 200 characters.
private MemoryJudgeResult judgeByLLM(Long tenantId, Long modelId,
List<MemoryMessage> fullContext, List<MemoryMessage> newMessages) {
Set<String> newMessageKeys = newMessages.stream()
.map(m -> (m.getRole() != null ? m.getRole() : "user")
+ ":" + (m.getContent() != null ? m.getContent() : ""))
.collect(Collectors.toSet());
StringBuilder content = new StringBuilder("## Complete Dialogue Context\n\n");
for (MemoryMessage message : fullContext) {
String role = message.getRole() != null ? message.getRole() : "user";
String text = message.getContent() != null ? message.getContent() : "";
content.append(newMessageKeys.contains(role + ":" + text)
? "[[NEW] " : "[")
.append(role).append("]: ").append(text).append("\n");
}
MemoryJudgeResult result = iModelRpcService.call(
tenantId, modelId, JUDGE_SYSTEM_PROMPT, content.toString(),
new ParameterizedTypeReference<MemoryJudgeResult>() {});
return result == null ? null : validateAndFixResult(result);
}
Judgment Points:
Generate message keys by
role:contentand mark key content; historical messages with the same content may also be hit together.Define importance intervals (1-10) based on 5 categories of information.
Clear scope attribution rules.
Deduplication and Conflict Handling
Before persistence, local similarity deduplication is performed to reduce unnecessary writes: Class Description: MemoryPersistenceServiceImpl#calculateSimilarity judges sequentially by exact, contains, character-level Jaccard (threshold 0.7), and short-text Levenshtein (threshold 0.8); similarity marks it as a duplicate. Null values or failure to meet thresholds return none. It is only responsible for local pre-deduplication within the same batch; conflicts across historical memories are still handled by the subsequent conflict service.
After batchPersist completes local deduplication, it first groups by memCubeId, then calls batchDetectConflict to get the conflict result for each candidate memory. Below, only the core loop of deciding "write new memory, skip, or record old memories to expire" per item is retained:
for (int i = 0; i < memoryList.size(); i++) {
MemoryToSave memory = memoryList.get(i);
MemoryConflictResult conflict = i < conflictResults.size()
? conflictResults.get(i) : MemoryConflictResult.noConflict();
if (!conflict.isHasConflict()) {
messagesToStore.add(createMessage(memory.getContent()));
continue;
}
ConflictResolutionResult resolution =
memoryConflictService.resolveConflict(
tenantId, memOSUserId, memCubeId,
memory.getContent(), conflict, modelId);
if (resolution.isShouldStoreNew()) {
messagesToStore.add(createMessage(memory.getContent()));
if (CollectionUtils.isNotEmpty(resolution.getMemoryIdsToExpire())) {
memoryIdsToDelete.addAll(resolution.getMemoryIdsToExpire());
}
}
}
The "batch" here mainly refers to grouping by memCubeId and reusing a batch of content to be written; the conflict service's batch method internally still calls the detection interface item by item, then decides item by item to keep new memory, skip, or mark old memory. Therefore, it reduces grouping and orchestration overhead, but cannot be equated to completing all conflict judgments in a single network request.
Write-First, Delete-Later Strategy
To avoid data loss, a "write new memory first, delete old memory later" strategy is adopted.
Class Description: MemoryPersistenceServiceImpl#batchPersist assembles MemOS's AddMemoryRequest by memCubeId, writes userId, writableCubeIds, and messages, and calls addMemory in async, fine mode. Only when the return result contains newly added memories are the conflicting old memory IDs handed to deleteMemories; delete exceptions only record a warning, and the already written new memories are retained.
This is a "write-priority" risk control, not transactional consistency: MemOS is an external HTTP service, and the platform cannot wrap both addition and deletion in a local transaction. Currently, delete failures only record a warning, which is best-effort; old memories may be temporarily retained and cleaned up by subsequent retrieval or the next conflict handling.
7. Core Design and Operational Observation
This implementation splits memory processing into two independent chains: parallel loading of short-term history and long-term memory before a request, and asynchronous filtering, deduplication, and persistence after the session ends. The core strategy focuses on four points:
Parallel Loading: Short-term memory and long-term memory are submitted to the same dedicated thread pool, with the main flow converging at
join.New Message Marking: Uses
[[NEW]]to mark new messages in this session, allowing the LLM to extract new information with the help of the complete context.Budget Control: Short-term history uses a sliding window and reserves summary space; long-term memory is grouped by scope and then allocated fixed tokens.
Reliable Writing: Redis locks and hashes handle idempotency; on the MemOS side, new memories are written first, then conflicting old memories are deleted on a best-effort basis.
Operational Observation Capabilities: The system provides query capabilities such as operational overview, memory type distribution, time trends, Top Agents, and Cube details, which can be used to understand the scale, classification, source, and change trends of memory data, and supports viewing long-term memory search trends by time range, suitable for daily inspection, data distribution analysis, and anomaly location.
8. Summary and Future Work
Short-term memory is responsible for bringing the current session into the next round; long-term memory is responsible for bringing filtered user facts and Agent experience to subsequent sessions. The two chains are read in parallel before a request and connected through asynchronous tasks after the session ends; long-term memory is further controlled by scope and Token Budget for injection content.
The current solution already covers Redis/MySQL fallback, MemOS semantic retrieval, LLM judgment, local deduplication, conflict handling, and write-first-delete-later. What still needs to be supplemented is observation of latency and failure rates under production traffic, as well as a compensation mechanism after external memory service failures.
Previous Reviews
EP-Harness: From Personal AI Coding to Team-level Agent Workflow | Dewu Technology
Dewu Knowledge Q&A: System Design Practice for Composite Retrieval Agents | Dewu Technology
Building a Coding Agent from Scratch in Practice: Violin | Dewu Technology
R&D Paradigm for AI Native Trading Core Systems | Dewu Technology
Text / Ou La
Follow Dewu Technology, technical干货 every Thursday
If you find the article helpful, feel free to comment, forward, and like~
Reproduction without permission from Dewu Technology is strictly prohibited, otherwise legal liability will be pursued according to law.