Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 273 additions & 48 deletions src/memos/api/handlers/chat_handler.py

Large diffs are not rendered by default.

52 changes: 45 additions & 7 deletions src/memos/mem_chat/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,28 @@ def run(self) -> None:
print(
f"🧠 [Memory] Searched memories:\n{self._str_memories(memories, mode='concise')}\n"
)
system_prompt = self._build_system_prompt(memories)
stable_system = self._build_stable_system_prompt()
dynamic_ctx = self._build_dynamic_memory_context(memories)
else:
system_prompt = self._build_system_prompt()
current_messages = [
{"role": "system", "content": system_prompt},
*messages,
{"role": "user", "content": user_input},
]
stable_system = self._build_stable_system_prompt()
dynamic_ctx = None
# Issue #2524: stable system first, then history, then dynamic
# context, then current user turn — keeps the chat history inside
# the provider's shared prefix. The dynamic context is delivered
# as a user-role framing message (OCR round 2 finding #9): a
# mid-conversation ``{"role": "system"}`` message is rejected or
# mishandled by several provider APIs, while a user turn with a
# "[Context for this turn]" header is unambiguous and portable.
current_messages: list[dict] = [{"role": "system", "content": stable_system}]
current_messages.extend(messages)
if dynamic_ctx:
current_messages.append(
{
"role": "user",
"content": "[Context for this turn]\n" + dynamic_ctx,
}
)
current_messages.append({"role": "user", "content": user_input})

if self.config.enable_activation_memory:
past_key_values = None
Expand Down Expand Up @@ -154,6 +168,30 @@ def _build_system_prompt(self, memories: list | None = None) -> str:

return base_prompt

# Prefix-cache-friendly split (Issue #2524)
def _build_stable_system_prompt(self) -> str:
"""Return the stable (cross-turn constant) system prompt."""
return (
"You are a knowledgeable and helpful AI assistant. "
"You have access to conversation memories that help you provide more personalized responses. "
"Use the memories to understand the user's context, preferences, and past interactions. "
"If memories are provided, reference them naturally when relevant, but don't explicitly mention having memories."
)

def _build_dynamic_memory_context(self, memories: list | None) -> str | None:
"""Return a system message carrying this turn's retrieved memories.

Returned string is intended to sit immediately before the latest
user turn so the chat history that precedes it stays part of the
provider's shared prefix.
"""
if not memories:
return None
lines = ["## Memories"]
for i, memory in enumerate(memories, 1):
lines.append(f"{i}. ({memory.metadata.memory_time}) {memory.memory}")
return "\n".join(lines)

def _str_memories(
self, memories: list[TextualMemoryItem], mode: Literal["concise", "full"] = "full"
) -> str:
Expand Down
81 changes: 74 additions & 7 deletions src/memos/mem_os/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,14 +300,30 @@ def chat(self, query: str, user_id: str | None = None, base_prompt: str | None =
)
memories_all.extend(memories)
logger.info(f"🧠 [Memory] Searched memories:\n{self._str_memories(memories_all)}\n")
system_prompt = self._build_system_prompt(memories_all, base_prompt=base_prompt)
# Issue #2524: emit a stable system prompt first, then history,
# then a dynamic context message with the retrieved memories,
# so provider prefix caches can reuse the history across turns.
stable_system, dynamic_ctx = self._build_cacheable_system_parts(
memories_all, base_prompt=base_prompt
)
else:
system_prompt = self._build_system_prompt(base_prompt=base_prompt)
current_messages = [
{"role": "system", "content": system_prompt},
*chat_history.chat_history,
{"role": "user", "content": query},
]
stable_system, dynamic_ctx = self._build_cacheable_system_parts(
None, base_prompt=base_prompt
)
current_messages: list[dict] = [{"role": "system", "content": stable_system}]
current_messages.extend(chat_history.chat_history)
if dynamic_ctx:
# Issue #2524 OCR round 2: avoid a mid-conversation
# ``{"role": "system"}`` message, which is rejected or
# mishandled by several provider APIs. Wrap the per-turn
# context in a user-role framing message instead.
current_messages.append(
{
"role": "user",
"content": "[Context for this turn]\n" + dynamic_ctx,
}
)
current_messages.append({"role": "user", "content": query})
past_key_values = None

if self.config.enable_activation_memory:
Expand Down Expand Up @@ -387,6 +403,57 @@ def _build_system_prompt(
return base_prompt + memory_context_with_header
return base_prompt

# Prefix-cache-friendly system split (Issue #2524)
def _build_cacheable_system_parts(
self,
memories: list[TextualMemoryItem] | list[str] | None = None,
base_prompt: str | None = None,
) -> tuple[str, str | None]:
"""Return ``(stable_system, dynamic_context)`` for cache-friendly
chat assembly.

* ``stable_system`` is the same bytes across every turn in one
conversation. No memories, no current time.
* ``dynamic_context`` carries the per-turn memory text and is
``None`` when there are no memories.

If ``base_prompt`` contains ``{memories}`` the caller used the
legacy single-string pattern; the stable half replaces the slot
with an empty string and the dynamic half still carries the memory
text so the LLM has the information available.
"""
default_prompt = (
"You are a knowledgeable and helpful AI assistant. "
"You have access to conversation memories that help you provide more personalized responses. "
"Use the memories to understand the user's context, preferences, and past interactions. "
"If memories are provided, reference them naturally when relevant, but don't explicitly mention having memories."
)
if base_prompt is None:
stable = default_prompt
elif "{memories}" in base_prompt:
stable = base_prompt.replace("{memories}", "").rstrip()
else:
stable = base_prompt

memory_text = ""
if memories:
rendered: list[str] = []
for i, memory in enumerate(memories, 1):
if isinstance(memory, TextualMemoryItem):
text_memory = memory.memory
else:
if not isinstance(memory, str):
# Skip rather than forward a repr(dict/object) to the
# LLM as a memory entry.
logger.error("Unexpected memory type: %s", type(memory))
continue
text_memory = memory
rendered.append(f"{i}. {text_memory}")
memory_text = "\n".join(rendered)
if not memory_text:
return stable, None
return stable, "## Memories\n" + memory_text

def _str_memories(
self, memories: list[TextualMemoryItem], mode: Literal["concise", "full"] = "full"
) -> str:
Expand Down
31 changes: 20 additions & 11 deletions src/memos/mem_os/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,19 +293,28 @@ def _generate_enhanced_response_with_context(
search_memories = search_engine.search(
original_query, top_k=self.config.top_k, mode="fast"
)
system_prompt = self._build_system_prompt(
# Issue #2524: emit a stable system prompt + per-turn dynamic
# context so provider prefix caches can reuse the chat history.
stable_system, dynamic_ctx = self._build_cacheable_system_parts(
search_memories, base_prompt=base_prompt
) # Use the same system prompt builder
)
else:
system_prompt = self._build_system_prompt(base_prompt=base_prompt)
current_messages = [
{"role": "system", "content": system_prompt + SYNTHESIS_PROMPT.format(qa_text=qa_text)},
*chat_history.chat_history,
{
"role": "user",
"content": original_query,
},
]
stable_system, dynamic_ctx = self._build_cacheable_system_parts(
None, base_prompt=base_prompt
)

synthesis_tail = SYNTHESIS_PROMPT.format(qa_text=qa_text)
# Issue #2524: keep the leading system message byte-identical across
# turns. ``synthesis_tail`` embeds the per-call sub-questions and
# sub-answers so it changes every turn — append it as its own system
# message *after* the chat history (together with the dynamic
# context) instead of concatenating it into ``stable_system``.
current_messages: list[dict] = [{"role": "system", "content": stable_system}]
current_messages.extend(chat_history.chat_history)
if dynamic_ctx:
current_messages.append({"role": "system", "content": dynamic_ctx})
current_messages.append({"role": "system", "content": synthesis_tail})
current_messages.append({"role": "user", "content": original_query})

# Handle activation memory if enabled (same as core method)
past_key_values = None
Expand Down
125 changes: 125 additions & 0 deletions src/memos/templates/cloud_service_prompt.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from datetime import datetime


Expand Down Expand Up @@ -105,3 +107,126 @@ def get_cloud_chat_prompt(lang: str = "en") -> str:
)
else:
raise ValueError(f"Invalid language: {lang}")


# -- Prefix-cache-friendly helpers (Issue #2524) ---------------------------
#
# The original ``get_cloud_chat_prompt`` embeds ``{current_time}`` (minute
# precision) and splices the current turn's retrieval into ``{memories}``.
# Because the resulting string is sent as the first (system) message of every
# turn, provider prefix caches cannot match any shared prefix across turns.
#
# The helpers below return the two halves separately:
#
# * ``get_cloud_chat_stable_prompt(lang)`` -- byte-identical across turns in
# one conversation. Safe to use as the leading system message so that the
# chat history that follows becomes part of the shared prefix.
# * ``get_cloud_chat_dynamic_context(...)`` -- per-turn context carrying the
# current time, the retrieved memories, and the retrieved preferences. The
# caller injects this as a system message immediately before the latest
# user turn.
# ---------------------------------------------------------------------------


_STABLE_PROMPT_SENTINEL = (
"A system message carrying the current time, retrieved memories, and "
"preferences will appear immediately before the latest user turn — "
"read it before answering."
)

_STABLE_PROMPT_SENTINEL_ZH = "紧跟在最新用户消息之前,会有一条系统消息,包含当前时间、检索到的记忆和偏好——回答前请先阅读该消息。"


def _build_stable_from_cloud_prompt(prompt: str, sentinel: str) -> str:
"""Return the cloud chat prompt with the per-turn placeholders replaced
by a short sentinel pointing to the dynamic context message.

The template surgery is implemented with regular expressions rather than
exact-string ``str.replace`` calls so that minor rewording of the
surrounding template (extra whitespace, punctuation tweaks, new wording
around ``{current_time}`` / ``{memories}``) still produces a correct
stable prompt. If a placeholder would survive the surgery we raise
``ValueError`` loudly instead of leaking the raw template token to the
model.
"""
# Replace the "- Current Time: {current_time} ..." line (English or
# Chinese) with the sentinel. Match the whole line so parenthetical
# qualifiers after the placeholder are also removed.
stable = re.sub(
r"- *(?:Current Time|当前时间)[::][^\n]*\{current_time\}[^\n]*",
f"- {sentinel}",
prompt,
)
# Replace the <memories>...{memories}...</memories> block (any
# whitespace between the tags) with the sentinel on its own line.
stable = re.sub(
r"<memories>\s*\{memories\}\s*</memories>",
sentinel,
stable,
flags=re.DOTALL,
)
# Loud failure beats silent leak: if a placeholder survived, the template
# has drifted and the derivation needs updating.
for leftover in ("{current_time}", "{memories}"):
if leftover in stable:
raise ValueError(
"Cloud chat template drifted: placeholder "
f"{leftover!r} was not substituted when building the stable "
"system prompt. Update _build_stable_from_cloud_prompt."
)
return stable


def get_cloud_chat_stable_prompt(lang: str = "en") -> str:
"""Return the stable (cross-turn constant) cloud chat prompt.

The returned string must be identical across turns in one conversation.
No current time, no retrieved memories. The template points the model to
the dynamic context message that follows the chat history.
"""
if lang == "zh":
return _build_stable_from_cloud_prompt(CLOUD_CHAT_PROMPT_ZH, _STABLE_PROMPT_SENTINEL_ZH)
if lang == "en":
return _build_stable_from_cloud_prompt(CLOUD_CHAT_PROMPT_EN, _STABLE_PROMPT_SENTINEL)
raise ValueError(f"Invalid language: {lang}")


def get_cloud_chat_dynamic_context(
*,
memories_block: str,
pref_string: str | None = None,
now: datetime | None = None,
lang: str = "en",
) -> str:
"""Return the per-turn dynamic context message.

Args:
memories_block: already-formatted memories (e.g. "1. ...\\n2. ...").
Pass an empty string when there are no memories.
pref_string: formatted preference string, or ``None``.
now: timestamp to use for the "Current Time" line. Defaults to
``datetime.now()``; parameterised to help tests.
lang: "en" or "zh".
"""
stamp = (now or datetime.now()).strftime("%Y-%m-%d %H:%M (%A)")

memories_section = memories_block.strip()
pref_section = (pref_string or "").strip()

if lang == "zh":
parts = [f"# 当前上下文\n- 当前时间: {stamp}"]
if memories_section:
parts.append("# 事实与记忆\n<memories>\n" + memories_section + "\n</memories>")
if pref_section:
parts.append("# 偏好\n" + pref_section)
return "\n\n".join(parts)

if lang == "en":
parts = [f"# Current Context\n- Current Time: {stamp}"]
if memories_section:
parts.append("# Facts & Memories\n<memories>\n" + memories_section + "\n</memories>")
if pref_section:
parts.append("# Preferences\n" + pref_section)
return "\n\n".join(parts)

raise ValueError(f"Invalid language: {lang}")
Loading
Loading