feat: 更新长期记忆工具提示,增强用户使用指导

This commit is contained in:
ooodc 2026-04-22 15:06:11 +08:00
parent 71a8033d15
commit 0331774466
2 changed files with 5 additions and 312 deletions

View File

@ -21,7 +21,7 @@ use std::time::Instant;
const MAX_TOOL_RESULT_CHARS: usize = 16_000; const MAX_TOOL_RESULT_CHARS: usize = 16_000;
/// Minimum characters to keep when truncating /// Minimum characters to keep when truncating
const TRUNCATION_SUFFIX_LEN: usize = 200; const TRUNCATION_SUFFIX_LEN: usize = 200;
const MEMORY_AUTOSAVE_SYSTEM_PROMPT: &str = "你可以在处理任务过程中使用长期记忆工具。读取记忆时,优先使用 memory_search当你需要用户长期偏好、稳定事实、历史决策、持续任务上下文时先 search已知 namespace/key 时可用 get需要浏览最近记忆时可用 list。写入或修改记忆时再使用 memory_manage。仅在遇到高价值且未来仍有用的信息时写入记忆用户长期偏好、稳定事实、用户对你的纠正、持续任务/项目上下文、明确决策。不要保存一次性工具结果、临时列表、敏感凭证或不确定推测。写入时优先使用规范 namespacepreferences、profile、tasks、decisions并优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。检索时尽量同时提供中文关键词、英文别名,以及可能的 snake_case memory_key 词,例如 email / 邮件 / email_folder_preference。"; const MEMORY_TOOL_USAGE_SYSTEM_PROMPT: &str = "你可以在处理任务过程中使用长期记忆工具。读取记忆时,优先使用 memory_search当你需要用户长期偏好、稳定事实、历史决策、持续任务上下文时先 search已知 namespace/key 时可用 get需要浏览最近记忆时可用 list。写入或修改记忆时再使用 memory_manage。仅在遇到高价值且未来仍有用的信息时写入记忆用户长期偏好、稳定事实、用户对你的纠正、持续任务/项目上下文、明确决策。不要保存一次性工具结果、临时列表、敏感凭证或不确定推测。写入时优先使用规范 namespacepreferences、profile、tasks、decisions并优先调用 memory_manage(action='put');同一 namespace/key 可直接覆盖更新。检索时尽量同时提供中文关键词、英文别名,以及可能的 snake_case memory_key 词,例如 email / 邮件 / email_folder_preference。";
const PENDING_USER_ACTION_MARKER: &str = "__PICOBOT_PENDING_USER_ACTION__"; const PENDING_USER_ACTION_MARKER: &str = "__PICOBOT_PENDING_USER_ACTION__";
const DEFAULT_PENDING_ASSISTANT_MESSAGE: &str = "工具已经启动并进入等待用户操作的状态。请先完成外部操作,完成后直接告诉我继续。"; const DEFAULT_PENDING_ASSISTANT_MESSAGE: &str = "工具已经启动并进入等待用户操作的状态。请先完成外部操作,完成后直接告诉我继续。";
@ -368,7 +368,7 @@ impl AgentLoop {
if let Some(skill_prompt) = self.skills.system_index_prompt() { if let Some(skill_prompt) = self.skills.system_index_prompt() {
messages_for_llm.push(Message::system(skill_prompt)); messages_for_llm.push(Message::system(skill_prompt));
} }
messages_for_llm.push(Message::system(MEMORY_AUTOSAVE_SYSTEM_PROMPT)); messages_for_llm.push(Message::system(MEMORY_TOOL_USAGE_SYSTEM_PROMPT));
messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message));
// Build request // Build request
@ -520,7 +520,6 @@ impl AgentLoop {
if let Some(skill_prompt) = self.skills.system_index_prompt() { if let Some(skill_prompt) = self.skills.system_index_prompt() {
messages_for_llm.push(Message::system(skill_prompt)); messages_for_llm.push(Message::system(skill_prompt));
} }
messages_for_llm.push(Message::system(MEMORY_AUTOSAVE_SYSTEM_PROMPT));
messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message)); messages_for_llm.extend(messages.iter().map(chat_message_to_llm_message));
let request = ChatCompletionRequest { let request = ChatCompletionRequest {

View File

@ -1,4 +1,4 @@
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
use std::fs; use std::fs;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -8,10 +8,9 @@ use uuid::Uuid;
use crate::bus::{ChatMessage, MessageBus, OutboundMessage}; use crate::bus::{ChatMessage, MessageBus, OutboundMessage};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::agent::{AgentLoop, AgentError, ContextCompressor, EmittedMessageHandler}; use crate::agent::{AgentLoop, AgentError, ContextCompressor, EmittedMessageHandler};
use crate::providers::{create_provider, ChatCompletionRequest, Message};
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{MemoryRecord, SessionRecord, SessionStore, persistent_session_id}; use crate::storage::{SessionRecord, SessionStore, persistent_session_id};
use crate::tools::{ use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
HttpRequestTool, MemoryManageTool, MemorySearchTool, SkillListTool, SkillManageTool, ToolContext, ToolRegistry, HttpRequestTool, MemoryManageTool, MemorySearchTool, SkillListTool, SkillManageTool, ToolContext, ToolRegistry,
@ -19,10 +18,6 @@ use crate::tools::{
}; };
const DEFAULT_AGENT_PROMPT: &str = "# PicoBot 代理配置\n\n## 身份\n- 你是 PicoBot一名务实、可靠的通用助理。\n- 你的目标是理解用户当下的真实需求,并给出清晰、可执行的帮助。\n\n## 工作方式\n- 优先理解意图,再给出回应或行动。\n- 保持简洁、准确、自然,不故作热情,也不空泛铺陈。\n- 能直接验证的内容尽量先验证,避免凭空猜测。\n- 当现有工具是完成任务的最直接方式时,优先使用工具。\n- 除非用户明确要求改变方向,否则保持用户原本目标不变。\n\n## 助理原则\n- 优先解决问题,而不是展示过程。\n- 输出要方便用户立即使用,结论尽量明确。\n- 对不确定的地方要直说,不把猜测包装成事实。\n- 复杂任务先收敛重点,简单任务直接给结果。\n- 避免不必要的重复、客套和冗长说明。\n\n## 回复规则\n- 除非用户另有要求,否则使用中文回复。\n- 默认短而清楚,按信息密度组织内容。\n- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。\n- 如果存在限制、风险或前提条件,要直接说明。\n\n## 补充要求\n- 你是 PicoBot。\n- 回答应以帮助用户完成当前目标为中心。\n- 在信息不足时先补关键前提,在信息充分时直接执行。\n"; const DEFAULT_AGENT_PROMPT: &str = "# PicoBot 代理配置\n\n## 身份\n- 你是 PicoBot一名务实、可靠的通用助理。\n- 你的目标是理解用户当下的真实需求,并给出清晰、可执行的帮助。\n\n## 工作方式\n- 优先理解意图,再给出回应或行动。\n- 保持简洁、准确、自然,不故作热情,也不空泛铺陈。\n- 能直接验证的内容尽量先验证,避免凭空猜测。\n- 当现有工具是完成任务的最直接方式时,优先使用工具。\n- 除非用户明确要求改变方向,否则保持用户原本目标不变。\n\n## 助理原则\n- 优先解决问题,而不是展示过程。\n- 输出要方便用户立即使用,结论尽量明确。\n- 对不确定的地方要直说,不把猜测包装成事实。\n- 复杂任务先收敛重点,简单任务直接给结果。\n- 避免不必要的重复、客套和冗长说明。\n\n## 回复规则\n- 除非用户另有要求,否则使用中文回复。\n- 默认短而清楚,按信息密度组织内容。\n- 如果任务涉及文件、命令、配置或下一步操作,优先给出最关键的那部分。\n- 如果存在限制、风险或前提条件,要直接说明。\n\n## 补充要求\n- 你是 PicoBot。\n- 回答应以帮助用户完成当前目标为中心。\n- 在信息不足时先补关键前提,在信息充分时直接执行。\n";
const MEMORY_KEYWORD_SYSTEM_PROMPT: &str = "你负责为长期记忆检索生成关键词。根据给定的最近会话,仅输出 JSON 数组字符串。关键词必须同时覆盖中文和英文优先为同一主题同时给出中文关键词和对应英文关键词。关键词必须是短词语优先使用最容易命中记忆的核心检索词不要输出完整句子、解释或长描述。必要时优先保留实体名、产品名、偏好名、snake_case key 风格短词。数组元素总数控制在 2 到 6 个简短关键词或短语。不要输出解释,不要输出 Markdown。如果最新的会话跟前面有明显主题变化且新主题可能与旧主题的关键词不同优先输出新主题的关键词如果最新会话是旧主题的延续优先输出旧主题的关键词如果最新会话同时包含新旧主题优先输出同时覆盖新旧主题的关键词。";
const RELATED_MEMORY_SYSTEM_PROMPT_PREFIX: &str = "找到相关的记忆。你必须优先参考这些记忆,并在后续推理中把它们当作当前会话的补充上下文;若与用户本轮明确要求冲突,以用户本轮要求为准。";
const MEMORY_KEYWORD_REASONING_EFFORT: &str = "none";
const MEMORY_KEYWORD_MAX_CHARS: usize = 32;
/// Session 按 channel 隔离,每个 channel 一个 Session /// Session 按 channel 隔离,每个 channel 一个 Session
/// History 按 chat_id 隔离,由 Session 统一管理 /// History 按 chat_id 隔离,由 Session 统一管理
@ -627,43 +622,7 @@ impl SessionManager {
let user_message_id = user_message.id.clone(); let user_message_id = user_message.id.clone();
session_guard.append_persisted_message(chat_id, user_message)?; session_guard.append_persisted_message(chat_id, user_message)?;
// 获取完整历史 let history = session_guard.get_or_create_history(chat_id).clone();
let mut history = session_guard.get_or_create_history(chat_id).clone();
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
history_len = history.len(),
"Starting synchronous related memory search"
);
if let Some(memory_prompt) = build_related_memory_prompt(
session_guard.provider_config().clone(),
self.store.clone(),
channel_name.to_string(),
sender_id.to_string(),
chat_id.to_string(),
history.clone(),
)
.await?
{
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
prompt_len = memory_prompt.len(),
"Injecting related memory system prompt before agent processing"
);
let memory_message = ChatMessage::system(memory_prompt);
session_guard.append_persisted_message(chat_id, memory_message.clone())?;
history.push(memory_message);
} else {
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
"No related memory prompt generated before agent processing"
);
}
// 压缩历史(如果需要) // 压缩历史(如果需要)
let history = session_guard.compressor let history = session_guard.compressor
@ -719,233 +678,6 @@ impl SessionManager {
} }
} }
async fn build_related_memory_prompt(
provider_config: LLMProviderConfig,
store: Arc<SessionStore>,
channel_name: String,
sender_id: String,
chat_id: String,
history: Vec<ChatMessage>,
) -> Result<Option<String>, AgentError> {
let keywords = generate_memory_search_keywords(provider_config, &history).await?;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
keyword_count = keywords.len(),
keywords = ?keywords,
"Generated memory search keywords"
);
if keywords.is_empty() {
return Ok(None);
}
let memories = search_related_memories(
store,
&channel_name,
&sender_id,
&chat_id,
&keywords,
)
.await?;
if memories.is_empty() {
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
keyword_count = keywords.len(),
"Related memory search returned no matches"
);
return Ok(None);
}
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
keyword_count = keywords.len(),
memory_count = memories.len(),
"Related memory search produced matches"
);
Ok(Some(format_related_memory_system_prompt(&keywords, &memories)))
}
async fn generate_memory_search_keywords(
mut provider_config: LLMProviderConfig,
history: &[ChatMessage],
) -> Result<Vec<String>, AgentError> {
if provider_config.provider_type == "openai" {
provider_config.model_extra.insert(
"reasoning_effort".to_string(),
serde_json::Value::String(MEMORY_KEYWORD_REASONING_EFFORT.to_string()),
);
}
let provider = create_provider(provider_config)
.map_err(|err| AgentError::ProviderCreation(err.to_string()))?;
let request = ChatCompletionRequest {
messages: vec![
Message::system(MEMORY_KEYWORD_SYSTEM_PROMPT),
Message::user(build_memory_keyword_context(history)),
],
temperature: Some(0.0),
max_tokens: Some(128),
tools: None,
};
let response = provider
.chat(request)
.await
.map_err(|err| AgentError::LlmError(err.to_string()))?;
Ok(parse_memory_keywords(&response.content))
}
fn build_memory_keyword_context(history: &[ChatMessage]) -> String {
let recent_messages: Vec<&ChatMessage> = history
.iter()
.rev()
.filter(|message| message.role != "system")
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let mut lines = vec!["请基于以下最近会话生成长期记忆搜索关键词:".to_string()];
for message in recent_messages {
let content = message.content.trim();
if content.is_empty() {
continue;
}
let compact = if content.chars().count() > 240 {
let prefix: String = content.chars().take(240).collect();
format!("{}...", prefix)
} else {
content.to_string()
};
lines.push(format!("{}: {}", message.role, compact));
}
lines.join("\n")
}
fn parse_memory_keywords(raw: &str) -> Vec<String> {
if let Ok(keywords) = serde_json::from_str::<Vec<String>>(raw) {
return normalize_keywords(keywords);
}
normalize_keywords(
raw.split(|ch| matches!(ch, '\n' | ',' | '' | ';' | ''))
.map(str::trim)
.filter(|part| !part.is_empty())
.map(ToOwned::to_owned)
.collect(),
)
}
fn normalize_keywords(keywords: Vec<String>) -> Vec<String> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for keyword in keywords {
let candidate = keyword
.trim()
.trim_matches('"')
.trim_matches('[')
.trim_matches(']')
.trim()
.to_string();
let candidate = compact_memory_keyword(&candidate);
if candidate.is_empty() {
continue;
}
let key = candidate.to_lowercase();
if seen.insert(key) {
normalized.push(candidate);
}
if normalized.len() >= 6 {
break;
}
}
normalized
}
fn compact_memory_keyword(candidate: &str) -> String {
let compact = candidate
.split_whitespace()
.next()
.unwrap_or(candidate)
.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '[' | ']' | '。' | '' | ',' | '' | ';' | ':' | ''))
.trim();
if compact.is_empty() {
return String::new();
}
compact.chars().take(MEMORY_KEYWORD_MAX_CHARS).collect()
}
async fn search_related_memories(
store: Arc<SessionStore>,
channel_name: &str,
sender_id: &str,
chat_id: &str,
keywords: &[String],
) -> Result<Vec<MemoryRecord>, AgentError> {
tracing::debug!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
keyword_count = keywords.len(),
keywords = ?keywords,
"Searching related memories with a single batched FTS query"
);
let scope_key = format!("{}:{}", channel_name, sender_id);
let merged = store
.search_memories_any("user", &scope_key, keywords, None, 10)
.map_err(|err| AgentError::Other(format!("batched memory search error: {}", err)))?;
tracing::info!(
channel = %channel_name,
chat_id = %chat_id,
sender_id = %sender_id,
keyword_count = keywords.len(),
deduped_memory_count = merged.len(),
"Finished related memory search aggregation"
);
Ok(merged)
}
fn format_related_memory_system_prompt(keywords: &[String], memories: &[MemoryRecord]) -> String {
let mut lines = vec![
RELATED_MEMORY_SYSTEM_PROMPT_PREFIX.to_string(),
format!("检索关键词: {}", keywords.join(" / ")),
"相关记忆: ".to_string(),
];
for (index, memory) in memories.iter().take(8).enumerate() {
lines.push(format!(
"{}. [{} / {}] {}",
index + 1,
memory.namespace,
memory.memory_key,
memory.content.replace('\n', " ").trim()
));
}
lines.join("\n")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1162,42 +894,4 @@ mod tests {
assert_eq!(history[0].role, "system"); assert_eq!(history[0].role, "system");
} }
#[test]
fn test_parse_memory_keywords_handles_json_and_dedup() {
let keywords = parse_memory_keywords("[\"Rust\", \"偏好\", \"rust\", \"自动化\"]");
assert_eq!(keywords, vec!["Rust", "偏好", "自动化"]);
let fallback = parse_memory_keywords("Rust, 偏好\n自动化");
assert_eq!(fallback, vec!["Rust", "偏好", "自动化"]);
let compacted = parse_memory_keywords("[\"用户 身份 信息 长描述\", \"email_folder_preference details\"]");
assert_eq!(compacted, vec!["用户", "email_folder_preference"]);
}
#[test]
fn test_format_related_memory_system_prompt_includes_prefix_keywords_and_memory_lines() {
let prompt = format_related_memory_system_prompt(
&["Rust 偏好".to_string(), "审批项目".to_string()],
&[MemoryRecord {
id: "memory-1".to_string(),
scope_kind: "user".to_string(),
scope_key: "feishu:user-1".to_string(),
namespace: "profile".to_string(),
memory_key: "language".to_string(),
content: "用户偏好 Rust 和自动化工具".to_string(),
source_type: "message".to_string(),
source_session_id: Some("feishu:chat-1".to_string()),
source_message_id: Some("msg-1".to_string()),
source_message_seq: Some(1),
source_channel_name: Some("feishu".to_string()),
source_chat_id: Some("chat-1".to_string()),
created_at: 1,
updated_at: 1,
}],
);
assert!(prompt.contains("找到相关的记忆"));
assert!(prompt.contains("Rust 偏好 / 审批项目"));
assert!(prompt.contains("[profile / language] 用户偏好 Rust 和自动化工具"));
}
} }