refactor(context): Phase2 上下文引擎升级 分层记忆+压缩+隔离 [v2026.07.19.4]
CI / gate (push) Has been cancelled

This commit is contained in:
QihuAI Design committed 2026-07-19 09:29:57 +08:00
1 parent a7a57f9a03
commit 491f31cead
7 files changed
+4813 -4601

No files matched your search

+161 -1
View File
@@ -20,7 +20,7 @@ from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary,
@@ -1230,3 +1230,163 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict:
return context
# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 =====
logger = logging.getLogger(__name__)
def _estimate_tokens(text):
"""粗略 token 估算:中英文混排约 2 字符/token。"""
if not text:
return 0
return max(1, len(text) // 2)
def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
exclude_latest_user=True, include_summary=True):
"""会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。
- 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
"""
summary_block = None
if include_summary:
try:
summ = db.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
if summ and summ.summary_text:
summary_block = {
"role": "system",
"content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text,
}
except Exception:
pass
rows = db.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(desc(Message.created_at)).limit(max_messages).all()
if exclude_latest_user:
for i, m in enumerate(rows):
if m.role == "user":
rows = rows[:i] + rows[i + 1:]
break
kept = []
used = 0
for m in rows: # 新->旧
t = _estimate_tokens(m.content or "")
if kept and used + t > max_tokens:
break
kept.append({"role": m.role, "content": m.content})
used += t
kept.reverse() # 时间正序
out = []
if summary_block:
out.append(summary_block)
out.extend(kept)
return out
async def _summarize_via_llm(prompt):
"""调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。"""
try:
from ..capability_registry import resolve_auto_model
from app.api.chat_utils import _proxy_for_url
sdb = SessionLocal()
try:
ai_model = resolve_auto_model("text", sdb)
if not ai_model:
return None
provider = sdb.query(ModelProvider).filter(
ModelProvider.id == ai_model.provider_id
).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
if not provider or not provider.api_base_url or not api_key:
return None
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
body = {
"model": ai_model.name,
"messages": [
{"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"},
{"role": "user", "content": prompt},
],
"max_tokens": 512,
"temperature": 0.2,
}
async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client:
resp = await client.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json=body,
)
if resp.status_code != 200:
return None
data = resp.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
finally:
sdb.close()
except Exception as e:
logger.warning(f"[context] summarize llm failed: {e}")
return None
async def _maybe_compress_history(conv_id):
"""后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
"""
sdb = None
try:
sdb = SessionLocal()
all_msgs = sdb.query(Message).filter(
Message.conversation_id == conv_id,
Message.role.in_(["user", "assistant"]),
).order_by(asc(Message.created_at)).all()
PROTECTED = 20
if len(all_msgs) <= PROTECTED:
return
compressible = all_msgs[: len(all_msgs) - PROTECTED]
if len(compressible) < 8:
return
chunk = compressible[:30]
text = "\n".join(f"{m.role}: {m.content}" for m in chunk)
existing = sdb.query(ConversationSummary).filter(
ConversationSummary.conversation_id == conv_id
).first()
prior = existing.summary_text if existing else ""
prompt = (
"请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。"
"若提供『已有摘要』,请合并而非重复。\n\n"
f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}"
)
summary = await _summarize_via_llm(prompt)
if not summary:
return
if existing:
existing.summary_text = summary
existing.token_count = _estimate_tokens(summary)
existing.updated_at = datetime.now()
else:
sdb.add(ConversationSummary(
conversation_id=conv_id,
summary_text=summary,
token_count=_estimate_tokens(summary),
updated_at=datetime.now(),
))
sdb.commit()
except Exception as e:
logger.warning(f"[context] compress history failed conv={conv_id}: {e}")
if sdb is not None:
try:
sdb.rollback()
except Exception:
pass
finally:
if sdb is not None:
try:
sdb.close()
except Exception:
pass