fix:记忆架构优化

This commit is contained in:
2026-09-13 21:22:54 +08:00
parent dd281b3361
commit 41284f0bb1
8 changed files with 354 additions and 11 deletions
+65 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import contextvars
import logging
import uuid
@@ -72,13 +73,20 @@ class MemoryConversationContext:
class MemoryAwareClientAgent:
"""在现有客服 Agent 外包裹记忆召回、候选保存和降级处理。"""
"""在现有客服 Agent 外包裹记忆召回、候选保存和降级处理。
def __init__(self, *, agent, memory_service, context, extractor=None):
候选记忆保存默认放入后台任务执行(background_saves=True),不阻塞
客服响应;测试或需要确定性顺序的场景可设为 False 改回同步执行。
"""
def __init__(self, *, agent, memory_service, context, extractor=None,
background_saves: bool = True):
self.agent = agent
self.memory_service = memory_service
self.context = context
self.extractor = extractor
self.background_saves = background_saves
self._pending_saves: set[asyncio.Task] = set()
async def handle(self, session_id: str, query: str, *, trace_id: str, customer_id: int) -> dict:
"""执行记忆召回、客服回答、消息写入和候选记忆保存。"""
@@ -107,7 +115,7 @@ class MemoryAwareClientAgent:
result = await self.agent.handle(
session_id, query, trace_id=trace_id, customer_id=customer_id
)
if self.extractor is not None:
if self.extractor is not None and not self.background_saves:
await self._save_candidates(
customer_id,
session_id,
@@ -117,6 +125,15 @@ class MemoryAwareClientAgent:
trace_id=trace_id,
)
result["memory_warnings"] = list(warnings)
if self.extractor is not None and self.background_saves:
self._spawn_save(
customer_id,
session_id,
query,
memory_context,
warnings,
trace_id=trace_id,
)
return result
finally:
_active_customer.reset(message_token)
@@ -124,6 +141,35 @@ class MemoryAwareClientAgent:
_active_warnings.reset(warnings_token)
_active_memory_context.reset(context_token)
def _spawn_save(
self,
customer_id,
session_id,
query,
context,
warnings,
*,
trace_id: str | None = None,
) -> None:
"""把候选记忆保存放入后台任务;任务异常已自捕获,不击穿响应。"""
task = asyncio.create_task(
self._save_candidates(
customer_id,
session_id,
query,
context,
warnings,
trace_id=trace_id,
)
)
self._pending_saves.add(task)
task.add_done_callback(self._pending_saves.discard)
async def wait_for_pending_saves(self) -> None:
"""等待全部后台保存完成,供测试与优雅退出使用。"""
if self._pending_saves:
await asyncio.gather(*list(self._pending_saves), return_exceptions=True)
async def _save_candidates(
self,
customer_id,
@@ -157,9 +203,24 @@ class MemoryAwareClientAgent:
try:
evidence_count = 1
if candidate.get("signal_type") == "interest_query":
# 兴趣主题首次出现即保存为候选,后续由长期记忆按精确内容合并证据。
# 兴趣主题先计数:达到阈值才固化为长期记忆,避免单次关注污染画像。
candidate["memory_type"] = "CUSTOMER_PREFERENCE"
candidate["source"] = "dialogue_inferred"
try:
_, reached = await self.memory_service.record_interest_signal(
customer_id=customer_id, tag=candidate["tag"]
)
except Exception as exc:
logger.exception(
"client interest signal failed: trace_id=%s customer_id=%s tag=%s",
trace_id,
customer_id,
candidate.get("tag"),
)
warnings.append(f"interest_signal_failed:{type(exc).__name__}")
continue
if not reached:
continue
memory = MemoryUnitDTO(
customer_id=customer_id,
session_id=session_id,
+1 -1
View File
@@ -97,7 +97,7 @@ class MemoryService:
warnings.append(f"customer_product_recall_failed:{type(exc).__name__}")
try:
memories, memory_warnings = await self.long_term.recall(
db, customer_id, limit=max(limit, 10)
db, customer_id, limit=max(limit, 10), query=query
)
warnings.extend(memory_warnings)
except Exception as exc:
+42 -4
View File
@@ -51,10 +51,10 @@ class LongTermMemoryService:
)
entity = existing
else:
# final_score 是召回重排阶段的临时分数,不属于 MySQL 主体字段。
# final_score/semantic_similarity 是召回重排阶段的临时分数,不属于 MySQL 主体字段。
values = memory.model_dump(
mode="json",
exclude={"id", "milvus_id", "graph_node_id", "final_score"},
exclude={"id", "milvus_id", "graph_node_id", "final_score", "semantic_similarity"},
)
now = datetime.now()
values["last_verified_at"] = values.get("last_verified_at") or now
@@ -147,12 +147,50 @@ class LongTermMemoryService:
memory_type: str | None = None,
tag: str | None = None,
limit: int = 100,
query: str | None = None,
) -> tuple[list[MemoryUnitDTO], list[str]]:
"""按客户、类型、标签和有效期召回主体记忆。"""
"""召回主体记忆;带 query 时叠加 Milvus 语义召回并标注相似度。"""
entities = await self.repository_factory(db).list_for_customer(
customer_id, memory_type=memory_type, tag=tag, limit=limit
)
return [self._to_dto(entity) for entity in entities], []
dtos = [self._to_dto(entity) for entity in entities]
if query is None or not str(query).strip():
return dtos, []
warnings: list[str] = []
try:
vector = self.embedder(str(query))
if isawaitable(vector):
vector = await vector
hits = await self.milvus_store.search(vector, customer_id, limit=max(limit, 1))
except Exception as exc:
return dtos, [f"semantic_recall_failed:{type(exc).__name__}"]
similarities: dict[int, float] = {}
for hit in hits:
raw_id = str(hit.get("memory_id", ""))
if raw_id.lstrip("-").isdigit():
similarities[int(raw_id)] = float(hit.get("distance") or 0.0)
matched: set[int] = set()
for dto in dtos:
if dto.id in similarities:
dto.semantic_similarity = similarities[dto.id]
matched.add(dto.id)
missing_ids = [mid for mid in similarities if mid not in matched]
if missing_ids:
try:
extra_entities = await self.repository_factory(db).list_for_customer_by_ids(
customer_id, missing_ids, memory_type=memory_type, tag=tag
)
for entity in extra_entities:
dto = self._to_dto(entity)
dto.semantic_similarity = similarities.get(dto.id)
dtos.append(dto)
except Exception as exc:
warnings.append(f"semantic_recall_fetch_failed:{type(exc).__name__}")
return dtos, warnings
async def refresh_confidence(
self, db, customer_id: int, *, limit: int = 500
+32 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import Any
from pymilvus import AsyncMilvusClient, DataType
from config.database.milvus import client as configured_client
@@ -87,15 +89,43 @@ class MilvusMemoryStore:
return memory_id
async def search(self, vector: list[float], customer_id: int, *, limit: int = 10) -> list[dict]:
"""按客户 ID 过滤向量查询结果。"""
"""按客户 ID 过滤向量查询,返回归一化的命中列表。"""
await self.ensure_collection()
return await self.client.search(
raw = await self.client.search(
collection_name=self.collection_name,
data=[vector],
limit=limit,
filter=f"customer_id == {int(customer_id)}",
output_fields=["memory_id", "customer_id", "memory_type", "tag", "content", "status"],
)
return self._normalize_hits(raw)
@staticmethod
def _normalize_hits(raw: Any) -> list[dict]:
"""将 pymilvus 返回结构收敛为 memory_id + distance 的扁平列表。"""
hits: list[dict] = []
for batch in raw or []:
for hit in batch or []:
if not isinstance(hit, dict):
continue
entity = hit.get("entity") or {}
memory_id = entity.get("memory_id") or hit.get("id")
if memory_id is None:
continue
try:
distance = float(hit.get("distance", 0.0))
except (TypeError, ValueError):
distance = 0.0
hits.append(
{
"memory_id": str(memory_id),
"distance": max(0.0, min(1.0, distance)),
"tag": entity.get("tag"),
"content": entity.get("content"),
"status": entity.get("status"),
}
)
return hits
async def delete(self, memory_id: int | str) -> None:
"""删除一条客户记忆向量。"""
+1
View File
@@ -79,6 +79,7 @@ class MemoryUnitDTO(BaseModel):
confidence_reason: str | None = Field(default=None, max_length=255)
confidence_update_time: datetime | None = None
final_score: float | None = Field(default=None, ge=0.0, le=1.0)
semantic_similarity: float | None = Field(default=None, ge=0.0, le=1.0)
evidence_count: int = Field(default=0, ge=0)
recall_count: int = Field(default=0, ge=0)
status: MemoryStatus = MemoryStatus.CANDIDATE