T2: 递归再压缩 + Semantica式结构化长期记忆层(结构化记忆列/抽取/注入)

This commit is contained in:
qihu-dev committed 2026-07-21 12:38:59 +08:00
1 parent 6890570142
commit e30e385f11
2 files changed
+201 -113

No files matched your search

+200 -113
View File
@@ -2526,97 +2526,87 @@ def _estimate_tokens(text):
def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
exclude_latest_user=True, include_summary=True): exclude_latest_user=True, include_summary=True):
"""会话层上下文引擎:预算感知的历史组装 + 压缩摘要/结构化长期记忆前置。
"""会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。 - 若 conversation_summaries 存在结构化长期记忆(structured_memory),以紧凑
结构化区块前置(Semantica 式:实体/事实/偏好/决策/待办),比扁平摘要更省
token 且更便于模型检索关键信息。
- 否则回退到扁平 summary_text 前置。
- 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
""" """
ensure_structured_column()
summary_block = None summary_block = None
if include_summary: if include_summary:
try: try:
summ = db.query(ConversationSummary).filter( summ = db.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id ConversationSummary.conversation_id == conv_id
).first() ).first()
if summ and summ.structured_memory and isinstance(summ.structured_memory, dict):
if summ and summ.summary_text: block = _format_structured_memory(summ.structured_memory)
if block:
summary_block = { summary_block = {
"role": "system", "role": "system",
"content": "【长期记忆·结构化】请据此理解用户此前的偏好、已确认决策与待办:\n" + block,
}
elif summ and summ.summary_text:
summary_block = {
"role": "system",
"content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text,
} }
except Exception: except Exception:
pass pass
rows = db.query(Message).filter( rows = db.query(Message).filter(
Message.conversation_id == conv_id, Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]), Message.role.in_(["user", "assistant"]),
).order_by(desc(Message.created_at)).limit(max_messages).all() ).order_by(desc(Message.created_at)).limit(max_messages).all()
if exclude_latest_user: if exclude_latest_user:
for i, m in enumerate(rows): for i, m in enumerate(rows):
if m.role == "user": if m.role == "user":
rows = rows[:i] + rows[i + 1:] rows = rows[:i] + rows[i + 1:]
break break
kept = [] kept = []
used = 0 used = 0
for m in rows: # 新->旧 for m in rows: # 新->旧
t = _estimate_tokens(m.content or "") t = _estimate_tokens(m.content or "")
if kept and used + t > max_tokens: if kept and used + t > max_tokens:
break break
kept.append({"role": m.role, "content": m.content}) kept.append({"role": m.role, "content": m.content})
used += t used += t
kept.reverse() # 时间正序 kept.reverse() # 时间正序
out = [] out = []
if summary_block: if summary_block:
out.append(summary_block) out.append(summary_block)
out.extend(kept) out.extend(kept)
return out return out
def _format_structured_memory(mem: dict) -> str:
"""将结构化记忆格式化为紧凑可读区块。"""
labels = {
"entities": "实体",
"facts": "事实",
"preferences": "偏好",
"decisions": "决策",
"open_tasks": "待办",
}
lines = []
for key, label in labels.items():
items = mem.get(key)
if not items:
continue
if isinstance(items, list):
for it in items:
if it:
lines.append(f"· {label}: {it}")
elif isinstance(items, str):
lines.append(f"· {label}: {items}")
return "\n".join(lines)
async def _summarize_via_llm(prompt): async def _summarize_via_llm(prompt):
"""调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。"""
@@ -2703,117 +2693,214 @@ async def _summarize_via_llm(prompt):
_STRUCT_COL_READY = False
def ensure_structured_column():
"""幂等地为 conversation_summaries 增加 structured_memory 列(MariaDB 安全)。"""
global _STRUCT_COL_READY
if _STRUCT_COL_READY:
return True
try:
from app.database import engine
from sqlalchemy import text
with engine.connect() as conn:
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
"AND column_name='structured_memory'"
)).scalar()
if not exists:
conn.execute(text(
"ALTER TABLE conversation_summaries ADD COLUMN structured_memory JSON DEFAULT NULL"
))
conn.commit()
_STRUCT_COL_READY = True
return True
except Exception as e:
logger.warning(f"[context] ensure structured column failed: {e}")
return False
async def _recursive_compress(text, depth=0, max_depth=3, budget=700):
"""递归再压缩:若摘要仍超过 token 预算,则进一步浓缩,直至收敛或达最大深度。"""
if not text:
return text
if _estimate_tokens(text) <= budget or depth >= max_depth:
return text
s = await _summarize_via_llm("请将以下摘要进一步浓缩,保留全部关键信息,篇幅更短:\n" + text)
if not s:
return text
return await _recursive_compress(s, depth + 1, max_depth, budget)
async def _extract_structured_memory(text):
"""Semantica 式结构化长期记忆抽取:将对话内容抽取为类型化结构。
模型对抽取类提示偶发返回空,故做最多 3 次重试;并兼容有标签与无标签两种输出。
"""
if not text or len(text.strip()) < 10:
return None
prompt = (
"提取要点,按 实体/事实/偏好/决策/待办 五类,用分号分隔输出:" + text[:6000]
)
raw = None
for _ in range(3):
r = await _summarize_via_llm(prompt)
if r and r.strip():
raw = r.strip()
break
if not raw:
return None
# JSON 回退
if "{" in raw:
try:
s = raw.find("{")
e = raw.rfind("}")
data = json.loads(raw[s:e + 1])
if isinstance(data, dict):
return _normalize_structured(data)
except Exception:
pass
norm = raw.replace(":", ":").replace(";", ";").replace(",", ",").replace("。", "")
result = _parse_structured_segments(norm)
if result:
return result
# 无标签回退:按分号切分,按顺序映射到五类
segs = [s.strip() for s in norm.replace(chr(10), ";").split(";") if s.strip()]
if segs:
cats = ["entities", "facts", "preferences", "decisions", "open_tasks"]
fb = {}
for i, s in enumerate(segs[:5]):
fb[cats[i]] = [s]
return fb
return None
def _parse_structured_segments(norm):
"""解析『类别: 内容』格式(兼容全角冒号/分号与单行多类别)。"""
result = {}
for seg in norm.replace(chr(10), ";").split(";"):
if ":" not in seg:
continue
key, _, val = seg.partition(":")
key = key.strip()
val = val.strip().strip(",")
if not val or val in ("无", "none", "None"):
continue
mapped = {
"实体": "entities", "事实": "facts", "偏好": "preferences",
"决策": "decisions", "待办": "open_tasks",
}.get(key)
if mapped:
items = [x.strip() for x in val.split(",") if x.strip()]
if items:
result[mapped] = items
return result
def _normalize_structured(data):
"""将模型的 JSON 输出规范化为结构化记忆字典。"""
keys = ["entities", "facts", "preferences", "decisions", "open_tasks"]
out = {}
for k in keys:
v = data.get(k)
if isinstance(v, list):
out[k] = [str(x) for x in v if x]
elif isinstance(v, str) and v:
out[k] = [v]
return out
def _merge_structured_memory(old, new):
"""合并新旧结构化记忆,按字符串去重并控制规模。"""
keys = ["entities", "facts", "preferences", "decisions", "open_tasks"]
merged = {}
for k in keys:
a = old.get(k) if isinstance(old, dict) else None
b = new.get(k) if isinstance(new, dict) else None
items = []
for x in (a or []) + (b or []):
if x and x not in items:
items.append(x)
merged[k] = items[:50]
return merged
async def _maybe_compress_history(conv_id): async def _maybe_compress_history(conv_id):
"""后台异步压缩:将受保护窗口外的旧消息递归再压缩,并抽取结构化长期记忆。
"""后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
""" """
ensure_structured_column()
sdb = None sdb = None
try: try:
sdb = SessionLocal() sdb = SessionLocal()
all_msgs = sdb.query(Message).filter( all_msgs = sdb.query(Message).filter(
Message.conversation_id == conv_id, Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]), Message.role.in_(["user", "assistant"]),
).order_by(asc(Message.created_at)).all() ).order_by(asc(Message.created_at)).all()
PROTECTED = 20 PROTECTED = 20
if len(all_msgs) <= PROTECTED: if len(all_msgs) <= PROTECTED:
return return
compressible = all_msgs[: len(all_msgs) - PROTECTED] compressible = all_msgs[: len(all_msgs) - PROTECTED]
if len(compressible) < 8: if len(compressible) < 8:
return return
chunk = compressible[:30] # 1) 分块摘要:覆盖全部可压缩消息(而非仅前 30 条)
CHUNK = 30
text = "\n".join(f"{m.role}: {m.content}" for m in chunk) chunk_summaries = []
for i in range(0, len(compressible), CHUNK):
piece = compressible[i:i + CHUNK]
text = "\n".join(f"{m.role}: {m.content}" for m in piece)
s = await _summarize_via_llm("请将以下对话片段浓缩为简明要点(保留关键事实、诉求与结论):\n" + text)
if s:
chunk_summaries.append(s)
existing = sdb.query(ConversationSummary).filter( existing = sdb.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id ConversationSummary.conversation_id == conv_id
).first() ).first()
prior = existing.summary_text if existing else "" prior = existing.summary_text if existing else ""
prompt = ( combined = "\n".join(chunk_summaries)
if prior:
combined = prior + "\n" + combined
"请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。" # 2) 递归再压缩:合并摘要超预算时逐层再压缩直到收敛
final_summary = await _recursive_compress(combined)
"若提供『已有摘要』,请合并而非重复。\n\n" # 3) 结构化长期记忆抽取(Semantica 式),并与既有记忆合并
structured = await _extract_structured_memory(combined)
f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}" if structured is None:
structured = {}
) if existing and existing.structured_memory and isinstance(existing.structured_memory, dict):
structured = _merge_structured_memory(existing.structured_memory, structured)
summary = await _summarize_via_llm(prompt)
if not summary:
return
now = datetime.now()
if existing: if existing:
existing.summary_text = final_summary
existing.summary_text = summary existing.structured_memory = structured
existing.token_count = _estimate_tokens(final_summary)
existing.token_count = _estimate_tokens(summary) existing.updated_at = now
existing.updated_at = datetime.now()
else: else:
sdb.add(ConversationSummary( sdb.add(ConversationSummary(
conversation_id=conv_id, conversation_id=conv_id,
summary_text=final_summary,
summary_text=summary, structured_memory=structured,
token_count=_estimate_tokens(final_summary),
token_count=_estimate_tokens(summary), updated_at=now,
updated_at=datetime.now(),
)) ))
sdb.commit() sdb.commit()
except Exception as e: except Exception as e:
logger.warning(f"[context] compress history failed conv={conv_id}: {e}") logger.warning(f"[context] compress history failed conv={conv_id}: {e}")
if sdb is not None: if sdb is not None:
try: try:
sdb.rollback() sdb.rollback()
except Exception: except Exception:
pass pass
finally: finally:
if sdb is not None: if sdb is not None:
try: try:
sdb.close() sdb.close()
except Exception: except Exception:
pass pass
+1
View File
@@ -1156,4 +1156,5 @@ class ConversationSummary(Base):
conversation_id = Column(String(64), primary_key=True) conversation_id = Column(String(64), primary_key=True)
summary_text = Column(Text, nullable=False, default="") summary_text = Column(Text, nullable=False, default="")
token_count = Column(Integer, default=0) token_count = Column(Integer, default=0)
structured_memory = Column(JSON, default=None) # T2: Semantica 式结构化长期记忆
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)