diff --git a/app/agent/middleware/activity_log.py b/app/agent/middleware/activity_log.py index ea3e3d6d..461aa677 100644 --- a/app/agent/middleware/activity_log.py +++ b/app/agent/middleware/activity_log.py @@ -2,7 +2,7 @@ 活动日志中间件 - 自动记录 Agent 每次交互的操作摘要。 按日期存储在 CONFIG_PATH/agent/activity/YYYY-MM-DD.md 中, -每次 Agent 执行完毕后自动追加一条活动记录, +每次 Agent 执行完毕后自动调用 LLM 对本轮对话生成简洁的活动摘要, 并在每次 Agent 启动时加载近几天的活动日志注入系统提示词。 """ @@ -21,7 +21,7 @@ from langchain.agents.middleware.types import ( PrivateStateAttr, # noqa ResponseT, ) -from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langgraph.runtime import Runtime from app.agent.middleware.utils import append_to_system_message @@ -30,15 +30,23 @@ from app.log import logger # 活动日志保留天数 DEFAULT_RETENTION_DAYS = 7 -# 单次活动记录的最大长度 -MAX_ENTRY_LENGTH = 300 - # 注入系统提示词时加载的天数 PROMPT_LOAD_DAYS = 3 # 每日日志文件最大大小 (256KB) MAX_LOG_FILE_SIZE = 256 * 1024 +# 提取本轮对话上下文的最大字符数(避免过长的对话消耗太多 token) +MAX_CONTEXT_FOR_SUMMARY = 4000 + +# LLM 总结的提示词 +SUMMARY_PROMPT = """请根据以下 AI 助手与用户的对话记录,生成一条简洁的活动摘要(中文,一句话,不超过80字)。 +摘要应包含:用户的需求是什么、助手做了什么、结果如何。 +只输出摘要内容,不要加任何前缀、标点序号或解释。 + +对话记录: +{conversation}""" + class ActivityLogState(AgentState): """ActivityLogMiddleware 的状态模型。""" @@ -53,22 +61,21 @@ class ActivityLogStateUpdate(TypedDict): activity_log_contents: dict[str, str] -def _extract_activity_summary(messages: list) -> str | None: - """从本次对话的消息列表中提取活动摘要。 +def _extract_last_round(messages: list) -> list | None: + """从完整消息列表中提取最后一轮交互。 - 只关注最后一轮交互(从最后一条用户消息到结尾), - 分析用户问题、Agent 使用的工具、最终回复,生成一行简洁的活动描述。 + 从最后一条 HumanMessage 到消息末尾即为本轮交互。 参数: messages: Agent 执行后的完整消息列表。 返回: - 活动摘要字符串,如果没有有意义的活动则返回 None。 + 本轮交互的消息子列表,如果无有效交互则返回 None。 """ if not messages: return None - # 找到最后一条用户消息的索引,从此处开始截取本轮交互 + # 找到最后一条用户消息的索引 last_human_idx = None for i in range(len(messages) - 1, -1, -1): if isinstance(messages[i], HumanMessage) and messages[i].content: @@ -78,52 +85,91 @@ def _extract_activity_summary(messages: list) -> str | None: if last_human_idx is None: return None - # 本轮交互的消息 round_messages = messages[last_human_idx:] - # 提取用户问题 + # 检查是否为系统心跳消息 user_msg = round_messages[0] user_content = ( user_msg.content if isinstance(user_msg.content, str) else str(user_msg.content) ) - - # 跳过系统心跳消息 if user_content.strip().startswith("[System Heartbeat]"): return None - user_query = user_content.strip()[:100] + return round_messages + + +def _format_conversation_for_summary(round_messages: list) -> str: + """将本轮对话消息格式化为文本,供 LLM 总结。 + + 参数: + round_messages: 本轮交互的消息列表。 + + 返回: + 格式化后的对话文本。 + """ + lines = [] + total_len = 0 - # 收集本轮交互中使用的工具(仅限本轮) - tool_names = set() for msg in round_messages: - if isinstance(msg, AIMessage) and hasattr(msg, "tool_calls") and msg.tool_calls: - for tc in msg.tool_calls: - if isinstance(tc, dict) and "name" in tc: - tool_names.add(tc["name"]) - - # 提取本轮最后一条 AI 回复的摘要 - ai_reply = None - for msg in reversed(round_messages): - if ( - isinstance(msg, AIMessage) - and msg.content - and not (hasattr(msg, "tool_calls") and msg.tool_calls) - ): + if isinstance(msg, HumanMessage): content = msg.content if isinstance(msg.content, str) else str(msg.content) - ai_reply = content.strip()[:120] + line = f"用户: {content}" + elif isinstance(msg, AIMessage): + if hasattr(msg, "tool_calls") and msg.tool_calls: + tool_names = [ + tc["name"] + for tc in msg.tool_calls + if isinstance(tc, dict) and "name" in tc + ] + line = f"助手调用工具: {', '.join(tool_names)}" + elif msg.content: + content = ( + msg.content if isinstance(msg.content, str) else str(msg.content) + ) + line = f"助手: {content}" + else: + continue + elif isinstance(msg, ToolMessage): + content = msg.content if isinstance(msg.content, str) else str(msg.content) + # 工具返回可能很长,截断 + if len(content) > 200: + content = content[:200] + "..." + line = f"工具返回: {content}" + else: + continue + + # 控制总长度 + if total_len + len(line) > MAX_CONTEXT_FOR_SUMMARY: + lines.append("...(后续对话省略)") break + lines.append(line) + total_len += len(line) - # 组装摘要 - parts = [f"用户: {user_query}"] - if tool_names: - parts.append(f"工具: {', '.join(sorted(tool_names))}") - if ai_reply: - parts.append(f"结果: {ai_reply}") + return "\n".join(lines) - summary = " | ".join(parts) - if len(summary) > MAX_ENTRY_LENGTH: - summary = summary[: MAX_ENTRY_LENGTH - 3] + "..." - return summary + +async def _summarize_with_llm(conversation_text: str) -> str | None: + """调用 LLM 对对话文本生成活动摘要。 + + 参数: + conversation_text: 格式化后的对话文本。 + + 返回: + LLM 生成的摘要字符串,失败时返回 None。 + """ + try: + from app.helper.llm import LLMHelper + + llm = LLMHelper.get_llm(streaming=False) + prompt = SUMMARY_PROMPT.format(conversation=conversation_text) + response = await llm.ainvoke(prompt) + summary = response.content.strip() + # 清理模型可能输出的前缀(如 "摘要:" "总结:") + summary = re.sub(r"^(摘要|总结|活动记录)[::]\s*", "", summary) + return summary if summary else None + except Exception as e: + logger.debug("LLM summarization failed: %s", e) + return None ACTIVITY_LOG_SYSTEM_PROMPT = """ @@ -331,14 +377,24 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response async def aafter_agent( self, state: ActivityLogState, runtime: Runtime ) -> dict[str, Any] | None: - """Agent 执行完毕后,从对话中提取摘要并追加到当日活动日志。""" + """Agent 执行完毕后,调用 LLM 对本轮对话生成摘要并追加到当日活动日志。""" try: messages = state.get("messages", []) if not messages: return None - # 提取活动摘要 - summary = _extract_activity_summary(messages) + # 提取本轮交互 + round_messages = _extract_last_round(messages) + if not round_messages: + return None + + # 格式化对话文本 + conversation_text = _format_conversation_for_summary(round_messages) + if not conversation_text: + return None + + # 调用 LLM 生成摘要 + summary = await _summarize_with_llm(conversation_text) if summary: await self._append_activity(summary) except Exception as e: