fix:记忆架构优化
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""删除一条客户记忆向量。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user