memory.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. # backend/core/memory.py
  2. from typing import Optional
  3. from langgraph.checkpoint.memory import MemorySaver
  4. from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
  5. from backend.core.logger import get_logger
  6. logger = get_logger(__name__)
  7. # 每个 Agent 独立的 MemorySaver 实例
  8. # 不同 Agent 的 State schema 不同,共用同一个 MemorySaver 会导致
  9. # msgpack 序列化时 schema 字段冲突,必须隔离
  10. _memory_savers: dict[str, MemorySaver] = {}
  11. def get_memory_saver(agent_type: str = "default") -> MemorySaver:
  12. """
  13. 获取指定 Agent 类型的 MemorySaver 单例。
  14. 本地阶段使用内存存储(进程重启后历史丢失)。
  15. 生产阶段替换为 AsyncPostgresSaver 即可持久化,业务代码无需修改。
  16. Args:
  17. agent_type: Agent 标识符,如 "qa" / "bid_review" / "qualification" / "defense"
  18. Returns:
  19. MemorySaver 实例,传给 StateGraph.compile(checkpointer=...)
  20. """
  21. if agent_type not in _memory_savers:
  22. _memory_savers[agent_type] = MemorySaver()
  23. logger.info("memory.saver_initialized", agent=agent_type)
  24. return _memory_savers[agent_type]
  25. def build_thread_id(bidder_id: str, session_id: str) -> str:
  26. """
  27. 构建 LangGraph Checkpointer 使用的 thread_id。
  28. 格式:bidder_{bidder_id}_session_{session_id}
  29. 同一投标人的不同会话有独立的历史,互不干扰。
  30. BidReviewple:
  31. build_thread_id("abc123", "xyz789")
  32. → "bidder_abc123_session_xyz789"
  33. """
  34. return f"bidder_{bidder_id}_session_{session_id}"
  35. def build_config(bidder_id: str, session_id: str) -> dict:
  36. """
  37. 构建 LangGraph 调用所需的 config 字典。
  38. 用法:
  39. config = build_config(bidder_id, session_id)
  40. result = await graph.ainvoke(state, config=config)
  41. Returns:
  42. {"configurable": {"thread_id": "bidder_xxx_session_yyy"}}
  43. """
  44. return {
  45. "configurable": {
  46. "thread_id": build_thread_id(bidder_id, session_id),
  47. }
  48. }
  49. def trim_messages_to_window(
  50. messages: list[BaseMessage],
  51. window_size: int = 10,
  52. ) -> list[BaseMessage]:
  53. """
  54. 滑动窗口裁剪:保留最近 window_size 轮对话。
  55. SystemMessage 始终保留在最前,不受 window_size 限制。
  56. Args:
  57. messages: 当前完整消息列表
  58. window_size: 保留的对话轮数(1轮 = 1 Human + 1 AI),默认 10 轮
  59. Returns:
  60. 裁剪后的消息列表
  61. """
  62. system_messages = [m for m in messages if isinstance(m, SystemMessage)]
  63. # print(f'system_messages: {system_messages}')
  64. dialogue_messages = [m for m in messages if not isinstance(m, SystemMessage)]
  65. # print(f'dialogue_messages: {dialogue_messages}')
  66. max_dialogue_messages = window_size * 2 # 1轮 = 2条消息
  67. if len(dialogue_messages) <= max_dialogue_messages:
  68. return messages # 未超出窗口,不裁剪
  69. trimmed = dialogue_messages[-max_dialogue_messages:]
  70. logger.info(
  71. "memory.window_trimmed",
  72. original=len(dialogue_messages),
  73. kept=len(trimmed),
  74. window_size=window_size,
  75. )
  76. return system_messages + trimmed
  77. def should_trigger_summary(
  78. messages: list[BaseMessage],
  79. threshold: int = 10,
  80. ) -> bool:
  81. """
  82. 判断对话轮数是否超过阈值,决定是否触发摘要压缩。
  83. Args:
  84. messages: 当前消息列表
  85. threshold: 触发压缩的轮数阈值,默认 10 轮
  86. Returns:
  87. True → 需要压缩
  88. """
  89. dialogue_count = sum(1 for m in messages if isinstance(m, (HumanMessage, AIMessage)))
  90. # print(f'dialogue_count: {dialogue_count}')
  91. # rounds = dialogue_count // 2 # 每轮2条
  92. #
  93. return dialogue_count % ( threshold * 2 ) == 0 # 每轮 2 条,dialogue_count / 2 = 实际轮数
  94. async def compress_to_summary(
  95. messages: list[BaseMessage],
  96. existing_summary: Optional[str] = None,
  97. ) -> str:
  98. """
  99. 将历史对话压缩为结构化投标人画像摘要。
  100. 增量压缩:传入 existing_summary 防止已记录内容被重复写入。
  101. Args:
  102. messages: 待压缩的历史消息列表
  103. existing_summary: 上次的摘要(可选)
  104. Returns:
  105. 压缩后的摘要文本
  106. """
  107. from langchain_core.messages import HumanMessage as LCHuman
  108. from backend.core.llm_factory import get_llm
  109. SUMMARY_PROMPT = """请将以下投标人对话压缩为结构化投标人画像摘要。
  110. 【压缩规则】
  111. 必须保留:投标人明确不理解的评审要点 / 反复出现的薄弱点 / 项目背景新增信息
  112. 选择性保留:已掌握评审要点(简短标注)/ 学习进度信息
  113. 可以丢弃:已在上次摘要记录的内容 / 闲聊 / 已解决且理解的问题
  114. 【上一次摘要】
  115. {previous_summary}
  116. 【本次新增对话】
  117. {new_conversations}
  118. 请直接输出摘要文本,不要加任何前缀。"""
  119. conversation_text = "\n".join([
  120. f"{'投标人' if isinstance(m, HumanMessage) else 'AI'}:{m.text if hasattr(m, 'text') and not callable(m.text) else str(m.content)}"
  121. for m in messages
  122. if isinstance(m, (HumanMessage, AIMessage))
  123. ])
  124. prompt_text = SUMMARY_PROMPT.format(
  125. previous_summary=existing_summary or "(无上次摘要)",
  126. new_conversations=conversation_text,
  127. )
  128. llm = get_llm("summarize")
  129. response = await llm.ainvoke([LCHuman(content=prompt_text)])
  130. summary = (
  131. response.content if isinstance(response.content, str)
  132. else str(response.content)
  133. ).strip()
  134. logger.info(
  135. "memory.summary_generated",
  136. input_messages=len(messages),
  137. summary_length=len(summary),
  138. )
  139. return summary
  140. if __name__ == '__main__':
  141. # print(build_config(bidder_id="abc123", session_id="xyz789"))
  142. # print(get_memory_saver(agent_type="qa"))
  143. # ── 测试 2:滑动窗口裁剪 ─────────────────────────────────────
  144. messages = [SystemMessage(content="你是评审助手")]
  145. print(messages[0].text)
  146. print(messages[0].content)
  147. for i in range(1, 7): # 模拟 6 轮对话
  148. messages.append(HumanMessage(content=f"问题{i}"))
  149. messages.append(AIMessage(content=f"回答{i}"))
  150. # # print(f'messages: {messages}')
  151. # # trimmed = trim_messages_to_window(messages, window_size=3)
  152. # # print(f'trimmed: {trimmed}')
  153. # print(should_trigger_summary(messages, threshold=10))
  154. import asyncio
  155. async def main():
  156. if should_trigger_summary(messages, threshold=3):
  157. summary = await compress_to_summary(messages)
  158. print(f'summary:{summary}')
  159. asyncio.run(main())