fischer-agentkit/src/agentkit/evolution/experience_store.py

513 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ExperienceStore - 任务经验存储
提供两种后端实现:
- ExperienceStore: 基于 PostgreSQL + pgvector 的语义检索存储
- InMemoryExperienceStore: 基于内存字典的轻量存储(用于测试)
存储任务执行经验(成功路径、失败原因、耗时分布),
支持按任务类型检索和语义搜索,追踪完成率/耗时/重试率趋势。
"""
from __future__ import annotations
import asyncio
import logging
import math
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.exc import DBAPIError
_SAFE_TABLE_NAME_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
from agentkit.evolution.experience_schema import EvolutionMetrics, TaskExperience
from agentkit.memory.embedder import Embedder
from agentkit.utils.vector_math import compute_cosine_similarity
logger = logging.getLogger(__name__)
class ExperienceStore:
"""任务经验存储 - PostgreSQL + pgvector 混合存储
基于 pgvector 向量索引 + tsvector 全文索引,
支持精确匹配 task_type + 语义相似度排序 + 时效性衰减。
检索策略:
1. pgvector ``<=>`` 算符进行最近邻检索
2. Python 侧 time_decay 重排
3. 混合评分alpha * cosine + (1 - alpha) * time_decay_score
当 pgvector_enabled=False 或 embedder 不可用时,
回退到客户端 O(N) cosine similarity。
"""
def __init__(
self,
session_factory: Any,
experience_model: Any,
embedder: Embedder | None = None,
decay_rate: float = 0.01,
alpha: float = 0.7,
retrieve_limit: int = 200,
pgvector_enabled: bool = True,
table_name: str = "task_experiences",
):
"""
Args:
session_factory: 返回 async context manager 的工厂
experience_model: TaskExperience ORM 模型类
embedder: 嵌入器,用于生成向量
decay_rate: 时间衰减率(越大衰减越快)
alpha: 混合评分权重alpha * cosine + (1-alpha) * time_decay
retrieve_limit: 客户端检索时的最大候选行数
pgvector_enabled: 是否使用 pgvector 原生 ``<=>`` 算符检索
table_name: pgvector 查询使用的表名
"""
self._session_factory = session_factory
self._experience_model = experience_model
self._embedder = embedder
self._decay_rate = decay_rate
self._alpha = alpha
self._retrieve_limit = retrieve_limit
self._pgvector_enabled = pgvector_enabled
self._table_name = table_name
if not _SAFE_TABLE_NAME_PATTERN.match(self._table_name):
raise ValueError(f"Invalid table_name: {self._table_name}. Must match [a-zA-Z_][a-zA-Z0-9_]*")
async def record_experience(self, experience: TaskExperience) -> str:
"""记录任务经验
如果 experience.embedding 为 None 且 embedder 可用,
自动生成 embedding。
Args:
experience: 任务经验数据
Returns:
经验 ID
"""
if not experience.experience_id:
experience.experience_id = str(uuid.uuid4())
# 自动生成 embedding
if experience.embedding is None and self._embedder is not None:
text = experience.text_for_embedding()
try:
experience.embedding = await self._embedder.embed(text)
except (ConnectionError, RuntimeError, asyncio.TimeoutError, ValueError) as e:
logger.warning(f"Failed to generate embedding for experience {experience.experience_id}: {e}")
async with self._session_factory() as db:
try:
Model = self._experience_model
entry = Model(
id=experience.experience_id,
task_type=experience.task_type,
goal=experience.goal,
steps_summary=experience.steps_summary,
outcome=experience.outcome,
duration_seconds=experience.duration_seconds,
success_rate=experience.success_rate,
failure_reasons=experience.failure_reasons,
optimization_tips=experience.optimization_tips,
embedding=experience.embedding,
created_at=experience.created_at,
)
db.add(entry)
await db.commit()
logger.info(
f"Experience recorded: {experience.experience_id} "
f"task_type={experience.task_type} outcome={experience.outcome}"
)
return experience.experience_id
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
await db.rollback()
logger.error(f"Failed to record experience: {e}")
raise
async def search(
self,
query: str,
top_k: int = 5,
task_type: str | None = None,
search_multiplier: int = 5,
) -> list[TaskExperience]:
"""语义检索相似经验
支持精确匹配 task_type + 语义相似度排序 + 时效性衰减。
Args:
query: 搜索查询文本
top_k: 返回的最大结果数
task_type: 可选的任务类型过滤
search_multiplier: 预取行数倍数
"""
async with self._session_factory() as db:
try:
if self._pgvector_enabled and self._embedder:
return await self._search_pgvector(db, query, top_k, task_type, search_multiplier)
return await self._search_client_side(db, query, top_k, task_type, search_multiplier)
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
logger.error(f"Failed to search experiences: {e}")
return []
async def _search_pgvector(
self,
db: Any,
query: str,
top_k: int,
task_type: str | None,
search_multiplier: int,
) -> list[TaskExperience]:
"""使用 pgvector ``<=>`` 算符检索,再 Python 侧 time_decay 重排"""
query_embedding = await self._embedder.embed(query)
fetch_limit = top_k * search_multiplier
where_clauses = []
params: dict[str, Any] = {"query_vec": str(query_embedding), "lim": fetch_limit}
if task_type:
where_clauses.append("task_type = :task_type")
params["task_type"] = task_type
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
sql = text(
f"SELECT *, embedding <=> :query_vec AS distance "
f"FROM {self._table_name}{where_sql} "
f"ORDER BY embedding <=> :query_vec "
f"LIMIT :lim"
)
result = await db.execute(sql, params)
rows = result.mappings().all()
if not rows:
return []
# Re-rank with time_decay in Python
items: list[tuple[float, TaskExperience]] = []
for row in rows:
row_embedding = row.get("embedding")
age_hours = (
(datetime.now(timezone.utc) - row["created_at"]).total_seconds() / 3600
if row.get("created_at")
else 0
)
decay = math.exp(-self._decay_rate * age_hours)
time_decay_score = (row.get("success_rate") or 0.5) * decay
if row_embedding is not None:
cosine_sim = compute_cosine_similarity(query_embedding, row_embedding)
score = self._alpha * cosine_sim + (1 - self._alpha) * time_decay_score
else:
score = time_decay_score
exp = TaskExperience(
experience_id=str(row.get("id", "")),
task_type=row.get("task_type", ""),
goal=row.get("goal", ""),
steps_summary=row.get("steps_summary", ""),
outcome=row.get("outcome", "success"),
duration_seconds=row.get("duration_seconds", 0.0),
success_rate=row.get("success_rate", 1.0),
failure_reasons=row.get("failure_reasons") or [],
optimization_tips=row.get("optimization_tips") or [],
embedding=row_embedding,
created_at=row.get("created_at") or datetime.now(timezone.utc),
)
items.append((score, exp))
items.sort(key=lambda x: x[0], reverse=True)
return [exp for _, exp in items[:top_k]]
async def _search_client_side(
self,
db: Any,
query: str,
top_k: int,
task_type: str | None,
search_multiplier: int,
) -> list[TaskExperience]:
"""客户端 O(N) cosine similarity 检索(回退路径)"""
Model = self._experience_model
from sqlalchemy import select
stmt = select(Model)
if task_type:
stmt = stmt.where(Model.task_type == task_type)
stmt = stmt.order_by(Model.created_at.desc()).limit(top_k * search_multiplier)
result = await db.execute(stmt)
entries = result.scalars().all()
query_embedding = None
if self._embedder and entries:
query_embedding = await self._embedder.embed(query)
items: list[tuple[float, TaskExperience]] = []
for entry in entries:
age_hours = (
(datetime.now(timezone.utc) - entry.created_at).total_seconds() / 3600
if entry.created_at
else 0
)
decay = math.exp(-self._decay_rate * age_hours)
time_decay_score = (entry.success_rate or 0.5) * decay
if self._embedder and query_embedding is not None and entry.embedding is not None:
cosine_sim = compute_cosine_similarity(query_embedding, entry.embedding)
score = self._alpha * cosine_sim + (1 - self._alpha) * time_decay_score
else:
score = time_decay_score
exp = TaskExperience(
experience_id=str(entry.id),
task_type=entry.task_type,
goal=entry.goal,
steps_summary=entry.steps_summary,
outcome=entry.outcome,
duration_seconds=entry.duration_seconds,
success_rate=entry.success_rate,
failure_reasons=entry.failure_reasons or [],
optimization_tips=entry.optimization_tips or [],
embedding=entry.embedding,
created_at=entry.created_at or datetime.now(timezone.utc),
)
items.append((score, exp))
items.sort(key=lambda x: x[0], reverse=True)
return [exp for _, exp in items[:top_k]]
async def get_metrics(
self,
task_type: str | None = None,
time_window: str = "24h",
) -> list[EvolutionMetrics]:
"""获取进化指标趋势
按任务类型和时间窗口聚合完成率、平均耗时和重试率。
Args:
task_type: 可选的任务类型过滤None 表示所有类型
time_window: 时间窗口("1h", "24h", "7d", "30d"
"""
window_delta = _parse_time_window(time_window)
window_start = datetime.now(timezone.utc) - window_delta
window_end = datetime.now(timezone.utc)
async with self._session_factory() as db:
try:
where_clauses = ["created_at >= :window_start"]
params: dict[str, Any] = {"window_start": window_start}
if task_type:
where_clauses.append("task_type = :task_type")
params["task_type"] = task_type
where_sql = " AND ".join(where_clauses)
# 按任务类型聚合
group_by = "task_type" if task_type is None else ""
select_clause = "task_type"
if task_type:
select_clause += f", '{task_type}' as filtered_task_type"
sql = text(
f"SELECT task_type, "
f" COUNT(*) as sample_count, "
f" AVG(CASE WHEN outcome = 'success' THEN 1.0 ELSE 0.0 END) as completion_rate, "
f" AVG(duration_seconds) as avg_duration, "
f" AVG(CASE WHEN success_rate < 1.0 THEN 1.0 ELSE 0.0 END) as retry_rate "
f"FROM {self._table_name} "
f"WHERE {where_sql} "
f"GROUP BY task_type"
)
result = await db.execute(sql, params)
rows = result.mappings().all()
metrics_list = []
for row in rows:
metrics_list.append(
EvolutionMetrics(
task_type=row["task_type"],
time_window=time_window,
completion_rate=row["completion_rate"] or 0.0,
avg_duration=row["avg_duration"] or 0.0,
retry_rate=row["retry_rate"] or 0.0,
sample_count=row["sample_count"] or 0,
window_start=window_start,
window_end=window_end,
)
)
return metrics_list
except (DBAPIError, ValueError, KeyError, RuntimeError) as e:
logger.error(f"Failed to get metrics: {e}")
return []
class InMemoryExperienceStore:
"""基于内存字典的任务经验存储(用于测试和轻量场景)
无需数据库,纯 dict-based 实现,支持与 ExperienceStore 相同的接口。
"""
def __init__(
self,
embedder: Embedder | None = None,
decay_rate: float = 0.01,
alpha: float = 0.7,
):
self._embedder = embedder
self._decay_rate = decay_rate
self._alpha = alpha
self._experiences: dict[str, TaskExperience] = {}
async def record_experience(self, experience: TaskExperience) -> str:
"""记录任务经验"""
if not experience.experience_id:
experience.experience_id = str(uuid.uuid4())
# 自动生成 embedding
if experience.embedding is None and self._embedder is not None:
text = experience.text_for_embedding()
try:
experience.embedding = await self._embedder.embed(text)
except (ConnectionError, RuntimeError, asyncio.TimeoutError, ValueError) as e:
logger.warning(f"Failed to generate embedding for experience {experience.experience_id}: {e}")
# 存储副本,避免外部修改影响内部状态
self._experiences[experience.experience_id] = TaskExperience(
experience_id=experience.experience_id,
task_type=experience.task_type,
goal=experience.goal,
steps_summary=experience.steps_summary,
outcome=experience.outcome,
duration_seconds=experience.duration_seconds,
success_rate=experience.success_rate,
failure_reasons=list(experience.failure_reasons),
optimization_tips=list(experience.optimization_tips),
embedding=experience.embedding,
created_at=experience.created_at,
)
logger.info(
f"Experience recorded: {experience.experience_id} "
f"task_type={experience.task_type} outcome={experience.outcome}"
)
return experience.experience_id
async def search(
self,
query: str,
top_k: int = 5,
task_type: str | None = None,
search_multiplier: int = 5,
) -> list[TaskExperience]:
"""语义检索相似经验"""
# 生成 query embedding
query_embedding = None
if self._embedder:
try:
query_embedding = await self._embedder.embed(query)
except (ConnectionError, RuntimeError, asyncio.TimeoutError, ValueError) as e:
logger.warning(f"Failed to generate query embedding: {e}")
# 筛选候选
candidates = list(self._experiences.values())
if task_type:
candidates = [e for e in candidates if e.task_type == task_type]
# 计算得分
items: list[tuple[float, TaskExperience]] = []
for exp in candidates:
age_hours = (
(datetime.now(timezone.utc) - exp.created_at).total_seconds() / 3600
if exp.created_at
else 0
)
decay = math.exp(-self._decay_rate * age_hours)
time_decay_score = exp.success_rate * decay
if query_embedding is not None and exp.embedding is not None:
cosine_sim = compute_cosine_similarity(query_embedding, exp.embedding)
score = self._alpha * cosine_sim + (1 - self._alpha) * time_decay_score
else:
score = time_decay_score
items.append((score, exp))
items.sort(key=lambda x: x[0], reverse=True)
return [exp for _, exp in items[:top_k]]
async def get_metrics(
self,
task_type: str | None = None,
time_window: str = "24h",
) -> list[EvolutionMetrics]:
"""获取进化指标趋势"""
window_delta = _parse_time_window(time_window)
window_start = datetime.now(timezone.utc) - window_delta
window_end = datetime.now(timezone.utc)
# 筛选时间窗口内的经验
candidates = [
e for e in self._experiences.values()
if e.created_at >= window_start
]
if task_type:
candidates = [e for e in candidates if e.task_type == task_type]
# 按 task_type 分组聚合
groups: dict[str, list[TaskExperience]] = {}
for exp in candidates:
groups.setdefault(exp.task_type, []).append(exp)
metrics_list = []
for tt, exps in groups.items():
n = len(exps)
if n == 0:
continue
completion_rate = sum(1 for e in exps if e.outcome == "success") / n
avg_duration = sum(e.duration_seconds for e in exps) / n
retry_rate = sum(1 for e in exps if e.success_rate < 1.0) / n
metrics_list.append(
EvolutionMetrics(
task_type=tt,
time_window=time_window,
completion_rate=completion_rate,
avg_duration=avg_duration,
retry_rate=retry_rate,
sample_count=n,
window_start=window_start,
window_end=window_end,
)
)
return metrics_list
# ── 辅助函数 ──────────────────────────────────────────────
def _parse_time_window(window: str) -> timedelta:
"""解析时间窗口字符串为 timedelta
支持格式: "1h", "24h", "7d", "30d"
"""
unit = window[-1].lower()
try:
value = int(window[:-1])
except ValueError:
return timedelta(hours=24)
if unit == "h":
return timedelta(hours=value)
elif unit == "d":
return timedelta(days=value)
else:
logger.warning(f"Unknown time window unit '{unit}', defaulting to 24h")
return timedelta(hours=24)